pi-weave 0.1.7 → 0.1.8
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 +114 -34
- package/package.json +16 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/frontmatter.ts +217 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +37 -4
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +24 -0
- package/src/core/index.ts +12 -0
- package/src/core/openInEditor.ts +69 -0
- package/src/core/types.ts +40 -0
- package/src/core/vault.ts +477 -43
- package/src/core/view/cluster.ts +262 -0
- package/src/core/view/detail.ts +118 -0
- package/src/core/view/focus.ts +109 -0
- package/src/core/view/health.ts +156 -0
- package/src/core/view/index.ts +15 -0
- package/src/core/view/links.ts +105 -0
- package/src/core/view/time.ts +47 -0
- package/src/core/view/tree.ts +269 -0
- package/src/core/view/types.ts +39 -0
- package/src/pi/index.ts +104 -11
- package/src/pi/viewer/tui/explorer.ts +4 -2
- package/src/pi/viewer/tui/model.ts +46 -667
- package/src/pi/viewer/tui/openNote.ts +7 -56
- package/src/pi/viewer/tui/surface/explore.ts +4 -2
- package/src/pi/viewer/web/run.ts +331 -0
- package/src/web/client/api.dom.ts +40 -0
- package/src/web/client/api.ts +472 -0
- package/src/web/client/bootstrap.ts +58 -0
- package/src/web/client/context/context.model.ts +313 -0
- package/src/web/client/dist/app.js +751 -0
- package/src/web/client/graph/Graph.tsx +158 -0
- package/src/web/client/graph/column.model.ts +431 -0
- package/src/web/client/graph/graph.model.ts +538 -0
- package/src/web/client/graph/positions.ts +339 -0
- package/src/web/client/graph/project.ts +153 -0
- package/src/web/client/graph/renderer.dom.ts +52 -0
- package/src/web/client/graph/renderer.ts +279 -0
- package/src/web/client/graph/scheme.ts +44 -0
- package/src/web/client/live.model.ts +275 -0
- package/src/web/client/live.ts +151 -0
- package/src/web/client/main.tsx +27 -0
- package/src/web/client/note/Editor.tsx +102 -0
- package/src/web/client/note/Note.tsx +113 -0
- package/src/web/client/note/editor.controller.ts +151 -0
- package/src/web/client/note/editor.model.ts +636 -0
- package/src/web/client/note/note.model.ts +738 -0
- package/src/web/client/search/SearchPalette.tsx +105 -0
- package/src/web/client/search/search.model.ts +588 -0
- package/src/web/client/search/search.ts +107 -0
- package/src/web/client/shell/Columns.tsx +161 -0
- package/src/web/client/shell/ContextRail.tsx +87 -0
- package/src/web/client/shell/Divider.tsx +44 -0
- package/src/web/client/shell/FocusTrap.tsx +56 -0
- package/src/web/client/shell/Header.tsx +54 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +193 -0
- package/src/web/client/shell/StatusBar.tsx +28 -0
- package/src/web/client/shell/cssvars.ts +70 -0
- package/src/web/client/shell/drag.model.ts +170 -0
- package/src/web/client/shell/focus.model.ts +100 -0
- package/src/web/client/shell/keys.model.ts +453 -0
- package/src/web/client/shell/keys.ts +59 -0
- package/src/web/client/shell/layout.model.ts +526 -0
- package/src/web/client/shell/shell.model.ts +333 -0
- package/src/web/client/shell/theme.ts +477 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +78 -0
- package/src/web/client/tree/Tree.tsx +138 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +214 -0
- package/src/web/server/page.ts +256 -0
- package/src/web/server/routes.ts +975 -0
- package/src/web/server/security.ts +361 -0
- package/src/web/server/server.ts +275 -0
- package/src/web/server/sse.ts +321 -0
- package/src/web/server/watcher.ts +507 -0
- package/src/web/shared/graph.ts +206 -0
- package/src/web/shared/layout.ts +497 -0
- package/src/web/shared/metrics.ts +136 -0
- package/src/web/shared/view.ts +200 -0
- package/src/web/shared/wire.ts +358 -0
package/src/core/vault.ts
CHANGED
|
@@ -2,9 +2,17 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { promises as fs } from "node:fs";
|
|
3
3
|
import { isAbsolute, join, relative, sep } from "node:path";
|
|
4
4
|
import { parseNoteFile, serializeNote } from "./frontmatter";
|
|
5
|
+
import { withMutationQueue } from "./mutex";
|
|
5
6
|
import { NOTES_DIR, OKF_MANIFEST } from "./paths";
|
|
6
7
|
import { slugify, uniqueSlug } from "./slug";
|
|
7
|
-
import type {
|
|
8
|
+
import type {
|
|
9
|
+
Note,
|
|
10
|
+
NoteFrontMatter,
|
|
11
|
+
NoteMeta,
|
|
12
|
+
NoteSearchHit,
|
|
13
|
+
NoteSource,
|
|
14
|
+
NoteSummary,
|
|
15
|
+
} from "./types";
|
|
8
16
|
|
|
9
17
|
/**
|
|
10
18
|
* The vault: the "smart notepad" half of pi-weave.
|
|
@@ -70,23 +78,34 @@ export function resolveNotePath(root: string, slug: string): string | null {
|
|
|
70
78
|
return candidate;
|
|
71
79
|
}
|
|
72
80
|
|
|
73
|
-
/**
|
|
81
|
+
/**
|
|
82
|
+
* Create a note. Returns the written note (with its final, unique slug).
|
|
83
|
+
*
|
|
84
|
+
* Serialized on the notes *directory* rather than on a note path, because
|
|
85
|
+
* what needs protecting is the slug allocation, and the slug is not known
|
|
86
|
+
* until it has been chosen. `uniqueSlug` is a check-then-create: two
|
|
87
|
+
* concurrent `addNote("Decision")` calls could both observe `decision.md`
|
|
88
|
+
* as free and the second would overwrite the first. Holding the directory
|
|
89
|
+
* makes choosing-and-writing a single step.
|
|
90
|
+
*/
|
|
74
91
|
export async function addNote(root: string, input: AddNoteInput): Promise<Note> {
|
|
75
92
|
await ensureVault(root);
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
93
|
+
return withNoteLocks([join(root, NOTES_DIR)], async () => {
|
|
94
|
+
const now = (input.now ?? new Date()).toISOString();
|
|
95
|
+
const base = slugify(input.title);
|
|
96
|
+
const slug = uniqueSlug(base, (candidate) => existsSync(notePath(root, candidate)));
|
|
97
|
+
|
|
98
|
+
const meta: NoteMeta = {
|
|
99
|
+
title: input.title,
|
|
100
|
+
created: now,
|
|
101
|
+
updated: now,
|
|
102
|
+
tags: input.tags ?? [],
|
|
103
|
+
source: input.source ?? "agent",
|
|
104
|
+
};
|
|
105
|
+
// No `frontMatter`: a brand-new note has no prior layout to respect, so
|
|
106
|
+
// the serializer writes its canonical block.
|
|
107
|
+
return writeNote(notePath(root, slug), slug, meta, input.body, undefined);
|
|
108
|
+
});
|
|
90
109
|
}
|
|
91
110
|
|
|
92
111
|
/** Read a note by slug. Returns null when missing, malformed, or an unsafe slug. */
|
|
@@ -100,13 +119,80 @@ export async function getNote(root: string, slug: string): Promise<Note | null>
|
|
|
100
119
|
return null;
|
|
101
120
|
}
|
|
102
121
|
try {
|
|
103
|
-
const { meta, body } = parseNoteFile(text);
|
|
104
|
-
return { slug, ...meta, body };
|
|
122
|
+
const { meta, body, frontMatter } = parseNoteFile(text);
|
|
123
|
+
return { slug, ...meta, body, frontMatter };
|
|
105
124
|
} catch {
|
|
106
125
|
return null;
|
|
107
126
|
}
|
|
108
127
|
}
|
|
109
128
|
|
|
129
|
+
/**
|
|
130
|
+
* Write a note file and return the note as it now exists on disk.
|
|
131
|
+
*
|
|
132
|
+
* Every mutation goes through here, for two reasons. It is the one place
|
|
133
|
+
* that threads `frontMatter` into `serializeNote`, so no write path can
|
|
134
|
+
* forget to and quietly resume deleting the user's unknown properties. And
|
|
135
|
+
* it re-parses what was written rather than returning the in-memory `meta`,
|
|
136
|
+
* so the returned note is what a subsequent `getNote` would see — which
|
|
137
|
+
* matters because the serializer legitimately declines some changes (a
|
|
138
|
+
* `tags:` block list is frozen, see `frontmatter.ts`). Reporting the intent
|
|
139
|
+
* instead of the result would make that divergence invisible to the caller.
|
|
140
|
+
*/
|
|
141
|
+
async function writeNote(
|
|
142
|
+
path: string,
|
|
143
|
+
slug: string,
|
|
144
|
+
meta: NoteMeta,
|
|
145
|
+
body: string,
|
|
146
|
+
frontMatter: NoteFrontMatter | undefined,
|
|
147
|
+
): Promise<Note> {
|
|
148
|
+
const text = serializeNote(meta, body, frontMatter);
|
|
149
|
+
await fs.writeFile(path, text, "utf8");
|
|
150
|
+
const parsed = parseNoteFile(text);
|
|
151
|
+
return { slug, ...parsed.meta, body: parsed.body, frontMatter: parsed.frontMatter };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Namespace prefix for this module's mutation-queue keys.
|
|
156
|
+
*
|
|
157
|
+
* `withMutationQueue` is a **non-reentrant** keyed queue, so a task that
|
|
158
|
+
* takes a key already held by an ancestor on the same call stack waits for
|
|
159
|
+
* itself. That is not hypothetical: `src/pi/tools/noteTool.ts` wraps
|
|
160
|
+
* `addNote`/`appendToNote`/`finalizeNote` in the queue keyed by the bare
|
|
161
|
+
* note path, from the days when locking lived in the adapter. Locking on
|
|
162
|
+
* the bare path in here too would deadlock every one of those tool calls.
|
|
163
|
+
*
|
|
164
|
+
* Prefixing gives core its own key space, so an outer lock held by any
|
|
165
|
+
* adapter is a coarser, harmless layer rather than a hang. The adapter's
|
|
166
|
+
* wrapper is now redundant — locking belongs in core per AGENTS.md rule 3,
|
|
167
|
+
* and it should be removed from the adapter in a change that owns that file
|
|
168
|
+
* — but redundant is a state the system can be in safely, and deadlocked is
|
|
169
|
+
* not.
|
|
170
|
+
*/
|
|
171
|
+
const LOCK_NS = "vault:note:";
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Run `task` with exclusive access to every given note path.
|
|
175
|
+
*
|
|
176
|
+
* Paths are locked in a fixed (sorted) order, which is what makes the
|
|
177
|
+
* two-path case — `renameNote`, the only operation touching two files —
|
|
178
|
+
* deadlock-free. Two concurrent renames in opposite directions (`a→b` and
|
|
179
|
+
* `b→a`) would otherwise be able to take one lock each and wait forever;
|
|
180
|
+
* with a total order on acquisition, one of them takes both and the other
|
|
181
|
+
* takes neither.
|
|
182
|
+
*
|
|
183
|
+
* Every mutation in this module goes through here, not just the new ones. A
|
|
184
|
+
* queue that half the writers ignore serializes nothing: an `appendToNote`
|
|
185
|
+
* racing an `updateNote` on the same file is a lost update whether or not
|
|
186
|
+
* the update took a lock.
|
|
187
|
+
*/
|
|
188
|
+
function withNoteLocks<T>(paths: readonly string[], task: () => Promise<T>): Promise<T> {
|
|
189
|
+
const ordered = [...new Set(paths)].sort();
|
|
190
|
+
return ordered.reduceRight<() => Promise<T>>(
|
|
191
|
+
(inner, path) => () => withMutationQueue(LOCK_NS + path, inner),
|
|
192
|
+
task,
|
|
193
|
+
)();
|
|
194
|
+
}
|
|
195
|
+
|
|
110
196
|
/** Append Markdown to an existing note and bump `updated`. */
|
|
111
197
|
export async function appendToNote(
|
|
112
198
|
root: string,
|
|
@@ -116,12 +202,12 @@ export async function appendToNote(
|
|
|
116
202
|
): Promise<Note | null> {
|
|
117
203
|
const path = resolveNotePath(root, slug);
|
|
118
204
|
if (!path) return null;
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
205
|
+
return withNoteLocks([path], async () => {
|
|
206
|
+
const note = await getNote(root, slug);
|
|
207
|
+
if (!note) return null;
|
|
208
|
+
const body = note.body.replace(/\s+$/, "") + "\n\n" + addition.trim() + "\n";
|
|
209
|
+
return writeNote(path, slug, { ...note, updated: now.toISOString() }, body, note.frontMatter);
|
|
210
|
+
});
|
|
125
211
|
}
|
|
126
212
|
|
|
127
213
|
/** Format a verbatim user scribble as an append-only raw block with a timestamp. */
|
|
@@ -183,13 +269,282 @@ export async function finalizeNote(
|
|
|
183
269
|
): Promise<Note | null> {
|
|
184
270
|
const path = resolveNotePath(root, slug);
|
|
185
271
|
if (!path) return null;
|
|
272
|
+
return withNoteLocks([path], async () => {
|
|
273
|
+
const note = await getNote(root, slug);
|
|
274
|
+
if (!note) return null;
|
|
275
|
+
const rawTail = extractRawTail(note.body);
|
|
276
|
+
const body = input.body.trim() + (rawTail ? `\n\n${rawTail}` : "");
|
|
277
|
+
const meta: NoteMeta = { ...note, updated: (input.now ?? new Date()).toISOString() };
|
|
278
|
+
return writeNote(path, slug, meta, body, note.frontMatter);
|
|
279
|
+
});
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Mutation APIs (weave-workspace §11 P5.2, P5.3)
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* A note plus the stamp that identifies the on-disk state it was read from.
|
|
288
|
+
*
|
|
289
|
+
* The read half of the conflict primitive (§11 P5.3). An editor reads this,
|
|
290
|
+
* holds `revision` for as long as the user is typing, and hands it back on
|
|
291
|
+
* save; a `revision` that no longer matches the file means someone else — a
|
|
292
|
+
* `weave_note` tool call, `$EDITOR`, an Obsidian sync — wrote in between.
|
|
293
|
+
*/
|
|
294
|
+
export interface RevisionedNote {
|
|
295
|
+
note: Note;
|
|
296
|
+
/**
|
|
297
|
+
* Opaque version stamp. **Compare it, do not interpret it.**
|
|
298
|
+
*
|
|
299
|
+
* It is currently `mtimeMs:size`, and the shape is deliberately not part
|
|
300
|
+
* of the contract: a caller that parses out the mtime is a caller that
|
|
301
|
+
* breaks when this becomes a content hash. Two fields rather than one
|
|
302
|
+
* because mtime alone has a real blind spot — the filesystem timestamp
|
|
303
|
+
* granularity. Two writes inside the same millisecond are indistinguishable
|
|
304
|
+
* by mtime, and "same millisecond" is not exotic when the writer is a
|
|
305
|
+
* program rather than a human; a size change catches the common case of
|
|
306
|
+
* such a pair differing in length.
|
|
307
|
+
*
|
|
308
|
+
* This does not make it a perfect detector. A same-millisecond write that
|
|
309
|
+
* preserves the byte count is invisible, which is the honest limitation of
|
|
310
|
+
* any stat-based scheme, and the reason this is typed as opaque: upgrading
|
|
311
|
+
* it to a digest is then a change here and nowhere else.
|
|
312
|
+
*/
|
|
313
|
+
revision: string;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function revisionOf(st: { mtimeMs: number; size: number }): string {
|
|
317
|
+
return `${st.mtimeMs}:${st.size}`;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Read a note together with its {@link RevisionedNote.revision}.
|
|
322
|
+
*
|
|
323
|
+
* Stat-then-read rather than read-then-stat: if a writer lands between the
|
|
324
|
+
* two calls, the revision is of the *older* state than the content, so the
|
|
325
|
+
* save that follows sees a mismatch and is rejected. The opposite order
|
|
326
|
+
* yields a revision newer than the content, which would let a stale body be
|
|
327
|
+
* written back under a revision that looks current — failing safe versus
|
|
328
|
+
* failing silently.
|
|
329
|
+
*/
|
|
330
|
+
export async function getNoteWithRevision(root: string, slug: string): Promise<RevisionedNote | null> {
|
|
331
|
+
const path = resolveNotePath(root, slug);
|
|
332
|
+
if (!path) return null;
|
|
333
|
+
let revision: string;
|
|
334
|
+
try {
|
|
335
|
+
revision = revisionOf(await fs.stat(path));
|
|
336
|
+
} catch {
|
|
337
|
+
return null;
|
|
338
|
+
}
|
|
186
339
|
const note = await getNote(root, slug);
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
340
|
+
return note === null ? null : { note, revision };
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Why a mutation did not happen. The server maps these to status codes —
|
|
345
|
+
* `"missing"` → 404, `"conflict"` → 409, `"collision"` → 409 — but the
|
|
346
|
+
* mapping is the server's business; core reports the *situation* (§11 P5.3).
|
|
347
|
+
*/
|
|
348
|
+
export type MutationFailure =
|
|
349
|
+
/** No such note, or a slug that failed the traversal guard. */
|
|
350
|
+
| { ok: false; reason: "missing" }
|
|
351
|
+
/** The file moved since `expectedRevision` was read. */
|
|
352
|
+
| { ok: false; reason: "conflict"; current: RevisionedNote }
|
|
353
|
+
/** A rename whose destination slug is already taken. */
|
|
354
|
+
| { ok: false; reason: "collision"; slug: string };
|
|
355
|
+
|
|
356
|
+
export type MutationResult = { ok: true; note: Note } | MutationFailure;
|
|
357
|
+
|
|
358
|
+
/** A successful delete, or why it did not happen. */
|
|
359
|
+
export type DeleteResult = { ok: true } | { ok: false; reason: "missing" };
|
|
360
|
+
|
|
361
|
+
export interface UpdateNoteInput {
|
|
362
|
+
/** Replacement Markdown body. Omit to change only metadata. */
|
|
363
|
+
body?: string;
|
|
364
|
+
/**
|
|
365
|
+
* Metadata to merge over the note's current values. `created` is not
|
|
366
|
+
* accepted: it records when the note came into existence, and an edit is
|
|
367
|
+
* not a re-creation.
|
|
368
|
+
*/
|
|
369
|
+
meta?: Partial<Pick<NoteMeta, "title" | "tags" | "source">>;
|
|
370
|
+
/**
|
|
371
|
+
* The {@link RevisionedNote.revision} the caller last read. When supplied
|
|
372
|
+
* and no longer current, the write is refused with `reason: "conflict"`
|
|
373
|
+
* and the caller is handed the note as it now is, so a UI can offer
|
|
374
|
+
* reload-or-overwrite without a second round trip. Omit for
|
|
375
|
+
* last-write-wins.
|
|
376
|
+
*/
|
|
377
|
+
expectedRevision?: string;
|
|
378
|
+
/** Injectable clock for tests. */
|
|
379
|
+
now?: Date;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Check `expectedRevision` against the file, inside the caller's lock.
|
|
384
|
+
*
|
|
385
|
+
* Returns the conflict to report, or null to proceed. Being inside the lock
|
|
386
|
+
* is the whole point: a check-then-write with the check outside the mutex is
|
|
387
|
+
* a race with a wider window than no check at all, because it looks like it
|
|
388
|
+
* is doing something.
|
|
389
|
+
*/
|
|
390
|
+
async function checkRevision(
|
|
391
|
+
root: string,
|
|
392
|
+
slug: string,
|
|
393
|
+
expected: string | undefined,
|
|
394
|
+
): Promise<MutationFailure | null> {
|
|
395
|
+
if (expected === undefined) return null;
|
|
396
|
+
const current = await getNoteWithRevision(root, slug);
|
|
397
|
+
if (current === null) return { ok: false, reason: "missing" };
|
|
398
|
+
return current.revision === expected ? null : { ok: false, reason: "conflict", current };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Update a note's body and/or metadata in place.
|
|
403
|
+
*
|
|
404
|
+
* Preserves unknown front-matter keys (they ride on `note.frontMatter`
|
|
405
|
+
* through `writeNote`) and, when `body` is given, the append-only `## Raw`
|
|
406
|
+
* tail: the replacement body is treated as the *editorial* region above the
|
|
407
|
+
* tail, exactly as `finalizeNote` treats it. A caller replacing the body of
|
|
408
|
+
* a dictated note therefore cannot delete the user's verbatim scribbles by
|
|
409
|
+
* omitting them, which is the one thing `docs/notepad.md` §4 says must never
|
|
410
|
+
* happen. A body that already carries its own `## Raw` tail is written as
|
|
411
|
+
* given, so a round-trip through an editor that shows the whole file is not
|
|
412
|
+
* penalised with a duplicated tail.
|
|
413
|
+
*
|
|
414
|
+
* `updated` is bumped on every successful call, including a metadata-only
|
|
415
|
+
* one — a tag change is a change to the note.
|
|
416
|
+
*/
|
|
417
|
+
export async function updateNote(
|
|
418
|
+
root: string,
|
|
419
|
+
slug: string,
|
|
420
|
+
input: UpdateNoteInput,
|
|
421
|
+
now: Date = new Date(),
|
|
422
|
+
): Promise<MutationResult> {
|
|
423
|
+
const path = resolveNotePath(root, slug);
|
|
424
|
+
if (!path) return { ok: false, reason: "missing" };
|
|
425
|
+
return withNoteLocks([path], async () => {
|
|
426
|
+
const conflict = await checkRevision(root, slug, input.expectedRevision);
|
|
427
|
+
if (conflict) return conflict;
|
|
428
|
+
const note = await getNote(root, slug);
|
|
429
|
+
if (!note) return { ok: false, reason: "missing" };
|
|
430
|
+
|
|
431
|
+
const body = input.body === undefined ? note.body : preserveRawTail(note.body, input.body);
|
|
432
|
+
const meta: NoteMeta = {
|
|
433
|
+
...note,
|
|
434
|
+
...input.meta,
|
|
435
|
+
updated: (input.now ?? now).toISOString(),
|
|
436
|
+
};
|
|
437
|
+
return { ok: true, note: await writeNote(path, slug, meta, body, note.frontMatter) };
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** Re-attach the existing `## Raw` tail unless the replacement already has one. */
|
|
442
|
+
function preserveRawTail(currentBody: string, nextBody: string): string {
|
|
443
|
+
const tail = extractRawTail(currentBody);
|
|
444
|
+
if (tail === "" || extractRawTail(nextBody) !== "") return nextBody.trim();
|
|
445
|
+
return nextBody.trim() + `\n\n${tail}`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Rename a note: move `oldSlug.md` to `newSlug.md`.
|
|
450
|
+
*
|
|
451
|
+
* `newSlug` is passed through `slugify`, so a caller may hand over either a
|
|
452
|
+
* slug or a human title and get the same filesystem-safe result the rest of
|
|
453
|
+
* the vault uses.
|
|
454
|
+
*
|
|
455
|
+
* ## Inbound `[[wikilinks]]` are deliberately NOT rewritten
|
|
456
|
+
*
|
|
457
|
+
* A rename can break links from other notes, and there are two honest
|
|
458
|
+
* options. Rewriting every referring note is the bigger hammer, and it is
|
|
459
|
+
* the wrong one here:
|
|
460
|
+
*
|
|
461
|
+
* - **It is a multi-file write with no transaction.** Renaming one note
|
|
462
|
+
* would rewrite N others; a failure partway leaves the vault half-updated,
|
|
463
|
+
* and there is no rollback. Trading one dangling link for an unknown
|
|
464
|
+
* number of half-edited files is a bad trade.
|
|
465
|
+
* - **It edits prose to fix an index.** A wikilink lives in body text a
|
|
466
|
+
* human wrote, sometimes inside a quote, a code fence, or a `## Raw` tail
|
|
467
|
+
* that `docs/notepad.md` declares append-only and verbatim. A textual
|
|
468
|
+
* substitution across the vault cannot honour that; a rename would become
|
|
469
|
+
* the one operation allowed to modify preserved user input.
|
|
470
|
+
* - **The alternative is already visible, not silent.** Dangling targets are
|
|
471
|
+
* a first-class concept: `buildGraph` collects `danglingLinks`, the wire
|
|
472
|
+
* payload ships them as `dangling`, and the note column renders an
|
|
473
|
+
* unresolved wikilink as an unfollowable ghost. A stale link therefore
|
|
474
|
+
* shows up in the UI as something to fix, which is a better failure than a
|
|
475
|
+
* silent bulk edit the user cannot review.
|
|
476
|
+
*
|
|
477
|
+
* So: renaming leaves inbound links pointing at the old slug, where they
|
|
478
|
+
* render as dangling. If link-following-a-rename is wanted later, the right
|
|
479
|
+
* shape is an explicit, previewable "update N referring notes?" step — a
|
|
480
|
+
* separate operation the user opts into, not a side effect of this one.
|
|
481
|
+
*/
|
|
482
|
+
export async function renameNote(
|
|
483
|
+
root: string,
|
|
484
|
+
oldSlug: string,
|
|
485
|
+
newSlug: string,
|
|
486
|
+
now: Date = new Date(),
|
|
487
|
+
): Promise<MutationResult> {
|
|
488
|
+
const from = resolveNotePath(root, oldSlug);
|
|
489
|
+
if (!from) return { ok: false, reason: "missing" };
|
|
490
|
+
const target = slugify(newSlug);
|
|
491
|
+
const to = resolveNotePath(root, target);
|
|
492
|
+
// `slugify` cannot emit a traversing slug, but the guard is applied anyway:
|
|
493
|
+
// "this input is already safe" is exactly the assumption that stops being
|
|
494
|
+
// true when someone changes the other function.
|
|
495
|
+
if (!to) return { ok: false, reason: "missing" };
|
|
496
|
+
if (target === oldSlug) {
|
|
497
|
+
const note = await getNote(root, oldSlug);
|
|
498
|
+
return note === null ? { ok: false, reason: "missing" } : { ok: true, note };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
return withNoteLocks([from, to], async () => {
|
|
502
|
+
const note = await getNote(root, oldSlug);
|
|
503
|
+
if (!note) return { ok: false, reason: "missing" };
|
|
504
|
+
// Refuse rather than uniquify: `addNote` may silently pick `decision-2`
|
|
505
|
+
// because nobody named a file there, but a rename onto an existing note
|
|
506
|
+
// is a user mistake, and quietly landing somewhere other than where they
|
|
507
|
+
// asked hides it. `fs.rename` would overwrite the destination outright.
|
|
508
|
+
if (await exists(to)) return { ok: false, reason: "collision", slug: target };
|
|
509
|
+
|
|
510
|
+
await fs.rename(from, to);
|
|
511
|
+
// The slug is the note's identity, so a rename is a change to the note.
|
|
512
|
+
const meta: NoteMeta = { ...note, updated: now.toISOString() };
|
|
513
|
+
return { ok: true, note: await writeNote(to, target, meta, note.body, note.frontMatter) };
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Delete a note. **Hard delete** — the file is unlinked.
|
|
519
|
+
*
|
|
520
|
+
* No trash directory, and that is a deliberate omission rather than an
|
|
521
|
+
* oversight. A trash is a real feature: it needs a location that does not
|
|
522
|
+
* pollute `notes/` (everything there is indexed and graphed), a retention
|
|
523
|
+
* policy, a restore path, and an answer for what happens when a deleted slug
|
|
524
|
+
* is later reused. Inventing all of that as a side effect of "P5 needs a
|
|
525
|
+
* delete button" is how a vault grows a second, undocumented store of notes
|
|
526
|
+
* that the index does not know about — and AGENTS.md rule 5 says nothing in
|
|
527
|
+
* `.okf` may be the only copy of anything, which cuts both ways: a
|
|
528
|
+
* half-designed trash becomes exactly such a place.
|
|
529
|
+
*
|
|
530
|
+
* The vault is plain files in a directory most users keep under version
|
|
531
|
+
* control or a synced folder, so the recovery story is the one they already
|
|
532
|
+
* have and understand. If a trash is wanted, it should arrive as its own
|
|
533
|
+
* design decision with those questions answered.
|
|
534
|
+
*/
|
|
535
|
+
export async function deleteNote(root: string, slug: string): Promise<DeleteResult> {
|
|
536
|
+
const path = resolveNotePath(root, slug);
|
|
537
|
+
if (!path) return { ok: false, reason: "missing" };
|
|
538
|
+
return withNoteLocks([path], async () => {
|
|
539
|
+
try {
|
|
540
|
+
await fs.unlink(path);
|
|
541
|
+
return { ok: true };
|
|
542
|
+
} catch {
|
|
543
|
+
// Already gone, or never existed. Both are "there is no such note",
|
|
544
|
+
// which is the caller's question — not "the unlink syscall failed".
|
|
545
|
+
return { ok: false, reason: "missing" };
|
|
546
|
+
}
|
|
547
|
+
});
|
|
193
548
|
}
|
|
194
549
|
|
|
195
550
|
async function listNoteFiles(root: string): Promise<string[]> {
|
|
@@ -203,18 +558,99 @@ async function listNoteFiles(root: string): Promise<string[]> {
|
|
|
203
558
|
return entries.filter((name) => name.endsWith(".md")).sort();
|
|
204
559
|
}
|
|
205
560
|
|
|
206
|
-
/**
|
|
207
|
-
|
|
561
|
+
/**
|
|
562
|
+
* Derive the list-shaped summary of a note (drops the body, keeps its length).
|
|
563
|
+
*
|
|
564
|
+
* `frontMatter` is dropped along with the body, and explicitly rather than by
|
|
565
|
+
* omission: a spread is exempt from TypeScript's excess-property check, so
|
|
566
|
+
* carrying it would type-check fine and then ship every note's raw metadata
|
|
567
|
+
* block through `listNotes` into the search results and the wire payload —
|
|
568
|
+
* a field no consumer reads, on a shape the contract test pins.
|
|
569
|
+
* Preservation is a property of the *write* path re-reading the file, not of
|
|
570
|
+
* summaries carrying it around.
|
|
571
|
+
*/
|
|
572
|
+
export function summarizeNote(note: Note): NoteSummary {
|
|
573
|
+
const { body, frontMatter, ...rest } = note;
|
|
574
|
+
void frontMatter;
|
|
575
|
+
return { ...rest, bodyLength: body.length };
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Newest-updated first. Ties fall back to slug ascending: the input arrives
|
|
580
|
+
* in readdir-sorted (slug) order and `Array.prototype.sort` is stable, so
|
|
581
|
+
* equal timestamps keep that order.
|
|
582
|
+
*/
|
|
583
|
+
function byUpdatedDesc(a: { updated: string }, b: { updated: string }): number {
|
|
584
|
+
return b.updated.localeCompare(a.updated);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/**
|
|
588
|
+
* Everything one pass over the vault can tell you: every readable note with
|
|
589
|
+
* its body, plus how many `.md` files exist.
|
|
590
|
+
*
|
|
591
|
+
* Callers that need both the note list *and* the bodies (the graph builder,
|
|
592
|
+
* search) must use this instead of `listNotes` + `getNote` per slug — that
|
|
593
|
+
* pattern reads and parses every file twice (weave-workspace §4.1).
|
|
594
|
+
*/
|
|
595
|
+
export interface VaultSnapshot {
|
|
596
|
+
/** Readable, parseable notes, newest-updated first. */
|
|
597
|
+
notes: Note[];
|
|
598
|
+
/**
|
|
599
|
+
* Number of `*.md` files present, *including* ones too malformed to parse.
|
|
600
|
+
* `notes.length` can be smaller; this is the honest on-disk count.
|
|
601
|
+
*/
|
|
602
|
+
fileCount: number;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** Read the whole vault in one pass: one readdir, one read per note. */
|
|
606
|
+
export async function readVault(root: string): Promise<VaultSnapshot> {
|
|
208
607
|
const files = await listNoteFiles(root);
|
|
209
|
-
const
|
|
608
|
+
const notes: Note[] = [];
|
|
210
609
|
for (const file of files) {
|
|
211
|
-
const
|
|
212
|
-
const note = await getNote(root, slug);
|
|
610
|
+
const note = await getNote(root, file.slice(0, -".md".length));
|
|
213
611
|
if (!note) continue; // unreadable/malformed files are skipped, not fatal
|
|
214
|
-
|
|
215
|
-
summaries.push({ ...summary, bodyLength: body.length });
|
|
612
|
+
notes.push(note);
|
|
216
613
|
}
|
|
217
|
-
return
|
|
614
|
+
return { notes: notes.sort(byUpdatedDesc), fileCount: files.length };
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
/** One note's identity and change-detection stamp, without reading its content. */
|
|
618
|
+
export interface NoteStat {
|
|
619
|
+
slug: string;
|
|
620
|
+
path: string;
|
|
621
|
+
mtimeMs: number;
|
|
622
|
+
size: number;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
/**
|
|
626
|
+
* Stat-only pass over the vault: enough to decide *which* notes changed,
|
|
627
|
+
* without reading or parsing any of them. The change-detection primitive
|
|
628
|
+
* behind `src/core/cache/workspace` — a no-change rebuild costs N stats and
|
|
629
|
+
* zero reads.
|
|
630
|
+
*
|
|
631
|
+
* Files that vanish between the readdir and the stat are dropped, so a note
|
|
632
|
+
* deleted mid-pass is simply absent rather than fatal.
|
|
633
|
+
*/
|
|
634
|
+
export async function statNotes(root: string): Promise<NoteStat[]> {
|
|
635
|
+
const dir = join(root, NOTES_DIR);
|
|
636
|
+
const files = await listNoteFiles(root);
|
|
637
|
+
const stats = await Promise.all(
|
|
638
|
+
files.map(async (file): Promise<NoteStat | null> => {
|
|
639
|
+
const path = join(dir, file);
|
|
640
|
+
try {
|
|
641
|
+
const st = await fs.stat(path);
|
|
642
|
+
return { slug: file.slice(0, -".md".length), path, mtimeMs: st.mtimeMs, size: st.size };
|
|
643
|
+
} catch {
|
|
644
|
+
return null; // raced a delete
|
|
645
|
+
}
|
|
646
|
+
}),
|
|
647
|
+
);
|
|
648
|
+
return stats.filter((s): s is NoteStat => s !== null);
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** List all notes with their metadata, newest-updated first. */
|
|
652
|
+
export async function listNotes(root: string): Promise<NoteSummary[]> {
|
|
653
|
+
return (await readVault(root)).notes.map(summarizeNote);
|
|
218
654
|
}
|
|
219
655
|
|
|
220
656
|
export async function noteCount(root: string): Promise<number> {
|
|
@@ -231,10 +667,8 @@ export async function searchNotes(root: string, query: string): Promise<NoteSear
|
|
|
231
667
|
if (q.length === 0) return [];
|
|
232
668
|
|
|
233
669
|
const hits: NoteSearchHit[] = [];
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
if (!note) continue;
|
|
237
|
-
|
|
670
|
+
// One pass: `listNotes` + a `getNote` per slug would read every file twice.
|
|
671
|
+
for (const note of (await readVault(root)).notes) {
|
|
238
672
|
let score = 0;
|
|
239
673
|
if (note.title.toLowerCase().includes(q)) score += 3;
|
|
240
674
|
if (note.tags.some((t) => t.toLowerCase().includes(q))) score += 2;
|
|
@@ -249,7 +683,7 @@ export async function searchNotes(root: string, query: string): Promise<NoteSear
|
|
|
249
683
|
score += bodyMatches;
|
|
250
684
|
|
|
251
685
|
if (score === 0) continue;
|
|
252
|
-
hits.push({ summary, score, snippet: makeSnippet(note.body, q) });
|
|
686
|
+
hits.push({ summary: summarizeNote(note), score, snippet: makeSnippet(note.body, q) });
|
|
253
687
|
}
|
|
254
688
|
return hits.sort((a, b) => b.score - a.score || a.summary.slug.localeCompare(b.summary.slug));
|
|
255
689
|
}
|