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