// License: GPL. For details, see LICENSE file. package reverter; import static org.openstreetmap.josm.tools.I18n.tr; import java.net.HttpURLConnection; import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.HashSet; import java.util.Iterator; import java.util.List; import org.openstreetmap.josm.Main; import org.openstreetmap.josm.command.Command; import org.openstreetmap.josm.command.DeleteCommand; import org.openstreetmap.josm.command.conflict.ConflictAddCommand; import org.openstreetmap.josm.data.conflict.Conflict; import org.openstreetmap.josm.data.coor.LatLon; import org.openstreetmap.josm.data.osm.Changeset; import org.openstreetmap.josm.data.osm.DataSet; import org.openstreetmap.josm.data.osm.Node; import org.openstreetmap.josm.data.osm.OsmPrimitive; import org.openstreetmap.josm.data.osm.OsmPrimitiveType; import org.openstreetmap.josm.data.osm.PrimitiveId; import org.openstreetmap.josm.data.osm.Relation; import org.openstreetmap.josm.data.osm.RelationMemberData; import org.openstreetmap.josm.data.osm.SimplePrimitiveId; import org.openstreetmap.josm.data.osm.Way; import org.openstreetmap.josm.data.osm.history.HistoryNode; import org.openstreetmap.josm.data.osm.history.HistoryOsmPrimitive; import org.openstreetmap.josm.data.osm.history.HistoryRelation; import org.openstreetmap.josm.data.osm.history.HistoryWay; import org.openstreetmap.josm.gui.layer.OsmDataLayer; import org.openstreetmap.josm.gui.progress.ProgressMonitor; import org.openstreetmap.josm.gui.util.GuiHelper; import org.openstreetmap.josm.io.MultiFetchServerObjectReader; import org.openstreetmap.josm.io.OsmApiException; import org.openstreetmap.josm.io.OsmTransferException; import reverter.corehacks.ChangesetDataSet; import reverter.corehacks.ChangesetDataSet.ChangesetDataSetEntry; import reverter.corehacks.ChangesetDataSet.ChangesetModificationType; import reverter.corehacks.OsmServerChangesetReader; /** * Fetches and stores data for reverting of specific changeset. * @author Upliner * */ public class ChangesetReverter { public enum RevertType { FULL, SELECTION, SELECTION_WITH_UNDELETE } public static final Collection<Long> MODERATOR_REDACTION_ACCOUNTS = Collections.unmodifiableCollection(Arrays.asList( 722137L, // OSMF Redaction Account 760215L // pnorman redaction revert )); public final int changesetId; public final Changeset changeset; public final RevertType revertType; private final OsmDataLayer layer; // data layer associated with reverter private final DataSet ds; // DataSet associated with reverter private final ChangesetDataSet cds; // Current changeset data private DataSet nds; // Dataset that contains new objects downloaded by reverter private final HashSet<PrimitiveId> missing = new HashSet<>(); private final HashSet<HistoryOsmPrimitive> created = new HashSet<>(); private final HashSet<HistoryOsmPrimitive> updated = new HashSet<>(); private final HashSet<HistoryOsmPrimitive> deleted = new HashSet<>(); //// Handling missing objects //////////////////////////////////////// private void addIfMissing(PrimitiveId id) { OsmPrimitive p = ds.getPrimitiveById(id); if (p == null || p.isIncomplete()) { missing.add(id); } } private void addMissingHistoryIds(Iterable<HistoryOsmPrimitive> primitives) { for (HistoryOsmPrimitive p : primitives) { addIfMissing(p.getPrimitiveId()); if (p.getType() == OsmPrimitiveType.WAY) { for (long nd : ((HistoryWay) p).getNodes()) { addIfMissing(new SimplePrimitiveId(nd, OsmPrimitiveType.NODE)); } } } } private void addMissingIds(Iterable<OsmPrimitive> primitives) { for (OsmPrimitive p : primitives) { addIfMissing(p); if (p.getType() == OsmPrimitiveType.WAY) { for (Node nd : ((Way) p).getNodes()) { addIfMissing(nd); } } } } /** * Checks if {@see ChangesetDataSetEntry} conforms to current RevertType * @param entry entry to be checked * @return <code>true</code> if {@see ChangesetDataSetEntry} conforms to current RevertType */ private boolean checkOsmChangeEntry(ChangesetDataSetEntry entry) { if (revertType == RevertType.FULL) return true; if (revertType == RevertType.SELECTION_WITH_UNDELETE && entry.getModificationType() == ChangesetModificationType.DELETED) { return true; } OsmPrimitive p = ds.getPrimitiveById(entry.getPrimitive().getPrimitiveId()); if (p == null) return false; return p.isSelected(); } /** * creates a reverter for specific changeset and fetches initial data * @param changesetId changeset id * @param monitor progress monitor * @throws OsmTransferException if data transfer errors occur * @throws RevertRedactedChangesetException if a redacted changeset is requested */ public ChangesetReverter(int changesetId, RevertType revertType, boolean newLayer, ProgressMonitor monitor) throws OsmTransferException, RevertRedactedChangesetException { this.changesetId = changesetId; if (newLayer) { this.ds = new DataSet(); this.layer = new OsmDataLayer(this.ds, tr("Reverted changeset") + tr(" [id: {0}]", String.valueOf(changesetId)), null); } else { this.layer = Main.getLayerManager().getEditLayer(); this.ds = layer.data; } this.revertType = revertType; OsmServerChangesetReader csr = new OsmServerChangesetReader(); monitor.beginTask("", 2); changeset = csr.readChangeset(changesetId, monitor.createSubTaskMonitor(1, false)); if (MODERATOR_REDACTION_ACCOUNTS.contains(changeset.getUser().getId())) { throw new RevertRedactedChangesetException(tr("It is not allowed to revert changeset from {0}", changeset.getUser().getName())); } try { cds = csr.downloadChangeset(changesetId, monitor.createSubTaskMonitor(1, false)); } finally { monitor.finishTask(); if (newLayer) { GuiHelper.runInEDT(new Runnable() { @Override public void run() { Main.getLayerManager().addLayer(layer); } }); } } // Build our own lists of created/updated/modified objects for better performance for (Iterator<ChangesetDataSetEntry> it = cds.iterator(); it.hasNext();) { ChangesetDataSetEntry entry = it.next(); if (!checkOsmChangeEntry(entry)) continue; if (entry.getModificationType() == ChangesetModificationType.CREATED) { created.add(entry.getPrimitive()); } else if (entry.getModificationType() == ChangesetModificationType.UPDATED) { updated.add(entry.getPrimitive()); } else if (entry.getModificationType() == ChangesetModificationType.DELETED) { deleted.add(entry.getPrimitive()); } else throw new AssertionError(); } } public void checkMissingCreated() { addMissingHistoryIds(created); } public void checkMissingUpdated() { addMissingHistoryIds(updated); } public void checkMissingDeleted() { addMissingHistoryIds(deleted); } private void readObjectVersion(OsmServerMultiObjectReader rdr, PrimitiveId id, int version, ProgressMonitor progressMonitor) throws OsmTransferException { boolean readOK = false; while (!readOK && version >= 1) { try { rdr.readObject(id, version, progressMonitor.createSubTaskMonitor(1, true)); readOK = true; } catch (OsmApiException e) { if (e.getResponseCode() != HttpURLConnection.HTTP_FORBIDDEN) { throw e; } String message = "Version "+version+" of "+id+" is unauthorized"; if (version > 1) { message += ", requesting previous one"; } Main.info(message); version--; } } if (!readOK) { Main.warn("Cannot retrieve any previous version of "+id); } } /** * fetch objects that were updated or deleted by changeset * @param progressMonitor progress monitor * @throws OsmTransferException if data transfer errors occur */ @SuppressWarnings("unchecked") public void downloadObjectsHistory(ProgressMonitor progressMonitor) throws OsmTransferException { final OsmServerMultiObjectReader rdr = new OsmServerMultiObjectReader(); progressMonitor.beginTask(tr("Downloading objects history"), updated.size()+deleted.size()+1); try { for (HashSet<HistoryOsmPrimitive> collection : Arrays.asList(new HashSet[]{updated, deleted})) { for (HistoryOsmPrimitive entry : collection) { PrimitiveId id = entry.getPrimitiveId(); readObjectVersion(rdr, id, cds.getEarliestVersion(id)-1, progressMonitor); if (progressMonitor.isCanceled()) return; } } nds = rdr.parseOsm(progressMonitor.createSubTaskMonitor(1, true)); for (OsmPrimitive p : nds.allPrimitives()) { if (!p.isIncomplete()) { addMissingIds(Collections.singleton(p)); } else { if (ds.getPrimitiveById(p.getPrimitiveId()) == null) { switch (p.getType()) { case NODE: ds.addPrimitive(new Node(p.getUniqueId())); break; case CLOSEDWAY: case WAY: ds.addPrimitive(new Way(p.getUniqueId())); break; case MULTIPOLYGON: case RELATION: ds.addPrimitive(new Relation(p.getUniqueId())); break; default: throw new AssertionError(); } } } } } finally { progressMonitor.finishTask(); } } public void downloadMissingPrimitives(ProgressMonitor monitor) throws OsmTransferException { if (!hasMissingObjects()) return; MultiFetchServerObjectReader rdr = MultiFetchServerObjectReader.create(); for (PrimitiveId id : missing) { switch (id.getType()) { case NODE: rdr.append(new Node(id.getUniqueId())); break; case CLOSEDWAY: case WAY: rdr.append(new Way(id.getUniqueId())); break; case MULTIPOLYGON: case RELATION: rdr.append(new Relation(id.getUniqueId())); break; default: throw new AssertionError(); } } DataSet source = rdr.parseOsm(monitor); for (OsmPrimitive p : source.allPrimitives()) { if (!p.isVisible() && !p.isDeleted()) { p.setDeleted(true); p.setModified(false); } } layer.mergeFrom(source); missing.clear(); } private static Conflict<? extends OsmPrimitive> CreateConflict(OsmPrimitive p, boolean isMyDeleted) { switch (p.getType()) { case NODE: return new Conflict<>((Node) p, new Node((Node) p), isMyDeleted); case CLOSEDWAY: case WAY: return new Conflict<>((Way) p, new Way((Way) p), isMyDeleted); case MULTIPOLYGON: case RELATION: return new Conflict<>((Relation) p, new Relation((Relation) p), isMyDeleted); default: throw new AssertionError(); } } private boolean hasEqualSemanticAttributes(OsmPrimitive current, HistoryOsmPrimitive history) { if (!current.getKeys().equals(history.getTags())) return false; switch (current.getType()) { case NODE: LatLon currentCoor = ((Node) current).getCoor(); LatLon historyCoor = ((HistoryNode) history).getCoords(); if (currentCoor == historyCoor || (currentCoor != null && historyCoor != null && currentCoor.equals(historyCoor))) return true; // Handle case where a deleted note has been restored to avoid false conflicts (fix #josm8660) if (currentCoor != null && historyCoor == null) { LatLon previousCoor = ((Node) nds.getPrimitiveById(history.getPrimitiveId())).getCoor(); return previousCoor != null && previousCoor.equals(currentCoor); } return false; case CLOSEDWAY: case WAY: List<Node> currentNodes = ((Way) current).getNodes(); List<Long> historyNodes = ((HistoryWay) history).getNodes(); if (currentNodes.size() != historyNodes.size()) return false; for (int i = 0; i < currentNodes.size(); i++) { if (currentNodes.get(i).getId() != historyNodes.get(i)) return false; } return true; case MULTIPOLYGON: case RELATION: List<org.openstreetmap.josm.data.osm.RelationMember> currentMembers = ((Relation) current).getMembers(); List<RelationMemberData> historyMembers = ((HistoryRelation) history).getMembers(); if (currentMembers.size() != historyMembers.size()) return false; for (int i = 0; i < currentMembers.size(); i++) { org.openstreetmap.josm.data.osm.RelationMember currentMember = currentMembers.get(i); RelationMemberData historyMember = historyMembers.get(i); if (!currentMember.getRole().equals(historyMember.getRole())) return false; if (!currentMember.getMember().getPrimitiveId().equals(new SimplePrimitiveId( historyMember.getMemberId(), historyMember.getMemberType()))) return false; } return true; default: throw new AssertionError(); } } /** * Builds a list of commands that will revert the changeset * */ public List<Command> getCommands() { if (this.nds == null) return null; ////////////////////////////////////////////////////////////////////////// // Create commands to restore/update all affected objects DataSetCommandMerger merger = new DataSetCommandMerger(nds, ds); List<Command> cmds = merger.getCommandList(); ////////////////////////////////////////////////////////////////////////// // Create a set of objects to be deleted HashSet<OsmPrimitive> toDelete = new HashSet<>(); // Mark objects that has visible=false to be deleted for (OsmPrimitive p : nds.allPrimitives()) { if (!p.isVisible()) { OsmPrimitive dp = ds.getPrimitiveById(p); if (dp != null) toDelete.add(dp); } } // Mark all created objects to be deleted for (HistoryOsmPrimitive id : created) { OsmPrimitive p = ds.getPrimitiveById(id.getPrimitiveId()); if (p != null) toDelete.add(p); } ////////////////////////////////////////////////////////////////////////// // Check reversion against current dataset and create necessary conflicts HashSet<OsmPrimitive> conflicted = new HashSet<>(); for (Conflict<? extends OsmPrimitive> conflict : merger.getConflicts()) { cmds.add(new ConflictAddCommand(layer, conflict)); } // Check objects versions for (Iterator<ChangesetDataSetEntry> it = cds.iterator(); it.hasNext();) { ChangesetDataSetEntry entry = it.next(); if (!checkOsmChangeEntry(entry)) continue; HistoryOsmPrimitive hp = entry.getPrimitive(); OsmPrimitive dp = ds.getPrimitiveById(hp.getPrimitiveId()); if (dp == null || dp.isIncomplete()) throw new IllegalStateException(tr("Missing merge target for {0} with id {1}", hp.getType(), hp.getId())); if (hp.getVersion() != dp.getVersion() && (hp.isVisible() || dp.isVisible()) && /* Don't create conflict if changeset object and dataset object * has same semantic attributes (but different versions) */ !hasEqualSemanticAttributes(dp, hp) /* Don't create conflict if the object has to be deleted but has already been deleted */ && !(toDelete.contains(dp) && dp.isDeleted())) { cmds.add(new ConflictAddCommand(layer, CreateConflict(dp, entry.getModificationType() == ChangesetModificationType.CREATED))); conflicted.add(dp); } } /* Check referrers for deleted objects: if object is referred by another object that * isn't going to be deleted or modified, create a conflict. */ for (Iterator<OsmPrimitive> it = toDelete.iterator(); it.hasNext();) { OsmPrimitive p = it.next(); if (p.isDeleted()) { it.remove(); continue; } for (OsmPrimitive referrer : p.getReferrers()) { if (toDelete.contains(referrer)) continue; // object is going to be deleted if (nds.getPrimitiveById(referrer) != null) continue; /* object is going to be modified so it cannot refer to * objects created in changeset to be reverted */ if (!conflicted.contains(p)) { cmds.add(new ConflictAddCommand(layer, CreateConflict(p, true))); conflicted.add(p); } it.remove(); break; } } // Create a Command to delete all marked objects List<? extends OsmPrimitive> list; list = OsmPrimitive.getFilteredList(toDelete, Relation.class); if (!list.isEmpty()) cmds.add(new DeleteCommand(list)); list = OsmPrimitive.getFilteredList(toDelete, Way.class); if (!list.isEmpty()) cmds.add(new DeleteCommand(list)); list = OsmPrimitive.getFilteredList(toDelete, Node.class); if (!list.isEmpty()) cmds.add(new DeleteCommand(list)); return cmds; } public boolean hasMissingObjects() { return !missing.isEmpty(); } public void fixNodesWithoutCoordinates(ProgressMonitor progressMonitor) throws OsmTransferException { for (Node n : nds.getNodes()) { if (!n.isDeleted() && n.getCoor() == null) { PrimitiveId id = n.getPrimitiveId(); OsmPrimitive p = ds.getPrimitiveById(id); if (p instanceof Node && p.getVersion() > 1) { LatLon coor = ((Node) p).getCoor(); if (coor == null) { final OsmServerMultiObjectReader rdr = new OsmServerMultiObjectReader(); readObjectVersion(rdr, id, p.getVersion()-1, progressMonitor); Collection<OsmPrimitive> result = rdr.parseOsm(progressMonitor.createSubTaskMonitor(1, true)).allPrimitives(); if (!result.isEmpty()) { coor = ((Node) result.iterator().next()).getCoor(); } } if (coor != null) { n.setCoor(coor); } } } } } }