Archilyzer · Source

archilyzer

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

commit 8b7e03213d9d1afca29dae7decf33f2684aa0462
parent 6995cc817f98308b72eeb0e6e37ad093cabc122d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue, 19 May 2026 21:33:33 -0400

Sync all toggle, sorting, and CHANGELOGs

Diffstat:
Mcommon/lib/channelConfig.ts | 4++++
Aeditor/CHANGELOG.md | 15+++++++++++++++
Meditor/app/channels/actions.ts | 21+++++++++++++++++++++
Meditor/app/channels/components/ChannelBuildToggle.tsx | 12+++++++-----
Aeditor/app/channels/components/ChannelSyncToggle.tsx | 45+++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/channels/components/ChannelsTable.tsx | 325+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/channels/page.tsx | 115++-----------------------------------------------------------------------------
Aeditor/e2e/channel-sync-toggle.spec.ts | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/e2e/channels-sort.spec.ts | 79+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aexport/CHANGELOG.md | 8++++++++
10 files changed, 564 insertions(+), 118 deletions(-)

diff --git a/common/lib/channelConfig.ts b/common/lib/channelConfig.ts @@ -26,6 +26,7 @@ export type ChannelConfig = { lastSyncedAt?: string; lastFullDownloadAt?: string; excludeFromBuild?: boolean; + excludeFromSync?: boolean; // Per-channel override for the global setting of the same name. When // omitted, the global SiteSettings value is used. 0 disables the sleep // for this channel. @@ -111,6 +112,9 @@ export function parseChannelConfig(raw: unknown): ChannelConfig | null { if (typeof r.excludeFromBuild === "boolean") { config.excludeFromBuild = r.excludeFromBuild; } + if (typeof r.excludeFromSync === "boolean") { + config.excludeFromSync = r.excludeFromSync; + } if ( typeof r.sleepBetweenDownloadsSeconds === "number" && Number.isFinite(r.sleepBetweenDownloadsSeconds) && diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -0,0 +1,15 @@ +# Changelog + +All notable changes to the editor workspace are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- Per-channel "Include in Sync all" toggle on the `/channels` list. Excluded channels are skipped by **Sync all** and surfaced in the existing skipped tooltip with reason "excluded from sync all". Stored as `excludeFromSync` in `channels/<slug>/config.json`. +- Sortable column headers on the `/channels` table. Clicking any column sorts client-side; clicking the active column flips direction. Default rendering is unsorted (input order preserved); sort state does not persist across navigation. + +### Changed +- `/channels` rows are now dimmed when a channel is excluded from Sync all, matching the existing treatment for build exclusion. diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts @@ -114,6 +114,23 @@ export async function toggleChannelBuildInclusionAction( return undefined; } +export async function toggleChannelSyncInclusionAction( + slug: string, +): Promise<ActionResult> { + const paths = getPaths(); + const existing = await readChannelConfig(paths, slug); + if (!existing) return { error: `Channel "${slug}" not found` }; + const next = { ...existing }; + if (existing.excludeFromSync) { + delete next.excludeFromSync; + } else { + next.excludeFromSync = true; + } + await writeChannelConfig(paths, slug, next); + revalidatePath("/channels"); + return undefined; +} + export type SyncAllResult = { queued: string[]; skipped: { slug: string; reason: string }[]; @@ -140,6 +157,10 @@ export async function syncAllChannelsAction(): Promise<SyncAllResult> { skipped.push({ slug: c.slug, reason: "no url" }); continue; } + if (c.config.excludeFromSync) { + skipped.push({ slug: c.slug, reason: "excluded from sync all" }); + continue; + } if (active.has(c.slug)) { skipped.push({ slug: c.slug, reason: "already running" }); continue; diff --git a/editor/app/channels/components/ChannelBuildToggle.tsx b/editor/app/channels/components/ChannelBuildToggle.tsx @@ -1,3 +1,5 @@ +"use client"; + import { toggleChannelBuildInclusionAction } from "../actions"; export function ChannelBuildToggle({ @@ -7,12 +9,12 @@ export function ChannelBuildToggle({ slug: string; excluded: boolean; }) { - async function submit() { - "use server"; - await toggleChannelBuildInclusionAction(slug); - } return ( - <form action={submit}> + <form + action={async () => { + await toggleChannelBuildInclusionAction(slug); + }} + > <button type="submit" aria-label={`toggle build inclusion for ${slug}`} diff --git a/editor/app/channels/components/ChannelSyncToggle.tsx b/editor/app/channels/components/ChannelSyncToggle.tsx @@ -0,0 +1,45 @@ +"use client"; + +import { toggleChannelSyncInclusionAction } from "../actions"; + +export function ChannelSyncToggle({ + slug, + excluded, +}: { + slug: string; + excluded: boolean; +}) { + return ( + <form + action={async () => { + await toggleChannelSyncInclusionAction(slug); + }} + > + <button + type="submit" + aria-label={`toggle sync inclusion for ${slug}`} + aria-pressed={!excluded} + title={ + excluded + ? "Excluded from Sync all. Click to include." + : "Included in Sync all. Click to exclude." + } + className={ + "inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs font-medium border transition-colors " + + (excluded + ? "border-zinc-300 dark:border-zinc-700 text-zinc-500 hover:bg-zinc-100 dark:hover:bg-zinc-800" + : "border-emerald-300 dark:border-emerald-800 bg-emerald-50 dark:bg-emerald-950/40 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-100 dark:hover:bg-emerald-900/40") + } + > + <span + aria-hidden="true" + className={ + "inline-block h-1.5 w-1.5 rounded-full " + + (excluded ? "bg-zinc-400" : "bg-emerald-500") + } + /> + {excluded ? "Skipped" : "Included"} + </button> + </form> + ); +} diff --git a/editor/app/channels/components/ChannelsTable.tsx b/editor/app/channels/components/ChannelsTable.tsx @@ -0,0 +1,325 @@ +"use client"; + +import Link from "next/link"; +import { useMemo, useState } from "react"; +import type { ChannelStat } from "yt-dlp-transcript-common/controller/channels"; +import { ChannelAvailabilityButton } from "./ChannelAvailabilityButton"; +import { ChannelBuildToggle } from "./ChannelBuildToggle"; +import { ChannelSyncButton } from "./ChannelSyncButton"; +import { ChannelSyncToggle } from "./ChannelSyncToggle"; + +type SortKey = + | "slug" + | "name" + | "handling" + | "build" + | "sync" + | "playlist" + | "lastSync" + | "downloads" + | "transcripts"; + +type SortDir = "asc" | "desc"; + +type SortState = { key: SortKey; dir: SortDir } | null; + +const DEFAULT_DIR: Record<SortKey, SortDir> = { + slug: "asc", + name: "asc", + handling: "asc", + build: "asc", + sync: "asc", + playlist: "desc", + lastSync: "asc", + downloads: "desc", + transcripts: "desc", +}; + +function compareStrings(a: string | undefined, b: string | undefined): number { + const aHas = a !== undefined && a !== ""; + const bHas = b !== undefined && b !== ""; + if (!aHas && !bHas) return 0; + if (!aHas) return -1; + if (!bHas) return 1; + return (a as string).localeCompare(b as string); +} + +function compareNumbers( + a: number | null | undefined, + b: number | null | undefined, +): number { + const aHas = a !== null && a !== undefined; + const bHas = b !== null && b !== undefined; + if (!aHas && !bHas) return 0; + if (!aHas) return -1; + if (!bHas) return 1; + return (a as number) - (b as number); +} + +function compareDates(a: string | undefined, b: string | undefined): number { + // Missing dates sort as oldest (so stale channels surface first on asc). + const aHas = !!a; + const bHas = !!b; + if (!aHas && !bHas) return 0; + if (!aHas) return -1; + if (!bHas) return 1; + return new Date(a as string).getTime() - new Date(b as string).getTime(); +} + +function compareBools(a: boolean, b: boolean): number { + // false (included) < true (excluded), so "Included" sorts first on asc. + return (a ? 1 : 0) - (b ? 1 : 0); +} + +function cmp(a: ChannelStat, b: ChannelStat, key: SortKey): number { + switch (key) { + case "slug": + return a.slug.localeCompare(b.slug); + case "name": + return compareStrings(a.config.name, b.config.name); + case "handling": + return compareStrings(a.config.handling, b.config.handling); + case "build": + return compareBools( + a.config.excludeFromBuild === true, + b.config.excludeFromBuild === true, + ); + case "sync": + return compareBools( + a.config.excludeFromSync === true, + b.config.excludeFromSync === true, + ); + case "playlist": + return compareNumbers(a.playlistCount, b.playlistCount); + case "lastSync": + return compareDates(a.config.lastSyncedAt, b.config.lastSyncedAt); + case "downloads": + return compareNumbers(a.downloadCount, b.downloadCount); + case "transcripts": + return compareNumbers(a.transcriptCount, b.transcriptCount); + } +} + +export function ChannelsTable({ channels }: { channels: ChannelStat[] }) { + const [sort, setSort] = useState<SortState>(null); + + const rows = useMemo(() => { + if (!sort) return channels; + const sorted = [...channels].sort((a, b) => cmp(a, b, sort.key)); + return sort.dir === "desc" ? sorted.reverse() : sorted; + }, [channels, sort]); + + function onHeaderClick(key: SortKey) { + setSort((prev) => { + if (prev && prev.key === key) { + return { key, dir: prev.dir === "asc" ? "desc" : "asc" }; + } + return { key, dir: DEFAULT_DIR[key] }; + }); + } + + return ( + <div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible"> + <table className="text-sm border-y md:border border-zinc-200 dark:border-zinc-800 md:rounded-md md:overflow-hidden w-full"> + <thead className="bg-zinc-100 dark:bg-zinc-900"> + <tr> + <SortableTh + label="Slug" + sortKey="slug" + sort={sort} + onClick={onHeaderClick} + /> + <SortableTh + label="Name" + sortKey="name" + sort={sort} + onClick={onHeaderClick} + /> + <SortableTh + label="Handling" + sortKey="handling" + sort={sort} + onClick={onHeaderClick} + /> + <SortableTh + label="Build" + sortKey="build" + sort={sort} + onClick={onHeaderClick} + /> + <SortableTh + label="Sync" + sortKey="sync" + sort={sort} + onClick={onHeaderClick} + /> + <SortableTh + label="Playlist" + sortKey="playlist" + sort={sort} + onClick={onHeaderClick} + align="right" + /> + <SortableTh + label="Last sync" + sortKey="lastSync" + sort={sort} + onClick={onHeaderClick} + className="whitespace-nowrap" + /> + <SortableTh + label="Downloads" + sortKey="downloads" + sort={sort} + onClick={onHeaderClick} + align="right" + /> + <SortableTh + label="Transcripts" + sortKey="transcripts" + sort={sort} + onClick={onHeaderClick} + align="right" + /> + <th className="text-left font-medium px-3 py-2 whitespace-nowrap"> + Actions + </th> + </tr> + </thead> + <tbody> + {rows.map((c) => ( + <tr + key={c.slug} + className={ + "border-t border-zinc-200 dark:border-zinc-800 " + + (c.config.excludeFromBuild || c.config.excludeFromSync + ? "opacity-60" + : "") + } + > + <Td className="font-mono"> + <Link + href={`/channels/${c.slug}`} + className="underline hover:text-zinc-900 dark:hover:text-zinc-100" + > + {c.slug} + </Link> + </Td> + <Td>{c.config.name ?? ""}</Td> + <Td>{c.config.handling}</Td> + <Td> + <ChannelBuildToggle + slug={c.slug} + excluded={c.config.excludeFromBuild === true} + /> + </Td> + <Td> + <ChannelSyncToggle + slug={c.slug} + excluded={c.config.excludeFromSync === true} + /> + </Td> + <Td + className="text-right" + ariaLabel={`playlist count for ${c.slug}`} + > + {c.playlistCount ?? "—"} + </Td> + <Td className="text-xs text-zinc-500 whitespace-nowrap"> + {c.config.lastSyncedAt + ? new Date(c.config.lastSyncedAt).toLocaleString() + : ""} + </Td> + <Td + className="text-right" + ariaLabel={`downloads count for ${c.slug}`} + > + {c.downloadCount} + </Td> + <Td + className="text-right" + ariaLabel={`transcripts count for ${c.slug}`} + > + {c.transcriptCount} + </Td> + <Td className="whitespace-nowrap"> + <div className="flex items-center gap-2"> + <ChannelSyncButton + slug={c.slug} + disabled={!c.config.url} + /> + <ChannelAvailabilityButton + slug={c.slug} + disabled={!c.config.url} + /> + </div> + </Td> + </tr> + ))} + </tbody> + </table> + </div> + ); +} + +function SortableTh({ + label, + sortKey, + sort, + onClick, + align = "left", + className = "", +}: { + label: string; + sortKey: SortKey; + sort: SortState; + onClick: (key: SortKey) => void; + align?: "left" | "right"; + className?: string; +}) { + const active = sort?.key === sortKey; + const indicator = active ? (sort?.dir === "asc" ? "▲" : "▼") : ""; + const justify = align === "right" ? "justify-end" : "justify-start"; + const textAlign = align === "right" ? "text-right" : "text-left"; + return ( + <th + className={`${textAlign} font-medium px-3 py-2 ${className}`} + aria-sort={ + active ? (sort?.dir === "asc" ? "ascending" : "descending") : "none" + } + > + <button + type="button" + onClick={() => onClick(sortKey)} + aria-label={`sort by ${label}`} + className={ + `inline-flex w-full items-center gap-1 ${justify} ` + + "font-medium hover:text-zinc-900 dark:hover:text-zinc-100 cursor-pointer" + } + > + <span>{label}</span> + {indicator && ( + <span aria-hidden="true" className="text-[10px] text-zinc-500"> + {indicator} + </span> + )} + </button> + </th> + ); +} + +function Td({ + children, + className = "", + ariaLabel, +}: { + children: React.ReactNode; + className?: string; + ariaLabel?: string; +}) { + return ( + <td className={`px-3 py-2 ${className}`} aria-label={ariaLabel}> + {children} + </td> + ); +} diff --git a/editor/app/channels/page.tsx b/editor/app/channels/page.tsx @@ -2,9 +2,7 @@ import type { Metadata } from "next"; import Link from "next/link"; import { listChannels } from "yt-dlp-transcript-common/controller/channels"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { ChannelAvailabilityButton } from "./components/ChannelAvailabilityButton"; -import { ChannelBuildToggle } from "./components/ChannelBuildToggle"; -import { ChannelSyncButton } from "./components/ChannelSyncButton"; +import { ChannelsTable } from "./components/ChannelsTable"; import { SyncAllChannelsButton } from "./components/SyncAllChannelsButton"; export const dynamic = "force-dynamic"; @@ -32,117 +30,8 @@ export default async function ChannelsPage() { No channels yet. </p> ) : ( - <div className="overflow-x-auto -mx-4 md:mx-0 md:overflow-visible"> - <table className="text-sm border-y md:border border-zinc-200 dark:border-zinc-800 md:rounded-md md:overflow-hidden w-full"> - <thead className="bg-zinc-100 dark:bg-zinc-900"> - <tr> - <Th>Slug</Th> - <Th>Name</Th> - <Th>Handling</Th> - <Th>Build</Th> - <Th className="text-right">Playlist</Th> - <Th className="whitespace-nowrap">Last sync</Th> - <Th className="text-right">Downloads</Th> - <Th className="text-right">Transcripts</Th> - <Th className="whitespace-nowrap">Actions</Th> - </tr> - </thead> - <tbody> - {channels.map((c) => ( - <tr - key={c.slug} - className={ - "border-t border-zinc-200 dark:border-zinc-800 " + - (c.config.excludeFromBuild ? "opacity-60" : "") - } - > - <Td className="font-mono"> - <Link - href={`/channels/${c.slug}`} - className="underline hover:text-zinc-900 dark:hover:text-zinc-100" - > - {c.slug} - </Link> - </Td> - <Td>{c.config.name ?? ""}</Td> - <Td>{c.config.handling}</Td> - <Td> - <ChannelBuildToggle - slug={c.slug} - excluded={c.config.excludeFromBuild === true} - /> - </Td> - <Td - className="text-right" - ariaLabel={`playlist count for ${c.slug}`} - > - {c.playlistCount ?? "—"} - </Td> - <Td className="text-xs text-zinc-500 whitespace-nowrap"> - {c.config.lastSyncedAt - ? new Date(c.config.lastSyncedAt).toLocaleString() - : ""} - </Td> - <Td - className="text-right" - ariaLabel={`downloads count for ${c.slug}`} - > - {c.downloadCount} - </Td> - <Td - className="text-right" - ariaLabel={`transcripts count for ${c.slug}`} - > - {c.transcriptCount} - </Td> - <Td className="whitespace-nowrap"> - <div className="flex items-center gap-2"> - <ChannelSyncButton - slug={c.slug} - disabled={!c.config.url} - /> - <ChannelAvailabilityButton - slug={c.slug} - disabled={!c.config.url} - /> - </div> - </Td> - </tr> - ))} - </tbody> - </table> - </div> + <ChannelsTable channels={channels} /> )} </div> ); } - -function Th({ - children, - className = "", -}: { - children: React.ReactNode; - className?: string; -}) { - return ( - <th className={`text-left font-medium px-3 py-2 ${className}`}> - {children} - </th> - ); -} - -function Td({ - children, - className = "", - ariaLabel, -}: { - children: React.ReactNode; - className?: string; - ariaLabel?: string; -}) { - return ( - <td className={`px-3 py-2 ${className}`} aria-label={ariaLabel}> - {children} - </td> - ); -} diff --git a/editor/e2e/channel-sync-toggle.spec.ts b/editor/e2e/channel-sync-toggle.spec.ts @@ -0,0 +1,58 @@ +import { test, expect } from "@playwright/test"; +import { readJson, resetData } from "./helpers"; +import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig"; + +// The sync-inclusion toggle on /channels flips `excludeFromSync` in the +// channel's config.json. The next "Sync all" then skips that channel and +// reports it in the existing skipped tooltip. Toggling back re-includes it. + +const SLOW_A_CONFIG = "test-transcripts/channels/slow-a/config.json"; + +test("toggling a channel off excludes it from Sync all; toggling back restores it", async ({ + page, +}) => { + await resetData("two-slow-channels"); + + await page.goto("/channels"); + + const toggleA = page.getByRole("button", { + name: "toggle sync inclusion for slow-a", + }); + await expect(toggleA).toHaveText(/Included/); + await expect(toggleA).toHaveAttribute("aria-pressed", "true"); + + await toggleA.click(); + + const toggleAAfter = page.getByRole("button", { + name: "toggle sync inclusion for slow-a", + }); + await expect(toggleAAfter).toHaveText(/Skipped/); + await expect(toggleAAfter).toHaveAttribute("aria-pressed", "false"); + + { + const cfg = await readJson<ChannelConfig>(SLOW_A_CONFIG); + expect(cfg.excludeFromSync).toBe(true); + } + + // Run Sync all. slow-a is excluded; slow-b queues. + await page.getByRole("button", { name: "sync all channels" }).click(); + const result = page.getByLabel("sync all result"); + await expect(result).toContainText(/Queued 1 . skipped 1/, { + timeout: 10_000, + }); + await expect(result).toHaveAttribute( + "title", + /slow-a: excluded from sync all/, + ); + + // Toggle back on. + await page.getByRole("button", { name: "toggle sync inclusion for slow-a" }).click(); + await expect( + page.getByRole("button", { name: "toggle sync inclusion for slow-a" }), + ).toHaveText(/Included/); + + { + const cfg = await readJson<ChannelConfig>(SLOW_A_CONFIG); + expect(cfg.excludeFromSync).toBeUndefined(); + } +}); diff --git a/editor/e2e/channels-sort.spec.ts b/editor/e2e/channels-sort.spec.ts @@ -0,0 +1,79 @@ +import { test, expect } from "@playwright/test"; +import { resetData } from "./helpers"; + +// /channels columns are sortable client-side. Default state shows no sort +// indicator (input order from listChannels). Clicking a header sorts by that +// column; clicking the active header flips direction. Sort state does not +// persist across navigation. + +test("clicking column headers sorts the channels table and indicates direction", async ({ + page, +}) => { + await resetData("two-slow-channels"); + await page.goto("/channels"); + + // Default: no header carries an active aria-sort. + for (const header of [ + "Slug", + "Name", + "Handling", + "Build", + "Sync", + "Playlist", + "Last sync", + "Downloads", + "Transcripts", + ]) { + const th = page + .getByRole("columnheader") + .filter({ hasText: new RegExp(`^${header}$`) }); + await expect(th).toHaveAttribute("aria-sort", "none"); + } + + // Click Slug — sorts ascending alphabetically. + await page.getByRole("button", { name: "sort by Slug" }).click(); + { + const slugHeader = page + .getByRole("columnheader") + .filter({ hasText: /^Slug/ }); + await expect(slugHeader).toHaveAttribute("aria-sort", "ascending"); + const rows = page.getByRole("row"); + // 0 is header row; first body row should be slow-a. + await expect(rows.nth(1)).toContainText("slow-a"); + await expect(rows.nth(2)).toContainText("slow-b"); + } + + // Click Slug again — direction flips to descending. + await page.getByRole("button", { name: "sort by Slug" }).click(); + { + const slugHeader = page + .getByRole("columnheader") + .filter({ hasText: /^Slug/ }); + await expect(slugHeader).toHaveAttribute("aria-sort", "descending"); + const rows = page.getByRole("row"); + await expect(rows.nth(1)).toContainText("slow-b"); + await expect(rows.nth(2)).toContainText("slow-a"); + } + + // Click Last sync — the active sort moves; Slug no longer indicated. + await page.getByRole("button", { name: "sort by Last sync" }).click(); + { + const slugHeader = page + .getByRole("columnheader") + .filter({ hasText: /^Slug/ }); + await expect(slugHeader).toHaveAttribute("aria-sort", "none"); + const lastSyncHeader = page + .getByRole("columnheader") + .filter({ hasText: /^Last sync/ }); + await expect(lastSyncHeader).toHaveAttribute("aria-sort", "ascending"); + } + + // Refresh — sort state is client-only and resets. + await page.reload(); + { + const lastSyncHeader = page + .getByRole("columnheader") + .filter({ hasText: /^Last sync/ }); + await expect(lastSyncHeader).toHaveAttribute("aria-sort", "none"); + } +}); diff --git a/export/CHANGELOG.md b/export/CHANGELOG.md @@ -0,0 +1,8 @@ +# Changelog + +All notable changes to the export workspace are documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased]