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 searchStateless(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 searchStateless(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 searchStateless(searchSetting); 250 } 251 252 /** 253 * Performs a stateless search specified by the settings in <code>s</code>. 254 * 255 * @param s search settings 256 * @since 15356 257 */ 258 public static void searchStateless(SearchSetting s) { 259 SearchTask.newSearchTask(s, new SelectSearchReceiver()).run(); 260 } 261 262 /** 263 * Performs the search specified by the search string {@code search} and the search mode {@code mode} and returns the result of the search. 264 * 265 * @param search the search string to use 266 * @param mode the search mode to use 267 * @return The result of the search. 268 * @since 10457 269 * @since 13950 (signature) 270 */ 271 public static Collection<IPrimitive> searchAndReturn(String search, SearchMode mode) { 272 final SearchSetting searchSetting = new SearchSetting(); 273 searchSetting.text = search; 274 searchSetting.mode = mode; 275 CapturingSearchReceiver receiver = new CapturingSearchReceiver(); 276 SearchTask.newSearchTask(searchSetting, receiver).run(); 277 return receiver.result; 278 } 279 280 /** 281 * Interfaces implementing this may receive the result of the current search. 282 * @author Michael Zangl 283 * @since 10457 284 * @since 10600 (functional interface) 285 * @since 13950 (signature) 286 */ 287 @FunctionalInterface 288 interface SearchReceiver { 289 /** 290 * Receive the search result 291 * @param ds The data set searched on. 292 * @param result The result collection, including the initial collection. 293 * @param foundMatches The number of matches added to the result. 294 * @param setting The setting used. 295 * @param parent parent component 296 */ 297 void receiveSearchResult(OsmData<?, ?, ?, ?> ds, Collection<IPrimitive> result, 298 int foundMatches, SearchSetting setting, Component parent); 299 } 300 301 /** 302 * Select the search result and display a status text for it. 303 */ 304 private static class SelectSearchReceiver implements SearchReceiver { 305 306 @Override 307 public void receiveSearchResult(OsmData<?, ?, ?, ?> ds, Collection<IPrimitive> result, 308 int foundMatches, SearchSetting setting, Component parent) { 309 ds.setSelected(result); 310 MapFrame map = MainApplication.getMap(); 311 if (foundMatches == 0) { 312 final String msg; 313 final String text = Utils.shortenString(setting.text, MAX_LENGTH_SEARCH_EXPRESSION_DISPLAY); 314 if (setting.mode == SearchMode.replace) { 315 msg = tr("No match found for ''{0}''", text); 316 } else if (setting.mode == SearchMode.add) { 317 msg = tr("Nothing added to selection by searching for ''{0}''", text); 318 } else if (setting.mode == SearchMode.remove) { 319 msg = tr("Nothing removed from selection by searching for ''{0}''", text); 320 } else if (setting.mode == SearchMode.in_selection) { 321 msg = tr("Nothing found in selection by searching for ''{0}''", text); 322 } else { 323 msg = null; 324 } 325 if (map != null) { 326 map.statusLine.setHelpText(msg); 327 } 328 if (!GraphicsEnvironment.isHeadless()) { 329 new Notification(msg).show(); 330 } 331 } else { 332 map.statusLine.setHelpText(tr("Found {0} matches", foundMatches)); 333 } 334 } 335 } 336 337 /** 338 * This class stores the result of the search in a local variable. 339 * @author Michael Zangl 340 */ 341 private static final class CapturingSearchReceiver implements SearchReceiver { 342 private Collection<IPrimitive> result; 343 344 @Override 345 public void receiveSearchResult(OsmData<?, ?, ?, ?> ds, Collection<IPrimitive> result, int foundMatches, 346 SearchSetting setting, Component parent) { 347 this.result = result; 348 } 349 } 350 351 static final class SearchTask extends PleaseWaitRunnable { 352 private final OsmData<?, ?, ?, ?> ds; 353 private final SearchSetting setting; 354 private final Collection<IPrimitive> selection; 355 private final Predicate<IPrimitive> predicate; 356 private boolean canceled; 357 private int foundMatches; 358 private final SearchReceiver resultReceiver; 359 360 private SearchTask(OsmData<?, ?, ?, ?> ds, SearchSetting setting, Collection<IPrimitive> selection, 361 Predicate<IPrimitive> predicate, SearchReceiver resultReceiver) { 362 super(tr("Searching")); 363 this.ds = ds; 364 this.setting = setting; 365 this.selection = selection; 366 this.predicate = predicate; 367 this.resultReceiver = resultReceiver; 368 } 369 370 static SearchTask newSearchTask(SearchSetting setting, SearchReceiver resultReceiver) { 371 final OsmData<?, ?, ?, ?> ds = MainApplication.getLayerManager().getActiveData(); 372 if (ds == null) { 373 throw new IllegalStateException("No active dataset"); 374 } 375 return newSearchTask(setting, ds, resultReceiver); 376 } 377 378 /** 379 * Create a new search task for the given search setting. 380 * @param setting The setting to use 381 * @param ds The data set to search on 382 * @param resultReceiver will receive the search result 383 * @return A new search task. 384 */ 385 private static SearchTask newSearchTask(SearchSetting setting, final OsmData<?, ?, ?, ?> ds, SearchReceiver resultReceiver) { 386 final Collection<IPrimitive> selection = new HashSet<>(ds.getAllSelected()); 387 return new SearchTask(ds, setting, selection, IPrimitive::isSelected, resultReceiver); 388 } 389 390 @Override 391 protected void cancel() { 392 this.canceled = true; 393 } 394 395 @Override 396 protected void realRun() { 397 try { 398 foundMatches = 0; 399 SearchCompiler.Match matcher = SearchCompiler.compile(setting); 400 401 if (setting.mode == SearchMode.replace) { 402 selection.clear(); 403 } else if (setting.mode == SearchMode.in_selection) { 404 foundMatches = selection.size(); 405 } 406 407 Collection<? extends IPrimitive> all; 408 if (setting.allElements) { 409 all = ds.allPrimitives(); 410 } else { 411 all = ds.getPrimitives(p -> p.isSelectable()); // Do not use method reference before Java 11! 412 } 413 final ProgressMonitor subMonitor = getProgressMonitor().createSubTaskMonitor(all.size(), false); 414 subMonitor.beginTask(trn("Searching in {0} object", "Searching in {0} objects", all.size(), all.size())); 415 416 for (IPrimitive osm : all) { 417 if (canceled) { 418 return; 419 } 420 if (setting.mode == SearchMode.replace) { 421 if (matcher.match(osm)) { 422 selection.add(osm); 423 ++foundMatches; 424 } 425 } else if (setting.mode == SearchMode.add && !predicate.test(osm) && matcher.match(osm)) { 426 selection.add(osm); 427 ++foundMatches; 428 } else if (setting.mode == SearchMode.remove && predicate.test(osm) && matcher.match(osm)) { 429 selection.remove(osm); 430 ++foundMatches; 431 } else if (setting.mode == SearchMode.in_selection && predicate.test(osm) && !matcher.match(osm)) { 432 selection.remove(osm); 433 --foundMatches; 434 } 435 subMonitor.worked(1); 436 } 437 subMonitor.finishTask(); 438 } catch (SearchParseError e) { 439 Logging.debug(e); 440 JOptionPane.showMessageDialog( 441 MainApplication.getMainFrame(), 442 e.getMessage(), 443 tr("Error"), 444 JOptionPane.ERROR_MESSAGE 445 ); 446 } 447 } 448 449 @Override 450 protected void finish() { 451 if (canceled) { 452 return; 453 } 454 resultReceiver.receiveSearchResult(ds, selection, foundMatches, setting, getProgressMonitor().getWindowParent()); 455 } 456 } 457 458 /** 459 * {@link ActionParameter} implementation with {@link SearchSetting} as value type. 460 * @since 12547 (moved from {@link ActionParameter}) 461 */ 462 public static class SearchSettingsActionParameter extends ActionParameter<SearchSetting> { 463 464 /** 465 * Constructs a new {@code SearchSettingsActionParameter}. 466 * @param name parameter name (the key) 467 */ 468 public SearchSettingsActionParameter(String name) { 469 super(name); 470 } 471 472 @Override 473 public Class<SearchSetting> getType() { 474 return SearchSetting.class; 475 } 476 477 @Override 478 public SearchSetting readFromString(String s) { 479 return SearchSetting.readFromString(s); 480 } 481 482 @Override 483 public String writeToString(SearchSetting value) { 484 if (value == null) 485 return ""; 486 return value.writeToString(); 487 } 488 } 489 490 /** 491 * Refreshes the enabled state 492 */ 493 @Override 494 protected void updateEnabledState() { 495 setEnabled(getLayerManager().getActiveData() != null); 496 } 497 498 @Override 499 public List<ActionParameter<?>> getActionParameters() { 500 return Collections.<ActionParameter<?>>singletonList(new SearchSettingsActionParameter(SEARCH_EXPRESSION)); 501 } 502}