import { ImageUploader, UploadResult } from "./ImageUploader" import LinkImageAction from "../Osm/Actions/LinkImageAction" import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore" 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 { 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 _gps: Store private readonly _osmConnection: OsmConnection private readonly _changes: Changes /** * 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 = new UIEventSource([]) public readonly successfull: Store = 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 = new UIEventSource< ImageUploadArguments[] >([]) public readonly fails: Store = this._fails.map((args) => args.map((a) => a.featureId)) /** * FeatureIDs of queued items */ public readonly queued: Store = 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 = new UIEventSource(undefined) public readonly isUploading: Store = this._isUploading private readonly _reportError: ( message: string | Error | XMLHttpRequest, extramessage?: string ) => Promise constructor( layout: ThemeConfig, uploader: ImageUploader, featureProperties: FeaturePropertiesStore, osmConnection: OsmConnection, changes: Changes, gpsLocation: Store, reportError: ( message: string | Error | XMLHttpRequest, extramessage?: string ) => Promise ) { this._uploader = uploader this._featureProperties = featureProperties this._theme = layout this._osmConnection = osmConnection this._changes = changes this._gps = gpsLocation this._reportError = reportError } 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 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 uploadImageAndApply( file: File, tagsStore: UIEventSource, targetKey: string, noblur: boolean, feature: Feature, options: { ignoreGPS: boolean | false } ): void { const canBeUploaded = this.canBeUploaded(file) if (canBeUploaded !== true) { throw canBeUploaded.error } const tags: OsmTags = tagsStore.data const featureId = tags.id const author = this._osmConnection?.userDetails?.data?.name ?? "Anonymous" // Might be a note upload /** * 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 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() } /** * 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 { 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> = this._featureProperties.getStore( args.featureId ) if (!isNaN(Number(args.featureId))) { // This is an OSM-note const url = result.absoluteUrl await this._osmConnection.addCommentToNote(args.featureId, url) const properties: UIEventSource> = 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 { let key: string let value: string let absoluteUrl: string try { ;({ key, value, absoluteUrl } = await this._uploader.uploadImage( blob, location, author, noblur )) } catch (e) { 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, }) ) } return undefined } key = targetKey ?? key if (targetKey && targetKey.indexOf(key) < 0) { // This is a non-standard key, so we use the image link directly value = absoluteUrl } return { key, absoluteUrl, value } } }