001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.tagging;
003
004import static org.openstreetmap.josm.tools.I18n.trn;
005
006import java.beans.PropertyChangeListener;
007import java.beans.PropertyChangeSupport;
008import java.util.ArrayList;
009import java.util.Arrays;
010import java.util.Collection;
011import java.util.Comparator;
012import java.util.EnumSet;
013import java.util.List;
014import java.util.Map;
015import java.util.Map.Entry;
016import java.util.Objects;
017import java.util.stream.Collectors;
018import java.util.stream.IntStream;
019
020import javax.swing.DefaultListSelectionModel;
021import javax.swing.table.AbstractTableModel;
022
023import org.openstreetmap.josm.command.ChangePropertyCommand;
024import org.openstreetmap.josm.command.Command;
025import org.openstreetmap.josm.command.SequenceCommand;
026import org.openstreetmap.josm.data.osm.OsmPrimitive;
027import org.openstreetmap.josm.data.osm.Tag;
028import org.openstreetmap.josm.data.osm.TagCollection;
029import org.openstreetmap.josm.data.osm.TagMap;
030import org.openstreetmap.josm.data.osm.Tagged;
031import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetType;
032import org.openstreetmap.josm.tools.CheckParameterUtil;
033import org.openstreetmap.josm.tools.Utils;
034
035/**
036 * TagEditorModel is a table model to use with {@link TagEditorPanel}.
037 * @since 1762
038 */
039public class TagEditorModel extends AbstractTableModel {
040    /**
041     * The dirty property. It is set whenever this table was changed
042     */
043    public static final String PROP_DIRTY = TagEditorModel.class.getName() + ".dirty";
044
045    /** the list holding the tags */
046    protected final transient List<TagModel> tags = new ArrayList<>();
047
048    /** indicates whether the model is dirty */
049    private boolean dirty;
050    private final PropertyChangeSupport propChangeSupport = new PropertyChangeSupport(this);
051
052    private final DefaultListSelectionModel rowSelectionModel;
053    private final DefaultListSelectionModel colSelectionModel;
054
055    private transient OsmPrimitive primitive;
056
057    private transient EndEditListener endEditListener;
058
059    /**
060     * Creates a new tag editor model. Internally allocates two selection models
061     * for row selection and column selection.
062     *
063     * To create a {@link javax.swing.JTable} with this model:
064     * <pre>
065     *    TagEditorModel model = new TagEditorModel();
066     *    TagTable tbl  = new TagTabel(model);
067     * </pre>
068     *
069     * @see #getRowSelectionModel()
070     * @see #getColumnSelectionModel()
071     */
072    public TagEditorModel() {
073        this(new DefaultListSelectionModel(), new DefaultListSelectionModel());
074    }
075
076    /**
077     * Creates a new tag editor model.
078     *
079     * @param rowSelectionModel the row selection model. Must not be null.
080     * @param colSelectionModel the column selection model. Must not be null.
081     * @throws IllegalArgumentException if {@code rowSelectionModel} is null
082     * @throws IllegalArgumentException if {@code colSelectionModel} is null
083     */
084    public TagEditorModel(DefaultListSelectionModel rowSelectionModel, DefaultListSelectionModel colSelectionModel) {
085        CheckParameterUtil.ensureParameterNotNull(rowSelectionModel, "rowSelectionModel");
086        CheckParameterUtil.ensureParameterNotNull(colSelectionModel, "colSelectionModel");
087        this.rowSelectionModel = rowSelectionModel;
088        this.colSelectionModel = colSelectionModel;
089    }
090
091    /**
092     * Adds property change listener.
093     * @param listener property change listener to add
094     */
095    public void addPropertyChangeListener(PropertyChangeListener listener) {
096        propChangeSupport.addPropertyChangeListener(listener);
097    }
098
099    /**
100     * Replies the row selection model used by this tag editor model
101     *
102     * @return the row selection model used by this tag editor model
103     */
104    public DefaultListSelectionModel getRowSelectionModel() {
105        return rowSelectionModel;
106    }
107
108    /**
109     * Replies the column selection model used by this tag editor model
110     *
111     * @return the column selection model used by this tag editor model
112     */
113    public DefaultListSelectionModel getColumnSelectionModel() {
114        return colSelectionModel;
115    }
116
117    /**
118     * Removes property change listener.
119     * @param listener property change listener to remove
120     */
121    public void removePropertyChangeListener(PropertyChangeListener listener) {
122        propChangeSupport.removePropertyChangeListener(listener);
123    }
124
125    protected void fireDirtyStateChanged(final boolean oldValue, final boolean newValue) {
126        propChangeSupport.firePropertyChange(PROP_DIRTY, oldValue, newValue);
127    }
128
129    protected void setDirty(boolean newValue) {
130        boolean oldValue = dirty;
131        dirty = newValue;
132        if (oldValue != newValue) {
133            fireDirtyStateChanged(oldValue, newValue);
134        }
135    }
136
137    @Override
138    public int getColumnCount() {
139        return 2;
140    }
141
142    @Override
143    public int getRowCount() {
144        return tags.size();
145    }
146
147    @Override
148    public Object getValueAt(int rowIndex, int columnIndex) {
149        if (rowIndex >= getRowCount())
150            throw new IndexOutOfBoundsException("unexpected rowIndex: rowIndex=" + rowIndex);
151
152        return tags.get(rowIndex);
153    }
154
155    @Override
156    public void setValueAt(Object value, int row, int col) {
157        TagModel tag = get(row);
158        if (tag != null) {
159            switch(col) {
160            case 0:
161                updateTagName(tag, (String) value);
162                break;
163            case 1:
164                String v = (String) value;
165                if ((tag.getValueCount() > 1 && !v.isEmpty()) || tag.getValueCount() <= 1) {
166                    updateTagValue(tag, v);
167                }
168                break;
169            default: // Do nothing
170            }
171        }
172    }
173
174    /**
175     * removes all tags in the model
176     */
177    public void clear() {
178        commitPendingEdit();
179        boolean wasEmpty = tags.isEmpty();
180        tags.clear();
181        if (!wasEmpty) {
182            setDirty(true);
183            fireTableDataChanged();
184        }
185    }
186
187    /**
188     * adds a tag to the model
189     *
190     * @param tag the tag. Must not be null.
191     *
192     * @throws IllegalArgumentException if tag is null
193     */
194    public void add(TagModel tag) {
195        commitPendingEdit();
196        CheckParameterUtil.ensureParameterNotNull(tag, "tag");
197        tags.add(tag);
198        setDirty(true);
199        fireTableDataChanged();
200    }
201
202    /**
203     * Add a tag at the beginning of the table.
204     *
205     * @param tag The tag to add
206     *
207     * @throws IllegalArgumentException if tag is null
208     *
209     * @see #add(TagModel)
210     */
211    public void prepend(TagModel tag) {
212        commitPendingEdit();
213        CheckParameterUtil.ensureParameterNotNull(tag, "tag");
214        tags.add(0, tag);
215        setDirty(true);
216        fireTableDataChanged();
217    }
218
219    /**
220     * adds a tag given by a name/value pair to the tag editor model.
221     *
222     * If there is no tag with name <code>name</code> yet, a new {@link TagModel} is created
223     * and append to this model.
224     *
225     * If there is a tag with name <code>name</code>, <code>value</code> is merged to the list
226     * of values for this tag.
227     *
228     * @param name the name; converted to "" if null
229     * @param value the value; converted to "" if null
230     */
231    public void add(String name, String value) {
232        commitPendingEdit();
233        String key = (name == null) ? "" : name;
234        String val = (value == null) ? "" : value;
235
236        TagModel tag = get(key);
237        if (tag == null) {
238            tag = new TagModel(key, val);
239            int index = tags.size();
240            while (index >= 1 && tags.get(index - 1).getName().isEmpty() && tags.get(index - 1).getValue().isEmpty()) {
241                index--; // If last line(s) is empty, add new tag before it
242            }
243            tags.add(index, tag);
244        } else {
245            tag.addValue(val);
246        }
247        setDirty(true);
248        fireTableDataChanged();
249    }
250
251    /**
252     * replies the tag with name <code>name</code>; null, if no such tag exists
253     * @param name the tag name
254     * @return the tag with name <code>name</code>; null, if no such tag exists
255     */
256    public TagModel get(String name) {
257        String key = (name == null) ? "" : name;
258        return tags.stream().filter(tag -> tag.getName().equals(key)).findFirst().orElse(null);
259    }
260
261    /**
262     * Gets a tag row
263     * @param idx The index of the row
264     * @return The tag model for that row
265     */
266    public TagModel get(int idx) {
267        return idx >= tags.size() ? null : tags.get(idx);
268    }
269
270    @Override
271    public boolean isCellEditable(int row, int col) {
272        // all cells are editable
273        return true;
274    }
275
276    /**
277     * deletes the names of the tags given by tagIndices
278     *
279     * @param tagIndices a list of tag indices
280     */
281    public void deleteTagNames(int... tagIndices) {
282        commitPendingEdit();
283        for (int tagIdx : tagIndices) {
284            TagModel tag = tags.get(tagIdx);
285            if (tag != null) {
286                tag.setName("");
287            }
288        }
289        fireTableDataChanged();
290        setDirty(true);
291    }
292
293    /**
294     * deletes the values of the tags given by tagIndices
295     *
296     * @param tagIndices the lit of tag indices
297     */
298    public void deleteTagValues(int... tagIndices) {
299        commitPendingEdit();
300        for (int tagIdx : tagIndices) {
301            TagModel tag = tags.get(tagIdx);
302            if (tag != null) {
303                tag.setValue("");
304            }
305        }
306        fireTableDataChanged();
307        setDirty(true);
308    }
309
310    /**
311     * Deletes all tags with name <code>name</code>
312     *
313     * @param name the name. Ignored if null.
314     */
315    public void delete(String name) {
316        commitPendingEdit();
317        if (name == null)
318            return;
319        boolean changed = tags.removeIf(tm -> tm.getName().equals(name));
320        if (changed) {
321            fireTableDataChanged();
322            setDirty(true);
323        }
324    }
325
326    /**
327     * deletes the tags given by tagIndices
328     *
329     * @param tagIndices the list of tag indices
330     */
331    public void deleteTags(int... tagIndices) {
332        commitPendingEdit();
333        List<TagModel> toDelete = Arrays.stream(tagIndices).mapToObj(tags::get).filter(Objects::nonNull).collect(Collectors.toList());
334        toDelete.forEach(tags::remove);
335        fireTableDataChanged();
336        setDirty(true);
337    }
338
339    /**
340     * creates a new tag and appends it to the model
341     */
342    public void appendNewTag() {
343        TagModel tag = new TagModel();
344        tags.add(tag);
345        fireTableDataChanged();
346    }
347
348    /**
349     * makes sure the model includes at least one (empty) tag
350     */
351    public void ensureOneTag() {
352        if (tags.isEmpty()) {
353            appendNewTag();
354        }
355    }
356
357    /**
358     * initializes the model with the tags of an OSM primitive
359     *
360     * @param primitive the OSM primitive
361     */
362    public void initFromPrimitive(Tagged primitive) {
363        commitPendingEdit();
364        this.tags.clear();
365        primitive.visitKeys((p, key, value) -> this.tags.add(new TagModel(key, value)));
366        sort();
367        TagModel tag = new TagModel();
368        tags.add(tag);
369        setDirty(false);
370        fireTableDataChanged();
371    }
372
373    /**
374     * Initializes the model with the tags of an OSM primitive
375     *
376     * @param tags the tags of an OSM primitive
377     */
378    public void initFromTags(Map<String, String> tags) {
379        commitPendingEdit();
380        this.tags.clear();
381        for (Entry<String, String> entry : tags.entrySet()) {
382            this.tags.add(new TagModel(entry.getKey(), entry.getValue()));
383        }
384        sort();
385        TagModel tag = new TagModel();
386        this.tags.add(tag);
387        setDirty(false);
388    }
389
390    /**
391     * Initializes the model with the tags in a tag collection. Removes
392     * all tags if {@code tags} is null.
393     *
394     * @param tags the tags
395     */
396    public void initFromTags(TagCollection tags) {
397        commitPendingEdit();
398        this.tags.clear();
399        if (tags == null) {
400            setDirty(false);
401            return;
402        }
403        for (String key : tags.getKeys()) {
404            String value = tags.getJoinedValues(key);
405            this.tags.add(new TagModel(key, value));
406        }
407        sort();
408        // add an empty row
409        TagModel tag = new TagModel();
410        this.tags.add(tag);
411        setDirty(false);
412    }
413
414    /**
415     * applies the current state of the tag editor model to a primitive
416     *
417     * @param primitive the primitive
418     *
419     */
420    public void applyToPrimitive(Tagged primitive) {
421        primitive.setKeys(applyToTags(false));
422    }
423
424    /**
425     * applies the current state of the tag editor model to a map of tags
426     * @param keepEmpty {@code true} to keep empty tags
427     *
428     * @return the map of key/value pairs
429     */
430    private Map<String, String> applyToTags(boolean keepEmpty) {
431        // TagMap preserves the order of tags.
432        TagMap result = new TagMap();
433        for (TagModel tag: this.tags) {
434            // tag still holds an unchanged list of different values for the same key.
435            // no property change command required
436            if (tag.getValueCount() > 1) {
437                continue;
438            }
439            boolean isKeyEmpty = Utils.isStripEmpty(tag.getName());
440            boolean isValueEmpty = Utils.isStripEmpty(tag.getValue());
441
442            // just the empty line at the bottom of the JTable
443            if (isKeyEmpty && isValueEmpty) {
444                continue;
445            }
446
447            // tag name holds an empty key. Don't apply it to the selection.
448            if (!keepEmpty && (isKeyEmpty || isValueEmpty)) {
449                continue;
450            }
451            result.put(Utils.strip(tag.getName()), Utils.strip(tag.getValue()));
452        }
453        return result;
454    }
455
456    /**
457     * Returns tags, without empty ones.
458     * @return not-empty tags
459     */
460    public Map<String, String> getTags() {
461        return getTags(false);
462    }
463
464    /**
465     * Returns tags.
466     * @param keepEmpty {@code true} to keep empty tags
467     * @return tags
468     */
469    public Map<String, String> getTags(boolean keepEmpty) {
470        return applyToTags(keepEmpty);
471    }
472
473    /**
474     * Replies the tags in this tag editor model as {@link TagCollection}.
475     *
476     * @return the tags in this tag editor model as {@link TagCollection}
477     */
478    public TagCollection getTagCollection() {
479        return TagCollection.from(getTags());
480    }
481
482    /**
483     * checks whether the tag model includes a tag with a given key
484     *
485     * @param key  the key
486     * @return true, if the tag model includes the tag; false, otherwise
487     */
488    public boolean includesTag(String key) {
489        return key != null && tags.stream().anyMatch(tag -> tag.getName().equals(key));
490    }
491
492    protected Command createUpdateTagCommand(Collection<OsmPrimitive> primitives, TagModel tag) {
493
494        // tag still holds an unchanged list of different values for the same key.
495        // no property change command required
496        if (tag.getValueCount() > 1)
497            return null;
498
499        // tag name holds an empty key. Don't apply it to the selection.
500        //
501        if (Utils.isStripEmpty(tag.getName()))
502            return null;
503
504        return new ChangePropertyCommand(primitives, tag.getName(), tag.getValue());
505    }
506
507    protected Command createDeleteTagsCommand(Collection<OsmPrimitive> primitives) {
508
509        List<String> currentkeys = getKeys();
510        List<Command> commands = new ArrayList<>();
511
512        for (OsmPrimitive prim : primitives) {
513            prim.visitKeys((p, oldkey, value) -> {
514                if (!currentkeys.contains(oldkey)) {
515                    commands.add(new ChangePropertyCommand(prim, oldkey, null));
516                }
517            });
518        }
519
520        return commands.isEmpty() ? null : new SequenceCommand(
521                trn("Remove old keys from up to {0} object", "Remove old keys from up to {0} objects", primitives.size(), primitives.size()),
522                commands
523        );
524    }
525
526    /**
527     * replies the list of keys of the tags managed by this model
528     *
529     * @return the list of keys managed by this model
530     */
531    public List<String> getKeys() {
532        return tags.stream()
533                .filter(tag -> !Utils.isStripEmpty(tag.getName()))
534                .map(TagModel::getName)
535                .collect(Collectors.toList());
536    }
537
538    /**
539     * sorts the current tags according alphabetical order of names
540     */
541    protected void sort() {
542        tags.sort(Comparator.comparing(TagModel::getName));
543    }
544
545    /**
546     * updates the name of a tag and sets the dirty state to  true if
547     * the new name is different from the old name.
548     *
549     * @param tag   the tag
550     * @param newName  the new name
551     */
552    public void updateTagName(TagModel tag, String newName) {
553        String oldName = tag.getName();
554        tag.setName(newName);
555        if (!newName.equals(oldName)) {
556            setDirty(true);
557        }
558        SelectionStateMemento memento = new SelectionStateMemento();
559        fireTableDataChanged();
560        memento.apply();
561    }
562
563    /**
564     * updates the value value of a tag and sets the dirty state to true if the
565     * new name is different from the old name
566     *
567     * @param tag  the tag
568     * @param newValue  the new value
569     */
570    public void updateTagValue(TagModel tag, String newValue) {
571        String oldValue = tag.getValue();
572        tag.setValue(newValue);
573        if (!newValue.equals(oldValue)) {
574            setDirty(true);
575        }
576        SelectionStateMemento memento = new SelectionStateMemento();
577        fireTableDataChanged();
578        memento.apply();
579    }
580
581    /**
582     * Load tags from given list
583     * @param tags - the list
584     */
585    public void updateTags(List<Tag> tags) {
586        if (tags.isEmpty())
587            return;
588
589        commitPendingEdit();
590        Map<String, TagModel> modelTags = IntStream.range(0, getRowCount())
591                .mapToObj(this::get)
592                .collect(Collectors.toMap(TagModel::getName, tagModel -> tagModel, (a, b) -> b));
593        for (Tag tag: tags) {
594            TagModel existing = modelTags.get(tag.getKey());
595
596            if (tag.getValue().isEmpty()) {
597                if (existing != null) {
598                    delete(tag.getKey());
599                }
600            } else {
601                if (existing != null) {
602                    updateTagValue(existing, tag.getValue());
603                } else {
604                    add(tag.getKey(), tag.getValue());
605                }
606            }
607        }
608    }
609
610    /**
611     * replies true, if this model has been updated
612     *
613     * @return true, if this model has been updated
614     */
615    public boolean isDirty() {
616        return dirty;
617    }
618
619    /**
620     * Returns the list of tagging presets types to consider when updating the presets list panel.
621     * By default returns type of associated primitive or empty set.
622     * @return the list of tagging presets types to consider when updating the presets list panel
623     * @see #forPrimitive
624     * @see TaggingPresetType#forPrimitive
625     * @since 9588
626     */
627    public Collection<TaggingPresetType> getTaggingPresetTypes() {
628        return primitive == null ? EnumSet.noneOf(TaggingPresetType.class) : EnumSet.of(TaggingPresetType.forPrimitive(primitive));
629    }
630
631    /**
632     * Makes this TagEditorModel specific to a given OSM primitive.
633     * @param primitive primitive to consider
634     * @return {@code this}
635     * @since 9588
636     */
637    public TagEditorModel forPrimitive(OsmPrimitive primitive) {
638        this.primitive = primitive;
639        return this;
640    }
641
642    /**
643     * Sets the listener that is notified when an edit should be aborted.
644     * @param endEditListener The listener to be notified when editing should be aborted.
645     */
646    public void setEndEditListener(EndEditListener endEditListener) {
647        this.endEditListener = endEditListener;
648    }
649
650    protected void commitPendingEdit() {
651        if (endEditListener != null) {
652            endEditListener.endCellEditing();
653        }
654    }
655
656    class SelectionStateMemento {
657        private final int rowMin;
658        private final int rowMax;
659        private final int colMin;
660        private final int colMax;
661
662        SelectionStateMemento() {
663            rowMin = rowSelectionModel.getMinSelectionIndex();
664            rowMax = rowSelectionModel.getMaxSelectionIndex();
665            colMin = colSelectionModel.getMinSelectionIndex();
666            colMax = colSelectionModel.getMaxSelectionIndex();
667        }
668
669        void apply() {
670            rowSelectionModel.setValueIsAdjusting(true);
671            colSelectionModel.setValueIsAdjusting(true);
672            if (rowMin >= 0 && rowMax >= 0) {
673                rowSelectionModel.setSelectionInterval(rowMin, rowMax);
674            }
675            if (colMin >= 0 && colMax >= 0) {
676                colSelectionModel.setSelectionInterval(colMin, colMax);
677            }
678            rowSelectionModel.setValueIsAdjusting(false);
679            colSelectionModel.setValueIsAdjusting(false);
680        }
681    }
682
683    /**
684     * A listener that is called whenever the cells may be updated from outside the editor and the editor should thus be committed.
685     * @since 10604
686     */
687    @FunctionalInterface
688    public interface EndEditListener {
689        /**
690         * Requests to end the editing of any cells on this model
691         */
692        void endCellEditing();
693    }
694}