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