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
|
@@ -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
|
+
}
|