@hraness/kb 0.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DISCLOSURE +43 -0
- package/LICENSE +21 -0
- package/README.md +545 -0
- package/dist/agent-context.js +35 -0
- package/dist/agent-guide-audit.js +18 -0
- package/dist/attachments.js +19 -0
- package/dist/authoring.js +33 -0
- package/dist/benchmark.js +19 -0
- package/dist/capture.js +49 -0
- package/dist/cli.js +3646 -0
- package/dist/clip/acquire.js +57 -0
- package/dist/clip/args.js +21 -0
- package/dist/clip/bounded-byte-buffer.js +9 -0
- package/dist/clip/browser-profiles.js +17 -0
- package/dist/clip/bundle-reader.js +11 -0
- package/dist/clip/cli.js +27 -0
- package/dist/clip/cookies.js +21 -0
- package/dist/clip/defuddle-worker.js +84 -0
- package/dist/clip/doctor.js +32 -0
- package/dist/clip/jobs.js +784 -0
- package/dist/clip/network-proxy.js +9 -0
- package/dist/clip/network.js +28 -0
- package/dist/clip/persist.js +30 -0
- package/dist/clip/refresh.js +11 -0
- package/dist/clip/terminal.js +11 -0
- package/dist/evaluation-builder.js +12238 -0
- package/dist/evaluation-kb.js +26 -0
- package/dist/evaluation.js +35 -0
- package/dist/git.js +39 -0
- package/dist/graph.js +39 -0
- package/dist/index-01jj6rbv.js +1103 -0
- package/dist/index-06c9ctr6.js +738 -0
- package/dist/index-0kavxzqj.js +2480 -0
- package/dist/index-1gwbassd.js +743 -0
- package/dist/index-1n418kb9.js +827 -0
- package/dist/index-1vrd1rmn.js +56 -0
- package/dist/index-1xxnjn0d.js +171 -0
- package/dist/index-3rm7cz6h.js +373 -0
- package/dist/index-3v2z4f0q.js +328 -0
- package/dist/index-48pz4jpc.js +392 -0
- package/dist/index-4j3tt0c3.js +234 -0
- package/dist/index-5n05se68.js +307 -0
- package/dist/index-5vwpzb5a.js +764 -0
- package/dist/index-6g2pv9d2.js +370 -0
- package/dist/index-7fzc32gf.js +712 -0
- package/dist/index-84x0vjjp.js +432 -0
- package/dist/index-adx6khj5.js +488 -0
- package/dist/index-b88v3vtm.js +805 -0
- package/dist/index-cv6fh7z5.js +462 -0
- package/dist/index-cxfrakt7.js +1067 -0
- package/dist/index-d13v9ckt.js +150 -0
- package/dist/index-dycjbhw2.js +1943 -0
- package/dist/index-dyqwejk5.js +531 -0
- package/dist/index-e5fbsywq.js +658 -0
- package/dist/index-f984hw45.js +927 -0
- package/dist/index-gh719d91.js +63 -0
- package/dist/index-hgve9rh2.js +2046 -0
- package/dist/index-hya40gb2.js +474 -0
- package/dist/index-j4zgmzjr.js +152 -0
- package/dist/index-jsmvyyvf.js +1166 -0
- package/dist/index-kh0pwvdd.js +1974 -0
- package/dist/index-mqx4nd6v.js +119 -0
- package/dist/index-mxxxytys.js +1254 -0
- package/dist/index-n5dd7r0v.js +711 -0
- package/dist/index-npg9z1a4.js +431 -0
- package/dist/index-pj501bh1.js +140 -0
- package/dist/index-qry4vhxk.js +1041 -0
- package/dist/index-s2gw5aw9.js +199 -0
- package/dist/index-vvdj6kca.js +70 -0
- package/dist/index-w2zc0vwa.js +571 -0
- package/dist/index-x3fthpsc.js +585 -0
- package/dist/index-zr53sf63.js +44 -0
- package/dist/index-zxdy5pby.js +2728 -0
- package/dist/index-zzhgcwyt.js +966 -0
- package/dist/index.js +453 -0
- package/dist/navigation.js +13 -0
- package/dist/pdf.js +85 -0
- package/dist/percolate.js +24 -0
- package/dist/portfolio.js +77 -0
- package/dist/query.js +29 -0
- package/dist/repository-memory.js +60 -0
- package/dist/sdk.js +36 -0
- package/dist/search-rules.js +34 -0
- package/dist/search.js +22 -0
- package/dist/semantic.js +39 -0
- package/dist/source-inbox.js +17 -0
- package/dist/untrusted-content.js +32 -0
- package/dist/url-intelligence.js +74 -0
- package/dist/workflow.js +23 -0
- package/dist/workflows/decision-context.js +20 -0
- package/dist/workflows/explain-change.js +8 -0
- package/dist/workflows/index.js +28 -0
- package/dist/workflows/plan-radar.js +10 -0
- package/package.json +409 -0
- package/skills/kb/AGENTS.md +19 -0
- package/skills/kb/SKILL.md +82 -0
- package/skills/kb/agents/openai.yaml +4 -0
- package/skills/kb/references/pdf-review.md +51 -0
- package/skills/kb/references/percolate.md +144 -0
- package/skills/kb/references/plan-structure.md +105 -0
- package/skills/kb/references/plan.md +115 -0
- package/skills/kb/references/query.md +231 -0
- package/skills/kb/references/refresh.md +133 -0
- package/skills/kb/references/save-pdf.md +125 -0
- package/skills/kb/references/save-url.md +178 -0
- package/skills/kb/references/url-authentication.md +75 -0
- package/skills/kb/references/url-platforms.md +25 -0
- package/src/agent-context.ts +1241 -0
- package/src/agent-guide-audit.ts +750 -0
- package/src/attachments.ts +723 -0
- package/src/authoring.ts +1725 -0
- package/src/benchmark.ts +362 -0
- package/src/capture.ts +68 -0
- package/src/cli.ts +3660 -0
- package/src/clip/acquire.ts +1273 -0
- package/src/clip/archive-today.ts +402 -0
- package/src/clip/args.ts +423 -0
- package/src/clip/assets.ts +262 -0
- package/src/clip/bounded-byte-buffer.ts +75 -0
- package/src/clip/browser-profiles.ts +372 -0
- package/src/clip/bundle-reader.ts +608 -0
- package/src/clip/capture.ts +1054 -0
- package/src/clip/cli.ts +181 -0
- package/src/clip/cookies.ts +512 -0
- package/src/clip/doctor.ts +1110 -0
- package/src/clip/extract.ts +868 -0
- package/src/clip/jobs.ts +959 -0
- package/src/clip/lib.ts +861 -0
- package/src/clip/media.ts +1474 -0
- package/src/clip/metadata-search-tool/Cargo.lock +2498 -0
- package/src/clip/metadata-search-tool/Cargo.toml +18 -0
- package/src/clip/metadata-search-tool/runner.ts +685 -0
- package/src/clip/metadata-search-tool/src/main.rs +389 -0
- package/src/clip/metadata-search.ts +687 -0
- package/src/clip/network-proxy.ts +627 -0
- package/src/clip/network.ts +950 -0
- package/src/clip/package-root.ts +54 -0
- package/src/clip/persist.ts +1472 -0
- package/src/clip/platforms.ts +1824 -0
- package/src/clip/refresh.ts +197 -0
- package/src/clip/structured.ts +432 -0
- package/src/clip/terminal.ts +197 -0
- package/src/clip/url-intelligence.ts +581 -0
- package/src/clip/url-metadata-backfill.ts +616 -0
- package/src/clip/url-metadata-cli.ts +251 -0
- package/src/clip/url-metadata.ts +1397 -0
- package/src/evaluation-analysis-v2.ts +1440 -0
- package/src/evaluation-builder.ts +2539 -0
- package/src/evaluation-corpus-authoring.ts +2052 -0
- package/src/evaluation-evidence.ts +1147 -0
- package/src/evaluation-execution-v2.ts +426 -0
- package/src/evaluation-implementation.ts +131 -0
- package/src/evaluation-kb-closure.ts +1430 -0
- package/src/evaluation-kb-runner-v2.ts +735 -0
- package/src/evaluation-kb-v2.ts +2567 -0
- package/src/evaluation-kb.ts +1018 -0
- package/src/evaluation-measurement-v2.ts +1462 -0
- package/src/evaluation-packing-v2.ts +452 -0
- package/src/evaluation-redaction.ts +28 -0
- package/src/evaluation-v2.ts +3857 -0
- package/src/evaluation.ts +1220 -0
- package/src/git.ts +1276 -0
- package/src/graph.ts +1487 -0
- package/src/index.ts +20 -0
- package/src/init.ts +123 -0
- package/src/navigation.ts +277 -0
- package/src/note-lock.ts +529 -0
- package/src/pdf/args.ts +202 -0
- package/src/pdf/capture.ts +298 -0
- package/src/pdf/cli.ts +159 -0
- package/src/pdf/extract.ts +303 -0
- package/src/pdf/layout.ts +618 -0
- package/src/pdf/markdown.ts +154 -0
- package/src/pdf/model.ts +234 -0
- package/src/pdf/ocr.ts +190 -0
- package/src/pdf/persist.ts +314 -0
- package/src/pdf/source.ts +135 -0
- package/src/pdf/tools.ts +146 -0
- package/src/pdf.ts +92 -0
- package/src/percolate.ts +877 -0
- package/src/portfolio-audit.ts +574 -0
- package/src/portfolio-identity.ts +162 -0
- package/src/portfolio-registry.ts +473 -0
- package/src/portfolio.ts +765 -0
- package/src/query.ts +646 -0
- package/src/repository-memory.ts +1150 -0
- package/src/sdk.ts +1452 -0
- package/src/search-rules.ts +712 -0
- package/src/search.ts +710 -0
- package/src/semantic-runtime.ts +1139 -0
- package/src/semantic.ts +2480 -0
- package/src/source-inbox.ts +232 -0
- package/src/untrusted-content.ts +401 -0
- package/src/url-intelligence.ts +104 -0
- package/src/vault.ts +636 -0
- package/src/workflow.ts +653 -0
- package/src/workflows/decision-context.ts +81 -0
- package/src/workflows/explain-change.ts +73 -0
- package/src/workflows/index.ts +3 -0
- package/src/workflows/plan-radar.ts +101 -0
|
@@ -0,0 +1,1103 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
import {
|
|
3
|
+
acquireNoteLock
|
|
4
|
+
} from "./index-3rm7cz6h.js";
|
|
5
|
+
import {
|
|
6
|
+
isCanonicalNoteId,
|
|
7
|
+
parseDocumentId,
|
|
8
|
+
parseQualifiedDocumentUri
|
|
9
|
+
} from "./index-cxfrakt7.js";
|
|
10
|
+
|
|
11
|
+
// src/authoring.ts
|
|
12
|
+
import { createHash, randomUUID } from "crypto";
|
|
13
|
+
import { constants } from "fs";
|
|
14
|
+
import {
|
|
15
|
+
link,
|
|
16
|
+
lstat,
|
|
17
|
+
mkdir,
|
|
18
|
+
open,
|
|
19
|
+
readdir,
|
|
20
|
+
realpath,
|
|
21
|
+
rename,
|
|
22
|
+
rmdir,
|
|
23
|
+
unlink
|
|
24
|
+
} from "fs/promises";
|
|
25
|
+
import {
|
|
26
|
+
basename,
|
|
27
|
+
dirname,
|
|
28
|
+
join,
|
|
29
|
+
posix,
|
|
30
|
+
relative,
|
|
31
|
+
resolve,
|
|
32
|
+
sep
|
|
33
|
+
} from "path";
|
|
34
|
+
import {
|
|
35
|
+
Document,
|
|
36
|
+
isMap,
|
|
37
|
+
isScalar,
|
|
38
|
+
isSeq,
|
|
39
|
+
parseDocument
|
|
40
|
+
} from "yaml";
|
|
41
|
+
var MAX_NOTE_BYTES = 16 * 1024 * 1024;
|
|
42
|
+
var NOTE_REVISION_PATTERN = /^sha256:[0-9a-f]{64}$/u;
|
|
43
|
+
var PREDICATE_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u;
|
|
44
|
+
var MAX_PARENT_DIRECTORY_ENTRIES = 1e5;
|
|
45
|
+
var MAX_RECOVERY_LOCATIONS_PER_NOTE = 8;
|
|
46
|
+
|
|
47
|
+
class InvalidCanonicalNoteIdError extends TypeError {
|
|
48
|
+
noteId;
|
|
49
|
+
constructor(noteId) {
|
|
50
|
+
super(`not an exact canonical note ID: ${JSON.stringify(noteId)}`);
|
|
51
|
+
this.name = "InvalidCanonicalNoteIdError";
|
|
52
|
+
this.noteId = noteId;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
class NoteRevisionConflictError extends Error {
|
|
57
|
+
path;
|
|
58
|
+
expected;
|
|
59
|
+
actual;
|
|
60
|
+
recoveryPath;
|
|
61
|
+
constructor(path, expected, actual, recoveryPath = null) {
|
|
62
|
+
super(recoveryPath === null ? "the note changed during authoring; retry from its current revision" : `the note changed during authoring; displaced bytes remain at ${recoveryPath}`);
|
|
63
|
+
this.name = "NoteRevisionConflictError";
|
|
64
|
+
this.path = path;
|
|
65
|
+
this.expected = expected;
|
|
66
|
+
this.actual = actual;
|
|
67
|
+
this.recoveryPath = recoveryPath;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
class NoteAlreadyExistsError extends Error {
|
|
72
|
+
path;
|
|
73
|
+
constructor(path, reason) {
|
|
74
|
+
super(`the existing note is incompatible with this create request: ${reason}`);
|
|
75
|
+
this.name = "NoteAlreadyExistsError";
|
|
76
|
+
this.path = path;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
class NoteRecoveryRequiredError extends Error {
|
|
81
|
+
path;
|
|
82
|
+
recoveryPath;
|
|
83
|
+
constructor(path, recoveryPath, cause) {
|
|
84
|
+
super(`authoring stopped; displaced bytes remain at ${recoveryPath}`, { cause });
|
|
85
|
+
this.name = "NoteRecoveryRequiredError";
|
|
86
|
+
this.path = path;
|
|
87
|
+
this.recoveryPath = recoveryPath;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function isErrno(error, code) {
|
|
91
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
92
|
+
}
|
|
93
|
+
function sha256(value) {
|
|
94
|
+
return createHash("sha256").update(value).digest("hex");
|
|
95
|
+
}
|
|
96
|
+
function revisionFor(bytes) {
|
|
97
|
+
return `sha256:${sha256(bytes)}`;
|
|
98
|
+
}
|
|
99
|
+
function inside(root, candidate) {
|
|
100
|
+
const fromRoot = relative(root, candidate);
|
|
101
|
+
return fromRoot !== "" && fromRoot !== ".." && !fromRoot.startsWith(`..${sep}`);
|
|
102
|
+
}
|
|
103
|
+
function canonicalNoteId(value) {
|
|
104
|
+
if (!isCanonicalNoteId(value)) {
|
|
105
|
+
throw new InvalidCanonicalNoteIdError(value);
|
|
106
|
+
}
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
function canonicalRelationTarget(value) {
|
|
110
|
+
if (value.startsWith("kb://"))
|
|
111
|
+
return parseQualifiedDocumentUri(value).uri;
|
|
112
|
+
return canonicalNoteId(value);
|
|
113
|
+
}
|
|
114
|
+
function normalizeRelationPredicate(value) {
|
|
115
|
+
const normalized = value.trim().normalize("NFC").toLocaleLowerCase("en-US").replaceAll("_", "-").replace(/\s+/gu, "-").replace(/-{2,}/gu, "-");
|
|
116
|
+
if (!PREDICATE_PATTERN.test(normalized)) {
|
|
117
|
+
throw new TypeError(`not a valid relation predicate: ${JSON.stringify(value)}`);
|
|
118
|
+
}
|
|
119
|
+
return normalized;
|
|
120
|
+
}
|
|
121
|
+
function exactPredicate(value) {
|
|
122
|
+
const normalized = normalizeRelationPredicate(value);
|
|
123
|
+
if (value !== normalized) {
|
|
124
|
+
throw new Error(`authored relation predicate is not canonical kebab-case: ${value}`);
|
|
125
|
+
}
|
|
126
|
+
return value;
|
|
127
|
+
}
|
|
128
|
+
function requireRevision(value) {
|
|
129
|
+
if (!NOTE_REVISION_PATTERN.test(value)) {
|
|
130
|
+
throw new TypeError("expectedRevision is not a KB note revision");
|
|
131
|
+
}
|
|
132
|
+
return value;
|
|
133
|
+
}
|
|
134
|
+
async function resolveVault(rootInput) {
|
|
135
|
+
const root = await realpath(resolve(rootInput));
|
|
136
|
+
const metadata = await lstat(root);
|
|
137
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
138
|
+
throw new Error("the vault root must be a real directory");
|
|
139
|
+
}
|
|
140
|
+
if (dirname(root) === root) {
|
|
141
|
+
throw new Error("refusing to author notes in a filesystem root");
|
|
142
|
+
}
|
|
143
|
+
return { root };
|
|
144
|
+
}
|
|
145
|
+
function pathFor(vault, id) {
|
|
146
|
+
const canonicalId = canonicalNoteId(id);
|
|
147
|
+
const relativePath = `${canonicalId}.md`;
|
|
148
|
+
const path = resolve(vault.root, ...relativePath.split("/"));
|
|
149
|
+
if (!inside(vault.root, path)) {
|
|
150
|
+
throw new InvalidCanonicalNoteIdError(id);
|
|
151
|
+
}
|
|
152
|
+
return { path, relativePath };
|
|
153
|
+
}
|
|
154
|
+
async function assertExactDirectoryEntry(directory, name) {
|
|
155
|
+
const entries = await readdir(directory);
|
|
156
|
+
if (!entries.includes(name)) {
|
|
157
|
+
const error = new Error(`vault path component is not exact: ${name}`);
|
|
158
|
+
error.code = "ENOENT";
|
|
159
|
+
throw error;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
async function assertSafeParent(vault, path) {
|
|
163
|
+
if (!inside(vault.root, path)) {
|
|
164
|
+
throw new Error("the note path must remain inside the vault");
|
|
165
|
+
}
|
|
166
|
+
const parent = dirname(path);
|
|
167
|
+
const segments = relative(vault.root, parent).split(sep).filter(Boolean);
|
|
168
|
+
let current = vault.root;
|
|
169
|
+
for (const segment of segments) {
|
|
170
|
+
await assertExactDirectoryEntry(current, segment);
|
|
171
|
+
current = join(current, segment);
|
|
172
|
+
const metadata = await lstat(current);
|
|
173
|
+
if (metadata.isSymbolicLink()) {
|
|
174
|
+
throw new Error("the note path must not traverse a symbolic link");
|
|
175
|
+
}
|
|
176
|
+
if (!metadata.isDirectory()) {
|
|
177
|
+
throw new Error("every note parent must be a directory");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
const canonicalParent = await realpath(parent);
|
|
181
|
+
if (canonicalParent !== parent || !inside(vault.root, join(canonicalParent, basename(path)))) {
|
|
182
|
+
throw new Error("the note parent resolves outside the vault");
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
async function readSnapshotAtPath(vault, path, relativePath) {
|
|
186
|
+
await assertSafeParent(vault, path);
|
|
187
|
+
await assertExactDirectoryEntry(dirname(path), basename(path));
|
|
188
|
+
const beforeOpen = await lstat(path, { bigint: true });
|
|
189
|
+
if (!beforeOpen.isFile() || beforeOpen.isSymbolicLink()) {
|
|
190
|
+
throw new Error("the note target must be a regular file");
|
|
191
|
+
}
|
|
192
|
+
if (beforeOpen.nlink !== 1n) {
|
|
193
|
+
throw new Error("the note target must not be hard-linked");
|
|
194
|
+
}
|
|
195
|
+
if (beforeOpen.size > BigInt(MAX_NOTE_BYTES)) {
|
|
196
|
+
throw new Error("the note is too large for bounded authoring");
|
|
197
|
+
}
|
|
198
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
199
|
+
try {
|
|
200
|
+
const opened = await handle.stat({ bigint: true });
|
|
201
|
+
if (!opened.isFile() || opened.nlink !== 1n || opened.dev !== beforeOpen.dev || opened.ino !== beforeOpen.ino || opened.size !== beforeOpen.size || opened.size > BigInt(MAX_NOTE_BYTES)) {
|
|
202
|
+
throw new Error("the note target changed while it was opened");
|
|
203
|
+
}
|
|
204
|
+
const bytes = new Uint8Array(Number(opened.size));
|
|
205
|
+
let offset = 0;
|
|
206
|
+
while (offset < bytes.byteLength) {
|
|
207
|
+
const result = await handle.read(bytes, offset, bytes.byteLength - offset, offset);
|
|
208
|
+
if (result.bytesRead === 0) {
|
|
209
|
+
throw new Error("the note target changed while it was read");
|
|
210
|
+
}
|
|
211
|
+
offset += result.bytesRead;
|
|
212
|
+
}
|
|
213
|
+
const overflow = new Uint8Array(1);
|
|
214
|
+
if ((await handle.read(overflow, 0, 1, Number(opened.size))).bytesRead !== 0) {
|
|
215
|
+
throw new Error("the note target grew while it was read");
|
|
216
|
+
}
|
|
217
|
+
const finished = await handle.stat({ bigint: true });
|
|
218
|
+
const finalPath = await lstat(path, { bigint: true });
|
|
219
|
+
if (!finalPath.isFile() || finalPath.isSymbolicLink() || finalPath.nlink !== 1n || finalPath.dev !== opened.dev || finalPath.ino !== opened.ino || finalPath.size !== opened.size || finished.size !== opened.size || finished.mtimeNs !== opened.mtimeNs || finished.ctimeNs !== opened.ctimeNs) {
|
|
220
|
+
throw new Error("the note target changed while it was read");
|
|
221
|
+
}
|
|
222
|
+
const canonicalPath = await realpath(path);
|
|
223
|
+
if (canonicalPath !== path || !inside(vault.root, canonicalPath)) {
|
|
224
|
+
throw new Error("the note target resolves outside the vault");
|
|
225
|
+
}
|
|
226
|
+
let content;
|
|
227
|
+
try {
|
|
228
|
+
content = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
229
|
+
} catch (error) {
|
|
230
|
+
throw new Error("the note target is not valid UTF-8", { cause: error });
|
|
231
|
+
}
|
|
232
|
+
return {
|
|
233
|
+
path,
|
|
234
|
+
relativePath,
|
|
235
|
+
content,
|
|
236
|
+
revision: revisionFor(bytes),
|
|
237
|
+
device: opened.dev,
|
|
238
|
+
inode: opened.ino,
|
|
239
|
+
size: opened.size,
|
|
240
|
+
modifiedAtNs: opened.mtimeNs,
|
|
241
|
+
changedAtNs: opened.ctimeNs,
|
|
242
|
+
mode: Number(opened.mode & 0o777n)
|
|
243
|
+
};
|
|
244
|
+
} finally {
|
|
245
|
+
await handle.close();
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
async function readSnapshot(vault, id) {
|
|
249
|
+
const { path, relativePath } = pathFor(vault, id);
|
|
250
|
+
return readSnapshotAtPath(vault, path, relativePath);
|
|
251
|
+
}
|
|
252
|
+
async function readOptionalSnapshot(vault, id) {
|
|
253
|
+
try {
|
|
254
|
+
return await readSnapshot(vault, id);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
if (isErrno(error, "ENOENT"))
|
|
257
|
+
return null;
|
|
258
|
+
throw error;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function sameSnapshot(left, right) {
|
|
262
|
+
return left.device === right.device && left.inode === right.inode && left.size === right.size && left.modifiedAtNs === right.modifiedAtNs && left.changedAtNs === right.changedAtNs && left.mode === right.mode && left.revision === right.revision;
|
|
263
|
+
}
|
|
264
|
+
function frontmatter(content, relativePath) {
|
|
265
|
+
const firstLineEnd = content.indexOf(`
|
|
266
|
+
`);
|
|
267
|
+
const openingEnd = firstLineEnd === -1 ? content.length : firstLineEnd;
|
|
268
|
+
const openingContentEnd = content[openingEnd - 1] === "\r" ? openingEnd - 1 : openingEnd;
|
|
269
|
+
const opening = content.slice(0, openingContentEnd);
|
|
270
|
+
if (opening.trim() !== "---") {
|
|
271
|
+
return {
|
|
272
|
+
document: parseFrontmatterDocument("", relativePath),
|
|
273
|
+
hadFrontmatter: false,
|
|
274
|
+
openingDelimiter: "---",
|
|
275
|
+
closingDelimiter: "---",
|
|
276
|
+
newline: content.includes(`\r
|
|
277
|
+
`) ? `\r
|
|
278
|
+
` : `
|
|
279
|
+
`,
|
|
280
|
+
bodySuffix: content
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (firstLineEnd === -1) {
|
|
284
|
+
throw new Error(`invalid YAML frontmatter in ${relativePath}: missing closing delimiter`);
|
|
285
|
+
}
|
|
286
|
+
const newline = content[firstLineEnd - 1] === "\r" ? `\r
|
|
287
|
+
` : `
|
|
288
|
+
`;
|
|
289
|
+
let cursor = firstLineEnd + 1;
|
|
290
|
+
for (;; ) {
|
|
291
|
+
const nextNewline = content.indexOf(`
|
|
292
|
+
`, cursor);
|
|
293
|
+
const lineEnd = nextNewline === -1 ? content.length : nextNewline;
|
|
294
|
+
const lineContentEnd = content[lineEnd - 1] === "\r" ? lineEnd - 1 : lineEnd;
|
|
295
|
+
const line = content.slice(cursor, lineContentEnd);
|
|
296
|
+
if (line.trim() === "---") {
|
|
297
|
+
const yamlSource = content.slice(firstLineEnd + 1, cursor);
|
|
298
|
+
return {
|
|
299
|
+
document: parseFrontmatterDocument(yamlSource, relativePath),
|
|
300
|
+
hadFrontmatter: true,
|
|
301
|
+
openingDelimiter: content.slice(0, openingContentEnd),
|
|
302
|
+
closingDelimiter: content.slice(cursor, lineContentEnd),
|
|
303
|
+
newline,
|
|
304
|
+
bodySuffix: content.slice(lineContentEnd)
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
if (nextNewline === -1)
|
|
308
|
+
break;
|
|
309
|
+
cursor = nextNewline + 1;
|
|
310
|
+
}
|
|
311
|
+
throw new Error(`invalid YAML frontmatter in ${relativePath}: missing closing delimiter`);
|
|
312
|
+
}
|
|
313
|
+
function parseFrontmatterDocument(source, relativePath) {
|
|
314
|
+
const document = parseDocument(source, {
|
|
315
|
+
keepSourceTokens: true,
|
|
316
|
+
schema: "core",
|
|
317
|
+
uniqueKeys: true
|
|
318
|
+
});
|
|
319
|
+
if (document.errors.length > 0) {
|
|
320
|
+
throw new Error(`invalid YAML frontmatter in ${relativePath}`);
|
|
321
|
+
}
|
|
322
|
+
if (document.contents !== null && !isMap(document.contents)) {
|
|
323
|
+
throw new Error(`invalid YAML frontmatter in ${relativePath}: expected a mapping`);
|
|
324
|
+
}
|
|
325
|
+
if (isMap(document.contents)) {
|
|
326
|
+
const seen = new Set;
|
|
327
|
+
for (const pair of document.contents.items) {
|
|
328
|
+
if (!isScalar(pair.key) || typeof pair.key.value !== "string") {
|
|
329
|
+
throw new Error(`invalid YAML frontmatter in ${relativePath}: keys must be strings`);
|
|
330
|
+
}
|
|
331
|
+
const folded = pair.key.value.toLocaleLowerCase("en-US");
|
|
332
|
+
if (seen.has(folded)) {
|
|
333
|
+
throw new Error(`invalid YAML frontmatter in ${relativePath}: keys must not differ only by case`);
|
|
334
|
+
}
|
|
335
|
+
seen.add(folded);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return document;
|
|
339
|
+
}
|
|
340
|
+
function relationNodes(parts, relativePath, create) {
|
|
341
|
+
const { document } = parts;
|
|
342
|
+
if (document.contents === null) {
|
|
343
|
+
if (!create) {
|
|
344
|
+
const detached = document.createNode({});
|
|
345
|
+
if (!isMap(detached))
|
|
346
|
+
throw new Error("YAML did not create a mapping");
|
|
347
|
+
return { root: detached, relations: null };
|
|
348
|
+
}
|
|
349
|
+
document.contents = document.createNode({});
|
|
350
|
+
}
|
|
351
|
+
if (!isMap(document.contents)) {
|
|
352
|
+
throw new Error(`invalid YAML frontmatter in ${relativePath}: expected a mapping`);
|
|
353
|
+
}
|
|
354
|
+
const root = document.contents;
|
|
355
|
+
const relationPair = root.items.find((pair) => isScalar(pair.key) && typeof pair.key.value === "string" && pair.key.value.toLocaleLowerCase("en-US") === "relations");
|
|
356
|
+
const existing = relationPair?.value;
|
|
357
|
+
if (existing === undefined) {
|
|
358
|
+
if (!create)
|
|
359
|
+
return { root, relations: null };
|
|
360
|
+
const created = document.createNode({});
|
|
361
|
+
if (!isMap(created))
|
|
362
|
+
throw new Error("YAML did not create a relation mapping");
|
|
363
|
+
root.set("relations", created);
|
|
364
|
+
return { root, relations: created };
|
|
365
|
+
}
|
|
366
|
+
if (!isMap(existing)) {
|
|
367
|
+
throw new Error(`invalid relations in ${relativePath}: expected a mapping`);
|
|
368
|
+
}
|
|
369
|
+
return { root, relations: existing };
|
|
370
|
+
}
|
|
371
|
+
function scalarString(value) {
|
|
372
|
+
return isScalar(value) && typeof value.value === "string" ? value.value : null;
|
|
373
|
+
}
|
|
374
|
+
function relationsFromParts(parts, relativePath) {
|
|
375
|
+
const { relations } = relationNodes(parts, relativePath, false);
|
|
376
|
+
if (relations === null)
|
|
377
|
+
return [];
|
|
378
|
+
const output = [];
|
|
379
|
+
const seen = new Set;
|
|
380
|
+
for (const pair of relations.items) {
|
|
381
|
+
const predicateValue = scalarString(pair.key);
|
|
382
|
+
if (predicateValue === null) {
|
|
383
|
+
throw new Error(`invalid relations in ${relativePath}: predicates must be strings`);
|
|
384
|
+
}
|
|
385
|
+
const predicate = exactPredicate(predicateValue);
|
|
386
|
+
const scalarTarget = scalarString(pair.value);
|
|
387
|
+
if (scalarTarget !== null) {
|
|
388
|
+
const target = canonicalRelationTarget(scalarTarget);
|
|
389
|
+
const key = `${predicate}\x00${target}`;
|
|
390
|
+
if (!seen.has(key)) {
|
|
391
|
+
seen.add(key);
|
|
392
|
+
output.push({ predicate, target });
|
|
393
|
+
}
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
if (!isSeq(pair.value)) {
|
|
397
|
+
throw new Error(`invalid relations in ${relativePath}: ${predicate} targets must be a string or array`);
|
|
398
|
+
}
|
|
399
|
+
for (const item of pair.value.items) {
|
|
400
|
+
const targetValue = scalarString(item);
|
|
401
|
+
if (targetValue === null) {
|
|
402
|
+
throw new Error(`invalid relations in ${relativePath}: ${predicate} targets must be strings`);
|
|
403
|
+
}
|
|
404
|
+
const target = canonicalRelationTarget(targetValue);
|
|
405
|
+
const key = `${predicate}\x00${target}`;
|
|
406
|
+
if (seen.has(key))
|
|
407
|
+
continue;
|
|
408
|
+
seen.add(key);
|
|
409
|
+
output.push({ predicate, target });
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return output.toSorted((left, right) => left.predicate.localeCompare(right.predicate) || left.target.localeCompare(right.target));
|
|
413
|
+
}
|
|
414
|
+
function relationValue(relations, predicate, relativePath) {
|
|
415
|
+
const value = relations.get(predicate, true);
|
|
416
|
+
if (value === undefined)
|
|
417
|
+
return null;
|
|
418
|
+
const scalarTarget = scalarString(value);
|
|
419
|
+
if (scalarTarget !== null) {
|
|
420
|
+
return { kind: "scalar", target: canonicalRelationTarget(scalarTarget) };
|
|
421
|
+
}
|
|
422
|
+
if (!isSeq(value)) {
|
|
423
|
+
throw new Error(`invalid relations in ${relativePath}: ${predicate} targets must be a string or array`);
|
|
424
|
+
}
|
|
425
|
+
for (const item of value.items) {
|
|
426
|
+
if (scalarString(item) === null) {
|
|
427
|
+
throw new Error(`invalid relations in ${relativePath}: ${predicate} targets must be strings`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
return { kind: "sequence", sequence: value };
|
|
431
|
+
}
|
|
432
|
+
function renderFrontmatter(parts) {
|
|
433
|
+
let yaml = parts.document.toString({ lineWidth: 0 });
|
|
434
|
+
if (parts.newline === `\r
|
|
435
|
+
`)
|
|
436
|
+
yaml = yaml.replaceAll(`
|
|
437
|
+
`, `\r
|
|
438
|
+
`);
|
|
439
|
+
if (!yaml.endsWith(parts.newline))
|
|
440
|
+
yaml += parts.newline;
|
|
441
|
+
if (parts.hadFrontmatter) {
|
|
442
|
+
return parts.openingDelimiter + parts.newline + yaml + parts.closingDelimiter + parts.bodySuffix;
|
|
443
|
+
}
|
|
444
|
+
return parts.openingDelimiter + parts.newline + yaml + parts.closingDelimiter + parts.newline + parts.bodySuffix;
|
|
445
|
+
}
|
|
446
|
+
function compareScalarNodes(left, right) {
|
|
447
|
+
return (scalarString(left) ?? "").localeCompare(scalarString(right) ?? "");
|
|
448
|
+
}
|
|
449
|
+
function addRelationToParts(parts, relativePath, predicate, target) {
|
|
450
|
+
const { relations } = relationNodes(parts, relativePath, true);
|
|
451
|
+
if (relations === null)
|
|
452
|
+
throw new Error("YAML did not create relations");
|
|
453
|
+
const existing = relationValue(relations, predicate, relativePath);
|
|
454
|
+
if (existing === null) {
|
|
455
|
+
const created = parts.document.createNode([target], { flow: true });
|
|
456
|
+
if (!isSeq(created))
|
|
457
|
+
throw new Error("YAML did not create a relation sequence");
|
|
458
|
+
relations.set(predicate, created);
|
|
459
|
+
return true;
|
|
460
|
+
}
|
|
461
|
+
if (existing.kind === "scalar") {
|
|
462
|
+
if (existing.target === target)
|
|
463
|
+
return false;
|
|
464
|
+
const created = parts.document.createNode([existing.target, target].toSorted((left, right) => left.localeCompare(right)), { flow: true });
|
|
465
|
+
if (!isSeq(created))
|
|
466
|
+
throw new Error("YAML did not create a relation sequence");
|
|
467
|
+
relations.set(predicate, created);
|
|
468
|
+
return true;
|
|
469
|
+
}
|
|
470
|
+
const sequence = existing.sequence;
|
|
471
|
+
if (sequence.items.some((item) => scalarString(item) === target))
|
|
472
|
+
return false;
|
|
473
|
+
sequence.add(parts.document.createNode(target));
|
|
474
|
+
sequence.items.sort(compareScalarNodes);
|
|
475
|
+
return true;
|
|
476
|
+
}
|
|
477
|
+
function removeRelationFromParts(parts, relativePath, predicate, target, sourceId) {
|
|
478
|
+
const { root, relations } = relationNodes(parts, relativePath, false);
|
|
479
|
+
if (relations === null)
|
|
480
|
+
return false;
|
|
481
|
+
const value = relations.get(predicate, true);
|
|
482
|
+
if (value === undefined)
|
|
483
|
+
return false;
|
|
484
|
+
const repairableTarget = (raw) => {
|
|
485
|
+
if (raw.startsWith("kb://")) {
|
|
486
|
+
try {
|
|
487
|
+
return canonicalRelationTarget(raw);
|
|
488
|
+
} catch {
|
|
489
|
+
return null;
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
let candidate = raw;
|
|
493
|
+
if (candidate.toLocaleLowerCase("en-US").endsWith(".md")) {
|
|
494
|
+
candidate = candidate.slice(0, -3);
|
|
495
|
+
}
|
|
496
|
+
if (candidate.startsWith(".")) {
|
|
497
|
+
candidate = posix.normalize(posix.join(posix.dirname(sourceId), candidate));
|
|
498
|
+
}
|
|
499
|
+
return isCanonicalNoteId(candidate) ? candidate : null;
|
|
500
|
+
};
|
|
501
|
+
const matches = (node) => {
|
|
502
|
+
const raw = scalarString(node);
|
|
503
|
+
return raw !== null && repairableTarget(raw) === target;
|
|
504
|
+
};
|
|
505
|
+
if (!isSeq(value)) {
|
|
506
|
+
if (!matches(value))
|
|
507
|
+
return false;
|
|
508
|
+
relations.delete(predicate);
|
|
509
|
+
if (relations.items.length === 0)
|
|
510
|
+
root.delete("relations");
|
|
511
|
+
return true;
|
|
512
|
+
}
|
|
513
|
+
const sequence = value;
|
|
514
|
+
const retained = sequence.items.filter((item) => !matches(item));
|
|
515
|
+
if (retained.length === sequence.items.length)
|
|
516
|
+
return false;
|
|
517
|
+
if (retained.length === 0) {
|
|
518
|
+
relations.delete(predicate);
|
|
519
|
+
if (relations.items.length === 0)
|
|
520
|
+
root.delete("relations");
|
|
521
|
+
} else {
|
|
522
|
+
sequence.items = retained;
|
|
523
|
+
}
|
|
524
|
+
return true;
|
|
525
|
+
}
|
|
526
|
+
function dependenciesFor(overrides) {
|
|
527
|
+
return {
|
|
528
|
+
documentId: overrides?.documentId ?? randomUUID,
|
|
529
|
+
token: overrides?.token ?? randomUUID,
|
|
530
|
+
...overrides?.beforeInstall === undefined ? {} : { beforeInstall: overrides.beforeInstall },
|
|
531
|
+
...overrides?.beforeCommit === undefined ? {} : { beforeCommit: overrides.beforeCommit },
|
|
532
|
+
...overrides?.afterSourceQuarantined === undefined ? {} : { afterSourceQuarantined: overrides.afterSourceQuarantined }
|
|
533
|
+
};
|
|
534
|
+
}
|
|
535
|
+
async function cleanupTemporary(temporaryPath, identity) {
|
|
536
|
+
if (identity === null)
|
|
537
|
+
return;
|
|
538
|
+
try {
|
|
539
|
+
const current = await lstat(temporaryPath, { bigint: true });
|
|
540
|
+
if (current.dev === identity.device && current.ino === identity.inode) {
|
|
541
|
+
await unlink(temporaryPath);
|
|
542
|
+
}
|
|
543
|
+
} catch (error) {
|
|
544
|
+
if (!isErrno(error, "ENOENT"))
|
|
545
|
+
throw error;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
async function fsyncDirectory(path) {
|
|
549
|
+
const handle = await open(path, constants.O_RDONLY);
|
|
550
|
+
try {
|
|
551
|
+
await handle.sync();
|
|
552
|
+
} finally {
|
|
553
|
+
await handle.close();
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
function recoveryRelativePath(vault, path) {
|
|
557
|
+
return relative(vault.root, path).split(sep).join("/");
|
|
558
|
+
}
|
|
559
|
+
async function discoveredRecoveryLocations(vault, notePath) {
|
|
560
|
+
const directory = dirname(notePath);
|
|
561
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
562
|
+
if (entries.length > MAX_PARENT_DIRECTORY_ENTRIES) {
|
|
563
|
+
throw new Error("the note parent has too many entries for bounded recovery");
|
|
564
|
+
}
|
|
565
|
+
const prefix = `.${basename(notePath)}.`;
|
|
566
|
+
const suffix = ".recovery";
|
|
567
|
+
const matching = entries.filter(({ name }) => name.startsWith(prefix) && name.endsWith(suffix)).toSorted((left, right) => left.name.localeCompare(right.name));
|
|
568
|
+
if (matching.length > MAX_RECOVERY_LOCATIONS_PER_NOTE) {
|
|
569
|
+
const firstPath = join(directory, matching[0]?.name ?? "");
|
|
570
|
+
throw new NoteRecoveryRequiredError(recoveryRelativePath(vault, notePath), recoveryRelativePath(vault, firstPath), new Error("too many interrupted authoring transactions require manual recovery"));
|
|
571
|
+
}
|
|
572
|
+
const recoverable = [];
|
|
573
|
+
const empty = [];
|
|
574
|
+
for (const entry of matching) {
|
|
575
|
+
const nonce = entry.name.slice(prefix.length, -suffix.length);
|
|
576
|
+
const recoveryDirectory = join(directory, entry.name);
|
|
577
|
+
const recoveryDirectoryRelative = recoveryRelativePath(vault, recoveryDirectory);
|
|
578
|
+
if (!/^\d+\.[0-9a-f]{32}$/u.test(nonce) || !entry.isDirectory() || entry.isSymbolicLink()) {
|
|
579
|
+
throw new NoteRecoveryRequiredError(recoveryRelativePath(vault, notePath), recoveryDirectoryRelative, new Error("an unrecognized authoring recovery artifact is present"));
|
|
580
|
+
}
|
|
581
|
+
const metadata = await lstat(recoveryDirectory, { bigint: true });
|
|
582
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink() || await realpath(recoveryDirectory) !== recoveryDirectory) {
|
|
583
|
+
throw new NoteRecoveryRequiredError(recoveryRelativePath(vault, notePath), recoveryDirectoryRelative, new Error("an authoring recovery directory changed identity"));
|
|
584
|
+
}
|
|
585
|
+
const children = await readdir(recoveryDirectory);
|
|
586
|
+
if (children.length === 0) {
|
|
587
|
+
empty.push({
|
|
588
|
+
directory: recoveryDirectory,
|
|
589
|
+
path: join(recoveryDirectory, basename(notePath)),
|
|
590
|
+
relativePath: recoveryRelativePath(vault, join(recoveryDirectory, basename(notePath))),
|
|
591
|
+
device: metadata.dev,
|
|
592
|
+
inode: metadata.ino
|
|
593
|
+
});
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
if (children.length !== 1 || children[0] !== basename(notePath)) {
|
|
597
|
+
throw new NoteRecoveryRequiredError(recoveryRelativePath(vault, notePath), recoveryDirectoryRelative, new Error("an authoring recovery directory has unexpected contents"));
|
|
598
|
+
}
|
|
599
|
+
const recoveryPath = join(recoveryDirectory, basename(notePath));
|
|
600
|
+
try {
|
|
601
|
+
await readSnapshotAtPath(vault, recoveryPath, recoveryRelativePath(vault, recoveryPath));
|
|
602
|
+
} catch (error) {
|
|
603
|
+
throw new NoteRecoveryRequiredError(recoveryRelativePath(vault, notePath), recoveryRelativePath(vault, recoveryPath), error);
|
|
604
|
+
}
|
|
605
|
+
recoverable.push({
|
|
606
|
+
directory: recoveryDirectory,
|
|
607
|
+
path: recoveryPath,
|
|
608
|
+
relativePath: recoveryRelativePath(vault, recoveryPath),
|
|
609
|
+
device: metadata.dev,
|
|
610
|
+
inode: metadata.ino
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
return { recoverable, empty };
|
|
614
|
+
}
|
|
615
|
+
async function directoryIdentity(vault, notePath) {
|
|
616
|
+
await assertSafeParent(vault, notePath);
|
|
617
|
+
const metadata = await lstat(dirname(notePath), { bigint: true });
|
|
618
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
619
|
+
throw new Error("the note parent must remain a real directory");
|
|
620
|
+
}
|
|
621
|
+
return { device: metadata.dev, inode: metadata.ino };
|
|
622
|
+
}
|
|
623
|
+
async function assertSameDirectory(vault, notePath, expected) {
|
|
624
|
+
const current = await directoryIdentity(vault, notePath);
|
|
625
|
+
if (current.device !== expected.device || current.inode !== expected.inode) {
|
|
626
|
+
throw new Error("the note parent changed during authoring");
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
async function createRecoveryLocation(vault, path, dependencies) {
|
|
630
|
+
const directory = dirname(path);
|
|
631
|
+
const recoveryDirectory = join(directory, `.${basename(path)}.${process.pid}.${sha256(dependencies.token()).slice(0, 32)}.recovery`);
|
|
632
|
+
await mkdir(recoveryDirectory, { mode: 448 });
|
|
633
|
+
const metadata = await lstat(recoveryDirectory, { bigint: true });
|
|
634
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink()) {
|
|
635
|
+
throw new Error("the recovery location is not a private directory");
|
|
636
|
+
}
|
|
637
|
+
await fsyncDirectory(directory);
|
|
638
|
+
const recoveryPath = join(recoveryDirectory, basename(path));
|
|
639
|
+
return {
|
|
640
|
+
directory: recoveryDirectory,
|
|
641
|
+
path: recoveryPath,
|
|
642
|
+
relativePath: relative(vault.root, recoveryPath).split(sep).join("/"),
|
|
643
|
+
device: metadata.dev,
|
|
644
|
+
inode: metadata.ino
|
|
645
|
+
};
|
|
646
|
+
}
|
|
647
|
+
async function assertRecoveryLocation(recovery) {
|
|
648
|
+
const metadata = await lstat(recovery.directory, { bigint: true });
|
|
649
|
+
if (!metadata.isDirectory() || metadata.isSymbolicLink() || metadata.dev !== recovery.device || metadata.ino !== recovery.inode || await realpath(recovery.directory) !== recovery.directory) {
|
|
650
|
+
throw new Error("the recovery location changed during authoring");
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
async function removeRecoveryDirectory(recovery, parentDirectory) {
|
|
654
|
+
await rmdir(recovery.directory);
|
|
655
|
+
await fsyncDirectory(parentDirectory);
|
|
656
|
+
}
|
|
657
|
+
function sameQuarantinedSnapshot(quarantined, expected) {
|
|
658
|
+
return quarantined.device === expected.device && quarantined.inode === expected.inode && quarantined.size === expected.size && quarantined.modifiedAtNs === expected.modifiedAtNs && quarantined.mode === expected.mode && quarantined.revision === expected.revision;
|
|
659
|
+
}
|
|
660
|
+
async function restoreQuarantinedSource(vault, recovery, path, expectedDirectory) {
|
|
661
|
+
await assertSameDirectory(vault, path, expectedDirectory);
|
|
662
|
+
await assertRecoveryLocation(recovery);
|
|
663
|
+
try {
|
|
664
|
+
await link(recovery.path, path);
|
|
665
|
+
} catch (error) {
|
|
666
|
+
if (isErrno(error, "EEXIST"))
|
|
667
|
+
return false;
|
|
668
|
+
throw error;
|
|
669
|
+
}
|
|
670
|
+
await unlink(recovery.path);
|
|
671
|
+
await fsyncDirectory(recovery.directory);
|
|
672
|
+
await removeRecoveryDirectory(recovery, dirname(path));
|
|
673
|
+
return true;
|
|
674
|
+
}
|
|
675
|
+
async function assertNoInterruptedRecovery(vault, id) {
|
|
676
|
+
const { path, relativePath } = pathFor(vault, id);
|
|
677
|
+
await assertSafeParent(vault, path);
|
|
678
|
+
const artifacts = await discoveredRecoveryLocations(vault, path);
|
|
679
|
+
const first = artifacts.recoverable[0] ?? artifacts.empty[0];
|
|
680
|
+
if (first !== undefined) {
|
|
681
|
+
throw new NoteRecoveryRequiredError(relativePath, first.relativePath, new Error("an interrupted authoring transaction requires a writer to recover it"));
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
async function recoverInterruptedAuthoring(vault, id, lock) {
|
|
685
|
+
const { path, relativePath } = pathFor(vault, id);
|
|
686
|
+
await lock.assertOwned();
|
|
687
|
+
await assertSafeParent(vault, path);
|
|
688
|
+
const artifacts = await discoveredRecoveryLocations(vault, path);
|
|
689
|
+
for (const emptyRecovery of artifacts.empty) {
|
|
690
|
+
await assertRecoveryLocation(emptyRecovery);
|
|
691
|
+
await removeRecoveryDirectory(emptyRecovery, dirname(path));
|
|
692
|
+
}
|
|
693
|
+
const first = artifacts.recoverable[0];
|
|
694
|
+
if (first === undefined)
|
|
695
|
+
return;
|
|
696
|
+
if (artifacts.recoverable.length !== 1) {
|
|
697
|
+
throw new NoteRecoveryRequiredError(relativePath, first.relativePath, new Error("multiple interrupted authoring transactions require manual recovery"));
|
|
698
|
+
}
|
|
699
|
+
const current = await readOptionalSnapshot(vault, id);
|
|
700
|
+
if (current !== null) {
|
|
701
|
+
throw new NoteRecoveryRequiredError(relativePath, first.relativePath, new Error("both the canonical note and displaced bytes exist"));
|
|
702
|
+
}
|
|
703
|
+
const expectedDirectory = await directoryIdentity(vault, path);
|
|
704
|
+
if (!await restoreQuarantinedSource(vault, first, path, expectedDirectory)) {
|
|
705
|
+
throw new NoteRecoveryRequiredError(relativePath, first.relativePath, new Error("the canonical note was recreated during interrupted recovery"));
|
|
706
|
+
}
|
|
707
|
+
await lock.assertOwned();
|
|
708
|
+
}
|
|
709
|
+
async function installTemporaryWithoutClobber(temporaryPath, path) {
|
|
710
|
+
try {
|
|
711
|
+
await link(temporaryPath, path);
|
|
712
|
+
} catch (error) {
|
|
713
|
+
if (isErrno(error, "EEXIST"))
|
|
714
|
+
return false;
|
|
715
|
+
throw error;
|
|
716
|
+
}
|
|
717
|
+
await unlink(temporaryPath);
|
|
718
|
+
return true;
|
|
719
|
+
}
|
|
720
|
+
async function currentRevisionOrNull(vault, id) {
|
|
721
|
+
try {
|
|
722
|
+
return (await readOptionalSnapshot(vault, id))?.revision ?? null;
|
|
723
|
+
} catch {
|
|
724
|
+
return null;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
function withRecoveryPath(error, relativePath, recoveryPath) {
|
|
728
|
+
if (error instanceof NoteRevisionConflictError) {
|
|
729
|
+
return new NoteRevisionConflictError(error.path, error.expected, error.actual, recoveryPath);
|
|
730
|
+
}
|
|
731
|
+
return new NoteRecoveryRequiredError(relativePath, recoveryPath, error);
|
|
732
|
+
}
|
|
733
|
+
async function atomicInstall(vault, id, content, expected, lock, dependencies) {
|
|
734
|
+
const { path, relativePath } = pathFor(vault, id);
|
|
735
|
+
await assertSafeParent(vault, path);
|
|
736
|
+
const bytes = new TextEncoder().encode(content);
|
|
737
|
+
if (bytes.byteLength > MAX_NOTE_BYTES) {
|
|
738
|
+
throw new Error("the rendered note is too large for bounded authoring");
|
|
739
|
+
}
|
|
740
|
+
const directory = dirname(path);
|
|
741
|
+
const expectedDirectory = await directoryIdentity(vault, path);
|
|
742
|
+
const temporaryPath = join(directory, `.${basename(path)}.${process.pid}.${sha256(dependencies.token()).slice(0, 32)}.tmp`);
|
|
743
|
+
const mode = expected?.mode ?? 420;
|
|
744
|
+
const handle = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, mode);
|
|
745
|
+
let closed = false;
|
|
746
|
+
let identity = null;
|
|
747
|
+
let recovery = null;
|
|
748
|
+
let sourceQuarantined = false;
|
|
749
|
+
let destinationInstalled = false;
|
|
750
|
+
try {
|
|
751
|
+
const created = await handle.stat({ bigint: true });
|
|
752
|
+
if (!created.isFile() || created.nlink !== 1n) {
|
|
753
|
+
throw new Error("the temporary note target is not a private regular file");
|
|
754
|
+
}
|
|
755
|
+
identity = { device: created.dev, inode: created.ino };
|
|
756
|
+
await handle.chmod(mode);
|
|
757
|
+
await handle.writeFile(bytes);
|
|
758
|
+
await handle.sync();
|
|
759
|
+
const complete = await handle.stat({ bigint: true });
|
|
760
|
+
if (!complete.isFile() || complete.nlink !== 1n || complete.dev !== identity.device || complete.ino !== identity.inode) {
|
|
761
|
+
throw new Error("the temporary note target changed before installation");
|
|
762
|
+
}
|
|
763
|
+
await handle.close();
|
|
764
|
+
closed = true;
|
|
765
|
+
await dependencies.beforeInstall?.({
|
|
766
|
+
operation: expected === null ? "create" : "replace",
|
|
767
|
+
path,
|
|
768
|
+
temporaryPath
|
|
769
|
+
});
|
|
770
|
+
await lock.assertOwned();
|
|
771
|
+
const current = await readOptionalSnapshot(vault, id);
|
|
772
|
+
if (expected === null && current !== null || expected !== null && (current === null || !sameSnapshot(current, expected))) {
|
|
773
|
+
throw new NoteRevisionConflictError(relativePath, expected?.revision ?? null, current?.revision ?? null);
|
|
774
|
+
}
|
|
775
|
+
await assertSafeParent(vault, path);
|
|
776
|
+
const temporary = await lstat(temporaryPath, { bigint: true });
|
|
777
|
+
if (!temporary.isFile() || temporary.isSymbolicLink() || temporary.nlink !== 1n || temporary.dev !== identity.device || temporary.ino !== identity.inode) {
|
|
778
|
+
throw new Error("the temporary note target changed before installation");
|
|
779
|
+
}
|
|
780
|
+
if (expected === null) {
|
|
781
|
+
const context2 = {
|
|
782
|
+
operation: "create",
|
|
783
|
+
path,
|
|
784
|
+
temporaryPath
|
|
785
|
+
};
|
|
786
|
+
await dependencies.beforeCommit?.(context2);
|
|
787
|
+
await lock.assertOwned();
|
|
788
|
+
await assertSameDirectory(vault, path, expectedDirectory);
|
|
789
|
+
if (!await installTemporaryWithoutClobber(temporaryPath, path)) {
|
|
790
|
+
throw new NoteRevisionConflictError(relativePath, null, await currentRevisionOrNull(vault, id));
|
|
791
|
+
}
|
|
792
|
+
destinationInstalled = true;
|
|
793
|
+
await fsyncDirectory(directory);
|
|
794
|
+
return revisionFor(bytes);
|
|
795
|
+
}
|
|
796
|
+
recovery = await createRecoveryLocation(vault, path, dependencies);
|
|
797
|
+
const context = {
|
|
798
|
+
operation: "replace",
|
|
799
|
+
path,
|
|
800
|
+
temporaryPath,
|
|
801
|
+
recoveryPath: recovery.path
|
|
802
|
+
};
|
|
803
|
+
await dependencies.beforeCommit?.(context);
|
|
804
|
+
await lock.assertOwned();
|
|
805
|
+
await assertSameDirectory(vault, path, expectedDirectory);
|
|
806
|
+
await assertRecoveryLocation(recovery);
|
|
807
|
+
try {
|
|
808
|
+
await rename(path, recovery.path);
|
|
809
|
+
} catch (error) {
|
|
810
|
+
if (isErrno(error, "ENOENT")) {
|
|
811
|
+
throw new NoteRevisionConflictError(relativePath, expected.revision, null);
|
|
812
|
+
}
|
|
813
|
+
throw error;
|
|
814
|
+
}
|
|
815
|
+
sourceQuarantined = true;
|
|
816
|
+
await Promise.all([
|
|
817
|
+
fsyncDirectory(directory),
|
|
818
|
+
fsyncDirectory(recovery.directory)
|
|
819
|
+
]);
|
|
820
|
+
const quarantined = await readSnapshotAtPath(vault, recovery.path, recovery.relativePath);
|
|
821
|
+
if (!sameQuarantinedSnapshot(quarantined, expected)) {
|
|
822
|
+
throw new NoteRevisionConflictError(relativePath, expected.revision, quarantined.revision);
|
|
823
|
+
}
|
|
824
|
+
await dependencies.afterSourceQuarantined?.(context);
|
|
825
|
+
await lock.assertOwned();
|
|
826
|
+
await assertSameDirectory(vault, path, expectedDirectory);
|
|
827
|
+
await assertRecoveryLocation(recovery);
|
|
828
|
+
const stillQuarantined = await readSnapshotAtPath(vault, recovery.path, recovery.relativePath);
|
|
829
|
+
if (!sameQuarantinedSnapshot(stillQuarantined, expected)) {
|
|
830
|
+
throw new NoteRevisionConflictError(relativePath, expected.revision, stillQuarantined.revision);
|
|
831
|
+
}
|
|
832
|
+
if (!await installTemporaryWithoutClobber(temporaryPath, path)) {
|
|
833
|
+
throw new NoteRevisionConflictError(relativePath, expected.revision, await currentRevisionOrNull(vault, id));
|
|
834
|
+
}
|
|
835
|
+
destinationInstalled = true;
|
|
836
|
+
await fsyncDirectory(directory);
|
|
837
|
+
await unlink(recovery.path);
|
|
838
|
+
sourceQuarantined = false;
|
|
839
|
+
await fsyncDirectory(recovery.directory);
|
|
840
|
+
await removeRecoveryDirectory(recovery, directory);
|
|
841
|
+
recovery = null;
|
|
842
|
+
await fsyncDirectory(directory);
|
|
843
|
+
return revisionFor(bytes);
|
|
844
|
+
} catch (error) {
|
|
845
|
+
if (recovery !== null && sourceQuarantined && !destinationInstalled) {
|
|
846
|
+
let restored = false;
|
|
847
|
+
try {
|
|
848
|
+
restored = await restoreQuarantinedSource(vault, recovery, path, expectedDirectory);
|
|
849
|
+
} catch (restoreError) {
|
|
850
|
+
throw withRecoveryPath(new AggregateError([error, restoreError], "authoring failed and the prior source could not be restored"), relativePath, recovery.relativePath);
|
|
851
|
+
}
|
|
852
|
+
if (restored) {
|
|
853
|
+
sourceQuarantined = false;
|
|
854
|
+
recovery = null;
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (recovery !== null && sourceQuarantined) {
|
|
858
|
+
throw withRecoveryPath(error, relativePath, recovery.relativePath);
|
|
859
|
+
}
|
|
860
|
+
if (recovery !== null) {
|
|
861
|
+
try {
|
|
862
|
+
await removeRecoveryDirectory(recovery, directory);
|
|
863
|
+
recovery = null;
|
|
864
|
+
} catch (cleanupError) {
|
|
865
|
+
if (!isErrno(cleanupError, "ENOENT")) {
|
|
866
|
+
throw new AggregateError([error, cleanupError], "authoring failed and its empty recovery directory could not be removed");
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
throw error;
|
|
871
|
+
} finally {
|
|
872
|
+
if (!closed)
|
|
873
|
+
await handle.close().catch(() => {
|
|
874
|
+
return;
|
|
875
|
+
});
|
|
876
|
+
await cleanupTemporary(temporaryPath, identity);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
function checkedExpectedRevision(options) {
|
|
880
|
+
return options.expectedRevision === undefined ? undefined : requireRevision(options.expectedRevision);
|
|
881
|
+
}
|
|
882
|
+
function assertExpected(snapshot, expected) {
|
|
883
|
+
if (expected !== undefined && snapshot.revision !== expected) {
|
|
884
|
+
throw new NoteRevisionConflictError(snapshot.relativePath, expected, snapshot.revision);
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
function noteResult(snapshot, relations, changed, documentId) {
|
|
888
|
+
return {
|
|
889
|
+
changed,
|
|
890
|
+
path: snapshot.relativePath,
|
|
891
|
+
revision: snapshot.revision,
|
|
892
|
+
relations,
|
|
893
|
+
...documentId === undefined ? {} : { documentId }
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
function validateTitle(title) {
|
|
897
|
+
if (title === "" || title !== title.trim() || title.includes(`
|
|
898
|
+
`) || title.includes("\r") || title.length > 512) {
|
|
899
|
+
throw new TypeError("a note title must be a non-empty single line");
|
|
900
|
+
}
|
|
901
|
+
return title;
|
|
902
|
+
}
|
|
903
|
+
function validateType(type) {
|
|
904
|
+
const canonical = normalizeRelationPredicate(type);
|
|
905
|
+
if (canonical !== type)
|
|
906
|
+
throw new TypeError("a note type must be canonical kebab-case");
|
|
907
|
+
return type;
|
|
908
|
+
}
|
|
909
|
+
function validateTags(tags) {
|
|
910
|
+
const result = [];
|
|
911
|
+
const seen = new Set;
|
|
912
|
+
for (const candidate of tags ?? []) {
|
|
913
|
+
const tag = candidate.trim().replace(/^#+/u, "").normalize("NFC");
|
|
914
|
+
if (tag === "" || tag.includes(`
|
|
915
|
+
`) || tag.includes("\r") || tag.length > 128) {
|
|
916
|
+
throw new TypeError(`not a valid note tag: ${JSON.stringify(candidate)}`);
|
|
917
|
+
}
|
|
918
|
+
const folded = tag.toLocaleLowerCase("en-US");
|
|
919
|
+
if (seen.has(folded))
|
|
920
|
+
continue;
|
|
921
|
+
seen.add(folded);
|
|
922
|
+
result.push(tag);
|
|
923
|
+
}
|
|
924
|
+
return result;
|
|
925
|
+
}
|
|
926
|
+
function normalizedRequestedBody(body) {
|
|
927
|
+
return body.endsWith(`
|
|
928
|
+
`) ? body : `${body}
|
|
929
|
+
`;
|
|
930
|
+
}
|
|
931
|
+
function renderCreatedNote(input, documentId) {
|
|
932
|
+
const title = validateTitle(input.title);
|
|
933
|
+
const type = validateType(input.type);
|
|
934
|
+
const tags = validateTags(input.tags);
|
|
935
|
+
const metadata = { document_id: documentId, type, title };
|
|
936
|
+
if (tags.length > 0)
|
|
937
|
+
metadata["tags"] = tags;
|
|
938
|
+
const document = new Document(metadata, { schema: "core" });
|
|
939
|
+
const body = normalizedRequestedBody(input.body ?? `# ${title}
|
|
940
|
+
`);
|
|
941
|
+
return `---
|
|
942
|
+
${document.toString({ lineWidth: 0 })}---
|
|
943
|
+
|
|
944
|
+
${body}`;
|
|
945
|
+
}
|
|
946
|
+
function topLevelScalar(parts, key) {
|
|
947
|
+
if (!isMap(parts.document.contents))
|
|
948
|
+
return null;
|
|
949
|
+
return scalarString(parts.document.contents.get(key, true));
|
|
950
|
+
}
|
|
951
|
+
function topLevelStrings(parts, key) {
|
|
952
|
+
if (!isMap(parts.document.contents))
|
|
953
|
+
return [];
|
|
954
|
+
const value = parts.document.contents.get(key, true);
|
|
955
|
+
if (value === undefined)
|
|
956
|
+
return [];
|
|
957
|
+
if (isScalar(value) && typeof value.value === "string")
|
|
958
|
+
return [value.value];
|
|
959
|
+
if (!isSeq(value))
|
|
960
|
+
return [];
|
|
961
|
+
return value.items.flatMap((item) => {
|
|
962
|
+
const candidate = scalarString(item);
|
|
963
|
+
return candidate === null ? [] : [candidate];
|
|
964
|
+
});
|
|
965
|
+
}
|
|
966
|
+
function existingDocumentId(parts) {
|
|
967
|
+
if (!isMap(parts.document.contents))
|
|
968
|
+
return { kind: "missing" };
|
|
969
|
+
const values = parts.document.contents.items.flatMap((pair) => {
|
|
970
|
+
const key = scalarString(pair.key);
|
|
971
|
+
if (key?.normalize("NFC").toLocaleLowerCase("en-US") !== "document_id")
|
|
972
|
+
return [];
|
|
973
|
+
const value = scalarString(pair.value);
|
|
974
|
+
return value === null ? [null] : [value];
|
|
975
|
+
});
|
|
976
|
+
if (values.length === 0)
|
|
977
|
+
return { kind: "missing" };
|
|
978
|
+
if (values.length !== 1 || values[0] === null)
|
|
979
|
+
return { kind: "invalid" };
|
|
980
|
+
try {
|
|
981
|
+
return { kind: "valid", documentId: parseDocumentId(values[0]) };
|
|
982
|
+
} catch {
|
|
983
|
+
return { kind: "invalid" };
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
function assertCompatibleCreate(snapshot, input, requestedDocumentId) {
|
|
987
|
+
const parts = frontmatter(snapshot.content, snapshot.relativePath);
|
|
988
|
+
const requestedType = validateType(input.type);
|
|
989
|
+
const requestedTitle = validateTitle(input.title);
|
|
990
|
+
if (topLevelScalar(parts, "type") !== requestedType) {
|
|
991
|
+
throw new NoteAlreadyExistsError(snapshot.relativePath, "type differs");
|
|
992
|
+
}
|
|
993
|
+
if (topLevelScalar(parts, "title") !== requestedTitle) {
|
|
994
|
+
throw new NoteAlreadyExistsError(snapshot.relativePath, "title differs");
|
|
995
|
+
}
|
|
996
|
+
const presentTags = new Set(topLevelStrings(parts, "tags").map((tag) => tag.toLocaleLowerCase("en-US")));
|
|
997
|
+
const missingTag = validateTags(input.tags).find((tag) => !presentTags.has(tag.toLocaleLowerCase("en-US")));
|
|
998
|
+
if (missingTag !== undefined) {
|
|
999
|
+
throw new NoteAlreadyExistsError(snapshot.relativePath, `tag is missing: ${missingTag}`);
|
|
1000
|
+
}
|
|
1001
|
+
if (input.body !== undefined && parts.bodySuffix !== `${parts.newline}${parts.newline}${normalizedRequestedBody(input.body)}`) {
|
|
1002
|
+
throw new NoteAlreadyExistsError(snapshot.relativePath, "body differs");
|
|
1003
|
+
}
|
|
1004
|
+
const existingId = existingDocumentId(parts);
|
|
1005
|
+
if (requestedDocumentId !== undefined && (existingId.kind !== "valid" || existingId.documentId !== requestedDocumentId)) {
|
|
1006
|
+
throw new NoteAlreadyExistsError(snapshot.relativePath, existingId.kind === "missing" ? "document_id is missing" : "document_id differs");
|
|
1007
|
+
}
|
|
1008
|
+
return {
|
|
1009
|
+
relations: relationsFromParts(parts, snapshot.relativePath),
|
|
1010
|
+
...existingId.kind === "valid" ? { documentId: existingId.documentId } : {}
|
|
1011
|
+
};
|
|
1012
|
+
}
|
|
1013
|
+
async function noteRevision(root, id) {
|
|
1014
|
+
const vault = await resolveVault(root);
|
|
1015
|
+
const canonicalId = canonicalNoteId(id);
|
|
1016
|
+
await assertNoInterruptedRecovery(vault, canonicalId);
|
|
1017
|
+
return (await readSnapshot(vault, canonicalId)).revision;
|
|
1018
|
+
}
|
|
1019
|
+
async function listNoteRelations(root, sourceId) {
|
|
1020
|
+
const vault = await resolveVault(root);
|
|
1021
|
+
const canonicalId = canonicalNoteId(sourceId);
|
|
1022
|
+
await assertNoInterruptedRecovery(vault, canonicalId);
|
|
1023
|
+
const source = await readSnapshot(vault, canonicalId);
|
|
1024
|
+
return relationsFromParts(frontmatter(source.content, source.relativePath), source.relativePath);
|
|
1025
|
+
}
|
|
1026
|
+
async function createNote(root, input, options = {}) {
|
|
1027
|
+
const vault = await resolveVault(root);
|
|
1028
|
+
const id = canonicalNoteId(input.id);
|
|
1029
|
+
const requestedDocumentId = input.documentId === undefined ? undefined : parseDocumentId(input.documentId);
|
|
1030
|
+
const expected = checkedExpectedRevision(options);
|
|
1031
|
+
const dependencies = dependenciesFor(options.dependencies);
|
|
1032
|
+
const lock = await acquireNoteLock(vault.root, id, options.lock);
|
|
1033
|
+
try {
|
|
1034
|
+
await recoverInterruptedAuthoring(vault, id, lock);
|
|
1035
|
+
const existing = await readOptionalSnapshot(vault, id);
|
|
1036
|
+
if (existing !== null) {
|
|
1037
|
+
assertExpected(existing, expected);
|
|
1038
|
+
const compatible = assertCompatibleCreate(existing, input, requestedDocumentId);
|
|
1039
|
+
return noteResult(existing, compatible.relations, false, compatible.documentId);
|
|
1040
|
+
}
|
|
1041
|
+
if (expected !== undefined) {
|
|
1042
|
+
throw new NoteRevisionConflictError(`${id}.md`, expected, null);
|
|
1043
|
+
}
|
|
1044
|
+
const documentId = requestedDocumentId ?? parseDocumentId(dependencies.documentId());
|
|
1045
|
+
const content = renderCreatedNote(input, documentId);
|
|
1046
|
+
const revision = await atomicInstall(vault, id, content, null, lock, dependencies);
|
|
1047
|
+
return {
|
|
1048
|
+
changed: true,
|
|
1049
|
+
path: `${id}.md`,
|
|
1050
|
+
revision,
|
|
1051
|
+
relations: [],
|
|
1052
|
+
documentId
|
|
1053
|
+
};
|
|
1054
|
+
} finally {
|
|
1055
|
+
await lock.release();
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
async function createConceptNote(root, input, options = {}) {
|
|
1059
|
+
return createNote(root, { ...input, type: "concept" }, options);
|
|
1060
|
+
}
|
|
1061
|
+
async function editNoteRelation(operation, root, sourceIdInput, predicateInput, targetIdInput, options) {
|
|
1062
|
+
const vault = await resolveVault(root);
|
|
1063
|
+
const sourceId = canonicalNoteId(sourceIdInput);
|
|
1064
|
+
const targetId = canonicalRelationTarget(targetIdInput);
|
|
1065
|
+
const predicate = normalizeRelationPredicate(predicateInput);
|
|
1066
|
+
const expected = checkedExpectedRevision(options);
|
|
1067
|
+
const dependencies = dependenciesFor(options.dependencies);
|
|
1068
|
+
const lock = await acquireNoteLock(vault.root, sourceId, options.lock);
|
|
1069
|
+
try {
|
|
1070
|
+
await recoverInterruptedAuthoring(vault, sourceId, lock);
|
|
1071
|
+
const source = await readSnapshot(vault, sourceId);
|
|
1072
|
+
assertExpected(source, expected);
|
|
1073
|
+
if (operation === "add" && !targetId.startsWith("kb://") && targetId !== sourceId) {
|
|
1074
|
+
await readSnapshot(vault, targetId);
|
|
1075
|
+
}
|
|
1076
|
+
const parts = frontmatter(source.content, source.relativePath);
|
|
1077
|
+
if (operation === "add")
|
|
1078
|
+
relationsFromParts(parts, source.relativePath);
|
|
1079
|
+
const changed = operation === "add" ? addRelationToParts(parts, source.relativePath, predicate, targetId) : removeRelationFromParts(parts, source.relativePath, predicate, targetId, sourceId);
|
|
1080
|
+
if (!changed) {
|
|
1081
|
+
return noteResult(source, relationsFromParts(parts, source.relativePath), false);
|
|
1082
|
+
}
|
|
1083
|
+
const content = renderFrontmatter(parts);
|
|
1084
|
+
const relations = relationsFromParts(frontmatter(content, source.relativePath), source.relativePath);
|
|
1085
|
+
const revision = await atomicInstall(vault, sourceId, content, source, lock, dependencies);
|
|
1086
|
+
return {
|
|
1087
|
+
changed: true,
|
|
1088
|
+
path: source.relativePath,
|
|
1089
|
+
revision,
|
|
1090
|
+
relations
|
|
1091
|
+
};
|
|
1092
|
+
} finally {
|
|
1093
|
+
await lock.release();
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
async function addNoteRelation(root, sourceId, predicate, targetId, options = {}) {
|
|
1097
|
+
return editNoteRelation("add", root, sourceId, predicate, targetId, options);
|
|
1098
|
+
}
|
|
1099
|
+
async function removeNoteRelation(root, sourceId, predicate, targetId, options = {}) {
|
|
1100
|
+
return editNoteRelation("remove", root, sourceId, predicate, targetId, options);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
export { InvalidCanonicalNoteIdError, NoteRevisionConflictError, NoteAlreadyExistsError, NoteRecoveryRequiredError, canonicalNoteId, canonicalRelationTarget, normalizeRelationPredicate, noteRevision, listNoteRelations, createNote, createConceptNote, addNoteRelation, removeNoteRelation };
|