update i18n config generation script to use json schema

This commit is contained in:
Josh Hawkins 2026-01-29 09:46:40 -06:00
parent 30db891837
commit bf94c009a4

View File

@ -8,12 +8,9 @@ and generates JSON translation files with titles and descriptions for the web UI
import json import json
import logging import logging
import shutil import sys
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Optional, get_args, get_origin from typing import Any, Dict, get_args, get_origin
from pydantic import BaseModel
from pydantic.fields import FieldInfo
from frigate.config.config import FrigateConfig from frigate.config.config import FrigateConfig
@ -21,7 +18,7 @@ logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def get_field_translations(field_info: FieldInfo) -> Dict[str, str]: def get_field_translations(field_info) -> Dict[str, str]:
"""Extract title and description from a Pydantic field.""" """Extract title and description from a Pydantic field."""
translations = {} translations = {}
@ -34,50 +31,147 @@ def get_field_translations(field_info: FieldInfo) -> Dict[str, str]:
return translations return translations
def process_model_fields(model: type[BaseModel]) -> Dict[str, Any]: def extract_translations_from_schema(
schema: Dict[str, Any], defs: Dict[str, Any] = None
) -> Dict[str, Any]:
""" """
Recursively process a Pydantic model to extract translations. Recursively extract translations (titles and descriptions) from a JSON schema.
Returns a dictionary structure with nested fields directly under their Returns a dictionary structure with label and description for each field,
parent keys. and nested fields directly under their parent keys.
""" """
if defs is None:
defs = schema.get("$defs", {})
translations = {} translations = {}
model_fields = model.model_fields # Add top-level title and description if present
if "title" in schema:
translations["label"] = schema["title"]
if "description" in schema:
translations["description"] = schema["description"]
for field_name, field_info in model_fields.items(): # Process nested properties
field_translations = get_field_translations(field_info) properties = schema.get("properties", {})
for field_name, field_schema in properties.items():
field_translations = {}
# Get the field's type annotation # Handle $ref references
field_type = field_info.annotation if "$ref" in field_schema:
ref_path = field_schema["$ref"]
if ref_path.startswith("#/$defs/"):
ref_name = ref_path.split("/")[-1]
if ref_name in defs:
ref_schema = defs[ref_name]
# Extract from the referenced schema
ref_translations = extract_translations_from_schema(
ref_schema, defs=defs
)
# Use the $ref field's own title/description if present
if "title" in field_schema:
field_translations["label"] = field_schema["title"]
elif "label" in ref_translations:
field_translations["label"] = ref_translations["label"]
if "description" in field_schema:
field_translations["description"] = field_schema["description"]
elif "description" in ref_translations:
field_translations["description"] = ref_translations[
"description"
]
# Add nested properties from referenced schema
nested_without_root = {
k: v
for k, v in ref_translations.items()
if k not in ("label", "description")
}
field_translations.update(nested_without_root)
# Handle additionalProperties with $ref (for dict types)
elif "additionalProperties" in field_schema:
additional_props = field_schema["additionalProperties"]
# Extract title and description from the field itself
if "title" in field_schema:
field_translations["label"] = field_schema["title"]
if "description" in field_schema:
field_translations["description"] = field_schema["description"]
# Handle Optional types # If additionalProperties contains a $ref, extract nested translations
origin = get_origin(field_type) if "$ref" in additional_props:
ref_path = additional_props["$ref"]
if ref_path.startswith("#/$defs/"):
ref_name = ref_path.split("/")[-1]
if ref_name in defs:
ref_schema = defs[ref_name]
nested = extract_translations_from_schema(ref_schema, defs=defs)
nested_without_root = {
k: v
for k, v in nested.items()
if k not in ("label", "description")
}
field_translations.update(nested_without_root)
# Handle items with $ref (for array types)
elif "items" in field_schema:
items = field_schema["items"]
# Extract title and description from the field itself
if "title" in field_schema:
field_translations["label"] = field_schema["title"]
if "description" in field_schema:
field_translations["description"] = field_schema["description"]
if origin is Optional or ( # If items contains a $ref, extract nested translations
hasattr(origin, "__name__") and origin.__name__ == "UnionType" if "$ref" in items:
): ref_path = items["$ref"]
args = get_args(field_type) if ref_path.startswith("#/$defs/"):
field_type = next( ref_name = ref_path.split("/")[-1]
(arg for arg in args if arg is not type(None)), field_type if ref_name in defs:
) ref_schema = defs[ref_name]
nested = extract_translations_from_schema(ref_schema, defs=defs)
nested_without_root = {
k: v
for k, v in nested.items()
if k not in ("label", "description")
}
field_translations.update(nested_without_root)
else:
# Extract title and description
if "title" in field_schema:
field_translations["label"] = field_schema["title"]
if "description" in field_schema:
field_translations["description"] = field_schema["description"]
# Handle Dict types (like Dict[str, CameraConfig]) # Recursively process nested properties
if get_origin(field_type) is dict: if "properties" in field_schema:
dict_args = get_args(field_type) nested = extract_translations_from_schema(field_schema, defs=defs)
# Merge nested translations
if len(dict_args) >= 2: nested_without_root = {
value_type = dict_args[1] k: v for k, v in nested.items() if k not in ("label", "description")
}
if isinstance(value_type, type) and issubclass(value_type, BaseModel): field_translations.update(nested_without_root)
nested_translations = process_model_fields(value_type) # Handle anyOf cases
elif "anyOf" in field_schema:
if nested_translations: for item in field_schema["anyOf"]:
field_translations.update(nested_translations) if "properties" in item:
elif isinstance(field_type, type) and issubclass(field_type, BaseModel): nested = extract_translations_from_schema(item, defs=defs)
nested_translations = process_model_fields(field_type) nested_without_root = {
if nested_translations: k: v
field_translations.update(nested_translations) for k, v in nested.items()
if k not in ("label", "description")
}
field_translations.update(nested_without_root)
elif "$ref" in item:
ref_path = item["$ref"]
if ref_path.startswith("#/$defs/"):
ref_name = ref_path.split("/")[-1]
if ref_name in defs:
ref_schema = defs[ref_name]
nested = extract_translations_from_schema(
ref_schema, defs=defs
)
nested_without_root = {
k: v
for k, v in nested.items()
if k not in ("label", "description")
}
field_translations.update(nested_without_root)
if field_translations: if field_translations:
translations[field_name] = field_translations translations[field_name] = field_translations
@ -85,58 +179,32 @@ def process_model_fields(model: type[BaseModel]) -> Dict[str, Any]:
return translations return translations
def generate_section_translation( def generate_section_translation(config_class: type) -> Dict[str, Any]:
section_name: str, field_info: FieldInfo
) -> Dict[str, Any]:
""" """
Generate translation structure for a top-level config section. Generate translation structure for a config section using its JSON schema.
Returns a structure with label and description at root level,
and nested fields directly under their parent keys.
""" """
section_translations = get_field_translations(field_info) schema = config_class.model_json_schema()
field_type = field_info.annotation return extract_translations_from_schema(schema)
origin = get_origin(field_type)
if origin is Optional or (
hasattr(origin, "__name__") and origin.__name__ == "UnionType"
):
args = get_args(field_type)
field_type = next((arg for arg in args if arg is not type(None)), field_type)
# Handle Dict types (like detectors, cameras, camera_groups)
if get_origin(field_type) is dict:
dict_args = get_args(field_type)
if len(dict_args) >= 2:
value_type = dict_args[1]
if isinstance(value_type, type) and issubclass(value_type, BaseModel):
nested = process_model_fields(value_type)
if nested:
section_translations.update(nested)
# If the field itself is a BaseModel, process it and add nested translations
elif isinstance(field_type, type) and issubclass(field_type, BaseModel):
nested = process_model_fields(field_type)
if nested:
section_translations.update(nested)
return section_translations
def main(): def main():
"""Main function to generate config translations.""" """Main function to generate config translations."""
# Define output directory # Define output directory
output_dir = Path(__file__).parent / "web" / "public" / "locales" / "en" / "config" if len(sys.argv) > 1:
output_dir = Path(sys.argv[1])
else:
output_dir = (
Path(__file__).parent / "web" / "public" / "locales" / "en" / "config"
)
logger.info(f"Output directory: {output_dir}") logger.info(f"Output directory: {output_dir}")
# Clean and recreate the output directory # Ensure the output directory exists; do not delete existing files.
if output_dir.exists():
logger.info(f"Removing existing directory: {output_dir}")
shutil.rmtree(output_dir)
logger.info(f"Creating directory: {output_dir}")
output_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True)
logger.info(
f"Using output directory (existing files will be overwritten): {output_dir}"
)
config_fields = FrigateConfig.model_fields config_fields = FrigateConfig.model_fields
logger.info(f"Found {len(config_fields)} top-level config sections") logger.info(f"Found {len(config_fields)} top-level config sections")
@ -146,18 +214,204 @@ def main():
continue continue
logger.info(f"Processing section: {field_name}") logger.info(f"Processing section: {field_name}")
section_data = generate_section_translation(field_name, field_info)
# Get the field's type
field_type = field_info.annotation
from typing import Optional, Union
origin = get_origin(field_type)
if (
origin is Optional
or origin is Union
or (
hasattr(origin, "__name__")
and origin.__name__ in ("UnionType", "Union")
)
):
args = get_args(field_type)
field_type = next(
(arg for arg in args if arg is not type(None)), field_type
)
# Handle Dict[str, SomeModel] - extract the value type
if origin is dict:
args = get_args(field_type)
if args and len(args) > 1:
field_type = args[1] # Get value type from Dict[key, value]
# Start with field's top-level metadata (label, description)
section_data = get_field_translations(field_info)
# Generate nested translations from the field type's schema
if hasattr(field_type, "model_json_schema"):
schema = field_type.model_json_schema()
# Extract nested properties from schema
nested = extract_translations_from_schema(schema)
# Remove top-level label/description from nested since we got those from field_info
nested_without_root = {
k: v for k, v in nested.items() if k not in ("label", "description")
}
section_data.update(nested_without_root)
if not section_data: if not section_data:
logger.warning(f"No translations found for section: {field_name}") logger.warning(f"No translations found for section: {field_name}")
continue continue
# Add camera-level fields to global config documentation if applicable
CAMERA_LEVEL_FIELDS = {
"birdseye": (
"frigate.config.camera.birdseye",
"BirdseyeCameraConfig",
["order"],
),
"ffmpeg": (
"frigate.config.camera.ffmpeg",
"CameraFfmpegConfig",
["inputs"],
),
"lpr": (
"frigate.config.classification",
"CameraLicensePlateRecognitionConfig",
["expire_time"],
),
"semantic_search": (
"frigate.config.classification",
"CameraSemanticSearchConfig",
["triggers"],
),
}
if field_name in CAMERA_LEVEL_FIELDS:
module_path, class_name, field_names = CAMERA_LEVEL_FIELDS[field_name]
try:
import importlib
module = importlib.import_module(module_path)
camera_class = getattr(module, class_name)
schema = camera_class.model_json_schema()
camera_fields = schema.get("properties", {})
defs = schema.get("$defs", {})
for fname in field_names:
if fname in camera_fields:
field_schema = camera_fields[fname]
field_trans = {}
if "title" in field_schema:
field_trans["label"] = field_schema["title"]
if "description" in field_schema:
field_trans["description"] = field_schema["description"]
# Extract nested properties based on schema type
nested_to_extract = None
# Handle direct $ref
if "$ref" in field_schema:
ref_path = field_schema["$ref"]
if ref_path.startswith("#/$defs/"):
ref_name = ref_path.split("/")[-1]
if ref_name in defs:
nested_to_extract = defs[ref_name]
# Handle additionalProperties with $ref (for dict types)
elif "additionalProperties" in field_schema:
additional_props = field_schema["additionalProperties"]
if "$ref" in additional_props:
ref_path = additional_props["$ref"]
if ref_path.startswith("#/$defs/"):
ref_name = ref_path.split("/")[-1]
if ref_name in defs:
nested_to_extract = defs[ref_name]
# Handle items with $ref (for array types)
elif "items" in field_schema:
items = field_schema["items"]
if "$ref" in items:
ref_path = items["$ref"]
if ref_path.startswith("#/$defs/"):
ref_name = ref_path.split("/")[-1]
if ref_name in defs:
nested_to_extract = defs[ref_name]
# Extract nested properties if we found a schema to use
if nested_to_extract:
nested = extract_translations_from_schema(
nested_to_extract, defs=defs
)
nested_without_root = {
k: v
for k, v in nested.items()
if k not in ("label", "description")
}
field_trans.update(nested_without_root)
if field_trans:
section_data[fname] = field_trans
except Exception as e:
logger.warning(
f"Could not add camera-level fields for {field_name}: {e}"
)
output_file = output_dir / f"{field_name}.json" output_file = output_dir / f"{field_name}.json"
with open(output_file, "w", encoding="utf-8") as f: with open(output_file, "w", encoding="utf-8") as f:
json.dump(section_data, f, indent=2, ensure_ascii=False) json.dump(section_data, f, indent=2, ensure_ascii=False)
f.write("\n") # Add trailing newline
logger.info(f"Generated: {output_file}") logger.info(f"Generated: {output_file}")
# Handle camera-level configs that aren't top-level FrigateConfig fields
# These are defined as fields in CameraConfig, so we extract title/description from there
camera_level_configs = {
"camera_mqtt": ("frigate.config.camera.mqtt", "CameraMqttConfig", "mqtt"),
"camera_ui": ("frigate.config.camera.ui", "CameraUiConfig", "ui"),
"onvif": ("frigate.config.camera.onvif", "OnvifConfig", "onvif"),
}
# Import CameraConfig to extract field metadata
from frigate.config.camera.camera import CameraConfig
camera_config_schema = CameraConfig.model_json_schema()
camera_properties = camera_config_schema.get("properties", {})
for config_name, (
module_path,
class_name,
camera_field_name,
) in camera_level_configs.items():
try:
logger.info(f"Processing camera-level section: {config_name}")
import importlib
module = importlib.import_module(module_path)
config_class = getattr(module, class_name)
section_data = {}
# Extract top-level label and description from CameraConfig field definition
if camera_field_name in camera_properties:
field_schema = camera_properties[camera_field_name]
if "title" in field_schema:
section_data["label"] = field_schema["title"]
if "description" in field_schema:
section_data["description"] = field_schema["description"]
# Process model fields from schema
schema = config_class.model_json_schema()
nested = extract_translations_from_schema(schema)
# Remove top-level label/description since we got those from CameraConfig
nested_without_root = {
k: v for k, v in nested.items() if k not in ("label", "description")
}
section_data.update(nested_without_root)
output_file = output_dir / f"{config_name}.json"
with open(output_file, "w", encoding="utf-8") as f:
json.dump(section_data, f, indent=2, ensure_ascii=False)
f.write("\n") # Add trailing newline
logger.info(f"Generated: {output_file}")
except Exception as e:
logger.error(f"Failed to generate {config_name}: {e}")
logger.info("Translation generation complete!") logger.info("Translation generation complete!")