Files
hockey_new/broadcast_settings/settings.py
2026-08-19 15:08:39 +03:00

1102 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 118",
"description": "Карточка игрока/строка с лунками 118.",
"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,
},
}