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.gui.HelpAwareOptionPane;
037import org.openstreetmap.josm.gui.MainApplication;
038import org.openstreetmap.josm.gui.MainMenu;
039import org.openstreetmap.josm.gui.util.WindowGeometry;
040import org.openstreetmap.josm.gui.widgets.JosmEditorPane;
041import org.openstreetmap.josm.gui.widgets.JosmHTMLEditorKit;
042import org.openstreetmap.josm.io.CachedFile;
043import org.openstreetmap.josm.tools.ImageProvider;
044import org.openstreetmap.josm.tools.InputMapUtils;
045import org.openstreetmap.josm.tools.LanguageInfo.LocaleType;
046import org.openstreetmap.josm.tools.Logging;
047import org.openstreetmap.josm.tools.OpenBrowser;
048
049/**
050 * Help browser displaying HTML pages fetched from JOSM wiki.
051 */
052public class HelpBrowser extends JFrame implements IHelpBrowser {
053
054    /** the unique instance */
055    private static HelpBrowser instance;
056
057    /** the menu item in the windows menu. Required to properly hide on dialog close */
058    private JMenuItem windowMenuItem;
059
060    /** the help browser */
061    private JosmEditorPane help;
062
063    /** the help browser history */
064    private transient HelpBrowserHistory history;
065
066    /** the currently displayed URL */
067    private String url;
068
069    private final transient HelpContentReader reader;
070
071    private static final JosmAction FOCUS_ACTION = new JosmAction(tr("JOSM Help Browser"), "help", "", null, false, false) {
072        @Override
073        public void actionPerformed(ActionEvent e) {
074            HelpBrowser.getInstance().setVisible(true);
075        }
076    };
077
078    /**
079     * Constructs a new {@code HelpBrowser}.
080     */
081    public HelpBrowser() {
082        reader = new HelpContentReader(HelpUtil.getWikiBaseUrl());
083        build();
084    }
085
086    /**
087     * Replies the unique instance of the help browser
088     *
089     * @return the unique instance of the help browser
090     */
091    public static synchronized HelpBrowser getInstance() {
092        if (instance == null) {
093            instance = new HelpBrowser();
094        }
095        return instance;
096    }
097
098    /**
099     * Show the help page for help topic <code>helpTopic</code>.
100     *
101     * @param helpTopic the help topic
102     */
103    public static void setUrlForHelpTopic(final String helpTopic) {
104        final HelpBrowser browser = getInstance();
105        SwingUtilities.invokeLater(() -> {
106            browser.openHelpTopic(helpTopic);
107            browser.setVisible(true);
108            browser.toFront();
109        });
110    }
111
112    /**
113     * Launches the internal help browser and directs it to the help page for
114     * <code>helpTopic</code>.
115     *
116     * @param helpTopic the help topic
117     */
118    public static void launchBrowser(String helpTopic) {
119        HelpBrowser browser = getInstance();
120        browser.openHelpTopic(helpTopic);
121        browser.setVisible(true);
122        browser.toFront();
123    }
124
125    /**
126     * Builds the style sheet used in the internal help browser
127     *
128     * @return the style sheet
129     */
130    protected StyleSheet buildStyleSheet() {
131        StyleSheet ss = new StyleSheet();
132        final String css;
133        try (CachedFile cf = new CachedFile("resource://data/help-browser.css")) {
134            css = new String(cf.getByteContent(), StandardCharsets.ISO_8859_1);
135        } catch (IOException e) {
136            Logging.error(tr("Failed to read CSS file ''help-browser.css''. Exception is: {0}", e.toString()));
137            Logging.error(e);
138            return ss;
139        }
140        ss.addRule(css);
141        return ss;
142    }
143
144    /**
145     * Builds toolbar.
146     * @return the toolbar
147     */
148    protected JToolBar buildToolBar() {
149        JToolBar tb = new JToolBar();
150        tb.add(new JButton(new HomeAction(this)));
151        tb.add(new JButton(new BackAction(this)));
152        tb.add(new JButton(new ForwardAction(this)));
153        tb.add(new JButton(new ReloadAction(this)));
154        tb.add(new JSeparator());
155        tb.add(new JButton(new OpenInBrowserAction(this)));
156        tb.add(new JButton(new EditAction(this)));
157        return tb;
158    }
159
160    /**
161     * Builds GUI.
162     */
163    protected final void build() {
164        help = new JosmEditorPane();
165        JosmHTMLEditorKit kit = new JosmHTMLEditorKit();
166        kit.setStyleSheet(buildStyleSheet());
167        help.setEditorKit(kit);
168        help.setEditable(false);
169        help.addHyperlinkListener(new HyperlinkHandler(this, help));
170        help.setContentType("text/html");
171        history = new HelpBrowserHistory(this);
172
173        JPanel p = new JPanel(new BorderLayout());
174        setContentPane(p);
175
176        p.add(new JScrollPane(help), BorderLayout.CENTER);
177
178        addWindowListener(new WindowAdapter() {
179            @Override public void windowClosing(WindowEvent e) {
180                setVisible(false);
181            }
182        });
183
184        p.add(buildToolBar(), BorderLayout.NORTH);
185        InputMapUtils.addEscapeAction(getRootPane(), new AbstractAction() {
186            @Override
187            public void actionPerformed(ActionEvent e) {
188                setVisible(false);
189            }
190        });
191
192        setMinimumSize(new Dimension(400, 200));
193        setTitle(tr("JOSM Help Browser"));
194    }
195
196    @Override
197    public void setVisible(boolean visible) {
198        if (visible) {
199            new WindowGeometry(
200                    getClass().getName() + ".geometry",
201                    WindowGeometry.centerInWindow(
202                            getParent(),
203                            new Dimension(600, 400)
204                    )
205            ).applySafe(this);
206        } else if (isShowing()) { // Avoid IllegalComponentStateException like in #8775
207            new WindowGeometry(this).remember(getClass().getName() + ".geometry");
208        }
209        MainMenu menu = MainApplication.getMenu();
210        if (menu != null && menu.windowMenu != null) {
211            if (windowMenuItem != null && !visible) {
212                menu.windowMenu.remove(windowMenuItem);
213                windowMenuItem = null;
214            }
215            if (windowMenuItem == null && visible) {
216                windowMenuItem = MainMenu.add(menu.windowMenu, FOCUS_ACTION, MainMenu.WINDOW_MENU_GROUP.VOLATILE);
217            }
218        }
219        super.setVisible(visible);
220    }
221
222    /**
223     * Load help topic.
224     * @param content topic contents
225     */
226    protected void loadTopic(String content) {
227        Document document = help.getEditorKit().createDefaultDocument();
228        try {
229            help.getEditorKit().read(new StringReader(content), document, 0);
230        } catch (IOException | BadLocationException e) {
231            Logging.error(e);
232        }
233        help.setDocument(document);
234    }
235
236    @Override
237    public String getUrl() {
238        return url;
239    }
240
241    @Override
242    public void setUrl(String url) {
243        this.url = url;
244    }
245
246    /**
247     * Displays a warning page when a help topic doesn't exist yet.
248     *
249     * @param relativeHelpTopic the help topic
250     */
251    protected void handleMissingHelpContent(String relativeHelpTopic) {
252        // i18n: do not translate "warning-header" and "warning-body"
253        String message = tr("<html><p class=\"warning-header\">Help content for help topic missing</p>"
254                + "<p class=\"warning-body\">Help content for the help topic <strong>{0}</strong> is "
255                + "not available yet. It is missing both in your local language ({1}) and in English.<br><br>"
256                + "Please help to improve the JOSM help system and fill in the missing information. "
257                + "You can both edit the <a href=\"{2}\">help topic in your local language ({1})</a> and "
258                + "the <a href=\"{3}\">help topic in English</a>."
259                + "</p></html>",
260                relativeHelpTopic,
261                Locale.getDefault().getDisplayName(),
262                getHelpTopicEditUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.DEFAULT)),
263                getHelpTopicEditUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.ENGLISH))
264        );
265        loadTopic(message);
266    }
267
268    /**
269     * Displays a error page if a help topic couldn't be loaded because of network or IO error.
270     *
271     * @param relativeHelpTopic the help topic
272     * @param e the exception
273     */
274    protected void handleHelpContentReaderException(String relativeHelpTopic, HelpContentReaderException e) {
275        String message = tr("<html><p class=\"error-header\">Error when retrieving help information</p>"
276                + "<p class=\"error-body\">The content for the help topic <strong>{0}</strong> could "
277                + "not be loaded. The error message is (untranslated):<br>"
278                + "<tt>{1}</tt>"
279                + "</p></html>",
280                relativeHelpTopic,
281                e.toString()
282        );
283        loadTopic(message);
284    }
285
286    /**
287     * Loads a help topic given by a relative help topic name (i.e. "/Action/New")
288     *
289     * First tries to load the language specific help topic. If it is missing, tries to
290     * load the topic in English.
291     *
292     * @param relativeHelpTopic the relative help topic
293     */
294    protected void loadRelativeHelpTopic(String relativeHelpTopic) {
295        String url = getHelpTopicUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.DEFAULTNOTENGLISH));
296        String content = null;
297        try {
298            content = reader.fetchHelpTopicContent(url, true);
299        } catch (MissingHelpContentException e) {
300            Logging.trace(e);
301            url = getHelpTopicUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.BASELANGUAGE));
302            try {
303                content = reader.fetchHelpTopicContent(url, true);
304            } catch (MissingHelpContentException e1) {
305                Logging.trace(e1);
306                url = getHelpTopicUrl(buildAbsoluteHelpTopic(relativeHelpTopic, LocaleType.ENGLISH));
307                try {
308                    content = reader.fetchHelpTopicContent(url, true);
309                } catch (MissingHelpContentException e2) {
310                    Logging.debug(e2);
311                    this.url = url;
312                    handleMissingHelpContent(relativeHelpTopic);
313                    return;
314                } catch (HelpContentReaderException e2) {
315                    Logging.error(e2);
316                    handleHelpContentReaderException(relativeHelpTopic, e2);
317                    return;
318                }
319            } catch (HelpContentReaderException e1) {
320                Logging.error(e1);
321                handleHelpContentReaderException(relativeHelpTopic, e1);
322                return;
323            }
324        } catch (HelpContentReaderException e) {
325            Logging.error(e);
326            handleHelpContentReaderException(relativeHelpTopic, e);
327            return;
328        }
329        loadTopic(content);
330        history.setCurrentUrl(url);
331        this.url = url;
332    }
333
334    /**
335     * Loads a help topic given by an absolute help topic name, i.e.
336     * "/De:Help/Action/New"
337     *
338     * @param absoluteHelpTopic the absolute help topic name
339     */
340    protected void loadAbsoluteHelpTopic(String absoluteHelpTopic) {
341        String url = getHelpTopicUrl(absoluteHelpTopic);
342        String content = null;
343        try {
344            content = reader.fetchHelpTopicContent(url, true);
345        } catch (MissingHelpContentException e) {
346            Logging.debug(e);
347            this.url = url;
348            handleMissingHelpContent(absoluteHelpTopic);
349            return;
350        } catch (HelpContentReaderException e) {
351            Logging.error(e);
352            handleHelpContentReaderException(absoluteHelpTopic, e);
353            return;
354        }
355        loadTopic(content);
356        history.setCurrentUrl(url);
357        this.url = url;
358    }
359
360    @Override
361    public void openUrl(String url) {
362        if (!isVisible()) {
363            setVisible(true);
364            toFront();
365        } else {
366            toFront();
367        }
368        String helpTopic = HelpUtil.extractAbsoluteHelpTopic(url);
369        if (helpTopic == null) {
370            try {
371                this.url = url;
372                String content = reader.fetchHelpTopicContent(url, false);
373                loadTopic(content);
374                history.setCurrentUrl(url);
375                this.url = url;
376            } catch (HelpContentReaderException e) {
377                Logging.warn(e);
378                HelpAwareOptionPane.showOptionDialog(
379                        MainApplication.getMainFrame(),
380                        tr(
381                                "<html>Failed to open help page for url {0}.<br>"
382                                + "This is most likely due to a network problem, please check<br>"
383                                + "your internet connection</html>",
384                                url
385                        ),
386                        tr("Failed to open URL"),
387                        JOptionPane.ERROR_MESSAGE,
388                        null, /* no icon */
389                        null, /* standard options, just OK button */
390                        null, /* default is standard */
391                        null /* no help context */
392                );
393            }
394            history.setCurrentUrl(url);
395        } else {
396            loadAbsoluteHelpTopic(helpTopic);
397        }
398    }
399
400    @Override
401    public void openHelpTopic(String relativeHelpTopic) {
402        if (!isVisible()) {
403            setVisible(true);
404            toFront();
405        } else {
406            toFront();
407        }
408        loadRelativeHelpTopic(relativeHelpTopic);
409    }
410
411    abstract static class AbstractBrowserAction extends AbstractAction {
412        protected final transient IHelpBrowser browser;
413
414        protected AbstractBrowserAction(IHelpBrowser browser) {
415            this.browser = browser;
416        }
417    }
418
419    static class OpenInBrowserAction extends AbstractBrowserAction {
420
421        /**
422         * Constructs a new {@code OpenInBrowserAction}.
423         * @param browser help browser
424         */
425        OpenInBrowserAction(IHelpBrowser browser) {
426            super(browser);
427            putValue(SHORT_DESCRIPTION, tr("Open the current help page in an external browser"));
428            new ImageProvider("help", "internet").getResource().attachImageIcon(this, true);
429        }
430
431        @Override
432        public void actionPerformed(ActionEvent e) {
433            OpenBrowser.displayUrl(browser.getUrl());
434        }
435    }
436
437    static class EditAction extends AbstractBrowserAction {
438
439        /**
440         * Constructs a new {@code EditAction}.
441         * @param browser help browser
442         */
443        EditAction(IHelpBrowser browser) {
444            super(browser);
445            putValue(SHORT_DESCRIPTION, tr("Edit the current help page"));
446            new ImageProvider("dialogs", "edit").getResource().attachImageIcon(this, true);
447        }
448
449        @Override
450        public void actionPerformed(ActionEvent e) {
451            String url = browser.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                        url,
460                        HelpUtil.getWikiBaseUrl()
461                );
462                JOptionPane.showMessageDialog(
463                        MainApplication.getMainFrame(),
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    static class ReloadAction extends AbstractBrowserAction {
476
477        /**
478         * Constructs a new {@code ReloadAction}.
479         * @param browser help browser
480         */
481        ReloadAction(IHelpBrowser browser) {
482            super(browser);
483            putValue(SHORT_DESCRIPTION, tr("Reload the current help page"));
484            new ImageProvider("dialogs", "refresh").getResource().attachImageIcon(this, true);
485        }
486
487        @Override
488        public void actionPerformed(ActionEvent e) {
489            browser.openUrl(browser.getUrl());
490        }
491    }
492
493    static class BackAction extends AbstractBrowserAction implements ChangeListener {
494
495        /**
496         * Constructs a new {@code BackAction}.
497         * @param browser help browser
498         */
499        BackAction(IHelpBrowser browser) {
500            super(browser);
501            browser.getHistory().addChangeListener(this);
502            putValue(SHORT_DESCRIPTION, tr("Go to the previous page"));
503            new ImageProvider("dialogs", "previous").getResource().attachImageIcon(this, true);
504            setEnabled(browser.getHistory().canGoBack());
505        }
506
507        @Override
508        public void actionPerformed(ActionEvent e) {
509            browser.getHistory().back();
510        }
511
512        @Override
513        public void stateChanged(ChangeEvent e) {
514            setEnabled(browser.getHistory().canGoBack());
515        }
516    }
517
518    static class ForwardAction extends AbstractBrowserAction implements ChangeListener {
519
520        /**
521         * Constructs a new {@code ForwardAction}.
522         * @param browser help browser
523         */
524        ForwardAction(IHelpBrowser browser) {
525            super(browser);
526            browser.getHistory().addChangeListener(this);
527            putValue(SHORT_DESCRIPTION, tr("Go to the next page"));
528            new ImageProvider("dialogs", "next").getResource().attachImageIcon(this, true);
529            setEnabled(browser.getHistory().canGoForward());
530        }
531
532        @Override
533        public void actionPerformed(ActionEvent e) {
534            browser.getHistory().forward();
535        }
536
537        @Override
538        public void stateChanged(ChangeEvent e) {
539            setEnabled(browser.getHistory().canGoForward());
540        }
541    }
542
543    static class HomeAction extends AbstractBrowserAction {
544
545        /**
546         * Constructs a new {@code HomeAction}.
547         * @param browser help browser
548         */
549        HomeAction(IHelpBrowser browser) {
550            super(browser);
551            putValue(SHORT_DESCRIPTION, tr("Go to the JOSM help home page"));
552            new ImageProvider("help", "home").getResource().attachImageIcon(this, true);
553        }
554
555        @Override
556        public void actionPerformed(ActionEvent e) {
557            browser.openHelpTopic("/");
558        }
559    }
560
561    @Override
562    public HelpBrowserHistory getHistory() {
563        return history;
564    }
565}