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:
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]