noggin-cli 0.1.3 → 0.4.3
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/README.md +35 -371
- package/SKILL.md +10 -2
- package/noggin-api.d.mts +262 -159
- package/noggin-api.mjs +654 -534
- package/noggin-mcp.mjs +163 -85
- package/noggin.mjs +311 -176
- package/package.json +4 -1
package/noggin-api.mjs
CHANGED
|
@@ -1,37 +1,41 @@
|
|
|
1
|
-
// noggin-api — typed, in-process
|
|
1
|
+
// noggin-api — typed, in-process engine for the noggin working-memory tree.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
3
|
+
// Three pillars:
|
|
4
|
+
// 1. `NogginDocument` and atomic ops (`AtomicOp`, `applyOps`) — the data
|
|
5
|
+
// model and the only way to mutate it.
|
|
6
|
+
// 2. `verbs.*` — the user-facing verb behaviors (push, add, done, …)
|
|
7
|
+
// implemented exactly once. Each verb reads state via a `Noggin`,
|
|
8
|
+
// composes a list of `AtomicOp`s, and calls `noggin.apply(ops)`.
|
|
9
|
+
// 3. Factories + `openNoggin(location)` — backends register a scheme
|
|
10
|
+
// prefix and an `open(location)` function. The engine never touches
|
|
11
|
+
// a file or any other storage; backends do.
|
|
11
12
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
13
|
+
// Backends only implement the `Noggin` interface (a handful of read
|
|
14
|
+
// accessors + `apply(ops)` + lifecycle + events). Verb semantics are
|
|
15
|
+
// not per-backend; they live in `verbs.*` and call the backend through
|
|
16
|
+
// the small `apply` primitive.
|
|
17
|
+
//
|
|
18
|
+
// Every failure throws a `NogginError` with a stable `code` and a
|
|
19
|
+
// CLI-style `exitCode`. Nothing in here writes to process.stderr or
|
|
20
|
+
// calls process.exit.
|
|
16
21
|
|
|
17
22
|
/// <reference path="./noggin-api.d.mts" />
|
|
18
23
|
|
|
19
|
-
import yaml from 'js-yaml';
|
|
20
|
-
import fs from 'node:fs';
|
|
21
|
-
import path from 'node:path';
|
|
22
|
-
import os from 'node:os';
|
|
23
24
|
import crypto from 'node:crypto';
|
|
24
25
|
|
|
25
26
|
export const SCHEMA_VERSION = 1;
|
|
26
|
-
export const DEFAULT_FILE = path.join(os.homedir(), '.noggin.yaml');
|
|
27
27
|
|
|
28
28
|
/**
|
|
29
|
-
* Version
|
|
30
|
-
* `formatSuccess` / `formatError`).
|
|
31
|
-
* `SCHEMA_VERSION`; bump when the shape
|
|
32
|
-
* or any per-verb payload changes in a
|
|
29
|
+
* Version stamped onto every response envelope this module produces
|
|
30
|
+
* (via `formatSuccess` / `formatError`). Distinct from the on-disk
|
|
31
|
+
* document `SCHEMA_VERSION`; bump when the envelope shape, the
|
|
32
|
+
* `CurrentTreeView` shape, or any per-verb payload changes in a
|
|
33
|
+
* breaking way.
|
|
33
34
|
*/
|
|
34
|
-
export const
|
|
35
|
+
export const RESPONSE_ENVELOPE_VERSION = 3;
|
|
36
|
+
|
|
37
|
+
/** @deprecated Renamed to `RESPONSE_ENVELOPE_VERSION`. */
|
|
38
|
+
export const JSON_SCHEMA_VERSION = RESPONSE_ENVELOPE_VERSION;
|
|
35
39
|
|
|
36
40
|
/**
|
|
37
41
|
* Text of the system-generated note appended whenever an item transitions
|
|
@@ -67,8 +71,8 @@ function runtime(code, message) {
|
|
|
67
71
|
|
|
68
72
|
// ── Low-level helpers ────────────────────────────────────────────────────────
|
|
69
73
|
|
|
70
|
-
function nowIso() {
|
|
71
|
-
return new Date().toISOString();
|
|
74
|
+
function nowIso(ctx) {
|
|
75
|
+
return ((ctx && ctx.now) || new Date()).toISOString();
|
|
72
76
|
}
|
|
73
77
|
|
|
74
78
|
function newKey() {
|
|
@@ -81,21 +85,29 @@ function newKey() {
|
|
|
81
85
|
return `i-${slug}-${hex}`;
|
|
82
86
|
}
|
|
83
87
|
|
|
84
|
-
function
|
|
88
|
+
function emptyDocument() {
|
|
85
89
|
return { schemaVersion: SCHEMA_VERSION, active: null, items: [] };
|
|
86
90
|
}
|
|
87
91
|
|
|
88
|
-
|
|
92
|
+
/**
|
|
93
|
+
* Normalize a single note object. Exported for the serializers, which
|
|
94
|
+
* share this shape contract.
|
|
95
|
+
*/
|
|
96
|
+
export function normalizeNote(note) {
|
|
89
97
|
if (note && typeof note === 'object' && note.text !== undefined) {
|
|
90
98
|
return { timestamp: note.timestamp ? String(note.timestamp) : null, text: String(note.text) };
|
|
91
99
|
}
|
|
92
100
|
usage('invalid-note', 'internal: invalid note object');
|
|
93
101
|
}
|
|
94
102
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
103
|
+
/**
|
|
104
|
+
* Normalize a parsed document in place: stamp schemaVersion, normalize
|
|
105
|
+
* notes, strip legacy fields. Used by serializers and by `applyOps`.
|
|
106
|
+
*/
|
|
107
|
+
export function normalizeDocument(doc) {
|
|
108
|
+
doc.schemaVersion = SCHEMA_VERSION;
|
|
109
|
+
for (const f of doc.items) {
|
|
110
|
+
if (!Array.isArray(f.notes)) usage('invalid-document', 'invalid contents: item notes must be an array');
|
|
99
111
|
f.notes = f.notes.map(normalizeNote);
|
|
100
112
|
// closedAt and pushedAt were both dropped before noggin shipped.
|
|
101
113
|
// Strip them on load so a dev's pre-rename test file doesn't carry
|
|
@@ -103,71 +115,48 @@ function normalizeStore(store) {
|
|
|
103
115
|
if ('closedAt' in f) delete f.closedAt;
|
|
104
116
|
if ('pushedAt' in f) delete f.pushedAt;
|
|
105
117
|
}
|
|
106
|
-
return
|
|
118
|
+
return doc;
|
|
107
119
|
}
|
|
108
120
|
|
|
109
|
-
|
|
121
|
+
/**
|
|
122
|
+
* Validate a document's structural invariants. Throws `NogginError`
|
|
123
|
+
* with code `'invalid-document'` if anything's wrong:
|
|
124
|
+
* - unique keys
|
|
125
|
+
* - parentKey references resolve
|
|
126
|
+
* - no cycles in the parent chain
|
|
127
|
+
* - active references an existing item (or is null)
|
|
128
|
+
*/
|
|
129
|
+
export function validateDocument(doc) {
|
|
130
|
+
if (!doc || !Array.isArray(doc.items)) {
|
|
131
|
+
usage('invalid-document', 'invalid contents: expected items array');
|
|
132
|
+
}
|
|
110
133
|
const keys = new Set();
|
|
111
|
-
for (const f of
|
|
112
|
-
if (!f.key) usage('invalid-
|
|
113
|
-
if (keys.has(f.key)) usage('invalid-
|
|
134
|
+
for (const f of doc.items) {
|
|
135
|
+
if (!f.key) usage('invalid-document', 'internal: item missing key');
|
|
136
|
+
if (keys.has(f.key)) usage('invalid-document', 'internal: duplicate item key detected');
|
|
114
137
|
keys.add(f.key);
|
|
115
138
|
}
|
|
116
|
-
for (const f of
|
|
117
|
-
if (f.parentKey && !keys.has(f.parentKey)) {
|
|
118
|
-
usage('invalid-
|
|
139
|
+
for (const f of doc.items) {
|
|
140
|
+
if (f.parentKey != null && !keys.has(f.parentKey)) {
|
|
141
|
+
usage('invalid-document', `internal: item '${f.key}' has unknown parent reference '${f.parentKey}'`);
|
|
119
142
|
}
|
|
120
143
|
}
|
|
121
|
-
|
|
122
|
-
|
|
144
|
+
// Cycle check: walk parent chain from each item, bound by item count.
|
|
145
|
+
const limit = doc.items.length + 1;
|
|
146
|
+
for (const f of doc.items) {
|
|
147
|
+
let n = f;
|
|
148
|
+
let steps = 0;
|
|
149
|
+
while (n.parentKey != null) {
|
|
150
|
+
if (++steps > limit) {
|
|
151
|
+
usage('invalid-document', `internal: parent chain cycle detected at '${f.key}'`);
|
|
152
|
+
}
|
|
153
|
+
n = doc.items.find((x) => x.key === n.parentKey);
|
|
154
|
+
if (!n) break; // already caught above; defensive
|
|
155
|
+
}
|
|
123
156
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* Load and validate a YAML store. Returns an empty store if the file does
|
|
128
|
-
* not exist or is empty.
|
|
129
|
-
*/
|
|
130
|
-
export function loadStore(filePath) {
|
|
131
|
-
if (!fs.existsSync(filePath)) return emptyStore();
|
|
132
|
-
let raw;
|
|
133
|
-
try { raw = fs.readFileSync(filePath, 'utf8'); }
|
|
134
|
-
catch (e) { usage('io', `failed to read ${filePath}: ${e.message}`); }
|
|
135
|
-
if (!raw.trim()) return emptyStore();
|
|
136
|
-
let data;
|
|
137
|
-
try { data = yaml.load(raw); }
|
|
138
|
-
catch (e) { usage('invalid-store', `failed to parse ${filePath}: ${e.message}`); }
|
|
139
|
-
if (!data || typeof data !== 'object') {
|
|
140
|
-
usage('invalid-store', `invalid contents in ${filePath}: expected a mapping`);
|
|
141
|
-
}
|
|
142
|
-
if (data.schemaVersion !== SCHEMA_VERSION) {
|
|
143
|
-
usage(
|
|
144
|
-
'unsupported-schema',
|
|
145
|
-
`schemaVersion ${data.schemaVersion} in ${filePath} not supported by this CLI ` +
|
|
146
|
-
`(expected ${SCHEMA_VERSION}).`,
|
|
147
|
-
);
|
|
157
|
+
if (doc.active != null && !keys.has(doc.active)) {
|
|
158
|
+
usage('invalid-document', `internal: active points to unknown item '${doc.active}'`);
|
|
148
159
|
}
|
|
149
|
-
if (!Array.isArray(data.items)) usage('invalid-store', `invalid contents in ${filePath}: expected items array`);
|
|
150
|
-
if (data.active === undefined) usage('invalid-store', `invalid contents in ${filePath}: expected active field`);
|
|
151
|
-
return normalizeStore(data);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
function dumpStore(store) {
|
|
155
|
-
return yaml.dump(store, { noRefs: true, lineWidth: 100, sortKeys: false });
|
|
156
|
-
}
|
|
157
|
-
|
|
158
|
-
function writeAtomic(filePath, contents) {
|
|
159
|
-
const dir = path.dirname(filePath);
|
|
160
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
161
|
-
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}`;
|
|
162
|
-
fs.writeFileSync(tmp, contents, 'utf8');
|
|
163
|
-
fs.renameSync(tmp, filePath);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
/** Write a YAML store. Atomic where the platform allows. */
|
|
167
|
-
export function saveStore(filePath, store) {
|
|
168
|
-
normalizeStore(store);
|
|
169
|
-
validateStore(store);
|
|
170
|
-
writeAtomic(filePath, dumpStore(store));
|
|
171
160
|
}
|
|
172
161
|
|
|
173
162
|
// ── Tree helpers ─────────────────────────────────────────────────────────────
|
|
@@ -558,111 +547,74 @@ function pruneDefaults(value) {
|
|
|
558
547
|
}
|
|
559
548
|
|
|
560
549
|
/**
|
|
561
|
-
* Wrap a successful verb result in the canonical
|
|
562
|
-
* both the CLI `--json` flag and the VS Code extension's
|
|
563
|
-
* tools so the two surfaces emit byte-identical shapes.
|
|
550
|
+
* Wrap a successful verb result in the canonical response envelope.
|
|
551
|
+
* Used by both the CLI `--json` flag and the VS Code extension's
|
|
552
|
+
* language-model tools so the two surfaces emit byte-identical shapes.
|
|
564
553
|
*
|
|
565
|
-
* The envelope itself (status,
|
|
566
|
-
*
|
|
567
|
-
*
|
|
554
|
+
* The envelope itself (status, envelopeVersion, verb, data) is always
|
|
555
|
+
* fully present. `data` is run through `pruneDefaults` so whitelisted
|
|
556
|
+
* fields equal to their declared default are omitted.
|
|
568
557
|
*
|
|
569
558
|
* @param {object} opts
|
|
570
559
|
* @param {string} [opts.verb] Verb name (e.g. 'push', 'show').
|
|
571
|
-
* @param {
|
|
572
|
-
* @param {any} [opts.data] Verb-specific payload (e.g. CurrentTreeView).
|
|
560
|
+
* @param {any} [opts.data] Verb-specific payload (e.g. CurrentTreeView).
|
|
573
561
|
*/
|
|
574
|
-
export function formatSuccess({ verb,
|
|
562
|
+
export function formatSuccess({ verb, data } = {}) {
|
|
575
563
|
return {
|
|
576
564
|
status: 'ok',
|
|
577
|
-
|
|
565
|
+
envelopeVersion: RESPONSE_ENVELOPE_VERSION,
|
|
578
566
|
verb: verb || null,
|
|
579
|
-
file: file || null,
|
|
580
567
|
data: data === undefined ? null : pruneDefaults(data),
|
|
581
568
|
};
|
|
582
569
|
}
|
|
583
570
|
|
|
584
571
|
/**
|
|
585
|
-
* Wrap an error in the canonical
|
|
586
|
-
* (preserves its `code` and `exitCode`) or any other
|
|
572
|
+
* Wrap an error in the canonical response envelope. Accepts a
|
|
573
|
+
* `NogginError` (preserves its `code` and `exitCode`) or any other
|
|
574
|
+
* thrown value.
|
|
587
575
|
*
|
|
588
576
|
* @param {object} opts
|
|
589
577
|
* @param {string} [opts.verb]
|
|
590
|
-
* @param {string|null} [opts.file]
|
|
591
578
|
* @param {unknown} [opts.error]
|
|
592
579
|
*/
|
|
593
|
-
export function formatError({ verb,
|
|
580
|
+
export function formatError({ verb, error } = {}) {
|
|
594
581
|
const isNoggin = error instanceof NogginError;
|
|
595
582
|
const message = error instanceof Error ? error.message : String(error ?? 'unknown error');
|
|
596
583
|
const code = isNoggin ? error.code : 'noggin-error';
|
|
597
584
|
const exitCode = isNoggin ? error.exitCode : 1;
|
|
598
585
|
return {
|
|
599
586
|
status: 'error',
|
|
600
|
-
|
|
587
|
+
envelopeVersion: RESPONSE_ENVELOPE_VERSION,
|
|
601
588
|
verb: verb || null,
|
|
602
|
-
file: file || null,
|
|
603
589
|
error: { code, message, exitCode },
|
|
604
590
|
};
|
|
605
591
|
}
|
|
606
592
|
|
|
607
|
-
// ── File resolution ──────────────────────────────────────────────────────────
|
|
608
|
-
|
|
609
|
-
/**
|
|
610
|
-
* Resolve the noggin file path with the same priority as the CLI:
|
|
611
|
-
* 1. `opts.file`
|
|
612
|
-
* 2. `opts.env.NOGGIN_FILE` (defaults to process.env)
|
|
613
|
-
* 3. `~/.noggin.yaml`
|
|
614
|
-
*/
|
|
615
|
-
export function resolveFile(opts = {}) {
|
|
616
|
-
const env = opts.env || process.env;
|
|
617
|
-
let file, source;
|
|
618
|
-
if (opts.file) { file = opts.file; source = 'flag'; }
|
|
619
|
-
else if (env.NOGGIN_FILE) { file = env.NOGGIN_FILE; source = 'env'; }
|
|
620
|
-
else { file = DEFAULT_FILE; source = 'default'; }
|
|
621
|
-
return {
|
|
622
|
-
file,
|
|
623
|
-
source,
|
|
624
|
-
exists: fs.existsSync(file),
|
|
625
|
-
defaultFile: DEFAULT_FILE,
|
|
626
|
-
env: env.NOGGIN_FILE || null,
|
|
627
|
-
};
|
|
628
|
-
}
|
|
629
|
-
|
|
630
593
|
// ── Internal verb helpers ────────────────────────────────────────────────────
|
|
631
594
|
|
|
632
|
-
function
|
|
595
|
+
function executeGotoOption(snapshot, base, goto, commandName) {
|
|
633
596
|
if (goto === undefined) return base;
|
|
634
597
|
if (!base) runtime('goto-base-missing', `${commandName}: --goto has no base item`);
|
|
635
598
|
const gotoPath = goto === true ? '.' : goto;
|
|
636
599
|
if (!gotoPath) runtime('goto-path-required', `${commandName}: --goto requires a path`);
|
|
637
|
-
const
|
|
638
|
-
const resolved = tryResolveDetailed(
|
|
600
|
+
const scopedDoc = { ...snapshot, active: base.key };
|
|
601
|
+
const resolved = tryResolveDetailed(scopedDoc, gotoPath);
|
|
639
602
|
if (!resolved.ok) runtime('goto-unresolved', `${commandName}: --goto ${resolved.error}`);
|
|
640
|
-
store.active = resolved.item.key;
|
|
641
603
|
return resolved.item;
|
|
642
604
|
}
|
|
643
605
|
|
|
644
|
-
function makeItem({ title, parentKey }) {
|
|
606
|
+
function makeItem({ title, parentKey }, ctx) {
|
|
645
607
|
return {
|
|
646
608
|
key: newKey(),
|
|
647
|
-
parentKey,
|
|
609
|
+
parentKey: parentKey ?? null,
|
|
648
610
|
title,
|
|
649
611
|
done: false,
|
|
650
|
-
createdAt: nowIso(),
|
|
612
|
+
createdAt: nowIso(ctx),
|
|
651
613
|
notes: [],
|
|
652
614
|
};
|
|
653
615
|
}
|
|
654
616
|
|
|
655
|
-
|
|
656
|
-
function appendCloseNote(item) {
|
|
657
|
-
if (!Array.isArray(item.notes)) item.notes = [];
|
|
658
|
-
item.notes.push({ timestamp: nowIso(), text: CLOSE_NOTE_TEXT });
|
|
659
|
-
}
|
|
660
|
-
|
|
661
|
-
/**
|
|
662
|
-
* Validate a placement option ({ kind, anchor } where anchor is a path).
|
|
663
|
-
* Returns the resolved anchor item and the kind.
|
|
664
|
-
*/
|
|
665
|
-
function resolvePlacement(store, placement, commandName) {
|
|
617
|
+
function resolvePlacement(snapshot, placement, commandName) {
|
|
666
618
|
if (!placement) return null;
|
|
667
619
|
const { kind, anchor } = placement;
|
|
668
620
|
if (!kind || !anchor) {
|
|
@@ -671,229 +623,352 @@ function resolvePlacement(store, placement, commandName) {
|
|
|
671
623
|
if (kind !== 'before' && kind !== 'after' && kind !== 'into') {
|
|
672
624
|
usage('placement-invalid', `${commandName}: unknown placement kind '${kind}'`);
|
|
673
625
|
}
|
|
674
|
-
const anchorItem = resolvePath(
|
|
626
|
+
const anchorItem = resolvePath(snapshot, anchor);
|
|
675
627
|
return { kind, anchor: anchorItem };
|
|
676
628
|
}
|
|
677
629
|
|
|
678
|
-
|
|
630
|
+
/** Compute (parentKey, position) for a placement spec against the current snapshot. */
|
|
631
|
+
function placementToTarget(snapshot, placement) {
|
|
632
|
+
const { kind, anchor } = placement;
|
|
633
|
+
if (kind === 'into') {
|
|
634
|
+
return { parentKey: anchor.key, position: 'end' };
|
|
635
|
+
}
|
|
636
|
+
const siblings = _childrenOf(snapshot.items, anchor.parentKey ?? null);
|
|
637
|
+
const idx = siblings.findIndex((s) => s.key === anchor.key);
|
|
638
|
+
return {
|
|
639
|
+
parentKey: anchor.parentKey ?? null,
|
|
640
|
+
position: kind === 'before' ? idx : idx + 1,
|
|
641
|
+
};
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/** Snapshot the noggin's live state into a doc-shaped {items, active} object. */
|
|
645
|
+
function nogginSnapshot(noggin) {
|
|
646
|
+
return {
|
|
647
|
+
items: noggin.items,
|
|
648
|
+
active: noggin.active ? noggin.active.key : null,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
// ── Atomic ops ───────────────────────────────────────────────────────────────
|
|
679
653
|
|
|
680
654
|
/**
|
|
681
|
-
*
|
|
655
|
+
* Apply a list of `AtomicOp`s to a NogginDocument in-place, then
|
|
656
|
+
* validate the result. Throws `NogginError` if any op references a
|
|
657
|
+
* missing item or the resulting document violates invariants.
|
|
658
|
+
*
|
|
659
|
+
* Op vocabulary:
|
|
660
|
+
* { type: 'add', item, parentKey, position }
|
|
661
|
+
* { type: 'remove', keys }
|
|
662
|
+
* { type: 'set', key, patch: { title?, done? } }
|
|
663
|
+
* { type: 'note', key, note: { timestamp, text } }
|
|
664
|
+
* { type: 'move', key, parentKey, position }
|
|
665
|
+
* { type: 'setActive', key }
|
|
666
|
+
*
|
|
667
|
+
* `position` is the 0-based index among siblings of `parentKey`, or
|
|
668
|
+
* the string 'end' for append.
|
|
669
|
+
*
|
|
670
|
+
* This is the single mutation primitive every backend's `apply()`
|
|
671
|
+
* delegates to. Verbs build the op list; backends execute it.
|
|
682
672
|
*/
|
|
683
|
-
export function
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
673
|
+
export function applyOps(doc, ops) {
|
|
674
|
+
if (!Array.isArray(ops)) usage('invalid-op', 'applyOps: ops must be an array');
|
|
675
|
+
for (const op of ops) applyOp(doc, op);
|
|
676
|
+
validateDocument(doc);
|
|
677
|
+
return doc;
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function applyOp(doc, op) {
|
|
681
|
+
if (!op || typeof op !== 'object') usage('invalid-op', 'applyOps: op must be an object');
|
|
682
|
+
switch (op.type) {
|
|
683
|
+
case 'add': return opAdd(doc, op);
|
|
684
|
+
case 'remove': return opRemove(doc, op);
|
|
685
|
+
case 'set': return opSet(doc, op);
|
|
686
|
+
case 'note': return opNote(doc, op);
|
|
687
|
+
case 'move': return opMove(doc, op);
|
|
688
|
+
case 'setActive': return opSetActive(doc, op);
|
|
689
|
+
default: usage('invalid-op', `applyOps: unknown op type '${op && op.type}'`);
|
|
690
|
+
}
|
|
693
691
|
}
|
|
694
692
|
|
|
693
|
+
function insertAtPosition(items, item, parentKey, position) {
|
|
694
|
+
const pkey = parentKey ?? null;
|
|
695
|
+
if (position === 'end') {
|
|
696
|
+
items.push(item);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (typeof position !== 'number' || position < 0) {
|
|
700
|
+
usage('invalid-op', `add/move: invalid position ${JSON.stringify(position)}`);
|
|
701
|
+
}
|
|
702
|
+
const siblings = items.filter((i) => (i.parentKey ?? null) === pkey);
|
|
703
|
+
if (position >= siblings.length) {
|
|
704
|
+
if (siblings.length === 0) { items.push(item); return; }
|
|
705
|
+
const last = siblings[siblings.length - 1];
|
|
706
|
+
items.splice(items.indexOf(last) + 1, 0, item);
|
|
707
|
+
return;
|
|
708
|
+
}
|
|
709
|
+
const before = siblings[position];
|
|
710
|
+
items.splice(items.indexOf(before), 0, item);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function opAdd(doc, op) {
|
|
714
|
+
if (!op.item || !op.item.key) usage('invalid-op', 'add: op.item with key required');
|
|
715
|
+
if (doc.items.some((i) => i.key === op.item.key)) {
|
|
716
|
+
usage('invalid-op', `add: item with key '${op.item.key}' already exists`);
|
|
717
|
+
}
|
|
718
|
+
const item = {
|
|
719
|
+
key: op.item.key,
|
|
720
|
+
parentKey: op.parentKey ?? null,
|
|
721
|
+
title: op.item.title,
|
|
722
|
+
done: Boolean(op.item.done),
|
|
723
|
+
createdAt: op.item.createdAt,
|
|
724
|
+
notes: Array.isArray(op.item.notes) ? op.item.notes.map(normalizeNote) : [],
|
|
725
|
+
};
|
|
726
|
+
insertAtPosition(doc.items, item, op.parentKey, op.position);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function opRemove(doc, op) {
|
|
730
|
+
if (!Array.isArray(op.keys)) usage('invalid-op', 'remove: op.keys array required');
|
|
731
|
+
const removeSet = new Set(op.keys);
|
|
732
|
+
doc.items = doc.items.filter((i) => !removeSet.has(i.key));
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
function opSet(doc, op) {
|
|
736
|
+
if (!op.key) usage('invalid-op', 'set: op.key required');
|
|
737
|
+
const item = doc.items.find((i) => i.key === op.key);
|
|
738
|
+
if (!item) usage('invalid-op', `set: item with key '${op.key}' not found`);
|
|
739
|
+
if (!op.patch || typeof op.patch !== 'object') usage('invalid-op', 'set: op.patch object required');
|
|
740
|
+
if (op.patch.title !== undefined) item.title = op.patch.title;
|
|
741
|
+
if (op.patch.done !== undefined) item.done = Boolean(op.patch.done);
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function opNote(doc, op) {
|
|
745
|
+
if (!op.key) usage('invalid-op', 'note: op.key required');
|
|
746
|
+
const item = doc.items.find((i) => i.key === op.key);
|
|
747
|
+
if (!item) usage('invalid-op', `note: item with key '${op.key}' not found`);
|
|
748
|
+
if (!op.note || op.note.text === undefined) usage('invalid-op', 'note: op.note.text required');
|
|
749
|
+
if (!Array.isArray(item.notes)) item.notes = [];
|
|
750
|
+
item.notes.push(normalizeNote(op.note));
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
function opMove(doc, op) {
|
|
754
|
+
if (!op.key) usage('invalid-op', 'move: op.key required');
|
|
755
|
+
const item = doc.items.find((i) => i.key === op.key);
|
|
756
|
+
if (!item) usage('invalid-op', `move: item with key '${op.key}' not found`);
|
|
757
|
+
const idx = doc.items.indexOf(item);
|
|
758
|
+
doc.items.splice(idx, 1);
|
|
759
|
+
item.parentKey = op.parentKey ?? null;
|
|
760
|
+
insertAtPosition(doc.items, item, op.parentKey, op.position);
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function opSetActive(doc, op) {
|
|
764
|
+
doc.active = op.key ?? null;
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Apply ops to a clone of the current state without persisting, so a
|
|
769
|
+
* verb can resolve a `--goto` path against the projected post-apply
|
|
770
|
+
* state before submitting the real apply.
|
|
771
|
+
*/
|
|
772
|
+
function projectOps(noggin, ops) {
|
|
773
|
+
const doc = {
|
|
774
|
+
schemaVersion: SCHEMA_VERSION,
|
|
775
|
+
active: noggin.active ? noggin.active.key : null,
|
|
776
|
+
items: noggin.items.map((i) => ({
|
|
777
|
+
key: i.key,
|
|
778
|
+
parentKey: i.parentKey ?? null,
|
|
779
|
+
title: i.title,
|
|
780
|
+
done: Boolean(i.done),
|
|
781
|
+
createdAt: i.createdAt,
|
|
782
|
+
notes: (i.notes || []).map((n) => ({ timestamp: n.timestamp, text: n.text })),
|
|
783
|
+
})),
|
|
784
|
+
};
|
|
785
|
+
for (const op of ops) applyOp(doc, op);
|
|
786
|
+
return doc;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// ── Verbs ────────────────────────────────────────────────────────────────────
|
|
790
|
+
|
|
695
791
|
/**
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
*
|
|
792
|
+
* The single verb implementation, shared by every backend. Each verb
|
|
793
|
+
* takes a `Noggin`, reads state via its accessors, composes the
|
|
794
|
+
* appropriate `AtomicOp[]`, calls `noggin.apply(ops)` once, and returns
|
|
795
|
+
* a `CurrentTreeView` (or a `DeleteResult` for delete).
|
|
796
|
+
*
|
|
797
|
+
* Verb behavior contracts — push moves active; add does not unless
|
|
798
|
+
* --goto; done appends a close note and surfaces to parent; --force
|
|
799
|
+
* vs --close-all close semantics; cycle protection on move; etc. —
|
|
800
|
+
* live here. Backends do not implement verbs.
|
|
699
801
|
*/
|
|
700
|
-
export
|
|
802
|
+
export const verbs = {
|
|
803
|
+
push: verbPush,
|
|
804
|
+
add: verbAdd,
|
|
805
|
+
move: verbMove,
|
|
806
|
+
goto: verbGoto,
|
|
807
|
+
done: verbDone,
|
|
808
|
+
pop: verbPop,
|
|
809
|
+
edit: verbEdit,
|
|
810
|
+
show: verbShow,
|
|
811
|
+
note: verbNote,
|
|
812
|
+
delete: verbDelete,
|
|
813
|
+
copy: verbCopy,
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
/** push: create a child of active (or a root if none) and become active. */
|
|
817
|
+
async function verbPush(noggin, opts, ctx) {
|
|
818
|
+
const title = (opts && opts.title || '').toString().trim();
|
|
819
|
+
if (!title) usage('title-required', 'push: title required (--title or positional)');
|
|
820
|
+
const active = noggin.active;
|
|
821
|
+
const item = makeItem({ title, parentKey: active ? active.key : null }, ctx);
|
|
822
|
+
const ops = [
|
|
823
|
+
{ type: 'add', item, parentKey: active ? active.key : null, position: 'end' },
|
|
824
|
+
{ type: 'setActive', key: item.key },
|
|
825
|
+
];
|
|
826
|
+
await noggin.apply(ops);
|
|
827
|
+
return buildView(nogginSnapshot(noggin), noggin.findByKey(item.key), {});
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** add: capture an item without making it active (unless --goto). */
|
|
831
|
+
async function verbAdd(noggin, opts = {}, ctx) {
|
|
701
832
|
const title = (opts.title || '').toString().trim();
|
|
702
833
|
if (!title) usage('title-required', 'add: title required (--title or positional)');
|
|
703
|
-
const
|
|
704
|
-
const
|
|
705
|
-
const placement = resolvePlacement(
|
|
834
|
+
const snap = nogginSnapshot(noggin);
|
|
835
|
+
const active = noggin.active;
|
|
836
|
+
const placement = resolvePlacement(snap, opts.placement, 'add');
|
|
706
837
|
|
|
707
|
-
let parentKey;
|
|
708
|
-
let insertIndex;
|
|
838
|
+
let parentKey, position;
|
|
709
839
|
if (placement) {
|
|
710
|
-
|
|
711
|
-
if (kind === 'into') {
|
|
712
|
-
parentKey = anchor.key;
|
|
713
|
-
insertIndex = store.items.length;
|
|
714
|
-
} else {
|
|
715
|
-
parentKey = anchor.parentKey;
|
|
716
|
-
const anchorIdx = store.items.indexOf(anchor);
|
|
717
|
-
insertIndex = kind === 'before' ? anchorIdx : anchorIdx + 1;
|
|
718
|
-
}
|
|
840
|
+
({ parentKey, position } = placementToTarget(snap, placement));
|
|
719
841
|
} else {
|
|
720
|
-
parentKey =
|
|
721
|
-
|
|
842
|
+
parentKey = active ? active.key : null;
|
|
843
|
+
position = 'end';
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
const item = makeItem({ title, parentKey }, ctx);
|
|
847
|
+
const ops = [{ type: 'add', item, parentKey, position }];
|
|
848
|
+
|
|
849
|
+
let viewTargetKey = item.key;
|
|
850
|
+
if (opts.goto !== undefined) {
|
|
851
|
+
const projected = projectOps(noggin, ops);
|
|
852
|
+
const projectedNew = findByKey(projected.items, item.key);
|
|
853
|
+
const target = executeGotoOption(projected, projectedNew, opts.goto, 'add');
|
|
854
|
+
ops.push({ type: 'setActive', key: target.key });
|
|
855
|
+
viewTargetKey = target.key;
|
|
722
856
|
}
|
|
723
857
|
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
const outputTarget = opts.goto !== undefined ? applyGoto(store, item, opts.goto, 'add') : item;
|
|
727
|
-
saveStore(file, store);
|
|
728
|
-
return buildView(store, outputTarget);
|
|
858
|
+
await noggin.apply(ops);
|
|
859
|
+
return buildView(nogginSnapshot(noggin), noggin.findByKey(viewTargetKey), {});
|
|
729
860
|
}
|
|
730
861
|
|
|
731
|
-
/**
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
export function apiMove(file, opts = {}) {
|
|
736
|
-
const store = loadStore(file);
|
|
737
|
-
const placement = resolvePlacement(store, opts.placement, 'move');
|
|
862
|
+
/** move: relocate an item. Required placement; cycle-checked. */
|
|
863
|
+
async function verbMove(noggin, opts = {}) {
|
|
864
|
+
const snap = nogginSnapshot(noggin);
|
|
865
|
+
const placement = resolvePlacement(snap, opts.placement, 'move');
|
|
738
866
|
if (!placement) usage('placement-missing', 'move: choose exactly one of --before, --after, or --into');
|
|
739
867
|
const { kind, anchor } = placement;
|
|
740
868
|
|
|
741
869
|
let target;
|
|
742
|
-
if (opts.path) target = resolvePath(
|
|
870
|
+
if (opts.path) target = noggin.resolvePath(opts.path);
|
|
743
871
|
else {
|
|
744
|
-
target =
|
|
872
|
+
target = noggin.active;
|
|
745
873
|
if (!target) runtime('no-active-item', 'move: no active item; pass a path');
|
|
746
874
|
}
|
|
747
875
|
|
|
876
|
+
// Cycle checks.
|
|
748
877
|
if (kind === 'into') {
|
|
749
878
|
if (target.key === anchor.key) {
|
|
750
|
-
runtime('cycle', `move: cannot move ${
|
|
879
|
+
runtime('cycle', `move: cannot move ${noggin.pathOf(target)} into itself (would create a cycle)`);
|
|
751
880
|
}
|
|
752
|
-
if (isDescendant(
|
|
753
|
-
runtime('cycle', `move: cannot move ${
|
|
881
|
+
if (isDescendant(noggin.items, anchor, target)) {
|
|
882
|
+
runtime('cycle', `move: cannot move ${noggin.pathOf(target)} into its own subtree (would create a cycle)`);
|
|
754
883
|
}
|
|
755
884
|
} else {
|
|
756
|
-
if (isDescendant(
|
|
757
|
-
runtime('cycle', `move: cannot move ${
|
|
758
|
-
}
|
|
759
|
-
if (anchor.key === target.key) {
|
|
760
|
-
// before/after self: same place. Silent no-op.
|
|
761
|
-
const activeItem = findByKey(store.items, store.active);
|
|
762
|
-
const outputTarget = opts.goto !== undefined ? applyGoto(store, target, opts.goto, 'move') : (activeItem || target);
|
|
763
|
-
saveStore(file, store);
|
|
764
|
-
return buildView(store, outputTarget);
|
|
885
|
+
if (isDescendant(noggin.items, anchor, target)) {
|
|
886
|
+
runtime('cycle', `move: cannot move ${noggin.pathOf(target)} next to its own descendant (would create a cycle)`);
|
|
765
887
|
}
|
|
766
888
|
}
|
|
767
889
|
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
let insertIndex;
|
|
890
|
+
// Compute new parentKey + position. For before/after we exclude the
|
|
891
|
+
// target itself from the sibling list so its current position doesn't
|
|
892
|
+
// shift the anchor index.
|
|
893
|
+
let parentKey, position;
|
|
773
894
|
if (kind === 'into') {
|
|
774
|
-
|
|
895
|
+
parentKey = anchor.key;
|
|
896
|
+
position = 'end';
|
|
897
|
+
} else if (anchor.key === target.key) {
|
|
898
|
+
// before/after self → silent no-op. Submit a redundant move op
|
|
899
|
+
// anyway so the verb still goes through the normal apply path
|
|
900
|
+
// (keeps event semantics consistent).
|
|
901
|
+
parentKey = target.parentKey ?? null;
|
|
902
|
+
const siblings = _childrenOf(noggin.items, parentKey);
|
|
903
|
+
position = siblings.findIndex((s) => s.key === target.key);
|
|
775
904
|
} else {
|
|
776
|
-
|
|
777
|
-
|
|
905
|
+
parentKey = anchor.parentKey ?? null;
|
|
906
|
+
const siblings = _childrenOf(noggin.items, parentKey).filter((s) => s.key !== target.key);
|
|
907
|
+
const anchorIdx = siblings.findIndex((s) => s.key === anchor.key);
|
|
908
|
+
position = kind === 'before' ? anchorIdx : anchorIdx + 1;
|
|
778
909
|
}
|
|
779
910
|
|
|
780
|
-
target.parentKey
|
|
781
|
-
store.items.splice(insertIndex, 0, target);
|
|
911
|
+
const ops = [{ type: 'move', key: target.key, parentKey, position }];
|
|
782
912
|
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
913
|
+
let viewTargetKey;
|
|
914
|
+
if (opts.goto !== undefined) {
|
|
915
|
+
const projected = projectOps(noggin, ops);
|
|
916
|
+
const projectedTarget = findByKey(projected.items, target.key);
|
|
917
|
+
const gotoTarget = executeGotoOption(projected, projectedTarget, opts.goto, 'move');
|
|
918
|
+
ops.push({ type: 'setActive', key: gotoTarget.key });
|
|
919
|
+
viewTargetKey = gotoTarget.key;
|
|
920
|
+
} else {
|
|
921
|
+
// Default view target: active (preserved by key) if still exists, else the moved target.
|
|
922
|
+
viewTargetKey = noggin.active ? noggin.active.key : target.key;
|
|
923
|
+
}
|
|
788
924
|
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
if (!opts.path) usage('path-required', 'goto: path required');
|
|
792
|
-
const store = loadStore(file);
|
|
793
|
-
const target = resolvePath(store, opts.path);
|
|
794
|
-
store.active = target.key;
|
|
795
|
-
saveStore(file, store);
|
|
796
|
-
return buildView(store, target);
|
|
925
|
+
await noggin.apply(ops);
|
|
926
|
+
return buildView(nogginSnapshot(noggin), noggin.findByKey(viewTargetKey), {});
|
|
797
927
|
}
|
|
798
928
|
|
|
799
|
-
/**
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
* even though some kids remain open
|
|
807
|
-
* closeAll walk descendants first; close every open one (each
|
|
808
|
-
* gets its own system "closed" note)
|
|
809
|
-
*
|
|
810
|
-
* Throws a runtime NogginError with code `open-descendants` if there
|
|
811
|
-
* are open descendants and neither flag is set.
|
|
812
|
-
*/
|
|
813
|
-
function closeWithRules(store, target, opts, verb) {
|
|
814
|
-
const force = opts.force === true;
|
|
815
|
-
const closeAll = opts.closeAll === true;
|
|
816
|
-
if (closeAll) {
|
|
817
|
-
for (const d of collectDescendants(store.items, target)) {
|
|
818
|
-
if (!d.done) {
|
|
819
|
-
d.done = true;
|
|
820
|
-
appendCloseNote(d);
|
|
821
|
-
}
|
|
822
|
-
}
|
|
823
|
-
}
|
|
824
|
-
if (!force && !closeAll) {
|
|
825
|
-
const open = countOpenDescendants(store.items, target);
|
|
826
|
-
if (open > 0) {
|
|
827
|
-
runtime(
|
|
828
|
-
'open-descendants',
|
|
829
|
-
`${verb}: ${_pathOf(store.items, target)} has ${open} open descendant(s); ` +
|
|
830
|
-
`pass --closeall to close them too, or --force to close ${target.title} anyway`,
|
|
831
|
-
);
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
if (!target.done) {
|
|
835
|
-
target.done = true;
|
|
836
|
-
appendCloseNote(target);
|
|
837
|
-
}
|
|
929
|
+
/** goto: make `path` active. */
|
|
930
|
+
async function verbGoto(noggin, opts = {}) {
|
|
931
|
+
if (!opts.path) usage('path-required', 'goto: path required');
|
|
932
|
+
const target = noggin.resolvePath(opts.path);
|
|
933
|
+
const ops = [{ type: 'setActive', key: target.key }];
|
|
934
|
+
await noggin.apply(ops);
|
|
935
|
+
return buildView(nogginSnapshot(noggin), noggin.findByKey(target.key), {});
|
|
838
936
|
}
|
|
839
937
|
|
|
840
|
-
/**
|
|
841
|
-
|
|
842
|
-
*
|
|
843
|
-
* Idempotent — if the target is already done, no error and no extra
|
|
844
|
-
* close note; the navigational side-effect (surface to parent) still
|
|
845
|
-
* happens.
|
|
846
|
-
*
|
|
847
|
-
* `--force` skips the open-descendant safety check; `--closeall` first
|
|
848
|
-
* closes every open descendant. Without either flag, an open
|
|
849
|
-
* descendant blocks the call with a runtime error.
|
|
850
|
-
*/
|
|
851
|
-
export function apiDone(file, opts = {}) {
|
|
938
|
+
/** done: mark target done, then surface active to parent. Idempotent. */
|
|
939
|
+
async function verbDone(noggin, opts = {}, ctx) {
|
|
852
940
|
if (opts.goto !== undefined) usage('goto-unsupported', 'done: --goto is not supported; done always moves to the target parent');
|
|
853
|
-
|
|
941
|
+
|
|
854
942
|
let target;
|
|
855
|
-
if (opts.path) target = resolvePath(
|
|
943
|
+
if (opts.path) target = noggin.resolvePath(opts.path);
|
|
856
944
|
else {
|
|
857
|
-
target =
|
|
945
|
+
target = noggin.active;
|
|
858
946
|
if (!target) runtime('no-active-item', 'done: no active item; pass a path');
|
|
859
947
|
}
|
|
860
|
-
|
|
861
|
-
const
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
948
|
+
|
|
949
|
+
const closeOps = buildCloseOps(noggin, target, opts, 'done', ctx);
|
|
950
|
+
const parentKey = target.parentKey ?? null;
|
|
951
|
+
const ops = [...closeOps, { type: 'setActive', key: parentKey }];
|
|
952
|
+
|
|
953
|
+
await noggin.apply(ops);
|
|
954
|
+
const newActive = noggin.active;
|
|
955
|
+
const viewTarget = newActive || noggin.findByKey(target.key);
|
|
956
|
+
return buildView(nogginSnapshot(noggin), viewTarget, {});
|
|
865
957
|
}
|
|
866
958
|
|
|
867
|
-
/** pop:
|
|
868
|
-
|
|
959
|
+
/** pop: done on active, no path argument. */
|
|
960
|
+
async function verbPop(noggin, opts = {}, ctx) {
|
|
869
961
|
if (opts && opts.path !== undefined) usage('pop-no-path', 'pop: takes no path; pop always operates on the active item');
|
|
870
|
-
if (opts && opts.goto !== undefined) usage('goto-unsupported',
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
return apiDone(file, {
|
|
962
|
+
if (opts && opts.goto !== undefined) usage('goto-unsupported', "pop: --goto is not supported; pop always moves to the active item's parent");
|
|
963
|
+
if (!noggin.active) runtime('no-active-item', 'pop: no active item');
|
|
964
|
+
return verbDone(noggin, {
|
|
874
965
|
force: opts.force === true,
|
|
875
966
|
closeAll: opts.closeAll === true,
|
|
876
|
-
});
|
|
967
|
+
}, ctx);
|
|
877
968
|
}
|
|
878
969
|
|
|
879
|
-
/**
|
|
880
|
-
|
|
881
|
-
* the old `set-state` and `retitle` verbs. At least one of `done`/`title`
|
|
882
|
-
* is required. Each operation is idempotent (no error if the value already
|
|
883
|
-
* matches).
|
|
884
|
-
*
|
|
885
|
-
* done true → close (subject to open-descendant rules below)
|
|
886
|
-
* false → reopen
|
|
887
|
-
* undefined → don't touch state
|
|
888
|
-
* title new title (trimmed; empty string is ignored, not an error)
|
|
889
|
-
* force when closing, skip the open-descendant check
|
|
890
|
-
* closeAll when closing, first close every open descendant
|
|
891
|
-
* goto standard reposition-after-write option
|
|
892
|
-
*
|
|
893
|
-
* Unlike `done`, `edit --done` does NOT surface active to the parent;
|
|
894
|
-
* active is unchanged unless `--goto` is passed.
|
|
895
|
-
*/
|
|
896
|
-
export function apiEdit(file, opts = {}) {
|
|
970
|
+
/** edit: idempotent mutation of state and/or title. */
|
|
971
|
+
async function verbEdit(noggin, opts = {}, ctx) {
|
|
897
972
|
const hasState = typeof opts.done === 'boolean';
|
|
898
973
|
const rawTitle = opts.title;
|
|
899
974
|
const hasTitle = typeof rawTitle === 'string' && rawTitle.trim() !== '';
|
|
@@ -908,293 +983,335 @@ export function apiEdit(file, opts = {}) {
|
|
|
908
983
|
usage('option-misused', 'edit: --close-all only applies when closing (with --done)');
|
|
909
984
|
}
|
|
910
985
|
|
|
911
|
-
const store = loadStore(file);
|
|
912
986
|
let target;
|
|
913
|
-
if (opts.path) target = resolvePath(
|
|
987
|
+
if (opts.path) target = noggin.resolvePath(opts.path);
|
|
914
988
|
else {
|
|
915
|
-
target =
|
|
989
|
+
target = noggin.active;
|
|
916
990
|
if (!target) runtime('no-active-item', 'edit: no active item; pass a path');
|
|
917
991
|
}
|
|
918
992
|
|
|
993
|
+
const ops = [];
|
|
994
|
+
|
|
919
995
|
if (hasState) {
|
|
920
996
|
if (opts.done) {
|
|
921
|
-
|
|
997
|
+
ops.push(...buildCloseOps(noggin, target, opts, 'edit', ctx));
|
|
922
998
|
} else if (target.done) {
|
|
923
|
-
target.done
|
|
999
|
+
ops.push({ type: 'set', key: target.key, patch: { done: false } });
|
|
924
1000
|
}
|
|
925
1001
|
}
|
|
926
1002
|
|
|
927
1003
|
if (hasTitle) {
|
|
928
1004
|
const next = rawTitle.toString().trim();
|
|
929
|
-
if (target.title !== next)
|
|
1005
|
+
if (target.title !== next) {
|
|
1006
|
+
ops.push({ type: 'set', key: target.key, patch: { title: next } });
|
|
1007
|
+
}
|
|
930
1008
|
}
|
|
931
1009
|
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
1010
|
+
let viewTargetKey = target.key;
|
|
1011
|
+
if (opts.goto !== undefined) {
|
|
1012
|
+
const projected = projectOps(noggin, ops);
|
|
1013
|
+
const projectedTarget = findByKey(projected.items, target.key);
|
|
1014
|
+
const gotoTarget = executeGotoOption(projected, projectedTarget, opts.goto, 'edit');
|
|
1015
|
+
ops.push({ type: 'setActive', key: gotoTarget.key });
|
|
1016
|
+
viewTargetKey = gotoTarget.key;
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
if (ops.length > 0) await noggin.apply(ops);
|
|
1020
|
+
return buildView(nogginSnapshot(noggin), noggin.findByKey(viewTargetKey), {});
|
|
935
1021
|
}
|
|
936
1022
|
|
|
937
|
-
/**
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
*/
|
|
941
|
-
export function apiShow(file, opts = {}) {
|
|
942
|
-
const store = loadStore(file);
|
|
943
|
-
const target = opts.path
|
|
944
|
-
? resolvePath(store, opts.path)
|
|
945
|
-
: findByKey(store.items, store.active);
|
|
1023
|
+
/** show: read-only view; --goto activates after. */
|
|
1024
|
+
async function verbShow(noggin, opts = {}) {
|
|
1025
|
+
const target = opts.path ? noggin.resolvePath(opts.path) : noggin.active;
|
|
946
1026
|
if (!target) return null;
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
1027
|
+
|
|
1028
|
+
let viewTargetKey = target.key;
|
|
1029
|
+
if (opts.goto !== undefined) {
|
|
1030
|
+
const gotoTarget = executeGotoOption(nogginSnapshot(noggin), target, opts.goto, 'show');
|
|
1031
|
+
if (gotoTarget.key !== (noggin.active ? noggin.active.key : null)) {
|
|
1032
|
+
await noggin.apply([{ type: 'setActive', key: gotoTarget.key }]);
|
|
1033
|
+
}
|
|
1034
|
+
viewTargetKey = gotoTarget.key;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
return buildView(nogginSnapshot(noggin), noggin.findByKey(viewTargetKey), {
|
|
950
1038
|
includeChildren: opts.includeChildren !== false,
|
|
951
1039
|
withSiblings: opts.withSiblings === true,
|
|
952
1040
|
withDescendants: opts.withDescendants === true,
|
|
953
1041
|
});
|
|
954
1042
|
}
|
|
955
1043
|
|
|
956
|
-
/** note: append a timestamped note.
|
|
957
|
-
|
|
1044
|
+
/** note: append a timestamped note. */
|
|
1045
|
+
async function verbNote(noggin, opts = {}, ctx) {
|
|
958
1046
|
const text = (opts.text || '').toString().trim();
|
|
959
1047
|
if (!text) usage('text-required', 'note: text required');
|
|
960
|
-
|
|
1048
|
+
|
|
961
1049
|
let target;
|
|
962
|
-
if (opts.path) target = resolvePath(
|
|
1050
|
+
if (opts.path) target = noggin.resolvePath(opts.path);
|
|
963
1051
|
else {
|
|
964
|
-
target =
|
|
1052
|
+
target = noggin.active;
|
|
965
1053
|
if (!target) runtime('no-active-item', 'note: no active item and no path given');
|
|
966
1054
|
}
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
1055
|
+
|
|
1056
|
+
const ops = [{
|
|
1057
|
+
type: 'note',
|
|
1058
|
+
key: target.key,
|
|
1059
|
+
note: { timestamp: nowIso(ctx), text },
|
|
1060
|
+
}];
|
|
1061
|
+
|
|
1062
|
+
let viewTargetKey = target.key;
|
|
1063
|
+
if (opts.goto !== undefined) {
|
|
1064
|
+
const projected = projectOps(noggin, ops);
|
|
1065
|
+
const projectedTarget = findByKey(projected.items, target.key);
|
|
1066
|
+
const gotoTarget = executeGotoOption(projected, projectedTarget, opts.goto, 'note');
|
|
1067
|
+
ops.push({ type: 'setActive', key: gotoTarget.key });
|
|
1068
|
+
viewTargetKey = gotoTarget.key;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
await noggin.apply(ops);
|
|
1072
|
+
return buildView(nogginSnapshot(noggin), noggin.findByKey(viewTargetKey), {});
|
|
972
1073
|
}
|
|
973
1074
|
|
|
974
|
-
/**
|
|
975
|
-
|
|
976
|
-
* If the deleted subtree contains the active item, active becomes the
|
|
977
|
-
* deleted item's parent (or null if it was a root).
|
|
978
|
-
*/
|
|
979
|
-
export function apiDelete(file, opts = {}) {
|
|
1075
|
+
/** delete: remove item; --recursive for subtree. */
|
|
1076
|
+
async function verbDelete(noggin, opts = {}) {
|
|
980
1077
|
if (opts.goto !== undefined) usage('goto-unsupported', 'delete: --goto is not supported');
|
|
981
1078
|
if (!opts.path) usage('path-required', 'delete: path required');
|
|
982
|
-
const
|
|
983
|
-
const target = resolvePath(store, opts.path);
|
|
984
|
-
const targetPath = _pathOf(store.items, target);
|
|
1079
|
+
const target = noggin.resolvePath(opts.path);
|
|
985
1080
|
const targetKey = target.key;
|
|
1081
|
+
const targetPath = noggin.pathOf(target);
|
|
986
1082
|
const targetTitle = target.title;
|
|
987
|
-
const descendants = collectDescendants(
|
|
1083
|
+
const descendants = collectDescendants(noggin.items, target);
|
|
1084
|
+
|
|
988
1085
|
if (descendants.length > 0 && opts.recursive !== true) {
|
|
989
1086
|
runtime(
|
|
990
1087
|
'has-descendants',
|
|
991
|
-
`delete: ${targetPath} has ${descendants.length} descendant(s);
|
|
992
|
-
`pass --recursive to delete the whole subtree`,
|
|
1088
|
+
`delete: ${targetPath} has ${descendants.length} descendant(s); pass --recursive to delete the whole subtree`,
|
|
993
1089
|
);
|
|
994
1090
|
}
|
|
995
|
-
|
|
996
|
-
const
|
|
997
|
-
|
|
1091
|
+
|
|
1092
|
+
const removeKeys = [target.key, ...descendants.map((d) => d.key)];
|
|
1093
|
+
const ops = [{ type: 'remove', keys: removeKeys }];
|
|
1094
|
+
|
|
1095
|
+
const activeWasRemoved = noggin.active != null && removeKeys.includes(noggin.active.key);
|
|
998
1096
|
if (activeWasRemoved) {
|
|
999
|
-
|
|
1097
|
+
ops.push({ type: 'setActive', key: target.parentKey ?? null });
|
|
1000
1098
|
}
|
|
1001
|
-
|
|
1002
|
-
|
|
1099
|
+
|
|
1100
|
+
await noggin.apply(ops);
|
|
1101
|
+
|
|
1102
|
+
const newActive = noggin.active;
|
|
1003
1103
|
return {
|
|
1004
1104
|
deleted: { key: targetKey, path: targetPath, title: targetTitle },
|
|
1005
1105
|
descendantCount: descendants.length,
|
|
1006
|
-
view: newActive ? buildView(
|
|
1106
|
+
view: newActive ? buildView(nogginSnapshot(noggin), newActive, {}) : null,
|
|
1007
1107
|
};
|
|
1008
1108
|
}
|
|
1009
1109
|
|
|
1010
|
-
/** where: returns the resolved file info for the current options. */
|
|
1011
|
-
export function apiWhere(opts = {}) {
|
|
1012
|
-
return resolveFile(opts);
|
|
1013
|
-
}
|
|
1014
|
-
|
|
1015
|
-
// ── Noggin class ─────────────────────────────────────────────────────────────
|
|
1016
|
-
|
|
1017
1110
|
/**
|
|
1018
|
-
*
|
|
1019
|
-
*
|
|
1020
|
-
* the store changes (via a verb method or an external edit).
|
|
1111
|
+
* copy: append every item from `source` into `dest`, preserving tree
|
|
1112
|
+
* structure but generating fresh keys.
|
|
1021
1113
|
*
|
|
1022
|
-
*
|
|
1023
|
-
*
|
|
1114
|
+
* v1 semantics (intentionally narrow; extension points reserved for
|
|
1115
|
+
* future versions):
|
|
1116
|
+
* - whole-noggin copy: every source item is copied; source paths/keys
|
|
1117
|
+
* are not selectable
|
|
1118
|
+
* - append-only: source roots become new roots at the end of dest's
|
|
1119
|
+
* root list, never overwriting existing dest content
|
|
1120
|
+
* - active is not transferred: dest's active pointer is unchanged
|
|
1121
|
+
* - notes (including system "closed" notes), `done`, and `createdAt`
|
|
1122
|
+
* are preserved verbatim — a copied item looks like the original
|
|
1123
|
+
* work, just under a different location
|
|
1124
|
+
* - same-noggin copy (source === dest) is supported: the entire
|
|
1125
|
+
* tree gets duplicated at the root with fresh keys
|
|
1126
|
+
*
|
|
1127
|
+
* Returns `{ copied, mapping }` where `mapping` is a `{oldKey: newKey}`
|
|
1128
|
+
* dictionary the caller can use to find the dest counterpart of any
|
|
1129
|
+
* source item.
|
|
1024
1130
|
*/
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
// Bind so they look like vscode.Event<T>: function-shaped subscribe.
|
|
1044
|
-
this.onDidChange = (handler) => {
|
|
1045
|
-
this._changeListeners.add(handler);
|
|
1046
|
-
return { dispose: () => this._changeListeners.delete(handler) };
|
|
1047
|
-
};
|
|
1048
|
-
this.onDidError = (handler) => {
|
|
1049
|
-
this._errorListeners.add(handler);
|
|
1050
|
-
return { dispose: () => this._errorListeners.delete(handler) };
|
|
1051
|
-
};
|
|
1131
|
+
async function verbCopy(source, dest, opts = {}, ctx) {
|
|
1132
|
+
if (!source || typeof source.apply !== 'function') usage('source-required', 'copy: source noggin required');
|
|
1133
|
+
if (!dest || typeof dest.apply !== 'function') usage('dest-required', 'copy: dest noggin required');
|
|
1134
|
+
|
|
1135
|
+
// Snapshot the source up-front. If source === dest, this freezes the
|
|
1136
|
+
// view we're copying from before any add ops mutate dest.
|
|
1137
|
+
const srcItems = source.items.map((it) => ({
|
|
1138
|
+
key: it.key,
|
|
1139
|
+
parentKey: it.parentKey ?? null,
|
|
1140
|
+
title: it.title,
|
|
1141
|
+
done: Boolean(it.done),
|
|
1142
|
+
createdAt: it.createdAt,
|
|
1143
|
+
notes: (it.notes || []).map((n) => ({ timestamp: n.timestamp, text: n.text })),
|
|
1144
|
+
}));
|
|
1145
|
+
|
|
1146
|
+
if (srcItems.length === 0) {
|
|
1147
|
+
return { copied: 0, mapping: {} };
|
|
1148
|
+
}
|
|
1052
1149
|
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1150
|
+
// Walk source as a tree (roots first, depth-first) so every parent
|
|
1151
|
+
// appears in the op list before its children. The flat items array
|
|
1152
|
+
// isn't guaranteed to be in topo order; the recursive walk is.
|
|
1153
|
+
const childrenByParent = new Map();
|
|
1154
|
+
for (const it of srcItems) {
|
|
1155
|
+
const parent = it.parentKey ?? null;
|
|
1156
|
+
if (!childrenByParent.has(parent)) childrenByParent.set(parent, []);
|
|
1157
|
+
childrenByParent.get(parent).push(it);
|
|
1158
|
+
}
|
|
1159
|
+
const ordered = [];
|
|
1160
|
+
function walk(parentKey) {
|
|
1161
|
+
const kids = childrenByParent.get(parentKey) || [];
|
|
1162
|
+
for (const kid of kids) {
|
|
1163
|
+
ordered.push(kid);
|
|
1164
|
+
walk(kid.key);
|
|
1059
1165
|
}
|
|
1060
|
-
|
|
1061
|
-
if (opts.watch) this._startWatch();
|
|
1062
1166
|
}
|
|
1167
|
+
walk(null);
|
|
1168
|
+
|
|
1169
|
+
// Allocate new keys for every source item.
|
|
1170
|
+
const mapping = Object.create(null);
|
|
1171
|
+
for (const it of ordered) mapping[it.key] = newKey();
|
|
1172
|
+
|
|
1173
|
+
// Build add ops in topo order. Source roots (parentKey === null)
|
|
1174
|
+
// become new roots in dest at position 'end' — appended after any
|
|
1175
|
+
// existing dest content. Every other item lands under its (already
|
|
1176
|
+
// added) parent.
|
|
1177
|
+
const ops = ordered.map((it) => {
|
|
1178
|
+
const newParentKey = it.parentKey ? mapping[it.parentKey] : null;
|
|
1179
|
+
return {
|
|
1180
|
+
type: 'add',
|
|
1181
|
+
item: {
|
|
1182
|
+
key: mapping[it.key],
|
|
1183
|
+
parentKey: newParentKey,
|
|
1184
|
+
title: it.title,
|
|
1185
|
+
done: it.done,
|
|
1186
|
+
createdAt: it.createdAt,
|
|
1187
|
+
notes: it.notes,
|
|
1188
|
+
},
|
|
1189
|
+
parentKey: newParentKey,
|
|
1190
|
+
position: 'end',
|
|
1191
|
+
};
|
|
1192
|
+
});
|
|
1063
1193
|
|
|
1064
|
-
|
|
1065
|
-
get store() { return this._store; }
|
|
1066
|
-
get active() { return this._store.active ? findByKey(this._store.items, this._store.active) : null; }
|
|
1067
|
-
get roots() { return _childrenOf(this._store.items, null); }
|
|
1194
|
+
await dest.apply(ops);
|
|
1068
1195
|
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
pathOf(item) { return _pathOf(this._store.items, item); }
|
|
1072
|
-
resolvePath(p) { return resolvePath(this._store, p); }
|
|
1073
|
-
tryResolvePath(p) { return tryResolvePath(this._store, p); }
|
|
1196
|
+
return { copied: ordered.length, mapping };
|
|
1197
|
+
}
|
|
1074
1198
|
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
const prev = this._store;
|
|
1093
|
-
let next;
|
|
1094
|
-
try { next = loadStore(this.file); }
|
|
1095
|
-
catch (e) {
|
|
1096
|
-
if (e instanceof NogginError) { this._fireError(e); return false; }
|
|
1097
|
-
throw e;
|
|
1098
|
-
}
|
|
1099
|
-
if (storesEqual(prev, next)) return false;
|
|
1100
|
-
this._store = freezeStore(next);
|
|
1101
|
-
this._fireChange();
|
|
1102
|
-
return true;
|
|
1103
|
-
}
|
|
1104
|
-
|
|
1105
|
-
dispose() {
|
|
1106
|
-
if (this._disposed) return;
|
|
1107
|
-
this._disposed = true;
|
|
1108
|
-
if (this._reloadTimer) { clearTimeout(this._reloadTimer); this._reloadTimer = null; }
|
|
1109
|
-
if (this._watcher) { try { this._watcher.close(); } catch { /* ignore */ } this._watcher = null; }
|
|
1110
|
-
this._changeListeners.clear();
|
|
1111
|
-
this._errorListeners.clear();
|
|
1112
|
-
}
|
|
1113
|
-
|
|
1114
|
-
// ── Verbs ───────────────────────────────────────────────────────────
|
|
1115
|
-
push(opts) { return this._run(apiPush, opts); }
|
|
1116
|
-
add(opts) { return this._run(apiAdd, opts); }
|
|
1117
|
-
move(opts) { return this._run(apiMove, opts); }
|
|
1118
|
-
goto(p) { return this._run(apiGoto, { path: p }); }
|
|
1119
|
-
done(opts) { return this._run(apiDone, opts); }
|
|
1120
|
-
pop(opts) { return this._run(apiPop, opts || {}); }
|
|
1121
|
-
edit(opts) { return this._run(apiEdit, opts); }
|
|
1122
|
-
show(opts) { return this._runRead(apiShow, opts); }
|
|
1123
|
-
note(opts) { return this._run(apiNote, opts); }
|
|
1124
|
-
delete(opts) { return this._run(apiDelete, opts); }
|
|
1125
|
-
where() { return resolveFile({ file: this.file }); }
|
|
1126
|
-
|
|
1127
|
-
// ── Internals ───────────────────────────────────────────────────────
|
|
1128
|
-
|
|
1129
|
-
_run(fn, opts) {
|
|
1130
|
-
const result = fn(this.file, opts || {});
|
|
1131
|
-
// Refresh cache and notify listeners.
|
|
1132
|
-
try {
|
|
1133
|
-
const next = loadStore(this.file);
|
|
1134
|
-
this._store = freezeStore(next);
|
|
1135
|
-
this._fireChange();
|
|
1136
|
-
} catch (e) {
|
|
1137
|
-
if (e instanceof NogginError) this._fireError(e);
|
|
1138
|
-
else throw e;
|
|
1139
|
-
}
|
|
1140
|
-
return result;
|
|
1141
|
-
}
|
|
1142
|
-
|
|
1143
|
-
_runRead(fn, opts) {
|
|
1144
|
-
const result = fn(this.file, opts || {});
|
|
1145
|
-
// show with --goto mutates; refresh cache in that case.
|
|
1146
|
-
if (opts && opts.goto !== undefined) {
|
|
1147
|
-
try {
|
|
1148
|
-
this._store = freezeStore(loadStore(this.file));
|
|
1149
|
-
this._fireChange();
|
|
1150
|
-
} catch (e) {
|
|
1151
|
-
if (e instanceof NogginError) this._fireError(e);
|
|
1199
|
+
/**
|
|
1200
|
+
* Build the op list for closing `target`, enforcing the open-descendant
|
|
1201
|
+
* rule unless `force` or `closeAll` opts it out. Shared by `done`,
|
|
1202
|
+
* `pop` (via done), and `edit --done`. Does NOT include the setActive
|
|
1203
|
+
* op — callers add that if their verb surfaces active.
|
|
1204
|
+
*/
|
|
1205
|
+
function buildCloseOps(noggin, target, opts, verb, ctx) {
|
|
1206
|
+
const force = opts.force === true;
|
|
1207
|
+
const closeAll = opts.closeAll === true;
|
|
1208
|
+
const ops = [];
|
|
1209
|
+
const ts = nowIso(ctx);
|
|
1210
|
+
|
|
1211
|
+
if (closeAll) {
|
|
1212
|
+
for (const d of collectDescendants(noggin.items, target)) {
|
|
1213
|
+
if (!d.done) {
|
|
1214
|
+
ops.push({ type: 'set', key: d.key, patch: { done: true } });
|
|
1215
|
+
ops.push({ type: 'note', key: d.key, note: { timestamp: ts, text: CLOSE_NOTE_TEXT } });
|
|
1152
1216
|
}
|
|
1153
1217
|
}
|
|
1154
|
-
return result;
|
|
1155
1218
|
}
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1219
|
+
if (!force && !closeAll) {
|
|
1220
|
+
const open = countOpenDescendants(noggin.items, target);
|
|
1221
|
+
if (open > 0) {
|
|
1222
|
+
runtime(
|
|
1223
|
+
'open-descendants',
|
|
1224
|
+
`${verb}: ${noggin.pathOf(target)} has ${open} open descendant(s); ` +
|
|
1225
|
+
`pass --closeall to close them too, or --force to close ${target.title} anyway`,
|
|
1226
|
+
);
|
|
1160
1227
|
}
|
|
1161
1228
|
}
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
try { h(err); } catch { /* swallow */ }
|
|
1166
|
-
}
|
|
1229
|
+
if (!target.done) {
|
|
1230
|
+
ops.push({ type: 'set', key: target.key, patch: { done: true } });
|
|
1231
|
+
ops.push({ type: 'note', key: target.key, note: { timestamp: ts, text: CLOSE_NOTE_TEXT } });
|
|
1167
1232
|
}
|
|
1233
|
+
return ops;
|
|
1234
|
+
}
|
|
1168
1235
|
|
|
1169
|
-
|
|
1170
|
-
const dir = path.dirname(this.file);
|
|
1171
|
-
if (!fs.existsSync(dir)) return; // can't watch a nonexistent dir; bail
|
|
1172
|
-
try {
|
|
1173
|
-
this._watcher = fs.watch(dir, { persistent: false }, (_event, name) => {
|
|
1174
|
-
if (!name) { this._scheduleReload(); return; }
|
|
1175
|
-
if (path.basename(this.file) === name) this._scheduleReload();
|
|
1176
|
-
});
|
|
1177
|
-
} catch { /* watching is best-effort */ }
|
|
1178
|
-
}
|
|
1236
|
+
// ── Factory registry ─────────────────────────────────────────────────────────
|
|
1179
1237
|
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1238
|
+
function createRegistry() {
|
|
1239
|
+
/** @type {Map<string, any>} */
|
|
1240
|
+
const byScheme = new Map();
|
|
1241
|
+
/** @type {string|null} */
|
|
1242
|
+
let defaultScheme = null;
|
|
1243
|
+
return {
|
|
1244
|
+
register(factory, opts = {}) {
|
|
1245
|
+
if (!factory || typeof factory.scheme !== 'string' || !factory.scheme) {
|
|
1246
|
+
throw new TypeError('factories.register: factory.scheme (non-empty string) required');
|
|
1247
|
+
}
|
|
1248
|
+
if (typeof factory.open !== 'function') {
|
|
1249
|
+
throw new TypeError('factories.register: factory.open function required');
|
|
1250
|
+
}
|
|
1251
|
+
byScheme.set(factory.scheme, factory);
|
|
1252
|
+
if (opts.default) defaultScheme = factory.scheme;
|
|
1253
|
+
},
|
|
1254
|
+
unregister(scheme) {
|
|
1255
|
+
const had = byScheme.delete(scheme);
|
|
1256
|
+
if (defaultScheme === scheme) defaultScheme = null;
|
|
1257
|
+
return had;
|
|
1258
|
+
},
|
|
1259
|
+
get(scheme) { return byScheme.get(scheme) || null; },
|
|
1260
|
+
getDefault() {
|
|
1261
|
+
return defaultScheme ? byScheme.get(defaultScheme) || null : null;
|
|
1262
|
+
},
|
|
1263
|
+
list() {
|
|
1264
|
+
return Array.from(byScheme.values()).map((f) => ({
|
|
1265
|
+
scheme: f.scheme,
|
|
1266
|
+
default: f.scheme === defaultScheme,
|
|
1267
|
+
}));
|
|
1268
|
+
},
|
|
1269
|
+
};
|
|
1188
1270
|
}
|
|
1189
1271
|
|
|
1190
|
-
/**
|
|
1191
|
-
|
|
1192
|
-
|
|
1272
|
+
/**
|
|
1273
|
+
* The process-wide noggin factory registry. Backends call
|
|
1274
|
+
* `factories.register({scheme, open})` (typically on import side-effect).
|
|
1275
|
+
* `openNoggin(location)` consults this registry to pick a factory by
|
|
1276
|
+
* scheme prefix; bare locations go to whichever factory was registered
|
|
1277
|
+
* with `{default: true}`.
|
|
1278
|
+
*/
|
|
1279
|
+
export const factories = createRegistry();
|
|
1280
|
+
|
|
1281
|
+
function parseLocation(s) {
|
|
1282
|
+
const m = String(s == null ? '' : s).match(/^([a-z][a-z0-9+.-]*):\/\/(.*)$/i);
|
|
1283
|
+
return m ? { scheme: m[1].toLowerCase(), rest: m[2] } : { scheme: null, rest: String(s == null ? '' : s) };
|
|
1193
1284
|
}
|
|
1194
1285
|
|
|
1195
|
-
|
|
1286
|
+
/**
|
|
1287
|
+
* Open a noggin by location. The scheme prefix (e.g. `file://`,
|
|
1288
|
+
* `localstorage://`) selects the factory; a bare location goes to
|
|
1289
|
+
* the default factory.
|
|
1290
|
+
*
|
|
1291
|
+
* @param {string} location
|
|
1292
|
+
* @param {object} [opts] Forwarded to the factory.
|
|
1293
|
+
* @returns {Promise<any>}
|
|
1294
|
+
*/
|
|
1295
|
+
export async function openNoggin(location, opts) {
|
|
1296
|
+
if (!location) {
|
|
1297
|
+
throw new NogginError('openNoggin: location required', { code: 'no-location', exitCode: 2 });
|
|
1298
|
+
}
|
|
1299
|
+
const { scheme, rest } = parseLocation(location);
|
|
1300
|
+
const factory = scheme ? factories.get(scheme) : factories.getDefault();
|
|
1301
|
+
if (!factory) {
|
|
1302
|
+
if (scheme) usage('no-factory', `no factory registered for scheme '${scheme}://'`);
|
|
1303
|
+
usage('no-factory', `no default factory registered; cannot open '${location}'`);
|
|
1304
|
+
}
|
|
1305
|
+
// Forward the original location so backends can preserve it for
|
|
1306
|
+
// round-trippable `where` output. Backends still receive `rest` (the
|
|
1307
|
+
// post-scheme portion) as the resolution input.
|
|
1308
|
+
return factory.open(rest, { ...opts, location });
|
|
1309
|
+
}
|
|
1196
1310
|
|
|
1197
|
-
|
|
1311
|
+
// ── Snapshot helpers (used by backends) ──────────────────────────────────────
|
|
1312
|
+
|
|
1313
|
+
/** Structural equality between two NogginDocuments. */
|
|
1314
|
+
export function documentsEqual(a, b) {
|
|
1198
1315
|
if (a === b) return true;
|
|
1199
1316
|
if (!a || !b) return false;
|
|
1200
1317
|
if (a.active !== b.active) return false;
|
|
@@ -1222,15 +1339,18 @@ function itemsEqual(a, b) {
|
|
|
1222
1339
|
return true;
|
|
1223
1340
|
}
|
|
1224
1341
|
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1342
|
+
/**
|
|
1343
|
+
* Deep-freeze a noggin document. Backends call this on the in-memory
|
|
1344
|
+
* cache that's exposed via accessors so consumers can't accidentally
|
|
1345
|
+
* mutate it.
|
|
1346
|
+
*/
|
|
1347
|
+
export function freezeDocument(doc) {
|
|
1348
|
+
for (const item of doc.items) {
|
|
1229
1349
|
for (const note of item.notes || []) Object.freeze(note);
|
|
1230
1350
|
Object.freeze(item.notes);
|
|
1231
1351
|
Object.freeze(item);
|
|
1232
1352
|
}
|
|
1233
|
-
Object.freeze(
|
|
1234
|
-
Object.freeze(
|
|
1235
|
-
return
|
|
1353
|
+
Object.freeze(doc.items);
|
|
1354
|
+
Object.freeze(doc);
|
|
1355
|
+
return doc;
|
|
1236
1356
|
}
|