Refactoring: port wikidata preview boxes and wikidata item picker to Svelte, fix #2019, fix #797

This commit is contained in:
Pieter Vander Vennet 2024-07-11 16:59:10 +02:00
parent 3a2addbddc
commit d5430891bf
22 changed files with 580 additions and 507 deletions

View file

@ -124,6 +124,7 @@
"helperArgs": [
"name",
{
"multiple": "yes",
"notInstanceOf": [
"Q79007",
"Q22698"

View file

@ -78,7 +78,14 @@ export class WikimediaImageProvider extends ImageProvider {
return new SvelteUIElement(Wikimedia_commons_white).SetStyle("width:2em;height: 2em")
}
public PrepUrl(value: string): ProvidedImage {
public PrepUrl(value: NonNullable<string>): ProvidedImage
public PrepUrl(value: undefined): undefined
public PrepUrl(value: string): ProvidedImage
public PrepUrl(value: string | undefined): ProvidedImage | undefined{
if(value === undefined){
return undefined
}
value = WikimediaImageProvider.removeCommonsPrefix(value)
if (value.startsWith("File:")) {

View file

@ -119,6 +119,8 @@ export interface WikidataAdvancedSearchoptions extends WikidataSearchoptions {
notInstanceOf?: number[]
}
interface SparqlResult {results: { bindings: {item, label, description, num}[] }}
/**
* Utility functions around wikidata
*/
@ -202,7 +204,7 @@ export default class Wikidata {
} ORDER BY ASC(?num) LIMIT ${options?.maxCount ?? 20}`
const url = wds.sparqlQuery(sparql)
const result = await Utils.downloadJson(url)
const result = await Utils.downloadJson<SparqlResult>(url)
/*The full uri of the wikidata-item*/
return result.results.bindings.map(({ item, label, description, num }) => ({
@ -389,7 +391,7 @@ export default class Wikidata {
' SERVICE wikibase:label { bd:serviceParam wikibase:language "[AUTO_LANGUAGE]". }\n' +
"}"
const url = wds.sparqlQuery(query)
const result = await Utils.downloadJsonCached(url, 24 * 60 * 60 * 1000)
const result = await Utils.downloadJsonCached<SparqlResult>(url, 24 * 60 * 60 * 1000)
return result.results.bindings
}
@ -420,7 +422,7 @@ export default class Wikidata {
}
const url = "https://www.wikidata.org/wiki/Special:EntityData/" + id + ".json"
const entities = (await Utils.downloadJsonCached(url, 10000)).entities
const entities = (await Utils.downloadJsonCached<{entities}>(url, 10000)).entities
const firstKey = <string>Array.from(Object.keys(entities))[0] // Roundabout way to fetch the entity; it might have been a redirect
const response = entities[firstKey]

View file

@ -215,7 +215,7 @@ export default class Wikipedia {
}
private async GetArticleUncachedAsync(pageName: string): Promise<string> {
const response = await Utils.downloadJson(this.getDataUrl(pageName))
const response = await Utils.downloadJson<any>(this.getDataUrl(pageName))
if (response?.parse?.text === undefined) {
return undefined
}

View file

@ -289,6 +289,11 @@ export interface QuestionableTagRenderingConfigJson extends TagRenderingConfigJs
* group: expert
*/
postfixDistinguished?: string
/**
* Extra arguments to configure the input element
* group: hidden
*/
helperArgs: any
}
/**

View file

@ -69,6 +69,7 @@ export default class TagRenderingConfig {
readonly inline: boolean
readonly default?: string
readonly postfixDistinguished?: string
readonly args?: any
}
public readonly multiAnswer: boolean
@ -203,6 +204,7 @@ export default class TagRenderingConfig {
inline: json.freeform.inline ?? false,
default: json.freeform.default,
postfixDistinguished: json.freeform.postfixDistinguished?.trim(),
args: json.freeform.helperArgs
}
if (json.freeform["extraTags"] !== undefined) {
throw `Freeform.extraTags is defined. This should probably be 'freeform.addExtraTag' (at ${context})`

View file

@ -26,7 +26,7 @@ export default class SvelteUIElement<
constructor(svelteElement, props?: Props, events?: Events, slots?: Slots) {
super()
this._svelteComponent = svelteElement
this._svelteComponent = <any> svelteElement
this._props = props ?? <Props>{}
this._events = events
this._slots = slots

View file

@ -0,0 +1,59 @@
<script lang="ts">
import { ImmutableStore, Store, UIEventSource } from "../../Logic/UIEventSource"
import Translations from "../i18n/Translations"
import Loading from "../Base/Loading.svelte"
import Hotkeys from "../Base/Hotkeys"
import { createEventDispatcher, onDestroy } from "svelte"
import { placeholder } from "../../Utils/placeholder"
import { SearchIcon } from "@rgossiaux/svelte-heroicons/solid"
import { ariaLabel } from "../../Utils/ariaLabel"
import { Translation } from "../i18n/Translation"
const dispatch = createEventDispatcher<{search: string}>()
export let searchValue: UIEventSource<string>
export let placeholderText: Translation = Translations.t.general.search.search
export let feedback = new UIEventSource<string>(undefined)
let isRunning: boolean = false
let inputElement: HTMLInputElement
function _performSearch(){
dispatch("search", searchValue.data)
}
</script>
<div class="normal-background flex justify-between rounded-full">
<form class="flex w-full flex-wrap" on:submit|preventDefault={() => {}}>
{#if isRunning}
<Loading>{Translations.t.general.search.searching}</Loading>
{:else}
<div class="flex w-full border border-gray-300 rounded-full">
<input
type="search"
class="w-full outline-none mx-2"
bind:this={inputElement}
on:keypress={(keypr) => {
feedback.set(undefined)
return keypr.key === "Enter" ? _performSearch() : undefined
}}
bind:value={$searchValue}
use:placeholder={placeholderText}
use:ariaLabel={Translations.t.general.search.search}
/>
<SearchIcon aria-hidden="true" class="h-6 w-6 self-end" on:click={event => _performSearch()} />
</div>
{#if $feedback !== undefined}
<!-- The feedback is _always_ shown for screenreaders and to make sure that the searchfield can still be selected by tabbing-->
<div class="alert" role="alert" aria-live="assertive">
{$feedback}
</div>
{/if}
{/if}
</form>
</div>

View file

@ -0,0 +1,145 @@
<script lang="ts">
/**
* Allows to search through wikidata and to select one value
*/
import Translations from "../../i18n/Translations"
import Tr from "../../Base/Tr.svelte"
import { ImmutableStore, Store, Stores, UIEventSource } from "../../../Logic/UIEventSource"
import Wikidata, { WikidataResponse } from "../../../Logic/Web/Wikidata"
import Locale from "../../i18n/Locale"
import SearchField from "../../BigComponents/SearchField.svelte"
import Loading from "../../Base/Loading.svelte"
import Wikidatapreview from "../../Wikipedia/Wikidatapreview.svelte"
import { Utils } from "../../../Utils"
import WikidataValidator from "../Validators/WikidataValidator"
const t = Translations.t.general.wikipedia
export let searchValue = new UIEventSource("Tom boonen")
export let placeholder = t.searchWikidata
export let allowMultiple = false
export let notInstanceOf: number[] = []
export let instanceOf: number[] = []
export let value: UIEventSource<string>
let selectedWikidataSingle: WikidataResponse = undefined
let selectedMany: Record<string, boolean> = {}
let previouslySeen = new Map<string, WikidataResponse>()
$:{
if (selectedWikidataSingle) {
value.setData(selectedWikidataSingle.id)
}
}
$:{
console.log(selectedMany)
const v = []
for (const id in selectedMany) {
if (selectedMany[id]) {
v.push(id)
}
}
value.setData(v.join(";"))
}
let tooShort = new ImmutableStore<{ success: WikidataResponse[] }>({ success: undefined })
let searchResult: Store<{ success?: WikidataResponse[]; error?: any }> = searchValue
.bind((searchText) => {
if (searchText.length < 3 && !searchText.match(/[qQ][0-9]+/)) {
return tooShort
}
const lang = Locale.language.data
const key = lang + ":" + searchText
let promise = WikidataValidator._searchCache.get(key)
if (promise === undefined) {
promise = Wikidata.searchAndFetch(searchText, {
lang,
maxCount: 5,
notInstanceOf,
instanceOf
})
WikidataValidator._searchCache.set(key, promise)
}
return Stores.FromPromiseWithErr(promise)
})
let selectedWithoutSearch: Store<WikidataResponse[]> = searchResult.map(sr => {
for (const wikidataItem of sr?.success ?? []) {
console.log("Saving", wikidataItem.id)
previouslySeen.set(wikidataItem.id, wikidataItem)
}
let knownIds: Set<string> = new Set(sr?.success?.map(item => item.id))
const seen = [selectedWikidataSingle]
for (const id in selectedMany) {
if (selectedMany[id]) {
const item = previouslySeen.get(id)
seen.push(item)
}
}
return Utils.NoNull(seen).filter(i => !knownIds.has(i.id))
})
</script>
<h3>
<Tr t={Translations.t.general.wikipedia.searchWikidata} />
</h3>
<form>
<SearchField {searchValue} placeholderText={placeholder}></SearchField>
{#if $searchValue.trim().length === 0}
<Tr cls="w-full flex justify-center p-4" t={ t.doSearch} />
{:else if $searchValue.trim().length < 3}
<Tr t={ t.searchToShort} />
{:else if $searchResult === undefined}
<div class="w-full flex justify-center p-4">
<Loading>
<Tr t={Translations.t.general.loading} />
</Loading>
</div>
{:else if $searchResult.error !== undefined}
<div class="w-full flex justify-center p-4">
<Tr cls="alert" t={t.failed} />
</div>
{:else if $searchResult.success}
{#if $searchResult.success.length === 0}
<Tr cls="w-full flex justify-center p-4" t={ t.noResults.Subs({search: $searchValue})} />
{:else}
{#each $searchResult.success as wikidata}
<label class="low-interaction m-4 p-2 rounded-xl flex items-center">
{#if allowMultiple}
<input type="checkbox" bind:checked={selectedMany[wikidata.id]} />
{:else}
<input type="radio" name="selectedWikidata" value={wikidata} bind:group={selectedWikidataSingle} />
{/if}
<Wikidatapreview {wikidata} />
</label>
{/each}
{/if}
{/if}
{#each $selectedWithoutSearch as wikidata}
<label class="low-interaction m-4 p-2 rounded-xl flex items-center">
{#if allowMultiple}
<input type="checkbox" bind:checked={selectedMany[wikidata.id]} />
{:else}
<input type="radio" name="selectedWikidata" value={wikidata} bind:group={selectedWikidataSingle} />
{/if}
<Wikidatapreview {wikidata} />
</label>
{/each}
</form>

View file

@ -19,6 +19,8 @@
import OpeningHoursInput from "./Helpers/OpeningHoursInput.svelte"
import SlopeInput from "./Helpers/SlopeInput.svelte"
import type { SpecialVisualizationState } from "../SpecialVisualization"
import WikidataInput from "./Helpers/WikidataInput.svelte"
import WikidataInputHelper from "./WikidataInputHelper.svelte"
export let type: ValidatorType
export let value: UIEventSource<string | object>
@ -26,17 +28,13 @@
export let feature: Feature
export let args: (string | number | boolean)[] = undefined
export let state: SpecialVisualizationState
export let helperArgs: (string | number | boolean)[]
export let key: string
export let extraTags: UIEventSource<Record<string, string>>
let properties = { feature, args: args ?? [] }
</script>
{#if type === "translation"}
<TranslationInput {value} on:submit {args} />
{:else if type === "direction"}
<DirectionInput {value} mapProperties={InputHelpers.constructMapProperties(properties)} />
<DirectionInput {value} mapProperties={InputHelpers.constructMapProperties( { feature, args: args ?? [] })} />
{:else if type === "date"}
<DateInput {value} />
{:else if type === "color"}
@ -52,5 +50,5 @@
{:else if type === "slope"}
<SlopeInput {value} {feature} {state} />
{:else if type === "wikidata"}
<ToSvelte construct={() => InputHelpers.constructWikidataHelper(value, properties)} />
<WikidataInputHelper {value} {feature} {state} {args}/>
{/if}

View file

@ -1,10 +1,6 @@
import { UIEventSource } from "../../Logic/UIEventSource"
import { MapProperties } from "../../Models/MapProperties"
import WikidataSearchBox from "../Wikipedia/WikidataSearchBox"
import Wikidata from "../../Logic/Web/Wikidata"
import { Utils } from "../../Utils"
import Locale from "../i18n/Locale"
import { Feature } from "geojson"
import { GeoOperations } from "../../Logic/GeoOperations"
@ -68,67 +64,5 @@ export default class InputHelpers {
return mapProperties
}
public static constructWikidataHelper(
value: UIEventSource<string>,
props: InputHelperProperties
) {
const inputHelperOptions = props
const args = inputHelperOptions.args ?? []
const searchKey: string = <string>args[0] ?? "name"
const searchFor: string =
searchKey
.split(";")
.map((k) => inputHelperOptions.feature?.properties[k]?.toLowerCase())
.find((foundValue) => !!foundValue) ?? ""
let searchForValue: UIEventSource<string> = new UIEventSource(searchFor)
const options: any = args[1]
if (searchFor !== undefined && options !== undefined) {
const prefixes = <string[] | Record<string, string[]>>options["removePrefixes"] ?? []
const postfixes = <string[] | Record<string, string[]>>options["removePostfixes"] ?? []
const defaultValueCandidate = Locale.language.map((lg) => {
const prefixesUnrwapped: RegExp[] = (
Array.isArray(prefixes) ? prefixes : prefixes[lg] ?? []
).map((s) => new RegExp("^" + s, "i"))
const postfixesUnwrapped: RegExp[] = (
Array.isArray(postfixes) ? postfixes : postfixes[lg] ?? []
).map((s) => new RegExp(s + "$", "i"))
let clipped = searchFor
for (const postfix of postfixesUnwrapped) {
const match = searchFor.match(postfix)
if (match !== null) {
clipped = searchFor.substring(0, searchFor.length - match[0].length)
break
}
}
for (const prefix of prefixesUnrwapped) {
const match = searchFor.match(prefix)
if (match !== null) {
clipped = searchFor.substring(match[0].length)
break
}
}
return clipped
})
defaultValueCandidate.addCallbackAndRun((clipped) => searchForValue.setData(clipped))
}
let instanceOf: number[] = Utils.NoNull(
(options?.instanceOf ?? []).map((i) => Wikidata.QIdToNumber(i))
)
let notInstanceOf: number[] = Utils.NoNull(
(options?.notInstanceOf ?? []).map((i) => Wikidata.QIdToNumber(i))
)
return new WikidataSearchBox({
value,
searchText: searchForValue,
instanceOf,
notInstanceOf,
})
}
}

View file

@ -1,13 +1,101 @@
import Combine from "../../Base/Combine"
import Wikidata from "../../../Logic/Web/Wikidata"
import Wikidata, { WikidataResponse } from "../../../Logic/Web/Wikidata"
import WikidataSearchBox from "../../Wikipedia/WikidataSearchBox"
import { Validator } from "../Validator"
import { Translation } from "../../i18n/Translation"
import Translations from "../../i18n/Translations"
import Title from "../../Base/Title"
import Table from "../../Base/Table"
export default class WikidataValidator extends Validator {
public static readonly _searchCache = new Map<string, Promise<WikidataResponse[]>>()
public static docs = new Combine([
new Title("Helper arguments"),
new Table(
["name", "doc"],
[
[
"key",
"the value of this tag will initialize search (default: name). This can be a ';'-separated list in which case every key will be inspected. The non-null value will be used as search",
],
[
"options",
new Combine([
"A JSON-object of type `{ removePrefixes: string[], removePostfixes: string[] }`.",
new Table(
["subarg", "doc"],
[
[
"removePrefixes",
"remove these snippets of text from the start of the passed string to search. This is either a list OR a hash of languages to a list. The individual strings are interpreted as case ignoring regexes",
],
[
"removePostfixes",
"remove these snippets of text from the end of the passed string to search. This is either a list OR a hash of languages to a list. The individual strings are interpreted as case ignoring regexes.",
],
[
"instanceOf",
"A list of Q-identifier which indicates that the search results _must_ be an entity of this type, e.g. [`Q5`](https://www.wikidata.org/wiki/Q5) for humans",
],
[
"notInstanceof",
"A list of Q-identifiers which indicates that the search results _must not_ be an entity of this type, e.g. [`Q79007`](https://www.wikidata.org/wiki/Q79007) to filter away all streets from the search results",
],
["multiple",
"If 'yes' or 'true', will allow to select multiple values at once"]
]
),
]),
],
]
),
new Title("Example usage"),
`The following is the 'freeform'-part of a layer config which will trigger a search for the wikidata item corresponding with the name of the selected feature. It will also remove '-street', '-square', ... if found at the end of the name
\`\`\`json
"freeform": {
"key": "name:etymology:wikidata",
"type": "wikidata",
"helperArgs": [
"name",
{
"removePostfixes": {"en": [
"street",
"boulevard",
"path",
"square",
"plaza",
],
"nl": ["straat","plein","pad","weg",laan"],
"fr":["route (de|de la|de l'| de le)"]
},
"#": "Remove streets and parks from the search results:"
"notInstanceOf": ["Q79007","Q22698"]
}
]
}
\`\`\`
Another example is to search for species and trees:
\`\`\`json
"freeform": {
"key": "species:wikidata",
"type": "wikidata",
"helperArgs": [
"species",
{
"instanceOf": [10884, 16521]
}]
}
\`\`\`
`,
])
constructor() {
super("wikidata", new Combine(["A wikidata identifier, e.g. Q42.", WikidataSearchBox.docs]))
super("wikidata", new Combine(["A wikidata identifier, e.g. Q42.", WikidataValidator.docs]))
}
public isValid(str): boolean {
@ -44,4 +132,48 @@ export default class WikidataValidator extends Validator {
}
return out
}
/**
*
* @param searchTerm
* @param postfixesToRemove
* @param prefixesToRemove
* @param language
*
*
* WikidataValidator.removePostAndPrefixes("Elf-Julistraat", [], ["straat", "laan"], "nl") // => "Elf-Juli"
* WikidataValidator.removePostAndPrefixes("Elf-Julistraat", [], {"nl":["straat", "laan"], "en": ["street"]}, "nl") // => "Elf-Juli"
* WikidataValidator.removePostAndPrefixes("Elf-Julistraat", [], {"nl":["straat", "laan"], "en": ["street"]}, "en") // => "Elf-Julistraat"
*/
public static removePostAndPrefixes(searchTerm: string, prefixesToRemove: string[] | Record<string, string[]>, postfixesToRemove: string[] | Record<string, string[]>, language: string): string {
const prefixes = prefixesToRemove
const postfixes = postfixesToRemove
const prefixesUnwrapped: RegExp[] = (
Array.isArray(prefixes) ? prefixes : prefixes[language] ?? []
).map((s) => new RegExp("^" + s, "i"))
const postfixesUnwrapped: RegExp[] = (
Array.isArray(postfixes) ? postfixes : postfixes[language] ?? []
).map((s) => new RegExp(s + "$", "i"))
let clipped = searchTerm.trim()
for (const postfix of postfixesUnwrapped) {
const match = searchTerm.trim().match(postfix)
if (match !== null) {
clipped = searchTerm.trim().substring(0, searchTerm.trim().length - match[0].length)
break
}
}
for (const prefix of prefixesUnwrapped) {
const match = searchTerm.trim().match(prefix)
if (match !== null) {
clipped = searchTerm.trim().substring(match[0].length)
break
}
}
return clipped
}
}

View file

@ -0,0 +1,58 @@
<script lang="ts">/**
*
Wrapper around 'WikidataInput.svelte' which handles the arguments
*/
import { UIEventSource } from "../../Logic/UIEventSource"
import Locale from "../i18n/Locale"
import { Utils } from "../../Utils"
import Wikidata from "../../Logic/Web/Wikidata"
import WikidataInput from "./Helpers/WikidataInput.svelte"
import type { Feature } from "geojson"
import { onDestroy } from "svelte"
import WikidataValidator from "./Validators/WikidataValidator"
export let args: (string | number | boolean)[] = []
export let feature: Feature
export let value: UIEventSource<string>
let searchKey: string = <string>args[0] ?? "name"
let searchFor: string =
searchKey
.split(";")
.map((k) => feature?.properties[k]?.toLowerCase())
.find((foundValue) => !!foundValue) ?? ""
const options: any = args[1]
console.log(">>>", args)
let searchForValue: UIEventSource<string> = new UIEventSource(searchFor)
onDestroy(
Locale.language.addCallbackAndRunD(lg => {
console.log(options)
if (searchFor !== undefined && options !== undefined) {
const post = options["removePostfixes"] ?? []
const pre = options["removePrefixes"] ?? []
const clipped = WikidataValidator.removePostAndPrefixes(searchFor, pre, post, lg)
console.log("Got clipped value:", clipped, post, pre)
searchForValue.setData(clipped)
}
})
)
let instanceOf: number[] = Utils.NoNull(
(options?.instanceOf ?? []).map((i) => Wikidata.QIdToNumber(i))
)
let notInstanceOf: number[] = Utils.NoNull(
(options?.notInstanceOf ?? []).map((i) => Wikidata.QIdToNumber(i))
)
let allowMultipleArg = options?.["multiple"]
let allowMultiple = allowMultipleArg === "yes" || (""+allowMultipleArg) === "true"
</script>
<WikidataInput searchValue={searchForValue} {value} {instanceOf} {notInstanceOf} {allowMultiple}/>

View file

@ -4,14 +4,13 @@
*/
import { createEventDispatcher } from "svelte"
import type { PlantNetSpeciesMatch } from "../../Logic/Web/PlantNet"
import { UIEventSource } from "../../Logic/UIEventSource"
import { Store, UIEventSource } from "../../Logic/UIEventSource"
import Wikidata from "../../Logic/Web/Wikidata"
import NextButton from "../Base/NextButton.svelte"
import Loading from "../Base/Loading.svelte"
import WikidataPreviewBox from "../Wikipedia/WikidataPreviewBox"
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
import ToSvelte from "../Base/ToSvelte.svelte"
import WikidatapreviewWithLoading from "../Wikipedia/WikidatapreviewWithLoading.svelte"
export let species: PlantNetSpeciesMatch
let wikidata = UIEventSource.FromPromise(
@ -46,16 +45,12 @@
/>
</Loading>
{:else}
<ToSvelte
construct={() =>
new WikidataPreviewBox(wikidataId, {
imageStyle: "max-width: 8rem; width: unset; height: 8rem",
extraItems: [
t.matchPercentage
.Subs({ match: Math.round(species.score * 100) })
.SetClass("thanks w-fit self-center"),
],
}).SetClass("w-full")}
/>
<WikidatapreviewWithLoading wikidataId={wikidataId} imageStyle="max-width: 8rem; width: unset; height: 8rem">
<div slot="extra">
<Tr cls="thanks w-fit self-center" t={ t.matchPercentage
.Subs({ match: Math.round(species.score * 100) })}/>
</div>
</WikidatapreviewWithLoading>
{/if}
</NextButton>

View file

@ -15,7 +15,6 @@
export let unvalidatedText: UIEventSource<string> = new UIEventSource<string>(value.data)
export let config: TagRenderingConfig
export let tags: UIEventSource<Record<string, string>>
export let extraTags: UIEventSource<Record<string, string>>
export let feature: Feature = undefined
export let state: SpecialVisualizationState
@ -28,8 +27,6 @@
inline = false
inline = config.freeform?.inline
}
let helperArgs = config.freeform?.helperArgs
let key = config.freeform?.key
const dispatch = createEventDispatcher<{ selected }>()
export let feedback: UIEventSource<Translation>
@ -73,14 +70,11 @@
{/if}
<InputHelper
args={config.freeform.helperArgs}
args={config.freeform.args}
{feature}
type={config.freeform.type}
{value}
{state}
{helperArgs}
{key}
{extraTags}
on:submit
/>
</div>

View file

@ -1,34 +1,21 @@
import { VariableUiElement } from "../Base/VariableUIElement"
import { Store } from "../../Logic/UIEventSource"
import Wikidata, { WikidataResponse } from "../../Logic/Web/Wikidata"
import { Translation, TypedTranslation } from "../i18n/Translation"
import { FixedUiElement } from "../Base/FixedUiElement"
import Loading from "../Base/Loading"
import { TypedTranslation } from "../i18n/Translation"
import Translations from "../i18n/Translations"
import Combine from "../Base/Combine"
import Img from "../Base/Img"
import { WikimediaImageProvider } from "../../Logic/ImageProviders/WikimediaImageProvider"
import Link from "../Base/Link"
import BaseUIElement from "../BaseUIElement"
import { Utils } from "../../Utils"
import SvelteUIElement from "../Base/SvelteUIElement"
import { default as Wikidata_icon } from "../../assets/svg/Wikidata.svelte"
import Gender_male from "../../assets/svg/Gender_male.svelte"
import Gender_female from "../../assets/svg/Gender_female.svelte"
import Gender_inter from "../../assets/svg/Gender_inter.svelte"
import Gender_trans from "../../assets/svg/Gender_trans.svelte"
import Gender_queer from "../../assets/svg/Gender_queer.svelte"
export default class WikidataPreviewBox extends VariableUiElement {
export default class WikidataPreviewBox {
private static isHuman = [{ p: 31 /*is a*/, q: 5 /* human */ }]
// @ts-ignore
private static extraProperties: {
public static extraProperties: {
requires?: { p: number; q?: number }[]
property: string
textMode?: Map<string, string>
display:
| TypedTranslation<{ value }>
| Map<string, string | (() => BaseUIElement) /*If translation: Subs({value: * }) */>
textMode?: Map<string, string>
| Map<string, any>,
}[] = [
{
requires: WikidataPreviewBox.isHuman,
@ -36,34 +23,28 @@ export default class WikidataPreviewBox extends VariableUiElement {
display: new Map([
[
"Q6581097",
() => new SvelteUIElement(Gender_male).SetStyle("width: 1rem; height: auto"),
Gender_male
],
[
"Q6581072",
() => new SvelteUIElement(Gender_female).SetStyle("width: 1rem; height: auto"),
Gender_female
],
[
"Q1097630",
() => new SvelteUIElement(Gender_inter).SetStyle("width: 1rem; height: auto"),
Gender_inter
],
[
"Q1052281",
() =>
new SvelteUIElement(Gender_trans).SetStyle(
"width: 1rem; height: auto"
) /*'transwomen'*/,
Gender_trans /*'transwomen'*/
],
[
"Q2449503",
() =>
new SvelteUIElement(Gender_trans).SetStyle(
"width: 1rem; height: auto"
) /*'transmen'*/,
Gender_trans /*'transmen'*/
],
[
"Q48270",
() => new SvelteUIElement(Gender_queer).SetStyle("width: 1rem; height: auto"),
],
Gender_queer
]
]),
textMode: new Map([
["Q6581097", "♂️"],
@ -71,158 +52,19 @@ export default class WikidataPreviewBox extends VariableUiElement {
["Q1097630", "⚥️"],
["Q1052281", "🏳️‍⚧️" /*'transwomen'*/],
["Q2449503", "🏳️‍⚧️" /*'transmen'*/],
["Q48270", "🏳️‍🌈 ⚧"],
]),
["Q48270", "🏳️‍🌈 ⚧"]
])
},
{
property: "P569",
requires: WikidataPreviewBox.isHuman,
display: Translations.t.general.wikipedia.previewbox.born,
display: Translations.t.general.wikipedia.previewbox.born
},
{
property: "P570",
requires: WikidataPreviewBox.isHuman,
display: Translations.t.general.wikipedia.previewbox.died,
},
display: Translations.t.general.wikipedia.previewbox.died
}
]
constructor(
wikidataId: Store<string>,
options?: {
noImages?: boolean
imageStyle?: string
whileLoading?: BaseUIElement | string
extraItems?: (BaseUIElement | string)[]
}
) {
let inited = false
const wikidata = wikidataId.stabilized(250).bind((id) => {
if (id === undefined || id === "" || id === "Q") {
return null
}
inited = true
return Wikidata.LoadWikidataEntry(id)
})
super(
wikidata.map((maybeWikidata) => {
if (maybeWikidata === null || !inited) {
return options?.whileLoading
}
if (maybeWikidata === undefined) {
return new Loading(Translations.t.general.loading)
}
if (maybeWikidata["error"] !== undefined) {
return new FixedUiElement(maybeWikidata["error"]).SetClass("alert")
}
const wikidata = <WikidataResponse>maybeWikidata["success"]
console.log(">>>> got wikidata", wikidata)
return WikidataPreviewBox.WikidataResponsePreview(wikidata, options)
})
)
}
public static WikidataResponsePreview(
wikidata: WikidataResponse,
options?: {
noImages?: boolean
imageStyle?: string
extraItems?: (BaseUIElement | string)[]
}
): BaseUIElement {
console.log(">>> constructing wikidata preview box", wikidata.labels)
const link = new Link(
new Combine([
wikidata.id,
options?.noImages
? wikidata.id
: new SvelteUIElement(Wikidata_icon)
.SetStyle("width: 2.5rem")
.SetClass("block"),
]).SetClass("flex"),
Wikidata.IdToArticle(wikidata.id),
true
)?.SetClass("must-link")
let info = new Combine([
new Combine([
Translation.fromMap(wikidata.labels)?.SetClass("font-bold"),
link,
]).SetClass("flex justify-between flex-wrap-reverse"),
Translation.fromMap(wikidata.descriptions, true),
WikidataPreviewBox.QuickFacts(wikidata, options),
...(options?.extraItems ?? []),
]).SetClass("flex flex-col link-underline")
let imageUrl = undefined
if (wikidata.claims.get("P18")?.size > 0) {
imageUrl = Array.from(wikidata.claims.get("P18"))[0]
}
if (imageUrl && !options?.noImages) {
imageUrl = WikimediaImageProvider.singleton.PrepUrl(imageUrl).url
info = new Combine([
new Img(imageUrl)
.SetStyle(options?.imageStyle ?? "max-width: 5rem; width: unset; height: 4rem")
.SetClass("rounded-xl mr-2"),
info.SetClass("w-full"),
]).SetClass("flex")
}
info.SetClass("p-2 w-full")
return info
}
public static QuickFacts(
wikidata: WikidataResponse,
options?: { noImages?: boolean }
): BaseUIElement {
const els: BaseUIElement[] = []
for (const extraProperty of WikidataPreviewBox.extraProperties) {
let hasAllRequirements = true
for (const requirement of extraProperty.requires) {
if (!wikidata.claims?.has("P" + requirement.p)) {
hasAllRequirements = false
break
}
if (!wikidata.claims?.get("P" + requirement.p).has("Q" + requirement.q)) {
hasAllRequirements = false
break
}
}
if (!hasAllRequirements) {
continue
}
const key = extraProperty.property
const display =
(options?.noImages ? extraProperty.textMode : extraProperty.display) ??
extraProperty.display
if (wikidata.claims?.get(key) === undefined) {
continue
}
const value: string[] = Array.from(wikidata.claims.get(key))
if (display instanceof Translation) {
els.push(display.Subs({ value: value.join(", ") }).SetClass("m-2"))
continue
}
const constructors = Utils.NoNull(value.map((property) => display.get(property)))
const elems = constructors.map((v) => {
if (typeof v === "string") {
return new FixedUiElement(v)
} else {
return v()
}
})
els.push(new Combine(elems).SetClass("flex m-2"))
}
if (els.length === 0) {
return undefined
}
return new Combine(els).SetClass("flex")
}
}

View file

@ -0,0 +1,49 @@
<script lang="ts">
import { Translation } from "../i18n/Translation"
import WikidataPreviewBox from "./WikidataPreviewBox"
import { WikidataResponse } from "../../Logic/Web/Wikidata"
import Tr from "../Base/Tr.svelte"
export let wikidata: WikidataResponse
let propertiesToRender = WikidataPreviewBox.extraProperties.filter(property => {
for (const requirement of property.requires) {
if (!wikidata.claims?.has("P" + requirement.p)) {
return false
}
if (!wikidata.claims?.get("P" + requirement.p).has("Q" + requirement.q)) {
return false
}
const key = property.property
if (wikidata.claims?.get(key) === undefined) {
return false
}
return true
}
})
function getProperty(property: {property: string}){
const key = property.property
const value = Array.from(wikidata.claims?.get(key)).join(", ")
return value
}
</script>
{#if propertiesToRender.length > 0}
<div class="flex justify-start items-center">
{#each propertiesToRender as property}
{#if typeof property.display === "string" }
{property.display}
{:else if property.display instanceof Translation}
<Tr cls="m-2 shrink-0"
t={property.display.Subs({value: getProperty(property)}) } />
{:else}
<svelte:component this={property.display.get(getProperty(property))} class="h-6 w-fit m-1"/>
{/if}
{/each}
</div>
{/if}

View file

@ -1,223 +0,0 @@
import Combine from "../Base/Combine"
import { InputElement } from "../Input/InputElement"
import { TextField } from "../Input/TextField"
import Translations from "../i18n/Translations"
import { ImmutableStore, Store, Stores, UIEventSource } from "../../Logic/UIEventSource"
import Wikidata, { WikidataResponse } from "../../Logic/Web/Wikidata"
import Locale from "../i18n/Locale"
import { VariableUiElement } from "../Base/VariableUIElement"
import WikidataPreviewBox from "./WikidataPreviewBox"
import Title from "../Base/Title"
import Svg from "../../Svg"
import Loading from "../Base/Loading"
import Table from "../Base/Table"
import SvelteUIElement from "../Base/SvelteUIElement"
import Search from "../../assets/svg/Search.svelte"
export default class WikidataSearchBox extends InputElement<string> {
public static docs = new Combine([
new Title("Helper arguments"),
new Table(
["name", "doc"],
[
[
"key",
"the value of this tag will initialize search (default: name). This can be a ';'-separated list in which case every key will be inspected. The non-null value will be used as search",
],
[
"options",
new Combine([
"A JSON-object of type `{ removePrefixes: string[], removePostfixes: string[] }`.",
new Table(
["subarg", "doc"],
[
[
"removePrefixes",
"remove these snippets of text from the start of the passed string to search. This is either a list OR a hash of languages to a list. The individual strings are interpreted as case ignoring regexes",
],
[
"removePostfixes",
"remove these snippets of text from the end of the passed string to search. This is either a list OR a hash of languages to a list. The individual strings are interpreted as case ignoring regexes.",
],
[
"instanceOf",
"A list of Q-identifier which indicates that the search results _must_ be an entity of this type, e.g. [`Q5`](https://www.wikidata.org/wiki/Q5) for humans",
],
[
"notInstanceof",
"A list of Q-identifiers which indicates that the search results _must not_ be an entity of this type, e.g. [`Q79007`](https://www.wikidata.org/wiki/Q79007) to filter away all streets from the search results",
],
]
),
]),
],
]
),
new Title("Example usage"),
`The following is the 'freeform'-part of a layer config which will trigger a search for the wikidata item corresponding with the name of the selected feature. It will also remove '-street', '-square', ... if found at the end of the name
\`\`\`json
"freeform": {
"key": "name:etymology:wikidata",
"type": "wikidata",
"helperArgs": [
"name",
{
"removePostfixes": {"en": [
"street",
"boulevard",
"path",
"square",
"plaza",
],
"nl": ["straat","plein","pad","weg",laan"],
"fr":["route (de|de la|de l'| de le)"]
},
"#": "Remove streets and parks from the search results:"
"notInstanceOf": ["Q79007","Q22698"]
}
]
}
\`\`\`
Another example is to search for species and trees:
\`\`\`json
"freeform": {
"key": "species:wikidata",
"type": "wikidata",
"helperArgs": [
"species",
{
"instanceOf": [10884, 16521]
}]
}
\`\`\`
`,
])
private static readonly _searchCache = new Map<string, Promise<WikidataResponse[]>>()
private readonly wikidataId: UIEventSource<string>
private readonly searchText: UIEventSource<string>
private readonly instanceOf?: number[]
private readonly notInstanceOf?: number[]
constructor(options?: {
searchText?: UIEventSource<string>
value?: UIEventSource<string>
notInstanceOf?: number[]
instanceOf?: number[]
}) {
super()
this.searchText = options?.searchText
this.wikidataId = options?.value ?? new UIEventSource<string>(undefined)
this.instanceOf = options?.instanceOf
this.notInstanceOf = options?.notInstanceOf
}
GetValue(): UIEventSource<string> {
return this.wikidataId
}
IsValid(t: string): boolean {
return t.startsWith("Q") && !isNaN(Number(t.substring(1)))
}
protected InnerConstructElement(): HTMLElement {
const searchField = new TextField({
placeholder: Translations.t.general.wikipedia.searchWikidata,
value: this.searchText,
inputStyle: "width: calc(100% - 0.5rem); border: 1px solid black",
})
const selectedWikidataId = this.wikidataId
const tooShort = new ImmutableStore<{ success: WikidataResponse[] }>({ success: undefined })
const searchResult: Store<{ success?: WikidataResponse[]; error?: any }> = searchField
.GetValue()
.bind((searchText) => {
if (searchText.length < 3 && !searchText.match(/[qQ][0-9]+/)) {
return tooShort
}
const lang = Locale.language.data
const key = lang + ":" + searchText
let promise = WikidataSearchBox._searchCache.get(key)
if (promise === undefined) {
promise = Wikidata.searchAndFetch(searchText, {
lang,
maxCount: 5,
notInstanceOf: this.notInstanceOf,
instanceOf: this.instanceOf,
})
WikidataSearchBox._searchCache.set(key, promise)
}
return Stores.FromPromiseWithErr(promise)
})
const previews = new VariableUiElement(
searchResult.map(
(searchResultsOrFail) => {
if (searchField.GetValue().data.length === 0) {
return Translations.t.general.wikipedia.doSearch
}
if (searchField.GetValue().data.length < 3) {
return Translations.t.general.wikipedia.searchToShort
}
if (searchResultsOrFail === undefined) {
return new Loading(Translations.t.general.loading)
}
if (searchResultsOrFail.error !== undefined) {
return new Combine([
Translations.t.general.wikipedia.failed.Clone().SetClass("alert"),
searchResultsOrFail.error,
])
}
const searchResults = searchResultsOrFail.success
if (searchResults.length === 0) {
return Translations.t.general.wikipedia.noResults.Subs({
search: searchField.GetValue().data ?? "",
})
}
return new Combine(
searchResults.map((wikidataresponse) => {
const el = WikidataPreviewBox.WikidataResponsePreview(
wikidataresponse
).SetClass(
"rounded-xl p-1 sm:p-2 md:p-3 m-px border-2 sm:border-4 transition-colors"
)
el.onClick(() => {
selectedWikidataId.setData(wikidataresponse.id)
})
selectedWikidataId.addCallbackAndRunD((selected) => {
if (selected === wikidataresponse.id) {
el.SetClass("subtle-background border-attention")
} else {
el.RemoveClass("subtle-background")
el.RemoveClass("border-attention")
}
})
return el
})
).SetClass("flex flex-col")
},
[searchField.GetValue()]
)
)
return new Combine([
new Title(Translations.t.general.wikipedia.searchWikidata, 3).SetClass("m-2"),
new Combine([
new SvelteUIElement(Search).SetClass("w-6"),
searchField.SetClass("m-2 w-full"),
]).SetClass("flex"),
previews,
])
.SetClass("flex flex-col border-2 border-black rounded-xl m-2 p-2")
.ConstructElement()
}
}

View file

@ -0,0 +1,42 @@
<script lang="ts">
import Wikidata, { WikidataResponse } from "../../Logic/Web/Wikidata"
import { Translation } from "../i18n/Translation"
import { WikimediaImageProvider } from "../../Logic/ImageProviders/WikimediaImageProvider"
import { default as Wikidata_icon } from "../../assets/svg/Wikidata.svelte"
import WikidataQuickfacts from "./WikidataQuickfacts.svelte"
import Tr from "../Base/Tr.svelte"
export let wikidata: WikidataResponse
export let imageStyle: string = "max-width: 5rem; width: unset; height: 4rem"
let imageProperty: string | undefined = Array.from(wikidata?.claims?.get("P18") ?? [])[0]
let imageUrl = WikimediaImageProvider.singleton.PrepUrl(imageProperty)?.url
</script>
<div class="flex w-full p-2">
{#if imageUrl}
<img src={imageUrl} style={imageStyle} class="mr-2" />
{/if}
<div class="flex flex-col w-full">
<div class="flex w-full justify-between">
<Tr cls="font-bold" t={ Translation.fromMap(wikidata.labels) } />
<a href={Wikidata.IdToArticle(wikidata.id)} target="_blank" class="flex must-link items-center">
<Wikidata_icon class="w-10" /> {wikidata.id}
</a>
</div>
<Tr t={ Translation.fromMap(wikidata.descriptions, true)} />
<div class="flex">
<WikidataQuickfacts {wikidata} />
</div>
<slot name="extra"></slot>
</div>
</div>

View file

@ -0,0 +1,35 @@
<script lang="ts">
import Wikidata, { WikidataResponse } from "../../Logic/Web/Wikidata"
import Translations from "../i18n/Translations"
import { FixedUiElement } from "../Base/FixedUiElement"
import { Store } from "../../Logic/UIEventSource"
import Wikidatapreview from "./Wikidatapreview.svelte"
import Tr from "../Base/Tr.svelte"
import Loading from "../Base/Loading.svelte"
export let wikidataId: Store<string>
export let imageStyle: string = undefined
let wikidata: Store<{ success: WikidataResponse } | { error: any }> = wikidataId.stabilized(100).bind((id) => {
if (id === undefined || id === "" || id === "Q") {
return null
}
return Wikidata.LoadWikidataEntry(id)
})
</script>
{#if $wikidata === undefined}
<Loading>
<Tr t={Translations.t.general.loading} />
</Loading>
{:else if $wikidata["error"]}
<div class="alert">
{$wikidata["error"]}
</div>
{:else}
<Wikidatapreview {imageStyle} wikidata={$wikidata["success"]}>
<slot name="extra" slot="extra" />
</Wikidatapreview>
{/if}

View file

@ -10,6 +10,7 @@
import Tr from "../Base/Tr.svelte"
import Translations from "../i18n/Translations"
import Wikipedia from "../../assets/svg/Wikipedia.svelte"
import Wikidatapreview from "./Wikidatapreview.svelte"
/**
* Shows a wikipedia-article + wikidata preview for the given item
@ -31,9 +32,7 @@
{/if}
{#if $wikipediaDetails.wikidata}
<ToSvelte
construct={() => WikidataPreviewBox.WikidataResponsePreview($wikipediaDetails.wikidata)}
/>
<Wikidatapreview wikidata={$wikipediaDetails.wikidata} />
{/if}
{#if $wikipediaDetails.articleUrl}

View file

@ -26,9 +26,7 @@ export class Translation extends BaseUIElement {
) {
super()
this._strictLanguages = strictLanguages
if (strictLanguages) {
console.log(">>> strict:", translations)
}
if (translations === undefined) {
console.error("Translation without content at " + context)
throw `Translation without content (${context})`
@ -138,7 +136,6 @@ export class Translation extends BaseUIElement {
static fromMap(transl: Map<string, string>, strictLanguages: boolean = false) {
const translations = {}
console.log("Strict:", strictLanguages)
let hasTranslation = false
transl?.forEach((value, key) => {
translations[key] = value