001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.preferences.advanced;
003
004import static org.openstreetmap.josm.tools.I18n.marktr;
005import static org.openstreetmap.josm.tools.I18n.tr;
006
007import java.awt.Dimension;
008import java.awt.GridBagLayout;
009import java.awt.GridLayout;
010import java.awt.event.ActionEvent;
011import java.awt.event.ActionListener;
012import java.io.File;
013import java.io.IOException;
014import java.nio.file.InvalidPathException;
015import java.util.ArrayList;
016import java.util.Collections;
017import java.util.Comparator;
018import java.util.LinkedHashMap;
019import java.util.List;
020import java.util.Locale;
021import java.util.Map;
022import java.util.Map.Entry;
023import java.util.Objects;
024import java.util.regex.Pattern;
025
026import javax.swing.AbstractAction;
027import javax.swing.JButton;
028import javax.swing.JFileChooser;
029import javax.swing.JMenu;
030import javax.swing.JOptionPane;
031import javax.swing.JPanel;
032import javax.swing.JPopupMenu;
033import javax.swing.JScrollPane;
034import javax.swing.event.MenuEvent;
035import javax.swing.event.MenuListener;
036import javax.swing.filechooser.FileFilter;
037
038import org.openstreetmap.josm.actions.DiskAccessAction;
039import org.openstreetmap.josm.data.Preferences;
040import org.openstreetmap.josm.data.PreferencesUtils;
041import org.openstreetmap.josm.data.osm.DataSet;
042import org.openstreetmap.josm.gui.MainApplication;
043import org.openstreetmap.josm.gui.dialogs.LogShowDialog;
044import org.openstreetmap.josm.gui.help.HelpUtil;
045import org.openstreetmap.josm.gui.io.CustomConfigurator;
046import org.openstreetmap.josm.gui.layer.MainLayerManager;
047import org.openstreetmap.josm.gui.layer.OsmDataLayer;
048import org.openstreetmap.josm.gui.preferences.DefaultTabPreferenceSetting;
049import org.openstreetmap.josm.gui.preferences.PreferenceSetting;
050import org.openstreetmap.josm.gui.preferences.PreferenceSettingFactory;
051import org.openstreetmap.josm.gui.preferences.PreferenceTabbedPane;
052import org.openstreetmap.josm.gui.util.DocumentAdapter;
053import org.openstreetmap.josm.gui.util.GuiHelper;
054import org.openstreetmap.josm.gui.widgets.AbstractFileChooser;
055import org.openstreetmap.josm.gui.widgets.FilterField;
056import org.openstreetmap.josm.gui.widgets.JosmTextField;
057import org.openstreetmap.josm.spi.preferences.Config;
058import org.openstreetmap.josm.spi.preferences.Setting;
059import org.openstreetmap.josm.spi.preferences.StringSetting;
060import org.openstreetmap.josm.tools.GBC;
061import org.openstreetmap.josm.tools.ImageProvider;
062import org.openstreetmap.josm.tools.Logging;
063import org.openstreetmap.josm.tools.Territories;
064import org.openstreetmap.josm.tools.Utils;
065
066/**
067 * Advanced preferences, allowing to set preference entries directly.
068 */
069public final class AdvancedPreference extends DefaultTabPreferenceSetting {
070
071    /**
072     * Factory used to create a new {@code AdvancedPreference}.
073     */
074    public static class Factory implements PreferenceSettingFactory {
075        @Override
076        public PreferenceSetting createPreferenceSetting() {
077            return new AdvancedPreference();
078        }
079    }
080
081    private static class UnclearableOsmDataLayer extends OsmDataLayer {
082        UnclearableOsmDataLayer(DataSet data, String name) {
083            super(data, name, null);
084        }
085
086        @Override
087        public void clear() {
088            // Do nothing
089        }
090    }
091
092    /**
093     * Requires {@link Logging#isDebugEnabled()}, otherwise dataset is unloaded
094     * @see Territories#initializeInternalData()
095     */
096    private static final class EditBoundariesAction extends AbstractAction {
097        EditBoundariesAction() {
098            super(tr("Edit boundaries"), ImageProvider.get("dialogs/edit", ImageProvider.ImageSizes.MENU));
099        }
100
101        @Override
102        public void actionPerformed(ActionEvent ae) {
103            DataSet dataSet = Territories.getOriginalDataSet();
104            MainLayerManager layerManager = MainApplication.getLayerManager();
105            if (layerManager.getLayersOfType(OsmDataLayer.class).stream().noneMatch(l -> dataSet.equals(l.getDataSet()))) {
106                layerManager.addLayer(new UnclearableOsmDataLayer(dataSet, tr("Internal JOSM boundaries")));
107            }
108        }
109    }
110
111    private final class ResetPreferencesAction extends AbstractAction {
112        ResetPreferencesAction() {
113            super(tr("Reset preferences"), ImageProvider.get("undo", ImageProvider.ImageSizes.MENU));
114        }
115
116        @Override
117        public void actionPerformed(ActionEvent ae) {
118            if (!GuiHelper.warnUser(tr("Reset preferences"),
119                    "<html>"+
120                    tr("You are about to clear all preferences to their default values<br />"+
121                    "All your settings will be deleted: plugins, imagery, filters, toolbar buttons, keyboard, etc. <br />"+
122                    "Are you sure you want to continue?")
123                    +"</html>", null, "")) {
124                Preferences.main().resetToDefault();
125                try {
126                    Preferences.main().save();
127                } catch (IOException | InvalidPathException e) {
128                    Logging.log(Logging.LEVEL_WARN, "Exception while saving preferences:", e);
129                }
130                readPreferences(Preferences.main());
131                applyFilter();
132            }
133        }
134    }
135
136    private List<PrefEntry> allData;
137    private final List<PrefEntry> displayData = new ArrayList<>();
138    private JosmTextField txtFilter;
139    private PreferencesTable table;
140
141    private final Map<String, String> profileTypes = new LinkedHashMap<>();
142
143    private final Comparator<PrefEntry> customComparator = (o1, o2) -> {
144        if (o1.isChanged() && !o2.isChanged())
145            return -1;
146        if (o2.isChanged() && !o1.isChanged())
147            return 1;
148        if (!o1.isDefault() && o2.isDefault())
149            return -1;
150        if (!o2.isDefault() && o1.isDefault())
151            return 1;
152        return o1.compareTo(o2);
153    };
154
155    private AdvancedPreference() {
156        super(/* ICON(preferences/) */ "advanced", tr("Advanced Preferences"), tr("Setting Preference entries directly. Use with caution!"));
157    }
158
159    @Override
160    public boolean isExpert() {
161        return true;
162    }
163
164    @Override
165    public void addGui(final PreferenceTabbedPane gui) {
166        JPanel p = gui.createPreferenceTab(this);
167
168        final JPanel txtFilterPanel = new JPanel(new GridBagLayout());
169        p.add(txtFilterPanel, GBC.eol().fill(GBC.HORIZONTAL));
170        txtFilter = new FilterField();
171        txtFilterPanel.add(txtFilter, GBC.eol().insets(0, 0, 0, 5).fill(GBC.HORIZONTAL));
172        txtFilter.getDocument().addDocumentListener(DocumentAdapter.create(ignore -> applyFilter()));
173        readPreferences(Preferences.main());
174
175        applyFilter();
176        table = new PreferencesTable(displayData);
177        JScrollPane scroll = new JScrollPane(table);
178        p.add(scroll, GBC.eol().fill(GBC.BOTH));
179        scroll.setPreferredSize(new Dimension(400, 200));
180
181        JPanel buttonPanel = new JPanel(new GridLayout(1, 6));
182        JButton add = new JButton(tr("Add"), ImageProvider.get("dialogs/add", ImageProvider.ImageSizes.SMALLICON));
183        buttonPanel.add(add);
184        add.setToolTipText(add.getText());
185        add.addActionListener(e -> {
186            PrefEntry pe = table.addPreference(gui);
187            if (pe != null) {
188                allData.add(pe);
189                Collections.sort(allData);
190                applyFilter();
191            }
192        });
193
194        JButton edit = new JButton(tr("Edit"), ImageProvider.get("dialogs/edit", ImageProvider.ImageSizes.SMALLICON));
195        buttonPanel.add(edit);
196        edit.setToolTipText(edit.getText());
197        edit.addActionListener(e -> {
198            if (table.editPreference(gui))
199                applyFilter();
200        });
201        table.getSelectionModel().addListSelectionListener(event -> edit.setEnabled(table.getSelectedRowCount() == 1));
202
203        JButton reset = new JButton(tr("Reset"), ImageProvider.get("undo", ImageProvider.ImageSizes.SMALLICON));
204        buttonPanel.add(reset);
205        reset.setToolTipText(reset.getText());
206        reset.addActionListener(e -> table.resetPreferences(gui));
207        table.getSelectionModel().addListSelectionListener(event -> reset.setEnabled(table.getSelectedRowCount() > 0));
208
209        JButton read = new JButton(tr("Read from file"), ImageProvider.get("open", ImageProvider.ImageSizes.SMALLICON));
210        buttonPanel.add(read);
211        read.setToolTipText(read.getText());
212        read.addActionListener(e -> readPreferencesFromXML());
213
214        JButton export = new JButton(tr("Export selected items"), ImageProvider.get("save", ImageProvider.ImageSizes.SMALLICON));
215        buttonPanel.add(export);
216        export.setToolTipText(export.getText());
217        export.addActionListener(e -> exportSelectedToXML());
218
219        final JButton more = new JButton(tr("More..."));
220        buttonPanel.add(more);
221        more.setToolTipText(more.getText());
222        more.addActionListener(new ActionListener() {
223            private final JPopupMenu menu = buildPopupMenu();
224            @Override
225            public void actionPerformed(ActionEvent ev) {
226                if (more.isShowing()) {
227                    menu.show(more, 0, 0);
228                }
229            }
230        });
231        p.add(buttonPanel, GBC.eol());
232    }
233
234    private void readPreferences(Preferences tmpPrefs) {
235        Map<String, Setting<?>> loaded;
236        Map<String, Setting<?>> orig = Preferences.main().getAllSettings();
237        Map<String, Setting<?>> defaults = tmpPrefs.getAllDefaults();
238        orig.remove("osm-server.password");
239        defaults.remove("osm-server.password");
240        if (tmpPrefs != Preferences.main()) {
241            loaded = tmpPrefs.getAllSettings();
242            // plugins preference keys may be changed directly later, after plugins are downloaded
243            // so we do not want to show it in the table as "changed" now
244            Setting<?> pluginSetting = orig.get("plugins");
245            if (pluginSetting != null) {
246                loaded.put("plugins", pluginSetting);
247            }
248        } else {
249            loaded = orig;
250        }
251        allData = prepareData(loaded, orig, defaults);
252    }
253
254    private static File[] askUserForCustomSettingsFiles(boolean saveFileFlag, String title) {
255        FileFilter filter = new FileFilter() {
256            @Override
257            public boolean accept(File f) {
258                return f.isDirectory() || Utils.hasExtension(f, "xml");
259            }
260
261            @Override
262            public String getDescription() {
263                return tr("JOSM custom settings files (*.xml)");
264            }
265        };
266        AbstractFileChooser fc = DiskAccessAction.createAndOpenFileChooser(!saveFileFlag, !saveFileFlag, title, filter,
267                JFileChooser.FILES_ONLY, "customsettings.lastDirectory");
268        if (fc != null) {
269            File[] sel = fc.isMultiSelectionEnabled() ? fc.getSelectedFiles() : new File[]{fc.getSelectedFile()};
270            if (sel.length == 1 && !sel[0].getName().contains("."))
271                sel[0] = new File(sel[0].getAbsolutePath()+".xml");
272            return sel;
273        }
274        return new File[0];
275    }
276
277    private void exportSelectedToXML() {
278        List<String> keys = new ArrayList<>();
279        boolean hasLists = false;
280
281        for (PrefEntry p: table.getSelectedItems()) {
282            // preferences with default values are not saved
283            if (!(p.getValue() instanceof StringSetting)) {
284                hasLists = true; // => append and replace differs
285            }
286            if (!p.isDefault()) {
287                keys.add(p.getKey());
288            }
289        }
290
291        if (keys.isEmpty()) {
292            JOptionPane.showMessageDialog(MainApplication.getMainFrame(),
293                    tr("Please select some preference keys not marked as default"), tr("Warning"), JOptionPane.WARNING_MESSAGE);
294            return;
295        }
296
297        File[] files = askUserForCustomSettingsFiles(true, tr("Export preferences keys to JOSM customization file"));
298        if (files.length == 0) {
299            return;
300        }
301
302        int answer = 0;
303        if (hasLists) {
304            answer = JOptionPane.showOptionDialog(
305                    MainApplication.getMainFrame(), tr("What to do with preference lists when this file is to be imported?"), tr("Question"),
306                    JOptionPane.YES_NO_CANCEL_OPTION, JOptionPane.QUESTION_MESSAGE, null,
307                    new String[]{tr("Append preferences from file to existing values"), tr("Replace existing values")}, 0);
308        }
309        CustomConfigurator.exportPreferencesKeysToFile(files[0].getAbsolutePath(), answer == 0, keys);
310    }
311
312    private void readPreferencesFromXML() {
313        File[] files = askUserForCustomSettingsFiles(false, tr("Open JOSM customization file"));
314        if (files.length == 0)
315            return;
316
317        Preferences tmpPrefs = new Preferences(Preferences.main());
318
319        StringBuilder log = new StringBuilder();
320        log.append("<html>");
321        for (File f : files) {
322            CustomConfigurator.readXML(f, tmpPrefs);
323            log.append(PreferencesUtils.getLog());
324        }
325        log.append("</html>");
326        String msg = log.toString().replace("\n", "<br/>");
327
328        new LogShowDialog(tr("Import log"), tr("<html>Here is file import summary. <br/>"
329                + "You can reject preferences changes by pressing \"Cancel\" in preferences dialog <br/>"
330                + "To activate some changes JOSM restart may be needed.</html>"), msg).showDialog();
331
332        readPreferences(tmpPrefs);
333        // sorting after modification - first modified, then non-default, then default entries
334        allData.sort(customComparator);
335        applyFilter();
336    }
337
338    private List<PrefEntry> prepareData(Map<String, Setting<?>> loaded, Map<String, Setting<?>> orig, Map<String, Setting<?>> defaults) {
339        List<PrefEntry> data = new ArrayList<>();
340        for (Entry<String, Setting<?>> e : loaded.entrySet()) {
341            Setting<?> value = e.getValue();
342            Setting<?> old = orig.get(e.getKey());
343            Setting<?> def = defaults.get(e.getKey());
344            if (def == null) {
345                def = value.getNullInstance();
346            }
347            PrefEntry en = new PrefEntry(e.getKey(), value, def, false);
348            // after changes we have nondefault value. Value is changed if is not equal to old value
349            if (!Objects.equals(old, value)) {
350                en.markAsChanged();
351            }
352            data.add(en);
353        }
354        for (Entry<String, Setting<?>> e : defaults.entrySet()) {
355            if (!loaded.containsKey(e.getKey())) {
356                PrefEntry en = new PrefEntry(e.getKey(), e.getValue(), e.getValue(), true);
357                // after changes we have default value. So, value is changed if old value is not default
358                Setting<?> old = orig.get(e.getKey());
359                if (old != null) {
360                    en.markAsChanged();
361                }
362                data.add(en);
363            }
364        }
365        Collections.sort(data);
366        displayData.clear();
367        displayData.addAll(data);
368        return data;
369    }
370
371    private JPopupMenu buildPopupMenu() {
372        JPopupMenu menu = new JPopupMenu();
373        profileTypes.put(marktr("shortcut"), "shortcut\\..*");
374        profileTypes.put(marktr("color"), "color\\..*");
375        profileTypes.put(marktr("toolbar"), "toolbar.*");
376        profileTypes.put(marktr("imagery"), "imagery.*");
377
378        for (Entry<String, String> e: profileTypes.entrySet()) {
379            menu.add(new ExportProfileAction(Preferences.main(), e.getKey(), e.getValue()));
380        }
381
382        menu.addSeparator();
383        menu.add(getProfileMenu());
384        if (Logging.isDebugEnabled()) {
385            menu.addSeparator();
386            menu.add(new EditBoundariesAction());
387        }
388        menu.addSeparator();
389        menu.add(new ResetPreferencesAction());
390        return menu;
391    }
392
393    private JMenu getProfileMenu() {
394        final JMenu p = new JMenu(tr("Load profile"));
395        p.setIcon(ImageProvider.get("open", ImageProvider.ImageSizes.MENU));
396        p.addMenuListener(new MenuListener() {
397            @Override
398            public void menuSelected(MenuEvent me) {
399                p.removeAll();
400                load(p, new File(".").listFiles());
401                load(p, Config.getDirs().getPreferencesDirectory(false).listFiles());
402            }
403
404            private void load(JMenu p, File[] files) {
405                if (files != null) {
406                    for (File f : files) {
407                        String s = f.getName();
408                        int idx = s.indexOf('_');
409                        if (idx >= 0) {
410                            String t = s.substring(0, idx);
411                            if (profileTypes.containsKey(t)) {
412                                p.add(new ImportProfileAction(s, f, t));
413                            }
414                        }
415                    }
416                }
417            }
418
419            @Override
420            public void menuDeselected(MenuEvent me) {
421                // Not implemented
422            }
423
424            @Override
425            public void menuCanceled(MenuEvent me) {
426                // Not implemented
427            }
428        });
429        return p;
430    }
431
432    private class ImportProfileAction extends AbstractAction {
433        private final File file;
434        private final String type;
435
436        ImportProfileAction(String name, File file, String type) {
437            super(name);
438            this.file = file;
439            this.type = type;
440        }
441
442        @Override
443        public void actionPerformed(ActionEvent ae) {
444            Preferences tmpPrefs = new Preferences(Preferences.main());
445            CustomConfigurator.readXML(file, tmpPrefs);
446            readPreferences(tmpPrefs);
447            String prefRegex = profileTypes.get(type);
448            // clean all the preferences from the chosen group
449            for (PrefEntry p : allData) {
450               if (p.getKey().matches(prefRegex) && !p.isDefault()) {
451                    p.reset();
452               }
453            }
454            // allow user to review the changes in table
455            allData.sort(customComparator);
456            applyFilter();
457        }
458    }
459
460    private void applyFilter() {
461        displayData.clear();
462        for (PrefEntry e : allData) {
463            String prefKey = e.getKey();
464            Setting<?> valueSetting = e.getValue();
465            String prefValue = valueSetting.getValue() == null ? "" : valueSetting.getValue().toString();
466
467
468            // Make 'wmsplugin cache' search for e.g. 'cache.wmsplugin'
469            final String prefKeyLower = prefKey.toLowerCase(Locale.ENGLISH);
470            final String prefValueLower = prefValue.toLowerCase(Locale.ENGLISH);
471            String filter = txtFilter.getText(); // see #19825
472            final boolean canHas = filter.isEmpty() || Pattern.compile("\\s+").splitAsStream(filter)
473                    .map(bit -> bit.toLowerCase(Locale.ENGLISH))
474                    .anyMatch(bit -> {
475                        switch (bit) {
476                            // syntax inspired by SearchCompiler
477                            case "changed":
478                                return e.isChanged();
479                            case "modified":
480                            case "-default":
481                                return !e.isDefault();
482                            case "-modified":
483                            case "default":
484                                return e.isDefault();
485                            default:
486                                return prefKeyLower.contains(bit) || prefValueLower.contains(bit);
487                        }
488                    });
489            if (canHas) {
490                displayData.add(e);
491            }
492        }
493        if (table != null)
494            table.fireDataChanged();
495    }
496
497    @Override
498    public boolean ok() {
499        for (PrefEntry e : allData) {
500            if (e.isChanged()) {
501                Preferences.main().putSetting(e.getKey(), e.getValue().getValue() == null ? null : e.getValue());
502            }
503        }
504        return false;
505    }
506
507    @Override
508    public String getHelpContext() {
509        return HelpUtil.ht("/Preferences/Advanced");
510    }
511}