Copy Unvisited Links (Custom History v1.9 - Filter Pasted URLs)

Tracks clicked/visited links. Copies unvisited links from page and also Filters user-pasted URLs against history.

// ==UserScript==
// @name         Copy Unvisited Links (Custom History v1.9 - Filter Pasted URLs)
// @namespace    http://tampermonkey.net/
// @version      1.9
// @description  Tracks clicked/visited links. Copies unvisited links from page and also Filters user-pasted URLs against history.
// @author       Greg Cromwell / Gemini AI
// @license      MIT
// @match        *://*/*
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_setClipboard
// @run-at       document-idle
// ==/UserScript==

(function() {
    'use strict';

    // --- Configuration ---
    const SCRIPT_VERSION = "1.9";
    const CONSOLE_PREFIX = `[Custom History v${SCRIPT_VERSION}]`;
    const CUSTOM_HISTORY_KEY = `customUserVisitedLinks_v${SCRIPT_VERSION}`;
    const MAX_CUSTOM_HISTORY_SIZE = 10000;
    const COPY_BUTTON_ID = `copyCustomUnvisitedBtn_v${SCRIPT_VERSION}`;
    const PASTE_FILTER_BUTTON_ID = `pasteFilterBtn_v${SCRIPT_VERSION}`;
    const MODAL_ID = `pasteFilterModal_v${SCRIPT_VERSION}`;
    const TEXTAREA_ID = `pasteFilterTextarea_v${SCRIPT_VERSION}`;

    console.log(`${CONSOLE_PREFIX} Script loading.`);

    // --- 1. URL Normalization ---
    function normalizeUrl(url) {
        try {
            const urlObj = new URL(url, document.baseURI);
            let normalized = `${urlObj.protocol}//${urlObj.hostname}${urlObj.port ? ':' + urlObj.port : ''}${urlObj.pathname}`;
            if (normalized.length > 1 && normalized.endsWith('/')) {
                normalized = normalized.slice(0, -1);
            }
            return normalized.toLowerCase();
        } catch (e) {
            console.warn(`${CONSOLE_PREFIX} Could not normalize URL: "${url}"`, e.message);
            return null;
        }
    }

    // --- 2. Custom History Management ---
    async function getCustomHistorySet() {
        const historyArray = await GM_getValue(CUSTOM_HISTORY_KEY, []);
        return new Set(Array.isArray(historyArray) ? historyArray : []);
    }

    async function addUrlToCustomHistory(url) {
        const normalizedUrl = normalizeUrl(url);
        if (!normalizedUrl) return;
        let historyArray = await GM_getValue(CUSTOM_HISTORY_KEY, []);
        if (!Array.isArray(historyArray)) {
            historyArray = [];
        }
        const tempHistorySet = new Set(historyArray);
        if (!tempHistorySet.has(normalizedUrl)) {
            historyArray.push(normalizedUrl);
            if (historyArray.length > MAX_CUSTOM_HISTORY_SIZE) {
                historyArray = historyArray.slice(historyArray.length - MAX_CUSTOM_HISTORY_SIZE);
            }
            await GM_setValue(CUSTOM_HISTORY_KEY, historyArray);
        }
    }

    // --- 3. Link Processing: Attach Click Listeners ---
    async function processLinksOnPageForHistory() {
        const links = document.querySelectorAll('a[href]');
        links.forEach(link => {
            const linkHref = link.href;
            link.addEventListener('click', function handleLinkClick() {
                addUrlToCustomHistory(linkHref);
            });
        });
    }

    // --- 4. Button Functionality ---

    // Function for the first button: Copy "New" Links from page
    async function handleCopyUnvisitedClick() {
        console.log(`${CONSOLE_PREFIX} handleCopyUnvisitedClick called.`);
        alert("Processing page links... Check console (F12) for progress.");
        const visitedSet = await getCustomHistorySet();
        const unvisitedLinksToCopy = [];
        let pageLinkCount = 0;
        const allPageLinks = document.querySelectorAll('a[href]');
        pageLinkCount = allPageLinks.length;
        allPageLinks.forEach(link => {
            const originalHref = link.href;
            const normalizedHref = normalizeUrl(originalHref);
            if (normalizedHref && !normalizedHref.startsWith('javascript:') && !normalizedHref.startsWith('mailto:')) {
                if (!visitedSet.has(normalizedHref)) {
                    unvisitedLinksToCopy.push(originalHref);
                }
            }
        });
        const uniqueUnvisitedLinks = [...new Set(unvisitedLinksToCopy)];
        if (uniqueUnvisitedLinks.length > 0) {
            GM_setClipboard(uniqueUnvisitedLinks.join('\n'), 'text');
            alert(`Copied ${uniqueUnvisitedLinks.length} unique "new" link(s) (not in custom history) out of ${pageLinkCount} links to clipboard.`);
        } else {
            alert(`No "new" links found on this page (out of ${pageLinkCount} links) based on your custom history.`);
        }
        console.log(`${CONSOLE_PREFIX} handleCopyUnvisitedClick finished.`);
    }

    // --- Modal for Pasting Links ---
    function showPasteModal(callback) {
        if (document.getElementById(MODAL_ID)) {
             document.getElementById(MODAL_ID).style.display = 'flex';
             document.getElementById(TEXTAREA_ID).value = ''; // Clear previous content
             document.getElementById(TEXTAREA_ID).focus();
             return;
        }

        const modalOverlay = document.createElement('div');
        modalOverlay.id = MODAL_ID;
        Object.assign(modalOverlay.style, {
            position: 'fixed', top: '0', left: '0', width: '100%', height: '100%',
            backgroundColor: 'rgba(0,0,0,0.6)', display: 'flex',
            justifyContent: 'center', alignItems: 'center', zIndex: '10001'
        });

        const modalContent = document.createElement('div');
        Object.assign(modalContent.style, {
            background: 'white', padding: '25px', borderRadius: '8px',
            boxShadow: '0 4px 15px rgba(0,0,0,0.2)', minWidth: '350px', maxWidth: '90%',
            display: 'flex', flexDirection: 'column', gap: '15px'
        });

        const title = document.createElement('h3');
        title.textContent = 'Filter Pasted Links';
        Object.assign(title.style, { margin: '0 0 10px 0', textAlign: 'center' });

        const instruction = document.createElement('p');
        instruction.textContent = 'Paste your list of URLs below (one URL per line):';
        Object.assign(instruction.style, { margin: '0 0 5px 0', fontSize: '14px' });


        const textarea = document.createElement('textarea');
        textarea.id = TEXTAREA_ID;
        Object.assign(textarea.style, {
            width: 'calc(100% - 20px)', minHeight: '150px', border: '1px solid #ccc',
            borderRadius: '4px', padding: '10px', fontSize: '13px', resize: 'vertical'
        });

        const buttonContainer = document.createElement('div');
        Object.assign(buttonContainer.style, { display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '10px' });

        const processButton = document.createElement('button');
        processButton.textContent = 'Filter & Copy';
        Object.assign(processButton.style, {
            padding: '8px 15px', background: '#4CAF50', color: 'white',
            border: 'none', borderRadius: '4px', cursor: 'pointer'
        });

        const cancelButton = document.createElement('button');
        cancelButton.textContent = 'Cancel';
        Object.assign(cancelButton.style, {
            padding: '8px 15px', background: '#f44336', color: 'white',
            border: 'none', borderRadius: '4px', cursor: 'pointer'
        });

        processButton.onclick = () => {
            const text = textarea.value;
            modalOverlay.style.display = 'none';
            callback(text);
        };

        cancelButton.onclick = () => {
            modalOverlay.style.display = 'none';
        };
        
        modalOverlay.onclick = (event) => { // Close if backdrop is clicked
            if (event.target === modalOverlay) {
                modalOverlay.style.display = 'none';
            }
        };


        buttonContainer.append(cancelButton, processButton);
        modalContent.append(title, instruction, textarea, buttonContainer);
        modalOverlay.appendChild(modalContent);
        document.body.appendChild(modalOverlay);
        textarea.focus();
    }

    // Function for the second button: Process Pasted Text
    async function handleFilterPastedContentClick() {
        console.log(`${CONSOLE_PREFIX} handleFilterPastedContentClick called.`);
        showPasteModal(async (pastedText) => {
            if (!pastedText || pastedText.trim() === "") {
                alert("No text was pasted or processed.");
                console.log(`${CONSOLE_PREFIX} No text provided in modal.`);
                return;
            }

            const urlsFromPastedText = pastedText.split('\n').map(line => line.trim()).filter(line => line.length > 0);
            if (urlsFromPastedText.length === 0) {
                alert("No valid URLs found in the pasted text after parsing.");
                console.log(`${CONSOLE_PREFIX} No URLs found in pasted content.`);
                return;
            }
            console.log(`${CONSOLE_PREFIX} Found ${urlsFromPastedText.length} lines/URLs in pasted text.`);

            const visitedSet = await getCustomHistorySet();
            const unvisitedFromPasted = [];
            urlsFromPastedText.forEach(url => {
                const normalizedUrl = normalizeUrl(url);
                if (normalizedUrl && !normalizedUrl.startsWith('javascript:') && !normalizedUrl.startsWith('mailto:')) {
                    if (!visitedSet.has(normalizedUrl)) {
                        unvisitedFromPasted.push(url); // Store original URL
                    }
                }
            });

            const uniqueUnvisitedFromPasted = [...new Set(unvisitedFromPasted)];
            console.log(`${CONSOLE_PREFIX} Found ${uniqueUnvisitedFromPasted.length} unique unvisited links from pasted text.`);

            if (uniqueUnvisitedFromPasted.length > 0) {
                GM_setClipboard(uniqueUnvisitedFromPasted.join('\n'), 'text');
                alert(`Filtered Pasted Text: ${uniqueUnvisitedFromPasted.length} unique "new" link(s) (not in your custom history) out of ${urlsFromPastedText.length} pasted links are now on your clipboard.`);
            } else {
                alert(`No "new" links found in your pasted text (out of ${urlsFromPastedText.length} lines) based on your custom history. Clipboard not changed.`);
            }
            console.log(`${CONSOLE_PREFIX} handleFilterPastedContentClick processing finished.`);
        });
    }

    // --- UI Element Creation ---
    function createAndAddButtons() {
        // Button 1: Copy "New" Links from Page
        if (!document.getElementById(COPY_BUTTON_ID)) {
            const button1 = document.createElement('button');
            button1.id = COPY_BUTTON_ID;
            button1.textContent = `CpNew v${SCRIPT_VERSION.substring(0,3)}`; // Shorter text
            button1.title = `Copy "New" Links from Page (v${SCRIPT_VERSION})`;
            Object.assign(button1.style, {
                position: 'fixed', top: '75px', right: '1px', zIndex: '3001',
                fontSize: '10px', padding: '1px 2px', margin: '0', lineHeight: '1',
                minWidth: '30px', backgroundColor: 'lightblue', border: '1px solid gray',
                borderRadius: '2px', cursor: 'pointer', boxShadow: '1px 1px 1px rgba(0,0,0,0.1)'
            });
            button1.addEventListener('click', handleCopyUnvisitedClick);
            appendElementToBody(button1, "Copy New Links button");
        }

        // Button 2: Paste & Filter
        if (!document.getElementById(PASTE_FILTER_BUTTON_ID)) {
            const button2 = document.createElement('button');
            button2.id = PASTE_FILTER_BUTTON_ID;
            button2.textContent = `Paste&Filt`; // Shorter text
            button2.title = `Paste and Filter URLs against Custom History (v${SCRIPT_VERSION})`;
            Object.assign(button2.style, {
                position: 'fixed', top: '95px', right: '1px', zIndex: '3000',
                fontSize: '10px', padding: '1px 2px', margin: '0', lineHeight: '1',
                minWidth: '30px', backgroundColor: 'lightgreen', border: '1px solid gray',
                borderRadius: '2px', cursor: 'pointer', boxShadow: '1px 1px 1px rgba(0,0,0,0.1)'
            });
            button2.addEventListener('click', handleFilterPastedContentClick);
            appendElementToBody(button2, "Paste & Filter button");
        }
    }

    function appendElementToBody(element, elementName) {
        if (document.body) {
            document.body.appendChild(element);
        } else {
            window.addEventListener('DOMContentLoaded', () => {
                if (document.body) document.body.appendChild(element);
                else console.error(`${CONSOLE_PREFIX} ${elementName}: document.body still not found.`);
            }, { once: true });
        }
    }

    // --- 5. Initialization and Dynamic Content Handling ---
    function initializeScript() {
        addUrlToCustomHistory(window.location.href);
        processLinksOnPageForHistory();
        createAndAddButtons();
    }

    try {
        initializeScript();
    } catch(e) {
        console.error(`${CONSOLE_PREFIX} CRITICAL ERROR during script initialization:`, e);
        alert(`[Custom History v${SCRIPT_VERSION}] CRITICAL ERROR: ${e.message}. Check console.`);
    }

    const observer = new MutationObserver((mutationsList) => {
        if (!document.getElementById(COPY_BUTTON_ID) || !document.getElementById(PASTE_FILTER_BUTTON_ID)) {
            createAndAddButtons();
        }
        for (const mutation of mutationsList) {
            if (mutation.type === 'childList' && mutation.addedNodes.length > 0) {
                let hasNewLinks = false;
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === Node.ELEMENT_NODE && (node.matches('a[href]') || node.querySelector('a[href]'))) {
                        hasNewLinks = true;
                    }
                });
                if (hasNewLinks) {
                    processLinksOnPageForHistory();
                    break;
                }
            }
        }
    });

    if (document.body) {
        observer.observe(document.body, { childList: true, subtree: true });
    } else {
        window.addEventListener('DOMContentLoaded', () => {
            if (document.body) observer.observe(document.body, { childList: true, subtree: true });
            else console.error(`${CONSOLE_PREFIX} MutationObserver: document.body not found.`);
        }, { once: true });
    }

    window[`clearUserLinkHistory_v${SCRIPT_VERSION.replace(/\./g, '_')}`] = async () => {
        console.log(`${CONSOLE_PREFIX} clearUserLinkHistory called by user.`);
        if (confirm(`Are you sure you want to clear all custom link history for script version ${SCRIPT_VERSION}?`)) {
            await GM_setValue(CUSTOM_HISTORY_KEY, []);
            alert(`Custom link history (v${SCRIPT_VERSION}) cleared. Reload page for full effect.`);
        } else {
            alert(`Custom link history (v${SCRIPT_VERSION}) clearing cancelled.`);
        }
    };
    console.log(`${CONSOLE_PREFIX} Script loaded. Type 'clearUserLinkHistory_v${SCRIPT_VERSION.replace(/\./g, '_')}()' in console to clear history.`);

})();