forked from MapComplete/MapComplete
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:
parent
7380841205
commit
9f3d198068
9 changed files with 313 additions and 8 deletions
|
@ -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"
|
||||
},
|
||||
|
|
112
src/Logic/ImageProviders/EmergencyImageBackup.ts
Normal file
112
src/Logic/ImageProviders/EmergencyImageBackup.ts
Normal 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)*/
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -26,6 +26,7 @@ export class MenuState {
|
|||
"about_theme",
|
||||
"download",
|
||||
"favourites",
|
||||
"failedImages",
|
||||
"usersettings",
|
||||
"share",
|
||||
"menu",
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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}>
|
||||
|
|
88
src/UI/Image/FailedImage.svelte
Normal file
88
src/UI/Image/FailedImage.svelte
Normal 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>
|
41
src/UI/Image/FailedImagesView.svelte
Normal file
41
src/UI/Image/FailedImagesView.svelte
Normal 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>
|
|
@ -58,7 +58,9 @@
|
|||
"image",
|
||||
noBlur,
|
||||
feature,
|
||||
ignoreGps
|
||||
{
|
||||
ignoreGps
|
||||
}
|
||||
)
|
||||
if (!uploadResult) {
|
||||
return
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue