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": [], } 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)), }) 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"])) return result