001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.actions.search;
003
004import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
005import static org.openstreetmap.josm.tools.I18n.tr;
006import static org.openstreetmap.josm.tools.I18n.trn;
007
008import java.awt.Component;
009import java.awt.Cursor;
010import java.awt.Dimension;
011import java.awt.FlowLayout;
012import java.awt.GraphicsEnvironment;
013import java.awt.GridBagLayout;
014import java.awt.event.ActionEvent;
015import java.awt.event.KeyEvent;
016import java.awt.event.MouseAdapter;
017import java.awt.event.MouseEvent;
018import java.util.ArrayList;
019import java.util.Arrays;
020import java.util.Collection;
021import java.util.Collections;
022import java.util.HashSet;
023import java.util.LinkedHashSet;
024import java.util.LinkedList;
025import java.util.List;
026import java.util.Map;
027import java.util.Set;
028import java.util.function.Predicate;
029
030import javax.swing.BorderFactory;
031import javax.swing.ButtonGroup;
032import javax.swing.JCheckBox;
033import javax.swing.JLabel;
034import javax.swing.JOptionPane;
035import javax.swing.JPanel;
036import javax.swing.JRadioButton;
037import javax.swing.SwingUtilities;
038import javax.swing.text.BadLocationException;
039import javax.swing.text.Document;
040import javax.swing.text.JTextComponent;
041
042import org.openstreetmap.josm.Main;
043import org.openstreetmap.josm.actions.ActionParameter;
044import org.openstreetmap.josm.actions.ExpertToggleAction;
045import org.openstreetmap.josm.actions.JosmAction;
046import org.openstreetmap.josm.actions.ParameterizedAction;
047import org.openstreetmap.josm.data.osm.Filter;
048import org.openstreetmap.josm.data.osm.IPrimitive;
049import org.openstreetmap.josm.data.osm.OsmData;
050import org.openstreetmap.josm.data.osm.search.PushbackTokenizer;
051import org.openstreetmap.josm.data.osm.search.SearchCompiler;
052import org.openstreetmap.josm.data.osm.search.SearchCompiler.Match;
053import org.openstreetmap.josm.data.osm.search.SearchCompiler.SimpleMatchFactory;
054import org.openstreetmap.josm.data.osm.search.SearchMode;
055import org.openstreetmap.josm.data.osm.search.SearchParseError;
056import org.openstreetmap.josm.data.osm.search.SearchSetting;
057import org.openstreetmap.josm.gui.ExtendedDialog;
058import org.openstreetmap.josm.gui.MainApplication;
059import org.openstreetmap.josm.gui.MapFrame;
060import org.openstreetmap.josm.gui.Notification;
061import org.openstreetmap.josm.gui.PleaseWaitRunnable;
062import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSException;
063import org.openstreetmap.josm.gui.preferences.ToolbarPreferences;
064import org.openstreetmap.josm.gui.preferences.ToolbarPreferences.ActionParser;
065import org.openstreetmap.josm.gui.progress.ProgressMonitor;
066import org.openstreetmap.josm.gui.tagging.presets.TaggingPreset;
067import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetSelector;
068import org.openstreetmap.josm.gui.widgets.AbstractTextComponentValidator;
069import org.openstreetmap.josm.gui.widgets.HistoryComboBox;
070import org.openstreetmap.josm.spi.preferences.Config;
071import org.openstreetmap.josm.tools.GBC;
072import org.openstreetmap.josm.tools.JosmRuntimeException;
073import org.openstreetmap.josm.tools.Logging;
074import org.openstreetmap.josm.tools.Shortcut;
075import org.openstreetmap.josm.tools.Utils;
076
077/**
078 * The search action allows the user to search the data layer using a complex search string.
079 *
080 * @see SearchCompiler
081 */
082public class SearchAction extends JosmAction implements ParameterizedAction {
083
084    /**
085     * The default size of the search history
086     */
087    public static final int DEFAULT_SEARCH_HISTORY_SIZE = 15;
088    /**
089     * Maximum number of characters before the search expression is shortened for display purposes.
090     */
091    public static final int MAX_LENGTH_SEARCH_EXPRESSION_DISPLAY = 100;
092
093    private static final String SEARCH_EXPRESSION = "searchExpression";
094
095    private static final LinkedList<SearchSetting> searchHistory = new LinkedList<>();
096    static {
097        SearchCompiler.addMatchFactory(new SimpleMatchFactory() {
098            @Override
099            public Collection<String> getKeywords() {
100                return Arrays.asList("inview", "allinview");
101            }
102
103            @Override
104            public Match get(String keyword, boolean caseSensitive, boolean regexSearch, PushbackTokenizer tokenizer) throws SearchParseError {
105                switch(keyword) {
106                case "inview":
107                    return new InView(false);
108                case "allinview":
109                    return new InView(true);
110                default:
111                    throw new IllegalStateException("Not expecting keyword " + keyword);
112                }
113            }
114        });
115
116        for (String s: Config.getPref().getList("search.history", Collections.<String>emptyList())) {
117            SearchSetting ss = SearchSetting.readFromString(s);
118            if (ss != null) {
119                searchHistory.add(ss);
120            }
121        }
122    }
123
124    /**
125     * Gets the search history
126     * @return The last searched terms. Do not modify it.
127     */
128    public static Collection<SearchSetting> getSearchHistory() {
129        return searchHistory;
130    }
131
132    /**
133     * Saves a search to the search history.
134     * @param s The search to save
135     */
136    public static void saveToHistory(SearchSetting s) {
137        if (searchHistory.isEmpty() || !s.equals(searchHistory.getFirst())) {
138            searchHistory.addFirst(new SearchSetting(s));
139        } else if (searchHistory.contains(s)) {
140            // move existing entry to front, fixes #8032 - search history loses entries when re-using queries
141            searchHistory.remove(s);
142            searchHistory.addFirst(new SearchSetting(s));
143        }
144        int maxsize = Config.getPref().getInt("search.history-size", DEFAULT_SEARCH_HISTORY_SIZE);
145        while (searchHistory.size() > maxsize) {
146            searchHistory.removeLast();
147        }
148        Set<String> savedHistory = new LinkedHashSet<>(searchHistory.size());
149        for (SearchSetting item: searchHistory) {
150            savedHistory.add(item.writeToString());
151        }
152        Config.getPref().putList("search.history", new ArrayList<>(savedHistory));
153    }
154
155    /**
156     * Gets a list of all texts that were recently used in the search
157     * @return The list of search texts.
158     */
159    public static List<String> getSearchExpressionHistory() {
160        List<String> ret = new ArrayList<>(getSearchHistory().size());
161        for (SearchSetting ss: getSearchHistory()) {
162            ret.add(ss.text);
163        }
164        return ret;
165    }
166
167    private static volatile SearchSetting lastSearch;
168
169    /**
170     * Constructs a new {@code SearchAction}.
171     */
172    public SearchAction() {
173        super(tr("Search..."), "dialogs/search", tr("Search for objects"),
174                Shortcut.registerShortcut("system:find", tr("Search..."), KeyEvent.VK_F, Shortcut.CTRL), true);
175        putValue("help", ht("/Action/Search"));
176    }
177
178    @Override
179    public void actionPerformed(ActionEvent e) {
180        if (!isEnabled())
181            return;
182        search();
183    }
184
185    @Override
186    public void actionPerformed(ActionEvent e, Map<String, Object> parameters) {
187        if (parameters.get(SEARCH_EXPRESSION) == null) {
188            actionPerformed(e);
189        } else {
190            searchWithoutHistory((SearchSetting) parameters.get(SEARCH_EXPRESSION));
191        }
192    }
193
194    private static class SearchKeywordRow extends JPanel {
195
196        private final HistoryComboBox hcb;
197
198        SearchKeywordRow(HistoryComboBox hcb) {
199            super(new FlowLayout(FlowLayout.LEFT));
200            this.hcb = hcb;
201        }
202
203        /**
204         * Adds the title (prefix) label at the beginning of the row. Should be called only once.
205         * @param title English title
206         * @return {@code this} for easy chaining
207         */
208        public SearchKeywordRow addTitle(String title) {
209            add(new JLabel(tr("{0}: ", title)));
210            return this;
211        }
212
213        /**
214         * Adds an example keyword label at the end of the row. Can be called several times.
215         * @param displayText displayed HTML text
216         * @param insertText optional: if set, makes the label clickable, and {@code insertText} will be inserted in search string
217         * @param description optional: HTML text to be displayed in the tooltip
218         * @param examples optional: examples joined as HTML list in the tooltip
219         * @return {@code this} for easy chaining
220         */
221        public SearchKeywordRow addKeyword(String displayText, final String insertText, String description, String... examples) {
222            JLabel label = new JLabel("<html>"
223                    + "<style>td{border:1px solid gray; font-weight:normal;}</style>"
224                    + "<table><tr><td>" + displayText + "</td></tr></table></html>");
225            add(label);
226            if (description != null || examples.length > 0) {
227                label.setToolTipText("<html>"
228                        + description
229                        + (examples.length > 0 ? Utils.joinAsHtmlUnorderedList(Arrays.asList(examples)) : "")
230                        + "</html>");
231            }
232            if (insertText != null) {
233                label.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
234                label.addMouseListener(new MouseAdapter() {
235
236                    @Override
237                    public void mouseClicked(MouseEvent e) {
238                        JTextComponent tf = hcb.getEditorComponent();
239
240                        /*
241                         * Make sure that the focus is transferred to the search text field
242                         * from the selector component.
243                         */
244                        if (!tf.hasFocus()) {
245                            tf.requestFocusInWindow();
246                        }
247
248                        /*
249                         * In order to make interaction with the search dialog simpler,
250                         * we make sure that if autocompletion triggers and the text field is
251                         * not in focus, the correct area is selected. We first request focus
252                         * and then execute the selection logic. invokeLater allows us to
253                         * defer the selection until waiting for focus.
254                         */
255                        SwingUtilities.invokeLater(() -> {
256                            try {
257                                tf.getDocument().insertString(tf.getCaretPosition(), ' ' + insertText, null);
258                            } catch (BadLocationException ex) {
259                                throw new JosmRuntimeException(ex.getMessage(), ex);
260                            }
261                        });
262                    }
263                });
264            }
265            return this;
266        }
267    }
268
269    /**
270     * Builds and shows the search dialog.
271     * @param initialValues A set of initial values needed in order to initialize the search dialog.
272     *                      If is {@code null}, then default settings are used.
273     * @return Returns {@link SearchAction} object containing parameters of the search.
274     */
275    public static SearchSetting showSearchDialog(SearchSetting initialValues) {
276        if (initialValues == null) {
277            initialValues = new SearchSetting();
278        }
279
280        // prepare the combo box with the search expressions
281        JLabel label = new JLabel(initialValues instanceof Filter ? tr("Filter string:") : tr("Search string:"));
282        HistoryComboBox hcbSearchString = new HistoryComboBox();
283        String tooltip = tr("Enter the search expression");
284        hcbSearchString.setText(initialValues.text);
285        hcbSearchString.setToolTipText(tooltip);
286
287        // we have to reverse the history, because ComboBoxHistory will reverse it again in addElement()
288        List<String> searchExpressionHistory = getSearchExpressionHistory();
289        Collections.reverse(searchExpressionHistory);
290        hcbSearchString.setPossibleItems(searchExpressionHistory);
291        hcbSearchString.setPreferredSize(new Dimension(40, hcbSearchString.getPreferredSize().height));
292        label.setLabelFor(hcbSearchString);
293
294        JRadioButton replace = new JRadioButton(tr("replace selection"), initialValues.mode == SearchMode.replace);
295        JRadioButton add = new JRadioButton(tr("add to selection"), initialValues.mode == SearchMode.add);
296        JRadioButton remove = new JRadioButton(tr("remove from selection"), initialValues.mode == SearchMode.remove);
297        JRadioButton inSelection = new JRadioButton(tr("find in selection"), initialValues.mode == SearchMode.in_selection);
298        ButtonGroup bg = new ButtonGroup();
299        bg.add(replace);
300        bg.add(add);
301        bg.add(remove);
302        bg.add(inSelection);
303
304        JCheckBox caseSensitive = new JCheckBox(tr("case sensitive"), initialValues.caseSensitive);
305        JCheckBox allElements = new JCheckBox(tr("all objects"), initialValues.allElements);
306        allElements.setToolTipText(tr("Also include incomplete and deleted objects in search."));
307        JCheckBox addOnToolbar = new JCheckBox(tr("add toolbar button"), false);
308
309        JRadioButton standardSearch = new JRadioButton(tr("standard"), !initialValues.regexSearch && !initialValues.mapCSSSearch);
310        JRadioButton regexSearch = new JRadioButton(tr("regular expression"), initialValues.regexSearch);
311        JRadioButton mapCSSSearch = new JRadioButton(tr("MapCSS selector"), initialValues.mapCSSSearch);
312        ButtonGroup bg2 = new ButtonGroup();
313        bg2.add(standardSearch);
314        bg2.add(regexSearch);
315        bg2.add(mapCSSSearch);
316
317        JPanel selectionSettings = new JPanel(new GridBagLayout());
318        selectionSettings.setBorder(BorderFactory.createTitledBorder(tr("Selection settings")));
319        selectionSettings.add(replace, GBC.eol().anchor(GBC.WEST).fill(GBC.HORIZONTAL));
320        selectionSettings.add(add, GBC.eol());
321        selectionSettings.add(remove, GBC.eol());
322        selectionSettings.add(inSelection, GBC.eop());
323
324        JPanel additionalSettings = new JPanel(new GridBagLayout());
325        additionalSettings.setBorder(BorderFactory.createTitledBorder(tr("Additional settings")));
326        additionalSettings.add(caseSensitive, GBC.eol().anchor(GBC.WEST).fill(GBC.HORIZONTAL));
327
328        JPanel left = new JPanel(new GridBagLayout());
329
330        left.add(selectionSettings, GBC.eol().fill(GBC.BOTH));
331        left.add(additionalSettings, GBC.eol().fill(GBC.BOTH));
332
333        if (ExpertToggleAction.isExpert()) {
334            additionalSettings.add(allElements, GBC.eol());
335            additionalSettings.add(addOnToolbar, GBC.eop());
336
337            JPanel searchOptions = new JPanel(new GridBagLayout());
338            searchOptions.setBorder(BorderFactory.createTitledBorder(tr("Search syntax")));
339            searchOptions.add(standardSearch, GBC.eol().anchor(GBC.WEST).fill(GBC.HORIZONTAL));
340            searchOptions.add(regexSearch, GBC.eol());
341            searchOptions.add(mapCSSSearch, GBC.eol());
342
343            left.add(searchOptions, GBC.eol().fill(GBC.BOTH));
344        }
345
346        JPanel right = SearchAction.buildHintsSection(hcbSearchString);
347        JPanel top = new JPanel(new GridBagLayout());
348        top.add(label, GBC.std().insets(0, 0, 5, 0));
349        top.add(hcbSearchString, GBC.eol().fill(GBC.HORIZONTAL));
350
351        JTextComponent editorComponent = hcbSearchString.getEditorComponent();
352        Document document = editorComponent.getDocument();
353
354        /*
355         * Setup the logic to validate the contents of the search text field which is executed
356         * every time the content of the field has changed. If the query is incorrect, then
357         * the text field is colored red.
358         */
359        document.addDocumentListener(new AbstractTextComponentValidator(editorComponent) {
360
361            @Override
362            public void validate() {
363                if (!isValid()) {
364                    feedbackInvalid(tr("Invalid search expression"));
365                } else {
366                    feedbackValid(tooltip);
367                }
368            }
369
370            @Override
371            public boolean isValid() {
372                try {
373                    SearchSetting ss = new SearchSetting();
374                    ss.text = hcbSearchString.getText();
375                    ss.caseSensitive = caseSensitive.isSelected();
376                    ss.regexSearch = regexSearch.isSelected();
377                    ss.mapCSSSearch = mapCSSSearch.isSelected();
378                    SearchCompiler.compile(ss);
379                    return true;
380                } catch (SearchParseError | MapCSSException e) {
381                    return false;
382                }
383            }
384        });
385
386        /*
387         * Setup the logic to append preset queries to the search text field according to
388         * selected preset by the user. Every query is of the form ' group/sub-group/.../presetName'
389         * if the corresponding group of the preset exists, otherwise it is simply ' presetName'.
390         */
391        TaggingPresetSelector selector = new TaggingPresetSelector(false, false);
392        selector.setBorder(BorderFactory.createTitledBorder(tr("Search by preset")));
393        selector.setDblClickListener(ev -> setPresetDblClickListener(selector, editorComponent));
394
395        JPanel p = new JPanel(new GridBagLayout());
396        p.add(top, GBC.eol().fill(GBC.HORIZONTAL).insets(5, 5, 5, 0));
397        p.add(left, GBC.std().anchor(GBC.NORTH).insets(5, 10, 10, 0).fill(GBC.VERTICAL));
398        p.add(right, GBC.std().fill(GBC.BOTH).insets(0, 10, 0, 0));
399        p.add(selector, GBC.eol().fill(GBC.BOTH).insets(0, 10, 0, 0));
400
401        ExtendedDialog dialog = new ExtendedDialog(
402                Main.parent,
403                initialValues instanceof Filter ? tr("Filter") : tr("Search"),
404                initialValues instanceof Filter ? tr("Submit filter") : tr("Start Search"),
405                tr("Cancel")
406        ) {
407            @Override
408            protected void buttonAction(int buttonIndex, ActionEvent evt) {
409                if (buttonIndex == 0) {
410                    try {
411                        SearchSetting ss = new SearchSetting();
412                        ss.text = hcbSearchString.getText();
413                        ss.caseSensitive = caseSensitive.isSelected();
414                        ss.regexSearch = regexSearch.isSelected();
415                        ss.mapCSSSearch = mapCSSSearch.isSelected();
416                        SearchCompiler.compile(ss);
417                        super.buttonAction(buttonIndex, evt);
418                    } catch (SearchParseError | MapCSSException e) {
419                        Logging.debug(e);
420                        JOptionPane.showMessageDialog(
421                                Main.parent,
422                                "<html>" + tr("Search expression is not valid: \n\n {0}",
423                                        e.getMessage().replace("<html>", "").replace("</html>", "")).replace("\n", "<br>") +
424                                "</html>",
425                                tr("Invalid search expression"),
426                                JOptionPane.ERROR_MESSAGE);
427                    }
428                } else {
429                    super.buttonAction(buttonIndex, evt);
430                }
431            }
432        };
433        dialog.setButtonIcons("dialogs/search", "cancel");
434        dialog.configureContextsensitiveHelp("/Action/Search", true /* show help button */);
435        dialog.setContent(p);
436
437        if (dialog.showDialog().getValue() != 1) return null;
438
439        // User pressed OK - let's perform the search
440        initialValues.text = hcbSearchString.getText();
441        initialValues.caseSensitive = caseSensitive.isSelected();
442        initialValues.allElements = allElements.isSelected();
443        initialValues.regexSearch = regexSearch.isSelected();
444        initialValues.mapCSSSearch = mapCSSSearch.isSelected();
445
446        if (inSelection.isSelected()) {
447            initialValues.mode = SearchMode.in_selection;
448        } else if (replace.isSelected()) {
449            initialValues.mode = SearchMode.replace;
450        } else if (add.isSelected()) {
451            initialValues.mode = SearchMode.add;
452        } else {
453            initialValues.mode = SearchMode.remove;
454        }
455
456        if (addOnToolbar.isSelected()) {
457            ToolbarPreferences.ActionDefinition aDef =
458                    new ToolbarPreferences.ActionDefinition(MainApplication.getMenu().search);
459            aDef.getParameters().put(SEARCH_EXPRESSION, initialValues);
460            // Display search expression as tooltip instead of generic one
461            aDef.setName(Utils.shortenString(initialValues.text, MAX_LENGTH_SEARCH_EXPRESSION_DISPLAY));
462            // parametrized action definition is now composed
463            ActionParser actionParser = new ToolbarPreferences.ActionParser(null);
464            String res = actionParser.saveAction(aDef);
465
466            // add custom search button to toolbar preferences
467            MainApplication.getToolbar().addCustomButton(res, -1, false);
468        }
469
470        return initialValues;
471    }
472
473    private static JPanel buildHintsSection(HistoryComboBox hcbSearchString) {
474        JPanel hintPanel = new JPanel(new GridBagLayout());
475        hintPanel.setBorder(BorderFactory.createTitledBorder(tr("Search hints")));
476
477        hintPanel.add(new SearchKeywordRow(hcbSearchString)
478                .addTitle(tr("basics"))
479                .addKeyword(tr("Baker Street"), null, tr("''Baker'' and ''Street'' in any key"))
480                .addKeyword(tr("\"Baker Street\""), "\"\"", tr("''Baker Street'' in any key"))
481                .addKeyword("<i>key</i>:<i>valuefragment</i>", null,
482                        tr("''valuefragment'' anywhere in ''key''"), "name:str matches name=Bakerstreet")
483                .addKeyword("-<i>key</i>:<i>valuefragment</i>", null, tr("''valuefragment'' nowhere in ''key''")),
484                GBC.eol());
485        hintPanel.add(new SearchKeywordRow(hcbSearchString)
486                .addKeyword("<i>key</i>", null, tr("matches if ''key'' exists"))
487                .addKeyword("<i>key</i>=<i>value</i>", null, tr("''key'' with exactly ''value''"))
488                .addKeyword("<i>key</i>=*", null, tr("''key'' with any value"))
489                .addKeyword("<i>key</i>=", null, tr("''key'' with empty value"))
490                .addKeyword("*=<i>value</i>", null, tr("''value'' in any key"))
491                .addKeyword("<i>key</i>><i>value</i>", null, tr("matches if ''key'' is greater than ''value'' (analogously, less than)"))
492                .addKeyword("\"key\"=\"value\"", "\"\"=\"\"",
493                        tr("to quote operators.<br>Within quoted strings the <b>\"</b> and <b>\\</b> characters need to be escaped " +
494                                "by a preceding <b>\\</b> (e.g. <b>\\\"</b> and <b>\\\\</b>)."),
495                        "\"addr:street\""),
496                GBC.eol().anchor(GBC.CENTER));
497        hintPanel.add(new SearchKeywordRow(hcbSearchString)
498                .addTitle(tr("combinators"))
499                .addKeyword("<i>expr</i> <i>expr</i>", null, tr("logical and (both expressions have to be satisfied)"))
500                .addKeyword("<i>expr</i> | <i>expr</i>", "| ", tr("logical or (at least one expression has to be satisfied)"))
501                .addKeyword("<i>expr</i> OR <i>expr</i>", "OR ", tr("logical or (at least one expression has to be satisfied)"))
502                .addKeyword("-<i>expr</i>", null, tr("logical not"))
503                .addKeyword("(<i>expr</i>)", "()", tr("use parenthesis to group expressions")),
504                GBC.eol());
505
506        if (ExpertToggleAction.isExpert()) {
507            hintPanel.add(new SearchKeywordRow(hcbSearchString)
508                .addTitle(tr("objects"))
509                .addKeyword("type:node", "type:node ", tr("all nodes"))
510                .addKeyword("type:way", "type:way ", tr("all ways"))
511                .addKeyword("type:relation", "type:relation ", tr("all relations"))
512                .addKeyword("closed", "closed ", tr("all closed ways"))
513                .addKeyword("untagged", "untagged ", tr("object without useful tags")),
514                GBC.eol());
515            hintPanel.add(new SearchKeywordRow(hcbSearchString)
516                    .addKeyword("preset:\"Annotation/Address\"", "preset:\"Annotation/Address\"",
517                            tr("all objects that use the address preset"))
518                    .addKeyword("preset:\"Geography/Nature/*\"", "preset:\"Geography/Nature/*\"",
519                            tr("all objects that use any preset under the Geography/Nature group")),
520                    GBC.eol().anchor(GBC.CENTER));
521            hintPanel.add(new SearchKeywordRow(hcbSearchString)
522                .addTitle(tr("metadata"))
523                .addKeyword("user:", "user:", tr("objects changed by user", "user:anonymous"))
524                .addKeyword("id:", "id:", tr("objects with given ID"), "id:0 (new objects)")
525                .addKeyword("version:", "version:", tr("objects with given version"), "version:0 (objects without an assigned version)")
526                .addKeyword("changeset:", "changeset:", tr("objects with given changeset ID"),
527                        "changeset:0 (objects without an assigned changeset)")
528                .addKeyword("timestamp:", "timestamp:", tr("objects with last modification timestamp within range"), "timestamp:2012/",
529                        "timestamp:2008/2011-02-04T12"),
530                GBC.eol());
531            hintPanel.add(new SearchKeywordRow(hcbSearchString)
532                .addTitle(tr("properties"))
533                .addKeyword("nodes:<i>20-</i>", "nodes:", tr("ways with at least 20 nodes, or relations containing at least 20 nodes"))
534                .addKeyword("ways:<i>3-</i>", "ways:", tr("nodes with at least 3 referring ways, or relations containing at least 3 ways"))
535                .addKeyword("tags:<i>5-10</i>", "tags:", tr("objects having 5 to 10 tags"))
536                .addKeyword("role:", "role:", tr("objects with given role in a relation"))
537                .addKeyword("areasize:<i>-100</i>", "areasize:", tr("closed ways with an area of 100 m\u00b2"))
538                .addKeyword("waylength:<i>200-</i>", "waylength:", tr("ways with a length of 200 m or more")),
539                GBC.eol());
540            hintPanel.add(new SearchKeywordRow(hcbSearchString)
541                .addTitle(tr("state"))
542                .addKeyword("modified", "modified ", tr("all modified objects"))
543                .addKeyword("new", "new ", tr("all new objects"))
544                .addKeyword("selected", "selected ", tr("all selected objects"))
545                .addKeyword("incomplete", "incomplete ", tr("all incomplete objects"))
546                .addKeyword("deleted", "deleted ", tr("all deleted objects (checkbox <b>{0}</b> must be enabled)", tr("all objects"))),
547                GBC.eol());
548            hintPanel.add(new SearchKeywordRow(hcbSearchString)
549                .addTitle(tr("related objects"))
550                .addKeyword("child <i>expr</i>", "child ", tr("all children of objects matching the expression"), "child building")
551                .addKeyword("parent <i>expr</i>", "parent ", tr("all parents of objects matching the expression"), "parent bus_stop")
552                .addKeyword("hasRole:<i>stop</i>", "hasRole:", tr("relation containing a member of role <i>stop</i>"))
553                .addKeyword("role:<i>stop</i>", "role:", tr("objects being part of a relation as role <i>stop</i>"))
554                .addKeyword("nth:<i>7</i>", "nth:",
555                        tr("n-th member of relation and/or n-th node of way"), "nth:5 (child type:relation)", "nth:-1")
556                .addKeyword("nth%:<i>7</i>", "nth%:",
557                        tr("every n-th member of relation and/or every n-th node of way"), "nth%:100 (child waterway)"),
558                GBC.eol());
559            hintPanel.add(new SearchKeywordRow(hcbSearchString)
560                .addTitle(tr("view"))
561                .addKeyword("inview", "inview ", tr("objects in current view"))
562                .addKeyword("allinview", "allinview ", tr("objects (and all its way nodes / relation members) in current view"))
563                .addKeyword("indownloadedarea", "indownloadedarea ", tr("objects in downloaded area"))
564                .addKeyword("allindownloadedarea", "allindownloadedarea ",
565                        tr("objects (and all its way nodes / relation members) in downloaded area")),
566                GBC.eol());
567        }
568
569        return hintPanel;
570    }
571
572    /**
573     * Launches the dialog for specifying search criteria and runs a search
574     */
575    public static void search() {
576        SearchSetting se = showSearchDialog(lastSearch);
577        if (se != null) {
578            searchWithHistory(se);
579        }
580    }
581
582    /**
583     * Adds the search specified by the settings in <code>s</code> to the
584     * search history and performs the search.
585     *
586     * @param s search settings
587     */
588    public static void searchWithHistory(SearchSetting s) {
589        saveToHistory(s);
590        lastSearch = new SearchSetting(s);
591        search(s);
592    }
593
594    /**
595     * Performs the search specified by the settings in <code>s</code> without saving it to search history.
596     *
597     * @param s search settings
598     */
599    public static void searchWithoutHistory(SearchSetting s) {
600        lastSearch = new SearchSetting(s);
601        search(s);
602    }
603
604    /**
605     * Performs the search specified by the search string {@code search} and the search mode {@code mode}.
606     *
607     * @param search the search string to use
608     * @param mode the search mode to use
609     */
610    public static void search(String search, SearchMode mode) {
611        final SearchSetting searchSetting = new SearchSetting();
612        searchSetting.text = search;
613        searchSetting.mode = mode;
614        search(searchSetting);
615    }
616
617    static void search(SearchSetting s) {
618        SearchTask.newSearchTask(s, new SelectSearchReceiver()).run();
619    }
620
621    /**
622     * Performs the search specified by the search string {@code search} and the search mode {@code mode} and returns the result of the search.
623     *
624     * @param search the search string to use
625     * @param mode the search mode to use
626     * @return The result of the search.
627     * @since 10457
628     * @since 13950 (signature)
629     */
630    public static Collection<IPrimitive> searchAndReturn(String search, SearchMode mode) {
631        final SearchSetting searchSetting = new SearchSetting();
632        searchSetting.text = search;
633        searchSetting.mode = mode;
634        CapturingSearchReceiver receiver = new CapturingSearchReceiver();
635        SearchTask.newSearchTask(searchSetting, receiver).run();
636        return receiver.result;
637    }
638
639    /**
640     *
641     * @param selector Selector component that the user interacts with
642     * @param searchEditor Editor for search queries
643     */
644    private static void setPresetDblClickListener(TaggingPresetSelector selector, JTextComponent searchEditor) {
645        TaggingPreset selectedPreset = selector.getSelectedPresetAndUpdateClassification();
646
647        if (selectedPreset == null) {
648            return;
649        }
650
651        /*
652         * Make sure that the focus is transferred to the search text field
653         * from the selector component.
654         */
655        searchEditor.requestFocusInWindow();
656
657        /*
658         * In order to make interaction with the search dialog simpler,
659         * we make sure that if autocompletion triggers and the text field is
660         * not in focus, the correct area is selected. We first request focus
661         * and then execute the selection logic. invokeLater allows us to
662         * defer the selection until waiting for focus.
663         */
664        SwingUtilities.invokeLater(() -> {
665            int textOffset = searchEditor.getCaretPosition();
666            String presetSearchQuery = " preset:" +
667                    "\"" + selectedPreset.getRawName() + "\"";
668            try {
669                searchEditor.getDocument().insertString(textOffset, presetSearchQuery, null);
670            } catch (BadLocationException e1) {
671                throw new JosmRuntimeException(e1.getMessage(), e1);
672            }
673        });
674    }
675
676    /**
677     * Interfaces implementing this may receive the result of the current search.
678     * @author Michael Zangl
679     * @since 10457
680     * @since 10600 (functional interface)
681     * @since 13950 (signature)
682     */
683    @FunctionalInterface
684    interface SearchReceiver {
685        /**
686         * Receive the search result
687         * @param ds The data set searched on.
688         * @param result The result collection, including the initial collection.
689         * @param foundMatches The number of matches added to the result.
690         * @param setting The setting used.
691         * @param parent parent component
692         */
693        void receiveSearchResult(OsmData<?, ?, ?, ?> ds, Collection<IPrimitive> result,
694                int foundMatches, SearchSetting setting, Component parent);
695    }
696
697    /**
698     * Select the search result and display a status text for it.
699     */
700    private static class SelectSearchReceiver implements SearchReceiver {
701
702        @Override
703        public void receiveSearchResult(OsmData<?, ?, ?, ?> ds, Collection<IPrimitive> result,
704                int foundMatches, SearchSetting setting, Component parent) {
705            ds.setSelected(result);
706            MapFrame map = MainApplication.getMap();
707            if (foundMatches == 0) {
708                final String msg;
709                final String text = Utils.shortenString(setting.text, MAX_LENGTH_SEARCH_EXPRESSION_DISPLAY);
710                if (setting.mode == SearchMode.replace) {
711                    msg = tr("No match found for ''{0}''", text);
712                } else if (setting.mode == SearchMode.add) {
713                    msg = tr("Nothing added to selection by searching for ''{0}''", text);
714                } else if (setting.mode == SearchMode.remove) {
715                    msg = tr("Nothing removed from selection by searching for ''{0}''", text);
716                } else if (setting.mode == SearchMode.in_selection) {
717                    msg = tr("Nothing found in selection by searching for ''{0}''", text);
718                } else {
719                    msg = null;
720                }
721                if (map != null) {
722                    map.statusLine.setHelpText(msg);
723                }
724                if (!GraphicsEnvironment.isHeadless()) {
725                    new Notification(msg).show();
726                }
727            } else {
728                map.statusLine.setHelpText(tr("Found {0} matches", foundMatches));
729            }
730        }
731    }
732
733    /**
734     * This class stores the result of the search in a local variable.
735     * @author Michael Zangl
736     */
737    private static final class CapturingSearchReceiver implements SearchReceiver {
738        private Collection<IPrimitive> result;
739
740        @Override
741        public void receiveSearchResult(OsmData<?, ?, ?, ?> ds, Collection<IPrimitive> result, int foundMatches,
742                SearchSetting setting, Component parent) {
743                    this.result = result;
744        }
745    }
746
747    static final class SearchTask extends PleaseWaitRunnable {
748        private final OsmData<?, ?, ?, ?> ds;
749        private final SearchSetting setting;
750        private final Collection<IPrimitive> selection;
751        private final Predicate<IPrimitive> predicate;
752        private boolean canceled;
753        private int foundMatches;
754        private final SearchReceiver resultReceiver;
755
756        private SearchTask(OsmData<?, ?, ?, ?> ds, SearchSetting setting, Collection<IPrimitive> selection,
757                Predicate<IPrimitive> predicate, SearchReceiver resultReceiver) {
758            super(tr("Searching"));
759            this.ds = ds;
760            this.setting = setting;
761            this.selection = selection;
762            this.predicate = predicate;
763            this.resultReceiver = resultReceiver;
764        }
765
766        static SearchTask newSearchTask(SearchSetting setting, SearchReceiver resultReceiver) {
767            final OsmData<?, ?, ?, ?> ds = MainApplication.getLayerManager().getActiveData();
768            if (ds == null) {
769                throw new IllegalStateException("No active dataset");
770            }
771            return newSearchTask(setting, ds, resultReceiver);
772        }
773
774        /**
775         * Create a new search task for the given search setting.
776         * @param setting The setting to use
777         * @param ds The data set to search on
778         * @param resultReceiver will receive the search result
779         * @return A new search task.
780         */
781        private static SearchTask newSearchTask(SearchSetting setting, final OsmData<?, ?, ?, ?> ds, SearchReceiver resultReceiver) {
782            final Collection<IPrimitive> selection = new HashSet<>(ds.getAllSelected());
783            return new SearchTask(ds, setting, selection, IPrimitive::isSelected, resultReceiver);
784        }
785
786        @Override
787        protected void cancel() {
788            this.canceled = true;
789        }
790
791        @Override
792        protected void realRun() {
793            try {
794                foundMatches = 0;
795                SearchCompiler.Match matcher = SearchCompiler.compile(setting);
796
797                if (setting.mode == SearchMode.replace) {
798                    selection.clear();
799                } else if (setting.mode == SearchMode.in_selection) {
800                    foundMatches = selection.size();
801                }
802
803                Collection<? extends IPrimitive> all;
804                if (setting.allElements) {
805                    all = ds.allPrimitives();
806                } else {
807                    all = ds.getPrimitives(p -> p.isSelectable()); // Do not use method reference before Java 11!
808                }
809                final ProgressMonitor subMonitor = getProgressMonitor().createSubTaskMonitor(all.size(), false);
810                subMonitor.beginTask(trn("Searching in {0} object", "Searching in {0} objects", all.size(), all.size()));
811
812                for (IPrimitive osm : all) {
813                    if (canceled) {
814                        return;
815                    }
816                    if (setting.mode == SearchMode.replace) {
817                        if (matcher.match(osm)) {
818                            selection.add(osm);
819                            ++foundMatches;
820                        }
821                    } else if (setting.mode == SearchMode.add && !predicate.test(osm) && matcher.match(osm)) {
822                        selection.add(osm);
823                        ++foundMatches;
824                    } else if (setting.mode == SearchMode.remove && predicate.test(osm) && matcher.match(osm)) {
825                        selection.remove(osm);
826                        ++foundMatches;
827                    } else if (setting.mode == SearchMode.in_selection && predicate.test(osm) && !matcher.match(osm)) {
828                        selection.remove(osm);
829                        --foundMatches;
830                    }
831                    subMonitor.worked(1);
832                }
833                subMonitor.finishTask();
834            } catch (SearchParseError e) {
835                Logging.debug(e);
836                JOptionPane.showMessageDialog(
837                        Main.parent,
838                        e.getMessage(),
839                        tr("Error"),
840                        JOptionPane.ERROR_MESSAGE
841                );
842            }
843        }
844
845        @Override
846        protected void finish() {
847            if (canceled) {
848                return;
849            }
850            resultReceiver.receiveSearchResult(ds, selection, foundMatches, setting, getProgressMonitor().getWindowParent());
851        }
852    }
853
854    /**
855     * {@link ActionParameter} implementation with {@link SearchSetting} as value type.
856     * @since 12547 (moved from {@link ActionParameter})
857     */
858    public static class SearchSettingsActionParameter extends ActionParameter<SearchSetting> {
859
860        /**
861         * Constructs a new {@code SearchSettingsActionParameter}.
862         * @param name parameter name (the key)
863         */
864        public SearchSettingsActionParameter(String name) {
865            super(name);
866        }
867
868        @Override
869        public Class<SearchSetting> getType() {
870            return SearchSetting.class;
871        }
872
873        @Override
874        public SearchSetting readFromString(String s) {
875            return SearchSetting.readFromString(s);
876        }
877
878        @Override
879        public String writeToString(SearchSetting value) {
880            if (value == null)
881                return "";
882            return value.writeToString();
883        }
884    }
885
886    /**
887     * Refreshes the enabled state
888     */
889    @Override
890    protected void updateEnabledState() {
891        setEnabled(getLayerManager().getActiveData() != null);
892    }
893
894    @Override
895    public List<ActionParameter<?>> getActionParameters() {
896        return Collections.<ActionParameter<?>>singletonList(new SearchSettingsActionParameter(SEARCH_EXPRESSION));
897    }
898}