From f0bd84bf635ac2ac90b225c07bf5a7541628ab17 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Sun, 8 Feb 2026 13:52:39 -0600 Subject: [PATCH] refactor to shared utils and add save all button --- web/public/locales/en/common.json | 4 +- web/public/locales/en/views/settings.json | 7 +- .../config-form/sections/BaseSection.tsx | 170 +------- web/src/pages/Settings.tsx | 330 +++++++++++++-- web/src/utils/configSaveUtil.ts | 399 ++++++++++++++++++ 5 files changed, 720 insertions(+), 190 deletions(-) create mode 100644 web/src/utils/configSaveUtil.ts diff --git a/web/public/locales/en/common.json b/web/public/locales/en/common.json index dc39e7c26..9878a44cb 100644 --- a/web/public/locales/en/common.json +++ b/web/public/locales/en/common.json @@ -156,7 +156,9 @@ "modified": "Modified", "overridden": "Overridden", "resetToGlobal": "Reset to Global", - "resetToDefault": "Reset to Default" + "resetToDefault": "Reset to Default", + "saveAll": "Save All", + "savingAll": "Saving All…" }, "menu": { "system": "System", diff --git a/web/public/locales/en/views/settings.json b/web/public/locales/en/views/settings.json index 7d07d5064..5d827c0ec 100644 --- a/web/public/locales/en/views/settings.json +++ b/web/public/locales/en/views/settings.json @@ -1311,7 +1311,12 @@ "error": "Failed to save settings", "validationError": "Validation failed: {{message}}", "resetSuccess": "Reset to global defaults", - "resetError": "Failed to reset settings" + "resetError": "Failed to reset settings", + "saveAllSuccess_one": "Saved {{count}} section successfully.", + "saveAllSuccess_other": "All {{count}} sections saved successfully.", + "saveAllPartial_one": "{{successCount}} of {{totalCount}} section saved. {{failCount}} failed.", + "saveAllPartial_other": "{{successCount}} of {{totalCount}} sections saved. {{failCount}} failed.", + "saveAllFailure": "Failed to save all sections." }, "unsavedChanges": "You have unsaved changes", "confirmReset": "Confirm Reset", diff --git a/web/src/components/config-form/sections/BaseSection.tsx b/web/src/components/config-form/sections/BaseSection.tsx index 13c8aad93..bde48dcca 100644 --- a/web/src/components/config-form/sections/BaseSection.tsx +++ b/web/src/components/config-form/sections/BaseSection.tsx @@ -17,10 +17,7 @@ import { sanitizeOverridesForSection, } from "./section-special-cases"; import { getSectionValidation } from "../section-validations"; -import { - useConfigOverride, - normalizeConfigValue, -} from "@/hooks/use-config-override"; +import { useConfigOverride } from "@/hooks/use-config-override"; import { useSectionSchema } from "@/hooks/use-config-schema"; import type { FrigateConfig } from "@/types/frigateConfig"; import { Badge } from "@/components/ui/badge"; @@ -28,7 +25,6 @@ import { Button } from "@/components/ui/button"; import { LuChevronDown, LuChevronRight } from "react-icons/lu"; import Heading from "@/components/ui/heading"; import get from "lodash/get"; -import unset from "lodash/unset"; import cloneDeep from "lodash/cloneDeep"; import isEqual from "lodash/isEqual"; import { @@ -47,9 +43,15 @@ import { AlertDialogTitle, } from "@/components/ui/alert-dialog"; import { applySchemaDefaults } from "@/lib/config-schema"; -import { cn, isJsonObject } from "@/lib/utils"; -import { ConfigSectionData, JsonObject, JsonValue } from "@/types/configForm"; +import { cn } from "@/lib/utils"; +import { ConfigSectionData, JsonValue } from "@/types/configForm"; import ActivityIndicator from "@/components/indicators/activity-indicator"; +import { + cameraUpdateTopicMap, + buildOverrides, + sanitizeSectionData as sharedSanitizeSectionData, + requiresRestartForOverrides as sharedRequiresRestartForOverrides, +} from "@/utils/configSaveUtil"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import { useRestart } from "@/api/ws"; @@ -128,28 +130,6 @@ export interface CreateSectionOptions { defaultConfig: SectionConfig; } -const cameraUpdateTopicMap: Record = { - detect: "detect", - record: "record", - snapshots: "snapshots", - motion: "motion", - objects: "objects", - review: "review", - audio: "audio", - notifications: "notifications", - live: "live", - timestamp_style: "timestamp_style", - audio_transcription: "audio_transcription", - birdseye: "birdseye", - face_recognition: "face_recognition", - ffmpeg: "ffmpeg", - lpr: "lpr", - semantic_search: "semantic_search", - mqtt: "mqtt", - onvif: "onvif", - ui: "ui", -}; - export type ConfigSectionProps = BaseSectionProps & CreateSectionOptions; export function ConfigSection({ @@ -276,22 +256,8 @@ export function ConfigSection({ }, [config, rawSectionValue]); const sanitizeSectionData = useCallback( - (data: ConfigSectionData) => { - const normalized = normalizeConfigValue(data) as ConfigSectionData; - if ( - !sectionConfig.hiddenFields || - sectionConfig.hiddenFields.length === 0 - ) { - return normalized; - } - - const cleaned = cloneDeep(normalized) as ConfigSectionData; - sectionConfig.hiddenFields.forEach((path) => { - if (!path) return; - unset(cleaned, path); - }); - return cleaned; - }, + (data: ConfigSectionData) => + sharedSanitizeSectionData(data, sectionConfig.hiddenFields), [sectionConfig.hiddenFields], ); @@ -354,94 +320,6 @@ export function ConfigSection({ } }, [formKey]); - // Build a minimal overrides payload by comparing `current` against `base` - // (existing config) and `defaults` (schema defaults). - // - Returns `undefined` for null/empty values or when `current` equals `base` - // (or equals `defaults` when `base` is undefined). - // - For objects, recurses and returns an object containing only keys that - // are overridden; returns `undefined` if no keys are overridden. - const buildOverrides = useCallback( - ( - current: unknown, - base: unknown, - defaults: unknown, - ): unknown | undefined => { - if (current === null || current === undefined || current === "") { - return undefined; - } - - if (Array.isArray(current)) { - if ( - current.length === 0 && - (base === undefined || base === null) && - (defaults === undefined || defaults === null) - ) { - return undefined; - } - if ( - (base === undefined && - defaults !== undefined && - isEqual(current, defaults)) || - isEqual(current, base) - ) { - return undefined; - } - return current; - } - - if (isJsonObject(current)) { - const currentObj = current; - const baseObj = isJsonObject(base) ? base : undefined; - const defaultsObj = isJsonObject(defaults) ? defaults : undefined; - - const result: JsonObject = {}; - for (const [key, value] of Object.entries(currentObj)) { - if (value === undefined && baseObj && baseObj[key] !== undefined) { - result[key] = ""; - continue; - } - const overrideValue = buildOverrides( - value, - baseObj ? baseObj[key] : undefined, - defaultsObj ? defaultsObj[key] : undefined, - ); - if (overrideValue !== undefined) { - result[key] = overrideValue as JsonValue; - } - } - - if (baseObj) { - for (const [key, baseValue] of Object.entries(baseObj)) { - if (Object.prototype.hasOwnProperty.call(currentObj, key)) { - continue; - } - if (baseValue === undefined) { - continue; - } - result[key] = ""; - } - } - - return Object.keys(result).length > 0 ? result : undefined; - } - - if ( - base === undefined && - defaults !== undefined && - isEqual(current, defaults) - ) { - return undefined; - } - - if (isEqual(current, base)) { - return undefined; - } - - return current; - }, - [], - ); - // Track if there are unsaved changes const hasChanges = useMemo(() => { if (!pendingData) return false; @@ -510,7 +388,6 @@ export function ConfigSection({ pendingData, compareBaseData, sanitizeSectionData, - buildOverrides, effectiveSchemaDefaults, setPendingData, setPendingOverrides, @@ -539,7 +416,6 @@ export function ConfigSection({ }, [ currentFormData, sanitizeSectionData, - buildOverrides, compareBaseData, effectiveSchemaDefaults, ]); @@ -550,23 +426,12 @@ export function ConfigSection({ const uiOverrides = dirtyOverrides ?? effectiveOverrides; const requiresRestartForOverrides = useCallback( - (overrides: unknown) => { - if (sectionConfig.restartRequired === undefined) { - return requiresRestart; - } - - if (sectionConfig.restartRequired.length === 0) { - return false; - } - - if (!overrides || typeof overrides !== "object") { - return false; - } - - return sectionConfig.restartRequired.some( - (path) => get(overrides as JsonObject, path) !== undefined, - ); - }, + (overrides: unknown) => + sharedRequiresRestartForOverrides( + overrides, + sectionConfig.restartRequired, + requiresRestart, + ), [requiresRestart, sectionConfig.restartRequired], ); @@ -703,7 +568,6 @@ export function ConfigSection({ onSave, rawFormData, sanitizeSectionData, - buildOverrides, effectiveSchemaDefaults, updateTopic, setPendingData, diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index dbf268eed..a2abb1cc0 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -80,6 +80,14 @@ import { MobilePageTitle, } from "@/components/mobile/MobilePage"; import { Toaster } from "@/components/ui/sonner"; +import axios from "axios"; +import { toast } from "sonner"; +import { mutate } from "swr"; +import { RJSFSchema } from "@rjsf/utils"; +import { prepareSectionSavePayload } from "@/utils/configSaveUtil"; +import ActivityIndicator from "@/components/indicators/activity-indicator"; +import RestartDialog from "@/components/overlay/dialog/RestartDialog"; +import { useRestart } from "@/api/ws"; const allSettingsViews = [ "profileSettings", @@ -557,7 +565,6 @@ export default function Settings() { ? ALLOWED_VIEWS_FOR_VIEWER : allSettingsViews; - // TODO: confirm leave page const [unsavedChanges, setUnsavedChanges] = useState(false); const [confirmationDialogOpen, setConfirmationDialogOpen] = useState(false); @@ -606,15 +613,206 @@ export default function Settings() { const [filterZoneMask, setFilterZoneMask] = useState(); + // Save All state + const [isSavingAll, setIsSavingAll] = useState(false); + const [restartDialogOpen, setRestartDialogOpen] = useState(false); + const { send: sendRestart } = useRestart(); + const { data: fullSchema } = useSWR("config/schema.json"); + + const hasPendingChanges = Object.keys(pendingDataBySection).length > 0; + + // Map a pendingDataKey to SettingsType menu key for clearing section status + const pendingKeyToMenuKey = useCallback( + (pendingDataKey: string): SettingsType | undefined => { + let sectionPath: string; + let level: "global" | "camera"; + + if (pendingDataKey.includes("::")) { + sectionPath = pendingDataKey.slice(pendingDataKey.indexOf("::") + 2); + level = "camera"; + } else { + sectionPath = pendingDataKey; + level = "global"; + } + + if (level === "camera") { + return CAMERA_SECTION_MAPPING[sectionPath] as SettingsType | undefined; + } + return ( + (GLOBAL_SECTION_MAPPING[sectionPath] as SettingsType | undefined) ?? + (ENRICHMENTS_SECTION_MAPPING[sectionPath] as + | SettingsType + | undefined) ?? + (SYSTEM_SECTION_MAPPING[sectionPath] as SettingsType | undefined) + ); + }, + [], + ); + + const handleSaveAll = useCallback(async () => { + if (!config || !fullSchema || !hasPendingChanges) return; + + setIsSavingAll(true); + let successCount = 0; + let failCount = 0; + let anyNeedsRestart = false; + const savedKeys: string[] = []; + + const pendingKeys = Object.keys(pendingDataBySection); + + for (const key of pendingKeys) { + const pendingData = pendingDataBySection[key]; + try { + const payload = prepareSectionSavePayload({ + pendingDataKey: key, + pendingData, + config, + fullSchema, + }); + + if (!payload) { + // No actual overrides — clear the pending entry + setPendingDataBySection((prev) => { + const { [key]: _, ...rest } = prev; + return rest; + }); + successCount++; + continue; + } + + await axios.put("config/set", { + requires_restart: payload.needsRestart ? 1 : 0, + update_topic: payload.updateTopic, + config_data: { [payload.basePath]: payload.sanitizedOverrides }, + }); + + // eslint-disable-next-line no-console + console.log("Save All – saved:", { + [payload.basePath]: payload.sanitizedOverrides, + update_topic: payload.updateTopic, + requires_restart: payload.needsRestart ? 1 : 0, + }); + + if (payload.needsRestart) { + anyNeedsRestart = true; + } + + // Clear pending entry on success + setPendingDataBySection((prev) => { + const { [key]: _, ...rest } = prev; + return rest; + }); + savedKeys.push(key); + successCount++; + } catch (error) { + // eslint-disable-next-line no-console + console.error("Save All – error saving", key, error); + failCount++; + } + } + + // Refresh config from server once + await mutate("config"); + + // Clear hasChanges in sidebar for all successfully saved sections + if (savedKeys.length > 0) { + setSectionStatusByKey((prev) => { + const updated = { ...prev }; + for (const key of savedKeys) { + const menuKey = pendingKeyToMenuKey(key); + if (menuKey && updated[menuKey]) { + updated[menuKey] = { + ...updated[menuKey], + hasChanges: false, + }; + } + } + return updated; + }); + } + + // Aggregate toast + const totalCount = successCount + failCount; + if (failCount === 0) { + if (anyNeedsRestart) { + toast.success( + t("toast.saveAllSuccess", { + ns: "views/settings", + count: successCount, + }), + { + action: ( + setRestartDialogOpen(true)}> + + + ), + }, + ); + } else { + toast.success( + t("toast.saveAllSuccess", { + ns: "views/settings", + count: successCount, + }), + ); + } + } else if (successCount > 0) { + toast.warning( + t("toast.saveAllPartial", { + ns: "views/settings", + count: totalCount, + successCount, + totalCount, + failCount, + }), + ); + } else { + toast.error(t("toast.saveAllFailure", { ns: "views/settings" })); + } + + setIsSavingAll(false); + }, [ + config, + fullSchema, + hasPendingChanges, + pendingDataBySection, + pendingKeyToMenuKey, + t, + ]); + + const handleUndoAll = useCallback(() => { + const pendingKeys = Object.keys(pendingDataBySection); + if (pendingKeys.length === 0) return; + + setPendingDataBySection({}); + setUnsavedChanges(false); + + setSectionStatusByKey((prev) => { + const updated = { ...prev }; + for (const key of pendingKeys) { + const menuKey = pendingKeyToMenuKey(key); + if (menuKey && updated[menuKey]) { + updated[menuKey] = { + ...updated[menuKey], + hasChanges: false, + }; + } + } + return updated; + }); + }, [pendingDataBySection, pendingKeyToMenuKey]); + const handleDialog = useCallback( (save: boolean) => { if (unsavedChanges && save) { - // TODO + handleSaveAll(); } setConfirmationDialogOpen(false); setUnsavedChanges(false); }, - [unsavedChanges], + [unsavedChanges, handleSaveAll], ); useEffect(() => { @@ -834,6 +1032,42 @@ export default function Settings() { ); })} + {hasPendingChanges && ( +
+
+ + {t("unsavedChanges", { + ns: "views/settings", + defaultValue: "You have unsaved changes", + })} + + + + +
+
+ )} )} navigate(-1)} actions={ - CAMERA_SELECT_BUTTON_PAGES.includes(pageToggle) ? ( -
- {pageToggle == "masksAndZones" && ( - + {CAMERA_SELECT_BUTTON_PAGES.includes(pageToggle) && ( + <> + {pageToggle == "masksAndZones" && ( + + )} + - )} - -
- ) : undefined + + )} + } > {t("menu." + page)} @@ -912,6 +1148,11 @@ export default function Settings() { )} + setRestartDialogOpen(false)} + onRestart={() => sendRestart("restart")} + /> ); } @@ -923,23 +1164,37 @@ export default function Settings() { {t("menu.settings", { ns: "common" })} - {CAMERA_SELECT_BUTTON_PAGES.includes(page) && ( -
- {pageToggle == "masksAndZones" && ( - + {hasPendingChanges && ( + + )} + {CAMERA_SELECT_BUTTON_PAGES.includes(page) && ( + <> + {pageToggle == "masksAndZones" && ( + + )} + - )} - -
- )} + + )} + @@ -1074,6 +1329,11 @@ export default function Settings() { )} + setRestartDialogOpen(false)} + onRestart={() => sendRestart("restart")} + /> ); } diff --git a/web/src/utils/configSaveUtil.ts b/web/src/utils/configSaveUtil.ts new file mode 100644 index 000000000..8d9a1ffab --- /dev/null +++ b/web/src/utils/configSaveUtil.ts @@ -0,0 +1,399 @@ +// Shared config save utilities. +// +// Provides the core per-section save logic (buildOverrides, sanitize, restart +// detection, update-topic resolution) used by both the individual per-section +// Save button in BaseSection and the global "Save All" coordinator in Settings. + +import get from "lodash/get"; +import cloneDeep from "lodash/cloneDeep"; +import unset from "lodash/unset"; +import isEqual from "lodash/isEqual"; +import { isJsonObject } from "@/lib/utils"; +import { applySchemaDefaults } from "@/lib/config-schema"; +import { normalizeConfigValue } from "@/hooks/use-config-override"; +import { + modifySchemaForSection, + getEffectiveDefaultsForSection, + sanitizeOverridesForSection, +} from "@/components/config-form/sections/section-special-cases"; +import { getSectionConfig } from "@/utils/sectionConfigsUtils"; +import type { RJSFSchema } from "@rjsf/utils"; +import type { FrigateConfig } from "@/types/frigateConfig"; +import type { + ConfigSectionData, + JsonObject, + JsonValue, +} from "@/types/configForm"; + +// --------------------------------------------------------------------------- +// cameraUpdateTopicMap — maps config section paths to MQTT/WS update topics +// --------------------------------------------------------------------------- + +export const cameraUpdateTopicMap: Record = { + detect: "detect", + record: "record", + snapshots: "snapshots", + motion: "motion", + objects: "objects", + review: "review", + audio: "audio", + notifications: "notifications", + live: "live", + timestamp_style: "timestamp_style", + audio_transcription: "audio_transcription", + birdseye: "birdseye", + face_recognition: "face_recognition", + ffmpeg: "ffmpeg", + lpr: "lpr", + semantic_search: "semantic_search", + mqtt: "mqtt", + onvif: "onvif", + ui: "ui", +}; + +// --------------------------------------------------------------------------- +// buildOverrides — pure recursive diff of current vs stored config & defaults +// --------------------------------------------------------------------------- + +// Recursively compare `current` (pending form data) against `base` (persisted +// config) and `defaults` (schema defaults) to produce a minimal overrides +// payload. +// +// - Returns `undefined` when the value matches `base` (or `defaults` when +// `base` is absent), indicating no override is needed. +// - For objects, recurses per-key; deleted keys (present in `base` but absent +// in `current`) are represented as `""`. +// - For arrays, returns the full array when it differs. + +export function buildOverrides( + current: unknown, + base: unknown, + defaults: unknown, +): unknown | undefined { + if (current === null || current === undefined || current === "") { + return undefined; + } + + if (Array.isArray(current)) { + if ( + current.length === 0 && + (base === undefined || base === null) && + (defaults === undefined || defaults === null) + ) { + return undefined; + } + if ( + (base === undefined && + defaults !== undefined && + isEqual(current, defaults)) || + isEqual(current, base) + ) { + return undefined; + } + return current; + } + + if (isJsonObject(current)) { + const currentObj = current; + const baseObj = isJsonObject(base) ? base : undefined; + const defaultsObj = isJsonObject(defaults) ? defaults : undefined; + + const result: JsonObject = {}; + for (const [key, value] of Object.entries(currentObj)) { + if (value === undefined && baseObj && baseObj[key] !== undefined) { + result[key] = ""; + continue; + } + const overrideValue = buildOverrides( + value, + baseObj ? baseObj[key] : undefined, + defaultsObj ? defaultsObj[key] : undefined, + ); + if (overrideValue !== undefined) { + result[key] = overrideValue as JsonValue; + } + } + + if (baseObj) { + for (const [key, baseValue] of Object.entries(baseObj)) { + if (Object.prototype.hasOwnProperty.call(currentObj, key)) { + continue; + } + if (baseValue === undefined) { + continue; + } + result[key] = ""; + } + } + + return Object.keys(result).length > 0 ? result : undefined; + } + + if ( + base === undefined && + defaults !== undefined && + isEqual(current, defaults) + ) { + return undefined; + } + + if (isEqual(current, base)) { + return undefined; + } + + return current; +} + +// --------------------------------------------------------------------------- +// sanitizeSectionData — normalize config values and strip hidden fields +// --------------------------------------------------------------------------- + +// Normalize raw config data (strip internal fields) and remove any paths +// listed in `hiddenFields` so they are not included in override computation. + +export function sanitizeSectionData( + data: ConfigSectionData, + hiddenFields?: string[], +): ConfigSectionData { + const normalized = normalizeConfigValue(data) as ConfigSectionData; + if (!hiddenFields || hiddenFields.length === 0) { + return normalized; + } + const cleaned = cloneDeep(normalized) as ConfigSectionData; + hiddenFields.forEach((path) => { + if (!path) return; + unset(cleaned, path); + }); + return cleaned; +} + +// --------------------------------------------------------------------------- +// requiresRestartForOverrides — determine whether a restart is needed +// --------------------------------------------------------------------------- + +// Check whether the given overrides include fields that require a Frigate +// restart. When `restartRequired` is `undefined` the caller's default is +// used; an empty array means "never restart"; otherwise the function checks +// if any of the listed field paths are present in the overrides object. + +export function requiresRestartForOverrides( + overrides: unknown, + restartRequired: string[] | undefined, + defaultRequiresRestart: boolean = true, +): boolean { + if (restartRequired === undefined) { + return defaultRequiresRestart; + } + if (restartRequired.length === 0) { + return false; + } + if (!overrides || typeof overrides !== "object") { + return false; + } + return restartRequired.some( + (path) => get(overrides as JsonObject, path) !== undefined, + ); +} + +// --------------------------------------------------------------------------- +// SectionSavePayload — data produced by prepareSectionSavePayload +// --------------------------------------------------------------------------- + +// Ready-to-PUT payload for a single config section. + +export interface SectionSavePayload { + basePath: string; + sanitizedOverrides: Record; + updateTopic: string | undefined; + needsRestart: boolean; + pendingDataKey: string; +} + +// --------------------------------------------------------------------------- +// extractSectionSchema — resolve a section schema from the full config schema +// --------------------------------------------------------------------------- + +import { resolveAndCleanSchema } from "@/lib/config-schema"; + +type SchemaWithDefinitions = RJSFSchema & { + $defs?: Record; + definitions?: Record; + properties?: Record; +}; + +function getSchemaDefinitions(schema: RJSFSchema): Record { + return ( + (schema as SchemaWithDefinitions).$defs || + (schema as SchemaWithDefinitions).definitions || + {} + ); +} + +function extractSectionSchema( + schema: RJSFSchema, + sectionPath: string, + level: "global" | "camera", +): RJSFSchema | null { + const defs = getSchemaDefinitions(schema); + const schemaObj = schema as SchemaWithDefinitions; + let sectionDef: RJSFSchema | null = null; + + if (level === "camera") { + const cameraConfigDef = defs.CameraConfig; + if (cameraConfigDef?.properties) { + const sectionProp = cameraConfigDef.properties[sectionPath]; + if (sectionProp && typeof sectionProp === "object") { + if ("$ref" in sectionProp && typeof sectionProp.$ref === "string") { + const refPath = sectionProp.$ref + .replace(/^#\/\$defs\//, "") + .replace(/^#\/definitions\//, ""); + sectionDef = defs[refPath] || null; + } else { + sectionDef = sectionProp; + } + } + } + } else { + if (schemaObj.properties) { + const sectionProp = schemaObj.properties[sectionPath]; + if (sectionProp && typeof sectionProp === "object") { + if ("$ref" in sectionProp && typeof sectionProp.$ref === "string") { + const refPath = sectionProp.$ref + .replace(/^#\/\$defs\//, "") + .replace(/^#\/definitions\//, ""); + sectionDef = defs[refPath] || null; + } else { + sectionDef = sectionProp; + } + } + } + } + + if (!sectionDef) return null; + + const schemaWithDefs: RJSFSchema = { ...sectionDef, $defs: defs }; + return resolveAndCleanSchema(schemaWithDefs); +} + +// --------------------------------------------------------------------------- +// prepareSectionSavePayload — build the PUT payload for a single section +// --------------------------------------------------------------------------- + +// Given a pending-data key (e.g. `"detect"` or `"front_door::detect"`), its +// dirty form data, the current stored config, and the full JSON Schema, +// produce a `SectionSavePayload` that can be sent directly to +// `PUT config/set`. Returns `null` when there are no effective overrides. + +export function prepareSectionSavePayload(opts: { + pendingDataKey: string; + pendingData: unknown; + config: FrigateConfig; + fullSchema: RJSFSchema; +}): SectionSavePayload | null { + const { pendingDataKey, pendingData, config, fullSchema } = opts; + + if (!pendingData) return null; + + // Parse pendingDataKey → sectionPath, level, cameraName + let sectionPath: string; + let level: "global" | "camera"; + let cameraName: string | undefined; + + if (pendingDataKey.includes("::")) { + const idx = pendingDataKey.indexOf("::"); + cameraName = pendingDataKey.slice(0, idx); + sectionPath = pendingDataKey.slice(idx + 2); + level = "camera"; + } else { + sectionPath = pendingDataKey; + level = "global"; + } + + // Resolve section config + const sectionConfig = getSectionConfig(sectionPath, level); + + // Resolve section schema + const sectionSchema = extractSectionSchema(fullSchema, sectionPath, level); + if (!sectionSchema) return null; + + const modifiedSchema = modifySchemaForSection( + sectionPath, + level, + sectionSchema, + ); + + // Compute rawFormData (the current stored value for this section) + let rawSectionValue: unknown; + if (level === "camera" && cameraName) { + rawSectionValue = get(config.cameras?.[cameraName], sectionPath); + } else { + rawSectionValue = get(config, sectionPath); + } + const rawFormData = + rawSectionValue === undefined || rawSectionValue === null + ? {} + : rawSectionValue; + + // Sanitize raw form data + const rawData = sanitizeSectionData( + rawFormData as ConfigSectionData, + sectionConfig.hiddenFields, + ); + + // Compute schema defaults + const schemaDefaults = modifiedSchema + ? applySchemaDefaults(modifiedSchema, {}) + : {}; + const effectiveDefaults = getEffectiveDefaultsForSection( + sectionPath, + level, + modifiedSchema ?? undefined, + schemaDefaults, + ); + + // Build overrides + const overrides = buildOverrides(pendingData, rawData, effectiveDefaults); + const sanitizedOverrides = sanitizeOverridesForSection( + sectionPath, + level, + overrides, + ); + + if ( + !sanitizedOverrides || + typeof sanitizedOverrides !== "object" || + Object.keys(sanitizedOverrides as Record).length === 0 + ) { + return null; + } + + // Compute basePath + const basePath = + level === "camera" && cameraName + ? `cameras.${cameraName}.${sectionPath}` + : sectionPath; + + // Compute updateTopic + let updateTopic: string | undefined; + if (level === "camera" && cameraName) { + const topic = cameraUpdateTopicMap[sectionPath]; + updateTopic = topic ? `config/cameras/${cameraName}/${topic}` : undefined; + } else { + updateTopic = `config/${sectionPath}`; + } + + // Restart detection + const needsRestart = requiresRestartForOverrides( + sanitizedOverrides, + sectionConfig.restartRequired, + true, + ); + + return { + basePath, + sanitizedOverrides: sanitizedOverrides as Record, + updateTopic, + needsRestart, + pendingDataKey, + }; +}