001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.command;
003
004import static org.openstreetmap.josm.tools.I18n.marktr;
005import static org.openstreetmap.josm.tools.I18n.tr;
006import static org.openstreetmap.josm.tools.I18n.trn;
007
008import java.util.Collection;
009import java.util.Collections;
010import java.util.HashMap;
011import java.util.LinkedList;
012import java.util.List;
013import java.util.Map;
014import java.util.Map.Entry;
015import java.util.NoSuchElementException;
016import java.util.Objects;
017import java.util.stream.Collectors;
018
019import javax.swing.Icon;
020
021import org.openstreetmap.josm.data.osm.DataSet;
022import org.openstreetmap.josm.data.osm.DefaultNameFormatter;
023import org.openstreetmap.josm.data.osm.OsmPrimitive;
024import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
025import org.openstreetmap.josm.data.osm.Tagged;
026import org.openstreetmap.josm.tools.I18n;
027import org.openstreetmap.josm.tools.ImageProvider;
028import org.openstreetmap.josm.tools.Utils;
029
030/**
031 * Command that manipulate the key/value structure of several objects. Manages deletion,
032 * adding and modify of values and keys.
033 *
034 * @author imi
035 * @since 24
036 */
037public class ChangePropertyCommand extends Command {
038
039    static final class OsmPseudoCommand implements PseudoCommand {
040        private final OsmPrimitive osm;
041
042        OsmPseudoCommand(OsmPrimitive osm) {
043            this.osm = osm;
044        }
045
046        @Override
047        public String getDescriptionText() {
048            return osm.getDisplayName(DefaultNameFormatter.getInstance());
049        }
050
051        @Override
052        public Icon getDescriptionIcon() {
053            return ImageProvider.get(osm.getDisplayType());
054        }
055
056        @Override
057        public Collection<? extends OsmPrimitive> getParticipatingPrimitives() {
058            return Collections.singleton(osm);
059        }
060    }
061
062    /**
063     * All primitives that are affected with this command.
064     */
065    private final List<OsmPrimitive> objects = new LinkedList<>();
066
067    /**
068     * Key and value pairs. If value is <code>null</code>, delete all key references with the given
069     * key. Otherwise, change the tags of all objects to the given value or create keys of
070     * those objects that do not have the key yet.
071     */
072    private final Map<String, String> tags;
073
074    /**
075     * Creates a command to change multiple tags of multiple objects
076     *
077     * @param ds The target data set. Must not be {@code null}
078     * @param objects the objects to modify. Must not be empty
079     * @param tags the tags to set. Caller must make sure that the tas are not changed once the command was executed.
080     * @since 12726
081     */
082    public ChangePropertyCommand(DataSet ds, Collection<? extends OsmPrimitive> objects, Map<String, String> tags) {
083        super(ds);
084        this.tags = tags;
085        init(objects);
086    }
087
088    /**
089     * Creates a command to change multiple tags of multiple objects
090     *
091     * @param objects the objects to modify. Must not be empty, and objects must belong to a data set
092     * @param tags the tags to set. Caller must make sure that the tas are not changed once the command was executed.
093     * @throws NullPointerException if objects is null or contain null item
094     * @throws NoSuchElementException if objects is empty
095     */
096    public ChangePropertyCommand(Collection<? extends OsmPrimitive> objects, Map<String, String> tags) {
097        this(objects.iterator().next().getDataSet(), objects, tags);
098    }
099
100    /**
101     * Creates a command to change one tag of multiple objects
102     *
103     * @param objects the objects to modify. Must not be empty, and objects must belong to a data set
104     * @param key the key of the tag to set
105     * @param value the value of the key to set
106     * @throws NullPointerException if objects is null or contain null item
107     * @throws NoSuchElementException if objects is empty
108     */
109    public ChangePropertyCommand(Collection<? extends OsmPrimitive> objects, String key, String value) {
110        super(objects.iterator().next().getDataSet());
111        this.tags = Collections.singletonMap(key, value);
112        init(objects);
113    }
114
115    /**
116     * Creates a command to change one tag of one object
117     *
118     * @param object the object to modify. Must belong to a data set
119     * @param key the key of the tag to set
120     * @param value the value of the key to set
121     * @throws NullPointerException if object is null
122     */
123    public ChangePropertyCommand(OsmPrimitive object, String key, String value) {
124        this(Collections.singleton(object), key, value);
125    }
126
127    /**
128     * Initialize the instance by finding what objects will be modified
129     *
130     * @param objects the objects to (possibly) modify
131     */
132    private void init(Collection<? extends OsmPrimitive> objects) {
133        // determine what objects will be modified
134        for (OsmPrimitive osm : objects) {
135            boolean modified = false;
136
137            // loop over all tags
138            for (Map.Entry<String, String> tag : this.tags.entrySet()) {
139                String oldVal = osm.get(tag.getKey());
140                String newVal = tag.getValue();
141
142                if (Utils.isEmpty(newVal)) {
143                    if (oldVal != null) {
144                        // new value is null and tag exists (will delete tag)
145                        modified = true;
146                        break;
147                    }
148                } else if (oldVal == null || !newVal.equals(oldVal)) {
149                    // new value is not null and is different from current value
150                    modified = true;
151                    break;
152                }
153            }
154            if (modified)
155                this.objects.add(osm);
156        }
157    }
158
159    @Override
160    public boolean executeCommand() {
161        if (objects.isEmpty())
162            return true;
163        final DataSet dataSet = objects.get(0).getDataSet();
164        if (dataSet != null) {
165            dataSet.beginUpdate();
166        }
167        try {
168            super.executeCommand(); // save old
169
170            for (OsmPrimitive osm : objects) {
171                // loop over all tags
172                for (Map.Entry<String, String> tag : this.tags.entrySet()) {
173                    String oldVal = osm.get(tag.getKey());
174                    String newVal = tag.getValue();
175
176                    if (Utils.isEmpty(newVal)) {
177                        if (oldVal != null)
178                            osm.remove(tag.getKey());
179                    } else if (oldVal == null || !newVal.equals(oldVal))
180                        osm.put(tag.getKey(), newVal);
181                }
182                // init() only keeps modified primitives. Therefore the modified
183                // bit can be set without further checks.
184                osm.setModified(true);
185            }
186            return true;
187        } finally {
188            if (dataSet != null) {
189                dataSet.endUpdate();
190            }
191        }
192    }
193
194    @Override
195    public void fillModifiedData(Collection<OsmPrimitive> modified, Collection<OsmPrimitive> deleted, Collection<OsmPrimitive> added) {
196        modified.addAll(objects);
197    }
198
199    @Override
200    public String getDescriptionText() {
201        @I18n.QuirkyPluralString
202        final String text;
203        if (objects.size() == 1 && tags.size() == 1) {
204            OsmPrimitive primitive = objects.get(0);
205            String msg;
206            Map.Entry<String, String> entry = tags.entrySet().iterator().next();
207            if (Utils.isEmpty(entry.getValue())) {
208                switch(OsmPrimitiveType.from(primitive)) {
209                case NODE: msg = marktr("Remove \"{0}\" for node ''{1}''"); break;
210                case WAY: msg = marktr("Remove \"{0}\" for way ''{1}''"); break;
211                case RELATION: msg = marktr("Remove \"{0}\" for relation ''{1}''"); break;
212                default: throw new AssertionError();
213                }
214                text = tr(msg, entry.getKey(), primitive.getDisplayName(DefaultNameFormatter.getInstance()));
215            } else {
216                switch(OsmPrimitiveType.from(primitive)) {
217                case NODE: msg = marktr("Set {0}={1} for node ''{2}''"); break;
218                case WAY: msg = marktr("Set {0}={1} for way ''{2}''"); break;
219                case RELATION: msg = marktr("Set {0}={1} for relation ''{2}''"); break;
220                default: throw new AssertionError();
221                }
222                text = tr(msg, entry.getKey(), entry.getValue(), primitive.getDisplayName(DefaultNameFormatter.getInstance()));
223            }
224        } else if (objects.size() > 1 && tags.size() == 1) {
225            Map.Entry<String, String> entry = tags.entrySet().iterator().next();
226            if (Utils.isEmpty(entry.getValue())) {
227                /* I18n: plural form for objects, but value < 2 not possible! */
228                text = trn("Remove \"{0}\" for {1} object", "Remove \"{0}\" for {1} objects", objects.size(), entry.getKey(), objects.size());
229            } else {
230                /* I18n: plural form for objects, but value < 2 not possible! */
231                text = trn("Set {0}={1} for {2} object", "Set {0}={1} for {2} objects",
232                        objects.size(), entry.getKey(), entry.getValue(), objects.size());
233            }
234        } else {
235            boolean allNull = this.tags.entrySet().stream()
236                    .allMatch(tag -> Utils.isEmpty(tag.getValue()));
237
238            if (allNull) {
239                /* I18n: plural form detected for objects only (but value < 2 not possible!), try to do your best for tags */
240                text = trn("Deleted {0} tags for {1} object", "Deleted {0} tags for {1} objects", objects.size(), tags.size(), objects.size());
241            } else {
242                /* I18n: plural form detected for objects only (but value < 2 not possible!), try to do your best for tags */
243                text = trn("Set {0} tags for {1} object", "Set {0} tags for {1} objects", objects.size(), tags.size(), objects.size());
244            }
245        }
246        return text;
247    }
248
249    @Override
250    public Icon getDescriptionIcon() {
251        return ImageProvider.get("dialogs", "propertiesdialog", ImageProvider.ImageSizes.SMALLICON);
252    }
253
254    @Override
255    public Collection<PseudoCommand> getChildren() {
256        if (objects.size() == 1)
257            return null;
258        return objects.stream().map(OsmPseudoCommand::new).collect(Collectors.toList());
259    }
260
261    /**
262     * Returns the number of objects that will effectively be modified, before the command is executed.
263     * @return the number of objects that will effectively be modified (can be 0)
264     * @see Command#getParticipatingPrimitives()
265     * @since 8945
266     */
267    public final int getObjectsNumber() {
268        return objects.size();
269    }
270
271    /**
272     * Returns the tags to set (key/value pairs).
273     * @return the tags to set (key/value pairs)
274     */
275    public Map<String, String> getTags() {
276        return Collections.unmodifiableMap(tags);
277    }
278
279    @Override
280    public int hashCode() {
281        return Objects.hash(super.hashCode(), objects, tags);
282    }
283
284    @Override
285    public boolean equals(Object obj) {
286        if (this == obj) return true;
287        if (obj == null || getClass() != obj.getClass()) return false;
288        if (!super.equals(obj)) return false;
289        ChangePropertyCommand that = (ChangePropertyCommand) obj;
290        return Objects.equals(objects, that.objects) &&
291                Objects.equals(tags, that.tags);
292    }
293
294    /**
295     * Calculate the {@link ChangePropertyCommand} that is needed to change the tags in source to be equal to those in target.
296     * @param source the source primitive
297     * @param target the target primitive
298     * @return null if no changes are needed, else a {@link ChangePropertyCommand}
299     * @since 17357
300     */
301    public static Command build(OsmPrimitive source, Tagged target) {
302        Map<String, String> changedTags = new HashMap<>();
303        // find tags which have to be changed or removed
304        for (Entry<String, String> tag : source.getKeys().entrySet()) {
305            String key = tag.getKey();
306            String val = target.get(key);
307            if (!tag.getValue().equals(val))
308                changedTags.put(key, val); // null or a different value
309        }
310        // find tags which exist only in target, they have to be added
311        for (Entry<String, String> tag : target.getKeys().entrySet()) {
312            String key = tag.getKey();
313            if (!source.hasTag(key))
314                changedTags.put(key, tag.getValue());
315        }
316        if (changedTags.isEmpty())
317            return null;
318        if (changedTags.size() == 1) {
319            Entry<String, String> tag = changedTags.entrySet().iterator().next();
320            return new ChangePropertyCommand(Collections.singleton(source), tag.getKey(), tag.getValue());
321        }
322        return new ChangePropertyCommand(Collections.singleton(source), new HashMap<>(changedTags));
323    }
324}