Archilyzer · Source

archilyzer

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

commit 29bfb7c42d2c4b1f110c0848fb0fa73c61189a24
parent 314d48162e881e53d52deb7433006831d7292d3f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 21 Sep 2026 14:24:44 -0400

tags: a site overlay that only recolours must not rename the tag

The model fix the S3 review is owed. `coerceTagDef` filled a missing `label`
with the id, and because the store sanitizes on write AND on read, a site row
that set nothing but a colour arrived at mergeTagDefs carrying `label:
"eva-collab"` and renamed the tag on that site — where the corpus says
"Collab". S3 worked around it by mirroring the corpus label into the site row,
which bought correctness today at the price of a rename that never followed
until someone re-saved the tab.

So `sanitizeTagsConfig(raw, {layer})`: at "global" a definition still ends up
named after its id, at "site" a missing presentation field STAYS missing.
readSiteTags/writeSiteTags pass the site layer in both directions, because the
file goes through sanitize twice. `CuratedTagDef.label` is optional now and the
display fallback is `label ?? id` at every consumer; the one place it is baked
in is publishedTagsFrom, because /tags.json is read, not layered.

The editor's mirroring is gone, and so is the site tab's "keeps the label it
had when you saved" copy — a blank field now follows the corpus for ever, which
is what the tab says. SiteTagsClient loses the cast it needed when `label` was
required.

Tests: sanitize at the site layer keeps a label absent and round-trips;
a colour-only overlay keeps the corpus label through mergeTagDefs and follows a
later rename; the same through the store's two sanitize passes; and the
integration test composes a colour-only overlay and asserts /tags.json
publishes "Collab", then renames in the corpus and asserts the site follows
with no site edit. The editor spec asserted the workaround — it now asserts the
site file carries no label at all, and that the rename reaches the tab.

Co-Authored-By: Claude Opus <noreply@anthropic.com>

Diffstat:
Mcommon/controller/curatedTagsBuild.test.ts | 46+++++++++++++++++++++++++++++++++++++++++++++-
Mcommon/controller/curatedTagsIndex.ts | 5++++-
Mcommon/lib/curatedTags.test.ts | 47+++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/curatedTags.ts | 34+++++++++++++++++++++++++++++-----
Mcommon/lib/curatedTagsStore.test.ts | 25+++++++++++++++++++++++++
Mcommon/lib/curatedTagsStore.ts | 15++++++++++++---
Meditor/app/channels/[slug]/lib/videoTagRows.ts | 7++++++-
Meditor/app/channels/[slug]/videos/[id]/lib/videoTags.ts | 2+-
Meditor/app/sites/[siteId]/tags/page.tsx | 6+++---
Meditor/app/sites/components/SiteTagsClient.tsx | 9+++++----
Meditor/app/tags/actions.ts | 34+++++++---------------------------
Meditor/e2e/tags.spec.ts | 25++++++++++++++++++++++++-
Mplans/FACTS.md | 9+++++++++
13 files changed, 217 insertions(+), 47 deletions(-)

