diff --git a/.gitignore b/.gitignore
index a15466d..595af71 100644
--- a/.gitignore
+++ b/.gitignore
@@ -40,7 +40,24 @@ pip-wheel-metadata/
!.env.example
!.env.sample
-backups/
+# Runtime settings. Do NOT ignore settings/ as a whole anymore:
+# ui_builder_draft.json / ui_builder_published.json contain the operator UI,
+# shortcuts, triggers, bottom-panel buttons and selectors and should be portable.
+# Everything else under settings stays local unless explicitly allow-listed.
+settings/*
+!settings/README.md
+!settings/ui_builder_draft.json
+!settings/ui_builder_published.json
+!settings/hockey_api.json
+!settings/stat2tv_credentials.example.json
+
+# Local secrets / machine-specific runtime settings (never commit).
+settings/stat2tv_credentials.local.json
+settings/editor_security.json
+settings/backups/
+settings/settings.json
+settings/vmix_json.json
+settings/vmix_functions.json
# Stat2TV / API diagnostic snapshots
hockey_data/snapshots/
diff --git a/app.py b/app.py
index 963e97d..9976514 100644
--- a/app.py
+++ b/app.py
@@ -29,7 +29,7 @@ from ui_builder import install_ui_builder
from khl_site.khl_data_center import APP as khl_site_app
BASE_DIR = Path(__file__).resolve().parent
-BUILD_VERSION = "2026.08.19.26"
+BUILD_VERSION = "2026.08.19.28"
# compatibility: BUILD_VERSION = "2026.08.19.24"
# compatibility: BUILD_VERSION = "2026.08.19.22"
load_dotenv(BASE_DIR / ".env.local", override=False)
@@ -66,23 +66,33 @@ async def hockey_mapping_auto_refresh_shutdown() -> None:
@app.middleware("http")
async def hockey_runtime_auth(request: Request, call_next):
- """Protect runtime and prevent stale operator-interface assets."""
- if request.url.path == "/" or request.url.path.startswith("/khl-site"):
+ """Protect runtime/editor and prevent stale operator-interface assets."""
+ path = request.url.path
+ editor_path = (
+ path == "/editor"
+ or path.startswith("/api/ui-builder/editor/")
+ or path.startswith("/api/ui-builder/auth/")
+ )
+ khl_admin_path = path.startswith("/khl-site")
+ protected_path = path == "/" or khl_admin_path or editor_path
+
+ if protected_path:
try:
user = await hockey_auth.optional_user(request)
except HTTPException as error:
return JSONResponse({"detail": error.detail}, status_code=error.status_code)
if user is None:
- if request.url.path.startswith("/khl-site"):
- return JSONResponse({"detail": "Требуется вход"}, status_code=401)
- return RedirectResponse("/login?reason=expired", status_code=303)
- if request.url.path.startswith("/khl-site") and not user.is_admin:
- return JSONResponse({"detail": "Раздел КХЛ доступен только администратору"}, status_code=403)
+ if path in {"/", "/editor"}:
+ return RedirectResponse("/login?reason=expired", status_code=303)
+ return JSONResponse({"detail": "Требуется вход"}, status_code=401)
+ if (khl_admin_path or editor_path) and not user.is_admin:
+ if path == "/editor":
+ return RedirectResponse("/", status_code=303)
+ return JSONResponse({"detail": "Раздел доступен только администратору"}, status_code=403)
request.state.wfl_user = user
response = await call_next(request)
- path = request.url.path
if (
- path == "/"
+ path in {"/", "/editor"}
or path.startswith("/ui-builder-assets/")
or path.startswith("/hockey-assets/")
or path.startswith("/api/ui-builder/runtime/")
diff --git a/hockey_data/agent_bridge.py b/hockey_data/agent_bridge.py
index 3d8df95..e87ce66 100644
--- a/hockey_data/agent_bridge.py
+++ b/hockey_data/agent_bridge.py
@@ -1986,6 +1986,308 @@ class VmixAgentHub:
"inventory": inventory,
}
+
+ @staticmethod
+ def _portable_text(value: Any) -> str:
+ return " ".join(str(value or "").strip().casefold().split())
+
+ @classmethod
+ def _rebind_portable_mapping_fields(
+ cls,
+ fields: list[dict[str, Any]],
+ inventory: dict[str, Any],
+ ) -> tuple[list[dict[str, Any]], dict[str, Any]]:
+ """Rebind saved Mapping targets to another vMix project inventory.
+
+ Resolution order is deliberately conservative: stable Input key first,
+ then a unique title, then a unique positional number as a legacy fallback.
+ The selected vMix field itself must also exist on the resolved Input.
+ """
+ inputs = inventory.get("inputs") if isinstance(inventory, dict) and isinstance(inventory.get("inputs"), list) else []
+ by_key: dict[str, dict[str, Any]] = {}
+ by_title: dict[str, list[dict[str, Any]]] = {}
+ by_number: dict[str, list[dict[str, Any]]] = {}
+ for item in inputs:
+ if not isinstance(item, dict):
+ continue
+ key = str(item.get("key") or "").strip()
+ title = cls._portable_text(item.get("title"))
+ number = str(item.get("number") or "").strip()
+ if key:
+ by_key[key] = item
+ if title:
+ by_title.setdefault(title, []).append(item)
+ if number:
+ by_number.setdefault(number, []).append(item)
+
+ rebound: list[dict[str, Any]] = []
+ skipped: list[dict[str, Any]] = []
+ matched_by = {"key": 0, "title": 0, "number": 0}
+ seen: set[tuple[str, str, str]] = set()
+
+ for index, raw in enumerate((fields or [])[:2000]):
+ if not isinstance(raw, dict):
+ skipped.append({"index": index, "reason": "invalid_field"})
+ continue
+ data_key = str(raw.get("data_key") or "").strip()
+ vmix_field = str(raw.get("vmix_field") or "").strip()
+ if not data_key or not vmix_field:
+ skipped.append({"index": index, "data_key": data_key, "vmix_field": vmix_field, "reason": "missing_mapping_key"})
+ continue
+
+ source_key = str(raw.get("vmix_input_key") or "").strip()
+ source_title = str(raw.get("vmix_input_title") or "").strip()
+ source_number = str(raw.get("vmix_input_number") or "").strip()
+ target = by_key.get(source_key) if source_key else None
+ resolution = "key" if target is not None else ""
+ if target is None and source_title:
+ title_matches = by_title.get(cls._portable_text(source_title), [])
+ if len(title_matches) == 1:
+ target = title_matches[0]
+ resolution = "title"
+ elif len(title_matches) > 1:
+ skipped.append({"index": index, "data_key": data_key, "input": source_title, "vmix_field": vmix_field, "reason": "ambiguous_input_title"})
+ continue
+ if target is None and source_number:
+ number_matches = by_number.get(source_number, [])
+ if len(number_matches) == 1:
+ target = number_matches[0]
+ resolution = "number"
+ elif len(number_matches) > 1:
+ skipped.append({"index": index, "data_key": data_key, "input": source_number, "vmix_field": vmix_field, "reason": "ambiguous_input_number"})
+ continue
+ if target is None:
+ skipped.append({"index": index, "data_key": data_key, "input": source_title or source_key or source_number, "vmix_field": vmix_field, "reason": "input_not_found"})
+ continue
+
+ target_fields = target.get("fields") if isinstance(target.get("fields"), list) else []
+ exact = next((f for f in target_fields if isinstance(f, dict) and str(f.get("name") or "").strip() == vmix_field), None)
+ selected_name = vmix_field
+ if exact is None:
+ folded = cls._portable_text(vmix_field)
+ folded_matches = [f for f in target_fields if isinstance(f, dict) and cls._portable_text(f.get("name")) == folded]
+ if len(folded_matches) == 1:
+ selected_name = str(folded_matches[0].get("name") or "").strip()
+ else:
+ skipped.append({
+ "index": index, "data_key": data_key,
+ "input": str(target.get("title") or target.get("key") or target.get("number") or ""),
+ "vmix_field": vmix_field,
+ "reason": "field_not_found" if not folded_matches else "ambiguous_field",
+ })
+ continue
+
+ target_key = str(target.get("key") or "").strip()
+ target_title = str(target.get("title") or "").strip()
+ target_number = str(target.get("number") or "").strip()
+ dedupe = (data_key, target_key or target_title or target_number, selected_name)
+ if dedupe in seen:
+ skipped.append({"index": index, "data_key": data_key, "input": target_title, "vmix_field": selected_name, "reason": "duplicate_target"})
+ continue
+ seen.add(dedupe)
+ matched_by[resolution or "title"] = matched_by.get(resolution or "title", 0) + 1
+ rebound.append({
+ "graphic": str(raw.get("graphic") or "")[:100],
+ "data_key": data_key[:200],
+ "vmix_input_key": target_key[:128],
+ "vmix_input_number": target_number[:32],
+ "vmix_input_title": target_title[:300],
+ "vmix_field": selected_name[:300],
+ "field_type": str(raw.get("field_type") or "text")[:32],
+ "rule": dict(raw.get("rule") or {}) if isinstance(raw.get("rule"), dict) else {},
+ "enabled": bool(raw.get("enabled", True)),
+ "sort_order": len(rebound),
+ })
+
+ return rebound, {
+ "total": min(len(fields or []), 2000),
+ "mapped": len(rebound),
+ "skipped": len(skipped),
+ "matched_by": matched_by,
+ "skipped_items": skipped[:200],
+ }
+
+ @staticmethod
+ def _portable_profile_name(session: Any, desired: str, fallback: str) -> str:
+ base = str(desired or fallback or "Imported Mapping").strip()[:200] or "Imported Mapping"
+ if session.scalar(select(VmixMappingProfile).where(VmixMappingProfile.name == base)) is None:
+ return base
+ stem = base[:185]
+ for number in range(2, 1000):
+ candidate = f"{stem} ({number})"[:200]
+ if session.scalar(select(VmixMappingProfile).where(VmixMappingProfile.name == candidate)) is None:
+ return candidate
+ return f"{stem} {secrets.token_hex(3)}"[:200]
+
+ def _create_portable_profile_for_device(
+ self,
+ session: Any,
+ *,
+ device: VmixDevice,
+ fields: list[dict[str, Any]],
+ name: str,
+ description: str,
+ user: HockeyUser,
+ replace_existing: bool,
+ ) -> tuple[VmixMappingProfile, dict[str, Any]]:
+ try:
+ inventory = json.loads(device.project_inventory_json or "{}")
+ except Exception:
+ inventory = {}
+ rebound, report = self._rebind_portable_mapping_fields(fields, inventory)
+ if not rebound:
+ raise HTTPException(status_code=409, detail="Не удалось сопоставить ни одной связи Mapping с выбранным vMix")
+
+ conflict = session.scalar(
+ select(VmixMappingProfile)
+ .where(and_(VmixMappingProfile.project_fingerprint == device.project_fingerprint, VmixMappingProfile.active.is_(True)))
+ .order_by(desc(VmixMappingProfile.updated_at), desc(VmixMappingProfile.id))
+ )
+ if conflict is not None:
+ if not replace_existing:
+ raise HTTPException(status_code=409, detail=f"Для этого vMix уже активен Mapping «{conflict.name}». Разрешите замену при переносе.")
+ conflict.active = False
+ conflict.updated_by = user.login
+ conflict.updated_at = _utcnow()
+
+ now = _utcnow()
+ profile = VmixMappingProfile(
+ name=self._portable_profile_name(session, name, "Imported Mapping"),
+ description=str(description or "")[:4000],
+ project_fingerprint=str(device.project_fingerprint or "")[:64],
+ inventory_json=device.project_inventory_json or "{}",
+ version=1,
+ active=True,
+ created_by=user.login,
+ updated_by=user.login,
+ created_at=now,
+ updated_at=now,
+ )
+ session.add(profile)
+ session.flush()
+ for index, item in enumerate(rebound):
+ session.add(VmixMappingField(
+ profile_id=profile.id,
+ graphic=str(item.get("graphic") or "")[:100],
+ data_key=str(item.get("data_key") or "")[:200],
+ vmix_input_key=str(item.get("vmix_input_key") or "")[:128],
+ vmix_input_number=str(item.get("vmix_input_number") or "")[:32],
+ vmix_input_title=str(item.get("vmix_input_title") or "")[:300],
+ vmix_field=str(item.get("vmix_field") or "")[:300],
+ field_type=str(item.get("field_type") or "text")[:32],
+ rule_json=json.dumps(dict(item.get("rule") or {}), ensure_ascii=False, separators=(",", ":"))[:12000],
+ enabled=bool(item.get("enabled", True)),
+ sort_order=index,
+ ))
+ session.flush()
+ return profile, report
+
+ async def export_mapping_profile(self, profile_id: int) -> dict[str, Any]:
+ with self.database.session() as session:
+ profile = session.get(VmixMappingProfile, profile_id)
+ if profile is None:
+ raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
+ payload = self._mapping_profile_payload(session, profile, include_inventory=True, include_fields=True)
+ fields = []
+ for row in payload.get("fields") or []:
+ fields.append({key: value for key, value in row.items() if key != "id"})
+ inventory = payload.get("inventory") if isinstance(payload.get("inventory"), dict) else {}
+ return {
+ "format": "hockey-vmix-mapping",
+ "schema_version": 1,
+ "exported_at": _utcnow().isoformat(),
+ "profile": {
+ "name": payload.get("name") or "",
+ "description": payload.get("description") or "",
+ "source_project_fingerprint": payload.get("project_fingerprint") or "",
+ "source_inventory": {
+ "input_count": inventory.get("input_count", 0),
+ "field_count": inventory.get("field_count", 0),
+ "vmix_version": inventory.get("vmix_version", ""),
+ },
+ "fields": fields,
+ },
+ }
+
+ async def copy_mapping_profile_to_device(self, profile_id: int, payload: Any, user: HockeyUser) -> dict[str, Any]:
+ device_id = self.normalise_device_id(payload.device_id)
+ with self.database.session() as session:
+ source = session.get(VmixMappingProfile, profile_id)
+ if source is None:
+ raise HTTPException(status_code=404, detail="Mapping-профиль не найден")
+ device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
+ if device is None or not device.project_fingerprint or not str(device.project_inventory_json or "").strip():
+ raise HTTPException(status_code=409, detail="Выбранный Agent ещё не передал структуру vMix")
+ source_payload = self._mapping_profile_payload(session, source, include_inventory=False, include_fields=True)
+ target_fingerprint = str(device.project_fingerprint or "")
+ if str(source.project_fingerprint or "") == target_fingerprint:
+ other = session.scalar(
+ select(VmixMappingProfile)
+ .where(and_(
+ VmixMappingProfile.project_fingerprint == target_fingerprint,
+ VmixMappingProfile.active.is_(True),
+ VmixMappingProfile.id != source.id,
+ ))
+ .order_by(desc(VmixMappingProfile.updated_at), desc(VmixMappingProfile.id))
+ )
+ if other is not None and not bool(payload.replace_existing):
+ raise HTTPException(status_code=409, detail=f"Для этого vMix уже активен Mapping «{other.name}». Разрешите замену при переносе.")
+ if other is not None:
+ other.active = False
+ other.updated_by = user.login
+ other.updated_at = _utcnow()
+ source.active = True
+ source.updated_by = user.login
+ source.updated_at = _utcnow()
+ result_profile = self._mapping_profile_payload(session, source, include_inventory=True, include_fields=True)
+ report = {"total": len(source_payload.get("fields") or []), "mapped": len(source_payload.get("fields") or []), "skipped": 0, "matched_by": {"key": len(source_payload.get("fields") or []), "title": 0, "number": 0}, "skipped_items": [], "reused": True}
+ else:
+ fallback_name = f"{source.name} · {device.name or device.hostname or device.device_uuid}"
+ created, report = self._create_portable_profile_for_device(
+ session, device=device, fields=list(source_payload.get("fields") or []),
+ name=str(payload.name or "").strip() or fallback_name, description=source.description, user=user,
+ replace_existing=bool(payload.replace_existing),
+ )
+ result_profile = self._mapping_profile_payload(session, created, include_inventory=True, include_fields=True)
+ await self._broadcast_mapping_for_fingerprint(target_fingerprint)
+ applied = None
+ if bool(payload.apply_now):
+ applied = await self.apply_mapping_to_device(device_id, reason="portable_mapping_copy")
+ return {"ok": True, "profile": result_profile, "report": report, "applied": applied}
+
+ async def import_mapping_profile_to_device(self, payload: Any, user: HockeyUser) -> dict[str, Any]:
+ device_id = self.normalise_device_id(payload.device_id)
+ document = dict(payload.document or {})
+ if document.get("format") not in {None, "", "hockey-vmix-mapping"}:
+ raise HTTPException(status_code=422, detail="Это не файл Hockey vMix Mapping")
+ try:
+ schema_version = int(document.get("schema_version") or 1)
+ except (TypeError, ValueError):
+ raise HTTPException(status_code=422, detail="Некорректная версия файла Mapping")
+ if schema_version != 1:
+ raise HTTPException(status_code=422, detail="Версия файла Mapping пока не поддерживается")
+ profile_doc = document.get("profile") if isinstance(document.get("profile"), dict) else document
+ fields = profile_doc.get("fields") if isinstance(profile_doc, dict) and isinstance(profile_doc.get("fields"), list) else []
+ if not fields:
+ raise HTTPException(status_code=422, detail="В файле Mapping нет связей")
+ with self.database.session() as session:
+ device = session.scalar(select(VmixDevice).where(VmixDevice.device_uuid == device_id))
+ if device is None or not device.project_fingerprint or not str(device.project_inventory_json or "").strip():
+ raise HTTPException(status_code=409, detail="Выбранный Agent ещё не передал структуру vMix")
+ target_fingerprint = str(device.project_fingerprint or "")
+ imported_name = str(payload.name or "").strip() or str(profile_doc.get("name") or "Imported Mapping").strip()
+ created, report = self._create_portable_profile_for_device(
+ session, device=device, fields=fields, name=imported_name,
+ description=str(profile_doc.get("description") or ""), user=user,
+ replace_existing=bool(payload.replace_existing),
+ )
+ result_profile = self._mapping_profile_payload(session, created, include_inventory=True, include_fields=True)
+ await self._broadcast_mapping_for_fingerprint(target_fingerprint)
+ applied = None
+ if bool(payload.apply_now):
+ applied = await self.apply_mapping_to_device(device_id, reason="portable_mapping_import")
+ return {"ok": True, "profile": result_profile, "report": report, "applied": applied}
+
async def list_mapping_profiles(self) -> dict[str, Any]:
with self.database.session() as session:
rows = list(session.scalars(select(VmixMappingProfile).order_by(desc(VmixMappingProfile.updated_at), VmixMappingProfile.name)))
@@ -2304,6 +2606,21 @@ class MappingFieldsReplacePayload(BaseModel):
fields: list[MappingFieldPayload] = Field(default_factory=list, max_length=2000)
+class MappingProfileCopyPayload(BaseModel):
+ device_id: str = Field(min_length=6, max_length=128)
+ name: str = Field(default="", max_length=200)
+ replace_existing: bool = False
+ apply_now: bool = True
+
+
+class MappingPortableImportPayload(BaseModel):
+ device_id: str = Field(min_length=6, max_length=128)
+ document: dict[str, Any] = Field(default_factory=dict)
+ name: str = Field(default="", max_length=200)
+ replace_existing: bool = False
+ apply_now: bool = True
+
+
class ContextVariablePayload(BaseModel):
key: str = Field(min_length=1, max_length=128)
label: str = Field(min_length=1, max_length=200)
@@ -2523,6 +2840,18 @@ def create_hockey_agent_router(
async def mapping_profile_create(payload: MappingProfileCreatePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.create_mapping_profile(payload, user)
+ @router.get("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/export", dependencies=admin)
+ async def mapping_profile_export(profile_id: int) -> dict[str, Any]:
+ return await hub.export_mapping_profile(profile_id)
+
+ @router.post("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/copy-to-device", dependencies=admin)
+ async def mapping_profile_copy_to_device(profile_id: int, payload: MappingProfileCopyPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
+ return await hub.copy_mapping_profile_to_device(profile_id, payload, user)
+
+ @router.post("/api/hockey/admin/vmix-mapping/import", dependencies=admin)
+ async def mapping_profile_import(payload: MappingPortableImportPayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
+ return await hub.import_mapping_profile_to_device(payload, user)
+
@router.put("/api/hockey/admin/vmix-mapping/profiles/{profile_id}", dependencies=admin)
async def mapping_profile_update(profile_id: int, payload: MappingProfileUpdatePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.update_mapping_profile(profile_id, payload, user)
diff --git a/hockey_data/mapping_context.py b/hockey_data/mapping_context.py
index 59ffa76..4027aa4 100644
--- a/hockey_data/mapping_context.py
+++ b/hockey_data/mapping_context.py
@@ -63,8 +63,19 @@ DEFAULT_CONTEXT_VARIABLES: tuple[dict[str, Any], ...] = (
{"key": "selected_referee_id", "label": "Выбранный судья", "category": "Выделенные", "entity_type": "referee", "scope": "match", "source_type": "selection"},
{"key": "selected_goal_id", "label": "Выбранный гол", "category": "Выделенные", "entity_type": "goal", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_id", "label": "Выбранное удаление", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
- {"key": "selected_penalty_player_id", "label": "Игрок выбранного удаления", "category": "Выделенные", "entity_type": "player", "scope": "match", "source_type": "selection"},
+ {"key": "selected_penalty_player_id", "label": "Игрок выбранного удаления (external ID)", "category": "Выделенные", "entity_type": "player", "scope": "match", "source_type": "selection"},
+ {"key": "selected_penalty_player_db_id", "label": "Игрок выбранного удаления (DB ID)", "category": "Выделенные", "entity_type": "player", "scope": "match", "source_type": "selection"},
+ {"key": "selected_penalty_team_penalty", "label": "Выбранное удаление командное (1/0)", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection", "value_type": "text"},
{"key": "selected_penalty_team_id", "label": "Команда выбранного удаления", "category": "Выделенные", "entity_type": "team", "scope": "match", "source_type": "selection"},
+
+ {"key": "selected_home_penalty_id", "label": "Удаление левой команды — ID удаления", "category": "Удаления — левая команда", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
+ {"key": "selected_home_penalty_player_id", "label": "Удаление левой команды — игрок external ID", "category": "Удаления — левая команда", "entity_type": "player", "scope": "match", "source_type": "selection"},
+ {"key": "selected_home_penalty_player_db_id", "label": "Удаление левой команды — игрок DB ID", "category": "Удаления — левая команда", "entity_type": "player", "scope": "match", "source_type": "selection"},
+ {"key": "selected_home_penalty_team_penalty", "label": "Удаление левой команды — командное (1/0)", "category": "Удаления — левая команда", "entity_type": "penalty", "scope": "match", "source_type": "selection", "value_type": "text"},
+ {"key": "selected_away_penalty_id", "label": "Удаление правой команды — ID удаления", "category": "Удаления — правая команда", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
+ {"key": "selected_away_penalty_player_id", "label": "Удаление правой команды — игрок external ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match", "source_type": "selection"},
+ {"key": "selected_away_penalty_player_db_id", "label": "Удаление правой команды — игрок DB ID", "category": "Удаления — правая команда", "entity_type": "player", "scope": "match", "source_type": "selection"},
+ {"key": "selected_away_penalty_team_penalty", "label": "Удаление правой команды — командное (1/0)", "category": "Удаления — правая команда", "entity_type": "penalty", "scope": "match", "source_type": "selection", "value_type": "text"},
{"key": "selected_penalty_infraction_id", "label": "Нарушение выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_preset_id", "label": "Длительность выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
{"key": "selected_penalty_side", "label": "Сторона выбранного удаления", "category": "Выделенные", "entity_type": "penalty", "scope": "match", "source_type": "selection"},
diff --git a/hockey_data/service.py b/hockey_data/service.py
index 8d2882f..4b85992 100644
--- a/hockey_data/service.py
+++ b/hockey_data/service.py
@@ -4866,6 +4866,7 @@ class HockeyDataService:
{
"id": player.external_id,
"external_id": player.external_id,
+ "db_id": player.id,
"side": roster.side,
"team_id": roster.team_external_id,
"number": roster.number,
diff --git a/hockey_data/static/admin-directories.css b/hockey_data/static/admin-directories.css
index 65db14d..4e7b3ba 100644
--- a/hockey_data/static/admin-directories.css
+++ b/hockey_data/static/admin-directories.css
@@ -1590,3 +1590,12 @@
.hockey-period-status-row input { width:100%; min-height:36px; box-sizing:border-box; padding:0 10px; color:#eef7ff; border:1px solid #304b67; border-radius:8px; outline:none; background:#101f31; font-size:11px; font-weight:720; }
.hockey-period-status-row input:focus { border-color:#48dfbd; box-shadow:0 0 0 3px rgba(72,223,189,.09); }
@media (max-width:760px) { .hockey-period-status-row { grid-template-columns:90px 1fr; } .hockey-period-status-row.is-head span:nth-child(3) { display:none; } .hockey-period-status-row input:last-child { grid-column:2; } }
+
+/* BUILD72 — portable vMix Mapping transfer/import */
+.hockey-map-transfer-inline{display:grid;grid-template-columns:minmax(190px,1fr) auto;gap:6px;align-items:center}
+.hockey-map-transfer-inline select{min-width:190px}
+.hockey-mapping-import{border-top:1px solid rgba(57,80,104,.7);padding-top:12px;margin-top:12px}
+.hockey-mapping-import input[type="file"]{height:auto;min-height:38px;padding:7px;background:#0b1723;color:#9fb5c9}
+.hockey-mapping-import small{display:block;color:#6f879d;font-size:8px;line-height:1.35}
+.hockey-mapping-import .hockey-directory-check{margin:2px 0}
+@media(max-width:900px){.hockey-map-transfer-inline{grid-template-columns:1fr}.hockey-map-transfer-inline select{min-width:0;width:100%}}
diff --git a/hockey_data/static/admin-directories.js b/hockey_data/static/admin-directories.js
index 52d7e78..b689d68 100644
--- a/hockey_data/static/admin-directories.js
+++ b/hockey_data/static/admin-directories.js
@@ -86,6 +86,31 @@
return data;
}
+
+
+ function downloadJson(filename, data) {
+ const safe = String(filename || "mapping.json").replace(/[\\/:*?"<>|]+/g, "_");
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url; anchor.download = safe; document.body.appendChild(anchor); anchor.click(); anchor.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
+ }
+
+ function mappingTransferReport(result, action = "Mapping перенесён") {
+ const report = result?.report || {};
+ const mapped = Number(report.mapped || 0);
+ const total = Number(report.total || mapped);
+ const skipped = Number(report.skipped || 0);
+ const by = report.matched_by || {};
+ const methods = [
+ Number(by.key || 0) ? `key ${Number(by.key || 0)}` : "",
+ Number(by.title || 0) ? `название ${Number(by.title || 0)}` : "",
+ Number(by.number || 0) ? `номер ${Number(by.number || 0)}` : "",
+ ].filter(Boolean).join(" · ");
+ return `${action}: ${mapped}/${total} связей${skipped ? ` · пропущено ${skipped}` : ""}${methods ? ` · ${methods}` : ""}`;
+ }
+
function selectedContext() {
const data = window.UIBuilderRuntime?.getData?.() || {};
const game = data.hockey?.selected_game || null;
@@ -2148,6 +2173,11 @@
+
+
+
+
+
@@ -2166,6 +2196,15 @@
+
Профили
${profileButtons}
@@ -2390,6 +2429,31 @@
} catch (error) { setStatus(error.message, true); }
renderMapping();
});
+
+ modal.querySelector("[data-mapping-import]")?.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ const form = new FormData(event.currentTarget);
+ const file = form.get("file");
+ if (!(file instanceof File) || !file.size) return setStatus("Выберите JSON-файл Mapping.", true), renderMapping();
+ if (file.size > 8 * 1024 * 1024) return setStatus("Файл Mapping слишком большой.", true), renderMapping();
+ try {
+ const document = JSON.parse(await file.text());
+ const result = await request("/api/hockey/admin/vmix-mapping/import", {
+ method: "POST",
+ body: JSON.stringify({
+ device_id: String(form.get("device_id") || ""),
+ name: String(form.get("name") || ""),
+ replace_existing: Boolean(form.get("replace_existing")),
+ apply_now: true,
+ document,
+ }),
+ });
+ await loadMapping(result.profile?.id || null);
+ state.mappingSelectedInputKey = ""; state.mappingVmixScrollTop = 0;
+ setStatus(mappingTransferReport(result, `Mapping «${result.profile?.name || ""}» импортирован`), Number(result.report?.skipped || 0) > 0);
+ } catch (error) { setStatus(error.message || "Не удалось прочитать Mapping JSON", true); }
+ renderMapping();
+ });
modal.querySelector("[data-map-input-picker]")?.addEventListener("change", (event) => {
state.mappingSelectedInputKey = event.currentTarget.value;
state.mappingTargetField = "";
@@ -2646,6 +2710,36 @@
} catch (error) { setStatus(error.message, true); }
renderMapping();
});
+ modal.querySelector("[data-map-export]")?.addEventListener("click", async () => {
+ if (!state.mappingActiveProfile) return;
+ try {
+ const document = await request(`/api/hockey/admin/vmix-mapping/profiles/${state.mappingActiveProfile.id}/export`);
+ const base = String(state.mappingActiveProfile.name || "mapping").trim().replace(/\s+/g, "_");
+ downloadJson(`${base}.hockey-mapping.json`, document);
+ setStatus(`Mapping «${state.mappingActiveProfile.name}» экспортирован.`);
+ } catch (error) { setStatus(error.message, true); }
+ renderMapping();
+ });
+ modal.querySelector("[data-map-copy-to-device]")?.addEventListener("click", async () => {
+ if (!state.mappingActiveProfile) return;
+ const deviceId = modal.querySelector("[data-map-copy-device]")?.value || "";
+ if (!deviceId) return setStatus("Выберите Agent, на который нужно перенести Mapping.", true), renderMapping();
+ const target = (state.mappingDevices?.devices || []).find((item) => item.device_id === deviceId);
+ const replaceExisting = Boolean(target?.mapping && target.mapping.id !== state.mappingActiveProfile.id)
+ ? window.confirm(`На «${target?.name || deviceId}» уже назначен Mapping «${target.mapping.name}». Заменить его новой копией?`)
+ : false;
+ if (target?.mapping && target.mapping.id !== state.mappingActiveProfile.id && !replaceExisting) return;
+ try {
+ const result = await request(`/api/hockey/admin/vmix-mapping/profiles/${state.mappingActiveProfile.id}/copy-to-device`, {
+ method: "POST",
+ body: JSON.stringify({ device_id: deviceId, replace_existing: replaceExisting, apply_now: true }),
+ });
+ await loadMapping(result.profile?.id || state.mappingActiveProfile.id);
+ state.mappingSelectedInputKey = ""; state.mappingVmixScrollTop = 0;
+ setStatus(mappingTransferReport(result, `Mapping перенесён на ${target?.name || deviceId}`), Number(result.report?.skipped || 0) > 0);
+ } catch (error) { setStatus(error.message, true); }
+ renderMapping();
+ });
modal.querySelector("[data-map-apply-now]")?.addEventListener("click", async () => {
const deviceId = state.mappingTestDeviceId || modal.querySelector("[data-map-test-device]")?.value || "";
if (!deviceId) return setStatus("Выберите online Agent для применения Mapping.", true), renderMapping();
diff --git a/hockey_data/static/tournament-menu.js b/hockey_data/static/tournament-menu.js
index 8f88cf7..386fb52 100644
--- a/hockey_data/static/tournament-menu.js
+++ b/hockey_data/static/tournament-menu.js
@@ -317,6 +317,9 @@
}
state.currentUser = user;
state.isAdmin = Boolean(user?.is_admin);
+ const editorButton = document.getElementById("runtimeEditorBtn");
+ editorButton?.classList.toggle("hidden", !state.isAdmin);
+ document.body.dataset.hockeyAdmin = state.isAdmin ? "1" : "0";
const currentAccountId = String(user?.id || "").trim();
if (!currentAccountId) return;
const previousAccountId = String(localStorage.getItem(storage.account) || "").trim();
diff --git a/tests/test_build69_runtime_entry_admin_editor.py b/tests/test_build69_runtime_entry_admin_editor.py
new file mode 100644
index 0000000..78b2d27
--- /dev/null
+++ b/tests/test_build69_runtime_entry_admin_editor.py
@@ -0,0 +1,45 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+RUNTIME = (ROOT / "ui_builder" / "static" / "runtime.html").read_text(encoding="utf-8")
+EDITOR = (ROOT / "ui_builder" / "static" / "index.html").read_text(encoding="utf-8")
+INTEGRATION = (ROOT / "ui_builder" / "integration.py").read_text(encoding="utf-8")
+APP_JS = (ROOT / "ui_builder" / "static" / "app.js").read_text(encoding="utf-8")
+TOURNAMENT_JS = (ROOT / "hockey_data" / "static" / "tournament-menu.js").read_text(encoding="utf-8")
+APP_PY = (ROOT / "app.py").read_text(encoding="utf-8")
+
+
+def test_runtime_has_no_builder_dom():
+ assert 'id="editor"' not in RUNTIME
+ assert 'id="componentLibrary"' not in RUNTIME
+ assert 'id="canvasStage"' not in RUNTIME
+ assert 'id="inspector"' not in RUNTIME
+ assert 'class="topbar editor-only"' not in RUNTIME
+ assert 'id="runtimeView"' in RUNTIME
+ assert 'runtime-view runtime-only hidden' not in RUNTIME
+
+
+def test_editor_keeps_full_builder():
+ assert 'id="editor"' in EDITOR
+ assert 'id="componentLibrary"' in EDITOR
+ assert 'id="canvasStage"' in EDITOR
+ assert 'id="inspector"' in EDITOR
+
+
+def test_integration_uses_separate_runtime_template():
+ assert 'runtime_template = (assets_dir / "runtime.html")' in INTEGRATION
+ assert 'template = editor_template if mode == "editor" else runtime_template' in INTEGRATION
+
+
+def test_editor_button_is_admin_only_and_hotkey_respects_it():
+ assert 'id="runtimeEditorBtn" class="icon-btn hidden"' in RUNTIME
+ assert 'editorButton?.classList.toggle("hidden", !state.isAdmin);' in TOURNAMENT_JS
+ assert 'document.body.dataset.hockeyAdmin = state.isAdmin ? "1" : "0";' in TOURNAMENT_JS
+ assert 'el.runtimeEditorBtn?.classList.contains("hidden")' in APP_JS
+
+
+def test_editor_server_paths_are_admin_protected():
+ assert 'path == "/editor"' in APP_PY
+ assert 'path.startswith("/api/ui-builder/editor/")' in APP_PY
+ assert 'path.startswith("/api/ui-builder/auth/")' in APP_PY
+ assert '(khl_admin_path or editor_path) and not user.is_admin' in APP_PY
diff --git a/tests/test_build70_crisp_runtime_rendering.py b/tests/test_build70_crisp_runtime_rendering.py
new file mode 100644
index 0000000..b7e72a3
--- /dev/null
+++ b/tests/test_build70_crisp_runtime_rendering.py
@@ -0,0 +1,30 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+JS = (ROOT / "ui_builder" / "static" / "app.js").read_text(encoding="utf-8")
+CSS = (ROOT / "ui_builder" / "static" / "styles.css").read_text(encoding="utf-8")
+APP = (ROOT / "app.py").read_text(encoding="utf-8")
+
+
+def test_build70_prefers_css_zoom_for_runtime_canvas():
+ assert 'CSS.supports("zoom", "1")' in JS
+ assert 'el.runtimeStage.style.zoom = String(scale);' in JS
+ assert 'el.runtimeStage.style.transform = "none";' in JS
+
+
+def test_build70_fallback_does_not_force_translate3d_layer():
+ start = JS.index('const canUseCssZoom')
+ snippet = JS[start:start + 1500]
+ assert 'style.transform = `translate3d' not in snippet
+ assert 'el.runtimeStage.style.transform = `scale(${scale})`;' in snippet
+
+
+def test_build70_runtime_topbar_has_no_backdrop_blur():
+ start = CSS.index('.runtime-topbar {')
+ runtime_topbar = CSS[start:CSS.index('}', start) + 1]
+ assert 'backdrop-filter' not in runtime_topbar
+ assert 'background: #080d15' in runtime_topbar
+
+
+def test_build70_version():
+ assert 'BUILD_VERSION = "2026.08.19.28"' in APP
diff --git a/tests/test_build71_penalty_side_identifiers.py b/tests/test_build71_penalty_side_identifiers.py
new file mode 100644
index 0000000..56e00bd
--- /dev/null
+++ b/tests/test_build71_penalty_side_identifiers.py
@@ -0,0 +1,52 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+JS = (ROOT / "ui_builder/static/app.js").read_text(encoding="utf-8")
+MAPPING = (ROOT / "hockey_data/mapping_context.py").read_text(encoding="utf-8")
+SERVICE = (ROOT / "hockey_data/service.py").read_text(encoding="utf-8")
+CSS = (ROOT / "ui_builder/static/styles.css").read_text(encoding="utf-8")
+
+
+def test_player_payload_exposes_database_id_without_replacing_external_id():
+ assert '"id": player.external_id' in SERVICE
+ assert '"external_id": player.external_id' in SERVICE
+ assert '"db_id": player.id' in SERVICE
+
+
+def test_mapping_has_independent_home_and_away_penalty_identifiers():
+ for key in (
+ "selected_home_penalty_id",
+ "selected_home_penalty_player_id",
+ "selected_home_penalty_player_db_id",
+ "selected_home_penalty_team_penalty",
+ "selected_away_penalty_id",
+ "selected_away_penalty_player_id",
+ "selected_away_penalty_player_db_id",
+ "selected_away_penalty_team_penalty",
+ "selected_penalty_player_db_id",
+ "selected_penalty_team_penalty",
+ ):
+ assert f'"key": "{key}"' in MAPPING
+
+
+def test_penalty_preview_writes_side_specific_context_and_team_flag():
+ for key in (
+ "selected_home_penalty_id",
+ "selected_home_penalty_player_db_id",
+ "selected_home_penalty_team_penalty",
+ "selected_away_penalty_id",
+ "selected_away_penalty_player_db_id",
+ "selected_away_penalty_team_penalty",
+ ):
+ assert f"{key}:" in JS
+ assert 'selected_penalty_team_penalty: detail.team_penalty ? "1" : "0"' in JS
+ assert 'selected_penalty_player_db_id: detail.player_db_id' in JS
+
+
+def test_preview_selection_is_kept_per_side_and_visible():
+ assert 'selectedPreviewEventIds: { home: "", away: "" }' in JS
+ assert 'board.selectedPreviewEventIds[side] = event.id' in JS
+ assert 'hockeyPenaltyIsPreviewSelected(board, event)' in JS
+ assert 'Player DB ID' in JS
+ assert 'Командное${event.teamPenalty ? "1" : "0"}' in JS
+ assert '.hpd-team-card-identifiers' in CSS
diff --git a/tests/test_build72_portable_mapping_transfer.py b/tests/test_build72_portable_mapping_transfer.py
new file mode 100644
index 0000000..dcb6528
--- /dev/null
+++ b/tests/test_build72_portable_mapping_transfer.py
@@ -0,0 +1,145 @@
+from __future__ import annotations
+
+import asyncio
+import json
+from pathlib import Path
+from types import SimpleNamespace
+
+from sqlalchemy import select
+
+from hockey_data.agent_bridge import VmixAgentHub
+from hockey_data.auth_bridge import HockeyUser
+from hockey_data.models import VmixDevice, VmixMappingField, VmixMappingProfile
+from tests.support import LocalTestDatabase
+
+
+ROOT = Path(__file__).resolve().parents[1]
+JS = (ROOT / "hockey_data/static/admin-directories.js").read_text(encoding="utf-8")
+CSS = (ROOT / "hockey_data/static/admin-directories.css").read_text(encoding="utf-8")
+BRIDGE = (ROOT / "hockey_data/agent_bridge.py").read_text(encoding="utf-8")
+
+
+def test_portable_rebind_uses_title_when_input_key_changed() -> None:
+ fields = [{
+ "graphic": "score",
+ "data_key": "game.home_score",
+ "vmix_input_key": "old-key",
+ "vmix_input_number": "5",
+ "vmix_input_title": "SCORE BUG",
+ "vmix_field": "HomeScore.Text",
+ "field_type": "text",
+ "enabled": True,
+ "rule": {"enabled": True, "operator": "eq", "right_value": "1"},
+ }]
+ inventory = {"inputs": [{
+ "key": "new-key",
+ "number": "17",
+ "title": "SCORE BUG",
+ "type": "GT",
+ "fields": [{"name": "HomeScore.Text", "type": "text", "index": "0"}],
+ }]}
+
+ rebound, report = VmixAgentHub._rebind_portable_mapping_fields(fields, inventory)
+ assert report["mapped"] == 1
+ assert report["skipped"] == 0
+ assert report["matched_by"]["title"] == 1
+ assert rebound[0]["vmix_input_key"] == "new-key"
+ assert rebound[0]["vmix_input_number"] == "17"
+ assert rebound[0]["vmix_input_title"] == "SCORE BUG"
+ assert rebound[0]["vmix_field"] == "HomeScore.Text"
+ assert rebound[0]["rule"]["operator"] == "eq"
+
+
+def test_portable_rebind_reports_missing_field_without_guessing() -> None:
+ fields = [{
+ "data_key": "game.clock",
+ "vmix_input_key": "old-key",
+ "vmix_input_title": "SCORE BUG",
+ "vmix_field": "Clock.Text",
+ "field_type": "text",
+ "enabled": True,
+ }]
+ inventory = {"inputs": [{
+ "key": "new-key",
+ "number": "2",
+ "title": "SCORE BUG",
+ "fields": [{"name": "Period.Text", "type": "text", "index": "0"}],
+ }]}
+ rebound, report = VmixAgentHub._rebind_portable_mapping_fields(fields, inventory)
+ assert rebound == []
+ assert report["mapped"] == 0
+ assert report["skipped"] == 1
+ assert report["skipped_items"][0]["reason"] == "field_not_found"
+
+
+def test_copy_profile_creates_target_specific_mapping_and_keeps_source(tmp_path: Path) -> None:
+ database = LocalTestDatabase(tmp_path / "portable-mapping.sqlite3")
+ database.create_all()
+ hub = VmixAgentHub(database) # type: ignore[arg-type]
+ user = HockeyUser(id="admin-1", login="admin", display_name="Admin")
+
+ source_inventory = {
+ "fingerprint": "a" * 64,
+ "inputs": [{
+ "key": "source-score-key", "number": "3", "title": "SCORE BUG", "type": "GT",
+ "fields": [{"name": "HomeScore.Text", "type": "text", "index": "0"}],
+ }],
+ "input_count": 1, "field_count": 1,
+ }
+ target_inventory = {
+ "fingerprint": "b" * 64,
+ "inputs": [{
+ "key": "target-score-key", "number": "11", "title": "SCORE BUG", "type": "GT",
+ "fields": [{"name": "HomeScore.Text", "type": "text", "index": "4"}],
+ }],
+ "input_count": 1, "field_count": 1,
+ }
+
+ with database.session() as session:
+ source = VmixMappingProfile(
+ name="KHL MAIN", description="portable", project_fingerprint="a" * 64,
+ inventory_json=json.dumps(source_inventory), version=7, active=True,
+ created_by="admin", updated_by="admin",
+ )
+ session.add(source)
+ session.flush()
+ source_id = source.id
+ session.add(VmixMappingField(
+ profile_id=source.id, graphic="score", data_key="game.home_score",
+ vmix_input_key="source-score-key", vmix_input_number="", vmix_input_title="SCORE BUG",
+ vmix_field="HomeScore.Text", field_type="text", rule_json="{}", enabled=True, sort_order=0,
+ ))
+ session.add(VmixDevice(
+ device_uuid="GFX-PORTABLE-02", device_secret_hash="x" * 64, name="GFX 2",
+ project_fingerprint="b" * 64, project_inventory_json=json.dumps(target_inventory),
+ project_input_count=1, project_field_count=1, vmix_connected=True,
+ ))
+
+ payload = SimpleNamespace(device_id="GFX-PORTABLE-02", name="", replace_existing=False, apply_now=False)
+ result = asyncio.run(hub.copy_mapping_profile_to_device(source_id, payload, user))
+ assert result["report"]["mapped"] == 1
+ assert result["report"]["matched_by"]["title"] == 1
+ assert result["profile"]["project_fingerprint"] == "b" * 64
+ assert result["profile"]["fields"][0]["vmix_input_key"] == "target-score-key"
+ assert result["profile"]["fields"][0]["vmix_input_title"] == "SCORE BUG"
+
+ with database.session() as session:
+ profiles = list(session.scalars(select(VmixMappingProfile).order_by(VmixMappingProfile.id)))
+ assert len(profiles) == 2
+ assert profiles[0].id == source_id and profiles[0].active is True
+ assert profiles[0].project_fingerprint == "a" * 64
+ assert profiles[1].active is True
+ assert profiles[1].project_fingerprint == "b" * 64
+
+
+def test_admin_ui_has_export_import_and_copy_to_agent_controls() -> None:
+ assert "data-map-export" in JS
+ assert "data-map-copy-to-device" in JS
+ assert "data-mapping-import" in JS
+ assert "/copy-to-device" in JS
+ assert "/api/hockey/admin/vmix-mapping/import" in JS
+ assert "hockey-mapping.json" in JS
+ assert "Input ищется по key → названию → номеру" in JS
+ assert "portable vMix Mapping transfer/import" in CSS
+ assert '"/api/hockey/admin/vmix-mapping/profiles/{profile_id}/export"' in BRIDGE
+ assert '"/api/hockey/admin/vmix-mapping/import"' in BRIDGE
diff --git a/ui_builder/integration.py b/ui_builder/integration.py
index b164b82..78051b6 100644
--- a/ui_builder/integration.py
+++ b/ui_builder/integration.py
@@ -89,7 +89,8 @@ def install_ui_builder(
)
)
- template = (assets_dir / "index.html").read_text(encoding="utf-8")
+ editor_template = (assets_dir / "index.html").read_text(encoding="utf-8")
+ runtime_template = (assets_dir / "runtime.html").read_text(encoding="utf-8")
asset_digest = hashlib.sha256()
for asset_name in ("app.js", "styles.css"):
asset_path = assets_dir / asset_name
@@ -101,6 +102,9 @@ def install_ui_builder(
def render_page(mode: str) -> HTMLResponse:
page_api = editor_api if mode == "editor" else runtime_api
+ # Runtime has its own HTML entry point and contains no Builder DOM at all.
+ # This prevents the visual editor from flashing during normal page load.
+ template = editor_template if mode == "editor" else runtime_template
html = (
template.replace("__UI_BUILDER_ASSETS__", assets_url.rstrip("/"))
.replace("__UI_BUILDER_ASSET_VERSION__", asset_version)
diff --git a/ui_builder/static/app.js b/ui_builder/static/app.js
index 984a3e6..5135f98 100644
--- a/ui_builder/static/app.js
+++ b/ui_builder/static/app.js
@@ -5951,8 +5951,14 @@ function openTimerQuickEditor(focusActionId = "") {
function normalizeHockeyPlayer(component, row, side, index) {
const props = component.props || {};
const get = (fieldName, fallback = "") => formatValue(getByPath(row, props[fieldName]), fallback);
+ const externalId = String(get("idField", "") || row?.external_id || row?.id || "");
+ const databaseId = String(row?.db_id ?? row?.database_id ?? "");
return {
- id: String(get("idField", `${side}-${index + 1}`)),
+ // `id` остаётся external_id для совместимости со старым Mapping.
+ // `dbId` — реальный PK hockey_players.id из нашей базы.
+ id: externalId || `${side}-${index + 1}`,
+ externalId,
+ dbId: databaseId,
side,
number: get("numberField", ""),
name: get("nameField", `Игрок ${index + 1}`),
@@ -6028,6 +6034,9 @@ function openTimerQuickEditor(focusActionId = "") {
selectedInfraction: null,
selectedPreset: null,
selectedEventId: null,
+ // Последний выбранный для редактирования штраф остаётся общим, а
+ // preview/Mapping-выбор хранится отдельно для левой и правой команды.
+ selectedPreviewEventIds: { home: "", away: "" },
penalties: [],
history: [],
historyOpen: false,
@@ -6128,11 +6137,15 @@ function openTimerQuickEditor(focusActionId = "") {
}
function hockeyPenaltyContext(event) {
+ const playerExternalId = String(event.player?.externalId || event.player?.external_id || event.player?.id || "");
+ const playerDbId = String(event.player?.dbId || event.player?.db_id || event.player?.database_id || event.player?.raw?.db_id || "");
return {
penalty_id: event.id,
event_time: event.eventTime,
event_time_ms: event.eventTimeMs,
player: event.player ? clone(event.player) : null,
+ player_id: playerExternalId,
+ player_db_id: playerDbId,
team_penalty: Boolean(event.teamPenalty),
infraction: event.infraction ? clone(event.infraction) : null,
preset: event.preset,
@@ -6458,6 +6471,7 @@ function openTimerQuickEditor(focusActionId = "") {
const side = String(event.player?.side || event.side || "").toLowerCase();
const remainingOnSide = board.penalties.filter((item) => !item.finished && hockeyEventReady(item) && String(item.player?.side || item.side || "").toLowerCase() === side).length;
if (board.selectedEventId === event.id) board.selectedEventId = null;
+ if (board.selectedPreviewEventIds?.[side] === event.id) board.selectedPreviewEventIds[side] = "";
persistHockeyBoard(component, board, true);
refreshHockeyBoardNodes(component);
rebalanceVmixPenaltyTargets({ force: true, hideUnused: true })
@@ -6476,6 +6490,10 @@ function openTimerQuickEditor(focusActionId = "") {
} else if (command === "remove") {
board.penalties = board.penalties.filter((item) => item.id !== eventId);
if (board.selectedEventId === eventId) board.selectedEventId = null;
+ {
+ const removedSide = String(event.player?.side || event.side || "").toLowerCase();
+ if (board.selectedPreviewEventIds?.[removedSide] === eventId) board.selectedPreviewEventIds[removedSide] = "";
+ }
addHockeyHistory(
component,
board,
@@ -7043,21 +7061,71 @@ function hockeyInfractionDropdown(component, runtime) {
return field;
}
+function hockeyPenaltyPlayerIdentifiers(component, event) {
+ const side = String(event.player?.side || event.side || "").toLowerCase();
+ let externalId = String(
+ event.player?.externalId
+ || event.player?.external_id
+ || event.player?.id
+ || event.player?.raw?.external_id
+ || event.player?.raw?.id
+ || ""
+ );
+ let dbId = String(
+ event.player?.dbId
+ || event.player?.db_id
+ || event.player?.database_id
+ || event.player?.raw?.db_id
+ || event.player?.raw?.database_id
+ || ""
+ );
+
+ // Для сохранённых удалений из старых сборок dbId мог отсутствовать.
+ // В этом случае восстанавливаем его по актуальному составу из БД.
+ if (externalId && !dbId && ["home", "away"].includes(side)) {
+ const rosterPlayer = hockeyRoster(component, side).find((player) =>
+ String(player.externalId || player.id || "") === externalId
+ );
+ if (rosterPlayer) {
+ dbId = String(rosterPlayer.dbId || rosterPlayer.raw?.db_id || "");
+ externalId = String(rosterPlayer.externalId || rosterPlayer.id || externalId);
+ }
+ }
+ return { externalId, dbId };
+}
+
+function hockeyPenaltyIsPreviewSelected(board, event) {
+ const side = String(event.player?.side || event.side || "").toLowerCase();
+ return ["home", "away"].includes(side)
+ && String(board.selectedPreviewEventIds?.[side] || "") === String(event.id || "");
+}
+
async function hockeySelectPenaltyForPreview(component, event) {
const board = ensureHockeyBoardState(component);
board.selectedEventId = event.id;
+ if (!board.selectedPreviewEventIds || typeof board.selectedPreviewEventIds !== "object") {
+ board.selectedPreviewEventIds = { home: "", away: "" };
+ }
+
const side = String(event.player?.side || event.side || "").toLowerCase();
+ if (["home", "away"].includes(side)) board.selectedPreviewEventIds[side] = event.id;
+
const teamId = side === "home"
? String(getByPath(state.data, "hockey.selected_game.home.id") || getByPath(state.data, "hockey.selected_game.home.external_id") || "")
: side === "away"
? String(getByPath(state.data, "hockey.selected_game.away.id") || getByPath(state.data, "hockey.selected_game.away.external_id") || "")
: "";
+ const playerIds = hockeyPenaltyPlayerIdentifiers(component, event);
const detail = {
item_id: String(event.external_id || event.id || ""),
value: String(event.external_id || event.id || ""),
penalty_id: String(event.external_id || event.id || ""),
- player_id: String(event.player?.id || ""),
+ // Старый player_id остаётся external_id, чтобы не ломать существующие SQL.
+ player_id: playerIds.externalId,
+ player_external_id: playerIds.externalId,
+ player_db_id: playerIds.dbId,
team_id: teamId,
+ team_penalty: Boolean(event.teamPenalty),
infraction_id: String(event.infraction?.id || ""),
preset_id: String(event.preset || ""),
side,
@@ -7067,7 +7135,14 @@ async function hockeySelectPenaltyForPreview(component, event) {
status: event.finished ? "finished" : event.running ? "running" : hockeyEventReady(event) ? "ready" : "draft",
selected: true,
};
- window.UIBuilderRuntime?.patchData?.({ hockey: { selected_penalty: detail } }, { render: false });
+
+ const sidePatch = ["home", "away"].includes(side) ? { [side]: detail } : {};
+ window.UIBuilderRuntime?.patchData?.({
+ hockey: {
+ selected_penalty: detail,
+ selected_penalties: sidePatch,
+ }
+ }, { render: false });
refreshHockeyBoardNodes(component);
const gameId = hockeyTimerSelectedGameId();
@@ -7075,24 +7150,45 @@ async function hockeySelectPenaltyForPreview(component, event) {
emitInteraction(component, "penalty_selected", detail);
return detail;
}
+
+ const values = {
+ selected_penalty_id: detail.penalty_id,
+ selected_penalty_player_id: detail.player_id,
+ selected_penalty_player_db_id: detail.player_db_id,
+ selected_penalty_team_penalty: detail.team_penalty ? "1" : "0",
+ selected_penalty_team_id: detail.team_id,
+ selected_penalty_infraction_id: detail.infraction_id,
+ selected_penalty_preset_id: detail.preset_id,
+ selected_penalty_side: detail.side,
+ selected_penalty_event_time: detail.event_time,
+ selected_penalty_status: detail.status,
+ selected_penalty_remaining_ms: String(detail.remaining_ms),
+ selected_penalty_duration_ms: String(detail.duration_ms),
+ selected_event_id: detail.penalty_id,
+ };
+
+ if (side === "home") {
+ Object.assign(values, {
+ selected_home_penalty_id: detail.penalty_id,
+ selected_home_penalty_player_id: detail.player_id,
+ selected_home_penalty_player_db_id: detail.player_db_id,
+ selected_home_penalty_team_penalty: detail.team_penalty ? "1" : "0",
+ });
+ } else if (side === "away") {
+ Object.assign(values, {
+ selected_away_penalty_id: detail.penalty_id,
+ selected_away_penalty_player_id: detail.player_id,
+ selected_away_penalty_player_db_id: detail.player_db_id,
+ selected_away_penalty_team_penalty: detail.team_penalty ? "1" : "0",
+ });
+ }
+
try {
const response = await fetch("/api/hockey/context/batch", {
method: "POST", cache: "no-store", credentials: "same-origin",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
- values: {
- selected_penalty_id: detail.penalty_id,
- selected_penalty_player_id: detail.player_id,
- selected_penalty_team_id: detail.team_id,
- selected_penalty_infraction_id: detail.infraction_id,
- selected_penalty_preset_id: detail.preset_id,
- selected_penalty_side: detail.side,
- selected_penalty_event_time: detail.event_time,
- selected_penalty_status: detail.status,
- selected_penalty_remaining_ms: String(detail.remaining_ms),
- selected_penalty_duration_ms: String(detail.duration_ms),
- selected_event_id: detail.penalty_id,
- },
+ values,
context: {
game_id: gameId,
device_id: currentRuntimeVmixDeviceId(),
@@ -7119,6 +7215,7 @@ function hockeyPenaltyCard(component, event, runtime) {
const card = div(`hpd-team-penalty-card team-${side}`);
card.dataset.penaltyId = event.id;
card.classList.toggle("is-selected", board.selectedEventId === event.id);
+ card.classList.toggle("is-preview-selected", hockeyPenaltyIsPreviewSelected(board, event));
card.classList.toggle("is-running", event.running);
card.classList.toggle("is-finished", event.finished);
card.classList.toggle("is-ready", hockeyEventReady(event) && !event.running && !event.finished);
@@ -7134,6 +7231,8 @@ function hockeyPenaltyCard(component, event, runtime) {
const presetText = event.preset
? `${escapeHtml(event.preset)} · ${escapeHtml(formatHockeyPenaltyTime(event.durationMs))}`
: "Выберите длительность";
+ const previewSelected = hockeyPenaltyIsPreviewSelected(board, event);
+ const playerIds = hockeyPenaltyPlayerIdentifiers(component, event);
card.innerHTML = `
@@ -7165,9 +7264,18 @@ function hockeyPenaltyCard(component, event, runtime) {
Командное удаление — без игрока
+ ${previewSelected ? `
+
+ ID удаления${escapeHtml(String(event.external_id || event.id || "—"))}
+ Player external ID${escapeHtml(playerIds.externalId || "—")}
+ Player DB ID${escapeHtml(playerIds.dbId || "—")}
+ Командное${event.teamPenalty ? "1" : "0"}
+
+ ` : ""}
+