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