копировать маппинг

This commit is contained in:
2026-08-19 18:00:04 +03:00
parent 14067f082c
commit 4453e23248
5 changed files with 573 additions and 19 deletions

View File

@@ -2546,6 +2546,65 @@ class VmixAgentHub:
await self._broadcast_mapping_for_fingerprint(result["project_fingerprint"])
return result
async def duplicate_mapping_profile(self, profile_id: int, user: HockeyUser) -> dict[str, Any]:
"""Create an independent editable copy of a visible Mapping config.
The copy keeps the source inventory, all field links and rules, but starts
inactive so it cannot unexpectedly replace the live Mapping for the same
vMix project. It can then be rescanned against another Agent/project and
edited without touching the original config.
"""
now = _utcnow()
with self.database.session() as session:
source = session.get(VmixMappingProfile, profile_id)
if source is None or self._runtime_mapping_source_id(source):
raise HTTPException(status_code=404, detail="Mapping-конфиг не найден")
copy_name = self._portable_profile_name(
session,
f"{str(source.name or 'Mapping').strip()} — копия",
"Mapping — копия",
)
clone = VmixMappingProfile(
name=copy_name,
description=str(source.description or ""),
project_fingerprint=str(source.project_fingerprint or ""),
inventory_json=str(source.inventory_json or "{}"),
version=1,
active=False,
created_by=user.login,
updated_by=user.login,
created_at=now,
updated_at=now,
)
session.add(clone)
session.flush()
source_fields = list(session.scalars(
select(VmixMappingField)
.where(VmixMappingField.profile_id == source.id)
.order_by(VmixMappingField.sort_order, VmixMappingField.id)
))
for item in source_fields:
session.add(VmixMappingField(
profile_id=clone.id,
graphic=str(item.graphic or "")[:100],
data_key=str(item.data_key or "")[:200],
vmix_input_key=str(item.vmix_input_key or "")[:128],
vmix_input_number=str(item.vmix_input_number or "")[:32],
vmix_input_title=str(item.vmix_input_title or "")[:300],
vmix_field=str(item.vmix_field or "")[:300],
field_type=str(item.field_type or "text")[:32],
rule_json=str(item.rule_json or "{}")[:12000],
enabled=bool(item.enabled),
sort_order=int(item.sort_order or 0),
))
session.flush()
result = self._mapping_profile_payload(session, clone, include_inventory=True, include_fields=True)
result["copied_from_profile_id"] = source.id
result["copied_fields"] = len(source_fields)
return result
async def update_mapping_profile(self, profile_id: int, payload: Any, user: HockeyUser) -> dict[str, Any]:
now = _utcnow()
with self.database.session() as session:
@@ -2570,7 +2629,66 @@ class VmixAgentHub:
await self._broadcast_mapping_for_fingerprint(result["project_fingerprint"])
return result
@classmethod
def _mapping_inventory_additions(cls, old_inventory: dict[str, Any], new_inventory: dict[str, Any]) -> dict[str, int]:
"""Count newly discovered Inputs/fields while tolerating stable-key changes.
Existing Inputs are resolved with the same conservative identity order used
by portable Mapping rebinding: key, unique title, then unique number. This is
only a UI report; it never changes or guesses Mapping links.
"""
old_inputs = old_inventory.get("inputs") if isinstance(old_inventory, dict) and isinstance(old_inventory.get("inputs"), list) else []
new_inputs = new_inventory.get("inputs") if isinstance(new_inventory, dict) and isinstance(new_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 new_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)
used: set[int] = set()
added_fields = 0
for old in old_inputs:
if not isinstance(old, dict):
continue
target = None
key = str(old.get("key") or "").strip()
title = cls._portable_text(old.get("title"))
number = str(old.get("number") or "").strip()
if key:
target = by_key.get(key)
if target is None and title and len(by_title.get(title, [])) == 1:
target = by_title[title][0]
if target is None and number and len(by_number.get(number, [])) == 1:
target = by_number[number][0]
if target is None:
continue
used.add(id(target))
old_names = {cls._portable_text(field.get("name")) for field in (old.get("fields") or []) if isinstance(field, dict) and str(field.get("name") or "").strip()}
new_names = {cls._portable_text(field.get("name")) for field in (target.get("fields") or []) if isinstance(field, dict) and str(field.get("name") or "").strip()}
added_fields += len(new_names - old_names)
added_inputs = [item for item in new_inputs if isinstance(item, dict) and id(item) not in used]
added_fields += sum(len([field for field in (item.get("fields") or []) if isinstance(field, dict) and str(field.get("name") or "").strip()]) for item in added_inputs)
return {"new_inputs": len(added_inputs), "new_fields": added_fields}
async def refresh_mapping_inventory(self, profile_id: int, device_id: str, user: HockeyUser) -> dict[str, Any]:
"""Refresh vMix structure without resetting existing Mapping links.
New titles/fields only expand the inventory. Existing links are rebound to
the freshly scanned project by Input key -> unique title -> unique number,
and the exact GT field name must still exist. A link that cannot currently
be resolved is kept unchanged in the database instead of being deleted.
"""
device_id = self.normalise_device_id(device_id)
now = _utcnow()
with self.database.session() as session:
@@ -2582,14 +2700,94 @@ class VmixAgentHub:
raise HTTPException(status_code=409, detail="Устройство не передало структуру vMix")
conflicting = session.scalar(select(VmixMappingProfile).where(and_(VmixMappingProfile.project_fingerprint == device.project_fingerprint, VmixMappingProfile.id != row.id, VmixMappingProfile.active.is_(True))))
if conflicting is not None:
raise HTTPException(status_code=409, detail=f"Эта структура уже используется mapping «{conflicting.name}»")
# Build 74 may have created a hidden runtime adaptation of this same
# visible config for the Agent. An explicit rescan means the admin now
# wants the visible config itself to adopt that concrete vMix structure.
if self._runtime_mapping_source_id(conflicting) == row.id:
conflicting.active = False
conflicting.updated_by = user.login
conflicting.updated_at = now
elif row.active:
raise HTTPException(status_code=409, detail=f"Эта структура уже используется mapping «{conflicting.name}»")
# An inactive visible copy is a draft. It may adopt the same vMix
# inventory as another live config so the admin can reuse the links,
# rescan against another project and edit the copy independently.
try:
old_inventory = json.loads(row.inventory_json or "{}")
if not isinstance(old_inventory, dict):
old_inventory = {}
except Exception:
old_inventory = {}
try:
new_inventory = json.loads(device.project_inventory_json or "{}")
if not isinstance(new_inventory, dict):
new_inventory = {}
except Exception:
new_inventory = {}
link_rows = list(session.scalars(
select(VmixMappingField)
.where(VmixMappingField.profile_id == profile_id)
.order_by(VmixMappingField.sort_order, VmixMappingField.id)
))
matched_by = {"key": 0, "title": 0, "number": 0}
preserved = 0
unresolved = 0
unresolved_items: list[dict[str, Any]] = []
for field_row in link_rows:
raw = {
"graphic": field_row.graphic,
"data_key": field_row.data_key,
"vmix_input_key": field_row.vmix_input_key,
"vmix_input_number": field_row.vmix_input_number,
"vmix_input_title": field_row.vmix_input_title,
"vmix_field": field_row.vmix_field,
"field_type": field_row.field_type,
"rule": _mapping_rule_payload(field_row.rule_json),
"enabled": field_row.enabled,
}
rebound, report = self._rebind_portable_mapping_fields([raw], new_inventory)
if rebound:
target = rebound[0]
field_row.vmix_input_key = str(target.get("vmix_input_key") or "")[:128]
field_row.vmix_input_number = str(target.get("vmix_input_number") or "")[:32]
field_row.vmix_input_title = str(target.get("vmix_input_title") or "")[:300]
field_row.vmix_field = str(target.get("vmix_field") or field_row.vmix_field)[:300]
preserved += 1
for method in matched_by:
matched_by[method] += int((report.get("matched_by") or {}).get(method, 0) or 0)
else:
# Keep the old link. This protects operator work from a temporary
# incomplete scan and lets it recover automatically if the target
# returns on the next refresh.
unresolved += 1
if len(unresolved_items) < 100:
skipped = (report.get("skipped_items") or [{}])[0]
unresolved_items.append({
"data_key": field_row.data_key,
"input": field_row.vmix_input_title or field_row.vmix_input_key or field_row.vmix_input_number,
"vmix_field": field_row.vmix_field,
"reason": skipped.get("reason", "not_found"),
})
additions = self._mapping_inventory_additions(old_inventory, new_inventory)
old_fingerprint = row.project_fingerprint
row.project_fingerprint = device.project_fingerprint
row.inventory_json = device.project_inventory_json
row.version = int(row.version or 0) + 1
row.updated_by = user.login
row.updated_at = now
session.flush()
result = self._mapping_profile_payload(session, row, include_inventory=True, include_fields=True)
result["refresh_report"] = {
"total_links": len(link_rows),
"preserved": preserved,
"unresolved": unresolved,
"matched_by": matched_by,
"unresolved_items": unresolved_items,
**additions,
}
if old_fingerprint and old_fingerprint != result["project_fingerprint"]:
await self._broadcast_mapping_for_fingerprint(old_fingerprint)
await self._broadcast_mapping_for_fingerprint(result["project_fingerprint"])
@@ -3064,6 +3262,10 @@ def create_hockey_agent_router(
async def mapping_profile_use(profile_id: int, payload: MappingProfileUsePayload, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.use_mapping_profile_for_user(profile_id, payload, user)
@router.post("/api/hockey/admin/vmix-mapping/profiles/{profile_id}/duplicate", dependencies=admin)
async def mapping_profile_duplicate(profile_id: int, user: HockeyUser = Depends(auth_dependency)) -> dict[str, Any]:
return await hub.duplicate_mapping_profile(profile_id, 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)

View File

@@ -1693,15 +1693,36 @@
/* Build 74: reusable Mapping configs + one-click apply to current Agent. */
.hockey-map-config-hint{font-size:11px;line-height:1.45;color:#8ea0b6;background:#0d1520;border:1px solid #2b394c;border-radius:10px;padding:9px 10px}
.hockey-mapping-profile-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:7px;align-items:stretch;border-radius:11px}
.hockey-mapping-profile-row.is-active{box-shadow:0 0 0 1px rgba(56,189,248,.18)}
.hockey-mapping-profile-row .hockey-mapping-profile{min-width:0;width:100%}
.hockey-map-use-profile{min-width:106px;border:1px solid #2f5870;background:#102433;color:#9bdcff;border-radius:10px;padding:7px 9px;font-size:11px;font-weight:700;cursor:pointer;white-space:normal;line-height:1.25}
.hockey-mapping-profile-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:5px;align-items:center;border-radius:9px;padding:2px}
.hockey-mapping-profile-row.is-active{background:rgba(56,189,248,.045);box-shadow:0 0 0 1px rgba(56,189,248,.18)}
.hockey-mapping-profile-row .hockey-mapping-profile{min-width:0;width:100%;padding:7px 8px;border-radius:8px}
.hockey-mapping-profile-row .hockey-mapping-profile strong{font-size:11px;line-height:1.25}
.hockey-mapping-profile-row .hockey-mapping-profile small{font-size:9px;line-height:1.2;opacity:.82}
.hockey-map-profile-actions{display:flex;align-items:center;gap:4px;padding-right:2px}
.hockey-map-profile-icon{width:28px;height:28px;min-width:28px;display:grid;place-items:center;border:1px solid #31465b;background:#0f1d2a;color:#9cb4c8;border-radius:8px;padding:0;font-size:12px;font-weight:850;line-height:1;cursor:pointer;transition:border-color .12s ease,background .12s ease,color .12s ease,transform .12s ease}
.hockey-map-profile-icon:hover{border-color:#4d718e;background:#14283a;color:#e7f4ff;transform:translateY(-1px)}
.hockey-map-use-profile{color:#8fdcff}
.hockey-map-use-profile:hover{border-color:#38bdf8;background:#123047;color:#d9f4ff}
.hockey-map-use-profile.is-used{border-color:#276749;background:#102b20;color:#8ff0b5}
.hockey-map-copy-profile{font-size:15px;color:#b9c9da}
.hockey-map-copy-profile:hover{border-color:#7c5cc7;background:#211a38;color:#ddceff}
.hockey-mapping-create-wrap{border:1px solid #334155;background:#101722;border-radius:12px;overflow:hidden}
.hockey-mapping-create-wrap>summary{cursor:pointer;padding:10px 12px;color:#b9c7d8;font-size:12px;font-weight:700;list-style:none}
.hockey-mapping-create-wrap>summary::-webkit-details-marker{display:none}
.hockey-mapping-create-wrap[open]>summary{border-bottom:1px solid #2e3b4e}
.hockey-mapping-create-wrap .hockey-mapping-create{border:0;border-radius:0;background:transparent}
@media(max-width:520px){.hockey-mapping-profile-row{grid-template-columns:1fr}.hockey-map-use-profile{min-height:34px}}
@media(max-width:520px){.hockey-mapping-profile-row{grid-template-columns:minmax(0,1fr) auto}.hockey-map-profile-icon{width:30px;height:30px;min-width:30px}}
/* Build 75: compact Mapping data browser. Heavy SQL tools and match/period
data groups start collapsed and remember their open state while editing. */
.hockey-map-sql-cell-quick>summary,.hockey-map-table-picker>summary{list-style:none;cursor:pointer;user-select:none}
.hockey-map-sql-cell-quick>summary::-webkit-details-marker,.hockey-map-table-picker>summary::-webkit-details-marker{display:none}
.hockey-map-sql-cell-quick>summary{display:flex;align-items:flex-end;justify-content:space-between;gap:10px}
.hockey-map-sql-cell-quick>summary>div{display:grid;gap:2px}.hockey-map-sql-cell-quick>summary span{font-size:8px;font-weight:950;letter-spacing:.12em;color:#69c9ef}.hockey-map-sql-cell-quick.is-target-ready>summary span{color:#48dfbd}.hockey-map-sql-cell-quick>summary strong{font-size:12px;color:#edf5ff}.hockey-map-sql-cell-quick>summary small{max-width:48%;font-size:9px;color:#7e96ad;text-align:right;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.hockey-map-sql-cell-quick>summary::after,.hockey-map-table-picker>summary::after,.hockey-map-data-group>summary::after{content:"▸";color:#6f879e;font-size:11px;transition:transform .12s ease}
.hockey-map-sql-cell-quick[open]>summary::after,.hockey-map-table-picker[open]>summary::after,.hockey-map-data-group[open]>summary::after{transform:rotate(90deg)}
.hockey-map-table-picker>summary{display:flex;align-items:center;justify-content:space-between;gap:10px}.hockey-map-table-picker>summary>div{display:grid;gap:2px;min-width:0}.hockey-map-table-picker>summary span{font-size:8px;font-weight:950;letter-spacing:.12em;color:#69c9ef}.hockey-map-table-picker>summary strong{font-size:11px;color:#dceaf6}.hockey-map-table-picker>summary>b{margin-left:auto;padding:4px 7px;border-radius:999px;background:#173148;color:#8ed5ef;font-size:9px;white-space:nowrap}
.hockey-map-sql-cell-quick:not([open]),.hockey-map-table-picker:not([open]){min-height:0!important;height:auto!important;display:block;padding:10px}
.hockey-map-sql-cell-quick:not([open])>summary,.hockey-map-table-picker:not([open])>summary{margin:0}
@media(max-width:1100px){.hockey-map-sql-cell-quick>summary{align-items:flex-start;flex-direction:column}.hockey-map-sql-cell-quick>summary small{max-width:none;text-align:left}}

View File

@@ -33,6 +33,9 @@
mappingSelectedTableRow: 1,
mappingSelectedTableColumn: "",
mappingTableRowSearch: "",
mappingSqlCellOpen: false,
mappingSqlTableOpen: false,
mappingOpenDataGroups: {},
mappingTestDeviceId: "",
mappingWorkspaceTab: "links",
mappingCatalog: null,
@@ -1837,8 +1840,8 @@
category.items.push(item);
}
const selectedSource = state.mappingSelectedSourceKey;
const groups = categories.map((category, index) => `
<details class="hockey-map-data-group" ${index < 3 || query ? "open" : ""}>
const groups = categories.map((category) => `
<details class="hockey-map-data-group" data-map-data-group="${escapeHtml(category.name)}" ${query || state.mappingOpenDataGroups[category.name] ? "open" : ""}>
<summary><span>${escapeHtml(category.name)}</span><b>${category.items.length}</b></summary>
<div>${category.items.map((item) => `
<button type="button" class="hockey-map-data-item ${selectedSource === item.key ? "is-selected" : ""}" data-map-source="${escapeHtml(item.key)}">
@@ -1872,11 +1875,11 @@
let quickTableMarkup = "";
if (tables.length) {
quickTableMarkup = `
<section class="hockey-map-sql-cell-quick ${targetField ? "is-target-ready" : ""}">
<header>
<details class="hockey-map-sql-cell-quick ${targetField ? "is-target-ready" : ""}" data-map-sql-cell-details ${state.mappingSqlCellOpen ? "open" : ""}>
<summary>
<div><span>SQL ЯЧЕЙКА</span><strong>Источник → строка → столбец</strong></div>
<small>${targetField ? `Для ${escapeHtml(currentInput?.title || "Input")}${escapeHtml(targetField.name || "")}` : "Сначала выберите поле vMix справа"}</small>
</header>
</summary>
<div class="hockey-map-sql-cell-grid">
<label><span>SQL источник</span><select data-map-quick-table-source>${tables.map((table) => `<option value="${escapeHtml(table.code)}" ${String(table.code) === String(selectedTable?.code) ? "selected" : ""}>${escapeHtml(table.name || table.code)}</option>`).join("")}</select></label>
<label><span>Строка</span><select data-map-quick-table-row ${selectedTableRows.length ? "" : "disabled"}>${selectedTableRows.map((row) => `<option value="${Number(row.index || 0)}" ${Number(row.index || 0) === Number(selectedTableRow?.index || 0) ? "selected" : ""}>#${Number(row.index || 0)} · ${escapeHtml(row.label || `Строка ${row.index}`)}</option>`).join("")}</select></label>
@@ -1886,7 +1889,7 @@
<div><span>Текущее значение</span><strong>${escapeHtml(mappingDisplayValue(quickCellSource?.value, quickCellSource?.kind || "text"))}</strong><code>${escapeHtml(quickCellKey || "—")}</code></div>
<button type="button" data-map-quick-cell-link ${quickLinkEnabled ? "" : "disabled"}>${targetField ? `Связать с ${escapeHtml(targetField.name || "полем")}` : "Выберите поле vMix"}</button>
</div>
</section>`;
</details>`;
const rowSearch = String(state.mappingTableRowSearch || "").trim().toLowerCase();
const allRows = selectedTable?.rows || [];
const filteredRows = allRows.filter((row) => {
@@ -1908,8 +1911,8 @@
}).join("")}
</tr>`).join("");
tableMarkup = `
<section class="hockey-map-table-picker">
<header><div><span>ТАБЛИЦА SQL</span><strong>Выберите конкретную строку и столбец</strong></div><b>${Number(selectedTable?.row_count || 0)} строк</b></header>
<details class="hockey-map-table-picker" data-map-sql-table-details ${state.mappingSqlTableOpen ? "open" : ""}>
<summary><div><span>ТАБЛИЦА SQL</span><strong>Выберите конкретную строку и столбец</strong></div><b>${Number(selectedTable?.row_count || 0)} строк</b></summary>
<div class="hockey-map-table-controls">
<select data-map-table-source>${tables.map((table) => `<option value="${escapeHtml(table.code)}" ${String(table.code) === String(selectedTable?.code) ? "selected" : ""}>${escapeHtml(table.name || table.code)} · ${Number(table.row_count || 0)} строк</option>`).join("")}</select>
<label><span>⌕</span><input data-map-table-row-search value="${escapeHtml(state.mappingTableRowSearch)}" placeholder="Поиск строки по любому значению…"></label>
@@ -1917,7 +1920,7 @@
${selectedTable?.skipped ? `<div class="hockey-directory-empty">Не хватает параметров: ${escapeHtml((selectedTable.missing || []).map((x) => `:${x}`).join(", "))}</div>` : selectedTable?.error ? `<div class="hockey-directory-empty is-error">${escapeHtml(selectedTable.error)}</div>` : `
<div class="hockey-map-table-grid" style="height:${tableGridHeight}px"><table><thead><tr><th>Строка</th>${columns.map((column) => `<th><strong>${escapeHtml(column.label || column.key)}${column.localized ? ` <i class="hockey-map-lang-auto">AUTO ${String(column.language || selectedTable?.language || "ru").toUpperCase()}</i>` : ""}</strong><small>${escapeHtml(column.key)}</small></th>`).join("")}</tr></thead><tbody>${cells || `<tr><td colspan="${columns.length + 1}">Нет строк</td></tr>`}</tbody></table></div>
${filteredRows.length > visibleRows.length ? `<small class="hockey-map-table-limit">Показаны первые ${visibleRows.length} из ${filteredRows.length} строк. Используйте поиск строки.</small>` : ""}`}
</section>`;
</details>`;
}
const targetHint = targetField
@@ -2160,11 +2163,14 @@
const isUsed = Boolean(selectedDevice?.mapping && Number(selectedDevice.mapping.source_profile_id || selectedDevice.mapping.id || 0) === Number(item.id));
return `
<div class="hockey-mapping-profile-row ${profile?.id === item.id ? "is-active" : ""}">
<button type="button" class="hockey-mapping-profile ${profile?.id === item.id ? "is-active" : ""}" data-mapping-profile="${item.id}">
<button type="button" class="hockey-mapping-profile ${profile?.id === item.id ? "is-active" : ""}" data-mapping-profile="${item.id}" title="Открыть и редактировать конфиг">
<strong>${escapeHtml(item.name)}</strong>
<small>v${Number(item.version || 1)} · ${Number(item.field_count || 0)} связей${item.created_by ? ` · ${escapeHtml(item.created_by)}` : ""}</small>
</button>
<button type="button" class="hockey-map-use-profile ${isUsed ? "is-used" : ""}" data-map-use-profile="${item.id}" ${isUsed ? 'title="Этот конфиг уже выбран на вашем Agent"' : ""}>${isUsed ? "✓ Используется" : "Применить к моему Agent"}</button>
<div class="hockey-map-profile-actions">
<button type="button" class="hockey-map-profile-icon hockey-map-use-profile ${isUsed ? "is-used" : ""}" data-map-use-profile="${item.id}" title="${isUsed ? "Этот конфиг уже используется на моём Agent" : "Применить к моему Agent"}" aria-label="${isUsed ? "Используется на моём Agent" : "Применить к моему Agent"}">${isUsed ? "✓" : "▶"}</button>
<button type="button" class="hockey-map-profile-icon hockey-map-copy-profile" data-map-copy-profile="${item.id}" title="Копировать конфиг" aria-label="Копировать конфиг">⧉</button>
</div>
</div>`;
}).join("") : `<div class="hockey-directory-empty">Mapping-конфигов ещё нет.</div>`;
@@ -2220,7 +2226,7 @@
<h3>vMix проекты</h3>
<div class="hockey-mapping-devices">${deviceRows}</div>
<h3>Конфиги Mapping</h3>
<div class="hockey-map-config-hint">Выберите любой готовый конфиг — свой или чужой — и нажмите «Применить к моему Agent». Он будет использован для текущего открытого матча.</div>
<div class="hockey-map-config-hint">▶ применяет готовый конфиг к вашему Agent. ⧉ создаёт независимую копию со всеми связями — её можно переименовать, пересчитать под другой vMix и отредактировать.</div>
<div class="hockey-mapping-profiles">${profileButtons}</div>
<details class="hockey-mapping-create-wrap">
<summary>+ Создать новый конфиг из vMix</summary>
@@ -2456,6 +2462,27 @@
renderMapping();
}));
modal.querySelectorAll("[data-map-copy-profile]").forEach((button) => button.addEventListener("click", async (event) => {
event.stopPropagation();
const profileId = Number(button.dataset.mapCopyProfile || 0);
if (!profileId) return;
const source = (state.mappingProfiles?.profiles || []).find((item) => Number(item.id) === profileId);
button.disabled = true;
button.textContent = "…";
try {
const created = await request(`/api/hockey/admin/vmix-mapping/profiles/${profileId}/duplicate`, { method: "POST" });
state.mappingSelectedInputKey = "";
state.mappingTargetField = "";
state.mappingSelectedSourceKey = "";
state.mappingVmixScrollTop = 0;
await loadMapping(created.id);
setStatus(`Копия «${created.name}» создана · ${Number(created.copied_fields || created.fields?.length || 0)} связей. Можно переименовать, считать структуру другого vMix и редактировать.`);
} catch (error) {
setStatus(error.message || `Не удалось скопировать конфиг «${source?.name || profileId}»`, true);
}
renderMapping();
}));
modal.querySelectorAll("[data-mapping-profile]").forEach((button) => button.addEventListener("click", async () => {
try {
state.mappingActiveProfile = await request(`/api/hockey/admin/vmix-mapping/profiles/${button.dataset.mappingProfile}`);
@@ -2536,6 +2563,16 @@
state.mappingTableRowSearch = "";
renderMapping();
});
modal.querySelectorAll("[data-map-data-group]").forEach((details) => details.addEventListener("toggle", (event) => {
const key = String(event.currentTarget.dataset.mapDataGroup || "");
if (key) state.mappingOpenDataGroups[key] = Boolean(event.currentTarget.open);
}));
modal.querySelector("[data-map-sql-cell-details]")?.addEventListener("toggle", (event) => {
state.mappingSqlCellOpen = Boolean(event.currentTarget.open);
});
modal.querySelector("[data-map-sql-table-details]")?.addEventListener("toggle", (event) => {
state.mappingSqlTableOpen = Boolean(event.currentTarget.open);
});
modal.querySelector("[data-map-quick-table-source]")?.addEventListener("change", (event) => {
state.mappingSelectedTableSource = event.currentTarget.value;
state.mappingSelectedTableRow = 1;
@@ -2817,7 +2854,14 @@
if (!deviceId) return setStatus("Выберите Agent, из которого нужно считать структуру.", true), renderMapping();
try {
const result = await request(`/api/hockey/admin/vmix-mapping/profiles/${state.mappingActiveProfile.id}/inventory`, { method: "POST", body: JSON.stringify({ device_id: deviceId }) });
await loadMapping(result.id); state.mappingSelectedInputKey = ""; state.mappingVmixScrollTop = 0; setStatus("Структура vMix обновлена.");
await loadMapping(result.id); state.mappingSelectedInputKey = ""; state.mappingVmixScrollTop = 0;
const report = result.refresh_report || {};
const preserved = Number(report.preserved || 0);
const total = Number(report.total_links || preserved);
const addedInputs = Number(report.new_inputs || 0);
const addedFields = Number(report.new_fields || 0);
const unresolved = Number(report.unresolved || 0);
setStatus(`Структура vMix обновлена · связи ${preserved}/${total} сохранены${addedInputs ? ` · новых Inputs ${addedInputs}` : ""}${addedFields ? ` · новых полей ${addedFields}` : ""}${unresolved ? ` · требуют проверки ${unresolved}` : ""}.`, unresolved > 0);
} catch (error) { setStatus(error.message, true); }
renderMapping();
});

View File

@@ -0,0 +1,157 @@
from __future__ import annotations
import asyncio
import json
from pathlib import Path
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")
def test_mapping_heavy_data_sections_are_collapsed_by_default() -> None:
assert 'data-map-sql-cell-details' in JS
assert 'data-map-sql-table-details' in JS
assert 'mappingSqlCellOpen: false' in JS
assert 'mappingSqlTableOpen: false' in JS
assert 'mappingOpenDataGroups: {}' in JS
assert 'data-map-data-group="${escapeHtml(category.name)}"' in JS
assert 'index < 3 || query' not in JS
assert 'query || state.mappingOpenDataGroups[category.name]' in JS
assert '.hockey-map-sql-cell-quick:not([open])' in CSS
assert '.hockey-map-table-picker:not([open])' in CSS
def test_rescan_preserves_links_and_only_adds_new_inventory_items(tmp_path: Path) -> None:
database = LocalTestDatabase(tmp_path / "build75-rescan.sqlite3")
database.create_all()
hub = VmixAgentHub(database) # type: ignore[arg-type]
user = HockeyUser(id="admin-1", login="admin", display_name="Admin")
old_inventory = {
"fingerprint": "a" * 64,
"inputs": [{
"key": "score-old-key", "number": "3", "title": "SCORE BUG", "type": "GT",
"fields": [
{"name": "HomeScore.Text", "type": "text", "index": "0"},
{"name": "OldOnly.Text", "type": "text", "index": "1"},
],
}],
"input_count": 1, "field_count": 2,
}
new_inventory = {
"fingerprint": "b" * 64,
"inputs": [
{
"key": "score-new-key", "number": "9", "title": "SCORE BUG", "type": "GT",
"fields": [
{"name": "HomeScore.Text", "type": "text", "index": "4"},
{"name": "AwayScore.Text", "type": "text", "index": "5"},
],
},
{
"key": "lower-third-key", "number": "10", "title": "PLAYER LOWER THIRD", "type": "GT",
"fields": [{"name": "Name.Text", "type": "text", "index": "0"}],
},
],
"input_count": 2, "field_count": 3,
}
with database.session() as session:
profile = VmixMappingProfile(
name="KHL MAIN", description="", project_fingerprint="a" * 64,
inventory_json=json.dumps(old_inventory), version=5, active=True,
created_by="admin", updated_by="admin",
)
session.add(profile)
session.flush()
profile_id = profile.id
session.add_all([
VmixMappingField(
profile_id=profile.id, graphic="score", data_key="game.home_score",
vmix_input_key="score-old-key", vmix_input_number="", vmix_input_title="SCORE BUG",
vmix_field="HomeScore.Text", field_type="text", rule_json="{}", enabled=True, sort_order=0,
),
VmixMappingField(
profile_id=profile.id, graphic="score", data_key="game.legacy",
vmix_input_key="score-old-key", vmix_input_number="", vmix_input_title="SCORE BUG",
vmix_field="OldOnly.Text", field_type="text", rule_json="{}", enabled=True, sort_order=1,
),
])
session.add(VmixDevice(
device_uuid="GFX-BUILD75", device_secret_hash="x" * 64, name="GFX",
project_fingerprint="b" * 64, project_inventory_json=json.dumps(new_inventory),
project_input_count=2, project_field_count=3, vmix_connected=True,
))
result = asyncio.run(hub.refresh_mapping_inventory(profile_id, "GFX-BUILD75", user))
report = result["refresh_report"]
assert report["total_links"] == 2
assert report["preserved"] == 1
assert report["unresolved"] == 1
assert report["matched_by"]["title"] == 1
assert report["new_inputs"] == 1
assert report["new_fields"] == 2
assert result["inventory"]["input_count"] == 2
with database.session() as session:
rows = list(session.scalars(
select(VmixMappingField)
.where(VmixMappingField.profile_id == profile_id)
.order_by(VmixMappingField.sort_order)
))
# Existing successful link is rebound to the newly scanned Input key.
assert rows[0].vmix_input_key == "score-new-key"
assert rows[0].vmix_field == "HomeScore.Text"
# Temporarily missing target is not deleted, protecting the operator's work.
assert rows[1].vmix_input_key == "score-old-key"
assert rows[1].vmix_field == "OldOnly.Text"
assert len(rows) == 2
def test_rescan_can_replace_hidden_runtime_copy_of_same_config(tmp_path: Path) -> None:
database = LocalTestDatabase(tmp_path / "build75-hidden-runtime.sqlite3")
database.create_all()
hub = VmixAgentHub(database) # type: ignore[arg-type]
user = HockeyUser(id="admin-1", login="admin", display_name="Admin")
old_inventory = {"inputs": [{"key": "old", "number": "1", "title": "TITLE", "fields": [{"name": "Name.Text", "type": "text"}]}]}
new_inventory = {"inputs": [{"key": "new", "number": "2", "title": "TITLE", "fields": [{"name": "Name.Text", "type": "text"}]}], "input_count": 1, "field_count": 1}
with database.session() as session:
source = VmixMappingProfile(
name="SHARED", description="", project_fingerprint="a" * 64,
inventory_json=json.dumps(old_inventory), version=1, 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="lt", data_key="player.name",
vmix_input_key="old", vmix_input_number="", vmix_input_title="TITLE",
vmix_field="Name.Text", field_type="text", rule_json="{}", enabled=True, sort_order=0,
))
runtime = VmixMappingProfile(
name=hub._runtime_mapping_profile_name(source.id, "b" * 64),
description="runtime", project_fingerprint="b" * 64,
inventory_json=json.dumps(new_inventory), version=1, active=True,
created_by="admin", updated_by="admin",
)
session.add(runtime); session.flush(); runtime_id = runtime.id
session.add(VmixDevice(
device_uuid="GFX-HIDDEN-75", device_secret_hash="x" * 64, name="GFX",
project_fingerprint="b" * 64, project_inventory_json=json.dumps(new_inventory),
project_input_count=1, project_field_count=1, vmix_connected=True,
))
result = asyncio.run(hub.refresh_mapping_inventory(source_id, "GFX-HIDDEN-75", user))
assert result["project_fingerprint"] == "b" * 64
assert result["fields"][0]["vmix_input_key"] == "new"
with database.session() as session:
runtime = session.get(VmixMappingProfile, runtime_id)
assert runtime.active is False

View File

@@ -0,0 +1,130 @@
from __future__ import annotations
import asyncio
import json
from pathlib import Path
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_mapping_config_list_is_compact_and_has_copy_action() -> None:
assert 'data-map-copy-profile="${item.id}"' in JS
assert 'title="Копировать конфиг"' in JS
assert '>⧉</button>' in JS
assert '${isUsed ? "" : ""}' in JS
assert 'hockey-map-profile-actions' in CSS
assert 'hockey-map-profile-icon' in CSS
assert 'width:28px;height:28px' in CSS
assert '/profiles/${profileId}/duplicate' in JS
assert 'Копия «${created.name}» создана' in JS
def test_duplicate_creates_independent_inactive_profile_with_all_links(tmp_path: Path) -> None:
database = LocalTestDatabase(tmp_path / "build76-copy.sqlite3")
database.create_all()
hub = VmixAgentHub(database) # type: ignore[arg-type]
user = HockeyUser(id="admin-1", login="admin", display_name="Admin")
inventory = {
"inputs": [{
"key": "score", "number": "3", "title": "SCORE", "type": "GT",
"fields": [{"name": "Home.Text", "type": "text"}, {"name": "BG.Color", "type": "color"}],
}],
"input_count": 1,
"field_count": 2,
}
with database.session() as session:
source = VmixMappingProfile(
name="KHL MAIN", description="base package", project_fingerprint="a" * 64,
inventory_json=json.dumps(inventory), version=7, active=True,
created_by="other-admin", updated_by="other-admin",
)
session.add(source); session.flush(); source_id = source.id
session.add_all([
VmixMappingField(
profile_id=source.id, graphic="score", data_key="game.home_score",
vmix_input_key="score", vmix_input_number="", vmix_input_title="SCORE",
vmix_field="Home.Text", field_type="text", rule_json='{"mode":"upper"}', enabled=True, sort_order=0,
),
VmixMappingField(
profile_id=source.id, graphic="score", data_key="home.color",
vmix_input_key="score", vmix_input_number="", vmix_input_title="SCORE",
vmix_field="BG.Color", field_type="color", rule_json="{}", enabled=False, sort_order=1,
),
])
first = asyncio.run(hub.duplicate_mapping_profile(source_id, user))
second = asyncio.run(hub.duplicate_mapping_profile(source_id, user))
assert first["name"] == "KHL MAIN — копия"
assert second["name"] == "KHL MAIN — копия (2)"
assert first["active"] is False
assert first["version"] == 1
assert first["description"] == "base package"
assert first["copied_from_profile_id"] == source_id
assert first["copied_fields"] == 2
assert len(first["fields"]) == 2
assert first["fields"][0]["rule"] == {"mode": "upper"}
assert first["fields"][1]["enabled"] is False
with database.session() as session:
source = session.get(VmixMappingProfile, source_id)
clone = session.get(VmixMappingProfile, first["id"])
assert source.active is True
assert source.version == 7
assert clone.active is False
assert clone.project_fingerprint == source.project_fingerprint
assert len(list(session.scalars(select(VmixMappingField).where(VmixMappingField.profile_id == clone.id)))) == 2
def test_inactive_copy_can_rescan_to_project_that_already_has_live_mapping(tmp_path: Path) -> None:
database = LocalTestDatabase(tmp_path / "build76-rescan-copy.sqlite3")
database.create_all()
hub = VmixAgentHub(database) # type: ignore[arg-type]
user = HockeyUser(id="admin-1", login="admin", display_name="Admin")
old_inventory = {"inputs": [{"key": "old", "number": "1", "title": "TITLE", "fields": [{"name": "Name.Text", "type": "text"}]}]}
new_inventory = {"inputs": [{"key": "new", "number": "2", "title": "TITLE", "fields": [{"name": "Name.Text", "type": "text"}, {"name": "Photo.Source", "type": "image"}]}], "input_count": 1, "field_count": 2}
with database.session() as session:
draft = VmixMappingProfile(
name="COPY DRAFT", description="", project_fingerprint="a" * 64,
inventory_json=json.dumps(old_inventory), version=1, active=False,
created_by="admin", updated_by="admin",
)
live = VmixMappingProfile(
name="OTHER LIVE", description="", project_fingerprint="b" * 64,
inventory_json=json.dumps(new_inventory), version=1, active=True,
created_by="admin", updated_by="admin",
)
session.add_all([draft, live]); session.flush(); draft_id = draft.id; live_id = live.id
session.add(VmixMappingField(
profile_id=draft.id, graphic="lt", data_key="player.name",
vmix_input_key="old", vmix_input_number="", vmix_input_title="TITLE",
vmix_field="Name.Text", field_type="text", rule_json="{}", enabled=True, sort_order=0,
))
session.add(VmixDevice(
device_uuid="GFX-COPY-76", device_secret_hash="x" * 64, name="GFX Copy",
project_fingerprint="b" * 64, project_inventory_json=json.dumps(new_inventory),
project_input_count=1, project_field_count=2, vmix_connected=True,
))
result = asyncio.run(hub.refresh_mapping_inventory(draft_id, "GFX-COPY-76", user))
assert result["project_fingerprint"] == "b" * 64
assert result["active"] is False
assert result["fields"][0]["vmix_input_key"] == "new"
assert result["refresh_report"]["new_fields"] == 1
with database.session() as session:
assert session.get(VmixMappingProfile, live_id).active is True
def test_backend_exposes_duplicate_endpoint() -> None:
assert '"/api/hockey/admin/vmix-mapping/profiles/{profile_id}/duplicate"' in BRIDGE
assert 'duplicate_mapping_profile' in BRIDGE