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.Point;
009import java.awt.event.ActionEvent;
010import java.awt.event.KeyEvent;
011import java.util.ArrayList;
012import java.util.Collection;
013import java.util.Collections;
014import java.util.HashMap;
015import java.util.HashSet;
016import java.util.List;
017import java.util.Map;
018import java.util.Set;
019import java.util.stream.Collectors;
020
021import javax.swing.JOptionPane;
022
023import org.openstreetmap.josm.command.AddCommand;
024import org.openstreetmap.josm.command.ChangeCommand;
025import org.openstreetmap.josm.command.ChangeMembersCommand;
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.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;
045
046/**
047 * Duplicate nodes that are used by multiple ways or tagged nodes used by a single way
048 * or nodes which referenced more than once by a single way.
049 *
050 * This is the opposite of the MergeNodesAction.
051 *
052 */
053public class UnGlueAction extends JosmAction {
054
055    private transient Node selectedNode;
056    private transient Way selectedWay;
057    private transient Set<Node> selectedNodes;
058
059    /**
060     * Create a new UnGlueAction.
061     */
062    public UnGlueAction() {
063        super(tr("UnGlue Ways"), "unglueways", tr("Duplicate nodes that are used by multiple ways."),
064                Shortcut.registerShortcut("tools:unglue", tr("Tools: {0}", tr("UnGlue Ways")), KeyEvent.VK_G, Shortcut.DIRECT), true);
065        setHelpId(ht("/Action/UnGlue"));
066    }
067
068    /**
069     * Called when the action is executed.
070     *
071     * This method does some checking on the selection and calls the matching unGlueWay method.
072     */
073    @Override
074    public void actionPerformed(ActionEvent e) {
075        try {
076            unglue();
077        } catch (UserCancelException ignore) {
078            Logging.trace(ignore);
079        } finally {
080            cleanup();
081        }
082    }
083
084    protected void unglue() throws UserCancelException {
085
086        Collection<OsmPrimitive> selection = getLayerManager().getEditDataSet().getSelected();
087
088        String errMsg = null;
089        int errorTime = Notification.TIME_DEFAULT;
090
091        if (checkSelectionOneNodeAtMostOneWay(selection)) {
092            checkAndConfirmOutlyingUnglue();
093            List<Way> parentWays = selectedNode.getParentWays().stream().filter(Way::isUsable).collect(Collectors.toList());
094
095            if (parentWays.size() < 2) {
096                if (!parentWays.isEmpty()) {
097                    // single way
098                    Way way = selectedWay == null ? parentWays.get(0) : selectedWay;
099                    boolean closedOrSelfCrossing = way.getNodes().stream().filter(n -> n == selectedNode).count() > 1;
100
101                    final PropertiesMembershipChoiceDialog dialog = PropertiesMembershipChoiceDialog.showIfNecessary(
102                            Collections.singleton(selectedNode), !selectedNode.isTagged());
103                    if (dialog != null && dialog.getTags().isPresent()) {
104                        unglueOneNodeAtMostOneWay(way, dialog);
105                        return;
106                    } else if (closedOrSelfCrossing) {
107                        unglueClosedOrSelfCrossingWay(way, dialog);
108                        return;
109                    }
110                }
111                errorTime = Notification.TIME_SHORT;
112                errMsg = tr("This node is not glued to anything else.");
113            } else {
114                // and then do the work.
115                unglueWays();
116            }
117        } else if (checkSelectionOneWayAnyNodes(selection)) {
118            checkAndConfirmOutlyingUnglue();
119            selectedNodes.removeIf(n -> n.getParentWays().stream().filter(Way::isUsable).count() < 2);
120            if (selectedNodes.isEmpty()) {
121                if (selection.size() > 1) {
122                    errMsg = tr("None of these nodes are glued to anything else.");
123                } else {
124                    errMsg = tr("None of this way''s nodes are glued to anything else.");
125                }
126            } else if (selectedNodes.size() == 1) {
127                selectedNode = selectedNodes.iterator().next();
128                unglueWays();
129            } else {
130                // and then do the work.
131                unglueOneWayAnyNodes();
132            }
133        } else {
134            errorTime = Notification.TIME_VERY_LONG;
135            errMsg =
136                tr("The current selection cannot be used for unglueing.")+'\n'+
137                '\n'+
138                tr("Select either:")+'\n'+
139                tr("* One tagged node, or")+'\n'+
140                tr("* One node that is used by more than one way, or")+'\n'+
141                tr("* One node that is used by more than one way and one of those ways, or")+'\n'+
142                tr("* One way that has one or more nodes that are used by more than one way, or")+'\n'+
143                tr("* One way and one or more of its nodes that are used by more than one way.")+'\n'+
144                '\n'+
145                tr("Note: If a way is selected, this way will get fresh copies of the unglued\n"+
146                        "nodes and the new nodes will be selected. Otherwise, all ways will get their\n"+
147                "own copy and all nodes will be selected.");
148        }
149
150        if (errMsg != null) {
151            new Notification(
152                    errMsg)
153                    .setIcon(JOptionPane.ERROR_MESSAGE)
154                    .setDuration(errorTime)
155                    .show();
156        }
157    }
158
159    private void cleanup() {
160        selectedNode = null;
161        selectedWay = null;
162        selectedNodes = null;
163    }
164
165    static void update(PropertiesMembershipChoiceDialog dialog, Node existingNode, List<Node> newNodes, List<Command> cmds) {
166        updateMemberships(dialog.getMemberships().orElse(null), existingNode, newNodes, cmds);
167        updateProperties(dialog.getTags().orElse(null), existingNode, newNodes, cmds);
168    }
169
170    private static void updateProperties(ExistingBothNew tags, Node existingNode, Iterable<Node> newNodes, List<Command> cmds) {
171        if (ExistingBothNew.NEW == tags) {
172            final Node newSelectedNode = new Node(existingNode);
173            newSelectedNode.removeAll();
174            cmds.add(new ChangeCommand(existingNode, newSelectedNode));
175        } else if (ExistingBothNew.OLD == tags) {
176            for (Node newNode : newNodes) {
177                newNode.removeAll();
178            }
179        }
180    }
181
182    /**
183     * Assumes there is one tagged Node stored in selectedNode that it will try to unglue.
184     * (i.e. copy node and remove all tags from the old one.)
185     * @param way way to modify
186     * @param dialog the user dialog
187     */
188    private void unglueOneNodeAtMostOneWay(Way way, PropertiesMembershipChoiceDialog dialog) {
189        List<Command> cmds = new ArrayList<>();
190        List<Node> newNodes = new ArrayList<>();
191        cmds.add(new ChangeNodesCommand(way, modifyWay(selectedNode, way, cmds, newNodes)));
192        if (dialog != null) {
193            update(dialog, selectedNode, newNodes, cmds);
194        }
195
196        // Place the selected node where the cursor is or some pixels above
197        MapView mv = MainApplication.getMap().mapView;
198        Point currMousePos = mv.getMousePosition();
199        if (currMousePos != null) {
200            cmds.add(new MoveCommand(selectedNode, mv.getLatLon(currMousePos.getX(), currMousePos.getY())));
201        } else {
202            cmds.add(new MoveCommand(selectedNode, 0, 5));
203        }
204        UndoRedoHandler.getInstance().add(new SequenceCommand(tr("Unglued Node"), cmds));
205        getLayerManager().getEditDataSet().setSelected(selectedNode);
206    }
207
208    /**
209     * Checks if the selection consists of something we can work with.
210     * Checks only if the number and type of items selected looks good.
211     *
212     * If this method returns "true", selectedNode will be set, selectedWay might be set
213     *
214     * Returns true if either one node is selected or one node and one
215     * way are selected and the node is part of the way.
216     *
217     * The way will be put into the object variable "selectedWay", the node into "selectedNode".
218     * @param selection selected primitives
219     * @return true if either one node is selected or one node and one way are selected and the node is part of the way
220     */
221    private boolean checkSelectionOneNodeAtMostOneWay(Collection<? extends OsmPrimitive> selection) {
222
223        int size = selection.size();
224        if (size < 1 || size > 2)
225            return false;
226
227        selectedNode = null;
228        selectedWay = null;
229
230        for (OsmPrimitive p : selection) {
231            if (p instanceof Node) {
232                selectedNode = (Node) p;
233                if (size == 1 || (selectedWay != null && selectedWay.containsNode(selectedNode)))
234                    return true;
235            } else if (p instanceof Way) {
236                selectedWay = (Way) p;
237                if (size == 2 && selectedNode != null)
238                    return selectedWay.containsNode(selectedNode);
239            }
240        }
241
242        return false;
243    }
244
245    /**
246     * Checks if the selection consists of something we can work with.
247     * Checks only if the number and type of items selected looks good.
248     *
249     * Returns true if one way and any number of nodes that are part of that way are selected.
250     * Note: "any" can be none, then all nodes of the way are used.
251     *
252     * The way will be put into the object variable "selectedWay", the nodes into "selectedNodes".
253     * @param selection selected primitives
254     * @return true if one way and any number of nodes that are part of that way are selected
255     */
256    private boolean checkSelectionOneWayAnyNodes(Collection<? extends OsmPrimitive> selection) {
257        if (selection.isEmpty())
258            return false;
259
260        selectedWay = null;
261        for (OsmPrimitive p : selection) {
262            if (p instanceof Way) {
263                if (selectedWay != null)
264                    return false;
265                selectedWay = (Way) p;
266            }
267        }
268        if (selectedWay == null)
269            return false;
270
271        selectedNodes = new HashSet<>();
272        for (OsmPrimitive p : selection) {
273            if (p instanceof Node) {
274                Node n = (Node) p;
275                if (!selectedWay.containsNode(n))
276                    return false;
277                selectedNodes.add(n);
278            }
279        }
280
281        if (selectedNodes.isEmpty()) {
282            selectedNodes.addAll(selectedWay.getNodes());
283        }
284
285        return true;
286    }
287
288    /**
289     * dupe the given node of the given way
290     *
291     * assume that originalNode is in the way
292     * <ul>
293     * <li>the new node will be put into the parameter newNodes.</li>
294     * <li>the add-node command will be put into the parameter cmds.</li>
295     * <li>the changed way will be returned and must be put into cmds by the caller!</li>
296     * </ul>
297     * @param originalNode original node to duplicate
298     * @param w parent way
299     * @param cmds List of commands that will contain the new "add node" command
300     * @param newNodes List of nodes that will contain the new node
301     * @return The modified list of way nodes. Change command must be handled by the caller
302     */
303    private static List<Node> modifyWay(Node originalNode, Way w, List<Command> cmds, List<Node> newNodes) {
304        // clone the node for the way
305        Node newNode = cloneNode(originalNode, cmds);
306        newNodes.add(newNode);
307
308        List<Node> nn = new ArrayList<>(w.getNodes());
309        nn.replaceAll(n -> n == originalNode ? newNode : n);
310        return nn;
311    }
312
313    private static Node cloneNode(Node originalNode, List<Command> cmds) {
314        Node newNode = new Node(originalNode, true /* clear OSM ID */);
315        cmds.add(new AddCommand(originalNode.getDataSet(), newNode));
316        return newNode;
317    }
318
319    /**
320     * put all newNodes into the same relation(s) that originalNode is in
321     * @param memberships where the memberships should be places
322     * @param originalNode original node to duplicate
323     * @param cmds List of commands that will contain the new "change relation" commands
324     * @param newNodes List of nodes that contain the new node
325     */
326    private static void updateMemberships(ExistingBothNew memberships, Node originalNode, List<Node> newNodes, List<Command> cmds) {
327        if (memberships == null || ExistingBothNew.OLD == memberships) {
328            return;
329        }
330        // modify all relations containing the node
331        for (Relation r : OsmPrimitive.getParentRelations(Collections.singleton(originalNode))) {
332            if (r.isDeleted()) {
333                continue;
334            }
335            List<RelationMember> newMembers = new ArrayList<>(r.getMembers());
336            // loop backwards because we add or remove members, works also when nodes appear
337            // multiple times in the same relation
338            boolean changed = false;
339            for (int i = r.getMembersCount() - 1; i >= 0; i--) {
340                RelationMember rm = r.getMember(i);
341                if (rm.getMember() != originalNode)
342                    continue;
343                for (Node n : newNodes) {
344                    newMembers.add(i + 1, new RelationMember(rm.getRole(), n));
345                }
346                if (ExistingBothNew.NEW == memberships) {
347                    // remove old member
348                    newMembers.remove(i);
349                }
350                changed = true;
351            }
352            if (changed) {
353                cmds.add(new ChangeMembersCommand(r, newMembers));
354            }
355        }
356    }
357
358    /**
359     * dupe a single node into as many nodes as there are ways using it, OR
360     *
361     * dupe a single node once, and put the copy on the selected way
362     * @throws UserCancelException if user cancels choice
363     */
364    private void unglueWays() throws UserCancelException {
365        final PropertiesMembershipChoiceDialog dialog = PropertiesMembershipChoiceDialog
366                .showIfNecessary(Collections.singleton(selectedNode), false);
367        List<Command> cmds = new ArrayList<>();
368        List<Node> newNodes = new ArrayList<>();
369        List<Way> parentWays;
370        if (selectedWay == null) {
371            parentWays = selectedNode.referrers(Way.class).filter(Way::isUsable).collect(Collectors.toList());
372            // see #5452 and #18670
373            parentWays.sort((o1, o2) -> {
374                int d = Boolean.compare(!o1.isNew() && !o1.isModified(), !o2.isNew() && !o2.isModified());
375                if (d == 0) {
376                    d = Integer.compare(o2.getReferrers().size(), o1.getReferrers().size()); // reversed
377                }
378                if (d == 0) {
379                    d = Boolean.compare(o1.isFirstLastNode(selectedNode), o2.isFirstLastNode(selectedNode));
380                }
381                return d;
382            });
383            // first way should not be changed, preferring older ways and those with fewer parents
384            parentWays.remove(0);
385        } else {
386            parentWays = Collections.singletonList(selectedWay);
387        }
388        Set<Way> warnParents = new HashSet<>();
389        for (Way w : parentWays) {
390            if (w.isFirstLastNode(selectedNode))
391                warnParents.add(w);
392            cmds.add(new ChangeNodesCommand(w, modifyWay(selectedNode, w, cmds, newNodes)));
393        }
394
395        if (dialog != null) {
396            update(dialog, selectedNode, newNodes, cmds);
397        }
398        notifyWayPartOfRelation(warnParents);
399
400        execCommands(cmds, newNodes);
401    }
402
403    /**
404     * Add commands to undo-redo system.
405     * @param cmds Commands to execute
406     * @param newNodes New created nodes by this set of command
407     */
408    private void execCommands(List<Command> cmds, List<Node> newNodes) {
409        UndoRedoHandler.getInstance().add(new SequenceCommand(/* for correct i18n of plural forms - see #9110 */
410                trn("Dupe into {0} node", "Dupe into {0} nodes", newNodes.size() + 1L, newNodes.size() + 1L), cmds));
411        // select one of the new nodes
412        getLayerManager().getEditDataSet().setSelected(newNodes.get(0));
413    }
414
415    /**
416     * Duplicates a node used several times by the same way. See #9896.
417     * First occurrence is kept. A closed way will be "opened" when the closing node is unglued.
418     * @param way way to modify
419     * @param dialog user dialog, might be null
420     * @return true if action is OK false if there is nothing to do
421     */
422    private boolean unglueClosedOrSelfCrossingWay(Way way, PropertiesMembershipChoiceDialog dialog) {
423        // According to previous check, only one valid way through that node
424        List<Command> cmds = new ArrayList<>();
425        List<Node> oldNodes = way.getNodes();
426        List<Node> newNodes = new ArrayList<>(oldNodes.size());
427        List<Node> addNodes = new ArrayList<>();
428        int count = 0;
429        for (Node n: oldNodes) {
430            if (n == selectedNode && count++ > 0) {
431                n = cloneNode(selectedNode, cmds);
432                addNodes.add(n);
433            }
434            newNodes.add(n);
435        }
436        if (addNodes.isEmpty()) {
437            // selectedNode doesn't need unglue
438            return false;
439        }
440        if (dialog != null) {
441            update(dialog, selectedNode, addNodes, cmds);
442        }
443        addCheckedChangeNodesCmd(cmds, way, newNodes);
444        execCommands(cmds, addNodes);
445        return true;
446    }
447
448    /**
449     * dupe all nodes that are selected, and put the copies on the selected way
450     * @throws UserCancelException if user cancels choice
451     */
452    private void unglueOneWayAnyNodes() throws UserCancelException {
453        final PropertiesMembershipChoiceDialog dialog =
454            PropertiesMembershipChoiceDialog.showIfNecessary(selectedNodes, false);
455
456        Map<Node, Node> replaced = new HashMap<>();
457        List<Command> cmds = new ArrayList<>();
458
459        selectedNodes.forEach(n -> replaced.put(n, cloneNode(n, cmds)));
460        List<Node> modNodes = new ArrayList<>(selectedWay.getNodes());
461        modNodes.replaceAll(n -> replaced.getOrDefault(n, n));
462
463        if (dialog != null) {
464            replaced.forEach((k, v) -> update(dialog, k, Collections.singletonList(v), cmds));
465        }
466
467        // only one changeCommand for a way, else garbage will happen
468        addCheckedChangeNodesCmd(cmds, selectedWay, modNodes);
469        UndoRedoHandler.getInstance().add(new SequenceCommand(
470                trn("Dupe {0} node into {1} nodes", "Dupe {0} nodes into {1} nodes",
471                        selectedNodes.size(), selectedNodes.size(), 2 * selectedNodes.size()), cmds));
472        getLayerManager().getEditDataSet().setSelected(replaced.values());
473    }
474
475    private boolean addCheckedChangeNodesCmd(List<Command> cmds, Way w, List<Node> nodes) {
476        boolean relationCheck = !calcAffectedRelations(Collections.singleton(w)).isEmpty();
477        cmds.add(new ChangeNodesCommand(w, nodes));
478        if (relationCheck) {
479            notifyWayPartOfRelation(Collections.singleton(w));
480        }
481        return relationCheck;
482    }
483
484    @Override
485    protected void updateEnabledState() {
486        updateEnabledStateOnCurrentSelection();
487    }
488
489    @Override
490    protected void updateEnabledState(Collection<? extends OsmPrimitive> selection) {
491        updateEnabledStateOnModifiableSelection(selection);
492    }
493
494    protected void checkAndConfirmOutlyingUnglue() throws UserCancelException {
495        List<OsmPrimitive> primitives = new ArrayList<>(2 + (selectedNodes == null ? 0 : selectedNodes.size()));
496        if (selectedNodes != null)
497            primitives.addAll(selectedNodes);
498        if (selectedNode != null)
499            primitives.add(selectedNode);
500        final boolean ok = checkAndConfirmOutlyingOperation("unglue",
501                tr("Unglue confirmation"),
502                tr("You are about to unglue nodes which can have other referrers not yet downloaded."
503                        + "<br>"
504                        + "This can cause problems because other objects (that you do not see) might use them."
505                        + "<br>"
506                        + "Do you really want to unglue?"),
507                tr("You are about to unglue incomplete objects."
508                        + "<br>"
509                        + "This will cause problems because you don''t see the real object."
510                        + "<br>" + "Do you really want to unglue?"),
511                primitives, null);
512        if (!ok) {
513            throw new UserCancelException();
514        }
515    }
516
517    protected void notifyWayPartOfRelation(final Collection<Way> ways) {
518        Set<Relation> affectedRelations = calcAffectedRelations(ways);
519        if (affectedRelations.isEmpty()) {
520            return;
521        }
522        final int size = affectedRelations.size();
523        final String msg1 = trn("Unglueing possibly affected {0} relation: {1}", "Unglueing possibly affected {0} relations: {1}",
524                size, size, DefaultNameFormatter.getInstance().formatAsHtmlUnorderedList(affectedRelations, 20));
525        final String msg2 = trn("Ensure that the relation has not been broken!", "Ensure that the relations have not been broken!",
526                size);
527        new Notification(msg1 + msg2).setIcon(JOptionPane.WARNING_MESSAGE).show();
528    }
529
530    protected Set<Relation> calcAffectedRelations(final Collection<Way> ways) {
531        final Set<Node> affectedNodes = (selectedNodes != null) ? selectedNodes : Collections.singleton(selectedNode);
532        return OsmPrimitive.getParentRelations(ways)
533                .stream().filter(r -> isRelationAffected(r, affectedNodes, ways))
534                .collect(Collectors.toSet());
535    }
536
537    private static boolean isRelationAffected(Relation r, Set<Node> affectedNodes, Collection<Way> ways) {
538        if (!r.isUsable())
539            return false;
540        // see #18670: suppress notification when well known restriction types are not affected
541        if (!r.hasTag("type", "restriction", "connectivity", "destination_sign") || r.hasIncompleteMembers())
542            return true;
543        int count = 0;
544        for (RelationMember rm : r.getMembers()) {
545            if (rm.isNode() && affectedNodes.contains(rm.getNode()))
546                count++;
547            if (rm.isWay() && ways.contains(rm.getWay())) {
548                count++;
549                if ("via".equals(rm.getRole())) {
550                    count++;
551                }
552            }
553        }
554        return count >= 2;
555    }
556}