commit 21f6ad831df8ca418ae0d2ef65bb995268b95999
parent ac144c6c416b819802881d77fd3673d9a017728a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 21 Sep 2026 13:10:33 -0400
tags S3.1: /tags — the curated vocabulary, its rules, and a bounded preview
The corpus-wide tag page: one card per tag (id, label, group, colour, order,
hidden, description) and its rules, plus a dry run of those rules over the
transcript index with Pin / Unpin / Suppress per row and Pin all for the page.
Three things worth naming:
* THE STORE IS ONE MODULE. editor/lib/tagsStore.ts is the only thing in this
package that opens tags.json, and it is TEMPORARY: S1.2's
common/lib/curatedTagsStore.ts is being written in parallel with exactly
these signatures, so the swap is a re-export in one file rather than twenty
imports to chase.
* THE PREVIEW IS READ-ONLY AND BOUNDED. common/controller/curatedTagsPreview.ts
opens index.mdb with readOnly:true (the recencyIndex.ts idiom: guard on
existsSync, degrade to empty, never throw), scopes to the rule's channels
first, caps the scan per call — 5,000 videos for a metadata rule, 250 for a
caption or chat one, because one decodes a summary and the other a whole
transcript — caps matches at 100, and hands back a cursor so "Scan more"
resumes rather than re-reading the head. It lives in common/ because that is
where `lmdb` is a dependency and where every other reader of index.mdb is.
* DEFS AND FACTS ARE TWO WRITES. Saving definitions carries the assignments
through from disk, so a def edit can never erase a pin; every pin and
suppression goes through applyTagAssignmentsAction — the one writer the
bulk bar, the ops route and umtool will also call — and one call is one
file write whatever the batch size.
The nav goes to thirteen, and nav.test.ts says why: a curated tag is a Corpus
noun, and folding it under /sites or /channels would make it the property of one
site or one channel, which is the one thing it must not be.
Co-Authored-By: Claude Opus <noreply@anthropic.com>
Diffstat:
8 files changed, 1209 insertions(+), 9 deletions(-)
diff --git a/common/controller/curatedTagsPreview.ts b/common/controller/curatedTagsPreview.ts
Binary files differ.
diff --git a/editor/app/lib/nav.test.ts b/editor/app/lib/nav.test.ts
@@ -16,15 +16,17 @@ test("four groups, one noun each", () => {
);
});
-// TWELVE, and the twelfth is a recorded exception rather than a slip. The IA
-// rule is "fold, do not add" and this test is where it is either true or not —
-// so the number moves only when a plan says why. /storage is a Machine NOUN (a
-// place the machine keeps bytes), not a fold of Cleanup (an operation over
-// bytes that already have a place); see nav.ts's header and
-// plans/editor-operations-ia.md.
-test("twelve entries, every href distinct", () => {
- assert.equal(NAV_LINKS.length, 12);
- assert.equal(new Set(NAV_LINKS.map((l) => l.href)).size, 12);
+// THIRTEEN, and the twelfth and thirteenth are recorded exceptions rather than
+// slips. The IA rule is "fold, do not add" and this test is where it is either
+// true or not — so the number moves only when a plan says why. /storage is a
+// Machine NOUN (a place the machine keeps bytes), not a fold of Cleanup (an
+// operation over bytes that already have a place); /tags is a Corpus NOUN (a
+// vocabulary over every channel and every site), which is precisely what it
+// stops being if it is folded under one site or one channel. See nav.ts's
+// header, plans/editor-operations-ia.md and plans/curated-tags.md.
+test("thirteen entries, every href distinct", () => {
+ assert.equal(NAV_LINKS.length, 13);
+ assert.equal(new Set(NAV_LINKS.map((l) => l.href)).size, 13);
});
test("the Sites group is one entry", () => {
diff --git a/editor/app/lib/nav.ts b/editor/app/lib/nav.ts
@@ -9,6 +9,7 @@ import {
ListPlus,
ScrollText,
Settings,
+ Tags,
Tv,
Bookmark,
Trash2,
@@ -37,6 +38,14 @@ import {
// (see editor/next.config.ts). The entries marked "interim" were folded by
// later slices; the end state is eleven, and slice 5 reached it (2026-08-30).
//
+// A SECOND RECORDED EXCEPTION, at thirteen: /tags. A curated tag is a
+// CORPUS noun — a vocabulary over videos, cutting across every channel and
+// every site — and there is no channel, operation or site it could fold under
+// without becoming that thing's property: under /sites it would be one site's
+// word list, under /channels it would be per channel, and both are exactly what
+// the feature is not. Agreed with the operator 2026-09-21; plans/curated-tags.md
+// carries the same sentence.
+//
// ONE RECORDED EXCEPTION, at twelve: /storage. A storage location is a place
// the machine keeps bytes — a Machine NOUN, like a worker or a job — and not a
// fold of Cleanup, which is an operation over bytes that already have a place.
@@ -66,6 +75,12 @@ export const NAV_GROUPS: NavGroup[] = [
{ href: "/", label: "Dashboard", icon: LayoutDashboard, keywords: "home overview monitor widget pipeline" },
{ href: "/channels", label: "Channels", icon: Tv },
{
+ href: "/tags",
+ label: "Tags",
+ icon: Tags,
+ keywords: "curated vocabulary pin suppress rule chip subject collab topic label",
+ },
+ {
href: "/review",
label: "Review",
icon: ClipboardCheck,
diff --git a/editor/app/tags/actions.ts b/editor/app/tags/actions.ts
@@ -0,0 +1,171 @@
+"use server";
+
+import { revalidatePath } from "next/cache";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { listChannelConfigs } from "yt-dlp-transcript-common/controller/channels";
+import {
+ TAG_ID_RE,
+ sanitizeTagsConfig,
+ type CuratedTagAssignment,
+ type CuratedTagDef,
+} from "yt-dlp-transcript-common/lib/curatedTags";
+import {
+ applyTagAssignments,
+ readGlobalTags,
+ readSiteTags,
+ writeGlobalTags,
+ writeSiteTags,
+ type TagAssignmentOp,
+ type TagVideoRef,
+} from "../../lib/tagsStore";
+import {
+ previewTagRule,
+ type TagPreviewResult,
+} from "yt-dlp-transcript-common/controller/curatedTagsPreview";
+
+// THE VOCABULARY AND THE FACTS ARE TWO DIFFERENT WRITES.
+//
+// saveTagDefsAction writes definitions and rules; applyTagAssignmentsAction
+// writes pins and suppressions. They are separate because a def edit must never
+// be able to drop an assignment (an assignment is a fact about a video, a def
+// is a label for it), and because the second one is THE writer every other
+// surface in this app funnels into — the video panel, the bulk bar, the ops
+// route and umtool all call it rather than touching the store themselves.
+
+export type TagActionResult = { ok: true } | { ok: false; error: string };
+
+function invalidIdOf(tags: CuratedTagDef[]): string | null {
+ for (const t of tags) {
+ const id = (t.id ?? "").trim();
+ if (!TAG_ID_RE.test(id)) return id;
+ }
+ return null;
+}
+
+// Persist the corpus vocabulary. ASSIGNMENTS ARE CARRIED THROUGH from the file
+// on disk, never from the client: the editing UI only ever holds definitions,
+// so posting the config wholesale would silently erase every pin the operator
+// (or an agent, or umtool) had made.
+export async function saveTagDefsAction(
+ tags: CuratedTagDef[],
+): Promise<TagActionResult> {
+ const bad = invalidIdOf(tags);
+ if (bad !== null) {
+ return {
+ ok: false,
+ error: `"${bad}" is not a valid tag id — lowercase letters, digits, ".", "_" and "-", starting with a letter or digit`,
+ };
+ }
+ const paths = getPaths();
+ const current = readGlobalTags(paths);
+ writeGlobalTags(paths, { ...current, tags: sanitizeTagsConfig({ tags }).tags });
+ revalidatePath("/tags");
+ return { ok: true };
+}
+
+// One site's overlay. Same rule, and the site file holds NO assignments at all
+// — mergeTagDefs layers presentation and appends rules; it can never delete a
+// corpus rule or an assignment.
+export async function saveSiteTagDefsAction(
+ siteId: string,
+ tags: CuratedTagDef[],
+): Promise<TagActionResult> {
+ const bad = invalidIdOf(tags);
+ if (bad !== null) {
+ return {
+ ok: false,
+ error: `"${bad}" is not a valid tag id — lowercase letters, digits, ".", "_" and "-", starting with a letter or digit`,
+ };
+ }
+ const paths = getPaths();
+ const current = readSiteTags(paths, siteId);
+ writeSiteTags(paths, siteId, {
+ ...current,
+ assignments: {},
+ tags: sanitizeTagsConfig({ tags }).tags,
+ });
+ revalidatePath(`/sites/${siteId}/tags`);
+ return { ok: true };
+}
+
+export type TagPreviewRow = {
+ channelSlug: string;
+ id: string;
+ title: string;
+ uploadDate: string;
+ // What the operator has already said about THIS tag on THIS video, so the
+ // row's buttons describe a change rather than repeating a state.
+ pinned: boolean;
+ suppressed: boolean;
+};
+
+export type TagPreviewActionResult = Omit<TagPreviewResult, "matches"> & {
+ rows: TagPreviewRow[];
+};
+
+// Dry-run a tag's rules over the LMDB index. READ-ONLY and BOUNDED — see
+// common/controller/curatedTagsPreview.ts for the three caps. Nothing is written: a rule hit is derived
+// at build time and never persisted, so a preview is exactly a preview.
+export async function previewTagRuleAction(input: {
+ def: CuratedTagDef;
+ cursor?: string | null;
+}): Promise<TagPreviewActionResult> {
+ const paths = getPaths();
+ const allSlugs = (await listChannelConfigs(paths)).map((c) => c.slug);
+ // The union of every rule's channel scope. A rule with no channels means
+ // "every channel", and then so does the tag.
+ const rules = input.def.rules ?? [];
+ const unscoped = rules.some((r) => !r.channels || r.channels.length === 0);
+ const channels = unscoped
+ ? []
+ : Array.from(new Set(rules.flatMap((r) => r.channels ?? [])));
+ const result = previewTagRule(paths, {
+ def: input.def,
+ channels,
+ allSlugs,
+ cursor: input.cursor ?? null,
+ });
+ const assignments = readGlobalTags(paths).assignments;
+ const rows: TagPreviewRow[] = result.matches.map((m) => {
+ const a: CuratedTagAssignment | undefined =
+ assignments[`${m.channelSlug}/${m.id}`];
+ return {
+ ...m,
+ pinned: (a?.manual ?? []).includes(input.def.id),
+ suppressed: (a?.suppressed ?? []).includes(input.def.id),
+ };
+ });
+ const { matches: _matches, ...rest } = result;
+ void _matches;
+ return { ...rest, rows };
+}
+
+// THE ONE WRITER of pins and suppressions. Every surface reaches this: the
+// /tags preview rows, the video page's panel, the bulk bar, /api/ops/tag-videos
+// and umtool's "Tag cited videos as …". One call = one file write, whatever the
+// batch size.
+//
+// `source` defaults to `operator` because a call with no source can only have
+// come from somebody clicking in this editor; an agent or umtool says who it is.
+export async function applyTagAssignmentsAction(input: {
+ op: TagAssignmentOp;
+ tag: string;
+ videos: TagVideoRef[];
+ source?: string;
+}): Promise<{ ok: true; changed: number } | { ok: false; error: string }> {
+ const tag = input.tag.trim().toLowerCase();
+ if (!TAG_ID_RE.test(tag)) {
+ return { ok: false, error: `"${input.tag}" is not a valid tag id` };
+ }
+ if (input.videos.length === 0) {
+ return { ok: false, error: "no videos given" };
+ }
+ const { changed } = applyTagAssignments(getPaths(), {
+ op: input.op,
+ tag,
+ videos: input.videos,
+ source: input.source?.trim() || "operator",
+ });
+ revalidatePath("/tags");
+ return { ok: true, changed };
+}
diff --git a/editor/app/tags/components/EditorTagsClient.tsx b/editor/app/tags/components/EditorTagsClient.tsx
@@ -0,0 +1,766 @@
+"use client";
+
+// Authoring the corpus vocabulary, and judging a rule before it ships.
+//
+// Two halves that deliberately do NOT share a save button:
+//
+// the DEFINITIONS — one card per tag, its presentation fields and its rules —
+// which are saved as a list, the way the alias page saves aliases;
+//
+// the PREVIEW — a dry run of one tag's rules over the transcript index,
+// listing what it would catch, with Pin / Unpin / Suppress per row and Pin
+// all for the page. Every one of those buttons calls the same server action
+// the bulk bar and the ops route call, so an operator's pin and an agent's
+// pin are the same write with different provenance.
+//
+// A preview uses the rules AS EDITED IN THE FORM, saved or not: the point is to
+// find out whether a pattern is right before committing it.
+
+import { useState, useTransition } from "react";
+import type {
+ CuratedTagDef,
+ CuratedTagRule,
+ CuratedTagRuleKind,
+} from "yt-dlp-transcript-common/lib/curatedTags";
+import { TAG_ID_RE } from "yt-dlp-transcript-common/lib/curatedTags";
+import {
+ applyTagAssignmentsAction,
+ previewTagRuleAction,
+ saveTagDefsAction,
+ type TagPreviewRow,
+} from "../actions";
+
+export type TagCounts = Record<string, { pinned: number; suppressed: number }>;
+
+const KINDS: { value: CuratedTagRuleKind; label: string; hint: string }[] = [
+ { value: "metadata", label: "Metadata", hint: "title + description + keywords" },
+ { value: "chat-author", label: "Chat author", hint: "the author of a live-chat line" },
+ { value: "caption", label: "Caption", hint: "the transcript's cue text" },
+];
+
+type RuleRow = CuratedTagRule & { key: string; channelsText: string };
+
+type TagRow = {
+ key: string;
+ id: string;
+ label: string;
+ group: string;
+ groupLabel: string;
+ description: string;
+ color: string;
+ order: string;
+ hidden: boolean;
+ rules: RuleRow[];
+};
+
+let keySeq = 0;
+const mkKey = () => `k${keySeq++}`;
+
+function toRow(def: CuratedTagDef): TagRow {
+ return {
+ key: mkKey(),
+ id: def.id,
+ label: def.label ?? "",
+ group: def.group ?? "",
+ groupLabel: def.groupLabel ?? "",
+ description: def.description ?? "",
+ color: def.color ?? "",
+ order: def.order === undefined ? "" : String(def.order),
+ hidden: def.hidden === true,
+ rules: (def.rules ?? []).map((r) => ({
+ ...r,
+ key: mkKey(),
+ channelsText: (r.channels ?? []).join(", "),
+ })),
+ };
+}
+
+function toDef(row: TagRow): CuratedTagDef {
+ const order = Number(row.order);
+ return {
+ id: row.id.trim().toLowerCase(),
+ label: row.label.trim() || row.id.trim(),
+ ...(row.group.trim() ? { group: row.group.trim() } : {}),
+ ...(row.groupLabel.trim() ? { groupLabel: row.groupLabel.trim() } : {}),
+ ...(row.description.trim() ? { description: row.description.trim() } : {}),
+ ...(row.color.trim() ? { color: row.color.trim() } : {}),
+ ...(row.order.trim() !== "" && Number.isFinite(order) ? { order } : {}),
+ ...(row.hidden ? { hidden: true } : {}),
+ ...(row.rules.length > 0
+ ? {
+ rules: row.rules.map((r) => {
+ const channels = r.channelsText
+ .split(",")
+ .map((c) => c.trim())
+ .filter(Boolean);
+ return {
+ id: r.id.trim() || "rule",
+ kind: r.kind,
+ pattern: r.pattern,
+ enabled: r.enabled !== false,
+ ...(channels.length > 0 ? { channels } : {}),
+ ...(r.dateFrom ? { dateFrom: r.dateFrom } : {}),
+ ...(r.dateTo ? { dateTo: r.dateTo } : {}),
+ } satisfies CuratedTagRule;
+ }),
+ }
+ : {}),
+ };
+}
+
+function regexError(pattern: string): string | null {
+ if (pattern.trim() === "") return "a rule with no pattern is dropped";
+ try {
+ new RegExp(pattern, "i");
+ return null;
+ } catch (e) {
+ return (e as Error).message;
+ }
+}
+
+type PreviewState = {
+ rows: TagPreviewRow[];
+ scanned: number;
+ nextCursor: string | null;
+ stoppedBy: "end" | "scan-cap" | "match-cap";
+ indexAvailable: boolean;
+};
+
+export function EditorTagsClient({
+ initialTags,
+ counts,
+ channels,
+}: {
+ initialTags: CuratedTagDef[];
+ counts: TagCounts;
+ channels: string[];
+}) {
+ const [rows, setRows] = useState<TagRow[]>(() => initialTags.map(toRow));
+ const [dirty, setDirty] = useState(false);
+ const [saved, setSaved] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const [saving, startSave] = useTransition();
+ const [preview, setPreview] = useState<Record<string, PreviewState>>({});
+ const [previewBusy, setPreviewBusy] = useState<string | null>(null);
+ const [previewNote, setPreviewNote] = useState<Record<string, string>>({});
+
+ const touch = () => {
+ setDirty(true);
+ setSaved(false);
+ setError(null);
+ };
+
+ const patch = (key: string, next: Partial<TagRow>) => {
+ setRows((rs) => rs.map((r) => (r.key === key ? { ...r, ...next } : r)));
+ touch();
+ };
+
+ const addTag = () => {
+ setRows((rs) => [
+ ...rs,
+ {
+ key: mkKey(),
+ id: "",
+ label: "",
+ group: "",
+ groupLabel: "",
+ description: "",
+ color: "",
+ order: "",
+ hidden: false,
+ rules: [],
+ },
+ ]);
+ touch();
+ };
+
+ const removeTag = (key: string) => {
+ setRows((rs) => rs.filter((r) => r.key !== key));
+ touch();
+ };
+
+ const save = () => {
+ const bad = rows.find((r) => !TAG_ID_RE.test(r.id.trim().toLowerCase()));
+ if (bad) {
+ setError(
+ `"${bad.id}" is not a valid tag id — lowercase letters, digits, ".", "_" and "-", starting with a letter or digit`,
+ );
+ return;
+ }
+ startSave(async () => {
+ const result = await saveTagDefsAction(rows.map(toDef));
+ if (result.ok) {
+ setDirty(false);
+ setSaved(true);
+ setError(null);
+ } else {
+ setError(result.error);
+ }
+ });
+ };
+
+ const runPreview = async (row: TagRow, cursor: string | null) => {
+ setPreviewBusy(row.key);
+ setPreviewNote((n) => ({ ...n, [row.key]: "" }));
+ try {
+ const result = await previewTagRuleAction({
+ def: toDef(row),
+ cursor,
+ });
+ setPreview((p) => ({
+ ...p,
+ [row.key]: {
+ rows: cursor ? [...(p[row.key]?.rows ?? []), ...result.rows] : result.rows,
+ scanned: (cursor ? (p[row.key]?.scanned ?? 0) : 0) + result.scanned,
+ nextCursor: result.nextCursor,
+ stoppedBy: result.stoppedBy,
+ indexAvailable: result.indexAvailable,
+ },
+ }));
+ } finally {
+ setPreviewBusy(null);
+ }
+ };
+
+ // Every pin/unpin/suppress on this page — one row or a whole page of them —
+ // is ONE call to the one action, so a "Pin all" of eighty videos is one file
+ // write and not eighty.
+ const apply = async (
+ row: TagRow,
+ op: "add" | "remove" | "suppress",
+ videos: { channelSlug: string; id: string }[],
+ ) => {
+ const tag = row.id.trim().toLowerCase();
+ setPreviewBusy(row.key);
+ try {
+ const result = await applyTagAssignmentsAction({ op, tag, videos });
+ if (!result.ok) {
+ setPreviewNote((n) => ({ ...n, [row.key]: result.error }));
+ return;
+ }
+ const ids = new Set(videos.map((v) => `${v.channelSlug}/${v.id}`));
+ setPreview((p) => {
+ const state = p[row.key];
+ if (!state) return p;
+ return {
+ ...p,
+ [row.key]: {
+ ...state,
+ rows: state.rows.map((r) =>
+ ids.has(`${r.channelSlug}/${r.id}`)
+ ? {
+ ...r,
+ pinned: op === "add",
+ suppressed: op === "suppress",
+ }
+ : r,
+ ),
+ },
+ };
+ });
+ setPreviewNote((n) => ({
+ ...n,
+ [row.key]: `${result.changed} video${result.changed === 1 ? "" : "s"} changed`,
+ }));
+ } finally {
+ setPreviewBusy(null);
+ }
+ };
+
+ return (
+ <div className="flex flex-col gap-4" data-testid="tags-client">
+ <div className="flex items-center gap-3">
+ <button
+ type="button"
+ onClick={save}
+ disabled={!dirty || saving}
+ aria-label="save tags"
+ className="rounded border border-border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50"
+ >
+ {saving ? "Saving…" : "Save"}
+ </button>
+ <button
+ type="button"
+ onClick={addTag}
+ aria-label="add tag"
+ className="rounded border border-border px-3 py-1 text-sm hover:bg-muted"
+ >
+ + Add tag
+ </button>
+ {saved && !dirty && (
+ <span className="text-xs text-success" data-testid="tags-saved">
+ Saved
+ </span>
+ )}
+ {error && (
+ <span role="status" className="text-xs text-warning" data-testid="tags-error">
+ {error}
+ </span>
+ )}
+ </div>
+
+ {rows.length === 0 ? (
+ <p className="rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground">
+ No tags yet. Add one, give it an id like <code>eva-collab</code>, and
+ either pin videos by hand or give it a rule.
+ </p>
+ ) : (
+ <ul className="flex flex-col gap-4" aria-label="tags">
+ {rows.map((row) => (
+ <TagCard
+ key={row.key}
+ row={row}
+ counts={counts[row.id.trim().toLowerCase()]}
+ channels={channels}
+ busy={previewBusy === row.key}
+ preview={preview[row.key]}
+ note={previewNote[row.key] ?? ""}
+ onPatch={(next) => patch(row.key, next)}
+ onRemove={() => removeTag(row.key)}
+ onPreview={(cursor) => runPreview(row, cursor)}
+ onApply={(op, videos) => apply(row, op, videos)}
+ />
+ ))}
+ </ul>
+ )}
+ </div>
+ );
+}
+
+function TagCard({
+ row,
+ counts,
+ channels,
+ busy,
+ preview,
+ note,
+ onPatch,
+ onRemove,
+ onPreview,
+ onApply,
+}: {
+ row: TagRow;
+ counts: { pinned: number; suppressed: number } | undefined;
+ channels: string[];
+ busy: boolean;
+ preview: PreviewState | undefined;
+ note: string;
+ onPatch: (next: Partial<TagRow>) => void;
+ onRemove: () => void;
+ onPreview: (cursor: string | null) => void;
+ onApply: (
+ op: "add" | "remove" | "suppress",
+ videos: { channelSlug: string; id: string }[],
+ ) => void;
+}) {
+ const tagId = row.id.trim().toLowerCase();
+ const idValid = TAG_ID_RE.test(tagId);
+
+ const patchRule = (key: string, next: Partial<RuleRow>) =>
+ onPatch({
+ rules: row.rules.map((r) => (r.key === key ? { ...r, ...next } : r)),
+ });
+
+ return (
+ <li
+ className="flex flex-col gap-3 rounded-lg border bg-card/60 px-3 py-3"
+ data-testid="tag-card"
+ data-tag={tagId}
+ >
+ <div className="flex flex-wrap items-end gap-3">
+ <label className="flex min-w-[9rem] flex-col gap-1 text-xs text-muted-foreground">
+ Id
+ <input
+ value={row.id}
+ onChange={(e) => onPatch({ id: e.target.value })}
+ placeholder="eva-collab"
+ aria-label="tag id"
+ aria-invalid={!idValid || undefined}
+ className="rounded border border-border bg-card px-2 py-1 font-mono text-sm text-foreground"
+ />
+ </label>
+ <label className="flex min-w-[9rem] flex-col gap-1 text-xs text-muted-foreground">
+ Label
+ <input
+ value={row.label}
+ onChange={(e) => onPatch({ label: e.target.value })}
+ placeholder="Collab"
+ aria-label="tag label"
+ className="rounded border border-border bg-card px-2 py-1 text-sm text-foreground"
+ />
+ </label>
+ <label className="flex min-w-[7rem] flex-col gap-1 text-xs text-muted-foreground">
+ Group
+ <input
+ value={row.group}
+ onChange={(e) => onPatch({ group: e.target.value })}
+ placeholder="eva"
+ aria-label="tag group"
+ className="rounded border border-border bg-card px-2 py-1 text-sm text-foreground"
+ />
+ </label>
+ <label className="flex min-w-[7rem] flex-col gap-1 text-xs text-muted-foreground">
+ Group label
+ <input
+ value={row.groupLabel}
+ onChange={(e) => onPatch({ groupLabel: e.target.value })}
+ placeholder="Eva"
+ aria-label="tag group label"
+ className="rounded border border-border bg-card px-2 py-1 text-sm text-foreground"
+ />
+ </label>
+ <label className="flex w-20 flex-col gap-1 text-xs text-muted-foreground">
+ Order
+ <input
+ value={row.order}
+ onChange={(e) => onPatch({ order: e.target.value })}
+ aria-label="tag order"
+ inputMode="numeric"
+ className="rounded border border-border bg-card px-2 py-1 text-sm text-foreground"
+ />
+ </label>
+ <label className="flex w-24 flex-col gap-1 text-xs text-muted-foreground">
+ Colour
+ <input
+ value={row.color}
+ onChange={(e) => onPatch({ color: e.target.value })}
+ placeholder="#b48ead"
+ aria-label="tag colour"
+ className="rounded border border-border bg-card px-2 py-1 font-mono text-xs text-foreground"
+ />
+ </label>
+ <label className="flex items-center gap-1.5 pb-1.5 text-xs">
+ <input
+ type="checkbox"
+ checked={row.hidden}
+ onChange={(e) => onPatch({ hidden: e.target.checked })}
+ aria-label="tag hidden"
+ />
+ Hidden
+ </label>
+ <button
+ type="button"
+ onClick={onRemove}
+ aria-label={`remove tag ${tagId || "new"}`}
+ className="ml-auto rounded border border-border px-2 py-1 text-xs hover:bg-muted"
+ >
+ ×
+ </button>
+ </div>
+
+ <label className="flex flex-col gap-1 text-xs text-muted-foreground">
+ Description
+ <input
+ value={row.description}
+ onChange={(e) => onPatch({ description: e.target.value })}
+ placeholder="What this tag means"
+ aria-label="tag description"
+ className="rounded border border-border bg-card px-2 py-1 text-sm text-foreground"
+ />
+ </label>
+
+ <p className="text-xs text-muted-foreground" data-testid="tag-counts">
+ {counts
+ ? `${counts.pinned} pinned · ${counts.suppressed} suppressed`
+ : "0 pinned · 0 suppressed"}
+ {" — rule hits are not counted here; they are derived at index build."}
+ </p>
+
+ {/* --- rules ------------------------------------------------------- */}
+ <div className="flex flex-col gap-2">
+ {row.rules.map((rule) => {
+ const err = regexError(rule.pattern);
+ return (
+ <div
+ key={rule.key}
+ data-testid="tag-rule"
+ className="flex flex-col gap-2 rounded border border-border/60 bg-background/40 px-2 py-2"
+ >
+ <div className="flex flex-wrap items-end gap-2">
+ <label className="flex w-24 flex-col gap-1 text-[11px] text-muted-foreground">
+ Rule id
+ <input
+ value={rule.id}
+ onChange={(e) => patchRule(rule.key, { id: e.target.value })}
+ aria-label="rule id"
+ className="rounded border border-border bg-card px-1.5 py-1 font-mono text-xs text-foreground"
+ />
+ </label>
+ <label className="flex flex-col gap-1 text-[11px] text-muted-foreground">
+ Kind
+ <select
+ value={rule.kind}
+ onChange={(e) =>
+ patchRule(rule.key, {
+ kind: e.target.value as CuratedTagRuleKind,
+ })
+ }
+ aria-label="rule kind"
+ className="rounded border border-border bg-card px-1.5 py-1 text-xs text-foreground"
+ >
+ {KINDS.map((k) => (
+ <option key={k.value} value={k.value}>
+ {k.label}
+ </option>
+ ))}
+ </select>
+ </label>
+ <label className="flex min-w-[14rem] flex-1 flex-col gap-1 text-[11px] text-muted-foreground">
+ Pattern (regex, case-insensitive)
+ <input
+ value={rule.pattern}
+ onChange={(e) =>
+ patchRule(rule.key, { pattern: e.target.value })
+ }
+ aria-label="rule pattern"
+ aria-invalid={err ? true : undefined}
+ className="rounded border border-border bg-card px-1.5 py-1 font-mono text-xs text-foreground"
+ />
+ </label>
+ <label className="flex items-center gap-1.5 pb-1.5 text-[11px]">
+ <input
+ type="checkbox"
+ checked={rule.enabled !== false}
+ onChange={(e) =>
+ patchRule(rule.key, { enabled: e.target.checked })
+ }
+ aria-label="rule enabled"
+ />
+ Enabled
+ </label>
+ <button
+ type="button"
+ onClick={() =>
+ onPatch({
+ rules: row.rules.filter((r) => r.key !== rule.key),
+ })
+ }
+ aria-label={`remove rule ${rule.id}`}
+ className="rounded border border-border px-1.5 py-1 text-xs hover:bg-muted"
+ >
+ ×
+ </button>
+ </div>
+ <div className="flex flex-wrap items-end gap-2">
+ <label className="flex min-w-[14rem] flex-1 flex-col gap-1 text-[11px] text-muted-foreground">
+ Channels (comma-separated; empty = every channel)
+ <input
+ value={rule.channelsText}
+ onChange={(e) =>
+ patchRule(rule.key, { channelsText: e.target.value })
+ }
+ list="tag-rule-channels"
+ aria-label="rule channels"
+ className="rounded border border-border bg-card px-1.5 py-1 font-mono text-xs text-foreground"
+ />
+ </label>
+ <label className="flex w-28 flex-col gap-1 text-[11px] text-muted-foreground">
+ From
+ <input
+ value={rule.dateFrom ?? ""}
+ onChange={(e) =>
+ patchRule(rule.key, { dateFrom: e.target.value })
+ }
+ placeholder="20240101"
+ aria-label="rule date from"
+ className="rounded border border-border bg-card px-1.5 py-1 font-mono text-xs text-foreground"
+ />
+ </label>
+ <label className="flex w-28 flex-col gap-1 text-[11px] text-muted-foreground">
+ To
+ <input
+ value={rule.dateTo ?? ""}
+ onChange={(e) =>
+ patchRule(rule.key, { dateTo: e.target.value })
+ }
+ placeholder="20261231"
+ aria-label="rule date to"
+ className="rounded border border-border bg-card px-1.5 py-1 font-mono text-xs text-foreground"
+ />
+ </label>
+ <span className="pb-1.5 text-[11px] text-muted-foreground">
+ {KINDS.find((k) => k.value === rule.kind)?.hint}
+ </span>
+ </div>
+ {/* A BROKEN REGEX IS KEPT, DISABLED, WITH THE REASON — never
+ dropped on save. The typo stays visible so it can be fixed. */}
+ {(err || rule.disabledReason) && (
+ <p
+ className="font-mono text-[11px] text-destructive"
+ data-testid="rule-disabled-reason"
+ >
+ {err ?? rule.disabledReason}
+ </p>
+ )}
+ </div>
+ );
+ })}
+ <div className="flex items-center gap-2">
+ <button
+ type="button"
+ onClick={() =>
+ onPatch({
+ rules: [
+ ...row.rules,
+ {
+ key: mkKey(),
+ id: `r${row.rules.length + 1}`,
+ kind: "metadata",
+ pattern: "",
+ enabled: true,
+ channelsText: "",
+ },
+ ],
+ })
+ }
+ aria-label={`add rule to ${tagId || "new tag"}`}
+ className="rounded border border-border px-2 py-1 text-xs hover:bg-muted"
+ >
+ + Add rule
+ </button>
+ <button
+ type="button"
+ disabled={busy || !idValid || row.rules.length === 0}
+ onClick={() => onPreview(null)}
+ aria-label={`preview ${tagId}`}
+ className="rounded border border-border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50"
+ >
+ {busy ? "Working…" : "Preview"}
+ </button>
+ {preview && preview.rows.length > 0 && (
+ <button
+ type="button"
+ disabled={busy}
+ onClick={() =>
+ onApply(
+ "add",
+ preview.rows.map((r) => ({
+ channelSlug: r.channelSlug,
+ id: r.id,
+ })),
+ )
+ }
+ aria-label={`pin all ${tagId}`}
+ className="rounded border border-border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50"
+ >
+ Pin all ({preview.rows.length})
+ </button>
+ )}
+ {note && (
+ <span role="status" className="text-xs text-muted-foreground">
+ {note}
+ </span>
+ )}
+ </div>
+ </div>
+
+ {preview && (
+ <div className="flex flex-col gap-1" data-testid="tag-preview">
+ {!preview.indexAvailable ? (
+ <p className="text-xs text-warning">
+ No transcript index yet — build it (Sites → Pool jobs → Build
+ index) and preview again. Rules can be written and saved
+ meanwhile.
+ </p>
+ ) : (
+ <>
+ <p className="text-xs text-muted-foreground">
+ {preview.rows.length} match
+ {preview.rows.length === 1 ? "" : "es"} in {preview.scanned}{" "}
+ video{preview.scanned === 1 ? "" : "s"} scanned
+ {preview.stoppedBy === "end"
+ ? " (whole scope)"
+ : preview.stoppedBy === "scan-cap"
+ ? " — stopped at the scan cap"
+ : " — stopped at the match cap"}
+ </p>
+ <ul className="flex flex-col divide-y divide-border rounded border border-border">
+ {preview.rows.map((r) => (
+ <li
+ key={`${r.channelSlug}/${r.id}`}
+ data-testid="preview-row"
+ className="flex flex-wrap items-center gap-2 px-2 py-1 text-xs"
+ >
+ <span className="font-mono text-muted-foreground">
+ {r.uploadDate}
+ </span>
+ <span className="font-mono text-muted-foreground">
+ {r.channelSlug}
+ </span>
+ <span className="min-w-0 flex-1 truncate">{r.title}</span>
+ {r.pinned && (
+ <span className="rounded-full border border-success/30 px-1.5 text-[10px] text-success">
+ pinned
+ </span>
+ )}
+ {r.suppressed && (
+ <span className="rounded-full border border-warning/30 px-1.5 text-[10px] text-warning">
+ suppressed
+ </span>
+ )}
+ <button
+ type="button"
+ disabled={busy}
+ onClick={() =>
+ onApply("add", [
+ { channelSlug: r.channelSlug, id: r.id },
+ ])
+ }
+ aria-label={`pin ${r.id}`}
+ className="rounded border border-border px-1.5 py-0.5 hover:bg-muted disabled:opacity-50"
+ >
+ Pin
+ </button>
+ <button
+ type="button"
+ disabled={busy}
+ onClick={() =>
+ onApply("remove", [
+ { channelSlug: r.channelSlug, id: r.id },
+ ])
+ }
+ aria-label={`unpin ${r.id}`}
+ className="rounded border border-border px-1.5 py-0.5 hover:bg-muted disabled:opacity-50"
+ >
+ Unpin
+ </button>
+ <button
+ type="button"
+ disabled={busy}
+ onClick={() =>
+ onApply("suppress", [
+ { channelSlug: r.channelSlug, id: r.id },
+ ])
+ }
+ aria-label={`suppress ${r.id}`}
+ className="rounded border border-border px-1.5 py-0.5 hover:bg-muted disabled:opacity-50"
+ >
+ Suppress
+ </button>
+ </li>
+ ))}
+ </ul>
+ {preview.nextCursor && (
+ <button
+ type="button"
+ disabled={busy}
+ onClick={() => onPreview(preview.nextCursor)}
+ aria-label={`scan more ${tagId}`}
+ className="self-start rounded border border-border px-2 py-1 text-xs hover:bg-muted disabled:opacity-50"
+ >
+ Scan more
+ </button>
+ )}
+ </>
+ )}
+ </div>
+ )}
+
+ <datalist id="tag-rule-channels">
+ {channels.map((c) => (
+ <option key={c} value={c} />
+ ))}
+ </datalist>
+ </li>
+ );
+}
diff --git a/editor/app/tags/page.tsx b/editor/app/tags/page.tsx
@@ -0,0 +1,59 @@
+import type { Metadata } from "next";
+import { getPaths } from "yt-dlp-transcript-common/lib/paths";
+import { listChannelConfigs } from "yt-dlp-transcript-common/controller/channels";
+import { readGlobalTags } from "../../lib/tagsStore";
+import { EditorTagsClient, type TagCounts } from "./components/EditorTagsClient";
+
+export const dynamic = "force-dynamic";
+export const metadata: Metadata = { title: "Tags" };
+
+// THE CURATED VOCABULARY — the corpus-wide one. A tag cuts ACROSS channels
+// ("every stream where X is on mic"), which is why it lives here beside the
+// corpus rather than under a channel or a site; a site may relabel, recolour,
+// hide or extend it on /sites/<id>/tags, and nothing else.
+//
+// Three unrelated things in this app are already called "tags" — yt-dlp
+// keywords, digest topics, and the search scope. This page is none of them; the
+// record field is `curatedTags` and the file is transcripts/tags.json. See
+// common/lib/curatedTags.ts.
+export default async function TagsPage() {
+ const paths = getPaths();
+ const config = readGlobalTags(paths);
+ const channels = (await listChannelConfigs(paths)).map((c) => c.slug);
+
+ // Pins and suppressions per tag, counted off the file. Rule hits are NOT
+ // here and cannot be: they are derived at index-build time and never stored,
+ // so the honest number on this page is "what a person or an agent asserted".
+ const counts: TagCounts = {};
+ for (const assignment of Object.values(config.assignments)) {
+ for (const id of assignment.manual ?? []) {
+ counts[id] = counts[id] ?? { pinned: 0, suppressed: 0 };
+ counts[id].pinned += 1;
+ }
+ for (const id of assignment.suppressed ?? []) {
+ counts[id] = counts[id] ?? { pinned: 0, suppressed: 0 };
+ counts[id].suppressed += 1;
+ }
+ }
+
+ return (
+ <section className="flex flex-col gap-4">
+ <header className="flex flex-col gap-1">
+ <h1 className="text-2xl font-semibold">Tags</h1>
+ <p className="max-w-3xl text-sm text-muted-foreground">
+ A curated vocabulary that cuts across channels. A tag lands on a video
+ three ways: a <strong>rule</strong> re-evaluated at every index build,
+ a <strong>pin</strong> somebody made by hand, or an import from umtool
+ — and a <strong>suppression</strong> rejects a rule's hit. Pins
+ and suppressions are stored with their provenance; rule hits never
+ are. Edit a rule, rebuild the index, done.
+ </p>
+ </header>
+ <EditorTagsClient
+ initialTags={config.tags}
+ counts={counts}
+ channels={channels}
+ />
+ </section>
+ );
+}
diff --git a/editor/lib/tagsStore.ts b/editor/lib/tagsStore.ts
@@ -0,0 +1,186 @@
+// THE EDITOR'S ONE DOOR ONTO THE CURATED-TAG STORE.
+//
+// Every tag read and every tag write in this package goes through this module —
+// the /tags page, the site overlay tab, the video panel, the bulk bar and both
+// /api/ops routes. Nothing else opens tags.json.
+//
+// TEMPORARY until S1.2 — replace with re-exports from
+// common/lib/curatedTagsStore. The store is being written in parallel on
+// `tags/core` with exactly the signatures below; when it lands, the body of
+// this file becomes
+//
+// export {
+// readGlobalTags, writeGlobalTags, readSiteTags, writeSiteTags,
+// effectiveSiteTags, applyTagAssignments,
+// } from "yt-dlp-transcript-common/lib/curatedTagsStore";
+//
+// and every call site is untouched. That is the whole reason this module
+// exists: one file to swap, not twenty imports to chase.
+//
+// The MODEL (types, sanitize, merge, rule evaluation) is already frozen in
+// common/lib/curatedTags.ts and is imported from there, never re-implemented.
+
+import path from "node:path";
+import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
+import type { Paths } from "yt-dlp-transcript-common/lib/paths";
+import {
+ CURATED_TAGS_VERSION,
+ TAGS_FILENAME,
+ assignmentKey,
+ mergeTagDefs,
+ sanitizeTagsConfig,
+ type CuratedTagAssignment,
+ type CuratedTagDef,
+ type CuratedTagsConfig,
+} from "yt-dlp-transcript-common/lib/curatedTags";
+
+// S1.2 adds `globalTagsFile` to Paths and `siteTagsFile()` to common/lib/site.ts.
+// Until then the two locations are derived here from the roots Paths already
+// has — the same two paths, spelled once.
+function globalTagsFile(paths: Paths): string {
+ const withTags = paths as Paths & { globalTagsFile?: string };
+ return (
+ withTags.globalTagsFile ?? path.join(paths.transcriptsDir, TAGS_FILENAME)
+ );
+}
+
+function siteTagsFile(paths: Paths, siteId: string): string {
+ return path.join(paths.sitesDir, siteId, TAGS_FILENAME);
+}
+
+function emptyConfig(): CuratedTagsConfig {
+ return { version: CURATED_TAGS_VERSION, tags: [], assignments: {} };
+}
+
+// tmp + rename, the repo's write idiom (normalizeTranscript.ts:139-141). A
+// half-written tags.json is an erased vocabulary, not a syntax error to fix.
+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);
+}
+
+function readConfig(file: string): CuratedTagsConfig {
+ try {
+ return sanitizeTagsConfig(JSON.parse(readFileSync(file, "utf8")));
+ } catch {
+ // Absent OR malformed → empty. Unlike aliases there are no seeded defaults:
+ // a curated vocabulary is the operator's, and inventing one would be
+ // inventing facts about their videos.
+ return emptyConfig();
+ }
+}
+
+export function readGlobalTags(paths: Paths): CuratedTagsConfig {
+ return readConfig(globalTagsFile(paths));
+}
+
+export function writeGlobalTags(paths: Paths, config: CuratedTagsConfig): void {
+ writeJsonAtomic(globalTagsFile(paths), sanitizeTagsConfig(config));
+}
+
+export function readSiteTags(paths: Paths, siteId: string): CuratedTagsConfig {
+ return readConfig(siteTagsFile(paths, siteId));
+}
+
+export function writeSiteTags(
+ paths: Paths,
+ siteId: string,
+ config: CuratedTagsConfig,
+): void {
+ writeJsonAtomic(siteTagsFile(paths, siteId), sanitizeTagsConfig(config));
+}
+
+// What a site actually ships: the corpus vocabulary with the site's overlay
+// laid over it. Assignments do NOT layer — an assignment is a fact about a
+// video, not a presentation choice — so only the defs merge.
+export function effectiveSiteTags(
+ paths: Paths,
+ siteId: string,
+): CuratedTagDef[] {
+ return mergeTagDefs(
+ readGlobalTags(paths).tags,
+ readSiteTags(paths, siteId).tags,
+ );
+}
+
+export type TagAssignmentOp = "add" | "remove" | "suppress" | "unsuppress";
+
+export type TagVideoRef = { channelSlug: string; id: string };
+
+export type ApplyTagAssignmentsInput = {
+ op: TagAssignmentOp;
+ tag: string;
+ videos: TagVideoRef[];
+ // `operator` | `agent:<label>` | `umtool:<project>`; recorded verbatim.
+ source: string;
+ // ISO-8601. Defaults to now.
+ at?: string;
+};
+
+// THE ONE WRITER. The editor UI, /api/ops/tag-videos and umtool all land here,
+// which is why provenance is a required argument rather than a default: a pin
+// with no answer to "who said so" is the thing this whole feature is trying not
+// to accumulate.
+//
+// ONE FILE WRITE FOR THE WHOLE BATCH. A bulk tag of four thousand videos is one
+// read, one mutation of the in-memory config and one rename — not four thousand
+// of each.
+//
+// add pin; clears a suppression of the same tag (a later pin beats an
+// older suppression, exactly as effectiveTagsFor folds them)
+// remove UNPIN ONLY. A rule hit survives — removing a pin is not the same
+// statement as "this tag does not belong here", which is `suppress`
+// suppress reject; also unpins, so the two lists never disagree
+// unsuppress clear a rejection; does not pin
+export function applyTagAssignments(
+ paths: Paths,
+ input: ApplyTagAssignmentsInput,
+): { changed: number } {
+ const tag = input.tag.trim().toLowerCase();
+ const setAt = input.at ?? new Date().toISOString();
+ const config = readGlobalTags(paths);
+ let changed = 0;
+
+ for (const video of input.videos) {
+ const key = assignmentKey(video.channelSlug, video.id);
+ const before = config.assignments[key];
+ const manual = new Set(before?.manual ?? []);
+ const suppressed = new Set(before?.suppressed ?? []);
+ const sources = { ...(before?.sources ?? {}) };
+ const had = { manual: manual.has(tag), suppressed: suppressed.has(tag) };
+
+ if (input.op === "add") {
+ manual.add(tag);
+ suppressed.delete(tag);
+ } else if (input.op === "remove") {
+ manual.delete(tag);
+ } else if (input.op === "suppress") {
+ suppressed.add(tag);
+ manual.delete(tag);
+ } else {
+ suppressed.delete(tag);
+ }
+
+ const nowListed = manual.has(tag) || suppressed.has(tag);
+ if (nowListed) sources[tag] = { source: input.source, setAt };
+ else delete sources[tag];
+
+ if (had.manual === manual.has(tag) && had.suppressed === suppressed.has(tag)) {
+ continue; // nothing moved for this video
+ }
+ changed += 1;
+
+ const next: CuratedTagAssignment = {
+ ...(manual.size > 0 ? { manual: [...manual].sort() } : {}),
+ ...(suppressed.size > 0 ? { suppressed: [...suppressed].sort() } : {}),
+ ...(Object.keys(sources).length > 0 ? { sources } : {}),
+ };
+ if (!next.manual && !next.suppressed) delete config.assignments[key];
+ else config.assignments[key] = next;
+ }
+
+ if (changed > 0) writeGlobalTags(paths, config);
+ return { changed };
+}
diff --git a/editor/scripts/measure-nav.mjs b/editor/scripts/measure-nav.mjs
@@ -47,6 +47,7 @@ const RUNS = Number(argOf("runs", "3"));
const ROUTES = [
["/", "__PAGE__"],
["/channels", "channels"],
+ ["/tags", "tags"],
["/review", "review"],
["/operations", "operations"],
["/sites", "sites"],