Reddit Advanced Content Filter v2.1.1

Filters Reddit content by keywords, regex, user, subreddit with target & normalization options. Includes text replacement for comments. Enhanced UI colors with select fix. UI panel is movable (wider drag area), resizable, and remembers its state.

// ==UserScript==
// @name         Reddit Advanced Content Filter v2.1.1
// @namespace    reddit-filter
// @version      2.1.1
// @description  Filters Reddit content by keywords, regex, user, subreddit with target & normalization options. Includes text replacement for comments. Enhanced UI colors with select fix. UI panel is movable (wider drag area), resizable, and remembers its state.
// @author       dani71153 (Modified by Assistant)
// @match        https://www.reddit.com/*
// @match        https://old.reddit.com/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_log
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/purify.min.js
// @license      MIT
// ==/UserScript==

/* global DOMPurify, GM_setValue, GM_getValue, GM_registerMenuCommand, GM_log, GM_info */

(function() {
    'use strict';

    // --- Constants ---
    const SCRIPT_PREFIX = 'RACF';
    const DEBOUNCE_DELAY_MS = 250;
    const STATS_SAVE_DEBOUNCE_MS = 2000;
    const CONFIG_STORAGE_KEY = 'config_v1.7';
    const STATS_STORAGE_KEY = 'stats_v1';
    const RULE_TYPES = ['keyword', 'user', 'subreddit'];
    const FILTER_ACTIONS = ['hide', 'blur', 'border', 'collapse', 'replace_text'];
    const DEBUG_LOGGING = false; // Set to true for detailed console logs

    // --- Default Structures ---
    const DEFAULT_CONFIG = {
        rules: [],
        filterTypes: ['posts', 'comments'], filterAction: 'hide',
        whitelist: { subreddits: [], users: [] },
        blacklist: { subreddits: [], users: [] },
        uiVisible: true, activeTab: 'settings',
        uiPosition: {
            top: '100px',
            left: null,
            right: '20px',
            width: null,
            height: null
        }
    };
    const DEFAULT_STATS = {
        totalProcessed: 0, totalFiltered: 0, totalWhitelisted: 0,
        filteredByType: { posts: 0, comments: 0, messages: 0 },
        filteredByRule: {},
        filteredByAction: { hide: 0, blur: 0, border: 0, collapse: 0, replace_text: 0 }
    };

    if (!window.MutationObserver) { GM_log(`[${SCRIPT_PREFIX}] MutationObserver not supported.`); }

    class RedditFilter {
        constructor() {
            this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
            this.stats = JSON.parse(JSON.stringify(DEFAULT_STATS));
            this.processedNodes = new WeakSet();
            this.selectors = {};
            this.isOldReddit = false;
            this.observer = null;
            this.uiContainer = null;
            this.shadowRoot = null;
            this.scrollTimer = null;
            this.lastFilterTime = 0;
            this.filterApplyDebounceTimer = null;
            this.statsSaveDebounceTimer = null;
            this.uiUpdateDebounceTimer = null;
            this.isDragging = false;
            this.dragStartX = 0;
            this.dragStartY = 0;
            this.dragInitialLeft = 0;
            this.dragInitialTop = 0;
            this.domPurify = (typeof DOMPurify === 'undefined') ? { sanitize: (t) => t } : DOMPurify;
            this.originalContentCache = new WeakMap();
        }

        log(message) {
            GM_log(`[${SCRIPT_PREFIX}] ${message}`);
        }

        debugLog(message, ...args) {
            if (DEBUG_LOGGING) {
                console.log(`[${SCRIPT_PREFIX} DEBUG] ${message}`, ...args);
            }
        }

        async init() {
            this.log(`Initializing v${GM_info?.script?.version || '2.1.1'}...`);
            try {
                await this.loadConfig();
                await this.loadStats();
                this.detectRedditVersion();
                this.injectUI();
                this.updateUI();
                this.registerMenuCommands();
                this.initializeObserver();
                this.addScrollListener();
                setTimeout(() => this.applyFilters(document.body), 500);
                this.log(`Initialization complete.`);
            } catch (error) {
                this.log(`Init failed: ${error.message}`);
                console.error(`[${SCRIPT_PREFIX}] Init failed:`, error);
            }
        }

        async loadConfig() {
            try {
                const savedConfigString = await GM_getValue(CONFIG_STORAGE_KEY, null);
                if (savedConfigString) {
                    const parsedConfig = JSON.parse(savedConfigString);
                    this.config = {
                        ...DEFAULT_CONFIG,
                        ...parsedConfig,
                        whitelist: { ...DEFAULT_CONFIG.whitelist, ...(parsedConfig.whitelist || {}) },
                        blacklist: { ...DEFAULT_CONFIG.blacklist, ...(parsedConfig.blacklist || {}) },
                        uiPosition: { ...DEFAULT_CONFIG.uiPosition, ...(parsedConfig.uiPosition || {}) },
                        rules: Array.isArray(parsedConfig.rules) ? parsedConfig.rules : []
                    };
                    if (!FILTER_ACTIONS.includes(this.config.filterAction)) {
                        this.log(`Invalid filterAction '${this.config.filterAction}', defaulting to '${DEFAULT_CONFIG.filterAction}'.`);
                        this.config.filterAction = DEFAULT_CONFIG.filterAction;
                    }
                    this.log(`Config loaded.`);
                } else {
                    this.log(`No saved config found. Using defaults.`);
                    this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
                }
            } catch (e) {
                this.log(`Failed to load config: ${e.message}. Using defaults.`);
                this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
                await this.saveConfig();
            }
        }

        async saveConfig() {
            try {
                if (!this.config) this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
                if (!Array.isArray(this.config.rules)) this.config.rules = [];
                if (!this.config.whitelist) this.config.whitelist = { subreddits: [], users: [] };
                if (!this.config.blacklist) this.config.blacklist = { subreddits: [], users: [] };
                this.config.uiPosition = {
                    ...DEFAULT_CONFIG.uiPosition,
                    ...(this.config.uiPosition || {})
                };
                 if (!FILTER_ACTIONS.includes(this.config.filterAction)) this.config.filterAction = DEFAULT_CONFIG.filterAction;

                await GM_setValue(CONFIG_STORAGE_KEY, JSON.stringify(this.config));
                 this.debugLog("Config saved:", this.config);
            } catch (e) {
                this.log(`Failed to save config: ${e.message}`);
                console.error(`[${SCRIPT_PREFIX}] Failed save config:`, e);
            }
        }

        async loadStats() {
            try {
                const savedStatsString = await GM_getValue(STATS_STORAGE_KEY, null);
                if (savedStatsString) {
                    const parsedStats = JSON.parse(savedStatsString);
                    const defaultActions = DEFAULT_STATS.filteredByAction;
                    const loadedActions = parsedStats.filteredByAction || {};
                    const mergedActions = { ...defaultActions };
                    for (const action in loadedActions) {
                        if (FILTER_ACTIONS.includes(action)) {
                            mergedActions[action] = loadedActions[action];
                        }
                    }
                    this.stats = {
                        ...DEFAULT_STATS,
                        ...parsedStats,
                        filteredByType: { ...DEFAULT_STATS.filteredByType, ...(parsedStats.filteredByType || {}) },
                        filteredByRule: { ...DEFAULT_STATS.filteredByRule, ...(parsedStats.filteredByRule || {}) },
                        filteredByAction: mergedActions
                    };
                } else {
                    this.log(`No saved stats found. Using defaults.`);
                    this.stats = JSON.parse(JSON.stringify(DEFAULT_STATS));
                }
            } catch (e) {
                this.log(`Failed to load stats: ${e.message}. Resetting.`);
                this.stats = JSON.parse(JSON.stringify(DEFAULT_STATS));
                await this.saveStats();
            }
        }

        async saveStats() {
            try {
                await GM_setValue(STATS_STORAGE_KEY, JSON.stringify(this.stats));
            } catch (e) {
                this.log(`Failed to save stats: ${e.message}`);
            }
        }

        debouncedSaveStats() {
            if (this.statsSaveDebounceTimer) clearTimeout(this.statsSaveDebounceTimer);
            this.statsSaveDebounceTimer = setTimeout(async () => {
                await this.saveStats();
                this.statsSaveDebounceTimer = null;
            }, STATS_SAVE_DEBOUNCE_MS);
        }

        async resetStats() {
            if (confirm("Reset all filter statistics? This cannot be undone.")) {
                this.stats = JSON.parse(JSON.stringify(DEFAULT_STATS));
                await this.saveStats();
                this.updateUI();
                this.log(`Stats reset.`);
            }
        }

        detectRedditVersion() {
            // (No changes needed)
             const isOldDomain = window.location.hostname === 'old.reddit.com';
             const hasOldBodyClass = document.body.classList.contains('listing-page') || document.body.classList.contains('comments-page');
             if (isOldDomain || hasOldBodyClass) {
                 this.isOldReddit = true;
                 this.selectors = { /* ... old reddit selectors ... */
                    post: '.thing.link:not(.promoted)',
                    comment: '.thing.comment',
                    postSubredditSelector: '.tagline .subreddit',
                    postAuthorSelector: '.tagline .author',
                    commentAuthorSelector: '.tagline .author',
                    postTitleSelector: 'a.title',
                    postBodySelector: '.usertext-body .md, .expando .usertext-body .md',
                    commentBodySelector: '.usertext-body .md',
                    commentEntry: '.entry',
                    commentContentContainer: '.child'
                 };
                 this.log(`Old Reddit detected.`);
             } else {
                 this.isOldReddit = false;
                 this.selectors = { /* ... new reddit selectors ... */
                     post: 'shreddit-post',
                     comment: 'shreddit-comment',
                     postSubredditSelector: '[slot="subreddit-name"]',
                     postAuthorSelector: '[slot="author-name"]',
                     commentAuthorSelector: '[slot="author-name"]',
                     postTitleSelector: '[slot="title"]',
                     postBodySelector: '#post-rtjson-content, [data-post-click-location="text-body"], [slot="text-body"]',
                     commentBodySelector: 'div[slot="comment"]',
                     commentEntry: ':host',
                     commentContentContainer: '[slot="comment"]'
                 };
                 this.log(`New Reddit detected.`);
             }
             this.selectors.message = '.message';
        }

        injectUI() {
            if (this.uiContainer) return;

            this.uiContainer = document.createElement('div');
            this.uiContainer.id = `${SCRIPT_PREFIX}-ui-container`;

            const pos = this.config.uiPosition;
            let initialPositionStyle = `position: fixed; z-index: 9999; `;
            initialPositionStyle += `top: ${pos.top || DEFAULT_CONFIG.uiPosition.top}; `;
            if (pos.left !== null && pos.left !== undefined) {
                initialPositionStyle += `left: ${pos.left}; right: auto; `;
            } else {
                initialPositionStyle += `left: auto; right: ${pos.right || DEFAULT_CONFIG.uiPosition.right}; `;
            }
            initialPositionStyle += `resize: both; overflow: auto; min-width: 380px; min-height: 200px; `;

            this.uiContainer.style.cssText = initialPositionStyle;
            if (pos.width && pos.width !== 'auto') this.uiContainer.style.width = pos.width;
            if (pos.height && pos.height !== 'auto') this.uiContainer.style.height = pos.height;
            this.uiContainer.style.display = this.config.uiVisible ? 'block' : 'none';

            this.shadowRoot = this.uiContainer.attachShadow({ mode: 'open' });

            // --- UI HTML (unchanged) ---
             const uiContent = document.createElement('div');
             uiContent.innerHTML = `
                 <div class="racf-card">
                      <div class="racf-tabs" id="racf-drag-handle">
                          <button class="racf-tab-btn active" data-tab="settings">Settings</button>
                          <button class="racf-tab-btn" data-tab="stats">Statistics</button>
                      </div>
                     <button id="racf-close-btn" class="racf-close-btn" title="Close Panel">×</button>
                     <div id="racf-settings-content" class="racf-tab-content active">
                         <h4>Filter Settings</h4>
                         <div class="racf-add-rule-section">
                             <div class="racf-input-group">
                                 <label for="racf-rule-input">Rule Text:</label>
                                 <input type="text" id="racf-rule-input" placeholder="Keyword, /regex/, user, subreddit">
                             </div>
                             <div class="racf-input-group">
                                 <label for="racf-rule-type">Rule Type:</label>
                                 <select id="racf-rule-type">
                                     <option value="keyword" selected>Keyword/Regex</option>
                                     <option value="user">User</option>
                                     <option value="subreddit">Subreddit</option>
                                 </select>
                             </div>
                             <div class="racf-input-group">
                                 <label for="racf-rule-target">Apply In:</label>
                                 <select id="racf-rule-target">
                                     <option value="both" selected>Title & Body</option>
                                     <option value="title">Title Only</option>
                                     <option value="body">Body Only</option>
                                 </select>
                             </div>
                             <div class="racf-input-group racf-checkbox-group">
                                 <label for="racf-rule-normalize">Normalize:</label>
                                 <input type="checkbox" id="racf-rule-normalize" title="Ignore accents/case (Keywords only, not Regex)">
                                 <small>(Keywords only)</small>
                             </div>
                             <button id="racf-add-rule-btn">Add Rule</button>
                         </div>
                         <div class="racf-section">
                             <label>Filter Types:</label>
                             <label><input type="checkbox" class="racf-filter-type" value="posts"> Posts</label>
                             <label><input type="checkbox" class="racf-filter-type" value="comments"> Comments</label>
                         </div>
                         <div class="racf-section">
                             <label for="racf-filter-action">Filter Action:</label>
                             <select id="racf-filter-action"></select>
                         </div>
                         <div class="racf-section">
                             <label>Active Rules (<span id="racf-rule-count">0</span>):</label>
                             <ul id="racf-rule-list"></ul>
                         </div>
                         <div class="racf-section">
                             <small>Global Whitelists/Blacklists are managed via JSON Import/Export.</small>
                         </div>
                         <div class="racf-section racf-buttons">
                             <button id="racf-import-btn">Import (.json)</button>
                             <button id="racf-export-btn">Export (.json)</button>
                             <input type="file" id="racf-import-file-input" accept=".json" style="display: none;">
                         </div>
                         <div class="racf-section racf-buttons">
                             <button id="racf-clear-processed-btn">Clear Processed Cache</button>
                         </div>
                     </div>
                     <div id="racf-stats-content" class="racf-tab-content">
                         <h4>Filter Statistics</h4>
                         <div class="racf-stats-grid">
                             <div>Total Processed:</div><div id="racf-stats-processed">0</div>
                             <div>Total Filtered:</div><div id="racf-stats-filtered">0</div>
                             <div>Filtering Rate:</div><div id="racf-stats-rate">0%</div>
                             <div>Total Whitelisted:</div><div id="racf-stats-whitelisted">0</div>
                             <div>Filtered Posts:</div><div id="racf-stats-type-posts">0</div>
                             <div>Filtered Comments:</div><div id="racf-stats-type-comments">0</div>
                             <div>Action - Hide:</div><div id="racf-stats-action-hide">0</div>
                             <div>Action - Blur:</div><div id="racf-stats-action-blur">0</div>
                             <div>Action - Border:</div><div id="racf-stats-action-border">0</div>
                             <div>Action - Collapse:</div><div id="racf-stats-action-collapse">0</div>
                             <div>Action - Replace Text:</div><div id="racf-stats-action-replace_text">0</div>
                         </div>
                         <div class="racf-section">
                             <label>Most Active Rules:</label>
                             <ul id="racf-stats-rule-list"><li>No rules active yet.</li></ul>
                         </div>
                         <div class="racf-section racf-buttons">
                             <button id="racf-reset-stats-btn">Reset Statistics</button>
                         </div>
                     </div>
                 </div>`;

            // --- CSS Styles (Keep cursor: move only on tabs for visual cue) ---
            const styles = document.createElement('style');
            styles.textContent = `
                :host { font-family: sans-serif; font-size: 14px; }
                .racf-card {
                    background-color: #f9f9f9; border: 1px solid #ccc; border-radius: 5px; padding: 0;
                    box-shadow: 0 2px 5px rgba(0,0,0,.2); position: relative; color: #333;
                    height: 100%; display: flex; flex-direction: column;
                }
                /* Keep cursor: move only on the explicit tabs bar for clarity */
                .racf-tabs { display: flex; border-bottom: 1px solid #ccc; cursor: move; user-select: none; flex-shrink: 0; }
                .racf-tab-btn {
                    flex: 1; padding: 10px 15px; background: #e9ecef; border: none;
                    border-right: 1px solid #dee2e6; cursor: pointer; font-size: 14px; color: #495057;
                    transition: background-color 0.2s, color 0.2s, border-color 0.2s;
                }
                .racf-tab-btn:last-child { border-right: none; }
                .racf-tab-btn:hover { background: #d3d9df; color: #212529; }
                .racf-tab-btn.active {
                    background: #f9f9f9; color: #0056b3; border-bottom: 1px solid #f9f9f9;
                    border-top: 3px solid #007bff; margin-bottom: -1px; font-weight: 700;
                }
                .racf-tab-content { display: none; padding: 15px; flex-grow: 1; overflow-y: auto; }
                .racf-tab-content.active { display: block; }
                .racf-card h4 { margin-top: 0; margin-bottom: 15px; border-bottom: 1px solid #eee; padding-bottom: 10px; color: #0056b3; }
                .racf-section { margin-bottom: 15px; }
                .racf-section small { font-weight: normal; font-style: italic; color: #555; font-size: 0.9em; }
                .racf-add-rule-section { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 10px; align-items: flex-end; margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px solid #eee; }
                .racf-input-group { display: flex; flex-direction: column; gap: 3px; }
                .racf-input-group label { font-size: .9em; font-weight: 700; color: #495057; }
                .racf-checkbox-group { flex-direction: row; align-items: center; gap: 5px; margin-top: auto; }
                .racf-checkbox-group label { margin-bottom: 0; }
                .racf-checkbox-group small { margin-left: 0; }
                input[type=text], select { width: 100%; padding: 8px; border: 1px solid #ccc; border-radius: 3px; box-sizing: border-box; font-size: 14px; background-color: #fff; color: #212529; }
                input[type=text]:focus, select:focus { border-color: #80bdff; outline: 0; box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25); }
                .racf-section input[type=checkbox] { margin-right: 3px; vertical-align: middle; }
                .racf-section label+label { margin-left: 10px; font-weight: 400; }
                button { padding: 8px 12px; border: 1px solid #adb5bd; background-color: #f8f9fa; color: #212529; border-radius: 3px; cursor: pointer; font-size: 14px; transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out, color 0.15s ease-in-out; }
                button:hover { background-color: #e9ecef; border-color: #a1a8af; color: #212529; }
                button:active { background-color: #dee2e6; border-color: #939ba1; }
                #racf-add-rule-btn { background-color: #007bff; color: #fff; border-color: #007bff; font-weight: bold; padding: 8px 15px; margin-top: 15px; grid-column: 1 / -1; }
                #racf-add-rule-btn:hover { background-color: #0056b3; border-color: #0056b3; }
                #racf-import-btn, #racf-export-btn { background-color: #28a745; color: #fff; border-color: #28a745; }
                #racf-import-btn:hover, #racf-export-btn:hover { background-color: #218838; border-color: #1e7e34; }
                #racf-clear-processed-btn { background-color: #6c757d; color: #fff; border-color: #6c757d; }
                #racf-clear-processed-btn:hover { background-color: #5a6268; border-color: #545b62; }
                #racf-reset-stats-btn { background-color: #dc3545; color: #fff; border-color: #dc3545; }
                #racf-reset-stats-btn:hover { background-color: #c82333; border-color: #bd2130; }
                #racf-rule-list button.racf-remove-btn { background: #dc3545; border: 1px solid #dc3545; color: #fff; padding: 3px 7px; font-size: 11px; margin-left: 5px; flex-shrink: 0; line-height: 1; }
                #racf-rule-list button.racf-remove-btn:hover { background-color: #c82333; border-color: #bd2130; }
                #racf-rule-list, #racf-stats-rule-list { list-style: none; padding: 0; max-height: 180px; overflow-y: auto; border: 1px solid #eee; margin-top: 5px; background: #fff; }
                #racf-rule-list li, #racf-stats-rule-list li { padding: 6px 10px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; align-items: center; font-size: 12px; }
                #racf-rule-list li:last-child, #racf-stats-rule-list li:last-child { border-bottom: none; }
                #racf-rule-list .racf-rule-details { flex-grow: 1; margin-right: 10px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
                #racf-rule-list .racf-rule-type-badge { font-size: .8em; padding: 1px 4px; border-radius: 3px; background-color: #6c757d; color: #fff; flex-shrink: 0; text-transform: uppercase; }
                #racf-rule-list .racf-rule-text { word-break: break-all; font-family: monospace; background: #e9ecef; padding: 1px 3px; border-radius: 2px; color: #212529;}
                #racf-stats-rule-list .racf-rule-text { flex-grow: 1; margin-right: 10px; word-break: break-all; font-family: monospace; background: #e9ecef; padding: 1px 3px; border-radius: 2px; color: #212529;}
                #racf-stats-rule-list .racf-rule-count { font-weight: 700; margin-left: 10px; flex-shrink: 0; background-color: #007bff; color: #fff; padding: 2px 5px; border-radius: 10px; font-size: 0.9em;}
                .racf-buttons { margin-top: 15px; border-top: 1px solid #eee; padding-top: 10px; display: flex; gap: 10px; flex-wrap: wrap; flex-shrink: 0; }
                .racf-buttons button { flex-grow: 1; margin: 0; }
                .racf-close-btn { position: absolute; top: 5px; right: 10px; background: 0 0; border: none; font-size: 24px; font-weight: 700; color: #6c757d; cursor: pointer; z-index: 10; margin: 0 !important; padding: 0 5px; line-height: 1; }
                .racf-close-btn:hover { color: #343a40; }
                .racf-stats-grid { display: grid; grid-template-columns: auto 1fr; gap: 5px 10px; margin-bottom: 15px; font-size: 13px; }
                .racf-stats-grid div:nth-child(odd) { font-weight: 700; text-align: right; color: #495057; }
                .racf-stats-grid div:nth-child(even) { font-family: monospace; color: #0056b3; }
            `;


            this.shadowRoot.appendChild(styles);
            this.shadowRoot.appendChild(uiContent);
            this.injectGlobalStyles();
            document.body.insertAdjacentElement('beforeend', this.uiContainer);
            this.addUIEventListeners();
            this.log(`UI injected with resize and wide drag area enabled.`);
        }

        injectGlobalStyles() {
             // (No changes needed)
             const styleId = `${SCRIPT_PREFIX}-global-styles`;
             let globalStyleSheet = document.getElementById(styleId);
             if (!globalStyleSheet) {
                 globalStyleSheet = document.createElement("style");
                 globalStyleSheet.id = styleId;
                 document.head.appendChild(globalStyleSheet);
             }
             const commentEntrySelector = this.selectors.commentEntry || '.comment';
             const commentContentContainerSelector = this.selectors.commentContentContainer || '.child';
             const commentTaglineSelector = this.isOldReddit ? '.entry > .tagline' : 'header';
             const commentFormSelector = this.isOldReddit ? '.entry > form' : 'shreddit-composer';
             globalStyleSheet.textContent = `
                 .${SCRIPT_PREFIX}-hide { display: none !important; height: 0 !important; overflow: hidden !important; margin: 0 !important; padding: 0 !important; border: none !important; visibility: hidden !important; }
                 .${SCRIPT_PREFIX}-blur { filter: blur(5px) !important; transition: filter 0.2s ease; cursor: pointer; }
                 .${SCRIPT_PREFIX}-blur:hover { filter: none !important; }
                 .${SCRIPT_PREFIX}-border { outline: 3px solid red !important; outline-offset: -1px; }
                 .${SCRIPT_PREFIX}-collapse > ${commentContentContainerSelector},
                 .${SCRIPT_PREFIX}-collapse ${commentFormSelector} { display: none !important; }
                 .${SCRIPT_PREFIX}-collapse.thing.comment > .entry > .child,
                 .${SCRIPT_PREFIX}-collapse.thing.comment > .entry > .usertext { display: none !important; }
                 .${SCRIPT_PREFIX}-collapse > ${commentTaglineSelector} { opacity: 0.6 !important; }
                 .${SCRIPT_PREFIX}-collapse > ${commentTaglineSelector}::after,
                 .${SCRIPT_PREFIX}-collapse.thing.comment > .entry > .tagline::after {
                     content: " [Filtered]"; font-style: italic; font-size: 0.9em; color: grey;
                     margin-left: 5px; display: inline; vertical-align: baseline;
                 }
                 .${SCRIPT_PREFIX}-hide.thing.comment,
                 .${SCRIPT_PREFIX}-hide.shreddit-comment { }
                 .${SCRIPT_PREFIX}-text-replaced .usertext-body .md p,
                 .${SCRIPT_PREFIX}-text-replaced div[slot="comment"] p {
                     color: grey; font-style: italic; margin: 0; padding: 5px 0;
                 }
             `;
        }

        addUIEventListeners() {
            if (!this.shadowRoot) return;
            const q = (s) => this.shadowRoot.querySelector(s);
            const qa = (s) => this.shadowRoot.querySelectorAll(s);

            // *** CHANGE HERE: Attach drag listener to the main card ***
            const cardElement = q('.racf-card');
            if (cardElement) {
                cardElement.addEventListener('mousedown', this.dragMouseDown.bind(this));
                this.debugLog("Drag listener attached to .racf-card");
            } else {
                this.log("Error: Card element (.racf-card) not found for attaching drag listener.");
            }

            // --- Other listeners remain the same ---

            // Tab switching (ensure stopPropagation to prevent drag)
            qa('.racf-tab-btn').forEach(btn => {
                btn.addEventListener('click', (e) => {
                    e.stopPropagation(); // Prevent drag start when clicking tabs
                    const tabId = e.target.dataset.tab;
                    if (!tabId) return;
                    qa('.racf-tab-btn').forEach(b => b.classList.remove('active'));
                    qa('.racf-tab-content').forEach(c => c.classList.remove('active'));
                    e.target.classList.add('active');
                    const contentEl = q(`#racf-${tabId}-content`);
                    if (contentEl) contentEl.classList.add('active');
                    this.config.activeTab = tabId;
                    if (tabId === 'stats') { this.updateUI(); }
                    // Don't save config on tab switch
                });
            });

            // Rule management
            q('#racf-add-rule-btn').addEventListener('click', () => this.handleAddRule());
            q('#racf-rule-input').addEventListener('keypress', (e) => { if (e.key === 'Enter') this.handleAddRule(); });
            q('#racf-rule-list').addEventListener('click', (e) => {
                 // Prevent drag start when clicking inside the rule list (buttons handled below)
                 e.stopPropagation();
                const removeButton = e.target.closest('button.racf-remove-btn');
                if (removeButton) {
                     // stopPropagation() already prevents drag, button click proceeds
                    const ruleIndex = parseInt(removeButton.dataset.ruleIndex, 10);
                    if (!isNaN(ruleIndex)) { this.removeRuleByIndex(ruleIndex); }
                    else { this.log(`Could not remove rule: Invalid index.`); }
                }
            });

            // Filter type checkboxes
            qa('.racf-filter-type').forEach(cb => {
                cb.addEventListener('change', (e) => this.handleFilterTypeChange(e));
            });

            // Filter action dropdown
            const filterActionSelect = q('#racf-filter-action');
            if (filterActionSelect) {
                filterActionSelect.addEventListener('change', (e) => {
                    const newAction = e.target.value;
                    if (FILTER_ACTIONS.includes(newAction)) {
                        this.config.filterAction = newAction;
                        this.saveConfigAndApplyFilters();
                    } else {
                        this.log(`Invalid filter action selected: ${newAction}`);
                        e.target.value = this.config.filterAction;
                    }
                });
            }

            // Import/Export/Clear/Reset buttons
            q('#racf-export-btn').addEventListener('click', () => this.exportConfig());
            q('#racf-import-btn').addEventListener('click', () => { q('#racf-import-file-input')?.click(); });
            q('#racf-import-file-input')?.addEventListener('change', (e) => this.importConfig(e));
            q('#racf-clear-processed-btn').addEventListener('click', () => {
                this.processedNodes = new WeakSet();
                this.originalContentCache = new WeakMap();
                this.log(`Processed node cache and original content cache cleared.`);
                this.applyFilters(document.body);
            });
            q('#racf-reset-stats-btn').addEventListener('click', () => this.resetStats());
            q('#racf-close-btn').addEventListener('click', (e) => {
                 e.stopPropagation(); // Prevent drag start when clicking close button
                 this.toggleUIVisibility(false)
            });

             // Resize end listener (mouseup on the container)
             if (this.uiContainer) {
                 this.uiContainer.addEventListener('mouseup', () => {
                    if (!this.isDragging) { // Only save dimensions if not dragging
                         this.saveCurrentDimensions();
                    }
                 });
             }
        }

        // --- Dragging Functions ---
        dragMouseDown(e) {
            // 1. Only react to left mouse button
            if (e.button !== 0) return;

            // *** CHANGE HERE: Prevent drag if clicking on interactive elements ***
            const noDragElementsSelector = 'button, input, select, textarea, a, ul#racf-rule-list, ul#racf-stats-rule-list';
            const clickedElement = e.target;

            if (clickedElement.closest(noDragElementsSelector)) {
                this.debugLog("Drag prevented: Clicked on an interactive element.", clickedElement);
                // Don't prevent default or stop propagation here, allow the click to proceed on the element
                return;
            }
            // Also prevent drag if clicking directly on scrollbars within the shadow DOM (experimental)
             if (e.offsetX > clickedElement.clientWidth || e.offsetY > clickedElement.clientHeight) {
                 this.debugLog("Drag prevented: Click likely on scrollbar.");
                 return;
             }

            // 2. If click is not on an excluded element, proceed with drag initiation
            e.preventDefault(); // Prevent text selection during drag
            e.stopPropagation(); // Prevent triggering other listeners if needed

            this.isDragging = true;
            this.dragStartX = e.clientX;
            this.dragStartY = e.clientY;
            const rect = this.uiContainer.getBoundingClientRect();
            this.dragInitialTop = rect.top;
            this.dragInitialLeft = rect.left;

            this.elementDragBound = this.elementDrag.bind(this);
            this.closeDragElementBound = this.closeDragElement.bind(this);
            document.addEventListener('mousemove', this.elementDragBound);
            document.addEventListener('mouseup', this.closeDragElementBound);

            // Optional visual feedback
            // this.uiContainer.style.cursor = 'grabbing'; // Might override internal cursors
            this.uiContainer.style.opacity = '0.9';
            this.uiContainer.style.userSelect = 'none'; // Prevent text selection
        }

        elementDrag(e) {
            // (No changes needed)
            if (!this.isDragging) return;
            e.preventDefault();
            const deltaX = e.clientX - this.dragStartX;
            const deltaY = e.clientY - this.dragStartY;
            let newTop = this.dragInitialTop + deltaY;
            let newLeft = this.dragInitialLeft + deltaX;
            const containerRect = this.uiContainer.getBoundingClientRect();
            newTop = Math.max(0, Math.min(newTop, window.innerHeight - containerRect.height));
            newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - containerRect.width));
            this.uiContainer.style.top = `${newTop}px`;
            this.uiContainer.style.left = `${newLeft}px`;
            this.uiContainer.style.right = 'auto';
        }

        closeDragElement() {
            // (No changes needed)
            if (!this.isDragging) return;
            this.isDragging = false;
            document.removeEventListener('mousemove', this.elementDragBound);
            document.removeEventListener('mouseup', this.closeDragElementBound);
            // this.uiContainer.style.cursor = '';
            this.uiContainer.style.opacity = '1';
            this.uiContainer.style.userSelect = '';
            this.saveCurrentPositionAndDimensions(); // Save final state
        }
        // --- End Dragging Functions ---

        saveCurrentPositionAndDimensions() {
            // (No changes needed)
             if (!this.uiContainer) return;
             const rect = this.uiContainer.getBoundingClientRect();
             this.config.uiPosition.top = `${rect.top}px`;
             this.config.uiPosition.left = `${rect.left}px`;
             this.config.uiPosition.right = null; // Always use left after interaction
             this.config.uiPosition.width = `${rect.width}px`;
             this.config.uiPosition.height = `${rect.height}px`;
             this.saveConfig();
        }

         saveCurrentDimensions() {
            // (No changes needed)
             if (!this.uiContainer) return;
             const rect = this.uiContainer.getBoundingClientRect();
             let changed = false;
             const newWidth = `${rect.width}px`;
             const newHeight = `${rect.height}px`;
             if (this.config.uiPosition.width !== newWidth) {
                 this.config.uiPosition.width = newWidth;
                 changed = true;
             }
              if (this.config.uiPosition.height !== newHeight) {
                 this.config.uiPosition.height = newHeight;
                 changed = true;
             }
             if (changed) {
                 this.debugLog(`Saving dimensions after resize: W=${newWidth}, H=${newHeight}`);
                 this.saveConfig();
             }
         }

        updateUI() {
             // (No changes needed)
            if (!this.shadowRoot || !this.uiContainer) return;
            this.uiContainer.style.display = this.config.uiVisible ? 'block' : 'none';
            const pos = this.config.uiPosition;
            this.uiContainer.style.top = pos.top || DEFAULT_CONFIG.uiPosition.top;
            if (pos.left !== null) {
                this.uiContainer.style.left = pos.left;
                this.uiContainer.style.right = 'auto';
            } else {
                this.uiContainer.style.left = 'auto';
                this.uiContainer.style.right = pos.right || DEFAULT_CONFIG.uiPosition.right;
            }
             if (pos.width && pos.width !== 'auto') this.uiContainer.style.width = pos.width;
             if (pos.height && pos.height !== 'auto') this.uiContainer.style.height = pos.height;

            const q = (s) => this.shadowRoot.querySelector(s);
            const qa = (s) => this.shadowRoot.querySelectorAll(s);
            const ruleListEl = q('#racf-rule-list');
            if (ruleListEl) {
                ruleListEl.innerHTML = '';
                (this.config.rules || []).forEach((rule, index) => {
                    const li = document.createElement('li');
                    const safeText = this.domPurify.sanitize(rule.text || '', { USE_PROFILES: { html: false } });
                    const typeTitle = `Type: ${rule.type}`; const regexTitle = rule.isRegex ? ' (Regex)' : '';
                    const caseTitle = (rule.type === 'keyword' && !rule.isRegex) ? (rule.caseSensitive ? ' (Case Sensitive)' : ' (Case Insensitive)') : '';
                    const targetTitle = `Applies to: ${rule.target || 'both'}`; const normTitle = rule.normalize ? ' (Normalized)' : '';
                    li.innerHTML = `<div class="racf-rule-details"><span class="racf-rule-type-badge" title="${typeTitle}">${rule.type}</span><span class="racf-rule-text">${safeText}</span>${rule.isRegex ? `<small title="Regular Expression${caseTitle}">(R${rule.caseSensitive ? '' : 'i'})</small>` : ''}${rule.type === 'keyword' && !rule.isRegex && !rule.caseSensitive && !rule.normalize ? '<small title="Case Insensitive">(i)</small>' : ''}<small title="${targetTitle}">[${rule.target || 'both'}]</small>${rule.normalize ? `<small title="${normTitle}">(Norm)</small>` : ''}</div><button class="racf-remove-btn" data-rule-index="${index}" title="Remove Rule">X</button>`;
                    ruleListEl.appendChild(li);
                });
                const ruleCountEl = q('#racf-rule-count'); if (ruleCountEl) ruleCountEl.textContent = (this.config.rules || []).length;
            }
            qa('.racf-filter-type').forEach(cb => { cb.checked = (this.config.filterTypes || []).includes(cb.value); });
            const actionSelect = q('#racf-filter-action');
            if (actionSelect) {
                if (actionSelect.options.length === 0) {
                    FILTER_ACTIONS.forEach(action => { const option = document.createElement('option'); option.value = action; switch(action){ case 'hide':option.textContent='Hide Completely';break; case 'blur':option.textContent='Blur (Hover)';break; case 'border':option.textContent='Red Border';break; case 'collapse':option.textContent='Collapse (Comments)';break; case 'replace_text':option.textContent='Replace Text (Comments)';break; default:option.textContent=action.charAt(0).toUpperCase()+action.slice(1); } actionSelect.appendChild(option); });
                } actionSelect.value = this.config.filterAction;
            }
             const statsP = q('#racf-stats-processed'); if(statsP) statsP.textContent=this.stats.totalProcessed;
             const statsF = q('#racf-stats-filtered'); if(statsF) statsF.textContent=this.stats.totalFiltered;
             const statsR = q('#racf-stats-rate'); if(statsR) {const r = this.stats.totalProcessed>0?((this.stats.totalFiltered/this.stats.totalProcessed)*100).toFixed(1):0; statsR.textContent=`${r}%`;}
             const statsW = q('#racf-stats-whitelisted'); if(statsW) statsW.textContent=this.stats.totalWhitelisted;
             const statsTP = q('#racf-stats-type-posts'); if(statsTP) statsTP.textContent=this.stats.filteredByType?.posts||0;
             const statsTC = q('#racf-stats-type-comments'); if(statsTC) statsTC.textContent=this.stats.filteredByType?.comments||0;
             const statsAH = q('#racf-stats-action-hide'); if(statsAH) statsAH.textContent=this.stats.filteredByAction?.hide||0;
             const statsAB = q('#racf-stats-action-blur'); if(statsAB) statsAB.textContent=this.stats.filteredByAction?.blur||0;
             const statsAbo = q('#racf-stats-action-border'); if(statsAbo) statsAbo.textContent=this.stats.filteredByAction?.border||0;
             const statsAC = q('#racf-stats-action-collapse'); if(statsAC) statsAC.textContent=this.stats.filteredByAction?.collapse||0;
             const statsAR = q('#racf-stats-action-replace_text'); if(statsAR) statsAR.textContent=this.stats.filteredByAction?.replace_text||0;
            if (this.config.activeTab === 'stats') {
                const statsRuleListEl = q('#racf-stats-rule-list');
                if (statsRuleListEl) {
                    statsRuleListEl.innerHTML = ''; const sortedRules = Object.entries(this.stats.filteredByRule || {}).filter(([, c]) => c > 0).sort(([, a], [, b]) => b - a);
                    if (sortedRules.length === 0) { statsRuleListEl.innerHTML = '<li>No rules triggered yet.</li>'; }
                    else { sortedRules.slice(0, 20).forEach(([rt, c]) => { const li = document.createElement('li'); const srt = this.domPurify.sanitize(rt, { USE_PROFILES: { html: false } }); li.innerHTML = `<span class="racf-rule-text">${srt}</span><span class="racf-rule-count" title="Times triggered">${c}</span>`; statsRuleListEl.appendChild(li); }); }
                }
            }
            const activeTabId = this.config.activeTab || 'settings';
            qa('.racf-tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === activeTabId));
            qa('.racf-tab-content').forEach(c => c.classList.toggle('active', c.id === `racf-${activeTabId}-content`));
        }

        // --- Filtering Logic (shouldFilterNode, extract*, filterNode, etc.) ---
        // (No changes needed in these core filtering functions)
        normalizeText(text) { if(typeof text !== 'string') return ''; try { return text.normalize("NFD").replace(/[\u0300-\u036f]/g, "").toLowerCase(); } catch (e) { this.log(`Error normalizing: ${e.message}`); return text.toLowerCase(); } }
        handleAddRule() { const iE=this.shadowRoot.querySelector('#racf-rule-input'); const tE=this.shadowRoot.querySelector('#racf-rule-type'); const tgE=this.shadowRoot.querySelector('#racf-rule-target'); const nE=this.shadowRoot.querySelector('#racf-rule-normalize'); if(!iE||!tE||!tgE||!nE){alert("UI error");return;} const rIT=iE.value.trim(); const rT=tE.value; const rTg=tgE.value; const rN=nE.checked; if(!rIT){alert("Empty rule");iE.focus();return;} if(!RULE_TYPES.includes(rT)){alert("Bad type");return;} let txt=rIT; let isR=false; let cS=true; if(rT==='keyword'){if(txt.startsWith('/')&&txt.length>2){const lSI=txt.lastIndexOf('/');if(lSI>0){const p=txt.substring(1,lSI); const f=txt.substring(lSI+1);try{new RegExp(p,f);isR=true;cS=!f.includes('i');txt=txt;}catch(e){alert(`Bad Regex:${e.message}`);return;}}else{isR=false;cS=false;}}else{isR=false;cS=false;}}else if(rT==='user'||rT==='subreddit'){txt=txt.replace(/^(u\/|r\/)/i,'');isR=false;cS=false;txt=txt.toLowerCase();} if(rN&&rT==='keyword'&&!isR){cS=false;} const nR={type:rT,text:txt,isRegex:isR,caseSensitive:cS,target:rTg,normalize:(rT==='keyword'&&!isR&&rN)}; if(!this.config.rules)this.config.rules=[]; const rE=this.config.rules.some(r=>r.type===nR.type&&r.text===nR.text&&r.isRegex===nR.isRegex&&r.caseSensitive===nR.caseSensitive&&r.target===nR.target&&r.normalize===nR.normalize); if(rE){alert("Rule exists");iE.value='';return;} this.config.rules.push(nR); this.log(`Rule added: ${JSON.stringify(nR)}`); iE.value=''; nE.checked=false; tgE.value='both'; tE.value='keyword'; iE.focus(); this.saveConfigAndApplyFilters(); this.updateUI(); }
        removeRuleByIndex(index) { if(!this.config.rules||index<0||index>=this.config.rules.length){this.log(`Bad index ${index}`);return;} const rm=this.config.rules.splice(index,1); this.log(`Rule removed: ${JSON.stringify(rm[0])}`); this.saveConfigAndApplyFilters(); this.updateUI(); }
        handleFilterTypeChange(event) { const{value,checked}=event.target; if(!this.config.filterTypes)this.config.filterTypes=[]; const index=this.config.filterTypes.indexOf(value); if(checked&&index===-1){this.config.filterTypes.push(value);}else if(!checked&&index>-1){this.config.filterTypes.splice(index,1);} this.saveConfigAndApplyFilters(); }
        initializeObserver() { if(!window.MutationObserver){this.log("No MutationObserver");return;} if(this.observer){this.observer.disconnect();} this.observer=new MutationObserver(this.mutationCallback.bind(this)); this.observer.observe(document.body,{childList:true,subtree:true}); this.log("Observer init."); }
        mutationCallback(mutationsList) { const nTC=new Set(); let hRC=false; for(const m of mutationsList){if(m.type==='childList'&&m.addedNodes.length>0){m.addedNodes.forEach(n=>{if(n.nodeType===Node.ELEMENT_NODE&&!n.id?.startsWith(SCRIPT_PREFIX)&&!n.closest(`#${SCRIPT_PREFIX}-ui-container`)){if(n.matches&&(n.matches(this.selectors.post)||n.matches(this.selectors.comment))){nTC.add(n);hRC=true;} if(n.querySelectorAll){try{n.querySelectorAll(`${this.selectors.post},${this.selectors.comment}`).forEach(el=>{nTC.add(el);hRC=true;});}catch(e){this.debugLog(`Query error: ${e.message}`,n);}}}});}} if(hRC&&nTC.size>0){this.debugLog(`Mutation: ${nTC.size} new nodes.`); this.applyFilters(Array.from(nTC));} }
        applyFilters(nodesOrRoot) { let iTP=[]; const sT=performance.now(); const eS=new Set(); const cE=(r)=>{if(!r||r.nodeType!==Node.ELEMENT_NODE)return; try{if(r.matches&&(r.matches(this.selectors.post)||r.matches(this.selectors.comment))){if(!this.processedNodes.has(r)){eS.add(r);}} r.querySelectorAll(`${this.selectors.post},${this.selectors.comment}`).forEach(el=>{if(!this.processedNodes.has(el)){eS.add(el);}});}catch(e){this.log(`Collect error: ${e.message}`);console.error("Collect Node:",r,e);}}; if(Array.isArray(nodesOrRoot)){nodesOrRoot.forEach(n=>cE(n));}else if(nodesOrRoot?.nodeType===Node.ELEMENT_NODE){cE(nodesOrRoot);}else{this.debugLog("Bad applyFilters input:",nodesOrRoot);return;} iTP=Array.from(eS); if(iTP.length===0){return;} this.debugLog(`Filtering ${iTP.length} new nodes...`); let sC=false; let pC=0; let fC=0; let wC=0; iTP.forEach(n=>{this.processedNodes.add(n);pC++;sC=true; try{const fR=this.shouldFilterNode(n); if(fR.whitelisted){wC++;this.unfilterNode(n);this.debugLog(`Whitelisted: ${fR.reason}`,n);}else if(fR.filter){fC++; const nT=fR.nodeType; const eA=this.getEffectiveAction(this.config.filterAction,nT); if(nT&&this.stats.filteredByType){this.stats.filteredByType[nT]=(this.stats.filteredByType[nT]||0)+1;} if(eA&&this.stats.filteredByAction){this.stats.filteredByAction[eA]=(this.stats.filteredByAction[eA]||0)+1;} const rST=fR.ruleText||`type:${fR.reason}`; if(rST&&this.stats.filteredByRule){this.stats.filteredByRule[rST]=(this.stats.filteredByRule[rST]||0)+1;} this.filterNode(n,fR.reason,nT,eA);this.debugLog(`Filtered (${eA}): ${fR.reason}`,n);}else{this.unfilterNode(n);this.debugLog(`Not filtered: ${fR.reason}`,n);}}catch(err){this.log(`Filter node error: ${err.message}`);console.error(`Filter error:`,err,n); try{this.unfilterNode(n);}catch{}}}); if(sC){this.stats.totalProcessed+=pC;this.stats.totalFiltered+=fC;this.stats.totalWhitelisted+=wC; this.debouncedSaveStats(); if(this.uiUpdateDebounceTimer)clearTimeout(this.uiUpdateDebounceTimer); this.uiUpdateDebounceTimer=setTimeout(()=>{if(this.config.uiVisible){this.updateUI();} this.uiUpdateDebounceTimer=null;},300);} this.lastFilterTime=performance.now(); const dur=this.lastFilterTime-sT; if(iTP.length>0){this.debugLog(`Filtering ${iTP.length} nodes took ${dur.toFixed(2)} ms.`);} }
        getEffectiveAction(cA,nT){if(nT!=='comments'){if(cA==='collapse'||cA==='replace_text'){return'hide';}} return cA;}
        shouldFilterNode(node){ let nT=null; if(node.matches(this.selectors.post))nT='posts'; else if(node.matches(this.selectors.comment))nT='comments'; else return{filter:false,reason:"Not target",whitelisted:false,ruleText:null,nodeType:null}; let res={filter:false,reason:"No match",whitelisted:false,ruleText:null,nodeType:nT}; if(!(this.config.filterTypes||[]).includes(nT)){res.reason=`Type ${nT} disabled`;return res;} const sub=this.extractSubreddit(node,nT)?.toLowerCase()??null; const aut=this.extractAuthor(node,nT)?.toLowerCase()??null; if(sub&&(this.config.blacklist?.subreddits||[]).includes(sub)){return{...res,filter:true,reason:`BL Sub: r/${sub}`,ruleText:`bl-sub:${sub}`};} if(aut&&(this.config.blacklist?.users||[]).includes(aut)){return{...res,filter:true,reason:`BL User: u/${aut}`,ruleText:`bl-user:${aut}`};} if(sub&&(this.config.whitelist?.subreddits||[]).includes(sub)){return{...res,whitelisted:true,reason:`WL Sub: r/${sub}`};} if(aut&&(this.config.whitelist?.users||[]).includes(aut)){return{...res,whitelisted:true,reason:`WL User: u/${aut}`};} let cC={title:null,body:null,checked:false}; for(const rule of(this.config.rules||[])){let match=false; const rST=`[${rule.type}${rule.isRegex?'(R)':''}${rule.normalize?'(N)':''}${rule.target?`-${rule.target}`:''}] ${rule.text}`; let rS=""; try{switch(rule.type){case'keyword':const targ=rule.target||'both'; if(!cC.checked){const ex=this.extractContent(node,nT);cC.title=ex.title;cC.body=ex.body;cC.checked=true;this.debugLog(`Extracted: T:${!!cC.title}, B:${!!cC.body}`,node);} let cTT=[]; let tA=[]; if((targ==='title'||targ==='both')&&cC.title){cTT.push(cC.title);tA.push('title');} if((targ==='body'||targ==='both')&&cC.body){cTT.push(cC.body);tA.push('body');} if(cTT.length===0){this.debugLog(`Skip rule ${rST}: no content for target '${targ}'`,node);continue;} rS=` in ${tA.join('&')}`; let patt=rule.text; let tF; if(rule.isRegex){const rM=patt.match(/^\/(.+)\/([gimyus]*)$/); if(rM){try{const rgx=new RegExp(rM[1],rM[2]);tF=(t)=>rgx.test(t);rS+=` (Regex${rgx.flags.includes('i')?', Insens.':''})`;}catch(rE){this.log(`Rule err (bad regex) ${rST}: ${rE.message}`);continue;}}else{this.log(`Rule err (malformed regex) ${rST}`);continue;}}else{const uN=rule.normalize; const iCS=rule.caseSensitive; const cP=uN?this.normalizeText(patt):(iCS?patt:patt.toLowerCase()); tF=(t)=>{if(!t)return false; const cCo=uN?this.normalizeText(t):(iCS?t:t.toLowerCase()); return cCo.includes(cP);}; rS+=`${uN?' (Norm.)':(iCS?' (Case Sens.)':' (Case Insens.)')}`;} match=cTT.some(t=>tF(t)); break; case'user':if(!aut)continue; match=aut===rule.text; rS=` (author: u/${aut})`; break; case'subreddit':if(!sub||nT!=='posts')continue; match=sub===rule.text; rS=` (sub: r/${sub})`; break;} if(match){const sRD=this.domPurify.sanitize(rule.text,{USE_PROFILES:{html:false}}); return{...res,filter:true,reason:`Rule: [${rule.type}] '${sRD}'${rS}`,ruleText:rST};}}catch(e){this.log(`Rule proc error ${rST}: ${e.message}`);console.error(`Rule error:`,e,rule,node);}} res.reason="No matches"; return res;}
        extractContent(n,nT){const r={title:null,body:null};try{if(nT==='posts'&&this.selectors.postTitleSelector){const tE=n.querySelector(this.selectors.postTitleSelector);if(tE){r.title=tE.textContent?.trim()||null;if(r.title)r.title=r.title.replace(/\s+/g,' ');}} let bS=null; if(nT==='posts'&&this.selectors.postBodySelector){bS=this.selectors.postBodySelector;}else if(nT==='comments'&&this.selectors.commentBodySelector){bS=this.selectors.commentBodySelector;} if(bS){const bE=n.querySelector(bS);if(bE){r.body=bE.textContent?.trim()||null;if(r.body)r.body=r.body.replace(/\s+/g,' ');}else if(this.isOldReddit&&nT==='posts'){const oPB=n.querySelector('.expando .usertext-body .md');if(oPB){r.body=oPB.textContent?.trim()||null;if(r.body)r.body=r.body.replace(/\s+/g,' ');}}}}catch(e){this.log(`Extract content err (t:${nT}): ${e.message}`);console.error("Extract Err:",n,e);} return r;}
        extractSubreddit(n,nT){if(nT!=='posts'||!this.selectors.postSubredditSelector)return null; try{const sE=n.querySelector(this.selectors.postSubredditSelector);if(sE){return sE.textContent?.trim().replace(/^r\//i,'')||null;} if(!this.isOldReddit){const lS=n.querySelector('a[data-testid="subreddit-name"]');if(lS)return lS.textContent?.trim().replace(/^r\//i,'')||null;} return null;}catch(e){this.log(`Extract sub err: ${e.message}`);return null;}}
        extractAuthor(n,nT){const sel=nT==='posts'?this.selectors.postAuthorSelector:this.selectors.commentAuthorSelector; if(!sel)return null; try{const aE=n.querySelector(sel); if(aE){const aT=aE.textContent?.trim();if(aT&&!['[deleted]','[removed]',''].includes(aT.toLowerCase())){return aT.replace(/^u\//i,'')||null;}} if(!this.isOldReddit){const lA=n.querySelector('a[data-testid="post-author-link"], a[data-testid="comment-author-link"]');if(lA){const aT=lA.textContent?.trim();if(aT&&!['[deleted]','[removed]',''].includes(aT.toLowerCase())){return aT.replace(/^u\//i,'')||null;}}} return null;}catch(e){this.log(`Extract author err (t ${nT}): ${e.message}`);return null;}}
        filterNode(n,rs,nT,ac){this.unfilterNode(n); const eA=this.getEffectiveAction(ac,nT); const sR=rs.substring(0,200)+(rs.length>200?'...':''); const fAV=`${SCRIPT_PREFIX}: Filtered [${eA}] (${sR})`; if(eA==='replace_text'&&nT==='comments'){this.replaceCommentText(n,sR);n.setAttribute('data-racf-filter-reason',fAV);n.title=fAV;}else if(FILTER_ACTIONS.includes(eA)&&eA!=='replace_text'){const aCl=`${SCRIPT_PREFIX}-${eA}`;n.classList.add(aCl);n.setAttribute('data-racf-filter-reason',fAV);n.title=fAV;this.debugLog(`Applied class '${aCl}' to:`,n);}else{this.log(`Invalid action '${ac}' in filterNode. Hiding.`);n.classList.add(`${SCRIPT_PREFIX}-hide`); const fbAV=`${SCRIPT_PREFIX}: Filtered [hide - fallback] (${sR})`;n.setAttribute('data-racf-filter-reason',fbAV);n.title=fbAV;}}
        replaceCommentText(cN,rs){const bS=this.selectors.commentBodySelector; if(!bS){this.log("No commentBodySelector");return;} const cB=cN.querySelector(bS); if(!cB){this.debugLog("Comment body not found:",bS,"on:",cN);return;} if(!this.originalContentCache.has(cB)){const cH=cB.innerHTML; if(!cH.includes(`[${SCRIPT_PREFIX}: Text Filtered`)){this.originalContentCache.set(cB,cH);this.debugLog("Stored original:",cB);}else{this.debugLog("Skip cache store (placeholder found).",cB);}} const pH=`<p>[${SCRIPT_PREFIX}: Text Filtered (${rs})]</p>`; if(cB.innerHTML!==pH){cB.innerHTML=pH;cN.classList.add(`${SCRIPT_PREFIX}-text-replaced`);this.debugLog("Replaced text:",cN);}else{this.debugLog("Text already replaced.",cN);}}
        unfilterNode(n){let wM=false; FILTER_ACTIONS.forEach(ac=>{if(ac!=='replace_text'){const clN=`${SCRIPT_PREFIX}-${ac}`;if(n.classList.contains(clN)){n.classList.remove(clN);wM=true;}}}); const tRM=`${SCRIPT_PREFIX}-text-replaced`; if(n.classList.contains(tRM)){n.classList.remove(tRM);wM=true; const bS=this.selectors.commentBodySelector; const cB=bS?n.querySelector(bS):null; if(cB&&this.originalContentCache.has(cB)){const oH=this.originalContentCache.get(cB); if(cB.innerHTML.includes(`[${SCRIPT_PREFIX}: Text Filtered`)){cB.innerHTML=oH;this.debugLog("Restored text:",n);}else{this.debugLog("Skip restore (not placeholder).",cB);} this.originalContentCache.delete(cB);}else if(cB){this.debugLog("Cannot restore text (no cache?).",n); if(cB.innerHTML.includes(`[${SCRIPT_PREFIX}: Text Filtered`)){cB.innerHTML=`<!-- [${SCRIPT_PREFIX}] Restore failed -->`;}}} if(n.hasAttribute('data-racf-filter-reason')){n.removeAttribute('data-racf-filter-reason');wM=true;} if(n.title?.startsWith(SCRIPT_PREFIX+':')){n.removeAttribute('title');wM=true;} if(wM){this.debugLog("Unfiltered node:",n);}}

        // --- Other Methods (Scroll, Export, Import, Menu, Toggle, Save&Apply) ---
        // (No changes needed in these)
        addScrollListener() { let sT=null; const hS=()=>{if(sT!==null){window.clearTimeout(sT);} if(performance.now()-this.lastFilterTime<DEBOUNCE_DELAY_MS/2){return;} sT=setTimeout(()=>{window.requestAnimationFrame(()=>{this.debugLog("Scroll end, filtering..."); this.applyFilters(document.body);}); sT=null;},DEBOUNCE_DELAY_MS);}; window.addEventListener('scroll',hS,{passive:true}); this.log("Scroll listener added."); }
        exportConfig() { try{const cTE={...DEFAULT_CONFIG,...this.config,rules:this.config.rules||[],filterTypes:this.config.filterTypes||[],filterAction:FILTER_ACTIONS.includes(this.config.filterAction)?this.config.filterAction:DEFAULT_CONFIG.filterAction,whitelist:{...DEFAULT_CONFIG.whitelist,...(this.config.whitelist||{})},blacklist:{...DEFAULT_CONFIG.blacklist,...(this.config.blacklist||{})},uiPosition:{...DEFAULT_CONFIG.uiPosition,...(this.config.uiPosition||{})},uiVisible:typeof this.config.uiVisible==='boolean'?this.config.uiVisible:DEFAULT_CONFIG.uiVisible,activeTab:typeof this.config.activeTab==='string'?this.config.activeTab:DEFAULT_CONFIG.activeTab,}; const cS=JSON.stringify(cTE,null,2); const blob=new Blob([cS],{type:'application/json;charset=utf-8'}); const url=URL.createObjectURL(blob); const link=document.createElement('a'); link.setAttribute('href',url); const ts=new Date().toISOString().replace(/[:.]/g,'-'); link.setAttribute('download',`reddit-filter-config-${ts}.json`); link.style.display='none'; document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); this.log("Config exported.");}catch(e){this.log(`Export error: ${e.message}`);alert(`Export failed: ${e.message}`);console.error("Export Err:",e);} }
        async importConfig(event) { const fI=event.target; const f=fI?.files?.[0]; if(!f){this.log("Import cancelled");return;} if(!f.type||!f.type.match('application/json')){alert('Bad file type');if(fI)fI.value=null;return;} const r=new FileReader(); r.onload=async(e)=>{const c=e.target?.result; if(!c){alert("Empty file");return;} try{const iC=JSON.parse(c); if(typeof iC!=='object'||iC===null){throw new Error("Bad JSON format");} const nC={...DEFAULT_CONFIG,...iC,rules:Array.isArray(iC.rules)?iC.rules:[],filterTypes:Array.isArray(iC.filterTypes)?iC.filterTypes.filter(t=>['posts','comments','messages'].includes(t)):DEFAULT_CONFIG.filterTypes,filterAction:FILTER_ACTIONS.includes(iC.filterAction)?iC.filterAction:DEFAULT_CONFIG.filterAction,whitelist:{subreddits:Array.isArray(iC.whitelist?.subreddits)?iC.whitelist.subreddits.map(s=>String(s).toLowerCase()):[],users:Array.isArray(iC.whitelist?.users)?iC.whitelist.users.map(u=>String(u).toLowerCase()):[]},blacklist:{subreddits:Array.isArray(iC.blacklist?.subreddits)?iC.blacklist.subreddits.map(s=>String(s).toLowerCase()):[],users:Array.isArray(iC.blacklist?.users)?iC.blacklist.users.map(u=>String(u).toLowerCase()):[]},uiPosition:{...DEFAULT_CONFIG.uiPosition,...(typeof iC.uiPosition==='object'?iC.uiPosition:{})},uiVisible:typeof iC.uiVisible==='boolean'?iC.uiVisible:DEFAULT_CONFIG.uiVisible,activeTab:typeof iC.activeTab==='string'?iC.activeTab:DEFAULT_CONFIG.activeTab}; nC.rules=nC.rules.filter(rl=>rl&&typeof rl==='object'&&RULE_TYPES.includes(rl.type)&&typeof rl.text==='string'&&rl.text.trim()!==''&&typeof rl.isRegex==='boolean'&&typeof rl.caseSensitive==='boolean'&&typeof rl.normalize==='boolean'&&(typeof rl.target==='string'&&['title','body','both'].includes(rl.target))).map(rl=>{if(rl.type==='user'||rl.type==='subreddit'){rl.text=rl.text.toLowerCase().replace(/^(u\/|r\/)/i,'');rl.caseSensitive=false;rl.normalize=false;rl.isRegex=false;} if(rl.normalize&&rl.type==='keyword'&&!rl.isRegex){rl.caseSensitive=false;} return rl;}); this.config=nC; if(this.uiContainer&&this.config.uiPosition){const p=this.config.uiPosition;this.uiContainer.style.top=p.top||DEFAULT_CONFIG.uiPosition.top; if(p.left!==null&&p.left!==undefined){this.uiContainer.style.left=p.left;this.uiContainer.style.right='auto';}else{this.uiContainer.style.left='auto';this.uiContainer.style.right=p.right||DEFAULT_CONFIG.uiPosition.right;} if(p.width&&p.width!=='auto')this.uiContainer.style.width=p.width; if(p.height&&p.height!=='auto')this.uiContainer.style.height=p.height; this.uiContainer.style.display=this.config.uiVisible?'block':'none';} this.log(`Config imported. ${nC.rules.length} rules.`); await this.saveConfig(); this.updateUI(); this.processedNodes=new WeakSet(); this.originalContentCache=new WeakMap(); this.applyFilters(document.body); alert('Config imported!');}catch(err){alert(`Import error: ${err.message}`);this.log(`Import error: ${err.message}`);console.error("Import Err:",err);}finally{if(fI)fI.value=null;}}; r.onerror=(e)=>{alert(`File read error: ${e.target?.error||'?'}`);this.log(`File read error: ${e.target?.error}`);if(fI)fI.value=null;}; r.readAsText(f); }
        registerMenuCommands() { GM_registerMenuCommand('Toggle Filter Panel',()=>this.toggleUIVisibility()); GM_registerMenuCommand('Re-apply All Filters',()=>{this.log(`Manual re-filter.`);this.processedNodes=new WeakSet();this.originalContentCache=new WeakMap();this.applyFilters(document.body);}); GM_registerMenuCommand('Reset Filter Statistics',()=>this.resetStats()); }
        toggleUIVisibility(forceState=null) { const sBV=forceState!==null?forceState:!this.config.uiVisible; if(sBV!==this.config.uiVisible){this.config.uiVisible=sBV; if(this.uiContainer){this.uiContainer.style.display=this.config.uiVisible?'block':'none';} this.saveConfig(); if(this.config.uiVisible){this.updateUI();} const oB=document.getElementById(`${SCRIPT_PREFIX}-options-btn`); if(oB){oB.textContent=this.config.uiVisible?'Ocultar RCF':'Mostrar RCF';oB.title=this.config.uiVisible?'Ocultar Panel':'Mostrar Panel';}}}
        async saveConfigAndApplyFilters() { await this.saveConfig(); if(this.filterApplyDebounceTimer)clearTimeout(this.filterApplyDebounceTimer); this.filterApplyDebounceTimer=setTimeout(()=>{this.log(`Config change, re-filtering...`);this.processedNodes=new WeakSet();this.originalContentCache=new WeakMap();this.applyFilters(document.body);this.filterApplyDebounceTimer=null;},150); }

    } // --- End RedditFilter Class ---

    // --- Options Button ---
    function addOptionsButton() {
        // (No changes needed)
         const buttonId=`${SCRIPT_PREFIX}-options-btn`; if(document.getElementById(buttonId))return; const btn=document.createElement('button'); btn.id=buttonId; btn.style.cssText=`position:fixed;bottom:15px;right:15px;z-index:10000;padding:8px 16px;background-color:#0079D3;color:white;border:1px solid #006abd;border-radius:20px;cursor:pointer;font-weight:bold;font-size:13px;box-shadow:0 4px 8px rgba(0,0,0,0.2);transition:background-color .2s ease,box-shadow .2s ease,transform .1s ease;font-family:inherit;line-height:1.5;`; btn.onmouseover=()=>{btn.style.backgroundColor='#005fa3';btn.style.boxShadow='0 6px 12px rgba(0,0,0,0.3)';}; btn.onmouseout=()=>{btn.style.backgroundColor='#0079D3';btn.style.boxShadow='0 4px 8px rgba(0,0,0,0.2)';btn.style.transform='scale(1)';}; btn.onmousedown=()=>{btn.style.transform='scale(0.97)';}; btn.onmouseup=()=>{btn.style.transform='scale(1)';}; const instance=window.redditAdvancedFilterInstance_1_7; btn.textContent=(instance&&instance.config.uiVisible)?'Ocultar RCF':'Mostrar RCF'; btn.title=(instance&&instance.config.uiVisible)?'Ocultar Panel':'Mostrar Panel'; btn.addEventListener('click',()=>{const currentInstance=window.redditAdvancedFilterInstance_1_7; if(currentInstance){currentInstance.toggleUIVisibility();}else{console.warn(`[${SCRIPT_PREFIX}] Instance not found.`);}}); document.body.appendChild(btn);
    }

    // --- Script Init ---
    function runScript() {
        // (No changes needed)
         const instanceName='redditAdvancedFilterInstance_1_7'; if(window[instanceName]){const v=window[instanceName].constructor?.version||GM_info?.script?.version||'?'; GM_log(`[${SCRIPT_PREFIX}] Instance running (v${v}). Skipping init.`); if(!document.getElementById(`${SCRIPT_PREFIX}-options-btn`)){addOptionsButton(); const btn=document.getElementById(`${SCRIPT_PREFIX}-options-btn`); if(btn){const i=window[instanceName]; if(i){btn.textContent=i.config.uiVisible?'Ocultar RCF':'Mostrar RCF'; btn.title=i.config.uiVisible?'Ocultar Panel':'Mostrar Panel';}}} return;} window[instanceName]=new RedditFilter(); window[instanceName].init().then(()=>{addOptionsButton();}).catch(error=>{GM_log(`[${SCRIPT_PREFIX}] Init Error: ${error.message}`); console.error(`[${SCRIPT_PREFIX}] Init failed:`,error); delete window[instanceName];});
    }

    if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', runScript);
    } else {
        runScript();
    }

})();

QingJ © 2025

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