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        Main.pref.setPluginSites(pnl.getUpdateSites());
273    }
274
275    /**
276     * Replies the set of plugins waiting for update or download
277     *
278     * @return the set of plugins waiting for update or download
279     */
280    public Set<PluginInformation> getPluginsScheduledForUpdateOrDownload() {
281        return model != null ? model.getPluginsScheduledForUpdateOrDownload() : null;
282    }
283
284    /**
285     * Replies the list of plugins which have been added by the user to the set of activated plugins
286     *
287     * @return the list of newly activated plugins
288     */
289    public List<PluginInformation> getNewlyActivatedPlugins() {
290        return model != null ? model.getNewlyActivatedPlugins() : null;
291    }
292
293    @Override
294    public boolean ok() {
295        if (!pluginPreferencesActivated)
296            return false;
297        pnlPluginUpdatePolicy.rememberInPreferences();
298        if (model.isActivePluginsChanged()) {
299            List<String> l = new LinkedList<>(model.getSelectedPluginNames());
300            Collections.sort(l);
301            Main.pref.putCollection("plugins", l);
302            if (!model.getNewlyDeactivatedPlugins().isEmpty())
303                return true;
304            for (PluginInformation pi : model.getNewlyActivatedPlugins()) {
305                if (!pi.canloadatruntime)
306                    return true;
307            }
308        }
309        return false;
310    }
311
312    /**
313     * Reads locally available information about plugins from the local file system.
314     * Scans cached plugin lists from plugin download sites and locally available
315     * plugin jar files.
316     *
317     */
318    public void readLocalPluginInformation() {
319        final ReadLocalPluginInformationTask task = new ReadLocalPluginInformationTask();
320        Runnable r = new Runnable() {
321            @Override
322            public void run() {
323                if (!task.isCanceled()) {
324                    SwingUtilities.invokeLater(new Runnable() {
325                        @Override
326                        public void run() {
327                            model.setAvailablePlugins(task.getAvailablePlugins());
328                            pnlPluginPreferences.refreshView();
329                        }
330                    });
331                }
332            }
333        };
334        Main.worker.submit(task);
335        Main.worker.submit(r);
336    }
337
338    /**
339     * The action for downloading the list of available plugins
340     */
341    class DownloadAvailablePluginsAction extends AbstractAction {
342
343        /**
344         * Constructs a new {@code DownloadAvailablePluginsAction}.
345         */
346        DownloadAvailablePluginsAction() {
347            putValue(NAME, tr("Download list"));
348            putValue(SHORT_DESCRIPTION, tr("Download the list of available plugins"));
349            putValue(SMALL_ICON, ImageProvider.get("download"));
350        }
351
352        @Override
353        public void actionPerformed(ActionEvent e) {
354            Collection<String> pluginSites = Main.pref.getOnlinePluginSites();
355            if (pluginSites.isEmpty()) {
356                return;
357            }
358            final ReadRemotePluginInformationTask task = new ReadRemotePluginInformationTask(pluginSites);
359            Runnable continuation = new Runnable() {
360                @Override
361                public void run() {
362                    if (!task.isCanceled()) {
363                        SwingUtilities.invokeLater(new Runnable() {
364                            @Override
365                            public void run() {
366                                model.updateAvailablePlugins(task.getAvailablePlugins());
367                                pnlPluginPreferences.refreshView();
368                                Main.pref.putInteger("pluginmanager.version", Version.getInstance().getVersion()); // fix #7030
369                            }
370                        });
371                    }
372                }
373            };
374            Main.worker.submit(task);
375            Main.worker.submit(continuation);
376        }
377    }
378
379    /**
380     * The action for updating the list of selected plugins
381     */
382    class UpdateSelectedPluginsAction extends AbstractAction {
383        UpdateSelectedPluginsAction() {
384            putValue(NAME, tr("Update plugins"));
385            putValue(SHORT_DESCRIPTION, tr("Update the selected plugins"));
386            putValue(SMALL_ICON, ImageProvider.get("dialogs", "refresh"));
387        }
388
389        protected void alertNothingToUpdate() {
390            try {
391                SwingUtilities.invokeAndWait(new Runnable() {
392                    @Override
393                    public void run() {
394                        HelpAwareOptionPane.showOptionDialog(
395                                pnlPluginPreferences,
396                                tr("All installed plugins are up to date. JOSM does not have to download newer versions."),
397                                tr("Plugins up to date"),
398                                JOptionPane.INFORMATION_MESSAGE,
399                                null // FIXME: provide help context
400                                );
401                    }
402                });
403            } catch (InterruptedException | InvocationTargetException e) {
404                Main.error(e);
405            }
406        }
407
408        @Override
409        public void actionPerformed(ActionEvent e) {
410            final List<PluginInformation> toUpdate = model.getSelectedPlugins();
411            // the async task for downloading plugins
412            final PluginDownloadTask pluginDownloadTask = new PluginDownloadTask(
413                    pnlPluginPreferences,
414                    toUpdate,
415                    tr("Update plugins")
416                    );
417            // the async task for downloading plugin information
418            final ReadRemotePluginInformationTask pluginInfoDownloadTask = new ReadRemotePluginInformationTask(
419                    Main.pref.getOnlinePluginSites());
420
421            // to be run asynchronously after the plugin download
422            //
423            final Runnable pluginDownloadContinuation = new Runnable() {
424                @Override
425                public void run() {
426                    if (pluginDownloadTask.isCanceled())
427                        return;
428                    boolean restartRequired = false;
429                    for (PluginInformation pi : pluginDownloadTask.getDownloadedPlugins()) {
430                        if (!model.getNewlyActivatedPlugins().contains(pi) || !pi.canloadatruntime) {
431                            restartRequired = true;
432                            break;
433                        }
434                    }
435                    notifyDownloadResults(pnlPluginPreferences, pluginDownloadTask, restartRequired);
436                    model.refreshLocalPluginVersion(pluginDownloadTask.getDownloadedPlugins());
437                    model.clearPendingPlugins(pluginDownloadTask.getDownloadedPlugins());
438                    GuiHelper.runInEDT(new Runnable() {
439                        @Override
440                        public void run() {
441                            pnlPluginPreferences.refreshView();
442                        }
443                    });
444                }
445            };
446
447            // to be run asynchronously after the plugin list download
448            //
449            final Runnable pluginInfoDownloadContinuation = new Runnable() {
450                @Override
451                public void run() {
452                    if (pluginInfoDownloadTask.isCanceled())
453                        return;
454                    model.updateAvailablePlugins(pluginInfoDownloadTask.getAvailablePlugins());
455                    // select plugins which actually have to be updated
456                    //
457                    Iterator<PluginInformation> it = toUpdate.iterator();
458                    while (it.hasNext()) {
459                        PluginInformation pi = it.next();
460                        if (!pi.isUpdateRequired()) {
461                            it.remove();
462                        }
463                    }
464                    if (toUpdate.isEmpty()) {
465                        alertNothingToUpdate();
466                        return;
467                    }
468                    pluginDownloadTask.setPluginsToDownload(toUpdate);
469                    Main.worker.submit(pluginDownloadTask);
470                    Main.worker.submit(pluginDownloadContinuation);
471                }
472            };
473
474            Main.worker.submit(pluginInfoDownloadTask);
475            Main.worker.submit(pluginInfoDownloadContinuation);
476        }
477    }
478
479
480    /**
481     * The action for configuring the plugin download sites
482     *
483     */
484    class ConfigureSitesAction extends AbstractAction {
485        ConfigureSitesAction() {
486            putValue(NAME, tr("Configure sites..."));
487            putValue(SHORT_DESCRIPTION, tr("Configure the list of sites where plugins are downloaded from"));
488            putValue(SMALL_ICON, ImageProvider.get("dialogs", "settings"));
489        }
490
491        @Override
492        public void actionPerformed(ActionEvent e) {
493            configureSites();
494        }
495    }
496
497    /**
498     * Applies the current filter condition in the filter text field to the model.
499     */
500    class SearchFieldAdapter implements DocumentListener {
501        private void filter() {
502            String expr = tfFilter.getText().trim();
503            if (expr.isEmpty()) {
504                expr = null;
505            }
506            model.filterDisplayedPlugins(expr);
507            pnlPluginPreferences.refreshView();
508        }
509
510        @Override
511        public void changedUpdate(DocumentEvent arg0) {
512            filter();
513        }
514
515        @Override
516        public void insertUpdate(DocumentEvent arg0) {
517            filter();
518        }
519
520        @Override
521        public void removeUpdate(DocumentEvent arg0) {
522            filter();
523        }
524    }
525
526    private static class PluginConfigurationSitesPanel extends JPanel {
527
528        private final DefaultListModel<String> model = new DefaultListModel<>();
529
530        PluginConfigurationSitesPanel() {
531            super(new GridBagLayout());
532            add(new JLabel(tr("Add JOSM Plugin description URL.")), GBC.eol());
533            for (String s : Main.pref.getPluginSites()) {
534                model.addElement(s);
535            }
536            final JList<String> list = new JList<>(model);
537            add(new JScrollPane(list), GBC.std().fill());
538            JPanel buttons = new JPanel(new GridBagLayout());
539            buttons.add(new JButton(new AbstractAction(tr("Add")) {
540                @Override
541                public void actionPerformed(ActionEvent e) {
542                    String s = JOptionPane.showInputDialog(
543                            GuiHelper.getFrameForComponent(PluginConfigurationSitesPanel.this),
544                            tr("Add JOSM Plugin description URL."),
545                            tr("Enter URL"),
546                            JOptionPane.QUESTION_MESSAGE
547                            );
548                    if (s != null) {
549                        model.addElement(s);
550                    }
551                }
552            }), GBC.eol().fill(GBC.HORIZONTAL));
553            buttons.add(new JButton(new AbstractAction(tr("Edit")) {
554                @Override
555                public void actionPerformed(ActionEvent e) {
556                    if (list.getSelectedValue() == null) {
557                        JOptionPane.showMessageDialog(
558                                GuiHelper.getFrameForComponent(PluginConfigurationSitesPanel.this),
559                                tr("Please select an entry."),
560                                tr("Warning"),
561                                JOptionPane.WARNING_MESSAGE
562                                );
563                        return;
564                    }
565                    String s = (String) JOptionPane.showInputDialog(
566                            Main.parent,
567                            tr("Edit JOSM Plugin description URL."),
568                            tr("JOSM Plugin description URL"),
569                            JOptionPane.QUESTION_MESSAGE,
570                            null,
571                            null,
572                            list.getSelectedValue()
573                            );
574                    if (s != null) {
575                        model.setElementAt(s, list.getSelectedIndex());
576                    }
577                }
578            }), GBC.eol().fill(GBC.HORIZONTAL));
579            buttons.add(new JButton(new AbstractAction(tr("Delete")) {
580                @Override
581                public void actionPerformed(ActionEvent event) {
582                    if (list.getSelectedValue() == null) {
583                        JOptionPane.showMessageDialog(
584                                GuiHelper.getFrameForComponent(PluginConfigurationSitesPanel.this),
585                                tr("Please select an entry."),
586                                tr("Warning"),
587                                JOptionPane.WARNING_MESSAGE
588                                );
589                        return;
590                    }
591                    model.removeElement(list.getSelectedValue());
592                }
593            }), GBC.eol().fill(GBC.HORIZONTAL));
594            add(buttons, GBC.eol());
595        }
596
597        protected List<String> getUpdateSites() {
598            if (model.getSize() == 0)
599                return Collections.emptyList();
600            List<String> ret = new ArrayList<>(model.getSize());
601            for (int i = 0; i < model.getSize(); i++) {
602                ret.add(model.get(i));
603            }
604            return ret;
605        }
606    }
607}