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