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