Archilyzer · Source

archilyzer

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

commit b2f77dfe021c4bd1a39769905781c48dd5ff5d8c
parent bb8298e49e15d352436771e230c1ea54996a6b57
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 21 Sep 2026 13:22:32 -0400

tags S3: the real store, and the site layer is presentation only

S1.2 landed, so editor/lib/tagsStore.ts becomes what it always said it would:
re-exports from common/lib/curatedTagsStore. The temporary implementation is
gone and no call site moved — which was the whole reason for the single door.
Two adaptations to the real surface: applyTagAssignments returns the assignment
keys that CHANGED (so the action reports changed.length, and re-pinning a pinned
video is correctly not a change), and it THROWS on a malformed ref or op rather
than storing junk, which the action turns into the { ok: false, error } every
caller here already handles.

The per-site tab loses its rule editor, per the orchestrator's scope correction,
and the page says why: a rule decides whether a tag lands on a RECORD, and a
record is shared by every site carrying its channel — so a site-layer rule would
be one site editing another site's data. A site owns how a tag READS (label,
group label, colour, order, visibility) and nothing else. Corpus rules stay
listed read-only for reference.

Co-Authored-By: Claude Opus <noreply@anthropic.com>

Diffstat:
Meditor/app/sites/[siteId]/tags/page.tsx | 29++++++++++++++++-------------
Meditor/app/sites/components/SiteTagsClient.tsx | 547++++++++++++++++++-------------------------------------------------------------
Meditor/app/tags/actions.ts | 25++++++++++++++++++-------
Meditor/lib/tagsStore.ts | 200++++++++-----------------------------------------------------------------------
4 files changed, 179 insertions(+), 622 deletions(-)

