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