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