// ==UserScript==
// @author Evgeniy Lykhov
// @name Integration Config - Delete connection
// @description Пользовательский скрипт, добавляющий в список подключений на странице online.sbis.ru/integration_config/?Page=7 удобный интерфейс для выбора и удаления подключений напрямую, без необходимости удаления их вручную через сайт.
// @version 29 (21-09-2025)
// @match https://online.sbis.ru/integration_config/?Page=7*
// @match https://online.sbis.ru/integration_config/?service=extExch&Page=7*
// @match https://fix-online.sbis.ru/integration_config/?Page=7*
// @icon https://cdn2.sbis.ru/cdn/SabyLogo/1.0.7/favicon/favicon.ico?v=1
// @run-at document-end
// @namespace https://greasyfork.org/users/1497438
// ==/UserScript==
(async function() {
'use strict';
// ===========================
// ========== Стили ==========
// ===========================
const style = document.createElement('style');
style.textContent = `
.controls-DataGridView__th.DataGridView__td__checkBox,
.controls-DataGridView__td.DataGridView__td__checkBox { width: 24px !important; text-align: center !important; display: flex !important; align-items: center !important; justify-content: center !important; padding: 0 !important;}
.controls-DataGridView__td.DataGridView__td__checkBox input { width: 16px; height: 16px; cursor: pointer;}
.controls-button {
display: inline-block;
outline: 0;
line-height: normal;
box-sizing: border-box;
font-family: Inter;
font-size: 14px;
font-weight: 400;
position: relative;
box-shadow: none;
border: 1px solid #587ab0;
min-width: 48px;
text-shadow: none;
-webkit-user-select: none;
user-select: none;
color: #000;
background: #fff;
height: 24px;
padding: 0 11px;
border-radius: 16px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.controls-button:hover { background: #e1ecf6; }
#sbis-panel { display: flex; justify-content: space-between; align-items: center; margin: 0px 25px; gap: 10px; }
#sbis-panel-left, #sbis-panel-right { display: flex; align-items: center; justify-content: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; gap: 10px; }
#sbis-header-counter { font-size: 16px; min-width: 110px; }
`;
document.head.appendChild(style);
// ===========================
// ======== Утилиты ==========
// ===========================
// waitFor — ждет появления селектора в DOM (promise)
function waitFor(selector, timeout = 5000) {
return new Promise((resolve, reject) => {
const el = document.querySelector(selector);
if (el) return resolve(el);
const obs = new MutationObserver(() => {
const found = document.querySelector(selector);
if (found) {
obs.disconnect();
resolve(found);
}
});
obs.observe(document.body, { childList: true, subtree: true });
setTimeout(() => {
obs.disconnect();
reject(new Error(`Не найдена нода ${selector} за ${timeout}ms`));
}, timeout);
});
}
try {
const connectionLimitForDeletion = 50;
const mainTable = await waitFor('.controls-DataGridView__table.ws-sticky-header__table');
const mainTbody = mainTable.querySelector('tbody');
/**
* - Универсально вставляет <col> colgroup
* - возвращает:
* true — если colgroup есть (вставили или уже присутствовал).
* false — если в таблице пока нет colgroup (нужно ждать)
*/
function ensureColForTable(tbl) {
if (!tbl) return false;
const cg = tbl.querySelector('colgroup');
if (!cg) return false; // ещё нет colgroup — нужно подождать
// Если маркер уже есть — считаем, что колонка вставлена
if (cg.querySelector('col[data-inserted]')) return true;
// Вставляем новую колонку слева
const newCol = document.createElement('col');
newCol.width = '24px';
newCol.setAttribute('data-inserted', 'true');
cg.insertBefore(newCol, cg.firstElementChild);
// Корректируем второй col (делаем его шириной 45%), как в основной таблице
const secondCol = cg.querySelectorAll('col')[1];
if (secondCol) {
secondCol.setAttribute('data-inserted', 'true');
secondCol.setAttribute('width', '45%');
}
// убираем width у остальных колонок, чтобы избежать конфликтов
cg.querySelectorAll('col:not([data-inserted])').forEach(c => c.removeAttribute('width'));
return true;
}
/**
* - универсально вставляет <th> как первый <th> в thead.
* - возвращает:
* true — если th вставлен (или уже присутствовал).
* false — если в таблице пока нет строки thead tr (нужно ждать)
*/
function ensureThForTable(tbl) {
if (!tbl) return false;
// если уже есть наш th — возвращаем true
const existing = tbl.querySelector('thead tr th.DataGridView__td__checkBox');
if (existing) return true;
const theadRow = tbl.querySelector('thead tr');
if (!theadRow) return false; // ещё нет thead/row — нужно подождать
// вставляем th слева
const th = document.createElement('th');
th.className = 'controls-DataGridView__th DataGridView__td__checkBox';
th.style.textAlign = 'center';
th.style.width = '24px';
theadRow.insertBefore(th, theadRow.firstElementChild);
return true;
}
// ===========================
// === Применяем к основной таблице ===
// ===========================
if (mainTable) {
ensureColForTable(mainTable);
ensureThForTable(mainTable);
// Следим за перерисовкой colgroup
new MutationObserver(() => ensureColForTable(mainTable))
.observe(mainTable.querySelector('colgroup').parentNode, { childList: true });
// Следим за перерисовкой tableheader
new MutationObserver(() => ensureThForTable(mainTable))
.observe(mainTable, { childList: true, subtree: true });
}
// ====== Добавление чекбокса в каждую строку ======
function addRowCheckbox(tr) {
if (tr.querySelector('.DataGridView__td__checkBox input')) return;
const id = tr.getAttribute('data-id');
if (!id) return;
const td = document.createElement('td');
td.className = 'controls-DataGridView__td DataGridView__td__checkBox';
const cb = document.createElement('input');
cb.type = 'checkbox';
cb.dataset.id = id;
cb.addEventListener('click', e => e.stopPropagation());
td.append(cb);
// делегируем клик на всю ячейку
td.addEventListener('click', () => {
cb.checked = !cb.checked;
updateHeaderCounter();
});
tr.insertBefore(td, tr.firstElementChild);
}
if (mainTbody) {
mainTbody.querySelectorAll('tr[data-id]').forEach(addRowCheckbox);
new MutationObserver(muts => muts.forEach(m => m.addedNodes.forEach(n =>
n.nodeType === 1 && n.matches('tr[data-id]') && addRowCheckbox(n))))
.observe(mainTbody, { childList: true });
}
// ====== Обновление счетчика ======
function updateHeaderCounter() {
const count = (mainTbody ? mainTbody.querySelectorAll('td.DataGridView__td__checkBox input:checked').length : 0);
const counter = document.getElementById('sbis-header-counter');
if (counter) counter.textContent = `Выбрано: ${count}`;
}
if (mainTbody) {
mainTbody.addEventListener('change', e => {
if (e.target.matches('td.DataGridView__td__checkBox input')) {
updateHeaderCounter();
}
});
}
// ====== Панель кнопок над списком, справа от поиска ======
function insertPanel() {
if (document.getElementById('sbis-panel')) return;
const searchCell = document.querySelector('.controls-Browser__tableCell-search');
if (!searchCell || !searchCell.parentNode) return;
const container = searchCell.parentNode;
const panel = document.createElement('div');
panel.id = 'sbis-panel';
const panelLeft = document.createElement('div');
panelLeft.id = 'sbis-panel-left';
const panelRight = document.createElement('div');
panelRight.id = 'sbis-panel-right';
const btnAll = document.createElement('button');
btnAll.textContent = 'Выбрать все';
btnAll.className = 'controls-button';
btnAll.onclick = () => {
if (!mainTbody) return;
mainTbody.querySelectorAll('td.DataGridView__td__checkBox input').forEach(cb => cb.checked = true);
updateHeaderCounter();
};
const btnNone = document.createElement('button');
btnNone.textContent = 'Снять все';
btnNone.className = 'controls-button';
btnNone.onclick = () => {
if (!mainTbody) return;
mainTbody.querySelectorAll('td.DataGridView__td__checkBox input').forEach(cb => cb.checked = false);
updateHeaderCounter();
};
const btnDelete = document.createElement('button');
btnDelete.textContent = 'Удалить выбранные';
btnDelete.className = 'controls-button';
btnDelete.onclick = () => {
if (!mainTbody) return;
const checked = Array.from(mainTbody.querySelectorAll('td.DataGridView__td__checkBox input:checked'));
const total = checked.length;
if (!total) {
alert('Не выбрано ни одного подключения для удаления.');
return;
} else if (total <= connectionLimitForDeletion) {
if (!confirm(`Вы удаляете ${total} подключений. Продолжить?`)) return;
} else if (total > connectionLimitForDeletion) {
if (!confirm(`Будут удалены первые ${connectionLimitForDeletion} из ${total} подключений - остальные останутся.\n` +
`Необходимо будет обновить список и повторить удаление оставщихся.\n` +
`Продолжить?`)) return;
}
const toDelete = checked.slice(0, connectionLimitForDeletion);
require(['Types/source'], src => {
const service = new src.SbisService({
endpoint: {
address: '/integration_config/service/',
contract: 'IntegrationConnection'
}
});
toDelete.forEach(cb => {
service.call('DeleteConnection', { id: cb.dataset.id });
console.log(`Удалено подключение: ${cb.dataset.id}`);
});
mainTbody.querySelectorAll('td.DataGridView__td__checkBox input').forEach(cb => cb.checked = false);
updateHeaderCounter();
});
};
const counter = document.createElement('span');
counter.id = 'sbis-header-counter';
counter.textContent = 'Выбрано: 0';
panelLeft.append(counter, btnAll, btnNone);
panelRight.append(btnDelete);
panel.append(panelLeft, panelRight);
container.insertBefore(panel, searchCell.nextSibling);
}
insertPanel();
new MutationObserver(insertPanel).observe(document.body, { childList: true, subtree: true });
// ===========================
// === Обработка виртуальных sticky-таблиц ===
// ===========================
// WeakSet чтобы не навешивать наблюдатели много раз на одну и ту же таблицу
const processedStickyTables = new WeakSet();
/*
* Проверяет, находится ли таблица внутри контейнера sticky header'а.
* Возвращает true если tbl вложена в .ws-sticky-header__header-container.
*/
function isInStickyContainer(tbl) {
return tbl && tbl.closest && !!tbl.closest('.ws-sticky-header__header-container');
}
/*
* Гарантирует, что в заданной таблице внутри sticky-контейнера появятся col + th:
* 1) Если colgroup и thead уже есть — вставляем сразу и помечаем таблицу как обработанную.
* 2) Если table создана частично (пока нет colgroup или thead) — ставим MutationObserver на саму table,
* который подождёт появления нужных узлов и затем выполнит вставку.
* Также есть safety timeout, чтобы не наблюдать бесконечно.
*/
function processTable(tbl) {
if (!tbl || !isInStickyContainer(tbl)) return;
// если уже обработана и th на месте — ничего не делаем
if (processedStickyTables.has(tbl) && tbl.querySelector('thead tr th.DataGridView__td__checkBox')) return;
// сначала попытаемся вставить сразу (если thead/colgroup уже есть)
const colOk = ensureColForTable(tbl);
const thOk = ensureThForTable(tbl);
if (colOk && thOk) {
processedStickyTables.add(tbl);
return;
}
// иначе — таблица добавлена, но thead/colgroup появятся позже. Наблюдаем за таблицей.
const obs = new MutationObserver((muts, observer) => {
try {
injectColIfNeeded(tbl);
if (injectThIfPossible(tbl)) {
processedStickyTables.add(tbl);
observer.disconnect();
}
} catch (e) {
console.warn('[Userscript] processTable error', e);
}
});
// Наблюдаем за дочерними узлами и вложенными изменениями — когда появятся thead/colgroup, мы подхватим.
obs.observe(tbl, { childList: true, subtree: true, attributes: false });
// Safety timeout: если через 7 секунд нужные узлы не появились — отключаем наблюдатель.
setTimeout(() => {
try { obs.disconnect(); } catch (e) {}
}, 7000);
}
// ----------------- Инициализация для уже существующих контейнеров -----------------
// Проходим по всем текущим sticky-контейнерам и проверяем таблицы внутри.
// Даже если таблица пока отсутствует — processTable ничего не сделает до появления элементов.
document.querySelectorAll('.ws-sticky-header__header-container').forEach(container => {
container.querySelectorAll('table.controls-DataGridView__table, .controls-DataGridView__table')
.forEach(tbl => ensureHeaderForStickyTable(tbl));
});
// Глобальный наблюдатель: ловим появление новых контейнеров или таблиц
const stickyGlobalObserver = new MutationObserver((muts) => {
for (const m of muts) {
// если добавлены узлы — проверяем их
if (m.addedNodes && m.addedNodes.length) {
m.addedNodes.forEach(node => {
if (node.nodeType !== 1) return;
// 1) Если добавленный узел сам является контейнером — обрабатываем его содержимое
if (node.matches && node.matches('.ws-sticky-header__header-container')) {
node.querySelectorAll('table.controls-DataGridView__table, .controls-DataGridView__table')
.forEach(tbl => processTable(tbl));
return;
}
// 2) Если добавленный узел содержит внутри контейнеры — обработаем их
const innerContainers = node.querySelectorAll && node.querySelectorAll('.ws-sticky-header__header-container');
if (innerContainers && innerContainers.length) {
innerContainers.forEach(cont => cont.querySelectorAll('table.controls-DataGridView__table, .controls-DataGridView__table')
.forEach(tbl => processTable(tbl)));
}
// 3) Если сам добавленный узел — таблица (возможно без контейнера) — проверим, находится ли она внутри sticky-контейнера и обработаем
if (node.matches && (node.matches('table.controls-DataGridView__table') || node.matches('.controls-DataGridView__table'))) {
if (isInStickyContainer(node)) processTable(node);
}
// 4) Если внутри добавленного узла появились таблицы — обработаем их
const innerTables = node.querySelectorAll && node.querySelectorAll('table.controls-DataGridView__table, .controls-DataGridView__table');
if (innerTables && innerTables.length) {
innerTables.forEach(tbl => {
if (isInStickyContainer(tbl)) processTable(tbl);
});
}
});
}
// Дополнительно: при каждой мутации прогоняем текущие контейнеры для надёжности.
// Это дешёвая операция и покрывает редкие случаи, когда мутация не имеет добавленных узлов,
// но состояние контейнеров изменилось косвенно.
document.querySelectorAll('.ws-sticky-header__header-container').forEach(container => {
container.querySelectorAll('table.controls-DataGridView__table, .controls-DataGridView__table')
.forEach(tbl => processTable(tbl));
});
}
});
// Запускаем глобальный наблюдатель за всем телом документа
stickyGlobalObserver.observe(document.body, { childList: true, subtree: true });
} catch (error) {
console.error('[Userscript] Ошибка:', error);
}
})();