FreshRSS NG Filter

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

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