source: josm/trunk/src/org/openstreetmap/josm/gui/dialogs/NotesDialog.java

Last change on this file was 18839, checked in by taylor.smock, 3 years ago

Fix #23179: Include changeset in note comment if feasible (patch by qeef, modified)

Modifications are as follows:

  • Unit tests
  • Better note matching
  • Find multiple changesets referring the same note
  • Property svn:eol-style set to native
File size: 22.7 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.gui.dialogs;
3
4import static org.openstreetmap.josm.tools.I18n.tr;
5
6import java.awt.BorderLayout;
7import java.awt.Component;
8import java.awt.event.ActionEvent;
9import java.awt.event.KeyEvent;
10import java.awt.event.MouseAdapter;
11import java.awt.event.MouseEvent;
12import java.time.format.DateTimeFormatter;
13import java.time.format.FormatStyle;
14import java.util.ArrayList;
15import java.util.Arrays;
16import java.util.Collection;
17import java.util.Collections;
18import java.util.List;
19import java.util.Objects;
20import java.util.function.Predicate;
21import java.util.regex.Matcher;
22import java.util.regex.Pattern;
23import java.util.stream.Collectors;
24
25import javax.swing.AbstractListModel;
26import javax.swing.DefaultListCellRenderer;
27import javax.swing.ImageIcon;
28import javax.swing.JLabel;
29import javax.swing.JList;
30import javax.swing.JOptionPane;
31import javax.swing.JPanel;
32import javax.swing.JPopupMenu;
33import javax.swing.JScrollPane;
34import javax.swing.ListCellRenderer;
35import javax.swing.ListSelectionModel;
36import javax.swing.SwingUtilities;
37
38import org.openstreetmap.josm.actions.DownloadNotesInViewAction;
39import org.openstreetmap.josm.actions.JosmAction;
40import org.openstreetmap.josm.actions.UploadNotesAction;
41import org.openstreetmap.josm.actions.mapmode.AddNoteAction;
42import org.openstreetmap.josm.data.notes.Note;
43import org.openstreetmap.josm.data.notes.Note.State;
44import org.openstreetmap.josm.data.notes.NoteComment;
45import org.openstreetmap.josm.data.osm.Changeset;
46import org.openstreetmap.josm.data.osm.ChangesetCache;
47import org.openstreetmap.josm.data.osm.NoteData;
48import org.openstreetmap.josm.data.osm.NoteData.NoteDataUpdateListener;
49import org.openstreetmap.josm.gui.MainApplication;
50import org.openstreetmap.josm.gui.MapFrame;
51import org.openstreetmap.josm.gui.NoteInputDialog;
52import org.openstreetmap.josm.gui.NoteSortDialog;
53import org.openstreetmap.josm.gui.SideButton;
54import org.openstreetmap.josm.gui.layer.LayerManager.LayerAddEvent;
55import org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener;
56import org.openstreetmap.josm.gui.layer.LayerManager.LayerOrderChangeEvent;
57import org.openstreetmap.josm.gui.layer.LayerManager.LayerRemoveEvent;
58import org.openstreetmap.josm.gui.layer.NoteLayer;
59import org.openstreetmap.josm.gui.util.DocumentAdapter;
60import org.openstreetmap.josm.gui.widgets.DisableShortcutsOnFocusGainedTextField;
61import org.openstreetmap.josm.gui.widgets.FilterField;
62import org.openstreetmap.josm.gui.widgets.JosmTextField;
63import org.openstreetmap.josm.gui.widgets.PopupMenuLauncher;
64import org.openstreetmap.josm.io.OsmApi;
65import org.openstreetmap.josm.spi.preferences.Config;
66import org.openstreetmap.josm.tools.ImageProvider;
67import org.openstreetmap.josm.tools.OpenBrowser;
68import org.openstreetmap.josm.tools.Shortcut;
69import org.openstreetmap.josm.tools.Utils;
70import org.openstreetmap.josm.tools.date.DateUtils;
71
72/**
73 * Dialog to display and manipulate notes.
74 * @since 7852 (renaming)
75 * @since 7608 (creation)
76 */
77public class NotesDialog extends ToggleDialog implements LayerChangeListener, NoteDataUpdateListener {
78
79 private NoteTableModel model;
80 private JList<Note> displayList;
81 private final JosmTextField filter = setupFilter();
82 private final AddCommentAction addCommentAction;
83 private final CloseAction closeAction;
84 private final DownloadNotesInViewAction downloadNotesInViewAction;
85 private final NewAction newAction;
86 private final ReopenAction reopenAction;
87 private final SortAction sortAction;
88 private final OpenInBrowserAction openInBrowserAction;
89 private final UploadNotesAction uploadAction;
90
91 private transient NoteData noteData;
92
93 /** Creates a new toggle dialog for notes */
94 public NotesDialog() {
95 super(tr("Notes"), "notes/note_open", tr("List of notes"),
96 Shortcut.registerShortcut("subwindow:notes", tr("Windows: {0}", tr("Notes")),
97 KeyEvent.CHAR_UNDEFINED, Shortcut.NONE), 150);
98 addCommentAction = new AddCommentAction();
99 closeAction = new CloseAction();
100 downloadNotesInViewAction = DownloadNotesInViewAction.newActionWithDownloadIcon();
101 newAction = new NewAction();
102 reopenAction = new ReopenAction();
103 sortAction = new SortAction();
104 openInBrowserAction = new OpenInBrowserAction();
105 uploadAction = new UploadNotesAction();
106 buildDialog();
107 MainApplication.getLayerManager().addLayerChangeListener(this);
108 }
109
110 private void buildDialog() {
111 model = new NoteTableModel();
112 displayList = new JList<>(model);
113 displayList.setCellRenderer(new NoteRenderer());
114 displayList.setSelectionMode(ListSelectionModel.SINGLE_SELECTION);
115 displayList.addListSelectionListener(e -> {
116 if (noteData != null) { //happens when layer is deleted while note selected
117 noteData.setSelectedNote(displayList.getSelectedValue());
118 }
119 updateButtonStates();
120 });
121 displayList.addMouseListener(new MouseAdapter() {
122 //center view on selected note on double click
123 @Override
124 public void mouseClicked(MouseEvent e) {
125 if (SwingUtilities.isLeftMouseButton(e) && e.getClickCount() == 2 && noteData != null && noteData.getSelectedNote() != null) {
126 MainApplication.getMap().mapView.zoomTo(noteData.getSelectedNote().getLatLon());
127 }
128 }
129 });
130
131 JPanel pane = new JPanel(new BorderLayout());
132 pane.add(filter, BorderLayout.NORTH);
133 pane.add(new JScrollPane(displayList), BorderLayout.CENTER);
134
135 createLayout(pane, false, Arrays.asList(
136 new SideButton(downloadNotesInViewAction, false),
137 new SideButton(newAction, false),
138 new SideButton(addCommentAction, false),
139 new SideButton(closeAction, false),
140 new SideButton(reopenAction, false),
141 new SideButton(sortAction, false),
142 new SideButton(openInBrowserAction, false),
143 new SideButton(uploadAction, false)));
144 updateButtonStates();
145
146 JPopupMenu notesPopupMenu = new JPopupMenu();
147 notesPopupMenu.add(addCommentAction);
148 notesPopupMenu.add(openInBrowserAction);
149 notesPopupMenu.add(closeAction);
150 notesPopupMenu.add(reopenAction);
151 displayList.addMouseListener(new PopupMenuLauncher(notesPopupMenu));
152 }
153
154 private void updateButtonStates() {
155 if (noteData == null || noteData.getSelectedNote() == null) {
156 closeAction.setEnabled(false);
157 addCommentAction.setEnabled(false);
158 reopenAction.setEnabled(false);
159 } else if (noteData.getSelectedNote().getState() == State.OPEN) {
160 closeAction.setEnabled(true);
161 addCommentAction.setEnabled(true);
162 reopenAction.setEnabled(false);
163 } else { //note is closed
164 closeAction.setEnabled(false);
165 addCommentAction.setEnabled(false);
166 reopenAction.setEnabled(true);
167 }
168 openInBrowserAction.setEnabled(noteData != null && noteData.getSelectedNote() != null && noteData.getSelectedNote().getId() > 0);
169 uploadAction.setEnabled(noteData != null && noteData.isModified());
170 //enable sort button if any notes are loaded
171 sortAction.setEnabled(noteData != null && !noteData.getNotes().isEmpty());
172 }
173
174 @Override
175 public void layerAdded(LayerAddEvent e) {
176 if (e.getAddedLayer() instanceof NoteLayer) {
177 noteData = ((NoteLayer) e.getAddedLayer()).getNoteData();
178 model.setData(noteData.getNotes());
179 setNotes(noteData.getSortedNotes());
180 noteData.addNoteDataUpdateListener(this);
181 }
182 }
183
184 @Override
185 public void layerRemoving(LayerRemoveEvent e) {
186 if (e.getRemovedLayer() instanceof NoteLayer) {
187 NoteData removedNoteData = ((NoteLayer) e.getRemovedLayer()).getNoteData();
188 removedNoteData.removeNoteDataUpdateListener(this);
189 if (Objects.equals(noteData, removedNoteData)) {
190 noteData = null;
191 model.clearData();
192 MapFrame map = MainApplication.getMap();
193 if (map.mapMode instanceof AddNoteAction) {
194 map.selectMapMode(map.mapModeSelect);
195 }
196 }
197 }
198 }
199
200 @Override
201 public void layerOrderChanged(LayerOrderChangeEvent e) {
202 // ignored
203 }
204
205 @Override
206 public void noteDataUpdated(NoteData data) {
207 setNotes(data.getSortedNotes());
208 }
209
210 @Override
211 public void selectedNoteChanged(NoteData noteData) {
212 selectionChanged();
213 }
214
215 /**
216 * Sets the list of notes to be displayed in the dialog.
217 * The dialog should match the notes displayed in the note layer.
218 * @param noteList List of notes to display
219 */
220 public void setNotes(Collection<Note> noteList) {
221 model.setData(noteList);
222 updateButtonStates();
223 selectionChanged();
224 this.repaint();
225 }
226
227 /**
228 * Notify the dialog that the note selection has changed.
229 * Causes it to update or clear its selection in the UI.
230 */
231 public void selectionChanged() {
232 if (noteData == null || noteData.getSelectedNote() == null || displayList.getModel().getSize() == 0) {
233 displayList.clearSelection();
234 } else {
235 displayList.setSelectedValue(noteData.getSelectedNote(), true);
236 }
237 updateButtonStates();
238 // TODO make a proper listener mechanism to handle change of note selection
239 MainApplication.getMenu().infoweb.noteSelectionChanged();
240 }
241
242 /**
243 * Returns the currently selected note, if any.
244 * @return currently selected note, or null
245 * @since 8475
246 */
247 public Note getSelectedNote() {
248 return noteData != null ? noteData.getSelectedNote() : null;
249 }
250
251 private JosmTextField setupFilter() {
252 final JosmTextField f = new DisableShortcutsOnFocusGainedTextField();
253 FilterField.setSearchIcon(f);
254 f.setToolTipText(tr("Note filter"));
255 f.getDocument().addDocumentListener(DocumentAdapter.create(ignore -> {
256 String text = f.getText();
257 model.setFilter(note -> matchesNote(text, note));
258 }));
259 return f;
260 }
261
262 static boolean matchesNote(String filter, Note note) {
263 if (Utils.isEmpty(filter)) {
264 return true;
265 }
266 return Pattern.compile("\\s+").splitAsStream(filter).allMatch(string -> {
267 NoteComment lastComment = note.getLastComment();
268 switch (string) {
269 case "open":
270 return note.getState() == State.OPEN;
271 case "closed":
272 return note.getState() == State.CLOSED;
273 case "reopened":
274 return lastComment != null && lastComment.getNoteAction() == NoteComment.Action.REOPENED;
275 case "new":
276 return note.getId() < 0;
277 case "modified":
278 return lastComment != null && lastComment.isNew();
279 default:
280 return note.getComments().toString().contains(string);
281 }
282 });
283 }
284
285 @Override
286 public void destroy() {
287 MainApplication.getLayerManager().removeLayerChangeListener(this);
288 super.destroy();
289 }
290
291 static class NoteRenderer implements ListCellRenderer<Note> {
292
293 private final DefaultListCellRenderer defaultListCellRenderer = new DefaultListCellRenderer();
294 private final DateTimeFormatter dateFormat = DateUtils.getDateTimeFormatter(FormatStyle.MEDIUM, FormatStyle.SHORT);
295
296 @Override
297 public Component getListCellRendererComponent(JList<? extends Note> list, Note note, int index,
298 boolean isSelected, boolean cellHasFocus) {
299 Component comp = defaultListCellRenderer.getListCellRendererComponent(list, note, index, isSelected, cellHasFocus);
300 if (note != null && comp instanceof JLabel) {
301 NoteComment fstComment = note.getFirstComment();
302 JLabel jlabel = (JLabel) comp;
303 if (fstComment != null) {
304 String text = fstComment.getText();
305 String userName = fstComment.getUser().getName();
306 if (Utils.isEmpty(userName)) {
307 userName = "<Anonymous>";
308 }
309 String toolTipText = userName + " @ " + dateFormat.format(note.getCreatedAt());
310 jlabel.setToolTipText(toolTipText);
311 jlabel.setText(note.getId() + ": " +text.replace("\n\n", "\n").replace("\n", "; ").replace(":; ", ": "));
312 } else {
313 jlabel.setToolTipText(null);
314 jlabel.setText(Long.toString(note.getId()));
315 }
316 ImageIcon icon;
317 if (note.getId() < 0) {
318 icon = ImageProvider.get("dialogs/notes", "note_new", ImageProvider.ImageSizes.SMALLICON);
319 } else if (note.getState() == State.CLOSED) {
320 icon = ImageProvider.get("dialogs/notes", "note_closed", ImageProvider.ImageSizes.SMALLICON);
321 } else {
322 icon = ImageProvider.get("dialogs/notes", "note_open", ImageProvider.ImageSizes.SMALLICON);
323 }
324 jlabel.setIcon(icon);
325 }
326 return comp;
327 }
328 }
329
330 class NoteTableModel extends AbstractListModel<Note> {
331 private final transient List<Note> data = new ArrayList<>();
332 private final transient List<Note> filteredData = new ArrayList<>();
333 private transient Predicate<Note> filter;
334
335 @Override
336 public int getSize() {
337 return filteredData.size();
338 }
339
340 @Override
341 public Note getElementAt(int index) {
342 return filteredData.get(index);
343 }
344
345 public void setFilter(Predicate<Note> filter) {
346 this.filter = filter;
347 filteredData.clear();
348 if (filter == null) {
349 filteredData.addAll(data);
350 } else {
351 filteredData.addAll(data.stream().filter(filter).collect(Collectors.toList()));
352 }
353 fireContentsChanged(this, 0, getSize());
354 setTitle(data.isEmpty()
355 ? tr("Notes")
356 : tr("Notes: {0}/{1}", filteredData.size(), data.size()));
357 }
358
359 /**
360 * Set the note data
361 * @param noteList The notes to show
362 */
363 public void setData(Collection<Note> noteList) {
364 data.clear();
365 data.addAll(noteList);
366 setFilter(filter);
367 }
368
369 /**
370 * Clear the note data
371 */
372 public void clearData() {
373 displayList.clearSelection();
374 data.clear();
375 setFilter(filter);
376 }
377 }
378
379 /**
380 * The action to add a new comment to OSM
381 */
382 class AddCommentAction extends JosmAction {
383
384 /**
385 * Constructs a new {@code AddCommentAction}.
386 */
387 AddCommentAction() {
388 super(tr("Comment"), "dialogs/notes/note_comment", tr("Add comment"),
389 Shortcut.registerShortcut("notes:comment:add", tr("Notes: Add comment"), KeyEvent.VK_UNDEFINED, Shortcut.NONE),
390 false, false);
391 }
392
393 @Override
394 public void actionPerformed(ActionEvent e) {
395 Note note = displayList.getSelectedValue();
396 if (note == null) {
397 JOptionPane.showMessageDialog(MainApplication.getMap(),
398 "You must select a note first",
399 "No note selected",
400 JOptionPane.ERROR_MESSAGE);
401 return;
402 }
403 NoteInputDialog dialog = new NoteInputDialog(MainApplication.getMainFrame(), tr("Comment on note"), tr("Add comment"));
404 dialog.showNoteDialog(tr("Add comment to note:"), ImageProvider.get("dialogs/notes", "note_comment"));
405 if (dialog.getValue() != 1) {
406 return;
407 }
408 int selectedIndex = displayList.getSelectedIndex();
409 noteData.addCommentToNote(note, dialog.getInputText());
410 noteData.setSelectedNote(model.getElementAt(selectedIndex));
411 }
412 }
413
414 /**
415 * Close a note
416 */
417 class CloseAction extends JosmAction {
418
419 /**
420 * Constructs a new {@code CloseAction}.
421 */
422 CloseAction() {
423 super(tr("Close"), "dialogs/notes/note_closed", tr("Close note"),
424 Shortcut.registerShortcut("notes:comment:close", tr("Notes: Close note"), KeyEvent.VK_UNDEFINED, Shortcut.NONE),
425 false, false);
426 }
427
428 @Override
429 public void actionPerformed(ActionEvent e) {
430 Note note = displayList.getSelectedValue();
431 final List<String> changesetUrls = note == null ? Collections.emptyList() : getRelatedChangesetUrls(note.getId());
432 NoteInputDialog dialog = new NoteInputDialog(MainApplication.getMainFrame(), tr("Close note"), tr("Close note"));
433 dialog.showNoteDialog(tr("Close note with message:"), ImageProvider.get("dialogs/notes", "note_closed"),
434 String.join("\n", changesetUrls));
435 if (dialog.getValue() != 1) {
436 return;
437 }
438 if (note != null) {
439 int selectedIndex = displayList.getSelectedIndex();
440 noteData.closeNote(note, dialog.getInputText());
441 // This is required since filtering may cause the model to not have any visible elements
442 if (model.getSize() > 0) {
443 noteData.setSelectedNote(model.getElementAt(selectedIndex));
444 } else {
445 noteData.setSelectedNote(null);
446 }
447 }
448 }
449 }
450
451 /**
452 * Get a list of changeset urls that may have fixed a note
453 * @param noteId The note ID to look for
454 * @return A list of changeset URLs
455 */
456 static List<String> getRelatedChangesetUrls(long noteId) {
457 final List<String> changesetUrls = new ArrayList<>();
458 final int patternFlags = Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CHARACTER_CLASS;
459 final Matcher noteMatcher = Pattern.compile("note " + noteId + "( |$)", patternFlags).matcher("");
460 final Matcher shortOsmMatcher = Pattern.compile("osm.org/note/" + noteId + "( |$)", patternFlags).matcher("");
461 final String hostUrl = Pattern.compile("https?://(www\\.)?")
462 .matcher(Config.getUrls().getBaseBrowseUrl())
463 .replaceFirst("");
464 final Matcher longOsmMatcher = Pattern.compile(hostUrl + "/note/" + noteId + "( |$)", patternFlags).matcher("");
465 final boolean isUsingDefaultOsmApi = Config.getUrls().getDefaultOsmApiUrl().equals(OsmApi.getOsmApi().getServerUrl());
466 for (Changeset cs: ChangesetCache.getInstance().getChangesets()) {
467 final String comment = cs.getComment();
468 if ((isUsingDefaultOsmApi && (shortOsmMatcher.reset(comment).find() || noteMatcher.reset(comment).find()))
469 || longOsmMatcher.reset(comment).find()) {
470 changesetUrls.add(Config.getUrls().getBaseBrowseUrl() + "/changeset/" + cs.getId());
471 }
472 }
473 return changesetUrls;
474 }
475
476 /**
477 * Create a new note
478 */
479 class NewAction extends JosmAction {
480
481 /**
482 * Constructs a new {@code NewAction}.
483 */
484 NewAction() {
485 super(tr("Create"), "dialogs/notes/note_new", tr("Create a new note"),
486 Shortcut.registerShortcut("notes:comment:new", tr("Notes: New note"), KeyEvent.VK_UNDEFINED, Shortcut.NONE),
487 false, false);
488 }
489
490 @Override
491 public void actionPerformed(ActionEvent e) {
492 if (noteData == null) { //there is no notes layer. Create one first
493 MainApplication.getLayerManager().addLayer(new NoteLayer());
494 }
495 if (noteData != null) {
496 MainApplication.getMap().selectMapMode(new AddNoteAction(noteData));
497 }
498 }
499 }
500
501 /**
502 * Reopen a note
503 */
504 class ReopenAction extends JosmAction {
505
506 /**
507 * Constructs a new {@code ReopenAction}.
508 */
509 ReopenAction() {
510 super(tr("Reopen"), "dialogs/notes/note_open", tr("Reopen note"),
511 Shortcut.registerShortcut("notes:comment:reopen", tr("Notes: Reopen note"), KeyEvent.VK_UNDEFINED, Shortcut.NONE),
512 false, false);
513 }
514
515 @Override
516 public void actionPerformed(ActionEvent e) {
517 NoteInputDialog dialog = new NoteInputDialog(MainApplication.getMainFrame(), tr("Reopen note"), tr("Reopen note"));
518 dialog.showNoteDialog(tr("Reopen note with message:"), ImageProvider.get("dialogs/notes", "note_open"));
519 if (dialog.getValue() != 1) {
520 return;
521 }
522
523 Note note = displayList.getSelectedValue();
524 int selectedIndex = displayList.getSelectedIndex();
525 noteData.reOpenNote(note, dialog.getInputText());
526 noteData.setSelectedNote(model.getElementAt(selectedIndex));
527 }
528 }
529
530 /**
531 * Sort notes
532 */
533 class SortAction extends JosmAction {
534
535 /**
536 * Constructs a new {@code SortAction}.
537 */
538 SortAction() {
539 super(tr("Sort"), "dialogs/sort", tr("Sort notes"),
540 Shortcut.registerShortcut("notes:comment:sort", tr("Notes: Sort notes"), KeyEvent.VK_UNDEFINED, Shortcut.NONE),
541 false, false);
542 }
543
544 @Override
545 public void actionPerformed(ActionEvent e) {
546 NoteSortDialog sortDialog = new NoteSortDialog(MainApplication.getMainFrame(), tr("Sort notes"), tr("Apply"));
547 sortDialog.showSortDialog(noteData.getCurrentSortMethod());
548 if (sortDialog.getValue() == 1) {
549 noteData.setSortMethod(sortDialog.getSelectedComparator());
550 }
551 }
552 }
553
554 /**
555 * Open the note in a browser
556 */
557 class OpenInBrowserAction extends JosmAction {
558 OpenInBrowserAction() {
559 super(tr("Open in browser"), "help/internet", tr("Open the note in an external browser"),
560 Shortcut.registerShortcut("notes:comment:open_in_browser", tr("Notes: Open note in browser"),
561 KeyEvent.VK_UNDEFINED, Shortcut.NONE), false, false);
562 }
563
564 @Override
565 public void actionPerformed(ActionEvent e) {
566 final Note note = displayList.getSelectedValue();
567 if (note.getId() > 0) {
568 final String url = Config.getUrls().getBaseBrowseUrl() + "/note/" + note.getId();
569 OpenBrowser.displayUrl(url);
570 }
571 }
572 }
573
574}
Note: See TracBrowser for help on using the repository browser.