(() => { "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, mappingDatabaseSchema: 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("<", "<") .replaceAll(">", ">") .replaceAll('"', """) .replaceAll("'", "'"); 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 `${escapeHtml(code.toUpperCase())}`; 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 mappingApplyTransportSummary(result) { const chunksTotal = Number(result?.batch_chunks_total || 0); const chunksApplied = Number(result?.batch_chunks_applied || 0); const retries = Number(result?.batch_retries || 0); const fallback = Number(result?.fallback_commands || 0); const inputs = Number(result?.input_groups || 0); const parts = []; if (inputs) parts.push(`Input: ${inputs}`); if (chunksTotal) parts.push(`пакетов ${chunksApplied}/${chunksTotal}`); if (retries) parts.push(`повторов ${retries}`); if (fallback) parts.push(`одиночных ${fallback}`); return parts.length ? ` · ${parts.join(" · ")}` : ""; } 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 `
${escapeHtml(state.status)}
`; } 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 ` `; } function leagueOptions(selected) { const leagues = state.teams?.leagues || []; return leagues.map((item) => ` `).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 ? `
Лига выбранного матча${escapeHtml(data.selected_league_label || lockedLeague.toUpperCase())}
` : ` `; const rows = data.items.length ? data.items.map((item) => ` ${escapeHtml(item.external_id)} ${escapeHtml(item.name_ru || "—")}${escapeHtml(item.short_name_ru || "")} ${escapeHtml(item.name_en || "—")}${escapeHtml(item.short_name_en || "")} ${escapeHtml(item.city_ru || item.city_en || "—")} ${item.color_hex ? `${escapeHtml(item.color_hex)}` : "—"} ${escapeHtml(item.source === "manual" ? "изменено" : "Stat2TV")} `).join("") : `Для этой лиги команды ещё не загружены. Нажмите «Обновить из API».`; const content = `
${leagueControl}

Команды формируются из матчей Stat2TV и разделяются по лигам. При выбранном матче показывается только его лига.

${statusMarkup()}
${rows}
ID APIРусскийEnglishГородЦветИсточник

${editing ? "Редактирование команды" : "Новая команда"}

${leagueOptions(formLeague)}
${editing ? `` : ""}
`; 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) => ` ${escapeHtml(item.code)} ${escapeHtml(item.name_ru)} ${escapeHtml(item.name_en)} ${escapeHtml(item.default_preset)} ${item.team_penalty ? "Да" : "Нет"} ${item.active ? "Да" : "Нет"} `).join("") : `Справочник удалений пуст.`; const content = `
Общий справочникДля всех видов хоккея

Активные записи автоматически появляются в списке нарушений основного меню.

${statusMarkup()}
${rows}
КодРусскийEnglishШтрафКомандноеАктивно

${editing ? "Редактирование удаления" : "Новое удаление"}

${editing ? `` : ""}
`; 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 = `
`; if (section === "players") return `${base}
`; if (section === "coaches") return `${base}
`; return `${base}
`; } 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 `${countryFlagMarkup(item)}${escapeHtml(item.iso2 || item.iso3 || item.external_id)}${escapeHtml(item.name_ru || "—")}${escapeHtml(item.name_en || "")}${escapeHtml(item.iso3 || "—")}`; 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 `${countryFlagMarkup(item)}${escapeHtml(item.external_id)}${escapeHtml(item.full_name_ru || item.name || "—")}${escapeHtml(item.full_name_en || "")}${escapeHtml(team || "—")}${escapeHtml(extras)}${item.birth_date ? `${escapeHtml(item.birth_date)}${item.age !== null && item.age !== undefined ? ` · ${escapeHtml(item.age)} лет` : ""}` : "—"}`; }).join("") : `Данные ещё не загружены.`; const needsTournament = section !== "countries"; const form = section === "countries" ? `
` : genericPersonFields(section, editing, context); const content = `

${needsTournament ? (context.tournamentId ? `Текущий турнир: ${escapeHtml(context.tournamentId)}.` : "Выберите матч или турнир, чтобы загрузить данные его лиги и сезона.") : "Глобальный справочник стран и связей игроков со странами."}

${statusMarkup()}
${section === "countries" ? "" : ""}${rows}
ФлагКодНазваниеISO-3ФлагIDИмяКоманда / данныеДата рождения

${editing ? `Редактирование: ${escapeHtml(config.title)}` : `Новая запись: ${escapeHtml(config.title)}`}

${form}
${editing ? '' : ""}
`; 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 ` `; } 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 = [``]; 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(``); }); if (selected && !found) { options.push(``); } 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 `
${escapeHtml(row.side)}${escapeHtml(row.title)}
`; }).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) => `
${escapeHtml(row.title)}${row.hint ? `${escapeHtml(row.hint)}` : ""}
ФорматРусскийEnglish
${[["compact","Компактный"],["short","Короткий"],["long","Полный"]].map(([formatKey, label]) => ` `).join("")}
`).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) => `
${escapeHtml(phase.title)}${escapeHtml(phase.hint)}
СоставРусская подписьEnglish label
${phase.states.map((stateKey) => ` `).join("")}
`).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(`
ОБЩИЕ ПРАВИЛА МАТЧА

Таймеры и численные составы

Эти значения используются для новых матчей, кнопки «Сброс» и автоматической установки времени при смене периода.

${statusMarkup()}
01
Длительность периодовУкажите своё время в минутах.
02
Базовый численный составКоличество полевых игроков до удалений.
Пример: при базовом составе 3×3 удаление одной команды отображает фактический состав 4×3 для соперника.
03
Подписи численных составов в верхнем счётеДля каждого состояния задаются отдельные русское и английское названия.
Как работает: программа сама считает активные удаления. В основном времени одно удаление даёт 5×4, два удаления одной команды — 5×3, по одному у каждой — 4×4. В овертайме регулярки одно удаление даёт 4×3, два — 5×3, а по одному у каждой команды — 4×4.
${strengthStateLabelsMarkup(value)}
04
Статус периода для MappingОтдельные русские и английские подписи в трёх форматах. Для номерного овертайма доступен шаблон {n}.
В Mapping: доступны period.compact, period.short, period.long, а также независимые period.ru.* и period.en.*. Можно сделать, например, «2», «2 ПЕР» и «2 период» одновременно для разных титров.
${periodStatusLabelsMarkup(value)}
05
Состояния команд в верхнем счётеКнопки «Пустые ворота» и «Отложенный штраф»: подписи RU/EN и отдельные vMix Inputs.
Логика: кнопка только включает состояние матча. Если верхний счёт уже в эфире, дополнительный Input включится/выключится сразу. Если счёт ещё не выдан, Input появится автоматически вместе с верхним счётом. В настройке сохраняется стабильный vMix key, а номер Input используется только для отображения.
${scoreboardTeamStatesMarkup(value)}
vMix: ${escapeHtml(state.timerRulesVmixInventory?.device_name || "Agent не выбран / inventory не загружен")}. Для обновления списка Inputs закройте и снова откройте этот раздел после выбора Agent.
`); 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) => ``).join(""); } function vmixColumnsMarkup(config) { const columns = Array.isArray(config?.columns) ? config.columns : []; if (!columns.length) return `Колонки не заданы. В режиме «Все поля» исходные поля будут переданы полностью.`; return columns.map((column, index) => ` `).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 `
${config ? `
VMIX JSON EDITOR${escapeHtml(config.title || config.key)}
${sourceFields.map((field) => ``).join("")}
Колонки JSONfield — поле, template — шаблон, expr — формула
${vmixColumnsMarkup(config)}
Вклkeylabelsourcemodeexprdefault
Объединение дополнительных источников

Необязательный массив правил joins из переносимого модуля. Источник указывается как source_type.

` : `
Создайте первую JSON-конфигурацию.
`}
`; } function vmixFunctionsMarkup() { const categories = state.vmixSettings?.vmix_functions?.categories || []; return `
${categories.map((category) => `
${escapeHtml(category.title || "Функции")}
${(category.items || []).map((item) => `
${escapeHtml(item.example || item.name || "")}${escapeHtml(item.description || "")}
`).join("")}
`).join("") || `
Справочник функций пуст.
`}
`; } function vmixImportMarkup() { const preview = state.vmixImportPreview; const configs = preview?.configs || preview?.configs_preview || preview?.vmix_configs || []; return `
IMPORTИмпорт настроек

Можно выбрать vmix_json.json, другой JSON с настройками или ZIP проекта. Перед применением показывается найденный список конфигураций.

${preview ? `
${escapeHtml(preview.file_name || "Файл")}Найдено: ${Number(preview.configs_count ?? configs.length ?? 0)}
${configs.map((item) => `${escapeHtml(item.title || item.key || "JSON")}`).join("")}
` : ""}
EXPORTЭкспорт текущих настроек

Скачивается переносимый JSON, который можно импортировать в другую хоккейную сборку.

`; } function renderVmixSettings() { const panelContent = state.vmixPanel === "functions" ? vmixFunctionsMarkup() : state.vmixPanel === "import" ? vmixImportMarkup() : vmixConfigPageMarkup(); const content = `
PORTABLE VMIX SETTINGS

Конструктор JSON для vMix

Каждый аккаунт имеет постоянный канал vMix. При выборе нового матча данные меняются, а ссылка в проекте vMix остаётся прежней.

${statusMarkup()} ${panelContent}
`; 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, databaseSchema] = 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: [] }), safe("databaseSchema", "/api/hockey/admin/mapping-data/database-schema", { tables: [], table_count: 0 }), ]); state.mappingDevices = devices; state.mappingProfiles = profiles; state.mappingContextVariables = variables; state.mappingSqlSources = sources; state.mappingDatabaseSchema = databaseSchema; 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, databaseSchema }; } 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" ? `` : ``; const leftOptions = `${rule.left_key && !leftExists ? `` : ""}${leftSources.map((item) => ``).join("")}`; const sourceOptions = `${rule.right_key && !rightExists ? `` : ""}${sources.map((item) => ``).join("")}`; const operatorOptions = [["gt", "> больше"], ["lt", "< меньше"], ["gte", "≥"], ["lte", "≤"], ["eq", "= равно"], ["neq", "≠ не равно"], ["contains", "содержит"], ["not_contains", "не содержит"], ["empty", "пусто"], ["not_empty", "не пусто"]] .map(([value, label]) => ``).join(""); const summary = !rule.enabled ? "Правило выключено" : (rule.action === "visibility" ? `Visibility · ${rule.operator}` : `TextColour · ${rule.operator}`); const valueEditor = rule.action === "visibility" ? `
` : `
`; return `
${open ? `
Быстро:
${rule.right_mode === "value" ? `` : ``}
${valueEditor}
${mappingParseTableCellKey(row.data_key) && mappingFieldSequence(input, field.name) ? `` : ""}
` : ""}
`; } 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) => `
${escapeHtml(category.name)}${category.items.length}
${category.items.map((item) => ` `).join("")}
`).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 = `
SQL ЯЧЕЙКАИсточник → строка → столбец
${targetField ? `Для ${escapeHtml(currentInput?.title || "Input")} → ${escapeHtml(targetField.name || "")}` : "Сначала выберите поле vMix справа"}
Текущее значение${escapeHtml(mappingDisplayValue(quickCellSource?.value, quickCellSource?.kind || "text"))}${escapeHtml(quickCellKey || "—")}
`; 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) => ` ${Number(row.index || 0)}${escapeHtml(row.label || `Строка ${row.index}`)} ${columns.map((column) => { const key = `${selectedTable.code}.row.${row.index}.${column.key}`; const source = mappingSourceByKey(key); const compatible = source && mappingCompatible(source, targetKind); return ``; }).join("")} `).join(""); tableMarkup = `
ТАБЛИЦА SQLВыберите конкретную строку и столбец
${Number(selectedTable?.row_count || 0)} строк
${selectedTable?.skipped ? `
Не хватает параметров: ${escapeHtml((selectedTable.missing || []).map((x) => `:${x}`).join(", "))}
` : selectedTable?.error ? `
${escapeHtml(selectedTable.error)}
` : `
${columns.map((column) => ``).join("")}${cells || ``}
Строка${escapeHtml(column.label || column.key)}${column.localized ? ` AUTO ${String(column.language || selectedTable?.language || "ru").toUpperCase()}` : ""}${escapeHtml(column.key)}
Нет строк
${filteredRows.length > visibleRows.length ? `Показаны первые ${visibleRows.length} из ${filteredRows.length} строк. Используйте поиск строки.` : ""}`}
`; } const targetHint = targetField ? `
Сейчас настраиваем${escapeHtml(currentInput?.title || "Input")} → ${escapeHtml(targetField.name || "")}Можно выбрать обычное значение или ячейку SQL-таблицы
` : `
Как связатьВыберите поле vMix справаЗатем выберите значение или конкретную ячейку таблицы
`; return `
ДАННЫЕ МАТЧА${escapeHtml(catalog.gameLabel)}
${targetHint} ${quickTableMarkup} ${tableMarkup}
${groups || `
Подходящих одиночных данных не найдено.
`}
`; } function mappingVmixBrowser(profile, currentInput, testDevices) { const allInputs = mappingInventoryInputs(profile); const filteredInputs = mappingFilteredInputs(profile); if (!currentInput) return `
Agent не передал Inputs текущего vMix.
`; 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 ? `Строка ${Number(source.row_index || 0)} · ${escapeHtml(source.column || "")}` : ""; const badge = kind === "image" ? "IMAGE" : kind === "color" ? "COLOR" : "TEXT"; return `
${escapeHtml(field.name || "Без названия")}${badge}
${row ? `` : ""}
${row ? `
${mappingRuleEditor(row, field, currentInput)} ${source?.table_cell && mappingFieldSequence(currentInput, field.name) ? `
` : ""}` : ` `}
`; }).join(""); const linkedCount = allFields.filter((field) => mappingRowForField(profile, currentInput, field.name)).length; return `
VMIX INPUT ${visibleInputs.length === allInputs.length ? `${allInputs.length} Inputs` : `Найдено ${filteredInputs.length} из ${allInputs.length}`}
${linkedCount}/ ${Number(allFields.length || 0)} полей
${rows || `
${state.mappingHideLinked ? "В этой категории не осталось несвязанных полей." : "У этого Input нет полей выбранного типа."}
`}
`; } function mappingWorkspaceTabs() { const tabs = [ ["links", "Связи vMix"], ["context", "Переменные"], ["sql", "SQL источники"], ]; return ``; } 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 `
${escapeHtml(item.label || item.key)}${escapeHtml(item.key)}
${escapeHtml(item.entity_type || item.value_type || "text")}${escapeHtml(item.scope || "match")}${escapeHtml(item.source_type || "manual")}${system ? `SYSTEM` : ""}
Сейчас${escapeHtml(mappingDisplayValue(item.value))}
${system ? `
Системное значение задаётся программой автоматически.
` : `
`}
`; }).join(""); return `
CONTEXT VARIABLESИдентификаторы и выделенные сущностиЭти значения можно использовать как :параметр в SQL. Match-scoped значения автоматически изолированы по матчу и аккаунту.
${rows || `
Переменных пока нет.
`}
`; } function mappingSqlDatabaseReference() { const payload = state.mappingDatabaseSchema || {}; const tables = Array.isArray(payload.tables) ? payload.tables : []; const count = Number(payload.table_count || tables.length || 0); const rows = tables.map((table) => { const columns = Array.isArray(table.columns) ? table.columns : []; const columnNames = columns.map((column) => String(column.name || "")).filter(Boolean); const compactColumns = columnNames.slice(0, 12).join(", "); const tail = columnNames.length > 12 ? ` … +${columnNames.length - 12}` : ""; return ``; }).join(""); const body = payload.error ? `
Не удалось получить схему БД: ${escapeHtml(payload.error)}
` : rows || `
Таблицы не найдены.
`; return `
Справочник таблиц БД${count} таблиц · клик по названию вставляет его в SQL
${body}
`; } 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 `
?
Подсказки PostgreSQLФункции, форматы и примеры — клик вставляет пример в SQL
Наш редактор: один read-only SELECT или WITH … SELECT. Завершающий ; и SQL-комментарии можно писать. Параметры проекта имеют вид :game_id; двоеточия внутри строк PostgreSQL, например 'HH24:MI:SS', переменными не считаются.
Автовыбор языка: если запрос возвращает пару name_RUS + name_ENG, Mapping создаёт одно виртуальное поле name и сам выбирает нужную колонку по языку интерфейса.
${groups.map((group) => `
${escapeHtml(group)}
${snippets.filter((row) => row[0] === group).map((row) => ``).join("")}
`).join("")}
`; } 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) => ``).join(""); const list = [``, ...sources.map((item) => ``)].join(""); let previewMarkup = `
Нажмите «Проверить SQL», чтобы увидеть реальные колонки и строки текущего матча.
`; if (preview) { if (preview.error) previewMarkup = `
${escapeHtml(preview.error)}
`; else if (preview.skipped) previewMarkup = `
Не хватает переменных: ${escapeHtml((preview.missing || []).map((x) => `:${x}`).join(", "))}
`; else { const columns = preview.columns || []; previewMarkup = `
${Number(preview.row_count || 0)} строк · ${columns.length} колонок
${columns.map((c) => ``).join("")}${(preview.rows || []).map((row) => `${columns.map((c) => ``).join("")}`).join("")}
${escapeHtml(c)}
${escapeHtml(mappingDisplayValue(row[c]))}
`; } } return `
SQL DATA SOURCESДинамические ключи из PostgreSQLRead-only PostgreSQL SELECT. Колонки результата автоматически становятся ключами вида source.column. Пары _RUS/_ENG объединяются в языковой AUTO-ключ.
Сервер повторяет только этот SQL. В vMix отправляются только связанные с ним поля и только если значение реально изменилось.
:
Доступные параметры${(state.mappingCatalog?.variables || []).length} идентификаторов · клик вставляет :параметр в SQL
${variableChips}
${mappingSqlDatabaseReference()} ${mappingSqlHelp()}
${draft.id ? `` : ""}

Живой результат

${previewMarkup}
`; } 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) => { const agentVersion = String(item.agent_version || "").trim(); const tooOldForMapping = Boolean(agentVersion && item.mapping_supported === false); const mappingState = tooOldForMapping ? `Agent ${escapeHtml(agentVersion)} слишком старый для Mapping · нужен 1.3.0+` : (item.mapping ? `Mapping: ${escapeHtml(item.mapping.name)} · v${Number(item.mapping.version || 1)}` : (item.project_fingerprint ? "Mapping не назначен" : "Ожидание структуры vMix")); return `
${escapeHtml(item.name || item.device_id)}${escapeHtml(item.device_id)}
${item.vmix_connected ? "vMix подключён" : "vMix не найден"}${agentVersion ? ` · Agent ${escapeHtml(agentVersion)}` : ""} ${Number(item.input_count || 0)} Inputs · ${Number(item.field_count || 0)} полей ${mappingState}
`; }).join("") : `
Agent пока не передал структуру ни одного vMix.
`; 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 `
`; }).join("") : `
Mapping-конфигов ещё нет.
`; const loadErrors = Object.entries(state.mappingLoadErrors || {}); const mappingDiagnostics = loadErrors.length ? `
Часть Mapping API недоступна${loadErrors.map(([key, value]) => `${escapeHtml(key)}${escapeHtml(value)}`).join("")}
` : ""; let editor = ""; if (state.mappingWorkspaceTab === "context") { editor = mappingContextPanel(); } else if (state.mappingWorkspaceTab === "sql") { editor = mappingSqlPanel(); } else if (!profile) { editor = `
Выберите профиль слева или создайте новый из подключённого vMix.
`; } else { const currentInput = mappingCurrentInput(profile); const profileId = Number(profile.id || 0); const matchingDevices = devices.filter((item) => { if (!item.online || !item.vmix_connected || item.mapping_supported === false) return false; const exactFingerprint = Boolean(item.project_fingerprint && item.project_fingerprint === profile.project_fingerprint); const resolvedProfileId = Number(item.mapping?.source_profile_id || item.mapping?.id || 0); return exactFingerprint || (profileId > 0 && resolvedProfileId === profileId); }); const onlineOldAgents = devices.filter((item) => item.online && item.vmix_connected && item.mapping_supported === false); 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 = `
Версия ${Number(profile.version || 1)} Inputs ${Number(profile.inventory?.input_count || mappingInventoryInputs(profile).length)} Связей ${Number(profile.fields?.length || 0)}
${gameReady ? "● ЖИВОЙ ПРИМЕР" : "○ НЕТ ТЕСТОВОГО МАТЧА"}${escapeHtml(catalog.gameLabel)}${gameReady ? "Значения получены через SQL Data Sources из PostgreSQL." : "Выберите матч; системный context game_id заполнится автоматически."}
${mappingDataBrowser(profile, currentInput)} ${mappingVmixBrowser(profile, currentInput, matchingDevices)}
`; } modal.innerHTML = shellMarkup(`
${statusMarkup()}${mappingDiagnostics}${editor}
`); 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-table-name]").forEach((button) => button.addEventListener("click", () => { const textarea = modal.querySelector("[data-sql-text]"); if (!textarea) return; const tableName = String(button.dataset.sqlTableName || "").trim(); if (!tableName) return; const start = textarea.selectionStart ?? textarea.value.length; const end = textarea.selectionEnd ?? start; textarea.value = textarea.value.slice(0, start) + tableName + textarea.value.slice(end); textarea.focus(); textarea.setSelectionRange(start + tableName.length, start + tableName.length); state.mappingSqlDraft = null; })); 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