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 | ||||
|             } | ||||
|           ) | ||||
|           if (!uploadResult) { | ||||
|             return | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue