Repository navigation
Expand file tree
/
Copy pathsolidReleaseDraftRepository.ts
More file actions
771 lines (734 loc) · 29.7 KB
/
Copy pathsolidReleaseDraftRepository.ts
File metadata and controls
771 lines (734 loc) · 29.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
// SPDX-FileCopyrightText: 2026 Anton Wiklund and Solid Memo contributors
//
// SPDX-License-Identifier: MIT
import { getContainedResourceUrlAll, getSolidDataset } from "./client/resources";
import {
type Dataset,
type FetchedDataset,
type NamedSubject,
asUrl,
createSolidDataset,
datasetFromQuads,
getThing,
getThingAll,
getUrlAll,
isEmptyDataset,
setThing,
withQuads,
} from "./client/rdf";
import type { Quad } from "@rdfjs/types";
import { editOf } from "./datasetEdit";
import type { ReleaseDraftRepository } from "@solid-memo/application/ports";
import { AppError } from "@solid-memo/domain/appError";
import {
catalogNodeUrlOf,
catalogUrlOf,
ensureTrailingSlash,
} from "@solid-memo/domain/instanceLayout";
import {
draftContainerOf,
draftPlaceOf,
draftSummaryOf,
isDraftDocumentName,
unreadableDraftSummary,
type ReleaseDraftSummary,
} from "@solid-memo/domain/release/draftLayout";
import { DCAT_NS, DCTERMS_NS } from "@solid-memo/domain/release/releaseModel";
import {
RDF_TYPE,
type DraftTriple,
type ReleaseDraft,
unsupportedIdOf,
} from "@solid-memo/domain/release/releaseDraft";
import { moved } from "@solid-memo/domain/release/releaseToDraft";
import { LATEST_VERSION } from "@solid-memo/vocab/types.generated";
import { addCatalogLink, catalogLinks, removeCatalogLink } from "./catalogLinks";
import { deleteContainerIfEmpty } from "./containers";
import {
deleteDataset,
getSolidDatasetOrNull,
removeOwned,
readDataset,
saveDataset,
versionOf,
} from "./datasets";
import {
draftEntries,
draftFromQuads,
draftIriOf,
entryQuads,
inPlaceIn,
keyOf,
quadOf,
quadsOf,
sameEntry,
termOf,
tripleKey,
type DraftEntry,
UNPUBLISHED_ROOT_PREDICATES,
} from "./mappers/releaseDraftMapper";
import { draftSubjects } from "./ownership";
import { recordThing, removeAllUnlessNewer, storedVersionOf } from "./records";
import { limitedFetch, RELEASE_LIMITS } from "./client/readLimits";
import { releaseReadError } from "./releaseReadError";
import { DECK_FILE_PREFIXES, parseDeckFile } from "./solidDeckArchive";
import { refuseUnwritable } from "./writableTerms";
import { turtleOf } from "./turtleWriter";
import { SM } from "./vocab";
import { noWriteCheck, type WriteCheck } from "./writeCheck";
const XSD_DATE_TIME = "http://www.w3.org/2001/XMLSchema#dateTime";
const DCAT = (name: string) => `${DCAT_NS}${name}`;
const DCTERMS = (name: string) => `${DCTERMS_NS}${name}`;
/** The prefixes a release is written with (docs/deck-library.md): a deck file's, and those a release's provenance uses. */
export const RELEASE_PREFIXES: Readonly<Record<string, string>> = {
"solid-memo": DECK_FILE_PREFIXES.sm!,
...Object.fromEntries(Object.entries(DECK_FILE_PREFIXES).filter(([prefix]) => prefix !== "sm")),
adms: "http://www.w3.org/ns/adms#",
spdx: "http://spdx.org/rdf/terms#",
};
/** Each of a draft's documents as read last, by URL, and the version they were at together. */
interface Read {
version: string;
documents: Map<string, Dataset>;
}
/**
* The ReleaseDraftRepository over the pod (docs/data-model.md "Drafts"):
* a draft is the container its release document is in, linked from the
* instance's catalog by `sm:releaseDraft`. A draft's documents are
* read whole, each remembered as read, and an edit of one is written
* only if it is still as it was (If-Match), one PUT of it whole; an
* edit is made from the read it names by its version, or refused. What
* the draft shapes describe is checked before it is written
* (`checkWrite`, "draft"); what they do not is written as it is.
* Releases are read, never written, with `releaseFetch`: a library
* release is read where the site serves it. The documents a release
* names its series and publisher in are read with `publicFetch`, as no
* one: what they say is copied into a draft to be published. Both are
* read under RELEASE_LIMITS: a release may be anyone's, on any server.
*/
export function createSolidReleaseDraftRepository({
fetch,
releaseFetch = fetch,
publicFetch = releaseFetch,
checkWrite = noWriteCheck,
}: {
fetch: typeof globalThis.fetch;
releaseFetch?: typeof globalThis.fetch;
publicFetch?: typeof globalThis.fetch;
checkWrite?: WriteCheck;
}): ReleaseDraftRepository {
/** Each draft's documents as last read, so an edit is written only if they are still so. */
const reads = new Map<string, Read>();
const releases = limitedFetch(releaseFetch, RELEASE_LIMITS);
const published = limitedFetch(publicFetch, RELEASE_LIMITS);
/** The drafts the instance's catalog links, as their places say: a link anywhere but a draft's place in the instance is not followed. */
async function linked(
instanceUrl: string,
): Promise<{ url: string; place: { instanceUrl: string; name: string; version: number } }[]> {
return (await catalogLinks(instanceUrl, SM.releaseDraft, fetch)).flatMap((url) => {
const place = draftPlaceOf(url);
return place === null || place.instanceUrl !== ensureTrailingSlash(instanceUrl)
? []
: [{ url, place }];
});
}
const documentsOf = (draftUrl: string) => draftDocumentsIn(draftUrl, fetch);
/** A draft's documents, read now and remembered as read. */
async function readDraft(draftUrl: string): Promise<{ draft: ReleaseDraft; version: string }> {
const urls = await documentsOf(draftUrl);
if (!urls.includes(draftUrl)) throw new AppError("draftGone");
const datasets = await Promise.all(urls.map((url) => readDataset(url, fetch)));
const quads = draftNamedQuads(draftUrl, urls, datasets);
// What its documents state otherwise than its records write it would be published, unseen and unchecked (GHSA-32jq-jh7h-r7v4).
const draft = draftFromQuads(quads, draftUrl, { refuseHidden: true });
if (draft === null) throw new AppError("draftGone");
// Its documents are anyone's to write: an id no draft has could name a chapter's document outside its container.
const unsupported = unsupportedIdOf(draft);
if (unsupported !== null) throw new AppError("draftIdUnsupported", { id: unsupported });
const version = versionOfDocuments(urls, datasets);
reads.set(draftUrl, {
version,
documents: new Map(urls.map((url, at) => [url, datasets[at]!])),
});
return { draft, version };
}
/** Read a release document, as it states itself, its formats ones this app reads. */
function readReleaseQuads(quads: readonly Quad[], url: string): ReleaseDraft {
const release = draftFromQuads(quads, url);
// A version is 1, 2, …: a draft is numbered after it, in its address, as is the next version's.
const version = release?.root.version ?? "1";
if (
release === null ||
!/^[1-9][0-9]*$/.test(version) ||
!Number.isSafeInteger(Number(version) + 1)
)
throw new AppError("releaseUnreadable", { url });
const dataset = datasetFromQuads(quads);
const root = getThing(dataset, url)!;
if (storedVersionOf(root) > LATEST_VERSION.libraryDeck) {
throw new AppError("libraryDeckTooNew", {
url,
version: storedVersionOf(root),
reads: LATEST_VERSION.libraryDeck,
});
}
for (const thing of getThingAll(dataset)) {
if (
getUrlAll(thing, RDF_TYPE).includes(SM.Card) &&
storedVersionOf(thing) > LATEST_VERSION.card
) {
throw new AppError("libraryCardTooNew", {
url: asUrl(thing),
version: storedVersionOf(thing),
reads: LATEST_VERSION.card,
});
}
}
return release;
}
return {
async list(instanceUrl) {
return Promise.all(
(await linked(instanceUrl)).map(async ({ url, place }): Promise<ReleaseDraftSummary> => {
try {
const dataset = await readDataset(url, fetch);
const draft = draftFromQuads(quadsOf(dataset), url);
return draft === null ? unreadableDraftSummary(url, place) : draftSummaryOf(draft)!;
} catch {
return unreadableDraftSummary(url, place);
}
}),
);
},
async documents(instanceUrl) {
return (
await Promise.all((await linked(instanceUrl)).map(({ url }) => documentsOf(url)))
).flat();
},
async create(instanceUrl, draft) {
// A draft at an address no draft has would be written where no list finds it, nor a delete.
const summary = draftSummaryOf(draft);
if (summary === null) throw new Error(`No draft can be at ${draft.url}`);
const catalog = await getSolidDatasetOrNull(catalogUrlOf(instanceUrl), fetch);
if (catalog === null || getThing(catalog, catalogNodeUrlOf(instanceUrl)) === null)
throw new AppError("noCatalogToUpdate");
const byDocument = new Map<string, DraftEntry[]>([[draft.url, []]]);
for (const entry of draftEntries(draft).values()) {
const entries = byDocument.get(entry.document);
if (entries === undefined) byDocument.set(entry.document, [entry]);
else entries.push(entry);
}
// The release document first: a draft whose place is taken stops there, having written nothing.
const written: string[] = [];
try {
for (const [url, entries] of byDocument) {
const dataset = datasetFromQuads(entries.flatMap(entryQuads));
await checkWrite(dataset, shapedSubjects(entries), "draft");
await saveDataset(url, dataset, fetch);
written.push(url);
}
await addCatalogLink(instanceUrl, SM.releaseDraft, draft.url, fetch);
} catch (error) {
// What this draft wrote goes, the release document last: no draft is left that no catalog links, nor deletes.
if (written.length > 0)
await deleteDocuments(draft.url, [...written.slice(1).reverse(), draft.url], fetch).catch(
() => undefined,
);
throw error;
}
return summary;
},
read(draftUrl) {
return readDraft(draftUrl);
},
async applyChanges(before, after, version) {
const read = reads.get(before.url);
// Read since at another version (an edit made meanwhile), or never: `before` is not what the documents were.
if (read?.version !== version) throw new AppError("changedElsewhere", { url: before.url });
const touched = changesByDocument(draftEntries(before), draftEntries(after));
// New documents first, so no subject is ever named from where it is not yet; then the others, the release document last.
const order = [...touched.keys()].sort(
(a, b) =>
Number(read.documents.has(a)) - Number(read.documents.has(b)) ||
Number(a === before.url) - Number(b === before.url),
);
for (const url of order) {
const stored = read.documents.get(url);
const { dataset, made } = changedDocument(stored, touched.get(url)!);
await checkWrite(dataset, shapedSubjects(made.map(({ entry }) => entry)), "draft");
if (stored !== undefined && url !== before.url && isEmptyDataset(dataset)) {
// A chapter's document with nothing left in it goes, as it was read.
await deleteDataset(url, stored, fetch);
} else {
// One PUT of the whole document, If-Match: a draft's documents are all text, which Community Solid
// Server's in-memory store cuts short after a PATCH, and node-solid-server stores a boolean a PATCH
// inserts as false (owl:deprecated true, a retirement, among them).
await saveDataset(url, dataset, fetch, { whole: true });
}
}
// The documents are no longer as read: an edit made from that read again is refused, a newer read kept.
read.version = "";
},
async assemble(draftUrl, targetUrl, issued, version) {
const urls = await documentsOf(draftUrl);
if (!urls.includes(draftUrl)) throw new AppError("draftGone");
const iriOf = draftIriOf(draftContainerOf(draftUrl), draftUrl);
const inPlace = inPlaceIn(draftContainerOf(draftUrl), draftUrl);
const to = (iri: string) => moved(iriOf(iri), draftUrl, targetUrl);
const datasets = await Promise.all(urls.map((url) => readDataset(url, fetch)));
if (version !== undefined && versionOfDocuments(urls, datasets) !== version)
throw new AppError("changedElsewhere", { url: draftUrl });
// A statement of the release at its address already would say more of it than the draft does, which no editor shows (GHSA-p2pr-x6fw-hrfx).
const atTargetAlready = (iri: string) => iri === targetUrl || iri.startsWith(`${targetUrl}#`);
const atTarget = datasets.flatMap((dataset, at) =>
quadsOf(dataset).map((quad) => {
const object = termOf(quad.object);
const label = (value: string) => `d${at}-${value}`;
if (quad.subject.termType !== "BlankNode" && atTargetAlready(iriOf(quad.subject.value)))
throw new AppError("releaseForeignStatement", { subject: iriOf(quad.subject.value) });
// So would one of the draft's own subjects named otherwise than the draft writes it, which no read of the draft has (GHSA-8w75-jg23-rxq5).
if (quad.subject.termType !== "BlankNode" && !inPlace(urls[at]!, quad.subject.value))
throw new AppError("draftAliasedSubject", { subject: quad.subject.value });
return quadOf(
quad.subject.termType === "BlankNode"
? `_:${label(quad.subject.value)}`
: to(quad.subject.value),
quad.predicate.value,
object.kind === "iri"
? { kind: "iri", value: to(object.value) }
: object.kind === "blank"
? { kind: "blank", value: label(object.value) }
: object,
);
}),
);
// So would a value of one of its subjects stated otherwise than its record writes it, which no read of the draft shows (GHSA-32jq-jh7h-r7v4).
draftFromQuads(draftNamedQuads(draftUrl, urls, datasets), draftUrl, { refuseHidden: true });
const quads = releaseQuadsOf(atTarget, targetUrl, issued);
const subjects = [...new Set(quads.map((quad) => keyOf(quad.subject)))];
return turtleOf(quads, {
base: targetUrl,
prefixes: RELEASE_PREFIXES,
order: [
targetUrl,
...subjects.filter((subject) => !subject.startsWith(`${targetUrl}#`)),
...subjects,
],
});
},
async readRelease(url) {
let dataset: Dataset;
try {
dataset = await readDataset(url, releases);
} catch (error) {
throw releaseReadError(url, error);
}
return readReleaseQuads(quadsOf(dataset), url);
},
async readLinked(release) {
const own = new Set(release.triples.map((triple) => triple.subject));
const named = [
release.root.inSeries,
release.root.isVersionOf,
release.root.publisher,
].filter((iri): iri is string => iri !== undefined && !own.has(iri));
const documents = [...new Set(named.map((iri) => iri.split("#")[0]!))].filter(
(url) => url !== release.url,
);
// One that cannot be read says nothing: the draft is made without it, and the release check finds what it lacks.
const read = await Promise.all(
documents.map((url) =>
readDataset(url, published).then(
(dataset) => quadsOf(dataset).map(asTriple),
() => [],
),
),
);
return read.flat();
},
async parseRelease(text, format) {
const base = "https://file.solid-memo.invalid/release.ttl";
// A release file is held to what a release read from anywhere is: one past it no one could read once published.
const quads = await parseDeckFile(text, format, base, RELEASE_LIMITS);
// A draft keeps a release's triples as they are: one Turtle cannot write as it is would say more once saved (GHSA-x937-hx25-qfp4).
refuseUnwritable(quads);
const roots = [
...new Set(
quads
.filter((quad) => quad.predicate.value === RDF_TYPE && quad.object.value === SM.Deck)
.map((quad) => quad.subject.value),
),
];
if (roots.length !== 1 || roots[0]!.includes("#")) throw new AppError("notAReleaseFile");
if (
quads.some(
(quad) =>
quad.subject.value === roots[0] &&
(quad.predicate.value === SM.cardsDocument ||
quad.predicate.value === SM.reviewsDocument),
)
) {
throw new AppError("notAReleaseFile");
}
try {
return readReleaseQuads(quads, roots[0]!);
} catch (error) {
throw error instanceof AppError && error.code === "releaseUnreadable"
? new AppError("notAReleaseFile")
: error;
}
},
async delete(draft) {
const keptDocuments = await deleteDraftResources(draft.url, fetch);
reads.delete(draft.url);
await removeCatalogLink(draft.instanceUrl, SM.releaseDraft, draft.url, fetch);
return { keptDocuments };
},
};
}
/** The version of each of a draft's documents, in order: a draft changed when any of them did, or one came or went. */
function versionOfDocuments(urls: readonly string[], datasets: readonly Dataset[]): string {
return JSON.stringify(urls.map((url, at) => [url, versionOf(datasets[at]!)]));
}
/**
* The statements of a draft's documents (`urls`, read as `datasets`),
* every IRI as the draft names it, as a read of the draft takes them: a
* subject a document names otherwise than the draft writes it there is
* not the draft's (inPlaceIn), and is left out; publishing refuses it.
*/
function draftNamedQuads(
draftUrl: string,
urls: readonly string[],
datasets: readonly Dataset[],
): Quad[] {
const iriOf = draftIriOf(draftContainerOf(draftUrl), draftUrl);
const inPlace = inPlaceIn(draftContainerOf(draftUrl), draftUrl);
return datasets.flatMap((dataset, at) =>
quadsOf(dataset).flatMap((quad) => {
if (quad.subject.termType !== "BlankNode" && !inPlace(urls[at]!, quad.subject.value))
return [];
// Blank nodes are named afresh in each document: here, they are each document's own.
const blank = (key: string) =>
key.startsWith("_:") ? `_:d${at}-${key.slice(2)}` : iriOf(key);
const object = termOf(quad.object);
return quadOf(
blank(keyOf(quad.subject)),
quad.predicate.value,
object.kind === "iri"
? { kind: "iri", value: iriOf(object.value) }
: object.kind === "blank"
? { kind: "blank", value: `d${at}-${object.value}` }
: object,
);
}),
);
}
/**
* A draft's statements, every subject moved to `targetUrl`, as the
* release they make, released `issued` (also its time of change):
* written at library deck format 6, with the series it starts described
* in it (withSeries). What `assemble` writes, and what the shapes check
* of a draft (ShapeValidator.validateRelease).
*/
export function releaseQuadsOf(quads: readonly Quad[], targetUrl: string, issued: string): Quad[] {
const time = { kind: "literal" as const, value: issued, language: "", datatype: XSD_DATE_TIME };
return withSeries(
[
...quads.filter(
(quad) =>
!(
quad.subject.value === targetUrl &&
UNPUBLISHED_ROOT_PREDICATES.has(quad.predicate.value)
),
),
quadOf(targetUrl, SM.formatVersion, {
kind: "literal",
value: String(LATEST_VERSION.libraryDeck),
language: "",
datatype: "http://www.w3.org/2001/XMLSchema#integer",
}),
quadOf(targetUrl, DCTERMS("issued"), time),
quadOf(targetUrl, DCTERMS("modified"), time),
],
targetUrl,
);
}
/** The documents of a draft's container, those a draft has (draftLayout.ts), by URL; none when it is gone (404). */
export async function draftDocumentsIn(
draftUrl: string,
fetch: typeof globalThis.fetch,
): Promise<string[]> {
const container = draftContainerOf(draftUrl);
// The listing is read afresh, never answered from what was read before: a server whose ETag
// outlives edits made in the same second would say a document just made is not there.
let listing: FetchedDataset;
try {
listing = await getSolidDataset(container, { fetch });
} catch (error) {
if ((error as { statusCode?: number }).statusCode === 404) return [];
throw error;
}
return getContainedResourceUrlAll(listing)
.filter((url) => url.startsWith(container) && isDraftDocumentName(url.slice(container.length)))
.sort();
}
/**
* Delete what a draft is in a pod, and nothing else: its documents, the
* release document last, so a delete cut short still finds the draft;
* then its container, the one named after it, and the instance's
* `drafts/`, each only if it is then empty. Anything else in them,
* another app's, is kept, and so is the container holding it. Returns
* the documents kept for another app's data in them.
*/
export async function deleteDraftResources(
draftUrl: string,
fetch: typeof globalThis.fetch,
): Promise<string[]> {
const urls = await draftDocumentsIn(draftUrl, fetch);
return deleteDocuments(draftUrl, [...urls.filter((one) => one !== draftUrl), draftUrl], fetch);
}
/**
* Remove the draft's subjects from the documents, in order, each
* deleted when nothing else is left in it (removeOwned, draftSubjects),
* then the draft's containers, each only if it is then empty. Returns
* the documents kept for what else they hold.
*/
async function deleteDocuments(
draftUrl: string,
urls: readonly string[],
fetch: typeof globalThis.fetch,
): Promise<string[]> {
const kept: string[] = [];
for (const url of urls) {
if ((await removeOwned(url, draftSubjects, fetch)) === "kept") kept.push(url);
}
const container = draftContainerOf(draftUrl);
const named = container.slice(0, container.slice(0, -1).lastIndexOf("/") + 1);
const drafts = named.slice(0, named.slice(0, -1).lastIndexOf("/") + 1);
for (const one of [container, named, drafts]) {
if (!(await deleteContainerIfEmpty(one, fetch))) break;
}
return kept;
}
/** An entry that comes or changes in its document, and what it was there before when it changed. */
interface MadeEntry {
entry: DraftEntry;
was?: DraftEntry;
}
/** For a document, the entries that leave it and those that come or change in it. */
interface DocumentChanges {
gone: DraftEntry[];
made: MadeEntry[];
}
/** For each document, what changes in it from the draft's entries `was` to those `now`. */
function changesByDocument(
was: ReadonlyMap<string, DraftEntry>,
now: ReadonlyMap<string, DraftEntry>,
): Map<string, DocumentChanges> {
const touched = new Map<string, DocumentChanges>();
const at = (document: string) => {
let changes = touched.get(document);
if (changes === undefined) {
changes = { gone: [], made: [] };
touched.set(document, changes);
}
return changes;
};
for (const key of new Set([...was.keys(), ...now.keys()])) {
const old = was.get(key);
const next = now.get(key);
if (old !== undefined && next !== undefined && sameEntry(old, next)) continue;
if (
old !== undefined &&
(next === undefined || old.document !== next.document || old.subject !== next.subject)
)
at(old.document).gone.push(old);
if (next !== undefined)
at(next.document).made.push({
entry: next,
...(old?.document === next.document && old.subject === next.subject ? { was: old } : {}),
});
}
return touched;
}
/**
* A document as read (`stored`, or none when it is new) with its
* changes made, and what comes or changes in it as the document names
* its blank nodes.
*/
function changedDocument(
stored: Dataset | undefined,
changes: DocumentChanges,
): { dataset: Dataset; made: MadeEntry[] } {
const gone = changes.gone.map(inDocument);
const made = changes.made.map(({ entry, was }) => ({
entry: inDocument(entry),
...(was === undefined ? {} : { was: inDocument(was) }),
}));
let dataset: Dataset = stored ?? createSolidDataset();
const blank = [
...gone,
...made.flatMap(({ entry, was }) => [entry, ...(was === undefined ? [] : [was])]),
].some(involvesBlank);
// Every subject taken out, and every one written, at once (removeThings, editOf).
const named = (entry: DraftEntry) => !entry.subject.startsWith("_:");
const edit = editOf(
removeAllUnlessNewer(
dataset,
gone.filter(named).map((entry) => entry.subject),
),
);
for (const { entry, was } of made) {
if (named(entry)) edit.setThing(rewritten(edit.getThing(entry.subject), entry, was));
}
dataset = edit.done();
if (blank) dataset = withBlankNodes(dataset, gone, made);
return { dataset, made };
}
/** The subjects of entries their shapes describe: what the write check checks. */
function shapedSubjects(entries: readonly DraftEntry[]): string[] {
return entries.filter((entry) => entry.shaped !== undefined).map((entry) => entry.subject);
}
function involvesBlank(entry: DraftEntry): boolean {
return (
entry.subject.startsWith("_:") || entry.raw.some((triple) => triple.object.kind === "blank")
);
}
/**
* A subject as the entry says it, from what the document has of it now
* (`existing`): its record written over what its shape owns (recordThing,
* which refuses what a newer app wrote), the statements it said before
* (`was`) and does not now taken out, those it says now put in; anything
* else the document says of it kept.
*/
function rewritten(
existing: NamedSubject | null,
entry: DraftEntry,
was: DraftEntry | undefined,
): NamedSubject {
const base =
entry.shaped === undefined
? existing
: recordThing(entry.subject, entry.shaped.descriptor, entry.shaped.record, existing);
const removed = new Set((was?.raw ?? []).map(tripleKey));
const kept =
base === null
? []
: quadsOf(setThing(createSolidDataset(), base)).filter(
(quad) => !removed.has(tripleKey(asTriple(quad))),
);
const quads = [
...kept,
...entry.raw.map((triple) => quadOf(triple.subject, triple.predicate, triple.object)),
];
// The entry says something of its subject (a record's type, or a statement): the subject is there.
return getThing(datasetFromQuads(quads), entry.subject)!;
}
function asTriple(quad: Quad): DraftTriple {
return {
subject: keyOf(quad.subject),
predicate: quad.predicate.value,
object: termOf(quad.object),
};
}
/** A blank node's label in its document: the draft's (`d<n>-<label>`, a document's own) without what made it the document's. */
function podLabel(label: string): string {
return label.replace(/^(_:)?d[0-9]+-/, "$1");
}
/** An entry with its blank nodes as its document names them. */
function inDocument(entry: DraftEntry): DraftEntry {
const triple = (one: DraftTriple): DraftTriple => ({
subject: podLabel(one.subject),
predicate: one.predicate,
object:
one.object.kind === "blank"
? { kind: "blank", value: podLabel(one.object.value) }
: one.object,
});
return { ...entry, subject: podLabel(entry.subject), raw: entry.raw.map(triple) };
}
/**
* The document with the blank nodes of what changed as the entries say
* them: those a subject that left or changed named taken out, those it
* names now put in, as the document is written whole.
*/
function withBlankNodes(
dataset: Dataset,
gone: readonly DraftEntry[],
made: readonly MadeEntry[],
): Dataset {
const before = new Set(
[...gone, ...made.flatMap(({ was }) => (was === undefined ? [] : [was]))]
.filter((entry) => entry.subject.startsWith("_:"))
.map((entry) => entry.subject),
);
const quads = [
...quadsOf(dataset).filter((quad) => !before.has(keyOf(quad.subject))),
...made
.filter(({ entry }) => entry.subject.startsWith("_:"))
.flatMap(({ entry }) => entryQuads(entry)),
];
return withQuads(dataset, quads);
}
/**
* The series a release is in, described in it when the draft says it
* starts it there (its IRI the release's own, `<#series>`) or describes
* it already: the release its last and current version, one of its
* versions, its title, description, publisher, themes and keywords the
* release's (docs/deck-library.md, A release outside the library). A
* series elsewhere, which an index describes, is left to it.
*/
function withSeries(quads: readonly Quad[], target: string): Quad[] {
const of = (subject: string, predicate: string) =>
quads.filter((quad) => quad.subject.value === subject && quad.predicate.value === predicate);
const [series] = of(target, DCAT("inSeries")).map((quad) => quad.object.value);
if (series === undefined) return [...quads];
const described = quads.some((quad) => quad.subject.value === series);
if (!described && !series.startsWith(`${target}#`)) return [...quads];
const replaced = new Set([
DCAT("last"),
DCAT("hasCurrentVersion"),
DCTERMS("title"),
DCTERMS("description"),
DCTERMS("publisher"),
DCAT("theme"),
DCAT("keyword"),
]);
const link = (predicate: string, value: string) =>
quadOf(series, predicate, { kind: "iri", value });
const copied = [
DCTERMS("title"),
DCTERMS("description"),
DCTERMS("publisher"),
DCAT("theme"),
DCAT("keyword"),
].flatMap((predicate) =>
of(target, predicate).map((quad) => quadOf(series, predicate, termOf(quad.object))),
);
const fresh = described
? []
: [
link(RDF_TYPE, DCAT("DatasetSeries")),
link(RDF_TYPE, DCAT("Dataset")),
quadOf(series, SM.formatVersion, {
kind: "literal",
value: String(LATEST_VERSION.libraryDeckSeries),
language: "",
datatype: "http://www.w3.org/2001/XMLSchema#integer",
}),
link(DCAT("first"), target),
];
const versions = of(series, DCAT("hasVersion")).some((quad) => quad.object.value === target)
? []
: [link(DCAT("hasVersion"), target)];
return [
...quads.filter(
(quad) => !(quad.subject.value === series && replaced.has(quad.predicate.value)),
),
...fresh,
...copied,
link(DCAT("last"), target),
link(DCAT("hasCurrentVersion"), target),
...versions,
];
}