001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.conflict.tags;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005import static org.openstreetmap.josm.tools.I18n.trn;
006
007import java.awt.BorderLayout;
008import java.awt.Component;
009import java.awt.Dimension;
010import java.awt.FlowLayout;
011import java.awt.Font;
012import java.awt.GridBagConstraints;
013import java.awt.GridBagLayout;
014import java.awt.Insets;
015import java.awt.event.ActionEvent;
016import java.beans.PropertyChangeEvent;
017import java.beans.PropertyChangeListener;
018import java.util.ArrayList;
019import java.util.EnumMap;
020import java.util.HashMap;
021import java.util.List;
022import java.util.Map;
023import java.util.Map.Entry;
024import java.util.StringJoiner;
025import java.util.stream.IntStream;
026
027import javax.swing.AbstractAction;
028import javax.swing.Action;
029import javax.swing.ImageIcon;
030import javax.swing.JButton;
031import javax.swing.JDialog;
032import javax.swing.JLabel;
033import javax.swing.JPanel;
034import javax.swing.JTabbedPane;
035import javax.swing.JTable;
036import javax.swing.UIManager;
037import javax.swing.table.DefaultTableModel;
038import javax.swing.table.TableCellRenderer;
039
040import org.openstreetmap.josm.data.osm.OsmPrimitiveType;
041import org.openstreetmap.josm.data.osm.TagCollection;
042import org.openstreetmap.josm.gui.tagging.TagTableColumnModelBuilder;
043import org.openstreetmap.josm.gui.util.GuiHelper;
044import org.openstreetmap.josm.gui.util.WindowGeometry;
045import org.openstreetmap.josm.tools.ImageProvider;
046import org.openstreetmap.josm.tools.InputMapUtils;
047
048/**
049 * This conflict resolution dialog is used when tags are pasted from the clipboard that conflict with the existing ones.
050 */
051public class PasteTagsConflictResolverDialog extends JDialog implements PropertyChangeListener {
052    static final Map<OsmPrimitiveType, String> PANE_TITLES;
053    static {
054        PANE_TITLES = new EnumMap<>(OsmPrimitiveType.class);
055        PANE_TITLES.put(OsmPrimitiveType.NODE, tr("Tags from nodes"));
056        PANE_TITLES.put(OsmPrimitiveType.WAY, tr("Tags from ways"));
057        PANE_TITLES.put(OsmPrimitiveType.RELATION, tr("Tags from relations"));
058    }
059
060    enum Mode {
061        RESOLVING_ONE_TAGCOLLECTION_ONLY,
062        RESOLVING_TYPED_TAGCOLLECTIONS
063    }
064
065    private final TagConflictResolverModel model = new TagConflictResolverModel();
066    private final transient Map<OsmPrimitiveType, TagConflictResolver> resolvers = new EnumMap<>(OsmPrimitiveType.class);
067    private final JTabbedPane tpResolvers = new JTabbedPane();
068    private Mode mode;
069    private boolean canceled;
070
071    private final ImageIcon iconResolved = ImageProvider.get("dialogs/conflict", "tagconflictresolved");
072    private final ImageIcon iconUnresolved = ImageProvider.get("dialogs/conflict", "tagconflictunresolved");
073    private final StatisticsTableModel statisticsModel = new StatisticsTableModel();
074    private final JPanel pnlTagResolver = new JPanel(new BorderLayout());
075
076    /**
077     * Constructs a new {@code PasteTagsConflictResolverDialog}.
078     * @param owner parent component
079     */
080    public PasteTagsConflictResolverDialog(Component owner) {
081        super(GuiHelper.getFrameForComponent(owner), ModalityType.DOCUMENT_MODAL);
082        build();
083    }
084
085    protected final void build() {
086        setTitle(tr("Conflicts in pasted tags"));
087        for (OsmPrimitiveType type: OsmPrimitiveType.dataValues()) {
088            TagConflictResolverModel tagModel = new TagConflictResolverModel();
089            resolvers.put(type, new TagConflictResolver(tagModel));
090            tagModel.addPropertyChangeListener(this);
091        }
092        getContentPane().setLayout(new GridBagLayout());
093        mode = null;
094        GridBagConstraints gc = new GridBagConstraints();
095        gc.gridx = 0;
096        gc.gridy = 0;
097        gc.fill = GridBagConstraints.HORIZONTAL;
098        gc.weightx = 1.0;
099        gc.weighty = 0.0;
100        getContentPane().add(buildSourceAndTargetInfoPanel(), gc);
101        gc.gridx = 0;
102        gc.gridy = 1;
103        gc.fill = GridBagConstraints.BOTH;
104        gc.weightx = 1.0;
105        gc.weighty = 1.0;
106        getContentPane().add(pnlTagResolver, gc);
107        gc.gridx = 0;
108        gc.gridy = 2;
109        gc.fill = GridBagConstraints.HORIZONTAL;
110        gc.weightx = 1.0;
111        gc.weighty = 0.0;
112        getContentPane().add(buildButtonPanel(), gc);
113        InputMapUtils.addEscapeAction(getRootPane(), new CancelAction());
114    }
115
116    protected JPanel buildButtonPanel() {
117        JPanel pnl = new JPanel(new FlowLayout(FlowLayout.CENTER));
118
119        // -- apply button
120        ApplyAction applyAction = new ApplyAction();
121        model.addPropertyChangeListener(applyAction);
122        for (TagConflictResolver r : resolvers.values()) {
123            r.getModel().addPropertyChangeListener(applyAction);
124        }
125        pnl.add(new JButton(applyAction));
126
127        // -- cancel button
128        CancelAction cancelAction = new CancelAction();
129        pnl.add(new JButton(cancelAction));
130
131        return pnl;
132    }
133
134    protected JPanel buildSourceAndTargetInfoPanel() {
135        JPanel pnl = new JPanel(new BorderLayout());
136        pnl.add(new StatisticsInfoTable(statisticsModel), BorderLayout.CENTER);
137        return pnl;
138    }
139
140    /**
141     * Initializes the conflict resolver for a specific type of primitives
142     *
143     * @param type the type of primitives
144     * @param tc the tags belonging to this type of primitives
145     * @param targetStatistics histogram of paste targets, number of primitives of each type in the paste target
146     */
147    protected void initResolver(OsmPrimitiveType type, TagCollection tc, Map<OsmPrimitiveType, Integer> targetStatistics) {
148        TagConflictResolver resolver = resolvers.get(type);
149        resolver.getModel().populate(tc, tc.getKeysWithMultipleValues());
150        resolver.getModel().prepareDefaultTagDecisions();
151        if (!tc.isEmpty() && targetStatistics.get(type) != null && targetStatistics.get(type) > 0) {
152            tpResolvers.add(PANE_TITLES.get(type), resolver);
153        }
154    }
155
156    /**
157     * Populates the conflict resolver with one tag collection
158     *
159     * @param tagsForAllPrimitives  the tag collection
160     * @param sourceStatistics histogram of tag source, number of primitives of each type in the source
161     * @param targetStatistics histogram of paste targets, number of primitives of each type in the paste target
162     */
163    public void populate(TagCollection tagsForAllPrimitives, Map<OsmPrimitiveType, Integer> sourceStatistics,
164            Map<OsmPrimitiveType, Integer> targetStatistics) {
165        mode = Mode.RESOLVING_ONE_TAGCOLLECTION_ONLY;
166        tagsForAllPrimitives = tagsForAllPrimitives == null ? new TagCollection() : tagsForAllPrimitives;
167        sourceStatistics = sourceStatistics == null ? new HashMap<>() : sourceStatistics;
168        targetStatistics = targetStatistics == null ? new HashMap<>() : targetStatistics;
169
170        // init the resolver
171        //
172        model.populate(tagsForAllPrimitives, tagsForAllPrimitives.getKeysWithMultipleValues());
173        model.prepareDefaultTagDecisions();
174
175        // prepare the dialog with one tag resolver
176        pnlTagResolver.removeAll();
177        pnlTagResolver.add(new TagConflictResolver(model), BorderLayout.CENTER);
178
179        statisticsModel.reset();
180        StatisticsInfo info = new StatisticsInfo();
181        info.numTags = tagsForAllPrimitives.getKeys().size();
182        info.sourceInfo.putAll(sourceStatistics);
183        info.targetInfo.putAll(targetStatistics);
184        statisticsModel.append(info);
185        validate();
186    }
187
188    protected int getNumResolverTabs() {
189        return tpResolvers.getTabCount();
190    }
191
192    protected TagConflictResolver getResolver(int idx) {
193        return (TagConflictResolver) tpResolvers.getComponentAt(idx);
194    }
195
196    /**
197     * Populate the tag conflict resolver with tags for each type of primitives
198     *
199     * @param tagsForNodes the tags belonging to nodes in the paste source
200     * @param tagsForWays the tags belonging to way in the paste source
201     * @param tagsForRelations the tags belonging to relations in the paste source
202     * @param sourceStatistics histogram of tag source, number of primitives of each type in the source
203     * @param targetStatistics histogram of paste targets, number of primitives of each type in the paste target
204     */
205    public void populate(TagCollection tagsForNodes, TagCollection tagsForWays, TagCollection tagsForRelations,
206            Map<OsmPrimitiveType, Integer> sourceStatistics, Map<OsmPrimitiveType, Integer> targetStatistics) {
207        tagsForNodes = (tagsForNodes == null) ? new TagCollection() : tagsForNodes;
208        tagsForWays = (tagsForWays == null) ? new TagCollection() : tagsForWays;
209        tagsForRelations = (tagsForRelations == null) ? new TagCollection() : tagsForRelations;
210        if (tagsForNodes.isEmpty() && tagsForWays.isEmpty() && tagsForRelations.isEmpty()) {
211            populate(null, null, null);
212            return;
213        }
214        tpResolvers.removeAll();
215        initResolver(OsmPrimitiveType.NODE, tagsForNodes, targetStatistics);
216        initResolver(OsmPrimitiveType.WAY, tagsForWays, targetStatistics);
217        initResolver(OsmPrimitiveType.RELATION, tagsForRelations, targetStatistics);
218
219        pnlTagResolver.removeAll();
220        pnlTagResolver.add(tpResolvers, BorderLayout.CENTER);
221        mode = Mode.RESOLVING_TYPED_TAGCOLLECTIONS;
222        validate();
223        statisticsModel.reset();
224        if (!tagsForNodes.isEmpty()) {
225            StatisticsInfo info = new StatisticsInfo();
226            info.numTags = tagsForNodes.getKeys().size();
227            int numTargets = targetStatistics.get(OsmPrimitiveType.NODE) == null ? 0 : targetStatistics.get(OsmPrimitiveType.NODE);
228            if (numTargets > 0) {
229                info.sourceInfo.put(OsmPrimitiveType.NODE, sourceStatistics.get(OsmPrimitiveType.NODE));
230                info.targetInfo.put(OsmPrimitiveType.NODE, numTargets);
231                statisticsModel.append(info);
232            }
233        }
234        if (!tagsForWays.isEmpty()) {
235            StatisticsInfo info = new StatisticsInfo();
236            info.numTags = tagsForWays.getKeys().size();
237            int numTargets = targetStatistics.get(OsmPrimitiveType.WAY) == null ? 0 : targetStatistics.get(OsmPrimitiveType.WAY);
238            if (numTargets > 0) {
239                info.sourceInfo.put(OsmPrimitiveType.WAY, sourceStatistics.get(OsmPrimitiveType.WAY));
240                info.targetInfo.put(OsmPrimitiveType.WAY, numTargets);
241                statisticsModel.append(info);
242            }
243        }
244        if (!tagsForRelations.isEmpty()) {
245            StatisticsInfo info = new StatisticsInfo();
246            info.numTags = tagsForRelations.getKeys().size();
247            int numTargets = targetStatistics.get(OsmPrimitiveType.RELATION) == null ? 0 : targetStatistics.get(OsmPrimitiveType.RELATION);
248            if (numTargets > 0) {
249                info.sourceInfo.put(OsmPrimitiveType.RELATION, sourceStatistics.get(OsmPrimitiveType.RELATION));
250                info.targetInfo.put(OsmPrimitiveType.RELATION, numTargets);
251                statisticsModel.append(info);
252            }
253        }
254
255        IntStream.range(0, getNumResolverTabs())
256                .filter(i -> !getResolver(i).getModel().isResolvedCompletely())
257                .findFirst()
258                .ifPresent(tpResolvers::setSelectedIndex);
259    }
260
261    protected void setCanceled(boolean canceled) {
262        this.canceled = canceled;
263    }
264
265    public boolean isCanceled() {
266        return this.canceled;
267    }
268
269    final class CancelAction extends AbstractAction {
270
271        private CancelAction() {
272            putValue(Action.SHORT_DESCRIPTION, tr("Cancel conflict resolution"));
273            putValue(Action.NAME, tr("Cancel"));
274            new ImageProvider("cancel").getResource().attachImageIcon(this);
275            setEnabled(true);
276        }
277
278        @Override
279        public void actionPerformed(ActionEvent arg0) {
280            setVisible(false);
281            setCanceled(true);
282        }
283    }
284
285    final class ApplyAction extends AbstractAction implements PropertyChangeListener {
286
287        private ApplyAction() {
288            putValue(Action.SHORT_DESCRIPTION, tr("Apply resolved conflicts"));
289            putValue(Action.NAME, tr("Apply"));
290            new ImageProvider("ok").getResource().attachImageIcon(this);
291            updateEnabledState();
292        }
293
294        @Override
295        public void actionPerformed(ActionEvent arg0) {
296            setVisible(false);
297        }
298
299        void updateEnabledState() {
300            if (mode == null) {
301                setEnabled(false);
302            } else if (mode == Mode.RESOLVING_ONE_TAGCOLLECTION_ONLY) {
303                setEnabled(model.isResolvedCompletely());
304            } else {
305                setEnabled(resolvers.values().stream().allMatch(val -> val.getModel().isResolvedCompletely()));
306            }
307        }
308
309        @Override
310        public void propertyChange(PropertyChangeEvent evt) {
311            if (evt.getPropertyName().equals(TagConflictResolverModel.NUM_CONFLICTS_PROP)) {
312                updateEnabledState();
313            }
314        }
315    }
316
317    @Override
318    public void setVisible(boolean visible) {
319        if (visible) {
320            new WindowGeometry(
321                    getClass().getName() + ".geometry",
322                    WindowGeometry.centerOnScreen(new Dimension(600, 400))
323            ).applySafe(this);
324        } else if (isShowing()) { // Avoid IllegalComponentStateException like in #8775
325            new WindowGeometry(this).remember(getClass().getName() + ".geometry");
326        }
327        super.setVisible(visible);
328    }
329
330    /**
331     * Returns conflict resolution.
332     * @return conflict resolution
333     */
334    public TagCollection getResolution() {
335        return model.getResolution();
336    }
337
338    public TagCollection getResolution(OsmPrimitiveType type) {
339        if (type == null) return null;
340        return resolvers.get(type).getModel().getResolution();
341    }
342
343    @Override
344    public void propertyChange(PropertyChangeEvent evt) {
345        if (evt.getPropertyName().equals(TagConflictResolverModel.NUM_CONFLICTS_PROP)) {
346            TagConflictResolverModel tagModel = (TagConflictResolverModel) evt.getSource();
347            for (int i = 0; i < tpResolvers.getTabCount(); i++) {
348                TagConflictResolver resolver = (TagConflictResolver) tpResolvers.getComponentAt(i);
349                if (tagModel == resolver.getModel()) {
350                    tpResolvers.setIconAt(i,
351                            (Integer) evt.getNewValue() == 0 ? iconResolved : iconUnresolved
352                    );
353                }
354            }
355        }
356    }
357
358    static final class StatisticsInfo {
359        int numTags;
360        final Map<OsmPrimitiveType, Integer> sourceInfo;
361        final Map<OsmPrimitiveType, Integer> targetInfo;
362
363        StatisticsInfo() {
364            sourceInfo = new EnumMap<>(OsmPrimitiveType.class);
365            targetInfo = new EnumMap<>(OsmPrimitiveType.class);
366        }
367    }
368
369    static final class StatisticsTableModel extends DefaultTableModel {
370        private static final String[] HEADERS = {tr("Paste ..."), tr("From ..."), tr("To ...") };
371        private final transient List<StatisticsInfo> data = new ArrayList<>();
372
373        @Override
374        public Object getValueAt(int row, int column) {
375            if (row == 0)
376                return HEADERS[column];
377            else if (row -1 < data.size())
378                return data.get(row -1);
379            else
380                return null;
381        }
382
383        @Override
384        public boolean isCellEditable(int row, int column) {
385            return false;
386        }
387
388        @Override
389        public int getRowCount() {
390            return data == null ? 1 : data.size() + 1;
391        }
392
393        void reset() {
394            data.clear();
395        }
396
397        void append(StatisticsInfo info) {
398            data.add(info);
399            fireTableDataChanged();
400        }
401    }
402
403    static final class StatisticsInfoRenderer extends JLabel implements TableCellRenderer {
404        private void reset() {
405            setIcon(null);
406            setText("");
407            setFont(UIManager.getFont("Table.font"));
408        }
409
410        private void renderNumTags(StatisticsInfo info) {
411            if (info == null) return;
412            setText(trn("{0} tag", "{0} tags", info.numTags, info.numTags));
413        }
414
415        private void renderStatistics(Map<OsmPrimitiveType, Integer> stat) {
416            if (stat == null) return;
417            if (stat.isEmpty()) return;
418            if (stat.size() == 1) {
419                setIcon(ImageProvider.get(stat.keySet().iterator().next()));
420            } else {
421                setIcon(ImageProvider.get("data", "object"));
422            }
423            StringJoiner text = new StringJoiner(", ");
424            for (Entry<OsmPrimitiveType, Integer> entry: stat.entrySet()) {
425                OsmPrimitiveType type = entry.getKey();
426                int numPrimitives = entry.getValue() == null ? 0 : entry.getValue();
427                if (numPrimitives == 0) {
428                    continue;
429                }
430                String msg;
431                switch(type) {
432                case NODE: msg = trn("{0} node", "{0} nodes", numPrimitives, numPrimitives); break;
433                case WAY: msg = trn("{0} way", "{0} ways", numPrimitives, numPrimitives); break;
434                case RELATION: msg = trn("{0} relation", "{0} relations", numPrimitives, numPrimitives); break;
435                default: throw new AssertionError();
436                }
437                text.add(msg);
438            }
439            setText(text.toString());
440        }
441
442        private void renderFrom(StatisticsInfo info) {
443            renderStatistics(info.sourceInfo);
444        }
445
446        private void renderTo(StatisticsInfo info) {
447            renderStatistics(info.targetInfo);
448        }
449
450        @Override
451        public Component getTableCellRendererComponent(JTable table, Object value, boolean isSelected,
452                boolean hasFocus, int row, int column) {
453            reset();
454            if (value == null)
455                return this;
456
457            if (row == 0) {
458                setFont(getFont().deriveFont(Font.BOLD));
459                setText((String) value);
460            } else {
461                StatisticsInfo info = (StatisticsInfo) value;
462
463                switch(column) {
464                case 0: renderNumTags(info); break;
465                case 1: renderFrom(info); break;
466                case 2: renderTo(info); break;
467                default: // Do nothing
468                }
469            }
470            return this;
471        }
472    }
473
474    static final class StatisticsInfoTable extends JPanel {
475
476        StatisticsInfoTable(StatisticsTableModel model) {
477            JTable infoTable = new JTable(model,
478                    new TagTableColumnModelBuilder(new StatisticsInfoRenderer(), tr("Paste ..."), tr("From ..."), tr("To ...")).build());
479            infoTable.setShowHorizontalLines(true);
480            infoTable.setShowVerticalLines(false);
481            infoTable.setEnabled(false);
482            setLayout(new BorderLayout());
483            add(infoTable, BorderLayout.CENTER);
484        }
485
486        @Override
487        public Insets getInsets() {
488            Insets insets = super.getInsets();
489            insets.bottom = 20;
490            return insets;
491        }
492    }
493}