Feature: image upload now uses the indexedDB-backed queue (formerly known as EmergencyBackup), rework (and simplify) counter logic (fix #2186; fix #1942; helps #2022)

This commit is contained in:
Pieter Vander Vennet 2025-04-07 02:53:21 +02:00
parent 55c015ad84
commit 3d3a72a70a
19 changed files with 402 additions and 503 deletions

View file

@ -63,16 +63,6 @@
"overwrite": "Overwrite in OpenStreetMap", "overwrite": "Overwrite in OpenStreetMap",
"title": "Structured data loaded from the external website" "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": { "favourite": {
"reload": "Reload the data" "reload": "Reload the data"
}, },
@ -612,6 +602,15 @@
}, },
"uploadFailed": "Could not upload your picture. Are you connected to the Internet, and allow third party API's? The Brave browser or the uMatrix plugin might block them." "uploadFailed": "Could not upload your picture. Are you connected to the Internet, and allow third party API's? The Brave browser or the uMatrix plugin might block them."
}, },
"imageQueue": {
"confirmDelete": "Permanently delete this image",
"confirmDeleteTitle": "Delete this image?",
"delete": "Delete this image",
"intro": "The following images are queued for upload",
"menu": "Image upload queue ({count})",
"noFailedImages": "There are currently no images in the upload queue",
"retryAll": "Retry uploading all images"
},
"importInspector": { "importInspector": {
"title": "Inspect and manage import notes" "title": "Inspect and manage import notes"
}, },

View file

