MapComplete/src/Logic/Osm/Changes.ts

858 lines
32 KiB
TypeScript

import { OsmNode, OsmObject, OsmRelation, OsmWay } from "./OsmObject"
import { ImmutableStore, Store, UIEventSource } from "../UIEventSource"
import Constants from "../../Models/Constants"
import OsmChangeAction from "./Actions/OsmChangeAction"
import { ChangeDescription, ChangeDescriptionTools } from "./Actions/ChangeDescription"
import { Utils } from "../../Utils"
import { LocalStorageSource } from "../Web/LocalStorageSource"
import SimpleMetaTagger from "../SimpleMetaTagger"
import { FeatureSource, IndexedFeatureSource } from "../FeatureSource/FeatureSource"
import { GeoLocationPointProperties } from "../State/GeoLocationState"
import { GeoOperations } from "../GeoOperations"
import { ChangesetHandler, ChangesetTag } from "./ChangesetHandler"
import { OsmConnection } from "./OsmConnection"
import OsmObjectDownloader from "./OsmObjectDownloader"
import ChangeLocationAction from "./Actions/ChangeLocationAction"
import ChangeTagAction from "./Actions/ChangeTagAction"
import DeleteAction from "./Actions/DeleteAction"
import MarkdownUtils from "../../Utils/MarkdownUtils"
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
/**
* Handles all changes made to OSM.
* Needs an authenticator via OsmConnection
*/
export class Changes {
public readonly pendingChanges: UIEventSource<ChangeDescription[]> =
LocalStorageSource.getParsed<ChangeDescription[]>("pending-changes", [])
public readonly allChanges = new UIEventSource<ChangeDescription[]>(undefined)
public readonly state: {
allElements?: IndexedFeatureSource
osmConnection: OsmConnection
featureSwitches?: {
featureSwitchMorePrivacy?: Store<boolean>
}
}
public readonly extraComment: UIEventSource<string> = new UIEventSource(undefined)
public readonly backend: string
public readonly isUploading = new UIEventSource(false)
public readonly errors = new UIEventSource<string[]>([], "upload-errors")
private readonly historicalUserLocations?: FeatureSource
private _nextId: number = 0 // Newly assigned ID's are negative
private readonly previouslyCreated: OsmObject[] = []
private readonly _leftRightSensitive: boolean
public readonly _changesetHandler: ChangesetHandler
private readonly _reportError?: (string: string | Error, extramessage?: string) => void
constructor(
state: {
featureSwitches: {
featureSwitchMorePrivacy?: Store<boolean>
featureSwitchIsTesting?: Store<boolean>
},
osmConnection: OsmConnection,
reportError?: (error: string) => void,
featureProperties?: FeaturePropertiesStore,
historicalUserLocations?: FeatureSource,
allElements?: IndexedFeatureSource
},
leftRightSensitive: boolean = false,
reportError?: (string: string | Error, extramessage?: string) => void
) {
this._leftRightSensitive = leftRightSensitive
// We keep track of all changes just as well
this.allChanges.setData([...this.pendingChanges.data])
// If a pending change contains a negative ID, we save that
this._nextId = Math.min(-1, ...(this.pendingChanges.data?.map((pch) => pch.id ?? 0) ?? []))
if(isNaN(this._nextId) && state.reportError !== undefined){
state.reportError("Got a NaN as nextID. Pending changes IDs are:" +this.pendingChanges.data?.map(pch => pch?.id).join("."))
this._nextId = -100
}
this.state = state
this.backend = state.osmConnection.Backend()
this._reportError = reportError
this._changesetHandler = new ChangesetHandler(
state.featureSwitches?.featureSwitchIsTesting ?? new ImmutableStore(false),
state.osmConnection,
state.featureProperties,
this,
(e, extramessage: string) => this._reportError(e, extramessage)
)
this.historicalUserLocations = state.historicalUserLocations
// Note: a changeset might be reused which was opened just before and might have already used some ids
// This doesn't matter however, as the '-1' is per piecewise upload, not global per changeset
}
public static createTestObject(): Changes{
return new Changes({
osmConnection: new OsmConnection(),
featureSwitches:{
featureSwitchIsTesting: new ImmutableStore(true)
}
})
}
static buildChangesetXML(
csId: string,
allChanges: {
modifiedObjects: OsmObject[]
newObjects: OsmObject[]
deletedObjects: OsmObject[]
}
): string {
const changedElements = allChanges.modifiedObjects ?? []
const newElements = allChanges.newObjects ?? []
const deletedElements = allChanges.deletedObjects ?? []
let changes = `<osmChange version='0.6' generator='Mapcomplete ${Constants.vNumber}'>`
if (newElements.length > 0) {
changes +=
"\n<create>\n" +
newElements.map((e) => e.ChangesetXML(csId)).join("\n") +
"</create>"
}
if (changedElements.length > 0) {
changes +=
"\n<modify>\n" +
changedElements.map((e) => e.ChangesetXML(csId)).join("\n") +
"\n</modify>"
}
if (deletedElements.length > 0) {
changes +=
"\n<delete>\n" +
deletedElements.map((e) => e.ChangesetXML(csId)).join("\n") +
"\n</delete>"
}
changes += "</osmChange>"
return changes
}
public static getDocs(): string {
function addSource(items: any[], src: string) {
items.forEach((i) => {
i["source"] = src
})
return items
}
const metatagsDocs: {
key?: string
value?: string
docs: string
changeType?: string[]
specialMotivation?: boolean
source?: string
}[] = [
...addSource(
[
{
key: "comment",
docs: "The changeset comment. Will be a fixed string, mentioning the theme",
},
{
key: "theme",
docs: "The name of the theme that was used to create this change. ",
},
{
key: "source",
value: "survey",
docs: "The contributor had their geolocation enabled while making changes",
},
{
key: "change_within_{distance}",
docs: "If the contributor enabled their geolocation, this will hint how far away they were from the objects they edited. This gives an indication of proximity and if they truly surveyed or were armchair-mapping",
},
{
key: "change_over_{distance}",
docs: "If the contributor enabled their geolocation, this will hint how far away they were from the objects they edited. If they were over 5000m away, the might have been armchair-mapping",
},
{
key: "created_by",
value: "MapComplete <version>",
docs: "The piece of software used to create this changeset; will always start with MapComplete, followed by the version number",
},
{
key: "locale",
value: "en|nl|de|...",
docs: "The code of the language that the contributor used MapComplete in. Hints what language the user speaks.",
},
{
key: "host",
value: "https://mapcomplete.org/<theme>",
docs: "The URL that the contributor used to make changes. One can see the used instance with this",
},
{
key: "imagery",
docs: "The identifier of the used background layer, this will probably be an identifier from the [editor layer index](https://github.com/osmlab/editor-layer-index)",
},
],
"default"
),
...addSource(ChangeTagAction.metatags, "ChangeTag"),
...addSource(ChangeLocationAction.metatags, "ChangeLocation"),
...addSource(DeleteAction.metatags, "DeleteAction"),
// TODO
/*
...DeleteAction.metatags,
...LinkImageAction.metatags,
...OsmChangeAction.metatags,
...RelationSplitHandler.metatags,
...ReplaceGeometryAction.metatags,
...SplitAction.metatags,*/
]
return [
"# Metatags on a changeset",
"You might encounter the following metatags on a changeset:",
MarkdownUtils.table(
["key", "value", "explanation", "source"],
metatagsDocs.map(({ key, value, docs, source, changeType, specialMotivation }) => [
key ?? changeType?.join(", ") ?? "",
value,
[
docs,
specialMotivation
? "This might give a reason per modified node or way"
: "",
].join("\n"),
source,
])
),
].join("\n\n")
}
public static GetNeededIds(changes: ChangeDescription[]) {
return Utils.Dedup(changes.filter((c) => c.id >= 0).map((c) => c.type + "/" + c.id))
}
/**
* Returns a new ID and updates the value for the next ID
*/
public getNewID() {
// See #2082. We check for previous rewritings, as a remapping might be from a previous session
do {
this._nextId--
} while (
this._changesetHandler._remappings.has("node/" + this._nextId) ||
this._changesetHandler._remappings.has("way/" + this._nextId) ||
this._changesetHandler._remappings.has("relation/" + this._nextId)
)
return this._nextId
}
/**
* Uploads all the pending changes in one go.
* Triggered by the 'PendingChangeUploader'-actor in Actors
*/
public async flushChanges(flushreason: string = undefined): Promise<void> {
if (this.pendingChanges.data.length === 0) {
return
}
if (this.isUploading.data) {
console.log("Is already uploading... Abort")
return
}
console.log("Uploading changes due to: ", flushreason)
this.isUploading.setData(true)
try {
await this.flushChangesAsync()
this.isUploading.setData(false)
console.log("Changes flushed")
this.errors.setData([])
} catch (e) {
this._reportError(e)
this.isUploading.setData(false)
this.errors.data.push(e)
this.errors.ping()
console.error("Flushing changes failed due to", e)
}
}
public async applyAction(action: OsmChangeAction): Promise<void> {
const changeDescriptions = await action.Perform(this)
const remapped = ChangeDescriptionTools.rewriteAllIds(
changeDescriptions,
this._changesetHandler._remappings
)
remapped[0].meta.distanceToObject = this.calculateDistanceToChanges(action, remapped)
this.applyChanges(remapped)
}
public applyChanges(changes: ChangeDescription[]) {
this.pendingChanges.data.push(...changes)
this.pendingChanges.ping()
this.allChanges.data.push(...changes)
this.allChanges.ping()
}
public CreateChangesetObjects(
changes: ChangeDescription[],
downloadedOsmObjects: OsmObject[],
ignoreNoCreate: boolean = false
): {
newObjects: OsmObject[]
modifiedObjects: OsmObject[]
deletedObjects: OsmObject[]
} {
/**
* This is a rather complicated method which does a lot of stuff.
*
* Our main important data is `state` and `objects` which will determine what is returned.
* First init all those states, then we actually apply the changes.
* At last, we sort them for easy handling, which is rather boring
*/
// ------------------ INIT -------------------------
/**
* Keeps track of every object what actually happened with it
*/
const states: Map<string, "unchanged" | "created" | "modified" | "deleted"> = new Map()
/**
* Keeps track of the _new_ state of the objects, how they should end up on the database
*/
const objects: Map<string, OsmObject> = new Map<string, OsmObject>()
for (const o of downloadedOsmObjects) {
objects.set(o.type + "/" + o.id, o)
states.set(o.type + "/" + o.id, "unchanged")
}
for (const o of this.previouslyCreated) {
objects.set(o.type + "/" + o.id, o)
states.set(o.type + "/" + o.id, "unchanged")
}
// -------------- APPLY INTERMEDIATE CHANGES -----------------
for (const change of changes) {
let changed = false
const id = change.type + "/" + change.id
if (!objects.has(id)) {
// The object hasn't been seen before, so it doesn't exist yet and is newly created by its very definition
if (change.id >= 0) {
// Might be a failed fetch for simply this object
throw "Did not get an object that should be known: " + id
}
if (change.changes === undefined) {
// This object is a change to a newly created object. However, we have not seen the creation changedescription yet!
if (ignoreNoCreate) {
continue
}
throw "Not a creation of the object: " + JSON.stringify(change)
}
// This is a new object that should be created
states.set(id, "created")
let osmObj: OsmObject = undefined
switch (change.type) {
case "node":
const n = new OsmNode(change.id)
n.lat = change.changes["lat"]
n.lon = change.changes["lon"]
osmObj = n
break
case "way":
const w = new OsmWay(change.id)
w.nodes = change.changes["nodes"]
osmObj = w
break
case "relation":
const r = new OsmRelation(change.id)
r.members = change.changes["members"]
osmObj = r
break
default:
throw "Got an invalid change.type: " + change.type
}
if (osmObj === undefined) {
throw "Hmm? This is a bug"
}
objects.set(id, osmObj)
this.previouslyCreated.push(osmObj)
}
const state = states.get(id)
if (change.doDelete) {
if (state === "created") {
states.set(id, "unchanged")
} else {
states.set(id, "deleted")
}
}
const obj = objects.get(id)
// Apply tag changes
for (const kv of change.tags ?? []) {
const k = kv.k
let v = kv.v
if (v === "") {
v = undefined
}
const oldV = obj.tags[k]
if (oldV === v) {
continue
}
obj.tags[k] = v
changed = true
}
if (change.changes !== undefined) {
switch (change.type) {
case "node":
// @ts-ignore
const nlat = Utils.Round7(change.changes.lat)
// @ts-ignore
const nlon = Utils.Round7(change.changes.lon)
const n = <OsmNode>obj
if (n.lat !== nlat || n.lon !== nlon) {
n.lat = nlat
n.lon = nlon
changed = true
}
break
case "way":
const nnodes = change.changes["nodes"]
const w = <OsmWay>obj
if (!Utils.Identical(nnodes, w.nodes)) {
w.nodes = nnodes
changed = true
}
break
case "relation":
const nmembers: {
type: "node" | "way" | "relation"
ref: number
role: string
}[] = change.changes["members"]
const r = <OsmRelation>obj
if (
!Utils.Identical(nmembers, r.members, (a, b) => {
return a.role === b.role && a.type === b.type && a.ref === b.ref
})
) {
r.members = nmembers
changed = true
}
break
}
}
if (changed && states.get(id) === "unchanged") {
states.set(id, "modified")
}
}
// ----------------- SORT OBJECTS -------------------
const result = {
newObjects: [],
modifiedObjects: [],
deletedObjects: [],
}
objects.forEach((v, id) => {
const state = states.get(id)
if (state === "created") {
result.newObjects.push(v)
}
if (state === "modified") {
result.modifiedObjects.push(v)
}
if (state === "deleted") {
result.deletedObjects.push(v)
}
})
if (
!(
result.newObjects.length === 0 &&
result.modifiedObjects.length === 0 &&
result.deletedObjects.length === 0
)
) {
console.debug(
"Calculated the pending changes: ",
result.newObjects.length,
"new; ",
result.modifiedObjects.length,
"modified;",
result.deletedObjects.length,
"deleted"
)
}
return result
}
private calculateDistanceToChanges(
change: OsmChangeAction,
changeDescriptions: ChangeDescription[]
) {
const locations = this.historicalUserLocations?.features?.data
if (locations === undefined) {
// No state loaded or no locations -> we can't calculate...
return
}
if (!change.trackStatistics) {
// Probably irrelevant, such as a new helper node
return
}
if (this.state.featureSwitches.featureSwitchMorePrivacy?.data) {
return
}
const now = new Date()
const recentLocationPoints = locations
.filter((feat) => feat.geometry.type === "Point")
.filter((feat) => {
const visitTime = new Date(
(<GeoLocationPointProperties>(<any>feat.properties)).date
)
// In seconds
const diff = (now.getTime() - visitTime.getTime()) / 1000
return diff < Constants.nearbyVisitTime
})
if (recentLocationPoints.length === 0) {
// Probably no GPS enabled/no fix
return
}
// The applicable points, contain information in their properties about location, time and GPS accuracy
// They are all GeoLocationPointProperties
// We walk every change and determine the closest distance possible
// Only if the change itself does _not_ contain any coordinates, we fall back and search the original feature in the state
const changedObjectCoordinates: [number, number][] = []
{
const feature = this.state.allElements?.featuresById?.data.get(change.mainObjectId)
if (feature !== undefined) {
changedObjectCoordinates.push(GeoOperations.centerpointCoordinates(feature))
}
}
for (const changeDescription of changeDescriptions) {
const chng:
| { lat: number; lon: number }
| { coordinates: [number, number][] }
| { members } = changeDescription.changes
if (chng === undefined) {
continue
}
if (chng["lat"] !== undefined) {
changedObjectCoordinates.push([chng["lat"], chng["lon"]])
}
if (chng["coordinates"] !== undefined) {
changedObjectCoordinates.push(...chng["coordinates"])
}
}
return Math.min(
...changedObjectCoordinates.map((coor) =>
Math.min(
...recentLocationPoints.map((gpsPoint) => {
const otherCoor = GeoOperations.centerpointCoordinates(gpsPoint)
return GeoOperations.distanceBetween(coor, otherCoor)
})
)
)
)
}
/**
* Gets a single, fresh version of the requested osmObject with some error handling
*/
private async getOsmObject(id: string, downloader: OsmObjectDownloader) {
try {
try {
// Important: we do **not** cache this request, we _always_ need a fresh version!
const osmObj = await downloader.DownloadObjectAsync(id, 0)
return { id, osmObj }
} catch (e) {
const msg =
"Could not download OSM-object " +
id +
" trying again before dropping it from the changes (" +
e +
")"
this._reportError(msg)
const osmObj = await downloader.DownloadObjectAsync(id, 0)
return { id, osmObj }
}
} catch (e) {
const msg =
"Could not download OSM-object " + id + " dropping it from the changes (" + e + ")"
this._reportError(msg)
this.errors.data.push(e)
this.errors.ping()
return undefined
}
}
public fragmentChanges(
pending: ChangeDescription[],
objects: OsmObject[]
): {
refused: ChangeDescription[]
toUpload: ChangeDescription[]
} {
const refused: ChangeDescription[] = []
const toUpload: ChangeDescription[] = []
// All ids which have an 'update'
const createdIds = new Set(
pending.filter((cd) => cd.changes !== undefined).map((cd) => cd.id)
)
pending.forEach((c) => {
if (c.id < 0) {
if (createdIds.has(c.id)) {
toUpload.push(c)
} else {
this._reportError(
`Got an orphaned change. The 'creation'-change description for ${c.type}/${c.id} got lost. Permanently dropping this change:` +
JSON.stringify(c)
)
}
return
}
const matchFound = !!objects.find((o) => o.id === c.id && o.type === c.type)
if (matchFound) {
toUpload.push(c)
} else {
console.log(
"Refusing change about " +
c.type +
"/" +
c.id +
" as not in the objects. No internet?"
)
refused.push(c)
}
})
return { refused, toUpload }
}
/**
* Upload the selected changes to OSM. This is typically called with changes for a single theme
* @return pending changes which could not be uploaded for some reason; undefined or empty array if successful
*/
private async flushSelectChanges(
pending: ChangeDescription[],
openChangeset: UIEventSource<number>
): Promise<ChangeDescription[]> {
const neededIds = Changes.GetNeededIds(pending)
/* Download the latest version of the OSM-objects
* We _do not_ pass in the Changes object itself - we want the data from OSM directly in order to apply the changes
*/
const downloader = new OsmObjectDownloader(this.backend, undefined)
const osmObjects = Utils.NoNull(
await Promise.all<{ id: string; osmObj: OsmObject | "deleted" }>(
neededIds.map((id) => this.getOsmObject(id, downloader))
)
)
// Drop changes to deleted items
for (const { osmObj, id } of osmObjects) {
if (osmObj === "deleted") {
pending = pending.filter((ch) => ch.type + "/" + ch.id !== id)
}
}
const objects = osmObjects
.filter((obj) => obj.osmObj !== "deleted")
.map((obj) => <OsmObject>obj.osmObj)
if (this._leftRightSensitive) {
objects.forEach((obj) => SimpleMetaTagger.removeBothTagging(obj.tags))
}
if (pending.length == 0) {
console.log("No pending changes...")
return undefined
}
const metatags = this.buildChangesetTags(pending)
let { toUpload, refused } = this.fragmentChanges(pending, objects)
if (toUpload.length === 0) {
return refused
}
await this._changesetHandler.UploadChangeset(
(csId, remappings) => {
if (remappings.size > 0) {
toUpload = toUpload.map((ch) =>
ChangeDescriptionTools.rewriteIds(ch, remappings)
)
}
const changes: {
newObjects: OsmObject[]
modifiedObjects: OsmObject[]
deletedObjects: OsmObject[]
} = this.CreateChangesetObjects(toUpload, objects)
return Changes.buildChangesetXML("" + csId, changes)
},
metatags,
openChangeset
)
console.log("Upload successful! Refused changes are", refused)
return refused
}
/**
* Builds all the changeset tags, such as `theme=cyclofix; answer=42; add-image: 5`, ...
*/
private buildChangesetTags(pending: ChangeDescription[]) {
// Build statistics for the changeset tags
const perType = Array.from(
Utils.Hist(
pending
.filter(
(descr) =>
descr.meta.changeType !== undefined && descr.meta.changeType !== null
)
.map((descr) => descr.meta.changeType)
),
([key, count]) => ({
key: key,
value: count,
aggregate: true,
})
)
const motivations = pending
.filter((descr) => descr.meta.specialMotivation !== undefined)
.map((descr) => ({
key: descr.meta.changeType + ":" + descr.type + "/" + descr.id,
value: descr.meta.specialMotivation,
}))
const distances = Utils.NoNull(pending.map((descr) => descr.meta.distanceToObject))
distances.sort((a, b) => a - b)
const perBinCount = Constants.distanceToChangeObjectBins.map(() => 0)
let j = 0
const maxDistances = Constants.distanceToChangeObjectBins
for (let i = 0; i < maxDistances.length; i++) {
const maxDistance = maxDistances[i]
// distances is sorted in ascending order, so as soon as one is to big, all the resting elements will be bigger too
while (j < distances.length && distances[j] < maxDistance) {
perBinCount[i]++
j++
}
}
const perBinMessage = Utils.NoNull(
perBinCount.map((count, i) => {
if (count === 0) {
return undefined
}
const maxD = maxDistances[i]
let key = `change_within_${maxD}m`
if (maxD === Number.MAX_VALUE) {
key = `change_over_${maxDistances[i - 1]}m`
}
return {
key,
value: count,
aggregate: true,
}
})
)
// This method is only called with changedescriptions for this theme
const theme = pending[0].meta.theme
let comment = "Adding data with #MapComplete for theme #" + theme
if (this.extraComment.data !== undefined) {
comment += "\n\n" + this.extraComment.data
}
const metatags: ChangesetTag[] = [
{
key: "comment",
value: comment,
},
{
key: "theme",
value: theme,
},
...perType,
...motivations,
...perBinMessage,
]
return metatags
}
private async flushChangesAsync(): Promise<void> {
const self = this
try {
// At last, we build the changeset and upload
const pending = self.pendingChanges.data
const pendingPerTheme = new Map<string, ChangeDescription[]>()
for (const changeDescription of pending) {
const theme = changeDescription.meta.theme
if (!pendingPerTheme.has(theme)) {
pendingPerTheme.set(theme, [])
}
pendingPerTheme.get(theme).push(changeDescription)
}
const refusedChanges: ChangeDescription[][] = await Promise.all(
Array.from(pendingPerTheme, async ([theme, pendingChanges]) => {
try {
const openChangeset = UIEventSource.asInt(
this.state.osmConnection.GetPreference(
"current-open-changeset-" + theme
)
)
console.log(
"Using current-open-changeset-" +
theme +
" from the preferences, got " +
openChangeset.data
)
const refused = await self.flushSelectChanges(pendingChanges, openChangeset)
if (!refused) {
this.errors.setData([])
}
return refused
} catch (e) {
this._reportError(e)
console.error("Could not upload some changes:", e)
this.errors.data.push(e)
this.errors.ping()
return pendingChanges
}
})
)
// We keep all the refused changes to try them again
this.pendingChanges.setData(refusedChanges.flatMap((c) => c).filter(c => {
if(c.id === null || c.id === undefined){
return false
}
return true
}))
} catch (e) {
console.error(
"Could not handle changes - probably an old, pending changeset in localstorage with an invalid format; erasing those",
e
)
this.errors.data.push(e)
this.errors.ping()
self.pendingChanges.setData([])
} finally {
self.isUploading.setData(false)
}
}
}