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.event.ActionEvent;
012import java.awt.event.WindowAdapter;
013import java.awt.event.WindowEvent;
014import java.io.IOException;
015import java.io.StringReader;
016import java.nio.charset.StandardCharsets;
017import java.util.Locale;
018
019import javax.swing.AbstractAction;
020import javax.swing.JButton;
021import javax.swing.JFrame;
022import javax.swing.JMenuItem;
023import javax.swing.JOptionPane;
024import javax.swing.JPanel;
025import javax.swing.JScrollPane;
026import javax.swing.JSeparator;
027import javax.swing.JToolBar;
028import javax.swing.SwingUtilities;
029import javax.swing.event.ChangeEvent;
030import javax.swing.event.ChangeListener;
031import javax.swing.text.BadLocationException;
032import javax.swing.text.Document;
033import javax.swing.text.html.StyleSheet;
034
035import org.openstreetmap.josm.actions.JosmAction;
036import org.openstreetmap.josm.data.preferences.BooleanProperty;
037import org.openstreetmap.josm.gui.HelpAwareOptionPane;
038import org.openstreetmap.josm.gui.MainApplication;
039import org.openstreetmap.josm.gui.MainMenu;
040import org.openstreetmap.josm.gui.util.WindowGeometry;
041import org.openstreetmap.josm.gui.widgets.JosmEditorPane;
042import org.openstreetmap.josm.gui.widgets.JosmHTMLEditorKit;
043import org.openstreetmap.josm.io.CachedFile;
044import org.openstreetmap.josm.tools.ImageProvider;
045import org.openstreetmap.josm.tools.InputMapUtils;
046import org.openstreetmap.josm.tools.LanguageInfo.LocaleType;
047import org.openstreetmap.josm.tools.Logging;
048import org.openstreetmap.josm.tools.OpenBrowser;
049
050/**
051 * Help browser displaying HTML pages fetched from JOSM wiki.
052 */
053public class HelpBrowser extends JFrame implements IHelpBrowser {
054
055    private static final BooleanProperty USE_EXTERNAL_BROWSER = new BooleanProperty("help.use-external-browser", false);
056
057    /** the unique instance */
058    private static HelpBrowser instance;
059
060    /** the menu item in the windows menu. Required to properly hide on dialog close */
061    private JMenuItem windowMenuItem;
062
063    /** the help browser */
064    private JosmEditorPane help;
065
066    /** the help browser history */
067    private transient HelpBrowserHistory history;
068
069    /** the currently displayed URL */
070    private String url;
071
072    private final transient HelpContentReader reader;
073
074    private static final JosmAction FOCUS_ACTION = new JosmAction(tr("JOSM Help Browser"), "help", "", null, false, false) {
075        @Override
076        public void actionPerformed(ActionEvent e) {
077            HelpBrowser.getInstance().setVisible(true);
078        }
079    };
080
081    /**
082     * Constructs a new {@code HelpBrowser}.
083     */
084    public HelpBrowser() {
085        reader = new HelpContentReader(HelpUtil.getWikiBaseUrl());
086        build();
087    }
088
089    /**
090     * Replies the unique instance of the help browser
091     *
092     * @return the unique instance of the help browser
093     */
094    public static synchronized HelpBrowser getInstance() {
095        if (instance == null) {
096            instance = new HelpBrowser();
097        }
098        return instance;
099    }
100
101    /**
102     * Show the help page for help topic <code>helpTopic</code>.
103     *
104     * @param helpTopic the help topic
105     */
106    public static void setUrlForHelpTopic(final String helpTopic) {
107        final HelpBrowser browser = getInstance();
108        if (Boolean.TRUE.equals(USE_EXTERNAL_BROWSER.get())) {
109            SwingUtilities.invokeLater(() -> {
110                browser.loadRelativeHelpTopic(helpTopic);
111                OpenBrowser.displayUrl(browser.url);
112            });
113            return;
114        }
115        SwingUtilities.invokeLater(() -> {
116            browser.openHelpTopic(helpTopic);
117            browser.setVisible(true);
118            browser.toFront();
119        });
120    }
121
122    /**
123     * Builds the style sheet used in the internal help browser
124     *
125     * @return the style sheet
126     */
127    protected StyleSheet buildStyleSheet() {
128        StyleSheet ss = new StyleSheet();
129        final String css;
130        try (CachedFile cf = new CachedFile("resource://data/help-browser.css")) {
131            css = new String(cf.getByteContent(), StandardCharsets.ISO_8859_1);
132        } catch (IOException e) {
133            Logging.error(tr("Failed to read CSS file ''help-browser.css''. Exception is: {0}", e.toString()));
134            Logging.error(e);
135            return ss;
136        }
137        ss.addRule(css);
138
139        // overwrite link color
140        String linkColor = JosmEditorPane.getLinkColor();
141        if (linkColor != null) {
142            ss.addRule("a {color: " + linkColor + "}");
143        }
144        return ss;
145    }
146
147    /**
148     * Builds toolbar.
149     * @return the toolbar
150     */
151    protected JToolBar buildToolBar() {
152        JToolBar tb = new JToolBar();
153        tb.add(new JButton(new HomeAction(this)));
154        tb.add(new JButton(new BackAction(this)));
155        tb.add(new JButton(new ForwardAction(this)));
156        tb.add(new JButton(new ReloadAction(this)));
157        tb.add(new JSeparator());
158        tb.add(new JButton(new OpenInBrowserAction(this)));
159        tb.add(new JButton(new EditAction(this)));
160        return tb;
161    }
162
163    /**
164     * Builds GUI.
165     */
166    protected final void build() {
167        help = new JosmEditorPane();
168        JosmHTMLEditorKit kit = new JosmHTMLEditorKit();
169        kit.setStyleSheet(buildStyleSheet());
170        help.setEditorKit(kit);
171        help.setEditable(false);
172        help.addHyperlinkListener(new HyperlinkHandler(this, help));
173        help.setContentType("text/html");
174        history = new HelpBrowserHistory(this);
175
176        JPanel p = new JPanel(new BorderLayout());
177        setContentPane(p);
178
179        p.add(new JScrollPane(help), BorderLayout.CENTER);
180
181        addWindowListener(new WindowAdapter() {
182            @Override public void windowClosing(WindowEvent e) {
183                setVisible(false);
184            }
185        });
186
187        p.add(buildToolBar(), BorderLayout.NORTH);
188        InputMapUtils.addEscapeAction(getRootPane(), new AbstractAction() {
189            @Override
190            public void actionPerformed(ActionEvent e) {
191                setVisible(false);
192            }
193        });
194
195        setMinimumSize(new Dimension(400, 200));
196        setTitle(tr("JOSM Help Browser"));
197    }
198
199    @Override
200    public void setVisible(boolean visible) {
201        if (visible) {
202            new WindowGeometry(
203                    getClass().getName() + ".geometry",
204                    WindowGeometry.centerInWindow(
205                            getParent(),
206                            new Dimension(600, 400)
207                    )
208            ).applySafe(this);
209        } else if (isShowing()) { // Avoid IllegalComponentStateException like in #8775
210            new WindowGeometry(this).remember(getClass().getName() + ".geometry");
211        }
212        MainMenu menu = MainApplication.getMenu();
213        if (menu != null && menu.windowMenu != null) {
214            if (windowMenuItem != null && !visible) {
215                menu.windowMenu.remove(windowMenuItem);
216                windowMenuItem = null;
217            }
218            if (windowMenuItem == null && visible) {
219                windowMenuItem = MainMenu.add(menu.windowMenu, FOCUS_ACTION, MainMenu.WINDOW_MENU_GROUP.VOLATILE);
220            }
221        }
222        super.setVisible(visible);
223    }
224
225    /**
226     * Load help topic.
227     * @param content topic contents
228     */
229    protected void loadTopic(String content) {
230        Document document = help.getEditorKit().createDefaultDocument();
231        try {
232            help.getEditorKit().read(new StringReader(content), document, 0);
233        } catch (IOException | BadLocationException e) {
234            Logging.error(e);
235        }
236        help.setDocument(document);
237    }
238
239    @Override
240    public String getUrl() {
241        return url;
242    }
243
244    @Override
245    public void setUrl(String url) {
246        this.url = url;
247    }
248
249    /**
250     * Displays a warning page when a help topic doesn't exist yet.
251     *
252     * @param relativeHelpTopic the help topic
253     */
254    protected void handleMissingHelpContent(String relativeHelpTopic) {
255        // i18n: do not translate "warning-header" and "warning-body"
256        String message = tr("<html><p class=\"warning-header\">Help content for help topic missing</p>"
257                + "<p class=\"warning-body\">Help content for the help topic <strong>{0}</strong> is "
258                + "not available yet. It is missing both in your local language ({1}) and in English.<br><br>"
259                + "Please help to improve the JOSM help system and fill in the missing information. "
260                + "You can both edit the <a href=\"{2}\">help topic in your local language ({1})</a> and "
261                + "the <a href=\"{3}\">help topic in English</a>."
262                + "</p></html>",
263                relativeHelpTopic,
264                Locale.getDefault().getDisplayName(),
265                getHelpTopicEditUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.DEFAULT)),
266                getHelpTopicEditUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.ENGLISH))
267        );
268        loadTopic(message);
269    }
270
271    /**
272     * Displays a error page if a help topic couldn't be loaded because of network or IO error.
273     *
274     * @param relativeHelpTopic the help topic
275     * @param e the exception
276     */
277    protected void handleHelpContentReaderException(String relativeHelpTopic, HelpContentReaderException e) {
278        String message = tr("<html><p class=\"error-header\">Error when retrieving help information</p>"
279                + "<p class=\"error-body\">The content for the help topic <strong>{0}</strong> could "
280                + "not be loaded. The error message is (untranslated):<br>"
281                + "<tt>{1}</tt>"
282                + "</p></html>",
283                relativeHelpTopic,
284                e.toString()
285        );
286        loadTopic(message);
287    }
288
289    /**
290     * Loads a help topic given by a relative help topic name (i.e. "/Action/New")
291     *
292     * First tries to load the language specific help topic. If it is missing, tries to
293     * load the topic in English.
294     *
295     * @param relativeHelpTopic the relative help topic
296     */
297    protected void loadRelativeHelpTopic(String relativeHelpTopic) {
298        String url = getHelpTopicUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.DEFAULTNOTENGLISH));
299        String content = null;
300        try {
301            content = reader.fetchHelpTopicContent(url, true);
302        } catch (MissingHelpContentException e) {
303            Logging.trace(e);
304            url = getHelpTopicUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.BASELANGUAGE));
305            try {
306                content = reader.fetchHelpTopicContent(url, true);
307            } catch (MissingHelpContentException e1) {
308                Logging.trace(e1);
309                url = getHelpTopicUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.ENGLISH));
310                try {
311                    content = reader.fetchHelpTopicContent(url, true);
312                } catch (MissingHelpContentException e2) {
313                    Logging.debug(e2);
314                    this.url = url;
315                    handleMissingHelpContent(relativeHelpTopic);
316                    return;
317                } catch (HelpContentReaderException e2) {
318                    Logging.error(e2);
319                    handleHelpContentReaderException(relativeHelpTopic, e2);
320                    return;
321                }
322            } catch (HelpContentReaderException e1) {
323                Logging.error(e1);
324                handleHelpContentReaderException(relativeHelpTopic, e1);
325                return;
326            }
327        } catch (HelpContentReaderException e) {
328            Logging.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            Logging.debug(e);
350            this.url = url;
351            handleMissingHelpContent(absoluteHelpTopic);
352            return;
353        } catch (HelpContentReaderException e) {
354            Logging.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                Logging.warn(e);
381                HelpAwareOptionPane.showOptionDialog(
382                        MainApplication.getMainFrame(),
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            new ImageProvider("help", "internet").getResource().attachImageIcon(this, true);
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            new ImageProvider("dialogs", "edit").getResource().attachImageIcon(this, true);
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                JOptionPane.showMessageDialog(
466                        MainApplication.getMainFrame(),
467                        message,
468                        tr("Warning"),
469                        JOptionPane.WARNING_MESSAGE
470                );
471                return;
472            }
473            url = url.replaceAll("#[^#]*$", "");
474            OpenBrowser.displayUrl(url+"?action=edit");
475        }
476    }
477
478    static class ReloadAction extends AbstractBrowserAction {
479
480        /**
481         * Constructs a new {@code ReloadAction}.
482         * @param browser help browser
483         */
484        ReloadAction(IHelpBrowser browser) {
485            super(browser);
486            putValue(SHORT_DESCRIPTION, tr("Reload the current help page"));
487            new ImageProvider("dialogs", "refresh").getResource().attachImageIcon(this, true);
488        }
489
490        @Override
491        public void actionPerformed(ActionEvent e) {
492            browser.openUrl(browser.getUrl());
493        }
494    }
495
496    static class BackAction extends AbstractBrowserAction implements ChangeListener {
497
498        /**
499         * Constructs a new {@code BackAction}.
500         * @param browser help browser
501         */
502        BackAction(IHelpBrowser browser) {
503            super(browser);
504            browser.getHistory().addChangeListener(this);
505            putValue(SHORT_DESCRIPTION, tr("Go to the previous page"));
506            new ImageProvider("dialogs", "previous").getResource().attachImageIcon(this, true);
507            setEnabled(browser.getHistory().canGoBack());
508        }
509
510        @Override
511        public void actionPerformed(ActionEvent e) {
512            browser.getHistory().back();
513        }
514
515        @Override
516        public void stateChanged(ChangeEvent e) {
517            setEnabled(browser.getHistory().canGoBack());
518        }
519    }
520
521    static class ForwardAction extends AbstractBrowserAction implements ChangeListener {
522
523        /**
524         * Constructs a new {@code ForwardAction}.
525         * @param browser help browser
526         */
527        ForwardAction(IHelpBrowser browser) {
528            super(browser);
529            browser.getHistory().addChangeListener(this);
530            putValue(SHORT_DESCRIPTION, tr("Go to the next page"));
531            new ImageProvider("dialogs", "next").getResource().attachImageIcon(this, true);
532            setEnabled(browser.getHistory().canGoForward());
533        }
534
535        @Override
536        public void actionPerformed(ActionEvent e) {
537            browser.getHistory().forward();
538        }
539
540        @Override
541        public void stateChanged(ChangeEvent e) {
542            setEnabled(browser.getHistory().canGoForward());
543        }
544    }
545
546    static class HomeAction extends AbstractBrowserAction {
547
548        /**
549         * Constructs a new {@code HomeAction}.
550         * @param browser help browser
551         */
552        HomeAction(IHelpBrowser browser) {
553            super(browser);
554            putValue(SHORT_DESCRIPTION, tr("Go to the JOSM help home page"));
555            new ImageProvider("help", "home").getResource().attachImageIcon(this, true);
556        }
557
558        @Override
559        public void actionPerformed(ActionEvent e) {
560            browser.openHelpTopic("/");
561        }
562    }
563
564    @Override
565    public HelpBrowserHistory getHistory() {
566        return history;
567    }
568}