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