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