// License: GPL. For details, see LICENSE file.
package org.openstreetmap.josm.data.validation.tests;
import static org.openstreetmap.josm.tools.I18n.tr;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Set;
import org.openstreetmap.josm.command.ChangeCommand;
import org.openstreetmap.josm.command.Command;
import org.openstreetmap.josm.command.DeleteCommand;
import org.openstreetmap.josm.command.SequenceCommand;
import org.openstreetmap.josm.data.coor.LatLon;
import org.openstreetmap.josm.data.osm.Node;
import org.openstreetmap.josm.data.osm.OsmPrimitive;
import org.openstreetmap.josm.data.osm.Relation;
import org.openstreetmap.josm.data.osm.RelationMember;
import org.openstreetmap.josm.data.osm.Way;
import org.openstreetmap.josm.data.validation.Severity;
import org.openstreetmap.josm.data.validation.Test;
import org.openstreetmap.josm.data.validation.TestError;
import org.openstreetmap.josm.gui.progress.ProgressMonitor;
import org.openstreetmap.josm.tools.MultiMap;
/**
* Tests if there are duplicate ways
*/
public class DuplicateWay extends Test {
/**
* Class to store a way reduced to coordinates and keys. Essentially this is used to call the
* equals{}
function.
*/
private static class WayPair {
private final List coor;
private final Map keys;
WayPair(List coor, Map keys) {
this.coor = coor;
this.keys = keys;
}
@Override
public int hashCode() {
return Objects.hash(coor, keys);
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
WayPair wayPair = (WayPair) obj;
return Objects.equals(coor, wayPair.coor) &&
Objects.equals(keys, wayPair.keys);
}
}
/**
* Class to store a way reduced to coordinates. Essentially this is used to call the
* equals{}
function.
*/
private static class WayPairNoTags {
private final List coor;
WayPairNoTags(List coor) {
this.coor = coor;
}
@Override
public int hashCode() {
return Objects.hash(coor);
}
@Override
public boolean equals(Object obj) {
if (this == obj) return true;
if (obj == null || getClass() != obj.getClass()) return false;
WayPairNoTags that = (WayPairNoTags) obj;
return Objects.equals(coor, that.coor);
}
}
/** Test identification for exactly identical ways (coordinates and tags). */
protected static final int DUPLICATE_WAY = 1401;
/** Test identification for identical ways (coordinates only). */
protected static final int SAME_WAY = 1402;
/** Bag of all ways */
private MultiMap ways;
/** Bag of all ways, regardless of tags */
private MultiMap waysNoTags;
/** Set of known hashcodes for list of coordinates **/
private Set knownHashCodes;
/**
* Constructor
*/
public DuplicateWay() {
super(tr("Duplicated ways"),
tr("This test checks that there are no ways with same node coordinates and optionally also same tags."));
}
@Override
public void startTest(ProgressMonitor monitor) {
super.startTest(monitor);
ways = new MultiMap<>(1000);
waysNoTags = new MultiMap<>(1000);
knownHashCodes = new HashSet<>(1000);
}
@Override
public void endTest() {
super.endTest();
for (Set duplicated : ways.values()) {
if (duplicated.size() > 1) {
TestError testError = TestError.builder(this, Severity.ERROR, DUPLICATE_WAY)
.message(tr("Duplicated ways"))
.primitives(duplicated)
.build();
errors.add(testError);
}
}
for (Set sameway : waysNoTags.values()) {
if (sameway.size() > 1) {
//Report error only if at least some tags are different, as otherwise the error was already reported as duplicated ways
Map tags0 = null;
boolean skip = true;
for (OsmPrimitive o : sameway) {
if (tags0 == null) {
tags0 = o.getKeys();
removeUninterestingKeys(tags0);
} else {
Map tagsCmp = o.getKeys();
removeUninterestingKeys(tagsCmp);
if (!tagsCmp.equals(tags0)) {
skip = false;
break;
}
}
}
if (skip) {
continue;
}
TestError testError = TestError.builder(this, Severity.WARNING, SAME_WAY)
.message(tr("Ways with same position"))
.primitives(sameway)
.build();
errors.add(testError);
}
}
ways = null;
waysNoTags = null;
knownHashCodes = null;
}
/**
* Remove uninteresting discardable keys to normalize the tags
* @param wkeys The tags of the way, obtained by {@code Way#getKeys}
*/
public void removeUninterestingKeys(Map wkeys) {
for (String key : OsmPrimitive.getDiscardableKeys()) {
wkeys.remove(key);
}
}
@Override
public void visit(Way w) {
if (!w.isUsable())
return;
List wLat = getOrderedNodes(w);
// If this way has not direction-dependant keys, make sure the list is ordered the same for all ways (fix #8015)
if (!w.hasDirectionKeys()) {
int hash = wLat.hashCode();
if (!knownHashCodes.contains(hash)) {
List reversedwLat = new ArrayList<>(wLat);
Collections.reverse(reversedwLat);
int reverseHash = reversedwLat.hashCode();
if (!knownHashCodes.contains(reverseHash)) {
// Neither hash or reversed hash is known, remember hash
knownHashCodes.add(hash);
} else {
// Reversed hash is known, use the reverse list then
wLat = reversedwLat;
}
}
}
Map wkeys = w.getKeys();
removeUninterestingKeys(wkeys);
WayPair wKey = new WayPair(wLat, wkeys);
ways.put(wKey, w);
WayPairNoTags wKeyN = new WayPairNoTags(wLat);
waysNoTags.put(wKeyN, w);
}
/**
* Replies the ordered list of nodes of way w such as it is easier to find duplicated ways.
* In case of a closed way, build the list of lat/lon starting from the node with the lowest id
* to ensure this list will produce the same hashcode as the list obtained from another closed
* way with the same nodes, in the same order, but that does not start from the same node (fix #8008)
* @param w way
* @return the ordered list of nodes of way w such as it is easier to find duplicated ways
* @since 7721
*/
public static List getOrderedNodes(Way w) {
List wNodes = w.getNodes(); // The original list of nodes for this way
List wNodesToUse = new ArrayList<>(wNodes.size()); // The list that will be considered for this test
if (w.isClosed()) {
int lowestIndex = 0;
long lowestNodeId = wNodes.get(0).getUniqueId();
for (int i = 1; i < wNodes.size(); i++) {
if (wNodes.get(i).getUniqueId() < lowestNodeId) {
lowestNodeId = wNodes.get(i).getUniqueId();
lowestIndex = i;
}
}
for (int i = lowestIndex; i < wNodes.size()-1; i++) {
wNodesToUse.add(wNodes.get(i));
}
for (int i = 0; i < lowestIndex; i++) {
wNodesToUse.add(wNodes.get(i));
}
wNodesToUse.add(wNodes.get(lowestIndex));
} else {
wNodesToUse.addAll(wNodes);
}
// Build the list of lat/lon
List wLat = new ArrayList<>(wNodesToUse.size());
for (Node node : wNodesToUse) {
wLat.add(node.getCoor());
}
return wLat;
}
/**
* Fix the error by removing all but one instance of duplicate ways
*/
@Override
public Command fixError(TestError testError) {
Collection extends OsmPrimitive> sel = testError.getPrimitives();
Set wayz = new HashSet<>();
for (OsmPrimitive osm : sel) {
if (osm instanceof Way && !osm.isDeleted()) {
wayz.add((Way) osm);
}
}
if (wayz.size() < 2)
return null;
long idToKeep = 0;
Way wayToKeep = wayz.iterator().next();
// Find the way that is member of one or more relations. (If any)
Way wayWithRelations = null;
List relations = null;
for (Way w : wayz) {
List rel = OsmPrimitive.getFilteredList(w.getReferrers(), Relation.class);
if (!rel.isEmpty()) {
if (wayWithRelations != null)
throw new AssertionError("Cannot fix duplicate Ways: More than one way is relation member.");
wayWithRelations = w;
relations = rel;
}
// Only one way will be kept - the one with lowest positive ID, if such exist
// or one "at random" if no such exists. Rest of the ways will be deleted
if (!w.isNew() && (idToKeep == 0 || w.getId() < idToKeep)) {
idToKeep = w.getId();
wayToKeep = w;
}
}
Collection commands = new LinkedList<>();
// Fix relations.
if (wayWithRelations != null && relations != null && wayToKeep != wayWithRelations) {
for (Relation rel : relations) {
Relation newRel = new Relation(rel);
for (int i = 0; i < newRel.getMembers().size(); ++i) {
RelationMember m = newRel.getMember(i);
if (wayWithRelations.equals(m.getMember())) {
newRel.setMember(i, new RelationMember(m.getRole(), wayToKeep));
}
}
commands.add(new ChangeCommand(rel, newRel));
}
}
// Delete all ways in the list
// Note: nodes are not deleted, these can be detected and deleted at next pass
wayz.remove(wayToKeep);
commands.add(new DeleteCommand(wayz));
return new SequenceCommand(tr("Delete duplicate ways"), commands);
}
@Override
public boolean isFixable(TestError testError) {
if (!(testError.getTester() instanceof DuplicateWay))
return false;
// Do not automatically fix same ways with different tags
if (testError.getCode() != DUPLICATE_WAY) return false;
// We fix it only if there is no more than one way that is relation member.
Collection extends OsmPrimitive> sel = testError.getPrimitives();
Set wayz = new HashSet<>();
for (OsmPrimitive osm : sel) {
if (osm instanceof Way) {
wayz.add((Way) osm);
}
}
if (wayz.size() < 2)
return false;
int waysWithRelations = 0;
for (Way w : wayz) {
List rel = OsmPrimitive.getFilteredList(w.getReferrers(), Relation.class);
if (!rel.isEmpty()) {
++waysWithRelations;
}
}
return waysWithRelations <= 1;
}
}