Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -1267,7 +1267,11 @@ own pod, as a draft, before publishing it
made, its sources) is written as it is, and kept through every edit;
the Studio's release screen writes it as the library's releases do
([deck-library.md](deck-library.md#provenance),
[studio.md](studio.md#the-releases-metadata-and-provenance)). A
[studio.md](studio.md#the-releases-metadata-and-provenance)). Of the
classes another app gives a subject the draft reads by its shape, the
draft keeps only those the subject's write would keep
([shapes.md](shapes.md#conventions)), so none of them stops an edit.
The release published from it leaves the others out too. A
draft names the release it was published as with `sm:releasedAs`, and
is then no longer edited. An `sm:releasedAs` at an address no release
can have (`isPublishedReleaseUrl`, as for the catalog's releases) is
Expand Down
7 changes: 6 additions & 1 deletion docs/shapes.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,12 @@ check a draft's cards, distractors, agents and distributions.
blank node, names no class but counts toward that number, so the
writer leaves it out of the subject it checks and writes. A
blank node stays in a document Solid Memo patches rather than
rewrites, but no write counts it again.
rewrites, but no write counts it again. A release draft keeps another
app's statements of its subjects as they are. So it reads and writes
the classes of its deck, chapters, steps, cards, distractors, agents
and distributions by the same rule (`keptClasses` in
[records.ts](../packages/solid/src/records.ts)). Publishing leaves
out of the release each class the draft doesn't keep.
- **Property shapes are named** (`<#front>`, never blank nodes) for the
generator and the results. `sh:message` replaces cryptic engine
text, always in exactly English and Swedish (`"…"@en`, `"…"@sv`),
Expand Down
86 changes: 79 additions & 7 deletions packages/solid/src/mappers/releaseDraftMapper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,14 @@ import { migrate } from "@solid-memo/domain/shapes/migrations";
import { DRAFT_DECK_V1, SHAPES } from "@solid-memo/vocab/descriptors.generated";
import type { ShapeDescriptor } from "@solid-memo/vocab/shapeDescriptor";
import type { ShapeName } from "@solid-memo/vocab/types.generated";
import { datetimeOf, readIris, readRecord, readVersioned, recordThing } from "../records";
import {
datetimeOf,
keptClasses,
readIris,
readRecord,
readVersioned,
recordThing,
} from "../records";
import { DCTERMS, SM } from "../vocab";

/**
Expand Down Expand Up @@ -381,8 +388,24 @@ function hiddenPredicateOf(
export function draftFromQuads(
quads: readonly Quad[],
url: string,
{ refuseHidden = false }: { refuseHidden?: boolean } = {},
options: { refuseHidden?: boolean } = {},
): ReleaseDraft | null {
return draftReadOf(quads, url, options)?.draft ?? null;
}

/**
* The draft `quads` state (draftFromQuads), and which classes it does not
* keep: `unkept(subject, object)` holds of an rdf:type of a subject the
* draft reads by its shape (`subject` its key, `object` as the draft
* names it) that is neither one of its own nor one keptClasses keeps of
* the others. Publishing leaves those out of the release, as the draft
* it checks does (GHSA-8hh7-p2ww-mpjf).
*/
export function draftReadOf(
quads: readonly Quad[],
url: string,
{ refuseHidden = false }: { refuseHidden?: boolean } = {},
): { draft: ReleaseDraft; unkept: (subject: string, object: ReleaseTerm) => boolean } | null {
const dataset = datasetFromQuads(quads);
/** Each subject's statements, by its key. */
const statements = new Map<string, Quad[]>();
Expand Down Expand Up @@ -480,11 +503,33 @@ export function draftFromQuads(
own(subject, descriptor);
nodes[list]!.push({ id, data });
}
// Of the other classes a shaped subject states, only those its write
// keeps (keptClasses): a class a shape asks something of, one past the
// most the write check reads, or a type that is no IRI would stop
// every write of the subject (GHSA-8hh7-p2ww-mpjf).
const classes = new Map<string, string[]>();
for (const quad of quads) {
const subject = keyOf(quad.subject);
if (!owned.has(subject) || !isClass(quad)) continue;
const of = classes.get(subject);
if (of === undefined) classes.set(subject, [quad.object.value]);
else of.push(quad.object.value);
}
const kept = new Map(
[...classes].map(([subject, types]) => [
subject,
new Set(keptClasses(owned.get(subject)!.types, types)),
]),
);
const unkept = (subject: string, object: ReleaseTerm) =>
owned.has(subject) && (object.kind !== "iri" || !kept.get(subject)!.has(object.value));
const triples = quads
.filter((quad) => {
const shaped = owned.get(keyOf(quad.subject));
const subject = keyOf(quad.subject);
const shaped = owned.get(subject);
if (shaped === undefined) return true;
if (quad.predicate.value === RDF_TYPE) return !shaped.types.has(quad.object.value);
if (quad.predicate.value === RDF_TYPE)
return !shaped.types.has(quad.object.value) && !unkept(subject, termOf(quad.object));
return !shaped.predicates.has(quad.predicate.value);
})
.map(
Expand All @@ -494,7 +539,7 @@ export function draftFromQuads(
object: termOf(quad.object),
}),
);
return {
const draft: ReleaseDraft = {
url,
course: rootTypes.includes(SCHEMA_COURSE),
root,
Expand All @@ -505,6 +550,7 @@ export function draftFromQuads(
triples,
published: NOTHING_PUBLISHED,
};
return { draft, unkept };
}

/** One subject of a draft as its document in the pod holds it: its record, when its shape describes it, and its other statements. */
Expand Down Expand Up @@ -647,10 +693,36 @@ export function entryQuads(entry: DraftEntry): Quad[] {
recordThing(entry.subject, entry.shaped.descriptor, entry.shaped.record, null),
),
);
return [
return withEntryClasses(entry, [
...shaped,
...entry.raw.map((triple) => quadOf(triple.subject, triple.predicate, triple.object)),
];
]);
}

/** Whether the quad states a class of its subject: an rdf:type that is an IRI. */
function isClass(quad: Quad): boolean {
return quad.predicate.value === RDF_TYPE && quad.object.termType === "NamedNode";
}

/**
* The quads of an entry with the classes its subject's write would not
* keep left out, when its shape describes it: each its own, and of the
* others only those keptClasses keeps, so that what a draft writes of a
* shaped subject is held to its own shapes alone, whatever its
* statements carry (GHSA-8hh7-p2ww-mpjf). A type that is no IRI goes too.
*/
export function withEntryClasses(entry: DraftEntry, quads: readonly Quad[]): Quad[] {
if (entry.shaped === undefined) return [...quads];
const { descriptor } = entry.shaped;
const typed = (quad: Quad) =>
quad.predicate.value === RDF_TYPE && keyOf(quad.subject) === entry.subject;
const kept = new Set(
keptClasses(
[descriptor.targetClass, ...descriptor.additionalTypes],
quads.filter((quad) => typed(quad) && isClass(quad)).map((quad) => quad.object.value),
),
);
return quads.filter((quad) => !typed(quad) || (isClass(quad) && kept.has(quad.object.value)));
}

/** Whether two entries say the same of their subject, in the same place. */
Expand Down
31 changes: 24 additions & 7 deletions packages/solid/src/records.ts
Original file line number Diff line number Diff line change
Expand Up @@ -433,19 +433,36 @@ export function withoutForeignShapedClasses(
thing: NamedSubject,
descriptor: ShapeDescriptor,
): NamedSubject {
const own = new Set([descriptor.targetClass, ...descriptor.additionalTypes]);
let room = MAX_CLASSES - own.size;
const kept = keptClasses(
[descriptor.targetClass, ...descriptor.additionalTypes],
getUrlAll(thing, RDF.type),
);
// A value of rdf:type that is no IRI (a literal, a blank node) names
// no class, yet the check counts it against MAX_CLASSES: it goes too.
return buildThing(thing).removeAll(RDF.type).setIriAll(RDF.type, kept).build();
}

/**
* Of the classes a subject states (`classes`, IRIs in the order it
* states them), the ones it keeps when its own are `own`: each of its
* own, and each class no shape asks anything of (SHAPED_CLASSES) while
* the subject, its own classes first, stays within the most the write
* check reads it by (MAX_CLASSES). The rule withoutForeignShapedClasses
* writes a subject by, for a writer that keeps a subject's classes as
* statements of its own (a release draft's).
*/
export function keptClasses(own: Iterable<string>, classes: Iterable<string>): string[] {
const owned = new Set(own);
let room = MAX_CLASSES - owned.size;
const kept: string[] = [];
for (const type of new Set(getUrlAll(thing, RDF.type))) {
if (own.has(type)) kept.push(type);
for (const type of new Set(classes)) {
if (owned.has(type)) kept.push(type);
else if (!SHAPED_CLASSES.has(type) && room > 0) {
kept.push(type);
room--;
}
}
// A value of rdf:type that is no IRI (a literal, a blank node) names
// no class, yet the check counts it against MAX_CLASSES: it goes too.
return buildThing(thing).removeAll(RDF.type).setIriAll(RDF.type, kept).build();
return kept;
}

/**
Expand Down
149 changes: 149 additions & 0 deletions packages/solid/src/solidReleaseDraftRepository.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { ruleProblems } from "@solid-memo/domain/release/releaseCheck";
import {
applyDraftChanges,
blankDraft,
iriIn,
isRefusal,
type DraftChange,
type DraftNode,
Expand Down Expand Up @@ -2040,3 +2041,151 @@ describe("a release with a card outside it (GHSA-67w8-qjpm-jhwp)", () => {
]);
});
});

describe("a class another app gives a draft's subject (GHSA-8hh7-p2ww-mpjf)", () => {
const PALETTE = `${SM}Palette`;
const OTHER = "http://example.org/Other";
const CARD = `${CHAPTER_A}#q-a-2a`;
const typeOf = (subject: string, object: string) => `\n<${subject}> <${RDF}type> ${object} .\n`;

/** The course fixture with `turtle` added to `document` as another app would, read again. */
async function planted(document: string, turtle: string) {
const pod = await draftPod({ checked: true });
await pod.repository.create(INSTANCE, course());
await pod.put(document, `${await pod.text(document)}${turtle}`);
const { draft, version } = await pod.repository.read(DRAFT);
return { pod, draft, version };
}

/** The classes the draft keeps of `subject` as statements of its own. */
const keptTypes = (draft: ReleaseDraft, subject: string) =>
draft.triples
.filter((triple) => triple.subject === subject && triple.predicate === `${RDF}type`)
.map((triple) => triple.object.value);

it("leaves a shaped class on the root out, so its edit is written", async () => {
const { pod, draft, version } = await planted(DRAFT, typeOf(DRAFT, `<${PALETTE}>`));
expect(keptTypes(draft, DRAFT)).toEqual([]);
await pod.repository.applyChanges(
draft,
changed(draft, { kind: "setMeta", meta: { title: { en: "New" } } }),
version,
);
expect(await pod.text(DRAFT)).not.toContain(PALETTE);
const { draft: read } = await pod.repository.read(DRAFT);
expect(read.root.title).toEqual({ en: "New" });
});

it("leaves a shaped class on a card out, so its edit is written", async () => {
const { pod, draft, version } = await planted(CHAPTER_A, typeOf(CARD, `<${PALETTE}>`));
expect(keptTypes(draft, iriIn(draft, "q-a-2a"))).toEqual([]);
await pod.repository.applyChanges(
draft,
changed(draft, {
kind: "editCard",
id: "q-a-2a",
card: { front: { en: "New?" }, back: { en: "Yes" } },
}),
version,
);
expect(await pod.text(CHAPTER_A)).not.toContain(PALETTE);
});

it("keeps a class no shape asks anything of through an edit", async () => {
const { pod, draft, version } = await planted(DRAFT, typeOf(DRAFT, `<${OTHER}>`));
expect(keptTypes(draft, DRAFT)).toEqual([OTHER]);
await pod.repository.applyChanges(
draft,
changed(draft, { kind: "setMeta", meta: { title: { en: "New" } } }),
version,
);
const { draft: read } = await pod.repository.read(DRAFT);
expect(keptTypes(read, DRAFT)).toEqual([OTHER]);
});

it("keeps no more classes than the write check reads, and no type that is no IRI", async () => {
const many = Array.from({ length: 40 }, (_, at) => `<${OTHER}${at}>`).join(" , ");
const { pod, draft, version } = await planted(
DRAFT,
`${typeOf(DRAFT, many)}${typeOf(DRAFT, '"Deck"')}${typeOf(DRAFT, "_:b")}`,
);
const kept = keptTypes(draft, DRAFT);
// Its own classes (sm:Deck, dcat:Dataset) and schema:Course count first.
expect(kept).toHaveLength(32 - 3);
expect(kept.every((type) => type.startsWith(OTHER))).toBe(true);
await pod.repository.applyChanges(
draft,
changed(draft, { kind: "setMeta", meta: { title: { en: "New" } } }),
version,
);
const text = await pod.text(DRAFT);
expect(text).not.toContain('"Deck"');
const { draft: read } = await pod.repository.read(DRAFT);
expect(keptTypes(read, DRAFT)).toEqual(kept);
});

it("writes no shaped class a draft's statements carry, made or changed", async () => {
const carried = (draft: ReleaseDraft, subject: string): ReleaseDraft => ({
...draft,
triples: [
...draft.triples,
{ subject, predicate: `${RDF}type`, object: { kind: "iri", value: PALETTE } },
{ subject, predicate: `${RDF}type`, object: { kind: "blank", value: "b" } },
],
});
const pod = await draftPod({ checked: true });
await pod.repository.create(INSTANCE, carried(course(), DRAFT));
expect(await pod.text(DRAFT)).not.toContain(PALETTE);
const { draft, version } = await pod.repository.read(DRAFT);
const card = iriIn(draft, "q-a-2a");
await pod.repository.applyChanges(
draft,
carried(
changed(draft, {
kind: "editCard",
id: "q-a-2a",
card: { front: { en: "New?" }, back: { en: "Yes" } },
}),
card,
),
version,
);
expect(await pod.text(CHAPTER_A)).not.toContain(PALETTE);
});
});

describe("publishing a draft whose subject another app gave a class (GHSA-8hh7-p2ww-mpjf)", () => {
const PALETTE = `${SM}Palette`;
const OTHER = "http://example.org/Other";
const DISTRACTOR = `${CHAPTER_A}#q-a-1a-d1`;
const typeOf = (subject: string, object: string) => `\n<${subject}> <${RDF}type> ${object} .\n`;

it("leaves a class the draft does not keep out of the release, as of the draft checked", async () => {
const pod = await draftPod({ checked: true });
await pod.repository.create(INSTANCE, course());
await pod.put(
CHAPTER_A,
`${await pod.text(CHAPTER_A)}${typeOf(DISTRACTOR, `<${PALETTE}>`)}${typeOf(DISTRACTOR, '"Deck"')}`,
);
await pod.put(
DRAFT,
`${await pod.text(DRAFT)}${typeOf(DRAFT, `<${PALETTE}>`)}${typeOf(DRAFT, `<${OTHER}>`)}`,
);
const { version } = await pod.repository.read(DRAFT);
const target = `${INSTANCE}releases/solid/v1.ttl`;
const quads = await quadsOfTurtle(
await pod.repository.assemble(DRAFT, target, NOW, version),
target,
);
const classesOf = (subject: string) =>
quads
.filter((quad) => quad.subject.value === subject && quad.predicate.value === `${RDF}type`)
.map((quad) => quad.object.value);
expect(classesOf(target)).not.toContain(PALETTE);
expect(classesOf(target)).toContain(OTHER);
expect(classesOf(`${target}#q-a-1a-d1`)).toEqual([
`${SM}Distractor`,
"https://schema.org/Answer",
]);
});
});
Loading
Loading