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