Archilyzer · Source

archilyzer

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

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:
Acommon/controller/curatedTagsPreview.ts | 0
Meditor/app/lib/nav.test.ts | 20+++++++++++---------
Meditor/app/lib/nav.ts | 15+++++++++++++++
Aeditor/app/tags/actions.ts | 171+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/tags/components/EditorTagsClient.tsx | 766+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/tags/page.tsx | 59+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/lib/tagsStore.ts | 186+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/scripts/measure-nav.mjs | 1+
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&apos;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"],