source: josm/trunk/src/org/openstreetmap/josm/gui/tagging/presets/TaggingPresetSelector.java@ 18824

Last change on this file since 18824 was 18824, checked in by taylor.smock, 9 months ago

Fix #23153: Remote Control API call is adding hashtags many times

This occurs due to adding the hashtags to the comment multiple times.

This is fixed by doing the following:
1) When finding hashtags from a comment, only return the distinct hashtags
2) When adding hashtags from the dataset, only add hashtags that are not already

part of the comment.

  • Property svn:eol-style set to native
File size: 22.6 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.gui.tagging.presets;
3
4import static org.openstreetmap.josm.tools.I18n.tr;
5
6import java.awt.BorderLayout;
7import java.awt.Component;
8import java.awt.Dimension;
9import java.awt.event.ActionEvent;
10import java.util.ArrayList;
11import java.util.Arrays;
12import java.util.Collection;
13import java.util.Collections;
14import java.util.EnumSet;
15import java.util.HashSet;
16import java.util.Iterator;
17import java.util.List;
18import java.util.Locale;
19import java.util.Objects;
20import java.util.Set;
21import java.util.regex.Pattern;
22
23import javax.swing.AbstractAction;
24import javax.swing.Action;
25import javax.swing.BoxLayout;
26import javax.swing.DefaultListCellRenderer;
27import javax.swing.Icon;
28import javax.swing.JCheckBox;
29import javax.swing.JLabel;
30import javax.swing.JList;
31import javax.swing.JPanel;
32import javax.swing.JPopupMenu;
33import javax.swing.ListCellRenderer;
34import javax.swing.event.ListSelectionEvent;
35import javax.swing.event.ListSelectionListener;
36
37import org.openstreetmap.josm.data.osm.DataSelectionListener;
38import org.openstreetmap.josm.data.osm.DataSet;
39import org.openstreetmap.josm.data.osm.OsmDataManager;
40import org.openstreetmap.josm.data.osm.OsmPrimitive;
41import org.openstreetmap.josm.data.preferences.BooleanProperty;
42import org.openstreetmap.josm.gui.MainApplication;
43import org.openstreetmap.josm.gui.tagging.presets.items.ComboMultiSelect;
44import org.openstreetmap.josm.gui.tagging.presets.items.Key;
45import org.openstreetmap.josm.gui.tagging.presets.items.KeyedItem;
46import org.openstreetmap.josm.gui.tagging.presets.items.Roles;
47import org.openstreetmap.josm.gui.tagging.presets.items.Roles.Role;
48import org.openstreetmap.josm.gui.widgets.PopupMenuLauncher;
49import org.openstreetmap.josm.gui.widgets.SearchTextResultListPanel;
50import org.openstreetmap.josm.tools.Destroyable;
51import org.openstreetmap.josm.tools.Utils;
52
53/**
54 * GUI component to select tagging preset: the list with filter and two checkboxes
55 * @since 6068
56 */
57public class TaggingPresetSelector extends SearchTextResultListPanel<TaggingPreset>
58 implements DataSelectionListener, TaggingPresetListener, Destroyable {
59
60 private static final int CLASSIFICATION_IN_FAVORITES = 300;
61 private static final int CLASSIFICATION_NAME_MATCH = 300;
62 private static final int CLASSIFICATION_GROUP_MATCH = 200;
63 private static final int CLASSIFICATION_TAGS_MATCH = 100;
64
65 private static final Pattern PATTERN_PUNCTUATION = Pattern.compile("\\p{Punct}", Pattern.UNICODE_CHARACTER_CLASS);
66 private static final Pattern PATTERN_WHITESPACE = Pattern.compile("\\s", Pattern.UNICODE_CHARACTER_CLASS);
67
68 private static final BooleanProperty SEARCH_IN_TAGS = new BooleanProperty("taggingpreset.dialog.search-in-tags", true);
69 private static final BooleanProperty ONLY_APPLICABLE = new BooleanProperty("taggingpreset.dialog.only-applicable-to-selection", true);
70
71 private final JCheckBox ckOnlyApplicable;
72 private final JCheckBox ckSearchInTags;
73 private final Set<TaggingPresetType> typesInSelection = EnumSet.noneOf(TaggingPresetType.class);
74 private boolean typesInSelectionDirty = true;
75 private final transient PresetClassifications classifications = new PresetClassifications();
76
77 private static class ResultListCellRenderer implements ListCellRenderer<TaggingPreset> {
78 private final DefaultListCellRenderer def = new DefaultListCellRenderer();
79 @Override
80 public Component getListCellRendererComponent(JList<? extends TaggingPreset> list, TaggingPreset tp, int index,
81 boolean isSelected, boolean cellHasFocus) {
82 JLabel result = (JLabel) def.getListCellRendererComponent(list, tp, index, isSelected, cellHasFocus);
83 result.setText(tp.getName());
84 result.setIcon((Icon) tp.getValue(Action.SMALL_ICON));
85 return result;
86 }
87 }
88
89 /**
90 * Computes the match ration of a {@link TaggingPreset} wrt. a searchString.
91 */
92 public static class PresetClassification implements Comparable<PresetClassification> {
93 /** The preset for this classification object */
94 public final TaggingPreset preset;
95 /**
96 * The classification for the preset (see {@link #CLASSIFICATION_TAGS_MATCH}, {@link #CLASSIFICATION_GROUP_MATCH},
97 * {@link #CLASSIFICATION_NAME_MATCH}, and {@link #CLASSIFICATION_IN_FAVORITES}). Higher numbers are better.
98 */
99 public int classification;
100 /**
101 * The index in favorites, index = {@link #CLASSIFICATION_IN_FAVORITES} - favoriteIndex
102 */
103 public int favoriteIndex;
104 /** Groups that have been run through {@link #simplifyString(String)} */
105 private final String[] groupsSimplified;
106 /** Names that have been run through {@link #simplifyString(String)}*/
107 private final String[] namesSimplified;
108 /** Tags that have been run through {@link #simplifyString(String)} */
109 private final String[] tagsSimplified;
110
111 PresetClassification(TaggingPreset preset) {
112 this.preset = preset;
113 Set<String> groupSet = new HashSet<>();
114 Set<String> nameSet = new HashSet<>();
115 Set<String> tagSet = new HashSet<>();
116 TaggingPreset group = preset.group;
117 while (group != null) {
118 addLocaleNames(groupSet, group);
119 group = group.group;
120 }
121 addLocaleNames(nameSet, preset);
122 for (TaggingPresetItem item: preset.data) {
123 if (item instanceof KeyedItem) {
124 tagSet.add(((KeyedItem) item).key);
125 if (item instanceof ComboMultiSelect) {
126 final ComboMultiSelect cms = (ComboMultiSelect) item;
127 if (cms.values_searchable) {
128 tagSet.addAll(cms.getDisplayValues());
129 }
130 }
131 if (item instanceof Key && ((Key) item).value != null) {
132 tagSet.add(((Key) item).value);
133 }
134 } else if (item instanceof Roles) {
135 for (Role role : ((Roles) item).roles) {
136 tagSet.add(role.key);
137 }
138 }
139 }
140 // These should be "frozen" arrays
141 this.groupsSimplified = groupSet.stream().map(PresetClassification::simplifyString)
142 .toArray(String[]::new);
143 this.namesSimplified = nameSet.stream().map(PresetClassification::simplifyString)
144 .toArray(String[]::new);
145 this.tagsSimplified = tagSet.stream().map(PresetClassification::simplifyString)
146 .toArray(String[]::new);
147 }
148
149 private static void addLocaleNames(Collection<String> collection, TaggingPreset preset) {
150 String locName = preset.getLocaleName();
151 if (locName != null) {
152 Collections.addAll(collection, PATTERN_WHITESPACE.split(locName.toLowerCase(Locale.ENGLISH), -1));
153 }
154 }
155
156 private static String simplifyString(String s) {
157 return PATTERN_PUNCTUATION.matcher(Utils.deAccent(s).toLowerCase(Locale.ENGLISH)).replaceAll("");
158 }
159
160 /**
161 * Check to see if the search string matches values
162 * @param deaccentedValues Values that have been simplified
163 * @param deaccentedSearchString The simplified search string to use
164 * @return The number used for sorting hits (bigger == more matches)
165 */
166 private static int isMatching(String[] deaccentedValues, String... deaccentedSearchString) {
167 int sum = 0;
168 for (String deaccentedWord: deaccentedSearchString) {
169 boolean found = false;
170 boolean foundFirst = false;
171 for (String value: deaccentedValues) {
172 int index = value.indexOf(deaccentedWord);
173 if (index == 0) {
174 foundFirst = true;
175 break;
176 } else if (index > 0) {
177 found = true;
178 }
179 }
180 if (foundFirst) {
181 sum += 2;
182 } else if (found) {
183 sum += 1;
184 } else
185 return 0;
186 }
187 return sum;
188 }
189
190 private int isMatchingGroup(String... words) {
191 return isMatching(groupsSimplified, words);
192 }
193
194 private int isMatchingName(String... words) {
195 return isMatching(namesSimplified, words);
196 }
197
198 private int isMatchingTags(String... words) {
199 return isMatching(tagsSimplified, words);
200 }
201
202 @Override
203 public int compareTo(PresetClassification o) {
204 int result = o.classification - classification;
205 if (result == 0)
206 return preset.getName().compareTo(o.preset.getName());
207 else
208 return result;
209 }
210
211 @Override
212 public int hashCode() {
213 return this.preset.hashCode() + 31 * (Integer.hashCode(this.classification)
214 + 31 * Integer.hashCode(this.favoriteIndex));
215 }
216
217 @Override
218 public boolean equals(Object obj) {
219 if (this.getClass().isInstance(obj)) {
220 PresetClassification other = (PresetClassification) obj;
221 return this.preset.equals(other.preset) && this.classification == other.classification
222 && this.favoriteIndex == other.favoriteIndex;
223 }
224 return false;
225 }
226
227 @Override
228 public String toString() {
229 return Integer.toString(classification) + ' ' + preset;
230 }
231 }
232
233 /**
234 * Constructs a new {@code TaggingPresetSelector}.
235 * @param displayOnlyApplicable if {@code true} display "Show only applicable to selection" checkbox
236 * @param displaySearchInTags if {@code true} display "Search in tags" checkbox
237 */
238 public TaggingPresetSelector(boolean displayOnlyApplicable, boolean displaySearchInTags) {
239 super();
240 lsResult.setCellRenderer(new ResultListCellRenderer());
241 classifications.loadPresets(TaggingPresets.getTaggingPresets());
242 TaggingPresets.addListener(this);
243
244 JPanel pnChecks = new JPanel();
245 pnChecks.setLayout(new BoxLayout(pnChecks, BoxLayout.Y_AXIS));
246
247 if (displayOnlyApplicable) {
248 ckOnlyApplicable = new JCheckBox();
249 ckOnlyApplicable.setText(tr("Show only applicable to selection"));
250 pnChecks.add(ckOnlyApplicable);
251 ckOnlyApplicable.addItemListener(e -> filterItems());
252 } else {
253 ckOnlyApplicable = null;
254 }
255
256 if (displaySearchInTags) {
257 ckSearchInTags = new JCheckBox();
258 ckSearchInTags.setText(tr("Search in tags"));
259 ckSearchInTags.setSelected(SEARCH_IN_TAGS.get());
260 ckSearchInTags.addItemListener(e -> filterItems());
261 pnChecks.add(ckSearchInTags);
262 } else {
263 ckSearchInTags = null;
264 }
265
266 add(pnChecks, BorderLayout.SOUTH);
267
268 setPreferredSize(new Dimension(400, 300));
269 filterItems();
270 JPopupMenu popupMenu = new JPopupMenu();
271 popupMenu.add(new AbstractAction(tr("Add toolbar button")) {
272 @Override
273 public void actionPerformed(ActionEvent ae) {
274 final TaggingPreset preset = getSelectedPreset();
275 if (preset != null) {
276 MainApplication.getToolbar().addCustomButton(preset.getToolbarString(), -1, false);
277 }
278 }
279 });
280 lsResult.addMouseListener(new PopupMenuLauncher(popupMenu));
281 }
282
283 /**
284 * Search expression can be in form: "group1/group2/name" where names can contain multiple words
285 */
286 @Override
287 protected synchronized void filterItems() {
288 //TODO Save favorites to file
289 String text = edSearchText.getText().toLowerCase(Locale.ENGLISH);
290 boolean onlyApplicable = ckOnlyApplicable != null && ckOnlyApplicable.isSelected();
291 boolean inTags = ckSearchInTags != null && ckSearchInTags.isSelected();
292
293 DataSet ds = OsmDataManager.getInstance().getEditDataSet();
294 Collection<OsmPrimitive> selected = (ds == null) ? Collections.emptyList() : ds.getSelected();
295 final List<PresetClassification> result = classifications.getMatchingPresets(
296 text, onlyApplicable, inTags, getTypesInSelection(), selected);
297
298 final TaggingPreset oldPreset = getSelectedPreset();
299 lsResultModel.setItems(Utils.transform(result, x -> x.preset));
300 final TaggingPreset newPreset = getSelectedPreset();
301 if (!Objects.equals(oldPreset, newPreset)) {
302 int[] indices = lsResult.getSelectedIndices();
303 for (ListSelectionListener listener : listSelectionListeners) {
304 listener.valueChanged(new ListSelectionEvent(lsResult, lsResult.getSelectedIndex(),
305 indices.length > 0 ? indices[indices.length-1] : -1, false));
306 }
307 }
308 }
309
310 /**
311 * A collection of {@link PresetClassification}s with the functionality of filtering wrt. searchString.
312 */
313 public static class PresetClassifications implements Iterable<PresetClassification> {
314 private static final PresetClassification[] EMPTY_PRESET_CLASSIFICATION = new PresetClassification[0];
315 private PresetClassification[] classifications = EMPTY_PRESET_CLASSIFICATION;
316
317 /**
318 * Get matching presets
319 * @param searchText The text to search for
320 * @param onlyApplicable Only look for presets that are applicable to the selection
321 * @param inTags Search for names in tags
322 * @param presetTypes The preset types to look for, may be {@code null}
323 * @param selectedPrimitives The primitives to filter on, must not be {@code null}
324 * if {@code onlyApplicable} is {@code true}
325 * @return The matching presets in a sorted list based off of relevance.
326 */
327 public List<PresetClassification> getMatchingPresets(String searchText, boolean onlyApplicable, boolean inTags,
328 Set<TaggingPresetType> presetTypes, final Collection<? extends OsmPrimitive> selectedPrimitives) {
329 final String[] groupWords;
330 final String[] nameWords;
331
332 if (searchText.contains("/")) {
333 groupWords = searchText.substring(0, searchText.lastIndexOf('/')).split("(?U)[\\s/]", -1);
334 nameWords = PATTERN_WHITESPACE.split(searchText.substring(searchText.indexOf('/') + 1), -1);
335 } else {
336 groupWords = null;
337 nameWords = PATTERN_WHITESPACE.split(searchText, -1);
338 }
339
340 return getMatchingPresets(groupWords, nameWords, onlyApplicable, inTags, presetTypes, selectedPrimitives);
341 }
342
343 /**
344 * Get matching presets
345 * @param groupWords The groups to search for
346 * @param nameWords The names to search for, may look in tags if {@code inTags} is {@code true}
347 * @param onlyApplicable Only look for presets that are applicable to the selection
348 * @param inTags Search for names in tags
349 * @param presetTypes The preset types to look for, may be {@code null}
350 * @param selectedPrimitives The primitives to filter on, must not be {@code null}
351 * if {@code onlyApplicable} is {@code true}
352 * @return The matching presets in a sorted list based off of relevance.
353 */
354 public List<PresetClassification> getMatchingPresets(String[] groupWords, String[] nameWords, boolean onlyApplicable,
355 boolean inTags, Set<TaggingPresetType> presetTypes, final Collection<? extends OsmPrimitive> selectedPrimitives) {
356
357 final List<PresetClassification> result = new ArrayList<>();
358 final String[] simplifiedGroupWords = groupWords == null ? null :
359 Arrays.stream(groupWords).map(PresetClassification::simplifyString).toArray(String[]::new);
360 final String[] simplifiedNameWords = nameWords == null ? null :
361 Arrays.stream(nameWords).map(PresetClassification::simplifyString).toArray(String[]::new);
362 for (PresetClassification presetClassification : classifications) {
363 TaggingPreset preset = presetClassification.preset;
364 presetClassification.classification = 0;
365
366 if (onlyApplicable) {
367 boolean suitable = preset.typeMatches(presetTypes);
368
369 if (!suitable && preset.types.contains(TaggingPresetType.RELATION)
370 && preset.roles != null && !preset.roles.roles.isEmpty()) {
371 suitable = preset.roles.roles.stream().anyMatch(
372 object -> object.memberExpression != null && selectedPrimitives.stream().anyMatch(object.memberExpression));
373 // keep the preset to allow the creation of new relations
374 }
375 if (!suitable) {
376 continue;
377 }
378 }
379
380 if (simplifiedGroupWords != null && presetClassification.isMatchingGroup(simplifiedGroupWords) == 0) {
381 continue;
382 }
383
384 int matchName = presetClassification.isMatchingName(simplifiedNameWords);
385
386 if (matchName == 0) {
387 if (simplifiedGroupWords == null) {
388 int groupMatch = presetClassification.isMatchingGroup(simplifiedNameWords);
389 if (groupMatch > 0) {
390 presetClassification.classification = CLASSIFICATION_GROUP_MATCH + groupMatch;
391 }
392 }
393 if (presetClassification.classification == 0 && inTags) {
394 int tagsMatch = presetClassification.isMatchingTags(simplifiedNameWords);
395 if (tagsMatch > 0) {
396 presetClassification.classification = CLASSIFICATION_TAGS_MATCH + tagsMatch;
397 }
398 }
399 } else {
400 presetClassification.classification = CLASSIFICATION_NAME_MATCH + matchName;
401 }
402
403 if (presetClassification.classification > 0) {
404 presetClassification.classification += presetClassification.favoriteIndex;
405 result.add(presetClassification);
406 }
407 }
408
409 Collections.sort(result);
410 return result;
411 }
412
413 /**
414 * Clears the selector.
415 */
416 public void clear() {
417 classifications = EMPTY_PRESET_CLASSIFICATION;
418 }
419
420 /**
421 * Loads a given collection of presets.
422 * @param presets presets collection
423 */
424 public void loadPresets(Collection<TaggingPreset> presets) {
425 final List<PresetClassification> classificationList = new ArrayList<>(presets.size());
426 for (TaggingPreset preset : presets) {
427 if (preset instanceof TaggingPresetSeparator || preset instanceof TaggingPresetMenu) {
428 continue;
429 }
430 classificationList.add(new PresetClassification(preset));
431 }
432 classifications = classificationList.toArray(new PresetClassification[0]);
433 }
434
435 @Override
436 public Iterator<PresetClassification> iterator() {
437 return Arrays.stream(classifications).iterator();
438 }
439 }
440
441 private Set<TaggingPresetType> getTypesInSelection() {
442 if (typesInSelectionDirty) {
443 synchronized (typesInSelection) {
444 typesInSelectionDirty = false;
445 typesInSelection.clear();
446 if (OsmDataManager.getInstance().getEditDataSet() == null) return typesInSelection;
447 for (OsmPrimitive primitive : OsmDataManager.getInstance().getEditDataSet().getSelected()) {
448 typesInSelection.add(TaggingPresetType.forPrimitive(primitive));
449 }
450 }
451 }
452 return typesInSelection;
453 }
454
455 @Override
456 public void selectionChanged(SelectionChangeEvent event) {
457 typesInSelectionDirty = true;
458 }
459
460 @Override
461 public synchronized void init() {
462 if (ckOnlyApplicable != null) {
463 ckOnlyApplicable.setEnabled(!getTypesInSelection().isEmpty());
464 ckOnlyApplicable.setSelected(!getTypesInSelection().isEmpty() && ONLY_APPLICABLE.get());
465 }
466 super.init();
467 }
468
469 /**
470 * Initializes the selector with a given collection of presets.
471 * @param presets presets collection
472 */
473 public void init(Collection<TaggingPreset> presets) {
474 classifications.clear();
475 classifications.loadPresets(presets);
476 init();
477 }
478
479 /**
480 * Save checkbox values in preferences for future reuse
481 */
482 public void savePreferences() {
483 if (ckSearchInTags != null) {
484 SEARCH_IN_TAGS.put(ckSearchInTags.isSelected());
485 }
486 if (ckOnlyApplicable != null && ckOnlyApplicable.isEnabled()) {
487 ONLY_APPLICABLE.put(ckOnlyApplicable.isSelected());
488 }
489 }
490
491 /**
492 * Determines, which preset is selected at the moment.
493 * @return selected preset (as action)
494 */
495 public synchronized TaggingPreset getSelectedPreset() {
496 if (lsResultModel.isEmpty()) return null;
497 int idx = lsResult.getSelectedIndex();
498 if (idx < 0 || idx >= lsResultModel.getSize()) {
499 idx = 0;
500 }
501 return lsResultModel.getElementAt(idx);
502 }
503
504 /**
505 * Determines, which preset is selected at the moment. Updates {@link PresetClassification#favoriteIndex}!
506 * @return selected preset (as action)
507 */
508 public synchronized TaggingPreset getSelectedPresetAndUpdateClassification() {
509 final TaggingPreset preset = getSelectedPreset();
510 for (PresetClassification pc: classifications) {
511 if (pc.preset == preset) {
512 pc.favoriteIndex = CLASSIFICATION_IN_FAVORITES;
513 } else if (pc.favoriteIndex > 0) {
514 pc.favoriteIndex--;
515 }
516 }
517 return preset;
518 }
519
520 /**
521 * Selects a given preset.
522 * @param p preset to select
523 */
524 public synchronized void setSelectedPreset(TaggingPreset p) {
525 lsResult.setSelectedValue(p, true);
526 }
527
528 @Override
529 public void taggingPresetsModified() {
530 classifications.clear();
531 classifications.loadPresets(TaggingPresets.getTaggingPresets());
532 }
533
534 @Override
535 public void destroy() {
536 TaggingPresets.removeListener(this);
537 }
538
539}
Note: See TracBrowser for help on using the repository browser.