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