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