diff --git a/common/controller/curatedTagsBuild.test.ts b/common/controller/curatedTagsBuild.test.ts @@ -29,7 +29,7 @@ process.env.SETTINGS_FILE = path.join(ROOT, "settings.json"); const { getPaths } = await import("../lib/paths"); const { buildIndex } = await import("./buildIndex"); -const { writeGlobalTags } = await import("../lib/curatedTagsStore"); +const { writeGlobalTags, writeSiteTags } = await import("../lib/curatedTagsStore"); const { curatedTagsRuntime, reapplyCuratedTags, @@ -226,6 +226,50 @@ test("compose 1: the site publishes /tags.json and corpus.json points at it", as ); }); +test("compose 1b: a colour-only site overlay publishes the CORPUS label", async () => { + // The site file is written through the same store the editor uses, so this + // covers the whole path the overlay takes: sanitize-on-write, sanitize-on- + // read, mergeTagDefs, publishedTagsFrom. A row that only recolours must not + // rename the tag to its id on this site. + writeSiteTags(paths, SITE, { + version: 1, + tags: [{ id: "eva-collab", color: "#b48ead", order: 3 }], + assignments: {}, + }); + await composeSite(); + const published = JSON.parse( + readFileSync(path.join(paths.exportPublicDir, "tags.json"), "utf8"), + ); + assert.deepEqual(published.tags, [ + { + id: "eva-collab", + label: "Collab", + group: "eva", + groupLabel: "Eva", + color: "#b48ead", + order: 3, + count: 1, + channels: { [CHANNEL]: 1 }, + }, + ]); + + // …and a corpus rename follows onto the site with no site edit at all. + writeGlobalTags(paths, { + ...EVA, + tags: [{ ...EVA.tags[0], label: "On mic" }], + }); + await composeSite(); + assert.equal( + JSON.parse( + readFileSync(path.join(paths.exportPublicDir, "tags.json"), "utf8"), + ).tags[0].label, + "On mic", + ); + // Put the fixture back for the tests below. + writeGlobalTags(paths, EVA); + writeSiteTags(paths, SITE, { version: 1, tags: [], assignments: {} }); +}); + test("build 3 + compose 2: dropping the vocabulary removes the file and the pointer", async () => { writeGlobalTags(paths, { version: 1, tags: [], assignments: {} }); await build(); diff --git a/common/controller/curatedTagsIndex.ts b/common/controller/curatedTagsIndex.ts @@ -498,7 +498,10 @@ export function publishedTagsFrom( }) .map(({ def, entry }) => ({ id: def.id, - label: def.label, + // The published document always carries a label: the wire format is read, + // not layered, so an un-overridden tag falls back to its id exactly once — + // here — and never by a sanitize pass inventing one on the site file. + label: def.label ?? def.id, ...(def.group ? { group: def.group } : {}), ...(def.groupLabel ? { groupLabel: def.groupLabel } : {}), ...(def.color ? { color: def.color } : {}), diff --git a/common/lib/curatedTags.test.ts b/common/lib/curatedTags.test.ts @@ -108,6 +108,32 @@ test("sanitizeTagsConfig defaults label to the id and drops empty extras", () => assert.deepEqual(cfg.tags[0], { id: "solo", label: "solo" }); }); +test("the SITE layer leaves a missing label ABSENT", () => { + // An overlay that only recolours must carry no label. Defaulting it to the id + // here would make mergeTagDefs rename the tag on that site — the site would + // publish "eva-collab" where the corpus says "Collab" — and because the store + // sanitizes on read as well as on write, stripping it later cannot help. + const site = sanitizeTagsConfig( + { tags: [{ id: "eva-collab", color: "#b48ead" }] }, + { layer: "site" }, + ); + assert.deepEqual(site.tags[0], { id: "eva-collab", color: "#b48ead" }); + assert.equal("label" in site.tags[0], false); + // A site row that DOES set a label keeps it, and is still coerced. + const named = sanitizeTagsConfig( + { tags: [{ id: "eva-collab", label: " On mic " }] }, + { layer: "site" }, + ); + assert.equal(named.tags[0].label, "On mic"); + // The global layer is unchanged: a definition always ends up named. + assert.equal( + sanitizeTagsConfig({ tags: [{ id: "eva-collab" }] }).tags[0].label, + "eva-collab", + ); + // And it survives a round trip at its own layer (the store sanitizes twice). + assert.deepEqual(sanitizeTagsConfig(site, { layer: "site" }), site); +}); + test("sanitizeTagsConfig drops unusable rules but keeps a non-compiling one disabled", () => { const cfg = sanitizeTagsConfig({ tags: [ @@ -243,6 +269,27 @@ test("mergeTagDefs cannot delete a global rule or a global tag", () => { ); }); +test("a colour-only overlay keeps the corpus label", () => { + // The bug this optionality exists for, end to end through both functions. + const site = sanitizeTagsConfig( + { tags: [{ id: "eva-collab", color: "#b48ead", order: 9 }] }, + { layer: "site" }, + ); + const merged = mergeTagDefs(DEFS, site.tags); + const collab = merged.find((t) => t.id === "eva-collab")!; + assert.equal(collab.label, "Collab", "the corpus wording, not the id"); + assert.equal(collab.color, "#b48ead"); + assert.equal(collab.order, 9); + // A later corpus rename follows with no site edit at all. + const renamed = DEFS.map((d) => + d.id === "eva-collab" ? { ...d, label: "On mic" } : d, + ); + assert.equal( + mergeTagDefs(renamed, site.tags).find((t) => t.id === "eva-collab")!.label, + "On mic", + ); +}); + test("mergeTagDefs appends a site-only tag whole", () => { const siteOnly: CuratedTagDef = { id: "site-thing", diff --git a/common/lib/curatedTags.ts b/common/lib/curatedTags.ts @@ -25,6 +25,10 @@ export const TAG_ID_RE = /^[a-z0-9][a-z0-9._-]*$/; export const CURATED_TAGS_VERSION = 1; +// Which file a config came out of. The two layers differ in exactly one way — +// see coerceTagDef — and naming it beats a boolean at every call site. +export type TagLayer = "global" | "site"; + export type CuratedTagRuleKind = "metadata" | "chat-author" | "caption"; const RULE_KINDS: readonly CuratedTagRuleKind[] = [ @@ -56,7 +60,11 @@ export type CuratedTagRule = { export type CuratedTagDef = { id: string; - label: string; + // OPTIONAL, and the optionality is load-bearing at the SITE layer: an overlay + // that only recolours a tag must carry no label, or mergeTagDefs would apply + // one and rename the tag on that site. Absent means "whatever the layer below + // says", and the display fallback everywhere is `label ?? id`. + label?: string; // Optional grouping so a chip row can read "Eva: Collab · In chat · Discussed". group?: string; groupLabel?: string; @@ -190,11 +198,17 @@ function coerceRule(raw: unknown, index: number): CuratedTagRule | null { return rule; } -function coerceTagDef(raw: unknown): CuratedTagDef | null { +function coerceTagDef(raw: unknown, layer: TagLayer): CuratedTagDef | null { if (!raw || typeof raw !== "object") return null; const r = raw as Record<string, unknown>; const id = normalizeTagId(r.id); if (!id) return null; + // The one field the two layers treat differently. A corpus definition is the + // thing being named, so it always ends up with a label (the id, if nobody + // wrote one). A site row is an OVERLAY: a missing label means "do not + // override", and inventing one here would rename the tag on that site, since + // mergeTagDefs applies every field the overlay carries. + const label = trimmedString(r.label) ?? (layer === "site" ? undefined : id); const rules = Array.isArray(r.rules) ? r.rules .map((rule, i) => coerceRule(rule, i)) @@ -206,7 +220,7 @@ function coerceTagDef(raw: unknown): CuratedTagDef | null { : undefined; return { id, - label: trimmedString(r.label) ?? id, + ...(label !== undefined ? { label } : {}), ...(trimmedString(r.group) ? { group: trimmedString(r.group) } : {}), ...(trimmedString(r.groupLabel) ? { groupLabel: trimmedString(r.groupLabel) } @@ -265,7 +279,17 @@ function coerceAssignment(raw: unknown): CuratedTagAssignment | null { // NEVER throws, and missing/invalid input yields an empty config. The one // deliberate exception to "drop what is malformed" is a rule whose regex does // not compile: that rule is kept, disabled, with a reason. -export function sanitizeTagsConfig(raw: unknown): CuratedTagsConfig { +// +// `layer` decides how a missing presentation field is read: at "global" a +// definition without a label is named after its id, at "site" it stays unnamed +// so the merge below leaves the corpus wording alone. Callers that hold a +// site file — readSiteTags/writeSiteTags, the site overlay action — must pass +// it in BOTH directions, because the file is sanitized on write and on read. +export function sanitizeTagsConfig( + raw: unknown, + opts: { layer?: TagLayer } = {}, +): CuratedTagsConfig { + const layer: TagLayer = opts.layer ?? "global"; const empty: CuratedTagsConfig = { version: CURATED_TAGS_VERSION, tags: [], @@ -282,7 +306,7 @@ export function sanitizeTagsConfig(raw: unknown): CuratedTagsConfig { const seen = new Set<string>(); if (Array.isArray(r.tags)) { for (const entry of r.tags) { - const def = coerceTagDef(entry); + const def = coerceTagDef(entry, layer); if (!def) continue; if (seen.has(def.id)) continue; // first definition of an id wins seen.add(def.id); diff --git a/common/lib/curatedTagsStore.test.ts b/common/lib/curatedTagsStore.test.ts @@ -151,6 +151,31 @@ test("effectiveSiteTags layers the site overlay over the corpus vocabulary", () }); }); +test("a colour-only overlay survives the store's two sanitize passes", () => { + // writeSiteTags sanitizes, readSiteTags sanitizes again — both at the SITE + // layer, so the row that set only a colour still carries no label and the + // site keeps publishing the corpus wording. A later corpus rename follows + // with no site edit. + withPaths((paths) => { + writeGlobalTags(paths, COLLAB); + writeSiteTags(paths, "anilyzer", { + version: 1, + tags: [{ id: "eva-collab", color: "#b48ead" }], + assignments: {}, + }); + assert.equal("label" in readSiteTags(paths, "anilyzer").tags[0], false); + const defs = effectiveSiteTags(paths, "anilyzer"); + assert.equal(defs[0].label, "Collab"); + assert.equal(defs[0].color, "#b48ead"); + + writeGlobalTags(paths, { + ...COLLAB, + tags: [{ ...COLLAB.tags[0], label: "On mic" }], + }); + assert.equal(effectiveSiteTags(paths, "anilyzer")[0].label, "On mic"); + }); +}); + test("a site with no overlay ships the corpus vocabulary unchanged", () => { withPaths((paths) => { writeGlobalTags(paths, COLLAB); diff --git a/common/lib/curatedTagsStore.ts b/common/lib/curatedTagsStore.ts @@ -81,12 +81,18 @@ export function writeGlobalTags(paths: Paths, config: CuratedTagsConfig): void { writeJsonAtomic(paths.globalTagsFile, sanitizeTagsConfig(config)); } -// Per-site presentation overlay + site-only rules/tags. Absent/unreadable → -// empty (no overlay). +// Per-site presentation overlay + site-only tags. Absent/unreadable → empty +// (no overlay). +// +// Coerced as the SITE layer, on read AND on write: an overlay row that carries +// no label must keep carrying none, or mergeTagDefs would apply an invented one +// and rename the tag on this site. Both directions matter because the file goes +// through sanitize twice — the write, and every read after it. export function readSiteTags(paths: Paths, siteId: string): CuratedTagsConfig { try { return sanitizeTagsConfig( JSON.parse(readFileSync(siteTagsFile(paths, siteId), "utf8")), + { layer: "site" }, ); } catch { return emptyTagsConfig(); @@ -98,7 +104,10 @@ export function writeSiteTags( siteId: string, config: CuratedTagsConfig, ): void { - writeJsonAtomic(siteTagsFile(paths, siteId), sanitizeTagsConfig(config)); + writeJsonAtomic( + siteTagsFile(paths, siteId), + sanitizeTagsConfig(config, { layer: "site" }), + ); } // The tag definitions a site actually ships: the corpus vocabulary with the diff --git a/editor/app/channels/[slug]/lib/videoTagRows.ts b/editor/app/channels/[slug]/lib/videoTagRows.ts @@ -28,7 +28,12 @@ export function attachCuratedTags( rows: VideoRow[], ): { rows: VideoRow[]; defs: TagDef[] } { const config = readGlobalTags(paths); - const defs: TagDef[] = config.tags.map((t) => ({ id: t.id, label: t.label })); + // `label` is optional on a definition (a site overlay may carry none); a row + // in the UI always needs one, and the id is the fallback everywhere. + const defs: TagDef[] = config.tags.map((t) => ({ + id: t.id, + label: t.label ?? t.id, + })); const prefix = `${slug}/`; const mine = new Map<string, string[]>(); for (const [key, assignment] of Object.entries(config.assignments)) { diff --git a/editor/app/channels/[slug]/videos/[id]/lib/videoTags.ts b/editor/app/channels/[slug]/videos/[id]/lib/videoTags.ts @@ -146,7 +146,7 @@ async function readVideoTags( rows, defs: defs.map((d) => ({ id: d.id, - label: d.label, + label: d.label ?? d.id, ...(d.color ? { color: d.color } : {}), ...(d.group ? { group: d.group } : {}), ...(d.groupLabel ? { groupLabel: d.groupLabel } : {}), diff --git a/editor/app/sites/[siteId]/tags/page.tsx b/editor/app/sites/[siteId]/tags/page.tsx @@ -41,9 +41,9 @@ export default async function SiteTagsPage({ assignments stay in the corpus vocabulary: a record is shared by every site carrying its channel, so what a tag MEANS is decided once. Corpus rules are listed read-only for reference. Changes bake into the - site&apos;s next export build. A tag you overlay here keeps the label it - had when you saved: rename it in the corpus and save this tab again to - follow the new wording. + site&apos;s next export build. A field you leave blank keeps following + the corpus: rename a tag there and this site follows it, unless you have + set your own wording here. </p> <SiteTagsClient key={siteId} diff --git a/editor/app/sites/components/SiteTagsClient.tsx b/editor/app/sites/components/SiteTagsClient.tsx @@ -56,14 +56,15 @@ function overlayToDef(row: OverlayRow): CuratedTagDef | null { } // mergeTagDefs applies only the fields that are PRESENT, so an overlay with // no label must not carry `label: ""` — that would blank the corpus label. - // The type requires `label`, hence the cast on the way out. - const def: Record<string, unknown> = { id: row.id }; + // `label` is optional on a definition precisely so this row can omit it and + // keep following the corpus wording. + const def: CuratedTagDef = { id: row.id }; if (label) def.label = label; if (groupLabel) def.groupLabel = groupLabel; if (color) def.color = color; if (hasOrder) def.order = order; if (row.hidden !== "") def.hidden = row.hidden === "true"; - return def as unknown as CuratedTagDef; + return def; } export function SiteTagsClient({ @@ -160,7 +161,7 @@ export function SiteTagsClient({ <input value={row.label} onChange={(e) => patch(g.id, { label: e.target.value })} - placeholder={g.label} + placeholder={g.label ?? g.id} aria-label={`label for ${g.id}`} className="rounded border border-border bg-card px-2 py-1 text-sm text-foreground" /> diff --git a/editor/app/tags/actions.ts b/editor/app/tags/actions.ts @@ -79,36 +79,16 @@ export async function saveSiteTagDefsAction( } const paths = getPaths(); const current = readSiteTags(paths, siteId); - // AN OVERLAY MUST NOT RENAME THE TAG IT IS ONLY RECOLOURING. - // - // mergeTagDefs applies every field the overlay CARRIES, and - // sanitizeTagsConfig fills a missing `label` with the id — so a site that set - // nothing but a colour would publish "eva-collab" where the corpus says - // "Collab". Stripping the invented label on the way out does not help: the - // store sanitizes on write AND on read (curatedTagsStore.ts), so the default - // comes back before compose ever sees the file. - // - // So a row that does not override the label is written carrying the CORPUS - // label — the overlay then says exactly what this site publishes, and the - // merge is a no-op for that field. The cost is stated rather than hidden: a - // later change to the corpus label does not follow into a site that already - // has an overlay until this tab is saved again, and the tab says so. The - // clean fix belongs in the model (sanitize must be able to coerce a SITE - // layer without defaulting presentation fields); this is the editor half. - const globalById = new Map( - readGlobalTags(paths).tags.map((t) => [t.id, t]), - ); - const mirrored = tags.map((t) => { - const id = (t.id ?? "").trim().toLowerCase(); - if (Object.prototype.hasOwnProperty.call(t, "label") && t.label?.trim()) { - return t; - } - return { ...t, label: globalById.get(id)?.label ?? id }; - }); + // AN OVERLAY MUST NOT RENAME THE TAG IT IS ONLY RECOLOURING — and that is now + // the model's job, not this action's. `sanitizeTagsConfig(..., {layer: + // "site"})` leaves a missing label ABSENT instead of defaulting it to the id, + // and readSiteTags/writeSiteTags coerce at that layer in both directions, so + // a row saved with no label of its own keeps following the corpus label for + // ever — no mirroring, and nothing that goes stale until the tab is re-saved. writeSiteTags(paths, siteId, { ...current, assignments: {}, - tags: sanitizeTagsConfig({ tags: mirrored }).tags, + tags: sanitizeTagsConfig({ tags }, { layer: "site" }).tags, }); revalidatePath(`/sites/${siteId}/tags`); return { ok: true }; diff --git a/editor/e2e/tags.spec.ts b/editor/e2e/tags.spec.ts @@ -441,8 +441,12 @@ test("a site overlays presentation and cannot touch rules, assignments or a labe const site = await readJson<TagsFile>( "test-transcripts/sites/testsite/tags.json", ); + // The row holds ONLY what this site said. No label — not even a copy of the + // corpus one — because the site file is coerced at the site layer, where a + // missing presentation field stays missing. That is what lets a later corpus + // rename follow onto this site with no edit here. expect(site.tags).toEqual([ - { id: "eva-collab", label: "Collab", color: "#b48ead", hidden: true }, + { id: "eva-collab", color: "#b48ead", hidden: true }, ]); // The corpus rule is untouched, and the site file carries no rules and no // assignments at all — a record is shared by every site that carries its @@ -450,4 +454,23 @@ test("a site overlays presentation and cannot touch rules, assignments or a labe expect(site.assignments).toEqual({}); expect((await tagsFile()).tags[0].rules?.length).toBe(1); await expect(page.getByText("Corpus rules:")).toBeVisible(); + + // And the corpus wording still reaches this site: rename the tag on /tags and + // the overlay — which never claimed a label — follows it. + await page.goto("/tags"); + await page.getByTestId("tag-card").first().getByLabel("tag label").fill("On mic"); + await page.getByRole("button", { name: "save tags" }).click(); + await expect(page.getByTestId("tags-saved")).toBeVisible(); + await page.goto("/sites/testsite/tags"); + await expect( + page + .getByTestId("tag-overlay") + .first() + // exact, or this also matches "group label for eva-collab". + .getByLabel("label for eva-collab", { exact: true }), + ).toHaveAttribute("placeholder", "On mic"); + expect( + (await readJson<TagsFile>("test-transcripts/sites/testsite/tags.json")) + .tags[0].label, + ).toBeUndefined(); }); diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -265,6 +265,15 @@ every site carrying the channel, so there is no per-site record for a per-site h in. Rules bind only from `transcripts/tags.json` — promote one there to make it fire. The editor therefore offers no site-side rule editor. +**`CuratedTagDef.label` is OPTIONAL, and that is what makes the overlay work.** +`sanitizeTagsConfig(raw, {layer})` defaults a missing label to the id at the `global` layer +and leaves it ABSENT at the `site` layer (`readSiteTags`/`writeSiteTags` pass `site`, in both +directions, because the file is sanitized on write AND on every read). Without that, a site +row that set only a colour would arrive at `mergeTagDefs` carrying `label: <id>` and rename +the tag on that site — publishing "eva-collab" where the corpus says "Collab". Every reader +therefore renders `label ?? id`; the one place the fallback is baked in is +`publishedTagsFrom`, because `/tags.json` is read, not layered. + **A chat cue's author is a string prefix, not a field.** `common/lib/liveChat.ts:100-105` emits every live-chat cue as ``text: author ? `${author}: ${text}` : text`` (`:104`) — `Cue` is `{start, end, text}` (`vtt.ts:1`) and has no `author`. So a `chat-author` rule matches