mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-30 20:04:54 +03:00
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* add generation script a script to read yaml code blocks from docs markdown files and generate corresponding "Frigate UI" tab instructions based on the json schema, i18n, section configs (hidden fields), and nav mappings * first pass * components * add to gitignore * second pass * fix broken anchors * fixes * clean up tabs * version bump * tweaks * remove role mapping config from ui
284 lines
8.9 KiB
Python
284 lines
8.9 KiB
Python
"""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>"""
|