// Writing a window back into video.manifest.json. // // This app is a SECOND writer of a file resolve-windows.mjs also writes, and an // agent running `umtool` is a third. Four rules follow from that, and each of // them is here because getting it wrong is silent: // // 1. ROUND TO 2 dp. resolve-windows.mjs is a fixed point, and both its EPS // lookup tolerance and its 0.05 s deadband assume 2 dp storage. Writing 4 dp // makes the widener grow the same clip a little on every subsequent run -- // the exact bug its own comments document. // // 2. PRESERVE THE CLI'S FORMATTING. It writes `JSON.stringify(m, null, 2)` plus // a trailing newline. lib/state.ts's writeJsonAtomic uses indent 1, which // would turn a two-number edit into a 600-line diff and make the next real // change unreviewable. // // 3. TMP + RENAME, under the process-wide state lock. A reader must never see // half a manifest, and two requests must not interleave a read-modify-write. // // 4. GUARD ON THE FILE'S OWN MTIME. A PUT carrying a stale token is a 409, not // a silent overwrite -- somebody may have run `resolve-windows --write` in // between, and losing that is losing human judgement. import { copyFile, readFile, rename, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { POPULATIONS, SCOPES, SCOPE_CONFIDENCE, VALUE_KINDS, rolesGaps, } from "umtool-report-to-video/ledger-totals"; import { isCalendarDate } from "umtool-report-to-video/attribution"; import { normalizeOnscreen, validateChrome, validatePosts, validateTeaser, validateTeasers } from "umtool-report-to-video/deck"; import { applySectionEnter } from "./sections.mjs"; import { normalizeClaim, validateClaims } from "umtool-report-to-video/factcheck"; import { parseMuteFrom } from "./playback.mjs"; import { DELIVERABLES_MODES } from "./storage.mjs"; import { createSnapshot, listSnapshots } from "./snapshots.mjs"; // Its own write queue, not lib/state.ts's. // // Two reasons, and the second is the real one. lib/state.ts is TypeScript, so // importing it would stop `umtool window` running under plain node -- and the // whole point of one writer is that the CLI and the app go through it. And the // scope is genuinely different: lib/state's queue serialises the SONG state // files, which have nothing to do with a manifest. // // Within a process this serialises read-modify-write. ACROSS processes -- an // agent running the CLI while the app has a page open -- the guard is the // tmp+rename plus the mtime token, which is what actually stops a lost update. /** @type {Promise} */ let queue = Promise.resolve(); /** * @template T * @param {() => Promise} fn * @returns {Promise} */ function withManifestLock(fn) { const run = queue.then(fn, fn); queue = run.then( () => undefined, () => undefined, ); return run; } export const MANIFEST_NAME = "video.manifest.json"; const manifestFile = (dir) => path.join(dir, MANIFEST_NAME); /** 2 dp, and never NaN. The one number format this file will write. */ const round2 = (n) => Number(Number(n).toFixed(2)); /** * The mtime a client must hand back to be allowed to write. * @param {string} dir * @returns {Promise} */ export async function manifestToken(dir) { const st = await stat(manifestFile(dir)).catch(() => null); return st ? String(Math.round(st.mtimeMs)) : null; } const serialise = (m) => JSON.stringify(m, null, 2) + "\n"; /** How long to leave between .bak copies of the same manifest. */ const BAK_INTERVAL_MS = 10 * 60 * 1000; async function backupOnce(file) { // A rolling stack of backups is worth less than one copy of the last // hand-authored state, which is the precedent ferret-rescue already set by // having a single video.manifest.json.bak beside it. const bak = `${file}.bak`; const [src, dst] = await Promise.all([ stat(file).catch(() => null), stat(bak).catch(() => null), ]); if (!src) return; if (dst && src.mtimeMs - dst.mtimeMs < BAK_INTERVAL_MS) return; await copyFile(file, bak).catch(() => {}); } async function writeManifestAtomic(dir, manifest) { const file = manifestFile(dir); await backupOnce(file); const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`; await writeFile(tmp, serialise(manifest), "utf8"); await rename(tmp, file); return manifestToken(dir); } export class StaleToken extends Error { constructor(expected, got) { super(`the manifest changed since you read it (${got} vs ${expected})`); this.name = "StaleToken"; this.expected = expected; this.got = got; } } const WINDOW_FIELDS = ["start", "end"]; const FLAG_FIELDS = ["lock", "lockStart", "lockEnd", "lockCut"]; /** Same tolerance the resolver and the build use for a stored 2 dp edge. */ const WIN_EPS = 0.02; /** * Patch ONE clip. The window, the locks, and what the header says. * * Two groups of fields, and they are one function because they are one edit: * somebody watching a clip fixes its edges and its attribution in the same * sitting, and splitting them would mean two tokens and two chances to lose * the other's write. * * Still deliberately not a general editor: re-ordering is a different operation * with different consequences (it has to recompute `sectionEnter`), and letting * a window save quietly move an entry is how a cut changes without anybody * deciding to change it. * * ATTRIBUTION FIELDS ARE VALIDATED HERE, not at the route. `date` is the one * that matters: it is prose in a field the renderer prints verbatim, so a typo * ships as a fact about when somebody said something. An empty value DELETES * the key, the way the flags do -- `"title": ""` in a manifest read by humans * is noise that reads like a decision. */ /** * @param {string} dir * @param {string} clipId * @param {Record} patch * @param {{ token?: string | null }} [opts] * @returns {Promise<{ entry: Record, before: {start:number,end:number}, token: string | null }>} */ export async function updateClip(dir, clipId, patch, { token = null } = {}) { return withManifestLock(async () => { const current = await manifestToken(dir); if (token !== null && current !== token) throw new StaleToken(token, current); // Re-read INSIDE the lock, every time. Nothing is held across requests. const raw = await readFile(manifestFile(dir), "utf8"); const manifest = JSON.parse(raw); const entry = (manifest.timeline ?? []).find((e) => e.id === clipId); if (!entry) throw new Error(`no timeline entry with id ${clipId}`); // `!== "clip"`, not `=== "card"`. The timeline's vocabulary is open -- one // real manifest carries `scroll` and `chart` entries -- and the card-only // check would have let a window be written onto one of those. if (entry.type !== "clip") throw new Error(`${clipId} is a ${entry.type ?? "non-clip"} entry, not a clip`); const before = { start: entry.start, end: entry.end }; for (const k of WINDOW_FIELDS) { if (patch[k] === undefined) continue; const v = Number(patch[k]); if (!Number.isFinite(v) || v < 0) throw new Error(`${k} must be a number ≥ 0`); entry[k] = round2(v); } if (entry.end - entry.start < 0.5) { throw new Error(`a clip must be at least half a second (${entry.start}–${entry.end})`); } for (const k of FLAG_FIELDS) { if (patch[k] === undefined) continue; // `false` REMOVES the key rather than writing it. The manifests are read // by humans, and `"lockEnd": false` is noise that reads like a decision. if (patch[k]) entry[k] = true; else delete entry[k]; } if (patch.note !== undefined) { if (patch.note) entry.note = String(patch.note); else delete entry.note; } // ---- what the header will say ------------------------------------------- // // `correction` is the odd one out and belongs here anyway: it is written in // the same sitting, by the same person, looking at the same clip. It says // what the REPORT got wrong -- wrong speaker, wrong addressee, wrong date -- // and nothing renders it. It is a message to the next report pass. for (const k of ["title", "quote", "correction"]) { if (patch[k] === undefined) continue; const v = String(patch[k] ?? "").trim(); if (v) entry[k] = v; else delete entry[k]; } if (patch.date !== undefined) { const v = String(patch.date ?? "").trim(); if (!v) delete entry.date; else if (!isCalendarDate(v)) { throw new Error( `date must be a real calendar date written YYYY-MM-DD (got \`${v}\`)`, ); } else entry.date = v; } if (patch.cite !== undefined) { // null or empty means "no cite", and the header then falls back to the // clip's own start -- which is what `entry.cite ?? entry.start` has always // done. Rounded like a window, for the same reason. if (patch.cite === null || patch.cite === "") delete entry.cite; else { const v = Number(patch.cite); if (!Number.isFinite(v) || v < 0) { throw new Error("cite must be a number of seconds ≥ 0, or empty to use the clip's start"); } entry.cite = round2(v); } } if (patch.citeUrl !== undefined) { const v = String(patch.citeUrl ?? "").trim(); if (!v) delete entry.citeUrl; else { // The QR target. A value that is not an http(s) URL encodes to something // a phone camera opens and nothing answers -- the same class of defect // as the 19 codes that shipped reading `undefined/?v=…`. let u = null; try { u = new URL(v); } catch { /* handled below */ } if (!u || (u.protocol !== "http:" && u.protocol !== "https:")) { throw new Error(`citeUrl must be an http:// or https:// URL (got \`${v}\`)`); } entry.citeUrl = v; } } // ---- the CUT, inside the extent ------------------------------------------ // // `start`/`end` is the reviewed EXTENT -- how much of this recording is // worth having -- and `cutStart`/`cutEnd` is the tight cut the video // renders, derived from where the quote actually is. Two numbers, one // rule: a cut that is not inside its extent is not a cut, it is a second // window nobody reviewed. // // Both or neither. A lone `cutStart` reads like a decision and builds like // nothing, because the build only honours the pair. for (const k of ["cutStart", "cutEnd"]) { if (patch[k] === undefined) continue; const raw = patch[k]; if (raw === null || raw === "") { delete entry[k]; continue; } const v = Number(raw); if (!Number.isFinite(v) || v < 0) throw new Error(`${k} must be a number ≥ 0, or empty`); entry[k] = round2(v); } if (patch.cutStart !== undefined || patch.cutEnd !== undefined || patch.start !== undefined || patch.end !== undefined) { const hasStart = entry.cutStart != null; const hasEnd = entry.cutEnd != null; if (hasStart !== hasEnd) { throw new Error("a cut needs both cutStart and cutEnd, or neither"); } if (hasStart) { if (entry.cutEnd - entry.cutStart < 0.5) { throw new Error(`a cut must be at least half a second (${entry.cutStart}–${entry.cutEnd})`); } if ( entry.cutStart < entry.start - WIN_EPS || entry.cutEnd > entry.end + WIN_EPS ) { throw new Error( `the cut ${entry.cutStart}–${entry.cutEnd} must lie inside the window ` + `${entry.start}–${entry.end} — widen the window, or clear the cut`, ); } } } // ---- the mute mark ------------------------------------------------------ // // `muteFrom`: from this source second to the end of the clip the sound // fades out and the picture plays on -- set by ear, in the bench, at the // last silence before a finale's ending sound. Inside the EXTENT, like the // cut, and checked against the entry AFTER the patch: a window save that // leaves the mark outside is refused rather than keeping a mark that no // longer says anything about the clip. Empty or null deletes it. if (patch.muteFrom !== undefined) { const v = parseMuteFrom(patch.muteFrom, entry.start, entry.end); if (v == null) delete entry.muteFrom; else entry.muteFrom = v; } else if ((patch.start !== undefined || patch.end !== undefined) && entry.muteFrom != null) { // Clamped and STORED, as a patched mark is: a mark within the writer's // 0.02 s of the moved edge lands on it (past the new start it still // mutes the whole clip), and the build (validateMuteFrom, strict) // accepts every manifest saved here. Left as it was, a start moved from 10.00 to 10.01 under a mark at // 10.00 saved, and the next build refused the whole manifest. try { entry.muteFrom = parseMuteFrom(entry.muteFrom, entry.start, entry.end); } catch { throw new Error( `the mute mark ${entry.muteFrom} must lie inside the window ` + `${entry.start}–${entry.end} — widen the window, or clear the mark`, ); } } // ---- the walk's verdict ------------------------------------------------- // // Whether somebody has LOOKED at this clip and said the description is what // the clip actually is. Three states, and an absent key is the honest way // to say "nobody has been here": // // verdict: "confirmed" -> the description is accurate // verdict: "incorrect" -> it is not, and `correction` says how // neither -> not yet reviewed // // `correction` is OPTIONAL under a confirmed verdict and REQUIRED under an // incorrect one. The two are not the same sentence: a note on a good clip // is why its window moved or a caveat for the writers, while an incorrect // clip with no note is a complaint nobody can act on. So the rule is a // property of the ENTRY AFTER the patch, which makes both halves of it one // check -- setting `incorrect` with no note, and clearing the note off a // clip that is already `incorrect`, are the same error. // // LEGACY, and deliberately not migrated: a clip carrying a `correction` and // no `verdict` predates `incorrect` and READS as incorrect (clipVerdict()). // The requirement above is keyed on the explicit value, so those entries can // still be cleared -- a rule that refused to let somebody undo a note they // wrote before the rule existed would be a trap. if (patch.verdict !== undefined) { const v = String(patch.verdict ?? "").trim(); if (!v) delete entry.verdict; else if (v !== "confirmed" && v !== "incorrect") { throw new Error( `verdict must be "confirmed" or "incorrect", or empty to clear it (got \`${v}\`)`, ); } else entry.verdict = v; } if (entry.verdict === "incorrect" && !String(entry.correction ?? "").trim()) { throw new Error("an incorrect verdict needs its note: say what the report got wrong"); } // ---- what the deck will say --------------------------------------------- // // The bench's On-screen fields, written in the same sitting as the header // fields above. setOnscreen is updateOnscreen's rule, so a clip edited here // and a row saved from the On-screen table cannot store different shapes. if (patch.onscreen !== undefined) setOnscreen(entry, patch.onscreen); const nextToken = await writeManifestAtomic(dir, manifest); return { entry, before, token: nextToken }; }); } // --------------------------------------------------------------------------- // Writing an ADJUDICATION back into a ledger entry. // // Separate from updateClip() on purpose. A clip edit moves a window; a claim // edit records a RULING on what a sentence meant, and the two have nothing in // common but the file they land in. Sharing a function would mean one of them // could quietly write the other's fields. // // Every value is checked against the vocabulary ledger-totals.mjs publishes, // imported rather than restated -- a page offering a seventh population that // the arithmetic has never heard of is exactly the silent divergence this whole // module exists to prevent. // --------------------------------------------------------------------------- const CLAIM_ENUMS = { scope: SCOPES, scopeConfidence: SCOPE_CONFIDENCE, population: POPULATIONS, valueKind: VALUE_KINDS, }; /** * Patch ONE ledger claim's six adjudication fields. * * @param {string} dir * @param {string} claimId * @param {Record} patch * @param {{ token?: string | null }} [opts] */ export async function updateClaim(dir, claimId, patch, { token = null } = {}) { return withManifestLock(async () => { const current = await manifestToken(dir); if (token !== null && current !== token) throw new StaleToken(token, current); const raw = await readFile(manifestFile(dir), "utf8"); const manifest = JSON.parse(raw); const entry = (manifest.ledger ?? []).find((e) => e.id === claimId); if (!entry) throw new Error(`no ledger claim with id ${claimId}`); for (const [field, allowed] of Object.entries(CLAIM_ENUMS)) { if (patch[field] === undefined) continue; const v = String(patch[field]); if (!allowed.includes(v)) { throw new Error(`${field} must be one of ${allowed.join(", ")} (got \`${v}\`)`); } entry[field] = v; } if (patch.scopeBasis !== undefined) { // The phrase from the quote that settles it. Refused when blank: an // adjudication with no basis is an opinion, and a reviewer cannot check // an opinion against the audio. const v = String(patch.scopeBasis ?? "").trim(); if (!v) throw new Error("scopeBasis must quote the phrase that settles the scope"); entry.scopeBasis = v; } if (patch.flags !== undefined) { if (!Array.isArray(patch.flags)) throw new Error("flags must be an array"); const list = patch.flags.map((f) => String(f).trim()).filter(Boolean); // Always WRITTEN, even empty. `flags` is one of the six fields the gate // checks, so an absent array reads as "nobody has looked" -- which is // exactly the state an adjudication is supposed to leave behind. entry.flags = list; } // ---- the roster ---- // Optional, and NOT one of the six: most claims are a number and nothing // else, and gating the inbox on a field only a handful of entries can carry // would leave it permanently red. But when it IS written it is a ruling // like any other -- who he named, how many of them, and his words for it -- // so it is checked here rather than trusted. if (patch.roles !== undefined) { if (patch.roles === null || (Array.isArray(patch.roles) && !patch.roles.length)) { delete entry.roles; } else { if (!Array.isArray(patch.roles)) throw new Error("roles must be an array"); const list = patch.roles.map((r) => ({ role: String(r?.role ?? "").trim(), count: Number(r?.count), verbatim: String(r?.verbatim ?? "").trim(), })); const bad = rolesGaps(list); if (bad.length) throw new Error(`roles: ${bad.join(", ")}`); entry.roles = list; } } if (patch.note !== undefined) { if (patch.note) entry.note = String(patch.note); else delete entry.note; } const nextToken = await writeManifestAtomic(dir, manifest); return { entry, token: nextToken }; }); } // --------------------------------------------------------------------------- // The DECK: per-entry on-screen text, and the render.chrome block that turns // the deck on. // // Both are checked by deck.mjs, imported rather than restated -- the build // refuses a manifest with the same two functions, so a value this file accepts // is one the build accepts, and the other way round. // --------------------------------------------------------------------------- /** * Store one entry's `onscreen`, normalised. The value REPLACES the entry's * whole `onscreen` -- `{ title }` alone clears a subtitle override -- because * the editors send a row, not a field. Nothing left (null, `{}`, blanks) * DELETES the key, the way an empty attribution field does. */ function setOnscreen(entry, value) { const v = normalizeOnscreen(value); if (v) entry.onscreen = v; else delete entry.onscreen; return v; } /** * A row of the On-screen table split into its two keys: the entry's * `onscreen` text, normalised (null: delete it), and -- only when the row * names one -- its fact-check `claim` (`{ id, verdict }`, or null to delete * it; factcheck.mjs). A row without a `claim` key leaves the entry's claim * alone. Throws on a value either normaliser refuses. * * @param {unknown} row * @returns {{ onscreen: { title?: string, subtitle?: string } | null, claim?: { id: string, verdict: string } | null }} */ export function splitOnscreenRow(row) { if (row === null || row === undefined || typeof row !== "object" || Array.isArray(row) || !("claim" in row)) { return { onscreen: normalizeOnscreen(row) }; } const { claim, ...text } = /** @type {Record} */ (row); return { onscreen: normalizeOnscreen(text), claim: normalizeClaim(claim) }; } /** * Patch the on-screen text of any number of timeline entries, in one write. * * Any entry type: a card's or a still's deck title is as much the author's as * a clip's. The batch is ALL OR NOTHING -- an unknown id, or a value * normalizeOnscreen refuses, fails the whole call before anything is written, * because a table saved with one row silently dropped reads as saved. * * A row may also carry the entry's fact-check `claim` (`{ id, verdict }`, or * null to remove it; splitOnscreenRow): set beside `onscreen`, never inside * it, and checked with validateClaims against the whole timeline once * applied -- a claim on a teaser, or one claim id given two verdicts, refuses * the batch. A row without `claim` leaves it as it is. * * Ids are matched against the WHOLE timeline, every variant's entries * included: an entry only the `full` cut shows still has a title. * * @param {string} dir * @param {Record} onscreen * @param {{ token?: string | null }} [opts] * @returns {Promise<{ onscreen: Record, * claims: Record, token: string | null }>} */ export async function updateOnscreen(dir, onscreen, { token = null } = {}) { if (!onscreen || typeof onscreen !== "object" || Array.isArray(onscreen)) { throw new Error("onscreen must be an object of entry id → { title, subtitle } or null"); } const ids = Object.keys(onscreen); if (!ids.length) throw new Error("nothing to change"); // Normalised BEFORE the lock: a bad value is the caller's error whatever the // file says, and refusing it needs no read. /** @type {Record} */ const next = {}; /** @type {Record} */ const claims = {}; for (const id of ids) { try { const row = splitOnscreenRow(onscreen[id]); next[id] = row.onscreen; if (row.claim !== undefined) claims[id] = row.claim; } catch (e) { throw new Error(`${id}: ${e instanceof Error ? e.message : String(e)}`); } } return withManifestLock(async () => { const current = await manifestToken(dir); if (token !== null && current !== token) throw new StaleToken(token, current); const manifest = JSON.parse(await readFile(manifestFile(dir), "utf8")); const byId = new Map((manifest.timeline ?? []).map((e) => [e.id, e])); const unknown = ids.filter((id) => !byId.has(id)); if (unknown.length) { throw new Error(`no timeline entry with id ${unknown.join(", ")} — nothing was written`); } // A timeline can repeat an id across variants (`variant: "sourced"` and // `variant: "full"` twins). Every entry with the id gets the text: they // are one moment in two cuts. for (const e of manifest.timeline) { if (e.id in next) setOnscreen(e, next[e.id]); if (e.id in claims) { if (claims[e.id]) e.claim = claims[e.id]; else delete e.claim; } } // The claims as the build will read them: one verdict per claim id, none // on a teaser. Refused before the write, so the file is as it was. const bad = validateClaims(manifest); if (bad.length) throw new Error(`claim: ${bad.join("; ")} — nothing was written`); const nextToken = await writeManifestAtomic(dir, manifest); return { onscreen: next, claims, token: nextToken }; }); } /** * Set, replace or remove `render.chrome`. * * `null` REMOVES it, which turns the deck off and puts the cut back on the * legacy chrome. Anything else is stored as given -- a manifest names only the * settings it changes, so the defaults are not written out -- once * validateChrome has nothing to say about it against the rest of the render * block (a rail or a legacy `chromeEngine` beside the deck is refused there, * and so is footage that does not fit above it). * * @param {string} dir * @param {Record | null} chrome * @param {{ token?: string | null }} [opts] * @returns {Promise<{ chrome: Record | null, token: string | null }>} */ export async function updateChrome(dir, chrome, { token = null } = {}) { if (chrome === undefined) throw new Error("chrome must be an object, or null to remove it"); return withManifestLock(async () => { const current = await manifestToken(dir); if (token !== null && current !== token) throw new StaleToken(token, current); const manifest = JSON.parse(await readFile(manifestFile(dir), "utf8")); const { chrome: _old, ...renderWithoutChrome } = manifest.render ?? {}; if (chrome === null) { if (manifest.render) delete manifest.render.chrome; } else { const errors = validateChrome(chrome, renderWithoutChrome); if (errors.length) throw new ChromeRefused(errors); manifest.render = { ...(manifest.render ?? {}), chrome }; } const nextToken = await writeManifestAtomic(dir, manifest); return { chrome: manifest.render?.chrome ?? null, token: nextToken }; }); } /** validateChrome's sentences, thrown whole so a route can return each one. */ export class ChromeRefused extends Error { /** @param {string[]} errors */ constructor(errors) { super(`render.chrome: ${errors.join("; ")}`); this.name = "ChromeRefused"; this.errors = errors; } } // --------------------------------------------------------------------------- // POSTS: the two things a person decides about a post once an agent has put it // in the manifest -- which clip it rides on, and whether it is shown at all. // // Adding, removing or rewording a post is not here. A post is a quotation of // somebody else's words with a permalink; it is written by whoever cites it, // into the manifest, and this writer refuses to be a second way to do that. // --------------------------------------------------------------------------- const POST_PATCH_KEYS = ["attachTo", "hide"]; /** * A batch of post patches, checked for shape before anything is read: post id * → `{ attachTo?: string | null, hide?: boolean }`. `attachTo: ""` is the * same as null (a select's "auto"). * * @param {unknown} posts * @returns {Record} */ export function normalizePostPatches(posts) { if (!posts || typeof posts !== "object" || Array.isArray(posts)) { throw new Error("posts must be an object of post id → { attachTo, hide }"); } const ids = Object.keys(posts); if (!ids.length) throw new Error("nothing to change"); /** @type {Record} */ const out = {}; for (const id of ids) { const v = /** @type {Record} */ (posts)[id]; if (!v || typeof v !== "object" || Array.isArray(v)) { throw new Error(`${id}: a post patch must be an object with attachTo and/or hide`); } for (const k of Object.keys(v)) { if (!POST_PATCH_KEYS.includes(k)) { throw new Error(`${id}: ${k} is not something this writer changes (only attachTo and hide)`); } } /** @type {{ attachTo?: string | null, hide?: boolean }} */ const p = {}; if ("attachTo" in v) { const a = /** @type {Record} */ (v).attachTo; if (a === null || a === undefined || a === "") p.attachTo = null; else if (typeof a === "string") p.attachTo = a; else throw new Error(`${id}: attachTo must be a clip id, or null for the automatic clip`); } if ("hide" in v) { const h = /** @type {Record} */ (v).hide; if (typeof h !== "boolean") throw new Error(`${id}: hide must be true or false`); p.hide = h; } out[id] = p; } return out; } /** * Patch the `attachTo` and `hide` of any number of posts, in one write. * * `attachTo: null` and `hide: false` DELETE the key -- the automatic clip and * a shown post are what an absent key already says, and a manifest read by * humans should not carry `"hide": false` as if somebody decided it. A key the * patch does not name is left alone. * * ALL OR NOTHING: an unknown post id, a shape this writer does not take, or a * result validatePosts refuses (an attachTo naming no clip in the timeline) * fails the whole call before anything is written. validatePosts is the * build's own check, so a manifest this accepts is one the build accepts. * * @param {string} dir * @param {Record} posts * @param {{ token?: string | null }} [opts] * @returns {Promise<{ posts: Record, token: string | null }>} */ export async function updatePosts(dir, posts, { token = null } = {}) { const next = normalizePostPatches(posts); const ids = Object.keys(next); return withManifestLock(async () => { const current = await manifestToken(dir); if (token !== null && current !== token) throw new StaleToken(token, current); const manifest = JSON.parse(await readFile(manifestFile(dir), "utf8")); if (!Array.isArray(manifest.posts)) { throw new Error("this manifest has no posts — they are added by editing the manifest, not here"); } const byId = new Map(manifest.posts.map((p) => [p?.id, p])); const unknown = ids.filter((id) => !byId.has(id)); if (unknown.length) throw new Error(`no post with id ${unknown.join(", ")} — nothing was written`); for (const id of ids) { const post = byId.get(id); const p = next[id]; if ("attachTo" in p) { if (p.attachTo) post.attachTo = p.attachTo; else delete post.attachTo; } if ("hide" in p) { if (p.hide) post.hide = true; else delete post.hide; } } const errors = validatePosts(manifest.posts, manifest.timeline ?? [], manifest.render); if (errors.length) throw new PostsRefused(errors); const nextToken = await writeManifestAtomic(dir, manifest); /** @type {Record} */ const saved = {}; for (const id of ids) { const post = byId.get(id); saved[id] = { attachTo: post.attachTo ?? null, hide: post.hide === true }; } return { posts: saved, token: nextToken }; }); } /** validatePosts' sentences, thrown whole so a route can return each one. */ export class PostsRefused extends Error { /** @param {string[]} errors */ constructor(errors) { super(`posts: ${errors.join("; ")}`); this.name = "PostsRefused"; this.errors = errors; } } // --------------------------------------------------------------------------- // STORAGE: where the project's deliverables live (release 17, slice U2). // // `"storage": { "deliverables": "local" | "media" }`, absent = local. Only // lib/report/storage.mjs's moveDeliverables calls this, and only once every // deliverable is where the value says -- a switch set before the move would // send the next cut to a drive the earlier ones are not on. // --------------------------------------------------------------------------- /** * Set `storage.deliverables`. Any other key under `storage` is kept. When the * file already says `deliverables`, nothing is written (no new mtime, so no * open bench page's token goes stale over a no-op). * * @param {string} dir * @param {{ deliverables: "local" | "media" }} patch * @param {{ token?: string | null }} [opts] * @returns {Promise<{ storage: Record, token: string | null, changed: boolean }>} */ export async function updateStorage(dir, { deliverables } = {}, { token = null } = {}) { if (!DELIVERABLES_MODES.includes(deliverables)) { throw new Error(`storage.deliverables is "local" or "media", not ${JSON.stringify(deliverables)}`); } return withManifestLock(async () => { const current = await manifestToken(dir); if (token !== null && current !== token) throw new StaleToken(token, current); const manifest = JSON.parse(await readFile(manifestFile(dir), "utf8")); const storage = manifest.storage && typeof manifest.storage === "object" && !Array.isArray(manifest.storage) ? manifest.storage : {}; if (storage.deliverables === deliverables) return { storage, token: current, changed: false }; manifest.storage = { ...storage, deliverables }; const nextToken = await writeManifestAtomic(dir, manifest); return { storage: manifest.storage, token: nextToken, changed: true }; }); } // --------------------------------------------------------------------------- // STRUCTURE: re-ordering the cut, and the edits that add or remove a thing. // // Every writer above changes the fields of something already in the manifest. // These change WHAT IS IN IT -- the order of the timeline, which entries it // holds, a teaser's lines, the posts, the fact-check's labels -- so they are // the edits a person wants to take back. Each one snapshots the manifest into // revisions/ first (`auto-before-`, at most one per op every two minutes, // so a burst of drags is one step back), and `undoStructural` restores the // newest of those. // // Same four rules as the rest of the file: the mtime token, tmp + rename under // the lock, 2 dp, and validation by the BUILD's own checks (deck.mjs, // factcheck.mjs) before anything is written, so a manifest these accept is one // the build accepts. // // An entry is named by its id. A timeline may repeat an id across variants // (`variant: "sourced"` / `"full"` twins); then the caller passes `at`, the // index it means, and a stale `at` (the id is not there any more) refuses. // --------------------------------------------------------------------------- export const AUTO_SNAPSHOT_PREFIX = "auto-before-"; const AUTO_SNAPSHOT_EVERY_MS = 2 * 60 * 1000; const ENTRY_ID_RE = /^[A-Za-z0-9_-]{1,64}$/; /** * Copy the manifest into revisions/ as `auto-before-`, unless one for the * same op was taken in the last two minutes. Never fatal: an undo point that * cannot be taken is reported, and the edit still lands. */ export async function autoSnapshot(dir, op, { now = Date.now() } = {}) { const label = `${AUTO_SNAPSHOT_PREFIX}${op}`; try { const recent = (await listSnapshots(dir)).find((s) => !s.legacy && s.label === label); if (recent && now - recent.mtimeMs < AUTO_SNAPSHOT_EVERY_MS) return { skipped: true, rel: recent.rel }; return { skipped: false, ...(await createSnapshot(dir, { label })) }; } catch (e) { return { skipped: true, error: e instanceof Error ? e.message : String(e) }; } } /** The index of entry `id`: `at` when it names it, else the only entry with that id. */ export function entryIndex(timeline, id, at = null) { if (at !== null && at !== undefined) { const i = Number(at); if (!Number.isInteger(i) || timeline[i]?.id !== id) { throw new Error(`timeline[${at}] is not ${id} any more — reload`); } return i; } const hits = []; timeline.forEach((e, i) => { if (e?.id === id) hits.push(i); }); if (!hits.length) throw new Error(`no timeline entry with id ${id}`); if (hits.length > 1) throw new Error(`${id} is in the timeline ${hits.length} times — say which (at)`); return hits[0]; } /** An id not yet in the timeline: `base`, else `base-2`, `base-3`, … */ export function freshEntryId(timeline, base) { const clean = String(base).replace(/[^A-Za-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 56) || "entry"; const ids = new Set(timeline.map((e) => e?.id)); if (!ids.has(clean)) return clean; for (let n = 2; ; n += 1) if (!ids.has(`${clean}-${n}`)) return `${clean}-${n}`; } /** Every reason the build would refuse the manifest's structure, after an edit. */ function structureErrors(manifest) { return [ ...validatePosts(manifest.posts, manifest.timeline ?? [], manifest.render), ...validateClaims(manifest), ...validateTeasers(manifest), ]; } /** The build's sentences, thrown whole so a route can return each one. */ export class StructureRefused extends Error { /** @param {string[]} errors */ constructor(errors) { super(errors.join("; ")); this.name = "StructureRefused"; this.errors = errors; } } /** * One structural write: token, read, `mutate` (which throws to refuse), the * build's checks, the auto snapshot of the file as it still is, then the write. */ async function structural(dir, op, token, mutate) { return withManifestLock(async () => { const current = await manifestToken(dir); if (token !== null && current !== token) throw new StaleToken(token, current); const manifest = JSON.parse(await readFile(manifestFile(dir), "utf8")); if (!Array.isArray(manifest.timeline)) manifest.timeline = []; // Only what THIS edit breaks refuses it: a manifest that already carries a // problem the build would name (somebody's hand edit) can still be // re-ordered, and the problem is still the build's to report. const already = new Set(structureErrors(manifest)); const result = mutate(manifest); const errors = structureErrors(manifest).filter((e) => !already.has(e)); if (errors.length) throw new StructureRefused(errors); applySectionEnter(manifest.timeline); const snapshot = await autoSnapshot(dir, op); const nextToken = await writeManifestAtomic(dir, manifest); return { ...result, snapshot, token: nextToken }; }); } /** * Move one entry to `toIndex` (its index in the timeline AFTER the move). * `sectionEnter` is recomputed by report-to-video's own rule (sections.mjs), * and only on a manifest that already uses it. * * @param {string} dir * @param {string} id * @param {number} toIndex * @param {{ token?: string | null, at?: number | null }} [opts] */ export async function moveEntry(dir, id, toIndex, { token = null, at = null } = {}) { return structural(dir, "move", token, (m) => { const from = entryIndex(m.timeline, id, at); const to = Number(toIndex); if (!Number.isInteger(to) || to < 0 || to >= m.timeline.length) { throw new Error(`toIndex must be 0–${m.timeline.length - 1}`); } if (to === from) throw new Error(`${id} is already at ${to}`); const [e] = m.timeline.splice(from, 1); m.timeline.splice(to, 0, e); return { id, from, to }; }); } /** * Remove one entry. Refused when something still points at it -- a post * attached to a clip, a claim -- in the build's own words. * * @param {string} dir * @param {string} id * @param {{ token?: string | null, at?: number | null }} [opts] */ export async function removeEntry(dir, id, { token = null, at = null } = {}) { return structural(dir, "remove", token, (m) => { const i = entryIndex(m.timeline, id, at); const [removed] = m.timeline.splice(i, 1); return { id, at: i, removed }; }); } /** Copy one entry to just after itself, under a fresh id. A copied claim is dropped (one claim, one entry). * * @param {string} dir * @param {string} id * @param {{ token?: string | null, at?: number | null }} [opts] */ export async function duplicateEntry(dir, id, { token = null, at = null } = {}) { return structural(dir, "duplicate", token, (m) => { const i = entryIndex(m.timeline, id, at); const copy = JSON.parse(JSON.stringify(m.timeline[i])); copy.id = freshEntryId(m.timeline, `${id}-copy`); delete copy.claim; m.timeline.splice(i + 1, 0, copy); return { id: copy.id, at: i + 1, entry: copy }; }); } /** `/