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.
Files changed (84) hide show
  1. package/README.md +114 -34
  2. package/package.json +16 -4
  3. package/src/core/cache/workspace.ts +466 -0
  4. package/src/core/frontmatter.ts +217 -23
  5. package/src/core/git.ts +19 -0
  6. package/src/core/graph/build.ts +37 -4
  7. package/src/core/graph/current.ts +41 -28
  8. package/src/core/graph/mentions.ts +170 -0
  9. package/src/core/graph/model.ts +24 -0
  10. package/src/core/index.ts +12 -0
  11. package/src/core/openInEditor.ts +69 -0
  12. package/src/core/types.ts +40 -0
  13. package/src/core/vault.ts +477 -43
  14. package/src/core/view/cluster.ts +262 -0
  15. package/src/core/view/detail.ts +118 -0
  16. package/src/core/view/focus.ts +109 -0
  17. package/src/core/view/health.ts +156 -0
  18. package/src/core/view/index.ts +15 -0
  19. package/src/core/view/links.ts +105 -0
  20. package/src/core/view/time.ts +47 -0
  21. package/src/core/view/tree.ts +269 -0
  22. package/src/core/view/types.ts +39 -0
  23. package/src/pi/index.ts +104 -11
  24. package/src/pi/viewer/tui/explorer.ts +4 -2
  25. package/src/pi/viewer/tui/model.ts +46 -667
  26. package/src/pi/viewer/tui/openNote.ts +7 -56
  27. package/src/pi/viewer/tui/surface/explore.ts +4 -2
  28. package/src/pi/viewer/web/run.ts +331 -0
  29. package/src/web/client/api.dom.ts +40 -0
  30. package/src/web/client/api.ts +472 -0
  31. package/src/web/client/bootstrap.ts +58 -0
  32. package/src/web/client/context/context.model.ts +313 -0
  33. package/src/web/client/dist/app.js +751 -0
  34. package/src/web/client/graph/Graph.tsx +158 -0
  35. package/src/web/client/graph/column.model.ts +431 -0
  36. package/src/web/client/graph/graph.model.ts +538 -0
  37. package/src/web/client/graph/positions.ts +339 -0
  38. package/src/web/client/graph/project.ts +153 -0
  39. package/src/web/client/graph/renderer.dom.ts +52 -0
  40. package/src/web/client/graph/renderer.ts +279 -0
  41. package/src/web/client/graph/scheme.ts +44 -0
  42. package/src/web/client/live.model.ts +275 -0
  43. package/src/web/client/live.ts +151 -0
  44. package/src/web/client/main.tsx +27 -0
  45. package/src/web/client/note/Editor.tsx +102 -0
  46. package/src/web/client/note/Note.tsx +113 -0
  47. package/src/web/client/note/editor.controller.ts +151 -0
  48. package/src/web/client/note/editor.model.ts +636 -0
  49. package/src/web/client/note/note.model.ts +738 -0
  50. package/src/web/client/search/SearchPalette.tsx +105 -0
  51. package/src/web/client/search/search.model.ts +588 -0
  52. package/src/web/client/search/search.ts +107 -0
  53. package/src/web/client/shell/Columns.tsx +161 -0
  54. package/src/web/client/shell/ContextRail.tsx +87 -0
  55. package/src/web/client/shell/Divider.tsx +44 -0
  56. package/src/web/client/shell/FocusTrap.tsx +56 -0
  57. package/src/web/client/shell/Header.tsx +54 -0
  58. package/src/web/client/shell/HelpOverlay.tsx +70 -0
  59. package/src/web/client/shell/Shell.tsx +193 -0
  60. package/src/web/client/shell/StatusBar.tsx +28 -0
  61. package/src/web/client/shell/cssvars.ts +70 -0
  62. package/src/web/client/shell/drag.model.ts +170 -0
  63. package/src/web/client/shell/focus.model.ts +100 -0
  64. package/src/web/client/shell/keys.model.ts +453 -0
  65. package/src/web/client/shell/keys.ts +59 -0
  66. package/src/web/client/shell/layout.model.ts +526 -0
  67. package/src/web/client/shell/shell.model.ts +333 -0
  68. package/src/web/client/shell/theme.ts +477 -0
  69. package/src/web/client/shell/viewport.ts +29 -0
  70. package/src/web/client/state.ts +78 -0
  71. package/src/web/client/tree/Tree.tsx +138 -0
  72. package/src/web/client/tree/tree.model.ts +674 -0
  73. package/src/web/client/workspace.ts +214 -0
  74. package/src/web/server/page.ts +256 -0
  75. package/src/web/server/routes.ts +975 -0
  76. package/src/web/server/security.ts +361 -0
  77. package/src/web/server/server.ts +275 -0
  78. package/src/web/server/sse.ts +321 -0
  79. package/src/web/server/watcher.ts +507 -0
  80. package/src/web/shared/graph.ts +206 -0
  81. package/src/web/shared/layout.ts +497 -0
  82. package/src/web/shared/metrics.ts +136 -0
  83. package/src/web/shared/view.ts +200 -0
  84. package/src/web/shared/wire.ts +358 -0
