001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.dialogs.validator;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.event.KeyListener;
007import java.awt.event.MouseEvent;
008import java.util.ArrayList;
009import java.util.Collection;
010import java.util.Enumeration;
011import java.util.HashSet;
012import java.util.List;
013import java.util.Map;
014import java.util.Map.Entry;
015import java.util.Set;
016import java.util.function.Consumer;
017import java.util.function.Predicate;
018
019import javax.swing.JTree;
020import javax.swing.ToolTipManager;
021import javax.swing.tree.DefaultMutableTreeNode;
022import javax.swing.tree.DefaultTreeModel;
023import javax.swing.tree.TreeNode;
024import javax.swing.tree.TreePath;
025import javax.swing.tree.TreeSelectionModel;
026
027import org.openstreetmap.josm.data.osm.OsmPrimitive;
028import org.openstreetmap.josm.data.osm.event.AbstractDatasetChangedEvent;
029import org.openstreetmap.josm.data.osm.event.DataChangedEvent;
030import org.openstreetmap.josm.data.osm.event.DataSetListener;
031import org.openstreetmap.josm.data.osm.event.DatasetEventManager;
032import org.openstreetmap.josm.data.osm.event.NodeMovedEvent;
033import org.openstreetmap.josm.data.osm.event.PrimitivesAddedEvent;
034import org.openstreetmap.josm.data.osm.event.PrimitivesRemovedEvent;
035import org.openstreetmap.josm.data.osm.event.RelationMembersChangedEvent;
036import org.openstreetmap.josm.data.osm.event.TagsChangedEvent;
037import org.openstreetmap.josm.data.osm.event.WayNodesChangedEvent;
038import org.openstreetmap.josm.data.preferences.sources.ValidatorPrefHelper;
039import org.openstreetmap.josm.data.validation.OsmValidator;
040import org.openstreetmap.josm.data.validation.Severity;
041import org.openstreetmap.josm.data.validation.TestError;
042import org.openstreetmap.josm.gui.util.GuiHelper;
043import org.openstreetmap.josm.tools.Destroyable;
044import org.openstreetmap.josm.tools.ListenerList;
045import org.openstreetmap.josm.tools.Utils;
046
047/**
048 * A panel that displays the error tree. The selection manager
049 * respects clicks into the selection list. Ctrl-click will remove entries from
050 * the list while single click will make the clicked entry the only selection.
051 *
052 * @author frsantos
053 */
054public class ValidatorTreePanel extends JTree implements Destroyable, DataSetListener {
055
056    private static final class GroupTreeNode extends DefaultMutableTreeNode {
057
058        GroupTreeNode(Object userObject) {
059            super(userObject);
060        }
061
062        @Override
063        public String toString() {
064            return tr("{0} ({1})", super.toString(), getLeafCount());
065        }
066    }
067
068    /**
069     * The validation data.
070     */
071    protected DefaultTreeModel valTreeModel = new DefaultTreeModel(new DefaultMutableTreeNode());
072
073    /** The list of errors shown in the tree, normally identical to field validationErrors in current edit layer*/
074    private transient List<TestError> errors;
075
076    /**
077     * If {@link #filter} is not <code>null</code> only errors are displayed
078     * that refer to one of the primitives in the filter.
079     */
080    private transient Set<? extends OsmPrimitive> filter;
081
082    private final transient ListenerList<Runnable> invalidationListeners = ListenerList.create();
083
084    /** if true, buildTree() does nothing */
085    private boolean resetScheduled;
086
087    /**
088     * Constructor
089     * @param errors The list of errors
090     */
091    public ValidatorTreePanel(List<TestError> errors) {
092        setErrorList(errors);
093        ToolTipManager.sharedInstance().registerComponent(this);
094        GuiHelper.extendTooltipDelay(this);
095
096        this.setModel(valTreeModel);
097        this.setRootVisible(false);
098        this.setShowsRootHandles(true);
099        this.expandRow(0);
100        this.setVisibleRowCount(8);
101        this.setCellRenderer(new ValidatorTreeRenderer());
102        this.getSelectionModel().setSelectionMode(TreeSelectionModel.DISCONTIGUOUS_TREE_SELECTION);
103        for (KeyListener keyListener : getKeyListeners()) {
104            // Fix #3596 - Remove default keyListener to avoid conflicts with JOSM commands
105            if ("javax.swing.plaf.basic.BasicTreeUI$Handler".equals(keyListener.getClass().getName())) {
106                removeKeyListener(keyListener);
107            }
108        }
109        DatasetEventManager.getInstance().addDatasetListener(this, DatasetEventManager.FireMode.IN_EDT);
110    }
111
112    @Override
113    public String getToolTipText(MouseEvent e) {
114        String res = null;
115        TreePath path = getPathForLocation(e.getX(), e.getY());
116        if (path != null) {
117            DefaultMutableTreeNode node = (DefaultMutableTreeNode) path.getLastPathComponent();
118            Object nodeInfo = node.getUserObject();
119
120            if (nodeInfo instanceof TestError) {
121                TestError error = (TestError) nodeInfo;
122                res = error.getNameVisitor().getText() + "<br>" + error.getMessage();
123                String d = error.getDescription();
124                if (d != null)
125                    res += "<br>" + d;
126                res += "<br>" + tr("Test: {0}", getTesterDetails(error));
127            } else {
128                Set<String> tests = new HashSet<>();
129                visitTestErrors(node, err -> tests.add(getTesterDetails(err)), null);
130                String source = (tests.size() == 1) ? tr("Test: {0}", tests.iterator().next()) : tr("Different tests");
131                res = node.toString() + "<br>" + source;
132            }
133        }
134        return res == null ? null : "<html>" + res + "</html>";
135    }
136
137    private static String getTesterDetails(TestError e) {
138        return e.getTester().getName() + "<br>" + e.getTester().getSource();
139    }
140
141    /** Constructor */
142    public ValidatorTreePanel() {
143        this(null);
144    }
145
146    @Override
147    public void setVisible(boolean v) {
148        if (v) {
149            buildTree();
150        } else {
151            valTreeModel.setRoot(new DefaultMutableTreeNode());
152        }
153        super.setVisible(v);
154        invalidationListeners.fireEvent(Runnable::run);
155    }
156
157    /**
158     * Builds the errors tree
159     */
160    public void buildTree() {
161        buildTree(true);
162    }
163
164    /**
165     * Builds the errors tree
166     * @param expandAgain if true, try to expand the same rows as before
167     */
168    public void buildTree(boolean expandAgain) {
169        if (resetScheduled)
170            return;
171        buildTreeInternal(expandAgain);
172        invalidationListeners.fireEvent(Runnable::run);
173    }
174
175    private void buildTreeInternal(boolean expandAgain) {
176        final DefaultMutableTreeNode rootNode = new DefaultMutableTreeNode();
177
178        if (errors == null)
179            errors = new ArrayList<>();
180
181        // Remember first selected tree row
182        TreePath selPath = getSelectionPath();
183        int selRow = selPath == null ? -1 : getRowForPath(selPath);
184
185        // Remember the currently expanded rows
186        Set<Object> oldExpandedRows = new HashSet<>();
187        if (expandAgain) {
188            Enumeration<TreePath> expanded = getExpandedDescendants(new TreePath(getRoot()));
189            if (expanded != null) {
190                while (expanded.hasMoreElements()) {
191                    TreePath path = expanded.nextElement();
192                    DefaultMutableTreeNode node = (DefaultMutableTreeNode) path.getLastPathComponent();
193                    Object userObject = node.getUserObject();
194                    if (userObject instanceof Severity) {
195                        oldExpandedRows.add(userObject);
196                    } else if (userObject instanceof String) {
197                        String msg = removeSize((String) userObject);
198                        oldExpandedRows.add(msg);
199                    }
200                }
201            }
202        }
203
204        Predicate<TestError> filterToUse = e -> !e.isIgnored();
205        if (!Boolean.TRUE.equals(ValidatorPrefHelper.PREF_OTHER.get())) {
206            filterToUse = filterToUse.and(e -> e.getSeverity() != Severity.OTHER);
207        }
208        if (filter != null) {
209            filterToUse = filterToUse.and(e -> e.getPrimitives().stream().anyMatch(filter::contains));
210        }
211        Map<Severity, Map<String, Map<String, List<TestError>>>> errorsBySeverityMessageDescription
212            = OsmValidator.getErrorsBySeverityMessageDescription(errors, filterToUse);
213
214        final List<TreePath> expandedPaths = new ArrayList<>();
215        for (Entry<Severity, Map<String, Map<String, List<TestError>>>> entry: errorsBySeverityMessageDescription.entrySet()) {
216            Severity severity = entry.getKey();
217            Map<String, Map<String, List<TestError>>> errorsByMessageDescription = entry.getValue();
218
219            // Severity node
220            final DefaultMutableTreeNode severityNode = new GroupTreeNode(severity);
221            rootNode.add(severityNode);
222
223            if (oldExpandedRows.contains(severity)) {
224                expandedPaths.add(new TreePath(severityNode.getPath()));
225            }
226
227            final Map<String, List<TestError>> errorsWithEmptyMessageByDescription = errorsByMessageDescription.get("");
228            if (errorsWithEmptyMessageByDescription != null) {
229                errorsWithEmptyMessageByDescription.forEach((description, noDescriptionErrors) -> {
230                    final String msg = addSize(description, noDescriptionErrors);
231                    final DefaultMutableTreeNode messageNode = new DefaultMutableTreeNode(msg);
232                    severityNode.add(messageNode);
233
234                    if (oldExpandedRows.contains(description)) {
235                        expandedPaths.add(new TreePath(messageNode.getPath()));
236                    }
237                    // add the matching errors to the current node
238                    noDescriptionErrors.stream().map(DefaultMutableTreeNode::new).forEach(messageNode::add);
239                });
240            }
241
242            errorsByMessageDescription.forEach((message, errorsByDescription) -> {
243                if (message.isEmpty()) {
244                    return;
245                }
246                // Group node
247                final DefaultMutableTreeNode groupNode;
248                if (errorsByDescription.size() > 1) {
249                    groupNode = new GroupTreeNode(message);
250                    severityNode.add(groupNode);
251                    if (oldExpandedRows.contains(message)) {
252                        expandedPaths.add(new TreePath(groupNode.getPath()));
253                    }
254                } else {
255                    groupNode = null;
256                }
257
258                errorsByDescription.forEach((description, errorsWithDescription) -> {
259                    // Message node
260                    final String searchMsg;
261                    if (groupNode != null) {
262                        searchMsg = description;
263                    } else if (Utils.isEmpty(description)) {
264                        searchMsg = message;
265                    } else {
266                        searchMsg = message + " - " + description;
267                    }
268                    final String msg = addSize(searchMsg, errorsWithDescription);
269
270                    final DefaultMutableTreeNode messageNode = new DefaultMutableTreeNode(msg);
271                    DefaultMutableTreeNode currNode = groupNode != null ? groupNode : severityNode;
272                    currNode.add(messageNode);
273                    if (oldExpandedRows.contains(searchMsg)) {
274                        expandedPaths.add(new TreePath(messageNode.getPath()));
275                    }
276
277                    // add the matching errors to the current node
278                    errorsWithDescription.stream().map(DefaultMutableTreeNode::new).forEach(messageNode::add);
279                });
280            });
281        }
282
283        valTreeModel.setRoot(rootNode);
284        for (TreePath path : expandedPaths) {
285            this.expandPath(path);
286        }
287
288        if (selPath != null) {
289            DefaultMutableTreeNode node = (DefaultMutableTreeNode) selPath.getLastPathComponent();
290            Object userObject = node.getUserObject();
291            if (userObject instanceof TestError && ((TestError) userObject).isIgnored()) {
292                // don't try to find ignored error
293                selPath = null;
294            }
295        }
296        if (selPath != null) {
297            // try to reselect previously selected row. May not work if tree structure changed too much.
298            DefaultMutableTreeNode node = (DefaultMutableTreeNode) selPath.getLastPathComponent();
299            Object searchObject = node.getUserObject();
300            String msg = null;
301            if (searchObject instanceof String) {
302                msg = removeSize((String) searchObject);
303            }
304            String searchString = msg;
305            visitTreeNodes(getRoot(), n -> {
306                boolean found = false;
307                final Object userInfo = n.getUserObject();
308                if (searchObject instanceof TestError && userInfo instanceof TestError) {
309                    TestError e1 = (TestError) searchObject;
310                    TestError e2 = (TestError) userInfo;
311                    found |= e1.getCode() == e2.getCode() && e1.getMessage().equals(e2.getMessage())
312                            && e1.getPrimitives().size() == e2.getPrimitives().size()
313                            && e1.getPrimitives().containsAll(e2.getPrimitives());
314                } else if (searchObject instanceof String && userInfo instanceof String) {
315                    found |= ((String) userInfo).startsWith(searchString);
316                } else if (searchObject instanceof Severity) {
317                    found |= searchObject.equals(userInfo);
318                }
319
320                if (found) {
321                    TreePath path = new TreePath(n.getPath());
322                    setSelectionPath(path);
323                    scrollPathToVisible(path);
324                }
325            });
326        }
327        if (selRow >= 0 && selRow < getRowCount() && getSelectionCount() == 0) {
328            // fall back: if we cannot find the previously selected entry, select the row by position
329            setSelectionRow(selRow);
330            scrollRowToVisible(selRow);
331        }
332    }
333
334    private static String addSize(String msg, Collection<?> coll) {
335        return msg + " (" + coll.size() + ")";
336    }
337
338    private static String removeSize(String msg) {
339        int index = msg.lastIndexOf(" (");
340        return index > 0 ? msg.substring(0, index) : msg;
341    }
342
343    /**
344     * Add a new invalidation listener
345     * @param listener The listener
346     */
347    public void addInvalidationListener(Runnable listener) {
348        invalidationListeners.addListener(listener);
349    }
350
351    /**
352     * Remove an invalidation listener
353     * @param listener The listener
354     * @since 10880
355     */
356    public void removeInvalidationListener(Runnable listener) {
357        invalidationListeners.removeListener(listener);
358    }
359
360    /**
361     * Sets the errors list used by a data layer
362     * @param errors The error list that is used by a data layer
363     */
364    public final void setErrorList(List<TestError> errors) {
365        if (errors != null && errors == this.errors)
366            return;
367        this.errors = errors != null ? errors : new ArrayList<>();
368        if (isVisible()) {
369            //TODO: If list is changed because another layer was activated it would be good to store/restore
370            // the expanded / selected paths.
371            clearSelection();
372            buildTree(false);
373        }
374    }
375
376    /**
377     * Clears the current error list and adds these errors to it
378     * @param newerrors The validation errors
379     */
380    public void setErrors(List<TestError> newerrors) {
381        errors.clear();
382        for (TestError error : newerrors) {
383            if (!error.isIgnored()) {
384                errors.add(error);
385            }
386        }
387        if (isVisible()) {
388            buildTree();
389        }
390    }
391
392    /**
393     * Returns the errors of the tree
394     * @return the errors of the tree
395     */
396    public List<TestError> getErrors() {
397        return errors;
398    }
399
400    /**
401     * Selects all errors related to the specified {@code primitives}, i.e. where {@link TestError#getPrimitives()}
402     * returns a primitive present in {@code primitives}.
403     * @param primitives collection of primitives
404     */
405    public void selectRelatedErrors(final Collection<OsmPrimitive> primitives) {
406        final List<TreePath> paths = new ArrayList<>();
407        walkAndSelectRelatedErrors(new TreePath(getRoot()), new HashSet<>(primitives)::contains, paths);
408        clearSelection();
409        setSelectionPaths(paths.toArray(new TreePath[0]));
410        // make sure that first path is visible
411        if (!paths.isEmpty()) {
412            scrollPathToVisible(paths.get(0));
413        }
414    }
415
416    private void walkAndSelectRelatedErrors(final TreePath p, final Predicate<OsmPrimitive> isRelevant, final Collection<TreePath> paths) {
417        final int count = getModel().getChildCount(p.getLastPathComponent());
418        for (int i = 0; i < count; i++) {
419            final Object child = getModel().getChild(p.getLastPathComponent(), i);
420            if (getModel().isLeaf(child) && child instanceof DefaultMutableTreeNode
421                    && ((DefaultMutableTreeNode) child).getUserObject() instanceof TestError) {
422                final TestError error = (TestError) ((DefaultMutableTreeNode) child).getUserObject();
423                if (error.getPrimitives().stream().anyMatch(isRelevant)) {
424                    paths.add(p.pathByAddingChild(child));
425                }
426            } else {
427                walkAndSelectRelatedErrors(p.pathByAddingChild(child), isRelevant, paths);
428            }
429        }
430    }
431
432    /**
433     * Returns the filter list
434     * @return the list of primitives used for filtering
435     */
436    public Set<? extends OsmPrimitive> getFilter() {
437        return filter;
438    }
439
440    /**
441     * Set the filter list to a set of primitives
442     * @param filter the list of primitives used for filtering
443     */
444    public void setFilter(Set<? extends OsmPrimitive> filter) {
445        if (filter != null && filter.isEmpty()) {
446            this.filter = null;
447        } else {
448            this.filter = filter;
449        }
450        if (isVisible()) {
451            buildTree();
452        }
453    }
454
455    /**
456     * Updates the current errors list
457     */
458    public void resetErrors() {
459        resetScheduled = false;
460        filterRemovedPrimitives();
461        setErrors(new ArrayList<>(errors));
462    }
463
464    /**
465     * Expands complete tree
466     */
467    public void expandAll() {
468        visitTreeNodes(getRoot(), x -> expandPath(new TreePath(x.getPath())));
469    }
470
471    /**
472     * Returns the root node model.
473     * @return The root node model
474     */
475    public DefaultMutableTreeNode getRoot() {
476        return (DefaultMutableTreeNode) valTreeModel.getRoot();
477    }
478
479    @Override
480    public void destroy() {
481        DatasetEventManager.getInstance().removeDatasetListener(this);
482        ToolTipManager.sharedInstance().unregisterComponent(this);
483        errors.clear();
484    }
485
486    /**
487     * Visitor call for all tree nodes children of root, in breadth-first order.
488     * @param root Root node
489     * @param visitor Visitor
490     * @since 13940
491     */
492    public static void visitTreeNodes(DefaultMutableTreeNode root, Consumer<DefaultMutableTreeNode> visitor) {
493        @SuppressWarnings("unchecked")
494        Enumeration<TreeNode> errorMessages = root.breadthFirstEnumeration();
495        while (errorMessages.hasMoreElements()) {
496            visitor.accept(((DefaultMutableTreeNode) errorMessages.nextElement()));
497        }
498    }
499
500    /**
501     * Visitor call for all {@link TestError} nodes children of root, in breadth-first order.
502     * @param root Root node
503     * @param visitor Visitor
504     * @since 13940
505     */
506    public static void visitTestErrors(DefaultMutableTreeNode root, Consumer<TestError> visitor) {
507        visitTestErrors(root, visitor, null);
508    }
509
510    /**
511     * Visitor call for all {@link TestError} nodes children of root, in breadth-first order.
512     * @param root Root node
513     * @param visitor Visitor
514     * @param processedNodes Set of already visited nodes (optional)
515     * @since 13940
516     */
517    public static void visitTestErrors(DefaultMutableTreeNode root, Consumer<TestError> visitor,
518            Set<DefaultMutableTreeNode> processedNodes) {
519        visitTreeNodes(root, n -> {
520            if (processedNodes == null || !processedNodes.contains(n)) {
521                if (processedNodes != null) {
522                    processedNodes.add(n);
523                }
524                Object o = n.getUserObject();
525                if (o instanceof TestError) {
526                    visitor.accept((TestError) o);
527                }
528            }
529        });
530    }
531
532    @Override public void primitivesRemoved(PrimitivesRemovedEvent event) {
533        // Remove purged primitives (fix #8639)
534        if (filterRemovedPrimitives()) {
535            buildTree();
536        }
537    }
538
539    @Override public void primitivesAdded(PrimitivesAddedEvent event) {
540        // Do nothing
541    }
542
543    @Override public void tagsChanged(TagsChangedEvent event) {
544        // Do nothing
545    }
546
547    @Override public void nodeMoved(NodeMovedEvent event) {
548        // Do nothing
549    }
550
551    @Override public void wayNodesChanged(WayNodesChangedEvent event) {
552        // Do nothing
553    }
554
555    @Override public void relationMembersChanged(RelationMembersChangedEvent event) {
556        // Do nothing
557    }
558
559    @Override public void otherDatasetChange(AbstractDatasetChangedEvent event) {
560        // Do nothing
561    }
562
563    @Override public void dataChanged(DataChangedEvent event) {
564        if (filterRemovedPrimitives()) {
565            buildTree();
566        }
567    }
568
569    /**
570     * Can be called to suppress execution of buildTree() while doing multiple updates. Caller must
571     * call resetErrors() to end this state.
572     * @since 14849
573     */
574    public void setResetScheduled() {
575        resetScheduled = true;
576    }
577
578    /**
579     * Remove errors which refer to removed or purged primitives.
580     * @return true if error list was changed
581     */
582    private boolean filterRemovedPrimitives() {
583        return errors.removeIf(
584                error -> error.getPrimitives().stream().anyMatch(p -> p.isDeleted() || p.getDataSet() == null));
585    }
586
587}