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