001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.preferences.plugin;
003
004import static java.awt.GridBagConstraints.HORIZONTAL;
005import static org.openstreetmap.josm.tools.I18n.tr;
006import static org.openstreetmap.josm.tools.I18n.trc;
007import static org.openstreetmap.josm.tools.I18n.trn;
008
009import java.awt.BorderLayout;
010import java.awt.Component;
011import java.awt.GridBagLayout;
012import java.awt.GridLayout;
013import java.awt.event.ActionEvent;
014import java.awt.event.ComponentAdapter;
015import java.awt.event.ComponentEvent;
016import java.lang.reflect.InvocationTargetException;
017import java.util.ArrayList;
018import java.util.Collection;
019import java.util.Collections;
020import java.util.LinkedList;
021import java.util.List;
022import java.util.Set;
023import java.util.regex.Pattern;
024import java.util.stream.Collectors;
025import java.util.stream.IntStream;
026
027import javax.swing.AbstractAction;
028import javax.swing.ButtonGroup;
029import javax.swing.DefaultListModel;
030import javax.swing.JButton;
031import javax.swing.JCheckBox;
032import javax.swing.JLabel;
033import javax.swing.JList;
034import javax.swing.JOptionPane;
035import javax.swing.JPanel;
036import javax.swing.JRadioButton;
037import javax.swing.JScrollPane;
038import javax.swing.JTabbedPane;
039import javax.swing.JTextArea;
040import javax.swing.SwingUtilities;
041import javax.swing.UIManager;
042
043import org.openstreetmap.josm.actions.ExpertToggleAction;
044import org.openstreetmap.josm.data.Preferences;
045import org.openstreetmap.josm.data.Version;
046import org.openstreetmap.josm.gui.HelpAwareOptionPane;
047import org.openstreetmap.josm.gui.HelpAwareOptionPane.ButtonSpec;
048import org.openstreetmap.josm.gui.MainApplication;
049import org.openstreetmap.josm.gui.help.HelpUtil;
050import org.openstreetmap.josm.gui.preferences.ExtensibleTabPreferenceSetting;
051import org.openstreetmap.josm.gui.preferences.PreferenceSetting;
052import org.openstreetmap.josm.gui.preferences.PreferenceSettingFactory;
053import org.openstreetmap.josm.gui.preferences.PreferenceTabbedPane;
054import org.openstreetmap.josm.gui.util.GuiHelper;
055import org.openstreetmap.josm.gui.widgets.FilterField;
056import org.openstreetmap.josm.plugins.PluginDownloadTask;
057import org.openstreetmap.josm.plugins.PluginHandler;
058import org.openstreetmap.josm.plugins.PluginInformation;
059import org.openstreetmap.josm.plugins.ReadLocalPluginInformationTask;
060import org.openstreetmap.josm.plugins.ReadRemotePluginInformationTask;
061import org.openstreetmap.josm.spi.preferences.Config;
062import org.openstreetmap.josm.tools.GBC;
063import org.openstreetmap.josm.tools.ImageProvider;
064import org.openstreetmap.josm.tools.Logging;
065import org.openstreetmap.josm.tools.Utils;
066
067/**
068 * Preference settings for plugins.
069 * @since 168
070 */
071public final class PluginPreference extends ExtensibleTabPreferenceSetting {
072
073    /**
074     * Factory used to create a new {@code PluginPreference}.
075     */
076    public static class Factory implements PreferenceSettingFactory {
077        @Override
078        public PreferenceSetting createPreferenceSetting() {
079            return new PluginPreference();
080        }
081    }
082
083    private PluginListPanel pnlPluginPreferences;
084    private PluginPreferencesModel model;
085    private JScrollPane spPluginPreferences;
086    private PluginUpdatePolicyPanel pnlPluginUpdatePolicy;
087
088    /**
089     * is set to true if this preference pane has been selected by the user
090     */
091    private boolean pluginPreferencesActivated;
092
093    private PluginPreference() {
094        super(/* ICON(preferences/) */ "plugin", tr("Plugins"), tr("Configure available plugins."), false);
095    }
096
097    /**
098     * Returns the download summary string to be shown.
099     * @param task The plugin download task that has completed
100     * @return the download summary string to be shown. Contains summary of success/failed plugins.
101     */
102    public static String buildDownloadSummary(PluginDownloadTask task) {
103        Collection<PluginInformation> downloaded = task.getDownloadedPlugins();
104        Collection<PluginInformation> failed = task.getFailedPlugins();
105        Exception exception = task.getLastException();
106        StringBuilder sb = new StringBuilder();
107        if (!downloaded.isEmpty()) {
108            sb.append(trn(
109                    "The following plugin has been downloaded <strong>successfully</strong>:",
110                    "The following {0} plugins have been downloaded <strong>successfully</strong>:",
111                    downloaded.size(),
112                    downloaded.size()
113                    ));
114            sb.append("<ul>");
115            for (PluginInformation pi: downloaded) {
116                sb.append("<li>").append(pi.name).append(" (").append(pi.version).append(")</li>");
117            }
118            sb.append("</ul>");
119        }
120        if (!failed.isEmpty()) {
121            sb.append(trn(
122                    "Downloading the following plugin has <strong>failed</strong>:",
123                    "Downloading the following {0} plugins has <strong>failed</strong>:",
124                    failed.size(),
125                    failed.size()
126                    ));
127            sb.append("<ul>");
128            for (PluginInformation pi: failed) {
129                sb.append("<li>").append(pi.name).append("</li>");
130            }
131            sb.append("</ul>");
132        }
133        if (exception != null) {
134            // Same i18n string in ExceptionUtil.explainBadRequest()
135            sb.append(tr("<br>Error message(untranslated): {0}", exception.getMessage()));
136        }
137        return sb.toString();
138    }
139
140    /**
141     * Notifies user about result of a finished plugin download task.
142     * @param parent The parent component
143     * @param task The finished plugin download task
144     * @param restartRequired true if a restart is required
145     * @since 6797
146     */
147    public static void notifyDownloadResults(final Component parent, PluginDownloadTask task, boolean restartRequired) {
148        final Collection<PluginInformation> failed = task.getFailedPlugins();
149        final StringBuilder sb = new StringBuilder();
150        sb.append("<html>")
151          .append(buildDownloadSummary(task));
152        if (restartRequired) {
153            sb.append(tr("Please restart JOSM to activate the downloaded plugins."));
154        }
155        sb.append("</html>");
156        GuiHelper.runInEDTAndWait(() -> HelpAwareOptionPane.showOptionDialog(
157                parent,
158                sb.toString(),
159                tr("Update plugins"),
160                !failed.isEmpty() ? JOptionPane.WARNING_MESSAGE : JOptionPane.INFORMATION_MESSAGE,
161                        HelpUtil.ht("/Preferences/Plugins")
162                ));
163    }
164
165    private JPanel buildSearchFieldPanel() {
166        JPanel pnl = new JPanel(new GridBagLayout());
167        pnl.add(GBC.glue(0, 0));
168
169        ButtonGroup bg = new ButtonGroup();
170        JPanel radios = new JPanel();
171        addRadioButton(bg, radios, new JRadioButton(trc("plugins", "All"), true), PluginInstallation.ALL);
172        addRadioButton(bg, radios, new JRadioButton(trc("plugins", "Installed")), PluginInstallation.INSTALLED);
173        addRadioButton(bg, radios, new JRadioButton(trc("plugins", "Available")), PluginInstallation.AVAILABLE);
174        pnl.add(radios, GBC.eol().fill(HORIZONTAL));
175
176        pnl.add(new FilterField().filter(expr -> {
177            model.filterDisplayedPlugins(expr);
178            pnlPluginPreferences.refreshView();
179        }), GBC.eol().insets(0, 0, 0, 5).fill(HORIZONTAL));
180        return pnl;
181    }
182
183    private void addRadioButton(ButtonGroup bg, JPanel pnl, JRadioButton rb, PluginInstallation value) {
184        bg.add(rb);
185        pnl.add(rb, GBC.std());
186        rb.addActionListener(e -> {
187            model.filterDisplayedPlugins(value);
188            pnlPluginPreferences.refreshView();
189        });
190    }
191
192    private static Component addButton(JPanel pnl, JButton button, String buttonName) {
193        button.setName(buttonName);
194        return pnl.add(button);
195    }
196
197    private JPanel buildActionPanel() {
198        JPanel pnl = new JPanel(new GridLayout(1, 4));
199
200        // assign some component names to these as we go to aid testing
201        addButton(pnl, new JButton(new DownloadAvailablePluginsAction()), "downloadListButton");
202        addButton(pnl, new JButton(new UpdateSelectedPluginsAction()), "updatePluginsButton");
203        ExpertToggleAction.addVisibilitySwitcher(addButton(pnl, new JButton(new SelectByListAction()), "loadFromListButton"));
204        ExpertToggleAction.addVisibilitySwitcher(addButton(pnl, new JButton(new ConfigureSitesAction()), "configureSitesButton"));
205        return pnl;
206    }
207
208    private JPanel buildPluginListPanel() {
209        JPanel pnl = new JPanel(new BorderLayout());
210        pnl.add(buildSearchFieldPanel(), BorderLayout.NORTH);
211        model = new PluginPreferencesModel();
212        pnlPluginPreferences = new PluginListPanel(model);
213        spPluginPreferences = GuiHelper.embedInVerticalScrollPane(pnlPluginPreferences);
214        spPluginPreferences.getVerticalScrollBar().addComponentListener(
215                new ComponentAdapter() {
216                    @Override
217                    public void componentShown(ComponentEvent e) {
218                        spPluginPreferences.setBorder(UIManager.getBorder("ScrollPane.border"));
219                    }
220
221                    @Override
222                    public void componentHidden(ComponentEvent e) {
223                        spPluginPreferences.setBorder(null);
224                    }
225                }
226                );
227
228        pnl.add(spPluginPreferences, BorderLayout.CENTER);
229        pnl.add(buildActionPanel(), BorderLayout.SOUTH);
230        return pnl;
231    }
232
233    @Override
234    public void addGui(final PreferenceTabbedPane gui) {
235        JTabbedPane pane = getTabPane();
236        pnlPluginUpdatePolicy = new PluginUpdatePolicyPanel();
237        pane.addTab(tr("Plugins"), buildPluginListPanel());
238        pane.addTab(tr("Plugin update policy"), pnlPluginUpdatePolicy);
239        super.addGui(gui);
240        readLocalPluginInformation();
241        pluginPreferencesActivated = true;
242    }
243
244    private void configureSites() {
245        ButtonSpec[] options = {
246                new ButtonSpec(
247                        tr("OK"),
248                        new ImageProvider("ok"),
249                        tr("Accept the new plugin sites and close the dialog"),
250                        null /* no special help topic */
251                        ),
252                        new ButtonSpec(
253                                tr("Cancel"),
254                                new ImageProvider("cancel"),
255                                tr("Close the dialog"),
256                                null /* no special help topic */
257                                )
258        };
259        PluginConfigurationSitesPanel pnl = new PluginConfigurationSitesPanel();
260
261        int answer = HelpAwareOptionPane.showOptionDialog(
262                pnlPluginPreferences,
263                pnl,
264                tr("Configure Plugin Sites"),
265                JOptionPane.QUESTION_MESSAGE,
266                null,
267                options,
268                options[0],
269                null /* no help topic */
270                );
271        if (answer != 0 /* OK */)
272            return;
273        Preferences.main().setPluginSites(pnl.getUpdateSites());
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            Config.getPref().putList("plugins", l);
303            List<PluginInformation> deactivatedPlugins = model.getNewlyDeactivatedPlugins();
304            if (!deactivatedPlugins.isEmpty()) {
305                boolean requiresRestart = PluginHandler.removePlugins(deactivatedPlugins);
306                if (requiresRestart)
307                    return requiresRestart;
308            }
309            return model.getNewlyActivatedPlugins().stream().anyMatch(pi -> !pi.canloadatruntime);
310        }
311        return false;
312    }
313
314    /**
315     * Reads locally available information about plugins from the local file system.
316     * Scans cached plugin lists from plugin download sites and locally available
317     * plugin jar files.
318     *
319     */
320    public void readLocalPluginInformation() {
321        final ReadLocalPluginInformationTask task = new ReadLocalPluginInformationTask();
322        Runnable r = () -> {
323            if (!task.isCanceled()) {
324                SwingUtilities.invokeLater(() -> {
325                    model.setAvailablePlugins(task.getAvailablePlugins());
326                    pnlPluginPreferences.resetDisplayedComponents();
327                    pnlPluginPreferences.refreshView();
328                });
329            }
330        };
331        MainApplication.worker.submit(task);
332        MainApplication.worker.submit(r);
333    }
334
335    /**
336     * The action for downloading the list of available plugins
337     */
338    class DownloadAvailablePluginsAction extends AbstractAction {
339
340        /**
341         * Constructs a new {@code DownloadAvailablePluginsAction}.
342         */
343        DownloadAvailablePluginsAction() {
344            putValue(NAME, tr("Download list"));
345            putValue(SHORT_DESCRIPTION, tr("Download the list of available plugins"));
346            new ImageProvider("download").getResource().attachImageIcon(this);
347        }
348
349        @Override
350        public void actionPerformed(ActionEvent e) {
351            Collection<String> pluginSites = Preferences.main().getOnlinePluginSites();
352            if (pluginSites.isEmpty()) {
353                return;
354            }
355            final ReadRemotePluginInformationTask task = new ReadRemotePluginInformationTask(pluginSites);
356            Runnable continuation = () -> {
357                if (!task.isCanceled()) {
358                    SwingUtilities.invokeLater(() -> {
359                        model.updateAvailablePlugins(task.getAvailablePlugins());
360                        pnlPluginPreferences.resetDisplayedComponents();
361                        pnlPluginPreferences.refreshView();
362                        Config.getPref().putInt("pluginmanager.version", Version.getInstance().getVersion()); // fix #7030
363                    });
364                }
365            };
366            MainApplication.worker.submit(task);
367            MainApplication.worker.submit(continuation);
368        }
369    }
370
371    /**
372     * The action for updating the list of selected plugins
373     */
374    class UpdateSelectedPluginsAction extends AbstractAction {
375        UpdateSelectedPluginsAction() {
376            putValue(NAME, tr("Update plugins"));
377            putValue(SHORT_DESCRIPTION, tr("Update the selected plugins"));
378            new ImageProvider("dialogs", "refresh").getResource().attachImageIcon(this);
379        }
380
381        protected void alertNothingToUpdate() {
382            try {
383                SwingUtilities.invokeAndWait(() -> HelpAwareOptionPane.showOptionDialog(
384                        pnlPluginPreferences,
385                        tr("All installed plugins are up to date. JOSM does not have to download newer versions."),
386                        tr("Plugins up to date"),
387                        JOptionPane.INFORMATION_MESSAGE,
388                        null // FIXME: provide help context
389                        ));
390            } catch (InterruptedException | InvocationTargetException e) {
391                Logging.error(e);
392            }
393        }
394
395        @Override
396        public void actionPerformed(ActionEvent e) {
397            final List<PluginInformation> toUpdate = model.getSelectedPlugins();
398            // the async task for downloading plugins
399            final PluginDownloadTask pluginDownloadTask = new PluginDownloadTask(
400                    pnlPluginPreferences,
401                    toUpdate,
402                    tr("Update plugins")
403                    );
404            // the async task for downloading plugin information
405            final ReadRemotePluginInformationTask pluginInfoDownloadTask = new ReadRemotePluginInformationTask(
406                    Preferences.main().getOnlinePluginSites());
407
408            // to be run asynchronously after the plugin download
409            //
410            final Runnable pluginDownloadContinuation = () -> {
411                if (pluginDownloadTask.isCanceled())
412                    return;
413                boolean restartRequired = pluginDownloadTask.getDownloadedPlugins().stream()
414                        .anyMatch(pi -> !(model.getNewlyActivatedPlugins().contains(pi) && pi.canloadatruntime));
415                notifyDownloadResults(pnlPluginPreferences, pluginDownloadTask, restartRequired);
416                model.refreshLocalPluginVersion(pluginDownloadTask.getDownloadedPlugins());
417                model.clearPendingPlugins(pluginDownloadTask.getDownloadedPlugins());
418                GuiHelper.runInEDT(pnlPluginPreferences::refreshView);
419            };
420
421            // to be run asynchronously after the plugin list download
422            //
423            final Runnable pluginInfoDownloadContinuation = () -> {
424                if (pluginInfoDownloadTask.isCanceled())
425                    return;
426                model.updateAvailablePlugins(pluginInfoDownloadTask.getAvailablePlugins());
427                // select plugins which actually have to be updated
428                //
429                toUpdate.removeIf(pi -> !pi.isUpdateRequired());
430                if (toUpdate.isEmpty()) {
431                    alertNothingToUpdate();
432                    return;
433                }
434                pluginDownloadTask.setPluginsToDownload(toUpdate);
435                MainApplication.worker.submit(pluginDownloadTask);
436                MainApplication.worker.submit(pluginDownloadContinuation);
437            };
438
439            MainApplication.worker.submit(pluginInfoDownloadTask);
440            MainApplication.worker.submit(pluginInfoDownloadContinuation);
441        }
442    }
443
444    /**
445     * The action for configuring the plugin download sites
446     *
447     */
448    class ConfigureSitesAction extends AbstractAction {
449        ConfigureSitesAction() {
450            putValue(NAME, tr("Configure sites..."));
451            putValue(SHORT_DESCRIPTION, tr("Configure the list of sites where plugins are downloaded from"));
452            new ImageProvider("preference").getResource().attachImageIcon(this);
453        }
454
455        @Override
456        public void actionPerformed(ActionEvent e) {
457            configureSites();
458        }
459    }
460
461    /**
462     * The action for selecting the plugins given by a text file compatible to JOSM bug report.
463     * @author Michael Zangl
464     */
465    class SelectByListAction extends AbstractAction {
466        SelectByListAction() {
467            putValue(NAME, tr("Load from list..."));
468            putValue(SHORT_DESCRIPTION, tr("Load plugins from a list of plugins"));
469            new ImageProvider("misc/statusreport").getResource().attachImageIcon(this);
470        }
471
472        @Override
473        public void actionPerformed(ActionEvent e) {
474            JTextArea textField = new JTextArea(10, 0);
475            JCheckBox deleteNotInList = new JCheckBox(tr("Disable all other plugins"));
476
477            JLabel helpLabel = new JLabel("<html>" + String.join("<br/>",
478                    tr("Enter a list of plugins you want to download."),
479                    tr("You should add one plugin id per line, version information is ignored."),
480                    tr("You can copy+paste the list of a status report here.")) + "</html>");
481
482            if (JOptionPane.OK_OPTION == JOptionPane.showConfirmDialog(GuiHelper.getFrameForComponent(getTabPane()),
483                    new Object[] {helpLabel, new JScrollPane(textField), deleteNotInList},
484                    tr("Load plugins from list"), JOptionPane.OK_CANCEL_OPTION, JOptionPane.PLAIN_MESSAGE)) {
485                activatePlugins(textField, deleteNotInList.isSelected());
486            }
487        }
488
489        private void activatePlugins(JTextArea textField, boolean deleteNotInList) {
490            String[] lines = textField.getText().split("\n", -1);
491            List<String> toActivate = new ArrayList<>();
492            List<String> notFound = new ArrayList<>();
493            // This pattern matches the default list format JOSM uses for bug reports.
494            // It removes a list item mark at the beginning of the line: +, -, *
495            // It removes the version number after the plugin, like: 123, (123), (v5.7alpha3), (1b3), (v1-SNAPSHOT-1)...
496            Pattern regex = Pattern.compile("^[-+\\*\\s]*|\\s[\\d\\s]*(\\([^\\(\\)\\[\\]]*\\))?[\\d\\s]*$");
497            for (String line : lines) {
498                String name = regex.matcher(line).replaceAll("");
499                if (name.isEmpty()) {
500                    continue;
501                }
502                PluginInformation plugin = model.getPluginInformation(name);
503                if (plugin == null) {
504                    notFound.add(name);
505                } else {
506                    toActivate.add(name);
507                }
508            }
509
510            if (notFound.isEmpty() || confirmIgnoreNotFound(notFound)) {
511                activatePlugins(toActivate, deleteNotInList);
512            }
513        }
514
515        private void activatePlugins(List<String> toActivate, boolean deleteNotInList) {
516            if (deleteNotInList) {
517                for (String name : model.getSelectedPluginNames()) {
518                    if (!toActivate.contains(name)) {
519                        model.setPluginSelected(name, false);
520                    }
521                }
522            }
523            for (String name : toActivate) {
524                model.setPluginSelected(name, true);
525            }
526            pnlPluginPreferences.refreshView();
527        }
528
529        private boolean confirmIgnoreNotFound(List<String> notFound) {
530            String list = "<ul><li>" + String.join("</li><li>", notFound) + "</li></ul>";
531            String message = "<html>" + tr("The following plugins were not found. Continue anyway?") + list + "</html>";
532            return JOptionPane.showConfirmDialog(GuiHelper.getFrameForComponent(getTabPane()),
533                    message) == JOptionPane.OK_OPTION;
534        }
535    }
536
537    private static class PluginConfigurationSitesPanel extends JPanel {
538
539        private final DefaultListModel<String> model = new DefaultListModel<>();
540
541        PluginConfigurationSitesPanel() {
542            super(new GridBagLayout());
543            add(new JLabel(tr("Add JOSM Plugin description URL.")), GBC.eol());
544            for (String s : Preferences.main().getPluginSites()) {
545                model.addElement(s);
546            }
547            final JList<String> list = new JList<>(model);
548            add(new JScrollPane(list), GBC.std().fill());
549            JPanel buttons = new JPanel(new GridBagLayout());
550            buttons.add(new JButton(new AbstractAction(tr("Add")) {
551                @Override
552                public void actionPerformed(ActionEvent e) {
553                    String s = JOptionPane.showInputDialog(
554                            GuiHelper.getFrameForComponent(PluginConfigurationSitesPanel.this),
555                            tr("Add JOSM Plugin description URL."),
556                            tr("Enter URL"),
557                            JOptionPane.QUESTION_MESSAGE
558                            );
559                    if (!Utils.isEmpty(s)) {
560                        model.addElement(s);
561                    }
562                }
563            }), GBC.eol().fill(HORIZONTAL));
564            buttons.add(new JButton(new AbstractAction(tr("Edit")) {
565                @Override
566                public void actionPerformed(ActionEvent e) {
567                    if (list.getSelectedValue() == null) {
568                        JOptionPane.showMessageDialog(
569                                GuiHelper.getFrameForComponent(PluginConfigurationSitesPanel.this),
570                                tr("Please select an entry."),
571                                tr("Warning"),
572                                JOptionPane.WARNING_MESSAGE
573                                );
574                        return;
575                    }
576                    String s = (String) JOptionPane.showInputDialog(
577                            MainApplication.getMainFrame(),
578                            tr("Edit JOSM Plugin description URL."),
579                            tr("JOSM Plugin description URL"),
580                            JOptionPane.QUESTION_MESSAGE,
581                            null,
582                            null,
583                            list.getSelectedValue()
584                            );
585                    if (!Utils.isEmpty(s)) {
586                        model.setElementAt(s, list.getSelectedIndex());
587                    }
588                }
589            }), GBC.eol().fill(HORIZONTAL));
590            buttons.add(new JButton(new AbstractAction(tr("Delete")) {
591                @Override
592                public void actionPerformed(ActionEvent event) {
593                    if (list.getSelectedValue() == null) {
594                        JOptionPane.showMessageDialog(
595                                GuiHelper.getFrameForComponent(PluginConfigurationSitesPanel.this),
596                                tr("Please select an entry."),
597                                tr("Warning"),
598                                JOptionPane.WARNING_MESSAGE
599                                );
600                        return;
601                    }
602                    model.removeElement(list.getSelectedValue());
603                }
604            }), GBC.eol().fill(HORIZONTAL));
605            add(buttons, GBC.eol());
606        }
607
608        protected List<String> getUpdateSites() {
609            if (model.getSize() == 0)
610                return Collections.emptyList();
611            return IntStream.range(0, model.getSize())
612                    .mapToObj(model::get)
613                    .collect(Collectors.toList());
614        }
615    }
616
617    @Override
618    public String getHelpContext() {
619        return HelpUtil.ht("/Preferences/Plugins");
620    }
621}