Archilyzer · Source

archilyzer

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

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:
Acommon/lib/curatedTagsStore.test.ts | 399+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/lib/curatedTagsStore.ts | 275+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/paths.ts | 10++++++++++
Mcommon/lib/site.ts | 9+++++++++
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 {