001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.conflict.tags;
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.BorderLayout;
009import java.awt.Component;
010import java.awt.Dimension;
011import java.awt.FlowLayout;
012import java.awt.GraphicsEnvironment;
013import java.awt.event.ActionEvent;
014import java.awt.event.WindowAdapter;
015import java.awt.event.WindowEvent;
016import java.beans.PropertyChangeEvent;
017import java.beans.PropertyChangeListener;
018import java.util.Collection;
019import java.util.LinkedList;
020import java.util.List;
021import java.util.Set;
022import java.util.stream.Collectors;
023
024import javax.swing.AbstractAction;
025import javax.swing.Action;
026import javax.swing.JButton;
027import javax.swing.JDialog;
028import javax.swing.JLabel;
029import javax.swing.JOptionPane;
030import javax.swing.JPanel;
031import javax.swing.JSplitPane;
032
033import org.openstreetmap.josm.actions.ExpertToggleAction;
034import org.openstreetmap.josm.command.Command;
035import org.openstreetmap.josm.data.osm.DefaultNameFormatter;
036import org.openstreetmap.josm.data.osm.Node;
037import org.openstreetmap.josm.data.osm.OsmPrimitive;
038import org.openstreetmap.josm.data.osm.Relation;
039import org.openstreetmap.josm.data.osm.TagCollection;
040import org.openstreetmap.josm.data.osm.Way;
041import org.openstreetmap.josm.gui.ConditionalOptionPaneUtil;
042import org.openstreetmap.josm.gui.MainApplication;
043import org.openstreetmap.josm.gui.help.ContextSensitiveHelpAction;
044import org.openstreetmap.josm.gui.help.HelpUtil;
045import org.openstreetmap.josm.gui.util.GuiHelper;
046import org.openstreetmap.josm.gui.util.WindowGeometry;
047import org.openstreetmap.josm.gui.widgets.AutoAdjustingSplitPane;
048import org.openstreetmap.josm.tools.CheckParameterUtil;
049import org.openstreetmap.josm.tools.ImageProvider;
050import org.openstreetmap.josm.tools.InputMapUtils;
051import org.openstreetmap.josm.tools.StreamUtils;
052import org.openstreetmap.josm.tools.UserCancelException;
053
054/**
055 * This dialog helps to resolve conflicts occurring when ways are combined or
056 * nodes are merged.
057 *
058 * Usage: {@link #launchIfNecessary} followed by {@link #buildResolutionCommands}.
059 *
060 * Prior to {@link #launchIfNecessary}, the following usage sequence was needed:
061 *
062 * The dialog uses two models: one  for resolving tag conflicts, the other
063 * for resolving conflicts in relation memberships. For both models there are accessors,
064 * i.e {@link #getTagConflictResolverModel()} and {@link #getRelationMemberConflictResolverModel()}.
065 *
066 * Models have to be <strong>populated</strong> before the dialog is launched. Example:
067 * <pre>
068 *    CombinePrimitiveResolverDialog dialog = new CombinePrimitiveResolverDialog(MainApplication.getMainFrame());
069 *    dialog.getTagConflictResolverModel().populate(aTagCollection);
070 *    dialog.getRelationMemberConflictResolverModel().populate(aRelationLinkCollection);
071 *    dialog.prepareDefaultDecisions();
072 * </pre>
073 *
074 * You should also set the target primitive which other primitives (ways or nodes) are
075 * merged to, see {@link #setTargetPrimitive(OsmPrimitive)}.
076 *
077 * After the dialog is closed use {@link #isApplied()} to check whether the dialog has been
078 * applied. If it was applied you may build a collection of {@link Command} objects
079 * which reflect the conflict resolution decisions the user made in the dialog:
080 * see {@link #buildResolutionCommands()}
081 */
082public class CombinePrimitiveResolverDialog extends JDialog {
083
084    private AutoAdjustingSplitPane spTagConflictTypes;
085    private final TagConflictResolverModel modelTagConflictResolver;
086    protected TagConflictResolver pnlTagConflictResolver;
087    private final RelationMemberConflictResolverModel modelRelConflictResolver;
088    protected RelationMemberConflictResolver pnlRelationMemberConflictResolver;
089    private final CombinePrimitiveResolver primitiveResolver;
090    private boolean applied;
091    private JPanel pnlButtons;
092    protected transient OsmPrimitive targetPrimitive;
093
094    /** the private help action */
095    private ContextSensitiveHelpAction helpAction;
096    /** the apply button */
097    private JButton btnApply;
098
099    /**
100     * Constructs a new {@code CombinePrimitiveResolverDialog}.
101     * @param parent The parent component in which this dialog will be displayed.
102     */
103    public CombinePrimitiveResolverDialog(Component parent) {
104        this(parent, new TagConflictResolverModel(), new RelationMemberConflictResolverModel());
105    }
106
107    /**
108     * Constructs a new {@code CombinePrimitiveResolverDialog}.
109     * @param parent The parent component in which this dialog will be displayed.
110     * @param tagModel tag conflict resolver model
111     * @param relModel relation member conflict resolver model
112     * @since 11772
113     */
114    public CombinePrimitiveResolverDialog(Component parent,
115            TagConflictResolverModel tagModel, RelationMemberConflictResolverModel relModel) {
116        super(GuiHelper.getFrameForComponent(parent), ModalityType.DOCUMENT_MODAL);
117        this.modelTagConflictResolver = tagModel;
118        this.modelRelConflictResolver = relModel;
119        this.primitiveResolver = new CombinePrimitiveResolver(tagModel, relModel);
120        build();
121    }
122
123    /**
124     * Replies the target primitive the collection of primitives is merged or combined to.
125     *
126     * @return the target primitive
127     * @since 11772 (naming)
128     */
129    public OsmPrimitive getTargetPrimitive() {
130        return targetPrimitive;
131    }
132
133    /**
134     * Sets the primitive the collection of primitives is merged or combined to.
135     *
136     * @param primitive the target primitive
137     */
138    public void setTargetPrimitive(final OsmPrimitive primitive) {
139        setTargetPrimitive(primitive, true);
140    }
141
142    /**
143     * Sets the primitive the collection of primitives is merged or combined to.
144     *
145     * @param primitive the target primitive
146     * @param updateTitle {@code true} to call {@link #updateTitle} in EDT (can be a slow operation)
147     * @since 11626
148     */
149    private void setTargetPrimitive(final OsmPrimitive primitive, boolean updateTitle) {
150        this.targetPrimitive = primitive;
151        if (updateTitle) {
152            GuiHelper.runInEDTAndWait(this::updateTitle);
153        }
154    }
155
156    /**
157     * Updates the dialog title.
158     */
159    protected void updateTitle() {
160        if (targetPrimitive == null) {
161            setTitle(tr("Conflicts when combining primitives"));
162            return;
163        }
164        if (targetPrimitive instanceof Way) {
165            setTitle(tr("Conflicts when combining ways - combined way is ''{0}''", targetPrimitive
166                    .getDisplayName(DefaultNameFormatter.getInstance())));
167            helpAction.setHelpTopic(ht("/Action/CombineWay#ResolvingConflicts"));
168            getRootPane().putClientProperty("help", ht("/Action/CombineWay#ResolvingConflicts"));
169            pnlRelationMemberConflictResolver.initForWayCombining();
170        } else if (targetPrimitive instanceof Node) {
171            setTitle(tr("Conflicts when merging nodes - target node is ''{0}''", targetPrimitive
172                    .getDisplayName(DefaultNameFormatter.getInstance())));
173            helpAction.setHelpTopic(ht("/Action/MergeNodes#ResolvingConflicts"));
174            getRootPane().putClientProperty("help", ht("/Action/MergeNodes#ResolvingConflicts"));
175            pnlRelationMemberConflictResolver.initForNodeMerging();
176        }
177    }
178
179    /**
180     * Builds the components.
181     */
182    protected final void build() {
183        getContentPane().setLayout(new BorderLayout());
184        updateTitle();
185        spTagConflictTypes = new AutoAdjustingSplitPane(JSplitPane.VERTICAL_SPLIT);
186        spTagConflictTypes.setTopComponent(buildTagConflictResolverPanel());
187        spTagConflictTypes.setBottomComponent(buildRelationMemberConflictResolverPanel());
188        pnlButtons = buildButtonPanel();
189        getContentPane().add(pnlButtons, BorderLayout.SOUTH);
190        addWindowListener(new AdjustDividerLocationAction());
191        HelpUtil.setHelpContext(getRootPane(), ht("/"));
192        InputMapUtils.addEscapeAction(getRootPane(), new CancelAction());
193    }
194
195    /**
196     * Builds the tag conflict resolver panel.
197     * @return the tag conflict resolver panel
198     */
199    protected JPanel buildTagConflictResolverPanel() {
200        pnlTagConflictResolver = new TagConflictResolver(modelTagConflictResolver);
201        return pnlTagConflictResolver;
202    }
203
204    /**
205     * Builds the relation member conflict resolver panel.
206     * @return the relation member conflict resolver panel
207     */
208    protected JPanel buildRelationMemberConflictResolverPanel() {
209        pnlRelationMemberConflictResolver = new RelationMemberConflictResolver(modelRelConflictResolver);
210        return pnlRelationMemberConflictResolver;
211    }
212
213    /**
214     * Builds the "Apply" action.
215     * @return the "Apply" action
216     */
217    protected ApplyAction buildApplyAction() {
218        return new ApplyAction();
219    }
220
221    /**
222     * Builds the button panel.
223     * @return the button panel
224     */
225    protected JPanel buildButtonPanel() {
226        JPanel pnl = new JPanel(new FlowLayout(FlowLayout.CENTER));
227
228        // -- apply button
229        ApplyAction applyAction = buildApplyAction();
230        modelTagConflictResolver.addPropertyChangeListener(applyAction);
231        modelRelConflictResolver.addPropertyChangeListener(applyAction);
232        btnApply = new JButton(applyAction);
233        btnApply.setFocusable(true);
234        pnl.add(btnApply);
235
236        // -- cancel button
237        CancelAction cancelAction = new CancelAction();
238        pnl.add(new JButton(cancelAction));
239
240        // -- help button
241        helpAction = new ContextSensitiveHelpAction();
242        pnl.add(new JButton(helpAction));
243
244        return pnl;
245    }
246
247    /**
248     * Replies the tag conflict resolver model.
249     * @return The tag conflict resolver model.
250     */
251    public TagConflictResolverModel getTagConflictResolverModel() {
252        return modelTagConflictResolver;
253    }
254
255    /**
256     * Replies the relation membership conflict resolver model.
257     * @return The relation membership conflict resolver model.
258     */
259    public RelationMemberConflictResolverModel getRelationMemberConflictResolverModel() {
260        return modelRelConflictResolver;
261    }
262
263    /**
264     * Replies true if all tag and relation member conflicts have been decided.
265     *
266     * @return true if all tag and relation member conflicts have been decided; false otherwise
267     */
268    public boolean isResolvedCompletely() {
269        return modelTagConflictResolver.isResolvedCompletely()
270            && modelRelConflictResolver.isResolvedCompletely();
271    }
272
273    /**
274     * Builds the list of tag change commands.
275     * @param primitive target primitive
276     * @param tc all resolutions
277     * @return the list of tag change commands
278     */
279    protected List<Command> buildTagChangeCommand(OsmPrimitive primitive, TagCollection tc) {
280        return primitiveResolver.buildTagChangeCommand(primitive, tc);
281    }
282
283    /**
284     * Replies the list of {@link Command commands} needed to apply resolution choices.
285     * @return The list of {@link Command commands} needed to apply resolution choices.
286     */
287    public List<Command> buildResolutionCommands() {
288        List<Command> cmds = primitiveResolver.buildResolutionCommands(targetPrimitive);
289        Command cmd = pnlRelationMemberConflictResolver.buildTagApplyCommands(modelRelConflictResolver
290                .getModifiedRelations(targetPrimitive));
291        if (cmd != null) {
292            cmds.add(cmd);
293        }
294        return cmds;
295    }
296
297    /**
298     * Prepares the default decisions for populated tag and relation membership conflicts.
299     */
300    public void prepareDefaultDecisions() {
301        prepareDefaultDecisions(true);
302    }
303
304    /**
305     * Prepares the default decisions for populated tag and relation membership conflicts.
306     * @param fireEvent {@code true} to call {@code fireTableDataChanged} (can be a slow operation)
307     * @since 11626
308     */
309    private void prepareDefaultDecisions(boolean fireEvent) {
310        modelTagConflictResolver.prepareDefaultTagDecisions(fireEvent);
311        modelRelConflictResolver.prepareDefaultRelationDecisions(fireEvent);
312    }
313
314    /**
315     * Builds empty conflicts panel.
316     * @return empty conflicts panel
317     */
318    protected JPanel buildEmptyConflictsPanel() {
319        JPanel pnl = new JPanel(new BorderLayout());
320        pnl.add(new JLabel(tr("No conflicts to resolve")));
321        return pnl;
322    }
323
324    /**
325     * Prepares GUI before conflict resolution starts.
326     */
327    protected void prepareGUIBeforeConflictResolutionStarts() {
328        getContentPane().removeAll();
329
330        if (modelRelConflictResolver.getNumDecisions() > 0 && modelTagConflictResolver.getNumDecisions() > 0) {
331            // display both, the dialog for resolving relation conflicts and for resolving tag conflicts
332            spTagConflictTypes.setTopComponent(pnlTagConflictResolver);
333            spTagConflictTypes.setBottomComponent(pnlRelationMemberConflictResolver);
334            getContentPane().add(spTagConflictTypes, BorderLayout.CENTER);
335        } else if (modelRelConflictResolver.getNumDecisions() > 0) {
336            // relation conflicts only
337            getContentPane().add(pnlRelationMemberConflictResolver, BorderLayout.CENTER);
338        } else if (modelTagConflictResolver.getNumDecisions() > 0) {
339            // tag conflicts only
340            getContentPane().add(pnlTagConflictResolver, BorderLayout.CENTER);
341        } else {
342            getContentPane().add(buildEmptyConflictsPanel(), BorderLayout.CENTER);
343        }
344
345        getContentPane().add(pnlButtons, BorderLayout.SOUTH);
346        validate();
347        adjustDividerLocation();
348        pnlRelationMemberConflictResolver.prepareForEditing();
349    }
350
351    /**
352     * Sets whether this dialog has been closed with "Apply".
353     * @param applied {@code true} if this dialog has been closed with "Apply"
354     */
355    protected void setApplied(boolean applied) {
356        this.applied = applied;
357    }
358
359    /**
360     * Determines if this dialog has been closed with "Apply".
361     * @return true if this dialog has been closed with "Apply", false otherwise.
362     */
363    public boolean isApplied() {
364        return applied;
365    }
366
367    @Override
368    public void setVisible(boolean visible) {
369        if (visible) {
370            prepareGUIBeforeConflictResolutionStarts();
371            setMinimumSize(new Dimension(400, 400));
372            new WindowGeometry(getClass().getName() + ".geometry", WindowGeometry.centerInWindow(MainApplication.getMainFrame(),
373                    new Dimension(800, 600))).applySafe(this);
374            setApplied(false);
375            btnApply.requestFocusInWindow();
376        } else if (isShowing()) { // Avoid IllegalComponentStateException like in #8775
377            new WindowGeometry(this).remember(getClass().getName() + ".geometry");
378        }
379        super.setVisible(visible);
380    }
381
382    /**
383     * Cancel action.
384     */
385    protected class CancelAction extends AbstractAction {
386
387        /**
388         * Constructs a new {@code CancelAction}.
389         */
390        public CancelAction() {
391            putValue(Action.SHORT_DESCRIPTION, tr("Cancel conflict resolution"));
392            putValue(Action.NAME, tr("Cancel"));
393            new ImageProvider("cancel").getResource().attachImageIcon(this);
394            setEnabled(true);
395        }
396
397        @Override
398        public void actionPerformed(ActionEvent arg0) {
399            setVisible(false);
400        }
401    }
402
403    /**
404     * Apply action.
405     */
406    protected class ApplyAction extends AbstractAction implements PropertyChangeListener {
407
408        /**
409         * Constructs a new {@code ApplyAction}.
410         */
411        public ApplyAction() {
412            putValue(Action.SHORT_DESCRIPTION, tr("Apply resolved conflicts"));
413            putValue(Action.NAME, tr("Apply"));
414            new ImageProvider("ok").getResource().attachImageIcon(this);
415            updateEnabledState();
416        }
417
418        @Override
419        public void actionPerformed(ActionEvent arg0) {
420            setApplied(true);
421            setVisible(false);
422            pnlTagConflictResolver.rememberPreferences();
423        }
424
425        /**
426         * Updates enabled state.
427         */
428        protected final void updateEnabledState() {
429            setEnabled(modelTagConflictResolver.isResolvedCompletely()
430                    && modelRelConflictResolver.isResolvedCompletely());
431        }
432
433        @Override
434        public void propertyChange(PropertyChangeEvent evt) {
435            if (evt.getPropertyName().equals(TagConflictResolverModel.NUM_CONFLICTS_PROP)) {
436                updateEnabledState();
437            }
438            if (evt.getPropertyName().equals(RelationMemberConflictResolverModel.NUM_CONFLICTS_PROP)) {
439                updateEnabledState();
440            }
441        }
442    }
443
444    private void adjustDividerLocation() {
445        int numTagDecisions = modelTagConflictResolver.getNumDecisions();
446        int numRelationDecisions = modelRelConflictResolver.getNumDecisions();
447        if (numTagDecisions > 0 && numRelationDecisions > 0) {
448            double nTop = 1.0 + numTagDecisions;
449            double nBottom = 2.5 + numRelationDecisions;
450            spTagConflictTypes.setDividerLocation(nTop/(nTop+nBottom));
451        }
452    }
453
454    class AdjustDividerLocationAction extends WindowAdapter {
455        @Override
456        public void windowOpened(WindowEvent e) {
457            adjustDividerLocation();
458        }
459    }
460
461    /**
462     * Replies the list of {@link Command commands} needed to resolve specified conflicts,
463     * by displaying if necessary a {@link CombinePrimitiveResolverDialog} to the user.
464     * This dialog will allow the user to choose conflict resolution actions.
465     *
466     * Non-expert users are informed first of the meaning of these operations, allowing them to cancel.
467     *
468     * @param tagsOfPrimitives The tag collection of the primitives to be combined.
469     *                         Should generally be equal to {@code TagCollection.unionOfAllPrimitives(primitives)}
470     * @param primitives The primitives to be combined
471     * @param targetPrimitives The primitives the collection of primitives are merged or combined to.
472     * @return The list of {@link Command commands} needed to apply resolution actions.
473     * @throws UserCancelException If the user cancelled a dialog.
474     */
475    public static List<Command> launchIfNecessary(
476            final TagCollection tagsOfPrimitives,
477            final Collection<? extends OsmPrimitive> primitives,
478            final Collection<? extends OsmPrimitive> targetPrimitives) throws UserCancelException {
479
480        CheckParameterUtil.ensureParameterNotNull(tagsOfPrimitives, "tagsOfPrimitives");
481        CheckParameterUtil.ensureParameterNotNull(primitives, "primitives");
482        CheckParameterUtil.ensureParameterNotNull(targetPrimitives, "targetPrimitives");
483
484        final TagCollection completeWayTags = new TagCollection(tagsOfPrimitives);
485        TagConflictResolutionUtil.applyAutomaticTagConflictResolution(completeWayTags);
486        TagConflictResolutionUtil.normalizeTagCollectionBeforeEditing(completeWayTags, primitives);
487        final TagCollection tagsToEdit = new TagCollection(completeWayTags);
488        TagConflictResolutionUtil.completeTagCollectionForEditing(tagsToEdit);
489
490        final Set<Relation> parentRelations = OsmPrimitive.getParentRelations(primitives);
491
492        // Show information dialogs about conflicts to non-experts
493        if (!ExpertToggleAction.isExpert()) {
494            // Tag conflicts
495            if (!completeWayTags.isApplicableToPrimitive()) {
496                informAboutTagConflicts(primitives, completeWayTags);
497            }
498            // Relation membership conflicts
499            if (!parentRelations.isEmpty()) {
500                informAboutRelationMembershipConflicts(primitives, parentRelations);
501            }
502        }
503
504        final List<Command> cmds = new LinkedList<>();
505
506        final TagConflictResolverModel tagModel = new TagConflictResolverModel();
507        final RelationMemberConflictResolverModel relModel = new RelationMemberConflictResolverModel();
508
509        tagModel.populate(tagsToEdit, completeWayTags.getKeysWithMultipleValues(), false);
510        relModel.populate(parentRelations, primitives, false);
511        tagModel.prepareDefaultTagDecisions(false);
512        relModel.prepareDefaultRelationDecisions(false);
513
514        if (tagModel.isResolvedCompletely() && relModel.isResolvedCompletely()) {
515            // Build commands without need of dialog
516            CombinePrimitiveResolver resolver = new CombinePrimitiveResolver(tagModel, relModel);
517            for (OsmPrimitive i : targetPrimitives) {
518                cmds.addAll(resolver.buildResolutionCommands(i));
519            }
520        } else if (!GraphicsEnvironment.isHeadless()) {
521            UserCancelException canceled = GuiHelper.runInEDTAndWaitAndReturn(() -> {
522                // Build conflict resolution dialog
523                final CombinePrimitiveResolverDialog dialog = new CombinePrimitiveResolverDialog(
524                        MainApplication.getMainFrame(), tagModel, relModel);
525
526                // Ensure a proper title is displayed instead of a previous target (fix #7925)
527                if (targetPrimitives.size() == 1) {
528                    dialog.setTargetPrimitive(targetPrimitives.iterator().next(), false);
529                } else {
530                    dialog.setTargetPrimitive(null, false);
531                }
532
533                // Resolve tag conflicts
534                GuiHelper.runInEDTAndWait(() -> {
535                    tagModel.fireTableDataChanged();
536                    relModel.fireTableDataChanged();
537                    dialog.updateTitle();
538                });
539                dialog.setVisible(true);
540                if (!dialog.isApplied()) {
541                    dialog.dispose();
542                    return new UserCancelException();
543                }
544
545                // Build commands
546                for (OsmPrimitive i : targetPrimitives) {
547                    dialog.setTargetPrimitive(i, false);
548                    cmds.addAll(dialog.buildResolutionCommands());
549                }
550                dialog.dispose();
551                return null;
552            });
553            if (canceled != null) {
554                throw canceled;
555            }
556        }
557        return cmds;
558    }
559
560    /**
561     * Inform a non-expert user about what relation membership conflict resolution means.
562     * @param primitives The primitives to be combined
563     * @param parentRelations The parent relations of the primitives
564     * @throws UserCancelException If the user cancels the dialog.
565     */
566    protected static void informAboutRelationMembershipConflicts(
567            final Collection<? extends OsmPrimitive> primitives,
568            final Set<Relation> parentRelations) throws UserCancelException {
569        /* I18n: object count < 2 is not possible */
570        String msg = trn("You are about to combine {1} object, "
571                + "which is part of {0} relation:<br/>{2}"
572                + "Combining these objects may break this relation. If you are unsure, please cancel this operation.<br/>"
573                + "If you want to continue, you are shown a dialog to decide how to adapt the relation.<br/><br/>"
574                + "Do you want to continue?",
575                "You are about to combine {1} objects, "
576                + "which are part of {0} relations:<br/>{2}"
577                + "Combining these objects may break these relations. If you are unsure, please cancel this operation.<br/>"
578                + "If you want to continue, you are shown a dialog to decide how to adapt the relations.<br/><br/>"
579                + "Do you want to continue?",
580                parentRelations.size(), parentRelations.size(), primitives.size(),
581                DefaultNameFormatter.getInstance().formatAsHtmlUnorderedList(parentRelations, 20));
582
583        if (!ConditionalOptionPaneUtil.showConfirmationDialog(
584                "combine_tags",
585                MainApplication.getMainFrame(),
586                "<html>" + msg + "</html>",
587                tr("Combine confirmation"),
588                JOptionPane.YES_NO_OPTION,
589                JOptionPane.QUESTION_MESSAGE,
590                JOptionPane.YES_OPTION)) {
591            throw new UserCancelException();
592        }
593    }
594
595    /**
596     * Inform a non-expert user about what tag conflict resolution means.
597     * @param primitives The primitives to be combined
598     * @param normalizedTags The normalized tag collection of the primitives to be combined
599     * @throws UserCancelException If the user cancels the dialog.
600     */
601    protected static void informAboutTagConflicts(
602            final Collection<? extends OsmPrimitive> primitives,
603            final TagCollection normalizedTags) throws UserCancelException {
604        String conflicts = normalizedTags.getKeysWithMultipleValues().stream().map(
605                key -> getKeyDescription(key, normalizedTags)).collect(StreamUtils.toHtmlList());
606        String msg = /* for correct i18n of plural forms - see #9110 */ trn("You are about to combine {0} objects, "
607                + "but the following tags are used conflictingly:<br/>{1}"
608                + "If these objects are combined, the resulting object may have unwanted tags.<br/>"
609                + "If you want to continue, you are shown a dialog to fix the conflicting tags.<br/><br/>"
610                + "Do you want to continue?", "You are about to combine {0} objects, "
611                + "but the following tags are used conflictingly:<br/>{1}"
612                + "If these objects are combined, the resulting object may have unwanted tags.<br/>"
613                + "If you want to continue, you are shown a dialog to fix the conflicting tags.<br/><br/>"
614                + "Do you want to continue?",
615                primitives.size(), primitives.size(), conflicts);
616
617        if (!ConditionalOptionPaneUtil.showConfirmationDialog(
618                "combine_tags",
619                MainApplication.getMainFrame(),
620                "<html>" + msg + "</html>",
621                tr("Combine confirmation"),
622                JOptionPane.YES_NO_OPTION,
623                JOptionPane.QUESTION_MESSAGE,
624                JOptionPane.YES_OPTION)) {
625            throw new UserCancelException();
626        }
627    }
628
629    private static String getKeyDescription(String key, TagCollection normalizedTags) {
630        String values = normalizedTags.getValues(key)
631                .stream()
632                .map(x -> (x == null || x.isEmpty()) ? tr("<i>missing</i>") : x)
633                .collect(Collectors.joining(tr(", ")));
634        return tr("{0} ({1})", key, values);
635    }
636
637    @Override
638    public void dispose() {
639        setTargetPrimitive(null, false);
640        super.dispose();
641    }
642}