001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.actions;
003
004import static org.openstreetmap.josm.gui.help.HelpUtil.ht;
005import static org.openstreetmap.josm.tools.I18n.tr;
006import static org.openstreetmap.josm.tools.I18n.trn;
007
008import java.awt.event.ActionEvent;
009import java.awt.event.KeyEvent;
010import java.util.ArrayList;
011import java.util.Collection;
012import java.util.Collections;
013import java.util.HashMap;
014import java.util.HashSet;
015import java.util.LinkedList;
016import java.util.List;
017import java.util.Map;
018import java.util.Set;
019
020import javax.swing.JOptionPane;
021import javax.swing.JPanel;
022
023import org.openstreetmap.josm.command.AddCommand;
024import org.openstreetmap.josm.command.ChangeCommand;
025import org.openstreetmap.josm.command.ChangeNodesCommand;
026import org.openstreetmap.josm.command.Command;
027import org.openstreetmap.josm.command.MoveCommand;
028import org.openstreetmap.josm.command.SequenceCommand;
029import org.openstreetmap.josm.data.UndoRedoHandler;
030import org.openstreetmap.josm.data.coor.LatLon;
031import org.openstreetmap.josm.data.osm.DefaultNameFormatter;
032import org.openstreetmap.josm.data.osm.Node;
033import org.openstreetmap.josm.data.osm.OsmPrimitive;
034import org.openstreetmap.josm.data.osm.Relation;
035import org.openstreetmap.josm.data.osm.RelationMember;
036import org.openstreetmap.josm.data.osm.Way;
037import org.openstreetmap.josm.gui.MainApplication;
038import org.openstreetmap.josm.gui.MapView;
039import org.openstreetmap.josm.gui.Notification;
040import org.openstreetmap.josm.gui.dialogs.PropertiesMembershipChoiceDialog;
041import org.openstreetmap.josm.gui.dialogs.PropertiesMembershipChoiceDialog.ExistingBothNew;
042import org.openstreetmap.josm.tools.Logging;
043import org.openstreetmap.josm.tools.Shortcut;
044import org.openstreetmap.josm.tools.UserCancelException;
045import org.openstreetmap.josm.tools.Utils;
046
047/**
048 * Duplicate nodes that are used by multiple ways.
049 *
050 * Resulting nodes are identical, up to their position.
051 *
052 * This is the opposite of the MergeNodesAction.
053 *
054 * If a single node is selected, it will copy that node and remove all tags from the old one
055 */
056public class UnGlueAction extends JosmAction {
057
058    private transient Node selectedNode;
059    private transient Way selectedWay;
060    private transient Set<Node> selectedNodes;
061
062    /**
063     * Create a new UnGlueAction.
064     */
065    public UnGlueAction() {
066        super(tr("UnGlue Ways"), "unglueways", tr("Duplicate nodes that are used by multiple ways."),
067                Shortcut.registerShortcut("tools:unglue", tr("Tool: {0}", tr("UnGlue Ways")), KeyEvent.VK_G, Shortcut.DIRECT), true);
068        setHelpId(ht("/Action/UnGlue"));
069    }
070
071    /**
072     * Called when the action is executed.
073     *
074     * This method does some checking on the selection and calls the matching unGlueWay method.
075     */
076    @Override
077    public void actionPerformed(ActionEvent e) {
078        try {
079            unglue(e);
080        } catch (UserCancelException ignore) {
081            Logging.trace(ignore);
082        } finally {
083            cleanup();
084        }
085    }
086
087    protected void unglue(ActionEvent e) throws UserCancelException {
088
089        Collection<OsmPrimitive> selection = getLayerManager().getEditDataSet().getSelected();
090
091        String errMsg = null;
092        int errorTime = Notification.TIME_DEFAULT;
093        if (checkSelectionOneNodeAtMostOneWay(selection)) {
094            checkAndConfirmOutlyingUnglue();
095            int count = 0;
096            for (Way w : selectedNode.getParentWays()) {
097                if (!w.isUsable() || w.getNodesCount() < 1) {
098                    continue;
099                }
100                count++;
101            }
102            if (count < 2) {
103                boolean selfCrossing = false;
104                if (count == 1) {
105                    // First try unglue self-crossing way
106                    selfCrossing = unglueSelfCrossingWay();
107                }
108                // If there aren't enough ways, maybe the user wanted to unglue the nodes
109                // (= copy tags to a new node)
110                if (!selfCrossing)
111                    if (checkForUnglueNode(selection)) {
112                        unglueOneNodeAtMostOneWay(e);
113                    } else {
114                        errorTime = Notification.TIME_SHORT;
115                        errMsg = tr("This node is not glued to anything else.");
116                    }
117            } else {
118                // and then do the work.
119                unglueWays();
120            }
121        } else if (checkSelectionOneWayAnyNodes(selection)) {
122            checkAndConfirmOutlyingUnglue();
123            Set<Node> tmpNodes = new HashSet<>();
124            for (Node n : selectedNodes) {
125                int count = 0;
126                for (Way w : n.getParentWays()) {
127                    if (!w.isUsable()) {
128                        continue;
129                    }
130                    count++;
131                }
132                if (count >= 2) {
133                    tmpNodes.add(n);
134                }
135            }
136            if (tmpNodes.isEmpty()) {
137                if (selection.size() > 1) {
138                    errMsg = tr("None of these nodes are glued to anything else.");
139                } else {
140                    errMsg = tr("None of this way''s nodes are glued to anything else.");
141                }
142            } else {
143                // and then do the work.
144                selectedNodes = tmpNodes;
145                unglueOneWayAnyNodes();
146            }
147        } else {
148            errorTime = Notification.TIME_VERY_LONG;
149            errMsg =
150                tr("The current selection cannot be used for unglueing.")+'\n'+
151                '\n'+
152                tr("Select either:")+'\n'+
153                tr("* One tagged node, or")+'\n'+
154                tr("* One node that is used by more than one way, or")+'\n'+
155                tr("* One node that is used by more than one way and one of those ways, or")+'\n'+
156                tr("* One way that has one or more nodes that are used by more than one way, or")+'\n'+
157                tr("* One way and one or more of its nodes that are used by more than one way.")+'\n'+
158                '\n'+
159                tr("Note: If a way is selected, this way will get fresh copies of the unglued\n"+
160                        "nodes and the new nodes will be selected. Otherwise, all ways will get their\n"+
161                "own copy and all nodes will be selected.");
162        }
163
164        if (errMsg != null) {
165            new Notification(
166                    errMsg)
167                    .setIcon(JOptionPane.ERROR_MESSAGE)
168                    .setDuration(errorTime)
169                    .show();
170        }
171    }
172
173    private void cleanup() {
174        selectedNode = null;
175        selectedWay = null;
176        selectedNodes = null;
177    }
178
179    static void update(PropertiesMembershipChoiceDialog dialog, Node existingNode, List<Node> newNodes, Collection<Command> cmds) {
180        updateMemberships(dialog.getMemberships().orElse(null), existingNode, newNodes, cmds);
181        updateProperties(dialog.getTags().orElse(null), existingNode, newNodes, cmds);
182    }
183
184    private static void updateProperties(ExistingBothNew tags, Node existingNode, Iterable<Node> newNodes, Collection<Command> cmds) {
185        if (ExistingBothNew.NEW == tags) {
186            final Node newSelectedNode = new Node(existingNode);
187            newSelectedNode.removeAll();
188            cmds.add(new ChangeCommand(existingNode, newSelectedNode));
189        } else if (ExistingBothNew.OLD == tags) {
190            for (Node newNode : newNodes) {
191                newNode.removeAll();
192            }
193        }
194    }
195
196    /**
197     * Assumes there is one tagged Node stored in selectedNode that it will try to unglue.
198     * (i.e. copy node and remove all tags from the old one. Relations will not be removed)
199     * @param e event that triggered the action
200     */
201    private void unglueOneNodeAtMostOneWay(ActionEvent e) {
202        final PropertiesMembershipChoiceDialog dialog;
203        try {
204            dialog = PropertiesMembershipChoiceDialog.showIfNecessary(Collections.singleton(selectedNode), true);
205        } catch (UserCancelException ex) {
206            Logging.trace(ex);
207            return;
208        }
209
210        final Node unglued = new Node(selectedNode, true);
211        boolean moveSelectedNode = false;
212
213        List<Command> cmds = new LinkedList<>();
214        cmds.add(new AddCommand(selectedNode.getDataSet(), unglued));
215        if (dialog != null && ExistingBothNew.NEW == dialog.getTags().orElse(null)) {
216            // unglued node gets the ID and history, thus replace way node with a fresh one
217            final Way way = selectedNode.getParentWays().get(0);
218            final List<Node> newWayNodes = way.getNodes();
219            newWayNodes.replaceAll(n -> selectedNode.equals(n) ? unglued : n);
220            cmds.add(new ChangeNodesCommand(way, newWayNodes));
221            updateMemberships(dialog.getMemberships().map(ExistingBothNew::opposite).orElse(null),
222                    selectedNode, Collections.singletonList(unglued), cmds);
223            updateProperties(dialog.getTags().map(ExistingBothNew::opposite).orElse(null),
224                    selectedNode, Collections.singletonList(unglued), cmds);
225            moveSelectedNode = true;
226        } else if (dialog != null) {
227            update(dialog, selectedNode, Collections.singletonList(unglued), cmds);
228        }
229
230        // If this wasn't called from menu, place it where the cursor is/was
231        MapView mv = MainApplication.getMap().mapView;
232        if (e.getSource() instanceof JPanel) {
233            final LatLon latLon = mv.getLatLon(mv.lastMEvent.getX(), mv.lastMEvent.getY());
234            if (moveSelectedNode) {
235                cmds.add(new MoveCommand(selectedNode, latLon));
236            } else {
237                unglued.setCoor(latLon);
238            }
239        }
240
241        UndoRedoHandler.getInstance().add(new SequenceCommand(tr("Unglued Node"), cmds));
242        getLayerManager().getEditDataSet().setSelected(moveSelectedNode ? selectedNode : unglued);
243        mv.repaint();
244    }
245
246    /**
247     * Checks if selection is suitable for ungluing. This is the case when there's a single,
248     * tagged node selected that's part of at least one way (ungluing an unconnected node does
249     * not make sense. Due to the call order in actionPerformed, this is only called when the
250     * node is only part of one or less ways.
251     *
252     * @param selection The selection to check against
253     * @return {@code true} if selection is suitable
254     */
255    private boolean checkForUnglueNode(Collection<? extends OsmPrimitive> selection) {
256        if (selection.size() != 1)
257            return false;
258        OsmPrimitive n = (OsmPrimitive) selection.toArray()[0];
259        if (!(n instanceof Node))
260            return false;
261        if (((Node) n).getParentWays().isEmpty())
262            return false;
263
264        selectedNode = (Node) n;
265        return selectedNode.isTagged();
266    }
267
268    /**
269     * Checks if the selection consists of something we can work with.
270     * Checks only if the number and type of items selected looks good.
271     *
272     * If this method returns "true", selectedNode and selectedWay will be set.
273     *
274     * Returns true if either one node is selected or one node and one
275     * way are selected and the node is part of the way.
276     *
277     * The way will be put into the object variable "selectedWay", the node into "selectedNode".
278     * @param selection selected primitives
279     * @return true if either one node is selected or one node and one way are selected and the node is part of the way
280     */
281    private boolean checkSelectionOneNodeAtMostOneWay(Collection<? extends OsmPrimitive> selection) {
282
283        int size = selection.size();
284        if (size < 1 || size > 2)
285            return false;
286
287        selectedNode = null;
288        selectedWay = null;
289
290        for (OsmPrimitive p : selection) {
291            if (p instanceof Node) {
292                selectedNode = (Node) p;
293                if (size == 1 || selectedWay != null)
294                    return size == 1 || selectedWay.containsNode(selectedNode);
295            } else if (p instanceof Way) {
296                selectedWay = (Way) p;
297                if (size == 2 && selectedNode != null)
298                    return selectedWay.containsNode(selectedNode);
299            }
300        }
301
302        return false;
303    }
304
305    /**
306     * Checks if the selection consists of something we can work with.
307     * Checks only if the number and type of items selected looks good.
308     *
309     * Returns true if one way and any number of nodes that are part of that way are selected.
310     * Note: "any" can be none, then all nodes of the way are used.
311     *
312     * The way will be put into the object variable "selectedWay", the nodes into "selectedNodes".
313     * @param selection selected primitives
314     * @return true if one way and any number of nodes that are part of that way are selected
315     */
316    private boolean checkSelectionOneWayAnyNodes(Collection<? extends OsmPrimitive> selection) {
317        if (selection.isEmpty())
318            return false;
319
320        selectedWay = null;
321        for (OsmPrimitive p : selection) {
322            if (p instanceof Way) {
323                if (selectedWay != null)
324                    return false;
325                selectedWay = (Way) p;
326            }
327        }
328        if (selectedWay == null)
329            return false;
330
331        selectedNodes = new HashSet<>();
332        for (OsmPrimitive p : selection) {
333            if (p instanceof Node) {
334                Node n = (Node) p;
335                if (!selectedWay.containsNode(n))
336                    return false;
337                selectedNodes.add(n);
338            }
339        }
340
341        if (selectedNodes.isEmpty()) {
342            selectedNodes.addAll(selectedWay.getNodes());
343        }
344
345        return true;
346    }
347
348    /**
349     * dupe the given node of the given way
350     *
351     * assume that originalNode is in the way
352     * <ul>
353     * <li>the new node will be put into the parameter newNodes.</li>
354     * <li>the add-node command will be put into the parameter cmds.</li>
355     * <li>the changed way will be returned and must be put into cmds by the caller!</li>
356     * </ul>
357     * @param originalNode original node to duplicate
358     * @param w parent way
359     * @param cmds List of commands that will contain the new "add node" command
360     * @param newNodes List of nodes that will contain the new node
361     * @return new way The modified way. Change command mus be handled by the caller
362     */
363    private static Way modifyWay(Node originalNode, Way w, List<Command> cmds, List<Node> newNodes) {
364        // clone the node for the way
365        Node newNode = new Node(originalNode, true /* clear OSM ID */);
366        newNodes.add(newNode);
367        cmds.add(new AddCommand(originalNode.getDataSet(), newNode));
368
369        List<Node> nn = new ArrayList<>();
370        for (Node pushNode : w.getNodes()) {
371            if (originalNode == pushNode) {
372                pushNode = newNode;
373            }
374            nn.add(pushNode);
375        }
376        Way newWay = new Way(w);
377        newWay.setNodes(nn);
378
379        return newWay;
380    }
381
382    /**
383     * put all newNodes into the same relation(s) that originalNode is in
384     * @param memberships where the memberships should be places
385     * @param originalNode original node to duplicate
386     * @param cmds List of commands that will contain the new "change relation" commands
387     * @param newNodes List of nodes that contain the new node
388     */
389    private static void updateMemberships(ExistingBothNew memberships, Node originalNode, List<Node> newNodes, Collection<Command> cmds) {
390        if (memberships == null || ExistingBothNew.OLD == memberships) {
391            return;
392        }
393        // modify all relations containing the node
394        for (Relation r : OsmPrimitive.getParentRelations(Collections.singleton(originalNode))) {
395            if (r.isDeleted()) {
396                continue;
397            }
398            Relation newRel = null;
399            Map<String, Integer> rolesToReAdd = null; // <role name, index>
400            int i = 0;
401            for (RelationMember rm : r.getMembers()) {
402                if (rm.isNode() && rm.getMember() == originalNode) {
403                    if (newRel == null) {
404                        newRel = new Relation(r);
405                        rolesToReAdd = new HashMap<>();
406                    }
407                    if (rolesToReAdd != null) {
408                        rolesToReAdd.put(rm.getRole(), i);
409                    }
410                }
411                i++;
412            }
413            if (newRel != null) {
414                if (rolesToReAdd != null) {
415                    for (Map.Entry<String, Integer> role : rolesToReAdd.entrySet()) {
416                        for (Node n : newNodes) {
417                            newRel.addMember(role.getValue() + 1, new RelationMember(role.getKey(), n));
418                        }
419                        if (ExistingBothNew.NEW == memberships) {
420                            // remove old member
421                            newRel.removeMember(role.getValue());
422                        }
423                    }
424                }
425                cmds.add(new ChangeCommand(r, newRel));
426            }
427        }
428    }
429
430    /**
431     * dupe a single node into as many nodes as there are ways using it, OR
432     *
433     * dupe a single node once, and put the copy on the selected way
434     */
435    private void unglueWays() {
436        final PropertiesMembershipChoiceDialog dialog;
437        try {
438            dialog = PropertiesMembershipChoiceDialog.showIfNecessary(Collections.singleton(selectedNode), false);
439        } catch (UserCancelException e) {
440            Logging.trace(e);
441            return;
442        }
443
444        List<Command> cmds = new LinkedList<>();
445        List<Node> newNodes = new LinkedList<>();
446        if (selectedWay == null) {
447            Way wayWithSelectedNode = null;
448            LinkedList<Way> parentWays = new LinkedList<>();
449            for (OsmPrimitive osm : selectedNode.getReferrers()) {
450                if (osm.isUsable() && osm instanceof Way) {
451                    Way w = (Way) osm;
452                    if (wayWithSelectedNode == null && !w.isFirstLastNode(selectedNode)) {
453                        wayWithSelectedNode = w;
454                    } else {
455                        parentWays.add(w);
456                    }
457                }
458            }
459            if (wayWithSelectedNode == null) {
460                parentWays.removeFirst();
461            }
462            for (Way w : parentWays) {
463                cmds.add(new ChangeCommand(w, modifyWay(selectedNode, w, cmds, newNodes)));
464            }
465            notifyWayPartOfRelation(parentWays);
466        } else {
467            cmds.add(new ChangeCommand(selectedWay, modifyWay(selectedNode, selectedWay, cmds, newNodes)));
468            notifyWayPartOfRelation(Collections.singleton(selectedWay));
469        }
470
471        if (dialog != null) {
472            update(dialog, selectedNode, newNodes, cmds);
473        }
474
475        execCommands(cmds, newNodes);
476    }
477
478    /**
479     * Add commands to undo-redo system.
480     * @param cmds Commands to execute
481     * @param newNodes New created nodes by this set of command
482     */
483    private void execCommands(List<Command> cmds, List<Node> newNodes) {
484        UndoRedoHandler.getInstance().add(new SequenceCommand(/* for correct i18n of plural forms - see #9110 */
485                trn("Dupe into {0} node", "Dupe into {0} nodes", newNodes.size() + 1L, newNodes.size() + 1L), cmds));
486        // select one of the new nodes
487        getLayerManager().getEditDataSet().setSelected(newNodes.get(0));
488    }
489
490    /**
491     * Duplicates a node used several times by the same way. See #9896.
492     * @return true if action is OK false if there is nothing to do
493     */
494    private boolean unglueSelfCrossingWay() {
495        // According to previous check, only one valid way through that node
496        Way way = null;
497        for (Way w: selectedNode.getParentWays()) {
498            if (w.isUsable() && w.getNodesCount() >= 1) {
499                way = w;
500            }
501        }
502        if (way == null) {
503            return false;
504        }
505        List<Command> cmds = new LinkedList<>();
506        List<Node> oldNodes = way.getNodes();
507        List<Node> newNodes = new ArrayList<>(oldNodes.size());
508        List<Node> addNodes = new ArrayList<>();
509        boolean seen = false;
510        for (Node n: oldNodes) {
511            if (n == selectedNode) {
512                if (seen) {
513                    Node newNode = new Node(n, true /* clear OSM ID */);
514                    cmds.add(new AddCommand(selectedNode.getDataSet(), newNode));
515                    newNodes.add(newNode);
516                    addNodes.add(newNode);
517                } else {
518                    newNodes.add(n);
519                    seen = true;
520                }
521            } else {
522                newNodes.add(n);
523            }
524        }
525        if (addNodes.isEmpty()) {
526            // selectedNode doesn't need unglue
527            return false;
528        }
529        cmds.add(new ChangeNodesCommand(way, newNodes));
530        notifyWayPartOfRelation(Collections.singleton(way));
531        try {
532            final PropertiesMembershipChoiceDialog dialog = PropertiesMembershipChoiceDialog.showIfNecessary(
533                    Collections.singleton(selectedNode), false);
534            if (dialog != null) {
535                update(dialog, selectedNode, addNodes, cmds);
536            }
537            execCommands(cmds, addNodes);
538            return true;
539        } catch (UserCancelException ignore) {
540            Logging.trace(ignore);
541        }
542        return false;
543    }
544
545    /**
546     * dupe all nodes that are selected, and put the copies on the selected way
547     *
548     */
549    private void unglueOneWayAnyNodes() {
550        Way tmpWay = selectedWay;
551
552        final PropertiesMembershipChoiceDialog dialog;
553        try {
554            dialog = PropertiesMembershipChoiceDialog.showIfNecessary(selectedNodes, false);
555        } catch (UserCancelException e) {
556            Logging.trace(e);
557            return;
558        }
559
560        List<Command> cmds = new LinkedList<>();
561        List<Node> allNewNodes = new LinkedList<>();
562        for (Node n : selectedNodes) {
563            List<Node> newNodes = new LinkedList<>();
564            tmpWay = modifyWay(n, tmpWay, cmds, newNodes);
565            if (dialog != null) {
566                update(dialog, n, newNodes, cmds);
567            }
568            allNewNodes.addAll(newNodes);
569        }
570        cmds.add(new ChangeCommand(selectedWay, tmpWay)); // only one changeCommand for a way, else garbage will happen
571        notifyWayPartOfRelation(Collections.singleton(selectedWay));
572
573        UndoRedoHandler.getInstance().add(new SequenceCommand(
574                trn("Dupe {0} node into {1} nodes", "Dupe {0} nodes into {1} nodes",
575                        selectedNodes.size(), selectedNodes.size(), selectedNodes.size()+allNewNodes.size()), cmds));
576        getLayerManager().getEditDataSet().setSelected(allNewNodes);
577    }
578
579    @Override
580    protected void updateEnabledState() {
581        updateEnabledStateOnCurrentSelection();
582    }
583
584    @Override
585    protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) {
586        updateEnabledStateOnModifiableSelection(selection);
587    }
588
589    protected void checkAndConfirmOutlyingUnglue() throws UserCancelException {
590        List<OsmPrimitive> primitives = new ArrayList<>(2 + (selectedNodes == null ? 0 : selectedNodes.size()));
591        if (selectedNodes != null)
592            primitives.addAll(selectedNodes);
593        if (selectedNode != null)
594            primitives.add(selectedNode);
595        final boolean ok = checkAndConfirmOutlyingOperation("unglue",
596                tr("Unglue confirmation"),
597                tr("You are about to unglue nodes outside of the area you have downloaded."
598                        + "<br>"
599                        + "This can cause problems because other objects (that you do not see) might use them."
600                        + "<br>"
601                        + "Do you really want to unglue?"),
602                tr("You are about to unglue incomplete objects."
603                        + "<br>"
604                        + "This will cause problems because you don''t see the real object."
605                        + "<br>" + "Do you really want to unglue?"),
606                primitives, null);
607        if (!ok) {
608            throw new UserCancelException();
609        }
610    }
611
612    protected void notifyWayPartOfRelation(final Iterable<Way> ways) {
613        final Set<String> affectedRelations = new HashSet<>();
614        for (Way way : ways) {
615            for (OsmPrimitive ref : way.getReferrers()) {
616                if (ref instanceof Relation && ref.isUsable()) {
617                    affectedRelations.add(ref.getDisplayName(DefaultNameFormatter.getInstance()));
618                }
619            }
620        }
621        if (affectedRelations.isEmpty()) {
622            return;
623        }
624
625        final String msg1 = trn("Unglueing affected {0} relation: {1}", "Unglueing affected {0} relations: {1}",
626                affectedRelations.size(), affectedRelations.size(), Utils.joinAsHtmlUnorderedList(affectedRelations));
627        final String msg2 = trn("Ensure that the relation has not been broken!", "Ensure that the relations have not been broken!",
628                affectedRelations.size());
629        new Notification("<html>" + msg1 + msg2).setIcon(JOptionPane.WARNING_MESSAGE).show();
630    }
631}