source: josm/trunk/src/org/openstreetmap/josm/gui/download/UserQueryList.java@ 16354

Last change on this file since 16354 was 14433, checked in by Don-vip, 5 years ago

fix #16994 - case-insensitive search in saved user queries

File size: 23.1 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.gui.download;
3
4import static org.openstreetmap.josm.tools.I18n.tr;
5
6import java.awt.Color;
7import java.awt.Component;
8import java.awt.Dimension;
9import java.awt.Font;
10import java.awt.GridBagLayout;
11import java.awt.Point;
12import java.awt.event.ActionEvent;
13import java.awt.event.ActionListener;
14import java.awt.event.MouseAdapter;
15import java.awt.event.MouseEvent;
16import java.time.LocalDateTime;
17import java.time.format.DateTimeFormatter;
18import java.time.format.DateTimeParseException;
19import java.util.ArrayList;
20import java.util.Collection;
21import java.util.Collections;
22import java.util.HashMap;
23import java.util.List;
24import java.util.Locale;
25import java.util.Map;
26import java.util.Objects;
27import java.util.Optional;
28import java.util.stream.Collectors;
29
30import javax.swing.AbstractAction;
31import javax.swing.BorderFactory;
32import javax.swing.JLabel;
33import javax.swing.JList;
34import javax.swing.JOptionPane;
35import javax.swing.JPanel;
36import javax.swing.JPopupMenu;
37import javax.swing.JScrollPane;
38import javax.swing.JTextField;
39import javax.swing.ListCellRenderer;
40import javax.swing.SwingUtilities;
41import javax.swing.border.CompoundBorder;
42import javax.swing.text.JTextComponent;
43
44import org.openstreetmap.josm.gui.ExtendedDialog;
45import org.openstreetmap.josm.gui.util.GuiHelper;
46import org.openstreetmap.josm.gui.widgets.AbstractTextComponentValidator;
47import org.openstreetmap.josm.gui.widgets.DefaultTextComponentValidator;
48import org.openstreetmap.josm.gui.widgets.JosmTextArea;
49import org.openstreetmap.josm.gui.widgets.SearchTextResultListPanel;
50import org.openstreetmap.josm.spi.preferences.Config;
51import org.openstreetmap.josm.tools.GBC;
52import org.openstreetmap.josm.tools.Logging;
53import org.openstreetmap.josm.tools.Utils;
54
55/**
56 * A component to select user saved queries.
57 * @since 12880
58 * @since 12574 as OverpassQueryList
59 */
60public final class UserQueryList extends SearchTextResultListPanel<UserQueryList.SelectorItem> {
61
62 private static final DateTimeFormatter FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss, dd-MM-yyyy");
63
64 /*
65 * GUI elements
66 */
67 private final JTextComponent target;
68 private final Component componentParent;
69
70 /*
71 * All loaded elements within the list.
72 */
73 private final transient Map<String, SelectorItem> items;
74
75 /*
76 * Preferences
77 */
78 private static final String KEY_KEY = "key";
79 private static final String QUERY_KEY = "query";
80 private static final String LAST_EDIT_KEY = "lastEdit";
81 private final String preferenceKey;
82
83 private static final String TRANSLATED_HISTORY = tr("history");
84
85 /**
86 * Constructs a new {@code OverpassQueryList}.
87 * @param parent The parent of this component.
88 * @param target The text component to which the queries must be added.
89 * @param preferenceKey The {@linkplain org.openstreetmap.josm.spi.preferences.IPreferences preference} key to store the user queries
90 */
91 public UserQueryList(Component parent, JTextComponent target, String preferenceKey) {
92 this.target = target;
93 this.componentParent = parent;
94 this.preferenceKey = preferenceKey;
95 this.items = restorePreferences();
96
97 QueryListMouseAdapter mouseHandler = new QueryListMouseAdapter(lsResult, lsResultModel);
98 super.lsResult.setCellRenderer(new QueryCellRendered());
99 super.setDblClickListener(e -> doubleClickEvent());
100 super.lsResult.addMouseListener(mouseHandler);
101 super.lsResult.addMouseMotionListener(mouseHandler);
102
103 filterItems();
104 }
105
106 /**
107 * Returns currently selected element from the list.
108 * @return An {@link Optional#empty()} if nothing is selected, otherwise
109 * the idem is returned.
110 */
111 public synchronized Optional<SelectorItem> getSelectedItem() {
112 int idx = lsResult.getSelectedIndex();
113 if (lsResultModel.getSize() <= idx || idx == -1) {
114 return Optional.empty();
115 }
116
117 SelectorItem item = lsResultModel.getElementAt(idx);
118
119 filterItems();
120
121 return Optional.of(item);
122 }
123
124 /**
125 * Adds a new historic item to the list. The key has form 'history {current date}'.
126 * Note, the item is not saved if there is already a historic item with the same query.
127 * @param query The query of the item.
128 * @exception IllegalArgumentException if the query is empty.
129 * @exception NullPointerException if the query is {@code null}.
130 */
131 public synchronized void saveHistoricItem(String query) {
132 boolean historicExist = this.items.values().stream()
133 .map(SelectorItem::getQuery)
134 .anyMatch(q -> q.equals(query));
135
136 if (!historicExist) {
137 SelectorItem item = new SelectorItem(
138 TRANSLATED_HISTORY + " " + LocalDateTime.now().format(FORMAT), query);
139
140 this.items.put(item.getKey(), item);
141
142 savePreferences();
143 filterItems();
144 }
145 }
146
147 /**
148 * Removes currently selected item, saves the current state to preferences and
149 * updates the view.
150 */
151 public synchronized void removeSelectedItem() {
152 Optional<SelectorItem> it = this.getSelectedItem();
153
154 if (!it.isPresent()) {
155 JOptionPane.showMessageDialog(
156 componentParent,
157 tr("Please select an item first"));
158 return;
159 }
160
161 SelectorItem item = it.get();
162 if (this.items.remove(item.getKey(), item)) {
163 clearSelection();
164 savePreferences();
165 filterItems();
166 }
167 }
168
169 /**
170 * Opens {@link EditItemDialog} for the selected item, saves the current state
171 * to preferences and updates the view.
172 */
173 public synchronized void editSelectedItem() {
174 Optional<SelectorItem> it = this.getSelectedItem();
175
176 if (!it.isPresent()) {
177 JOptionPane.showMessageDialog(
178 componentParent,
179 tr("Please select an item first"));
180 return;
181 }
182
183 SelectorItem item = it.get();
184
185 EditItemDialog dialog = new EditItemDialog(
186 componentParent,
187 tr("Edit item"),
188 item,
189 tr("Save"), tr("Cancel"));
190 dialog.showDialog();
191
192 Optional<SelectorItem> editedItem = dialog.getOutputItem();
193 editedItem.ifPresent(i -> {
194 this.items.remove(item.getKey(), item);
195 this.items.put(i.getKey(), i);
196
197 savePreferences();
198 filterItems();
199 });
200 }
201
202 /**
203 * Opens {@link EditItemDialog}, saves the state to preferences if a new item is added
204 * and updates the view.
205 */
206 public synchronized void createNewItem() {
207 EditItemDialog dialog = new EditItemDialog(componentParent, tr("Add snippet"), tr("Add"));
208 dialog.showDialog();
209
210 Optional<SelectorItem> newItem = dialog.getOutputItem();
211 newItem.ifPresent(i -> {
212 items.put(i.getKey(), i);
213 savePreferences();
214 filterItems();
215 });
216 }
217
218 @Override
219 public void setDblClickListener(ActionListener dblClickListener) {
220 // this listener is already set within this class
221 }
222
223 @Override
224 protected void filterItems() {
225 String text = edSearchText.getText().toLowerCase(Locale.ENGLISH);
226 List<SelectorItem> matchingItems = this.items.values().stream()
227 .sorted((i1, i2) -> i2.getLastEdit().compareTo(i1.getLastEdit()))
228 .filter(item -> item.getKey().toLowerCase(Locale.ENGLISH).contains(text))
229 .collect(Collectors.toList());
230
231 super.lsResultModel.setItems(matchingItems);
232 }
233
234 private void doubleClickEvent() {
235 Optional<SelectorItem> selectedItem = this.getSelectedItem();
236
237 if (!selectedItem.isPresent()) {
238 return;
239 }
240
241 SelectorItem item = selectedItem.get();
242 this.target.setText(item.getQuery());
243 }
244
245 /**
246 * Saves all elements from the list to {@link Config#getPref}.
247 */
248 private void savePreferences() {
249 List<Map<String, String>> toSave = new ArrayList<>(this.items.size());
250 for (SelectorItem item : this.items.values()) {
251 Map<String, String> it = new HashMap<>();
252 it.put(KEY_KEY, item.getKey());
253 it.put(QUERY_KEY, item.getQuery());
254 it.put(LAST_EDIT_KEY, item.getLastEdit().format(FORMAT));
255
256 toSave.add(it);
257 }
258
259 Config.getPref().putListOfMaps(preferenceKey, toSave);
260 }
261
262 /**
263 * Loads the user saved items from {@link Config#getPref}.
264 * @return A set of the user saved items.
265 */
266 private Map<String, SelectorItem> restorePreferences() {
267 Collection<Map<String, String>> toRetrieve =
268 Config.getPref().getListOfMaps(preferenceKey, Collections.emptyList());
269 Map<String, SelectorItem> result = new HashMap<>();
270
271 for (Map<String, String> entry : toRetrieve) {
272 try {
273 String key = entry.get(KEY_KEY);
274 String query = entry.get(QUERY_KEY);
275 String lastEditText = entry.get(LAST_EDIT_KEY);
276 // Compatibility: Some entries may not have a last edit set.
277 LocalDateTime lastEdit = lastEditText == null ? LocalDateTime.MIN : LocalDateTime.parse(lastEditText, FORMAT);
278
279 result.put(key, new SelectorItem(key, query, lastEdit));
280 } catch (IllegalArgumentException | DateTimeParseException e) {
281 // skip any corrupted item
282 Logging.error(e);
283 }
284 }
285
286 return result;
287 }
288
289 private class QueryListMouseAdapter extends MouseAdapter {
290
291 private final JList<SelectorItem> list;
292 private final ResultListModel<SelectorItem> model;
293 private final JPopupMenu emptySelectionPopup = new JPopupMenu();
294 private final JPopupMenu elementPopup = new JPopupMenu();
295
296 QueryListMouseAdapter(JList<SelectorItem> list, ResultListModel<SelectorItem> listModel) {
297 this.list = list;
298 this.model = listModel;
299
300 this.initPopupMenus();
301 }
302
303 /*
304 * Do not select the closest element if the user clicked on
305 * an empty area within the list.
306 */
307 private int locationToIndex(Point p) {
308 int idx = list.locationToIndex(p);
309
310 if (idx != -1 && !list.getCellBounds(idx, idx).contains(p)) {
311 return -1;
312 } else {
313 return idx;
314 }
315 }
316
317 @Override
318 public void mouseClicked(MouseEvent e) {
319 super.mouseClicked(e);
320 if (SwingUtilities.isRightMouseButton(e)) {
321 int index = locationToIndex(e.getPoint());
322
323 if (model.getSize() == 0 || index == -1) {
324 list.clearSelection();
325 if (list.isShowing()) {
326 emptySelectionPopup.show(list, e.getX(), e.getY());
327 }
328 } else {
329 list.setSelectedIndex(index);
330 list.ensureIndexIsVisible(index);
331 if (list.isShowing()) {
332 elementPopup.show(list, e.getX(), e.getY());
333 }
334 }
335 }
336 }
337
338 @Override
339 public void mouseMoved(MouseEvent e) {
340 super.mouseMoved(e);
341 int idx = locationToIndex(e.getPoint());
342 if (idx == -1) {
343 return;
344 }
345
346 SelectorItem item = model.getElementAt(idx);
347 list.setToolTipText("<html><pre style='width:300px;'>" +
348 Utils.escapeReservedCharactersHTML(Utils.restrictStringLines(item.getQuery(), 9)));
349 }
350
351 private void initPopupMenus() {
352 AbstractAction add = new AbstractAction(tr("Add")) {
353 @Override
354 public void actionPerformed(ActionEvent e) {
355 createNewItem();
356 }
357 };
358 AbstractAction edit = new AbstractAction(tr("Edit")) {
359 @Override
360 public void actionPerformed(ActionEvent e) {
361 editSelectedItem();
362 }
363 };
364 AbstractAction remove = new AbstractAction(tr("Remove")) {
365 @Override
366 public void actionPerformed(ActionEvent e) {
367 removeSelectedItem();
368 }
369 };
370 this.emptySelectionPopup.add(add);
371 this.elementPopup.add(add);
372 this.elementPopup.add(edit);
373 this.elementPopup.add(remove);
374 }
375 }
376
377 /**
378 * This class defines the way each element is rendered in the list.
379 */
380 private static class QueryCellRendered extends JLabel implements ListCellRenderer<SelectorItem> {
381
382 QueryCellRendered() {
383 setOpaque(true);
384 }
385
386 @Override
387 public Component getListCellRendererComponent(
388 JList<? extends SelectorItem> list,
389 SelectorItem value,
390 int index,
391 boolean isSelected,
392 boolean cellHasFocus) {
393
394 Font font = list.getFont();
395 if (isSelected) {
396 setFont(new Font(font.getFontName(), Font.BOLD, font.getSize() + 2));
397 setBackground(list.getSelectionBackground());
398 setForeground(list.getSelectionForeground());
399 } else {
400 setFont(new Font(font.getFontName(), Font.PLAIN, font.getSize() + 2));
401 setBackground(list.getBackground());
402 setForeground(list.getForeground());
403 }
404
405 setEnabled(list.isEnabled());
406 setText(value.getKey());
407
408 if (isSelected && cellHasFocus) {
409 setBorder(new CompoundBorder(
410 BorderFactory.createLineBorder(Color.BLACK, 1),
411 BorderFactory.createEmptyBorder(2, 0, 2, 0)));
412 } else {
413 setBorder(new CompoundBorder(
414 null,
415 BorderFactory.createEmptyBorder(2, 0, 2, 0)));
416 }
417
418 return this;
419 }
420 }
421
422 /**
423 * Dialog that provides functionality to add/edit an item from the list.
424 */
425 private final class EditItemDialog extends ExtendedDialog {
426
427 private final JTextField name;
428 private final JosmTextArea query;
429
430 private final transient AbstractTextComponentValidator queryValidator;
431 private final transient AbstractTextComponentValidator nameValidator;
432
433 private static final int SUCCESS_BTN = 0;
434 private static final int CANCEL_BTN = 1;
435
436 private final transient SelectorItem itemToEdit;
437
438 /**
439 * Added/Edited object to be returned. If {@link Optional#empty()} then probably
440 * the user closed the dialog, otherwise {@link SelectorItem} is present.
441 */
442 private transient Optional<SelectorItem> outputItem = Optional.empty();
443
444 EditItemDialog(Component parent, String title, String... buttonTexts) {
445 this(parent, title, null, buttonTexts);
446 }
447
448 EditItemDialog(
449 Component parent,
450 String title,
451 SelectorItem itemToEdit,
452 String... buttonTexts) {
453 super(parent, title, buttonTexts);
454
455 this.itemToEdit = itemToEdit;
456
457 String nameToEdit = itemToEdit == null ? "" : itemToEdit.getKey();
458 String queryToEdit = itemToEdit == null ? "" : itemToEdit.getQuery();
459
460 this.name = new JTextField(nameToEdit);
461 this.query = new JosmTextArea(queryToEdit);
462
463 this.queryValidator = new DefaultTextComponentValidator(this.query, "", tr("Query cannot be empty"));
464 this.nameValidator = new AbstractTextComponentValidator(this.name) {
465 @Override
466 public void validate() {
467 if (isValid()) {
468 feedbackValid(tr("This name can be used for the item"));
469 } else {
470 feedbackInvalid(tr("Item with this name already exists"));
471 }
472 }
473
474 @Override
475 public boolean isValid() {
476 String currentName = name.getText();
477
478 boolean notEmpty = !Utils.isStripEmpty(currentName);
479 boolean exist = !currentName.equals(nameToEdit) &&
480 items.containsKey(currentName);
481
482 return notEmpty && !exist;
483 }
484 };
485
486 this.name.getDocument().addDocumentListener(this.nameValidator);
487 this.query.getDocument().addDocumentListener(this.queryValidator);
488
489 JPanel panel = new JPanel(new GridBagLayout());
490 JScrollPane queryScrollPane = GuiHelper.embedInVerticalScrollPane(this.query);
491 queryScrollPane.getVerticalScrollBar().setUnitIncrement(10); // make scrolling smooth
492
493 GBC constraint = GBC.eol().insets(8, 0, 8, 8).anchor(GBC.CENTER).fill(GBC.HORIZONTAL);
494 constraint.ipady = 250;
495 panel.add(this.name, GBC.eol().insets(5).anchor(GBC.SOUTHEAST).fill(GBC.HORIZONTAL));
496 panel.add(queryScrollPane, constraint);
497
498 setDefaultButton(SUCCESS_BTN + 1);
499 setCancelButton(CANCEL_BTN + 1);
500 setPreferredSize(new Dimension(400, 400));
501 setContent(panel, false);
502 }
503
504 /**
505 * Gets a new {@link SelectorItem} if one was created/modified.
506 * @return A {@link SelectorItem} object created out of the fields of the dialog.
507 */
508 public Optional<SelectorItem> getOutputItem() {
509 return this.outputItem;
510 }
511
512 @Override
513 protected void buttonAction(int buttonIndex, ActionEvent evt) {
514 if (buttonIndex == SUCCESS_BTN) {
515 if (!this.nameValidator.isValid()) {
516 JOptionPane.showMessageDialog(
517 componentParent,
518 tr("The item cannot be created with provided name"),
519 tr("Warning"),
520 JOptionPane.WARNING_MESSAGE);
521
522 return;
523 } else if (!this.queryValidator.isValid()) {
524 JOptionPane.showMessageDialog(
525 componentParent,
526 tr("The item cannot be created with an empty query"),
527 tr("Warning"),
528 JOptionPane.WARNING_MESSAGE);
529
530 return;
531 } else if (this.itemToEdit != null) { // editing the item
532 String newKey = this.name.getText();
533 String newQuery = this.query.getText();
534
535 String itemKey = this.itemToEdit.getKey();
536 String itemQuery = this.itemToEdit.getQuery();
537
538 this.outputItem = Optional.of(new SelectorItem(
539 this.name.getText(),
540 this.query.getText(),
541 !newKey.equals(itemKey) || !newQuery.equals(itemQuery)
542 ? LocalDateTime.now()
543 : this.itemToEdit.getLastEdit()));
544
545 } else { // creating new
546 this.outputItem = Optional.of(new SelectorItem(
547 this.name.getText(),
548 this.query.getText()));
549 }
550 }
551
552 super.buttonAction(buttonIndex, evt);
553 }
554 }
555
556 /**
557 * This class represents an Overpass query used by the user that can be
558 * shown within {@link UserQueryList}.
559 */
560 public static class SelectorItem {
561 private final String itemKey;
562 private final String query;
563 private final LocalDateTime lastEdit;
564
565 /**
566 * Constructs a new {@code SelectorItem}.
567 * @param key The key of this item.
568 * @param query The query of the item.
569 * @exception NullPointerException if any parameter is {@code null}.
570 * @exception IllegalArgumentException if any parameter is empty.
571 */
572 public SelectorItem(String key, String query) {
573 this(key, query, LocalDateTime.now());
574 }
575
576 /**
577 * Constructs a new {@code SelectorItem}.
578 * @param key The key of this item.
579 * @param query The query of the item.
580 * @param lastEdit The latest when the item was
581 * @exception NullPointerException if any parameter is {@code null}.
582 * @exception IllegalArgumentException if any parameter is empty.
583 */
584 public SelectorItem(String key, String query, LocalDateTime lastEdit) {
585 Objects.requireNonNull(key, "The name of the item cannot be null");
586 Objects.requireNonNull(query, "The query of the item cannot be null");
587 Objects.requireNonNull(lastEdit, "The last edit date time cannot be null");
588
589 if (Utils.isStripEmpty(key)) {
590 throw new IllegalArgumentException("The key of the item cannot be empty");
591 }
592 if (Utils.isStripEmpty(query)) {
593 throw new IllegalArgumentException("The query cannot be empty");
594 }
595
596 this.itemKey = key;
597 this.query = query;
598 this.lastEdit = lastEdit;
599 }
600
601 /**
602 * Gets the key (a string that is displayed in the selector) of this item.
603 * @return A string representing the key of this item.
604 */
605 public String getKey() {
606 return this.itemKey;
607 }
608
609 /**
610 * Gets the query of this item.
611 * @return A string representing the query of this item.
612 */
613 public String getQuery() {
614 return this.query;
615 }
616
617 /**
618 * Gets the latest date time when the item was created/changed.
619 * @return The latest date time when the item was created/changed.
620 */
621 public LocalDateTime getLastEdit() {
622 return lastEdit;
623 }
624
625 @Override
626 public int hashCode() {
627 final int prime = 31;
628 int result = 1;
629 result = prime * result + ((itemKey == null) ? 0 : itemKey.hashCode());
630 result = prime * result + ((query == null) ? 0 : query.hashCode());
631 return result;
632 }
633
634 @Override
635 public boolean equals(Object obj) {
636 if (this == obj) {
637 return true;
638 }
639 if (obj == null) {
640 return false;
641 }
642 if (getClass() != obj.getClass()) {
643 return false;
644 }
645 SelectorItem other = (SelectorItem) obj;
646 if (itemKey == null) {
647 if (other.itemKey != null) {
648 return false;
649 }
650 } else if (!itemKey.equals(other.itemKey)) {
651 return false;
652 }
653 if (query == null) {
654 if (other.query != null) {
655 return false;
656 }
657 } else if (!query.equals(other.query)) {
658 return false;
659 }
660 return true;
661 }
662 }
663}
Note: See TracBrowser for help on using the repository browser.