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.

  1. // ==UserScript==
  2. // @name Reddit Advanced Content Filter v2.1.1
  3. // @namespace reddit-filter
  4. // @version 2.1.1
  5. // @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.
  6. // @author dani71153 (Modified by Assistant)
  7. // @match https://www.reddit.com/*
  8. // @match https://old.reddit.com/*
  9. // @grant GM_setValue
  10. // @grant GM_getValue
  11. // @grant GM_registerMenuCommand
  12. // @grant GM_log
  13. // @require https://cdn.jsdelivr.net/npm/dompurify@2.4.4/dist/purify.min.js
  14. // @license MIT
  15. // ==/UserScript==
  16.  
  17. /* global DOMPurify, GM_setValue, GM_getValue, GM_registerMenuCommand, GM_log, GM_info */
  18.  
  19. (function() {
  20. 'use strict';
  21.  
  22. // --- Constants ---
  23. const SCRIPT_PREFIX = 'RACF';
  24. const DEBOUNCE_DELAY_MS = 250;
  25. const STATS_SAVE_DEBOUNCE_MS = 2000;
  26. const CONFIG_STORAGE_KEY = 'config_v1.7';
  27. const STATS_STORAGE_KEY = 'stats_v1';
  28. const RULE_TYPES = ['keyword', 'user', 'subreddit'];
  29. const FILTER_ACTIONS = ['hide', 'blur', 'border', 'collapse', 'replace_text'];
  30. const DEBUG_LOGGING = false; // Set to true for detailed console logs
  31.  
  32. // --- Default Structures ---
  33. const DEFAULT_CONFIG = {
  34. rules: [],
  35. filterTypes: ['posts', 'comments'], filterAction: 'hide',
  36. whitelist: { subreddits: [], users: [] },
  37. blacklist: { subreddits: [], users: [] },
  38. uiVisible: true, activeTab: 'settings',
  39. uiPosition: {
  40. top: '100px',
  41. left: null,
  42. right: '20px',
  43. width: null,
  44. height: null
  45. }
  46. };
  47. const DEFAULT_STATS = {
  48. totalProcessed: 0, totalFiltered: 0, totalWhitelisted: 0,
  49. filteredByType: { posts: 0, comments: 0, messages: 0 },
  50. filteredByRule: {},
  51. filteredByAction: { hide: 0, blur: 0, border: 0, collapse: 0, replace_text: 0 }
  52. };
  53.  
  54. if (!window.MutationObserver) { GM_log(`[${SCRIPT_PREFIX}] MutationObserver not supported.`); }
  55.  
  56. class RedditFilter {
  57. constructor() {
  58. this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
  59. this.stats = JSON.parse(JSON.stringify(DEFAULT_STATS));
  60. this.processedNodes = new WeakSet();
  61. this.selectors = {};
  62. this.isOldReddit = false;
  63. this.observer = null;
  64. this.uiContainer = null;
  65. this.shadowRoot = null;
  66. this.scrollTimer = null;
  67. this.lastFilterTime = 0;
  68. this.filterApplyDebounceTimer = null;
  69. this.statsSaveDebounceTimer = null;
  70. this.uiUpdateDebounceTimer = null;
  71. this.isDragging = false;
  72. this.dragStartX = 0;
  73. this.dragStartY = 0;
  74. this.dragInitialLeft = 0;
  75. this.dragInitialTop = 0;
  76. this.domPurify = (typeof DOMPurify === 'undefined') ? { sanitize: (t) => t } : DOMPurify;
  77. this.originalContentCache = new WeakMap();
  78. }
  79.  
  80. log(message) {
  81. GM_log(`[${SCRIPT_PREFIX}] ${message}`);
  82. }
  83.  
  84. debugLog(message, ...args) {
  85. if (DEBUG_LOGGING) {
  86. console.log(`[${SCRIPT_PREFIX} DEBUG] ${message}`, ...args);
  87. }
  88. }
  89.  
  90. async init() {
  91. this.log(`Initializing v${GM_info?.script?.version || '2.1.1'}...`);
  92. try {
  93. await this.loadConfig();
  94. await this.loadStats();
  95. this.detectRedditVersion();
  96. this.injectUI();
  97. this.updateUI();
  98. this.registerMenuCommands();
  99. this.initializeObserver();
  100. this.addScrollListener();
  101. setTimeout(() => this.applyFilters(document.body), 500);
  102. this.log(`Initialization complete.`);
  103. } catch (error) {
  104. this.log(`Init failed: ${error.message}`);
  105. console.error(`[${SCRIPT_PREFIX}] Init failed:`, error);
  106. }
  107. }
  108.  
  109. async loadConfig() {
  110. try {
  111. const savedConfigString = await GM_getValue(CONFIG_STORAGE_KEY, null);
  112. if (savedConfigString) {
  113. const parsedConfig = JSON.parse(savedConfigString);
  114. this.config = {
  115. ...DEFAULT_CONFIG,
  116. ...parsedConfig,
  117. whitelist: { ...DEFAULT_CONFIG.whitelist, ...(parsedConfig.whitelist || {}) },
  118. blacklist: { ...DEFAULT_CONFIG.blacklist, ...(parsedConfig.blacklist || {}) },
  119. uiPosition: { ...DEFAULT_CONFIG.uiPosition, ...(parsedConfig.uiPosition || {}) },
  120. rules: Array.isArray(parsedConfig.rules) ? parsedConfig.rules : []
  121. };
  122. if (!FILTER_ACTIONS.includes(this.config.filterAction)) {
  123. this.log(`Invalid filterAction '${this.config.filterAction}', defaulting to '${DEFAULT_CONFIG.filterAction}'.`);
  124. this.config.filterAction = DEFAULT_CONFIG.filterAction;
  125. }
  126. this.log(`Config loaded.`);
  127. } else {
  128. this.log(`No saved config found. Using defaults.`);
  129. this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
  130. }
  131. } catch (e) {
  132. this.log(`Failed to load config: ${e.message}. Using defaults.`);
  133. this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
  134. await this.saveConfig();
  135. }
  136. }
  137.  
  138. async saveConfig() {
  139. try {
  140. if (!this.config) this.config = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
  141. if (!Array.isArray(this.config.rules)) this.config.rules = [];
  142. if (!this.config.whitelist) this.config.whitelist = { subreddits: [], users: [] };
  143. if (!this.config.blacklist) this.config.blacklist = { subreddits: [], users: [] };
  144. this.config.uiPosition = {
  145. ...DEFAULT_CONFIG.uiPosition,
  146. ...(this.config.uiPosition || {})
  147. };
  148. if (!FILTER_ACTIONS.includes(this.config.filterAction)) this.config.filterAction = DEFAULT_CONFIG.filterAction;
  149.  
  150. await GM_setValue(CONFIG_STORAGE_KEY, JSON.stringify(this.config));
  151. this.debugLog("Config saved:", this.config);
  152. } catch (e) {
  153. this.log(`Failed to save config: ${e.message}`);
  154. console.error(`[${SCRIPT_PREFIX}] Failed save config:`, e);
  155. }
  156. }
  157.  
  158. async loadStats() {
  159. try {
  160. const savedStatsString = await GM_getValue(STATS_STORAGE_KEY, null);
  161. if (savedStatsString) {
  162. const parsedStats = JSON.parse(savedStatsString);
  163. const defaultActions = DEFAULT_STATS.filteredByAction;
  164. const loadedActions = parsedStats.filteredByAction || {};
  165. const mergedActions = { ...defaultActions };
  166. for (const action in loadedActions) {
  167. if (FILTER_ACTIONS.includes(action)) {
  168. mergedActions[action] = loadedActions[action];
  169. }
  170. }
  171. this.stats = {
  172. ...DEFAULT_STATS,
  173. ...parsedStats,
  174. filteredByType: { ...DEFAULT_STATS.filteredByType, ...(parsedStats.filteredByType || {}) },
  175. filteredByRule: { ...DEFAULT_STATS.filteredByRule, ...(parsedStats.filteredByRule || {}) },
  176. filteredByAction: mergedActions
  177. };
  178. } else {
  179. this.log(`No saved stats found. Using defaults.`);
  180. this.stats = JSON.parse(JSON.stringify(DEFAULT_STATS));
  181. }
  182. } catch (e) {
  183. this.log(`Failed to load stats: ${e.message}. Resetting.`);
  184. this.stats = JSON.parse(JSON.stringify(DEFAULT_STATS));
  185. await this.saveStats();
  186. }
  187. }
  188.  
  189. async saveStats() {
  190. try {
  191. await GM_setValue(STATS_STORAGE_KEY, JSON.stringify(this.stats));
  192. } catch (e) {
  193. this.log(`Failed to save stats: ${e.message}`);
  194. }
  195. }
  196.  
  197. debouncedSaveStats() {
  198. if (this.statsSaveDebounceTimer) clearTimeout(this.statsSaveDebounceTimer);
  199. this.statsSaveDebounceTimer = setTimeout(async () => {
  200. await this.saveStats();
  201. this.statsSaveDebounceTimer = null;
  202. }, STATS_SAVE_DEBOUNCE_MS);
  203. }
  204.  
  205. async resetStats() {
  206. if (confirm("Reset all filter statistics? This cannot be undone.")) {
  207. this.stats = JSON.parse(JSON.stringify(DEFAULT_STATS));
  208. await this.saveStats();
  209. this.updateUI();
  210. this.log(`Stats reset.`);
  211. }
  212. }
  213.  
  214. detectRedditVersion() {
  215. // (No changes needed)
  216. const isOldDomain = window.location.hostname === 'old.reddit.com';
  217. const hasOldBodyClass = document.body.classList.contains('listing-page') || document.body.classList.contains('comments-page');
  218. if (isOldDomain || hasOldBodyClass) {
  219. this.isOldReddit = true;
  220. this.selectors = { /* ... old reddit selectors ... */
  221. post: '.thing.link:not(.promoted)',
  222. comment: '.thing.comment',
  223. postSubredditSelector: '.tagline .subreddit',
  224. postAuthorSelector: '.tagline .author',
  225. commentAuthorSelector: '.tagline .author',
  226. postTitleSelector: 'a.title',
  227. postBodySelector: '.usertext-body .md, .expando .usertext-body .md',
  228. commentBodySelector: '.usertext-body .md',
  229. commentEntry: '.entry',
  230. commentContentContainer: '.child'
  231. };
  232. this.log(`Old Reddit detected.`);
  233. } else {
  234. this.isOldReddit = false;
  235. this.selectors = { /* ... new reddit selectors ... */
  236. post: 'shreddit-post',
  237. comment: 'shreddit-comment',
  238. postSubredditSelector: '[slot="subreddit-name"]',
  239. postAuthorSelector: '[slot="author-name"]',
  240. commentAuthorSelector: '[slot="author-name"]',
  241. postTitleSelector: '[slot="title"]',
  242. postBodySelector: '#post-rtjson-content, [data-post-click-location="text-body"], [slot="text-body"]',
  243. commentBodySelector: 'div[slot="comment"]',
  244. commentEntry: ':host',
  245. commentContentContainer: '[slot="comment"]'
  246. };
  247. this.log(`New Reddit detected.`);
  248. }
  249. this.selectors.message = '.message';
  250. }
  251.  
  252. injectUI() {
  253. if (this.uiContainer) return;
  254.  
  255. this.uiContainer = document.createElement('div');
  256. this.uiContainer.id = `${SCRIPT_PREFIX}-ui-container`;
  257.  
  258. const pos = this.config.uiPosition;
  259. let initialPositionStyle = `position: fixed; z-index: 9999; `;
  260. initialPositionStyle += `top: ${pos.top || DEFAULT_CONFIG.uiPosition.top}; `;
  261. if (pos.left !== null && pos.left !== undefined) {
  262. initialPositionStyle += `left: ${pos.left}; right: auto; `;
  263. } else {
  264. initialPositionStyle += `left: auto; right: ${pos.right || DEFAULT_CONFIG.uiPosition.right}; `;
  265. }
  266. initialPositionStyle += `resize: both; overflow: auto; min-width: 380px; min-height: 200px; `;
  267.  
  268. this.uiContainer.style.cssText = initialPositionStyle;
  269. if (pos.width && pos.width !== 'auto') this.uiContainer.style.width = pos.width;
  270. if (pos.height && pos.height !== 'auto') this.uiContainer.style.height = pos.height;
  271. this.uiContainer.style.display = this.config.uiVisible ? 'block' : 'none';
  272.  
  273. this.shadowRoot = this.uiContainer.attachShadow({ mode: 'open' });
  274.  
  275. // --- UI HTML (unchanged) ---
  276. const uiContent = document.createElement('div');
  277. uiContent.innerHTML = `
  278. <div class="racf-card">
  279. <div class="racf-tabs" id="racf-drag-handle">
  280. <button class="racf-tab-btn active" data-tab="settings">Settings</button>
  281. <button class="racf-tab-btn" data-tab="stats">Statistics</button>
  282. </div>
  283. <button id="racf-close-btn" class="racf-close-btn" title="Close Panel">×</button>
  284. <div id="racf-settings-content" class="racf-tab-content active">
  285. <h4>Filter Settings</h4>
  286. <div class="racf-add-rule-section">
  287. <div class="racf-input-group">
  288. <label for="racf-rule-input">Rule Text:</label>
  289. <input type="text" id="racf-rule-input" placeholder="Keyword, /regex/, user, subreddit">
  290. </div>
  291. <div class="racf-input-group">
  292. <label for="racf-rule-type">Rule Type:</label>
  293. <select id="racf-rule-type">
  294. <option value="keyword" selected>Keyword/Regex</option>
  295. <option value="user">User</option>
  296. <option value="subreddit">Subreddit</option>
  297. </select>
  298. </div>
  299. <div class="racf-input-group">
  300. <label for="racf-rule-target">Apply In:</label>
  301. <select id="racf-rule-target">
  302. <option value="both" selected>Title & Body</option>
  303. <option value="title">Title Only</option>
  304. <option value="body">Body Only</option>
  305. </select>
  306. </div>
  307. <div class="racf-input-group racf-checkbox-group">
  308. <label for="racf-rule-normalize">Normalize:</label>
  309. <input type="checkbox" id="racf-rule-normalize" title="Ignore accents/case (Keywords only, not Regex)">
  310. <small>(Keywords only)</small>
  311. </div>
  312. <button id="racf-add-rule-btn">Add Rule</button>
  313. </div>
  314. <div class="racf-section">
  315. <label>Filter Types:</label>
  316. <label><input type="checkbox" class="racf-filter-type" value="posts"> Posts</label>
  317. <label><input type="checkbox" class="racf-filter-type" value="comments"> Comments</label>
  318. </div>
  319. <div class="racf-section">
  320. <label for="racf-filter-action">Filter Action:</label>
  321. <select id="racf-filter-action"></select>
  322. </div>
  323. <div class="racf-section">
  324. <label>Active Rules (<span id="racf-rule-count">0</span>):</label>
  325. <ul id="racf-rule-list"></ul>
  326. </div>
  327. <div class="racf-section">
  328. <small>Global Whitelists/Blacklists are managed via JSON Import/Export.</small>
  329. </div>
  330. <div class="racf-section racf-buttons">
  331. <button id="racf-import-btn">Import (.json)</button>
  332. <button id="racf-export-btn">Export (.json)</button>
  333. <input type="file" id="racf-import-file-input" accept=".json" style="display: none;">
  334. </div>
  335. <div class="racf-section racf-buttons">
  336. <button id="racf-clear-processed-btn">Clear Processed Cache</button>
  337. </div>
  338. </div>
  339. <div id="racf-stats-content" class="racf-tab-content">
  340. <h4>Filter Statistics</h4>
  341. <div class="racf-stats-grid">
  342. <div>Total Processed:</div><div id="racf-stats-processed">0</div>
  343. <div>Total Filtered:</div><div id="racf-stats-filtered">0</div>
  344. <div>Filtering Rate:</div><div id="racf-stats-rate">0%</div>
  345. <div>Total Whitelisted:</div><div id="racf-stats-whitelisted">0</div>
  346. <div>Filtered Posts:</div><div id="racf-stats-type-posts">0</div>
  347. <div>Filtered Comments:</div><div id="racf-stats-type-comments">0</div>
  348. <div>Action - Hide:</div><div id="racf-stats-action-hide">0</div>
  349. <div>Action - Blur:</div><div id="racf-stats-action-blur">0</div>
  350. <div>Action - Border:</div><div id="racf-stats-action-border">0</div>
  351. <div>Action - Collapse:</div><div id="racf-stats-action-collapse">0</div>
  352. <div>Action - Replace Text:</div><div id="racf-stats-action-replace_text">0</div>
  353. </div>
  354. <div class="racf-section">
  355. <label>Most Active Rules:</label>
  356. <ul id="racf-stats-rule-list"><li>No rules active yet.</li></ul>
  357. </div>
  358. <div class="racf-section racf-buttons">
  359. <button id="racf-reset-stats-btn">Reset Statistics</button>
  360. </div>
  361. </div>
  362. </div>`;
  363.  
  364. // --- CSS Styles (Keep cursor: move only on tabs for visual cue) ---
  365. const styles = document.createElement('style');
  366. styles.textContent = `
  367. :host { font-family: sans-serif; font-size: 14px; }
  368. .racf-card {
  369. background-color: #f9f9f9; border: 1px solid #ccc; border-radius: 5px; padding: 0;
  370. box-shadow: 0 2px 5px rgba(0,0,0,.2); position: relative; color: #333;
  371. height: 100%; display: flex; flex-direction: column;
  372. }
  373. /* Keep cursor: move only on the explicit tabs bar for clarity */
  374. .racf-tabs { display: flex; border-bottom: 1px solid #ccc; cursor: move; user-select: none; flex-shrink: 0; }
  375. .racf-tab-btn {
  376. flex: 1; padding: 10px 15px; background: #e9ecef; border: none;
  377. border-right: 1px solid #dee2e6; cursor: pointer; font-size: 14px; color: #495057;
  378. transition: background-color 0.2s, color 0.2s, border-color 0.2s;
  379. }
  380. .racf-tab-btn:last-child { border-right: none; }
  381. .racf-tab-btn:hover { background: #d3d9df; color: #212529; }
  382. .racf-tab-btn.active {
  383. background: #f9f9f9; color: #0056b3; border-bottom: 1px solid #f9f9f9;
  384. border-top: 3px solid #007bff; margin-bottom: -1px; font-weight: 700;
  385. }
  386. .racf-tab-content { display: none; padding: 15px; flex-grow: 1; overflow-y: auto; }
  387. .racf-tab-content.active { display: block; }
  388. .racf-card h4 { margin-top: 0; margin-bottom: 15px; border-bottom: 1px solid #eee; padding-bottom: 10px; color: #0056b3; }
  389. .racf-section { margin-bottom: 15px; }
  390. .racf-section small { font-weight: normal; font-style: italic; color: #555; font-size: 0.9em; }
  391. .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; }
  392. .racf-input-group { display: flex; flex-direction: column; gap: 3px; }
  393. .racf-input-group label { font-size: .9em; font-weight: 700; color: #495057; }
  394. .racf-checkbox-group { flex-direction: row; align-items: center; gap: 5px; margin-top: auto; }
  395. .racf-checkbox-group label { margin-bottom: 0; }
  396. .racf-checkbox-group small { margin-left: 0; }
  397. 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; }
  398. input[type=text]:focus, select:focus { border-color: #80bdff; outline: 0; box-shadow: 0 0 0 0.2rem rgba(0,123,255,.25); }
  399. .racf-section input[type=checkbox] { margin-right: 3px; vertical-align: middle; }
  400. .racf-section label+label { margin-left: 10px; font-weight: 400; }
  401. 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; }
  402. button:hover { background-color: #e9ecef; border-color: #a1a8af; color: #212529; }
  403. button:active { background-color: #dee2e6; border-color: #939ba1; }
  404. #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; }
  405. #racf-add-rule-btn:hover { background-color: #0056b3; border-color: #0056b3; }
  406. #racf-import-btn, #racf-export-btn { background-color: #28a745; color: #fff; border-color: #28a745; }
  407. #racf-import-btn:hover, #racf-export-btn:hover { background-color: #218838; border-color: #1e7e34; }
  408. #racf-clear-processed-btn { background-color: #6c757d; color: #fff; border-color: #6c757d; }
  409. #racf-clear-processed-btn:hover { background-color: #5a6268; border-color: #545b62; }
  410. #racf-reset-stats-btn { background-color: #dc3545; color: #fff; border-color: #dc3545; }
  411. #racf-reset-stats-btn:hover { background-color: #c82333; border-color: #bd2130; }
  412. #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; }
  413. #racf-rule-list button.racf-remove-btn:hover { background-color: #c82333; border-color: #bd2130; }
  414. #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; }
  415. #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; }
  416. #racf-rule-list li:last-child, #racf-stats-rule-list li:last-child { border-bottom: none; }
  417. #racf-rule-list .racf-rule-details { flex-grow: 1; margin-right: 10px; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
  418. #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; }
  419. #racf-rule-list .racf-rule-text { word-break: break-all; font-family: monospace; background: #e9ecef; padding: 1px 3px; border-radius: 2px; color: #212529;}
  420. #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;}
  421. #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;}
  422. .racf-buttons { margin-top: 15px; border-top: 1px solid #eee; padding-top: 10px; display: flex; gap: 10px; flex-wrap: wrap; flex-shrink: 0; }
  423. .racf-buttons button { flex-grow: 1; margin: 0; }
  424. .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; }
  425. .racf-close-btn:hover { color: #343a40; }
  426. .racf-stats-grid { display: grid; grid-template-columns: auto 1fr; gap: 5px 10px; margin-bottom: 15px; font-size: 13px; }
  427. .racf-stats-grid div:nth-child(odd) { font-weight: 700; text-align: right; color: #495057; }
  428. .racf-stats-grid div:nth-child(even) { font-family: monospace; color: #0056b3; }
  429. `;
  430.  
  431.  
  432. this.shadowRoot.appendChild(styles);
  433. this.shadowRoot.appendChild(uiContent);
  434. this.injectGlobalStyles();
  435. document.body.insertAdjacentElement('beforeend', this.uiContainer);
  436. this.addUIEventListeners();
  437. this.log(`UI injected with resize and wide drag area enabled.`);
  438. }
  439.  
  440. injectGlobalStyles() {
  441. // (No changes needed)
  442. const styleId = `${SCRIPT_PREFIX}-global-styles`;
  443. let globalStyleSheet = document.getElementById(styleId);
  444. if (!globalStyleSheet) {
  445. globalStyleSheet = document.createElement("style");
  446. globalStyleSheet.id = styleId;
  447. document.head.appendChild(globalStyleSheet);
  448. }
  449. const commentEntrySelector = this.selectors.commentEntry || '.comment';
  450. const commentContentContainerSelector = this.selectors.commentContentContainer || '.child';
  451. const commentTaglineSelector = this.isOldReddit ? '.entry > .tagline' : 'header';
  452. const commentFormSelector = this.isOldReddit ? '.entry > form' : 'shreddit-composer';
  453. globalStyleSheet.textContent = `
  454. .${SCRIPT_PREFIX}-hide { display: none !important; height: 0 !important; overflow: hidden !important; margin: 0 !important; padding: 0 !important; border: none !important; visibility: hidden !important; }
  455. .${SCRIPT_PREFIX}-blur { filter: blur(5px) !important; transition: filter 0.2s ease; cursor: pointer; }
  456. .${SCRIPT_PREFIX}-blur:hover { filter: none !important; }
  457. .${SCRIPT_PREFIX}-border { outline: 3px solid red !important; outline-offset: -1px; }
  458. .${SCRIPT_PREFIX}-collapse > ${commentContentContainerSelector},
  459. .${SCRIPT_PREFIX}-collapse ${commentFormSelector} { display: none !important; }
  460. .${SCRIPT_PREFIX}-collapse.thing.comment > .entry > .child,
  461. .${SCRIPT_PREFIX}-collapse.thing.comment > .entry > .usertext { display: none !important; }
  462. .${SCRIPT_PREFIX}-collapse > ${commentTaglineSelector} { opacity: 0.6 !important; }
  463. .${SCRIPT_PREFIX}-collapse > ${commentTaglineSelector}::after,
  464. .${SCRIPT_PREFIX}-collapse.thing.comment > .entry > .tagline::after {
  465. content: " [Filtered]"; font-style: italic; font-size: 0.9em; color: grey;
  466. margin-left: 5px; display: inline; vertical-align: baseline;
  467. }
  468. .${SCRIPT_PREFIX}-hide.thing.comment,
  469. .${SCRIPT_PREFIX}-hide.shreddit-comment { }
  470. .${SCRIPT_PREFIX}-text-replaced .usertext-body .md p,
  471. .${SCRIPT_PREFIX}-text-replaced div[slot="comment"] p {
  472. color: grey; font-style: italic; margin: 0; padding: 5px 0;
  473. }
  474. `;
  475. }
  476.  
  477. addUIEventListeners() {
  478. if (!this.shadowRoot) return;
  479. const q = (s) => this.shadowRoot.querySelector(s);
  480. const qa = (s) => this.shadowRoot.querySelectorAll(s);
  481.  
  482. // *** CHANGE HERE: Attach drag listener to the main card ***
  483. const cardElement = q('.racf-card');
  484. if (cardElement) {
  485. cardElement.addEventListener('mousedown', this.dragMouseDown.bind(this));
  486. this.debugLog("Drag listener attached to .racf-card");
  487. } else {
  488. this.log("Error: Card element (.racf-card) not found for attaching drag listener.");
  489. }
  490.  
  491. // --- Other listeners remain the same ---
  492.  
  493. // Tab switching (ensure stopPropagation to prevent drag)
  494. qa('.racf-tab-btn').forEach(btn => {
  495. btn.addEventListener('click', (e) => {
  496. e.stopPropagation(); // Prevent drag start when clicking tabs
  497. const tabId = e.target.dataset.tab;
  498. if (!tabId) return;
  499. qa('.racf-tab-btn').forEach(b => b.classList.remove('active'));
  500. qa('.racf-tab-content').forEach(c => c.classList.remove('active'));
  501. e.target.classList.add('active');
  502. const contentEl = q(`#racf-${tabId}-content`);
  503. if (contentEl) contentEl.classList.add('active');
  504. this.config.activeTab = tabId;
  505. if (tabId === 'stats') { this.updateUI(); }
  506. // Don't save config on tab switch
  507. });
  508. });
  509.  
  510. // Rule management
  511. q('#racf-add-rule-btn').addEventListener('click', () => this.handleAddRule());
  512. q('#racf-rule-input').addEventListener('keypress', (e) => { if (e.key === 'Enter') this.handleAddRule(); });
  513. q('#racf-rule-list').addEventListener('click', (e) => {
  514. // Prevent drag start when clicking inside the rule list (buttons handled below)
  515. e.stopPropagation();
  516. const removeButton = e.target.closest('button.racf-remove-btn');
  517. if (removeButton) {
  518. // stopPropagation() already prevents drag, button click proceeds
  519. const ruleIndex = parseInt(removeButton.dataset.ruleIndex, 10);
  520. if (!isNaN(ruleIndex)) { this.removeRuleByIndex(ruleIndex); }
  521. else { this.log(`Could not remove rule: Invalid index.`); }
  522. }
  523. });
  524.  
  525. // Filter type checkboxes
  526. qa('.racf-filter-type').forEach(cb => {
  527. cb.addEventListener('change', (e) => this.handleFilterTypeChange(e));
  528. });
  529.  
  530. // Filter action dropdown
  531. const filterActionSelect = q('#racf-filter-action');
  532. if (filterActionSelect) {
  533. filterActionSelect.addEventListener('change', (e) => {
  534. const newAction = e.target.value;
  535. if (FILTER_ACTIONS.includes(newAction)) {
  536. this.config.filterAction = newAction;
  537. this.saveConfigAndApplyFilters();
  538. } else {
  539. this.log(`Invalid filter action selected: ${newAction}`);
  540. e.target.value = this.config.filterAction;
  541. }
  542. });
  543. }
  544.  
  545. // Import/Export/Clear/Reset buttons
  546. q('#racf-export-btn').addEventListener('click', () => this.exportConfig());
  547. q('#racf-import-btn').addEventListener('click', () => { q('#racf-import-file-input')?.click(); });
  548. q('#racf-import-file-input')?.addEventListener('change', (e) => this.importConfig(e));
  549. q('#racf-clear-processed-btn').addEventListener('click', () => {
  550. this.processedNodes = new WeakSet();
  551. this.originalContentCache = new WeakMap();
  552. this.log(`Processed node cache and original content cache cleared.`);
  553. this.applyFilters(document.body);
  554. });
  555. q('#racf-reset-stats-btn').addEventListener('click', () => this.resetStats());
  556. q('#racf-close-btn').addEventListener('click', (e) => {
  557. e.stopPropagation(); // Prevent drag start when clicking close button
  558. this.toggleUIVisibility(false)
  559. });
  560.  
  561. // Resize end listener (mouseup on the container)
  562. if (this.uiContainer) {
  563. this.uiContainer.addEventListener('mouseup', () => {
  564. if (!this.isDragging) { // Only save dimensions if not dragging
  565. this.saveCurrentDimensions();
  566. }
  567. });
  568. }
  569. }
  570.  
  571. // --- Dragging Functions ---
  572. dragMouseDown(e) {
  573. // 1. Only react to left mouse button
  574. if (e.button !== 0) return;
  575.  
  576. // *** CHANGE HERE: Prevent drag if clicking on interactive elements ***
  577. const noDragElementsSelector = 'button, input, select, textarea, a, ul#racf-rule-list, ul#racf-stats-rule-list';
  578. const clickedElement = e.target;
  579.  
  580. if (clickedElement.closest(noDragElementsSelector)) {
  581. this.debugLog("Drag prevented: Clicked on an interactive element.", clickedElement);
  582. // Don't prevent default or stop propagation here, allow the click to proceed on the element
  583. return;
  584. }
  585. // Also prevent drag if clicking directly on scrollbars within the shadow DOM (experimental)
  586. if (e.offsetX > clickedElement.clientWidth || e.offsetY > clickedElement.clientHeight) {
  587. this.debugLog("Drag prevented: Click likely on scrollbar.");
  588. return;
  589. }
  590.  
  591. // 2. If click is not on an excluded element, proceed with drag initiation
  592. e.preventDefault(); // Prevent text selection during drag
  593. e.stopPropagation(); // Prevent triggering other listeners if needed
  594.  
  595. this.isDragging = true;
  596. this.dragStartX = e.clientX;
  597. this.dragStartY = e.clientY;
  598. const rect = this.uiContainer.getBoundingClientRect();
  599. this.dragInitialTop = rect.top;
  600. this.dragInitialLeft = rect.left;
  601.  
  602. this.elementDragBound = this.elementDrag.bind(this);
  603. this.closeDragElementBound = this.closeDragElement.bind(this);
  604. document.addEventListener('mousemove', this.elementDragBound);
  605. document.addEventListener('mouseup', this.closeDragElementBound);
  606.  
  607. // Optional visual feedback
  608. // this.uiContainer.style.cursor = 'grabbing'; // Might override internal cursors
  609. this.uiContainer.style.opacity = '0.9';
  610. this.uiContainer.style.userSelect = 'none'; // Prevent text selection
  611. }
  612.  
  613. elementDrag(e) {
  614. // (No changes needed)
  615. if (!this.isDragging) return;
  616. e.preventDefault();
  617. const deltaX = e.clientX - this.dragStartX;
  618. const deltaY = e.clientY - this.dragStartY;
  619. let newTop = this.dragInitialTop + deltaY;
  620. let newLeft = this.dragInitialLeft + deltaX;
  621. const containerRect = this.uiContainer.getBoundingClientRect();
  622. newTop = Math.max(0, Math.min(newTop, window.innerHeight - containerRect.height));
  623. newLeft = Math.max(0, Math.min(newLeft, window.innerWidth - containerRect.width));
  624. this.uiContainer.style.top = `${newTop}px`;
  625. this.uiContainer.style.left = `${newLeft}px`;
  626. this.uiContainer.style.right = 'auto';
  627. }
  628.  
  629. closeDragElement() {
  630. // (No changes needed)
  631. if (!this.isDragging) return;
  632. this.isDragging = false;
  633. document.removeEventListener('mousemove', this.elementDragBound);
  634. document.removeEventListener('mouseup', this.closeDragElementBound);
  635. // this.uiContainer.style.cursor = '';
  636. this.uiContainer.style.opacity = '1';
  637. this.uiContainer.style.userSelect = '';
  638. this.saveCurrentPositionAndDimensions(); // Save final state
  639. }
  640. // --- End Dragging Functions ---
  641.  
  642. saveCurrentPositionAndDimensions() {
  643. // (No changes needed)
  644. if (!this.uiContainer) return;
  645. const rect = this.uiContainer.getBoundingClientRect();
  646. this.config.uiPosition.top = `${rect.top}px`;
  647. this.config.uiPosition.left = `${rect.left}px`;
  648. this.config.uiPosition.right = null; // Always use left after interaction
  649. this.config.uiPosition.width = `${rect.width}px`;
  650. this.config.uiPosition.height = `${rect.height}px`;
  651. this.saveConfig();
  652. }
  653.  
  654. saveCurrentDimensions() {
  655. // (No changes needed)
  656. if (!this.uiContainer) return;
  657. const rect = this.uiContainer.getBoundingClientRect();
  658. let changed = false;
  659. const newWidth = `${rect.width}px`;
  660. const newHeight = `${rect.height}px`;
  661. if (this.config.uiPosition.width !== newWidth) {
  662. this.config.uiPosition.width = newWidth;
  663. changed = true;
  664. }
  665. if (this.config.uiPosition.height !== newHeight) {
  666. this.config.uiPosition.height = newHeight;
  667. changed = true;
  668. }
  669. if (changed) {
  670. this.debugLog(`Saving dimensions after resize: W=${newWidth}, H=${newHeight}`);
  671. this.saveConfig();
  672. }
  673. }
  674.  
  675. updateUI() {
  676. // (No changes needed)
  677. if (!this.shadowRoot || !this.uiContainer) return;
  678. this.uiContainer.style.display = this.config.uiVisible ? 'block' : 'none';
  679. const pos = this.config.uiPosition;
  680. this.uiContainer.style.top = pos.top || DEFAULT_CONFIG.uiPosition.top;
  681. if (pos.left !== null) {
  682. this.uiContainer.style.left = pos.left;
  683. this.uiContainer.style.right = 'auto';
  684. } else {
  685. this.uiContainer.style.left = 'auto';
  686. this.uiContainer.style.right = pos.right || DEFAULT_CONFIG.uiPosition.right;
  687. }
  688. if (pos.width && pos.width !== 'auto') this.uiContainer.style.width = pos.width;
  689. if (pos.height && pos.height !== 'auto') this.uiContainer.style.height = pos.height;
  690.  
  691. const q = (s) => this.shadowRoot.querySelector(s);
  692. const qa = (s) => this.shadowRoot.querySelectorAll(s);
  693. const ruleListEl = q('#racf-rule-list');
  694. if (ruleListEl) {
  695. ruleListEl.innerHTML = '';
  696. (this.config.rules || []).forEach((rule, index) => {
  697. const li = document.createElement('li');
  698. const safeText = this.domPurify.sanitize(rule.text || '', { USE_PROFILES: { html: false } });
  699. const typeTitle = `Type: ${rule.type}`; const regexTitle = rule.isRegex ? ' (Regex)' : '';
  700. const caseTitle = (rule.type === 'keyword' && !rule.isRegex) ? (rule.caseSensitive ? ' (Case Sensitive)' : ' (Case Insensitive)') : '';
  701. const targetTitle = `Applies to: ${rule.target || 'both'}`; const normTitle = rule.normalize ? ' (Normalized)' : '';
  702. 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>`;
  703. ruleListEl.appendChild(li);
  704. });
  705. const ruleCountEl = q('#racf-rule-count'); if (ruleCountEl) ruleCountEl.textContent = (this.config.rules || []).length;
  706. }
  707. qa('.racf-filter-type').forEach(cb => { cb.checked = (this.config.filterTypes || []).includes(cb.value); });
  708. const actionSelect = q('#racf-filter-action');
  709. if (actionSelect) {
  710. if (actionSelect.options.length === 0) {
  711. 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); });
  712. } actionSelect.value = this.config.filterAction;
  713. }
  714. const statsP = q('#racf-stats-processed'); if(statsP) statsP.textContent=this.stats.totalProcessed;
  715. const statsF = q('#racf-stats-filtered'); if(statsF) statsF.textContent=this.stats.totalFiltered;
  716. 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}%`;}
  717. const statsW = q('#racf-stats-whitelisted'); if(statsW) statsW.textContent=this.stats.totalWhitelisted;
  718. const statsTP = q('#racf-stats-type-posts'); if(statsTP) statsTP.textContent=this.stats.filteredByType?.posts||0;
  719. const statsTC = q('#racf-stats-type-comments'); if(statsTC) statsTC.textContent=this.stats.filteredByType?.comments||0;
  720. const statsAH = q('#racf-stats-action-hide'); if(statsAH) statsAH.textContent=this.stats.filteredByAction?.hide||0;
  721. const statsAB = q('#racf-stats-action-blur'); if(statsAB) statsAB.textContent=this.stats.filteredByAction?.blur||0;
  722. const statsAbo = q('#racf-stats-action-border'); if(statsAbo) statsAbo.textContent=this.stats.filteredByAction?.border||0;
  723. const statsAC = q('#racf-stats-action-collapse'); if(statsAC) statsAC.textContent=this.stats.filteredByAction?.collapse||0;
  724. const statsAR = q('#racf-stats-action-replace_text'); if(statsAR) statsAR.textContent=this.stats.filteredByAction?.replace_text||0;
  725. if (this.config.activeTab === 'stats') {
  726. const statsRuleListEl = q('#racf-stats-rule-list');
  727. if (statsRuleListEl) {
  728. statsRuleListEl.innerHTML = ''; const sortedRules = Object.entries(this.stats.filteredByRule || {}).filter(([, c]) => c > 0).sort(([, a], [, b]) => b - a);
  729. if (sortedRules.length === 0) { statsRuleListEl.innerHTML = '<li>No rules triggered yet.</li>'; }
  730. 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); }); }
  731. }
  732. }
  733. const activeTabId = this.config.activeTab || 'settings';
  734. qa('.racf-tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === activeTabId));
  735. qa('.racf-tab-content').forEach(c => c.classList.toggle('active', c.id === `racf-${activeTabId}-content`));
  736. }
  737.  
  738. // --- Filtering Logic (shouldFilterNode, extract*, filterNode, etc.) ---
  739. // (No changes needed in these core filtering functions)
  740. 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(); } }
  741. 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(); }
  742. 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(); }
  743. 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(); }
  744. 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."); }
  745. 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));} }
  746. 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.`);} }
  747. getEffectiveAction(cA,nT){if(nT!=='comments'){if(cA==='collapse'||cA==='replace_text'){return'hide';}} return cA;}
  748. 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;}
  749. 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;}
  750. 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;}}
  751. 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;}}
  752. 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;}}
  753. 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);}}
  754. 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);}}
  755.  
  756. // --- Other Methods (Scroll, Export, Import, Menu, Toggle, Save&Apply) ---
  757. // (No changes needed in these)
  758. 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."); }
  759. 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);} }
  760. 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); }
  761. 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()); }
  762. 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';}}}
  763. 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); }
  764.  
  765. } // --- End RedditFilter Class ---
  766.  
  767. // --- Options Button ---
  768. function addOptionsButton() {
  769. // (No changes needed)
  770. 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);
  771. }
  772.  
  773. // --- Script Init ---
  774. function runScript() {
  775. // (No changes needed)
  776. 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];});
  777. }
  778.  
  779. if (document.readyState === 'loading') {
  780. document.addEventListener('DOMContentLoaded', runScript);
  781. } else {
  782. runScript();
  783. }
  784.  
  785. })();