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