feat(Settings): enhance writeSettings to skip unchanged files and serialize config into JSON strings

This commit is contained in:
Jaime Idolpx 2026-06-11 21:55:36 -04:00
parent 488d2304be
commit 199ae0e3d8

View File

@ -164,34 +164,59 @@ export async function readSettings(): Promise<ReadSettingsResult | null> {
} }
} }
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 * Persist the settings object to the WebDAV server, creating the
* containing `/.sys/` directory if needed. Pretty-prints with 2-space * containing `/.sys/` directory if needed. Pretty-prints with 2-space
* indentation so the file is diff-friendly. * 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<void> { export async function writeSettings(
config: SettingsConfig,
lastSaved?: SavedJson | null,
): Promise<SavedJson> {
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 */ } try { await createFolder(SETTINGS_DIR, true); } catch { /* exists */ }
const { devices, ...mainConfig } = config; const writes: Promise<void>[] = [];
const configJson = JSON.stringify(mainConfig, null, 2) + '\n'; if (writeConfig) writes.push(putFileContents(SETTINGS_PATH, next.configJson));
const devicesJson = JSON.stringify({ devices }, null, 2) + '\n'; if (writeDevices) writes.push(putFileContents(DEVICES_PATH, next.devicesJson));
await Promise.all(writes);
await Promise.all([
putFileContents(SETTINGS_PATH, configJson),
putFileContents(DEVICES_PATH, devicesJson),
]);
// Mirror to /sd/.sys/ if the SD card is mounted. // Mirror to /sd/.sys/ if the SD card is mounted.
try { try {
const sdStat = await stat('/sd'); const sdStat = await stat('/sd');
if (sdStat) { if (sdStat) {
try { await createFolder(SD_SYS_DIR, true); } catch { /* exists */ } try { await createFolder(SD_SYS_DIR, true); } catch { /* exists */ }
await Promise.all([ const sdWrites: Promise<void>[] = [];
putFileContents(SD_SYS_DIR + '/config.json', configJson), if (writeConfig) sdWrites.push(putFileContents(SD_SYS_DIR + '/config.json', next.configJson));
putFileContents(SD_SYS_DIR + '/devices.json', devicesJson), if (writeDevices) sdWrites.push(putFileContents(SD_SYS_DIR + '/devices.json', next.devicesJson));
]); await Promise.all(sdWrites);
} }
} catch { /* /sd unreachable — skip mirror */ } } catch { /* /sd unreachable — skip mirror */ }
return next;
} }
export type SaveStatus = export type SaveStatus =
@ -246,6 +271,7 @@ export function useSettings(): UseSettingsResult {
const dirtyRef = useRef<boolean>(false); const dirtyRef = useRef<boolean>(false);
const pendingRef = useRef<number>(0); const pendingRef = useRef<number>(0);
const savedTimerRef = useRef<number | null>(null); const savedTimerRef = useRef<number | null>(null);
const lastSavedRef = useRef<SavedJson | null>(null);
const load = useCallback(async () => { const load = useCallback(async () => {
setSaveStatus('loading'); setSaveStatus('loading');
@ -257,7 +283,7 @@ export function useSettings(): UseSettingsResult {
// Persist the migrated config immediately so the device is updated. // Persist the migrated config immediately so the device is updated.
setSaveStatus('saving'); setSaveStatus('saving');
try { try {
await writeSettings(result.config); lastSavedRef.current = await writeSettings(result.config);
setSaveStatus('saved'); setSaveStatus('saved');
savedTimerRef.current = window.setTimeout(() => { savedTimerRef.current = window.setTimeout(() => {
setSaveStatus((s) => (s === 'saved' ? 'idle' : s)); setSaveStatus((s) => (s === 'saved' ? 'idle' : s));
@ -273,6 +299,8 @@ export function useSettings(): UseSettingsResult {
setLoaded(true); setLoaded(true);
return; return;
} }
// Record what the server holds so flushNow can skip unchanged files.
lastSavedRef.current = serializeConfig(result.config);
} }
loadedRef.current = true; loadedRef.current = true;
setLoaded(true); setLoaded(true);
@ -299,7 +327,7 @@ export function useSettings(): UseSettingsResult {
setPendingCount(0); setPendingCount(0);
setSaveStatus('saving'); setSaveStatus('saving');
try { try {
await writeSettings(configRef.current); lastSavedRef.current = await writeSettings(configRef.current, lastSavedRef.current);
setSaveStatus('saved'); setSaveStatus('saved');
// Drop the "saved" badge after a short delay so it doesn't linger. // Drop the "saved" badge after a short delay so it doesn't linger.
savedTimerRef.current = window.setTimeout(() => { savedTimerRef.current = window.setTimeout(() => {
@ -324,19 +352,20 @@ export function useSettings(): UseSettingsResult {
// Modern browsers: `fetch` with `keepalive: true` continues even // Modern browsers: `fetch` with `keepalive: true` continues even
// after the page is being unloaded. We don't await it. // after the page is being unloaded. We don't await it.
try { try {
const { devices, ...mainConfig } = configRef.current; const next = serializeConfig(configRef.current);
const last = lastSavedRef.current;
if (!last || next.configJson !== last.configJson) {
void fetch(absoluteUrl(SETTINGS_PATH), { void fetch(absoluteUrl(SETTINGS_PATH), {
method: 'PUT', method: 'PUT', body: next.configJson,
body: JSON.stringify(mainConfig, null, 2) + '\n', headers: { 'Content-Type': 'application/json' }, keepalive: true,
headers: { 'Content-Type': 'application/json' },
keepalive: true,
}); });
}
if (!last || next.devicesJson !== last.devicesJson) {
void fetch(absoluteUrl(DEVICES_PATH), { void fetch(absoluteUrl(DEVICES_PATH), {
method: 'PUT', method: 'PUT', body: next.devicesJson,
body: JSON.stringify({ devices }, null, 2) + '\n', headers: { 'Content-Type': 'application/json' }, keepalive: true,
headers: { 'Content-Type': 'application/json' },
keepalive: true,
}); });
}
} catch { } catch {
/* ignore */ /* ignore */
} }