@@ -0,0 +1,472 @@
1
+ /**
2
+ * Typed wrappers over the workspace server's endpoints (weave-workspace §5.3).
3
+ *
4
+ * One function per route, each returning a discriminated {@link ApiResult}
5
+ * rather than throwing. That is the whole design decision here, and it is
6
+ * driven by §6: the client refetches on every SSE frame and on every
7
+ * reconnect, so a failed request is a *normal, frequent* occurrence — the
8
+ * server may be mid-restart, the session may have been killed, the token may
9
+ * have rotated. Exceptions are for the unexpected, and none of these are.
10
+ * A caller that must handle "the server went away" on every call is better
11
+ * served by a value it cannot forget to check than by a `catch` it can.
12
+ *
13
+ * ## `fetch` is injected
14
+ *
15
+ * Not `globalThis.fetch`. Two reasons, and the second is the load-bearing
16
+ * one:
17
+ *
18
+ * 1. There is no DOM test environment in this repository and §10 forbids
19
+ * adding one, so the only way to test this file is to hand it a fake.
20
+ * 2. This module is a `.ts`, not a `.tsx`, which means it is compiled by the
21
+ * **root** `tsconfig.json` when a test imports it — and that project has
22
+ * no `DOM` lib. So `Response`, `RequestInit` and `fetch` are not merely
23
+ * inconvenient to reference, they do not exist as types here. {@link
24
+ * HttpResponse} and {@link FetchLike} are the minimal structural ports
25
+ * that both the real `fetch` and a two-line fake satisfy.
26
+ *
27
+ * ## The 304 contract
28
+ *
29
+ * `GET /api/graph` is ETag'd on `stamp`, which the server derives from input
30
+ * timestamps rather than the wall clock — so an unchanged workspace produces
31
+ * a byte-identical stamp and `If-None-Match` is a *true* conditional GET.
32
+ * {@link fetchGraph} therefore takes the previously-held payload and returns
33
+ * it unchanged on a `304`. Returning `null` instead, and making the caller
34
+ * remember that `null` means "keep what you have" rather than "there is
35
+ * nothing", is exactly the sort of API that produces a blank column once a
36
+ * fortnight.
37
+ */
38
+
39
+ import type {
40
+ ConflictPayload,
41
+ DeleteNoteResult,
42
+ GraphPayload,
43
+ NotePayload,
44
+ OkfFilePayload,
45
+ OpenResult,
46
+ SaveNoteRequest,
47
+ SearchPayload,
48
+ ViewNote,
49
+ } from "../shared/wire";
50
+
51
+ // --- the injected HTTP port ------------------------------------------------------
52
+
53
+ /**
54
+ * The slice of `Response` this module reads.
55
+ *
56
+ * Structural, so the platform `Response` satisfies it without a cast, and so
57
+ * a fake is an object literal. `json()` is typed `Promise<unknown>` rather
58
+ * than the DOM's `Promise<any>` on purpose: `any` would silently disable
59
+ * every check below, which is precisely the layer where an unchecked
60
+ * assumption about the server's output becomes a runtime crash.
61
+ */
62
+ export interface HttpResponse {
63
+ readonly ok: boolean;
64
+ readonly status: number;
65
+ json(): Promise<unknown>;
66
+ }
67
+
68
+ /** The slice of `RequestInit` this module sets. */
69
+ export interface HttpRequest {
70
+ readonly method?: string;
71
+ readonly headers?: Record<string, string>;
72
+ readonly body?: string;
73
+ }
74
+
75
+ /** The injected `fetch`. The platform's satisfies this structurally. */
76
+ export type FetchLike = (url: string, init?: HttpRequest) => Promise<HttpResponse>;
77
+
78
+ // --- results ----------------------------------------------------------------------
79
+
80
+ /**
81
+ * Why a request did not produce data.
82
+ *
83
+ * Five cases, because the client reacts differently to each:
84
+ *
85
+ * - `auth` — a `403`. The four security layers (§5.1) refused us. The session
86
+ * is over; reconnecting will not help and the UI must say so rather than
87
+ * spinning.
88
+ * - `missing` — a `404`. Normal: a note was deleted between the frame that
89
+ * announced it and the fetch that wanted it.
90
+ * - `server` — any other non-2xx. Transient; a retry is reasonable.
91
+ * - `malformed` — 2xx with a body that is not what the route promised. Rarer
92
+ * and more alarming than a network failure, because it means the two sides
93
+ * disagree about the contract; separated so it can be surfaced differently.
94
+ * - `network` — `fetch` itself rejected. The server is gone or the socket
95
+ * died mid-flight.
96
+ */
97
+ export type ApiErrorKind = "auth" | "missing" | "server" | "malformed" | "network";
98
+
99
+ export interface ApiFailure {
100
+ readonly ok: false;
101
+ readonly kind: ApiErrorKind;
102
+ /** HTTP status, or `0` when the request never completed. */
103
+ readonly status: number;
104
+ /** Short, human-facing. Safe to render — never carries a response body. */
105
+ readonly message: string;
106
+ }
107
+
108
+ /**
109
+ * A `409` from a write route — its own arm, not a sixth {@link ApiErrorKind}.
110
+ *
111
+ * Every other failure is a dead end: the caller shows a message and stops. A
112
+ * conflict is the opposite — it is the server handing back **the information
113
+ * needed to continue**, and the editor's whole reload-or-overwrite prompt is
114
+ * built from `conflict.current`. Folding it into `ApiFailure` would mean
115
+ * either an optional field every caller has to remember is only sometimes
116
+ * there, or a cast; a separate arm makes the payload's presence a fact the
117
+ * compiler enforces once the caller has narrowed on `kind`.
118
+ *
119
+ * The `409` is therefore *not* an error in the sense the other five are, and
120
+ * the type says so.
121
+ */
122
+ export interface ApiConflict {
123
+ readonly ok: false;
124
+ readonly kind: "conflict";
125
+ readonly status: 409;
126
+ readonly message: string;
127
+ /** The server's `409` body: the current note, or the taken slug. */
128
+ readonly conflict: ConflictPayload;
129
+ }
130
+
131
+ export interface ApiSuccess<T> {
132
+ readonly ok: true;
133
+ readonly data: T;
134
+ /**
135
+ * `true` when the server answered `304` and `data` is the caller's own
136
+ * previous value. Lets a caller skip re-deriving anything downstream.
137
+ */
138
+ readonly cached: boolean;
139
+ }
140
+
141
+ export type ApiResult<T> = ApiSuccess<T> | ApiFailure;
142
+
143
+ /** The result of a write: as {@link ApiResult}, plus the `409` arm. */
144
+ export type WriteResult<T> = ApiSuccess<T> | ApiFailure | ApiConflict;
145
+
146
+ function success<T>(data: T, cached = false): ApiSuccess<T> {
147
+ return { ok: true, data, cached };
148
+ }
149
+
150
+ function failure(kind: ApiErrorKind, status: number, message: string): ApiFailure {
151
+ return { ok: false, kind, status, message };
152
+ }
153
+
154
+ /** Map a non-2xx status to the kind a caller should branch on. */
155
+ export function classifyStatus(status: number): ApiErrorKind {
156
+ if (status === 403) return "auth";
157
+ if (status === 404) return "missing";
158
+ return "server";
159
+ }
160
+
161
+ /** The user-facing sentence for a status. Deliberately terse and generic. */
162
+ export function messageForStatus(status: number): string {
163
+ if (status === 403) return "not authorised — this workspace session has ended";
164
+ if (status === 404) return "not found";
165
+ return `server error (${status})`;
166
+ }
167
+
168
+ // --- the request core ---------------------------------------------------------------
169
+
170
+ /**
171
+ * Perform a request and validate the decoded body.
172
+ *
173
+ * Every route goes through here, so there is exactly one place that decides
174
+ * what a rejection, a bad status or an unparseable body means — and exactly
175
+ * one place to look when that decision turns out to be wrong.
176
+ *
177
+ * `guard` is not optional and not a cast. The body arrives from a socket, and
178
+ * "the status was 200" says nothing about the shape: a proxy can interpose,
179
+ * a stale server build can answer an old shape, and `JSON.parse` will happily
180
+ * hand back a number. Narrowing here means every caller below receives a
181
+ * value that has actually been inspected.
182
+ */
183
+ async function request<T>(
184
+ fetchImpl: FetchLike,
185
+ url: string,
186
+ guard: (value: unknown) => value is T,
187
+ init?: HttpRequest,
188
+ ): Promise<ApiResult<T>> {
189
+ let response: HttpResponse;
190
+ try {
191
+ response = await fetchImpl(url, init);
192
+ } catch (error) {
193
+ // `fetch` rejects on DNS failure, connection refused, and a socket that
194
+ // dies mid-body. All of them are "the server is not there".
195
+ return failure("network", 0, error instanceof Error ? error.message : "network request failed");
196
+ }
197
+
198
+ if (!response.ok) return failure(classifyStatus(response.status), response.status, messageForStatus(response.status));
199
+
200
+ let body: unknown;
201
+ try {
202
+ body = await response.json();
203
+ } catch {
204
+ // A truncated response, or an HTML error page from something upstream.
205
+ return failure("malformed", response.status, "response was not valid JSON");
206
+ }
207
+
208
+ if (!guard(body)) return failure("malformed", response.status, "response did not match the expected shape");
209
+ return success(body);
210
+ }
211
+
212
+ // --- structural guards ----------------------------------------------------------------
213
+
214
+ function isObject(value: unknown): value is Record<string, unknown> {
215
+ // `typeof null === "object"`, and an array is an object too — both would
216
+ // pass a naive check and then fail on property access three frames later.
217
+ return typeof value === "object" && value !== null && !Array.isArray(value);
218
+ }
219
+
220
+ function isStringArray(value: unknown): value is string[] {
221
+ return Array.isArray(value) && value.every((entry) => typeof entry === "string");
222
+ }
223
+
224
+ /**
225
+ * `GraphPayload`, checked at the depth the client actually depends on.
226
+ *
227
+ * `model.nodes` and `model.edges` are verified to be arrays and `stamp` to be
228
+ * a string, because those three are what the columns index into and what the
229
+ * SSE dedupe compares. Individual node fields are not walked: a per-node
230
+ * validation pass on every refetch is real cost on a large graph, and a
231
+ * malformed node degrades one row rather than crashing the app. The cutoff is
232
+ * "what breaks the shell if absent", not "everything the type declares".
233
+ */
234
+ export function isGraphPayload(value: unknown): value is GraphPayload {
235
+ if (!isObject(value)) return false;
236
+ if (typeof value["stamp"] !== "string") return false;
237
+ const model = value["model"];
238
+ if (!isObject(model)) return false;
239
+ if (!Array.isArray(model["nodes"]) || !Array.isArray(model["edges"])) return false;
240
+ return isObject(value["tags"]) && isObject(value["dangling"]);
241
+ }
242
+
243
+ /** `ViewNote`. Every field is rendered, so every field is checked. */
244
+ export function isViewNote(value: unknown): value is ViewNote {
245
+ if (!isObject(value)) return false;
246
+ for (const key of ["slug", "title", "body", "created", "updated", "source"]) {
247
+ if (typeof value[key] !== "string") return false;
248
+ }
249
+ return isStringArray(value["tags"]);
250
+ }
251
+
252
+ /** `NotePayload` — a `ViewNote` plus the revision the editor saves against. */
253
+ export function isNotePayload(value: unknown): value is NotePayload {
254
+ return isObject(value) && typeof value["revision"] === "string" && isViewNote(value["note"]);
255
+ }
256
+
257
+ /** `DeleteNoteResult`. */
258
+ export function isDeleteResult(value: unknown): value is DeleteNoteResult {
259
+ return isObject(value) && value["deleted"] === true;
260
+ }
261
+
262
+ /**
263
+ * `ConflictPayload` — the `409` body.
264
+ *
265
+ * Checked to the depth the prompt renders, which for a `conflict` is the
266
+ * whole nested note: the reload button writes `current.note` into the column
267
+ * and the overwrite button sends `current.revision`, so a payload missing
268
+ * either would produce a dialog whose buttons do nothing. Falling back to a
269
+ * generic "server error" is the honest outcome for a malformed one.
270
+ */
271
+ export function isConflictPayload(value: unknown): value is ConflictPayload {
272
+ if (!isObject(value) || typeof value["error"] !== "string") return false;
273
+ if (value["reason"] === "collision") return typeof value["slug"] === "string";
274
+ if (value["reason"] !== "conflict") return false;
275
+ return isNotePayload(value["current"]);
276
+ }
277
+
278
+ /** `OkfFilePayload`. */
279
+ export function isOkfFile(value: unknown): value is OkfFilePayload {
280
+ return isObject(value) && typeof value["path"] === "string" && typeof value["body"] === "string";
281
+ }
282
+
283
+ /** `SearchPayload`. Hits are checked as an array; ranking tolerates junk. */
284
+ export function isSearchPayload(value: unknown): value is SearchPayload {
285
+ return isObject(value) && typeof value["query"] === "string" && Array.isArray(value["hits"]);
286
+ }
287
+
288
+ /** `OpenResult`. */
289
+ export function isOpenResult(value: unknown): value is OpenResult {
290
+ return isObject(value) && typeof value["opened"] === "boolean";
291
+ }
292
+
293
+ // --- routes --------------------------------------------------------------------------
294
+
295
+ /**
296
+ * `GET /api/graph`, conditionally.
297
+ *
298
+ * @param previous the payload currently held, or `null` on first load. When
299
+ * present its `stamp` is sent as `If-None-Match` and a `304` returns it
300
+ * back with `cached: true`.
301
+ */
302
+ export async function fetchGraph(fetchImpl: FetchLike, previous: GraphPayload | null = null): Promise<ApiResult<GraphPayload>> {
303
+ const headers = previous === null ? undefined : { "if-none-match": `"${previous.stamp}"` };
304
+
305
+ let response: HttpResponse;
306
+ try {
307
+ response = await fetchImpl("/api/graph", headers === undefined ? {} : { headers });
308
+ } catch (error) {
309
+ return failure("network", 0, error instanceof Error ? error.message : "network request failed");
310
+ }
311
+
312
+ // Handled before the `ok` check: `304` is *not* ok by the Fetch spec's
313
+ // definition (which is 200–299), so testing `ok` first would misclassify
314
+ // the success case as a server error. This is the one place the generic
315
+ // path above cannot serve, which is why this route does not use it.
316
+ if (response.status === 304) {
317
+ // A `304` with nothing to return means we sent an `If-None-Match` we did
318
+ // not have, which is a bug rather than a cache hit. Refuse it loudly
319
+ // rather than handing back a `null` typed as a payload.
320
+ if (previous === null) return failure("malformed", 304, "server sent 304 for an unconditional request");
321
+ return success(previous, true);
322
+ }
323
+ if (!response.ok) return failure(classifyStatus(response.status), response.status, messageForStatus(response.status));
324
+
325
+ let body: unknown;
326
+ try {
327
+ body = await response.json();
328
+ } catch {
329
+ return failure("malformed", response.status, "response was not valid JSON");
330
+ }
331
+ if (!isGraphPayload(body)) return failure("malformed", response.status, "response did not match the expected shape");
332
+ return success(body);
333
+ }
334
+
335
+ /**
336
+ * `GET /api/note/:slug`.
337
+ *
338
+ * The slug is percent-encoded here and traversal-checked *there* — the
339
+ * server hands it to `resolveNotePath`, which rejects anything that is not a
340
+ * flat slug. Doing our own check as well would be a second implementation to
341
+ * keep in sync, which is how traversal bugs are actually born (see
342
+ * `routes.ts`). Encoding is still required, because an unencoded `#` or `?`
343
+ * would silently truncate the path.
344
+ *
345
+ * Returns a {@link NotePayload}, not a bare `ViewNote`: the revision is read
346
+ * **with** the note or not at all. Fetching it separately would leave a
347
+ * window in which the two describe different states of the file, and a save
348
+ * carrying a revision that does not match the body it was typed against is
349
+ * worse than a save carrying none.
350
+ */
351
+ export function fetchNote(fetchImpl: FetchLike, slug: string): Promise<ApiResult<NotePayload>> {
352
+ return request(fetchImpl, `/api/note/${encodeURIComponent(slug)}`, isNotePayload);
353
+ }
354
+
355
+ /** The URL for one note. One definition, so the four routes cannot disagree. */
356
+ function noteUrl(slug: string, suffix = ""): string {
357
+ return `/api/note/${encodeURIComponent(slug)}${suffix}`;
358
+ }
359
+
360
+ /** A JSON write, as {@link HttpRequest}. */
361
+ function writeInit(method: string, body: unknown): HttpRequest {
362
+ return { method, headers: { "content-type": "application/json" }, body: JSON.stringify(body) };
363
+ }
364
+
365
+ /**
366
+ * Perform a write, decoding the `409` rather than flattening it.
367
+ *
368
+ * The generic {@link request} cannot serve here: it maps every non-2xx to an
369
+ * {@link ApiFailure} with a message and no body, and the `409` body is the
370
+ * entire point — it carries the note the user must choose between keeping
371
+ * and discarding. So the conflict is intercepted before the status
372
+ * classification and decoded through {@link isConflictPayload}.
373
+ *
374
+ * A `409` whose body does *not* decode falls through to a `server` failure.
375
+ * That is deliberate rather than defensive: presenting a reload-or-overwrite
376
+ * prompt built from a payload we could not read would offer the user two
377
+ * buttons, at least one of which silently does nothing.
378
+ */
379
+ async function write<T>(
380
+ fetchImpl: FetchLike,
381
+ url: string,
382
+ guard: (value: unknown) => value is T,
383
+ init: HttpRequest,
384
+ ): Promise<WriteResult<T>> {
385
+ let response: HttpResponse;
386
+ try {
387
+ response = await fetchImpl(url, init);
388
+ } catch (error) {
389
+ return failure("network", 0, error instanceof Error ? error.message : "network request failed");
390
+ }
391
+
392
+ if (response.status === 409) {
393
+ let body: unknown;
394
+ try {
395
+ body = await response.json();
396
+ } catch {
397
+ return failure("malformed", 409, "response was not valid JSON");
398
+ }
399
+ if (!isConflictPayload(body)) return failure("malformed", 409, "response did not match the expected shape");
400
+ return { ok: false, kind: "conflict", status: 409, message: body.error, conflict: body };
401
+ }
402
+
403
+ if (!response.ok) return failure(classifyStatus(response.status), response.status, messageForStatus(response.status));
404
+
405
+ let body: unknown;
406
+ try {
407
+ body = await response.json();
408
+ } catch {
409
+ return failure("malformed", response.status, "response was not valid JSON");
410
+ }
411
+ if (!guard(body)) return failure("malformed", response.status, "response did not match the expected shape");
412
+ return success(body);
413
+ }
414
+
415
+ /**
416
+ * `POST /api/note/:slug` — save (§11 P5.3, P5.5).
417
+ *
418
+ * `input.expectedRevision` is what makes a save safe, and omitting it is what
419
+ * makes one an overwrite. Both are legitimate and neither is a default the
420
+ * caller should stumble into, so this function takes the request verbatim
421
+ * rather than deciding for it: `editor.model.ts` supplies the revision on a
422
+ * normal save and drops it only after the user has looked at a conflict and
423
+ * chosen to win.
424
+ */
425
+ export function saveNote(fetchImpl: FetchLike, slug: string, input: SaveNoteRequest): Promise<WriteResult<NotePayload>> {
426
+ return write(fetchImpl, noteUrl(slug), isNotePayload, writeInit("POST", input));
427
+ }
428
+
429
+ /**
430
+ * `POST /api/note/:slug/rename`.
431
+ *
432
+ * A `409` here is a `collision`, not a `conflict` — the destination is
433
+ * taken. The server refuses rather than uniquifying, because landing
434
+ * somewhere other than where the user asked hides their mistake.
435
+ */
436
+ export function renameNote(fetchImpl: FetchLike, slug: string, target: string): Promise<WriteResult<NotePayload>> {
437
+ return write(fetchImpl, noteUrl(slug, "/rename"), isNotePayload, writeInit("POST", { slug: target }));
438
+ }
439
+
440
+ /** `DELETE /api/note/:slug`. Hard delete — the vault has no trash. */
441
+ export function deleteNote(fetchImpl: FetchLike, slug: string): Promise<WriteResult<DeleteNoteResult>> {
442
+ return write(fetchImpl, noteUrl(slug), isDeleteResult, { method: "DELETE" });
443
+ }
444
+
445
+ /** `GET /api/okf/:rel`. Anchored under `<cwd>/.okf` by the server. */
446
+ export function fetchOkfFile(fetchImpl: FetchLike, rel: string): Promise<ApiResult<OkfFilePayload>> {
447
+ // Per segment: `encodeURIComponent` would escape the separators of a
448
+ // relative path like `index/notes.md` into `%2F` and produce a 404.
449
+ const encoded = rel.split("/").map(encodeURIComponent).join("/");
450
+ return request(fetchImpl, `/api/okf/${encoded}`, isOkfFile);
451
+ }
452
+
453
+ /** `GET /api/search?q=`. An empty query is valid and returns no hits. */
454
+ export function fetchSearch(fetchImpl: FetchLike, query: string): Promise<ApiResult<SearchPayload>> {
455
+ return request(fetchImpl, `/api/search?q=${encodeURIComponent(query)}`, isSearchPayload);
456
+ }
457
+
458
+ /**
459
+ * `POST /api/open` — the only write in P1–P4.
460
+ *
461
+ * A missing note answers `404` with `{opened:false}`, which surfaces here as
462
+ * a `missing` failure rather than a success carrying `false`. The status code
463
+ * is the server's chosen signal (see `routes.ts`) and collapsing it into the
464
+ * body would hide the failure from a network panel.
465
+ */
466
+ export function openNote(fetchImpl: FetchLike, slug: string): Promise<ApiResult<OpenResult>> {
467
+ return request(fetchImpl, "/api/open", isOpenResult, {
468
+ method: "POST",
469
+ headers: { "content-type": "application/json" },
470
+ body: JSON.stringify({ slug }),
471
+ });
472
+ }
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Reading the page's bootstrap block (weave-workspace §5.3).
3
+ *
4
+ * `page.ts` embeds `{cwd, vaultRoot, session}` as a nonce'd
5
+ * `<script type="application/json">` so the first paint knows where it is
6
+ * without a round trip. This decodes it.
7
+ *
8
+ * A `.ts` taking the raw text rather than an element, so the parsing and its
9
+ * failure modes are covered by ordinary tests while `main.tsx` keeps only the
10
+ * `getElementById` that cannot be tested without a DOM.
11
+ *
12
+ * ## Why a missing block is not fatal
13
+ *
14
+ * The bootstrap is a *convenience* — every value in it is cosmetic at P1
15
+ * (`cwd` is a status-bar label) and the API routes work without it. So a
16
+ * block that is absent, empty or malformed yields {@link EMPTY_BOOTSTRAP}
17
+ * rather than throwing. Throwing here would turn a cosmetic problem into a
18
+ * blank page, which is a much worse trade than a status bar reading `—`.
19
+ */
20
+
21
+ import type { Bootstrap } from "../shared/wire";
22
+
23
+ /** What the client assumes when the page told it nothing. */
24
+ export const EMPTY_BOOTSTRAP: Bootstrap = { cwd: "", vaultRoot: "", session: "" };
25
+
26
+ /**
27
+ * Structural guard. Every field is a string, and all three are read.
28
+ *
29
+ * `session` is unused at P1 and checked anyway: it is what a later phase uses
30
+ * to tell "I missed frames" from "this is a different server" (see its
31
+ * `wire.ts` comment), and a guard that admits a bootstrap without it would
32
+ * let that distinction fail silently the day it starts mattering.
33
+ */
34
+ function isBootstrap(value: unknown): value is Bootstrap {
35
+ if (typeof value !== "object" || value === null) return false;
36
+ const candidate = value as Record<string, unknown>;
37
+ return (
38
+ typeof candidate["cwd"] === "string" &&
39
+ typeof candidate["vaultRoot"] === "string" &&
40
+ typeof candidate["session"] === "string"
41
+ );
42
+ }
43
+
44
+ /**
45
+ * Decode the bootstrap JSON, falling back to {@link EMPTY_BOOTSTRAP}.
46
+ *
47
+ * @param text the block's `textContent`, or `null` when it is absent.
48
+ */
49
+ export function readBootstrap(text: string | null): Bootstrap {
50
+ if (text === null || text.trim() === "") return EMPTY_BOOTSTRAP;
51
+ let parsed: unknown;
52
+ try {
53
+ parsed = JSON.parse(text);
54
+ } catch {
55
+ return EMPTY_BOOTSTRAP;
56
+ }
57
+ return isBootstrap(parsed) ? parsed : EMPTY_BOOTSTRAP;
58
+ }