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}