kaizen-agent 1.32.0 → 1.33.0

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/web/notebook.ts CHANGED
@@ -1,52 +1,104 @@
1
1
  // The notebook page. Bundled into web/notebook.js by `bun run build:web`; the page
2
2
  // never sees the filesystem, only /vault. See design.md, "Notebook", for the look.
3
- import { EditorView, Decoration, ViewPlugin, WidgetType, keymap, type DecorationSet, type ViewUpdate } from "@codemirror/view";
3
+ import {
4
+ EditorView,
5
+ Decoration,
6
+ ViewPlugin,
7
+ WidgetType,
8
+ keymap,
9
+ type DecorationSet,
10
+ type ViewUpdate,
11
+ } from "@codemirror/view";
4
12
  import { EditorState, StateEffect, StateField, type Range } from "@codemirror/state";
5
13
  import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
6
14
  import { syntaxTree, syntaxHighlighting, HighlightStyle } from "@codemirror/language";
7
15
  import { markdown, markdownLanguage, markdownKeymap } from "@codemirror/lang-markdown";
8
16
  import { tags as t } from "@lezer/highlight";
9
17
  import { autocompletion, startCompletion, type CompletionContext, type Completion } from "@codemirror/autocomplete";
10
- import { wikilinks, tags, resolve, safeUrl, tree, freshName, parseTable, boardLink, resolveRun, anchorWords, TABS, type Tree, type BoardLink } from "./notes-lib.ts";
18
+ import {
19
+ wikilinks,
20
+ tags,
21
+ resolve,
22
+ safeUrl,
23
+ tree,
24
+ freshName,
25
+ parseTable,
26
+ boardLink,
27
+ resolveRun,
28
+ anchorWords,
29
+ TABS,
30
+ type Tree,
31
+ type BoardLink,
32
+ } from "./notes-lib.ts";
11
33
 
12
34
  type Note = { name: string; mtime: number; text: string };
13
35
 
14
36
  const $ = <T extends HTMLElement = HTMLElement>(id: string) => document.getElementById(id) as T;
15
- const esc = (s: string) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
37
+ const esc = (s: string) =>
38
+ String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c]!);
16
39
  const ico = (n: string) => `<svg><use href="#i-${n}"/></svg>`;
17
40
  const last = (name: string) => name.split("/").pop()!;
18
- const folderOf = (name: string) => name.includes("/") ? name.slice(0, name.lastIndexOf("/")) : "";
41
+ const folderOf = (name: string) => (name.includes("/") ? name.slice(0, name.lastIndexOf("/")) : "");
19
42
 
20
43
  type Board = {
21
- runs: { id: string; short: string; title: string; status: string; findings: { n: number; text: string; done: boolean }[]; backlog: { n: number; status: string; text: string }[] }[];
44
+ runs: {
45
+ id: string;
46
+ short: string;
47
+ title: string;
48
+ status: string;
49
+ findings: { n: number; text: string; done: boolean }[];
50
+ backlog: { n: number; status: string; text: string }[];
51
+ }[];
22
52
  ideas: { text: string; status: string }[];
23
53
  };
24
- let board: Board = { runs: [], ideas: [] }; // what [[run:…]] and [[idea:…]] can name, from /links
25
- let dir = ""; // the state dir whose notes/ is shown
54
+ let board: Board = { runs: [], ideas: [] }; // what [[run:…]] and [[idea:…]] can name, from /links
55
+ let dir = ""; // the state dir whose notes/ is shown
26
56
  let notes: Note[] = [];
27
- let open: string | null = null; // the note in the editor
28
- let base = 0; // the mtime it was loaded or last saved at
29
- let dirty = false, saving: Promise<void> | null = null, timer: ReturnType<typeof setTimeout> | undefined;
57
+ let open: string | null = null; // the note in the editor
58
+ let base = 0; // the mtime it was loaded or last saved at
59
+ let dirty = false,
60
+ saving: Promise<void> | null = null,
61
+ timer: ReturnType<typeof setTimeout> | undefined;
30
62
  let conflict: { text: string; mtime: number } | null = null;
31
63
  let confirmDelete = false;
32
- const shut = new Set<string>(); // folders folded in the tree, for this page load
64
+ const shut = new Set<string>(); // folders folded in the tree, for this page load
33
65
 
34
- function toast(msg: string) { const el = $("toast"); el.textContent = msg; el.classList.add("on"); setTimeout(() => el.classList.remove("on"), 2600); }
66
+ function toast(msg: string) {
67
+ const el = $("toast");
68
+ el.textContent = msg;
69
+ el.classList.add("on");
70
+ setTimeout(() => el.classList.remove("on"), 2600);
71
+ }
35
72
  async function post(body: Record<string, unknown>) {
36
73
  try {
37
- const r = await fetch("/vault", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ dir, ...body }) });
74
+ const r = await fetch("/vault", {
75
+ method: "POST",
76
+ headers: { "content-type": "application/json" },
77
+ body: JSON.stringify({ dir, ...body }),
78
+ });
38
79
  return { status: r.status, ...(await r.json().catch(() => ({}))) };
39
- } catch { return { status: 0, error: "The board is not running" }; }
80
+ } catch {
81
+ return { status: 0, error: "The board is not running" };
82
+ }
40
83
  }
41
84
 
42
85
  // ---- live preview: markdown hidden and typeset on every line the cursor is not on
43
86
 
