623 lines
36 KiB
Python
623 lines
36 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": 22,
|
|
"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)
|
|
try:
|
|
source_version = int(raw.get("version") or 0) if isinstance(raw, dict) else 0
|
|
except (TypeError, ValueError):
|
|
source_version = 0
|
|
normalized = self._normalize(raw)
|
|
if had_legacy_shortcuts or source_version < 22:
|
|
# Build61: remove old hidden component-level Space/Ctrl+R bindings.
|
|
# BUILD90: persist the one-time v21 -> v22 native-countdown migration
|
|
# so old Text mirror timer steps stop producing per-second SetText traffic.
|
|
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
|
|
|
|
source_version_raw = config.get("version", 0)
|
|
try:
|
|
source_version = int(source_version_raw or 0)
|
|
except (TypeError, ValueError):
|
|
source_version = 0
|
|
result["version"] = 22
|
|
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",
|
|
# BUILD90: configs created before v22 used Text mirror as the default,
|
|
# which pushed timer text every second. Migrate those hockey timer
|
|
# sync steps once to native vMix countdown transport. From v22 onward
|
|
# an explicitly selected legacy Text mirror remains available.
|
|
"game_vmix_mode": (
|
|
"countdown" if source_version < 22 and step_type == "hockey_vmix_timers_start"
|
|
else (str(step.get("game_vmix_mode") or "countdown") if str(step.get("game_vmix_mode") or "countdown") in {"countdown", "text"} else "countdown")
|
|
),
|
|
"penalty_vmix_mode": (
|
|
"countdown" if source_version < 22 and step_type == "hockey_vmix_timers_start"
|
|
else (str(step.get("penalty_vmix_mode") or "countdown") if str(step.get("penalty_vmix_mode") or "countdown") in {"countdown", "text"} else "countdown")
|
|
),
|
|
"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
|