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:
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'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'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'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";