forked from MapComplete/MapComplete
309 lines
12 KiB
TypeScript
309 lines
12 KiB
TypeScript
import { ImageUploader, UploadResult } from "./ImageUploader"
|
|
import LinkImageAction from "../Osm/Actions/LinkImageAction"
|
|
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
|
|
import { NoteId, OsmId, OsmTags } from "../../Models/OsmFeature"
|
|
import ThemeConfig from "../../Models/ThemeConfig/ThemeConfig"
|
|
import { Store, UIEventSource } from "../UIEventSource"
|
|
import { OsmConnection } from "../Osm/OsmConnection"
|
|
import { Changes } from "../Osm/Changes"
|
|
import Translations from "../../UI/i18n/Translations"
|
|
import { Translation } from "../../UI/i18n/Translation"
|
|
import { Feature } from "geojson"
|
|
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
|
|
*/
|
|
export class ImageUploadManager {
|
|
private readonly _queue: ImageUploadQueue = ImageUploadQueue.singleton
|
|
private readonly _uploader: ImageUploader
|
|
private readonly _featureProperties: FeaturePropertiesStore
|
|
private readonly _theme: ThemeConfig
|
|
private readonly _gps: Store<GeolocationCoordinates | undefined>
|
|
private readonly _osmConnection: OsmConnection
|
|
private readonly _changes: Changes
|
|
|
|
/**
|
|
* 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: (
|
|
message: string | Error | XMLHttpRequest,
|
|
extramessage?: string
|
|
) => Promise<void>
|
|
|
|
constructor(
|
|
layout: ThemeConfig,
|
|
uploader: ImageUploader,
|
|
featureProperties: FeaturePropertiesStore,
|
|
osmConnection: OsmConnection,
|
|
changes: Changes,
|
|
gpsLocation: Store<GeolocationCoordinates | undefined>,
|
|
reportError: (
|
|
message: string | Error | XMLHttpRequest,
|
|
extramessage?: string
|
|
) => Promise<void>
|
|
) {
|
|
this._uploader = uploader
|
|
this._featureProperties = featureProperties
|
|
this._theme = layout
|
|
this._osmConnection = osmConnection
|
|
this._changes = changes
|
|
this._gps = gpsLocation
|
|
this._reportError = reportError
|
|
}
|
|
|
|
public canBeUploaded(file: File): true | { error: Translation } {
|
|
const sizeInBytes = file.size
|
|
if (sizeInBytes > this._uploader.maxFileSizeInMegabytes * 1000000) {
|
|
const error = Translations.t.image.toBig.Subs({
|
|
actual_size: Math.floor(sizeInBytes / 1000000) + "MB",
|
|
max_size: this._uploader.maxFileSizeInMegabytes + "MB",
|
|
})
|
|
return { error }
|
|
}
|
|
const ext = file.name.split(".").at(-1).toLowerCase()
|
|
if (ext !== "jpg" && ext !== "jpeg") {
|
|
return { error: new Translation({ en: "Only JPG-files are allowed" }) }
|
|
}
|
|
return true
|
|
}
|
|
|
|
/**
|
|
* 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 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 tagsStore The tags of the feature
|
|
* @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 feature the feature this image is about. Will be used as fallback to get the GPS-coordinates
|
|
*/
|
|
public uploadImageAndApply(
|
|
file: File,
|
|
tagsStore: UIEventSource<OsmTags>,
|
|
targetKey: string,
|
|
noblur: boolean,
|
|
feature: Feature,
|
|
options: {
|
|
ignoreGPS: boolean | false
|
|
}
|
|
): void {
|
|
const canBeUploaded = this.canBeUploaded(file)
|
|
if (canBeUploaded !== true) {
|
|
throw canBeUploaded.error
|
|
}
|
|
|
|
const tags: OsmTags = tagsStore.data
|
|
const featureId = <OsmId | NoteId>tags.id
|
|
|
|
const author = this._osmConnection?.userDetails?.data?.name ?? "Anonymous" // Might be a note upload
|
|
|
|
/**
|
|
* The location to upload the image with.
|
|
* Note that EXIF-data will always be trusted _more_ by the uploader
|
|
*/
|
|
let location: [number, number] = GeoOperations.centerpointCoordinates(feature)
|
|
if (this._gps.data && !options?.ignoreGPS) {
|
|
location = [this._gps.data.longitude, this._gps.data.latitude]
|
|
}
|
|
|
|
const args: ImageUploadArguments = {
|
|
location,
|
|
date: new Date().getTime(),
|
|
layoutId: this._theme.id,
|
|
author,
|
|
blob: file,
|
|
featureId,
|
|
noblur,
|
|
targetKey,
|
|
}
|
|
console.log("Args are", args)
|
|
|
|
this._queue.add(args)
|
|
this.uploadQueue()
|
|
}
|
|
|
|
/**
|
|
* Attempts to upload all items in the queue
|
|
*/
|
|
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 (!isNaN(Number(args.featureId))) {
|
|
// 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
|
|
}
|
|
this._featureProperties.trackFeature(downloaded.asGeoJson())
|
|
properties = this._featureProperties.getStore(args.featureId)
|
|
}
|
|
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 author
|
|
* @param blob
|
|
* @param targetKey
|
|
* @param noblur
|
|
* @param lastGpsLocation
|
|
* @param ignoreGps
|
|
* @param layoutId
|
|
* @param date
|
|
* @param reportOnFail If set, reports an error to the mapcomplete server so that pietervdvn can fix it
|
|
* @private
|
|
*/
|
|
private async attemptSingleUpload(
|
|
{ featureId, author, blob, targetKey, noblur, location }: ImageUploadArguments,
|
|
reportOnFail: boolean
|
|
): Promise<UploadResult | undefined> {
|
|
let key: string
|
|
let value: string
|
|
let absoluteUrl: string
|
|
|
|
try {
|
|
;({ key, value, absoluteUrl } = await this._uploader.uploadImage(
|
|
blob,
|
|
location,
|
|
author,
|
|
noblur
|
|
))
|
|
} catch (e) {
|
|
console.error("Could again not upload image due to", e)
|
|
if (reportOnFail) {
|
|
await this._reportError(
|
|
e,
|
|
JSON.stringify({
|
|
ctx: "While uploading an image in the Image Upload Manager",
|
|
featureId,
|
|
author,
|
|
targetKey,
|
|
})
|
|
)
|
|
}
|
|
return undefined
|
|
}
|
|
key = targetKey ?? key
|
|
if (targetKey && targetKey.indexOf(key) < 0) {
|
|
// This is a non-standard key, so we use the image link directly
|
|
value = absoluteUrl
|
|
}
|
|
return { key, absoluteUrl, value }
|
|
}
|
|
}
|