Files
hockey_new/ui_builder/manager.py

605 lines
34 KiB
Python

from __future__ import annotations
import json
import re
import shutil
from copy import deepcopy
from datetime import datetime
from pathlib import Path
from threading import RLock
from typing import Any
DEFAULT_CONFIG: dict[str, Any] = {
"version": 21,
"project_name": "Новый интерфейс",
"data_source": "golf",
"canvas": {
"width": 1440,
"height": 900,
"grid_size": 10,
"snap_enabled": True,
"snap_threshold": 8,
"show_grid": True,
"auto_bind_containers": True,
"background": "#0c1421",
},
"tabs": [
{"id": "main", "label": "Основное"},
{"id": "data", "label": "Данные"},
],
"components": [],
"triggers": [],
"shortcut_sequences": [],
"prematch_groups": [],
"prematch_buttons": [],
"quick_panel_selectors": [],
"player_selection_panels": [],
}
DEFAULT_HOCKEY_PLAYER_SELECTION_PANELS: list[dict[str, Any]] = [
{"id": "compare_home", "label": "Сравнение · левая команда", "description": "Два игрока левой команды", "slots": 2, "rule": "home", "sync_selected_player": False, "collapsed_default": True, "sort_order": 0, "enabled": True},
{"id": "compare_away", "label": "Сравнение · правая команда", "description": "Два игрока правой команды", "slots": 2, "rule": "away", "sync_selected_player": False, "collapsed_default": True, "sort_order": 10, "enabled": True},
{"id": "compare_mixed", "label": "Сравнение · разные команды", "description": "По одному игроку каждой команды", "slots": 2, "rule": "different_teams", "sync_selected_player": False, "collapsed_default": True, "sort_order": 20, "enabled": True},
{"id": "three_stars", "label": "3 звезды", "description": "Три игрока для финального титра", "slots": 3, "rule": "any", "sync_selected_player": False, "collapsed_default": True, "sort_order": 30, "enabled": True},
{"id": "selected_player", "label": "Выбранный игрок", "description": "Один игрок для подписи / индивидуального титра", "slots": 1, "rule": "any", "sync_selected_player": True, "collapsed_default": True, "sort_order": 40, "enabled": True},
]
class UIBuilderManager:
"""JSON settings storage with atomic writes and rolling backups."""
def __init__(self, settings_dir: Path, *, filename: str = "ui_builder.json") -> None:
self.settings_dir = Path(settings_dir)
self.settings_file = self.settings_dir / Path(filename).name
self.backups_dir = self.settings_dir / "backups"
self.backup_prefix = self.settings_file.stem
self._lock = RLock()
self.settings_dir.mkdir(parents=True, exist_ok=True)
self.backups_dir.mkdir(parents=True, exist_ok=True)
if not self.settings_file.exists():
self.save(DEFAULT_CONFIG, create_backup=False)
def load(self) -> dict[str, Any]:
with self._lock:
try:
raw = json.loads(self.settings_file.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, TypeError):
return deepcopy(DEFAULT_CONFIG)
had_legacy_shortcuts = self._has_legacy_hockey_component_shortcuts(raw)
normalized = self._normalize(raw)
if had_legacy_shortcuts:
# Build61 migration: remove old hidden component-level Space/Ctrl+R
# bindings from the persisted draft/published JSON. Shortcut Sequences
# are intentionally untouched, so an explicitly configured Space
# sequence remains available in the visible shortcut editor.
self.save(normalized, create_backup=False)
return normalized
def save(self, config: dict[str, Any], *, create_backup: bool = True) -> dict[str, Any]:
normalized = self._normalize(config)
with self._lock:
if create_backup and self.settings_file.exists():
stamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
shutil.copy2(self.settings_file, self.backups_dir / f"{self.backup_prefix}_{stamp}.json")
self._trim_backups(40)
temp_file = self.settings_file.with_suffix(self.settings_file.suffix + ".tmp")
temp_file.write_text(
json.dumps(normalized, ensure_ascii=False, indent=2),
encoding="utf-8",
)
temp_file.replace(self.settings_file)
return normalized
def import_config(self, payload: dict[str, Any]) -> dict[str, Any]:
return self.save(payload, create_backup=True)
def list_backups(self) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for path in sorted(self.backups_dir.glob(f"{self.backup_prefix}_*.json"), reverse=True):
result.append(
{
"name": path.name,
"modified": datetime.fromtimestamp(path.stat().st_mtime).isoformat(timespec="seconds"),
"size": path.stat().st_size,
}
)
return result
def restore_backup(self, name: str) -> dict[str, Any]:
safe_name = Path(name).name
path = self.backups_dir / safe_name
if not path.exists() or path.parent.resolve() != self.backups_dir.resolve():
raise FileNotFoundError(safe_name)
payload = json.loads(path.read_text(encoding="utf-8"))
return self.save(payload, create_backup=True)
def _trim_backups(self, keep: int) -> None:
backups = sorted(self.backups_dir.glob(f"{self.backup_prefix}_*.json"), reverse=True)
for path in backups[keep:]:
path.unlink(missing_ok=True)
@staticmethod
def _is_hockey_config(config: dict[str, Any]) -> bool:
components = config.get("components") if isinstance(config, dict) else None
if not isinstance(components, list):
return False
for component in components:
if not isinstance(component, dict):
continue
component_type = str(component.get("type") or "")
action_id = str(component.get("action_id") or "")
if component_type.startswith("hockey_") or action_id.startswith("hockey_"):
return True
return False
@staticmethod
def _legacy_component_shortcut_combo(value: Any) -> bool:
compact = re.sub(r"\s+", "", str(value or "")).lower()
return compact in {"space", "spacebar", "ctrl+r", "control+r"}
@classmethod
def _has_legacy_hockey_component_shortcuts(cls, config: dict[str, Any]) -> bool:
if not cls._is_hockey_config(config):
return False
components = config.get("components") if isinstance(config.get("components"), list) else []
for component in components:
if not isinstance(component, dict):
continue
shortcuts = component.get("shortcuts") if isinstance(component.get("shortcuts"), list) else []
if any(isinstance(shortcut, dict) and cls._legacy_component_shortcut_combo(shortcut.get("combo")) for shortcut in shortcuts):
return True
return False
@staticmethod
def _normalize(config: dict[str, Any]) -> dict[str, Any]:
result = deepcopy(DEFAULT_CONFIG)
if not isinstance(config, dict):
return result
result["version"] = 21
result["project_name"] = str(config.get("project_name") or result["project_name"])
result["data_source"] = str(config.get("data_source") or result["data_source"])
canvas = config.get("canvas") if isinstance(config.get("canvas"), dict) else {}
result["canvas"].update(canvas)
for key, low, high, fallback in (
("width", 640, 7680, 1440),
("height", 360, 4320, 900),
("grid_size", 1, 100, 10),
("snap_threshold", 1, 50, 8),
):
try:
value = int(result["canvas"].get(key, fallback))
except (TypeError, ValueError):
value = fallback
result["canvas"][key] = max(low, min(high, value))
result["canvas"]["snap_enabled"] = bool(result["canvas"].get("snap_enabled", True))
result["canvas"]["show_grid"] = bool(result["canvas"].get("show_grid", True))
result["canvas"]["auto_bind_containers"] = bool(result["canvas"].get("auto_bind_containers", True))
result["canvas"]["background"] = str(result["canvas"].get("background") or "#0c1421")
tabs = config.get("tabs") if isinstance(config.get("tabs"), list) else []
normalized_tabs: list[dict[str, str]] = []
seen_tabs: set[str] = set()
for index, tab in enumerate(tabs):
if not isinstance(tab, dict):
continue
tab_id = str(tab.get("id") or f"tab-{index + 1}").strip()
if not tab_id or tab_id in seen_tabs or tab_id == "*":
continue
seen_tabs.add(tab_id)
normalized_tabs.append({"id": tab_id, "label": str(tab.get("label") or tab_id)})
result["tabs"] = normalized_tabs or deepcopy(DEFAULT_CONFIG["tabs"])
components = config.get("components") if isinstance(config.get("components"), list) else []
normalized_components: list[dict[str, Any]] = []
for index, component in enumerate(components):
if not isinstance(component, dict):
continue
item = deepcopy(component)
item["id"] = str(item.get("id") or f"component-{index + 1}")
item["type"] = str(item.get("type") or "text")
item["title"] = str(item.get("title") or item["type"])
for key, fallback in (("x", 20), ("y", 20), ("w", 240), ("h", 80), ("z", index + 1)):
try:
item[key] = int(float(item.get(key, fallback)))
except (TypeError, ValueError):
item[key] = fallback
item["x"] = max(0, min(result["canvas"]["width"] - 20, item["x"]))
item["y"] = max(0, min(result["canvas"]["height"] - 20, item["y"]))
item["w"] = max(40, min(result["canvas"]["width"], item["w"]))
item["h"] = max(28, min(result["canvas"]["height"], item["h"]))
item["z"] = max(0, item["z"])
component_tabs = item.get("tabs") if isinstance(item.get("tabs"), list) else ["*"]
item["tabs"] = [str(tab) for tab in component_tabs if str(tab)] or ["*"]
item["props"] = item.get("props") if isinstance(item.get("props"), dict) else {}
item["style"] = item.get("style") if isinstance(item.get("style"), dict) else {}
item["locked"] = bool(item.get("locked", False))
item["hidden"] = bool(item.get("hidden", False))
parent_id = item.get("parent_id")
item["parent_id"] = str(parent_id) if parent_id else None
action_id = str(item.get("action_id") or f"{item['type']}_{index + 1}").strip()
action_id = re.sub(r"[^a-zA-Z0-9_.:-]+", "_", action_id).strip("_") or f"component_{index + 1}"
item["action_id"] = action_id
item["interaction_mode"] = str(item.get("interaction_mode") or "event_only")
item["initial_state"] = bool(item.get("initial_state", False))
raw_shortcuts = item.get("shortcuts") if isinstance(item.get("shortcuts"), list) else []
if UIBuilderManager._is_hockey_config(config):
raw_shortcuts = [
shortcut for shortcut in raw_shortcuts
if not (
isinstance(shortcut, dict)
and UIBuilderManager._legacy_component_shortcut_combo(shortcut.get("combo"))
)
]
normalized_shortcuts: list[dict[str, Any]] = []
for shortcut_index, shortcut in enumerate(raw_shortcuts):
if not isinstance(shortcut, dict):
continue
combo = str(shortcut.get("combo") or "").strip()
normalized_shortcuts.append({
"id": str(shortcut.get("id") or f"shortcut-{shortcut_index + 1}"),
"enabled": bool(shortcut.get("enabled", True)),
"combo": combo,
"event": str(shortcut.get("event") or "click"),
"item_id": str(shortcut.get("item_id") or ""),
"value": shortcut.get("value", ""),
"prevent_default": bool(shortcut.get("prevent_default", True)),
"allow_in_inputs": bool(shortcut.get("allow_in_inputs", False)),
"global": bool(shortcut.get("global", False)),
"scope": "all" if shortcut.get("scope") == "all" else "runtime",
})
item["shortcuts"] = normalized_shortcuts
normalized_components.append(item)
valid_ids = {item["id"] for item in normalized_components}
by_id = {item["id"]: item for item in normalized_components}
for item in normalized_components:
if item["parent_id"] not in valid_ids or item["parent_id"] == item["id"]:
item["parent_id"] = None
continue
seen = {item["id"]}
parent_id = item["parent_id"]
while parent_id:
if parent_id in seen:
item["parent_id"] = None
break
seen.add(parent_id)
parent_id = by_id.get(parent_id, {}).get("parent_id")
used_action_ids: set[str] = set()
for index, item in enumerate(normalized_components):
base = item["action_id"]
candidate = base
suffix = 2
while candidate in used_action_ids:
candidate = f"{base}_{suffix}"
suffix += 1
item["action_id"] = candidate
used_action_ids.add(candidate)
triggers = config.get("triggers") if isinstance(config.get("triggers"), list) else []
normalized_triggers: list[dict[str, Any]] = []
for index, trigger in enumerate(triggers):
if not isinstance(trigger, dict):
continue
action = trigger.get("action") if isinstance(trigger.get("action"), dict) else {}
condition = trigger.get("condition") if isinstance(trigger.get("condition"), dict) else {}
normalized_triggers.append({
"id": str(trigger.get("id") or f"trigger-{index + 1}"),
"name": str(trigger.get("name") or f"Триггер {index + 1}"),
"enabled": bool(trigger.get("enabled", True)),
"source_action_id": str(trigger.get("source_action_id") or ""),
"event": str(trigger.get("event") or "click"),
"item_id": str(trigger.get("item_id") or ""),
"condition": {
"field": str(condition.get("field") or ""),
"operator": str(condition.get("operator") or "equals"),
"value": condition.get("value", ""),
},
"action": {
"type": str(action.get("type") or "show_message"),
"target_action_id": str(action.get("target_action_id") or ""),
"state_key": str(action.get("state_key") or "active"),
"value": action.get("value", "true"),
"function_name": str(action.get("function_name") or ""),
"event_name": str(action.get("event_name") or "ui-builder:custom"),
"message": str(action.get("message") or "Триггер выполнен"),
"tab_id": str(action.get("tab_id") or ""),
"url": str(action.get("url") or ""),
"method": str(action.get("method") or "POST").upper(),
"body": str(action.get("body") or ""),
"timer_command": str(action.get("timer_command") or "toggle"),
"timer_value": action.get("timer_value", ""),
"sequence_id": str(action.get("sequence_id") or ""),
},
})
shortcut_sequences = config.get("shortcut_sequences") if isinstance(config.get("shortcut_sequences"), list) else []
normalized_shortcut_sequences: list[dict[str, Any]] = []
valid_conditions = {
"always", "has_penalties", "no_penalties", "has_home_penalties",
"has_away_penalties", "has_both_penalties", "no_home_penalties", "no_away_penalties",
"home_delayed_penalty", "away_delayed_penalty", "any_delayed_penalty",
"home_empty_net", "away_empty_net", "any_empty_net",
"prematch_button_active", "prematch_button_inactive",
"active_tab", "inactive_tab",
}
valid_step_types = {
"timer_command", "hockey_penalties_command", "vmix_command",
"hockey_vmix_timers_start", "delay", "dispatch_event",
}
for sequence_index, sequence in enumerate(shortcut_sequences):
if not isinstance(sequence, dict):
continue
raw_steps = sequence.get("steps") if isinstance(sequence.get("steps"), list) else []
steps: list[dict[str, Any]] = []
for step_index, step in enumerate(raw_steps):
if not isinstance(step, dict):
continue
step_type = str(step.get("type") or "vmix_command")
if step_type not in valid_step_types:
step_type = "vmix_command"
condition = str(step.get("condition") or "always")
if condition not in valid_conditions:
condition = "always"
try:
milliseconds = int(float(step.get("milliseconds") or 0))
except (TypeError, ValueError):
milliseconds = 0
milliseconds = max(0, min(10000, milliseconds))
def _normalize_penalty_targets(raw_targets: Any, legacy_inputs: Any, legacy_names: Any) -> list[dict[str, str]]:
targets: list[dict[str, Any]] = []
if isinstance(raw_targets, list):
targets = [item for item in raw_targets if isinstance(item, dict)]
else:
inputs = [item.strip() for item in str(legacy_inputs or "").replace(";", ",").split(",") if item.strip()]
names = [item.strip() for item in str(legacy_names or "").replace(";", ",").split(",") if item.strip()]
targets = [{"input": input_ref, "selected_name": names[index] if index < len(names) else ""} for index, input_ref in enumerate(inputs)]
return [{
"id": str(item.get("id") or f"penalty-target-{index + 1}"),
"input": str(item.get("input") or ""),
"selected_name": str(item.get("selected_name") or item.get("selectedName") or ""),
"overlay": str(item.get("overlay") or "2") if str(item.get("overlay") or "2") in {"1", "2", "3", "4"} else "2",
"auto_hide_on_finish": bool(item.get("auto_hide_on_finish", True)),
} for index, item in enumerate(targets[:8])]
def _normalize_finish_actions(raw_actions: Any) -> list[dict[str, Any]]:
if not isinstance(raw_actions, list):
return []
result_actions: list[dict[str, Any]] = []
for action_index, action in enumerate(raw_actions[:12]):
if not isinstance(action, dict):
continue
source = str(action.get("source") or "game")
if source not in {"game", "any_penalty", "home_penalty", "away_penalty"}:
source = "game"
overlay = str(action.get("overlay") or "1")
if overlay not in {"1", "2", "3", "4"}:
overlay = "1"
try:
duration_ms = int(float(action.get("duration_ms") or 3000))
except (TypeError, ValueError):
duration_ms = 3000
result_actions.append({
"id": str(action.get("id") or f"finish-action-{action_index + 1}"),
"enabled": bool(action.get("enabled", True)),
"source": source,
"input": str(action.get("input") or ""),
"overlay": overlay,
"duration_ms": max(100, min(120000, duration_ms)),
"only_when_side_clear": bool(action.get("only_when_side_clear", source in {"any_penalty", "home_penalty", "away_penalty"})),
})
return result_actions
home_penalty_targets = _normalize_penalty_targets(
step.get("home_penalty_targets"), step.get("home_penalty_inputs"), step.get("home_penalty_selected_names")
)
away_penalty_targets = _normalize_penalty_targets(
step.get("away_penalty_targets"), step.get("away_penalty_inputs"), step.get("away_penalty_selected_names")
)
timer_finish_actions = _normalize_finish_actions(step.get("timer_finish_actions"))
steps.append({
"id": str(step.get("id") or f"step-{step_index + 1}"),
"enabled": bool(step.get("enabled", True)),
"type": step_type,
"label": str(step.get("label") or ""),
"condition": condition,
"condition_value": str(step.get("condition_value") or "")[:96],
"target_action_id": str(step.get("target_action_id") or ""),
"timer_command": str(step.get("timer_command") or "start"),
"timer_value": step.get("timer_value", ""),
"penalty_command": str(step.get("penalty_command") or "start"),
"function": str(step.get("function") or ""),
"input": str(step.get("input") or ""),
"value": step.get("value", ""),
"selected_name": str(step.get("selected_name") or ""),
"duration": str(step.get("duration") or ""),
"mix": str(step.get("mix") or ""),
"use_scoreboard_alternate": bool(step.get("use_scoreboard_alternate", False)),
"scoreboard_alternate_input": str(step.get("scoreboard_alternate_input") or ""),
"scoreboard_alternate_selected_name": str(step.get("scoreboard_alternate_selected_name") or ""),
"game_timer_action_id": str(step.get("game_timer_action_id") or "hockey_game_timer"),
"hockey_timer_command": str(step.get("hockey_timer_command") or "toggle") if str(step.get("hockey_timer_command") or "toggle") in {"toggle", "start", "pause", "resume"} else "toggle",
"game_vmix_mode": str(step.get("game_vmix_mode") or "text") if str(step.get("game_vmix_mode") or "text") in {"countdown", "text"} else "text",
"penalty_vmix_mode": str(step.get("penalty_vmix_mode") or "text") if str(step.get("penalty_vmix_mode") or "text") in {"countdown", "text"} else "text",
"penalty_display_mode": "all" if str(step.get("penalty_display_mode") or "soonest") == "all" else "soonest",
"game_vmix_input": str(step.get("game_vmix_input") or ""),
"game_vmix_selected_name": str(step.get("game_vmix_selected_name") or ""),
"home_penalty_inputs": str(step.get("home_penalty_inputs") or ""),
"away_penalty_inputs": str(step.get("away_penalty_inputs") or ""),
"home_penalty_selected_names": str(step.get("home_penalty_selected_names") or ""),
"away_penalty_selected_names": str(step.get("away_penalty_selected_names") or ""),
"home_penalty_targets": home_penalty_targets,
"away_penalty_targets": away_penalty_targets,
"timer_finish_actions": timer_finish_actions,
"start_web_game": bool(step.get("start_web_game", True)),
"start_web_penalties": bool(step.get("start_web_penalties", True)),
"sync_vmix_game": bool(step.get("sync_vmix_game", True)),
"sync_vmix_penalties": bool(step.get("sync_vmix_penalties", True)),
"milliseconds": milliseconds,
"event_name": str(step.get("event_name") or "ui-builder:shortcut-sequence"),
})
normalized_shortcut_sequences.append({
"id": str(sequence.get("id") or f"sequence-{sequence_index + 1}"),
"name": str(sequence.get("name") or f"Шорткат {sequence_index + 1}"),
"description": str(sequence.get("description") or ""),
"enabled": bool(sequence.get("enabled", True)),
"combo": str(sequence.get("combo") or "").strip(),
"prevent_default": bool(sequence.get("prevent_default", True)),
"allow_in_inputs": bool(sequence.get("allow_in_inputs", False)),
"toggle_all_overlays_on_repeat": bool(sequence.get("toggle_all_overlays_on_repeat", False)),
"sync_hockey_team_states": bool(sequence.get("sync_hockey_team_states", sequence.get("toggle_all_overlays_on_repeat", False))),
"is_scoreboard_sequence": bool(sequence.get("is_scoreboard_sequence", sequence.get("sync_hockey_team_states", sequence.get("toggle_all_overlays_on_repeat", False)))),
"scope": "all" if sequence.get("scope") == "all" else "runtime",
"steps": steps,
})
raw_prematch_groups = config.get("prematch_groups") if isinstance(config.get("prematch_groups"), list) else []
normalized_prematch_groups: list[dict[str, Any]] = []
seen_group_ids: set[str] = set()
for group_index, group in enumerate(raw_prematch_groups[:24]):
if not isinstance(group, dict):
continue
raw_id = re.sub(r"[^A-Za-z0-9_-]+", "_", str(group.get("id") or f"group_{group_index + 1}").strip())[:48].strip("_")
group_id = raw_id or f"group_{group_index + 1}"
suffix = 2
base_id = group_id
while group_id in seen_group_ids:
group_id = f"{base_id}_{suffix}"[:48]
suffix += 1
seen_group_ids.add(group_id)
normalized_prematch_groups.append({
"id": group_id,
"label": str(group.get("label") or f"Вкладка {group_index + 1}")[:80],
"sort_order": int(group.get("sort_order") if str(group.get("sort_order", "")).lstrip("-").isdigit() else group_index * 10),
"enabled": bool(group.get("enabled", True)),
})
raw_prematch_buttons = config.get("prematch_buttons") if isinstance(config.get("prematch_buttons"), list) else []
normalized_prematch_buttons: list[dict[str, Any]] = []
seen_prematch_ids: set[str] = set()
for button_index, button in enumerate(raw_prematch_buttons[:64]):
if not isinstance(button, dict):
continue
raw_id = re.sub(r"[^A-Za-z0-9_-]+", "_", str(button.get("id") or f"prematch_{button_index + 1}").strip())[:48].strip("_")
button_id = raw_id or f"prematch_{button_index + 1}"
suffix = 2
base_id = button_id
while button_id in seen_prematch_ids:
button_id = f"{base_id}_{suffix}"[:48]
suffix += 1
seen_prematch_ids.add(button_id)
mode = str(button.get("mode") or "action").lower()
if mode not in {"action", "toggle"}:
mode = "action"
normalized_prematch_buttons.append({
"id": button_id,
"label": str(button.get("label") or f"Кнопка {button_index + 1}")[:80],
"description": str(button.get("description") or "")[:300],
"mode": mode,
"sequence_id": str(button.get("sequence_id") or "")[:128],
"group_id": str(button.get("group_id") or "")[:48] if str(button.get("group_id") or "") in seen_group_ids else "",
"sort_order": int(button.get("sort_order") if str(button.get("sort_order", "")).lstrip("-").isdigit() else button_index * 10),
"enabled": bool(button.get("enabled", True)),
})
raw_quick_panel_selectors = config.get("quick_panel_selectors") if isinstance(config.get("quick_panel_selectors"), list) else []
normalized_quick_panel_selectors: list[dict[str, Any]] = []
seen_selector_ids: set[str] = set()
valid_button_ids = {item["id"] for item in normalized_prematch_buttons}
valid_group_ids = {item["id"] for item in normalized_prematch_groups}
for selector_index, selector in enumerate(raw_quick_panel_selectors[:64]):
if not isinstance(selector, dict):
continue
raw_id = re.sub(r"[^A-Za-z0-9_-]+", "_", str(selector.get("id") or f"selector_{selector_index + 1}").strip())[:48].strip("_")
selector_id = raw_id or f"selector_{selector_index + 1}"
base_id = selector_id
suffix = 2
while selector_id in seen_selector_ids:
selector_id = f"{base_id}_{suffix}"[:48]
suffix += 1
seen_selector_ids.add(selector_id)
raw_options = selector.get("options") if isinstance(selector.get("options"), list) else []
options: list[dict[str, str]] = []
for option_index, option in enumerate(raw_options[:16]):
if isinstance(option, dict):
value = str(option.get("value", option.get("id", option_index + 1)))[:64]
label = str(option.get("label", value))[:80]
else:
value = str(option)[:64]
label = value[:80]
if value:
options.append({"value": value, "label": label})
if not options:
options = [{"value": "1", "label": "1"}, {"value": "2", "label": "2"}, {"value": "3", "label": "3"}]
default_value = str(selector.get("default_value") or options[0]["value"])[:64]
if default_value not in {item["value"] for item in options}:
default_value = options[0]["value"]
group_id = str(selector.get("group_id") or "")[:48]
button_id = str(selector.get("button_id") or "")[:48]
normalized_quick_panel_selectors.append({
"id": selector_id,
"label": str(selector.get("label") or f"Переключатель {selector_index + 1}")[:80],
"description": str(selector.get("description") or "")[:300],
"group_id": group_id if group_id in valid_group_ids else "",
"button_id": button_id if button_id in valid_button_ids else "",
"style": "select" if str(selector.get("style") or "segments") == "select" else "segments",
"options": options,
"default_value": default_value,
"sort_order": int(selector.get("sort_order") if str(selector.get("sort_order", "")).lstrip("-").isdigit() else selector_index * 10),
"enabled": bool(selector.get("enabled", True)),
})
raw_player_selection_panels = config.get("player_selection_panels") if isinstance(config.get("player_selection_panels"), list) else None
if raw_player_selection_panels is None:
raw_player_selection_panels = deepcopy(DEFAULT_HOCKEY_PLAYER_SELECTION_PANELS) if ("player_selection_panels" not in config and UIBuilderManager._is_hockey_config(config)) else []
normalized_player_selection_panels: list[dict[str, Any]] = []
seen_player_panel_ids: set[str] = set()
allowed_player_panel_rules = {"any", "home", "away", "different_teams"}
for panel_index, panel in enumerate(raw_player_selection_panels[:16]):
if not isinstance(panel, dict):
continue
raw_id = re.sub(r"[^A-Za-z0-9_]+", "_", str(panel.get("id") or f"players_{panel_index + 1}").strip())[:48].strip("_")
panel_id = raw_id or f"players_{panel_index + 1}"
if not re.match(r"^[A-Za-z]", panel_id):
panel_id = f"p_{panel_id}"[:48]
base_id = panel_id
suffix = 2
while panel_id in seen_player_panel_ids:
panel_id = f"{base_id}_{suffix}"[:48]
suffix += 1
seen_player_panel_ids.add(panel_id)
try:
slots = max(1, min(6, int(panel.get("slots") or 1)))
except (TypeError, ValueError):
slots = 1
rule = str(panel.get("rule") or "any").strip().lower()
if rule not in allowed_player_panel_rules:
rule = "any"
normalized_player_selection_panels.append({
"id": panel_id,
"label": str(panel.get("label") or f"Игроки {panel_index + 1}")[:80],
"description": str(panel.get("description") or "")[:300],
"slots": slots,
"rule": rule,
"sync_selected_player": bool(panel.get("sync_selected_player", False)),
"collapsed_default": bool(panel.get("collapsed_default", True)),
"sort_order": int(panel.get("sort_order") if str(panel.get("sort_order", "")).lstrip("-").isdigit() else panel_index * 10),
"enabled": bool(panel.get("enabled", True)),
})
result["components"] = normalized_components
result["triggers"] = normalized_triggers
result["shortcut_sequences"] = normalized_shortcut_sequences
result["prematch_groups"] = sorted(normalized_prematch_groups, key=lambda item: (item["sort_order"], item["label"]))
result["prematch_buttons"] = sorted(normalized_prematch_buttons, key=lambda item: (item.get("group_id", ""), item["sort_order"], item["label"]))
result["quick_panel_selectors"] = sorted(normalized_quick_panel_selectors, key=lambda item: (item.get("group_id", ""), item["sort_order"], item["label"]))
result["player_selection_panels"] = sorted(normalized_player_selection_panels, key=lambda item: (item["sort_order"], item["label"]))
return result