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}