Files
hockey_new/hockey_data/static/admin-directories.js

3212 lines
206 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

(() => {
"use strict";
const state = {
language: localStorage.getItem("hockey.language") || "ru",
isAdmin: false,
open: false,
section: "teams",
teams: null,
penalties: null,
timerRules: null,
timerRulesVmixInventory: null,
vmixSettings: null,
vmixChannel: null,
vmixActiveKey: "",
vmixPanel: "configs",
vmixSourceFields: null,
vmixImportPreview: null,
mappingDevices: null,
mappingProfiles: null,
mappingActiveProfile: null,
mappingCreateDeviceId: "",
mappingSelectedInputKey: "",
mappingTargetField: "",
mappingSelectedSourceKey: "",
mappingDataSearch: "",
mappingInputSearch: "",
mappingFieldFilter: "text",
mappingHideLinked: false,
mappingRuleField: "",
mappingVmixScrollTop: 0,
mappingSelectedTableSource: "",
mappingSelectedTableRow: 1,
mappingSelectedTableColumn: "",
mappingTableRowSearch: "",
mappingSqlCellOpen: false,
mappingSqlTableOpen: false,
mappingOpenDataGroups: {},
mappingTestDeviceId: "",
mappingWorkspaceTab: "links",
mappingCatalog: null,
mappingContextVariables: null,
mappingSqlSources: null,
mappingSelectedSqlSourceId: null,
mappingSqlPreview: null,
mappingSqlDraft: null,
mappingLoadErrors: {},
mappingContextRefreshTimer: null,
countries: null,
players: null,
referees: null,
coaches: null,
teamLeague: "",
directorySearch: "",
editingTeam: null,
editingPenalty: null,
editingRecord: null,
status: "",
statusError: false,
modal: null,
};
const escapeHtml = (value) => String(value ?? "")
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
const normalizeTeamColorHex = (value) => {
let color = String(value ?? "").trim().toUpperCase();
if (!color) return "";
if (!color.startsWith("#")) color = `#${color}`;
return /^#[0-9A-F]{6}$/.test(color) ? color : "";
};
const teamColorPreview = (value) => {
const color = normalizeTeamColorHex(value);
return color || "#FFFFFF";
};
const countryFlagMarkup = (item) => {
const code = String(item?.iso2 || item?.country_code || "").trim().toLowerCase().replace(/[^a-z]/g, "").slice(0, 2);
const url = String(item?.flag_url || (code.length === 2 ? `/hockey-assets/flags/${code}.svg` : ""));
if (url) return `<img src="${escapeHtml(url)}" alt="${escapeHtml(code.toUpperCase())}" title="${escapeHtml(item?.country_name || item?.name || code.toUpperCase())}" loading="lazy" onerror="this.hidden=true;this.nextElementSibling.hidden=false"><span class="hockey-directory-flag-fallback" hidden>${escapeHtml(item?.flag || "")}</span>`;
return escapeHtml(item?.flag || "");
};
async function request(url, options = {}) {
const response = await fetch(url, {
credentials: "same-origin",
...options,
headers: {
...(options.body ? { "Content-Type": "application/json" } : {}),
...(options.headers || {}),
},
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(data.detail || `Ошибка запроса: ${response.status}`);
}
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;
const tournament = data.hockey?.selected_tournament || null;
const operatorSession = data.hockey?.operator_session || data.hockey?.session || null;
return {
game,
tournament,
tournamentLevel: String(tournament?.level || game?.league_key || game?.level || "").toLowerCase(),
tournamentId: String(
game?.tournament_external_id
|| localStorage.getItem("hockey.selectedTournamentId")
|| ""
),
token: String(
operatorSession?.channel_key
|| operatorSession?.token
|| state.vmixChannel?.channel_key
|| state.vmixChannel?.token
|| localStorage.getItem("hockey.operatorSessionToken")
|| ""
),
language: state.language === "en" ? "en" : "ru",
};
}
function isKhlTournamentSelected() {
const context = selectedContext();
return context.tournamentLevel === "khl";
}
function patchRuntimePenalties(items) {
const activeItems = (items || [])
.filter((item) => item.active !== false)
.map((item) => ({
...item,
name: state.language === "en"
? (item.name_en || item.name_ru || item.code)
: (item.name_ru || item.name_en || item.code),
}));
window.UIBuilderRuntime?.patchData?.({
hockey: {
penalty_directory: activeItems,
},
});
}
async function loadPenalties(includeInactive = false) {
const params = new URLSearchParams({ language: state.language });
if (includeInactive) params.set("include_inactive", "true");
const data = await request(`/api/hockey/directories/penalties?${params}`);
state.penalties = data;
patchRuntimePenalties(data.items);
return data;
}
async function loadTimerRules() {
const deviceId = String(localStorage.getItem("hockey.vmix.selected_device") || "").trim();
const [settings, inventory] = await Promise.all([
request("/api/hockey/settings"),
request(`/api/hockey/agents/vmix-inventory${deviceId ? `?device_id=${encodeURIComponent(deviceId)}` : ""}`).catch(() => null),
]);
state.timerRules = settings;
state.timerRulesVmixInventory = inventory;
return state.timerRules;
}
async function loadTeams() {
const context = selectedContext();
const params = new URLSearchParams({
language: state.language,
include_inactive: "true",
});
if (context.tournamentId) {
params.set("tournament_external_id", context.tournamentId);
} else if (state.teamLeague) {
params.set("league_key", state.teamLeague);
}
state.teams = await request(`/api/hockey/directories/teams?${params}`);
if (context.tournamentId) {
state.teamLeague = state.teams.selected_league || "";
}
return state.teams;
}
function setStatus(message, isError = false) {
state.status = String(message || "");
state.statusError = isError;
}
function statusMarkup() {
if (!state.status) return "";
return `<div class="hockey-directory-status ${state.statusError ? "is-error" : ""}">${escapeHtml(state.status)}</div>`;
}
function isMappingSection(section = state.section) {
return ["mapping", "mapping_context", "mapping_sql"].includes(String(section || ""));
}
function mappingTabFromSection(section = state.section) {
if (section === "mapping_context") return "context";
if (section === "mapping_sql") return "sql";
return "links";
}
function ensureModal() {
if (state.modal) return state.modal;
const modal = document.createElement("div");
modal.className = "hockey-directory-backdrop hidden";
modal.addEventListener("mousedown", (event) => {
if (event.target === modal) closeModal();
});
document.body.appendChild(modal);
state.modal = modal;
return modal;
}
function closeModal() {
state.open = false;
state.editingTeam = null;
state.editingPenalty = null;
state.editingRecord = null;
state.status = "";
ensureModal().classList.add("hidden");
document.body.classList.remove("hockey-admin-settings-open");
window.dispatchEvent(new CustomEvent("hockey:admin-settings-visibility", { detail: { open: false } }));
}
function shellMarkup(content) {
return `
<section class="hockey-directory-modal ${state.section === "khl_site" ? "is-khl-site" : ""} ${isMappingSection() ? "is-mapping" : ""}" role="dialog" aria-modal="true" aria-label="Справочники базы данных">
<header>
<div>
<span>НАСТРОЙКИ</span>
<strong>Общие настройки хоккея</strong>
</div>
<button type="button" class="hockey-directory-close" data-directory-close aria-label="Закрыть">×</button>
</header>
<nav>
<button type="button" data-directory-section="teams" class="${state.section === "teams" ? "is-active" : ""}">Команды</button>
<button type="button" data-directory-section="players" class="${state.section === "players" ? "is-active" : ""}">Игроки</button>
<button type="button" data-directory-section="coaches" class="${state.section === "coaches" ? "is-active" : ""}">Тренеры</button>
<button type="button" data-directory-section="referees" class="${state.section === "referees" ? "is-active" : ""}">Судьи</button>
<button type="button" data-directory-section="countries" class="${state.section === "countries" ? "is-active" : ""}">Страны</button>
<button type="button" data-directory-section="timer_rules" class="${state.section === "timer_rules" ? "is-active" : ""}">Таймеры и составы</button>
<button type="button" data-directory-section="penalties" class="${state.section === "penalties" ? "is-active" : ""}">Удаления</button>
<button type="button" data-directory-section="vmix" class="${state.section === "vmix" ? "is-active" : ""}">vMix JSON</button>
<button type="button" data-directory-section="mapping" class="${state.section === "mapping" ? "is-active" : ""}">Mapping</button>
<button type="button" data-directory-section="mapping_context" class="${state.section === "mapping_context" ? "is-active" : ""}">Переменные</button>
<button type="button" data-directory-section="mapping_sql" class="${state.section === "mapping_sql" ? "is-active" : ""}">SQL источники</button>
${isKhlTournamentSelected() ? `<button type="button" data-directory-section="khl_site" class="${state.section === "khl_site" ? "is-active" : ""}">КХЛ сайт</button>` : ""}
</nav>
<div class="hockey-directory-content">${content}</div>
</section>
`;
}
function leagueOptions(selected) {
const leagues = state.teams?.leagues || [];
return leagues.map((item) => `
<option value="${escapeHtml(item.value)}" ${item.value === selected ? "selected" : ""}>${escapeHtml(item.label)} (${escapeHtml(item.value.toUpperCase())})</option>
`).join("");
}
function renderTeams() {
const data = state.teams || { items: [], leagues: [] };
const context = selectedContext();
const lockedLeague = context.tournamentId ? data.selected_league : "";
const editing = state.editingTeam;
const formLeague = editing?.league_key || lockedLeague || state.teamLeague || "";
const formTournament = editing?.tournament_external_id || context.tournamentId || "";
const leagueControl = lockedLeague
? `<div class="hockey-directory-context"><span>Лига выбранного матча</span><strong>${escapeHtml(data.selected_league_label || lockedLeague.toUpperCase())}</strong></div>`
: `
<label class="hockey-directory-filter">
<span>Показать лигу</span>
<select data-team-league-filter>
<option value="">Все лиги</option>
${leagueOptions(state.teamLeague)}
</select>
</label>
`;
const rows = data.items.length
? data.items.map((item) => `
<tr class="${item.active ? "" : "is-inactive"}">
<td><code>${escapeHtml(item.external_id)}</code></td>
<td><strong>${escapeHtml(item.name_ru || "—")}</strong><small>${escapeHtml(item.short_name_ru || "")}</small></td>
<td><strong>${escapeHtml(item.name_en || "—")}</strong><small>${escapeHtml(item.short_name_en || "")}</small></td>
<td>${escapeHtml(item.city_ru || item.city_en || "—")}</td>
<td>${item.color_hex ? `<span class="hockey-team-color-cell"><i style="--team-color:${escapeHtml(item.color_hex)}"></i><code>${escapeHtml(item.color_hex)}</code></span>` : "—"}</td>
<td><span class="hockey-directory-source">${escapeHtml(item.source === "manual" ? "изменено" : "Stat2TV")}</span></td>
<td><button type="button" class="hockey-directory-edit" data-edit-team="${item.id}">Изменить</button></td>
</tr>
`).join("")
: `<tr><td colspan="7" class="hockey-directory-empty">Для этой лиги команды ещё не загружены. Нажмите «Обновить из API».</td></tr>`;
const content = `
<div class="hockey-directory-toolbar">
${leagueControl}
<div class="hockey-directory-toolbar-actions">
<button type="button" data-sync-teams>Обновить из API</button>
<button type="button" class="is-accent" data-new-team>Добавить команду</button>
</div>
</div>
<p class="hockey-directory-note">Команды формируются из матчей Stat2TV и разделяются по лигам. При выбранном матче показывается только его лига.</p>
${statusMarkup()}
<div class="hockey-directory-grid">
<div class="hockey-directory-table-wrap">
<table>
<thead><tr><th>ID API</th><th>Русский</th><th>English</th><th>Город</th><th>Цвет</th><th>Источник</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
<form class="hockey-directory-form" data-team-form>
<h3>${editing ? "Редактирование команды" : "Новая команда"}</h3>
<label><span>ID команды из API</span><input name="external_id" maxlength="64" value="${escapeHtml(editing?.external_id || "")}" ${editing ? "readonly" : "required"}></label>
<label><span>Лига</span><input name="league_key" list="hockey-league-list" maxlength="32" value="${escapeHtml(formLeague)}" ${lockedLeague ? "readonly" : "required"}></label>
<datalist id="hockey-league-list">${leagueOptions(formLeague)}</datalist>
<input type="hidden" name="tournament_external_id" value="${escapeHtml(formTournament)}">
<div class="hockey-directory-form-columns">
<label><span>Название (русский)</span><input name="name_ru" maxlength="500" value="${escapeHtml(editing?.name_ru || "")}"></label>
<label><span>Name (English)</span><input name="name_en" maxlength="500" value="${escapeHtml(editing?.name_en || "")}"></label>
<label><span>Короткое (русский)</span><input name="short_name_ru" maxlength="255" value="${escapeHtml(editing?.short_name_ru || "")}"></label>
<label><span>Short name (English)</span><input name="short_name_en" maxlength="255" value="${escapeHtml(editing?.short_name_en || "")}"></label>
<label><span>Город (русский)</span><input name="city_ru" maxlength="255" value="${escapeHtml(editing?.city_ru || "")}"></label>
<label><span>City (English)</span><input name="city_en" maxlength="255" value="${escapeHtml(editing?.city_en || "")}"></label>
</div>
<label><span>Логотип (URL)</span><input name="logo_url" maxlength="2000" value="${escapeHtml(editing?.logo_url || "")}"></label>
<div class="hockey-team-color-field">
<label>
<span>Цвет команды</span>
<div class="hockey-team-color-control">
<button type="button" class="hockey-team-color-picker-wrap" data-team-color-open title="Выбрать цвет">
<i data-team-color-preview style="--team-color:${escapeHtml(teamColorPreview(editing?.color_hex))}"></i>
<input type="color" data-team-color-picker value="${escapeHtml(teamColorPreview(editing?.color_hex))}" tabindex="-1" aria-label="Выбрать цвет команды">
</button>
<input name="color_hex" data-team-color-text maxlength="7" placeholder="#282E66" value="${escapeHtml(editing?.color_hex || "")}" autocomplete="off" spellcheck="false">
<button type="button" class="hockey-team-color-clear" data-team-color-clear title="Очистить цвет">×</button>
</div>
<small>HEX, например #282E66</small>
</label>
</div>
<label class="hockey-directory-check"><input type="checkbox" name="active" ${editing?.active === false ? "" : "checked"}><span>Активна</span></label>
<div class="hockey-directory-form-actions">
${editing ? `<button type="button" data-cancel-team>Отмена</button>` : ""}
<button type="submit" class="is-accent">${editing ? "Сохранить" : "Добавить"}</button>
</div>
</form>
</div>
`;
const modal = ensureModal();
modal.innerHTML = shellMarkup(content);
bindShell();
bindTeams();
}
function renderPenalties() {
const editing = state.editingPenalty;
const items = state.penalties?.items || [];
const rows = items.length
? items.map((item) => `
<tr class="${item.active ? "" : "is-inactive"}">
<td><code>${escapeHtml(item.code)}</code></td>
<td>${escapeHtml(item.name_ru)}</td>
<td>${escapeHtml(item.name_en)}</td>
<td><strong>${escapeHtml(item.default_preset)}</strong></td>
<td>${item.team_penalty ? "Да" : "Нет"}</td>
<td>${item.active ? "Да" : "Нет"}</td>
<td><button type="button" class="hockey-directory-edit" data-edit-penalty="${item.id}">Изменить</button></td>
</tr>
`).join("")
: `<tr><td colspan="7" class="hockey-directory-empty">Справочник удалений пуст.</td></tr>`;
const content = `
<div class="hockey-directory-toolbar">
<div class="hockey-directory-context"><span>Общий справочник</span><strong>Для всех видов хоккея</strong></div>
<button type="button" class="is-accent" data-new-penalty>Добавить удаление</button>
</div>
<p class="hockey-directory-note">Активные записи автоматически появляются в списке нарушений основного меню.</p>
${statusMarkup()}
<div class="hockey-directory-grid">
<div class="hockey-directory-table-wrap">
<table>
<thead><tr><th>Код</th><th>Русский</th><th>English</th><th>Штраф</th><th>Командное</th><th>Активно</th><th></th></tr></thead>
<tbody>${rows}</tbody>
</table>
</div>
<form class="hockey-directory-form" data-penalty-form>
<h3>${editing ? "Редактирование удаления" : "Новое удаление"}</h3>
<label><span>Код</span><input name="code" maxlength="64" required value="${escapeHtml(editing?.code || "")}"></label>
<label><span>Название на русском</span><input name="name_ru" maxlength="500" required value="${escapeHtml(editing?.name_ru || "")}"></label>
<label><span>Name in English</span><input name="name_en" maxlength="500" required value="${escapeHtml(editing?.name_en || "")}"></label>
<div class="hockey-directory-form-columns">
<label><span>Штраф по умолчанию</span><select name="default_preset">
${["2", "2+2", "4", "5", "5+20", "10"].map((value) => `<option value="${value}" ${(editing?.default_preset || "2") === value ? "selected" : ""}>${value}</option>`).join("")}
</select></label>
<label><span>Порядок</span><input name="sort_order" type="number" min="0" max="100000" value="${escapeHtml(editing?.sort_order ?? "")}"></label>
</div>
<label class="hockey-directory-check"><input type="checkbox" name="team_penalty" ${editing?.team_penalty ? "checked" : ""}><span>Командное удаление — игрок не выбирается</span></label>
<label class="hockey-directory-check"><input type="checkbox" name="active" ${editing?.active === false ? "" : "checked"}><span>Показывать в основном меню</span></label>
<div class="hockey-directory-form-actions">
${editing ? `<button type="button" data-cancel-penalty>Отмена</button>` : ""}
<button type="submit" class="is-accent">${editing ? "Сохранить" : "Добавить"}</button>
</div>
</form>
</div>
`;
const modal = ensureModal();
modal.innerHTML = shellMarkup(content);
bindShell();
bindPenalties();
}
const genericSections = {
countries: { title: "Страны", endpoint: "countries", syncLabel: "Обновить players-countries.xml" },
players: { title: "Игроки", endpoint: "players", syncLabel: "Загрузить игроков лиги" },
referees: { title: "Судьи", endpoint: "referees", syncLabel: "Загрузить судей лиги" },
coaches: { title: "Тренеры", endpoint: "coaches", syncLabel: "Загрузить тренеров лиги" },
};
async function loadGeneric(section) {
const config = genericSections[section];
if (!config) return null;
const context = selectedContext();
const params = new URLSearchParams({ language: state.language, include_inactive: "true" });
if (state.directorySearch) params.set("search", state.directorySearch);
if (section !== "countries" && context.tournamentId) params.set("tournament_external_id", context.tournamentId);
const data = await request(`/api/hockey/directories/${config.endpoint}?${params}`);
state[section] = data;
return data;
}
function genericPersonFields(section, editing, context) {
const base = `
<label><span>ID из API</span><input name="external_id" maxlength="64" required value="${escapeHtml(editing?.external_id || "")}" ${editing ? "readonly" : ""}></label>
<div class="hockey-directory-form-columns">
<label><span>Полное имя (русский)</span><input name="full_name_ru" maxlength="500" value="${escapeHtml(editing?.full_name_ru || "")}"></label>
<label><span>Full name (English)</span><input name="full_name_en" maxlength="500" value="${escapeHtml(editing?.full_name_en || "")}"></label>
<label><span>Имя (русский)</span><input name="first_name_ru" maxlength="255" value="${escapeHtml(editing?.first_name_ru || "")}"></label>
<label><span>First name</span><input name="first_name_en" maxlength="255" value="${escapeHtml(editing?.first_name_en || "")}"></label>
<label><span>Фамилия (русский)</span><input name="last_name_ru" maxlength="255" value="${escapeHtml(editing?.last_name_ru || "")}"></label>
<label><span>Last name</span><input name="last_name_en" maxlength="255" value="${escapeHtml(editing?.last_name_en || "")}"></label>
<label><span>Дата рождения</span><input name="birth_date" type="date" value="${escapeHtml(editing?.birth_date || "")}"></label>
<label><span>Код страны</span><input name="country_code" maxlength="8" value="${escapeHtml(editing?.country_code || "")}"></label>
</div>`;
if (section === "players") return `${base}
<input type="hidden" name="tournament_external_id" value="${escapeHtml(context.tournamentId || editing?.tournament_external_id || "")}">
<div class="hockey-directory-form-columns">
<label><span>Амплуа (русский)</span><input name="position_ru" value="${escapeHtml(editing?.position_ru || "")}"></label>
<label><span>Position (English)</span><input name="position_en" value="${escapeHtml(editing?.position_en || "")}"></label>
<label><span>Рост, см</span><input name="height_cm" type="number" min="0" max="300" value="${escapeHtml(editing?.height_cm || "")}"></label>
<label><span>Вес, кг</span><input name="weight_kg" type="number" min="0" max="300" value="${escapeHtml(editing?.weight_kg || "")}"></label>
<label><span>Хват / stick</span><input name="stick" maxlength="32" value="${escapeHtml(editing?.stick || "")}"></label>
<label><span>Номер</span><input name="jersey_number" maxlength="16" value="${escapeHtml(editing?.jersey_number || "")}"></label>
<label><span>ID клуба</span><input name="club_external_id" maxlength="64" value="${escapeHtml(editing?.club_external_id || "")}"></label>
<label><span>ID команды турнира</span><input name="team_entry_external_id" maxlength="64" value="${escapeHtml(editing?.team_entry_external_id || "")}"></label>
<label><span>Команда (русский)</span><input name="team_name_ru" value="${escapeHtml(editing?.team_name_ru || "")}"></label>
<label><span>Team (English)</span><input name="team_name_en" value="${escapeHtml(editing?.team_name_en || "")}"></label>
</div>`;
if (section === "coaches") return `${base}
<input type="hidden" name="tournament_external_id" value="${escapeHtml(context.tournamentId || editing?.tournament_external_id || "")}">
<div class="hockey-directory-form-columns">
<label><span>ID команды</span><input name="team_external_id" maxlength="64" value="${escapeHtml(editing?.team_external_id || "")}"></label>
<label><span>Команда (русский)</span><input name="team_name_ru" value="${escapeHtml(editing?.team_name_ru || "")}"></label>
<label><span>Team (English)</span><input name="team_name_en" value="${escapeHtml(editing?.team_name_en || "")}"></label>
<label><span>Роль (русский)</span><input name="role_ru" value="${escapeHtml(editing?.role_ru || "Главный тренер")}"></label>
<label><span>Role (English)</span><input name="role_en" value="${escapeHtml(editing?.role_en || "Head coach")}"></label>
</div>`;
return `${base}
<div class="hockey-directory-form-columns">
<label><span>Роль / код</span><input name="position_code" maxlength="32" value="${escapeHtml(editing?.position_code || "")}"></label>
<label><span>Номер</span><input name="number" maxlength="16" value="${escapeHtml(editing?.number || "")}"></label>
<label><span>Страна (русский)</span><input name="country_ru" value="${escapeHtml(editing?.country_ru || "")}"></label>
<label><span>Country (English)</span><input name="country_en" value="${escapeHtml(editing?.country_en || "")}"></label>
<label><span>Город (русский)</span><input name="town_ru" value="${escapeHtml(editing?.town_ru || "")}"></label>
<label><span>Town (English)</span><input name="town_en" value="${escapeHtml(editing?.town_en || "")}"></label>
</div>`;
}
function renderGeneric(section) {
const config = genericSections[section];
const data = state[section] || { items: [] };
const context = selectedContext();
const editing = state.editingRecord;
const rows = data.items.length ? data.items.map((item) => {
if (section === "countries") return `<tr class="${item.active ? "" : "is-inactive"}"><td class="hockey-directory-flag">${countryFlagMarkup(item)}</td><td><code>${escapeHtml(item.iso2 || item.iso3 || item.external_id)}</code></td><td><strong>${escapeHtml(item.name_ru || "—")}</strong><small>${escapeHtml(item.name_en || "")}</small></td><td>${escapeHtml(item.iso3 || "—")}</td><td><button type="button" class="hockey-directory-edit" data-edit-record="${item.id}">Изменить</button></td></tr>`;
const team = item.team_name_ru || item.team_name_en || "";
const extras = section === "players"
? `${item.position_ru || item.position_en || "—"} · ${item.height_cm || "—"} см · ${item.weight_kg || "—"} кг · ${item.stick || "—"}`
: section === "referees" ? `${item.position_code || "—"} · № ${item.number || "—"}` : `${item.role_ru || item.role_en || "—"}`;
return `<tr class="${item.active ? "" : "is-inactive"}"><td class="hockey-directory-flag">${countryFlagMarkup(item)}</td><td><code>${escapeHtml(item.external_id)}</code></td><td><strong>${escapeHtml(item.full_name_ru || item.name || "—")}</strong><small>${escapeHtml(item.full_name_en || "")}</small></td><td><strong>${escapeHtml(team || "—")}</strong><small>${escapeHtml(extras)}</small></td><td>${item.birth_date ? `${escapeHtml(item.birth_date)}${item.age !== null && item.age !== undefined ? ` · ${escapeHtml(item.age)} лет` : ""}` : "—"}</td><td><button type="button" class="hockey-directory-edit" data-edit-record="${item.id}">Изменить</button></td></tr>`;
}).join("") : `<tr><td colspan="6" class="hockey-directory-empty">Данные ещё не загружены.</td></tr>`;
const needsTournament = section !== "countries";
const form = section === "countries" ? `
<label><span>ID / код API</span><input name="external_id" maxlength="64" required value="${escapeHtml(editing?.external_id || "")}" ${editing ? "readonly" : ""}></label>
<div class="hockey-directory-form-columns"><label><span>ISO-2</span><input name="iso2" maxlength="2" value="${escapeHtml(editing?.iso2 || "")}"></label><label><span>ISO-3</span><input name="iso3" maxlength="3" value="${escapeHtml(editing?.iso3 || "")}"></label><label><span>Название на русском</span><input name="name_ru" value="${escapeHtml(editing?.name_ru || "")}"></label><label><span>Name in English</span><input name="name_en" value="${escapeHtml(editing?.name_en || "")}"></label></div>` : genericPersonFields(section, editing, context);
const content = `
<div class="hockey-directory-toolbar">
<label class="hockey-directory-filter"><span>Поиск</span><input data-generic-search value="${escapeHtml(state.directorySearch)}" placeholder="Имя, ID, команда"></label>
<div class="hockey-directory-toolbar-actions"><button type="button" data-sync-generic ${needsTournament && !context.tournamentId ? "disabled" : ""}>${escapeHtml(config.syncLabel)}</button><button type="button" class="is-accent" data-new-record>Добавить</button></div>
</div>
<p class="hockey-directory-note">${needsTournament ? (context.tournamentId ? `Текущий турнир: ${escapeHtml(context.tournamentId)}.` : "Выберите матч или турнир, чтобы загрузить данные его лиги и сезона.") : "Глобальный справочник стран и связей игроков со странами."}</p>
${statusMarkup()}
<div class="hockey-directory-grid">
<div class="hockey-directory-table-wrap"><table><thead><tr>${section === "countries" ? "<th>Флаг</th><th>Код</th><th>Название</th><th>ISO-3</th><th></th>" : "<th>Флаг</th><th>ID</th><th>Имя</th><th>Команда / данные</th><th>Дата рождения</th><th></th>"}</tr></thead><tbody>${rows}</tbody></table></div>
<form class="hockey-directory-form" data-generic-form><h3>${editing ? `Редактирование: ${escapeHtml(config.title)}` : `Новая запись: ${escapeHtml(config.title)}`}</h3>${form}<label class="hockey-directory-check"><input type="checkbox" name="active" ${editing?.active === false ? "" : "checked"}><span>Активна</span></label><div class="hockey-directory-form-actions">${editing ? '<button type="button" data-cancel-record>Отмена</button>' : ""}<button type="submit" class="is-accent">${editing ? "Сохранить" : "Добавить"}</button></div></form>
</div>`;
const modal = ensureModal(); modal.innerHTML = shellMarkup(content); bindShell(); bindGeneric(section);
}
function formPayload(form) {
const values = new FormData(form); const payload = {};
for (const [key, value] of values.entries()) payload[key] = value;
["height_cm", "weight_kg"].forEach((key) => { if (key in payload) payload[key] = Number(payload[key] || 0); });
payload.active = values.has("active"); return payload;
}
function bindGeneric(section) {
const modal = ensureModal(); const config = genericSections[section];
modal.querySelector("[data-generic-search]")?.addEventListener("change", async (event) => { state.directorySearch = event.target.value.trim(); await loadGeneric(section); renderGeneric(section); });
modal.querySelector("[data-sync-generic]")?.addEventListener("click", async (event) => {
const button=event.currentTarget; const context=selectedContext(); const params=new URLSearchParams();
if (section !== "countries") params.set("tournament_external_id", context.tournamentId);
button.disabled=true;
try { const result=await request(`/api/hockey/directories/${config.endpoint}/sync?${params}`, {method:"POST"}); await loadGeneric(section); setStatus(`Справочник обновлён: ${result.players ?? result.referees ?? result.coaches ?? result.countries ?? state[section]?.count ?? 0} записей.`); }
catch(error){ setStatus(error.message,true); }
renderGeneric(section);
});
modal.querySelector("[data-new-record]")?.addEventListener("click",()=>{state.editingRecord=null;setStatus("");renderGeneric(section);});
modal.querySelectorAll("[data-edit-record]").forEach((button)=>button.addEventListener("click",()=>{state.editingRecord=(state[section]?.items||[]).find((item)=>String(item.id)===button.dataset.editRecord)||null;setStatus("");renderGeneric(section);}));
modal.querySelector("[data-cancel-record]")?.addEventListener("click",()=>{state.editingRecord=null;setStatus("");renderGeneric(section);});
modal.querySelector("[data-generic-form]")?.addEventListener("submit",async(event)=>{event.preventDefault();const payload=formPayload(event.currentTarget);const id=state.editingRecord?.id;try{await request(id?`/api/hockey/directories/${config.endpoint}/${id}`:`/api/hockey/directories/${config.endpoint}`,{method:id?"PUT":"POST",body:JSON.stringify(payload)});state.editingRecord=null;await loadGeneric(section);setStatus("Запись сохранена.");}catch(error){setStatus(error.message,true);}renderGeneric(section);});
}
function timerRuleModeOptions(selected) {
const value = String(selected || "pp");
return `
<option value="pp" ${value === "pp" ? "selected" : ""}>Только PP / PK</option>
<option value="numbers" ${value === "numbers" ? "selected" : ""}>Только состав: 5×4 / 4×3</option>
<option value="pp_numbers" ${value === "pp_numbers" ? "selected" : ""}>PP / PK вместе с составом</option>
`;
}
const STRENGTH_LABEL_PHASES = [
{ key: "regulation", title: "Основное время", hint: "Базовый состав 5×5", states: ["5x5", "5x4", "4x4", "5x3", "4x3", "3x3"] },
{ key: "regular_overtime", title: "Овертайм регулярки", hint: "Базовый состав 3×3; удаления добавляют игроков сопернику", states: ["3x3", "4x3", "4x4", "5x3", "5x4", "5x5"] },
{ key: "playoff_overtime", title: "Овертайм плей-офф", hint: "Базовый состав 5×5", states: ["5x5", "5x4", "4x4", "5x3", "4x3", "3x3"] },
];
const PERIOD_STATUS_ROWS = [
{ key: "1", title: "1 период" },
{ key: "2", title: "2 период" },
{ key: "3", title: "3 период" },
{ key: "ot", title: "Овертайм регулярки" },
{ key: "ot_numbered", title: "Номерной овертайм плей-офф", hint: "Можно использовать {n}" },
{ key: "so", title: "Буллиты" },
{ key: "finished", title: "Матч завершён" },
];
const SCOREBOARD_TEAM_STATE_ROWS = [
{ key: "home_delayed_penalty", side: "HOME", title: "Отложенный штраф", defaultRu: "Отложенный штраф", defaultEn: "Delayed penalty" },
{ key: "home_empty_net", side: "HOME", title: "Пустые ворота", defaultRu: "Пустые ворота", defaultEn: "Empty net" },
{ key: "away_delayed_penalty", side: "AWAY", title: "Отложенный штраф", defaultRu: "Отложенный штраф", defaultEn: "Delayed penalty" },
{ key: "away_empty_net", side: "AWAY", title: "Пустые ворота", defaultRu: "Пустые ворота", defaultEn: "Empty net" },
];
function timerRulesInventoryInputs() {
const inputs = state.timerRulesVmixInventory?.inventory?.inputs;
return Array.isArray(inputs) ? [...inputs] : [];
}
function scoreboardTeamStateValue(settings, key) {
const source = settings?.scoreboard_team_states?.[key];
return source && typeof source === "object" ? source : {};
}
function scoreboardInputOptions(selectedValue, selectedTitle = "") {
const selected = String(selectedValue || "").trim();
const inputs = timerRulesInventoryInputs();
const options = [`<option value="">— не выводить отдельный Input —</option>`];
let found = !selected;
inputs
.sort((a, b) => Number(a.number || 0) - Number(b.number || 0))
.forEach((input) => {
const key = String(input.key || input.title || input.number || "").trim();
if (!key) return;
const title = String(input.title || input.name || key).trim();
const number = String(input.number || "").trim();
const label = `${number ? `#${number} · ` : ""}${title}`;
if (key === selected) found = true;
options.push(`<option value="${escapeHtml(key)}" data-input-title="${escapeHtml(title)}" ${key === selected ? "selected" : ""}>${escapeHtml(label)}</option>`);
});
if (selected && !found) {
options.push(`<option value="${escapeHtml(selected)}" data-input-title="${escapeHtml(selectedTitle)}" selected>Сохранённый Input · ${escapeHtml(selectedTitle || selected)}</option>`);
}
return options.join("");
}
function scoreboardTeamStatesMarkup(settings) {
return SCOREBOARD_TEAM_STATE_ROWS.map((row) => {
const value = scoreboardTeamStateValue(settings, row.key);
const overlay = ["1", "2", "3", "4"].includes(String(value.overlay || "")) ? String(value.overlay) : (row.key.includes("empty_net") ? "3" : "2");
return `
<div class="hockey-team-state-setting-row">
<div class="hockey-team-state-setting-kind"><b>${escapeHtml(row.side)}</b><strong>${escapeHtml(row.title)}</strong></div>
<label><span>Русская надпись</span><input name="team_state_${row.key}_ru" maxlength="48" value="${escapeHtml(value.ru || row.defaultRu)}"></label>
<label><span>English label</span><input name="team_state_${row.key}_en" maxlength="48" value="${escapeHtml(value.en || row.defaultEn)}"></label>
<label><span>vMix Input</span><select name="team_state_${row.key}_input">${scoreboardInputOptions(value.input, value.input_title)}</select></label>
<label><span>Overlay</span><select name="team_state_${row.key}_overlay">${[1,2,3,4].map((item) => `<option value="${item}" ${String(item) === overlay ? "selected" : ""}>Overlay ${item}</option>`).join("")}</select></label>
<label class="hockey-rules-switch compact"><input name="team_state_${row.key}_enabled" type="checkbox" ${value.enabled !== false ? "checked" : ""}><i></i><span><strong>Использовать</strong></span></label>
</div>`;
}).join("");
}
function collectScoreboardTeamStates(values, form) {
const result = {};
SCOREBOARD_TEAM_STATE_ROWS.forEach((row) => {
const select = form.querySelector(`[name="team_state_${row.key}_input"]`);
const selectedOption = select?.selectedOptions?.[0];
result[row.key] = {
ru: String(values.get(`team_state_${row.key}_ru`) || "").trim(),
en: String(values.get(`team_state_${row.key}_en`) || "").trim(),
input: String(values.get(`team_state_${row.key}_input`) || "").trim(),
input_title: String(selectedOption?.dataset?.inputTitle || selectedOption?.textContent || "").replace(/^#\d+\s*·\s*/, "").replace(/^Сохранённый Input\s*·\s*/, "").trim(),
overlay: String(values.get(`team_state_${row.key}_overlay`) || "2"),
enabled: values.has(`team_state_${row.key}_enabled`),
};
});
return result;
}
function periodStatusLabelValue(settings, periodKey, formatKey, language) {
return String(settings?.period_status_labels?.[periodKey]?.[formatKey]?.[language] || "");
}
function periodStatusLabelsMarkup(settings) {
return PERIOD_STATUS_ROWS.map((row) => `
<div class="hockey-period-status-group">
<header><strong>${escapeHtml(row.title)}</strong>${row.hint ? `<small>${escapeHtml(row.hint)}</small>` : ""}</header>
<div class="hockey-period-status-table">
<div class="hockey-period-status-row is-head"><span>Формат</span><span>Русский</span><span>English</span></div>
${[["compact","Компактный"],["short","Короткий"],["long","Полный"]].map(([formatKey, label]) => `
<label class="hockey-period-status-row">
<b>${escapeHtml(label)}</b>
<input name="period_status_${row.key}_${formatKey}_ru" maxlength="64" value="${escapeHtml(periodStatusLabelValue(settings, row.key, formatKey, "ru"))}" placeholder="RU">
<input name="period_status_${row.key}_${formatKey}_en" maxlength="64" value="${escapeHtml(periodStatusLabelValue(settings, row.key, formatKey, "en"))}" placeholder="EN">
</label>`).join("")}
</div>
</div>`).join("");
}
function collectPeriodStatusLabels(values) {
const result = {};
PERIOD_STATUS_ROWS.forEach((row) => {
result[row.key] = {};
["compact", "short", "long"].forEach((formatKey) => {
result[row.key][formatKey] = {
ru: String(values.get(`period_status_${row.key}_${formatKey}_ru`) || "").trim(),
en: String(values.get(`period_status_${row.key}_${formatKey}_en`) || "").trim(),
};
});
});
return result;
}
function strengthStateLabelValue(settings, phase, stateKey, language) {
return String(settings?.strength_state_labels?.[phase]?.[stateKey]?.[language] || "");
}
function strengthStateLabelsMarkup(settings) {
return STRENGTH_LABEL_PHASES.map((phase) => `
<div class="hockey-strength-label-group">
<header><strong>${escapeHtml(phase.title)}</strong><small>${escapeHtml(phase.hint)}</small></header>
<div class="hockey-strength-label-table">
<div class="hockey-strength-label-row is-head"><span>Состав</span><span>Русская подпись</span><span>English label</span></div>
${phase.states.map((stateKey) => `
<label class="hockey-strength-label-row">
<b>${escapeHtml(stateKey.replace("x", "×"))}</b>
<input name="strength_state_${phase.key}_${stateKey}_ru" maxlength="40" value="${escapeHtml(strengthStateLabelValue(settings, phase.key, stateKey, "ru"))}" placeholder="Например: ${stateKey === "5x4" ? "PP" : stateKey.replace("x", " на ")}">
<input name="strength_state_${phase.key}_${stateKey}_en" maxlength="40" value="${escapeHtml(strengthStateLabelValue(settings, phase.key, stateKey, "en"))}" placeholder="Example: ${stateKey === "5x4" ? "PP" : stateKey.replace("x", " on ")}">
</label>`).join("")}
</div>
</div>`).join("");
}
function collectStrengthStateLabels(values) {
const result = {};
STRENGTH_LABEL_PHASES.forEach((phase) => {
result[phase.key] = {};
phase.states.forEach((stateKey) => {
result[phase.key][stateKey] = {
ru: String(values.get(`strength_state_${phase.key}_${stateKey}_ru`) || "").trim(),
en: String(values.get(`strength_state_${phase.key}_${stateKey}_en`) || "").trim(),
};
});
});
return result;
}
function renderTimerRules() {
const value = state.timerRules || {};
const modal = ensureModal();
modal.innerHTML = shellMarkup(`
<div class="hockey-rules-page">
<div class="hockey-rules-intro">
<div>
<span>ОБЩИЕ ПРАВИЛА МАТЧА</span>
<h2>Таймеры и численные составы</h2>
<p>Эти значения используются для новых матчей, кнопки «Сброс» и автоматической установки времени при смене периода.</p>
</div>
<button type="button" data-open-penalty-directory>Открыть справочник удалений</button>
</div>
${statusMarkup()}
<form class="hockey-rules-form" data-timer-rules-form>
<section class="hockey-rules-card">
<header><span>01</span><div><strong>Длительность периодов</strong><small>Укажите своё время в минутах.</small></div></header>
<div class="hockey-rules-fields three">
<label><span>Обычный период</span><div class="hockey-rules-number"><input name="timer_period_minutes" type="number" min="1" max="60" value="${Number(value.timer_period_minutes || 20)}"><b>мин</b></div><small>1, 2 и 3 периоды</small></label>
<label><span>Овертайм регулярки</span><div class="hockey-rules-number"><input name="timer_regular_overtime_minutes" type="number" min="1" max="60" value="${Number(value.timer_regular_overtime_minutes || 5)}"><b>мин</b></div><small>Обычно 5 минут</small></label>
<label><span>Овертайм плей-офф</span><div class="hockey-rules-number"><input name="timer_playoff_overtime_minutes" type="number" min="1" max="60" value="${Number(value.timer_playoff_overtime_minutes || 20)}"><b>мин</b></div><small>Каждый дополнительный период</small></label>
</div>
<label class="hockey-rules-switch">
<input name="timer_reset_on_period_change" type="checkbox" ${value.timer_reset_on_period_change !== false ? "checked" : ""}>
<i></i><span><strong>Автоматически ставить время нового периода</strong><small>При переключении периода таймер остановится и получит заданную выше длительность.</small></span>
</label>
</section>
<section class="hockey-rules-card">
<header><span>02</span><div><strong>Базовый численный состав</strong><small>Количество полевых игроков до удалений.</small></div></header>
<div class="hockey-rules-fields four">
<label><span>Основное время</span><div class="hockey-rules-number"><input name="strength_regulation_skaters" type="number" min="3" max="6" value="${Number(value.strength_regulation_skaters || 5)}"><b>×</b></div></label>
<label><span>ОТ регулярки</span><div class="hockey-rules-number"><input name="strength_regular_overtime_skaters" type="number" min="3" max="6" value="${Number(value.strength_regular_overtime_skaters || 3)}"><b>×</b></div></label>
<label><span>ОТ плей-офф</span><div class="hockey-rules-number"><input name="strength_playoff_overtime_skaters" type="number" min="3" max="6" value="${Number(value.strength_playoff_overtime_skaters || 5)}"><b>×</b></div></label>
<label><span>Минимум игроков</span><div class="hockey-rules-number"><input name="strength_min_skaters" type="number" min="2" max="5" value="${Number(value.strength_min_skaters || 3)}"><b>×</b></div></label>
</div>
<div class="hockey-rules-example">
<strong>Пример:</strong> при базовом составе 3×3 удаление одной команды отображает фактический состав 4×3 для соперника.
</div>
</section>
<section class="hockey-rules-card">
<header><span>03</span><div><strong>Подписи численных составов в верхнем счёте</strong><small>Для каждого состояния задаются отдельные русское и английское названия.</small></div></header>
<div class="hockey-rules-example">
<strong>Как работает:</strong> программа сама считает активные удаления. В основном времени одно удаление даёт 5×4, два удаления одной команды — 5×3, по одному у каждой — 4×4. В овертайме регулярки одно удаление даёт 4×3, два — 5×3, а по одному у каждой команды — 4×4.
</div>
<div class="hockey-strength-label-groups">${strengthStateLabelsMarkup(value)}</div>
</section>
<section class="hockey-rules-card">
<header><span>04</span><div><strong>Статус периода для Mapping</strong><small>Отдельные русские и английские подписи в трёх форматах. Для номерного овертайма доступен шаблон {n}.</small></div></header>
<div class="hockey-rules-example">
<strong>В Mapping:</strong> доступны period.compact, period.short, period.long, а также независимые period.ru.* и period.en.*. Можно сделать, например, «2», «2 ПЕР» и «2 период» одновременно для разных титров.
</div>
<div class="hockey-period-status-groups">${periodStatusLabelsMarkup(value)}</div>
</section>
<section class="hockey-rules-card">
<header><span>05</span><div><strong>Состояния команд в верхнем счёте</strong><small>Кнопки «Пустые ворота» и «Отложенный штраф»: подписи RU/EN и отдельные vMix Inputs.</small></div></header>
<div class="hockey-rules-example">
<strong>Логика:</strong> кнопка только включает состояние матча. Если верхний счёт уже в эфире, дополнительный Input включится/выключится сразу. Если счёт ещё не выдан, Input появится автоматически вместе с верхним счётом. В настройке сохраняется стабильный vMix key, а номер Input используется только для отображения.
</div>
<div class="hockey-team-state-settings">${scoreboardTeamStatesMarkup(value)}</div>
<small class="hockey-team-state-inventory-note">vMix: ${escapeHtml(state.timerRulesVmixInventory?.device_name || "Agent не выбран / inventory не загружен")}. Для обновления списка Inputs закройте и снова откройте этот раздел после выбора Agent.</small>
</section>
<footer class="hockey-rules-actions">
<div><small>Изменения применяются сразу и сохраняются в общих настройках сервера.</small><strong data-rules-save-status></strong></div>
<button type="submit">Сохранить настройки</button>
</footer>
</form>
</div>
`);
bindShell();
bindTimerRules();
}
function bindTimerRules() {
const modal = ensureModal();
modal.querySelector("[data-open-penalty-directory]")?.addEventListener("click", async () => {
state.section = "penalties";
state.status = "";
try { await loadPenalties(true); } catch (error) { setStatus(error.message, true); }
render();
});
modal.querySelector("[data-timer-rules-form]")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const values = new FormData(form);
const button = form.querySelector("button[type='submit']");
const inlineStatus = form.querySelector("[data-rules-save-status]");
button.disabled = true;
inlineStatus.textContent = "Сохранение…";
try {
const payload = {
timer_period_minutes: Number(values.get("timer_period_minutes") || 20),
timer_regular_overtime_minutes: Number(values.get("timer_regular_overtime_minutes") || 5),
timer_playoff_overtime_minutes: Number(values.get("timer_playoff_overtime_minutes") || 20),
timer_reset_on_period_change: values.has("timer_reset_on_period_change"),
strength_regulation_skaters: Number(values.get("strength_regulation_skaters") || 5),
strength_regular_overtime_skaters: Number(values.get("strength_regular_overtime_skaters") || 3),
strength_playoff_overtime_skaters: Number(values.get("strength_playoff_overtime_skaters") || 5),
strength_min_skaters: Number(values.get("strength_min_skaters") || 3),
strength_state_labels: collectStrengthStateLabels(values),
period_status_labels: collectPeriodStatusLabels(values),
scoreboard_team_states: collectScoreboardTeamStates(values, form),
};
state.timerRules = await request("/api/hockey/settings", { method: "PUT", body: JSON.stringify(payload) });
inlineStatus.textContent = "Настройки сохранены";
inlineStatus.classList.remove("is-error");
window.dispatchEvent(new CustomEvent("hockey:settings-updated", { detail: { settings: state.timerRules } }));
} catch (error) {
inlineStatus.textContent = error.message;
inlineStatus.classList.add("is-error");
} finally {
button.disabled = false;
}
});
}
async function loadVmixSettings(force = false) {
if (state.vmixSettings && !force) return state.vmixSettings;
const [data, channelData] = await Promise.all([
request("/api/hockey/vmix/settings"),
request(`/api/hockey/vmix-channel?language=${encodeURIComponent(state.language === "en" ? "en" : "ru")}`).catch(() => ({ channel: null })),
]);
state.vmixSettings = data;
state.vmixChannel = channelData?.channel || null;
const configs = vmixConfigs();
if (!configs.some((item) => item.key === state.vmixActiveKey)) {
state.vmixActiveKey = String(data.settings?.active_vmix_json || configs[0]?.key || "");
}
return data;
}
function vmixConfigs() {
if (!state.vmixSettings) state.vmixSettings = { vmix_json: { version: 1, configs: [] }, vmix_functions: { categories: [] }, source_types: [] };
if (!state.vmixSettings.vmix_json || !Array.isArray(state.vmixSettings.vmix_json.configs)) {
state.vmixSettings.vmix_json = { version: 1, configs: [] };
}
return state.vmixSettings.vmix_json.configs;
}
function activeVmixConfig() {
return vmixConfigs().find((item) => item.key === state.vmixActiveKey) || vmixConfigs()[0] || null;
}
function vmixSafeKey(value, fallback = "json") {
return String(value || "").trim().replace(/[^A-Za-z0-9_]+/g, "_").replace(/^_+|_+$/g, "") || fallback;
}
function vmixEndpointTemplate(key) {
return `/vmix/hockey/channel/{channel}/{language}/custom/${vmixSafeKey(key)}.json`;
}
function vmixLiveEndpoint(config) {
const context = selectedContext();
if (!context.token || !config) return "";
return `${location.origin}/vmix/hockey/channel/${encodeURIComponent(context.token)}/${context.language}/custom/${encodeURIComponent(config.key)}.json`;
}
function vmixSourceOptions(selected) {
const items = state.vmixSettings?.source_types || [];
return items.map((item) => `<option value="${escapeHtml(item.value)}" ${item.value === selected ? "selected" : ""}>${escapeHtml(item.label)} · ${escapeHtml(item.value)}</option>`).join("");
}
function vmixColumnsMarkup(config) {
const columns = Array.isArray(config?.columns) ? config.columns : [];
if (!columns.length) return `<tr><td colspan="8" class="hockey-vmix-empty">Колонки не заданы. В режиме «Все поля» исходные поля будут переданы полностью.</td></tr>`;
return columns.map((column, index) => `
<tr data-vmix-column="${index}">
<td><input type="checkbox" data-vmix-col="enabled" ${column.enabled !== false ? "checked" : ""}></td>
<td><input type="text" data-vmix-col="key" value="${escapeHtml(column.key || "")}"></td>
<td><input type="text" data-vmix-col="label" value="${escapeHtml(column.label || "")}"></td>
<td><input type="text" data-vmix-col="source" value="${escapeHtml(column.source || "")}"></td>
<td>
<select data-vmix-col="mode">
<option value="" ${!column.mode ? "selected" : ""}>auto</option>
<option value="field" ${column.mode === "field" ? "selected" : ""}>field</option>
<option value="template" ${column.mode === "template" ? "selected" : ""}>template</option>
<option value="expr" ${column.mode === "expr" ? "selected" : ""}>expr</option>
</select>
</td>
<td><input type="text" data-vmix-col="expr" value="${escapeHtml(column.expr || "")}" placeholder="if_eq(period, '1', 'I', period)"></td>
<td><input type="text" data-vmix-col="default" value="${escapeHtml(column.default || "")}"></td>
<td class="hockey-vmix-col-actions">
<button type="button" data-vmix-col-action="up" data-index="${index}" aria-label="Вверх">↑</button>
<button type="button" data-vmix-col-action="down" data-index="${index}" aria-label="Вниз">↓</button>
<button type="button" data-vmix-col-action="delete" data-index="${index}" aria-label="Удалить">×</button>
</td>
</tr>
`).join("");
}
function vmixConfigPageMarkup() {
const configs = vmixConfigs();
const config = activeVmixConfig();
const context = selectedContext();
const liveEndpoint = vmixLiveEndpoint(config);
const sourceFields = state.vmixSourceFields?.sourceType === config?.source_type ? state.vmixSourceFields.fields : [];
return `
<div class="hockey-vmix-layout">
<aside class="hockey-vmix-list-panel">
<div class="hockey-vmix-list-head"><strong>JSON-конфигурации</strong><small>${configs.length} шт.</small></div>
<div class="hockey-vmix-config-list">
${configs.map((item) => `
<button type="button" data-vmix-config="${escapeHtml(item.key)}" class="${item.key === state.vmixActiveKey ? "is-active" : ""}">
<strong>${escapeHtml(item.title || item.key)}</strong>
<span>${escapeHtml(item.key)}</span>
<em>${escapeHtml(item.source_type || "scoreboard")}</em>
</button>
`).join("") || `<div class="hockey-vmix-empty">Конфигураций пока нет.</div>`}
</div>
<div class="hockey-vmix-list-actions">
<button type="button" data-vmix-new>+ Новый</button>
<button type="button" data-vmix-duplicate ${config ? "" : "disabled"}>Копия</button>
<button type="button" data-vmix-delete ${config ? "" : "disabled"}>Удалить</button>
</div>
</aside>
<section class="hockey-vmix-editor">
${config ? `
<header class="hockey-vmix-editor-head">
<div><span>VMIX JSON EDITOR</span><strong>${escapeHtml(config.title || config.key)}</strong></div>
<div class="hockey-vmix-editor-actions">
<button type="button" data-vmix-fields>Поля источника</button>
<button type="button" data-vmix-preview ${liveEndpoint ? "" : "disabled"}>Открыть JSON</button>
<button type="button" class="is-accent" data-vmix-save>Сохранить</button>
</div>
</header>
<div class="hockey-vmix-form-grid">
<label><span>Ключ JSON</span><input data-vmix-main="key" value="${escapeHtml(config.key || "")}"></label>
<label><span>Название</span><input data-vmix-main="title" value="${escapeHtml(config.title || "")}"></label>
<label class="is-wide"><span>Адрес-шаблон</span><input data-vmix-main="endpoint" value="${escapeHtml(vmixEndpointTemplate(config.key))}" readonly></label>
<label><span>Источник</span><select data-vmix-main="source_type">${vmixSourceOptions(config.source_type)}</select></label>
<label><span>Вывод</span><select data-vmix-main="output_mode"><option value="columns" ${config.output_mode !== "all" ? "selected" : ""}>Только выбранные колонки</option><option value="all" ${config.output_mode === "all" ? "selected" : ""}>Все поля</option></select></label>
<label><span>Лимит строк, 0 — без лимита</span><input type="number" min="0" max="1000" data-vmix-main="default_limit" value="${Number(config.default_limit || 0)}"></label>
<label class="hockey-directory-check"><input type="checkbox" data-vmix-main="enabled" ${config.enabled !== false ? "checked" : ""}><span>Конфигурация включена</span></label>
</div>
<div class="hockey-vmix-live-link ${liveEndpoint ? "" : "is-disabled"}">
<span>${context.token ? `Постоянный канал аккаунта ${escapeHtml(state.vmixChannel?.channel_owner || "")}` : "Сначала откройте матч, чтобы создать персональный канал vMix"}</span>
<code>${escapeHtml(liveEndpoint || vmixEndpointTemplate(config.key))}</code>
</div>
<div class="hockey-vmix-fields ${sourceFields.length ? "" : "hidden"}" data-vmix-fields-box>
${sourceFields.map((field) => `<button type="button" data-vmix-source-field="${escapeHtml(field)}">${escapeHtml(field)}</button>`).join("")}
</div>
<div class="hockey-vmix-columns-head">
<div><strong>Колонки JSON</strong><small>field — поле, template — шаблон, expr — формула</small></div>
<button type="button" data-vmix-add-column>+ Колонка</button>
</div>
<div class="hockey-vmix-table-wrap">
<table class="hockey-vmix-columns-table">
<thead><tr><th>Вкл</th><th>key</th><th>label</th><th>source</th><th>mode</th><th>expr</th><th>default</th><th></th></tr></thead>
<tbody>${vmixColumnsMarkup(config)}</tbody>
</table>
</div>
<details class="hockey-vmix-joins">
<summary>Объединение дополнительных источников</summary>
<p>Необязательный массив правил joins из переносимого модуля. Источник указывается как <code>source_type</code>.</p>
<textarea data-vmix-joins spellcheck="false">${escapeHtml(JSON.stringify(config.joins || [], null, 2))}</textarea>
</details>
` : `<div class="hockey-vmix-empty is-large">Создайте первую JSON-конфигурацию.</div>`}
</section>
</div>
`;
}
function vmixFunctionsMarkup() {
const categories = state.vmixSettings?.vmix_functions?.categories || [];
return `<div class="hockey-vmix-functions">${categories.map((category) => `
<details open>
<summary>${escapeHtml(category.title || "Функции")}</summary>
<div>${(category.items || []).map((item) => `<article><code>${escapeHtml(item.example || item.name || "")}</code><span>${escapeHtml(item.description || "")}</span></article>`).join("")}</div>
</details>
`).join("") || `<div class="hockey-vmix-empty">Справочник функций пуст.</div>`}</div>`;
}
function vmixImportMarkup() {
const preview = state.vmixImportPreview;
const configs = preview?.configs || preview?.configs_preview || preview?.vmix_configs || [];
return `
<div class="hockey-vmix-import">
<section>
<span>IMPORT</span><strong>Импорт настроек</strong>
<p>Можно выбрать <code>vmix_json.json</code>, другой JSON с настройками или ZIP проекта. Перед применением показывается найденный список конфигураций.</p>
<div class="hockey-vmix-import-actions">
<input type="file" data-vmix-import-file accept=".json,.zip,application/json,application/zip">
<button type="button" data-vmix-import-preview>Предпросмотр</button>
<button type="button" class="is-accent" data-vmix-import-apply ${preview ? "" : "disabled"}>Добавить конфигурации</button>
</div>
${preview ? `<div class="hockey-vmix-import-preview"><strong>${escapeHtml(preview.file_name || "Файл")}</strong><span>Найдено: ${Number(preview.configs_count ?? configs.length ?? 0)}</span><div>${configs.map((item) => `<em>${escapeHtml(item.title || item.key || "JSON")}</em>`).join("")}</div></div>` : ""}
</section>
<section>
<span>EXPORT</span><strong>Экспорт текущих настроек</strong>
<p>Скачивается переносимый JSON, который можно импортировать в другую хоккейную сборку.</p>
<button type="button" data-vmix-export>Скачать vmix_json.json</button>
</section>
</div>
`;
}
function renderVmixSettings() {
const panelContent = state.vmixPanel === "functions"
? vmixFunctionsMarkup()
: state.vmixPanel === "import"
? vmixImportMarkup()
: vmixConfigPageMarkup();
const content = `
<div class="hockey-vmix-page">
<div class="hockey-vmix-intro">
<div><span>PORTABLE VMIX SETTINGS</span><h2>Конструктор JSON для vMix</h2><p>Каждый аккаунт имеет постоянный канал vMix. При выборе нового матча данные меняются, а ссылка в проекте vMix остаётся прежней.</p></div>
<nav>
<button type="button" data-vmix-panel="configs" class="${state.vmixPanel === "configs" ? "is-active" : ""}">JSON</button>
<button type="button" data-vmix-panel="functions" class="${state.vmixPanel === "functions" ? "is-active" : ""}">Формулы</button>
<button type="button" data-vmix-panel="import" class="${state.vmixPanel === "import" ? "is-active" : ""}">Импорт / экспорт</button>
</nav>
</div>
${statusMarkup()}
${panelContent}
</div>
`;
const modal = ensureModal();
modal.innerHTML = shellMarkup(content);
bindShell();
bindVmixSettings();
}
function collectVmixEditor() {
const config = activeVmixConfig();
const modal = ensureModal();
if (!config || state.vmixPanel !== "configs") return true;
const oldKey = config.key;
const key = vmixSafeKey(modal.querySelector('[data-vmix-main="key"]')?.value, oldKey || "json");
config.key = key;
config.title = modal.querySelector('[data-vmix-main="title"]')?.value.trim() || key;
config.endpoint = vmixEndpointTemplate(key);
config.source_type = modal.querySelector('[data-vmix-main="source_type"]')?.value || "scoreboard";
config.output_mode = modal.querySelector('[data-vmix-main="output_mode"]')?.value === "all" ? "all" : "columns";
config.default_limit = Math.max(0, Number(modal.querySelector('[data-vmix-main="default_limit"]')?.value || 0));
config.enabled = modal.querySelector('[data-vmix-main="enabled"]')?.checked !== false;
config.columns = Array.from(modal.querySelectorAll("[data-vmix-column]")).map((row) => ({
enabled: row.querySelector('[data-vmix-col="enabled"]')?.checked !== false,
key: row.querySelector('[data-vmix-col="key"]')?.value.trim() || "",
label: row.querySelector('[data-vmix-col="label"]')?.value.trim() || "",
source: row.querySelector('[data-vmix-col="source"]')?.value || "",
mode: row.querySelector('[data-vmix-col="mode"]')?.value || "",
expr: row.querySelector('[data-vmix-col="expr"]')?.value || "",
default: row.querySelector('[data-vmix-col="default"]')?.value || "",
}));
const joinsText = modal.querySelector("[data-vmix-joins]")?.value.trim() || "[]";
try {
const joins = JSON.parse(joinsText);
if (!Array.isArray(joins)) throw new Error("joins должен быть массивом");
config.joins = joins;
} catch (error) {
setStatus(`Ошибка в joins: ${error.message}`, true);
return false;
}
if (oldKey !== key) state.vmixActiveKey = key;
return true;
}
function createVmixConfig(copy = false) {
collectVmixEditor();
const current = activeVmixConfig();
const config = copy && current ? JSON.parse(JSON.stringify(current)) : {
key: "new_json", title: "Новый JSON", description: "", source_type: "scoreboard", output_mode: "columns", root_key: "rows", default_limit: 0, enabled: true, columns: [], joins: [],
};
const used = new Set(vmixConfigs().map((item) => item.key));
const base = vmixSafeKey(config.key, "new_json");
let key = base;
let index = 2;
while (used.has(key)) key = `${base}_${index++}`;
config.key = key;
config.title = copy ? `${config.title || base} — копия` : config.title;
config.endpoint = vmixEndpointTemplate(key);
vmixConfigs().push(config);
state.vmixActiveKey = key;
state.vmixSourceFields = null;
renderVmixSettings();
}
async function saveVmixConfigs() {
if (!collectVmixEditor()) {
renderVmixSettings();
return;
}
try {
const data = await request("/api/hockey/vmix/configs", { method: "PUT", body: JSON.stringify({ vmix_json: state.vmixSettings.vmix_json }) });
state.vmixSettings.vmix_json = data.vmix_json;
state.vmixActiveKey = activeVmixConfig()?.key || state.vmixSettings.vmix_json.configs[0]?.key || "";
setStatus("Настройки vMix сохранены.");
} catch (error) {
setStatus(error.message, true);
}
renderVmixSettings();
}
async function loadVmixSourceFields() {
if (!collectVmixEditor()) return renderVmixSettings();
const config = activeVmixConfig();
const context = selectedContext();
if (!context.token) {
setStatus("Сначала откройте любой матч: после этого для аккаунта будет создан постоянный канал vMix.", true);
return renderVmixSettings();
}
try {
const params = new URLSearchParams({ token: context.token, language: context.language, source_type: config.source_type || "scoreboard" });
const data = await request(`/api/hockey/vmix/source-fields?${params}`);
state.vmixSourceFields = { sourceType: config.source_type, fields: data.fields || [] };
setStatus(`Найдено полей: ${(data.fields || []).length}. Нажмите на поле, чтобы добавить колонку.`);
} catch (error) {
setStatus(error.message, true);
}
renderVmixSettings();
}
function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result || "").split(",")[1] || "");
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
async function previewVmixImport() {
const file = ensureModal().querySelector("[data-vmix-import-file]")?.files?.[0];
if (!file) {
setStatus("Выберите JSON или ZIP.", true);
return renderVmixSettings();
}
try {
const contentBase64 = await fileToBase64(file);
state.vmixImportPreview = await request("/api/hockey/vmix/import/preview-file", { method: "POST", body: JSON.stringify({ file_name: file.name, content_base64: contentBase64 }) });
setStatus("Предпросмотр импорта готов.");
} catch (error) {
state.vmixImportPreview = null;
setStatus(error.message, true);
}
renderVmixSettings();
}
async function applyVmixImport() {
if (!state.vmixImportPreview) return;
try {
const data = await request("/api/hockey/vmix/import/apply", { method: "POST", body: JSON.stringify({ preview: state.vmixImportPreview, import_vmix: true, mode: "add_new" }) });
state.vmixSettings.vmix_json = data.vmix_json;
state.vmixActiveKey = data.vmix_json?.configs?.[0]?.key || "";
state.vmixImportPreview = null;
setStatus("Конфигурации импортированы и адаптированы к хоккейным источникам.");
state.vmixPanel = "configs";
} catch (error) {
setStatus(error.message, true);
}
renderVmixSettings();
}
async function exportVmixSettings() {
try {
const data = await request("/api/hockey/vmix/export");
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = "hockey_vmix_json.json";
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(url);
setStatus("Настройки экспортированы.");
} catch (error) {
setStatus(error.message, true);
}
renderVmixSettings();
}
function bindVmixSettings() {
const modal = ensureModal();
modal.querySelectorAll("[data-vmix-panel]").forEach((button) => button.addEventListener("click", () => {
if (state.vmixPanel === "configs" && !collectVmixEditor()) return renderVmixSettings();
state.vmixPanel = button.dataset.vmixPanel;
state.status = "";
renderVmixSettings();
}));
modal.querySelectorAll("[data-vmix-config]").forEach((button) => button.addEventListener("click", () => {
if (!collectVmixEditor()) return renderVmixSettings();
state.vmixActiveKey = button.dataset.vmixConfig;
state.vmixSourceFields = null;
state.status = "";
renderVmixSettings();
}));
modal.querySelector("[data-vmix-new]")?.addEventListener("click", () => createVmixConfig(false));
modal.querySelector("[data-vmix-duplicate]")?.addEventListener("click", () => createVmixConfig(true));
modal.querySelector("[data-vmix-delete]")?.addEventListener("click", () => {
const config = activeVmixConfig();
if (!config || !confirm(`Удалить JSON «${config.title || config.key}»?`)) return;
const index = vmixConfigs().indexOf(config);
vmixConfigs().splice(index, 1);
state.vmixActiveKey = vmixConfigs()[Math.max(0, index - 1)]?.key || vmixConfigs()[0]?.key || "";
state.vmixSourceFields = null;
renderVmixSettings();
});
modal.querySelector("[data-vmix-save]")?.addEventListener("click", saveVmixConfigs);
modal.querySelector("[data-vmix-fields]")?.addEventListener("click", loadVmixSourceFields);
modal.querySelector("[data-vmix-preview]")?.addEventListener("click", () => {
if (!collectVmixEditor()) return renderVmixSettings();
const endpoint = vmixLiveEndpoint(activeVmixConfig());
if (endpoint) window.open(endpoint, "_blank", "noopener");
});
modal.querySelector("[data-vmix-add-column]")?.addEventListener("click", () => {
if (!collectVmixEditor()) return renderVmixSettings();
const config = activeVmixConfig();
config.columns.push({ key: `field_${config.columns.length + 1}`, label: "", source: "", mode: "field", expr: "", default: "", enabled: true });
config.output_mode = "columns";
renderVmixSettings();
});
modal.querySelectorAll("[data-vmix-col-action]").forEach((button) => button.addEventListener("click", () => {
if (!collectVmixEditor()) return renderVmixSettings();
const config = activeVmixConfig();
const index = Number(button.dataset.index);
const action = button.dataset.vmixColAction;
if (action === "delete") config.columns.splice(index, 1);
if (action === "up" && index > 0) config.columns.splice(index - 1, 0, config.columns.splice(index, 1)[0]);
if (action === "down" && index < config.columns.length - 1) config.columns.splice(index + 1, 0, config.columns.splice(index, 1)[0]);
renderVmixSettings();
}));
modal.querySelectorAll("[data-vmix-source-field]").forEach((button) => button.addEventListener("click", () => {
if (!collectVmixEditor()) return renderVmixSettings();
const field = button.dataset.vmixSourceField || "field";
activeVmixConfig().columns.push({ key: vmixSafeKey(field.replaceAll(".", "_"), "field"), label: field, source: field, mode: "field", expr: "", default: "", enabled: true });
activeVmixConfig().output_mode = "columns";
renderVmixSettings();
}));
modal.querySelector('[data-vmix-main="source_type"]')?.addEventListener("change", () => { state.vmixSourceFields = null; });
modal.querySelector("[data-vmix-import-preview]")?.addEventListener("click", previewVmixImport);
modal.querySelector("[data-vmix-import-apply]")?.addEventListener("click", applyVmixImport);
modal.querySelector("[data-vmix-export]")?.addEventListener("click", exportVmixSettings);
}
function mappingRequestContext() {
const runtime = window.UIBuilderRuntime?.getData?.()?.hockey || {};
const selected = selectedContext();
const game = runtime.selected_game || selected.game || {};
const tournament = runtime.selected_tournament || selected.tournament || {};
const selectedPlayer = runtime.selected_player || runtime.player || runtime.match_details?.selected_player || {};
const home = runtime.home || game.home || {};
const away = runtime.away || game.away || {};
const value = (obj, keys) => {
for (const key of keys) {
const parts = String(key).split(".");
let current = obj; let ok = true;
for (const part of parts) {
if (current == null || !(part in Object(current))) { ok = false; break; }
current = current[part];
}
if (ok && current !== null && current !== undefined && String(current) !== "") return String(current);
}
return "";
};
return {
game_id: value(game, ["external_id", "id"]),
tournament_id: value(game, ["tournament_external_id"]) || value(tournament, ["external_id", "id"]) || selected.tournamentId || "",
team1_id: value(game, ["home_team_external_id"]) || value(home, ["external_id", "id", "team_id"]),
team2_id: value(game, ["away_team_external_id"]) || value(away, ["external_id", "id", "team_id"]),
selected_player_id: value(selectedPlayer, ["external_id", "id", "player_id"]),
selected_player_team_id: value(selectedPlayer, ["team.external_id", "team.id", "team_id"]),
ui_language: String(runtime.language?.display || state.language || "ru").toLowerCase() === "en" ? "en" : "ru",
session_token: selected.token || "",
};
}
async function loadMappingCatalog() {
try {
state.mappingCatalog = await request("/api/hockey/admin/mapping-data/catalog", {
method: "POST", body: JSON.stringify({ context: mappingRequestContext() }),
});
} catch (error) {
state.mappingCatalog = { items: [], variables: [], sources: [], context: mappingRequestContext(), error: error.message || String(error) };
}
return state.mappingCatalog;
}
async function loadMapping(profileId = null) {
state.mappingWorkspaceTab = mappingTabFromSection();
state.mappingLoadErrors = {};
const safe = async (key, url, fallback) => {
try { return await request(url); }
catch (error) { state.mappingLoadErrors[key] = error.message || String(error); return fallback; }
};
const [devices, profiles, variables, sources] = await Promise.all([
safe("devices", "/api/hockey/admin/vmix-mapping/devices", { devices: [] }),
safe("profiles", "/api/hockey/admin/vmix-mapping/profiles", { profiles: [] }),
safe("variables", "/api/hockey/admin/mapping-context/variables", { variables: [] }),
safe("sources", "/api/hockey/admin/mapping-data/sources", { sources: [] }),
]);
state.mappingDevices = devices;
state.mappingProfiles = profiles;
state.mappingContextVariables = variables;
state.mappingSqlSources = sources;
const id = profileId ?? state.mappingActiveProfile?.id ?? profiles.profiles?.[0]?.id ?? null;
if (id) {
try { state.mappingActiveProfile = await request(`/api/hockey/admin/vmix-mapping/profiles/${id}`); }
catch (error) { state.mappingActiveProfile = null; state.mappingLoadErrors.profile = error.message || String(error); }
} else state.mappingActiveProfile = null;
if (!state.mappingSelectedSqlSourceId && sources.sources?.length) state.mappingSelectedSqlSourceId = sources.sources[0].id;
await loadMappingCatalog();
if (state.mappingCatalog?.error) state.mappingLoadErrors.catalog = state.mappingCatalog.error;
return { devices, profiles, variables, sources };
}
function mappingInventoryInputs(profile) {
// Mapping picker is operator-facing: show Inputs in vMix numeric order so a
// title can be found by its familiar # quickly. Stable links still use key/title.
const inputs = Array.isArray(profile?.inventory?.inputs) ? [...profile.inventory.inputs] : [];
return inputs.sort((left, right) => {
const leftNumber = Number(left?.number);
const rightNumber = Number(right?.number);
const leftRank = Number.isFinite(leftNumber) ? leftNumber : Number.MAX_SAFE_INTEGER;
const rightRank = Number.isFinite(rightNumber) ? rightNumber : Number.MAX_SAFE_INTEGER;
return leftRank - rightRank
|| String(left?.title || "").localeCompare(String(right?.title || ""), "ru", { numeric: true, sensitivity: "base" });
});
}
function mappingVmixFieldOrder(left, right) {
// Natural sort keeps Player2.Text before Player10.Text and makes long GT
// templates much easier to scan. Type suffix stays part of the comparison.
return String(left?.name || "").localeCompare(String(right?.name || ""), "ru", {
numeric: true,
sensitivity: "base",
});
}
function mappingInputByKey(profile, key, title = "") {
const inputs = mappingInventoryInputs(profile);
return inputs.find((item) => String(item.key || item.title || item.number || "") === String(key || ""))
|| inputs.find((item) => String(item.title || "") === String(title || ""))
|| null;
}
function mappingInputValue(item) {
return String(item?.key || item?.title || item?.number || "");
}
function mappingInputLabel(item) {
if (!item) return "—";
return `${item.number ? `#${item.number} · ` : ""}${item.title || "Без названия"}${item.type ? ` [${item.type}]` : ""}`;
}
function mappingFilteredInputs(profile) {
const inputs = mappingInventoryInputs(profile);
const query = String(state.mappingInputSearch || "").trim().toLowerCase();
if (!query) return inputs;
return inputs.filter((item) => `${item.number || ""} ${item.title || ""} ${item.key || ""} ${item.type || ""}`.toLowerCase().includes(query));
}
function mappingFieldKind(field) {
const name = String(field?.name || "").trim().toLowerCase();
if (name.endsWith(".source")) return "image";
if (name.endsWith(".color")) return "color";
if (name.endsWith(".text")) return "text";
const raw = String(field?.type || "text").toLowerCase();
if (raw.includes("image") || raw.includes("source")) return "image";
if (raw.includes("color")) return "color";
return "text";
}
function mappingFieldFilterMatch(field, filter) {
const name = String(field?.name || "").trim().toLowerCase();
if (filter === "image") return name.endsWith(".source");
if (filter === "color") return name.endsWith(".color");
return name.endsWith(".text");
}
function mappingPathValue(root, paths, fallback = "") {
for (const path of paths) {
let value = root;
let ok = true;
for (const part of String(path).split(".")) {
if (value == null || !(part in Object(value))) { ok = false; break; }
value = value[part];
}
if (ok && value !== null && value !== undefined && value !== "") return value;
}
return fallback;
}
function mappingDisplayValue(value, kind = "text") {
if (value === null || value === undefined || value === "") return "—";
if (typeof value === "boolean") return value ? "Да" : "Нет";
if (Array.isArray(value)) return value.length ? `${value.length} элементов` : "—";
if (typeof value === "object") {
const text = value.name || value.title || value.label || value.full_name || value.value;
return text ? String(text) : "Данные доступны";
}
const text = String(value);
if (kind === "image") {
const part = text.split(/[\\/]/).pop() || text;
return part.length > 52 ? `${part.slice(0, 49)}` : part;
}
return text.length > 74 ? `${text.slice(0, 71)}` : text;
}
function mappingSource(key, label, category, value, options = {}) {
return {
key,
label,
category,
value,
kind: options.kind || "text",
description: options.description || "",
};
}
function mappingDataCatalog() {
const payload = state.mappingCatalog || {};
const items = Array.isArray(payload.items) ? payload.items.map((item) => ({
key: String(item.key || ""),
label: String(item.label || item.key || "Данные"),
category: String(item.category || "Данные"),
value: item.value,
kind: String(item.kind || "text"),
description: String(item.description || ""),
source_code: String(item.source_code || ""),
source_name: String(item.source_name || ""),
column: String(item.column || ""),
row_index: Number(item.row_index || 0),
row_label: String(item.row_label || ""),
table_cell: Boolean(item.table_cell),
localized: Boolean(item.localized),
language: String(item.language || payload.language || ""),
rus_column: String(item.rus_column || ""),
eng_column: String(item.eng_column || ""),
resolved_column: String(item.resolved_column || ""),
language_explicit: Boolean(item.language_explicit),
})) : [];
const byKey = Object.fromEntries(items.map((item) => [item.key, item.value]));
const context = payload.context || mappingRequestContext();
const gameId = context.game_id || "";
const home = byKey["game.home.name"] || "Хозяева";
const away = byKey["game.away.name"] || "Гости";
return {
game: gameId ? { external_id: gameId } : {},
items,
gameLabel: gameId ? `${gameId} · ${home}${away}` : "Матч не выбран",
variables: Array.isArray(payload.variables) ? payload.variables : [],
sourceStatus: Array.isArray(payload.sources) ? payload.sources : [],
tables: Array.isArray(payload.tables) ? payload.tables : [],
error: payload.error || "",
};
}
function mappingSourceByKey(key) {
return mappingDataCatalog().items.find((item) => item.key === key) || null;
}
function mappingCurrentInput(profile) {
const inputs = mappingInventoryInputs(profile);
if (!inputs.length) return null;
let current = inputs.find((item) => mappingInputValue(item) === state.mappingSelectedInputKey);
if (!current) {
const linked = (profile?.fields || [])[0];
current = linked ? mappingInputByKey(profile, linked.vmix_input_key, linked.vmix_input_title) : null;
}
current ||= inputs[0];
state.mappingSelectedInputKey = mappingInputValue(current);
return current;
}
function mappingRowForField(profile, input, fieldName) {
return (profile?.fields || []).find((row) => {
const sameInput = String(row.vmix_input_key || "") === String(input?.key || "")
|| (!row.vmix_input_key && String(row.vmix_input_title || "") === String(input?.title || ""));
return sameInput && String(row.vmix_field || "") === String(fieldName || "");
}) || null;
}
function mappingSetLink(profile, input, field, source) {
const fields = [...(profile?.fields || [])];
const index = fields.findIndex((row) => {
const sameInput = String(row.vmix_input_key || "") === String(input?.key || "")
|| (!row.vmix_input_key && String(row.vmix_input_title || "") === String(input?.title || ""));
return sameInput && String(row.vmix_field || "") === String(field?.name || "");
});
const previous = index >= 0 ? fields[index] : null;
const previousRule = previous?.rule && typeof previous.rule === "object" ? { ...previous.rule } : {};
if (!previousRule.left_key || String(previousRule.left_key) === String(previous?.data_key || "")) {
previousRule.left_key = source.key;
}
const next = {
graphic: String(input?.title || "graphic").trim().slice(0, 100),
data_key: source.key,
vmix_input_key: String(input?.key || ""),
// Input number is positional in vMix and changes after reordering.
// Persist key/title only; number remains display-only in inventory.
vmix_input_number: "",
vmix_input_title: String(input?.title || ""),
vmix_field: String(field?.name || ""),
field_type: mappingFieldKind(field),
rule: previousRule,
enabled: true,
};
if (index >= 0) fields[index] = next;
else fields.push(next);
profile.fields = fields;
}
function mappingRemoveLink(profile, input, fieldName) {
profile.fields = (profile?.fields || []).filter((row) => {
const sameInput = String(row.vmix_input_key || "") === String(input?.key || "")
|| (!row.vmix_input_key && String(row.vmix_input_title || "") === String(input?.title || ""));
return !(sameInput && String(row.vmix_field || "") === String(fieldName || ""));
});
}
function mappingNormaliseRule(row, kind = "text") {
const raw = row?.rule && typeof row.rule === "object" ? row.rule : {};
const actionDefault = kind === "image" ? "visibility" : "text_colour";
return {
enabled: Boolean(raw.enabled),
action: String(raw.action || actionDefault),
left_key: String(raw.left_key || row?.data_key || ""),
operator: String(raw.operator || "gt"),
right_mode: String(raw.right_mode || "field"),
right_key: String(raw.right_key || ""),
right_value: raw.right_value == null ? "" : String(raw.right_value),
true_value: raw.true_value == null ? (actionDefault === "visibility" ? "on" : "#E5CEA8") : String(raw.true_value),
false_value: raw.false_value == null ? (actionDefault === "visibility" ? "off" : "#FFFFFF") : String(raw.false_value),
};
}
function mappingRuleSources(row, excludeKey = "") {
const items = (mappingDataCatalog().items || []).filter((item) => item.kind !== "image" && (!excludeKey || item.key !== excludeKey));
const current = mappingSourceByKey(row?.data_key);
const sameRow = current?.table_cell ? items.filter((item) => item.table_cell && item.source_code === current.source_code && Number(item.row_index) === Number(current.row_index)) : [];
const preferred = [...sameRow, ...items.filter((item) => !sameRow.includes(item) && !item.table_cell), ...items.filter((item) => !sameRow.includes(item) && item.table_cell)];
return preferred.slice(0, 600);
}
function mappingRuleNumeric(value) {
if (value == null || typeof value === "boolean") return null;
if (typeof value === "number" && Number.isFinite(value)) return value;
const text = String(value).trim();
if (!text) return null;
const compact = text.replaceAll(" ", "").replaceAll("%", "");
if (/^[-+]?\d{1,4}:\d{1,2}(?::\d{1,2}(?:[.,]\d+)?)?$/.test(compact)) {
const parts = compact.replace(",", ".").split(":").map(Number);
if (parts.every(Number.isFinite)) return parts.length === 2 ? parts[0] * 60 + parts[1] : parts[0] * 3600 + parts[1] * 60 + parts[2];
}
const number = Number(compact.replace(",", "."));
return Number.isFinite(number) ? number : null;
}
function mappingRuleMatches(left, operator, right) {
const op = String(operator || "eq").toLowerCase();
const aText = left == null ? "" : String(left).trim();
const bText = right == null ? "" : String(right).trim();
if (["empty", "is_empty"].includes(op)) return aText === "";
if (["not_empty", "is_not_empty"].includes(op)) return aText !== "";
if (op === "contains") return aText.toLowerCase().includes(bText.toLowerCase());
if (op === "not_contains") return !aText.toLowerCase().includes(bText.toLowerCase());
const an = mappingRuleNumeric(left); const bn = mappingRuleNumeric(right);
const a = an != null && bn != null ? an : aText.toLowerCase();
const b = an != null && bn != null ? bn : bText.toLowerCase();
if (["gt", ">"].includes(op)) return a > b;
if (["lt", "<"].includes(op)) return a < b;
if (["gte", ">="].includes(op)) return a >= b;
if (["lte", "<="].includes(op)) return a <= b;
if (["neq", "!=", "<>"].includes(op)) return a !== b;
return a === b;
}
function mappingRuleCommand(row, field) {
const kind = mappingFieldKind(field);
const rule = mappingNormaliseRule(row, kind);
if (!rule.enabled || kind === "color") return null;
const left = mappingSourceByKey(rule.left_key || row.data_key);
if (!left) return null;
let right = null;
if (!["empty", "is_empty", "not_empty", "is_not_empty"].includes(rule.operator)) {
right = rule.right_mode === "value" ? { value: rule.right_value } : mappingSourceByKey(rule.right_key);
if (!right) return null;
}
const matched = mappingRuleMatches(left.value, rule.operator, right?.value);
if (rule.action === "visibility") {
const raw = matched ? rule.true_value : rule.false_value;
const visible = !["0", "false", "off", "hide", "hidden", "no"].includes(String(raw).toLowerCase());
return {
input: row.vmix_input_key || row.vmix_input_title || row.vmix_input_number,
selected_name: row.vmix_field,
value: "",
field_type: kind,
function: kind === "image" ? (visible ? "SetImageVisibleOn" : "SetImageVisibleOff") : (visible ? "SetTextVisibleOn" : "SetTextVisibleOff"),
data_key: `rule:${row.data_key}`,
};
}
if (kind !== "text") return null;
const colour = matched ? rule.true_value : rule.false_value;
if (!String(colour || "").trim()) return null;
return {
input: row.vmix_input_key || row.vmix_input_title || row.vmix_input_number,
selected_name: row.vmix_field,
value: String(colour),
field_type: kind,
function: "SetTextColour",
data_key: `rule:${row.data_key}`,
};
}
function mappingRuleEditor(row, field, input) {
const kind = mappingFieldKind(field);
if (!row || kind === "color") return "";
const rule = mappingNormaliseRule(row, kind);
const open = state.mappingRuleField === String(field.name || "");
const leftSources = mappingRuleSources(row);
const sources = mappingRuleSources(row, rule.left_key);
const leftExists = leftSources.some((item) => item.key === rule.left_key);
const rightExists = sources.some((item) => item.key === rule.right_key);
const actionOptions = kind === "image"
? `<option value="visibility" selected>Видимость</option>`
: `<option value="text_colour" ${rule.action === "text_colour" ? "selected" : ""}>Цвет текста</option><option value="visibility" ${rule.action === "visibility" ? "selected" : ""}>Видимость</option>`;
const leftOptions = `${rule.left_key && !leftExists ? `<option value="${escapeHtml(rule.left_key)}" selected>${escapeHtml(rule.left_key)}</option>` : ""}${leftSources.map((item) => `<option value="${escapeHtml(item.key)}" ${item.key === rule.left_key ? "selected" : ""}>${escapeHtml(item.label || item.key)} · ${escapeHtml(mappingDisplayValue(item.value, item.kind))}</option>`).join("")}`;
const sourceOptions = `${rule.right_key && !rightExists ? `<option value="${escapeHtml(rule.right_key)}" selected>${escapeHtml(rule.right_key)}</option>` : ""}${sources.map((item) => `<option value="${escapeHtml(item.key)}" ${item.key === rule.right_key ? "selected" : ""}>${escapeHtml(item.label || item.key)} · ${escapeHtml(mappingDisplayValue(item.value, item.kind))}</option>`).join("")}`;
const operatorOptions = [["gt", "> больше"], ["lt", "< меньше"], ["gte", "≥"], ["lte", "≤"], ["eq", "= равно"], ["neq", "≠ не равно"], ["contains", "содержит"], ["not_contains", "не содержит"], ["empty", "пусто"], ["not_empty", "не пусто"]]
.map(([value, label]) => `<option value="${value}" ${rule.operator === value ? "selected" : ""}>${label}</option>`).join("");
const summary = !rule.enabled ? "Правило выключено" : (rule.action === "visibility" ? `Visibility · ${rule.operator}` : `TextColour · ${rule.operator}`);
const valueEditor = rule.action === "visibility"
? `<div class="hockey-map-rule-values"><label>ЕСЛИ TRUE<select data-map-rule-true data-rule-field="${escapeHtml(field.name)}"><option value="on" ${String(rule.true_value).toLowerCase() === "on" ? "selected" : ""}>Показать</option><option value="off" ${String(rule.true_value).toLowerCase() === "off" ? "selected" : ""}>Скрыть</option></select></label><label>ELSE<select data-map-rule-false data-rule-field="${escapeHtml(field.name)}"><option value="off" ${String(rule.false_value).toLowerCase() === "off" ? "selected" : ""}>Скрыть</option><option value="on" ${String(rule.false_value).toLowerCase() === "on" ? "selected" : ""}>Показать</option></select></label></div>`
: `<div class="hockey-map-rule-values"><label>Цвет TRUE<input data-map-rule-true data-rule-field="${escapeHtml(field.name)}" value="${escapeHtml(rule.true_value)}" placeholder="#E5CEA8 или red"></label><label>Цвет ELSE<input data-map-rule-false data-rule-field="${escapeHtml(field.name)}" value="${escapeHtml(rule.false_value)}" placeholder="#FFFFFF"></label></div>`;
return `
<div class="hockey-map-rule-wrap ${rule.enabled ? "is-enabled" : ""}">
<button type="button" class="hockey-map-rule-toggle" data-map-rule-toggle="${escapeHtml(field.name)}"><span>⚡ Правило</span><small>${escapeHtml(summary)}</small><b>${open ? "" : "+"}</b></button>
${open ? `<div class="hockey-map-rule-editor">
<div class="hockey-map-rule-head"><label><input type="checkbox" data-map-rule-enabled data-rule-field="${escapeHtml(field.name)}" ${rule.enabled ? "checked" : ""}> <span>Включить правило</span></label><button type="button" data-map-rule-clear="${escapeHtml(field.name)}">Сбросить</button></div>
<div class="hockey-map-rule-presets"><span>Быстро:</span><button type="button" data-map-rule-preset="gt" data-rule-field="${escapeHtml(field.name)}">Больше</button><button type="button" data-map-rule-preset="lt" data-rule-field="${escapeHtml(field.name)}">Меньше</button><button type="button" data-map-rule-preset="eq" data-rule-field="${escapeHtml(field.name)}">Равно</button></div>
<div class="hockey-map-rule-grid">
<label>Действие<select data-map-rule-action data-rule-field="${escapeHtml(field.name)}">${actionOptions}</select></label>
<label>Левое значение<select data-map-rule-left-key data-rule-field="${escapeHtml(field.name)}">${leftOptions}</select></label>
<label>Условие<select data-map-rule-operator data-rule-field="${escapeHtml(field.name)}">${operatorOptions}</select></label>
<label>Сравнивать с<select data-map-rule-right-mode data-rule-field="${escapeHtml(field.name)}"><option value="field" ${rule.right_mode === "field" ? "selected" : ""}>Другим полем</option><option value="value" ${rule.right_mode === "value" ? "selected" : ""}>Значением</option></select></label>
${rule.right_mode === "value" ? `<label class="is-wide">Значение<input data-map-rule-right-value data-rule-field="${escapeHtml(field.name)}" value="${escapeHtml(rule.right_value)}" placeholder="10"></label>` : `<label class="is-wide">Поле для сравнения<select data-map-rule-right-key data-rule-field="${escapeHtml(field.name)}"><option value="">— выберите поле —</option>${sourceOptions}</select></label>`}
</div>
${valueEditor}
<div class="hockey-map-rule-actions"><button type="button" data-map-rule-mirror="${escapeHtml(field.name)}" ${rule.right_mode === "field" && rule.right_key ? "" : "disabled"}>↔ Зеркально на второе поле</button>${mappingParseTableCellKey(row.data_key) && mappingFieldSequence(input, field.name) ? `<button type="button" data-map-rule-series="${escapeHtml(field.name)}">↧ Размножить правило по строкам</button>` : ""}</div>
</div>` : ""}
</div>`;
}
function mappingRuleRow(profile, input, fieldName) {
const row = mappingRowForField(profile, input, fieldName);
const field = (input?.fields || []).find((item) => String(item.name || "") === String(fieldName || ""));
return { row, field };
}
function mappingPatchRule(fieldName, patch) {
const profile = state.mappingActiveProfile; const input = mappingCurrentInput(profile);
const { row, field } = mappingRuleRow(profile, input, fieldName);
if (!row || !field) return null;
const base = mappingNormaliseRule(row, mappingFieldKind(field));
row.rule = { ...base, ...patch, left_key: Object.prototype.hasOwnProperty.call(patch, "left_key") ? String(patch.left_key || row.data_key) : (base.left_key || row.data_key) };
return row.rule;
}
function mappingMirrorRule(fieldName) {
const profile = state.mappingActiveProfile; const input = mappingCurrentInput(profile);
const { row, field } = mappingRuleRow(profile, input, fieldName);
if (!row || !field) return { ok: false, reason: "Связь не найдена." };
const rule = mappingNormaliseRule(row, mappingFieldKind(field));
if (rule.right_mode !== "field" || !rule.right_key) return { ok: false, reason: "Сначала выберите второе поле." };
const peer = (profile?.fields || []).find((item) => {
const sameInput = String(item.vmix_input_key || "") === String(input?.key || "") || (!item.vmix_input_key && String(item.vmix_input_title || "") === String(input?.title || ""));
return sameInput && String(item.data_key || "") === rule.right_key;
});
if (!peer) return { ok: false, reason: "Второе поле данных ещё не связано с элементом vMix этого Input." };
const invert = { gt: "lt", lt: "gt", gte: "lte", lte: "gte", ">": "<", "<": ">", ">=": "<=", "<=": ">=" };
peer.rule = { ...rule, left_key: peer.data_key, right_key: row.data_key, operator: invert[rule.operator] || rule.operator, enabled: true };
return { ok: true, target: peer.vmix_field };
}
function mappingCloneRuleSeries(fieldName) {
const profile = state.mappingActiveProfile; const input = mappingCurrentInput(profile);
const { row, field } = mappingRuleRow(profile, input, fieldName);
const anchorCell = mappingParseTableCellKey(row?.data_key);
const sequence = mappingFieldSequence(input, fieldName);
if (!row || !field || !anchorCell || !sequence) return { changed: 0 };
const rule = mappingNormaliseRule(row, mappingFieldKind(field));
const shiftKey = (key, delta) => {
const cell = mappingParseTableCellKey(key);
return cell && cell.sourceCode === anchorCell.sourceCode ? `${cell.sourceCode}.row.${cell.rowIndex + delta}.${cell.column}` : key;
};
let changed = 0;
for (const target of sequence.matches) {
const targetRow = mappingRowForField(profile, input, target.field.name);
if (!targetRow) continue;
const delta = target.index - sequence.anchorIndex;
targetRow.rule = { ...rule, left_key: shiftKey(rule.left_key || row.data_key, delta), right_key: rule.right_mode === "field" ? shiftKey(rule.right_key, delta) : rule.right_key };
changed += 1;
}
return { changed };
}
function mappingParseTableCellKey(key) {
const match = String(key || "").match(/^(.*)\.row\.(\d+)\.(.+)$/);
if (!match) return null;
return { sourceCode: match[1], rowIndex: Number(match[2] || 0), column: match[3] };
}
function mappingEscapeRegExp(value) {
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
// Find the numeric token in a GT field name which forms the strongest sequence
// inside the current Input. Example: Player1.Text -> Player2.Text -> Player3.Text.
// If a name has several numbers, the token with the largest matching family wins.
function mappingFieldSequence(input, fieldName) {
const name = String(fieldName || "");
const tokens = [...name.matchAll(/\d+/g)];
let best = null;
for (const token of tokens) {
const start = Number(token.index || 0);
const finish = start + String(token[0] || "").length;
const prefix = name.slice(0, start);
const suffix = name.slice(finish);
const regex = new RegExp(`^${mappingEscapeRegExp(prefix)}(\\d+)${mappingEscapeRegExp(suffix)}$`);
const matches = (input?.fields || []).map((field) => {
const hit = String(field.name || "").match(regex);
return hit ? { field, index: Number(hit[1]) } : null;
}).filter(Boolean).sort((a, b) => a.index - b.index);
if (!best || matches.length > best.matches.length) {
best = { anchorIndex: Number(token[0]), prefix, suffix, matches };
}
}
return best && best.matches.length >= 2 ? best : null;
}
function mappingSeriesPlan(profile, input, anchorFieldName, mode = "column") {
const anchorRow = mappingRowForField(profile, input, anchorFieldName);
const anchorCell = anchorRow ? mappingParseTableCellKey(anchorRow.data_key) : null;
if (!anchorRow || !anchorCell) return { links: [], reason: "Связь должна вести на ячейку SQL-таблицы." };
let seedRows = [anchorRow];
if (mode === "row") {
seedRows = (profile?.fields || []).filter((row) => {
const sameInput = String(row.vmix_input_key || "") === String(input?.key || "")
|| (!row.vmix_input_key && String(row.vmix_input_title || "") === String(input?.title || ""));
if (!sameInput) return false;
const cell = mappingParseTableCellKey(row.data_key);
return cell && cell.sourceCode === anchorCell.sourceCode && cell.rowIndex === anchorCell.rowIndex;
});
}
const planned = new Map();
let sequentialSeeds = 0;
for (const seed of seedRows) {
const seedCell = mappingParseTableCellKey(seed.data_key);
const sequence = mappingFieldSequence(input, seed.vmix_field);
if (!seedCell || !sequence) continue;
sequentialSeeds += 1;
const seedField = (input?.fields || []).find((field) => String(field.name || "") === String(seed.vmix_field || ""));
const targetKind = seedField ? mappingFieldKind(seedField) : String(seed.field_type || "text");
for (const target of sequence.matches) {
const sourceRowIndex = seedCell.rowIndex + (target.index - sequence.anchorIndex);
if (sourceRowIndex < 1) continue;
const sourceKey = `${seedCell.sourceCode}.row.${sourceRowIndex}.${seedCell.column}`;
const source = mappingSourceByKey(sourceKey);
if (!source || !mappingCompatible(source, targetKind)) continue;
planned.set(String(target.field.name || ""), {
field: target.field,
source,
sourceRowIndex,
column: seedCell.column,
sourceCode: seedCell.sourceCode,
targetIndex: target.index,
});
}
}
const links = [...planned.values()].sort((a, b) => a.targetIndex - b.targetIndex || String(a.field.name).localeCompare(String(b.field.name), "ru", { numeric: true }));
if (!sequentialSeeds) return { links: [], reason: "В имени поля vMix не найдена числовая последовательность, например Name1.Text, Name2.Text…" };
if (links.length < 2) return { links: [], reason: "Не найдено следующих полей vMix или строк SQL для продолжения серии." };
return { links, seedCount: sequentialSeeds, anchorCell };
}
function mappingApplySeries(profile, input, anchorFieldName, mode = "column") {
const plan = mappingSeriesPlan(profile, input, anchorFieldName, mode);
if (!plan.links.length) return plan;
let changed = 0;
let overwritten = 0;
for (const item of plan.links) {
const existing = mappingRowForField(profile, input, item.field.name);
if (existing && String(existing.data_key || "") !== String(item.source.key || "")) overwritten += 1;
if (!existing || String(existing.data_key || "") !== String(item.source.key || "")) changed += 1;
mappingSetLink(profile, input, item.field, item.source);
}
return { ...plan, changed, overwritten };
}
function mappingCompatible(source, targetKind) {
if (!targetKind) return true;
if (targetKind === "image") return source.kind === "image";
return source.kind !== "image";
}
function mappingDataBrowser(profile, currentInput) {
const catalog = mappingDataCatalog();
const targetField = (currentInput?.fields || []).find((field) => String(field.name || "") === state.mappingTargetField) || null;
const targetKind = targetField ? mappingFieldKind(targetField) : "";
const query = String(state.mappingDataSearch || "").trim().toLowerCase();
const multiRowCodes = new Set((catalog.tables || []).filter((table) => Number(table.row_count || table.rows?.length || 0) > 1).map((table) => String(table.code || "")));
const categories = [];
for (const item of catalog.items) {
if (item.table_cell) continue;
// Raw *_RUS/*_ENG columns stay addressable for backwards compatibility, but
// the visual picker prefers one virtual AUTO language field.
if (item.language_explicit) continue;
// Multi-row SQL sources are configured through the table/cell picker below.
if (multiRowCodes.has(String(item.source_code || ""))) continue;
if (!mappingCompatible(item, targetKind)) continue;
const haystack = `${item.label} ${item.category} ${item.key} ${mappingDisplayValue(item.value, item.kind)}`.toLowerCase();
if (query && !haystack.includes(query)) continue;
let category = categories.find((row) => row.name === item.category);
if (!category) { category = { name: item.category, items: [] }; categories.push(category); }
category.items.push(item);
}
const selectedSource = state.mappingSelectedSourceKey;
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)}">
<span class="hockey-map-data-main"><strong>${escapeHtml(item.label)}${item.localized ? ` <i class="hockey-map-lang-auto">AUTO ${String(item.language || "ru").toUpperCase()}</i>` : ""}</strong><em>${escapeHtml(mappingDisplayValue(item.value, item.kind))}</em></span>
<small>${escapeHtml(item.key)}${item.localized ? ` · ${escapeHtml(item.resolved_column || "")}` : ""}</small>
</button>`).join("")}</div>
</details>`).join("");
const tables = (catalog.tables || []).filter((table) => Number(table.row_count || table.rows?.length || 0) > 1);
if (tables.length && !tables.some((table) => String(table.code) === String(state.mappingSelectedTableSource))) {
state.mappingSelectedTableSource = String(tables[0].code || "");
}
const selectedTable = tables.find((table) => String(table.code) === String(state.mappingSelectedTableSource)) || null;
const selectedTableRows = selectedTable?.rows || [];
let selectedTableRow = selectedTableRows.find((row) => Number(row.index || 0) === Number(state.mappingSelectedTableRow || 0)) || selectedTableRows[0] || null;
if (selectedTableRow) state.mappingSelectedTableRow = Number(selectedTableRow.index || 1);
const quickCompatibleColumns = (selectedTable?.columns || []).filter((column) => {
if (!selectedTableRow) return false;
const key = `${selectedTable.code}.row.${selectedTableRow.index}.${column.key}`;
const source = mappingSourceByKey(key);
return source && (!targetKind || mappingCompatible(source, targetKind));
});
if (!quickCompatibleColumns.some((column) => String(column.key) === String(state.mappingSelectedTableColumn))) {
state.mappingSelectedTableColumn = String(quickCompatibleColumns[0]?.key || "");
}
const quickCellKey = selectedTable && selectedTableRow && state.mappingSelectedTableColumn
? `${selectedTable.code}.row.${selectedTableRow.index}.${state.mappingSelectedTableColumn}` : "";
const quickCellSource = quickCellKey ? mappingSourceByKey(quickCellKey) : null;
const quickLinkEnabled = Boolean(targetField && quickCellSource && mappingCompatible(quickCellSource, targetKind));
let tableMarkup = "";
let quickTableMarkup = "";
if (tables.length) {
quickTableMarkup = `
<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>
</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>
<label><span>Столбец</span><select data-map-quick-table-column ${quickCompatibleColumns.length ? "" : "disabled"}>${quickCompatibleColumns.map((column) => `<option value="${escapeHtml(column.key)}" ${String(column.key) === String(state.mappingSelectedTableColumn) ? "selected" : ""}>${escapeHtml(column.label || column.key)}${column.localized ? ` · AUTO ${String(column.language || selectedTable?.language || "ru").toUpperCase()}` : ""} · ${escapeHtml(column.key)}</option>`).join("")}</select></label>
</div>
<div class="hockey-map-sql-cell-preview">
<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>
</details>`;
const rowSearch = String(state.mappingTableRowSearch || "").trim().toLowerCase();
const allRows = selectedTable?.rows || [];
const filteredRows = allRows.filter((row) => {
if (!rowSearch) return true;
const values = Object.values(row.values || {}).map((value) => mappingDisplayValue(value)).join(" ");
return `${row.index || ""} ${row.label || ""} ${values}`.toLowerCase().includes(rowSearch);
});
const visibleRows = filteredRows.slice(0, 120);
const columns = selectedTable?.columns || [];
const tableGridHeight = Math.min(430, Math.max(230, visibleRows.length * 44 + 42));
const cells = visibleRows.map((row) => `
<tr>
<th><b>${Number(row.index || 0)}</b><span>${escapeHtml(row.label || `Строка ${row.index}`)}</span></th>
${columns.map((column) => {
const key = `${selectedTable.code}.row.${row.index}.${column.key}`;
const source = mappingSourceByKey(key);
const compatible = source && mappingCompatible(source, targetKind);
return `<td><button type="button" data-map-source="${escapeHtml(key)}" class="${selectedSource === key ? "is-selected" : ""}" ${compatible ? "" : "disabled"} title="${escapeHtml(key)}"><strong>${escapeHtml(mappingDisplayValue(row.values?.[column.key], column.kind || "text"))}</strong><small>${escapeHtml(column.label || column.key)}</small></button></td>`;
}).join("")}
</tr>`).join("");
tableMarkup = `
<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>
</div>
${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>` : ""}`}
</details>`;
}
const targetHint = targetField
? `<div class="hockey-map-target-hint is-active"><span>Сейчас настраиваем</span><strong>${escapeHtml(currentInput?.title || "Input")}${escapeHtml(targetField.name || "")}</strong><small>Можно выбрать обычное значение или ячейку SQL-таблицы</small></div>`
: `<div class="hockey-map-target-hint"><span>Как связать</span><strong>Выберите поле vMix справа</strong><small>Затем выберите значение или конкретную ячейку таблицы</small></div>`;
return `
<section class="hockey-map-data-browser">
<header><div><span>ДАННЫЕ МАТЧА</span><strong>${escapeHtml(catalog.gameLabel)}</strong></div><button type="button" data-map-live-refresh title="Обновить живые значения">↻</button></header>
${targetHint}
${quickTableMarkup}
${tableMarkup}
<label class="hockey-map-data-search"><span>⌕</span><input data-map-data-search value="${escapeHtml(state.mappingDataSearch)}" placeholder="Обычные значения: название, счёт, игрок, ключ…"></label>
<div class="hockey-map-data-scroll">${groups || `<div class="hockey-directory-empty">Подходящих одиночных данных не найдено.</div>`}</div>
</section>`;
}
function mappingVmixBrowser(profile, currentInput, testDevices) {
const allInputs = mappingInventoryInputs(profile);
const filteredInputs = mappingFilteredInputs(profile);
if (!currentInput) return `<div class="hockey-directory-empty">Agent не передал Inputs текущего vMix.</div>`;
const visibleInputs = filteredInputs.some((item) => mappingInputValue(item) === mappingInputValue(currentInput))
? filteredInputs
: [currentInput, ...filteredInputs];
const allFields = [...(currentInput.fields || [])].sort(mappingVmixFieldOrder);
const filter = ["text", "image", "color"].includes(state.mappingFieldFilter) ? state.mappingFieldFilter : "text";
const counts = {
text: allFields.filter((field) => mappingFieldFilterMatch(field, "text")).length,
image: allFields.filter((field) => mappingFieldFilterMatch(field, "image")).length,
color: allFields.filter((field) => mappingFieldFilterMatch(field, "color")).length,
};
const visibleFields = allFields.filter((field) => {
if (!mappingFieldFilterMatch(field, filter)) return false;
if (state.mappingHideLinked && mappingRowForField(profile, currentInput, field.name)) return false;
return true;
});
const rows = visibleFields.map((field) => {
const row = mappingRowForField(profile, currentInput, field.name);
const source = row ? mappingSourceByKey(row.data_key) : null;
const kind = mappingFieldKind(field);
const isTarget = String(state.mappingTargetField || "") === String(field.name || "");
const value = source ? mappingDisplayValue(source.value, source.kind) : "";
const cellInfo = source?.table_cell ? `<small class="hockey-map-cell-address">Строка ${Number(source.row_index || 0)} · ${escapeHtml(source.column || "")}</small>` : "";
const badge = kind === "image" ? "IMAGE" : kind === "color" ? "COLOR" : "TEXT";
return `
<article class="hockey-map-vmix-field ${row ? "is-linked" : ""} ${isTarget ? "is-target" : ""}" data-map-target="${escapeHtml(field.name || "")}">
<div class="hockey-map-vmix-field-head">
<div><strong>${escapeHtml(field.name || "Без названия")}</strong><span>${badge}</span></div>
${row ? `<button type="button" class="hockey-map-unlink" data-map-unlink="${escapeHtml(field.name || "")}" title="Удалить связь">×</button>` : ""}
</div>
${row ? `
<div class="hockey-map-link-value">
<span>${escapeHtml(source?.source_name || source?.category || "Источник")}</span>
<strong>${escapeHtml(source?.label || row.data_key)}</strong>
<em>${escapeHtml(value || "—")}</em>
${cellInfo}
<small>${escapeHtml(row.data_key)}</small>
</div>
<div class="hockey-map-field-actions">
<button type="button" data-map-choose="${escapeHtml(field.name || "")}">Изменить данные</button>
<button type="button" class="is-accent" data-map-test="${escapeHtml(field.name || "")}" ${testDevices.length ? "" : "disabled"}>Тест поля</button>
</div>
${mappingRuleEditor(row, field, currentInput)}
${source?.table_cell && mappingFieldSequence(currentInput, field.name) ? `<div class="hockey-map-series-actions">
<button type="button" data-map-series-column="${escapeHtml(field.name || "")}" title="Продолжить эту колонку по SQL-строкам и последовательным полям vMix">↧ Автосвязать столбец</button>
<button type="button" data-map-series-row="${escapeHtml(field.name || "")}" title="Размножить все уже настроенные поля этой SQL-строки на следующие строки">⇊ Размножить строку</button>
</div>` : ""}` : `
<button type="button" class="hockey-map-empty-link" data-map-choose="${escapeHtml(field.name || "")}">
<b>+</b><span>Выбрать данные</span><small>Обычное значение или конкретная ячейка SQL</small>
</button>`}
</article>`;
}).join("");
const linkedCount = allFields.filter((field) => mappingRowForField(profile, currentInput, field.name)).length;
return `
<section class="hockey-map-vmix-browser">
<header class="hockey-map-input-header">
<div class="hockey-map-input-choice">
<span>VMIX INPUT</span>
<label class="hockey-map-input-search"><span>⌕</span><input data-map-input-search value="${escapeHtml(state.mappingInputSearch)}" placeholder="Поиск по №, названию или key…"></label>
<select data-map-input-picker>${visibleInputs.map((item) => `<option value="${escapeHtml(mappingInputValue(item))}" ${mappingInputValue(item) === mappingInputValue(currentInput) ? "selected" : ""}>${escapeHtml(mappingInputLabel(item))}</option>`).join("")}</select>
<small>${visibleInputs.length === allInputs.length ? `${allInputs.length} Inputs` : `Найдено ${filteredInputs.length} из ${allInputs.length}`}</small>
<div class="hockey-map-field-toolbar">
<div class="hockey-map-field-tabs" role="group" aria-label="Тип поля vMix">
<button type="button" data-map-field-filter="text" class="${filter === "text" ? "is-active" : ""}">Text <b>${counts.text}</b></button>
<button type="button" data-map-field-filter="image" class="${filter === "image" ? "is-active" : ""}">Image <b>${counts.image}</b></button>
<button type="button" data-map-field-filter="color" class="${filter === "color" ? "is-active" : ""}">Color <b>${counts.color}</b></button>
</div>
<label class="hockey-map-hide-linked"><input type="checkbox" data-map-hide-linked ${state.mappingHideLinked ? "checked" : ""}><span>Скрывать связанные</span></label>
</div>
</div>
<div class="hockey-map-input-progress"><div><b>${linkedCount}</b><span>/ ${Number(allFields.length || 0)} полей</span></div><button type="button" data-map-apply-input ${linkedCount && testDevices.length ? "" : "disabled"}>Применить Input</button></div>
</header>
<div class="hockey-map-vmix-scroll">${rows || `<div class="hockey-directory-empty">${state.mappingHideLinked ? "В этой категории не осталось несвязанных полей." : "У этого Input нет полей выбранного типа."}</div>`}</div>
</section>`;
}
function mappingWorkspaceTabs() {
const tabs = [
["links", "Связи vMix"],
["context", "Переменные"],
["sql", "SQL источники"],
];
return `<nav class="hockey-map-workspace-tabs">${tabs.map(([key, label]) => `<button type="button" data-map-workspace-tab="${key}" class="${state.mappingWorkspaceTab === key ? "is-active" : ""}">${label}</button>`).join("")}</nav>`;
}
function mappingContextPanel() {
const variables = state.mappingCatalog?.variables || state.mappingContextVariables?.variables || [];
const rows = variables.map((item) => {
const system = Boolean(item.is_system || item.source_type === "system");
return `<article class="hockey-map-context-row ${system ? "is-system" : ""}" data-context-row="${Number(item.id || 0)}">
<div class="hockey-map-context-main">
<div><strong>${escapeHtml(item.label || item.key)}</strong><code>${escapeHtml(item.key)}</code></div>
<div class="hockey-map-context-badges"><span>${escapeHtml(item.entity_type || item.value_type || "text")}</span><span>${escapeHtml(item.scope || "match")}</span><span>${escapeHtml(item.source_type || "manual")}</span>${system ? `<b>SYSTEM</b>` : ""}</div>
</div>
<div class="hockey-map-context-live"><span>Сейчас</span><strong>${escapeHtml(mappingDisplayValue(item.value))}</strong></div>
${system ? `<div class="hockey-map-context-system-note">Системное значение задаётся программой автоматически.</div>` : `
<div class="hockey-map-context-edit-grid">
<label>Название<input data-context-label value="${escapeHtml(item.label || "")}"></label>
<label>Категория<input data-context-category value="${escapeHtml(item.category || "Пользовательские")}"></label>
<label>Entity<select data-context-entity>${["", "match", "team", "player", "coach", "referee", "goal", "penalty", "event", "device", "assignment"].map((v) => `<option value="${v}" ${String(item.entity_type || "") === v ? "selected" : ""}>${v || "—"}</option>`).join("")}</select></label>
<label>Scope<select data-context-scope>${["match", "session", "account", "global", "temporary"].map((v) => `<option value="${v}" ${String(item.scope || "match") === v ? "selected" : ""}>${v}</option>`).join("")}</select></label>
<label>Источник<select data-context-source>${["manual", "selection", "derived"].map((v) => `<option value="${v}" ${String(item.source_type || "manual") === v ? "selected" : ""}>${v}</option>`).join("")}</select></label>
<label>Текущее значение<input data-context-current value="${escapeHtml(item.value || "")}" placeholder="ID / значение"></label>
</div>
<div class="hockey-map-context-actions"><button type="button" data-context-save="${Number(item.id || 0)}">Сохранить описание</button><button type="button" class="is-accent" data-context-set="${escapeHtml(item.key)}">Установить значение</button><button type="button" class="danger" data-context-delete="${Number(item.id || 0)}">Удалить</button></div>`}
</article>`;
}).join("");
return `<section class="hockey-map-admin-panel">
<header class="hockey-map-admin-head"><div><span>CONTEXT VARIABLES</span><strong>Идентификаторы и выделенные сущности</strong><small>Эти значения можно использовать как <code>:параметр</code> в SQL. Match-scoped значения автоматически изолированы по матчу и аккаунту.</small></div></header>
<form class="hockey-map-context-create" data-context-create>
<label>Ключ<input name="key" required placeholder="my_special_player_id"></label>
<label>Название<input name="label" required placeholder="Мой выделенный игрок"></label>
<label>Entity<select name="entity_type"><option value="">—</option><option>match</option><option>team</option><option>player</option><option>coach</option><option>referee</option><option>goal</option><option>penalty</option><option>event</option></select></label>
<label>Scope<select name="scope"><option value="match">match</option><option value="session">session</option><option value="account">account</option><option value="global">global</option></select></label>
<label>Источник<select name="source_type"><option value="manual">manual</option><option value="selection">selection</option><option value="derived">derived</option></select></label>
<button type="submit" class="is-accent">+ Создать переменную</button>
</form>
<div class="hockey-map-context-list">${rows || `<div class="hockey-directory-empty">Переменных пока нет.</div>`}</div>
</section>`;
}
function mappingSqlHelp() {
const snippets = [
["Текст", "CONCAT_WS", "CONCAT_WS(' ', last_name, first_name)", "Склеить строки через разделитель"],
["Текст", "COALESCE", "COALESCE(value, '')", "Подставить значение вместо NULL"],
["Текст", "UPPER / LOWER", "UPPER(name)", "Верхний / нижний регистр"],
["Текст", "TRIM", "TRIM(name)", "Убрать пробелы по краям"],
["Текст", "REPLACE", "REPLACE(name, 'old', 'new')", "Заменить часть строки"],
["Текст", "SUBSTRING", "SUBSTRING(name FROM 1 FOR 12)", "Взять часть строки"],
["Условия", "CASE", "CASE WHEN score > 0 THEN 'Да' ELSE 'Нет' END", "Условное значение"],
["Условия", "NULLIF", "NULLIF(value, '')", "Преобразовать значение в NULL при совпадении"],
["Дата/время", "TO_CHAR", "TO_CHAR(game_date, 'DD.MM.YYYY')", "Форматировать дату/время"],
["Дата/время", "EXTRACT", "EXTRACT(YEAR FROM game_date)", "Получить год, месяц, день и т.п."],
["Дата/время", "CURRENT_DATE", "CURRENT_DATE", "Текущая дата PostgreSQL"],
["Дата/время", "Текущее время", "TO_CHAR(NOW(), 'HH24:MI:SS')", "Текущее время PostgreSQL до секунд"],
["Дата/время", "Часовой пояс", "TO_CHAR(NOW() AT TIME ZONE 'Europe/Moscow', 'HH24:MI:SS')", "Время в выбранной IANA-зоне"],
["Числа", "ROUND", "ROUND(value::numeric, 2)", "Округлить число"],
["Числа", "ABS", "ABS(value)", "Модуль числа"],
["Числа", "GREATEST / LEAST", "GREATEST(a, b)", "Максимум / минимум из значений"],
["Типы", "::text", "value::text", "Быстро привести значение к типу"],
["Типы", "CAST", "CAST(value AS integer)", "Стандартное преобразование типа"],
["Агрегация", "COUNT", "COUNT(*)", "Количество строк"],
["Агрегация", "SUM / AVG", "AVG(value)", "Сумма / среднее"],
["Агрегация", "STRING_AGG", "STRING_AGG(name, ', ' ORDER BY name)", "Собрать строки в одну"],
["Таблицы", "ROW_NUMBER", "ROW_NUMBER() OVER (ORDER BY points DESC)", "Нумерация строк без схлопывания таблицы"],
];
const groups = [...new Set(snippets.map((row) => row[0]))];
return `<details class="hockey-map-sql-help">
<summary><span>?</span><div><strong>Подсказки PostgreSQL</strong><small>Функции, форматы и примеры — клик вставляет пример в SQL</small></div></summary>
<div class="hockey-map-sql-help-body">
<div class="hockey-map-sql-help-note"><b>Наш редактор:</b> один read-only <code>SELECT</code> или <code>WITH … SELECT</code>. Завершающий <code>;</code> и SQL-комментарии можно писать. Параметры проекта имеют вид <code>:game_id</code>; двоеточия внутри строк PostgreSQL, например <code>'HH24:MI:SS'</code>, переменными не считаются.</div>
<div class="hockey-map-sql-help-lang"><b>Автовыбор языка:</b> если запрос возвращает пару <code>name_RUS</code> + <code>name_ENG</code>, Mapping создаёт одно виртуальное поле <code>name</code> и сам выбирает нужную колонку по языку интерфейса.</div>
${groups.map((group) => `<section><h5>${escapeHtml(group)}</h5><div>${snippets.filter((row) => row[0] === group).map((row) => `<button type="button" data-sql-snippet="${escapeHtml(row[2])}" title="${escapeHtml(row[3])}"><strong>${escapeHtml(row[1])}</strong><code>${escapeHtml(row[2])}</code><small>${escapeHtml(row[3])}</small></button>`).join("")}</div></section>`).join("")}
</div>
</details>`;
}
function mappingSqlPanel() {
const sources = state.mappingSqlSources?.sources || [];
const selected = state.mappingSelectedSqlSourceId === "new" ? null : sources.find((item) => Number(item.id) === Number(state.mappingSelectedSqlSourceId)) || sources[0] || null;
if (selected && !state.mappingSelectedSqlSourceId) state.mappingSelectedSqlSourceId = selected.id;
const sourceToken = String(state.mappingSelectedSqlSourceId || selected?.id || "new");
const liveDraft = state.mappingSqlDraft && String(state.mappingSqlDraft.__source_id || "") === sourceToken ? state.mappingSqlDraft : null;
const draft = liveDraft || selected || { id: "", code: "", name: "", category: "Данные", description: "", sql_text: "SELECT\n :game_id AS \"game_id\"", enabled: true, auto_refresh_enabled: false, refresh_interval_ms: 1000, sort_order: 1000, field_metadata: {} };
const preview = state.mappingSqlPreview;
const variableChips = (state.mappingCatalog?.variables || []).map((item) => `<button type="button" data-sql-param="${escapeHtml(item.key)}" title="${escapeHtml(item.label || item.key)}"><code>:${escapeHtml(item.key)}</code><span>${escapeHtml(mappingDisplayValue(item.value))}</span></button>`).join("");
const list = [`<button type="button" data-sql-source="new" class="${state.mappingSelectedSqlSourceId === "new" ? "is-active" : ""}"><strong>+ Новый источник</strong><small>Создать SELECT</small></button>`, ...sources.map((item) => `<button type="button" data-sql-source="${item.id}" class="${Number(draft.id) === Number(item.id) && state.mappingSelectedSqlSourceId !== "new" ? "is-active" : ""}"><strong>${escapeHtml(item.name)}</strong><small>${escapeHtml(item.code)} · ${item.enabled ? "ON" : "OFF"}${item.auto_refresh_enabled ? ` · AUTO ${Math.max(1, Number(item.refresh_interval_ms || 1000) / 1000)}с` : ""}</small></button>`)].join("");
let previewMarkup = `<div class="hockey-directory-empty">Нажмите «Проверить SQL», чтобы увидеть реальные колонки и строки текущего матча.</div>`;
if (preview) {
if (preview.error) previewMarkup = `<div class="hockey-directory-empty is-error">${escapeHtml(preview.error)}</div>`;
else if (preview.skipped) previewMarkup = `<div class="hockey-directory-empty">Не хватает переменных: ${escapeHtml((preview.missing || []).map((x) => `:${x}`).join(", "))}</div>`;
else {
const columns = preview.columns || [];
previewMarkup = `<div class="hockey-map-sql-preview-meta">${Number(preview.row_count || 0)} строк · ${columns.length} колонок</div><div class="hockey-map-sql-preview-table"><table><thead><tr>${columns.map((c) => `<th>${escapeHtml(c)}</th>`).join("")}</tr></thead><tbody>${(preview.rows || []).map((row) => `<tr>${columns.map((c) => `<td>${escapeHtml(mappingDisplayValue(row[c]))}</td>`).join("")}</tr>`).join("")}</tbody></table></div>`;
}
}
return `<section class="hockey-map-admin-panel is-sql">
<header class="hockey-map-admin-head"><div><span>SQL DATA SOURCES</span><strong>Динамические ключи из PostgreSQL</strong><small>Read-only PostgreSQL SELECT. Колонки результата автоматически становятся ключами вида <code>source.column</code>. Пары <code>_RUS/_ENG</code> объединяются в языковой AUTO-ключ.</small></div></header>
<div class="hockey-map-sql-layout">
<aside class="hockey-map-sql-list">${list}</aside>
<div class="hockey-map-sql-editor" data-sql-editor data-source-id="${escapeHtml(draft.id || "new")}">
<div class="hockey-map-sql-form-grid">
<label>Код источника<input data-sql-code value="${escapeHtml(draft.code || "")}" placeholder="selected_player"></label>
<label>Название<input data-sql-name value="${escapeHtml(draft.name || "")}" placeholder="Выбранный игрок"></label>
<label>Категория<input data-sql-category value="${escapeHtml(draft.category || "Данные")}"></label>
<label>Порядок<input type="number" data-sql-order value="${Number(draft.sort_order || 1000)}"></label>
</div>
<label class="hockey-map-sql-description">Описание<input data-sql-description value="${escapeHtml(draft.description || "")}"></label>
<div class="hockey-map-sql-refresh ${draft.auto_refresh_enabled ? "is-enabled" : ""}">
<label class="hockey-directory-check"><input type="checkbox" data-sql-auto-refresh ${draft.auto_refresh_enabled ? "checked" : ""}><span>Автообновление в vMix</span></label>
<label>Период, сек<input type="number" min="1" max="3600" step="1" data-sql-refresh-seconds value="${Math.max(1, Math.round(Number(draft.refresh_interval_ms || 1000) / 1000))}" ${draft.auto_refresh_enabled ? "" : "disabled"}></label>
<small>Сервер повторяет только этот SQL. В vMix отправляются только связанные с ним поля и только если значение реально изменилось.</small>
</div>
<div class="hockey-map-sql-params"><span>Доступные параметры — клик вставляет в SQL</span><div>${variableChips}</div></div>
${mappingSqlHelp()}
<label class="hockey-map-sql-code"><span>SQL</span><textarea data-sql-text spellcheck="false">${escapeHtml(draft.sql_text || "")}</textarea></label>
<div class="hockey-map-sql-actions"><label class="hockey-directory-check"><input type="checkbox" data-sql-enabled ${draft.enabled !== false ? "checked" : ""}><span>Источник включён</span></label><button type="button" data-sql-preview>Проверить SQL</button><button type="button" class="is-accent" data-sql-save>${draft.id ? "Сохранить источник" : "Создать источник"}</button>${draft.id ? `<button type="button" class="danger" data-sql-delete>Удалить</button>` : ""}</div>
<section class="hockey-map-sql-preview"><h4>Живой результат</h4>${previewMarkup}</section>
</div>
</div>
</section>`;
}
function renderMapping() {
const modal = ensureModal();
const devices = state.mappingDevices?.devices || [];
const profiles = state.mappingProfiles?.profiles || [];
const profile = state.mappingActiveProfile;
const usableDevices = devices.filter((item) => item.project_fingerprint);
const deviceRows = devices.length ? devices.map((item) => `
<article class="hockey-mapping-device ${item.online ? "is-online" : ""}">
<div><strong>${escapeHtml(item.name || item.device_id)}</strong><code>${escapeHtml(item.device_id)}</code></div>
<span>${item.vmix_connected ? "vMix подключён" : "vMix не найден"}</span>
<span>${Number(item.input_count || 0)} Inputs · ${Number(item.field_count || 0)} полей</span>
<span>${item.mapping ? `Mapping: <b>${escapeHtml(item.mapping.name)}</b> · v${Number(item.mapping.version || 1)}` : (item.project_fingerprint ? "Mapping не назначен" : "Ожидание структуры vMix")}</span>
</article>`).join("") : `<div class="hockey-directory-empty">Agent пока не передал структуру ни одного vMix.</div>`;
const selectedDeviceId = String(localStorage.getItem("hockey.vmix.selected_device") || "").trim();
const selectedDevice = devices.find((item) => String(item.device_id || "") === selectedDeviceId) || null;
const profileButtons = profiles.length ? profiles.map((item) => {
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}" 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>
<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>`;
const loadErrors = Object.entries(state.mappingLoadErrors || {});
const mappingDiagnostics = loadErrors.length ? `<div class="hockey-map-load-errors"><strong>Часть Mapping API недоступна</strong>${loadErrors.map(([key, value]) => `<span><code>${escapeHtml(key)}</code>${escapeHtml(value)}</span>`).join("")}</div>` : "";
let editor = "";
if (state.mappingWorkspaceTab === "context") {
editor = mappingContextPanel();
} else if (state.mappingWorkspaceTab === "sql") {
editor = mappingSqlPanel();
} else if (!profile) {
editor = `<div class="hockey-directory-empty">Выберите профиль слева или создайте новый из подключённого vMix.</div>`;
} else {
const currentInput = mappingCurrentInput(profile);
const matchingDevices = devices.filter((item) => item.project_fingerprint && item.project_fingerprint === profile.project_fingerprint && item.online && item.vmix_connected);
if (!state.mappingTestDeviceId || !matchingDevices.some((item) => item.device_id === state.mappingTestDeviceId)) state.mappingTestDeviceId = matchingDevices[0]?.device_id || "";
const catalog = mappingDataCatalog();
const gameReady = Boolean(catalog.game && (catalog.game.external_id || catalog.game.id));
editor = `
<div class="hockey-mapping-editor-head is-visual">
<div>
<label>Название<input data-mapping-name value="${escapeHtml(profile.name || "")}"></label>
<label>Описание<input data-mapping-description value="${escapeHtml(profile.description || "")}"></label>
</div>
<div class="hockey-mapping-meta">
<span>Версия <b>${Number(profile.version || 1)}</b></span>
<span>Inputs <b>${Number(profile.inventory?.input_count || mappingInventoryInputs(profile).length)}</b></span>
<span>Связей <b>${Number(profile.fields?.length || 0)}</b></span>
</div>
</div>
<div class="hockey-map-livebar ${gameReady ? "is-ready" : ""}">
<div><span>${gameReady ? "● ЖИВОЙ ПРИМЕР" : "○ НЕТ ТЕСТОВОГО МАТЧА"}</span><strong>${escapeHtml(catalog.gameLabel)}</strong><small>${gameReady ? "Значения получены через SQL Data Sources из PostgreSQL." : "Выберите матч; системный context game_id заполнится автоматически."}</small></div>
<label>Тестовый Agent<select data-map-test-device><option value="">— нет подходящего online Agent —</option>${matchingDevices.map((item) => `<option value="${escapeHtml(item.device_id)}" ${item.device_id === state.mappingTestDeviceId ? "selected" : ""}>${escapeHtml(item.name || item.device_id)}</option>`).join("")}</select></label>
</div>
<div class="hockey-map-visual-workspace">
${mappingDataBrowser(profile, currentInput)}
${mappingVmixBrowser(profile, currentInput, matchingDevices)}
</div>
<div class="hockey-mapping-savebar is-visual">
<div class="hockey-map-maintenance">
<select data-map-refresh-device><option value="">Обновить структуру из Agent…</option>${usableDevices.map((item) => `<option value="${escapeHtml(item.device_id)}">${escapeHtml(item.name || item.device_id)} · ${Number(item.input_count || 0)}/${Number(item.field_count || 0)}</option>`).join("")}</select>
<button type="button" data-map-refresh>Считать vMix заново</button>
<button type="button" class="danger" data-map-delete>Удалить конфиг</button>
</div>
<div><button type="button" class="is-accent" data-map-save>Сохранить конфиг</button></div>
</div>`;
}
modal.innerHTML = shellMarkup(`
<div class="hockey-mapping-layout is-visual">
<aside class="hockey-mapping-sidebar">
<h3>vMix проекты</h3>
<div class="hockey-mapping-devices">${deviceRows}</div>
<h3>Конфиги Mapping</h3>
<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>
<form class="hockey-mapping-create" data-mapping-create>
<label>Agent / текущий vMix<select name="device_id" required><option value="">Выберите устройство</option>${usableDevices.map((item) => `<option value="${escapeHtml(item.device_id)}">${escapeHtml(item.name || item.device_id)} · ${Number(item.input_count || 0)} Inputs</option>`).join("")}</select></label>
<label>Название<input name="name" required placeholder="KHL_MAIN_2026"></label>
<label>Описание<input name="description" placeholder="Основной графический пакет"></label>
<button type="submit" class="is-accent" ${usableDevices.length ? "" : "disabled"}>Создать конфиг</button>
</form>
</details>
</aside>
<section class="hockey-mapping-workspace">${statusMarkup()}${mappingDiagnostics}${editor}</section>
</div>`);
bindShell();
bindMapping();
}
function collectMappingFields() {
return [...(state.mappingActiveProfile?.fields || [])].map((row, index) => ({
...row,
// Never re-save a positional vMix Input number when a stable key/title is known.
vmix_input_number: (row.vmix_input_key || row.vmix_input_title) ? "" : String(row.vmix_input_number || ""),
sort_order: index,
})).filter((row) => row.data_key && row.vmix_field);
}
function bindMapping() {
const modal = ensureModal();
// Keep the vMix field list exactly where the operator left it across
// renderMapping() calls (rule edits, save/reload, test/apply actions).
const vmixScroll = modal.querySelector(".hockey-map-vmix-scroll");
if (vmixScroll) {
vmixScroll.scrollTop = Math.max(0, Number(state.mappingVmixScrollTop || 0));
vmixScroll.addEventListener("scroll", () => {
state.mappingVmixScrollTop = vmixScroll.scrollTop;
}, { passive: true });
}
modal.querySelectorAll("[data-map-workspace-tab]").forEach((button) => button.addEventListener("click", () => {
state.mappingWorkspaceTab = button.dataset.mapWorkspaceTab || "links";
state.section = state.mappingWorkspaceTab === "context" ? "mapping_context" : (state.mappingWorkspaceTab === "sql" ? "mapping_sql" : "mapping");
state.mappingSqlPreview = null;
setStatus("");
renderMapping();
}));
modal.querySelector("[data-context-create]")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
await request("/api/hockey/admin/mapping-context/variables", {
method: "POST",
body: JSON.stringify({
key: form.get("key"), label: form.get("label"), category: "Пользовательские", description: "",
value_type: "id", entity_type: form.get("entity_type") || "", scope: form.get("scope") || "match",
source_type: form.get("source_type") || "manual", default_value: "", enabled: true, sort_order: 1000,
}),
});
await loadMapping(state.mappingActiveProfile?.id || null);
setStatus("Переменная создана.");
} catch (error) { setStatus(error.message, true); }
renderMapping();
});
modal.querySelectorAll("[data-context-save]").forEach((button) => button.addEventListener("click", async () => {
const id = Number(button.dataset.contextSave || 0);
const item = (state.mappingCatalog?.variables || state.mappingContextVariables?.variables || []).find((row) => Number(row.id) === id);
const row = button.closest("[data-context-row]");
if (!item || !row) return;
try {
await request(`/api/hockey/admin/mapping-context/variables/${id}`, {
method: "PUT",
body: JSON.stringify({
key: item.key,
label: row.querySelector("[data-context-label]")?.value || item.label,
category: row.querySelector("[data-context-category]")?.value || item.category || "Пользовательские",
description: item.description || "", value_type: item.value_type || "id",
entity_type: row.querySelector("[data-context-entity]")?.value || "",
scope: row.querySelector("[data-context-scope]")?.value || "match",
source_type: row.querySelector("[data-context-source]")?.value || "manual",
default_value: item.default_value || "", enabled: item.enabled !== false, sort_order: Number(item.sort_order || 1000),
}),
});
await loadMapping(state.mappingActiveProfile?.id || null); setStatus(`Переменная ${item.key} сохранена.`);
} catch (error) { setStatus(error.message, true); }
renderMapping();
}));
modal.querySelectorAll("[data-context-set]").forEach((button) => button.addEventListener("click", async () => {
const key = button.dataset.contextSet || "";
const row = button.closest("[data-context-row]");
const value = row?.querySelector("[data-context-current]")?.value || "";
try {
await request(`/api/hockey/context/${encodeURIComponent(key)}`, { method: "POST", body: JSON.stringify({ value, context: mappingRequestContext() }) });
await loadMappingCatalog(); setStatus(`${key} = ${value || "—"}`);
} catch (error) { setStatus(error.message, true); }
renderMapping();
}));
modal.querySelectorAll("[data-context-delete]").forEach((button) => button.addEventListener("click", async () => {
const id = Number(button.dataset.contextDelete || 0);
const item = (state.mappingCatalog?.variables || []).find((row) => Number(row.id) === id);
if (!id || !window.confirm(`Удалить переменную «${item?.label || item?.key || id}»?`)) return;
try {
await request(`/api/hockey/admin/mapping-context/variables/${id}`, { method: "DELETE" });
await loadMapping(state.mappingActiveProfile?.id || null); setStatus("Переменная удалена.");
} catch (error) { setStatus(error.message, true); }
renderMapping();
}));
modal.querySelectorAll("[data-sql-source]").forEach((button) => button.addEventListener("click", () => {
state.mappingSelectedSqlSourceId = button.dataset.sqlSource === "new" ? "new" : Number(button.dataset.sqlSource);
state.mappingSqlPreview = null;
state.mappingSqlDraft = null;
renderMapping();
}));
modal.querySelectorAll("[data-sql-param]").forEach((button) => button.addEventListener("click", () => {
const textarea = modal.querySelector("[data-sql-text]");
if (!textarea) return;
const token = `:${button.dataset.sqlParam}`;
const start = textarea.selectionStart ?? textarea.value.length;
const end = textarea.selectionEnd ?? start;
textarea.value = textarea.value.slice(0, start) + token + textarea.value.slice(end);
textarea.focus(); textarea.setSelectionRange(start + token.length, start + token.length);
}));
modal.querySelectorAll("[data-sql-snippet]").forEach((button) => button.addEventListener("click", () => {
const textarea = modal.querySelector("[data-sql-text]");
if (!textarea) return;
const snippet = String(button.dataset.sqlSnippet || "");
const start = textarea.selectionStart ?? textarea.value.length;
const end = textarea.selectionEnd ?? start;
textarea.value = textarea.value.slice(0, start) + snippet + textarea.value.slice(end);
textarea.focus(); textarea.setSelectionRange(start + snippet.length, start + snippet.length);
state.mappingSqlDraft = null;
}));
modal.querySelector("[data-sql-auto-refresh]")?.addEventListener("change", (event) => {
const seconds = modal.querySelector("[data-sql-refresh-seconds]");
if (seconds) seconds.disabled = !event.currentTarget.checked;
event.currentTarget.closest(".hockey-map-sql-refresh")?.classList.toggle("is-enabled", Boolean(event.currentTarget.checked));
});
const sqlPayload = () => {
const selected = (state.mappingSqlSources?.sources || []).find((item) => Number(item.id) === Number(state.mappingSelectedSqlSourceId));
return {
code: modal.querySelector("[data-sql-code]")?.value || "",
name: modal.querySelector("[data-sql-name]")?.value || "",
category: modal.querySelector("[data-sql-category]")?.value || "Данные",
description: modal.querySelector("[data-sql-description]")?.value || "",
sql_text: modal.querySelector("[data-sql-text]")?.value || "",
field_metadata: selected?.field_metadata || {},
enabled: Boolean(modal.querySelector("[data-sql-enabled]")?.checked),
auto_refresh_enabled: Boolean(modal.querySelector("[data-sql-auto-refresh]")?.checked),
refresh_interval_ms: Math.max(1000, Math.min(3600000, Number(modal.querySelector("[data-sql-refresh-seconds]")?.value || 1) * 1000)),
sort_order: Number(modal.querySelector("[data-sql-order]")?.value || 1000),
};
};
modal.querySelector("[data-sql-preview]")?.addEventListener("click", async () => {
const payload = sqlPayload();
const selected = (state.mappingSqlSources?.sources || []).find((item) => Number(item.id) === Number(state.mappingSelectedSqlSourceId));
// Preview must never reset an unsaved editor. Keep the complete form draft
// in UI state before the async request and restore it on render.
state.mappingSqlDraft = {
...payload,
id: selected?.id || "",
field_metadata: selected?.field_metadata || payload.field_metadata || {},
__source_id: String(state.mappingSelectedSqlSourceId || selected?.id || "new"),
};
try {
state.mappingSqlPreview = await request("/api/hockey/admin/mapping-data/preview-sql", { method: "POST", body: JSON.stringify({ sql_text: payload.sql_text, context: mappingRequestContext() }) });
setStatus(state.mappingSqlPreview.skipped ? "SQL корректен, но не хватает параметров." : "SQL выполнен в read-only режиме.");
} catch (error) { state.mappingSqlPreview = { error: error.message }; setStatus(error.message, true); }
renderMapping();
});
modal.querySelector("[data-sql-save]")?.addEventListener("click", async () => {
try {
const payload = sqlPayload();
const id = state.mappingSelectedSqlSourceId;
const result = id && id !== "new"
? await request(`/api/hockey/admin/mapping-data/sources/${id}`, { method: "PUT", body: JSON.stringify(payload) })
: await request("/api/hockey/admin/mapping-data/sources", { method: "POST", body: JSON.stringify(payload) });
state.mappingSelectedSqlSourceId = result.id;
state.mappingSqlPreview = null;
state.mappingSqlDraft = null;
await loadMapping(state.mappingActiveProfile?.id || null);
setStatus(`SQL-источник «${result.name}» сохранён. Ключи обновлены автоматически.`);
} catch (error) { setStatus(error.message, true); }
renderMapping();
});
modal.querySelector("[data-sql-delete]")?.addEventListener("click", async () => {
const id = Number(state.mappingSelectedSqlSourceId || 0);
const item = (state.mappingSqlSources?.sources || []).find((row) => Number(row.id) === id);
if (!id || !window.confirm(`Удалить SQL-источник «${item?.name || id}»?`)) return;
try {
await request(`/api/hockey/admin/mapping-data/sources/${id}`, { method: "DELETE" });
state.mappingSelectedSqlSourceId = null; state.mappingSqlPreview = null; state.mappingSqlDraft = null;
await loadMapping(state.mappingActiveProfile?.id || null); setStatus("SQL-источник удалён.");
} catch (error) { setStatus(error.message, true); }
renderMapping();
});
modal.querySelector("[data-mapping-name]")?.addEventListener("input", (event) => { if (state.mappingActiveProfile) state.mappingActiveProfile.name = event.currentTarget.value; });
modal.querySelector("[data-mapping-description]")?.addEventListener("input", (event) => { if (state.mappingActiveProfile) state.mappingActiveProfile.description = event.currentTarget.value; });
modal.querySelector("[data-mapping-active]")?.addEventListener("change", (event) => { if (state.mappingActiveProfile) state.mappingActiveProfile.active = Boolean(event.currentTarget.checked); });
modal.querySelectorAll("[data-map-use-profile]").forEach((button) => button.addEventListener("click", async (event) => {
event.stopPropagation();
const profileId = Number(button.dataset.mapUseProfile || 0);
if (!profileId) return;
const selected = (state.mappingProfiles?.profiles || []).find((item) => Number(item.id) === profileId);
const deviceId = String(localStorage.getItem("hockey.vmix.selected_device") || "").trim();
const sessionToken = String(selectedContext().token || "").trim();
button.disabled = true;
button.textContent = "Применяю…";
try {
const result = await request(`/api/hockey/admin/vmix-mapping/profiles/${profileId}/use`, {
method: "POST",
body: JSON.stringify({ device_id: deviceId, session_token: sessionToken }),
});
await loadMapping(profileId);
const applied = result.applied || {};
const report = result.report || {};
const suffix = Number(report.skipped || 0) ? ` · не сопоставлено ${Number(report.skipped || 0)}` : "";
const send = applied.ok === false ? ` · ${escapeHtml(applied.reason || "не удалось отправить данные")}` : ` · отправлено ${Number(applied.applied || 0)} из ${Number(applied.total || 0)}`;
setStatus(`✓ Конфиг «${selected?.name || result.profile?.name || profileId}» выбран для моего Agent${send}${suffix}`, Number(report.skipped || 0) > 0 || applied.ok === false);
} catch (error) {
setStatus(error.message || "Не удалось применить Mapping-конфиг", true);
}
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}`);
state.mappingSelectedInputKey = "";
state.mappingTargetField = "";
state.mappingSelectedSourceKey = "";
state.mappingVmixScrollTop = 0;
setStatus("");
} catch (error) { setStatus(error.message, true); }
renderMapping();
}));
modal.querySelector("[data-mapping-create]")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
const created = await request("/api/hockey/admin/vmix-mapping/profiles", { method: "POST", body: JSON.stringify({ device_id: form.get("device_id"), name: form.get("name"), description: form.get("description") }) });
await loadMapping(created.id);
state.mappingSelectedInputKey = "";
state.mappingVmixScrollTop = 0;
setStatus(`Mapping «${created.name}» создан.`);
} 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 = "";
state.mappingSelectedSourceKey = "";
state.mappingVmixScrollTop = 0;
renderMapping();
});
modal.querySelector("[data-map-input-search]")?.addEventListener("input", (event) => {
state.mappingInputSearch = event.currentTarget.value;
const value = state.mappingInputSearch;
renderMapping();
const input = ensureModal().querySelector("[data-map-input-search]");
if (input) { input.focus(); input.setSelectionRange(value.length, value.length); }
});
modal.querySelectorAll("[data-map-field-filter]").forEach((button) => button.addEventListener("click", () => {
state.mappingFieldFilter = button.dataset.mapFieldFilter || "text";
state.mappingTargetField = "";
state.mappingVmixScrollTop = 0;
renderMapping();
}));
modal.querySelector("[data-map-hide-linked]")?.addEventListener("change", (event) => {
state.mappingHideLinked = Boolean(event.currentTarget.checked);
if (state.mappingHideLinked) state.mappingTargetField = "";
renderMapping();
});
modal.querySelector("[data-map-table-source]")?.addEventListener("change", (event) => {
state.mappingSelectedTableSource = event.currentTarget.value;
state.mappingSelectedTableRow = 1;
state.mappingSelectedTableColumn = "";
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;
state.mappingSelectedTableColumn = "";
renderMapping();
});
modal.querySelector("[data-map-quick-table-row]")?.addEventListener("change", (event) => {
state.mappingSelectedTableRow = Number(event.currentTarget.value || 1);
state.mappingSelectedTableColumn = "";
renderMapping();
});
modal.querySelector("[data-map-quick-table-column]")?.addEventListener("change", (event) => {
state.mappingSelectedTableColumn = event.currentTarget.value;
renderMapping();
});
modal.querySelector("[data-map-table-row-search]")?.addEventListener("input", (event) => {
state.mappingTableRowSearch = event.currentTarget.value;
const value = state.mappingTableRowSearch;
renderMapping();
const input = ensureModal().querySelector("[data-map-table-row-search]");
if (input) { input.focus(); input.setSelectionRange(value.length, value.length); }
});
modal.querySelector("[data-map-test-device]")?.addEventListener("change", (event) => { state.mappingTestDeviceId = event.currentTarget.value; });
modal.querySelector("[data-map-data-search]")?.addEventListener("input", (event) => {
state.mappingDataSearch = event.currentTarget.value;
const value = state.mappingDataSearch;
renderMapping();
const input = ensureModal().querySelector("[data-map-data-search]");
if (input) { input.focus(); input.setSelectionRange(value.length, value.length); }
});
modal.querySelector("[data-map-live-refresh]")?.addEventListener("click", async () => { await loadMappingCatalog(); renderMapping(); });
const chooseTarget = (fieldName) => {
const profile = state.mappingActiveProfile;
const input = mappingCurrentInput(profile);
const field = (input?.fields || []).find((item) => String(item.name || "") === String(fieldName || ""));
if (!field) return;
state.mappingTargetField = fieldName;
if (state.mappingSelectedSourceKey) {
const source = mappingSourceByKey(state.mappingSelectedSourceKey);
if (source && mappingCompatible(source, mappingFieldKind(field))) {
mappingSetLink(profile, input, field, source);
state.mappingTargetField = "";
state.mappingSelectedSourceKey = "";
}
}
renderMapping();
};
modal.querySelectorAll("[data-map-target]").forEach((node) => node.addEventListener("click", (event) => {
// Controls inside a linked vMix card (especially rule <select>s) must not
// bubble into the card click handler. Otherwise opening a dropdown
// selects the whole field and renderMapping() destroys the dropdown.
if (event.target.closest("button, input, select, textarea, label, a, [contenteditable=\"true\"], .hockey-map-rule-wrap")) return;
chooseTarget(node.dataset.mapTarget);
}));
modal.querySelectorAll("[data-map-choose]").forEach((button) => button.addEventListener("click", () => chooseTarget(button.dataset.mapChoose)));
modal.querySelector("[data-map-quick-cell-link]")?.addEventListener("click", () => {
const profile = state.mappingActiveProfile;
const input = mappingCurrentInput(profile);
const field = (input?.fields || []).find((item) => String(item.name || "") === String(state.mappingTargetField || ""));
const table = (mappingDataCatalog().tables || []).find((item) => String(item.code || "") === String(state.mappingSelectedTableSource || ""));
const rowIndex = Number(state.mappingSelectedTableRow || 0);
const column = String(state.mappingSelectedTableColumn || "");
const key = table && rowIndex && column ? `${table.code}.row.${rowIndex}.${column}` : "";
const source = key ? mappingSourceByKey(key) : null;
if (!input || !field) return setStatus("Сначала выберите поле vMix справа.", true), renderMapping();
if (!source) return setStatus("Не удалось получить выбранную SQL-ячейку.", true), renderMapping();
if (!mappingCompatible(source, mappingFieldKind(field))) return setStatus(`Тип SQL-ячейки не подходит для ${field.name}.`, true), renderMapping();
mappingSetLink(profile, input, field, source);
state.mappingTargetField = "";
state.mappingSelectedSourceKey = "";
setStatus(`Связано: ${field.name}${table.name} / строка ${rowIndex} / ${column} · сейчас «${mappingDisplayValue(source.value, source.kind)}»`);
renderMapping();
});
modal.querySelectorAll("[data-map-source]").forEach((button) => button.addEventListener("click", () => {
const key = button.dataset.mapSource;
const source = mappingSourceByKey(key);
const profile = state.mappingActiveProfile;
const input = mappingCurrentInput(profile);
if (!source || !input) return;
if (!state.mappingTargetField) {
state.mappingSelectedSourceKey = state.mappingSelectedSourceKey === key ? "" : key;
setStatus(state.mappingSelectedSourceKey ? `Выбрано «${source.label}». Теперь нажмите поле vMix справа.` : "");
return renderMapping();
}
const field = (input.fields || []).find((item) => String(item.name || "") === String(state.mappingTargetField));
if (!field) return;
if (!mappingCompatible(source, mappingFieldKind(field))) {
setStatus(`Тип данных не подходит для поля ${field.name}.`, true);
return renderMapping();
}
mappingSetLink(profile, input, field, source);
state.mappingTargetField = "";
state.mappingSelectedSourceKey = "";
setStatus(`Связано: ${field.name}${source.label}`);
renderMapping();
}));
const runSeriesLink = (fieldName, mode) => {
const profile = state.mappingActiveProfile;
const input = mappingCurrentInput(profile);
if (!profile || !input) return;
const plan = mappingSeriesPlan(profile, input, fieldName, mode);
if (!plan.links.length) {
setStatus(plan.reason || "Не удалось построить серию связей.", true);
return renderMapping();
}
const anchor = mappingRowForField(profile, input, fieldName);
const seedCount = Number(plan.seedCount || 1);
const preview = plan.links.slice(0, 6).map((item) => `${item.field.name} ← row.${item.sourceRowIndex}.${item.column}`).join("\n");
const extra = plan.links.length > 6 ? `\n… ещё ${plan.links.length - 6}` : "";
const title = mode === "row"
? `Размножить ${seedCount} пол${seedCount === 1 ? "е" : "я"} шаблонной строки на всю серию?`
: "Автоматически продолжить эту колонку по строкам?";
if (!window.confirm(`${title}\n\nШаблон: ${fieldName}${anchor?.data_key || ""}\nБудет обработано связей: ${plan.links.length}\n\n${preview}${extra}`)) return;
const result = mappingApplySeries(profile, input, fieldName, mode);
setStatus(`${mode === "row" ? "Строка размножена" : "Столбец автосвязан"}: ${result.changed} новых/изменённых связей${result.overwritten ? ` · перезаписано ${result.overwritten}` : ""}. Нажмите «Сохранить mapping».`);
renderMapping();
};
modal.querySelectorAll("[data-map-series-column]").forEach((button) => button.addEventListener("click", (event) => {
event.stopPropagation();
runSeriesLink(button.dataset.mapSeriesColumn, "column");
}));
modal.querySelectorAll("[data-map-series-row]").forEach((button) => button.addEventListener("click", (event) => {
event.stopPropagation();
runSeriesLink(button.dataset.mapSeriesRow, "row");
}));
modal.querySelectorAll("[data-map-rule-toggle]").forEach((button) => button.addEventListener("click", (event) => {
event.stopPropagation();
state.mappingRuleField = state.mappingRuleField === button.dataset.mapRuleToggle ? "" : button.dataset.mapRuleToggle;
renderMapping();
}));
modal.querySelectorAll("[data-map-rule-enabled]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { enabled: control.checked }); renderMapping(); }));
modal.querySelectorAll("[data-map-rule-action]").forEach((control) => control.addEventListener("change", () => {
const action = control.value;
mappingPatchRule(control.dataset.ruleField, { action, true_value: action === "visibility" ? "on" : "#E5CEA8", false_value: action === "visibility" ? "off" : "#FFFFFF", enabled: true });
renderMapping();
}));
modal.querySelectorAll("[data-map-rule-left-key]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { left_key: control.value, enabled: true }); renderMapping(); }));
modal.querySelectorAll("[data-map-rule-operator]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { operator: control.value, enabled: true }); renderMapping(); }));
modal.querySelectorAll("[data-map-rule-right-mode]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { right_mode: control.value, enabled: true }); renderMapping(); }));
modal.querySelectorAll("[data-map-rule-right-key]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { right_key: control.value, enabled: true }); renderMapping(); }));
modal.querySelectorAll("[data-map-rule-right-value]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { right_value: control.value, enabled: true }); renderMapping(); }));
modal.querySelectorAll("[data-map-rule-true]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { true_value: control.value, enabled: true }); renderMapping(); }));
modal.querySelectorAll("[data-map-rule-false]").forEach((control) => control.addEventListener("change", () => { mappingPatchRule(control.dataset.ruleField, { false_value: control.value, enabled: true }); renderMapping(); }));
modal.querySelectorAll("[data-map-rule-preset]").forEach((button) => button.addEventListener("click", () => { mappingPatchRule(button.dataset.ruleField, { operator: button.dataset.mapRulePreset, right_mode: "field", enabled: true }); renderMapping(); }));
modal.querySelectorAll("[data-map-rule-clear]").forEach((button) => button.addEventListener("click", () => {
const { row } = mappingRuleRow(state.mappingActiveProfile, mappingCurrentInput(state.mappingActiveProfile), button.dataset.mapRuleClear);
if (row) row.rule = {};
renderMapping();
}));
modal.querySelectorAll("[data-map-rule-mirror]").forEach((button) => button.addEventListener("click", () => {
const result = mappingMirrorRule(button.dataset.mapRuleMirror);
setStatus(result.ok ? `Зеркальное правило создано для ${result.target}. Нажмите «Сохранить mapping».` : result.reason, !result.ok);
renderMapping();
}));
modal.querySelectorAll("[data-map-rule-series]").forEach((button) => button.addEventListener("click", () => {
const result = mappingCloneRuleSeries(button.dataset.mapRuleSeries);
setStatus(result.changed ? `Правило размножено на ${result.changed} связанных полей. Нажмите «Сохранить mapping».` : "Не удалось найти связанную последовательность полей.", !result.changed);
renderMapping();
}));
modal.querySelectorAll("[data-map-unlink]").forEach((button) => button.addEventListener("click", (event) => {
event.stopPropagation();
mappingRemoveLink(state.mappingActiveProfile, mappingCurrentInput(state.mappingActiveProfile), button.dataset.mapUnlink);
if (state.mappingTargetField === button.dataset.mapUnlink) state.mappingTargetField = "";
renderMapping();
}));
modal.querySelectorAll("[data-map-test]").forEach((button) => button.addEventListener("click", async (event) => {
event.stopPropagation();
const profile = state.mappingActiveProfile;
const input = mappingCurrentInput(profile);
const field = (input?.fields || []).find((item) => String(item.name || "") === String(button.dataset.mapTest || ""));
const row = field ? mappingRowForField(profile, input, field.name) : null;
const source = row ? mappingSourceByKey(row.data_key) : null;
const deviceId = state.mappingTestDeviceId;
if (!deviceId) return setStatus("Выберите тестовый Agent.", true), renderMapping();
if (!row || !source) return setStatus("Сначала свяжите поле с данными.", true), renderMapping();
if (source.value === "" || source.value === null || source.value === undefined) return setStatus(`Для «${source.label}» в текущем матче нет значения.`, true), renderMapping();
const commands = [{
input: row.vmix_input_key || row.vmix_input_title || row.vmix_input_number,
selected_name: row.vmix_field, value: String(source.value),
field_type: row.field_type || mappingFieldKind(field), data_key: row.data_key,
}];
const ruleCommand = mappingRuleCommand(row, field);
if (ruleCommand) commands.push(ruleCommand);
button.disabled = true; button.textContent = "Отправка…";
try {
const result = await request("/api/hockey/admin/vmix-mapping/test-batch", { method: "POST", body: JSON.stringify({ device_id: deviceId, commands }) });
setStatus(`${row.vmix_field}: значение${ruleCommand ? " + правило" : ""} отправлено в vMix · ${Number(result.applied || 0)} команд.`);
} catch (error) { setStatus(error.message, true); }
renderMapping();
}));
modal.querySelector("[data-map-apply-input]")?.addEventListener("click", async () => {
const deviceId = state.mappingTestDeviceId || modal.querySelector("[data-map-test-device]")?.value || "";
if (!deviceId) return setStatus("Выберите online Agent для применения Input.", true), renderMapping();
await loadMappingCatalog();
const profile = state.mappingActiveProfile;
const input = mappingCurrentInput(profile);
const commands = [];
const missing = [];
for (const field of (input?.fields || [])) {
const row = mappingRowForField(profile, input, field.name);
if (!row) continue;
const source = mappingSourceByKey(row.data_key);
if (!source) { missing.push(row.data_key); continue; }
commands.push({
input: row.vmix_input_key || row.vmix_input_title || row.vmix_input_number,
selected_name: row.vmix_field,
value: source.value == null ? "" : String(source.value),
field_type: row.field_type || mappingFieldKind(field),
data_key: row.data_key,
});
const ruleCommand = mappingRuleCommand(row, field);
if (ruleCommand) commands.push(ruleCommand);
}
if (!commands.length) return setStatus("У текущего Input нет настроенных связей с доступными данными.", true), renderMapping();
try {
const result = await request("/api/hockey/admin/vmix-mapping/test-batch", { method: "POST", body: JSON.stringify({ device_id: deviceId, commands }) });
const tail = `${missing.length ? ` · нет данных ${missing.length}` : ""}${result.errors?.length ? ` · ошибок ${result.errors.length}` : ""}`;
setStatus(`Input ${input?.number ? `#${input.number} ` : ""}${input?.title || ""}: отправлено ${Number(result.applied || 0)} из ${Number(result.total || commands.length)} полей${tail}.`, Boolean(result.errors?.length));
} 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();
try {
const result = await request(`/api/hockey/admin/vmix-mapping/apply/${encodeURIComponent(deviceId)}`, { method: "POST" });
const suffix = result.errors?.length ? ` · ошибок ${result.errors.length}` : "";
setStatus(`Mapping применён: ${Number(result.applied || 0)} из ${Number(result.total || 0)} связей${suffix}.`);
} catch (error) { setStatus(error.message, true); }
renderMapping();
});
modal.querySelector("[data-map-save]")?.addEventListener("click", async () => {
if (!state.mappingActiveProfile) return;
const currentScroll = modal.querySelector(".hockey-map-vmix-scroll");
if (currentScroll) state.mappingVmixScrollTop = currentScroll.scrollTop;
const id = state.mappingActiveProfile.id;
try {
await request(`/api/hockey/admin/vmix-mapping/profiles/${id}`, { method: "PUT", body: JSON.stringify({ name: modal.querySelector("[data-mapping-name]")?.value || state.mappingActiveProfile.name, description: modal.querySelector("[data-mapping-description]")?.value || "", active: modal.querySelector("[data-mapping-active]") ? Boolean(modal.querySelector("[data-mapping-active]")?.checked) : state.mappingActiveProfile.active !== false }) });
const result = await request(`/api/hockey/admin/vmix-mapping/profiles/${id}/fields`, { method: "PUT", body: JSON.stringify({ fields: collectMappingFields() }) });
await loadMapping(id); setStatus(`Mapping сохранён · версия ${result.version}.`);
} catch (error) { setStatus(error.message, true); }
renderMapping();
});
modal.querySelector("[data-map-refresh]")?.addEventListener("click", async () => {
const deviceId = modal.querySelector("[data-map-refresh-device]")?.value || "";
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;
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();
});
modal.querySelector("[data-map-delete]")?.addEventListener("click", async () => {
if (!state.mappingActiveProfile || !window.confirm(`Удалить mapping «${state.mappingActiveProfile.name}»?`)) return;
try {
await request(`/api/hockey/admin/vmix-mapping/profiles/${state.mappingActiveProfile.id}`, { method: "DELETE" });
state.mappingActiveProfile = null; state.mappingSelectedInputKey = ""; state.mappingVmixScrollTop = 0; await loadMapping(); setStatus("Mapping удалён.");
} catch (error) { setStatus(error.message, true); }
renderMapping();
});
}
function renderKhlSite() {
if (!isKhlTournamentSelected()) {
state.section = "teams";
return renderTeams();
}
const context = selectedContext();
const title = context.tournament?.name || context.tournament?.full_name || `Турнир ${context.tournamentId || "КХЛ"}`;
const content = `
<div class="hockey-khl-site-head">
<div><span>КХЛ · официальный сайт</span><strong>${escapeHtml(title)}</strong></div>
<small>Игроки, тренеры, судьи и статистика KHL.ru</small>
</div>
<div class="hockey-khl-site-frame-wrap">
<iframe class="hockey-khl-site-frame" src="/khl-site/" title="КХЛ сайт" loading="eager"></iframe>
</div>`;
const modal = ensureModal();
modal.innerHTML = shellMarkup(content);
bindShell();
}
function render() {
if (!state.open) return;
if (state.section === "timer_rules") renderTimerRules();
else if (state.section === "penalties") renderPenalties();
else if (state.section === "vmix") renderVmixSettings();
else if (isMappingSection()) renderMapping();
else if (state.section === "khl_site") renderKhlSite();
else if (state.section === "teams") renderTeams();
else renderGeneric(state.section);
}
function bindShell() {
const modal = ensureModal();
modal.querySelector("[data-directory-close]")?.addEventListener("click", closeModal);
modal.querySelectorAll("[data-directory-section]").forEach((button) => {
button.addEventListener("click", async () => {
state.section = button.dataset.directorySection;
if (isMappingSection()) state.mappingWorkspaceTab = mappingTabFromSection();
state.editingTeam = null;
state.editingPenalty = null;
state.editingRecord = null;
state.status = "";
try {
if (state.section === "timer_rules") await loadTimerRules();
else if (state.section === "penalties") await loadPenalties(true);
else if (state.section === "vmix") await loadVmixSettings();
else if (isMappingSection()) await loadMapping();
else if (state.section === "khl_site") { /* iframe loads its own KHL module */ }
else if (state.section === "teams") await loadTeams();
else await loadGeneric(state.section);
} catch (error) {
setStatus(error.message, true);
}
render();
});
});
}
function bindTeams() {
const modal = ensureModal();
modal.querySelector("[data-team-league-filter]")?.addEventListener("change", async (event) => {
state.teamLeague = event.target.value;
state.editingTeam = null;
try {
await loadTeams();
setStatus("");
} catch (error) {
setStatus(error.message, true);
}
renderTeams();
});
modal.querySelector("[data-sync-teams]")?.addEventListener("click", async (event) => {
const button = event.currentTarget;
const tournamentId = selectedContext().tournamentId;
const params = new URLSearchParams();
if (tournamentId) params.set("tournament_external_id", tournamentId);
button.disabled = true;
try {
const result = await request(`/api/hockey/directories/teams/sync?${params}`, { method: "POST" });
await loadTeams();
setStatus(`Справочник обновлён: новых — ${result.created}, обновлено — ${result.updated}.`);
} catch (error) {
setStatus(error.message, true);
}
renderTeams();
});
modal.querySelector("[data-new-team]")?.addEventListener("click", () => {
state.editingTeam = null;
setStatus("");
renderTeams();
modal.querySelector("[data-team-form]")?.scrollIntoView({ behavior: "smooth", block: "start" });
});
modal.querySelectorAll("[data-edit-team]").forEach((button) => {
button.addEventListener("click", () => {
state.editingTeam = state.teams.items.find((item) => String(item.id) === button.dataset.editTeam) || null;
setStatus("");
renderTeams();
});
});
modal.querySelector("[data-cancel-team]")?.addEventListener("click", () => {
state.editingTeam = null;
setStatus("");
renderTeams();
});
const colorText = modal.querySelector("[data-team-color-text]");
const colorPicker = modal.querySelector("[data-team-color-picker]");
const colorPreview = modal.querySelector("[data-team-color-preview]");
const syncTeamColorUi = (rawValue, { commit = false } = {}) => {
const normalized = normalizeTeamColorHex(rawValue);
if (normalized) {
if (colorText && (commit || colorText.value !== rawValue)) colorText.value = normalized;
if (colorPicker) colorPicker.value = normalized;
if (colorPreview) colorPreview.style.setProperty("--team-color", normalized);
return normalized;
}
if (commit && colorText && String(rawValue || "").trim()) {
setStatus("Цвет команды должен быть в формате #RRGGBB", true);
}
if (!String(rawValue || "").trim()) {
if (colorText && commit) colorText.value = "";
if (colorPicker) colorPicker.value = "#FFFFFF";
if (colorPreview) colorPreview.style.setProperty("--team-color", "#FFFFFF");
}
return "";
};
modal.querySelector("[data-team-color-open]")?.addEventListener("click", () => colorPicker?.click());
colorPicker?.addEventListener("input", () => {
if (colorText) colorText.value = String(colorPicker.value || "").toUpperCase();
syncTeamColorUi(colorPicker.value);
});
colorText?.addEventListener("input", () => {
const raw = String(colorText.value || "");
const compact = raw.replace(/\s+/g, "").toUpperCase();
if (raw !== compact) colorText.value = compact;
const normalized = normalizeTeamColorHex(compact);
if (normalized) syncTeamColorUi(normalized);
});
colorText?.addEventListener("blur", () => syncTeamColorUi(colorText.value, { commit: true }));
modal.querySelector("[data-team-color-clear]")?.addEventListener("click", () => {
if (colorText) colorText.value = "";
syncTeamColorUi("", { commit: true });
colorText?.focus();
});
modal.querySelector("[data-team-form]")?.addEventListener("submit", async (event) => {
event.preventDefault();
const form = event.currentTarget;
const values = new FormData(form);
const rawTeamColor = String(values.get("color_hex") || "").trim();
const normalizedTeamColor = normalizeTeamColorHex(rawTeamColor);
if (rawTeamColor && !normalizedTeamColor) {
setStatus("Цвет команды должен быть в формате #RRGGBB", true);
colorText?.focus();
return;
}
const payload = {
external_id: values.get("external_id") || state.editingTeam?.external_id || "",
league_key: values.get("league_key") || "",
tournament_external_id: values.get("tournament_external_id") || "",
name_ru: values.get("name_ru") || "",
name_en: values.get("name_en") || "",
short_name_ru: values.get("short_name_ru") || "",
short_name_en: values.get("short_name_en") || "",
city_ru: values.get("city_ru") || "",
city_en: values.get("city_en") || "",
color_hex: normalizedTeamColor,
logo_url: values.get("logo_url") || "",
active: values.has("active"),
};
const editingId = state.editingTeam?.id;
try {
await request(
editingId ? `/api/hockey/directories/teams/${editingId}` : "/api/hockey/directories/teams",
{ method: editingId ? "PUT" : "POST", body: JSON.stringify(payload) }
);
state.editingTeam = null;
await loadTeams();
setStatus("Команда сохранена.");
} catch (error) {
setStatus(error.message, true);
}
renderTeams();
});
}
function bindPenalties() {
const modal = ensureModal();
modal.querySelector("[data-new-penalty]")?.addEventListener("click", () => {
state.editingPenalty = null;
setStatus("");
renderPenalties();
});
modal.querySelectorAll("[data-edit-penalty]").forEach((button) => {
button.addEventListener("click", () => {
state.editingPenalty = state.penalties.items.find((item) => String(item.id) === button.dataset.editPenalty) || null;
setStatus("");
renderPenalties();
});
});
modal.querySelector("[data-cancel-penalty]")?.addEventListener("click", () => {
state.editingPenalty = null;
setStatus("");
renderPenalties();
});
modal.querySelector("[data-penalty-form]")?.addEventListener("submit", async (event) => {
event.preventDefault();
const values = new FormData(event.currentTarget);
const rawOrder = String(values.get("sort_order") || "").trim();
const payload = {
code: values.get("code") || "",
name_ru: values.get("name_ru") || "",
name_en: values.get("name_en") || "",
default_preset: values.get("default_preset") || "2",
team_penalty: values.has("team_penalty"),
sort_order: rawOrder ? Number(rawOrder) : null,
active: values.has("active"),
};
const editingId = state.editingPenalty?.id;
try {
await request(
editingId ? `/api/hockey/directories/penalties/${editingId}` : "/api/hockey/directories/penalties",
{ method: editingId ? "PUT" : "POST", body: JSON.stringify(payload) }
);
state.editingPenalty = null;
await loadPenalties(true);
setStatus("Удаление сохранено и обновлено в основном меню.");
window.dispatchEvent(new CustomEvent("hockey:directories-updated", { detail: { type: "penalties" } }));
} catch (error) {
setStatus(error.message, true);
}
renderPenalties();
});
}
async function openModal() {
if (!state.isAdmin) return;
state.open = true;
document.body.classList.add("hockey-admin-settings-open");
window.dispatchEvent(new CustomEvent("hockey:admin-settings-visibility", { detail: { open: true } }));
state.status = "";
const modal = ensureModal();
modal.classList.remove("hidden");
modal.innerHTML = shellMarkup(`<div class="hockey-directory-loading">Загрузка справочников…</div>`);
bindShell();
try {
if (state.section === "timer_rules") await loadTimerRules();
else if (state.section === "penalties") await loadPenalties(true);
else if (state.section === "vmix") await loadVmixSettings();
else if (isMappingSection()) await loadMapping();
else if (state.section === "khl_site") { if (!isKhlTournamentSelected()) state.section = "teams"; }
else if (state.section === "teams") await loadTeams();
else await loadGeneric(state.section);
} catch (error) {
setStatus(error.message, true);
}
render();
}
async function init() {
const gear = document.getElementById("runtimeAdminSettingsBtn");
gear?.addEventListener("click", openModal);
loadPenalties(false).catch(() => {
// The hard-coded v20.2 list remains available when the API is offline.
});
try {
const user = await request("/api/hockey/me");
state.isAdmin = Boolean(user.is_admin);
gear?.classList.toggle("hidden", !state.isAdmin);
} catch (_) {
gear?.classList.add("hidden");
}
}
window.addEventListener("hockey:game-selected", async (event) => {
state.language = event.detail?.language === "en" ? "en" : "ru";
try {
await loadPenalties(state.isAdmin && state.open && state.section === "penalties");
if (state.open && state.section === "teams") await loadTeams();
else if (state.open && state.section === "timer_rules") await loadTimerRules();
else if (state.open && state.section === "vmix") await loadVmixSettings(true);
else if (state.open && isMappingSection()) await loadMapping();
else if (state.open && state.section === "khl_site" && !isKhlTournamentSelected()) state.section = "teams";
else if (state.open && genericSections[state.section]) await loadGeneric(state.section);
} catch (_) {
return;
}
render();
});
window.addEventListener("hockey:settings-updated", async (event) => {
const lang = String(event.detail?.settings?.ui_language || event.detail?.settings?.settings?.ui_language || "").toLowerCase();
if (lang === "ru" || lang === "en") state.language = lang;
if (!state.open || !isMappingSection()) return;
try { await loadMappingCatalog(); } catch (_) {}
render();
});
window.addEventListener("hockey:mapping-context-updated", () => {
if (!state.open || !isMappingSection()) return;
if (state.mappingContextRefreshTimer) clearTimeout(state.mappingContextRefreshTimer);
state.mappingContextRefreshTimer = setTimeout(async () => {
state.mappingContextRefreshTimer = null;
try { await loadMappingCatalog(); } catch (_) {}
renderMapping();
}, 80);
});
window.addEventListener("hockey:tournament-selected", () => {
if (!state.open) return;
if (state.section === "khl_site" && !isKhlTournamentSelected()) state.section = "teams";
render();
});
document.addEventListener("keydown", (event) => {
if (event.key === "Escape" && state.open) closeModal();
});
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init, { once: true });
} else {
init();
}
})();