Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 6d1e54c9eadeb413ec869ab3d78a0f20e9594ac0
parent 1676f41fd7046a4671b4ec532483208f2ad5da14
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 18 Aug 2026 10:12:19 -0400

Let a search become an edit

Named shortlists in song/shortlist.json, plus a JSON and a markdown export.
No renderer here, deliberately: the deliverable is an ordered, named,
self-describing list, and whatever consumes it should not be able to change
what the list means by being rewritten.

ORDER IS THE EDIT, so picks are an array -- never a Set, never a Record. Any
container that does not preserve the sequence throws away the only decision
this part of the tool exists to record.

A pick is a SNAPSHOT, not a reference. Storing only `<video>#<wordIndex>` would
let a re-transcription silently re-point it at a different word, because the
indices shift the moment a chunk boundary moves and nothing would say so. With
the text, the window and the query written down, drift becomes DETECTABLE
rather than invisible -- and `q` is what lets a list of forty moments still
explain itself six weeks later.

`reorder` takes a WHOLE PERMUTATION of the current ids rather than an index
move: atomic, idempotent, and a missing, unknown or duplicated id comes back as
a 400 instead of being reconciled into an order nobody asked for. `add` is
idempotent by id -- a moment already on the list keeps its position and its
note, since repeating a sound in a cut is the renderer's job and duplicate ids
would make the permutation check meaningless. Rename keeps the slug, which is
the identity and is in every URL anyone kept.

Every mutation is one withStateLock(read -> mutate -> writeJsonAtomic) with the
READ INSIDE THE LOCK, per lib/state.ts's preamble. A lost update here is lost
human judgement about what belongs in a cut, which is exactly the sort of thing
nobody notices going missing.

The two client components stay in sync over one window event carrying the whole
list the server just wrote. A router.refresh() would have been the obvious
alternative and is wrong: it re-runs the search, rebuilds the rows and throws
away which row was focused, on every single star.

Verified live: reorder permutes, a short or unknown-id reorder 400s, an unknown
list 404s, a duplicate name 409s, re-adding a pick keeps both its position and
its note, and both exports come back as attachments.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

Diffstat:
Aumtool/app/api/shortlist/route.ts | 125+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/app/browse/find/page.tsx | 22+++++++++++++++++++++-
Mumtool/components/PhraseConsole.tsx | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Aumtool/components/ShortlistBar.tsx | 252+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/shortlist-types.ts | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/lib/shortlist.ts | 260+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 928 insertions(+), 3 deletions(-)

