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