// An edit made in umtool to a GENERATED manifest, written down for the agent // that generates it. // // A manifest with `generatedBy` (polemics/video/make-videos.py, …) is rebuilt // from the generator's inputs, and the rebuild overwrites whatever was edited // here. So edits are still allowed -- the operator is watching the cut and the // fix belongs there -- and each one becomes an `edit` note in the project's // notes.json (lib/annotations/): which entry, which field, from what, to what. // The agent ports it into the generator's inputs (BEATS, drafts) and resolves // the note, and the next rebuild keeps it. // // ONE wrapper does this for every manifest writer (lib/report/guard.ts), by // diffing the manifest before and after the write -- so a writer added later // is covered without knowing this exists. // // Repeated saves of one field COALESCE: an open edit note on the same entry and // field keeps its original `from` and takes the new `to`, and an edit that // returns the field to its `from` deletes the note. Dragging a window five // times is one note, and dragging it back is none. import { readNotes } from "../annotations/store.mjs"; import { writeNote } from "../annotations/targets.mjs"; const MAX_VALUE = 3000; /** A value small enough to keep in a note; a large one is summarised. */ function keep(v) { if (v === undefined) return null; const s = JSON.stringify(v); if (s.length <= MAX_VALUE) return v; return `(${Array.isArray(v) ? `${v.length} items` : "object"}, ${s.length} characters)`; } const ID_LISTS = ["posts", "ledger"]; const same = (a, b) => JSON.stringify(a) === JSON.stringify(b); /** Key the timeline by `id` (and its variant, since twins share an id). */ function entryKey(e) { return e?.variant ? `${e.id}@${e.variant}` : String(e?.id); } /** * Every change between two manifests, as `{ entry?, field, from, to }`. * * timeline entry field { entry: id, field: "" } * an entry added { entry: id, field: "timeline+", from: null, to: } * an entry removed { entry: id, field: "timeline-", from: , to: null } * the order { field: "timeline.order", from: [ids], to: [ids] } (survivors only) * a post, a ledger claim { entry: id, field: "posts." | "posts+" | "posts-" } (ledger alike) * anything else { field: "." } one level down (render.chrome, provenance.siteOrigin) * * @param {Record} before * @param {Record} after */ export function editsBetween(before, after) { const out = []; 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) => [entryKey(e), e])); const byB = new Map(b.map((e) => [entryKey(e), e])); for (const [k, e] of byA) if (!byB.has(k)) out.push({ entry: String(e.id), field: "timeline-", from: keep(e), to: null }); for (const [k, e] of byB) if (!byA.has(k)) out.push({ entry: String(e.id), field: "timeline+", from: null, to: keep(e) }); const sa = a.map(entryKey).filter((k) => byB.has(k)); const sb = b.map(entryKey).filter((k) => byA.has(k)); if (!same(sa, sb)) out.push({ field: "timeline.order", from: keep(sa), to: keep(sb) }); for (const [k, x] of byA) { const y = byB.get(k); if (!y) continue; for (const f of new Set([...Object.keys(x), ...Object.keys(y)])) { // sectionEnter follows the order; the order change already says it. if (f === "sectionEnter" || same(x[f], y[f])) continue; out.push({ entry: String(x.id), field: f, from: keep(x[f]), to: keep(y[f]) }); } } // Lists of things with ids -- the posts, the ledger's claims -- by id. for (const list of ID_LISTS) { const pa = new Map((Array.isArray(before?.[list]) ? before[list] : []).map((p) => [String(p?.id), p])); const pb = new Map((Array.isArray(after?.[list]) ? after[list] : []).map((p) => [String(p?.id), p])); for (const [id, p] of pa) if (!pb.has(id)) out.push({ entry: id, field: `${list}-`, from: keep(p), to: null }); for (const [id, p] of pb) { const q = pa.get(id); if (!q) { out.push({ entry: id, field: `${list}+`, from: null, to: keep(p) }); continue; } for (const f of new Set([...Object.keys(q), ...Object.keys(p)])) { if (!same(q[f], p[f])) out.push({ entry: id, field: `${list}.${f}`, from: keep(q[f]), to: keep(p[f]) }); } } } for (const top of new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})])) { if (top === "timeline" || ID_LISTS.includes(top)) continue; const x = before?.[top]; const y = after?.[top]; if (same(x, y)) continue; const isObj = (v) => v && typeof v === "object" && !Array.isArray(v); if (isObj(x) && isObj(y)) { for (const f of new Set([...Object.keys(x), ...Object.keys(y)])) { if (!same(x[f], y[f])) out.push({ field: `${top}.${f}`, from: keep(x[f]), to: keep(y[f]) }); } } else { out.push({ field: top, from: keep(x), to: keep(y) }); } } return out; } /** The sentence an edit note carries; the anchor carries the values. */ export function editText(edit, generatedBy) { const where = edit.entry ? `${edit.entry} ` : ""; const what = edit.field === "timeline+" ? "added to the timeline" : edit.field === "timeline-" ? "removed from the timeline" : edit.field === "timeline.order" ? "the timeline was re-ordered" : edit.field.endsWith("+") ? `${edit.field.slice(0, -1)} entry added` : edit.field.endsWith("-") ? `${edit.field.slice(0, -1)} entry removed` : `${edit.field} changed`; return `${where}${what} in umtool. Port it into the inputs of ${generatedBy}; a rebuild of manifests overwrites it.`; } /** * Write `edits` into a project's notes as `edit` notes, coalescing with open * ones on the same entry and field (see the top of this file). Returns how many * notes were added, updated and deleted. Errors are returned, not thrown: the * manifest write already happened, and a notes file that will not take a note * must not turn a saved edit into a reported failure. * * @param {{ file: string, subject: Record, source: () => Promise }} target * @param {Array<{ entry?: string, field: string, from: unknown, to: unknown }>} edits * @param {string} generatedBy */ export async function recordEdits(target, edits, generatedBy) { const counts = { added: 0, updated: 0, deleted: 0, errors: /** @type {string[]} */ ([]) }; for (const edit of edits) { try { const { doc } = await readNotes(target.file); const open = (doc?.notes ?? []).find( (n) => n.status === "open" && n.author === "operator" && n.anchor.kind === "edit" && n.anchor.field === edit.field && (n.anchor.entry ?? null) === (edit.entry ?? null), ); if (open) { const from = /** @type {any} */ (open.anchor).from; // Put back as it was: the note says nothing -- unless somebody has // already replied to it, and then it stays for them to resolve. if (same(from, edit.to) && !open.replies.length) { await writeNote(target, { op: "delete", id: open.id }, { by: "operator" }); counts.deleted += 1; } else { const anchor = { kind: "edit", field: edit.field, from, to: edit.to, ...(edit.entry ? { entry: edit.entry } : {}) }; await writeNote(target, { op: "edit", id: open.id, anchor }, { by: "operator" }); counts.updated += 1; } continue; } const anchor = { kind: "edit", field: edit.field, from: edit.from, to: edit.to, ...(edit.entry ? { entry: edit.entry } : {}) }; await writeNote(target, { op: "add", text: editText(edit, generatedBy), anchor }, { by: "operator" }); counts.added += 1; } catch (e) { counts.errors.push(e instanceof Error ? e.message : String(e)); } } return counts; }