forked from MapComplete/MapComplete
Chore: rework image uploading, should work better now
This commit is contained in:
parent
6f5b0622a5
commit
94ba18785d
17 changed files with 548 additions and 238 deletions
|
@ -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()
|
||||
}
|
||||
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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 }>;
|
||||
}
|
|
@ -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 {
|
||||
|
|
|
@ -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()
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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()
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue