TMS Case Per-Case Smart Filter

Фильтр кейсов с индивидуальным выбором сочетаний параметров для каждого кейса, поиском, названиями и перетаскиваемой кнопкой

// ==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 = '&times;';
                        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();
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址