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.ZoneId; 018import java.time.format.DateTimeFormatter; 019import java.time.format.DateTimeParseException; 020import java.util.ArrayList; 021import java.util.Collection; 022import java.util.Collections; 023import java.util.HashMap; 024import java.util.List; 025import java.util.Locale; 026import java.util.Map; 027import java.util.Objects; 028import java.util.Optional; 029import java.util.stream.Collectors; 030 031import javax.swing.AbstractAction; 032import javax.swing.BorderFactory; 033import javax.swing.JLabel; 034import javax.swing.JList; 035import javax.swing.JOptionPane; 036import javax.swing.JPanel; 037import javax.swing.JPopupMenu; 038import javax.swing.JScrollPane; 039import javax.swing.JTextField; 040import javax.swing.ListCellRenderer; 041import javax.swing.SwingUtilities; 042import javax.swing.border.CompoundBorder; 043import javax.swing.text.JTextComponent; 044 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(ZoneId.systemDefault()).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().toLowerCase(Locale.ENGLISH).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 Config#getPref}. 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 Config#getPref}. 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 if (list.isShowing()) { 327 emptySelectionPopup.show(list, e.getX(), e.getY()); 328 } 329 } else { 330 list.setSelectedIndex(index); 331 list.ensureIndexIsVisible(index); 332 if (list.isShowing()) { 333 elementPopup.show(list, e.getX(), e.getY()); 334 } 335 } 336 } 337 } 338 339 @Override 340 public void mouseMoved(MouseEvent e) { 341 super.mouseMoved(e); 342 int idx = locationToIndex(e.getPoint()); 343 if (idx == -1) { 344 return; 345 } 346 347 SelectorItem item = model.getElementAt(idx); 348 list.setToolTipText("<html><pre style='width:300px;'>" + 349 Utils.escapeReservedCharactersHTML(Utils.restrictStringLines(item.getQuery(), 9))); 350 } 351 352 private void initPopupMenus() { 353 AbstractAction add = new AbstractAction(tr("Add")) { 354 @Override 355 public void actionPerformed(ActionEvent e) { 356 createNewItem(); 357 } 358 }; 359 AbstractAction edit = new AbstractAction(tr("Edit")) { 360 @Override 361 public void actionPerformed(ActionEvent e) { 362 editSelectedItem(); 363 } 364 }; 365 AbstractAction remove = new AbstractAction(tr("Remove")) { 366 @Override 367 public void actionPerformed(ActionEvent e) { 368 removeSelectedItem(); 369 } 370 }; 371 this.emptySelectionPopup.add(add); 372 this.elementPopup.add(add); 373 this.elementPopup.add(edit); 374 this.elementPopup.add(remove); 375 } 376 } 377 378 /** 379 * This class defines the way each element is rendered in the list. 380 */ 381 private static class QueryCellRendered extends JLabel implements ListCellRenderer<SelectorItem> { 382 383 QueryCellRendered() { 384 setOpaque(true); 385 } 386 387 @Override 388 public Component getListCellRendererComponent( 389 JList<? extends SelectorItem> list, 390 SelectorItem value, 391 int index, 392 boolean isSelected, 393 boolean cellHasFocus) { 394 395 Font font = list.getFont(); 396 if (isSelected) { 397 setFont(new Font(font.getFontName(), Font.BOLD, font.getSize() + 2)); 398 setBackground(list.getSelectionBackground()); 399 setForeground(list.getSelectionForeground()); 400 } else { 401 setFont(new Font(font.getFontName(), Font.PLAIN, font.getSize() + 2)); 402 setBackground(list.getBackground()); 403 setForeground(list.getForeground()); 404 } 405 406 setEnabled(list.isEnabled()); 407 setText(value.getKey()); 408 409 if (isSelected && cellHasFocus) { 410 setBorder(new CompoundBorder( 411 BorderFactory.createLineBorder(Color.BLACK, 1), 412 BorderFactory.createEmptyBorder(2, 0, 2, 0))); 413 } else { 414 setBorder(new CompoundBorder( 415 null, 416 BorderFactory.createEmptyBorder(2, 0, 2, 0))); 417 } 418 419 return this; 420 } 421 } 422 423 /** 424 * Dialog that provides functionality to add/edit an item from the list. 425 */ 426 private final class EditItemDialog extends ExtendedDialog { 427 428 private final JTextField name; 429 private final JosmTextArea query; 430 431 private final transient AbstractTextComponentValidator queryValidator; 432 private final transient AbstractTextComponentValidator nameValidator; 433 434 private static final int SUCCESS_BTN = 0; 435 private static final int CANCEL_BTN = 1; 436 437 private final transient SelectorItem itemToEdit; 438 439 /** 440 * Added/Edited object to be returned. If {@link Optional#empty()} then probably 441 * the user closed the dialog, otherwise {@link SelectorItem} is present. 442 */ 443 private transient Optional<SelectorItem> outputItem = Optional.empty(); 444 445 EditItemDialog(Component parent, String title, String... buttonTexts) { 446 this(parent, title, null, buttonTexts); 447 } 448 449 EditItemDialog( 450 Component parent, 451 String title, 452 SelectorItem itemToEdit, 453 String... buttonTexts) { 454 super(parent, title, buttonTexts); 455 456 this.itemToEdit = itemToEdit; 457 458 String nameToEdit = itemToEdit == null ? "" : itemToEdit.getKey(); 459 String queryToEdit = itemToEdit == null ? "" : itemToEdit.getQuery(); 460 461 this.name = new JTextField(nameToEdit); 462 this.query = new JosmTextArea(queryToEdit); 463 464 this.queryValidator = new DefaultTextComponentValidator(this.query, "", tr("Query cannot be empty")); 465 this.nameValidator = new AbstractTextComponentValidator(this.name) { 466 @Override 467 public void validate() { 468 if (isValid()) { 469 feedbackValid(tr("This name can be used for the item")); 470 } else { 471 feedbackInvalid(tr("Item with this name already exists")); 472 } 473 } 474 475 @Override 476 public boolean isValid() { 477 String currentName = name.getText(); 478 479 boolean notEmpty = !Utils.isStripEmpty(currentName); 480 boolean exist = !currentName.equals(nameToEdit) && 481 items.containsKey(currentName); 482 483 return notEmpty && !exist; 484 } 485 }; 486 487 this.name.getDocument().addDocumentListener(this.nameValidator); 488 this.query.getDocument().addDocumentListener(this.queryValidator); 489 490 JPanel panel = new JPanel(new GridBagLayout()); 491 JScrollPane queryScrollPane = GuiHelper.embedInVerticalScrollPane(this.query); 492 queryScrollPane.getVerticalScrollBar().setUnitIncrement(10); // make scrolling smooth 493 494 GBC constraint = GBC.eol().insets(8, 0, 8, 8).anchor(GBC.CENTER).fill(GBC.HORIZONTAL); 495 constraint.ipady = 250; 496 panel.add(this.name, GBC.eol().insets(5).anchor(GBC.SOUTHEAST).fill(GBC.HORIZONTAL)); 497 panel.add(queryScrollPane, constraint); 498 499 setDefaultButton(SUCCESS_BTN + 1); 500 setCancelButton(CANCEL_BTN + 1); 501 setPreferredSize(new Dimension(400, 400)); 502 setContent(panel, false); 503 } 504 505 /** 506 * Gets a new {@link SelectorItem} if one was created/modified. 507 * @return A {@link SelectorItem} object created out of the fields of the dialog. 508 */ 509 public Optional<SelectorItem> getOutputItem() { 510 return this.outputItem; 511 } 512 513 @Override 514 protected void buttonAction(int buttonIndex, ActionEvent evt) { 515 if (buttonIndex == SUCCESS_BTN) { 516 if (!this.nameValidator.isValid()) { 517 JOptionPane.showMessageDialog( 518 componentParent, 519 tr("The item cannot be created with provided name"), 520 tr("Warning"), 521 JOptionPane.WARNING_MESSAGE); 522 523 return; 524 } else if (!this.queryValidator.isValid()) { 525 JOptionPane.showMessageDialog( 526 componentParent, 527 tr("The item cannot be created with an empty query"), 528 tr("Warning"), 529 JOptionPane.WARNING_MESSAGE); 530 531 return; 532 } else if (this.itemToEdit != null) { // editing the item 533 String newKey = this.name.getText(); 534 String newQuery = this.query.getText(); 535 536 String itemKey = this.itemToEdit.getKey(); 537 String itemQuery = this.itemToEdit.getQuery(); 538 539 this.outputItem = Optional.of(new SelectorItem( 540 this.name.getText(), 541 this.query.getText(), 542 !newKey.equals(itemKey) || !newQuery.equals(itemQuery) 543 ? LocalDateTime.now(ZoneId.systemDefault()) 544 : this.itemToEdit.getLastEdit())); 545 546 } else { // creating new 547 this.outputItem = Optional.of(new SelectorItem( 548 this.name.getText(), 549 this.query.getText())); 550 } 551 } 552 553 super.buttonAction(buttonIndex, evt); 554 } 555 } 556 557 /** 558 * This class represents an Overpass query used by the user that can be 559 * shown within {@link UserQueryList}. 560 */ 561 public static class SelectorItem { 562 private final String itemKey; 563 private final String query; 564 private final LocalDateTime lastEdit; 565 566 /** 567 * Constructs a new {@code SelectorItem}. 568 * @param key The key of this item. 569 * @param query The query of the item. 570 * @exception NullPointerException if any parameter is {@code null}. 571 * @exception IllegalArgumentException if any parameter is empty. 572 */ 573 public SelectorItem(String key, String query) { 574 this(key, query, LocalDateTime.now(ZoneId.systemDefault())); 575 } 576 577 /** 578 * Constructs a new {@code SelectorItem}. 579 * @param key The key of this item. 580 * @param query The query of the item. 581 * @param lastEdit The latest when the item was 582 * @exception NullPointerException if any parameter is {@code null}. 583 * @exception IllegalArgumentException if any parameter is empty. 584 */ 585 public SelectorItem(String key, String query, LocalDateTime lastEdit) { 586 Objects.requireNonNull(key, "The name of the item cannot be null"); 587 Objects.requireNonNull(query, "The query of the item cannot be null"); 588 Objects.requireNonNull(lastEdit, "The last edit date time cannot be null"); 589 590 if (Utils.isStripEmpty(key)) { 591 throw new IllegalArgumentException("The key of the item cannot be empty"); 592 } 593 if (Utils.isStripEmpty(query)) { 594 throw new IllegalArgumentException("The query cannot be empty"); 595 } 596 597 this.itemKey = key; 598 this.query = query; 599 this.lastEdit = lastEdit; 600 } 601 602 /** 603 * Gets the key (a string that is displayed in the selector) of this item. 604 * @return A string representing the key of this item. 605 */ 606 public String getKey() { 607 return this.itemKey; 608 } 609 610 /** 611 * Gets the query of this item. 612 * @return A string representing the query of this item. 613 */ 614 public String getQuery() { 615 return this.query; 616 } 617 618 /** 619 * Gets the latest date time when the item was created/changed. 620 * @return The latest date time when the item was created/changed. 621 */ 622 public LocalDateTime getLastEdit() { 623 return lastEdit; 624 } 625 626 @Override 627 public int hashCode() { 628 return Objects.hash(itemKey, query); 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}