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