@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.
- package/dist/engine/analysis.d.ts +15 -0
- package/dist/engine/analysis.js +30 -0
- package/dist/engine/engine.d.ts +203 -0
- package/dist/engine/engine.js +7 -0
- package/dist/engine/exporters.d.ts +45 -0
- package/dist/engine/exporters.js +110 -0
- package/dist/engine/layout.d.ts +19 -0
- package/dist/engine/layout.js +142 -0
- package/dist/engine/loopGraph.d.ts +51 -0
- package/dist/engine/loopGraph.js +0 -0
- package/dist/engine/loopKey.d.ts +24 -0
- package/dist/engine/loopKey.js +32 -0
- package/dist/engine/loopNote.d.ts +40 -0
- package/dist/engine/loopNote.js +130 -0
- package/dist/engine/nativeEngine.d.ts +175 -0
- package/dist/engine/nativeEngine.js +1120 -0
- package/dist/engine/noteCodec.d.ts +29 -0
- package/dist/engine/noteCodec.js +254 -0
- package/dist/engine/noteNaming.d.ts +18 -0
- package/dist/engine/noteNaming.js +28 -0
- package/dist/engine/specHash.d.ts +37 -0
- package/dist/engine/specHash.js +86 -0
- package/dist/engine/storage.d.ts +49 -0
- package/dist/engine/storage.js +116 -0
- package/dist/engine/subsystemLinks.d.ts +29 -0
- package/dist/engine/subsystemLinks.js +55 -0
- package/dist/engine/types.d.ts +103 -0
- package/dist/engine/types.js +155 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.js +19 -0
- package/dist/view/camera.d.ts +42 -0
- package/dist/view/camera.js +75 -0
- package/dist/view/geometry.d.ts +83 -0
- package/dist/view/geometry.js +408 -0
- package/dist/view/painter.d.ts +63 -0
- package/dist/view/painter.js +471 -0
- package/dist/view/sceneCache.d.ts +61 -0
- package/dist/view/sceneCache.js +134 -0
- package/dist/view/theme.d.ts +57 -0
- package/dist/view/theme.js +90 -0
- package/package.json +17 -0
|
@@ -0,0 +1,1120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NativeEngine — the default, fully-open-source TypeScript implementation of the
|
|
3
|
+
* qualitative neoloopy surface, working directly on vault files through a
|
|
4
|
+
* `VaultStorage`. It reuses the Phase-2 engine modules (codec, loop graph,
|
|
5
|
+
* layout, exporters, signature) so reads/writes are byte-compatible with the
|
|
6
|
+
* Dart CLI/app and the Python bridge. No `obsidian` import — unit-testable in
|
|
7
|
+
* plain Node against `MemoryStorage`.
|
|
8
|
+
*
|
|
9
|
+
* Vault conventions mirrored from `core/lib/vault/desktop_vault_storage.dart`
|
|
10
|
+
* and `core/lib/cli/vault_engine.dart`:
|
|
11
|
+
* - variable note file = `Nodes/<id>.md`, id = `var_` + 8 hex. Legacy vaults
|
|
12
|
+
* wrote `<id>.md` flat at the model root; those are still read (a `Nodes/`
|
|
13
|
+
* copy wins on a duplicate id) and swept into `Nodes/` when next written.
|
|
14
|
+
* - loop notes live in the model's `Loops/<slug>.md` files (see below).
|
|
15
|
+
* - model folder name = slug(name) (`-2`, `-3` … on collision), `model.json`
|
|
16
|
+
* manifest pretty-printed (2-space indent)
|
|
17
|
+
* - `System.md` / `Futures.md` / `CLA.md` are not variables
|
|
18
|
+
* - discovery: any folder with a `model.json` is a model and is NOT recursed
|
|
19
|
+
* into, so a model's own `Nodes/`/`Loops/` subfolders are never mistaken for
|
|
20
|
+
* nested models; grouped/nested model folders elsewhere are still found.
|
|
21
|
+
*/
|
|
22
|
+
import { emptyVariable, manifestFromJson, manifestToJson, normalizeBasis, normalizeConfidence, } from "./types";
|
|
23
|
+
import { LoopGraph, labelLoopsByKey } from "./loopGraph";
|
|
24
|
+
import { parseNote, serializeNote, splitFrontmatter } from "./noteCodec";
|
|
25
|
+
import { noteSlug, noteUnslug } from "./noteNaming";
|
|
26
|
+
import { canonicalLoopMembers, loopMatchesNote, loopSlug, parseLoopNote, serializeLoopNote, } from "./loopNote";
|
|
27
|
+
import { contentSignature, stampMeta, WRITE_SOURCE_PLUGIN } from "./specHash";
|
|
28
|
+
import { loopEchoLabel, loopKey } from "./loopKey";
|
|
29
|
+
import { autoLayout } from "./layout";
|
|
30
|
+
import { render, } from "./exporters";
|
|
31
|
+
import { LoopType } from "./types";
|
|
32
|
+
import { baseName, joinPath, parentPath, } from "./storage";
|
|
33
|
+
import { deriveParentAnchors } from "./subsystemLinks";
|
|
34
|
+
/** Non-variable notes that share a model folder. */
|
|
35
|
+
const SPECIAL_NOTES = new Set(["System.md", "Futures.md", "CLA.md"]);
|
|
36
|
+
const MAX_SCAN_DEPTH = 12;
|
|
37
|
+
export class NativeEngine {
|
|
38
|
+
constructor(storage, yaml, opts) {
|
|
39
|
+
this.storage = storage;
|
|
40
|
+
this.yaml = yaml;
|
|
41
|
+
this.opts = opts;
|
|
42
|
+
}
|
|
43
|
+
// ---- discovery / listing -------------------------------------------------
|
|
44
|
+
async listModels() {
|
|
45
|
+
const folders = await this.findModelFolders();
|
|
46
|
+
const refs = [];
|
|
47
|
+
for (const folder of folders) {
|
|
48
|
+
try {
|
|
49
|
+
const manifest = await this.readManifest(folder);
|
|
50
|
+
const notes = await this.listNoteFiles(folder);
|
|
51
|
+
refs.push({
|
|
52
|
+
id: manifest.id,
|
|
53
|
+
name: manifest.name,
|
|
54
|
+
folder,
|
|
55
|
+
group: manifest.folder ?? null,
|
|
56
|
+
modified: manifest.modified,
|
|
57
|
+
variableCount: notes.length,
|
|
58
|
+
quant: manifestIsQuant(manifest),
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
// Unreadable/partial model folder — skip rather than fail the whole list.
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
refs.sort((a, b) => a.name.localeCompare(b.name));
|
|
66
|
+
return refs;
|
|
67
|
+
}
|
|
68
|
+
async loadGraph(folder) {
|
|
69
|
+
const manifest = await this.readManifest(folder);
|
|
70
|
+
const nodes = await this.loadNotes(folder);
|
|
71
|
+
const graph = new LoopGraph(nodes);
|
|
72
|
+
const loops = graph.detectLoops();
|
|
73
|
+
const labels = labelLoopsByKey(loops, (id) => graph.node(id)?.label ?? id);
|
|
74
|
+
const quant = manifestIsQuant(manifest) || nodes.some((n) => "quant" in n.extra);
|
|
75
|
+
return { folder, manifest, nodes, loops, labels, quant };
|
|
76
|
+
}
|
|
77
|
+
// ---- model lifecycle -----------------------------------------------------
|
|
78
|
+
async createModel(name) {
|
|
79
|
+
const modelId = genModelId();
|
|
80
|
+
const leaf = slug(name) || modelId;
|
|
81
|
+
let folder = joinPath(this.opts.modelsRoot, leaf);
|
|
82
|
+
let n = 2;
|
|
83
|
+
while (await this.storage.exists(folder)) {
|
|
84
|
+
folder = joinPath(this.opts.modelsRoot, `${leaf}-${n}`);
|
|
85
|
+
n++;
|
|
86
|
+
}
|
|
87
|
+
await this.storage.mkdirs(folder);
|
|
88
|
+
const now = new Date().toISOString();
|
|
89
|
+
const manifest = {
|
|
90
|
+
id: modelId,
|
|
91
|
+
name,
|
|
92
|
+
schemaVersion: 1,
|
|
93
|
+
viewport: { x: 0, y: 0, zoom: 1 },
|
|
94
|
+
created: now,
|
|
95
|
+
modified: now,
|
|
96
|
+
order: 0,
|
|
97
|
+
extra: {},
|
|
98
|
+
};
|
|
99
|
+
await this.writeManifest(folder, manifest);
|
|
100
|
+
return { id: modelId, name, folder, group: null, modified: now, variableCount: 0, quant: false };
|
|
101
|
+
}
|
|
102
|
+
async renameModel(folder, name) {
|
|
103
|
+
const trimmed = name.trim();
|
|
104
|
+
if (trimmed.length === 0) {
|
|
105
|
+
throw new Error("Model title cannot be empty.");
|
|
106
|
+
}
|
|
107
|
+
const manifest = await this.readManifest(folder);
|
|
108
|
+
// Keep the folder name in sync with the title, using the same slug rule and
|
|
109
|
+
// `-2/-3…` collision suffixing as createModel. Renaming the leaf within the
|
|
110
|
+
// model's current parent dir preserves any folder organization. Skip the move
|
|
111
|
+
// when the slug is unchanged (e.g. punctuation-only edits) so we don't churn
|
|
112
|
+
// paths — and so we never collide the folder with itself.
|
|
113
|
+
const dest = await this.destForRename(folder, trimmed);
|
|
114
|
+
// Write the new title into model.json first (at the current path), then move
|
|
115
|
+
// the folder so links settle around the final location.
|
|
116
|
+
const updated = {
|
|
117
|
+
...manifest,
|
|
118
|
+
name: trimmed,
|
|
119
|
+
modified: new Date().toISOString(),
|
|
120
|
+
};
|
|
121
|
+
await this.writeManifest(folder, updated);
|
|
122
|
+
if (dest !== folder) {
|
|
123
|
+
await this.storage.move(folder, dest);
|
|
124
|
+
}
|
|
125
|
+
const notes = await this.listNoteFiles(dest);
|
|
126
|
+
return {
|
|
127
|
+
id: updated.id,
|
|
128
|
+
name: updated.name,
|
|
129
|
+
folder: dest,
|
|
130
|
+
group: updated.folder ?? null,
|
|
131
|
+
modified: updated.modified,
|
|
132
|
+
variableCount: notes.length,
|
|
133
|
+
quant: manifestIsQuant(updated),
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
async retitleModel(folder, name) {
|
|
137
|
+
const trimmed = name.trim();
|
|
138
|
+
if (trimmed.length === 0) {
|
|
139
|
+
throw new Error("Model title cannot be empty.");
|
|
140
|
+
}
|
|
141
|
+
// The inverse of renameModel: the folder is already where the user put it
|
|
142
|
+
// (e.g. they renamed it in Obsidian's file explorer), so the title follows
|
|
143
|
+
// the folder. Write the new name into model.json in place — never re-slug or
|
|
144
|
+
// move the directory, or we'd fight the rename the user just made.
|
|
145
|
+
const manifest = await this.readManifest(folder);
|
|
146
|
+
const updated = {
|
|
147
|
+
...manifest,
|
|
148
|
+
name: trimmed,
|
|
149
|
+
modified: new Date().toISOString(),
|
|
150
|
+
};
|
|
151
|
+
await this.writeManifest(folder, updated);
|
|
152
|
+
const notes = await this.listNoteFiles(folder);
|
|
153
|
+
return {
|
|
154
|
+
id: updated.id,
|
|
155
|
+
name: updated.name,
|
|
156
|
+
folder,
|
|
157
|
+
group: updated.folder ?? null,
|
|
158
|
+
modified: updated.modified,
|
|
159
|
+
variableCount: notes.length,
|
|
160
|
+
quant: manifestIsQuant(updated),
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Sync a node's label to its filename after the user renamed the file in the
|
|
165
|
+
* vault (`Nodes/<stem>.md`). The node-level inverse of {@link retitleModel}:
|
|
166
|
+
* the file is already where the user put it, so the label follows it (the
|
|
167
|
+
* de-slugged stem, underscores → spaces). {@link writeNote} may then
|
|
168
|
+
* re-normalize the on-disk name (spaces → underscores) so it converges. The
|
|
169
|
+
* stable `var_…` id and every link to it are untouched, so links never break.
|
|
170
|
+
*/
|
|
171
|
+
async relabelNodeFromFilename(folder, fileStem) {
|
|
172
|
+
let prev;
|
|
173
|
+
try {
|
|
174
|
+
const raw = await this.storage.read(this.notePath(folder, fileStem));
|
|
175
|
+
prev = parseNote(raw, this.yaml, fileStem);
|
|
176
|
+
}
|
|
177
|
+
catch {
|
|
178
|
+
return; // file vanished or unreadable between rename and handler — nothing to sync
|
|
179
|
+
}
|
|
180
|
+
const label = noteUnslug(fileStem);
|
|
181
|
+
if (label.length === 0 || label === prev.label)
|
|
182
|
+
return;
|
|
183
|
+
await this.writeNote(folder, prev, { ...prev, label });
|
|
184
|
+
await this.touchManifest(folder);
|
|
185
|
+
}
|
|
186
|
+
/** Target folder for renaming `folder`'s model to `name`: the new slug under
|
|
187
|
+
* the same parent, suffixed on collision; unchanged when the slug matches. */
|
|
188
|
+
async destForRename(folder, name) {
|
|
189
|
+
const current = baseName(folder);
|
|
190
|
+
const desired = slug(name) || current;
|
|
191
|
+
if (desired === current)
|
|
192
|
+
return folder;
|
|
193
|
+
const parent = parentPath(folder);
|
|
194
|
+
let leaf = desired;
|
|
195
|
+
let n = 2;
|
|
196
|
+
while (await this.storage.exists(joinPath(parent, leaf))) {
|
|
197
|
+
leaf = `${desired}-${n}`;
|
|
198
|
+
n++;
|
|
199
|
+
}
|
|
200
|
+
return joinPath(parent, leaf);
|
|
201
|
+
}
|
|
202
|
+
async deleteModel(folder) {
|
|
203
|
+
await this.storage.rmdir(folder);
|
|
204
|
+
}
|
|
205
|
+
/** Resolve a folder to its model id (read from `model.json`). */
|
|
206
|
+
async modelId(folder) {
|
|
207
|
+
return (await this.readManifest(folder)).id;
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Duplicate a model as a brand-new model: a byte-for-byte copy of the folder
|
|
211
|
+
* tree (so prose, layout, loop notes, and System note all come along), then a
|
|
212
|
+
* full re-key so the copy has a fresh model id, fresh variable ids, and fresh
|
|
213
|
+
* loop/System identity anchors. `shared` keys are PRESERVED, so the copy stays
|
|
214
|
+
* joined to the cross-model graph. Lands as a sibling of the source, titled
|
|
215
|
+
* "<name> (copy)" (bumping to "(copy 2)…" on a name clash).
|
|
216
|
+
*/
|
|
217
|
+
async duplicateModel(srcFolder) {
|
|
218
|
+
const manifest = await this.readManifest(srcFolder);
|
|
219
|
+
const copyName = await this.uniqueModelName(manifest.name);
|
|
220
|
+
const dest = await this.uniqueSiblingFolder(parentPath(srcFolder), slug(copyName) || "model");
|
|
221
|
+
await this.copyTree(srcFolder, dest);
|
|
222
|
+
const newId = await this.rekeyModel(dest);
|
|
223
|
+
// rekeyModel stamped the fresh id + modified; set the copy's title in place
|
|
224
|
+
// (the folder is already named from copyName's slug — don't re-slug/move).
|
|
225
|
+
const copied = await this.readManifest(dest);
|
|
226
|
+
await this.writeManifest(dest, { ...copied, name: copyName });
|
|
227
|
+
return {
|
|
228
|
+
id: newId,
|
|
229
|
+
name: copyName,
|
|
230
|
+
folder: dest,
|
|
231
|
+
group: copied.folder ?? null,
|
|
232
|
+
modified: copied.modified,
|
|
233
|
+
variableCount: (await this.listNoteFiles(dest)).length,
|
|
234
|
+
quant: manifestIsQuant(copied),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
/**
|
|
238
|
+
* Heal model-id collisions: a raw Obsidian copy of a model (or a folder of
|
|
239
|
+
* models) clones their ids verbatim, so two folders end up claiming the same
|
|
240
|
+
* `mdl_…`. Group folders by id; for each colliding group keep the OLDEST (by
|
|
241
|
+
* `created`) and re-key every newer sibling. Returns one row per re-keyed
|
|
242
|
+
* folder. A no-op (empty result) when every id is already unique.
|
|
243
|
+
*/
|
|
244
|
+
async healDuplicateIds() {
|
|
245
|
+
const models = await this.listModels();
|
|
246
|
+
const byId = new Map();
|
|
247
|
+
for (const m of models) {
|
|
248
|
+
const arr = byId.get(m.id);
|
|
249
|
+
if (arr)
|
|
250
|
+
arr.push(m);
|
|
251
|
+
else
|
|
252
|
+
byId.set(m.id, [m]);
|
|
253
|
+
}
|
|
254
|
+
const changed = [];
|
|
255
|
+
for (const [id, group] of byId) {
|
|
256
|
+
if (group.length < 2)
|
|
257
|
+
continue;
|
|
258
|
+
// Oldest model keeps the id; tie-break on folder path so the choice is
|
|
259
|
+
// deterministic (and stable across heal passes).
|
|
260
|
+
const ranked = await Promise.all(group.map(async (m) => ({
|
|
261
|
+
m,
|
|
262
|
+
created: (await this.readManifest(m.folder)).created,
|
|
263
|
+
})));
|
|
264
|
+
ranked.sort((a, b) => a.created !== b.created
|
|
265
|
+
? a.created.localeCompare(b.created)
|
|
266
|
+
: a.m.folder.localeCompare(b.m.folder));
|
|
267
|
+
for (const { m } of ranked.slice(1)) {
|
|
268
|
+
const newId = await this.rekeyModel(m.folder);
|
|
269
|
+
changed.push({ folder: m.folder, oldId: id, newId });
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return changed;
|
|
273
|
+
}
|
|
274
|
+
// ---- variables -----------------------------------------------------------
|
|
275
|
+
async addVariable(folder, init) {
|
|
276
|
+
const vf = {
|
|
277
|
+
...emptyVariable(genVarId(), init.label),
|
|
278
|
+
type: init.type ?? "auxiliary",
|
|
279
|
+
group: init.group,
|
|
280
|
+
shared: init.shared,
|
|
281
|
+
claLayer: init.claLayer,
|
|
282
|
+
x: init.x ?? 0,
|
|
283
|
+
y: init.y ?? 0,
|
|
284
|
+
};
|
|
285
|
+
const out = await this.writeNote(folder, undefined, vf);
|
|
286
|
+
await this.touchManifest(folder);
|
|
287
|
+
return out;
|
|
288
|
+
}
|
|
289
|
+
async updateVariable(folder, id, patch) {
|
|
290
|
+
const prev = await this.readNote(folder, id);
|
|
291
|
+
const next = { ...prev };
|
|
292
|
+
if (patch.label !== undefined)
|
|
293
|
+
next.label = patch.label;
|
|
294
|
+
if (patch.type !== undefined)
|
|
295
|
+
next.type = patch.type;
|
|
296
|
+
if (patch.group !== undefined)
|
|
297
|
+
next.group = patch.group ?? undefined;
|
|
298
|
+
if (patch.shared !== undefined)
|
|
299
|
+
next.shared = patch.shared ?? undefined;
|
|
300
|
+
if (patch.claLayer !== undefined)
|
|
301
|
+
next.claLayer = patch.claLayer ?? undefined;
|
|
302
|
+
if (patch.status !== undefined)
|
|
303
|
+
next.status = patch.status ?? undefined;
|
|
304
|
+
if (patch.tags !== undefined)
|
|
305
|
+
next.tags = [...patch.tags];
|
|
306
|
+
if (patch.body !== undefined)
|
|
307
|
+
next.body = patch.body;
|
|
308
|
+
const out = await this.writeNote(folder, prev, next);
|
|
309
|
+
await this.touchManifest(folder);
|
|
310
|
+
return out;
|
|
311
|
+
}
|
|
312
|
+
async setEquation(folder, id, patch) {
|
|
313
|
+
const prev = await this.readNote(folder, id);
|
|
314
|
+
const prevQuant = prev.extra["quant"] && typeof prev.extra["quant"] === "object"
|
|
315
|
+
? prev.extra["quant"]
|
|
316
|
+
: {};
|
|
317
|
+
const quant = { ...prevQuant };
|
|
318
|
+
// A provided field with a value sets it; an empty/blank value clears it;
|
|
319
|
+
// `undefined` leaves it as-is.
|
|
320
|
+
for (const key of ["equation", "initial", "units"]) {
|
|
321
|
+
const v = patch[key];
|
|
322
|
+
if (v === undefined)
|
|
323
|
+
continue;
|
|
324
|
+
const trimmed = v.trim();
|
|
325
|
+
if (trimmed.length === 0)
|
|
326
|
+
delete quant[key];
|
|
327
|
+
else
|
|
328
|
+
quant[key] = trimmed;
|
|
329
|
+
}
|
|
330
|
+
const extra = { ...prev.extra };
|
|
331
|
+
if (Object.keys(quant).length === 0)
|
|
332
|
+
delete extra["quant"];
|
|
333
|
+
else
|
|
334
|
+
extra["quant"] = quant;
|
|
335
|
+
const out = await this.writeNote(folder, prev, { ...prev, extra });
|
|
336
|
+
await this.touchManifest(folder);
|
|
337
|
+
return out;
|
|
338
|
+
}
|
|
339
|
+
async setSubsystem(folder, varId, child) {
|
|
340
|
+
const prev = await this.readNote(folder, varId);
|
|
341
|
+
// Match the app/CLI exactly: a relative wikilink to the child's System note,
|
|
342
|
+
// keyed on the child folder basename, aliased to the child's display name.
|
|
343
|
+
const dir = child ? child.folder.split("/").filter(Boolean).pop() ?? child.folder : "";
|
|
344
|
+
const link = child ? `[[../${dir}/System|${child.name}]]` : undefined;
|
|
345
|
+
await this.writeNote(folder, prev, { ...prev, subsystem: link });
|
|
346
|
+
await this.touchManifest(folder);
|
|
347
|
+
}
|
|
348
|
+
async moveVariable(folder, id, x, y) {
|
|
349
|
+
const prev = await this.readNote(folder, id);
|
|
350
|
+
// stampMeta treats an x/y-only change as cosmetic → rev/modified preserved.
|
|
351
|
+
await this.writeNote(folder, prev, { ...prev, x, y });
|
|
352
|
+
}
|
|
353
|
+
async removeVariable(folder, id) {
|
|
354
|
+
// The note is named by its label now, so resolve its real path by id; also
|
|
355
|
+
// clear any stale id-named copies (a legacy flat note or an old Nodes/<id>.md).
|
|
356
|
+
const resolved = (await this.noteFilesById(folder)).get(id);
|
|
357
|
+
if (resolved)
|
|
358
|
+
await this.storage.remove(resolved);
|
|
359
|
+
for (const p of [this.notePath(folder, id), this.legacyNotePath(folder, id)]) {
|
|
360
|
+
if (p !== resolved)
|
|
361
|
+
await this.storage.remove(p);
|
|
362
|
+
}
|
|
363
|
+
// Drop inbound links so loop detection and the manifest stay consistent.
|
|
364
|
+
const notes = await this.loadNotes(folder);
|
|
365
|
+
for (const n of notes) {
|
|
366
|
+
if (n.links.some((l) => l.to === id)) {
|
|
367
|
+
await this.writeNote(folder, n, {
|
|
368
|
+
...n,
|
|
369
|
+
links: n.links.filter((l) => l.to !== id),
|
|
370
|
+
});
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
await this.touchManifest(folder);
|
|
374
|
+
}
|
|
375
|
+
// ---- links ---------------------------------------------------------------
|
|
376
|
+
async addLink(folder, from, to, init = {}) {
|
|
377
|
+
const prev = await this.readNote(folder, from);
|
|
378
|
+
const links = prev.links.filter((l) => l.to !== to);
|
|
379
|
+
links.push(makeLink(to, init));
|
|
380
|
+
await this.writeNote(folder, prev, { ...prev, links });
|
|
381
|
+
await this.touchManifest(folder);
|
|
382
|
+
}
|
|
383
|
+
async updateLink(folder, from, to, patch) {
|
|
384
|
+
const prev = await this.readNote(folder, from);
|
|
385
|
+
const links = prev.links.map((l) => l.to === to ? applyLinkPatch(l, patch) : l);
|
|
386
|
+
await this.writeNote(folder, prev, { ...prev, links });
|
|
387
|
+
await this.touchManifest(folder);
|
|
388
|
+
}
|
|
389
|
+
async removeLink(folder, from, to) {
|
|
390
|
+
const prev = await this.readNote(folder, from);
|
|
391
|
+
await this.writeNote(folder, prev, {
|
|
392
|
+
...prev,
|
|
393
|
+
links: prev.links.filter((l) => l.to !== to),
|
|
394
|
+
});
|
|
395
|
+
await this.touchManifest(folder);
|
|
396
|
+
}
|
|
397
|
+
// ---- bulk build / layout / viewport --------------------------------------
|
|
398
|
+
async buildModel(folder, spec) {
|
|
399
|
+
const idByRef = new Map();
|
|
400
|
+
const created = [];
|
|
401
|
+
for (const v of spec.variables) {
|
|
402
|
+
const id = v.id && v.id.trim() ? v.id.trim() : genVarId();
|
|
403
|
+
created.push({
|
|
404
|
+
...emptyVariable(id, v.label),
|
|
405
|
+
type: v.type ?? "auxiliary",
|
|
406
|
+
group: v.group,
|
|
407
|
+
shared: v.shared,
|
|
408
|
+
});
|
|
409
|
+
idByRef.set(v.label, id);
|
|
410
|
+
idByRef.set(id, id);
|
|
411
|
+
}
|
|
412
|
+
const resolve = (ref) => idByRef.get(ref) ?? ref;
|
|
413
|
+
const byId = new Map(created.map((v) => [v.id, v]));
|
|
414
|
+
for (const lk of spec.links) {
|
|
415
|
+
const src = byId.get(resolve(lk.from));
|
|
416
|
+
if (!src)
|
|
417
|
+
continue; // link from an unknown source — skip
|
|
418
|
+
src.links = src.links.filter((l) => l.to !== resolve(lk.to));
|
|
419
|
+
src.links.push(makeLink(resolve(lk.to), lk));
|
|
420
|
+
}
|
|
421
|
+
if (spec.layout !== false)
|
|
422
|
+
applyLayout(created);
|
|
423
|
+
for (const v of created)
|
|
424
|
+
await this.writeNote(folder, undefined, v);
|
|
425
|
+
await this.touchManifest(folder);
|
|
426
|
+
}
|
|
427
|
+
async relayout(folder) {
|
|
428
|
+
const notes = await this.loadNotes(folder);
|
|
429
|
+
const pos = layoutPositions(notes);
|
|
430
|
+
for (const n of notes) {
|
|
431
|
+
const p = pos.get(n.id);
|
|
432
|
+
if (p)
|
|
433
|
+
await this.writeNote(folder, n, { ...n, x: p[0], y: p[1] });
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async setViewport(folder, viewport) {
|
|
437
|
+
const manifest = await this.readManifest(folder);
|
|
438
|
+
// Viewport is cosmetic — write the manifest without touching `modified`.
|
|
439
|
+
await this.writeManifest(folder, { ...manifest, viewport });
|
|
440
|
+
}
|
|
441
|
+
// ---- loop notes (Loops/*.md — Obsidian-native, shared with the Dart app) --
|
|
442
|
+
//
|
|
443
|
+
// Each feedback loop's annotation is one `Loops/<slug>.md` file whose identity
|
|
444
|
+
// is the loop's (type, canonical-ordered member ids) in frontmatter — never the
|
|
445
|
+
// filename or the labels. The legacy `model.json` maps (loopNotes/loopTitles/
|
|
446
|
+
// loopValence/loopArchetypes) are auto-migrated to files on first touch. This
|
|
447
|
+
// mirrors `core/lib/cli/vault_engine.dart` so the plugin and the app/CLI agree
|
|
448
|
+
// on one vault. Resolved maps stay keyed by the legacy `<R|B>:<sorted labels>`
|
|
449
|
+
// key (see `loopKey`) so existing UI consumers are unchanged.
|
|
450
|
+
async getLoopNotes(folder) {
|
|
451
|
+
await this.migrateLoopNotesIfNeeded(folder);
|
|
452
|
+
const files = await this.listLoopNoteFiles(folder);
|
|
453
|
+
if (files.length === 0) {
|
|
454
|
+
// Pre-migration fallback: no Loops/ files → serve the manifest map.
|
|
455
|
+
const manifest = await this.readManifest(folder);
|
|
456
|
+
const out = {};
|
|
457
|
+
for (const [k, v] of Object.entries(readLoopNotes(manifest))) {
|
|
458
|
+
if (v.trim().length > 0)
|
|
459
|
+
out[k] = v;
|
|
460
|
+
}
|
|
461
|
+
return out;
|
|
462
|
+
}
|
|
463
|
+
const graph = new LoopGraph(await this.loadNotes(folder));
|
|
464
|
+
const nameOf = (id) => graph.node(id)?.label ?? id;
|
|
465
|
+
const out = {};
|
|
466
|
+
const matched = new Set();
|
|
467
|
+
for (const l of graph.detectLoops()) {
|
|
468
|
+
const type = l.type === LoopType.reinforcing ? "R" : "B";
|
|
469
|
+
let note;
|
|
470
|
+
for (const f of files) {
|
|
471
|
+
if (matched.has(f))
|
|
472
|
+
continue;
|
|
473
|
+
const parsed = parseLoopNote((await this.readLoopNoteFile(folder, f)) ?? "", this.yaml);
|
|
474
|
+
if (loopMatchesNote(type, l.nodeIds, parsed)) {
|
|
475
|
+
matched.add(f);
|
|
476
|
+
note = parsed;
|
|
477
|
+
break;
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (!note)
|
|
481
|
+
continue;
|
|
482
|
+
if (note.body.trim().length > 0) {
|
|
483
|
+
out[loopKey(l.nodeIds.map(nameOf), type)] = note.body;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
return out;
|
|
487
|
+
}
|
|
488
|
+
async setLoopNote(folder, key, text) {
|
|
489
|
+
await this.upsertLoopFileByKey(folder, key, { note: text });
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* The vault-relative path of the canonical `Loops/*.md` file for `key`,
|
|
493
|
+
* creating an empty-but-anchored note if none exists yet. Returns null when no
|
|
494
|
+
* live loop carries that key (e.g. the graph changed). Used by the canvas to
|
|
495
|
+
* open the real markdown file for editing.
|
|
496
|
+
*/
|
|
497
|
+
async loopNotePath(folder, key) {
|
|
498
|
+
await this.migrateLoopNotesIfNeeded(folder);
|
|
499
|
+
const graph = new LoopGraph(await this.loadNotes(folder));
|
|
500
|
+
const nameOf = (id) => graph.node(id)?.label ?? id;
|
|
501
|
+
for (const l of graph.detectLoops()) {
|
|
502
|
+
const type = l.type === LoopType.reinforcing ? "R" : "B";
|
|
503
|
+
const memberLabels = l.nodeIds.map(nameOf);
|
|
504
|
+
if (loopKey(memberLabels, type) !== key)
|
|
505
|
+
continue;
|
|
506
|
+
for (const f of await this.listLoopNoteFiles(folder)) {
|
|
507
|
+
const parsed = parseLoopNote((await this.readLoopNoteFile(folder, f)) ?? "", this.yaml);
|
|
508
|
+
if (loopMatchesNote(type, l.nodeIds, parsed)) {
|
|
509
|
+
return joinPath(this.loopsDir(folder), f);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
const file = await this.writeLoopFile(folder, l.nodeIds, type, memberLabels, {});
|
|
513
|
+
return joinPath(this.loopsDir(folder), file);
|
|
514
|
+
}
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
async ensureSystemNote(folder) {
|
|
518
|
+
const path = joinPath(folder, "System.md");
|
|
519
|
+
if (!(await this.storage.exists(path))) {
|
|
520
|
+
const manifest = await this.readManifest(folder);
|
|
521
|
+
// Minimal valid System note — frontmatter only. Mirrors the required half
|
|
522
|
+
// of core/lib/vault/system_note.dart (model id); `h`/summary/body are
|
|
523
|
+
// optional and stamped by the app on its next write.
|
|
524
|
+
await this.storage.write(path, `---\nmodel: ${JSON.stringify(manifest.id)}\n---\n`);
|
|
525
|
+
}
|
|
526
|
+
return path;
|
|
527
|
+
}
|
|
528
|
+
async deriveParents(folder) {
|
|
529
|
+
const models = await this.listModels();
|
|
530
|
+
const current = models.find((m) => m.folder === folder);
|
|
531
|
+
if (!current)
|
|
532
|
+
return [];
|
|
533
|
+
return deriveParentAnchors({ folder: current.folder, name: current.name }, models.filter((m) => m.folder !== folder).map((m) => ({ folder: m.folder, name: m.name })), (f) => this.loadNotes(f));
|
|
534
|
+
}
|
|
535
|
+
// ---- export --------------------------------------------------------------
|
|
536
|
+
async export(folder, format) {
|
|
537
|
+
const view = await this.loadGraph(folder);
|
|
538
|
+
const { nodes, edges, loops } = toExportGraph(view);
|
|
539
|
+
return render(format, view.manifest.id, view.manifest.name, nodes, edges, loops);
|
|
540
|
+
}
|
|
541
|
+
// ---- internals -----------------------------------------------------------
|
|
542
|
+
/** The model's `Nodes/` subfolder, where variable notes now live. */
|
|
543
|
+
nodesDir(folder) {
|
|
544
|
+
return joinPath(folder, "Nodes");
|
|
545
|
+
}
|
|
546
|
+
/** A variable note's path for a filename stem: `<folder>/Nodes/<stem>.md`. */
|
|
547
|
+
notePath(folder, stem) {
|
|
548
|
+
return joinPath(this.nodesDir(folder), `${stem}.md`);
|
|
549
|
+
}
|
|
550
|
+
/** Pre-`Nodes/` location of a variable note, flat at the model root. */
|
|
551
|
+
legacyNotePath(folder, stem) {
|
|
552
|
+
return joinPath(folder, `${stem}.md`);
|
|
553
|
+
}
|
|
554
|
+
async readNote(folder, id) {
|
|
555
|
+
// The filename tracks the label, so resolve by frontmatter id. Fall back to
|
|
556
|
+
// the canonical id-named path for an id that isn't on disk yet.
|
|
557
|
+
const path = (await this.noteFilesById(folder)).get(id) ?? this.notePath(folder, id);
|
|
558
|
+
return parseNote(await this.storage.read(path), this.yaml, id);
|
|
559
|
+
}
|
|
560
|
+
async writeNote(folder, prev, next) {
|
|
561
|
+
const stamped = stampMeta(prev, next, WRITE_SOURCE_PLUGIN);
|
|
562
|
+
// The file is named after the label (so the vault reads like the diagram);
|
|
563
|
+
// a label-less node falls back to its stable id. Dedupe against the names
|
|
564
|
+
// already taken by *other* notes, suffixing `-2/-3…` like model folders.
|
|
565
|
+
const byId = await this.noteFilesById(folder);
|
|
566
|
+
const currentPath = byId.get(stamped.id);
|
|
567
|
+
const desired = noteSlug(stamped.label) || stamped.id;
|
|
568
|
+
const taken = new Set();
|
|
569
|
+
for (const [otherId, p] of byId) {
|
|
570
|
+
if (otherId !== stamped.id)
|
|
571
|
+
taken.add(baseName(p).replace(/\.md$/, ""));
|
|
572
|
+
}
|
|
573
|
+
let stem = desired;
|
|
574
|
+
let n = 2;
|
|
575
|
+
while (taken.has(stem))
|
|
576
|
+
stem = `${desired}-${n++}`;
|
|
577
|
+
const target = this.notePath(folder, stem);
|
|
578
|
+
await this.storage.write(target, serializeNote(stamped));
|
|
579
|
+
// Migrate on write: drop the node's previous file if its name changed (an
|
|
580
|
+
// id-named or differently-labelled copy), plus any legacy flat copy.
|
|
581
|
+
if (currentPath && currentPath !== target) {
|
|
582
|
+
await this.storage.remove(currentPath);
|
|
583
|
+
}
|
|
584
|
+
const legacy = this.legacyNotePath(folder, stamped.id);
|
|
585
|
+
if (legacy !== target && legacy !== currentPath && (await this.storage.exists(legacy))) {
|
|
586
|
+
await this.storage.remove(legacy);
|
|
587
|
+
}
|
|
588
|
+
return stamped;
|
|
589
|
+
}
|
|
590
|
+
async readManifest(folder) {
|
|
591
|
+
const raw = await this.storage.read(joinPath(folder, "model.json"));
|
|
592
|
+
const j = JSON.parse(raw);
|
|
593
|
+
if (j["id"] === undefined || j["id"] === null)
|
|
594
|
+
j["id"] = baseName(folder);
|
|
595
|
+
return manifestFromJson(j);
|
|
596
|
+
}
|
|
597
|
+
async writeManifest(folder, manifest) {
|
|
598
|
+
await this.storage.write(joinPath(folder, "model.json"), JSON.stringify(manifestToJson(manifest), null, 2));
|
|
599
|
+
}
|
|
600
|
+
/** Bump `model.json` modified after a content change. */
|
|
601
|
+
async touchManifest(folder) {
|
|
602
|
+
try {
|
|
603
|
+
const manifest = await this.readManifest(folder);
|
|
604
|
+
await this.writeManifest(folder, {
|
|
605
|
+
...manifest,
|
|
606
|
+
modified: new Date().toISOString(),
|
|
607
|
+
});
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
// No manifest yet (mid-build) — ignore.
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
/**
|
|
614
|
+
* Variable-note id → vault path, gathered from the model's `Nodes/` subfolder
|
|
615
|
+
* and any legacy flat notes at the model root. A `Nodes/` note wins on a
|
|
616
|
+
* duplicate id (mirrors the Dart reader). `model.json` and the special notes
|
|
617
|
+
* (System/Futures/CLA) are excluded.
|
|
618
|
+
*/
|
|
619
|
+
async noteFilesById(folder) {
|
|
620
|
+
const byId = new Map();
|
|
621
|
+
const collect = async (files, excludeSpecial) => {
|
|
622
|
+
for (const f of files) {
|
|
623
|
+
const b = baseName(f);
|
|
624
|
+
if (!b.endsWith(".md"))
|
|
625
|
+
continue;
|
|
626
|
+
if (excludeSpecial && SPECIAL_NOTES.has(b))
|
|
627
|
+
continue;
|
|
628
|
+
// The filename no longer is the id — read it from the frontmatter
|
|
629
|
+
// (falling back to the filename stem for a note that lacks one).
|
|
630
|
+
const id = parseNote(await this.storage.read(f), this.yaml, b.replace(/\.md$/, "")).id;
|
|
631
|
+
byId.set(id, f);
|
|
632
|
+
}
|
|
633
|
+
};
|
|
634
|
+
// Legacy flat notes first, then let canonical `Nodes/` notes override.
|
|
635
|
+
await collect((await this.storage.list(folder)).files, true);
|
|
636
|
+
try {
|
|
637
|
+
await collect((await this.storage.list(this.nodesDir(folder))).files, false);
|
|
638
|
+
}
|
|
639
|
+
catch {
|
|
640
|
+
// No `Nodes/` subfolder — flat-only (legacy) layout.
|
|
641
|
+
}
|
|
642
|
+
return byId;
|
|
643
|
+
}
|
|
644
|
+
async listNoteFiles(folder) {
|
|
645
|
+
return [...(await this.noteFilesById(folder)).values()];
|
|
646
|
+
}
|
|
647
|
+
async loadNotes(folder) {
|
|
648
|
+
const byId = new Map();
|
|
649
|
+
const collect = async (files, excludeSpecial, canonical) => {
|
|
650
|
+
for (const f of files) {
|
|
651
|
+
const b = baseName(f);
|
|
652
|
+
if (!b.endsWith(".md"))
|
|
653
|
+
continue;
|
|
654
|
+
if (excludeSpecial && SPECIAL_NOTES.has(b))
|
|
655
|
+
continue;
|
|
656
|
+
const file = parseNote(await this.storage.read(f), this.yaml, b.replace(/\.md$/, ""));
|
|
657
|
+
// Canonical `Nodes/` notes override a legacy flat note with the same id.
|
|
658
|
+
if (canonical || !byId.has(file.id))
|
|
659
|
+
byId.set(file.id, file);
|
|
660
|
+
}
|
|
661
|
+
};
|
|
662
|
+
await collect((await this.storage.list(folder)).files, true, false);
|
|
663
|
+
try {
|
|
664
|
+
await collect((await this.storage.list(this.nodesDir(folder))).files, false, true);
|
|
665
|
+
}
|
|
666
|
+
catch {
|
|
667
|
+
// No `Nodes/` subfolder — flat-only (legacy) layout.
|
|
668
|
+
}
|
|
669
|
+
return [...byId.values()];
|
|
670
|
+
}
|
|
671
|
+
// ---- loop-note file storage (the model's `Loops/` subfolder) -------------
|
|
672
|
+
loopsDir(folder) {
|
|
673
|
+
return joinPath(folder, "Loops");
|
|
674
|
+
}
|
|
675
|
+
/** Sorted `.md` basenames in `Loops/`; tolerant of a missing folder. */
|
|
676
|
+
async listLoopNoteFiles(folder) {
|
|
677
|
+
let listing;
|
|
678
|
+
try {
|
|
679
|
+
listing = await this.storage.list(this.loopsDir(folder));
|
|
680
|
+
}
|
|
681
|
+
catch {
|
|
682
|
+
return [];
|
|
683
|
+
}
|
|
684
|
+
return listing.files
|
|
685
|
+
.map(baseName)
|
|
686
|
+
.filter((b) => b.endsWith(".md"))
|
|
687
|
+
.sort();
|
|
688
|
+
}
|
|
689
|
+
async readLoopNoteFile(folder, filename) {
|
|
690
|
+
try {
|
|
691
|
+
return await this.storage.read(joinPath(this.loopsDir(folder), filename));
|
|
692
|
+
}
|
|
693
|
+
catch {
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
async writeLoopNoteFile(folder, filename, content) {
|
|
698
|
+
await this.storage.write(joinPath(this.loopsDir(folder), filename), content);
|
|
699
|
+
}
|
|
700
|
+
async uniqueLoopFilename(folder, slug) {
|
|
701
|
+
const taken = new Set(await this.listLoopNoteFiles(folder));
|
|
702
|
+
let name = `${slug}.md`;
|
|
703
|
+
let n = 2;
|
|
704
|
+
while (taken.has(name)) {
|
|
705
|
+
name = `${slug}-${n}.md`;
|
|
706
|
+
n++;
|
|
707
|
+
}
|
|
708
|
+
return name;
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* One-way, idempotent migration of legacy loop annotations from the
|
|
712
|
+
* `model.json` maps into `Loops/*.md` files. Guard: if any `Loops/` file
|
|
713
|
+
* exists the model is treated as migrated and this returns immediately. Each
|
|
714
|
+
* legacy key is matched to a live loop by its `loopKey`; a matched key keeps
|
|
715
|
+
* the loop's canonical member ids, an unmatched key becomes a pre-flagged
|
|
716
|
+
* orphan (`members: []`, original key kept in `loop:`) so nothing is lost.
|
|
717
|
+
* After writing, the four legacy keys are stripped from the manifest.
|
|
718
|
+
*/
|
|
719
|
+
async migrateLoopNotesIfNeeded(folder) {
|
|
720
|
+
if ((await this.listLoopNoteFiles(folder)).length > 0)
|
|
721
|
+
return;
|
|
722
|
+
const manifest = await this.readManifest(folder);
|
|
723
|
+
const fields = ["loopNotes", "loopTitles", "loopValence", "loopArchetypes"];
|
|
724
|
+
const mapOf = (f) => {
|
|
725
|
+
const m = manifest.extra[f];
|
|
726
|
+
const out = {};
|
|
727
|
+
if (m && typeof m === "object" && !Array.isArray(m)) {
|
|
728
|
+
for (const [k, v] of Object.entries(m))
|
|
729
|
+
out[k] = String(v);
|
|
730
|
+
}
|
|
731
|
+
return out;
|
|
732
|
+
};
|
|
733
|
+
const hasLegacy = fields.some((f) => Object.keys(mapOf(f)).length > 0);
|
|
734
|
+
if (!hasLegacy)
|
|
735
|
+
return;
|
|
736
|
+
const notes = mapOf("loopNotes");
|
|
737
|
+
const titles = mapOf("loopTitles");
|
|
738
|
+
const valence = mapOf("loopValence");
|
|
739
|
+
const archetypes = mapOf("loopArchetypes");
|
|
740
|
+
const allKeys = new Set([
|
|
741
|
+
...Object.keys(notes),
|
|
742
|
+
...Object.keys(titles),
|
|
743
|
+
...Object.keys(valence),
|
|
744
|
+
...Object.keys(archetypes),
|
|
745
|
+
]);
|
|
746
|
+
const graph = new LoopGraph(await this.loadNotes(folder));
|
|
747
|
+
const nameOf = (id) => graph.node(id)?.label ?? id;
|
|
748
|
+
const liveByKey = new Map();
|
|
749
|
+
for (const l of graph.detectLoops()) {
|
|
750
|
+
const type = l.type === LoopType.reinforcing ? "R" : "B";
|
|
751
|
+
liveByKey.set(loopKey(l.nodeIds.map(nameOf), type), l);
|
|
752
|
+
}
|
|
753
|
+
for (const key of allKeys) {
|
|
754
|
+
const type = key.startsWith("B") ? "B" : "R";
|
|
755
|
+
const live = liveByKey.get(key);
|
|
756
|
+
const members = live ? canonicalLoopMembers(live.nodeIds) : [];
|
|
757
|
+
const labels = live
|
|
758
|
+
? live.nodeIds.map(nameOf)
|
|
759
|
+
: key.includes(":")
|
|
760
|
+
? key.substring(key.indexOf(":") + 1).split("|")
|
|
761
|
+
: [];
|
|
762
|
+
const arch = archetypes[key] ?? "";
|
|
763
|
+
const note = {
|
|
764
|
+
type,
|
|
765
|
+
members,
|
|
766
|
+
title: titles[key] ?? "",
|
|
767
|
+
valence: valence[key] ?? "",
|
|
768
|
+
loopEcho: loopEchoLabel(labels, type),
|
|
769
|
+
body: notes[key] ?? "",
|
|
770
|
+
extra: arch.length > 0 ? { archetype: arch } : {},
|
|
771
|
+
malformed: false,
|
|
772
|
+
};
|
|
773
|
+
const filename = await this.uniqueLoopFilename(folder, loopSlug(type, note.title, labels));
|
|
774
|
+
await this.writeLoopNoteFile(folder, filename, serializeLoopNote(note));
|
|
775
|
+
}
|
|
776
|
+
const extra = { ...manifest.extra };
|
|
777
|
+
for (const f of fields)
|
|
778
|
+
delete extra[f];
|
|
779
|
+
await this.writeManifest(folder, {
|
|
780
|
+
...manifest,
|
|
781
|
+
extra,
|
|
782
|
+
modified: new Date().toISOString(),
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
/** Find the live loop whose `loopKey` == `key`, then upsert its file. No-op
|
|
786
|
+
* (and no file) when the graph carries no such loop. */
|
|
787
|
+
async upsertLoopFileByKey(folder, key, fields) {
|
|
788
|
+
await this.migrateLoopNotesIfNeeded(folder);
|
|
789
|
+
const graph = new LoopGraph(await this.loadNotes(folder));
|
|
790
|
+
const nameOf = (id) => graph.node(id)?.label ?? id;
|
|
791
|
+
for (const l of graph.detectLoops()) {
|
|
792
|
+
const type = l.type === LoopType.reinforcing ? "R" : "B";
|
|
793
|
+
const memberLabels = l.nodeIds.map(nameOf);
|
|
794
|
+
if (loopKey(memberLabels, type) !== key)
|
|
795
|
+
continue;
|
|
796
|
+
await this.writeLoopFile(folder, l.nodeIds, type, memberLabels, fields);
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
/** Find-or-create the identity-matched `Loops/` file and apply `fields`
|
|
801
|
+
* (undefined leaves a field unchanged; `archetype: ""` clears it). Returns the
|
|
802
|
+
* filename written. Title/valence/extra of an existing file are preserved. */
|
|
803
|
+
async writeLoopFile(folder, nodeIds, type, memberLabels, fields) {
|
|
804
|
+
const members = canonicalLoopMembers(nodeIds);
|
|
805
|
+
let filename;
|
|
806
|
+
let existing = {
|
|
807
|
+
type,
|
|
808
|
+
members,
|
|
809
|
+
title: "",
|
|
810
|
+
valence: "",
|
|
811
|
+
loopEcho: "",
|
|
812
|
+
body: "",
|
|
813
|
+
extra: {},
|
|
814
|
+
malformed: false,
|
|
815
|
+
};
|
|
816
|
+
for (const f of await this.listLoopNoteFiles(folder)) {
|
|
817
|
+
const parsed = parseLoopNote((await this.readLoopNoteFile(folder, f)) ?? "", this.yaml);
|
|
818
|
+
if (loopMatchesNote(type, nodeIds, parsed)) {
|
|
819
|
+
filename = f;
|
|
820
|
+
existing = parsed;
|
|
821
|
+
break;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
const extra = { ...existing.extra };
|
|
825
|
+
if (fields.archetype !== undefined) {
|
|
826
|
+
if (fields.archetype.trim().length === 0)
|
|
827
|
+
delete extra["archetype"];
|
|
828
|
+
else
|
|
829
|
+
extra["archetype"] = fields.archetype.trim();
|
|
830
|
+
}
|
|
831
|
+
const next = {
|
|
832
|
+
type,
|
|
833
|
+
members,
|
|
834
|
+
title: fields.title ?? existing.title,
|
|
835
|
+
valence: fields.valence ?? existing.valence,
|
|
836
|
+
loopEcho: loopEchoLabel(memberLabels, type),
|
|
837
|
+
body: fields.note ?? existing.body,
|
|
838
|
+
extra,
|
|
839
|
+
malformed: false,
|
|
840
|
+
};
|
|
841
|
+
if (filename === undefined) {
|
|
842
|
+
filename = await this.uniqueLoopFilename(folder, loopSlug(type, next.title, memberLabels));
|
|
843
|
+
}
|
|
844
|
+
await this.writeLoopNoteFile(folder, filename, serializeLoopNote(next));
|
|
845
|
+
return filename;
|
|
846
|
+
}
|
|
847
|
+
/** Recursively find folders containing a `model.json` (dot-folders pruned). */
|
|
848
|
+
async findModelFolders() {
|
|
849
|
+
const out = [];
|
|
850
|
+
const visit = async (dir, depth) => {
|
|
851
|
+
if (depth > MAX_SCAN_DEPTH)
|
|
852
|
+
return;
|
|
853
|
+
let listing;
|
|
854
|
+
try {
|
|
855
|
+
listing = await this.storage.list(dir);
|
|
856
|
+
}
|
|
857
|
+
catch {
|
|
858
|
+
return;
|
|
859
|
+
}
|
|
860
|
+
if (listing.files.some((f) => baseName(f) === "model.json")) {
|
|
861
|
+
out.push(dir);
|
|
862
|
+
return; // Prune: a model's `Nodes/`/`Loops/` subfolders aren't models.
|
|
863
|
+
}
|
|
864
|
+
for (const sub of listing.folders) {
|
|
865
|
+
if (baseName(sub).startsWith("."))
|
|
866
|
+
continue;
|
|
867
|
+
await visit(sub, depth + 1);
|
|
868
|
+
}
|
|
869
|
+
};
|
|
870
|
+
// Discover models across the WHOLE vault, not just `modelsRoot`. The latter
|
|
871
|
+
// only governs where *new* models are created (createModel); existing
|
|
872
|
+
// vaults — and the Dart app/CLI — write models at the vault top level too.
|
|
873
|
+
await visit("", 0);
|
|
874
|
+
return out;
|
|
875
|
+
}
|
|
876
|
+
// ---- duplicate / re-key internals ----------------------------------------
|
|
877
|
+
/**
|
|
878
|
+
* Re-key a model in place: assign a fresh model id, fresh variable ids, and
|
|
879
|
+
* re-point every internal reference (links, loop-note members, the System
|
|
880
|
+
* note's identity anchor). Structure, prose, layout, `shared` keys, and
|
|
881
|
+
* subsystem wikilinks are untouched. Returns the new model id. Used by both
|
|
882
|
+
* {@link duplicateModel} (on a fresh copy) and {@link healDuplicateIds} (on a
|
|
883
|
+
* collided clone).
|
|
884
|
+
*/
|
|
885
|
+
async rekeyModel(folder) {
|
|
886
|
+
const manifest = await this.readManifest(folder);
|
|
887
|
+
const notes = await this.loadNotes(folder);
|
|
888
|
+
const idMap = new Map();
|
|
889
|
+
for (const v of notes)
|
|
890
|
+
idMap.set(v.id, genVarId());
|
|
891
|
+
const newModelId = genModelId();
|
|
892
|
+
// Notes are filename-by-label, so a naive per-note rewrite would collide the
|
|
893
|
+
// new files against the still-present old ones (same label → same stem →
|
|
894
|
+
// `-2` phantom duplicates). Clear them all first, then write each remapped
|
|
895
|
+
// note into an empty `Nodes/`.
|
|
896
|
+
await this.removeAllVariableFiles(folder);
|
|
897
|
+
for (const v of notes) {
|
|
898
|
+
await this.writeNote(folder, undefined, remapVariableIds(v, idMap));
|
|
899
|
+
}
|
|
900
|
+
// Re-point each loop note's members (its stable identity) to the new ids.
|
|
901
|
+
for (const filename of await this.listLoopNoteFiles(folder)) {
|
|
902
|
+
const raw = await this.readLoopNoteFile(folder, filename);
|
|
903
|
+
if (raw === null)
|
|
904
|
+
continue;
|
|
905
|
+
const note = parseLoopNote(raw, this.yaml);
|
|
906
|
+
const members = canonicalLoopMembers(note.members.map((m) => idMap.get(m) ?? m));
|
|
907
|
+
await this.writeLoopNoteFile(folder, filename, serializeLoopNote({ ...note, members }));
|
|
908
|
+
}
|
|
909
|
+
await this.swapSystemModelId(folder, newModelId);
|
|
910
|
+
await this.writeManifest(folder, {
|
|
911
|
+
...manifest,
|
|
912
|
+
id: newModelId,
|
|
913
|
+
modified: new Date().toISOString(),
|
|
914
|
+
});
|
|
915
|
+
return newModelId;
|
|
916
|
+
}
|
|
917
|
+
/** Recursively copy a folder tree byte-for-byte (the source is untouched). */
|
|
918
|
+
async copyTree(src, dst) {
|
|
919
|
+
await this.storage.mkdirs(dst);
|
|
920
|
+
const listing = await this.storage.list(src);
|
|
921
|
+
for (const f of listing.files) {
|
|
922
|
+
await this.storage.write(joinPath(dst, baseName(f)), await this.storage.read(f));
|
|
923
|
+
}
|
|
924
|
+
for (const sub of listing.folders) {
|
|
925
|
+
await this.copyTree(sub, joinPath(dst, baseName(sub)));
|
|
926
|
+
}
|
|
927
|
+
}
|
|
928
|
+
/** Remove every variable note (flat root + `Nodes/`); keep the special notes. */
|
|
929
|
+
async removeAllVariableFiles(folder) {
|
|
930
|
+
const root = (await this.storage.list(folder)).files.filter((f) => baseName(f).endsWith(".md") && !SPECIAL_NOTES.has(baseName(f)));
|
|
931
|
+
for (const f of root)
|
|
932
|
+
await this.storage.remove(f);
|
|
933
|
+
try {
|
|
934
|
+
const nodes = (await this.storage.list(this.nodesDir(folder))).files.filter((f) => baseName(f).endsWith(".md"));
|
|
935
|
+
for (const f of nodes)
|
|
936
|
+
await this.storage.remove(f);
|
|
937
|
+
}
|
|
938
|
+
catch {
|
|
939
|
+
// No `Nodes/` subfolder — flat-only (legacy) layout.
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
/** Rewrite `System.md`'s `model:` identity anchor to `newId` (if it exists). */
|
|
943
|
+
async swapSystemModelId(folder, newId) {
|
|
944
|
+
const path = joinPath(folder, "System.md");
|
|
945
|
+
if (!(await this.storage.exists(path)))
|
|
946
|
+
return;
|
|
947
|
+
const [fm, body] = splitFrontmatter(await this.storage.read(path));
|
|
948
|
+
if (fm === null)
|
|
949
|
+
return; // no frontmatter to anchor — leave as-is
|
|
950
|
+
const line = `model: ${JSON.stringify(newId)}`;
|
|
951
|
+
const nextFm = /^\s*model:\s*.*$/m.test(fm)
|
|
952
|
+
? fm.replace(/^\s*model:\s*.*$/m, line)
|
|
953
|
+
: `${line}\n${fm}`;
|
|
954
|
+
let out = `---\n${nextFm}${nextFm.endsWith("\n") ? "" : "\n"}---\n`;
|
|
955
|
+
if (body.trim().length > 0)
|
|
956
|
+
out += `\n${body.trim()}\n`;
|
|
957
|
+
await this.storage.write(path, out);
|
|
958
|
+
}
|
|
959
|
+
/** "<base> (copy)" — bumping "(copy 2)…" against existing model titles. */
|
|
960
|
+
async uniqueModelName(base) {
|
|
961
|
+
const taken = new Set((await this.listModels()).map((m) => m.name.toLowerCase()));
|
|
962
|
+
let candidate = `${base} (copy)`;
|
|
963
|
+
let n = 2;
|
|
964
|
+
while (taken.has(candidate.toLowerCase()))
|
|
965
|
+
candidate = `${base} (copy ${n++})`;
|
|
966
|
+
return candidate;
|
|
967
|
+
}
|
|
968
|
+
/** A free folder `<parent>/<leaf>` (suffixing `-2/-3…` like createModel). */
|
|
969
|
+
async uniqueSiblingFolder(parent, leaf) {
|
|
970
|
+
let folder = joinPath(parent, leaf);
|
|
971
|
+
let n = 2;
|
|
972
|
+
while (await this.storage.exists(folder)) {
|
|
973
|
+
folder = joinPath(parent, `${leaf}-${n}`);
|
|
974
|
+
n++;
|
|
975
|
+
}
|
|
976
|
+
return folder;
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
// ---- pure helpers ----------------------------------------------------------
|
|
980
|
+
/** A copy of `v` with its id and every link target re-pointed through `idMap`. */
|
|
981
|
+
function remapVariableIds(v, idMap) {
|
|
982
|
+
return {
|
|
983
|
+
...v,
|
|
984
|
+
id: idMap.get(v.id) ?? v.id,
|
|
985
|
+
links: v.links.map((l) => ({ ...l, to: idMap.get(l.to) ?? l.to })),
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
function makeLink(to, init) {
|
|
989
|
+
return {
|
|
990
|
+
to,
|
|
991
|
+
polarity: init.polarity === "-" ? "-" : "+",
|
|
992
|
+
delay: init.delay === true,
|
|
993
|
+
indirect: init.indirect === true,
|
|
994
|
+
nonlinear: init.nonlinear === true,
|
|
995
|
+
weight: init.weight,
|
|
996
|
+
curvature: init.curvature,
|
|
997
|
+
confidence: normalizeConfidence(init.confidence),
|
|
998
|
+
basis: normalizeBasis(init.basis),
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
function applyLinkPatch(l, patch) {
|
|
1002
|
+
return {
|
|
1003
|
+
...l,
|
|
1004
|
+
polarity: patch.polarity ?? l.polarity,
|
|
1005
|
+
delay: patch.delay ?? l.delay,
|
|
1006
|
+
indirect: patch.indirect ?? l.indirect,
|
|
1007
|
+
nonlinear: patch.nonlinear ?? l.nonlinear,
|
|
1008
|
+
weight: patch.weight !== undefined ? patch.weight : l.weight,
|
|
1009
|
+
curvature: patch.curvature === undefined
|
|
1010
|
+
? l.curvature
|
|
1011
|
+
: patch.curvature === null
|
|
1012
|
+
? undefined
|
|
1013
|
+
: patch.curvature,
|
|
1014
|
+
confidence: patch.confidence !== undefined
|
|
1015
|
+
? normalizeConfidence(patch.confidence)
|
|
1016
|
+
: l.confidence,
|
|
1017
|
+
basis: patch.basis !== undefined ? normalizeBasis(patch.basis) : l.basis,
|
|
1018
|
+
};
|
|
1019
|
+
}
|
|
1020
|
+
function layoutPositions(notes) {
|
|
1021
|
+
return autoLayout(notes.map((v) => ({ id: v.id, name: v.label, kind: v.type })), notes.flatMap((v) => v.links.map((l) => ({ source: v.id, target: l.to }))));
|
|
1022
|
+
}
|
|
1023
|
+
function applyLayout(notes) {
|
|
1024
|
+
const pos = layoutPositions(notes);
|
|
1025
|
+
for (const v of notes) {
|
|
1026
|
+
const p = pos.get(v.id);
|
|
1027
|
+
if (p) {
|
|
1028
|
+
v.x = p[0];
|
|
1029
|
+
v.y = p[1];
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
function toExportGraph(view) {
|
|
1034
|
+
const nodes = view.nodes.map((n) => ({
|
|
1035
|
+
id: n.id,
|
|
1036
|
+
name: n.label,
|
|
1037
|
+
kind: n.type,
|
|
1038
|
+
x: n.x,
|
|
1039
|
+
y: n.y,
|
|
1040
|
+
group: n.group,
|
|
1041
|
+
}));
|
|
1042
|
+
const edges = [];
|
|
1043
|
+
for (const n of view.nodes) {
|
|
1044
|
+
for (const l of n.links) {
|
|
1045
|
+
edges.push({
|
|
1046
|
+
id: `${n.id}__${l.to}`,
|
|
1047
|
+
source: n.id,
|
|
1048
|
+
target: l.to,
|
|
1049
|
+
polarity: l.polarity === "-" ? -1 : 1,
|
|
1050
|
+
delay: l.delay,
|
|
1051
|
+
curvature: l.curvature,
|
|
1052
|
+
dashed: l.indirect,
|
|
1053
|
+
weight: l.weight,
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
const nameById = new Map(view.nodes.map((n) => [n.id, n.label]));
|
|
1058
|
+
const loops = view.loops.map((lp) => ({
|
|
1059
|
+
loop: lp.nodeIds.map((id) => nameById.get(id) ?? id),
|
|
1060
|
+
type: lp.type === LoopType.reinforcing ? "R (reinforcing)" : "B (balancing)",
|
|
1061
|
+
label: view.labels.get(lp.key),
|
|
1062
|
+
}));
|
|
1063
|
+
return { nodes, edges, loops };
|
|
1064
|
+
}
|
|
1065
|
+
/** Re-exported from `./loopKey` so existing importers keep their path. */
|
|
1066
|
+
export { loopKey } from "./loopKey";
|
|
1067
|
+
/** Read the `loopNotes` string map out of a manifest's `extra` (defensive). */
|
|
1068
|
+
function readLoopNotes(m) {
|
|
1069
|
+
const raw = m.extra["loopNotes"];
|
|
1070
|
+
const out = {};
|
|
1071
|
+
if (raw && typeof raw === "object" && !Array.isArray(raw)) {
|
|
1072
|
+
for (const [k, v] of Object.entries(raw)) {
|
|
1073
|
+
if (typeof v === "string")
|
|
1074
|
+
out[k] = v;
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
return out;
|
|
1078
|
+
}
|
|
1079
|
+
function manifestIsQuant(m) {
|
|
1080
|
+
const e = m.extra;
|
|
1081
|
+
return (e["quantitative"] === true ||
|
|
1082
|
+
e["quant"] === true ||
|
|
1083
|
+
e["mode"] === "quantitative" ||
|
|
1084
|
+
e["kind"] === "quantitative");
|
|
1085
|
+
}
|
|
1086
|
+
function slug(s) {
|
|
1087
|
+
return s
|
|
1088
|
+
.toLowerCase()
|
|
1089
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
1090
|
+
.replace(/^-+|-+$/g, "");
|
|
1091
|
+
}
|
|
1092
|
+
function randomHex(bytes) {
|
|
1093
|
+
const arr = new Uint8Array(bytes);
|
|
1094
|
+
// Web Crypto is exposed as the global `crypto` in both Obsidian (Electron)
|
|
1095
|
+
// and Node (this pure engine's vitest tests). Referenced bare — not via
|
|
1096
|
+
// `window`/`globalThis` — so it resolves under Node too; `typeof`-guarded so
|
|
1097
|
+
// it falls back to Math.random anywhere it is unavailable.
|
|
1098
|
+
const c = typeof crypto !== "undefined" ? crypto : undefined;
|
|
1099
|
+
if (c && typeof c.getRandomValues === "function") {
|
|
1100
|
+
c.getRandomValues(arr);
|
|
1101
|
+
}
|
|
1102
|
+
else {
|
|
1103
|
+
for (let i = 0; i < bytes; i++)
|
|
1104
|
+
arr[i] = Math.floor(Math.random() * 256);
|
|
1105
|
+
}
|
|
1106
|
+
let s = "";
|
|
1107
|
+
for (const b of arr)
|
|
1108
|
+
s += b.toString(16).padStart(2, "0");
|
|
1109
|
+
return s;
|
|
1110
|
+
}
|
|
1111
|
+
function genVarId() {
|
|
1112
|
+
return `var_${randomHex(4)}`;
|
|
1113
|
+
}
|
|
1114
|
+
function genModelId() {
|
|
1115
|
+
const n = Date.now() * 1000 + Math.floor(Math.random() * 1000);
|
|
1116
|
+
const hex = n.toString(16);
|
|
1117
|
+
return `mdl_${hex.slice(-6)}`;
|
|
1118
|
+
}
|
|
1119
|
+
/** Re-export so callers don't need a second import for the signature helper. */
|
|
1120
|
+
export { contentSignature };
|