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