// What changed between two manifests, by timeline entry. // // PURE. Two parsed manifests in, a list of changes out: added / removed / // moved / window (start, end, lock) / retyped -- the same shape the // ferret-rescue `.bak` comparison produced by hand (23 entries then, 10 clips // now, a whole restructure). Entries are matched by `id`, because array order // IS the cut and a moved entry is a change worth its own line. // // Presentation follows PlanDiffPanel's idiom (a list of typed changes), not its // code: that one diffs song plans by slot, and a timeline has no slots. const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : null); const lockOf = (e) => ({ lock: !!e.lock, lockStart: !!e.lockStart, lockEnd: !!e.lockEnd }); const sameLock = (a, b) => a.lock === b.lock && a.lockStart === b.lockStart && a.lockEnd === b.lockEnd; /** * @param {object} before the older manifest (a snapshot) * @param {object} after the newer one (usually the current manifest) * @returns {{ changes: Array<{ kind: "added"|"removed"|"moved"|"window"|"retyped"|"field", id: string, type?: string, from?: unknown, to?: unknown, fields?: string[] }>, counts: Record, same: boolean }} */ export function diffManifests(before, after) { const a = (before?.timeline ?? []).filter((e) => e && e.id != null); const b = (after?.timeline ?? []).filter((e) => e && e.id != null); const byA = new Map(a.map((e, i) => [String(e.id), { e, i }])); const byB = new Map(b.map((e, i) => [String(e.id), { e, i }])); const changes = []; for (const [id, { e, i }] of byA) { if (!byB.has(id)) changes.push({ kind: "removed", id, type: e.type ?? "entry", at: i }); } for (const [id, { e, i }] of byB) { if (!byA.has(id)) changes.push({ kind: "added", id, type: e.type ?? "entry", at: i }); } // Order, compared on the ids BOTH have: an entry is "moved" when its rank // among the survivors changed, so a removal above it does not report every // entry below as moved. const survivorsA = a.filter((e) => byB.has(String(e.id))).map((e) => String(e.id)); const survivorsB = b.filter((e) => byA.has(String(e.id))).map((e) => String(e.id)); for (let i = 0; i < survivorsB.length; i += 1) { const id = survivorsB[i]; const was = survivorsA.indexOf(id); if (was !== i) changes.push({ kind: "moved", id, from: was, to: i }); } for (const [id, { e: x }] of byA) { const y = byB.get(id)?.e; if (!y) continue; if ((x.type ?? "entry") !== (y.type ?? "entry")) { changes.push({ kind: "retyped", id, from: x.type ?? "entry", to: y.type ?? "entry" }); continue; } if (x.type === "clip") { const fields = []; if (num(x.start) !== num(y.start)) fields.push("start"); if (num(x.end) !== num(y.end)) fields.push("end"); if (!sameLock(lockOf(x), lockOf(y))) fields.push("lock"); if (String(x.video ?? "") !== String(y.video ?? "") || String(x.channel ?? "") !== String(y.channel ?? "")) fields.push("source"); if (fields.length) { changes.push({ kind: "window", id, fields, from: { start: num(x.start), end: num(x.end), ...lockOf(x), video: x.video, channel: x.channel ?? null }, to: { start: num(y.start), end: num(y.end), ...lockOf(y), video: y.video, channel: y.channel ?? null }, }); } } // Any other field that differs, named but not expanded: a quote rewrite or // a new chapter title is a change, and a diff that hid it would be lying. const other = []; for (const k of new Set([...Object.keys(x), ...Object.keys(y)])) { if (["start", "end", "lock", "lockStart", "lockEnd", "video", "channel", "type", "id"].includes(k)) continue; if (JSON.stringify(x[k]) !== JSON.stringify(y[k])) other.push(k); } if (other.length) changes.push({ kind: "field", id, fields: other.sort() }); } const counts = {}; for (const c of changes) counts[c.kind] = (counts[c.kind] ?? 0) + 1; const rank = { removed: 0, added: 1, retyped: 2, window: 3, moved: 4, field: 5 }; changes.sort((p, q) => rank[p.kind] - rank[q.kind] || String(p.id).localeCompare(String(q.id))); return { changes, counts, same: changes.length === 0, entriesBefore: a.length, entriesAfter: b.length }; } /** One line per change, for the CLI. */ export function formatChange(c) { switch (c.kind) { case "added": return `+ ${c.id} added (${c.type}, at ${c.at})`; case "removed": return `- ${c.id} removed (${c.type}, was at ${c.at})`; case "moved": return `~ ${c.id} moved ${c.from} -> ${c.to}`; case "retyped": return `~ ${c.id} retyped ${c.from} -> ${c.to}`; case "window": { const parts = []; if (c.fields.includes("start") || c.fields.includes("end")) { parts.push(`${c.from.start ?? "?"}–${c.from.end ?? "?"} -> ${c.to.start ?? "?"}–${c.to.end ?? "?"}`); } if (c.fields.includes("lock")) { const l = (o) => ["lock", "lockStart", "lockEnd"].filter((k) => o[k]).join("+") || "unlocked"; parts.push(`${l(c.from)} -> ${l(c.to)}`); } if (c.fields.includes("source")) parts.push(`${c.from.channel ?? ""}/${c.from.video} -> ${c.to.channel ?? ""}/${c.to.video}`); return `~ ${c.id} window: ${parts.join("; ")}`; } case "field": return `~ ${c.id} ${c.fields.join(", ")} changed`; default: return `? ${c.id}`; } }