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