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