Merge master
This commit is contained in:
commit
cc4db080aa
45 changed files with 619 additions and 812 deletions
|
@ -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
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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 | "*") {
|
||||
|
|
|
@ -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 }>
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
|
191
src/Logic/ImageProviders/Panoramax.ts
Normal file
191
src/Logic/ImageProviders/Panoramax.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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> {
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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: {
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue