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