diff --git a/editor/app/sites/[siteId]/tags/page.tsx b/editor/app/sites/[siteId]/tags/page.tsx @@ -10,15 +10,18 @@ export const dynamic = "force-dynamic"; // → "Tags — <id> — Sites" through the layout's title template. export const metadata: Metadata = { title: "Tags" }; -// A SITE MAY DRESS A TAG UP; IT MAY NOT GUT IT. +// A SITE OWNS HOW A TAG READS, AND NOTHING ELSE. // -// The corpus file is authoritative for what a tag IS and for which videos carry -// it. This tab is presentation — label, group label, colour, order, hidden — -// plus rules that fire on this site's build only. mergeTagDefs is a field-wise -// overlay that APPENDS rules: there is no way to delete a corpus rule or a -// corpus assignment from here, and the UI deliberately does not offer one (an -// assignment is a fact about a video, not a presentation choice). Hide the chip -// instead — that is what `hidden` is for. +// The corpus file is authoritative for what a tag IS, which rules derive it and +// which videos carry it. This tab is presentation only: label, group label, +// colour, order, visibility. +// +// No rule editor, deliberately. A rule decides whether a tag lands on a RECORD, +// and a record is shared by every site carrying its channel — a site-layer rule +// would be one site editing another site's data. Nor is there anything here +// that deletes a corpus rule or an assignment: an assignment is a fact about a +// video. A site that wants a tag gone hides the chip, which is what `hidden` is +// for. export default async function SiteTagsPage({ params, }: { @@ -34,11 +37,11 @@ export default async function SiteTagsPage({ <p className="max-w-3xl text-sm text-muted-foreground"> The corpus vocabulary is on <Link href="/tags" className="underline">Tags</Link>; this tab is how <strong>{siteId}</strong> shows it. Relabel, recolour, - reorder or hide a tag here, or give it an extra rule that fires only on - this site&apos;s build. A blank field inherits. Corpus rules are listed - read-only: a site can add to them, never remove one — and it can never - remove an assignment, which is a fact about a video. Changes bake into - the site&apos;s next export build. + reorder or hide a tag here — a blank field inherits. Rules and + assignments stay in the corpus vocabulary: a record is shared by every + site carrying its channel, so what a tag MEANS is decided once. Corpus + rules are listed read-only for reference. Changes bake into the + site&apos;s next export build. </p> <SiteTagsClient key={siteId} diff --git a/editor/app/sites/components/SiteTagsClient.tsx b/editor/app/sites/components/SiteTagsClient.tsx @@ -1,33 +1,27 @@ "use client"; -// One site's overlay on the corpus vocabulary. +// One site's overlay on the corpus vocabulary — PRESENTATION ONLY. // -// Two lists, and the difference between them is the whole point: +// A row per corpus tag, with label / group label / colour / order / visibility. +// Every field is optional and a blank one inherits, so the site's file holds +// only what this site actually changed. // -// OVERLAYS — one row per corpus tag. Every field is optional and a blank one -// inherits, so the file holds only what this site actually changed. The tag's -// corpus rules are printed read-only; the site may APPEND a rule and remove -// only the rules it added itself. There is no control that deletes a corpus -// rule or a corpus assignment, because mergeTagDefs cannot express either. +// THERE IS NO RULE EDITOR HERE, AND THAT IS NOT AN OMISSION. A rule decides +// whether a tag lands on a RECORD, and a record is shared by every site that +// carries its channel — so a site-layer rule would be one site editing another +// site's data. Rules and assignments live in the corpus vocabulary at /tags; +// what a site owns is how the tag READS: its wording, its colour, its position +// in the chip row, and whether it appears at all. // -// SITE-ONLY TAGS — ids the corpus does not define at all, edited in full. -// -// Assignments never appear here. An assignment is a fact about a video; a site -// hides a chip, it does not erase a fact. +// For the same reason there is nothing here that deletes a corpus rule or an +// assignment: an assignment is a fact about a video, not a presentation choice. +// A site that wants a tag gone hides the chip. -import { useMemo, useState, useTransition } from "react"; -import { - TAG_ID_RE, - type CuratedTagDef, - type CuratedTagRule, - type CuratedTagRuleKind, -} from "yt-dlp-transcript-common/lib/curatedTags"; +import { useState, useTransition } from "react"; +import Link from "next/link"; +import type { CuratedTagDef } from "yt-dlp-transcript-common/lib/curatedTags"; import { saveSiteTagDefsAction } from "../../tags/actions"; -const KINDS: CuratedTagRuleKind[] = ["metadata", "chat-author", "caption"]; - -type RuleRow = CuratedTagRule & { key: string; channelsText: string }; - type OverlayRow = { id: string; label: string; @@ -37,30 +31,8 @@ type OverlayRow = { // Three states, not two: inherit / shown / hidden. A site that has said // nothing about a tag must not start writing `hidden: false` into its file. hidden: "" | "true" | "false"; - rules: RuleRow[]; }; -let keySeq = 0; -const mkKey = () => `s${keySeq++}`; - -function toRuleRow(r: CuratedTagRule): RuleRow { - return { ...r, key: mkKey(), channelsText: (r.channels ?? []).join(", ") }; -} - -function toRule(r: RuleRow): CuratedTagRule { - const channels = r.channelsText - .split(",") - .map((c) => c.trim()) - .filter(Boolean); - return { - id: r.id.trim() || "site", - kind: r.kind, - pattern: r.pattern, - enabled: r.enabled !== false, - ...(channels.length > 0 ? { channels } : {}), - }; -} - function overlayRowFor(id: string, site: CuratedTagDef | undefined): OverlayRow { return { id, @@ -69,37 +41,29 @@ function overlayRowFor(id: string, site: CuratedTagDef | undefined): OverlayRow color: site?.color ?? "", order: site?.order === undefined ? "" : String(site.order), hidden: site?.hidden === undefined ? "" : site.hidden ? "true" : "false", - rules: (site?.rules ?? []).map(toRuleRow), }; } // An overlay row is worth writing only when it says something. function overlayToDef(row: OverlayRow): CuratedTagDef | null { const order = Number(row.order); - const def: CuratedTagDef = { - id: row.id, - // `label` is required on the type; the corpus label is what the merge keeps - // when the site does not override it, so an untouched overlay is dropped - // entirely below rather than written with an empty label. - label: row.label.trim(), - ...(row.groupLabel.trim() ? { groupLabel: row.groupLabel.trim() } : {}), - ...(row.color.trim() ? { color: row.color.trim() } : {}), - ...(row.order.trim() !== "" && Number.isFinite(order) ? { order } : {}), - ...(row.hidden === "" ? {} : { hidden: row.hidden === "true" }), - ...(row.rules.length > 0 ? { rules: row.rules.map(toRule) } : {}), - }; - const saysSomething = - def.label !== "" || - def.groupLabel !== undefined || - def.color !== undefined || - def.order !== undefined || - def.hidden !== undefined || - (def.rules?.length ?? 0) > 0; - if (!saysSomething) return null; - // mergeTagDefs only applies fields that are present, so an overlay with no - // label must not carry `label: ""` — that would blank the corpus label. - if (def.label === "") delete (def as { label?: string }).label; - return def; + const label = row.label.trim(); + const groupLabel = row.groupLabel.trim(); + const color = row.color.trim(); + const hasOrder = row.order.trim() !== "" && Number.isFinite(order); + if (!label && !groupLabel && !color && !hasOrder && row.hidden === "") { + return null; + } + // mergeTagDefs applies only the fields that are PRESENT, so an overlay with + // no label must not carry `label: ""` — that would blank the corpus label. + // The type requires `label`, hence the cast on the way out. + const def: Record<string, unknown> = { id: row.id }; + if (label) def.label = label; + if (groupLabel) def.groupLabel = groupLabel; + if (color) def.color = color; + if (hasOrder) def.order = order; + if (row.hidden !== "") def.hidden = row.hidden === "true"; + return def as unknown as CuratedTagDef; } export function SiteTagsClient({ @@ -111,10 +75,6 @@ export function SiteTagsClient({ globalTags: CuratedTagDef[]; siteTags: CuratedTagDef[]; }) { - const globalIds = useMemo( - () => new Set(globalTags.map((t) => t.id)), - [globalTags], - ); const [overlays, setOverlays] = useState<OverlayRow[]>(() => globalTags.map((g) => overlayRowFor( @@ -123,37 +83,22 @@ export function SiteTagsClient({ ), ), ); - const [ownRows, setOwnRows] = useState<OverlayRow[]>(() => - siteTags - .filter((s) => !globalIds.has(s.id)) - .map((s) => overlayRowFor(s.id, s)), - ); const [dirty, setDirty] = useState(false); const [saved, setSaved] = useState(false); const [error, setError] = useState<string | null>(null); const [saving, startSave] = useTransition(); - const touch = () => { + const patch = (id: string, next: Partial<OverlayRow>) => { + setOverlays((rs) => rs.map((r) => (r.id === id ? { ...r, ...next } : r))); setDirty(true); setSaved(false); setError(null); }; const save = () => { - const bad = ownRows.find((r) => !TAG_ID_RE.test(r.id.trim().toLowerCase())); - if (bad) { - setError(`"${bad.id}" is not a valid tag id`); - return; - } - const defs = [ - ...overlays.map(overlayToDef).filter((d): d is CuratedTagDef => d !== null), - ...ownRows - .map((r) => overlayToDef({ ...r, id: r.id.trim().toLowerCase() })) - .filter((d): d is CuratedTagDef => d !== null) - // A site-only tag with no label at all is unusable as a chip; give it - // its id, which is what sanitizeTagsConfig would do anyway. - .map((d) => ({ ...d, label: d.label || d.id })), - ]; + const defs = overlays + .map(overlayToDef) + .filter((d): d is CuratedTagDef => d !== null); startSave(async () => { const result = await saveSiteTagDefsAction(siteId, defs); if (result.ok) { @@ -165,18 +110,8 @@ export function SiteTagsClient({ }); }; - const patchOverlay = ( - list: "global" | "own", - id: string, - next: Partial<OverlayRow>, - ) => { - const set = list === "global" ? setOverlays : setOwnRows; - set((rs) => rs.map((r) => (r.id === id ? { ...r, ...next } : r))); - touch(); - }; - return ( - <div className="flex flex-col gap-6" data-testid="site-tags-client"> + <div className="flex flex-col gap-4" data-testid="site-tags-client"> <div className="flex items-center gap-3"> <button type="button" @@ -199,338 +134,106 @@ export function SiteTagsClient({ )} </div> - <section className="flex flex-col gap-3"> - <h2 className="text-lg font-semibold">Corpus tags on this site</h2> - {globalTags.length === 0 ? ( - <p className="rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground"> - The corpus has no tags yet. - </p> - ) : ( - <ul className="flex flex-col gap-3" aria-label="tag overlays"> - {globalTags.map((g) => { - const row = overlays.find((o) => o.id === g.id)!; - return ( - <li - key={g.id} - data-testid="tag-overlay" - data-tag={g.id} - className="flex flex-col gap-2 rounded-lg border bg-card/60 px-3 py-3" - > - <div className="flex flex-wrap items-end gap-3"> - <span className="pb-1.5 font-mono text-sm">{g.id}</span> - <label className="flex min-w-[9rem] flex-col gap-1 text-xs text-muted-foreground"> - Label - <input - value={row.label} - onChange={(e) => - patchOverlay("global", g.id, { label: e.target.value }) - } - placeholder={g.label} - aria-label={`label for ${g.id}`} - className="rounded border border-border bg-card px-2 py-1 text-sm text-foreground" - /> - </label> - <label className="flex min-w-[8rem] flex-col gap-1 text-xs text-muted-foreground"> - Group label - <input - value={row.groupLabel} - onChange={(e) => - patchOverlay("global", g.id, { - groupLabel: e.target.value, - }) - } - placeholder={g.groupLabel ?? ""} - aria-label={`group label for ${g.id}`} - 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) => - patchOverlay("global", g.id, { color: e.target.value }) - } - placeholder={g.color ?? "#b48ead"} - aria-label={`colour for ${g.id}`} - className="rounded border border-border bg-card px-2 py-1 font-mono text-xs text-foreground" - /> - </label> - <label className="flex w-20 flex-col gap-1 text-xs text-muted-foreground"> - Order - <input - value={row.order} - onChange={(e) => - patchOverlay("global", g.id, { order: e.target.value }) - } - placeholder={g.order === undefined ? "" : String(g.order)} - aria-label={`order for ${g.id}`} - inputMode="numeric" - className="rounded border border-border bg-card px-2 py-1 text-sm text-foreground" - /> - </label> - <label className="flex flex-col gap-1 text-xs text-muted-foreground"> - Visibility - <select - value={row.hidden} - onChange={(e) => - patchOverlay("global", g.id, { - hidden: e.target.value as OverlayRow["hidden"], - }) - } - aria-label={`visibility for ${g.id}`} - className="rounded border border-border bg-card px-2 py-1 text-sm text-foreground" - > - <option value=""> - Inherit ({g.hidden ? "hidden" : "shown"}) - </option> - <option value="false">Shown here</option> - <option value="true">Hidden here</option> - </select> - </label> - </div> - - {(g.rules ?? []).length > 0 && ( - <p className="text-[11px] text-muted-foreground"> - Corpus rules (read-only here):{" "} - {(g.rules ?? []) - .map( - (r) => - `${r.id} · ${r.kind} · /${r.pattern}/${r.enabled === false ? " (disabled)" : ""}`, - ) - .join(" · ")} - </p> - )} - - <SiteRules - rows={row.rules} - onChange={(rules) => patchOverlay("global", g.id, { rules })} - tagId={g.id} - /> - </li> - ); - })} - </ul> - )} - </section> - - <section className="flex flex-col gap-3"> - <div className="flex items-center gap-3"> - <h2 className="text-lg font-semibold">Site-only tags</h2> - <button - type="button" - aria-label="add site-only tag" - onClick={() => { - setOwnRows((rs) => [ - ...rs, - { - id: "", - label: "", - groupLabel: "", - color: "", - order: "", - hidden: "", - rules: [], - }, - ]); - touch(); - }} - className="rounded border border-border px-2 py-1 text-xs hover:bg-muted" - > - + Add - </button> - </div> - {ownRows.length === 0 ? ( - <p className="text-xs text-muted-foreground"> - None. A site-only tag exists on this site and nowhere else. - </p> - ) : ( - <ul className="flex flex-col gap-3" aria-label="site-only tags"> - {ownRows.map((row, i) => ( + {globalTags.length === 0 ? ( + <p className="rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground"> + The corpus has no tags yet — define one on{" "} + <Link href="/tags" className="underline"> + Tags + </Link> + . + </p> + ) : ( + <ul className="flex flex-col gap-3" aria-label="tag overlays"> + {globalTags.map((g) => { + const row = overlays.find((o) => o.id === g.id)!; + return ( <li - key={`own-${i}`} - data-testid="site-only-tag" + key={g.id} + data-testid="tag-overlay" + data-tag={g.id} className="flex flex-col gap-2 rounded-lg border bg-card/60 px-3 py-3" > <div className="flex flex-wrap items-end gap-3"> + <span className="pb-1.5 font-mono text-sm">{g.id}</span> <label className="flex min-w-[9rem] flex-col gap-1 text-xs text-muted-foreground"> - Id + Label <input - value={row.id} - onChange={(e) => { - const id = e.target.value; - setOwnRows((rs) => - rs.map((r, j) => (j === i ? { ...r, id } : r)), - ); - touch(); - }} - aria-label="site-only tag id" - className="rounded border border-border bg-card px-2 py-1 font-mono text-sm text-foreground" + value={row.label} + onChange={(e) => patch(g.id, { label: e.target.value })} + placeholder={g.label} + aria-label={`label for ${g.id}`} + className="rounded border border-border bg-card px-2 py-1 text-sm text-foreground" /> </label> - <label className="flex min-w-[9rem] flex-col gap-1 text-xs text-muted-foreground"> - Label + <label className="flex min-w-[8rem] flex-col gap-1 text-xs text-muted-foreground"> + Group label <input - value={row.label} - onChange={(e) => { - const label = e.target.value; - setOwnRows((rs) => - rs.map((r, j) => (j === i ? { ...r, label } : r)), - ); - touch(); - }} - aria-label="site-only tag label" + value={row.groupLabel} + onChange={(e) => + patch(g.id, { groupLabel: e.target.value }) + } + placeholder={g.groupLabel ?? ""} + aria-label={`group label for ${g.id}`} className="rounded border border-border bg-card px-2 py-1 text-sm text-foreground" /> </label> - <button - type="button" - onClick={() => { - setOwnRows((rs) => rs.filter((_, j) => j !== i)); - touch(); - }} - aria-label={`remove site-only tag ${row.id || "new"}`} - className="ml-auto rounded border border-border px-2 py-1 text-xs hover:bg-muted" - > - × - </button> + <label className="flex w-24 flex-col gap-1 text-xs text-muted-foreground"> + Colour + <input + value={row.color} + onChange={(e) => patch(g.id, { color: e.target.value })} + placeholder={g.color ?? "#b48ead"} + aria-label={`colour for ${g.id}`} + className="rounded border border-border bg-card px-2 py-1 font-mono text-xs text-foreground" + /> + </label> + <label className="flex w-20 flex-col gap-1 text-xs text-muted-foreground"> + Order + <input + value={row.order} + onChange={(e) => patch(g.id, { order: e.target.value })} + placeholder={g.order === undefined ? "" : String(g.order)} + aria-label={`order for ${g.id}`} + inputMode="numeric" + className="rounded border border-border bg-card px-2 py-1 text-sm text-foreground" + /> + </label> + <label className="flex flex-col gap-1 text-xs text-muted-foreground"> + Visibility + <select + value={row.hidden} + onChange={(e) => + patch(g.id, { + hidden: e.target.value as OverlayRow["hidden"], + }) + } + aria-label={`visibility for ${g.id}`} + className="rounded border border-border bg-card px-2 py-1 text-sm text-foreground" + > + <option value=""> + Inherit ({g.hidden ? "hidden" : "shown"}) + </option> + <option value="false">Shown here</option> + <option value="true">Hidden here</option> + </select> + </label> </div> - <SiteRules - rows={row.rules} - tagId={row.id || `own-${i}`} - onChange={(rules) => { - setOwnRows((rs) => - rs.map((r, j) => (j === i ? { ...r, rules } : r)), - ); - touch(); - }} - /> + {(g.rules ?? []).length > 0 && ( + <p className="text-[11px] text-muted-foreground"> + Corpus rules:{" "} + {(g.rules ?? []) + .map( + (r) => + `${r.id} · ${r.kind} · /${r.pattern}/${r.enabled === false ? " (disabled)" : ""}`, + ) + .join(" · ")} + </p> + )} </li> - ))} - </ul> - )} - </section> - </div> - ); -} - -function SiteRules({ - rows, - tagId, - onChange, -}: { - rows: RuleRow[]; - tagId: string; - onChange: (rules: RuleRow[]) => void; -}) { - return ( - <div className="flex flex-col gap-2"> - {rows.map((rule) => ( - <div - key={rule.key} - data-testid="site-tag-rule" - className="flex flex-wrap items-end gap-2 rounded border border-border/60 bg-background/40 px-2 py-2" - > - <label className="flex w-24 flex-col gap-1 text-[11px] text-muted-foreground"> - Rule id - <input - value={rule.id} - onChange={(e) => - onChange( - rows.map((r) => - r.key === rule.key ? { ...r, id: e.target.value } : r, - ), - ) - } - aria-label={`site rule id for ${tagId}`} - 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) => - onChange( - rows.map((r) => - r.key === rule.key - ? { ...r, kind: e.target.value as CuratedTagRuleKind } - : r, - ), - ) - } - aria-label={`site rule kind for ${tagId}`} - className="rounded border border-border bg-card px-1.5 py-1 text-xs text-foreground" - > - {KINDS.map((k) => ( - <option key={k} value={k}> - {k} - </option> - ))} - </select> - </label> - <label className="flex min-w-[12rem] flex-1 flex-col gap-1 text-[11px] text-muted-foreground"> - Pattern - <input - value={rule.pattern} - onChange={(e) => - onChange( - rows.map((r) => - r.key === rule.key ? { ...r, pattern: e.target.value } : r, - ), - ) - } - aria-label={`site rule pattern for ${tagId}`} - className="rounded border border-border bg-card px-1.5 py-1 font-mono text-xs text-foreground" - /> - </label> - <label className="flex min-w-[10rem] flex-1 flex-col gap-1 text-[11px] text-muted-foreground"> - Channels - <input - value={rule.channelsText} - onChange={(e) => - onChange( - rows.map((r) => - r.key === rule.key - ? { ...r, channelsText: e.target.value } - : r, - ), - ) - } - aria-label={`site rule channels for ${tagId}`} - className="rounded border border-border bg-card px-1.5 py-1 font-mono text-xs text-foreground" - /> - </label> - <button - type="button" - onClick={() => onChange(rows.filter((r) => r.key !== rule.key))} - aria-label={`remove site rule ${rule.id} from ${tagId}`} - className="rounded border border-border px-1.5 py-1 text-xs hover:bg-muted" - > - × - </button> - </div> - ))} - <button - type="button" - onClick={() => - onChange([ - ...rows, - { - key: mkKey(), - id: `site${rows.length + 1}`, - kind: "metadata", - pattern: "", - enabled: true, - channelsText: "", - }, - ]) - } - aria-label={`add site rule to ${tagId}`} - className="self-start rounded border border-border px-2 py-1 text-xs hover:bg-muted" - > - + Add site rule - </button> + ); + })} + </ul> + )} </div> ); } diff --git a/editor/app/tags/actions.ts b/editor/app/tags/actions.ts @@ -198,12 +198,23 @@ export async function applyTagAssignmentsAction(input: { 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", - }); + // The store THROWS on a malformed ref or op — it is the one write path all + // three writers funnel through and refuses rather than storing junk — so the + // action turns that into the `{ ok: false, error }` every caller here already + // handles. + let changed: string[]; + try { + ({ changed } = applyTagAssignments(getPaths(), { + op: input.op, + tag, + videos: input.videos, + source: input.source?.trim() || "operator", + })); + } catch (e) { + return { ok: false, error: (e as Error).message }; + } revalidatePath("/tags"); - return { ok: true, changed }; + // `changed` is the keys that actually MOVED; re-pinning a pinned video is not + // a change and is not reported as one. + return { ok: true, changed: changed.length }; } diff --git a/editor/lib/tagsStore.ts b/editor/lib/tagsStore.ts @@ -4,183 +4,23 @@ // 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 }; -} +// It is a re-export, and that is the point: while common/lib/curatedTagsStore.ts +// was being written in parallel, this file held a temporary implementation with +// exactly these signatures, so the swap was one file rather than twenty imports +// to chase. Keep it that way — a second import path for the store is how two +// writers of one file start disagreeing about what it means. + +export { + emptyTagsConfig, + readGlobalTags, + writeGlobalTags, + readSiteTags, + writeSiteTags, + effectiveSiteTags, + applyTagAssignments, + assignmentFor, + type TagAssignmentOp, + type TagVideoRef, + type ApplyTagAssignmentsInput, + type ApplyTagAssignmentsResult, +} from "yt-dlp-transcript-common/lib/curatedTagsStore";