import { OsmNode, OsmObject, OsmRelation, OsmWay } from "./OsmObject" import { 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 FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore" import OsmObjectDownloader from "./OsmObjectDownloader" /** * Handles all changes made to OSM. * Needs an authenticator via OsmConnection */ export class Changes { public readonly pendingChanges: UIEventSource = LocalStorageSource.GetParsed("pending-changes", []) public readonly allChanges = new UIEventSource(undefined) public readonly state: { allElements?: IndexedFeatureSource; osmConnection: OsmConnection } public readonly extraComment: UIEventSource = new UIEventSource(undefined) public readonly backend: string public readonly isUploading = new UIEventSource(false) private readonly historicalUserLocations?: FeatureSource private _nextId: number = -1 // Newly assigned ID's are negative private readonly previouslyCreated: OsmObject[] = [] private readonly _leftRightSensitive: boolean private readonly _changesetHandler: ChangesetHandler constructor( state: { dryRun: Store allElements?: IndexedFeatureSource featurePropertiesStore?: FeaturePropertiesStore osmConnection: OsmConnection historicalUserLocations?: FeatureSource }, leftRightSensitive: boolean = false ) { 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) ?? [])) this.state = state this.backend = state.osmConnection.Backend() this._changesetHandler = new ChangesetHandler( state.dryRun, state.osmConnection, state.featurePropertiesStore, this ) 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 } static createChangesetFor( csId: string, allChanges: { modifiedObjects: OsmObject[] newObjects: OsmObject[] deletedObjects: OsmObject[] } ): string { const changedElements = allChanges.modifiedObjects ?? [] const newElements = allChanges.newObjects ?? [] const deletedElements = allChanges.deletedObjects ?? [] let changes = `` if (newElements.length > 0) { changes += "\n\n" + newElements.map((e) => e.ChangesetXML(csId)).join("\n") + "" } if (changedElements.length > 0) { changes += "\n\n" + changedElements.map((e) => e.ChangesetXML(csId)).join("\n") + "\n" } if (deletedElements.length > 0) { changes += "\n\n" + deletedElements.map((e) => e.ChangesetXML(csId)).join("\n") + "\n" } changes += "" return changes } private 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() { 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 { 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 { const csNumber = await this.flushChangesAsync() this.isUploading.setData(false) console.log("Changes flushed. Your changeset is " + csNumber) } catch (e) { this.isUploading.setData(false) console.error("Flushing changes failed due to", e) } } public async applyAction(action: OsmChangeAction): Promise { const changeDescriptions = await action.Perform(this) changeDescriptions[0].meta.distanceToObject = this.calculateDistanceToChanges( action, changeDescriptions ) this.applyChanges(changeDescriptions) } public applyChanges(changes: ChangeDescription[]) { console.log("Received changes:", changes) this.pendingChanges.data.push(...changes) this.pendingChanges.ping() this.allChanges.data.push(...changes) this.allChanges.ping() } public CreateChangesetObjects( changes: ChangeDescription[], downloadedOsmObjects: OsmObject[] ): { newObjects: OsmObject[] modifiedObjects: OsmObject[] deletedObjects: OsmObject[] } { const objects: Map = new Map() const states: Map = new Map() 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") } 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! throw "Not a creation of the object" } // This is a new object that should be created states.set(id, "created") console.log("Creating object for changeDescription", change) 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 } 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 = obj if (n.lat !== nlat || n.lon !== nlon) { console.log("Node moved:", n.lat, nlat, n.lon, nlon) n.lat = nlat n.lon = nlon changed = true } break case "way": const nnodes = change.changes["nodes"] const w = 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 = 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") } } 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) } }) console.debug( "Calculated the pending changes: ", result.newObjects.length, "new; ", result.modifiedObjects.length, "modified;", result.deletedObjects, "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 } const now = new Date() const recentLocationPoints = locations .filter((feat) => feat.geometry.type === "Point") .filter((feat) => { const visitTime = new Date( ((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) }) ) ) ) } /** * Upload the selected changes to OSM. * Returns 'true' if successful and if they can be removed */ private async flushSelectChanges( pending: ChangeDescription[], openChangeset: UIEventSource ): Promise { const self = this const neededIds = Changes.GetNeededIds(pending) // 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) let osmObjects = await Promise.all<{ id: string; osmObj: OsmObject | "deleted" }>( neededIds.map(async (id) => { try { const osmObj = await downloader.DownloadObjectAsync(id) return { id, osmObj } } catch (e) { console.error( "Could not download OSM-object", id, " dropping it from the changes (" + e + ")" ) return undefined } }) ) osmObjects = Utils.NoNull(osmObjects) 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) => obj.osmObj) if (this._leftRightSensitive) { objects.forEach((obj) => SimpleMetaTagger.removeBothTagging(obj.tags)) } console.log("Got the fresh objects!", objects, "pending: ", pending) if (pending.length == 0) { console.log("No pending changes...") return true } 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, ] await this._changesetHandler.UploadChangeset( (csId, remappings) => { if (remappings.size > 0) { console.log("Rewriting pending changes from", pending, "with", remappings) pending = pending.map((ch) => ChangeDescriptionTools.rewriteIds(ch, remappings)) console.log("Result is", pending) } const changes: { newObjects: OsmObject[] modifiedObjects: OsmObject[] deletedObjects: OsmObject[] } = self.CreateChangesetObjects(pending, objects) return Changes.createChangesetFor("" + csId, changes) }, metatags, openChangeset ) console.log("Upload successfull!") return true } private async flushChangesAsync(): Promise { const self = this try { // At last, we build the changeset and upload const pending = self.pendingChanges.data const pendingPerTheme = new Map() for (const changeDescription of pending) { const theme = changeDescription.meta.theme if (!pendingPerTheme.has(theme)) { pendingPerTheme.set(theme, []) } pendingPerTheme.get(theme).push(changeDescription) } const successes = await Promise.all( Array.from(pendingPerTheme, async ([theme, pendingChanges]) => { try { const openChangeset = this.state.osmConnection .GetPreference("current-open-changeset-" + theme) .sync( (str) => { const n = Number(str) if (isNaN(n)) { return undefined } return n }, [], (n) => "" + n ) console.log( "Using current-open-changeset-" + theme + " from the preferences, got " + openChangeset.data ) return await self.flushSelectChanges(pendingChanges, openChangeset) } catch (e) { console.error("Could not upload some changes:", e) return false } }) ) if (!successes.some((s) => s == false)) { // All changes successfull, we clear the data! this.pendingChanges.setData([]) } } catch (e) { console.error( "Could not handle changes - probably an old, pending changeset in localstorage with an invalid format; erasing those", e ) self.pendingChanges.setData([]) } finally { self.isUploading.setData(false) } } }