Archilyzer · Source

archilyzer

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

commit b94055b379541a2a1af878782fd8d28fc1e6f836
parent 8b5aa2cd6315d4fe1fa829cb66249cce08f7c183
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed, 23 Sep 2026 19:33:57 -0400

editor: one settings writer — saveSettings(patch), and the 11 callers pass patches

one-core phase 3 slice 4a, commit 4. editor/app/settings/saveSettings.ts
reads the current settings, merges a Partial<SiteSettings> ONE LEVEL DEEP
(object blocks merge over the stored block; arrays, scalars and objects
nested inside a block replace — the rule editor/e2e/helpers.ts already
applies) and calls writeSettings. It is now the only editor file that
imports writeSettings.

Deliberate deviation from plans/one-core.md's saveSettingsBlock(block,
patch): channels/actions.ts writes channelPriority and the four autoQueue
roots compiled from it in one write, which a one-block signature would split.

Callers (19 call sites in 11 files) drop their {...getSettings(), ...}
preamble. Two behaviour changes, both from the merge:
- storage/actions.ts: adding or editing a location no longer erases
  storage.savedVideosLocationId (it rebuilt the block from two keys).
- settings/actions.ts: the /settings form patches only its own fields; it
  no longer rewrites the rollback-only transcriptionApps shadow to {}
  (the shadow is still re-derived from workers on every write).

saveSettings.test.ts pins the merge rule; editor unit 59 -> 63.

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

Diffstat:
Meditor/app/channels/[slug]/incompleteTranscriptActions.ts | 6+++---
Meditor/app/channels/actions.ts | 11++++++-----
Meditor/app/jobs/actions.ts | 12++++--------
Meditor/app/operations/actions.ts | 14++++++--------
Meditor/app/operations/settingsActions.ts | 47+++++++++++++++++++++++------------------------
Meditor/app/saved-videos/backupActions.ts | 6+++---
Meditor/app/scheduler/actions.ts | 9++++-----
Meditor/app/settings/actions.ts | 58++++++++++++++--------------------------------------------
Aeditor/app/settings/saveSettings.test.ts | 78++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/settings/saveSettings.ts | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/sites/lib/buildModeAction.ts | 8+++-----
Meditor/app/storage/actions.ts | 21+++++++++++----------
Meditor/app/workers/actions.ts | 6+++---
13 files changed, 216 insertions(+), 118 deletions(-)