@ -426,7 +426,7 @@ class GenerateLayouts extends Script {
const csp: Record<string, string> = { const csp: Record<string, string> = {
"default-src": "'self'", "default-src": "'self'",
"child-src": "'self' blob: ", "child-src": "'self' blob: ",
"img-src": "* data:", // maplibre depends on 'data:' to load "img-src": "* data: blob:", // maplibre depends on 'data:' to load; 'blob:' is needed
"report-to": "https://report.mapcomplete.org/csp", "report-to": "https://report.mapcomplete.org/csp",
"worker-src": "'self' blob:", // Vite somehow loads the worker via a 'blob' "worker-src": "'self' blob:", // Vite somehow loads the worker via a 'blob'
"style-src": "'self' 'unsafe-inline'", // unsafe-inline is needed to change the default background pin colours "style-src": "'self' 'unsafe-inline'", // unsafe-inline is needed to change the default background pin colours

View file

@ -1,112 +0,0 @@
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

@ -1,35 +1,53 @@
import { ImageUploader, UploadResult } from "./ImageUploader" import { ImageUploader, UploadResult } from "./ImageUploader"
import LinkImageAction from "../Osm/Actions/LinkImageAction" import LinkImageAction from "../Osm/Actions/LinkImageAction"
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore" import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
import { OsmId, OsmTags } from "../../Models/OsmFeature" import { NoteId, OsmId, OsmTags } from "../../Models/OsmFeature"
import ThemeConfig from "../../Models/ThemeConfig/ThemeConfig" import ThemeConfig from "../../Models/ThemeConfig/ThemeConfig"
import { Store, UIEventSource } from "../UIEventSource" import { Store, UIEventSource } from "../UIEventSource"
import { OsmConnection } from "../Osm/OsmConnection" import { OsmConnection } from "../Osm/OsmConnection"
import { Changes } from "../Osm/Changes" import { Changes } from "../Osm/Changes"
import Translations from "../../UI/i18n/Translations" import Translations from "../../UI/i18n/Translations"
import { Translation } from "../../UI/i18n/Translation" import { Translation } from "../../UI/i18n/Translation"
import { IndexedFeatureSource } from "../FeatureSource/FeatureSource"
import { GeoOperations } from "../GeoOperations"
import { Feature } from "geojson" import { Feature } from "geojson"
import EmergencyImageBackup from "./EmergencyImageBackup" 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 * The ImageUploadManager has a
*/ */
export class ImageUploadManager { export class ImageUploadManager {
private readonly _queue: ImageUploadQueue = ImageUploadQueue.singleton
private readonly _uploader: ImageUploader private readonly _uploader: ImageUploader
private readonly _featureProperties: FeaturePropertiesStore private readonly _featureProperties: FeaturePropertiesStore
private readonly _theme: ThemeConfig private readonly _theme: ThemeConfig
private readonly _indexedFeatures: IndexedFeatureSource
private readonly _gps: Store<GeolocationCoordinates | undefined> private readonly _gps: Store<GeolocationCoordinates | undefined>
private readonly _uploadStarted: Map<string, UIEventSource<number>> = new Map()
private readonly _uploadFinished: Map<string, UIEventSource<number>> = new Map()
private readonly _uploadFailed: Map<string, UIEventSource<number>> = new Map()
private readonly _uploadRetried: Map<string, UIEventSource<number>> = new Map()
private readonly _uploadRetriedSuccess: Map<string, UIEventSource<number>> = new Map()
private readonly _osmConnection: OsmConnection private readonly _osmConnection: OsmConnection
private readonly _changes: Changes private readonly _changes: Changes
public readonly isUploading: Store<boolean>
/**
* 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<string[]> = new UIEventSource<string[]>([])
public readonly successfull: Store<string[]> = 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<ImageUploadArguments[]> = new UIEventSource<ImageUploadArguments[]>([])
public readonly fails: Store<string[]> = this._fails.map(args => args.map(a => a.featureId))
/**
* FeatureIDs of queued items
*/
public readonly queued: Store<string[]> = 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<string | undefined> = new UIEventSource(undefined)
public readonly isUploading: Store<string | undefined> = this._isUploading
private readonly _reportError: ( private readonly _reportError: (
message: string | Error | XMLHttpRequest, message: string | Error | XMLHttpRequest,
extramessage?: string extramessage?: string
@ -42,7 +60,6 @@ export class ImageUploadManager {
osmConnection: OsmConnection, osmConnection: OsmConnection,
changes: Changes, changes: Changes,
gpsLocation: Store<GeolocationCoordinates | undefined>, gpsLocation: Store<GeolocationCoordinates | undefined>,
allFeatures: IndexedFeatureSource,
reportError: ( reportError: (
message: string | Error | XMLHttpRequest, message: string | Error | XMLHttpRequest,
extramessage?: string extramessage?: string
@ -53,41 +70,8 @@ export class ImageUploadManager {
this._theme = layout this._theme = layout
this._osmConnection = osmConnection this._osmConnection = osmConnection
this._changes = changes this._changes = changes
this._indexedFeatures = allFeatures
this._gps = gpsLocation this._gps = gpsLocation
this._reportError = reportError this._reportError = reportError
const failed = this.getCounterFor(this._uploadFailed, "*")
const done = this.getCounterFor(this._uploadFinished, "*")
this.isUploading = this.getCounterFor(this._uploadStarted, "*").map(
(startedCount) => {
return startedCount > failed.data + done.data
},
[failed, done]
)
}
/**
* Gets various counters.
* Note that counters can only increase
* If a retry was a success, both 'retrySuccess' _and_ 'uploadFinished' will be increased
* @param featureId the id of the feature you want information for. '*' has a global counter
*/
public getCountsFor(featureId: string | "*"): {
retried: Store<number>
uploadStarted: Store<number>
retrySuccess: Store<number>
failed: Store<number>
uploadFinished: Store<number>
} {
return {
uploadStarted: this.getCounterFor(this._uploadStarted, featureId),
uploadFinished: this.getCounterFor(this._uploadFinished, featureId),
retried: this.getCounterFor(this._uploadRetried, featureId),
failed: this.getCounterFor(this._uploadFailed, featureId),
retrySuccess: this.getCounterFor(this._uploadRetriedSuccess, featureId)
}
} }
public canBeUploaded(file: File): true | { error: Translation } { public canBeUploaded(file: File): true | { error: Translation } {
@ -108,110 +92,187 @@ export class ImageUploadManager {
/** /**
* Uploads the given image, applies the correct title and license for the known user. * 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 * 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 file a jpg file to upload
* @param tagsStore The tags of the feature * @param tagsStore The tags of the feature
* @param targetKey Use this key to save the attribute under. Default: 'image' * @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 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 * @param feature the feature this image is about. Will be used as fallback to get the GPS-coordinates
*/ */
public async uploadImageAndApply( public uploadImageAndApply(
file: File, file: File,
tagsStore: UIEventSource<OsmTags>, tagsStore: UIEventSource<OsmTags>,
targetKey: string, targetKey: string,
noblur: boolean, noblur: boolean,
feature: Feature feature: Feature,
): Promise<void> { options: {
ignoreGPS: boolean | false
}
): void {
const canBeUploaded = this.canBeUploaded(file) const canBeUploaded = this.canBeUploaded(file)
if (canBeUploaded !== true) { if (canBeUploaded !== true) {
throw canBeUploaded.error throw canBeUploaded.error
} }
const tags = tagsStore.data const tags: OsmTags = tagsStore.data
const featureId = <OsmId | NoteId>tags.id
const featureId = <OsmId>tags.id
const author = this._osmConnection.userDetails.data.name const author = this._osmConnection?.userDetails?.data?.name ?? "Anonymous" // Might be a note upload
const uploadResult = await this.uploadImageWithLicense( /**
featureId, * The location to upload the image with.
author, * Note that EXIF-data will always be trusted _more_ by the uploader
file, */
targetKey, let location: [number, number] = GeoOperations.centerpointCoordinates(feature)
noblur, if (this._gps.data && !options?.ignoreGPS) {
feature location = [this._gps.data.longitude, this._gps.data.latitude]
)
if (!uploadResult) {
return
} }
const properties = this._featureProperties.getStore(featureId)
const action = new LinkImageAction( const args: ImageUploadArguments = {
featureId, location,
uploadResult.key, date: new Date().getTime(),
uploadResult.value, layoutId: this._theme.id,
properties, author, blob: file, featureId, noblur, targetKey
{ }
theme: tags?.data?.["_orig_theme"] ?? this._theme.id, console.log("Args are", args)
changeType: "add-image"
} this._queue.add(args)
) this.uploadQueue()
await this._changes.applyAction(action)
} }
/** /**
* Uploads an image; returns undefined if the image upload failed. * Attempts to upload all items in the queue
* Errors are handled internally */
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<void> {
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<Record<string, string>> = this._featureProperties.getStore(args.featureId)
if (args.featureId.startsWith("note/")) {
// This is an OSM-note
const url = result.absoluteUrl
await this._osmConnection.addCommentToNote(args.featureId, url)
const properties: UIEventSource<Record<string, string>> = 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
}
properties = new UIEventSource(downloaded.tags)
}
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 featureId
* @param author * @param author
* @param blob * @param blob
* @param targetKey * @param targetKey
* @param noblur * @param noblur
* @param feature * @param lastGpsLocation
* @param options * @param ignoreGps
* @param layoutId
* @param date
* @param reportOnFail If set, reports an error to the mapcomplete server so that pietervdvn can fix it
* @private
*/ */
public async uploadImageWithLicense( private async attemptSingleUpload(
featureId: string, {
author: string, featureId,
blob: File, author,
targetKey: string | undefined, blob,
noblur: boolean, targetKey,
feature: Feature, noblur,
options?: { location
ignoreGps?: boolean, }: ImageUploadArguments,
noBackup?: boolean, reportOnFail: boolean
overwriteGps?: GeolocationCoordinates
}
): Promise<UploadResult | undefined> { ): Promise<UploadResult | undefined> {
this.increaseCountFor(this._uploadStarted, featureId)
let key: string let key: string
let value: string let value: string
let absoluteUrl: string let absoluteUrl: string
let location: [number, number] = undefined
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]
}
{
feature ??= this._indexedFeatures.featuresById.data.get(featureId)
if (feature === undefined) {
throw "ImageUploadManager: no feature given and no feature found in the indexedFeature. Cannot upload this image"
}
const featureCenterpoint = GeoOperations.centerpointCoordinates(feature)
if (
location === undefined ||
location?.some((l) => l === undefined) ||
GeoOperations.distanceBetween(location, featureCenterpoint) > 150
) {
/* GPS location is either unknown or very far away from the photographed location.
* Default to the centerpoint
*/
location = featureCenterpoint
}
}
try { try {
({ key, value, absoluteUrl } = await this._uploader.uploadImage( ({ key, value, absoluteUrl } = await this._uploader.uploadImage(
blob, blob,
@ -220,27 +281,9 @@ export class ImageUploadManager {
noblur noblur
)) ))
} catch (e) { } catch (e) {
this.increaseCountFor(this._uploadRetried, featureId) console.error("Could again not upload image due to", e)
console.error("Could not upload image, trying again:", e) if (reportOnFail) {
try {
({ key, value, absoluteUrl } = await this._uploader.uploadImage(
blob,
location,
author,
noblur
))
this.increaseCountFor(this._uploadRetriedSuccess, featureId)
} 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( await this._reportError(
e, e,
JSON.stringify({ JSON.stringify({
@ -250,37 +293,15 @@ export class ImageUploadManager {
targetKey targetKey
}) })
) )
return undefined
} }
return undefined
} }
console.log("Uploading image done, creating action for", featureId, " targetkey is", targetKey, "key is", key)
key = targetKey ?? key key = targetKey ?? key
if (targetKey && targetKey.indexOf(key) < 0) { if (targetKey && targetKey.indexOf(key) < 0) {
// This is a non-standard key, so we use the image link directly // This is a non-standard key, so we use the image link directly
value = absoluteUrl value = absoluteUrl
} }
this.increaseCountFor(this._uploadFinished, featureId)
return { key, absoluteUrl, value } return { key, absoluteUrl, value }
} }
private getCounterFor(collection: Map<string, UIEventSource<number>>, key: string | "*") {
if (this._featureProperties.aliases.has(key)) {
key = this._featureProperties.aliases.get(key)
}
if (!collection.has(key)) {
collection.set(key, new UIEventSource<number>(0))
}
return collection.get(key)
}
private increaseCountFor(collection: Map<string, UIEventSource<number>>, key: string | "*") {
{
const counter = this.getCounterFor(collection, key)
counter.setData(counter.data + 1)
}
{
const global = this.getCounterFor(collection, "*")
global.setData(global.data + 1)
}
}
} }

View file

@ -0,0 +1,46 @@
import { IdbLocalStorage } from "../Web/IdbLocalStorage"
import { Store, UIEventSource } from "../UIEventSource"
export interface ImageUploadArguments {
readonly featureId: string,
readonly author: string,
readonly blob: File,
readonly targetKey: string | undefined,
readonly noblur: boolean,
readonly location: [number, number],
readonly layoutId: string
readonly date: number
}
/**
* The 'imageUploadQueue' keeps track of all images that should still be uploaded.
* It is backed up in the indexedDB as to not drop images in case of connection problems
*/
export default class ImageUploadQueue {
public static readonly singleton = new ImageUploadQueue()
private readonly _imagesInQueue: UIEventSource<ImageUploadArguments[]>
public readonly imagesInQueue: Store<ImageUploadArguments[]>
private constructor() {
this._imagesInQueue = IdbLocalStorage.Get<ImageUploadArguments[]>("failed-images-backup", { defaultValue: [] })
this.imagesInQueue = this._imagesInQueue
}
public add(args: ImageUploadArguments) {
this._imagesInQueue.data.push(args)
console.log("Got args", args)
this._imagesInQueue.ping()
}
public delete(img: ImageUploadArguments) {
const index = this._imagesInQueue.data.indexOf(img)
if (index < 0) {
return
}
this._imagesInQueue.data.splice(index, 1)
this._imagesInQueue.ping()
}
}

View file

@ -263,7 +263,7 @@ export class PanoramaxUploader implements ImageUploader {
} }
console.log("Tags are", tags) console.log("Tags are", tags)
} catch (e) { } catch (e) {
console.error("Could not read EXIF-tags") console.warn("Could not read EXIF-tags")
} }
const p = this.panoramax const p = this.panoramax

View file

@ -19,9 +19,8 @@ export abstract class OsmObject {
public changed: boolean = false public changed: boolean = false
timestamp: Date timestamp: Date
protected constructor(type: string, id: number) { protected constructor(type: "node" | "way" | "relation", id: number) {
this.id = id this.id = id
// @ts-ignore
this.type = type this.type = type
this.tags = { this.tags = {
id: `${this.type}/${id}`, id: `${this.type}/${id}`,

View file

@ -90,7 +90,6 @@ export class OptionallySyncedHistory<T extends object | string> {
this.syncedBackingStore = Stores.fromArray( this.syncedBackingStore = Stores.fromArray(
Utils.TimesT(maxHistory, (i) => { Utils.TimesT(maxHistory, (i) => {
const pref = osmconnection.getPreference(key + "-hist-" + i + "-") const pref = osmconnection.getPreference(key + "-hist-" + i + "-")
pref.addCallbackAndRun(v => console.trace(">>> pref", pref.tag, " is now ", v))
return UIEventSource.asObject<T>(pref, undefined) return UIEventSource.asObject<T>(pref, undefined)
})) }))

View file

@ -3,6 +3,8 @@ import { Feature, Geometry } from "geojson"
export type RelationId = `relation/${number}` export type RelationId = `relation/${number}`
export type WayId = `way/${number}` export type WayId = `way/${number}`
export type NodeId = `node/${number}` export type NodeId = `node/${number}`
export type NoteId = `node/${number}`
export type OsmId = NodeId | WayId | RelationId export type OsmId = NodeId | WayId | RelationId
export type OsmTags = Record<string, string> & { id: string } export type OsmTags = Record<string, string> & { id: string }

View file

@ -9,7 +9,6 @@ import ThemeViewStateHashActor from "../../Logic/Web/ThemeViewStateHashActor"
import PendingChangesUploader from "../../Logic/Actors/PendingChangesUploader" import PendingChangesUploader from "../../Logic/Actors/PendingChangesUploader"
import { WithGuiState } from "./WithGuiState" import { WithGuiState } from "./WithGuiState"
import { SpecialVisualizationState } from "../../UI/SpecialVisualization" import { SpecialVisualizationState } from "../../UI/SpecialVisualization"
import EmergencyImageBackup from "../../Logic/ImageProviders/EmergencyImageBackup"
export class WithImageState extends WithGuiState implements SpecialVisualizationState { export class WithImageState extends WithGuiState implements SpecialVisualizationState {
readonly imageUploadManager: ImageUploadManager readonly imageUploadManager: ImageUploadManager
@ -30,7 +29,6 @@ export class WithImageState extends WithGuiState implements SpecialVisualization
this.osmConnection, this.osmConnection,
this.changes, this.changes,
this.geolocation.geolocationState.currentGPSLocation, this.geolocation.geolocationState.currentGPSLocation,
this.indexedFeatures,
this.reportError this.reportError
) )
const longAgo = new Date() const longAgo = new Date()
@ -45,7 +43,7 @@ export class WithImageState extends WithGuiState implements SpecialVisualization
}) })
this.osmConnection.userDetails.addCallbackAndRunD(() => { this.osmConnection.userDetails.addCallbackAndRunD(() => {
EmergencyImageBackup.singleton.retryAll(this) this.imageUploadManager.uploadQueue()
}) })
} }
@ -57,7 +55,7 @@ export class WithImageState extends WithGuiState implements SpecialVisualization
featureSwitches: this.featureSwitches, featureSwitches: this.featureSwitches,
selectedElement: this.selectedElement, selectedElement: this.selectedElement,
indexedFeatures: this.indexedFeatures, indexedFeatures: this.indexedFeatures,
guistate: this.guistate, guistate: this.guistate
}) })
new PendingChangesUploader(this.changes, this.selectedElement, this.imageUploadManager) new PendingChangesUploader(this.changes, this.selectedElement, this.imageUploadManager)
} }

