Archilyzer · Source

archilyzer

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

commit 3890e1d4540d5a7fbf6175e88c866811d437151b
parent 5a717dd1901e8277854225d48f61c207305890e7
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  8 Oct 2026 22:36:59 -0400

umtool: structural manifest writers (move, remove, duplicate, insert, teaser, posts, fact-check, undo)

report-to-video/sections.mjs writes down the sectionEnter rule the build only
ever read (identity on every real manifest); each structural write snapshots
auto-before-<op> (one per op per 2 min) and refuses only what it breaks.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mumtool/lib/report/manifest.mjs | 354++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Aumtool/lib/report/structure.test.mjs | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/report-to-video/package.json | 1+
Aumtool/report-to-video/sections.mjs | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aumtool/report-to-video/sections.test.mjs | 42++++++++++++++++++++++++++++++++++++++++++
5 files changed, 690 insertions(+), 1 deletion(-)

diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs @@ -30,10 +30,12 @@ import { rolesGaps, } from "umtool-report-to-video/ledger-totals"; import { isCalendarDate } from "umtool-report-to-video/attribution"; -import { normalizeOnscreen, validateChrome, validatePosts } from "umtool-report-to-video/deck"; +import { normalizeOnscreen, validateChrome, validatePosts, validateTeaser, validateTeasers } from "umtool-report-to-video/deck"; +import { applySectionEnter } from "umtool-report-to-video/sections"; 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. // @@ -787,3 +789,353 @@ export async function updateStorage(dir, { deliverables } = {}, { token = null } 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-<op>`, 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-<op>`, 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. + */ +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). */ +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 }; + }); +} + +/** `<channel>/<video>@<start>-<end>`, in seconds. */ +const CLIP_SPEC_RE = /^([A-Za-z0-9._-]+)\/([^@\s/]+)@(\d+(?:\.\d+)?)-(\d+(?:\.\d+)?)$/; + +/** + * What `insertEntry` will put in the timeline, checked: a clip spec string + * (`<channel>/<video>@<start>-<end>`) becomes a bare clip; an object is an + * entry as written, given a fresh id when it has none (or one already taken). + * + * @param {Array<Record<string, unknown>>} timeline + * @param {unknown} spec + */ +export function entryFromSpec(timeline, spec) { + if (typeof spec === "string") { + const mm = spec.trim().match(CLIP_SPEC_RE); + if (!mm) throw new Error("a clip is <channel>/<video>@<start>-<end> in seconds"); + const [, channel, video, s, e] = mm; + return clipEntry(timeline, { type: "clip", channel, video, start: Number(s), end: Number(e) }); + } + if (!spec || typeof spec !== "object" || Array.isArray(spec)) throw new Error("an entry is an object, or a clip spec string"); + const entry = JSON.parse(JSON.stringify(spec)); + if (JSON.stringify(entry).length > 20000) throw new Error("that entry is too large"); + if (typeof entry.type !== "string" || !entry.type) throw new Error("an entry needs a type"); + if (entry.type === "clip") return clipEntry(timeline, entry); + const base = typeof entry.id === "string" && ENTRY_ID_RE.test(entry.id) ? entry.id : entry.type; + entry.id = freshEntryId(timeline, base); + return entry; +} + +function clipEntry(timeline, e) { + if (typeof e.video !== "string" || !e.video.trim()) throw new Error("a clip needs its video id"); + for (const k of ["start", "end"]) { + const v = Number(e[k]); + if (!Number.isFinite(v) || v < 0) throw new Error(`a clip's ${k} must be a number ≥ 0`); + e[k] = round2(v); + } + if (e.end - e.start < 0.5) throw new Error(`a clip must be at least half a second (${e.start}–${e.end})`); + if (e.channel !== undefined && (typeof e.channel !== "string" || !e.channel)) delete e.channel; + const base = typeof e.id === "string" && ENTRY_ID_RE.test(e.id) ? e.id : `${e.video}-${Math.floor(e.start)}`; + // `type` and `id` first: the manifests are read by humans. + const { type: _t, id: _i, ...rest } = e; + return { type: "clip", id: freshEntryId(timeline, base), ...rest }; +} + +/** + * Insert an entry after `afterId` (null or "": at the start). `spec` is a clip + * spec string or an entry object (entryFromSpec). + * + * @param {string} dir + * @param {string | null} afterId + * @param {unknown} spec + * @param {{ token?: string | null, at?: number | null }} [opts] `at` is afterId's index + */ +export async function insertEntry(dir, afterId, spec, { token = null, at = null } = {}) { + return structural(dir, "insert", token, (m) => { + const i = afterId ? entryIndex(m.timeline, afterId, at) + 1 : 0; + const entry = entryFromSpec(m.timeline, spec); + m.timeline.splice(i, 0, entry); + return { id: entry.id, at: i, entry }; + }); +} + +const TEASER_PATCH_KEYS = ["lines", "beat", "dip", "tail", "tailWait"]; + +/** + * Patch one teaser: its lines, beat, dip, tail and tail wait. A key given as + * null (or "") is removed; a key not given is kept. Checked with the build's + * validateTeaser (which checks the dip too) against the patched entry. + */ +export async function updateTeaser(dir, id, patch, { token = null, at = null } = {}) { + if (!patch || typeof patch !== "object" || Array.isArray(patch)) throw new Error("a teaser patch is an object"); + const keys = Object.keys(patch); + const bad = keys.filter((k) => !TEASER_PATCH_KEYS.includes(k)); + if (bad.length) throw new Error(`${bad.join(", ")}: not something this writer changes (${TEASER_PATCH_KEYS.join(", ")})`); + if (!keys.length) throw new Error("nothing to change"); + return structural(dir, "teaser", token, (m) => { + const e = m.timeline[entryIndex(m.timeline, id, at)]; + if (e.type !== "teaser") throw new Error(`${id} is a ${e.type ?? "non-teaser"} entry, not a teaser`); + for (const k of keys) { + const v = patch[k]; + if (v === null || v === "" || v === undefined) delete e[k]; + else if (k === "beat" || k === "tailWait") e[k] = round2(Number(v)); + else if (k === "dip") { + e.dip = typeof v === "object" && v ? { fade: round2(Number(v.fade)), black: round2(Number(v.black)) } : v; + } else if (k === "tail") e.tail = String(v); + else e[k] = v; + } + const errors = validateTeaser(e); + if (errors.length) throw new StructureRefused(errors); + return { id, entry: e }; + }); +} + +const POST_TEXT_KEYS = ["platform", "author", "handle", "date", "text", "url", "shot", "flag", "accent", "logo", "siteChannel", "siteUrl", "postId", "variant"]; + +/** + * Add a post, or replace the one with its id. The value is the post as it + * should be stored: an empty optional string is dropped rather than written. + * `attachTo` and `hide` are kept from the stored post unless the value names + * them. Checked with the build's validatePosts. + */ +export async function upsertPost(dir, post, { token = null } = {}) { + if (!post || typeof post !== "object" || Array.isArray(post)) throw new Error("a post is an object"); + if (typeof post.id !== "string" || !ENTRY_ID_RE.test(post.id)) throw new Error("a post needs an id: letters, digits, dashes, underscores"); + return structural(dir, "post", token, (m) => { + if (!Array.isArray(m.posts)) m.posts = []; + const i = m.posts.findIndex((p) => p?.id === post.id); + const prev = i >= 0 ? m.posts[i] : {}; + const next = { id: post.id }; + for (const k of POST_TEXT_KEYS) { + const v = k in post ? post[k] : prev[k]; + if (v === undefined || v === null || (typeof v === "string" && !v.trim())) continue; + next[k] = typeof v === "string" ? v.trim() : v; + } + for (const k of ["attachTo", "hide"]) { + const v = k in post ? post[k] : prev[k]; + if (v === undefined || v === null || v === "" || v === false) continue; + next[k] = v; + } + if (i >= 0) m.posts[i] = next; + else m.posts.push(next); + return { post: next, created: i < 0 }; + }); +} + +/** Remove one post by id. */ +export async function removePost(dir, id, { token = null } = {}) { + return structural(dir, "post", token, (m) => { + const i = (m.posts ?? []).findIndex((p) => p?.id === id); + if (i < 0) throw new Error(`no post with id ${id}`); + const [removed] = m.posts.splice(i, 1); + if (!m.posts.length) delete m.posts; + return { removed }; + }); +} + +/** + * Set or remove `render.chrome.factcheck`: the stamp, the tally, and each + * verdict's label and colour. Only on a manifest whose deck is on (the + * fact-check is drawn by the deck); null removes it. Checked by the build's + * validateChrome against the rest of the render block. + */ +export async function updateFactcheck(dir, factcheck, { token = null } = {}) { + if (factcheck === undefined) throw new Error("factcheck must be an object, or null to remove it"); + return structural(dir, "factcheck", token, (m) => { + const render = m.render ?? {}; + if (!render.chrome || typeof render.chrome !== "object") { + throw new Error("the fact-check is drawn by the on-screen deck, and this manifest has none — turn the deck on first"); + } + const chrome = { ...render.chrome }; + if (factcheck === null) delete chrome.factcheck; + else chrome.factcheck = factcheck; + const { chrome: _old, ...rest } = render; + const already = new Set(validateChrome(render.chrome, rest)); + const errors = validateChrome(chrome, rest).filter((e) => !already.has(e)); + if (errors.length) throw new StructureRefused(errors); + m.render = { ...render, chrome }; + return { factcheck: chrome.factcheck ?? null }; + }); +} + +/** + * Take back the newest structural edit: restore the newest `auto-before-<op>` + * snapshot, byte for byte. The manifest as it is now is kept first + * (`undo-saved`), and the restored snapshot is renamed `undone-<op>` so the + * next undo goes one step further back rather than round in a circle. + */ +export async function undoStructural(dir, { token = null } = {}) { + return withManifestLock(async () => { + const current = await manifestToken(dir); + if (token !== null && current !== token) throw new StaleToken(token, current); + const target = (await listSnapshots(dir)).find((s) => !s.legacy && s.label?.startsWith(AUTO_SNAPSHOT_PREFIX)); + if (!target) throw new Error("nothing to undo: no automatic snapshot in revisions/"); + const op = target.label.slice(AUTO_SNAPSHOT_PREFIX.length); + const text = await readFile(path.join(dir, target.rel), "utf8"); + JSON.parse(text); // a snapshot that does not parse is not restored over a manifest that does + let saved = null; + try { + saved = (await createSnapshot(dir, { label: "undo-saved" })).rel; + } catch { + // within the same second as another snapshot: the state is already kept + } + const file = manifestFile(dir); + const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`; + await writeFile(tmp, text, "utf8"); + await rename(tmp, file); + const undone = target.rel.replace(`-${target.label}.manifest.json`, `-undone-${op}.manifest.json`); + await rename(path.join(dir, target.rel), path.join(dir, undone)).catch(() => {}); + return { restored: target.rel, op, saved, token: await manifestToken(dir) }; + }); +} diff --git a/umtool/lib/report/structure.test.mjs b/umtool/lib/report/structure.test.mjs @@ -0,0 +1,235 @@ +// The structural writers: move, remove, duplicate, insert, the teaser, the +// posts, the fact-check, and undo. Each goes through the token, the lock, the +// build's own checks and an automatic snapshot, and each refusal leaves the +// file byte-for-byte as it was. +// +// Run with: pnpm test:scripts +import assert from "node:assert/strict"; +import { mkdtemp, readFile, readdir, rm, utimes, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + MANIFEST_NAME, + StaleToken, + StructureRefused, + duplicateEntry, + entryFromSpec, + insertEntry, + manifestToken, + moveEntry, + removeEntry, + removePost, + undoStructural, + updateFactcheck, + updateTeaser, + upsertPost, +} from "./manifest.mjs"; + +const base = () => ({ + slug: "t", + provenance: { siteOrigin: "https://example.test", channelSlug: "chan" }, + render: { width: 1920, height: 1080, fps: 30, transition: 0.5 }, + timeline: [ + { type: "teaser", id: "tz", lines: ["THE PROMISE"] }, + { type: "clip", id: "c01", video: "v1", start: 10, end: 20, section: 1, sectionEnter: true }, + { type: "clip", id: "c02", video: "v2", start: 30, end: 41, section: 1 }, + { type: "clip", id: "c03", video: "v3", start: 50, end: 55, section: 2, sectionEnter: true }, + ], + posts: [ + { id: "p1", platform: "x", date: "2024-01-02", text: "hello", url: "https://x.com/a/status/1", attachTo: "c02" }, + ], +}); + +async function project(manifest = base()) { + const dir = await mkdtemp(path.join(tmpdir(), "umtool-structure-")); + await writeFile(path.join(dir, MANIFEST_NAME), JSON.stringify(manifest, null, 2) + "\n"); + return dir; +} +const readRaw = (dir) => readFile(path.join(dir, MANIFEST_NAME), "utf8"); +const read = async (dir) => JSON.parse(await readRaw(dir)); +const ids = (m) => m.timeline.map((e) => e.id); +const revisions = async (dir) => (await readdir(path.join(dir, "revisions")).catch(() => [])).sort(); + +test("moveEntry: re-orders, recomputes sectionEnter, snapshots once per burst", async () => { + const dir = await project(); + try { + const r = await moveEntry(dir, "c03", 1, { token: await manifestToken(dir) }); + assert.deepEqual([r.from, r.to], [3, 1]); + const m = await read(dir); + assert.deepEqual(ids(m), ["tz", "c03", "c01", "c02"]); + // c03 (section 2) now first: it enters; c01 (section 1, after a 2) enters; c02 does not + assert.equal(m.timeline[1].sectionEnter, true); + assert.equal(m.timeline[2].sectionEnter, true); + assert.equal("sectionEnter" in m.timeline[3], false); + // The file is still the CLI's formatting. + assert.equal(await readRaw(dir), JSON.stringify(m, null, 2) + "\n"); + assert.equal((await revisions(dir)).filter((n) => n.includes("auto-before-move")).length, 1); + await moveEntry(dir, "c03", 3); + assert.equal((await revisions(dir)).filter((n) => n.includes("auto-before-move")).length, 1, "throttled"); + await assert.rejects(moveEntry(dir, "c03", 3), /already at 3/); + await assert.rejects(moveEntry(dir, "c03", 9), /toIndex/); + await assert.rejects(moveEntry(dir, "nope", 0), /no timeline entry/); + } finally { + await rm(dir, { recursive: true }); + } +}); + +test("a stale token refuses every structural write and leaves the file alone", async () => { + const dir = await project(); + try { + const before = await readRaw(dir); + for (const call of [ + () => moveEntry(dir, "c03", 1, { token: "1" }), + () => removeEntry(dir, "c03", { token: "1" }), + () => duplicateEntry(dir, "c03", { token: "1" }), + () => insertEntry(dir, "c03", "chan/vx@1-5", { token: "1" }), + () => updateTeaser(dir, "tz", { beat: 1 }, { token: "1" }), + () => upsertPost(dir, { id: "p2" }, { token: "1" }), + () => removePost(dir, "p1", { token: "1" }), + () => undoStructural(dir, { token: "1" }), + ]) { + await assert.rejects(call(), StaleToken); + } + assert.equal(await readRaw(dir), before); + } finally { + await rm(dir, { recursive: true }); + } +}); + +test("removeEntry: refused while a post rides on the clip; fine once it does not", async () => { + const dir = await project(); + try { + const before = await readRaw(dir); + await assert.rejects(removeEntry(dir, "c02"), (e) => e instanceof StructureRefused && /attachTo/.test(e.message)); + assert.equal(await readRaw(dir), before); + const r = await removeEntry(dir, "c03"); + assert.equal(r.removed.id, "c03"); + assert.deepEqual(ids(await read(dir)), ["tz", "c01", "c02"]); + } finally { + await rm(dir, { recursive: true }); + } +}); + +test("duplicateEntry and insertEntry: fresh ids, the clip spec, 2 dp, at the right place", async () => { + const dir = await project(); + try { + const d = await duplicateEntry(dir, "c01"); + assert.equal(d.id, "c01-copy"); + const again = await duplicateEntry(dir, "c01"); + assert.equal(again.id, "c01-copy-2"); + const ins = await insertEntry(dir, "c02", "mychan/abc123@12.3456-20.1"); + assert.deepEqual(ins.entry, { type: "clip", id: "abc123-12", channel: "mychan", video: "abc123", start: 12.35, end: 20.1 }); + const first = await insertEntry(dir, null, { type: "card", heading: "Start" }); + assert.equal(first.at, 0); + assert.equal(first.id, "card"); + const m = await read(dir); + assert.deepEqual(ids(m), ["card", "tz", "c01", "c01-copy-2", "c01-copy", "c02", "abc123-12", "c03"]); + await assert.rejects(insertEntry(dir, "c02", "not a spec"), /channel/); + await assert.rejects(insertEntry(dir, "c02", "chan/v@5-5.2"), /half a second/); + // A teaser that the build would refuse is refused here. + await assert.rejects(insertEntry(dir, "c02", { type: "teaser", lines: [] }), StructureRefused); + } finally { + await rm(dir, { recursive: true }); + } +}); + +test("entryFromSpec never reuses an id", () => { + const tl = [{ id: "a" }, { id: "v-1" }]; + assert.equal(entryFromSpec(tl, { type: "card", id: "a" }).id, "a-2"); + assert.equal(entryFromSpec(tl, "c/v@1-3").id, "v-1-2"); +}); + +test("updateTeaser: lines, beat, tail, dip — validated by the build's own check", async () => { + const dir = await project(); + try { + const r = await updateTeaser(dir, "tz", { lines: ["THE PROMISE", "AND WHAT HAPPENED"], beat: 1.2345, tail: "?", tailWait: 1 }); + assert.equal(r.entry.beat, 1.23); + await assert.rejects(updateTeaser(dir, "tz", { lines: [] }), StructureRefused); + await assert.rejects(updateTeaser(dir, "tz", { tail: null }), /tailWait/); + await updateTeaser(dir, "tz", { tail: null, tailWait: null }); + const m = await read(dir); + assert.equal("tail" in m.timeline[0], false); + await assert.rejects(updateTeaser(dir, "c01", { beat: 1 }), /not a teaser/); + await assert.rejects(updateTeaser(dir, "tz", { seconds: 4 }), /not something/); + } finally { + await rm(dir, { recursive: true }); + } +}); + +test("upsertPost / removePost: add, replace keeping attachTo, refuse what validatePosts refuses", async () => { + const dir = await project(); + try { + const add = await upsertPost(dir, { id: "p2", platform: "bluesky", date: "2024-03-04", text: " words ", url: "https://bsky.app/x", author: "" }); + assert.equal(add.created, true); + assert.deepEqual(add.post, { id: "p2", platform: "bluesky", date: "2024-03-04", text: "words", url: "https://bsky.app/x" }); + const rep = await upsertPost(dir, { id: "p1", platform: "x", date: "2024-01-02", text: "edited", url: "https://x.com/a/status/1" }); + assert.equal(rep.post.attachTo, "c02"); + await assert.rejects(upsertPost(dir, { id: "p3", platform: "myspace", date: "2024", text: "x", url: "http://no" }), StructureRefused); + await removePost(dir, "p2"); + await removePost(dir, "p1"); + assert.equal("posts" in (await read(dir)), false); + await assert.rejects(removePost(dir, "p1"), /no post/); + } finally { + await rm(dir, { recursive: true }); + } +}); + +test("updateFactcheck: needs the deck; labels and colours checked by validateChrome", async () => { + const dir = await project(); + try { + await assert.rejects(updateFactcheck(dir, { stamp: { seconds: 3 } }), /deck on first/); + const m = await read(dir); + m.render.chrome = { engine: "hyperframes", layout: "deck" }; + await writeFile(path.join(dir, MANIFEST_NAME), JSON.stringify(m, null, 2) + "\n"); + const r = await updateFactcheck(dir, { verdicts: { CONTRADICTED: { label: "NOPE", color: "#ff0000" } }, stamp: { seconds: 4 } }); + assert.equal(r.factcheck.stamp.seconds, 4); + await assert.rejects(updateFactcheck(dir, { stamp: { seconds: 99 } }), StructureRefused); + await updateFactcheck(dir, null); + assert.equal("factcheck" in (await read(dir)).render.chrome, false); + } finally { + await rm(dir, { recursive: true }); + } +}); + +test("undoStructural restores the newest automatic snapshot, keeps the current state, and walks back", async () => { + const dir = await project(); + try { + const original = await readRaw(dir); + await assert.rejects(undoStructural(dir), /nothing to undo/); + await moveEntry(dir, "c03", 1); + // Age the move's snapshot so the next op is not throttled into it. + const rev = path.join(dir, "revisions"); + for (const n of await readdir(rev)) await utimes(path.join(rev, n), new Date(Date.now() - 600_000), new Date(Date.now() - 600_000)); + await new Promise((r) => setTimeout(r, 1100)); + await removeEntry(dir, "c01"); + const afterRemove = ids(await read(dir)); + assert.deepEqual(afterRemove, ["tz", "c03", "c02"]); + + const u1 = await undoStructural(dir); + assert.equal(u1.op, "remove"); + assert.deepEqual(ids(await read(dir)), ["tz", "c03", "c01", "c02"]); + await new Promise((r) => setTimeout(r, 1100)); + const u2 = await undoStructural(dir); + assert.equal(u2.op, "move"); + assert.equal(await readRaw(dir), original, "byte for byte"); + const names = await revisions(dir); + assert.ok(names.some((n) => n.includes("undone-move"))); + assert.ok(names.some((n) => n.includes("undo-saved"))); + } finally { + await rm(dir, { recursive: true }); + } +}); + +test("a manifest that already has a build problem can still be re-ordered", async () => { + const m = base(); + m.posts[0].attachTo = "gone"; // already broken before the edit + const dir = await project(m); + try { + await moveEntry(dir, "c03", 1); + assert.deepEqual(ids(await read(dir)), ["tz", "c03", "c01", "c02"]); + } finally { + await rm(dir, { recursive: true }); + } +}); diff --git a/umtool/report-to-video/package.json b/umtool/report-to-video/package.json @@ -32,6 +32,7 @@ "./post-links": "./post-links.mjs", "./render-cards": "./render-cards.mjs", "./resolve-windows": "./resolve-windows.mjs", + "./sections": "./sections.mjs", "./shoot-page": "./shoot-page.mjs", "./sources": "./sources.mjs", "./verify-build": "./verify-build.mjs" diff --git a/umtool/report-to-video/sections.mjs b/umtool/report-to-video/sections.mjs @@ -0,0 +1,59 @@ +// `sectionEnter`: the flag on the first clip of each section, which is what +// makes the legacy footer's marker slide from one node to the next +// (build-video.mjs reads it; README "The marker slides"). +// +// The build has only ever READ it. The rule lived in whoever wrote the +// manifest: in the one real manifest that carries it +// (quartering-employee-count, seven of nineteen clips), a clip carries the flag +// exactly when its `section` differs from the previous CLIP's -- the first clip +// counts, a card between two clips does not break a section, and a clip with +// no `section` never enters one. This is that rule, written once, for a writer +// that re-orders the timeline (umtool's lib/report/manifest.mjs moveEntry). +// +// A manifest that carries no `sectionEnter` key at all (every deck-era cut: +// the deck draws no footer) is left exactly as it is: `applySectionEnter` +// changes nothing unless some entry already carries the key. + +/** + * The flag each entry should carry, by index: true on a clip whose `section` + * is set and differs from the previous clip's; false everywhere else. + * + * @param {Array<Record<string, unknown>>} timeline + * @returns {boolean[]} + */ +export function sectionEnterFlags(timeline) { + let prev; + return (timeline ?? []).map((e) => { + if (e?.type !== "clip") return false; + const s = e.section; + const enters = s !== undefined && s !== null && s !== prev; + prev = s; + return enters; + }); +} + +/** Does this timeline use the flag at all? */ +export const usesSectionEnter = (timeline) => (timeline ?? []).some((e) => e && Object.hasOwn(e, "sectionEnter")); + +/** + * Recompute `sectionEnter` in place after a re-order. Only on a timeline that + * already uses it; written as `true` or removed (an absent key reads as false, + * and `"sectionEnter": false` is noise a human reads as a decision). Returns + * the ids whose flag changed. + * + * @param {Array<Record<string, unknown>>} timeline + * @returns {string[]} + */ +export function applySectionEnter(timeline) { + if (!usesSectionEnter(timeline)) return []; + const flags = sectionEnterFlags(timeline); + const changed = []; + timeline.forEach((e, i) => { + const was = e.sectionEnter === true; + if (flags[i] === was) return; + if (flags[i]) e.sectionEnter = true; + else delete e.sectionEnter; + changed.push(String(e.id)); + }); + return changed; +} diff --git a/umtool/report-to-video/sections.test.mjs b/umtool/report-to-video/sections.test.mjs @@ -0,0 +1,42 @@ +// The sectionEnter rule, against the shape of the one manifest that uses it. +import assert from "node:assert/strict"; +import test from "node:test"; +import { applySectionEnter, sectionEnterFlags, usesSectionEnter } from "./sections.mjs"; + +const clip = (id, section, extra = {}) => ({ type: "clip", id, section, ...extra }); + +test("a clip enters a section when its section differs from the previous clip's", () => { + const tl = [ + { type: "card", id: "t0" }, + clip("a", 1), + clip("b", 1), + { type: "card", id: "mid" }, + clip("c", 1), + clip("d", 2), + clip("e"), + clip("f", 2), + { type: "scroll", id: "s" }, + ]; + assert.deepEqual(sectionEnterFlags(tl), [false, true, false, false, false, true, false, true, false]); +}); + +test("applySectionEnter leaves a timeline that never used the flag alone", () => { + const tl = [clip("a", 1), clip("b", 2)]; + assert.deepEqual(applySectionEnter(tl), []); + assert.equal(usesSectionEnter(tl), false); + assert.equal("sectionEnter" in tl[0], false); +}); + +test("applySectionEnter is the identity on a timeline already in order, and fixes a move", () => { + const tl = [clip("a", 1, { sectionEnter: true }), clip("b", 1), clip("c", 2, { sectionEnter: true }), clip("d", 2)]; + assert.deepEqual(applySectionEnter(tl), []); + // d moved to the front: d enters 2, a enters 1, c no longer enters (b was 1, c is 2 -> still enters) + const moved = [tl[3], tl[0], tl[1], tl[2]]; + assert.deepEqual(applySectionEnter(moved).sort(), ["d"]); + assert.equal(moved[0].sectionEnter, true); + assert.equal(moved[3].sectionEnter, true); + // and moving it back removes the flag again (deleted, never written false) + const back = [moved[1], moved[2], moved[3], moved[0]]; + assert.deepEqual(applySectionEnter(back), ["d"]); + assert.equal("sectionEnter" in back[3], false); +});