001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.dialogs; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005 006import java.awt.BorderLayout; 007import java.awt.Component; 008import java.awt.event.ActionEvent; 009import java.awt.event.KeyEvent; 010import java.awt.event.MouseAdapter; 011import java.awt.event.MouseEvent; 012import java.time.format.DateTimeFormatter; 013import java.time.format.FormatStyle; 014import java.util.ArrayList; 015import java.util.Arrays; 016import java.util.Collection; 017import java.util.List; 018import java.util.Objects; 019import java.util.function.Predicate; 020import java.util.regex.Pattern; 021 022import javax.swing.AbstractAction; 023import javax.swing.AbstractListModel; 024import javax.swing.DefaultListCellRenderer; 025import javax.swing.ImageIcon; 026import javax.swing.JLabel; 027import javax.swing.JList; 028import javax.swing.JOptionPane; 029import javax.swing.JPanel; 030import javax.swing.JScrollPane; 031import javax.swing.ListCellRenderer; 032import javax.swing.ListSelectionModel; 033import javax.swing.SwingUtilities; 034 035import org.openstreetmap.josm.actions.DownloadNotesInViewAction; 036import org.openstreetmap.josm.actions.UploadNotesAction; 037import org.openstreetmap.josm.actions.mapmode.AddNoteAction; 038import org.openstreetmap.josm.data.notes.Note; 039import org.openstreetmap.josm.data.notes.Note.State; 040import org.openstreetmap.josm.data.notes.NoteComment; 041import org.openstreetmap.josm.data.osm.NoteData; 042import org.openstreetmap.josm.data.osm.NoteData.NoteDataUpdateListener; 043import org.openstreetmap.josm.gui.MainApplication; 044import org.openstreetmap.josm.gui.MapFrame; 045import org.openstreetmap.josm.gui.NoteInputDialog; 046import org.openstreetmap.josm.gui.NoteSortDialog; 047import org.openstreetmap.josm.gui.SideButton; 048import org.openstreetmap.josm.gui.layer.LayerManager.LayerAddEvent; 049import org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener; 050import org.openstreetmap.josm.gui.layer.LayerManager.LayerOrderChangeEvent; 051import org.openstreetmap.josm.gui.layer.LayerManager.LayerRemoveEvent; 052import org.openstreetmap.josm.gui.layer.NoteLayer; 053import org.openstreetmap.josm.gui.util.DocumentAdapter; 054import org.openstreetmap.josm.gui.widgets.DisableShortcutsOnFocusGainedTextField; 055import org.openstreetmap.josm.gui.widgets.FilterField; 056import org.openstreetmap.josm.gui.widgets.JosmTextField; 057import org.openstreetmap.josm.spi.preferences.Config; 058import org.openstreetmap.josm.tools.ImageProvider; 059import org.openstreetmap.josm.tools.OpenBrowser; 060import org.openstreetmap.josm.tools.Shortcut; 061import org.openstreetmap.josm.tools.Utils; 062import org.openstreetmap.josm.tools.date.DateUtils; 063 064/** 065 * Dialog to display and manipulate notes. 066 * @since 7852 (renaming) 067 * @since 7608 (creation) 068 */ 069public class NotesDialog extends ToggleDialog implements LayerChangeListener, NoteDataUpdateListener { 070 071 private NoteTableModel model; 072 private JList<Note> displayList; 073 private final JosmTextField filter = setupFilter(); 074 private final AddCommentAction addCommentAction; 075 private final CloseAction closeAction; 076 private final DownloadNotesInViewAction downloadNotesInViewAction; 077 private final NewAction newAction; 078 private final ReopenAction reopenAction; 079 private final SortAction sortAction; 080 private final OpenInBrowserAction openInBrowserAction; 081 private final UploadNotesAction uploadAction; 082 083 private transient NoteData noteData; 084 085 /** Creates a new toggle dialog for notes */ 086 public NotesDialog() { 087 super(tr("Notes"), "notes/note_open", tr("List of notes"), 088 Shortcut.registerShortcut("subwindow:notes", tr("Windows: {0}", tr("Notes")), 089 KeyEvent.CHAR_UNDEFINED, Shortcut.NONE), 150); 090 addCommentAction = new AddCommentAction(); 091 closeAction = new CloseAction(); 092 downloadNotesInViewAction = DownloadNotesInViewAction.newActionWithDownloadIcon(); 093 newAction = new NewAction(); 094 reopenAction = new ReopenAction(); 095 sortAction = new SortAction(); 096 openInBrowserAction = new OpenInBrowserAction(); 097 uploadAction = new UploadNotesAction(); 098 buildDialog(); 099 MainApplication.getLayerManager().addLayerChangeListener(this); 100 } 101 102 private void buildDialog() { 103 model = new NoteTableModel(); 104 displayList = new JList<>(model); 105 displayList.setCellRenderer(new NoteRenderer()); 106 displayList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION); 107 displayList.addListSelectionListener(e -> { 108 if (noteData != null) { //happens when layer is deleted while note selected 109 noteData.setSelectedNote(displayList.getSelectedValue()); 110 } 111 updateButtonStates(); 112 }); 113 displayList.addMouseListener(new MouseAdapter() { 114 //center view on selected note on double click 115 @Override 116 public void mouseClicked(MouseEvent e) { 117 if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2 && noteData != null && noteData.getSelectedNote() != null) { 118 MainApplication.getMap().mapView.zoomTo(noteData.getSelectedNote().getLatLon()); 119 } 120 } 121 }); 122 123 JPanel pane = new JPanel(new BorderLayout()); 124 pane.add(filter, BorderLayout.NORTH); 125 pane.add(new JScrollPane(displayList), BorderLayout.CENTER); 126 127 createLayout(pane, false, Arrays.asList( 128 new SideButton(downloadNotesInViewAction, false), 129 new SideButton(newAction, false), 130 new SideButton(addCommentAction, false), 131 new SideButton(closeAction, false), 132 new SideButton(reopenAction, false), 133 new SideButton(sortAction, false), 134 new SideButton(openInBrowserAction, false), 135 new SideButton(uploadAction, false))); 136 updateButtonStates(); 137 } 138 139 private void updateButtonStates() { 140 if (noteData == null || noteData.getSelectedNote() == null) { 141 closeAction.setEnabled(false); 142 addCommentAction.setEnabled(false); 143 reopenAction.setEnabled(false); 144 } else if (noteData.getSelectedNote().getState() == State.OPEN) { 145 closeAction.setEnabled(true); 146 addCommentAction.setEnabled(true); 147 reopenAction.setEnabled(false); 148 } else { //note is closed 149 closeAction.setEnabled(false); 150 addCommentAction.setEnabled(false); 151 reopenAction.setEnabled(true); 152 } 153 openInBrowserAction.setEnabled(noteData != null && noteData.getSelectedNote() != null && noteData.getSelectedNote().getId() > 0); 154 uploadAction.setEnabled(noteData != null && noteData.isModified()); 155 //enable sort button if any notes are loaded 156 sortAction.setEnabled(noteData != null && !noteData.getNotes().isEmpty()); 157 } 158 159 @Override 160 public void layerAdded(LayerAddEvent e) { 161 if (e.getAddedLayer() instanceof NoteLayer) { 162 noteData = ((NoteLayer) e.getAddedLayer()).getNoteData(); 163 model.setData(noteData.getNotes()); 164 setNotes(noteData.getSortedNotes()); 165 noteData.addNoteDataUpdateListener(this); 166 } 167 } 168 169 @Override 170 public void layerRemoving(LayerRemoveEvent e) { 171 if (e.getRemovedLayer() instanceof NoteLayer) { 172 NoteData removedNoteData = ((NoteLayer) e.getRemovedLayer()).getNoteData(); 173 removedNoteData.removeNoteDataUpdateListener(this); 174 if (Objects.equals(noteData, removedNoteData)) { 175 noteData = null; 176 model.clearData(); 177 MapFrame map = MainApplication.getMap(); 178 if (map.mapMode instanceof AddNoteAction) { 179 map.selectMapMode(map.mapModeSelect); 180 } 181 } 182 } 183 } 184 185 @Override 186 public void layerOrderChanged(LayerOrderChangeEvent e) { 187 // ignored 188 } 189 190 @Override 191 public void noteDataUpdated(NoteData data) { 192 setNotes(data.getSortedNotes()); 193 } 194 195 @Override 196 public void selectedNoteChanged(NoteData noteData) { 197 selectionChanged(); 198 } 199 200 /** 201 * Sets the list of notes to be displayed in the dialog. 202 * The dialog should match the notes displayed in the note layer. 203 * @param noteList List of notes to display 204 */ 205 public void setNotes(Collection<Note> noteList) { 206 model.setData(noteList); 207 updateButtonStates(); 208 this.repaint(); 209 } 210 211 /** 212 * Notify the dialog that the note selection has changed. 213 * Causes it to update or clear its selection in the UI. 214 */ 215 public void selectionChanged() { 216 if (noteData == null || noteData.getSelectedNote() == null) { 217 displayList.clearSelection(); 218 } else { 219 displayList.setSelectedValue(noteData.getSelectedNote(), true); 220 } 221 updateButtonStates(); 222 // TODO make a proper listener mechanism to handle change of note selection 223 MainApplication.getMenu().infoweb.noteSelectionChanged(); 224 } 225 226 /** 227 * Returns the currently selected note, if any. 228 * @return currently selected note, or null 229 * @since 8475 230 */ 231 public Note getSelectedNote() { 232 return noteData != null ? noteData.getSelectedNote() : null; 233 } 234 235 private JosmTextField setupFilter() { 236 final JosmTextField f = new DisableShortcutsOnFocusGainedTextField(); 237 FilterField.setSearchIcon(f); 238 f.setToolTipText(tr("Note filter")); 239 f.getDocument().addDocumentListener(DocumentAdapter.create(ignore -> { 240 String text = f.getText(); 241 model.setFilter(note -> matchesNote(text, note)); 242 })); 243 return f; 244 } 245 246 static boolean matchesNote(String filter, Note note) { 247 if (Utils.isEmpty(filter)) { 248 return true; 249 } 250 return Pattern.compile("\\s+").splitAsStream(filter).allMatch(string -> { 251 NoteComment lastComment = note.getLastComment(); 252 switch (string) { 253 case "open": 254 return note.getState() == State.OPEN; 255 case "closed": 256 return note.getState() == State.CLOSED; 257 case "reopened": 258 return lastComment != null && lastComment.getNoteAction() == NoteComment.Action.REOPENED; 259 case "new": 260 return note.getId() < 0; 261 case "modified": 262 return lastComment != null && lastComment.isNew(); 263 default: 264 return note.getComments().toString().contains(string); 265 } 266 }); 267 } 268 269 @Override 270 public void destroy() { 271 MainApplication.getLayerManager().removeLayerChangeListener(this); 272 super.destroy(); 273 } 274 275 static class NoteRenderer implements ListCellRenderer<Note> { 276 277 private final DefaultListCellRenderer defaultListCellRenderer = new DefaultListCellRenderer(); 278 private final DateTimeFormatter dateFormat = DateUtils.getDateTimeFormatter(FormatStyle.MEDIUM, FormatStyle.SHORT); 279 280 @Override 281 public Component getListCellRendererComponent(JList<? extends Note> list, Note note, int index, 282 boolean isSelected, boolean cellHasFocus) { 283 Component comp = defaultListCellRenderer.getListCellRendererComponent(list, note, index, isSelected, cellHasFocus); 284 if (note != null && comp instanceof JLabel) { 285 NoteComment fstComment = note.getFirstComment(); 286 JLabel jlabel = (JLabel) comp; 287 if (fstComment != null) { 288 String text = fstComment.getText(); 289 String userName = fstComment.getUser().getName(); 290 if (Utils.isEmpty(userName)) { 291 userName = "<Anonymous>"; 292 } 293 String toolTipText = userName + " @ " + dateFormat.format(note.getCreatedAt()); 294 jlabel.setToolTipText(toolTipText); 295 jlabel.setText(note.getId() + ": " +text.replace("\n\n", "\n").replace("\n", "; ").replace(":; ", ": ")); 296 } else { 297 jlabel.setToolTipText(null); 298 jlabel.setText(Long.toString(note.getId())); 299 } 300 ImageIcon icon; 301 if (note.getId() < 0) { 302 icon = ImageProvider.get("dialogs/notes", "note_new", ImageProvider.ImageSizes.SMALLICON); 303 } else if (note.getState() == State.CLOSED) { 304 icon = ImageProvider.get("dialogs/notes", "note_closed", ImageProvider.ImageSizes.SMALLICON); 305 } else { 306 icon = ImageProvider.get("dialogs/notes", "note_open", ImageProvider.ImageSizes.SMALLICON); 307 } 308 jlabel.setIcon(icon); 309 } 310 return comp; 311 } 312 } 313 314 class NoteTableModel extends AbstractListModel<Note> { 315 private final transient List<Note> data = new ArrayList<>(); 316 private final transient List<Note> filteredData = new ArrayList<>(); 317 private transient Predicate<Note> filter; 318 319 @Override 320 public int getSize() { 321 return filteredData.size(); 322 } 323 324 @Override 325 public Note getElementAt(int index) { 326 return filteredData.get(index); 327 } 328 329 public void setFilter(Predicate<Note> filter) { 330 this.filter = filter; 331 filteredData.clear(); 332 if (filter == null) { 333 filteredData.addAll(data); 334 } else { 335 data.stream().filter(filter).forEach(filteredData::add); 336 } 337 fireContentsChanged(this, 0, getSize()); 338 setTitle(data.isEmpty() 339 ? tr("Notes") 340 : tr("Notes: {0}/{1}", filteredData.size(), data.size())); 341 } 342 343 public void setData(Collection<Note> noteList) { 344 data.clear(); 345 data.addAll(noteList); 346 setFilter(filter); 347 } 348 349 public void clearData() { 350 displayList.clearSelection(); 351 data.clear(); 352 setFilter(filter); 353 } 354 } 355 356 class AddCommentAction extends AbstractAction { 357 358 /** 359 * Constructs a new {@code AddCommentAction}. 360 */ 361 AddCommentAction() { 362 putValue(SHORT_DESCRIPTION, tr("Add comment")); 363 putValue(NAME, tr("Comment")); 364 new ImageProvider("dialogs/notes", "note_comment").getResource().attachImageIcon(this, true); 365 } 366 367 @Override 368 public void actionPerformed(ActionEvent e) { 369 Note note = displayList.getSelectedValue(); 370 if (note == null) { 371 JOptionPane.showMessageDialog(MainApplication.getMap(), 372 "You must select a note first", 373 "No note selected", 374 JOptionPane.ERROR_MESSAGE); 375 return; 376 } 377 NoteInputDialog dialog = new NoteInputDialog(MainApplication.getMainFrame(), tr("Comment on note"), tr("Add comment")); 378 dialog.showNoteDialog(tr("Add comment to note:"), ImageProvider.get("dialogs/notes", "note_comment")); 379 if (dialog.getValue() != 1) { 380 return; 381 } 382 int selectedIndex = displayList.getSelectedIndex(); 383 noteData.addCommentToNote(note, dialog.getInputText()); 384 noteData.setSelectedNote(model.getElementAt(selectedIndex)); 385 } 386 } 387 388 class CloseAction extends AbstractAction { 389 390 /** 391 * Constructs a new {@code CloseAction}. 392 */ 393 CloseAction() { 394 putValue(SHORT_DESCRIPTION, tr("Close note")); 395 putValue(NAME, tr("Close")); 396 new ImageProvider("dialogs/notes", "note_closed").getResource().attachImageIcon(this, true); 397 } 398 399 @Override 400 public void actionPerformed(ActionEvent e) { 401 NoteInputDialog dialog = new NoteInputDialog(MainApplication.getMainFrame(), tr("Close note"), tr("Close note")); 402 dialog.showNoteDialog(tr("Close note with message:"), ImageProvider.get("dialogs/notes", "note_closed")); 403 if (dialog.getValue() != 1) { 404 return; 405 } 406 Note note = displayList.getSelectedValue(); 407 if (note != null) { 408 int selectedIndex = displayList.getSelectedIndex(); 409 noteData.closeNote(note, dialog.getInputText()); 410 noteData.setSelectedNote(model.getElementAt(selectedIndex)); 411 } 412 } 413 } 414 415 class NewAction extends AbstractAction { 416 417 /** 418 * Constructs a new {@code NewAction}. 419 */ 420 NewAction() { 421 putValue(SHORT_DESCRIPTION, tr("Create a new note")); 422 putValue(NAME, tr("Create")); 423 new ImageProvider("dialogs/notes", "note_new").getResource().attachImageIcon(this, true); 424 } 425 426 @Override 427 public void actionPerformed(ActionEvent e) { 428 if (noteData == null) { //there is no notes layer. Create one first 429 MainApplication.getLayerManager().addLayer(new NoteLayer()); 430 } 431 if (noteData != null) { 432 MainApplication.getMap().selectMapMode(new AddNoteAction(noteData)); 433 } 434 } 435 } 436 437 class ReopenAction extends AbstractAction { 438 439 /** 440 * Constructs a new {@code ReopenAction}. 441 */ 442 ReopenAction() { 443 putValue(SHORT_DESCRIPTION, tr("Reopen note")); 444 putValue(NAME, tr("Reopen")); 445 new ImageProvider("dialogs/notes", "note_open").getResource().attachImageIcon(this, true); 446 } 447 448 @Override 449 public void actionPerformed(ActionEvent e) { 450 NoteInputDialog dialog = new NoteInputDialog(MainApplication.getMainFrame(), tr("Reopen note"), tr("Reopen note")); 451 dialog.showNoteDialog(tr("Reopen note with message:"), ImageProvider.get("dialogs/notes", "note_open")); 452 if (dialog.getValue() != 1) { 453 return; 454 } 455 456 Note note = displayList.getSelectedValue(); 457 int selectedIndex = displayList.getSelectedIndex(); 458 noteData.reOpenNote(note, dialog.getInputText()); 459 noteData.setSelectedNote(model.getElementAt(selectedIndex)); 460 } 461 } 462 463 class SortAction extends AbstractAction { 464 465 /** 466 * Constructs a new {@code SortAction}. 467 */ 468 SortAction() { 469 putValue(SHORT_DESCRIPTION, tr("Sort notes")); 470 putValue(NAME, tr("Sort")); 471 new ImageProvider("dialogs", "sort").getResource().attachImageIcon(this, true); 472 } 473 474 @Override 475 public void actionPerformed(ActionEvent e) { 476 NoteSortDialog sortDialog = new NoteSortDialog(MainApplication.getMainFrame(), tr("Sort notes"), tr("Apply")); 477 sortDialog.showSortDialog(noteData.getCurrentSortMethod()); 478 if (sortDialog.getValue() == 1) { 479 noteData.setSortMethod(sortDialog.getSelectedComparator()); 480 } 481 } 482 } 483 484 class OpenInBrowserAction extends AbstractAction { 485 OpenInBrowserAction() { 486 putValue(SHORT_DESCRIPTION, tr("Open the note in an external browser")); 487 new ImageProvider("help", "internet").getResource().attachImageIcon(this, true); 488 } 489 490 @Override 491 public void actionPerformed(ActionEvent e) { 492 final Note note = displayList.getSelectedValue(); 493 if (note.getId() > 0) { 494 final String url = Config.getUrls().getBaseBrowseUrl() + "/note/" + note.getId(); 495 OpenBrowser.displayUrl(url); 496 } 497 } 498 } 499 500}