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

@ -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}/>