Extend "AO3: Kudosed and seen history" @Min_

Add Export-Import buttons at the bottom of the page. Rename+color Seen/Unseen buttons and Title. Mark as seen on open from AO3 only.

目前为 2025-03-06 提交的版本。查看 最新版本

// ==UserScript==
// @name         Extend "AO3: Kudosed and seen history" @Min_
// @description  Add Export-Import buttons at the bottom of the page. Rename+color Seen/Unseen buttons and Title. Mark as seen on open from AO3 only.
// @author C89sd
// @version      1.2
// @match        https://archiveofourown.org/*
// @namespace https://greasyfork.org/users/1376767
// ==/UserScript==

const MARK_SEEN_FROM_AO3_ONLY = true;

(function() {
    'use strict';

    const footer = document.createElement('div');
    footer.style.width = '100%';
    footer.style.paddingTop = '5px';
    footer.style.paddingBottom = '5px';
    footer.style.display = 'flex';
    footer.style.justifyContent = 'center';
    footer.style.gap = '10px';
    footer.classList.add('footer');

    // Turn title into a link
    const firstH1 = document.querySelector('h2.title.heading');
    var firstH1link = null;

    if (firstH1) {
        const title = firstH1.lastChild ? firstH1.lastChild : firstH1;
        const titleLink = document.createElement('a');
        titleLink.href = window.location.href;
        if (title) {
            const titleClone = title.cloneNode(true);
            titleLink.appendChild(titleClone);
            title.parentNode.replaceChild(titleLink, title);
        }

      firstH1link = titleLink;
    }

    const BTN_1 = ['button'];
    const BTN_2 = ['button', 'button--link'];

    // Create Export Button
    const exportButton = document.createElement('button');
    exportButton.textContent = 'Export';
    exportButton.classList.add(...BTN_1);
    exportButton.addEventListener('click', exportToTxt);
    footer.appendChild(exportButton);

    // Create Import Button
    const importButton = document.createElement('button');
    importButton.textContent = 'Import';
    importButton.classList.add(...BTN_1);

    // Create hidden file input
    const fileInput = document.createElement('input');
    fileInput.type = 'file';
    fileInput.style.display = 'none'; // Hide the input element

    // Trigger file input on "Restore" button click
    importButton.addEventListener('click', () => {
        fileInput.click(); // Open the file dialog when the button is clicked
    });

    // Listen for file selection and handle the import
    fileInput.addEventListener('change', importFromTxt);
    footer.appendChild(importButton);

    // Append footer to the page
    const xFooter = document.getElementById('footer');
    if (xFooter) {
        xFooter.insertAdjacentElement('beforebegin', footer);
    } else {
        document.body.appendChild(footer);
    }

    console.log(localStorage.getItem('kudoshistory_username'))
    console.log(typeof localStorage.getItem('kudoshistory_username'))
    console.log(localStorage.getItem('kudoshistory_settings'))
    console.log(typeof localStorage.getItem('kudoshistory_settings'))

    // Export function
    function exportToTxt() {
        var kudos_history = {
            username:   { data: localStorage.getItem('kudoshistory_username') },
            settings:   { data: localStorage.getItem('kudoshistory_settings') },
            bookmarked: { data: localStorage.getItem('kudoshistory_bookmarked') || ',' },
            kudosed:    { data: localStorage.getItem('kudoshistory_kudosed')    || ',' },
            skipped:    { data: localStorage.getItem('kudoshistory_skipped')    || ',' },
            seen:       { data: localStorage.getItem('kudoshistory_seen')       || ',' },
            checked:    { data: localStorage.getItem('kudoshistory_checked')    || ',' }
        };

        var export_lists = {
            username: kudos_history.username.data,
            settings: kudos_history.settings.data,
            bookmarked: kudos_history.bookmarked.data,
            kudosed: kudos_history.kudosed.data,
            skipped: kudos_history.skipped.data,
            seen: kudos_history.seen.data,
            checked: kudos_history.checked.data
        };

        var textToSave = JSON.stringify(export_lists, null, 2);
        var blob = new Blob([textToSave], {
            type: "text/plain"
        });
        var a = document.createElement('a');
        a.href = URL.createObjectURL(blob);
        a.download = 'AO3_kudoshistory.json';
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
    }

    // Import function
    function importFromTxt(event) {
        var file = event.target.files[0];
        if (!file) return;

        var reader = new FileReader();
        reader.onload = function(e) {
            try {
                var importedData = JSON.parse(e.target.result);
                if (!importedData.kudosed || !importedData.seen || !importedData.bookmarked || !importedData.skipped || !importedData.checked) {
                    throw new Error("Missing data.");
                }
                var notes = ""
                if (importedData.kudosed)    localStorage.setItem('kudoshistory_kudosed',    importedData.kudosed);
                if (importedData.seen)       localStorage.setItem('kudoshistory_seen',       importedData.seen);
                if (importedData.bookmarked) localStorage.setItem('kudoshistory_bookmarked', importedData.bookmarked);
                if (importedData.skipped)    localStorage.setItem('kudoshistory_skipped',    importedData.skipped);
                if (importedData.checked)    localStorage.setItem('kudoshistory_checked',    importedData.checked);

                if (localStorage.getItem('kudoshistory_username') == "null" && importedData.username && importedData.username != "null") {
                  localStorage.setItem('kudoshistory_username', importedData.username);
                  notes += "\n+ Username: updated to " + importedData.username }
                else {
                  notes += "\n- Username: no changes."
                }

                if (importedData.settings && importedData.settings != localStorage.getItem('kudoshistory_settings')) {
                  localStorage.setItem('kudoshistory_settings', importedData.settings);
                  notes += "\n+ Settings: updated " + importedData.settings }
                else {
                  notes += "\n- Settings: no changes."
                }

                alert("Data imported successfully!" + notes);
            } catch (error) {
                alert("Invalid file format / missing data.");
            }
        };
        reader.readAsText(file);
    }


    // ==========================================


    let wasClicked = false;

    // Step 1: Wait for the button to exist and click it if it shows "Seen"
    function waitForSeenButton() {
        let attempts = 0;
        const maxAttempts = 100; // Stop after ~5 seconds (100 * 50ms)

        const buttonCheckInterval = setInterval(function() {
            attempts++;
            const seenButton = document.querySelector('.kh-seen-button a');

            if (seenButton) {
                clearInterval(buttonCheckInterval);
                if (seenButton.textContent.includes('Seen ✓')) {
                    if (!MARK_SEEN_FROM_AO3_ONLY || document.referrer.includes("archiveofourown.org")) {
                        seenButton.click();
                        wasClicked = true;
                    }
                } else {
                    wasClicked = false;
                }

                // Move to Step 2
                setupButtonObserver();
            } else if (attempts >= maxAttempts) {
                clearInterval(buttonCheckInterval);
            }
        }, 50);
    }

    // Step 2: Monitor the button text and toggle it
    function setupButtonObserver() {
        toggleButtonText(true, wasClicked);

        // Button to observe
        const targetNode = document.querySelector('.kh-seen-button');
        if (!targetNode) {
            return;
        }

        const observer = new MutationObserver(function(mutations) {
            mutations.forEach(function(mutation) {
                if (mutation.type === 'childList' || mutation.type === 'characterData') {
                    toggleButtonText(false, false);
                }
            });
        });

        const config = {
            childList: true,
            characterData: true,
            subtree: true
        };

        observer.observe(targetNode, config);
    }

    function toggleButtonText(isFirst = false, wasClicked = false) {
        const buttonElement = document.querySelector('.kh-seen-button a');
        if (!buttonElement) return;

        // Ignore changes we made ourselves
        if (buttonElement.textContent === "SEEN Now (click to unmark)" ||
            buttonElement.textContent === "Old SEEN (click to unmark)" ||
            buttonElement.textContent === "SEEN (click to unmark)"     ||
            buttonElement.textContent === "NOT SEEN (click to mark)"   ||
            buttonElement.textContent === "UNSEEN (click to mark)") {
            return;
        }

        const state_seen = buttonElement.textContent.includes('Unseen ✗') ? true :
            buttonElement.textContent.includes('Seen ✓') ? false : null;

        if (state_seen === null) {
            alert('[AO3 Patch] Unknown text: ' + buttonElement.textContent);
            return;
        }

        const GREEN = "#33cc70"; // "#33cc70";
        const GREEN_DARKER = "#00a13a"; // "#149b49";
        const RED = "#ff6d50";

        buttonElement.textContent =
            state_seen ?
            (isFirst ?
                (wasClicked ? "SEEN Now (click to unmark)" : "Old SEEN (click to unmark)") :
                "SEEN (click to unmark)") :
            "UNSEEN (click to mark)";

        const color = state_seen ? (isFirst && !wasClicked ? GREEN_DARKER : GREEN) : RED;
        buttonElement.style.backgroundColor = color;
        buttonElement.style.padding = "2px 6px";
        buttonElement.style.borderRadius = "3px";
        buttonElement.style.boxShadow = "none";
        buttonElement.style.backgroundImage = "none";

        firstH1link.style.color = color;

        if (isFirst && wasClicked) { // blink
            buttonElement.style.transition = "background-color 150ms ease";
            buttonElement.style.backgroundColor = GREEN;
            setTimeout(() => {
                buttonElement.style.backgroundColor = "#00e64b";
            }, 150);
            setTimeout(() => {
                buttonElement.style.transition = "background-color 200ms linear";
                buttonElement.style.backgroundColor = GREEN;
            }, 200);
        } else if (!isFirst) {
            buttonElement.style.transition = "none"; // Clear transition for subsequent calls
            buttonElement.style.backgroundColor = state_seen ? GREEN : RED;
        }
    }

    // Start the process
    waitForSeenButton();
})();