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