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