View file

@ -61,9 +61,9 @@
import Hotkeys from "../Base/Hotkeys" import Hotkeys from "../Base/Hotkeys"
import { ArrowTrendingUp } from "@babeard/svelte-heroicons/solid/ArrowTrendingUp" import { ArrowTrendingUp } from "@babeard/svelte-heroicons/solid/ArrowTrendingUp"
import ArrowTopRightOnSquare from "@babeard/svelte-heroicons/mini/ArrowTopRightOnSquare" import ArrowTopRightOnSquare from "@babeard/svelte-heroicons/mini/ArrowTopRightOnSquare"
import FailedImagesView from "../Image/FailedImagesView.svelte"
import { PhotoIcon } from "@babeard/svelte-heroicons/outline" import { PhotoIcon } from "@babeard/svelte-heroicons/outline"
import EmergencyImageBackup from "../../Logic/ImageProviders/EmergencyImageBackup" import ImageUploadQueue from "../../Logic/ImageProviders/ImageUploadQueue"
import QueuedImagesView from "../Image/QueuedImagesView.svelte"
export let state: { export let state: {
favourites: FavouritesFeatureSource favourites: FavouritesFeatureSource
@ -100,7 +100,7 @@
} }
}) })
let isAndroid = AndroidPolyfill.inAndroid let isAndroid = AndroidPolyfill.inAndroid
let nrOfFailedImages = EmergencyImageBackup.singleton.failedImages let nrOfFailedImages = ImageUploadQueue.singleton.imagesInQueue
let failedImagesOpen = pg.failedImages let failedImagesOpen = pg.failedImages
</script> </script>
@ -165,9 +165,9 @@
<Page {onlyLink} shown={pg.failedImages} bodyPadding="p-0 pb-4"> <Page {onlyLink} shown={pg.failedImages} bodyPadding="p-0 pb-4">
<svelte:fragment slot="header"> <svelte:fragment slot="header">
<PhotoIcon /> <PhotoIcon />
<Tr t={Translations.t.failedImages.menu.Subs({count: $nrOfFailedImages.length})} /> <Tr t={Translations.t.imageQueue.menu.Subs({count: $nrOfFailedImages.length})} />
</svelte:fragment> </svelte:fragment>
<FailedImagesView {state} /> <QueuedImagesView {state} />
</Page> </Page>
{/if} {/if}

