From fdb6e18373ba8e2d6cf0da479a388586e35e66c4 Mon Sep 17 00:00:00 2001 From: Alastair Porter Date: Tue, 22 Jun 2021 15:28:04 +0200 Subject: [PATCH 1/6] fix(json-ld): Only convert date to string if it's set --- src/routes/helpers/transformers.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/routes/helpers/transformers.js b/src/routes/helpers/transformers.js index a28f7ac..2f273db 100644 --- a/src/routes/helpers/transformers.js +++ b/src/routes/helpers/transformers.js @@ -88,7 +88,7 @@ export const transformJsonLD = (type, data) => { // A DateTime object should be formatted as an iso8601 date // TODO: there is also a LocalDateTime, but we don't appear to use it - if (isDateTime(elementValue)) { + if (elementValue && isDateTime(elementValue)) { elementValue = elementValue.toString() } From 5a6bdb833def239b22fa8d5a28bf36fa49e8ff14 Mon Sep 17 00:00:00 2001 From: Alastair Porter Date: Tue, 22 Jun 2021 15:28:49 +0200 Subject: [PATCH 2/6] feat(json-ld): Make DefinedTerm an oa:Motivation if broader is set --- src/routes/helpers/jsonld/DefinedTerm.json | 5 +- src/routes/helpers/transformer.test.js | 123 +++++++++++++++++++++ src/routes/helpers/transformers.js | 32 ++++++ 3 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 src/routes/helpers/transformer.test.js diff --git a/src/routes/helpers/jsonld/DefinedTerm.json b/src/routes/helpers/jsonld/DefinedTerm.json index f37c798..2380af9 100644 --- a/src/routes/helpers/jsonld/DefinedTerm.json +++ b/src/routes/helpers/jsonld/DefinedTerm.json @@ -99,7 +99,10 @@ "inDefinedTermSet": [ "https://pending.schema.org/inDefinedTermSet" ], - "broader": [ + "broaderUrl": [ + "http://www.w3.org/2004/02/skos/core#broader" + ], + "broaderMotivation": [ "http://www.w3.org/2004/02/skos/core#broader" ] } diff --git a/src/routes/helpers/transformer.test.js b/src/routes/helpers/transformer.test.js new file mode 100644 index 0000000..f97d1cc --- /dev/null +++ b/src/routes/helpers/transformer.test.js @@ -0,0 +1,123 @@ +import {preprocessDefinedTerm, transformJsonLD} from "./transformers"; +import { DateTime } from 'neo4j-driver/lib/temporal-types' + + +describe('definedTermTest', function () { + const sampleDocument = { + "broaderMotivation": "commenting", + "modified": new DateTime(2021, 6, 22, 10, 50, 24, 292000000, 0, null), + "broaderUrl": null, + "identifier": "77c376dd-009b-4460-a94c-ade21b1aa772", + "image": "https://alastair.trompa-solid.upf.edu/annotation-images/slur.png", + "additionalProperty": [], + "creator": "https://alastair.trompa-solid.upf.edu/profile/card#me", + "created": new DateTime(2021, 6, 22, 10, 50, 24, 292000000, 0, null), + "additionalType": [ + "https://vocab.trompamusic.eu/vocab#AnnotationMotivationCollectionElement", + "http://www.w3.org/ns/oa#Motivation" + ], + "potentialAction": [], + "inDefinedTermSet": [ + "http://localhost:4000/7c0e9ac0-942a-422a-89b8-884444da6b5a" + ], + "termCode": "Slur" + } + + it('generates jsonld using broaderMotivation', function() { + const response = transformJsonLD("DefinedTerm", sampleDocument); + expect(response["skos:broader"]).toEqual("oa:commenting"); + }) + + it('generates jsonld using broaderUrl', function() { + sampleDocument["broaderMotivation"] = null; + sampleDocument["broaderUrl"] = "https://example.com#motivation"; + const response = transformJsonLD("DefinedTerm", sampleDocument); + expect(response["skos:broader"]).toEqual("https://example.com#motivation"); + }) + + it('Adds oa:Motivation additionalType if broader enum is present and type is not set', function () { + const definedTerm = { + "broaderMotivation": "commenting", + "broaderUrl": null, + "additionalProperty": [], + "additionalType": [ + "https://vocab.trompamusic.eu/vocab#AnnotationMotivationCollectionElement" + ], + "inDefinedTermSet": [ + "http://localhost:4000/7c0e9ac0-942a-422a-89b8-884444da6b5a" + ], + "termCode": "Slur" + } + const result = preprocessDefinedTerm(definedTerm); + expect(result["additionalType"].includes("https://www.w3.org/ns/oa#Motivation")).toEqual(true); + expect(result["additionalType"].length).toEqual(2); + }); + + it('Adds oa:Motivation additionalType if broader url is present and type is not set', function () { + const definedTerm = { + "broaderMotivation": null, + "broaderUrl": "https://example.com#Motivation", + "additionalProperty": [], + "additionalType": [ + "https://vocab.trompamusic.eu/vocab#AnnotationMotivationCollectionElement" + ], + "inDefinedTermSet": [ + "http://localhost:4000/7c0e9ac0-942a-422a-89b8-884444da6b5a" + ], + "termCode": "Slur" + } + const result = preprocessDefinedTerm(definedTerm); + expect(result["additionalType"].includes("https://www.w3.org/ns/oa#Motivation")).toEqual(true); + expect(result["additionalType"].length).toEqual(2); + }); + + it("Doesn't add oa:Motivation additionalType if it already exists", function () { + const definedTerm = { + "broaderMotivation": null, + "broaderUrl": "https://example.com#Motivation", + "additionalProperty": [], + "additionalType": [ + "https://vocab.trompamusic.eu/vocab#AnnotationMotivationCollectionElement", + "http://www.w3.org/ns/oa#Motivation" + ], + "inDefinedTermSet": [ + "http://localhost:4000/7c0e9ac0-942a-422a-89b8-884444da6b5a" + ], + "termCode": "Slur" + } + const result = preprocessDefinedTerm(definedTerm); + expect(result["additionalType"].includes("http://www.w3.org/ns/oa#Motivation")).toEqual(true); + expect(result["additionalType"].includes("https://www.w3.org/ns/oa#Motivation")).toEqual(false); + expect(result["additionalType"].length).toEqual(2); + }); + + it("Doesn't add oa:Motivation additionalType if broader isn't set", function () { + const definedTerm = { + "broaderMotivation": null, + "broaderUrl": null, + "additionalProperty": [], + "additionalType": null, + "inDefinedTermSet": [ + "http://localhost:4000/7c0e9ac0-942a-422a-89b8-884444da6b5a" + ], + "termCode": "Slur" + } + const result = preprocessDefinedTerm(definedTerm); + expect(result["additionalType"]).toEqual(null); + }); + + it('Prefixes motivation enums with oa: namespace', function () { + const definedTerm = { + "broaderMotivation": "commenting", + "broaderUrl": null, + "additionalProperty": [], + "additionalType": null, + "inDefinedTermSet": [ + "http://localhost:4000/7c0e9ac0-942a-422a-89b8-884444da6b5a" + ], + "termCode": "Slur" + } + const result = preprocessDefinedTerm(definedTerm); + expect(result["broaderMotivation"]).toEqual("oa:commenting"); + }); +}); diff --git a/src/routes/helpers/transformers.js b/src/routes/helpers/transformers.js index 2f273db..bdda7bc 100644 --- a/src/routes/helpers/transformers.js +++ b/src/routes/helpers/transformers.js @@ -61,6 +61,11 @@ export const transformJsonLD = (type, data) => { if (!config) { throw new Error(`JSON LD not supported for type "${type}"`) } + + if (type === "DefinedTerm") { + data = preprocessDefinedTerm(data); + } + const jsonldRelationalProperties = config.relationalProperties || [] // Base JSON-LD document @@ -148,3 +153,30 @@ export const transformJsonLD = (type, data) => { return jsonLdData } + + +/** + * Preprocess data for a DefinedTerm before converting to JSON-LD. + * If a DefinedTerm has a motivation set (either broaderMotivation or + * broaderUrl), then add oa:Motivation to additionalTypes. + * + * If broaderMotivation is set (graphql enum), prefix it with the oa: namespace. + * @param data the result from document.getDocument + */ +export function preprocessDefinedTerm(data) { + const additionalType = data["additionalType"]; + const hasMotivationAdditionalType = additionalType && (additionalType.includes("http://www.w3.org/ns/oa#Motivation") || + additionalType.includes("https://www.w3.org/ns/oa#Motivation")) + if ((data["broaderUrl"] || data["broaderMotivation"]) && !hasMotivationAdditionalType) { + if (additionalType) { + data["additionalType"].push("https://www.w3.org/ns/oa#Motivation"); + } else { + data["additionalType"] = ["https://www.w3.org/ns/oa#Motivation"]; + } + } + + if (data["broaderMotivation"]) { + data["broaderMotivation"] = "oa:" + data["broaderMotivation"]; + } + return data +} From abce56e327efa5aa4347f61b68e1d5bb75e9ab6e Mon Sep 17 00:00:00 2001 From: Alastair Porter Date: Tue, 22 Jun 2021 15:30:01 +0200 Subject: [PATCH 3/6] fix(json-ld): Delete additionalType after it has been hoisted to @type --- src/routes/helpers/transformers.js | 1 + 1 file changed, 1 insertion(+) diff --git a/src/routes/helpers/transformers.js b/src/routes/helpers/transformers.js index bdda7bc..adb0cd7 100644 --- a/src/routes/helpers/transformers.js +++ b/src/routes/helpers/transformers.js @@ -81,6 +81,7 @@ export const transformJsonLD = (type, data) => { // the values are valid URLs. if (Array.isArray(data.additionalType)) { jsonLdData['@type'] = [...jsonLdData['@type'], ...data.additionalType] + delete data.additionalType; } // Iterate all keys in the data document From 086a1977df38ddcf4837a5a272b45a43000629d2 Mon Sep 17 00:00:00 2001 From: Alastair Porter Date: Tue, 22 Jun 2021 18:25:59 +0200 Subject: [PATCH 4/6] feat(json-ld): Special-case ItemList JSON-LD Output In the case that a list has items, we should also include information about those items in the output, rather than requiring clients to perform an additional query per item to get that information --- src/routes/helpers/document.js | 67 ++++++++++++++++++++ src/routes/helpers/jsonld/ItemList.json | 1 + src/routes/helpers/jsonld/ListItem.json | 2 +- src/routes/helpers/queries/ItemList.cypher | 1 + src/routes/helpers/queries/ItemList.query | 30 +++++++++ src/routes/helpers/testdata/itemlist.json | 46 ++++++++++++++ src/routes/helpers/transformer.test.js | 33 ++++++++++ src/routes/helpers/transformers.js | 71 +++++++++++++++++++++- src/routes/index.js | 2 +- 9 files changed, 248 insertions(+), 5 deletions(-) create mode 100644 src/routes/helpers/queries/ItemList.cypher create mode 100644 src/routes/helpers/queries/ItemList.query create mode 100644 src/routes/helpers/testdata/itemlist.json diff --git a/src/routes/helpers/document.js b/src/routes/helpers/document.js index 2ed6f6e..fa3efe7 100644 --- a/src/routes/helpers/document.js +++ b/src/routes/helpers/document.js @@ -3,6 +3,8 @@ import { driver } from '../../driver' import GetTypeQuery from '../../queries/GetTypeQuery' import GetFullNodeQuery from '../../queries/GetFullNodeQuery' import SchemaHelper from '../../helpers/SchemaHelper' +import fs from 'fs' +import path from 'path' export const getDocument = (identifier, host) => { const session = driver.session() @@ -30,6 +32,15 @@ export const getDocument = (identifier, host) => { return Promise.reject(new Error('Node not found')) } + // Special-case some types to perform a custom query to get additional relations in a single query + // Unfortunately neo4j-graphql-js doesn't provide an easy way to do this, so we do the following: + // Start up the CE with DEBUG=neo4j-graphql-js + // Take the file ./queries/TYPE.query and run it in the web interface + // Copy the query which is written to the console to ./queries/TYPE.cypher + if (type === "ItemList") { + return getItemList(session, identifier).then(data => ({data, type})); + } + return getNodeProperties(session, type, identifier, host) .then(data => ({ data, type })) }, reason => { @@ -41,6 +52,62 @@ export const getDocument = (identifier, host) => { }) } +const getItemList = (session, identifier) => { + const query = fs.readFileSync(path.resolve(__dirname, './queries/ItemList.cypher'), {encoding:'utf8', flag:'r'}); + return session.run(query, {identifier: identifier, ThingInterface_derivedTypes: [ + "Action", + "AddAction", + "Annotation", + "Article", + "Audience", + "AudioObject", + "ControlAction", + "DataDownload", + "Dataset", + "DefinedTerm", + "DefinedTermSet", + "DeleteAction", + "DigitalDocument", + "DigitalDocumentPermission", + "EntryPoint", + "Event", + "ImageObject", + "Intangible", + "ItemList", + "ListItem", + "MediaObject", + "MusicAlbum", + "MusicComposition", + "MusicGroup", + "MusicPlaylist", + "MusicRecording", + "Occupation", + "Organization", + "Person", + "Place", + "Product", + "Property", + "PropertyValue", + "PropertyValueSpecification", + "Rating", + "ReplaceAction", + "Review", + "SoftwareApplication", + "VideoObject" + ]}) + .then(fullResult => { + if (fullResult.records.length) { + return fullResult.records[0].get('itemList') + } else { + return undefined; + } + }) + .catch(function (error) { + info('_getItemList caught error: ' + error.message) + throw error + }) +} + const getNodeProperties = (session, type, identifier, host) => { const getFullNodeQuery = new GetFullNodeQuery(type, identifier, host, 2) const query = getFullNodeQuery.query diff --git a/src/routes/helpers/jsonld/ItemList.json b/src/routes/helpers/jsonld/ItemList.json index 3e6681c..d7e55d9 100644 --- a/src/routes/helpers/jsonld/ItemList.json +++ b/src/routes/helpers/jsonld/ItemList.json @@ -6,6 +6,7 @@ "http://www.w3.org/ns/ldp#BasicContainer" ] }, + "nonRelationalProperties": ["itemListElement"], "properties": { "identifier": [ "http://purl.org/dc/terms/identifier", diff --git a/src/routes/helpers/jsonld/ListItem.json b/src/routes/helpers/jsonld/ListItem.json index 78e2249..fb740bb 100644 --- a/src/routes/helpers/jsonld/ListItem.json +++ b/src/routes/helpers/jsonld/ListItem.json @@ -4,7 +4,7 @@ "https://schema.org/ListItem" ] }, - "relationalProperties": ["itemUrl"], + "relationalProperties": ["itemUrl", "item"], "properties": { "identifier": [ "http://purl.org/dc/terms/identifier", diff --git a/src/routes/helpers/queries/ItemList.cypher b/src/routes/helpers/queries/ItemList.cypher new file mode 100644 index 0000000..40180c9 --- /dev/null +++ b/src/routes/helpers/queries/ItemList.cypher @@ -0,0 +1 @@ +MATCH (`itemList`:`ItemList` {identifier:$identifier}) RETURN `itemList` { .identifier , .name , .creator ,created: { formatted: toString(`itemList`.created) },modified: { formatted: toString(`itemList`.modified) }, .additionalType , .itemListOrder ,itemListElement: [(`itemList`)-[:`ITEM_LIST_ELEMENT`]->(`itemList_itemListElement`:`ThingInterface`) WHERE ("Action" IN labels(`itemList_itemListElement`) OR "AddAction" IN labels(`itemList_itemListElement`) OR "Annotation" IN labels(`itemList_itemListElement`) OR "Article" IN labels(`itemList_itemListElement`) OR "Audience" IN labels(`itemList_itemListElement`) OR "AudioObject" IN labels(`itemList_itemListElement`) OR "ControlAction" IN labels(`itemList_itemListElement`) OR "DataDownload" IN labels(`itemList_itemListElement`) OR "Dataset" IN labels(`itemList_itemListElement`) OR "DefinedTerm" IN labels(`itemList_itemListElement`) OR "DefinedTermSet" IN labels(`itemList_itemListElement`) OR "DeleteAction" IN labels(`itemList_itemListElement`) OR "DigitalDocument" IN labels(`itemList_itemListElement`) OR "DigitalDocumentPermission" IN labels(`itemList_itemListElement`) OR "EntryPoint" IN labels(`itemList_itemListElement`) OR "Event" IN labels(`itemList_itemListElement`) OR "ImageObject" IN labels(`itemList_itemListElement`) OR "Intangible" IN labels(`itemList_itemListElement`) OR "ItemList" IN labels(`itemList_itemListElement`) OR "ListItem" IN labels(`itemList_itemListElement`) OR "MediaObject" IN labels(`itemList_itemListElement`) OR "MusicAlbum" IN labels(`itemList_itemListElement`) OR "MusicComposition" IN labels(`itemList_itemListElement`) OR "MusicGroup" IN labels(`itemList_itemListElement`) OR "MusicPlaylist" IN labels(`itemList_itemListElement`) OR "MusicRecording" IN labels(`itemList_itemListElement`) OR "Occupation" IN labels(`itemList_itemListElement`) OR "Organization" IN labels(`itemList_itemListElement`) OR "Person" IN labels(`itemList_itemListElement`) OR "Place" IN labels(`itemList_itemListElement`) OR "Product" IN labels(`itemList_itemListElement`) OR "Property" IN labels(`itemList_itemListElement`) OR "PropertyValue" IN labels(`itemList_itemListElement`) OR "PropertyValueSpecification" IN labels(`itemList_itemListElement`) OR "Rating" IN labels(`itemList_itemListElement`) OR "ReplaceAction" IN labels(`itemList_itemListElement`) OR "Review" IN labels(`itemList_itemListElement`) OR "SoftwareApplication" IN labels(`itemList_itemListElement`) OR "VideoObject" IN labels(`itemList_itemListElement`)) | head([`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "Action" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "Action", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "AddAction" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "AddAction", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "Annotation" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "Annotation", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "Article" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "Article", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "Audience" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "Audience", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "AudioObject" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "AudioObject", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "ControlAction" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "ControlAction", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "DataDownload" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "DataDownload", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "Dataset" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "Dataset", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "DefinedTerm" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "DefinedTerm", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "DefinedTermSet" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "DefinedTermSet", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "DeleteAction" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "DeleteAction", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "DigitalDocument" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "DigitalDocument", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "DigitalDocumentPermission" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "DigitalDocumentPermission", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "EntryPoint" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "EntryPoint", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "Event" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "Event", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "ImageObject" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "ImageObject", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "Intangible" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "Intangible", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "ItemList" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "ItemList", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "ListItem" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "ListItem", .itemUrl ,item: [(`itemList_itemListElement`)-[:`ITEM`]->(`itemList_itemListElement_item`:`ThingInterface`) | `itemList_itemListElement_item` {FRAGMENT_TYPE: head( [ label IN labels(`itemList_itemListElement_item`) WHERE label IN $ThingInterface_derivedTypes ] ), .identifier }] , .position , .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "MediaObject" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "MediaObject", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "MusicAlbum" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "MusicAlbum", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "MusicComposition" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "MusicComposition", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "MusicGroup" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "MusicGroup", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "MusicPlaylist" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "MusicPlaylist", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "MusicRecording" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "MusicRecording", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "Occupation" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "Occupation", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "Organization" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "Organization", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "Person" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "Person", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "Place" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "Place", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "Product" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "Product", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "Property" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "Property", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "PropertyValue" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "PropertyValue", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "PropertyValueSpecification" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "PropertyValueSpecification", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "Rating" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "Rating", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "ReplaceAction" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "ReplaceAction", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "Review" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "Review", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "SoftwareApplication" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "SoftwareApplication", .identifier , .name , .description , .image , .url }] + [`itemList_itemListElement` IN [`itemList_itemListElement`] WHERE "VideoObject" IN labels(`itemList_itemListElement`) | `itemList_itemListElement` { FRAGMENT_TYPE: "VideoObject", .identifier , .name , .description , .image , .url }])] } AS `itemList` \ No newline at end of file diff --git a/src/routes/helpers/queries/ItemList.query b/src/routes/helpers/queries/ItemList.query new file mode 100644 index 0000000..c6b2c3b --- /dev/null +++ b/src/routes/helpers/queries/ItemList.query @@ -0,0 +1,30 @@ +query { + ItemList(identifier:"a1a839c0-4b6b-49e3-ac11-384568ce0caf") { + identifier + name + creator + created { + formatted + } + modified { + formatted + } + itemListOrder + additionalType + itemListElement { + __typename + identifier + name + description + image + url + ... on ListItem { + itemUrl + item { + identifier + } + position + } + } + } +} \ No newline at end of file diff --git a/src/routes/helpers/testdata/itemlist.json b/src/routes/helpers/testdata/itemlist.json new file mode 100644 index 0000000..7412de4 --- /dev/null +++ b/src/routes/helpers/testdata/itemlist.json @@ -0,0 +1,46 @@ +{ + "identifier": "a1a839c0-4b6b-49e3-ac11-384568ce0caf", + "creator": "https://testuser.trompa-solid.upf.edu/profile/card#me", + "itemListElement": [ + { + "identifier": "bb418c6b-7667-4559-96de-5716d9d52e2e", + "image": null, + "item": [ + { + "FRAGMENT_TYPE": "Annotation", + "identifier": "e03dd66d-17ba-4d91-a409-d1eb2cb3d3bb" + } + ], + "name": null, + "description": null, + "FRAGMENT_TYPE": "ListItem", + "position": null, + "url": null, + "itemUrl": null + }, + { + "identifier": "551877a6-c61b-4fc7-8174-17247d460823", + "image": null, + "item": null, + "name": null, + "description": null, + "FRAGMENT_TYPE": "ListItem", + "position": null, + "url": null, + "itemUrl": "https://example.com/externalItem" + }, + { + "identifier": "f3ed1b21-cc30-43be-805a-ce2b56b78e09", + "name": null, + "description": null, + "FRAGMENT_TYPE": "DigitalDocument", + "image": null, + "url": null + } + ], + "additionalType": [ "https://vocab.trompamusic.eu/vocab#AnnotationSession" ], + "created": { "formatted": "2021-06-22T13:47:10.693Z" }, + "name": "Some session", + "modified": { "formatted": "2021-06-22T13:47:10.693Z" }, + "itemListOrder": "ItemListUnordered" +} \ No newline at end of file diff --git a/src/routes/helpers/transformer.test.js b/src/routes/helpers/transformer.test.js index f97d1cc..b811eb2 100644 --- a/src/routes/helpers/transformer.test.js +++ b/src/routes/helpers/transformer.test.js @@ -121,3 +121,36 @@ describe('definedTermTest', function () { expect(result["broaderMotivation"]).toEqual("oa:commenting"); }); }); + + +describe("ItemList test", function() { + it("Converts custom ItemList response to json-ld", function() { + const itemListResponse = require("./testdata/itemlist.json"); + const itemListJsonLd = transformJsonLD("ItemList", itemListResponse, "http://localhost"); + + expect(itemListJsonLd["@type"]).toEqual(["https://schema.org/ItemList", "http://www.w3.org/ns/ldp#Container", "http://www.w3.org/ns/ldp#BasicContainer", "https://vocab.trompamusic.eu/vocab#AnnotationSession"]); + // Created and modified are adjusted before export + expect(itemListJsonLd["dc:created"]).toEqual("2021-06-22T13:47:10.693Z"); + expect(itemListJsonLd["dc:modified"]).toEqual("2021-06-22T13:47:10.693Z"); + // First element, a ListItem whose `item` points to another thing in the CE + const expectedElementOne = { + "@id": "http://localhost/bb418c6b-7667-4559-96de-5716d9d52e2e", + "@type": ["https://schema.org/ListItem"], + "dc:identifier": "http://localhost/bb418c6b-7667-4559-96de-5716d9d52e2e", + "identifier": "http://localhost/bb418c6b-7667-4559-96de-5716d9d52e2e", + "item": {"@id": "http://localhost/e03dd66d-17ba-4d91-a409-d1eb2cb3d3bb"} + } + expect(itemListJsonLd.itemListElement[0]).toEqual(expectedElementOne); + // Second element, a ListItem whose `itemUrl` points to an external URL + const expectedElementType = { + "@id": "http://localhost/551877a6-c61b-4fc7-8174-17247d460823", + "@type": ["https://schema.org/ListItem"], + "dc:identifier": "http://localhost/551877a6-c61b-4fc7-8174-17247d460823", + "identifier": "http://localhost/551877a6-c61b-4fc7-8174-17247d460823", + "item": {"@id": "https://example.com/externalItem"} + } + expect(itemListJsonLd.itemListElement[1]).toEqual(expectedElementType); + // Third element, a DigitalDocument in the CE with no ListItem + expect(itemListJsonLd.itemListElement[2]).toEqual({"@id": "http://localhost/f3ed1b21-cc30-43be-805a-ce2b56b78e09"}); + }); +}); \ No newline at end of file diff --git a/src/routes/helpers/transformers.js b/src/routes/helpers/transformers.js index adb0cd7..30357b3 100644 --- a/src/routes/helpers/transformers.js +++ b/src/routes/helpers/transformers.js @@ -42,10 +42,10 @@ const isEmpty = value => { * Transform document to a JSON-LD structured document * @param {string} type * @param {Object} data - * @param {string} required_language: the language that this data should be in + * @param {string} baseUrl: the base URL of the server (used to prefix ids if needed) * @returns {Object} JSON-LD structured document */ -export const transformJsonLD = (type, data) => { +export const transformJsonLD = (type, data, baseUrl) => { const schemaHelper = new SchemaHelper() const prefixes = Object.keys(scopedContexts) @@ -66,7 +66,14 @@ export const transformJsonLD = (type, data) => { data = preprocessDefinedTerm(data); } + if (type === "ItemList") { + data = preprocessItemList(data, baseUrl); + } + + // A property that we want to force to be relational (render as {"@id": value}) const jsonldRelationalProperties = config.relationalProperties || [] + // A property that we want to force as not being relational, even if GraphQL thinks it is + const jsonldNonRelationalProperties = config.nonRelationalProperties || [] // Base JSON-LD document const jsonLdData = { @@ -99,7 +106,11 @@ export const transformJsonLD = (type, data) => { } // Transform the value when it's a relational property - if (elementValue && (schemaHelper.isRelationalProperty(property) || jsonldRelationalProperties.includes(key))) { + // Sometimes GraphQL knows that this is a relational property but we have preprocessed the data in + // a way that we don't want this behaviour. In this case, use nonRelationalProperties + // in the relevant json specification file. + if (elementValue && !jsonldNonRelationalProperties.includes(key) && + (schemaHelper.isRelationalProperty(property) || jsonldRelationalProperties.includes(key))) { if (Array.isArray(elementValue)) { elementValue = elementValue.map(id => ({ '@id': id @@ -108,6 +119,10 @@ export const transformJsonLD = (type, data) => { elementValue = { '@id': elementValue } + } else if (typeof elementValue === 'object' && elementValue.identifier) { + elementValue = { + '@id': elementValue.identifier + } } } @@ -155,6 +170,56 @@ export const transformJsonLD = (type, data) => { return jsonLdData } +/** + * Preprocess data for an ItemList before converting to JSON-LD. + * + * The ItemList.itemListElement field could be a reference to an external + * object, in which case it should be rendered as + * {"@id": "external object identifier"} + * or it could be a ListItem object, in which case we should render the contents + * of the object inline: + * {"@id": "CE ListItem identifier", + * "name": Name of the item} + * or it could be a ListItem with its `item` field pointing to another element: + * {"@id": "CE ListItem identifier", + * "item": {"@id": "ID of the thing that this points to} + * } + * + * @param data + * @param baseUrl + */ +export function preprocessItemList(data, baseUrl) { + if (data.itemListElement && Array.isArray(data.itemListElement)) { + data.itemListElement = data.itemListElement.map(element => { + if (element.FRAGMENT_TYPE === "ListItem") { + if (element.item && Array.isArray(element.item) && element.item.length) { + // We only expect to have one related item + element.item = element.item[0] + element.item.identifier = baseUrl + "/" + element.item.identifier; + } + delete element.FRAGMENT_TYPE; + element.identifier = baseUrl + "/" + element.identifier; + const listItemJson = transformJsonLD("ListItem", element, baseUrl) + listItemJson["@id"] = listItemJson.identifier + // We use transformJsonLD but we don't want the context, this is present in the + // surrounding object + delete listItemJson["@context"]; + return listItemJson + } else { + // This is some other item in the CE which isn't a ListItem, just link to it + return {"@id": baseUrl + "/" + element.identifier} + } + }); + } + if (data["created"] && data["created"].formatted) { + data["created"] = data["created"].formatted + } + if (data["modified"] && data["modified"].formatted) { + data["modified"] = data["modified"].formatted + } + return data; +} + /** * Preprocess data for a DefinedTerm before converting to JSON-LD. diff --git a/src/routes/index.js b/src/routes/index.js index a283231..acec0bf 100644 --- a/src/routes/index.js +++ b/src/routes/index.js @@ -36,7 +36,7 @@ router.get('/:identifier', (req, res) => { if (!isValidLanguage(data, acceptLang)) { return res.status(406).send() } - return res.status(200).send(transformJsonLD(type, data)) + return res.status(200).send(transformJsonLD(type, data, baseURL)) } res.status(200).send(data) From 266402883fcbdb47f7fbe61defe78cff7f43b6b7 Mon Sep 17 00:00:00 2001 From: Alastair Porter Date: Tue, 13 Jul 2021 11:16:21 +0200 Subject: [PATCH 5/6] feat(schema): Move skos:broader from DefinedTerm to DefinedTermSet After using the previous model we realised that it made more sense to have a DTS as the equivalent of an annotation "motivation", and use a related term as the annotation's body. This moves broaderUrl and broaderMotivation from DT to DTS, and changes supporting code --- src/routes/helpers/jsonld/DefinedTerm.json | 6 -- src/routes/helpers/jsonld/DefinedTermSet.json | 6 ++ src/routes/helpers/transformer.test.js | 56 +++++++++---------- src/routes/helpers/transformers.js | 10 ++-- src/schema/type/Annotation.graphql | 4 +- src/schema/type/DefinedTerm.graphql | 12 ---- src/schema/type/DefinedTermSet.graphql | 10 ++++ 7 files changed, 49 insertions(+), 55 deletions(-) diff --git a/src/routes/helpers/jsonld/DefinedTerm.json b/src/routes/helpers/jsonld/DefinedTerm.json index 2380af9..a2e1fb0 100644 --- a/src/routes/helpers/jsonld/DefinedTerm.json +++ b/src/routes/helpers/jsonld/DefinedTerm.json @@ -98,12 +98,6 @@ ], "inDefinedTermSet": [ "https://pending.schema.org/inDefinedTermSet" - ], - "broaderUrl": [ - "http://www.w3.org/2004/02/skos/core#broader" - ], - "broaderMotivation": [ - "http://www.w3.org/2004/02/skos/core#broader" ] } } \ No newline at end of file diff --git a/src/routes/helpers/jsonld/DefinedTermSet.json b/src/routes/helpers/jsonld/DefinedTermSet.json index 519e6b2..4e13fb1 100644 --- a/src/routes/helpers/jsonld/DefinedTermSet.json +++ b/src/routes/helpers/jsonld/DefinedTermSet.json @@ -248,6 +248,12 @@ ], "hasDefinedTerm": [ "https://pending.schema.org/hasDefinedTerm" + ], + "broaderUrl": [ + "http://www.w3.org/2004/02/skos/core#broader" + ], + "broaderMotivation": [ + "http://www.w3.org/2004/02/skos/core#broader" ] } } \ No newline at end of file diff --git a/src/routes/helpers/transformer.test.js b/src/routes/helpers/transformer.test.js index b811eb2..010b25a 100644 --- a/src/routes/helpers/transformer.test.js +++ b/src/routes/helpers/transformer.test.js @@ -1,78 +1,74 @@ -import {preprocessDefinedTerm, transformJsonLD} from "./transformers"; +import {preprocessDefinedTermSet, transformJsonLD} from "./transformers"; import { DateTime } from 'neo4j-driver/lib/temporal-types' -describe('definedTermTest', function () { +describe('definedTermSetTest', function () { const sampleDocument = { "broaderMotivation": "commenting", "modified": new DateTime(2021, 6, 22, 10, 50, 24, 292000000, 0, null), "broaderUrl": null, "identifier": "77c376dd-009b-4460-a94c-ade21b1aa772", "image": "https://alastair.trompa-solid.upf.edu/annotation-images/slur.png", - "additionalProperty": [], "creator": "https://alastair.trompa-solid.upf.edu/profile/card#me", "created": new DateTime(2021, 6, 22, 10, 50, 24, 292000000, 0, null), "additionalType": [ "https://vocab.trompamusic.eu/vocab#AnnotationMotivationCollectionElement", "http://www.w3.org/ns/oa#Motivation" ], - "potentialAction": [], - "inDefinedTermSet": [ + "hasDefinedTerm": [ "http://localhost:4000/7c0e9ac0-942a-422a-89b8-884444da6b5a" ], - "termCode": "Slur" + "name": "Performance Directions" } it('generates jsonld using broaderMotivation', function() { - const response = transformJsonLD("DefinedTerm", sampleDocument); + const response = transformJsonLD("DefinedTermSet", sampleDocument, "http://localhost:4000"); expect(response["skos:broader"]).toEqual("oa:commenting"); }) it('generates jsonld using broaderUrl', function() { sampleDocument["broaderMotivation"] = null; sampleDocument["broaderUrl"] = "https://example.com#motivation"; - const response = transformJsonLD("DefinedTerm", sampleDocument); + const response = transformJsonLD("DefinedTermSet", sampleDocument, "http://localhost:4000"); expect(response["skos:broader"]).toEqual("https://example.com#motivation"); }) it('Adds oa:Motivation additionalType if broader enum is present and type is not set', function () { - const definedTerm = { + const definedTermSet = { "broaderMotivation": "commenting", "broaderUrl": null, - "additionalProperty": [], "additionalType": [ "https://vocab.trompamusic.eu/vocab#AnnotationMotivationCollectionElement" ], - "inDefinedTermSet": [ + "hasDefinedTerm": [ "http://localhost:4000/7c0e9ac0-942a-422a-89b8-884444da6b5a" ], - "termCode": "Slur" + "name": "Performance Directions" } - const result = preprocessDefinedTerm(definedTerm); + const result = preprocessDefinedTermSet(definedTermSet); expect(result["additionalType"].includes("https://www.w3.org/ns/oa#Motivation")).toEqual(true); expect(result["additionalType"].length).toEqual(2); }); it('Adds oa:Motivation additionalType if broader url is present and type is not set', function () { - const definedTerm = { + const definedTermSet = { "broaderMotivation": null, "broaderUrl": "https://example.com#Motivation", - "additionalProperty": [], "additionalType": [ "https://vocab.trompamusic.eu/vocab#AnnotationMotivationCollectionElement" ], - "inDefinedTermSet": [ + "hasDefinedTerm": [ "http://localhost:4000/7c0e9ac0-942a-422a-89b8-884444da6b5a" ], - "termCode": "Slur" + "name": "Performance Directions" } - const result = preprocessDefinedTerm(definedTerm); + const result = preprocessDefinedTermSet(definedTermSet); expect(result["additionalType"].includes("https://www.w3.org/ns/oa#Motivation")).toEqual(true); expect(result["additionalType"].length).toEqual(2); }); it("Doesn't add oa:Motivation additionalType if it already exists", function () { - const definedTerm = { + const definedTermSet = { "broaderMotivation": null, "broaderUrl": "https://example.com#Motivation", "additionalProperty": [], @@ -80,44 +76,44 @@ describe('definedTermTest', function () { "https://vocab.trompamusic.eu/vocab#AnnotationMotivationCollectionElement", "http://www.w3.org/ns/oa#Motivation" ], - "inDefinedTermSet": [ + "hasDefinedTerm": [ "http://localhost:4000/7c0e9ac0-942a-422a-89b8-884444da6b5a" ], - "termCode": "Slur" + "name": "Performance Directions" } - const result = preprocessDefinedTerm(definedTerm); + const result = preprocessDefinedTermSet(definedTermSet); expect(result["additionalType"].includes("http://www.w3.org/ns/oa#Motivation")).toEqual(true); expect(result["additionalType"].includes("https://www.w3.org/ns/oa#Motivation")).toEqual(false); expect(result["additionalType"].length).toEqual(2); }); it("Doesn't add oa:Motivation additionalType if broader isn't set", function () { - const definedTerm = { + const definedTermSet = { "broaderMotivation": null, "broaderUrl": null, "additionalProperty": [], "additionalType": null, - "inDefinedTermSet": [ + "hasDefinedTerm": [ "http://localhost:4000/7c0e9ac0-942a-422a-89b8-884444da6b5a" ], - "termCode": "Slur" + "name": "Performance Directions" } - const result = preprocessDefinedTerm(definedTerm); + const result = preprocessDefinedTermSet(definedTermSet); expect(result["additionalType"]).toEqual(null); }); it('Prefixes motivation enums with oa: namespace', function () { - const definedTerm = { + const definedTermSet = { "broaderMotivation": "commenting", "broaderUrl": null, "additionalProperty": [], "additionalType": null, - "inDefinedTermSet": [ + "hasDefinedTerm": [ "http://localhost:4000/7c0e9ac0-942a-422a-89b8-884444da6b5a" ], - "termCode": "Slur" + "name": "Performance Directions" } - const result = preprocessDefinedTerm(definedTerm); + const result = preprocessDefinedTermSet(definedTermSet); expect(result["broaderMotivation"]).toEqual("oa:commenting"); }); }); diff --git a/src/routes/helpers/transformers.js b/src/routes/helpers/transformers.js index 30357b3..2e47cd0 100644 --- a/src/routes/helpers/transformers.js +++ b/src/routes/helpers/transformers.js @@ -62,8 +62,8 @@ export const transformJsonLD = (type, data, baseUrl) => { throw new Error(`JSON LD not supported for type "${type}"`) } - if (type === "DefinedTerm") { - data = preprocessDefinedTerm(data); + if (type === "DefinedTermSet") { + data = preprocessDefinedTermSet(data); } if (type === "ItemList") { @@ -222,14 +222,14 @@ export function preprocessItemList(data, baseUrl) { /** - * Preprocess data for a DefinedTerm before converting to JSON-LD. - * If a DefinedTerm has a motivation set (either broaderMotivation or + * Preprocess data for a DefinedTermSet before converting to JSON-LD. + * If a DefinedTermSet has a motivation set (either broaderMotivation or * broaderUrl), then add oa:Motivation to additionalTypes. * * If broaderMotivation is set (graphql enum), prefix it with the oa: namespace. * @param data the result from document.getDocument */ -export function preprocessDefinedTerm(data) { +export function preprocessDefinedTermSet(data) { const additionalType = data["additionalType"]; const hasMotivationAdditionalType = additionalType && (additionalType.includes("http://www.w3.org/ns/oa#Motivation") || additionalType.includes("https://www.w3.org/ns/oa#Motivation")) diff --git a/src/schema/type/Annotation.graphql b/src/schema/type/Annotation.graphql index 7cae0ae..8d740ae 100644 --- a/src/schema/type/Annotation.graphql +++ b/src/schema/type/Annotation.graphql @@ -80,10 +80,10 @@ type Annotation implements ThingInterface & ProvenanceEntityInterface { "An external resource that this annotation is about" targetUrl: [String] # An annotation should always have a motivation. If you want to use a more specific - # annotation from a toolkit (a DefinedTerm) then you can include it, but you should + # annotation from a toolkit (a DefinedTermSet) then you can include it, but you should # still add the base motivation. "http://www.w3.org/ns/oa#Motivation" - motivationDefinedTerm: DefinedTerm @relation(name: "ANNOTATON_MOTIVATION_DEFINED_TERM", direction: OUT) + motivationDefinedTermSet: DefinedTermSet @relation(name: "ANNOTATON_MOTIVATION_DEFINED_TERM_SET", direction: OUT) "http://www.w3.org/ns/oa#Motivation" motivation: AnnotationMotivation! motivationUrl: [String] diff --git a/src/schema/type/DefinedTerm.graphql b/src/schema/type/DefinedTerm.graphql index 39a0505..e858f01 100644 --- a/src/schema/type/DefinedTerm.graphql +++ b/src/schema/type/DefinedTerm.graphql @@ -69,16 +69,4 @@ type DefinedTerm implements ThingInterface { # TODO: This should only be a relation to one DefinedTermSet "https://pending.schema.org/inDefinedTermSet" inDefinedTermSet: [DefinedTermSet] @relation(name: "HAS_DEFINED_TERM", direction: IN) - - ####################### - ### SKOS properties ### - # When we use a DefinedTerm to refer to an annotation motivation, we have to add the motivation - # of which this term is a more specific version of to skos:broader. If you do this, you should - # also add http://www.w3.org/ns/oa#Motivation as an additionalType - # If this DefinedTerm has a standard motivations as its broader value, set an enum in - # broaderMotivation. If it's a third party URL, use broaderUrl. - "http://www.w3.org/2004/02/skos/core#broader" - broaderUrl: String - "http://www.w3.org/2004/02/skos/core#broader" - broaderMotivation: AnnotationMotivation } diff --git a/src/schema/type/DefinedTermSet.graphql b/src/schema/type/DefinedTermSet.graphql index e9f8019..657639b 100644 --- a/src/schema/type/DefinedTermSet.graphql +++ b/src/schema/type/DefinedTermSet.graphql @@ -74,6 +74,16 @@ type DefinedTermSet implements CreativeWorkInterface & ThingInterface { "http://www.w3.org/2004/02/skos/core#relatedMatch" relatedMatch: [ThingInterface] @relation(name: "RELATED_MATCH", direction: OUT) + # When we use a DefinedTermSet to refer to an annotation motivation, we have to add the motivation + # of which this term is a more specific version of to skos:broader. If you do this, you should + # also add http://www.w3.org/ns/oa#Motivation as an additionalType + # If this DefinedTerm has a standard motivations as its broader value, set an enum in + # broaderMotivation. If it's a third party URL, use broaderUrl. + "http://www.w3.org/2004/02/skos/core#broader" + broaderUrl: String + "http://www.w3.org/2004/02/skos/core#broader" + broaderMotivation: AnnotationMotivation + ############################################ ### ProvenanceEntityInterface properties ### "http://www.w3.org/ns/prov#wasGeneratedBy" From 09a2ca1409cf6b447be7687549beee879b8bbbcb Mon Sep 17 00:00:00 2001 From: Alastair Porter Date: Tue, 13 Jul 2021 11:18:41 +0200 Subject: [PATCH 6/6] feat(schema): Add oa:canonical and oa:via to Annotation data model --- src/schema/type/Annotation.graphql | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/schema/type/Annotation.graphql b/src/schema/type/Annotation.graphql index 8d740ae..f907937 100644 --- a/src/schema/type/Annotation.graphql +++ b/src/schema/type/Annotation.graphql @@ -96,6 +96,9 @@ type Annotation implements ThingInterface & ProvenanceEntityInterface { "A body which represents a node in the CE [will be rendered in the same way as bodyUrl]" bodyNode: [ThingInterface] @relation(name: "ANNOTATION_BODY_NODE", direction: OUT) + canonical: String + via: String + ################################# ### ThingInterface properties ### "https://schema.org/additionalType"