// Curated per-video tags — an operator-owned vocabulary that cuts ACROSS // channels ("every stream where Eva is on mic", on any site, any channel). // // Three unrelated things in this repo are already called "tags": // 1. `TranscriptSummary.tags` — yt-dlp keywords from metadata.info.json // (the search scope "tags", relabelled "Keywords" in the UI); // 2. AI digest topic tags; // 3. THIS — curated tags, carried on a record as `curatedTags`. // The record field is therefore NEVER `tags`. See plans/FACTS.md. // // This module is PURE (no I/O) so it can be unit-tested and shared by the // server-side store (common/lib/curatedTagsStore.ts), the index builder // (common/controller/curatedTagsIndex.ts), the editor and the export viewer. // Disk/merge/defaulting policy lives in the store; this file only knows how to // coerce a config, layer a site over the corpus, evaluate rules and fold rule // hits together with the operator's pins and suppressions. // The file name, identical at every layer: `transcripts/tags.json`, // `transcripts/sites//tags.json` and the published `/tags.json`. export const TAGS_FILENAME = "tags.json"; // Tag ids are free-form but url/filename-safe and lowercase, so they can travel // in a query param, a CLI argument and a JSON key without quoting. 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[] = [ "metadata", "chat-author", "caption", ]; export type CuratedTagRule = { // Stable within its tag; used in provenance (`rule:`) and to let a // site layer append rules without colliding with the corpus ones. id: string; kind: CuratedTagRuleKind; // A JS regex source, matched case-insensitively. What it is matched against // depends on `kind` — see evaluateCompiledRules. pattern: string; // Channel slugs this rule may fire on. Absent or empty = every channel. channels?: string[]; // Channel slugs this rule may NEVER fire on, applied AFTER `channels`. A slug // in both lists is excluded: naming a channel to skip is the more specific // statement, and the shape this exists for — "every channel except that one" // — needs no allow-list at all. Absent or empty = exclude nothing. channelsExclude?: string[]; // Inclusive upload-date bounds, compared digit-wise so both "20260101" and // "2026-01-01" work. null/absent = unbounded. dateFrom?: string | null; dateTo?: string | null; // Defaults true. A rule whose pattern does not compile is KEPT with // `enabled: false` and a `disabledReason` — never dropped, never thrown, so // the operator can see and fix their typo instead of losing the rule. enabled: boolean; disabledReason?: string; }; export type CuratedTagDef = { id: 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; description?: string; color?: string; // Sort key for chips and for the order of a record's `curatedTags`. order?: number; // Presentation only: hidden tags are dropped from a published /tags.json but // still ride on records (a site hides a chip; it does not erase a fact). hidden?: boolean; // The sites this tag EXISTS on (site ids). Absent or empty = every site. // Unlike `hidden`, this is not presentation: a scoped tag is never derived for // a video whose channel is not a member of one of these sites (its rules do // not fire there and its pins do not apply), and a site outside the list // drops it from its summaries and its /tags.json — so it is on no record, // chip or count of another site (the Eva tags: Anilyzer's only). Corpus layer // only: a site's own tags.json cannot widen or narrow it. sites?: string[]; // Optional — a purely manual tag is legal. rules?: CuratedTagRule[]; }; // Where a pin or a suppression came from: `operator`, `agent: