// License: GPL. For details, see LICENSE file. package org.openstreetmap.josm.gui.help; import static org.openstreetmap.josm.gui.help.HelpUtil.buildAbsoluteHelpTopic; import static org.openstreetmap.josm.gui.help.HelpUtil.getHelpTopicEditUrl; import static org.openstreetmap.josm.gui.help.HelpUtil.getHelpTopicUrl; import static org.openstreetmap.josm.tools.I18n.tr; import java.awt.BorderLayout; import java.awt.Dimension; import java.awt.GraphicsEnvironment; import java.awt.Rectangle; import java.awt.event.ActionEvent; import java.awt.event.WindowAdapter; import java.awt.event.WindowEvent; import java.io.IOException; import java.io.StringReader; import java.nio.charset.StandardCharsets; import java.util.Locale; import javax.swing.AbstractAction; import javax.swing.JButton; import javax.swing.JDialog; import javax.swing.JMenuItem; import javax.swing.JOptionPane; import javax.swing.JPanel; import javax.swing.JScrollPane; import javax.swing.JSeparator; import javax.swing.JToolBar; import javax.swing.SwingUtilities; import javax.swing.event.ChangeEvent; import javax.swing.event.ChangeListener; import javax.swing.event.HyperlinkEvent; import javax.swing.event.HyperlinkListener; import javax.swing.text.AttributeSet; import javax.swing.text.BadLocationException; import javax.swing.text.Document; import javax.swing.text.Element; import javax.swing.text.SimpleAttributeSet; import javax.swing.text.html.HTML.Tag; import javax.swing.text.html.HTMLDocument; import javax.swing.text.html.StyleSheet; import org.openstreetmap.josm.Main; import org.openstreetmap.josm.actions.JosmAction; import org.openstreetmap.josm.gui.HelpAwareOptionPane; import org.openstreetmap.josm.gui.MainMenu; import org.openstreetmap.josm.gui.widgets.JosmEditorPane; import org.openstreetmap.josm.gui.widgets.JosmHTMLEditorKit; import org.openstreetmap.josm.io.CachedFile; import org.openstreetmap.josm.tools.ImageProvider; import org.openstreetmap.josm.tools.InputMapUtils; import org.openstreetmap.josm.tools.LanguageInfo.LocaleType; import org.openstreetmap.josm.tools.OpenBrowser; import org.openstreetmap.josm.tools.WindowGeometry; /** * Help browser displaying HTML pages fetched from JOSM wiki. */ public class HelpBrowser extends JDialog implements IHelpBrowser { /** the unique instance */ private static HelpBrowser instance; /** the menu item in the windows menu. Required to properly hide on dialog close */ private JMenuItem windowMenuItem; /** the help browser */ private JosmEditorPane help; /** the help browser history */ private transient HelpBrowserHistory history; /** the currently displayed URL */ private String url; private final transient HelpContentReader reader; private static final JosmAction focusAction = new JosmAction(tr("JOSM Help Browser"), "help", "", null, false, false) { @Override public void actionPerformed(ActionEvent e) { HelpBrowser.getInstance().setVisible(true); } }; /** * Constructs a new {@code HelpBrowser}. */ public HelpBrowser() { reader = new HelpContentReader(HelpUtil.getWikiBaseUrl()); build(); } /** * Replies the unique instance of the help browser * * @return the unique instance of the help browser */ public static synchronized HelpBrowser getInstance() { if (instance == null) { instance = new HelpBrowser(); } return instance; } /** * Show the help page for help topic <code>helpTopic</code>. * * @param helpTopic the help topic */ public static void setUrlForHelpTopic(final String helpTopic) { final HelpBrowser browser = getInstance(); SwingUtilities.invokeLater(() -> { browser.openHelpTopic(helpTopic); browser.setVisible(true); browser.toFront(); }); } /** * Launches the internal help browser and directs it to the help page for * <code>helpTopic</code>. * * @param helpTopic the help topic */ public static void launchBrowser(String helpTopic) { HelpBrowser browser = getInstance(); browser.openHelpTopic(helpTopic); browser.setVisible(true); browser.toFront(); } /** * Builds the style sheet used in the internal help browser * * @return the style sheet */ protected StyleSheet buildStyleSheet() { StyleSheet ss = new StyleSheet(); final String css; try (CachedFile cf = new CachedFile("resource://data/help-browser.css")) { css = new String(cf.getByteContent(), StandardCharsets.ISO_8859_1); } catch (IOException e) { Main.error(tr("Failed to read CSS file ''help-browser.css''. Exception is: {0}", e.toString())); Main.error(e); return ss; } ss.addRule(css); return ss; } protected JToolBar buildToolBar() { JToolBar tb = new JToolBar(); tb.add(new JButton(new HomeAction(this))); tb.add(new JButton(new BackAction(this))); tb.add(new JButton(new ForwardAction(this))); tb.add(new JButton(new ReloadAction(this))); tb.add(new JSeparator()); tb.add(new JButton(new OpenInBrowserAction(this))); tb.add(new JButton(new EditAction(this))); return tb; } protected final void build() { help = new JosmEditorPane(); JosmHTMLEditorKit kit = new JosmHTMLEditorKit(); kit.setStyleSheet(buildStyleSheet()); help.setEditorKit(kit); help.setEditable(false); help.addHyperlinkListener(new HyperlinkHandler()); help.setContentType("text/html"); history = new HelpBrowserHistory(this); JPanel p = new JPanel(new BorderLayout()); setContentPane(p); p.add(new JScrollPane(help), BorderLayout.CENTER); addWindowListener(new WindowAdapter() { @Override public void windowClosing(WindowEvent e) { setVisible(false); } }); p.add(buildToolBar(), BorderLayout.NORTH); InputMapUtils.addEscapeAction(getRootPane(), new AbstractAction() { @Override public void actionPerformed(ActionEvent e) { setVisible(false); } }); setMinimumSize(new Dimension(400, 200)); setTitle(tr("JOSM Help Browser")); } @Override public void setVisible(boolean visible) { if (visible) { new WindowGeometry( getClass().getName() + ".geometry", WindowGeometry.centerInWindow( getParent(), new Dimension(600, 400) ) ).applySafe(this); } else if (isShowing()) { // Avoid IllegalComponentStateException like in #8775 new WindowGeometry(this).remember(getClass().getName() + ".geometry"); } if (Main.main != null && Main.main.menu != null && Main.main.menu.windowMenu != null) { if (windowMenuItem != null && !visible) { Main.main.menu.windowMenu.remove(windowMenuItem); windowMenuItem = null; } if (windowMenuItem == null && visible) { windowMenuItem = MainMenu.add(Main.main.menu.windowMenu, focusAction, MainMenu.WINDOW_MENU_GROUP.VOLATILE); } } super.setVisible(visible); } protected void loadTopic(String content) { Document document = help.getEditorKit().createDefaultDocument(); try { help.getEditorKit().read(new StringReader(content), document, 0); } catch (IOException | BadLocationException e) { Main.error(e); } help.setDocument(document); } @Override public String getUrl() { return url; } /** * Displays a warning page when a help topic doesn't exist yet. * * @param relativeHelpTopic the help topic */ protected void handleMissingHelpContent(String relativeHelpTopic) { // i18n: do not translate "warning-header" and "warning-body" String message = tr("<html><p class=\"warning-header\">Help content for help topic missing</p>" + "<p class=\"warning-body\">Help content for the help topic <strong>{0}</strong> is " + "not available yet. It is missing both in your local language ({1}) and in English.<br><br>" + "Please help to improve the JOSM help system and fill in the missing information. " + "You can both edit the <a href=\"{2}\">help topic in your local language ({1})</a> and " + "the <a href=\"{3}\">help topic in English</a>." + "</p></html>", relativeHelpTopic, Locale.getDefault().getDisplayName(), getHelpTopicEditUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.DEFAULT)), getHelpTopicEditUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.ENGLISH)) ); loadTopic(message); } /** * Displays a error page if a help topic couldn't be loaded because of network or IO error. * * @param relativeHelpTopic the help topic * @param e the exception */ protected void handleHelpContentReaderException(String relativeHelpTopic, HelpContentReaderException e) { String message = tr("<html><p class=\"error-header\">Error when retrieving help information</p>" + "<p class=\"error-body\">The content for the help topic <strong>{0}</strong> could " + "not be loaded. The error message is (untranslated):<br>" + "<tt>{1}</tt>" + "</p></html>", relativeHelpTopic, e.toString() ); loadTopic(message); } /** * Loads a help topic given by a relative help topic name (i.e. "/Action/New") * * First tries to load the language specific help topic. If it is missing, tries to * load the topic in English. * * @param relativeHelpTopic the relative help topic */ protected void loadRelativeHelpTopic(String relativeHelpTopic) { String url = getHelpTopicUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.DEFAULTNOTENGLISH)); String content = null; try { content = reader.fetchHelpTopicContent(url, true); } catch (MissingHelpContentException e) { Main.trace(e); url = getHelpTopicUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.BASELANGUAGE)); try { content = reader.fetchHelpTopicContent(url, true); } catch (MissingHelpContentException e1) { Main.trace(e1); url = getHelpTopicUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.ENGLISH)); try { content = reader.fetchHelpTopicContent(url, true); } catch (MissingHelpContentException e2) { Main.debug(e2); this.url = url; handleMissingHelpContent(relativeHelpTopic); return; } catch (HelpContentReaderException e2) { Main.error(e2); handleHelpContentReaderException(relativeHelpTopic, e2); return; } } catch (HelpContentReaderException e1) { Main.error(e1); handleHelpContentReaderException(relativeHelpTopic, e1); return; } } catch (HelpContentReaderException e) { Main.error(e); handleHelpContentReaderException(relativeHelpTopic, e); return; } loadTopic(content); history.setCurrentUrl(url); this.url = url; } /** * Loads a help topic given by an absolute help topic name, i.e. * "/De:Help/Action/New" * * @param absoluteHelpTopic the absolute help topic name */ protected void loadAbsoluteHelpTopic(String absoluteHelpTopic) { String url = getHelpTopicUrl(absoluteHelpTopic); String content = null; try { content = reader.fetchHelpTopicContent(url, true); } catch (MissingHelpContentException e) { Main.debug(e); this.url = url; handleMissingHelpContent(absoluteHelpTopic); return; } catch (HelpContentReaderException e) { Main.error(e); handleHelpContentReaderException(absoluteHelpTopic, e); return; } loadTopic(content); history.setCurrentUrl(url); this.url = url; } @Override public void openUrl(String url) { if (!isVisible()) { setVisible(true); toFront(); } else { toFront(); } String helpTopic = HelpUtil.extractAbsoluteHelpTopic(url); if (helpTopic == null) { try { this.url = url; String content = reader.fetchHelpTopicContent(url, false); loadTopic(content); history.setCurrentUrl(url); this.url = url; } catch (HelpContentReaderException e) { Main.warn(e); HelpAwareOptionPane.showOptionDialog( Main.parent, tr( "<html>Failed to open help page for url {0}.<br>" + "This is most likely due to a network problem, please check<br>" + "your internet connection</html>", url ), tr("Failed to open URL"), JOptionPane.ERROR_MESSAGE, null, /* no icon */ null, /* standard options, just OK button */ null, /* default is standard */ null /* no help context */ ); } history.setCurrentUrl(url); } else { loadAbsoluteHelpTopic(helpTopic); } } @Override public void openHelpTopic(String relativeHelpTopic) { if (!isVisible()) { setVisible(true); toFront(); } else { toFront(); } loadRelativeHelpTopic(relativeHelpTopic); } abstract static class AbstractBrowserAction extends AbstractAction { protected final transient IHelpBrowser browser; protected AbstractBrowserAction(IHelpBrowser browser) { this.browser = browser; } } static class OpenInBrowserAction extends AbstractBrowserAction { /** * Constructs a new {@code OpenInBrowserAction}. * @param browser help browser */ OpenInBrowserAction(IHelpBrowser browser) { super(browser); putValue(SHORT_DESCRIPTION, tr("Open the current help page in an external browser")); putValue(SMALL_ICON, ImageProvider.get("help", "internet")); } @Override public void actionPerformed(ActionEvent e) { OpenBrowser.displayUrl(browser.getUrl()); } } static class EditAction extends AbstractBrowserAction { /** * Constructs a new {@code EditAction}. * @param browser help browser */ EditAction(IHelpBrowser browser) { super(browser); putValue(SHORT_DESCRIPTION, tr("Edit the current help page")); putValue(SMALL_ICON, ImageProvider.get("dialogs", "edit")); } @Override public void actionPerformed(ActionEvent e) { String url = browser.getUrl(); if (url == null) return; if (!url.startsWith(HelpUtil.getWikiBaseHelpUrl())) { String message = tr( "<html>The current URL <tt>{0}</tt><br>" + "is an external URL. Editing is only possible for help topics<br>" + "on the help server <tt>{1}</tt>.</html>", url, HelpUtil.getWikiBaseUrl() ); if (!GraphicsEnvironment.isHeadless()) { JOptionPane.showMessageDialog( Main.parent, message, tr("Warning"), JOptionPane.WARNING_MESSAGE ); } return; } url = url.replaceAll("#[^#]*$", ""); OpenBrowser.displayUrl(url+"?action=edit"); } } static class ReloadAction extends AbstractBrowserAction { /** * Constructs a new {@code ReloadAction}. * @param browser help browser */ ReloadAction(IHelpBrowser browser) { super(browser); putValue(SHORT_DESCRIPTION, tr("Reload the current help page")); putValue(SMALL_ICON, ImageProvider.get("dialogs", "refresh")); } @Override public void actionPerformed(ActionEvent e) { browser.openUrl(browser.getUrl()); } } static class BackAction extends AbstractBrowserAction implements ChangeListener { /** * Constructs a new {@code BackAction}. * @param browser help browser */ BackAction(IHelpBrowser browser) { super(browser); browser.getHistory().addChangeListener(this); putValue(SHORT_DESCRIPTION, tr("Go to the previous page")); putValue(SMALL_ICON, ImageProvider.get("help", "previous")); setEnabled(browser.getHistory().canGoBack()); } @Override public void actionPerformed(ActionEvent e) { browser.getHistory().back(); } @Override public void stateChanged(ChangeEvent e) { setEnabled(browser.getHistory().canGoBack()); } } static class ForwardAction extends AbstractBrowserAction implements ChangeListener { /** * Constructs a new {@code ForwardAction}. * @param browser help browser */ ForwardAction(IHelpBrowser browser) { super(browser); browser.getHistory().addChangeListener(this); putValue(SHORT_DESCRIPTION, tr("Go to the next page")); putValue(SMALL_ICON, ImageProvider.get("help", "next")); setEnabled(browser.getHistory().canGoForward()); } @Override public void actionPerformed(ActionEvent e) { browser.getHistory().forward(); } @Override public void stateChanged(ChangeEvent e) { setEnabled(browser.getHistory().canGoForward()); } } static class HomeAction extends AbstractBrowserAction { /** * Constructs a new {@code HomeAction}. * @param browser help browser */ HomeAction(IHelpBrowser browser) { super(browser); putValue(SHORT_DESCRIPTION, tr("Go to the JOSM help home page")); putValue(SMALL_ICON, ImageProvider.get("help", "home")); } @Override public void actionPerformed(ActionEvent e) { browser.openHelpTopic("/"); } } class HyperlinkHandler implements HyperlinkListener { /** * Scrolls the help browser to the element with id <code>id</code> * * @param id the id * @return true, if an element with this id was found and scrolling was successful; false, otherwise */ protected boolean scrollToElementWithId(String id) { Document d = help.getDocument(); if (d instanceof HTMLDocument) { HTMLDocument doc = (HTMLDocument) d; Element element = doc.getElement(id); try { Rectangle r = help.modelToView(element.getStartOffset()); if (r != null) { Rectangle vis = help.getVisibleRect(); r.height = vis.height; help.scrollRectToVisible(r); return true; } } catch (BadLocationException e) { Main.warn(tr("Bad location in HTML document. Exception was: {0}", e.toString())); Main.error(e); } } return false; } /** * Checks whether the hyperlink event originated on a <a ...> element with * a relative href consisting of a URL fragment only, i.e. * <a href="#thisIsALocalFragment">. If so, replies the fragment, i.e. "thisIsALocalFragment". * * Otherwise, replies <code>null</code> * * @param e the hyperlink event * @return the local fragment or <code>null</code> */ protected String getUrlFragment(HyperlinkEvent e) { AttributeSet set = e.getSourceElement().getAttributes(); Object value = set.getAttribute(Tag.A); if (!(value instanceof SimpleAttributeSet)) return null; SimpleAttributeSet atts = (SimpleAttributeSet) value; value = atts.getAttribute(javax.swing.text.html.HTML.Attribute.HREF); if (value == null) return null; String s = (String) value; if (s.matches("#.*")) return s.substring(1); return null; } @Override public void hyperlinkUpdate(HyperlinkEvent e) { if (e.getEventType() != HyperlinkEvent.EventType.ACTIVATED) return; if (e.getURL() == null || e.getURL().toExternalForm().startsWith(url+'#')) { // Probably hyperlink event on a an A-element with a href consisting of a fragment only, i.e. "#ALocalFragment". String fragment = getUrlFragment(e); if (fragment != null) { // first try to scroll to an element with id==fragment. This is the way // table of contents are built in the JOSM wiki. If this fails, try to // scroll to a <A name="..."> element. // if (!scrollToElementWithId(fragment)) { help.scrollToReference(fragment); } } else { HelpAwareOptionPane.showOptionDialog( Main.parent, tr("Failed to open help page. The target URL is empty."), tr("Failed to open help page"), JOptionPane.ERROR_MESSAGE, null, /* no icon */ null, /* standard options, just OK button */ null, /* default is standard */ null /* no help context */ ); } } else if (e.getURL().toExternalForm().endsWith("action=edit")) { OpenBrowser.displayUrl(e.getURL().toExternalForm()); } else { url = e.getURL().toExternalForm(); openUrl(url); } } } @Override public HelpBrowserHistory getHistory() { return history; } }