chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
"""Load i18n translation files for Settings UI field labels."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Base path for locale files
|
||||
WEB_LOCALES = Path(__file__).resolve().parents[3] / "web" / "public" / "locales" / "en"
|
||||
|
||||
|
||||
def load_i18n() -> dict[str, Any]:
|
||||
"""Load and merge all relevant i18n files.
|
||||
|
||||
Returns:
|
||||
Dict with keys: "global", "cameras", "settings_menu"
|
||||
"""
|
||||
global_path = WEB_LOCALES / "config" / "global.json"
|
||||
cameras_path = WEB_LOCALES / "config" / "cameras.json"
|
||||
settings_path = WEB_LOCALES / "views" / "settings.json"
|
||||
|
||||
result: dict[str, Any] = {}
|
||||
|
||||
with open(global_path) as f:
|
||||
result["global"] = json.load(f)
|
||||
|
||||
with open(cameras_path) as f:
|
||||
result["cameras"] = json.load(f)
|
||||
|
||||
with open(settings_path) as f:
|
||||
settings = json.load(f)
|
||||
result["settings_menu"] = settings.get("menu", {})
|
||||
|
||||
# Build a unified enum value → label lookup from all known sources.
|
||||
# Merges multiple maps so callers don't need to know which file
|
||||
# a particular enum lives in.
|
||||
value_labels: dict[str, str] = {}
|
||||
|
||||
config_form = settings.get("configForm", {})
|
||||
|
||||
# FFmpeg preset labels (preset-vaapi → "VAAPI (Intel/AMD GPU)")
|
||||
value_labels.update(
|
||||
config_form.get("ffmpegArgs", {}).get("presetLabels", {})
|
||||
)
|
||||
|
||||
# Timestamp position (tl → "Top left")
|
||||
value_labels.update(settings.get("timestampPosition", {}))
|
||||
|
||||
# Input role options (detect → "Detect")
|
||||
value_labels.update(
|
||||
config_form.get("inputRoles", {}).get("options", {})
|
||||
)
|
||||
|
||||
# GenAI role options (vision → "Vision")
|
||||
value_labels.update(
|
||||
config_form.get("genaiRoles", {}).get("options", {})
|
||||
)
|
||||
|
||||
result["value_labels"] = value_labels
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_field_label(
|
||||
i18n: dict[str, Any],
|
||||
section_key: str,
|
||||
field_path: list[str],
|
||||
level: str = "global",
|
||||
) -> str | None:
|
||||
"""Look up the UI label for a field.
|
||||
|
||||
Args:
|
||||
i18n: Loaded i18n data from load_i18n()
|
||||
section_key: Config section (e.g., "record")
|
||||
field_path: Path within section (e.g., ["continuous", "days"])
|
||||
level: "global" or "cameras"
|
||||
|
||||
Returns:
|
||||
The label string, or None if not found.
|
||||
"""
|
||||
source = i18n.get(level, {})
|
||||
node = source.get(section_key, {})
|
||||
|
||||
for key in field_path:
|
||||
if not isinstance(node, dict):
|
||||
return None
|
||||
node = node.get(key, {})
|
||||
|
||||
if isinstance(node, dict):
|
||||
return node.get("label")
|
||||
return None
|
||||
|
||||
|
||||
def get_field_description(
|
||||
i18n: dict[str, Any],
|
||||
section_key: str,
|
||||
field_path: list[str],
|
||||
level: str = "global",
|
||||
) -> str | None:
|
||||
"""Look up the UI description for a field."""
|
||||
source = i18n.get(level, {})
|
||||
node = source.get(section_key, {})
|
||||
|
||||
for key in field_path:
|
||||
if not isinstance(node, dict):
|
||||
return None
|
||||
node = node.get(key, {})
|
||||
|
||||
if isinstance(node, dict):
|
||||
return node.get("description")
|
||||
return None
|
||||
|
||||
|
||||
def get_value_label(
|
||||
i18n: dict[str, Any],
|
||||
value: str,
|
||||
) -> str | None:
|
||||
"""Look up the display label for an enum/option value.
|
||||
|
||||
Args:
|
||||
i18n: Loaded i18n data from load_i18n()
|
||||
value: The raw config value (e.g., "preset-vaapi", "tl")
|
||||
|
||||
Returns:
|
||||
The human-readable label (e.g., "VAAPI (Intel/AMD GPU)"), or None.
|
||||
"""
|
||||
return i18n.get("value_labels", {}).get(value)
|
||||
|
||||
|
||||
def get_section_label(
|
||||
i18n: dict[str, Any],
|
||||
section_key: str,
|
||||
level: str = "global",
|
||||
) -> str | None:
|
||||
"""Get the top-level label for a config section."""
|
||||
source = i18n.get(level, {})
|
||||
section = source.get(section_key, {})
|
||||
if isinstance(section, dict):
|
||||
return section.get("label")
|
||||
return None
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Map config section keys to Settings UI navigation paths."""
|
||||
|
||||
# Derived from web/src/pages/Settings.tsx section mappings
|
||||
# and web/public/locales/en/views/settings.json menu labels.
|
||||
#
|
||||
# Format: section_key -> (group_label, page_label)
|
||||
# Navigation path: "Settings > {group_label} > {page_label}"
|
||||
|
||||
GLOBAL_NAV: dict[str, tuple[str, str]] = {
|
||||
"detect": ("Global configuration", "Object detection"),
|
||||
"ffmpeg": ("Global configuration", "FFmpeg"),
|
||||
"record": ("Global configuration", "Recording"),
|
||||
"snapshots": ("Global configuration", "Snapshots"),
|
||||
"motion": ("Global configuration", "Motion detection"),
|
||||
"objects": ("Global configuration", "Objects"),
|
||||
"review": ("Global configuration", "Review"),
|
||||
"audio": ("Global configuration", "Audio events"),
|
||||
"live": ("Global configuration", "Live playback"),
|
||||
"timestamp_style": ("Global configuration", "Timestamp style"),
|
||||
"notifications": ("Notifications", "Notifications"),
|
||||
}
|
||||
|
||||
CAMERA_NAV: dict[str, tuple[str, str]] = {
|
||||
"detect": ("Camera configuration", "Object detection"),
|
||||
"ffmpeg": ("Camera configuration", "FFmpeg"),
|
||||
"record": ("Camera configuration", "Recording"),
|
||||
"snapshots": ("Camera configuration", "Snapshots"),
|
||||
"motion": ("Camera configuration", "Motion detection"),
|
||||
"objects": ("Camera configuration", "Objects"),
|
||||
"review": ("Camera configuration", "Review"),
|
||||
"audio": ("Camera configuration", "Audio events"),
|
||||
"audio_transcription": ("Camera configuration", "Audio transcription"),
|
||||
"notifications": ("Camera configuration", "Notifications"),
|
||||
"live": ("Camera configuration", "Live playback"),
|
||||
"birdseye": ("Camera configuration", "Birdseye"),
|
||||
"face_recognition": ("Camera configuration", "Face recognition"),
|
||||
"lpr": ("Camera configuration", "License plate recognition"),
|
||||
"mqtt": ("Camera configuration", "MQTT"),
|
||||
"onvif": ("Camera configuration", "ONVIF"),
|
||||
"ui": ("Camera configuration", "Camera UI"),
|
||||
"timestamp_style": ("Camera configuration", "Timestamp style"),
|
||||
}
|
||||
|
||||
ENRICHMENT_NAV: dict[str, tuple[str, str]] = {
|
||||
"semantic_search": ("Enrichments", "Semantic search"),
|
||||
"genai": ("Enrichments", "Generative AI"),
|
||||
"face_recognition": ("Enrichments", "Face recognition"),
|
||||
"lpr": ("Enrichments", "License plate recognition"),
|
||||
"classification": ("Enrichments", "Object classification"),
|
||||
"audio_transcription": ("Enrichments", "Audio transcription"),
|
||||
}
|
||||
|
||||
SYSTEM_NAV: dict[str, tuple[str, str]] = {
|
||||
"go2rtc_streams": ("System", "go2rtc streams"),
|
||||
"database": ("System", "Database"),
|
||||
"mqtt": ("System", "MQTT"),
|
||||
"tls": ("System", "TLS"),
|
||||
"auth": ("System", "Authentication"),
|
||||
"networking": ("System", "Networking"),
|
||||
"proxy": ("System", "Proxy"),
|
||||
"ui": ("System", "UI"),
|
||||
"logger": ("System", "Logging"),
|
||||
"environment_vars": ("System", "Environment variables"),
|
||||
"telemetry": ("System", "Telemetry"),
|
||||
"birdseye": ("System", "Birdseye"),
|
||||
"detectors": ("System", "Detectors and model"),
|
||||
"model": ("System", "Detectors and model"),
|
||||
}
|
||||
|
||||
# All known top-level config section keys
|
||||
ALL_CONFIG_SECTIONS = (
|
||||
set(GLOBAL_NAV)
|
||||
| set(CAMERA_NAV)
|
||||
| set(ENRICHMENT_NAV)
|
||||
| set(SYSTEM_NAV)
|
||||
| {"cameras"}
|
||||
)
|
||||
|
||||
|
||||
def get_nav_path(section_key: str, level: str = "global") -> str | None:
|
||||
"""Get the full navigation path for a config section.
|
||||
|
||||
Args:
|
||||
section_key: Config section key (e.g., "record")
|
||||
level: "global", "camera", "enrichment", or "system"
|
||||
|
||||
Returns:
|
||||
NavPath string like "Settings > Global configuration > Recording",
|
||||
or None if not found.
|
||||
"""
|
||||
nav_tables = {
|
||||
"global": GLOBAL_NAV,
|
||||
"camera": CAMERA_NAV,
|
||||
"enrichment": ENRICHMENT_NAV,
|
||||
"system": SYSTEM_NAV,
|
||||
}
|
||||
|
||||
table = nav_tables.get(level)
|
||||
if table is None:
|
||||
return None
|
||||
|
||||
entry = table.get(section_key)
|
||||
if entry is None:
|
||||
return None
|
||||
|
||||
group, page = entry
|
||||
return f"Settings > {group} > {page}"
|
||||
|
||||
|
||||
def detect_level(section_key: str) -> str:
|
||||
"""Detect whether a config section is global, camera, enrichment, or system."""
|
||||
if section_key in SYSTEM_NAV:
|
||||
return "system"
|
||||
if section_key in ENRICHMENT_NAV:
|
||||
return "enrichment"
|
||||
if section_key in GLOBAL_NAV:
|
||||
return "global"
|
||||
if section_key in CAMERA_NAV:
|
||||
return "camera"
|
||||
return "global"
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Load JSON schema from Frigate's Pydantic config models."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def load_schema() -> dict[str, Any]:
|
||||
"""Generate and return the full JSON schema for FrigateConfig."""
|
||||
from frigate.config.config import FrigateConfig
|
||||
from frigate.util.schema import get_config_schema
|
||||
|
||||
return get_config_schema(FrigateConfig)
|
||||
|
||||
|
||||
def resolve_ref(schema: dict[str, Any], ref: str) -> dict[str, Any]:
|
||||
"""Resolve a $ref pointer within the schema."""
|
||||
# ref format: "#/$defs/RecordConfig"
|
||||
parts = ref.lstrip("#/").split("/")
|
||||
node = schema
|
||||
for part in parts:
|
||||
node = node[part]
|
||||
return node
|
||||
|
||||
|
||||
def resolve_schema_node(
|
||||
schema: dict[str, Any], node: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve a schema node, following $ref and allOf if present."""
|
||||
if "$ref" in node:
|
||||
node = resolve_ref(schema, node["$ref"])
|
||||
if "allOf" in node:
|
||||
merged: dict[str, Any] = {}
|
||||
for item in node["allOf"]:
|
||||
resolved = resolve_schema_node(schema, item)
|
||||
merged.update(resolved)
|
||||
return merged
|
||||
return node
|
||||
|
||||
|
||||
def get_section_schema(
|
||||
schema: dict[str, Any], section_key: str
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get the resolved schema for a top-level config section."""
|
||||
props = schema.get("properties", {})
|
||||
if section_key not in props:
|
||||
return None
|
||||
return resolve_schema_node(schema, props[section_key])
|
||||
|
||||
|
||||
def get_field_info(
|
||||
schema: dict[str, Any], section_key: str, field_path: list[str]
|
||||
) -> dict[str, Any] | None:
|
||||
"""Get schema info for a specific field path within a section.
|
||||
|
||||
Args:
|
||||
schema: Full JSON schema
|
||||
section_key: Top-level section (e.g., "record")
|
||||
field_path: List of nested keys (e.g., ["continuous", "days"])
|
||||
|
||||
Returns:
|
||||
Resolved schema node for the field, or None if not found.
|
||||
"""
|
||||
section = get_section_schema(schema, section_key)
|
||||
if section is None:
|
||||
return None
|
||||
|
||||
node = section
|
||||
for key in field_path:
|
||||
props = node.get("properties", {})
|
||||
if key not in props:
|
||||
return None
|
||||
node = resolve_schema_node(schema, props[key])
|
||||
|
||||
return node
|
||||
|
||||
|
||||
def is_boolean_field(field_schema: dict[str, Any]) -> bool:
|
||||
"""Check if a schema node represents a boolean field."""
|
||||
return field_schema.get("type") == "boolean"
|
||||
|
||||
|
||||
def is_enum_field(field_schema: dict[str, Any]) -> bool:
|
||||
"""Check if a schema node is an enum."""
|
||||
return "enum" in field_schema
|
||||
|
||||
|
||||
def is_object_field(field_schema: dict[str, Any]) -> bool:
|
||||
"""Check if a schema node is an object with properties."""
|
||||
return field_schema.get("type") == "object" or "properties" in field_schema
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Parse TypeScript section config files for hidden/advanced field info."""
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
SECTION_CONFIGS_DIR = (
|
||||
Path(__file__).resolve().parents[3]
|
||||
/ "web"
|
||||
/ "src"
|
||||
/ "components"
|
||||
/ "config-form"
|
||||
/ "section-configs"
|
||||
)
|
||||
|
||||
|
||||
def _extract_string_array(text: str, field_name: str) -> list[str]:
|
||||
"""Extract a string array value from TypeScript object literal text."""
|
||||
pattern = rf"{field_name}\s*:\s*\[(.*?)\]"
|
||||
match = re.search(pattern, text, re.DOTALL)
|
||||
if not match:
|
||||
return []
|
||||
content = match.group(1)
|
||||
return re.findall(r'"([^"]*)"', content)
|
||||
|
||||
|
||||
def _parse_section_file(filepath: Path) -> dict[str, Any]:
|
||||
"""Parse a single section config .ts file."""
|
||||
text = filepath.read_text()
|
||||
|
||||
# Extract base block
|
||||
base_match = re.search(r"base\s*:\s*\{(.*?)\n \}", text, re.DOTALL)
|
||||
base_text = base_match.group(1) if base_match else ""
|
||||
|
||||
# Extract global block
|
||||
global_match = re.search(r"global\s*:\s*\{(.*?)\n \}", text, re.DOTALL)
|
||||
global_text = global_match.group(1) if global_match else ""
|
||||
|
||||
# Extract camera block
|
||||
camera_match = re.search(r"camera\s*:\s*\{(.*?)\n \}", text, re.DOTALL)
|
||||
camera_text = camera_match.group(1) if camera_match else ""
|
||||
|
||||
result: dict[str, Any] = {
|
||||
"fieldOrder": _extract_string_array(base_text, "fieldOrder"),
|
||||
"hiddenFields": _extract_string_array(base_text, "hiddenFields"),
|
||||
"advancedFields": _extract_string_array(base_text, "advancedFields"),
|
||||
}
|
||||
|
||||
# Merge global-level hidden fields
|
||||
global_hidden = _extract_string_array(global_text, "hiddenFields")
|
||||
if global_hidden:
|
||||
result["globalHiddenFields"] = global_hidden
|
||||
|
||||
# Merge camera-level hidden fields
|
||||
camera_hidden = _extract_string_array(camera_text, "hiddenFields")
|
||||
if camera_hidden:
|
||||
result["cameraHiddenFields"] = camera_hidden
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def load_section_configs() -> dict[str, dict[str, Any]]:
|
||||
"""Load all section configs from TypeScript files.
|
||||
|
||||
Returns:
|
||||
Dict mapping section name to parsed config.
|
||||
"""
|
||||
# Read sectionConfigs.ts to get the mapping of section keys to filenames
|
||||
registry_path = SECTION_CONFIGS_DIR.parent / "sectionConfigs.ts"
|
||||
registry_text = registry_path.read_text()
|
||||
|
||||
configs: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for ts_file in SECTION_CONFIGS_DIR.glob("*.ts"):
|
||||
if ts_file.name == "types.ts":
|
||||
continue
|
||||
|
||||
section_name = ts_file.stem
|
||||
configs[section_name] = _parse_section_file(ts_file)
|
||||
|
||||
# Map section config keys from the registry (handles renames like
|
||||
# "timestamp_style: timestampStyle")
|
||||
key_map: dict[str, str] = {}
|
||||
for match in re.finditer(
|
||||
r"(\w+)(?:\s*:\s*\w+)?\s*,", registry_text[registry_text.find("{") :]
|
||||
):
|
||||
key = match.group(1)
|
||||
key_map[key] = key
|
||||
|
||||
# Handle explicit key mappings like `timestamp_style: timestampStyle`
|
||||
for match in re.finditer(r"(\w+)\s*:\s*(\w+)\s*,", registry_text):
|
||||
key_map[match.group(1)] = match.group(2)
|
||||
|
||||
return configs
|
||||
|
||||
|
||||
def get_hidden_fields(
|
||||
configs: dict[str, dict[str, Any]],
|
||||
section_key: str,
|
||||
level: str = "global",
|
||||
) -> set[str]:
|
||||
"""Get the set of hidden fields for a section at a given level.
|
||||
|
||||
Args:
|
||||
configs: Loaded section configs
|
||||
section_key: Config section name (e.g., "record")
|
||||
level: "global" or "camera"
|
||||
|
||||
Returns:
|
||||
Set of hidden field paths (e.g., {"enabled_in_config", "sync_recordings"})
|
||||
"""
|
||||
config = configs.get(section_key, {})
|
||||
hidden = set(config.get("hiddenFields", []))
|
||||
|
||||
if level == "global":
|
||||
hidden.update(config.get("globalHiddenFields", []))
|
||||
elif level == "camera":
|
||||
hidden.update(config.get("cameraHiddenFields", []))
|
||||
|
||||
return hidden
|
||||
|
||||
|
||||
def get_advanced_fields(
|
||||
configs: dict[str, dict[str, Any]],
|
||||
section_key: str,
|
||||
) -> set[str]:
|
||||
"""Get the set of advanced fields for a section."""
|
||||
config = configs.get(section_key, {})
|
||||
return set(config.get("advancedFields", []))
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Generate UI tab markdown content from parsed YAML blocks."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from .i18n_loader import get_field_description, get_field_label, get_value_label
|
||||
from .nav_map import ALL_CONFIG_SECTIONS, detect_level, get_nav_path
|
||||
from .schema_loader import is_boolean_field, is_object_field
|
||||
from .section_config_parser import get_hidden_fields
|
||||
from .yaml_extractor import YamlBlock, get_leaf_paths
|
||||
|
||||
|
||||
def _format_value(
|
||||
value: object,
|
||||
field_schema: dict[str, Any] | None,
|
||||
i18n: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
"""Format a YAML value for UI display.
|
||||
|
||||
Looks up i18n labels for enum/option values when available.
|
||||
"""
|
||||
if field_schema and is_boolean_field(field_schema):
|
||||
return "on" if value else "off"
|
||||
if isinstance(value, bool):
|
||||
return "on" if value else "off"
|
||||
if isinstance(value, list):
|
||||
if len(value) == 0:
|
||||
return "an empty list"
|
||||
items = []
|
||||
for v in value:
|
||||
label = get_value_label(i18n, str(v)) if i18n else None
|
||||
items.append(f"`{label}`" if label else f"`{v}`")
|
||||
return ", ".join(items)
|
||||
if value is None:
|
||||
return "empty"
|
||||
|
||||
# Try i18n label for the raw value (enum translations)
|
||||
if i18n and isinstance(value, str):
|
||||
label = get_value_label(i18n, value)
|
||||
if label:
|
||||
return f"`{label}`"
|
||||
|
||||
return f"`{value}`"
|
||||
|
||||
|
||||
def _build_field_label(
|
||||
i18n: dict[str, Any],
|
||||
section_key: str,
|
||||
field_path: list[str],
|
||||
level: str,
|
||||
) -> str:
|
||||
"""Build the display label for a field using i18n labels.
|
||||
|
||||
For a path like ["continuous", "days"], produces
|
||||
"Continuous retention > Retention days" using the actual i18n labels.
|
||||
"""
|
||||
parts: list[str] = []
|
||||
|
||||
for depth in range(len(field_path)):
|
||||
sub_path = field_path[: depth + 1]
|
||||
label = get_field_label(i18n, section_key, sub_path, level)
|
||||
|
||||
if label:
|
||||
parts.append(label)
|
||||
else:
|
||||
# Fallback to title-cased field name
|
||||
parts.append(field_path[depth].replace("_", " ").title())
|
||||
|
||||
return " > ".join(parts)
|
||||
|
||||
|
||||
def _is_hidden(
|
||||
field_key: str,
|
||||
full_path: list[str],
|
||||
hidden_fields: set[str],
|
||||
) -> bool:
|
||||
"""Check if a field should be hidden from UI output."""
|
||||
# Check exact match
|
||||
if field_key in hidden_fields:
|
||||
return True
|
||||
|
||||
# Check dotted path match (e.g., "alerts.enabled_in_config")
|
||||
dotted = ".".join(str(p) for p in full_path)
|
||||
if dotted in hidden_fields:
|
||||
return True
|
||||
|
||||
# Check wildcard patterns (e.g., "filters.*.mask")
|
||||
for pattern in hidden_fields:
|
||||
if "*" in pattern:
|
||||
parts = pattern.split(".")
|
||||
if len(parts) == len(full_path):
|
||||
match = all(
|
||||
p == "*" or p == fp for p, fp in zip(parts, full_path)
|
||||
)
|
||||
if match:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def generate_ui_content(
|
||||
block: YamlBlock,
|
||||
schema: dict[str, Any],
|
||||
i18n: dict[str, Any],
|
||||
section_configs: dict[str, dict[str, Any]],
|
||||
) -> str | None:
|
||||
"""Generate UI tab markdown content for a YAML block.
|
||||
|
||||
Args:
|
||||
block: Parsed YAML block from a doc file
|
||||
schema: Full JSON schema
|
||||
i18n: Loaded i18n translations
|
||||
section_configs: Parsed section config data
|
||||
|
||||
Returns:
|
||||
Generated markdown string for the UI tab, or None if the block
|
||||
can't be converted (not a config block, etc.)
|
||||
"""
|
||||
if block.section_key is None:
|
||||
return None
|
||||
|
||||
# Determine which config data to walk
|
||||
if block.is_camera_level:
|
||||
# Camera-level: unwrap cameras.{name}.{section}
|
||||
cam_data = block.parsed.get("cameras", {})
|
||||
cam_name = block.camera_name or next(iter(cam_data), None)
|
||||
if not cam_name:
|
||||
return None
|
||||
inner = cam_data.get(cam_name, {})
|
||||
if not isinstance(inner, dict):
|
||||
return None
|
||||
level = "camera"
|
||||
else:
|
||||
inner = block.parsed
|
||||
# Determine level from section key
|
||||
level = detect_level(block.section_key)
|
||||
|
||||
# Collect sections to process (may span multiple top-level keys)
|
||||
sections_to_process: list[tuple[str, dict]] = []
|
||||
for key in inner:
|
||||
if key in ALL_CONFIG_SECTIONS or key == block.section_key:
|
||||
val = inner[key]
|
||||
if isinstance(val, dict):
|
||||
sections_to_process.append((key, val))
|
||||
else:
|
||||
# Simple scalar at section level (e.g., record.enabled = True)
|
||||
sections_to_process.append((key, {key: val}))
|
||||
|
||||
# If inner is the section itself (e.g., parsed = {"record": {...}})
|
||||
if not sections_to_process and block.section_key in inner:
|
||||
section_data = inner[block.section_key]
|
||||
if isinstance(section_data, dict):
|
||||
sections_to_process = [(block.section_key, section_data)]
|
||||
|
||||
if not sections_to_process:
|
||||
# Try treating the whole inner dict as the section data
|
||||
sections_to_process = [(block.section_key, inner)]
|
||||
|
||||
# Choose pattern based on whether YAML has comments (descriptive) or values
|
||||
use_table = block.has_comments
|
||||
|
||||
lines: list[str] = []
|
||||
step_num = 1
|
||||
|
||||
for section_key, section_data in sections_to_process:
|
||||
# Get navigation path
|
||||
i18n_level = "cameras" if level == "camera" else "global"
|
||||
nav_path = get_nav_path(section_key, level)
|
||||
if nav_path is None:
|
||||
# Try global as fallback
|
||||
nav_path = get_nav_path(section_key, "global")
|
||||
if nav_path is None:
|
||||
continue
|
||||
|
||||
# Get hidden fields for this section
|
||||
hidden = get_hidden_fields(section_configs, section_key, level)
|
||||
|
||||
# Get leaf paths from the YAML data
|
||||
leaves = get_leaf_paths(section_data)
|
||||
|
||||
# Filter out hidden fields
|
||||
visible_leaves: list[tuple[tuple[str, ...], object]] = []
|
||||
for path, value in leaves:
|
||||
path_list = list(path)
|
||||
if not _is_hidden(path_list[-1], path_list, hidden):
|
||||
visible_leaves.append((path, value))
|
||||
|
||||
if not visible_leaves:
|
||||
continue
|
||||
|
||||
if use_table:
|
||||
# Pattern A: Field table with descriptions
|
||||
lines.append(
|
||||
f'Navigate to <NavPath path="{nav_path}" />.'
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("| Field | Description |")
|
||||
lines.append("|-------|-------------|")
|
||||
|
||||
for path, _value in visible_leaves:
|
||||
path_list = list(path)
|
||||
label = _build_field_label(
|
||||
i18n, section_key, path_list, i18n_level
|
||||
)
|
||||
desc = get_field_description(
|
||||
i18n, section_key, path_list, i18n_level
|
||||
)
|
||||
if not desc:
|
||||
desc = ""
|
||||
lines.append(f"| **{label}** | {desc} |")
|
||||
else:
|
||||
# Pattern B: Set instructions
|
||||
multi_section = len(sections_to_process) > 1
|
||||
|
||||
if multi_section:
|
||||
camera_note = ""
|
||||
if block.is_camera_level:
|
||||
camera_note = (
|
||||
" and select your camera"
|
||||
)
|
||||
lines.append(
|
||||
f'{step_num}. Navigate to <NavPath path="{nav_path}" />{camera_note}.'
|
||||
)
|
||||
else:
|
||||
if block.is_camera_level:
|
||||
lines.append(
|
||||
f'1. Navigate to <NavPath path="{nav_path}" /> and select your camera.'
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
f'Navigate to <NavPath path="{nav_path}" />.'
|
||||
)
|
||||
lines.append("")
|
||||
|
||||
from .schema_loader import get_field_info
|
||||
|
||||
for path, value in visible_leaves:
|
||||
path_list = list(path)
|
||||
label = _build_field_label(
|
||||
i18n, section_key, path_list, i18n_level
|
||||
)
|
||||
field_info = get_field_info(schema, section_key, path_list)
|
||||
formatted = _format_value(value, field_info, i18n)
|
||||
|
||||
if multi_section or block.is_camera_level:
|
||||
lines.append(f" - Set **{label}** to {formatted}")
|
||||
else:
|
||||
lines.append(f"- Set **{label}** to {formatted}")
|
||||
|
||||
step_num += 1
|
||||
|
||||
if not lines:
|
||||
return None
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def wrap_with_config_tabs(ui_content: str, yaml_raw: str, highlight: str | None = None) -> str:
|
||||
"""Wrap UI content and YAML in ConfigTabs markup.
|
||||
|
||||
Args:
|
||||
ui_content: Generated UI tab markdown
|
||||
yaml_raw: Original YAML text
|
||||
highlight: Optional highlight spec (e.g., "{3-4}")
|
||||
|
||||
Returns:
|
||||
Full ConfigTabs MDX block
|
||||
"""
|
||||
highlight_str = f" {highlight}" if highlight else ""
|
||||
|
||||
return f"""<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
|
||||
{ui_content}
|
||||
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
|
||||
```yaml{highlight_str}
|
||||
{yaml_raw}
|
||||
```
|
||||
|
||||
</TabItem>
|
||||
</ConfigTabs>"""
|
||||
@@ -0,0 +1,283 @@
|
||||
"""Extract YAML code blocks from markdown documentation files."""
|
||||
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
@dataclass
|
||||
class YamlBlock:
|
||||
"""A YAML code block extracted from a markdown file."""
|
||||
|
||||
raw: str # Original YAML text
|
||||
parsed: dict # Parsed YAML content
|
||||
line_start: int # Line number in the markdown file (1-based)
|
||||
line_end: int # End line number
|
||||
highlight: str | None = None # Highlight spec (e.g., "{3-4}")
|
||||
has_comments: bool = False # Whether the YAML has inline comments
|
||||
inside_config_tabs: bool = False # Already wrapped in ConfigTabs
|
||||
section_key: str | None = None # Detected top-level config section
|
||||
is_camera_level: bool = False # Whether this is camera-level config
|
||||
camera_name: str | None = None # Camera name if camera-level
|
||||
config_keys: list[str] = field(
|
||||
default_factory=list
|
||||
) # Top-level keys in the YAML
|
||||
|
||||
|
||||
def extract_yaml_blocks(content: str) -> list[YamlBlock]:
|
||||
"""Extract all YAML fenced code blocks from markdown content.
|
||||
|
||||
Args:
|
||||
content: Markdown file content
|
||||
|
||||
Returns:
|
||||
List of YamlBlock instances
|
||||
"""
|
||||
blocks: list[YamlBlock] = []
|
||||
lines = content.split("\n")
|
||||
i = 0
|
||||
in_config_tabs = False
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
|
||||
# Track ConfigTabs context
|
||||
if "<ConfigTabs>" in line:
|
||||
in_config_tabs = True
|
||||
elif "</ConfigTabs>" in line:
|
||||
in_config_tabs = False
|
||||
|
||||
# Look for YAML fence opening
|
||||
fence_match = re.match(r"^```yaml\s*(\{[^}]*\})?\s*$", line)
|
||||
if fence_match:
|
||||
highlight = fence_match.group(1)
|
||||
start_line = i + 1 # 1-based
|
||||
yaml_lines: list[str] = []
|
||||
i += 1
|
||||
|
||||
# Collect until closing fence
|
||||
while i < len(lines) and not lines[i].startswith("```"):
|
||||
yaml_lines.append(lines[i])
|
||||
i += 1
|
||||
|
||||
end_line = i + 1 # 1-based, inclusive of closing fence
|
||||
raw = "\n".join(yaml_lines)
|
||||
|
||||
# Check for inline comments
|
||||
has_comments = any(
|
||||
re.search(r"#\s*(<-|[A-Za-z])", yl) for yl in yaml_lines
|
||||
)
|
||||
|
||||
# Parse YAML
|
||||
try:
|
||||
parsed = yaml.safe_load(raw)
|
||||
except yaml.YAMLError:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Detect config section and level
|
||||
config_keys = list(parsed.keys())
|
||||
section_key = None
|
||||
is_camera = False
|
||||
camera_name = None
|
||||
|
||||
if "cameras" in parsed and isinstance(parsed["cameras"], dict):
|
||||
is_camera = True
|
||||
cam_entries = parsed["cameras"]
|
||||
if len(cam_entries) == 1:
|
||||
camera_name = list(cam_entries.keys())[0]
|
||||
inner = cam_entries[camera_name]
|
||||
if isinstance(inner, dict):
|
||||
inner_keys = list(inner.keys())
|
||||
if len(inner_keys) >= 1:
|
||||
section_key = inner_keys[0]
|
||||
elif len(config_keys) >= 1:
|
||||
section_key = config_keys[0]
|
||||
|
||||
blocks.append(
|
||||
YamlBlock(
|
||||
raw=raw,
|
||||
parsed=parsed,
|
||||
line_start=start_line,
|
||||
line_end=end_line,
|
||||
highlight=highlight,
|
||||
has_comments=has_comments,
|
||||
inside_config_tabs=in_config_tabs,
|
||||
section_key=section_key,
|
||||
is_camera_level=is_camera,
|
||||
camera_name=camera_name,
|
||||
config_keys=config_keys,
|
||||
)
|
||||
)
|
||||
|
||||
i += 1
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConfigTabsBlock:
|
||||
"""An existing ConfigTabs block in a markdown file."""
|
||||
|
||||
line_start: int # 1-based line of <ConfigTabs>
|
||||
line_end: int # 1-based line of </ConfigTabs>
|
||||
ui_content: str # Content inside the UI TabItem
|
||||
yaml_block: YamlBlock # The YAML block inside the YAML TabItem
|
||||
raw_text: str # Full raw text of the ConfigTabs block
|
||||
|
||||
|
||||
def extract_config_tabs_blocks(content: str) -> list[ConfigTabsBlock]:
|
||||
"""Extract existing ConfigTabs blocks from markdown content.
|
||||
|
||||
Parses the structure:
|
||||
<ConfigTabs>
|
||||
<TabItem value="ui">
|
||||
...ui content...
|
||||
</TabItem>
|
||||
<TabItem value="yaml">
|
||||
```yaml
|
||||
...yaml...
|
||||
```
|
||||
</TabItem>
|
||||
</ConfigTabs>
|
||||
|
||||
Returns:
|
||||
List of ConfigTabsBlock instances
|
||||
"""
|
||||
blocks: list[ConfigTabsBlock] = []
|
||||
lines = content.split("\n")
|
||||
i = 0
|
||||
|
||||
while i < len(lines):
|
||||
if "<ConfigTabs>" not in lines[i]:
|
||||
i += 1
|
||||
continue
|
||||
|
||||
block_start = i # 0-based
|
||||
|
||||
# Find </ConfigTabs>
|
||||
j = i + 1
|
||||
while j < len(lines) and "</ConfigTabs>" not in lines[j]:
|
||||
j += 1
|
||||
|
||||
if j >= len(lines):
|
||||
i += 1
|
||||
continue
|
||||
|
||||
block_end = j # 0-based, line with </ConfigTabs>
|
||||
block_text = "\n".join(lines[block_start : block_end + 1])
|
||||
|
||||
# Extract UI content (between <TabItem value="ui"> and </TabItem>)
|
||||
ui_match = re.search(
|
||||
r'<TabItem\s+value="ui">\s*\n(.*?)\n\s*</TabItem>',
|
||||
block_text,
|
||||
re.DOTALL,
|
||||
)
|
||||
ui_content = ui_match.group(1).strip() if ui_match else ""
|
||||
|
||||
# Extract YAML block from inside the yaml TabItem
|
||||
yaml_tab_match = re.search(
|
||||
r'<TabItem\s+value="yaml">\s*\n(.*?)\n\s*</TabItem>',
|
||||
block_text,
|
||||
re.DOTALL,
|
||||
)
|
||||
|
||||
yaml_block = None
|
||||
if yaml_tab_match:
|
||||
yaml_tab_text = yaml_tab_match.group(1)
|
||||
fence_match = re.search(
|
||||
r"```yaml\s*(\{[^}]*\})?\s*\n(.*?)\n```",
|
||||
yaml_tab_text,
|
||||
re.DOTALL,
|
||||
)
|
||||
if fence_match:
|
||||
highlight = fence_match.group(1)
|
||||
yaml_raw = fence_match.group(2)
|
||||
has_comments = bool(
|
||||
re.search(r"#\s*(<-|[A-Za-z])", yaml_raw)
|
||||
)
|
||||
|
||||
try:
|
||||
parsed = yaml.safe_load(yaml_raw)
|
||||
except yaml.YAMLError:
|
||||
parsed = {}
|
||||
|
||||
if isinstance(parsed, dict):
|
||||
config_keys = list(parsed.keys())
|
||||
section_key = None
|
||||
is_camera = False
|
||||
camera_name = None
|
||||
|
||||
if "cameras" in parsed and isinstance(
|
||||
parsed["cameras"], dict
|
||||
):
|
||||
is_camera = True
|
||||
cam_entries = parsed["cameras"]
|
||||
if len(cam_entries) == 1:
|
||||
camera_name = list(cam_entries.keys())[0]
|
||||
inner = cam_entries[camera_name]
|
||||
if isinstance(inner, dict):
|
||||
inner_keys = list(inner.keys())
|
||||
if len(inner_keys) >= 1:
|
||||
section_key = inner_keys[0]
|
||||
elif len(config_keys) >= 1:
|
||||
section_key = config_keys[0]
|
||||
|
||||
yaml_block = YamlBlock(
|
||||
raw=yaml_raw,
|
||||
parsed=parsed,
|
||||
line_start=block_start + 1,
|
||||
line_end=block_end + 1,
|
||||
highlight=highlight,
|
||||
has_comments=has_comments,
|
||||
inside_config_tabs=True,
|
||||
section_key=section_key,
|
||||
is_camera_level=is_camera,
|
||||
camera_name=camera_name,
|
||||
config_keys=config_keys,
|
||||
)
|
||||
|
||||
if yaml_block:
|
||||
blocks.append(
|
||||
ConfigTabsBlock(
|
||||
line_start=block_start + 1, # 1-based
|
||||
line_end=block_end + 1, # 1-based
|
||||
ui_content=ui_content,
|
||||
yaml_block=yaml_block,
|
||||
raw_text=block_text,
|
||||
)
|
||||
)
|
||||
|
||||
i = j + 1
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
def get_leaf_paths(
|
||||
data: dict, prefix: tuple[str, ...] = ()
|
||||
) -> list[tuple[tuple[str, ...], object]]:
|
||||
"""Walk a parsed YAML dict and return all leaf key paths with values.
|
||||
|
||||
Args:
|
||||
data: Parsed YAML dict
|
||||
prefix: Current key path prefix
|
||||
|
||||
Returns:
|
||||
List of (key_path_tuple, value) pairs.
|
||||
e.g., [( ("record", "continuous", "days"), 3 ), ...]
|
||||
"""
|
||||
results: list[tuple[tuple[str, ...], object]] = []
|
||||
|
||||
for key, value in data.items():
|
||||
path = prefix + (str(key),)
|
||||
if isinstance(value, dict):
|
||||
results.extend(get_leaf_paths(value, path))
|
||||
else:
|
||||
results.append((path, value))
|
||||
|
||||
return results
|
||||
Reference in New Issue
Block a user