001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.dialogs;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005import static org.openstreetmap.josm.tools.I18n.trc;
006
007import java.awt.Cursor;
008import java.awt.Dimension;
009import java.awt.FlowLayout;
010import java.awt.GridBagLayout;
011import java.awt.event.ActionEvent;
012import java.awt.event.ItemEvent;
013import java.awt.event.ItemListener;
014import java.awt.event.MouseAdapter;
015import java.awt.event.MouseEvent;
016import java.util.Arrays;
017import java.util.List;
018
019import javax.swing.BorderFactory;
020import javax.swing.ButtonGroup;
021import javax.swing.JCheckBox;
022import javax.swing.JLabel;
023import javax.swing.JOptionPane;
024import javax.swing.JPanel;
025import javax.swing.JRadioButton;
026import javax.swing.SwingUtilities;
027import javax.swing.text.BadLocationException;
028import javax.swing.text.Document;
029import javax.swing.text.JTextComponent;
030
031import org.openstreetmap.josm.data.osm.Filter;
032import org.openstreetmap.josm.data.osm.search.SearchCompiler;
033import org.openstreetmap.josm.data.osm.search.SearchMode;
034import org.openstreetmap.josm.data.osm.search.SearchParseError;
035import org.openstreetmap.josm.data.osm.search.SearchSetting;
036import org.openstreetmap.josm.gui.ExtendedDialog;
037import org.openstreetmap.josm.gui.MainApplication;
038import org.openstreetmap.josm.gui.mappaint.mapcss.MapCSSException;
039import org.openstreetmap.josm.gui.tagging.presets.TaggingPreset;
040import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetSelector;
041import org.openstreetmap.josm.gui.widgets.AbstractTextComponentValidator;
042import org.openstreetmap.josm.gui.widgets.HistoryComboBox;
043import org.openstreetmap.josm.tools.GBC;
044import org.openstreetmap.josm.tools.JosmRuntimeException;
045import org.openstreetmap.josm.tools.Logging;
046import org.openstreetmap.josm.tools.Utils;
047
048/**
049 * Search dialog to find primitives by a wide range of search criteria.
050 * @since 14927 (extracted from {@code SearchAction})
051 */
052public class SearchDialog extends ExtendedDialog {
053
054    private final SearchSetting searchSettings;
055
056    private final HistoryComboBox hcbSearchString = new HistoryComboBox();
057
058    private JCheckBox addOnToolbar;
059    private JCheckBox caseSensitive;
060    private JCheckBox allElements;
061
062    private JRadioButton standardSearch;
063    private JRadioButton regexSearch;
064    private JRadioButton mapCSSSearch;
065
066    private JRadioButton replace;
067    private JRadioButton add;
068    private JRadioButton remove;
069    private JRadioButton inSelection;
070
071    /**
072     * Constructs a new {@code SearchDialog}.
073     * @param initialValues initial search settings
074     * @param searchExpressionHistory list of all texts that were recently used in the search
075     * @param expertMode expert mode
076     */
077    public SearchDialog(SearchSetting initialValues, List<String> searchExpressionHistory, boolean expertMode) {
078        super(MainApplication.getMainFrame(),
079                initialValues instanceof Filter ? tr("Filter") : tr("Search"),
080                initialValues instanceof Filter ? tr("Submit filter") : tr("Search"),
081                tr("Cancel"));
082        this.searchSettings = new SearchSetting(initialValues);
083        setButtonIcons("dialogs/search", "cancel");
084        configureContextsensitiveHelp("/Action/Search", true /* show help button */);
085        setContent(buildPanel(searchExpressionHistory, expertMode));
086    }
087
088    private JPanel buildPanel(List<String> searchExpressionHistory, boolean expertMode) {
089
090        // prepare the combo box with the search expressions
091        JLabel label = new JLabel(searchSettings instanceof Filter ? tr("Filter string:") : tr("Search string:"));
092
093        String tooltip = tr("Enter the search expression");
094        hcbSearchString.setText(searchSettings.text);
095        hcbSearchString.setToolTipText(tooltip);
096
097        hcbSearchString.setPossibleItemsTopDown(searchExpressionHistory);
098        hcbSearchString.setPreferredSize(new Dimension(40, hcbSearchString.getPreferredSize().height));
099        label.setLabelFor(hcbSearchString);
100
101        replace = new JRadioButton(tr("select"), searchSettings.mode == SearchMode.replace);
102        add = new JRadioButton(tr("add to selection"), searchSettings.mode == SearchMode.add);
103        remove = new JRadioButton(tr("remove from selection"), searchSettings.mode == SearchMode.remove);
104        inSelection = new JRadioButton(tr("find in selection"), searchSettings.mode == SearchMode.in_selection);
105        ButtonGroup bg = new ButtonGroup();
106        bg.add(replace);
107        bg.add(add);
108        bg.add(remove);
109        bg.add(inSelection);
110
111        caseSensitive = new JCheckBox(tr("case sensitive"), searchSettings.caseSensitive);
112        allElements = new JCheckBox(tr("all objects"), searchSettings.allElements);
113        allElements.setToolTipText(tr("Also include incomplete and deleted objects in search."));
114        addOnToolbar = new JCheckBox(tr("add toolbar button"), false);
115        addOnToolbar.setToolTipText(tr("Add a button with this search expression to the toolbar."));
116
117        standardSearch = new JRadioButton(tr("standard"), !searchSettings.regexSearch && !searchSettings.mapCSSSearch);
118        regexSearch = new JRadioButton(tr("regular expression"), searchSettings.regexSearch);
119        mapCSSSearch = new JRadioButton(tr("MapCSS selector"), searchSettings.mapCSSSearch);
120
121        ButtonGroup bg2 = new ButtonGroup();
122        bg2.add(standardSearch);
123        bg2.add(regexSearch);
124        bg2.add(mapCSSSearch);
125
126        JPanel selectionSettings = new JPanel(new GridBagLayout());
127        selectionSettings.setBorder(BorderFactory.createTitledBorder(tr("Results")));
128        selectionSettings.add(replace, GBC.eol().anchor(GBC.WEST).fill(GBC.HORIZONTAL));
129        selectionSettings.add(add, GBC.eol());
130        selectionSettings.add(remove, GBC.eol());
131        selectionSettings.add(inSelection, GBC.eop());
132
133        JPanel additionalSettings = new JPanel(new GridBagLayout());
134        additionalSettings.setBorder(BorderFactory.createTitledBorder(tr("Options")));
135        additionalSettings.add(caseSensitive, GBC.eol().anchor(GBC.WEST).fill(GBC.HORIZONTAL));
136
137        JPanel left = new JPanel(new GridBagLayout());
138
139        left.add(selectionSettings, GBC.eol().fill(GBC.BOTH));
140        left.add(additionalSettings, GBC.eol().fill(GBC.BOTH));
141
142        if (expertMode) {
143            additionalSettings.add(allElements, GBC.eol());
144            additionalSettings.add(addOnToolbar, GBC.eop());
145
146            JPanel searchOptions = new JPanel(new GridBagLayout());
147            searchOptions.setBorder(BorderFactory.createTitledBorder(tr("Search syntax")));
148            searchOptions.add(standardSearch, GBC.eol().anchor(GBC.WEST).fill(GBC.HORIZONTAL));
149            searchOptions.add(regexSearch, GBC.eol());
150            searchOptions.add(mapCSSSearch, GBC.eol());
151
152            left.add(searchOptions, GBC.eol().fill(GBC.BOTH));
153        }
154
155        JPanel right = buildHintsSection(hcbSearchString, expertMode);
156        JPanel top = new JPanel(new GridBagLayout());
157        top.add(label, GBC.std().insets(0, 0, 5, 0));
158        top.add(hcbSearchString, GBC.eol().fill(GBC.HORIZONTAL));
159
160        JTextComponent editorComponent = hcbSearchString.getEditorComponent();
161        Document document = editorComponent.getDocument();
162
163        /*
164         * Setup the logic to validate the contents of the search text field which is executed
165         * every time the content of the field has changed. If the query is incorrect, then
166         * the text field is colored red.
167         */
168        AbstractTextComponentValidator validator = new AbstractTextComponentValidator(editorComponent) {
169
170            @Override
171            public void validate() {
172                if (!isValid()) {
173                    feedbackInvalid(tr("Invalid search expression"));
174                } else {
175                    feedbackValid(tooltip);
176                }
177            }
178
179            @Override
180            public boolean isValid() {
181                try {
182                    SearchSetting ss = new SearchSetting();
183                    ss.text = hcbSearchString.getText();
184                    ss.caseSensitive = caseSensitive.isSelected();
185                    ss.regexSearch = regexSearch.isSelected();
186                    ss.mapCSSSearch = mapCSSSearch.isSelected();
187                    SearchCompiler.compile(ss);
188                    return true;
189                } catch (SearchParseError | MapCSSException e) {
190                    Logging.trace(e);
191                    return false;
192                }
193            }
194        };
195        document.addDocumentListener(validator);
196        ItemListener validateActionListener = e -> {
197            if (e.getStateChange() == ItemEvent.SELECTED) {
198                validator.validate();
199            }
200        };
201        standardSearch.addItemListener(validateActionListener);
202        regexSearch.addItemListener(validateActionListener);
203        mapCSSSearch.addItemListener(validateActionListener);
204
205        /*
206         * Setup the logic to append preset queries to the search text field according to
207         * selected preset by the user. Every query is of the form ' group/sub-group/.../presetName'
208         * if the corresponding group of the preset exists, otherwise it is simply ' presetName'.
209         */
210        TaggingPresetSelector selector = new TaggingPresetSelector(false, false);
211        selector.setBorder(BorderFactory.createTitledBorder(tr("Search by preset")));
212        selector.setDblClickListener(ev -> setPresetDblClickListener(selector, editorComponent));
213
214        JPanel p = new JPanel(new GridBagLayout());
215        p.add(top, GBC.eol().fill(GBC.HORIZONTAL).insets(5, 5, 5, 0));
216        p.add(left, GBC.std().anchor(GBC.NORTH).insets(5, 10, 10, 0).fill(GBC.VERTICAL));
217        p.add(right, GBC.std().fill(GBC.BOTH).insets(0, 10, 0, 0));
218        p.add(selector, GBC.eol().fill(GBC.BOTH).insets(0, 10, 0, 0));
219
220        return p;
221    }
222
223    @Override
224    protected void buttonAction(int buttonIndex, ActionEvent evt) {
225        if (buttonIndex == 0) {
226            try {
227                SearchSetting ss = new SearchSetting();
228                ss.text = hcbSearchString.getText();
229                ss.caseSensitive = caseSensitive.isSelected();
230                ss.regexSearch = regexSearch.isSelected();
231                ss.mapCSSSearch = mapCSSSearch.isSelected();
232                SearchCompiler.compile(ss);
233                super.buttonAction(buttonIndex, evt);
234            } catch (SearchParseError | MapCSSException e) {
235                Logging.debug(e);
236                JOptionPane.showMessageDialog(
237                        MainApplication.getMainFrame(),
238                        "<html>" + tr("Search expression is not valid: \n\n {0}",
239                                e.getMessage().replace("<html>", "").replace("</html>", "")).replace("\n", "<br>") +
240                        "</html>",
241                        tr("Invalid search expression"),
242                        JOptionPane.ERROR_MESSAGE);
243            }
244        } else {
245            super.buttonAction(buttonIndex, evt);
246        }
247    }
248
249    /**
250     * Returns the search settings chosen by user.
251     * @return the search settings chosen by user
252     */
253    public SearchSetting getSearchSettings() {
254        searchSettings.text = hcbSearchString.getText();
255        searchSettings.caseSensitive = caseSensitive.isSelected();
256        searchSettings.allElements = allElements.isSelected();
257        searchSettings.regexSearch = regexSearch.isSelected();
258        searchSettings.mapCSSSearch = mapCSSSearch.isSelected();
259
260        if (inSelection.isSelected()) {
261            searchSettings.mode = SearchMode.in_selection;
262        } else if (replace.isSelected()) {
263            searchSettings.mode = SearchMode.replace;
264        } else if (add.isSelected()) {
265            searchSettings.mode = SearchMode.add;
266        } else {
267            searchSettings.mode = SearchMode.remove;
268        }
269        return searchSettings;
270    }
271
272    /**
273     * Determines if the "add toolbar button" checkbox is selected.
274     * @return {@code true} if the "add toolbar button" checkbox is selected
275     */
276    public boolean isAddOnToolbar() {
277        return addOnToolbar.isSelected();
278    }
279
280    private static JPanel buildHintsSection(HistoryComboBox hcbSearchString, boolean expertMode) {
281        JPanel hintPanel = new JPanel(new GridBagLayout());
282        hintPanel.setBorder(BorderFactory.createTitledBorder(tr("Hints")));
283
284        hintPanel.add(new SearchKeywordRow(hcbSearchString)
285                .addTitle(tr("basics"))
286                .addKeyword(tr("Baker Street"), null, tr("''Baker'' and ''Street'' in any key"))
287                .addKeyword(tr("\"Baker Street\""), "\"\"", tr("''Baker Street'' in any key"))
288                .addKeyword("<i>key</i>:<i>valuefragment</i>", null,
289                        tr("''valuefragment'' anywhere in ''key''"),
290                        trc("search string example", "name:str matches name=Bakerstreet"))
291                .addKeyword("-<i>key</i>:<i>valuefragment</i>", null, tr("''valuefragment'' nowhere in ''key''")),
292                GBC.eol());
293        hintPanel.add(new SearchKeywordRow(hcbSearchString)
294                .addKeyword("<i>key</i>", null, tr("matches if ''key'' exists"))
295                .addKeyword("<i>key</i>=<i>value</i>", null, tr("''key'' with exactly ''value''"))
296                .addKeyword("<i>key</i>=*", null, tr("''key'' with any value"))
297                .addKeyword("<i>key</i>=", null, tr("''key'' with empty value"))
298                .addKeyword("*=<i>value</i>", null, tr("''value'' in any key"))
299                .addKeyword("<i>key</i>><i>value</i>", null, tr("matches if ''key'' is greater than ''value'' (analogously, less than)"))
300                .addKeyword("\"key\"=\"value\"", "\"\"=\"\"",
301                        tr("to quote operators.<br>Within quoted strings the <b>\"</b> and <b>\\</b> characters need to be escaped " +
302                                "by a preceding <b>\\</b> (e.g. <b>\\\"</b> and <b>\\\\</b>)."),
303                        trc("search string example", "name=\"Baker Street\""),
304                        "\"addr:street\""),
305                GBC.eol().anchor(GBC.CENTER));
306        hintPanel.add(new SearchKeywordRow(hcbSearchString)
307                .addTitle(tr("combinators"))
308                .addKeyword("<i>expr</i> <i>expr</i>", null,
309                        tr("logical and (both expressions have to be satisfied)"),
310                        trc("search string example", "Baker Street"))
311                .addKeyword("<i>expr</i> | <i>expr</i>", "| ", tr("logical or (at least one expression has to be satisfied)"))
312                .addKeyword("<i>expr</i> OR <i>expr</i>", "OR ", tr("logical or (at least one expression has to be satisfied)"))
313                .addKeyword("-<i>expr</i>", null, tr("logical not"))
314                .addKeyword("(<i>expr</i>)", "()", tr("use parenthesis to group expressions")),
315                GBC.eol());
316
317        if (expertMode) {
318            hintPanel.add(new SearchKeywordRow(hcbSearchString)
319                .addTitle(tr("objects"))
320                .addKeyword("type:node", "type:node ", tr("all nodes"))
321                .addKeyword("type:way", "type:way ", tr("all ways"))
322                .addKeyword("type:relation", "type:relation ", tr("all relations"))
323                .addKeyword("closed", "closed ", tr("all closed ways"))
324                .addKeyword("untagged", "untagged ", tr("object without useful tags")),
325                GBC.eol());
326            hintPanel.add(new SearchKeywordRow(hcbSearchString)
327                    .addKeyword("preset:\"Annotation/Address\"", "preset:\"Annotation/Address\"",
328                            tr("all objects that use the address preset"))
329                    .addKeyword("preset:\"Geography/Nature/*\"", "preset:\"Geography/Nature/*\"",
330                            tr("all objects that use any preset under the Geography/Nature group")),
331                    GBC.eol().anchor(GBC.CENTER));
332            hintPanel.add(new SearchKeywordRow(hcbSearchString)
333                .addTitle(tr("metadata"))
334                .addKeyword("user:", "user:", tr("objects changed by author"),
335                        trc("search string example", "user:<i>OSM username</i> (objects with the author <i>OSM username</i>)"),
336                        trc("search string example", "user:anonymous (objects without an assigned author)"))
337                .addKeyword("id:", "id:", tr("objects with given ID"),
338                        trc("search string example", "id:0 (new objects)"))
339                .addKeyword("version:", "version:", tr("objects with given version"),
340                        trc("search string example", "version:0 (objects without an assigned version)"))
341                .addKeyword("changeset:", "changeset:", tr("objects with given changeset ID"),
342                        trc("search string example", "changeset:0 (objects without an assigned changeset)"))
343                .addKeyword("timestamp:", "timestamp:", tr("objects with last modification timestamp within range"), "timestamp:2012/",
344                        "timestamp:2008/2011-02-04T12"),
345                GBC.eol());
346            hintPanel.add(new SearchKeywordRow(hcbSearchString)
347                .addTitle(tr("properties"))
348                .addKeyword("nodes:<i>20-</i>", "nodes:", tr("ways with at least 20 nodes, or relations containing at least 20 nodes"))
349                .addKeyword("ways:<i>3-</i>", "ways:", tr("nodes with at least 3 referring ways, or relations containing at least 3 ways"))
350                .addKeyword("tags:<i>5-10</i>", "tags:", tr("objects having 5 to 10 tags"))
351                .addKeyword("role:", "role:", tr("objects with given role in a relation"))
352                .addKeyword("areasize:<i>-100</i>", "areasize:", tr("closed ways with an area of 100 m\u00b2"))
353                .addKeyword("waylength:<i>200-</i>", "waylength:", tr("ways with a length of 200 m or more")),
354                GBC.eol());
355            hintPanel.add(new SearchKeywordRow(hcbSearchString)
356                .addTitle(tr("state"))
357                .addKeyword("modified", "modified ", tr("all modified objects"))
358                .addKeyword("new", "new ", tr("all new objects"))
359                .addKeyword("selected", "selected ", tr("all selected objects"))
360                .addKeyword("incomplete", "incomplete ", tr("all incomplete objects"))
361                .addKeyword("deleted", "deleted ", tr("all deleted objects (checkbox <b>{0}</b> must be enabled)", tr("all objects"))),
362                GBC.eol());
363            hintPanel.add(new SearchKeywordRow(hcbSearchString)
364                .addTitle(tr("related objects"))
365                .addKeyword("child <i>expr</i>", "child ", tr("all children of objects matching the expression"), "child building")
366                .addKeyword("parent <i>expr</i>", "parent ", tr("all parents of objects matching the expression"), "parent bus_stop")
367                .addKeyword("hasRole:<i>stop</i>", "hasRole:", tr("relation containing a member of role <i>stop</i>"))
368                .addKeyword("role:<i>stop</i>", "role:", tr("objects being part of a relation as role <i>stop</i>"))
369                .addKeyword("nth:<i>7</i>", "nth:",
370                        tr("n-th member of relation and/or n-th node of way"), "nth:5 (child type:relation)", "nth:-1")
371                .addKeyword("nth%:<i>7</i>", "nth%:",
372                        tr("every n-th member of relation and/or every n-th node of way"), "nth%:100 (child waterway)"),
373                GBC.eol());
374            hintPanel.add(new SearchKeywordRow(hcbSearchString)
375                .addTitle(tr("view"))
376                .addKeyword("inview", "inview ", tr("objects in current view"))
377                .addKeyword("allinview", "allinview ", tr("objects (and all its way nodes / relation members) in current view"))
378                .addKeyword("indownloadedarea", "indownloadedarea ", tr("objects in downloaded area"))
379                .addKeyword("allindownloadedarea", "allindownloadedarea ",
380                        tr("objects (and all its way nodes / relation members) in downloaded area")),
381                GBC.eol());
382        }
383
384        return hintPanel;
385    }
386
387    /**
388     *
389     * @param selector Selector component that the user interacts with
390     * @param searchEditor Editor for search queries
391     */
392    private static void setPresetDblClickListener(TaggingPresetSelector selector, JTextComponent searchEditor) {
393        TaggingPreset selectedPreset = selector.getSelectedPresetAndUpdateClassification();
394
395        if (selectedPreset == null) {
396            return;
397        }
398
399        // Make sure that the focus is transferred to the search text field from the selector component
400        searchEditor.requestFocusInWindow();
401
402        // In order to make interaction with the search dialog simpler, we make sure that
403        // if autocompletion triggers and the text field is not in focus, the correct area is selected.
404        // We first request focus and then execute the selection logic.
405        // invokeLater allows us to defer the selection until waiting for focus.
406        SwingUtilities.invokeLater(() -> {
407            int textOffset = searchEditor.getCaretPosition();
408            String presetSearchQuery = " preset:" +
409                    "\"" + selectedPreset.getRawName() + "\"";
410            try {
411                searchEditor.getDocument().insertString(textOffset, presetSearchQuery, null);
412            } catch (BadLocationException e1) {
413                throw new JosmRuntimeException(e1.getMessage(), e1);
414            }
415        });
416    }
417
418    private static class SearchKeywordRow extends JPanel {
419
420        private final HistoryComboBox hcb;
421
422        SearchKeywordRow(HistoryComboBox hcb) {
423            super(new FlowLayout(FlowLayout.LEFT));
424            this.hcb = hcb;
425        }
426
427        /**
428         * Adds the title (prefix) label at the beginning of the row. Should be called only once.
429         * @param title English title
430         * @return {@code this} for easy chaining
431         */
432        public SearchKeywordRow addTitle(String title) {
433            add(new JLabel(tr("{0}: ", title)));
434            return this;
435        }
436
437        /**
438         * Adds an example keyword label at the end of the row. Can be called several times.
439         * @param displayText displayed HTML text
440         * @param insertText optional: if set, makes the label clickable, and {@code insertText} will be inserted in search string
441         * @param description optional: HTML text to be displayed in the tooltip
442         * @param examples optional: examples joined as HTML list in the tooltip
443         * @return {@code this} for easy chaining
444         */
445        public SearchKeywordRow addKeyword(String displayText, final String insertText, String description, String... examples) {
446            JLabel label = new JLabel("<html>"
447                    + "<style>td{border:1px solid gray; font-weight:normal;}</style>"
448                    + "<table><tr><td>" + displayText + "</td></tr></table></html>");
449            add(label);
450            if (description != null || examples.length > 0) {
451                label.setToolTipText("<html>"
452                        + description
453                        + (examples.length > 0 ? Utils.joinAsHtmlUnorderedList(Arrays.asList(examples)) : "")
454                        + "</html>");
455            }
456            if (insertText != null) {
457                label.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));
458                label.addMouseListener(new MouseAdapter() {
459
460                    @Override
461                    public void mouseClicked(MouseEvent e) {
462                        JTextComponent tf = hcb.getEditorComponent();
463
464                        // Make sure that the focus is transferred to the search text field from the selector component
465                        if (!tf.hasFocus()) {
466                            tf.requestFocusInWindow();
467                        }
468
469                        // In order to make interaction with the search dialog simpler, we make sure that
470                        // if autocompletion triggers and the text field is not in focus, the correct area is selected.
471                        // We first request focus and then execute the selection logic.
472                        // invokeLater allows us to defer the selection until waiting for focus.
473                        SwingUtilities.invokeLater(() -> {
474                            try {
475                                tf.getDocument().insertString(tf.getCaretPosition(), ' ' + insertText, null);
476                            } catch (BadLocationException ex) {
477                                throw new JosmRuntimeException(ex.getMessage(), ex);
478                            }
479                        });
480                    }
481                });
482            }
483            return this;
484        }
485    }
486}