View file

@ -1,88 +0,0 @@
<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

@ -1,41 +0,0 @@
<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

@ -0,0 +1,65 @@
<script lang="ts">
import type { ImageUploadArguments } from "../../Logic/ImageProviders/ImageUploadQueue"
import ImageUploadQueue from "../../Logic/ImageProviders/ImageUploadQueue"
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 queue = ImageUploadQueue.singleton
export let imageArguments: ImageUploadArguments
let confirmDelete = new UIEventSource(false)
function del() {
queue.delete(imageArguments)
}
const t = Translations.t
let src = undefined
try{
src = URL.createObjectURL(imageArguments.blob)
}catch (e) {
console.error("Could not create an ObjectURL for blob", imageArguments.blob)
}
</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} />
{imageArguments.featureId} {imageArguments.layoutId}
<button class="as-link self-end" on:click={() => {confirmDelete.set(true)}}>
<TrashIcon class="w-4" />
<Tr t={t.imageQueue.delete} />
</button>
<Popup shown={confirmDelete} dismissable={true}>
<Page shown={confirmDelete}>
<svelte:fragment slot="header">
<TrashIcon class="w-8 m-1" />
<Tr t={t.imageQueue.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(imageArguments.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.imageQueue.confirmDelete} />
</button>
</div>
</div>
</Page>
</Popup>
</div>

View file

@ -0,0 +1,42 @@
<script lang="ts">
import QueuedImage from "./QueuedImage.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"
import type { ImageUploadArguments } from "../../Logic/ImageProviders/ImageUploadQueue"
import { Store } from "../../Logic/UIEventSource"
import UploadingImageCounter from "./UploadingImageCounter.svelte"
export let state: WithImageState
let queued: Store<ImageUploadArguments[]> = state.imageUploadManager.queuedArgs
let isUploading = state.imageUploadManager.isUploading
const t = Translations.t
const q = t.imageQueue
</script>
<div class="m-4 flex flex-col">
{#if $queued.length === 0}
<Tr t={q.noFailedImages} />
{:else}
<div>
<Tr t={q.intro} />
</div>
<UploadingImageCounter {state}/>
{#if $isUploading}
<Loading />
{:else}
<button class="primary" on:click={() => state.imageUploadManager.uploadQueue()}>
<ArrowPathIcon class="w-8 h-8 m-1" />
<Tr t={q.retryAll} />
</button>
{/if}
<div class="flex flex-wrap">
{#each $queued as i (i.date + i.featureId)}
<QueuedImage imageArguments={i} />
{/each}
</div>
{/if}
</div>

View file

@ -14,7 +14,6 @@
import LoginButton from "../Base/LoginButton.svelte" import LoginButton from "../Base/LoginButton.svelte"
import { Translation } from "../i18n/Translation" import { Translation } from "../i18n/Translation"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig" import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import NoteCommentElement from "../Popup/Notes/NoteCommentElement"
import type { Feature } from "geojson" import type { Feature } from "geojson"
import Camera from "@babeard/svelte-heroicons/mini/Camera" import Camera from "@babeard/svelte-heroicons/mini/Camera"
@ -38,7 +37,7 @@
let errors = new UIEventSource<Translation[]>([]) let errors = new UIEventSource<Translation[]>([])
async function handleFiles(files: FileList, ignoreGps: boolean = false) { async function handleFiles(files: FileList, ignoreGPS: boolean = false) {
const errs = [] const errs = []
for (let i = 0; i < files.length; i++) { for (let i = 0; i < files.length; i++) {
const file = files.item(i) const file = files.item(i)
@ -49,31 +48,7 @@
errs.push(canBeUploaded.error) errs.push(canBeUploaded.error)
continue continue
} }
await state?.imageUploadManager?.uploadImageAndApply(file, tags, targetKey, noBlur, feature, { ignoreGPS })
if (layer?.id === "note") {
const uploadResult = await state?.imageUploadManager.uploadImageWithLicense(
tags.data.id,
state.osmConnection.userDetails.data?.name ?? "Anonymous",
file,
"image",
noBlur,
feature,
{
ignoreGps
}
)
if (!uploadResult) {
return
}
const url = uploadResult.absoluteUrl
await state.osmConnection.addCommentToNote(tags.data.id, url)
NoteCommentElement.addCommentTo(url, <UIEventSource<OsmTags>>tags, {
osmConnection: state.osmConnection,
})
return
}
await state?.imageUploadManager?.uploadImageAndApply(file, tags, targetKey, noBlur, feature)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
state.reportError(e, "Could not upload image") state.reportError(e, "Could not upload image")
@ -100,7 +75,7 @@
<Tr t={error} cls="alert" /> <Tr t={error} cls="alert" />
{/each} {/each}
<FileSelector <FileSelector
accept="image/*" accept=".jpg,.jpeg,image/jpeg"
capture="environment" capture="environment"
cls="button border-2 flex flex-col" cls="button border-2 flex flex-col"
multiple={true} multiple={true}

View file

@ -7,76 +7,69 @@
import type { SpecialVisualizationState } from "../SpecialVisualization" import type { SpecialVisualizationState } from "../SpecialVisualization"
import { Store } from "../../Logic/UIEventSource" import { Store } from "../../Logic/UIEventSource"
import type { OsmTags } from "../../Models/OsmFeature" import type { NoteId, OsmTags, OsmId } from "../../Models/OsmFeature"
import Translations from "../i18n/Translations" import Translations from "../i18n/Translations"
import Tr from "../Base/Tr.svelte" import Tr from "../Base/Tr.svelte"
import Loading from "../Base/Loading.svelte" import Loading from "../Base/Loading.svelte"
import { XCircleIcon } from "@babeard/svelte-heroicons/solid"
import UploadFailedMessage from "./UploadFailedMessage.svelte" import UploadFailedMessage from "./UploadFailedMessage.svelte"
export let state: SpecialVisualizationState export let state: SpecialVisualizationState
export let tags: Store<OsmTags> = undefined export let tags: Store<OsmTags> = undefined
export let featureId = tags?.data?.id export let featureId: OsmId | NoteId | "*" = tags?.data?.id ?? "*"
if (featureId === undefined) { if (featureId === undefined) {
throw "No tags or featureID given" throw "No tags or featureID given"
} }
export let showThankYou: boolean = true export let showThankYou: boolean = true
const { uploadStarted, uploadFinished, retried, failed } =
state.imageUploadManager.getCountsFor(featureId) /*
Number of images uploaded succesfully
*/
function getCount(input: Store<string[]>): Store<number> {
if (featureId == "*") {
return input.map(inp => inp.length)
}
return input.map(success => success.filter(item => item === featureId).length)
}
let successfull = getCount(state.imageUploadManager.successfull)
/* Number of failed uploads */
let failed = getCount(state.imageUploadManager.fails)
let pending = getCount(state.imageUploadManager.queued)
const t = Translations.t.image const t = Translations.t.image
const debugging = state.featureSwitches.featureSwitchIsDebugging const debugging = state.featureSwitches.featureSwitchIsDebugging
let dismissed = 0 let dismissed = 0
failed.addCallbackAndRun(failed => {
dismissed = Math.min(failed, dismissed)
})
</script> </script>
{#if $debugging} {#if $debugging}
<div class="low-interaction"> <div class="low-interaction">
Started {$uploadStarted} Done {$uploadFinished} Retry {$retried} Err {$failed} Pending {$pending} Done {$successfull} Err {$failed}
</div> </div>
{/if} {/if}
{#if dismissed === $uploadStarted}
<!-- We don't show anything as we ignore this number of failed items--> {#if $pending - $failed > 0}
{:else if $uploadStarted === 1} <div class="alert">
{#if $uploadFinished === 1} <Loading>
{#if showThankYou} {#if $pending - $failed === 1}
<Tr cls="thanks" t={t.upload.one.done} />
{/if}
{:else if $failed === 1}
<UploadFailedMessage failed={$failed} on:click={() => (dismissed = $failed)} />
{:else if $retried === 1}
<div class="alert">
<Loading>
<Tr t={t.upload.one.retrying} />
</Loading>
</div>
{:else}
<div class="alert">
<Loading>
<Tr t={t.upload.one.uploading} /> <Tr t={t.upload.one.uploading} />
</Loading> {:else if $pending - $failed > 1}
</div> <Tr t={t.upload.multiple.uploading.Subs({count: $pending})} />
{/if} {/if}
{:else if $uploadStarted > 1}
{#if $uploadFinished + $failed === $uploadStarted}
{#if $uploadFinished === 0}
<!-- pass -->
{:else if showThankYou}
<Tr cls="thanks" t={t.upload.multiple.done.Subs({ count: $uploadFinished })} />
{/if}
{:else if $uploadFinished === 0}
<Loading cls="alert">
<Tr t={t.upload.multiple.uploading.Subs({ count: $uploadStarted })} />
</Loading> </Loading>
{:else if $uploadFinished > 0} </div>
<Loading cls="alert"> {/if}
<Tr
t={t.upload.multiple.partiallyDone.Subs({ {#if $failed > dismissed}
count: $uploadStarted - $uploadFinished, <UploadFailedMessage failed={$failed} on:click={() => (dismissed = $failed)} />
done: $uploadFinished, {/if}
})}
/> {#if showThankYou}
</Loading> {#if $successfull === 1}
{/if} <Tr cls="thanks" t={t.upload.one.done} />
{#if $failed > 0} {:else if $successfull > 1}
<UploadFailedMessage failed={$failed} on:click={() => (dismissed = $failed)} /> <Tr cls="thanks" t={t.upload.multiple.done.Subs({count: $successfull})} />
{/if} {/if}
{/if} {/if}

View file

@ -9,7 +9,7 @@ export default class NoteCommentElement {
*/ */
public static addCommentTo( public static addCommentTo(
txt: string, txt: string,
tags: UIEventSource<any>, tags: UIEventSource<Record<string, string>>,
state: { osmConnection: { userDetails: Store<{ name: string; uid: number }> } } state: { osmConnection: { userDetails: Store<{ name: string; uid: number }> } }
) { ) {
const comments: any[] = JSON.parse(tags.data["comments"]) const comments: any[] = JSON.parse(tags.data["comments"])

View file

@ -11,11 +11,12 @@
import { TrashIcon } from "@babeard/svelte-heroicons/mini" import { TrashIcon } from "@babeard/svelte-heroicons/mini"
import { CogIcon } from "@rgossiaux/svelte-heroicons/solid" import { CogIcon } from "@rgossiaux/svelte-heroicons/solid"
import Tr from "../Base/Tr.svelte" import Tr from "../Base/Tr.svelte"
import { Utils } from "../../Utils.ts"
export let state: SpecialVisualizationState export let state: SpecialVisualizationState
let searchTerm = state.searchState.searchTerm let searchTerm = state.searchState.searchTerm
let recentThemes = state.userRelatedState.recentlyVisitedThemes.value.map((themes) => let recentThemes = state.userRelatedState.recentlyVisitedThemes.value.map((themes) =>
themes.filter((th) => th !== state.theme.id).slice(0, 6) Utils.Dedup(themes.filter((th) => th !== state.theme.id).slice(0, 6))
) )
let themeResults = state.searchState.themeSuggestions let themeResults = state.searchState.themeSuggestions