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