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.gui.ExtendedDialog;
045import org.openstreetmap.josm.gui.util.GuiHelper;
046import org.openstreetmap.josm.gui.widgets.AbstractTextComponentValidator;
047import org.openstreetmap.josm.gui.widgets.DefaultTextComponentValidator;
048import org.openstreetmap.josm.gui.widgets.JosmTextArea;
049import org.openstreetmap.josm.gui.widgets.SearchTextResultListPanel;
050import org.openstreetmap.josm.spi.preferences.Config;
051import org.openstreetmap.josm.tools.GBC;
052import org.openstreetmap.josm.tools.Logging;
053import org.openstreetmap.josm.tools.Utils;
054
055/**
056 * A component to select user saved queries.
057 * @since 12880
058 * @since 12574 as OverpassQueryList
059 */
060public final class UserQueryList extends SearchTextResultListPanel<UserQueryList.SelectorItem> {
061
062    private static final DateTimeFormatter FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss, dd-MM-yyyy");
063
064    /*
065     * GUI elements
066     */
067    private final JTextComponent target;
068    private final Component componentParent;
069
070    /*
071     * All loaded elements within the list.
072     */
073    private final transient Map<String, SelectorItem> items;
074
075    /*
076     * Preferences
077     */
078    private static final String KEY_KEY = "key";
079    private static final String QUERY_KEY = "query";
080    private static final String LAST_EDIT_KEY = "lastEdit";
081    private final String preferenceKey;
082
083    private static final String TRANSLATED_HISTORY = tr("history");
084
085    /**
086     * Constructs a new {@code OverpassQueryList}.
087     * @param parent The parent of this component.
088     * @param target The text component to which the queries must be added.
089     * @param preferenceKey The {@linkplain org.openstreetmap.josm.spi.preferences.IPreferences preference} key to store the user queries
090     */
091    public UserQueryList(Component parent, JTextComponent target, String preferenceKey) {
092        this.target = target;
093        this.componentParent = parent;
094        this.preferenceKey = preferenceKey;
095        this.items = restorePreferences();
096
097        QueryListMouseAdapter mouseHandler = new QueryListMouseAdapter(lsResult, lsResultModel);
098        super.lsResult.setCellRenderer(new QueryCellRendered());
099        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}