Persistent Find and Replace

Manage multiple groups of find-and-replace pairs.

// ==UserScript==
// @name         Persistent Find and Replace
// @namespace    http://tampermonkey.net/
// @version      0.1
// @description  Manage multiple groups of find-and-replace pairs.
// @match        https://archiveofourown.org/*
// @author       Matskye
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_addStyle
// @grant        GM_setClipboard
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // Load data from storage
    let groups = JSON.parse(GM_getValue('groups', '[]'));
    let activeGroupName = GM_getValue('activeGroupName', '');
    let autoReplace = GM_getValue('autoReplace', false);
    let currentTheme = GM_getValue('currentTheme', 'light');

    // Save functions
    function saveGroups() { GM_setValue('groups', JSON.stringify(groups)); }
    function saveActiveGroupName() { GM_setValue('activeGroupName', activeGroupName); }
    function saveAutoReplace() { GM_setValue('autoReplace', autoReplace); }
    function saveTheme() { GM_setValue('currentTheme', currentTheme); }

// Escape special characters in the search string
function escapeRegExp(string) {
    return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

// Perform replacements
function replaceTextInNode(node, pairs) {
    if (node.nodeType === 3) { // Text node
        let text = node.nodeValue;
        for (let i = 0; i < pairs.length; i++) {
            let findText = escapeRegExp(pairs[i].find); // Escape special characters
            // Create regex that matches the exact phrase with optional spaces or punctuation around it
            let regex = new RegExp(`(?<!\\w)${findText}(?!\\w)`, 'gi'); // Case-insensitive, no word before or after
            text = text.replace(regex, pairs[i].replace);
        }
        node.nodeValue = text;
    } else if (node.nodeType === 1 && node.nodeName !== 'SCRIPT' && node.nodeName !== 'STYLE') {
        for (let i = 0; i < node.childNodes.length; i++) {
            replaceTextInNode(node.childNodes[i], pairs);
        }
    }
}

    function createUI() {
        if (document.getElementById('findReplaceOverlay')) return;

        // Create host element
        let overlayHost = document.createElement('div');
        overlayHost.id = 'findReplaceOverlay';
        document.body.appendChild(overlayHost);

        // Attach shadow root
        let shadowRoot = overlayHost.attachShadow({ mode: 'closed' });

        // Create overlay elements
        let overlay = document.createElement('div');
        overlay.id = 'overlay';

        let dialog = document.createElement('div');
        dialog.id = 'dialog';
        dialog.className = currentTheme + '-theme';

        // Close button
        let closeBtn = document.createElement('span');
        closeBtn.id = 'closeBtn';
        closeBtn.innerHTML = '&times;';
        closeBtn.onclick = () => overlayHost.remove();
        dialog.appendChild(closeBtn);

        // Theme toggle button
        // let themeToggleBtn = document.createElement('span');
        // themeToggleBtn.id = 'themeToggleBtn';
        // themeToggleBtn.title = 'Toggle Theme';
        // themeToggleBtn.innerHTML = currentTheme === 'light' ? '🌙' : '☀️';
        // themeToggleBtn.onclick = () => {
        //     currentTheme = currentTheme === 'light' ? 'dark' : 'light';
        //     saveTheme();
        //     dialog.className = currentTheme + '-theme';
        //     themeToggleBtn.innerHTML = currentTheme === 'light' ? '🌙' : '☀️';
        // };
        // dialog.appendChild(themeToggleBtn);

        // Title
        let title = document.createElement('h2');
        title.textContent = 'Find and Replace Manager';
        dialog.appendChild(title);

        // Group Selection
        let groupSelectContainer = document.createElement('div');
        groupSelectContainer.className = 'groupSelectContainer';

        let groupLabel = document.createElement('label');
        groupLabel.textContent = 'Select Group: ';
        groupSelectContainer.appendChild(groupLabel);

        let groupSelect = document.createElement('select');
        groupSelect.id = 'groupSelect';

        // Function to refresh group options
        function refreshGroupOptions() {
            groupSelect.innerHTML = '';
            for (let i = 0; i < groups.length; i++) {
                let option = document.createElement('option');
                option.value = groups[i].name;
                option.textContent = groups[i].name;
                groupSelect.appendChild(option);
            }
            groupSelect.value = activeGroupName;
        }

        groupSelect.onchange = function() {
            activeGroupName = groupSelect.value;
            saveActiveGroupName();
            refreshPairs();
        };

        groupSelectContainer.appendChild(groupSelect);

        // Add New Group Button
        let addGroupButton = document.createElement('button');
        addGroupButton.textContent = '+ Add Group';
        addGroupButton.className = 'btn btn-secondary';
        addGroupButton.onclick = function() {
            let groupName = prompt('Enter new group name:');
            if (groupName) {
                // Check if group name already exists
                if (groups.find(g => g.name === groupName)) {
                    alert('A group with this name already exists.');
                    return;
                }
                groups.push({ name: groupName, pairs: [] });
                saveGroups();
                refreshGroupOptions();
                activeGroupName = groupName;
                saveActiveGroupName();
                refreshPairs();
            }
        };
        groupSelectContainer.appendChild(addGroupButton);

        // Delete Group Button
        let deleteGroupButton = document.createElement('button');
        deleteGroupButton.textContent = 'Delete Group';
        deleteGroupButton.className = 'btn btn-danger';
        deleteGroupButton.onclick = function() {
            if (confirm('Are you sure you want to delete this group?')) {
                groups = groups.filter(g => g.name !== activeGroupName);
                saveGroups();
                if (groups.length > 0) {
                    activeGroupName = groups[0].name;
                } else {
                    activeGroupName = '';
                }
                saveActiveGroupName();
                refreshGroupOptions();
                refreshPairs();
            }
        };
        groupSelectContainer.appendChild(deleteGroupButton);

        dialog.appendChild(groupSelectContainer);

        // Import/Export Buttons
        let importExportContainer = document.createElement('div');
        importExportContainer.className = 'importExportContainer';

        let exportButton = document.createElement('button');
        exportButton.textContent = 'Export Group';
        exportButton.className = 'btn btn-secondary';
        exportButton.onclick = function() {
            let activeGroup = groups.find(g => g.name === activeGroupName);
            if (activeGroup) {
                let dataStr = JSON.stringify(activeGroup);
                GM_setClipboard(dataStr);
                alert('Group data copied to clipboard. You can share it with others.');
            } else {
                alert('No active group selected.');
            }
        };
        importExportContainer.appendChild(exportButton);

        let importButton = document.createElement('button');
        importButton.textContent = 'Import Group';
        importButton.className = 'btn btn-secondary';
        importButton.onclick = function() {
            let dataStr = prompt('Paste the group data here:');
            if (dataStr) {
                try {
                    let importedGroup = JSON.parse(dataStr);
                    if (!importedGroup.name || !Array.isArray(importedGroup.pairs)) {
                        alert('Invalid group data.');
                        return;
                    }
                    // Check if group name already exists
                    if (groups.find(g => g.name === importedGroup.name)) {
                        alert('A group with this name already exists.');
                        return;
                    }
                    groups.push(importedGroup);
                    saveGroups();
                    refreshGroupOptions();
                    activeGroupName = importedGroup.name;
                    saveActiveGroupName();
                    refreshPairs();
                    alert('Group imported successfully.');
                } catch (e) {
                    alert('Failed to parse group data.');
                }
            }
        };
        importExportContainer.appendChild(importButton);

        dialog.appendChild(importExportContainer);

        // Create container for the pairs
        let pairsContainer = document.createElement('div');
        pairsContainer.id = 'pairsContainer';

        // Function to refresh the pairs list
        function refreshPairs() {
            // Clear container
            pairsContainer.innerHTML = '';

            let activeGroup = groups.find(g => g.name === activeGroupName);

            if (!activeGroup) {
                // No groups available
                let noGroupMsg = document.createElement('p');
                noGroupMsg.textContent = 'No group selected or groups available. Please add a new group.';
                pairsContainer.appendChild(noGroupMsg);
                return;
            }

            let pairs = activeGroup.pairs;

            for (let i = 0; i < pairs.length; i++) {
                (function(index) {
                    let pairDiv = document.createElement('div');
                    pairDiv.className = 'pairDiv';

                    let findInput = document.createElement('input');
                    findInput.type = 'text';
                    findInput.value = pairs[index].find;
                    findInput.placeholder = 'Find';

                    let replaceInput = document.createElement('input');
                    replaceInput.type = 'text';
                    replaceInput.value = pairs[index].replace;
                    replaceInput.placeholder = 'Replace';

                    findInput.onchange = function() {
                        pairs[index].find = findInput.value;
                        saveGroups();
                    };

                    replaceInput.onchange = function() {
                        pairs[index].replace = replaceInput.value;
                        saveGroups();
                    };

                    let removeButton = document.createElement('button');
                    removeButton.innerHTML = '&times;';
                    removeButton.className = 'btn btn-remove';
                    removeButton.title = 'Remove Pair';
                    removeButton.onclick = function() {
                        pairs.splice(index, 1);
                        saveGroups();
                        refreshPairs();
                    };

                    pairDiv.appendChild(findInput);
                    pairDiv.appendChild(replaceInput);
                    pairDiv.appendChild(removeButton);

                    pairsContainer.appendChild(pairDiv);
                })(i);
            }
        }

        refreshGroupOptions();
        refreshPairs();

        dialog.appendChild(pairsContainer);

        // Add pair button
        let addPairButton = document.createElement('button');
        addPairButton.textContent = '+ Add Pair';
        addPairButton.className = 'btn btn-primary';
        addPairButton.onclick = function() {
            let activeGroup = groups.find(g => g.name === activeGroupName);
            if (activeGroup) {
                activeGroup.pairs.push({ find: '', replace: '' });
                saveGroups();
                refreshPairs();
            } else {
                alert('No active group selected.');
            }
        };
        dialog.appendChild(addPairButton);

        // Apply replacements button
        let replaceButton = document.createElement('button');
        replaceButton.textContent = 'Apply Replacements';
        replaceButton.className = 'btn btn-success';
        replaceButton.onclick = function() {
            let activeGroup = groups.find(g => g.name === activeGroupName);
            if (activeGroup) {
                replaceTextInNode(document.body, activeGroup.pairs);
                alert('Replacements applied to the page.');
            } else {
                alert('No active group selected.');
            }
        };
        dialog.appendChild(replaceButton);

        // Auto-Replace checkbox
        let autoReplaceContainer = document.createElement('div');
        autoReplaceContainer.className = 'autoReplaceContainer';

        let autoReplaceCheckbox = document.createElement('input');
        autoReplaceCheckbox.type = 'checkbox';
        autoReplaceCheckbox.checked = autoReplace;
        autoReplaceCheckbox.id = 'autoReplaceCheckbox';
        autoReplaceCheckbox.onchange = function() {
            autoReplace = autoReplaceCheckbox.checked;
            saveAutoReplace();
        };

        let autoReplaceLabel = document.createElement('label');
        autoReplaceLabel.htmlFor = 'autoReplaceCheckbox';
        autoReplaceLabel.textContent = ' Automatically apply replacements on page load';

        autoReplaceContainer.appendChild(autoReplaceCheckbox);
        autoReplaceContainer.appendChild(autoReplaceLabel);
        dialog.appendChild(autoReplaceContainer);

        // Append dialog to overlay
        overlay.appendChild(dialog);

        // Append overlay to shadow root
        shadowRoot.appendChild(overlay);

        // Add styles
        let style = document.createElement('style');
        style.textContent = `
            #overlay {
                position: fixed;
                top: 0;
                left: 0;
                width: 100vw;
                height: 100vh;
                background-color: rgba(0,0,0,0.3);
                z-index: 10000;
                display: flex;
                justify-content: center;
                align-items: center;
            }
            #dialog {
                background-color: #fff;
                color: #000;
                padding: 20px 30px;
                border-radius: 8px;
                box-shadow: 0 5px 15px rgba(0,0,0,0.3);
                max-height: 80vh;
                overflow-y: auto;
                width: 500px;
                position: relative;
                font-family: Arial, sans-serif;
            }
            #closeBtn {
                position: absolute;
                top: 15px;
                right: 20px;
                font-size: 24px;
                font-weight: bold;
                cursor: pointer;
            }
            #closeBtn:hover {
                color: #000;
            }
        `;
        shadowRoot.appendChild(style);
    }

    // Add the button to open the UI
    function addButton() {
        let btnHost = document.createElement('div');
        document.body.appendChild(btnHost);

        let btnShadow = btnHost.attachShadow({ mode: 'closed' });

        let btn = document.createElement('button');
        btn.id = 'findReplaceButton';
        btn.textContent = 'Find & Replace';
        btn.onclick = createUI;

        // Button styles
        let btnStyle = document.createElement('style');
        btnStyle.textContent = `
            #findReplaceButton {
                position: fixed;
                bottom: 20px;
                right: 20px;
                z-index: 9999;
                padding: 12px 20px;
                background-color: #007BFF;
                color: #fff;
                border: none;
                border-radius: 50px;
                cursor: pointer;
                font-size: 16px;
                box-shadow: 0 2px 5px rgba(0,0,0,0.2);
                font-family: Arial, sans-serif;
            }
            #findReplaceButton:hover {
                background-color: #0069d9;
            }
        `;
        btnShadow.appendChild(btnStyle);
        btnShadow.appendChild(btn);
    }

    // Perform replacements on page load
    if (autoReplace) {
        let activeGroup = groups.find(g => g.name === activeGroupName);
        if (activeGroup) {
            replaceTextInNode(document.body, activeGroup.pairs);
        }
    }

    // Initialize the script
    addButton();
})();