From 5e7a17c9c582ea153cdf3009bfc5a8179c876da1 Mon Sep 17 00:00:00 2001 From: Anton Wiklund Date: Sun, 8 Nov 2026 05:32:06 +0100 Subject: [PATCH] fix(solid): keep a literal's base direction from read to write A literal with an RDF 1.2 base direction ("..."@ar--rtl) that another app wrote into a pod document lost its language and direction when the app read the document. Every later write of that document then held a literal typed rdf:dirLangString with no language, which the write check rightly refuses, so a save of the preferences, the palettes or a large card edit, and any edit of that value, failed until the document was fixed outside the app. A Studio draft that held one could neither be edited nor published. Both clients now keep such a literal with its language and direction, in every dataset made from what was read, and tell two values apart by their direction in a dataset's changes. A dataset made from an RDF/JS dataset is now built as a read builds one, so it keeps the direction too and holds a repeated triple once. A draft's statements carry the direction as well, and tell "x"@ar from "x"@ar--rtl, and so does the check that a release already in the pod is the one being published. The PATCH body, the release writer, the JSON-LD export and the test pod's store write the direction after the language. The write check accepts a well-formed one: a language and ltr or rtl on a literal typed rdf:dirLangString. It still refuses one with no language, no direction or any other direction, or a direction on any other literal. The app writes no direction of its own. Such a literal is not read as text in a language, so the getters of that text leave it out, and setting a property's strings keeps it. It shows nowhere new and stays as data that is written back as it was read. Refs: GHSA-vgv2-vh37-7c34 --- docs/data-model.md | 21 +++- docs/vendor-code.md | 5 +- packages/domain/src/release/releaseModel.ts | 6 ++ .../shacl/node/quadsToReleaseModel.test.ts | 11 ++ packages/shacl/node/quadsToReleaseModel.ts | 6 +- packages/solid-memo-client/src/dataset.ts | 8 +- .../solid/src/client/linearDataset.test.ts | 26 +++++ packages/solid/src/client/linearDataset.ts | 38 ++++++- packages/solid/src/client/rdf.ts | 58 ++++++++-- packages/solid/src/client/sparqlUpdate.ts | 11 +- .../solid/src/directionalLiterals.test.ts | 60 +++++++++++ packages/solid/src/jsonLd.test.ts | 17 +++ packages/solid/src/jsonLd.ts | 8 +- .../solid/src/mappers/releaseDraftMapper.ts | 15 ++- packages/solid/src/ntriples.test.ts | 3 +- packages/solid/src/ntriples.ts | 3 +- .../src/solidReleaseDraftRepository.test.ts | 43 ++++++++ .../solid/src/solidReleasePublisher.test.ts | 12 +++ packages/solid/src/solidReleasePublisher.ts | 5 +- packages/solid/src/testing/contract/rdf.ts | 100 ++++++++++++++++++ .../solid/src/testing/contract/resources.ts | 23 ++++ packages/solid/src/turtleWriter.test.ts | 9 +- packages/solid/src/turtleWriter.ts | 11 +- packages/solid/src/writableTerms.test.ts | 19 +++- packages/solid/src/writableTerms.ts | 28 ++++- 25 files changed, 501 insertions(+), 45 deletions(-) create mode 100644 packages/solid/src/directionalLiterals.test.ts diff --git a/docs/data-model.md b/docs/data-model.md index dfebe567e..4d12b19b9 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -1736,11 +1736,15 @@ by one. an IRI with a control character, a space or one of `` <>"{}|^`\ ``. So is a blank node label or a language tag outside Turtle's grammar, a language subtag over 8 characters, which the pod's parser refuses, text - typed `rdf:langString` without a language, a base direction, or a - quoted triple. JSON-LD can hold such a term, and the writers don't escape it, - so a `>` would end an IRI and the rest be read as statements no check - saw. A PATCH, a PUT, an insert-only PATCH, a release's Turtle and an - export are refused with `unwritableTerm` before anything is sent + typed `rdf:langString` or `rdf:dirLangString` without a language, or a + quoted triple. So is text typed `rdf:dirLangString` without a base + direction or with one other than `ltr` or `rtl`, and a direction on + any other literal. JSON-LD can hold such a term, and the writers + don't escape it, so a `>` would end an IRI and the rest be read as + statements no check saw. A JSON-LD value with a `@direction` but no + `@language` is such a term too. A PATCH, a PUT, an insert-only PATCH, + a release's Turtle and an export are refused with `unwritableTerm` + before anything is sent ([writableTerms.ts](../packages/solid/src/writableTerms.ts)). An imported deck leaves such triples out; a release read from a file is refused, as a draft keeps its triples as they are. @@ -1764,6 +1768,13 @@ by one. `sm:fromRelease`. A draft that states one is refused as one that states a value otherwise than the Studio reads it (`draftHiddenValue`), so it is never published with it. + A literal with a base direction (RDF 1.2, `"…"@ar--rtl`) that another + app wrote is kept with its language and direction, and written back + as it was read, in a PATCH and in a PUT alike (`graphsOf`). A Studio + draft keeps it too, as it is edited and published, and so does a + deck's export, in Turtle and in JSON-LD (`@direction`). + The app writes no direction of its own and reads no such literal as + text in a language. - Mutations always follow read → modify → save ([datasets.ts](../packages/solid/src/datasets.ts): `readDataset` or `getSolidDatasetOrNull`, then `saveDataset`). The save sends a PATCH diff --git a/docs/vendor-code.md b/docs/vendor-code.md index 69b829eb2..c7bcf933f 100644 --- a/docs/vendor-code.md +++ b/docs/vendor-code.md @@ -150,7 +150,10 @@ type crosses upward past a mapper. can name one. A subject made by name resolves against the URL its dataset was read from. `getDatetime` throws on some literals and gives an invalid date past the JavaScript date range. Language - tags read back in lower case. `getDecimal` reads as `parseFloat` + tags read back in lower case. A literal with a base direction + (`"…"@ar--rtl`) keeps its language and direction, is no text in a + language to `getStringByLocaleAll`, and is written back as read. + `getDecimal` reads as `parseFloat` does, and a decimal is written as JavaScript spells it (`1`, not `1.0`). The change log (`changesOf`) and the source (`sourceOf`) behave as their comments in `rdf.ts` say. The resource module's pin diff --git a/packages/domain/src/release/releaseModel.ts b/packages/domain/src/release/releaseModel.ts index c0dd85e76..39f54e981 100644 --- a/packages/domain/src/release/releaseModel.ts +++ b/packages/domain/src/release/releaseModel.ts @@ -36,6 +36,12 @@ export interface ReleaseText { /** The language tag, "" when untagged. */ language: string; datatype: string; + /** + * Its base direction (RDF 1.2, `"…"@ar--rtl`), on text typed + * rdf:dirLangString that another app wrote; absent for any other. The + * app writes none of its own, and keeps one it read. + */ + direction?: string; } /** What every subject of the outline has: its IRI and whether it is retired (owl:deprecated true). */ diff --git a/packages/shacl/node/quadsToReleaseModel.test.ts b/packages/shacl/node/quadsToReleaseModel.test.ts index a5c7ac288..18f17e441 100644 --- a/packages/shacl/node/quadsToReleaseModel.test.ts +++ b/packages/shacl/node/quadsToReleaseModel.test.ts @@ -222,3 +222,14 @@ it("reads each kind of term", () => { const [q] = parseTurtle(` _:x .`, URL); expect(termOf(q.object)).toEqual({ kind: "blank", value: q.object.value }); }); + +it("reads a literal's base direction with it", () => { + const [q] = parseTurtle(` "x"@ar--rtl .`, URL); + expect(termOf(q.object)).toEqual({ + kind: "literal", + value: "x", + language: "ar", + direction: "rtl", + datatype: "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString", + }); +}); diff --git a/packages/shacl/node/quadsToReleaseModel.ts b/packages/shacl/node/quadsToReleaseModel.ts index 3f90cf52d..e196108d8 100644 --- a/packages/shacl/node/quadsToReleaseModel.ts +++ b/packages/shacl/node/quadsToReleaseModel.ts @@ -26,13 +26,17 @@ const OWL_DEPRECATED = "http://www.w3.org/2002/07/owl#deprecated"; /** A term as the release rules read it. */ export function termOf(object: Quad_Object): ReleaseTerm { switch (object.termType) { - case "Literal": + case "Literal": { + // A base direction (RDF 1.2), which n3 reads but this package's RDF/JS types do not name. + const { direction } = object as { direction?: string }; return { kind: "literal", value: object.value, language: object.language, datatype: object.datatype.value, + ...(direction ? { direction } : {}), }; + } case "BlankNode": return { kind: "blank", value: object.value }; default: diff --git a/packages/solid-memo-client/src/dataset.ts b/packages/solid-memo-client/src/dataset.ts index e5851bd98..e8321cd39 100644 --- a/packages/solid-memo-client/src/dataset.ts +++ b/packages/solid-memo-client/src/dataset.ts @@ -137,18 +137,20 @@ function subjectId(term: Quad_Subject): string { return term.termType === "BlankNode" ? `_:${term.value}` : term.value; } -/** A value with its language tag in lower case, as every value read is held. */ +/** A value with its language tag in lower case, as every value read is held, its base direction kept. */ function lowerCased(value: Value): Value { if (value.termType !== "Literal" || value.language === value.language.toLowerCase()) { return value; } - return literal(value.value, value.language.toLowerCase()); + // n3's factory reads a tag with a direction after `--` (`ar--rtl`) as the two apart. + const direction = value.direction ? `--${value.direction}` : ""; + return literal(value.value, `${value.language.toLowerCase()}${direction}`); } /** A value's parts, each a step of the path that keys its quad (pathKeys): two values are the same when their parts are. */ function valueParts(value: Value): string[] { if (value.termType !== "Literal") return [value.termType, value.value]; - return ["Literal", value.datatype.value, value.language, value.value]; + return ["Literal", value.datatype.value, value.language, value.direction ?? "", value.value]; } /** diff --git a/packages/solid/src/client/linearDataset.test.ts b/packages/solid/src/client/linearDataset.test.ts index d02a61f95..af5aae956 100644 --- a/packages/solid/src/client/linearDataset.test.ts +++ b/packages/solid/src/client/linearDataset.test.ts @@ -163,6 +163,32 @@ describe("graphsOf", () => { expect(getThing(fromRdfJsDataset([] as never), victim)).toBeNull(); }); + it("keeps a literal with a base direction under its language and direction, one with no language or no direction by its datatype", async () => { + const [first] = await quadsOf(`<#a> "x" .`); + const DIR_LANG_STRING = "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString"; + const withObject = (language: string, direction: string) => + ({ + subject: first!.subject, + predicate: first!.predicate, + graph: first!.graph, + object: { + termType: "Literal", + value: `${language}${direction}`, + language, + direction, + datatype: { termType: "NamedNode", value: DIR_LANG_STRING }, + }, + }) as Quad; + const objects = graphsOf([ + withObject("AR", "RTL"), + withObject("ar", "ltr"), + withObject("", "rtl"), + withObject("ar", ""), + ]).default[`${URL_}#a`]!.predicates["https://example.com/ns#p"]!; + expect(objects.langStrings).toEqual({ "ar--rtl": ["ARRTL"], "ar--ltr": ["arltr"] }); + expect(objects.literals).toEqual({ [DIR_LANG_STRING]: ["rtl", "ar"] }); + }); + it("refuses an object solid-client cannot hold, as solid-client does", async () => { const [first] = await quadsOf(`<#a> "x" .`); const { subject, predicate, graph } = first!; diff --git a/packages/solid/src/client/linearDataset.ts b/packages/solid/src/client/linearDataset.ts index d7a2e548f..60ccd7558 100644 --- a/packages/solid/src/client/linearDataset.ts +++ b/packages/solid/src/client/linearDataset.ts @@ -8,7 +8,7 @@ import { type Parser, type SolidDataset, } from "@inrupt/solid-client"; -import type { Quad } from "@rdfjs/types"; +import type { Literal, Quad } from "@rdfjs/types"; import { Parser as N3Parser, type ParserOptions } from "n3"; import { ReadLimitError, type ReadLimits } from "./interface"; import { withBoundedHeaders } from "./linkHeader"; @@ -31,6 +31,7 @@ type Objects = Graph[string]["predicates"][string]; type Dataset = Awaited>; const LANG_STRING = "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString"; +const DIR_LANG_STRING = "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString"; /** Where the library names a subject that has no URL yet: `<#name>` in the document it is saved to. */ export const LOCAL_NODE = "https://inrupt.com/.well-known/sdk-local-node/"; @@ -85,6 +86,8 @@ export async function getSolidDatasetLinear( * parse fails at the first triple that holds one all the same, as an * annotation (`{| ... |}`) or a reifier (`~`) gives, which the option * does not keep n3 from reading; after its error it reports nothing more. + * An RDF 1.2 literal with a base direction (`"…"@ar--rtl`) is read, and + * kept with its language and direction (graphsOf). */ export function turtleParser(): Parser { const onQuad: ((quad: Quad) => void)[] = []; @@ -186,7 +189,9 @@ function collectingTurtleParser(collected: Collected, url: string): Parser { * a way to write onto Object.prototype (GHSA-m4hj-7c28-fq4h). A quad * is told from the others by the path of its terms (pathKeys), each a * key of its own, as no string of them joined would be held to the - * bound on a term (MAX_TERM_CHARACTERS). + * bound on a term (MAX_TERM_CHARACTERS). A literal with a base direction + * keeps its language and direction (localeOf), so that what is read can + * be written back as it is. */ export function graphsOf(quads: Iterable): Graphs { const graphs = @@ -215,8 +220,9 @@ export function graphsOf(quads: Iterable): Graphs { } else if (object.termType === "BlankNode") { (objects.blankNodes ??= []).push(`_:${object.value}`); } else if (object.termType === "Literal") { - if (object.datatype.value === LANG_STRING) { - ((objects.langStrings ??= map())[object.language.toLowerCase()] ??= []).push(object.value); + const locale = localeOf(object); + if (locale !== undefined) { + ((objects.langStrings ??= map())[locale] ??= []).push(object.value); } else { ((objects.literals ??= map())[object.datatype.value] ??= []).push(object.value); } @@ -227,6 +233,27 @@ export function graphsOf(quads: Iterable): Graphs { return deepFreeze(graphs) as Graphs; } +/** + * Where a literal in a language is kept among a subject's language + * strings: under its language in lower case, and one with a base + * direction (RDF 1.2) under its language and direction as Turtle writes + * them (`ar--rtl`), from which the library's data factory (n3's) makes + * the same literal again, its language and direction apart. No language + * tag has `--` in it, so no other is kept there; the getters of language + * text leave such a one out (rdf.ts getStringByLocaleAll). Undefined for + * a literal in no language, or one with a direction but no language, + * which is kept by its datatype, as Turtle cannot write it (writableTerms.ts). + */ +function localeOf(literal: Literal): string | undefined { + const language = literal.language.toLowerCase(); + if (literal.datatype.value === LANG_STRING) return language; + const direction = literal.direction ?? ""; + if (literal.datatype.value !== DIR_LANG_STRING || language === "" || direction === "") { + return undefined; + } + return `${language}--${direction.toLowerCase()}`; +} + /** An empty map with no prototype, so that no key reaches Object.prototype. */ function map(): Record { return Object.create(null) as Record; @@ -235,7 +262,8 @@ function map(): Record { /** An object's parts, each a step of the path that tells its quad from others (graphsOf). */ function termParts(term: Quad["object"]): string[] { if (term.termType !== "Literal") return [term.termType, term.value]; - return ["Literal", term.datatype.value, term.language.toLowerCase(), term.value]; + const { datatype, language, direction, value } = term; + return ["Literal", datatype.value, language.toLowerCase(), direction ?? "", value]; } function deepFreeze(value: T): T { diff --git a/packages/solid/src/client/rdf.ts b/packages/solid/src/client/rdf.ts index 2e902d262..0a143df16 100644 --- a/packages/solid/src/client/rdf.ts +++ b/packages/solid/src/client/rdf.ts @@ -12,7 +12,6 @@ import { asUrl as libraryAsUrl, createSolidDataset as libraryCreateSolidDataset, createThing as libraryCreateThing, - fromRdfJsDataset as libraryFromRdfJsDataset, getBoolean as libraryGetBoolean, getDatetime as libraryGetDatetime, getDecimal as libraryGetDecimal, @@ -87,6 +86,11 @@ import { pathKeys } from "./readLimits"; * (getStringByLocaleAll, getLiteralAll), however the document spells it. * - getDecimal reads a decimal as parseFloat does (`2.50` is 2.5, a value * that is no number null). + * - A literal with a base direction (RDF 1.2, `"…"@ar--rtl`) is kept with + * its language and direction, and written back as it was read. It is + * no language text: getStringByLocaleAll and setStringAll leave it be, + * and propertiesOf gives it as a literal of its datatype + * (rdf:dirLangString); getLiteralAll and getTermAll give it whole. */ export type { @@ -160,6 +164,18 @@ export function propertiesOf(subject: Subject): { predicate: string; values: Pro })); } +const DIR_LANG_STRING = "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString"; + +/** + * Whether a subject's language strings under `locale` have a base + * direction: kept under their language and direction (`ar--rtl`, + * linearDataset.ts graphsOf), never under a language tag alone, which + * has no `--` in it. + */ +function isDirectional(locale: string): boolean { + return locale.includes("--"); +} + function valuesOf(objects: Thing["predicates"][string]): PropertyValue[] { const values: PropertyValue[] = []; for (const iri of objects.namedNodes ?? []) { @@ -170,7 +186,13 @@ function valuesOf(objects: Thing["predicates"][string]): PropertyValue[] { values.push({ type: "literal", value: literal, dataType }); } } - for (const [language, strings] of Object.entries(objects.langStrings ?? {})) { + const langStrings = Object.entries(objects.langStrings ?? {}); + for (const [, strings] of langStrings.filter(([locale]) => isDirectional(locale))) { + for (const value of strings) { + values.push({ type: "literal", value, dataType: DIR_LANG_STRING }); + } + } + for (const [language, strings] of langStrings.filter(([locale]) => !isDirectional(locale))) { for (const value of strings) { values.push({ type: "langString", value, language }); } @@ -347,7 +369,11 @@ function editOf(data: SolidDataset) { function quadKeys(): (quad: Quad) => number { const keyOf = pathKeys(); return ({ subject, predicate, object }) => { - const literal = object.termType === "Literal" ? [object.datatype.value, object.language] : []; + // Every quad keyed is made by the library's data factory (n3's), whose literals all name a direction. + const literal = + object.termType === "Literal" + ? [object.datatype.value, object.language, object.direction as string] + : []; return keyOf([ subject.termType, subject.value, @@ -507,9 +533,11 @@ class Builder implements SubjectBuilder { const literals = { ...previous.literals }; delete literals[XSD_STRING]; if (strings.size > 0) literals[XSD_STRING] = [...strings]; - const langStrings = Object.fromEntries( - [...byLanguage].map(([tag, texts]) => [tag, [...texts]]), - ); + // Text with a base direction is no language text: kept as it is. + const langStrings = Object.fromEntries([ + ...Object.entries(previous.langStrings ?? {}).filter(([locale]) => isDirectional(locale)), + ...[...byLanguage].map(([tag, texts]) => [tag, [...texts]]), + ]); return { ...previous, literals, langStrings }; }); } @@ -570,9 +598,15 @@ export function getStringNoLocaleAll(subject: Subject, property: string): string return libraryGetStringNoLocaleAll(thing(subject), property); } -/** The subject's strings with a language for the property, by their language in lower case. */ +/** + * The subject's strings with a language for the property, by their + * language in lower case; none with a base direction, which is no + * language text (isDirectional). + */ export function getStringByLocaleAll(subject: Subject, property: string): Map { - return libraryGetStringByLocaleAll(thing(subject), property); + const strings = libraryGetStringByLocaleAll(thing(subject), property); + for (const locale of strings.keys()) if (isDirectional(locale)) strings.delete(locale); + return strings; } /** An integer the subject has for the property; null when it has none. */ @@ -615,7 +649,11 @@ export function toRdfJsDataset(dataset: Dataset): DatasetCore { return libraryToRdfJsDataset(solidDataset(dataset)); } -/** A dataset of an RDF/JS dataset's triples. */ +/** + * A dataset of an RDF/JS dataset's triples, built in one pass as a read + * builds one (datasetFromQuads), a literal with a base direction kept + * whole. + */ export function fromRdfJsDataset(dataset: DatasetCore): Dataset { - return libraryFromRdfJsDataset(dataset); + return datasetFromQuads(dataset); } diff --git a/packages/solid/src/client/sparqlUpdate.ts b/packages/solid/src/client/sparqlUpdate.ts index 402e5f2bb..c387e4538 100644 --- a/packages/solid/src/client/sparqlUpdate.ts +++ b/packages/solid/src/client/sparqlUpdate.ts @@ -87,6 +87,15 @@ function literal(term: Literal, decimalsAsNumbers: boolean): string { ? String(Number.parseFloat(term.value)) : term.value; const text = `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`; - if (term.language !== "") return `${text}@${term.language}`; + if (term.language !== "") return `${text}@${term.language}${directionOf(term)}`; return term.datatype.value === XSD_STRING ? text : `${text}^^<${term.datatype.value}>`; } + +/** + * A literal's base direction as Turtle, N-Triples and SPARQL write it + * after its language (`"…"@ar--rtl`, RDF 1.2); empty for one with none. + * Whether it can be written so is writableTerms.ts's to check. + */ +export function directionOf(term: { direction?: string | null | undefined }): string { + return term.direction ? `--${term.direction}` : ""; +} diff --git a/packages/solid/src/directionalLiterals.test.ts b/packages/solid/src/directionalLiterals.test.ts new file mode 100644 index 000000000..abb98205a --- /dev/null +++ b/packages/solid/src/directionalLiterals.test.ts @@ -0,0 +1,60 @@ +// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors +// +// SPDX-License-Identifier: MIT + +import { describe, expect, it } from "vitest"; +import { DEFAULT_PREFERENCES } from "@solid-memo/domain/preferences"; +import { buildThing, getThing, setThing } from "./client/rdf"; +import { getSolidDatasetOrNull, saveDataset } from "./datasets"; +import { createSolidPreferencesRepository } from "./solidPreferencesRepository"; +import { fakePod } from "./testing/fakePod"; + +const INSTANCE = "https://pod.example/memo/"; +const TITLE = "http://purl.org/dc/terms/title"; +const LABEL = "http://example.org/label"; + +/** + * A literal with a base direction (RDF 1.2) that another app wrote into + * a document of the app's is kept as it was read, and written back so, + * whatever the app writes beside it or in its place. + */ +describe("a literal with a base direction in a pod document", () => { + it("is kept by a save of the preferences, which writes the whole document", async () => { + const doc = `${INSTANCE}preferences.ttl`; + const pod = fakePod(); + await pod.put(doc, `<#note> <${LABEL}> "مرحبا"@ar--rtl .`); + await createSolidPreferencesRepository({ fetch: pod.fetch }).savePreferences( + INSTANCE, + DEFAULT_PREFERENCES, + ); + expect(pod.requests.map(({ method }) => method)).toContain("PUT"); + expect(pod.triples(doc)).toContain(`<${doc}#note> <${LABEL}> "مرحبا"@ar--rtl .`); + }); + + it("is kept by an edit beside it, and replaced by an edit of it, each one PATCH", async () => { + const doc = `${INSTANCE}decks/x.ttl`; + const pod = fakePod(); + await pod.put(doc, `<#c> <${TITLE}> "a"@en--ltr ; <${LABEL}> "b"@ar--rtl .`); + const edit = async (property: string, value: string) => { + const read = (await getSolidDatasetOrNull(doc, pod.fetch))!; + const thing = buildThing(getThing(read, `${doc}#c`)!) + .removeAll(property) + .addStringNoLocale(property, value) + .build(); + await saveDataset(doc, setThing(read, thing), pod.fetch); + }; + await edit(TITLE, "new"); + expect(pod.triples(doc)).toEqual([ + `<${doc}#c> <${LABEL}> "b"@ar--rtl .`, + `<${doc}#c> <${TITLE}> "new" .`, + ]); + await edit(LABEL, "plain"); + expect(pod.triples(doc)).toEqual([ + `<${doc}#c> <${LABEL}> "plain" .`, + `<${doc}#c> <${TITLE}> "new" .`, + ]); + expect( + pod.requests.filter(({ method }) => method !== "GET").map(({ method }) => method), + ).toEqual(["PATCH", "PATCH"]); + }); +}); diff --git a/packages/solid/src/jsonLd.test.ts b/packages/solid/src/jsonLd.test.ts index a95bedb0a..91e0d9ecf 100644 --- a/packages/solid/src/jsonLd.test.ts +++ b/packages/solid/src/jsonLd.test.ts @@ -80,6 +80,23 @@ describe("jsonLdOf", () => { expect(a[RDF_TYPE]).toEqual({ "@id": "_:t" }); }); + it("writes a literal's base direction, which reads back the same", async () => { + const directional = { + termType: "Literal", + value: "مرحبا", + language: "ar", + direction: "rtl", + datatype: iri("http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString"), + } as Term; + const text = jsonLdOf([quad(iri(`${EX}a`), `${EX}name`, directional)], { ex: EX }); + expect(JSON.parse(text)["@graph"][0]["ex:name"]).toEqual({ + "@value": "مرحبا", + "@language": "ar", + "@direction": "rtl", + }); + expect(await triplesOf(text)).toEqual([`<${EX}a> <${EX}name> "مرحبا"@ar--rtl .`]); + }); + it("writes the same triples the same, whatever their order", () => { expect(jsonLdOf([...quads].reverse(), { ex: EX })).toBe(jsonLdOf(quads, { ex: EX })); }); diff --git a/packages/solid/src/jsonLd.ts b/packages/solid/src/jsonLd.ts index efdded7e5..56e7ee4aa 100644 --- a/packages/solid/src/jsonLd.ts +++ b/packages/solid/src/jsonLd.ts @@ -15,7 +15,7 @@ const LOCAL_NAME = /^[A-Za-z0-9_][A-Za-z0-9_.-]*$/; type Value = | string | { "@id": string } - | { "@value": string; "@language"?: string; "@type"?: string }; + | { "@value": string; "@language"?: string; "@direction"?: string; "@type"?: string }; /** * Triples as a JSON-LD document of our own writing: flattened, every @@ -77,7 +77,11 @@ function idOf(term: Term): string { function termValue(term: Term, compact: (iri: string) => string): Value { if (term.termType !== "Literal") return { "@id": term.termType === "BlankNode" ? idOf(term) : compact(term.value) }; - if (term.language !== "") return { "@value": term.value, "@language": term.language }; + if (term.language !== "") { + // A base direction a read kept (`"…"@ar--rtl`) is written as it is, as Turtle writes it. + const direction = term.direction ? { "@direction": term.direction } : {}; + return { "@value": term.value, "@language": term.language, ...direction }; + } if (term.datatype.value === XSD_STRING) return term.value; return { "@value": term.value, "@type": compact(term.datatype.value) }; } diff --git a/packages/solid/src/mappers/releaseDraftMapper.ts b/packages/solid/src/mappers/releaseDraftMapper.ts index 6c319022a..0b42ee3a6 100644 --- a/packages/solid/src/mappers/releaseDraftMapper.ts +++ b/packages/solid/src/mappers/releaseDraftMapper.ts @@ -13,6 +13,7 @@ import { toRdfJsDataset, } from "../client/rdf"; import { MAX_TERM_CHARACTERS, pathKeys } from "../client/readLimits"; +import { directionOf } from "../client/sparqlUpdate"; import type { Literal, Quad, Term } from "@rdfjs/types"; import { AppError } from "@solid-memo/domain/appError"; import { isSameUrl } from "@solid-memo/domain/instanceLayout"; @@ -79,7 +80,7 @@ const LATEST = (shape: ShapeName): ShapeDescriptor => { return versions[Math.max(...Object.keys(versions).map(Number))]!; }; -/** A term as the domain keeps it. */ +/** A term as the domain keeps it, a literal's base direction with it when it has one. */ export function termOf(term: Term): ReleaseTerm { switch (term.termType) { case "Literal": @@ -88,6 +89,7 @@ export function termOf(term: Term): ReleaseTerm { value: term.value, language: term.language, datatype: term.datatype.value, + ...(term.direction ? { direction: term.direction } : {}), }; case "BlankNode": return { kind: "blank", value: term.value }; @@ -114,6 +116,7 @@ export function quadOf(subject: string, predicate: string, object: ReleaseTerm): value: object.value, language: object.language, datatype: { termType: "NamedNode", value: object.datatype }, + ...(object.direction ? { direction: object.direction } : {}), } : subjectTerm(object.kind === "blank" ? `_:${object.value}` : object.value); return { @@ -134,7 +137,7 @@ export function tripleKey(triple: DraftTriple): string { const { object } = triple; const value = object.kind === "literal" - ? `"${object.value}"@${object.language.toLowerCase()}^^${object.datatype}` + ? `"${object.value}"@${object.language.toLowerCase()}${directionOf(object)}^^${object.datatype}` : `${object.kind} ${object.value}`; return `${triple.subject} ${triple.predicate} ${value}`; } @@ -149,7 +152,13 @@ export function tripleParts(triple: DraftTriple): string[] { const { object } = triple; const value = object.kind === "literal" - ? [object.kind, object.value, object.language.toLowerCase(), object.datatype] + ? [ + object.kind, + object.value, + object.language.toLowerCase(), + object.direction ?? "", + object.datatype, + ] : [object.kind, object.value]; return [triple.subject, triple.predicate, ...value]; } diff --git a/packages/solid/src/ntriples.test.ts b/packages/solid/src/ntriples.test.ts index 0218d8c57..375a4ae5d 100644 --- a/packages/solid/src/ntriples.test.ts +++ b/packages/solid/src/ntriples.test.ts @@ -12,7 +12,7 @@ describe("triplesOf", () => { it("writes every kind of term as Solid Memo's PATCH bodies do", async () => { const triples = await triplesOf( DOC, - `<#a> <#p> [ <#q> "x" ] ; <#r> "say \\"hi\\"\\n\\r\\\\"@en , 3 , "plain" .`, + `<#a> <#p> [ <#q> "x" ] ; <#r> "say \\"hi\\"\\n\\r\\\\"@en , 3 , "plain" , "مرحبا"@ar--rtl .`, ); const lines = [...triples]; expect(lines).toContain(`<${DOC}#a> <${DOC}#r> "say \\"hi\\"\\n\\r\\\\"@en .`); @@ -20,6 +20,7 @@ describe("triplesOf", () => { `<${DOC}#a> <${DOC}#r> "3"^^ .`, ); expect(lines).toContain(`<${DOC}#a> <${DOC}#r> "plain" .`); + expect(lines).toContain(`<${DOC}#a> <${DOC}#r> "مرحبا"@ar--rtl .`); const blank = lines.find((line) => line.startsWith(`<${DOC}#a> <${DOC}#p> _:`))!; const node = blank.split(" ")[2]!; expect(lines).toContain(`${node} <${DOC}#q> "x" .`); diff --git a/packages/solid/src/ntriples.ts b/packages/solid/src/ntriples.ts index 38e40c94f..436f88c02 100644 --- a/packages/solid/src/ntriples.ts +++ b/packages/solid/src/ntriples.ts @@ -6,6 +6,7 @@ import { pathKeys } from "@solid-memo/domain/pathKeys"; import type { Quad, Term } from "@rdfjs/types"; import { toRdfJsDataset } from "./client/rdf"; import { MAX_TERM_CHARACTERS } from "./client/readLimits"; +import { directionOf } from "./client/sparqlUpdate"; import { getSolidDatasetLinear } from "./client/resources"; const XSD_STRING = "http://www.w3.org/2001/XMLSchema#string"; @@ -74,6 +75,6 @@ function piecesOf(line: string): string[] { export function termOf(t: Term): string { if (t.termType !== "Literal") return t.termType === "BlankNode" ? `_:${t.value}` : `<${t.value}>`; const text = `"${t.value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`; - if (t.language !== "") return `${text}@${t.language}`; + if (t.language !== "") return `${text}@${t.language}${directionOf(t)}`; return t.datatype.value === XSD_STRING ? text : `${text}^^<${t.datatype.value}>`; } diff --git a/packages/solid/src/solidReleaseDraftRepository.test.ts b/packages/solid/src/solidReleaseDraftRepository.test.ts index 2a2bd56ac..1befa75b3 100644 --- a/packages/solid/src/solidReleaseDraftRepository.test.ts +++ b/packages/solid/src/solidReleaseDraftRepository.test.ts @@ -57,6 +57,7 @@ const CHAPTER_B = `${CONTAINER}chapter-ch-b.ttl`; const CARDS = `${CONTAINER}cards.ttl`; const CATALOG = `${INSTANCE}catalog.ttl`; const SM = "https://solid-memo.com/ns/vocab/v1.ttl#"; +const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; /** The course fixture as a draft of the instance. */ const course = (): ReleaseDraft => rebaseDraft(courseDraft(), DRAFT); @@ -609,6 +610,48 @@ describe("applyChanges", () => { expect(sorted(read)).toEqual(sorted(after)); }); + it("keeps a literal with a base direction another app wrote, through an edit beside it and publishing (GHSA-vgv2-vh37-7c34)", async () => { + const { pod } = await opened(); + await pod.put( + CARDS, + `${await pod.text(CARDS)}\n<#q-loose> "مرحبا"@ar--rtl, "مرحبا"@ar ; "a" .`, + ); + const { draft, version } = await pod.repository.read(DRAFT); + const label = draft.triples.filter( + (triple) => triple.predicate === "https://other.example/label", + ); + expect(label.map((triple) => triple.object)).toEqual( + expect.arrayContaining([ + { kind: "literal", value: "مرحبا", language: "ar", datatype: `${RDF}langString` }, + { + kind: "literal", + value: "مرحبا", + language: "ar", + direction: "rtl", + datatype: `${RDF}dirLangString`, + }, + ]), + ); + const after = { + ...draft, + triples: draft.triples.map((triple) => + triple.object.kind === "literal" && triple.object.value === "a" + ? { ...triple, object: { ...triple.object, value: "b" } } + : triple, + ), + }; + await pod.repository.applyChanges(draft, after, version); + const text = await pod.text(CARDS); + expect(text).toContain('"b"'); + expect(text).toContain('"مرحبا"@ar--rtl'); + expect(text).toMatch(/"مرحبا"@ar[^-]/); + const { draft: read } = await pod.repository.read(DRAFT); + expect(sorted(read)).toEqual(sorted(after)); + const turtle = await pod.repository.assemble(DRAFT, `${INSTANCE}releases/solid/v1.ttl`, NOW); + expect(turtle).toContain('"مرحبا"@ar--rtl'); + expect(turtle).toMatch(/"مرحبا"@ar[^-]/); + }); + it("keeps what another app said in a document of the draft", async () => { const { pod } = await opened(); await pod.put( diff --git a/packages/solid/src/solidReleasePublisher.test.ts b/packages/solid/src/solidReleasePublisher.test.ts index 79f351b41..ae5bc839c 100644 --- a/packages/solid/src/solidReleasePublisher.test.ts +++ b/packages/solid/src/solidReleasePublisher.test.ts @@ -236,6 +236,18 @@ describe("publish", () => { ).rejects.toMatchObject({ code: "releaseTaken" }); }); + it("tells a release there from this one by a text's base direction", async () => { + const { pod, publisher } = await publisherPod(); + const titled = (title: string) => `${TURTLE}\n<> ${title} .`; + await pod.put(RELEASE, titled('"Solid"@ar--ltr')); + await expect( + publisher.publish(INSTANCE, titled('"Solid"@ar--rtl'), RELEASE), + ).rejects.toMatchObject({ code: "releaseTaken" }); + await expect(publisher.publish(INSTANCE, titled('"Solid"@ar--ltr'), RELEASE)).resolves.toEqual({ + public: true, + }); + }); + it("publishes a release the pod will not make public all the same, readable by its owner alone", async () => { const { pod, publisher } = await publisherPod({ wac: false }); await expect(publisher.publish(INSTANCE, TURTLE, RELEASE)).resolves.toEqual({ public: false }); diff --git a/packages/solid/src/solidReleasePublisher.ts b/packages/solid/src/solidReleasePublisher.ts index 05bbaa3d2..1f90dd405 100644 --- a/packages/solid/src/solidReleasePublisher.ts +++ b/packages/solid/src/solidReleasePublisher.ts @@ -174,14 +174,15 @@ const TIMES = new Set([`${DCTERMS_NS}issued`, `${DCTERMS_NS}modified`]); /** * A release's statements as text to compare, in order, its times left - * out: every blank node alike, as each server names them afresh. + * out: every blank node alike, as each server names them afresh. Both + * sides are read by n3, whose literals all name a direction ("" for none). */ function statementsOf(quads: readonly Quad[], url: string): string { const term = (one: Term) => one.termType === "BlankNode" ? "_:" : one.termType === "Literal" - ? JSON.stringify([one.value, one.language, one.datatype.value]) + ? JSON.stringify([one.value, one.language, one.direction, one.datatype.value]) : `<${one.value}>`; return quads .filter((quad) => !(quad.subject.value === url && TIMES.has(quad.predicate.value))) diff --git a/packages/solid/src/testing/contract/rdf.ts b/packages/solid/src/testing/contract/rdf.ts index e442b37bf..66136c865 100644 --- a/packages/solid/src/testing/contract/rdf.ts +++ b/packages/solid/src/testing/contract/rdf.ts @@ -496,6 +496,106 @@ export function rdfContract({ rdf, resources }: DataClient): void { }); }); + describe("literals with a base direction", () => { + const P = `${EX}p`; + const DIR_LANG_STRING = "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString"; + const DIRECTED = `<#a> <${P}> "مرحبا"@ar--rtl, "hello"@en--ltr, "hello"@en--rtl, "hi"@en, "plain" .\n`; + /** A literal's parts, its language and direction among them. */ + const parts = (term: Quad["object"]) => + term.termType === "Literal" + ? [term.value, term.language, term.direction ?? "", term.datatype.value] + : [term.value]; + const sorted = (terms: Iterable) => [...terms].map(parts).sort(); + const DIRECTED_PARTS = [ + ["hello", "en", "ltr", DIR_LANG_STRING], + ["hello", "en", "rtl", DIR_LANG_STRING], + ["hi", "en", "", "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString"], + ["plain", "", "", `${XSD}string`], + ["مرحبا", "ar", "rtl", DIR_LANG_STRING], + ]; + + it("keep their language and direction as read, through every dataset made of them", async () => { + const dataset = await read(DIRECTED); + for (const again of [ + dataset, + datasetFromQuads(toRdfJsDataset(dataset) as Iterable), + fromRdfJsDataset(toRdfJsDataset(dataset)), + withQuads(dataset, toRdfJsDataset(dataset) as Iterable), + ]) { + expect(sorted([...toRdfJsDataset(again)].map((one) => one.object))).toEqual(DIRECTED_PARTS); + expect(sorted(getLiteralAll(getThing(again, `${URL_}#a`)!, P))).toEqual(DIRECTED_PARTS); + } + }); + + it("keep their direction from quads that spell their language in upper case", () => { + const quads = [ + quad(`${URL_}#a`, { + termType: "Literal", + value: "مرحبا", + language: "AR", + direction: "rtl", + datatype: { termType: "NamedNode", value: DIR_LANG_STRING }, + } as Quad["object"]), + ]; + for (const dataset of [datasetFromQuads(quads), fromRdfJsDataset(quads as never)]) { + expect(sorted(getLiteralAll(getThing(dataset, `${URL_}#a`)!, P))).toEqual([ + ["مرحبا", "ar", "rtl", DIR_LANG_STRING], + ]); + } + }); + + it("are kept once when an RDF/JS dataset states one twice, as a read keeps it", () => { + const one = quad(`${URL_}#a`, { + termType: "Literal", + value: "مرحبا", + language: "ar", + direction: "rtl", + datatype: { termType: "NamedNode", value: DIR_LANG_STRING }, + } as Quad["object"]); + const dataset = fromRdfJsDataset([one, { ...one }] as never); + expect(sorted(getLiteralAll(getThing(dataset, `${URL_}#a`)!, P))).toEqual([ + ["مرحبا", "ar", "rtl", DIR_LANG_STRING], + ]); + }); + + it("are no text in a language, which keeps them when it is set", async () => { + const thing = getThing(await read(DIRECTED), `${URL_}#a`)!; + expect(getStringByLocaleAll(thing, P)).toEqual(new Map([["en", ["hi"]]])); + expect(getStringNoLocale(thing, P)).toBe("plain"); + expect( + propertiesOf(thing)[0]! + .values.filter((one) => one.type === "literal") + .map((one) => one.value) + .sort(), + ).toEqual(["hello", "hello", "plain", "مرحبا"]); + expect( + propertiesOf(thing)[0]!.values.filter( + (one) => one.type === "literal" && one.dataType === DIR_LANG_STRING, + ), + ).toHaveLength(3); + const set = buildThing(thing) + .setStringAll(P, [{ value: "hej", language: "sv" }]) + .build(); + expect(getStringByLocaleAll(set, P)).toEqual(new Map([["sv", ["hej"]]])); + expect(sorted(getLiteralAll(set, P))).toEqual([ + ["hej", "sv", "", "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString"], + ...DIRECTED_PARTS.filter(([, , direction]) => direction !== ""), + ]); + }); + + it("are told apart by their direction in a dataset's changes", async () => { + const dataset = await read(DIRECTED); + const kept = getThing(await read(`<#a> <${P}> "hello"@en--rtl .\n`), `${URL_}#a`)!; + const changes = changesOf(setThing(dataset, kept))!; + expect(changes.additions).toEqual([]); + expect(sorted(changes.deletions.map((one) => one.object))).toEqual( + DIRECTED_PARTS.filter( + ([, language, direction]) => !(language === "en" && direction === "rtl"), + ), + ); + }); + }); + describe("localNameOf", () => { it("names a subject made by name and not saved yet, and nothing else", () => { const thing = buildThing(createThing({ name: "new" })) diff --git a/packages/solid/src/testing/contract/resources.ts b/packages/solid/src/testing/contract/resources.ts index 21398283b..34a3ec659 100644 --- a/packages/solid/src/testing/contract/resources.ts +++ b/packages/solid/src/testing/contract/resources.ts @@ -790,6 +790,29 @@ export function resourcesContract({ rdf, resources }: DataClient): void { expect(sent).toEqual([]); }); + it("writes back a literal with a base direction as it was read: beside an edit, in a whole write, and in an edit of it", async () => { + const directed = `<${URL_}#a> <${EX}label> "مرحبا"@ar--rtl .`; + const dataset = await read(`<#a> <${EX}p> "x" ; <${EX}label> "مرحبا"@ar--rtl .`); + const edited = (property: string) => + setThing( + dataset, + buildThing(getThing(dataset, `${URL_}#a`)!) + .removeAll(property) + .addStringNoLocale(property, "y") + .build(), + ); + const { fetch, sent } = pod({ status: 205, body: "" }); + await writeDocument(URL_, edited(`${EX}p`), { fetch }); + await writeDocument(URL_, edited(`${EX}p`), { fetch, whole: true }); + await writeDocument(URL_, edited(`${EX}label`), { fetch }); + expect(sent.map(({ method }) => method)).toEqual(["PATCH", "PUT", "PATCH"]); + expect(String(sent[0]!.body)).not.toContain("مرحبا"); + expect(String(sent[2]!.body)).toContain(`DELETE DATA {\n${directed}\n}`); + const back = await getSolidDatasetLinear(URL_, { fetch: turtleAt(String(sent[1]!.body)) }); + const [label] = rdf.getLiteralAll(getThing(back, `${URL_}#a`)!, `${EX}label`); + expect([label!.value, label!.language, label!.direction]).toEqual(["مرحبا", "ar", "rtl"]); + }); + it("refuses a term longer than any read takes in, sending nothing, and writes one at the bound", async () => { // A text is named in quotes, a line break in it escaped: one of MAX_TERM_CHARACTERS - 2 characters is // at the bound, and one of a character less with a line break is past it. diff --git a/packages/solid/src/turtleWriter.test.ts b/packages/solid/src/turtleWriter.test.ts index 542f1dcb9..dc8c29915 100644 --- a/packages/solid/src/turtleWriter.test.ts +++ b/packages/solid/src/turtleWriter.test.ts @@ -2,6 +2,7 @@ // // SPDX-License-Identifier: MIT +import type { Quad } from "@rdfjs/types"; import { describe, expect, it } from "vitest"; import { quadOf } from "./mappers/releaseDraftMapper"; import { quadsOfTurtle } from "./testing/releaseDrafts"; @@ -20,6 +21,10 @@ const literal = (value: string, datatype = `${XSD}string`, language = "") => ({ language, datatype, }); +const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#"; +/** The quad, its literal given a right-to-left base direction, as a read of `"…"@ar--rtl` gives it. */ +const directed = (quad: Quad): Quad => + ({ ...quad, object: { ...quad.object, direction: "rtl" } }) as Quad; const blank = (value: string) => ({ kind: "blank" as const, value }); describe("turtleOf", () => { @@ -89,6 +94,7 @@ describe("turtleOf", () => { quadOf(BASE, `${SM}d`, literal("x1", `${XSD}integer`)), quadOf(BASE, `${SM}e`, literal("maybe", `${XSD}boolean`)), quadOf(BASE, `${SM}f`, literal("1", "https://other.example/type")), + directed(quadOf(BASE, `${SM}g`, literal("مرحبا", `${RDF}dirLangString`, "ar"))), ], { base: BASE, prefixes: PREFIXES }, ); @@ -97,7 +103,8 @@ describe("turtleOf", () => { expect(turtle).toContain('solid-memo:c "2026-10-10T00:00:00Z"^^xsd:dateTime ;'); expect(turtle).toContain('solid-memo:d "x1"^^xsd:integer ;'); expect(turtle).toContain('solid-memo:e "maybe"^^xsd:boolean ;'); - expect(turtle).toContain('solid-memo:f "1"^^ .'); + expect(turtle).toContain('solid-memo:f "1"^^ ;'); + expect(turtle).toContain('solid-memo:g "مرحبا"@ar--rtl .'); }); it("writes a blank node named once in place, any other by its label, and reads back as written", async () => { diff --git a/packages/solid/src/turtleWriter.ts b/packages/solid/src/turtleWriter.ts index 089cec911..1198b4ca8 100644 --- a/packages/solid/src/turtleWriter.ts +++ b/packages/solid/src/turtleWriter.ts @@ -3,6 +3,7 @@ // SPDX-License-Identifier: MIT import type { Quad, Term } from "@rdfjs/types"; +import { directionOf } from "./client/sparqlUpdate"; import { refuseUnwritable } from "./writableTerms"; const RDF_TYPE = "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"; @@ -143,7 +144,12 @@ function keyOf(term: Term): string { } function literal( - term: { value: string; language: string; datatype: { value: string } }, + term: { + value: string; + language: string; + direction?: "ltr" | "rtl" | "" | null; + datatype: { value: string }; + }, iri: (value: string) => string, ): string { const datatype = term.datatype.value; @@ -151,6 +157,7 @@ function literal( if (datatype === `${XSD}boolean` && (term.value === "true" || term.value === "false")) return term.value; const text = `"${term.value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\n").replace(/\r/g, "\\r")}"`; - if (term.language !== "" || datatype === LANG_STRING) return `${text}@${term.language}`; + if (term.language !== "" || datatype === LANG_STRING) + return `${text}@${term.language}${directionOf(term)}`; return datatype === `${XSD}string` ? text : `${text}^^${iri(datatype)}`; } diff --git a/packages/solid/src/writableTerms.test.ts b/packages/solid/src/writableTerms.test.ts index 7e3b9be92..9696f7c22 100644 --- a/packages/solid/src/writableTerms.test.ts +++ b/packages/solid/src/writableTerms.test.ts @@ -7,6 +7,8 @@ import { describe, expect, it } from "vitest"; import { MAX_TERM_CHARACTERS } from "./client/readLimits"; import { refuseUnwritable, unwritableTermOf } from "./writableTerms"; +const DIR_LANG_STRING = "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString"; +const LANG_STRING = "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString"; const named = (value: string) => ({ termType: "NamedNode", value }) as Term; const literal = ( value: string, @@ -32,6 +34,8 @@ describe("unwritableTermOf", () => { ["text with anything in it", literal('a> . "c', "en-GB")], ["a language tag with subtags of 8 characters", literal("a", "abcdefgh-12345678")], ["a literal without a direction", { ...literal("a"), direction: undefined } as Term], + ["text with a base direction read from a pod", literal("a", "ar", DIR_LANG_STRING, "rtl")], + ["text with a left-to-right direction", literal("a", "en-gb", DIR_LANG_STRING, "ltr")], ])("finds none in %s", (_, term) => { expect(unwritableTermOf(quad(term))).toBeUndefined(); }); @@ -55,9 +59,20 @@ describe("unwritableTermOf", () => { ["a language subtag longer than 8 characters", literal("a", "constructor")], ["a later subtag longer than 8 characters", literal("a", "en-123456789")], [ - "a base direction", - literal("a", "ar", "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString", "rtl"), + "text typed as having a base direction without a language", + literal("a", "", DIR_LANG_STRING, "rtl"), ], + ["text typed as having a base direction without one", literal("a", "ar", DIR_LANG_STRING)], + [ + "text typed as having a base direction without one at all", + { ...literal("a", "ar", DIR_LANG_STRING), direction: undefined } as Term, + ], + ["a base direction RDF does not have", literal("a", "ar", DIR_LANG_STRING, "up")], + [ + "a base direction on text typed as in a language only", + literal("a", "ar", LANG_STRING, "rtl"), + ], + ["a base direction on text in no language", literal("a", "", undefined, "rtl")], ["a quoted triple", { termType: "Quad", value: "" } as unknown as Term], ])("finds %s", (_, term) => { expect(unwritableTermOf(quad(term))).toBe(term); diff --git a/packages/solid/src/writableTerms.ts b/packages/solid/src/writableTerms.ts index 57c35018c..a51d7ba12 100644 --- a/packages/solid/src/writableTerms.ts +++ b/packages/solid/src/writableTerms.ts @@ -2,7 +2,7 @@ // // SPDX-License-Identifier: MIT -import type { Quad, Term } from "@rdfjs/types"; +import type { Literal, Quad, Term } from "@rdfjs/types"; import { AppError } from "@solid-memo/domain/appError"; import { isWritableIri } from "@solid-memo/domain/webId"; import { MAX_TERM_CHARACTERS, termKeyLength } from "./client/readLimits"; @@ -17,12 +17,18 @@ const PN_CHARS = `${PN_CHARS_U}\\-0-9\\u00B7\\u0300-\\u036F\\u203F\\u2040`; /** A blank node label as Turtle reads one after `_:` (BLANK_NODE_LABEL). */ const BLANK_LABEL = new RegExp(`^[${PN_CHARS_U}0-9](?:[${PN_CHARS}.]*[${PN_CHARS}])?$`, "u"); +/** The datatype of text in a language with a base direction (RDF 1.2), which Turtle writes as `"…"@tag--dir`. */ +const DIR_LANG_STRING = "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString"; + /** The datatypes of text in a language, which Turtle writes as `"…"@tag`, never with `^^`. */ const LANGUAGE_STRINGS = new Set([ "http://www.w3.org/1999/02/22-rdf-syntax-ns#langString", - "http://www.w3.org/1999/02/22-rdf-syntax-ns#dirLangString", + DIR_LANG_STRING, ]); +/** The base directions RDF 1.2 has, the only ones Turtle reads after `--`. */ +const DIRECTIONS = new Set(["ltr", "rtl"]); + /** * A language tag as Turtle reads one after `@` (LANGTAG), its subtags no * longer than 8 characters as BCP 47 has them: the parser that reads the @@ -38,8 +44,10 @@ const LANGUAGE = /^[a-zA-Z]{1,8}(?:-[a-zA-Z0-9]{1,8})*$/; * node label or a language tag, and a JSON-LD parser keeps any text in * them: written, a `>` in an IRI ends it, and what follows is read as * statements no check saw (GHSA-x937-hx25-qfp4). Nor can it write text - * typed as in a language without one, a base direction or a quoted - * triple (RDF-star), none of which the app writes. + * typed as in a language without one, a base direction but on text in a + * language (isWritableDirection), or a quoted triple (RDF-star). The app + * writes none of these, nor any base direction of its own; a literal + * with one that a read kept (`"…"@ar--rtl`) is written back as it was. */ function isWritable(term: Term): boolean { switch (term.termType) { @@ -53,7 +61,7 @@ function isWritable(term: Term): boolean { (term.language === "" ? !LANGUAGE_STRINGS.has(term.datatype.value) : LANGUAGE.test(term.language)) && - (term.direction ?? "") === "" + isWritableDirection(term) ); case "Quad": return false; @@ -62,6 +70,16 @@ function isWritable(term: Term): boolean { } } +/** + * Whether Turtle can write a literal's base direction as it is: `ltr` or + * `rtl` on text typed as having one (`"…"@ar--rtl`), and none on any + * other literal, which a writer would give one it does not have. + */ +function isWritableDirection({ datatype, direction }: Literal): boolean { + if (datatype.value === DIR_LANG_STRING) return DIRECTIONS.has(direction ?? ""); + return (direction ?? "") === ""; +} + /** The first term of a quad Turtle cannot write as it is (isWritable); undefined when there is none. */ export function unwritableTermOf(quad: Quad): Term | undefined { return [quad.subject, quad.predicate, quad.object, quad.graph].find((term) => !isWritable(term));