Changeset 14927 in josm
- Timestamp:
- 2019-03-24T22:30:59+01:00 (6 years ago)
- Location:
- trunk/src/org/openstreetmap/josm
- Files:
-
- 1 added
- 1 edited
Legend:
- Unmodified
- Added
- Removed
-
trunk/src/org/openstreetmap/josm/actions/search/SearchAction.java
r14687 r14927 7 7 8 8 import java.awt.Component; 9 import java.awt.Cursor;10 import java.awt.Dimension;11 import java.awt.FlowLayout;12 9 import java.awt.GraphicsEnvironment; 13 import java.awt.GridBagLayout;14 10 import java.awt.event.ActionEvent; 15 11 import java.awt.event.KeyEvent; 16 import java.awt.event.MouseAdapter;17 import java.awt.event.MouseEvent;18 12 import java.util.ArrayList; 19 13 import java.util.Arrays; … … 28 22 import java.util.function.Predicate; 29 23 30 import javax.swing.BorderFactory;31 import javax.swing.ButtonGroup;32 import javax.swing.JCheckBox;33 import javax.swing.JLabel;34 24 import javax.swing.JOptionPane; 35 import javax.swing.JPanel;36 import javax.swing.JRadioButton;37 import javax.swing.SwingUtilities;38 import javax.swing.text.BadLocationException;39 import javax.swing.text.Document;40 import javax.swing.text.JTextComponent;41 25 42 26 import org.openstreetmap.josm.actions.ActionParameter; … … 44 28 import org.openstreetmap.josm.actions.JosmAction; 45 29 import org.openstreetmap.josm.actions.ParameterizedAction; 46 import org.openstreetmap.josm.data.osm.Filter;47 30 import org.openstreetmap.josm.data.osm.IPrimitive; 48 31 import org.openstreetmap.josm.data.osm.OsmData; … … 54 37 import org.openstreetmap.josm.data.osm.search.SearchParseError; 55 38 import org.openstreetmap.josm.data.osm.search.SearchSetting; 56 import org.openstreetmap.josm.gui.ExtendedDialog;57 39 import org.openstreetmap.josm.gui.MainApplication; 58 40 import org.openstreetmap.josm.gui.MapFrame; 59 41 import org.openstreetmap.josm.gui.Notification; 60 42 import org.openstreetmap.josm.gui.PleaseWaitRunnable; 61 import org.openstreetmap.josm.gui. mappaint.mapcss.MapCSSException;43 import org.openstreetmap.josm.gui.dialogs.SearchDialog; 62 44 import org.openstreetmap.josm.gui.preferences.ToolbarPreferences; 63 45 import org.openstreetmap.josm.gui.preferences.ToolbarPreferences.ActionParser; 64 46 import org.openstreetmap.josm.gui.progress.ProgressMonitor; 65 import org.openstreetmap.josm.gui.tagging.presets.TaggingPreset;66 import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetSelector;67 import org.openstreetmap.josm.gui.widgets.AbstractTextComponentValidator;68 import org.openstreetmap.josm.gui.widgets.HistoryComboBox;69 47 import org.openstreetmap.josm.spi.preferences.Config; 70 import org.openstreetmap.josm.tools.GBC;71 import org.openstreetmap.josm.tools.JosmRuntimeException;72 48 import org.openstreetmap.josm.tools.Logging; 73 49 import org.openstreetmap.josm.tools.Shortcut; … … 78 54 * 79 55 * @see SearchCompiler 56 * @see SearchDialog 80 57 */ 81 58 public class SearchAction extends JosmAction implements ParameterizedAction { … … 191 168 } 192 169 193 private static class SearchKeywordRow extends JPanel {194 195 private final HistoryComboBox hcb;196 197 SearchKeywordRow(HistoryComboBox hcb) {198 super(new FlowLayout(FlowLayout.LEFT));199 this.hcb = hcb;200 }201 202 /**203 * Adds the title (prefix) label at the beginning of the row. Should be called only once.204 * @param title English title205 * @return {@code this} for easy chaining206 */207 public SearchKeywordRow addTitle(String title) {208 add(new JLabel(tr("{0}: ", title)));209 return this;210 }211 212 /**213 * Adds an example keyword label at the end of the row. Can be called several times.214 * @param displayText displayed HTML text215 * @param insertText optional: if set, makes the label clickable, and {@code insertText} will be inserted in search string216 * @param description optional: HTML text to be displayed in the tooltip217 * @param examples optional: examples joined as HTML list in the tooltip218 * @return {@code this} for easy chaining219 */220 public SearchKeywordRow addKeyword(String displayText, final String insertText, String description, String... examples) {221 JLabel label = new JLabel("<html>"222 + "<style>td{border:1px solid gray; font-weight:normal;}</style>"223 + "<table><tr><td>" + displayText + "</td></tr></table></html>");224 add(label);225 if (description != null || examples.length > 0) {226 label.setToolTipText("<html>"227 + description228 + (examples.length > 0 ? Utils.joinAsHtmlUnorderedList(Arrays.asList(examples)) : "")229 + "</html>");230 }231 if (insertText != null) {232 label.setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR));233 label.addMouseListener(new MouseAdapter() {234 235 @Override236 public void mouseClicked(MouseEvent e) {237 JTextComponent tf = hcb.getEditorComponent();238 239 /*240 * Make sure that the focus is transferred to the search text field241 * from the selector component.242 */243 if (!tf.hasFocus()) {244 tf.requestFocusInWindow();245 }246 247 /*248 * In order to make interaction with the search dialog simpler,249 * we make sure that if autocompletion triggers and the text field is250 * not in focus, the correct area is selected. We first request focus251 * and then execute the selection logic. invokeLater allows us to252 * defer the selection until waiting for focus.253 */254 SwingUtilities.invokeLater(() -> {255 try {256 tf.getDocument().insertString(tf.getCaretPosition(), ' ' + insertText, null);257 } catch (BadLocationException ex) {258 throw new JosmRuntimeException(ex.getMessage(), ex);259 }260 });261 }262 });263 }264 return this;265 }266 }267 170 268 171 /** … … 277 180 } 278 181 279 // prepare the combo box with the search expressions 280 JLabel label = new JLabel(initialValues instanceof Filter ? tr("Filter string:") : tr("Search string:")); 281 HistoryComboBox hcbSearchString = new HistoryComboBox(); 282 String tooltip = tr("Enter the search expression"); 283 hcbSearchString.setText(initialValues.text); 284 hcbSearchString.setToolTipText(tooltip); 285 286 // we have to reverse the history, because ComboBoxHistory will reverse it again in addElement() 287 List<String> searchExpressionHistory = getSearchExpressionHistory(); 288 Collections.reverse(searchExpressionHistory); 289 hcbSearchString.setPossibleItems(searchExpressionHistory); 290 hcbSearchString.setPreferredSize(new Dimension(40, hcbSearchString.getPreferredSize().height)); 291 label.setLabelFor(hcbSearchString); 292 293 JRadioButton replace = new JRadioButton(tr("replace selection"), initialValues.mode == SearchMode.replace); 294 JRadioButton add = new JRadioButton(tr("add to selection"), initialValues.mode == SearchMode.add); 295 JRadioButton remove = new JRadioButton(tr("remove from selection"), initialValues.mode == SearchMode.remove); 296 JRadioButton inSelection = new JRadioButton(tr("find in selection"), initialValues.mode == SearchMode.in_selection); 297 ButtonGroup bg = new ButtonGroup(); 298 bg.add(replace); 299 bg.add(add); 300 bg.add(remove); 301 bg.add(inSelection); 302 303 JCheckBox caseSensitive = new JCheckBox(tr("case sensitive"), initialValues.caseSensitive); 304 JCheckBox allElements = new JCheckBox(tr("all objects"), initialValues.allElements); 305 allElements.setToolTipText(tr("Also include incomplete and deleted objects in search.")); 306 JCheckBox addOnToolbar = new JCheckBox(tr("add toolbar button"), false); 307 308 JRadioButton standardSearch = new JRadioButton(tr("standard"), !initialValues.regexSearch && !initialValues.mapCSSSearch); 309 JRadioButton regexSearch = new JRadioButton(tr("regular expression"), initialValues.regexSearch); 310 JRadioButton mapCSSSearch = new JRadioButton(tr("MapCSS selector"), initialValues.mapCSSSearch); 311 ButtonGroup bg2 = new ButtonGroup(); 312 bg2.add(standardSearch); 313 bg2.add(regexSearch); 314 bg2.add(mapCSSSearch); 315 316 JPanel selectionSettings = new JPanel(new GridBagLayout()); 317 selectionSettings.setBorder(BorderFactory.createTitledBorder(tr("Selection settings"))); 318 selectionSettings.add(replace, GBC.eol().anchor(GBC.WEST).fill(GBC.HORIZONTAL)); 319 selectionSettings.add(add, GBC.eol()); 320 selectionSettings.add(remove, GBC.eol()); 321 selectionSettings.add(inSelection, GBC.eop()); 322 323 JPanel additionalSettings = new JPanel(new GridBagLayout()); 324 additionalSettings.setBorder(BorderFactory.createTitledBorder(tr("Additional settings"))); 325 additionalSettings.add(caseSensitive, GBC.eol().anchor(GBC.WEST).fill(GBC.HORIZONTAL)); 326 327 JPanel left = new JPanel(new GridBagLayout()); 328 329 left.add(selectionSettings, GBC.eol().fill(GBC.BOTH)); 330 left.add(additionalSettings, GBC.eol().fill(GBC.BOTH)); 331 332 if (ExpertToggleAction.isExpert()) { 333 additionalSettings.add(allElements, GBC.eol()); 334 additionalSettings.add(addOnToolbar, GBC.eop()); 335 336 JPanel searchOptions = new JPanel(new GridBagLayout()); 337 searchOptions.setBorder(BorderFactory.createTitledBorder(tr("Search syntax"))); 338 searchOptions.add(standardSearch, GBC.eol().anchor(GBC.WEST).fill(GBC.HORIZONTAL)); 339 searchOptions.add(regexSearch, GBC.eol()); 340 searchOptions.add(mapCSSSearch, GBC.eol()); 341 342 left.add(searchOptions, GBC.eol().fill(GBC.BOTH)); 343 } 344 345 JPanel right = SearchAction.buildHintsSection(hcbSearchString); 346 JPanel top = new JPanel(new GridBagLayout()); 347 top.add(label, GBC.std().insets(0, 0, 5, 0)); 348 top.add(hcbSearchString, GBC.eol().fill(GBC.HORIZONTAL)); 349 350 JTextComponent editorComponent = hcbSearchString.getEditorComponent(); 351 Document document = editorComponent.getDocument(); 352 353 /* 354 * Setup the logic to validate the contents of the search text field which is executed 355 * every time the content of the field has changed. If the query is incorrect, then 356 * the text field is colored red. 357 */ 358 document.addDocumentListener(new AbstractTextComponentValidator(editorComponent) { 359 360 @Override 361 public void validate() { 362 if (!isValid()) { 363 feedbackInvalid(tr("Invalid search expression")); 364 } else { 365 feedbackValid(tooltip); 366 } 367 } 368 369 @Override 370 public boolean isValid() { 371 try { 372 SearchSetting ss = new SearchSetting(); 373 ss.text = hcbSearchString.getText(); 374 ss.caseSensitive = caseSensitive.isSelected(); 375 ss.regexSearch = regexSearch.isSelected(); 376 ss.mapCSSSearch = mapCSSSearch.isSelected(); 377 SearchCompiler.compile(ss); 378 return true; 379 } catch (SearchParseError | MapCSSException e) { 380 return false; 381 } 382 } 383 }); 384 385 /* 386 * Setup the logic to append preset queries to the search text field according to 387 * selected preset by the user. Every query is of the form ' group/sub-group/.../presetName' 388 * if the corresponding group of the preset exists, otherwise it is simply ' presetName'. 389 */ 390 TaggingPresetSelector selector = new TaggingPresetSelector(false, false); 391 selector.setBorder(BorderFactory.createTitledBorder(tr("Search by preset"))); 392 selector.setDblClickListener(ev -> setPresetDblClickListener(selector, editorComponent)); 393 394 JPanel p = new JPanel(new GridBagLayout()); 395 p.add(top, GBC.eol().fill(GBC.HORIZONTAL).insets(5, 5, 5, 0)); 396 p.add(left, GBC.std().anchor(GBC.NORTH).insets(5, 10, 10, 0).fill(GBC.VERTICAL)); 397 p.add(right, GBC.std().fill(GBC.BOTH).insets(0, 10, 0, 0)); 398 p.add(selector, GBC.eol().fill(GBC.BOTH).insets(0, 10, 0, 0)); 399 400 ExtendedDialog dialog = new ExtendedDialog( 401 MainApplication.getMainFrame(), 402 initialValues instanceof Filter ? tr("Filter") : tr("Search"), 403 initialValues instanceof Filter ? tr("Submit filter") : tr("Start Search"), 404 tr("Cancel") 405 ) { 406 @Override 407 protected void buttonAction(int buttonIndex, ActionEvent evt) { 408 if (buttonIndex == 0) { 409 try { 410 SearchSetting ss = new SearchSetting(); 411 ss.text = hcbSearchString.getText(); 412 ss.caseSensitive = caseSensitive.isSelected(); 413 ss.regexSearch = regexSearch.isSelected(); 414 ss.mapCSSSearch = mapCSSSearch.isSelected(); 415 SearchCompiler.compile(ss); 416 super.buttonAction(buttonIndex, evt); 417 } catch (SearchParseError | MapCSSException e) { 418 Logging.debug(e); 419 JOptionPane.showMessageDialog( 420 MainApplication.getMainFrame(), 421 "<html>" + tr("Search expression is not valid: \n\n {0}", 422 e.getMessage().replace("<html>", "").replace("</html>", "")).replace("\n", "<br>") + 423 "</html>", 424 tr("Invalid search expression"), 425 JOptionPane.ERROR_MESSAGE); 426 } 427 } else { 428 super.buttonAction(buttonIndex, evt); 429 } 430 } 431 }; 432 dialog.setButtonIcons("dialogs/search", "cancel"); 433 dialog.configureContextsensitiveHelp("/Action/Search", true /* show help button */); 434 dialog.setContent(p); 182 SearchDialog dialog = new SearchDialog( 183 initialValues, getSearchExpressionHistory(), ExpertToggleAction.isExpert()); 435 184 436 185 if (dialog.showDialog().getValue() != 1) return null; 437 186 438 187 // User pressed OK - let's perform the search 439 initialValues.text = hcbSearchString.getText(); 440 initialValues.caseSensitive = caseSensitive.isSelected(); 441 initialValues.allElements = allElements.isSelected(); 442 initialValues.regexSearch = regexSearch.isSelected(); 443 initialValues.mapCSSSearch = mapCSSSearch.isSelected(); 444 445 if (inSelection.isSelected()) { 446 initialValues.mode = SearchMode.in_selection; 447 } else if (replace.isSelected()) { 448 initialValues.mode = SearchMode.replace; 449 } else if (add.isSelected()) { 450 initialValues.mode = SearchMode.add; 451 } else { 452 initialValues.mode = SearchMode.remove; 453 } 454 455 if (addOnToolbar.isSelected()) { 188 SearchSetting searchSettings = dialog.getSearchSettings(); 189 190 if (dialog.isAddOnToolbar()) { 456 191 ToolbarPreferences.ActionDefinition aDef = 457 192 new ToolbarPreferences.ActionDefinition(MainApplication.getMenu().search); 458 aDef.getParameters().put(SEARCH_EXPRESSION, initialValues);193 aDef.getParameters().put(SEARCH_EXPRESSION, searchSettings); 459 194 // Display search expression as tooltip instead of generic one 460 aDef.setName(Utils.shortenString( initialValues.text, MAX_LENGTH_SEARCH_EXPRESSION_DISPLAY));195 aDef.setName(Utils.shortenString(searchSettings.text, MAX_LENGTH_SEARCH_EXPRESSION_DISPLAY)); 461 196 // parametrized action definition is now composed 462 197 ActionParser actionParser = new ToolbarPreferences.ActionParser(null); … … 467 202 } 468 203 469 return initialValues; 470 } 471 472 private static JPanel buildHintsSection(HistoryComboBox hcbSearchString) { 473 JPanel hintPanel = new JPanel(new GridBagLayout()); 474 hintPanel.setBorder(BorderFactory.createTitledBorder(tr("Search hints"))); 475 476 hintPanel.add(new SearchKeywordRow(hcbSearchString) 477 .addTitle(tr("basics")) 478 .addKeyword(tr("Baker Street"), null, tr("''Baker'' and ''Street'' in any key")) 479 .addKeyword(tr("\"Baker Street\""), "\"\"", tr("''Baker Street'' in any key")) 480 .addKeyword("<i>key</i>:<i>valuefragment</i>", null, 481 tr("''valuefragment'' anywhere in ''key''"), "name:str matches name=Bakerstreet") 482 .addKeyword("-<i>key</i>:<i>valuefragment</i>", null, tr("''valuefragment'' nowhere in ''key''")), 483 GBC.eol()); 484 hintPanel.add(new SearchKeywordRow(hcbSearchString) 485 .addKeyword("<i>key</i>", null, tr("matches if ''key'' exists")) 486 .addKeyword("<i>key</i>=<i>value</i>", null, tr("''key'' with exactly ''value''")) 487 .addKeyword("<i>key</i>=*", null, tr("''key'' with any value")) 488 .addKeyword("<i>key</i>=", null, tr("''key'' with empty value")) 489 .addKeyword("*=<i>value</i>", null, tr("''value'' in any key")) 490 .addKeyword("<i>key</i>><i>value</i>", null, tr("matches if ''key'' is greater than ''value'' (analogously, less than)")) 491 .addKeyword("\"key\"=\"value\"", "\"\"=\"\"", 492 tr("to quote operators.<br>Within quoted strings the <b>\"</b> and <b>\\</b> characters need to be escaped " + 493 "by a preceding <b>\\</b> (e.g. <b>\\\"</b> and <b>\\\\</b>)."), 494 "\"addr:street\""), 495 GBC.eol().anchor(GBC.CENTER)); 496 hintPanel.add(new SearchKeywordRow(hcbSearchString) 497 .addTitle(tr("combinators")) 498 .addKeyword("<i>expr</i> <i>expr</i>", null, tr("logical and (both expressions have to be satisfied)")) 499 .addKeyword("<i>expr</i> | <i>expr</i>", "| ", tr("logical or (at least one expression has to be satisfied)")) 500 .addKeyword("<i>expr</i> OR <i>expr</i>", "OR ", tr("logical or (at least one expression has to be satisfied)")) 501 .addKeyword("-<i>expr</i>", null, tr("logical not")) 502 .addKeyword("(<i>expr</i>)", "()", tr("use parenthesis to group expressions")), 503 GBC.eol()); 504 505 if (ExpertToggleAction.isExpert()) { 506 hintPanel.add(new SearchKeywordRow(hcbSearchString) 507 .addTitle(tr("objects")) 508 .addKeyword("type:node", "type:node ", tr("all nodes")) 509 .addKeyword("type:way", "type:way ", tr("all ways")) 510 .addKeyword("type:relation", "type:relation ", tr("all relations")) 511 .addKeyword("closed", "closed ", tr("all closed ways")) 512 .addKeyword("untagged", "untagged ", tr("object without useful tags")), 513 GBC.eol()); 514 hintPanel.add(new SearchKeywordRow(hcbSearchString) 515 .addKeyword("preset:\"Annotation/Address\"", "preset:\"Annotation/Address\"", 516 tr("all objects that use the address preset")) 517 .addKeyword("preset:\"Geography/Nature/*\"", "preset:\"Geography/Nature/*\"", 518 tr("all objects that use any preset under the Geography/Nature group")), 519 GBC.eol().anchor(GBC.CENTER)); 520 hintPanel.add(new SearchKeywordRow(hcbSearchString) 521 .addTitle(tr("metadata")) 522 .addKeyword("user:", "user:", tr("objects changed by user", "user:anonymous")) 523 .addKeyword("id:", "id:", tr("objects with given ID"), "id:0 (new objects)") 524 .addKeyword("version:", "version:", tr("objects with given version"), "version:0 (objects without an assigned version)") 525 .addKeyword("changeset:", "changeset:", tr("objects with given changeset ID"), 526 "changeset:0 (objects without an assigned changeset)") 527 .addKeyword("timestamp:", "timestamp:", tr("objects with last modification timestamp within range"), "timestamp:2012/", 528 "timestamp:2008/2011-02-04T12"), 529 GBC.eol()); 530 hintPanel.add(new SearchKeywordRow(hcbSearchString) 531 .addTitle(tr("properties")) 532 .addKeyword("nodes:<i>20-</i>", "nodes:", tr("ways with at least 20 nodes, or relations containing at least 20 nodes")) 533 .addKeyword("ways:<i>3-</i>", "ways:", tr("nodes with at least 3 referring ways, or relations containing at least 3 ways")) 534 .addKeyword("tags:<i>5-10</i>", "tags:", tr("objects having 5 to 10 tags")) 535 .addKeyword("role:", "role:", tr("objects with given role in a relation")) 536 .addKeyword("areasize:<i>-100</i>", "areasize:", tr("closed ways with an area of 100 m\u00b2")) 537 .addKeyword("waylength:<i>200-</i>", "waylength:", tr("ways with a length of 200 m or more")), 538 GBC.eol()); 539 hintPanel.add(new SearchKeywordRow(hcbSearchString) 540 .addTitle(tr("state")) 541 .addKeyword("modified", "modified ", tr("all modified objects")) 542 .addKeyword("new", "new ", tr("all new objects")) 543 .addKeyword("selected", "selected ", tr("all selected objects")) 544 .addKeyword("incomplete", "incomplete ", tr("all incomplete objects")) 545 .addKeyword("deleted", "deleted ", tr("all deleted objects (checkbox <b>{0}</b> must be enabled)", tr("all objects"))), 546 GBC.eol()); 547 hintPanel.add(new SearchKeywordRow(hcbSearchString) 548 .addTitle(tr("related objects")) 549 .addKeyword("child <i>expr</i>", "child ", tr("all children of objects matching the expression"), "child building") 550 .addKeyword("parent <i>expr</i>", "parent ", tr("all parents of objects matching the expression"), "parent bus_stop") 551 .addKeyword("hasRole:<i>stop</i>", "hasRole:", tr("relation containing a member of role <i>stop</i>")) 552 .addKeyword("role:<i>stop</i>", "role:", tr("objects being part of a relation as role <i>stop</i>")) 553 .addKeyword("nth:<i>7</i>", "nth:", 554 tr("n-th member of relation and/or n-th node of way"), "nth:5 (child type:relation)", "nth:-1") 555 .addKeyword("nth%:<i>7</i>", "nth%:", 556 tr("every n-th member of relation and/or every n-th node of way"), "nth%:100 (child waterway)"), 557 GBC.eol()); 558 hintPanel.add(new SearchKeywordRow(hcbSearchString) 559 .addTitle(tr("view")) 560 .addKeyword("inview", "inview ", tr("objects in current view")) 561 .addKeyword("allinview", "allinview ", tr("objects (and all its way nodes / relation members) in current view")) 562 .addKeyword("indownloadedarea", "indownloadedarea ", tr("objects in downloaded area")) 563 .addKeyword("allindownloadedarea", "allindownloadedarea ", 564 tr("objects (and all its way nodes / relation members) in downloaded area")), 565 GBC.eol()); 566 } 567 568 return hintPanel; 204 return searchSettings; 569 205 } 570 206 … … 634 270 SearchTask.newSearchTask(searchSetting, receiver).run(); 635 271 return receiver.result; 636 }637 638 /**639 *640 * @param selector Selector component that the user interacts with641 * @param searchEditor Editor for search queries642 */643 private static void setPresetDblClickListener(TaggingPresetSelector selector, JTextComponent searchEditor) {644 TaggingPreset selectedPreset = selector.getSelectedPresetAndUpdateClassification();645 646 if (selectedPreset == null) {647 return;648 }649 650 /*651 * Make sure that the focus is transferred to the search text field652 * from the selector component.653 */654 searchEditor.requestFocusInWindow();655 656 /*657 * In order to make interaction with the search dialog simpler,658 * we make sure that if autocompletion triggers and the text field is659 * not in focus, the correct area is selected. We first request focus660 * and then execute the selection logic. invokeLater allows us to661 * defer the selection until waiting for focus.662 */663 SwingUtilities.invokeLater(() -> {664 int textOffset = searchEditor.getCaretPosition();665 String presetSearchQuery = " preset:" +666 "\"" + selectedPreset.getRawName() + "\"";667 try {668 searchEditor.getDocument().insertString(textOffset, presetSearchQuery, null);669 } catch (BadLocationException e1) {670 throw new JosmRuntimeException(e1.getMessage(), e1);671 }672 });673 272 } 674 273
Note:
See TracChangeset
for help on using the changeset viewer.