44
87
  class Check extends WidgetType {
45
- constructor(readonly on: boolean, readonly at: number) { super(); }
46
- eq(o: Check) { return o.on === this.on && o.at === this.at; }
88
+ constructor(
89
+ readonly on: boolean,
90
+ readonly at: number,
91
+ ) {
92
+ super();
93
+ }
94
+ eq(o: Check) {
95
+ return o.on === this.on && o.at === this.at;
96
+ }
47
97
  toDOM(view: EditorView) {
48
98
  const b = document.createElement("input");
49
- b.type = "checkbox"; b.checked = this.on; b.className = "cm-task";
99
+ b.type = "checkbox";
100
+ b.checked = this.on;
101
+ b.className = "cm-task";
50
102
  b.setAttribute("aria-label", this.on ? "done" : "to do");
51
103
  b.addEventListener("mousedown", (e) => {
52
104
  e.preventDefault();
@@ -54,58 +106,114 @@ class Check extends WidgetType {
54
106
  });
55
107
  return b;
56
108
  }
57
- ignoreEvent() { return true; }
109
+ ignoreEvent() {
110
+ return true;
111
+ }
58
112
  }
59
113
  class Bullet extends WidgetType {
60
- eq() { return true; }
61
- toDOM() { const s = document.createElement("span"); s.className = "cm-bullet"; s.textContent = "•"; return s; }
114
+ eq() {
115
+ return true;
116
+ }
117
+ toDOM() {
118
+ const s = document.createElement("span");
119
+ s.className = "cm-bullet";
120
+ s.textContent = "•";
121
+ return s;
122
+ }
62
123
  }
63
124
  class Rule extends WidgetType {
64
- eq() { return true; }
65
- toDOM() { const s = document.createElement("span"); s.className = "cm-rule"; return s; }
125
+ eq() {
126
+ return true;
127
+ }
128
+ toDOM() {
129
+ const s = document.createElement("span");
130
+ s.className = "cm-rule";
131
+ return s;
132
+ }
66
133
  }
67
134
  class Img extends WidgetType {
68
- constructor(readonly src: string, readonly alt: string) { super(); }
69
- eq(o: Img) { return o.src === this.src && o.alt === this.alt; }
70
- toDOM() { const i = document.createElement("img"); i.src = this.src; i.alt = this.alt; i.className = "cm-img"; return i; }
135
+ constructor(
136
+ readonly src: string,
137
+ readonly alt: string,
138
+ ) {
139
+ super();
140
+ }
141
+ eq(o: Img) {
142
+ return o.src === this.src && o.alt === this.alt;
143
+ }
144
+ toDOM() {
145
+ const i = document.createElement("img");
146
+ i.src = this.src;
147
+ i.alt = this.alt;
148
+ i.className = "cm-img";
149
+ return i;
150
+ }
71
151
  }
72
152
 
73
153
  // A table the cursor is not in is drawn as one; clicking it puts the cursor in its
74
154
  // source. Cells are text nodes: nothing in a note becomes markup.
75
155
  class TableView extends WidgetType {
76
- constructor(readonly src: string, readonly at: number) { super(); }
77
- eq(o: TableView) { return o.src === this.src && o.at === this.at; }
156
+ constructor(
157
+ readonly src: string,
158
+ readonly at: number,
159
+ ) {
160
+ super();
161
+ }
162
+ eq(o: TableView) {
163
+ return o.src === this.src && o.at === this.at;
164
+ }
78
165
  toDOM(view: EditorView) {
79
- const t = parseTable(this.src), wrap = document.createElement("div"), table = document.createElement("table");
166
+ const t = parseTable(this.src),
167
+ wrap = document.createElement("div"),
168
+ table = document.createElement("table");
80
169
  wrap.className = "cm-table";
81
170
  const row = (cells: string[], tag: "th" | "td") => {
82
171
  const tr = document.createElement("tr");
83
- cells.forEach((c, i) => { const el = document.createElement(tag); el.textContent = c; if (t.align[i]) el.style.textAlign = t.align[i]!; tr.append(el); });
172
+ cells.forEach((c, i) => {
173
+ const el = document.createElement(tag);
174
+ el.textContent = c;
175
+ if (t.align[i]) el.style.textAlign = t.align[i]!;
176
+ tr.append(el);
177
+ });
84
178
  return tr;
85
179
  };
86
- const head = document.createElement("thead"), body = document.createElement("tbody");
87
- head.append(row(t.head, "th")); t.rows.forEach((r) => body.append(row(r, "td")));
88
- table.append(head, body); wrap.append(table);
89
- wrap.addEventListener("mousedown", (e) => { e.preventDefault(); view.dispatch({ selection: { anchor: this.at } }); view.focus(); });
180
+ const head = document.createElement("thead"),
181
+ body = document.createElement("tbody");
182
+ head.append(row(t.head, "th"));
183
+ t.rows.forEach((r) => body.append(row(r, "td")));
184
+ table.append(head, body);
185
+ wrap.append(table);
186
+ wrap.addEventListener("mousedown", (e) => {
187
+ e.preventDefault();
188
+ view.dispatch({ selection: { anchor: this.at } });
189
+ view.focus();
190
+ });
90
191
  return wrap;
91
192
  }
92
- ignoreEvent() { return true; }
193
+ ignoreEvent() {
194
+ return true;
195
+ }
93
196
  }
94
197
  // Whether the editor has focus, as state: an unfocused editor shows every table drawn.
95
198
  const focusSet = StateEffect.define<boolean>();
96
199
  const focused = StateField.define<boolean>({
97
200
  create: () => false,
98
- update: (v, tr) => tr.effects.reduce((f, e) => e.is(focusSet) ? e.value : f, v),
201
+ update: (v, tr) => tr.effects.reduce((f, e) => (e.is(focusSet) ? e.value : f), v),
99
202
  });
100
203
  // Block widgets may span lines, which a view plugin may not do: tables live in a field.
101
204
  function tables(state: EditorState) {
102
- const out: Range<Decoration>[] = [], sel = state.selection.main, live = state.field(focused, false);
205
+ const out: Range<Decoration>[] = [],
206
+ sel = state.selection.main,
207
+ live = state.field(focused, false);
103
208
  syntaxTree(state).iterate({
104
209
  enter(n) {
105
210
  if (n.name !== "Table") return;
106
- const from = state.doc.lineAt(n.from).from, to = state.doc.lineAt(n.to).to;
211
+ const from = state.doc.lineAt(n.from).from,
212
+ to = state.doc.lineAt(n.to).to;
107
213
  if (live && sel.to >= from && sel.from <= to) return false;
108
- out.push(Decoration.replace({ widget: new TableView(state.sliceDoc(from, to), from), block: true }).range(from, to));
214
+ out.push(
215
+ Decoration.replace({ widget: new TableView(state.sliceDoc(from, to), from), block: true }).range(from, to),
216
+ );
109
217
  return false;
110
218
  },
111
219
  });
@@ -113,36 +221,77 @@ function tables(state: EditorState) {
113
221
  }
114
222
  const tableField = StateField.define<DecorationSet>({
115
223
  create: tables,
116
- update: (v, tr) => tr.docChanged || tr.selection || tr.effects.some((e) => e.is(focusSet)) || syntaxTree(tr.startState) !== syntaxTree(tr.state) ? tables(tr.state) : v,
224
+ update: (v, tr) =>
225
+ tr.docChanged ||
226
+ tr.selection ||
227
+ tr.effects.some((e) => e.is(focusSet)) ||
228
+ syntaxTree(tr.startState) !== syntaxTree(tr.state)
229
+ ? tables(tr.state)
230
+ : v,
117
231
  provide: (f) => EditorView.decorations.from(f),
118
232
  });
119
233
 
120
234
  // A link to the board: where it goes, what the chip says, and whether it is there.
121
- const ICON: Record<string, string> = { idea: "idea", starting: "idea", running: "run", stalled: "stall", waiting: "wait", done: "done", abandoned: "aband" };
235
+ const ICON: Record<string, string> = {
236
+ idea: "idea",
237
+ starting: "idea",
238
+ running: "run",
239
+ stalled: "stall",
240
+ waiting: "wait",
241
+ done: "done",
242
+ abandoned: "aband",
243
+ };
122
244
  function boardTarget(l: BoardLink) {
123
245
  const q = (o: Record<string, string>) => "/?" + new URLSearchParams({ all: "1", dir, ...o }).toString();
124
246
  if (l.kind === "idea") {
125
247
  const t = l.text.toLowerCase();
126
- const idea = board.ideas.find((i) => i.text.toLowerCase() === t) ?? board.ideas.find((i) => i.text.toLowerCase().startsWith(t));
127
- return { name: idea?.text ?? l.text, sub: "idea", status: idea?.status ?? "", href: idea ? q({ idea: idea.text }) : null };
248
+ const idea =
249
+ board.ideas.find((i) => i.text.toLowerCase() === t) ??
250
+ board.ideas.find((i) => i.text.toLowerCase().startsWith(t));
251
+ return {
252
+ name: idea?.text ?? l.text,
253
+ sub: "idea",
254
+ status: idea?.status ?? "",
255
+ href: idea ? q({ idea: idea.text }) : null,
256
+ };
128
257
  }
129
- const id = resolveRun(l.id, board.runs.map((r) => r.id)), run = board.runs.find((r) => r.id === id);
258
+ const id = resolveRun(
259
+ l.id,
260
+ board.runs.map((r) => r.id),
261
+ ),
262
+ run = board.runs.find((r) => r.id === id);
130
263
  const item = l.item && run && (l.item.type === "finding" ? run.findings : run.backlog).some((x) => x.n === l.item!.n);
131
264
  const ok = !!run && (!l.item || item);
132
265
  return {
133
- name: run?.short ?? l.id, sub: anchorWords(l), status: run?.status ?? "",
134
- href: ok ? q({ run: run!.id, ...(l.tab ? { tab: l.tab } : {}), ...(l.item ? { item: `${l.item.type}-${l.item.n}` } : {}) }) : null,
266
+ name: run?.short ?? l.id,
267
+ sub: anchorWords(l),
268
+ status: run?.status ?? "",
269
+ href: ok
270
+ ? q({ run: run!.id, ...(l.tab ? { tab: l.tab } : {}), ...(l.item ? { item: `${l.item.type}-${l.item.n}` } : {}) })
271
+ : null,
135
272
  };
136
273
  }
137
274
  // Leave for the board only once the note is on disk.
138
275
  async function go(href: string) {
139
276
  await flush();
140
- if (dirty) { toast("This note is not saved yet"); return; }
277
+ if (dirty) {
278
+ toast("This note is not saved yet");
279
+ return;
280
+ }
141
281
  location.href = href;
142
282
  }
143
283
  class BoardChip extends WidgetType {
144
- constructor(readonly name: string, readonly sub: string, readonly status: string, readonly href: string | null) { super(); }
145
- eq(o: BoardChip) { return o.name === this.name && o.sub === this.sub && o.status === this.status && o.href === this.href; }
284
+ constructor(
285
+ readonly name: string,
286
+ readonly sub: string,
287
+ readonly status: string,
288
+ readonly href: string | null,
289
+ ) {
290
+ super();
291
+ }
292
+ eq(o: BoardChip) {
293
+ return o.name === this.name && o.sub === this.sub && o.status === this.status && o.href === this.href;
294
+ }
146
295
  toDOM() {
147
296
  const c = document.createElement("span");
148
297
  c.className = "cm-board" + (this.href ? "" : " cm-board-missing");
@@ -154,125 +303,234 @@ class BoardChip extends WidgetType {
154
303
  c.append(ic);
155
304
  }
156
305
  c.append(this.name);
157
- if (this.sub) { const s = document.createElement("span"); s.className = "sub"; s.textContent = "› " + this.sub; c.append(s); }
158
- c.addEventListener("mousedown", (e) => { e.preventDefault(); if (this.href) void go(this.href); else toast("That is not on the board"); });
306
+ if (this.sub) {
307
+ const s = document.createElement("span");
308
+ s.className = "sub";
309
+ s.textContent = "› " + this.sub;
310
+ c.append(s);
311
+ }
312
+ c.addEventListener("mousedown", (e) => {
313
+ e.preventDefault();
314
+ if (this.href) void go(this.href);
315
+ else toast("That is not on the board");
316
+ });
159
317
  return c;
160
318
  }
161
- ignoreEvent() { return true; }
319
+ ignoreEvent() {
320
+ return true;
321
+ }
162
322
  }
163
323
 
164
324
  const hide = Decoration.replace({});
165
- const refresh = StateEffect.define<null>(); // the list of notes changed: redraw missing links
325
+ const refresh = StateEffect.define<null>(); // the list of notes changed: redraw missing links
166
326
 
167
327
  function build(view: EditorView): DecorationSet {
168
- const { state } = view, doc = state.doc, out: Range<Decoration>[] = [];
328
+ const { state } = view,
329
+ doc = state.doc,
330
+ out: Range<Decoration>[] = [];
169
331
  const live = new Set<number>();
170
332
  // Only a focused editor has a cursor line: a note just opened reads fully typeset.
171
- if (view.hasFocus) for (const r of state.selection.ranges) for (let l = doc.lineAt(r.from).number; l <= doc.lineAt(r.to).number; l++) live.add(l);
333
+ if (view.hasFocus)
334
+ for (const r of state.selection.ranges)
335
+ for (let l = doc.lineAt(r.from).number; l <= doc.lineAt(r.to).number; l++) live.add(l);
172
336
  const on = (pos: number) => live.has(doc.lineAt(pos).number);
173
337
  // A plugin may not replace a line break: anything that would is left as written.
174
- const put = (d: Decoration, from: number, to: number) => { if (doc.lineAt(from).number === doc.lineAt(to).number) out.push(d.range(from, to)); };
338
+ const put = (d: Decoration, from: number, to: number) => {
339
+ if (doc.lineAt(from).number === doc.lineAt(to).number) out.push(d.range(from, to));
340
+ };
175
341
  const lines = (from: number, to: number, cls: string) => {
176
- for (let l = doc.lineAt(from).number; l <= doc.lineAt(to).number; l++) out.push(Decoration.line({ class: cls }).range(doc.line(l).from));
342
+ for (let l = doc.lineAt(from).number; l <= doc.lineAt(to).number; l++)
343
+ out.push(Decoration.line({ class: cls }).range(doc.line(l).from));
177
344
  };
178
345
  for (const { from, to } of view.visibleRanges) {
179
346
  syntaxTree(state).iterate({
180
- from, to,
347
+ from,
348
+ to,
181
349
  enter(n) {
182
350
  const name = n.name;
183
351
  const h = /^ATXHeading(\d)$/.exec(name);
184
- if (h) { lines(n.from, n.from, `cm-h cm-h${Math.min(+h[1]!, 4)}`); return; }
352
+ if (h) {
353
+ lines(n.from, n.from, `cm-h cm-h${Math.min(+h[1]!, 4)}`);
354
+ return;
355
+ }
185
356
  if (name === "HeaderMark" && n.node.parent?.name.startsWith("ATX")) {
186
- if (!on(n.from)) put(hide, n.from, Math.min(n.to + (doc.sliceString(n.to, n.to + 1) === " " ? 1 : 0), doc.lineAt(n.from).to));
357
+ if (!on(n.from))
358
+ put(
359
+ hide,
360
+ n.from,
361
+ Math.min(n.to + (doc.sliceString(n.to, n.to + 1) === " " ? 1 : 0), doc.lineAt(n.from).to),
362
+ );
187
363
  return;
188
364
  }
189
- if (name === "EmphasisMark" || name === "StrikethroughMark" || (name === "CodeMark" && n.node.parent?.name === "InlineCode")) {
365
+ if (
366
+ name === "EmphasisMark" ||
367
+ name === "StrikethroughMark" ||
368
+ (name === "CodeMark" && n.node.parent?.name === "InlineCode")
369
+ ) {
190
370
  if (!on(n.from)) put(hide, n.from, n.to);
191
371
  return;
192
372
  }
193
- if (name === "Blockquote") { lines(n.from, n.to, "cm-quote"); return; }
373
+ if (name === "Blockquote") {
374
+ lines(n.from, n.to, "cm-quote");
375
+ return;
376
+ }
194
377
  if (name === "QuoteMark") {
195
- if (!on(n.from)) put(hide, n.from, Math.min(n.to + (doc.sliceString(n.to, n.to + 1) === " " ? 1 : 0), doc.lineAt(n.from).to));
378
+ if (!on(n.from))
379
+ put(
380
+ hide,
381
+ n.from,
382
+ Math.min(n.to + (doc.sliceString(n.to, n.to + 1) === " " ? 1 : 0), doc.lineAt(n.from).to),
383
+ );
196
384
  return;
197
385
  }
198
386
  if (name === "FencedCode") {
199
387
  lines(n.from, n.to, "cm-fence");
200
- out.push(Decoration.line({ class: "cm-fence-a" }).range(doc.lineAt(n.from).from), Decoration.line({ class: "cm-fence-z" }).range(doc.lineAt(n.to).from));
388
+ out.push(
389
+ Decoration.line({ class: "cm-fence-a" }).range(doc.lineAt(n.from).from),
390
+ Decoration.line({ class: "cm-fence-z" }).range(doc.lineAt(n.to).from),
391
+ );
201
392
  return false;
202
393
  }
203
- if (name === "HorizontalRule") { if (!on(n.from)) put(Decoration.replace({ widget: new Rule() }), n.from, n.to); return; }
394
+ if (name === "HorizontalRule") {
395
+ if (!on(n.from)) put(Decoration.replace({ widget: new Rule() }), n.from, n.to);
396
+ return;
397
+ }
204
398
  if (name === "ListMark" && n.node.parent?.parent?.name === "BulletList") {
205
399
  if (on(n.from)) return;
206
400
  const task = n.node.nextSibling?.name === "Task";
207
- put(task ? hide : Decoration.replace({ widget: new Bullet() }), n.from, task ? Math.min(n.to + 1, doc.lineAt(n.from).to) : n.to);
401
+ put(
402
+ task ? hide : Decoration.replace({ widget: new Bullet() }),
403
+ n.from,
404
+ task ? Math.min(n.to + 1, doc.lineAt(n.from).to) : n.to,
405
+ );
208
406
  return;
209
407
  }
210
408
  if (name === "TaskMarker") {
211
- if (!on(n.from)) put(Decoration.replace({ widget: new Check(/x/i.test(doc.sliceString(n.from, n.to)), n.from) }), n.from, n.to);
409
+ if (!on(n.from))
410
+ put(
411
+ Decoration.replace({ widget: new Check(/x/i.test(doc.sliceString(n.from, n.to)), n.from) }),
412
+ n.from,
413
+ n.to,
414
+ );
212
415
  return;
213
416
  }
214
417
  if (name === "Image") {
215
- const url = n.node.getChild("URL"), marks = n.node.getChildren("LinkMark");
418
+ const url = n.node.getChild("URL"),
419
+ marks = n.node.getChildren("LinkMark");
216
420
  if (on(n.from) || !url || marks.length < 2) return false;
217
421
  const src = safeUrl(doc.sliceString(url.from, url.to));
218
- if (src && /^https?:/i.test(src)) put(Decoration.replace({ widget: new Img(src, doc.sliceString(marks[0]!.to, marks[1]!.from)) }), n.from, n.to);
422
+ if (src && /^https?:/i.test(src))
423
+ put(
424
+ Decoration.replace({ widget: new Img(src, doc.sliceString(marks[0]!.to, marks[1]!.from)) }),
425
+ n.from,
426
+ n.to,
427
+ );
219
428
  return false;
220
429
  }
221
430
  if (name === "Link") {
222
- const url = n.node.getChild("URL"), marks = n.node.getChildren("LinkMark");
431
+ const url = n.node.getChild("URL"),
432
+ marks = n.node.getChildren("LinkMark");
223
433
  // Only the inline form `[text](url)`; a reference link stays as written.
224
434
  if (!url || marks.length < 4) return;
225
435
  const href = safeUrl(doc.sliceString(url.from, url.to));
226
436
  const text = { from: marks[0]!.to, to: marks[1]!.from };
227
437
  // A link to somewhere safeUrl refuses stays as written, so what it points at is visible.
228
- if (on(n.from) || !href) { out.push(Decoration.mark({ class: "cm-link-src" }).range(n.from, n.to)); return; }
229
- if (text.to > text.from) out.push(Decoration.mark({ class: "cm-link", attributes: { "data-href": href } }).range(text.from, text.to));
230
- put(hide, marks[0]!.from, marks[0]!.to); put(hide, marks[1]!.from, n.to);
438
+ if (on(n.from) || !href) {
439
+ out.push(Decoration.mark({ class: "cm-link-src" }).range(n.from, n.to));
440
+ return;
441
+ }
442
+ if (text.to > text.from)
443
+ out.push(
444
+ Decoration.mark({ class: "cm-link", attributes: { "data-href": href } }).range(text.from, text.to),
445
+ );
446
+ put(hide, marks[0]!.from, marks[0]!.to);
447
+ put(hide, marks[1]!.from, n.to);
231
448
  return;
232
449
  }
233
450
  if (name === "URL" && n.node.parent?.name !== "Link" && n.node.parent?.name !== "Image") {
234
451
  const href = safeUrl(doc.sliceString(n.from, n.to));
235
- out.push(Decoration.mark({ class: "cm-link", attributes: href && !on(n.from) ? { "data-href": href } : {} }).range(n.from, n.to));
452
+ out.push(
453
+ Decoration.mark({ class: "cm-link", attributes: href && !on(n.from) ? { "data-href": href } : {} }).range(
454
+ n.from,
455
+ n.to,
456
+ ),
457
+ );
236
458
  }
237
459
  },
238
460
  });
239
461
  }
240
462
  // Wikilinks and tags are not markdown: found by notes-lib over the text.
241
463
  // ponytail: the whole note is scanned on each redraw; limit to visible ranges if notes reach megabytes.
242
- const text = doc.toString(), names = notes.map((x) => x.name);
464
+ const text = doc.toString(),
465
+ names = notes.map((x) => x.name);
243
466
  for (const w of wikilinks(text)) {
244
467
  const bl = boardLink(w);
245
468
  if (bl) {
246
- if (on(w.from)) { out.push(Decoration.mark({ class: "cm-wiki" }).range(w.from, w.to)); continue; }
469
+ if (on(w.from)) {
470
+ out.push(Decoration.mark({ class: "cm-wiki" }).range(w.from, w.to));
471
+ continue;
472
+ }
247
473
  const t = boardTarget(bl);
248
- put(Decoration.replace({ widget: new BoardChip(w.label !== w.target ? w.label : t.name, t.sub, t.status, t.href) }), w.from, w.to);
474
+ put(
475
+ Decoration.replace({ widget: new BoardChip(w.label !== w.target ? w.label : t.name, t.sub, t.status, t.href) }),
476
+ w.from,
477
+ w.to,
478
+ );
249
479
  continue;
250
480
  }
251
481
  const target = resolve(w.name, names);
252
482
  const cls = "cm-wiki" + (target ? "" : " cm-wiki-missing");
253
- if (on(w.from)) { out.push(Decoration.mark({ class: cls }).range(w.from, w.to)); continue; }
483
+ if (on(w.from)) {
484
+ out.push(Decoration.mark({ class: cls }).range(w.from, w.to));
485
+ continue;
486
+ }
254
487
  const labelAt = text.lastIndexOf(w.label, w.to - 2);
255
- const lf = labelAt > w.from ? labelAt : w.from + 2, lt = lf + w.label.length;
256
- put(hide, w.from, lf); out.push(Decoration.mark({ class: cls, attributes: { "data-note": target ?? w.name } }).range(lf, lt)); put(hide, lt, w.to);
488
+ const lf = labelAt > w.from ? labelAt : w.from + 2,
489
+ lt = lf + w.label.length;
490
+ put(hide, w.from, lf);
491
+ out.push(Decoration.mark({ class: cls, attributes: { "data-note": target ?? w.name } }).range(lf, lt));
492
+ put(hide, lt, w.to);
257
493
  }
258
- for (const g of tags(text)) out.push(Decoration.mark({ class: "cm-tag", attributes: on(g.from) ? {} : { "data-tag": g.tag } }).range(g.from, g.to));
494
+ for (const g of tags(text))
495
+ out.push(
496
+ Decoration.mark({ class: "cm-tag", attributes: on(g.from) ? {} : { "data-tag": g.tag } }).range(g.from, g.to),
497
+ );
259
498
  return Decoration.set(out, true);
260
499
  }
261
500
 
262
- const preview = ViewPlugin.fromClass(class {
263
- decorations: DecorationSet;
264
- constructor(view: EditorView) { this.decorations = build(view); }
265
- update(u: ViewUpdate) {
266
- if (u.docChanged || u.viewportChanged || u.selectionSet || u.focusChanged || syntaxTree(u.startState) !== syntaxTree(u.state)
267
- || u.transactions.some((tr) => tr.effects.some((e) => e.is(refresh)))) this.decorations = build(u.view);
268
- }
269
- }, { decorations: (v) => v.decorations });
501
+ const preview = ViewPlugin.fromClass(
502
+ class {
503
+ decorations: DecorationSet;
504
+ constructor(view: EditorView) {
505
+ this.decorations = build(view);
506
+ }
507
+ update(u: ViewUpdate) {
508
+ if (
509
+ u.docChanged ||
510
+ u.viewportChanged ||
511
+ u.selectionSet ||
512
+ u.focusChanged ||
513
+ syntaxTree(u.startState) !== syntaxTree(u.state) ||
514
+ u.transactions.some((tr) => tr.effects.some((e) => e.is(refresh)))
515
+ )
516
+ this.decorations = build(u.view);
517
+ }
518
+ },
519
+ { decorations: (v) => v.decorations },
520
+ );
270
521
 
271
522
  const look = HighlightStyle.define([
272
523
  { tag: t.emphasis, fontStyle: "italic" },
273
524
  { tag: t.strong, fontWeight: "600" },
274
525
  { tag: t.strikethrough, textDecoration: "line-through" },
275
- { tag: t.monospace, fontFamily: "var(--mono)", fontSize: "0.86em", background: "var(--bg)", borderRadius: "var(--r-s)", padding: "1px 4px" },
526
+ {
527
+ tag: t.monospace,
528
+ fontFamily: "var(--mono)",
529
+ fontSize: "0.86em",
530
+ background: "var(--bg)",
531
+ borderRadius: "var(--r-s)",
532
+ padding: "1px 4px",
533
+ },
276
534
  { tag: [t.processingInstruction, t.meta, t.url, t.labelName, t.contentSeparator], color: "var(--dim)" },
277
535
  { tag: t.link, color: "var(--accent)" },
278
536
  { tag: t.quote, color: "var(--dim)" },
@@ -287,12 +545,23 @@ const clicks = EditorView.domEventHandlers({
287
545
  const href = el.dataset.href;
288
546
  // A relative link is a note (`[x](Other%20note.md)`), as in Obsidian; the rest open in a tab.
289
547
  if (href && !/^[a-z][a-z0-9+.-]*:/i.test(href)) {
290
- let name = href; try { name = decodeURIComponent(href); } catch { /* as written */ }
291
- const hit = resolve(name.split("#")[0]!, notes.map((x) => x.name));
548
+ let name = href;
549
+ try {
550
+ name = decodeURIComponent(href);
551
+ } catch {
552
+ /* as written */
553
+ }
554
+ const hit = resolve(
555
+ name.split("#")[0]!,
556
+ notes.map((x) => x.name),
557
+ );
292
558
  hit ? void openNote(hit) : toast(`No note named ${name}`);
293
559
  } else if (href) window.open(href, "_blank", "noopener");
294
560
  else if (el.dataset.note) openOrCreate(el.dataset.note);
295
- else if (el.dataset.tag) { $<HTMLInputElement>("q").value = "#" + el.dataset.tag; drawSide(); }
561
+ else if (el.dataset.tag) {
562
+ $<HTMLInputElement>("q").value = "#" + el.dataset.tag;
563
+ drawSide();
564
+ }
296
565
  return true;
297
566
  },
298
567
  });
@@ -325,14 +594,27 @@ function slash(cx: CompletionContext) {
325
594
  validFor: /^\/[\w ]*$/,
326
595
  // Boosted in list order, so an unfiltered menu reads as written above.
327
596
  options: BLOCKS.map(([label, src, detail], i): Completion => ({
328
- label: "/" + label.toLowerCase(), displayLabel: label, detail, boost: 50 - i,
597
+ label: "/" + label.toLowerCase(),
598
+ displayLabel: label,
599
+ detail,
600
+ boost: 50 - i,
329
601
  apply: (view, _c, from, to) => {
330
602
  // Straight under a line of text, `---` would underline it into a heading and a
331
603
  // table would join its paragraph: both need a blank line above.
332
604
  const line = view.state.doc.lineAt(from);
333
- const gap = (label === "Divider" || label === "Table") && line.number > 1 && view.state.doc.line(line.number - 1).text.trim() ? "\n" : "";
334
- const text = gap + src.replace("|", ""), caret = from + gap.length + src.indexOf("|");
335
- view.dispatch({ changes: { from, to, insert: text }, selection: { anchor: caret }, userEvent: "input.complete" });
605
+ const gap =
606
+ (label === "Divider" || label === "Table") &&
607
+ line.number > 1 &&
608
+ view.state.doc.line(line.number - 1).text.trim()
609
+ ? "\n"
610
+ : "";
611
+ const text = gap + src.replace("|", ""),
612
+ caret = from + gap.length + src.indexOf("|");
613
+ view.dispatch({
614
+ changes: { from, to, insert: text },
615
+ selection: { anchor: caret },
616
+ userEvent: "input.complete",
617
+ });
336
618
  // A link is only begun: the next list offers what it can point at.
337
619
  if (label.startsWith("Link to")) setTimeout(() => startCompletion(view));
338
620
  },
@@ -346,33 +628,62 @@ function slash(cx: CompletionContext) {
346
628
  function links(cx: CompletionContext) {
347
629
  const m = cx.matchBefore(/\[\[[^\]\n|]*$/);
348
630
  if (!m) return null;
349
- const q = m.text.slice(2), close = cx.state.sliceDoc(cx.pos, cx.pos + 2) === "]]";
631
+ const q = m.text.slice(2),
632
+ close = cx.state.sliceDoc(cx.pos, cx.pos + 2) === "]]";
350
633
  const put = (text: string, stay: boolean) => (view: EditorView, _c: Completion, from: number, to: number) => {
351
- view.dispatch({ changes: { from, to, insert: text + (close ? "" : "]]") }, selection: { anchor: from + text.length + (stay ? 0 : 2) }, userEvent: "input.complete" });
634
+ view.dispatch({
635
+ changes: { from, to, insert: text + (close ? "" : "]]") },
636
+ selection: { anchor: from + text.length + (stay ? 0 : 2) },
637
+ userEvent: "input.complete",
638
+ });
352
639
  };
353
640
  const anchor = /^run:\s*([\w.-]+)#([\w-]*)$/i.exec(q);
354
641
  if (anchor) {
355
- const id = resolveRun(anchor[1]!, board.runs.map((r) => r.id)), run = board.runs.find((r) => r.id === id);
642
+ const id = resolveRun(
643
+ anchor[1]!,
644
+ board.runs.map((r) => r.id),
645
+ ),
646
+ run = board.runs.find((r) => r.id === id);
356
647
  if (!run) return null;
357
- const cut = (t: string) => t.length > 60 ? t.slice(0, 59) + "…" : t;
648
+ const cut = (t: string) => (t.length > 60 ? t.slice(0, 59) + "…" : t);
358
649
  return {
359
- from: cx.pos - anchor[2]!.length, validFor: /^[\w-]*$/,
650
+ from: cx.pos - anchor[2]!.length,
651
+ validFor: /^[\w-]*$/,
360
652
  options: [
361
653
  ...TABS.map((t, i): Completion => ({ label: t, detail: "tab", boost: 20 - i, apply: put(t, false) })),
362
- ...run.findings.map((f): Completion => ({ label: `finding-${f.n}`, detail: cut(f.text) + (f.done ? " (closed)" : ""), apply: put(`finding-${f.n}`, false) })),
363
- ...run.backlog.map((b): Completion => ({ label: `backlog-${b.n}`, detail: `${b.status}: ${cut(b.text)}`, apply: put(`backlog-${b.n}`, false) })),
654
+ ...run.findings.map((f): Completion => ({
655
+ label: `finding-${f.n}`,
656
+ detail: cut(f.text) + (f.done ? " (closed)" : ""),
657
+ apply: put(`finding-${f.n}`, false),
658
+ })),
659
+ ...run.backlog.map((b): Completion => ({
660
+ label: `backlog-${b.n}`,
661
+ detail: `${b.status}: ${cut(b.text)}`,
662
+ apply: put(`backlog-${b.n}`, false),
663
+ })),
364
664
  ],
365
665
  };
366
666
  }
367
667
  return {
368
- from: m.from + 2, validFor: /^[^\]\n|#]*$/,
668
+ from: m.from + 2,
669
+ validFor: /^[^\]\n|#]*$/,
369
670
  options: [
370
671
  ...notes.map((n): Completion => ({ label: n.name, detail: "note", apply: put(n.name, false) })),
371
672
  // Newest first, by the name the board shows; the full id is what gets written.
372
- ...[...board.runs].sort((a, b) => b.id.localeCompare(a.id)).map((r, i): Completion => ({
373
- label: "run:" + r.id, displayLabel: r.short, detail: r.id.slice(0, 10) + " · " + r.title, boost: Math.max(-99, 60 - i), apply: put("run:" + r.id, true),
673
+ ...[...board.runs]
674
+ .sort((a, b) => b.id.localeCompare(a.id))
675
+ .map((r, i): Completion => ({
676
+ label: "run:" + r.id,
677
+ displayLabel: r.short,
678
+ detail: r.id.slice(0, 10) + " · " + r.title,
679
+ boost: Math.max(-99, 60 - i),
680
+ apply: put("run:" + r.id, true),
681
+ })),
682
+ ...board.ideas.map((i): Completion => ({
683
+ label: "idea:" + i.text,
684
+ detail: "idea",
685
+ apply: put("idea:" + i.text, false),
374
686
  })),
375
- ...board.ideas.map((i): Completion => ({ label: "idea:" + i.text, detail: "idea", apply: put("idea:" + i.text, false) })),
376
687
  ],
377
688
  };
378
689
  }
@@ -380,14 +691,37 @@ function links(cx: CompletionContext) {
380
691
  const exts = (crlf: boolean) => [
381
692
  autocompletion({ override: [slash, links], icons: false }),
382
693
  history(),
383
- keymap.of([...markdownKeymap, ...defaultKeymap, ...historyKeymap, indentWithTab, { key: "Mod-s", run: () => { void flush(); return true; } }]),
694
+ keymap.of([
695
+ ...markdownKeymap,
696
+ ...defaultKeymap,
697
+ ...historyKeymap,
698
+ indentWithTab,
699
+ {
700
+ key: "Mod-s",
701
+ run: () => {
702
+ void flush();
703
+ return true;
704
+ },
705
+ },
706
+ ]),
384
707
  markdown({ base: markdownLanguage }),
385
708
  syntaxHighlighting(look),
386
709
  EditorView.lineWrapping,
387
710
  EditorView.contentAttributes.of({ "aria-label": "Note", spellcheck: "true" }),
388
- focused, EditorView.focusChangeEffect.of((_s, on) => focusSet.of(on)), tableField,
389
- preview, clicks,
390
- EditorView.updateListener.of((u) => { if (u.docChanged && !loading) { dirty = true; conflict = null; drawMeta(); clearTimeout(timer); timer = setTimeout(() => void flush(), 600); } }),
711
+ focused,
712
+ EditorView.focusChangeEffect.of((_s, on) => focusSet.of(on)),
713
+ tableField,
714
+ preview,
715
+ clicks,
716
+ EditorView.updateListener.of((u) => {
717
+ if (u.docChanged && !loading) {
718
+ dirty = true;
719
+ conflict = null;
720
+ drawMeta();
721
+ clearTimeout(timer);
722
+ timer = setTimeout(() => void flush(), 600);
723
+ }
724
+ }),
391
725
  crlf ? EditorState.lineSeparator.of("\r\n") : [],
392
726
  ];
393
727
 
@@ -397,7 +731,11 @@ const view = new EditorView({ parent: $("editor"), state: EditorState.create({ d
397
731
  function setText(text: string, fresh: boolean) {
398
732
  loading = true;
399
733
  if (fresh) view.setState(EditorState.create({ doc: text, extensions: exts(text.includes("\r\n")) }));
400
- else view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: text }, selection: { anchor: Math.min(view.state.selection.main.head, text.length) } });
734
+ else
735
+ view.dispatch({
736
+ changes: { from: 0, to: view.state.doc.length, insert: text },
737
+ selection: { anchor: Math.min(view.state.selection.main.head, text.length) },
738
+ });
401
739
  loading = false;
402
740
  }
403
741
 
@@ -408,37 +746,68 @@ async function flush(): Promise<void> {
408
746
  clearTimeout(timer);
409
747
  if (saving) await saving;
410
748
  if (!open || !dirty || conflict) return;
411
- const name = open, text = view.state.sliceDoc();
749
+ const name = open,
750
+ text = view.state.sliceDoc();
412
751
  let saved = false;
413
752
  saving = (async () => {
414
753
  drawMeta("Saving…");
415
754
  const r = await post({ op: "save", name, text, base });
416
755
  if (open !== name) return;
417
- if (r.ok) { saved = true; base = r.mtime; if (view.state.sliceDoc() === text) dirty = false; upsert({ name, mtime: r.mtime, text }); }
418
- else if (r.status === 409) conflict = { text: r.text ?? "", mtime: r.mtime ?? 0 };
756
+ if (r.ok) {
757
+ saved = true;
758
+ base = r.mtime;
759
+ if (view.state.sliceDoc() === text) dirty = false;
760
+ upsert({ name, mtime: r.mtime, text });
761
+ } else if (r.status === 409) conflict = { text: r.text ?? "", mtime: r.mtime ?? 0 };
419
762
  else toast(r.error || "Not saved");
420
763
  })();
421
- await saving; saving = null;
764
+ await saving;
765
+ saving = null;
422
766
  drawMeta();
423
767
  // Typed on while that save was out: save again. A failed save waits for the next keystroke.
424
- if (saved && dirty && !conflict && open === name) { clearTimeout(timer); timer = setTimeout(() => void flush(), 600); }
768
+ if (saved && dirty && !conflict && open === name) {
769
+ clearTimeout(timer);
770
+ timer = setTimeout(() => void flush(), 600);
771
+ }
772
+ }
773
+ function upsert(n: Note) {
774
+ notes = [n, ...notes.filter((x) => x.name !== n.name)];
775
+ drawSide();
776
+ drawLinks();
425
777
  }
426
- function upsert(n: Note) { notes = [n, ...notes.filter((x) => x.name !== n.name)]; drawSide(); drawLinks(); }
427
778
 
428
779
  // ---- the vault
429
780
 
430
781
  async function load() {
431
782
  let r: { notes?: Note[]; error?: string };
432
- try { r = await (await fetch("/vault?dir=" + encodeURIComponent(dir))).json(); } catch { toast("The board is not running"); return; }
433
- if (!r.notes) { toast(r.error || "Could not read notes"); return; }
783
+ try {
784
+ r = await (await fetch("/vault?dir=" + encodeURIComponent(dir))).json();
785
+ } catch {
786
+ toast("The board is not running");
787
+ return;
788
+ }
789
+ if (!r.notes) {
790
+ toast(r.error || "Could not read notes");
791
+ return;
792
+ }
434
793
  notes = r.notes;
435
794
  // The board's items, for chips and completion; a failure keeps the last ones.
436
- try { const b = await (await fetch("/links?dir=" + encodeURIComponent(dir))).json(); if (Array.isArray(b.runs)) board = b; } catch { /* keep */ }
795
+ try {
796
+ const b = await (await fetch("/links?dir=" + encodeURIComponent(dir))).json();
797
+ if (Array.isArray(b.runs)) board = b;
798
+ } catch {
799
+ /* keep */
800
+ }
437
801
  const mine = open && notes.find((n) => n.name === open);
438
802
  // Someone else changed the open note: take it only when nothing typed here is unsaved.
439
- if (mine && mine.mtime !== base && !dirty && !saving) { base = mine.mtime; setText(mine.text, false); }
803
+ if (mine && mine.mtime !== base && !dirty && !saving) {
804
+ base = mine.mtime;
805
+ setText(mine.text, false);
806
+ }
440
807
  view.dispatch({ effects: refresh.of(null) });
441
- drawSide(); drawLinks(); drawPane();
808
+ drawSide();
809
+ drawLinks();
810
+ drawPane();
442
811
  }
443
812
 
444
813
  // Laying down a page: the note being left is copied onto a sheet under the paper
@@ -451,12 +820,18 @@ const DEAL = { duration: 260, easing: "cubic-bezier(.22,.61,.36,1)" };
451
820
  function lift(): HTMLElement | null {
452
821
  const main = document.querySelector<HTMLElement>(".main")!;
453
822
  if (matchMedia("(prefers-reduced-motion: reduce)").matches) return null;
454
- const r = main.getBoundingClientRect(), top = main.scrollTop;
823
+ const r = main.getBoundingClientRect(),
824
+ top = main.scrollTop;
455
825
  const sheet = main.cloneNode(true) as HTMLElement;
456
826
  sheet.classList.add("sheet");
457
827
  sheet.setAttribute("aria-hidden", "true");
458
828
  sheet.inert = true;
459
- Object.assign(sheet.style, { left: r.left + "px", top: r.top + "px", width: r.width + "px", height: r.height + "px" });
829
+ Object.assign(sheet.style, {
830
+ left: r.left + "px",
831
+ top: r.top + "px",
832
+ width: r.width + "px",
833
+ height: r.height + "px",
834
+ });
460
835
  sheet.append(Object.assign(document.createElement("i"), { className: "shade" }));
461
836
  // After the real page in document order, so every id still finds the real one first.
462
837
  document.body.append(sheet);
@@ -467,10 +842,28 @@ function turn(sheet: HTMLElement | null) {
467
842
  if (!sheet) return;
468
843
  const main = document.querySelector<HTMLElement>(".main")!;
469
844
  main.classList.add("dealt");
470
- const done = () => { main.classList.remove("dealt"); sheet.remove(); };
471
- main.animate([{ transform: "translateX(56px)", opacity: 0 }, { transform: "none", opacity: 1 }], DEAL).finished.then(done, done);
845
+ const done = () => {
846
+ main.classList.remove("dealt");
847
+ sheet.remove();
848
+ };
849
+ main
850
+ .animate(
851
+ [
852
+ { transform: "translateX(56px)", opacity: 0 },
853
+ { transform: "none", opacity: 1 },
854
+ ],
855
+ DEAL,
856
+ )
857
+ .finished.then(done, done);
472
858
  // The old page is gone before the new one is fully up, so the two never read as one.
473
- sheet.animate([{ transform: "none", opacity: 1 }, { transform: "translateX(-24px)", opacity: 0, offset: 0.6 }, { transform: "translateX(-24px)", opacity: 0 }], { ...DEAL, fill: "forwards" });
859
+ sheet.animate(
860
+ [
861
+ { transform: "none", opacity: 1 },
862
+ { transform: "translateX(-24px)", opacity: 0, offset: 0.6 },
863
+ { transform: "translateX(-24px)", opacity: 0 },
864
+ ],
865
+ { ...DEAL, fill: "forwards" },
866
+ );
474
867
  sheet.querySelector<HTMLElement>(".shade")!.animate([{ opacity: 0 }, { opacity: 1 }], { ...DEAL, fill: "forwards" });
475
868
  }
476
869
 
@@ -478,43 +871,87 @@ function turn(sheet: HTMLElement | null) {
478
871
  async function openNote(name: string | null): Promise<boolean> {
479
872
  if (name === open) return true;
480
873
  await flush();
481
- if (dirty) { toast(conflict ? "Choose Keep mine or Load theirs first" : "This note is not saved yet"); return false; }
874
+ if (dirty) {
875
+ toast(conflict ? "Choose Keep mine or Load theirs first" : "This note is not saved yet");
876
+ return false;
877
+ }
482
878
  const sheet = lift();
483
- open = name; conflict = null; confirmDelete = false; dirty = false;
879
+ open = name;
880
+ conflict = null;
881
+ confirmDelete = false;
882
+ dirty = false;
484
883
  const n = name ? notes.find((x) => x.name === name) : null;
485
884
  base = n?.mtime ?? 0;
486
885
  setText(n?.text ?? "", true);
487
- try { name ? sessionStorage.setItem("kz-note:" + dir, name) : sessionStorage.removeItem("kz-note:" + dir); } catch { /* private window */ }
886
+ try {
887
+ name ? sessionStorage.setItem("kz-note:" + dir, name) : sessionStorage.removeItem("kz-note:" + dir);
888
+ } catch {
889
+ /* private window */
890
+ }
488
891
  document.body.classList.remove("side-open");
489
- drawSide(); drawPane(); drawLinks();
892
+ drawSide();
893
+ drawPane();
894
+ drawLinks();
490
895
  turn(sheet);
491
896
  return true;
492
897
  }
493
898
 
494
899
  async function create(name: string) {
495
900
  const r = await post({ op: "save", name, text: "", base: 0 });
496
- if (!r.ok) { toast(r.status === 409 ? "A note with that name exists" : r.error || "Could not create the note"); return false; }
901
+ if (!r.ok) {
902
+ toast(r.status === 409 ? "A note with that name exists" : r.error || "Could not create the note");
903
+ return false;
904
+ }
497
905
  upsert({ name: r.name, mtime: r.mtime, text: "" });
498
906
  return openNote(r.name);
499
907
  }
500
908
  async function openOrCreate(name: string) {
501
- const hit = resolve(name, notes.map((n) => n.name));
909
+ const hit = resolve(
910
+ name,
911
+ notes.map((n) => n.name),
912
+ );
502
913
  if (hit) return void openNote(hit);
503
914
  if (await create(name)) view.focus();
504
915
  }
505
916
  async function newNote() {
506
- if (await create(freshName(notes.map((n) => n.name), open ? folderOf(open) : ""))) { const t = $<HTMLInputElement>("title"); t.focus(); t.select(); }
917
+ if (
918
+ await create(
919
+ freshName(
920
+ notes.map((n) => n.name),
921
+ open ? folderOf(open) : "",
922
+ ),
923
+ )
924
+ ) {
925
+ const t = $<HTMLInputElement>("title");
926
+ t.focus();
927
+ t.select();
928
+ }
507
929
  }
508
930
 
509
931
  async function rename(to: string) {
510
- if (!open || !to.trim() || to.trim() === open) { drawPane(); return; }
932
+ if (!open || !to.trim() || to.trim() === open) {
933
+ drawPane();
934
+ return;
935
+ }
511
936
  const from = open;
512
937
  await flush();
513
938
  const r = await post({ op: "rename", name: from, to });
514
- if (!r.ok) { toast(r.error || "Could not rename"); drawPane(); return; }
939
+ if (!r.ok) {
940
+ toast(r.error || "Could not rename");
941
+ drawPane();
942
+ return;
943
+ }
515
944
  // Another note was opened meanwhile: the rename stands, the editor stays where it is.
516
- if (open !== from) { await load(); return; }
517
- open = to.trim().replace(/\.md$/i, "").split("/").map((p) => p.trim()).join("/");
945
+ if (open !== from) {
946
+ await load();
947
+ return;
948
+ }
949
+ open = to
950
+ .trim()
951
+ .replace(/\.md$/i, "")
952
+ .split("/")
953
+ .map((p) => p.trim())
954
+ .join("/");
518
955
  await load();
519
956
  base = notes.find((n) => n.name === open)?.mtime ?? base;
520
957
  toast(folderOf(from) === folderOf(open) ? "Renamed" : `Moved to ${folderOf(open) || "the top"}`);
@@ -524,9 +961,13 @@ async function remove() {
524
961
  if (!open) return;
525
962
  clearTimeout(timer);
526
963
  const r = await post({ op: "delete", name: open });
527
- if (!r.ok) { toast(r.error || "Could not delete"); return; }
964
+ if (!r.ok) {
965
+ toast(r.error || "Could not delete");
966
+ return;
967
+ }
528
968
  toast("Moved to notes/.trash");
529
- dirty = false; notes = notes.filter((n) => n.name !== open);
969
+ dirty = false;
970
+ notes = notes.filter((n) => n.name !== open);
530
971
  await openNote(null);
531
972
  }
532
973
 
@@ -538,24 +979,36 @@ function drawSide() {
538
979
  const count = new Map<string, number>();
539
980
  for (const n of notes) for (const g of new Set(tags(n.text).map((x) => x.tag))) count.set(g, (count.get(g) ?? 0) + 1);
540
981
  const tg = q.startsWith("#") ? q.slice(1) : null;
541
- $("tags").innerHTML = [...count].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
542
- .map(([g, c]) => `<button class="chip${g === tg ? " on" : ""}" data-tag="${esc(g)}" aria-pressed="${g === tg}">#${esc(g)} <span>${c}</span></button>`).join("");
982
+ $("tags").innerHTML = [...count]
983
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
984
+ .map(
985
+ ([g, c]) =>
986
+ `<button class="chip${g === tg ? " on" : ""}" data-tag="${esc(g)}" aria-pressed="${g === tg}">#${esc(g)} <span>${c}</span></button>`,
987
+ )
988
+ .join("");
543
989
  $("tagsh").hidden = !count.size;
544
990
  const row = (n: string, withFolder = false) => {
545
991
  const note = notes.find((x) => x.name === n);
546
992
  return `<li><button class="nt${n === open ? " cur" : ""}" data-open="${esc(n)}"${n === open ? ' aria-current="true"' : ""}><span class="nm">${esc(last(n))}</span>${withFolder && folderOf(n) ? `<span class="fd">${esc(folderOf(n))}</span>` : ""}<span class="age">${note ? age(note.mtime) : ""}</span></button></li>`;
547
993
  };
548
994
  if (q) {
549
- const hits = notes.filter((n) => tg !== null
550
- ? tags(n.text).some((x) => x.tag === tg || x.tag.startsWith(tg + "/"))
551
- : n.name.toLowerCase().includes(q) || n.text.toLowerCase().includes(q));
552
- $("tree").innerHTML = hits.length ? `<ul>${hits.map((n) => row(n.name, true)).join("")}</ul>` : `<p class="none">No note matches.</p>`;
995
+ const hits = notes.filter((n) =>
996
+ tg !== null
997
+ ? tags(n.text).some((x) => x.tag === tg || x.tag.startsWith(tg + "/"))
998
+ : n.name.toLowerCase().includes(q) || n.text.toLowerCase().includes(q),
999
+ );
1000
+ $("tree").innerHTML = hits.length
1001
+ ? `<ul>${hits.map((n) => row(n.name, true)).join("")}</ul>`
1002
+ : `<p class="none">No note matches.</p>`;
553
1003
  return;
554
1004
  }
555
- const draw = (tr: Tree, path: string): string => `<ul>${[...tr.folders].map(([f, sub]) => {
556
- const p = path + f;
557
- return `<li><details data-folder="${esc(p)}"${shut.has(p) ? "" : " open"}><summary><i class="chev">${ico("chev")}</i>${ico("folder")}<span>${esc(f)}</span></summary>${draw(sub, p + "/")}</details></li>`;
558
- }).join("")}${tr.notes.map((n) => row(n)).join("")}</ul>`;
1005
+ const draw = (tr: Tree, path: string): string =>
1006
+ `<ul>${[...tr.folders]
1007
+ .map(([f, sub]) => {
1008
+ const p = path + f;
1009
+ return `<li><details data-folder="${esc(p)}"${shut.has(p) ? "" : " open"}><summary><i class="chev">${ico("chev")}</i>${ico("folder")}<span>${esc(f)}</span></summary>${draw(sub, p + "/")}</details></li>`;
1010
+ })
1011
+ .join("")}${tr.notes.map((n) => row(n)).join("")}</ul>`;
559
1012
  $("tree").innerHTML = notes.length ? draw(tree(notes.map((n) => n.name)), "") : "";
560
1013
  }
561
1014
 
@@ -563,13 +1016,24 @@ function drawSide() {
563
1016
  // on; it then holds from note to note until the icon or Esc turns it off. With no note
564
1017
  // open the page is never full screen: picking one needs the sidebar.
565
1018
  let full = false;
566
- function setFull(on: boolean) { full = on; drawPane(); }
1019
+ function setFull(on: boolean) {
1020
+ full = on;
1021
+ drawPane();
1022
+ }
567
1023
  // Width: the reading column (boxed, 68ch) or the whole paper side. Kept per browser.
568
1024
  let wide = false;
569
- try { wide = localStorage.getItem("kz-note-wide") === "1"; } catch { /* no storage */ }
1025
+ try {
1026
+ wide = localStorage.getItem("kz-note-wide") === "1";
1027
+ } catch {
1028
+ /* no storage */
1029
+ }
570
1030
  function setWide(on: boolean) {
571
1031
  wide = on;
572
- try { localStorage.setItem("kz-note-wide", on ? "1" : "0"); } catch { /* no storage */ }
1032
+ try {
1033
+ localStorage.setItem("kz-note-wide", on ? "1" : "0");
1034
+ } catch {
1035
+ /* no storage */
1036
+ }
573
1037
  drawPane();
574
1038
  }
575
1039
 
@@ -591,26 +1055,49 @@ function drawMeta(state?: string) {
591
1055
  if (!open) return;
592
1056
  if (conflict) {
593
1057
  m.innerHTML = `<span class="warnw">This note changed somewhere else since you opened it.</span><span class="acts"><button class="btn ghost" id="theirs">Load theirs</button><button class="btn pri" id="mine">Keep mine</button></span>`;
594
- $("theirs").onclick = () => { const c = conflict!; conflict = null; dirty = false; base = c.mtime; setText(c.text, false); drawMeta(); };
595
- $("mine").onclick = () => { base = conflict!.mtime; conflict = null; void flush(); };
1058
+ $("theirs").onclick = () => {
1059
+ const c = conflict!;
1060
+ conflict = null;
1061
+ dirty = false;
1062
+ base = c.mtime;
1063
+ setText(c.text, false);
1064
+ drawMeta();
1065
+ };
1066
+ $("mine").onclick = () => {
1067
+ base = conflict!.mtime;
1068
+ conflict = null;
1069
+ void flush();
1070
+ };
596
1071
  return;
597
1072
  }
598
1073
  if (confirmDelete) {
599
1074
  m.innerHTML = `<span>Move this note to <code>notes/.trash</code>?</span><span class="acts"><button class="btn ghost" id="cancel">Cancel</button><button class="btn danger" id="del">Delete</button></span>`;
600
- $("cancel").onclick = () => { confirmDelete = false; drawMeta(); };
601
- $("del").onclick = () => { confirmDelete = false; void remove(); };
1075
+ $("cancel").onclick = () => {
1076
+ confirmDelete = false;
1077
+ drawMeta();
1078
+ };
1079
+ $("del").onclick = () => {
1080
+ confirmDelete = false;
1081
+ void remove();
1082
+ };
602
1083
  return;
603
1084
  }
604
1085
  const where = folderOf(open);
605
1086
  m.innerHTML = `<span>${where ? esc(where) : "Notes"}</span><span>·</span><span id="saved">${state ?? (dirty ? "Not saved yet" : "Saved")}</span><span class="sp"></span><button class="ib" id="wide" title="${wide ? "Boxed width" : "Full width"}" aria-label="${wide ? "Boxed width" : "Full width"}" aria-pressed="${wide}">${ico(wide ? "box" : "wide")}</button><button class="ib" id="full" title="${full ? "Exit full screen (Esc)" : "Full screen"}" aria-label="${full ? "Exit full screen" : "Full screen"}">${ico(full ? "min" : "max")}</button><button class="ib" id="trash" title="Delete note" aria-label="Delete note">${ico("trash")}</button>`;
606
- $("trash").onclick = () => { confirmDelete = true; drawMeta(); };
1087
+ $("trash").onclick = () => {
1088
+ confirmDelete = true;
1089
+ drawMeta();
1090
+ };
607
1091
  $("full").onclick = () => setFull(!full);
608
1092
  $("wide").onclick = () => setWide(!wide);
609
1093
  }
610
1094
 
611
1095
  function drawLinks() {
612
1096
  const box = $("links");
613
- if (!open) { box.hidden = true; return; }
1097
+ if (!open) {
1098
+ box.hidden = true;
1099
+ return;
1100
+ }
614
1101
  const names = notes.map((n) => n.name);
615
1102
  const from = notes.filter((n) => n.name !== open && wikilinks(n.text).some((l) => resolve(l.name, names) === open));
616
1103
  box.hidden = !from.length;
@@ -619,7 +1106,13 @@ function drawLinks() {
619
1106
 
620
1107
  function age(ms: number) {
621
1108
  const s = (Date.now() - ms) / 1000;
622
- return s < 60 ? "now" : s < 3600 ? `${Math.floor(s / 60)}m` : s < 86400 ? `${Math.floor(s / 3600)}h` : `${Math.floor(s / 86400)}d`;
1109
+ return s < 60
1110
+ ? "now"
1111
+ : s < 3600
1112
+ ? `${Math.floor(s / 60)}m`
1113
+ : s < 86400
1114
+ ? `${Math.floor(s / 3600)}h`
1115
+ : `${Math.floor(s / 86400)}d`;
623
1116
  }
624
1117
 
625
1118
  // ---- wiring
@@ -627,24 +1120,39 @@ function age(ms: number) {
627
1120
  document.addEventListener("click", (e) => {
628
1121
  // The narrow layout's drawer closes on any click outside it.
629
1122
  const tg = e.target as HTMLElement;
630
- if (document.body.classList.contains("side-open") && !tg.closest?.(".side,#side")) document.body.classList.remove("side-open");
1123
+ if (document.body.classList.contains("side-open") && !tg.closest?.(".side,#side"))
1124
+ document.body.classList.remove("side-open");
631
1125
  const el = (e.target as HTMLElement).closest?.("[data-open],[data-tag]") as HTMLElement | null;
632
1126
  if (!el || el.closest(".cm-editor")) return;
633
1127
  if (el.dataset.open) void openNote(el.dataset.open);
634
- else if (el.dataset.tag) { const q = $<HTMLInputElement>("q"); q.value = q.value === "#" + el.dataset.tag ? "" : "#" + el.dataset.tag; drawSide(); }
1128
+ else if (el.dataset.tag) {
1129
+ const q = $<HTMLInputElement>("q");
1130
+ q.value = q.value === "#" + el.dataset.tag ? "" : "#" + el.dataset.tag;
1131
+ drawSide();
1132
+ }
635
1133
  });
636
- $("tree").addEventListener("toggle", (e) => {
637
- const d = e.target as HTMLDetailsElement;
638
- if (d.dataset.folder) d.open ? shut.delete(d.dataset.folder) : shut.add(d.dataset.folder);
639
- }, true);
1134
+ $("tree").addEventListener(
1135
+ "toggle",
1136
+ (e) => {
1137
+ const d = e.target as HTMLDetailsElement;
1138
+ if (d.dataset.folder) d.open ? shut.delete(d.dataset.folder) : shut.add(d.dataset.folder);
1139
+ },
1140
+ true,
1141
+ );
640
1142
  $("q").addEventListener("input", drawSide);
641
1143
  $("new").onclick = () => void newNote();
642
1144
  $("new2").onclick = () => void newNote();
643
1145
  $("side").onclick = () => document.body.classList.toggle("side-open");
644
1146
  const title = $<HTMLInputElement>("title");
645
1147
  title.addEventListener("keydown", (e) => {
646
- if (e.key === "Enter") { e.preventDefault(); title.blur(); }
647
- if (e.key === "Escape") { title.value = open ?? ""; title.blur(); }
1148
+ if (e.key === "Enter") {
1149
+ e.preventDefault();
1150
+ title.blur();
1151
+ }
1152
+ if (e.key === "Escape") {
1153
+ title.value = open ?? "";
1154
+ title.blur();
1155
+ }
648
1156
  });
649
1157
  title.addEventListener("blur", () => void rename(title.value));
650
1158
  document.addEventListener("keydown", (e) => {
@@ -654,34 +1162,67 @@ document.addEventListener("keydown", (e) => {
654
1162
  if (document.body.classList.contains("full")) setFull(false);
655
1163
  });
656
1164
  // Closing with text not on disk: the browser asks first.
657
- addEventListener("beforeunload", (e) => { if (dirty) e.preventDefault(); });
1165
+ addEventListener("beforeunload", (e) => {
1166
+ if (dirty) e.preventDefault();
1167
+ });
658
1168
  // Leaving with unsaved text: a keepalive request outlives the page.
659
1169
  addEventListener("pagehide", () => {
660
- if (open && dirty && !conflict) fetch("/vault", { method: "POST", keepalive: true, headers: { "content-type": "application/json" }, body: JSON.stringify({ dir, op: "save", name: open, text: view.state.sliceDoc(), base }) });
1170
+ if (open && dirty && !conflict)
1171
+ fetch("/vault", {
1172
+ method: "POST",
1173
+ keepalive: true,
1174
+ headers: { "content-type": "application/json" },
1175
+ body: JSON.stringify({ dir, op: "save", name: open, text: view.state.sliceDoc(), base }),
1176
+ });
661
1177
  });
662
1178
 
663
1179
  // The project: ?dir= in the URL, else the one the board picked last, else where it started.
664
1180
  (async () => {
665
- try { const th = localStorage.getItem("kz-theme"); if (th) document.documentElement.dataset.theme = th; } catch { /* no storage */ }
1181
+ try {
1182
+ const th = localStorage.getItem("kz-theme");
1183
+ if (th) document.documentElement.dataset.theme = th;
1184
+ } catch {
1185
+ /* no storage */
1186
+ }
666
1187
  let S: { dir: string | null; projects: { dir: string; label: string }[] };
667
- try { S = await (await fetch("/state")).json(); } catch { toast("The board is not running"); return; }
1188
+ try {
1189
+ S = await (await fetch("/state")).json();
1190
+ } catch {
1191
+ toast("The board is not running");
1192
+ return;
1193
+ }
668
1194
  let kept: string | null = null;
669
- try { kept = localStorage.getItem("kz-proj"); } catch { /* no storage */ }
1195
+ try {
1196
+ kept = localStorage.getItem("kz-proj");
1197
+ } catch {
1198
+ /* no storage */
1199
+ }
670
1200
  const want = new URLSearchParams(location.search).get("dir");
671
1201
  const known = (d: string | null) => !!d && S.projects.some((p) => p.dir === d);
672
1202
  dir = [want, kept, S.dir].find(known) ?? S.projects[0]?.dir ?? "";
673
1203
  const sel = $<HTMLSelectElement>("proj");
674
- sel.innerHTML = S.projects.map((p) => `<option value="${esc(p.dir)}"${p.dir === dir ? " selected" : ""}>${esc(p.label)}</option>`).join("");
1204
+ sel.innerHTML = S.projects
1205
+ .map((p) => `<option value="${esc(p.dir)}"${p.dir === dir ? " selected" : ""}>${esc(p.label)}</option>`)
1206
+ .join("");
675
1207
  sel.onchange = async () => {
676
- if (!(await openNote(null))) { sel.value = dir; return; }
1208
+ if (!(await openNote(null))) {
1209
+ sel.value = dir;
1210
+ return;
1211
+ }
677
1212
  dir = sel.value;
678
1213
  // window.: `history` here is CodeMirror's undo extension.
679
- const u = new URL(location.href); u.searchParams.set("dir", dir); window.history.replaceState(null, "", u);
1214
+ const u = new URL(location.href);
1215
+ u.searchParams.set("dir", dir);
1216
+ window.history.replaceState(null, "", u);
680
1217
  await load();
681
1218
  };
682
1219
  await load();
683
1220
  let again: string | null = null;
684
- try { again = sessionStorage.getItem("kz-note:" + dir); } catch { /* no storage */ }
1221
+ try {
1222
+ again = sessionStorage.getItem("kz-note:" + dir);
1223
+ } catch {
1224
+ /* no storage */
1225
+ }
685
1226
  // ?note= is the board's "Linked from notes"; otherwise the note open last in this tab.
686
1227
  const asked = new URLSearchParams(location.search).get("note");
687
1228
  if (asked && notes.some((n) => n.name === asked)) again = asked;
@@ -689,8 +1230,13 @@ addEventListener("pagehide", () => {
689
1230
  // Every write under a state dir arrives as `changed`, this page's own saves included.
690
1231
  const listen = () => {
691
1232
  const es = new EventSource("/events");
692
- es.onmessage = (ev) => { if (ev.data === "changed") void load(); };
693
- es.onerror = () => { es.close(); setTimeout(listen, 3000); };
1233
+ es.onmessage = (ev) => {
1234
+ if (ev.data === "changed") void load();
1235
+ };
1236
+ es.onerror = () => {
1237
+ es.close();
1238
+ setTimeout(listen, 3000);
1239
+ };
694
1240
  };
695
1241
  listen();
696
1242
  })();