commit 37fd74d18b3bb358654aac78fed2e8e58298c31a
parent 97e03c8cc2bf2b60da934e061321149640e866ce
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Fri, 18 Sep 2026 17:30:45 -0400
umtool: the attribution is writable, through the one writer
updateClip() now takes title, date, cite, citeUrl and quote beside the window
and the locks. 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.
Validated at the writer, not the route. `date` is the one that matters — it is
prose the renderer prints verbatim, so 2025-02-31 would ship as a fact about
when somebody said something. citeUrl must be http(s): a QR that encodes
anything else is the same class of defect as the 19 codes reading
`undefined/?v=…`. An empty value deletes the key, the way the flags do.
The route mirrors the whitelist; `umtool window` grows the matching flags and
prints the header line it just changed, so an attribution edit is checkable
without building anything.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
3 files changed, 113 insertions(+), 9 deletions(-)
diff --git a/umtool/app/api/report/window/route.ts b/umtool/app/api/report/window/route.ts
@@ -3,10 +3,10 @@ import { resolveClip } from "@/lib/report/serve.mjs";
export const dynamic = "force-dynamic";
-// Saving a window.
+// Saving a window, and what the header says about it.
//
-// The client sends a project id, a clip id, numbers, and the TOKEN it was given
-// when it opened the clip. It never sends a path, and it cannot ask for an
+// The client sends a project id, a clip id, numbers, strings, and the TOKEN it
+// was given when it opened the clip. It never sends a path, and it cannot ask for an
// entry to move: re-ordering recomputes `sectionEnter` and changes the cut, so
// it is a different operation with a different button.
//
@@ -26,8 +26,23 @@ export async function PUT(request: Request) {
const r = await resolveClip(projectId, clipId);
if ("error" in r) return Response.json({ error: r.error }, { status: r.status });
+ // The whitelist, mirrored from lib/report/manifest.mjs so a field nobody
+ // meant to expose cannot arrive through a JSON body. The second group is the
+ // attribution: what the burned-in header and the QR will say.
const patch: Record<string, unknown> = {};
- for (const k of ["start", "end", "lock", "lockStart", "lockEnd", "note"]) {
+ for (const k of [
+ "start",
+ "end",
+ "lock",
+ "lockStart",
+ "lockEnd",
+ "note",
+ "title",
+ "date",
+ "cite",
+ "citeUrl",
+ "quote",
+ ]) {
if (body[k] !== undefined) patch[k] = body[k];
}
if (!Object.keys(patch).length) return Response.json({ error: "nothing to change" }, { status: 400 });
diff --git a/umtool/bin/umtool.mjs b/umtool/bin/umtool.mjs
@@ -17,7 +17,8 @@
// umtool decisions [--json]
// umtool folders [--json]
// umtool kinds [--json]
-// umtool window <project> <clip> [--start S] [--end E] [--lock] [--lock-end] ...
+// umtool window <project> <clip> [--start S] [--end E] [--lock] [--lock-end]
+// [--title T] [--date YYYY-MM-DD] [--cite S] [--cite-url U] [--quote Q]
// umtool build <project> [--preset preview|fast|final] [--only ID] [--dry]
// umtool index [--rebuild] [--prune] [--since MS] [--json]
// umtool new <slug> [--kind report-video] [--from <report.md>|<share URL>|<channel>/<id>]
@@ -44,6 +45,7 @@ import { diffManifests, formatChange } from "../lib/report/manifest-diff.mjs";
import { EXPORT_FORMATS, exportProject } from "../lib/report/export.mjs";
import path from "node:path";
import { updateClip } from "../lib/report/manifest.mjs";
+import { hms } from "umtool-report-to-video/attribution";
import { buildSteps, checkSourcesSteps, PRESETS } from "../lib/report/driver.mjs";
import { openIndex, signRecord } from "../lib/projects/index-db.mjs";
import { probeTools } from "../lib/tools.mjs";
@@ -431,6 +433,8 @@ function usage() {
" umtool folders [--json]",
" umtool kinds [--json]",
" umtool window <project> <clip> [--start S] [--end E] [--lock|--lock-end|…]",
+ " attribution too: --title, --date YYYY-MM-DD, --cite, --cite-url, --quote",
+ " an empty value (--title '') deletes the field",
" umtool build <project> [--preset preview|fast|final] [--only ID]",
" umtool index [--rebuild] [--prune] [--since MS] [--json]",
" umtool new <slug> [--from <report.md>|<share URL>|<channel>/<id>] [--site-origin URL] [--seed chapters]",
@@ -499,6 +503,18 @@ async function cmdWindow() {
if (has(`--no-${flag.slice(2)}`)) patch[key] = false;
}
if (val("--note") !== undefined) patch.note = val("--note");
+ // The attribution: what the burned-in header and the QR will say. An empty
+ // value DELETES the field, the same rule the flags keep -- `--title ''` is how
+ // you go back to the archived record's own title.
+ for (const [flag, key] of [
+ ["--title", "title"],
+ ["--date", "date"],
+ ["--cite", "cite"],
+ ["--cite-url", "citeUrl"],
+ ["--quote", "quote"],
+ ]) {
+ if (val(flag) !== undefined) patch[key] = val(flag);
+ }
if (!Object.keys(patch).length) die("nothing to change");
try {
@@ -512,6 +528,15 @@ async function cmdWindow() {
);
const marks = ["lock", "lockStart", "lockEnd"].filter((k) => res.entry[k]);
if (marks.length) console.log(` ${marks.join(", ")}`);
+ // The line the renderer will burn in, printed so an attribution edit is
+ // checkable without building anything.
+ if (["title", "date", "cite", "citeUrl", "quote"].some((k) => patch[k] !== undefined)) {
+ console.log(
+ ` header: ${res.entry.title ?? "(the record's own title)"} · ` +
+ `${res.entry.date ?? "(the record's upload date)"} @ ${hms(res.entry.cite ?? res.entry.start)}`,
+ );
+ if (res.entry.citeUrl) console.log(` QR: ${res.entry.citeUrl}`);
+ }
console.log(`\nRun resolve-windows to see whether the widener agrees:`);
console.log(` node umtool/report-to-video/resolve-windows.mjs ${p.dir}/video.manifest.json`);
} catch (e) {
diff --git a/umtool/lib/report/manifest.mjs b/umtool/lib/report/manifest.mjs
@@ -29,6 +29,7 @@ import {
VALUE_KINDS,
rolesGaps,
} from "umtool-report-to-video/ledger-totals";
+import { isCalendarDate } from "umtool-report-to-video/attribution";
// Its own write queue, not lib/state.ts's.
//
@@ -114,12 +115,23 @@ const WINDOW_FIELDS = ["start", "end"];
const FLAG_FIELDS = ["lock", "lockStart", "lockEnd"];
/**
- * Patch ONE clip. Windows and locks only.
+ * Patch ONE clip. The window, the locks, and what the header says.
*
- * 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
+ * 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
@@ -165,6 +177,58 @@ export async function updateClip(dir, clipId, patch, { token = null } = {}) {
else delete entry.note;
}
+ // ---- what the header will say -------------------------------------------
+ for (const k of ["title", "quote"]) {
+ 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;
+ }
+ }
+
const nextToken = await writeManifestAtomic(dir, manifest);
return { entry, before, token: nextToken };
});