wirenote 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. wirenote/__init__.py +3 -0
  2. wirenote/calendar/__init__.py +2 -0
  3. wirenote/calendar/ics.py +236 -0
  4. wirenote/capture/__init__.py +2 -0
  5. wirenote/capture/recorder.py +304 -0
  6. wirenote/capture/wav.py +58 -0
  7. wirenote/cli.py +262 -0
  8. wirenote/config/__init__.py +2 -0
  9. wirenote/config/apps.py +158 -0
  10. wirenote/config/migrate.py +130 -0
  11. wirenote/config/perms.py +61 -0
  12. wirenote/config/secrets.py +98 -0
  13. wirenote/config/settings.py +176 -0
  14. wirenote/config/vocabulary.py +32 -0
  15. wirenote/data/wirenote-ui.service +15 -0
  16. wirenote/data/wirenote.desktop +10 -0
  17. wirenote/data/wirenote.service +15 -0
  18. wirenote/detector/__init__.py +2 -0
  19. wirenote/detector/core.py +115 -0
  20. wirenote/detector/daemon.py +743 -0
  21. wirenote/detector/graph.py +102 -0
  22. wirenote/detector/identity.py +205 -0
  23. wirenote/detector/streams.py +69 -0
  24. wirenote/detector/tracker.py +304 -0
  25. wirenote/net/__init__.py +2 -0
  26. wirenote/net/peer.py +101 -0
  27. wirenote/notify/__init__.py +2 -0
  28. wirenote/notify/clipboard.py +43 -0
  29. wirenote/notify/dbus.py +89 -0
  30. wirenote/process/__init__.py +2 -0
  31. wirenote/process/jobs.py +122 -0
  32. wirenote/process/merge.py +87 -0
  33. wirenote/process/models.py +57 -0
  34. wirenote/process/note.py +124 -0
  35. wirenote/process/speakers.py +295 -0
  36. wirenote/process/summarize.py +287 -0
  37. wirenote/process/transcribe.py +97 -0
  38. wirenote/process/worker.py +229 -0
  39. wirenote/tools/__init__.py +2 -0
  40. wirenote/tools/dump_streams.py +595 -0
  41. wirenote/tools/install.py +140 -0
  42. wirenote/tools/setup.py +338 -0
  43. wirenote/ui/__init__.py +2 -0
  44. wirenote/ui/data.py +783 -0
  45. wirenote/ui/launch.py +88 -0
  46. wirenote/ui/server.py +423 -0
  47. wirenote/ui/static/app.css +907 -0
  48. wirenote/ui/static/app.js +1267 -0
  49. wirenote/ui/static/fonts/OFL.txt +93 -0
  50. wirenote/ui/static/fonts/inter-latin-ext.woff2 +0 -0
  51. wirenote/ui/static/fonts/inter-latin.woff2 +0 -0
  52. wirenote/ui/static/icon.svg +1 -0
  53. wirenote/ui/static/icons.svg +31 -0
  54. wirenote/ui/static/index.html +57 -0
  55. wirenote/ui/static/login.html +19 -0
  56. wirenote-0.1.0.dist-info/METADATA +106 -0
  57. wirenote-0.1.0.dist-info/RECORD +60 -0
  58. wirenote-0.1.0.dist-info/WHEEL +4 -0
  59. wirenote-0.1.0.dist-info/entry_points.txt +2 -0
  60. wirenote-0.1.0.dist-info/licenses/LICENSE +661 -0
