diff --git a/CHANGELOG.md b/CHANGELOG.md index f7075b7bf..82472ca9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,11 @@ You can also check the - Features - add link to https://status.lindas.admin.ch/ in footer + - Show the abbreviation (`schema:alternateName`) next to the name of cube + creators from https://lindas.admin.ch/fch/rvov, so that e.g. "Bundeskanzlei" + is displayed as "Bundeskanzlei BK" like the entries from the old termset. + Creators from both graphs are now also resolved on dataset metadata and + visualization detail pages - Fixes - Statistics page no longer fails when the database contains malformed chart configs; unsafe cube IRIs are filtered out and SPARQL metadata lookups diff --git a/app/rdf/parse-search-results.spec.ts b/app/rdf/parse-search-results.spec.ts index 857b22ae1..958387bd9 100644 --- a/app/rdf/parse-search-results.spec.ts +++ b/app/rdf/parse-search-results.spec.ts @@ -6,6 +6,7 @@ import { Quad } from "rdf-js"; import { describe, expect, it } from "vitest"; import { SearchCube } from "@/domain/data"; +import * as ns from "@/rdf/namespace"; import { mergeSearchCubes } from "@/rdf/query-search"; import { buildSearchCubes } from "./parse-search-results"; @@ -341,6 +342,48 @@ describe("parse-search-results", () => { ] `); }); + + it("should merge creator alternateName into creator label", () => { + const cubeIri = "https://example.com/cube"; + const creatorIri = "https://ld.admin.ch/FCh"; + const q = (subject: string, predicate: string, object: string) => + ({ + subject: { value: subject }, + predicate: { value: predicate }, + object: { value: object }, + }) as Quad; + + const makeCubeQuads = (creatorQuads: Quad[]) => [ + q(cubeIri, ns.rdf.type.value, ns.cube.Cube.value), + q( + cubeIri, + ns.schema.creativeWorkStatus.value, + ns.adminVocabulary("CreativeWorkStatus/Published").value + ), + q(cubeIri, ns.schema.creator.value, creatorIri), + ...creatorQuads, + ]; + + const withAlternateName = buildSearchCubes( + makeCubeQuads([ + q(creatorIri, ns.schema.name.value, "Bundeskanzlei"), + q(creatorIri, ns.schema.alternateName.value, "BK"), + q(creatorIri, ns.schema.alternateName.value, "ChF"), + ]) + ); + expect(withAlternateName[0].creator).toEqual({ + iri: creatorIri, + label: "Bundeskanzlei BK ChF", + }); + + const withoutAlternateName = buildSearchCubes( + makeCubeQuads([q(creatorIri, ns.schema.name.value, "Bundeskanzlei")]) + ); + expect(withoutAlternateName[0].creator).toEqual({ + iri: creatorIri, + label: "Bundeskanzlei", + }); + }); }); it("should merge search cubes together", async () => { diff --git a/app/rdf/parse-search-results.ts b/app/rdf/parse-search-results.ts index d5b150a95..d01c8d03a 100644 --- a/app/rdf/parse-search-results.ts +++ b/app/rdf/parse-search-results.ts @@ -2,6 +2,7 @@ import { group } from "d3-array"; import { Quad } from "rdf-js"; import { SearchCube } from "@/domain/data"; +import { truthy } from "@/domain/types"; import { stringifyComponentId } from "@/graphql/make-component-id"; import { DataCubePublicationStatus } from "@/graphql/resolver-types"; import * as ns from "@/rdf/namespace"; @@ -49,6 +50,22 @@ function buildSearchCubes( const dimensions = cubeQuads.get(visualizePredicates.hasDimension); const creatorIri = cubeQuads.get(ns.schema.creator.value)?.[0]?.object .value; + const creatorSubjectQuads = creatorIri + ? bySubjectAndPredicate.get(creatorIri) + : undefined; + const creatorAlternateNames = [ + ...new Set( + creatorSubjectQuads + ?.get(ns.schema.alternateName.value) + ?.map((x) => x.object.value) ?? [] + ), + ].sort(); + const creatorLabel = [ + creatorSubjectQuads?.get(ns.schema.name.value)?.[0]?.object.value, + ...creatorAlternateNames, + ] + .filter(truthy) + .join(" "); const publicationStatus = cubeQuads.get( ns.schema.creativeWorkStatus.value )?.[0].object.value; @@ -73,10 +90,7 @@ function buildSearchCubes( creator: creatorIri ? { iri: creatorIri, - label: - bySubjectAndPredicate - .get(creatorIri) - ?.get(ns.schema.name.value)?.[0].object.value ?? "", + label: creatorLabel, } : null, themes: diff --git a/app/rdf/query-cube-metadata.spec.ts b/app/rdf/query-cube-metadata.spec.ts new file mode 100644 index 000000000..a1f234ffd --- /dev/null +++ b/app/rdf/query-cube-metadata.spec.ts @@ -0,0 +1,122 @@ +import { describe, expect, it, vi } from "vitest"; + +import { getCubeMetadata } from "@/rdf/query-cube-metadata"; +import { GROUP_SEPARATOR } from "@/rdf/query-utils"; + +const term = (value: string) => ({ value }) as any; + +const buildRow = (overrides: Record = {}) => ({ + iri: term("https://example.com/cube"), + unversionedIri: term("https://example.com/cube"), + identifier: term("id"), + title: term("Title"), + description: term("Description"), + version: term("1.0"), + datePublished: term("2024-01-01"), + dateModified: term("2024-01-02"), + status: term("http://publishers.schema.org/Published"), + themeIris: term(""), + themeLabels: term(""), + creatorIri: undefined, + creatorLabel: undefined, + creatorAlternateNames: term(""), + contactPointEmails: term(""), + contactPointNames: term(""), + publisher: term("https://example.com/publisher"), + landingPage: term("https://example.com"), + expires: term(""), + workExamples: term(""), + ...overrides, +}); + +const mockClient = (row: any, onQuery?: (q: string) => void) => + ({ + query: { + select: vi.fn(async (q: string) => { + onQuery?.(q); + return [row]; + }), + }, + }) as any; + +describe("getCubeMetadata creator", () => { + it("should combine creator name and alternate name", async () => { + const metadata = await getCubeMetadata("https://example.com/cube", { + locale: "de", + sparqlClient: mockClient( + buildRow({ + creatorIri: term("https://ld.admin.ch/FCh"), + creatorLabel: term("Bundeskanzlei"), + creatorAlternateNames: term("BK"), + }) + ), + }); + + expect(metadata.creator).toEqual({ + iri: "https://ld.admin.ch/FCh", + label: "Bundeskanzlei BK", + }); + }); + + it("should sort multiple alternate names", async () => { + const metadata = await getCubeMetadata("https://example.com/cube", { + locale: "de", + sparqlClient: mockClient( + buildRow({ + creatorIri: term("https://ld.admin.ch/office/VI.1.4"), + creatorLabel: term( + "Staatssekretariat für Bildung, Forschung und Innovation" + ), + creatorAlternateNames: term(["SBFI", "BFI"].join(GROUP_SEPARATOR)), + }) + ), + }); + + expect(metadata.creator?.label).toBe( + "Staatssekretariat für Bildung, Forschung und Innovation BFI SBFI" + ); + }); + + it("should keep legacy creator label without alternate names", async () => { + const metadata = await getCubeMetadata("https://example.com/cube", { + locale: "de", + sparqlClient: mockClient( + buildRow({ + creatorIri: term( + "https://register.ld.admin.ch/opendataswiss/org/bundesamt-fur-energie-bfe" + ), + creatorLabel: term("Swiss Federal Office of Energy SFOE"), + creatorAlternateNames: term(""), + }) + ), + }); + + expect(metadata.creator).toEqual({ + iri: "https://register.ld.admin.ch/opendataswiss/org/bundesamt-fur-energie-bfe", + label: "Swiss Federal Office of Energy SFOE", + }); + }); + + it("should return no creator when creator is missing", async () => { + const metadata = await getCubeMetadata("https://example.com/cube", { + locale: "de", + sparqlClient: mockClient(buildRow()), + }); + + expect(metadata.creator).toBeUndefined(); + }); + + it("should query both creator graphs", async () => { + let query = ""; + await getCubeMetadata("https://example.com/cube", { + locale: "de", + sparqlClient: mockClient(buildRow(), (q) => (query = q)), + }); + + expect(query).toContain("https://lindas.admin.ch/fch/rvov"); + expect(query).toContain("https://lindas.admin.ch/sfa/opendataswiss"); + expect(query).toContain("schema:alternateName"); + expect(query).toContain("schema:creator"); + expect(query).toContain("dcterms:creator"); + }); +}); diff --git a/app/rdf/query-cube-metadata.ts b/app/rdf/query-cube-metadata.ts index f51409c4f..e3322fbc0 100644 --- a/app/rdf/query-cube-metadata.ts +++ b/app/rdf/query-cube-metadata.ts @@ -3,6 +3,7 @@ import { Literal, NamedNode } from "rdf-js"; import { ParsingClient } from "sparql-http-client/ParsingClient"; import { DataCubeMetadata } from "@/domain/data"; +import { truthy } from "@/domain/types"; import { DataCubePublicationStatus } from "@/graphql/resolver-types"; import { pragmas } from "@/rdf/create-source"; import * as ns from "@/rdf/namespace"; @@ -22,6 +23,7 @@ type RawDataCubeMetadata = { themeLabels: Literal; creatorIri: NamedNode; creatorLabel: Literal; + creatorAlternateNames: Literal; contactPointEmails: NamedNode; contactPointNames: NamedNode; publisher: NamedNode; @@ -60,7 +62,17 @@ const parseRawMetadata = (cube: RawDataCubeMetadata): DataCubeMetadata => { : [], creator: cube.creatorIri && cube.creatorLabel - ? { iri: cube.creatorIri.value, label: cube.creatorLabel.value } + ? { + iri: cube.creatorIri.value, + label: [ + cube.creatorLabel.value, + ...(cube.creatorAlternateNames?.value + .split(GROUP_SEPARATOR) + .sort() ?? []), + ] + .filter(truthy) + .join(" "), + } : undefined, contactPoints: contactPointEmails.length === contactPointNames.length @@ -83,6 +95,7 @@ export const getCubeMetadata = async ( const query = SELECT` ?iri ?identifier ?title ?description ?version ?datePublished ?dateModified ?status ?creatorIri ?creatorLabel ?unversionedIri ?publisher ?landingPage ?expires (GROUP_CONCAT(DISTINCT ?themeIri; SEPARATOR="${GROUP_SEPARATOR}") AS ?themeIris) + (GROUP_CONCAT(DISTINCT ?creatorAlternateName; SEPARATOR="${GROUP_SEPARATOR}") AS ?creatorAlternateNames) (GROUP_CONCAT(DISTINCT ?themeLabel; SEPARATOR="${GROUP_SEPARATOR}") AS ?themeLabels) (GROUP_CONCAT(DISTINCT ?subthemeIri; SEPARATOR="${GROUP_SEPARATOR}") AS ?subthemeIris) (GROUP_CONCAT(DISTINCT ?subthemeLabel; SEPARATOR="${GROUP_SEPARATOR}") AS ?subthemeLabels) @@ -103,18 +116,31 @@ export const getCubeMetadata = async ( OPTIONAL { ?iri ${ns.schema.dateModified} ?dateModified . } ?iri ${ns.schema.creativeWorkStatus} ?status . OPTIONAL { - ?iri ${ns.dcterms.creator} ?creatorIri . - GRAPH { - ?creatorIri a ${ns.schema.Organization} ; - ${ - ns.schema.inDefinedTermSet - } . + { + ?iri ${ns.dcterms.creator} ?creatorIri . + } UNION { + ?iri ${ns.schema.creator} ?creatorIri . + } + VALUES ?creatorGraph { + + + } + GRAPH ?creatorGraph { + VALUES ?creatorType { schema:Organization schema:GovernmentOrganization } + ?creatorIri a ?creatorType . + ${buildLocalizedSubQuery("creatorIri", "schema:name", "creatorLabel", { + locale, + })} + } + OPTIONAL { + GRAPH { ${buildLocalizedSubQuery( "creatorIri", - "schema:name", - "creatorLabel", + "schema:alternateName", + "creatorAlternateName", { locale } )} + } } } OPTIONAL { diff --git a/app/rdf/query-search.ts b/app/rdf/query-search.ts index 8323c8fc0..d63289c3b 100644 --- a/app/rdf/query-search.ts +++ b/app/rdf/query-search.ts @@ -211,7 +211,9 @@ const mkScoresQuery = ( meta:isUsedIn ?iri ; schema:name ?termsetLabel . - ?creatorIri schema:name ?creatorLabel . + ?creatorIri + schema:name ?creatorLabel ; + schema:alternateName ?creatorAlternateName . ?subthemeIri schema:inDefinedTermSet ?subthemeTermset ; @@ -237,6 +239,7 @@ const mkScoresQuery = ( ?termsetIri ?termsetLabel ?creatorLabel + ?creatorAlternateName ?subthemeIri ?subthemeTermset ?subthemeLabel @@ -339,6 +342,16 @@ const mkScoresQuery = ( { locale } )} } + OPTIONAL { + GRAPH { + ${buildLocalizedSubQuery( + "creatorIri", + "schema:alternateName", + "creatorAlternateName", + { locale } + )} + } + } } OPTIONAL { @@ -417,6 +430,12 @@ const mkScoresQuery = ( CONTAINS(LCASE(?creatorLabel_en), LCASE(?keyword)) || CONTAINS(LCASE(?creatorLabel_), LCASE(?keyword)) || + CONTAINS(LCASE(?creatorAlternateName_de), LCASE(?keyword)) || + CONTAINS(LCASE(?creatorAlternateName_fr), LCASE(?keyword)) || + CONTAINS(LCASE(?creatorAlternateName_it), LCASE(?keyword)) || + CONTAINS(LCASE(?creatorAlternateName_en), LCASE(?keyword)) || + CONTAINS(LCASE(?creatorAlternateName_), LCASE(?keyword)) || + CONTAINS(LCASE(?themeLabel_de), LCASE(?keyword)) || CONTAINS(LCASE(?themeLabel_fr), LCASE(?keyword)) || CONTAINS(LCASE(?themeLabel_it), LCASE(?keyword)) || @@ -452,6 +471,7 @@ const mkScoresQuery = ( ?termsetIri ?termsetLabel ?creatorLabel + ?creatorAlternateName ?subthemeIri ?subthemeTermset ?subthemeLabel