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