from __future__ import annotations import json import re from copy import deepcopy from datetime import datetime from pathlib import Path from typing import Any LIB_DIR = Path(__file__).resolve().parent PROJECT_DIR = LIB_DIR.parent APP_DIR = PROJECT_DIR / "app" SETTINGS_DIR = PROJECT_DIR / "settings" SETTINGS_FILE = SETTINGS_DIR / "settings.json" VMIX_JSON_FILE = SETTINGS_DIR / "vmix_json.json" VMIX_FUNCTIONS_FILE = SETTINGS_DIR / "vmix_functions.json" BACKUPS_DIR = SETTINGS_DIR / "backups" def configure( *, project_dir: str | Path | None = None, app_dir: str | Path | None = None, settings_dir: str | Path | None = None, settings_file: str = "settings.json", vmix_json_file: str = "vmix_json.json", vmix_functions_file: str = "vmix_functions.json", default_settings: dict[str, Any] | None = None, default_vmix_json: dict[str, Any] | None = None, default_vmix_functions: dict[str, Any] | None = None, ) -> None: """Configure the library for any project. The module keeps the same simple function API, but all paths and defaults can be injected by another project. That means the same folder ``broadcast_settings`` can be copied to Golf, Athletics, Football, etc. """ global PROJECT_DIR, APP_DIR, SETTINGS_DIR, SETTINGS_FILE, VMIX_JSON_FILE, VMIX_FUNCTIONS_FILE, BACKUPS_DIR global DEFAULT_SETTINGS, DEFAULT_VMIX_JSON, DEFAULT_VMIX_FUNCTIONS if project_dir is not None: PROJECT_DIR = Path(project_dir).resolve() if app_dir is not None: APP_DIR = Path(app_dir).resolve() else: APP_DIR = PROJECT_DIR / "app" if settings_dir is not None: SETTINGS_DIR = Path(settings_dir).resolve() else: SETTINGS_DIR = PROJECT_DIR / "settings" SETTINGS_FILE = SETTINGS_DIR / settings_file VMIX_JSON_FILE = SETTINGS_DIR / vmix_json_file VMIX_FUNCTIONS_FILE = SETTINGS_DIR / vmix_functions_file BACKUPS_DIR = SETTINGS_DIR / "backups" if default_settings is not None: DEFAULT_SETTINGS = deepcopy(default_settings) if default_vmix_json is not None: DEFAULT_VMIX_JSON = deepcopy(default_vmix_json) if default_vmix_functions is not None: DEFAULT_VMIX_FUNCTIONS = deepcopy(default_vmix_functions) DEFAULT_SELECTED_COLUMNS = [ "position", "player", "player_id", "country", "club", "total", "to_par", "today", "thru", "hole_1", "hole_2", "hole_3", "hole_4", "hole_5", "hole_6", "hole_7", "hole_8", "hole_9", "hole_10", "hole_11", "hole_12", "hole_13", "hole_14", "hole_15", "hole_16", "hole_17", "hole_18", ] DEFAULT_SETTINGS: dict[str, Any] = { "version": 1, "ui": { "calendar_collapsed": True, "default_date": "today", "theme": "broadcast_dark", }, "selection": {}, "scores": { "limit": "", "search": "", "selected_columns": DEFAULT_SELECTED_COLUMNS, "active_vmix_json": "leaderboard", }, "api": { "base_url": "https://rusgolf.ru/api/livescoring", "cache_tournaments_seconds": 30, "cache_scores_seconds": 2, }, } DEFAULT_VMIX_JSON: dict[str, Any] = { "version": 1, "configs": [ { "key": "leaderboard", "title": "Leaderboard", "description": "Основная таблица лидеров для vMix.", "endpoint": "/vmix/golf/leaderboard.json", "root_key": "players", "default_limit": 0, "enabled": True, "show_tab": True, "tab_title": "Leaderboard", "sort_key": "position", "sort_dir": "asc", "columns": [ {"key": "position", "label": "Position", "source": "position", "default": "", "enabled": True}, {"key": "player", "label": "Player", "source": "player", "default": "", "enabled": True}, {"key": "country", "label": "Country", "source": "country", "default": "", "enabled": True}, {"key": "club", "label": "Club", "source": "club", "default": "", "enabled": True}, {"key": "total", "label": "Total", "source": "total", "default": "", "enabled": True}, {"key": "to_par", "label": "To Par", "source": "to_par", "default": "", "enabled": True}, {"key": "today", "label": "Today", "source": "today", "default": "", "enabled": True}, {"key": "thru", "label": "Thru", "source": "thru", "default": "", "enabled": True}, ], }, { "key": "scorecard", "title": "Scorecard 1–18", "description": "Карточка игрока/строка с лунками 1–18.", "endpoint": "/vmix/golf/json/scorecard.json", "root_key": "players", "default_limit": 0, "enabled": True, "show_tab": True, "tab_title": "Scorecard", "sort_key": "position", "sort_dir": "asc", "columns": [ {"key": "position", "label": "Position", "source": "position", "default": "", "enabled": True}, {"key": "player", "label": "Player", "source": "player", "default": "", "enabled": True}, {"key": "total", "label": "Total", "source": "total", "default": "", "enabled": True}, {"key": "to_par", "label": "To Par", "source": "to_par", "default": "", "enabled": True}, *[ {"key": f"hole_{i}", "label": f"H{i}", "source": f"hole_{i}", "default": "", "enabled": True} for i in range(1, 19) ], ], }, { "key": "current", "title": "Current Selection", "description": "Фиксированный JSON по сохранённому соревнованию и раунду.", "endpoint": "/vmix/golf/current.json", "root_key": "players", "default_limit": 0, "enabled": True, "show_tab": True, "tab_title": "Current", "sort_key": "position", "sort_dir": "asc", "columns": [ {"key": "position", "label": "Position", "source": "position", "default": "", "enabled": True}, {"key": "player", "label": "Player", "source": "player", "default": "", "enabled": True}, {"key": "total", "label": "Total", "source": "total", "default": "", "enabled": True}, {"key": "to_par", "label": "To Par", "source": "to_par", "default": "", "enabled": True}, {"key": "today", "label": "Today", "source": "today", "default": "", "enabled": True}, {"key": "thru", "label": "Thru", "source": "thru", "default": "", "enabled": True}, ], }, { "key": "full", "title": "Full normalized", "description": "Все нормализованные поля без ручного ограничения колонок.", "endpoint": "/vmix/golf/json/full.json", "root_key": "players", "default_limit": 0, "enabled": True, "show_tab": True, "tab_title": "Full", "sort_key": "position", "sort_dir": "asc", "columns": [], }, ], } DEFAULT_VMIX_FUNCTIONS: dict[str, Any] = { "version": 1, "title": "Функции формул vMix JSON", "description": "Справочник функций, которые доступны в колонках типа Формула.", "categories": [ { "title": "Поля и сборка строк", "items": [ {"name": "get", "example": "get('player')", "description": "Взять поле по имени. Если поля нет — пустая строка."}, {"name": "val", "example": "val('total', '-')", "description": "То же самое, что get()."}, {"name": "concat", "example": "concat(position, '. ', player)", "description": "Склеить значения без разделителя."}, {"name": "join", "example": "join(' / ', player, club, total)", "description": "Склеить только заполненные значения через разделитель."}, {"name": "tpl", "example": "tpl('#{position} {player} {total}')", "description": "Шаблон с подстановкой полей в фигурных скобках."}, {"name": "coalesce", "example": "coalesce(player, name, '-')", "description": "Первое непустое значение."} ] }, { "title": "Текст", "items": [ {"name": "split", "example": "split(player, ' ', 0)", "description": "Разбить строку и взять часть по индексу."}, {"name": "part", "example": "part(player, ' ', 1)", "description": "Короткий вариант split(value, sep, index)."}, {"name": "left", "example": "left(player, 3)", "description": "Первые N символов."}, {"name": "right", "example": "right(player, 3)", "description": "Последние N символов."}, {"name": "mid", "example": "mid(player, 2, 4)", "description": "Фрагмент строки."}, {"name": "replace", "example": "replace(player, ' ', ' ')", "description": "Обычная замена текста."}, {"name": "upper/lower/title/strip", "example": "upper(country)", "description": "Регистр и очистка пробелов."}, {"name": "swap", "example": "swap(player)", "description": "Поменять порядок частей строки: Иван Петров → Петров Иван."} ] }, { "title": "Условия", "items": [ {"name": "case / iif", "example": "case(to_par == 0, 'E', to_par)", "description": "Если условие истинно — первое значение, иначе второе."}, {"name": "empty / filled", "example": "if_empty(total, '-', total)", "description": "Проверка пустого/заполненного значения."}, {"name": "if_empty", "example": "if_empty(thru, '—', thru)", "description": "Если пусто."}, {"name": "if_filled", "example": "if_filled(country, country, '')", "description": "Если заполнено."}, {"name": "if_contains", "example": "if_contains(status, 'active', 'LIVE', '')", "description": "Если строка содержит текст."}, {"name": "if_not_contains", "example": "if_not_contains(status, 'active', '', 'LIVE')", "description": "Если строка не содержит текст."}, {"name": "if_equals / if_eq", "example": "if_eq(to_par, '0', 'E', to_par)", "description": "Если значение равно ожидаемому."}, {"name": "if_field_empty", "example": "if_field_empty('today', '-', today)", "description": "Проверить поле по имени."} ] }, { "title": "Поиск и Regex", "items": [ {"name": "contains", "example": "contains(player, 'Иван')", "description": "True/False: содержит текст."}, {"name": "starts / ends", "example": "starts(player, 'A')", "description": "Начинается/заканчивается на текст."}, {"name": "regex / match", "example": "regex(player, '^A')", "description": "Проверка регулярным выражением."}, {"name": "if_regex", "example": "if_regex(player, '^A', 'A', '')", "description": "Условие по регулярному выражению."}, {"name": "re_replace", "example": "re_replace(title, '(\\d)(?=(\\d{3})+\\b)', '\\1.')", "description": "Regex-замена."}, {"name": "re_replace_first", "example": "re_replace_first(text, '\\s+', ' ')", "description": "Заменить только первое совпадение."}, {"name": "re_remove", "example": "re_remove(player, '\\s+')", "description": "Удалить по regex."}, {"name": "re_extract", "example": "re_extract(player, '#(\\d+)', 1)", "description": "Достать группу из текста."}, {"name": "first_match", "example": "first_match(title, {'СБ':'Барьеры', 'Бег':'Бег'}, '')", "description": "Первое совпавшее правило."} ] }, { "title": "Числа, время, даты", "items": [ {"name": "number / num", "example": "number(total, 0)", "description": "Преобразовать в число."}, {"name": "int/float/round/min/max/abs/sum", "example": "round(number(total), 1)", "description": "Базовая математика Python."}, {"name": "format_thousands", "example": "format_thousands(10000)", "description": "10.000."}, {"name": "format_distance", "example": "format_distance('10000')", "description": "Формат дистанций: 10.000."}, {"name": "time_seconds", "example": "time_seconds('1:02.34')", "description": "Время в секунды."}, {"name": "format_time", "example": "format_time(62.34, 'auto_tt')", "description": "Секунды обратно во время."}, {"name": "time_diff", "example": "time_diff(result, leader, 'auto_tt')", "description": "Разница двух времен."}, {"name": "today", "example": "today('%d.%m.%Y')", "description": "Сегодняшняя дата."}, {"name": "age / age_at", "example": "age_at(birthDate, today())", "description": "Возраст по дате рождения."}, {"name": "birth_year", "example": "birth_year(birthDate)", "description": "Год рождения."} ] }, { "title": "Гольф", "items": [ {"name": "golf_score", "example": "golf_score(to_par)", "description": "0 → E, положительные с плюсом, отрицательные без изменений."}, {"name": "plus_minus", "example": "plus_minus(to_par)", "description": "Добавить + к положительному числу."}, {"name": "zero_as", "example": "zero_as(to_par, 'E')", "description": "Заменить 0 на нужный текст."} ] }, { "title": "Склейка JSON", "items": [ {"name": "match", "example": "player_id=id", "description": "Сопоставление ключей: слева поле основного JSON, справа поле подключаемого источника."}, {"name": "fields", "example": "club=club\ncountry=country", "description": "Какие поля подтянуть: слева новое имя поля, справа поле источника."}, {"name": "multiple", "example": "first / last / all / count", "description": "Что делать, если найдено несколько совпадений."}, {"name": "source_type", "example": "scores / tournament / round / vmix_json / url", "description": "Тип подключаемого источника данных."} ] } ] } def _deep_merge(default: Any, loaded: Any) -> Any: if isinstance(default, dict) and isinstance(loaded, dict): result = deepcopy(default) for key, value in loaded.items(): result[key] = _deep_merge(result.get(key), value) if key in result else value return result return loaded if loaded is not None else deepcopy(default) def ensure_settings_files() -> None: SETTINGS_DIR.mkdir(parents=True, exist_ok=True) if not SETTINGS_FILE.exists(): save_json(SETTINGS_FILE, DEFAULT_SETTINGS) if not VMIX_JSON_FILE.exists(): save_json(VMIX_JSON_FILE, DEFAULT_VMIX_JSON) if not VMIX_FUNCTIONS_FILE.exists(): save_json(VMIX_FUNCTIONS_FILE, DEFAULT_VMIX_FUNCTIONS) def load_json(path: Path, default: dict[str, Any]) -> dict[str, Any]: ensure_settings_files() if path in (SETTINGS_FILE, VMIX_JSON_FILE, VMIX_FUNCTIONS_FILE) and not path.exists() else None try: with path.open("r", encoding="utf-8") as f: data = json.load(f) if not isinstance(data, dict): return deepcopy(default) return _deep_merge(default, data) except Exception: return deepcopy(default) def save_json(path: Path, data: dict[str, Any]) -> dict[str, Any]: path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2) return data def read_settings() -> dict[str, Any]: ensure_settings_files() return load_json(SETTINGS_FILE, DEFAULT_SETTINGS) def write_settings(data: dict[str, Any]) -> dict[str, Any]: merged = _deep_merge(DEFAULT_SETTINGS, data) return save_json(SETTINGS_FILE, merged) def _enabled_column_keys(config: dict[str, Any]) -> list[str]: columns = config.get("columns") or [] if not isinstance(columns, list): return [] keys: list[str] = [] for column in columns: if not isinstance(column, dict): continue key = str(column.get("key") or "").strip() if key and column.get("enabled", True) is not False: keys.append(key) return keys def _normalize_web_display(config: dict[str, Any]) -> dict[str, Any]: """Normalize portable web-display settings for any project. Older projects stored tab settings directly on the vMix JSON config (`show_tab`, `tab_title`, `sort_key`, `sort_dir`). New projects keep these in `web_display` so vMix output and web table output can differ. """ web = config.get("web_display") if not isinstance(web, dict): web = {} web.setdefault("enabled", config.get("show_tab", True)) web.setdefault("tab_title", config.get("tab_title") or config.get("title") or config.get("key") or "Вкладка") web.setdefault("description", config.get("data_source_description") or config.get("description") or "") web.setdefault("columns", _enabled_column_keys(config)) web.setdefault("sort_key", config.get("sort_key") or config.get("tab_sort_key") or "") web.setdefault("sort_dir", config.get("sort_dir") or config.get("tab_sort_dir") or "asc") if not isinstance(web.get("columns"), list): if isinstance(web.get("columns"), str): web["columns"] = [x.strip() for x in web["columns"].replace(";", ",").split(",") if x.strip()] else: web["columns"] = [] web["enabled"] = bool(web.get("enabled", True)) web["tab_title"] = str(web.get("tab_title") or config.get("title") or config.get("key") or "Вкладка") web["sort_dir"] = "desc" if str(web.get("sort_dir") or "asc").lower() == "desc" else "asc" config["web_display"] = web return web def _normalize_vmix_json_schema(data: dict[str, Any]) -> dict[str, Any]: data = deepcopy(data) data.setdefault("version", 3) configs = data.setdefault("configs", []) if isinstance(configs, list): for config in configs: if isinstance(config, dict): config.setdefault("joins", []) config.setdefault("output_mode", "columns") config.setdefault("default_limit", 0) _normalize_web_display(config) return data def read_vmix_json() -> dict[str, Any]: ensure_settings_files() return _normalize_vmix_json_schema(load_json(VMIX_JSON_FILE, DEFAULT_VMIX_JSON)) def write_vmix_json(data: dict[str, Any]) -> dict[str, Any]: if "configs" not in data or not isinstance(data["configs"], list): raise ValueError("vmix_json должен содержать список configs") return save_json(VMIX_JSON_FILE, _normalize_vmix_json_schema(data)) def read_vmix_functions() -> dict[str, Any]: ensure_settings_files() return load_json(VMIX_FUNCTIONS_FILE, DEFAULT_VMIX_FUNCTIONS) def write_vmix_functions(data: dict[str, Any]) -> dict[str, Any]: data.setdefault("version", 1) data.setdefault("categories", []) return save_json(VMIX_FUNCTIONS_FILE, data) def read_selection() -> dict[str, Any]: return read_settings().get("selection", {}) or {} def write_selection(selection: dict[str, Any]) -> dict[str, Any]: settings = read_settings() settings["selection"] = selection write_settings(settings) return selection def get_vmix_config(key: str | None = None) -> dict[str, Any]: data = read_vmix_json() configs = data.get("configs", []) if not configs: raise KeyError("Нет vMix JSON конфигов") wanted = key or read_settings().get("scores", {}).get("active_vmix_json") or "leaderboard" for config in configs: if str(config.get("key")) == str(wanted): return config for config in configs: if str(config.get("key")) == "leaderboard": return config return configs[0] def settings_meta() -> dict[str, str]: ensure_settings_files() return { "settings_dir": str(SETTINGS_DIR), "settings_file": str(SETTINGS_FILE), "vmix_json_file": str(VMIX_JSON_FILE), "vmix_functions_file": str(VMIX_FUNCTIONS_FILE), } # --------------------------------------------------------------------------- # Universal settings import # --------------------------------------------------------------------------- COMMON_SETTINGS_KEYS = {"version", "ui", "selection", "scores", "api", "modules", "features", "paths"} CONFIG_LIST_KEYS = ( "configs", "json_configs", "jsonConfigs", "vmix_json", "vmixJson", "vmix", "jsons", "outputs", "endpoints", "presets", "vmix_presets", "vmixPresets", ) COLUMN_LIST_KEYS = ( "columns", "fields", "selected_columns", "selectedColumns", "items", "rows", "schema", "mapping", "mappings", ) def make_settings_backup(reason: str = "manual") -> dict[str, str]: """Copy current settings files before a destructive import/save operation.""" ensure_settings_files() BACKUPS_DIR.mkdir(parents=True, exist_ok=True) stamp = datetime.now().strftime("%Y%m%d_%H%M%S") safe_reason = _slugify(reason) or "backup" out: dict[str, str] = {} for src in (SETTINGS_FILE, VMIX_JSON_FILE): if not src.exists(): continue dst = BACKUPS_DIR / f"{src.stem}_{stamp}_{safe_reason}{src.suffix}" dst.write_text(src.read_text(encoding="utf-8"), encoding="utf-8") out[src.name] = str(dst) return out def _slugify(value: Any, fallback: str = "imported_json") -> str: text = str(value or "").strip().lower() text = text.replace("/", "_").replace("\\", "_") text = re.sub(r"[^a-zа-яё0-9_\-]+", "_", text, flags=re.IGNORECASE) text = re.sub(r"_+", "_", text).strip("_") return text or fallback def _path_join(path: str, key: Any) -> str: if path: return f"{path}.{key}" return str(key) def _walk_settings(obj: Any, path: str = "root", depth: int = 0, max_depth: int = 8): yield path, obj if depth >= max_depth: return if isinstance(obj, dict): for key, value in obj.items(): yield from _walk_settings(value, _path_join(path, key), depth + 1, max_depth) elif isinstance(obj, list): for index, value in enumerate(obj[:200]): yield from _walk_settings(value, f"{path}[{index}]", depth + 1, max_depth) def _first_value(d: dict[str, Any], *keys: str, default: Any = "") -> Any: for key in keys: if key in d and d[key] not in (None, ""): return d[key] return default def _to_bool(value: Any, default: bool = True) -> bool: if value is None: return default if isinstance(value, bool): return value if isinstance(value, (int, float)): return bool(value) text = str(value).strip().lower() if text in {"0", "false", "no", "off", "disabled", "hide", "hidden"}: return False if text in {"1", "true", "yes", "on", "enabled", "show", "visible"}: return True return default def normalize_import_column(column: Any, fallback_index: int = 1) -> dict[str, Any] | None: """Normalize a column from many possible project formats to the portable format.""" if isinstance(column, str): key = _slugify(column, fallback=f"field_{fallback_index}") return {"key": key, "label": column, "source": column, "default": "", "enabled": True} if isinstance(column, (int, float)): text = str(column) return {"key": f"field_{fallback_index}", "label": text, "source": text, "default": "", "enabled": True} if not isinstance(column, dict): return None raw_key = _first_value( column, "key", "json_key", "jsonKey", "field", "name", "id", "column", "column_name", "columnName", "target", "output", "title", "label", default=f"field_{fallback_index}", ) key = _slugify(raw_key, fallback=f"field_{fallback_index}") label = str(_first_value(column, "label", "title", "caption", "name", "header", "display", default=raw_key)) source = _first_value( column, "source", "src", "path", "from", "field", "value", "template", "expression", "formula", "data_key", "dataKey", "raw", "key", default=raw_key, ) default = _first_value(column, "default", "default_value", "defaultValue", "fallback", "empty", default="") enabled = _to_bool(_first_value(column, "enabled", "visible", "show", "is_visible", "isVisible", default=True), True) return { "key": key, "label": label, "source": str(source), "default": default, "enabled": enabled, } def _normalize_columns(value: Any) -> list[dict[str, Any]]: if isinstance(value, dict): # Format like {"player": "athlete.name", "total": {"source": "score"}} items: list[Any] = [] for key, val in value.items(): if isinstance(val, dict): merged = {"key": key, **val} else: merged = {"key": key, "label": key, "source": val} items.append(merged) elif isinstance(value, list): items = value else: return [] result: list[dict[str, Any]] = [] seen: set[str] = set() for index, item in enumerate(items, start=1): col = normalize_import_column(item, index) if not col: continue key = str(col["key"]) if key in seen: base = key n = 2 while f"{base}_{n}" in seen: n += 1 col["key"] = f"{base}_{n}" seen.add(str(col["key"])) result.append(col) return result def _normalize_etiming_output_columns(value: Any) -> list[dict[str, Any]]: """Preserve exact legacy eTiming column names for output.""" items: list[Any] if isinstance(value, dict): items = [] for key, val in value.items(): if isinstance(val, dict): items.append({"key": key, "label": key, **val}) else: items.append({"key": key, "label": key, "source": val}) elif isinstance(value, list): items = value else: return [] result: list[dict[str, Any]] = [] for index, item in enumerate(items, start=1): if isinstance(item, str): name = item.strip() if name: result.append({"key": name, "label": name, "source": name, "default": "", "enabled": True, "mode": "field"}) elif isinstance(item, dict): raw_key = _first_value(item, "key", "name", "field", "column", "label", default=f"field_{index}") key = str(raw_key).strip() or f"field_{index}" result.append({ "key": key, "label": str(_first_value(item, "label", "title", "caption", "name", default=key)), "source": str(_first_value(item, "source", "src", "path", "field", "value", "key", default=key)), "default": _first_value(item, "default", "default_value", "fallback", default=""), "enabled": _to_bool(_first_value(item, "enabled", "visible", "show", default=True), True), "mode": str(_first_value(item, "mode", "type", default="field")), }) return result def _normalize_etiming_columns(value: Any, transforms: list[dict[str, Any]] | None = None) -> tuple[list[dict[str, Any]], bool]: """Convert eTiming vmixColumns + vmixTransforms to portable column rules. eTiming stores output columns in settings.json like: vmixColumns: {"results": "all", "info": ["title", ...]} vmixTransforms: {"results": [{"name": "Mask", "expr": "if_empty(...)"}]} vmixRows: {"results": 10} The portable golf project stores one config with a list of editable columns. Formula rules are represented as columns with mode="expr" and expr="...". """ columns: list[dict[str, Any]] = [] all_fields = False if isinstance(value, str) and value.strip().lower() == "all": all_fields = True else: columns = _normalize_etiming_output_columns(value) for index, rule in enumerate(transforms or [], start=1): if not isinstance(rule, dict): continue name = str(_first_value(rule, "name", "key", "field", "column", default=f"computed_{index}")).strip() expr = str(_first_value(rule, "expr", "expression", "formula", "source", default="")).strip() if not name or not expr: continue col = { "key": _slugify(name, fallback=f"computed_{index}"), "label": name, "source": expr, "expr": expr, "mode": "expr", "default": "", "enabled": _to_bool(_first_value(rule, "enabled", "visible", "show", default=True), True), } # Preserve the exact output name from eTiming, even if it contains uppercase/underscore. col["key"] = name columns.append(col) # Deduplicate keys while preserving imported names. seen: set[str] = set() result: list[dict[str, Any]] = [] for col in columns: key = str(col.get("key") or "").strip() if not key: continue if key in seen: base = key n = 2 while f"{base}_{n}" in seen: n += 1 col = deepcopy(col) col["key"] = f"{base}_{n}" seen.add(str(col.get("key"))) result.append(col) return result, all_fields def _scan_etiming_vmix_settings(data: Any) -> list[dict[str, Any]]: """Find legacy eTiming vmixColumns/vmixTransforms/vmixRows blocks.""" if not isinstance(data, dict): return [] source = data.get("settings") if isinstance(data.get("settings"), dict) else data if not isinstance(source, dict): return [] vmix_columns = source.get("vmixColumns") or source.get("vmix_columns") vmix_transforms = source.get("vmixTransforms") or source.get("vmixComputedColumns") or source.get("vmix_transforms") or {} vmix_rows = source.get("vmixRows") or source.get("vmix_rows") or {} if not isinstance(vmix_columns, dict): return [] candidates: list[dict[str, Any]] = [] title_map = { "info": "Info", "schedule-list-5": "Schedule List 5", "schedule-list": "Schedule List", "results": "Results", "current-athlete": "Current Athlete", "timetronics": "TimeTronics", "timetronics-splits": "TimeTronics Splits", "combined-table": "Combined Table", "combined-results": "Combined Results", } for endpoint_key, columns_value in vmix_columns.items(): key = str(endpoint_key).strip() if not key: continue raw_transforms = vmix_transforms.get(key, []) if isinstance(vmix_transforms, dict) else [] if isinstance(raw_transforms, dict): raw_transforms = list(raw_transforms.values()) if not isinstance(raw_transforms, list): raw_transforms = [] columns, all_fields = _normalize_etiming_columns(columns_value, raw_transforms) try: limit = int(vmix_rows.get(key, 0) if isinstance(vmix_rows, dict) else 0) except Exception: limit = 0 notes = ["legacy eTiming: vmixColumns/vmixTransforms/vmixRows"] if all_fields: notes.append("вывод колонок: all") if raw_transforms: notes.append(f"формул: {len(raw_transforms)}") candidates.append({ "key": _slugify(key, fallback="imported_json"), "title": title_map.get(key, key), "description": f"Импортировано из eTiming settings.json: {key}", "endpoint": f"/vmix/golf/json/{_slugify(key, fallback='imported_json')}.json", "root_key": "items" if key in {"info", "timetronics"} else "players", "default_limit": max(0, limit), "enabled": True, "output_mode": "all" if all_fields else "columns", "columns": columns, "import_meta": { "source_path": f"root.vmixColumns.{key}", "confidence": 98, "notes": notes, "legacy_format": "etiming", }, }) # Also include transform-only endpoints like current-athlete in the uploaded eTiming file. if isinstance(vmix_transforms, dict): for endpoint_key, raw_transforms in vmix_transforms.items(): key = str(endpoint_key).strip() if not key or key in vmix_columns: continue if not isinstance(raw_transforms, list): continue columns, _ = _normalize_etiming_columns([], raw_transforms) if not columns: continue candidates.append({ "key": _slugify(key, fallback="imported_json"), "title": title_map.get(key, key), "description": f"Импортировано из eTiming vmixTransforms: {key}", "endpoint": f"/vmix/golf/json/{_slugify(key, fallback='imported_json')}.json", "root_key": "players", "default_limit": int(vmix_rows.get(key, 0) if isinstance(vmix_rows, dict) else 0), "enabled": True, "output_mode": "columns", "columns": columns, "import_meta": { "source_path": f"root.vmixTransforms.{key}", "confidence": 92, "notes": ["legacy eTiming: endpoint найден только в vmixTransforms", f"формул: {len(raw_transforms)}"], "legacy_format": "etiming", }, }) return candidates def _extract_columns_from_dict(obj: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]: for key in COLUMN_LIST_KEYS: if key in obj: cols = _normalize_columns(obj.get(key)) if cols: return key, cols return "", [] def _looks_like_config(obj: Any) -> bool: if not isinstance(obj, dict): return False _, cols = _extract_columns_from_dict(obj) if cols: return True return any(k in obj for k in ("endpoint", "root_key", "rootKey", "default_limit", "defaultLimit")) and any( k in obj for k in ("key", "name", "title", "id") ) def normalize_import_vmix_config(obj: Any, fallback_key: str, source_path: str) -> dict[str, Any] | None: if not isinstance(obj, dict): return None column_source_key, columns = _extract_columns_from_dict(obj) if not columns and not _looks_like_config(obj): return None raw_key = _first_value( obj, "key", "json_key", "jsonKey", "name", "id", "slug", "title", "endpoint", default=fallback_key, ) key = _slugify(raw_key, fallback=_slugify(fallback_key)) title = str(_first_value(obj, "title", "label", "name", "caption", default=key)) root_key = str(_first_value(obj, "root_key", "rootKey", "root", "items_key", "itemsKey", default="players")) endpoint = str(_first_value(obj, "endpoint", "url", "path", default=f"/vmix/golf/json/{key}.json")) if not endpoint.startswith("/") and not endpoint.startswith("http"): endpoint = f"/vmix/golf/json/{key}.json" default_limit_raw = _first_value(obj, "default_limit", "defaultLimit", "limit", "rows_limit", "rowsLimit", default=0) try: default_limit = int(default_limit_raw or 0) except Exception: default_limit = 0 confidence = 70 notes = [] if columns: confidence += 20 notes.append(f"найдено колонок: {len(columns)}") if column_source_key: notes.append(f"источник колонок: {column_source_key}") if "endpoint" in obj or "url" in obj or "path" in obj: confidence += 5 if "root_key" in obj or "rootKey" in obj: confidence += 5 return { "key": key, "title": title, "description": str(_first_value(obj, "description", "desc", "comment", default=f"Импортировано из {source_path}")), "endpoint": endpoint, "root_key": root_key, "default_limit": default_limit, "enabled": _to_bool(_first_value(obj, "enabled", "visible", "show", default=True), True), "columns": columns, "import_meta": { "source_path": source_path, "confidence": min(confidence, 100), "notes": notes, }, } def _add_unique_candidate(candidates: list[dict[str, Any]], candidate: dict[str, Any]) -> None: signature = (str(candidate.get("key")), str(candidate.get("import_meta", {}).get("source_path")), len(candidate.get("columns") or [])) for existing in candidates: existing_signature = ( str(existing.get("key")), str(existing.get("import_meta", {}).get("source_path")), len(existing.get("columns") or []), ) if existing_signature == signature: return candidates.append(candidate) def _scan_vmix_candidates(data: Any) -> list[dict[str, Any]]: candidates: list[dict[str, Any]] = [] # First handle the legacy eTiming settings format explicitly. for candidate in _scan_etiming_vmix_settings(data): _add_unique_candidate(candidates, candidate) for path, obj in _walk_settings(data): if isinstance(obj, dict): # Direct single config: {key, title, columns: [...]}. candidate = normalize_import_vmix_config(obj, fallback_key=path.split(".")[-1], source_path=path) if candidate: _add_unique_candidate(candidates, candidate) # Common container: {configs: [{...}, {...}]}. for list_key in CONFIG_LIST_KEYS: value = obj.get(list_key) if isinstance(value, list): for index, item in enumerate(value, start=1): candidate = normalize_import_vmix_config(item, fallback_key=f"{list_key}_{index}", source_path=f"{path}.{list_key}[{index - 1}]") if candidate: _add_unique_candidate(candidates, candidate) elif isinstance(value, dict): for child_key, child in value.items(): if isinstance(child, dict): candidate = normalize_import_vmix_config(child, fallback_key=str(child_key), source_path=f"{path}.{list_key}.{child_key}") if candidate: _add_unique_candidate(candidates, candidate) # Mapping format: {leaderboard: {columns: [...]}, current: {columns: [...]}} config_like_children = [ (key, val) for key, val in obj.items() if isinstance(val, dict) and _looks_like_config(val) ] if len(config_like_children) >= 1: for child_key, child in config_like_children: candidate = normalize_import_vmix_config(child, fallback_key=str(child_key), source_path=f"{path}.{child_key}") if candidate: _add_unique_candidate(candidates, candidate) elif isinstance(obj, list) and obj and all(isinstance(x, dict) for x in obj): # A bare list of configs. config_count = sum(1 for x in obj if _looks_like_config(x)) if config_count >= max(1, len(obj) // 2): for index, item in enumerate(obj, start=1): candidate = normalize_import_vmix_config(item, fallback_key=f"json_{index}", source_path=f"{path}[{index - 1}]") if candidate: _add_unique_candidate(candidates, candidate) # If no configs were found but there is a selected_columns-like setting, create a basic config. for path, obj in _walk_settings(data): if isinstance(obj, dict): for key in ("selected_columns", "selectedColumns"): if key in obj: cols = _normalize_columns(obj[key]) if cols: _add_unique_candidate(candidates, { "key": "imported_selected_columns", "title": "Imported selected columns", "description": f"Собрано из {path}.{key}", "endpoint": "/vmix/golf/json/imported_selected_columns.json", "root_key": "players", "default_limit": int(obj.get("limit") or obj.get("default_limit") or 0), "enabled": True, "columns": cols, "import_meta": {"source_path": f"{path}.{key}", "confidence": 65, "notes": ["fallback из selected_columns"]}, }) # Sort stronger candidates first. candidates.sort(key=lambda item: (item.get("import_meta", {}).get("confidence", 0), len(item.get("columns") or [])), reverse=True) return candidates def _extract_general_settings(data: Any) -> dict[str, Any]: if not isinstance(data, dict): return {} source = data.get("settings") if isinstance(data.get("settings"), dict) else data if not isinstance(source, dict): return {} general = {key: deepcopy(value) for key, value in source.items() if key in COMMON_SETTINGS_KEYS} # Do not import selection by default: tournament/round IDs from another project are usually invalid here. general.pop("selection", None) return general def preview_settings_import(data: Any, file_name: str = "settings.json") -> dict[str, Any]: if not isinstance(data, (dict, list)): raise ValueError("Файл настроек должен быть JSON-объектом или JSON-массивом") candidates = _scan_vmix_candidates(data) general = _extract_general_settings(data) warnings: list[str] = [] if not candidates: warnings.append("Не найдено vMix JSON конфигураций. Можно импортировать только общие настройки, если они есть.") if not general: warnings.append("Общие настройки проекта не найдены или не похожи на переносимый формат.") return { "file_name": file_name, "source_format": "legacy-etiming/vmixColumns" if any((c.get("import_meta") or {}).get("legacy_format") == "etiming" for c in candidates) else ("portable/vmix-json" if candidates else "generic-json"), "general_settings": general, "vmix_configs": candidates, "summary": { "general_keys": list(general.keys()), "vmix_configs_found": len(candidates), "columns_found": sum(len(c.get("columns") or []) for c in candidates), }, "warnings": warnings, } def _unique_key(base_key: str, existing: set[str]) -> str: key = _slugify(base_key) if key not in existing: existing.add(key) return key n = 2 while f"{key}_{n}" in existing: n += 1 final = f"{key}_{n}" existing.add(final) return final def _clean_imported_config(config: dict[str, Any]) -> dict[str, Any]: # Previews produced by this module are already normalized. Preserve portable # extras such as output_mode and expression column metadata. if isinstance(config, dict) and "columns" in config and "key" in config: clean = deepcopy(config) clean.pop("import_meta", None) clean.setdefault("endpoint", f"/vmix/golf/json/{_slugify(clean.get('key'))}.json") clean.setdefault("root_key", "players") clean.setdefault("default_limit", 0) clean.setdefault("enabled", True) clean["columns"] = [c for c in (clean.get("columns") or []) if isinstance(c, dict)] return clean clean = normalize_import_vmix_config(config, fallback_key=str(config.get("key") or "imported_json"), source_path=str(config.get("import_meta", {}).get("source_path", "import.apply"))) if not clean: raise ValueError("Некорректная конфигурация vMix JSON") clean.pop("import_meta", None) return clean def apply_settings_import( preview: dict[str, Any], selected_indexes: list[int] | None = None, import_general: bool = False, import_vmix: bool = True, mode: str = "add_new", ) -> dict[str, Any]: """ Apply a normalized preview. mode: - add_new: add imported configs and rename duplicates - update_existing: replace configs with same key, add missing - replace_all: replace current vmix_json configs with selected imported configs """ if not isinstance(preview, dict): raise ValueError("preview должен быть объектом") backups = make_settings_backup("before_import") imported_configs = preview.get("vmix_configs") or [] if selected_indexes is None: selected_indexes = list(range(len(imported_configs))) selected: list[dict[str, Any]] = [] for index in selected_indexes: try: item = imported_configs[int(index)] except Exception: continue if isinstance(item, dict): selected.append(_clean_imported_config(item)) current_settings = read_settings() current_vmix = read_vmix_json() general_changed = False if import_general: general = preview.get("general_settings") or {} if isinstance(general, dict) and general: # Preserve current selection because imported IDs usually belong to another project. saved_selection = current_settings.get("selection", {}) current_settings = _deep_merge(current_settings, general) current_settings["selection"] = saved_selection write_settings(current_settings) general_changed = True vmix_changed = False if import_vmix and selected: existing_configs = current_vmix.get("configs") or [] if mode == "replace_all": final_configs = selected elif mode == "update_existing": by_key = {str(c.get("key")): deepcopy(c) for c in existing_configs if isinstance(c, dict)} order = [str(c.get("key")) for c in existing_configs if isinstance(c, dict)] for item in selected: key = str(item.get("key")) if key not in by_key: order.append(key) by_key[key] = item final_configs = [by_key[key] for key in order if key in by_key] else: # add_new existing = {str(c.get("key")) for c in existing_configs if isinstance(c, dict)} final_configs = [deepcopy(c) for c in existing_configs if isinstance(c, dict)] for item in selected: item = deepcopy(item) item["key"] = _unique_key(str(item.get("key") or "imported_json"), existing) if item.get("endpoint", "").startswith("/vmix/golf/json/") or not item.get("endpoint"): item["endpoint"] = f"/vmix/golf/json/{item['key']}.json" final_configs.append(item) current_vmix["configs"] = final_configs write_vmix_json(current_vmix) vmix_changed = True return { "settings": read_settings(), "vmix_json": read_vmix_json(), "meta": settings_meta() | {"backups": backups}, "imported": { "general_changed": general_changed, "vmix_changed": vmix_changed, "configs_selected": len(selected), "mode": mode, }, }