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.format.DateTimeFormatter;
018import java.time.format.DateTimeParseException;
019import java.util.ArrayList;
020import java.util.Collection;
021import java.util.Collections;
022import java.util.HashMap;
023import java.util.List;
024import java.util.Locale;
025import java.util.Map;
026import java.util.Objects;
027import java.util.Optional;
028import java.util.stream.Collectors;
029
030import javax.swing.AbstractAction;
031import javax.swing.BorderFactory;
032import javax.swing.JLabel;
033import javax.swing.JList;
034import javax.swing.JOptionPane;
035import javax.swing.JPanel;
036import javax.swing.JPopupMenu;
037import javax.swing.JScrollPane;
038import javax.swing.JTextField;
039import javax.swing.ListCellRenderer;
040import javax.swing.SwingUtilities;
041import javax.swing.border.CompoundBorder;
042import javax.swing.text.JTextComponent;
043
044import org.openstreetmap.josm.Main;
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().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().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 Main#pref}.
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 Main#pref}.
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                    emptySelectionPopup.show(list, e.getX(), e.getY());
327                } else {
328                    list.setSelectedIndex(index);
329                    list.ensureIndexIsVisible(index);
330                    elementPopup.show(list, e.getX(), e.getY());
331                }
332            }
333        }
334
335        @Override
336        public void mouseMoved(MouseEvent e) {
337            super.mouseMoved(e);
338            int idx = locationToIndex(e.getPoint());
339            if (idx == -1) {
340                return;
341            }
342
343            SelectorItem item = model.getElementAt(idx);
344            list.setToolTipText("<html><pre style='width:300px;'>" +
345                    Utils.escapeReservedCharactersHTML(Utils.restrictStringLines(item.getQuery(), 9)));
346        }
347
348        private void initPopupMenus() {
349            AbstractAction add = new AbstractAction(tr("Add")) {
350                @Override
351                public void actionPerformed(ActionEvent e) {
352                    createNewItem();
353                }
354            };
355            AbstractAction edit = new AbstractAction(tr("Edit")) {
356                @Override
357                public void actionPerformed(ActionEvent e) {
358                    editSelectedItem();
359                }
360            };
361            AbstractAction remove = new AbstractAction(tr("Remove")) {
362                @Override
363                public void actionPerformed(ActionEvent e) {
364                    removeSelectedItem();
365                }
366            };
367            this.emptySelectionPopup.add(add);
368            this.elementPopup.add(add);
369            this.elementPopup.add(edit);
370            this.elementPopup.add(remove);
371        }
372    }
373
374    /**
375     * This class defines the way each element is rendered in the list.
376     */
377    private static class QueryCellRendered extends JLabel implements ListCellRenderer<SelectorItem> {
378
379        QueryCellRendered() {
380            setOpaque(true);
381        }
382
383        @Override
384        public Component getListCellRendererComponent(
385                JList<? extends SelectorItem> list,
386                SelectorItem value,
387                int index,
388                boolean isSelected,
389                boolean cellHasFocus) {
390
391            Font font = list.getFont();
392            if (isSelected) {
393                setFont(new Font(font.getFontName(), Font.BOLD, font.getSize() + 2));
394                setBackground(list.getSelectionBackground());
395                setForeground(list.getSelectionForeground());
396            } else {
397                setFont(new Font(font.getFontName(), Font.PLAIN, font.getSize() + 2));
398                setBackground(list.getBackground());
399                setForeground(list.getForeground());
400            }
401
402            setEnabled(list.isEnabled());
403            setText(value.getKey());
404
405            if (isSelected && cellHasFocus) {
406                setBorder(new CompoundBorder(
407                        BorderFactory.createLineBorder(Color.BLACK, 1),
408                        BorderFactory.createEmptyBorder(2, 0, 2, 0)));
409            } else {
410                setBorder(new CompoundBorder(
411                        null,
412                        BorderFactory.createEmptyBorder(2, 0, 2, 0)));
413            }
414
415            return this;
416        }
417    }
418
419    /**
420     * Dialog that provides functionality to add/edit an item from the list.
421     */
422    private final class EditItemDialog extends ExtendedDialog {
423
424        private final JTextField name;
425        private final JosmTextArea query;
426
427        private final transient AbstractTextComponentValidator queryValidator;
428        private final transient AbstractTextComponentValidator nameValidator;
429
430        private static final int SUCCESS_BTN = 0;
431        private static final int CANCEL_BTN = 1;
432
433        private final transient SelectorItem itemToEdit;
434
435        /**
436         * Added/Edited object to be returned. If {@link Optional#empty()} then probably
437         * the user closed the dialog, otherwise {@link SelectorItem} is present.
438         */
439        private transient Optional<SelectorItem> outputItem = Optional.empty();
440
441        EditItemDialog(Component parent, String title, String... buttonTexts) {
442            this(parent, title, null, buttonTexts);
443        }
444
445        EditItemDialog(
446                Component parent,
447                String title,
448                SelectorItem itemToEdit,
449                String... buttonTexts) {
450            super(parent, title, buttonTexts);
451
452            this.itemToEdit = itemToEdit;
453
454            String nameToEdit = itemToEdit == null ? "" : itemToEdit.getKey();
455            String queryToEdit = itemToEdit == null ? "" : itemToEdit.getQuery();
456
457            this.name = new JTextField(nameToEdit);
458            this.query = new JosmTextArea(queryToEdit);
459
460            this.queryValidator = new DefaultTextComponentValidator(this.query, "", tr("Query cannot be empty"));
461            this.nameValidator = new AbstractTextComponentValidator(this.name) {
462                @Override
463                public void validate() {
464                    if (isValid()) {
465                        feedbackValid(tr("This name can be used for the item"));
466                    } else {
467                        feedbackInvalid(tr("Item with this name already exists"));
468                    }
469                }
470
471                @Override
472                public boolean isValid() {
473                    String currentName = name.getText();
474
475                    boolean notEmpty = !Utils.isStripEmpty(currentName);
476                    boolean exist = !currentName.equals(nameToEdit) &&
477                                        items.containsKey(currentName);
478
479                    return notEmpty && !exist;
480                }
481            };
482
483            this.name.getDocument().addDocumentListener(this.nameValidator);
484            this.query.getDocument().addDocumentListener(this.queryValidator);
485
486            JPanel panel = new JPanel(new GridBagLayout());
487            JScrollPane queryScrollPane = GuiHelper.embedInVerticalScrollPane(this.query);
488            queryScrollPane.getVerticalScrollBar().setUnitIncrement(10); // make scrolling smooth
489
490            GBC constraint = GBC.eol().insets(8, 0, 8, 8).anchor(GBC.CENTER).fill(GBC.HORIZONTAL);
491            constraint.ipady = 250;
492            panel.add(this.name, GBC.eol().insets(5).anchor(GBC.SOUTHEAST).fill(GBC.HORIZONTAL));
493            panel.add(queryScrollPane, constraint);
494
495            setDefaultButton(SUCCESS_BTN + 1);
496            setCancelButton(CANCEL_BTN + 1);
497            setPreferredSize(new Dimension(400, 400));
498            setContent(panel, false);
499        }
500
501        /**
502         * Gets a new {@link SelectorItem} if one was created/modified.
503         * @return A {@link SelectorItem} object created out of the fields of the dialog.
504         */
505        public Optional<SelectorItem> getOutputItem() {
506            return this.outputItem;
507        }
508
509        @Override
510        protected void buttonAction(int buttonIndex, ActionEvent evt) {
511            if (buttonIndex == SUCCESS_BTN) {
512                if (!this.nameValidator.isValid()) {
513                    JOptionPane.showMessageDialog(
514                            componentParent,
515                            tr("The item cannot be created with provided name"),
516                            tr("Warning"),
517                            JOptionPane.WARNING_MESSAGE);
518
519                    return;
520                } else if (!this.queryValidator.isValid()) {
521                    JOptionPane.showMessageDialog(
522                            componentParent,
523                            tr("The item cannot be created with an empty query"),
524                            tr("Warning"),
525                            JOptionPane.WARNING_MESSAGE);
526
527                    return;
528                } else if (this.itemToEdit != null) { // editing the item
529                    String newKey = this.name.getText();
530                    String newQuery = this.query.getText();
531
532                    String itemKey = this.itemToEdit.getKey();
533                    String itemQuery = this.itemToEdit.getQuery();
534
535                    this.outputItem = Optional.of(new SelectorItem(
536                            this.name.getText(),
537                            this.query.getText(),
538                            !newKey.equals(itemKey) || !newQuery.equals(itemQuery)
539                                ? LocalDateTime.now()
540                                : this.itemToEdit.getLastEdit()));
541
542                } else { // creating new
543                    this.outputItem = Optional.of(new SelectorItem(
544                            this.name.getText(),
545                            this.query.getText()));
546                }
547            }
548
549            super.buttonAction(buttonIndex, evt);
550        }
551    }
552
553    /**
554     * This class represents an Overpass query used by the user that can be
555     * shown within {@link UserQueryList}.
556     */
557    public static class SelectorItem {
558        private final String itemKey;
559        private final String query;
560        private final LocalDateTime lastEdit;
561
562        /**
563         * Constructs a new {@code SelectorItem}.
564         * @param key The key of this item.
565         * @param query The query of the item.
566         * @exception NullPointerException if any parameter is {@code null}.
567         * @exception IllegalArgumentException if any parameter is empty.
568         */
569        public SelectorItem(String key, String query) {
570            this(key, query, LocalDateTime.now());
571        }
572
573        /**
574         * Constructs a new {@code SelectorItem}.
575         * @param key The key of this item.
576         * @param query The query of the item.
577         * @param lastEdit The latest when the item was
578         * @exception NullPointerException if any parameter is {@code null}.
579         * @exception IllegalArgumentException if any parameter is empty.
580         */
581        public SelectorItem(String key, String query, LocalDateTime lastEdit) {
582            Objects.requireNonNull(key, "The name of the item cannot be null");
583            Objects.requireNonNull(query, "The query of the item cannot be null");
584            Objects.requireNonNull(lastEdit, "The last edit date time cannot be null");
585
586            if (Utils.isStripEmpty(key)) {
587                throw new IllegalArgumentException("The key of the item cannot be empty");
588            }
589            if (Utils.isStripEmpty(query)) {
590                throw new IllegalArgumentException("The query cannot be empty");
591            }
592
593            this.itemKey = key;
594            this.query = query;
595            this.lastEdit = lastEdit;
596        }
597
598        /**
599         * Gets the key (a string that is displayed in the selector) of this item.
600         * @return A string representing the key of this item.
601         */
602        public String getKey() {
603            return this.itemKey;
604        }
605
606        /**
607         * Gets the query of this item.
608         * @return A string representing the query of this item.
609         */
610        public String getQuery() {
611            return this.query;
612        }
613
614        /**
615         * Gets the latest date time when the item was created/changed.
616         * @return The latest date time when the item was created/changed.
617         */
618        public LocalDateTime getLastEdit() {
619            return lastEdit;
620        }
621
622        @Override
623        public int hashCode() {
624            final int prime = 31;
625            int result = 1;
626            result = prime * result + ((itemKey == null) ? 0 : itemKey.hashCode());
627            result = prime * result + ((query == null) ? 0 : query.hashCode());
628            return result;
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}