yamlover 0.3.0
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/LICENSE +21 -0
- package/README.md +214 -0
- package/bin/yamlover.js +202 -0
- package/dist/server.js +4681 -0
- package/index.html +12 -0
- package/package.json +72 -0
- package/src/client/App.tsx +372 -0
- package/src/client/NodeView.tsx +422 -0
- package/src/client/TaskStrip.tsx +34 -0
- package/src/client/Tree.tsx +97 -0
- package/src/client/api.ts +186 -0
- package/src/client/icons.ts +91 -0
- package/src/client/links.tsx +108 -0
- package/src/client/live.ts +42 -0
- package/src/client/main.tsx +10 -0
- package/src/client/paste-html.ts +228 -0
- package/src/client/paste-links.ts +42 -0
- package/src/client/paths.ts +109 -0
- package/src/client/render.tsx +326 -0
- package/src/client/renderers/annotate.tsx +507 -0
- package/src/client/renderers/asciidoc.tsx +35 -0
- package/src/client/renderers/chapter.tsx +138 -0
- package/src/client/renderers/csv.tsx +233 -0
- package/src/client/renderers/decoded.tsx +72 -0
- package/src/client/renderers/djvu.tsx +97 -0
- package/src/client/renderers/doc.tsx +40 -0
- package/src/client/renderers/docx.tsx +49 -0
- package/src/client/renderers/epub.tsx +147 -0
- package/src/client/renderers/explorer.tsx +209 -0
- package/src/client/renderers/fb2.tsx +149 -0
- package/src/client/renderers/headings.ts +69 -0
- package/src/client/renderers/heic.tsx +23 -0
- package/src/client/renderers/imagemap.tsx +157 -0
- package/src/client/renderers/kml.ts +46 -0
- package/src/client/renderers/latex.tsx +36 -0
- package/src/client/renderers/map.tsx +205 -0
- package/src/client/renderers/marklower.tsx +119 -0
- package/src/client/renderers/markup.tsx +64 -0
- package/src/client/renderers/media.tsx +19 -0
- package/src/client/renderers/panzoom.ts +101 -0
- package/src/client/renderers/pdf.tsx +176 -0
- package/src/client/renderers/plaintext.tsx +120 -0
- package/src/client/renderers/plantuml.tsx +82 -0
- package/src/client/renderers/psd.tsx +25 -0
- package/src/client/renderers/registry.tsx +389 -0
- package/src/client/renderers/rtf.tsx +210 -0
- package/src/client/renderers/spreadsheet.tsx +105 -0
- package/src/client/renderers/tag.tsx +113 -0
- package/src/client/renderers/text.tsx +41 -0
- package/src/client/renderers/tiff.tsx +33 -0
- package/src/client/styles.css +1115 -0
- package/src/client/vendor/README.md +30 -0
- package/src/client/vendor/djvu.js +15535 -0
- package/src/client/vite-env.d.ts +31 -0
- package/src/server/api.ts +147 -0
- package/src/server/engine-api.ts +1442 -0
- package/src/server/gitignore.ts +81 -0
- package/src/server/node-kind.ts +48 -0
- package/src/server/tasks.ts +83 -0
- package/src/server/yamlover.ts +1133 -0
|
@@ -0,0 +1,1442 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine-api.ts — the JSON API, backed by the new yamlover ENGINE.
|
|
3
|
+
*
|
|
4
|
+
* This replaces the legacy `loadEntity` materializer (./yamlover.ts) with the engine:
|
|
5
|
+
* `walkDir` (directory concrete → IR) + `Store` (SQLite property-graph index). It emits the
|
|
6
|
+
* SAME response shapes the React client already consumes (TreeNode, the `$yamloverLink` /
|
|
7
|
+
* `$yamloverRef` / `$yamloverBinary` markers, the schema view), so the UI works "as it was".
|
|
8
|
+
*
|
|
9
|
+
* Endpoints (path is JSON-space: `/key[0]/sub`):
|
|
10
|
+
* GET /api/info breadcrumb head (root label)
|
|
11
|
+
* GET /api/tree?path&depth the TOC subtree
|
|
12
|
+
* GET /api/json?path&depth&binary the node value (depth-limited; nested = link markers)
|
|
13
|
+
* GET /api/schema?path&depth the instance schema
|
|
14
|
+
* GET /api/blob?path a file-backed node's raw bytes
|
|
15
|
+
* GET /api/tagged?path the materials filed under a tag (annotations → targets)
|
|
16
|
+
* GET /api/events SSE: {type:"diff",…} reindex diffs + {type:"task",…} progress
|
|
17
|
+
* GET /api/tasks long-running tasks in flight (snapshot for a fresh page)
|
|
18
|
+
* GET /api/query?q&path the 3g query evaluator (colon match templates)
|
|
19
|
+
* GET /api/dangling pointers that did not resolve at index time
|
|
20
|
+
* POST /api/reindex manual reconcile (the watcher's fallback)
|
|
21
|
+
*
|
|
22
|
+
* The on-disk index lives at <root>/.yamlover/index.db. It is a derived cache with a persistent
|
|
23
|
+
* FILE MANIFEST (path + hash + size + mtime): startup re-indexes against it (the offline
|
|
24
|
+
* reconcile — unchanged blobs are never re-read, so it is cheap), and an FS watcher re-indexes
|
|
25
|
+
* on external edits (the watched-live tier), broadcasting what changed over /api/events.
|
|
26
|
+
*
|
|
27
|
+
* LONG-RUNNING WORK runs as background tasks (./tasks.ts): the initial index starts the moment
|
|
28
|
+
* createHandlers returns (the HTTP server can listen immediately and serve the PREVIOUS index —
|
|
29
|
+
* or an empty one on a cold start), and the background hasher then fills in content hashes for
|
|
30
|
+
* the large blobs the walk no longer reads. Store-mutating jobs (index, mv, paste, annotate)
|
|
31
|
+
* serialize through one writer queue; reads never wait.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import path from "node:path";
|
|
35
|
+
import fs from "node:fs";
|
|
36
|
+
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
37
|
+
import { Store, reindex, reindexAsync, hashFileAsync, watchTree, loadSettings, mv, relinkMoved, evalQuery } from "../../../engine/ts/src/index.ts";
|
|
38
|
+
import type { NodeRow, EdgeRow, Settings, IndexDiff } from "../../../engine/ts/src/index.ts";
|
|
39
|
+
import { parseYamlover } from "../../../parser/ts/src/yamlover.ts";
|
|
40
|
+
import { pointerToken, anchorToken } from "../../../parser/ts/src/serialize-yamlover.ts";
|
|
41
|
+
import { colonSegment } from "../../../parser/ts/src/pointer.ts";
|
|
42
|
+
import { isPointer } from "../../../parser/ts/src/ir.ts";
|
|
43
|
+
import type { Node as IrNode } from "../../../parser/ts/src/ir.ts";
|
|
44
|
+
import { buildGitIgnore } from "./gitignore.js";
|
|
45
|
+
import { displayKind, ownedEntries, typeName } from "./node-kind.js";
|
|
46
|
+
import { TaskRegistry } from "./tasks.js";
|
|
47
|
+
import type { TaskHandle } from "./tasks.js";
|
|
48
|
+
|
|
49
|
+
type Handler = (req: IncomingMessage, res: ServerResponse, url: URL) => void;
|
|
50
|
+
interface Options {
|
|
51
|
+
gitignore?: boolean; // honor .gitignore for stray files (default: true)
|
|
52
|
+
watch?: boolean; // watch the tree and re-index on external edits (default: false; bin turns it on)
|
|
53
|
+
log?: (line: string) => void; // server-side progress lines (the bin wires console.log; tests stay silent)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Marker keys + types the client recognizes (must match src/client expectations).
|
|
57
|
+
const LINK_KEY = "$yamloverLink";
|
|
58
|
+
const BINARY_KEY = "$yamloverBinary";
|
|
59
|
+
const MIXED_KEY = "$yamloverMixed"; // an omni/mix node: a self-value and/or interleaved items+fields
|
|
60
|
+
type Seg = string | number;
|
|
61
|
+
// Node-KIND classification (object|array|scalar|binary|omni|mix → the client `type:`) lives in
|
|
62
|
+
// ./node-kind.ts so it can be unit-tested against a Store without the HTTP layer.
|
|
63
|
+
|
|
64
|
+
export function createHandlers(dataRoot: string, opts: Options = {}): Handler & { close: () => void; ready: Promise<IndexDiff> } {
|
|
65
|
+
const rootName = path.basename(path.resolve(dataRoot)) || "/";
|
|
66
|
+
const dbPath = path.join(dataRoot, ".yamlover", "index.db");
|
|
67
|
+
// Project configuration (<root>/.yamlover/settings.yamlover) — defaults for WRITE paths
|
|
68
|
+
// (e.g. where new annotations are created). Read once at startup, like the index.
|
|
69
|
+
const settings: Settings = loadSettings(dataRoot);
|
|
70
|
+
// Skip git-ignored strays (node_modules, build output, …) so serving the project root works.
|
|
71
|
+
const ignore = opts.gitignore === false ? undefined : buildGitIgnore(dataRoot);
|
|
72
|
+
|
|
73
|
+
// ONE Store, open for the server's lifetime; every request is answered from it (indexed
|
|
74
|
+
// lookups — sub-millisecond). Freshness is the reconcile loop, not a per-request re-walk:
|
|
75
|
+
// `reindex` re-walks against the persisted file manifest (an unchanged blob is never
|
|
76
|
+
// re-read — the cost that once made refresh block on a click), swaps the tables in one
|
|
77
|
+
// transaction, and reports what changed. It runs at startup (the OFFLINE reconcile: external
|
|
78
|
+
// edits made while the server was down show up immediately) and on every FS-watcher batch
|
|
79
|
+
// (the WATCHED-LIVE tier), with POST /api/reindex as the manual fallback. Changes are pushed
|
|
80
|
+
// to clients over GET /api/events (SSE). Move inference / relinking waits on the serializers.
|
|
81
|
+
fs.mkdirSync(path.dirname(dbPath), { recursive: true });
|
|
82
|
+
const store0 = new Store(dbPath);
|
|
83
|
+
const store = (): Store => store0;
|
|
84
|
+
const log = opts.log ?? ((): void => {});
|
|
85
|
+
let closed = false;
|
|
86
|
+
|
|
87
|
+
// SSE subscribers. Frames are typed: `{type:"diff", added,changed,removed,moved}` (a reindex
|
|
88
|
+
// that found changes, as client JSON paths) and `{type:"task", task}` (long-running task
|
|
89
|
+
// lifecycle — see ./tasks.ts).
|
|
90
|
+
const sseClients = new Set<ServerResponse>();
|
|
91
|
+
const sseWrite = (frame: unknown): void => {
|
|
92
|
+
const payload = JSON.stringify(frame);
|
|
93
|
+
for (const res of sseClients) res.write(`data: ${payload}\n\n`);
|
|
94
|
+
};
|
|
95
|
+
const broadcast = (diff: IndexDiff): void => {
|
|
96
|
+
if (diff.added.length + diff.changed.length + diff.removed.length + diff.moved.length === 0) return;
|
|
97
|
+
const toClient = (rel: string): string => segsToStr(rel.split("/"));
|
|
98
|
+
sseWrite({
|
|
99
|
+
type: "diff",
|
|
100
|
+
added: diff.added.map(toClient), changed: diff.changed.map(toClient), removed: diff.removed.map(toClient),
|
|
101
|
+
moved: diff.moved.map((m) => ({ from: toClient(m.from), to: toClient(m.to) })),
|
|
102
|
+
});
|
|
103
|
+
};
|
|
104
|
+
// ONE change currency for every write path: a mediated endpoint announces the file-level
|
|
105
|
+
// change it just made in the same IndexDiff shape the reconcile broadcasts, so every client
|
|
106
|
+
// surface (TOC, node pane, marks, tag pages) refreshes through the SAME SSE flow — never a
|
|
107
|
+
// per-endpoint push path. Incremental writes (annotate, tag) call this with the one file
|
|
108
|
+
// they touched; full-reindex writes (paste, mv) broadcast their reconcile diff directly.
|
|
109
|
+
const announce = (d: Partial<IndexDiff>): void => broadcast({ added: [], changed: [], removed: [], moved: [], ...d });
|
|
110
|
+
// a client JSON path (keys percent-encoded) as the root-relative FILE path diffs speak
|
|
111
|
+
const relFileOf = (clientPath: string): string => strToSegs(clientPath).map(String).join("/");
|
|
112
|
+
const tasks = new TaskRegistry((t) => sseWrite({ type: "task", task: t }));
|
|
113
|
+
|
|
114
|
+
// ONE WRITER at a time: every job that mutates the Store or needs a consistent manifest
|
|
115
|
+
// (indexing, mv, paste, annotations) chains here, so e.g. an annotation cannot be swallowed
|
|
116
|
+
// by a concurrently-committing full walk whose disk snapshot predates it. Read endpoints
|
|
117
|
+
// never queue — they answer from the current index (stale-but-instant during a reindex).
|
|
118
|
+
let chain: Promise<unknown> = Promise.resolve();
|
|
119
|
+
const enqueue = <T,>(fn: () => T | Promise<T>): Promise<T> => {
|
|
120
|
+
const p = chain.then(fn);
|
|
121
|
+
chain = p.catch(() => {}); // a failed job must not poison the queue
|
|
122
|
+
return p;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// The background HASHER: fills in content hashes the walk skipped (blobs over the inline
|
|
126
|
+
// limit), smallest-first, as a visible task. A singleton loop OUTSIDE the write queue — it
|
|
127
|
+
// only reads bytes; each tiny manifest update enqueues on its own, so a multi-GB file never
|
|
128
|
+
// holds the queue. It re-queries the store every step, so files added by later reconciles
|
|
129
|
+
// are picked up; a file that changed or vanished mid-hash fails the (size, mtime) guard and
|
|
130
|
+
// is skipped (the next reconcile re-queues it with fresh identity).
|
|
131
|
+
const gib = (b: number): string => (b / 2 ** 30).toFixed(1);
|
|
132
|
+
const BIG_FILE_BYTES = 256 * 2 ** 20; // show within-file byte progress above this
|
|
133
|
+
let hashing = false;
|
|
134
|
+
const scheduleHasher = (): void => {
|
|
135
|
+
if (hashing || closed) return;
|
|
136
|
+
if (store0.unhashedFiles(1).length === 0) return;
|
|
137
|
+
hashing = true;
|
|
138
|
+
void (async () => {
|
|
139
|
+
const skip = new Set<string>();
|
|
140
|
+
let done = 0;
|
|
141
|
+
let lastLog = 0;
|
|
142
|
+
const t0 = Date.now();
|
|
143
|
+
let h: TaskHandle | null = null;
|
|
144
|
+
try {
|
|
145
|
+
for (;;) {
|
|
146
|
+
if (closed) break;
|
|
147
|
+
const pending = store0.unhashedFiles().filter((f) => !skip.has(f.path));
|
|
148
|
+
if (pending.length === 0) break;
|
|
149
|
+
h ??= tasks.start("hashing large files");
|
|
150
|
+
const next = pending[0];
|
|
151
|
+
const total = done + pending.length;
|
|
152
|
+
h.progress(done, total, next.path);
|
|
153
|
+
const abs = path.join(dataRoot, ...next.path.split("/"));
|
|
154
|
+
let hash: string | null = null;
|
|
155
|
+
try {
|
|
156
|
+
hash = await hashFileAsync(abs, (bytes) => {
|
|
157
|
+
if (next.size >= BIG_FILE_BYTES) h?.progress(done, total, `${next.path} — ${gib(bytes)}/${gib(next.size)} GiB`);
|
|
158
|
+
});
|
|
159
|
+
} catch {
|
|
160
|
+
// unreadable or vanished — skip; a later reconcile re-queues it if it still exists
|
|
161
|
+
}
|
|
162
|
+
const st = hash !== null ? fs.statSync(abs, { throwIfNoEntry: false }) : undefined;
|
|
163
|
+
const fresh = st !== undefined && st.size === next.size && st.mtimeMs === next.mtimeMs;
|
|
164
|
+
const ok = hash !== null && fresh && !closed
|
|
165
|
+
? await enqueue(() => store0.setFileHash(next.path, hash, next.size, next.mtimeMs))
|
|
166
|
+
: false;
|
|
167
|
+
if (!ok) {
|
|
168
|
+
skip.add(next.path);
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
done++;
|
|
172
|
+
const now = Date.now();
|
|
173
|
+
if (now - lastLog >= 500) {
|
|
174
|
+
lastLog = now;
|
|
175
|
+
log(`hashing ${done}/${total} — ${next.path}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
h?.done();
|
|
179
|
+
if (h) log(`hashing done — ${done} file(s) in ${((Date.now() - t0) / 1000).toFixed(1)}s`);
|
|
180
|
+
} catch (e) {
|
|
181
|
+
h?.fail(e);
|
|
182
|
+
log(`hashing FAILED — ${String((e as Error)?.message ?? e)}`);
|
|
183
|
+
} finally {
|
|
184
|
+
hashing = false;
|
|
185
|
+
}
|
|
186
|
+
})();
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
// A reindex usable inside an already-queued job (NOT queued itself — callers queue).
|
|
190
|
+
const doReindex = (): Promise<IndexDiff> => reindexAsync(store0, dataRoot, { ignore });
|
|
191
|
+
|
|
192
|
+
// The INITIAL index, as a background task: the server listens (and serves the previous
|
|
193
|
+
// on-disk index — or an empty one, cold) while the walk runs. Progress is determinate
|
|
194
|
+
// (an enumeration pre-pass counts the tree) and lands in SSE + the log.
|
|
195
|
+
const runIndexTask = (label: string): Promise<IndexDiff> =>
|
|
196
|
+
enqueue(async () => {
|
|
197
|
+
const h = tasks.start(label);
|
|
198
|
+
const t0 = Date.now();
|
|
199
|
+
let lastLog = 0;
|
|
200
|
+
log(`${label}…`);
|
|
201
|
+
try {
|
|
202
|
+
const diff = await reindexAsync(store0, dataRoot, {
|
|
203
|
+
ignore,
|
|
204
|
+
onProgress: (p) => {
|
|
205
|
+
h.progress(p.done, p.total, p.message);
|
|
206
|
+
const now = Date.now();
|
|
207
|
+
if (now - lastLog >= 500) {
|
|
208
|
+
lastLog = now;
|
|
209
|
+
log(`${label} ${p.done}/${p.total ?? "?"}${p.message ? ` — ${p.message}` : ""}`);
|
|
210
|
+
}
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
h.done();
|
|
214
|
+
log(
|
|
215
|
+
`${label} done in ${((Date.now() - t0) / 1000).toFixed(1)}s` +
|
|
216
|
+
` (+${diff.added.length} ~${diff.changed.length} −${diff.removed.length} →${diff.moved.length})`,
|
|
217
|
+
);
|
|
218
|
+
broadcast(diff);
|
|
219
|
+
scheduleHasher();
|
|
220
|
+
return diff;
|
|
221
|
+
} catch (e) {
|
|
222
|
+
h.fail(e);
|
|
223
|
+
log(`${label} FAILED — ${String((e as Error)?.message ?? e)}`);
|
|
224
|
+
throw e;
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// An UNMEDIATED move (mv in a shell, a file manager) shows up as an inferred `moved` —
|
|
229
|
+
// relink the inbound refs the way the mediated tier would (ENGINE.md tier 2: "inferred
|
|
230
|
+
// as a move and relinked"), then reconcile once more so the rewritten files re-index.
|
|
231
|
+
const reconcile = (): Promise<IndexDiff> =>
|
|
232
|
+
enqueue(async () => {
|
|
233
|
+
const h = tasks.start("reconciling");
|
|
234
|
+
try {
|
|
235
|
+
const diff = await doReindex();
|
|
236
|
+
if (diff.moved.length > 0) {
|
|
237
|
+
const r = relinkMoved(dataRoot, diff.moved, { ignore });
|
|
238
|
+
if (r.editedFiles.length > 0) {
|
|
239
|
+
const follow = reindex(store0, dataRoot, { ignore });
|
|
240
|
+
diff.changed = [...new Set([...diff.changed, ...follow.changed])];
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
h.done();
|
|
244
|
+
broadcast(diff);
|
|
245
|
+
scheduleHasher();
|
|
246
|
+
return diff;
|
|
247
|
+
} catch (e) {
|
|
248
|
+
h.fail(e);
|
|
249
|
+
throw e;
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
const ready = runIndexTask(`indexing ${rootName}`);
|
|
254
|
+
const stopWatch = opts.watch
|
|
255
|
+
? watchTree(dataRoot, () => {
|
|
256
|
+
reconcile().catch((e) => log(`reconcile FAILED — ${String((e as Error)?.message ?? e)}`));
|
|
257
|
+
}, { ignore })
|
|
258
|
+
: null;
|
|
259
|
+
|
|
260
|
+
const handler: Handler = (req, res, url) => {
|
|
261
|
+
try {
|
|
262
|
+
const s = store();
|
|
263
|
+
|
|
264
|
+
// Server-pushed change notifications: an SSE stream of reindex diffs (client JSON
|
|
265
|
+
// paths). The comment pings keep idle proxies from reaping the connection.
|
|
266
|
+
if (req.method === "GET" && url.pathname === "/api/events") {
|
|
267
|
+
res.statusCode = 200;
|
|
268
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
269
|
+
res.setHeader("Cache-Control", "no-cache");
|
|
270
|
+
res.setHeader("Connection", "keep-alive");
|
|
271
|
+
res.write(": connected\n\n");
|
|
272
|
+
sseClients.add(res);
|
|
273
|
+
const ping = setInterval(() => res.write(": ping\n\n"), 30_000);
|
|
274
|
+
req.on("close", () => { clearInterval(ping); sseClients.delete(res); });
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Manual reconcile — the watcher's fallback; responds with what changed (inferred
|
|
279
|
+
// moves are relinked, like the watcher path). Queued behind any in-flight index.
|
|
280
|
+
if (req.method === "POST" && url.pathname === "/api/reindex") {
|
|
281
|
+
reconcile()
|
|
282
|
+
.then((diff) => sendJson(res, 200, diff))
|
|
283
|
+
.catch((e) => sendJson(res, 500, { error: String((e as Error).message || e) }));
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Long-running server tasks (indexing, hashing, …) currently in flight (or just
|
|
288
|
+
// finished) — the snapshot a freshly loaded page needs; updates ride /api/events.
|
|
289
|
+
if (url.pathname === "/api/tasks") {
|
|
290
|
+
sendJson(res, 200, tasks.list());
|
|
291
|
+
return;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// The QUERY evaluator (PLAN.md 3g / QUERY.md): a colon-grammar match template,
|
|
295
|
+
// evaluated at `path` (default: the root). Results are client JSON paths.
|
|
296
|
+
if (url.pathname === "/api/query") {
|
|
297
|
+
const q = url.searchParams.get("q") || "";
|
|
298
|
+
const at = storePath(strToSegs(url.searchParams.get("path") || ":"));
|
|
299
|
+
try {
|
|
300
|
+
const results = evalQuery(s, q, at).map((p) => segsToStr(storePathToSegs(p)));
|
|
301
|
+
sendJson(res, 200, { results });
|
|
302
|
+
} catch (e) {
|
|
303
|
+
sendJson(res, 400, { error: String((e as Error).message || e) });
|
|
304
|
+
}
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Pointers that did not resolve at index time (ENGINE.md: reported, never dropped).
|
|
309
|
+
if (url.pathname === "/api/dangling") {
|
|
310
|
+
sendJson(res, 200, s.dangling().map((d) => ({ from: segsToStr(storePathToSegs(d.from)), raw: d.raw, reason: d.reason })));
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// Create an annotation — ONE TAG APPLICATION (a WRITE path): persist it as a yamlover file
|
|
315
|
+
// under the project's default annotation location (settings.yamlover; `/annotations` by
|
|
316
|
+
// default), then index it so it joins the graph (reverse-linked to its material, member of
|
|
317
|
+
// its tag). An annotation is NOT tied to that location — it may be moved to (or authored
|
|
318
|
+
// in) any directory and keeps working; the setting only says where NEW ones land. Body:
|
|
319
|
+
// { target, tag, selector?, description? } — target/tag are the material's and the applied
|
|
320
|
+
// tag's JSON paths; no selector applies the tag to the WHOLE node.
|
|
321
|
+
if (req.method === "POST" && url.pathname === "/api/annotate") {
|
|
322
|
+
readBody(req)
|
|
323
|
+
.then((data) =>
|
|
324
|
+
// Queued: an incremental row added while a full walk (whose disk snapshot predates
|
|
325
|
+
// this annotation) is committing would be silently swapped away.
|
|
326
|
+
enqueue(() => {
|
|
327
|
+
const a = data as AnnotationInput;
|
|
328
|
+
const tagStore = storePath(strToSegs(a.tag ?? ""));
|
|
329
|
+
if (!a?.tag || s.node(tagStore)?.format !== TAG_FORMAT) {
|
|
330
|
+
throw new Error("annotation needs a `tag` that is an x-yamlover-tag node");
|
|
331
|
+
}
|
|
332
|
+
const annPath = writeAnnotation(dataRoot, settings.annotations.location, a);
|
|
333
|
+
// Update the index INCREMENTALLY (not a full rebuild — that re-reads every changed
|
|
334
|
+
// file and blocks the next click). Add just this annotation's nodes + its edges.
|
|
335
|
+
const doc = parseYamlover(fs.readFileSync(path.join(dataRoot, ...strToSegs(annPath).map(String)), "utf8"), annPath);
|
|
336
|
+
s.addAnnotation(storePath(strToSegs(annPath)), storePath(strToSegs(a.target)), doc, tagStore);
|
|
337
|
+
announce({ added: [relFileOf(annPath)] });
|
|
338
|
+
return { path: annPath };
|
|
339
|
+
}),
|
|
340
|
+
)
|
|
341
|
+
.then((body) => sendJson(res, 201, body))
|
|
342
|
+
.catch((e) => sendJson(res, 400, { error: String((e as Error).message || e) }));
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// Delete an annotation by its node path (recolor = delete + create, client-side). Removes
|
|
347
|
+
// the annotation FILE and its index rows — incrementally. Works wherever the annotation
|
|
348
|
+
// lives: the guard is its schema (`x-yamlover-annotation`), not a directory.
|
|
349
|
+
if (req.method === "DELETE" && url.pathname === "/api/annotate") {
|
|
350
|
+
const annPath = url.searchParams.get("path") || "";
|
|
351
|
+
enqueue(() => {
|
|
352
|
+
deleteAnnotation(dataRoot, s, annPath);
|
|
353
|
+
s.removeAnnotation(storePath(strToSegs(annPath)));
|
|
354
|
+
announce({ removed: [relFileOf(annPath)] });
|
|
355
|
+
})
|
|
356
|
+
.then(() => sendJson(res, 200, { ok: true }))
|
|
357
|
+
.catch((e) => sendJson(res, 400, { error: String((e as Error).message || e) }));
|
|
358
|
+
return;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Create a NAMED TAG (a WRITE path — the picker's create-on-miss): add
|
|
362
|
+
// `<name>: !!<*yamlover/$defs/tag>` to the taxonomy body at the project's default tags
|
|
363
|
+
// location (settings.yamlover; `/tags` by default → `<location>/.yamlover/body.yamlover`),
|
|
364
|
+
// then reconcile so it joins the graph. The direct schema attach makes the node an
|
|
365
|
+
// `x-yamlover-tag` wherever the taxonomy lives — like an annotation, a created tag may be
|
|
366
|
+
// moved anywhere and keeps working. Idempotent: a tag already at that path is returned
|
|
367
|
+
// as-is. Body: { name }.
|
|
368
|
+
if (req.method === "POST" && url.pathname === "/api/tag") {
|
|
369
|
+
readBody(req)
|
|
370
|
+
.then((data) =>
|
|
371
|
+
enqueue(async () => {
|
|
372
|
+
const name = String((data as { name?: unknown })?.name ?? "").trim();
|
|
373
|
+
if (!name) throw new Error("tag needs a non-empty name");
|
|
374
|
+
const segs = [...strToSegs(settings.tags.location), name];
|
|
375
|
+
const tagPath = segsToStr(segs);
|
|
376
|
+
const existing = s.node(storePath(segs));
|
|
377
|
+
if (existing) {
|
|
378
|
+
if (existing.format !== TAG_FORMAT) throw new Error(`a node already exists at ${tagPath} and is not a tag`);
|
|
379
|
+
const color = s.node(storePath(segs) + ":color")?.value;
|
|
380
|
+
return { path: tagPath, name, color: typeof color === "string" ? color : null, created: false };
|
|
381
|
+
}
|
|
382
|
+
// Index INCREMENTALLY (the annotate pattern — not a full rebuild, which stats the
|
|
383
|
+
// whole tree and blocks the picker for seconds on a big root); the watcher's
|
|
384
|
+
// reconcile re-walks the edited body and trues the rows up moments later.
|
|
385
|
+
const written = writeTag(dataRoot, settings.tags.location, name);
|
|
386
|
+
s.addTag(storePath(strToSegs(settings.tags.location)), name, written.pos, written.node);
|
|
387
|
+
if (s.node(storePath(segs))?.format !== TAG_FORMAT) throw new Error(`the created tag did not index as a tag: ${tagPath}`);
|
|
388
|
+
announce(written.createdFile ? { added: [written.file] } : { changed: [written.file] });
|
|
389
|
+
return { path: tagPath, name, color: null, created: true };
|
|
390
|
+
}),
|
|
391
|
+
)
|
|
392
|
+
.then((body) => sendJson(res, 201, body))
|
|
393
|
+
.catch((e) => sendJson(res, 400, { error: String((e as Error).message || e) }));
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Upload a pasted file, TEXT, or RICH content (a WRITE path). A file onto a DIRECTORY
|
|
398
|
+
// page → it lands in that directory; onto a CHAPTER page → it lands in the chapter's
|
|
399
|
+
// owning directory AND a `*…` pointer to it is appended as the chapter's last chunk.
|
|
400
|
+
// TEXT onto a chapter → the text itself is appended as a new chunk (no file); anywhere
|
|
401
|
+
// else → a new chapter .yamlover file in the nearest directory. RICH (an HTML selection:
|
|
402
|
+
// text + image chunks + heading-nested subchapters) onto a chapter → chunks append to
|
|
403
|
+
// `chunks:`, subchapters to `children:`; anywhere else → a new chapter (directory-backed
|
|
404
|
+
// when it carries files). Body: { path, filename, contentBase64 } | { path, text } |
|
|
405
|
+
// { path, rich }. A new file / edited chapter source needs the graph re-walked — a
|
|
406
|
+
// manifest-cached reconcile, so only the new/edited files are read.
|
|
407
|
+
if (req.method === "POST" && url.pathname === "/api/paste") {
|
|
408
|
+
readBody(req)
|
|
409
|
+
.then((data) =>
|
|
410
|
+
enqueue(async () => {
|
|
411
|
+
const result = handlePaste(dataRoot, s, data as PasteInput);
|
|
412
|
+
broadcast(await doReindex());
|
|
413
|
+
scheduleHasher();
|
|
414
|
+
return result;
|
|
415
|
+
}),
|
|
416
|
+
)
|
|
417
|
+
.then((result) => sendJson(res, 201, result))
|
|
418
|
+
.catch((e) => sendJson(res, 400, { error: String((e as Error).message || e) }));
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// Move/rename a file or directory (a WRITE path — the engine-MEDIATED tier): the engine
|
|
423
|
+
// relocates the FS object AND rewrites every inbound `*`/`~` pointer in the source files
|
|
424
|
+
// (surgical span edits; ENGINE.md "a move rewrites references"). Body: { from, to } as
|
|
425
|
+
// JSON paths addressing FS-level nodes (keyed segments only — no positions).
|
|
426
|
+
if (req.method === "POST" && url.pathname === "/api/mv") {
|
|
427
|
+
readBody(req)
|
|
428
|
+
.then((data) =>
|
|
429
|
+
enqueue(async () => {
|
|
430
|
+
const { from, to } = data as { from?: string; to?: string };
|
|
431
|
+
const rel = (p: string, what: string): string => {
|
|
432
|
+
const segs = strToSegs(p);
|
|
433
|
+
if (segs.length === 0) throw new Error(`mv: ${what} must name a file or directory`);
|
|
434
|
+
if (segs.some((g) => typeof g === "number")) throw new Error(`mv: ${what} must be a file/directory path (no positions)`);
|
|
435
|
+
return segs.join("/");
|
|
436
|
+
};
|
|
437
|
+
const report = mv(dataRoot, rel(from ?? "", "from"), rel(to ?? "", "to"), { ignore });
|
|
438
|
+
const diff = await doReindex();
|
|
439
|
+
broadcast(diff);
|
|
440
|
+
return { ...report, diff };
|
|
441
|
+
}),
|
|
442
|
+
)
|
|
443
|
+
.then((body) => sendJson(res, 200, body))
|
|
444
|
+
.catch((e) => sendJson(res, 400, { error: String((e as Error).message || e) }));
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const segs = strToSegs(url.searchParams.get("path") || ":");
|
|
449
|
+
const p = storePath(segs);
|
|
450
|
+
const depth = parseDepth(url.searchParams.get("depth"));
|
|
451
|
+
|
|
452
|
+
if (url.pathname === "/api/info") {
|
|
453
|
+
sendJson(res, 200, { root: rootName });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// The annotations whose `target` is this material (the engine's reverse link).
|
|
458
|
+
if (url.pathname === "/api/annotations") {
|
|
459
|
+
sendJson(res, 200, annotationsFor(dataRoot, s, segs));
|
|
460
|
+
return;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
// The materials filed under this tag (annotations resolved to their `target`; deduped) —
|
|
464
|
+
// the explorer renderer's member list for a tag page.
|
|
465
|
+
if (url.pathname === "/api/tagged") {
|
|
466
|
+
const row = s.node(p);
|
|
467
|
+
if (!row || row.format !== TAG_FORMAT) return notFound(res, url);
|
|
468
|
+
sendJson(res, 200, taggedMaterials(dataRoot, s, p));
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
if (url.pathname === "/api/tree") {
|
|
473
|
+
const row = s.node(p);
|
|
474
|
+
if (!row) return notFound(res, url);
|
|
475
|
+
const label = segs.length === 0 ? rootName : labelFor(s, p, segs[segs.length - 1]);
|
|
476
|
+
sendJson(res, 200, buildTree(dataRoot, s, segs, label, depth ?? 3));
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (url.pathname === "/api/blob") {
|
|
481
|
+
const file = path.join(dataRoot, ...segs.map(String));
|
|
482
|
+
if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) return notFound(res, url);
|
|
483
|
+
// STREAM the bytes — a readFileSync of a big PDF/video would block the event loop
|
|
484
|
+
// (and with it every other request and the Vite HMR socket) for its whole read.
|
|
485
|
+
res.statusCode = 200;
|
|
486
|
+
res.setHeader("Content-Type", s.node(p)?.format ?? formatFromExt(file) ?? "application/octet-stream");
|
|
487
|
+
res.setHeader("Content-Length", String(fs.statSync(file).size));
|
|
488
|
+
const stream = fs.createReadStream(file);
|
|
489
|
+
stream.on("error", () => res.destroy());
|
|
490
|
+
stream.pipe(res);
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const row = s.node(p);
|
|
495
|
+
if (!row) return notFound(res, url);
|
|
496
|
+
const viewDepth = depth ?? 1;
|
|
497
|
+
const kind = displayKind(s, p, row);
|
|
498
|
+
|
|
499
|
+
if (url.pathname === "/api/json") {
|
|
500
|
+
const wantBytes = kind === "binary" && url.searchParams.get("binary") === "1";
|
|
501
|
+
sendJson(res, 200, {
|
|
502
|
+
path: segsToStr(segs),
|
|
503
|
+
type: tocType(s, p, row),
|
|
504
|
+
format: row.format ?? null,
|
|
505
|
+
concrete: concreteOf(dataRoot, segs, row), // dir | yamlover | null (stat-derived; engine tracks no per-node concrete yet)
|
|
506
|
+
documentPath: documentPath(s, segs), // nearest enclosing document root (for `/…` links)
|
|
507
|
+
title: titleOf(s, p),
|
|
508
|
+
description: null,
|
|
509
|
+
value: wantBytes ? binaryContent(dataRoot, segs, row) : projectValue(dataRoot, s, segs, viewDepth, true),
|
|
510
|
+
relations: buildRelations(dataRoot, s, segs),
|
|
511
|
+
});
|
|
512
|
+
} else if (url.pathname === "/api/schema") {
|
|
513
|
+
sendJson(res, 200, projectSchema(dataRoot, s, segs, viewDepth, true));
|
|
514
|
+
} else {
|
|
515
|
+
notFound(res, url);
|
|
516
|
+
}
|
|
517
|
+
} catch (exc) {
|
|
518
|
+
sendJson(res, 400, { error: (exc as Error).message || String(exc) });
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
// Tear-down for embedders/tests: stop the watcher + hasher, drop SSE subscribers, close the
|
|
522
|
+
// DB. `ready` resolves when the initial background index lands (tests await it; the bin
|
|
523
|
+
// catches it so a failed index cannot crash as an unhandled rejection).
|
|
524
|
+
return Object.assign(handler, {
|
|
525
|
+
ready,
|
|
526
|
+
close: (): void => {
|
|
527
|
+
closed = true;
|
|
528
|
+
stopWatch?.();
|
|
529
|
+
for (const r of sseClients) r.end();
|
|
530
|
+
sseClients.clear();
|
|
531
|
+
store0.close();
|
|
532
|
+
},
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// --------------------------------------------------------------------------- //
|
|
537
|
+
// Projection (Store rows → the client's value / schema / tree / marker shapes)
|
|
538
|
+
// --------------------------------------------------------------------------- //
|
|
539
|
+
|
|
540
|
+
/** The (type) label shown in the TOC/header — the schema-style {@link typeName}. */
|
|
541
|
+
function tocType(s: Store, p: string, row: NodeRow): string {
|
|
542
|
+
return typeName(s, p, row);
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
/** How the node at `segs` is stored on disk, as far as a stat can tell: `"yamlover"` (a directory
|
|
546
|
+
* with a `.yamlover/` marker), `"dir"` (a plain folder), or null (not a filesystem directory —
|
|
547
|
+
* files and interior nodes alike; the engine does not track per-node concrete yet). Only a
|
|
548
|
+
* mapping can be a directory, and positional segments never name FS entries, so most nodes
|
|
549
|
+
* short-circuit without touching the disk. */
|
|
550
|
+
function concreteOf(dataRoot: string, segs: Seg[], row: NodeRow): "dir" | "yamlover" | null {
|
|
551
|
+
if (row.type !== "mapping") return null;
|
|
552
|
+
if (segs.some((g) => typeof g === "number")) return null;
|
|
553
|
+
const abs = path.resolve(dataRoot, ...segs.map(String));
|
|
554
|
+
let st: fs.Stats | undefined;
|
|
555
|
+
try { st = fs.statSync(abs); } catch { return null; }
|
|
556
|
+
if (!st.isDirectory()) return null;
|
|
557
|
+
return fs.existsSync(path.join(abs, ".yamlover")) ? "yamlover" : "dir";
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// --------------------------------------------------------------------------- //
|
|
561
|
+
// Relation direction. A relation has ONE natural direction (upstream → downstream), regardless of
|
|
562
|
+
// which side authored it: a forward `*` ref / containment runs from→to; a `~` back-edge is stored
|
|
563
|
+
// reversed (it is authored on the downstream side, pointing back up), so its nature is to→from.
|
|
564
|
+
// A node's DOWNSTREAM relations (it is the natural source) are its children/value, shown below the
|
|
565
|
+
// <hr>; its UPSTREAM relations (it is the natural target) are shown above it. Authoring a relation
|
|
566
|
+
// both ways (forward at the parent AND `~` at the child) yields two stored edges for ONE relation,
|
|
567
|
+
// so each direction is de-duplicated by (label, other end). This split is used everywhere — the
|
|
568
|
+
// value/schema projections and the relations panel — so nothing has to special-case `~`.
|
|
569
|
+
// --------------------------------------------------------------------------- //
|
|
570
|
+
|
|
571
|
+
const relKey = (label: string | null, other: string): string => `${label ?? ""}${other}`;
|
|
572
|
+
|
|
573
|
+
/** A node's DOWNSTREAM entries (it is the natural source), in source order: its containment
|
|
574
|
+
* children and forward `*` refs (authored here, positioned), then any `~` back-edges that target
|
|
575
|
+
* it from elsewhere (authored on the downstream node, so unpositioned → appended, ordered
|
|
576
|
+
* lexicographically by the member's path — URIs.md §`~-`).
|
|
577
|
+
*
|
|
578
|
+
* Dedup is by identity, which only a LABEL provides: a same-label both-ways pair (`L: *x` +
|
|
579
|
+
* `~L: …`) is one relation authored twice → one entry. A KEYLESS membership (label null, the
|
|
580
|
+
* `~-` form) has no identity and is ADDITIVE — every declaration appends an element, even
|
|
581
|
+
* alongside a forward `- *member` (lists repeat) — unless the container is a `!!set` /
|
|
582
|
+
* `uniqueItems: true` (NodeMeta.set), where membership is by target and ALL duplicates
|
|
583
|
+
* (forward+forward, forward+reverse, reverse+reverse) collapse. */
|
|
584
|
+
function downstreamEntries(s: Store, p: string): { to: string; label: string | null; pos: number | null; kind: EdgeRow["kind"] }[] {
|
|
585
|
+
const isSet = !!s.node(p)?.meta?.set;
|
|
586
|
+
let own = s.entries(p).filter((e) => e.kind !== "back"); // contain + forward ref, ordered by pos
|
|
587
|
+
const seen = new Set(own.map((e) => relKey(e.label, e.to)));
|
|
588
|
+
if (isSet) {
|
|
589
|
+
const kept = new Set<string>(); // set semantics: an element appears at most once
|
|
590
|
+
own = own.filter((e) => { const k = relKey(e.label, e.to); if (kept.has(k)) return false; kept.add(k); return true; });
|
|
591
|
+
}
|
|
592
|
+
const out: { to: string; label: string | null; pos: number | null; kind: EdgeRow["kind"] }[] = [...own];
|
|
593
|
+
const backs = s.relationships(p).in
|
|
594
|
+
.filter((e) => e.kind === "back" && e.from)
|
|
595
|
+
.sort((a, b) => (a.from < b.from ? -1 : a.from > b.from ? 1 : 0)); // lexicographic by member path
|
|
596
|
+
for (const e of backs) {
|
|
597
|
+
const k = relKey(e.label, e.from); // natural target of a back-edge is its `from`
|
|
598
|
+
if (e.label != null || isSet) {
|
|
599
|
+
if (seen.has(k)) continue;
|
|
600
|
+
seen.add(k);
|
|
601
|
+
}
|
|
602
|
+
out.push({ to: e.from, label: e.label, pos: null, kind: "ref" });
|
|
603
|
+
}
|
|
604
|
+
return out;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/** A node value as plain JSON-able data. `depth` limits nesting; a container past the budget,
|
|
608
|
+
* or any non-top binary, becomes a `$yamloverLink` marker the client navigates on click. */
|
|
609
|
+
function projectValue(dataRoot: string, s: Store, segs: Seg[], depth: number, top: boolean): unknown {
|
|
610
|
+
const p = storePath(segs);
|
|
611
|
+
const row = s.node(p)!;
|
|
612
|
+
const k = displayKind(s, p, row);
|
|
613
|
+
if (!top && depth <= 0) return linkMarker(dataRoot, s, segs);
|
|
614
|
+
if (k === "binary" && !top) return linkMarker(dataRoot, s, segs);
|
|
615
|
+
if (k === "binary") return { size: row.size, format: row.format }; // top binary header
|
|
616
|
+
// DOWNSTREAM entries in order — containment recursed, a forward `*` ref or an incoming `~`
|
|
617
|
+
// back-edge shown as a link marker to the downstream node (so a `chunks` array mixing inline
|
|
618
|
+
// blocks and `*sample.png` pointers is whole, and a child reached only by `~` still appears).
|
|
619
|
+
const kids = downstreamEntries(s, p);
|
|
620
|
+
const project = (c: { to: string; label: string | null; pos: number | null; kind: string }) =>
|
|
621
|
+
c.kind === "contain"
|
|
622
|
+
? projectValue(dataRoot, s, [...segs, c.label ?? c.pos ?? 0], depth - 1, false)
|
|
623
|
+
: linkMarker(dataRoot, s, storePathToSegs(c.to)); // pointer → a marker to where it resolves
|
|
624
|
+
if (k === "array") return kids.map(project);
|
|
625
|
+
if (k === "omni" || k === "mix") {
|
|
626
|
+
// A `$yamloverMixed` marker preserving source order: each entry is positional (`key: null` →
|
|
627
|
+
// a `- item`) or keyed (`key: "scale"` → `scale: …`); an omni also carries its self-value.
|
|
628
|
+
const entries = kids.map((c) => ({ key: c.label, value: project(c) }));
|
|
629
|
+
const marker: Record<string, unknown> = { kind: k, entries };
|
|
630
|
+
if (k === "omni") marker.value = row.value; // the node's own scalar self-value (the `!!omni 5`)
|
|
631
|
+
return { [MIXED_KEY]: marker };
|
|
632
|
+
}
|
|
633
|
+
if (k === "object") {
|
|
634
|
+
const out: Record<string, unknown> = {};
|
|
635
|
+
for (const c of kids) out[c.label ?? String(c.pos)] = project(c);
|
|
636
|
+
return out;
|
|
637
|
+
}
|
|
638
|
+
return row.value; // scalar
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/** The instance schema (every value `v` → `{const: v}`); containers past depth = link markers. */
|
|
642
|
+
function projectSchema(dataRoot: string, s: Store, segs: Seg[], depth: number, top: boolean): unknown {
|
|
643
|
+
const p = storePath(segs);
|
|
644
|
+
const row = s.node(p)!;
|
|
645
|
+
const k = displayKind(s, p, row);
|
|
646
|
+
if ((k === "object" || k === "array" || k === "mix" || k === "omni") && depth <= 0) return linkMarker(dataRoot, s, segs);
|
|
647
|
+
if (k === "binary" && !top) return linkMarker(dataRoot, s, segs);
|
|
648
|
+
const schema: Record<string, unknown> = { type: typeName(s, p, row) }; // object|array|binary|mixed|variant|<scalar>
|
|
649
|
+
if (row.format) schema.format = row.format;
|
|
650
|
+
const kids = downstreamEntries(s, p);
|
|
651
|
+
const sub = (c: { to: string; label: string | null; pos: number | null; kind: string }) =>
|
|
652
|
+
c.kind === "contain" ? projectSchema(dataRoot, s, [...segs, c.label ?? c.pos ?? 0], depth - 1, false) : linkMarker(dataRoot, s, storePathToSegs(c.to));
|
|
653
|
+
if (k === "object" || k === "mix" || k === "omni") {
|
|
654
|
+
// mixed/variant fields: keyless entries keep their `[pos]` key, keyed ones their name; a
|
|
655
|
+
// variant (omni) also pins its self-value. (Order is the property insertion order.)
|
|
656
|
+
const props: Record<string, unknown> = {};
|
|
657
|
+
for (const c of kids) props[c.label ?? `[${c.pos}]`] = sub(c);
|
|
658
|
+
schema.properties = props;
|
|
659
|
+
if (k === "omni") schema.value = row.value;
|
|
660
|
+
} else if (k === "array") {
|
|
661
|
+
schema.prefixItems = kids.map(sub);
|
|
662
|
+
schema.items = false;
|
|
663
|
+
} else if (k === "binary") {
|
|
664
|
+
schema.const = { size: row.size, format: row.format };
|
|
665
|
+
} else {
|
|
666
|
+
schema.const = row.value;
|
|
667
|
+
}
|
|
668
|
+
const t = titleOf(s, p);
|
|
669
|
+
if (t) schema.title = t;
|
|
670
|
+
return schema;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** A `$yamloverLink` marker for the node at `segs` (a navigable summary). */
|
|
674
|
+
function linkMarker(dataRoot: string, s: Store, segs: Seg[]): Record<string, unknown> {
|
|
675
|
+
const p = storePath(segs);
|
|
676
|
+
const row = s.node(p)!;
|
|
677
|
+
const k = displayKind(s, p, row);
|
|
678
|
+
const info: Record<string, unknown> = { kind: k, type: tocType(s, p, row), path: segsToStr(segs) };
|
|
679
|
+
if (row.format) info.format = row.format;
|
|
680
|
+
const concrete = concreteOf(dataRoot, segs, row);
|
|
681
|
+
if (concrete) info.concrete = concrete; // a folder child renders with a folder icon
|
|
682
|
+
const title = titleOf(s, p);
|
|
683
|
+
if (title) info.title = title;
|
|
684
|
+
if (k === "binary") info.size = row.size;
|
|
685
|
+
else if (k === "scalar") info.value = row.value;
|
|
686
|
+
else if (k === "omni" || k === "mix") {
|
|
687
|
+
info.count = ownedEntries(s, p).length; // owned items + fields (reverse members excluded)
|
|
688
|
+
if (k === "omni") info.value = row.value; // the self-scalar, for the link label
|
|
689
|
+
} else info.count = s.children(p).length;
|
|
690
|
+
if (row.format === TAG_FORMAT) {
|
|
691
|
+
// a pure color tag's explicit color rides the link, so badges color correctly everywhere
|
|
692
|
+
const c = s.node(p + ":color")?.value;
|
|
693
|
+
if (typeof c === "string") info.color = c;
|
|
694
|
+
}
|
|
695
|
+
return { [LINK_KEY]: info };
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
const segsEqual = (a: Seg[], b: Seg[]): boolean => a.length === b.length && a.every((x, i) => x === b[i]);
|
|
699
|
+
|
|
700
|
+
/** An upstream node's path written in the scope it has FROM the current node's document frame:
|
|
701
|
+
* document-relative (`:eve`) when it lives in the same document, else a project-scope link
|
|
702
|
+
* (`::examples:…`) — mirroring the colon scope ladder (SEPARATOR.md: `:` = document root,
|
|
703
|
+
* `::` = project). */
|
|
704
|
+
function scopedPath(s: Store, src: Seg[], currentDoc: Seg[]): string {
|
|
705
|
+
if (segsEqual(documentRootSegs(s, src), currentDoc)) return segsToStr(src.slice(currentDoc.length)); // `:…`
|
|
706
|
+
return "::" + segsToStr(src).slice(1); // `::…` — a project-scope link
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/** The relations panel: this node's UPSTREAM relations — those for which it is the natural target.
|
|
710
|
+
* Led by the containment parent as `..`, then each `*`/`~` upstream source: a forward ref authored
|
|
711
|
+
* AT the source (stored into this node) or a `~` back-edge authored here pointing at the source
|
|
712
|
+
* (stored out of it) — the same relation either way, so deduped by source + label. Each is keyed
|
|
713
|
+
* by the path it has from this node's document frame, with a link to its summary; a source that is
|
|
714
|
+
* a tag node is peeled into a header badge by splitTagRefs. (A tag is upstream of what it files —
|
|
715
|
+
* the membership `~tag` back-edge lands here naturally, no special-casing.) */
|
|
716
|
+
function buildRelations(dataRoot: string, s: Store, segs: Seg[]): Record<string, unknown> {
|
|
717
|
+
const p = storePath(segs);
|
|
718
|
+
const out: Record<string, unknown> = {};
|
|
719
|
+
const put = (label: string, marker: unknown) => {
|
|
720
|
+
let k = label;
|
|
721
|
+
for (let i = 2; k in out; i++) k = `${label} (${i})`;
|
|
722
|
+
out[k] = marker;
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
// The containment parent — the upstream containment relation, always the primary way up.
|
|
726
|
+
if (segs.length > 0) put("..", linkMarker(dataRoot, s, segs.slice(0, -1)));
|
|
727
|
+
|
|
728
|
+
// Upstream `*`/`~` sources (this node is the natural target), deduped across forward+reverse
|
|
729
|
+
// authoring. A forward ref INTO p has its source at `from`; a `~` back-edge OUT of p (stored
|
|
730
|
+
// reversed) has its source at `to`.
|
|
731
|
+
const currentDoc = documentRootSegs(s, segs);
|
|
732
|
+
const { out: outEdges, in: inEdges } = s.relationships(p);
|
|
733
|
+
const upstream = new Map<string, string>(); // relKey → source store-path
|
|
734
|
+
const addUp = (src: string | null, label: string | null) => {
|
|
735
|
+
if (src) upstream.set(relKey(label, src), src);
|
|
736
|
+
};
|
|
737
|
+
for (const e of inEdges) if (e.kind === "ref") addUp(e.from, e.label); // forward ref INTO p
|
|
738
|
+
for (const e of outEdges) if (e.kind === "back") addUp(e.to, e.label); // `~` back-edge OUT of p
|
|
739
|
+
for (const src of upstream.values()) {
|
|
740
|
+
const segs2 = storePathToSegs(src);
|
|
741
|
+
put(scopedPath(s, segs2, currentDoc), linkMarker(dataRoot, s, segs2));
|
|
742
|
+
}
|
|
743
|
+
return out;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/** A binary leaf's bytes as a base64 payload (only when the leaf itself is selected). */
|
|
747
|
+
function binaryContent(dataRoot: string, segs: Seg[], row: NodeRow): Record<string, unknown> {
|
|
748
|
+
const file = path.join(dataRoot, ...segs.map(String));
|
|
749
|
+
const bytes = fs.existsSync(file) ? fs.readFileSync(file) : Buffer.alloc(0);
|
|
750
|
+
return { [BINARY_KEY]: { format: row.format ?? null, size: row.size ?? bytes.length, base64: bytes.toString("base64") } };
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
interface TreeNode {
|
|
754
|
+
path: string; label: string; type: string; format: string | null;
|
|
755
|
+
concrete: string | null; hasChildren: boolean; children: TreeNode[];
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** The TOC subtree rooted at `segs`, `depth` levels deep (every node listed). */
|
|
759
|
+
function buildTree(dataRoot: string, s: Store, segs: Seg[], label: string, depth: number): TreeNode {
|
|
760
|
+
const p = storePath(segs);
|
|
761
|
+
const row = s.node(p)!;
|
|
762
|
+
const node: TreeNode = {
|
|
763
|
+
path: segsToStr(segs),
|
|
764
|
+
label,
|
|
765
|
+
type: tocType(s, p, row),
|
|
766
|
+
format: row.format ?? null,
|
|
767
|
+
concrete: concreteOf(dataRoot, segs, row),
|
|
768
|
+
hasChildren: s.hasChildren(p),
|
|
769
|
+
children: [],
|
|
770
|
+
};
|
|
771
|
+
if (s.hasChildren(p) && depth > 0) {
|
|
772
|
+
for (const c of s.children(p)) {
|
|
773
|
+
const seg = c.label ?? c.pos ?? 0;
|
|
774
|
+
node.children.push(buildTree(dataRoot, s, [...segs, seg], labelFor(s, c.to, seg), depth - 1));
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return node;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** A node's tree label: an instance `title` child, else the key / `[index]`. */
|
|
781
|
+
function labelFor(s: Store, p: string, keyOrIdx: Seg): string {
|
|
782
|
+
const t = titleOf(s, p);
|
|
783
|
+
if (t) return t;
|
|
784
|
+
return typeof keyOrIdx === "number" ? `[${keyOrIdx}]` : keyOrIdx;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// --------------------------------------------------------------------------- //
|
|
788
|
+
// Annotations — graph-native TAG APPLICATIONS: each is a yamlover object under
|
|
789
|
+
// `<root>/annotations/`, pointing (`target: *//…`) at its material and holding a keyless `~-`
|
|
790
|
+
// membership in its applied tag; a material's annotations are the inverse of the target edges.
|
|
791
|
+
// --------------------------------------------------------------------------- //
|
|
792
|
+
|
|
793
|
+
const TAG_FORMAT = "x-yamlover-tag";
|
|
794
|
+
|
|
795
|
+
interface AnnotationInput {
|
|
796
|
+
target: string; // the material's JSON path (e.g. "/60-simple-chapter.yamlover")
|
|
797
|
+
tag: string; // the applied tag's JSON path (e.g. "/yamlover/tags/colors/yellow")
|
|
798
|
+
selector?: Record<string, unknown>; // { type: "text", exact, prefix, suffix } | { type:"rect", … }; absent = whole node
|
|
799
|
+
description?: string; // the per-application comment
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** The tag an annotation applies — its keyless `back` edge to an `x-yamlover-tag` node —
|
|
803
|
+
* projected as { path, name, color } (color = the tag's explicit `color`, else null: the
|
|
804
|
+
* client derives a hue from the name). Null for a legacy annotation with no tag. */
|
|
805
|
+
function appliedTag(s: Store, annStorePath: string): { path: string; name: string; color: string | null } | null {
|
|
806
|
+
const e = s.relationships(annStorePath).out.find(
|
|
807
|
+
(t) => t.kind === "back" && t.label === null && s.node(t.to)?.format === TAG_FORMAT,
|
|
808
|
+
);
|
|
809
|
+
if (!e) return null;
|
|
810
|
+
const segs = storePathToSegs(e.to);
|
|
811
|
+
const color = s.node(e.to + ":color")?.value;
|
|
812
|
+
return { path: segsToStr(segs), name: String(segs[segs.length - 1] ?? ""), color: typeof color === "string" ? color : null };
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
/** The annotations whose `target` resolves to this material — the incoming `ref` edges from
|
|
816
|
+
* `x-yamlover-annotation` nodes — each projected to its full object (selector, description,
|
|
817
|
+
* created) plus its applied `tag` { path, name, color }. */
|
|
818
|
+
function annotationsFor(dataRoot: string, s: Store, segs: Seg[]): unknown[] {
|
|
819
|
+
const p = storePath(segs);
|
|
820
|
+
const out: unknown[] = [];
|
|
821
|
+
for (const e of s.relationships(p).in) {
|
|
822
|
+
if (e.kind !== "ref") continue;
|
|
823
|
+
const src = s.node(e.from);
|
|
824
|
+
if (src?.format !== "x-yamlover-annotation") continue;
|
|
825
|
+
const aSegs = storePathToSegs(e.from);
|
|
826
|
+
out.push({
|
|
827
|
+
path: segsToStr(aSegs),
|
|
828
|
+
tag: appliedTag(s, e.from),
|
|
829
|
+
...(projectValue(dataRoot, s, aSegs, 6, true) as Record<string, unknown>),
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
return out;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** The MATERIALS filed under a tag — every node holding a `~` membership in it, with an
|
|
836
|
+
* annotation (one tag APPLICATION) resolved to its `target` material. Deduped by material:
|
|
837
|
+
* two annotations applying the same tag to one node, or a direct `~- *tag` alongside an
|
|
838
|
+
* annotation, show the material once. Subtags are containment children, not memberships —
|
|
839
|
+
* they never appear here. Ordered lexicographically by the member's path, like
|
|
840
|
+
* {@link downstreamEntries}' back-edge tail. */
|
|
841
|
+
function taggedMaterials(dataRoot: string, s: Store, tagStorePath: string): unknown[] {
|
|
842
|
+
const seen = new Set<string>();
|
|
843
|
+
const out: unknown[] = [];
|
|
844
|
+
const backs = s.relationships(tagStorePath).in
|
|
845
|
+
.filter((e) => e.kind === "back" && e.from)
|
|
846
|
+
.sort((a, b) => (a.from < b.from ? -1 : a.from > b.from ? 1 : 0));
|
|
847
|
+
for (const e of backs) {
|
|
848
|
+
let material = e.from;
|
|
849
|
+
if (s.node(e.from)?.format === "x-yamlover-annotation") {
|
|
850
|
+
const t = s.relationships(e.from).out.find((o) => o.kind === "ref" && o.label === "target");
|
|
851
|
+
if (!t) continue; // a dangling annotation — no resolvable material
|
|
852
|
+
material = t.to;
|
|
853
|
+
}
|
|
854
|
+
if (seen.has(material) || !s.node(material)) continue;
|
|
855
|
+
seen.add(material);
|
|
856
|
+
out.push(linkMarker(dataRoot, s, storePathToSegs(material)));
|
|
857
|
+
}
|
|
858
|
+
return out;
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
/** A client JSON path (`:key[0]:x`, keys PERCENT-ENCODED) as project-scoped COLON pointer
|
|
862
|
+
* raw text (`::key[0]:x`, keys RAW — quoted when spacey): pointer steps are matched against
|
|
863
|
+
* store keys verbatim — an encoded key would go dangling on the next re-walk. */
|
|
864
|
+
function pointerRaw(clientPath: string): string {
|
|
865
|
+
let out = "";
|
|
866
|
+
for (const seg of strToSegs(clientPath)) {
|
|
867
|
+
out += typeof seg === "number" ? `[${seg}]` : (out === "" ? "" : ":") + colonSegment(seg);
|
|
868
|
+
}
|
|
869
|
+
return "::" + out;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/** Serialize a value as a yamlover scalar (double-quoted strings round-trip through the parser). */
|
|
873
|
+
function yScalar(v: unknown): string {
|
|
874
|
+
return typeof v === "number" || typeof v === "boolean" ? String(v) : JSON.stringify(String(v ?? ""));
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
/** Persist a new annotation (one tag application) as a yamlover file under the project's default
|
|
878
|
+
* annotation location (`settings.yamlover`; `/annotations` unless configured); returns its node
|
|
879
|
+
* path. The material and the applied tag are referenced with project-scoped deref pointers so
|
|
880
|
+
* the engine reverse-links them on re-index (a leading star + a "//path" project path; the tag
|
|
881
|
+
* as an ordinal path anchor `&//path/to/tag[]` — the deprecated `~-` spelling still parses).
|
|
882
|
+
* The location is only the CREATION default — an annotation file works from any directory. */
|
|
883
|
+
function writeAnnotation(dataRoot: string, location: string, a: AnnotationInput): string {
|
|
884
|
+
if (!a?.target || !a?.tag) throw new Error("annotation needs a target and a tag");
|
|
885
|
+
const dir = path.resolve(dataRoot, ...strToSegs(location).map(String));
|
|
886
|
+
const root = path.resolve(dataRoot);
|
|
887
|
+
if (dir !== root && !dir.startsWith(root + path.sep)) throw new Error("annotation location escapes the data root");
|
|
888
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
889
|
+
const id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
|
|
890
|
+
const file = `${id}.yamlover`;
|
|
891
|
+
const lines = [
|
|
892
|
+
"!!<*yamlover/$defs/annotation>",
|
|
893
|
+
`target: ${pointerToken(pointerRaw(a.target))}`,
|
|
894
|
+
anchorToken(`${pointerRaw(a.tag)}[]`), // the applied tag holds me (ordinal path anchor)
|
|
895
|
+
];
|
|
896
|
+
if (a.selector) lines.push("selector:", ...Object.entries(a.selector).map(([k, v]) => ` ${k}: ${yScalar(v)}`));
|
|
897
|
+
if (a.description) lines.push(`description: ${yScalar(a.description)}`);
|
|
898
|
+
lines.push(`created: ${new Date().toISOString()}`, "");
|
|
899
|
+
fs.writeFileSync(path.join(dir, file), lines.join("\n"));
|
|
900
|
+
return `${location}:${file}`;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/** Persist a NEW named tag as a key of the tag-taxonomy body at the project's default tags
|
|
904
|
+
* location (`settings.yamlover`; `/tags` unless configured): `<location>/.yamlover/body.yamlover`
|
|
905
|
+
* gains a `<name>: !!<*yamlover/$defs/tag>` entry. The would-be body is PARSED before
|
|
906
|
+
* committing, so a name the yamlover syntax cannot hold as a plain key (one that vanishes into
|
|
907
|
+
* a comment, say) is refused instead of corrupting the taxonomy. */
|
|
908
|
+
function writeTag(
|
|
909
|
+
dataRoot: string,
|
|
910
|
+
location: string,
|
|
911
|
+
name: string,
|
|
912
|
+
): { node: IrNode; pos: number; file: string; createdFile: boolean } {
|
|
913
|
+
if (/[/\\\r\n:]/.test(name)) throw new Error("a tag name cannot contain '/', '\\', ':' or line breaks");
|
|
914
|
+
const root = path.resolve(dataRoot);
|
|
915
|
+
const dir = path.resolve(dataRoot, ...strToSegs(location).map(String), ".yamlover");
|
|
916
|
+
if (!dir.startsWith(root + path.sep)) throw new Error("tags location escapes the data root");
|
|
917
|
+
const file = path.join(dir, "body.yamlover");
|
|
918
|
+
const createdFile = !fs.existsSync(file);
|
|
919
|
+
const head = "# Named tags created from the annotation picker (settings.yamlover: tags.location).\n";
|
|
920
|
+
const existing = createdFile ? head : fs.readFileSync(file, "utf8");
|
|
921
|
+
const body = (existing === "" || existing.endsWith("\n") ? existing : existing + "\n") + `${name}: !!<*yamlover/$defs/tag>\n`;
|
|
922
|
+
const entries = parseYamlover(body, file).root.entries ?? [];
|
|
923
|
+
const pos = entries.findIndex((e) => e.key === name);
|
|
924
|
+
const entry = pos >= 0 ? entries[pos] : undefined;
|
|
925
|
+
if (!entry || isPointer(entry.value) || entry.value.meta?.schema === undefined) {
|
|
926
|
+
throw new Error(`cannot write a tag named ${JSON.stringify(name)}`);
|
|
927
|
+
}
|
|
928
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
929
|
+
fs.writeFileSync(file, body);
|
|
930
|
+
return { node: entry.value, pos, file: [...strToSegs(location).map(String), ".yamlover", "body.yamlover"].join("/"), createdFile };
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
/** Delete an annotation file given its node path. The guard is the GRAPH, not a directory: the
|
|
934
|
+
* node must be indexed as an `x-yamlover-annotation` and be a whole standalone `.yamlover` file
|
|
935
|
+
* (an annotation authored inline in a shared document cannot be deleted this way), inside the
|
|
936
|
+
* served root. So an annotation moved to any directory remains deletable. */
|
|
937
|
+
function deleteAnnotation(dataRoot: string, s: Store, annPath: string): void {
|
|
938
|
+
const segs = strToSegs(annPath);
|
|
939
|
+
if (!String(segs[segs.length - 1] ?? "").endsWith(".yamlover")) throw new Error("not an annotation file");
|
|
940
|
+
if (s.node(storePath(segs))?.format !== "x-yamlover-annotation") throw new Error("not an annotation node");
|
|
941
|
+
const root = path.resolve(dataRoot);
|
|
942
|
+
const file = path.resolve(dataRoot, ...segs.map(String));
|
|
943
|
+
if (!file.startsWith(root + path.sep)) throw new Error("outside the served root");
|
|
944
|
+
if (!fs.existsSync(file) || fs.statSync(file).isDirectory()) throw new Error("not an annotation file");
|
|
945
|
+
fs.rmSync(file, { force: true });
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
// --------------------------------------------------------------------------- //
|
|
949
|
+
// Paste / upload — drop a clipboard file OR plain text into the tree. A file: a directory target
|
|
950
|
+
// takes it as a new child; a chapter target takes it into its owning directory and gains a `*…`
|
|
951
|
+
// pointer chunk. Text: a chapter target gains it as an inline chunk (no file); any other target
|
|
952
|
+
// gets a new chapter .yamlover file in the nearest directory, the text as its one chunk.
|
|
953
|
+
// --------------------------------------------------------------------------- //
|
|
954
|
+
|
|
955
|
+
interface PasteInput {
|
|
956
|
+
path: string; // the page's node path (a directory or a chapter)
|
|
957
|
+
filename?: string; // file mode: the source filename (sanitized + de-duplicated server-side)
|
|
958
|
+
contentBase64?: string; // file mode: the file bytes, base64
|
|
959
|
+
text?: string; // text mode: the clipboard's plain text
|
|
960
|
+
rich?: unknown; // rich mode: an HTML selection as a chapter tree (see parseRich) — text +
|
|
961
|
+
// inline-file chunks, heading-nested children; the modes are mutually exclusive
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
/** Handle a paste/upload onto the node at `input.path`. Returns the new file's node path and,
|
|
965
|
+
* for a chapter, the chapter path + the chunk pointer appended to it. */
|
|
966
|
+
function handlePaste(dataRoot: string, s: Store, input: PasteInput): Record<string, unknown> {
|
|
967
|
+
const segs = strToSegs(input.path || ":");
|
|
968
|
+
const row = s.node(storePath(segs));
|
|
969
|
+
if (!row) throw new Error(`no such node: ${input.path}`);
|
|
970
|
+
|
|
971
|
+
if (input.rich != null) {
|
|
972
|
+
const rich = parseRich(input.rich);
|
|
973
|
+
if (row.format === "x-yamlover-chapter") return pasteRichIntoChapter(dataRoot, s, segs, rich);
|
|
974
|
+
return pasteRichAsChapter(dataRoot, segs, rich);
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
if (typeof input.text === "string") {
|
|
978
|
+
const text = input.text.replace(/\r\n?/g, "\n");
|
|
979
|
+
if (text.trim().length === 0) throw new Error("empty paste (no text)");
|
|
980
|
+
if (row.format === "x-yamlover-chapter") return pasteTextIntoChapter(dataRoot, s, segs, text);
|
|
981
|
+
return pasteTextAsChapterFile(dataRoot, segs, text);
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
const bytes = Buffer.from(input.contentBase64 || "", "base64");
|
|
985
|
+
if (bytes.length === 0) throw new Error("empty paste (no file bytes)");
|
|
986
|
+
const name = sanitizeName(input.filename ?? "");
|
|
987
|
+
|
|
988
|
+
if (row.format === "x-yamlover-chapter") return pasteIntoChapter(dataRoot, s, segs, name, bytes);
|
|
989
|
+
|
|
990
|
+
// a directory page, or a MEMBER of one (any non-chapter node): the file lands in the nearest
|
|
991
|
+
// enclosing directory. `open` marks the member case — the page is not the directory, so the
|
|
992
|
+
// client opens the new file (on a directory page it just refreshes in place).
|
|
993
|
+
const dirSegs = nearestDirSegs(dataRoot, segs);
|
|
994
|
+
if (!dirSegs) throw new Error("no enclosing directory to paste into");
|
|
995
|
+
const dir = path.resolve(dataRoot, ...dirSegs.map(String));
|
|
996
|
+
const final = uniqueName(dir, name);
|
|
997
|
+
writeInside(dataRoot, dir, final, bytes);
|
|
998
|
+
return { path: segsToStr([...dirSegs, final]), dir: segsToStr(dirSegs), open: dirSegs.length !== segs.length };
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/** The nearest enclosing filesystem directory at or above `segs` (the node itself when it is a
|
|
1002
|
+
* directory, else its closest ancestor that is one), as segments; null if none under the root. */
|
|
1003
|
+
function nearestDirSegs(dataRoot: string, segs: Seg[]): Seg[] | null {
|
|
1004
|
+
for (let i = segs.length; i >= 0; i--) {
|
|
1005
|
+
const sub = segs.slice(0, i);
|
|
1006
|
+
const abs = path.resolve(dataRoot, ...sub.map(String));
|
|
1007
|
+
if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) return sub;
|
|
1008
|
+
}
|
|
1009
|
+
return null;
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/** The .yamlover source holding the chapter at `segs` — directory-backed
|
|
1013
|
+
* (`.yamlover/body.yamlover`) or a standalone `*.yamlover` file — plus its document root. */
|
|
1014
|
+
function chapterSource(dataRoot: string, s: Store, segs: Seg[]): { docSegs: Seg[]; bodyFile: string; dirBacked: boolean } {
|
|
1015
|
+
const docSegs = documentRootSegs(s, segs);
|
|
1016
|
+
const docFs = path.resolve(dataRoot, ...docSegs.map(String));
|
|
1017
|
+
const dirBacked = fs.existsSync(docFs) && fs.statSync(docFs).isDirectory();
|
|
1018
|
+
const bodyFile = dirBacked ? path.join(docFs, ".yamlover", "body.yamlover") : docFs;
|
|
1019
|
+
if (!bodyFile.endsWith(".yamlover") || !fs.existsSync(bodyFile)) {
|
|
1020
|
+
throw new Error("unsupported chapter source (need a .yamlover body)");
|
|
1021
|
+
}
|
|
1022
|
+
return { docSegs, bodyFile, dirBacked };
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
/** A chapter paste: write the file into the chapter's owning directory, then append a pointer to
|
|
1026
|
+
* it as the chapter's last chunk (editing the .yamlover source). */
|
|
1027
|
+
function pasteIntoChapter(dataRoot: string, s: Store, segs: Seg[], name: string, bytes: Buffer): Record<string, unknown> {
|
|
1028
|
+
const { docSegs, bodyFile, dirBacked } = chapterSource(dataRoot, s, segs);
|
|
1029
|
+
// the file lands in the doc-root dir (directory-backed) or beside the standalone chapter file.
|
|
1030
|
+
const writeDirSegs = dirBacked ? docSegs : docSegs.slice(0, -1);
|
|
1031
|
+
const writeDir = path.resolve(dataRoot, ...writeDirSegs.map(String));
|
|
1032
|
+
const final = uniqueName(writeDir, name);
|
|
1033
|
+
writeInside(dataRoot, writeDir, final, bytes);
|
|
1034
|
+
|
|
1035
|
+
// The chunk pointer: document-scoped (`*/file`) when the file sits inside the chapter's own
|
|
1036
|
+
// document (directory-backed); else a project-root link (`*//dir/file`) reaching the sibling.
|
|
1037
|
+
const fileSegs = [...writeDirSegs, final];
|
|
1038
|
+
const pointer = dirBacked ? `*/${final}` : `*/${segsToStr(fileSegs)}`;
|
|
1039
|
+
// The chapter's location WITHIN its document — alternating `children`,N pairs (empty = top-level).
|
|
1040
|
+
const within = segs.slice(docSegs.length);
|
|
1041
|
+
const src = fs.readFileSync(bodyFile, "utf8");
|
|
1042
|
+
fs.writeFileSync(bodyFile, appendToList(src, within, "chunks", (indent) => [`${" ".repeat(indent)}- ${pointer}`]));
|
|
1043
|
+
return { path: segsToStr(fileSegs), chapter: segsToStr(segs), pointer };
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/** A text paste onto a chapter: the text itself becomes the chapter's last chunk — no file is
|
|
1047
|
+
* written, only the .yamlover source gains an item. */
|
|
1048
|
+
function pasteTextIntoChapter(dataRoot: string, s: Store, segs: Seg[], text: string): Record<string, unknown> {
|
|
1049
|
+
const { docSegs, bodyFile } = chapterSource(dataRoot, s, segs);
|
|
1050
|
+
const within = segs.slice(docSegs.length);
|
|
1051
|
+
const src = fs.readFileSync(bodyFile, "utf8");
|
|
1052
|
+
fs.writeFileSync(bodyFile, appendToList(src, within, "chunks", (indent) => textChunkLines(text, indent)));
|
|
1053
|
+
return { path: segsToStr(segs), chapter: segsToStr(segs) };
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** A text paste onto anything that is NOT a chapter: a new chapter .yamlover file lands in the
|
|
1057
|
+
* nearest enclosing directory — title from the text's first line, the text as its one chunk. */
|
|
1058
|
+
function pasteTextAsChapterFile(dataRoot: string, segs: Seg[], text: string): Record<string, unknown> {
|
|
1059
|
+
const dirSegs = nearestDirSegs(dataRoot, segs);
|
|
1060
|
+
if (!dirSegs) throw new Error("no enclosing directory to paste into");
|
|
1061
|
+
const dir = path.resolve(dataRoot, ...dirSegs.map(String));
|
|
1062
|
+
const title = titleFromText(text);
|
|
1063
|
+
const final = uniqueName(dir, chapterFileName(title));
|
|
1064
|
+
const src = ["!!<*yamlover/$defs/chapter>", `title: ${JSON.stringify(title)}`, "chunks:", ...textChunkLines(text, 0), ""].join("\n");
|
|
1065
|
+
writeInside(dataRoot, dir, final, Buffer.from(src, "utf8"));
|
|
1066
|
+
return { path: segsToStr([...dirSegs, final]), dir: segsToStr(dirSegs), open: dirSegs.length !== segs.length };
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
// --- rich paste: an HTML selection as a chapter tree (text + image chunks, subchapters) ----- //
|
|
1070
|
+
|
|
1071
|
+
type RichItem = { text: string } | { name: string; bytes: Buffer };
|
|
1072
|
+
interface Rich {
|
|
1073
|
+
title: string | null;
|
|
1074
|
+
chunks: RichItem[];
|
|
1075
|
+
children: Array<Rich & { title: string }>;
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/** Validate + normalize the wire `rich` payload: chunks are {text} or {file:{name,
|
|
1079
|
+
* contentBase64}}, children recurse (each titled). Whitespace-only texts are dropped. */
|
|
1080
|
+
function parseRich(raw: unknown, depth = 0): Rich {
|
|
1081
|
+
if (depth > 8) throw new Error("rich paste: nesting too deep");
|
|
1082
|
+
const r = (raw ?? {}) as { title?: unknown; chunks?: unknown; children?: unknown };
|
|
1083
|
+
const chunks: RichItem[] = [];
|
|
1084
|
+
for (const c of Array.isArray(r.chunks) ? (r.chunks as Array<Record<string, unknown>>) : []) {
|
|
1085
|
+
if (typeof c?.text === "string") {
|
|
1086
|
+
if (c.text.trim()) chunks.push({ text: c.text.replace(/\r\n?/g, "\n") });
|
|
1087
|
+
continue;
|
|
1088
|
+
}
|
|
1089
|
+
const f = c?.file as { name?: unknown; contentBase64?: unknown } | undefined;
|
|
1090
|
+
if (f && typeof f.name === "string") {
|
|
1091
|
+
const bytes = Buffer.from(String(f.contentBase64 ?? ""), "base64");
|
|
1092
|
+
if (bytes.length === 0) throw new Error("rich paste: empty file chunk");
|
|
1093
|
+
chunks.push({ name: sanitizeName(f.name), bytes });
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
throw new Error("rich paste: a chunk must be {text} or {file}");
|
|
1097
|
+
}
|
|
1098
|
+
const children = (Array.isArray(r.children) ? r.children : []).map((k) => {
|
|
1099
|
+
const sub = parseRich(k, depth + 1);
|
|
1100
|
+
const title = typeof (k as { title?: unknown })?.title === "string" ? String((k as { title: string }).title).trim() : "";
|
|
1101
|
+
return { ...sub, title: title || "Untitled" };
|
|
1102
|
+
});
|
|
1103
|
+
if (depth === 0 && chunks.length === 0 && children.length === 0) throw new Error("empty rich paste");
|
|
1104
|
+
return { title: typeof r.title === "string" && r.title.trim() ? r.title.trim() : null, chunks, children };
|
|
1105
|
+
}
|
|
1106
|
+
|
|
1107
|
+
/** One chunk item's source lines: a text becomes a block scalar, a file is written through
|
|
1108
|
+
* `pointerFor` (which yields its `*…` pointer). */
|
|
1109
|
+
function richItemLines(item: RichItem, indent: number, pointerFor: (name: string, bytes: Buffer) => string): string[] {
|
|
1110
|
+
if ("text" in item) return textChunkLines(item.text, indent);
|
|
1111
|
+
return [`${" ".repeat(indent)}- ${pointerFor(item.name, item.bytes)}`];
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
/** A subchapter as a `children:` list item (title + chunks + recursive children), at the
|
|
1115
|
+
* list's indent — the item body keys sit 2 deeper, matching the chapter examples. */
|
|
1116
|
+
function richChildLines(node: Rich & { title: string }, indent: number, pointerFor: (name: string, bytes: Buffer) => string): string[] {
|
|
1117
|
+
const pad = " ".repeat(indent);
|
|
1118
|
+
const lines = [`${pad}- title: ${JSON.stringify(node.title)}`];
|
|
1119
|
+
if (node.chunks.length) lines.push(`${pad} chunks:`, ...node.chunks.flatMap((c) => richItemLines(c, indent + 2, pointerFor)));
|
|
1120
|
+
if (node.children.length) lines.push(`${pad} children:`, ...node.children.flatMap((k) => richChildLines(k, indent + 2, pointerFor)));
|
|
1121
|
+
return lines;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/** A rich paste onto a chapter: files land in the chapter's owning directory, the chunks
|
|
1125
|
+
* (text + pointers, order kept) append to `chunks:` and the subchapters to `children:` —
|
|
1126
|
+
* either list is created when the chapter source lacks it. */
|
|
1127
|
+
function pasteRichIntoChapter(dataRoot: string, s: Store, segs: Seg[], rich: Rich): Record<string, unknown> {
|
|
1128
|
+
const { docSegs, bodyFile, dirBacked } = chapterSource(dataRoot, s, segs);
|
|
1129
|
+
const writeDirSegs = dirBacked ? docSegs : docSegs.slice(0, -1);
|
|
1130
|
+
const writeDir = path.resolve(dataRoot, ...writeDirSegs.map(String));
|
|
1131
|
+
const files: string[] = [];
|
|
1132
|
+
const pointerFor = (name: string, bytes: Buffer): string => {
|
|
1133
|
+
const final = uniqueName(writeDir, name);
|
|
1134
|
+
writeInside(dataRoot, writeDir, final, bytes);
|
|
1135
|
+
files.push(segsToStr([...writeDirSegs, final]));
|
|
1136
|
+
return dirBacked ? `*/${final}` : `*/${segsToStr([...writeDirSegs, final])}`;
|
|
1137
|
+
};
|
|
1138
|
+
const within = segs.slice(docSegs.length);
|
|
1139
|
+
let src = fs.readFileSync(bodyFile, "utf8");
|
|
1140
|
+
if (rich.chunks.length) src = appendToList(src, within, "chunks", (ind) => rich.chunks.flatMap((c) => richItemLines(c, ind, pointerFor)));
|
|
1141
|
+
if (rich.children.length) src = appendToList(src, within, "children", (ind) => rich.children.flatMap((k) => richChildLines(k, ind, pointerFor)));
|
|
1142
|
+
fs.writeFileSync(bodyFile, src);
|
|
1143
|
+
return { path: segsToStr(segs), chapter: segsToStr(segs), files };
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/** A rich paste onto anything that is NOT a chapter: a new chapter in the nearest enclosing
|
|
1147
|
+
* directory — DIRECTORY-BACKED when it carries files (the images live inside it), else a
|
|
1148
|
+
* standalone .yamlover file. A selection that STARTS with its own heading IS the chapter:
|
|
1149
|
+
* the sole top child is promoted to the root (its title names the chapter). */
|
|
1150
|
+
function pasteRichAsChapter(dataRoot: string, segs: Seg[], rich: Rich): Record<string, unknown> {
|
|
1151
|
+
const dirSegs = nearestDirSegs(dataRoot, segs);
|
|
1152
|
+
if (!dirSegs) throw new Error("no enclosing directory to paste into");
|
|
1153
|
+
const dir = path.resolve(dataRoot, ...dirSegs.map(String));
|
|
1154
|
+
if (!rich.title && rich.chunks.length === 0 && rich.children.length === 1) rich = rich.children[0];
|
|
1155
|
+
const firstText = rich.chunks.find((c): c is { text: string } => "text" in c);
|
|
1156
|
+
const title = rich.title ?? (firstText ? titleFromText(firstText.text) : rich.children[0]?.title ?? "Pasted content");
|
|
1157
|
+
|
|
1158
|
+
if (!richHasFiles(rich)) {
|
|
1159
|
+
const final = uniqueName(dir, chapterFileName(title));
|
|
1160
|
+
const src = renderChapterSource(title, rich, () => {
|
|
1161
|
+
throw new Error("unreachable: no files");
|
|
1162
|
+
});
|
|
1163
|
+
writeInside(dataRoot, dir, final, Buffer.from(src, "utf8"));
|
|
1164
|
+
return { path: segsToStr([...dirSegs, final]), dir: segsToStr(dirSegs), open: dirSegs.length !== segs.length };
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
// directory-backed: <name>/.yamlover/body.yamlover + the image files inside <name>/
|
|
1168
|
+
const name = uniqueName(dir, chapterFileName(title).replace(/\.yamlover$/, ""));
|
|
1169
|
+
const chDir = path.join(dir, name);
|
|
1170
|
+
if (!path.resolve(chDir).startsWith(path.resolve(dataRoot) + path.sep)) throw new Error("target escapes the data root");
|
|
1171
|
+
fs.mkdirSync(path.join(chDir, ".yamlover"), { recursive: true });
|
|
1172
|
+
const pointerFor = (fname: string, bytes: Buffer): string => {
|
|
1173
|
+
const final = uniqueName(chDir, fname);
|
|
1174
|
+
writeInside(dataRoot, chDir, final, bytes);
|
|
1175
|
+
return `*/${final}`;
|
|
1176
|
+
};
|
|
1177
|
+
const src = renderChapterSource(title, rich, pointerFor);
|
|
1178
|
+
writeInside(dataRoot, path.join(chDir, ".yamlover"), "body.yamlover", Buffer.from(src, "utf8"));
|
|
1179
|
+
return { path: segsToStr([...dirSegs, name]), dir: segsToStr(dirSegs), open: dirSegs.length !== segs.length };
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/** The whole .yamlover source of a new rich chapter (the tag, the title, chunks, children). */
|
|
1183
|
+
function renderChapterSource(title: string, rich: Rich, pointerFor: (name: string, bytes: Buffer) => string): string {
|
|
1184
|
+
const lines = ["!!<*yamlover/$defs/chapter>", `title: ${JSON.stringify(title)}`];
|
|
1185
|
+
if (rich.chunks.length) lines.push("chunks:", ...rich.chunks.flatMap((c) => richItemLines(c, 0, pointerFor)));
|
|
1186
|
+
if (rich.children.length) lines.push("children:", ...rich.children.flatMap((k) => richChildLines(k, 0, pointerFor)));
|
|
1187
|
+
return lines.join("\n") + "\n";
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function richHasFiles(rich: Rich): boolean {
|
|
1191
|
+
return rich.chunks.some((c) => "bytes" in c) || rich.children.some(richHasFiles);
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/** A title for a pasted-text chapter: the first content line, sans any markdown heading
|
|
1195
|
+
* marker, clipped to 80 chars. */
|
|
1196
|
+
function titleFromText(text: string): string {
|
|
1197
|
+
const first = text.split("\n").find((l) => l.trim().length > 0)?.trim() ?? "";
|
|
1198
|
+
const t = first.replace(/^#{1,6}\s+/, "").trim();
|
|
1199
|
+
return (t.length > 80 ? t.slice(0, 79).trimEnd() + "…" : t) || "Pasted text";
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/** A filename for a new chapter file, from its title: unicode letters/digits/space/dot/dash kept
|
|
1203
|
+
* (non-ASCII names are first-class — see uniqueName for collisions), never hidden. */
|
|
1204
|
+
function chapterFileName(title: string): string {
|
|
1205
|
+
const base = title.replace(/[^\p{L}\p{N} ._-]+/gu, " ").replace(/\s+/g, " ").trim().slice(0, 60).trim().replace(/^\.+/, "");
|
|
1206
|
+
return `${base || "pasted"}.yamlover`;
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
/** A safe filename: basename only, restricted charset, never hidden; defaults when empty. */
|
|
1210
|
+
function sanitizeName(raw: string): string {
|
|
1211
|
+
const base = path.basename(String(raw || "")).replace(/[^A-Za-z0-9._-]/g, "_").replace(/^\.+/, "");
|
|
1212
|
+
return base || "pasted";
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
/** `name`, or `name-1`/`name-2`/… if it already exists in `dir` (extension kept). */
|
|
1216
|
+
function uniqueName(dir: string, name: string): string {
|
|
1217
|
+
if (!fs.existsSync(path.join(dir, name))) return name;
|
|
1218
|
+
const ext = path.extname(name);
|
|
1219
|
+
const stem = name.slice(0, name.length - ext.length);
|
|
1220
|
+
for (let i = 1; ; i++) {
|
|
1221
|
+
const cand = `${stem}-${i}${ext}`;
|
|
1222
|
+
if (!fs.existsSync(path.join(dir, cand))) return cand;
|
|
1223
|
+
}
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/** Write `bytes` to `dir/name`, refusing any path that escapes the served root. */
|
|
1227
|
+
function writeInside(dataRoot: string, dir: string, name: string, bytes: Buffer): void {
|
|
1228
|
+
const root = path.resolve(dataRoot);
|
|
1229
|
+
const target = path.resolve(dir, name);
|
|
1230
|
+
if (target !== root && !target.startsWith(root + path.sep)) throw new Error("target escapes the data root");
|
|
1231
|
+
fs.writeFileSync(target, bytes);
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// --- chapter list insertion (indentation-aware; the parser does not track spans) ------------- //
|
|
1235
|
+
// A directory body / standalone chapter is YAML-shaped: a mapping's keys at one indent, a
|
|
1236
|
+
// sequence's `- ` items at the SAME indent as their key, an item's mapping body at key-indent+2.
|
|
1237
|
+
// To reach a subchapter we descend `children:` sequences by index; then we append to a list key
|
|
1238
|
+
// (`chunks:` for content, `children:` for pasted subchapters), creating it when absent.
|
|
1239
|
+
|
|
1240
|
+
const indentOf = (line: string): number => { let i = 0; while (line[i] === " ") i++; return i; };
|
|
1241
|
+
const isContentLine = (line: string): boolean => { const t = line.trim(); return t.length > 0 && !t.startsWith("#"); };
|
|
1242
|
+
|
|
1243
|
+
/** Render a pasted text as the lines of one `- ` chunk item at `indent`. A literal block scalar
|
|
1244
|
+
* when the text round-trips — the parser detects the block indent from the FIRST content line,
|
|
1245
|
+
* so it must be unindented; else one double-quoted line (JSON escapes — exactly the subset
|
|
1246
|
+
* quotedScalar reads back). */
|
|
1247
|
+
function textChunkLines(text: string, indent: number): string[] {
|
|
1248
|
+
const pad = " ".repeat(indent);
|
|
1249
|
+
const first = text.split("\n").find((l) => l.trim().length > 0);
|
|
1250
|
+
if (!first || /^\s/.test(first)) return [`${pad}- ${JSON.stringify(text)}`];
|
|
1251
|
+
const body = text.endsWith("\n") ? text.slice(0, -1) : text;
|
|
1252
|
+
const head = text.endsWith("\n") ? "|" : "|-"; // the chomping matches the text's own ending
|
|
1253
|
+
return [`${pad}- ${head}`, ...body.split("\n").map((l) => (l.trim().length ? `${pad} ${l}` : ""))];
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
/** Append items (rendered by `renderItems` at the list's indent) to the `key:` list of the
|
|
1257
|
+
* chapter at `chapterPath` (alternating ["children", N, …] pairs; empty = the top-level
|
|
1258
|
+
* chapter) within a .yamlover source. A chapter authored without the list gains the key at
|
|
1259
|
+
* the end of its mapping. */
|
|
1260
|
+
function appendToList(text: string, chapterPath: Seg[], key: string, renderItems: (indent: number) => string[]): string {
|
|
1261
|
+
const lines = text.split("\n");
|
|
1262
|
+
let lo = 0;
|
|
1263
|
+
let hi = lines.length;
|
|
1264
|
+
let indent = firstContentIndent(lines); // the chapter mapping's key indent
|
|
1265
|
+
|
|
1266
|
+
for (let i = 0; i < chapterPath.length; i += 2) {
|
|
1267
|
+
const idx = Number(chapterPath[i + 1]);
|
|
1268
|
+
const kids = findKeyLine(lines, lo, hi, indent, "children");
|
|
1269
|
+
if (kids < 0) throw new Error(`no 'children:' at indent ${indent}`);
|
|
1270
|
+
const items = seqItems(lines, kids + 1, hi, indent);
|
|
1271
|
+
if (!(idx >= 0 && idx < items.length)) throw new Error(`children[${idx}] out of range (${items.length})`);
|
|
1272
|
+
hi = idx + 1 < items.length ? items[idx + 1] : seqEnd(lines, kids + 1, hi, indent);
|
|
1273
|
+
lo = items[idx] + 1; // body starts past the `- ` marker (its inline key sits at the parent indent)
|
|
1274
|
+
indent += 2;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
const keyLine = findKeyLine(lines, lo, hi, indent, key);
|
|
1278
|
+
if (keyLine < 0) {
|
|
1279
|
+
const end = trimBack(lines, lo - 1, hi); // the chapter mapping's end, sans trailing blanks
|
|
1280
|
+
lines.splice(end, 0, `${" ".repeat(indent)}${key}:`, ...renderItems(indent));
|
|
1281
|
+
} else {
|
|
1282
|
+
const end = seqEnd(lines, keyLine + 1, hi, indent);
|
|
1283
|
+
lines.splice(end, 0, ...renderItems(indent));
|
|
1284
|
+
}
|
|
1285
|
+
return lines.join("\n");
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/** The indent of the first content line — the chapter mapping's key column. */
|
|
1289
|
+
function firstContentIndent(lines: string[]): number {
|
|
1290
|
+
for (const l of lines) if (isContentLine(l)) return indentOf(l);
|
|
1291
|
+
return 0;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/** Line index of `key:` at exactly `indent` within [lo,hi); -1 once the mapping ends (a dedent). */
|
|
1295
|
+
function findKeyLine(lines: string[], lo: number, hi: number, indent: number, key: string): number {
|
|
1296
|
+
for (let i = lo; i < hi; i++) {
|
|
1297
|
+
if (!isContentLine(lines[i])) continue;
|
|
1298
|
+
const ind = indentOf(lines[i]);
|
|
1299
|
+
if (ind < indent) return -1; // left the mapping
|
|
1300
|
+
if (ind !== indent) continue; // deeper (a nested value / block scalar)
|
|
1301
|
+
const t = lines[i].trim();
|
|
1302
|
+
if (t === `${key}:` || t.startsWith(`${key}:`)) return i;
|
|
1303
|
+
}
|
|
1304
|
+
return -1;
|
|
1305
|
+
}
|
|
1306
|
+
|
|
1307
|
+
/** Start lines of the `- ` items of a sequence whose items sit at `indent`, from `from`. */
|
|
1308
|
+
function seqItems(lines: string[], from: number, hi: number, indent: number): number[] {
|
|
1309
|
+
const out: number[] = [];
|
|
1310
|
+
for (let i = from; i < hi; i++) {
|
|
1311
|
+
if (!isContentLine(lines[i])) continue;
|
|
1312
|
+
const ind = indentOf(lines[i]);
|
|
1313
|
+
if (ind < indent) break; // dedent → sequence ended
|
|
1314
|
+
if (ind !== indent) continue; // deeper → the current item's body
|
|
1315
|
+
const t = lines[i].trim();
|
|
1316
|
+
if (t === "-" || t.startsWith("- ")) out.push(i);
|
|
1317
|
+
else break; // a sibling key at the same indent → sequence ended
|
|
1318
|
+
}
|
|
1319
|
+
return out;
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/** The line index that ends the sequence starting at `from` (a dedent below `indent`, or a
|
|
1323
|
+
* non-item sibling key at `indent`), skipping back over trailing blank lines. */
|
|
1324
|
+
function seqEnd(lines: string[], from: number, hi: number, indent: number): number {
|
|
1325
|
+
let last = from;
|
|
1326
|
+
for (let i = from; i < hi; i++) {
|
|
1327
|
+
if (!isContentLine(lines[i])) continue;
|
|
1328
|
+
const ind = indentOf(lines[i]);
|
|
1329
|
+
if (ind < indent) return trimBack(lines, last, i);
|
|
1330
|
+
if (ind === indent) {
|
|
1331
|
+
const t = lines[i].trim();
|
|
1332
|
+
if (t === "-" || t.startsWith("- ")) { last = i; continue; }
|
|
1333
|
+
return trimBack(lines, last, i); // sibling key
|
|
1334
|
+
}
|
|
1335
|
+
last = i; // deeper: part of the current item
|
|
1336
|
+
}
|
|
1337
|
+
return trimBack(lines, last, hi);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
/** Walk an end index back over trailing blank lines, so we insert right after the last item. */
|
|
1341
|
+
function trimBack(lines: string[], lastItemLine: number, end: number): number {
|
|
1342
|
+
let e = end;
|
|
1343
|
+
while (e > lastItemLine + 1 && !isContentLine(lines[e - 1])) e--;
|
|
1344
|
+
return e;
|
|
1345
|
+
}
|
|
1346
|
+
|
|
1347
|
+
/** Read a request body and parse it as JSON. */
|
|
1348
|
+
function readBody(req: IncomingMessage): Promise<unknown> {
|
|
1349
|
+
return new Promise((resolve, reject) => {
|
|
1350
|
+
const chunks: Buffer[] = [];
|
|
1351
|
+
req.on("data", (c: Buffer) => chunks.push(c));
|
|
1352
|
+
req.on("end", () => {
|
|
1353
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}")); }
|
|
1354
|
+
catch (e) { reject(e); }
|
|
1355
|
+
});
|
|
1356
|
+
req.on("error", reject);
|
|
1357
|
+
});
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
/** The nearest enclosing DOCUMENT root for `segs` — the closest ancestor (or self) whose node
|
|
1361
|
+
* is flagged `documentRoot` (a parsed file / `.yamlover` dir / served root), as segments. It is
|
|
1362
|
+
* the anchor a document-relative (`/…`) pointer resolves against, mirroring the `/` pointer scope. */
|
|
1363
|
+
function documentRootSegs(s: Store, segs: Seg[]): Seg[] {
|
|
1364
|
+
for (let i = segs.length; i >= 0; i--) {
|
|
1365
|
+
const anc = segs.slice(0, i);
|
|
1366
|
+
if (s.node(storePath(anc))?.meta?.documentRoot) return anc;
|
|
1367
|
+
}
|
|
1368
|
+
return [];
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
/** The nearest enclosing document root as a client JSON path (`/…`). */
|
|
1372
|
+
function documentPath(s: Store, segs: Seg[]): string {
|
|
1373
|
+
return segsToStr(documentRootSegs(s, segs));
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/** A node's `title` child value (a scalar), if any — used as a friendly label. */
|
|
1377
|
+
function titleOf(s: Store, p: string): string | null {
|
|
1378
|
+
const titlePath = (p === ":" ? "" : p) + ":title";
|
|
1379
|
+
const t = s.node(titlePath);
|
|
1380
|
+
if (t && t.type === "scalar" && !s.hasChildren(titlePath) && t.value != null) return String(t.value);
|
|
1381
|
+
return null;
|
|
1382
|
+
}
|
|
1383
|
+
|
|
1384
|
+
// --------------------------------------------------------------------------- //
|
|
1385
|
+
// Path handling (JSON space; matches the client + the Store path scheme)
|
|
1386
|
+
// --------------------------------------------------------------------------- //
|
|
1387
|
+
|
|
1388
|
+
const PATH_TOKEN = /\[\d+\]|[^:\[\]]+/g;
|
|
1389
|
+
|
|
1390
|
+
/** Render segments as a client-facing JSON path (`:key[0]:x`, colon-form — SEPARATOR.md M4),
|
|
1391
|
+
* percent-encoding keys. */
|
|
1392
|
+
function segsToStr(segs: Seg[]): string {
|
|
1393
|
+
return segs.map((seg) => (typeof seg === "number" ? `[${seg}]` : `:${encodeURIComponent(seg)}`)).join("") || ":";
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
/** Parse a client JSON path into segments (`[n]` → number, else a decoded key). */
|
|
1397
|
+
function strToSegs(str: string): Seg[] {
|
|
1398
|
+
const out: Seg[] = [];
|
|
1399
|
+
for (const tok of str.match(PATH_TOKEN) || []) out.push(/^\[\d+\]$/.test(tok) ? Number(tok.slice(1, -1)) : safeDecode(tok));
|
|
1400
|
+
return out;
|
|
1401
|
+
}
|
|
1402
|
+
|
|
1403
|
+
/** Build the raw Store path (un-encoded keys) the index uses, from decoded segments. */
|
|
1404
|
+
function storePath(segs: Seg[]): string {
|
|
1405
|
+
return segs.map((seg) => (typeof seg === "number" ? `[${seg}]` : `:${seg}`)).join("") || ":";
|
|
1406
|
+
}
|
|
1407
|
+
|
|
1408
|
+
/** Parse a raw Store path back into segments (keys are raw — no decode). */
|
|
1409
|
+
function storePathToSegs(p: string): Seg[] {
|
|
1410
|
+
const out: Seg[] = [];
|
|
1411
|
+
for (const tok of p.match(PATH_TOKEN) || []) out.push(/^\[\d+\]$/.test(tok) ? Number(tok.slice(1, -1)) : tok);
|
|
1412
|
+
return out;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
function safeDecode(s: string): string {
|
|
1416
|
+
try { return decodeURIComponent(s); } catch { return s; }
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
// extension → Content-Type for the blob endpoint (mirrors the engine walker's table subset).
|
|
1420
|
+
const EXT_CT: Record<string, string> = {
|
|
1421
|
+
".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif",
|
|
1422
|
+
".webp": "image/webp", ".svg": "image/svg+xml", ".bmp": "image/bmp", ".ico": "image/x-icon",
|
|
1423
|
+
".pdf": "application/pdf", ".tiff": "image/tiff", ".tif": "image/tiff", ".html": "text/html",
|
|
1424
|
+
".md": "text/markdown", ".csv": "text/csv", ".epub": "application/epub+zip",
|
|
1425
|
+
};
|
|
1426
|
+
function formatFromExt(file: string): string | null {
|
|
1427
|
+
return EXT_CT[path.extname(file).toLowerCase()] ?? null;
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
function parseDepth(raw: string | null): number | null {
|
|
1431
|
+
if (raw == null || raw === "") return null;
|
|
1432
|
+
const n = Number(raw);
|
|
1433
|
+
return Number.isInteger(n) && n >= 0 ? n : null;
|
|
1434
|
+
}
|
|
1435
|
+
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
1436
|
+
res.statusCode = status;
|
|
1437
|
+
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
|
1438
|
+
res.end(JSON.stringify(body, null, 2));
|
|
1439
|
+
}
|
|
1440
|
+
function notFound(res: ServerResponse, url: URL): void {
|
|
1441
|
+
sendJson(res, 404, { error: `no such node/endpoint: ${url.pathname}?${url.searchParams}` });
|
|
1442
|
+
}
|