001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.dialogs;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.event.ActionEvent;
007import java.awt.event.KeyEvent;
008import java.awt.event.MouseEvent;
009import java.io.IOException;
010import java.lang.reflect.InvocationTargetException;
011import java.util.ArrayList;
012import java.util.Collection;
013import java.util.Enumeration;
014import java.util.HashSet;
015import java.util.LinkedList;
016import java.util.List;
017import java.util.Set;
018
019import javax.swing.AbstractAction;
020import javax.swing.JComponent;
021import javax.swing.JOptionPane;
022import javax.swing.JPopupMenu;
023import javax.swing.SwingUtilities;
024import javax.swing.event.TreeSelectionEvent;
025import javax.swing.event.TreeSelectionListener;
026import javax.swing.tree.DefaultMutableTreeNode;
027import javax.swing.tree.TreeNode;
028import javax.swing.tree.TreePath;
029
030import org.openstreetmap.josm.Main;
031import org.openstreetmap.josm.actions.AbstractSelectAction;
032import org.openstreetmap.josm.actions.AutoScaleAction;
033import org.openstreetmap.josm.actions.relation.EditRelationAction;
034import org.openstreetmap.josm.command.Command;
035import org.openstreetmap.josm.data.SelectionChangedListener;
036import org.openstreetmap.josm.data.osm.DataSet;
037import org.openstreetmap.josm.data.osm.Node;
038import org.openstreetmap.josm.data.osm.OsmPrimitive;
039import org.openstreetmap.josm.data.osm.WaySegment;
040import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
041import org.openstreetmap.josm.data.validation.OsmValidator;
042import org.openstreetmap.josm.data.validation.TestError;
043import org.openstreetmap.josm.data.validation.ValidatorVisitor;
044import org.openstreetmap.josm.gui.MapView;
045import org.openstreetmap.josm.gui.MapView.LayerChangeListener;
046import org.openstreetmap.josm.gui.PleaseWaitRunnable;
047import org.openstreetmap.josm.gui.PopupMenuHandler;
048import org.openstreetmap.josm.gui.SideButton;
049import org.openstreetmap.josm.gui.dialogs.validator.ValidatorTreePanel;
050import org.openstreetmap.josm.gui.layer.Layer;
051import org.openstreetmap.josm.gui.layer.OsmDataLayer;
052import org.openstreetmap.josm.gui.preferences.validator.ValidatorPreference;
053import org.openstreetmap.josm.gui.progress.ProgressMonitor;
054import org.openstreetmap.josm.gui.widgets.PopupMenuLauncher;
055import org.openstreetmap.josm.io.OsmTransferException;
056import org.openstreetmap.josm.tools.ImageProvider;
057import org.openstreetmap.josm.tools.InputMapUtils;
058import org.openstreetmap.josm.tools.Shortcut;
059import org.xml.sax.SAXException;
060
061/**
062 * A small tool dialog for displaying the current errors. The selection manager
063 * respects clicks into the selection list. Ctrl-click will remove entries from
064 * the list while single click will make the clicked entry the only selection.
065 *
066 * @author frsantos
067 */
068public class ValidatorDialog extends ToggleDialog implements SelectionChangedListener, LayerChangeListener {
069
070    /** The display tree */
071    public ValidatorTreePanel tree;
072
073    /** The fix button */
074    private final SideButton fixButton;
075    /** The ignore button */
076    private final SideButton ignoreButton;
077    /** The select button */
078    private final SideButton selectButton;
079    /** The lookup button */
080    private final SideButton lookupButton;
081
082    private final JPopupMenu popupMenu = new JPopupMenu();
083    private final transient PopupMenuHandler popupMenuHandler = new PopupMenuHandler(popupMenu);
084
085    /** Last selected element */
086    private DefaultMutableTreeNode lastSelectedNode;
087
088    private transient OsmDataLayer linkedLayer;
089
090    /**
091     * Constructor
092     */
093    public ValidatorDialog() {
094        super(tr("Validation Results"), "validator", tr("Open the validation window."),
095                Shortcut.registerShortcut("subwindow:validator", tr("Toggle: {0}", tr("Validation results")),
096                        KeyEvent.VK_V, Shortcut.ALT_SHIFT), 150, false, ValidatorPreference.class);
097
098        popupMenuHandler.addAction(Main.main.menu.autoScaleActions.get("problem"));
099        popupMenuHandler.addAction(new EditRelationAction());
100
101        tree = new ValidatorTreePanel();
102        tree.addMouseListener(new MouseEventHandler());
103        addTreeSelectionListener(new SelectionWatch());
104        InputMapUtils.unassignCtrlShiftUpDown(tree, JComponent.WHEN_FOCUSED);
105
106        List<SideButton> buttons = new LinkedList<>();
107
108        selectButton = new SideButton(new AbstractSelectAction() {
109            @Override
110            public void actionPerformed(ActionEvent e) {
111                setSelectedItems();
112            }
113        });
114        InputMapUtils.addEnterAction(tree, selectButton.getAction());
115
116        selectButton.setEnabled(false);
117        buttons.add(selectButton);
118
119        lookupButton = new SideButton(new AbstractAction() {
120            {
121                putValue(NAME, tr("Lookup"));
122                putValue(SHORT_DESCRIPTION, tr("Looks up the selected primitives in the error list."));
123                putValue(SMALL_ICON, ImageProvider.get("dialogs", "search"));
124            }
125
126            @Override
127            public void actionPerformed(ActionEvent e) {
128                final DataSet ds = Main.main.getCurrentDataSet();
129                if (ds == null) {
130                    return;
131                }
132                tree.selectRelatedErrors(ds.getSelected());
133            }
134        });
135
136        buttons.add(lookupButton);
137
138        buttons.add(new SideButton(Main.main.validator.validateAction));
139
140        fixButton = new SideButton(new AbstractAction() {
141            {
142                putValue(NAME, tr("Fix"));
143                putValue(SHORT_DESCRIPTION,  tr("Fix the selected issue."));
144                putValue(SMALL_ICON, ImageProvider.get("dialogs", "fix"));
145            }
146            @Override
147            public void actionPerformed(ActionEvent e) {
148                fixErrors();
149            }
150        });
151        fixButton.setEnabled(false);
152        buttons.add(fixButton);
153
154        if (Main.pref.getBoolean(ValidatorPreference.PREF_USE_IGNORE, true)) {
155            ignoreButton = new SideButton(new AbstractAction() {
156                {
157                    putValue(NAME, tr("Ignore"));
158                    putValue(SHORT_DESCRIPTION,  tr("Ignore the selected issue next time."));
159                    putValue(SMALL_ICON, ImageProvider.get("dialogs", "fix"));
160                }
161                @Override
162                public void actionPerformed(ActionEvent e) {
163                    ignoreErrors();
164                }
165            });
166            ignoreButton.setEnabled(false);
167            buttons.add(ignoreButton);
168        } else {
169            ignoreButton = null;
170        }
171        createLayout(tree, true, buttons);
172    }
173
174    @Override
175    public void showNotify() {
176        DataSet.addSelectionListener(this);
177        DataSet ds = Main.main.getCurrentDataSet();
178        if (ds != null) {
179            updateSelection(ds.getAllSelected());
180        }
181        MapView.addLayerChangeListener(this);
182        Layer activeLayer = Main.map.mapView.getActiveLayer();
183        if (activeLayer != null) {
184            activeLayerChange(null, activeLayer);
185        }
186    }
187
188    @Override
189    public void hideNotify() {
190        MapView.removeLayerChangeListener(this);
191        DataSet.removeSelectionListener(this);
192    }
193
194    @Override
195    public void setVisible(boolean v) {
196        if (tree != null) {
197            tree.setVisible(v);
198        }
199        super.setVisible(v);
200        Main.map.repaint();
201    }
202
203    /**
204     * Fix selected errors
205     */
206    @SuppressWarnings("unchecked")
207    private void fixErrors() {
208        TreePath[] selectionPaths = tree.getSelectionPaths();
209        if (selectionPaths == null)
210            return;
211
212        Set<DefaultMutableTreeNode> processedNodes = new HashSet<>();
213
214        List<TestError> errorsToFix = new LinkedList<>();
215        for (TreePath path : selectionPaths) {
216            DefaultMutableTreeNode node = (DefaultMutableTreeNode) path.getLastPathComponent();
217            if (node == null) {
218                continue;
219            }
220
221            Enumeration<TreeNode> children = node.breadthFirstEnumeration();
222            while (children.hasMoreElements()) {
223                DefaultMutableTreeNode childNode = (DefaultMutableTreeNode) children.nextElement();
224                if (processedNodes.contains(childNode)) {
225                    continue;
226                }
227
228                processedNodes.add(childNode);
229                Object nodeInfo = childNode.getUserObject();
230                if (nodeInfo instanceof TestError) {
231                    errorsToFix.add((TestError) nodeInfo);
232                }
233            }
234        }
235
236        // run fix task asynchronously
237        //
238        FixTask fixTask = new FixTask(errorsToFix);
239        Main.worker.submit(fixTask);
240    }
241
242    /**
243     * Set selected errors to ignore state
244     */
245    @SuppressWarnings("unchecked")
246    private void ignoreErrors() {
247        int asked = JOptionPane.DEFAULT_OPTION;
248        boolean changed = false;
249        TreePath[] selectionPaths = tree.getSelectionPaths();
250        if (selectionPaths == null)
251            return;
252
253        Set<DefaultMutableTreeNode> processedNodes = new HashSet<>();
254        for (TreePath path : selectionPaths) {
255            DefaultMutableTreeNode node = (DefaultMutableTreeNode) path.getLastPathComponent();
256            if (node == null) {
257                continue;
258            }
259
260            Object mainNodeInfo = node.getUserObject();
261            if (!(mainNodeInfo instanceof TestError)) {
262                Set<String> state = new HashSet<>();
263                // ask if the whole set should be ignored
264                if (asked == JOptionPane.DEFAULT_OPTION) {
265                    String[] a = new String[] {tr("Whole group"), tr("Single elements"), tr("Nothing")};
266                    asked = JOptionPane.showOptionDialog(Main.parent, tr("Ignore whole group or individual elements?"),
267                            tr("Ignoring elements"), JOptionPane.YES_NO_CANCEL_OPTION, JOptionPane.WARNING_MESSAGE, null,
268                            a, a[1]);
269                }
270                if (asked == JOptionPane.YES_NO_OPTION) {
271                    Enumeration<TreeNode> children = node.breadthFirstEnumeration();
272                    while (children.hasMoreElements()) {
273                        DefaultMutableTreeNode childNode = (DefaultMutableTreeNode) children.nextElement();
274                        if (processedNodes.contains(childNode)) {
275                            continue;
276                        }
277
278                        processedNodes.add(childNode);
279                        Object nodeInfo = childNode.getUserObject();
280                        if (nodeInfo instanceof TestError) {
281                            TestError err = (TestError) nodeInfo;
282                            err.setIgnored(true);
283                            changed = true;
284                            state.add(node.getDepth() == 1 ? err.getIgnoreSubGroup() : err.getIgnoreGroup());
285                        }
286                    }
287                    for (String s : state) {
288                        OsmValidator.addIgnoredError(s);
289                    }
290                    continue;
291                } else if (asked == JOptionPane.CANCEL_OPTION || asked == JOptionPane.CLOSED_OPTION) {
292                    continue;
293                }
294            }
295
296            Enumeration<TreeNode> children = node.breadthFirstEnumeration();
297            while (children.hasMoreElements()) {
298                DefaultMutableTreeNode childNode = (DefaultMutableTreeNode) children.nextElement();
299                if (processedNodes.contains(childNode)) {
300                    continue;
301                }
302
303                processedNodes.add(childNode);
304                Object nodeInfo = childNode.getUserObject();
305                if (nodeInfo instanceof TestError) {
306                    TestError error = (TestError) nodeInfo;
307                    String state = error.getIgnoreState();
308                    if (state != null) {
309                        OsmValidator.addIgnoredError(state);
310                    }
311                    changed = true;
312                    error.setIgnored(true);
313                }
314            }
315        }
316        if (changed) {
317            tree.resetErrors();
318            OsmValidator.saveIgnoredErrors();
319            Main.map.repaint();
320        }
321    }
322
323    /**
324     * Sets the selection of the map to the current selected items.
325     */
326    @SuppressWarnings("unchecked")
327    private void setSelectedItems() {
328        if (tree == null)
329            return;
330
331        Collection<OsmPrimitive> sel = new HashSet<>(40);
332
333        TreePath[] selectedPaths = tree.getSelectionPaths();
334        if (selectedPaths == null)
335            return;
336
337        for (TreePath path : selectedPaths) {
338            DefaultMutableTreeNode node = (DefaultMutableTreeNode) path.getLastPathComponent();
339            Enumeration<TreeNode> children = node.breadthFirstEnumeration();
340            while (children.hasMoreElements()) {
341                DefaultMutableTreeNode childNode = (DefaultMutableTreeNode) children.nextElement();
342                Object nodeInfo = childNode.getUserObject();
343                if (nodeInfo instanceof TestError) {
344                    TestError error = (TestError) nodeInfo;
345                    sel.addAll(error.getSelectablePrimitives());
346                }
347            }
348        }
349        DataSet ds = Main.main.getCurrentDataSet();
350        if (ds != null) {
351            ds.setSelected(sel);
352        }
353    }
354
355    /**
356     * Checks for fixes in selected element and, if needed, adds to the sel
357     * parameter all selected elements
358     *
359     * @param sel
360     *            The collection where to add all selected elements
361     * @param addSelected
362     *            if true, add all selected elements to collection
363     * @return whether the selected elements has any fix
364     */
365    @SuppressWarnings("unchecked")
366    private boolean setSelection(Collection<OsmPrimitive> sel, boolean addSelected) {
367        boolean hasFixes = false;
368
369        DefaultMutableTreeNode node = (DefaultMutableTreeNode) tree.getLastSelectedPathComponent();
370        if (lastSelectedNode != null && !lastSelectedNode.equals(node)) {
371            Enumeration<TreeNode> children = lastSelectedNode.breadthFirstEnumeration();
372            while (children.hasMoreElements()) {
373                DefaultMutableTreeNode childNode = (DefaultMutableTreeNode) children.nextElement();
374                Object nodeInfo = childNode.getUserObject();
375                if (nodeInfo instanceof TestError) {
376                    TestError error = (TestError) nodeInfo;
377                    error.setSelected(false);
378                }
379            }
380        }
381
382        lastSelectedNode = node;
383        if (node == null)
384            return hasFixes;
385
386        Enumeration<TreeNode> children = node.breadthFirstEnumeration();
387        while (children.hasMoreElements()) {
388            DefaultMutableTreeNode childNode = (DefaultMutableTreeNode) children.nextElement();
389            Object nodeInfo = childNode.getUserObject();
390            if (nodeInfo instanceof TestError) {
391                TestError error = (TestError) nodeInfo;
392                error.setSelected(true);
393
394                hasFixes = hasFixes || error.isFixable();
395                if (addSelected) {
396                    sel.addAll(error.getSelectablePrimitives());
397                }
398            }
399        }
400        selectButton.setEnabled(true);
401        if (ignoreButton != null) {
402            ignoreButton.setEnabled(true);
403        }
404
405        return hasFixes;
406    }
407
408    @Override
409    public void activeLayerChange(Layer oldLayer, Layer newLayer) {
410        if (newLayer instanceof OsmDataLayer) {
411            linkedLayer = (OsmDataLayer) newLayer;
412            tree.setErrorList(linkedLayer.validationErrors);
413        }
414    }
415
416    @Override
417    public void layerAdded(Layer newLayer) {
418        // Do nothing
419    }
420
421    @Override
422    public void layerRemoved(Layer oldLayer) {
423        if (oldLayer == linkedLayer) {
424            tree.setErrorList(new ArrayList<TestError>());
425        }
426    }
427
428    /**
429     * Add a tree selection listener to the validator tree.
430     * @param listener the TreeSelectionListener
431     * @since 5958
432     */
433    public void addTreeSelectionListener(TreeSelectionListener listener) {
434        tree.addTreeSelectionListener(listener);
435    }
436
437    /**
438     * Remove the given tree selection listener from the validator tree.
439     * @param listener the TreeSelectionListener
440     * @since 5958
441     */
442    public void removeTreeSelectionListener(TreeSelectionListener listener) {
443        tree.removeTreeSelectionListener(listener);
444    }
445
446    /**
447     * Replies the popup menu handler.
448     * @return The popup menu handler
449     * @since 5958
450     */
451    public PopupMenuHandler getPopupMenuHandler() {
452        return popupMenuHandler;
453    }
454
455    /**
456     * Replies the currently selected error, or {@code null}.
457     * @return The selected error, if any.
458     * @since 5958
459     */
460    public TestError getSelectedError() {
461        Object comp = tree.getLastSelectedPathComponent();
462        if (comp instanceof DefaultMutableTreeNode) {
463            Object object = ((DefaultMutableTreeNode) comp).getUserObject();
464            if (object instanceof TestError) {
465                return (TestError) object;
466            }
467        }
468        return null;
469    }
470
471    /**
472     * Watches for double clicks and launches the popup menu.
473     */
474    class MouseEventHandler extends PopupMenuLauncher {
475
476        MouseEventHandler() {
477            super(popupMenu);
478        }
479
480        @Override
481        public void mouseClicked(MouseEvent e) {
482            fixButton.setEnabled(false);
483            if (ignoreButton != null) {
484                ignoreButton.setEnabled(false);
485            }
486            selectButton.setEnabled(false);
487
488            boolean isDblClick = isDoubleClick(e);
489
490            Collection<OsmPrimitive> sel = isDblClick ? new HashSet<OsmPrimitive>(40) : null;
491
492            boolean hasFixes = setSelection(sel, isDblClick);
493            fixButton.setEnabled(hasFixes);
494
495            if (isDblClick) {
496                Main.main.getCurrentDataSet().setSelected(sel);
497                if (Main.pref.getBoolean("validator.autozoom", false)) {
498                    AutoScaleAction.zoomTo(sel);
499                }
500            }
501        }
502
503        @Override public void launch(MouseEvent e) {
504            TreePath selPath = tree.getPathForLocation(e.getX(), e.getY());
505            if (selPath == null)
506                return;
507            DefaultMutableTreeNode node = (DefaultMutableTreeNode) selPath.getPathComponent(selPath.getPathCount() - 1);
508            if (!(node.getUserObject() instanceof TestError))
509                return;
510            super.launch(e);
511        }
512
513    }
514
515    /**
516     * Watches for tree selection.
517     */
518    public class SelectionWatch implements TreeSelectionListener {
519        @Override
520        public void valueChanged(TreeSelectionEvent e) {
521            fixButton.setEnabled(false);
522            if (ignoreButton != null) {
523                ignoreButton.setEnabled(false);
524            }
525            selectButton.setEnabled(false);
526
527            Collection<OsmPrimitive> sel = new HashSet<>();
528            boolean hasFixes = setSelection(sel, true);
529            fixButton.setEnabled(hasFixes);
530            popupMenuHandler.setPrimitives(sel);
531            if (Main.map != null) {
532                Main.map.repaint();
533            }
534        }
535    }
536
537    public static class ValidatorBoundingXYVisitor extends BoundingXYVisitor implements ValidatorVisitor {
538        @Override
539        public void visit(OsmPrimitive p) {
540            if (p.isUsable()) {
541                p.accept(this);
542            }
543        }
544
545        @Override
546        public void visit(WaySegment ws) {
547            if (ws.lowerIndex < 0 || ws.lowerIndex + 1 >= ws.way.getNodesCount())
548                return;
549            visit(ws.way.getNodes().get(ws.lowerIndex));
550            visit(ws.way.getNodes().get(ws.lowerIndex + 1));
551        }
552
553        @Override
554        public void visit(List<Node> nodes) {
555            for (Node n: nodes) {
556                visit(n);
557            }
558        }
559
560        @Override
561        public void visit(TestError error) {
562            if (error != null) {
563                error.visitHighlighted(this);
564            }
565        }
566    }
567
568    public void updateSelection(Collection<? extends OsmPrimitive> newSelection) {
569        if (!Main.pref.getBoolean(ValidatorPreference.PREF_FILTER_BY_SELECTION, false))
570            return;
571        if (newSelection.isEmpty()) {
572            tree.setFilter(null);
573        }
574        tree.setFilter(new HashSet<>(newSelection));
575    }
576
577    @Override
578    public void selectionChanged(Collection<? extends OsmPrimitive> newSelection) {
579        updateSelection(newSelection);
580    }
581
582    /**
583     * Task for fixing a collection of {@link TestError}s. Can be run asynchronously.
584     *
585     *
586     */
587    class FixTask extends PleaseWaitRunnable {
588        private final Collection<TestError> testErrors;
589        private boolean canceled;
590
591        FixTask(Collection<TestError> testErrors) {
592            super(tr("Fixing errors ..."), false /* don't ignore exceptions */);
593            this.testErrors = testErrors == null ? new ArrayList<TestError>() : testErrors;
594        }
595
596        @Override
597        protected void cancel() {
598            this.canceled = true;
599        }
600
601        @Override
602        protected void finish() {
603            // do nothing
604        }
605
606        protected void fixError(TestError error) throws InterruptedException, InvocationTargetException {
607            if (error.isFixable()) {
608                final Command fixCommand = error.getFix();
609                if (fixCommand != null) {
610                    SwingUtilities.invokeAndWait(new Runnable() {
611                        @Override
612                        public void run() {
613                            Main.main.undoRedo.addNoRedraw(fixCommand);
614                        }
615                    });
616                }
617                // It is wanted to ignore an error if it said fixable, even if fixCommand was null
618                // This is to fix #5764 and #5773:
619                // a delete command, for example, may be null if all concerned primitives have already been deleted
620                error.setIgnored(true);
621            }
622        }
623
624        @Override
625        protected void realRun() throws SAXException, IOException,
626        OsmTransferException {
627            ProgressMonitor monitor = getProgressMonitor();
628            try {
629                monitor.setTicksCount(testErrors.size());
630                int i = 0;
631                SwingUtilities.invokeAndWait(new Runnable() {
632                    @Override
633                    public void run() {
634                        Main.main.getCurrentDataSet().beginUpdate();
635                    }
636                });
637                try {
638                    for (TestError error: testErrors) {
639                        i++;
640                        monitor.subTask(tr("Fixing ({0}/{1}): ''{2}''", i, testErrors.size(), error.getMessage()));
641                        if (this.canceled)
642                            return;
643                        fixError(error);
644                        monitor.worked(1);
645                    }
646                } finally {
647                    SwingUtilities.invokeAndWait(new Runnable() {
648                        @Override
649                        public void run() {
650                            Main.main.getCurrentDataSet().endUpdate();
651                        }
652                    });
653                }
654                monitor.subTask(tr("Updating map ..."));
655                SwingUtilities.invokeAndWait(new Runnable() {
656                    @Override
657                    public void run() {
658                        Main.main.undoRedo.afterAdd();
659                        Main.map.repaint();
660                        tree.resetErrors();
661                        Main.main.getCurrentDataSet().fireSelectionChanged();
662                    }
663                });
664            } catch (InterruptedException | InvocationTargetException e) {
665                // FIXME: signature of realRun should have a generic checked exception we
666                // could throw here
667                throw new RuntimeException(e);
668            } finally {
669                monitor.finishTask();
670            }
671        }
672    }
673}