forked from MapComplete/MapComplete
merge develop
This commit is contained in:
commit
3e4708b0b9
506 changed files with 7945 additions and 74587 deletions
|
|
@ -1,19 +1,18 @@
|
|||
import { QueryParameters } from "../Web/QueryParameters"
|
||||
import { BBox } from "../BBox"
|
||||
import Constants from "../../Models/Constants"
|
||||
import { GeoLocationState } from "../State/GeoLocationState"
|
||||
import { GeoLocationPointProperties, GeoLocationState } from "../State/GeoLocationState"
|
||||
import { Store, UIEventSource } from "../UIEventSource"
|
||||
import { Feature, LineString, Point } from "geojson"
|
||||
import { FeatureSource, WritableFeatureSource } from "../FeatureSource/FeatureSource"
|
||||
import { LocalStorageSource } from "../Web/LocalStorageSource"
|
||||
import { GeoOperations } from "../GeoOperations"
|
||||
import { OsmTags } from "../../Models/OsmFeature"
|
||||
import StaticFeatureSource, {
|
||||
WritableStaticFeatureSource,
|
||||
} from "../FeatureSource/Sources/StaticFeatureSource"
|
||||
import StaticFeatureSource, { WritableStaticFeatureSource } from "../FeatureSource/Sources/StaticFeatureSource"
|
||||
import { MapProperties } from "../../Models/MapProperties"
|
||||
import { Orientation } from "../../Sensors/Orientation"
|
||||
;("use strict")
|
||||
|
||||
("use strict")
|
||||
/**
|
||||
* The geolocation-handler takes a map-location and a geolocation state.
|
||||
* It'll move the map as appropriate given the state of the geolocation-API
|
||||
|
|
@ -25,12 +24,12 @@ export default class GeoLocationHandler {
|
|||
/**
|
||||
* The location as delivered by the GPS, wrapped as FeatureSource
|
||||
*/
|
||||
public currentUserLocation: FeatureSource
|
||||
public currentUserLocation: FeatureSource<Feature<Point, GeoLocationPointProperties>>
|
||||
|
||||
/**
|
||||
* All previously visited points (as 'Point'-objects), with their metadata
|
||||
*/
|
||||
public historicalUserLocations: WritableFeatureSource<Feature<Point>>
|
||||
public historicalUserLocations: WritableFeatureSource<Feature<Point, GeoLocationPointProperties>>
|
||||
|
||||
/**
|
||||
* A featureSource containing a single linestring which has the GPS-history of the user.
|
||||
|
|
@ -151,27 +150,29 @@ export default class GeoLocationHandler {
|
|||
}
|
||||
|
||||
private CopyGeolocationIntoMapstate() {
|
||||
const features: UIEventSource<Feature[]> = new UIEventSource<Feature[]>([])
|
||||
const features: UIEventSource<Feature<Point, GeoLocationPointProperties>[]> = new UIEventSource<Feature<Point, GeoLocationPointProperties>[]>([])
|
||||
this.currentUserLocation = new StaticFeatureSource(features)
|
||||
let i = 0
|
||||
this.geolocationState.currentGPSLocation.addCallbackAndRunD((location) => {
|
||||
const properties = {
|
||||
const properties: GeoLocationPointProperties = {
|
||||
id: "gps-" + i,
|
||||
"user:location": "yes",
|
||||
date: new Date().toISOString(),
|
||||
// GeolocationObject behaves really weird when indexing, so copying it one by one is the most stable
|
||||
accuracy: location.accuracy,
|
||||
speed: location.speed,
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
altitude: location.altitude,
|
||||
altitudeAccuracy: location.altitudeAccuracy,
|
||||
heading: location.heading,
|
||||
alpha: Orientation.singleton.gotMeasurement.data
|
||||
? "" + Orientation.singleton.alpha.data
|
||||
? ("" + Orientation.singleton.alpha.data)
|
||||
: undefined,
|
||||
}
|
||||
i++
|
||||
|
||||
const feature = <Feature>{
|
||||
const feature = <Feature<Point, GeoLocationPointProperties>>{
|
||||
type: "Feature",
|
||||
properties,
|
||||
geometry: {
|
||||
|
|
@ -184,7 +185,7 @@ export default class GeoLocationHandler {
|
|||
}
|
||||
|
||||
private initUserLocationTrail() {
|
||||
const features = LocalStorageSource.getParsed<Feature<Point>[]>("gps_location_history", [])
|
||||
const features = LocalStorageSource.getParsed<Feature<Point, GeoLocationPointProperties>[]>("gps_location_history", [])
|
||||
const now = new Date().getTime()
|
||||
features.data = features.data.filter((ff) => {
|
||||
if (ff.properties === undefined) {
|
||||
|
|
@ -197,7 +198,7 @@ export default class GeoLocationHandler {
|
|||
)
|
||||
})
|
||||
features.ping()
|
||||
this.currentUserLocation?.features?.addCallbackAndRunD(([location]: [Feature<Point>]) => {
|
||||
this.currentUserLocation?.features?.addCallbackAndRunD(([location]: [Feature<Point, GeoLocationPointProperties>]) => {
|
||||
if (location === undefined) {
|
||||
return
|
||||
}
|
||||
|
|
@ -231,7 +232,7 @@ export default class GeoLocationHandler {
|
|||
features.ping()
|
||||
})
|
||||
|
||||
this.historicalUserLocations = new WritableStaticFeatureSource<Feature<Point>>(features)
|
||||
this.historicalUserLocations = new WritableStaticFeatureSource<Feature<Point, GeoLocationPointProperties>>(features)
|
||||
|
||||
const asLine = features.map((allPoints) => {
|
||||
if (allPoints === undefined || allPoints.length < 2) {
|
||||
|
|
|
|||
|
|
@ -27,8 +27,7 @@ export default class PendingChangesUploader {
|
|||
}
|
||||
|
||||
document.addEventListener("mouseout", (e) => {
|
||||
// @ts-ignore
|
||||
if (!e.toElement && !e.relatedTarget) {
|
||||
if (!e["toElement"] && !e.relatedTarget) {
|
||||
changes.flushChanges("Flushing changes due to focus lost")
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ export class BBox {
|
|||
this.minLon = Math.min(coors[0], coors[2])
|
||||
this.maxLat = Math.max(coors[1], coors[3])
|
||||
this.minLat = Math.min(coors[1], coors[3])
|
||||
this.check()
|
||||
return
|
||||
}
|
||||
this.maxLat = -90
|
||||
|
|
@ -325,6 +326,11 @@ export class BBox {
|
|||
console.trace("BBox with NaN detected:", this)
|
||||
throw "BBOX has NAN"
|
||||
}
|
||||
if (this.minLat < -90 || this.maxLat > 90) {
|
||||
const msg = "Invalid BBOX detected: latitude is out of range. Did you swap lat & lon somewhere? min:" + this.minLat + "; max:" + this.maxLat
|
||||
console.trace(msg)
|
||||
throw msg
|
||||
}
|
||||
}
|
||||
|
||||
public overlapsWithFeature(f: Feature) {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import { FeatureSource } from "../FeatureSource"
|
||||
import { UIEventSource } from "../../UIEventSource"
|
||||
import { OsmTags } from "../../../Models/OsmFeature"
|
||||
|
||||
/**
|
||||
* Constructs a UIEventStore for the properties of every Feature, indexed by id
|
||||
|
|
@ -53,12 +52,12 @@ export default class FeaturePropertiesStore {
|
|||
public getStore(id: string): UIEventSource<Record<string, string>> {
|
||||
const store = this._elements.get(id)
|
||||
if (store === undefined) {
|
||||
console.error("PANIC: no store for", id)
|
||||
console.warn("PANIC: no properties store for", id)
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
public trackFeature(feature: { properties: OsmTags }) {
|
||||
public trackFeature(feature: { properties: Record<string, string> }) {
|
||||
const id = feature.properties.id
|
||||
if (id === undefined) {
|
||||
console.trace("Error: feature without ID:", feature)
|
||||
|
|
@ -67,7 +66,7 @@ export default class FeaturePropertiesStore {
|
|||
|
||||
const source = this._elements.get(id)
|
||||
if (source === undefined) {
|
||||
this._elements.set(id, new UIEventSource<any>(feature.properties))
|
||||
this._elements.set(id, new UIEventSource<Record<string, string>>(feature.properties))
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -25,22 +25,8 @@ export default class GeoIndexedStore implements FeatureSource {
|
|||
*/
|
||||
public GetFeaturesWithin(bbox: BBox): Feature[] {
|
||||
const bboxFeature = bbox.asGeojsonCached()
|
||||
return this.features.data.filter((f) => {
|
||||
if (f.geometry.type === "Point") {
|
||||
return bbox.contains(<[number, number]>f.geometry.coordinates)
|
||||
}
|
||||
if (f.geometry.type === "LineString") {
|
||||
const intersection = GeoOperations.intersect(
|
||||
BBox.get(f).asGeojsonCached(),
|
||||
bboxFeature
|
||||
)
|
||||
return intersection !== undefined
|
||||
}
|
||||
if (f.geometry.type === "Polygon" || f.geometry.type === "MultiPolygon") {
|
||||
return GeoOperations.intersect(f, bboxFeature) !== undefined
|
||||
}
|
||||
return GeoOperations.intersect(f, bboxFeature) !== undefined
|
||||
})
|
||||
return this.features.data.filter((f) =>
|
||||
GeoOperations.completelyWithin(f, bboxFeature))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
* Applies geometry changes from 'Changes' onto every feature of a featureSource
|
||||
*/
|
||||
import { Changes } from "../../Osm/Changes"
|
||||
import { UIEventSource } from "../../UIEventSource"
|
||||
import { Stores, UIEventSource } from "../../UIEventSource"
|
||||
import { FeatureSource, IndexedFeatureSource } from "../FeatureSource"
|
||||
import { ChangeDescription, ChangeDescriptionTools } from "../../Osm/Actions/ChangeDescription"
|
||||
import { Feature } from "geojson"
|
||||
|
|
@ -19,10 +19,9 @@ export default class ChangeGeometryApplicator implements FeatureSource {
|
|||
|
||||
this.features = new UIEventSource<Feature[]>(undefined)
|
||||
|
||||
const self = this
|
||||
source.features.addCallbackAndRunD((_) => self.update())
|
||||
source.features.addCallbackAndRunD(() => this.update())
|
||||
|
||||
changes.allChanges.addCallbackAndRunD((_) => self.update())
|
||||
Stores.ListStabilized(changes.allChanges).addCallbackAndRunD(() => this.update())
|
||||
}
|
||||
|
||||
private update() {
|
||||
|
|
@ -65,7 +64,7 @@ export default class ChangeGeometryApplicator implements FeatureSource {
|
|||
|
||||
// Allright! We have a feature to rewrite!
|
||||
const copy = {
|
||||
...feature,
|
||||
...feature
|
||||
}
|
||||
// We only apply the last change as that one'll have the latest geometry
|
||||
const change = changesForFeature[changesForFeature.length - 1]
|
||||
|
|
|
|||
|
|
@ -10,15 +10,16 @@ import {
|
|||
MultiPolygon,
|
||||
Point,
|
||||
Polygon,
|
||||
Position,
|
||||
Position
|
||||
} from "geojson"
|
||||
import { Tiles } from "../Models/TileRange"
|
||||
import { Utils } from "../Utils"
|
||||
;("use strict")
|
||||
|
||||
("use strict")
|
||||
|
||||
export class GeoOperations {
|
||||
private static readonly _earthRadius = 6378137
|
||||
private static readonly _originShift = (2 * Math.PI * GeoOperations._earthRadius) / 2
|
||||
private static readonly _earthRadius: number = 6378137
|
||||
private static readonly _originShift: number = (2 * Math.PI * GeoOperations._earthRadius) / 2
|
||||
private static readonly directions = ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] as const
|
||||
private static readonly directionsRelative = [
|
||||
"straight",
|
||||
|
|
@ -480,7 +481,7 @@ export class GeoOperations {
|
|||
const lon = lonLat[0]
|
||||
const lat = lonLat[1]
|
||||
const x = (180 * lon) / GeoOperations._originShift
|
||||
let y = (180 * lat) / GeoOperations._originShiftcons
|
||||
let y = (180 * lat) / GeoOperations._originShift
|
||||
y = (180 / Math.PI) * (2 * Math.atan(Math.exp((y * Math.PI) / 180)) - Math.PI / 2)
|
||||
return [x, y]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Mapillary } from "./Mapillary"
|
|||
import { WikimediaImageProvider } from "./WikimediaImageProvider"
|
||||
import { Imgur } from "./Imgur"
|
||||
import GenericImageProvider from "./GenericImageProvider"
|
||||
import { ImmutableStore, Store, UIEventSource } from "../UIEventSource"
|
||||
import { ImmutableStore, Store, Stores } from "../UIEventSource"
|
||||
import ImageProvider, { ProvidedImage } from "./ImageProvider"
|
||||
import { WikidataImageProvider } from "./WikidataImageProvider"
|
||||
import Panoramax from "./Panoramax"
|
||||
|
|
@ -36,8 +36,8 @@ export default class AllImageProviders {
|
|||
public static apiUrls: string[] = [].concat(
|
||||
...AllImageProviders.imageAttributionSources.map((src) => src.apiUrls())
|
||||
)
|
||||
public static defaultKeys = [].concat(
|
||||
AllImageProviders.imageAttributionSources.map((provider) => provider.defaultKeyPrefixes)
|
||||
public static defaultKeys: string[] = [].concat(
|
||||
...AllImageProviders.imageAttributionSources.map((provider) => provider.defaultKeyPrefixes)
|
||||
)
|
||||
private static providersByName = {
|
||||
imgur: Imgur.singleton,
|
||||
|
|
@ -122,7 +122,6 @@ export default class AllImageProviders {
|
|||
return this._cachedImageStores[cachekey]
|
||||
}
|
||||
|
||||
const source = new UIEventSource([])
|
||||
const allSources: Store<ProvidedImage[]>[] = []
|
||||
for (const imageProvider of AllImageProviders.imageAttributionSources) {
|
||||
/*
|
||||
|
|
@ -132,12 +131,11 @@ export default class AllImageProviders {
|
|||
const prefixes = tagKey ?? imageProvider.defaultKeyPrefixes
|
||||
const singleSource = tags.bindD((tags) => imageProvider.getRelevantUrls(tags, prefixes))
|
||||
allSources.push(singleSource)
|
||||
singleSource.addCallbackAndRunD((_) => {
|
||||
const all: ProvidedImage[] = [].concat(...allSources.map((source) => source.data))
|
||||
const dedup = Utils.DedupOnId(all, (i) => i?.id ?? i?.url)
|
||||
source.set(dedup)
|
||||
})
|
||||
}
|
||||
const source = Stores.fromStoresArray(allSources).map(result => {
|
||||
const all = [].concat(...result)
|
||||
return Utils.DedupOnId(all, (i) => i?.id ?? i?.url)
|
||||
})
|
||||
this._cachedImageStores[cachekey] = source
|
||||
return source
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,34 +1,53 @@
|
|||
import { ImageUploader, UploadResult } from "./ImageUploader"
|
||||
import LinkImageAction from "../Osm/Actions/LinkImageAction"
|
||||
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
|
||||
import { OsmId, OsmTags } from "../../Models/OsmFeature"
|
||||
import { NoteId, OsmId, OsmTags } from "../../Models/OsmFeature"
|
||||
import ThemeConfig from "../../Models/ThemeConfig/ThemeConfig"
|
||||
import { Store, UIEventSource } from "../UIEventSource"
|
||||
import { OsmConnection } from "../Osm/OsmConnection"
|
||||
import { Changes } from "../Osm/Changes"
|
||||
import Translations from "../../UI/i18n/Translations"
|
||||
import { Translation } from "../../UI/i18n/Translation"
|
||||
import { IndexedFeatureSource } from "../FeatureSource/FeatureSource"
|
||||
import { GeoOperations } from "../GeoOperations"
|
||||
import { Feature } from "geojson"
|
||||
import ImageUploadQueue, { ImageUploadArguments } from "./ImageUploadQueue"
|
||||
import { GeoOperations } from "../GeoOperations"
|
||||
import NoteCommentElement from "../../UI/Popup/Notes/NoteCommentElement"
|
||||
import OsmObjectDownloader from "../Osm/OsmObjectDownloader"
|
||||
|
||||
/**
|
||||
* The ImageUploadManager has a
|
||||
*/
|
||||
export class ImageUploadManager {
|
||||
private readonly _queue: ImageUploadQueue = ImageUploadQueue.singleton
|
||||
private readonly _uploader: ImageUploader
|
||||
private readonly _featureProperties: FeaturePropertiesStore
|
||||
private readonly _theme: ThemeConfig
|
||||
private readonly _indexedFeatures: IndexedFeatureSource
|
||||
private readonly _gps: Store<GeolocationCoordinates | undefined>
|
||||
private readonly _uploadStarted: Map<string, UIEventSource<number>> = new Map()
|
||||
private readonly _uploadFinished: Map<string, UIEventSource<number>> = new Map()
|
||||
private readonly _uploadFailed: Map<string, UIEventSource<number>> = new Map()
|
||||
private readonly _uploadRetried: Map<string, UIEventSource<number>> = new Map()
|
||||
private readonly _uploadRetriedSuccess: Map<string, UIEventSource<number>> = new Map()
|
||||
private readonly _osmConnection: OsmConnection
|
||||
private readonly _changes: Changes
|
||||
public readonly isUploading: Store<boolean>
|
||||
|
||||
/**
|
||||
* Keeps track of the _features_ for which an upload was successfull. Only used to give an indication.
|
||||
* Every time an image is uploaded, the featureID is added to the list. Not persisted (and should not be)
|
||||
*/
|
||||
private readonly _successfull: UIEventSource<string[]> = new UIEventSource<string[]>([])
|
||||
public readonly successfull: Store<string[]> = this._successfull
|
||||
/**
|
||||
* Keeps track of the _features_ for which an upload failed. Only used to give an indication to the user.
|
||||
* Every time an image upload fails, the featureID is added to the list. Not persisted (and should not be)
|
||||
*/
|
||||
private readonly _fails: UIEventSource<ImageUploadArguments[]> = new UIEventSource<ImageUploadArguments[]>([])
|
||||
public readonly fails: Store<string[]> = this._fails.map(args => args.map(a => a.featureId))
|
||||
/**
|
||||
* FeatureIDs of queued items
|
||||
*/
|
||||
public readonly queued: Store<string[]> = this._queue.imagesInQueue.map(queue => queue.map(q => q.featureId))
|
||||
public readonly queuedArgs = this._queue.imagesInQueue
|
||||
/**
|
||||
* The feature for which an upload is currently running
|
||||
*/
|
||||
public readonly _isUploading: UIEventSource<string | undefined> = new UIEventSource(undefined)
|
||||
public readonly isUploading: Store<string | undefined> = this._isUploading
|
||||
private readonly _reportError: (
|
||||
message: string | Error | XMLHttpRequest,
|
||||
extramessage?: string
|
||||
|
|
@ -41,7 +60,6 @@ export class ImageUploadManager {
|
|||
osmConnection: OsmConnection,
|
||||
changes: Changes,
|
||||
gpsLocation: Store<GeolocationCoordinates | undefined>,
|
||||
allFeatures: IndexedFeatureSource,
|
||||
reportError: (
|
||||
message: string | Error | XMLHttpRequest,
|
||||
extramessage?: string
|
||||
|
|
@ -52,41 +70,8 @@ export class ImageUploadManager {
|
|||
this._theme = layout
|
||||
this._osmConnection = osmConnection
|
||||
this._changes = changes
|
||||
this._indexedFeatures = allFeatures
|
||||
this._gps = gpsLocation
|
||||
this._reportError = reportError
|
||||
|
||||
const failed = this.getCounterFor(this._uploadFailed, "*")
|
||||
const done = this.getCounterFor(this._uploadFinished, "*")
|
||||
|
||||
this.isUploading = this.getCounterFor(this._uploadStarted, "*").map(
|
||||
(startedCount) => {
|
||||
return startedCount > failed.data + done.data
|
||||
},
|
||||
[failed, done]
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets various counters.
|
||||
* Note that counters can only increase
|
||||
* If a retry was a success, both 'retrySuccess' _and_ 'uploadFinished' will be increased
|
||||
* @param featureId the id of the feature you want information for. '*' has a global counter
|
||||
*/
|
||||
public getCountsFor(featureId: string | "*"): {
|
||||
retried: Store<number>
|
||||
uploadStarted: Store<number>
|
||||
retrySuccess: Store<number>
|
||||
failed: Store<number>
|
||||
uploadFinished: Store<number>
|
||||
} {
|
||||
return {
|
||||
uploadStarted: this.getCounterFor(this._uploadStarted, featureId),
|
||||
uploadFinished: this.getCounterFor(this._uploadFinished, featureId),
|
||||
retried: this.getCounterFor(this._uploadRetried, featureId),
|
||||
failed: this.getCounterFor(this._uploadFailed, featureId),
|
||||
retrySuccess: this.getCounterFor(this._uploadRetriedSuccess, featureId),
|
||||
}
|
||||
}
|
||||
|
||||
public canBeUploaded(file: File): true | { error: Translation } {
|
||||
|
|
@ -94,162 +79,230 @@ export class ImageUploadManager {
|
|||
if (sizeInBytes > this._uploader.maxFileSizeInMegabytes * 1000000) {
|
||||
const error = Translations.t.image.toBig.Subs({
|
||||
actual_size: Math.floor(sizeInBytes / 1000000) + "MB",
|
||||
max_size: this._uploader.maxFileSizeInMegabytes + "MB",
|
||||
max_size: this._uploader.maxFileSizeInMegabytes + "MB"
|
||||
})
|
||||
return { error }
|
||||
}
|
||||
const ext = file.name.split(".").at(-1).toLowerCase()
|
||||
if (ext !== "jpg" && ext !== "jpeg") {
|
||||
return { error: new Translation({ en: "Only JPG-files are allowed" }) }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads the given image, applies the correct title and license for the known user.
|
||||
* Will then add this image to the OSM-feature or the OSM-note
|
||||
* Will then add this image to the OSM-feature or the OSM-note automatically, based on the ID of the feature.
|
||||
* Note: the image will actually be added to the queue. If the image-upload fails, this will be attempted when visiting MC again
|
||||
* @param file a jpg file to upload
|
||||
* @param tagsStore The tags of the feature
|
||||
* @param targetKey Use this key to save the attribute under. Default: 'image'
|
||||
* @param noblur if true, then the api call will indicate that the image is already blurred. The server won't apply blurring in this case
|
||||
* @param feature the feature this image is about. Will be used as fallback to get the GPS-coordinates
|
||||
*/
|
||||
public async uploadImageAndApply(
|
||||
public uploadImageAndApply(
|
||||
file: File,
|
||||
tagsStore: UIEventSource<OsmTags>,
|
||||
targetKey: string,
|
||||
noblur: boolean,
|
||||
feature: Feature
|
||||
): Promise<void> {
|
||||
feature: Feature,
|
||||
options: {
|
||||
ignoreGPS: boolean | false
|
||||
}
|
||||
): void {
|
||||
const canBeUploaded = this.canBeUploaded(file)
|
||||
if (canBeUploaded !== true) {
|
||||
throw canBeUploaded.error
|
||||
}
|
||||
|
||||
const tags = tagsStore.data
|
||||
const tags: OsmTags = tagsStore.data
|
||||
const featureId = <OsmId | NoteId>tags.id
|
||||
|
||||
const featureId = <OsmId>tags.id
|
||||
|
||||
const author = this._osmConnection.userDetails.data.name
|
||||
const author = this._osmConnection?.userDetails?.data?.name ?? "Anonymous" // Might be a note upload
|
||||
|
||||
const uploadResult = await this.uploadImageWithLicense(
|
||||
featureId,
|
||||
author,
|
||||
file,
|
||||
targetKey,
|
||||
noblur,
|
||||
feature
|
||||
)
|
||||
if (!uploadResult) {
|
||||
return
|
||||
/**
|
||||
* The location to upload the image with.
|
||||
* Note that EXIF-data will always be trusted _more_ by the uploader
|
||||
*/
|
||||
let location: [number, number] = GeoOperations.centerpointCoordinates(feature)
|
||||
if (this._gps.data && !options?.ignoreGPS) {
|
||||
location = [this._gps.data.longitude, this._gps.data.latitude]
|
||||
}
|
||||
const properties = this._featureProperties.getStore(featureId)
|
||||
|
||||
const action = new LinkImageAction(
|
||||
featureId,
|
||||
uploadResult.key,
|
||||
uploadResult.value,
|
||||
properties,
|
||||
{
|
||||
theme: tags?.data?.["_orig_theme"] ?? this._theme.id,
|
||||
changeType: "add-image",
|
||||
}
|
||||
)
|
||||
const args: ImageUploadArguments = {
|
||||
location,
|
||||
date: new Date().getTime(),
|
||||
layoutId: this._theme.id,
|
||||
author, blob: file, featureId, noblur, targetKey
|
||||
}
|
||||
console.log("Args are", args)
|
||||
|
||||
this._queue.add(args)
|
||||
this.uploadQueue()
|
||||
|
||||
await this._changes.applyAction(action)
|
||||
}
|
||||
|
||||
public async uploadImageWithLicense(
|
||||
featureId: string,
|
||||
author: string,
|
||||
blob: File,
|
||||
targetKey: string | undefined,
|
||||
noblur: boolean,
|
||||
feature: Feature,
|
||||
ignoreGps: boolean = false
|
||||
): Promise<UploadResult> {
|
||||
this.increaseCountFor(this._uploadStarted, featureId)
|
||||
/**
|
||||
* Attempts to upload all items in the queue
|
||||
*/
|
||||
private uploadingAll = false
|
||||
|
||||
public async uploadQueue() {
|
||||
if (this.uploadingAll) {
|
||||
return
|
||||
}
|
||||
const queue = this._queue.imagesInQueue.data ?? []
|
||||
if (queue.length === 0) {
|
||||
return
|
||||
}
|
||||
console.log("Checking image upload queue and uploading if needed")
|
||||
this.uploadingAll = true
|
||||
try {
|
||||
for (const imageToUpload of queue) {
|
||||
await this.handleQueueItem(imageToUpload)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error while handling the queue:", e)
|
||||
await this._reportError("Image Upload Manager: queue stopped working:", e)
|
||||
} finally {
|
||||
this.uploadingAll = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles a queue item:
|
||||
* - starts upload
|
||||
* - indicates that the upload is busy
|
||||
* - Applies the action to the correct element
|
||||
* - indicates failure
|
||||
* @private
|
||||
*/
|
||||
private async handleQueueItem(args: ImageUploadArguments): Promise<void> {
|
||||
console.log("Handling queue item", args)
|
||||
if (!args) {
|
||||
return
|
||||
}
|
||||
this._isUploading.set(args.featureId)
|
||||
|
||||
let result: UploadResult = undefined
|
||||
let attempts = 2
|
||||
while (attempts > 0 && result === undefined) {
|
||||
attempts--
|
||||
const doReport = attempts == 0
|
||||
result = await this.attemptSingleUpload(args, doReport)
|
||||
if (!result) {
|
||||
console.log("Upload attempt failed, attempts left:", attempts)
|
||||
}
|
||||
}
|
||||
this._isUploading.set(undefined)
|
||||
if (result === undefined) {
|
||||
this._fails.data.push(args)
|
||||
this._fails.ping()
|
||||
return
|
||||
}
|
||||
this._fails.set(this._fails.data.filter(a => a !== args))
|
||||
let properties: UIEventSource<Record<string, string>> = this._featureProperties.getStore(args.featureId)
|
||||
|
||||
if (args.featureId.startsWith("note/")) {
|
||||
// This is an OSM-note
|
||||
const url = result.absoluteUrl
|
||||
await this._osmConnection.addCommentToNote(args.featureId, url)
|
||||
const properties: UIEventSource<Record<string, string>> = this._featureProperties.getStore(args.featureId)
|
||||
if (properties) {
|
||||
// Properties will not be defined if the note isn't loaded, but that is no problem as the below code is only relevant if the note is shown
|
||||
NoteCommentElement.addCommentTo(url, properties, {
|
||||
osmConnection: this._osmConnection
|
||||
})
|
||||
}
|
||||
} else {
|
||||
if (properties === undefined) {
|
||||
const downloaded = await new OsmObjectDownloader(this._osmConnection.Backend(), this._changes).DownloadObjectAsync(args.featureId)
|
||||
if (downloaded === "deleted") {
|
||||
this._queue.delete(args)
|
||||
return
|
||||
}
|
||||
this._featureProperties.trackFeature(downloaded.asGeoJson())
|
||||
properties = this._featureProperties.getStore(args.featureId)
|
||||
}
|
||||
const action = new LinkImageAction(
|
||||
args.featureId,
|
||||
result.key,
|
||||
result.value,
|
||||
properties,
|
||||
{
|
||||
theme: properties?.data?.["_orig_theme"] ?? this._theme.id,
|
||||
changeType: "add-image"
|
||||
}
|
||||
)
|
||||
await this._changes.applyAction(action)
|
||||
await this._changes.flushChanges("Image upload completed")
|
||||
}
|
||||
|
||||
this._queue.delete(args)
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to upload the image (once).
|
||||
* Returns 'undefined' if failed
|
||||
* @param featureId
|
||||
* @param author
|
||||
* @param blob
|
||||
* @param targetKey
|
||||
* @param noblur
|
||||
* @param lastGpsLocation
|
||||
* @param ignoreGps
|
||||
* @param layoutId
|
||||
* @param date
|
||||
* @param reportOnFail If set, reports an error to the mapcomplete server so that pietervdvn can fix it
|
||||
* @private
|
||||
*/
|
||||
private async attemptSingleUpload(
|
||||
{
|
||||
featureId,
|
||||
author,
|
||||
blob,
|
||||
targetKey,
|
||||
noblur,
|
||||
location
|
||||
}: ImageUploadArguments,
|
||||
reportOnFail: boolean
|
||||
): Promise<UploadResult | undefined> {
|
||||
|
||||
let key: string
|
||||
let value: string
|
||||
let absoluteUrl: string
|
||||
let location: [number, number] = undefined
|
||||
if (this._gps.data && !ignoreGps) {
|
||||
location = [this._gps.data.longitude, this._gps.data.latitude]
|
||||
}
|
||||
{
|
||||
feature ??= this._indexedFeatures.featuresById.data.get(featureId)
|
||||
if (feature === undefined) {
|
||||
throw "ImageUploadManager: no feature given and no feature found in the indexedFeature. Cannot upload this image"
|
||||
}
|
||||
const featureCenterpoint = GeoOperations.centerpointCoordinates(feature)
|
||||
if (
|
||||
location === undefined ||
|
||||
location?.some((l) => l === undefined) ||
|
||||
GeoOperations.distanceBetween(location, featureCenterpoint) > 150
|
||||
) {
|
||||
/* GPS location is either unknown or very far away from the photographed location.
|
||||
* Default to the centerpoint
|
||||
*/
|
||||
location = featureCenterpoint
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
;({ key, value, absoluteUrl } = await this._uploader.uploadImage(
|
||||
({ key, value, absoluteUrl } = await this._uploader.uploadImage(
|
||||
blob,
|
||||
location,
|
||||
author,
|
||||
noblur
|
||||
))
|
||||
} catch (e) {
|
||||
this.increaseCountFor(this._uploadRetried, featureId)
|
||||
console.error("Could not upload image, trying again:", e)
|
||||
try {
|
||||
;({ key, value, absoluteUrl } = await this._uploader.uploadImage(
|
||||
blob,
|
||||
location,
|
||||
author,
|
||||
noblur
|
||||
))
|
||||
this.increaseCountFor(this._uploadRetriedSuccess, featureId)
|
||||
} catch (e) {
|
||||
console.error("Could again not upload image due to", e)
|
||||
this.increaseCountFor(this._uploadFailed, featureId)
|
||||
console.error("Could again not upload image due to", e)
|
||||
if (reportOnFail) {
|
||||
|
||||
await this._reportError(
|
||||
e,
|
||||
JSON.stringify({
|
||||
ctx: "While uploading an image in the Image Upload Manager",
|
||||
featureId,
|
||||
author,
|
||||
targetKey,
|
||||
targetKey
|
||||
})
|
||||
)
|
||||
return undefined
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
console.log("Uploading image done, creating action for", featureId)
|
||||
key = targetKey ?? key
|
||||
if (targetKey) {
|
||||
if (targetKey && targetKey.indexOf(key) < 0) {
|
||||
// This is a non-standard key, so we use the image link directly
|
||||
value = absoluteUrl
|
||||
}
|
||||
this.increaseCountFor(this._uploadFinished, featureId)
|
||||
return { key, absoluteUrl, value }
|
||||
}
|
||||
|
||||
private getCounterFor(collection: Map<string, UIEventSource<number>>, key: string | "*") {
|
||||
if (this._featureProperties.aliases.has(key)) {
|
||||
key = this._featureProperties.aliases.get(key)
|
||||
}
|
||||
if (!collection.has(key)) {
|
||||
collection.set(key, new UIEventSource<number>(0))
|
||||
}
|
||||
return collection.get(key)
|
||||
}
|
||||
|
||||
private increaseCountFor(collection: Map<string, UIEventSource<number>>, key: string | "*") {
|
||||
{
|
||||
const counter = this.getCounterFor(collection, key)
|
||||
counter.setData(counter.data + 1)
|
||||
}
|
||||
{
|
||||
const global = this.getCounterFor(collection, "*")
|
||||
global.setData(global.data + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
59
src/Logic/ImageProviders/ImageUploadQueue.ts
Normal file
59
src/Logic/ImageProviders/ImageUploadQueue.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { IdbLocalStorage } from "../Web/IdbLocalStorage"
|
||||
import { Store, UIEventSource } from "../UIEventSource"
|
||||
|
||||
export interface ImageUploadArguments {
|
||||
featureId: string,
|
||||
readonly author: string,
|
||||
readonly blob: File,
|
||||
readonly targetKey: string | undefined,
|
||||
readonly noblur: boolean,
|
||||
readonly location: [number, number],
|
||||
readonly layoutId: string
|
||||
readonly date: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The 'imageUploadQueue' keeps track of all images that should still be uploaded.
|
||||
* It is backed up in the indexedDB as to not drop images in case of connection problems
|
||||
*/
|
||||
export default class ImageUploadQueue {
|
||||
|
||||
public static readonly singleton = new ImageUploadQueue()
|
||||
private readonly _imagesInQueue: UIEventSource<ImageUploadArguments[]>
|
||||
|
||||
public readonly imagesInQueue: Store<ImageUploadArguments[]>
|
||||
|
||||
private constructor() {
|
||||
this._imagesInQueue = IdbLocalStorage.Get<ImageUploadArguments[]>("failed-images-backup", { defaultValue: [] })
|
||||
this.imagesInQueue = this._imagesInQueue
|
||||
}
|
||||
|
||||
public add(args: ImageUploadArguments) {
|
||||
this._imagesInQueue.data.push(args)
|
||||
console.log("Got args", args)
|
||||
this._imagesInQueue.ping()
|
||||
}
|
||||
|
||||
public delete(img: ImageUploadArguments) {
|
||||
const index = this._imagesInQueue.data.indexOf(img)
|
||||
if (index < 0) {
|
||||
return
|
||||
}
|
||||
this._imagesInQueue.data.splice(index, 1)
|
||||
this._imagesInQueue.ping()
|
||||
}
|
||||
|
||||
applyRemapping(oldId: string, newId: string) {
|
||||
|
||||
let hasChange = false
|
||||
for (const img of this._imagesInQueue.data) {
|
||||
if (img.featureId === oldId) {
|
||||
img.featureId = newId
|
||||
hasChange = true
|
||||
}
|
||||
}
|
||||
if (hasChange) {
|
||||
this._imagesInQueue.ping()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -83,6 +83,8 @@ export class Mapillary extends ImageProvider {
|
|||
|
||||
/**
|
||||
* Returns the correct key for API v4.0
|
||||
*
|
||||
* Mapillary.ExtractKeyFromURL("999924810651016") // => 999924810651016
|
||||
*/
|
||||
private static ExtractKeyFromURL(value: string): number {
|
||||
let key: string
|
||||
|
|
@ -180,7 +182,9 @@ export class Mapillary extends ImageProvider {
|
|||
mapillaryId +
|
||||
"?fields=thumb_1024_url,thumb_original_url,captured_at,creator&access_token=" +
|
||||
Constants.mapillary_client_token_v4
|
||||
const response = await Utils.downloadJsonCached(metadataUrl, 60 * 60)
|
||||
const response = await Utils.downloadJsonCached<{
|
||||
thumb_1024_url: string, thumb_original_url: string, captured_at, creator: string
|
||||
}>(metadataUrl, 60 * 60)
|
||||
|
||||
const license = new LicenseInfo()
|
||||
license.artist = response["creator"]["username"]
|
||||
|
|
@ -200,9 +204,13 @@ export class Mapillary extends ImageProvider {
|
|||
const metadataUrl =
|
||||
"https://graph.mapillary.com/" +
|
||||
mapillaryId +
|
||||
"?fields=thumb_1024_url,thumb_original_url,captured_at,compass_angle,geometry,creator&access_token=" +
|
||||
"?fields=thumb_1024_url,thumb_original_url,captured_at,compass_angle,geometry,creator,camera_type&access_token=" +
|
||||
Constants.mapillary_client_token_v4
|
||||
const response = await Utils.downloadJsonCached(metadataUrl, 60 * 60)
|
||||
const response = await Utils.downloadJsonCached<{
|
||||
thumb_1024_url: string, thumb_original_url: string, captured_at,
|
||||
compass_angle: number,
|
||||
creator: string
|
||||
}>(metadataUrl, 60 * 60)
|
||||
const url = <string>response["thumb_1024_url"]
|
||||
const url_hd = <string>response["thumb_original_url"]
|
||||
const date = new Date()
|
||||
|
|
|
|||
|
|
@ -132,7 +132,8 @@ export default class PanoramaxImageProvider extends ImageProvider {
|
|||
}
|
||||
|
||||
public async getInfo(hash: string): Promise<ProvidedImage> {
|
||||
return await this.getInfoFor(hash).then((r) => this.featureToImage(<any>r))
|
||||
const r: { data: ImageData; url: string } = await this.getInfoFor(hash)
|
||||
return this.featureToImage(r)
|
||||
}
|
||||
|
||||
getRelevantUrls(tags: Record<string, string>, prefixes: string[]): Store<ProvidedImage[]> {
|
||||
|
|
@ -278,7 +279,7 @@ export class PanoramaxUploader implements ImageUploader {
|
|||
}
|
||||
console.log("Tags are", tags)
|
||||
} catch (e) {
|
||||
console.error("Could not read EXIF-tags")
|
||||
console.warn("Could not read EXIF-tags")
|
||||
}
|
||||
|
||||
const p = this.panoramax
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import Wikidata from "../Web/Wikidata"
|
|||
import SvelteUIElement from "../../UI/Base/SvelteUIElement"
|
||||
import * as Wikidata_icon from "../../assets/svg/Wikidata.svelte"
|
||||
import { Utils } from "../../Utils"
|
||||
import { ImmutableStore, Store, Stores, UIEventSource } from "../UIEventSource"
|
||||
|
||||
export class WikidataImageProvider extends ImageProvider {
|
||||
public static readonly singleton = new WikidataImageProvider()
|
||||
|
|
@ -64,7 +63,7 @@ export class WikidataImageProvider extends ImageProvider {
|
|||
return [].concat(...resolved)
|
||||
}
|
||||
|
||||
public DownloadAttribution(_): Promise<any> {
|
||||
public DownloadAttribution(): Promise<undefined> {
|
||||
throw new Error("Method not implemented; shouldn't be needed!")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import ChangeTagAction from "./Actions/ChangeTagAction"
|
|||
import DeleteAction from "./Actions/DeleteAction"
|
||||
import MarkdownUtils from "../../Utils/MarkdownUtils"
|
||||
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
|
||||
import { Feature, Point } from "geojson"
|
||||
|
||||
/**
|
||||
* Handles all changes made to OSM.
|
||||
|
|
@ -37,7 +38,7 @@ export class Changes {
|
|||
public readonly backend: string
|
||||
public readonly isUploading = new UIEventSource(false)
|
||||
public readonly errors = new UIEventSource<string[]>([], "upload-errors")
|
||||
private readonly historicalUserLocations?: FeatureSource
|
||||
private readonly historicalUserLocations?: FeatureSource<Feature<Point, GeoLocationPointProperties>>
|
||||
private _nextId: number = 0 // Newly assigned ID's are negative
|
||||
private readonly previouslyCreated: OsmObject[] = []
|
||||
private readonly _leftRightSensitive: boolean
|
||||
|
|
@ -53,7 +54,7 @@ export class Changes {
|
|||
osmConnection: OsmConnection
|
||||
reportError?: (error: string) => void
|
||||
featureProperties?: FeaturePropertiesStore
|
||||
historicalUserLocations?: FeatureSource
|
||||
historicalUserLocations?: FeatureSource<Feature<Point, GeoLocationPointProperties>>
|
||||
allElements?: IndexedFeatureSource
|
||||
},
|
||||
leftRightSensitive: boolean = false
|
||||
|
|
@ -66,7 +67,7 @@ export class Changes {
|
|||
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.pendingChanges.data?.map((pch) => pch?.id).join(".")
|
||||
)
|
||||
this._nextId = -100
|
||||
}
|
||||
|
|
@ -90,13 +91,39 @@ export class Changes {
|
|||
return new Changes({
|
||||
osmConnection: new OsmConnection(),
|
||||
featureSwitches: {
|
||||
featureSwitchIsTesting: new ImmutableStore(true),
|
||||
},
|
||||
featureSwitchIsTesting: new ImmutableStore(true)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
public static async createChangesetXMLForJosm(actions: OsmChangeAction[], osmConnection?: OsmConnection): Promise<string> {
|
||||
osmConnection ??= new OsmConnection()
|
||||
const changes = new Changes({
|
||||
osmConnection
|
||||
})
|
||||
const descriptions: ChangeDescription[] = []
|
||||
for (const action of actions) {
|
||||
descriptions.push(...await action.Perform(changes))
|
||||
}
|
||||
const downloader = new OsmObjectDownloader(osmConnection.Backend(), undefined)
|
||||
const downloaded: OsmObject[] = []
|
||||
for (const action of actions) {
|
||||
const osmObj = await downloader.DownloadObjectAsync(action.mainObjectId)
|
||||
if (osmObj === "deleted") {
|
||||
continue
|
||||
}
|
||||
downloaded.push(osmObj)
|
||||
}
|
||||
return Changes.buildChangesetXML("", changes.CreateChangesetObjects(descriptions, downloaded))
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a changeset
|
||||
* @param csId either the ID-number of the changeset, or an empty string (e.g. when uploading with JOSM)
|
||||
* @param allChanges use 'new Changes()
|
||||
*/
|
||||
static buildChangesetXML(
|
||||
csId: string,
|
||||
csId: number | string,
|
||||
allChanges: {
|
||||
modifiedObjects: OsmObject[]
|
||||
newObjects: OsmObject[]
|
||||
|
|
@ -111,20 +138,20 @@ export class Changes {
|
|||
if (newElements.length > 0) {
|
||||
changes +=
|
||||
"\n<create>\n" +
|
||||
newElements.map((e) => e.ChangesetXML(csId)).join("\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") +
|
||||
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") +
|
||||
deletedElements.map((e) => e.ChangesetXML("" + csId)).join("\n") +
|
||||
"\n</delete>"
|
||||
}
|
||||
|
||||
|
|
@ -152,50 +179,50 @@ export class Changes {
|
|||
[
|
||||
{
|
||||
key: "comment",
|
||||
docs: "The changeset comment. Will be a fixed string, mentioning the theme",
|
||||
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. ",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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",
|
||||
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.",
|
||||
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",
|
||||
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)",
|
||||
},
|
||||
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"),
|
||||
...addSource(DeleteAction.metatags, "DeleteAction")
|
||||
// TODO
|
||||
/*
|
||||
...DeleteAction.metatags,
|
||||
|
|
@ -217,11 +244,11 @@ export class Changes {
|
|||
docs,
|
||||
specialMotivation
|
||||
? "This might give a reason per modified node or way"
|
||||
: "",
|
||||
: ""
|
||||
].join("\n"),
|
||||
source,
|
||||
source
|
||||
])
|
||||
),
|
||||
)
|
||||
].join("\n\n")
|
||||
}
|
||||
|
||||
|
|
@ -240,7 +267,7 @@ export class Changes {
|
|||
this._changesetHandler._remappings.has("node/" + this._nextId) ||
|
||||
this._changesetHandler._remappings.has("way/" + this._nextId) ||
|
||||
this._changesetHandler._remappings.has("relation/" + this._nextId)
|
||||
)
|
||||
)
|
||||
return this._nextId
|
||||
}
|
||||
|
||||
|
|
@ -307,6 +334,7 @@ export class Changes {
|
|||
this.previouslyCreated
|
||||
)
|
||||
}
|
||||
|
||||
public static createChangesetObjectsStatic(
|
||||
changes: ChangeDescription[],
|
||||
downloadedOsmObjects: OsmObject[],
|
||||
|
|
@ -368,22 +396,25 @@ export class Changes {
|
|||
states.set(id, "created")
|
||||
let osmObj: OsmObject = undefined
|
||||
switch (change.type) {
|
||||
case "node":
|
||||
case "node": {
|
||||
const n = new OsmNode(change.id)
|
||||
n.lat = change.changes["lat"]
|
||||
n.lon = change.changes["lon"]
|
||||
osmObj = n
|
||||
break
|
||||
case "way":
|
||||
}
|
||||
case "way": {
|
||||
const w = new OsmWay(change.id)
|
||||
w.nodes = change.changes["nodes"]
|
||||
osmObj = w
|
||||
break
|
||||
case "relation":
|
||||
}
|
||||
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
|
||||
}
|
||||
|
|
@ -424,10 +455,8 @@ export class Changes {
|
|||
|
||||
if (change.changes !== undefined) {
|
||||
switch (change.type) {
|
||||
case "node":
|
||||
// @ts-ignore
|
||||
case "node": {
|
||||
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) {
|
||||
|
|
@ -436,7 +465,8 @@ export class Changes {
|
|||
changed = true
|
||||
}
|
||||
break
|
||||
case "way":
|
||||
}
|
||||
case "way": {
|
||||
const nnodes = change.changes["nodes"]
|
||||
const w = <OsmWay>obj
|
||||
if (!Utils.Identical(nnodes, w.nodes)) {
|
||||
|
|
@ -444,7 +474,8 @@ export class Changes {
|
|||
changed = true
|
||||
}
|
||||
break
|
||||
case "relation":
|
||||
}
|
||||
case "relation": {
|
||||
const nmembers: {
|
||||
type: "node" | "way" | "relation"
|
||||
ref: number
|
||||
|
|
@ -460,6 +491,7 @@ export class Changes {
|
|||
changed = true
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -472,7 +504,7 @@ export class Changes {
|
|||
const result = {
|
||||
newObjects: [],
|
||||
modifiedObjects: [],
|
||||
deletedObjects: [],
|
||||
deletedObjects: []
|
||||
}
|
||||
|
||||
objects.forEach((v, id) => {
|
||||
|
|
@ -529,9 +561,7 @@ export class Changes {
|
|||
const recentLocationPoints = locations
|
||||
.filter((feat) => feat.geometry.type === "Point")
|
||||
.filter((feat) => {
|
||||
const visitTime = new Date(
|
||||
(<GeoLocationPointProperties>(<any>feat.properties)).date
|
||||
)
|
||||
const visitTime = new Date(feat.properties.date)
|
||||
// In seconds
|
||||
const diff = (now.getTime() - visitTime.getTime()) / 1000
|
||||
return diff < Constants.nearbyVisitTime
|
||||
|
|
@ -600,6 +630,7 @@ export class Changes {
|
|||
e +
|
||||
")"
|
||||
// this._reportError(msg) // We don't report this yet, might be a temporary fluke
|
||||
console.log(msg)
|
||||
const osmObj = await downloader.DownloadObjectAsync(id, 0)
|
||||
return { id, osmObj }
|
||||
}
|
||||
|
|
@ -634,7 +665,7 @@ export class Changes {
|
|||
} 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)
|
||||
JSON.stringify(c)
|
||||
)
|
||||
}
|
||||
return
|
||||
|
|
@ -645,10 +676,10 @@ export class Changes {
|
|||
} else {
|
||||
console.log(
|
||||
"Refusing change about " +
|
||||
c.type +
|
||||
"/" +
|
||||
c.id +
|
||||
" as not in the objects. No internet?"
|
||||
c.type +
|
||||
"/" +
|
||||
c.id +
|
||||
" as not in the objects. No internet?"
|
||||
)
|
||||
refused.push(c)
|
||||
}
|
||||
|
|
@ -663,7 +694,7 @@ export class Changes {
|
|||
*/
|
||||
private async flushSelectChanges(
|
||||
pending: ChangeDescription[],
|
||||
openChangeset: UIEventSource<number>
|
||||
openChangeset: UIEventSource<{ id: number, opened: number }>
|
||||
): Promise<ChangeDescription[]> {
|
||||
const neededIds = Changes.GetNeededIds(pending)
|
||||
/* Download the latest version of the OSM-objects
|
||||
|
|
@ -717,7 +748,7 @@ export class Changes {
|
|||
deletedObjects: OsmObject[]
|
||||
} = this.CreateChangesetObjects(toUpload, objects)
|
||||
|
||||
return Changes.buildChangesetXML("" + csId, changes)
|
||||
return Changes.buildChangesetXML(csId, changes)
|
||||
},
|
||||
metatags,
|
||||
openChangeset
|
||||
|
|
@ -744,14 +775,14 @@ export class Changes {
|
|||
([key, count]) => ({
|
||||
key: key,
|
||||
value: count,
|
||||
aggregate: true,
|
||||
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,
|
||||
value: descr.meta.specialMotivation
|
||||
}))
|
||||
|
||||
const distances = Utils.NoNull(pending.map((descr) => descr.meta.distanceToObject))
|
||||
|
|
@ -782,7 +813,7 @@ export class Changes {
|
|||
return {
|
||||
key,
|
||||
value: count,
|
||||
aggregate: true,
|
||||
aggregate: true
|
||||
}
|
||||
})
|
||||
)
|
||||
|
|
@ -797,24 +828,24 @@ export class Changes {
|
|||
const metatags: ChangesetTag[] = [
|
||||
{
|
||||
key: "comment",
|
||||
value: comment,
|
||||
value: comment
|
||||
},
|
||||
{
|
||||
key: "theme",
|
||||
value: theme,
|
||||
value: theme
|
||||
},
|
||||
...perType,
|
||||
...motivations,
|
||||
...perBinMessage,
|
||||
...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 pending = this.pendingChanges.data
|
||||
|
||||
const pendingPerTheme = new Map<string, ChangeDescription[]>()
|
||||
for (const changeDescription of pending) {
|
||||
|
|
@ -828,19 +859,15 @@ export class Changes {
|
|||
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
|
||||
)
|
||||
)
|
||||
const openChangeset = this.state.osmConnection.getCurrentChangesetFor(theme)
|
||||
console.log(
|
||||
"Using current-open-changeset-" +
|
||||
theme +
|
||||
" from the preferences, got " +
|
||||
openChangeset.data
|
||||
theme +
|
||||
" from the preferences, got " +
|
||||
openChangeset.data
|
||||
)
|
||||
|
||||
const refused = await self.flushSelectChanges(pendingChanges, openChangeset)
|
||||
const refused = await this.flushSelectChanges(pendingChanges, openChangeset)
|
||||
if (!refused) {
|
||||
this.errors.setData([])
|
||||
}
|
||||
|
|
@ -873,9 +900,9 @@ export class Changes {
|
|||
)
|
||||
this.errors.data.push(e)
|
||||
this.errors.ping()
|
||||
self.pendingChanges.setData([])
|
||||
this.pendingChanges.setData([])
|
||||
} finally {
|
||||
self.isUploading.setData(false)
|
||||
this.isUploading.setData(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { Changes } from "./Changes"
|
|||
import { Utils } from "../../Utils"
|
||||
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
|
||||
import { AndroidPolyfill } from "../Web/AndroidPolyfill"
|
||||
import ImageUploadQueue from "../ImageProviders/ImageUploadQueue"
|
||||
|
||||
export interface ChangesetTag {
|
||||
key: string
|
||||
|
|
@ -113,11 +114,11 @@ export class ChangesetHandler {
|
|||
|
||||
private async UploadWithNew(
|
||||
generateChangeXML: (csid: number, remappings: Map<string, string>) => string,
|
||||
openChangeset: UIEventSource<number>,
|
||||
openChangeset: UIEventSource<{ id: number, opened: number }>,
|
||||
extraMetaTags: ChangesetTag[]
|
||||
) {
|
||||
const csId = await this.OpenChangeset(extraMetaTags)
|
||||
openChangeset.setData(csId)
|
||||
openChangeset.setData({ id: csId, opened: new Date().getTime() })
|
||||
const changeset = generateChangeXML(csId, this._remappings)
|
||||
console.log(
|
||||
"Opened a new changeset (openChangeset.data is undefined):",
|
||||
|
|
@ -145,7 +146,7 @@ export class ChangesetHandler {
|
|||
public async UploadChangeset(
|
||||
generateChangeXML: (csid: number, remappings: Map<string, string>) => string,
|
||||
extraMetaTags: ChangesetTag[],
|
||||
openChangeset: UIEventSource<number>
|
||||
openChangeset: UIEventSource<{ id: number, opened: number }>
|
||||
): Promise<void> {
|
||||
if (
|
||||
!extraMetaTags.some((tag) => tag.key === "comment") ||
|
||||
|
|
@ -169,18 +170,21 @@ export class ChangesetHandler {
|
|||
}
|
||||
|
||||
console.log("Trying to reuse changeset", openChangeset.data)
|
||||
if (openChangeset.data) {
|
||||
const now = new Date()
|
||||
const changesetIsUsable = openChangeset.data !== undefined &&
|
||||
(now.getTime() - openChangeset.data.opened < 24 * 60 * 60 * 1000)
|
||||
if (changesetIsUsable) {
|
||||
try {
|
||||
const csId = openChangeset.data
|
||||
const oldChangesetMeta = await this.GetChangesetMeta(csId)
|
||||
const oldChangesetMeta = await this.GetChangesetMeta(csId.id)
|
||||
console.log("Got metadata:", oldChangesetMeta, "isopen", oldChangesetMeta?.open)
|
||||
if (oldChangesetMeta.open) {
|
||||
// We can hopefully reuse the changeset
|
||||
|
||||
try {
|
||||
const rewritings = await this.UploadChange(
|
||||
csId,
|
||||
generateChangeXML(csId, this._remappings)
|
||||
csId.id,
|
||||
generateChangeXML(csId.id, this._remappings)
|
||||
)
|
||||
|
||||
const rewrittenTags = this.RewriteTagsOf(
|
||||
|
|
@ -188,7 +192,7 @@ export class ChangesetHandler {
|
|||
rewritings,
|
||||
oldChangesetMeta
|
||||
)
|
||||
await this.UpdateTags(csId, rewrittenTags)
|
||||
await this.UpdateTags(csId.id, rewrittenTags)
|
||||
return // We are done!
|
||||
} catch (e) {
|
||||
this._reportError(e, "While reusing a changeset " + openChangeset.data)
|
||||
|
|
@ -236,9 +240,9 @@ export class ChangesetHandler {
|
|||
/**
|
||||
* Given an existing changeset with metadata and extraMetaTags to add, will fuse them to a new set of metatags
|
||||
* Does not yet send data
|
||||
* @param extraMetaTags: new changeset tags to add/fuse with this changeset
|
||||
* @param rewriteIds: the mapping of ids
|
||||
* @param oldChangesetMeta: the metadata-object of the already existing changeset
|
||||
* @param extraMetaTags new changeset tags to add/fuse with this changeset
|
||||
* @param rewriteIds the mapping of ids
|
||||
* @param oldChangesetMeta the metadata-object of the already existing changeset
|
||||
*
|
||||
* @public for testing purposes
|
||||
*/
|
||||
|
|
@ -250,7 +254,7 @@ export class ChangesetHandler {
|
|||
id: number
|
||||
uid: number // User ID
|
||||
changes_count: number
|
||||
tags: any
|
||||
tags: Record<string, string>
|
||||
}
|
||||
): ChangesetTag[] {
|
||||
// Note: extraMetaTags is where all the tags are collected into
|
||||
|
|
@ -300,11 +304,11 @@ export class ChangesetHandler {
|
|||
|
||||
/**
|
||||
* Updates the id in the AllElements store, returns the new ID
|
||||
* @param node: the XML-element, e.g. <node old_id="-1" new_id="9650458521" new_version="1"/>
|
||||
* @param node the XML-element, e.g. <node old_id="-1" new_id="9650458521" new_version="1"/>
|
||||
* @param type
|
||||
* @private
|
||||
*/
|
||||
private static parseIdRewrite(node: any, type: string): [string, string] {
|
||||
private static parseIdRewrite(node: any, type: "node" | "way" | "relation"): [string, string] {
|
||||
const oldId = parseInt(node.attributes.old_id.value)
|
||||
if (node.attributes.new_id === undefined) {
|
||||
return [type + "/" + oldId, undefined]
|
||||
|
|
@ -355,7 +359,8 @@ export class ChangesetHandler {
|
|||
const [oldId, newId] = mapping
|
||||
this.allElements?.addAlias(oldId, newId)
|
||||
if (newId !== undefined) {
|
||||
this._remappings.set(mapping[0], mapping[1])
|
||||
this._remappings.set(oldId, newId)
|
||||
ImageUploadQueue.singleton.applyRemapping(oldId, newId)
|
||||
}
|
||||
}
|
||||
return new Map<string, string>(mappings)
|
||||
|
|
|
|||
|
|
@ -246,11 +246,13 @@ export class OsmConnection {
|
|||
}
|
||||
|
||||
public getPreference<T extends string = string>(
|
||||
key: string,
|
||||
defaultValue: string = undefined,
|
||||
prefix: string = "mapcomplete-"
|
||||
key: string, options?: {
|
||||
defaultValue?: string,
|
||||
prefix?: "mapcomplete-" | string,
|
||||
saveToLocalStorage?: true | boolean
|
||||
}
|
||||
): UIEventSource<T | undefined> {
|
||||
return <UIEventSource<T>>this.preferencesHandler.getPreference(key, defaultValue, prefix)
|
||||
return <UIEventSource<T>>this.preferencesHandler.getPreference(key, options?.defaultValue, options?.prefix ?? "mapcomplete-")
|
||||
}
|
||||
|
||||
public LogOut() {
|
||||
|
|
@ -731,4 +733,24 @@ export class OsmConnection {
|
|||
return { api: "offline", gpx: "offline", database: "online" }
|
||||
}
|
||||
}
|
||||
|
||||
public getCurrentChangesetFor(theme: string) {
|
||||
return UIEventSource.asObject<{ id: number, opened: number }>(
|
||||
this.GetPreference(
|
||||
"current-changeset-" + theme
|
||||
),
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the names of the themes that have an open changeset
|
||||
*/
|
||||
public getAllOpenChangesetsPreferences(): Store<string[]> {
|
||||
const prefix = "current-changeset-"
|
||||
return this.preferencesHandler.allPreferences.map(dict =>
|
||||
Object.keys(dict)
|
||||
.filter(k => k.startsWith(prefix))
|
||||
.map(k => k.substring(prefix.length)))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,8 @@ export abstract class OsmObject {
|
|||
public changed: boolean = false
|
||||
timestamp: Date
|
||||
|
||||
protected constructor(type: string, id: number) {
|
||||
protected constructor(type: "node" | "way" | "relation", id: number) {
|
||||
this.id = id
|
||||
// @ts-ignore
|
||||
this.type = type
|
||||
this.tags = {
|
||||
id: `${this.type}/${id}`,
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ export class OsmPreferences {
|
|||
private localStorageInited: Set<string> = new Set()
|
||||
/**
|
||||
* Contains all the keys as returned by the OSM-preferences.
|
||||
* This includes combined preferences, such as: pref, pref:0, pref:1
|
||||
* Used to clean up old preferences
|
||||
*/
|
||||
private seenKeys: string[] = []
|
||||
|
|
@ -59,18 +60,18 @@ export class OsmPreferences {
|
|||
value: string = undefined,
|
||||
deferPing = false
|
||||
): UIEventSource<string> {
|
||||
if (this.preferences[key] !== undefined) {
|
||||
const cached = this.preferences[key]
|
||||
if (cached !== undefined) {
|
||||
if (value !== undefined) {
|
||||
this.preferences[key].set(value)
|
||||
cached.set(value)
|
||||
}
|
||||
return this.preferences[key]
|
||||
return cached
|
||||
}
|
||||
const pref = (this.preferences[key] = new UIEventSource(value, "preference: " + key))
|
||||
if (value) {
|
||||
this.setPreferencesAll(key, value, deferPing)
|
||||
}
|
||||
pref.addCallback((v) => {
|
||||
console.log("Got an update:", key, "--->", v)
|
||||
this.uploadKvSplit(key, v)
|
||||
this.setPreferencesAll(key, v, deferPing)
|
||||
})
|
||||
|
|
@ -80,22 +81,18 @@ export class OsmPreferences {
|
|||
private async loadBulkPreferences() {
|
||||
const prefs = await this.getPreferencesDictDirectly()
|
||||
this.seenKeys = Object.keys(prefs)
|
||||
const legacy = OsmPreferences.getLegacyCombinedItems(prefs)
|
||||
const merged = OsmPreferences.mergeDict(prefs)
|
||||
if (Object.keys(legacy).length > 0) {
|
||||
await this.removeLegacy(legacy)
|
||||
}
|
||||
for (const key in merged) {
|
||||
this.initPreference(key, prefs[key], true)
|
||||
}
|
||||
for (const key in legacy) {
|
||||
this.initPreference(key, legacy[key], true)
|
||||
this.initPreference(key, merged[key], true)
|
||||
}
|
||||
this._allPreferences.ping()
|
||||
if (this.osmConnection.isLoggedIn.data) {
|
||||
await this.cleanup()
|
||||
}
|
||||
}
|
||||
|
||||
public getPreference(key: string, defaultValue: string = undefined, prefix?: string) {
|
||||
return this.getPreferenceSeedFromlocal(key, defaultValue, { prefix })
|
||||
public getPreference(key: string, defaultValue: string = undefined, prefix?: string, saveLocally = true) {
|
||||
return this.getPreferenceSeedFromlocal(key, defaultValue, { prefix, saveToLocalStorage: saveLocally })
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -124,6 +121,7 @@ export class OsmPreferences {
|
|||
key,
|
||||
localStorage.data ?? defaultValue
|
||||
) // cached
|
||||
|
||||
if (this.localStorageInited.has(key)) {
|
||||
return pref
|
||||
}
|
||||
|
|
@ -140,73 +138,57 @@ export class OsmPreferences {
|
|||
this.removeAllWithPrefix("")
|
||||
}
|
||||
|
||||
public async removeLegacy(legacyDict: Record<string, string>) {
|
||||
for (const k in legacyDict) {
|
||||
const v = legacyDict[k]
|
||||
console.log("Upgrading legacy preference", k)
|
||||
await this.removeAllWithPrefix(k)
|
||||
this.osmConnection.getPreference(k).set(v)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* OsmPreferences.mergeDict({abc: "123", def: "123", "def:0": "456", "def:1":"789"}) // => {abc: "123", def: "123456789"}
|
||||
*/
|
||||
private static mergeDict(dict: Record<string, string>): Record<string, string> {
|
||||
const newDict = {}
|
||||
|
||||
const allKeys: string[] = Object.keys(dict)
|
||||
const normalKeys = allKeys.filter((k) => !k.match(/[a-z-_0-9A-Z]*:[0-9]+/))
|
||||
for (const normalKey of normalKeys) {
|
||||
if (normalKey.match(/-combined-[0-9]*$/) || normalKey.match(/-combined-length$/)) {
|
||||
// Ignore legacy keys
|
||||
const keyParts: Record<string, Record<number, string>> = {}
|
||||
const endsWithNumber = /:[0-9]+$/
|
||||
for (const key of Object.keys(dict)) {
|
||||
if (key.match(/-combined-[0-9]*$/) || key.match(/-combined-length$/)) {
|
||||
continue
|
||||
}
|
||||
const partKeys = OsmPreferences.keysStartingWith(allKeys, normalKey)
|
||||
const parts = partKeys.map((k) => dict[k])
|
||||
newDict[normalKey] = parts.join("")
|
||||
const nr = key.match(endsWithNumber)
|
||||
if (nr) {
|
||||
const i = Number(nr[0].substring(1))
|
||||
const k = key.substring(0, key.length - nr[0].length)
|
||||
let subparts = keyParts[k]
|
||||
if (!subparts) {
|
||||
subparts = {}
|
||||
keyParts[k] = subparts
|
||||
}
|
||||
subparts[i] = dict[key]
|
||||
} else {
|
||||
let subparts = keyParts[key]
|
||||
if (!subparts) {
|
||||
subparts = keyParts[key] = {}
|
||||
}
|
||||
subparts[""] = dict[key]
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const newDict = {}
|
||||
|
||||
for (const key in keyParts) {
|
||||
const subparts = keyParts[key]
|
||||
let i = 0
|
||||
let v = subparts[""] ?? ""
|
||||
while (subparts[i]) {
|
||||
v += subparts[i]
|
||||
i++
|
||||
}
|
||||
newDict[key] = v
|
||||
}
|
||||
|
||||
return newDict
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all items which have a 'combined'-string, the legacy long preferences
|
||||
*
|
||||
* const input = {
|
||||
* "extra-noncombined-key":"xyz",
|
||||
* "mapcomplete-unofficial-theme-httpsrawgithubusercontentcomosm-catalanwikidataimgmainwikidataimgjson-combined-0":
|
||||
* "{\"id\":\"https://raw.githubusercontent.com/osm-catalan/wikidataimg/main/wikidataimg.json\",\"icon\":\"https://upload.wikimedia.org/wikipedia/commons/5/50/Yes_Check_Circle.svg\",\"title\":{\"ca\":\"wikidataimg\",\"_context\":\"themes:wikidataimg.title\"},\"shortDescription\"",
|
||||
* "mapcomplete-unofficial-theme-httpsrawgithubusercontentcomosm-catalanwikidataimgmainwikidataimgjson-combined-1":
|
||||
* ":{\"ca\":\"Afegeix imatges d'articles de wikimedia\",\"_context\":\"themes:wikidataimg\"}}",
|
||||
* }
|
||||
* const merged = OsmPreferences.getLegacyCombinedItems(input)
|
||||
* const data = merged["mapcomplete-unofficial-theme-httpsrawgithubusercontentcomosm-catalanwikidataimgmainwikidataimgjson"]
|
||||
* JSON.parse(data) // => {"id": "https://raw.githubusercontent.com/osm-catalan/wikidataimg/main/wikidataimg.json", "icon": "https://upload.wikimedia.org/wikipedia/commons/5/50/Yes_Check_Circle.svg","title": { "ca": "wikidataimg", "_context": "themes:wikidataimg.title" }, "shortDescription": {"ca": "Afegeix imatges d'articles de wikimedia","_context": "themes:wikidataimg"}}
|
||||
* merged["extra-noncombined-key"] // => undefined
|
||||
*/
|
||||
public static getLegacyCombinedItems(dict: Record<string, string>): Record<string, string> {
|
||||
const merged: Record<string, string> = {}
|
||||
const keys = Object.keys(dict)
|
||||
const toCheck = Utils.NoNullInplace(
|
||||
Utils.Dedup(keys.map((k) => k.match(/(.*)-combined-[0-9]+$/)?.[1]))
|
||||
)
|
||||
for (const key of toCheck) {
|
||||
let i = 0
|
||||
let str = ""
|
||||
let v: string
|
||||
do {
|
||||
v = dict[key + "-combined-" + i]
|
||||
str += v ?? ""
|
||||
i++
|
||||
} while (v !== undefined)
|
||||
merged[key] = str
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk-downloads all preferences
|
||||
* Bulk-downloads all preferences, creates a simple record from all preferences.
|
||||
* This should still be merged!
|
||||
* @private
|
||||
*/
|
||||
private async getPreferencesDictDirectly(): Promise<Record<string, string>> {
|
||||
|
|
@ -217,7 +199,7 @@ export class OsmPreferences {
|
|||
this.auth.xhr(
|
||||
{
|
||||
method: "GET",
|
||||
path: "/api/0.6/user/preferences",
|
||||
path: "/api/0.6/user/preferences"
|
||||
},
|
||||
(error, value: XMLDocument) => {
|
||||
if (error) {
|
||||
|
|
@ -238,6 +220,9 @@ export class OsmPreferences {
|
|||
})
|
||||
}
|
||||
|
||||
|
||||
private static readonly endsWithNumber = /:[0-9]+$/
|
||||
|
||||
/**
|
||||
* Returns all keys matching `k:[number]`
|
||||
* Split separately for test
|
||||
|
|
@ -249,7 +234,24 @@ export class OsmPreferences {
|
|||
*
|
||||
*/
|
||||
private static keysStartingWith(allKeys: string[], key: string): string[] {
|
||||
const keys = allKeys.filter((k) => k === key || k.match(new RegExp(key + ":[0-9]+")))
|
||||
|
||||
const keys = allKeys.filter((k) => {
|
||||
if (k === key) {
|
||||
return true
|
||||
}
|
||||
if (!k.startsWith(key)) {
|
||||
return false
|
||||
}
|
||||
const match = k.match(OsmPreferences.endsWithNumber)
|
||||
if (!match) {
|
||||
return false
|
||||
}
|
||||
const matchLength = match[0].length
|
||||
if (key.length + matchLength !== k.length) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
keys.sort()
|
||||
return keys
|
||||
}
|
||||
|
|
@ -298,7 +300,7 @@ export class OsmPreferences {
|
|||
{
|
||||
method: "DELETE",
|
||||
path: "/api/0.6/user/preferences/" + encodeURIComponent(k),
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
headers: { "Content-Type": "text/plain" }
|
||||
},
|
||||
(error) => {
|
||||
if (error) {
|
||||
|
|
@ -306,7 +308,6 @@ export class OsmPreferences {
|
|||
reject(error)
|
||||
return
|
||||
}
|
||||
console.debug("Preference ", k, "removed!")
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
|
|
@ -340,33 +341,50 @@ export class OsmPreferences {
|
|||
throw "Preference too long, at most 255 characters are supported"
|
||||
}
|
||||
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
this.auth.xhr(
|
||||
{
|
||||
method: "PUT",
|
||||
path: "/api/0.6/user/preferences/" + encodeURIComponent(k),
|
||||
headers: { "Content-Type": "text/plain" },
|
||||
content: v,
|
||||
},
|
||||
(error) => {
|
||||
if (error) {
|
||||
console.warn(`Could not set preference "${k}"'`, error)
|
||||
reject(error)
|
||||
return
|
||||
}
|
||||
resolve()
|
||||
}
|
||||
)
|
||||
})
|
||||
try {
|
||||
|
||||
return this.osmConnection.interact("user/preferences/" + encodeURIComponent(k),
|
||||
"PUT", { "Content-Type": "text/plain" }, v)
|
||||
} catch (e) {
|
||||
console.error("Could not upload preference due to", e)
|
||||
}
|
||||
}
|
||||
|
||||
async removeAllWithPrefix(prefix: string) {
|
||||
const keys = this.seenKeys
|
||||
let somethingChanged = false
|
||||
for (const key of keys) {
|
||||
if (!key.startsWith(prefix)) {
|
||||
continue
|
||||
}
|
||||
console.log("Cleaning up preference", key)
|
||||
await this.deleteKeyDirectly(key)
|
||||
somethingChanged = true
|
||||
}
|
||||
return somethingChanged
|
||||
}
|
||||
|
||||
private async cleanup() {
|
||||
const prefixesToClean = ["mapcomplete-mapcomplete-", "mapcomplete-places-history", "unofficial-theme-", "mapcompleteplaces", "mapcompletethemes"] // TODO enable this one once the new system is in prod "mapcomplete-current-open-changeset-"]
|
||||
let somethingChanged = false
|
||||
for (const prefix of prefixesToClean) {
|
||||
const hasChange = await this.removeAllWithPrefix(prefix) // Don't inline - short-circuiting
|
||||
somethingChanged ||= hasChange
|
||||
}
|
||||
if (somethingChanged) {
|
||||
this._allPreferences.ping()
|
||||
}
|
||||
|
||||
const themes = this.osmConnection.getAllOpenChangesetsPreferences()
|
||||
const now = new Date()
|
||||
|
||||
for (const theme of themes.data) {
|
||||
const cs = this.osmConnection.getCurrentChangesetFor(theme)
|
||||
if (now.getTime() - cs.data.opened > 24 * 60 * 60 * 1000) {
|
||||
console.log("Clearing 'open changeset' for theme", theme, "; definitively expired by now")
|
||||
cs.set(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,10 @@ import { Utils } from "../../Utils"
|
|||
import { ImmutableStore, Store } from "../UIEventSource"
|
||||
import { BBox } from "../BBox"
|
||||
import osmtogeojson from "osmtogeojson"
|
||||
import { FeatureCollection } from "@turf/turf"
|
||||
import { Geometry } from "geojson"
|
||||
import { FeatureCollection, Geometry } from "geojson"
|
||||
import { OsmTags } from "../../Models/OsmFeature"
|
||||
;("use strict")
|
||||
|
||||
("use strict")
|
||||
/**
|
||||
* Interfaces overpass to get all the latest data
|
||||
*/
|
||||
|
|
@ -64,14 +64,14 @@ export class Overpass {
|
|||
elements: []
|
||||
remark
|
||||
osm3s: { timestamp_osm_base: string }
|
||||
}>(this.buildUrl(query))
|
||||
}>(this.buildUrl(query), {})
|
||||
|
||||
if (json.elements.length === 0 && json.remark !== undefined) {
|
||||
console.warn("Timeout or other runtime error while querying overpass", json.remark)
|
||||
throw `Runtime error (timeout or similar)${json.remark}`
|
||||
}
|
||||
if (json.elements.length === 0) {
|
||||
console.warn("No features for", json)
|
||||
console.warn("No features for", this.buildUrl(query))
|
||||
}
|
||||
|
||||
const geojson = <FeatureCollection<Geometry, OsmTags>>osmtogeojson(json)
|
||||
|
|
|
|||
|
|
@ -17,15 +17,16 @@ export default class SearchUtils {
|
|||
return true
|
||||
}
|
||||
if (searchTerm === "bugs" || searchTerm === "issues") {
|
||||
window.location.href = "https://github.com/pietervdvn/MapComplete/issues"
|
||||
window.location.href = "https://source.mapcomplete.org/MapComplete/MapComplete/issues"
|
||||
return true
|
||||
}
|
||||
if (searchTerm === "source") {
|
||||
window.location.href = "https://github.com/pietervdvn/MapComplete"
|
||||
window.location.href = "https://source.mapcomplete.org/MapComplete/MapComplete"
|
||||
return true
|
||||
}
|
||||
if (searchTerm === "docs") {
|
||||
window.location.href = "https://github.com/pietervdvn/MapComplete/tree/develop/Docs"
|
||||
window.location.href =
|
||||
"https://source.mapcomplete.org/MapComplete/MapComplete/src/branch/develop/Docs"
|
||||
return true
|
||||
}
|
||||
if (searchTerm === "osmcha" || searchTerm === "stats") {
|
||||
|
|
|
|||
|
|
@ -8,9 +8,10 @@ import { AndroidPolyfill } from "../Web/AndroidPolyfill"
|
|||
export type GeolocationPermissionState = "prompt" | "requested" | "granted" | "denied"
|
||||
|
||||
export interface GeoLocationPointProperties extends GeolocationCoordinates {
|
||||
id: "gps"
|
||||
id: "gps" | string
|
||||
"user:location": "yes"
|
||||
date: string
|
||||
date: string,
|
||||
alpha?: string
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -70,7 +70,17 @@ export default class SearchState {
|
|||
|
||||
const themeSearch = ThemeSearchIndex.fromState(state)
|
||||
this.themeSuggestions = this.searchTerm.mapD(
|
||||
(query) => themeSearch.data.search(query, 3),
|
||||
(query) => {
|
||||
const results = themeSearch.data.search(query, 3)
|
||||
const deduped: MinimalThemeInformation[] = []
|
||||
for (const result of results) {
|
||||
if (deduped.some(th => th.id === result.id)) {
|
||||
continue
|
||||
}
|
||||
deduped.push(result)
|
||||
}
|
||||
return deduped
|
||||
},
|
||||
[themeSearch]
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -21,10 +21,53 @@ import Showdown from "showdown"
|
|||
import { LocalStorageSource } from "../Web/LocalStorageSource"
|
||||
import { GeocodeResult } from "../Search/GeocodingProvider"
|
||||
|
||||
export class OptionallySyncedHistory<T> {
|
||||
class RoundRobinStore<T> {
|
||||
private readonly _store: UIEventSource<T[]>
|
||||
private readonly _index: UIEventSource<number>
|
||||
private readonly _value: UIEventSource<T[]>
|
||||
public readonly value: Store<T[]>
|
||||
private readonly _maxCount: number
|
||||
|
||||
constructor(store: UIEventSource<T[]>, index: UIEventSource<number>, maxCount: number) {
|
||||
this._store = store
|
||||
this._index = index
|
||||
this._maxCount = maxCount
|
||||
this._value = new UIEventSource([])
|
||||
this.value = this._value
|
||||
this._store.addCallbackD(() => this.set())
|
||||
this.set()
|
||||
}
|
||||
|
||||
private set() {
|
||||
const v = this._store.data
|
||||
const i = this._index.data
|
||||
const newList = Utils.NoNull(v.slice(i + 1, v.length).concat(v.slice(0, i + 1)))
|
||||
if (newList.length === 0) {
|
||||
this._index.set(0)
|
||||
}
|
||||
this._value.set(newList)
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a value to the underlying store
|
||||
* @param t
|
||||
*/
|
||||
public add(t: T) {
|
||||
const i = this._index.data
|
||||
this._index.set((i + 1) % this._maxCount)
|
||||
this._store.data[i] = t
|
||||
this._store.ping()
|
||||
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
export class OptionallySyncedHistory<T extends object | string> {
|
||||
public readonly syncPreference: UIEventSource<"sync" | "local" | "no">
|
||||
public readonly value: Store<T[]>
|
||||
private readonly synced: UIEventSource<T[]>
|
||||
private readonly syncedBackingStore: UIEventSource<T[]>
|
||||
private readonly syncedOrdered: RoundRobinStore<T>
|
||||
private readonly local: UIEventSource<T[]>
|
||||
private readonly thisSession: UIEventSource<T[]>
|
||||
private readonly _maxHistory: number
|
||||
|
|
@ -40,11 +83,20 @@ export class OptionallySyncedHistory<T> {
|
|||
this.osmconnection = osmconnection
|
||||
this._maxHistory = maxHistory
|
||||
this._isSame = isSame
|
||||
this.syncPreference = osmconnection.getPreference("preference-" + key + "-history", "sync")
|
||||
const synced = (this.synced = UIEventSource.asObject<T[]>(
|
||||
osmconnection.getPreference(key + "-history"),
|
||||
[]
|
||||
))
|
||||
this.syncPreference = osmconnection.getPreference("preference-" + key + "-history", {
|
||||
defaultValue: "sync"
|
||||
})
|
||||
|
||||
this.syncedBackingStore = Stores.fromArray(
|
||||
Utils.TimesT(maxHistory, (i) => {
|
||||
const pref = osmconnection.getPreference(key + "-hist-" + i + "-")
|
||||
return UIEventSource.asObject<T>(pref, undefined)
|
||||
}))
|
||||
|
||||
const ringIndex = UIEventSource.asInt(osmconnection.getPreference(key + "-hist-round-robin", {
|
||||
defaultValue: "0"
|
||||
}))
|
||||
this.syncedOrdered = new RoundRobinStore<T>(this.syncedBackingStore, ringIndex, 10)
|
||||
const local = (this.local = LocalStorageSource.getParsed<T[]>(key + "-history", []))
|
||||
const thisSession = (this.thisSession = new UIEventSource<T[]>(
|
||||
[],
|
||||
|
|
@ -52,7 +104,7 @@ export class OptionallySyncedHistory<T> {
|
|||
))
|
||||
this.syncPreference.addCallback((syncmode) => {
|
||||
if (syncmode === "sync") {
|
||||
const list = [...thisSession.data, ...synced.data].slice(0, maxHistory)
|
||||
const list = [...thisSession.data, ...this.syncedOrdered.value.data].slice(0, maxHistory)
|
||||
if (this._isSame) {
|
||||
for (let i = 0; i < list.length; i++) {
|
||||
for (let j = i + 1; j < list.length; j++) {
|
||||
|
|
@ -62,12 +114,12 @@ export class OptionallySyncedHistory<T> {
|
|||
}
|
||||
}
|
||||
}
|
||||
synced.set(list)
|
||||
this.syncedBackingStore.set(list)
|
||||
} else if (syncmode === "local") {
|
||||
local.set(synced.data?.slice(0, maxHistory))
|
||||
synced.set([])
|
||||
local.set(this.syncedOrdered.value.data?.slice(0, maxHistory))
|
||||
this.syncedBackingStore.set([])
|
||||
} else {
|
||||
synced.set([])
|
||||
this.syncedBackingStore.set([])
|
||||
local.set([])
|
||||
}
|
||||
})
|
||||
|
|
@ -75,10 +127,10 @@ export class OptionallySyncedHistory<T> {
|
|||
this.value = this.syncPreference.bind((syncPref) => this.getAppropriateStore(syncPref))
|
||||
}
|
||||
|
||||
private getAppropriateStore(syncPref?: string) {
|
||||
private getAppropriateStore(syncPref?: string): Store<T[]> {
|
||||
syncPref ??= this.syncPreference.data
|
||||
if (syncPref === "sync") {
|
||||
return this.synced
|
||||
return this.syncedOrdered.value
|
||||
}
|
||||
if (syncPref === "local") {
|
||||
return this.local
|
||||
|
|
@ -87,12 +139,29 @@ export class OptionallySyncedHistory<T> {
|
|||
}
|
||||
|
||||
public add(t: T) {
|
||||
const store = this.getAppropriateStore()
|
||||
let oldList = store.data ?? []
|
||||
if (this._isSame) {
|
||||
oldList = oldList.filter((x) => !this._isSame(t, x))
|
||||
const alreadyNoted = this.getAppropriateStore().data.some(item => this._isSame(item, t))
|
||||
if (alreadyNoted) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (this.syncPreference.data === "local") {
|
||||
const ls = this.local.data
|
||||
ls.unshift(t)
|
||||
if (ls.length >= this._maxHistory) {
|
||||
ls.splice(this._maxHistory, 1)
|
||||
}
|
||||
this.local.ping()
|
||||
} else if (this.syncPreference.data === "sync") {
|
||||
this.osmconnection.isLoggedIn.addCallbackAndRun(loggedIn => {
|
||||
// Wait until we are logged in and the settings are downloaded before adding the preference
|
||||
if (loggedIn) {
|
||||
this.syncedOrdered.add(t)
|
||||
return true
|
||||
}
|
||||
})
|
||||
}
|
||||
store.set([t, ...oldList].slice(0, this._maxHistory))
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -113,7 +182,8 @@ export class OptionallySyncedHistory<T> {
|
|||
}
|
||||
|
||||
clear() {
|
||||
this.getAppropriateStore().set([])
|
||||
this.syncedBackingStore.set([])
|
||||
this.local.set([])
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -190,33 +260,33 @@ export default class UserRelatedState {
|
|||
this.osmConnection = osmConnection
|
||||
|
||||
this.showAllQuestionsAtOnce = UIEventSource.asBoolean(
|
||||
this.osmConnection.getPreference("show-all-questions", "false")
|
||||
this.osmConnection.getPreference("show-all-questions", { defaultValue: "false" })
|
||||
)
|
||||
this.language = this.osmConnection.getPreference("language")
|
||||
this.showTags = this.osmConnection.getPreference("show_tags")
|
||||
this.showCrosshair = this.osmConnection.getPreference("show_crosshair")
|
||||
this.fixateNorth = this.osmConnection.getPreference("fixate-north")
|
||||
this.morePrivacy = this.osmConnection.getPreference("more_privacy", "no")
|
||||
this.morePrivacy = this.osmConnection.getPreference("more_privacy", { defaultValue: "no" })
|
||||
|
||||
this.a11y = this.osmConnection.getPreference("a11y")
|
||||
|
||||
this.mangroveIdentity = new MangroveIdentity(
|
||||
this.osmConnection.getPreference("identity", undefined, "mangrove"),
|
||||
this.osmConnection.getPreference("identity-creation-date", undefined, "mangrove")
|
||||
)
|
||||
this.preferredBackgroundLayer = this.osmConnection.getPreference(
|
||||
"preferred-background-layer"
|
||||
this.osmConnection.getPreference("identity", { defaultValue: undefined, prefix: "mangrove" }),
|
||||
this.osmConnection.getPreference("identity-creation-date", {
|
||||
defaultValue: undefined,
|
||||
prefix: "mangrove"
|
||||
})
|
||||
)
|
||||
this.preferredBackgroundLayer = this.osmConnection.getPreference("preferred-background-layer")
|
||||
|
||||
this.addNewFeatureMode = this.osmConnection.getPreference(
|
||||
"preferences-add-new-mode",
|
||||
"button_click_right"
|
||||
this.addNewFeatureMode = this.osmConnection.getPreference("preferences-add-new-mode",
|
||||
{ defaultValue: "button_click_right" }
|
||||
)
|
||||
this.showScale = UIEventSource.asBoolean(
|
||||
this.osmConnection.GetPreference("preference-show-scale", "false")
|
||||
this.osmConnection.getPreference("preference-show-scale", { defaultValue: "false" })
|
||||
)
|
||||
|
||||
this.imageLicense = this.osmConnection.getPreference("pictures-license", "CC0")
|
||||
this.imageLicense = this.osmConnection.getPreference("pictures-license", { defaultValue: "CC0" })
|
||||
this.installedUserThemes = UserRelatedState.initInstalledUserThemes(osmConnection)
|
||||
this.translationMode = this.initTranslationMode()
|
||||
this.homeLocation = this.initHomeLocation()
|
||||
|
|
@ -249,7 +319,7 @@ export default class UserRelatedState {
|
|||
|
||||
private initTranslationMode(): UIEventSource<"false" | "true" | "mobile" | undefined | string> {
|
||||
const translationMode: UIEventSource<undefined | "true" | "false" | "mobile" | string> =
|
||||
this.osmConnection.getPreference("translation-mode", "false")
|
||||
this.osmConnection.getPreference("translation-mode", { defaultValue: "false" })
|
||||
translationMode.addCallbackAndRunD((mode) => {
|
||||
mode = mode.toLowerCase()
|
||||
if (mode === "true" || mode === "yes") {
|
||||
|
|
@ -300,8 +370,8 @@ export default class UserRelatedState {
|
|||
} catch (e) {
|
||||
console.warn(
|
||||
"Removing theme " +
|
||||
id +
|
||||
" as it could not be parsed from the preferences; the content is:",
|
||||
id +
|
||||
" as it could not be parsed from the preferences; the content is:",
|
||||
str
|
||||
)
|
||||
pref.setData(null)
|
||||
|
|
@ -331,7 +401,7 @@ export default class UserRelatedState {
|
|||
icon: layout.icon,
|
||||
title: layout.title.translations,
|
||||
shortDescription: layout.shortDescription.translations,
|
||||
definition: layout["definition"],
|
||||
definition: layout["definition"]
|
||||
})
|
||||
)
|
||||
}
|
||||
|
|
@ -386,13 +456,13 @@ export default class UserRelatedState {
|
|||
id: "home",
|
||||
"user:home": "yes",
|
||||
_lon: homeLonLat[0],
|
||||
_lat: homeLonLat[1],
|
||||
_lat: homeLonLat[1]
|
||||
},
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: homeLonLat,
|
||||
},
|
||||
},
|
||||
coordinates: homeLonLat
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
return new StaticFeatureSource(feature)
|
||||
|
|
@ -414,7 +484,7 @@ export default class UserRelatedState {
|
|||
_applicationOpened: new Date().toISOString(),
|
||||
_supports_sharing:
|
||||
typeof window === "undefined" ? "no" : window.navigator.share ? "yes" : "no",
|
||||
_iframe: Utils.isIframe ? "yes" : "no",
|
||||
_iframe: Utils.isIframe ? "yes" : "no"
|
||||
})
|
||||
if (!Utils.runningFromConsole) {
|
||||
amendedPrefs.data["_host"] = window.location.host
|
||||
|
|
@ -462,18 +532,18 @@ export default class UserRelatedState {
|
|||
const zenLinks: { link: string; id: string }[] = Utils.NoNull([
|
||||
hasMissingTheme
|
||||
? {
|
||||
id: "theme:" + layout.id,
|
||||
link: LinkToWeblate.hrefToWeblateZen(
|
||||
language,
|
||||
"themes",
|
||||
layout.id
|
||||
),
|
||||
}
|
||||
id: "theme:" + layout.id,
|
||||
link: LinkToWeblate.hrefToWeblateZen(
|
||||
language,
|
||||
"themes",
|
||||
layout.id
|
||||
)
|
||||
}
|
||||
: undefined,
|
||||
...missingLayers.map((id) => ({
|
||||
id: "layer:" + id,
|
||||
link: LinkToWeblate.hrefToWeblateZen(language, "layers", id),
|
||||
})),
|
||||
link: LinkToWeblate.hrefToWeblateZen(language, "layers", id)
|
||||
}))
|
||||
])
|
||||
const untranslated_count = untranslated.length
|
||||
amendedPrefs.data["_translation_total"] = "" + total
|
||||
|
|
@ -579,7 +649,7 @@ export default class UserRelatedState {
|
|||
public getThemeDisabled(themeId: string, layerId: string): UIEventSource<string[]> {
|
||||
const flatSource = this.osmConnection.getPreference(
|
||||
"disabled-questions-" + themeId + "-" + layerId,
|
||||
"[]"
|
||||
{ defaultValue: "[]" }
|
||||
)
|
||||
return UIEventSource.asObject<string[]>(flatSource, [])
|
||||
}
|
||||
|
|
|
|||
|
|
@ -92,12 +92,12 @@ export class TagUtils {
|
|||
"!~i~": {
|
||||
name: "Value does *not* match case-invariant regex",
|
||||
overpassSupport: true,
|
||||
docs: "A tag can also be tested against a regex with `key~i~regex`, where the case of the value will be ignored. The regex is still matched against the _entire_ value. This filter returns true if the value does *not* match",
|
||||
docs: "A tag can also be tested against a regex with `key~i~regex`, where the case of the value will be ignored. The regex is still matched against the _entire_ value (thus: a `^` and `$` are automatically added to start and end). This filter returns true if the value does *not* match"
|
||||
},
|
||||
"~~": {
|
||||
name: "Key and value should match given regex",
|
||||
overpassSupport: true,
|
||||
docs: "Both the `key` and `value` part of this specification are interpreted as regexes, both the key and value musth completely match their respective regexes",
|
||||
docs: "Both the `key` and `value` part of this specification are interpreted as regexes, both the key and value must completely match their respective regexes (thus: a `^` and `$` are automatically added to start and end)"
|
||||
},
|
||||
"~i~~": {
|
||||
name: "Key and value should match a given regex; value is case-invariant",
|
||||
|
|
@ -191,7 +191,7 @@ export class TagUtils {
|
|||
"When creating the `json` file describing your layer or theme, you'll have to add a few tags to describe what you want.\n" +
|
||||
"This document gives an overview of what every expression means and how it behaves in edge cases.\n" +
|
||||
"\n" +
|
||||
"If the schema-files note a type [`TagConfigJson`](https://github.com/pietervdvn/MapComplete/blob/develop/src/Models/ThemeConfig/Json/TagConfigJson.ts), you can use one of these values.\n" +
|
||||
"If the schema-files note a type [`TagConfigJson`](https://source.mapcomplete.org/MapComplete/MapComplete/src/branch/develop/src/Models/ThemeConfig/Json/TagConfigJson.ts), you can use one of these values.\n" +
|
||||
"\n" +
|
||||
"In some cases, not every type of tags-filter can be used. For example, _rendering_ an option with a regex is\n" +
|
||||
'fine (`"if": "brand~[Bb]randname", "then":" The brand is Brandname"`); but this regex can not be used to write a value\n' +
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { Utils } from "../Utils"
|
||||
import { Readable, Subscriber, Unsubscriber, Updater, Writable } from "svelte/store"
|
||||
|
||||
/**
|
||||
* Various static utils
|
||||
*/
|
||||
|
|
@ -36,8 +37,10 @@ export class Stores {
|
|||
*/
|
||||
public static FromPromise<T>(promise: Promise<T>): Store<T | undefined> {
|
||||
const src = new UIEventSource<T>(undefined)
|
||||
promise?.then((d) => src.setData(d))
|
||||
promise?.catch((err) => console.warn("Promise failed:", err))
|
||||
promise?.catch((err): undefined => {
|
||||
console.warn("Promise failed:", err)
|
||||
return undefined
|
||||
})?.then((d) => src.setData(d))
|
||||
return src
|
||||
}
|
||||
|
||||
|
|
@ -104,6 +107,33 @@ export class Stores {
|
|||
})
|
||||
return newStore
|
||||
}
|
||||
|
||||
public static fromArray<T>(sources: ReadonlyArray<UIEventSource<T>>): UIEventSource<T[]> {
|
||||
const src = new UIEventSource<T[]>(sources.map(s => s.data))
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
sources[i].addCallback(content => {
|
||||
src.data[i] = content
|
||||
src.ping()
|
||||
})
|
||||
}
|
||||
src.addCallbackD(contents => {
|
||||
for (let i = 0; i < contents.length; i++) {
|
||||
sources[i].setData(contents[i])
|
||||
}
|
||||
})
|
||||
return src
|
||||
}
|
||||
|
||||
public static fromStoresArray<T>(sources: ReadonlyArray<Store<T>>): Store<T[]> {
|
||||
const src = new UIEventSource<T[]>(sources.map(s => s.data))
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
sources[i].addCallback(content => {
|
||||
src.data[i] = content
|
||||
src.ping()
|
||||
})
|
||||
}
|
||||
return src
|
||||
}
|
||||
}
|
||||
|
||||
export abstract class Store<T> implements Readable<T> {
|
||||
|
|
@ -129,16 +159,16 @@ export abstract class Store<T> implements Readable<T> {
|
|||
}
|
||||
|
||||
abstract map<J>(f: (t: T) => J): Store<J>
|
||||
abstract map<J>(f: (t: T) => J, extraStoresToWatch: Store<any>[]): Store<J>
|
||||
abstract map<J>(f: (t: T) => J, extraStoresToWatch: Store<unknown>[]): Store<J>
|
||||
abstract map<J>(
|
||||
f: (t: T) => J,
|
||||
extraStoresToWatch: Store<any>[],
|
||||
extraStoresToWatch: Store<unknown>[],
|
||||
callbackDestroyFunction: (f: () => void) => void
|
||||
): Store<J>
|
||||
|
||||
public mapD<J>(
|
||||
f: (t: Exclude<T, undefined | null>) => J,
|
||||
extraStoresToWatch?: Store<any>[],
|
||||
extraStoresToWatch?: Store<unknown>[],
|
||||
callbackDestroyFunction?: (f: () => void) => void
|
||||
): Store<J> {
|
||||
return this.map(
|
||||
|
|
@ -241,7 +271,7 @@ export abstract class Store<T> implements Readable<T> {
|
|||
* src.setData(0)
|
||||
* lastValue // => "def"
|
||||
*/
|
||||
public bind<X>(f: (t: T) => Store<X>, extraSources: Store<object>[] = []): Store<X> {
|
||||
public bind<X>(f: (t: T) => Store<X>, extraSources: Store<unknown>[] = []): Store<X> {
|
||||
const mapped = this.map(f, extraSources)
|
||||
const sink = new UIEventSource<X>(undefined)
|
||||
const seenEventSources = new Set<Store<X>>()
|
||||
|
|
@ -275,7 +305,7 @@ export abstract class Store<T> implements Readable<T> {
|
|||
|
||||
public bindD<X>(
|
||||
f: (t: Exclude<T, undefined | null>) => Store<X>,
|
||||
extraSources: Store<any>[] = []
|
||||
extraSources: Store<unknown>[] = []
|
||||
): Store<X> {
|
||||
return this.bind((t) => {
|
||||
if (t === null) {
|
||||
|
|
@ -295,10 +325,9 @@ export abstract class Store<T> implements Readable<T> {
|
|||
|
||||
const newSource = new UIEventSource<T>(this.data)
|
||||
|
||||
const self = this
|
||||
this.addCallback((latestData) => {
|
||||
window.setTimeout(() => {
|
||||
if (self.data == latestData) {
|
||||
if (this.data == latestData) {
|
||||
// compare by reference.
|
||||
// Note that 'latestData' and 'self.data' are both from the same UIEVentSource, but both are dereferenced at a different time
|
||||
newSource.setData(latestData)
|
||||
|
|
@ -316,14 +345,13 @@ export abstract class Store<T> implements Readable<T> {
|
|||
* @constructor
|
||||
*/
|
||||
public AsPromise(condition?: (t: T) => boolean): Promise<T> {
|
||||
const self = this
|
||||
condition = condition ?? ((t) => t !== undefined)
|
||||
return new Promise((resolve) => {
|
||||
const data = self.data
|
||||
const data = this.data
|
||||
if (condition(data)) {
|
||||
resolve(data)
|
||||
} else {
|
||||
self.addCallbackD((data) => {
|
||||
this.addCallbackD((data) => {
|
||||
if (condition(data)) {
|
||||
resolve(data)
|
||||
return true // return true to unregister as we only need to be called once
|
||||
|
|
@ -338,7 +366,7 @@ export abstract class Store<T> implements Readable<T> {
|
|||
/**
|
||||
* Same as 'addCallbackAndRun', added to be compatible with Svelte
|
||||
*/
|
||||
public subscribe(run: Subscriber<T> & ((value: T) => void), _?): Unsubscriber {
|
||||
public subscribe(run: Subscriber<T> & ((value: T) => void)): Unsubscriber {
|
||||
// We don't need to do anything with 'invalidate', see
|
||||
// https://github.com/sveltejs/svelte/issues/3859
|
||||
|
||||
|
|
@ -371,7 +399,8 @@ export class ImmutableStore<T> extends Store<T> {
|
|||
this.data = data
|
||||
}
|
||||
|
||||
private static readonly pass: () => void = () => {}
|
||||
private static readonly pass: () => void = () => {
|
||||
}
|
||||
|
||||
addCallback(_: (data: T) => void): () => void {
|
||||
// pass: data will never change
|
||||
|
|
@ -451,7 +480,7 @@ class ListenerTracker<T> {
|
|||
public ping(data: T): number {
|
||||
this.pingCount++
|
||||
let toDelete = undefined
|
||||
let startTime = new Date().getTime() / 1000
|
||||
const startTime = new Date().getTime() / 1000
|
||||
for (const callback of this._callbacks) {
|
||||
try {
|
||||
if (callback(data) === true) {
|
||||
|
|
@ -467,7 +496,7 @@ class ListenerTracker<T> {
|
|||
console.error("Got an error while running a callback:", e)
|
||||
}
|
||||
}
|
||||
let endTime = new Date().getTime() / 1000
|
||||
const endTime = new Date().getTime() / 1000
|
||||
if (endTime - startTime > 500) {
|
||||
console.trace(
|
||||
"Warning: a ping took more then 500ms; this is probably a performance issue"
|
||||
|
|
@ -495,13 +524,13 @@ class ListenerTracker<T> {
|
|||
* The mapped store is a helper type which does the mapping of a function.
|
||||
*/
|
||||
class MappedStore<TIn, T> extends Store<T> {
|
||||
private static readonly pass: () => {}
|
||||
private static readonly pass: () => void
|
||||
private readonly _upstream: Store<TIn>
|
||||
private readonly _upstreamCallbackHandler: ListenerTracker<TIn> | undefined
|
||||
private _upstreamPingCount: number = -1
|
||||
private _unregisterFromUpstream: () => void
|
||||
private readonly _f: (t: TIn) => T
|
||||
private readonly _extraStores: Store<any>[] | undefined
|
||||
private readonly _extraStores: Store<unknown>[] | undefined
|
||||
private _unregisterFromExtraStores: (() => void)[] | undefined
|
||||
private _callbacks: ListenerTracker<T> = new ListenerTracker<T>()
|
||||
private _callbacksAreRegistered = false
|
||||
|
|
@ -509,7 +538,7 @@ class MappedStore<TIn, T> extends Store<T> {
|
|||
constructor(
|
||||
upstream: Store<TIn>,
|
||||
f: (t: TIn) => T,
|
||||
extraStores: Store<any>[],
|
||||
extraStores: Store<unknown>[],
|
||||
upstreamListenerHandler: ListenerTracker<TIn> | undefined,
|
||||
initialState: T,
|
||||
onDestroy?: (f: () => void) => void
|
||||
|
|
@ -551,10 +580,10 @@ class MappedStore<TIn, T> extends Store<T> {
|
|||
|
||||
map<J>(
|
||||
f: (t: T) => J,
|
||||
extraStores: Store<any>[] = undefined,
|
||||
extraStores: Store<unknown>[] = undefined,
|
||||
ondestroyCallback?: (f: () => void) => void
|
||||
): Store<J> {
|
||||
let stores: Store<any>[] = undefined
|
||||
let stores: Store<unknown>[] = undefined
|
||||
if (extraStores?.length > 0 || this._extraStores?.length > 0) {
|
||||
stores = []
|
||||
}
|
||||
|
|
@ -578,7 +607,7 @@ class MappedStore<TIn, T> extends Store<T> {
|
|||
)
|
||||
}
|
||||
|
||||
addCallback(callback: (data: T) => any | boolean | void): () => void {
|
||||
addCallback(callback: (data: T) => unknown | boolean | void): () => void {
|
||||
if (!this._callbacksAreRegistered) {
|
||||
// This is the first callback that is added
|
||||
// We register this 'map' to the upstream object and all the streams
|
||||
|
|
@ -593,7 +622,7 @@ class MappedStore<TIn, T> extends Store<T> {
|
|||
}
|
||||
}
|
||||
|
||||
addCallbackAndRun(callback: (data: T) => any | boolean | void): () => void {
|
||||
addCallbackAndRun(callback: (data: T) => unknown | boolean | void): () => void {
|
||||
const unregister = this.addCallback(callback)
|
||||
const doRemove = callback(this.data)
|
||||
if (doRemove === true) {
|
||||
|
|
@ -603,7 +632,7 @@ class MappedStore<TIn, T> extends Store<T> {
|
|||
return unregister
|
||||
}
|
||||
|
||||
addCallbackAndRunD(callback: (data: T) => any | boolean | void): () => void {
|
||||
addCallbackAndRunD(callback: (data: T) => unknown | boolean | void): () => void {
|
||||
return this.addCallbackAndRun((data) => {
|
||||
if (data !== undefined) {
|
||||
return callback(data)
|
||||
|
|
@ -611,7 +640,7 @@ class MappedStore<TIn, T> extends Store<T> {
|
|||
})
|
||||
}
|
||||
|
||||
addCallbackD(callback: (data: T) => any | boolean | void): () => void {
|
||||
addCallbackD(callback: (data: T) => unknown | boolean | void): () => void {
|
||||
return this.addCallback((data) => {
|
||||
if (data !== undefined) {
|
||||
return callback(data)
|
||||
|
|
@ -626,9 +655,9 @@ class MappedStore<TIn, T> extends Store<T> {
|
|||
}
|
||||
|
||||
private registerCallbacksToUpstream() {
|
||||
this._unregisterFromUpstream = this._upstream.addCallback((_) => this.update())
|
||||
this._unregisterFromUpstream = this._upstream.addCallback(() => this.update())
|
||||
this._unregisterFromExtraStores = this._extraStores?.map((store) =>
|
||||
store?.addCallback((_) => this.update())
|
||||
store?.addCallback(() => this.update())
|
||||
)
|
||||
this._callbacksAreRegistered = true
|
||||
}
|
||||
|
|
@ -649,7 +678,8 @@ class MappedStore<TIn, T> extends Store<T> {
|
|||
}
|
||||
|
||||
export class UIEventSource<T> extends Store<T> implements Writable<T> {
|
||||
private static readonly pass: () => void = () => {}
|
||||
private static readonly pass: () => void = () => {
|
||||
}
|
||||
public data: T
|
||||
_callbacks: ListenerTracker<T> = new ListenerTracker<T>()
|
||||
|
||||
|
|
@ -790,7 +820,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
|
|||
)
|
||||
}
|
||||
|
||||
static asObject<T extends object>(
|
||||
static asObject<T extends object | string>(
|
||||
stringUIEventSource: UIEventSource<string>,
|
||||
defaultV: T
|
||||
): UIEventSource<T> {
|
||||
|
|
@ -802,7 +832,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
|
|||
try {
|
||||
return <T>JSON.parse(str)
|
||||
} catch (e) {
|
||||
console.error("Could not parse value", str, "due to", e)
|
||||
console.error("Could not parse value", str, "due to", e, "; the underlying data store has tag", stringUIEventSource.tag)
|
||||
return defaultV
|
||||
}
|
||||
},
|
||||
|
|
@ -827,11 +857,11 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
|
|||
* If the result of the callback is 'true', the callback is considered finished and will be removed again
|
||||
* @param callback
|
||||
*/
|
||||
public addCallback(callback: (latestData: T) => boolean | void | any): () => void {
|
||||
public addCallback(callback: (latestData: T) => boolean | void | unknown): () => void {
|
||||
return this._callbacks.addCallback(callback)
|
||||
}
|
||||
|
||||
public addCallbackAndRun(callback: (latestData: T) => boolean | void | any): () => void {
|
||||
public addCallbackAndRun(callback: (latestData: T) => boolean | void | unknown): () => void {
|
||||
const doDeleteCallback = callback(this.data)
|
||||
if (doDeleteCallback !== true) {
|
||||
return this.addCallback(callback)
|
||||
|
|
@ -896,7 +926,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
|
|||
*/
|
||||
public map<J>(
|
||||
f: (t: T) => J,
|
||||
extraSources: Store<any>[] = [],
|
||||
extraSources: Store<unknown>[] = [],
|
||||
onDestroy?: (f: () => void) => void
|
||||
): Store<J> {
|
||||
return new MappedStore(this, f, extraSources, this._callbacks, f(this.data), onDestroy)
|
||||
|
|
@ -908,7 +938,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
|
|||
*/
|
||||
public mapD<J>(
|
||||
f: (t: Exclude<T, undefined | null>) => J,
|
||||
extraSources: Store<any>[] = [],
|
||||
extraSources: Store<unknown>[] = [],
|
||||
callbackDestroyFunction?: (f: () => void) => void
|
||||
): Store<J | undefined> {
|
||||
return new MappedStore(
|
||||
|
|
@ -926,7 +956,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
|
|||
this._callbacks,
|
||||
this.data === undefined || this.data === null
|
||||
? <undefined | null>this.data
|
||||
: f(<any>this.data),
|
||||
: f(<Exclude<T, undefined | null>>this.data),
|
||||
callbackDestroyFunction
|
||||
)
|
||||
}
|
||||
|
|
@ -945,7 +975,7 @@ export class UIEventSource<T> extends Store<T> implements Writable<T> {
|
|||
*/
|
||||
public sync<J>(
|
||||
f: (t: T) => J,
|
||||
extraSources: Store<any>[],
|
||||
extraSources: Store<unknown>[],
|
||||
g: (j: J, t: T) => T,
|
||||
allowUnregister = false
|
||||
): UIEventSource<J> {
|
||||
|
|
|
|||
|
|
@ -38,11 +38,11 @@ export class IdbLocalStorage {
|
|||
return src
|
||||
}
|
||||
|
||||
public static SetDirectly(key: string, value: any): Promise<void> {
|
||||
public static SetDirectly<T>(key: string, value: T): Promise<void> {
|
||||
return idb.set(key, value)
|
||||
}
|
||||
|
||||
static GetDirectly(key: string): Promise<any> {
|
||||
static GetDirectly<T>(key: string): Promise<T> {
|
||||
return idb.get(key)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -141,7 +141,7 @@ export class MangroveIdentity {
|
|||
*/
|
||||
export default class FeatureReviews {
|
||||
/**
|
||||
* See https://gitlab.com/open-reviews/mangrove/-/blob/master/servers/reviewer/src/review.rs#L269 and https://github.com/pietervdvn/MapComplete/issues/1775
|
||||
* See https://gitlab.com/open-reviews/mangrove/-/blob/master/servers/reviewer/src/review.rs#L269 and https://source.mapcomplete.org/MapComplete/MapComplete/issues/1775
|
||||
*/
|
||||
public static readonly REVIEW_OPINION_MAX_LENGTH = 1000
|
||||
private static readonly _featureReviewsCache: Record<string, FeatureReviews> = {}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue