001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.download; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.awt.Color; 007import java.awt.Component; 008import java.awt.Dimension; 009import java.awt.Font; 010import java.awt.GridBagLayout; 011import java.awt.Point; 012import java.awt.event.ActionEvent; 013import java.awt.event.ActionListener; 014import java.awt.event.MouseAdapter; 015import java.awt.event.MouseEvent; 016import java.time.LocalDateTime; 017import java.time.format.DateTimeFormatter; 018import java.time.format.DateTimeParseException; 019import java.util.ArrayList; 020import java.util.Collection; 021import java.util.Collections; 022import java.util.HashMap; 023import java.util.List; 024import java.util.Locale; 025import java.util.Map; 026import java.util.Objects; 027import java.util.Optional; 028import java.util.stream.Collectors; 029 030import javax.swing.AbstractAction; 031import javax.swing.BorderFactory; 032import javax.swing.JLabel; 033import javax.swing.JList; 034import javax.swing.JOptionPane; 035import javax.swing.JPanel; 036import javax.swing.JPopupMenu; 037import javax.swing.JScrollPane; 038import javax.swing.JTextField; 039import javax.swing.ListCellRenderer; 040import javax.swing.SwingUtilities; 041import javax.swing.border.CompoundBorder; 042import javax.swing.text.JTextComponent; 043 044import org.openstreetmap.josm.Main; 045import org.openstreetmap.josm.gui.ExtendedDialog; 046import org.openstreetmap.josm.gui.util.GuiHelper; 047import org.openstreetmap.josm.gui.widgets.AbstractTextComponentValidator; 048import org.openstreetmap.josm.gui.widgets.DefaultTextComponentValidator; 049import org.openstreetmap.josm.gui.widgets.JosmTextArea; 050import org.openstreetmap.josm.gui.widgets.SearchTextResultListPanel; 051import org.openstreetmap.josm.spi.preferences.Config; 052import org.openstreetmap.josm.tools.GBC; 053import org.openstreetmap.josm.tools.Logging; 054import org.openstreetmap.josm.tools.Utils; 055 056/** 057 * A component to select user saved queries. 058 * @since 12880 059 * @since 12574 as OverpassQueryList 060 */ 061public final class UserQueryList extends SearchTextResultListPanel<UserQueryList.SelectorItem> { 062 063 private static final DateTimeFormatter FORMAT = DateTimeFormatter.ofPattern("HH:mm:ss, dd-MM-yyyy"); 064 065 /* 066 * GUI elements 067 */ 068 private final JTextComponent target; 069 private final Component componentParent; 070 071 /* 072 * All loaded elements within the list. 073 */ 074 private final transient Map<String, SelectorItem> items; 075 076 /* 077 * Preferences 078 */ 079 private static final String KEY_KEY = "key"; 080 private static final String QUERY_KEY = "query"; 081 private static final String LAST_EDIT_KEY = "lastEdit"; 082 private final String preferenceKey; 083 084 private static final String TRANSLATED_HISTORY = tr("history"); 085 086 /** 087 * Constructs a new {@code OverpassQueryList}. 088 * @param parent The parent of this component. 089 * @param target The text component to which the queries must be added. 090 * @param preferenceKey The {@linkplain org.openstreetmap.josm.spi.preferences.IPreferences preference} key to store the user queries 091 */ 092 public UserQueryList(Component parent, JTextComponent target, String preferenceKey) { 093 this.target = target; 094 this.componentParent = parent; 095 this.preferenceKey = preferenceKey; 096 this.items = restorePreferences(); 097 098 QueryListMouseAdapter mouseHandler = new QueryListMouseAdapter(lsResult, lsResultModel); 099 super.lsResult.setCellRenderer(new QueryCellRendered()); 100 super.setDblClickListener(e -> doubleClickEvent()); 101 super.lsResult.addMouseListener(mouseHandler); 102 super.lsResult.addMouseMotionListener(mouseHandler); 103 104 filterItems(); 105 } 106 107 /** 108 * Returns currently selected element from the list. 109 * @return An {@link Optional#empty()} if nothing is selected, otherwise 110 * the idem is returned. 111 */ 112 public synchronized Optional<SelectorItem> getSelectedItem() { 113 int idx = lsResult.getSelectedIndex(); 114 if (lsResultModel.getSize() <= idx || idx == -1) { 115 return Optional.empty(); 116 } 117 118 SelectorItem item = lsResultModel.getElementAt(idx); 119 120 filterItems(); 121 122 return Optional.of(item); 123 } 124 125 /** 126 * Adds a new historic item to the list. The key has form 'history {current date}'. 127 * Note, the item is not saved if there is already a historic item with the same query. 128 * @param query The query of the item. 129 * @exception IllegalArgumentException if the query is empty. 130 * @exception NullPointerException if the query is {@code null}. 131 */ 132 public synchronized void saveHistoricItem(String query) { 133 boolean historicExist = this.items.values().stream() 134 .map(SelectorItem::getQuery) 135 .anyMatch(q -> q.equals(query)); 136 137 if (!historicExist) { 138 SelectorItem item = new SelectorItem( 139 TRANSLATED_HISTORY + " " + LocalDateTime.now().format(FORMAT), query); 140 141 this.items.put(item.getKey(), item); 142 143 savePreferences(); 144 filterItems(); 145 } 146 } 147 148 /** 149 * Removes currently selected item, saves the current state to preferences and 150 * updates the view. 151 */ 152 public synchronized void removeSelectedItem() { 153 Optional<SelectorItem> it = this.getSelectedItem(); 154 155 if (!it.isPresent()) { 156 JOptionPane.showMessageDialog( 157 componentParent, 158 tr("Please select an item first")); 159 return; 160 } 161 162 SelectorItem item = it.get(); 163 if (this.items.remove(item.getKey(), item)) { 164 clearSelection(); 165 savePreferences(); 166 filterItems(); 167 } 168 } 169 170 /** 171 * Opens {@link EditItemDialog} for the selected item, saves the current state 172 * to preferences and updates the view. 173 */ 174 public synchronized void editSelectedItem() { 175 Optional<SelectorItem> it = this.getSelectedItem(); 176 177 if (!it.isPresent()) { 178 JOptionPane.showMessageDialog( 179 componentParent, 180 tr("Please select an item first")); 181 return; 182 } 183 184 SelectorItem item = it.get(); 185 186 EditItemDialog dialog = new EditItemDialog( 187 componentParent, 188 tr("Edit item"), 189 item, 190 tr("Save"), tr("Cancel")); 191 dialog.showDialog(); 192 193 Optional<SelectorItem> editedItem = dialog.getOutputItem(); 194 editedItem.ifPresent(i -> { 195 this.items.remove(item.getKey(), item); 196 this.items.put(i.getKey(), i); 197 198 savePreferences(); 199 filterItems(); 200 }); 201 } 202 203 /** 204 * Opens {@link EditItemDialog}, saves the state to preferences if a new item is added 205 * and updates the view. 206 */ 207 public synchronized void createNewItem() { 208 EditItemDialog dialog = new EditItemDialog(componentParent, tr("Add snippet"), tr("Add")); 209 dialog.showDialog(); 210 211 Optional<SelectorItem> newItem = dialog.getOutputItem(); 212 newItem.ifPresent(i -> { 213 items.put(i.getKey(), i); 214 savePreferences(); 215 filterItems(); 216 }); 217 } 218 219 @Override 220 public void setDblClickListener(ActionListener dblClickListener) { 221 // this listener is already set within this class 222 } 223 224 @Override 225 protected void filterItems() { 226 String text = edSearchText.getText().toLowerCase(Locale.ENGLISH); 227 List<SelectorItem> matchingItems = this.items.values().stream() 228 .sorted((i1, i2) -> i2.getLastEdit().compareTo(i1.getLastEdit())) 229 .filter(item -> item.getKey().contains(text)) 230 .collect(Collectors.toList()); 231 232 super.lsResultModel.setItems(matchingItems); 233 } 234 235 private void doubleClickEvent() { 236 Optional<SelectorItem> selectedItem = this.getSelectedItem(); 237 238 if (!selectedItem.isPresent()) { 239 return; 240 } 241 242 SelectorItem item = selectedItem.get(); 243 this.target.setText(item.getQuery()); 244 } 245 246 /** 247 * Saves all elements from the list to {@link Main#pref}. 248 */ 249 private void savePreferences() { 250 List<Map<String, String>> toSave = new ArrayList<>(this.items.size()); 251 for (SelectorItem item : this.items.values()) { 252 Map<String, String> it = new HashMap<>(); 253 it.put(KEY_KEY, item.getKey()); 254 it.put(QUERY_KEY, item.getQuery()); 255 it.put(LAST_EDIT_KEY, item.getLastEdit().format(FORMAT)); 256 257 toSave.add(it); 258 } 259 260 Config.getPref().putListOfMaps(preferenceKey, toSave); 261 } 262 263 /** 264 * Loads the user saved items from {@link Main#pref}. 265 * @return A set of the user saved items. 266 */ 267 private Map<String, SelectorItem> restorePreferences() { 268 Collection<Map<String, String>> toRetrieve = 269 Config.getPref().getListOfMaps(preferenceKey, Collections.emptyList()); 270 Map<String, SelectorItem> result = new HashMap<>(); 271 272 for (Map<String, String> entry : toRetrieve) { 273 try { 274 String key = entry.get(KEY_KEY); 275 String query = entry.get(QUERY_KEY); 276 String lastEditText = entry.get(LAST_EDIT_KEY); 277 // Compatibility: Some entries may not have a last edit set. 278 LocalDateTime lastEdit = lastEditText == null ? LocalDateTime.MIN : LocalDateTime.parse(lastEditText, FORMAT); 279 280 result.put(key, new SelectorItem(key, query, lastEdit)); 281 } catch (IllegalArgumentException | DateTimeParseException e) { 282 // skip any corrupted item 283 Logging.error(e); 284 } 285 } 286 287 return result; 288 } 289 290 private class QueryListMouseAdapter extends MouseAdapter { 291 292 private final JList<SelectorItem> list; 293 private final ResultListModel<SelectorItem> model; 294 private final JPopupMenu emptySelectionPopup = new JPopupMenu(); 295 private final JPopupMenu elementPopup = new JPopupMenu(); 296 297 QueryListMouseAdapter(JList<SelectorItem> list, ResultListModel<SelectorItem> listModel) { 298 this.list = list; 299 this.model = listModel; 300 301 this.initPopupMenus(); 302 } 303 304 /* 305 * Do not select the closest element if the user clicked on 306 * an empty area within the list. 307 */ 308 private int locationToIndex(Point p) { 309 int idx = list.locationToIndex(p); 310 311 if (idx != -1 && !list.getCellBounds(idx, idx).contains(p)) { 312 return -1; 313 } else { 314 return idx; 315 } 316 } 317 318 @Override 319 public void mouseClicked(MouseEvent e) { 320 super.mouseClicked(e); 321 if (SwingUtilities.isRightMouseButton(e)) { 322 int index = locationToIndex(e.getPoint()); 323 324 if (model.getSize() == 0 || index == -1) { 325 list.clearSelection(); 326 emptySelectionPopup.show(list, e.getX(), e.getY()); 327 } else { 328 list.setSelectedIndex(index); 329 list.ensureIndexIsVisible(index); 330 elementPopup.show(list, e.getX(), e.getY()); 331 } 332 } 333 } 334 335 @Override 336 public void mouseMoved(MouseEvent e) { 337 super.mouseMoved(e); 338 int idx = locationToIndex(e.getPoint()); 339 if (idx == -1) { 340 return; 341 } 342 343 SelectorItem item = model.getElementAt(idx); 344 list.setToolTipText("<html><pre style='width:300px;'>" + 345 Utils.escapeReservedCharactersHTML(Utils.restrictStringLines(item.getQuery(), 9))); 346 } 347 348 private void initPopupMenus() { 349 AbstractAction add = new AbstractAction(tr("Add")) { 350 @Override 351 public void actionPerformed(ActionEvent e) { 352 createNewItem(); 353 } 354 }; 355 AbstractAction edit = new AbstractAction(tr("Edit")) { 356 @Override 357 public void actionPerformed(ActionEvent e) { 358 editSelectedItem(); 359 } 360 }; 361 AbstractAction remove = new AbstractAction(tr("Remove")) { 362 @Override 363 public void actionPerformed(ActionEvent e) { 364 removeSelectedItem(); 365 } 366 }; 367 this.emptySelectionPopup.add(add); 368 this.elementPopup.add(add); 369 this.elementPopup.add(edit); 370 this.elementPopup.add(remove); 371 } 372 } 373 374 /** 375 * This class defines the way each element is rendered in the list. 376 */ 377 private static class QueryCellRendered extends JLabel implements ListCellRenderer<SelectorItem> { 378 379 QueryCellRendered() { 380 setOpaque(true); 381 } 382 383 @Override 384 public Component getListCellRendererComponent( 385 JList<? extends SelectorItem> list, 386 SelectorItem value, 387 int index, 388 boolean isSelected, 389 boolean cellHasFocus) { 390 391 Font font = list.getFont(); 392 if (isSelected) { 393 setFont(new Font(font.getFontName(), Font.BOLD, font.getSize() + 2)); 394 setBackground(list.getSelectionBackground()); 395 setForeground(list.getSelectionForeground()); 396 } else { 397 setFont(new Font(font.getFontName(), Font.PLAIN, font.getSize() + 2)); 398 setBackground(list.getBackground()); 399 setForeground(list.getForeground()); 400 } 401 402 setEnabled(list.isEnabled()); 403 setText(value.getKey()); 404 405 if (isSelected && cellHasFocus) { 406 setBorder(new CompoundBorder( 407 BorderFactory.createLineBorder(Color.BLACK, 1), 408 BorderFactory.createEmptyBorder(2, 0, 2, 0))); 409 } else { 410 setBorder(new CompoundBorder( 411 null, 412 BorderFactory.createEmptyBorder(2, 0, 2, 0))); 413 } 414 415 return this; 416 } 417 } 418 419 /** 420 * Dialog that provides functionality to add/edit an item from the list. 421 */ 422 private final class EditItemDialog extends ExtendedDialog { 423 424 private final JTextField name; 425 private final JosmTextArea query; 426 427 private final transient AbstractTextComponentValidator queryValidator; 428 private final transient AbstractTextComponentValidator nameValidator; 429 430 private static final int SUCCESS_BTN = 0; 431 private static final int CANCEL_BTN = 1; 432 433 private final transient SelectorItem itemToEdit; 434 435 /** 436 * Added/Edited object to be returned. If {@link Optional#empty()} then probably 437 * the user closed the dialog, otherwise {@link SelectorItem} is present. 438 */ 439 private transient Optional<SelectorItem> outputItem = Optional.empty(); 440 441 EditItemDialog(Component parent, String title, String... buttonTexts) { 442 this(parent, title, null, buttonTexts); 443 } 444 445 EditItemDialog( 446 Component parent, 447 String title, 448 SelectorItem itemToEdit, 449 String... buttonTexts) { 450 super(parent, title, buttonTexts); 451 452 this.itemToEdit = itemToEdit; 453 454 String nameToEdit = itemToEdit == null ? "" : itemToEdit.getKey(); 455 String queryToEdit = itemToEdit == null ? "" : itemToEdit.getQuery(); 456 457 this.name = new JTextField(nameToEdit); 458 this.query = new JosmTextArea(queryToEdit); 459 460 this.queryValidator = new DefaultTextComponentValidator(this.query, "", tr("Query cannot be empty")); 461 this.nameValidator = new AbstractTextComponentValidator(this.name) { 462 @Override 463 public void validate() { 464 if (isValid()) { 465 feedbackValid(tr("This name can be used for the item")); 466 } else { 467 feedbackInvalid(tr("Item with this name already exists")); 468 } 469 } 470 471 @Override 472 public boolean isValid() { 473 String currentName = name.getText(); 474 475 boolean notEmpty = !Utils.isStripEmpty(currentName); 476 boolean exist = !currentName.equals(nameToEdit) && 477 items.containsKey(currentName); 478 479 return notEmpty && !exist; 480 } 481 }; 482 483 this.name.getDocument().addDocumentListener(this.nameValidator); 484 this.query.getDocument().addDocumentListener(this.queryValidator); 485 486 JPanel panel = new JPanel(new GridBagLayout()); 487 JScrollPane queryScrollPane = GuiHelper.embedInVerticalScrollPane(this.query); 488 queryScrollPane.getVerticalScrollBar().setUnitIncrement(10); // make scrolling smooth 489 490 GBC constraint = GBC.eol().insets(8, 0, 8, 8).anchor(GBC.CENTER).fill(GBC.HORIZONTAL); 491 constraint.ipady = 250; 492 panel.add(this.name, GBC.eol().insets(5).anchor(GBC.SOUTHEAST).fill(GBC.HORIZONTAL)); 493 panel.add(queryScrollPane, constraint); 494 495 setDefaultButton(SUCCESS_BTN + 1); 496 setCancelButton(CANCEL_BTN + 1); 497 setPreferredSize(new Dimension(400, 400)); 498 setContent(panel, false); 499 } 500 501 /** 502 * Gets a new {@link SelectorItem} if one was created/modified. 503 * @return A {@link SelectorItem} object created out of the fields of the dialog. 504 */ 505 public Optional<SelectorItem> getOutputItem() { 506 return this.outputItem; 507 } 508 509 @Override 510 protected void buttonAction(int buttonIndex, ActionEvent evt) { 511 if (buttonIndex == SUCCESS_BTN) { 512 if (!this.nameValidator.isValid()) { 513 JOptionPane.showMessageDialog( 514 componentParent, 515 tr("The item cannot be created with provided name"), 516 tr("Warning"), 517 JOptionPane.WARNING_MESSAGE); 518 519 return; 520 } else if (!this.queryValidator.isValid()) { 521 JOptionPane.showMessageDialog( 522 componentParent, 523 tr("The item cannot be created with an empty query"), 524 tr("Warning"), 525 JOptionPane.WARNING_MESSAGE); 526 527 return; 528 } else if (this.itemToEdit != null) { // editing the item 529 String newKey = this.name.getText(); 530 String newQuery = this.query.getText(); 531 532 String itemKey = this.itemToEdit.getKey(); 533 String itemQuery = this.itemToEdit.getQuery(); 534 535 this.outputItem = Optional.of(new SelectorItem( 536 this.name.getText(), 537 this.query.getText(), 538 !newKey.equals(itemKey) || !newQuery.equals(itemQuery) 539 ? LocalDateTime.now() 540 : this.itemToEdit.getLastEdit())); 541 542 } else { // creating new 543 this.outputItem = Optional.of(new SelectorItem( 544 this.name.getText(), 545 this.query.getText())); 546 } 547 } 548 549 super.buttonAction(buttonIndex, evt); 550 } 551 } 552 553 /** 554 * This class represents an Overpass query used by the user that can be 555 * shown within {@link UserQueryList}. 556 */ 557 public static class SelectorItem { 558 private final String itemKey; 559 private final String query; 560 private final LocalDateTime lastEdit; 561 562 /** 563 * Constructs a new {@code SelectorItem}. 564 * @param key The key of this item. 565 * @param query The query of the item. 566 * @exception NullPointerException if any parameter is {@code null}. 567 * @exception IllegalArgumentException if any parameter is empty. 568 */ 569 public SelectorItem(String key, String query) { 570 this(key, query, LocalDateTime.now()); 571 } 572 573 /** 574 * Constructs a new {@code SelectorItem}. 575 * @param key The key of this item. 576 * @param query The query of the item. 577 * @param lastEdit The latest when the item was 578 * @exception NullPointerException if any parameter is {@code null}. 579 * @exception IllegalArgumentException if any parameter is empty. 580 */ 581 public SelectorItem(String key, String query, LocalDateTime lastEdit) { 582 Objects.requireNonNull(key, "The name of the item cannot be null"); 583 Objects.requireNonNull(query, "The query of the item cannot be null"); 584 Objects.requireNonNull(lastEdit, "The last edit date time cannot be null"); 585 586 if (Utils.isStripEmpty(key)) { 587 throw new IllegalArgumentException("The key of the item cannot be empty"); 588 } 589 if (Utils.isStripEmpty(query)) { 590 throw new IllegalArgumentException("The query cannot be empty"); 591 } 592 593 this.itemKey = key; 594 this.query = query; 595 this.lastEdit = lastEdit; 596 } 597 598 /** 599 * Gets the key (a string that is displayed in the selector) of this item. 600 * @return A string representing the key of this item. 601 */ 602 public String getKey() { 603 return this.itemKey; 604 } 605 606 /** 607 * Gets the query of this item. 608 * @return A string representing the query of this item. 609 */ 610 public String getQuery() { 611 return this.query; 612 } 613 614 /** 615 * Gets the latest date time when the item was created/changed. 616 * @return The latest date time when the item was created/changed. 617 */ 618 public LocalDateTime getLastEdit() { 619 return lastEdit; 620 } 621 622 @Override 623 public int hashCode() { 624 final int prime = 31; 625 int result = 1; 626 result = prime * result + ((itemKey == null) ? 0 : itemKey.hashCode()); 627 result = prime * result + ((query == null) ? 0 : query.hashCode()); 628 return result; 629 } 630 631 @Override 632 public boolean equals(Object obj) { 633 if (this == obj) { 634 return true; 635 } 636 if (obj == null) { 637 return false; 638 } 639 if (getClass() != obj.getClass()) { 640 return false; 641 } 642 SelectorItem other = (SelectorItem) obj; 643 if (itemKey == null) { 644 if (other.itemKey != null) { 645 return false; 646 } 647 } else if (!itemKey.equals(other.itemKey)) { 648 return false; 649 } 650 if (query == null) { 651 if (other.query != null) { 652 return false; 653 } 654 } else if (!query.equals(other.query)) { 655 return false; 656 } 657 return true; 658 } 659 } 660}