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

This commit is contained in:
Pieter Vander Vennet 2025-04-06 15:32:58 +02:00
parent 7380841205
commit 9f3d198068
9 changed files with 313 additions and 8 deletions

View file

@ -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"
},

View file

@ -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<FailedImageArgs[]>
public readonly failedImages: Store<FailedImageArgs[]>
private readonly _isUploading: UIEventSource<boolean> = new UIEventSource(false)
public readonly isUploading: Store<boolean> = this._isUploading
private constructor() {
this._failedImages = IdbLocalStorage.Get<FailedImageArgs[]>("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<boolean> {
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)*/
}
}
}

View file

@ -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<UploadResult> {
options?: {
ignoreGps?: boolean,
noBackup?: boolean,
overwriteGps?: GeolocationCoordinates
}
): Promise<UploadResult | undefined> {
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

View file

@ -26,6 +26,7 @@ export class MenuState {
"about_theme",
"download",
"favourites",
"failedImages",
"usersettings",
"share",
"menu",

View file

@ -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)
})
}
/**

View file

@ -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
</script>
<div
@ -156,6 +161,16 @@
/>
</Page>
{#if $nrOfFailedImages.length > 0 || $failedImagesOpen}
<Page {onlyLink} shown={pg.failedImages} bodyPadding="p-0 pb-4">
<svelte:fragment slot="header">
<PhotoIcon />
<Tr t={Translations.t.failedImages.menu.Subs({count: $nrOfFailedImages.length})} />
</svelte:fragment>
<FailedImagesView {state} />
</Page>
{/if}
<LoginToggle {state} silentFail>
{#if state.favourites}
<Page {onlyLink} shown={pg.favourites}>

View file

@ -0,0 +1,88 @@
<script lang="ts">
import type { FailedImageArgs } from "../../Logic/ImageProviders/EmergencyImageBackup"
import ThemeViewState from "../../Models/ThemeViewState"
import EmergencyImageBackup from "../../Logic/ImageProviders/EmergencyImageBackup"
import Loading from "../Base/Loading.svelte"
import { TrashIcon } from "@babeard/svelte-heroicons/mini"
import Popup from "../Base/Popup.svelte"
import { UIEventSource } from "../../Logic/UIEventSource"
import Page from "../Base/Page.svelte"
import BackButton from "../Base/BackButton.svelte"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
let emergencyBackup = EmergencyImageBackup.singleton
let isUploading = emergencyBackup.isUploading
export let state: ThemeViewState
let _state: "idle" | "retrying" | "failed" | "success" = "idle"
export let failedImage: FailedImageArgs
let confirmDelete = new UIEventSource(false)
async function retry() {
_state = "retrying"
const success = await emergencyBackup.retryUploading(state, failedImage)
if (success) {
_state = "success"
} else {
_state = "failed"
}
}
function del() {
emergencyBackup.delete(failedImage)
}
const t = Translations.t
</script>
<div class="low-interaction rounded border-interactive w-fit p-2 m-1 flex flex-col">
<img class="max-w-64 w-auto max-h-64 w-auto" src={URL.createObjectURL(failedImage.blob)} />
{failedImage.featureId} {failedImage.layoutId}
{#if $isUploading || _state === "retrying"}
<Loading>
<Tr t={t.image.upload.one.uploading} />
</Loading>
{:else if _state === "idle" || _state === "failed"}
<button on:click={() => retry()}>
<Tr t={t.failedImages.retry} />
</button>
{#if _state === "failed"}
<span class="alert"><Tr t={t.image.upload.one.failed} /></span>
{/if}
{:else if _state === "success"}
<div class="thanks">
<Tr t={t.image.upload.one.done} />
</div>
{/if}
<button class="as-link self-end" on:click={() => {confirmDelete.set(true)}}>
<TrashIcon class="w-4" />
<Tr t={t.failedImages.delete} />
</button>
<Popup shown={confirmDelete} dismissable={true}>
<Page shown={confirmDelete}>
<svelte:fragment slot="header">
<TrashIcon class="w-8 m-1" />
<Tr t={t.failedImages.confirmDeleteTitle} />
</svelte:fragment>
<div class="flex flex-col ">
<div class="flex justify-center">
<img class="max-w-128 w-auto max-h-128 w-auto" src={URL.createObjectURL(failedImage.blob)} />
</div>
<div class="flex w-full">
<BackButton clss="w-full" on:click={() => confirmDelete.set(false)}>
<Tr t={t.general.back} />
</BackButton>
<button on:click={() => del()} class="primary w-full">
<TrashIcon class="w-8 m-1" />
<Tr t={t.failedImages.confirmDelete} />
</button>
</div>
</div>
</Page>
</Popup>
</div>

View file

@ -0,0 +1,41 @@
<script lang="ts">
import EmergencyImageBackup from "../../Logic/ImageProviders/EmergencyImageBackup"
import ThemeViewState from "../../Models/ThemeViewState"
import FailedImage from "./FailedImage.svelte"
import { ArrowPathIcon } from "@babeard/svelte-heroicons/mini"
import Loading from "../Base/Loading.svelte"
import { WithImageState } from "../../Models/ThemeViewState/WithImageState"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
let emergencyBackup = EmergencyImageBackup.singleton
let failed = emergencyBackup.failedImages
export let state: WithImageState
let isUploading = emergencyBackup.isUploading
const t = Translations.t
</script>
<div class="m-4 flex flex-col">
{#if $failed.length === 0}
<Tr t={t.failedImages.noFailedImages} />
{:else}
<div>
<Tr t={t.failedImages.intro} />
</div>
{#if $isUploading}
<Loading />
{:else}
<button class="primary" on:click={() => emergencyBackup.retryAll(state)}>
<ArrowPathIcon class="w-8 h-8 m-1" />
<Tr t={t.failedImages.retryAll} />
</button>
{/if}
<div class="flex flex-wrap">
{#each $failed as failedImage (failedImage.date + failedImage.featureId)}
<FailedImage {failedImage} {state} />
{/each}
</div>
{/if}
</div>

View file

@ -58,7 +58,9 @@
"image",
noBlur,
feature,
ignoreGps
{
ignoreGps
}
)
if (!uploadResult) {
return