Greasy Fork

8chan Catalog Filter

Filter catalog threads using regex patterns with per-filter board settings and bumplock hide option

目前为 2025-04-22 提交的版本。查看 最新版本

// ==UserScript==
// @name         8chan Catalog Filter
// @version      1.5
// @description  Filter catalog threads using regex patterns with per-filter board settings and bumplock hide option
// @match        *://8chan.moe/*/catalog.*
// @match        *://8chan.se/*/catalog.*
// @grant        none
// @license MIT
// @namespace https://greasyfork.org/users/1459581
// ==/UserScript==
(function() {
    'use strict';

    // Initial configuration - can be modified by the user through the dashboard
    let config = {
        filters: [
            {
                pattern: /Arknights|AKG/i, // Example regex pattern
                board: 'gacha',           // Board for this filter (optional)
                action: 'setTop'          // 'setTop' or 'remove'
            }
            // More filters can be added by the user
        ],
        hideBumplocked: true  // Default to hide bumplocked threads
    };

    // Load saved configuration from localStorage if available
    function loadConfig() {
        const savedConfig = localStorage.getItem('8chanCatalogFilterConfig');
        if (savedConfig) {
            try {
                const parsedConfig = JSON.parse(savedConfig);
                // Convert string patterns back to RegExp objects
                parsedConfig.filters = parsedConfig.filters.map(filter => ({
                    pattern: new RegExp(filter.patternText, filter.flags),
                    patternText: filter.patternText, // Store the raw text pattern
                    board: filter.board || '',       // Board setting for this filter
                    action: filter.action
                }));

                // Handle bumplocked setting if it exists
                if (parsedConfig.hideBumplocked !== undefined) {
                    config.hideBumplocked = parsedConfig.hideBumplocked;
                }

                config = parsedConfig;
            } catch (e) {
                console.error('Failed to load saved filters:', e);
            }
        }
    }

    // Save configuration to localStorage
    function saveConfig() {
        // Convert RegExp objects to a serializable format
        const serializedConfig = {
            filters: config.filters.map(filter => ({
                patternText: filter.patternText || filter.pattern.source,
                flags: filter.pattern.flags,
                board: filter.board || '',
                action: filter.action
            })),
            hideBumplocked: config.hideBumplocked
        };
        localStorage.setItem('8chanCatalogFilterConfig', JSON.stringify(serializedConfig));
    }

    // Get current board from URL
    function getCurrentBoard() {
        const match = window.location.pathname.match(/\/([^\/]+)\/catalog/);
        return match ? match[1] : '';
    }

    // Check if a filter should apply on the current board
    function shouldApplyFilter(filter) {
        const currentBoard = getCurrentBoard();
        // If filter has no board specified or matches current board, apply it
        return !filter.board || filter.board === '' || filter.board === currentBoard;
    }

    // Create and inject the filter dashboard
    function createDashboard() {
        const toolsDiv = document.getElementById('divTools');
        if (!toolsDiv) return;

        // Create container for the filter dashboard
        const dashboardContainer = document.createElement('div');
        dashboardContainer.id = 'filterDashboard';
        dashboardContainer.style.marginBottom = '10px';

        // Create the dashboard controls
        const dashboardControls = document.createElement('div');
        dashboardControls.className = 'catalogLabel';
        dashboardControls.innerHTML = `
            <span style="font-weight: bold;">Filters:</span>
            <button id="showFilterManager" class="catalogLabel" style="margin-left: 5px;">Manage Filters</button>
            <button id="applyFilters" class="catalogLabel" style="margin-left: 5px;">Apply Filters</button>
            <label style="margin-left: 5px; display: inline-flex; align-items: center;">
                <input type="checkbox" id="hideBumplockedCheck" ${config.hideBumplocked ? 'checked' : ''}>
                <span style="margin-left: 3px;">Hide Bumplocked</span>
            </label>
            <span id="activeFiltersCount" style="margin-left: 5px;">(${config.filters.length} active)</span>
            <span id="currentBoardInfo" style="margin-left: 5px;">Current board: ${getCurrentBoard() || 'unknown'}</span>
        `;

        // Create the filter manager panel (initially hidden)
        const filterManager = document.createElement('div');
        filterManager.id = 'filterManagerPanel';
        filterManager.style.display = 'none';
        filterManager.style.border = '1px solid #ccc';
        filterManager.style.padding = '10px';
        filterManager.style.marginTop = '5px';

        updateFilterManagerContent(filterManager);

        // Add everything to the dashboard container
        dashboardContainer.appendChild(dashboardControls);
        dashboardContainer.appendChild(filterManager);

        // Insert dashboard before the search box
        const searchDiv = toolsDiv.querySelector('div[style="float: right; margin-top: 6px;"]');
        if (searchDiv) {
            toolsDiv.insertBefore(dashboardContainer, searchDiv);
        } else {
            toolsDiv.appendChild(dashboardContainer);
        }

        // Add event listeners
        document.getElementById('showFilterManager').addEventListener('click', function() {
            const panel = document.getElementById('filterManagerPanel');
            panel.style.display = panel.style.display === 'none' ? 'block' : 'none';
        });

        document.getElementById('applyFilters').addEventListener('click', function() {
            processCatalog();
        });

        document.getElementById('hideBumplockedCheck').addEventListener('change', function() {
            config.hideBumplocked = this.checked;
            saveConfig();
            processCatalog();
        });
    }

    function updateFilterManagerContent(filterManager) {
        let content = `
            <div style="margin-bottom: 10px;">
                <h4 style="margin: 0 0 5px 0;">Current Filters</h4>
                <table style="width: 100%; border-collapse: collapse;">
                    <thead>
                        <tr>
                            <th style="text-align: left; padding: 2px 5px;">Pattern</th>
                            <th style="text-align: left; padding: 2px 5px;">Board</th>
                            <th style="text-align: left; padding: 2px 5px;">Action</th>
                            <th style="text-align: center; padding: 2px 5px;">Remove</th>
                        </tr>
                    </thead>
                    <tbody id="filtersTableBody">
        `;

        config.filters.forEach((filter, index) => {
            content += `
                <tr>
                    <td style="padding: 2px 5px;">${filter.patternText || filter.pattern.source}</td>
                    <td style="padding: 2px 5px;">${filter.board || 'All boards'}</td>
                    <td style="padding: 2px 5px;">${filter.action}</td>
                    <td style="text-align: center; padding: 2px 5px;">
                        <button class="removeFilterBtn" data-index="${index}" style="cursor: pointer;">X</button>
                    </td>
                </tr>
            `;
        });

        content += `
                    </tbody>
                </table>
            </div>
            <div style="margin-bottom: 10px;">
                <h4 style="margin: 0 0 5px 0;">Add New Filter</h4>
                <div style="display: flex; gap: 5px; align-items: center; margin-bottom: 5px;">
                    <input type="text" id="newFilterPattern" placeholder="Regex pattern (e.g. anime|manga)" style="flex-grow: 1;">
                    <label style="white-space: nowrap;">
                        <input type="checkbox" id="caseInsensitive" checked> Case insensitive
                    </label>
                </div>
                <div style="display: flex; gap: 5px; align-items: center;">
                    <input type="text" id="newFilterBoard" placeholder="Board name (empty for all boards)" style="flex-grow: 1;">
                    <select id="newFilterAction">
                        <option value="setTop">Move to Top</option>
                        <option value="remove">Hide</option>
                    </select>
                    <button id="addNewFilter" style="cursor: pointer;">Add</button>
                </div>
            </div>
        `;

        filterManager.innerHTML = content;

        // Add event listeners after updating content
        setTimeout(() => {
            // Remove filter buttons
            document.querySelectorAll('.removeFilterBtn').forEach(btn => {
                btn.addEventListener('click', function() {
                    const index = parseInt(this.dataset.index);
                    config.filters.splice(index, 1);
                    saveConfig();
                    updateFilterManagerContent(filterManager);
                    updateActiveFiltersCount();
                });
            });

            // Add new filter button
            document.getElementById('addNewFilter').addEventListener('click', function() {
                const patternInput = document.getElementById('newFilterPattern');
                const boardInput = document.getElementById('newFilterBoard');
                const caseInsensitive = document.getElementById('caseInsensitive').checked;
                const actionSelect = document.getElementById('newFilterAction');

                if (patternInput.value.trim()) {
                    try {
                        const patternText = patternInput.value.trim();
                        const flags = caseInsensitive ? 'i' : '';
                        const boardValue = boardInput.value.trim();

                        const newFilter = {
                            pattern: new RegExp(patternText, flags),
                            patternText: patternText, // Store the raw text
                            board: boardValue,       // Board specific to this filter
                            action: actionSelect.value
                        };

                        config.filters.push(newFilter);
                        saveConfig();
                        updateFilterManagerContent(filterManager);
                        updateActiveFiltersCount();
                        patternInput.value = '';
                        boardInput.value = '';
                    } catch (e) {
                        alert('Invalid regex pattern: ' + e.message);
                    }
                }
            });
        }, 0);
    }

    function updateActiveFiltersCount() {
        const countElement = document.getElementById('activeFiltersCount');
        if (countElement) {
            // Count only filters applicable to the current board
            const currentBoard = getCurrentBoard();
            const applicableFilters = config.filters.filter(filter =>
                !filter.board || filter.board === '' || filter.board === currentBoard
            );
            countElement.textContent = `(${applicableFilters.length} active on this board)`;
        }
    }

    function processCatalog() {
        const catalogDiv = document.getElementById('divThreads');
        if (!catalogDiv) return;

        // Reset all cells visibility first
        const allCells = Array.from(catalogDiv.querySelectorAll('.catalogCell'));
        allCells.forEach(cell => {
            cell.style.display = '';
            cell.style.order = '';
        });

        // Apply filters
        const cells = Array.from(catalogDiv.querySelectorAll('.catalogCell'));
        const matchedCells = [];

        cells.forEach(cell => {
            const subject = cell.querySelector('.labelSubject')?.textContent || '';
            const message = cell.querySelector('.divMessage')?.textContent || '';
            const text = `${subject} ${message}`;
            let shouldHide = false;

            // Check for bumplocked threads first if option is enabled
            if (config.hideBumplocked) {
                const bumpLockIndicator = cell.querySelector('.bumpLockIndicator');
                if (bumpLockIndicator) {
                    cell.style.display = 'none';
                    shouldHide = true;
                }
            }

            // If not already hidden by bumplocked filter, check other filters
            if (!shouldHide) {
                config.filters.forEach(filter => {
                    // Only apply filter if it's for the current board or for all boards
                    if (shouldApplyFilter(filter) && filter.pattern.test(text)) {
                        if (filter.action === 'remove') {
                            cell.style.display = 'none';
                        } else if (filter.action === 'setTop') {
                            matchedCells.push(cell);
                            cell.style.order = -1;
                        }
                    }
                });
            }
        });

        // Bring matched cells to top
        matchedCells.reverse().forEach(cell => {
            catalogDiv.insertBefore(cell, catalogDiv.firstChild);
        });
    }

    // Initialize the script
    function init() {
        loadConfig();
        createDashboard();
        processCatalog();
        updateActiveFiltersCount();

        // Optional: Add mutation observer to handle dynamic updates
        const observer = new MutationObserver(mutations => {
            for (const mutation of mutations) {
                if (mutation.type === 'childList' &&
                    (mutation.target.id === 'divThreads' ||
                     mutation.target.classList.contains('catalogCell'))) {
                    processCatalog();
                    break;
                }
            }
        });

        const threadsDiv = document.getElementById('divThreads');
        if (threadsDiv) {
            observer.observe(threadsDiv, { childList: true, subtree: true });
        }
    }

    // Wait for page to load completely
    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', init);
    } else {
        init();
    }
})();