forked from MapComplete/MapComplete
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:
parent
55c015ad84
commit
3d3a72a70a
19 changed files with 402 additions and 503 deletions
|
@ -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"
|
||||||
},
|
},
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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)*/
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
46
src/Logic/ImageProviders/ImageUploadQueue.ts
Normal file
46
src/Logic/ImageProviders/ImageUploadQueue.ts
Normal 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()
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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}`,
|
||||||
|
|
|
@ -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)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
||||||
|
|
|
@ -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>
|
|
|
@ -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>
|
|
65
src/UI/Image/QueuedImage.svelte
Normal file
65
src/UI/Image/QueuedImage.svelte
Normal 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>
|
42
src/UI/Image/QueuedImagesView.svelte
Normal file
42
src/UI/Image/QueuedImagesView.svelte
Normal 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>
|
|
@ -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}
|
||||||
|
|
|
@ -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}
|
||||||
|
|
|
@ -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"])
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue