diff --git a/frigate/util/builtin.py b/frigate/util/builtin.py index 867d2533d..bcdc2feda 100644 --- a/frigate/util/builtin.py +++ b/frigate/util/builtin.py @@ -195,7 +195,8 @@ def flatten_config_data( ) -> Dict[str, Any]: items = [] for key, value in config_data.items(): - new_key = f"{parent_key}.{key}" if parent_key else key + escaped_key = escape_config_key_segment(str(key)) + new_key = f"{parent_key}.{escaped_key}" if parent_key else escaped_key if isinstance(value, dict): items.extend(flatten_config_data(value, new_key).items()) else: @@ -203,6 +204,41 @@ def flatten_config_data( return dict(items) +def escape_config_key_segment(segment: str) -> str: + """Escape dots and backslashes so they can be treated as literal key chars.""" + return segment.replace("\\", "\\\\").replace(".", "\\.") + + +def split_config_key_path(key_path_str: str) -> list[str]: + """Split a dotted config path, honoring \\. as a literal dot in a key.""" + parts: list[str] = [] + current: list[str] = [] + escaped = False + + for char in key_path_str: + if escaped: + current.append(char) + escaped = False + continue + + if char == "\\": + escaped = True + continue + + if char == ".": + parts.append("".join(current)) + current = [] + continue + + current.append(char) + + if escaped: + current.append("\\") + + parts.append("".join(current)) + return parts + + def update_yaml_file_bulk(file_path: str, updates: Dict[str, Any]): yaml = YAML() yaml.indent(mapping=2, sequence=4, offset=2) @@ -218,7 +254,7 @@ def update_yaml_file_bulk(file_path: str, updates: Dict[str, Any]): # Apply all updates for key_path_str, new_value in updates.items(): - key_path = key_path_str.split(".") + key_path = split_config_key_path(key_path_str) for i in range(len(key_path)): try: index = int(key_path[i]) diff --git a/web/src/components/config-form/sections/section-special-cases.ts b/web/src/components/config-form/sections/section-special-cases.ts index 1f4b9ddae..94771644f 100644 --- a/web/src/components/config-form/sections/section-special-cases.ts +++ b/web/src/components/config-form/sections/section-special-cases.ts @@ -9,7 +9,7 @@ import { RJSFSchema } from "@rjsf/utils"; import { applySchemaDefaults } from "@/lib/config-schema"; import { isJsonObject } from "@/lib/utils"; -import { JsonObject } from "@/types/configForm"; +import { JsonObject, JsonValue } from "@/types/configForm"; /** * Sections that require special handling at the global level. @@ -146,6 +146,22 @@ export function sanitizeOverridesForSection( }; } + const flattenRecordWithDots = ( + value: JsonObject, + prefix: string = "", + ): JsonObject => { + const flattened: JsonObject = {}; + Object.entries(value).forEach(([key, entry]) => { + const nextKey = prefix ? `${prefix}.${key}` : key; + if (isJsonObject(entry)) { + Object.assign(flattened, flattenRecordWithDots(entry, nextKey)); + } else { + flattened[nextKey] = entry as JsonValue; + } + }); + return flattened; + }; + // detectors: Strip readonly model fields that are generated on startup // and should never be persisted back to the config file. if (sectionPath === "detectors") { @@ -167,5 +183,21 @@ export function sanitizeOverridesForSection( return cleaned; } + if (sectionPath === "logger") { + const overridesObj = overrides as JsonObject; + const logs = overridesObj.logs; + if (isJsonObject(logs)) { + return { + ...overridesObj, + logs: flattenRecordWithDots(logs), + }; + } + } + + if (sectionPath === "environment_vars") { + const overridesObj = overrides as JsonObject; + return flattenRecordWithDots(overridesObj); + } + return overrides; }