Archilyzer · Source

archilyzer

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

commit 05877ffb6d9756dbbb8173225125dd05bf403794
parent 09d0f4decb5a8202c87c33dc4b5f5e0c8fafc1c9
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu, 24 Sep 2026 22:51:56 -0400

Merge one-core/r5-exports — release 5 slice X: per-site transcriptDownloads (default on), hub key on homepage.json, archives-off coverage

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

Diffstat:
MSITE.md | 7+++++++
Mcommon/components/PlayerProvider.tsx | 24++++++++++++++++++++++++
Mcommon/components/TranscriptModal.tsx | 146++++++++++++++++++++++++++++++++++++++++++-------------------------------------
Acommon/lib/homepage.test.ts | 46++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/lib/homepage.ts | 7+++++++
Mcommon/lib/siteSchema.test.ts | 13++++++++++++-
Mcommon/lib/siteSchema.ts | 7+++++++
Meditor/CHANGELOG.md | 1+
Meditor/app/sites/actions.ts | 3+++
Meditor/app/sites/components/HomepageConfigForm.tsx | 15+++++++++++++++
Meditor/app/sites/components/SiteForm.tsx | 16++++++++++++++++
Meditor/app/sites/lib/homepageActions.ts | 3+++
Meditor/e2e/sites-crud.spec.ts | 95+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/app/(workspace)/SiteWorkspace.tsx | 5++++-
Mexport/app/(workspace)/ask/page.tsx | 2+-
Mexport/app/(workspace)/layout.tsx | 9++++++++-
Mexport/app/(workspace)/page.tsx | 4+++-
Mexport/app/ask/AskHub.tsx | 11+++++++++--
Mexport/app/components/hub/HubHome.tsx | 11+++++++++--
Mexport/app/duplicates/page.tsx | 5++++-
Mexport/app/lib/site.ts | 2++
Aexport/e2e-hub/transcript-downloads.spec.ts | 70++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/e2e/archives-off.spec.ts | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/e2e/transcript-downloads.spec.ts | 85+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mexport/playwright.config.ts | 14++++++++++++++
Mexport/playwright.hub.config.ts | 6++++++
Mplans/release-5.md | 120+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----
Mplans/site-exports-off.md | 5+++++
Mplans/tools/phase3-files-numbers.ts | 5+++--
29 files changed, 726 insertions(+), 85 deletions(-)

