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.IOException; 019import java.io.InputStreamReader; 020import java.io.StringReader; 021import java.nio.charset.StandardCharsets; 022import java.util.Locale; 023 024import javax.swing.AbstractAction; 025import javax.swing.JButton; 026import javax.swing.JComponent; 027import javax.swing.JDialog; 028import javax.swing.JMenuItem; 029import javax.swing.JOptionPane; 030import javax.swing.JPanel; 031import javax.swing.JScrollPane; 032import javax.swing.JSeparator; 033import javax.swing.JToolBar; 034import javax.swing.KeyStroke; 035import javax.swing.SwingUtilities; 036import javax.swing.event.ChangeEvent; 037import javax.swing.event.ChangeListener; 038import javax.swing.event.HyperlinkEvent; 039import javax.swing.event.HyperlinkListener; 040import javax.swing.text.AttributeSet; 041import javax.swing.text.BadLocationException; 042import javax.swing.text.Document; 043import javax.swing.text.Element; 044import javax.swing.text.SimpleAttributeSet; 045import javax.swing.text.html.HTML.Tag; 046import javax.swing.text.html.HTMLDocument; 047import javax.swing.text.html.StyleSheet; 048 049import org.openstreetmap.josm.Main; 050import org.openstreetmap.josm.actions.JosmAction; 051import org.openstreetmap.josm.gui.HelpAwareOptionPane; 052import org.openstreetmap.josm.gui.MainMenu; 053import org.openstreetmap.josm.gui.widgets.JosmEditorPane; 054import org.openstreetmap.josm.gui.widgets.JosmHTMLEditorKit; 055import org.openstreetmap.josm.tools.ImageProvider; 056import org.openstreetmap.josm.tools.LanguageInfo.LocaleType; 057import org.openstreetmap.josm.tools.OpenBrowser; 058import org.openstreetmap.josm.tools.WindowGeometry; 059 060/** 061 * Help browser displaying HTML pages fetched from JOSM wiki. 062 */ 063public class HelpBrowser extends JDialog implements IHelpBrowser { 064 065 /** the unique instance */ 066 private static HelpBrowser instance; 067 068 /** the menu item in the windows menu. Required to properly hide on dialog close */ 069 private JMenuItem windowMenuItem; 070 071 /** the help browser */ 072 private JosmEditorPane help; 073 074 /** the help browser history */ 075 private transient HelpBrowserHistory history; 076 077 /** the currently displayed URL */ 078 private String url; 079 080 private final transient HelpContentReader reader; 081 082 private static final JosmAction focusAction = new JosmAction(tr("JOSM Help Browser"), "help", "", null, false, false) { 083 @Override 084 public void actionPerformed(ActionEvent e) { 085 HelpBrowser.getInstance().setVisible(true); 086 } 087 }; 088 089 /** 090 * Constructs a new {@code HelpBrowser}. 091 */ 092 public HelpBrowser() { 093 reader = new HelpContentReader(HelpUtil.getWikiBaseUrl()); 094 build(); 095 } 096 097 /** 098 * Replies the unique instance of the help browser 099 * 100 * @return the unique instance of the help browser 101 */ 102 public static synchronized HelpBrowser getInstance() { 103 if (instance == null) { 104 instance = new HelpBrowser(); 105 } 106 return instance; 107 } 108 109 /** 110 * Show the help page for help topic <code>helpTopic</code>. 111 * 112 * @param helpTopic the help topic 113 */ 114 public static void setUrlForHelpTopic(final String helpTopic) { 115 final HelpBrowser browser = getInstance(); 116 Runnable r = new Runnable() { 117 @Override 118 public void run() { 119 browser.openHelpTopic(helpTopic); 120 browser.setVisible(true); 121 browser.toFront(); 122 } 123 }; 124 SwingUtilities.invokeLater(r); 125 } 126 127 /** 128 * Launches the internal help browser and directs it to the help page for 129 * <code>helpTopic</code>. 130 * 131 * @param helpTopic the help topic 132 */ 133 public static void launchBrowser(String helpTopic) { 134 HelpBrowser browser = getInstance(); 135 browser.openHelpTopic(helpTopic); 136 browser.setVisible(true); 137 browser.toFront(); 138 } 139 140 /** 141 * Builds the style sheet used in the internal help browser 142 * 143 * @return the style sheet 144 */ 145 protected StyleSheet buildStyleSheet() { 146 StyleSheet ss = new StyleSheet(); 147 StringBuilder css = new StringBuilder(); 148 try (BufferedReader breader = new BufferedReader( 149 new InputStreamReader( 150 getClass().getResourceAsStream("/data/help-browser.css"), StandardCharsets.UTF_8 151 ) 152 )) { 153 String line; 154 while ((line = breader.readLine()) != null) { 155 css.append(line); 156 css.append('\n'); 157 } 158 } catch (IOException e) { 159 Main.error(tr("Failed to read CSS file ''help-browser.css''. Exception is: {0}", e.toString())); 160 Main.error(e); 161 return ss; 162 } 163 ss.addRule(css.toString()); 164 return ss; 165 } 166 167 protected JToolBar buildToolBar() { 168 JToolBar tb = new JToolBar(); 169 tb.add(new JButton(new HomeAction(this))); 170 tb.add(new JButton(new BackAction(this))); 171 tb.add(new JButton(new ForwardAction(this))); 172 tb.add(new JButton(new ReloadAction(this))); 173 tb.add(new JSeparator()); 174 tb.add(new JButton(new OpenInBrowserAction(this))); 175 tb.add(new JButton(new EditAction(this))); 176 return tb; 177 } 178 179 protected final void build() { 180 help = new JosmEditorPane(); 181 JosmHTMLEditorKit kit = new JosmHTMLEditorKit(); 182 kit.setStyleSheet(buildStyleSheet()); 183 help.setEditorKit(kit); 184 help.setEditable(false); 185 help.addHyperlinkListener(new HyperlinkHandler()); 186 help.setContentType("text/html"); 187 history = new HelpBrowserHistory(this); 188 189 JPanel p = new JPanel(new BorderLayout()); 190 setContentPane(p); 191 192 p.add(new JScrollPane(help), BorderLayout.CENTER); 193 194 addWindowListener(new WindowAdapter() { 195 @Override public void windowClosing(WindowEvent e) { 196 setVisible(false); 197 } 198 }); 199 200 p.add(buildToolBar(), BorderLayout.NORTH); 201 help.getInputMap(JComponent.WHEN_IN_FOCUSED_WINDOW).put(KeyStroke.getKeyStroke(KeyEvent.VK_ESCAPE, 0), "Close"); 202 help.getActionMap().put("Close", new AbstractAction() { 203 @Override 204 public void actionPerformed(ActionEvent e) { 205 setVisible(false); 206 } 207 }); 208 209 setMinimumSize(new Dimension(400, 200)); 210 setTitle(tr("JOSM Help Browser")); 211 } 212 213 @Override 214 public void setVisible(boolean visible) { 215 if (visible) { 216 new WindowGeometry( 217 getClass().getName() + ".geometry", 218 WindowGeometry.centerInWindow( 219 getParent(), 220 new Dimension(600, 400) 221 ) 222 ).applySafe(this); 223 } else if (isShowing()) { // Avoid IllegalComponentStateException like in #8775 224 new WindowGeometry(this).remember(getClass().getName() + ".geometry"); 225 } 226 if (Main.main != null && Main.main.menu != null && Main.main.menu.windowMenu != null) { 227 if (windowMenuItem != null && !visible) { 228 Main.main.menu.windowMenu.remove(windowMenuItem); 229 windowMenuItem = null; 230 } 231 if (windowMenuItem == null && visible) { 232 windowMenuItem = MainMenu.add(Main.main.menu.windowMenu, focusAction, MainMenu.WINDOW_MENU_GROUP.VOLATILE); 233 } 234 } 235 super.setVisible(visible); 236 } 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 Main.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 url = getHelpTopicUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.BASELANGUAGE)); 308 try { 309 content = reader.fetchHelpTopicContent(url, true); 310 } catch (MissingHelpContentException e1) { 311 url = getHelpTopicUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.ENGLISH)); 312 try { 313 content = reader.fetchHelpTopicContent(url, true); 314 } catch (MissingHelpContentException e2) { 315 this.url = url; 316 handleMissingHelpContent(relativeHelpTopic); 317 return; 318 } catch (HelpContentReaderException e2) { 319 Main.error(e2); 320 handleHelpContentReaderException(relativeHelpTopic, e2); 321 return; 322 } 323 } catch (HelpContentReaderException e1) { 324 Main.error(e1); 325 handleHelpContentReaderException(relativeHelpTopic, e1); 326 return; 327 } 328 } catch (HelpContentReaderException e) { 329 Main.error(e); 330 handleHelpContentReaderException(relativeHelpTopic, e); 331 return; 332 } 333 loadTopic(content); 334 history.setCurrentUrl(url); 335 this.url = url; 336 } 337 338 /** 339 * Loads a help topic given by an absolute help topic name, i.e. 340 * "/De:Help/Action/New" 341 * 342 * @param absoluteHelpTopic the absolute help topic name 343 */ 344 protected void loadAbsoluteHelpTopic(String absoluteHelpTopic) { 345 String url = getHelpTopicUrl(absoluteHelpTopic); 346 String content = null; 347 try { 348 content = reader.fetchHelpTopicContent(url, true); 349 } catch (MissingHelpContentException e) { 350 this.url = url; 351 handleMissingHelpContent(absoluteHelpTopic); 352 return; 353 } catch (HelpContentReaderException e) { 354 Main.error(e); 355 handleHelpContentReaderException(absoluteHelpTopic, e); 356 return; 357 } 358 loadTopic(content); 359 history.setCurrentUrl(url); 360 this.url = url; 361 } 362 363 @Override 364 public void openUrl(String url) { 365 if (!isVisible()) { 366 setVisible(true); 367 toFront(); 368 } else { 369 toFront(); 370 } 371 String helpTopic = HelpUtil.extractAbsoluteHelpTopic(url); 372 if (helpTopic == null) { 373 try { 374 this.url = url; 375 String content = reader.fetchHelpTopicContent(url, false); 376 loadTopic(content); 377 history.setCurrentUrl(url); 378 this.url = url; 379 } catch (HelpContentReaderException e) { 380 Main.warn(e); 381 HelpAwareOptionPane.showOptionDialog( 382 Main.parent, 383 tr( 384 "<html>Failed to open help page for url {0}.<br>" 385 + "This is most likely due to a network problem, please check<br>" 386 + "your internet connection</html>", 387 url 388 ), 389 tr("Failed to open URL"), 390 JOptionPane.ERROR_MESSAGE, 391 null, /* no icon */ 392 null, /* standard options, just OK button */ 393 null, /* default is standard */ 394 null /* no help context */ 395 ); 396 } 397 history.setCurrentUrl(url); 398 } else { 399 loadAbsoluteHelpTopic(helpTopic); 400 } 401 } 402 403 @Override 404 public void openHelpTopic(String relativeHelpTopic) { 405 if (!isVisible()) { 406 setVisible(true); 407 toFront(); 408 } else { 409 toFront(); 410 } 411 loadRelativeHelpTopic(relativeHelpTopic); 412 } 413 414 abstract static class AbstractBrowserAction extends AbstractAction { 415 protected final transient IHelpBrowser browser; 416 417 protected AbstractBrowserAction(IHelpBrowser browser) { 418 this.browser = browser; 419 } 420 } 421 422 static class OpenInBrowserAction extends AbstractBrowserAction { 423 424 /** 425 * Constructs a new {@code OpenInBrowserAction}. 426 * @param browser help browser 427 */ 428 OpenInBrowserAction(IHelpBrowser browser) { 429 super(browser); 430 putValue(SHORT_DESCRIPTION, tr("Open the current help page in an external browser")); 431 putValue(SMALL_ICON, ImageProvider.get("help", "internet")); 432 } 433 434 @Override 435 public void actionPerformed(ActionEvent e) { 436 OpenBrowser.displayUrl(browser.getUrl()); 437 } 438 } 439 440 static class EditAction extends AbstractBrowserAction { 441 442 /** 443 * Constructs a new {@code EditAction}. 444 * @param browser help browser 445 */ 446 EditAction(IHelpBrowser browser) { 447 super(browser); 448 putValue(SHORT_DESCRIPTION, tr("Edit the current help page")); 449 putValue(SMALL_ICON, ImageProvider.get("dialogs", "edit")); 450 } 451 452 @Override 453 public void actionPerformed(ActionEvent e) { 454 String url = browser.getUrl(); 455 if (url == null) 456 return; 457 if (!url.startsWith(HelpUtil.getWikiBaseHelpUrl())) { 458 String message = tr( 459 "<html>The current URL <tt>{0}</tt><br>" 460 + "is an external URL. Editing is only possible for help topics<br>" 461 + "on the help server <tt>{1}</tt>.</html>", 462 url, 463 HelpUtil.getWikiBaseUrl() 464 ); 465 if (!GraphicsEnvironment.isHeadless()) { 466 JOptionPane.showMessageDialog( 467 Main.parent, 468 message, 469 tr("Warning"), 470 JOptionPane.WARNING_MESSAGE 471 ); 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 putValue(SMALL_ICON, ImageProvider.get("dialogs", "refresh")); 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 putValue(SMALL_ICON, ImageProvider.get("help", "previous")); 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 putValue(SMALL_ICON, ImageProvider.get("help", "next")); 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 putValue(SMALL_ICON, ImageProvider.get("help", "home")); 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 Rectangle r = help.modelToView(element.getStartOffset()); 581 if (r != null) { 582 Rectangle vis = help.getVisibleRect(); 583 r.height = vis.height; 584 help.scrollRectToVisible(r); 585 return true; 586 } 587 } catch (BadLocationException e) { 588 Main.warn(tr("Bad location in HTML document. Exception was: {0}", e.toString())); 589 Main.error(e); 590 } 591 } 592 return false; 593 } 594 595 /** 596 * Checks whether the hyperlink event originated on a <a ...> element with 597 * a relative href consisting of a URL fragment only, i.e. 598 * <a href="#thisIsALocalFragment">. If so, replies the fragment, i.e. "thisIsALocalFragment". 599 * 600 * Otherwise, replies <code>null</code> 601 * 602 * @param e the hyperlink event 603 * @return the local fragment or <code>null</code> 604 */ 605 protected String getUrlFragment(HyperlinkEvent e) { 606 AttributeSet set = e.getSourceElement().getAttributes(); 607 Object value = set.getAttribute(Tag.A); 608 if (!(value instanceof SimpleAttributeSet)) 609 return null; 610 SimpleAttributeSet atts = (SimpleAttributeSet) value; 611 value = atts.getAttribute(javax.swing.text.html.HTML.Attribute.HREF); 612 if (value == null) 613 return null; 614 String s = (String) value; 615 if (s.matches("#.*")) 616 return s.substring(1); 617 return null; 618 } 619 620 @Override 621 public void hyperlinkUpdate(HyperlinkEvent e) { 622 if (e.getEventType() != HyperlinkEvent.EventType.ACTIVATED) 623 return; 624 if (e.getURL() == null || e.getURL().toString().startsWith(url+'#')) { 625 // Probably hyperlink event on a an A-element with a href consisting of a fragment only, i.e. "#ALocalFragment". 626 String fragment = getUrlFragment(e); 627 if (fragment != null) { 628 // first try to scroll to an element with id==fragment. This is the way 629 // table of contents are built in the JOSM wiki. If this fails, try to 630 // scroll to a <A name="..."> element. 631 // 632 if (!scrollToElementWithId(fragment)) { 633 help.scrollToReference(fragment); 634 } 635 } else { 636 HelpAwareOptionPane.showOptionDialog( 637 Main.parent, 638 tr("Failed to open help page. The target URL is empty."), 639 tr("Failed to open help page"), 640 JOptionPane.ERROR_MESSAGE, 641 null, /* no icon */ 642 null, /* standard options, just OK button */ 643 null, /* default is standard */ 644 null /* no help context */ 645 ); 646 } 647 } else if (e.getURL().toString().endsWith("action=edit")) { 648 OpenBrowser.displayUrl(e.getURL().toString()); 649 } else { 650 url = e.getURL().toString(); 651 openUrl(e.getURL().toString()); 652 } 653 } 654 } 655 656 @Override 657 public HelpBrowserHistory getHistory() { 658 return history; 659 } 660}