- // ==UserScript==
- // @name FreshRSS NG Filter
- // @namespace https://github.com/hiroki-miya
- // @version 1.0.4
- // @description Mark as read and hide articles matching the rule in FreshRSS. Rules are described by regular expressions.
- // @author hiroki-miya
- // @license MIT
- // @match https://freshrss.example.net/*
- // @grant GM_addStyle
- // @grant GM_getValue
- // @grant GM_registerMenuCommand
- // @grant GM_setValue
- // @run-at document-idle
- // ==/UserScript==
-
- (function() {
- 'use strict';
-
- // Language for sorting
- const sortLocale = 'ja';
-
- // Retrieve saved filters
- let savedFilters = GM_getValue('filters', {});
-
- // Define editingFilterName globally (the name of the filter currently being edited)
- let editingFilterName = null;
-
- // Add styles
- GM_addStyle(`
- #freshrss-ng-filter {
- position: fixed;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- z-index: 10000;
- background-color: white;
- border: 1px solid black;
- padding: 10px;
- width: max-content;
- }
- #freshrss-ng-filter > h2 {
- box-shadow: inset 0 0 0 0.5px black;
- padding: 5px 10px;
- text-align: center;
- cursor: move;
- }
- #freshrss-ng-filter > h4 {
- margin-top: 0;
- }
- #filter-list {
- margin-bottom: 10px;
- max-height: 50vh;
- overflow-y: auto;
- }
- .filter-item {
- display: flex;
- justify-content: space-between;
- align-items: center;
- }
- #filter-edit > div {
- display: flex;
- justify-content: space-between;
- align-items: center;
- line-height: 2;
- margin-bottom: 5px;
- }
- #filter-edit > div input {
- line-height: 2;
- margin: 0;
- }
- .filter-name,
- #filter-edit > div > label {
- flex-grow: 1;
- margin-right: 10px;
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- #filter-edit > div > div:has(input[type="checkbox"]) {
- margin-left: 5px;
- max-width: 90%;
- width: 300px;
- }
- #filter-edit > div input[type="checkbox"] {
- transform: scale(1.5);
- margin-left: 4px;
- }
- .edit-filter, .delete-filter,
- #filter-edit > div > input {
- margin-left: 5px;
- }
- .filter-info-label {
- display: inline;
- }
- .filter-info {
- display: inline-block;
- border-radius: 50%;
- width: 16px;
- height: 16px;
- min-height: 16px;
- line-height: 1.2;
- margin-left: 4px;
- position: relative;
- top: -6px;
- text-align: center;
- background-color: black;
- color: white;
- font-weight: 700;
- }
- `);
-
- // Function to render the filter list
- function updateFilterList() {
- // Sort filters
- const filterNames = Object.keys(savedFilters).sort((a, b) => a.localeCompare(b, sortLocale));
- const filterList = filterNames.map(name => {
- const filter = savedFilters[name];
- const checked = filter.disabled ? 'checked' : '';
- return `
- <div class="filter-item">
- <div class="filter-name">${name}</div>
- <button class="edit-filter" data-name="${name}">Edit</button>
- <button class="delete-filter" data-name="${name}">Delete</button>
- <label><input type="checkbox" class="disable-filter" data-name="${name}" ${checked}> Disabled</label>
- </div>
- `;
- }).join('');
-
- // Render the filter list
- document.getElementById('filter-list').innerHTML = filterList || 'No registered filters';
-
- // Re-register the filter edit button events
- Array.from(document.querySelectorAll('.edit-filter')).forEach(button => {
- button.addEventListener('click', () => {
- const filterName = button.getAttribute('data-name');
- const filter = savedFilters[filterName];
-
- // Pre-fill the form with the filter values
- document.getElementById('filter-name').value = filterName;
- document.getElementById('filter-currentUrl').value = filter.currentUrl || '';
- document.getElementById('filter-title').value = filter.title || '';
- document.getElementById('filter-url').value = filter.url || '';
- document.getElementById('filter-content').value = filter.content || '';
- document.getElementById('filter-text').value = filter.text || '';
- document.getElementById('filter-case').checked = filter.caseInsensitive || false;
-
- editingFilterName = filterName;
-
- // Update the form heading for editing
- document.querySelector('#filter-edit-title').innerText = 'Edit Existing Filter';
- document.querySelector('#fnfs-save').innerText = 'Update';
- });
- });
-
- Array.from(document.querySelectorAll('.disable-filter')).forEach(checkbox => {
- checkbox.addEventListener('change', (e) => {
- const filterName = e.target.getAttribute('data-name');
- savedFilters[filterName].disabled = e.target.checked;
- GM_setValue('filters', savedFilters);
- applyAllFilters();
- });
- });
-
- document.getElementById('fnfs-toggle-all-filters').innerText = areFiltersDisabled() ? 'Enable All Filters' : 'Disable All Filters';
- // Re-register the filter delete button events
- Array.from(document.querySelectorAll('.delete-filter')).forEach(button => {
- button.addEventListener('click', () => {
- const filterName = button.getAttribute('data-name');
- delete savedFilters[filterName];
- GM_setValue('filters', savedFilters);
- updateFilterList();
- applyAllFilters();
- });
- });
- }
-
- function areFiltersDisabled() {
- return Object.values(savedFilters).every(filter => filter.disabled);
- }
-
- function toggleAllFilters() {
- const disableAll = !areFiltersDisabled();
- Object.keys(savedFilters).forEach(filterName => {
- savedFilters[filterName].disabled = disableAll;
- });
- GM_setValue('filters', savedFilters);
- updateFilterList();
- applyAllFilters();
- }
-
- // Display filter settings
- function showSettings() {
- const settingsHTML = `
- <h2>NG Filter Settings</h2>
- <h4>Saved Filters</h4>
- <div id="filter-list"></div>
- <button id="fnfs-toggle-all-filters">${areFiltersDisabled() ? 'Enable All Filters' : 'Disable All Filters'}</button>
- <br>
- <hr>
- <h4 id="filter-edit-title">Create New Filter</h4>
- <div id="filter-edit">
- <div><label>Filter Name</label><input type="text" id="filter-name"></div>
- <div><label>FreshRSS Feed List URL</label><input type="text" id="filter-currentUrl"></div>
- <div><label>Title</label><input type="text" id="filter-title"></div>
- <div><label>Content URL</label><input type="text" id="filter-url"></div>
- <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>
- <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>
- <div><label>Case insensitive?</label><div><input type="checkbox" id="filter-case"></div></div>
- <br>
- </div>
- <button id="fnfs-save">Save</button>
- <button id="fnfs-clear">Clear</button>
- <button id="fnfs-close">Close</button>
- `;
-
- const settingsDiv = document.createElement('div');
- settingsDiv.id = 'freshrss-ng-filter';
- settingsDiv.innerHTML = settingsHTML;
- document.body.appendChild(settingsDiv);
-
- // Initial render of saved filter list
- updateFilterList();
-
- // Make settings panel draggable
- makeDraggable(settingsDiv);
-
- // Save or update button event
- document.getElementById('fnfs-save').addEventListener('click', () => {
- const filterName = document.getElementById('filter-name').value;
- const filterCurrentUrl = document.getElementById('filter-currentUrl').value;
- const filterTitle = document.getElementById('filter-title').value;
- const filterUrl = document.getElementById('filter-url').value;
- const filterContent = document.getElementById('filter-content').value;
- const filterText = document.getElementById('filter-text').value;
- const caseInsensitive = document.getElementById('filter-case').checked;
-
- if (!filterName) {
- alert('Please enter a filter name');
- return;
- }
-
- // Save or update the filter
- savedFilters[filterName] = {
- currentUrl: filterCurrentUrl,
- title: filterTitle,
- url: filterUrl,
- content: filterContent,
- text: filterText,
- caseInsensitive: caseInsensitive,
- disabled: false
- };
-
- // If the filter name was changed during editing, delete the old filter
- if (editingFilterName && editingFilterName !== filterName) {
- delete savedFilters[editingFilterName];
- }
-
- GM_setValue('filters', savedFilters);
-
- showTooltip('Saved');
-
- initEdit();
-
- // Update filter list
- updateFilterList();
-
- // Apply filters immediately after saving
- applyAllFilters();
- });
-
- // Clear button event
- document.getElementById('fnfs-clear').addEventListener('click', () => {
- initEdit();
- });
-
- // Close button event
- document.getElementById('fnfs-close').addEventListener('click', () => {
- document.body.removeChild(settingsDiv);
- });
-
- document.getElementById('fnfs-toggle-all-filters').addEventListener('click', toggleAllFilters);
- }
-
- function initEdit() {
- editingFilterName = null;
- document.getElementById('filter-name').value = '';
- document.getElementById('filter-currentUrl').value = '';
- document.getElementById('filter-title').value = '';
- document.getElementById('filter-url').value = '';
- document.getElementById('filter-content').value = '';
- document.getElementById('filter-text').value = '';
- document.getElementById('filter-case').checked = false;
-
- // Update the form heading for creating a new filter
- document.querySelector('#filter-edit-title').innerText = 'Create New Filter';
- document.querySelector('#fnfs-save').innerText = 'Save';
- }
-
- // Function to display the tooltip
- function showTooltip(message) {
- // Create the tooltip element
- const tooltip = document.createElement('div');
- tooltip.textContent = message;
- tooltip.style.position = 'fixed';
- tooltip.style.top = '50%';
- tooltip.style.left = '50%';
- tooltip.style.transform = 'translate(-50%, -50%)';
- tooltip.style.backgroundColor = 'rgba(0, 0, 0, 0.75)';
- tooltip.style.color = 'white';
- tooltip.style.padding = '10px 20px';
- tooltip.style.borderRadius = '5px';
- tooltip.style.zIndex = '10000';
- tooltip.style.fontSize = '16px';
- tooltip.style.textAlign = 'center';
-
- // Add the tooltip to the page
- document.body.appendChild(tooltip);
-
- // Automatically remove the tooltip after 1 second
- setTimeout(() => {
- document.body.removeChild(tooltip);
- }, 1000);
- }
-
- // Make element draggable
- function makeDraggable(elmnt) {
- const header = elmnt.querySelector('h2');
- let pos1 = 0, pos2 = 0, pos3 = 0, pos4 = 0;
- header.onmousedown = dragMouseDown;
-
- function dragMouseDown(e) {
- e = e || window.event;
- e.preventDefault();
-
- // Get the mouse cursor position at startup:
- pos3 = e.clientX;
- pos4 = e.clientY;
- document.onmouseup = closeDragElement;
- document.onmousemove = elementDrag;
- }
-
- function elementDrag(e) {
- e = e || window.event;
- e.preventDefault();
-
- // Calculate the new cursor position:
- pos1 = pos3 - e.clientX;
- pos2 = pos4 - e.clientY;
- pos3 = e.clientX;
- pos4 = e.clientY;
-
- // Set the element's new position:
- elmnt.style.top = (elmnt.offsetTop - pos2) + "px";
- elmnt.style.left = (elmnt.offsetLeft - pos1) + "px";
- }
-
- function closeDragElement() {
- document.onmouseup = null;
- document.onmousemove = null;
- }
- }
-
- // Mark as read and hide articles
- function markAsNG(articleElement) {
- if (!articleElement) return;
-
- // Check if mark_read function is available
- if (typeof mark_read === 'function') {
- mark_read(articleElement, true, true);
- } else {
- // Fallback: manually add 'read' class and trigger 'read' event
- articleElement.classList.add('read');
- const event = new Event('read');
- articleElement.dispatchEvent(event);
- }
-
- // Hide the article
- articleElement.remove();
- }
-
- // Apply all filters automatically
- function applyAllFilters() {
- const articles = Array.from(document.querySelectorAll('#stream > .flux'));
- const currentPageUrl = window.location.href;
-
- articles.forEach(article => {
- const title = article.querySelector('a.item-element.title')?.innerText || '';
- const url = article.querySelector('a.item-element.title')?.href || '';
- const content = article.querySelector('.flux_content')?.innerText || '';
- const text = article.querySelector('div.text')?.innerHTML || '';
-
- let matchesAnyFilter = false;
-
- for (let filterName in savedFilters) {
- const filter = savedFilters[filterName];
- if (filter.disabled) continue;
-
- const regexFlags = filter.caseInsensitive ? 'i' : '';
- const currentUrlMatch = !filter.currentUrl || new RegExp(filter.currentUrl, regexFlags).test(currentPageUrl);
- const titleMatch = !filter.title || new RegExp(filter.title, regexFlags).test(title);
- const urlMatch = !filter.url || new RegExp(filter.url, regexFlags).test(url);
- const contentMatch = !filter.content || new RegExp(filter.content, regexFlags).test(content);
- const textMatch = !filter.text || new RegExp(filter.text, regexFlags).test(text);
-
- // console.log('titleMatch(' + titleMatch + '): ' + filter.title + ' = ' + title + '\n' +
- // 'urlMatch(' + urlMatch + '): ' + filter.url + ' = ' + url + '\n' +
- // 'contentMatch(' + contentMatch + '): ' + filter.content + ' = ' + content + '\n' +
- // 'textMatch(' + textMatch + '): ' + filter.text + ' = ' + text + '\n');
-
- // Check if all filter conditions are met (AND condition)
- if (currentUrlMatch && titleMatch && urlMatch && contentMatch && textMatch) {
- markAsNG(article);
- break;
- }
- }
- });
- }
-
- // Setup MutationObserver
- function setupObserver() {
- const targetNode = document.querySelector('#stream');
- if (targetNode) {
- const observer = new MutationObserver(applyAllFilters);
- observer.observe(targetNode, { childList: true, subtree: true });
- applyAllFilters();
- } else {
- // Retry if #stream is not found
- setTimeout(setupObserver, 1000);
- }
- }
-
- // Register settings screen
- GM_registerMenuCommand('Settings', showSettings);
-
- // Start setupObserver when the script starts
- setupObserver();
- })();