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