Merge master

This commit is contained in:
Pieter Vander Vennet 2024-09-28 02:23:19 +02:00
commit cc4db080aa
45 changed files with 619 additions and 812 deletions

View file

@ -5,6 +5,8 @@ import GenericImageProvider from "./GenericImageProvider"
import { Store, UIEventSource } from "../UIEventSource"
import ImageProvider, { ProvidedImage } from "./ImageProvider"
import { WikidataImageProvider } from "./WikidataImageProvider"
import Panoramax from "./Panoramax"
import { Utils } from "../../Utils"
/**
* A generic 'from the interwebz' image picker, without attribution
@ -28,6 +30,7 @@ export default class AllImageProviders {
Mapillary.singleton,
WikidataImageProvider.singleton,
WikimediaImageProvider.singleton,
Panoramax.singleton,
AllImageProviders.genericImageProvider,
]
public static apiUrls: string[] = [].concat(
@ -41,11 +44,8 @@ export default class AllImageProviders {
mapillary: Mapillary.singleton,
wikidata: WikidataImageProvider.singleton,
wikimedia: WikimediaImageProvider.singleton,
panoramax: Panoramax.singleton
}
private static _cache: Map<string, UIEventSource<ProvidedImage[]>> = new Map<
string,
UIEventSource<ProvidedImage[]>
>()
public static byName(name: string) {
return AllImageProviders.providersByName[name.toLowerCase()]
@ -66,45 +66,32 @@ export default class AllImageProviders {
return AllImageProviders.genericImageProvider
}
/**
* Tries to extract all image data for this image
*/
public static LoadImagesFor(
tags: Store<Record<string, string>>,
tagKey?: string[]
): Store<ProvidedImage[]> {
if (tags.data.id === undefined) {
if (tags?.data?.id === undefined) {
return undefined
}
const cacheKey = tags.data.id + tagKey
const cached = this._cache.get(cacheKey)
if (cached !== undefined) {
return cached
}
const source = new UIEventSource([])
this._cache.set(cacheKey, source)
const allSources: Store<ProvidedImage[]>[] = []
for (const imageProvider of AllImageProviders.ImageAttributionSource) {
let prefixes = imageProvider.defaultKeyPrefixes
if (tagKey !== undefined) {
prefixes = tagKey
}
const singleSource = imageProvider.GetRelevantUrls(tags, {
prefixes: prefixes,
})
/*
By default, 'GetRelevantUrls' uses the defaultKeyPrefixes.
However, we override them if a custom image tag is set, e.g. 'image:menu'
*/
const prefixes = tagKey ?? imageProvider.defaultKeyPrefixes
const singleSource = tags.bindD(tags => imageProvider.getRelevantUrls(tags, prefixes))
allSources.push(singleSource)
singleSource.addCallbackAndRunD((_) => {
const all: ProvidedImage[] = [].concat(...allSources.map((source) => source.data))
const uniq = []
const seen = new Set<string>()
for (const img of all) {
if (seen.has(img.url)) {
continue
}
seen.add(img.url)
uniq.push(img)
}
source.setData(uniq)
const dedup = Utils.DedupOnId(all, i => i?.id ?? i?.url)
source.set(dedup)
})
}
return source

View file

@ -15,26 +15,24 @@ export default class GenericImageProvider extends ImageProvider {
this._valuePrefixBlacklist = valuePrefixBlacklist
}
async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
ExtractUrls(key: string, value: string): undefined | ProvidedImage[] {
if (this._valuePrefixBlacklist.some((prefix) => value.startsWith(prefix))) {
return []
return undefined
}
try {
new URL(value)
} catch (_) {
// Not a valid URL
return []
return undefined
}
return [
Promise.resolve({
key: key,
url: value,
provider: this,
id: value,
}),
]
return [{
key: key,
url: value,
provider: this,
id: value,
}]
}
SourceIcon() {

View file

@ -1,4 +1,4 @@
import { Store, UIEventSource } from "../UIEventSource"
import { Store, Stores, UIEventSource } from "../UIEventSource"
import BaseUIElement from "../../UI/BaseUIElement"
import { LicenseInfo } from "./LicenseInfo"
import { Utils } from "../../Utils"
@ -10,6 +10,7 @@ export interface ProvidedImage {
provider: ImageProvider
id: string
date?: Date,
status?: string | "ready"
/**
* Compass angle of the taken image
* 0 = north, 90° = East
@ -26,56 +27,45 @@ export default abstract class ImageProvider {
public abstract SourceIcon(id?: string, location?: { lon: number; lat: number }): BaseUIElement
/**
* Given a properties object, maps it onto _all_ the available pictures for this imageProvider.
* This iterates over _all_ tags and matches _anything_ that might be an image
* Gets all the relevant URLS for the given tags and for the given prefixes;
* extracts the necessary information
* @param tags
* @param prefixes
*/
public GetRelevantUrls(
allTags: Store<any>,
options?: {
prefixes?: string[]
}
): UIEventSource<ProvidedImage[]> {
const prefixes = options?.prefixes ?? this.defaultKeyPrefixes
if (prefixes === undefined) {
throw "No `defaultKeyPrefixes` defined by this image provider"
}
const relevantUrls = new UIEventSource<
{ id: string; url: string; key: string; provider: ImageProvider }[]
>([])
public async getRelevantUrlsFor(tags: Record<string, string>, prefixes: string[]): Promise<ProvidedImage[]> {
const relevantUrls: ProvidedImage[] = []
const seenValues = new Set<string>()
allTags.addCallbackAndRunD((tags) => {
for (const key in tags) {
if (!prefixes.some((prefix) => key.startsWith(prefix))) {
for (const key in tags) {
if (!prefixes.some((prefix) => key === prefix || key.match(new RegExp(prefix+":[0-9]+")))) {
continue
}
const values = Utils.NoEmpty(tags[key]?.split(";")?.map((v) => v.trim()) ?? [])
for (const value of values) {
if (seenValues.has(value)) {
continue
}
const values = Utils.NoEmpty(tags[key]?.split(";")?.map((v) => v.trim()) ?? [])
for (const value of values) {
if (seenValues.has(value)) {
continue
}
seenValues.add(value)
this.ExtractUrls(key, value).then((promises) => {
for (const promise of promises ?? []) {
if (promise === undefined) {
continue
}
promise.then((providedImage) => {
if (providedImage === undefined) {
return
}
relevantUrls.data.push(providedImage)
relevantUrls.ping()
})
}
})
seenValues.add(value)
let images = this.ExtractUrls(key, value)
if(!Array.isArray(images)){
images = await images
}
if(images){
relevantUrls.push(...images)
}
}
})
}
return relevantUrls
}
public abstract ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]>
public getRelevantUrls(tags: Record<string, string>, prefixes: string[]): Store<ProvidedImage[]> {
return Stores.FromPromise(this.getRelevantUrlsFor(tags, prefixes))
}
public abstract ExtractUrls(key: string, value: string): undefined | ProvidedImage[] | Promise<ProvidedImage[]>
public abstract DownloadAttribution(providedImage: {
url: string

View file

@ -9,6 +9,8 @@ import { Changes } from "../Osm/Changes"
import Translations from "../../UI/i18n/Translations"
import NoteCommentElement from "../../UI/Popup/Notes/NoteCommentElement"
import { Translation } from "../../UI/i18n/Translation"
import { IndexedFeatureSource } from "../FeatureSource/FeatureSource"
import { GeoOperations } from "../GeoOperations"
/**
* The ImageUploadManager has a
@ -17,7 +19,8 @@ export class ImageUploadManager {
private readonly _uploader: ImageUploader
private readonly _featureProperties: FeaturePropertiesStore
private readonly _layout: LayoutConfig
private readonly _indexedFeatures: IndexedFeatureSource
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()
@ -32,13 +35,17 @@ export class ImageUploadManager {
uploader: ImageUploader,
featureProperties: FeaturePropertiesStore,
osmConnection: OsmConnection,
changes: Changes
changes: Changes,
gpsLocation: Store<GeolocationCoordinates | undefined>,
allFeatures: IndexedFeatureSource,
) {
this._uploader = uploader
this._featureProperties = featureProperties
this._layout = layout
this._osmConnection = osmConnection
this._changes = changes
this._indexedFeatures = allFeatures
this._gps = gpsLocation
const failed = this.getCounterFor(this._uploadFailed, "*")
const done = this.getCounterFor(this._uploadFinished, "*")
@ -47,7 +54,7 @@ export class ImageUploadManager {
(startedCount) => {
return startedCount > failed.data + done.data
},
[failed, done]
[failed, done],
)
}
@ -55,7 +62,7 @@ export class ImageUploadManager {
* 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
* @param featureId the id of the feature you want information for. '*' has a global counter
*/
public getCountsFor(featureId: string | "*"): {
retried: Store<number>
@ -96,7 +103,7 @@ export class ImageUploadManager {
public async uploadImageAndApply(
file: File,
tagsStore: UIEventSource<OsmTags>,
targetKey?: string
targetKey?: string,
): Promise<void> {
const canBeUploaded = this.canBeUploaded(file)
if (canBeUploaded !== true) {
@ -105,28 +112,15 @@ export class ImageUploadManager {
const tags = tagsStore.data
const featureId = <OsmId>tags.id
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")
const author = this._osmConnection.userDetails.data.name
const action = await this.uploadImageWithLicense(
featureId,
title,
description,
author,
file,
targetKey,
tags?.data?.["_orig_theme"]
tags?.data?.["_orig_theme"],
)
if (!action) {
@ -146,23 +140,31 @@ export class ImageUploadManager {
private async uploadImageWithLicense(
featureId: OsmId,
title: string,
description: string,
author: string,
blob: File,
targetKey: string | undefined,
theme?: string
theme?: string,
): Promise<LinkImageAction> {
this.increaseCountFor(this._uploadStarted, featureId)
const properties = this._featureProperties.getStore(featureId)
let key: string
let value: string
let location: [number, number] = undefined
if (this._gps.data) {
location = [this._gps.data.longitude, this._gps.data.latitude]
}
if (location === undefined || location?.some(l => l === undefined)) {
const feature = this._indexedFeatures.featuresById.data.get(featureId)
location = GeoOperations.centerpointCoordinates(feature)
}
let absoluteUrl: string
try {
;({ key, value } = await this._uploader.uploadImage(title, description, blob))
;({ key, value, absoluteUrl } = await this._uploader.uploadImage(blob, location, author))
} 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))
;({ key, value , absoluteUrl} = await this._uploader.uploadImage(blob, location, author))
this.increaseCountFor(this._uploadRetriedSuccess, featureId)
} catch (e) {
console.error("Could again not upload image due to", e)
@ -172,12 +174,15 @@ export class ImageUploadManager {
}
console.log("Uploading image done, creating action for", featureId)
key = targetKey ?? key
if(targetKey){
// This is a non-standard key, so we use the image link directly
value = absoluteUrl
}
this.increaseCountFor(this._uploadFinished, featureId)
const action = new LinkImageAction(featureId, key, value, properties, {
return new LinkImageAction(featureId, key, value, properties, {
theme: theme ?? this._layout.id,
changeType: "add-image",
})
return action
}
private getCounterFor(collection: Map<string, UIEventSource<number>>, key: string | "*") {

View file

@ -3,13 +3,10 @@ export interface ImageUploader {
/**
* 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 }>
blob: File,
currentGps: [number,number],
author: string
): Promise<{ key: string; value: string, absoluteUrl: string }>
}

View file

@ -3,14 +3,12 @@ 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 implements ImageUploader {
export class Imgur extends ImageProvider {
public static readonly defaultValuePrefix = ["https://i.imgur.com"]
public static readonly singleton = new Imgur()
public readonly name = "Imgur"
public readonly defaultKeyPrefixes: string[] = ["image"]
public readonly maxFileSizeInMegabytes = 10
public static readonly apiUrl = "https://api.imgur.com/3/image"
public static readonly supportingUrls = ["https://i.imgur.com"]
private constructor() {
@ -21,57 +19,23 @@ export class Imgur extends ImageProvider implements ImageUploader {
return [Imgur.apiUrl]
}
/**
* Uploads an image, returns the URL where to find the image
* @param title
* @param description
* @param blob
*/
public async uploadImage(
title: string,
description: string,
blob: File
): Promise<{ key: string; value: string }> {
const apiUrl = Imgur.apiUrl
const apiKey = Constants.ImgurApiKey
const formData = new FormData()
formData.append("image", blob)
formData.append("title", title)
formData.append("description", description)
const settings: RequestInit = {
method: "POST",
body: formData,
redirect: "follow",
headers: new Headers({
Authorization: `Client-ID ${apiKey}`,
Accept: "application/json",
}),
}
// Response contains stringified JSON
const response = await fetch(apiUrl, settings)
const content = await response.json()
return { key: "image", value: content.data.link }
}
SourceIcon(): BaseUIElement {
return undefined
}
public async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
public ExtractUrls(key: string, value: string): undefined | ProvidedImage[] {
if (Imgur.defaultValuePrefix.some((prefix) => value.startsWith(prefix))) {
return [
Promise.resolve({
{
url: value,
key: key,
provider: this,
id: value,
}),
}
]
}
return []
return undefined
}
/**

View file

@ -1,14 +1,14 @@
export class LicenseInfo {
title: string = ""
title?: string = ""
artist: string = ""
license: string = undefined
licenseShortName: string = ""
usageTerms: string = ""
attributionRequired: boolean = false
copyrighted: boolean = false
credit: string = ""
description: string = ""
informationLocation: URL = undefined
license?: string = undefined
licenseShortName?: string = ""
usageTerms?: string = ""
attributionRequired?: boolean = false
copyrighted?: boolean = false
credit?: string = ""
description?: string = ""
informationLocation?: URL = undefined
date?: Date
views?: number
}

View file

@ -131,8 +131,9 @@ export class Mapillary extends ImageProvider {
return new SvelteUIElement(MapillaryIcon, { url })
}
async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
return [this.PrepareUrlAsync(key, value)]
async ExtractUrls(key: string, value: string): Promise<ProvidedImage[]> {
const img = await this.PrepareUrlAsync(key, value)
return [img]
}
public async DownloadAttribution(providedImage: { id: string }): Promise<LicenseInfo> {

View file

@ -0,0 +1,191 @@
import { ImageUploader } from "./ImageUploader"
import { AuthorizedPanoramax, PanoramaxXYZ, ImageData } from "panoramax-js/dist"
import ExifReader from "exifreader"
import ImageProvider, { ProvidedImage } from "./ImageProvider"
import BaseUIElement from "../../UI/BaseUIElement"
import { LicenseInfo } from "./LicenseInfo"
import { Utils } from "../../Utils"
import { GeoOperations } from "../GeoOperations"
import Constants from "../../Models/Constants"
import { Store, Stores, UIEventSource } from "../UIEventSource"
export default class PanoramaxImageProvider extends ImageProvider {
public static readonly singleton = new PanoramaxImageProvider()
private static readonly xyz = new PanoramaxXYZ()
private static defaultPanoramax = new AuthorizedPanoramax(Constants.panoramax.url, Constants.panoramax.token)
public defaultKeyPrefixes: string[] = ["panoramax"]
public readonly name: string = "panoramax"
private static knownMeta: Record<string, { data: ImageData, time: Date }> = {}
public SourceIcon(id?: string, location?: { lon: number; lat: number; }): BaseUIElement {
return undefined
}
public addKnownMeta(meta: ImageData) {
PanoramaxImageProvider.knownMeta[meta.id] = { data: meta, time: new Date() }
}
/**
* Tries to get the entry from the mapcomplete-panoramax instance. Might return undefined
* @param id
* @private
*/
private async getInfoFromMapComplete(id: string): Promise<{ data: ImageData, url: string }> {
const sequence = "6e702976-580b-419c-8fb3-cf7bd364e6f8" // We always reuse this sequence
const url = `https://panoramax.mapcomplete.org/`
const data = await PanoramaxImageProvider.defaultPanoramax.imageInfo(sequence, id)
return { url, data }
}
private async getInfoFromXYZ(imageId: string): Promise<{ data: ImageData, url: string }> {
const data = await PanoramaxImageProvider.xyz.imageInfo(imageId)
return { data, url: "https://api.panoramax.xyz/" }
}
/**
* Reads a geovisio-somewhat-looking-like-geojson object and converts it to a provided image
* @param meta
* @private
*/
private featureToImage(info: { data: ImageData, url: string }) {
const meta = info?.data
if (!meta) {
return undefined
}
const url = info.url
function makeAbsolute(s: string) {
if (!s.startsWith("https://") && !s.startsWith("http://")) {
const parsed = new URL(url)
return parsed.protocol + "//" + parsed.host + s
}
return s
}
const [lon, lat] = GeoOperations.centerpointCoordinates(meta)
return <ProvidedImage>{
id: meta.id,
url: makeAbsolute(meta.assets.sd.href),
url_hd: makeAbsolute(meta.assets.hd.href),
lon, lat,
key: "panoramax",
provider: this,
status: meta.properties["geovisio:status"],
rotation: Number(meta.properties["view:azimuth"]),
date: new Date(meta.properties.datetime),
}
}
private async getInfoFor(id: string): Promise<{ data: ImageData, url: string }> {
if (!id.match(/^[a-zA-Z0-9-]+$/)) {
return undefined
}
const cached = PanoramaxImageProvider.knownMeta[id]
if (cached) {
if(new Date().getTime() - cached.time.getTime() < 1000){
return { data: cached.data, url: undefined }
}
}
try {
return await this.getInfoFromMapComplete(id)
} catch (e) {
console.debug(e)
}
try {
return await this.getInfoFromXYZ(id)
} catch (e) {
console.debug(e)
}
return undefined
}
public async ExtractUrls(key: string, value: string): Promise<ProvidedImage[]> {
return [await this.getInfoFor(value).then(r => this.featureToImage(<any>r))]
}
getRelevantUrls(tags: Record<string, string>, prefixes: string[]): Store<ProvidedImage[]> {
const source = UIEventSource.FromPromise(super.getRelevantUrlsFor(tags, prefixes))
function hasLoading(data: ProvidedImage[]) {
if(data === undefined){
return true
}
return data?.some(img => img?.status !== undefined && img?.status !== "ready" && img?.status !== "broken")
}
Stores.Chronic(1500, () =>
hasLoading(source.data),
).addCallback(_ => {
console.log("UPdating... ")
super.getRelevantUrlsFor(tags, prefixes).then(data => {
console.log("New panoramax data is", data, hasLoading(data))
source.set(data)
return !hasLoading(data)
})
})
return source
}
public async DownloadAttribution(providedImage: { url: string; id: string; }): Promise<LicenseInfo> {
const meta = await this.getInfoFor(providedImage.id)
return {
artist: meta.data.providers.at(-1).name, // We take the last provider, as that one probably contain the username of the uploader
date: new Date(meta.data.properties["datetime"]),
licenseShortName: meta.data.properties["geovisio:license"],
}
}
public apiUrls(): string[] {
return ["https://panoramax.mapcomplete.org", "https://panoramax.xyz"]
}
}
export class PanoramaxUploader implements ImageUploader {
private readonly _panoramax: AuthorizedPanoramax
constructor(url: string, token: string) {
this._panoramax = new AuthorizedPanoramax(url, token)
}
async uploadImage(blob: File, currentGps: [number, number], author: string): Promise<{
key: string;
value: string;
absoluteUrl: string
}> {
const tags = await ExifReader.load(blob)
const hasDate = tags.DateTime !== undefined
const hasGPS = tags.GPSLatitude !== undefined && tags.GPSLongitude !== undefined
const [lon, lat] = currentGps
const p = this._panoramax
const defaultSequence = (await p.mySequences())[0]
const img = <ImageData>await p.addImage(blob, defaultSequence, {
lat: !hasGPS ? lat : undefined,
lon: !hasGPS ? lon : undefined,
datetime: !hasDate ? new Date().toISOString() : undefined,
exifOverride: {
Artist: author,
},
})
PanoramaxImageProvider.singleton.addKnownMeta(img)
return {
key: "panoramax",
value: img.id,
absoluteUrl: img.assets.hd.href,
}
}
}

View file

@ -5,6 +5,7 @@ import Wikidata from "../Web/Wikidata"
import SvelteUIElement from "../../UI/Base/SvelteUIElement"
import * as Wikidata_icon from "../../assets/svg/Wikidata.svelte"
import { Utils } from "../../Utils"
import { ImmutableStore, Store, Stores, UIEventSource } from "../UIEventSource"
export class WikidataImageProvider extends ImageProvider {
public static readonly singleton = new WikidataImageProvider()
@ -25,28 +26,28 @@ export class WikidataImageProvider extends ImageProvider {
return new SvelteUIElement(Wikidata_icon)
}
public async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
public async ExtractUrls(key: string, value: string): Promise<ProvidedImage[]> {
if (WikidataImageProvider.keyBlacklist.has(key)) {
return []
return undefined
}
const entity = await Wikidata.LoadWikidataEntryAsync(value)
if (entity === undefined) {
return []
return undefined
}
const allImages: Promise<ProvidedImage>[] = []
const allImages: Promise<ProvidedImage[]>[] = []
// P18 is the claim 'depicted in this image'
for (const img of Array.from(entity.claims.get("P18") ?? [])) {
const promises = await WikimediaImageProvider.singleton.ExtractUrls(undefined, img)
allImages.push(...promises)
const promises = WikimediaImageProvider.singleton.ExtractUrls(undefined, img)
allImages.push(promises)
}
// P373 is 'commons category'
for (let cat of Array.from(entity.claims.get("P373") ?? [])) {
if (!cat.startsWith("Category:")) {
cat = "Category:" + cat
}
const promises = await WikimediaImageProvider.singleton.ExtractUrls(undefined, cat)
allImages.push(...promises)
const promises = WikimediaImageProvider.singleton.ExtractUrls(undefined, cat)
allImages.push(promises)
}
const commons = entity.commons
@ -54,10 +55,11 @@ export class WikidataImageProvider extends ImageProvider {
commons !== undefined &&
(commons.startsWith("Category:") || commons.startsWith("File:"))
) {
const promises = await WikimediaImageProvider.singleton.ExtractUrls(undefined, commons)
allImages.push(...promises)
const promises = WikimediaImageProvider.singleton.ExtractUrls(undefined, commons)
allImages.push(promises)
}
return allImages
const resolved = await Promise.all(Utils.NoNull(allImages))
return [].concat(...resolved)
}
public DownloadAttribution(_): Promise<any> {

View file

@ -37,7 +37,7 @@ export class WikimediaImageProvider extends ImageProvider {
return value
}
const baseUrl = `https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(
value
value,
)}`
if (useHd) {
return baseUrl
@ -97,28 +97,27 @@ export class WikimediaImageProvider extends ImageProvider {
return this.UrlForImage("File:" + value)
}
public async ExtractUrls(key: string, value: string): Promise<Promise<ProvidedImage>[]> {
public async ExtractUrls(key: string, value: string): undefined | Promise<ProvidedImage[]> {
const hasCommonsPrefix = WikimediaImageProvider.startsWithCommonsPrefix(value)
if (key !== undefined && key !== this.commons_key && !hasCommonsPrefix) {
return []
return undefined
}
value = WikimediaImageProvider.removeCommonsPrefix(value)
if (value.startsWith("Category:")) {
const urls = await Wikimedia.GetCategoryContents(value)
return urls
.filter((url) => url.startsWith("File:"))
.map((image) => Promise.resolve(this.UrlForImage(image)))
return urls.filter((url) => url.startsWith("File:"))
.map((image) => this.UrlForImage(image))
}
if (value.startsWith("File:")) {
return [Promise.resolve(this.UrlForImage(value))]
return [this.UrlForImage(value)]
}
if (value.startsWith("http")) {
// PRobably an error
return []
// Probably an error
return undefined
}
// We do a last effort and assume this is a file
return [Promise.resolve(this.UrlForImage("File:" + value))]
return [(this.UrlForImage("File:" + value))]
}
public async DownloadAttribution(img: { url: string }): Promise<LicenseInfo> {
@ -148,7 +147,7 @@ export class WikimediaImageProvider extends ImageProvider {
console.warn(
"The file",
filename,
"has no usable metedata or license attached... Please fix the license info file yourself!"
"has no usable metedata or license attached... Please fix the license info file yourself!",
)
return undefined
}

View file

@ -69,16 +69,14 @@ export default class DeleteAction extends OsmChangeAction {
* const obj : OsmNode= new OsmNode(1)
* obj.tags = {id:"node/1",name:"Monte Piselli - San Giacomo"}
* const da = new DeleteAction("node/1", new Tag("man_made",""), {theme: "test", specialMotivation: "Testcase"}, true)
* const state = { dryRun: new ImmutableStore(true), osmConnection: new OsmConnection() }
* const descr = await da.CreateChangeDescriptions(new Changes(state), obj)
* const descr = await da.CreateChangeDescriptions(Changes.createTestObject(), obj)
* descr[0] // => {doDelete: true, meta: {theme: "test", specialMotivation: "Testcase",changeType: "deletion"}, type: "node",id: 1 }
*
* // Must not crash if softDeletionTags are undefined
* const da = new DeleteAction("node/1", undefined, {theme: "test", specialMotivation: "Testcase"}, true)
* const obj : OsmNode= new OsmNode(1)
* obj.tags = {id:"node/1",name:"Monte Piselli - San Giacomo"}
* const state = { dryRun: new ImmutableStore(true), osmConnection: new OsmConnection() }
* const descr = await da.CreateChangeDescriptions(new Changes(state), obj)
* const descr = await da.CreateChangeDescriptions(Changes.createTestObject(), obj)
* descr[0] // => {doDelete: true, meta: {theme: "test", specialMotivation: "Testcase", changeType: "deletion"}, type: "node",id: 1 }
*/
public async CreateChangeDescriptions(

View file

@ -1,5 +1,5 @@
import { OsmNode, OsmObject, OsmRelation, OsmWay } from "./OsmObject"
import { Store, UIEventSource } from "../UIEventSource"
import { ImmutableStore, Store, UIEventSource } from "../UIEventSource"
import Constants from "../../Models/Constants"
import OsmChangeAction from "./Actions/OsmChangeAction"
import { ChangeDescription, ChangeDescriptionTools } from "./Actions/ChangeDescription"
@ -11,13 +11,12 @@ import { GeoLocationPointProperties } from "../State/GeoLocationState"
import { GeoOperations } from "../GeoOperations"
import { ChangesetHandler, ChangesetTag } from "./ChangesetHandler"
import { OsmConnection } from "./OsmConnection"
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
import OsmObjectDownloader from "./OsmObjectDownloader"
import ChangeLocationAction from "./Actions/ChangeLocationAction"
import ChangeTagAction from "./Actions/ChangeTagAction"
import FeatureSwitchState from "../State/FeatureSwitchState"
import DeleteAction from "./Actions/DeleteAction"
import MarkdownUtils from "../../Utils/MarkdownUtils"
import FeaturePropertiesStore from "../FeatureSource/Actors/FeaturePropertiesStore"
/**
* Handles all changes made to OSM.
@ -30,7 +29,9 @@ export class Changes {
public readonly state: {
allElements?: IndexedFeatureSource
osmConnection: OsmConnection
featureSwitches?: FeatureSwitchState
featureSwitches?: {
featureSwitchMorePrivacy?: Store<boolean>
}
}
public readonly extraComment: UIEventSource<string> = new UIEventSource(undefined)
public readonly backend: string
@ -45,12 +46,15 @@ export class Changes {
constructor(
state: {
dryRun: Store<boolean>
featureSwitches: {
featureSwitchMorePrivacy?: Store<boolean>
featureSwitchIsTesting?: Store<boolean>
},
osmConnection: OsmConnection,
reportError?: (error: string) => void,
featureProperties?: FeaturePropertiesStore,
historicalUserLocations?: FeatureSource,
allElements?: IndexedFeatureSource
featurePropertiesStore?: FeaturePropertiesStore
osmConnection: OsmConnection
historicalUserLocations?: FeatureSource
featureSwitches?: FeatureSwitchState
},
leftRightSensitive: boolean = false,
reportError?: (string: string | Error, extramessage?: string) => void
@ -59,14 +63,18 @@ export class Changes {
// We keep track of all changes just as well
this.allChanges.setData([...this.pendingChanges.data])
// If a pending change contains a negative ID, we save that
this._nextId = Math.min(-1, ...(this.pendingChanges.data?.map((pch) => pch.id) ?? []))
this._nextId = Math.min(-1, ...(this.pendingChanges.data?.map((pch) => pch.id ?? 0) ?? []))
if(isNaN(this._nextId) && state.reportError !== undefined){
state.reportError("Got a NaN as nextID. Pending changes IDs are:" +this.pendingChanges.data?.map(pch => pch?.id).join("."))
this._nextId = -100
}
this.state = state
this.backend = state.osmConnection.Backend()
this._reportError = reportError
this._changesetHandler = new ChangesetHandler(
state.dryRun,
state.featureSwitches.featureSwitchIsTesting,
state.osmConnection,
state.featurePropertiesStore,
state.featureProperties,
this,
(e, extramessage: string) => this._reportError(e, extramessage)
)
@ -76,6 +84,15 @@ export class Changes {
// This doesn't matter however, as the '-1' is per piecewise upload, not global per changeset
}
public static createTestObject(): Changes{
return new Changes({
osmConnection: new OsmConnection(),
featureSwitches:{
featureSwitchIsTesting: new ImmutableStore(true)
}
})
}
static buildChangesetXML(
csId: string,
allChanges: {

View file

@ -1,10 +1,6 @@
import { GeoOperations } from "./GeoOperations"
import { Utils } from "../Utils"
import opening_hours from "opening_hours"
import Combine from "../UI/Base/Combine"
import BaseUIElement from "../UI/BaseUIElement"
import Title from "../UI/Base/Title"
import { FixedUiElement } from "../UI/Base/FixedUiElement"
import LayerConfig from "../Models/ThemeConfig/LayerConfig"
import { CountryCoder } from "latlon2country"
import Constants from "../Models/Constants"