pi-weave 0.1.12 → 0.1.13
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 +8 -37
- package/package.json +1 -2
- package/src/core/concurrency.ts +3 -6
- package/src/core/frontmatter.ts +0 -53
- package/src/core/graph/build.ts +6 -7
- package/src/core/graph/current.ts +2 -4
- package/src/core/graph/model.ts +1 -1
- package/src/core/graph/wikilinks.ts +3 -3
- package/src/core/index.ts +26 -27
- package/src/core/paths.ts +0 -7
- package/src/core/vault.ts +16 -681
- package/src/core/view/detail.ts +1 -1
- package/src/core/view/health.ts +1 -1
- package/src/core/view/tree.ts +1 -1
- package/src/pi/index.ts +6 -85
- package/src/pi/summarize.ts +2 -2
- package/src/pi/viewer/tui/bodyStore.ts +4 -7
- package/src/pi/viewer/tui/branding.ts +7 -148
- package/src/pi/viewer/tui/run.ts +3 -17
- package/src/pi/viewer/tui/surface/base.ts +24 -3
- package/src/pi/viewer/tui/surface/explore.ts +41 -6
- package/src/pi/viewer/tui/workspace.ts +23 -351
- package/src/pi/viewer/tui/workspaceRoot.ts +31 -172
- package/src/pi/viewer/web/run.ts +7 -117
- package/src/web/client/api.dom.ts +2 -2
- package/src/web/client/api.ts +14 -223
- package/src/web/client/bootstrap.ts +5 -14
- package/src/web/client/context/context.model.ts +9 -11
- package/src/web/client/dist/app.js +93 -219
- package/src/web/client/graph/dynamics.ts +5 -65
- package/src/web/client/graph/renderer.dom.ts +7 -8
- package/src/web/client/graph/renderer.ts +9 -35
- package/src/web/client/main.tsx +1 -1
- package/src/web/client/note/Note.tsx +21 -63
- package/src/web/client/search/SearchPalette.tsx +45 -36
- package/src/web/client/search/search.model.ts +33 -454
- package/src/web/client/shell/Columns.tsx +13 -83
- package/src/web/client/shell/Header.tsx +2 -10
- package/src/web/client/shell/Shell.tsx +50 -125
- package/src/web/client/shell/StatusBar.tsx +1 -4
- package/src/web/client/shell/icons.model.ts +4 -7
- package/src/web/client/shell/keys.model.ts +5 -42
- package/src/web/client/shell/keys.ts +2 -2
- package/src/web/client/shell/shell.model.ts +10 -133
- package/src/web/client/shell/theme.model.ts +2 -2
- package/src/web/client/shell/theme.ts +33 -157
- package/src/web/client/state.ts +9 -89
- package/src/web/client/tree/Tree.tsx +25 -575
- package/src/web/client/tree/tree.model.ts +8 -162
- package/src/web/client/workspace.ts +72 -242
- package/src/web/server/page.ts +8 -10
- package/src/web/server/routes.ts +30 -563
- package/src/web/server/server.ts +6 -145
- package/src/web/shared/layout.ts +72 -624
- package/src/web/shared/wire.ts +10 -196
- package/src/core/sessions.ts +0 -929
- package/src/pi/sessionScan.ts +0 -104
- package/src/pi/viewer/tui/explorer.ts +0 -586
- package/src/web/client/live.model.ts +0 -275
- package/src/web/client/live.ts +0 -151
- package/src/web/client/note/Editor.tsx +0 -109
- package/src/web/client/note/editor.controller.ts +0 -151
- package/src/web/client/note/editor.model.ts +0 -686
- package/src/web/client/search/search.ts +0 -107
- package/src/web/client/shell/Divider.tsx +0 -44
- package/src/web/client/shell/cssvars.ts +0 -70
- package/src/web/client/shell/drag.model.ts +0 -170
- package/src/web/client/shell/layout.model.ts +0 -500
- package/src/web/client/shell/viewport.ts +0 -29
- package/src/web/server/sse.ts +0 -321
- package/src/web/server/watcher.ts +0 -507
package/src/core/vault.ts
CHANGED
|
@@ -1,17 +1,14 @@
|
|
|
1
|
-
import { existsSync
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
2
|
import { promises as fs } from "node:fs";
|
|
3
3
|
import { dirname, isAbsolute, join, relative } from "node:path";
|
|
4
4
|
import {
|
|
5
|
-
MANAGED_FRONT_MATTER_KEYS,
|
|
6
5
|
parseFrontMatter,
|
|
7
6
|
parseNoteFile,
|
|
8
|
-
quoteField,
|
|
9
7
|
serializeNote,
|
|
10
|
-
upsertFrontMatterFields,
|
|
11
8
|
} from "./frontmatter";
|
|
12
9
|
import { withMutationQueue } from "./mutex";
|
|
13
10
|
import { NOTES_DIR, OKF_MANIFEST } from "./paths";
|
|
14
|
-
import { slugify,
|
|
11
|
+
import { slugify, uniqueSlug } from "./slug";
|
|
15
12
|
import type {
|
|
16
13
|
Note,
|
|
17
14
|
NoteFrontMatter,
|
|
@@ -74,18 +71,16 @@ function notePath(root: string, slug: string): string {
|
|
|
74
71
|
* Resolve a note slug to its on-disk path, or null when the slug is unsafe.
|
|
75
72
|
* Slugs arrive from tool parameters, so they are untrusted: `../x` and
|
|
76
73
|
* absolute escapes must never read or write outside `<vault>/notes/`
|
|
77
|
-
* (subdirectories *within* it are legitimate
|
|
74
|
+
* (subdirectories *within* it are legitimate for nested notes).
|
|
78
75
|
*/
|
|
79
76
|
export function resolveNotePath(root: string, slug: string): string | null {
|
|
80
77
|
if (slug.trim().length === 0) return null;
|
|
81
78
|
const notesDir = join(root, NOTES_DIR);
|
|
82
79
|
const candidate = join(notesDir, `${slug}.md`);
|
|
83
80
|
const rel = relative(notesDir, candidate);
|
|
84
|
-
// Slugs may nest
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
// outside notes/ and are rejected here, at the one door every read and
|
|
88
|
-
// write walks through.
|
|
81
|
+
// Slugs may nest, but they may never escape the collection: `..` segments
|
|
82
|
+
// and absolute paths resolve outside notes/ and are rejected here, at the
|
|
83
|
+
// one door every read and write walks through.
|
|
89
84
|
if (rel.startsWith("..") || isAbsolute(rel) || rel.length === 0) return null;
|
|
90
85
|
return candidate;
|
|
91
86
|
}
|
|
@@ -158,8 +153,8 @@ async function writeNote(
|
|
|
158
153
|
frontMatter: NoteFrontMatter | undefined,
|
|
159
154
|
): Promise<Note> {
|
|
160
155
|
const text = serializeNote(meta, body, frontMatter);
|
|
161
|
-
// Path-slugs
|
|
162
|
-
//
|
|
156
|
+
// Path-slugs may introduce a new subdirectory; every write path funnels
|
|
157
|
+
// through here, so this is the one mkdir that matters.
|
|
163
158
|
await fs.mkdir(dirname(path), { recursive: true });
|
|
164
159
|
await fs.writeFile(path, text, "utf8");
|
|
165
160
|
const parsed = parseNoteFile(text);
|
|
@@ -188,17 +183,9 @@ const LOCK_NS = "vault:note:";
|
|
|
188
183
|
/**
|
|
189
184
|
* Run `task` with exclusive access to every given note path.
|
|
190
185
|
*
|
|
191
|
-
* Paths are locked in a fixed
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
* `b→a`) would otherwise be able to take one lock each and wait forever;
|
|
195
|
-
* with a total order on acquisition, one of them takes both and the other
|
|
196
|
-
* takes neither.
|
|
197
|
-
*
|
|
198
|
-
* Every mutation in this module goes through here, not just the new ones. A
|
|
199
|
-
* queue that half the writers ignore serializes nothing: an `appendToNote`
|
|
200
|
-
* racing an `updateNote` on the same file is a lost update whether or not
|
|
201
|
-
* the update took a lock.
|
|
186
|
+
* Paths are locked in a fixed order so callers that need multiple locks cannot
|
|
187
|
+
* deadlock. Every mutation in this module goes through here, keeping appends
|
|
188
|
+
* and finalization serialized with one another.
|
|
202
189
|
*/
|
|
203
190
|
function withNoteLocks<T>(paths: readonly string[], task: () => Promise<T>): Promise<T> {
|
|
204
191
|
const ordered = [...new Set(paths)].sort();
|
|
@@ -344,8 +331,8 @@ export async function finalizeNote(
|
|
|
344
331
|
const structured = input.body.trim();
|
|
345
332
|
// A note whose body carries no `## Raw` marker yet is treated as *all*
|
|
346
333
|
// raw: the entire pre-finalize body is preserved verbatim beneath the
|
|
347
|
-
// restructured body as a freshly created tail
|
|
348
|
-
//
|
|
334
|
+
// restructured body as a freshly created tail so the user's words are
|
|
335
|
+
// never silently destroyed by finalization.
|
|
349
336
|
const body =
|
|
350
337
|
structured +
|
|
351
338
|
(rawTail !== ""
|
|
@@ -358,662 +345,21 @@ export async function finalizeNote(
|
|
|
358
345
|
});
|
|
359
346
|
}
|
|
360
347
|
|
|
361
|
-
// ---------------------------------------------------------------------------
|
|
362
|
-
// Mutation APIs (weave-workspace §11 P5.2, P5.3)
|
|
363
|
-
// ---------------------------------------------------------------------------
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* A note plus the stamp that identifies the on-disk state it was read from.
|
|
367
|
-
*
|
|
368
|
-
* The read half of the conflict primitive (§11 P5.3). An editor reads this,
|
|
369
|
-
* holds `revision` for as long as the user is typing, and hands it back on
|
|
370
|
-
* save; a `revision` that no longer matches the file means someone else — a
|
|
371
|
-
* `weave_note` tool call, `$EDITOR`, an Obsidian sync — wrote in between.
|
|
372
|
-
*/
|
|
373
|
-
export interface RevisionedNote {
|
|
374
|
-
note: Note;
|
|
375
|
-
/**
|
|
376
|
-
* Opaque version stamp. **Compare it, do not interpret it.**
|
|
377
|
-
*
|
|
378
|
-
* It is currently `mtimeMs:size`, and the shape is deliberately not part
|
|
379
|
-
* of the contract: a caller that parses out the mtime is a caller that
|
|
380
|
-
* breaks when this becomes a content hash. Two fields rather than one
|
|
381
|
-
* because mtime alone has a real blind spot — the filesystem timestamp
|
|
382
|
-
* granularity. Two writes inside the same millisecond are indistinguishable
|
|
383
|
-
* by mtime, and "same millisecond" is not exotic when the writer is a
|
|
384
|
-
* program rather than a human; a size change catches the common case of
|
|
385
|
-
* such a pair differing in length.
|
|
386
|
-
*
|
|
387
|
-
* This does not make it a perfect detector. A same-millisecond write that
|
|
388
|
-
* preserves the byte count is invisible, which is the honest limitation of
|
|
389
|
-
* any stat-based scheme, and the reason this is typed as opaque: upgrading
|
|
390
|
-
* it to a digest is then a change here and nowhere else.
|
|
391
|
-
*/
|
|
392
|
-
revision: string;
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
function revisionOf(st: { mtimeMs: number; size: number }): string {
|
|
396
|
-
return `${st.mtimeMs}:${st.size}`;
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
/**
|
|
400
|
-
* Read a note together with its {@link RevisionedNote.revision}.
|
|
401
|
-
*
|
|
402
|
-
* Stat-then-read rather than read-then-stat: if a writer lands between the
|
|
403
|
-
* two calls, the revision is of the *older* state than the content, so the
|
|
404
|
-
* save that follows sees a mismatch and is rejected. The opposite order
|
|
405
|
-
* yields a revision newer than the content, which would let a stale body be
|
|
406
|
-
* written back under a revision that looks current — failing safe versus
|
|
407
|
-
* failing silently.
|
|
408
|
-
*/
|
|
409
|
-
export async function getNoteWithRevision(root: string, slug: string): Promise<RevisionedNote | null> {
|
|
410
|
-
const path = resolveNotePath(root, slug);
|
|
411
|
-
if (!path) return null;
|
|
412
|
-
let revision: string;
|
|
413
|
-
try {
|
|
414
|
-
revision = revisionOf(await fs.stat(path));
|
|
415
|
-
} catch {
|
|
416
|
-
return null;
|
|
417
|
-
}
|
|
418
|
-
const note = await getNote(root, slug);
|
|
419
|
-
return note === null ? null : { note, revision };
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
/**
|
|
423
|
-
* Why a mutation did not happen. The server maps these to status codes —
|
|
424
|
-
* `"missing"` → 404, `"conflict"` → 409, `"collision"` → 409 — but the
|
|
425
|
-
* mapping is the server's business; core reports the *situation* (§11 P5.3).
|
|
426
|
-
*/
|
|
427
|
-
export type MutationFailure =
|
|
428
|
-
/** No such note, or a slug that failed the traversal guard. */
|
|
429
|
-
| { ok: false; reason: "missing" }
|
|
430
|
-
/** The file moved since `expectedRevision` was read. */
|
|
431
|
-
| { ok: false; reason: "conflict"; current: RevisionedNote }
|
|
432
|
-
/** A rename whose destination slug is already taken. */
|
|
433
|
-
| { ok: false; reason: "collision"; slug: string };
|
|
434
|
-
|
|
435
|
-
export type MutationResult = { ok: true; note: Note } | MutationFailure;
|
|
436
|
-
|
|
437
|
-
/** A successful delete, or why it did not happen. */
|
|
438
|
-
export type DeleteResult = { ok: true } | { ok: false; reason: "missing" };
|
|
439
|
-
|
|
440
|
-
export interface UpdateNoteInput {
|
|
441
|
-
/** Replacement Markdown body. Omit to change only metadata. */
|
|
442
|
-
body?: string;
|
|
443
|
-
/**
|
|
444
|
-
* Metadata to merge over the note's current values. `created` is not
|
|
445
|
-
* accepted: it records when the note came into existence, and an edit is
|
|
446
|
-
* not a re-creation.
|
|
447
|
-
*/
|
|
448
|
-
meta?: Partial<Pick<NoteMeta, "title" | "tags" | "source">>;
|
|
449
|
-
/**
|
|
450
|
-
* The {@link RevisionedNote.revision} the caller last read. When supplied
|
|
451
|
-
* and no longer current, the write is refused with `reason: "conflict"`
|
|
452
|
-
* and the caller is handed the note as it now is, so a UI can offer
|
|
453
|
-
* reload-or-overwrite without a second round trip. Omit for
|
|
454
|
-
* last-write-wins.
|
|
455
|
-
*/
|
|
456
|
-
expectedRevision?: string;
|
|
457
|
-
/** Injectable clock for tests. */
|
|
458
|
-
now?: Date;
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
/**
|
|
462
|
-
* Check `expectedRevision` against the file, inside the caller's lock.
|
|
463
|
-
*
|
|
464
|
-
* Returns the conflict to report, or null to proceed. Being inside the lock
|
|
465
|
-
* is the whole point: a check-then-write with the check outside the mutex is
|
|
466
|
-
* a race with a wider window than no check at all, because it looks like it
|
|
467
|
-
* is doing something.
|
|
468
|
-
*/
|
|
469
|
-
async function checkRevision(
|
|
470
|
-
root: string,
|
|
471
|
-
slug: string,
|
|
472
|
-
expected: string | undefined,
|
|
473
|
-
): Promise<MutationFailure | null> {
|
|
474
|
-
if (expected === undefined) return null;
|
|
475
|
-
const current = await getNoteWithRevision(root, slug);
|
|
476
|
-
if (current === null) return { ok: false, reason: "missing" };
|
|
477
|
-
return current.revision === expected ? null : { ok: false, reason: "conflict", current };
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
/**
|
|
481
|
-
* Update a note's body and/or metadata in place.
|
|
482
|
-
*
|
|
483
|
-
* Preserves unknown front-matter keys (they ride on `note.frontMatter`
|
|
484
|
-
* through `writeNote`) and, when `body` is given, the append-only `## Raw`
|
|
485
|
-
* tail: the replacement body is treated as the *editorial* region above the
|
|
486
|
-
* tail, exactly as `finalizeNote` treats it. A caller replacing the body of
|
|
487
|
-
* a dictated note therefore cannot delete the user's verbatim scribbles by
|
|
488
|
-
* omitting them, which is the one thing `docs/notepad.md` §4 says must never
|
|
489
|
-
* happen. A body that already carries its own `## Raw` tail is written as
|
|
490
|
-
* given, so a round-trip through an editor that shows the whole file is not
|
|
491
|
-
* penalised with a duplicated tail.
|
|
492
|
-
*
|
|
493
|
-
* `updated` is bumped on every successful call, including a metadata-only
|
|
494
|
-
* one — a tag change is a change to the note.
|
|
495
|
-
*/
|
|
496
|
-
export async function updateNote(
|
|
497
|
-
root: string,
|
|
498
|
-
slug: string,
|
|
499
|
-
input: UpdateNoteInput,
|
|
500
|
-
now: Date = new Date(),
|
|
501
|
-
): Promise<MutationResult> {
|
|
502
|
-
const path = resolveNotePath(root, slug);
|
|
503
|
-
if (!path) return { ok: false, reason: "missing" };
|
|
504
|
-
return withNoteLocks([path], async () => {
|
|
505
|
-
const conflict = await checkRevision(root, slug, input.expectedRevision);
|
|
506
|
-
if (conflict) return conflict;
|
|
507
|
-
const note = await getNote(root, slug);
|
|
508
|
-
if (!note) return { ok: false, reason: "missing" };
|
|
509
|
-
|
|
510
|
-
const body = input.body === undefined ? note.body : preserveRawTail(note.body, input.body);
|
|
511
|
-
const meta: NoteMeta = {
|
|
512
|
-
...note,
|
|
513
|
-
...input.meta,
|
|
514
|
-
updated: (input.now ?? now).toISOString(),
|
|
515
|
-
};
|
|
516
|
-
return { ok: true, note: await writeNote(path, slug, meta, body, note.frontMatter) };
|
|
517
|
-
});
|
|
518
|
-
}
|
|
519
|
-
|
|
520
|
-
/** Re-attach the existing `## Raw` tail unless the replacement already has one. */
|
|
521
|
-
function preserveRawTail(currentBody: string, nextBody: string): string {
|
|
522
|
-
const tail = extractRawTail(currentBody);
|
|
523
|
-
if (tail === "" || extractRawTail(nextBody) !== "") return nextBody.trim();
|
|
524
|
-
return nextBody.trim() + `\n\n${tail}`;
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
/**
|
|
528
|
-
* Rename a note: move `oldSlug.md` to `newSlug.md`.
|
|
529
|
-
*
|
|
530
|
-
* `newSlug` is passed through `slugify`, so a caller may hand over either a
|
|
531
|
-
* slug or a human title and get the same filesystem-safe result the rest of
|
|
532
|
-
* the vault uses.
|
|
533
|
-
*
|
|
534
|
-
* ## Inbound `[[wikilinks]]` are deliberately NOT rewritten
|
|
535
|
-
*
|
|
536
|
-
* A rename can break links from other notes, and there are two honest
|
|
537
|
-
* options. Rewriting every referring note is the bigger hammer, and it is
|
|
538
|
-
* the wrong one here:
|
|
539
|
-
*
|
|
540
|
-
* - **It is a multi-file write with no transaction.** Renaming one note
|
|
541
|
-
* would rewrite N others; a failure partway leaves the vault half-updated,
|
|
542
|
-
* and there is no rollback. Trading one dangling link for an unknown
|
|
543
|
-
* number of half-edited files is a bad trade.
|
|
544
|
-
* - **It edits prose to fix an index.** A wikilink lives in body text a
|
|
545
|
-
* human wrote, sometimes inside a quote, a code fence, or a `## Raw` tail
|
|
546
|
-
* that `docs/notepad.md` declares append-only and verbatim. A textual
|
|
547
|
-
* substitution across the vault cannot honour that; a rename would become
|
|
548
|
-
* the one operation allowed to modify preserved user input.
|
|
549
|
-
* - **The alternative is already visible, not silent.** Dangling targets are
|
|
550
|
-
* a first-class concept: `buildGraph` collects `danglingLinks`, the wire
|
|
551
|
-
* payload ships them as `dangling`, and the note column renders an
|
|
552
|
-
* unresolved wikilink as an unfollowable ghost. A stale link therefore
|
|
553
|
-
* shows up in the UI as something to fix, which is a better failure than a
|
|
554
|
-
* silent bulk edit the user cannot review.
|
|
555
|
-
*
|
|
556
|
-
* So: renaming leaves inbound links pointing at the old slug, where they
|
|
557
|
-
* render as dangling. If link-following-a-rename is wanted later, the right
|
|
558
|
-
* shape is an explicit, previewable "update N referring notes?" step — a
|
|
559
|
-
* separate operation the user opts into, not a side effect of this one.
|
|
560
|
-
*/
|
|
561
|
-
export async function renameNote(
|
|
562
|
-
root: string,
|
|
563
|
-
oldSlug: string,
|
|
564
|
-
newSlug: string,
|
|
565
|
-
now: Date = new Date(),
|
|
566
|
-
newTitle?: string,
|
|
567
|
-
): Promise<MutationResult> {
|
|
568
|
-
const from = resolveNotePath(root, oldSlug);
|
|
569
|
-
if (!from) return { ok: false, reason: "missing" };
|
|
570
|
-
|
|
571
|
-
const rawTarget = newSlug.trim();
|
|
572
|
-
if (rawTarget.length === 0) return { ok: false, reason: "missing" };
|
|
573
|
-
|
|
574
|
-
const oldFolder = oldSlug.includes("/") ? oldSlug.split("/").slice(0, -1).join("/") : "";
|
|
575
|
-
const hasSlash = /[\/\\]/.test(rawTarget);
|
|
576
|
-
const target = hasSlash
|
|
577
|
-
? slugifyPath(rawTarget)
|
|
578
|
-
: oldFolder.length > 0
|
|
579
|
-
? `${oldFolder}/${slugify(rawTarget)}`
|
|
580
|
-
: slugify(rawTarget);
|
|
581
|
-
|
|
582
|
-
const to = resolveNotePath(root, target);
|
|
583
|
-
// `slugify`/`slugifyPath` cannot emit a traversing slug, but the guard is applied anyway:
|
|
584
|
-
// "this input is already safe" is exactly the assumption that stops being
|
|
585
|
-
// true when someone changes the other function.
|
|
586
|
-
if (!to) return { ok: false, reason: "missing" };
|
|
587
|
-
if (target === oldSlug && (newTitle === undefined || newTitle.trim().length === 0)) {
|
|
588
|
-
const note = await getNote(root, oldSlug);
|
|
589
|
-
return note === null ? { ok: false, reason: "missing" } : { ok: true, note };
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
return withNoteLocks([from, to], async () => {
|
|
593
|
-
const note = await getNote(root, oldSlug);
|
|
594
|
-
if (!note) return { ok: false, reason: "missing" };
|
|
595
|
-
// Refuse rather than uniquify: `addNote` may silently pick `decision-2`
|
|
596
|
-
// because nobody named a file there, but a rename onto an existing note
|
|
597
|
-
// is a user mistake, and quietly landing somewhere other than where they
|
|
598
|
-
// asked hides it. `fs.rename` would overwrite the destination outright.
|
|
599
|
-
if (await exists(to)) return { ok: false, reason: "collision", slug: target };
|
|
600
|
-
|
|
601
|
-
await fs.mkdir(dirname(to), { recursive: true });
|
|
602
|
-
if (from !== to) {
|
|
603
|
-
await fs.rename(from, to);
|
|
604
|
-
}
|
|
605
|
-
|
|
606
|
-
const title =
|
|
607
|
-
newTitle !== undefined && newTitle.trim().length > 0 ? newTitle.trim() : note.title;
|
|
608
|
-
|
|
609
|
-
// The slug is the note's identity, so a rename is a change to the note.
|
|
610
|
-
const meta: NoteMeta = { ...note, title, updated: now.toISOString() };
|
|
611
|
-
return { ok: true, note: await writeNote(to, target, meta, note.body, note.frontMatter) };
|
|
612
|
-
});
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
/**
|
|
616
|
-
* Delete a note. **Hard delete** — the file is unlinked.
|
|
617
|
-
*
|
|
618
|
-
* No trash directory, and that is a deliberate omission rather than an
|
|
619
|
-
* oversight. A trash is a real feature: it needs a location that does not
|
|
620
|
-
* pollute `notes/` (everything there is indexed and graphed), a retention
|
|
621
|
-
* policy, a restore path, and an answer for what happens when a deleted slug
|
|
622
|
-
* is later reused. Inventing all of that as a side effect of "P5 needs a
|
|
623
|
-
* delete button" is how a vault grows a second, undocumented store of notes
|
|
624
|
-
* that the index does not know about — and AGENTS.md rule 5 says nothing in
|
|
625
|
-
* `.okf` may be the only copy of anything, which cuts both ways: a
|
|
626
|
-
* half-designed trash becomes exactly such a place.
|
|
627
|
-
*
|
|
628
|
-
* The vault is plain files in a directory most users keep under version
|
|
629
|
-
* control or a synced folder, so the recovery story is the one they already
|
|
630
|
-
* have and understand. If a trash is wanted, it should arrive as its own
|
|
631
|
-
* design decision with those questions answered.
|
|
632
|
-
*/
|
|
633
|
-
export async function deleteNote(root: string, slug: string): Promise<DeleteResult> {
|
|
634
|
-
const path = resolveNotePath(root, slug);
|
|
635
|
-
if (!path) return { ok: false, reason: "missing" };
|
|
636
|
-
return withNoteLocks([path], async () => {
|
|
637
|
-
try {
|
|
638
|
-
await fs.unlink(path);
|
|
639
|
-
return { ok: true };
|
|
640
|
-
} catch {
|
|
641
|
-
// Already gone, or never existed. Both are "there is no such note",
|
|
642
|
-
// which is the caller's question — not "the unlink syscall failed".
|
|
643
|
-
return { ok: false, reason: "missing" };
|
|
644
|
-
}
|
|
645
|
-
});
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
/**
|
|
649
|
-
* Move a note to a folder (or root when targetFolder is null/empty/"vault").
|
|
650
|
-
* Automatically ensures the note carries the folder's name in its #tags
|
|
651
|
-
* so that notes filed in the folder are graph-connected.
|
|
652
|
-
*/
|
|
653
|
-
export async function moveNoteToFolder(
|
|
654
|
-
root: string,
|
|
655
|
-
slug: string,
|
|
656
|
-
targetFolder: string | null,
|
|
657
|
-
now: Date = new Date(),
|
|
658
|
-
): Promise<MutationResult> {
|
|
659
|
-
const from = resolveNotePath(root, slug);
|
|
660
|
-
if (!from) return { ok: false, reason: "missing" };
|
|
661
|
-
|
|
662
|
-
const noteBaseName = slug.split("/").pop() ?? slug;
|
|
663
|
-
let newSlug: string;
|
|
664
|
-
let folderTag: string | null = null;
|
|
665
|
-
|
|
666
|
-
if (targetFolder !== null && targetFolder.trim().length > 0 && targetFolder !== "vault") {
|
|
667
|
-
const cleanedFolder = targetFolder.trim().replace(/^[\/\\]+|[\/\\]+$/g, "");
|
|
668
|
-
const folderSegments = cleanedFolder.split(/[\/\\]+/).map(slugify).filter((s) => s.length > 0);
|
|
669
|
-
if (folderSegments.length === 0) return { ok: false, reason: "missing" };
|
|
670
|
-
const folderPath = folderSegments.join("/");
|
|
671
|
-
newSlug = `${folderPath}/${noteBaseName}`;
|
|
672
|
-
folderTag = folderSegments[folderSegments.length - 1] ?? null;
|
|
673
|
-
} else {
|
|
674
|
-
newSlug = noteBaseName;
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
const to = resolveNotePath(root, newSlug);
|
|
678
|
-
if (!to) return { ok: false, reason: "missing" };
|
|
679
|
-
|
|
680
|
-
return withNoteLocks([from, to], async () => {
|
|
681
|
-
const note = await getNote(root, slug);
|
|
682
|
-
if (!note) return { ok: false, reason: "missing" };
|
|
683
|
-
|
|
684
|
-
if (newSlug !== slug && (await exists(to))) {
|
|
685
|
-
return { ok: false, reason: "collision", slug: newSlug };
|
|
686
|
-
}
|
|
687
|
-
|
|
688
|
-
await fs.mkdir(dirname(to), { recursive: true });
|
|
689
|
-
if (from !== to) {
|
|
690
|
-
await fs.rename(from, to);
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
const tags = [...note.tags];
|
|
694
|
-
if (folderTag && !tags.includes(folderTag)) {
|
|
695
|
-
tags.push(folderTag);
|
|
696
|
-
}
|
|
697
|
-
|
|
698
|
-
const meta: NoteMeta = {
|
|
699
|
-
...note,
|
|
700
|
-
tags,
|
|
701
|
-
updated: now.toISOString(),
|
|
702
|
-
};
|
|
703
|
-
const written = await writeNote(to, newSlug, meta, note.body, note.frontMatter);
|
|
704
|
-
return { ok: true, note: written };
|
|
705
|
-
});
|
|
706
|
-
}
|
|
707
|
-
|
|
708
|
-
/**
|
|
709
|
-
* Create a folder under the vault's notes directory.
|
|
710
|
-
*/
|
|
711
|
-
export async function createFolder(
|
|
712
|
-
root: string,
|
|
713
|
-
folderName: string,
|
|
714
|
-
): Promise<{ ok: true; path: string } | { ok: false; reason: "invalid-name" | "collision" }> {
|
|
715
|
-
await ensureVault(root);
|
|
716
|
-
const trimmed = folderName.trim().replace(/^[\/\\]+|[\/\\]+$/g, "");
|
|
717
|
-
if (trimmed.length === 0) return { ok: false, reason: "invalid-name" };
|
|
718
|
-
const segments = trimmed.split(/[\/\\]+/).map(slugify).filter((s) => s.length > 0);
|
|
719
|
-
if (segments.length === 0) return { ok: false, reason: "invalid-name" };
|
|
720
|
-
const relPath = segments.join("/");
|
|
721
|
-
const notesDir = join(root, NOTES_DIR);
|
|
722
|
-
const fullPath = join(notesDir, relPath);
|
|
723
|
-
const rel = relative(notesDir, fullPath);
|
|
724
|
-
if (rel.startsWith("..") || isAbsolute(rel) || rel.length === 0) {
|
|
725
|
-
return { ok: false, reason: "invalid-name" };
|
|
726
|
-
}
|
|
727
|
-
try {
|
|
728
|
-
const stat = await fs.stat(fullPath);
|
|
729
|
-
if (!stat.isDirectory()) return { ok: false, reason: "collision" };
|
|
730
|
-
return { ok: true, path: relPath };
|
|
731
|
-
} catch {
|
|
732
|
-
// does not exist yet
|
|
733
|
-
}
|
|
734
|
-
await fs.mkdir(fullPath, { recursive: true });
|
|
735
|
-
return { ok: true, path: relPath };
|
|
736
|
-
}
|
|
737
|
-
|
|
738
|
-
/**
|
|
739
|
-
* Delete a folder under <vault>/notes/.
|
|
740
|
-
* Deletes the directory and any notes/subdirectories within it.
|
|
741
|
-
*/
|
|
742
|
-
export async function deleteFolder(
|
|
743
|
-
root: string,
|
|
744
|
-
folderName: string,
|
|
745
|
-
): Promise<{ ok: true } | { ok: false; reason: "invalid-name" | "missing" }> {
|
|
746
|
-
const trimmed = folderName.trim().replace(/^[\/\\]+|[\/\\]+$/g, "");
|
|
747
|
-
if (trimmed.length === 0) return { ok: false, reason: "invalid-name" };
|
|
748
|
-
const segments = trimmed.split(/[\/\\]+/).map(slugify).filter((s) => s.length > 0);
|
|
749
|
-
if (segments.length === 0) return { ok: false, reason: "invalid-name" };
|
|
750
|
-
const relPath = segments.join("/");
|
|
751
|
-
const notesDir = join(root, NOTES_DIR);
|
|
752
|
-
const fullPath = join(notesDir, relPath);
|
|
753
|
-
const rel = relative(notesDir, fullPath);
|
|
754
|
-
if (rel.startsWith("..") || isAbsolute(rel) || rel.length === 0) {
|
|
755
|
-
return { ok: false, reason: "invalid-name" };
|
|
756
|
-
}
|
|
757
|
-
try {
|
|
758
|
-
const stat = await fs.stat(fullPath);
|
|
759
|
-
if (!stat.isDirectory()) return { ok: false, reason: "invalid-name" };
|
|
760
|
-
await fs.rm(fullPath, { recursive: true, force: true });
|
|
761
|
-
return { ok: true };
|
|
762
|
-
} catch {
|
|
763
|
-
return { ok: false, reason: "missing" };
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
|
|
767
|
-
/**
|
|
768
|
-
* Rename a folder under <vault>/notes/.
|
|
769
|
-
* Moves the directory on disk and updates frontmatter #tags on all contained
|
|
770
|
-
* notes so the tag reflects the new folder name.
|
|
771
|
-
*/
|
|
772
|
-
export async function renameFolder(
|
|
773
|
-
root: string,
|
|
774
|
-
oldPath: string,
|
|
775
|
-
newPath: string,
|
|
776
|
-
now: Date = new Date(),
|
|
777
|
-
): Promise<{ ok: true; path: string } | { ok: false; reason: "missing" | "collision" | "invalid-name" }> {
|
|
778
|
-
await ensureVault(root);
|
|
779
|
-
const cleanOld = oldPath.trim().replace(/^[\/\\]+|[\/\\]+$/g, "");
|
|
780
|
-
const cleanNew = newPath.trim().replace(/^[\/\\]+|[\/\\]+$/g, "");
|
|
781
|
-
if (cleanOld.length === 0 || cleanNew.length === 0) return { ok: false, reason: "invalid-name" };
|
|
782
|
-
|
|
783
|
-
const oldSegments = cleanOld.split(/[\/\\]+/).map(slugify).filter((s) => s.length > 0);
|
|
784
|
-
const newSegments = cleanNew.split(/[\/\\]+/).map(slugify).filter((s) => s.length > 0);
|
|
785
|
-
if (oldSegments.length === 0 || newSegments.length === 0) return { ok: false, reason: "invalid-name" };
|
|
786
|
-
|
|
787
|
-
const oldRel = oldSegments.join("/");
|
|
788
|
-
const newRel = newSegments.join("/");
|
|
789
|
-
if (oldRel === newRel) return { ok: true, path: oldRel };
|
|
790
|
-
|
|
791
|
-
const notesDir = join(root, NOTES_DIR);
|
|
792
|
-
const fromDir = join(notesDir, oldRel);
|
|
793
|
-
const toDir = join(notesDir, newRel);
|
|
794
|
-
|
|
795
|
-
const relFrom = relative(notesDir, fromDir);
|
|
796
|
-
const relTo = relative(notesDir, toDir);
|
|
797
|
-
if (relFrom.startsWith("..") || isAbsolute(relFrom) || relTo.startsWith("..") || isAbsolute(relTo)) {
|
|
798
|
-
return { ok: false, reason: "invalid-name" };
|
|
799
|
-
}
|
|
800
|
-
|
|
801
|
-
try {
|
|
802
|
-
const stat = await fs.stat(fromDir);
|
|
803
|
-
if (!stat.isDirectory()) return { ok: false, reason: "missing" };
|
|
804
|
-
} catch {
|
|
805
|
-
return { ok: false, reason: "missing" };
|
|
806
|
-
}
|
|
807
|
-
|
|
808
|
-
if (await exists(toDir)) {
|
|
809
|
-
return { ok: false, reason: "collision" };
|
|
810
|
-
}
|
|
811
|
-
|
|
812
|
-
await fs.mkdir(dirname(toDir), { recursive: true });
|
|
813
|
-
await fs.rename(fromDir, toDir);
|
|
814
|
-
|
|
815
|
-
const oldLeaf = oldSegments[oldSegments.length - 1]!;
|
|
816
|
-
const newLeaf = newSegments[newSegments.length - 1]!;
|
|
817
|
-
const updateNotesInDir = async (dir: string, prefix: string) => {
|
|
818
|
-
let entries;
|
|
819
|
-
try {
|
|
820
|
-
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
821
|
-
} catch {
|
|
822
|
-
return;
|
|
823
|
-
}
|
|
824
|
-
for (const entry of entries) {
|
|
825
|
-
if (entry.isDirectory()) {
|
|
826
|
-
await updateNotesInDir(join(dir, entry.name), `${prefix}/${entry.name}`);
|
|
827
|
-
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
828
|
-
const noteSlug = `${prefix}/${entry.name.slice(0, -".md".length)}`;
|
|
829
|
-
const notePath = join(dir, entry.name);
|
|
830
|
-
const note = await getNote(root, noteSlug);
|
|
831
|
-
if (!note) continue;
|
|
832
|
-
const tags = note.tags.map((t) => (t === oldLeaf ? newLeaf : t));
|
|
833
|
-
if (!tags.includes(newLeaf)) tags.push(newLeaf);
|
|
834
|
-
const meta: NoteMeta = { ...note, tags, updated: now.toISOString() };
|
|
835
|
-
await writeNote(notePath, noteSlug, meta, note.body, note.frontMatter);
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
};
|
|
839
|
-
await updateNotesInDir(toDir, newRel);
|
|
840
|
-
|
|
841
|
-
return { ok: true, path: newRel };
|
|
842
|
-
}
|
|
843
|
-
|
|
844
|
-
// ---------------------------------------------------------------------------
|
|
845
|
-
// Generated-note upsert (weave-scan sessions; docs/session-scan.md)
|
|
846
|
-
// ---------------------------------------------------------------------------
|
|
847
|
-
|
|
848
|
-
export interface UpsertNoteInput {
|
|
849
|
-
/** Desired slug (already slug-safe); uniquified (`-2`, `-3`…) when creating. */
|
|
850
|
-
slug: string;
|
|
851
|
-
title: string;
|
|
852
|
-
body: string;
|
|
853
|
-
tags?: string[];
|
|
854
|
-
/** Defaults to `"generated"` — the safe direction for AGENTS.md rule 4. */
|
|
855
|
-
source?: NoteSource;
|
|
856
|
-
/**
|
|
857
|
-
* Extra **owned scalar** front-matter fields (e.g. `session_hash`) upserted
|
|
858
|
-
* on every write. Managed keys and syntactically unsafe keys are silently
|
|
859
|
-
* dropped: the note engine owns those, and this function will not fight it.
|
|
860
|
-
*/
|
|
861
|
-
fields?: Record<string, string>;
|
|
862
|
-
/**
|
|
863
|
-
* Content identity of the generated note, when the slug alone must not
|
|
864
|
-
* decide ownership: on the create path, a candidate slug already occupied
|
|
865
|
-
* by a note carrying a **different** identity value is skipped (the slug
|
|
866
|
-
* uniquifies to `-2`, `-3`…), while a same-identity occupant is treated as
|
|
867
|
-
* ours. Without this, two generated artifacts that derive the same slug —
|
|
868
|
-
* two sessions that began with the same first message, say — would have
|
|
869
|
-
* the second silently overwrite the first, marker keys and all.
|
|
870
|
-
*/
|
|
871
|
-
identity?: { field: string; value: string };
|
|
872
|
-
/** Injectable clock for tests. */
|
|
873
|
-
now?: Date;
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
/**
|
|
877
|
-
* Idempotently create-or-update a note, for generated knowledge that is
|
|
878
|
-
* re-derivable from a source of truth (a session transcript, a scan) and
|
|
879
|
-
* keyed by content the note carries in its front matter.
|
|
880
|
-
*
|
|
881
|
-
* - **Create** (no file at `slug`): canonical managed block plus the extra
|
|
882
|
-
* fields, body as given, `created` = `updated` = now. The slug is passed
|
|
883
|
-
* through `uniqueSlug`, so a taken slug shifts to `-2` rather than
|
|
884
|
-
* overwriting a note the caller could not see.
|
|
885
|
-
* - **Update** (file exists): replace the body and the extra fields, bump
|
|
886
|
-
* `updated`, and change nothing else — `title`, `created`, `tags`, unknown
|
|
887
|
-
* front-matter keys, and the append-only `## Raw` tail all survive, per
|
|
888
|
-
* the vault's round-trip guarantees. Title and tags are deliberately
|
|
889
|
-
* creation-time values: the human may have retitled or retagged the note,
|
|
890
|
-
* and a re-scan must not clobber that.
|
|
891
|
-
*
|
|
892
|
-
* Both paths hold the notes-**directory** lock (like `addNote`): the create
|
|
893
|
-
* path runs a check-then-create slug allocation that must be atomic, and the
|
|
894
|
-
* update path's `getNote`-then-write is the same lost-update window.
|
|
895
|
-
*/
|
|
896
|
-
export async function upsertNote(root: string, input: UpsertNoteInput): Promise<Note> {
|
|
897
|
-
await ensureVault(root);
|
|
898
|
-
return withNoteLocks([join(root, NOTES_DIR)], async () => {
|
|
899
|
-
const now = (input.now ?? new Date()).toISOString();
|
|
900
|
-
const noteAt = (slug: string) => notePath(root, slug);
|
|
901
|
-
const identity = input.identity;
|
|
902
|
-
let existing = await getNote(root, input.slug);
|
|
903
|
-
if (
|
|
904
|
-
existing !== null &&
|
|
905
|
-
identity &&
|
|
906
|
-
fieldFromFrontMatter(existing.frontMatter, identity.field) !== identity.value
|
|
907
|
-
) {
|
|
908
|
-
// The note at this slug belongs to a different identity (or to no
|
|
909
|
-
// identity at all — a human note): never update it in place. Fall
|
|
910
|
-
// through to the create path, whose guard picks the next free slug.
|
|
911
|
-
existing = null;
|
|
912
|
-
}
|
|
913
|
-
if (existing === null) {
|
|
914
|
-
const slug = uniqueSlug(input.slug, (candidate) => {
|
|
915
|
-
if (!existsSync(noteAt(candidate))) return false; // free
|
|
916
|
-
if (!identity) return true; // slug ownership is the caller's problem
|
|
917
|
-
return occupantIdentity(root, candidate, identity.field) !== identity.value;
|
|
918
|
-
});
|
|
919
|
-
const meta: NoteMeta = {
|
|
920
|
-
title: input.title,
|
|
921
|
-
created: now,
|
|
922
|
-
updated: now,
|
|
923
|
-
tags: input.tags ?? [],
|
|
924
|
-
source: input.source ?? "generated",
|
|
925
|
-
};
|
|
926
|
-
// The managed lines below are re-rendered from `meta` by `serializeNote`
|
|
927
|
-
// (replayBlock substitutes rendered values for managed keys in place);
|
|
928
|
-
// spelling them here just fixes the block's key order.
|
|
929
|
-
const fields = sanitizeUpsertFields(input.fields);
|
|
930
|
-
const frontMatter = [
|
|
931
|
-
`title: ${quoteField(meta.title)}`,
|
|
932
|
-
`created: ${meta.created}`,
|
|
933
|
-
`updated: ${meta.updated}`,
|
|
934
|
-
`tags: [${meta.tags.map(quoteField).join(", ")}]`,
|
|
935
|
-
`source: ${meta.source}`,
|
|
936
|
-
...upsertFrontMatterFields([], fields),
|
|
937
|
-
];
|
|
938
|
-
return writeNote(noteAt(slug), slug, meta, input.body, frontMatter);
|
|
939
|
-
}
|
|
940
|
-
const meta: NoteMeta = { ...existing, updated: now };
|
|
941
|
-
const fields = sanitizeUpsertFields(input.fields);
|
|
942
|
-
const frontMatter = upsertFrontMatterFields(existing.frontMatter ?? [], fields);
|
|
943
|
-
const body = preserveRawTail(existing.body, input.body);
|
|
944
|
-
return writeNote(noteAt(input.slug), input.slug, meta, body, frontMatter);
|
|
945
|
-
});
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
/**
|
|
949
|
-
* The identity value carried in a note's own front-matter block, or null when
|
|
950
|
-
* absent — an absent identity never matches, so unmarked notes are never
|
|
951
|
-
* claimed as ours.
|
|
952
|
-
*/
|
|
953
|
-
function fieldFromFrontMatter(lines: NoteFrontMatter | undefined, field: string): string | null {
|
|
954
|
-
if (!lines) return null;
|
|
955
|
-
const parsed = parseFrontMatter(["---", ...lines, "---", ""].join("\n"));
|
|
956
|
-
if (!parsed) return null;
|
|
957
|
-
return parsed.fields.get(field) ?? null;
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
/**
|
|
961
|
-
* The identity value a note at `slug` carries in its front matter, or null
|
|
962
|
-
* when the file is missing, malformed, or does not declare the field — a
|
|
963
|
-
* null never equals a real identity value, so such a file blocks the slug.
|
|
964
|
-
*/
|
|
965
|
-
function occupantIdentity(root: string, slug: string, field: string): string | null {
|
|
966
|
-
let text: string;
|
|
967
|
-
try {
|
|
968
|
-
text = readFileSync(notePath(root, slug), "utf8");
|
|
969
|
-
} catch {
|
|
970
|
-
return null;
|
|
971
|
-
}
|
|
972
|
-
const parsed = parseFrontMatter(text);
|
|
973
|
-
if (!parsed) return null;
|
|
974
|
-
return parsed.fields.get(field) ?? null;
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
/**
|
|
978
|
-
* Drop fields this function has no business writing: managed keys (the note
|
|
979
|
-
* engine renders those from `NoteMeta`) and keys that are not plain scalar
|
|
980
|
-
* identifiers (a hostile key could smuggle newlines or `---` into the block).
|
|
981
|
-
* Values are guarded by `quoteField` at render time.
|
|
982
|
-
*/
|
|
983
|
-
function sanitizeUpsertFields(fields: Record<string, string> | undefined): Record<string, string> {
|
|
984
|
-
if (!fields) return {};
|
|
985
|
-
const managed = new Set<string>(MANAGED_FRONT_MATTER_KEYS);
|
|
986
|
-
const out: Record<string, string> = {};
|
|
987
|
-
for (const [key, value] of Object.entries(fields)) {
|
|
988
|
-
if (managed.has(key)) continue;
|
|
989
|
-
if (!/^[A-Za-z][A-Za-z0-9_-]*$/.test(key)) continue;
|
|
990
|
-
out[key] = value;
|
|
991
|
-
}
|
|
992
|
-
return out;
|
|
993
|
-
}
|
|
994
|
-
|
|
995
348
|
async function listNoteFiles(root: string): Promise<string[]> {
|
|
996
349
|
const dir = join(root, NOTES_DIR);
|
|
997
350
|
const out: string[] = [];
|
|
998
|
-
// Recursive by design: vault notes may nest (`sessions/<name>` — session
|
|
999
|
-
// memory lives in an inner folder of the graph, docs/session-scan.md), and
|
|
1000
|
-
// every consumer above this function (list, search, graph, cache) speaks
|
|
1001
|
-
// in slugs, so the relative path *is* the slug.
|
|
1002
351
|
async function walk(prefix: string): Promise<void> {
|
|
1003
352
|
let entries;
|
|
1004
353
|
try {
|
|
1005
354
|
entries = await fs.readdir(prefix.length > 0 ? join(dir, prefix) : dir, { withFileTypes: true });
|
|
1006
355
|
} catch {
|
|
1007
|
-
return;
|
|
356
|
+
return;
|
|
1008
357
|
}
|
|
1009
358
|
for (const entry of entries) {
|
|
1010
359
|
if (entry.isDirectory()) {
|
|
1011
360
|
await walk(prefix.length > 0 ? `${prefix}/${entry.name}` : entry.name);
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
1015
|
-
const slug = prefix.length > 0 ? `${prefix}/${entry.name}` : entry.name;
|
|
1016
|
-
out.push(slug);
|
|
361
|
+
} else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
362
|
+
out.push(prefix.length > 0 ? `${prefix}/${entry.name}` : entry.name);
|
|
1017
363
|
}
|
|
1018
364
|
}
|
|
1019
365
|
}
|
|
@@ -1021,17 +367,6 @@ async function listNoteFiles(root: string): Promise<string[]> {
|
|
|
1021
367
|
return out.sort();
|
|
1022
368
|
}
|
|
1023
369
|
|
|
1024
|
-
/**
|
|
1025
|
-
* Derive the list-shaped summary of a note (drops the body, keeps its length).
|
|
1026
|
-
*
|
|
1027
|
-
* `frontMatter` is dropped along with the body, and explicitly rather than by
|
|
1028
|
-
* omission: a spread is exempt from TypeScript's excess-property check, so
|
|
1029
|
-
* carrying it would type-check fine and then ship every note's raw metadata
|
|
1030
|
-
* block through `listNotes` into the search results and the wire payload —
|
|
1031
|
-
* a field no consumer reads, on a shape the contract test pins.
|
|
1032
|
-
* Preservation is a property of the *write* path re-reading the file, not of
|
|
1033
|
-
* summaries carrying it around.
|
|
1034
|
-
*/
|
|
1035
370
|
export function summarizeNote(note: Note): NoteSummary {
|
|
1036
371
|
const { body, frontMatter, ...rest } = note;
|
|
1037
372
|
void frontMatter;
|