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