Chore: rework image uploading, should work better now

This commit is contained in:
Pieter Vander Vennet 2023-09-25 02:13:24 +02:00
parent 6f5b0622a5
commit 94ba18785d
17 changed files with 548 additions and 238 deletions

View file

@ -7,7 +7,7 @@ import { OsmTags } from "../../../Models/OsmFeature"
*/
export default class FeaturePropertiesStore {
private readonly _elements = new Map<string, UIEventSource<Record<string, string>>>()
public readonly aliases = new Map<string, string>()
constructor(...sources: FeatureSource[]) {
for (const source of sources) {
this.trackFeatureSource(source)
@ -92,7 +92,6 @@ export default class FeaturePropertiesStore {
})
}
// noinspection JSUnusedGlobalSymbols
public addAlias(oldId: string, newId: string): void {
if (newId === undefined) {
// We removed the node/way/relation with type 'type' and id 'oldId' on openstreetmap!
@ -112,6 +111,7 @@ export default class FeaturePropertiesStore {
}
element.data.id = newId
this._elements.set(newId, element)
this.aliases.set(newId, oldId)
element.ping()
}

View file

@ -0,0 +1,150 @@
import { ImageUploader } from "./ImageUploader";
import LinkImageAction from "../Osm/Actions/LinkImageAction";
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore";
import { OsmId, OsmTags } from "../../Models/OsmFeature";
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import { Store, UIEventSource } from "../UIEventSource";
import { OsmConnection } from "../Osm/OsmConnection";
import { Changes } from "../Osm/Changes";
import Translations from "../../UI/i18n/Translations";
/**
* The ImageUploadManager has a
*/
export class ImageUploadManager {
private readonly _uploader: ImageUploader;
private readonly _featureProperties: FeaturePropertiesStore;
private readonly _layout: LayoutConfig;
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 _changes: Changes;
constructor(layout: LayoutConfig, uploader: ImageUploader, featureProperties: FeaturePropertiesStore, osmConnection: OsmConnection, changes: Changes) {
this._uploader = uploader;
this._featureProperties = featureProperties;
this._layout = layout;
this._osmConnection = osmConnection;
this._changes = changes;
}
/**
* 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)
};
}
/**
* Uploads the given image, applies the correct title and license for the known user
*/
public async uploadImageAndApply(file: File, tags: OsmTags) {
const sizeInBytes = file.size
const featureId = <OsmId> tags.id
console.log(file.name + " has a size of " + sizeInBytes + " Bytes, attaching to", tags.id)
const self = this
if (sizeInBytes > this._uploader.maxFileSizeInMegabytes * 1000000) {
this.increaseCountFor(this._uploadStarted, featureId)
this.increaseCountFor(this._uploadFailed, featureId)
throw(
Translations.t.image.toBig.Subs({
actual_size: Math.floor(sizeInBytes / 1000000) + "MB",
max_size: self._uploader.maxFileSizeInMegabytes + "MB",
}).txt
)
}
const licenseStore = this._osmConnection?.GetPreference("pictures-license", "CC0");
const license = licenseStore?.data ?? "CC0";
const matchingLayer = this._layout?.getMatchingLayer(tags);
const title =
matchingLayer?.title?.GetRenderValue(tags)?.Subs(tags)?.textFor("en") ??
tags.name ??
"https//osm.org/" + tags.id;
const description = [
"author:" + this._osmConnection.userDetails.data.name,
"license:" + license,
"osmid:" + tags.id
].join("\n");
console.log("Upload done, creating ")
const action = await this.uploadImageWithLicense(featureId, title, description, file);
await this._changes.applyAction(action);
}
private async uploadImageWithLicense(
featureId: OsmId,
title: string, description: string, blob: File
): Promise<LinkImageAction> {
this.increaseCountFor(this._uploadStarted, featureId);
const properties = this._featureProperties.getStore(featureId);
let key: string;
let value: string;
try {
({ key, value } = await this._uploader.uploadImage(title, description, blob));
} catch (e) {
this.increaseCountFor(this._uploadRetried, featureId);
console.error("Could not upload image, trying again:", e);
try {
({ key, value } = await this._uploader.uploadImage(title, description, blob));
this.increaseCountFor(this._uploadRetriedSuccess, featureId);
} catch (e) {
console.error("Could again not upload image due to", e);
this.increaseCountFor(this._uploadFailed, featureId);
}
}
console.log("Uploading done, creating action for", featureId)
const action = new LinkImageAction(featureId, key, value, properties, {
theme: this._layout.id,
changeType: "add-image"
});
this.increaseCountFor(this._uploadFinished, featureId);
return action;
}
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(counter.data + 1);
}
}

View file

@ -0,0 +1,15 @@
export interface ImageUploader {
maxFileSizeInMegabytes?: number;
/**
* Uploads the 'blob' as image, with some metadata.
* Returns the URL to be linked + the appropriate key to add this to OSM
* @param title
* @param description
* @param blob
*/
uploadImage(
title: string,
description: string,
blob: File
): Promise<{ key: string, value: string }>;
}

View file

@ -1,60 +1,30 @@
import ImageProvider, { ProvidedImage } from "./ImageProvider"
import BaseUIElement from "../../UI/BaseUIElement"
import { Utils } from "../../Utils"
import Constants from "../../Models/Constants"
import { LicenseInfo } from "./LicenseInfo"
import ImageProvider, { ProvidedImage } from "./ImageProvider";
import BaseUIElement from "../../UI/BaseUIElement";
import { Utils } from "../../Utils";
import Constants from "../../Models/Constants";
import { LicenseInfo } from "./LicenseInfo";
import { ImageUploader } from "./ImageUploader";
export class Imgur extends ImageProvider {
export class Imgur extends ImageProvider implements ImageUploader{
public static readonly defaultValuePrefix = ["https://i.imgur.com"]
public static readonly singleton = new Imgur()
public readonly defaultKeyPrefixes: string[] = ["image"]
public readonly maxFileSizeInMegabytes = 10
private constructor() {
super()
}
static uploadMultiple(
/**
* Uploads an image, returns the URL where to find the image
* @param title
* @param description
* @param blob
*/
public async uploadImage(
title: string,
description: string,
blobs: FileList,
handleSuccessfullUpload: (imageURL: string) => Promise<void>,
allDone: () => void,
onFail: (reason: string) => void,
offset: number = 0
) {
if (blobs.length == offset) {
allDone()
return
}
const blob = blobs.item(offset)
const self = this
this.uploadImage(
title,
description,
blob,
async (imageUrl) => {
await handleSuccessfullUpload(imageUrl)
self.uploadMultiple(
title,
description,
blobs,
handleSuccessfullUpload,
allDone,
onFail,
offset + 1
)
},
onFail
)
}
static uploadImage(
title: string,
description: string,
blob: File,
handleSuccessfullUpload: (imageURL: string) => Promise<void>,
onFail: (reason: string) => void
) {
blob: File
): Promise<{ key: string, value: string }> {
const apiUrl = "https://api.imgur.com/3/image"
const apiKey = Constants.ImgurApiKey
@ -63,6 +33,7 @@ export class Imgur extends ImageProvider {
formData.append("title", title)
formData.append("description", description)
const settings: RequestInit = {
method: "POST",
body: formData,
@ -74,17 +45,9 @@ export class Imgur extends ImageProvider {
}
// Response contains stringified JSON
// Image URL available at response.data.link
fetch(apiUrl, settings)
.then(async function (response) {
const content = await response.json()
await handleSuccessfullUpload(content.data.link)
})
.catch((reason) => {
console.log("Uploading to IMGUR failed", reason)
// @ts-ignore
onFail(reason)
})
const response = await fetch(apiUrl, settings)
const content = await response.json()
return { key: "image", value: content.data.link }
}
SourceIcon(): BaseUIElement {

View file

@ -1,11 +1,20 @@
import ChangeTagAction from "./ChangeTagAction"
import { Tag } from "../../Tags/Tag"
import ChangeTagAction from "./ChangeTagAction";
import { Tag } from "../../Tags/Tag";
import OsmChangeAction from "./OsmChangeAction";
import { Changes } from "../Changes";
import { ChangeDescription } from "./ChangeDescription";
import { Store } from "../../UIEventSource";
export default class LinkImageAction extends OsmChangeAction {
private readonly _proposedKey: "image" | "mapillary" | "wiki_commons" | string;
private readonly _url: string;
private readonly _currentTags: Store<Record<string, string>>;
private readonly _meta: { theme: string; changeType: "add-image" | "link-image" };
export default class LinkPicture extends ChangeTagAction {
/**
* Adds a link to an image
* Adds an image-link to a feature
* @param elementId
* @param proposedKey: a key which might be used, typically `image`. If the key is already used with a different URL, `key+":0"` will be used instead (or a higher number if needed)
* @param proposedKey a key which might be used, typically `image`. If the key is already used with a different URL, `key+":0"` will be used instead (or a higher number if needed)
* @param url
* @param currentTags
* @param meta
@ -15,18 +24,31 @@ export default class LinkPicture extends ChangeTagAction {
elementId: string,
proposedKey: "image" | "mapillary" | "wiki_commons" | string,
url: string,
currentTags: Record<string, string>,
currentTags: Store<Record<string, string>>,
meta: {
theme: string
changeType: "add-image" | "link-image"
}
) {
let key = proposedKey
super(elementId, true)
this._proposedKey = proposedKey;
this._url = url;
this._currentTags = currentTags;
this._meta = meta;
}
protected CreateChangeDescriptions(): Promise<ChangeDescription[]> {
let key = this._proposedKey
let i = 0
const currentTags = this._currentTags.data
const url = this._url
while (currentTags[key] !== undefined && currentTags[key] !== url) {
key = proposedKey + ":" + i
key = this._proposedKey + ":" + i
i++
}
super(elementId, new Tag(key, url), currentTags, meta)
const tagChangeAction = new ChangeTagAction ( this.mainObjectId, new Tag(key, url), currentTags, this._meta)
return tagChangeAction.CreateChangeDescriptions()
}
}

View file

@ -19,6 +19,9 @@ export default abstract class OsmChangeAction {
constructor(mainObjectId: string, trackStatistics: boolean = true) {
this.trackStatistics = trackStatistics
this.mainObjectId = mainObjectId
if(mainObjectId === undefined || mainObjectId === null){
throw "OsmObject received '"+mainObjectId+"' as mainObjectId"
}
}
public async Perform(changes: Changes) {

View file

@ -1,22 +1,22 @@
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig"
import { OsmConnection } from "../Osm/OsmConnection"
import { MangroveIdentity } from "../Web/MangroveReviews"
import { Store, Stores, UIEventSource } from "../UIEventSource"
import StaticFeatureSource from "../FeatureSource/Sources/StaticFeatureSource"
import { FeatureSource } from "../FeatureSource/FeatureSource"
import { Feature } from "geojson"
import { Utils } from "../../Utils"
import translators from "../../assets/translators.json"
import codeContributors from "../../assets/contributors.json"
import LayerConfig from "../../Models/ThemeConfig/LayerConfig"
import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson"
import usersettings from "../../../src/assets/generated/layers/usersettings.json"
import Locale from "../../UI/i18n/Locale"
import LinkToWeblate from "../../UI/Base/LinkToWeblate"
import FeatureSwitchState from "./FeatureSwitchState"
import Constants from "../../Models/Constants"
import { QueryParameters } from "../Web/QueryParameters"
import { ThemeMetaTagging } from "./UserSettingsMetaTagging"
import LayoutConfig from "../../Models/ThemeConfig/LayoutConfig";
import { OsmConnection } from "../Osm/OsmConnection";
import { MangroveIdentity } from "../Web/MangroveReviews";
import { Store, Stores, UIEventSource } from "../UIEventSource";
import StaticFeatureSource from "../FeatureSource/Sources/StaticFeatureSource";
import { FeatureSource } from "../FeatureSource/FeatureSource";
import { Feature } from "geojson";
import { Utils } from "../../Utils";
import translators from "../../assets/translators.json";
import codeContributors from "../../assets/contributors.json";
import LayerConfig from "../../Models/ThemeConfig/LayerConfig";
import { LayerConfigJson } from "../../Models/ThemeConfig/Json/LayerConfigJson";
import usersettings from "../../../src/assets/generated/layers/usersettings.json";
import Locale from "../../UI/i18n/Locale";
import LinkToWeblate from "../../UI/Base/LinkToWeblate";
import FeatureSwitchState from "./FeatureSwitchState";
import Constants from "../../Models/Constants";
import { QueryParameters } from "../Web/QueryParameters";
import { ThemeMetaTagging } from "./UserSettingsMetaTagging";
import { MapProperties } from "../../Models/MapProperties";
/**
@ -43,7 +43,7 @@ export default class UserRelatedState {
public readonly homeLocation: FeatureSource
public readonly language: UIEventSource<string>
public readonly preferredBackgroundLayer: UIEventSource<string | "photo" | "map" | "osmbasedmap" | undefined>
public readonly preferredBackgroundLayerForTheme: UIEventSource<string | "photo" | "map" | "osmbasedmap" | undefined>
public readonly imageLicense : UIEventSource<string>
/**
* The number of seconds that the GPS-locations are stored in memory.
* Time in seconds
@ -108,6 +108,9 @@ export default class UserRelatedState {
documentation: "The ID of a layer or layer category that MapComplete uses by default"
})
this.imageLicense = this.osmConnection.GetPreference("pictures-license", "CC0", {
documentation: "The license under which new images are uploaded"
})
this.installedUserThemes = this.InitInstalledUserThemes()
this.homeLocation = this.initHomeLocation()