FreshRSS Keyword Highlight

Highlight articles in FreshRSS that match the rule. Rules are described by regular expressions.

// ==UserScript==
// @name        FreshRSS Keyword Highlight
// @namespace   https://github.com/hiroki-miya
// @version     1.0.3
// @description Highlight articles in FreshRSS that match the rule. 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';

    // Highlight Color
    const highlightColor = '#ffff60';

    // Retrieve saved highlights
    let savedHighlights = GM_getValue('highlights', {});

    // Define editingHighlightName globally (the name of the highlight currently being edited)
    let editingHighlightName = null;

    // Add styles
    GM_addStyle(`
        #freshrss-keyword-highlight {
            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-keyword-highlight > h2 {
            box-shadow: inset 0 0 0 0.5px black;
            padding: 5px 10px;
            text-align: center;
            cursor: move;
        }
        #freshrss-keyword-highlight > h4 {
            margin-top: 0;
        }
        #highlight-list {
            margin-bottom: 10px;
            max-height: 50vh;
            overflow-y: auto;
        }
        .highlight-item  {
            display: flex;
            justify-content: space-between;
            align-items: center;
        }
        #highlight-edit > div {
            display: flex;
            justify-content: space-between;
            align-items: center;
            line-height: 2;
            margin-bottom: 5px;
        }
        #highlight-edit > div input {
            line-height: 2;
            margin: 0;
        }
        .highlight-name,
        #highlight-edit > div > label {
            flex-grow: 1;
            margin-right: 10px;
            white-space: nowrap;
            overflow: hidden;
            text-overflow: ellipsis;
        }
        #highlight-edit > div > div:has(input[type="checkbox"]) {
            margin-left: 5px;
            max-width: 90%;
            width: 300px;
        }
        #highlight-edit > div input[type="checkbox"] {
            transform: scale(1.5);
            margin-left: 4px;
        }
        .edit-highlight, .delete-highlight,
        #highlight-edit > div > input {
            margin-left: 5px;
        }
        .highlight-info-label {
            display: inline;
        }
        .highlight-info {
            display: inline-block;
            border-radius: 50%;
            width: 16px;
            height: 16px;
            min-height: 16px;
            line-height: 1.2;
            margin-left: 4px;
            text-align: center;
            background-color: black;
            color: white;
            font-weight: 700;
        }
    `);

    // Function to render the highlight list
    function updateHighlightList() {
        // Sort highlights
        const highlightNames = Object.keys(savedHighlights).sort((a, b) => a.localeCompare(b, sortLocale));
        const highlightList = highlightNames.map(name => {
            return `
                <div class="highlight-item">
                    <div class="highlight-name">${name}</div>
                    <button class="edit-highlight" data-name="${name}">Edit</button>
                    <button class="delete-highlight" data-name="${name}">Delete</button>
                </div>
            `;
        }).join('');

        // Render the highlight list
        document.getElementById('highlight-list').innerHTML = highlightList || 'No registered highlight rules';

        // Re-register the highlight edit button events
        Array.from(document.querySelectorAll('.edit-highlight')).forEach(button => {
            button.addEventListener('click', () => {
                const highlightName = button.getAttribute('data-name');
                const highlight = savedHighlights[highlightName];

                // Pre-fill the form with the highlight values
                document.getElementById('highlight-name').value = highlightName;
                document.getElementById('highlight-currentUrl').value = highlight.currentUrl || '';
                document.getElementById('highlight-title').value = highlight.title || '';
                document.getElementById('highlight-url').value = highlight.url || '';
                document.getElementById('highlight-content').value = highlight.content || '';
                document.getElementById('highlight-text').value = highlight.text || '';
                document.getElementById('highlight-case').checked = highlight.caseInsensitive || false;

                editingHighlightName = highlightName;

                // Update the form heading for editing
                document.querySelector('#highlight-edit-title').innerText = 'Edit Existing Highlight Rule';
                document.querySelector('#fkh-save').innerText = 'Update';
            });
        });

        // Re-register the highlight delete button events
        Array.from(document.querySelectorAll('.delete-highlight')).forEach(button => {
            button.addEventListener('click', () => {
                const highlightName = button.getAttribute('data-name');
                delete savedHighlights[highlightName];
                GM_setValue('highlights', savedHighlights);
                updateHighlightList();
                applyAllHighlights();
            });
        });
    }

    // Display highlight settings
    function showSettings() {
        const settingsHTML = `
            <h2>Keyword Highlight Rule Settings</h2>
            <h4>Saved Highlight Rules</h4>
            <div id="highlight-list"></div>
            <br>
            <hr>
            <h4 id="highlight-edit-title">Create New Highlight Rule</h4>
            <div id="highlight-edit">
            <div><label>Highlight Name</label><input type="text" id="highlight-name"></div>
            <div><label>FreshRSS Feed List URL</label><input type="text" id="highlight-currentUrl"></div>
            <div><label>Title</label><input type="text" id="highlight-title"></div>
            <div><label>Content URL</label><input type="text" id="highlight-url"></div>
            <div><label class="highlight-info-label">Content<div title="article.flux_content.innerText" class="highlight-info">i</div></label><input type="text" id="highlight-content"></div>
            <div><label class="highlight-info-label">Text<div title="div.text.innerHTML" class="highlight-info">i</div></label><input type="text" id="highlight-text"></div>
            <div><label>Case insensitive?</label><div><input type="checkbox" id="highlight-case"></div></div>
            <br>
            </div>
            <button id="fkh-save">Save</button>
            <button id="fkh-clear">Clear</button>
            <button id="fkh-close">Close</button>
        `;

        const settingsDiv = document.createElement('div');
        settingsDiv.id = 'freshrss-keyword-highlight';
        settingsDiv.innerHTML = settingsHTML;
        document.body.appendChild(settingsDiv);

        // Initial render of saved highlight list
        updateHighlightList();

        // Make settikeywords panel draggable
        makeDraggable(settingsDiv);

        // Save or update button event
        document.getElementById('fkh-save').addEventListener('click', () => {
            const highlightName = document.getElementById('highlight-name').value;
            const highlightCurrentUrl = document.getElementById('highlight-currentUrl').value;
            const highlightTitle = document.getElementById('highlight-title').value;
            const highlightUrl = document.getElementById('highlight-url').value;
            const highlightContent = document.getElementById('highlight-content').value;
            const highlightText = document.getElementById('highlight-text').value;
            const caseInsensitive = document.getElementById('highlight-case').checked;

            if (!highlightName) {
                alert('Please enter a highlight name');
                return;
            }

            // Save or update the highlight
            savedHighlights[highlightName] = {
                currentUrl: highlightCurrentUrl,
                title: highlightTitle,
                url: highlightUrl,
                content: highlightContent,
                text: highlightText,
                caseInsensitive: caseInsensitive
            };

            // If the highlight name was changed during editing, delete the old highlight
            if (editingHighlightName && editingHighlightName !== highlightName) {
                delete savedHighlights[editingHighlightName];
            }

            GM_setValue('highlights', savedHighlights);

            showTooltip('Saved');

            initEdit();

            // Update highlight list
            updateHighlightList();

            // Apply highlights immediately after saving
            applyAllHighlights();
        });

        // Clear button event
        document.getElementById('fkh-clear').addEventListener('click', () => {
            initEdit();
        });

        // Close button event
        document.getElementById('fkh-close').addEventListener('click', () => {
            document.body.removeChild(settingsDiv);
        });
    }

    function initEdit() {
        editingHighlightName = null;
        document.getElementById('highlight-name').value = '';
        document.getElementById('highlight-currentUrl').value = '';
        document.getElementById('highlight-title').value = '';
        document.getElementById('highlight-url').value = '';
        document.getElementById('highlight-content').value = '';
        document.getElementById('highlight-text').value = '';
        document.getElementById('highlight-case').checked = false;

        // Update the form heading for creating a new highlight
        document.querySelector('#highlight-edit-title').innerText = 'Create New Highlight Rule';
        document.querySelector('#fkh-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;
        }
    }

    // Apply all highlights automatically
    function applyAllHighlights() {
        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 matchesAnyHighlight = false;

            // Check all saved highlights
            for (let highlightName in savedHighlights) {
                const highlight = savedHighlights[highlightName];
                const regexFlags = highlight.caseInsensitive ? 'i' : '';
                const currentUrlMatch = !highlight.currentUrl || new RegExp(highlight.currentUrl, regexFlags).test(currentPageUrl);
                const titleMatch = !highlight.title || new RegExp(highlight.title, regexFlags).test(title);
                const urlMatch = !highlight.url || new RegExp(highlight.url, regexFlags).test(url);
                const contentMatch = !highlight.content || new RegExp(highlight.content, regexFlags).test(content);
                const textMatch = !highlight.text || new RegExp(highlight.text, regexFlags).test(text);

//                 console.log('titleMatch(' + titleMatch + '): ' + highlight.title + ' = ' + title + '\n' +
//                      'urlMatch(' + urlMatch + '): ' + highlight.url + ' = ' + url + '\n' +
//                      'contentMatch(' + contentMatch + '): ' + highlight.content + ' = ' + content + '\n' +
//                      'textMatch(' + textMatch + '): ' + highlight.text + ' = ' + text + '\n');

                // Check if all highlight conditions are met (AND condition)
                if (currentUrlMatch && titleMatch && urlMatch && contentMatch && textMatch) {
                    matchesAnyHighlight = true;
                    break;
                }
            }

            // Add ng class to articles matching the highlight
            if (matchesAnyHighlight) {
                article.classList.add('highlight');
                article.style.backgroundColor = highlightColor;
            } else {
                article.classList.remove('highlight');
                article.style.backgroundColor = null;
            }
        });
    }

    // Setup MutationObserver
    function setupObserver() {
        const targetNode = document.querySelector('#stream');
        if (targetNode) {
            const observer = new MutationObserver(applyAllHighlights);
            observer.observe(targetNode, { childList: true, subtree: true });
            // Initial highlight application
            applyAllHighlights();
        } else {
            // Retry if #stream is not found
            setTimeout(setupObserver, 1000);
        }
    }

    // Register settings screen
    GM_registerMenuCommand('Settings', showSettings);

    // Start setupObserver when the script starts
    setupObserver();
})();