From 18391ba95c619d80495e48416a1f08797b6f5887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Olov=20Ylinenp=C3=A4=C3=A4?= <51744858+olovy@users.noreply.github.com> Date: Fri, 26 Jan 2024 13:03:44 +0100 Subject: [PATCH] Generate display decorated data (#943) A first stab at generating "display decorated" data Changes * Add utility classes VocabUtil and DisplayUtil that wrap vocab, context and display data and operations * Make singletons of these preloaded with vocab/display data available on the server side * Option to load display.jsonld from local filesystem definitions repo (placed beside lxlvewer repo) for testing * USE_LOCAL_DISPLAY_JSONLD=true * Data structure for search results * Example of using decorated data in entity page and search page * Rename /search route to /find. So that it matches XL search API) * Token for e.g. Identifier "<@type> " e.g . "ISBN 9783833148408" * ISNI/ORCID formatting TODO: * TESTS (I postponed this until we're happy with the design of the decorate data) * "mappings" in SearchResult, i.e. representation of the active query * transliterated values (e.g. titles) in language containers * type coercion (code, langCode, langCodeFull -> code^^ISO639-2, code^^ISO639-3 etc.) * fresnel:allProperties? We can split/move/rename "xl.ts" (e.g. to lxljs) when it has become more stable --- lxl-web/.env.example | 1 + lxl-web/package-lock.json | 1 + lxl-web/src/hooks.server.ts | 42 + lxl-web/src/lib/components/Search.svelte | 2 +- lxl-web/src/lib/utils/xl.ts | 788 ++++++++++++++++++ .../[fnurgel=fnurgel]/+page.server.ts | 32 +- .../[fnurgel=fnurgel]/+page.svelte | 8 +- .../(app)/[[lang=lang]]/find/+page.server.ts | 20 + .../(app)/[[lang=lang]]/find/+page.svelte | 51 ++ .../routes/(app)/[[lang=lang]]/find/search.ts | 183 ++++ .../[[lang=lang]]/search/+page.server.ts | 20 - .../(app)/[[lang=lang]]/search/+page.svelte | 16 - lxljs/package.json | 3 +- 13 files changed, 1121 insertions(+), 46 deletions(-) create mode 100644 lxl-web/src/lib/utils/xl.ts create mode 100644 lxl-web/src/routes/(app)/[[lang=lang]]/find/+page.server.ts create mode 100644 lxl-web/src/routes/(app)/[[lang=lang]]/find/+page.svelte create mode 100644 lxl-web/src/routes/(app)/[[lang=lang]]/find/search.ts delete mode 100644 lxl-web/src/routes/(app)/[[lang=lang]]/search/+page.server.ts delete mode 100644 lxl-web/src/routes/(app)/[[lang=lang]]/search/+page.svelte diff --git a/lxl-web/.env.example b/lxl-web/.env.example index e74308e8e..0dcc6ce2b 100644 --- a/lxl-web/.env.example +++ b/lxl-web/.env.example @@ -1,3 +1,4 @@ SVELTE_INSPECTOR_TOGGLE=shift-alt API_URL= ID_URL= +USE_LOCAL_DISPLAY_JSONLD=false diff --git a/lxl-web/package-lock.json b/lxl-web/package-lock.json index de54f6d08..b45ce1b14 100644 --- a/lxl-web/package-lock.json +++ b/lxl-web/package-lock.json @@ -40,6 +40,7 @@ "dev": true, "license": "Apache-2.0", "dependencies": { + "@types/node": "18.18.2", "eslint": "^7.32.0", "eslint-config-airbnb-base": "^14.2.1", "eslint-plugin-import": "^2.25.2", diff --git a/lxl-web/src/hooks.server.ts b/lxl-web/src/hooks.server.ts index af8e13933..fa915b588 100644 --- a/lxl-web/src/hooks.server.ts +++ b/lxl-web/src/hooks.server.ts @@ -1,6 +1,16 @@ import { defaultLocale, Locales } from '$lib/i18n/locales'; +import { USE_LOCAL_DISPLAY_JSONLD } from '$env/static/private'; +import { env } from '$env/dynamic/private'; +import { DisplayUtil, VocabUtil } from '$lib/utils/xl'; +import fs from 'fs'; + +let utilCache; export const handle = async ({ event, resolve }) => { + const [vocabUtil, displayUtil] = await loadUtilCached(); + event.locals.vocab = vocabUtil; + event.locals.display = displayUtil; + // set HTML lang // https://github.com/sveltejs/kit/issues/3091#issuecomment-1112589090 const path = event.url.pathname; @@ -16,3 +26,35 @@ export const handle = async ({ event, resolve }) => { transformPageChunk: ({ html }) => html.replace('%lang%', lang) }); }; + +async function loadUtilCached() { + if (!utilCache) { + utilCache = loadUtil(); + } + return utilCache; +} + +// TODO move +// TODO error handling +async function loadUtil() { + const [contextRes, vocabRes, displayRes] = await Promise.all([ + fetch(`${env.ID_URL}/context.jsonld`), + fetch(`${env.ID_URL}/vocab/data.jsonld`), + fetch(`${env.ID_URL}/vocab/display/data.jsonld`) + ]); + + const context = await contextRes.json(); + const vocab = await vocabRes.json(); + let display = await displayRes.json(); + + if (USE_LOCAL_DISPLAY_JSONLD === 'true') { + const path = '../../definitions/source/vocab/display.jsonld'; + const displayJson = fs.readFileSync(path, { encoding: 'utf8' }); + display = JSON.parse(displayJson); + console.warn(`USE_LOCAL_DISPLAY_JSONLD true. Using ${path}`); + } + + const vocabUtil = new VocabUtil(vocab, context); + const displayUtil = new DisplayUtil(display, vocabUtil); + return [vocabUtil, displayUtil]; +} diff --git a/lxl-web/src/lib/components/Search.svelte b/lxl-web/src/lib/components/Search.svelte index edcb42e52..fab5fd881 100644 --- a/lxl-web/src/lib/components/Search.svelte +++ b/lxl-web/src/lib/components/Search.svelte @@ -26,7 +26,7 @@ } -
+ ; + formatters: Record; +} + +type LangContainer = Record; + +enum Fresnel { + Format = 'fresnel:Format', + Group = 'fresnel:Group', + Lens = 'fresnel:Lens', + classFormatDomain = 'fresnel:classFormatDomain', + contentAfter = 'fresnel:contentAfter', + contentBefore = 'fresnel:contentBefore', + contentFirst = 'fresnel:contentFirst', + contentLast = 'fresnel:contentLast', + extends = 'fresnel:extends', + group = 'fresnel:group', + propertyFormat = 'fresnel:propertyFormat', + propertyFormatDomain = 'fresnel:propertyFormatDomain', + propertyStyle = 'fresnel:propertyStyle', + resourceFormat = 'fresnel:resourceFormat', + resourceStyle = 'fresnel:resourceStyle', + super = 'fresnel:super', + valueFormat = 'fresnel:valueFormat', + valueStyle = 'fresnel:valueStyle' +} + +interface Format { + [JsonLd.ID]?: string; + [JsonLd.TYPE]?: Fresnel.Format; + [Fresnel.group]?: string; + [Fresnel.classFormatDomain]?: [ClassName]; + [Fresnel.propertyFormatDomain]?: [PropertyName]; + [Fresnel.propertyFormat]?: FormatDetails; + [Fresnel.resourceFormat]: FormatDetails; + [Fresnel.valueFormat]?: FormatDetails; + [Fresnel.propertyStyle]?: string[]; + [Fresnel.resourceStyle]?: string[]; + [Fresnel.valueStyle]?: string[]; +} + +interface FormatDetails { + [Fresnel.contentBefore]?: string; + [Fresnel.contentAfter]?: string; + [Fresnel.contentFirst]?: string; + [Fresnel.contentLast]?: string; +} + +interface LensGroup { + [JsonLd.ID]?: string; + [JsonLd.TYPE]: Fresnel.Group; + lenses: Record; + [Fresnel.classFormatDomain]?: [ClassName]; +} + +export interface Link { + '@id': string; +} + +type RangeRestriction = { subPropertyOf: PropertyName; range: ClassName }; +type AlternateProperties = { alternateProperties: (PropertyName | RangeRestriction)[] }; +type ShowProperty = PropertyName | Fresnel.super | AlternateProperties | { inverse: PropertyName }; +type ShowProperties = ShowProperty[]; + +interface Lens { + '@id'?: string; + '@type': Fresnel.Lens; + classLensDomain?: ClassName; + [Fresnel.extends]?: Link; + [Fresnel.group]?: string; + showProperties: ShowProperties; +} + +export enum LensType { + Token = 'tokens', + Chip = 'chips', + Card = 'cards', + Full = 'full', + SearchChip = 'search-chips', + SearchCard = 'search-cards' +} + +type Context = Record>; + +// TODO +type Data = Record; +export type FramedData = Record; + +export type PropertyDefinition = unknown; + +// TODO +type LensedOrdered = unknown; +export type DisplayDecorated = unknown; + +interface VocabData { + '@context'?: string | Context; + '@graph': Record[]; +} + +interface ContextData { + '@context': Context; + '@graph': Record[]; +} + +export class VocabUtil { + //vocabId: string + vocabIndex: Map; + context; + + constructor(vocab: VocabData, context: ContextData) { + this.context = lxljsVocab.preprocessContext(context)[JsonLd.CONTEXT]; + this.vocabIndex = lxljsVocab.preprocessVocab(vocab); + } + + getBaseClasses(className: ClassName | ClassName[]): ClassName[] { + //FIXME? if multiple base classes, base classes are returned in depth-first order instead of breadth-first + return Array.isArray(className) + ? className.map((c) => lxljsVocab.getBaseClasses(c, this.vocabIndex, this.context)).flat() + : lxljsVocab.getBaseClasses(className, this.vocabIndex, this.context); + } + + // TODO handle missing type + getType(thing: Data): ClassName { + return thing[JsonLd.TYPE] as ClassName; + } + + isSubClassOf(className: ClassName, baseClassName: ClassName) { + this.getBaseClasses(className).includes(baseClassName); + } + + isStructuredValue(className: ClassName) { + return this.isSubClassOf(className, Base.StructuredValue); + } + + // TODO? reimplement? + hasCategory(propertyName: PropertyName, category: string) { + return lxljsVocab.hasCategory(propertyName, category, { + vocab: this.vocabIndex, + context: this.context + }); + } +} + +type FormatIndex = Record; + +// TODO transliterated values in language containers +// TODO handle not framed data, i.e. @graph +// TODO type coercion (code, langCode, langCodeFull -> code^^ISO639-2, code^^ISO639-3 etc.) +// TODO fresnel:allProperties? +export class DisplayUtil { + private readonly display: DisplayJsonLd; + private readonly vocabUtil: VocabUtil; + + // TODO + private readonly DEFAULT_LENS: Lens = { + [JsonLd.TYPE]: Fresnel.Lens, + showProperties: [{ alternateProperties: ['prefLabel', 'label', 'name', '@id'] }] + }; + + // TODO category integral should remain on same level? + private readonly DEFAULT_SUBLENS_SELECTOR = (lensType: LensType, propertyName: PropertyName) => { + if (this.vocabUtil.hasCategory(propertyName, Platform.integral)) { + return lensType; + } + + switch (lensType) { + case LensType.Full: + return LensType.Card; + case LensType.Card: + case LensType.SearchCard: + return LensType.Chip; + case LensType.Chip: + case LensType.SearchChip: + case LensType.Token: + return LensType.Token; + } + }; + + // x -> xByLang + langContainerAlias: Record = {}; + // xByLang -> x + langContainerAliasInverted: Record = {}; + + private readonly formatIndex: FormatIndex; + + constructor(display: DisplayJsonLd, vocabUtil: VocabUtil) { + this.display = display; + this.vocabUtil = vocabUtil; + this.buildLangContainerAliasMap(); + this.expandInheritedLensProperties(); + + this.formatIndex = buildFormatIndex(this.display); + console.log('Initialized DisplayUtil'); + } + + applyLens(thing: FramedData, lensType: LensType) { + return this._applyLens( + thing, + lensType, + this.DEFAULT_SUBLENS_SELECTOR, + (result, p, value) => { + (result as Data)[p] = value; + }, + () => { + return {}; + } + ); + } + + applyLensOrdered(thing: FramedData, lensType: LensType): LensedOrdered { + return this._applyLens( + thing, + lensType, + this.DEFAULT_SUBLENS_SELECTOR, + (result, p, value) => { + [JsonLd.ID, JsonLd.TYPE].includes(p) + ? ((result as Record)[p] = value) + : (result as { _props: Array })._props.push({ [p]: value }); + }, + () => { + return { _props: [] }; + } + ); + } + + lensAndFormat(thing: FramedData, lensType: LensType, locale: LangCode): DisplayDecorated { + return this.format(this.applyLensOrdered(thing, lensType), locale); + } + + format(thing: LensedOrdered, locale: LangCode): DisplayDecorated { + return new Formatter(this, this.vocabUtil, this.formatIndex, locale).displayDecorate(thing); + } + + /* + applyLensOrdered(thing: FramedData, lensType: LensType) { + return this._applyLens( + thing, + lensType, + (result, p, value) => { + (result as Array).push({ [p]: value }); + }, + () => { + return []; + } + ); + } + */ + + private _applyLens( + thing: unknown, + lensType: LensType, + subLensSelector: (lensType: LensType, propertyName: PropertyName) => LensType, + ack: (result: unknown, p: PropertyName, value: unknown) => void, + ackInit: () => unknown + ) { + if (!isTypedNode(thing)) { + return thing; + } + + const lens = this.findLens(lensType, this.vocabUtil.getType(thing)); + const result = ackInit(); + + const has = (src: Data, key: string): boolean => { + return key in src || (key in this.langContainerAlias && this.langContainerAlias[key] in src); + }; + + const accumulate = (src: Data, key: string) => { + const value = Array.isArray(src[key]) + ? (src[key] as Array).map((v) => + this._applyLens(v, subLensSelector(lensType, key), subLensSelector, ack, ackInit) + ) + : this._applyLens(src[key], subLensSelector(lensType, key), subLensSelector, ack, ackInit); + ack(result, key, value); + }; + + const pick = (src: Data, key: string) => { + if (key in src) { + accumulate(src, key); + } + if (key in this.langContainerAlias) { + const alias = this.langContainerAlias[key]; + if (alias in src) { + accumulate(src, alias); + } + } + }; + + for (const p of [JsonLd.TYPE, JsonLd.ID, ...lens.showProperties]) { + if (isAlternateProperties(p)) { + for (const alternative of p.alternateProperties) { + if (isRangeRestriction(alternative)) { + // can never be language container + const k = alternative.subPropertyOf; + const v = thing[k]; + if (isTypedNode(v) && v[JsonLd.TYPE] === alternative.range) { + pick(thing, k); + break; + } + } else { + if (has(thing, alternative)) { + pick(thing, alternative); + break; + } + } + } + } else if (isInverseProperty(p)) { + // TODO + } else { + if (has(thing, p)) { + pick(thing, p); + } + } + } + + return result; + } + + private findLens(lens: LensType, className: ClassName) { + for (const cls of [className, ...this.vocabUtil.getBaseClasses(className)]) { + if (cls in this.display.lensGroups[lens].lenses) { + return this.display.lensGroups[lens].lenses[cls]; + } + } + + // TODO... decide what we want + if (lens == LensType.Token) { + for (const cls of [className, ...this.vocabUtil.getBaseClasses(className)]) { + if (cls in this.display.lensGroups[LensType.Chip].lenses) { + return this.display.lensGroups[LensType.Chip].lenses[cls]; + } + } + } + + return this.DEFAULT_LENS; + } + + _getFormatIndex() { + return this.formatIndex; + } + + private buildLangContainerAliasMap() { + for (const [k, v] of Object.entries({ + ...this.vocabUtil.context, + ...this.display[JsonLd.CONTEXT] + })) { + // TODO why null check? + if (v && isLangContainerDefinition(v as Record)) { + this.langContainerAlias[(v as Record)[JsonLd.ID]] = k; + } + } + + this.langContainerAliasInverted = invertRecord(this.langContainerAlias); + } + + private expandInheritedLensProperties() { + const lensesById: Record = {}; + this.eachLens((lens) => { + if (lens[JsonLd.ID]) { + lensesById[lens[JsonLd.ID]] = lens; + } + }); + + const flattenedProps = (lens: Lens, hierarchy: string[]): ShowProperties => { + if (lens[JsonLd.ID] && hierarchy.includes(lens[JsonLd.ID])) { + throw Error(`${Fresnel.extends} inheritance loop: ${hierarchy}`); + } + + const superLensId = lens[Fresnel.extends]?.[JsonLd.ID]; + if (!superLensId) { + return lens.showProperties; + } else { + if (!lensesById[superLensId]) { + throw Error(`Super lens not found: ${lens[JsonLd.ID]} ${Fresnel.extends} ${superLensId}`); + } + + if (lens['@id']) { + hierarchy.push(lens[JsonLd.ID]!); + } + const superProps = flattenedProps(lensesById[superLensId], hierarchy); + let props = lens.showProperties; + if (!props.includes(Fresnel.super)) { + props = ([Fresnel.super] as ShowProperties).concat(props); + } + return props.map((p) => (p === Fresnel.super ? superProps : p)).flat(); + } + }; + + this.eachLens((lens) => { + lens.showProperties = flattenedProps(lens, []); + delete lens[Fresnel.extends]; + }); + } + + private eachLens(fn: (a: Lens) => void) { + const groups = Object.values(this.display.lensGroups).filter( + (g) => g[JsonLd.TYPE] === Fresnel.Group + ); // TODO until "formatters" is moved + const lenses = groups.map((g) => Object.values(g.lenses)).flat(); + for (const lens of lenses) { + fn(lens); + } + } +} + +type Styler = (v: unknown) => unknown; + +class Formatter { + private readonly DEFAULT_FORMAT: Format = { + [JsonLd.TYPE]: Fresnel.Format, + [Fresnel.propertyFormat]: {}, + [Fresnel.valueFormat]: {}, + [Fresnel.resourceFormat]: {} + }; + + private readonly stylers: Record = { + 'isniGroupDigits()': (v) => { + const formatIsni = (isni: unknown) => + typeof isni === 'string' && isni.length === 16 + ? `${isni.slice(0, 4)} ${isni.slice(4, 8)} ${isni.slice(8, 12)} ${isni.slice(12, 16)}` + : isni; + + return Array.isArray(v) ? v.map(formatIsni) : formatIsni(v); + }, + 'displayType()': (v) => { + if (isObject(v) && JsonLd.TYPE in v && Fmt.DISPLAY in v) { + // TODO doesn't translate type name + (v[Fmt.DISPLAY] as Array).unshift({ [JsonLd.VALUE]: v[JsonLd.TYPE] }); + } + return v; + } + }; + + private readonly formatIndex: FormatIndex; + private readonly locale: LangCode; + private readonly displayUtil: DisplayUtil; + private readonly vocabUtil: VocabUtil; + + constructor( + displayUtil: DisplayUtil, + vocabUtil: VocabUtil, + formatIndex: FormatIndex, + locale: LangCode + ) { + this.displayUtil = displayUtil; + this.vocabUtil = vocabUtil; + this.formatIndex = formatIndex; + this.locale = locale; + } + + displayDecorate(thing: LensedOrdered) { + if (!isTypedNode(thing)) { + return thing; + } + + return this.formatResource(thing, false, false); + } + + private formatResource(resource, isFirst: boolean, isLast: boolean) { + const className = resource[JsonLd.TYPE]; + let result = { + ...(JsonLd.ID in resource && { [JsonLd.ID]: resource[JsonLd.ID] }), + [JsonLd.TYPE]: className, + [Fmt.DISPLAY]: this.formatProperties(resource[Fmt.PROPS], className) + }; + result = this.styleResource(result, className); + this.addFormatDetail(result, this.findResourceFormat(className), isFirst, isLast); + + return result; + } + + private formatProperties(properties, className: ClassName) { + return asArray(properties).map((p, ix, a) => { + return this.formatProperty(p, className, ix == 0, 1 == a.length); + }); + } + + private formatProperty(property, className: ClassName, isFirst: boolean, isLast: boolean) { + const propertyName = Object.keys(property)[0]; + const value = Object.values(property)[0]; + + // FIXME reaching inside + if (this.displayUtil.langContainerAliasInverted[propertyName]) { + return { + [this.displayUtil.langContainerAliasInverted[propertyName]]: this.pickLanguage(value) + }; + } + + const result = {} as Record; + this.addFormatDetail(result, this.findPropertyFormat(className, propertyName), isFirst, isLast); + + result[propertyName] = this.formatValues(value, className, propertyName); + + return result; + } + + private formatValues(values, className: ClassName, propertyName: PropertyName) { + values = unwrapSingle(values); + if (Array.isArray(values)) { + return values.map((v, ix, a) => { + return this.formatValueInArray(v, className, propertyName, ix == 0, ix + 1 == a.length); + }); + } else { + return this.formatSingleValue(values, className, propertyName); + } + } + + private formatSingleValue(value, className: ClassName, propertyName: PropertyName) { + if (isTypedNode(value)) { + const result = this.formatResource(value, true, true); + this.addFormatDetail(result, this.findValueFormat(className, propertyName), true, true); + return result; + } else { + return this.styleValue(value, className, propertyName); + } + } + + private formatValueInArray( + value, + className: ClassName, + propertyName: PropertyName, + isFirst: boolean, + isLast: boolean + ) { + let result; + if (isTypedNode(value)) { + result = this.formatResource(value, isFirst, isLast); + } else { + result = { [JsonLd.VALUE]: this.styleValue(value, className, propertyName) }; + } + this.addFormatDetail(result, this.findValueFormat(className, propertyName), isFirst, isLast); + return result; + } + + private styleResource(value, className: ClassName) { + this.findResourceStyle(className).forEach((style) => { + if (style in this.stylers) { + value = this.stylers[style](value); + } + }); + return value; + } + + private styleValue(value, className: ClassName, propertyName: PropertyName) { + this.findValueStyle(className, propertyName).forEach((style) => { + if (style in this.stylers) { + value = this.stylers[style](value); + } + }); + return value; + } + + private addFormatDetail( + obj: Record, + details: FormatDetails, + isFirst: boolean, + isLast: boolean + ) { + if (isFirst && Fresnel.contentFirst in details) { + if (details[Fresnel.contentFirst] !== '') { + // TODO decide if we should generate contentBefore or contentFirst here + obj[Fmt.CONTENT_BEFORE] = details[Fresnel.contentFirst]; + } + } else if (Fresnel.contentBefore in details && details[Fresnel.contentBefore] !== '') { + obj[Fmt.CONTENT_BEFORE] = details[Fresnel.contentBefore]; + } + + if (isLast && Fresnel.contentLast in details) { + if (details[Fresnel.contentLast] !== '') { + // TODO decide if we should generate contentAfter or contentLast here + obj[Fmt.CONTENT_AFTER] = details[Fresnel.contentLast]; + } + } else if (Fresnel.contentAfter in details && details[Fresnel.contentAfter] !== '') { + obj[Fmt.CONTENT_AFTER] = details[Fresnel.contentAfter]; + } + } + + private findResourceFormat(className: ClassName): FormatDetails { + return this._findResourceFormat(className, Fresnel.resourceFormat)[Fresnel.resourceFormat]!; + } + + private findResourceStyle(className: ClassName): string[] { + return this._findResourceFormat(className, Fresnel.resourceStyle)[Fresnel.resourceStyle] || []; + } + + private findPropertyFormat(className: ClassName, propertyName: PropertyName): FormatDetails { + const f = this._findPropertyOrValueFormat(className, propertyName, Fresnel.propertyFormat); + return f[Fresnel.propertyFormat]!; + } + + private findValueFormat(className: ClassName, propertyName: PropertyName): FormatDetails { + const f = this._findPropertyOrValueFormat(className, propertyName, Fresnel.valueFormat); + return f[Fresnel.valueFormat]!; + } + + private findValueStyle(className: ClassName, propertyName: PropertyName): string[] { + const f = this._findPropertyOrValueFormat(className, propertyName, Fresnel.valueStyle); + return f[Fresnel.valueStyle] || []; + } + + private _findResourceFormat( + className: ClassName, + key: Fresnel.resourceFormat | Fresnel.resourceStyle + ) { + for (const cls of [className, ...this.vocabUtil.getBaseClasses(className)]) { + const hasFormat = (f: Format) => key in f; + if (cls in this.formatIndex && hasFormat(this.formatIndex[cls])) { + return this.formatIndex[cls]; + } + } + return this.DEFAULT_FORMAT; + } + + private _findPropertyOrValueFormat( + className: ClassName, + propertyName: PropertyName, + key: Fresnel.propertyFormat | Fresnel.propertyStyle | Fresnel.valueFormat | Fresnel.valueStyle + ) { + // TODO precompute / memoize formats for base classes + const hasFormat = (f: Format) => key in f; + for (const cls of [className, ...this.vocabUtil.getBaseClasses(className)]) { + const ix = `${cls}/${propertyName}`; + if (ix in this.formatIndex && hasFormat(this.formatIndex[ix])) { + console.debug(`${ix} -> ${this.formatIndex[ix]}`); + return this.formatIndex[ix]; + } + } + if (propertyName in this.formatIndex && hasFormat(this.formatIndex[propertyName])) { + console.debug(`${className} ${propertyName} ${key} -> ${this.formatIndex[propertyName]}`); + return this.formatIndex[propertyName]; + } + for (const cls of [className, ...this.vocabUtil.getBaseClasses(className)]) { + const ix = `${cls}/*`; + if (ix in this.formatIndex && hasFormat(this.formatIndex[ix])) { + return this.formatIndex[ix]; + } + } + return this.DEFAULT_FORMAT; + } + + private pickLanguage(container: LangContainer) { + // TODO handle missing + return container[this.locale]; + } +} + +function buildFormatIndex(display: DisplayJsonLd) { + if (!display.formatters) { + return {}; + } + + const formatIndex: FormatIndex = {}; + + Object.entries(display.formatters).forEach(([key, format]) => { + if (key !== format[JsonLd.ID]) { + console.warn( + `Mismatch in ${Fresnel.Format} identifiers: key ${key} @id ${format[JsonLd.ID]}` + ); + } + const classDomain = asArray(format[Fresnel.classFormatDomain]); + const propertyDomain = asArray(format[Fresnel.propertyFormatDomain]); + if (Fresnel.resourceFormat in format && !classDomain.length) { + console.warn(`${Fresnel.resourceFormat} without ${Fresnel.classFormatDomain}: ${key}`); + } + if (Fresnel.propertyFormat in format && !propertyDomain.length) { + console.warn(`${Fresnel.propertyFormat} without ${Fresnel.propertyFormatDomain}: ${key}`); + } + if (Fresnel.valueFormat in format && !propertyDomain.length) { + console.warn(`${Fresnel.valueFormat} without ${Fresnel.propertyFormatDomain}: ${key}`); + } + + if (classDomain.length && propertyDomain.length) { + classDomain.forEach((c) => + propertyDomain.forEach((p) => { + formatIndex[`${c}/${p}`] = format; + }) + ); + } else if (classDomain.length) { + classDomain.forEach((c) => { + formatIndex[`${c}`] = format; + }); + } else if (propertyDomain.length) { + propertyDomain.forEach((p) => { + formatIndex[`${p}`] = format; + }); + } + }); + + return formatIndex; +} + +function invertRecord( + obj: Record +): Record { + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [v, k])); +} + +/* +export function mapValuesOfObject(obj: Record, fn: (v: V, k: string, i: number) => V2): Record { + return Object.fromEntries( + Object.entries(obj).map( + ([k, v], i) => [k, fn(v, k, i)] + ) + ) +} + + */ + +function isAlternateProperties(v: ShowProperty): v is AlternateProperties { + return typeof v !== 'string' && 'alternateProperties' in v; +} + +function isInverseProperty(v: ShowProperty): v is { inverse: PropertyName } { + return typeof v !== 'string' && 'inverse' in v; +} + +function isRangeRestriction(v: PropertyName | RangeRestriction): v is RangeRestriction { + return typeof v !== 'string' && 'subPropertyOf' in v && 'range' in v; +} + +// TODO... +function isTypedNode(data: unknown): data is Data { + return isObject(data) && JsonLd.TYPE in data; +} + +function asArray(v: unknown): Array { + return Array.isArray(v) ? v : v === null || v === undefined ? [] : [v]; +} + +function unwrapSingle(v: unknown) { + return Array.isArray(v) ? (v.length == 1 ? v[0] : v) : v; +} + +/* +function isLink(data: unknown): data is Link { + return isObject(data) + && Object.keys(data).length === 1 + && Key.ID in data; +} + */ + +export function isObject(data: unknown): data is Data { + return typeof data === 'object' && !Array.isArray(data) && data !== null; +} + +function isLangContainerDefinition(dfn: Record) { + return dfn[JsonLd.CONTAINER] == JsonLd.LANGUAGE; +} diff --git a/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/+page.server.ts b/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/+page.server.ts index 1f3bda629..ecb5eb97d 100644 --- a/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/+page.server.ts +++ b/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/+page.server.ts @@ -1,9 +1,31 @@ import { env } from '$env/dynamic/private'; +import { DisplayUtil, type FramedData, LensType } from '$lib/utils/xl'; +import { getSupportedLocale } from '$lib/i18n/locales'; -export const load = async ({ params, fetch }) => { - const iri = `${env.API_URL}/${params.fnurgel}`; - const response = await fetch(iri, { headers: { Accept: 'application/ld+json' } }); - const doc = await response.json(); +export const load = async ({ params, locals, fetch }) => { + const doc = await loadDoc(fetch, params.fnurgel); + + const displayUtil: DisplayUtil = locals.display; + //const vocabUtil: VocabUtil = locals.vocab; + + const data = doc; - return { fnurgel: params.fnurgel, iri: iri, lang: params.lang ?? 'sv', doc: doc }; + const foo = { + card_decorated: displayUtil.format( + displayUtil.applyLensOrdered(data, LensType.Card), + getSupportedLocale(params?.lang) + ), + card_ordered: displayUtil.applyLensOrdered(data, LensType.Card), + format_index: displayUtil._getFormatIndex() + //card: displayUtil.applyLens(doc, LensType.Chip), + }; + + return { fnurgel: params.fnurgel, doc, foo }; }; + +async function loadDoc(fetch, fnurgel: string) { + const iri = `${env.API_URL}/${fnurgel}?framed=true`; + const response = await fetch(iri, { headers: { Accept: 'application/ld+json' } }); + const doc = await response.json(); + return doc['mainEntity'] as FramedData; +} diff --git a/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/+page.svelte b/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/+page.svelte index 19543893e..43ca93f10 100644 --- a/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/+page.svelte +++ b/lxl-web/src/routes/(app)/[[lang=lang]]/[fnurgel=fnurgel]/+page.svelte @@ -1,7 +1,9 @@ -

