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 { const j = await readJson(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 { 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 { 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(fn: (f: ShortlistFile) => T): Promise { 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 { 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 { 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 { 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 { 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 { 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 { 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"); }