From 199ae0e3d825606d42a61944b930a5e2349b3597 Mon Sep 17 00:00:00 2001 From: Jaime Idolpx Date: Thu, 11 Jun 2026 21:55:36 -0400 Subject: [PATCH] feat(Settings): enhance writeSettings to skip unchanged files and serialize config into JSON strings --- src/app/settings.ts | 97 +++++++++++++++++++++++++++++---------------- 1 file changed, 63 insertions(+), 34 deletions(-) diff --git a/src/app/settings.ts b/src/app/settings.ts index 96515a6..6b402d5 100644 --- a/src/app/settings.ts +++ b/src/app/settings.ts @@ -164,34 +164,59 @@ export async function readSettings(): Promise { } } +export interface SavedJson { + configJson: string; + devicesJson: string; +} + +/** Serialize config into the two canonical JSON strings without writing anything. */ +export function serializeConfig(config: SettingsConfig): SavedJson { + const { devices, ...mainConfig } = config; + return { + configJson: JSON.stringify(mainConfig, null, 2) + '\n', + devicesJson: JSON.stringify({ devices }, null, 2) + '\n', + }; +} + /** * Persist the settings object to the WebDAV server, creating the * containing `/.sys/` directory if needed. Pretty-prints with 2-space * indentation so the file is diff-friendly. + * + * Pass `lastSaved` to skip writing files whose content hasn't changed. + * Returns the canonical JSON strings that were written (or would have been). */ -export async function writeSettings(config: SettingsConfig): Promise { +export async function writeSettings( + config: SettingsConfig, + lastSaved?: SavedJson | null, +): Promise { + const next = serializeConfig(config); + + const writeConfig = !lastSaved || next.configJson !== lastSaved.configJson; + const writeDevices = !lastSaved || next.devicesJson !== lastSaved.devicesJson; + + if (!writeConfig && !writeDevices) return next; + try { await createFolder(SETTINGS_DIR, true); } catch { /* exists */ } - const { devices, ...mainConfig } = config; - const configJson = JSON.stringify(mainConfig, null, 2) + '\n'; - const devicesJson = JSON.stringify({ devices }, null, 2) + '\n'; - - await Promise.all([ - putFileContents(SETTINGS_PATH, configJson), - putFileContents(DEVICES_PATH, devicesJson), - ]); + const writes: Promise[] = []; + if (writeConfig) writes.push(putFileContents(SETTINGS_PATH, next.configJson)); + if (writeDevices) writes.push(putFileContents(DEVICES_PATH, next.devicesJson)); + await Promise.all(writes); // Mirror to /sd/.sys/ if the SD card is mounted. try { const sdStat = await stat('/sd'); if (sdStat) { try { await createFolder(SD_SYS_DIR, true); } catch { /* exists */ } - await Promise.all([ - putFileContents(SD_SYS_DIR + '/config.json', configJson), - putFileContents(SD_SYS_DIR + '/devices.json', devicesJson), - ]); + const sdWrites: Promise[] = []; + if (writeConfig) sdWrites.push(putFileContents(SD_SYS_DIR + '/config.json', next.configJson)); + if (writeDevices) sdWrites.push(putFileContents(SD_SYS_DIR + '/devices.json', next.devicesJson)); + await Promise.all(sdWrites); } } catch { /* /sd unreachable — skip mirror */ } + + return next; } export type SaveStatus = @@ -240,12 +265,13 @@ export function useSettings(): UseSettingsResult { // Refs so the debounced writer always sees the latest values without // needing to re-subscribe on every render. - const configRef = useRef(config); - const loadedRef = useRef(false); - const timerRef = useRef(null); - const dirtyRef = useRef(false); - const pendingRef = useRef(0); - const savedTimerRef = useRef(null); + const configRef = useRef(config); + const loadedRef = useRef(false); + const timerRef = useRef(null); + const dirtyRef = useRef(false); + const pendingRef = useRef(0); + const savedTimerRef = useRef(null); + const lastSavedRef = useRef(null); const load = useCallback(async () => { setSaveStatus('loading'); @@ -257,7 +283,7 @@ export function useSettings(): UseSettingsResult { // Persist the migrated config immediately so the device is updated. setSaveStatus('saving'); try { - await writeSettings(result.config); + lastSavedRef.current = await writeSettings(result.config); setSaveStatus('saved'); savedTimerRef.current = window.setTimeout(() => { setSaveStatus((s) => (s === 'saved' ? 'idle' : s)); @@ -273,6 +299,8 @@ export function useSettings(): UseSettingsResult { setLoaded(true); return; } + // Record what the server holds so flushNow can skip unchanged files. + lastSavedRef.current = serializeConfig(result.config); } loadedRef.current = true; setLoaded(true); @@ -299,7 +327,7 @@ export function useSettings(): UseSettingsResult { setPendingCount(0); setSaveStatus('saving'); try { - await writeSettings(configRef.current); + lastSavedRef.current = await writeSettings(configRef.current, lastSavedRef.current); setSaveStatus('saved'); // Drop the "saved" badge after a short delay so it doesn't linger. savedTimerRef.current = window.setTimeout(() => { @@ -324,19 +352,20 @@ export function useSettings(): UseSettingsResult { // Modern browsers: `fetch` with `keepalive: true` continues even // after the page is being unloaded. We don't await it. try { - const { devices, ...mainConfig } = configRef.current; - void fetch(absoluteUrl(SETTINGS_PATH), { - method: 'PUT', - body: JSON.stringify(mainConfig, null, 2) + '\n', - headers: { 'Content-Type': 'application/json' }, - keepalive: true, - }); - void fetch(absoluteUrl(DEVICES_PATH), { - method: 'PUT', - body: JSON.stringify({ devices }, null, 2) + '\n', - headers: { 'Content-Type': 'application/json' }, - keepalive: true, - }); + const next = serializeConfig(configRef.current); + const last = lastSavedRef.current; + if (!last || next.configJson !== last.configJson) { + void fetch(absoluteUrl(SETTINGS_PATH), { + method: 'PUT', body: next.configJson, + headers: { 'Content-Type': 'application/json' }, keepalive: true, + }); + } + if (!last || next.devicesJson !== last.devicesJson) { + void fetch(absoluteUrl(DEVICES_PATH), { + method: 'PUT', body: next.devicesJson, + headers: { 'Content-Type': 'application/json' }, keepalive: true, + }); + } } catch { /* ignore */ }