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}