PageSourceSearch

https://gettheminutes.com/v2-assets/app.mjs

js gettheminutes.com collected 2026-09-24 14:43:47 UTC 38,174 bytes, 1,159 lines download raw bytes

1import { completeSnapshot, downloadSnapshot, textExport } from "./exports.mjs";
2import { openWorkspace } from "./workspace.mjs";
3import { openTranslation } from "./translation.mjs";
4import { openAssistant } from "./assistant.mjs";
5import { BrowserRecording } from "./recording.mjs";
6import { openModelSettings } from "./settings.mjs";
7import { openMicrophoneCheck } from "./microphone.mjs";
8import { MIC_TEXTS, loadMicPreference } from "./mic.mjs";
9const $ = (id) => document.getElementById(id);
10const publicToken = location.pathname.match(
11  /^\/v2\/public\/([A-Za-z0-9_-]{43})$/,
12)?.[1];
13const state = {
14  me: null,
15  // How this server signs people in (GET /api/auth/config): the Community edition shows its
16  // email and password form; the hosted edition hands the browser to Oddproof.
17  auth: { identity: "local", login_url: null, account_url: null, portal_url: null },
18  authLoaded: false,
19  cap: null,
20  selected: null,
21  items: [],
22  doc: null,
23  request: 0,
24  authGeneration: 0,
25  libraryRequest: 0,
26  libraryMore: false,
27  libraryQuery: "",
28};
29// Server codes the person can act on. Any other code is shown with its underscores as spaces.
30const MESSAGES = {
31  insufficient_credit: "No credit left. Add credit at Oddproof, then try again.",
32  capture_budget_exceeded:
33    "Recording stopped at the paid boundary; the rest is saved on this device. Add credit at Oddproof, then use Recover uploads.",
34  seat_required:
35    "Your team seat is not active; ask your team's owner in the Oddproof portal.",
36  workspace_not_ready:
37    "Your workspace is not ready. Reload the app to settle it first.",
38  platform_recovering:
39    "Oddproof is restoring, content is briefly unavailable. Try again in a moment.",
40  access_revoked: "Your sign-in ended. Sign in again.",
41  ...MIC_TEXTS,
42  web_capture_unavailable:
43    "Recording in the browser is not enabled on this server.",
44  meeting_audio_not_selected: "Share a tab with audio to include the meeting.",
45  recording_account_changed:
46    "Your account changed. Reload the app before recording.",
47  storage_unavailable:
48    "This browser cannot store audio locally. Check private-mode and storage settings.",
49  request_failed: "Request could not be completed.",
50};
51// Codes the sign-in callback appends as ?login_error= when an Oddproof sign-in does not finish.
52const LOGIN_ERRORS = {
53  access_denied: "Sign-in was cancelled.",
54  email_unverified:
55    "Verify the email address of your Oddproof account, then sign in again.",
56  login_expired: "That sign-in link has expired. Try again.",
57  login_binding_invalid: "Finish signing in in the browser where you started.",
58  identity_unavailable: "Oddproof could not be reached. Try again in a moment.",
59};
60const PHASES = {
61  pending: "Audio saved · Transcription pending",
62  recording: "Recording",
63  complete: "Transcription complete",
64  cancelled: "Processing cancelled",
65  pending_credit:
66    "Waiting for credit · transcription starts once your workspace has credit at Oddproof",
67  needs_attention: "Transcription needs attention",
68  audio_expired: "Server audio expired · recover from your saved device copy",
69};
70const describe = (error) =>
71  MESSAGES[error.message] ||
72  String(error.message || "request_failed").replaceAll("_", " ");
73const phaseLabel = (phase) =>
74  ({ pending_credit: "waiting for credit", needs_attention: "needs attention" })[
75    phase
76  ] || String(phase).replaceAll("_", " ");
77const node = (tag, text, cls) => {
78  const n = document.createElement(tag);
79  if (text !== undefined) n.textContent = text;
80  if (cls) n.className = cls;
81  return n;
82};
83const link = (label, href, cls) => {
84  const a = node("a", label, cls);
85  a.href = href;
86  a.target = "_blank";
87  a.rel = "noopener";
88  return a;
89};
90const portalUrl = () => state.me?.portal_url || state.auth.portal_url || null;
91function notify(text) {
92  $("notice").textContent = text;
93  $("notice").hidden = false;
94  clearTimeout(notify.timer);
95  notify.timer = setTimeout(() => ($("notice").hidden = true), 6000);
96}
97async function api(path, options = {}) {
98  const response = await fetch(path, {
99    credentials: "same-origin",
100    signal: AbortSignal.timeout(30000),
101    ...options,
102    headers: {
103      ...(options.body ? { "Content-Type": "application/json" } : {}),
104      ...options.headers,
105    },
106  });
107  const data = await response.json().catch(() => ({}));
108  if (!response.ok) {
109    // app/v2 answers {"detail": {"code", "retryable", ...}}; the shared sign-in and workspace
110    // layer answers a plain string such as "workspace_not_ready".
111    const detail = data.detail;
112    const error = new Error(
113      (typeof detail === "string" ? detail : detail?.code) || "request_failed",
114    );
115    error.status = response.status;
116    error.detail = detail && typeof detail === "object" ? detail : {};
117    throw error;
118  }
119  return data;
120}
121function modal(title, body) {
122  $("dialogTitle").textContent = title;
123  $("dialogBody").replaceChildren(body);
124  $("dialog").showModal();
125}
126let recording = null;
127let assistant = null;
128let assistantKind = null;
129function closeAssistant() {
130  assistant?.close();
131  assistant = null;
132  assistantKind = null;
133  $("assistantHost").hidden = true;
134  $("shell").classList.remove("with-assistant");
135  $("library").inert = false;
136  document.querySelector("main").inert = false;
137}
138function showAssistant(kind) {
139  if (assistantKind === kind) {
140    closeAssistant();
141    return;
142  }
143  closeAssistant();
144  if (!state.selected || !state.me?.features?.[kind]) return;
145  $("assistantHost").hidden = false;
146  $("shell").classList.add("with-assistant");
147  syncAssistantFocus();
148  assistantKind = kind;
149  assistant = openAssistant({
150    kind,
151    recordingId: state.selected,
152    host: $("assistantHost"),
153    api,
154    identity: { ...state.me },
155    canEdit: !!state.doc?.can_edit && !state.me.read_only,
156    onError: notify,
157  });
158  $("assistantHost").querySelector("button")?.focus();
159}
160const focusedAssistant = matchMedia("(max-width: 1200px)");
161function syncAssistantFocus() {
162  const focused = !$("assistantHost").hidden && focusedAssistant.matches;
163  $("library").inert = focused;
164  document.querySelector("main").inert = focused;
165}
166focusedAssistant.addEventListener("change", syncAssistantFocus);
167document.addEventListener("keydown", (event) => {
168  if (
169    event.key === "Escape" &&
170    !$("assistantHost").hidden &&
171    !$("dialog").open
172  ) {
173    closeAssistant();
174    $("openChat").hidden ? $("openActions").focus() : $("openChat").focus();
175  }
176});
177$("openChat").onclick = () => showAssistant("chat");
178$("openActions").onclick = () => showAssistant("actions");
179// The panel's own Close button removes it; restore the reader layout and focus.
180new MutationObserver(() => {
181  if (!$("assistantHost").children.length && !$("assistantHost").hidden) {
182    closeAssistant();
183    $("openChat").hidden ? $("openActions").focus() : $("openChat").focus();
184  }
185}).observe($("assistantHost"), { childList: true });
186function banner(message, { portal = false, retry = null } = {}) {
187  $("captureBanner").hidden = !message;
188  $("captureMessage").textContent = message || "";
189  const url = portalUrl();
190  $("capturePortal").hidden = !portal || !url;
191  if (url) $("capturePortal").href = url;
192  $("captureRetry").hidden = !retry;
193  $("captureRetry").onclick = retry;
194}
195function captureState(phase, message) {
196  banner(message, {
197    portal: phase === "credit",
198    retry: phase === "credit" ? retryCredit : null,
199  });
200  $("newCapture").textContent = recording?.active ? "Stop recording" : "Record";
201  $("pauseCapture").hidden = !["recording", "paused"].includes(phase);
202  $("pauseCapture").textContent = phase === "paused" ? "Resume" : "Pause";
203  $("newCapture").disabled = ["permission", "saving"].includes(phase);
204  $("recoverCapture").hidden =
205    !recording || !["pending", "attention", "credit"].includes(phase);
206}
207// A capture that did not start or finish: 402 insufficient_credit left the recording waiting
208// for credit at the platform (phase pending_credit) and "Try again" resumes that very recording;
209// everything else is a fact about this device or this sign-in.
210function captureFailed(error) {
211  if (error.status === 401 || error.message === "access_revoked")
212    return signIn();
213  if (error.message === "insufficient_credit" && recording?.pendingCredit)
214    return captureState("credit", MESSAGES.insufficient_credit);
215  captureState("attention", describe(error));
216}
217async function retryCredit() {
218  const pending = recording?.pendingCredit;
219  if (!pending || recording.active) return;
220  $("captureRetry").disabled = true;
221  try {
222    await recording.start({
223      meeting: pending.meeting,
224      resume: pending.recordingId,
225    });
226  } catch (error) {
227    captureFailed(error);
228  } finally {
229    $("captureRetry").disabled = false;
230  }
231}
232// A same-origin path to continue to after sign-in (the extension and iOS consent pages use it).
233function loginNext() {
234  const next = new URLSearchParams(location.search).get("next") || "";
235  return next.startsWith("/") && !next.startsWith("//") ? next : "";
236}
237async function loadAuth() {
238  if (state.authLoaded) return state.auth;
239  try {
240    state.auth = await api("/api/auth/config");
241    state.authLoaded = true;
242  } catch {
243    /* keep the Community default until the server answers */
244  }
245  return state.auth;
246}
247function renderSignIn() {
248  const hosted = state.auth.identity === "oddproof";
249  $("oddproofSignIn").hidden = !hosted;
250  $("accountHint").hidden = !hosted || !state.auth.account_url;
251  if (state.auth.account_url) $("accountLink").href = state.auth.account_url;
252  $("loginForm").hidden = hosted;
253}
254async function signIn() {
255  closeAssistant();
256  recording?.revoke();
257  recording = null;
258  state.request++;
259  state.authGeneration++;
260  state.me = null;
261  $("dialog").close();
262  $("dialogBody").replaceChildren();
263  state.selected = null;
264  state.doc = null;
265  state.items = [];
266  $("transcript").replaceChildren();
267  $("recordings").replaceChildren();
268  $("shell").hidden = true;
269  $("gate").hidden = true;
270  $("login").hidden = false;
271  $("password").value = "";
272  const code = new URLSearchParams(location.search).get("login_error");
273  if (code)
274    $("loginError").textContent =
275      LOGIN_ERRORS[code] || `Sign-in did not complete (${code}). Try again.`;
276  const generation = state.authGeneration;
277  renderSignIn();
278  await loadAuth();
279  if (generation === state.authGeneration && !state.me) renderSignIn();
280}
281$("oddproofSignIn").onclick = () => {
282  const next = loginNext() || "/";
283  location.href = `${state.auth.login_url || "/auth/login"}?next=${encodeURIComponent(next)}`;
284};
285async function logout() {
286  await api("/api/auth/logout", { method: "POST" }).catch(() => {});
287  location.replace("/");
288}
289function fail(error) {
290  if (error.status === 401 || error.message === "access_revoked")
291    return signIn();
292  if (error.message === "workspace_not_ready")
293    return recording?.active
294      ? notify(MESSAGES.workspace_not_ready)
295      : gate();
296  if (error.message === "platform_recovering") {
297    $("connection").textContent = "Oddproof is restoring…";
298    return notify(MESSAGES.platform_recovering);
299  }
300  notify(describe(error));
301}
302function workspaceLabel(me) {
303  const base =
304    me.workspace_kind === "team"
305      ? `${me.workspace_name || "Team"} · ${me.role || "member"}`
306      : "Personal workspace";
307  return me.read_only ? `${base} · Read only` : base;
308}
309async function init() {
310  const generation = ++state.authGeneration;
311  if (recording && !recording.active) {
312    recording.revoke();
313    recording = null;
314  }
315  $("gate").hidden = true;
316  let me;
317  try {
318    me = await api("/api/v2/me");
319  } catch (error) {
320    if (generation !== state.authGeneration) return;
321    if (error.status === 401 || error.message === "access_revoked")
322      return signIn();
323    if (error.message === "workspace_not_ready") return gate();
324    if (error.message === "platform_recovering") return recovering();
325    $("shell").hidden = true;
326    $("login").hidden = false;
327    renderSignIn();
328    $("loginError").textContent = describe(error);
329    return;
330  }
331  if (generation !== state.authGeneration) return;
332  state.me = me;
333  const next = loginNext();
334  if (next && next !== location.pathname) {
335    location.href = next;
336    return;
337  }
338  $("login").hidden = true;
339  $("loginError").textContent = "";
340  $("shell").hidden = false;
341  $("workspace").textContent = workspaceLabel(me);
342  let cap;
343  try {
344    cap = await api("/api/v2/capabilities");
345  } catch (error) {
346    return fail(error);
347  }
348  if (generation !== state.authGeneration) return;
349  state.cap = cap;
350  $("captureStatus").textContent = cap.transcription
351    ? "Ready for your next recording."
352    : "Transcription is not enabled on this server yet.";
353  const canRecord =
354    cap.web_capture &&
355    (cap.batch_transcription || cap.live_transcription) &&
356    !me.read_only;
357  if (canRecord) {
358    recording = new BrowserRecording({
359      api,
360      live: Boolean(cap.live_transcription),
361      identity: { ...me },
362      onState: (phase, message) => {
363        if (generation === state.authGeneration) captureState(phase, message);
364      },
365      onSaved: async (id) => {
366        if (generation !== state.authGeneration) return;
367        await library();
368        await select(id);
369      },
370    });
371    $("newCapture").hidden = false;
372    $("recoverCapture").hidden = false;
373    banner("");
374  } else {
375    $("newCapture").hidden = true;
376    $("pauseCapture").hidden = true;
377    $("recoverCapture").hidden = true;
378    const seat = me.read_only && me.workspace_kind === "team";
379    banner(
380      me.read_only
381        ? seat
382          ? MESSAGES.seat_required
383          : "Your workspace is read-only."
384        : "",
385      { portal: seat },
386    );
387  }
388  await library();
389}
390function showGate(title, paragraphs, actions) {
391  $("shell").hidden = true;
392  $("login").hidden = true;
393  $("gate").hidden = false;
394  $("gateTitle").textContent = title;
395  $("gateBody").replaceChildren(...paragraphs.map((text) => node("p", text)));
396  $("gateError").textContent = "";
397  $("gateActions").replaceChildren(...actions);
398}
399function gateButton(label, action, cls) {
400  const button = node("button", label, cls);
401  button.type = "button";
402  button.onclick = async () => {
403    button.disabled = true;
404    $("gateError").textContent = "";
405    try {
406      await action();
407    } catch (error) {
408      $("gateError").textContent = describe(error);
409    } finally {
410      button.disabled = false;
411    }
412  };
413  return button;
414}
415// Every account works in exactly one workspace: its team, otherwise its personal one. A
416// membership the platform cannot settle (two teams, platform unreachable) blocks the app until
417// it is settled, and recording into a team a person just joined is an explicit step when they
418// already own recordings, so nothing lands in the wrong place.
419async function gate() {
420  let ws;
421  try {
422    ws = await api("/api/workspace");
423  } catch (error) {
424    if (error.status === 401) return signIn();
425    if (error.message === "platform_recovering") return recovering();
426    ws = { state: "unavailable", note: describe(error) };
427  }
428  const portal = portalUrl();
429  if (ws.state === "switch_required" && ws.pending) {
430    const n = ws.pending.recordings ?? 0;
431    const them = n === 1 ? "it" : "them";
432    showGate(
433      `Your account is now part of ${ws.pending.name || "a team"}`,
434      [
435        `Minutes keeps one workspace per account. You own ${n} recording${n === 1 ? "" : "s"} in your personal workspace. Continue to move ${them} into the team, where current and future team admins can see ${them}.`,
436        "If you would rather keep them private, export them first, then come back; or ask the team owner to invite a different email address.",
437      ],
438      [
439        gateButton(
440          "Move my recordings and continue",
441          async () => {
442            await api("/api/workspace/switch", {
443              method: "POST",
444              body: JSON.stringify({ confirmation: "MOVE RECORDINGS" }),
445            });
446            await init();
447          },
448          "primary",
449        ),
450        gateButton("Sign out", logout),
451      ],
452    );
453    return;
454  }
455  const reasons = {
456    ambiguous: [
457      "Your account belongs to more than one Minutes team",
458      "Minutes keeps one workspace per account. Ask the owners of the other teams to remove you, then check again.",
459    ],
460    unavailable: [
461      "Oddproof could not be reached",
462      "Minutes needs to read your account's teams before it can show your recordings. Check again in a moment.",
463    ],
464  };
465  const [title, text] = reasons[ws.state] || [
466    "Your workspace is not ready",
467    `State: ${ws.state}${ws.note ? ` (${ws.note})` : ""}. Check again in a moment.`,
468  ];
469  const actions = [
470    gateButton(
471      "Check again",
472      async () => {
473        await api("/api/workspace/refresh", { method: "POST" });
474        await init();
475      },
476      "primary",
477    ),
478  ];
479  if (portal) actions.push(link("Open Oddproof", portal, "portal-link"));
480  actions.push(gateButton("Sign out", logout));
481  showGate(title, [text], actions);
482}
483function recovering() {
484  showGate(
485    "Oddproof is restoring",
486    [
487      "Content is briefly unavailable while Oddproof restores. Your recordings are safe; try again in a moment.",
488    ],
489    [gateButton("Try again", init, "primary"), gateButton("Sign out", logout)],
490  );
491}
492async function library(append = false) {
493  const generation = state.authGeneration;
494  const request = ++state.libraryRequest;
495  const query = $("search").value.trim();
496  const cursor = append ? state.items.at(-1)?.id : null;
497  $("libraryMore").disabled = true;
498  const params = new URLSearchParams({ limit: "50", q: query });
499  if (cursor) params.set("before", cursor);
500  try {
501    const items = await api("/api/v2/recordings?" + params);
502    if (
503      generation !== state.authGeneration ||
504      request !== state.libraryRequest ||
505      query !== $("search").value.trim()
506    )
507      return;
508    state.items = append
509      ? [
510          ...state.items,
511          ...items.filter(
512            (item) => !state.items.some((old) => old.id === item.id),
513          ),
514        ]
515      : items;
516    state.libraryQuery = query;
517    state.libraryMore = items.length === 50;
518    renderLibrary();
519  } catch (error) {
520    if (generation !== state.authGeneration || request !== state.libraryRequest)
521      return;
522    if (append && error.message === "library_changed") {
523      await library();
524      notify("Your library changed. Its latest recordings are shown.");
525    } else fail(error);
526  } finally {
527    if (request === state.libraryRequest) $("libraryMore").disabled = false;
528  }
529}
530function renderLibrary() {
531  $("recordings").replaceChildren();
532  for (const item of state.items) {
533    const b = node("button", undefined, "recording");
534    b.setAttribute("aria-current", String(item.id === state.selected));
535    b.append(
536      node("strong", item.title),
537      node(
538        "small",
539        new Date(item.created_at).toLocaleDateString(undefined, {
540          month: "short",
541          day: "numeric",
542        }) +
543          " · " +
544          phaseLabel(item.phase),
545      ),
546    );
547    b.onclick = () => select(item.id);
548    $("recordings").append(b);
549  }
550  $("libraryMore").hidden = !state.libraryMore;
551  if (!state.items.length)
552    $("recordings").append(
553      node(
554        "p",
555        state.libraryQuery ? "No matching recordings." : "No recordings yet.",
556        "muted",
557      ),
558    );
559}
560async function select(id) {
561  if (id !== state.selected) closeAssistant();
562  const request = ++state.request;
563  state.selected = id;
564  $("library").classList.remove("open");
565  renderLibrary();
566  try {
567    const doc = await api("/api/v2/recordings/" + id);
568    if (request !== state.request) return;
569    state.doc = doc;
570    renderDoc();
571  } catch (error) {
572    if (request === state.request) fail(error);
573  }
574}
575function time(seconds) {
576  return (
577    Math.floor(seconds / 60)
578      .toString()
579      .padStart(2, "0") +
580    ":" +
581    Math.floor(seconds % 60)
582      .toString()
583      .padStart(2, "0")
584  );
585}
586// The recording's state is never hidden: while the transcriber is still working the phase
587// line says how far it is (parts committed of parts recorded) and what that means for the
588// reader, refreshed every few seconds until the recording is complete.
589let progressTimer = null;
590// What the server's live state means for the reader, in its words: the code the server
591// recorded is the reason, never a guess.
592function progressReason(status, code) {
593  if (status === "pending_credit" || code === "insufficient_credit")
594    return "waiting for credit at Oddproof";
595  if (status === "needs_recovery" || status === "blocked") return "held, Retry continues it";
596  if (code === "rate_limited") return "waiting for a free live session at Oddproof, retrying";
597  if (code === "provider_unavailable") return "the transcription provider is unavailable, retrying";
598  if (code) return "recovering after an interrupted session, retrying";
599  return "text appears as it arrives; Actions run when it is complete";
600}
601function showProgress(doc) {
602  clearTimeout(progressTimer);
603  const recordingId = state.selected,
604    generation = state.authGeneration;
605  if (publicToken || !recordingId || doc.phase === "complete") return;
606  const tick = async () => {
607    let coverage;
608    try {
609      coverage = await api(`/api/v2/recordings/${recordingId}/coverage`);
610    } catch {
611      return;
612    }
613    if (recordingId !== state.selected || generation !== state.authGeneration) return;
614    const ranges = coverage.ranges || [];
615    const done = ranges.filter((r) => r.committed).length;
616    const live = coverage.live_checkpoint || {};
617    if (coverage.phase === "complete") {
618      $("phase").textContent = PHASES.complete || "Transcribed";
619      return;
620    }
621    const reason = progressReason(live.status, live.error_code);
622    $("phase").textContent =
623      `Transcribing · ${done} of ${ranges.length} parts · ${reason}`;
624    progressTimer = setTimeout(tick, 5000);
625  };
626  tick();
627}
628function renderDoc() {
629  const doc = state.doc;
630  $("empty").hidden = true;
631  $("detail").hidden = false;
632  $("title").textContent = doc.title;
633  $("phase").textContent = PHASES[doc.phase] || phaseLabel(doc.phase);
634  showProgress(doc);
635  const portal = portalUrl();
636  $("phasePortal").hidden =
637    doc.phase !== "pending_credit" || !portal || !!publicToken;
638  if (portal) $("phasePortal").href = portal;
639  $("retryTranscription").hidden =
640    !doc.can_edit ||
641    !["pending_credit", "needs_attention", "audio_expired"].includes(doc.phase);
642  $("breadcrumb").textContent = "Your recordings";
643  $("openChat").hidden =
644    !state.me?.features?.chat || !doc.can_assistant_read || !!publicToken;
645  $("openActions").hidden =
646    !state.me?.features?.actions || !doc.can_assistant_read || !!publicToken;
647  $("translate").hidden = !doc.can_edit || !state.me?.features?.translation;
648  $("share").hidden = !doc.can_edit;
649  $("remove").hidden = !doc.can_edit;
650  $("transcript").replaceChildren();
651  for (const segment of doc.segments) {
652    const passage = node("section", undefined, "passage");
653    passage.dataset.id = segment.id;
654    const content = node("div");
655    const speaker = node("button", segment.speaker, "speaker");
656    speaker.disabled = !doc.can_edit || !segment.speaker_binding;
657    speaker.onclick = () => rename(segment);
658    content.append(speaker);
659    const columns = node("div", undefined, "passage-columns");
660    columns.append(node("p", segment.text));
661    if (segment.translations?.length) {
662      const translations = node("div", undefined, "translations");
663      for (const translated of segment.translations) {
664        const translation = node("section");
665        translation.append(
666          node("small", translated.language),
667          node("p", translated.text),
668        );
669        translation.lang = translated.language;
670        translation.dir = "auto";
671        translations.append(translation);
672      }
673      columns.classList.add("translated");
674      columns.append(translations);
675    }
676    content.append(columns);
677    passage.append(node("span", time(segment.start_seconds), "time"), content);
678    $("transcript").append(passage);
679  }
680  if (doc.live?.hypothesis?.length) {
681    const interim = node("section", undefined, "live-hypothesis");
682    interim.append(
683      node("small", "Live draft · may change"),
684      node("p", doc.live.hypothesis.map((t) => t.text).join("")),
685    );
686    interim.dir = "auto";
687    $("transcript").append(interim);
688  }
689  if (!doc.segments.length && !doc.live?.hypothesis?.length)
690    $("transcript").append(
691      node(
692        "p",
693        doc.phase === "complete"
694          ? "No speech was found in this recording."
695          : "Your transcript will appear here when processing completes.",
696        "muted",
697      ),
698    );
699  $("wordCount").textContent = doc.segments.length + " passages";
700  $("more").hidden = doc.next_offset === null;
701}
702function rename(segment) {
703  const form = node("form");
704  const label = node("label", "Speaker name");
705  const input = node("input");
706  input.value = segment.speaker;
707  input.maxLength = 80;
708  input.required = true;
709  label.append(input);
710  const save = node("button", "Save name", "primary");
711  form.append(label, save);
712  form.onsubmit = async (e) => {
713    e.preventDefault();
714    save.disabled = true;
715    try {
716      await api("/api/v2/recordings/" + state.selected + "/speakers", {
717        method: "PUT",
718        body: JSON.stringify({
719          binding: segment.speaker_binding,
720          name: input.value.trim(),
721        }),
722      });
723      $("dialog").close();
724      await select(state.selected);
725    } catch (error) {
726      fail(error);
727      save.disabled = false;
728    }
729  };
730  modal("Rename speaker", form);
731  input.focus();
732  input.select();
733}
734$("loginForm").onsubmit = async (e) => {
735  e.preventDefault();
736  const button = e.target.querySelector("button");
737  button.disabled = true;
738  $("loginError").textContent = "";
739  try {
740    await api("/api/auth/login", {
741      method: "POST",
742      body: JSON.stringify({
743        email: $("email").value.trim(),
744        password: $("password").value,
745      }),
746    });
747    $("password").value = "";
748    const next = loginNext();
749    if (next) {
750      location.href = next;
751      return;
752    }
753    await init();
754  } catch (error) {
755    $("loginError").textContent =
756      error.status === 429
757        ? "Too many sign-in attempts. Please wait before trying again."
758        : error.message === "password_sign_in_disabled"
759          ? "This server signs people in through Oddproof."
760          : "Sign-in failed. Check your email and password.";
761  } finally {
762    button.disabled = false;
763  }
764};
765$("newCapture").onclick = async () => {
766  if (recording?.active) {
767    try {
768      await recording.stop();
769    } catch (error) {
770      fail(error);
771    }
772    return;
773  }
774  const choices = node("div");
775  choices.append(
776    node(
777      "p",
778      recording?.live
779        ? "Record your microphone, or include meeting audio from a shared tab. Your transcript appears as you speak."
780        : "Record your microphone, or include meeting audio from a shared tab. Transcription starts after you stop.",
781    ),
782  );
783  const mic = loadMicPreference();
784  choices.append(
785    node(
786      "p",
787      mic.label ? `Input: ${mic.label}` : "Input: system default microphone",
788      "muted",
789    ),
790  );
791  for (const [meeting, label] of [
792    [false, "Microphone"],
793    [true, "Meeting + microphone"],
794  ]) {
795    const button = node("button", label);
796    button.onclick = async () => {
797      $("dialog").close();
798      // The first recording in this browser, or one after a dead input: the microphone check
799      // runs first and recording starts only from a confirmed input.
800      if (!loadMicPreference().verified) {
801        const confirmed = await openMicrophoneCheck({ modal, dialog: $("dialog") });
802        if (!confirmed?.verified) return;
803      }
804      $("newCapture").disabled = true;
805      try {
806        await recording.start({ meeting });
807      } catch (error) {
808        captureFailed(error);
809      } finally {
810        $("newCapture").disabled = false;
811      }
812    };
813    choices.append(button);
814  }
815  modal("New recording", choices);
816};
817$("pauseCapture").onclick = () =>
818  recording?.pause($("pauseCapture").textContent === "Pause");
819$("recoverCapture").onclick = async () => {
820  $("recoverCapture").disabled = true;
821  try {
822    const count = await recording.recover();
823    notify(count ? "Saved recordings synchronized." : "No pending uploads.");
824    await library();
825  } catch (error) {
826    fail(error);
827  } finally {
828    $("recoverCapture").disabled = false;
829  }
830};
831window.addEventListener("beforeunload", (event) => {
832  if (recording?.active) {
833    event.preventDefault();
834    event.returnValue = "";
835  }
836});
837let searchTimer;
838$("search").oninput = () => {
839  ++state.libraryRequest;
840  clearTimeout(searchTimer);
841  searchTimer = setTimeout(() => library(), 250);
842};
843$("libraryMore").onclick = () => library(true);
844$("openLibrary").onclick = () => {
845  $("library").classList.add("open");
846  $("closeLibrary").focus();
847};
848$("closeLibrary").onclick = () => {
849  $("library").classList.remove("open");
850  $("openLibrary").focus();
851};
852$("logout").onclick = logout;
853$("more").onclick = async () => {
854  const id = state.selected;
855  try {
856    const page = await api(
857      `/api/v2/recordings/${id}?offset=${state.doc.next_offset}&watermark=${state.doc.watermark}`,
858    );
859    if (id !== state.selected) return;
860    state.doc = {
861      ...page,
862      segments: [...state.doc.segments, ...page.segments],
863    };
864    renderDoc();
865  } catch (error) {
866    if (error.message === "snapshot_changed") {
867      await select(id);
868      notify("The transcript changed. Refreshed to its latest version.");
869    } else fail(error);
870  }
871};
872$("share").onclick = () => {
873  const body = node("div");
874  body.append(
875    node(
876      "p",
877      "Anyone with this link can read the transcript and speaker names. Creating a new link revokes the previous one.",
878    ),
879  );
880  const create = node("button", "Create a new link", "primary");
881  const revoke = node("button", "Revoke current link");
882  body.append(create, revoke);
883  create.onclick = async () => {
884    try {
885      const data = await api(
886        "/api/v2/recordings/" + state.selected + "/public-link",
887        { method: "POST" },
888      );
889      const field = node("input");
890      field.readOnly = true;
891      field.value = location.origin + "/v2/public/" + data.token;
892      field.setAttribute("aria-label", "Public recording link");
893      body.replaceChildren(
894        node("p", "Link created. Copy it to share this recording."),
895        field,
896      );
897      field.select();
898    } catch (error) {
899      fail(error);
900    }
901  };
902  revoke.onclick = async () => {
903    try {
904      await api("/api/v2/recordings/" + state.selected + "/public-link", {
905        method: "DELETE",
906      });
907      $("dialog").close();
908      notify("Public link revoked.");
909    } catch (error) {
910      fail(error);
911    }
912  };
913  modal("Share recording", body);
914};
915$("remove").onclick = () => {
916  const id = state.selected;
917  const body = node("div");
918  body.append(
919    node(
920      "p",
921      "Delete this recording and its transcript? Public access stops immediately. Background cleanup removes stored content.",
922    ),
923  );
924  const confirm = node("button", "Delete recording", "danger");
925  body.append(confirm);
926  confirm.onclick = async () => {
927    confirm.disabled = true;
928    try {
929      await api("/api/v2/recordings/" + id, { method: "DELETE" });
930      $("dialog").close();
931      state.request++;
932      closeAssistant();
933      state.selected = null;
934      state.doc = null;
935      $("detail").hidden = true;
936      $("empty").hidden = false;
937      await library();
938      notify("Recording deleted.");
939    } catch (error) {
940      fail(error);
941      confirm.disabled = false;
942    }
943  };
944  modal("Delete recording?", body);
945};
946$("export").onclick = () => {
947  const recordingId = state.selected,
948    generation = state.authGeneration;
949  const body = node("div");
950  body.append(
951    node(
952      "p",
953      "Export the complete finalized transcript, speaker names and available translations. Chat and Action results are separate.",
954    ),
955  );
956  for (const [format, label] of [
957    ["copy", "Copy to clipboard"],
958    ["txt", "Text file"],
959    ["json", "JSON file"],
960  ]) {
961    const button = node("button", label);
962    body.append(button);
963    button.onclick = async () => {
964      button.disabled = true;
965      try {
966        const base = publicToken
967          ? `/api/v2/public/${publicToken}`
968          : `/api/v2/recordings/${recordingId}`;
969        const snapshot = await completeSnapshot((offset, watermark) =>
970          api(
971            base + (offset ? `?offset=${offset}&watermark=${watermark}` : ""),
972          ),
973        );
974        if (
975          !publicToken &&
976          (generation !== state.authGeneration ||
977            recordingId !== state.selected)
978        )
979          return;
980        if (format === "copy") {
981          await navigator.clipboard.writeText(textExport(snapshot));
982          notify("Complete transcript copied to the clipboard.");
983        } else {
984          downloadSnapshot(snapshot, format);
985          notify("Complete transcript exported.");
986        }
987      } catch (error) {
988        fail(error);
989      } finally {
990        button.disabled = false;
991      }
992    };
993  }
994  modal("Export recording", body);
995};
996$("retryTranscription").onclick = async () => {
997  const id = state.selected;
998  $("retryTranscription").disabled = true;
999  try {
1000    await api(`/api/v2/recordings/${id}/retry`, {
1001      method: "POST",
1002      body: JSON.stringify({ operation_id: crypto.randomUUID() }),
1003    });
1004    await select(id);
1005  } catch (error) {
1006    fail(error);
1007  } finally {
1008    $("retryTranscription").disabled = false;
1009  }
1010};
1011$("translate").onclick = async () => {
1012  const id = state.selected;
1013  const generation = state.authGeneration;
1014  try {
1015    await openTranslation({
1016      api,
1017      recordingId: id,
1018      watermark: state.doc.watermark,
1019      identity: { ...state.me },
1020      modal: (title, body) => {
1021        if (generation === state.authGeneration && id === state.selected)
1022          modal(title, body);
1023      },
1024      onQueued: () =>
1025        notify("Translation queued. Results will appear beside the original."),
1026    });
1027  } catch (error) {
1028    fail(error);
1029  }
1030};
1031$("workspaceSettings").onclick = async () => {
1032  try {
1033    await openWorkspace({
1034      api,
1035      modal,
1036      identity: { ...state.me },
1037      auth: state.auth,
1038    });
1039  } catch (error) {
1040    fail(error);
1041  }
1042};
1043$("microphone").onclick = () => {
1044  openMicrophoneCheck({ modal, dialog: $("dialog") });
1045};
1046$("settings").onclick = async () => {
1047  try {
1048    await openModelSettings({ api, modal, identity: { ...state.me } });
1049  } catch (error) {
1050    fail(error);
1051  }
1052};
1053document.addEventListener("keydown", (e) => {
1054  if (!$("library").classList.contains("open")) return;
1055  if (e.key === "Escape") $("closeLibrary").click();
1056  if (e.key === "Tab") {
1057    const focus = [...$("library").querySelectorAll("button,input,a")].filter(
1058      (n) => n.getClientRects().length,
1059    );
1060    if (e.shiftKey && document.activeElement === focus[0]) {
1061      e.preventDefault();
1062      focus.at(-1).focus();
1063    } else if (!e.shiftKey && document.activeElement === focus.at(-1)) {
1064      e.preventDefault();
1065      focus[0].focus();
1066    }
1067  }
1068});
1069let readerPollRunning = false,
1070  readerPollAt = 0;
1071setInterval(async () => {
1072  if (
1073    readerPollRunning ||
1074    Date.now() - readerPollAt < (recording?.active ? 900 : 4900)
1075  )
1076    return;
1077  if (document.hidden || !state.me || !state.selected || $("dialog").open)
1078    return;
1079  const id = state.selected,
1080    generation = state.authGeneration;
1081  readerPollRunning = true;
1082  readerPollAt = Date.now();
1083  try {
1084    const doc = await api("/api/v2/recordings/" + id);
1085    if (id !== state.selected || generation !== state.authGeneration) return;
1086    $("connection").textContent = "Up to date";
1087    if (doc.watermark !== state.doc?.watermark) {
1088      const anchor = [...document.querySelectorAll(".passage")].find(
1089        (n) => n.getBoundingClientRect().bottom > 0,
1090      );
1091      const top = anchor?.getBoundingClientRect().top;
1092      state.doc = doc;
1093      renderDoc();
1094      if (anchor) {
1095        const replacement = [...document.querySelectorAll(".passage")].find(
1096          (n) => n.dataset.id === anchor.dataset.id,
1097        );
1098        if (replacement)
1099          scrollBy(0, replacement.getBoundingClientRect().top - top);
1100      }
1101    }
1102  } catch (error) {
1103    if (generation !== state.authGeneration || id !== state.selected) return;
1104    if (error.status === 401) signIn();
1105    else
1106      $("connection").textContent =
1107        error.message === "platform_recovering"
1108          ? "Oddproof is restoring…"
1109          : "Reconnecting…";
1110  } finally {
1111    readerPollRunning = false;
1112  }
1113}, 1000);
1114if (publicToken) {
1115  document.body.classList.add("public");
1116  $("shell").hidden = false;
1117  $("library").hidden = true;
1118  $("openLibrary").hidden = true;
1119  $("breadcrumb").textContent = "Shared recording";
1120  try {
1121    state.doc = await api("/api/v2/public/" + publicToken);
1122    renderDoc();
1123    $("more").onclick = async () => {
1124      try {
1125        const page = await api(
1126          `/api/v2/public/${publicToken}?offset=${state.doc.next_offset}&watermark=${state.doc.watermark}`,
1127        );
1128        state.doc = {
1129          ...page,
1130          segments: [...state.doc.segments, ...page.segments],
1131        };
1132        renderDoc();
1133      } catch (error) {
1134        notify("The shared recording changed. Reload to continue.");
1135      }
1136    };
1137    setInterval(async () => {
1138      if (document.hidden) return;
1139      try {
1140        const doc = await api("/api/v2/public/" + publicToken);
1141        if (doc.watermark !== state.doc.watermark) {
1142          state.doc = doc;
1143          renderDoc();
1144        }
1145      } catch {
1146        $("detail").hidden = true;
1147        $("empty").hidden = false;
1148        $("empty").replaceChildren(
1149          node("h1", "This link is no longer available."),
1150        );
1151      }
1152    }, 5000);
1153  } catch {
1154    $("empty").replaceChildren(node("h1", "This link is not available."));
1155  }
1156} else {
1157  await loadAuth();
1158  await init();
1159}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.