import cloneDeep from "lodash/cloneDeep"; import isEqual from "lodash/isEqual"; import merge from "lodash/merge"; import type { RJSFSchema } from "@rjsf/utils"; import { buildOverrides, cameraUpdateTopicMap, flattenOverrides, getEffectiveAttributeLabels, getSectionConfig, prepareSectionSavePayload, resolveHiddenFieldEntries, sanitizeSectionData, type SectionSavePayload, } from "@/utils/configUtil"; import { applySchemaDefaults } from "@/lib/config-schema"; import type { SaveAllPreviewItem } from "@/components/overlay/detail/SaveAllPreviewPopover"; import type { CameraConfig, FrigateConfig } from "@/types/frigateConfig"; import type { ConfigSectionData, JsonObject, JsonValue, } from "@/types/configForm"; import { processCameraName } from "@/utils/cameraUtil"; /** * Sections whose `filters` dict is auto-populated by the backend at parse * time. `attributeBump` reflects the global-level `min_score=0.7` override * the backend applies to attribute labels (face, license_plate, Frigate+ * couriers) — see `frigate/config/config.py`. */ const FILTER_SECTION_DEFS: Record< string, { listField: string; filterDef: string; attributeBump?: { min_score: number }; } > = { objects: { listField: "track", filterDef: "FilterConfig", attributeBump: { min_score: 0.7 }, }, audio: { listField: "listen", filterDef: "AudioFilterConfig" }, }; function resolveDef(schema: RJSFSchema, name: string): RJSFSchema | undefined { const defs = (schema as { $defs?: Record }).$defs ?? (schema as { definitions?: Record }).definitions; return defs ? defs[name] : undefined; } /** * Reduce each filter entry to the fields that differ from the backend's * auto-default. An entry that is entirely auto-populated drops out; a * partially-customized entry keeps only its customized fields, so cloning * doesn't copy the auto-populated default for every other field. */ function stripAutoDefaultFilters( section: string, sourceSection: JsonObject, fullSchema: RJSFSchema, fullConfig: FrigateConfig, fullCameraConfig: CameraConfig, ): JsonObject { const def = FILTER_SECTION_DEFS[section]; if (!def) return sourceSection; const filters = sourceSection.filters; if (!filters || typeof filters !== "object" || Array.isArray(filters)) { return sourceSection; } const filterDef = resolveDef(fullSchema, def.filterDef); if (!filterDef) return sourceSection; const baseDefaults = applySchemaDefaults(filterDef, {}) as JsonObject; const attributeDefaults = def.attributeBump ? ({ ...baseDefaults, ...def.attributeBump } as JsonObject) : baseDefaults; const attributeSet = section === "objects" ? new Set( getEffectiveAttributeLabels(fullConfig, fullCameraConfig, "camera"), ) : new Set(); // Ignore runtime-only `mask`/`raw_mask`: the API ships them as `{}` while the // schema default omits them, which would otherwise break the equality check. const withoutRuntimeFields = (entry: JsonValue): JsonValue => { if (!entry || typeof entry !== "object" || Array.isArray(entry)) { return entry; } const copy = { ...(entry as JsonObject) }; delete copy.mask; delete copy.raw_mask; return copy; }; const cleaned: JsonObject = {}; for (const [label, value] of Object.entries(filters as JsonObject)) { const expected = attributeSet.has(label) ? attributeDefaults : baseDefaults; const valNorm = withoutRuntimeFields(value as JsonValue); const expNorm = withoutRuntimeFields(expected as JsonValue); // Non-object filter value: keep only if it differs from the default. if ( !valNorm || typeof valNorm !== "object" || Array.isArray(valNorm) || !expNorm || typeof expNorm !== "object" || Array.isArray(expNorm) ) { if (!isEqual(valNorm, expNorm)) { cleaned[label] = value as JsonValue; } continue; } const diff: JsonObject = {}; for (const [field, fieldValue] of Object.entries(valNorm as JsonObject)) { if (!isEqual(fieldValue, (expNorm as JsonObject)[field])) { diff[field] = fieldValue as JsonValue; } } if (Object.keys(diff).length > 0) { cleaned[label] = diff; } } return { ...sourceSection, filters: cleaned }; } /** * Strip runtime-only fields from each entry of a dict-of-objects (mask * `enabled_in_config`/`raw_coordinates`, zone `color`) that clone re-injects * from the API. */ function stripDictEntryFields( dict: unknown, fieldsToStrip: readonly string[], ): unknown { if (!dict || typeof dict !== "object" || Array.isArray(dict)) return dict; const result: JsonObject = {}; for (const [key, value] of Object.entries(dict as JsonObject)) { if (value && typeof value === "object" && !Array.isArray(value)) { const cleaned = { ...(value as JsonObject) }; for (const field of fieldsToStrip) { delete cleaned[field]; } result[key] = cleaned as JsonValue; } else { result[key] = value as JsonValue; } } return result; } /** * Per-object masks (`objects.filters.