pi-weave 0.3.11 → 0.3.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -154,6 +154,18 @@ export function Graph(props: GraphProps) {
154
154
  // mount frames the *starting* positions, which a settling simulation then
155
155
  // pushes past the edges — a two-note vault opened with clipped circles.
156
156
  const fitted = useRef(false);
157
+ // A re-frame asked for while the layout was still moving, done when it rests.
158
+ const reframe = useRef(false);
159
+ /**
160
+ * Frame the graph for where it is shown: the full view fits everything, and
161
+ * the Context card's note graph opens on the open note and its neighbours,
162
+ * since you read it while on that note (`renderer.ts`'s `focus`).
163
+ */
164
+ const frame = useCallback(() => {
165
+ const { full: whole, model: shown } = live.current;
166
+ if (whole) renderer.current?.fit();
167
+ else renderer.current?.focus(shown.highlight);
168
+ }, []);
157
169
  /**
158
170
  * Arm the simulation clock — idempotent.
159
171
  *
@@ -171,9 +183,10 @@ export function Graph(props: GraphProps) {
171
183
  engine.tick();
172
184
  // Asleep: let the clock die. The next pin re-arms it.
173
185
  if (!engine.awake()) {
174
- if (!fitted.current) {
186
+ if (!fitted.current || reframe.current) {
175
187
  fitted.current = true;
176
- renderer.current?.fit();
188
+ reframe.current = false;
189
+ frame();
177
190
  }
178
191
  return;
179
192
  }
@@ -220,8 +233,8 @@ export function Graph(props: GraphProps) {
220
233
  // Read by the mount-time `onSelect`, which outlives this render.
221
234
  const trailRef = useRef<ReadonlyMap<string, TrailGlow>>(NO_GLOWS);
222
235
  trailRef.current = props.trail ?? NO_GLOWS;
223
- const live = useRef({ view, model, picture, onSelect: props.onSelect, selectedId: props.selectedId });
224
- live.current = { view, model, picture, onSelect: props.onSelect, selectedId: props.selectedId };
236
+ const live = useRef({ view, model, picture, full, onSelect: props.onSelect, selectedId: props.selectedId });
237
+ live.current = { view, model, picture, full, onSelect: props.onSelect, selectedId: props.selectedId };
225
238
 
226
239
  useEffect(() => {
227
240
  const instance = props.renderer(scheme);
@@ -320,6 +333,16 @@ export function Graph(props: GraphProps) {
320
333
  renderer.current?.setHighlight(picture.highlight, picture.selectedId, picture.style);
321
334
  }, [picture]);
322
335
 
336
+ // Re-frame when another note opens under the Context card's graph, or the
337
+ // graph moves between the card and the full view. A click in the full view
338
+ // leaves the camera where it is. Before the first layout rests, the settle
339
+ // frames it; while the layout is still moving, the frame waits for it.
340
+ useEffect(() => {
341
+ if (!fitted.current) return;
342
+ if (clock.current !== null) reframe.current = true;
343
+ else frame();
344
+ }, [full, full ? null : props.selectedId]);
345
+
323
346
  useEffect(() => {
324
347
  renderer.current?.setTrail(trailRef.current);
325
348
  }, [props.trail]);
@@ -545,6 +545,47 @@ export function frameBox(points: readonly Point[]): ViewBox | null {
545
545
  return { x: [minX - padding, maxX + padding], y: [minY - padding, maxY + padding] };
546
546
  }
547
547
 
548
+ /** Where sigma's camera looks, in the unit square a {@link ViewBox} is normalized onto. */
549
+ export interface CameraTarget {
550
+ readonly x: number;
551
+ readonly y: number;
552
+ /** Sigma's zoom: 1 shows the whole box, smaller is closer. */
553
+ readonly ratio: number;
554
+ }
555
+
556
+ /** Room a focus leaves around what it frames, as a multiple of its span. */
557
+ export const FOCUS_MARGIN = 1.2;
558
+
559
+ /** The smallest span a focus frames, so a note with no links is not blown up to fill the card. */
560
+ export const FOCUS_MIN_SPAN = COLLIDE_RADIUS * 10;
561
+
562
+ /**
563
+ * The camera that frames `points` in a view frozen on `box`.
564
+ *
565
+ * The Context card's note graph is read while you are on a note, so it opens
566
+ * on that note and its neighbours instead of the whole vault. This is the
567
+ * arithmetic. Sigma normalizes the box onto a unit square centred on 0.5 (its
568
+ * longer side becomes 1), and a camera ratio of 1 shows that whole square. So
569
+ * the camera's centre is the points' centre in that square, and its ratio is
570
+ * their padded span as a share of the box. Never above 1: a focus does not
571
+ * zoom out past the fit.
572
+ *
573
+ * `null` when there is nothing to frame: no box, or no finite point.
574
+ */
575
+ export function focusCamera(box: ViewBox | null, points: readonly Point[]): CameraTarget | null {
576
+ const around = frameBox(points);
577
+ if (box === null || around === null) return null;
578
+ const extent = Math.max(box.x[1] - box.x[0], box.y[1] - box.y[0]);
579
+ const scale = extent > 0 && Number.isFinite(extent) ? extent : 1;
580
+ const span = Math.max(around.x[1] - around.x[0], around.y[1] - around.y[0], FOCUS_MIN_SPAN);
581
+ const centre = (range: readonly [number, number]): number => (range[0] + range[1]) / 2;
582
+ return {
583
+ x: 0.5 + (centre(around.x) - centre(box.x)) / scale,
584
+ y: 0.5 + (centre(around.y) - centre(box.y)) / scale,
585
+ ratio: Math.min(1, (span * FOCUS_MARGIN) / scale),
586
+ };
587
+ }
588
+
548
589
  /** The empty graph. What the column renders before the first payload lands. */
549
590
  export const EMPTY_RENDER_GRAPH: RenderGraph = { nodes: [], edges: [] };
550
591
 
@@ -1,7 +1,7 @@
1
1
  import type { GraphTheme } from "../../shared/themes";
2
2
  import type { Point } from "../../shared/layout";
3
- import type { EdgeDisplayOverride, GraphSettings, HighlightStyle, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, TrailGlow, ViewBox } from "./graph.model";
4
- import { edgeReducer, fadeStep, frameBox, graphSettings, labelThreshold, nodeReducer, trailOverride } from "./graph.model";
3
+ import type { CameraTarget, EdgeDisplayOverride, GraphSettings, HighlightStyle, NodeDisplayOverride, RenderEdge, RenderGraph, RenderNode, TrailGlow, ViewBox } from "./graph.model";
4
+ import { edgeReducer, fadeStep, focusCamera, frameBox, graphSettings, labelThreshold, nodeReducer, trailOverride } from "./graph.model";
5
5
  import type { ProjectedGraph } from "./project";
6
6
  import { positionsOf, project, syncPositions } from "./project";
7
7
 
@@ -68,7 +68,7 @@ export interface SigmaLike {
68
68
  */
69
69
  setCustomBBox(box: ViewBox | null): unknown;
70
70
  refresh(): unknown;
71
- getCamera(): { animatedReset(): Promise<void> };
71
+ getCamera(): { animatedReset(): Promise<void>; animate(state: CameraTarget): Promise<void> };
72
72
  kill(): void;
73
73
  }
74
74
 
@@ -169,6 +169,20 @@ export function sigmaRenderer(
169
169
  /** The box to freeze the view on, from the graph's *current* positions. */
170
170
  const boxOf = (): ViewBox | null => frameBox([...positionsOf(graph).values()]);
171
171
 
172
+ /**
173
+ * Repaint once a camera move lands. Sigma leaves edges out of frames drawn
174
+ * while the camera moves (`hideEdgesOnMove`) and repaints when the camera
175
+ * changes; a move onto where the camera already is changes nothing, so the
176
+ * frame drawn during it, without edges, stayed on screen until something
177
+ * else repainted. A renderer destroyed mid-move paints nothing, and a
178
+ * rejection from a camera killed mid-flight is noise.
179
+ */
180
+ const settle = (instance: SigmaLike, move: Promise<void>): void => {
181
+ move.then(() => {
182
+ if (sigma === instance) instance.refresh();
183
+ }, () => {});
184
+ };
185
+
172
186
  /**
173
187
  * Push the current highlight into sigma's reducers (§7.4).
174
188
  *
@@ -346,14 +360,24 @@ export function sigmaRenderer(
346
360
  const instance = sigma;
347
361
  if (instance === null) return;
348
362
  instance.setCustomBBox(boxOf());
349
- // Sigma leaves edges out of frames drawn while the camera moves
350
- // (`hideEdgesOnMove`) and repaints when the camera changes. A reset onto
351
- // where the camera already is changes nothing, so the frame drawn during
352
- // it, without edges, stayed on screen until something else repainted.
353
- // One refresh once it settles draws them back.
354
- instance.getCamera().animatedReset().then(() => {
355
- if (sigma === instance) instance.refresh();
356
- }, () => {});
363
+ settle(instance, instance.getCamera().animatedReset());
364
+ },
365
+
366
+ /**
367
+ * Frame these nodes instead of the whole graph — the Context card's note
368
+ * graph opening on the note you are reading (`focusCamera`). Re-frames on
369
+ * the current positions first, like `fit`, so the camera and the box
370
+ * agree. Nothing to frame (`null`, or no drawn node among `ids`) is a fit.
371
+ */
372
+ focus(ids: ReadonlySet<string> | null) {
373
+ const instance = sigma;
374
+ if (instance === null) return;
375
+ const box = boxOf();
376
+ const at = positionsOf(graph);
377
+ const points = [...(ids ?? [])].flatMap((id) => at.get(id) ?? []);
378
+ const target = focusCamera(box, points);
379
+ instance.setCustomBBox(box);
380
+ settle(instance, target === null ? instance.getCamera().animatedReset() : instance.getCamera().animate(target));
357
381
  },
358
382
 
359
383
  positions() {
@@ -21,9 +21,10 @@ import { kindIcon } from "../tree/tree.model";
21
21
  import type { IconName } from "./icons.model";
22
22
  export const NOTE_DRAG_TYPE = "application/x-weave-note";
23
23
 
24
- /** Pane drops open only note IDs that still exist in this workspace. */
24
+ /** Pane drops open only note and HTML artifact IDs that still exist in this workspace. */
25
25
  export function noteDropId(graph: GraphPayload | null, id: string): string | null {
26
- return id.startsWith("note:") && graph?.model.nodes.some((node) => node.id === id && node.kind === "note") ? id : null;
26
+ const kind = id.startsWith("note:") ? "note" : id.startsWith("artifact:") ? "file" : null;
27
+ return kind !== null && graph?.model.nodes.some((node) => node.id === id && node.kind === kind) ? id : null;
27
28
  }
28
29
 
29
30
  export type ColumnId = "tree" | "note" | "graph";
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { NOTE_DRAG_TYPE } from "../shell/shell.model";
4
4
  import { useLayoutEffect, useRef, useState } from "preact/hooks";
5
- import { createFolder, deleteFolder, deleteNote, moveNote, renameFolder, renameNote } from "../api";
5
+ import { createFolder, deleteArtifact, deleteFolder, deleteNote, moveArtifact, moveNote, renameArtifact, renameFolder, renameNote } from "../api";
6
6
  import { fetchJson } from "../api.dom";
7
7
  import { isTextEntry, type KeyTarget } from "../shell/keys.model";
8
8
  import { ICON_BOX, ICON_STROKE, ICONS } from "../shell/icons.model";
@@ -21,6 +21,7 @@ import {
21
21
  deleteNeedsConfirmation,
22
22
  depthVar,
23
23
  dropFolder,
24
+ movableTreeRow,
24
25
  initialTreeView,
25
26
  newFolderParent,
26
27
  newFolderPath,
@@ -41,6 +42,7 @@ import {
41
42
  treeEmptyMessage,
42
43
  treeKey,
43
44
  } from "./tree.model";
45
+ import type { TreeTarget } from "./tree.model";
44
46
 
45
47
  export interface TreeProps {
46
48
  graph: GraphPayload | null;
@@ -91,7 +93,7 @@ function Row({ view, recentIds, edit, onEdit, onRename, onSelect, onToggle, onMe
91
93
  aria-expanded={view.hasKids ? view.expanded : undefined}
92
94
  style={depthVar(view.depth)}
93
95
  onClick={(event) => onSelect(event.metaKey || event.ctrlKey)}
94
- draggable={view.id.startsWith("note:")}
96
+ draggable={movableTreeRow(view.id) !== null}
95
97
  aria-label={view.id === DRAFT_FOLDER_ID ? "New folder" : undefined}
96
98
  onDragStart={(event) => { event.dataTransfer?.setData("text/plain", view.id); event.dataTransfer?.setData(NOTE_DRAG_TYPE, view.id); if (event.dataTransfer) event.dataTransfer.effectAllowed = "copyMove"; }}
97
99
  onDragOver={folder === undefined ? undefined : (event) => event.preventDefault()}
@@ -210,7 +212,7 @@ export function Tree(props: TreeProps) {
210
212
  // Before the request: asking after a delete is asking about a file that
211
213
  // is already gone, and "keep editing" would strand the draft on a 404.
212
214
  if (!props.onMutate(affectedNoteSlugs(props.graph, target))) return;
213
- await run(target.type === "note" ? deleteNote(fetchJson, target.path) : deleteFolder(fetchJson, target.path), deletesSelection(props.selectedId, target) ? "vault" : undefined);
215
+ await run(target.type === "note" ? deleteNote(fetchJson, target.path) : target.type === "artifact" ? deleteArtifact(fetchJson, target.path) : deleteFolder(fetchJson, target.path), deletesSelection(props.selectedId, target) ? "vault" : undefined);
214
216
  }
215
217
  };
216
218
 
@@ -222,7 +224,7 @@ export function Tree(props: TreeProps) {
222
224
  * the row shows no count for, so it takes two — and the second click's label
223
225
  * names the folder, which is the chance to notice the wrong row was hit.
224
226
  */
225
- const clickDelete = (target: { type: "note" | "folder" | "vault" }): void => {
227
+ const clickDelete = (target: Pick<TreeTarget, "type">): void => {
226
228
  if (menu === null) return;
227
229
  if (deleteNeedsConfirmation(target) && menu.armed !== true) {
228
230
  setMenu({ ...menu, armed: true });
@@ -259,7 +261,7 @@ export function Tree(props: TreeProps) {
259
261
  // An open draft is a different question; `onMutate` asks that one.
260
262
  if (target !== null && name && name !== current.label) {
261
263
  if (!props.onMutate(affectedNoteSlugs(props.graph, target))) return;
262
- void run(target.type === "note" ? renameNote(fetchJson, target.path, name) : renameFolder(fetchJson, target.path, name));
264
+ void run(target.type === "note" ? renameNote(fetchJson, target.path, name) : target.type === "artifact" ? renameArtifact(fetchJson, target.path, name) : renameFolder(fetchJson, target.path, name));
263
265
  }
264
266
  };
265
267
 
@@ -297,7 +299,7 @@ export function Tree(props: TreeProps) {
297
299
  setMenu({ id: "vault", label: "Vault", x: event.clientX, y: event.clientY });
298
300
  }}
299
301
  >
300
- {rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={(newTab) => { props.onSelect(view.id, newTab); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); if (dragged.startsWith("note:") && folder !== undefined) { const target = mutableTreeRow(dragged); if (target !== null && !props.onMutate(affectedNoteSlugs(props.graph, target))) return; void run(moveNote(fetchJson, dragged.slice("note:".length), folder)); } }} />)}
302
+ {rowViews(rows, props.selectedId, props.now).map((view) => <Row key={view.id} view={view} recentIds={props.recentIds} edit={editing?.id === view.id ? editing.value : null} onEdit={(value) => setEditing((current) => current === null ? null : { ...current, value })} onRename={commitEdit} onSelect={(newTab) => { props.onSelect(view.id, newTab); if (view.hasKids) setState((current) => toggleExpanded(current, view.id)); }} onToggle={() => setState(toggleExpanded(state, view.id))} onMenu={(x, y) => setMenu({ id: view.id, label: view.label, x, y })} onDrop={(dragged) => { const folder = dropFolder(view.id); const moving = movableTreeRow(dragged); if (moving === null || folder === undefined) return; if (moving.type === "artifact") { void run(moveArtifact(fetchJson, moving.slug, folder)); return; } const target = mutableTreeRow(dragged); if (target !== null && !props.onMutate(affectedNoteSlugs(props.graph, target))) return; void run(moveNote(fetchJson, moving.slug, folder)); }} />)}
301
303
  </ul>
302
304
  ) : <p class="weave-tree-empty">{empty}</p>}
303
305
  <p class="weave-tree-count">{rowCountLabel(rows)}</p>
@@ -131,7 +131,7 @@ export const DRAFT_FOLDER_ID = "vfolder:\u0000draft";
131
131
  * it; on a *note*, a sibling of that note — which is what "new folder here"
132
132
  * means when "here" is a file. Returns the parent path, `""` for the root.
133
133
  */
134
- export function newFolderParent(target: { type: "note" | "folder" | "vault"; path: string }): string {
134
+ export function newFolderParent(target: TreeTarget): string {
135
135
  if (target.type === "vault") return "";
136
136
  if (target.type === "folder") return target.path;
137
137
  return target.path.split("/").slice(0, -1).join("/");
@@ -225,25 +225,40 @@ export function dropFolder(id: string): string | null | undefined {
225
225
  return id.startsWith("vfolder:") ? id.slice("vfolder:".length) : undefined;
226
226
  }
227
227
 
228
- export function mutableTreeRow(id: string): { type: "note" | "folder" | "vault"; path: string } | null {
228
+ /** A row the tree can drag into a folder: a vault note or a vault HTML artifact. */
229
+ export function movableTreeRow(id: string): { type: "note" | "artifact"; slug: string } | null {
230
+ if (id.startsWith("note:")) return { type: "note", slug: id.slice("note:".length) };
231
+ if (id.startsWith("artifact:")) return { type: "artifact", slug: id.slice("artifact:".length) };
232
+ return null;
233
+ }
234
+
235
+ /** A tree row the file actions (rename, delete, new folder) can act on. */
236
+ export interface TreeTarget {
237
+ type: "note" | "artifact" | "folder" | "vault";
238
+ path: string;
239
+ }
240
+
241
+ export function mutableTreeRow(id: string): TreeTarget | null {
229
242
  if (id === "vault") return { type: "vault", path: "" };
230
243
  if (id.startsWith("note:")) return { type: "note", path: id.slice("note:".length) };
244
+ if (id.startsWith("artifact:")) return { type: "artifact", path: id.slice("artifact:".length) };
231
245
  if (id.startsWith("vfolder:")) return { type: "folder", path: id.slice("vfolder:".length) };
232
246
  return null;
233
247
  }
234
248
 
235
249
  /** Note slugs a note or folder mutation can invalidate. */
236
- export function affectedNoteSlugs(graph: GraphPayload | null, target: { type: "note" | "folder" | "vault"; path: string }): string[] {
250
+ export function affectedNoteSlugs(graph: GraphPayload | null, target: TreeTarget): string[] {
237
251
  if (target.type === "note") return [target.path];
238
252
  if (target.type !== "folder" || graph === null) return [];
239
253
  const prefix = `note:${target.path}/`;
240
254
  return graph.model.nodes.filter((node) => node.kind === "note" && node.id.startsWith(prefix)).map((node) => node.id.slice(5));
241
255
  }
242
256
 
243
- export function deletesSelection(selectedId: string | null, target: { type: "note" | "folder" | "vault"; path: string }): boolean {
257
+ export function deletesSelection(selectedId: string | null, target: TreeTarget): boolean {
244
258
  if (selectedId === null) return false;
245
259
  if (target.type === "note") return selectedId === `note:${target.path}`;
246
- if (target.type === "folder") return selectedId === `vfolder:${target.path}` || selectedId.startsWith(`vfolder:${target.path}/`) || selectedId.startsWith(`note:${target.path}/`);
260
+ if (target.type === "artifact") return selectedId === `artifact:${target.path}`;
261
+ if (target.type === "folder") return selectedId === `vfolder:${target.path}` || ["vfolder:", "note:", "artifact:"].some((kind) => selectedId.startsWith(`${kind}${target.path}/`));
247
262
  return false;
248
263
  }
249
264
 
@@ -260,7 +275,7 @@ export function deletesSelection(selectedId: string | null, target: { type: "not
260
275
  * The confirmation is {@link deleteItemLabel}: the same menu item, clicked
261
276
  * twice, with the second click's label saying exactly what it will destroy.
262
277
  */
263
- export function deleteNeedsConfirmation(target: { type: "note" | "folder" | "vault" }): boolean {
278
+ export function deleteNeedsConfirmation(target: Pick<TreeTarget, "type">): boolean {
264
279
  return target.type === "folder";
265
280
  }
266
281
 
@@ -274,7 +289,7 @@ export function deleteNeedsConfirmation(target: { type: "note" | "folder" | "vau
274
289
  * safety mechanism — it is the user's chance to notice they right-clicked the
275
290
  * wrong row.
276
291
  */
277
- export function deleteItemLabel(target: { type: "note" | "folder" | "vault" }, label: string, armed: boolean): string {
292
+ export function deleteItemLabel(target: Pick<TreeTarget, "type">, label: string, armed: boolean): string {
278
293
  if (!deleteNeedsConfirmation(target)) return "Delete";
279
294
  return armed ? `Delete “${label}” and everything in it` : "Delete…";
280
295
  }
@@ -21,6 +21,9 @@
21
21
  * | POST | `/api/history-settings` | turn note history on or off |
22
22
  * | GET | `/api/okf/:rel` | {@link OkfFilePayload} |
23
23
  * | GET | `/api/artifact/:rel` | sandboxed HTML artifact |
24
+ * | POST | `/api/artifact/:rel/move` | move a vault HTML artifact |
25
+ * | POST | `/api/artifact/:rel/rename` | rename a vault HTML artifact |
26
+ * | DELETE | `/api/artifact/:rel` | delete a vault HTML artifact |
24
27
  * | GET | `/api/search?q=` | {@link SearchPayload} |
25
28
  * | POST | `/api/open` | {@link OpenResult} — hand the note to `$EDITOR` |
26
29
  *
@@ -64,7 +67,7 @@ import { openNoteInEditor } from "../../core/openInEditor";
64
67
  import { recallTrail, type RecallTrail } from "../../core/trail";
65
68
  import type { Note } from "../../core/types";
66
69
  import type { VaultMutationResult } from "../../core/vault";
67
- import { addNote, createFolder, deleteFolder, deleteNote, getNote, moveNote, renameFolder, readNoteHistory, renameNote, resolveHtmlPath, searchNotes, setNoteBody } from "../../core/vault";
70
+ import { addNote, createFolder, deleteFolder, deleteNote, deleteHtmlArtifact, getNote, moveHtmlArtifact, moveNote, renameHtmlArtifact, renameFolder, readNoteHistory, renameNote, resolveHtmlPath, searchNotes, setNoteBody } from "../../core/vault";
68
71
  import { deriveTagIndex, type TaggedNote } from "../../core/view/links";
69
72
  import type {
70
73
  GraphPayload,
@@ -434,6 +437,12 @@ async function route(
434
437
  if (method === "GET" && path.startsWith("/api/artifact/")) {
435
438
  return sendArtifact(deps, path.slice("/api/artifact/".length), res);
436
439
  }
440
+ if (method === "POST" && path.startsWith("/api/artifact/") && /\/(move|rename)$/.test(path)) {
441
+ return changeArtifact(deps, path.slice("/api/artifact/".length), req, res);
442
+ }
443
+ if (method === "DELETE" && path.startsWith("/api/artifact/")) {
444
+ return sendMutation(res, await deleteHtmlArtifact(deps.vaultRoot, path.slice("/api/artifact/".length)), "artifact");
445
+ }
437
446
  if (method === "GET" && path === "/api/search") return sendSearch(deps, query, res);
438
447
  if (method === "POST" && path === "/api/open") return openNote(deps, req, res);
439
448
  sendText(res, 404, "not found\n");
@@ -705,9 +714,22 @@ async function routeFolder(deps: RouteDeps, method: string, target: string, req:
705
714
  return false;
706
715
  }
707
716
 
708
- function sendMutation(res: ServerResponse, result: VaultMutationResult): void {
717
+ /** `POST /api/artifact/:rel/move` and `/rename`; `rel` still carries the suffix. */
718
+ async function changeArtifact(deps: RouteDeps, target: string, req: IncomingMessage, res: ServerResponse): Promise<void> {
719
+ const rename = target.endsWith("/rename");
720
+ const rel = target.slice(0, target.lastIndexOf("/"));
721
+ const body = await readJsonBody(req);
722
+ const fields = typeof body === "object" && body !== null ? body as { folder?: unknown; name?: unknown } : {};
723
+ if (rename) {
724
+ if (typeof fields.name !== "string") sendJson(res, 400, { error: "expected { name: string }" });
725
+ else sendMutation(res, await renameHtmlArtifact(deps.vaultRoot, rel, fields.name), "artifact");
726
+ } else if (fields.folder !== null && typeof fields.folder !== "string") sendJson(res, 400, { error: "expected { folder: string | null }" });
727
+ else sendMutation(res, await moveHtmlArtifact(deps.vaultRoot, rel, fields.folder), "artifact");
728
+ }
729
+
730
+ function sendMutation(res: ServerResponse, result: VaultMutationResult, kind: "note" | "artifact" = "note"): void {
709
731
  if (result.ok) {
710
- const id = result.slug === undefined ? result.path === undefined ? undefined : `vfolder:${result.path}` : `note:${result.slug}`;
732
+ const id = result.slug === undefined ? result.path === undefined ? undefined : `vfolder:${result.path}` : `${kind}:${result.slug}`;
711
733
  sendJson(res, 200, { ok: true, ...(id === undefined ? {} : { id }) });
712
734
  return;
713
735
  }