From 9f3d198068c852f770e280d759c18fa1538ca9b3 Mon Sep 17 00:00:00 2001 From: Pieter Vander Vennet Date: Sun, 6 Apr 2025 15:32:58 +0200 Subject: [PATCH] Feature: add emergency image backup. If uploading images fails, they are saved into local storage and uploaded later on. Part of #2111, but also #2342 --- langs/en.json | 10 ++ .../ImageProviders/EmergencyImageBackup.ts | 112 ++++++++++++++++++ .../ImageProviders/ImageUploadManager.ts | 45 +++++-- src/Models/MenuState.ts | 1 + src/Models/ThemeViewState/WithImageState.ts | 5 + src/UI/BigComponents/MenuDrawer.svelte | 15 +++ src/UI/Image/FailedImage.svelte | 88 ++++++++++++++ src/UI/Image/FailedImagesView.svelte | 41 +++++++ src/UI/Image/UploadImage.svelte | 4 +- 9 files changed, 313 insertions(+), 8 deletions(-) create mode 100644 src/Logic/ImageProviders/EmergencyImageBackup.ts create mode 100644 src/UI/Image/FailedImage.svelte create mode 100644 src/UI/Image/FailedImagesView.svelte diff --git a/langs/en.json b/langs/en.json index 6d5b7f7880..1b2195d265 100644 --- a/langs/en.json +++ b/langs/en.json @@ -63,6 +63,16 @@ "overwrite": "Overwrite in OpenStreetMap", "title": "Structured data loaded from the external website" }, + "failedImages": { + "confirmDelete": "Permanently delete this image", + "confirmDeleteTitle": "Delete this image?", + "delete": "Delete this image", + "intro": "The following images failed to upload", + "menu": "Failed images ({count})", + "noFailedImages": "There are currently no failed images", + "retry": "Retry uploading this image", + "retryAll": "Retry uploading all images" + }, "favourite": { "reload": "Reload the data" }, diff --git a/src/Logic/ImageProviders/EmergencyImageBackup.ts b/src/Logic/ImageProviders/EmergencyImageBackup.ts new file mode 100644 index 0000000000..329e1731cf --- /dev/null +++ b/src/Logic/ImageProviders/EmergencyImageBackup.ts @@ -0,0 +1,112 @@ +import { IdbLocalStorage } from "../Web/IdbLocalStorage" +import { Store, UIEventSource } from "../UIEventSource" +import ThemeViewState from "../../Models/ThemeViewState" +import LinkImageAction from "../Osm/Actions/LinkImageAction" +import { WithImageState } from "../../Models/ThemeViewState/WithImageState" + +export interface FailedImageArgs { + readonly featureId: string, + readonly author: string, + readonly blob: File, + readonly targetKey: string | undefined, + readonly noblur: boolean, + readonly ignoreGps: boolean, + readonly lastGpsLocation: GeolocationCoordinates, + readonly layoutId: string + readonly date: number +} + +export default class EmergencyImageBackup { + + public static readonly singleton = new EmergencyImageBackup() + private readonly _failedImages: UIEventSource + + public readonly failedImages: Store + + private readonly _isUploading: UIEventSource = new UIEventSource(false) + public readonly isUploading: Store = this._isUploading + + private constructor() { + this._failedImages = IdbLocalStorage.Get("failed-images-backup", { defaultValue: [] }) + this.failedImages = this._failedImages + } + + public addFailedImage(args: FailedImageArgs) { + this._failedImages.data.push(args) + this._failedImages.ping() + } + + public delete(img: FailedImageArgs) { + const index = this._failedImages.data.indexOf(img) + if (index < 0) { + return + } + this._failedImages.data.splice(index, 1) + this._failedImages.ping() + } + + /** + * Retries uploading the given image + * Returns 'true' if the image got correctly uploaded and linked (or upload is no longer necessary, e.g. deleted iem) + * @param state + * @param i + */ + public async retryUploading(state: ThemeViewState, i: FailedImageArgs): Promise { + this._isUploading.set(true) + try { + + const feature = await state.osmObjectDownloader.DownloadObjectAsync(i.featureId) + if (feature === "deleted") { + return true + } + + const asGeojson = feature.asGeoJson() + const uploadResult = await state.imageUploadManager.uploadImageWithLicense( + i.featureId, + i.author, + i.blob, + i.targetKey, + i.noblur, + asGeojson, + { + ignoreGps: i.ignoreGps, + noBackup: true,// Don't save this _again_ + overwriteGps: i.lastGpsLocation + } + ) + if (!uploadResult) { + // Upload failed again + return false + } + state.featureProperties.trackFeature(asGeojson) + const properties = state.featureProperties.getStore(i.featureId) + // Upload successful, time to link this to the image + const action = new LinkImageAction( + i.featureId, + uploadResult.key, + uploadResult.value, + properties, + { + theme: i.layoutId, + changeType: "add-image" + } + ) + + await state.changes.applyAction(action) + await state.changes.flushChanges("delayed image upload link") + this.delete(i) + return true + } finally { + this._isUploading.set(false) + } + } + + public async retryAll(state: WithImageState) { + for (const img of [...this._failedImages.data]) { + await this.retryUploading(state, img) + /*this._isUploading.setData(true) + await Utils.waitFor(2000) + this._isUploading.set(false)*/ + } + } +} diff --git a/src/Logic/ImageProviders/ImageUploadManager.ts b/src/Logic/ImageProviders/ImageUploadManager.ts index a58a3500fb..593bdf8a13 100644 --- a/src/Logic/ImageProviders/ImageUploadManager.ts +++ b/src/Logic/ImageProviders/ImageUploadManager.ts @@ -11,6 +11,7 @@ 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 @@ -85,7 +86,7 @@ export class ImageUploadManager { uploadFinished: this.getCounterFor(this._uploadFinished, featureId), retried: this.getCounterFor(this._uploadRetried, featureId), failed: this.getCounterFor(this._uploadFailed, featureId), - retrySuccess: this.getCounterFor(this._uploadRetriedSuccess, featureId), + retrySuccess: this.getCounterFor(this._uploadRetriedSuccess, featureId) } } @@ -94,10 +95,14 @@ 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 } @@ -148,13 +153,24 @@ export class ImageUploadManager { properties, { theme: tags?.data?.["_orig_theme"] ?? this._theme.id, - changeType: "add-image", + 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, @@ -162,14 +178,21 @@ export class ImageUploadManager { targetKey: string | undefined, noblur: boolean, feature: Feature, - ignoreGps: boolean = false - ): Promise { + 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 (this._gps.data && !ignoreGps) { + 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] } { @@ -210,13 +233,21 @@ export class ImageUploadManager { } 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, + targetKey }) ) return undefined diff --git a/src/Models/MenuState.ts b/src/Models/MenuState.ts index c4105fbde8..9a616f0157 100644 --- a/src/Models/MenuState.ts +++ b/src/Models/MenuState.ts @@ -26,6 +26,7 @@ export class MenuState { "about_theme", "download", "favourites", + "failedImages", "usersettings", "share", "menu", diff --git a/src/Models/ThemeViewState/WithImageState.ts b/src/Models/ThemeViewState/WithImageState.ts index 63843f07d7..f9d0f1dcfc 100644 --- a/src/Models/ThemeViewState/WithImageState.ts +++ b/src/Models/ThemeViewState/WithImageState.ts @@ -9,6 +9,7 @@ import ThemeViewStateHashActor from "../../Logic/Web/ThemeViewStateHashActor" import PendingChangesUploader from "../../Logic/Actors/PendingChangesUploader" import { WithGuiState } from "./WithGuiState" import { SpecialVisualizationState } from "../../UI/SpecialVisualization" +import EmergencyImageBackup from "../../Logic/ImageProviders/EmergencyImageBackup" export class WithImageState extends WithGuiState implements SpecialVisualizationState { readonly imageUploadManager: ImageUploadManager @@ -42,6 +43,10 @@ export class WithImageState extends WithGuiState implements SpecialVisualization this.selectCurrentView() } }) + + this.osmConnection.userDetails.addCallbackAndRunD(() => { + EmergencyImageBackup.singleton.retryAll(this) + }) } /** diff --git a/src/UI/BigComponents/MenuDrawer.svelte b/src/UI/BigComponents/MenuDrawer.svelte index dffece5f44..6ba902f226 100644 --- a/src/UI/BigComponents/MenuDrawer.svelte +++ b/src/UI/BigComponents/MenuDrawer.svelte @@ -61,6 +61,9 @@ import Hotkeys from "../Base/Hotkeys" import { ArrowTrendingUp } from "@babeard/svelte-heroicons/solid/ArrowTrendingUp" import ArrowTopRightOnSquare from "@babeard/svelte-heroicons/mini/ArrowTopRightOnSquare" + import FailedImagesView from "../Image/FailedImagesView.svelte" + import { PhotoIcon } from "@babeard/svelte-heroicons/outline" + import EmergencyImageBackup from "../../Logic/ImageProviders/EmergencyImageBackup" export let state: { favourites: FavouritesFeatureSource @@ -97,6 +100,8 @@ } }) let isAndroid = AndroidPolyfill.inAndroid + let nrOfFailedImages = EmergencyImageBackup.singleton.failedImages + let failedImagesOpen = pg.failedImages
+ {#if $nrOfFailedImages.length > 0 || $failedImagesOpen} + + + + + + + + {/if} + {#if state.favourites} diff --git a/src/UI/Image/FailedImage.svelte b/src/UI/Image/FailedImage.svelte new file mode 100644 index 0000000000..3e22320b89 --- /dev/null +++ b/src/UI/Image/FailedImage.svelte @@ -0,0 +1,88 @@ + + +
+ + + {failedImage.featureId} {failedImage.layoutId} + {#if $isUploading || _state === "retrying"} + + + + {:else if _state === "idle" || _state === "failed"} + + {#if _state === "failed"} + + {/if} + {:else if _state === "success"} +
+ +
+ {/if} + + + + + + + + +
+ +
+ +
+ +
+ confirmDelete.set(false)}> + + + +
+
+
+
+
diff --git a/src/UI/Image/FailedImagesView.svelte b/src/UI/Image/FailedImagesView.svelte new file mode 100644 index 0000000000..1a6d5a55c7 --- /dev/null +++ b/src/UI/Image/FailedImagesView.svelte @@ -0,0 +1,41 @@ + + +
+ {#if $failed.length === 0} + + {:else} +
+ +
+ + {#if $isUploading} + + {:else} + + {/if} +
+ {#each $failed as failedImage (failedImage.date + failedImage.featureId)} + + {/each} +
+ {/if} +
diff --git a/src/UI/Image/UploadImage.svelte b/src/UI/Image/UploadImage.svelte index 8e6a8c2848..30d85ca03c 100644 --- a/src/UI/Image/UploadImage.svelte +++ b/src/UI/Image/UploadImage.svelte @@ -58,7 +58,9 @@ "image", noBlur, feature, - ignoreGps + { + ignoreGps + } ) if (!uploadResult) { return