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