// 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);
}
}
}
}
}
}