diff --git a/editor/app/channels/[slug]/incompleteTranscriptActions.ts b/editor/app/channels/[slug]/incompleteTranscriptActions.ts @@ -7,7 +7,8 @@ import { resolveQueueKey, } from "yt-dlp-transcript-common/lib/queueKeys"; import { readChannelConfig } from "yt-dlp-transcript-common/controller/channels"; -import { getSettings, writeSettings } from "yt-dlp-transcript-common/lib/settings"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { saveSettings } from "../../settings/saveSettings"; import { startAutoRunner } from "yt-dlp-transcript-common/controller/autoRunner"; import { runManagedFunction, @@ -40,8 +41,7 @@ export async function enableAutoRunners(): Promise<void> { !current.autoQueue.transcription.enabled || !current.autoQueue.download.enabled ) { - await writeSettings({ - ...current, + await saveSettings({ autoQueue: { ...current.autoQueue, transcription: { ...current.autoQueue.transcription, enabled: true }, diff --git a/editor/app/channels/actions.ts b/editor/app/channels/actions.ts @@ -35,10 +35,8 @@ import { siteChannelIndex, type Site, } from "yt-dlp-transcript-common/lib/site"; -import { - getSettings, - writeSettings, -} from "yt-dlp-transcript-common/lib/settings"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { saveSettings } from "../settings/saveSettings"; import { LANES, type AutoQueueKind, @@ -944,7 +942,10 @@ export async function saveChannelPriorityAction( autoQueue[lane] = { ...autoQueue[lane], enabled: true }; } try { - await writeSettings({ ...settings, channelPriority: next, autoQueue }); + // BOTH BLOCKS IN ONE WRITE — the reason saveSettings takes a whole-settings + // patch rather than one block. `channelPriority` is a full document (its + // `channels` map replaces), and `autoQueue` carries all four lanes. + await saveSettings({ channelPriority: next, autoQueue }); } catch (e) { return { error: (e as Error).message }; } diff --git a/editor/app/jobs/actions.ts b/editor/app/jobs/actions.ts @@ -2,10 +2,8 @@ import { revalidatePath } from "next/cache"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { - getSettings, - writeSettings, -} from "yt-dlp-transcript-common/lib/settings"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { saveSettings } from "../settings/saveSettings"; import { laneRootFromScope } from "yt-dlp-transcript-common/lib/laneMigration"; import { isDefaultChannelPriority } from "yt-dlp-transcript-common/lib/channelPriority"; import { operationsForLane } from "yt-dlp-transcript-common/lib/operations"; @@ -216,8 +214,7 @@ export async function armLaneAction( }; } const policy = settings.autoQueue[lane]; - await writeSettings({ - ...settings, + await saveSettings({ autoQueue: { ...settings.autoQueue, [lane]: { @@ -276,8 +273,7 @@ export async function disarmLaneAction( ): Promise<ArmLaneResult> { try { const settings = getSettings(); - await writeSettings({ - ...settings, + await saveSettings({ autoQueue: { ...settings.autoQueue, [lane]: { ...settings.autoQueue[lane], enabled: false }, diff --git a/editor/app/operations/actions.ts b/editor/app/operations/actions.ts @@ -3,9 +3,9 @@ import { revalidatePath } from "next/cache"; import { getSettings, - writeSettings, type SiteSettings, } from "yt-dlp-transcript-common/lib/settings"; +import { saveSettings } from "../settings/saveSettings"; import { isGateHeld, withGateHeld, @@ -94,8 +94,7 @@ export async function saveAutoQueueAction( // in slice 1.4, is exactly what "forgotten here" would now mean. `undefined` // is carried as undefined on purpose: that is what keeps a lane that has // never been written falling back to its retired field. - const next: SiteSettings = { - ...current, + const next: Partial<SiteSettings> = { autoQueue: { ...current.autoQueue, [kind]: { @@ -110,7 +109,7 @@ export async function saveAutoQueueAction( }, }; try { - await writeSettings(next); + await saveSettings(next); } catch (e) { return { ok: false, error: (e as Error).message }; } @@ -153,15 +152,14 @@ export async function snoozeAutoQueueAction( untilMs: number | null, ): Promise<SaveResult> { const current = getSettings(); - const next: SiteSettings = { - ...current, + const next: Partial<SiteSettings> = { autoQueue: { ...current.autoQueue, [kind]: { ...current.autoQueue[kind], snoozeUntil: untilMs }, }, }; try { - await writeSettings(next); + await saveSettings(next); } catch (e) { return { ok: false, error: (e as Error).message }; } @@ -199,7 +197,7 @@ async function setLaneHeld( try { const cur = getSettings(); if (isGateHeld(cur, lane) !== held) { - await writeSettings(withGateHeld(cur, lane, held)); + await saveSettings({ autoQueue: withGateHeld(cur, lane, held).autoQueue }); } } catch (e) { // REPORTED, not swallowed. The workers page's old best-effort persist diff --git a/editor/app/operations/settingsActions.ts b/editor/app/operations/settingsActions.ts @@ -6,7 +6,9 @@ // `*FormPresent` marker, because unchecked checkboxes are simply ABSENT from a // FormData and a submit from a form lacking the block would read every switch // as off. One form per block makes the marker unnecessary — the `...current` -// spread at the top of each block is now the whole isolation story. +// spread at the top of each block is now the whole isolation story, and +// `saveSettings` (../settings/saveSettings.ts) merges each patch onto the +// rest of the file. // // Every field name is the one the old single form used, and the persisted keys // are unchanged: this is a move, not a redesign. @@ -14,9 +16,9 @@ import { revalidatePath } from "next/cache"; import { getSettings, - writeSettings, type SiteSettings, } from "yt-dlp-transcript-common/lib/settings"; +import { saveSettings } from "../settings/saveSettings"; import { isDigestSectionKind, isDigestTimestampMode, @@ -62,7 +64,7 @@ export async function saveDigestSettingsAction( return { ok: false, error: "Digest app config payload is malformed" }; } } - // Filtered to KNOWN kinds here rather than leaning on writeSettings' sanitizer. + // Filtered to KNOWN kinds here rather than leaning on the settings schema's sanitizer. // sanitizeDigest would drop an unknown value anyway, but typing it honestly is // what lets the digest block below be checked against DigestSettings instead of // cast — and the cast is what hid the dropped-fields bug. @@ -73,8 +75,7 @@ export async function saveDigestSettingsAction( const digestTimestampModeRaw = String( formData.get("digestTimestampMode") ?? "", ).trim(); - const next: SiteSettings = { - ...current, + const next: Partial<SiteSettings> = { digest: { // Spread the CURRENT block first. Every field this form does not render // must survive a save untouched, and before this spread they did not: @@ -119,7 +120,7 @@ export async function saveDigestSettingsAction( }, }; try { - await writeSettings(next); + await saveSettings(next); } catch (e) { return { ok: false, error: (e as Error).message }; } @@ -139,8 +140,7 @@ export async function saveDiarizationSettingsAction( ): Promise<SaveResult> { const current = getSettings(); const dDiar = current.diarization; - const next: SiteSettings = { - ...current, + const next: Partial<SiteSettings> = { diarization: { ...dDiar, enabled: formData.get("diarizationEnabled") === "on", @@ -179,7 +179,7 @@ export async function saveDiarizationSettingsAction( }, }; try { - await writeSettings(next); + await saveSettings(next); } catch (e) { return { ok: false, error: (e as Error).message }; } @@ -210,20 +210,20 @@ export async function saveBackfillLaneSettingsAction( ): Promise<SaveResult> { const current = getSettings(); const dBack = current.backfill; - const next: SiteSettings = withGateHeld( - { - ...current, - backfill: { - ...dBack, - concurrency: num(formData, "backfillConcurrency", dBack.concurrency), - allowRedownload: formData.get("backfillAllowRedownload") === "on", - }, + const next: Partial<SiteSettings> = { + backfill: { + ...dBack, + concurrency: num(formData, "backfillConcurrency", dBack.concurrency), + allowRedownload: formData.get("backfillAllowRedownload") === "on", }, - "backfill", - formData.get("backfillEnabled") !== "on", - ); + autoQueue: withGateHeld( + current, + "backfill", + formData.get("backfillEnabled") !== "on", + ).autoQueue, + }; try { - await writeSettings(next); + await saveSettings(next); } catch (e) { return { ok: false, error: (e as Error).message }; } @@ -245,8 +245,7 @@ export async function saveAttributionSettingsAction( ): Promise<SaveResult> { const current = getSettings(); const dAttr = current.attribution; - const next: SiteSettings = { - ...current, + const next: Partial<SiteSettings> = { attribution: { ...dAttr, enabled: formData.get("attributionEnabled") === "on", @@ -265,7 +264,7 @@ export async function saveAttributionSettingsAction( }, }; try { - await writeSettings(next); + await saveSettings(next); } catch (e) { return { ok: false, error: (e as Error).message }; } diff --git a/editor/app/saved-videos/backupActions.ts b/editor/app/saved-videos/backupActions.ts @@ -2,7 +2,8 @@ import { revalidatePath } from "next/cache"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { getSettings, writeSettings } from "yt-dlp-transcript-common/lib/settings"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { saveSettings } from "../settings/saveSettings"; import { SYNC_INTERVAL_MAX_MINUTES } from "yt-dlp-transcript-common/lib/channelConfig"; import { backupSavedVideos, @@ -101,8 +102,7 @@ export async function saveSavedVideoBackupAction( } const settings = getSettings(); try { - await writeSettings({ - ...settings, + await saveSettings({ savedVideoBackup: { enabled, dest, diff --git a/editor/app/scheduler/actions.ts b/editor/app/scheduler/actions.ts @@ -13,9 +13,9 @@ import { } from "yt-dlp-transcript-common/lib/channelConfig"; import { getSettings, - writeSettings, type SiteSettings, } from "yt-dlp-transcript-common/lib/settings"; +import { saveSettings } from "../settings/saveSettings"; import { DURATION_KEEP } from "yt-dlp-transcript-common/lib/duration"; export type SaveResult = { ok: true } | { ok: false; error: string }; @@ -114,7 +114,7 @@ export async function setChannelCadencesAction( // block is the sync operation's settings form now, on the sync operation's // page, and this is what saves it. // -// Values are clamped/sanitized by sanitizeSyncScheduler inside writeSettings, +// Values are clamped/sanitized by sanitizeSyncScheduler when saveSettings writes, // so we only coerce here. export async function saveSchedulerSettingsAction( _prev: SaveResult | undefined, @@ -143,8 +143,7 @@ export async function saveSchedulerSettingsAction( const n = Number.parseInt(raw, 10); return Number.isFinite(n) ? n : null; }; - const next: SiteSettings = { - ...current, + const next: Partial<SiteSettings> = { syncScheduler: { ...current.syncScheduler, enabled: formData.get("syncSchedulerEnabled") === "on", @@ -189,7 +188,7 @@ export async function saveSchedulerSettingsAction( }, }; try { - await writeSettings(next); + await saveSettings(next); } catch (e) { return { ok: false, error: (e as Error).message }; } diff --git a/editor/app/settings/actions.ts b/editor/app/settings/actions.ts @@ -8,7 +8,6 @@ import { defaultBuildPipeline, isBuildMode, isReportDebouncePreset, - getSettings, MIN_FREE_DISK_GB_MAX, RESUME_MARGIN_GB_DEFAULT, RESUME_MARGIN_GB_MAX, @@ -18,11 +17,10 @@ import { SLEEP_BETWEEN_DOWNLOADS_MAX_SECONDS, TRANSCRIPT_PAGE_HARD_CAP_BYTES, TRANSCRIPT_PAGE_MIN_BYTES, - writeSettings, type SiteSettings, type SocialLink, } from "yt-dlp-transcript-common/lib/settings"; -import { DEFAULT_TRANSCRIPTION_APP_ID } from "yt-dlp-transcript-common/lib/transcriptionApps"; +import { saveSettings } from "./saveSettings"; import { DEFAULT_COOKIE_MODE, isCookieMode, @@ -182,7 +180,7 @@ export async function saveSettingsAction( } // Build pipeline. Values are clamped/coerced by sanitizeBuildPipeline inside - // writeSettings, so we only read the form here (NaN/blank → default). The + // the settings schema on save, so we only read the form here (NaN/blank → default). The // deploy-page toggle also writes `mode`; whichever saves last wins. const dB = defaultBuildPipeline(); const buildModeRaw = String(formData.get("buildMode") ?? "").trim(); @@ -198,22 +196,25 @@ export async function saveSettingsAction( String(formData.get("dockerfile") ?? "").trim() || dB.dockerfile, }; - const next: SiteSettings = { + // ONLY WHAT THIS FORM EDITS. saveSettings merges the patch over the stored + // file, so every block edited elsewhere — workers (/workers), syncScheduler + // (/operations/sync), autoQueue and channelPriority (the operation pages and + // /channels), savedVideoBackup (/saved-videos), storage (/storage) and the + // four operation blocks (/operations/<id>) — survives a save here without + // being read and re-listed. Before slice 4a this literal named all 31 fields + // and carried each foreign block through `getSettings()`, which is how an + // unrelated save once disarmed a sweep. + const next: Partial<SiteSettings> = { adminTitle, maxTranscriptPageBytes: parsed, - // Workers are the source of truth; writeSettings derives the deprecated - // transcriptionApp/transcriptionApps shadow from them. parallelTranscriptions - // is vestigial (kept only for rollback) so pass the default. - transcriptionApp: DEFAULT_TRANSCRIPTION_APP_ID, - transcriptionApps: {}, - // workers: edited on /workers, beside the live list, by saveWorkersAction. - workers: getSettings().workers, cookiesFromBrowser, cookieMode, sleepBetweenDownloadsSeconds: sleepParsed, downloadFormat, minFreeDiskGB: minFreeDiskParsed, resumeMarginGB: resumeMarginParsed, + // Vestigial (the worker list is the parallelism; kept only for rollback), + // and this form has always reset it to the default. parallelTranscriptions: PARALLEL_TRANSCRIPTIONS_DEFAULT, inlineTranscribeOnFallback, skipLiveDownloads, @@ -222,43 +223,12 @@ export async function saveSettingsAction( archiveStorage, reportDebouncePreset, autoRefreshIntervalSeconds: autoRefreshParsed, - // syncScheduler: edited on /operations/sync, below the schedule, by - // saveSchedulerSettingsAction. - syncScheduler: getSettings().syncScheduler, - // Preserve the existing auto-queue policy on an unrelated settings save - // (this form doesn't edit it; the Auto-queue page does). writeSettings - // re-sanitizes it regardless. - autoQueue: getSettings().autoQueue, - // Preserved for the same reason, and for one more: it is the SOURCE the - // four roots above are compiled from, so rebuilding it here would silently - // undo a focus. Edited on /channels by saveChannelPriorityAction, which is - // its one writer. - channelPriority: getSettings().channelPriority, socialLinks, homepageUrl, - // Preserve the saved-video backup config on an unrelated settings save (the - // Saved Videos page edits it). writeSettings re-sanitizes it regardless. - savedVideoBackup: getSettings().savedVideoBackup, - // NO LONGER THIS FORM'S. The storage block became a list of named - // locations edited on /storage; preserved here the way autoQueue and - // channelPriority are, so an unrelated settings save cannot erase it. - // writeSettings re-sanitizes it regardless. - storage: getSettings().storage, buildPipeline, - // Each edited on its own operation page; preserved here. Slice 3 moved - // these four fieldsets to /operations/<id>, and with them the hidden - // `*FormPresent` markers that used to gate them — a marker only existed - // because one form saved everything, and reading an absent checkbox as - // `false` could disarm a sweep, drop the diarization capture lane, flip - // `allowRedownload` on, or arm ~194,000 model calls. Reading the CURRENT - // block is now the whole protection, exactly as it is for autoQueue. - digest: getSettings().digest, - diarization: getSettings().diarization, - backfill: getSettings().backfill, - attribution: getSettings().attribution, }; try { - await writeSettings(next); + await saveSettings(next); } catch (e) { return { ok: false, error: (e as Error).message }; } diff --git a/editor/app/settings/saveSettings.test.ts b/editor/app/settings/saveSettings.test.ts @@ -0,0 +1,78 @@ +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { test, after } from "node:test"; +import assert from "node:assert/strict"; + +// Run with: pnpm -C editor exec tsx --test "app/**/*.test.ts" +// +// THE MERGE RULE of the editor's one settings writer: ONE LEVEL DEEP. An object +// block merges over the stored block's keys; arrays and scalars replace; an +// object nested INSIDE a block replaces. Same rule as editor/e2e/helpers.ts. +// +// SETTINGS SEAM as in common/lib/settingsWrite.test.ts: getPaths() memoizes, so +// SETTINGS_FILE is set before the module under test is imported. + +const ROOT = mkdtempSync(path.join(os.tmpdir(), "save-settings-")); +process.env.TRANSCRIPTS_DIR = ROOT; +process.env.SETTINGS_FILE = path.join(ROOT, "settings.json"); + +const { mergeSettingsPatch, saveSettings } = await import("./saveSettings"); +const { defaultSiteSettings, getSettings } = await import( + "yt-dlp-transcript-common/lib/settings" +); + +after(() => rm(ROOT, { recursive: true, force: true })); + +test("an object block merges over the stored block, one level", () => { + const base = defaultSiteSettings(); + base.syncScheduler.maxConcurrentSyncs = 7; + const out = mergeSettingsPatch(base, { + syncScheduler: { enabled: true } as typeof base.syncScheduler, + }); + assert.equal(out.syncScheduler.enabled, true); + assert.equal(out.syncScheduler.maxConcurrentSyncs, 7); + // The input is not mutated. + assert.equal(base.syncScheduler.enabled, false); +}); + +test("arrays and scalars replace", () => { + const base = defaultSiteSettings(); + base.socialLinks = [{ label: "a", url: "/a", svg: "<svg></svg>" }]; + const out = mergeSettingsPatch(base, { socialLinks: [], adminTitle: "X" }); + assert.deepEqual(out.socialLinks, []); + assert.equal(out.adminTitle, "X"); + assert.equal(out.minFreeDiskGB, base.minFreeDiskGB); +}); + +test("an object nested inside a block replaces, it is not merged", () => { + const base = defaultSiteSettings(); + base.digest.apps = { a: { model: "m1", numCtx: 4096 } }; + base.channelPriority.channels = { keep: { tier: "low" } }; + const out = mergeSettingsPatch(base, { + digest: { apps: { b: { model: "m2" } } } as unknown as typeof base.digest, + channelPriority: { + focus: { kind: "none" }, + channels: { other: { tier: "paused" } }, + }, + }); + assert.deepEqual(out.digest.apps, { b: { model: "m2" } }); + assert.equal(out.digest.localAppId, base.digest.localAppId); + assert.deepEqual(out.channelPriority.channels, { other: { tier: "paused" } }); +}); + +test("saveSettings writes the merged result and touches nothing else", async () => { + writeFileSync( + process.env.SETTINGS_FILE!, + JSON.stringify({ adminTitle: "Kept", minFreeDiskGB: 9 }), + ); + await saveSettings({ minFreeDiskGB: 3, backfill: { concurrency: 4 } as never }); + const s = getSettings(); + assert.equal(s.adminTitle, "Kept"); + assert.equal(s.minFreeDiskGB, 3); + assert.equal(s.backfill.concurrency, 4); + assert.equal(s.backfill.allowRedownload, false); + const onDisk = JSON.parse(readFileSync(process.env.SETTINGS_FILE!, "utf8")); + assert.equal(onDisk.adminTitle, "Kept"); +}); diff --git a/editor/app/settings/saveSettings.ts b/editor/app/settings/saveSettings.ts @@ -0,0 +1,58 @@ +import { + getSettings, + writeSettings, + type SiteSettings, +} from "yt-dlp-transcript-common/lib/settings"; + +// THE EDITOR'S ONE SETTINGS WRITER (one-core phase 3 slice 4a). +// +// Every server action that changes settings.json calls `saveSettings(patch)` +// with only what it changed; this reads the current settings, merges the +// patch, and hands the result to `writeSettings` — which is imported by this +// file and by no other file under editor/app. +// +// THE MERGE IS ONE LEVEL DEEP, and no deeper — the same rule e2e's +// `writeSettings` helper (editor/e2e/helpers.ts) applies to the fixture: +// +// - a patch key whose value is a PLAIN OBJECT merges over the current block's +// keys: `{ syncScheduler: { enabled: true } }` changes one flag and keeps the +// rest of the scheduler; +// - ARRAYS and SCALARS replace: `workers: []` means no workers; +// - an object NESTED INSIDE a block replaces: `autoQueue: { digest: {…} }` +// replaces the digest lane whole (a half-merged policy tree would be a +// worse surprise than a replaced one), and `channelPriority: { channels }` +// replaces the whole channel map. +// +// WHY A PATCH OF THE WHOLE SETTINGS OBJECT, NOT `saveSettingsBlock(block, +// patch)` as plans/one-core.md sketched: the channel-priority action writes +// `channelPriority` AND the four `autoQueue` roots compiled from it in ONE +// write. A one-block signature would split that into two, and a reader between +// them would see a priority model and trees that disagree. +// +// NOT a "use server" module: it is a helper the action files call, never an +// action a client can invoke with an arbitrary patch. + +type Patch = Partial<SiteSettings>; + +function isPlainObject(v: unknown): v is Record<string, unknown> { + return typeof v === "object" && v !== null && !Array.isArray(v); +} + +// Pure, so the merge rule is testable without a settings file. +export function mergeSettingsPatch( + current: SiteSettings, + patch: Patch, +): SiteSettings { + const merged: Record<string, unknown> = { ...current }; + for (const [key, value] of Object.entries(patch)) { + if (value === undefined) continue; + const base = (current as Record<string, unknown>)[key]; + merged[key] = + isPlainObject(value) && isPlainObject(base) ? { ...base, ...value } : value; + } + return merged as SiteSettings; +} + +export async function saveSettings(patch: Patch): Promise<void> { + await writeSettings(mergeSettingsPatch(getSettings(), patch)); +} diff --git a/editor/app/sites/lib/buildModeAction.ts b/editor/app/sites/lib/buildModeAction.ts @@ -4,9 +4,9 @@ import { revalidatePath } from "next/cache"; import { getSettings, isBuildMode, - writeSettings, type BuildMode, } from "yt-dlp-transcript-common/lib/settings"; +import { saveSettings } from "../../settings/saveSettings"; // Persist the build-mode choice (Basic vs Docker) from the family page (/sites) // so it becomes the default for every subsequent build. Only the mode is touched @@ -17,10 +17,8 @@ export async function setBuildModeAction( ): Promise<{ ok: boolean }> { if (!isBuildMode(mode)) return { ok: false }; const settings = getSettings(); - await writeSettings({ - ...settings, - buildPipeline: { ...settings.buildPipeline, mode }, - }); + // One key of one block: saveSettings merges it over the stored pipeline. + await saveSettings({ buildPipeline: { ...settings.buildPipeline, mode } }); revalidatePath("/sites"); return { ok: true }; } diff --git a/editor/app/storage/actions.ts b/editor/app/storage/actions.ts @@ -3,10 +3,8 @@ import path from "node:path"; import { revalidatePath } from "next/cache"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { - getSettings, - writeSettings, -} from "yt-dlp-transcript-common/lib/settings"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { saveSettings } from "../settings/saveSettings"; import type { StorageLocation } from "yt-dlp-transcript-common/lib/storageLocations"; import { mountByUuid, @@ -105,8 +103,11 @@ export async function addStorageLocationAction( autoRepoint: draft.autoRepoint, }; const locations = [...settings.storage.locations, location]; - await writeSettings({ - ...settings, + // A PATCH of the storage block: saveSettings merges these keys over the + // stored block, so `savedVideosLocationId` — which this action does not + // edit — survives the save. (Before slice 4a it was rebuilt from these two + // keys alone, and adding or editing a location erased the store's record.) + await saveSettings({ storage: { locations, // The FIRST location is the default whether or not the box was ticked: @@ -152,8 +153,8 @@ export async function editStorageLocationAction( }; if (!keepVolume) delete (updated as { volume?: unknown }).volume; - await writeSettings({ - ...settings, + // A patch of the storage block — `savedVideosLocationId` survives (see above). + await saveSettings({ storage: { locations: settings.storage.locations.map((l) => (l.id === id ? updated : l)), defaultLocationId: draft.makeDefault @@ -214,8 +215,8 @@ export async function deleteStorageLocationAction( }; } const locations = settings.storage.locations.filter((l) => l.id !== id); - await writeSettings({ - ...settings, + // A patch of the storage block — `savedVideosLocationId` survives (see above). + await saveSettings({ storage: { locations, defaultLocationId: diff --git a/editor/app/workers/actions.ts b/editor/app/workers/actions.ts @@ -3,7 +3,7 @@ import { revalidatePath } from "next/cache"; import { getWorkerPool } from "yt-dlp-transcript-common/jobs/workerPool"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; -import { getSettings, writeSettings } from "yt-dlp-transcript-common/lib/settings"; +import { saveSettings } from "../settings/saveSettings"; import { sanitizeWorkers, validateWorkers, @@ -38,7 +38,7 @@ export async function saveWorkersAction( formData: FormData, ): Promise<SaveWorkersResult> { // Workers are submitted as a JSON array by the WorkersField client component. - // Sanitize + validate here for a friendly error; writeSettings re-validates. + // Sanitize + validate here for a friendly error; writeSettings re-validates on save. let workersInput: unknown; try { workersInput = JSON.parse(String(formData.get("workersJson") ?? "[]")); @@ -50,7 +50,7 @@ export async function saveWorkersAction( if (workersErr) return { ok: false, error: workersErr }; try { - await writeSettings({ ...getSettings(), workers }); + await saveSettings({ workers }); } catch (e) { return { ok: false, error: (e as Error).message }; }