meatloaf-config/src/app/settings.ts
Jaime Idolpx aa67b9ee17 fix(IECPage): update section title from "Directory Settings" to "Directory Enhancements" for clarity
feat(settings): add getTextWithFallback function to improve file reading reliability
fix(config): reorder nfo_header in directory settings for consistency
2026-06-10 16:37:38 -04:00

286 lines
10 KiB
TypeScript

/**
* Settings persistence.
*
* Settings live at `/.sys/config.json` on the WebDAV server. The bundled
* `config.json` is used as the initial value while the file is being
* fetched, and as a fallback if the server is unreachable.
*
* The `useSettings()` hook returns the same `(config, setConfig)` shape
* the page components already use, but every change is also persisted
* to the WebDAV server after a short debounce.
*/
import { useCallback, useEffect, useRef, useState } from 'react';
import bundledConfig from '../imports/config.json';
import {
createFolder,
getFileContents,
putFileContents,
stat,
} from './webdav';
/** The canonical paths on the WebDAV server. */
export const SETTINGS_PATH = '/.sys/config.json';
export const DEVICES_PATH = '/.sys/devices.json';
const SETTINGS_DIR = '/.sys';
const SD_SYS_DIR = '/sd/.sys';
/**
* How long to wait after the last change before writing to disk.
*
* The debounce timer is reset on every change, so this is effectively
* "the number of milliseconds the user must be idle before the change
* is committed". Bump this up to give the user more time to make
* multiple related edits; lower it to write changes more eagerly.
*/
export const SAVE_IDLE_MS = 3000;
/** How long the "Saved" indicator lingers before fading out. */
const SAVED_INDICATOR_MS = 1500;
export type SettingsConfig = Record<string, any>;
/** Try primary path first, fall back to secondary. Returns null if both fail. */
async function getTextWithFallback(primary: string, fallback: string): Promise<string | null> {
try { return await getFileContents(primary).then(b => b.text()); } catch { /* try fallback */ }
try { return await getFileContents(fallback).then(b => b.text()); } catch { /* both failed */ }
return null;
}
/** Read both config files from the WebDAV server and merge them. Returns null on failure. */
export async function readSettings(): Promise<SettingsConfig | null> {
try {
// Mirror firmware priority: /sd/.sys/ first, fall back to /.sys/
const [configText, devicesText] = await Promise.all([
getTextWithFallback('/sd' + SETTINGS_PATH, SETTINGS_PATH),
getTextWithFallback('/sd' + DEVICES_PATH, DEVICES_PATH),
]);
if (!configText) return null;
const config = JSON.parse(configText);
if (!config || typeof config !== 'object') return null;
if (devicesText) {
const devices = JSON.parse(devicesText);
if (devices && typeof devices === 'object') {
// One-level deep merge: devices.json keys are merged into matching
// top-level objects in config (e.g. devices.iec.devices → config.iec.devices).
for (const [k, v] of Object.entries(devices)) {
if (config[k] && typeof config[k] === 'object' && !Array.isArray(config[k]) &&
v && typeof v === 'object' && !Array.isArray(v)) {
config[k] = { ...config[k], ...(v as object) };
} else {
config[k] = v;
}
}
}
}
return config as SettingsConfig;
} catch {
return null;
}
}
/**
* 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.
*/
export async function writeSettings(config: SettingsConfig): Promise<void> {
try { await createFolder(SETTINGS_DIR, true); } catch { /* exists */ }
const { iec, ...mainConfig } = config;
const { devices: iecDevices, ...iecBusConfig } = (iec ?? {}) as any;
const configJson = JSON.stringify({ ...mainConfig, iec: iecBusConfig }, null, 2) + '\n';
const devicesJson = JSON.stringify({ iec: { devices: iecDevices } }, null, 2) + '\n';
await Promise.all([
putFileContents(SETTINGS_PATH, configJson),
putFileContents(DEVICES_PATH, devicesJson),
]);
// 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),
]);
}
} catch { /* /sd unreachable — skip mirror */ }
}
export type SaveStatus =
| 'idle' // nothing to do
| 'loading' // initial fetch from server
| 'unsaved' // one or more changes pending; waiting for the idle timer
| 'saving' // a write is currently in flight
| 'saved' // last write succeeded (fades back to 'idle' shortly)
| 'error'; // last write failed; will retry on the next change
export interface UseSettingsResult {
config: SettingsConfig;
setConfig: (next: SettingsConfig | ((prev: SettingsConfig) => SettingsConfig)) => void;
/** Whether the initial load from the server has completed. */
loaded: boolean;
/** Current save status (see {@link SaveStatus}). */
saveStatus: SaveStatus;
/** Number of pending (uncommitted) changes since the last successful save. */
pendingCount: number;
/** Force a re-fetch from the server and replace local state. */
reload: () => Promise<void>;
/** Flush any pending changes immediately, ignoring the idle timer. */
flushNow: () => Promise<void>;
}
/**
* React hook that loads settings from the WebDAV server on mount and
* auto-saves them on every change (debounced).
*
* Behavior:
* - State is initialised with the bundled `config.json` so the UI
* renders immediately, even before the server is contacted.
* - On mount, an async fetch replaces state with the server's copy.
* - On every `setConfig`, a debounced write fires `SAVE_DEBOUNCE_MS`
* after the most recent change.
* - On `beforeunload`, any pending change is flushed synchronously
* (best-effort: `sendBeacon` cannot be used with WebDAV, so the page
* may be allowed to close only if the user agrees via the browser's
* default "Leave site?" dialog).
*/
export function useSettings(): UseSettingsResult {
const [config, setConfigState] = useState<SettingsConfig>(bundledConfig as SettingsConfig);
const [loaded, setLoaded] = useState(false);
const [saveStatus, setSaveStatus] = useState<SaveStatus>('loading');
const [pendingCount, setPendingCount] = useState(0);
// Refs so the debounced writer always sees the latest values without
// needing to re-subscribe on every render.
const configRef = useRef<SettingsConfig>(config);
const loadedRef = useRef<boolean>(false);
const timerRef = useRef<number | null>(null);
const dirtyRef = useRef<boolean>(false);
const pendingRef = useRef<number>(0);
const savedTimerRef = useRef<number | null>(null);
const load = useCallback(async () => {
setSaveStatus('loading');
const fromServer = await readSettings();
if (fromServer) {
configRef.current = fromServer;
setConfigState(fromServer);
}
loadedRef.current = true;
setLoaded(true);
setSaveStatus('idle');
}, []);
useEffect(() => {
void load();
}, [load]);
const flushNow = useCallback(async () => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
if (savedTimerRef.current !== null) {
window.clearTimeout(savedTimerRef.current);
savedTimerRef.current = null;
}
if (!dirtyRef.current) return;
dirtyRef.current = false;
const pending = pendingRef.current;
pendingRef.current = 0;
setPendingCount(0);
setSaveStatus('saving');
try {
await writeSettings(configRef.current);
setSaveStatus('saved');
// Drop the "saved" badge after a short delay so it doesn't linger.
savedTimerRef.current = window.setTimeout(() => {
setSaveStatus((s) => (s === 'saved' ? 'idle' : s));
}, SAVED_INDICATOR_MS);
} catch (e) {
// Roll back: the change is still pending.
dirtyRef.current = true;
pendingRef.current = pending;
setPendingCount(pending);
setSaveStatus('error');
// Re-throw so the caller can handle it if they want.
throw e;
}
}, []);
// Flush on tab close (best effort; most browsers will block until the
// fetch completes if it can).
useEffect(() => {
const onBeforeUnload = (e: BeforeUnloadEvent) => {
if (!dirtyRef.current) return;
// Modern browsers: `fetch` with `keepalive: true` continues even
// after the page is being unloaded. We don't await it.
try {
const { iec, ...mainConfig } = configRef.current;
const { devices: iecDevices, ...iecBusConfig } = (iec ?? {}) as any;
void fetch(absoluteUrl(SETTINGS_PATH), {
method: 'PUT',
body: JSON.stringify({ ...mainConfig, iec: iecBusConfig }, null, 2) + '\n',
headers: { 'Content-Type': 'application/json' },
keepalive: true,
});
void fetch(absoluteUrl(DEVICES_PATH), {
method: 'PUT',
body: JSON.stringify({ iec: { devices: iecDevices } }, null, 2) + '\n',
headers: { 'Content-Type': 'application/json' },
keepalive: true,
});
} catch {
/* ignore */
}
e.preventDefault();
e.returnValue = '';
};
window.addEventListener('beforeunload', onBeforeUnload);
return () => window.removeEventListener('beforeunload', onBeforeUnload);
}, []);
const setConfig = useCallback(
(next: SettingsConfig | ((prev: SettingsConfig) => SettingsConfig)) => {
const value = typeof next === 'function'
? (next as (p: SettingsConfig) => SettingsConfig)(configRef.current)
: next;
configRef.current = value;
setConfigState(value);
dirtyRef.current = true;
pendingRef.current += 1;
setPendingCount(pendingRef.current);
setSaveStatus('unsaved');
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
}
// Reset the idle timer: save `SAVE_IDLE_MS` after the most recent
// change. Every additional change resets the timer again.
timerRef.current = window.setTimeout(() => {
void flushNow();
}, SAVE_IDLE_MS);
},
[flushNow],
);
return {
config,
setConfig,
loaded,
saveStatus,
pendingCount,
reload: load,
flushNow,
};
}
function absoluteUrl(path: string): string {
return `${window.location.protocol}//${window.location.hostname}${
window.location.port ? ':' + window.location.port : ''
}${path}`;
}