1102 lines
48 KiB
Python
1102 lines
48 KiB
Python
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,
|
||
},
|
||
}
|