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     * Builds and shows the search dialog.
172     * @param initialValues A set of initial values needed in order to initialize the search dialog.
173     *                      If is {@code null}, then default settings are used.
174     * @return Returns new {@link SearchSetting} object containing parameters of the search.
175     */
176    public static SearchSetting showSearchDialog(SearchSetting initialValues) {
177        if (initialValues == null) {
178            initialValues = new SearchSetting();
179        }
180
181        SearchDialog dialog = new SearchDialog(
182                initialValues, getSearchExpressionHistory(), ExpertToggleAction.isExpert());
183
184        if (dialog.showDialog().getValue() != 1) return null;
185
186        // User pressed OK - let's perform the search
187        SearchSetting searchSettings = dialog.getSearchSettings();
188
189        if (dialog.isAddOnToolbar()) {
190            ToolbarPreferences.ActionDefinition aDef =
191                    new ToolbarPreferences.ActionDefinition(MainApplication.getMenu().search);
192            aDef.getParameters().put(SEARCH_EXPRESSION, searchSettings);
193            // Display search expression as tooltip instead of generic one
194            aDef.setName(Utils.shortenString(searchSettings.text, MAX_LENGTH_SEARCH_EXPRESSION_DISPLAY));
195            // parametrized action definition is now composed
196            ActionParser actionParser = new ToolbarPreferences.ActionParser(null);
197            String res = actionParser.saveAction(aDef);
198
199            // add custom search button to toolbar preferences
200            MainApplication.getToolbar().addCustomButton(res, -1, false);
201        }
202
203        return searchSettings;
204    }
205
206    /**
207     * Launches the dialog for specifying search criteria and runs a search
208     */
209    public static void search() {
210        SearchSetting se = showSearchDialog(lastSearch);
211        if (se != null) {
212            searchWithHistory(se);
213        }
214    }
215
216    /**
217     * Adds the search specified by the settings in <code>s</code> to the
218     * search history and performs the search.
219     *
220     * @param s search settings
221     */
222    public static void searchWithHistory(SearchSetting s) {
223        saveToHistory(s);
224        lastSearch = new SearchSetting(s);
225        searchStateless(s);
226    }
227
228    /**
229     * Performs the search specified by the settings in <code>s</code> without saving it to search history.
230     *
231     * @param s search settings
232     */
233    public static void searchWithoutHistory(SearchSetting s) {
234        lastSearch = new SearchSetting(s);
235        searchStateless(s);
236    }
237
238    /**
239     * Performs the search specified by the search string {@code search} and the search mode {@code mode}.
240     *
241     * @param search the search string to use
242     * @param mode the search mode to use
243     */
244    public static void search(String search, SearchMode mode) {
245        final SearchSetting searchSetting = new SearchSetting();
246        searchSetting.text = search;
247        searchSetting.mode = mode;
248        searchStateless(searchSetting);
249    }
250
251    /**
252     * Performs a stateless search specified by the settings in <code>s</code>.
253     *
254     * @param s search settings
255     * @since 15356
256     */
257    public static void searchStateless(SearchSetting s) {
258        SearchTask.newSearchTask(s, new SelectSearchReceiver()).run();
259    }
260
261    /**
262     * Performs the search specified by the search string {@code search} and the search mode {@code mode} and returns the result of the search.
263     *
264     * @param search the search string to use
265     * @param mode the search mode to use
266     * @return The result of the search.
267     * @since 10457
268     * @since 13950 (signature)
269     */
270    public static Collection<IPrimitive> searchAndReturn(String search, SearchMode mode) {
271        final SearchSetting searchSetting = new SearchSetting();
272        searchSetting.text = search;
273        searchSetting.mode = mode;
274        CapturingSearchReceiver receiver = new CapturingSearchReceiver();
275        SearchTask.newSearchTask(searchSetting, receiver).run();
276        return receiver.result;
277    }
278
279    /**
280     * Interfaces implementing this may receive the result of the current search.
281     * @author Michael Zangl
282     * @since 10457
283     * @since 10600 (functional interface)
284     * @since 13950 (signature)
285     */
286    @FunctionalInterface
287    interface SearchReceiver {
288        /**
289         * Receive the search result
290         * @param ds The data set searched on.
291         * @param result The result collection, including the initial collection.
292         * @param foundMatches The number of matches added to the result.
293         * @param setting The setting used.
294         * @param parent parent component
295         */
296        void receiveSearchResult(OsmData<?, ?, ?, ?> ds, Collection<IPrimitive> result,
297                int foundMatches, SearchSetting setting, Component parent);
298    }
299
300    /**
301     * Select the search result and display a status text for it.
302     */
303    private static class SelectSearchReceiver implements SearchReceiver {
304
305        @Override
306        public void receiveSearchResult(OsmData<?, ?, ?, ?> ds, Collection<IPrimitive> result,
307                int foundMatches, SearchSetting setting, Component parent) {
308            ds.setSelected(result);
309            MapFrame map = MainApplication.getMap();
310            if (foundMatches == 0) {
311                final String msg;
312                final String text = Utils.shortenString(setting.text, MAX_LENGTH_SEARCH_EXPRESSION_DISPLAY);
313                if (setting.mode == SearchMode.replace) {
314                    msg = tr("No match found for ''{0}''", text);
315                } else if (setting.mode == SearchMode.add) {
316                    msg = tr("Nothing added to selection by searching for ''{0}''", text);
317                } else if (setting.mode == SearchMode.remove) {
318                    msg = tr("Nothing removed from selection by searching for ''{0}''", text);
319                } else if (setting.mode == SearchMode.in_selection) {
320                    msg = tr("Nothing found in selection by searching for ''{0}''", text);
321                } else {
322                    msg = null;
323                }
324                if (map != null) {
325                    map.statusLine.setHelpText(msg);
326                }
327                if (!GraphicsEnvironment.isHeadless()) {
328                    new Notification(msg).show();
329                }
330            } else {
331                map.statusLine.setHelpText(tr("Found {0} matches", foundMatches));
332            }
333        }
334    }
335
336    /**
337     * This class stores the result of the search in a local variable.
338     * @author Michael Zangl
339     */
340    private static final class CapturingSearchReceiver implements SearchReceiver {
341        private Collection<IPrimitive> result;
342
343        @Override
344        public void receiveSearchResult(OsmData<?, ?, ?, ?> ds, Collection<IPrimitive> result, int foundMatches,
345                SearchSetting setting, Component parent) {
346                    this.result = result;
347        }
348    }
349
350    static final class SearchTask extends PleaseWaitRunnable {
351        private final OsmData<?, ?, ?, ?> ds;
352        private final SearchSetting setting;
353        private final Collection<IPrimitive> selection;
354        private final Predicate<IPrimitive> predicate;
355        private boolean canceled;
356        private int foundMatches;
357        private final SearchReceiver resultReceiver;
358
359        private SearchTask(OsmData<?, ?, ?, ?> ds, SearchSetting setting, Collection<IPrimitive> selection,
360                Predicate<IPrimitive> predicate, SearchReceiver resultReceiver) {
361            super(tr("Searching"));
362            this.ds = ds;
363            this.setting = setting;
364            this.selection = selection;
365            this.predicate = predicate;
366            this.resultReceiver = resultReceiver;
367        }
368
369        static SearchTask newSearchTask(SearchSetting setting, SearchReceiver resultReceiver) {
370            final OsmData<?, ?, ?, ?> ds = MainApplication.getLayerManager().getActiveData();
371            if (ds == null) {
372                throw new IllegalStateException("No active dataset");
373            }
374            return newSearchTask(setting, ds, resultReceiver);
375        }
376
377        /**
378         * Create a new search task for the given search setting.
379         * @param setting The setting to use
380         * @param ds The data set to search on
381         * @param resultReceiver will receive the search result
382         * @return A new search task.
383         */
384        private static SearchTask newSearchTask(SearchSetting setting, final OsmData<?, ?, ?, ?> ds, SearchReceiver resultReceiver) {
385            final Collection<IPrimitive> selection = new HashSet<>(ds.getAllSelected());
386            return new SearchTask(ds, setting, selection, IPrimitive::isSelected, resultReceiver);
387        }
388
389        @Override
390        protected void cancel() {
391            this.canceled = true;
392        }
393
394        @Override
395        protected void realRun() {
396            try {
397                foundMatches = 0;
398                SearchCompiler.Match matcher = SearchCompiler.compile(setting);
399
400                if (setting.mode == SearchMode.replace) {
401                    selection.clear();
402                } else if (setting.mode == SearchMode.in_selection) {
403                    foundMatches = selection.size();
404                }
405
406                Collection<? extends IPrimitive> all;
407                if (setting.allElements) {
408                    all = ds.allPrimitives();
409                } else {
410                    all = ds.getPrimitives(p -> p.isSelectable()); // Do not use method reference before Java 11!
411                }
412                final ProgressMonitor subMonitor = getProgressMonitor().createSubTaskMonitor(all.size(), false);
413                subMonitor.beginTask(trn("Searching in {0} object", "Searching in {0} objects", all.size(), all.size()));
414
415                for (IPrimitive osm : all) {
416                    if (canceled) {
417                        return;
418                    }
419                    if (setting.mode == SearchMode.replace) {
420                        if (matcher.match(osm)) {
421                            selection.add(osm);
422                            ++foundMatches;
423                        }
424                    } else if (setting.mode == SearchMode.add && !predicate.test(osm) && matcher.match(osm)) {
425                        selection.add(osm);
426                        ++foundMatches;
427                    } else if (setting.mode == SearchMode.remove && predicate.test(osm) && matcher.match(osm)) {
428                        selection.remove(osm);
429                        ++foundMatches;
430                    } else if (setting.mode == SearchMode.in_selection && predicate.test(osm) && !matcher.match(osm)) {
431                        selection.remove(osm);
432                        --foundMatches;
433                    }
434                    subMonitor.worked(1);
435                }
436                subMonitor.finishTask();
437            } catch (SearchParseError e) {
438                Logging.debug(e);
439                JOptionPane.showMessageDialog(
440                        MainApplication.getMainFrame(),
441                        e.getMessage(),
442                        tr("Error"),
443                        JOptionPane.ERROR_MESSAGE
444                );
445            }
446        }
447
448        @Override
449        protected void finish() {
450            if (canceled) {
451                return;
452            }
453            resultReceiver.receiveSearchResult(ds, selection, foundMatches, setting, getProgressMonitor().getWindowParent());
454        }
455    }
456
457    /**
458     * {@link ActionParameter} implementation with {@link SearchSetting} as value type.
459     * @since 12547 (moved from {@link ActionParameter})
460     */
461    public static class SearchSettingsActionParameter extends ActionParameter<SearchSetting> {
462
463        /**
464         * Constructs a new {@code SearchSettingsActionParameter}.
465         * @param name parameter name (the key)
466         */
467        public SearchSettingsActionParameter(String name) {
468            super(name);
469        }
470
471        @Override
472        public Class<SearchSetting> getType() {
473            return SearchSetting.class;
474        }
475
476        @Override
477        public SearchSetting readFromString(String s) {
478            return SearchSetting.readFromString(s);
479        }
480
481        @Override
482        public String writeToString(SearchSetting value) {
483            if (value == null)
484                return "";
485            return value.writeToString();
486        }
487    }
488
489    /**
490     * Refreshes the enabled state
491     */
492    @Override
493    protected void updateEnabledState() {
494        setEnabled(getLayerManager().getActiveData() != null);
495    }
496
497    @Override
498    public List<ActionParameter<?>> getActionParameters() {
499        return Collections.<ActionParameter<?>>singletonList(new SearchSettingsActionParameter(SEARCH_EXPRESSION));
500    }
501}