From a6e8714ae07fe3f63f55172938de1f0503e5f543 Mon Sep 17 00:00:00 2001 From: pietervdvn Date: Wed, 29 Sep 2021 23:56:59 +0200 Subject: [PATCH] Refactoring of image detection, fix loading wikimedia images --- Logic/Actors/ImageSearcher.ts | 173 ---------------- .../TiledFromLocalStorageSource.ts | 3 +- Logic/ImageProviders/AllImageProviders.ts | 54 ++++- Logic/ImageProviders/GenericImageProvider.ts | 36 ++++ .../ImageProviders/ImageAttributionSource.ts | 33 ---- Logic/ImageProviders/ImageProvider.ts | 64 ++++++ Logic/ImageProviders/Imgur.ts | 23 ++- Logic/ImageProviders/LicenseInfo.ts | 10 + Logic/ImageProviders/Mapillary.ts | 59 +++--- Logic/ImageProviders/WikidataImageProvider.ts | 51 +++++ Logic/ImageProviders/Wikimedia.ts | 185 ------------------ .../ImageProviders/WikimediaImageProvider.ts | 163 +++++++++++++++ Logic/UIEventSource.ts | 2 +- Models/Constants.ts | 7 +- UI/Image/AttributedImage.ts | 23 +-- UI/Image/Attribution.ts | 2 +- UI/Image/ImageCarousel.ts | 67 +++---- UI/SpecialVisualizations.ts | 7 +- assets/themes/natuurpunt/natuurpunt.json | 4 +- test/ImageSearcher.spec.ts | 28 --- test/TestAll.ts | 2 - 21 files changed, 468 insertions(+), 528 deletions(-) delete mode 100644 Logic/Actors/ImageSearcher.ts create mode 100644 Logic/ImageProviders/GenericImageProvider.ts delete mode 100644 Logic/ImageProviders/ImageAttributionSource.ts create mode 100644 Logic/ImageProviders/ImageProvider.ts create mode 100644 Logic/ImageProviders/LicenseInfo.ts create mode 100644 Logic/ImageProviders/WikidataImageProvider.ts delete mode 100644 Logic/ImageProviders/Wikimedia.ts create mode 100644 Logic/ImageProviders/WikimediaImageProvider.ts delete mode 100644 test/ImageSearcher.spec.ts diff --git a/Logic/Actors/ImageSearcher.ts b/Logic/Actors/ImageSearcher.ts deleted file mode 100644 index c2d089e51..000000000 --- a/Logic/Actors/ImageSearcher.ts +++ /dev/null @@ -1,173 +0,0 @@ -import {ImagesInCategory, Wikidata, Wikimedia} from "../ImageProviders/Wikimedia"; -import {UIEventSource} from "../UIEventSource"; - -/** - * There are multiple way to fetch images for an object - * 1) There is an image tag - * 2) There is an image tag, the image tag contains multiple ';'-separated URLS - * 3) there are multiple image tags, e.g. 'image', 'image:0', 'image:1', and 'image_0', 'image_1' - however, these are pretty rare so we are gonna ignore them - * 4) There is a wikimedia_commons-tag, which either has a 'File': or a 'category:' containing images - * 5) There is a wikidata-tag, and the wikidata item either has an 'image' attribute or has 'a link to a wikimedia commons category' - * 6) There is a wikipedia article, from which we can deduct the wikidata item - * - * For some images, author and license should be shown - */ -/** - * Class which search for all the possible locations for images and which builds a list of UI-elements for it. - * Note that this list is embedded into an UIEVentSource, ready to put it into a carousel. - * - */ -export class ImageSearcher extends UIEventSource<{ key: string, url: string }[]> { - - private static _cache = new Map(); - private readonly _wdItem = new UIEventSource(""); - private readonly _commons = new UIEventSource(""); - - private constructor(tags: UIEventSource, imagePrefix = "image", loadSpecial = true) { - super([]) - const self = this; - - function AddImages(images: { key: string, url: string }[]) { - const oldUrls = self.data.map(kurl => kurl.url); - let somethingChanged = false; - for (const image of images) { - const url = image.url; - - if (url === undefined || url === null || url === "") { - continue; - } - if (oldUrls.indexOf(url) >= 0) { - // Already exists - continue; - } - - self.data.push(image); - somethingChanged = true; - } - if (somethingChanged) { - self.ping(); - } - } - - function addImage(image: string) { - AddImages([{url: image, key: undefined}]); - } - - - // By wrapping this in a UIEventSource, we prevent multiple queries of loadWikiData - this._wdItem.addCallback(wdItemContents => { - ImageSearcher.loadWikidata(wdItemContents, addImage); - }); - this._commons.addCallback(commonsData => { - ImageSearcher.LoadCommons(commonsData, addImage) - }); - tags.addCallbackAndRun(tags => { - AddImages(ImageSearcher.LoadImages(tags, imagePrefix)); - }); - - if (loadSpecial) { - tags.addCallbackAndRunD(tags => { - - const wdItem = tags.wikidata; - if (wdItem !== undefined) { - self._wdItem.setData(wdItem); - } - const commons = tags.wikimedia_commons; - if (commons !== undefined) { - self._commons.setData(commons); - } - - if (tags.mapillary) { - let mapillary = tags.mapillary; - const prefix = "https://www.mapillary.com/map/im/"; - - let regex = /https?:\/\/www.mapillary.com\/app\/.*pKey=([^&]*).*/ - let match = mapillary.match(regex); - if (match) { - mapillary = match[1]; - } - - if (mapillary.indexOf(prefix) < 0) { - mapillary = prefix + mapillary; - } - - - AddImages([{url: mapillary, key: undefined}]); - } - }) - } - } - - public static construct(tags: UIEventSource, imagePrefix = "image", loadSpecial = true): ImageSearcher { - const key = tags.data["id"] + " " + imagePrefix + loadSpecial; - if (tags.data["id"] !== undefined && ImageSearcher._cache.has(key)) { - return ImageSearcher._cache.get(key) - } - - const searcher = new ImageSearcher(tags, imagePrefix, loadSpecial); - ImageSearcher._cache.set(key, searcher) - return searcher; - } - - private static loadWikidata(wikidataItem, addImage: ((url: string) => void)): void { - // Load the wikidata item, then detect usage on 'commons' - let allWikidataId = wikidataItem.split(";"); - for (let wikidataId of allWikidataId) { - // @ts-ignore - if (wikidataId.startsWith("Q")) { - wikidataId = wikidataId.substr(1); - } - Wikimedia.GetWikiData(parseInt(wikidataId), (wd: Wikidata) => { - addImage(wd.image); - Wikimedia.GetCategoryFiles(wd.commonsWiki, (images: ImagesInCategory) => { - for (const image of images.images) { - if (image.startsWith("File:")) { - addImage(image); - } - } - }) - }) - } - } - - private static LoadCommons(commonsData: string, addImage: ((url: string) => void)): void { - const allCommons: string[] = commonsData.split(";"); - for (const commons of allCommons) { - if (commons.startsWith("Category:")) { - Wikimedia.GetCategoryFiles(commons, (images: ImagesInCategory) => { - for (const image of images.images) { - if (image.startsWith("File:")) { - addImage(image) - } - } - }) - } else { - if (commons.startsWith("File:")) { - addImage(commons) - } - } - } - } - - private static LoadImages(tags: any, imagePrefix: string): { key: string, url: string }[] { - const imageTag = tags[imagePrefix]; - const images: { key: string, url: string }[] = []; - if (imageTag !== undefined) { - const bareImages = imageTag.split(";"); - for (const bareImage of bareImages) { - images.push({key: imagePrefix, url: bareImage}) - } - } - - for (const key in tags) { - if (key.startsWith(imagePrefix + ":")) { - const url = tags[key] - images.push({key: key, url: url}) - } - } - - - return images; - } - -} \ No newline at end of file diff --git a/Logic/FeatureSource/TiledFeatureSource/TiledFromLocalStorageSource.ts b/Logic/FeatureSource/TiledFeatureSource/TiledFromLocalStorageSource.ts index 3ad933d7e..016537968 100644 --- a/Logic/FeatureSource/TiledFeatureSource/TiledFromLocalStorageSource.ts +++ b/Logic/FeatureSource/TiledFeatureSource/TiledFromLocalStorageSource.ts @@ -24,11 +24,12 @@ public tileFreshness : Map = new Map() // @ts-ignore const indexes: number[] = Object.keys(localStorage) .filter(key => { - return key.startsWith(prefix) && !key.endsWith("-time"); + return key.startsWith(prefix) && !key.endsWith("-time") && !key.endsWith("-format"); }) .map(key => { return Number(key.substring(prefix.length)); }) + .filter(i => !isNaN(i)) console.debug("Layer", layer.layerDef.id, "has following tiles in available in localstorage", indexes.map(i => Tiles.tile_from_index(i).join("/")).join(", ")) for (const index of indexes) { diff --git a/Logic/ImageProviders/AllImageProviders.ts b/Logic/ImageProviders/AllImageProviders.ts index e616e59e7..c1fe3bb45 100644 --- a/Logic/ImageProviders/AllImageProviders.ts +++ b/Logic/ImageProviders/AllImageProviders.ts @@ -1,9 +1,59 @@ import {Mapillary} from "./Mapillary"; -import {Wikimedia} from "./Wikimedia"; +import {WikimediaImageProvider} from "./WikimediaImageProvider"; import {Imgur} from "./Imgur"; +import GenericImageProvider from "./GenericImageProvider"; +import {UIEventSource} from "../UIEventSource"; +import ImageProvider, {ProvidedImage} from "./ImageProvider"; +import {WikidataImageProvider} from "./WikidataImageProvider"; +import {Utils} from "../../Utils"; +/** + * A generic 'from the interwebz' image picker, without attribution + */ export default class AllImageProviders { - public static ImageAttributionSource = [Imgur.singleton, Mapillary.singleton, Wikimedia.singleton] + public static ImageAttributionSource: ImageProvider[] = [ + Imgur.singleton, + Mapillary.singleton, + WikidataImageProvider.singleton, + WikimediaImageProvider.singleton, + new GenericImageProvider(Imgur.defaultValuePrefix)] + + + private static _cache: Map> = new Map>() + + public static LoadImagesFor(tags: UIEventSource, imagePrefix: string, loadSpecialSource: boolean): UIEventSource { + const id = tags.data.id + if (id === undefined) { + return undefined; + } + + const cached = this._cache.get(tags.data.id) + if (cached !== undefined) { + return cached + } + + const source = new UIEventSource([]) + this._cache.set(id, source) + const allSources = [] + for (const imageProvider of AllImageProviders.ImageAttributionSource) { + const singleSource = imageProvider.GetRelevantUrls(tags) + allSources.push(singleSource) + singleSource.addCallbackAndRunD(_ => { + const all : ProvidedImage[] = [].concat(...allSources.map(source => source.data)) + const uniq = [] + const seen = new Set() + for (const img of all) { + if(seen.has(img.url)){ + continue + } + seen.add(img.url) + uniq.push(img) + } + source.setData(uniq) + }) + } + return source; + } } \ No newline at end of file diff --git a/Logic/ImageProviders/GenericImageProvider.ts b/Logic/ImageProviders/GenericImageProvider.ts new file mode 100644 index 000000000..170829fe0 --- /dev/null +++ b/Logic/ImageProviders/GenericImageProvider.ts @@ -0,0 +1,36 @@ +import ImageProvider, {ProvidedImage} from "./ImageProvider"; + +export default class GenericImageProvider extends ImageProvider { + public defaultKeyPrefixes: string[] = ["image"]; + + private readonly _valuePrefixBlacklist: string[]; + + public constructor(valuePrefixBlacklist: string[]) { + super(); + this._valuePrefixBlacklist = valuePrefixBlacklist; + } + + + protected DownloadAttribution(url: string) { + return undefined + } + + async ExtractUrls(key: string, value: string): Promise[]> { + + if (this._valuePrefixBlacklist.some(prefix => value.startsWith(prefix))) { + return [] + } + + return [Promise.resolve({ + key: key, + url: value, + provider: this + })] + } + + SourceIcon(backlinkSource?: string) { + return undefined; + } + + +} \ No newline at end of file diff --git a/Logic/ImageProviders/ImageAttributionSource.ts b/Logic/ImageProviders/ImageAttributionSource.ts deleted file mode 100644 index 1f0f097b7..000000000 --- a/Logic/ImageProviders/ImageAttributionSource.ts +++ /dev/null @@ -1,33 +0,0 @@ -import {UIEventSource} from "../UIEventSource"; -import {LicenseInfo} from "./Wikimedia"; -import BaseUIElement from "../../UI/BaseUIElement"; - - -export default abstract class ImageAttributionSource { - - private _cache = new Map>() - - GetAttributionFor(url: string): UIEventSource { - const cached = this._cache.get(url); - if (cached !== undefined) { - return cached; - } - const src = new UIEventSource(undefined) - this._cache.set(url, src) - this.DownloadAttribution(url).then(license => - src.setData(license)) - .catch(e => console.error("Could not download license information for ", url, " due to", e)) - return src; - } - - - public abstract SourceIcon(backlinkSource?: string): BaseUIElement; - - /*Converts a value to a URL. Can return null if not applicable*/ - public PrepareUrl(value: string): string | UIEventSource { - return value; - } - - protected abstract DownloadAttribution(url: string): Promise; - -} \ No newline at end of file diff --git a/Logic/ImageProviders/ImageProvider.ts b/Logic/ImageProviders/ImageProvider.ts new file mode 100644 index 000000000..67154d945 --- /dev/null +++ b/Logic/ImageProviders/ImageProvider.ts @@ -0,0 +1,64 @@ +import {UIEventSource} from "../UIEventSource"; +import BaseUIElement from "../../UI/BaseUIElement"; +import {LicenseInfo} from "./LicenseInfo"; + +export interface ProvidedImage { + url: string, key: string, provider: ImageProvider +} + +export default abstract class ImageProvider { + + protected abstract readonly defaultKeyPrefixes : string[] + + private _cache = new Map>() + + GetAttributionFor(url: string): UIEventSource { + const cached = this._cache.get(url); + if (cached !== undefined) { + return cached; + } + const src =UIEventSource.FromPromise(this.DownloadAttribution(url)) + this._cache.set(url, src) + return src; + } + + public abstract SourceIcon(backlinkSource?: string): BaseUIElement; + + protected abstract DownloadAttribution(url: string): Promise; + + /** + * Given a properies object, maps it onto _all_ the available pictures for this imageProvider + */ + public GetRelevantUrls(allTags: UIEventSource, options?: { + prefixes?: string[] + }):UIEventSource { + const prefixes = options?.prefixes ?? this.defaultKeyPrefixes + const relevantUrls = new UIEventSource<{ url: string; key: string; provider: ImageProvider }[]>([]) + const seenValues = new Set() + allTags.addCallbackAndRunD(tags => { + for (const key in tags) { + if(!prefixes.some(prefix => key.startsWith(prefix))){ + continue + } + const value = tags[key] + if(seenValues.has(value)){ + continue + } + seenValues.add(value) + + this.ExtractUrls(key, value).then(promises => { + for (const promise of promises) { + promise.then(providedImage => { + relevantUrls.data.push(providedImage) + relevantUrls.ping() + }) + } + }) + } + }) + return relevantUrls + } + + public abstract ExtractUrls(key: string, value: string) : Promise[]>; + +} \ No newline at end of file diff --git a/Logic/ImageProviders/Imgur.ts b/Logic/ImageProviders/Imgur.ts index f85f3228a..289dad1f8 100644 --- a/Logic/ImageProviders/Imgur.ts +++ b/Logic/ImageProviders/Imgur.ts @@ -1,12 +1,14 @@ -// @ts-ignore import $ from "jquery" -import {LicenseInfo} from "./Wikimedia"; -import ImageAttributionSource from "./ImageAttributionSource"; +import ImageProvider, {ProvidedImage} from "./ImageProvider"; import BaseUIElement from "../../UI/BaseUIElement"; import {Utils} from "../../Utils"; import Constants from "../../Models/Constants"; +import {LicenseInfo} from "./LicenseInfo"; -export class Imgur extends ImageAttributionSource { +export class Imgur extends ImageProvider { + + public static readonly defaultValuePrefix = ["https://i.imgur.com"] + public readonly defaultKeyPrefixes: string[] = ["image"]; public static readonly singleton = new Imgur(); @@ -87,7 +89,7 @@ export class Imgur extends ImageAttributionSource { return undefined; } - protected async DownloadAttribution(url: string): Promise { + protected DownloadAttribution: (url: string) => Promise = async (url: string) => { const hash = url.substr("https://i.imgur.com/".length).split(".jpg")[0]; const apiUrl = 'https://api.imgur.com/3/image/' + hash; @@ -110,5 +112,16 @@ export class Imgur extends ImageAttributionSource { return licenseInfo } + public async ExtractUrls(key: string, value: string): Promise[]> { + if (Imgur.defaultValuePrefix.some(prefix => value.startsWith(prefix))) { + return [Promise.resolve({ + url: value, + key: key, + provider: this + })] + } + return [] + } + } \ No newline at end of file diff --git a/Logic/ImageProviders/LicenseInfo.ts b/Logic/ImageProviders/LicenseInfo.ts new file mode 100644 index 000000000..b5954693c --- /dev/null +++ b/Logic/ImageProviders/LicenseInfo.ts @@ -0,0 +1,10 @@ +export class LicenseInfo { + artist: string = ""; + license: string = ""; + licenseShortName: string = ""; + usageTerms: string = ""; + attributionRequired: boolean = false; + copyrighted: boolean = false; + credit: string = ""; + description: string = ""; +} \ No newline at end of file diff --git a/Logic/ImageProviders/Mapillary.ts b/Logic/ImageProviders/Mapillary.ts index ae5808a01..45342bc14 100644 --- a/Logic/ImageProviders/Mapillary.ts +++ b/Logic/ImageProviders/Mapillary.ts @@ -1,19 +1,19 @@ -import {LicenseInfo} from "./Wikimedia"; -import ImageAttributionSource from "./ImageAttributionSource"; +import ImageProvider, {ProvidedImage} from "./ImageProvider"; import BaseUIElement from "../../UI/BaseUIElement"; import {UIEventSource} from "../UIEventSource"; import Svg from "../../Svg"; import {Utils} from "../../Utils"; +import {LicenseInfo} from "./LicenseInfo"; +import Constants from "../../Models/Constants"; -export class Mapillary extends ImageAttributionSource { +export class Mapillary extends ImageProvider { + defaultKeyPrefixes = ["mapillary"] + public static readonly singleton = new Mapillary(); private static readonly v4_cached_urls = new Map>(); - private static readonly client_token_v3 = 'TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2' - private static readonly client_token_v4 = "MLY|4441509239301885|b40ad2d3ea105435bd40c7e76993ae85" - private constructor() { super(); } @@ -56,29 +56,34 @@ export class Mapillary extends ImageAttributionSource { return Svg.mapillary_svg(); } - PrepareUrl(value: string): string | UIEventSource { - const keyV = Mapillary.ExtractKeyFromURL(value) - if (!keyV.isApiv4) { - return `https://images.mapillary.com/${keyV.key}/thumb-640.jpg?client_id=${Mapillary.client_token_v3}` - } else { - const key = keyV.key; - if (Mapillary.v4_cached_urls.has(key)) { - return Mapillary.v4_cached_urls.get(key) - } - - const metadataUrl = 'https://graph.mapillary.com/' + key + '?fields=thumb_1024_url&&access_token=' + Mapillary.client_token_v4; - const source = new UIEventSource(undefined) - Mapillary.v4_cached_urls.set(key, source) - Utils.downloadJson(metadataUrl).then( - json => { - console.warn("Got response on mapillary image", json, json["thumb_1024_url"]) - return source.setData(json["thumb_1024_url"]); - } - ) - return source - } + async ExtractUrls(key: string, value: string): Promise[]> { + return [this.PrepareUrlAsync(key, value)] } + private async PrepareUrlAsync(key: string, value: string): Promise { + const keyV = Mapillary.ExtractKeyFromURL(value) + if (!keyV.isApiv4) { + const url = `https://images.mapillary.com/${keyV.key}/thumb-640.jpg?client_id=${Constants.mapillary_client_token_v3}` + return { + url: url, + provider: this, + key: key + } + } else { + const key = keyV.key; + const metadataUrl = 'https://graph.mapillary.com/' + key + '?fields=thumb_1024_url&&access_token=' + Constants.mapillary_client_token_v4; + const source = new UIEventSource(undefined) + Mapillary.v4_cached_urls.set(key, source) + const response = await Utils.downloadJson(metadataUrl) + const url = response["thumb_1024_url"]; + return { + url: url, + provider: this, + key: key + } + } + } + protected async DownloadAttribution(url: string): Promise { const keyV = Mapillary.ExtractKeyFromURL(url) diff --git a/Logic/ImageProviders/WikidataImageProvider.ts b/Logic/ImageProviders/WikidataImageProvider.ts new file mode 100644 index 000000000..3bb7d3c91 --- /dev/null +++ b/Logic/ImageProviders/WikidataImageProvider.ts @@ -0,0 +1,51 @@ +import {Utils} from "../../Utils"; +import ImageProvider, {ProvidedImage} from "./ImageProvider"; +import BaseUIElement from "../../UI/BaseUIElement"; +import Svg from "../../Svg"; +import {WikimediaImageProvider} from "./WikimediaImageProvider"; + +export class WikidataImageProvider extends ImageProvider { + + public SourceIcon(backlinkSource?: string): BaseUIElement { + throw Svg.wikidata_svg(); + } + + public static readonly singleton = new WikidataImageProvider() + public readonly defaultKeyPrefixes = ["wikidata"] + + private constructor() { + super() + } + + protected DownloadAttribution(url: string): Promise { + throw new Error("Method not implemented; shouldn't be needed!"); + } + + public async ExtractUrls(key: string, value: string): Promise[]> { + const wikidataUrl = "https://www.wikidata.org/wiki/" + if (value.startsWith(wikidataUrl)) { + value = value.substring(wikidataUrl.length) + } + if (!value.startsWith("Q")) { + value = "Q" + value + } + const url = "https://www.wikidata.org/wiki/Special:EntityData/" + value + ".json"; + const response = await Utils.downloadJson(url) + const entity = response.entities[value]; + const commons = entity.sitelinks.commonswiki; + // P18 is the claim 'depicted in this image' + const image = entity.claims.P18?.[0]?.mainsnak?.datavalue?.value; + const allImages = [] + if (image !== undefined) { + // We found a 'File://' + const promises = await WikimediaImageProvider.singleton.ExtractUrls(key, image) + allImages.push(...promises) + } + if (commons !== undefined) { + const promises = await WikimediaImageProvider.singleton.ExtractUrls(commons, image) + allImages.push(...promises) + } + return allImages + } + +} \ No newline at end of file diff --git a/Logic/ImageProviders/Wikimedia.ts b/Logic/ImageProviders/Wikimedia.ts deleted file mode 100644 index cd5aea5d5..000000000 --- a/Logic/ImageProviders/Wikimedia.ts +++ /dev/null @@ -1,185 +0,0 @@ -import ImageAttributionSource from "./ImageAttributionSource"; -import BaseUIElement from "../../UI/BaseUIElement"; -import Svg from "../../Svg"; -import Link from "../../UI/Base/Link"; -import {Utils} from "../../Utils"; - -/** - * This module provides endpoints for wikipedia/wikimedia and others - */ -export class Wikimedia extends ImageAttributionSource { - - - public static readonly singleton = new Wikimedia(); - - private constructor() { - super(); - } - - - static ImageNameToUrl(filename: string, width: number = 500, height: number = 200): string { - filename = encodeURIComponent(filename); - return "https://commons.wikimedia.org/wiki/Special:FilePath/" + filename + "?width=" + width + "&height=" + height; - } - - static GetCategoryFiles(categoryName: string, handleCategory: ((ImagesInCategory: ImagesInCategory) => void), - alreadyLoaded = 0, - continueParameter: { k: string, param: string } = undefined) { - if (categoryName === undefined || categoryName === null || categoryName === "") { - return; - } - // @ts-ignore - if (!categoryName.startsWith("Category:")) { - categoryName = "Category:" + categoryName; - } - let url = "https://commons.wikimedia.org/w/api.php?" + - "action=query&list=categorymembers&format=json&" + - "&origin=*" + - "&cmtitle=" + encodeURIComponent(categoryName); - if (continueParameter !== undefined) { - url = url + "&" + continueParameter.k + "=" + continueParameter.param; - } - const self = this; - console.log("Loading a wikimedia category: ", url) - Utils.downloadJson(url).then((response) => { - let imageOverview = new ImagesInCategory(); - let members = response.query?.categorymembers; - if (members === undefined) { - members = []; - } - - for (const member of members) { - imageOverview.images.push(member.title); - } - console.log("Got images! ", imageOverview) - if (response.continue === undefined) { - handleCategory(imageOverview); - return; - } - - if (alreadyLoaded > 10) { - console.log(`Recursive wikimedia category load stopped for ${categoryName} - got already enough images now (${alreadyLoaded})`) - handleCategory(imageOverview) - return; - } - - self.GetCategoryFiles(categoryName, - (recursiveImages) => { - recursiveImages.images.push(...imageOverview.images); - handleCategory(recursiveImages); - }, - alreadyLoaded + 10, - {k: "cmcontinue", param: response.continue.cmcontinue}) - - }); - } - - static GetWikiData(id: number, handleWikidata: ((Wikidata) => void)) { - const url = "https://www.wikidata.org/wiki/Special:EntityData/Q" + id + ".json"; - Utils.downloadJson(url).then(response => { - const entity = response.entities["Q" + id]; - const commons = entity.sitelinks.commonswiki; - const wd = new Wikidata(); - wd.commonsWiki = commons?.title; - - // P18 is the claim 'depicted in this image' - const image = entity.claims.P18?.[0]?.mainsnak?.datavalue?.value; - if (image) { - wd.image = "File:" + image; - } - handleWikidata(wd); - }); - } - - private static ExtractFileName(url: string) { - if (!url.startsWith("http")) { - return url; - } - const path = new URL(url).pathname - return path.substring(path.lastIndexOf("/") + 1); - - } - - SourceIcon(backlink: string): BaseUIElement { - const img = Svg.wikimedia_commons_white_svg() - .SetStyle("width:2em;height: 2em"); - if (backlink === undefined) { - return img - } - - - return new Link(Svg.wikimedia_commons_white_img, - `https://commons.wikimedia.org/wiki/${backlink}`, true) - - - } - - PrepareUrl(value: string): string { - - if (value.toLowerCase().startsWith("https://commons.wikimedia.org/wiki/")) { - return value; - } - return Wikimedia.ImageNameToUrl(value, 500, 400) - .replace(/'/g, '%27'); - } - - protected async DownloadAttribution(filename: string): Promise { - filename = Wikimedia.ExtractFileName(filename) - - if (filename === "") { - return undefined; - } - - const url = "https://en.wikipedia.org/w/" + - "api.php?action=query&prop=imageinfo&iiprop=extmetadata&" + - "titles=" + filename + - "&format=json&origin=*"; - const data = await Utils.downloadJson(url) - const licenseInfo = new LicenseInfo(); - const license = (data.query.pages[-1].imageinfo ?? [])[0]?.extmetadata; - if (license === undefined) { - console.error("This file has no usable metedata or license attached... Please fix the license info file yourself!") - return undefined; - } - - licenseInfo.artist = license.Artist?.value; - licenseInfo.license = license.License?.value; - licenseInfo.copyrighted = license.Copyrighted?.value; - licenseInfo.attributionRequired = license.AttributionRequired?.value; - licenseInfo.usageTerms = license.UsageTerms?.value; - licenseInfo.licenseShortName = license.LicenseShortName?.value; - licenseInfo.credit = license.Credit?.value; - licenseInfo.description = license.ImageDescription?.value; - return licenseInfo; - - } - - -} - -export class Wikidata { - - commonsWiki: string; - image: string; - -} - -export class ImagesInCategory { - // Filenames of relevant images - images: string[] = []; -} - -export class LicenseInfo { - - - artist: string = ""; - license: string = ""; - licenseShortName: string = ""; - usageTerms: string = ""; - attributionRequired: boolean = false; - copyrighted: boolean = false; - credit: string = ""; - description: string = ""; - - -} \ No newline at end of file diff --git a/Logic/ImageProviders/WikimediaImageProvider.ts b/Logic/ImageProviders/WikimediaImageProvider.ts new file mode 100644 index 000000000..cf4c1837b --- /dev/null +++ b/Logic/ImageProviders/WikimediaImageProvider.ts @@ -0,0 +1,163 @@ +import ImageProvider, {ProvidedImage} from "./ImageProvider"; +import BaseUIElement from "../../UI/BaseUIElement"; +import Svg from "../../Svg"; +import Link from "../../UI/Base/Link"; +import {Utils} from "../../Utils"; +import {LicenseInfo} from "./LicenseInfo"; + +/** + * This module provides endpoints for wikimedia and others + */ +export class WikimediaImageProvider extends ImageProvider { + + + public readonly defaultKeyPrefixes = ["wikimedia_commons"] + public static readonly singleton = new WikimediaImageProvider(); + + private constructor() { + super(); + } + /** + * Recursively walks a wikimedia commons category in order to search for (image) files + * Returns (a promise of) a list of URLS + * @param categoryName The name of the wikimedia category + * @param maxLoad: the maximum amount of images to return + * @param continueParameter: if the page indicates that more pages should be loaded, this uses a token to continue. Provided by wikimedia + */ + private static async GetImagesInCategory(categoryName: string, + maxLoad = 10, + continueParameter: string = undefined): Promise { + if (categoryName === undefined || categoryName === null || categoryName === "") { + return []; + } + if (!categoryName.startsWith("Category:")) { + categoryName = "Category:" + categoryName; + } + + let url = "https://commons.wikimedia.org/w/api.php?" + + "action=query&list=categorymembers&format=json&" + + "&origin=*" + + "&cmtitle=" + encodeURIComponent(categoryName); + if (continueParameter !== undefined) { + url = `${url}&cmcontinue=${continueParameter}`; + } + console.log("Loading a wikimedia category: ", url) + const response = await Utils.downloadJson(url) + const members = response.query?.categorymembers ?? []; + const imageOverview: string[] = members.map(member => member.title); + + if (response.continue === undefined) { + // We are done crawling through the category - no continuation in sight + return imageOverview; + } + + if (maxLoad - imageOverview.length <= 0) { + console.log(`Recursive wikimedia category load stopped for ${categoryName}`) + return imageOverview; + } + + // We do have a continue token - let's load the next page + const recursive = await this.GetImagesInCategory(categoryName, maxLoad - imageOverview.length, response.continue.cmcontinue) + imageOverview.push(...recursive) + return imageOverview + } + + private static ExtractFileName(url: string) { + if (!url.startsWith("http")) { + return url; + } + const path = new URL(url).pathname + return path.substring(path.lastIndexOf("/") + 1); + + } + + SourceIcon(backlink: string): BaseUIElement { + const img = Svg.wikimedia_commons_white_svg() + .SetStyle("width:2em;height: 2em"); + if (backlink === undefined) { + return img + } + + + return new Link(Svg.wikimedia_commons_white_img, + `https://commons.wikimedia.org/wiki/${backlink}`, true) + + + } + + private PrepareUrl(value: string): string { + + if (value.toLowerCase().startsWith("https://commons.wikimedia.org/wiki/")) { + return value; + } + return (`https://commons.wikimedia.org/wiki/Special:FilePath/${encodeURIComponent(value)}?width=500&height=400`) + } + + protected async DownloadAttribution(filename: string): Promise { + filename = WikimediaImageProvider.ExtractFileName(filename) + + if (filename === "") { + return undefined; + } + + const url = "https://en.wikipedia.org/w/" + + "api.php?action=query&prop=imageinfo&iiprop=extmetadata&" + + "titles=" + filename + + "&format=json&origin=*"; + const data = await Utils.downloadJson(url) + const licenseInfo = new LicenseInfo(); + const license = (data.query.pages[-1].imageinfo ?? [])[0]?.extmetadata; + if (license === undefined) { + console.error("This file has no usable metedata or license attached... Please fix the license info file yourself!") + return undefined; + } + + licenseInfo.artist = license.Artist?.value; + licenseInfo.license = license.License?.value; + licenseInfo.copyrighted = license.Copyrighted?.value; + licenseInfo.attributionRequired = license.AttributionRequired?.value; + licenseInfo.usageTerms = license.UsageTerms?.value; + licenseInfo.licenseShortName = license.LicenseShortName?.value; + licenseInfo.credit = license.Credit?.value; + licenseInfo.description = license.ImageDescription?.value; + return licenseInfo; + + } + + private async UrlForImage(image: string): Promise{ + if(!image.startsWith("File:")){ + image = "File:"+image + } + return {url: this.PrepareUrl(image), key: undefined, provider: this} + } + + public async ExtractUrls(key: string, value: string): Promise[]> { + const commonsPrefix = "https://commons.wikimedia.org/wiki/" + if(value.startsWith(commonsPrefix)){ + value = value.substring(commonsPrefix.length) + } else if(value.startsWith("https://upload.wikimedia.org")){ + const result : ProvidedImage = { + key: undefined, + url: value, + provider: this + } + return [Promise.resolve(result)] + } + if(value.startsWith("Category:")){ + const urls = await WikimediaImageProvider.GetImagesInCategory(value) + return urls.map(image => this.UrlForImage(image)) + } + if(value.startsWith("File:")){ + return [this.UrlForImage(value)] + } + if(value.startsWith("http")){ + // PRobably an error + return [] + } + // We do a last effort and assume this is a file + return [this.UrlForImage("File:"+value)] + } + + +} + diff --git a/Logic/UIEventSource.ts b/Logic/UIEventSource.ts index 42282ea2a..5f51dc004 100644 --- a/Logic/UIEventSource.ts +++ b/Logic/UIEventSource.ts @@ -64,7 +64,7 @@ export class UIEventSource { public static FromPromise(promise : Promise): UIEventSource{ const src = new UIEventSource(undefined) - promise.then(d => src.setData(d)) + promise?.then(d => src.setData(d)) return src } diff --git a/Models/Constants.ts b/Models/Constants.ts index d524c6689..4e588a057 100644 --- a/Models/Constants.ts +++ b/Models/Constants.ts @@ -2,8 +2,11 @@ import {Utils} from "../Utils"; export default class Constants { - public static vNumber = "0.10.0-rc0"; + public static vNumber = "0.10.0-rc1"; public static ImgurApiKey = '7070e7167f0a25a' + public static readonly mapillary_client_token_v3 = 'TXhLaWthQ1d4RUg0czVxaTVoRjFJZzowNDczNjUzNmIyNTQyYzI2' + public static readonly mapillary_client_token_v4 = "MLY|4441509239301885|b40ad2d3ea105435bd40c7e76993ae85" + public static defaultOverpassUrls = [ // The official instance, 10000 queries per day per project allowed "https://overpass-api.de/api/interpreter", @@ -15,6 +18,8 @@ export default class Constants { "https://overpass.openstreetmap.fr/api/interpreter" ] + + // The user journey states thresholds when a new feature gets unlocked public static userJourney = { moreScreenUnlock: 1, diff --git a/UI/Image/AttributedImage.ts b/UI/Image/AttributedImage.ts index 7614077c5..919a9ac17 100644 --- a/UI/Image/AttributedImage.ts +++ b/UI/Image/AttributedImage.ts @@ -1,30 +1,19 @@ import Combine from "../Base/Combine"; import Attribution from "./Attribution"; import Img from "../Base/Img"; -import ImageAttributionSource from "../../Logic/ImageProviders/ImageAttributionSource"; +import {ProvidedImage} from "../../Logic/ImageProviders/ImageProvider"; import BaseUIElement from "../BaseUIElement"; -import {VariableUiElement} from "../Base/VariableUIElement"; -import Loading from "../Base/Loading"; export class AttributedImage extends Combine { - constructor(urlSource: string, imgSource: ImageAttributionSource) { - const preparedUrl = imgSource.PrepareUrl(urlSource) + constructor(imageInfo: ProvidedImage) { let img: BaseUIElement; let attr: BaseUIElement - if (typeof preparedUrl === "string") { - img = new Img(urlSource); - attr = new Attribution(imgSource.GetAttributionFor(urlSource), imgSource.SourceIcon()) - } else { - img = new VariableUiElement(preparedUrl.map(url => { - if(url === undefined){ - return new Loading() - } - return new Img(url, false, {fallbackImage: './assets/svg/blocked.svg'}); - })) - attr = new VariableUiElement(preparedUrl.map(_ => new Attribution(imgSource.GetAttributionFor(urlSource), imgSource.SourceIcon()))) - } + img = new Img(imageInfo.url); + attr = new Attribution(imageInfo.provider.GetAttributionFor(imageInfo.url), + imageInfo.provider.SourceIcon(), + ) super([img, attr]); diff --git a/UI/Image/Attribution.ts b/UI/Image/Attribution.ts index 9afd9b815..f57911e93 100644 --- a/UI/Image/Attribution.ts +++ b/UI/Image/Attribution.ts @@ -3,7 +3,7 @@ import Translations from "../i18n/Translations"; import BaseUIElement from "../BaseUIElement"; import {VariableUiElement} from "../Base/VariableUIElement"; import {UIEventSource} from "../../Logic/UIEventSource"; -import {LicenseInfo} from "../../Logic/ImageProviders/Wikimedia"; +import {LicenseInfo} from "../../Logic/ImageProviders/LicenseInfo"; export default class Attribution extends VariableUiElement { diff --git a/UI/Image/ImageCarousel.ts b/UI/Image/ImageCarousel.ts index 4e7d032d2..c20f52080 100644 --- a/UI/Image/ImageCarousel.ts +++ b/UI/Image/ImageCarousel.ts @@ -4,29 +4,35 @@ import Combine from "../Base/Combine"; import DeleteImage from "./DeleteImage"; import {AttributedImage} from "./AttributedImage"; import BaseUIElement from "../BaseUIElement"; -import Img from "../Base/Img"; import Toggle from "../Input/Toggle"; -import {Wikimedia} from "../../Logic/ImageProviders/Wikimedia"; -import {Imgur} from "../../Logic/ImageProviders/Imgur"; -import {Mapillary} from "../../Logic/ImageProviders/Mapillary"; +import ImageProvider from "../../Logic/ImageProviders/ImageProvider"; export class ImageCarousel extends Toggle { - constructor(images: UIEventSource<{ key: string, url: string }[]>, tags: UIEventSource) { - const uiElements = images.map((imageURLS: { key: string, url: string }[]) => { + constructor(images: UIEventSource<{ key: string, url: string, provider: ImageProvider }[]>, tags: UIEventSource) { + const uiElements = images.map((imageURLS: { key: string, url: string, provider: ImageProvider }[]) => { const uiElements: BaseUIElement[] = []; for (const url of imageURLS) { - let image = ImageCarousel.CreateImageElement(url.url) - if (url.key !== undefined) { - image = new Combine([ - image, - new DeleteImage(url.key, tags).SetClass("delete-image-marker absolute top-0 left-0 pl-3") - ]).SetClass("relative"); + + try { + + let image = new AttributedImage(url) + + if (url.key !== undefined) { + image = new Combine([ + image, + new DeleteImage(url.key, tags).SetClass("delete-image-marker absolute top-0 left-0 pl-3") + ]).SetClass("relative"); + } + image + .SetClass("w-full block") + .SetStyle("min-width: 50px; background: grey;") + uiElements.push(image); + } catch (e) { + console.error("Could not generate image element for", url.url, "due to", e) } - image - .SetClass("w-full block") - .SetStyle("min-width: 50px; background: grey;") - uiElements.push(image); + + } return uiElements; }); @@ -38,33 +44,4 @@ export class ImageCarousel extends Toggle { ) this.SetClass("block w-full"); } - - /*** - * Creates either a 'simpleimage' or a 'wikimediaimage' based on the string - * @param url - * @constructor - */ - private static CreateImageElement(url: string): BaseUIElement { - // @ts-ignore - let attrSource: ImageAttributionSource = undefined; - if (url.startsWith("File:")) { - attrSource = Wikimedia.singleton - } else if (url.toLowerCase().startsWith("https://commons.wikimedia.org/wiki/")) { - attrSource = Wikimedia.singleton; - } else if (url.toLowerCase().startsWith("https://i.imgur.com/")) { - attrSource = Imgur.singleton - } else if (url.toLowerCase().startsWith("https://www.mapillary.com/map/im/")) { - attrSource = Mapillary.singleton - } else { - return new Img(url); - } - - try { - return new AttributedImage(url, attrSource) - } catch (e) { - console.error("Could not create an image: ", e) - return undefined; - } - - } } \ No newline at end of file diff --git a/UI/SpecialVisualizations.ts b/UI/SpecialVisualizations.ts index 5b15cfdaa..4aba67e7d 100644 --- a/UI/SpecialVisualizations.ts +++ b/UI/SpecialVisualizations.ts @@ -13,20 +13,19 @@ import Translations from "./i18n/Translations"; import ReviewForm from "./Reviews/ReviewForm"; import OpeningHoursVisualization from "./OpeningHours/OpeningHoursVisualization"; import State from "../State"; -import {ImageSearcher} from "../Logic/Actors/ImageSearcher"; import BaseUIElement from "./BaseUIElement"; import Title from "./Base/Title"; import Table from "./Base/Table"; import Histogram from "./BigComponents/Histogram"; import Loc from "../Models/Loc"; import {Utils} from "../Utils"; -import BaseLayer from "../Models/BaseLayer"; import LayerConfig from "../Models/ThemeConfig/LayerConfig"; import ImportButton from "./BigComponents/ImportButton"; import {Tag} from "../Logic/Tags/Tag"; import StaticFeatureSource from "../Logic/FeatureSource/Sources/StaticFeatureSource"; import ShowDataMultiLayer from "./ShowDataLayer/ShowDataMultiLayer"; import Minimap from "./Base/Minimap"; +import AllImageProviders from "../Logic/ImageProviders/AllImageProviders"; export interface SpecialVisualization { funcName: string, @@ -76,9 +75,7 @@ export default class SpecialVisualizations { constr: (state: State, tags, args) => { const imagePrefix = args[0]; const loadSpecial = args[1].toLowerCase() === "true"; - const searcher: UIEventSource<{ key: string, url: string }[]> = ImageSearcher.construct(tags, imagePrefix, loadSpecial); - - return new ImageCarousel(searcher, tags); + return new ImageCarousel(AllImageProviders.LoadImagesFor(tags, imagePrefix, loadSpecial), tags); } }, { diff --git a/assets/themes/natuurpunt/natuurpunt.json b/assets/themes/natuurpunt/natuurpunt.json index 1ed0b7e88..c5345a55f 100644 --- a/assets/themes/natuurpunt/natuurpunt.json +++ b/assets/themes/natuurpunt/natuurpunt.json @@ -75,7 +75,7 @@ "icon": { "render": "circle:#FE6F32;./assets/themes/natuurpunt/nature_reserve.svg" }, - "presets": null + "presets": [] } }, { @@ -262,4 +262,4 @@ } } ] -} \ No newline at end of file +} diff --git a/test/ImageSearcher.spec.ts b/test/ImageSearcher.spec.ts deleted file mode 100644 index 9b3d713ac..000000000 --- a/test/ImageSearcher.spec.ts +++ /dev/null @@ -1,28 +0,0 @@ -import {Utils} from "../Utils"; -import {equal} from "assert"; -import T from "./TestHelper"; -import {UIEventSource} from "../Logic/UIEventSource"; -import {ImageSearcher} from "../Logic/Actors/ImageSearcher"; - -Utils.runningFromConsole = true; -export default class ImageSearcherSpec extends T { - - constructor() { - super("imagesearcher", [ - [ - "Should find images", - () => { - const tags = new UIEventSource({ - "mapillary": "https://www.mapillary.com/app/?pKey=bYH6FFl8LXAPapz4PNSh3Q" - }); - const searcher = ImageSearcher.construct(tags) - const result = searcher.data[0]; - equal(result.url, "https://www.mapillary.com/map/im/bYH6FFl8LXAPapz4PNSh3Q"); - } - ], - ]); - - } - - -} diff --git a/test/TestAll.ts b/test/TestAll.ts index 1a25a0afc..0efa98dd4 100644 --- a/test/TestAll.ts +++ b/test/TestAll.ts @@ -1,7 +1,6 @@ import TagSpec from "./Tag.spec"; import ImageAttributionSpec from "./ImageAttribution.spec"; import GeoOperationsSpec from "./GeoOperations.spec"; -import ImageSearcherSpec from "./ImageSearcher.spec"; import ThemeSpec from "./Theme.spec"; import UtilsSpec from "./Utils.spec"; import OsmObjectSpec from "./OsmObject.spec"; @@ -18,7 +17,6 @@ const allTests = [ new TagSpec(), new ImageAttributionSpec(), new GeoOperationsSpec(), - new ImageSearcherSpec(), new ThemeSpec(), new UtilsSpec(), new UnitsSpec(),