@@ -0,0 +1,1267 @@
1
+ // wirenote UI (docs/dev/ui-design.md, docs/dev/features-2-design.md). No framework, no build step.
2
+ // All text from transcripts and notes goes in through textContent (h()), never innerHTML.
3
+
4
+ "use strict";
5
+
6
+ const TOKEN = document.querySelector('meta[name="wirenote-token"]').content;
7
+ const $ = (sel, root = document) => root.querySelector(sel);
8
+
9
+ const state = {
10
+ calls: [],
11
+ query: "",
12
+ status: {},
13
+ call: null, // the open call (full detail)
14
+ view: "home", // home | call | ask | tasks | settings
15
+ tab: "notes",
16
+ asking: false,
17
+ find: "",
18
+ statusKey: "",
19
+ namesChanged: false, // a speaker was renamed since the summary was written
20
+ notesChanged: false, // your notes changed since the summary was written
21
+ showDone: false,
22
+ askingAll: false,
23
+ doctor: null, // /api/doctor result, fetched once per page load
24
+ };
25
+
26
+ // DOM helpers ---------------------------------------------------------------
27
+
28
+ function h(tag, attrs, ...children) {
29
+ const el = document.createElement(tag);
30
+ for (const [k, v] of Object.entries(attrs || {})) {
31
+ if (v === null || v === undefined || v === false) continue;
32
+ if (k === "class") el.className = v;
33
+ else if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
34
+ else if (k === "text") el.textContent = v;
35
+ else if (k === "value") el.value = v;
36
+ else el.setAttribute(k, v === true ? "" : v);
37
+ }
38
+ for (const c of children.flat()) {
39
+ if (c === null || c === undefined || c === false) continue;
40
+ el.append(c instanceof Node ? c : document.createTextNode(String(c)));
41
+ }
42
+ return el;
43
+ }
44
+
45
+ function icon(name, cls = "icon") {
46
+ const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
47
+ svg.setAttribute("class", cls);
48
+ svg.setAttribute("aria-hidden", "true");
49
+ const use = document.createElementNS("http://www.w3.org/2000/svg", "use");
50
+ use.setAttribute("href", `/static/icons.svg#${name}`);
51
+ svg.append(use);
52
+ return svg;
53
+ }
54
+
55
+ // Text with every case-insensitive occurrence of q wrapped in <mark>.
56
+ function highlighted(text, q) {
57
+ const frag = document.createDocumentFragment();
58
+ if (!q) { frag.append(text); return frag; }
59
+ const lower = text.toLowerCase(), needle = q.toLowerCase();
60
+ let i = 0, j;
61
+ while ((j = lower.indexOf(needle, i)) >= 0) {
62
+ frag.append(text.slice(i, j), h("mark", {}, text.slice(j, j + q.length)));
63
+ i = j + q.length;
64
+ }
65
+ frag.append(text.slice(i));
66
+ return frag;
67
+ }
68
+
69
+ function toast(msg) {
70
+ const t = $("#toast");
71
+ t.textContent = msg;
72
+ t.classList.add("show");
73
+ clearTimeout(toast.timer);
74
+ toast.timer = setTimeout(() => t.classList.remove("show"), 2800);
75
+ }
76
+
77
+ function openModal(content, onClose) {
78
+ const modal = $("#modal");
79
+ const close = () => { modal.hidden = true; modal.replaceChildren(); if (onClose) onClose(); };
80
+ modal.replaceChildren(content);
81
+ modal.onclick = (e) => { if (e.target === modal) close(); };
82
+ modal.hidden = false;
83
+ return close;
84
+ }
85
+
86
+ function confirmDialog(title, text, okLabel, danger = true) {
87
+ return new Promise((resolve) => {
88
+ let result = false;
89
+ const ok = h("button", { class: danger ? "btn btn-danger" : "btn btn-primary" }, okLabel);
90
+ const cancel = h("button", { class: "btn" }, "Cancel");
91
+ const close = openModal(h("div", { class: "dialog", role: "dialog", "aria-modal": "true" },
92
+ h("h3", {}, title), h("p", {}, text), h("div", { class: "row" }, cancel, ok)), () => resolve(result));
93
+ ok.onclick = () => { result = true; close(); };
94
+ cancel.onclick = close;
95
+ ok.focus();
96
+ });
97
+ }
98
+
99
+ // API -----------------------------------------------------------------------
100
+
101
+ async function api(path, opts = {}) {
102
+ const init = { method: opts.method || "GET", headers: {} };
103
+ if (init.method !== "GET") {
104
+ init.headers["X-Wirenote-Token"] = TOKEN;
105
+ init.headers["Content-Type"] = "application/json";
106
+ init.body = JSON.stringify(opts.body || {});
107
+ }
108
+ const res = await fetch(path, init);
109
+ if (res.status === 401) { location.reload(); throw new Error("Not logged in. Open Wirenote from the app menu."); }
110
+ const data = await res.json().catch(() => ({}));
111
+ if (!res.ok) throw new Error(data.error || `HTTP ${res.status}`);
112
+ return data;
113
+ }
114
+
115
+ // Formatting ----------------------------------------------------------------
116
+
117
+ const LOCALE = undefined; // the browser's language
118
+ const fmtTime = (d) => d.toLocaleTimeString(LOCALE, { hour: "2-digit", minute: "2-digit" });
119
+ const fmtDate = (d) => d.toLocaleDateString(LOCALE, { weekday: "long", day: "numeric", month: "long" });
120
+ const fmtShort = (d) => d.toLocaleDateString(LOCALE, { day: "numeric", month: "short" });
121
+
122
+ function fmtDuration(s) {
123
+ if (!s) return "";
124
+ const m = Math.round(s / 60);
125
+ if (m < 1) return `${Math.round(s)} s`;
126
+ return m < 60 ? `${m} min` : `${Math.floor(m / 60)} h ${m % 60} min`;
127
+ }
128
+
129
+ function clock(sec) {
130
+ sec = Math.max(0, Math.floor(sec || 0));
131
+ const hh = Math.floor(sec / 3600), mm = Math.floor((sec % 3600) / 60), ss = sec % 60;
132
+ const p = (n) => String(n).padStart(2, "0");
133
+ return hh ? `${hh}:${p(mm)}:${p(ss)}` : `${mm}:${p(ss)}`;
134
+ }
135
+
136
+ function dayGroup(d) {
137
+ const today = new Date(); today.setHours(0, 0, 0, 0);
138
+ const day = new Date(d); day.setHours(0, 0, 0, 0);
139
+ const diff = Math.round((today - day) / 86400000);
140
+ if (diff === 0) return "Today";
141
+ if (diff === 1) return "Yesterday";
142
+ if (diff < 7) return day.toLocaleDateString(LOCALE, { weekday: "long" });
143
+ if (day.getFullYear() === today.getFullYear()) return day.toLocaleDateString(LOCALE, { month: "long" });
144
+ return day.toLocaleDateString(LOCALE, { month: "long", year: "numeric" });
145
+ }
146
+
147
+ // Green is "Me"; grey would read as "unknown".
148
+ const PALETTE = ["#3b6fd8", "#c2571f", "#8a4fc7", "#c23b6b", "#2a8a9a", "#8a7a1a", "#4f5bd5", "#9a5a3a"];
149
+ function colorFor(key) {
150
+ if (key === "Me") return "var(--accent)";
151
+ let hash = 0;
152
+ for (const ch of key || "?") hash = (hash * 31 + ch.charCodeAt(0)) >>> 0;
153
+ return PALETTE[hash % PALETTE.length];
154
+ }
155
+
156
+ function avatar(name, key) {
157
+ const n = (name || "?").trim();
158
+ const initials = n === "Me" ? "Me" : /^Speaker \d+$/.test(n) ? n.split(" ")[1]
159
+ : n.split(/[\s@._-]+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join("");
160
+ const el = h("span", { class: "avatar", "aria-hidden": "true" }, initials.slice(0, 2) || "?");
161
+ el.style.setProperty("background", colorFor(key || n));
162
+ return el;
163
+ }
164
+
165
+ function sourceLabel(c) {
166
+ if (c.app === "manual") return "In person";
167
+ return c.service ? `${c.service} · ${c.name}` : c.name || c.app;
168
+ }
169
+
170
+ // Where a summary or answer was made: on this laptop, or by Claude.
171
+ function cloudBadge(backend) {
172
+ if (!backend) return null;
173
+ if (backend.startsWith("anthropic:")) return h("span", { class: "pill cloud", title: "Sent to Anthropic's Claude API" }, "Sent to Claude");
174
+ if (backend.startsWith("ollama:")) return h("span", { class: "pill local", title: "Made on this laptop" }, "Local");
175
+ return null;
176
+ }
177
+
178
+ function stateBadge(c) {
179
+ if (c.state === "recording") return h("span", { class: "dot rec", title: "Recording" });
180
+ if (c.state === "queued" || c.state === "transcribing" || c.state === "summarizing")
181
+ return h("span", { class: "dot work", title: "Writing the note" });
182
+ if (c.state === "failed") return h("span", { class: "dot fail", title: "Note failed" });
183
+ return null;
184
+ }
185
+
186
+ const nameOf = (label) => (state.call && state.call.names && state.call.names[label]) || (label === "Others" ? "Others" : label.replace(/^S(\d+)$/, "Speaker $1"));
187
+
188
+ // Setup check (wirenote doctor) ---------------------------------------------
189
+
190
+ async function loadDoctor(force) {
191
+ if (force || !state.doctor) state.doctor = await api("/api/doctor");
192
+ return state.doctor;
193
+ }
194
+
195
+ function checksList(checks) {
196
+ const mark = { ok: "✓", warn: "!", bad: "✗" };
197
+ return h("ul", { class: "doctor-checks" }, checks.map((c) => h("li", { class: `doctor-check ${c.status}` },
198
+ h("span", { class: "check-mark", "aria-hidden": "true" }, mark[c.status] || "?"),
199
+ h("span", { class: "grow" }, c.message,
200
+ c.fix ? h("button", { class: "check-fix", title: "Copy this command", onclick: async () => {
201
+ try { await navigator.clipboard.writeText(c.fix); toast("Copied. Run it in a terminal."); }
202
+ catch { toast(c.fix); }
203
+ } }, c.fix) : null))));
204
+ }
205
+
206
+ // Sidebar -------------------------------------------------------------------
207
+
208
+ function renderNav() {
209
+ for (const a of document.querySelectorAll(".nav-item"))
210
+ a.classList.toggle("active", a.dataset.nav === state.view);
211
+ const mine = state.calls.reduce((n, c) => n + (c.my_open_actions || 0), 0);
212
+ $("#tasks-count").textContent = mine ? String(mine) : "";
213
+ }
214
+
215
+ function renderList() {
216
+ renderNav();
217
+ const nav = $("#list");
218
+ const activeId = state.view === "call" && state.call ? state.call.id : null;
219
+ if (!state.calls.length) {
220
+ nav.replaceChildren(h("div", { class: "list-empty" },
221
+ state.query ? `No meetings match “${state.query}”.` : "No meetings yet. Record a call and it shows up here."));
222
+ return;
223
+ }
224
+ const out = [];
225
+ let group = null;
226
+ for (const c of state.calls) {
227
+ const d = new Date(c.started);
228
+ const g = dayGroup(d);
229
+ if (g !== group) { out.push(h("div", { class: "group-label" }, g)); group = g; }
230
+ const meta = [fmtTime(d), fmtDuration(c.duration_s)].filter(Boolean).join(" · ");
231
+ out.push(h("a", { class: "item" + (c.id === activeId ? " active" : ""), href: `#/call/${c.id}` },
232
+ h("div", { class: "item-title" }, stateBadge(c), h("span", {}, c.title)),
233
+ h("div", { class: "item-meta" }, meta),
234
+ c.match ? h("div", { class: "item-match" }, highlighted(c.match.text, state.query)) : null));
235
+ }
236
+ nav.replaceChildren(...out);
237
+ }
238
+
239
+ function renderSidebarStatus() {
240
+ const st = state.status;
241
+ const el = $("#sidebar-status");
242
+ const btn = $("#record-btn");
243
+ btn.classList.toggle("recording", !!st.recording);
244
+ $("#record-label").textContent = st.recording ? "Stop recording" : "Record a meeting";
245
+ btn.disabled = !st.alive;
246
+ if (!st.alive) {
247
+ el.replaceChildren(h("span", { class: "dot fail" }), "Detector not running");
248
+ return;
249
+ }
250
+ const jobs = st.jobs || {};
251
+ const working = (jobs.queued || 0) + (jobs.transcribing || 0) + (jobs.summarizing || 0);
252
+ el.replaceChildren(h("span", { class: "dot" + (st.recording ? " rec" : working ? " work" : "") }),
253
+ st.recording ? "Recording" : working ? `Writing ${working} note${working > 1 ? "s" : ""}…` : "Listening for calls");
254
+ }
255
+
256
+ function recordingElapsed() {
257
+ const st = state.status;
258
+ const app = (st.apps || {})[st.recording] || {};
259
+ if (app.recording_for_s === undefined) return 0;
260
+ return app.recording_for_s + (Date.now() - Date.parse(st.updated)) / 1000;
261
+ }
262
+
263
+ function renderBanner() {
264
+ const st = state.status;
265
+ const b = $("#banner");
266
+ const onRecordingPage = state.view === "call" && state.call && state.call.id === st.recording_id;
267
+ if (!st.recording || onRecordingPage) { b.hidden = true; return; }
268
+ const app = (st.apps || {})[st.recording] || {};
269
+ b.replaceChildren(h("span", { class: "dot rec" }),
270
+ h("span", {}, h("b", {}, `Recording ${app.name || st.recording}`)),
271
+ h("span", { class: "elapsed" }, clock(recordingElapsed())),
272
+ h("span", { class: "grow" }),
273
+ st.recording_id ? h("a", { class: "btn", href: `#/call/${st.recording_id}` }, icon("pen"), "Take notes") : null,
274
+ h("button", { class: "btn btn-stop", onclick: stopRecording }, icon("stop"), "Stop"));
275
+ b.hidden = false;
276
+ }
277
+
278
+ async function stopRecording() {
279
+ try {
280
+ const r = await api("/api/stop", { method: "POST" });
281
+ toast(r.ok ? "Recording stopped. Writing the note…" : r.error);
282
+ refreshStatus();
283
+ } catch (e) { toast(e.message); }
284
+ }
285
+
286
+ async function startRecording() {
287
+ if (state.status.recording) return stopRecording();
288
+ try {
289
+ const r = await api("/api/record", { method: "POST" });
290
+ if (!r.ok) return toast(r.error);
291
+ toast("Recording. Take notes while you talk.");
292
+ // Open the new recording as soon as the daemon has created it.
293
+ for (let i = 0; i < 20; i++) {
294
+ await new Promise((res) => setTimeout(res, 400));
295
+ await refreshStatus();
296
+ if (state.status.recording_id) { location.hash = `#/call/${state.status.recording_id}`; return; }
297
+ }
298
+ } catch (e) { toast(e.message); }
299
+ }
300
+
301
+ // Home ----------------------------------------------------------------------
302
+
303
+ async function renderHome() {
304
+ state.view = "home";
305
+ state.call = null;
306
+ setPlayer(null);
307
+ renderList();
308
+ renderBanner();
309
+ const now = new Date();
310
+ const hour = now.getHours();
311
+ const hello = hour < 12 ? "Good morning" : hour < 18 ? "Good afternoon" : "Good evening";
312
+ const page = h("div", { class: "page" },
313
+ h("h1", { class: "hello" }, hello), h("div", { class: "today" }, fmtDate(now)));
314
+ const upcoming = h("section", { class: "home-section" },
315
+ h("div", { class: "section" }, h("h2", {}, icon("calendar"), "Coming up")),
316
+ h("div", { class: "hint" }, "Loading…"));
317
+ page.append(upcoming);
318
+
319
+ const recent = state.calls.slice(0, 6);
320
+ const recentSec = h("section", { class: "home-section" },
321
+ h("div", { class: "section" }, h("h2", {}, "Recent meetings")));
322
+ if (recent.length) {
323
+ recentSec.append(h("div", { class: "cards" }, recent.map((c) => {
324
+ const d = new Date(c.started);
325
+ const pill = c.state === "recording" ? h("span", { class: "pill bad" }, "Recording")
326
+ : ["queued", "transcribing", "summarizing"].includes(c.state) ? h("span", { class: "pill warn" }, "Writing note…")
327
+ : c.my_open_actions ? h("span", { class: "pill" }, `${c.my_open_actions} for you`) : null;
328
+ const who = c.speakers.length ? c.speakers.join(", ") : c.attendees.join(", ");
329
+ return h("a", { class: "card", href: `#/call/${c.id}` },
330
+ h("div", { class: "card-title" }, h("span", { class: "grow" }, c.title), pill),
331
+ h("div", { class: "card-meta" }, [`${dayGroup(d)}, ${fmtTime(d)}`, fmtDuration(c.duration_s), sourceLabel(c), who].filter(Boolean).join(" · ")),
332
+ c.preview ? h("div", { class: "card-preview" }, c.preview) : null);
333
+ })));
334
+ } else {
335
+ recentSec.append(h("div", { class: "steps" },
336
+ h("div", { class: "step" }, "Join a call in Chrome, Firefox, Brave, Discord or Rocket.Chat. Wirenote asks whether to record. For a meeting in a room, click “Record a meeting”."),
337
+ h("div", { class: "step" }, "Take notes here while you talk, if you like. Your mic and the call audio are recorded on this laptop."),
338
+ h("div", { class: "step" }, "After the call: who said what, a summary, action items and a follow-up email draft.")));
339
+ }
340
+ page.append(recentSec);
341
+ const setup = h("section", { class: "home-section setup-check", hidden: true });
342
+ page.insertBefore(setup, upcoming);
343
+ $("#main").replaceChildren(page);
344
+ loadDoctor().then((d) => {
345
+ const bad = d.checks.filter((c) => c.status === "bad");
346
+ if (!bad.length) return;
347
+ setup.replaceChildren(
348
+ h("div", { class: "section" }, h("h2", {}, "Finish setting up"), h("a", { class: "hint", href: "#/settings" }, "All checks")),
349
+ checksList(bad));
350
+ setup.hidden = false;
351
+ }).catch(() => {});
352
+
353
+ try {
354
+ const { events } = await api("/api/upcoming");
355
+ const box = upcoming.lastChild;
356
+ if (!events.length) {
357
+ box.replaceChildren("Nothing in your calendar for the next hours. Connect one with ",
358
+ h("code", {}, "wirenote calendar add"), " to see meetings here and get their titles and attendees in your notes.");
359
+ return;
360
+ }
361
+ box.replaceWith(h("div", { class: "cards" }, events.map((e) => {
362
+ const s = new Date(e.start), en = new Date(e.end);
363
+ const live = s <= new Date() && new Date() < en;
364
+ return h("div", { class: "card event" + (live ? " now" : "") },
365
+ h("div", { class: "event-time" }, h("b", {}, live ? "Now" : fmtTime(s)), `${fmtTime(s)}–${fmtTime(en)}`),
366
+ h("div", { class: "event-bar" + (e.call ? " call" : "") }),
367
+ h("div", { class: "grow" },
368
+ h("div", { class: "card-title" }, h("span", { class: "grow" }, e.title), e.call ? h("span", { class: "pill" }, "Call") : null),
369
+ e.attendees.length ? h("div", { class: "card-meta" }, e.attendees.slice(0, 5).join(", ") + (e.attendees.length > 5 ? ` +${e.attendees.length - 5}` : "")) : null));
370
+ })));
371
+ } catch (e) {
372
+ upcoming.lastChild.textContent = `Calendar: ${e.message}`;
373
+ }
374
+ }
375
+
376
+ // Call page -----------------------------------------------------------------
377
+
378
+ async function openCall(id, tab, seek) {
379
+ try {
380
+ const call = await api(`/api/calls/${encodeURIComponent(id)}`);
381
+ const sameCall = state.call && state.call.id === call.id;
382
+ if (!sameCall) { state.namesChanged = false; state.notesChanged = false; state.find = ""; }
383
+ state.view = "call";
384
+ state.call = call;
385
+ if (tab) state.tab = tab;
386
+ else if (!sameCall) state.tab = "notes";
387
+ renderList();
388
+ renderBanner();
389
+ renderCall();
390
+ if (!sameCall) setPlayer(call);
391
+ if (seek !== undefined) jumpTo(seek, true);
392
+ } catch (e) {
393
+ $("#main").replaceChildren(h("div", { class: "page" }, h("h1", { class: "hello" }, "Not found"),
394
+ h("p", { class: "hint" }, e.message), h("a", { class: "btn", href: "#/" }, "Back to all meetings")));
395
+ }
396
+ }
397
+
398
+ function renderCall() {
399
+ const c = state.call;
400
+ const main = $("#main");
401
+ const scroll = main.scrollTop;
402
+ const keepFocus = document.activeElement && document.activeElement.id === "mynotes-text";
403
+ const caret = keepFocus ? [document.activeElement.selectionStart, document.activeElement.selectionEnd] : null;
404
+ if (c.state === "recording") main.replaceChildren(recordingView(c));
405
+ else main.replaceChildren(callView(c));
406
+ main.scrollTop = scroll;
407
+ if (keepFocus) {
408
+ const ta = $("#mynotes-text");
409
+ if (ta) { ta.focus(); ta.setSelectionRange(caret[0], caret[1]); }
410
+ }
411
+ }
412
+
413
+ function titleEditor(c) {
414
+ const title = h("h1", { class: "title", contenteditable: "true", spellcheck: "false", title: "Click to rename" }, c.title);
415
+ title.addEventListener("keydown", (e) => {
416
+ if (e.key === "Enter") { e.preventDefault(); title.blur(); }
417
+ if (e.key === "Escape") { title.textContent = c.title; title.blur(); }
418
+ });
419
+ title.addEventListener("blur", async () => {
420
+ const t = title.textContent.trim();
421
+ if (t === c.title) return;
422
+ try { await api(`/api/calls/${c.id}/rename`, { method: "POST", body: { title: t } }); toast("Renamed"); refreshList(); openCall(c.id); }
423
+ catch (e) { toast(e.message); }
424
+ });
425
+ return title;
426
+ }
427
+
428
+ function metaLine(c) {
429
+ const d = new Date(c.started);
430
+ return h("div", { class: "meta" },
431
+ h("span", {}, icon("calendar"), `${fmtDate(d)}, ${fmtTime(d)}`),
432
+ c.duration_s && c.state !== "recording" ? h("span", {}, icon("clock"), fmtDuration(c.duration_s)) : null,
433
+ h("span", {}, icon(c.app === "manual" ? "users" : "video"), sourceLabel(c) + (c.room ? ` · ${c.room}` : "")),
434
+ c.calendar && c.calendar !== c.title ? h("span", {}, icon("calendar"), c.calendar) : null);
435
+ }
436
+
437
+ function recordingView(c) {
438
+ const clockEl = h("span", { class: "clock" }, clock(recordingElapsed()));
439
+ clearInterval(recordingView.timer);
440
+ recordingView.timer = setInterval(() => {
441
+ if (!document.body.contains(clockEl)) return clearInterval(recordingView.timer);
442
+ clockEl.textContent = clock(recordingElapsed());
443
+ }, 1000);
444
+ return h("div", { class: "page" },
445
+ h("div", { class: "head-row" }, h("div", { class: "grow" }, titleEditor(c), metaLine(c),
446
+ c.attendees.length ? h("div", { class: "people" }, c.attendees.map((p) => h("span", { class: "person" }, avatar(p), p))) : null)),
447
+ h("div", { class: "live-head" }, h("span", { class: "dot rec" }), "Recording", clockEl,
448
+ h("span", { class: "grow" }), h("button", { class: "btn btn-stop", onclick: stopRecording }, icon("stop"), "Stop")),
449
+ myNotesEditor(c, true),
450
+ h("div", { class: "live-tip" }, "Jot what matters: decisions, who does what, names. A word or two is enough; the summary fills in the rest from the transcript."));
451
+ }
452
+
453
+ function callView(c) {
454
+ const page = h("div", { class: "page" },
455
+ h("div", { class: "head-row" },
456
+ h("div", { class: "grow" }, titleEditor(c), metaLine(c), speakersRow(c)),
457
+ h("div", { class: "head-actions" },
458
+ c.summary ? h("button", { class: "btn-icon", title: "Draft a follow-up email", onclick: () => followupDialog(c) }, icon("mail")) : null,
459
+ c.summary ? h("button", { class: "btn-icon", title: "Copy notes as Markdown", onclick: copyNotes }, icon("copy")) : null,
460
+ moreMenu(c))),
461
+ statusNotice(c));
462
+ const lines = c.lines || [];
463
+ page.append(h("div", { class: "tabs", role: "tablist" },
464
+ tabButton("notes", "Notes"),
465
+ tabButton("transcript", "Transcript", lines.length ? String(lines.length) : null)));
466
+ page.append(state.tab === "transcript" ? transcriptView(c) : notesView(c));
467
+ return page;
468
+ }
469
+
470
+ function speakersRow(c) {
471
+ const people = c.people || [];
472
+ if (!people.length) return c.attendees.length ? h("div", { class: "people" }, c.attendees.map((p) => h("span", { class: "person" }, avatar(p), p))) : null;
473
+ const total = people.reduce((n, p) => n + p.seconds, 0) || 1;
474
+ const unnamed = people.some((p) => p.label !== "Me" && !p.named);
475
+ return h("div", { class: "speakers" },
476
+ people.map((p) => {
477
+ const share = `${Math.round((p.seconds / total) * 100)}%`;
478
+ if (p.label === "Me") return h("span", { class: "speaker-chip" }, avatar("Me"), "Me", h("span", { class: "share" }, share));
479
+ return h("button", { class: "speaker-chip" + (p.named ? "" : " unnamed"), title: p.named ? `Change who ${p.name} is` : "Who is this? Click to name",
480
+ onclick: () => speakerDialog(c, p.label) }, avatar(p.name, p.label), p.name, h("span", { class: "share" }, share), icon("pen"));
481
+ }),
482
+ unnamed ? h("span", { class: "speakers-hint" }, "Click a speaker to name them") : null);
483
+ }
484
+
485
+ function tabButton(id, label, count) {
486
+ return h("button", {
487
+ class: "tab" + (state.tab === id ? " active" : ""), role: "tab",
488
+ "aria-selected": String(state.tab === id),
489
+ onclick: () => { location.hash = `#/call/${state.call.id}${id === "notes" ? "" : "/" + id}`; },
490
+ }, label, count ? h("span", { class: "count" }, count) : null);
491
+ }
492
+
493
+ function statusNotice(c) {
494
+ const working = { queued: "Waiting to write the note…", transcribing: "Transcribing and telling the speakers apart…", summarizing: "Writing the summary…" }[c.state];
495
+ if (working)
496
+ return h("div", { class: "notice info" }, h("span", { class: "spinner" }), h("span", { class: "grow" }, working,
497
+ c.state === "transcribing" ? " This takes about half the call's length." : ""));
498
+ if (c.state === "failed")
499
+ return h("div", { class: "notice bad" }, h("span", { class: "grow" }, `The note failed: ${c.error || "unknown error"}`),
500
+ c.has_audio ? h("button", { class: "btn", onclick: () => requeue(false) }, icon("refresh"), "Try again") : null);
501
+ if (!c.summary && c.has_transcript && c.summary_error)
502
+ return h("div", { class: "notice" }, h("span", { class: "grow" }, `No summary: ${c.summary_error}`),
503
+ h("button", { class: "btn", onclick: () => requeue(true) }, icon("sparkle"), "Retry summary"));
504
+ if (c.has_transcript && (state.namesChanged || state.notesChanged))
505
+ return h("div", { class: "notice info" }, h("span", { class: "grow" },
506
+ state.namesChanged ? "Speaker names changed. Update the summary so owners and quotes use them." : "Your notes changed. Update the summary to build them in."),
507
+ h("button", { class: "btn btn-primary", onclick: () => { state.namesChanged = state.notesChanged = false; requeue(true); } }, icon("sparkle"), "Update summary"));
508
+ return null;
509
+ }
510
+
511
+ function moreMenu(c) {
512
+ const wrap = h("div", { class: "menu-wrap" });
513
+ const btn = h("button", { class: "btn-icon", title: "More", "aria-haspopup": "true" }, icon("more"));
514
+ let menu = null;
515
+ const close = () => { if (menu) { menu.remove(); menu = null; document.removeEventListener("click", outside, true); } };
516
+ const outside = (e) => { if (!wrap.contains(e.target)) close(); };
517
+ const item = (ic, label, fn, cls) => h("button", { class: cls || "", onclick: () => { close(); fn(); } }, icon(ic), label);
518
+ btn.addEventListener("click", () => {
519
+ if (menu) return close();
520
+ menu = h("div", { class: "menu", role: "menu" },
521
+ c.summary ? item("mail", "Draft follow-up email", () => followupDialog(c)) : null,
522
+ c.summary ? item("copy", "Copy notes as Markdown", copyNotes) : null,
523
+ c.note ? item("file", "Open Markdown file", () => post(`/api/calls/${c.id}/open`)) : null,
524
+ item("folder", "Show recording folder", () => post(`/api/calls/${c.id}/folder`)),
525
+ h("hr"),
526
+ c.has_transcript ? item("sparkle", "Redo summary", () => requeue(true)) : null,
527
+ c.has_audio ? item("refresh", "Transcribe again", () => requeue(false)) : null,
528
+ h("hr"),
529
+ c.has_audio ? item("audio-off", "Delete audio, keep notes", deleteAudio, "danger") : null,
530
+ item("trash", "Delete meeting", deleteCall, "danger"));
531
+ wrap.append(menu);
532
+ document.addEventListener("click", outside, true);
533
+ });
534
+ wrap.append(btn);
535
+ return wrap;
536
+ }
537
+
538
+ async function post(path) {
539
+ try { await api(path, { method: "POST" }); } catch (e) { toast(e.message); }
540
+ }
541
+
542
+ async function requeue(summaryOnly) {
543
+ const c = state.call;
544
+ try {
545
+ await api(`/api/calls/${c.id}/${summaryOnly ? "resummarize" : "retranscribe"}`, { method: "POST" });
546
+ toast(summaryOnly ? "Updating the summary…" : "Transcribing again…");
547
+ await refreshList();
548
+ openCall(c.id);
549
+ } catch (e) { toast(e.message); }
550
+ }
551
+
552
+ async function deleteAudio() {
553
+ const c = state.call;
554
+ if (!await confirmDialog("Delete the audio?", "The transcript and notes stay. You can no longer play the call or transcribe it again.", "Delete audio")) return;
555
+ try { await api(`/api/calls/${c.id}/audio`, { method: "DELETE" }); toast("Audio deleted"); setPlayer(null); openCall(c.id); }
556
+ catch (e) { toast(e.message); }
557
+ }
558
+
559
+ async function deleteCall() {
560
+ const c = state.call;
561
+ if (!await confirmDialog("Delete this meeting?", `“${c.title}”: the audio, transcript, summary and the Markdown note are deleted for good.`, "Delete meeting")) return;
562
+ try { await api(`/api/calls/${c.id}`, { method: "DELETE" }); toast("Meeting deleted"); location.hash = "#/"; refreshList(); }
563
+ catch (e) { toast(e.message); }
564
+ }
565
+
566
+ function notesMarkdown(c) {
567
+ const s = c.summary;
568
+ const out = [`# ${c.title}`, "", `${fmtDate(new Date(c.started))}, ${fmtTime(new Date(c.started))} · ${fmtDuration(c.duration_s)}`, ""];
569
+ const people = (c.people || []).filter((p) => p.label !== "Me").map((p) => p.name);
570
+ if (people.length) out.push(`With: ${people.join(", ")}`, "");
571
+ const list = (head, xs) => { if (xs && xs.length) out.push(`## ${head}`, "", ...xs.map((x) => `- ${x}`), ""); };
572
+ list("Summary", s.summary);
573
+ list("Decisions", s.decisions);
574
+ if (s.action_items.length) {
575
+ out.push("## Action items", "", ...s.action_items.map((a) => `- [${a.done ? "x" : " "}] **${a.owner}**: ${a.task}${a.due ? ` (due: ${a.due})` : ""}`), "");
576
+ }
577
+ list("Open questions", s.open_questions);
578
+ return out.join("\n");
579
+ }
580
+
581
+ async function copyNotes() {
582
+ try { await navigator.clipboard.writeText(notesMarkdown(state.call)); toast("Notes copied as Markdown"); }
583
+ catch { toast("Copy failed"); }
584
+ }
585
+
586
+ // Your notes -----------------------------------------------------------------
587
+
588
+ function myNotesEditor(c, big) {
589
+ const saved = h("span", { class: "saved" }, c.my_notes ? "Saved" : "");
590
+ const ta = h("textarea", { id: "mynotes-text", placeholder: big ? "Type notes while you talk…\n- decision: …\n- Anna: send the draft by Friday" : "Add your own notes. They shape the summary.", spellcheck: "true" });
591
+ ta.value = c.my_notes || "";
592
+ const grow = () => { ta.style.height = "auto"; ta.style.height = `${Math.max(ta.scrollHeight, big ? 320 : 96)}px`; };
593
+ let timer = null, last = ta.value;
594
+ const save = async () => {
595
+ if (ta.value === last) return;
596
+ const text = ta.value;
597
+ saved.textContent = "Saving…";
598
+ try {
599
+ await api(`/api/calls/${c.id}/notes`, { method: "POST", body: { text } });
600
+ last = text;
601
+ c.my_notes = text;
602
+ saved.textContent = "Saved";
603
+ if (c.state !== "recording" && c.has_transcript && !state.notesChanged) { state.notesChanged = true; renderCall(); }
604
+ } catch (e) { saved.textContent = "Not saved"; toast(e.message); }
605
+ };
606
+ ta.addEventListener("input", () => { grow(); saved.textContent = "Editing…"; clearTimeout(timer); timer = setTimeout(save, 700); });
607
+ ta.addEventListener("blur", () => { clearTimeout(timer); save(); });
608
+ requestAnimationFrame(grow);
609
+ if (big) requestAnimationFrame(() => { if (!document.activeElement || document.activeElement === document.body) ta.focus(); });
610
+ return h("div", { class: "mynotes" + (big ? " big" : "") },
611
+ h("div", { class: "mynotes-head" }, icon("pen"), "Your notes", saved), ta);
612
+ }
613
+
614
+ // Speakers ---------------------------------------------------------------
615
+
616
+ function speakerDialog(c, label) {
617
+ const p = (c.people || []).find((x) => x.label === label);
618
+ if (!p) return;
619
+ const input = h("input", { type: "text", value: p.named ? p.name : "", placeholder: "Name", autocomplete: "off" });
620
+ const remember = h("input", { type: "checkbox", id: "remember-voice" });
621
+ remember.checked = true;
622
+ const taken = new Set((c.people || []).filter((x) => x.label !== label && x.named).map((x) => x.name.toLowerCase()));
623
+ const current = p.named ? p.name.toLowerCase() : "";
624
+ const suggestions = [...new Set([...(c.attendees || []), ...(c.known_voices || [])])]
625
+ .filter((n) => !taken.has(n.toLowerCase()) && n.toLowerCase() !== current);
626
+ const sample = (c.lines || []).filter((l) => l.speaker === label).sort((a, b) => b.text.length - a.text.length)[0];
627
+ const others = (c.people || []).filter((x) => x.label !== "Me" && x.label !== label);
628
+ const save = async (body) => {
629
+ try {
630
+ await api(`/api/calls/${c.id}/speaker`, { method: "POST", body });
631
+ close();
632
+ state.namesChanged = !!c.summary;
633
+ toast(body.merge_into ? "Speakers merged" : body.name ? `Named ${body.name}` : "Name removed");
634
+ openCall(c.id);
635
+ refreshList();
636
+ } catch (e) { toast(e.message); }
637
+ };
638
+ const ok = h("button", { class: "btn btn-primary" }, "Save");
639
+ ok.onclick = () => save({ label, name: input.value, remember: remember.checked });
640
+ input.addEventListener("keydown", (e) => { if (e.key === "Enter") ok.click(); });
641
+ const close = openModal(h("div", { class: "dialog wide", role: "dialog", "aria-modal": "true" },
642
+ h("h3", {}, `Who is ${p.name}?`),
643
+ sample ? h("p", {}, "“" + (sample.text.length > 160 ? sample.text.slice(0, 160) + "…" : sample.text) + "”") : null,
644
+ p.source === "transcript" && p.evidence ? h("div", { class: "evidence" }, `Guessed from the call: “${p.evidence}”`) : null,
645
+ p.source === "voice" ? h("div", { class: "evidence" }, "Recognized from a remembered voice.") : null,
646
+ h("div", { class: "field" }, h("label", {}, "Name"), input),
647
+ suggestions.length ? h("div", { class: "suggest" }, suggestions.slice(0, 10).map((n) => h("button", { onclick: () => { input.value = n; input.focus(); } }, n))) : null,
648
+ p.can_remember ? h("label", { class: "check-row", for: "remember-voice" }, remember, "Recognize this voice in future calls") : null,
649
+ others.length ? h("div", { class: "sub" }, "Or: this is the same person as") : null,
650
+ others.length ? h("div", { class: "merge-list" }, others.map((o) => h("button", { class: "speaker-chip", onclick: () => save({ label, merge_into: o.label }) }, icon("merge"), avatar(o.name, o.label), o.name))) : null,
651
+ h("div", { class: "row" }, h("button", { class: "btn", onclick: () => close() }, "Cancel"), ok)));
652
+ input.focus();
653
+ input.select();
654
+ }
655
+
656
+ // Follow-up -------------------------------------------------------------------
657
+
658
+ function followupDialog(c) {
659
+ const subject = h("input", { type: "text", value: c.followup ? c.followup.subject : "" });
660
+ const body = h("textarea", {}, "");
661
+ body.value = c.followup ? c.followup.body : "";
662
+ const status = h("span", { class: "hint" });
663
+ const gen = h("button", { class: "btn" }, icon("sparkle"), c.followup ? "Write again" : "Write draft");
664
+ const copy = h("button", { class: "btn" }, icon("copy"), "Copy");
665
+ const mail = h("button", { class: "btn btn-primary" }, icon("mail"), "Open in mail");
666
+ const to = (c.followup && c.followup.to && c.followup.to.length ? c.followup.to : c.emails) || [];
667
+ const run = async () => {
668
+ gen.disabled = true;
669
+ status.replaceChildren(h("span", { class: "spinner" }), " Writing… a local model takes up to a minute.");
670
+ try {
671
+ const f = await api(`/api/calls/${c.id}/followup`, { method: "POST" });
672
+ c.followup = f;
673
+ subject.value = f.subject;
674
+ body.value = f.body;
675
+ status.textContent = "";
676
+ } catch (e) { status.textContent = e.message; }
677
+ gen.disabled = false;
678
+ };
679
+ gen.onclick = run;
680
+ copy.onclick = async () => {
681
+ try { await navigator.clipboard.writeText(`${subject.value}\n\n${body.value}`); toast("Email copied"); }
682
+ catch { toast("Copy failed"); }
683
+ };
684
+ mail.onclick = () => {
685
+ const url = `mailto:${to.map(encodeURIComponent).join(",")}?subject=${encodeURIComponent(subject.value)}&body=${encodeURIComponent(body.value)}`;
686
+ window.location.href = url;
687
+ };
688
+ openModal(h("div", { class: "dialog wide", role: "dialog", "aria-modal": "true" },
689
+ h("h3", {}, "Follow-up email"),
690
+ h("p", {}, to.length ? `To: ${to.join(", ")}` : "Written from the summary and your notes. Edit it before sending."),
691
+ h("div", { class: "field" }, h("label", {}, "Subject"), subject),
692
+ h("div", { class: "field" }, h("label", {}, "Message"), body),
693
+ h("div", { class: "row" }, status, h("span", { class: "grow" }), gen, copy, mail)));
694
+ if (!c.followup) run();
695
+ }
696
+
697
+ // Notes tab ------------------------------------------------------------------
698
+
699
+ function notesView(c) {
700
+ const s = c.summary;
701
+ const wrap = h("div", {});
702
+ wrap.append(myNotesEditor(c, false));
703
+ if (!s) {
704
+ if (c.has_transcript) {
705
+ wrap.append(h("p", { class: "empty-line" }, "No summary for this call. The full transcript is in the Transcript tab."));
706
+ wrap.append(askView(c));
707
+ } else if (!["queued", "transcribing", "summarizing", "recording"].includes(c.state)) wrap.append(h("p", { class: "empty-line" }, "Nothing here yet."));
708
+ return wrap;
709
+ }
710
+ const bulletList = (items, cls) => h("ul", { class: "bullets " + (cls || "") },
711
+ items.map((text) => h("li", {}, h("span", {}, text), jumpButton(text))));
712
+
713
+ wrap.append(h("section", { class: "section" }, h("h2", {}, "Summary", cloudBadge(c.summary_backend)),
714
+ s.summary.length ? bulletList(s.summary) : h("div", { class: "empty-line" }, "—")));
715
+
716
+ wrap.append(h("section", { class: "section" },
717
+ h("h2", {}, "Action items", s.action_items.length ? h("span", { class: "n" }, String(s.action_items.length)) : null),
718
+ s.action_items.length ? h("ul", { class: "actions" }, s.action_items.map((a, i) => actionItem(c.id, a, i, false)))
719
+ : h("div", { class: "empty-line" }, "No action items.")));
720
+
721
+ if (s.decisions.length) wrap.append(h("section", { class: "section" }, h("h2", {}, "Decisions"), bulletList(s.decisions, "decisions")));
722
+ if (s.open_questions.length) wrap.append(h("section", { class: "section" }, h("h2", {}, "Open questions"), bulletList(s.open_questions, "questions")));
723
+ if (c.has_transcript) wrap.append(askView(c));
724
+ return wrap;
725
+ }
726
+
727
+ function actionItem(callId, a, i, withCall) {
728
+ const li = h(withCall ? "div" : "li", { class: (withCall ? "task-row" : "") + (a.done ? " done" : "") });
729
+ const box = h("button", { class: "check", role: "checkbox", "aria-checked": String(!!a.done), title: a.done ? "Mark as open" : "Mark as done" }, icon("check"));
730
+ box.addEventListener("click", async () => {
731
+ const done = !a.done;
732
+ a.done = done;
733
+ li.classList.toggle("done", done);
734
+ box.setAttribute("aria-checked", String(done));
735
+ try {
736
+ await api(`/api/calls/${callId}/action/${i}`, { method: "POST", body: { done } });
737
+ if (state.call && state.call.summary && state.call.id === callId) state.call.summary.action_items[i].done = done;
738
+ refreshList();
739
+ } catch (e) { a.done = !done; li.classList.toggle("done", !done); toast(e.message); }
740
+ });
741
+ li.append(box, h("div", { class: "a-body" },
742
+ h("div", { class: "a-task" }, a.task),
743
+ h("div", { class: "a-meta" }, h("span", { class: "tag" }, avatar(a.owner), a.owner),
744
+ a.due ? h("span", { class: "tag due" }, a.due) : null,
745
+ withCall ? h("a", { href: `#/call/${callId}` }, `${a.title} · ${fmtShort(new Date(a.started))}`) : null)));
746
+ if (!withCall) li.append(jumpButton(a.task));
747
+ return li;
748
+ }
749
+
750
+ function jumpButton(text) {
751
+ return h("button", { class: "jump", title: "Jump to this moment in the transcript", onclick: () => {
752
+ const t = bestMoment(text);
753
+ if (t === null) return toast("No matching moment found");
754
+ jumpTo(t, false);
755
+ } }, icon("jump"));
756
+ }
757
+
758
+ // The transcript line that shares the most words with a summary point.
759
+ const WORD = /[\p{L}\p{N}]{3,}/gu;
760
+ function words(s) { return new Set((s.toLowerCase().match(WORD) || []).map((w) => w.slice(0, 5))); }
761
+ function bestMoment(text) {
762
+ const want = words(text);
763
+ let best = null, bestScore = 0;
764
+ for (const l of state.call.lines || []) {
765
+ const got = words(l.text);
766
+ let common = 0;
767
+ for (const w of want) if (got.has(w)) common++;
768
+ const score = common / Math.sqrt(got.size + 1);
769
+ if (score > bestScore) { bestScore = score; best = l.start; }
770
+ }
771
+ return bestScore > 0 ? best : null;
772
+ }
773
+
774
+ function jumpTo(seconds, quiet) {
775
+ if (state.tab !== "transcript") {
776
+ state.tab = "transcript";
777
+ history.replaceState(null, "", `#/call/${state.call.id}/transcript`);
778
+ renderCall();
779
+ }
780
+ const lines = [...document.querySelectorAll(".t-line")];
781
+ let target = lines[0];
782
+ for (const el of lines) { if (Number(el.dataset.start) <= seconds + 0.01) target = el; else break; }
783
+ if (target) {
784
+ target.scrollIntoView({ block: "center", behavior: quiet ? "auto" : "smooth" });
785
+ target.classList.remove("flash"); void target.offsetWidth; target.classList.add("flash");
786
+ }
787
+ if (state.call.has_audio) {
788
+ seekAudio(seconds);
789
+ if (!quiet && audio().paused) toast("Press Space to play from here");
790
+ }
791
+ }
792
+
793
+ // Ask (one call) ---------------------------------------------------------------
794
+
795
+ function askBox(placeholder, busy, onAsk) {
796
+ const input = h("input", { type: "text", placeholder, maxlength: "500" });
797
+ const send = h("button", { class: "send", title: "Ask", disabled: true }, icon("send"));
798
+ input.addEventListener("input", () => { send.disabled = !input.value.trim() || busy(); });
799
+ const go = () => { const q = input.value.trim(); if (q && !busy()) onAsk(q); };
800
+ input.addEventListener("keydown", (e) => { if (e.key === "Enter") go(); });
801
+ send.addEventListener("click", go);
802
+ return { box: h("div", { class: "ask-box" }, icon("sparkle"), input, send), input };
803
+ }
804
+
805
+ function askView(c) {
806
+ const sec = h("section", { class: "section ask" }, h("h2", {}, icon("sparkle"), "Ask about this meeting"));
807
+ for (const qa of c.asks || []) sec.append(qaCard(qa, (t) => jumpTo(t, false)));
808
+ if (state.asking) sec.append(h("div", { class: "ask-wait" }, h("span", { class: "spinner" }), "Thinking… a local model takes up to a minute."));
809
+ const { box } = askBox("What did we decide about…? Who owns…?", () => state.asking, async (q) => {
810
+ state.asking = true;
811
+ renderCall();
812
+ try {
813
+ const entry = await api(`/api/calls/${c.id}/ask`, { method: "POST", body: { question: q } });
814
+ if (state.call && state.call.id === c.id) state.call.asks = [...(state.call.asks || []), entry];
815
+ } catch (e) { toast(e.message); }
816
+ state.asking = false;
817
+ if (state.call && state.call.id === c.id) renderCall();
818
+ });
819
+ sec.append(box);
820
+ return sec;
821
+ }
822
+
823
+ function qaCard(qa, onCite) {
824
+ return h("div", { class: "qa" },
825
+ h("div", { class: "qa-q" }, qa.question),
826
+ h("div", { class: "qa-a" }, qa.answer),
827
+ qa.citations.length ? h("div", { class: "qa-cites" }, qa.citations.map((t) =>
828
+ typeof t === "number"
829
+ ? h("button", { class: "cite", title: "Jump to this moment", onclick: () => onCite(t) }, clock(t))
830
+ : h("a", { class: "cite call", href: `#/call/${t.call}/transcript?t=${t.t}`, title: "Open this moment" }, `${t.title} · ${clock(t.t)}`))) : null,
831
+ h("div", { class: "qa-meta" }, cloudBadge(qa.backend), [qa.backend, qa.partial ? "searched the most relevant parts" : "",
832
+ qa.searched !== undefined ? `searched ${qa.searched} call${qa.searched === 1 ? "" : "s"}` : ""].filter(Boolean).join(" · ")));
833
+ }
834
+
835
+ // Transcript tab -------------------------------------------------------------
836
+
837
+ function transcriptView(c) {
838
+ const lines = c.lines || [];
839
+ const wrap = h("div", {});
840
+ if (!lines.length) {
841
+ wrap.append(h("p", { class: "empty-line" }, c.has_transcript ? "No speech was recognized." : "The transcript appears here once the call is transcribed."));
842
+ return wrap;
843
+ }
844
+ const count = h("span", { class: "t-count" });
845
+ const find = h("input", { type: "search", placeholder: "Find in transcript", value: state.find });
846
+ const body = h("div", {});
847
+ const draw = () => {
848
+ const q = state.find.trim();
849
+ let hits = 0;
850
+ body.replaceChildren(...lines.map((l) => {
851
+ if (q && l.text.toLowerCase().includes(q.toLowerCase())) hits++;
852
+ const me = l.speaker === "Me";
853
+ const name = nameOf(l.speaker);
854
+ const who = me ? h("div", { class: "t-speaker me" }, "Me")
855
+ : h("button", { class: "t-speaker others", title: "Name this speaker", onclick: () => speakerDialog(c, l.speaker) }, name);
856
+ if (!me) who.style.setProperty("color", colorFor(l.speaker));
857
+ return h("div", { class: "t-line", "data-start": String(l.start) },
858
+ h("button", { class: "t-time", title: "Play from here", onclick: () => { seekAudio(l.start); playAudio(); } }, clock(l.start)),
859
+ h("div", {}, who, h("div", { class: "t-text" }, highlighted(l.text, q))));
860
+ }));
861
+ count.textContent = q ? `${hits} match${hits === 1 ? "" : "es"}` : "";
862
+ const first = body.querySelector("mark");
863
+ if (first) first.closest(".t-line").scrollIntoView({ block: "center" });
864
+ markPlaying();
865
+ };
866
+ find.addEventListener("input", () => { state.find = find.value; draw(); });
867
+ const runs = c.runs || [];
868
+ const langs = [...new Set(runs.map((r) => r.language).filter(Boolean))];
869
+ wrap.append(h("div", { class: "t-tools" },
870
+ h("label", { class: "t-find" }, icon("search"), find), count,
871
+ h("span", { class: "t-info" }, [c.transcript_model, langs.join(", "), c.echo_dropped ? `${c.echo_dropped} echo line${c.echo_dropped > 1 ? "s" : ""} removed` : ""].filter(Boolean).join(" · "))));
872
+ wrap.append(body);
873
+ draw();
874
+ return wrap;
875
+ }
876
+
877
+ // Ask across meetings ----------------------------------------------------------
878
+
879
+ async function renderAsk() {
880
+ state.view = "ask";
881
+ state.call = null;
882
+ setPlayer(null);
883
+ renderList();
884
+ renderBanner();
885
+ const history = h("div", {});
886
+ const wait = h("div", { class: "ask-wait", hidden: true }, h("span", { class: "spinner" }), "Searching your meetings… a local model takes up to a minute.");
887
+ const ask = async (q) => {
888
+ state.askingAll = true;
889
+ wait.hidden = false;
890
+ try {
891
+ const entry = await api("/api/ask", { method: "POST", body: { question: q } });
892
+ history.prepend(qaCard(entry));
893
+ input.value = "";
894
+ } catch (e) { toast(e.message); }
895
+ state.askingAll = false;
896
+ wait.hidden = true;
897
+ };
898
+ const { box, input } = askBox("Ask anything about your past meetings…", () => state.askingAll, ask);
899
+ const examples = ["What did I promise to do this week?", "What was decided about the release date?", "Which open questions are still unanswered?"];
900
+ $("#main").replaceChildren(h("div", { class: "page" },
901
+ h("h1", { class: "page-title" }, "Ask your meetings"),
902
+ h("div", { class: "page-sub" }, "Answers come from your transcripts, with links to the exact moments."),
903
+ h("div", { class: "big-ask" }, box),
904
+ h("div", { class: "examples" }, examples.map((x) => h("button", { onclick: () => { input.value = x; ask(x); } }, x))),
905
+ h("div", { class: "section" }, wait, history)));
906
+ input.focus();
907
+ try {
908
+ const { asks } = await api("/api/asks");
909
+ history.replaceChildren(...asks.slice().reverse().map((qa) => qaCard(qa)));
910
+ } catch (e) { /* history is optional */ }
911
+ }
912
+
913
+ // Action items -----------------------------------------------------------------
914
+
915
+ async function renderTasks() {
916
+ state.view = "tasks";
917
+ state.call = null;
918
+ setPlayer(null);
919
+ renderList();
920
+ renderBanner();
921
+ const page = h("div", { class: "page" }, h("h1", { class: "page-title" }, "Action items"),
922
+ h("div", { class: "page-sub" }, "Everything agreed in your meetings. Tick items off here or in the meeting."));
923
+ $("#main").replaceChildren(page);
924
+ let items;
925
+ try { ({ items } = await api("/api/actions")); } catch (e) { page.append(h("p", { class: "hint" }, e.message)); return; }
926
+ const mine = items.filter((a) => !a.done && a.owner === "Me");
927
+ const others = items.filter((a) => !a.done && a.owner !== "Me");
928
+ const done = items.filter((a) => a.done);
929
+ const section = (title, list, empty) => h("section", { class: "section" },
930
+ h("h2", {}, title, list.length ? h("span", { class: "n" }, String(list.length)) : null),
931
+ list.length ? list.map((a) => actionItem(a.call, a, a.index, true)) : h("div", { class: "empty-line" }, empty));
932
+ page.append(section("Yours", mine, "Nothing open for you."));
933
+ page.append(section("Others", others, "Nothing open for others."));
934
+ if (done.length) {
935
+ const toggle = h("button", { class: "btn-ghost toggle-done", onclick: () => { state.showDone = !state.showDone; renderTasks(); } },
936
+ state.showDone ? `Hide ${done.length} done` : `Show ${done.length} done`);
937
+ page.append(toggle);
938
+ if (state.showDone) page.append(section("Done", done, ""));
939
+ }
940
+ }
941
+
942
+ // Settings ---------------------------------------------------------------------
943
+
944
+ async function renderSettings() {
945
+ state.view = "settings";
946
+ state.call = null;
947
+ setPlayer(null);
948
+ renderList();
949
+ renderBanner();
950
+ const page = h("div", { class: "page" }, h("h1", { class: "page-title" }, "Settings"));
951
+ $("#main").replaceChildren(page);
952
+ let s;
953
+ try { s = await api("/api/settings"); } catch (e) { page.append(h("p", { class: "hint" }, e.message)); return; }
954
+
955
+ const vocab = h("textarea", { placeholder: "One per line, e.g.\nAnna Schmidt\nJonas Weber\nSuchindex\nRocket.Chat" });
956
+ vocab.value = s.vocabulary.join("\n");
957
+ const saveVocab = h("button", { class: "btn btn-primary" }, "Save");
958
+ saveVocab.onclick = async () => {
959
+ try {
960
+ const r = await api("/api/vocabulary", { method: "POST", body: { terms: vocab.value.split("\n") } });
961
+ vocab.value = r.terms.join("\n");
962
+ toast(`Saved ${r.terms.length} term${r.terms.length === 1 ? "" : "s"}. Used for the next transcripts.`);
963
+ } catch (e) { toast(e.message); }
964
+ };
965
+ page.append(h("div", { class: "settings-card" },
966
+ h("h2", {}, "Vocabulary"),
967
+ h("p", {}, "Names of colleagues, products and terms the transcript should spell right. Also helps the summary."),
968
+ vocab, h("div", { class: "row-end" }, saveVocab)));
969
+
970
+ const voices = h("div", {});
971
+ const drawVoices = () => voices.replaceChildren(...(s.voices.length ? s.voices.map((v) => h("div", { class: "voice-row" },
972
+ avatar(v.name), h("span", { class: "grow" }, v.name), h("span", { class: "muted" }, `${v.samples} call${v.samples === 1 ? "" : "s"}`),
973
+ h("button", { class: "btn-ghost", onclick: async () => {
974
+ if (!await confirmDialog(`Forget ${v.name}'s voice?`, "Future calls will show this person as “Speaker N” until you name them again.", "Forget voice")) return;
975
+ try { await api(`/api/voices/${encodeURIComponent(v.name)}`, { method: "DELETE" }); s.voices = s.voices.filter((x) => x.name !== v.name); drawVoices(); }
976
+ catch (e) { toast(e.message); }
977
+ } }, "Forget"))) : [h("div", { class: "empty-line" }, "None yet. Name a speaker in a meeting's transcript and tick “Recognize this voice”.")]));
978
+ drawVoices();
979
+ page.append(h("div", { class: "settings-card" },
980
+ h("h2", {}, "Remembered voices"),
981
+ h("p", {}, "People recognized automatically in new calls. Voice prints stay on this laptop."), voices));
982
+
983
+ const checksBox = h("div", {}, h("div", { class: "hint" }, "Checking…"));
984
+ const rerun = h("button", { class: "btn" }, "Check again");
985
+ const drawChecks = (force) => loadDoctor(force)
986
+ .then((d) => checksBox.replaceChildren(checksList(d.checks)))
987
+ .catch((e) => checksBox.replaceChildren(h("div", { class: "hint" }, e.message)));
988
+ rerun.onclick = () => { checksBox.replaceChildren(h("div", { class: "hint" }, "Checking…")); drawChecks(true); };
989
+ page.append(h("div", { class: "settings-card" },
990
+ h("h2", {}, "Setup check"),
991
+ h("p", {}, "The same checks as wirenote doctor. Click a command to copy it."),
992
+ checksBox, h("div", { class: "row-end" }, rerun)));
993
+ drawChecks(false);
994
+
995
+ page.append(setupCard(s));
996
+ page.append(calendarsCard(s));
997
+ }
998
+
999
+ const BACKENDS = [["ollama", "On this laptop (Ollama)"], ["anthropic", "Claude (sent to Anthropic)"], ["none", "Off"]];
1000
+
1001
+ function field(label, control, hint) {
1002
+ return h("div", { class: "field" }, h("label", {}, label), control, hint ? h("div", { class: "hint" }, hint) : null);
1003
+ }
1004
+
1005
+ // Summaries, transcription, consent line and the Claude key (PUT /api/settings).
1006
+ function setupCard(s) {
1007
+ const e = s.editable;
1008
+ const text = (key, placeholder) => h("input", { type: "text", value: e[key], placeholder, maxlength: "500", spellcheck: "false" });
1009
+ const backend = h("select", {}, BACKENDS.map(([v, label]) => h("option", { value: v, selected: v === e.summary_backend }, label)));
1010
+ const inputs = {
1011
+ summary_backend: backend,
1012
+ ollama_model: text("ollama_model"), anthropic_model: text("anthropic_model"),
1013
+ summary_language: text("summary_language", "auto"),
1014
+ whisper_model: text("whisper_model"), whisper_language: text("whisper_language", "auto"),
1015
+ consent_line: h("textarea", { class: "short", maxlength: "500", placeholder: "e.g. Is it OK if I record this call for my notes?" }),
1016
+ };
1017
+ inputs.consent_line.value = e.consent_line;
1018
+ const ollamaRow = field("Ollama model", inputs.ollama_model);
1019
+ const claudeRow = field("Claude model", inputs.anthropic_model);
1020
+ const keyBox = h("div", {});
1021
+ const showFor = () => {
1022
+ ollamaRow.hidden = backend.value !== "ollama";
1023
+ claudeRow.hidden = keyBox.hidden = backend.value !== "anthropic";
1024
+ };
1025
+ backend.onchange = showFor;
1026
+
1027
+ const keyInput = h("input", { type: "password", autocomplete: "off", placeholder: s.anthropic_key_set ? "A key is saved; paste a new one to replace it" : "sk-ant-…" });
1028
+ const saveKey = h("button", { class: "btn" }, "Save key");
1029
+ saveKey.onclick = async () => {
1030
+ try {
1031
+ await api("/api/settings/anthropic-key", { method: "POST", body: { key: keyInput.value } });
1032
+ keyInput.value = "";
1033
+ keyInput.placeholder = "A key is saved; paste a new one to replace it";
1034
+ toast("Key saved. It stays on this laptop, in secrets.toml.");
1035
+ } catch (err) { toast(err.message); }
1036
+ };
1037
+ keyBox.append(field("Claude API key", h("div", { class: "inline" }, keyInput, saveKey),
1038
+ "Kept in secrets.toml on this laptop; never shown here again."));
1039
+ showFor();
1040
+
1041
+ const save = h("button", { class: "btn btn-primary" }, "Save");
1042
+ save.onclick = async () => {
1043
+ const changes = {};
1044
+ for (const [k, el] of Object.entries(inputs)) if (el.value !== e[k]) changes[k] = el.value;
1045
+ if (!Object.keys(changes).length) { toast("Nothing changed."); return; }
1046
+ let confirm_cloud = false;
1047
+ if (changes.summary_backend === "anthropic") {
1048
+ if (!await confirmDialog("Send call data to Claude?", s.cloud_disclosure, "Use Claude")) return;
1049
+ confirm_cloud = true;
1050
+ }
1051
+ try {
1052
+ const r = await api("/api/settings", { method: "PUT", body: { changes, confirm_cloud } });
1053
+ Object.assign(s, r);
1054
+ Object.assign(e, r.editable);
1055
+ state.doctor = null;
1056
+ toast("Saved. New calls use these settings.");
1057
+ } catch (err) { toast(err.message); }
1058
+ };
1059
+
1060
+ const kv = (k, v) => [h("dt", {}, k), h("dd", {}, v)];
1061
+ return h("div", { class: "settings-card" },
1062
+ h("h2", {}, "Setup"),
1063
+ h("p", {}, "Summaries, transcription and the consent line. Everything else is in config.toml."),
1064
+ field("Summaries and Ask", backend, "Ollama and Off keep everything on this laptop."),
1065
+ ollamaRow, claudeRow, keyBox,
1066
+ field("Summary language", inputs.summary_language, "auto: the language of the call, or e.g. English, German."),
1067
+ field("Whisper model", inputs.whisper_model, "e.g. large-v3-turbo, medium, small."),
1068
+ field("Call language", inputs.whisper_language, "auto, or a code like en, de."),
1069
+ field("Consent line", inputs.consent_line, "Copied to the clipboard when you press Record, to paste into the call chat."),
1070
+ h("div", { class: "row-end" }, save),
1071
+ h("dl", { class: "kv" },
1072
+ kv("Speakers", s.diarize ? "told apart" : "not told apart"),
1073
+ kv("Audio kept for", `${s.audio_retention_days} days after the note`),
1074
+ kv("Notes folder", s.notes_dir)));
1075
+ }
1076
+
1077
+ // Calendar links are secret: the page only ever sees an id and the host name.
1078
+ function calendarsCard(s) {
1079
+ const list = h("div", {});
1080
+ const draw = () => list.replaceChildren(...(s.calendars.length ? s.calendars.map((c) => h("div", { class: "voice-row" },
1081
+ h("span", { class: "grow" }, c.label), h("span", { class: "muted" }, c.kind === "link" ? "link" : "file in config.toml"),
1082
+ c.kind === "link" ? h("button", { class: "btn-ghost", onclick: async () => {
1083
+ if (!await confirmDialog(`Remove the calendar from ${c.label}?`, "Wirenote stops reading it. You can add the link again later.", "Remove")) return;
1084
+ try { s.calendars = (await api(`/api/calendars/${encodeURIComponent(c.id)}`, { method: "DELETE" })).calendars; draw(); }
1085
+ catch (e) { toast(e.message); }
1086
+ } }, "Remove") : null)) : [h("div", { class: "empty-line" }, "None yet. Calendars give calls their title and invitees.")]));
1087
+ draw();
1088
+ const link = h("input", { type: "password", autocomplete: "off", placeholder: "https://… or webcal://… (.ics)" });
1089
+ const add = h("button", { class: "btn" }, "Add");
1090
+ add.onclick = async () => {
1091
+ add.disabled = true;
1092
+ try {
1093
+ s.calendars = (await api("/api/calendars", { method: "POST", body: { url: link.value } })).calendars;
1094
+ link.value = "";
1095
+ draw();
1096
+ toast("Calendar added.");
1097
+ } catch (e) { toast(e.message); }
1098
+ add.disabled = false;
1099
+ };
1100
+ return h("div", { class: "settings-card" },
1101
+ h("h2", {}, "Calendars"),
1102
+ h("p", {}, "Paste a calendar's secret ICS link (Outlook: Publish a calendar › ICS; Google: Secret address in iCal format). It stays in secrets.toml and is not shown again."),
1103
+ list, h("div", { class: "field inline-add" }, h("div", { class: "inline" }, link, add)));
1104
+ }
1105
+
1106
+ // Player ---------------------------------------------------------------------
1107
+
1108
+ const audio = () => $("#audio");
1109
+
1110
+ function setPlayer(c) {
1111
+ const p = $("#player"), a = audio();
1112
+ if (c && a.dataset.call === c.id && c.has_audio) { p.hidden = false; return; }
1113
+ a.pause();
1114
+ a.removeAttribute("src");
1115
+ a.dataset.call = "";
1116
+ $("#p-play").classList.remove("loading");
1117
+ if (!c || !c.has_audio || c.state === "recording") { p.hidden = true; return; }
1118
+ a.dataset.call = c.id;
1119
+ $("#p-dur").textContent = clock(c.duration_s);
1120
+ $("#p-time").textContent = "0:00";
1121
+ $("#p-scrub").value = 0;
1122
+ p.hidden = false;
1123
+ }
1124
+
1125
+ function ensureSrc() {
1126
+ const a = audio();
1127
+ if (!a.getAttribute("src") && a.dataset.call) {
1128
+ $("#p-play").classList.add("loading");
1129
+ a.src = `/api/calls/${encodeURIComponent(a.dataset.call)}/audio`;
1130
+ }
1131
+ }
1132
+
1133
+ function playAudio() {
1134
+ if (!state.call || !state.call.has_audio) return;
1135
+ ensureSrc();
1136
+ audio().play().catch((e) => { $("#p-play").classList.remove("loading"); toast(`Cannot play: ${e.message}`); });
1137
+ }
1138
+
1139
+ function seekAudio(t) {
1140
+ const a = audio();
1141
+ if (!a.dataset.call) return;
1142
+ ensureSrc();
1143
+ const apply = () => { a.currentTime = t; };
1144
+ if (a.readyState >= 1) apply(); else a.addEventListener("loadedmetadata", apply, { once: true });
1145
+ }
1146
+
1147
+ function markPlaying() {
1148
+ const a = audio();
1149
+ if (!a.getAttribute("src") || a.paused) {
1150
+ document.querySelectorAll(".t-line.playing").forEach((el) => el.classList.remove("playing"));
1151
+ return;
1152
+ }
1153
+ let cur = null;
1154
+ for (const el of document.querySelectorAll(".t-line")) {
1155
+ if (Number(el.dataset.start) <= a.currentTime) cur = el; else break;
1156
+ }
1157
+ document.querySelectorAll(".t-line.playing").forEach((el) => { if (el !== cur) el.classList.remove("playing"); });
1158
+ if (cur && !cur.classList.contains("playing")) cur.classList.add("playing");
1159
+ }
1160
+
1161
+ function wirePlayer() {
1162
+ const a = audio(), play = $("#p-play"), scrub = $("#p-scrub");
1163
+ let dragging = false;
1164
+ play.addEventListener("click", () => (a.paused ? playAudio() : a.pause()));
1165
+ $("#p-back").addEventListener("click", () => seekAudio(Math.max(0, a.currentTime - 15)));
1166
+ $("#p-fwd").addEventListener("click", () => seekAudio(a.currentTime + 15));
1167
+ const speeds = [1, 1.25, 1.5, 2];
1168
+ $("#p-speed").addEventListener("click", (e) => {
1169
+ const next = speeds[(speeds.indexOf(a.playbackRate) + 1) % speeds.length];
1170
+ a.playbackRate = next;
1171
+ e.currentTarget.textContent = `${next}×`;
1172
+ });
1173
+ a.addEventListener("play", () => { play.replaceChildren(icon("pause")); });
1174
+ a.addEventListener("pause", () => { play.replaceChildren(icon("play")); markPlaying(); });
1175
+ a.addEventListener("playing", () => play.classList.remove("loading"));
1176
+ a.addEventListener("canplay", () => play.classList.remove("loading"));
1177
+ a.addEventListener("error", () => { play.classList.remove("loading"); if (a.getAttribute("src")) toast("Could not load the audio"); });
1178
+ a.addEventListener("loadedmetadata", () => { if (isFinite(a.duration)) $("#p-dur").textContent = clock(a.duration); });
1179
+ a.addEventListener("timeupdate", () => {
1180
+ $("#p-time").textContent = clock(a.currentTime);
1181
+ if (!dragging && isFinite(a.duration) && a.duration) scrub.value = Math.round((a.currentTime / a.duration) * 1000);
1182
+ markPlaying();
1183
+ });
1184
+ const dur = () => (isFinite(a.duration) && a.duration ? a.duration : state.call ? state.call.duration_s : 0);
1185
+ scrub.addEventListener("input", () => { dragging = true; $("#p-time").textContent = clock((scrub.value / 1000) * dur()); });
1186
+ scrub.addEventListener("change", () => { dragging = false; seekAudio((scrub.value / 1000) * dur()); });
1187
+ }
1188
+
1189
+ // Routing and refresh --------------------------------------------------------
1190
+
1191
+ function route() {
1192
+ const hash = location.hash;
1193
+ const m = hash.match(/^#\/call\/([^/?]+)(?:\/(transcript))?(?:\?t=(\d+(?:\.\d+)?))?$/);
1194
+ if (m) return openCall(decodeURIComponent(m[1]), m[2] || "notes", m[3] !== undefined ? Number(m[3]) : undefined);
1195
+ if (hash === "#/ask") return renderAsk();
1196
+ if (hash === "#/tasks") return renderTasks();
1197
+ if (hash === "#/settings") return renderSettings();
1198
+ return renderHome();
1199
+ }
1200
+
1201
+ async function refreshList() {
1202
+ try {
1203
+ const { calls } = await api(`/api/calls?q=${encodeURIComponent(state.query)}`);
1204
+ state.calls = calls;
1205
+ renderList();
1206
+ } catch (e) { /* server restarting; the next poll retries */ }
1207
+ }
1208
+
1209
+ async function refreshStatus() {
1210
+ try {
1211
+ state.status = await api("/api/status");
1212
+ } catch { state.status = {}; }
1213
+ renderSidebarStatus();
1214
+ renderBanner();
1215
+ const st = state.status;
1216
+ const key = JSON.stringify([st.recording_id, st.jobs]);
1217
+ if (key !== state.statusKey) {
1218
+ const first = state.statusKey === "";
1219
+ state.statusKey = key;
1220
+ if (!first) {
1221
+ await refreshList();
1222
+ if (state.view === "call" && state.call && !state.asking) {
1223
+ const fresh = state.calls.find((c) => c.id === state.call.id);
1224
+ if (fresh && fresh.state !== state.call.state) openCall(state.call.id);
1225
+ } else if (state.view === "home") renderHome();
1226
+ }
1227
+ }
1228
+ }
1229
+
1230
+ function wireKeys() {
1231
+ const search = $("#search");
1232
+ let timer;
1233
+ search.addEventListener("input", () => {
1234
+ clearTimeout(timer);
1235
+ timer = setTimeout(() => { state.query = search.value; refreshList(); }, 180);
1236
+ });
1237
+ search.addEventListener("keydown", (e) => {
1238
+ if (e.key === "Escape") { search.value = ""; state.query = ""; refreshList(); search.blur(); }
1239
+ if (e.key === "Enter" && state.calls[0]) {
1240
+ const c = state.calls[0];
1241
+ location.hash = c.match && c.match.start !== undefined ? `#/call/${c.id}/transcript?t=${c.match.start}` : `#/call/${c.id}`;
1242
+ }
1243
+ });
1244
+ $("#record-btn").addEventListener("click", startRecording);
1245
+ document.addEventListener("keydown", (e) => {
1246
+ const typing = /^(INPUT|TEXTAREA)$/.test(e.target.tagName) || e.target.isContentEditable;
1247
+ if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k") { e.preventDefault(); search.focus(); search.select(); return; }
1248
+ if (e.key === "Escape" && $("#modal").hidden === false) { $("#modal").click(); return; }
1249
+ if (typing) return;
1250
+ if (e.key === "/") { e.preventDefault(); search.focus(); }
1251
+ else if (e.key === " " && state.call && state.call.has_audio) { e.preventDefault(); audio().paused ? playAudio() : audio().pause(); }
1252
+ else if (e.key === "ArrowLeft" && state.call && audio().getAttribute("src")) seekAudio(Math.max(0, audio().currentTime - 15));
1253
+ else if (e.key === "ArrowRight" && state.call && audio().getAttribute("src")) seekAudio(audio().currentTime + 15);
1254
+ });
1255
+ }
1256
+
1257
+ async function start() {
1258
+ wirePlayer();
1259
+ wireKeys();
1260
+ await Promise.all([refreshStatus(), refreshList()]);
1261
+ window.addEventListener("hashchange", route);
1262
+ route();
1263
+ setInterval(refreshStatus, 3000);
1264
+ setInterval(() => { if (state.status.recording) renderBanner(); }, 1000);
1265
+ }
1266
+
1267
+ start();