001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.preferences.plugin;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005import static org.openstreetmap.josm.tools.I18n.trn;
006
007import java.awt.BorderLayout;
008import java.awt.Component;
009import java.awt.GraphicsEnvironment;
010import java.awt.GridBagConstraints;
011import java.awt.GridBagLayout;
012import java.awt.GridLayout;
013import java.awt.Insets;
014import java.awt.event.ActionEvent;
015import java.awt.event.ComponentAdapter;
016import java.awt.event.ComponentEvent;
017import java.lang.reflect.InvocationTargetException;
018import java.util.ArrayList;
019import java.util.Collection;
020import java.util.Collections;
021import java.util.Iterator;
022import java.util.LinkedList;
023import java.util.List;
024import java.util.Set;
025
026import javax.swing.AbstractAction;
027import javax.swing.BorderFactory;
028import javax.swing.DefaultListModel;
029import javax.swing.JButton;
030import javax.swing.JLabel;
031import javax.swing.JList;
032import javax.swing.JOptionPane;
033import javax.swing.JPanel;
034import javax.swing.JScrollPane;
035import javax.swing.JTabbedPane;
036import javax.swing.SwingUtilities;
037import javax.swing.UIManager;
038import javax.swing.event.DocumentEvent;
039import javax.swing.event.DocumentListener;
040
041import org.openstreetmap.josm.Main;
042import org.openstreetmap.josm.data.Version;
043import org.openstreetmap.josm.gui.HelpAwareOptionPane;
044import org.openstreetmap.josm.gui.HelpAwareOptionPane.ButtonSpec;
045import org.openstreetmap.josm.gui.help.HelpUtil;
046import org.openstreetmap.josm.gui.preferences.DefaultTabPreferenceSetting;
047import org.openstreetmap.josm.gui.preferences.PreferenceSetting;
048import org.openstreetmap.josm.gui.preferences.PreferenceSettingFactory;
049import org.openstreetmap.josm.gui.preferences.PreferenceTabbedPane;
050import org.openstreetmap.josm.gui.preferences.PreferenceTabbedPane.PreferencePanel;
051import org.openstreetmap.josm.gui.util.GuiHelper;
052import org.openstreetmap.josm.gui.widgets.JosmTextField;
053import org.openstreetmap.josm.gui.widgets.SelectAllOnFocusGainedDecorator;
054import org.openstreetmap.josm.plugins.PluginDownloadTask;
055import org.openstreetmap.josm.plugins.PluginInformation;
056import org.openstreetmap.josm.plugins.ReadLocalPluginInformationTask;
057import org.openstreetmap.josm.plugins.ReadRemotePluginInformationTask;
058import org.openstreetmap.josm.tools.GBC;
059import org.openstreetmap.josm.tools.ImageProvider;
060
061/**
062 * Preference settings for plugins.
063 * @since 168
064 */
065public final class PluginPreference extends DefaultTabPreferenceSetting {
066
067    /**
068     * Factory used to create a new {@code PluginPreference}.
069     */
070    public static class Factory implements PreferenceSettingFactory {
071        @Override
072        public PreferenceSetting createPreferenceSetting() {
073            return new PluginPreference();
074        }
075    }
076
077    private JosmTextField tfFilter;
078    private PluginListPanel pnlPluginPreferences;
079    private PluginPreferencesModel model;
080    private JScrollPane spPluginPreferences;
081    private PluginUpdatePolicyPanel pnlPluginUpdatePolicy;
082
083    /**
084     * is set to true if this preference pane has been selected by the user
085     */
086    private boolean pluginPreferencesActivated;
087
088    private PluginPreference() {
089        super(/* ICON(preferences/) */ "plugin", tr("Plugins"), tr("Configure available plugins."), false, new JTabbedPane());
090    }
091
092    /**
093     * Returns the download summary string to be shown.
094     * @param task The plugin download task that has completed
095     * @return the download summary string to be shown. Contains summary of success/failed plugins.
096     */
097    public static String buildDownloadSummary(PluginDownloadTask task) {
098        Collection<PluginInformation> downloaded = task.getDownloadedPlugins();
099        Collection<PluginInformation> failed = task.getFailedPlugins();
100        Exception exception = task.getLastException();
101        StringBuilder sb = new StringBuilder();
102        if (!downloaded.isEmpty()) {
103            sb.append(trn(
104                    "The following plugin has been downloaded <strong>successfully</strong>:",
105                    "The following {0} plugins have been downloaded <strong>successfully</strong>:",
106                    downloaded.size(),
107                    downloaded.size()
108                    ));
109            sb.append("<ul>");
110            for (PluginInformation pi: downloaded) {
111                sb.append("<li>").append(pi.name).append(" (").append(pi.version).append(")</li>");
112            }
113            sb.append("</ul>");
114        }
115        if (!failed.isEmpty()) {
116            sb.append(trn(
117                    "Downloading the following plugin has <strong>failed</strong>:",
118                    "Downloading the following {0} plugins has <strong>failed</strong>:",
119                    failed.size(),
120                    failed.size()
121                    ));
122            sb.append("<ul>");
123            for (PluginInformation pi: failed) {
124                sb.append("<li>").append(pi.name).append("</li>");
125            }
126            sb.append("</ul>");
127        }
128        if (exception != null) {
129            // Same i18n string in ExceptionUtil.explainBadRequest()
130            sb.append(tr("<br>Error message(untranslated): {0}", exception.getMessage()));
131        }
132        return sb.toString();
133    }
134
135    /**
136     * Notifies user about result of a finished plugin download task.
137     * @param parent The parent component
138     * @param task The finished plugin download task
139     * @param restartRequired true if a restart is required
140     * @since 6797
141     */
142    public static void notifyDownloadResults(final Component parent, PluginDownloadTask task, boolean restartRequired) {
143        final Collection<PluginInformation> failed = task.getFailedPlugins();
144        final StringBuilder sb = new StringBuilder();
145        sb.append("<html>")
146          .append(buildDownloadSummary(task));
147        if (restartRequired) {
148            sb.append(tr("Please restart JOSM to activate the downloaded plugins."));
149        }
150        sb.append("</html>");
151        if (!GraphicsEnvironment.isHeadless()) {
152            GuiHelper.runInEDTAndWait(new Runnable() {
153                @Override
154                public void run() {
155                    HelpAwareOptionPane.showOptionDialog(
156                            parent,
157                            sb.toString(),
158                            tr("Update plugins"),
159                            !failed.isEmpty() ? JOptionPane.WARNING_MESSAGE : JOptionPane.INFORMATION_MESSAGE,
160                                    HelpUtil.ht("/Preferences/Plugins")
161                            );
162                }
163            });
164        }
165    }
166
167    protected JPanel buildSearchFieldPanel() {
168        JPanel pnl = new JPanel(new GridBagLayout());
169        pnl.setBorder(BorderFactory.createEmptyBorder(5, 5, 5, 5));
170        GridBagConstraints gc = new GridBagConstraints();
171
172        gc.anchor = GridBagConstraints.NORTHWEST;
173        gc.fill = GridBagConstraints.HORIZONTAL;
174        gc.weightx = 0.0;
175        gc.insets = new Insets(0, 0, 0, 3);
176        pnl.add(new JLabel(tr("Search:")), gc);
177
178        gc.gridx = 1;
179        gc.weightx = 1.0;
180        tfFilter = new JosmTextField();
181        pnl.add(tfFilter, gc);
182        tfFilter.setToolTipText(tr("Enter a search expression"));
183        SelectAllOnFocusGainedDecorator.decorate(tfFilter);
184        tfFilter.getDocument().addDocumentListener(new SearchFieldAdapter());
185        return pnl;
186    }
187
188    protected JPanel buildActionPanel() {
189        JPanel pnl = new JPanel(new GridLayout(1, 3));
190
191        pnl.add(new JButton(new DownloadAvailablePluginsAction()));
192        pnl.add(new JButton(new UpdateSelectedPluginsAction()));
193        pnl.add(new JButton(new ConfigureSitesAction()));
194        return pnl;
195    }
196
197    protected JPanel buildPluginListPanel() {
198        JPanel pnl = new JPanel(new BorderLayout());
199        pnl.add(buildSearchFieldPanel(), BorderLayout.NORTH);
200        model  = new PluginPreferencesModel();
201        pnlPluginPreferences = new PluginListPanel(model);
202        spPluginPreferences = GuiHelper.embedInVerticalScrollPane(pnlPluginPreferences);
203        spPluginPreferences.getVerticalScrollBar().addComponentListener(
204                new ComponentAdapter() {
205                    @Override
206                    public void componentShown(ComponentEvent e) {
207                        spPluginPreferences.setBorder(UIManager.getBorder("ScrollPane.border"));
208                    }
209
210                    @Override
211                    public void componentHidden(ComponentEvent e) {
212                        spPluginPreferences.setBorder(null);
213                    }
214                }
215                );
216
217        pnl.add(spPluginPreferences, BorderLayout.CENTER);
218        pnl.add(buildActionPanel(), BorderLayout.SOUTH);
219        return pnl;
220    }
221
222    protected JTabbedPane buildContentPane() {
223        JTabbedPane pane = getTabPane();
224        pnlPluginUpdatePolicy = new PluginUpdatePolicyPanel();
225        pane.addTab(tr("Plugins"), buildPluginListPanel());
226        pane.addTab(tr("Plugin update policy"), pnlPluginUpdatePolicy);
227        return pane;
228    }
229
230    @Override
231    public void addGui(final PreferenceTabbedPane gui) {
232        GridBagConstraints gc = new GridBagConstraints();
233        gc.weightx = 1.0;
234        gc.weighty = 1.0;
235        gc.anchor = GridBagConstraints.NORTHWEST;
236        gc.fill = GridBagConstraints.BOTH;
237        PreferencePanel plugins = gui.createPreferenceTab(this);
238        plugins.add(buildContentPane(), gc);
239        readLocalPluginInformation();
240        pluginPreferencesActivated = true;
241    }
242
243    private void configureSites() {
244        ButtonSpec[] options = new ButtonSpec[] {
245                new ButtonSpec(
246                        tr("OK"),
247                        ImageProvider.get("ok"),
248                        tr("Accept the new plugin sites and close the dialog"),
249                        null /* no special help topic */
250                        ),
251                        new ButtonSpec(
252                                tr("Cancel"),
253                                ImageProvider.get("cancel"),
254                                tr("Close the dialog"),
255                                null /* no special help topic */
256                                )
257        };
258        PluginConfigurationSitesPanel pnl = new PluginConfigurationSitesPanel();
259
260        int answer = HelpAwareOptionPane.showOptionDialog(
261                pnlPluginPreferences,
262                pnl,
263                tr("Configure Plugin Sites"),
264                JOptionPane.QUESTION_MESSAGE,
265                null,
266                options,
267                options[0],
268                null /* no help topic */
269                );
270        if (answer != 0 /* OK */)
271            return;
272        List<String> sites = pnl.getUpdateSites();
273        Main.pref.setPluginSites(sites);
274    }
275
276    /**
277     * Replies the set of plugins waiting for update or download
278     *
279     * @return the set of plugins waiting for update or download
280     */
281    public Set<PluginInformation> getPluginsScheduledForUpdateOrDownload() {
282        return model != null ? model.getPluginsScheduledForUpdateOrDownload() : null;
283    }
284
285    /**
286     * Replies the list of plugins which have been added by the user to the set of activated plugins
287     *
288     * @return the list of newly activated plugins
289     */
290    public List<PluginInformation> getNewlyActivatedPlugins() {
291        return model != null ? model.getNewlyActivatedPlugins() : null;
292    }
293
294    @Override
295    public boolean ok() {
296        if (!pluginPreferencesActivated)
297            return false;
298        pnlPluginUpdatePolicy.rememberInPreferences();
299        if (model.isActivePluginsChanged()) {
300            List<String> l = new LinkedList<>(model.getSelectedPluginNames());
301            Collections.sort(l);
302            Main.pref.putCollection("plugins", l);
303            if (!model.getNewlyDeactivatedPlugins().isEmpty())
304                return true;
305            for (PluginInformation pi : model.getNewlyActivatedPlugins()) {
306                if (!pi.canloadatruntime)
307                    return true;
308            }
309        }
310        return false;
311    }
312
313    /**
314     * Reads locally available information about plugins from the local file system.
315     * Scans cached plugin lists from plugin download sites and locally available
316     * plugin jar files.
317     *
318     */
319    public void readLocalPluginInformation() {
320        final ReadLocalPluginInformationTask task = new ReadLocalPluginInformationTask();
321        Runnable r = new Runnable() {
322            @Override
323            public void run() {
324                if (!task.isCanceled()) {
325                    SwingUtilities.invokeLater(new Runnable() {
326                        @Override
327                        public void run() {
328                            model.setAvailablePlugins(task.getAvailablePlugins());
329                            pnlPluginPreferences.refreshView();
330                        }
331                    });
332                }
333            }
334        };
335        Main.worker.submit(task);
336        Main.worker.submit(r);
337    }
338
339    /**
340     * The action for downloading the list of available plugins
341     */
342    class DownloadAvailablePluginsAction extends AbstractAction {
343
344        /**
345         * Constructs a new {@code DownloadAvailablePluginsAction}.
346         */
347        DownloadAvailablePluginsAction() {
348            putValue(NAME, tr("Download list"));
349            putValue(SHORT_DESCRIPTION, tr("Download the list of available plugins"));
350            putValue(SMALL_ICON, ImageProvider.get("download"));
351        }
352
353        @Override
354        public void actionPerformed(ActionEvent e) {
355            Collection<String> pluginSites = Main.pref.getOnlinePluginSites();
356            if (pluginSites.isEmpty()) {
357                return;
358            }
359            final ReadRemotePluginInformationTask task = new ReadRemotePluginInformationTask(pluginSites);
360            Runnable continuation = new Runnable() {
361                @Override
362                public void run() {
363                    if (!task.isCanceled()) {
364                        SwingUtilities.invokeLater(new Runnable() {
365                            @Override
366                            public void run() {
367                                model.updateAvailablePlugins(task.getAvailablePlugins());
368                                pnlPluginPreferences.refreshView();
369                                Main.pref.putInteger("pluginmanager.version", Version.getInstance().getVersion()); // fix #7030
370                            }
371                        });
372                    }
373                }
374            };
375            Main.worker.submit(task);
376            Main.worker.submit(continuation);
377        }
378    }
379
380    /**
381     * The action for updating the list of selected plugins
382     */
383    class UpdateSelectedPluginsAction extends AbstractAction {
384        UpdateSelectedPluginsAction() {
385            putValue(NAME, tr("Update plugins"));
386            putValue(SHORT_DESCRIPTION, tr("Update the selected plugins"));
387            putValue(SMALL_ICON, ImageProvider.get("dialogs", "refresh"));
388        }
389
390        protected void alertNothingToUpdate() {
391            try {
392                SwingUtilities.invokeAndWait(new Runnable() {
393                    @Override
394                    public void run() {
395                        HelpAwareOptionPane.showOptionDialog(
396                                pnlPluginPreferences,
397                                tr("All installed plugins are up to date. JOSM does not have to download newer versions."),
398                                tr("Plugins up to date"),
399                                JOptionPane.INFORMATION_MESSAGE,
400                                null // FIXME: provide help context
401                                );
402                    }
403                });
404            } catch (InterruptedException | InvocationTargetException e) {
405                Main.error(e);
406            }
407        }
408
409        @Override
410        public void actionPerformed(ActionEvent e) {
411            final List<PluginInformation> toUpdate = model.getSelectedPlugins();
412            // the async task for downloading plugins
413            final PluginDownloadTask pluginDownloadTask = new PluginDownloadTask(
414                    pnlPluginPreferences,
415                    toUpdate,
416                    tr("Update plugins")
417                    );
418            // the async task for downloading plugin information
419            final ReadRemotePluginInformationTask pluginInfoDownloadTask = new ReadRemotePluginInformationTask(
420                    Main.pref.getOnlinePluginSites());
421
422            // to be run asynchronously after the plugin download
423            //
424            final Runnable pluginDownloadContinuation = new Runnable() {
425                @Override
426                public void run() {
427                    if (pluginDownloadTask.isCanceled())
428                        return;
429                    boolean restartRequired = false;
430                    for (PluginInformation pi : pluginDownloadTask.getDownloadedPlugins()) {
431                        if (!model.getNewlyActivatedPlugins().contains(pi) || !pi.canloadatruntime) {
432                            restartRequired = true;
433                            break;
434                        }
435                    }
436                    notifyDownloadResults(pnlPluginPreferences, pluginDownloadTask, restartRequired);
437                    model.refreshLocalPluginVersion(pluginDownloadTask.getDownloadedPlugins());
438                    model.clearPendingPlugins(pluginDownloadTask.getDownloadedPlugins());
439                    GuiHelper.runInEDT(new Runnable() {
440                        @Override
441                        public void run() {
442                            pnlPluginPreferences.refreshView();
443                        }
444                    });
445                }
446            };
447
448            // to be run asynchronously after the plugin list download
449            //
450            final Runnable pluginInfoDownloadContinuation = new Runnable() {
451                @Override
452                public void run() {
453                    if (pluginInfoDownloadTask.isCanceled())
454                        return;
455                    model.updateAvailablePlugins(pluginInfoDownloadTask.getAvailablePlugins());
456                    // select plugins which actually have to be updated
457                    //
458                    Iterator<PluginInformation> it = toUpdate.iterator();
459                    while (it.hasNext()) {
460                        PluginInformation pi = it.next();
461                        if (!pi.isUpdateRequired()) {
462                            it.remove();
463                        }
464                    }
465                    if (toUpdate.isEmpty()) {
466                        alertNothingToUpdate();
467                        return;
468                    }
469                    pluginDownloadTask.setPluginsToDownload(toUpdate);
470                    Main.worker.submit(pluginDownloadTask);
471                    Main.worker.submit(pluginDownloadContinuation);
472                }
473            };
474
475            Main.worker.submit(pluginInfoDownloadTask);
476            Main.worker.submit(pluginInfoDownloadContinuation);
477        }
478    }
479
480
481    /**
482     * The action for configuring the plugin download sites
483     *
484     */
485    class ConfigureSitesAction extends AbstractAction {
486        ConfigureSitesAction() {
487            putValue(NAME, tr("Configure sites..."));
488            putValue(SHORT_DESCRIPTION, tr("Configure the list of sites where plugins are downloaded from"));
489            putValue(SMALL_ICON, ImageProvider.get("dialogs", "settings"));
490        }
491
492        @Override
493        public void actionPerformed(ActionEvent e) {
494            configureSites();
495        }
496    }
497
498    /**
499     * Applies the current filter condition in the filter text field to the
500     * model
501     */
502    class SearchFieldAdapter implements DocumentListener {
503        public void filter() {
504            String expr = tfFilter.getText().trim();
505            if (expr.isEmpty()) {
506                expr = null;
507            }
508            model.filterDisplayedPlugins(expr);
509            pnlPluginPreferences.refreshView();
510        }
511
512        @Override
513        public void changedUpdate(DocumentEvent arg0) {
514            filter();
515        }
516
517        @Override
518        public void insertUpdate(DocumentEvent arg0) {
519            filter();
520        }
521
522        @Override
523        public void removeUpdate(DocumentEvent arg0) {
524            filter();
525        }
526    }
527
528    private static class PluginConfigurationSitesPanel extends JPanel {
529
530        private final DefaultListModel<String> model = new DefaultListModel<>();
531
532        PluginConfigurationSitesPanel() {
533            super(new GridBagLayout());
534            add(new JLabel(tr("Add JOSM Plugin description URL.")), GBC.eol());
535            for (String s : Main.pref.getPluginSites()) {
536                model.addElement(s);
537            }
538            final JList<String> list = new JList<>(model);
539            add(new JScrollPane(list), GBC.std().fill());
540            JPanel buttons = new JPanel(new GridBagLayout());
541            buttons.add(new JButton(new AbstractAction(tr("Add")) {
542                @Override
543                public void actionPerformed(ActionEvent e) {
544                    String s = JOptionPane.showInputDialog(
545                            JOptionPane.getFrameForComponent(PluginConfigurationSitesPanel.this),
546                            tr("Add JOSM Plugin description URL."),
547                            tr("Enter URL"),
548                            JOptionPane.QUESTION_MESSAGE
549                            );
550                    if (s != null) {
551                        model.addElement(s);
552                    }
553                }
554            }), GBC.eol().fill(GBC.HORIZONTAL));
555            buttons.add(new JButton(new AbstractAction(tr("Edit")) {
556                @Override
557                public void actionPerformed(ActionEvent e) {
558                    if (list.getSelectedValue() == null) {
559                        JOptionPane.showMessageDialog(
560                                JOptionPane.getFrameForComponent(PluginConfigurationSitesPanel.this),
561                                tr("Please select an entry."),
562                                tr("Warning"),
563                                JOptionPane.WARNING_MESSAGE
564                                );
565                        return;
566                    }
567                    String s = (String) JOptionPane.showInputDialog(
568                            Main.parent,
569                            tr("Edit JOSM Plugin description URL."),
570                            tr("JOSM Plugin description URL"),
571                            JOptionPane.QUESTION_MESSAGE,
572                            null,
573                            null,
574                            list.getSelectedValue()
575                            );
576                    if (s != null) {
577                        model.setElementAt(s, list.getSelectedIndex());
578                    }
579                }
580            }), GBC.eol().fill(GBC.HORIZONTAL));
581            buttons.add(new JButton(new AbstractAction(tr("Delete")) {
582                @Override
583                public void actionPerformed(ActionEvent event) {
584                    if (list.getSelectedValue() == null) {
585                        JOptionPane.showMessageDialog(
586                                JOptionPane.getFrameForComponent(PluginConfigurationSitesPanel.this),
587                                tr("Please select an entry."),
588                                tr("Warning"),
589                                JOptionPane.WARNING_MESSAGE
590                                );
591                        return;
592                    }
593                    model.removeElement(list.getSelectedValue());
594                }
595            }), GBC.eol().fill(GBC.HORIZONTAL));
596            add(buttons, GBC.eol());
597        }
598
599        public List<String> getUpdateSites() {
600            if (model.getSize() == 0)
601                return Collections.emptyList();
602            List<String> ret = new ArrayList<>(model.getSize());
603            for (int i = 0; i < model.getSize(); i++) {
604                ret.add(model.get(i));
605            }
606            return ret;
607        }
608    }
609}