001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.preferences;
003
004import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
005import static org.openstreetmap.josm.tools.I18n.tr;
006
007import java.awt.Component;
008import java.awt.Dimension;
009import java.awt.Font;
010import java.awt.GridBagConstraints;
011import java.awt.GridBagLayout;
012import java.awt.Insets;
013import java.awt.Rectangle;
014import java.awt.event.ActionEvent;
015import java.awt.event.FocusAdapter;
016import java.awt.event.FocusEvent;
017import java.awt.event.KeyEvent;
018import java.awt.event.MouseAdapter;
019import java.awt.event.MouseEvent;
020import java.io.BufferedReader;
021import java.io.File;
022import java.io.IOException;
023import java.net.MalformedURLException;
024import java.net.URL;
025import java.util.ArrayList;
026import java.util.Arrays;
027import java.util.Collection;
028import java.util.Collections;
029import java.util.EventObject;
030import java.util.HashMap;
031import java.util.Iterator;
032import java.util.List;
033import java.util.Map;
034import java.util.Objects;
035import java.util.concurrent.CopyOnWriteArrayList;
036import java.util.regex.Matcher;
037import java.util.regex.Pattern;
038
039import javax.swing.AbstractAction;
040import javax.swing.BorderFactory;
041import javax.swing.Box;
042import javax.swing.DefaultListModel;
043import javax.swing.DefaultListSelectionModel;
044import javax.swing.ImageIcon;
045import javax.swing.JButton;
046import javax.swing.JCheckBox;
047import javax.swing.JComponent;
048import javax.swing.JFileChooser;
049import javax.swing.JLabel;
050import javax.swing.JList;
051import javax.swing.JOptionPane;
052import javax.swing.JPanel;
053import javax.swing.JScrollPane;
054import javax.swing.JSeparator;
055import javax.swing.JTable;
056import javax.swing.JToolBar;
057import javax.swing.KeyStroke;
058import javax.swing.ListCellRenderer;
059import javax.swing.ListSelectionModel;
060import javax.swing.event.CellEditorListener;
061import javax.swing.event.ChangeEvent;
062import javax.swing.event.DocumentEvent;
063import javax.swing.event.DocumentListener;
064import javax.swing.event.ListSelectionEvent;
065import javax.swing.event.ListSelectionListener;
066import javax.swing.event.TableModelEvent;
067import javax.swing.event.TableModelListener;
068import javax.swing.filechooser.FileFilter;
069import javax.swing.table.AbstractTableModel;
070import javax.swing.table.DefaultTableCellRenderer;
071import javax.swing.table.TableCellEditor;
072import javax.swing.table.TableModel;
073
074import org.openstreetmap.josm.Main;
075import org.openstreetmap.josm.actions.ExtensionFileFilter;
076import org.openstreetmap.josm.data.Version;
077import org.openstreetmap.josm.data.preferences.sources.ExtendedSourceEntry;
078import org.openstreetmap.josm.data.preferences.sources.SourceEntry;
079import org.openstreetmap.josm.data.preferences.sources.SourcePrefHelper;
080import org.openstreetmap.josm.data.preferences.sources.SourceProvider;
081import org.openstreetmap.josm.data.preferences.sources.SourceType;
082import org.openstreetmap.josm.gui.ExtendedDialog;
083import org.openstreetmap.josm.gui.HelpAwareOptionPane;
084import org.openstreetmap.josm.gui.MainApplication;
085import org.openstreetmap.josm.gui.PleaseWaitRunnable;
086import org.openstreetmap.josm.gui.util.FileFilterAllFiles;
087import org.openstreetmap.josm.gui.util.GuiHelper;
088import org.openstreetmap.josm.gui.util.TableHelper;
089import org.openstreetmap.josm.gui.widgets.AbstractFileChooser;
090import org.openstreetmap.josm.gui.widgets.FileChooserManager;
091import org.openstreetmap.josm.gui.widgets.JosmTextField;
092import org.openstreetmap.josm.io.CachedFile;
093import org.openstreetmap.josm.io.OnlineResource;
094import org.openstreetmap.josm.io.OsmTransferException;
095import org.openstreetmap.josm.spi.preferences.Config;
096import org.openstreetmap.josm.tools.GBC;
097import org.openstreetmap.josm.tools.ImageOverlay;
098import org.openstreetmap.josm.tools.ImageProvider;
099import org.openstreetmap.josm.tools.ImageProvider.ImageSizes;
100import org.openstreetmap.josm.tools.LanguageInfo;
101import org.openstreetmap.josm.tools.Logging;
102import org.openstreetmap.josm.tools.Utils;
103import org.xml.sax.SAXException;
104
105/**
106 * Editor for JOSM extensions source entries.
107 * @since 1743
108 */
109public abstract class SourceEditor extends JPanel {
110
111    /** the type of source entry **/
112    protected final SourceType sourceType;
113    /** determines if the entry type can be enabled (set as active) **/
114    protected final boolean canEnable;
115
116    /** the table of active sources **/
117    protected final JTable tblActiveSources;
118    /** the underlying model of active sources **/
119    protected final ActiveSourcesModel activeSourcesModel;
120    /** the list of available sources **/
121    protected final JList<ExtendedSourceEntry> lstAvailableSources;
122    /** the underlying model of available sources **/
123    protected final AvailableSourcesListModel availableSourcesModel;
124    /** the URL from which the available sources are fetched **/
125    protected final String availableSourcesUrl;
126    /** the list of source providers **/
127    protected final transient List<SourceProvider> sourceProviders;
128
129    private JTable tblIconPaths;
130    private IconPathTableModel iconPathsModel;
131
132    /** determines if the source providers have been initially loaded **/
133    protected boolean sourcesInitiallyLoaded;
134
135    /**
136     * Constructs a new {@code SourceEditor}.
137     * @param sourceType the type of source managed by this editor
138     * @param availableSourcesUrl the URL to the list of available sources
139     * @param sourceProviders the list of additional source providers, from plugins
140     * @param handleIcons {@code true} if icons may be managed, {@code false} otherwise
141     */
142    public SourceEditor(SourceType sourceType, String availableSourcesUrl, List<SourceProvider> sourceProviders, boolean handleIcons) {
143
144        this.sourceType = sourceType;
145        this.canEnable = sourceType.equals(SourceType.MAP_PAINT_STYLE) || sourceType.equals(SourceType.TAGCHECKER_RULE);
146
147        DefaultListSelectionModel selectionModel = new DefaultListSelectionModel();
148        this.availableSourcesModel = new AvailableSourcesListModel(selectionModel);
149        this.lstAvailableSources = new JList<>(availableSourcesModel);
150        this.lstAvailableSources.setSelectionModel(selectionModel);
151        final SourceEntryListCellRenderer listCellRenderer = new SourceEntryListCellRenderer();
152        this.lstAvailableSources.setCellRenderer(listCellRenderer);
153        GuiHelper.extendTooltipDelay(lstAvailableSources);
154        this.availableSourcesUrl = availableSourcesUrl;
155        this.sourceProviders = sourceProviders;
156
157        selectionModel = new DefaultListSelectionModel();
158        activeSourcesModel = new ActiveSourcesModel(selectionModel);
159        tblActiveSources = new ScrollHackTable(activeSourcesModel);
160        tblActiveSources.putClientProperty("terminateEditOnFocusLost", Boolean.TRUE);
161        tblActiveSources.setSelectionModel(selectionModel);
162        tblActiveSources.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
163        tblActiveSources.setShowGrid(false);
164        tblActiveSources.setIntercellSpacing(new Dimension(0, 0));
165        tblActiveSources.setTableHeader(null);
166        tblActiveSources.setAutoResizeMode(JTable.AUTO_RESIZE_OFF);
167        SourceEntryTableCellRenderer sourceEntryRenderer = new SourceEntryTableCellRenderer();
168        if (canEnable) {
169            tblActiveSources.getColumnModel().getColumn(0).setMaxWidth(1);
170            tblActiveSources.getColumnModel().getColumn(0).setResizable(false);
171            tblActiveSources.getColumnModel().getColumn(1).setCellRenderer(sourceEntryRenderer);
172        } else {
173            tblActiveSources.getColumnModel().getColumn(0).setCellRenderer(sourceEntryRenderer);
174        }
175
176        activeSourcesModel.addTableModelListener(e -> {
177            listCellRenderer.updateSources(activeSourcesModel.getSources());
178            lstAvailableSources.repaint();
179        });
180        tblActiveSources.addPropertyChangeListener(evt -> {
181            listCellRenderer.updateSources(activeSourcesModel.getSources());
182            lstAvailableSources.repaint();
183        });
184        // Force Swing to show horizontal scrollbars for the JTable
185        // Yes, this is a little ugly, but should work
186        activeSourcesModel.addTableModelListener(e -> TableHelper.adjustColumnWidth(tblActiveSources, canEnable ? 1 : 0, 800));
187        activeSourcesModel.setActiveSources(getInitialSourcesList());
188
189        final EditActiveSourceAction editActiveSourceAction = new EditActiveSourceAction();
190        tblActiveSources.getSelectionModel().addListSelectionListener(editActiveSourceAction);
191        tblActiveSources.addMouseListener(new MouseAdapter() {
192            @Override
193            public void mouseClicked(MouseEvent e) {
194                if (e.getClickCount() == 2) {
195                    int row = tblActiveSources.rowAtPoint(e.getPoint());
196                    int col = tblActiveSources.columnAtPoint(e.getPoint());
197                    if (row < 0 || row >= tblActiveSources.getRowCount())
198                        return;
199                    if (canEnable && col != 1)
200                        return;
201                    editActiveSourceAction.actionPerformed(null);
202                }
203            }
204        });
205
206        RemoveActiveSourcesAction removeActiveSourcesAction = new RemoveActiveSourcesAction();
207        tblActiveSources.getSelectionModel().addListSelectionListener(removeActiveSourcesAction);
208        tblActiveSources.getInputMap(JComponent.WHEN_FOCUSED).put(KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, 0), "delete");
209        tblActiveSources.getActionMap().put("delete", removeActiveSourcesAction);
210
211        MoveUpDownAction moveUp = null;
212        MoveUpDownAction moveDown = null;
213        if (sourceType.equals(SourceType.MAP_PAINT_STYLE)) {
214            moveUp = new MoveUpDownAction(false);
215            moveDown = new MoveUpDownAction(true);
216            tblActiveSources.getSelectionModel().addListSelectionListener(moveUp);
217            tblActiveSources.getSelectionModel().addListSelectionListener(moveDown);
218            activeSourcesModel.addTableModelListener(moveUp);
219            activeSourcesModel.addTableModelListener(moveDown);
220        }
221
222        ActivateSourcesAction activateSourcesAction = new ActivateSourcesAction();
223        lstAvailableSources.addListSelectionListener(activateSourcesAction);
224        JButton activate = new JButton(activateSourcesAction);
225
226        setBorder(BorderFactory.createEmptyBorder(0, 0, 0, 0));
227        setLayout(new GridBagLayout());
228
229        GridBagConstraints gbc = new GridBagConstraints();
230        gbc.gridx = 0;
231        gbc.gridy = 0;
232        gbc.weightx = 0.5;
233        gbc.gridwidth = 2;
234        gbc.anchor = GBC.WEST;
235        gbc.insets = new Insets(5, 11, 0, 0);
236
237        add(new JLabel(getStr(I18nString.AVAILABLE_SOURCES)), gbc);
238
239        gbc.gridx = 2;
240        gbc.insets = new Insets(5, 0, 0, 6);
241
242        add(new JLabel(getStr(I18nString.ACTIVE_SOURCES)), gbc);
243
244        gbc.gridwidth = 1;
245        gbc.gridx = 0;
246        gbc.gridy++;
247        gbc.weighty = 0.8;
248        gbc.fill = GBC.BOTH;
249        gbc.anchor = GBC.CENTER;
250        gbc.insets = new Insets(0, 11, 0, 0);
251
252        JScrollPane sp1 = new JScrollPane(lstAvailableSources);
253        add(sp1, gbc);
254
255        gbc.gridx = 1;
256        gbc.weightx = 0.0;
257        gbc.fill = GBC.VERTICAL;
258        gbc.insets = new Insets(0, 0, 0, 0);
259
260        JToolBar middleTB = new JToolBar();
261        middleTB.setFloatable(false);
262        middleTB.setBorderPainted(false);
263        middleTB.setOpaque(false);
264        middleTB.add(Box.createHorizontalGlue());
265        middleTB.add(activate);
266        middleTB.add(Box.createHorizontalGlue());
267        add(middleTB, gbc);
268
269        gbc.gridx++;
270        gbc.weightx = 0.5;
271        gbc.fill = GBC.BOTH;
272
273        JScrollPane sp = new JScrollPane(tblActiveSources);
274        add(sp, gbc);
275        sp.setColumnHeaderView(null);
276
277        gbc.gridx++;
278        gbc.weightx = 0.0;
279        gbc.fill = GBC.VERTICAL;
280        gbc.insets = new Insets(0, 0, 0, 6);
281
282        JToolBar sideButtonTB = new JToolBar(JToolBar.VERTICAL);
283        sideButtonTB.setFloatable(false);
284        sideButtonTB.setBorderPainted(false);
285        sideButtonTB.setOpaque(false);
286        sideButtonTB.add(new NewActiveSourceAction());
287        sideButtonTB.add(editActiveSourceAction);
288        sideButtonTB.add(removeActiveSourcesAction);
289        sideButtonTB.addSeparator(new Dimension(12, 30));
290        if (sourceType.equals(SourceType.MAP_PAINT_STYLE)) {
291            sideButtonTB.add(moveUp);
292            sideButtonTB.add(moveDown);
293        }
294        add(sideButtonTB, gbc);
295
296        gbc.gridx = 0;
297        gbc.gridy++;
298        gbc.weighty = 0.0;
299        gbc.weightx = 0.5;
300        gbc.fill = GBC.HORIZONTAL;
301        gbc.anchor = GBC.WEST;
302        gbc.insets = new Insets(0, 11, 0, 0);
303
304        JToolBar bottomLeftTB = new JToolBar();
305        bottomLeftTB.setFloatable(false);
306        bottomLeftTB.setBorderPainted(false);
307        bottomLeftTB.setOpaque(false);
308        bottomLeftTB.add(new ReloadSourcesAction(availableSourcesUrl, sourceProviders));
309        bottomLeftTB.add(Box.createHorizontalGlue());
310        add(bottomLeftTB, gbc);
311
312        gbc.gridx = 2;
313        gbc.anchor = GBC.CENTER;
314        gbc.insets = new Insets(0, 0, 0, 0);
315
316        JToolBar bottomRightTB = new JToolBar();
317        bottomRightTB.setFloatable(false);
318        bottomRightTB.setBorderPainted(false);
319        bottomRightTB.setOpaque(false);
320        bottomRightTB.add(Box.createHorizontalGlue());
321        bottomRightTB.add(new JButton(new ResetAction()));
322        add(bottomRightTB, gbc);
323
324        // Icon configuration
325        if (handleIcons) {
326            buildIcons(gbc);
327        }
328    }
329
330    private void buildIcons(GridBagConstraints gbc) {
331        DefaultListSelectionModel selectionModel = new DefaultListSelectionModel();
332        iconPathsModel = new IconPathTableModel(selectionModel);
333        tblIconPaths = new JTable(iconPathsModel);
334        tblIconPaths.setSelectionModel(selectionModel);
335        tblIconPaths.setSelectionMode(ListSelectionModel.MULTIPLE_INTERVAL_SELECTION);
336        tblIconPaths.setTableHeader(null);
337        tblIconPaths.getColumnModel().getColumn(0).setCellEditor(new FileOrUrlCellEditor(false));
338        tblIconPaths.setRowHeight(20);
339        tblIconPaths.putClientProperty("terminateEditOnFocusLost", Boolean.TRUE);
340        iconPathsModel.setIconPaths(getInitialIconPathsList());
341
342        EditIconPathAction editIconPathAction = new EditIconPathAction();
343        tblIconPaths.getSelectionModel().addListSelectionListener(editIconPathAction);
344
345        RemoveIconPathAction removeIconPathAction = new RemoveIconPathAction();
346        tblIconPaths.getSelectionModel().addListSelectionListener(removeIconPathAction);
347        tblIconPaths.getInputMap(JComponent.WHEN_FOCUSED).put(KeyStroke.getKeyStroke(KeyEvent.VK_DELETE, 0), "delete");
348        tblIconPaths.getActionMap().put("delete", removeIconPathAction);
349
350        gbc.gridx = 0;
351        gbc.gridy++;
352        gbc.weightx = 1.0;
353        gbc.gridwidth = GBC.REMAINDER;
354        gbc.insets = new Insets(8, 11, 8, 6);
355
356        add(new JSeparator(), gbc);
357
358        gbc.gridy++;
359        gbc.insets = new Insets(0, 11, 0, 6);
360
361        add(new JLabel(tr("Icon paths:")), gbc);
362
363        gbc.gridy++;
364        gbc.weighty = 0.2;
365        gbc.gridwidth = 3;
366        gbc.fill = GBC.BOTH;
367        gbc.insets = new Insets(0, 11, 0, 0);
368
369        JScrollPane sp = new JScrollPane(tblIconPaths);
370        add(sp, gbc);
371        sp.setColumnHeaderView(null);
372
373        gbc.gridx = 3;
374        gbc.gridwidth = 1;
375        gbc.weightx = 0.0;
376        gbc.fill = GBC.VERTICAL;
377        gbc.insets = new Insets(0, 0, 0, 6);
378
379        JToolBar sideButtonTBIcons = new JToolBar(JToolBar.VERTICAL);
380        sideButtonTBIcons.setFloatable(false);
381        sideButtonTBIcons.setBorderPainted(false);
382        sideButtonTBIcons.setOpaque(false);
383        sideButtonTBIcons.add(new NewIconPathAction());
384        sideButtonTBIcons.add(editIconPathAction);
385        sideButtonTBIcons.add(removeIconPathAction);
386        add(sideButtonTBIcons, gbc);
387    }
388
389    /**
390     * Load the list of source entries that the user has configured.
391     * @return list of source entries that the user has configured
392     */
393    public abstract Collection<? extends SourceEntry> getInitialSourcesList();
394
395    /**
396     * Load the list of configured icon paths.
397     * @return list of configured icon paths
398     */
399    public abstract Collection<String> getInitialIconPathsList();
400
401    /**
402     * Get the default list of entries (used when resetting the list).
403     * @return default list of entries
404     */
405    public abstract Collection<ExtendedSourceEntry> getDefault();
406
407    /**
408     * Save the settings after user clicked "Ok".
409     * @return true if restart is required
410     */
411    public abstract boolean finish();
412
413    /**
414     * Default implementation of {@link #finish}.
415     * @param prefHelper Helper class for specialized extensions preferences
416     * @param iconPref icons path preference
417     * @return true if restart is required
418     */
419    protected boolean doFinish(SourcePrefHelper prefHelper, String iconPref) {
420        boolean changed = prefHelper.put(activeSourcesModel.getSources());
421
422        if (tblIconPaths != null) {
423            List<String> iconPaths = iconPathsModel.getIconPaths();
424
425            if (!iconPaths.isEmpty()) {
426                if (Config.getPref().putList(iconPref, iconPaths)) {
427                    changed = true;
428                }
429            } else if (Config.getPref().putList(iconPref, null)) {
430                changed = true;
431            }
432        }
433        return changed;
434    }
435
436    /**
437     * Provide the GUI strings. (There are differences for MapPaint, Preset and TagChecker Rule)
438     * @param ident any {@link I18nString} value
439     * @return the translated string for {@code ident}
440     */
441    protected abstract String getStr(I18nString ident);
442
443    static final class ScrollHackTable extends JTable {
444        ScrollHackTable(TableModel dm) {
445            super(dm);
446        }
447
448        // some kind of hack to prevent the table from scrolling slightly to the right when clicking on the text
449        @Override
450        public void scrollRectToVisible(Rectangle aRect) {
451            super.scrollRectToVisible(new Rectangle(0, aRect.y, aRect.width, aRect.height));
452        }
453    }
454
455    /**
456     * Identifiers for strings that need to be provided.
457     */
458    public enum I18nString {
459        /** Available (styles|presets|rules) */
460        AVAILABLE_SOURCES,
461        /** Active (styles|presets|rules) */
462        ACTIVE_SOURCES,
463        /** Add a new (style|preset|rule) by entering filename or URL */
464        NEW_SOURCE_ENTRY_TOOLTIP,
465        /** New (style|preset|rule) entry */
466        NEW_SOURCE_ENTRY,
467        /** Remove the selected (styles|presets|rules) from the list of active (styles|presets|rules) */
468        REMOVE_SOURCE_TOOLTIP,
469        /** Edit the filename or URL for the selected active (style|preset|rule) */
470        EDIT_SOURCE_TOOLTIP,
471        /** Add the selected available (styles|presets|rules) to the list of active (styles|presets|rules) */
472        ACTIVATE_TOOLTIP,
473        /** Reloads the list of available (styles|presets|rules) */
474        RELOAD_ALL_AVAILABLE,
475        /** Loading (style|preset|rule) sources */
476        LOADING_SOURCES_FROM,
477        /** Failed to load the list of (style|preset|rule) sources */
478        FAILED_TO_LOAD_SOURCES_FROM,
479        /** /Preferences/(Styles|Presets|Rules)#FailedToLoad(Style|Preset|Rule)Sources */
480        FAILED_TO_LOAD_SOURCES_FROM_HELP_TOPIC,
481        /** Illegal format of entry in (style|preset|rule) list */
482        ILLEGAL_FORMAT_OF_ENTRY
483    }
484
485    /**
486     * Determines whether the list of active sources has changed.
487     * @return {@code true} if the list of active sources has changed, {@code false} otherwise
488     */
489    public boolean hasActiveSourcesChanged() {
490        Collection<? extends SourceEntry> prev = getInitialSourcesList();
491        List<SourceEntry> cur = activeSourcesModel.getSources();
492        if (prev.size() != cur.size())
493            return true;
494        Iterator<? extends SourceEntry> p = prev.iterator();
495        Iterator<SourceEntry> c = cur.iterator();
496        while (p.hasNext()) {
497            SourceEntry pe = p.next();
498            SourceEntry ce = c.next();
499            if (!Objects.equals(pe.url, ce.url) || !Objects.equals(pe.name, ce.name) || pe.active != ce.active)
500                return true;
501        }
502        return false;
503    }
504
505    /**
506     * Returns the list of active sources.
507     * @return the list of active sources
508     */
509    public Collection<SourceEntry> getActiveSources() {
510        return activeSourcesModel.getSources();
511    }
512
513    /**
514     * Synchronously loads available sources and returns the parsed list.
515     * @return list of available sources
516     * @throws OsmTransferException in case of OSM transfer error
517     * @throws IOException in case of any I/O error
518     * @throws SAXException in case of any SAX error
519     */
520    public final Collection<ExtendedSourceEntry> loadAndGetAvailableSources() throws SAXException, IOException, OsmTransferException {
521        final SourceLoader loader = new SourceLoader(availableSourcesUrl, sourceProviders);
522        loader.realRun();
523        return loader.sources;
524    }
525
526    /**
527     * Remove sources associated with given indexes from active list.
528     * @param idxs indexes of sources to remove
529     */
530    public void removeSources(Collection<Integer> idxs) {
531        activeSourcesModel.removeIdxs(idxs);
532    }
533
534    /**
535     * Reload available sources.
536     * @param url the URL from which the available sources are fetched
537     * @param sourceProviders the list of source providers
538     */
539    protected void reloadAvailableSources(String url, List<SourceProvider> sourceProviders) {
540        MainApplication.worker.submit(new SourceLoader(url, sourceProviders));
541    }
542
543    /**
544     * Performs the initial loading of source providers. Does nothing if already done.
545     */
546    public void initiallyLoadAvailableSources() {
547        if (!sourcesInitiallyLoaded) {
548            reloadAvailableSources(availableSourcesUrl, sourceProviders);
549        }
550        sourcesInitiallyLoaded = true;
551    }
552
553    /**
554     * List model of available sources.
555     */
556    protected static class AvailableSourcesListModel extends DefaultListModel<ExtendedSourceEntry> {
557        private final transient List<ExtendedSourceEntry> data;
558        private final DefaultListSelectionModel selectionModel;
559
560        /**
561         * Constructs a new {@code AvailableSourcesListModel}
562         * @param selectionModel selection model
563         */
564        public AvailableSourcesListModel(DefaultListSelectionModel selectionModel) {
565            data = new ArrayList<>();
566            this.selectionModel = selectionModel;
567        }
568
569        /**
570         * Sets the source list.
571         * @param sources source list
572         */
573        public void setSources(List<ExtendedSourceEntry> sources) {
574            data.clear();
575            if (sources != null) {
576                data.addAll(sources);
577            }
578            fireContentsChanged(this, 0, data.size());
579        }
580
581        @Override
582        public ExtendedSourceEntry getElementAt(int index) {
583            return data.get(index);
584        }
585
586        @Override
587        public int getSize() {
588            if (data == null) return 0;
589            return data.size();
590        }
591
592        /**
593         * Deletes the selected sources.
594         */
595        public void deleteSelected() {
596            Iterator<ExtendedSourceEntry> it = data.iterator();
597            int i = 0;
598            while (it.hasNext()) {
599                it.next();
600                if (selectionModel.isSelectedIndex(i)) {
601                    it.remove();
602                }
603                i++;
604            }
605            fireContentsChanged(this, 0, data.size());
606        }
607
608        /**
609         * Returns the selected sources.
610         * @return the selected sources
611         */
612        public List<ExtendedSourceEntry> getSelected() {
613            List<ExtendedSourceEntry> ret = new ArrayList<>();
614            for (int i = 0; i < data.size(); i++) {
615                if (selectionModel.isSelectedIndex(i)) {
616                    ret.add(data.get(i));
617                }
618            }
619            return ret;
620        }
621    }
622
623    /**
624     * Table model of active sources.
625     */
626    protected class ActiveSourcesModel extends AbstractTableModel {
627        private transient List<SourceEntry> data;
628        private final DefaultListSelectionModel selectionModel;
629
630        /**
631         * Constructs a new {@code ActiveSourcesModel}.
632         * @param selectionModel selection model
633         */
634        public ActiveSourcesModel(DefaultListSelectionModel selectionModel) {
635            this.selectionModel = selectionModel;
636            this.data = new ArrayList<>();
637        }
638
639        @Override
640        public int getColumnCount() {
641            return canEnable ? 2 : 1;
642        }
643
644        @Override
645        public int getRowCount() {
646            return data == null ? 0 : data.size();
647        }
648
649        @Override
650        public Object getValueAt(int rowIndex, int columnIndex) {
651            if (canEnable && columnIndex == 0)
652                return data.get(rowIndex).active;
653            else
654                return data.get(rowIndex);
655        }
656
657        @Override
658        public boolean isCellEditable(int rowIndex, int columnIndex) {
659            return canEnable && columnIndex == 0;
660        }
661
662        @Override
663        public Class<?> getColumnClass(int column) {
664            if (canEnable && column == 0)
665                return Boolean.class;
666            else return SourceEntry.class;
667        }
668
669        @Override
670        public void setValueAt(Object aValue, int row, int column) {
671            if (row < 0 || row >= getRowCount() || aValue == null)
672                return;
673            if (canEnable && column == 0) {
674                data.get(row).active = !data.get(row).active;
675            }
676        }
677
678        /**
679         * Sets active sources.
680         * @param sources active sources
681         */
682        public void setActiveSources(Collection<? extends SourceEntry> sources) {
683            data.clear();
684            if (sources != null) {
685                for (SourceEntry e : sources) {
686                    data.add(new SourceEntry(e));
687                }
688            }
689            fireTableDataChanged();
690        }
691
692        /**
693         * Adds an active source.
694         * @param entry source to add
695         */
696        public void addSource(SourceEntry entry) {
697            if (entry == null) return;
698            data.add(entry);
699            fireTableDataChanged();
700            int idx = data.indexOf(entry);
701            if (idx >= 0) {
702                selectionModel.setSelectionInterval(idx, idx);
703            }
704        }
705
706        /**
707         * Removes the selected sources.
708         */
709        public void removeSelected() {
710            Iterator<SourceEntry> it = data.iterator();
711            int i = 0;
712            while (it.hasNext()) {
713                it.next();
714                if (selectionModel.isSelectedIndex(i)) {
715                    it.remove();
716                }
717                i++;
718            }
719            fireTableDataChanged();
720        }
721
722        /**
723         * Removes the sources at given indexes.
724         * @param idxs indexes to remove
725         */
726        public void removeIdxs(Collection<Integer> idxs) {
727            List<SourceEntry> newData = new ArrayList<>();
728            for (int i = 0; i < data.size(); ++i) {
729                if (!idxs.contains(i)) {
730                    newData.add(data.get(i));
731                }
732            }
733            data = newData;
734            fireTableDataChanged();
735        }
736
737        /**
738         * Adds multiple sources.
739         * @param sources source entries
740         */
741        public void addExtendedSourceEntries(List<ExtendedSourceEntry> sources) {
742            if (sources == null) return;
743            for (ExtendedSourceEntry info: sources) {
744                data.add(new SourceEntry(info.type, info.url, info.name, info.getDisplayName(), true));
745            }
746            fireTableDataChanged();
747            selectionModel.setValueIsAdjusting(true);
748            selectionModel.clearSelection();
749            for (ExtendedSourceEntry info: sources) {
750                int pos = data.indexOf(info);
751                if (pos >= 0) {
752                    selectionModel.addSelectionInterval(pos, pos);
753                }
754            }
755            selectionModel.setValueIsAdjusting(false);
756        }
757
758        /**
759         * Returns the active sources.
760         * @return the active sources
761         */
762        public List<SourceEntry> getSources() {
763            return new ArrayList<>(data);
764        }
765
766        public boolean canMove(int i) {
767            int[] sel = tblActiveSources.getSelectedRows();
768            if (sel.length == 0)
769                return false;
770            if (i < 0)
771                return sel[0] >= -i;
772                else if (i > 0)
773                    return sel[sel.length-1] <= getRowCount()-1 - i;
774                else
775                    return true;
776        }
777
778        public void move(int i) {
779            if (!canMove(i)) return;
780            int[] sel = tblActiveSources.getSelectedRows();
781            for (int row: sel) {
782                SourceEntry t1 = data.get(row);
783                SourceEntry t2 = data.get(row + i);
784                data.set(row, t2);
785                data.set(row + i, t1);
786            }
787            selectionModel.setValueIsAdjusting(true);
788            selectionModel.clearSelection();
789            for (int row: sel) {
790                selectionModel.addSelectionInterval(row + i, row + i);
791            }
792            selectionModel.setValueIsAdjusting(false);
793        }
794    }
795
796    private static void prepareFileChooser(String url, AbstractFileChooser fc) {
797        if (url == null || url.trim().isEmpty()) return;
798        URL sourceUrl = null;
799        try {
800            sourceUrl = new URL(url);
801        } catch (MalformedURLException e) {
802            File f = new File(url);
803            if (f.isFile()) {
804                f = f.getParentFile();
805            }
806            if (f != null) {
807                fc.setCurrentDirectory(f);
808            }
809            return;
810        }
811        if (sourceUrl.getProtocol().startsWith("file")) {
812            File f = new File(sourceUrl.getPath());
813            if (f.isFile()) {
814                f = f.getParentFile();
815            }
816            if (f != null) {
817                fc.setCurrentDirectory(f);
818            }
819        }
820    }
821
822    /**
823     * Dialog to edit a source entry.
824     */
825    protected class EditSourceEntryDialog extends ExtendedDialog {
826
827        private final JosmTextField tfTitle;
828        private final JosmTextField tfURL;
829        private JCheckBox cbActive;
830
831        /**
832         * Constructs a new {@code EditSourceEntryDialog}.
833         * @param parent parent component
834         * @param title dialog title
835         * @param e source entry to edit
836         */
837        public EditSourceEntryDialog(Component parent, String title, SourceEntry e) {
838            super(parent, title, tr("Ok"), tr("Cancel"));
839
840            JPanel p = new JPanel(new GridBagLayout());
841
842            tfTitle = new JosmTextField(60);
843            p.add(new JLabel(tr("Name (optional):")), GBC.std().insets(15, 0, 5, 5));
844            p.add(tfTitle, GBC.eol().insets(0, 0, 5, 5));
845
846            tfURL = new JosmTextField(60);
847            p.add(new JLabel(tr("URL / File:")), GBC.std().insets(15, 0, 5, 0));
848            p.add(tfURL, GBC.std().insets(0, 0, 5, 5));
849            JButton fileChooser = new JButton(new LaunchFileChooserAction());
850            fileChooser.setMargin(new Insets(0, 0, 0, 0));
851            p.add(fileChooser, GBC.eol().insets(0, 0, 5, 5));
852
853            if (e != null) {
854                if (e.title != null) {
855                    tfTitle.setText(e.title);
856                }
857                tfURL.setText(e.url);
858            }
859
860            if (canEnable) {
861                cbActive = new JCheckBox(tr("active"), e == null || e.active);
862                p.add(cbActive, GBC.eol().insets(15, 0, 5, 0));
863            }
864            setButtonIcons("ok", "cancel");
865            setContent(p);
866
867            // Make OK button enabled only when a file/URL has been set
868            tfURL.getDocument().addDocumentListener(new DocumentListener() {
869                @Override
870                public void insertUpdate(DocumentEvent e) {
871                    updateOkButtonState();
872                }
873
874                @Override
875                public void removeUpdate(DocumentEvent e) {
876                    updateOkButtonState();
877                }
878
879                @Override
880                public void changedUpdate(DocumentEvent e) {
881                    updateOkButtonState();
882                }
883            });
884        }
885
886        private void updateOkButtonState() {
887            buttons.get(0).setEnabled(!Utils.isStripEmpty(tfURL.getText()));
888        }
889
890        @Override
891        public void setupDialog() {
892            super.setupDialog();
893            updateOkButtonState();
894        }
895
896        class LaunchFileChooserAction extends AbstractAction {
897            LaunchFileChooserAction() {
898                new ImageProvider("open").getResource().attachImageIcon(this);
899                putValue(SHORT_DESCRIPTION, tr("Launch a file chooser to select a file"));
900            }
901
902            @Override
903            public void actionPerformed(ActionEvent e) {
904                FileFilter ff;
905                switch (sourceType) {
906                case MAP_PAINT_STYLE:
907                    ff = new ExtensionFileFilter("xml,mapcss,css,zip", "xml", tr("Map paint style file (*.xml, *.mapcss, *.zip)"));
908                    break;
909                case TAGGING_PRESET:
910                    ff = new ExtensionFileFilter("xml,zip", "xml", tr("Preset definition file (*.xml, *.zip)"));
911                    break;
912                case TAGCHECKER_RULE:
913                    ff = new ExtensionFileFilter("validator.mapcss,zip", "validator.mapcss", tr("Tag checker rule (*.validator.mapcss, *.zip)"));
914                    break;
915                default:
916                    Logging.error("Unsupported source type: "+sourceType);
917                    return;
918                }
919                FileChooserManager fcm = new FileChooserManager(true)
920                        .createFileChooser(true, null, Arrays.asList(ff, FileFilterAllFiles.getInstance()), ff, JFileChooser.FILES_ONLY);
921                prepareFileChooser(tfURL.getText(), fcm.getFileChooser());
922                AbstractFileChooser fc = fcm.openFileChooser(GuiHelper.getFrameForComponent(SourceEditor.this));
923                if (fc != null) {
924                    tfURL.setText(fc.getSelectedFile().toString());
925                }
926            }
927        }
928
929        @Override
930        public String getTitle() {
931            return tfTitle.getText();
932        }
933
934        /**
935         * Returns the entered URL / File.
936         * @return the entered URL / File
937         */
938        public String getURL() {
939            return tfURL.getText();
940        }
941
942        /**
943         * Determines if the active combobox is selected.
944         * @return {@code true} if the active combobox is selected
945         */
946        public boolean active() {
947            if (!canEnable)
948                throw new UnsupportedOperationException();
949            return cbActive.isSelected();
950        }
951    }
952
953    class NewActiveSourceAction extends AbstractAction {
954        NewActiveSourceAction() {
955            putValue(NAME, tr("New"));
956            putValue(SHORT_DESCRIPTION, getStr(I18nString.NEW_SOURCE_ENTRY_TOOLTIP));
957            new ImageProvider("dialogs", "add").getResource().attachImageIcon(this);
958        }
959
960        @Override
961        public void actionPerformed(ActionEvent evt) {
962            EditSourceEntryDialog editEntryDialog = new EditSourceEntryDialog(
963                    SourceEditor.this,
964                    getStr(I18nString.NEW_SOURCE_ENTRY),
965                    null);
966            editEntryDialog.showDialog();
967            if (editEntryDialog.getValue() == 1) {
968                boolean active = true;
969                if (canEnable) {
970                    active = editEntryDialog.active();
971                }
972                final SourceEntry entry = new SourceEntry(sourceType,
973                        editEntryDialog.getURL(),
974                        null, editEntryDialog.getTitle(), active);
975                entry.title = getTitleForSourceEntry(entry);
976                activeSourcesModel.addSource(entry);
977                activeSourcesModel.fireTableDataChanged();
978            }
979        }
980    }
981
982    class RemoveActiveSourcesAction extends AbstractAction implements ListSelectionListener {
983
984        RemoveActiveSourcesAction() {
985            putValue(NAME, tr("Remove"));
986            putValue(SHORT_DESCRIPTION, getStr(I18nString.REMOVE_SOURCE_TOOLTIP));
987            new ImageProvider("dialogs", "delete").getResource().attachImageIcon(this);
988            updateEnabledState();
989        }
990
991        protected final void updateEnabledState() {
992            setEnabled(tblActiveSources.getSelectedRowCount() > 0);
993        }
994
995        @Override
996        public void valueChanged(ListSelectionEvent e) {
997            updateEnabledState();
998        }
999
1000        @Override
1001        public void actionPerformed(ActionEvent e) {
1002            activeSourcesModel.removeSelected();
1003        }
1004    }
1005
1006    class EditActiveSourceAction extends AbstractAction implements ListSelectionListener {
1007        EditActiveSourceAction() {
1008            putValue(NAME, tr("Edit"));
1009            putValue(SHORT_DESCRIPTION, getStr(I18nString.EDIT_SOURCE_TOOLTIP));
1010            new ImageProvider("dialogs", "edit").getResource().attachImageIcon(this);
1011            updateEnabledState();
1012        }
1013
1014        protected final void updateEnabledState() {
1015            setEnabled(tblActiveSources.getSelectedRowCount() == 1);
1016        }
1017
1018        @Override
1019        public void valueChanged(ListSelectionEvent e) {
1020            updateEnabledState();
1021        }
1022
1023        @Override
1024        public void actionPerformed(ActionEvent evt) {
1025            int pos = tblActiveSources.getSelectedRow();
1026            if (pos < 0 || pos >= tblActiveSources.getRowCount())
1027                return;
1028
1029            SourceEntry e = (SourceEntry) activeSourcesModel.getValueAt(pos, 1);
1030
1031            EditSourceEntryDialog editEntryDialog = new EditSourceEntryDialog(
1032                    SourceEditor.this, tr("Edit source entry:"), e);
1033            editEntryDialog.showDialog();
1034            if (editEntryDialog.getValue() == 1) {
1035                if (e.title != null || !"".equals(editEntryDialog.getTitle())) {
1036                    e.title = editEntryDialog.getTitle();
1037                    e.title = getTitleForSourceEntry(e);
1038                }
1039                e.url = editEntryDialog.getURL();
1040                if (canEnable) {
1041                    e.active = editEntryDialog.active();
1042                }
1043                activeSourcesModel.fireTableRowsUpdated(pos, pos);
1044            }
1045        }
1046    }
1047
1048    /**
1049     * The action to move the currently selected entries up or down in the list.
1050     */
1051    class MoveUpDownAction extends AbstractAction implements ListSelectionListener, TableModelListener {
1052        private final int increment;
1053
1054        MoveUpDownAction(boolean isDown) {
1055            increment = isDown ? 1 : -1;
1056            new ImageProvider("dialogs", isDown ? "down" : "up").getResource().attachImageIcon(this, true);
1057            putValue(SHORT_DESCRIPTION, isDown ? tr("Move the selected entry one row down.") : tr("Move the selected entry one row up."));
1058            updateEnabledState();
1059        }
1060
1061        public final void updateEnabledState() {
1062            setEnabled(activeSourcesModel.canMove(increment));
1063        }
1064
1065        @Override
1066        public void actionPerformed(ActionEvent e) {
1067            activeSourcesModel.move(increment);
1068        }
1069
1070        @Override
1071        public void valueChanged(ListSelectionEvent e) {
1072            updateEnabledState();
1073        }
1074
1075        @Override
1076        public void tableChanged(TableModelEvent e) {
1077            updateEnabledState();
1078        }
1079    }
1080
1081    class ActivateSourcesAction extends AbstractAction implements ListSelectionListener {
1082        ActivateSourcesAction() {
1083            putValue(SHORT_DESCRIPTION, getStr(I18nString.ACTIVATE_TOOLTIP));
1084            new ImageProvider("preferences", "activate-right").getResource().attachImageIcon(this);
1085            updateEnabledState();
1086        }
1087
1088        protected final void updateEnabledState() {
1089            setEnabled(lstAvailableSources.getSelectedIndices().length > 0);
1090        }
1091
1092        @Override
1093        public void valueChanged(ListSelectionEvent e) {
1094            updateEnabledState();
1095        }
1096
1097        @Override
1098        public void actionPerformed(ActionEvent e) {
1099            List<ExtendedSourceEntry> sources = availableSourcesModel.getSelected();
1100            int josmVersion = Version.getInstance().getVersion();
1101            if (josmVersion != Version.JOSM_UNKNOWN_VERSION) {
1102                Collection<String> messages = new ArrayList<>();
1103                for (ExtendedSourceEntry entry : sources) {
1104                    if (entry.minJosmVersion != null && entry.minJosmVersion > josmVersion) {
1105                        messages.add(tr("Entry ''{0}'' requires JOSM Version {1}. (Currently running: {2})",
1106                                entry.title,
1107                                Integer.toString(entry.minJosmVersion),
1108                                Integer.toString(josmVersion))
1109                        );
1110                    }
1111                }
1112                if (!messages.isEmpty()) {
1113                    ExtendedDialog dlg = new ExtendedDialog(Main.parent, tr("Warning"), tr("Cancel"), tr("Continue anyway"));
1114                    dlg.setButtonIcons(
1115                        ImageProvider.get("cancel"),
1116                        new ImageProvider("ok").setMaxSize(ImageSizes.LARGEICON).addOverlay(
1117                                new ImageOverlay(new ImageProvider("warning-small"), 0.5, 0.5, 1.0, 1.0)).get()
1118                    );
1119                    dlg.setToolTipTexts(
1120                        tr("Cancel and return to the previous dialog"),
1121                        tr("Ignore warning and install style anyway"));
1122                    dlg.setContent("<html>" + tr("Some entries have unmet dependencies:") +
1123                            "<br>" + Utils.join("<br>", messages) + "</html>");
1124                    dlg.setIcon(JOptionPane.WARNING_MESSAGE);
1125                    if (dlg.showDialog().getValue() != 2)
1126                        return;
1127                }
1128            }
1129            activeSourcesModel.addExtendedSourceEntries(sources);
1130        }
1131    }
1132
1133    class ResetAction extends AbstractAction {
1134
1135        ResetAction() {
1136            putValue(NAME, tr("Reset"));
1137            putValue(SHORT_DESCRIPTION, tr("Reset to default"));
1138            new ImageProvider("preferences", "reset").getResource().attachImageIcon(this);
1139        }
1140
1141        @Override
1142        public void actionPerformed(ActionEvent e) {
1143            activeSourcesModel.setActiveSources(getDefault());
1144        }
1145    }
1146
1147    class ReloadSourcesAction extends AbstractAction {
1148        private final String url;
1149        private final transient List<SourceProvider> sourceProviders;
1150
1151        ReloadSourcesAction(String url, List<SourceProvider> sourceProviders) {
1152            putValue(NAME, tr("Reload"));
1153            putValue(SHORT_DESCRIPTION, tr(getStr(I18nString.RELOAD_ALL_AVAILABLE), url));
1154            new ImageProvider("dialogs", "refresh").getResource().attachImageIcon(this);
1155            this.url = url;
1156            this.sourceProviders = sourceProviders;
1157            setEnabled(!Main.isOffline(OnlineResource.JOSM_WEBSITE));
1158        }
1159
1160        @Override
1161        public void actionPerformed(ActionEvent e) {
1162            CachedFile.cleanup(url);
1163            reloadAvailableSources(url, sourceProviders);
1164        }
1165    }
1166
1167    /**
1168     * Table model for icons paths.
1169     */
1170    protected static class IconPathTableModel extends AbstractTableModel {
1171        private final List<String> data;
1172        private final DefaultListSelectionModel selectionModel;
1173
1174        /**
1175         * Constructs a new {@code IconPathTableModel}.
1176         * @param selectionModel selection model
1177         */
1178        public IconPathTableModel(DefaultListSelectionModel selectionModel) {
1179            this.selectionModel = selectionModel;
1180            this.data = new ArrayList<>();
1181        }
1182
1183        @Override
1184        public int getColumnCount() {
1185            return 1;
1186        }
1187
1188        @Override
1189        public int getRowCount() {
1190            return data == null ? 0 : data.size();
1191        }
1192
1193        @Override
1194        public Object getValueAt(int rowIndex, int columnIndex) {
1195            return data.get(rowIndex);
1196        }
1197
1198        @Override
1199        public boolean isCellEditable(int rowIndex, int columnIndex) {
1200            return true;
1201        }
1202
1203        @Override
1204        public void setValueAt(Object aValue, int rowIndex, int columnIndex) {
1205            updatePath(rowIndex, (String) aValue);
1206        }
1207
1208        /**
1209         * Sets the icons paths.
1210         * @param paths icons paths
1211         */
1212        public void setIconPaths(Collection<String> paths) {
1213            data.clear();
1214            if (paths != null) {
1215                data.addAll(paths);
1216            }
1217            sort();
1218            fireTableDataChanged();
1219        }
1220
1221        /**
1222         * Adds an icon path.
1223         * @param path icon path to add
1224         */
1225        public void addPath(String path) {
1226            if (path == null) return;
1227            data.add(path);
1228            sort();
1229            fireTableDataChanged();
1230            int idx = data.indexOf(path);
1231            if (idx >= 0) {
1232                selectionModel.setSelectionInterval(idx, idx);
1233            }
1234        }
1235
1236        /**
1237         * Updates icon path at given index.
1238         * @param pos position
1239         * @param path new path
1240         */
1241        public void updatePath(int pos, String path) {
1242            if (path == null) return;
1243            if (pos < 0 || pos >= getRowCount()) return;
1244            data.set(pos, path);
1245            sort();
1246            fireTableDataChanged();
1247            int idx = data.indexOf(path);
1248            if (idx >= 0) {
1249                selectionModel.setSelectionInterval(idx, idx);
1250            }
1251        }
1252
1253        /**
1254         * Removes the selected path.
1255         */
1256        public void removeSelected() {
1257            Iterator<String> it = data.iterator();
1258            int i = 0;
1259            while (it.hasNext()) {
1260                it.next();
1261                if (selectionModel.isSelectedIndex(i)) {
1262                    it.remove();
1263                }
1264                i++;
1265            }
1266            fireTableDataChanged();
1267            selectionModel.clearSelection();
1268        }
1269
1270        /**
1271         * Sorts paths lexicographically.
1272         */
1273        protected void sort() {
1274            data.sort((o1, o2) -> {
1275                    if (o1.isEmpty() && o2.isEmpty())
1276                        return 0;
1277                    if (o1.isEmpty()) return 1;
1278                    if (o2.isEmpty()) return -1;
1279                    return o1.compareTo(o2);
1280                });
1281        }
1282
1283        /**
1284         * Returns the icon paths.
1285         * @return the icon paths
1286         */
1287        public List<String> getIconPaths() {
1288            return new ArrayList<>(data);
1289        }
1290    }
1291
1292    class NewIconPathAction extends AbstractAction {
1293        NewIconPathAction() {
1294            putValue(NAME, tr("New"));
1295            putValue(SHORT_DESCRIPTION, tr("Add a new icon path"));
1296            new ImageProvider("dialogs", "add").getResource().attachImageIcon(this);
1297        }
1298
1299        @Override
1300        public void actionPerformed(ActionEvent e) {
1301            iconPathsModel.addPath("");
1302            tblIconPaths.editCellAt(iconPathsModel.getRowCount() -1, 0);
1303        }
1304    }
1305
1306    class RemoveIconPathAction extends AbstractAction implements ListSelectionListener {
1307        RemoveIconPathAction() {
1308            putValue(NAME, tr("Remove"));
1309            putValue(SHORT_DESCRIPTION, tr("Remove the selected icon paths"));
1310            new ImageProvider("dialogs", "delete").getResource().attachImageIcon(this);
1311            updateEnabledState();
1312        }
1313
1314        protected final void updateEnabledState() {
1315            setEnabled(tblIconPaths.getSelectedRowCount() > 0);
1316        }
1317
1318        @Override
1319        public void valueChanged(ListSelectionEvent e) {
1320            updateEnabledState();
1321        }
1322
1323        @Override
1324        public void actionPerformed(ActionEvent e) {
1325            iconPathsModel.removeSelected();
1326        }
1327    }
1328
1329    class EditIconPathAction extends AbstractAction implements ListSelectionListener {
1330        EditIconPathAction() {
1331            putValue(NAME, tr("Edit"));
1332            putValue(SHORT_DESCRIPTION, tr("Edit the selected icon path"));
1333            new ImageProvider("dialogs", "edit").getResource().attachImageIcon(this);
1334            updateEnabledState();
1335        }
1336
1337        protected final void updateEnabledState() {
1338            setEnabled(tblIconPaths.getSelectedRowCount() == 1);
1339        }
1340
1341        @Override
1342        public void valueChanged(ListSelectionEvent e) {
1343            updateEnabledState();
1344        }
1345
1346        @Override
1347        public void actionPerformed(ActionEvent e) {
1348            int row = tblIconPaths.getSelectedRow();
1349            tblIconPaths.editCellAt(row, 0);
1350        }
1351    }
1352
1353    static class SourceEntryListCellRenderer extends JLabel implements ListCellRenderer<ExtendedSourceEntry> {
1354
1355        private final ImageIcon GREEN_CHECK = ImageProvider.getIfAvailable("misc", "green_check");
1356        private final ImageIcon GRAY_CHECK = ImageProvider.getIfAvailable("misc", "gray_check");
1357        private final Map<String, SourceEntry> entryByUrl = new HashMap<>();
1358
1359        @Override
1360        public Component getListCellRendererComponent(JList<? extends ExtendedSourceEntry> list, ExtendedSourceEntry value,
1361                int index, boolean isSelected, boolean cellHasFocus) {
1362            String s = value.toString();
1363            setText(s);
1364            if (isSelected) {
1365                setBackground(list.getSelectionBackground());
1366                setForeground(list.getSelectionForeground());
1367            } else {
1368                setBackground(list.getBackground());
1369                setForeground(list.getForeground());
1370            }
1371            setEnabled(list.isEnabled());
1372            setFont(list.getFont());
1373            setFont(getFont().deriveFont(Font.PLAIN));
1374            setOpaque(true);
1375            setToolTipText(value.getTooltip());
1376            final SourceEntry sourceEntry = entryByUrl.get(value.url);
1377            setIcon(sourceEntry == null ? null : sourceEntry.active ? GREEN_CHECK : GRAY_CHECK);
1378            return this;
1379        }
1380
1381        public void updateSources(List<SourceEntry> sources) {
1382            synchronized (entryByUrl) {
1383                entryByUrl.clear();
1384                for (SourceEntry i : sources) {
1385                    entryByUrl.put(i.url, i);
1386                }
1387            }
1388        }
1389    }
1390
1391    class SourceLoader extends PleaseWaitRunnable {
1392        private final String url;
1393        private final List<SourceProvider> sourceProviders;
1394        private CachedFile cachedFile;
1395        private boolean canceled;
1396        private final List<ExtendedSourceEntry> sources = new ArrayList<>();
1397
1398        SourceLoader(String url, List<SourceProvider> sourceProviders) {
1399            super(tr(getStr(I18nString.LOADING_SOURCES_FROM), url));
1400            this.url = url;
1401            this.sourceProviders = sourceProviders;
1402        }
1403
1404        @Override
1405        protected void cancel() {
1406            canceled = true;
1407            Utils.close(cachedFile);
1408        }
1409
1410        protected void warn(Exception e) {
1411            String emsg = Utils.escapeReservedCharactersHTML(e.getMessage() != null ? e.getMessage() : e.toString());
1412            final String msg = tr(getStr(I18nString.FAILED_TO_LOAD_SOURCES_FROM), url, emsg);
1413
1414            GuiHelper.runInEDT(() -> HelpAwareOptionPane.showOptionDialog(
1415                    Main.parent,
1416                    msg,
1417                    tr("Error"),
1418                    JOptionPane.ERROR_MESSAGE,
1419                    ht(getStr(I18nString.FAILED_TO_LOAD_SOURCES_FROM_HELP_TOPIC))
1420                    ));
1421        }
1422
1423        @Override
1424        protected void realRun() throws SAXException, IOException, OsmTransferException {
1425            try {
1426                sources.addAll(getDefault());
1427
1428                for (SourceProvider provider : sourceProviders) {
1429                    for (SourceEntry src : provider.getSources()) {
1430                        if (src instanceof ExtendedSourceEntry) {
1431                            sources.add((ExtendedSourceEntry) src);
1432                        }
1433                    }
1434                }
1435                readFile();
1436                for (Iterator<ExtendedSourceEntry> it = sources.iterator(); it.hasNext();) {
1437                    if ("xml".equals(it.next().styleType)) {
1438                        Logging.debug("Removing XML source entry");
1439                        it.remove();
1440                    }
1441                }
1442            } catch (IOException e) {
1443                if (canceled)
1444                    // ignore the exception and return
1445                    return;
1446                OsmTransferException ex = new OsmTransferException(e);
1447                ex.setUrl(url);
1448                warn(ex);
1449            }
1450        }
1451
1452        protected void readFile() throws IOException {
1453            final String lang = LanguageInfo.getLanguageCodeXML();
1454            cachedFile = new CachedFile(url);
1455            try (BufferedReader reader = cachedFile.getContentReader()) {
1456
1457                String line;
1458                ExtendedSourceEntry last = null;
1459
1460                while ((line = reader.readLine()) != null && !canceled) {
1461                    if (line.trim().isEmpty()) {
1462                        continue; // skip empty lines
1463                    }
1464                    if (line.startsWith("\t")) {
1465                        Matcher m = Pattern.compile("^\t([^:]+): *(.+)$").matcher(line);
1466                        if (!m.matches()) {
1467                            Logging.error(tr(getStr(I18nString.ILLEGAL_FORMAT_OF_ENTRY), url, line));
1468                            continue;
1469                        }
1470                        if (last != null) {
1471                            String key = m.group(1);
1472                            String value = m.group(2);
1473                            if ("author".equals(key) && last.author == null) {
1474                                last.author = value;
1475                            } else if ("version".equals(key)) {
1476                                last.version = value;
1477                            } else if ("link".equals(key) && last.link == null) {
1478                                last.link = value;
1479                            } else if ("description".equals(key) && last.description == null) {
1480                                last.description = value;
1481                            } else if ((lang + "shortdescription").equals(key) && last.title == null) {
1482                                last.title = value;
1483                            } else if ("shortdescription".equals(key) && last.title == null) {
1484                                last.title = value;
1485                            } else if ((lang + "title").equals(key) && last.title == null) {
1486                                last.title = value;
1487                            } else if ("title".equals(key) && last.title == null) {
1488                                last.title = value;
1489                            } else if ("name".equals(key) && last.name == null) {
1490                                last.name = value;
1491                            } else if ((lang + "author").equals(key)) {
1492                                last.author = value;
1493                            } else if ((lang + "link").equals(key)) {
1494                                last.link = value;
1495                            } else if ((lang + "description").equals(key)) {
1496                                last.description = value;
1497                            } else if ("min-josm-version".equals(key)) {
1498                                try {
1499                                    last.minJosmVersion = Integer.valueOf(value);
1500                                } catch (NumberFormatException e) {
1501                                    // ignore
1502                                    Logging.trace(e);
1503                                }
1504                            } else if ("style-type".equals(key)) {
1505                                last.styleType = value;
1506                            }
1507                        }
1508                    } else {
1509                        last = null;
1510                        Matcher m = Pattern.compile("^(.+);(.+)$").matcher(line);
1511                        if (m.matches()) {
1512                            last = new ExtendedSourceEntry(sourceType, m.group(1), m.group(2));
1513                            sources.add(last);
1514                        } else {
1515                            Logging.error(tr(getStr(I18nString.ILLEGAL_FORMAT_OF_ENTRY), url, line));
1516                        }
1517                    }
1518                }
1519            }
1520        }
1521
1522        @Override
1523        protected void finish() {
1524            Collections.sort(sources);
1525            availableSourcesModel.setSources(sources);
1526        }
1527    }
1528
1529    static class SourceEntryTableCellRenderer extends DefaultTableCellRenderer {
1530        @Override
1531        public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected, boolean hasFocus, int row, int column) {
1532            if (value == null)
1533                return this;
1534            return super.getTableCellRendererComponent(table,
1535                    fromSourceEntry((SourceEntry) value), isSelected, hasFocus, row, column);
1536        }
1537
1538        private static String fromSourceEntry(SourceEntry entry) {
1539            if (entry == null)
1540                return null;
1541            StringBuilder s = new StringBuilder(128).append("<html><b>");
1542            if (entry.title != null) {
1543                s.append(Utils.escapeReservedCharactersHTML(entry.title)).append("</b> <span color=\"gray\">");
1544            }
1545            s.append(entry.url);
1546            if (entry.title != null) {
1547                s.append("</span>");
1548            }
1549            s.append("</html>");
1550            return s.toString();
1551        }
1552    }
1553
1554    class FileOrUrlCellEditor extends JPanel implements TableCellEditor {
1555        private final JosmTextField tfFileName = new JosmTextField();
1556        private final CopyOnWriteArrayList<CellEditorListener> listeners;
1557        private String value;
1558        private final boolean isFile;
1559
1560        /**
1561         * build the GUI
1562         */
1563        protected final void build() {
1564            setLayout(new GridBagLayout());
1565            GridBagConstraints gc = new GridBagConstraints();
1566            gc.gridx = 0;
1567            gc.gridy = 0;
1568            gc.fill = GridBagConstraints.BOTH;
1569            gc.weightx = 1.0;
1570            gc.weighty = 1.0;
1571            add(tfFileName, gc);
1572
1573            gc.gridx = 1;
1574            gc.gridy = 0;
1575            gc.fill = GridBagConstraints.BOTH;
1576            gc.weightx = 0.0;
1577            gc.weighty = 1.0;
1578            add(new JButton(new LaunchFileChooserAction()));
1579
1580            tfFileName.addFocusListener(
1581                    new FocusAdapter() {
1582                        @Override
1583                        public void focusGained(FocusEvent e) {
1584                            tfFileName.selectAll();
1585                        }
1586                    }
1587                    );
1588        }
1589
1590        FileOrUrlCellEditor(boolean isFile) {
1591            this.isFile = isFile;
1592            listeners = new CopyOnWriteArrayList<>();
1593            build();
1594        }
1595
1596        @Override
1597        public void addCellEditorListener(CellEditorListener l) {
1598            if (l != null) {
1599                listeners.addIfAbsent(l);
1600            }
1601        }
1602
1603        protected void fireEditingCanceled() {
1604            for (CellEditorListener l: listeners) {
1605                l.editingCanceled(new ChangeEvent(this));
1606            }
1607        }
1608
1609        protected void fireEditingStopped() {
1610            for (CellEditorListener l: listeners) {
1611                l.editingStopped(new ChangeEvent(this));
1612            }
1613        }
1614
1615        @Override
1616        public void cancelCellEditing() {
1617            fireEditingCanceled();
1618        }
1619
1620        @Override
1621        public Object getCellEditorValue() {
1622            return value;
1623        }
1624
1625        @Override
1626        public boolean isCellEditable(EventObject anEvent) {
1627            if (anEvent instanceof MouseEvent)
1628                return ((MouseEvent) anEvent).getClickCount() >= 2;
1629            return true;
1630        }
1631
1632        @Override
1633        public void removeCellEditorListener(CellEditorListener l) {
1634            listeners.remove(l);
1635        }
1636
1637        @Override
1638        public boolean shouldSelectCell(EventObject anEvent) {
1639            return true;
1640        }
1641
1642        @Override
1643        public boolean stopCellEditing() {
1644            value = tfFileName.getText();
1645            fireEditingStopped();
1646            return true;
1647        }
1648
1649        public void setInitialValue(String initialValue) {
1650            this.value = initialValue;
1651            if (initialValue == null) {
1652                this.tfFileName.setText("");
1653            } else {
1654                this.tfFileName.setText(initialValue);
1655            }
1656        }
1657
1658        @Override
1659        public Component getTableCellEditorComponent(JTable table, Object value, boolean isSelected, int row, int column) {
1660            setInitialValue((String) value);
1661            tfFileName.selectAll();
1662            return this;
1663        }
1664
1665        class LaunchFileChooserAction extends AbstractAction {
1666            LaunchFileChooserAction() {
1667                putValue(NAME, "...");
1668                putValue(SHORT_DESCRIPTION, tr("Launch a file chooser to select a file"));
1669            }
1670
1671            @Override
1672            public void actionPerformed(ActionEvent e) {
1673                FileChooserManager fcm = new FileChooserManager(true).createFileChooser();
1674                if (!isFile) {
1675                    fcm.getFileChooser().setFileSelectionMode(JFileChooser.DIRECTORIES_ONLY);
1676                }
1677                prepareFileChooser(tfFileName.getText(), fcm.getFileChooser());
1678                AbstractFileChooser fc = fcm.openFileChooser(GuiHelper.getFrameForComponent(SourceEditor.this));
1679                if (fc != null) {
1680                    tfFileName.setText(fc.getSelectedFile().toString());
1681                }
1682            }
1683        }
1684    }
1685
1686    /**
1687     * Defers loading of sources to the first time the adequate tab is selected.
1688     * @param tab The preferences tab
1689     * @param component The tab component
1690     * @since 6670
1691     */
1692    public final void deferLoading(final DefaultTabPreferenceSetting tab, final Component component) {
1693        tab.getTabPane().addChangeListener(e -> {
1694            if (tab.getTabPane().getSelectedComponent() == component) {
1695                initiallyLoadAvailableSources();
1696            }
1697        });
1698    }
1699
1700    /**
1701     * Returns the title of the given source entry.
1702     * @param entry source entry
1703     * @return the title of the given source entry, or null if empty
1704     */
1705    protected String getTitleForSourceEntry(SourceEntry entry) {
1706        return "".equals(entry.title) ? null : entry.title;
1707    }
1708}