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