001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.tagging.presets;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.BorderLayout;
007import java.awt.Component;
008import java.awt.Dimension;
009import java.awt.event.ActionEvent;
010import java.util.ArrayList;
011import java.util.Collection;
012import java.util.Collections;
013import java.util.EnumSet;
014import java.util.HashSet;
015import java.util.Iterator;
016import java.util.List;
017import java.util.Locale;
018import java.util.Objects;
019import java.util.Set;
020import java.util.stream.Collectors;
021
022import javax.swing.AbstractAction;
023import javax.swing.Action;
024import javax.swing.BoxLayout;
025import javax.swing.DefaultListCellRenderer;
026import javax.swing.Icon;
027import javax.swing.JCheckBox;
028import javax.swing.JLabel;
029import javax.swing.JList;
030import javax.swing.JPanel;
031import javax.swing.JPopupMenu;
032import javax.swing.ListCellRenderer;
033import javax.swing.event.ListSelectionEvent;
034import javax.swing.event.ListSelectionListener;
035
036import org.openstreetmap.josm.data.osm.DataSelectionListener;
037import org.openstreetmap.josm.data.osm.DataSet;
038import org.openstreetmap.josm.data.osm.OsmDataManager;
039import org.openstreetmap.josm.data.osm.OsmPrimitive;
040import org.openstreetmap.josm.data.preferences.BooleanProperty;
041import org.openstreetmap.josm.gui.MainApplication;
042import org.openstreetmap.josm.gui.tagging.presets.items.ComboMultiSelect;
043import org.openstreetmap.josm.gui.tagging.presets.items.Key;
044import org.openstreetmap.josm.gui.tagging.presets.items.KeyedItem;
045import org.openstreetmap.josm.gui.tagging.presets.items.Roles;
046import org.openstreetmap.josm.gui.tagging.presets.items.Roles.Role;
047import org.openstreetmap.josm.gui.widgets.PopupMenuLauncher;
048import org.openstreetmap.josm.gui.widgets.SearchTextResultListPanel;
049import org.openstreetmap.josm.tools.Destroyable;
050import org.openstreetmap.josm.tools.Utils;
051
052/**
053 * GUI component to select tagging preset: the list with filter and two checkboxes
054 * @since 6068
055 */
056public class TaggingPresetSelector extends SearchTextResultListPanel<TaggingPreset>
057        implements DataSelectionListener, TaggingPresetListener, Destroyable {
058
059    private static final int CLASSIFICATION_IN_FAVORITES = 300;
060    private static final int CLASSIFICATION_NAME_MATCH = 300;
061    private static final int CLASSIFICATION_GROUP_MATCH = 200;
062    private static final int CLASSIFICATION_TAGS_MATCH = 100;
063
064    private static final BooleanProperty SEARCH_IN_TAGS = new BooleanProperty("taggingpreset.dialog.search-in-tags", true);
065    private static final BooleanProperty ONLY_APPLICABLE = new BooleanProperty("taggingpreset.dialog.only-applicable-to-selection", true);
066
067    private final JCheckBox ckOnlyApplicable;
068    private final JCheckBox ckSearchInTags;
069    private final Set<TaggingPresetType> typesInSelection = EnumSet.noneOf(TaggingPresetType.class);
070    private boolean typesInSelectionDirty = true;
071    private final transient PresetClassifications classifications = new PresetClassifications();
072
073    private static class ResultListCellRenderer implements ListCellRenderer<TaggingPreset> {
074        private final DefaultListCellRenderer def = new DefaultListCellRenderer();
075        @Override
076        public Component getListCellRendererComponent(JList<? extends TaggingPreset> list, TaggingPreset tp, int index,
077                boolean isSelected, boolean cellHasFocus) {
078            JLabel result = (JLabel) def.getListCellRendererComponent(list, tp, index, isSelected, cellHasFocus);
079            result.setText(tp.getName());
080            result.setIcon((Icon) tp.getValue(Action.SMALL_ICON));
081            return result;
082        }
083    }
084
085    /**
086     * Computes the match ration of a {@link TaggingPreset} wrt. a searchString.
087     */
088    public static class PresetClassification implements Comparable<PresetClassification> {
089        public final TaggingPreset preset;
090        public int classification;
091        public int favoriteIndex;
092        private final Collection<String> groups;
093        private final Collection<String> names;
094        private final Collection<String> tags;
095
096        PresetClassification(TaggingPreset preset) {
097            this.preset = preset;
098            Set<String> groupSet = new HashSet<>();
099            Set<String> nameSet = new HashSet<>();
100            Set<String> tagSet = new HashSet<>();
101            TaggingPreset group = preset.group;
102            while (group != null) {
103                addLocaleNames(groupSet, group);
104                group = group.group;
105            }
106            addLocaleNames(nameSet, preset);
107            for (TaggingPresetItem item: preset.data) {
108                if (item instanceof KeyedItem) {
109                    tagSet.add(((KeyedItem) item).key);
110                    if (item instanceof ComboMultiSelect) {
111                        final ComboMultiSelect cms = (ComboMultiSelect) item;
112                        if (cms.values_searchable) {
113                            tagSet.addAll(cms.getDisplayValues());
114                        }
115                    }
116                    if (item instanceof Key && ((Key) item).value != null) {
117                        tagSet.add(((Key) item).value);
118                    }
119                } else if (item instanceof Roles) {
120                    for (Role role : ((Roles) item).roles) {
121                        tagSet.add(role.key);
122                    }
123                }
124            }
125            this.groups = Utils.toUnmodifiableList(groupSet);
126            this.names = Utils.toUnmodifiableList(nameSet);
127            this.tags = Utils.toUnmodifiableList(tagSet);
128        }
129
130        private static void addLocaleNames(Collection<String> collection, TaggingPreset preset) {
131            String locName = preset.getLocaleName();
132            if (locName != null) {
133                Collections.addAll(collection, locName.toLowerCase(Locale.ENGLISH).split("\\s", -1));
134            }
135        }
136
137        private static String simplifyString(String s) {
138            return Utils.deAccent(s).toLowerCase(Locale.ENGLISH).replaceAll("\\p{Punct}", "");
139        }
140
141        private static int isMatching(Collection<String> values, String... searchString) {
142            int sum = 0;
143            List<String> deaccentedValues = values.stream()
144                    .map(PresetClassification::simplifyString).collect(Collectors.toList());
145            for (String word: searchString) {
146                boolean found = false;
147                boolean foundFirst = false;
148                String deaccentedWord = simplifyString(word);
149                for (String value: deaccentedValues) {
150                    int index = value.indexOf(deaccentedWord);
151                    if (index == 0) {
152                        foundFirst = true;
153                        break;
154                    } else if (index > 0) {
155                        found = true;
156                    }
157                }
158                if (foundFirst) {
159                    sum += 2;
160                } else if (found) {
161                    sum += 1;
162                } else
163                    return 0;
164            }
165            return sum;
166        }
167
168        int isMatchingGroup(String... words) {
169            return isMatching(groups, words);
170        }
171
172        int isMatchingName(String... words) {
173            return isMatching(names, words);
174        }
175
176        int isMatchingTags(String... words) {
177            return isMatching(tags, words);
178        }
179
180        @Override
181        public int compareTo(PresetClassification o) {
182            int result = o.classification - classification;
183            if (result == 0)
184                return preset.getName().compareTo(o.preset.getName());
185            else
186                return result;
187        }
188
189        @Override
190        public String toString() {
191            return Integer.toString(classification) + ' ' + preset;
192        }
193    }
194
195    /**
196     * Constructs a new {@code TaggingPresetSelector}.
197     * @param displayOnlyApplicable if {@code true} display "Show only applicable to selection" checkbox
198     * @param displaySearchInTags if {@code true} display "Search in tags" checkbox
199     */
200    public TaggingPresetSelector(boolean displayOnlyApplicable, boolean displaySearchInTags) {
201        super();
202        lsResult.setCellRenderer(new ResultListCellRenderer());
203        classifications.loadPresets(TaggingPresets.getTaggingPresets());
204        TaggingPresets.addListener(this);
205
206        JPanel pnChecks = new JPanel();
207        pnChecks.setLayout(new BoxLayout(pnChecks, BoxLayout.Y_AXIS));
208
209        if (displayOnlyApplicable) {
210            ckOnlyApplicable = new JCheckBox();
211            ckOnlyApplicable.setText(tr("Show only applicable to selection"));
212            pnChecks.add(ckOnlyApplicable);
213            ckOnlyApplicable.addItemListener(e -> filterItems());
214        } else {
215            ckOnlyApplicable = null;
216        }
217
218        if (displaySearchInTags) {
219            ckSearchInTags = new JCheckBox();
220            ckSearchInTags.setText(tr("Search in tags"));
221            ckSearchInTags.setSelected(SEARCH_IN_TAGS.get());
222            ckSearchInTags.addItemListener(e -> filterItems());
223            pnChecks.add(ckSearchInTags);
224        } else {
225            ckSearchInTags = null;
226        }
227
228        add(pnChecks, BorderLayout.SOUTH);
229
230        setPreferredSize(new Dimension(400, 300));
231        filterItems();
232        JPopupMenu popupMenu = new JPopupMenu();
233        popupMenu.add(new AbstractAction(tr("Add toolbar button")) {
234            @Override
235            public void actionPerformed(ActionEvent ae) {
236                final TaggingPreset preset = getSelectedPreset();
237                if (preset != null) {
238                    MainApplication.getToolbar().addCustomButton(preset.getToolbarString(), -1, false);
239                }
240            }
241        });
242        lsResult.addMouseListener(new PopupMenuLauncher(popupMenu));
243    }
244
245    /**
246     * Search expression can be in form: "group1/group2/name" where names can contain multiple words
247     */
248    @Override
249    protected synchronized void filterItems() {
250        //TODO Save favorites to file
251        String text = edSearchText.getText().toLowerCase(Locale.ENGLISH);
252        boolean onlyApplicable = ckOnlyApplicable != null && ckOnlyApplicable.isSelected();
253        boolean inTags = ckSearchInTags != null && ckSearchInTags.isSelected();
254
255        DataSet ds = OsmDataManager.getInstance().getEditDataSet();
256        Collection<OsmPrimitive> selected = (ds == null) ? Collections.<OsmPrimitive>emptyList() : ds.getSelected();
257        final List<PresetClassification> result = classifications.getMatchingPresets(
258                text, onlyApplicable, inTags, getTypesInSelection(), selected);
259
260        final TaggingPreset oldPreset = getSelectedPreset();
261        lsResultModel.setItems(Utils.transform(result, x -> x.preset));
262        final TaggingPreset newPreset = getSelectedPreset();
263        if (!Objects.equals(oldPreset, newPreset)) {
264            int[] indices = lsResult.getSelectedIndices();
265            for (ListSelectionListener listener : listSelectionListeners) {
266                listener.valueChanged(new ListSelectionEvent(lsResult, lsResult.getSelectedIndex(),
267                        indices.length > 0 ? indices[indices.length-1] : -1, false));
268            }
269        }
270    }
271
272    /**
273     * A collection of {@link PresetClassification}s with the functionality of filtering wrt. searchString.
274     */
275    public static class PresetClassifications implements Iterable<PresetClassification> {
276
277        private final List<PresetClassification> classifications = new ArrayList<>();
278
279        public List<PresetClassification> getMatchingPresets(String searchText, boolean onlyApplicable, boolean inTags,
280                Set<TaggingPresetType> presetTypes, final Collection<? extends OsmPrimitive> selectedPrimitives) {
281            final String[] groupWords;
282            final String[] nameWords;
283
284            if (searchText.contains("/")) {
285                groupWords = searchText.substring(0, searchText.lastIndexOf('/')).split("[\\s/]", -1);
286                nameWords = searchText.substring(searchText.indexOf('/') + 1).split("\\s", -1);
287            } else {
288                groupWords = null;
289                nameWords = searchText.split("\\s", -1);
290            }
291
292            return getMatchingPresets(groupWords, nameWords, onlyApplicable, inTags, presetTypes, selectedPrimitives);
293        }
294
295        public List<PresetClassification> getMatchingPresets(String[] groupWords, String[] nameWords, boolean onlyApplicable,
296                boolean inTags, Set<TaggingPresetType> presetTypes, final Collection<? extends OsmPrimitive> selectedPrimitives) {
297
298            final List<PresetClassification> result = new ArrayList<>();
299            for (PresetClassification presetClassification : classifications) {
300                TaggingPreset preset = presetClassification.preset;
301                presetClassification.classification = 0;
302
303                if (onlyApplicable) {
304                    boolean suitable = preset.typeMatches(presetTypes);
305
306                    if (!suitable && preset.types.contains(TaggingPresetType.RELATION)
307                            && preset.roles != null && !preset.roles.roles.isEmpty()) {
308                        suitable = preset.roles.roles.stream().anyMatch(
309                                object -> object.memberExpression != null && selectedPrimitives.stream().anyMatch(object.memberExpression));
310                        // keep the preset to allow the creation of new relations
311                    }
312                    if (!suitable) {
313                        continue;
314                    }
315                }
316
317                if (groupWords != null && presetClassification.isMatchingGroup(groupWords) == 0) {
318                    continue;
319                }
320
321                int matchName = presetClassification.isMatchingName(nameWords);
322
323                if (matchName == 0) {
324                    if (groupWords == null) {
325                        int groupMatch = presetClassification.isMatchingGroup(nameWords);
326                        if (groupMatch > 0) {
327                            presetClassification.classification = CLASSIFICATION_GROUP_MATCH + groupMatch;
328                        }
329                    }
330                    if (presetClassification.classification == 0 && inTags) {
331                        int tagsMatch = presetClassification.isMatchingTags(nameWords);
332                        if (tagsMatch > 0) {
333                            presetClassification.classification = CLASSIFICATION_TAGS_MATCH + tagsMatch;
334                        }
335                    }
336                } else {
337                    presetClassification.classification = CLASSIFICATION_NAME_MATCH + matchName;
338                }
339
340                if (presetClassification.classification > 0) {
341                    presetClassification.classification += presetClassification.favoriteIndex;
342                    result.add(presetClassification);
343                }
344            }
345
346            Collections.sort(result);
347            return result;
348        }
349
350        /**
351         * Clears the selector.
352         */
353        public void clear() {
354            classifications.clear();
355        }
356
357        /**
358         * Loads a given collection of presets.
359         * @param presets presets collection
360         */
361        public void loadPresets(Collection<TaggingPreset> presets) {
362            for (TaggingPreset preset : presets) {
363                if (preset instanceof TaggingPresetSeparator || preset instanceof TaggingPresetMenu) {
364                    continue;
365                }
366                classifications.add(new PresetClassification(preset));
367            }
368        }
369
370        @Override
371        public Iterator<PresetClassification> iterator() {
372            return classifications.iterator();
373        }
374    }
375
376    private Set<TaggingPresetType> getTypesInSelection() {
377        if (typesInSelectionDirty) {
378            synchronized (typesInSelection) {
379                typesInSelectionDirty = false;
380                typesInSelection.clear();
381                if (OsmDataManager.getInstance().getEditDataSet() == null) return typesInSelection;
382                for (OsmPrimitive primitive : OsmDataManager.getInstance().getEditDataSet().getSelected()) {
383                    typesInSelection.add(TaggingPresetType.forPrimitive(primitive));
384                }
385            }
386        }
387        return typesInSelection;
388    }
389
390    @Override
391    public void selectionChanged(SelectionChangeEvent event) {
392        typesInSelectionDirty = true;
393    }
394
395    @Override
396    public synchronized void init() {
397        if (ckOnlyApplicable != null) {
398            ckOnlyApplicable.setEnabled(!getTypesInSelection().isEmpty());
399            ckOnlyApplicable.setSelected(!getTypesInSelection().isEmpty() && ONLY_APPLICABLE.get());
400        }
401        super.init();
402    }
403
404    /**
405     * Initializes the selector with a given collection of presets.
406     * @param presets presets collection
407     */
408    public void init(Collection<TaggingPreset> presets) {
409        classifications.clear();
410        classifications.loadPresets(presets);
411        init();
412    }
413
414    /**
415     * Save checkbox values in preferences for future reuse
416     */
417    public void savePreferences() {
418        if (ckSearchInTags != null) {
419            SEARCH_IN_TAGS.put(ckSearchInTags.isSelected());
420        }
421        if (ckOnlyApplicable != null && ckOnlyApplicable.isEnabled()) {
422            ONLY_APPLICABLE.put(ckOnlyApplicable.isSelected());
423        }
424    }
425
426    /**
427     * Determines, which preset is selected at the moment.
428     * @return selected preset (as action)
429     */
430    public synchronized TaggingPreset getSelectedPreset() {
431        if (lsResultModel.isEmpty()) return null;
432        int idx = lsResult.getSelectedIndex();
433        if (idx < 0 || idx >= lsResultModel.getSize()) {
434            idx = 0;
435        }
436        return lsResultModel.getElementAt(idx);
437    }
438
439    /**
440     * Determines, which preset is selected at the moment. Updates {@link PresetClassification#favoriteIndex}!
441     * @return selected preset (as action)
442     */
443    public synchronized TaggingPreset getSelectedPresetAndUpdateClassification() {
444        final TaggingPreset preset = getSelectedPreset();
445        for (PresetClassification pc: classifications) {
446            if (pc.preset == preset) {
447                pc.favoriteIndex = CLASSIFICATION_IN_FAVORITES;
448            } else if (pc.favoriteIndex > 0) {
449                pc.favoriteIndex--;
450            }
451        }
452        return preset;
453    }
454
455    /**
456     * Selects a given preset.
457     * @param p preset to select
458     */
459    public synchronized void setSelectedPreset(TaggingPreset p) {
460        lsResult.setSelectedValue(p, true);
461    }
462
463    @Override
464    public void taggingPresetsModified() {
465        classifications.clear();
466        classifications.loadPresets(TaggingPresets.getTaggingPresets());
467    }
468
469    @Override
470    public void destroy() {
471        TaggingPresets.removeListener(this);
472    }
473
474}