commit bb8298e49e15d352436771e230c1ea54996a6b57
parent a17d0ef2127aabd94866aa60a4be102df3fef883
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 21 Sep 2026 13:20:25 -0400
Merge branch 'main' into tags/editor
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 {