{data.fnurgel} på {data.lang}

-

{data.iri}

-

{JSON.stringify(data)}

+

{data.fnurgel}

+ +
{JSON.stringify(data.foo, null, 2)}
diff --git a/lxl-web/src/routes/(app)/[[lang=lang]]/find/+page.server.ts b/lxl-web/src/routes/(app)/[[lang=lang]]/find/+page.server.ts new file mode 100644 index 000000000..13b5edb8b --- /dev/null +++ b/lxl-web/src/routes/(app)/[[lang=lang]]/find/+page.server.ts @@ -0,0 +1,20 @@ +import { env } from '$env/dynamic/private'; +import { redirect } from '@sveltejs/kit'; +import { DisplayUtil } from '$lib/utils/xl'; +import { getSupportedLocale } from '$lib/i18n/locales'; +import { asResult, type PartialCollectionView } from './search'; + +export const load = async ({ params, locals, fetch, url }) => { + if (!url.searchParams.size) { + redirect(303, `/`); // redirect to home page if no search params are given + } + + const recordsRes = await fetch(`${env.API_URL}/find.jsonld?${url.searchParams.toString()}`); + const result = (await recordsRes.json()) as PartialCollectionView; + const displayUtil: DisplayUtil = locals.display; + const searchResult = asResult(result, displayUtil, getSupportedLocale(params?.lang)); + + return { + searchResult + }; +}; diff --git a/lxl-web/src/routes/(app)/[[lang=lang]]/find/+page.svelte b/lxl-web/src/routes/(app)/[[lang=lang]]/find/+page.svelte new file mode 100644 index 000000000..5f3fcead3 --- /dev/null +++ b/lxl-web/src/routes/(app)/[[lang=lang]]/find/+page.svelte @@ -0,0 +1,51 @@ + + +
+

{$page.data.t('search.result_info', { q: `${q}` })}

+
    + {#each data.searchResult.mapping as mapping} +
  • + + {#if 'up' in mapping} + x + {/if} +
  • + {/each} +
+
+
+
    + {#each data.searchResult.items as item (item['@id'])} +
  • + +
  • +
    + {/each} +
+
+
+
    + {#each data.searchResult.facetGroups as group (group.dimension)} +

    {group.label}

    +
    + + {#each group.facets as facet (facet.view)} + + + +
    {JSON.stringify(facet)}
    +
    + {/each} + +
    +
    + {/each} +
+
diff --git a/lxl-web/src/routes/(app)/[[lang=lang]]/find/search.ts b/lxl-web/src/routes/(app)/[[lang=lang]]/find/search.ts new file mode 100644 index 000000000..f3ff4621d --- /dev/null +++ b/lxl-web/src/routes/(app)/[[lang=lang]]/find/search.ts @@ -0,0 +1,183 @@ +import { + type DisplayDecorated, + DisplayUtil, + type FramedData, + isObject, + JsonLd, + type LangCode, + LensType, + type Link, + type PropertyName +} from '$lib/utils/xl'; + +export function asResult( + view: PartialCollectionView, + displayUtil: DisplayUtil, + locale: LangCode +): SearchResult { + return { + ...('next' in view && { next: view.next }), + itemOffset: view.itemOffset, + itemsPerPage: view.itemsPerPage, + totalItems: view.totalItems, + maxItems: view.maxItems, + mapping: displayMappings(view, displayUtil, locale), + first: view.first, + last: view.last, + items: view.items.map((i) => displayUtil.lensAndFormat(i, LensType.Card, locale)), + facetGroups: displayFacetGroups(view, displayUtil, locale) + }; +} + +export interface SearchResult { + itemOffset: number; + itemsPerPage: number; + totalItems: number; + maxItems: number; + mapping: DisplayMapping[]; + first: Link; + last: Link; + next?: Link; + items: DisplayDecorated[]; + facetGroups: FacetGroup[]; +} + +type FacetGroupId = string; + +interface FacetGroup { + label: string; + dimension: FacetGroupId; + // TODO better to do this distinction on the group level? + facets: (Facet | MultiSelectFacet)[]; +} + +interface Facet { + totalItems: number; + view: Link; + object: DisplayDecorated; +} + +interface MultiSelectFacet extends Facet { + selected: boolean; +} + +interface DisplayMapping { + display: DisplayDecorated; + up?: Link; + children: DisplayMapping[]; +} + +export interface PartialCollectionView { + [JsonLd.TYPE]: 'PartialCollectionView'; + [JsonLd.ID]: string; + [JsonLd.CONTEXT]: string; + itemOffset: number; + itemsPerPage: number; + totalItems: number; + maxItems: number; + search: { + mapping: (PredicateAndObject | PredicateAndValue)[]; + }; + first: Link; + last: Link; + next?: Link; + items: FramedData[]; + stats?: { + [JsonLd.ID]: '#stats'; + sliceByDimension: Record; + }; +} + +interface Slice { + dimension: FacetGroupId; + dimensionChain: PropertyName[]; + observation: Observation[]; +} + +interface Observation { + totalItems: number; + view: Link; + object: FramedData; + _selected?: boolean; +} + +interface PredicateAndObject { + variable: string; + predicate: ObjectProperty | DatatypeProperty | PropertyChainAxiom; + object: FramedData; +} +interface PredicateAndValue { + variable: string; + predicate: ObjectProperty | DatatypeProperty | PropertyChainAxiom; + value: string; +} + +interface ObjectProperty {} + +interface DatatypeProperty {} + +function displayMappings( + view: PartialCollectionView, + displayUtil: DisplayUtil, + locale: LangCode +): DisplayMapping[] { + const mapping = view.search?.mapping || []; + + return mapping.map((m) => { + if (isPredicateAndObject(m)) { + return { + ...('up' in m && { up: m.up }), + display: displayUtil.lensAndFormat(m.object, LensType.Chip, locale), + children: [] + } as DisplayMapping; + } else if (isPredicateAndValue(m)) { + return { + ...('up' in m && { up: m.up }), + display: { [JsonLd.VALUE]: m.value } as DisplayDecorated, + children: [] + } as DisplayMapping; + } else { + return { + display: { [JsonLd.VALUE]: '' } as DisplayDecorated, + children: [] + } as DisplayMapping; + } + }); +} + +function displayFacetGroups( + view: PartialCollectionView, + displayUtil: DisplayUtil, + locale: LangCode +): FacetGroup[] { + const slices = view.stats?.sliceByDimension || {}; + + return Object.values(slices).map((g) => { + return { + label: g.dimension, // TODO + dimension: g.dimension, + facets: g.observation.map((o) => { + return { + ...('_selected' in o && { selected: o._selected }), + totalItems: o.totalItems, + view: o.view, + object: displayUtil.lensAndFormat(o.object, LensType.Chip, locale) + }; + }) + }; + }); +} + +function isPredicateAndObject(v: unknown): v is PredicateAndObject { + return isObject(v) && 'variable' in v && 'predicate' in v && 'object' in v; +} + +function isPredicateAndValue(v: unknown): v is PredicateAndValue { + return isObject(v) && 'variable' in v && 'predicate' in v && 'value' in v; +} + +interface PropertyChainAxiom { + propertyChainAxiom: (ObjectProperty | DatatypeProperty)[]; + label: string; // e.g. "instanceOf language" + _key: string; // e.g. "instanceOf.language" +} diff --git a/lxl-web/src/routes/(app)/[[lang=lang]]/search/+page.server.ts b/lxl-web/src/routes/(app)/[[lang=lang]]/search/+page.server.ts deleted file mode 100644 index 5e60ef216..000000000 --- a/lxl-web/src/routes/(app)/[[lang=lang]]/search/+page.server.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { env } from '$env/dynamic/private'; -import { redirect } from '@sveltejs/kit'; - -export const load = async ({ fetch, url }) => { - if (!url.searchParams.size) { - redirect(303, `/`); // redirect to home page if no search params are given - } - - const recordsRes = await fetch(`${env.API_URL}/find.jsonld?${url.searchParams.toString()}`); - const records = await recordsRes.json(); - - const items = records.items.map((item) => ({ - fnurgel: new URL(item['@id']).pathname.replace('/', ''), - '@id': item['@id'] - })); - - return { - items - }; -}; diff --git a/lxl-web/src/routes/(app)/[[lang=lang]]/search/+page.svelte b/lxl-web/src/routes/(app)/[[lang=lang]]/search/+page.svelte deleted file mode 100644 index 680b5ccfe..000000000 --- a/lxl-web/src/routes/(app)/[[lang=lang]]/search/+page.svelte +++ /dev/null @@ -1,16 +0,0 @@ - - -
-

{$page.data.t('search.result_info', { q: `${q}` })}

-
    - {#each data.items as item (item['@id'])} -
  • {item.fnurgel}
  • - {/each} -
-
diff --git a/lxljs/package.json b/lxljs/package.json index ab26a5f59..d5f787625 100644 --- a/lxljs/package.json +++ b/lxljs/package.json @@ -13,7 +13,8 @@ "eslint-config-airbnb-base": "^14.2.1", "eslint-plugin-import": "^2.25.2", "lodash-es": "^4.17.21", - "sjcl": "^1.0.8" + "sjcl": "^1.0.8", + "@types/node": "18.18.2" }, "devDependencies": { "@babel/plugin-transform-modules-commonjs": "^7.16.0",