// ==UserScript==
// @name TMS Case Per-Case Smart Filter
// @namespace http://tampermonkey.net/
// @version 1.9.3
// @description Фильтр кейсов с индивидуальным выбором сочетаний параметров для каждого кейса, поиском, названиями и перетаскиваемой кнопкой
// @match https://ingr.firetms.ru/p/*/runs/*
// @license MIT
// @grant none
// ==/UserScript==
(function() {
'use strict';
// --- Drag & Drop для кнопки ---
function makeDraggable(btn, storageKey = 'tms-case-filter-btn-pos') {
let offsetX, offsetY, isDragging = false, moved = false;
// Восстановить позицию
const saved = localStorage.getItem(storageKey);
if (saved) {
const {left, top} = JSON.parse(saved);
btn.style.left = left;
btn.style.top = top;
btn.style.right = '';
btn.style.bottom = '';
} else {
btn.style.right = '24px';
btn.style.bottom = '24px';
}
btn.style.position = 'fixed';
btn.style.userSelect = 'none';
btn.style.width = '180px';
btn.style.height = '40px';
btn.style.fontSize = '16px';
btn.style.background = '#1976d2';
btn.style.color = '#fff';
btn.style.border = 'none';
btn.style.borderRadius = '6px';
btn.style.boxShadow = '0 2px 8px rgba(0,0,0,0.15)';
btn.style.cursor = 'pointer';
btn.style.whiteSpace = 'nowrap';
btn.style.textAlign = 'center';
btn.style.lineHeight = '40px';
btn.style.padding = '0';
btn.style.resize = 'none';
btn.style.display = 'block';
btn.style.zIndex = '2147483647';
btn.addEventListener('mousedown', function(e) {
if (e.button !== 0) return; // Только ЛКМ
isDragging = true;
moved = false;
offsetX = e.clientX - btn.getBoundingClientRect().left;
offsetY = e.clientY - btn.getBoundingClientRect().top;
document.body.style.userSelect = 'none';
});
document.addEventListener('mousemove', function(e) {
if (!isDragging) return;
moved = true;
btn.style.left = (e.clientX - offsetX) + 'px';
btn.style.top = (e.clientY - offsetY) + 'px';
btn.style.right = '';
btn.style.bottom = '';
});
document.addEventListener('mouseup', function(e) {
if (isDragging) {
isDragging = false;
document.body.style.userSelect = '';
localStorage.setItem(storageKey, JSON.stringify({
left: btn.style.left,
top: btn.style.top
}));
}
});
// Возвращаем функцию, чтобы узнать был ли drag, и функцию сброса moved
return {
wasMoved: () => moved,
resetMoved: () => { moved = false; }
};
}
// --- Сбор кейсов ---
function getCases() {
return Array.from(document.querySelectorAll('.run-case__item')).map((item, idx) => {
const checkbox = item.querySelector('input[type="checkbox"].form-check-input.checkbox-title');
if (!checkbox) return null;
const paramsDiv = item.querySelector('.run-case__params');
const paramsText = paramsDiv ? paramsDiv.textContent.trim().replace(/^Параметры:\s*/i, '') : '';
const link = item.querySelector('a[href]');
const name = link ? link.textContent.trim() : `Кейс #${idx+1}`;
// Новый способ: ищем название в .run-case__title-text > .section-visible-tooltip-toggler:first-child > div
let title = '';
const titleBlock = item.querySelector('.run-case__title-text .section-visible-tooltip-toggler');
if (titleBlock && titleBlock.getAttribute('data-tooltip-text')) {
title = titleBlock.getAttribute('data-tooltip-text').trim();
} else if (titleBlock) {
// fallback: текст внутри div
const innerDiv = titleBlock.querySelector('div');
if (innerDiv) title = innerDiv.textContent.trim();
}
// Если не нашли, title остаётся пустым!
const paramsObj = {};
paramsText.split(';').forEach(pair => {
const [k, v] = pair.split(':').map(s => s && s.trim());
if (k && v) paramsObj[k] = v;
});
return {item, paramsText, paramsObj, name, title, link: link ? link.href : '', checkbox};
}).filter(Boolean);
}
// --- Уникальные параметры и значения для каждого кейса ---
function getCaseParamValues(cases) {
const caseParams = {};
cases.forEach(c => {
if (!caseParams[c.name]) caseParams[c.name] = {};
Object.entries(c.paramsObj).forEach(([k, v]) => {
if (!caseParams[c.name][k]) caseParams[c.name][k] = new Set();
caseParams[c.name][k].add(v);
});
});
// Преобразуем Set в массив
Object.keys(caseParams).forEach(caseName => {
Object.keys(caseParams[caseName]).forEach(k => {
caseParams[caseName][k] = Array.from(caseParams[caseName][k]);
});
});
return caseParams;
}
// --- UI: Overlay с индивидуальным выбором сочетаний для каждого кейса ---
function showOverlay(cases, caseParamValues, caseCombinations, onSave, caseTitles) {
// Стили
const style = document.createElement('style');
style.textContent = `
#tms-case-filter-modal {
background: #fff; padding: 24px; border-radius: 8px; min-width: 60vw; max-width: 60vw; max-height: 80vh; overflow: hidden;
box-shadow: 0 2px 16px rgba(0,0,0,0.2); margin: 40px auto 0 auto; position: relative;
display: flex; flex-direction: column; align-items: stretch;
}
#tms-case-filter-overlay {
position: fixed; top: 0; left: 0; width: 100vw; height: 100vh;
background: rgba(0,0,0,0.5); z-index: 99999; display: flex; align-items: flex-start; justify-content: center;
}
#tms-case-filter-close {
position: absolute; top: 8px; right: 12px; font-size: 32px; color: #888; cursor: pointer; font-weight: bold; background: none; border: none;
line-height: 1;
}
#tms-case-filter-close:hover { color: #d33; }
#tms-case-filter-cases-scroll {
flex: 1 1 auto;
overflow-y: auto;
max-height: 60vh;
margin-bottom: 16px;
}
.case-block { border: 1px solid #eee; border-radius: 6px; margin-bottom: 16px; padding: 10px; }
.case-title { font-weight: bold; margin-bottom: 6px; }
.comb-block { border: 1px solid #f0f0f0; border-radius: 6px; margin-bottom: 8px; padding: 8px; position: relative; }
.comb-params-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 6px;
position: relative;
}
.comb-param-select {
flex: 0 1 auto;
min-width: 180px;
margin-bottom: 4px;
}
.select-default {
background: #ffeaea !important;
color: #b22222 !important;
}
.comb-remove-btn {
font-size: 20px !important;
font-weight: bold;
padding: 0 6px;
line-height: 1;
background: none;
border: none;
color: #888;
cursor: pointer;
margin-left: auto;
align-self: center;
position: relative;
z-index: 1;
}
.comb-remove-btn:hover { color: #d33; }
.add-comb-btn { margin-bottom: 8px; }
#tms-case-filter-apply { margin-top: 12px; }
#tms-case-filter-search {
width: 60%;
font-size: 16px;
padding: 6px 10px;
border: 1px solid #ccc;
border-radius: 4px;
margin-bottom: 16px;
display: block;
margin-left: auto;
margin-right: auto;
}
`;
document.head.appendChild(style);
// Overlay
const overlay = document.createElement('div');
overlay.id = 'tms-case-filter-overlay';
// Модалка
const modal = document.createElement('div');
modal.id = 'tms-case-filter-modal';
// Крестик для закрытия
const closeBtn = document.createElement('button');
closeBtn.id = 'tms-case-filter-close';
closeBtn.innerHTML = '×';
closeBtn.onclick = () => {
overlay.remove();
style.remove();
onSave(caseCombinations); // Сохраняем при закрытии
};
modal.appendChild(closeBtn);
// Закрытие по клику вне модалки
overlay.addEventListener('mousedown', function(e) {
if (!modal.contains(e.target)) {
overlay.remove();
style.remove();
onSave(caseCombinations);
}
});
// Поиск
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.id = 'tms-case-filter-search';
searchInput.placeholder = 'Поиск по коду или названию кейса...';
modal.appendChild(searchInput);
// Контейнер для всех кейсов с прокруткой
const allCasesDivScroll = document.createElement('div');
allCasesDivScroll.id = 'tms-case-filter-cases-scroll';
modal.appendChild(allCasesDivScroll);
// Список уникальных кейсов
const uniqueCases = Object.keys(caseParamValues);
// Функция создания пустого сочетания
function createEmptyCombination(caseName) {
const comb = {};
Object.keys(caseParamValues[caseName]).forEach(param => {
comb[param] = '';
});
return comb;
}
// Рендер блоков для каждого кейса
function renderAllCases() {
allCasesDivScroll.innerHTML = '';
const filter = searchInput.value.trim().toLowerCase();
uniqueCases.forEach(caseName => {
const title = caseTitles && caseTitles[caseName] ? caseTitles[caseName] : '';
if (
!filter ||
caseName.toLowerCase().includes(filter) ||
title.toLowerCase().includes(filter)
) {
const block = document.createElement('div');
block.className = 'case-block';
block.innerHTML = `<div class="case-title">${caseName}${(title && title !== caseName) ? ' — ' + title : ''}</div>`;
const combsContainer = document.createElement('div');
// Рендер сочетаний
(caseCombinations[caseName] || []).forEach((comb, idx) => {
const combBlock = document.createElement('div');
combBlock.className = 'comb-block';
const paramRow = document.createElement('div');
paramRow.className = 'comb-params-row';
Object.keys(caseParamValues[caseName]).forEach(param => {
const sel = document.createElement('select');
sel.className = 'comb-param-select';
sel.innerHTML = `<option value="">${param}</option>` +
caseParamValues[caseName][param].map(v => `<option value="${v}">${v}</option>`).join('');
sel.value = comb[param] || '';
// Подсветка дефолта
function updateSelectStyle() {
if (sel.value === '') sel.classList.add('select-default');
else sel.classList.remove('select-default');
}
sel.onchange = () => {
comb[param] = sel.value;
updateSelectStyle();
};
updateSelectStyle();
paramRow.appendChild(sel);
});
// Крестик всегда справа
const removeBtn = document.createElement('button');
removeBtn.className = 'comb-remove-btn';
removeBtn.innerHTML = '×';
removeBtn.title = 'Удалить сочетание';
removeBtn.onclick = () => {
caseCombinations[caseName].splice(idx, 1);
renderAllCases();
};
paramRow.appendChild(removeBtn);
combBlock.appendChild(paramRow);
combsContainer.appendChild(combBlock);
});
if (Object.keys(caseParamValues[caseName]).length === 0) {
// Нет параметров — показываем некликабельную кнопку
const noParamsBtn = document.createElement('button');
noParamsBtn.className = 'add-comb-btn';
noParamsBtn.textContent = 'Нет параметров';
noParamsBtn.disabled = true;
noParamsBtn.style.opacity = '0.6';
block.appendChild(combsContainer);
block.appendChild(noParamsBtn);
} else {
// Обычная кнопка "Добавить сочетание"
const addCombBtn = document.createElement('button');
addCombBtn.className = 'add-comb-btn';
addCombBtn.textContent = 'Добавить сочетание';
addCombBtn.onclick = function() {
caseCombinations[caseName].push(createEmptyCombination(caseName));
renderAllCases();
};
block.appendChild(combsContainer);
block.appendChild(addCombBtn);
}
allCasesDivScroll.appendChild(block);
}
});
}
renderAllCases();
searchInput.addEventListener('input', renderAllCases);
// Кнопка применить
const applyBtn = document.createElement('button');
applyBtn.id = 'tms-case-filter-apply';
applyBtn.textContent = 'Применить';
applyBtn.onclick = function() {
overlay.remove();
style.remove();
onSave(caseCombinations); // Сохраняем при применении
// Для каждого кейса на странице ищем его name, сравниваем параметры с сочетаниями для этого name
cases.forEach(c => {
const combs = caseCombinations[c.name] || [];
// Если сочетаний нет — кейс остаётся
if (!combs.length) {
c.item.style.background = '';
if (c.checkbox.checked) c.checkbox.click();
return;
}
// Кейс подходит, если совпадает хотя бы с одним сочетанием
const isMatch = combs.some(comb =>
Object.entries(comb).every(([k, v]) => !v || c.paramsObj[k] === v)
);
if (!isMatch && !c.checkbox.checked) c.checkbox.click();
if (isMatch && c.checkbox.checked) c.checkbox.click();
if (!isMatch) c.item.style.background = '#ffe0e0';
else c.item.style.background = '';
});
};
modal.appendChild(applyBtn);
overlay.appendChild(modal);
document.body.appendChild(overlay);
}
// --- Основная логика ---
function main() {
const cases = getCases();
const caseParamValues = getCaseParamValues(cases);
// Ключ для localStorage — уникальный для каждого рана
const runKey = 'tms-case-filter-combs-' + location.pathname;
// Загружаем сохранённые сочетания
let saved = localStorage.getItem(runKey);
let caseCombinations = {};
if (saved) {
try {
caseCombinations = JSON.parse(saved);
} catch (e) {}
}
// Инициализация для новых кейсов
Object.keys(caseParamValues).forEach(name => {
if (!caseCombinations[name]) caseCombinations[name] = [];
});
// Собираем названия кейсов
const caseTitles = {};
cases.forEach(c => { caseTitles[c.name] = c.title; });
// Добавляем кнопку для открытия фильтра
if (!document.getElementById('tms-case-filter-btn')) {
const btn = document.createElement('button');
btn.id = 'tms-case-filter-btn';
btn.textContent = 'Фильтр кейсов';
const dragState = makeDraggable(btn, 'tms-case-filter-btn-pos-' + location.pathname);
btn.addEventListener('click', function(e) {
if (!dragState.wasMoved()) {
showOverlay(cases, caseParamValues, caseCombinations, (newCombs) => {
caseCombinations = newCombs;
localStorage.setItem(runKey, JSON.stringify(caseCombinations));
}, caseTitles);
}
dragState.resetMoved();
});
document.body.appendChild(btn);
}
}
// Ждём появления кейсов
function waitForCases() {
const interval = setInterval(() => {
if (document.querySelectorAll('.run-case__item').length > 0) {
clearInterval(interval);
main();
}
}, 500);
setTimeout(() => clearInterval(interval), 10000);
}
waitForCases();
})();