diff --git a/umtool/app/api/shortlist/route.ts b/umtool/app/api/shortlist/route.ts @@ -0,0 +1,125 @@ +import { + ShortlistError, + addPick, + createShortlist, + deleteShortlist, + exportJson, + exportMarkdown, + isShortlistBody, + listShortlists, + noteShortlist, + readShortlist, + removePick, + renameShortlist, + reorderShortlist, +} from "@/lib/shortlist"; + +export const dynamic = "force-dynamic"; + +// The shortlists. +// +// GET every list, as summaries +// GET ?list=<slug> one list, in full +// GET ?list=<slug>&format=md the same list for a person to read +// GET ?list=<slug>&format=json THE EXPORT -- what a renderer consumes +// POST <ShortlistBody> create · add · remove · note · reorder · rename · delete +// +// The two export formats answer different questions and neither is the other's +// pretty-printing: the JSON is an ordered list of absolute episode windows on +// the same timeline as wav48/ and media/, and the markdown is what you paste +// into a note six weeks later to remember why these forty moments. + +const noStore = { "cache-control": "no-store" }; + +export async function GET(request: Request) { + const url = new URL(request.url); + const slug = url.searchParams.get("list"); + if (!slug) { + return Response.json({ lists: await listShortlists() }, { headers: noStore }); + } + + const list = await readShortlist(slug); + if (!list) return Response.json({ error: `no shortlist called ${slug}` }, { status: 404 }); + + const format = url.searchParams.get("format"); + // An EXPORT is a file, so it is served as one. Handing it back inline would + // make the obvious next step "select all and copy", which loses the order to + // whatever the clipboard does with a wrapped line. + if (format === "md") { + return new Response(exportMarkdown(list), { + headers: { + ...noStore, + "content-type": "text/markdown; charset=utf-8", + "content-disposition": `attachment; filename="${slug}.md"`, + }, + }); + } + if (format === "json") { + return new Response(JSON.stringify(exportJson(list), null, 1), { + headers: { + ...noStore, + "content-type": "application/json; charset=utf-8", + "content-disposition": `attachment; filename="${slug}.json"`, + }, + }); + } + + return Response.json({ list }, { headers: noStore }); +} + +export async function POST(request: Request) { + let body: unknown; + try { + body = await request.json(); + } catch { + return Response.json({ error: "bad json" }, { status: 400 }); + } + if (!isShortlistBody(body)) { + return Response.json({ error: "not a shortlist op" }, { status: 400 }); + } + + try { + switch (body.op) { + case "create": + return Response.json( + { ok: true, list: await createShortlist(body.name, body.note) }, + { headers: noStore }, + ); + case "add": + return Response.json( + { ok: true, list: await addPick(body.list, body.pick) }, + { headers: noStore }, + ); + case "remove": + return Response.json( + { ok: true, list: await removePick(body.list, body.id) }, + { headers: noStore }, + ); + case "note": + return Response.json( + { ok: true, list: await noteShortlist(body.list, body.note, body.id) }, + { headers: noStore }, + ); + case "reorder": + return Response.json( + { ok: true, list: await reorderShortlist(body.list, body.ids) }, + { headers: noStore }, + ); + case "rename": + return Response.json( + { ok: true, list: await renameShortlist(body.list, body.name) }, + { headers: noStore }, + ); + case "delete": + return Response.json( + { ok: true, ...(await deleteShortlist(body.list)) }, + { headers: noStore }, + ); + } + } catch (e) { + if (e instanceof ShortlistError) { + return Response.json({ error: e.message }, { status: e.status }); + } + return Response.json({ error: "could not write the shortlist" }, { status: 500 }); + } +} diff --git a/umtool/app/browse/find/page.tsx b/umtool/app/browse/find/page.tsx @@ -1,6 +1,7 @@ import Link from "next/link"; import BrowseHeader from "@/components/BrowseHeader"; import PhraseConsole from "@/components/PhraseConsole"; +import ShortlistBar from "@/components/ShortlistBar"; import { episodeIds, isEpisode, @@ -10,6 +11,7 @@ import { PAGE_SIZE, type PhraseResult, } from "@/lib/phrases"; +import { listShortlists, readShortlist } from "@/lib/shortlist"; export const dynamic = "force-dynamic"; @@ -54,9 +56,20 @@ export default async function FindPage({ searchParams }: { searchParams: Promise let result: PhraseResult | null = null; if (query.terms.length && !unknownVideo) result = await searchPhrases(query); + // The shortlists are small JSON and are read on every render, like every + // other pile in this tool -- the CLI and a second tab write the same file, + // and a cached copy would be a stale copy the moment either did. + const slug = raw.get("list"); + const [lists, active] = await Promise.all([ + listShortlists(), + slug ? readShortlist(slug) : Promise.resolve(null), + ]); + const starred = new Set(active?.picks.map((p) => p.id) ?? []); + const notes = [ ...errors.map((e) => e.message), ...(unknownVideo ? [`no episode called ${query.video}`] : []), + ...(slug && !active ? [`no shortlist called ${slug}`] : []), ...(result?.notes ?? []), ]; @@ -112,6 +125,8 @@ export default async function FindPage({ searchParams }: { searchParams: Promise </span> </form> + <ShortlistBar lists={lists} active={active} qs={raw.toString()} /> + {notes.length > 0 && ( <div className="mb-3 space-y-1.5 rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2"> {notes.map((n, i) => ( @@ -178,7 +193,12 @@ export default async function FindPage({ searchParams }: { searchParams: Promise <span className="num">{query.page}</span> of <span className="num">{pages}</span> </p> - <PhraseConsole hits={result.hits} /> + <PhraseConsole + hits={result.hits} + q={query.q} + list={active?.slug ?? null} + starred={result.hits.filter((h) => starred.has(h.id)).map((h) => h.id)} + /> {result.total === 0 && ( <p className="text-[12px] text-[var(--color-dim)]"> diff --git a/umtool/components/PhraseConsole.tsx b/umtool/components/PhraseConsole.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { previewWindow, type PhraseHit } from "@/lib/phrase-types"; +import { SHORTLIST_EVENT, type Pick, type Shortlist } from "@/lib/shortlist-types"; // The rows, and ONE island for all of them. // @@ -20,8 +21,23 @@ const fmtTime = (t: number) => { return `${Math.floor(s / 60)}:${String(s % 60).padStart(2, "0")}`; }; -export default function PhraseConsole({ hits }: { hits: PhraseHit[] }) { +export default function PhraseConsole({ + hits, + q, + list, + starred, +}: { + hits: PhraseHit[]; + /** The query that found these, snapshotted into every pick. */ + q: string; + /** The active shortlist's slug, or null -- `s` needs somewhere to put it. */ + list: string | null; + /** Which of these hits are already on that list, decided by the server. */ + starred: string[]; +}) { const [cursor, setCursor] = useState(0); + const [stars, setStars] = useState<Set<string>>(() => new Set(starred)); + const [starring, setStarring] = useState(false); const [playing, setPlaying] = useState<string | null>(null); const [playhead, setPlayhead] = useState<number | null>(null); // The id whose picture is mounted. Never set on load -- see below. @@ -101,6 +117,73 @@ export default function PhraseConsole({ hits }: { hits: PhraseHit[] }) { useEffect(() => stop, [stop]); + useEffect(() => setStars(new Set(starred)), [starred]); + + // The bar can unstar too, and both must agree a moment later. Every mutation + // returns the whole list, so one event carrying it keeps them exact -- where + // a router.refresh() would re-run the search and lose the focused row on + // every single star. + useEffect(() => { + const onChange = (e: Event) => { + const l = (e as CustomEvent<Shortlist | null>).detail; + if (!l || l.slug !== list) return; + setStars(new Set(l.picks.map((p) => p.id))); + }; + window.addEventListener(SHORTLIST_EVENT, onChange); + return () => window.removeEventListener(SHORTLIST_EVENT, onChange); + }, [list]); + + // STAR, and it snapshots rather than referencing. A pick that held only an id + // would be silently re-pointed at a different word by a re-transcription; + // with the text and the window written down, a drift is DETECTABLE. + const star = useCallback( + async (h: PhraseHit) => { + if (!list) { + setError("no shortlist selected — make one first"); + return; + } + const on = stars.has(h.id); + setStarring(true); + setError(null); + const pick: Pick = { + id: h.id, + video: h.video, + i: h.i, + start: h.start, + end: h.end, + text: h.text, + from: h.from, + to: h.to, + q, + conf: h.confMin, + suspect: h.suspect, + at: new Date().toISOString(), + }; + try { + const res = await fetch("/api/shortlist", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify( + on ? { op: "remove", list, id: h.id } : { op: "add", list, pick }, + ), + cache: "no-store", + }); + const j = (await res.json()) as { list?: Shortlist; error?: string }; + if (!res.ok || !j.list) { + setError(j.error ?? `could not ${on ? "unstar" : "star"} that`); + return; + } + setStars(new Set(j.list.picks.map((p) => p.id))); + window.dispatchEvent(new CustomEvent(SHORTLIST_EVENT, { detail: j.list })); + } catch { + setError("could not reach the shortlist"); + } finally { + setStarring(false); + } + }, + [list, q, stars], + ); + // A new page of results is a new list. Anything held about the old one -- // which row was focused, what was mid-play -- is about hits that are gone. useEffect(() => { @@ -151,6 +234,9 @@ export default function PhraseConsole({ hits }: { hits: PhraseHit[] }) { } else if (e.key === "v") { e.preventDefault(); if (h.hasVideo) setPicture((p) => (p === h.id ? null : h.id)); + } else if (e.key === "s") { + e.preventDefault(); + void star(h); } else if (e.key === "Escape") { e.preventDefault(); stop(); @@ -158,7 +244,7 @@ export default function PhraseConsole({ hits }: { hits: PhraseHit[] }) { }; window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey); - }, [hits, cursor, move, play, stop]); + }, [hits, cursor, move, play, stop, star]); if (!hits.length) return null; @@ -187,6 +273,7 @@ export default function PhraseConsole({ hits }: { hits: PhraseHit[] }) { data-conf={h.confMin ?? ""} data-suspect={h.suspect ? "1" : "0"} data-dupes={h.dupes} + data-starred={stars.has(h.id) ? "1" : "0"} onClick={() => setCursor(i)} className={`rounded border px-3 py-2 ${ on @@ -217,6 +304,23 @@ export default function PhraseConsole({ hits }: { hits: PhraseHit[] }) { > wide </button> + <button + type="button" + onClick={() => { + setCursor(i); + void star(h); + }} + disabled={starring} + aria-label={`${stars.has(h.id) ? "unstar" : "star"} ${h.text}`} + title={list ? `${stars.has(h.id) ? "remove from" : "add to"} ${list}` : "no shortlist selected"} + className={`rounded border px-1.5 py-0.5 font-mono text-[11px] ${ + stars.has(h.id) + ? "border-[var(--color-sel)] text-[var(--color-sel)]" + : "border-[var(--color-line)] text-[var(--color-dim)] hover:text-[var(--color-text)]" + }`} + > + {stars.has(h.id) ? "★" : "☆"} + </button> <span className="truncate text-[12px] text-[var(--color-dim)]" title={h.video}> {h.title} </span> diff --git a/umtool/components/ShortlistBar.tsx b/umtool/components/ShortlistBar.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { useCallback, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { + SHORTLIST_EVENT, + type Shortlist, + type ShortlistSummary, +} from "@/lib/shortlist-types"; + +// The lists, and the active one's order. +// +// ORDER IS THE EDIT, so this is where it gets decided -- and every move sends a +// WHOLE PERMUTATION rather than "move item 3 up". Atomic, idempotent, and a +// disagreement about what the list holds comes back as a 400 instead of being +// reconciled into an order nobody asked for. + +const mmss = (t: number) => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`; + +export default function ShortlistBar({ + lists, + active, + qs, +}: { + lists: ShortlistSummary[]; + active: Shortlist | null; + /** The page's current query string, so a list chip keeps the search. */ + qs: string; +}) { + const router = useRouter(); + const [all, setAll] = useState(lists); + const [list, setList] = useState(active); + const [name, setName] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + + useEffect(() => setAll(lists), [lists]); + useEffect(() => setList(active), [active]); + + // The rows star; this shows what was starred. One event, carrying the whole + // list the server just wrote. + useEffect(() => { + const onChange = (e: Event) => { + const l = (e as CustomEvent<Shortlist | null>).detail; + if (!l) return; + setList((cur) => (cur && cur.slug === l.slug ? l : cur)); + setAll((cur) => + cur.map((s) => + s.slug === l.slug ? { ...s, picks: l.picks.length, updated: l.updated } : s, + ), + ); + }; + window.addEventListener(SHORTLIST_EVENT, onChange); + return () => window.removeEventListener(SHORTLIST_EVENT, onChange); + }, []); + + const post = useCallback(async (body: unknown): Promise<Shortlist | null> => { + setBusy(true); + setError(null); + try { + const res = await fetch("/api/shortlist", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + cache: "no-store", + }); + const j = (await res.json()) as { list?: Shortlist; error?: string }; + if (!res.ok) { + setError(j.error ?? `failed (${res.status})`); + return null; + } + if (j.list) { + setList(j.list); + window.dispatchEvent(new CustomEvent(SHORTLIST_EVENT, { detail: j.list })); + } + return j.list ?? null; + } catch { + setError("could not reach the shortlist"); + return null; + } finally { + setBusy(false); + } + }, []); + + const href = (slug: string | null) => { + const p = new URLSearchParams(qs); + if (slug) p.set("list", slug); + else p.delete("list"); + const s = p.toString(); + return `/browse/find${s ? `?${s}` : ""}`; + }; + + const move = async (i: number, d: number) => { + if (!list) return; + const ids = list.picks.map((p) => p.id); + const j = i + d; + if (j < 0 || j >= ids.length) return; + [ids[i], ids[j]] = [ids[j], ids[i]]; + await post({ op: "reorder", list: list.slug, ids }); + }; + + return ( + <div className="mb-3 rounded border border-[var(--color-line)] bg-[var(--color-panel)] px-3 py-2" data-shortlist-bar> + <div className="flex flex-wrap items-center gap-1.5"> + <span className="micro">shortlist</span> + {all.length === 0 && <span className="text-[12px] text-[var(--color-dim)]">none yet</span>} + {all.map((s) => ( + <a + key={s.slug} + href={href(s.slug)} + data-list-chip={s.slug} + aria-current={list?.slug === s.slug ? "true" : undefined} + className={`num rounded border px-1.5 py-0.5 font-mono text-[11px] ${ + list?.slug === s.slug + ? "border-[var(--color-sel)] text-[var(--color-sel)]" + : "border-[var(--color-line)] text-[var(--color-dim)] hover:text-[var(--color-text)]" + }`} + > + {s.name} {s.picks} + </a> + ))} + + <form + className="ml-2 flex items-center gap-1" + onSubmit={async (e) => { + e.preventDefault(); + if (!name.trim()) return; + const made = await post({ op: "create", name }); + if (made) { + setName(""); + router.push(href(made.slug)); + } + }} + > + <input + value={name} + onChange={(e) => setName(e.target.value)} + onKeyDown={(e) => e.stopPropagation()} + placeholder="new list" + aria-label="new shortlist name" + className="w-36 rounded border border-[var(--color-line)] bg-[var(--color-ink)] px-1.5 py-0.5 font-mono text-[11px] outline-none focus:border-[var(--color-sel)]" + /> + <button + type="submit" + disabled={busy || !name.trim()} + className="rounded border border-[var(--color-line)] px-1.5 py-0.5 font-mono text-[11px] text-[var(--color-dim)] hover:text-[var(--color-text)] disabled:opacity-40" + > + create + </button> + </form> + + {list && ( + <span className="ml-auto flex items-center gap-2"> + <a + href={`/api/shortlist?list=${encodeURIComponent(list.slug)}&format=json`} + className="font-mono text-[11px] text-[var(--color-sel)] hover:underline" + > + export json + </a> + <a + href={`/api/shortlist?list=${encodeURIComponent(list.slug)}&format=md`} + className="font-mono text-[11px] text-[var(--color-sel)] hover:underline" + > + markdown + </a> + <a href={href(null)} className="micro hover:text-[var(--color-text)]"> + close + </a> + </span> + )} + </div> + + {error && ( + <p className="mt-1 text-[11px] text-[var(--color-bad)]" role="status"> + {error} + </p> + )} + + {!list && all.length > 0 && ( + <p className="mt-1 text-[11px] text-[var(--color-dim)]"> + Pick one to star into it — <kbd>s</kbd> on a row. + </p> + )} + + {list && ( + <ol className="mt-2 space-y-1" data-picks={list.slug}> + {list.picks.length === 0 && ( + <li className="text-[12px] text-[var(--color-dim)]"> + Empty. <kbd>s</kbd> stars the focused row into it; the order you build here is the + edit. + </li> + )} + {list.picks.map((p, i) => ( + <li + key={p.id} + data-pick={p.id} + data-pos={i} + className="flex flex-wrap items-baseline gap-2 rounded bg-[var(--color-panel-2)] px-2 py-1" + > + <span className="num micro w-6">{i + 1}</span> + <button + type="button" + onClick={() => void move(i, -1)} + disabled={busy || i === 0} + aria-label={`move ${p.text} up`} + className="font-mono text-[11px] text-[var(--color-dim)] hover:text-[var(--color-text)] disabled:opacity-30" + > + ↑ + </button> + <button + type="button" + onClick={() => void move(i, 1)} + disabled={busy || i === list.picks.length - 1} + aria-label={`move ${p.text} down`} + className="font-mono text-[11px] text-[var(--color-dim)] hover:text-[var(--color-text)] disabled:opacity-30" + > + ↓ + </button> + <span className="font-mono text-[12px] text-[var(--color-text)]">{p.text}</span> + <span className="num micro"> + {p.video} {mmss(p.start)} + </span> + {p.suspect && ( + <span className="text-[10px] text-[var(--color-dirty)]">flagged source</span> + )} + <input + defaultValue={p.note ?? ""} + placeholder="why this one" + aria-label={`note on ${p.text}`} + onKeyDown={(e) => e.stopPropagation()} + onBlur={(e) => { + if ((e.target.value || "") === (p.note ?? "")) return; + void post({ op: "note", list: list.slug, id: p.id, note: e.target.value }); + }} + className="min-w-[8rem] flex-1 rounded border border-transparent bg-transparent px-1 text-[11px] text-[var(--color-dim)] outline-none focus:border-[var(--color-line)] focus:text-[var(--color-text)]" + /> + <button + type="button" + onClick={() => void post({ op: "remove", list: list.slug, id: p.id })} + disabled={busy} + aria-label={`remove ${p.text}`} + className="font-mono text-[11px] text-[var(--color-dim)] hover:text-[var(--color-bad)]" + > + ✕ + </button> + </li> + ))} + </ol> + )} + </div> + ); +} diff --git a/umtool/lib/shortlist-types.ts b/umtool/lib/shortlist-types.ts @@ -0,0 +1,164 @@ +// The shortlist shapes and their validator, with NO server imports -- the same +// client-boundary rule lib/note-types.ts and lib/phrase-types.ts follow. +// +// A shortlist is what turns a search into an EDIT. The console retrieves by +// meaning; this is where the order gets decided, which is the part a renderer +// downstream actually consumes. + +import { NOTE_LIMIT } from "./note-types"; + +export { NOTE_LIMIT }; + +/** + * The one channel the two client components on /browse/find use to agree. + * + * The rows can star and the bar can unstar, and both must show the same list a + * moment later. Every mutation returns the WHOLE list, so one event carrying it + * keeps them exact -- where a router.refresh() would re-run the search, rebuild + * the rows and throw away which row was focused, on every star. + */ +export const SHORTLIST_EVENT = "umtool:shortlist"; + +export const NAME_LIMIT = 120; +export const SLUG_LIMIT = 60; + +/** + * One starred moment. + * + * A SNAPSHOT, not a reference. Storing only the id would let a re-transcription + * silently re-point a pick at a different word -- the word indices shift the + * moment a chunk boundary moves, and nothing would say so. With the text and + * the times written down, staleness can be DETECTED: the list still says what + * it meant, and a mismatch against the corpus is visible rather than invisible. + * + * `q` is kept for the same reason. Six weeks later a list of 40 moments with no + * record of what was asked for is a list that has to be re-derived by ear. + */ +export type Pick = { + /** `<video>#<wordIndex>`, the hit's identity. Never a timestamp. */ + id: string; + video: string; + i: number; + start: number; + end: number; + /** The words as written, at the time of starring. */ + text: string; + /** The preview window, lead-in already applied. */ + from: number; + to: number; + /** The query that found it. */ + q: string; + conf: number | null; + suspect: boolean; + note?: string; + at: string; +}; + +export type Shortlist = { + slug: string; + name: string; + created: string; + updated: string; + note?: string; + /** + * ORDER IS THE EDIT. An array, never a Set and never a Record -- the sequence + * IS the thing being authored, and any container that does not preserve it + * throws away the only decision this tool exists to record. + */ + picks: Pick[]; +}; + +export type ShortlistFile = { version: 1; lists: Record<string, Shortlist> }; + +export type ShortlistSummary = { + slug: string; + name: string; + picks: number; + updated: string; + note?: string; +}; + +// --------------------------------------------------------------------------- +// Ops. +// --------------------------------------------------------------------------- + +export type ShortlistBody = + | { op: "create"; name: string; note?: string } + | { op: "add"; list: string; pick: Pick } + | { op: "remove"; list: string; id: string } + | { op: "note"; list: string; id?: string; note: string } + /** + * A WHOLE PERMUTATION of the list's current ids, not an index move. + * + * Atomic and idempotent: the client sends the order it believes in and either + * gets it or gets a 400, where a stream of "move item 3 up" messages against + * a list two tabs are editing produces an order neither of them asked for. + * A non-permutation -- a missing id, an unknown one, a duplicate -- is a + * disagreement about what the list HOLDS, so it is rejected rather than + * reconciled. + */ + | { op: "reorder"; list: string; ids: string[] } + | { op: "rename"; list: string; name: string } + | { op: "delete"; list: string }; + +export const HIT_ID = /^[\w-]+#\d+$/; + +const str = (v: unknown): v is string => typeof v === "string"; +const fin = (v: unknown): v is number => typeof v === "number" && Number.isFinite(v); + +export function isPick(v: unknown): v is Pick { + if (!v || typeof v !== "object") return false; + const p = v as Record<string, unknown>; + return ( + str(p.id) && + HIT_ID.test(p.id) && + str(p.video) && + p.video.length > 0 && + fin(p.i) && + fin(p.start) && + fin(p.end) && + fin(p.from) && + fin(p.to) && + str(p.text) && + str(p.q) && + (p.conf === null || fin(p.conf)) && + typeof p.suspect === "boolean" && + (p.note === undefined || str(p.note)) + ); +} + +export function isShortlistBody(v: unknown): v is ShortlistBody { + if (!v || typeof v !== "object") return false; + const b = v as Record<string, unknown>; + const list = str(b.list) && b.list.length > 0; + switch (b.op) { + case "create": + return str(b.name) && b.name.trim().length > 0 && (b.note === undefined || str(b.note)); + case "add": + return list && isPick(b.pick); + case "remove": + return list && str(b.id) && HIT_ID.test(b.id); + case "note": + return list && str(b.note) && (b.id === undefined || (str(b.id) && HIT_ID.test(b.id))); + case "reorder": + return list && Array.isArray(b.ids) && b.ids.every((x) => str(x) && HIT_ID.test(x)); + case "rename": + return list && str(b.name) && b.name.trim().length > 0; + case "delete": + return list; + default: + return false; + } +} + +/** A name to a key. Stable, lowercase, and safe in a URL and a filename alike. */ +export function slugify(name: string): string { + return name + .toLowerCase() + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, SLUG_LIMIT) + .replace(/-+$/, ""); +} diff --git a/umtool/lib/shortlist.ts b/umtool/lib/shortlist.ts @@ -0,0 +1,260 @@ +import { stateFile } from "./paths"; +import { readJson, withStateLock, writeJsonAtomic } from "./state"; +import { + NAME_LIMIT, + NOTE_LIMIT, + slugify, + type Pick, + type Shortlist, + type ShortlistFile, + type ShortlistSummary, +} from "./shortlist-types"; + +export * from "./shortlist-types"; + +// --------------------------------------------------------------------------- +// Named lists of starred moments, in song/shortlist.json. +// +// This is where /browse/find stops being a search and starts being an edit. +// There is NO RENDERER here on purpose: the deliverable is an ordered, named, +// self-describing list plus a JSON export, and whatever consumes it -- a +// supercut, a word edit -- is a separate program that should not be able to +// change what the list means by being rewritten. +// +// The file has two writers in principle (this app, and anything a person runs +// against the same JSON), so it follows the same discipline lib/state.ts's +// preamble sets out and for the same reason: nothing is held across requests, +// every mutation RE-READS INSIDE THE LOCK, and the write is a rename() over the +// target. A lost update here is lost human judgement about what belongs in a +// cut, which is exactly the kind of thing nobody notices going missing. +// --------------------------------------------------------------------------- + +export const SHORTLIST = () => stateFile("shortlist.json"); + +const blank = (): ShortlistFile => ({ version: 1, lists: {} }); + +export async function readShortlists(): Promise<ShortlistFile> { + const j = await readJson<ShortlistFile>(SHORTLIST(), blank()); + if (!j || typeof j !== "object" || !j.lists || typeof j.lists !== "object") return blank(); + return { version: 1, lists: j.lists }; +} + +export function summarise(l: Shortlist): ShortlistSummary { + return { slug: l.slug, name: l.name, picks: l.picks.length, updated: l.updated, note: l.note }; +} + +export async function listShortlists(): Promise<ShortlistSummary[]> { + const f = await readShortlists(); + return Object.values(f.lists) + .map(summarise) + .sort((a, b) => b.updated.localeCompare(a.updated)); +} + +export async function readShortlist(slug: string): Promise<Shortlist | null> { + const f = await readShortlists(); + return f.lists[slug] ?? null; +} + +/** Errors carry the status the route should answer with. */ +export class ShortlistError extends Error { + constructor( + message: string, + readonly status: number, + ) { + super(message); + } +} + +const now = () => new Date().toISOString(); +const trimNote = (s: string) => s.slice(0, NOTE_LIMIT).replace(/\s+$/, ""); + +/** + * One mutation, one lock, one atomic write -- and the read happens INSIDE the + * lock. Reading first and mutating after would reintroduce exactly the + * interleaved read-modify-write that lib/state.ts exists to prevent. + */ +async function mutate<T>(fn: (f: ShortlistFile) => T): Promise<T> { + let out!: T; + await withStateLock(async () => { + const f = await readShortlists(); + out = fn(f); + await writeJsonAtomic(SHORTLIST(), f); + }); + return out; +} + +function get(f: ShortlistFile, slug: string): Shortlist { + const l = f.lists[slug]; + if (!l) throw new ShortlistError(`no shortlist called ${slug}`, 404); + return l; +} + +export async function createShortlist(name: string, note?: string): Promise<Shortlist> { + const clean = name.trim().slice(0, NAME_LIMIT); + const slug = slugify(clean); + if (!slug) throw new ShortlistError("that name has nothing sluggable in it", 400); + return mutate((f) => { + if (f.lists[slug]) throw new ShortlistError(`a shortlist called ${slug} already exists`, 409); + const at = now(); + const l: Shortlist = { slug, name: clean, created: at, updated: at, picks: [] }; + if (note) l.note = trimNote(note); + f.lists[slug] = l; + return l; + }); +} + +/** + * Star a moment. + * + * IDEMPOTENT BY ID: a moment already on the list keeps its POSITION and has its + * snapshot refreshed. It is not appended a second time -- the id identifies one + * word in one episode, and repeating that sound in a cut is the renderer's job, + * not a second entry's. It also keeps `reorder`'s permutation check meaningful, + * which a list with duplicate ids could not have. + */ +export async function addPick(slug: string, pick: Pick): Promise<Shortlist> { + return mutate((f) => { + const l = get(f, slug); + const at = pick.at || now(); + const found = l.picks.findIndex((p) => p.id === pick.id); + const next: Pick = { ...pick, at }; + if (found >= 0) next.note = l.picks[found].note ?? pick.note; + if (next.note === undefined) delete next.note; + if (found >= 0) l.picks[found] = next; + else l.picks.push(next); + l.updated = now(); + return l; + }); +} + +export async function removePick(slug: string, id: string): Promise<Shortlist> { + return mutate((f) => { + const l = get(f, slug); + const before = l.picks.length; + l.picks = l.picks.filter((p) => p.id !== id); + if (l.picks.length !== before) l.updated = now(); + return l; + }); +} + +/** With an id, a note on one pick; without, a note on the list. Empty clears. */ +export async function noteShortlist(slug: string, note: string, id?: string): Promise<Shortlist> { + return mutate((f) => { + const l = get(f, slug); + const text = trimNote(note); + if (id === undefined) { + if (text) l.note = text; + else delete l.note; + } else { + const p = l.picks.find((x) => x.id === id); + if (!p) throw new ShortlistError(`${id} is not on ${slug}`, 404); + if (text) p.note = text; + else delete p.note; + } + l.updated = now(); + return l; + }); +} + +export async function reorderShortlist(slug: string, ids: string[]): Promise<Shortlist> { + return mutate((f) => { + const l = get(f, slug); + // A PERMUTATION or a 400. Same length, same multiset, no duplicates -- any + // of those failing means the client is ordering a list it does not have, + // and guessing which of the two is right would silently drop a pick. + const have = l.picks.map((p) => p.id); + const seen = new Set(ids); + if (ids.length !== have.length || seen.size !== ids.length || !have.every((h) => seen.has(h))) { + throw new ShortlistError( + `reorder must be a permutation of the ${have.length} ids on ${slug}`, + 400, + ); + } + const by = new Map(l.picks.map((p) => [p.id, p])); + l.picks = ids.map((id) => by.get(id) as Pick); + l.updated = now(); + return l; + }); +} + +/** + * Rename in place. The SLUG DOES NOT MOVE: it is the list's identity, it is in + * every URL anyone has kept, and a rename is about the label. + */ +export async function renameShortlist(slug: string, name: string): Promise<Shortlist> { + return mutate((f) => { + const l = get(f, slug); + l.name = name.trim().slice(0, NAME_LIMIT); + l.updated = now(); + return l; + }); +} + +export async function deleteShortlist(slug: string): Promise<{ slug: string }> { + return mutate((f) => { + get(f, slug); + delete f.lists[slug]; + return { slug }; + }); +} + +// --------------------------------------------------------------------------- +// The exports. Two, because they answer different questions: the JSON is for a +// program, and the markdown is for a person or a model reading the list cold. +// --------------------------------------------------------------------------- + +export type ShortlistExport = { + version: 1; + list: string; + name: string; + note?: string; + exported: string; + /** Times are ABSOLUTE episode seconds, on the same timeline as wav48/ and media/. */ + picks: Pick[]; +}; + +export function exportJson(l: Shortlist): ShortlistExport { + return { + version: 1, + list: l.slug, + name: l.name, + note: l.note, + exported: now(), + picks: l.picks, + }; +} + +const mmss = (t: number) => `${Math.floor(t / 60)}:${String(Math.floor(t % 60)).padStart(2, "0")}`; + +export function exportMarkdown(l: Shortlist): string { + const lines = [ + `# ${l.name}`, + "", + `${l.picks.length} ${l.picks.length === 1 ? "moment" : "moments"}, in order. Updated ${l.updated}.`, + ]; + if (l.note) lines.push("", l.note); + lines.push( + "", + "| # | said | episode | at | window | conf | found by |", + "|---|---|---|---|---|---|---|", + ); + l.picks.forEach((p, i) => { + lines.push( + `| ${i + 1} | ${p.text.replace(/\|/g, "\\|")} | \`${p.video}\` | ${mmss(p.start)} | ` + + `${p.from.toFixed(2)}–${p.to.toFixed(2)}s | ${p.conf === null ? "?" : p.conf.toFixed(2)} | ` + + `\`${p.q}\`${p.suspect ? " ⚑" : ""} |`, + ); + }); + const notes = l.picks.filter((p) => p.note); + if (notes.length) { + lines.push("", "## Notes", ""); + for (const p of notes) lines.push(`* **${p.text}** (\`${p.video}\` ${mmss(p.start)}) — ${p.note}`); + } + lines.push( + "", + "⚑ marks a FLAGGED SOURCE: a clip somewhere in that episode was judged another", + "speaker. Per-episode, not per-moment.", + "", + ); + return lines.join("\n"); +}