001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.help; 003 004import static org.openstreetmap.josm.gui.help.HelpUtil.buildAbsoluteHelpTopic; 005import static org.openstreetmap.josm.gui.help.HelpUtil.getHelpTopicEditUrl; 006import static org.openstreetmap.josm.gui.help.HelpUtil.getHelpTopicUrl; 007import static org.openstreetmap.josm.tools.I18n.tr; 008 009import java.awt.BorderLayout; 010import java.awt.Dimension; 011import java.awt.GraphicsEnvironment; 012import java.awt.Rectangle; 013import java.awt.event.ActionEvent; 014import java.awt.event.KeyEvent; 015import java.awt.event.WindowAdapter; 016import java.awt.event.WindowEvent; 017import java.io.BufferedReader; 018import java.io.InputStreamReader; 019import java.io.StringReader; 020import java.nio.charset.StandardCharsets; 021import java.util.Locale; 022import java.util.Observable; 023import java.util.Observer; 024 025import javax.swing.AbstractAction; 026import javax.swing.JButton; 027import javax.swing.JComponent; 028import javax.swing.JDialog; 029import javax.swing.JMenuItem; 030import javax.swing.JOptionPane; 031import javax.swing.JPanel; 032import javax.swing.JScrollPane; 033import javax.swing.JSeparator; 034import javax.swing.JToolBar; 035import javax.swing.KeyStroke; 036import javax.swing.SwingUtilities; 037import javax.swing.event.HyperlinkEvent; 038import javax.swing.event.HyperlinkListener; 039import javax.swing.text.AttributeSet; 040import javax.swing.text.BadLocationException; 041import javax.swing.text.Document; 042import javax.swing.text.Element; 043import javax.swing.text.SimpleAttributeSet; 044import javax.swing.text.html.HTML.Tag; 045import javax.swing.text.html.HTMLDocument; 046import javax.swing.text.html.StyleSheet; 047 048import org.openstreetmap.josm.Main; 049import org.openstreetmap.josm.actions.JosmAction; 050import org.openstreetmap.josm.gui.HelpAwareOptionPane; 051import org.openstreetmap.josm.gui.MainMenu; 052import org.openstreetmap.josm.gui.widgets.JosmEditorPane; 053import org.openstreetmap.josm.gui.widgets.JosmHTMLEditorKit; 054import org.openstreetmap.josm.tools.ImageProvider; 055import org.openstreetmap.josm.tools.LanguageInfo.LocaleType; 056import org.openstreetmap.josm.tools.OpenBrowser; 057import org.openstreetmap.josm.tools.WindowGeometry; 058 059/** 060 * Help browser displaying HTML pages fetched from JOSM wiki. 061 */ 062public class HelpBrowser extends JDialog implements IHelpBrowser { 063 064 /** the unique instance */ 065 private static HelpBrowser instance; 066 067 /** the menu item in the windows menu. Required to properly hide on dialog close */ 068 private JMenuItem windowMenuItem; 069 070 /** the help browser */ 071 private JosmEditorPane help; 072 073 /** the help browser history */ 074 private transient HelpBrowserHistory history; 075 076 /** the currently displayed URL */ 077 private String url; 078 079 private final transient HelpContentReader reader; 080 081 private static final JosmAction focusAction = new JosmAction(tr("JOSM Help Browser"), "help", "", null, false, false) { 082 @Override 083 public void actionPerformed(ActionEvent e) { 084 HelpBrowser.getInstance().setVisible(true); 085 } 086 }; 087 088 /** 089 * Constructs a new {@code HelpBrowser}. 090 */ 091 public HelpBrowser() { 092 reader = new HelpContentReader(HelpUtil.getWikiBaseUrl()); 093 build(); 094 } 095 096 /** 097 * Replies the unique instance of the help browser 098 * 099 * @return the unique instance of the help browser 100 */ 101 public static synchronized HelpBrowser getInstance() { 102 if (instance == null) { 103 instance = new HelpBrowser(); 104 } 105 return instance; 106 } 107 108 /** 109 * Show the help page for help topic <code>helpTopic</code>. 110 * 111 * @param helpTopic the help topic 112 */ 113 public static void setUrlForHelpTopic(final String helpTopic) { 114 final HelpBrowser browser = getInstance(); 115 Runnable r = new Runnable() { 116 @Override 117 public void run() { 118 browser.openHelpTopic(helpTopic); 119 browser.setVisible(true); 120 browser.toFront(); 121 } 122 }; 123 SwingUtilities.invokeLater(r); 124 } 125 126 /** 127 * Launches the internal help browser and directs it to the help page for 128 * <code>helpTopic</code>. 129 * 130 * @param helpTopic the help topic 131 */ 132 public static void launchBrowser(String helpTopic) { 133 HelpBrowser browser = getInstance(); 134 browser.openHelpTopic(helpTopic); 135 browser.setVisible(true); 136 browser.toFront(); 137 } 138 139 /** 140 * Builds the style sheet used in the internal help browser 141 * 142 * @return the style sheet 143 */ 144 protected StyleSheet buildStyleSheet() { 145 StyleSheet ss = new StyleSheet(); 146 StringBuilder css = new StringBuilder(); 147 try (BufferedReader breader = new BufferedReader( 148 new InputStreamReader( 149 getClass().getResourceAsStream("/data/help-browser.css"), StandardCharsets.UTF_8 150 ) 151 )) { 152 String line; 153 while ((line = breader.readLine()) != null) { 154 css.append(line); 155 css.append('\n'); 156 } 157 } catch (Exception e) { 158 Main.error(tr("Failed to read CSS file ''help-browser.css''. Exception is: {0}", e.toString())); 159 Main.error(e); 160 return ss; 161 } 162 ss.addRule(css.toString()); 163 return ss; 164 } 165 166 protected JToolBar buildToolBar() { 167 JToolBar tb = new JToolBar(); 168 tb.add(new JButton(new HomeAction(this))); 169 tb.add(new JButton(new BackAction(this))); 170 tb.add(new JButton(new ForwardAction(this))); 171 tb.add(new JButton(new ReloadAction(this))); 172 tb.add(new JSeparator()); 173 tb.add(new JButton(new OpenInBrowserAction(this))); 174 tb.add(new JButton(new EditAction(this))); 175 return tb; 176 } 177 178 protected final void build() { 179 help = new JosmEditorPane(); 180 JosmHTMLEditorKit kit = new JosmHTMLEditorKit(); 181 kit.setStyleSheet(buildStyleSheet()); 182 help.setEditorKit(kit); 183 help.setEditable(false); 184 help.addHyperlinkListener(new HyperlinkHandler()); 185 help.setContentType("text/html"); 186 history = new HelpBrowserHistory(this); 187 188 JPanel p = new JPanel(new BorderLayout()); 189 setContentPane(p); 190 191 p.add(new JScrollPane(help), BorderLayout.CENTER); 192 193 addWindowListener(new WindowAdapter() { 194 @Override public void windowClosing(WindowEvent e) { 195 setVisible(false); 196 } 197 }); 198 199 p.add(buildToolBar(), BorderLayout.NORTH); 200 help.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "Close"); 201 help.getActionMap().put("Close", new AbstractAction() { 202 @Override 203 public void actionPerformed(ActionEvent e) { 204 setVisible(false); 205 } 206 }); 207 208 setMinimumSize(new Dimension(400, 200)); 209 setTitle(tr("JOSM Help Browser")); 210 } 211 212 @Override 213 public void setVisible(boolean visible) { 214 if (visible) { 215 new WindowGeometry( 216 getClass().getName() + ".geometry", 217 WindowGeometry.centerInWindow( 218 getParent(), 219 new Dimension(600, 400) 220 ) 221 ).applySafe(this); 222 } else if (isShowing()) { // Avoid IllegalComponentStateException like in #8775 223 new WindowGeometry(this).remember(getClass().getName() + ".geometry"); 224 } 225 if (Main.main != null && Main.main.menu != null && Main.main.menu.windowMenu != null) { 226 if (windowMenuItem != null && !visible) { 227 Main.main.menu.windowMenu.remove(windowMenuItem); 228 windowMenuItem = null; 229 } 230 if (windowMenuItem == null && visible) { 231 windowMenuItem = MainMenu.add(Main.main.menu.windowMenu, focusAction, MainMenu.WINDOW_MENU_GROUP.VOLATILE); 232 } 233 } 234 super.setVisible(visible); 235 } 236 237 protected void loadTopic(String content) { 238 Document document = help.getEditorKit().createDefaultDocument(); 239 try { 240 help.getEditorKit().read(new StringReader(content), document, 0); 241 } catch (Exception e) { 242 Main.error(e); 243 } 244 help.setDocument(document); 245 } 246 247 @Override 248 public String getUrl() { 249 return url; 250 } 251 252 /** 253 * Displays a warning page when a help topic doesn't exist yet. 254 * 255 * @param relativeHelpTopic the help topic 256 */ 257 protected void handleMissingHelpContent(String relativeHelpTopic) { 258 // i18n: do not translate "warning-header" and "warning-body" 259 String message = tr("<html><p class=\"warning-header\">Help content for help topic missing</p>" 260 + "<p class=\"warning-body\">Help content for the help topic <strong>{0}</strong> is " 261 + "not available yet. It is missing both in your local language ({1}) and in English.<br><br>" 262 + "Please help to improve the JOSM help system and fill in the missing information. " 263 + "You can both edit the <a href=\"{2}\">help topic in your local language ({1})</a> and " 264 + "the <a href=\"{3}\">help topic in English</a>." 265 + "</p></html>", 266 relativeHelpTopic, 267 Locale.getDefault().getDisplayName(), 268 getHelpTopicEditUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.DEFAULT)), 269 getHelpTopicEditUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.ENGLISH)) 270 ); 271 loadTopic(message); 272 } 273 274 /** 275 * Displays a error page if a help topic couldn't be loaded because of network or IO error. 276 * 277 * @param relativeHelpTopic the help topic 278 * @param e the exception 279 */ 280 protected void handleHelpContentReaderException(String relativeHelpTopic, HelpContentReaderException e) { 281 String message = tr("<html><p class=\"error-header\">Error when retrieving help information</p>" 282 + "<p class=\"error-body\">The content for the help topic <strong>{0}</strong> could " 283 + "not be loaded. The error message is (untranslated):<br>" 284 + "<tt>{1}</tt>" 285 + "</p></html>", 286 relativeHelpTopic, 287 e.toString() 288 ); 289 loadTopic(message); 290 } 291 292 /** 293 * Loads a help topic given by a relative help topic name (i.e. "/Action/New") 294 * 295 * First tries to load the language specific help topic. If it is missing, tries to 296 * load the topic in English. 297 * 298 * @param relativeHelpTopic the relative help topic 299 */ 300 protected void loadRelativeHelpTopic(String relativeHelpTopic) { 301 String url = getHelpTopicUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.DEFAULTNOTENGLISH)); 302 String content = null; 303 try { 304 content = reader.fetchHelpTopicContent(url, true); 305 } catch (MissingHelpContentException e) { 306 url = getHelpTopicUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.BASELANGUAGE)); 307 try { 308 content = reader.fetchHelpTopicContent(url, true); 309 } catch (MissingHelpContentException e1) { 310 url = getHelpTopicUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.ENGLISH)); 311 try { 312 content = reader.fetchHelpTopicContent(url, true); 313 } catch (MissingHelpContentException e2) { 314 this.url = url; 315 handleMissingHelpContent(relativeHelpTopic); 316 return; 317 } catch (HelpContentReaderException e2) { 318 Main.error(e2); 319 handleHelpContentReaderException(relativeHelpTopic, e2); 320 return; 321 } 322 } catch (HelpContentReaderException e1) { 323 Main.error(e1); 324 handleHelpContentReaderException(relativeHelpTopic, e1); 325 return; 326 } 327 } catch (HelpContentReaderException e) { 328 Main.error(e); 329 handleHelpContentReaderException(relativeHelpTopic, e); 330 return; 331 } 332 loadTopic(content); 333 history.setCurrentUrl(url); 334 this.url = url; 335 } 336 337 /** 338 * Loads a help topic given by an absolute help topic name, i.e. 339 * "/De:Help/Action/New" 340 * 341 * @param absoluteHelpTopic the absolute help topic name 342 */ 343 protected void loadAbsoluteHelpTopic(String absoluteHelpTopic) { 344 String url = getHelpTopicUrl(absoluteHelpTopic); 345 String content = null; 346 try { 347 content = reader.fetchHelpTopicContent(url, true); 348 } catch (MissingHelpContentException e) { 349 this.url = url; 350 handleMissingHelpContent(absoluteHelpTopic); 351 return; 352 } catch (HelpContentReaderException e) { 353 Main.error(e); 354 handleHelpContentReaderException(absoluteHelpTopic, e); 355 return; 356 } 357 loadTopic(content); 358 history.setCurrentUrl(url); 359 this.url = url; 360 } 361 362 @Override 363 public void openUrl(String url) { 364 if (!isVisible()) { 365 setVisible(true); 366 toFront(); 367 } else { 368 toFront(); 369 } 370 String helpTopic = HelpUtil.extractAbsoluteHelpTopic(url); 371 if (helpTopic == null) { 372 try { 373 this.url = url; 374 String content = reader.fetchHelpTopicContent(url, false); 375 loadTopic(content); 376 history.setCurrentUrl(url); 377 this.url = url; 378 } catch (Exception e) { 379 Main.warn(e); 380 HelpAwareOptionPane.showOptionDialog( 381 Main.parent, 382 tr( 383 "<html>Failed to open help page for url {0}.<br>" 384 + "This is most likely due to a network problem, please check<br>" 385 + "your internet connection</html>", 386 url 387 ), 388 tr("Failed to open URL"), 389 JOptionPane.ERROR_MESSAGE, 390 null, /* no icon */ 391 null, /* standard options, just OK button */ 392 null, /* default is standard */ 393 null /* no help context */ 394 ); 395 } 396 history.setCurrentUrl(url); 397 } else { 398 loadAbsoluteHelpTopic(helpTopic); 399 } 400 } 401 402 @Override 403 public void openHelpTopic(String relativeHelpTopic) { 404 if (!isVisible()) { 405 setVisible(true); 406 toFront(); 407 } else { 408 toFront(); 409 } 410 loadRelativeHelpTopic(relativeHelpTopic); 411 } 412 413 abstract static class AbstractBrowserAction extends AbstractAction { 414 protected final transient IHelpBrowser browser; 415 416 protected AbstractBrowserAction(IHelpBrowser browser) { 417 this.browser = browser; 418 } 419 } 420 421 static class OpenInBrowserAction extends AbstractBrowserAction { 422 423 /** 424 * Constructs a new {@code OpenInBrowserAction}. 425 * @param browser help browser 426 */ 427 OpenInBrowserAction(IHelpBrowser browser) { 428 super(browser); 429 putValue(SHORT_DESCRIPTION, tr("Open the current help page in an external browser")); 430 putValue(SMALL_ICON, ImageProvider.get("help", "internet")); 431 } 432 433 @Override 434 public void actionPerformed(ActionEvent e) { 435 OpenBrowser.displayUrl(browser.getUrl()); 436 } 437 } 438 439 static class EditAction extends AbstractBrowserAction { 440 441 /** 442 * Constructs a new {@code EditAction}. 443 * @param browser help browser 444 */ 445 EditAction(IHelpBrowser browser) { 446 super(browser); 447 putValue(SHORT_DESCRIPTION, tr("Edit the current help page")); 448 putValue(SMALL_ICON, ImageProvider.get("dialogs", "edit")); 449 } 450 451 @Override 452 public void actionPerformed(ActionEvent e) { 453 String url = browser.getUrl(); 454 if (url == null) 455 return; 456 if (!url.startsWith(HelpUtil.getWikiBaseHelpUrl())) { 457 String message = tr( 458 "<html>The current URL <tt>{0}</tt><br>" 459 + "is an external URL. Editing is only possible for help topics<br>" 460 + "on the help server <tt>{1}</tt>.</html>", 461 url, 462 HelpUtil.getWikiBaseUrl() 463 ); 464 if (!GraphicsEnvironment.isHeadless()) { 465 JOptionPane.showMessageDialog( 466 Main.parent, 467 message, 468 tr("Warning"), 469 JOptionPane.WARNING_MESSAGE 470 ); 471 } 472 return; 473 } 474 url = url.replaceAll("#[^#]*$", ""); 475 OpenBrowser.displayUrl(url+"?action=edit"); 476 } 477 } 478 479 static class ReloadAction extends AbstractBrowserAction { 480 481 /** 482 * Constructs a new {@code ReloadAction}. 483 * @param browser help browser 484 */ 485 ReloadAction(IHelpBrowser browser) { 486 super(browser); 487 putValue(SHORT_DESCRIPTION, tr("Reload the current help page")); 488 putValue(SMALL_ICON, ImageProvider.get("dialogs", "refresh")); 489 } 490 491 @Override 492 public void actionPerformed(ActionEvent e) { 493 browser.openUrl(browser.getUrl()); 494 } 495 } 496 497 static class BackAction extends AbstractBrowserAction implements Observer { 498 499 /** 500 * Constructs a new {@code BackAction}. 501 * @param browser help browser 502 */ 503 BackAction(IHelpBrowser browser) { 504 super(browser); 505 browser.getHistory().addObserver(this); 506 putValue(SHORT_DESCRIPTION, tr("Go to the previous page")); 507 putValue(SMALL_ICON, ImageProvider.get("help", "previous")); 508 setEnabled(browser.getHistory().canGoBack()); 509 } 510 511 @Override 512 public void actionPerformed(ActionEvent e) { 513 browser.getHistory().back(); 514 } 515 516 @Override 517 public void update(Observable o, Object arg) { 518 setEnabled(browser.getHistory().canGoBack()); 519 } 520 } 521 522 static class ForwardAction extends AbstractBrowserAction implements Observer { 523 524 /** 525 * Constructs a new {@code ForwardAction}. 526 * @param browser help browser 527 */ 528 ForwardAction(IHelpBrowser browser) { 529 super(browser); 530 browser.getHistory().addObserver(this); 531 putValue(SHORT_DESCRIPTION, tr("Go to the next page")); 532 putValue(SMALL_ICON, ImageProvider.get("help", "next")); 533 setEnabled(browser.getHistory().canGoForward()); 534 } 535 536 @Override 537 public void actionPerformed(ActionEvent e) { 538 browser.getHistory().forward(); 539 } 540 541 @Override 542 public void update(Observable o, Object arg) { 543 setEnabled(browser.getHistory().canGoForward()); 544 } 545 } 546 547 static class HomeAction extends AbstractBrowserAction { 548 549 /** 550 * Constructs a new {@code HomeAction}. 551 * @param browser help browser 552 */ 553 HomeAction(IHelpBrowser browser) { 554 super(browser); 555 putValue(SHORT_DESCRIPTION, tr("Go to the JOSM help home page")); 556 putValue(SMALL_ICON, ImageProvider.get("help", "home")); 557 } 558 559 @Override 560 public void actionPerformed(ActionEvent e) { 561 browser.openHelpTopic("/"); 562 } 563 } 564 565 class HyperlinkHandler implements HyperlinkListener { 566 567 /** 568 * Scrolls the help browser to the element with id <code>id</code> 569 * 570 * @param id the id 571 * @return true, if an element with this id was found and scrolling was successful; false, otherwise 572 */ 573 protected boolean scrollToElementWithId(String id) { 574 Document d = help.getDocument(); 575 if (d instanceof HTMLDocument) { 576 HTMLDocument doc = (HTMLDocument) d; 577 Element element = doc.getElement(id); 578 try { 579 Rectangle r = help.modelToView(element.getStartOffset()); 580 if (r != null) { 581 Rectangle vis = help.getVisibleRect(); 582 r.height = vis.height; 583 help.scrollRectToVisible(r); 584 return true; 585 } 586 } catch (BadLocationException e) { 587 Main.warn(tr("Bad location in HTML document. Exception was: {0}", e.toString())); 588 Main.error(e); 589 } 590 } 591 return false; 592 } 593 594 /** 595 * Checks whether the hyperlink event originated on a <a ...> element with 596 * a relative href consisting of a URL fragment only, i.e. 597 * <a href="#thisIsALocalFragment">. If so, replies the fragment, i.e. "thisIsALocalFragment". 598 * 599 * Otherwise, replies <code>null</code> 600 * 601 * @param e the hyperlink event 602 * @return the local fragment or <code>null</code> 603 */ 604 protected String getUrlFragment(HyperlinkEvent e) { 605 AttributeSet set = e.getSourceElement().getAttributes(); 606 Object value = set.getAttribute(Tag.A); 607 if (!(value instanceof SimpleAttributeSet)) 608 return null; 609 SimpleAttributeSet atts = (SimpleAttributeSet) value; 610 value = atts.getAttribute(javax.swing.text.html.HTML.Attribute.HREF); 611 if (value == null) 612 return null; 613 String s = (String) value; 614 if (s.matches("#.*")) 615 return s.substring(1); 616 return null; 617 } 618 619 @Override 620 public void hyperlinkUpdate(HyperlinkEvent e) { 621 if (e.getEventType() != HyperlinkEvent.EventType.ACTIVATED) 622 return; 623 if (e.getURL() == null || e.getURL().toString().startsWith(url+'#')) { 624 // Probably hyperlink event on a an A-element with a href consisting of a fragment only, i.e. "#ALocalFragment". 625 String fragment = getUrlFragment(e); 626 if (fragment != null) { 627 // first try to scroll to an element with id==fragment. This is the way 628 // table of contents are built in the JOSM wiki. If this fails, try to 629 // scroll to a <A name="..."> element. 630 // 631 if (!scrollToElementWithId(fragment)) { 632 help.scrollToReference(fragment); 633 } 634 } else { 635 HelpAwareOptionPane.showOptionDialog( 636 Main.parent, 637 tr("Failed to open help page. The target URL is empty."), 638 tr("Failed to open help page"), 639 JOptionPane.ERROR_MESSAGE, 640 null, /* no icon */ 641 null, /* standard options, just OK button */ 642 null, /* default is standard */ 643 null /* no help context */ 644 ); 645 } 646 } else if (e.getURL().toString().endsWith("action=edit")) { 647 OpenBrowser.displayUrl(e.getURL().toString()); 648 } else { 649 url = e.getURL().toString(); 650 openUrl(e.getURL().toString()); 651 } 652 } 653 } 654 655 @Override 656 public HelpBrowserHistory getHistory() { 657 return history; 658 } 659}