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.util.Locale;
019import java.util.Observable;
020import java.util.Observer;
021
022import javax.swing.AbstractAction;
023import javax.swing.JButton;
024import javax.swing.JComponent;
025import javax.swing.JDialog;
026import javax.swing.JMenuItem;
027import javax.swing.JOptionPane;
028import javax.swing.JPanel;
029import javax.swing.JScrollPane;
030import javax.swing.JSeparator;
031import javax.swing.JToolBar;
032import javax.swing.KeyStroke;
033import javax.swing.SwingUtilities;
034import javax.swing.event.HyperlinkEvent;
035import javax.swing.event.HyperlinkListener;
036import javax.swing.text.AttributeSet;
037import javax.swing.text.BadLocationException;
038import javax.swing.text.Document;
039import javax.swing.text.Element;
040import javax.swing.text.SimpleAttributeSet;
041import javax.swing.text.html.HTML.Tag;
042import javax.swing.text.html.HTMLDocument;
043import javax.swing.text.html.StyleSheet;
044
045import org.openstreetmap.josm.Main;
046import org.openstreetmap.josm.actions.JosmAction;
047import org.openstreetmap.josm.gui.HelpAwareOptionPane;
048import org.openstreetmap.josm.gui.MainMenu;
049import org.openstreetmap.josm.gui.widgets.JosmEditorPane;
050import org.openstreetmap.josm.gui.widgets.JosmHTMLEditorKit;
051import org.openstreetmap.josm.tools.ImageProvider;
052import org.openstreetmap.josm.tools.LanguageInfo.LocaleType;
053import org.openstreetmap.josm.tools.OpenBrowser;
054import org.openstreetmap.josm.tools.Utils;
055import org.openstreetmap.josm.tools.WindowGeometry;
056
057public class HelpBrowser extends JDialog {
058    /** the unique instance */
059    private static HelpBrowser instance;
060
061    /** the menu item in the windows menu. Required to properly
062     * hide on dialog close.
063     */
064    private JMenuItem windowMenuItem;
065
066    /**
067     * Replies the unique instance of the help browser
068     *
069     * @return the unique instance of the help browser
070     */
071    static public HelpBrowser getInstance() {
072        if (instance == null) {
073            instance = new HelpBrowser();
074        }
075        return instance;
076    }
077
078    /**
079     * Show the help page for help topic <code>helpTopic</code>.
080     *
081     * @param helpTopic the help topic
082     */
083    public static void setUrlForHelpTopic(final String helpTopic) {
084        final HelpBrowser browser = getInstance();
085        Runnable r = new Runnable() {
086            @Override
087            public void run() {
088                browser.openHelpTopic(helpTopic);
089                browser.setVisible(true);
090                browser.toFront();
091            }
092        };
093        SwingUtilities.invokeLater(r);
094    }
095
096    /**
097     * Launches the internal help browser and directs it to the help page for
098     * <code>helpTopic</code>.
099     *
100     * @param helpTopic the help topic
101     */
102    static public void launchBrowser(String helpTopic) {
103        HelpBrowser browser = getInstance();
104        browser.openHelpTopic(helpTopic);
105        browser.setVisible(true);
106        browser.toFront();
107    }
108
109    /** the help browser */
110    private JosmEditorPane help;
111
112    /** the help browser history */
113    private HelpBrowserHistory history;
114
115    /** the currently displayed URL */
116    private String url;
117
118    private HelpContentReader reader;
119
120    private static final JosmAction focusAction = new JosmAction(tr("JOSM Help Browser"), "help", "", null, false, false) {
121        @Override
122        public void actionPerformed(ActionEvent e) {
123            HelpBrowser.getInstance().setVisible(true);
124        }
125    };
126
127    /**
128     * Builds the style sheet used in the internal help browser
129     *
130     * @return the style sheet
131     */
132    protected StyleSheet buildStyleSheet() {
133        StyleSheet ss = new StyleSheet();
134        BufferedReader reader = new BufferedReader(
135                new InputStreamReader(
136                        getClass().getResourceAsStream("/data/help-browser.css")
137                )
138        );
139        StringBuffer css = new StringBuffer();
140        try {
141            String line = null;
142            while ((line = reader.readLine()) != null) {
143                css.append(line);
144                css.append("\n");
145            }
146        } catch(Exception e) {
147            Main.error(tr("Failed to read CSS file ''help-browser.css''. Exception is: {0}", e.toString()));
148            e.printStackTrace();
149            return ss;
150        } finally {
151            Utils.close(reader);
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 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(windowMenuItem != null && !visible) {
217            Main.main.menu.windowMenu.remove(windowMenuItem);
218            windowMenuItem = null;
219        }
220        if(windowMenuItem == null && visible) {
221            windowMenuItem = MainMenu.add(Main.main.menu.windowMenu, focusAction, MainMenu.WINDOW_MENU_GROUP.VOLATILE);
222        }
223        super.setVisible(visible);
224    }
225
226    /**
227     * Constructs a new {@code HelpBrowser}.
228     */
229    public HelpBrowser() {
230        reader = new HelpContentReader(HelpUtil.getWikiBaseUrl());
231        build();
232    }
233
234    protected void loadTopic(String content) {
235        Document document = help.getEditorKit().createDefaultDocument();
236        try {
237            help.getEditorKit().read(new StringReader(content), document, 0);
238        } catch (Exception e) {
239            e.printStackTrace();
240        }
241        help.setDocument(document);
242    }
243
244    /**
245     * Replies the current URL
246     *
247     * @return the current URL
248     */
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 = HelpUtil.getHelpTopicUrl(HelpUtil.buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.DEFAULTNOTENGLISH));
303        String content = null;
304        try {
305            content = reader.fetchHelpTopicContent(url, true);
306        } catch(MissingHelpContentException e) {
307            url = HelpUtil.getHelpTopicUrl(HelpUtil.buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.BASELANGUAGE));
308            try {
309                content = reader.fetchHelpTopicContent(url, true);
310            } catch(MissingHelpContentException e1) {
311                url = HelpUtil.getHelpTopicUrl(HelpUtil.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                    e2.printStackTrace();
320                    handleHelpContentReaderException(relativeHelpTopic, e2);
321                    return;
322                }
323            } catch(HelpContentReaderException e1) {
324                e1.printStackTrace();
325                handleHelpContentReaderException(relativeHelpTopic, e1);
326                return;
327            }
328        } catch(HelpContentReaderException e) {
329            e.printStackTrace();
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 = HelpUtil.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            e.printStackTrace();
355            handleHelpContentReaderException(absoluteHelpTopic, e);
356            return;
357        }
358        loadTopic(content);
359        history.setCurrentUrl(url);
360        this.url = url;
361    }
362
363    /**
364     * Opens an URL and displays the content.
365     *
366     *  If the URL is the locator of an absolute help topic, help content is loaded from
367     *  the JOSM wiki. Otherwise, the help browser loads the page from the given URL
368     *
369     * @param url the url
370     */
371    public void openUrl(String url) {
372        if (!isVisible()) {
373            setVisible(true);
374            toFront();
375        } else {
376            toFront();
377        }
378        String helpTopic = HelpUtil.extractAbsoluteHelpTopic(url);
379        if (helpTopic == null) {
380            try {
381                this.url = url;
382                String content = reader.fetchHelpTopicContent(url, false);
383                loadTopic(content);
384                history.setCurrentUrl(url);
385                this.url = url;
386            } catch(Exception e) {
387                HelpAwareOptionPane.showOptionDialog(
388                        Main.parent,
389                        tr(
390                                "<html>Failed to open help page for url {0}.<br>"
391                                + "This is most likely due to a network problem, please check<br>"
392                                + "your internet connection</html>",
393                                url
394                        ),
395                        tr("Failed to open URL"),
396                        JOptionPane.ERROR_MESSAGE,
397                        null, /* no icon */
398                        null, /* standard options, just OK button */
399                        null, /* default is standard */
400                        null /* no help context */
401                );
402            }
403            history.setCurrentUrl(url);
404        } else {
405            loadAbsoluteHelpTopic(helpTopic);
406        }
407    }
408
409    /**
410     * Loads and displays the help information for a help topic given
411     * by a relative help topic name, i.e. "/Action/New"
412     *
413     * @param relativeHelpTopic the relative help topic
414     */
415    public void openHelpTopic(String relativeHelpTopic) {
416        if (!isVisible()) {
417            setVisible(true);
418            toFront();
419        } else {
420            toFront();
421        }
422        loadRelativeHelpTopic(relativeHelpTopic);
423    }
424
425    class OpenInBrowserAction extends AbstractAction {
426        public OpenInBrowserAction() {
427            //putValue(NAME, tr("Open in Browser"));
428            putValue(SHORT_DESCRIPTION, tr("Open the current help page in an external browser"));
429            putValue(SMALL_ICON, ImageProvider.get("help", "internet"));
430        }
431
432        @Override
433        public void actionPerformed(ActionEvent e) {
434            OpenBrowser.displayUrl(getUrl());
435        }
436    }
437
438    class EditAction extends AbstractAction {
439        public EditAction() {
440            // putValue(NAME, tr("Edit"));
441            putValue(SHORT_DESCRIPTION, tr("Edit the current help page"));
442            putValue(SMALL_ICON,ImageProvider.get("dialogs", "edit"));
443        }
444
445        @Override
446        public void actionPerformed(ActionEvent e) {
447            String url = getUrl();
448            if(url == null)
449                return;
450            if (!url.startsWith(HelpUtil.getWikiBaseHelpUrl())) {
451                String message = tr(
452                        "<html>The current URL <tt>{0}</tt><br>"
453                        + "is an external URL. Editing is only possible for help topics<br>"
454                        + "on the help server <tt>{1}</tt>.</html>",
455                        getUrl(),
456                        HelpUtil.getWikiBaseUrl()
457                );
458                JOptionPane.showMessageDialog(
459                        Main.parent,
460                        message,
461                        tr("Warning"),
462                        JOptionPane.WARNING_MESSAGE
463                );
464                return;
465            }
466            url = url.replaceAll("#[^#]*$", "");
467            OpenBrowser.displayUrl(url+"?action=edit");
468        }
469    }
470
471    class ReloadAction extends AbstractAction {
472        public ReloadAction() {
473            putValue(SHORT_DESCRIPTION, tr("Reload the current help page"));
474            putValue(SMALL_ICON, ImageProvider.get("dialogs", "refresh"));
475        }
476
477        @Override
478        public void actionPerformed(ActionEvent e) {
479            openUrl(getUrl());
480        }
481    }
482
483    static class BackAction extends AbstractAction implements Observer {
484        private HelpBrowserHistory history;
485        public BackAction(HelpBrowserHistory history) {
486            this.history = history;
487            history.addObserver(this);
488            putValue(SHORT_DESCRIPTION, tr("Go to the previous page"));
489            putValue(SMALL_ICON, ImageProvider.get("help", "previous"));
490            setEnabled(history.canGoBack());
491        }
492
493        @Override
494        public void actionPerformed(ActionEvent e) {
495            history.back();
496        }
497        @Override
498        public void update(Observable o, Object arg) {
499            setEnabled(history.canGoBack());
500        }
501    }
502
503    static class ForwardAction extends AbstractAction implements Observer {
504        private HelpBrowserHistory history;
505        public ForwardAction(HelpBrowserHistory history) {
506            this.history = history;
507            history.addObserver(this);
508            putValue(SHORT_DESCRIPTION, tr("Go to the next page"));
509            putValue(SMALL_ICON, ImageProvider.get("help", "next"));
510            setEnabled(history.canGoForward());
511        }
512
513        @Override
514        public void actionPerformed(ActionEvent e) {
515            history.forward();
516        }
517        @Override
518        public void update(Observable o, Object arg) {
519            setEnabled(history.canGoForward());
520        }
521    }
522
523    class HomeAction extends AbstractAction  {
524        public HomeAction() {
525            putValue(SHORT_DESCRIPTION, tr("Go to the JOSM help home page"));
526            putValue(SMALL_ICON, ImageProvider.get("help", "home"));
527        }
528
529        @Override
530        public void actionPerformed(ActionEvent e) {
531            openHelpTopic("/");
532        }
533    }
534
535    class HyperlinkHandler implements HyperlinkListener {
536
537        /**
538         * Scrolls the help browser to the element with id <code>id</code>
539         *
540         * @param id the id
541         * @return true, if an element with this id was found and scrolling was successful; false, otherwise
542         */
543        protected boolean scrollToElementWithId(String id) {
544            Document d = help.getDocument();
545            if (d instanceof HTMLDocument) {
546                HTMLDocument doc = (HTMLDocument) d;
547                Element element = doc.getElement(id);
548                try {
549                    Rectangle r = help.modelToView(element.getStartOffset());
550                    if (r != null) {
551                        Rectangle vis = help.getVisibleRect();
552                        r.height = vis.height;
553                        help.scrollRectToVisible(r);
554                        return true;
555                    }
556                } catch (BadLocationException e) {
557                    Main.warn(tr("Bad location in HTML document. Exception was: {0}", e.toString()));
558                    e.printStackTrace();
559                }
560            }
561            return false;
562        }
563
564        /**
565         * Checks whether the hyperlink event originated on a &lt;a ...&gt; element with
566         * a relative href consisting of a URL fragment only, i.e.
567         * &lt;a href="#thisIsALocalFragment"&gt;. If so, replies the fragment, i.e.
568         * "thisIsALocalFragment".
569         *
570         * Otherwise, replies <code>null</code>
571         *
572         * @param e the hyperlink event
573         * @return the local fragment or <code>null</code>
574         */
575        protected String getUrlFragment(HyperlinkEvent e) {
576            AttributeSet set = e.getSourceElement().getAttributes();
577            Object value = set.getAttribute(Tag.A);
578            if (!(value instanceof SimpleAttributeSet)) return null;
579            SimpleAttributeSet atts = (SimpleAttributeSet)value;
580            value = atts.getAttribute(javax.swing.text.html.HTML.Attribute.HREF);
581            if (value == null) return null;
582            String s = (String)value;
583            if (s.matches("#.*"))
584                return s.substring(1);
585            return null;
586        }
587
588        @Override
589        public void hyperlinkUpdate(HyperlinkEvent e) {
590            if (e.getEventType() != HyperlinkEvent.EventType.ACTIVATED)
591                return;
592            if (e.getURL() == null || e.getURL().toString().startsWith(url+"#")) {
593                // Probably hyperlink event on a an A-element with a href consisting of
594                // a fragment only, i.e. "#ALocalFragment".
595                //
596                String fragment = getUrlFragment(e);
597                if (fragment != null) {
598                    // first try to scroll to an element with id==fragment. This is the way
599                    // table of contents are built in the JOSM wiki. If this fails, try to
600                    // scroll to a <A name="..."> element.
601                    //
602                    if (!scrollToElementWithId(fragment)) {
603                        help.scrollToReference(fragment);
604                    }
605                } else {
606                    HelpAwareOptionPane.showOptionDialog(
607                            Main.parent,
608                            tr("Failed to open help page. The target URL is empty."),
609                            tr("Failed to open help page"),
610                            JOptionPane.ERROR_MESSAGE,
611                            null, /* no icon */
612                            null, /* standard options, just OK button */
613                            null, /* default is standard */
614                            null /* no help context */
615                    );
616                }
617            } else if (e.getURL().toString().endsWith("action=edit")) {
618                OpenBrowser.displayUrl(e.getURL().toString());
619            } else {
620                url = e.getURL().toString();
621                openUrl(e.getURL().toString());
622            }
623        }
624    }
625}