@neoloopy/cld-canvas 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/dist/engine/analysis.d.ts +15 -0
  2. package/dist/engine/analysis.js +30 -0
  3. package/dist/engine/engine.d.ts +203 -0
  4. package/dist/engine/engine.js +7 -0
  5. package/dist/engine/exporters.d.ts +45 -0
  6. package/dist/engine/exporters.js +110 -0
  7. package/dist/engine/layout.d.ts +19 -0
  8. package/dist/engine/layout.js +142 -0
  9. package/dist/engine/loopGraph.d.ts +51 -0
  10. package/dist/engine/loopGraph.js +0 -0
  11. package/dist/engine/loopKey.d.ts +24 -0
  12. package/dist/engine/loopKey.js +32 -0
  13. package/dist/engine/loopNote.d.ts +40 -0
  14. package/dist/engine/loopNote.js +130 -0
  15. package/dist/engine/nativeEngine.d.ts +175 -0
  16. package/dist/engine/nativeEngine.js +1120 -0
  17. package/dist/engine/noteCodec.d.ts +29 -0
  18. package/dist/engine/noteCodec.js +254 -0
  19. package/dist/engine/noteNaming.d.ts +18 -0
  20. package/dist/engine/noteNaming.js +28 -0
  21. package/dist/engine/specHash.d.ts +37 -0
  22. package/dist/engine/specHash.js +86 -0
  23. package/dist/engine/storage.d.ts +49 -0
  24. package/dist/engine/storage.js +116 -0
  25. package/dist/engine/subsystemLinks.d.ts +29 -0
  26. package/dist/engine/subsystemLinks.js +55 -0
  27. package/dist/engine/types.d.ts +103 -0
  28. package/dist/engine/types.js +155 -0
  29. package/dist/index.d.ts +19 -0
  30. package/dist/index.js +19 -0
  31. package/dist/view/camera.d.ts +42 -0
  32. package/dist/view/camera.js +75 -0
  33. package/dist/view/geometry.d.ts +83 -0
  34. package/dist/view/geometry.js +408 -0
  35. package/dist/view/painter.d.ts +63 -0
  36. package/dist/view/painter.js +471 -0
  37. package/dist/view/sceneCache.d.ts +61 -0
  38. package/dist/view/sceneCache.js +134 -0
  39. package/dist/view/theme.d.ts +57 -0
  40. package/dist/view/theme.js +90 -0
  41. package/package.json +17 -0
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Reads & writes a single variable note: YAML frontmatter + Markdown body.
3
+ * TypeScript port of `core/lib/vault/note_codec.dart`.
4
+ *
5
+ * Round-trip invariant: parsing then serializing preserves the structured fields
6
+ * AND any unknown frontmatter keys (format rule §3 — never clobber another
7
+ * tool's / the user's data). The serializer is hand-rolled to match the Dart
8
+ * output byte-for-byte (field order, scalar quoting, x/y one-decimal format,
9
+ * block-list link indentation), so notes written by the plugin and the app share
10
+ * an identical content signature and round-trip losslessly on one vault.
11
+ *
12
+ * Parsing is delegated to an injected YAML parser (Obsidian's `parseYaml` at
13
+ * runtime; the `yaml` package in tests) so this module stays pure and testable.
14
+ *
15
+ * Known limitation: a JS number cannot distinguish int from float, so a
16
+ * whole-number float inside a *preserved unknown* key (e.g. a quant block's
17
+ * `100.0`) may re-serialize as `100`. Structured qualitative fields handle this
18
+ * exactly; preserved blocks keep the value, not necessarily the `.0` suffix.
19
+ */
20
+ import { VariableFile } from "./types";
21
+ export type YamlParse = (s: string) => unknown;
22
+ /** Returns [frontmatterText|null, body]. Tolerates notes with no frontmatter. */
23
+ export declare function splitFrontmatter(source: string): [string | null, string];
24
+ export declare function parseNote(source: string, yaml: YamlParse, fallbackId?: string): VariableFile;
25
+ /** Emit a YAML scalar, quoting when needed so it parses back identically. */
26
+ export declare function scalar(v: unknown): string;
27
+ /** Emit `key`/`value` as YAML into `lines` at the given indent level. */
28
+ export declare function emitExtra(lines: string[], key: string, value: unknown, indent: number): void;
29
+ export declare function serializeNote(v: VariableFile): string;
@@ -0,0 +1,254 @@
1
+ /**
2
+ * Reads & writes a single variable note: YAML frontmatter + Markdown body.
3
+ * TypeScript port of `core/lib/vault/note_codec.dart`.
4
+ *
5
+ * Round-trip invariant: parsing then serializing preserves the structured fields
6
+ * AND any unknown frontmatter keys (format rule §3 — never clobber another
7
+ * tool's / the user's data). The serializer is hand-rolled to match the Dart
8
+ * output byte-for-byte (field order, scalar quoting, x/y one-decimal format,
9
+ * block-list link indentation), so notes written by the plugin and the app share
10
+ * an identical content signature and round-trip losslessly on one vault.
11
+ *
12
+ * Parsing is delegated to an injected YAML parser (Obsidian's `parseYaml` at
13
+ * runtime; the `yaml` package in tests) so this module stays pure and testable.
14
+ *
15
+ * Known limitation: a JS number cannot distinguish int from float, so a
16
+ * whole-number float inside a *preserved unknown* key (e.g. a quant block's
17
+ * `100.0`) may re-serialize as `100`. Structured qualitative fields handle this
18
+ * exactly; preserved blocks keep the value, not necessarily the `.0` suffix.
19
+ */
20
+ import { varTypeFrom, linkFromMap, linkToMap, toUtcIso, } from "./types";
21
+ const FENCE = "---";
22
+ /** Frontmatter keys this codec owns; everything else is carried in `extra`. */
23
+ const KNOWN = new Set([
24
+ "id",
25
+ "type",
26
+ "label",
27
+ "group",
28
+ "claLayer",
29
+ "shared",
30
+ "x",
31
+ "y",
32
+ "links",
33
+ "tags",
34
+ "status",
35
+ "created",
36
+ "modified",
37
+ "rev",
38
+ "source",
39
+ "reviewed",
40
+ "reviewedBy",
41
+ "h",
42
+ "subsystem",
43
+ ]);
44
+ /** Returns [frontmatterText|null, body]. Tolerates notes with no frontmatter. */
45
+ export function splitFrontmatter(source) {
46
+ const s = source.replace(/\r\n/g, "\n");
47
+ const t = s.startsWith(`${FENCE}\n`) ? s : s.replace(/^\s+/, "");
48
+ if (!t.startsWith(`${FENCE}\n`))
49
+ return [null, source.trim()];
50
+ const end = t.indexOf(`\n${FENCE}`, FENCE.length);
51
+ if (end < 0)
52
+ return [null, source.trim()];
53
+ const fm = t.substring(FENCE.length + 1, end + 1);
54
+ let rest = t.substring(end + 1 + FENCE.length + 1);
55
+ if (rest.startsWith("\n"))
56
+ rest = rest.substring(1);
57
+ return [fm, rest.trim()];
58
+ }
59
+ function nonEmpty(v) {
60
+ if (v === null || v === undefined)
61
+ return undefined;
62
+ const s = String(v);
63
+ return s.length === 0 ? undefined : s;
64
+ }
65
+ function tagsFrom(v) {
66
+ if (Array.isArray(v))
67
+ return v.map((e) => String(e).trim()).filter((s) => s.length > 0);
68
+ if (typeof v === "string")
69
+ return v.split(",").map((s) => s.trim()).filter((s) => s.length > 0);
70
+ return [];
71
+ }
72
+ /**
73
+ * Read a top-level scalar straight from the frontmatter *text*. Timestamps must
74
+ * survive byte-for-byte (Dart writes microsecond precision, `2026-..Z`), but
75
+ * YAML parsers vary: the `yaml` package keeps them as strings while js-yaml may
76
+ * coerce `!!timestamp` to a `Date` and silently truncate to milliseconds. Going
77
+ * back to the raw bytes makes timestamp round-trip parser-independent.
78
+ */
79
+ function rawScalar(frontmatter, key) {
80
+ if (frontmatter === null)
81
+ return undefined;
82
+ const m = new RegExp(`^${key}:[ \\t]*(.+?)[ \\t]*$`, "m").exec(frontmatter);
83
+ if (!m)
84
+ return undefined;
85
+ let s = m[1];
86
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
87
+ s = s.slice(1, -1);
88
+ }
89
+ return s.length === 0 ? undefined : s;
90
+ }
91
+ /** Prefer the exact on-disk timestamp bytes; fall back to normalizing the parsed value. */
92
+ function tsField(frontmatter, key, parsed) {
93
+ return rawScalar(frontmatter, key) ?? toUtcIso(parsed);
94
+ }
95
+ export function parseNote(source, yaml, fallbackId) {
96
+ const [frontmatter, body] = splitFrontmatter(source);
97
+ const parsed = frontmatter === null ? {} : yaml(frontmatter);
98
+ const m = parsed && typeof parsed === "object" && !Array.isArray(parsed)
99
+ ? parsed
100
+ : {};
101
+ const links = [];
102
+ const rawLinks = m["links"];
103
+ if (Array.isArray(rawLinks)) {
104
+ for (const e of rawLinks) {
105
+ if (e && typeof e === "object")
106
+ links.push(linkFromMap(e));
107
+ }
108
+ }
109
+ const numOr0 = (v) => (typeof v === "number" ? v : 0);
110
+ const extra = {};
111
+ for (const [k, v] of Object.entries(m))
112
+ if (!KNOWN.has(k))
113
+ extra[k] = v;
114
+ return {
115
+ id: String(m["id"] ?? fallbackId ?? ""),
116
+ type: varTypeFrom(typeof m["type"] === "string" ? (m["type"]) : undefined),
117
+ label: String(m["label"] ?? ""),
118
+ group: nonEmpty(m["group"]),
119
+ claLayer: nonEmpty(m["claLayer"]),
120
+ shared: nonEmpty(m["shared"]),
121
+ x: numOr0(m["x"]),
122
+ y: numOr0(m["y"]),
123
+ links,
124
+ tags: tagsFrom(m["tags"]),
125
+ status: nonEmpty(m["status"]),
126
+ created: tsField(frontmatter, "created", m["created"]),
127
+ modified: tsField(frontmatter, "modified", m["modified"]),
128
+ rev: typeof m["rev"] === "number" ? Math.trunc(m["rev"]) : 0,
129
+ source: nonEmpty(m["source"]),
130
+ reviewed: tsField(frontmatter, "reviewed", m["reviewed"]),
131
+ reviewedBy: nonEmpty(m["reviewedBy"]),
132
+ h: nonEmpty(m["h"]),
133
+ subsystem: nonEmpty(m["subsystem"]),
134
+ body,
135
+ extra,
136
+ };
137
+ }
138
+ /** Emit a YAML scalar, quoting when needed so it parses back identically. */
139
+ export function scalar(v) {
140
+ if (v === null || v === undefined)
141
+ return "null";
142
+ if (typeof v === "number" || typeof v === "boolean")
143
+ return String(v);
144
+ const s = String(v);
145
+ const needsQuote = s.length === 0 ||
146
+ /^[\s\-+?:,[\]{}#&*!|>%@`"']/.test(s) ||
147
+ /\s$/.test(s) ||
148
+ s.includes(": ") ||
149
+ s.includes(" #") ||
150
+ s.includes("\n") ||
151
+ /^(true|false|null|yes|no|on|off)$/i.test(s) ||
152
+ /^[-+]?\d/.test(s);
153
+ if (!needsQuote)
154
+ return s;
155
+ return `"${s.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
156
+ }
157
+ /** Match Dart's `'$d'` for a double-typed value: whole numbers keep a `.0`. */
158
+ function dartDouble(d) {
159
+ return Number.isInteger(d) ? `${d}.0` : String(d);
160
+ }
161
+ /** x/y formatting: integer-valued -> one decimal, else minimal. */
162
+ function numToYaml(d) {
163
+ return Number.isInteger(d) ? d.toFixed(1) : String(d);
164
+ }
165
+ /** Serialize a link entry value with the correct numeric type (Dart parity). */
166
+ function linkScalar(key, val) {
167
+ if (typeof val === "boolean")
168
+ return String(val);
169
+ if (typeof val === "number")
170
+ return key === "weight" ? String(Math.trunc(val)) : dartDouble(val);
171
+ return scalar(val);
172
+ }
173
+ /** Emit `key`/`value` as YAML into `lines` at the given indent level. */
174
+ export function emitExtra(lines, key, value, indent) {
175
+ const pad = " ".repeat(indent);
176
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
177
+ lines.push(`${pad}${key}:`);
178
+ for (const [k, val] of Object.entries(value)) {
179
+ emitExtra(lines, k, val, indent + 1);
180
+ }
181
+ }
182
+ else if (Array.isArray(value)) {
183
+ lines.push(`${pad}${key}:`);
184
+ for (const item of value) {
185
+ if (item !== null && typeof item === "object" && !Array.isArray(item)) {
186
+ let first = true;
187
+ for (const [k, val] of Object.entries(item)) {
188
+ lines.push(`${pad} ${first ? "- " : " "}${k}: ${scalar(val)}`);
189
+ first = false;
190
+ }
191
+ }
192
+ else {
193
+ lines.push(`${pad} - ${scalar(item)}`);
194
+ }
195
+ }
196
+ }
197
+ else {
198
+ lines.push(`${pad}${key}: ${scalar(value)}`);
199
+ }
200
+ }
201
+ export function serializeNote(v) {
202
+ const lines = [FENCE];
203
+ lines.push(`id: ${scalar(v.id)}`);
204
+ lines.push(`type: ${v.type}`);
205
+ lines.push(`label: ${scalar(v.label)}`);
206
+ if (v.group && v.group.length > 0)
207
+ lines.push(`group: ${scalar(v.group)}`);
208
+ if (v.claLayer && v.claLayer.length > 0)
209
+ lines.push(`claLayer: ${scalar(v.claLayer)}`);
210
+ if (v.shared && v.shared.length > 0)
211
+ lines.push(`shared: ${scalar(v.shared)}`);
212
+ if (v.tags.length > 0) {
213
+ lines.push("tags:");
214
+ for (const t of v.tags)
215
+ lines.push(` - ${scalar(t)}`);
216
+ }
217
+ if (v.status && v.status.length > 0)
218
+ lines.push(`status: ${scalar(v.status)}`);
219
+ if (v.created)
220
+ lines.push(`created: ${v.created}`);
221
+ if (v.modified)
222
+ lines.push(`modified: ${v.modified}`);
223
+ if (v.rev > 0)
224
+ lines.push(`rev: ${v.rev}`);
225
+ if (v.source && v.source.length > 0)
226
+ lines.push(`source: ${scalar(v.source)}`);
227
+ if (v.reviewed)
228
+ lines.push(`reviewed: ${v.reviewed}`);
229
+ if (v.reviewedBy && v.reviewedBy.length > 0)
230
+ lines.push(`reviewedBy: ${scalar(v.reviewedBy)}`);
231
+ if (v.h && v.h.length > 0)
232
+ lines.push(`h: ${scalar(v.h)}`);
233
+ if (v.subsystem && v.subsystem.length > 0)
234
+ lines.push(`subsystem: ${scalar(v.subsystem)}`);
235
+ lines.push(`x: ${numToYaml(v.x)}`);
236
+ lines.push(`y: ${numToYaml(v.y)}`);
237
+ if (v.links.length > 0) {
238
+ lines.push("links:");
239
+ for (const l of v.links) {
240
+ let first = true;
241
+ for (const [key, val] of linkToMap(l)) {
242
+ lines.push(`${first ? " - " : " "}${key}: ${linkScalar(key, val)}`);
243
+ first = false;
244
+ }
245
+ }
246
+ }
247
+ for (const [k, val] of Object.entries(v.extra))
248
+ emitExtra(lines, k, val, 0);
249
+ lines.push(FENCE);
250
+ let out = lines.join("\n") + "\n";
251
+ if (v.body.trim().length > 0)
252
+ out += "\n" + v.body.trim() + "\n";
253
+ return out;
254
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Filename ⇄ label mapping for variable ("node") notes. A node's Markdown file
3
+ * is named after its label so the vault reads like the diagram (`Birth_Rate.md`
4
+ * rather than `var_9c1b.md`), while the stable `var_…` id in the frontmatter
5
+ * stays the link target — so renaming a node never breaks a link.
6
+ *
7
+ * The rule (per product decision): spaces become underscores, case is kept, and
8
+ * characters that are illegal in a path or significant in an Obsidian wikilink
9
+ * are dropped. Pure and obsidian-free so both the engine and the view can use it.
10
+ */
11
+ /**
12
+ * Filename stem (no extension) for a label. Returns "" when the label is blank
13
+ * or made entirely of reserved characters — callers fall back to the id then.
14
+ */
15
+ export declare function noteSlug(label: string): string;
16
+ /** Best-effort inverse, for when a user renames the file in the vault: turn the
17
+ * filename stem back into a label (underscores → spaces). */
18
+ export declare function noteUnslug(stem: string): string;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Filename ⇄ label mapping for variable ("node") notes. A node's Markdown file
3
+ * is named after its label so the vault reads like the diagram (`Birth_Rate.md`
4
+ * rather than `var_9c1b.md`), while the stable `var_…` id in the frontmatter
5
+ * stays the link target — so renaming a node never breaks a link.
6
+ *
7
+ * The rule (per product decision): spaces become underscores, case is kept, and
8
+ * characters that are illegal in a path or significant in an Obsidian wikilink
9
+ * are dropped. Pure and obsidian-free so both the engine and the view can use it.
10
+ */
11
+ // Path-illegal (\ / : * ? " < > |) plus wikilink-significant (# ^ [ ]) characters.
12
+ const RESERVED = /[\\/:*?"<>|#^[\]]/g;
13
+ /**
14
+ * Filename stem (no extension) for a label. Returns "" when the label is blank
15
+ * or made entirely of reserved characters — callers fall back to the id then.
16
+ */
17
+ export function noteSlug(label) {
18
+ return label
19
+ .replace(RESERVED, "")
20
+ .replace(/\s+/g, "_")
21
+ .replace(/_+/g, "_")
22
+ .replace(/^_+|_+$/g, "");
23
+ }
24
+ /** Best-effort inverse, for when a user renames the file in the vault: turn the
25
+ * filename stem back into a label (underscores → spaces). */
26
+ export function noteUnslug(stem) {
27
+ return stem.replace(/_+/g, " ").trim();
28
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Content signature + metadata stamping — TypeScript port of
3
+ * `core/lib/vault/meta_stamp.dart`.
4
+ *
5
+ * `fnv1a32` is the dependency-free 32-bit FNV-1a the Dart and Python codecs use,
6
+ * so all three produce identical 8-hex signatures (mirror: Dart `fnv1a32`,
7
+ * Python `_fnv1a32`). The signature covers a note's CONTENT fields only — it
8
+ * excludes x/y, cosmetic link attributes, and the derived
9
+ * created/modified/rev/source/h — so cosmetic moves and field reordering are not
10
+ * treated as content changes, and the plugin's writes are not falsely flagged as
11
+ * external edits by the app.
12
+ *
13
+ * The 64-bit quant spec hash (`quant_spec_hash`) is intentionally NOT ported:
14
+ * the qualitative plugin never computes quant staleness.
15
+ */
16
+ import { VariableFile } from "./types";
17
+ /** Provenance value stored in a note's `source` key for plugin writes. */
18
+ export declare const WRITE_SOURCE_PLUGIN = "plugin";
19
+ /** FNV-1a 32-bit hash of `s` (UTF-8), as 8 lowercase hex chars. */
20
+ export declare function fnv1a32(s: string): string;
21
+ /**
22
+ * Canonical, deterministic string over a note's content fields only. Links
23
+ * sorted by target, tags sorted; `shared` and per-link `confidence`/`basis` are
24
+ * appended only when set, so a note without them hashes byte-identically to
25
+ * before those fields existed.
26
+ */
27
+ export declare function canonicalContent(v: VariableFile): string;
28
+ /** 8-hex content signature stored as the note's `h` key. */
29
+ export declare function contentSignature(v: VariableFile): string;
30
+ /**
31
+ * Stamp recency metadata onto `next` before writing it.
32
+ * - `prev` is the note's prior on-disk state (undefined = creation).
33
+ * - A content change (signature differs, or creation) bumps modified/rev/source.
34
+ * A cosmetic change (x/y only) preserves them.
35
+ * - `h` is always refreshed to the current content signature.
36
+ */
37
+ export declare function stampMeta(prev: VariableFile | undefined, next: VariableFile, source: string, now?: Date): VariableFile;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Content signature + metadata stamping — TypeScript port of
3
+ * `core/lib/vault/meta_stamp.dart`.
4
+ *
5
+ * `fnv1a32` is the dependency-free 32-bit FNV-1a the Dart and Python codecs use,
6
+ * so all three produce identical 8-hex signatures (mirror: Dart `fnv1a32`,
7
+ * Python `_fnv1a32`). The signature covers a note's CONTENT fields only — it
8
+ * excludes x/y, cosmetic link attributes, and the derived
9
+ * created/modified/rev/source/h — so cosmetic moves and field reordering are not
10
+ * treated as content changes, and the plugin's writes are not falsely flagged as
11
+ * external edits by the app.
12
+ *
13
+ * The 64-bit quant spec hash (`quant_spec_hash`) is intentionally NOT ported:
14
+ * the qualitative plugin never computes quant staleness.
15
+ */
16
+ import { varTypeName } from "./types";
17
+ /** Provenance value stored in a note's `source` key for plugin writes. */
18
+ export const WRITE_SOURCE_PLUGIN = "plugin";
19
+ const encoder = new TextEncoder();
20
+ /** FNV-1a 32-bit hash of `s` (UTF-8), as 8 lowercase hex chars. */
21
+ export function fnv1a32(s) {
22
+ let hash = 0x811c9dc5;
23
+ for (const b of encoder.encode(s)) {
24
+ hash ^= b;
25
+ hash = Math.imul(hash, 0x01000193);
26
+ }
27
+ return (hash >>> 0).toString(16).padStart(8, "0");
28
+ }
29
+ /**
30
+ * Canonical, deterministic string over a note's content fields only. Links
31
+ * sorted by target, tags sorted; `shared` and per-link `confidence`/`basis` are
32
+ * appended only when set, so a note without them hashes byte-identically to
33
+ * before those fields existed.
34
+ */
35
+ export function canonicalContent(v) {
36
+ const tags = [...v.tags].sort();
37
+ const links = [...v.links].sort((a, b) => (a.to < b.to ? -1 : a.to > b.to ? 1 : 0));
38
+ const linkPart = (l) => {
39
+ const base = `${l.to}|${l.polarity}|${l.delay ? 1 : 0}|${l.indirect ? 1 : 0}|${l.nonlinear ? 1 : 0}`;
40
+ const conf = l.confidence !== undefined ? `|c${l.confidence.toFixed(3)}` : "";
41
+ const basis = l.basis !== undefined && l.basis.length > 0 ? `|b${l.basis}` : "";
42
+ return `${base}${conf}${basis}`;
43
+ };
44
+ const parts = [
45
+ v.label,
46
+ varTypeName(v.type),
47
+ v.group ?? "",
48
+ v.claLayer ?? "",
49
+ v.subsystem ?? "",
50
+ v.status ?? "",
51
+ tags.join(","),
52
+ ...links.map(linkPart),
53
+ v.body.trim(),
54
+ ];
55
+ if ((v.shared ?? "").length > 0)
56
+ parts.push(v.shared);
57
+ return parts.join("\n");
58
+ }
59
+ /** 8-hex content signature stored as the note's `h` key. */
60
+ export function contentSignature(v) {
61
+ return fnv1a32(canonicalContent(v));
62
+ }
63
+ /**
64
+ * Stamp recency metadata onto `next` before writing it.
65
+ * - `prev` is the note's prior on-disk state (undefined = creation).
66
+ * - A content change (signature differs, or creation) bumps modified/rev/source.
67
+ * A cosmetic change (x/y only) preserves them.
68
+ * - `h` is always refreshed to the current content signature.
69
+ */
70
+ export function stampMeta(prev, next, source, now) {
71
+ const ts = (now ?? new Date()).toISOString();
72
+ const sig = contentSignature(next);
73
+ const created = prev?.created ?? next.created ?? ts;
74
+ const contentChanged = prev === undefined || contentSignature(prev) !== sig;
75
+ if (contentChanged) {
76
+ return { ...next, created, modified: ts, rev: (prev?.rev ?? 0) + 1, source, h: sig };
77
+ }
78
+ return {
79
+ ...next,
80
+ created,
81
+ modified: prev.modified ?? ts,
82
+ rev: prev.rev === 0 ? 1 : prev.rev,
83
+ source: prev.source ?? source,
84
+ h: sig,
85
+ };
86
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Storage seam for the engine — a small filesystem-like interface plus an
3
+ * in-memory implementation for tests. The Obsidian-backed implementation lives
4
+ * in `obsidianStorage.ts` (kept separate so this module and the engine never
5
+ * import the `obsidian` package and stay unit-testable in plain Node).
6
+ *
7
+ * All paths are vault-relative, "/"-separated.
8
+ */
9
+ export interface DirListing {
10
+ files: string[];
11
+ folders: string[];
12
+ }
13
+ export interface VaultStorage {
14
+ exists(path: string): Promise<boolean>;
15
+ read(path: string): Promise<string>;
16
+ write(path: string, data: string): Promise<void>;
17
+ remove(path: string): Promise<void>;
18
+ /** Create `path` and any missing ancestor folders. */
19
+ mkdirs(path: string): Promise<void>;
20
+ /** Remove a folder and everything under it. */
21
+ rmdir(path: string): Promise<void>;
22
+ /**
23
+ * Move/rename a file or folder (and everything under it) from one
24
+ * vault-relative path to another, creating the destination's parent as needed.
25
+ * The Obsidian implementation routes through `fileManager.renameFile` so every
26
+ * wikilink/backlink pointing into the moved tree (subsystem anchors included)
27
+ * is rewritten to the new location.
28
+ */
29
+ move(from: string, to: string): Promise<void>;
30
+ /** Immediate children of `path` (vault-relative paths). */
31
+ list(path: string): Promise<DirListing>;
32
+ }
33
+ /** Join + normalize vault-relative path segments. */
34
+ export declare function joinPath(...parts: string[]): string;
35
+ export declare function parentPath(path: string): string;
36
+ export declare function baseName(path: string): string;
37
+ /** In-memory storage for tests. */
38
+ export declare class MemoryStorage implements VaultStorage {
39
+ private files;
40
+ private folders;
41
+ exists(path: string): Promise<boolean>;
42
+ read(path: string): Promise<string>;
43
+ write(path: string, data: string): Promise<void>;
44
+ remove(path: string): Promise<void>;
45
+ mkdirs(path: string): Promise<void>;
46
+ rmdir(path: string): Promise<void>;
47
+ move(from: string, to: string): Promise<void>;
48
+ list(path: string): Promise<DirListing>;
49
+ }
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Storage seam for the engine — a small filesystem-like interface plus an
3
+ * in-memory implementation for tests. The Obsidian-backed implementation lives
4
+ * in `obsidianStorage.ts` (kept separate so this module and the engine never
5
+ * import the `obsidian` package and stay unit-testable in plain Node).
6
+ *
7
+ * All paths are vault-relative, "/"-separated.
8
+ */
9
+ /** Join + normalize vault-relative path segments. */
10
+ export function joinPath(...parts) {
11
+ return parts
12
+ .flatMap((p) => p.split("/"))
13
+ .filter((s) => s.length > 0 && s !== ".")
14
+ .join("/");
15
+ }
16
+ export function parentPath(path) {
17
+ const i = path.lastIndexOf("/");
18
+ return i < 0 ? "" : path.substring(0, i);
19
+ }
20
+ export function baseName(path) {
21
+ const i = path.lastIndexOf("/");
22
+ return i < 0 ? path : path.substring(i + 1);
23
+ }
24
+ /** In-memory storage for tests. */
25
+ export class MemoryStorage {
26
+ constructor() {
27
+ this.files = new Map();
28
+ this.folders = new Set([""]);
29
+ }
30
+ async exists(path) {
31
+ const p = joinPath(path);
32
+ return this.files.has(p) || this.folders.has(p);
33
+ }
34
+ async read(path) {
35
+ const p = joinPath(path);
36
+ const v = this.files.get(p);
37
+ if (v === undefined)
38
+ throw new Error(`ENOENT: ${p}`);
39
+ return v;
40
+ }
41
+ async write(path, data) {
42
+ const p = joinPath(path);
43
+ await this.mkdirs(parentPath(p));
44
+ this.files.set(p, data);
45
+ }
46
+ async remove(path) {
47
+ this.files.delete(joinPath(path));
48
+ }
49
+ async mkdirs(path) {
50
+ const p = joinPath(path);
51
+ if (p === "")
52
+ return;
53
+ const segs = p.split("/");
54
+ let cur = "";
55
+ for (const s of segs) {
56
+ cur = cur === "" ? s : `${cur}/${s}`;
57
+ this.folders.add(cur);
58
+ }
59
+ }
60
+ async rmdir(path) {
61
+ const p = joinPath(path);
62
+ const prefix = p + "/";
63
+ for (const f of [...this.files.keys()]) {
64
+ if (f === p || f.startsWith(prefix))
65
+ this.files.delete(f);
66
+ }
67
+ for (const d of [...this.folders]) {
68
+ if (d === p || d.startsWith(prefix))
69
+ this.folders.delete(d);
70
+ }
71
+ }
72
+ async move(from, to) {
73
+ const src = joinPath(from);
74
+ const dst = joinPath(to);
75
+ if (src === dst)
76
+ return;
77
+ await this.mkdirs(parentPath(dst));
78
+ const prefix = src + "/";
79
+ const rename = (p) => (p === src ? dst : dst + "/" + p.substring(prefix.length));
80
+ for (const f of [...this.files.keys()]) {
81
+ if (f === src || f.startsWith(prefix)) {
82
+ this.files.set(rename(f), this.files.get(f));
83
+ this.files.delete(f);
84
+ }
85
+ }
86
+ for (const d of [...this.folders]) {
87
+ if (d === src || d.startsWith(prefix)) {
88
+ this.folders.delete(d);
89
+ this.folders.add(rename(d));
90
+ }
91
+ }
92
+ }
93
+ async list(path) {
94
+ const p = joinPath(path);
95
+ const prefix = p === "" ? "" : p + "/";
96
+ const files = [];
97
+ const folders = [];
98
+ for (const f of this.files.keys()) {
99
+ if (f.startsWith(prefix)) {
100
+ const rest = f.substring(prefix.length);
101
+ if (rest.length > 0 && !rest.includes("/"))
102
+ files.push(f);
103
+ }
104
+ }
105
+ for (const d of this.folders) {
106
+ if (d === "")
107
+ continue;
108
+ if (d.startsWith(prefix)) {
109
+ const rest = d.substring(prefix.length);
110
+ if (rest.length > 0 && !rest.includes("/"))
111
+ folders.push(d);
112
+ }
113
+ }
114
+ return { files: files.sort(), folders: folders.sort() };
115
+ }
116
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Subsystem-link resolution + parent-anchor derivation — the TypeScript mirror
3
+ * of `core/lib/vault/subsystem_link.dart`. A node's `subsystem` field holds a
4
+ * relative wikilink `[[../<dir>/System|<alias>]]` pointing DOWN into a child
5
+ * model; deriving parents is the inverse — scanning other models for nodes that
6
+ * point at us. Pure: IO is injected via `readNodes` so it stays unit-testable.
7
+ */
8
+ import { VariableFile } from "./types";
9
+ /** One parent-model node that anchors a given child model as its subsystem. */
10
+ export interface ParentAnchor {
11
+ modelFolder: string;
12
+ modelName: string;
13
+ anchorVarId: string;
14
+ anchorVarLabel: string;
15
+ }
16
+ /** The minimal model identity the link rules need. */
17
+ export interface ModelKey {
18
+ folder: string;
19
+ name: string;
20
+ }
21
+ /** Parse `[[../<dir>/System|<alias>]]` into its folder-basename hint + alias. */
22
+ export declare function parseSubsystemLink(raw: string): {
23
+ dir: string | null;
24
+ alias: string | null;
25
+ };
26
+ /** Does a stored subsystem link resolve to `model`? Folder basename, then name. */
27
+ export declare function linkPointsToModel(raw: string, model: ModelKey): boolean;
28
+ /** Scan `others` for nodes whose `subsystem` link points back at `current`. */
29
+ export declare function deriveParentAnchors(current: ModelKey, others: ModelKey[], readNodes: (folder: string) => Promise<VariableFile[]>): Promise<ParentAnchor[]>;