Archilyzer · Source

archilyzer

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

commit 3c7e40e62da2a8e7732bd91d19da797fcb385092
parent 21f6ad831df8ca418ae0d2ef65bb995268b95999
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 21 Sep 2026 13:12:07 -0400

tags S3.2: the per-site overlay tab

/sites/<id>/tags, beside Charts and Search aliases. It edits presentation —
label, group label, colour, order, visibility — and rules that fire only on this
site's build.

WHAT IT DOES NOT OFFER IS THE POINT. mergeTagDefs is a field-wise overlay that
APPENDS rules, so there is no control here that deletes a corpus rule, and none
that touches an assignment at all: an assignment is a fact about a video, not a
presentation choice. A site that wants a tag gone hides the chip. Corpus rules
are printed read-only beside the site's own so the difference is visible rather
than implied.

Visibility is a THREE-state select (inherit / shown / hidden) rather than a
checkbox, because a site that has said nothing about a tag must not start
writing `hidden: false` into its file — and an overlay row that says nothing at
all is dropped on save rather than written as a row of empty strings, which
would blank the corpus label through the merge.

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

Diffstat:
Meditor/app/sites/[siteId]/components/SiteTabs.tsx | 7++++++-
Aeditor/app/sites/[siteId]/tags/page.tsx | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/sites/components/SiteTagsClient.tsx | 536+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 593 insertions(+), 1 deletion(-)

diff --git a/editor/app/sites/[siteId]/components/SiteTabs.tsx b/editor/app/sites/[siteId]/components/SiteTabs.tsx @@ -3,7 +3,7 @@ import Link from "next/link"; import { useSelectedLayoutSegment } from "next/navigation"; -// Settings | Charts | Search aliases | Publish. +// Settings | Charts | Search aliases | Tags | Publish. // // A CLIENT component on purpose. Next 16 layouts are cached during navigation // and DO NOT RERENDER — they cannot read the pathname and would not re-evaluate @@ -27,6 +27,11 @@ export function SiteTabs({ siteId }: { siteId: string }) { label="Search aliases" /> <Tab + href={`/sites/${siteId}/tags`} + active={segment === "tags"} + label="Tags" + /> + <Tab href={`/sites/${siteId}/publish`} active={segment === "publish"} label="Publish" diff --git a/editor/app/sites/[siteId]/tags/page.tsx b/editor/app/sites/[siteId]/tags/page.tsx @@ -0,0 +1,51 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { readGlobalTags, readSiteTags } from "../../../../lib/tagsStore"; +import { SiteTagsClient } from "../../components/SiteTagsClient"; +import { getSiteCached } from "../lib/siteCache"; + +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. +// +// 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. +export default async function SiteTagsPage({ + params, +}: { + params: Promise<{ siteId: string }>; +}) { + const { siteId } = await params; + if (!getSiteCached(siteId)) notFound(); + const paths = getPaths(); + const globalConfig = readGlobalTags(paths); + const siteConfig = readSiteTags(paths, siteId); + return ( + <section className="flex flex-col gap-4"> + <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. + </p> + <SiteTagsClient + key={siteId} + siteId={siteId} + globalTags={globalConfig.tags} + siteTags={siteConfig.tags} + /> + </section> + ); +} diff --git a/editor/app/sites/components/SiteTagsClient.tsx b/editor/app/sites/components/SiteTagsClient.tsx @@ -0,0 +1,536 @@ +"use client"; + +// One site's overlay on the corpus vocabulary. +// +// Two lists, and the difference between them is the whole point: +// +// 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. +// +// 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. + +import { useMemo, useState, useTransition } from "react"; +import { + TAG_ID_RE, + type CuratedTagDef, + type CuratedTagRule, + type CuratedTagRuleKind, +} 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; + groupLabel: string; + color: string; + order: string; + // 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, + label: site?.label ?? "", + groupLabel: site?.groupLabel ?? "", + 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; +} + +export function SiteTagsClient({ + siteId, + globalTags, + siteTags, +}: { + siteId: string; + globalTags: CuratedTagDef[]; + siteTags: CuratedTagDef[]; +}) { + const globalIds = useMemo( + () => new Set(globalTags.map((t) => t.id)), + [globalTags], + ); + const [overlays, setOverlays] = useState<OverlayRow[]>(() => + globalTags.map((g) => + overlayRowFor( + g.id, + siteTags.find((s) => s.id === g.id), + ), + ), + ); + 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 = () => { + 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 })), + ]; + startSave(async () => { + const result = await saveSiteTagDefsAction(siteId, defs); + if (result.ok) { + setDirty(false); + setSaved(true); + } else { + setError(result.error); + } + }); + }; + + 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 items-center gap-3"> + <button + type="button" + onClick={save} + disabled={!dirty || saving} + aria-label="save site tags" + className="rounded border border-border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50" + > + {saving ? "Saving…" : "Save"} + </button> + {saved && !dirty && ( + <span className="text-xs text-success" data-testid="site-tags-saved"> + Saved + </span> + )} + {error && ( + <span role="status" className="text-xs text-warning"> + {error} + </span> + )} + </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) => ( + <li + key={`own-${i}`} + data-testid="site-only-tag" + 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"> + <label className="flex min-w-[9rem] flex-col gap-1 text-xs text-muted-foreground"> + Id + <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" + /> + </label> + <label className="flex min-w-[9rem] flex-col gap-1 text-xs text-muted-foreground"> + 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" + 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> + </div> + <SiteRules + rows={row.rules} + tagId={row.id || `own-${i}`} + onChange={(rules) => { + setOwnRows((rs) => + rs.map((r, j) => (j === i ? { ...r, rules } : r)), + ); + touch(); + }} + /> + </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> + </div> + ); +}