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