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.GraphicsEnvironment;
010import java.awt.event.ActionEvent;
011import java.awt.event.KeyEvent;
012import java.util.Arrays;
013import java.util.Collection;
014import java.util.Collections;
015import java.util.HashSet;
016import java.util.List;
017import java.util.Map;
018import java.util.function.Predicate;
019
020import javax.swing.JOptionPane;
021
022import org.openstreetmap.josm.actions.ActionParameter;
023import org.openstreetmap.josm.actions.ExpertToggleAction;
024import org.openstreetmap.josm.actions.JosmAction;
025import org.openstreetmap.josm.actions.ParameterizedAction;
026import org.openstreetmap.josm.data.osm.IPrimitive;
027import org.openstreetmap.josm.data.osm.OsmData;
028import org.openstreetmap.josm.data.osm.search.PushbackTokenizer;
029import org.openstreetmap.josm.data.osm.search.SearchCompiler;
030import org.openstreetmap.josm.data.osm.search.SearchCompiler.Match;
031import org.openstreetmap.josm.data.osm.search.SearchCompiler.SimpleMatchFactory;
032import org.openstreetmap.josm.data.osm.search.SearchMode;
033import org.openstreetmap.josm.data.osm.search.SearchParseError;
034import org.openstreetmap.josm.data.osm.search.SearchSetting;
035import org.openstreetmap.josm.gui.MainApplication;
036import org.openstreetmap.josm.gui.MapFrame;
037import org.openstreetmap.josm.gui.Notification;
038import org.openstreetmap.josm.gui.PleaseWaitRunnable;
039import org.openstreetmap.josm.gui.dialogs.SearchDialog;
040import org.openstreetmap.josm.gui.preferences.ToolbarPreferences;
041import org.openstreetmap.josm.gui.preferences.ToolbarPreferences.ActionParser;
042import org.openstreetmap.josm.gui.progress.ProgressMonitor;
043import org.openstreetmap.josm.gui.tagging.ac.AutoCompComboBoxModel;
044import org.openstreetmap.josm.spi.preferences.Config;
045import org.openstreetmap.josm.tools.Logging;
046import org.openstreetmap.josm.tools.Shortcut;
047import org.openstreetmap.josm.tools.Utils;
048
049/**
050 * The search action allows the user to search the data layer using a complex search string.
051 *
052 * @see SearchCompiler
053 * @see SearchDialog
054 */
055public class SearchAction extends JosmAction implements ParameterizedAction {
056
057    /**
058     * The default size of the search history
059     */
060    public static final int DEFAULT_SEARCH_HISTORY_SIZE = 15;
061    /**
062     * Maximum number of characters before the search expression is shortened for display purposes.
063     */
064    public static final int MAX_LENGTH_SEARCH_EXPRESSION_DISPLAY = 100;
065
066    private static final String SEARCH_EXPRESSION = "searchExpression";
067
068    private static AutoCompComboBoxModel<SearchSetting> model = new AutoCompComboBoxModel<>();
069
070    /** preferences reader/writer with automatic transmogrification to and from String */
071    private static AutoCompComboBoxModel<SearchSetting>.Preferences prefs = model.prefs(
072            SearchSetting::readFromString, SearchSetting::writeToString);
073
074    static {
075        SearchCompiler.addMatchFactory(new SimpleMatchFactory() {
076            @Override
077            public Collection<String> getKeywords() {
078                return Arrays.asList("inview", "allinview");
079            }
080
081            @Override
082            public Match get(String keyword, boolean caseSensitive, boolean regexSearch, PushbackTokenizer tokenizer) throws SearchParseError {
083                switch(keyword) {
084                case "inview":
085                    return new InView(false);
086                case "allinview":
087                    return new InView(true);
088                default:
089                    throw new IllegalStateException("Not expecting keyword " + keyword);
090                }
091            }
092        });
093        model.setSize(Config.getPref().getInt("search.history-size", DEFAULT_SEARCH_HISTORY_SIZE));
094    }
095
096    /**
097     * Gets the search history
098     * @return The last searched terms.
099     */
100    public static Collection<SearchSetting> getSearchHistory() {
101        return model.asCollection();
102    }
103
104    /**
105     * Saves a search to the search history.
106     * @param s The search to save
107     */
108    public static void saveToHistory(SearchSetting s) {
109        model.addTopElement(s);
110        prefs.save("search.history");
111    }
112
113    /**
114     * Gets a list of all texts that were recently used in the search
115     * @return The list of search texts.
116     */
117    public static List<String> getSearchExpressionHistory() {
118        return prefs.asStringList();
119    }
120
121    private static volatile SearchSetting lastSearch;
122
123    /**
124     * Constructs a new {@code SearchAction}.
125     */
126    public SearchAction() {
127        super(tr("Search..."), "dialogs/search", tr("Search for objects"),
128                Shortcut.registerShortcut("system:find", tr("Edit: {0}", tr("Search...")), KeyEvent.VK_F, Shortcut.CTRL), true);
129        setHelpId(ht("/Action/Search"));
130    }
131
132    @Override
133    public void actionPerformed(ActionEvent e) {
134        if (!isEnabled())
135            return;
136        search();
137    }
138
139    @Override
140    public void actionPerformed(ActionEvent e, Map<String, Object> parameters) {
141        if (parameters.get(SEARCH_EXPRESSION) == null) {
142            actionPerformed(e);
143        } else {
144            searchWithoutHistory((SearchSetting) parameters.get(SEARCH_EXPRESSION));
145        }
146    }
147
148    /**
149     * Builds and shows the search dialog.
150     * @param initialValues A set of initial values needed in order to initialize the search dialog.
151     *                      If is {@code null}, then default settings are used.
152     * @return Returns new {@link SearchSetting} object containing parameters of the search.
153     */
154    public static SearchSetting showSearchDialog(SearchSetting initialValues) {
155        if (initialValues == null) {
156            initialValues = new SearchSetting();
157        }
158
159        SearchDialog dialog = new SearchDialog(
160                initialValues, model, ExpertToggleAction.isExpert());
161
162        if (dialog.showDialog().getValue() != 1) return null;
163
164        // User pressed OK - let's perform the search
165        SearchSetting searchSettings = dialog.getSearchSettings();
166
167        if (dialog.isAddOnToolbar()) {
168            ToolbarPreferences.ActionDefinition aDef =
169                    new ToolbarPreferences.ActionDefinition(MainApplication.getMenu().search);
170            aDef.getParameters().put(SEARCH_EXPRESSION, searchSettings);
171            // Display search expression as tooltip instead of generic one
172            aDef.setName(Utils.shortenString(searchSettings.text, MAX_LENGTH_SEARCH_EXPRESSION_DISPLAY));
173            // parametrized action definition is now composed
174            ActionParser actionParser = new ToolbarPreferences.ActionParser(null);
175            String res = actionParser.saveAction(aDef);
176
177            // add custom search button to toolbar preferences
178            MainApplication.getToolbar().addCustomButton(res, -1, false);
179        }
180
181        return searchSettings;
182    }
183
184    /**
185     * Launches the dialog for specifying search criteria and runs a search
186     */
187    public static void search() {
188        prefs.load("search.history");
189        SearchSetting se = showSearchDialog(lastSearch);
190        if (se != null) {
191            searchWithHistory(se);
192        }
193    }
194
195    /**
196     * Adds the search specified by the settings in <code>s</code> to the
197     * search history and performs the search.
198     *
199     * @param s search settings
200     */
201    public static void searchWithHistory(SearchSetting s) {
202        saveToHistory(s);
203        lastSearch = new SearchSetting(s);
204        searchStateless(s);
205    }
206
207    /**
208     * Performs the search specified by the settings in <code>s</code> without saving it to search history.
209     *
210     * @param s search settings
211     */
212    public static void searchWithoutHistory(SearchSetting s) {
213        lastSearch = new SearchSetting(s);
214        searchStateless(s);
215    }
216
217    /**
218     * Performs the search specified by the search string {@code search} and the search mode {@code mode}.
219     *
220     * @param search the search string to use
221     * @param mode the search mode to use
222     */
223    public static void search(String search, SearchMode mode) {
224        final SearchSetting searchSetting = new SearchSetting();
225        searchSetting.text = search;
226        searchSetting.mode = mode;
227        searchStateless(searchSetting);
228    }
229
230    /**
231     * Performs a stateless search specified by the settings in <code>s</code>.
232     *
233     * @param s search settings
234     * @since 15356
235     */
236    public static void searchStateless(SearchSetting s) {
237        SearchTask.newSearchTask(s, new SelectSearchReceiver()).run();
238    }
239
240    /**
241     * Performs the search specified by the search string {@code search} and the search mode {@code mode} and returns the result of the search.
242     *
243     * @param search the search string to use
244     * @param mode the search mode to use
245     * @return The result of the search.
246     * @since 10457
247     * @since 13950 (signature)
248     */
249    public static Collection<IPrimitive> searchAndReturn(String search, SearchMode mode) {
250        final SearchSetting searchSetting = new SearchSetting();
251        searchSetting.text = search;
252        searchSetting.mode = mode;
253        CapturingSearchReceiver receiver = new CapturingSearchReceiver();
254        SearchTask.newSearchTask(searchSetting, receiver).run();
255        return receiver.result;
256    }
257
258    /**
259     * Interfaces implementing this may receive the result of the current search.
260     * @author Michael Zangl
261     * @since 10457
262     * @since 10600 (functional interface)
263     * @since 13950 (signature)
264     */
265    @FunctionalInterface
266    interface SearchReceiver {
267        /**
268         * Receive the search result
269         * @param ds The data set searched on.
270         * @param result The result collection, including the initial collection.
271         * @param foundMatches The number of matches added to the result.
272         * @param setting The setting used.
273         * @param parent parent component
274         */
275        void receiveSearchResult(OsmData<?, ?, ?, ?> ds, Collection<IPrimitive> result,
276                int foundMatches, SearchSetting setting, Component parent);
277    }
278
279    /**
280     * Select the search result and display a status text for it.
281     */
282    private static class SelectSearchReceiver implements SearchReceiver {
283
284        @Override
285        public void receiveSearchResult(OsmData<?, ?, ?, ?> ds, Collection<IPrimitive> result,
286                int foundMatches, SearchSetting setting, Component parent) {
287            ds.setSelected(result);
288            MapFrame map = MainApplication.getMap();
289            if (foundMatches == 0) {
290                final String msg;
291                final String text = Utils.shortenString(setting.text, MAX_LENGTH_SEARCH_EXPRESSION_DISPLAY);
292                if (setting.mode == SearchMode.replace) {
293                    msg = tr("No match found for ''{0}''", text);
294                } else if (setting.mode == SearchMode.add) {
295                    msg = tr("Nothing added to selection by searching for ''{0}''", text);
296                } else if (setting.mode == SearchMode.remove) {
297                    msg = tr("Nothing removed from selection by searching for ''{0}''", text);
298                } else if (setting.mode == SearchMode.in_selection) {
299                    msg = tr("Nothing found in selection by searching for ''{0}''", text);
300                } else {
301                    msg = null;
302                }
303                if (map != null) {
304                    map.statusLine.setHelpText(msg);
305                }
306                if (!GraphicsEnvironment.isHeadless()) {
307                    new Notification(msg).show();
308                }
309            } else {
310                map.statusLine.setHelpText(tr("Found {0} matches", foundMatches));
311            }
312        }
313    }
314
315    /**
316     * This class stores the result of the search in a local variable.
317     * @author Michael Zangl
318     */
319    private static final class CapturingSearchReceiver implements SearchReceiver {
320        private Collection<IPrimitive> result;
321
322        @Override
323        public void receiveSearchResult(OsmData<?, ?, ?, ?> ds, Collection<IPrimitive> result, int foundMatches,
324                SearchSetting setting, Component parent) {
325                    this.result = result;
326        }
327    }
328
329    static final class SearchTask extends PleaseWaitRunnable {
330        private final OsmData<?, ?, ?, ?> ds;
331        private final SearchSetting setting;
332        private final Collection<IPrimitive> selection;
333        private final Predicate<IPrimitive> predicate;
334        private boolean canceled;
335        private int foundMatches;
336        private final SearchReceiver resultReceiver;
337
338        private SearchTask(OsmData<?, ?, ?, ?> ds, SearchSetting setting, Collection<IPrimitive> selection,
339                Predicate<IPrimitive> predicate, SearchReceiver resultReceiver) {
340            super(tr("Searching"));
341            this.ds = ds;
342            this.setting = setting;
343            this.selection = selection;
344            this.predicate = predicate;
345            this.resultReceiver = resultReceiver;
346        }
347
348        static SearchTask newSearchTask(SearchSetting setting, SearchReceiver resultReceiver) {
349            final OsmData<?, ?, ?, ?> ds = MainApplication.getLayerManager().getActiveData();
350            if (ds == null) {
351                throw new IllegalStateException("No active dataset");
352            }
353            return newSearchTask(setting, ds, resultReceiver);
354        }
355
356        /**
357         * Create a new search task for the given search setting.
358         * @param setting The setting to use
359         * @param ds The data set to search on
360         * @param resultReceiver will receive the search result
361         * @return A new search task.
362         */
363        private static SearchTask newSearchTask(SearchSetting setting, final OsmData<?, ?, ?, ?> ds, SearchReceiver resultReceiver) {
364            final Collection<IPrimitive> selection = new HashSet<>(ds.getAllSelected());
365            return new SearchTask(ds, setting, selection, IPrimitive::isSelected, resultReceiver);
366        }
367
368        @Override
369        protected void cancel() {
370            this.canceled = true;
371        }
372
373        @Override
374        protected void realRun() {
375            try {
376                foundMatches = 0;
377                SearchCompiler.Match matcher = SearchCompiler.compile(setting);
378
379                if (setting.mode == SearchMode.replace) {
380                    selection.clear();
381                } else if (setting.mode == SearchMode.in_selection) {
382                    foundMatches = selection.size();
383                }
384
385                Collection<? extends IPrimitive> all;
386                if (setting.allElements) {
387                    all = ds.allPrimitives();
388                } else {
389                    all = ds.getPrimitives(p -> p.isSelectable()); // Do not use method reference before Java 11!
390                }
391                final ProgressMonitor subMonitor = getProgressMonitor().createSubTaskMonitor(all.size(), false);
392                subMonitor.beginTask(trn("Searching in {0} object", "Searching in {0} objects", all.size(), all.size()));
393
394                for (IPrimitive osm : all) {
395                    if (canceled) {
396                        return;
397                    }
398                    if (setting.mode == SearchMode.replace) {
399                        if (matcher.match(osm)) {
400                            selection.add(osm);
401                            ++foundMatches;
402                        }
403                    } else if (setting.mode == SearchMode.add && !predicate.test(osm) && matcher.match(osm)) {
404                        selection.add(osm);
405                        ++foundMatches;
406                    } else if (setting.mode == SearchMode.remove && predicate.test(osm) && matcher.match(osm)) {
407                        selection.remove(osm);
408                        ++foundMatches;
409                    } else if (setting.mode == SearchMode.in_selection && predicate.test(osm) && !matcher.match(osm)) {
410                        selection.remove(osm);
411                        --foundMatches;
412                    }
413                    subMonitor.worked(1);
414                }
415                subMonitor.finishTask();
416            } catch (SearchParseError e) {
417                Logging.debug(e);
418                JOptionPane.showMessageDialog(
419                        MainApplication.getMainFrame(),
420                        e.getMessage(),
421                        tr("Error"),
422                        JOptionPane.ERROR_MESSAGE
423                );
424            }
425        }
426
427        @Override
428        protected void finish() {
429            if (canceled) {
430                return;
431            }
432            resultReceiver.receiveSearchResult(ds, selection, foundMatches, setting, getProgressMonitor().getWindowParent());
433        }
434    }
435
436    /**
437     * {@link ActionParameter} implementation with {@link SearchSetting} as value type.
438     * @since 12547 (moved from {@link ActionParameter})
439     */
440    public static class SearchSettingsActionParameter extends ActionParameter<SearchSetting> {
441
442        /**
443         * Constructs a new {@code SearchSettingsActionParameter}.
444         * @param name parameter name (the key)
445         */
446        public SearchSettingsActionParameter(String name) {
447            super(name);
448        }
449
450        @Override
451        public Class<SearchSetting> getType() {
452            return SearchSetting.class;
453        }
454
455        @Override
456        public SearchSetting readFromString(String s) {
457            return SearchSetting.readFromString(s);
458        }
459
460        @Override
461        public String writeToString(SearchSetting value) {
462            if (value == null)
463                return "";
464            return value.writeToString();
465        }
466    }
467
468    @Override
469    protected boolean listenToSelectionChange() {
470        return false;
471    }
472
473    /**
474     * Refreshes the enabled state
475     */
476    @Override
477    protected void updateEnabledState() {
478        setEnabled(getLayerManager().getActiveData() != null);
479    }
480
481    @Override
482    public List<ActionParameter<?>> getActionParameters() {
483        return Collections.<ActionParameter<?>>singletonList(new SearchSettingsActionParameter(SEARCH_EXPRESSION));
484    }
485}