FreshRSS NG Filter

Mark as read and hide articles matching the rule in FreshRSS. Rules are described by regular expressions.

  1. // ==UserScript==
  2. // @name FreshRSS NG Filter
  3. // @namespace https://github.com/hiroki-miya
  4. // @version 1.0.4
  5. // @description Mark as read and hide articles matching the rule in FreshRSS. Rules are described by regular expressions.
  6. // @author hiroki-miya
  7. // @license MIT
  8. // @match https://freshrss.example.net/*
  9. // @grant GM_addStyle
  10. // @grant GM_getValue
  11. // @grant GM_registerMenuCommand
  12. // @grant GM_setValue
  13. // @run-at document-idle
  14. // ==/UserScript==
  15.  
  16. (function() {
  17. 'use strict';
  18.  
  19. // Language for sorting
  20. const sortLocale = 'ja';
  21.  
  22. // Retrieve saved filters
  23. let savedFilters = GM_getValue('filters', {});
  24.  
  25. // Define editingFilterName globally (the name of the filter currently being edited)
  26. let editingFilterName = null;
  27.  
  28. // Add styles
  29. GM_addStyle(`
  30. #freshrss-ng-filter {
  31. position: fixed;
  32. top: 50%;
  33. left: 50%;
  34. transform: translate(-50%, -50%);
  35. z-index: 10000;
  36. background-color: white;
  37. border: 1px solid black;
  38. padding: 10px;
  39. width: max-content;
  40. }
  41. #freshrss-ng-filter > h2 {
  42. box-shadow: inset 0 0 0 0.5px black;
  43. padding: 5px 10px;
  44. text-align: center;
  45. cursor: move;
  46. }
  47. #freshrss-ng-filter > h4 {
  48. margin-top: 0;
  49. }
  50. #filter-list {
  51. margin-bottom: 10px;
  52. max-height: 50vh;
  53. overflow-y: auto;
  54. }
  55. .filter-item {
  56. display: flex;
  57. justify-content: space-between;
  58. align-items: center;
  59. }
  60. #filter-edit > div {
  61. display: flex;
  62. justify-content: space-between;
  63. align-items: center;
  64. line-height: 2;
  65. margin-bottom: 5px;
  66. }
  67. #filter-edit > div input {
  68. line-height: 2;
  69. margin: 0;
  70. }
  71. .filter-name,
  72. #filter-edit > div > label {
  73. flex-grow: 1;
  74. margin-right: 10px;
  75. white-space: nowrap;
  76. overflow: hidden;
  77. text-overflow: ellipsis;
  78. }
  79. #filter-edit > div > div:has(input[type="checkbox"]) {
  80. margin-left: 5px;
  81. max-width: 90%;
  82. width: 300px;
  83. }
  84. #filter-edit > div input[type="checkbox"] {
  85. transform: scale(1.5);
  86. margin-left: 4px;
  87. }
  88. .edit-filter, .delete-filter,
  89. #filter-edit > div > input {
  90. margin-left: 5px;
  91. }
  92. .filter-info-label {
  93. display: inline;
  94. }
  95. .filter-info {
  96. display: inline-block;
  97. border-radius: 50%;
  98. width: 16px;
  99. height: 16px;
  100. min-height: 16px;
  101. line-height: 1.2;
  102. margin-left: 4px;
  103. position: relative;
  104. top: -6px;
  105. text-align: center;
  106. background-color: black;
  107. color: white;
  108. font-weight: 700;
  109. }
  110. `);
  111.  
  112. // Function to render the filter list
  113. function updateFilterList() {
  114. // Sort filters
  115. const filterNames = Object.keys(savedFilters).sort((a, b) => a.localeCompare(b, sortLocale));
  116. const filterList = filterNames.map(name => {
  117. const filter = savedFilters[name];
  118. const checked = filter.disabled ? 'checked' : '';
  119. return `
  120. <div class="filter-item">
  121. <div class="filter-name">${name}</div>
  122. <button class="edit-filter" data-name="${name}">Edit</button>
  123. <button class="delete-filter" data-name="${name}">Delete</button>
  124. <label><input type="checkbox" class="disable-filter" data-name="${name}" ${checked}> Disabled</label>
  125. </div>
  126. `;
  127. }).join('');
  128.  
  129. // Render the filter list
  130. document.getElementById('filter-list').innerHTML = filterList || 'No registered filters';
  131.  
  132. // Re-register the filter edit button events
  133. Array.from(document.querySelectorAll('.edit-filter')).forEach(button => {
  134. button.addEventListener('click', () => {
  135. const filterName = button.getAttribute('data-name');
  136. const filter = savedFilters[filterName];
  137.  
  138. // Pre-fill the form with the filter values
  139. document.getElementById('filter-name').value = filterName;
  140. document.getElementById('filter-currentUrl').value = filter.currentUrl || '';
  141. document.getElementById('filter-title').value = filter.title || '';
  142. document.getElementById('filter-url').value = filter.url || '';
  143. document.getElementById('filter-content').value = filter.content || '';
  144. document.getElementById('filter-text').value = filter.text || '';
  145. document.getElementById('filter-case').checked = filter.caseInsensitive || false;
  146.  
  147. editingFilterName = filterName;
  148.  
  149. // Update the form heading for editing
  150. document.querySelector('#filter-edit-title').innerText = 'Edit Existing Filter';
  151. document.querySelector('#fnfs-save').innerText = 'Update';
  152. });
  153. });
  154.  
  155. Array.from(document.querySelectorAll('.disable-filter')).forEach(checkbox => {
  156. checkbox.addEventListener('change', (e) => {
  157. const filterName = e.target.getAttribute('data-name');
  158. savedFilters[filterName].disabled = e.target.checked;
  159. GM_setValue('filters', savedFilters);
  160. applyAllFilters();
  161. });
  162. });
  163.  
  164. document.getElementById('fnfs-toggle-all-filters').innerText = areFiltersDisabled() ? 'Enable All Filters' : 'Disable All Filters';
  165. // Re-register the filter delete button events
  166. Array.from(document.querySelectorAll('.delete-filter')).forEach(button => {
  167. button.addEventListener('click', () => {
  168. const filterName = button.getAttribute('data-name');
  169. delete savedFilters[filterName];
  170. GM_setValue('filters', savedFilters);
  171. updateFilterList();
  172. applyAllFilters();
  173. });
  174. });
  175. }
  176.  
  177. function areFiltersDisabled() {
  178. return Object.values(savedFilters).every(filter => filter.disabled);
  179. }
  180.  
  181. function toggleAllFilters() {
  182. const disableAll = !areFiltersDisabled();
  183. Object.keys(savedFilters).forEach(filterName => {
  184. savedFilters[filterName].disabled = disableAll;
  185. });
  186. GM_setValue('filters', savedFilters);
  187. updateFilterList();
  188. applyAllFilters();
  189. }
  190.  
  191. // Display filter settings
  192. function showSettings() {
  193. const settingsHTML = `
  194. <h2>NG Filter Settings</h2>
  195. <h4>Saved Filters</h4>
  196. <div id="filter-list"></div>
  197. <button id="fnfs-toggle-all-filters">${areFiltersDisabled() ? 'Enable All Filters' : 'Disable All Filters'}</button>
  198. <br>
  199. <hr>
  200. <h4 id="filter-edit-title">Create New Filter</h4>
  201. <div id="filter-edit">
  202. <div><label>Filter Name</label><input type="text" id="filter-name"></div>
  203. <div><label>FreshRSS Feed List URL</label><input type="text" id="filter-currentUrl"></div>
  204. <div><label>Title</label><input type="text" id="filter-title"></div>
  205. <div><label>Content URL</label><input type="text" id="filter-url"></div>
  206. <div><label class="filter-info-label">Content<div title="article.flux_content.innerText" class="filter-info">i</div></label><input type="text" id="filter-content"></div>
  207. <div><label class="filter-info-label">Text<div title="div.text.innerHTML" class="filter-info">i</div></label><input type="text" id="filter-text"></div>
  208. <div><label>Case insensitive?</label><div><input type="checkbox" id="filter-case"></div></div>
  209. <br>
  210. </div>
  211. <button id="fnfs-save">Save</button>
  212. <button id="fnfs-clear">Clear</button>
  213. <button id="fnfs-close">Close</button>
  214. `;
  215.  
  216. const settingsDiv = document.createElement('div');
  217. settingsDiv.id = 'freshrss-ng-filter';
  218. settingsDiv.innerHTML = settingsHTML;
  219. document.body.appendChild(settingsDiv);
  220.  
  221. // Initial render of saved filter list
  222. updateFilterList();
  223.  
  224. // Make settings panel draggable
  225. makeDraggable(settingsDiv);
  226.  
  227. // Save or update button event
  228. document.getElementById('fnfs-save').addEventListener('click', () => {
  229. const filterName = document.getElementById('filter-name').value;
  230. const filterCurrentUrl = document.getElementById('filter-currentUrl').value;
  231. const filterTitle = document.getElementById('filter-title').value;
  232. const filterUrl = document.getElementById('filter-url').value;
  233. const filterContent = document.getElementById('filter-content').value;
  234. const filterText = document.getElementById('filter-text').value;
  235. const caseInsensitive = document.getElementById('filter-case').checked;
  236.  
  237. if (!filterName) {
  238. alert('Please enter a filter name');
  239. return;
  240. }
  241.  
  242. // Save or update the filter
  243. savedFilters[filterName] = {
  244. currentUrl: filterCurrentUrl,
  245. title: filterTitle,
  246. url: filterUrl,
  247. content: filterContent,
  248. text: filterText,
  249. caseInsensitive: caseInsensitive,
  250. disabled: false
  251. };
  252.  
  253. // If the filter name was changed during editing, delete the old filter
  254. if (editingFilterName && editingFilterName !== filterName) {
  255. delete savedFilters[editingFilterName];
  256. }
  257.  
  258. GM_setValue('filters', savedFilters);
  259.  
  260. showTooltip('Saved');
  261.  
  262. initEdit();
  263.  
  264. // Update filter list
  265. updateFilterList();
  266.  
  267. // Apply filters immediately after saving
  268. applyAllFilters();
  269. });
  270.  
  271. // Clear button event
  272. document.getElementById('fnfs-clear').addEventListener('click', () => {
  273. initEdit();
  274. });
  275.  
  276. // Close button event
  277. document.getElementById('fnfs-close').addEventListener('click', () => {
  278. document.body.removeChild(settingsDiv);
  279. });
  280.  
  281. document.getElementById('fnfs-toggle-all-filters').addEventListener('click', toggleAllFilters);
  282. }
  283.  
  284. function initEdit() {
  285. editingFilterName = null;
  286. document.getElementById('filter-name').value = '';
  287. document.getElementById('filter-currentUrl').value = '';
  288. document.getElementById('filter-title').value = '';
  289. document.getElementById('filter-url').value = '';
  290. document.getElementById('filter-content').value = '';
  291. document.getElementById('filter-text').value = '';
  292. document.getElementById('filter-case').checked = false;
  293.  
  294. // Update the form heading for creating a new filter
  295. document.querySelector('#filter-edit-title').innerText = 'Create New Filter';
  296. document.querySelector('#fnfs-save').innerText = 'Save';
  297. }
  298.  
  299. // Function to display the tooltip
  300. function showTooltip(message) {
  301. // Create the tooltip element
  302. const tooltip = document.createElement('div');
  303. tooltip.textContent = message;
  304. tooltip.style.position = 'fixed';
  305. tooltip.style.top = '50%';
  306. tooltip.style.left = '50%';
  307. tooltip.style.transform = 'translate(-50%, -50%)';
  308. tooltip.style.backgroundColor = 'rgba(0, 0, 0, 0.75)';
  309. tooltip.style.color = 'white';
  310. tooltip.style.padding = '10px 20px';
  311. tooltip.style.borderRadius = '5px';
  312. tooltip.style.zIndex = '10000';
  313. tooltip.style.fontSize = '16px';
  314. tooltip.style.textAlign = 'center';
  315.  
  316. // Add the tooltip to the page
  317. document.body.appendChild(tooltip);
  318.  
  319. // Automatically remove the tooltip after 1 second
  320. setTimeout(() => {
  321. document.body.removeChild(tooltip);
  322. }, 1000);
  323. }
  324.  
  325. // Make element draggable
  326. function makeDraggable(elmnt) {
  327. const header = elmnt.querySelector('h2');
  328. let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
  329. header.onmousedown = dragMouseDown;
  330.  
  331. function dragMouseDown(e) {
  332. e = e || window.event;
  333. e.preventDefault();
  334.  
  335. // Get the mouse cursor position at startup:
  336. pos3 = e.clientX;
  337. pos4 = e.clientY;
  338. document.onmouseup = closeDragElement;
  339. document.onmousemove = elementDrag;
  340. }
  341.  
  342. function elementDrag(e) {
  343. e = e || window.event;
  344. e.preventDefault();
  345.  
  346. // Calculate the new cursor position:
  347. pos1 = pos3 - e.clientX;
  348. pos2 = pos4 - e.clientY;
  349. pos3 = e.clientX;
  350. pos4 = e.clientY;
  351.  
  352. // Set the element's new position:
  353. elmnt.style.top = (elmnt.offsetTop - pos2) + "px";
  354. elmnt.style.left = (elmnt.offsetLeft - pos1) + "px";
  355. }
  356.  
  357. function closeDragElement() {
  358. document.onmouseup = null;
  359. document.onmousemove = null;
  360. }
  361. }
  362.  
  363. // Mark as read and hide articles
  364. function markAsNG(articleElement) {
  365. if (!articleElement) return;
  366.  
  367. // Check if mark_read function is available
  368. if (typeof mark_read === 'function') {
  369. mark_read(articleElement, true, true);
  370. } else {
  371. // Fallback: manually add 'read' class and trigger 'read' event
  372. articleElement.classList.add('read');
  373. const event = new Event('read');
  374. articleElement.dispatchEvent(event);
  375. }
  376.  
  377. // Hide the article
  378. articleElement.remove();
  379. }
  380.  
  381. // Apply all filters automatically
  382. function applyAllFilters() {
  383. const articles = Array.from(document.querySelectorAll('#stream > .flux'));
  384. const currentPageUrl = window.location.href;
  385.  
  386. articles.forEach(article => {
  387. const title = article.querySelector('a.item-element.title')?.innerText || '';
  388. const url = article.querySelector('a.item-element.title')?.href || '';
  389. const content = article.querySelector('.flux_content')?.innerText || '';
  390. const text = article.querySelector('div.text')?.innerHTML || '';
  391.  
  392. let matchesAnyFilter = false;
  393.  
  394. for (let filterName in savedFilters) {
  395. const filter = savedFilters[filterName];
  396. if (filter.disabled) continue;
  397.  
  398. const regexFlags = filter.caseInsensitive ? 'i' : '';
  399. const currentUrlMatch = !filter.currentUrl || new RegExp(filter.currentUrl, regexFlags).test(currentPageUrl);
  400. const titleMatch = !filter.title || new RegExp(filter.title, regexFlags).test(title);
  401. const urlMatch = !filter.url || new RegExp(filter.url, regexFlags).test(url);
  402. const contentMatch = !filter.content || new RegExp(filter.content, regexFlags).test(content);
  403. const textMatch = !filter.text || new RegExp(filter.text, regexFlags).test(text);
  404.  
  405. // console.log('titleMatch(' + titleMatch + '): ' + filter.title + ' = ' + title + '\n' +
  406. // 'urlMatch(' + urlMatch + '): ' + filter.url + ' = ' + url + '\n' +
  407. // 'contentMatch(' + contentMatch + '): ' + filter.content + ' = ' + content + '\n' +
  408. // 'textMatch(' + textMatch + '): ' + filter.text + ' = ' + text + '\n');
  409.  
  410. // Check if all filter conditions are met (AND condition)
  411. if (currentUrlMatch && titleMatch && urlMatch && contentMatch && textMatch) {
  412. markAsNG(article);
  413. break;
  414. }
  415. }
  416. });
  417. }
  418.  
  419. // Setup MutationObserver
  420. function setupObserver() {
  421. const targetNode = document.querySelector('#stream');
  422. if (targetNode) {
  423. const observer = new MutationObserver(applyAllFilters);
  424. observer.observe(targetNode, { childList: true, subtree: true });
  425. applyAllFilters();
  426. } else {
  427. // Retry if #stream is not found
  428. setTimeout(setupObserver, 1000);
  429. }
  430. }
  431.  
  432. // Register settings screen
  433. GM_registerMenuCommand('Settings', showSettings);
  434.  
  435. // Start setupObserver when the script starts
  436. setupObserver();
  437. })();