import { ImageUploader, UploadResult } from "./ImageUploader" import LinkImageAction from "../Osm/Actions/LinkImageAction" import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore" import { 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 EmergencyImageBackup from "./EmergencyImageBackup" /** * The ImageUploadManager has a */ export class ImageUploadManager { private readonly _uploader: ImageUploader private readonly _featureProperties: FeaturePropertiesStore private readonly _theme: ThemeConfig private readonly _indexedFeatures: IndexedFeatureSource private readonly _gps: Store private readonly _uploadStarted: Map> = new Map() private readonly _uploadFinished: Map> = new Map() private readonly _uploadFailed: Map> = new Map() private readonly _uploadRetried: Map> = new Map() private readonly _uploadRetriedSuccess: Map> = new Map() private readonly _osmConnection: OsmConnection private readonly _changes: Changes public readonly isUploading: Store private readonly _reportError: ( message: string | Error | XMLHttpRequest, extramessage?: string ) => Promise constructor( layout: ThemeConfig, uploader: ImageUploader, featureProperties: FeaturePropertiesStore, osmConnection: OsmConnection, changes: Changes, gpsLocation: Store, allFeatures: IndexedFeatureSource, reportError: ( message: string | Error | XMLHttpRequest, extramessage?: string ) => Promise ) { this._uploader = uploader this._featureProperties = featureProperties 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 uploadStarted: Store retrySuccess: Store failed: Store uploadFinished: Store } { 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 } { const sizeInBytes = file.size 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" }) 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 * @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( file: File, tagsStore: UIEventSource, targetKey: string, noblur: boolean, feature: Feature ): Promise { const canBeUploaded = this.canBeUploaded(file) if (canBeUploaded !== true) { throw canBeUploaded.error } const tags = tagsStore.data const featureId = tags.id const author = this._osmConnection.userDetails.data.name const uploadResult = await this.uploadImageWithLicense( featureId, author, file, targetKey, noblur, feature ) if (!uploadResult) { return } 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" } ) await this._changes.applyAction(action) } /** * Uploads an image; returns undefined if the image upload failed. * Errors are handled internally * @param featureId * @param author * @param blob * @param targetKey * @param noblur * @param feature * @param options */ public async uploadImageWithLicense( featureId: string, author: string, blob: File, targetKey: string | undefined, noblur: boolean, feature: Feature, options?: { ignoreGps?: boolean, noBackup?: boolean, overwriteGps?: GeolocationCoordinates } ): Promise { this.increaseCountFor(this._uploadStarted, featureId) let key: string let value: string let absoluteUrl: string let location: [number, number] = undefined if (options?.overwriteGps) { location = [options.overwriteGps.longitude, options.overwriteGps.latitude] } else if (this._gps.data && !options?.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( 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) if (!options?.noBackup) { EmergencyImageBackup.singleton.addFailedImage({ blob, author, noblur, featureId, targetKey, ignoreGps: options?.ignoreGps, layoutId: this._theme.id, lastGpsLocation: this._gps.data, date: new Date().getTime() }) } await this._reportError( e, JSON.stringify({ ctx: "While uploading an image in the Image Upload Manager", featureId, author, targetKey }) ) return undefined } } console.log("Uploading image done, creating action for", featureId, " targetkey is", targetKey, "key is", key) key = targetKey ?? key 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>, key: string | "*") { if (this._featureProperties.aliases.has(key)) { key = this._featureProperties.aliases.get(key) } if (!collection.has(key)) { collection.set(key, new UIEventSource(0)) } return collection.get(key) } private increaseCountFor(collection: Map>, key: string | "*") { { const counter = this.getCounterFor(collection, key) counter.setData(counter.data + 1) } { const global = this.getCounterFor(collection, "*") global.setData(global.data + 1) } } }