commit a4f77c30bee93e9a25ede8b5082516408813b494
parent ac144c6c416b819802881d77fd3673d9a017728a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 21 Sep 2026 13:00:59 -0400
tags S1.2: the curated-tags store, and the four ops of an assignment
common/lib/curatedTagsStore.ts mirrors aliasesStore with one deliberate
difference: an absent global file reads EMPTY. Aliases seed defaults because a
fresh install still wants suggestions; a fresh install has no tags to seed.
The op set is the interesting part. A tag reaches a video two ways — a rule
matched, or the operator pinned it — and rule hits are never persisted, so
"take this tag off" has two meanings that have to be separate ops:
add pin, and clear any suppression of the same tag
remove unpin only; a rule that matches this video still tags it
suppress reject: unpin AND record a suppression, the only way a
rule-derived tag can be made to go away
unsuppress clear the rejection
The editor toggle uses add/suppress, the bulk list actions add/remove/suppress,
umtool add. Every pin and every suppression stamps provenance, re-stamped only
when the claim actually changes, and pruned when the video stops carrying the
tag. One call is ONE atomic write however many videos it touches, and it
returns the keys that changed so the caller can dirty exactly those.
paths.globalTagsFile is <transcriptsDir>/tags.json (CURATED_TAGS_FILE
overrides); siteTagsFile is sites/<id>/tags.json. The site layer carries
presentation and site-only rules — never assignments, which are global facts.
Co-Authored-By: Claude Opus <noreply@anthropic.com>
Diffstat:
4 files changed, 693 insertions(+), 0 deletions(-)
diff --git a/common/lib/curatedTagsStore.test.ts b/common/lib/curatedTagsStore.test.ts
@@ -0,0 +1,399 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { mkdtempSync, readFileSync, rmSync, writeFileSync, mkdirSync } from "node:fs";
+import { tmpdir } from "node:os";
+import path from "node:path";
+import type { Paths } from "./paths";
+import {
+ applyTagAssignments,
+ assignmentFor,
+ effectiveSiteTags,
+ emptyTagsConfig,
+ readGlobalTags,
+ readSiteTags,
+ writeGlobalTags,
+ writeSiteTags,
+} from "./curatedTagsStore";
+import { siteTagsFile } from "./site";
+import type { CuratedTagsConfig } from "./curatedTags";
+
+function tempPaths(): { paths: Paths; dir: string; cleanup: () => void } {
+ const dir = mkdtempSync(path.join(tmpdir(), "curated-tags-store-"));
+ // Only the fields curatedTagsStore touches need to be real.
+ const paths = {
+ sitesDir: path.join(dir, "sites"),
+ globalTagsFile: path.join(dir, "tags.json"),
+ } as Paths;
+ return {
+ paths,
+ dir,
+ cleanup: () => rmSync(dir, { recursive: true, force: true }),
+ };
+}
+
+function withPaths(fn: (paths: Paths, dir: string) => void): void {
+ const { paths, dir, cleanup } = tempPaths();
+ try {
+ fn(paths, dir);
+ } finally {
+ cleanup();
+ }
+}
+
+const COLLAB: CuratedTagsConfig = {
+ version: 1,
+ tags: [
+ {
+ id: "eva-collab",
+ label: "Collab",
+ group: "eva",
+ groupLabel: "Eva",
+ order: 1,
+ rules: [
+ { id: "meta", kind: "metadata", pattern: "elfpire", enabled: true },
+ ],
+ },
+ ],
+ assignments: {},
+};
+
+const V1 = { channelSlug: "legal-mindset", id: "XZqL6k9IHGA" };
+const V2 = { channelSlug: "legal-mindset", id: "AAAAAAAAAAA" };
+
+// ─── read / write ───
+
+test("readGlobalTags of an absent file is EMPTY — no seeded defaults", () => {
+ withPaths((paths) => {
+ assert.deepEqual(readGlobalTags(paths), emptyTagsConfig());
+ assert.deepEqual(readGlobalTags(paths).tags, []);
+ });
+});
+
+test("readGlobalTags of unreadable junk is empty, not a throw", () => {
+ withPaths((paths) => {
+ writeFileSync(paths.globalTagsFile, "{not json");
+ assert.deepEqual(readGlobalTags(paths).tags, []);
+ });
+});
+
+test("global tags round-trip and are sanitized on the way out", () => {
+ withPaths((paths) => {
+ writeGlobalTags(paths, {
+ ...COLLAB,
+ tags: [
+ ...COLLAB.tags,
+ { id: "BAD ID", label: "dropped" } as never,
+ ],
+ });
+ const back = readGlobalTags(paths);
+ assert.deepEqual(
+ back.tags.map((t) => t.id),
+ ["eva-collab"],
+ );
+ // Written pretty so the file stays hand-readable (never hand-EDITED).
+ assert.match(readFileSync(paths.globalTagsFile, "utf8"), /\n {2}"tags"/);
+ });
+});
+
+test("site tags land beside site.json and default to empty", () => {
+ withPaths((paths) => {
+ assert.deepEqual(readSiteTags(paths, "anilyzer").tags, []);
+ writeSiteTags(paths, "anilyzer", {
+ version: 1,
+ tags: [{ id: "eva-collab", label: "On mic" }],
+ assignments: {},
+ });
+ assert.equal(
+ siteTagsFile(paths, "anilyzer"),
+ path.join(paths.sitesDir, "anilyzer", "tags.json"),
+ );
+ assert.equal(readSiteTags(paths, "anilyzer").tags[0].label, "On mic");
+ });
+});
+
+// ─── layering ───
+
+test("effectiveSiteTags layers the site overlay over the corpus vocabulary", () => {
+ withPaths((paths) => {
+ writeGlobalTags(paths, COLLAB);
+ writeSiteTags(paths, "anilyzer", {
+ version: 1,
+ tags: [
+ { id: "eva-collab", label: "On mic", color: "#b48ead" },
+ { id: "site-only", label: "Site only" },
+ ],
+ assignments: {},
+ });
+ const defs = effectiveSiteTags(paths, "anilyzer");
+ assert.deepEqual(
+ defs.map((d) => d.id),
+ ["eva-collab", "site-only"],
+ );
+ assert.equal(defs[0].label, "On mic");
+ assert.equal(defs[0].color, "#b48ead");
+ // Untouched global fields survive the overlay.
+ assert.equal(defs[0].group, "eva");
+ assert.deepEqual(
+ (defs[0].rules ?? []).map((r) => r.id),
+ ["meta"],
+ );
+ });
+});
+
+test("a site with no overlay ships the corpus vocabulary unchanged", () => {
+ withPaths((paths) => {
+ writeGlobalTags(paths, COLLAB);
+ assert.deepEqual(effectiveSiteTags(paths, "anilyzer"), COLLAB.tags);
+ });
+});
+
+test("the site layer cannot delete a global rule, tag or assignment", () => {
+ withPaths((paths) => {
+ writeGlobalTags(paths, COLLAB);
+ applyTagAssignments(paths, {
+ op: "add",
+ tag: "eva-collab",
+ videos: [V1],
+ source: "operator",
+ at: "2026-09-21T18:00:00Z",
+ });
+ // A site file that tries to empty the rules, and to carry an assignment.
+ mkdirSync(path.join(paths.sitesDir, "anilyzer"), { recursive: true });
+ writeFileSync(
+ siteTagsFile(paths, "anilyzer"),
+ JSON.stringify({
+ version: 1,
+ tags: [{ id: "eva-collab", label: "x", rules: [] }],
+ assignments: { "legal-mindset/XZqL6k9IHGA": { suppressed: ["eva-collab"] } },
+ }),
+ );
+ const defs = effectiveSiteTags(paths, "anilyzer");
+ assert.deepEqual(
+ (defs[0].rules ?? []).map((r) => r.id),
+ ["meta"],
+ );
+ // Assignments are global facts: the site file's copy is simply not consulted.
+ const global = readGlobalTags(paths);
+ assert.deepEqual(assignmentFor(global, V1.channelSlug, V1.id)?.manual, [
+ "eva-collab",
+ ]);
+ assert.equal(
+ assignmentFor(global, V1.channelSlug, V1.id)?.suppressed,
+ undefined,
+ );
+ });
+});
+
+// ─── applyTagAssignments: validation ───
+
+test("applyTagAssignments rejects a bad op, tag id or video ref", () => {
+ withPaths((paths) => {
+ assert.throws(
+ () => applyTagAssignments(paths, { op: "nope" as never, tag: "t", videos: [V1] }),
+ /unknown tag op/,
+ );
+ assert.throws(
+ () => applyTagAssignments(paths, { op: "add", tag: "Bad Id", videos: [V1] }),
+ /invalid tag id/,
+ );
+ assert.throws(
+ () =>
+ applyTagAssignments(paths, {
+ op: "add",
+ tag: "t",
+ videos: [{ channelSlug: "a/b", id: "v" }],
+ }),
+ /invalid video ref/,
+ );
+ assert.throws(
+ () => applyTagAssignments(paths, { op: "add", tag: "t", videos: "x" as never }),
+ /videos must be an array/,
+ );
+ // Nothing was written by any of those.
+ assert.deepEqual(readGlobalTags(paths).assignments, {});
+ });
+});
+
+test("applyTagAssignments lowercases the tag id it stores", () => {
+ withPaths((paths) => {
+ applyTagAssignments(paths, { op: "add", tag: " EVA-Collab ", videos: [V1] });
+ assert.deepEqual(readGlobalTags(paths).assignments["legal-mindset/XZqL6k9IHGA"].manual, [
+ "eva-collab",
+ ]);
+ });
+});
+
+// ─── applyTagAssignments: the four ops ───
+
+test("add pins with provenance and is idempotent", () => {
+ withPaths((paths) => {
+ const first = applyTagAssignments(paths, {
+ op: "add",
+ tag: "eva-collab",
+ videos: [V1, V2, V1], // duplicate ref collapses
+ source: "umtool:elfpire-eva",
+ at: "2026-09-21T18:04:11Z",
+ });
+ assert.deepEqual(first.changed, [
+ "legal-mindset/XZqL6k9IHGA",
+ "legal-mindset/AAAAAAAAAAA",
+ ]);
+ assert.equal(first.touched.length, 2);
+ const cfg = readGlobalTags(paths);
+ const a = assignmentFor(cfg, V1.channelSlug, V1.id)!;
+ assert.deepEqual(a.manual, ["eva-collab"]);
+ assert.deepEqual(a.sources?.["eva-collab"], {
+ source: "umtool:elfpire-eva",
+ setAt: "2026-09-21T18:04:11Z",
+ });
+ // Re-adding changes nothing and does not re-stamp provenance.
+ const again = applyTagAssignments(paths, {
+ op: "add",
+ tag: "eva-collab",
+ videos: [V1],
+ source: "operator",
+ at: "2026-09-22T00:00:00Z",
+ });
+ assert.deepEqual(again.changed, []);
+ assert.equal(
+ readGlobalTags(paths).assignments["legal-mindset/XZqL6k9IHGA"].sources?.[
+ "eva-collab"
+ ].source,
+ "umtool:elfpire-eva",
+ );
+ });
+});
+
+test("add clears a suppression of the same tag", () => {
+ withPaths((paths) => {
+ applyTagAssignments(paths, { op: "suppress", tag: "eva-collab", videos: [V1] });
+ applyTagAssignments(paths, { op: "add", tag: "eva-collab", videos: [V1] });
+ const a = assignmentFor(readGlobalTags(paths), V1.channelSlug, V1.id)!;
+ assert.deepEqual(a.manual, ["eva-collab"]);
+ assert.equal(a.suppressed, undefined);
+ });
+});
+
+test("remove unpins only — a rule hit survives it, which is why suppress exists", () => {
+ withPaths((paths) => {
+ applyTagAssignments(paths, { op: "add", tag: "eva-collab", videos: [V1] });
+ applyTagAssignments(paths, { op: "remove", tag: "eva-collab", videos: [V1] });
+ const cfg = readGlobalTags(paths);
+ // No pin, and crucially NO suppression: the rule may still tag this video.
+ assert.equal(assignmentFor(cfg, V1.channelSlug, V1.id), undefined);
+ assert.deepEqual(cfg.assignments, {});
+ });
+});
+
+test("suppress rejects a rule-derived tag and records provenance", () => {
+ withPaths((paths) => {
+ const res = applyTagAssignments(paths, {
+ op: "suppress",
+ tag: "eva-topic",
+ videos: [V1],
+ source: "operator",
+ at: "2026-09-21T18:05:02Z",
+ });
+ assert.deepEqual(res.changed, ["legal-mindset/XZqL6k9IHGA"]);
+ const a = assignmentFor(readGlobalTags(paths), V1.channelSlug, V1.id)!;
+ assert.deepEqual(a.suppressed, ["eva-topic"]);
+ assert.equal(a.manual, undefined);
+ assert.deepEqual(a.sources?.["eva-topic"], {
+ source: "operator",
+ setAt: "2026-09-21T18:05:02Z",
+ });
+ });
+});
+
+test("suppress also unpins, and unsuppress clears the rejection", () => {
+ withPaths((paths) => {
+ applyTagAssignments(paths, { op: "add", tag: "eva-collab", videos: [V1] });
+ applyTagAssignments(paths, { op: "suppress", tag: "eva-collab", videos: [V1] });
+ let a = assignmentFor(readGlobalTags(paths), V1.channelSlug, V1.id)!;
+ assert.equal(a.manual, undefined);
+ assert.deepEqual(a.suppressed, ["eva-collab"]);
+ applyTagAssignments(paths, { op: "unsuppress", tag: "eva-collab", videos: [V1] });
+ // Both claims gone: the key itself is dropped rather than left as a husk.
+ assert.deepEqual(readGlobalTags(paths).assignments, {});
+ });
+});
+
+test("an op that changes nothing writes no file at all", () => {
+ withPaths((paths) => {
+ const res = applyTagAssignments(paths, {
+ op: "remove",
+ tag: "eva-collab",
+ videos: [V1],
+ });
+ assert.deepEqual(res.changed, []);
+ assert.deepEqual(res.touched, ["legal-mindset/XZqL6k9IHGA"]);
+ assert.throws(() => readFileSync(paths.globalTagsFile, "utf8"));
+ });
+});
+
+test("other tags on the same video, and other videos, are untouched", () => {
+ withPaths((paths) => {
+ applyTagAssignments(paths, { op: "add", tag: "eva-collab", videos: [V1, V2] });
+ applyTagAssignments(paths, { op: "add", tag: "eva-topic", videos: [V1] });
+ applyTagAssignments(paths, { op: "suppress", tag: "eva-collab", videos: [V1] });
+ const cfg = readGlobalTags(paths);
+ const a = assignmentFor(cfg, V1.channelSlug, V1.id)!;
+ assert.deepEqual(a.manual, ["eva-topic"]);
+ assert.deepEqual(a.suppressed, ["eva-collab"]);
+ assert.deepEqual(Object.keys(a.sources ?? {}).sort(), ["eva-collab", "eva-topic"]);
+ assert.deepEqual(assignmentFor(cfg, V2.channelSlug, V2.id)?.manual, ["eva-collab"]);
+ });
+});
+
+test("the tag vocabulary survives an assignment write", () => {
+ withPaths((paths) => {
+ writeGlobalTags(paths, COLLAB);
+ applyTagAssignments(paths, { op: "add", tag: "eva-collab", videos: [V1] });
+ const cfg = readGlobalTags(paths);
+ assert.deepEqual(cfg.tags, COLLAB.tags);
+ });
+});
+
+test("a bulk call is ONE write, and reports exactly the keys it changed", () => {
+ withPaths((paths) => {
+ const many = Array.from({ length: 50 }, (_, i) => ({
+ channelSlug: "legal-mindset",
+ id: `v${i}`,
+ }));
+ applyTagAssignments(paths, { op: "add", tag: "eva-collab", videos: many });
+ assert.equal(Object.keys(readGlobalTags(paths).assignments).length, 50);
+ // Half already pinned: only the new half is reported as changed.
+ const mixed = applyTagAssignments(paths, {
+ op: "add",
+ tag: "eva-collab",
+ videos: [...many.slice(0, 25), { channelSlug: "other", id: "z1" }],
+ });
+ assert.deepEqual(mixed.changed, ["other/z1"]);
+ assert.equal(mixed.touched.length, 26);
+ });
+});
+
+test("provenance for a tag that is neither pinned nor suppressed is pruned", () => {
+ withPaths((paths) => {
+ // A hand-authored file with a stale source entry for an unassigned tag.
+ writeFileSync(
+ paths.globalTagsFile,
+ JSON.stringify({
+ version: 1,
+ tags: [],
+ assignments: {
+ "legal-mindset/XZqL6k9IHGA": {
+ manual: ["eva-collab"],
+ sources: {
+ "eva-collab": { source: "operator", setAt: "2026-01-01T00:00:00Z" },
+ },
+ },
+ },
+ }),
+ );
+ applyTagAssignments(paths, { op: "add", tag: "eva-topic", videos: [V1] });
+ const a = assignmentFor(readGlobalTags(paths), V1.channelSlug, V1.id)!;
+ assert.deepEqual(a.manual, ["eva-collab", "eva-topic"]);
+ assert.deepEqual(Object.keys(a.sources ?? {}).sort(), ["eva-collab", "eva-topic"]);
+ });
+});
diff --git a/common/lib/curatedTagsStore.ts b/common/lib/curatedTagsStore.ts
@@ -0,0 +1,275 @@
+// Server-side persistence for curated per-video tags. Two source files:
+// - global: paths.globalTagsFile (<transcriptsDir>/tags.json)
+// - per-site: sites/<siteId>/tags.json (siteTagsFile)
+// The global file is AUTHORITATIVE for both the vocabulary and the assignments
+// (an assignment is a fact about a video, not a presentation choice). The
+// per-site file is presentation — relabel/recolour/reorder/hide — plus
+// site-only rules and site-only tags. See common/lib/curatedTags.ts for the
+// pure model, the coercion and mergeTagDefs.
+//
+// Unlike aliasesStore there are NO seeded defaults: a fresh install has no
+// tags, and an absent global file reads as an empty config.
+//
+// ─── The four ops of applyTagAssignments ───
+//
+// A tag lands on a video two ways: a RULE matched (re-evaluated at every index
+// build, never persisted) or the operator PINNED it. So "take this tag off this
+// video" has two distinct meanings and they are different ops:
+//
+// add pin the tag, and clear any suppression of it
+// remove unpin only — a rule that matches this video still tags it
+// suppress reject the tag: unpin it AND record a suppression, which is the
+// ONLY way a rule-derived tag can be made to go away, because rule
+// hits are not stored anywhere to delete
+// unsuppress clear the rejection (the rule, if any, tags the video again)
+//
+// The editor's per-video toggle uses add/suppress; the bulk list actions use
+// add/remove/suppress; umtool uses add. Every pin AND every suppression records
+// provenance (`operator`, `agent:<label>`, `umtool:<project>`), so a later
+// reader can always say who claimed what and when.
+//
+// One call = ONE atomic write of the whole file, however many videos it
+// touches. A per-video loop would be N read-modify-writes and could interleave
+// with another writer.
+
+import path from "node:path";
+import { readFileSync, writeFileSync, renameSync, mkdirSync } from "node:fs";
+import type { Paths } from "./paths";
+import { siteTagsFile } from "./site";
+import {
+ assignmentKey,
+ mergeTagDefs,
+ sanitizeTagsConfig,
+ CURATED_TAGS_VERSION,
+ TAG_ID_RE,
+ type CuratedTagAssignment,
+ type CuratedTagDef,
+ type CuratedTagsConfig,
+} from "./curatedTags";
+
+function writeJsonAtomic(filePath: string, value: unknown): void {
+ mkdirSync(path.dirname(filePath), { recursive: true });
+ const tmp = `${filePath}.tmp-${process.pid}`;
+ writeFileSync(tmp, JSON.stringify(value, null, 2));
+ renameSync(tmp, filePath);
+}
+
+export function emptyTagsConfig(): CuratedTagsConfig {
+ return { version: CURATED_TAGS_VERSION, tags: [], assignments: {} };
+}
+
+// The corpus-wide vocabulary + every assignment. Absent/unreadable → empty (a
+// fresh install has no tags; there is nothing sensible to seed).
+export function readGlobalTags(paths: Paths): CuratedTagsConfig {
+ try {
+ return sanitizeTagsConfig(
+ JSON.parse(readFileSync(paths.globalTagsFile, "utf8")),
+ );
+ } catch {
+ return emptyTagsConfig();
+ }
+}
+
+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).
+export function readSiteTags(paths: Paths, siteId: string): CuratedTagsConfig {
+ try {
+ return sanitizeTagsConfig(
+ JSON.parse(readFileSync(siteTagsFile(paths, siteId), "utf8")),
+ );
+ } catch {
+ return emptyTagsConfig();
+ }
+}
+
+export function writeSiteTags(
+ paths: Paths,
+ siteId: string,
+ config: CuratedTagsConfig,
+): void {
+ writeJsonAtomic(siteTagsFile(paths, siteId), sanitizeTagsConfig(config));
+}
+
+// The tag definitions a site actually ships: the corpus vocabulary with the
+// site's overlay applied. Read directly by compose-site.ts at build time (the
+// source files are available there, like duplicates.json), so there is no
+// separate staging step.
+//
+// Assignments are deliberately NOT part of this: they are global facts and are
+// consumed by the index build, not by compose.
+export function effectiveSiteTags(
+ paths: Paths,
+ siteId: string,
+): CuratedTagDef[] {
+ return mergeTagDefs(readGlobalTags(paths).tags, readSiteTags(paths, siteId).tags);
+}
+
+// ─── Assignments ───
+
+export type TagAssignmentOp = "add" | "remove" | "suppress" | "unsuppress";
+
+const OPS: readonly TagAssignmentOp[] = [
+ "add",
+ "remove",
+ "suppress",
+ "unsuppress",
+];
+
+export type TagVideoRef = { channelSlug: string; id: string };
+
+export type ApplyTagAssignmentsInput = {
+ op: TagAssignmentOp;
+ tag: string;
+ videos: TagVideoRef[];
+ // Provenance, recorded on every pin and every suppression this call makes.
+ // `operator` | `agent:<label>` | `umtool:<project>` | `rule:<ruleId>`.
+ source?: string;
+ // ISO-8601 instant; defaults to now. Injectable so tests are deterministic.
+ at?: string;
+};
+
+export type ApplyTagAssignmentsResult = {
+ op: TagAssignmentOp;
+ tag: string;
+ // Assignment keys whose stored state actually changed — the caller's dirty
+ // set. A no-op (pinning what is already pinned) is not reported here.
+ changed: string[];
+ // Every key the call addressed, changed or not.
+ touched: string[];
+};
+
+function dedupeRefs(videos: TagVideoRef[]): string[] {
+ const keys: string[] = [];
+ for (const v of videos) {
+ if (!v || typeof v.channelSlug !== "string" || typeof v.id !== "string") {
+ throw new Error("each video needs a channelSlug and an id");
+ }
+ const slug = v.channelSlug.trim();
+ const id = v.id.trim();
+ if (!slug || !id || slug.includes("/") || id.includes("/")) {
+ throw new Error(
+ `invalid video ref ${JSON.stringify(v)}: channelSlug and id must be non-empty and contain no "/"`,
+ );
+ }
+ const key = assignmentKey(slug, id);
+ if (!keys.includes(key)) keys.push(key);
+ }
+ return keys;
+}
+
+function withoutTag(list: string[] | undefined, tag: string): string[] {
+ return (list ?? []).filter((t) => t !== tag);
+}
+
+// Apply one op for one tag to any number of videos, in a SINGLE atomic write of
+// the global file. Validates the tag id and the op, and records provenance for
+// every pin and suppression it writes. Throws on invalid input (an unknown op,
+// a malformed tag id or video ref) — this is the one write path all three
+// writers (editor UI, ops route, umtool) funnel through, so it refuses rather
+// than silently storing junk a sanitize pass would drop later.
+//
+// Returns which keys changed, so the caller can dirty exactly those videos.
+export function applyTagAssignments(
+ paths: Paths,
+ input: ApplyTagAssignmentsInput,
+): ApplyTagAssignmentsResult {
+ const op = input.op;
+ if (!OPS.includes(op)) {
+ throw new Error(`unknown tag op ${JSON.stringify(op)}; expected one of ${OPS.join(", ")}`);
+ }
+ const tag = typeof input.tag === "string" ? input.tag.trim().toLowerCase() : "";
+ if (!TAG_ID_RE.test(tag)) {
+ throw new Error(
+ `invalid tag id ${JSON.stringify(input.tag)}; must match ${TAG_ID_RE}`,
+ );
+ }
+ if (!Array.isArray(input.videos)) {
+ throw new Error("videos must be an array");
+ }
+ const keys = dedupeRefs(input.videos);
+ const source = (input.source ?? "operator").trim() || "operator";
+ const at = input.at ?? new Date().toISOString();
+
+ const config = readGlobalTags(paths);
+ const changed: string[] = [];
+
+ for (const key of keys) {
+ const before = config.assignments[key];
+ const manual = withoutTag(before?.manual, tag);
+ const suppressed = withoutTag(before?.suppressed, tag);
+ const sources: Record<string, { source: string; setAt: string }> = {
+ ...(before?.sources ?? {}),
+ };
+ const wasManual = (before?.manual ?? []).includes(tag);
+ const wasSuppressed = (before?.suppressed ?? []).includes(tag);
+
+ let nowManual = wasManual;
+ let nowSuppressed = wasSuppressed;
+ switch (op) {
+ case "add":
+ nowManual = true;
+ nowSuppressed = false; // a pin clears a rejection of the same tag
+ break;
+ case "remove":
+ // Unpin only. A rule that matches this video still tags it — use
+ // `suppress` to reject a rule-derived tag.
+ nowManual = false;
+ break;
+ case "suppress":
+ nowManual = false;
+ nowSuppressed = true;
+ break;
+ case "unsuppress":
+ nowSuppressed = false;
+ break;
+ }
+
+ if (nowManual === wasManual && nowSuppressed === wasSuppressed) {
+ continue; // nothing to write for this video
+ }
+
+ if (nowManual) manual.push(tag);
+ if (nowSuppressed) suppressed.push(tag);
+ if (nowManual || nowSuppressed) {
+ // Provenance is re-stamped whenever the claim changes: the recorded
+ // source is the one that put the tag in its CURRENT state.
+ sources[tag] = { source, setAt: at };
+ } else {
+ delete sources[tag];
+ }
+ // Provenance for tags this video no longer carries is dead weight.
+ for (const id of Object.keys(sources)) {
+ if (!manual.includes(id) && !suppressed.includes(id)) delete sources[id];
+ }
+
+ const next: CuratedTagAssignment = {
+ ...(manual.length > 0 ? { manual } : {}),
+ ...(suppressed.length > 0 ? { suppressed } : {}),
+ ...(Object.keys(sources).length > 0 ? { sources } : {}),
+ };
+ if (manual.length === 0 && suppressed.length === 0) {
+ // The last claim about this video is gone; drop the key entirely rather
+ // than leaving an empty husk in the file.
+ delete config.assignments[key];
+ } else {
+ config.assignments[key] = next;
+ }
+ changed.push(key);
+ }
+
+ if (changed.length > 0) writeGlobalTags(paths, config);
+ return { op, tag, changed, touched: keys };
+}
+
+// Every assignment for one video, or undefined when it carries none.
+export function assignmentFor(
+ config: CuratedTagsConfig,
+ channelSlug: string,
+ id: string,
+): CuratedTagAssignment | undefined {
+ return config.assignments[assignmentKey(channelSlug, id)];
+}
diff --git a/common/lib/paths.ts b/common/lib/paths.ts
@@ -1,6 +1,8 @@
import fs from "node:fs";
import path from "node:path";
import os from "node:os";
+// curatedTags.ts is pure (no imports of its own), so this cannot cycle.
+import { TAGS_FILENAME } from "./curatedTags";
export type Paths = {
monorepoRoot: string;
@@ -103,6 +105,11 @@ export type Paths = {
// data dir (not monorepoRoot — the legacy chartsConfigFile location there is
// migration-only). See common/lib/aliasesStore.ts.
globalAliasesFile: string;
+ // Curated per-video tags: the corpus-wide vocabulary AND every assignment
+ // (<transcriptsDir>/tags.json). Per-site presentation overlays live at
+ // sitesDir/<siteId>/tags.json (see common/lib/site.ts). Authoritative, and
+ // real curated data — never hand-edited. See common/lib/curatedTagsStore.ts.
+ globalTagsFile: string;
ytdlpBin: string;
whisperBin: string;
whisperModel: string;
@@ -219,6 +226,9 @@ export function getPaths(): Paths {
globalAliasesFile:
process.env.SEARCH_ALIASES_FILE ??
path.join(transcriptsDir, "search-aliases.json"),
+ globalTagsFile:
+ process.env.CURATED_TAGS_FILE ??
+ path.join(transcriptsDir, TAGS_FILENAME),
ytdlpBin: process.env.YTDLP_BIN ?? "yt-dlp",
whisperBin: process.env.WHISPER_BIN ?? "whisper-cli",
whisperModel:
diff --git a/common/lib/site.ts b/common/lib/site.ts
@@ -8,6 +8,7 @@ import {
type ChannelGroup,
} from "./channelGroups";
import { getPaths, type Paths } from "./paths";
+import { TAGS_FILENAME } from "./curatedTags";
import type { SiteChannelIndex } from "./channelPriority";
import { parseAccent } from "./accent";
import {
@@ -133,6 +134,14 @@ export function siteAliasesFile(paths: Paths, siteId: string): string {
return path.join(siteDir(paths, siteId), "search-aliases.json");
}
+// Per-site curated-tag overlay: presentation fields (label/groupLabel/colour/
+// order/hidden) and site-only rules or tags, layered over the corpus
+// vocabulary (paths.globalTagsFile) at compose time. It carries no assignments
+// — those are global facts. See common/lib/curatedTagsStore.ts.
+export function siteTagsFile(paths: Paths, siteId: string): string {
+ return path.join(siteDir(paths, siteId), TAGS_FILENAME);
+}
+
// Staging output dirs (NOT served) where per-site aggregates are built before
// composition into export/public. See common/lib/paths.ts.
export function siteIndexDir(paths: Paths, siteId: string): string {