diff --git a/SITE.md b/SITE.md @@ -26,6 +26,7 @@ Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/f | [`pwa`](#pwa) | `false` | | [`archives`](#archives) | `true` | | [`duplicates`](#duplicates) | `true` | +| [`transcriptDownloads`](#transcriptdownloads) | `true` | | [`archiveMaxBytes`](#archivemaxbytes) | absent | | [`hubUrl`](#huburl) | absent | @@ -185,6 +186,12 @@ Whether this site publishes the Duplicates page (and its header link). Opt-OUT: Default: `true` +## `transcriptDownloads` + +Whether a visitor gets the per-video export controls in the transcript modal: the Download menu (txt / srt / json), Copy MD and Copy download command. Opt-OUT: absent/true = on, only an explicit `false` hides them. The machine contract (`/corpus.json`, manifests, shards, `llms.txt`) is served either way. The editor always shows them. + +Default: `true` + ## `archiveMaxBytes` Per-site served-file size cap in bytes: any archive larger is dropped from what is served and flagged in the manifest, so a capped host (Cloudflare Pages: 25 MB) will not reject the deploy. 0 = no cap. Absent = the global default. Negative or non-numeric values are dropped. diff --git a/common/components/PlayerProvider.tsx b/common/components/PlayerProvider.tsx @@ -109,8 +109,20 @@ type ClipState = { end: number | null; }; +// Per-mount switches for the modal's optional surfaces. The export site derives +// them from its site.json (`transcriptDownloads`); the editor mounts nothing +// and every host that passes nothing keeps everything on. +export type PlayerFeatures = { + // The three per-video export controls: Copy download command, the Download + // menu (txt / srt / json) and Copy MD. + transcriptDownloads: boolean; +}; + +export const DEFAULT_PLAYER_FEATURES: PlayerFeatures = { transcriptDownloads: true }; + type PlayerState = { activeSlug: string | null; + features: PlayerFeatures; data: TranscriptData | null; status: Status; displayMode: DisplayMode; @@ -361,8 +373,10 @@ function coreReducer(state: Core, action: CoreAction): Core { export function PlayerProvider({ children, + features = DEFAULT_PLAYER_FEATURES, }: { children: React.ReactNode; + features?: PlayerFeatures; }) { const { v: urlSlug, t: urlTime, vm: urlVm } = useUrlParams(); const [core, dispatch] = useReducer(coreReducer, INITIAL_CORE); @@ -858,9 +872,18 @@ export function PlayerProvider({ } }; + // Keyed on the flag, not the object: a mount site passing an inline + // `features={{…}}` must not rebuild the context value on every render. + const transcriptDownloads = features.transcriptDownloads; + const stableFeatures: PlayerFeatures = useMemo( + () => ({ transcriptDownloads }), + [transcriptDownloads], + ); + const value: PlayerState = useMemo( () => ({ activeSlug, + features: stableFeatures, data, status, displayMode, @@ -890,6 +913,7 @@ export function PlayerProvider({ }), [ activeSlug, + stableFeatures, data, status, displayMode, diff --git a/common/components/TranscriptModal.tsx b/common/components/TranscriptModal.tsx @@ -72,7 +72,11 @@ export default function TranscriptModal() { copyTranscriptMarkdown, openInPreservetube, seekTo, + features, } = usePlayer(); + // Off on a site whose site.json sets `transcriptDownloads: false`: the three + // per-video export controls are not rendered at all (Share stays). + const showDownloads = features.transcriptDownloads; const { currentTime } = usePlayerTime(); const scrollRef = useRef<HTMLDivElement | null>(null); const [copied, setCopied] = useState(false); @@ -400,26 +404,28 @@ export default function TranscriptModal() { clipEnd={clipEnd} onClear={clearClip} /> - <ControlButton - title={ - canDownload - ? copied - ? "Copied!" - : "Copy yt-dlp download command for this clip" - : "Mark both clip start and end first" - } - onClick={onCopy} - icon={ - copied ? ( - <CheckIcon className={iconSize} /> - ) : ( - <TerminalIcon className={iconSize} /> - ) - } - label="yt-dlp" - char={copied ? "\u2713" : "\u2193"} - disabled={!canDownload} - /> + {showDownloads && ( + <ControlButton + title={ + canDownload + ? copied + ? "Copied!" + : "Copy yt-dlp download command for this clip" + : "Mark both clip start and end first" + } + onClick={onCopy} + icon={ + copied ? ( + <CheckIcon className={iconSize} /> + ) : ( + <TerminalIcon className={iconSize} /> + ) + } + label="yt-dlp" + char={copied ? "\u2713" : "\u2193"} + disabled={!canDownload} + /> + )} <Divider /> @@ -446,59 +452,63 @@ export default function TranscriptModal() { {/* A portalled menu, because the trigger now lives inside a horizontally scrolling strip: an absolutely-positioned panel would be clipped by that scroller. */} - <DropdownMenu open={downloadOpen} onOpenChange={setDownloadOpen}> - <DropdownMenuTrigger asChild> + {showDownloads && ( + <> + <DropdownMenu open={downloadOpen} onOpenChange={setDownloadOpen}> + <DropdownMenuTrigger asChild> + <ControlButton + title={ + canDownloadFile + ? `Download this ${isChat ? "live chat" : "transcript"} as a file` + : "Nothing to download yet" + } + icon={<DownloadIcon className={iconSize} />} + label="Download" + char="\u2913" + disabled={!canDownloadFile} + highlight={downloadOpen} + /> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="z-[70] min-w-0"> + {(["txt", "srt", "json"] as const).map((fmt) => ( + <DropdownMenuItem + key={fmt} + onSelect={() => downloadTranscriptFile(fmt)} + className="font-mono text-xs uppercase tracking-wide" + > + .{fmt} + </DropdownMenuItem> + ))} + </DropdownMenuContent> + </DropdownMenu> <ControlButton title={ - canDownloadFile - ? `Download this ${isChat ? "live chat" : "transcript"} as a file` - : "Nothing to download yet" + !canDownloadFile + ? "Nothing to copy yet" + : mdCopied + ? "Copied!" + : `Copy this ${isChat ? "live chat" : "transcript"} as Markdown (for AI)` } - icon={<DownloadIcon className={iconSize} />} - label="Download" - char="\u2913" + onClick={async () => { + const ok = await copyTranscriptMarkdown(); + if (!ok) return; + setMdCopied(true); + if (mdResetRef.current) window.clearTimeout(mdResetRef.current); + mdResetRef.current = window.setTimeout(() => setMdCopied(false), 1500); + }} + icon={ + mdCopied ? ( + <CheckIcon className={iconSize} /> + ) : ( + <ClipboardIcon className={iconSize} /> + ) + } + label="Copy MD" + char={mdCopied ? "\u2713" : "\u{1F4CB}"} disabled={!canDownloadFile} - highlight={downloadOpen} /> - </DropdownMenuTrigger> - <DropdownMenuContent align="end" className="z-[70] min-w-0"> - {(["txt", "srt", "json"] as const).map((fmt) => ( - <DropdownMenuItem - key={fmt} - onSelect={() => downloadTranscriptFile(fmt)} - className="font-mono text-xs uppercase tracking-wide" - > - .{fmt} - </DropdownMenuItem> - ))} - </DropdownMenuContent> - </DropdownMenu> - <ControlButton - title={ - !canDownloadFile - ? "Nothing to copy yet" - : mdCopied - ? "Copied!" - : `Copy this ${isChat ? "live chat" : "transcript"} as Markdown (for AI)` - } - onClick={async () => { - const ok = await copyTranscriptMarkdown(); - if (!ok) return; - setMdCopied(true); - if (mdResetRef.current) window.clearTimeout(mdResetRef.current); - mdResetRef.current = window.setTimeout(() => setMdCopied(false), 1500); - }} - icon={ - mdCopied ? ( - <CheckIcon className={iconSize} /> - ) : ( - <ClipboardIcon className={iconSize} /> - ) - } - label="Copy MD" - char={mdCopied ? "\u2713" : "\u{1F4CB}"} - disabled={!canDownloadFile} - /> + </> + )} {data?.platform === "youtube" && ( <ControlButton title="Open in Preservetube" diff --git a/common/lib/homepage.test.ts b/common/lib/homepage.test.ts @@ -0,0 +1,46 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { mkdtemp, readFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import type { Paths } from "./paths"; +import { getHomepageConfig, parseHomepageConfig, writeHomepageConfig } from "./homepage"; + +// homepage.json's `transcriptDownloads` follows site.json's: opt-out, read as +// off only on an explicit false, written only when false. + +test("transcriptDownloads is off only when explicitly false", () => { + for (const v of [undefined, true, 0, "false", null]) { + assert.equal( + parseHomepageConfig({ transcriptDownloads: v }).transcriptDownloads, + undefined, + String(v), + ); + } + assert.equal(parseHomepageConfig({ transcriptDownloads: false }).transcriptDownloads, false); + assert.equal(parseHomepageConfig({}).transcriptDownloads, undefined); +}); + +function scratchPaths(dir: string): Paths { + return { homepageConfigFile: path.join(dir, "_homepage", "homepage.json") } as Paths; +} + +test("writeHomepageConfig persists transcriptDownloads only when false, and it round-trips", async () => { + const paths = scratchPaths(await mkdtemp(path.join(os.tmpdir(), "homepage-"))); + const base = parseHomepageConfig({ siteTitle: "Hub" }); + + await writeHomepageConfig({ ...base, transcriptDownloads: false }, paths); + let disk = JSON.parse(await readFile(paths.homepageConfigFile, "utf8")); + assert.equal(disk.transcriptDownloads, false); + assert.equal(getHomepageConfig(paths).transcriptDownloads, false); + + await writeHomepageConfig({ ...base, transcriptDownloads: undefined }, paths); + disk = JSON.parse(await readFile(paths.homepageConfigFile, "utf8")); + assert.equal("transcriptDownloads" in disk, false); + assert.equal(getHomepageConfig(paths).transcriptDownloads, undefined); + + // A true is the default, so it is not written either. + await writeHomepageConfig({ ...base, transcriptDownloads: true }, paths); + disk = JSON.parse(await readFile(paths.homepageConfigFile, "utf8")); + assert.equal("transcriptDownloads" in disk, false); +}); diff --git a/common/lib/homepage.ts b/common/lib/homepage.ts @@ -32,6 +32,11 @@ export type HomepageConfig = { siteUrl?: string; // Cloudflare Pages project the hub deploys to. cloudflareProject?: string; + // The transcript modal's per-video export controls (Download menu, Copy MD, + // Copy download command) on the hub's Browse and Ask pages — the same switch + // as site.json `transcriptDownloads`. Opt-OUT: absent = on; only an explicit + // false is read, and only false is written. + transcriptDownloads?: boolean; }; // Neutral defaults for an unconfigured install. These are the PRODUCT strings @@ -75,6 +80,7 @@ export function parseHomepageConfig(raw: unknown): HomepageConfig { typeof r.cloudflareProject === "string" && r.cloudflareProject.trim() ? r.cloudflareProject.trim() : undefined, + transcriptDownloads: r.transcriptDownloads === false ? false : undefined, }; } @@ -124,6 +130,7 @@ export async function writeHomepageConfig( ...(config.cloudflareProject && config.cloudflareProject.trim() ? { cloudflareProject: config.cloudflareProject.trim() } : {}), + ...(config.transcriptDownloads === false ? { transcriptDownloads: false } : {}), }; // mkdir: the parent of homepageConfigFile IS homepageDir (lib/paths.ts). await writeJsonAtomic(paths.homepageConfigFile, merged, { mkdir: true }); diff --git a/common/lib/siteSchema.test.ts b/common/lib/siteSchema.test.ts @@ -58,6 +58,7 @@ test("empty, null, [] and a number all read as the defaults, every key emitted", assert.equal(want.defaultGroupId, "default"); assert.equal(want.archives, true); assert.equal(want.duplicates, true); + assert.equal(want.transcriptDownloads, true); assert.equal(want.pwa, false); assert.deepEqual(want.relatedSites, []); assert.equal(want.socialLinks, undefined); @@ -104,13 +105,19 @@ test("socialLinks: absent inherits (undefined), [] overrides", () => { assert.equal(parseSite("s", { socialLinks: "x" }).socialLinks, undefined); }); -test("archives / duplicates are off only when explicitly false", () => { +test("archives / duplicates / transcriptDownloads are off only when explicitly false", () => { for (const v of [undefined, true, 0, "false", null]) { assert.equal(parseSite("s", { archives: v }).archives, true, String(v)); assert.equal(parseSite("s", { duplicates: v }).duplicates, true, String(v)); + assert.equal( + parseSite("s", { transcriptDownloads: v }).transcriptDownloads, + true, + String(v), + ); } assert.equal(parseSite("s", { archives: false }).archives, false); assert.equal(parseSite("s", { duplicates: false }).duplicates, false); + assert.equal(parseSite("s", { transcriptDownloads: false }).transcriptDownloads, false); }); test("siteToDisk persists only the non-defaults", () => { @@ -129,6 +136,7 @@ test("siteToDisk persists only the non-defaults", () => { parseSite("s", { archives: false, duplicates: false, + transcriptDownloads: false, pwa: true, archiveMaxBytes: 1024.9, siteUrl: "https://a.example/", @@ -141,6 +149,7 @@ test("siteToDisk persists only the non-defaults", () => { ); assert.equal(full.archives, false); assert.equal(full.duplicates, false); + assert.equal(full.transcriptDownloads, false); assert.equal(full.pwa, true); assert.equal(full.archiveMaxBytes, 1024); assert.equal(full.siteUrl, "https://a.example"); @@ -177,6 +186,7 @@ function fixtures(): Array<[string, unknown]> { archives: false, archiveMaxBytes: -1, duplicates: false, + transcriptDownloads: false, hubUrl: "ftp://nope", bogus: true, }, @@ -225,5 +235,6 @@ test("writeSite → getSite round-trips, and the file holds only non-defaults", const disk = JSON.parse(await readFile(siteConfigFile(paths, "s"), "utf8")); assert.equal(disk.archives, false); assert.equal("duplicates" in disk, false); + assert.equal("transcriptDownloads" in disk, false); assert.equal("pwa" in disk, false); }); diff --git a/common/lib/siteSchema.ts b/common/lib/siteSchema.ts @@ -85,6 +85,7 @@ export type Site = { pwa?: boolean; archives?: boolean; duplicates?: boolean; + transcriptDownloads?: boolean; archiveMaxBytes?: number; hubUrl?: string; }; @@ -118,6 +119,8 @@ export const SITE_FIELD_DOCS: FieldDocs<Site> = { "Whether the site build generates downloadable transcript/live-chat archive zips (and links them on the Downloads page). Opt-OUT: absent/true = on, only an explicit `false` disables. Also gated by the global setting and a per-build flag.", duplicates: "Whether this site publishes the Duplicates page (and its header link). Opt-OUT: absent/true = on, only an explicit `false` hides it. Even when on, the page auto-hides when the site has no in-scope duplicate clusters.", + transcriptDownloads: + "Whether a visitor gets the per-video export controls in the transcript modal: the Download menu (txt / srt / json), Copy MD and Copy download command. Opt-OUT: absent/true = on, only an explicit `false` hides them. The machine contract (`/corpus.json`, manifests, shards, `llms.txt`) is served either way. The editor always shows them.", archiveMaxBytes: "Per-site served-file size cap in bytes: any archive larger is dropped from what is served and flagged in the manifest, so a capped host (Cloudflare Pages: 25 MB) will not reject the deploy. 0 = no cap. Absent = the global default. Negative or non-numeric values are dropped.", hubUrl: @@ -239,6 +242,9 @@ export const siteFieldsSchema = z.object({ // Opt-out: only an explicit false disables. Absent/true stays on. archives: settingsField((v): boolean => v !== false).describe(d.archives), duplicates: settingsField((v): boolean => v !== false).describe(d.duplicates), + transcriptDownloads: settingsField((v): boolean => v !== false).describe( + d.transcriptDownloads, + ), archiveMaxBytes: settingsField(archiveMaxBytesOf).describe(d.archiveMaxBytes), hubUrl: settingsField(parseSiteUrl).describe(d.hubUrl), }); @@ -319,6 +325,7 @@ export function siteToDisk(site: Site): Site { // Persist only the non-default: archives is on unless explicitly disabled. ...(site.archives === false ? { archives: false } : {}), ...(site.duplicates === false ? { duplicates: false } : {}), + ...(site.transcriptDownloads === false ? { transcriptDownloads: false } : {}), ...(archiveMaxBytes !== undefined ? { archiveMaxBytes } : {}), ...(hubUrl ? { hubUrl } : {}), }; diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -2,6 +2,7 @@ ## [Unreleased] - **Rumble works again, and a Rumble full sweep that gets rate-limited no longer fails the sync.** Every Rumble request had started coming back 403 from Cloudflare unless yt-dlp presents a browser fingerprint (yt-dlp #17496), so Rumble downloads failed and a Rumble channel could not even be added. Every yt-dlp run for a Rumble channel — sync, download, metadata scan, availability check, the clip-window fetch and the new-channel probe — now passes `--impersonate chrome --sleep-requests 1`, from one table in the code; a channel's own extra yt-dlp arguments still come last and still win. Separately, a full sweep that hits HTTP 429 part-way through the listing used to fail the whole sync and try again on the next one, so a large channel (The Quartering on Rumble, 44 days) never synced at all. What it read is now treated as *incomplete* — not a listing, so nothing is flagged missing and the stored playlist is untouched: the job records the platform's rate-limit cooldown, says "sweep incomplete: 429 at page N of the listing, M entries" in its log, does the ordinary newest-first sync instead, and succeeds. Syncs for that platform are then refused until its cooldown ends, and the full sweep is tried again after that. Any other yt-dlp failure still fails the sync as before. +- **A site can turn off its visitors' per-video transcript downloads.** The transcript viewer on a published site has always offered three ways to take a video's text away: a **Download** menu (txt, srt, json), **Copy MD**, and **Copy download command** (a `yt-dlp` line for a marked clip). A site's settings form now has a checkbox for them, *Per-video transcript downloads*, beside the archive zips one. Unticked, the site's next build shows none of the three; **Share** and the clip marks stay. It is on by default, so a site nobody touches is unchanged, and the file stores `"transcriptDownloads": false` only when it is off (`SITE.md` has the key). The site's machine contract (`/corpus.json`, `llms.txt`, the manifests and shards the MCP server and report-to-video read) is published either way. The hub follows the same switch: the hub form on **Sites** has the same checkbox, stored as `"transcriptDownloads": false` in the hub's `homepage.json`, and it hides the three controls on the hub's Browse and Ask pages. The editor's own video pages are unaffected. - **Channel rows no longer scroll over a group's controls on `/channels`.** Scrolled down and to the right, the pinned Slug column of every row painted over the pinned group header and its five station buttons (Sync, Download, Transcribe, Digest and the speaker lane), and took the clicks. The pinned Slug cell and the group header sat at the same stacking level, and the later rows won. The rack now has one named layer order, kept in one file: the Advanced panel, then the column header, then the group header, then the pinned checkbox and Slug cells. Nothing ties any more. The screenshot audit found four more problems, fixed as well. A group header's name and buttons now stay on screen however far the columns scroll across (they used to scroll off to the left). An Advanced panel opened near the bottom or the right edge scrolls itself into view instead of being cut off. The rule above a pinned group header moves with it instead of leaving a gap the rows showed through. On a phone, the column header no longer paints over the selection bar pinned to the bottom of the screen. - **A group's Transcribe works for YouTube channels, and it counts what it queues.** The station used to be disabled for every `youtube`-handling channel with the message "a youtube-handling channel never runs whisper". That was wrong. A YouTube video that came down with no captions is transcription work like any other, and the automatic runner already treats it that way. Transcribe now counts two kinds of video, after the usual members-only, deleted and private exclusions: downloaded videos with no transcript at all, and downloaded videos whose only transcript is YouTube's auto-captions. Pressing it queues exactly those videos, by id, as the channel page does: up to two jobs per channel on the transcription queue. A video downloaded before it went private, members-only or deleted is no longer transcribed by the group button, because it was never in the figure. Pressing it again while either job runs says *already running*. The wording names no method ("…has downloaded audio to transcribe", "…each takes minutes"). **This figure can now be higher than the Transcription band in the same rack on channels with many auto-caption-only videos.** The band counts videos with no transcript at all, while the station counts everything its button would queue. That is intended. - **The editor's atomic JSON, text and binary writes now go one way, and a failed write no longer leaves a temp file behind.** Nineteen JSON write sites and seven text and binary ones each wrote `<file>.tmp-<pid>` and renamed it over the original — the channel roster, maybe-missing and metadata-scan records, the scheduler and auto-queue state, worker defaults, widget presets, the homepage config, relocation markers, shard configs, the duplicate and media-scan reports and their review decisions, the saved-video backup manifest, both cue normalizers, the playlist, the failed-transcriptions list, the X cookie jar, a site's CHANGELOG cut, a saved video copied into its store across drives, and the video page's VTT promote and remark. They now all go through one writer (`common/lib/jsonFile-server.ts`), which gives every write its own temp name and queues writes to the same file one behind another, so two jobs touching one channel's roster at once cannot trip over each other's temp file. A write that fails now removes its temp: the live `.auto-queue/` holds 175 `state.json.tmp-…` files (173 of them empty) from the day `/home` filled up (2026-09-11), each one a failed write the old code left behind; nothing deletes those old ones for you — `find transcripts -name '*.tmp-*'` lists them. Four temp names stay, on purpose: the export build's two page writers `buildIndex.ts` (a streaming page writer) and `buildStats.ts` (a hand-joined array) — folding them is a restructuring, not a swap — and `transcode.ts` / `transcribeOne.ts` name the output file ffmpeg or the transcription app writes, which is not our write to fold. No file's contents change — every writer puts the same bytes on disk it did before, measured over the live corpus. The cookie jar is still created readable only by you. diff --git a/editor/app/sites/actions.ts b/editor/app/sites/actions.ts @@ -76,6 +76,8 @@ export async function saveSiteAction( const archives = formData.get("archives") === "on"; // Duplicates default on, same opt-out idiom as archives. const duplicates = formData.get("duplicates") === "on"; + // Per-video transcript downloads default on, same opt-out idiom. + const transcriptDownloads = formData.get("transcriptDownloads") === "on"; const archiveMaxMBRaw = String(formData.get("archiveMaxMB") ?? "").trim(); let archiveMaxBytes: number | undefined; if (archiveMaxMBRaw) { @@ -181,6 +183,7 @@ export async function saveSiteAction( ...(pwa ? { pwa: true } : {}), ...(archives ? {} : { archives: false }), ...(duplicates ? {} : { duplicates: false }), + ...(transcriptDownloads ? {} : { transcriptDownloads: false }), ...(archiveMaxBytes !== undefined ? { archiveMaxBytes } : {}), ...(relatedSites.length > 0 ? { relatedSites } : {}), }; diff --git a/editor/app/sites/components/HomepageConfigForm.tsx b/editor/app/sites/components/HomepageConfigForm.tsx @@ -61,6 +61,21 @@ export function HomepageConfigForm({ config }: { config: HomepageConfig }) { defaultValue={config.cloudflareProject ?? ""} /> </label> + <label className="flex items-center gap-2 text-sm"> + <input + type="checkbox" + name="transcriptDownloads" + defaultChecked={config.transcriptDownloads !== false} + className="accent-brand" + /> + Per-video transcript downloads (Download menu, Copy Markdown, Copy download + command) + </label> + <p className="-mt-2 text-xs text-muted-foreground"> + On by default: the hub&apos;s transcript viewer offers the transcript as a + file, as Markdown, and a yt-dlp command for a marked clip, for every + archive it federates. Turn off to hide all three on the hub. + </p> <div className="flex items-center gap-3"> <button type="submit" diff --git a/editor/app/sites/components/SiteForm.tsx b/editor/app/sites/components/SiteForm.tsx @@ -293,6 +293,22 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { <label className="flex items-center gap-2 text-sm"> <input type="checkbox" + name="transcriptDownloads" + defaultChecked={initial.transcriptDownloads !== false} + className="accent-brand" + /> + Per-video transcript downloads (Download menu, Copy Markdown, Copy download + command) + </label> + <p className="-mt-2 text-xs text-muted-foreground"> + On by default: a visitor&apos;s transcript viewer offers the transcript as a + txt/srt/json file, as Markdown, and a yt-dlp command for a marked clip. + Turn off to hide all three on this site. The machine contract + (corpus.json, manifests, shards) is published either way. + </p> + <label className="flex items-center gap-2 text-sm"> + <input + type="checkbox" name="duplicates" defaultChecked={initial.duplicates !== false} className="accent-brand" diff --git a/editor/app/sites/lib/homepageActions.ts b/editor/app/sites/lib/homepageActions.ts @@ -41,6 +41,9 @@ export async function saveHomepageConfigAction( homeTagline: String(formData.get("homeTagline") ?? "").trim(), siteUrl: parseHomepageUrl(siteUrlRaw), cloudflareProject: cloudflareProject || undefined, + // Default on, the site form's opt-out idiom: unticked → false → persisted. + transcriptDownloads: + formData.get("transcriptDownloads") === "on" ? undefined : false, }; try { await writeHomepageConfig(next, paths); diff --git a/editor/e2e/sites-crud.spec.ts b/editor/e2e/sites-crud.spec.ts @@ -290,6 +290,101 @@ test("inline-chips flag round-trips; seeded default group has it on", async ({ await expect(chipToggles.nth(1)).not.toBeChecked(); }); +test("archives + per-video transcript downloads opt-outs round-trip", async ({ + page, +}) => { + await resetData("empty"); + await writeSite("optout", { siteTitle: "Opt Out" }); + + type OptOutSiteFile = { archives?: boolean; transcriptDownloads?: boolean }; + const file = "test-transcripts/sites/optout/site.json"; + const archives = page.getByRole("checkbox", { + name: /generate downloadable archive zips/i, + }); + const downloads = page.getByRole("checkbox", { + name: /per-video transcript downloads/i, + }); + const save = async () => { + await page.getByRole("button", { name: /save site/i }).click(); + await expect( + page.getByRole("status").filter({ hasText: "Saved" }), + ).toBeVisible(); + }; + + // Both default ON: absent from the file, ticked in the form. + await page.goto("/sites/optout"); + await expect(archives).toBeChecked(); + await expect(downloads).toBeChecked(); + + await archives.uncheck(); + await downloads.uncheck(); + await save(); + // Poll: the "Saved" status and the on-disk write can land slightly apart. + await expect(async () => { + const site = await readJson<OptOutSiteFile>(file); + expect(site.archives).toBe(false); + expect(site.transcriptDownloads).toBe(false); + }).toPass({ timeout: 10_000 }); + + await page.goto("/sites/optout"); + await expect(archives).not.toBeChecked(); + await expect(downloads).not.toBeChecked(); + + // Ticking back on removes the key — only the non-default is persisted. + await archives.check(); + await downloads.check(); + await save(); + await expect(async () => { + const site = await readJson<OptOutSiteFile>(file); + expect("archives" in site).toBe(false); + expect("transcriptDownloads" in site).toBe(false); + }).toPass({ timeout: 10_000 }); + + await page.goto("/sites/optout"); + await expect(archives).toBeChecked(); + await expect(downloads).toBeChecked(); +}); + +test("the hub form's per-video transcript downloads opt-out round-trips to homepage.json", async ({ + page, +}) => { + await resetData("empty"); + type HomepageFile = { siteTitle?: string; transcriptDownloads?: boolean }; + const file = "test-transcripts/sites/_homepage/homepage.json"; + // The hub form is the one on /sites whose submit is "Save config". + const form = page + .locator("form") + .filter({ has: page.getByRole("button", { name: "Save config" }) }); + const downloads = form.getByRole("checkbox", { + name: /per-video transcript downloads/i, + }); + const save = async () => { + await form.getByRole("button", { name: "Save config" }).click(); + await expect(form.getByText("Saved.")).toBeVisible(); + }; + + await page.goto("/sites"); + await expect(downloads).toBeChecked(); + await downloads.uncheck(); + await save(); + await expect(async () => { + const hub = await readJson<HomepageFile>(file); + expect(hub.transcriptDownloads).toBe(false); + }).toPass({ timeout: 10_000 }); + + await page.goto("/sites"); + await expect(downloads).not.toBeChecked(); + await downloads.check(); + await save(); + await expect(async () => { + const hub = await readJson<HomepageFile>(file); + expect("transcriptDownloads" in hub).toBe(false); + }).toPass({ timeout: 10_000 }); + + await page.goto("/sites"); + await expect(downloads).toBeChecked(); +}); + test("first-run migrate button appears only when no sites exist", async ({ page, }) => { diff --git a/export/app/(workspace)/SiteWorkspace.tsx b/export/app/(workspace)/SiteWorkspace.tsx @@ -21,11 +21,14 @@ import WorkspaceNav from "./WorkspaceNav"; export default function SiteWorkspace({ children, + transcriptDownloads, }: { children: React.ReactNode; + // From the layout's currentSite() — see WorkspaceLayout. + transcriptDownloads: boolean; }) { return ( - <PlayerProvider> + <PlayerProvider features={{ transcriptDownloads }}> <SingleSiteDataProvider> <SearchSessionProvider> {/* The view provider wraps BOTH: the [Search|Chat|Split] control is diff --git a/export/app/(workspace)/ask/page.tsx b/export/app/(workspace)/ask/page.tsx @@ -45,7 +45,7 @@ export default function AskPage() { (WorkspaceView), so this route renders only its intro header — the shell slots it above the chat. Hub mode bypasses the shell, so AskHub renders here with its own MultiSiteDataProvider wiring. */} - {isHub && <AskHub />} + {isHub && <AskHub transcriptDownloads={site.transcriptDownloads !== false} />} </div> ); } diff --git a/export/app/(workspace)/layout.tsx b/export/app/(workspace)/layout.tsx @@ -1,4 +1,5 @@ import { instanceMode } from "../lib/mode"; +import { currentSite } from "../lib/site"; import SiteWorkspace from "./SiteWorkspace"; // Shared shell for the two workspace routes — `/` (results) and `/ask` (chat). @@ -14,5 +15,11 @@ export default function WorkspaceLayout({ children: React.ReactNode; }) { if (instanceMode() === "hub") return <>{children}</>; - return <SiteWorkspace>{children}</SiteWorkspace>; + // site.json `transcriptDownloads: false` hides the transcript modal's + // per-video export controls (absent = on). + return ( + <SiteWorkspace transcriptDownloads={currentSite().transcriptDownloads !== false}> + {children} + </SiteWorkspace> + ); } diff --git a/export/app/(workspace)/page.tsx b/export/app/(workspace)/page.tsx @@ -9,7 +9,9 @@ export default async function Home() { // summaries manifest (there is none), so branch before countTranscripts(). // The workspace layout passes hub children straight through, so <HubHome/> // stays fully self-contained (its own PlayerProvider + MultiSiteDataProvider). - if (instanceMode() === "hub") return <HubHome />; + if (instanceMode() === "hub") { + return <HubHome transcriptDownloads={currentSite().transcriptDownloads !== false} />; + } const settings = currentSite(); const count = await countTranscripts(); diff --git a/export/app/ask/AskHub.tsx b/export/app/ask/AskHub.tsx @@ -16,7 +16,14 @@ import { SearchSessionProvider } from "yt-dlp-transcript-common/components/Searc import { useRegistry } from "yt-dlp-transcript-common/components/siteRegistry"; import AskChat from "./AskChat"; -export default function AskHub() { +// `transcriptDownloads` comes from the server parent's currentSite() (a client +// component cannot read site.json): false hides the modal's per-video export +// controls. +export default function AskHub({ + transcriptDownloads = true, +}: { + transcriptDownloads?: boolean; +}) { const { sites } = useRegistry(); const federated = useMemo<FederatedSite[]>( () => @@ -39,7 +46,7 @@ export default function AskHub() { // took the whole route with it — which is why e2e:2origin was red. The fix is // the stack, never an opt-out of prerendering. return ( - <PlayerProvider> + <PlayerProvider features={{ transcriptDownloads }}> <MultiSiteDataProvider sites={federated}> <SearchSessionProvider> <AskChat /> diff --git a/export/app/components/hub/HubHome.tsx b/export/app/components/hub/HubHome.tsx @@ -19,7 +19,14 @@ import ArchiveShelf from "./ArchiveShelf"; import HubStats from "./HubStats"; import HubOfflineManager from "./HubOfflineManager"; -export default function HubHome() { +// `transcriptDownloads` comes from the server parent's currentSite() (a client +// component cannot read site.json): false hides the modal's per-video export +// controls. +export default function HubHome({ + transcriptDownloads = true, +}: { + transcriptDownloads?: boolean; +}) { const { sites } = useRegistry(); // Carry each site's accent through to the merged search for provenance. @@ -34,7 +41,7 @@ export default function HubHome() { ); return ( - <PlayerProvider> + <PlayerProvider features={{ transcriptDownloads }}> <div className="flex flex-col gap-6"> <ArchiveShelf /> <MultiSiteDataProvider sites={federated}> diff --git a/export/app/duplicates/page.tsx b/export/app/duplicates/page.tsx @@ -2,13 +2,16 @@ import type { Metadata } from "next"; import { PlayerProvider } from "yt-dlp-transcript-common/components/PlayerProvider"; import TranscriptModal from "yt-dlp-transcript-common/components/TranscriptModal"; import PostModal from "yt-dlp-transcript-common/components/PostModal"; +import { currentSite } from "../lib/site"; import { DuplicatesClient } from "./DuplicatesClient"; export const metadata: Metadata = { title: "Duplicates" }; export default function DuplicatesPage() { return ( - <PlayerProvider> + <PlayerProvider + features={{ transcriptDownloads: currentSite().transcriptDownloads !== false }} + > <div className="flex flex-col gap-4"> <div> <h1 className="text-2xl font-semibold">Duplicate shorts</h1> diff --git a/export/app/lib/site.ts b/export/app/lib/site.ts @@ -36,6 +36,8 @@ function hubSite(): Site { ...(cfg.siteUrl ? { siteUrl: cfg.siteUrl } : {}), socialLinks: resolveHomepageSocialLinks(cfg), pwa: true, + // homepage.json's opt-out, so the hub's modal follows the same switch. + ...(cfg.transcriptDownloads === false ? { transcriptDownloads: false } : {}), }); } diff --git a/export/e2e-hub/transcript-downloads.spec.ts b/export/e2e-hub/transcript-downloads.spec.ts @@ -0,0 +1,70 @@ +import fs from "node:fs"; +import path from "node:path"; +import { expect, test, type Page } from "@playwright/test"; +import { CHANNEL_SLUG, VIDEO_TRANSCRIPT_ONLY } from "../e2e/fixtures/data"; +import { expectModalOpen, installRoutes } from "../e2e/helpers"; + +// The hub has no site.json: its switch is homepage.json `transcriptDownloads` +// (SITES_DIR/_homepage/homepage.json), read by hubSite() into currentSite(), so +// HubHome's transcript modal follows it. The fixture tree has no homepage.json +// at all (every field its default); the off case writes one for one test and +// removes it. playwright.hub.config.ts removes a leftover from a killed run. +const HOMEPAGE_FILE = path.resolve( + process.cwd(), + "e2e", + "fixtures", + "sites", + "_homepage", + "homepage.json", +); + +test.describe.configure({ mode: "serial" }); + +test.afterEach(() => { + fs.rmSync(path.dirname(HOMEPAGE_FILE), { recursive: true, force: true }); +}); + +function exportControls(page: Page) { + return [ + page.getByRole("button", { name: "Mark both clip start and end first" }), + page.getByRole("button", { name: "Download this transcript as a file" }), + page.getByRole("button", { + name: "Copy this transcript as Markdown (for AI)", + }), + ]; +} + +// A bare channel/video id is same-origin content, which the hub reads from its +// own origin — installRoutes serves it. +async function openModal(page: Page) { + await installRoutes(page); + await page.route("**/hub-sites.json", (r) => + r.fulfill({ status: 200, contentType: "application/json", body: "[]" }), + ); + await page.goto(`/?v=${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}`); + await expectModalOpen(page); + await expect(page.locator("li", { hasText: "beta line" })).toBeVisible(); +} + +test("hub, no homepage.json key: the three export controls are present", async ({ + page, +}) => { + await openModal(page); + for (const control of exportControls(page)) { + await expect(control).toBeVisible(); + } +}); + +test("hub, homepage.json transcriptDownloads: false hides all three; Share stays", async ({ + page, +}) => { + fs.mkdirSync(path.dirname(HOMEPAGE_FILE), { recursive: true }); + fs.writeFileSync(HOMEPAGE_FILE, JSON.stringify({ transcriptDownloads: false })); + await openModal(page); + for (const control of exportControls(page)) { + await expect(control).toHaveCount(0); + } + await expect( + page.getByRole("button", { name: "Copy share link at current time" }), + ).toBeVisible(); +}); diff --git a/export/e2e/archives-off.spec.ts b/export/e2e/archives-off.spec.ts @@ -0,0 +1,74 @@ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { expect, test } from "@playwright/test"; + +// site.json `archives: false` is a BUILD-time switch: compose-site skips the +// zips and writes no archives/manifest.json, and the Downloads page plus its +// Header/Footer links all key off that manifest's presence (app/lib/archives.ts +// hasArchives()). The dev server this suite drives reads the checked-out +// export/public, so the switch is exercised where it acts — one compose-site +// run per case into a scratch EXPORT_PUBLIC_DIR under the test's output dir, +// over a scratch site with no channels. No browser. + +const TSX = path.resolve(process.cwd(), "node_modules", ".bin", "tsx"); +const COMPOSE = path.resolve(process.cwd(), "..", "common", "bin", "compose-site.ts"); + +function compose(root: string, site: Record<string, unknown>) { + const sitesDir = path.join(root, "transcripts", "sites", "scratchsite"); + fs.mkdirSync(sitesDir, { recursive: true }); + fs.writeFileSync( + path.join(sitesDir, "site.json"), + JSON.stringify({ + siteId: "scratchsite", + siteTitle: "Scratch", + siteDescription: "", + headerTitle: "Scratch", + homeTagline: "", + groups: [{ id: "default", name: "All", selectedByDefault: true }], + defaultGroupId: "default", + channels: [], + ...site, + }), + ); + const publicDir = path.join(root, "public"); + // A stale bundle from an earlier (archives-on) build: a disabled site must + // never serve it. + fs.mkdirSync(path.join(publicDir, "archives"), { recursive: true }); + fs.writeFileSync( + path.join(publicDir, "archives", "manifest.json"), + JSON.stringify({ entries: [{ filename: "stale.zip" }] }), + ); + fs.writeFileSync(path.join(root, "settings.json"), "{}"); + execFileSync(TSX, [COMPOSE], { + cwd: process.cwd(), + env: { + ...process.env, + SITE_ID: "scratchsite", + TRANSCRIPTS_DIR: path.join(root, "transcripts"), + SITES_DIR: path.join(root, "transcripts", "sites"), + EXPORT_PUBLIC_DIR: publicDir, + EXPORT_INDEX_DIR: path.join(root, ".export-index"), + EXPORT_BUILDS_DIR: path.join(root, ".export-builds"), + SETTINGS_FILE: path.join(root, "settings.json"), + BUILD_ARCHIVES: "1", + }, + stdio: "pipe", + }); + return path.join(publicDir, "archives", "manifest.json"); +} + +test("archives: false composes no zip manifest (and clears a stale one)", async ({}, testInfo) => { + const manifest = compose(testInfo.outputPath("off"), { archives: false }); + expect(fs.existsSync(manifest)).toBe(false); +}); + +test("archives absent (default on) composes a manifest", async ({}, testInfo) => { + const manifest = compose(testInfo.outputPath("on"), {}); + expect(fs.existsSync(manifest)).toBe(true); + const parsed = JSON.parse(fs.readFileSync(manifest, "utf8")) as { + entries: { filename: string }[]; + }; + // A fresh manifest from this build, never the stale bundle. + expect(parsed.entries.map((e) => e.filename)).not.toContain("stale.zip"); +}); diff --git a/export/e2e/transcript-downloads.spec.ts b/export/e2e/transcript-downloads.spec.ts @@ -0,0 +1,85 @@ +import fs from "node:fs"; +import path from "node:path"; +import { expect, test, type Page } from "@playwright/test"; +import { CHANNEL_SLUG, VIDEO_TRANSCRIPT_ONLY } from "./fixtures/data"; +import { expectModalOpen, installRoutes } from "./helpers"; + +// site.json `transcriptDownloads` gates the transcript modal's three per-video +// export controls. The suite serves ONE site (SITE_ID=testsite, SITES_DIR = +// e2e/fixtures/sites) and currentSite() re-reads site.json on every dev render, +// so the off case flips the key in the fixture for the duration of one test and +// puts the file back. A run killed mid-test leaves the key behind; +// playwright.config.ts strips it at load, before any spec runs. +const SITE_FILE = path.resolve( + process.cwd(), + "e2e", + "fixtures", + "sites", + "testsite", + "site.json", +); + +function writeSiteFile(value: Record<string, unknown>) { + fs.writeFileSync(SITE_FILE, `${JSON.stringify(value, null, 2)}\n`); +} + +let pristine = ""; + +test.describe.configure({ mode: "serial" }); + +test.beforeAll(() => { + pristine = fs.readFileSync(SITE_FILE, "utf8"); +}); + +test.afterEach(() => { + fs.writeFileSync(SITE_FILE, pristine); +}); + +// The three controls, by the accessible names they carry once the transcript +// has loaded and no clip is marked. +function exportControls(page: Page) { + return [ + page.getByRole("button", { name: "Mark both clip start and end first" }), + page.getByRole("button", { name: "Download this transcript as a file" }), + page.getByRole("button", { + name: "Copy this transcript as Markdown (for AI)", + }), + ]; +} + +async function openModal(page: Page) { + await installRoutes(page); + await page.goto(`/?v=${CHANNEL_SLUG}/${VIDEO_TRANSCRIPT_ONLY}`); + await expectModalOpen(page); + // Loaded: the cue list is up, so the file controls are past "Nothing to …". + await expect(page.locator("li", { hasText: "beta line" })).toBeVisible(); +} + +test("default (key absent): the three export controls are present", async ({ + page, +}) => { + await openModal(page); + for (const control of exportControls(page)) { + await expect(control).toBeVisible(); + } + await expect( + page.getByRole("button", { name: "Copy share link at current time" }), + ).toBeVisible(); +}); + +test("transcriptDownloads: false hides all three; Share stays", async ({ + page, +}) => { + writeSiteFile({ ...JSON.parse(pristine), transcriptDownloads: false }); + await openModal(page); + for (const control of exportControls(page)) { + await expect(control).toHaveCount(0); + } + // Nothing else in the bar went with them. + await expect( + page.getByRole("button", { name: "Copy share link at current time" }), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Mark clip end at current time" }), + ).toBeVisible(); +}); diff --git a/export/playwright.config.ts b/export/playwright.config.ts @@ -27,6 +27,20 @@ buildFixtureSettings(TEST_SETTINGS_FILE); // step, so pwa.spec's `GET /sw.js` would depend on a stale build artifact being // left on disk. Stage it here (mirroring compose) so the service-worker test is // deterministic on a clean checkout, exactly like the committed public/icons/*. +// transcript-downloads.spec flips `transcriptDownloads: false` into the served +// fixture site for one test and restores it afterwards. A run killed inside that +// test leaves the key behind, and every modal spec before it would then fail on +// the missing controls — so strip it here, before any spec runs. +const FIXTURE_SITE = path.join(TEST_SITES_DIR, "testsite", "site.json"); +{ + const raw = fs.readFileSync(FIXTURE_SITE, "utf8"); + const site = JSON.parse(raw) as Record<string, unknown>; + if ("transcriptDownloads" in site) { + delete site.transcriptDownloads; + fs.writeFileSync(FIXTURE_SITE, `${JSON.stringify(site, null, 2)}\n`); + } +} + const SW_SRC = path.resolve(process.cwd(), "service-worker", "site-sw.js"); const SW_DEST = path.resolve(process.cwd(), "public", "sw.js"); if (fs.existsSync(SW_SRC)) fs.copyFileSync(SW_SRC, SW_DEST); diff --git a/export/playwright.hub.config.ts b/export/playwright.hub.config.ts @@ -1,3 +1,4 @@ +import fs from "node:fs"; import path from "node:path"; import { defineConfig, devices } from "@playwright/test"; import { buildFixtureSettings } from "./e2e/fixtures/data"; @@ -17,6 +18,11 @@ const TEST_SITES_DIR = path.resolve(process.cwd(), "e2e", "fixtures", "sites"); buildFixtureSettings(TEST_SETTINGS_FILE); +// e2e-hub/transcript-downloads.spec writes SITES_DIR/_homepage/homepage.json for +// one test and removes it; the fixture tree never commits one. Remove a leftover +// from a killed run before any spec reads the hub's config. +fs.rmSync(path.join(TEST_SITES_DIR, "_homepage"), { recursive: true, force: true }); + export default defineConfig({ testDir: "./e2e-hub", timeout: 30_000, diff --git a/plans/release-5.md b/plans/release-5.md @@ -91,7 +91,8 @@ Numbers: none (no file format changes). Branch `one-core/r5-exports`. The editor does NOT mount `TranscriptModal`/`PlayerProvider` — the gate is export-only. There is no `pnpm ops site-config`: `site.json` is written only by the -Settings form (`SiteForm.tsx` → `saveSiteAction` → `writeSite`). Five sites mount the modal. +Settings form (`SiteForm.tsx` → `saveSiteAction` → `writeSite`). Four places mount the modal (SiteWorkspace, duplicates, HubHome, AskHub — all +in `export/`). 1. **Schema key `transcriptDownloads`** (boolean, absent = on), the `archives` four-site pattern in `common/lib/siteSchema.ts` (type, docs, zod opt-out, write-if-non-default). Regenerate `SITE.md` @@ -104,16 +105,21 @@ Settings form (`SiteForm.tsx` → `saveSiteAction` → `writeSite`). Five sites off (Copy download command, the Download dropdown, Copy MD). Mount sites pass `features={{ transcriptDownloads: currentSite().transcriptDownloads !== false }}`: `export/app/(workspace)/SiteWorkspace.tsx`, `export/app/duplicates/page.tsx`; hub pages - `HubHome.tsx` and `AskHub.tsx` read the hub's own `_homepage` site.json the same way (the hub is a - published surface and follows the same key; default on). + `HubHome.tsx` and `AskHub.tsx` read the hub's own key the same way — the hub has no site.json, so + it is `transcriptDownloads` in `sites/_homepage/homepage.json`, passed through `hubSite()` and + set by the editor's hub form on `/sites` (the hub is a published surface and follows the same + key; default on). 4. **Tests.** Export e2e: a fixture site with `transcriptDownloads: false` → the three buttons absent (by accessible name), default → present; an `archives: false` fixture → no `/downloads` link, no zip manifest (new coverage); `phase3-files-numbers.ts` still reports zero unknown keys on both sides. Editor e2e: the site form round-trips the new checkbox. 5. **Rollout (after merge and the editor restart):** for each of jeralyzer, rekietalyzer, hasanalyzer, anilyzer, bonnellyzer: Settings form → untick archives and transcript downloads → - save → build-site + deploy-site. Verify per site: `/downloads` absent, transcript modal has no - Download/Copy MD/Copy download command, `/corpus.json` and one `page-0001.json` still 200. + save → build-site + deploy-site; then the hub form on `/sites` → untick transcript downloads → + save → rebuild + deploy the hub. Verify per site: no Downloads link in the header or footer, and + `/downloads` shows its empty state (the route still builds and answers 200); transcript modal has + no Download/Copy MD/Copy download command, `/corpus.json` and one `page-0001.json` still 200. + On the hub: a federated transcript's modal has none of the three either. `use-with-ai` and `llms.txt` already gate their archives prose. 6. Out of scope: gating the machine contract; "Copy share link"; umtool clip fetching; pruning the archives R2 volume. @@ -209,3 +215,107 @@ availability availability-backfill queues fetch-window pipeline`: **33 passed, 0 **Rollout.** Item 6 above stands: after R is live remove `fullSweepIntervalMinutes: 0` from `the-quartering-rumble` and watch one paced sweep (at 1 req/s, ~155+ pages ≈ 3 minutes of listing); the first accepted listing after 44 days may shrink — the two-observation guard owns it. + +### Slice X, as shipped — visitor exports off, per site (2026-09-24) + +Branch `one-core/r5-exports` off `main` `f4da04a9`. One new `site.json` key, `transcriptDownloads` +(boolean, absent = on), gating the transcript modal's three per-video export controls on the export +site; the site form writes it. The hub, which has no `site.json`, gets the same key in +`homepage.json`, written by the hub form. `archives: false` needed no code — it gained test coverage. + +| sha | what | +|---|---| +| `acf0c676` | `common/lib/siteSchema.ts`: `transcriptDownloads` in the `Site` type, `SITE_FIELD_DOCS` (after `duplicates`), a zod field that is off only on an explicit `false`, `siteToDisk` writes it only when `false` — the `archives` pattern, four places. `siteSchema.test.ts` extends the existing default / opt-out / siteToDisk / round-trip / writeSite tests. `SITE.md` regenerated (`--check` green) | +| `d301d757` | `PlayerProvider({children, features?})`: `PlayerFeatures = {transcriptDownloads}`, `DEFAULT_PLAYER_FEATURES` all on, exposed as `usePlayer().features` (memoised on the flag, so an inline `features={{…}}` does not rebuild the context). `TranscriptModal` renders none of Copy download command, the Download dropdown and Copy MD when off; Share, the clip marks and every accessible name are unchanged when on. Mounts: `(workspace)/layout.tsx` → `SiteWorkspace` prop, `duplicates/page.tsx` directly, `HubHome`/`AskHub` via a prop from their server pages (`(workspace)/page.tsx`, `(workspace)/ask/page.tsx`) — all `currentSite().transcriptDownloads !== false` | +| `a42b1737` | `SiteForm.tsx` checkbox after the archive size cap, label "Per-video transcript downloads (Download menu, Copy Markdown, Copy download command)"; `actions.ts` reads it like `archives`. `editor/e2e/sites-crud.spec.ts`: one new test round-trips BOTH opt-outs (no spec covered the `archives` checkbox before) | +| `9958b42b` | export e2e: `transcript-downloads.spec.ts` (2), `archives-off.spec.ts` (2) | +| `6950945a` | `plans/tools/phase3-files-numbers.ts` literal gains the key; this record; `site-exports-off.md` superseded note; changelog | +| `c09ef9c7` | review fix: the leftover-key strip moves from the spec's `beforeAll` into `export/playwright.config.ts` (config load, before any spec) | +| `c65c08c2` | review fix, the hub: `homepage.json` `transcriptDownloads` in `common/lib/homepage.ts` (type; read only as `false`; `writeHomepageConfig` writes it only when `false`), `hubSite()` passes it through (`export/app/lib/site.ts`), checkbox on `HomepageConfigForm.tsx` (same label) + `homepageActions.ts`. `common/lib/homepage.test.ts` (new, 2 tests — there was no homepage test), `export/e2e-hub/transcript-downloads.spec.ts` (2, the hub-mode suite), `sites-crud` round-trip of the hub form. `playwright.hub.config.ts` removes a leftover fixture `_homepage/` at load | +| `41bbf92e` | record + changelog after review | +| `4ec5af9b` | merge `main` `3adaea9b` (slice R); conflicts only in `editor/CHANGELOG.md` `[Unreleased]` and this file's `## Record`, both slices' text kept, R's first | +| *(this commit)* | the merged-tree gates below | + +**Deviations / findings.** +1. *The hub has no `site.json`, so its key is in `homepage.json`.* In hub mode `currentSite()` is + `hubSite()`, a `Site` synthesised from `sites/_homepage/homepage.json`. The first cut left the + hub always on; review held the plan's decision (the hub is a published surface — and it + federates exactly the five sites being turned off — so it follows the same key, default on), + and `c65c08c2` adds `transcriptDownloads` to `homepage.json` under the same name and rules. + `writeHomepageConfig` rebuilds the file from named fields, so the hub form is the key's writer. + **`homepage.json` has no generated doc** (`file-schemas-docs.ts` covers `settings.json`, + `site.json` and `config.json` only) and no zod schema; none was invented. The numbers tool does + not read `homepage.json`, so it was not re-run for this commit. +2. *Client mounts cannot call `currentSite()`* (it reads `site.json` off disk). `SiteWorkspace`, + `HubHome` and `AskHub` are `"use client"`, so each takes a boolean prop from its server parent; + `duplicates/page.tsx` is a server component and passes the features itself. +3. *Export e2e varies per-site config by flipping the fixture.* The suite serves ONE site + (`SITE_ID=testsite`) from one `next dev`; no spec varied `site.json` before (the per-site + variants in the suite are route mocks of client data). `currentSite()` is uncached and `next + dev` re-renders the layout per request, so the off test writes `transcriptDownloads: false` + into `e2e/fixtures/sites/testsite/site.json`, asserts, and `afterEach` restores the bytes. A + run killed mid-test leaves the key; `playwright.config.ts` strips it when the config loads, + before any spec runs (`c09ef9c7` — the first cut stripped it in the spec's own `beforeAll`, + which runs 36th of 37 files, after `player-control-bar` had already failed on it). The strip + re-serialises the file, so after a killed run the fixture can differ from git in whitespace + only. A second fixture site would need a second `next dev` of the same app dir. The hub-mode + spec does the same with a `_homepage/homepage.json` that the fixture tree never commits; the + hub config deletes a leftover. +4. *`archives: false` is a build-time switch, so its coverage is a compose run, not a page.* The + Downloads page and its Header/Footer links key off `public/archives/manifest.json` + (`hasArchives()`), which `compose-site` writes; the dev server reads the checked-out + `export/public` (symlinked from the primary). `archives-off.spec.ts` runs `compose-site` into a + scratch `EXPORT_PUBLIC_DIR` under the test's output dir over a scratch channel-less site: + `archives: false` → no manifest, a pre-seeded stale one removed; key absent → a fresh manifest. + The "no `/downloads` link" half follows from `hasArchives()` and is not rendered in a test. +5. *The actions themselves are not gated.* `copyDownloadCommand` / `downloadTranscriptFile` / + `copyTranscriptMarkdown` stay on the context; the three buttons are their only callers (grep), + and the data is in the visitor's browser either way — this removes the affordance, it is not + access control. + +**Gates** (worktree root). +- tsc `pnpm -r --no-bail --workspace-concurrency=1 exec tsc --noEmit`: clean. +- common `pnpm --filter yt-dlp-transcript-common test`: **1738/1738** (assertions added inside + existing tests; count unchanged). `file-schemas-docs.ts --check`: green. +- editor unit (`editor/`, `tsx --test "app/**/*.test.ts"`): **72/72**. `pnpm run test:scripts`: + **156 pass + 1 skip**. mcp: **219/219**. +- `pnpm --filter editor exec next build`: ok (47 s). `pnpm --filter export exec next build`: ok + (32 s); no dangling `export/public` links before either. +- EXPORT e2e, full suite (`node scripts/worktree.mjs run -- pnpm --filter export run e2e`): + **192 passed, 0 failed, 8.5 min** (188 + the 4 new). The fixture was back to its committed + bytes afterwards (`git status` clean). +- EDITOR e2e (`pnpm e2e` with `sites-crud site-publish-preview site-scope cut-release view-route + settings`, all six exist; `sites-crud` is now the spec covering the `archives` checkbox): + **50 passed, 0 failed, 1.7 min** (after ~7.5 min behind slice R's run on the queue). + +**Gates after review** (tip `c65c08c2`, same commands). tsc clean. common **1740/1740** (+2, +`homepage.test.ts`). `file-schemas-docs.ts --check` green. editor unit **72/72**. editor `next +build` ok (39 s), export `exec next build` ok (24 s), no dangling `export/public` links. EXPORT e2e, +full suite: **192 passed, 0 failed, 7.5 min**. Export HUB suite (`… run e2e:hub`, the existing +hub-mode run, `playwright.hub.config.ts`): **8 passed, 0 failed, 17 s** (6 + the 2 new). EDITOR e2e +`sites-crud`: **13 passed, 0 failed, 41 s** (the spec closest to the hub form; no spec covered that +form before). Fixture tree clean after all three (`git status`; no `_homepage/` left). +test:scripts and mcp are untouched by the fixes (no file under their globs changed) and were not +re-run. + +**Gates on the merged tree** (`4ec5af9b`, slice R + slice X — the tree to merge as-is). tsc clean. +common **1750/1750** (R's tests + the 2 homepage tests). `--check` green. editor unit **72/72**. +test:scripts **156 + 1 skip**. mcp **219/219**. editor `next build` ok (40 s), export `exec next +build` ok (25 s). EXPORT e2e full: **192 passed, 0 failed, 6.3 min**. HUB e2e: **8 passed, 0 +failed, 16 s**. EDITOR e2e (`sites-crud site-publish-preview site-scope cut-release view-route +settings`): **51 passed, 0 failed, 1.7 min** (50 + the hub-form test). `git status` clean after. + +**Numbers** (`plans/tools/phase3-files-numbers.ts`, `TMPDIR` = the job's scratch). Inputs frozen +once (`FREEZE_TO`) from the live corpus: 6 `site.json`, 71 `config.json`, 1,763 sidecars. Before = +the tool on `f4da04a9` code (branch changes stashed); after = the branch with the literal updated +(it asserts the literal equals `SITE_FIELD_DOCS`). **Unknown-key report: empty on both sides**, 77 +files each. **Diff: 18 lines, exactly one per site** — each parsed site gains +`"transcriptDownloads": true`; every `writeSite(getSite())` file is byte-identical (no site gains +the key on disk). A third run over a scratch copy with jeralyzer's `site.json` carrying +`transcriptDownloads: false` + `archives: false`: unknown keys still empty, both parse `false`, +and the written file keeps both keys. + +**Left.** Rollout item 5 is the operator's, after merge: untick both on the five published sites +and build + deploy each, then untick transcript downloads on the hub form and rebuild + deploy the +hub. Verify per site as item 5 now says: no Downloads link in the header or footer, and +`/downloads` shows its empty state (it still answers 200). diff --git a/plans/site-exports-off.md b/plans/site-exports-off.md @@ -1,5 +1,10 @@ # Plan — visitor exports off on the published sites, kept as an option for the OSS release +> **Superseded by [`release-5.md`](release-5.md) slice X** (shipped as "### Slice X, as shipped" +> in its Record). Kept as written for the inventory; where the two differ, release-5.md wins — +> notably there is no `pnpm ops site-config` (the site form is the one writer), and the hub has no +> `site.json` of its own. + **Asked 2026-09-24** (operator, mid release 4): turn off "exports" on every existing published site to strengthen the legal footing of the operator's own instances, while keeping the capability as a per-site option so anyone running the OSS release can leave it on. Scheduled as diff --git a/plans/tools/phase3-files-numbers.ts b/plans/tools/phase3-files-numbers.ts @@ -54,13 +54,14 @@ const SCRATCH = fs.mkdtempSync(path.join(os.tmpdir(), "phase3-files-")); process.env.TRANSCRIPTS_DIR = SCRATCH; process.env.TZ = "UTC"; -// The keys each file may carry, as of 2026-09-24. On the branch these must +// The keys each file may carry, as of 2026-09-24 (+ transcriptDownloads, release +// 5). On the branch these must // equal the docs records (asserted below). const SITE_KEYS_LITERAL = [ "siteId", "siteTitle", "siteDescription", "headerTitle", "homeTagline", "accent", "socialLinks", "groups", "defaultGroupId", "channels", "cloudflareProject", "siteUrl", "relatedSites", "pwa", "archives", - "archiveMaxBytes", "duplicates", "hubUrl", + "archiveMaxBytes", "duplicates", "transcriptDownloads", "hubUrl", ]; const CHANNEL_KEYS_LITERAL = [ "handling", "sourceKind", "postFetcher", "socialHandle", "platform", "name",