// License: GPL. For details, see LICENSE file. package org.openstreetmap.josm.gui.tagging; import static org.openstreetmap.josm.tools.I18n.trn; import java.beans.PropertyChangeListener; import java.beans.PropertyChangeSupport; import java.util.ArrayList; import java.util.Collection; import java.util.Comparator; import java.util.EnumSet; import java.util.HashMap; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Map.Entry; import javax.swing.DefaultListSelectionModel; import javax.swing.table.AbstractTableModel; import org.openstreetmap.josm.command.ChangePropertyCommand; import org.openstreetmap.josm.command.Command; import org.openstreetmap.josm.command.SequenceCommand; import org.openstreetmap.josm.data.osm.OsmPrimitive; import org.openstreetmap.josm.data.osm.Tag; import org.openstreetmap.josm.data.osm.TagCollection; import org.openstreetmap.josm.data.osm.TagMap; import org.openstreetmap.josm.data.osm.Tagged; import org.openstreetmap.josm.gui.tagging.presets.TaggingPresetType; import org.openstreetmap.josm.tools.CheckParameterUtil; /** * TagEditorModel is a table model to use with {@link TagEditorPanel}. * @since 1762 */ public class TagEditorModel extends AbstractTableModel { public static final String PROP_DIRTY = TagEditorModel.class.getName() + ".dirty"; /** the list holding the tags */ protected final transient List<TagModel> tags = new ArrayList<>(); /** indicates whether the model is dirty */ private boolean dirty; private final PropertyChangeSupport propChangeSupport = new PropertyChangeSupport(this); private final DefaultListSelectionModel rowSelectionModel; private final DefaultListSelectionModel colSelectionModel; private transient OsmPrimitive primitive; private EndEditListener endEditListener; /** * Creates a new tag editor model. Internally allocates two selection models * for row selection and column selection. * * To create a {@link javax.swing.JTable} with this model: * <pre> * TagEditorModel model = new TagEditorModel(); * TagTable tbl = new TagTabel(model); * </pre> * * @see #getRowSelectionModel() * @see #getColumnSelectionModel() */ public TagEditorModel() { this(new DefaultListSelectionModel(), new DefaultListSelectionModel()); } /** * Creates a new tag editor model. * * @param rowSelectionModel the row selection model. Must not be null. * @param colSelectionModel the column selection model. Must not be null. * @throws IllegalArgumentException if {@code rowSelectionModel} is null * @throws IllegalArgumentException if {@code colSelectionModel} is null */ public TagEditorModel(DefaultListSelectionModel rowSelectionModel, DefaultListSelectionModel colSelectionModel) { CheckParameterUtil.ensureParameterNotNull(rowSelectionModel, "rowSelectionModel"); CheckParameterUtil.ensureParameterNotNull(colSelectionModel, "colSelectionModel"); this.rowSelectionModel = rowSelectionModel; this.colSelectionModel = colSelectionModel; } /** * Adds property change listener. * @param listener property change listener to add */ public void addPropertyChangeListener(PropertyChangeListener listener) { propChangeSupport.addPropertyChangeListener(listener); } /** * Replies the row selection model used by this tag editor model * * @return the row selection model used by this tag editor model */ public DefaultListSelectionModel getRowSelectionModel() { return rowSelectionModel; } /** * Replies the column selection model used by this tag editor model * * @return the column selection model used by this tag editor model */ public DefaultListSelectionModel getColumnSelectionModel() { return colSelectionModel; } /** * Removes property change listener. * @param listener property change listener to remove */ public void removePropertyChangeListener(PropertyChangeListener listener) { propChangeSupport.removePropertyChangeListener(listener); } protected void fireDirtyStateChanged(final boolean oldValue, final boolean newValue) { propChangeSupport.firePropertyChange(PROP_DIRTY, oldValue, newValue); } protected void setDirty(boolean newValue) { boolean oldValue = dirty; dirty = newValue; if (oldValue != newValue) { fireDirtyStateChanged(oldValue, newValue); } } @Override public int getColumnCount() { return 2; } @Override public int getRowCount() { return tags.size(); } @Override public Object getValueAt(int rowIndex, int columnIndex) { if (rowIndex >= getRowCount()) throw new IndexOutOfBoundsException("unexpected rowIndex: rowIndex=" + rowIndex); return tags.get(rowIndex); } @Override public void setValueAt(Object value, int row, int col) { TagModel tag = get(row); if (tag != null) { switch(col) { case 0: updateTagName(tag, (String) value); break; case 1: String v = (String) value; if ((tag.getValueCount() > 1 && !v.isEmpty()) || tag.getValueCount() <= 1) { updateTagValue(tag, v); } break; default: // Do nothing } } } /** * removes all tags in the model */ public void clear() { commitPendingEdit(); boolean wasEmpty = tags.isEmpty(); tags.clear(); if (!wasEmpty) { setDirty(true); fireTableDataChanged(); } } /** * adds a tag to the model * * @param tag the tag. Must not be null. * * @throws IllegalArgumentException if tag is null */ public void add(TagModel tag) { commitPendingEdit(); CheckParameterUtil.ensureParameterNotNull(tag, "tag"); tags.add(tag); setDirty(true); fireTableDataChanged(); } /** * Add a tag at the beginning of the table. * * @param tag The tag to add * * @throws IllegalArgumentException if tag is null * * @see #add(TagModel) */ public void prepend(TagModel tag) { commitPendingEdit(); CheckParameterUtil.ensureParameterNotNull(tag, "tag"); tags.add(0, tag); setDirty(true); fireTableDataChanged(); } /** * adds a tag given by a name/value pair to the tag editor model. * * If there is no tag with name <code>name</code> yet, a new {@link TagModel} is created * and append to this model. * * If there is a tag with name <code>name</code>, <code>value</code> is merged to the list * of values for this tag. * * @param name the name; converted to "" if null * @param value the value; converted to "" if null */ public void add(String name, String value) { commitPendingEdit(); String key = (name == null) ? "" : name; String val = (value == null) ? "" : value; TagModel tag = get(key); if (tag == null) { tag = new TagModel(key, val); int index = tags.size(); while (index >= 1 && tags.get(index - 1).getName().isEmpty() && tags.get(index - 1).getValue().isEmpty()) { index--; // If last line(s) is empty, add new tag before it } tags.add(index, tag); } else { tag.addValue(val); } setDirty(true); fireTableDataChanged(); } /** * replies the tag with name <code>name</code>; null, if no such tag exists * @param name the tag name * @return the tag with name <code>name</code>; null, if no such tag exists */ public TagModel get(String name) { String key = (name == null) ? "" : name; for (TagModel tag : tags) { if (tag.getName().equals(key)) return tag; } return null; } public TagModel get(int idx) { return idx >= tags.size() ? null : tags.get(idx); } @Override public boolean isCellEditable(int row, int col) { // all cells are editable return true; } /** * deletes the names of the tags given by tagIndices * * @param tagIndices a list of tag indices */ public void deleteTagNames(int... tagIndices) { if (tags == null) return; commitPendingEdit(); for (int tagIdx : tagIndices) { TagModel tag = tags.get(tagIdx); if (tag != null) { tag.setName(""); } } fireTableDataChanged(); setDirty(true); } /** * deletes the values of the tags given by tagIndices * * @param tagIndices the lit of tag indices */ public void deleteTagValues(int... tagIndices) { if (tags == null) return; commitPendingEdit(); for (int tagIdx : tagIndices) { TagModel tag = tags.get(tagIdx); if (tag != null) { tag.setValue(""); } } fireTableDataChanged(); setDirty(true); } /** * Deletes all tags with name <code>name</code> * * @param name the name. Ignored if null. */ public void delete(String name) { commitPendingEdit(); if (name == null) return; Iterator<TagModel> it = tags.iterator(); boolean changed = false; while (it.hasNext()) { TagModel tm = it.next(); if (tm.getName().equals(name)) { changed = true; it.remove(); } } if (changed) { fireTableDataChanged(); setDirty(true); } } /** * deletes the tags given by tagIndices * * @param tagIndices the list of tag indices */ public void deleteTags(int... tagIndices) { if (tags == null) return; commitPendingEdit(); List<TagModel> toDelete = new ArrayList<>(); for (int tagIdx : tagIndices) { TagModel tag = tags.get(tagIdx); if (tag != null) { toDelete.add(tag); } } for (TagModel tag : toDelete) { tags.remove(tag); } fireTableDataChanged(); setDirty(true); } /** * creates a new tag and appends it to the model */ public void appendNewTag() { TagModel tag = new TagModel(); tags.add(tag); fireTableDataChanged(); } /** * makes sure the model includes at least one (empty) tag */ public void ensureOneTag() { if (tags.isEmpty()) { appendNewTag(); } } /** * initializes the model with the tags of an OSM primitive * * @param primitive the OSM primitive */ public void initFromPrimitive(Tagged primitive) { commitPendingEdit(); this.tags.clear(); for (String key : primitive.keySet()) { String value = primitive.get(key); this.tags.add(new TagModel(key, value)); } sort(); TagModel tag = new TagModel(); tags.add(tag); setDirty(false); fireTableDataChanged(); } /** * Initializes the model with the tags of an OSM primitive * * @param tags the tags of an OSM primitive */ public void initFromTags(Map<String, String> tags) { commitPendingEdit(); this.tags.clear(); for (Entry<String, String> entry : tags.entrySet()) { this.tags.add(new TagModel(entry.getKey(), entry.getValue())); } sort(); TagModel tag = new TagModel(); this.tags.add(tag); setDirty(false); } /** * Initializes the model with the tags in a tag collection. Removes * all tags if {@code tags} is null. * * @param tags the tags */ public void initFromTags(TagCollection tags) { commitPendingEdit(); this.tags.clear(); if (tags == null) { setDirty(false); return; } for (String key : tags.getKeys()) { String value = tags.getJoinedValues(key); this.tags.add(new TagModel(key, value)); } sort(); // add an empty row TagModel tag = new TagModel(); this.tags.add(tag); setDirty(false); } /** * applies the current state of the tag editor model to a primitive * * @param primitive the primitive * */ public void applyToPrimitive(Tagged primitive) { primitive.setKeys(applyToTags(false)); } /** * applies the current state of the tag editor model to a map of tags * @param keepEmpty {@code true} to keep empty tags * * @return the map of key/value pairs */ private Map<String, String> applyToTags(boolean keepEmpty) { // TagMap preserves the order of tags. TagMap result = new TagMap(); for (TagModel tag: this.tags) { // tag still holds an unchanged list of different values for the same key. // no property change command required if (tag.getValueCount() > 1) { continue; } // tag name holds an empty key. Don't apply it to the selection. if (!keepEmpty && (tag.getName().trim().isEmpty() || tag.getValue().trim().isEmpty())) { continue; } result.put(tag.getName().trim(), tag.getValue().trim()); } return result; } /** * Returns tags, without empty ones. * @return not-empty tags */ public Map<String, String> getTags() { return getTags(false); } /** * Returns tags. * @param keepEmpty {@code true} to keep empty tags * @return tags */ public Map<String, String> getTags(boolean keepEmpty) { return applyToTags(keepEmpty); } /** * Replies the tags in this tag editor model as {@link TagCollection}. * * @return the tags in this tag editor model as {@link TagCollection} */ public TagCollection getTagCollection() { return TagCollection.from(getTags()); } /** * checks whether the tag model includes a tag with a given key * * @param key the key * @return true, if the tag model includes the tag; false, otherwise */ public boolean includesTag(String key) { if (key != null) { for (TagModel tag : tags) { if (tag.getName().equals(key)) return true; } } return false; } protected Command createUpdateTagCommand(Collection<OsmPrimitive> primitives, TagModel tag) { // tag still holds an unchanged list of different values for the same key. // no property change command required if (tag.getValueCount() > 1) return null; // tag name holds an empty key. Don't apply it to the selection. // if (tag.getName().trim().isEmpty()) return null; return new ChangePropertyCommand(primitives, tag.getName(), tag.getValue()); } protected Command createDeleteTagsCommand(Collection<OsmPrimitive> primitives) { List<String> currentkeys = getKeys(); List<Command> commands = new ArrayList<>(); for (OsmPrimitive prim : primitives) { for (String oldkey : prim.keySet()) { if (!currentkeys.contains(oldkey)) { ChangePropertyCommand deleteCommand = new ChangePropertyCommand(prim, oldkey, null); commands.add(deleteCommand); } } } return new SequenceCommand( trn("Remove old keys from up to {0} object", "Remove old keys from up to {0} objects", primitives.size(), primitives.size()), commands ); } /** * replies the list of keys of the tags managed by this model * * @return the list of keys managed by this model */ public List<String> getKeys() { List<String> keys = new ArrayList<>(); for (TagModel tag: tags) { if (!tag.getName().trim().isEmpty()) { keys.add(tag.getName()); } } return keys; } /** * sorts the current tags according alphabetical order of names */ protected void sort() { tags.sort(Comparator.comparing(TagModel::getName)); } /** * updates the name of a tag and sets the dirty state to true if * the new name is different from the old name. * * @param tag the tag * @param newName the new name */ public void updateTagName(TagModel tag, String newName) { String oldName = tag.getName(); tag.setName(newName); if (!newName.equals(oldName)) { setDirty(true); } SelectionStateMemento memento = new SelectionStateMemento(); fireTableDataChanged(); memento.apply(); } /** * updates the value value of a tag and sets the dirty state to true if the * new name is different from the old name * * @param tag the tag * @param newValue the new value */ public void updateTagValue(TagModel tag, String newValue) { String oldValue = tag.getValue(); tag.setValue(newValue); if (!newValue.equals(oldValue)) { setDirty(true); } SelectionStateMemento memento = new SelectionStateMemento(); fireTableDataChanged(); memento.apply(); } /** * Load tags from given list * @param tags - the list */ public void updateTags(List<Tag> tags) { if (tags.isEmpty()) return; commitPendingEdit(); Map<String, TagModel> modelTags = new HashMap<>(); for (int i = 0; i < getRowCount(); i++) { TagModel tagModel = get(i); modelTags.put(tagModel.getName(), tagModel); } for (Tag tag: tags) { TagModel existing = modelTags.get(tag.getKey()); if (tag.getValue().isEmpty()) { if (existing != null) { delete(tag.getKey()); } } else { if (existing != null) { updateTagValue(existing, tag.getValue()); } else { add(tag.getKey(), tag.getValue()); } } } } /** * replies true, if this model has been updated * * @return true, if this model has been updated */ public boolean isDirty() { return dirty; } /** * Returns the list of tagging presets types to consider when updating the presets list panel. * By default returns type of associated primitive or empty set. * @return the list of tagging presets types to consider when updating the presets list panel * @see #forPrimitive * @see TaggingPresetType#forPrimitive * @since 9588 */ public Collection<TaggingPresetType> getTaggingPresetTypes() { return primitive == null ? EnumSet.noneOf(TaggingPresetType.class) : EnumSet.of(TaggingPresetType.forPrimitive(primitive)); } /** * Makes this TagEditorModel specific to a given OSM primitive. * @param primitive primitive to consider * @return {@code this} * @since 9588 */ public TagEditorModel forPrimitive(OsmPrimitive primitive) { this.primitive = primitive; return this; } /** * Sets the listener that is notified when an edit should be aborted. * @param endEditListener The listener to be notified when editing should be aborted. */ public void setEndEditListener(EndEditListener endEditListener) { this.endEditListener = endEditListener; } private void commitPendingEdit() { if (endEditListener != null) { endEditListener.endCellEditing(); } } class SelectionStateMemento { private final int rowMin; private final int rowMax; private final int colMin; private final int colMax; SelectionStateMemento() { rowMin = rowSelectionModel.getMinSelectionIndex(); rowMax = rowSelectionModel.getMaxSelectionIndex(); colMin = colSelectionModel.getMinSelectionIndex(); colMax = colSelectionModel.getMaxSelectionIndex(); } void apply() { rowSelectionModel.setValueIsAdjusting(true); colSelectionModel.setValueIsAdjusting(true); if (rowMin >= 0 && rowMax >= 0) { rowSelectionModel.setSelectionInterval(rowMin, rowMax); } if (colMin >= 0 && colMax >= 0) { colSelectionModel.setSelectionInterval(colMin, colMax); } rowSelectionModel.setValueIsAdjusting(false); colSelectionModel.setValueIsAdjusting(false); } } /** * A listener that is called whenever the cells may be updated from outside the editor and the editor should thus be commited. * @since 10604 */ @FunctionalInterface public interface EndEditListener { /** * Requests to end the editing of any cells on this model */ void endCellEditing(); } }