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.event.ActionEvent; 012import java.awt.event.WindowAdapter; 013import java.awt.event.WindowEvent; 014import java.io.IOException; 015import java.io.StringReader; 016import java.nio.charset.StandardCharsets; 017import java.util.Locale; 018 019import javax.swing.AbstractAction; 020import javax.swing.JButton; 021import javax.swing.JFrame; 022import javax.swing.JMenuItem; 023import javax.swing.JOptionPane; 024import javax.swing.JPanel; 025import javax.swing.JScrollPane; 026import javax.swing.JSeparator; 027import javax.swing.JToolBar; 028import javax.swing.SwingUtilities; 029import javax.swing.event.ChangeEvent; 030import javax.swing.event.ChangeListener; 031import javax.swing.text.BadLocationException; 032import javax.swing.text.Document; 033import javax.swing.text.html.StyleSheet; 034 035import org.openstreetmap.josm.actions.JosmAction; 036import org.openstreetmap.josm.data.preferences.BooleanProperty; 037import org.openstreetmap.josm.gui.HelpAwareOptionPane; 038import org.openstreetmap.josm.gui.MainApplication; 039import org.openstreetmap.josm.gui.MainMenu; 040import org.openstreetmap.josm.gui.util.WindowGeometry; 041import org.openstreetmap.josm.gui.widgets.JosmEditorPane; 042import org.openstreetmap.josm.gui.widgets.JosmHTMLEditorKit; 043import org.openstreetmap.josm.io.CachedFile; 044import org.openstreetmap.josm.tools.ImageProvider; 045import org.openstreetmap.josm.tools.InputMapUtils; 046import org.openstreetmap.josm.tools.LanguageInfo.LocaleType; 047import org.openstreetmap.josm.tools.Logging; 048import org.openstreetmap.josm.tools.OpenBrowser; 049 050/** 051 * Help browser displaying HTML pages fetched from JOSM wiki. 052 */ 053public class HelpBrowser extends JFrame implements IHelpBrowser { 054 055 private static final BooleanProperty USE_EXTERNAL_BROWSER = new BooleanProperty("help.use-external-browser", false); 056 057 /** the unique instance */ 058 private static HelpBrowser instance; 059 060 /** the menu item in the windows menu. Required to properly hide on dialog close */ 061 private JMenuItem windowMenuItem; 062 063 /** the help browser */ 064 private JosmEditorPane help; 065 066 /** the help browser history */ 067 private transient HelpBrowserHistory history; 068 069 /** the currently displayed URL */ 070 private String url; 071 072 private final transient HelpContentReader reader; 073 074 private static final JosmAction FOCUS_ACTION = new JosmAction(tr("JOSM Help Browser"), "help", "", null, false, false) { 075 @Override 076 public void actionPerformed(ActionEvent e) { 077 HelpBrowser.getInstance().setVisible(true); 078 } 079 }; 080 081 /** 082 * Constructs a new {@code HelpBrowser}. 083 */ 084 public HelpBrowser() { 085 reader = new HelpContentReader(HelpUtil.getWikiBaseUrl()); 086 build(); 087 } 088 089 /** 090 * Replies the unique instance of the help browser 091 * 092 * @return the unique instance of the help browser 093 */ 094 public static synchronized HelpBrowser getInstance() { 095 if (instance == null) { 096 instance = new HelpBrowser(); 097 } 098 return instance; 099 } 100 101 /** 102 * Show the help page for help topic <code>helpTopic</code>. 103 * 104 * @param helpTopic the help topic 105 */ 106 public static void setUrlForHelpTopic(final String helpTopic) { 107 final HelpBrowser browser = getInstance(); 108 if (Boolean.TRUE.equals(USE_EXTERNAL_BROWSER.get())) { 109 SwingUtilities.invokeLater(() -> { 110 browser.loadRelativeHelpTopic(helpTopic); 111 OpenBrowser.displayUrl(browser.url); 112 }); 113 return; 114 } 115 SwingUtilities.invokeLater(() -> { 116 browser.openHelpTopic(helpTopic); 117 browser.setVisible(true); 118 browser.toFront(); 119 }); 120 } 121 122 /** 123 * Builds the style sheet used in the internal help browser 124 * 125 * @return the style sheet 126 */ 127 protected StyleSheet buildStyleSheet() { 128 StyleSheet ss = new StyleSheet(); 129 final String css; 130 try (CachedFile cf = new CachedFile("resource://data/help-browser.css")) { 131 css = new String(cf.getByteContent(), StandardCharsets.ISO_8859_1); 132 } catch (IOException e) { 133 Logging.error(tr("Failed to read CSS file ''help-browser.css''. Exception is: {0}", e.toString())); 134 Logging.error(e); 135 return ss; 136 } 137 ss.addRule(css); 138 139 // overwrite link color 140 String linkColor = JosmEditorPane.getLinkColor(); 141 if (linkColor != null) { 142 ss.addRule("a {color: " + linkColor + "}"); 143 } 144 return ss; 145 } 146 147 /** 148 * Builds toolbar. 149 * @return the toolbar 150 */ 151 protected JToolBar buildToolBar() { 152 JToolBar tb = new JToolBar(); 153 tb.add(new JButton(new HomeAction(this))); 154 tb.add(new JButton(new BackAction(this))); 155 tb.add(new JButton(new ForwardAction(this))); 156 tb.add(new JButton(new ReloadAction(this))); 157 tb.add(new JSeparator()); 158 tb.add(new JButton(new OpenInBrowserAction(this))); 159 tb.add(new JButton(new EditAction(this))); 160 return tb; 161 } 162 163 /** 164 * Builds GUI. 165 */ 166 protected final void build() { 167 help = new JosmEditorPane(); 168 JosmHTMLEditorKit kit = new JosmHTMLEditorKit(); 169 kit.setStyleSheet(buildStyleSheet()); 170 help.setEditorKit(kit); 171 help.setEditable(false); 172 help.addHyperlinkListener(new HyperlinkHandler(this, help)); 173 help.setContentType("text/html"); 174 history = new HelpBrowserHistory(this); 175 176 JPanel p = new JPanel(new BorderLayout()); 177 setContentPane(p); 178 179 p.add(new JScrollPane(help), BorderLayout.CENTER); 180 181 addWindowListener(new WindowAdapter() { 182 @Override public void windowClosing(WindowEvent e) { 183 setVisible(false); 184 } 185 }); 186 187 p.add(buildToolBar(), BorderLayout.NORTH); 188 InputMapUtils.addEscapeAction(getRootPane(), new AbstractAction() { 189 @Override 190 public void actionPerformed(ActionEvent e) { 191 setVisible(false); 192 } 193 }); 194 195 setMinimumSize(new Dimension(400, 200)); 196 setTitle(tr("JOSM Help Browser")); 197 } 198 199 @Override 200 public void setVisible(boolean visible) { 201 if (visible) { 202 new WindowGeometry( 203 getClass().getName() + ".geometry", 204 WindowGeometry.centerInWindow( 205 getParent(), 206 new Dimension(600, 400) 207 ) 208 ).applySafe(this); 209 } else if (isShowing()) { // Avoid IllegalComponentStateException like in #8775 210 new WindowGeometry(this).remember(getClass().getName() + ".geometry"); 211 } 212 MainMenu menu = MainApplication.getMenu(); 213 if (menu != null && menu.windowMenu != null) { 214 if (windowMenuItem != null && !visible) { 215 menu.windowMenu.remove(windowMenuItem); 216 windowMenuItem = null; 217 } 218 if (windowMenuItem == null && visible) { 219 windowMenuItem = MainMenu.add(menu.windowMenu, FOCUS_ACTION, MainMenu.WINDOW_MENU_GROUP.VOLATILE); 220 } 221 } 222 super.setVisible(visible); 223 } 224 225 /** 226 * Load help topic. 227 * @param content topic contents 228 */ 229 protected void loadTopic(String content) { 230 Document document = help.getEditorKit().createDefaultDocument(); 231 try { 232 help.getEditorKit().read(new StringReader(content), document, 0); 233 } catch (IOException | BadLocationException e) { 234 Logging.error(e); 235 } 236 help.setDocument(document); 237 } 238 239 @Override 240 public String getUrl() { 241 return url; 242 } 243 244 @Override 245 public void setUrl(String url) { 246 this.url = url; 247 } 248 249 /** 250 * Displays a warning page when a help topic doesn't exist yet. 251 * 252 * @param relativeHelpTopic the help topic 253 */ 254 protected void handleMissingHelpContent(String relativeHelpTopic) { 255 // i18n: do not translate "warning-header" and "warning-body" 256 String message = tr("<html><p class=\"warning-header\">Help content for help topic missing</p>" 257 + "<p class=\"warning-body\">Help content for the help topic <strong>{0}</strong> is " 258 + "not available yet. It is missing both in your local language ({1}) and in English.<br><br>" 259 + "Please help to improve the JOSM help system and fill in the missing information. " 260 + "You can both edit the <a href=\"{2}\">help topic in your local language ({1})</a> and " 261 + "the <a href=\"{3}\">help topic in English</a>." 262 + "</p></html>", 263 relativeHelpTopic, 264 Locale.getDefault().getDisplayName(), 265 getHelpTopicEditUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.DEFAULT)), 266 getHelpTopicEditUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.ENGLISH)) 267 ); 268 loadTopic(message); 269 } 270 271 /** 272 * Displays a error page if a help topic couldn't be loaded because of network or IO error. 273 * 274 * @param relativeHelpTopic the help topic 275 * @param e the exception 276 */ 277 protected void handleHelpContentReaderException(String relativeHelpTopic, HelpContentReaderException e) { 278 String message = tr("<html><p class=\"error-header\">Error when retrieving help information</p>" 279 + "<p class=\"error-body\">The content for the help topic <strong>{0}</strong> could " 280 + "not be loaded. The error message is (untranslated):<br>" 281 + "<tt>{1}</tt>" 282 + "</p></html>", 283 relativeHelpTopic, 284 e.toString() 285 ); 286 loadTopic(message); 287 } 288 289 /** 290 * Loads a help topic given by a relative help topic name (i.e. "/Action/New") 291 * 292 * First tries to load the language specific help topic. If it is missing, tries to 293 * load the topic in English. 294 * 295 * @param relativeHelpTopic the relative help topic 296 */ 297 protected void loadRelativeHelpTopic(String relativeHelpTopic) { 298 String url = getHelpTopicUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.DEFAULTNOTENGLISH)); 299 String content = null; 300 try { 301 content = reader.fetchHelpTopicContent(url, true); 302 } catch (MissingHelpContentException e) { 303 Logging.trace(e); 304 url = getHelpTopicUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.BASELANGUAGE)); 305 try { 306 content = reader.fetchHelpTopicContent(url, true); 307 } catch (MissingHelpContentException e1) { 308 Logging.trace(e1); 309 url = getHelpTopicUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.ENGLISH)); 310 try { 311 content = reader.fetchHelpTopicContent(url, true); 312 } catch (MissingHelpContentException e2) { 313 Logging.debug(e2); 314 this.url = url; 315 handleMissingHelpContent(relativeHelpTopic); 316 return; 317 } catch (HelpContentReaderException e2) { 318 Logging.error(e2); 319 handleHelpContentReaderException(relativeHelpTopic, e2); 320 return; 321 } 322 } catch (HelpContentReaderException e1) { 323 Logging.error(e1); 324 handleHelpContentReaderException(relativeHelpTopic, e1); 325 return; 326 } 327 } catch (HelpContentReaderException e) { 328 Logging.error(e); 329 handleHelpContentReaderException(relativeHelpTopic, e); 330 return; 331 } 332 loadTopic(content); 333 history.setCurrentUrl(url); 334 this.url = url; 335 } 336 337 /** 338 * Loads a help topic given by an absolute help topic name, i.e. 339 * "/De:Help/Action/New" 340 * 341 * @param absoluteHelpTopic the absolute help topic name 342 */ 343 protected void loadAbsoluteHelpTopic(String absoluteHelpTopic) { 344 String url = getHelpTopicUrl(absoluteHelpTopic); 345 String content = null; 346 try { 347 content = reader.fetchHelpTopicContent(url, true); 348 } catch (MissingHelpContentException e) { 349 Logging.debug(e); 350 this.url = url; 351 handleMissingHelpContent(absoluteHelpTopic); 352 return; 353 } catch (HelpContentReaderException e) { 354 Logging.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 Logging.warn(e); 381 HelpAwareOptionPane.showOptionDialog( 382 MainApplication.getMainFrame(), 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 new ImageProvider("help", "internet").getResource().attachImageIcon(this, true); 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 new ImageProvider("dialogs", "edit").getResource().attachImageIcon(this, true); 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 JOptionPane.showMessageDialog( 466 MainApplication.getMainFrame(), 467 message, 468 tr("Warning"), 469 JOptionPane.WARNING_MESSAGE 470 ); 471 return; 472 } 473 url = url.replaceAll("#[^#]*$", ""); 474 OpenBrowser.displayUrl(url+"?action=edit"); 475 } 476 } 477 478 static class ReloadAction extends AbstractBrowserAction { 479 480 /** 481 * Constructs a new {@code ReloadAction}. 482 * @param browser help browser 483 */ 484 ReloadAction(IHelpBrowser browser) { 485 super(browser); 486 putValue(SHORT_DESCRIPTION, tr("Reload the current help page")); 487 new ImageProvider("dialogs", "refresh").getResource().attachImageIcon(this, true); 488 } 489 490 @Override 491 public void actionPerformed(ActionEvent e) { 492 browser.openUrl(browser.getUrl()); 493 } 494 } 495 496 static class BackAction extends AbstractBrowserAction implements ChangeListener { 497 498 /** 499 * Constructs a new {@code BackAction}. 500 * @param browser help browser 501 */ 502 BackAction(IHelpBrowser browser) { 503 super(browser); 504 browser.getHistory().addChangeListener(this); 505 putValue(SHORT_DESCRIPTION, tr("Go to the previous page")); 506 new ImageProvider("dialogs", "previous").getResource().attachImageIcon(this, true); 507 setEnabled(browser.getHistory().canGoBack()); 508 } 509 510 @Override 511 public void actionPerformed(ActionEvent e) { 512 browser.getHistory().back(); 513 } 514 515 @Override 516 public void stateChanged(ChangeEvent e) { 517 setEnabled(browser.getHistory().canGoBack()); 518 } 519 } 520 521 static class ForwardAction extends AbstractBrowserAction implements ChangeListener { 522 523 /** 524 * Constructs a new {@code ForwardAction}. 525 * @param browser help browser 526 */ 527 ForwardAction(IHelpBrowser browser) { 528 super(browser); 529 browser.getHistory().addChangeListener(this); 530 putValue(SHORT_DESCRIPTION, tr("Go to the next page")); 531 new ImageProvider("dialogs", "next").getResource().attachImageIcon(this, true); 532 setEnabled(browser.getHistory().canGoForward()); 533 } 534 535 @Override 536 public void actionPerformed(ActionEvent e) { 537 browser.getHistory().forward(); 538 } 539 540 @Override 541 public void stateChanged(ChangeEvent e) { 542 setEnabled(browser.getHistory().canGoForward()); 543 } 544 } 545 546 static class HomeAction extends AbstractBrowserAction { 547 548 /** 549 * Constructs a new {@code HomeAction}. 550 * @param browser help browser 551 */ 552 HomeAction(IHelpBrowser browser) { 553 super(browser); 554 putValue(SHORT_DESCRIPTION, tr("Go to the JOSM help home page")); 555 new ImageProvider("help", "home").getResource().attachImageIcon(this, true); 556 } 557 558 @Override 559 public void actionPerformed(ActionEvent e) { 560 browser.openHelpTopic("/"); 561 } 562 } 563 564 @Override 565 public HelpBrowserHistory getHistory() { 566 return history; 567 } 568}