Extend "AO3: Kudosed and seen history" | Export/Import + Standalone Light/Dark mode toggle

Add Export/Import history buttons at the bottom of the page. Color&rename the confusingly named Seen/Unseen buttons. Enhance the title. Fix "Mark as seen on open" triggering on external links. :: Standalone feature: Light/Dark site skin toggle button.

当前为 2025-03-08 提交的版本,查看 最新版本

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Greasemonkey 油猴子Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Violentmonkey 暴力猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴Userscripts ,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey 篡改猴,才能安装此脚本。

您需要先安装一款用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         Extend "AO3: Kudosed and seen history" | Export/Import + Standalone Light/Dark mode toggle
// @description  Add Export/Import history  buttons at the bottom of the page. Color&rename the confusingly named Seen/Unseen buttons. Enhance the title. Fix "Mark as seen on open" triggering on external links. :: Standalone feature: Light/Dark site skin toggle button.
// @author C89sd
// @version      1.16
// @match        https://archiveofourown.org/*
// @grant GM_xmlhttpRequest
// @namespace https://greasyfork.org/users/1376767
// ==/UserScript==

const ENHANCED_SEEN_BUTTON       = true; // Seen button is colored and renamed
    const COLORED_TITLE_LINK     = true; //   Title becomes a colored link
const ENHANCED_MARK_SEEN_ON_OPEN = true; // Enable improved "Mark seen on open" feature with a distinction between SEEN Now and Old SEEN
    const IGNORE_EXTERNAL_LINKS  = true; //   Ignore external links, only Mark SEEN within archiveofourown.org
const SITE_SKINS = [ "Default", "Reversi" ];


// Function to fetch the preferences form, extract the authenticity token, and get the skin_id
function getPreferencesForm(user) {
    // GET the preferences
    fetch(`https://archiveofourown.org/users/${user}/preferences`, {
        method: 'GET',
        headers: {
            'Content-Type': 'text/html'
        }
    })
    .then(response => response.text())
    .then(responseText => {
        const doc = new DOMParser().parseFromString(responseText, 'text/html');

        // Extract the authenticity token
        const authenticity_token = doc.querySelector('input[name="authenticity_token"]')?.value;
        if (authenticity_token) {
            // console.log('authenticity_token: ', authenticity_token);  // Log the token
        } else {
            alert('[userscript:Extend AO3] Error\n[authenticity_token] not found!');
            return;
        }

        // Find the <form class="edit_preference">
        const form = doc.querySelector('form.edit_preference');
        if (form) {
            // console.log('Form:', form);  // Log the form

            // Extract the action URL for the form submission
            const formAction = form.getAttribute('action');
            // console.log('Form Action:', formAction);

            // Find the <select id="preference_skin_id"> list
            const skinSelect = form.querySelector('#preference_skin_id');
            if (skinSelect) {
                // console.log('Found skin_id <select> element:', skinSelect);  // Log the select

                const workSkinIds = [];
                let currentSkinId = null;
                let unmatchedSkins = [...SITE_SKINS];

                // Loop through the <option value="skinId">skinName</option>
                const options = skinSelect.querySelectorAll('option');
                options.forEach(option => {
                    const optionValue = option.value;
                    const optionText = option.textContent.trim();

                    if (SITE_SKINS.includes(optionText)) {
                      // console.log('- option: value=', optionValue, ", text=", optionText, option.selected ? "SELECTED" : ".");

                      workSkinIds.push(optionValue);

                        // Remove matched name from unmatchedSkins
                        unmatchedSkins = unmatchedSkins.filter(name => name !== optionText);

                        if (option.selected) { // <option selected="selected"> is the current one
                            currentSkinId = optionValue;
                        }
                    }
                });

                // console.log('SKINS: ', SITE_SKINS, ", workSkinIds: ", workSkinIds);

                // Alert if any SITE_SKINS was not matched to an ID
                if (unmatchedSkins.length > 0) {
                  alert("ERROR.\nThe following skins were not found in the list under 'My Preferences > Your site skin'. Please check for spelling mistakes:\n[" + unmatchedSkins.join(", ") + "]\nThey will be skipped for now.");
                }

                // Cycle the ids: find the current ID in the list and pick the next modulo the array length
                if (workSkinIds.length > 0) {
                  let nextSkinId = null;
                  let currentIndex = workSkinIds.indexOf(currentSkinId);

                  if (currentSkinId === null || currentIndex === -1) {
                      // If currentSkinId is null or not found, select the first workSkinId
                      nextSkinId = workSkinIds[0];
                      alert("Current skin was not in list, first skin \"" + SITE_SKINS[0] + "\" will be applied.")
                  } else {
                      let nextIndex = (currentIndex + 1) % workSkinIds.length;
                      nextSkinId = workSkinIds[nextIndex];
                  }

                  // console.log('Next skin ID:', nextSkinId);

                  //  ------ POST settings update
                  //  NOTE: This triggers mutiple redirects ending in 404 .. but it works !
                  //  so we manualy handle and reload the page at the first redirect instead.

                  // // This approach is way simpler but I did not find how to get the current selected skin id, and I need it to decide the next skin to use. This approach seems to not update the settings, but maybe the id can be found on the page?
                  // fetch(`https://archiveofourown.org/skins/${nextSkinId}/set`, {
                  //     credentials: 'include'
                  // })
                  // .then(() => window.location.reload())
                  // .catch(error => {
                  //     console.error('Error setting the skin:', error);
                  //     alert('[userscript:Extend AO3] Error\nError setting the skin: ' + error);
                  // });

                  const formData = new URLSearchParams();
                    formData.append('_method', 'patch');
                    formData.append('authenticity_token', authenticity_token);
                    formData.append('preference[skin_id]', nextSkinId);
                    formData.append('commit', 'Update');  // Ensure the commit button is also included

                    fetch(formAction, {
                        method: 'POST',
                        body: formData.toString(),
                        headers: {
                            'Content-Type': 'application/x-www-form-urlencoded',
                            'Accept':  'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', //'application/json',
                            'Sec-Fetch-Dest': 'document',
                            'Sec-Fetch-Mode': 'navigate',
                            'Sec-Fetch-Site': 'same-origin',
                            'Sec-Fetch-User': '?1',
                            'Upgrade-Insecure-Requests': '1',
                            'Referer': `https://archiveofourown.org/users/${user}/preferences`
                        },
                        credentials: 'include',
                        redirect: 'manual'  // Prevents automatic redirect handling
                    })
                    .then(response => {
                        // If there is a redirect, response will have status code 3xx
                        if (response.type === 'opaqueredirect') {
                            // console.log('Redirect blocked, handling manually.');

                            window.location.reload(); // reload the page
                            return;
                        } else {
                            return response.text();
                        }
                    })
                    .then(responseText => {
                        // console.log('Form submitted successfully:', responseText);
                        window.location.reload(); // reload the page
                        return;
                    })
                    .catch(error => {
                        console.error('Error submitting the form:', error);
                        alert('[userscript:Extend AO3] Error\nError submitting the form: ' + error);
                    });
                }
            } else {
                alert('[userscript:Extend AO3] Error\nNo <select> element with id="preference_skin_id" found in the form');
            }
        } else {
            alert('[userscript:Extend AO3] Error\nNo form found with class "edit_preference"');
        }
    })
    .catch(error => {
        alert('[userscript:Extend AO3] Error\nError fetching preferences form: ' + error);
    });
}

// Button callback
function toggleLightDark() {
    const greetingElement = document.querySelector('#greeting a');

    if (!greetingElement) {
        alert('[userscript:Extend AO3] Error\nUsername not found in top right corner "Hi, user!"');
        return;
    }

    const user = greetingElement.href.split('/').pop();
    getPreferencesForm(user);
}


(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');

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

      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 Light/Dark Button
    const lightDarkButton = document.createElement('button');
    lightDarkButton.textContent = 'Light/Dark';
    lightDarkButton.classList.add(...['button']);
    lightDarkButton.addEventListener('click', toggleLightDark);
    footer.appendChild(lightDarkButton);

    // Create Export Button
    const exportButton = document.createElement('button');
    exportButton.textContent = 'Export';
    exportButton.classList.add(...BTN_1);
    exportButton.addEventListener('click', exportToJson);
    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.accept = '.txt, .json';
    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', importFromJson);
    footer.appendChild(importButton);

    // Append footer to the page
    const ao3Footer = document.querySelector('body > div > div#footer');
    if (ao3Footer) {
        ao3Footer.insertAdjacentElement('beforebegin', footer);
    } else {
        document.body.appendChild(footer);
    }

    // Export function
    function exportToJson() {
        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.txt';
        document.body.appendChild(a);
        a.click();
        document.body.removeChild(a);
    }

    // Import function
    function importFromJson(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 kudosed/seen/bookmarked/skipped/checked data fields.");
                }

                var notes = ""
                var sizes_before = ['kudoshistory_kudosed', 'kudoshistory_seen', 'kudoshistory_bookmarked', 'kudoshistory_skipped', 'kudoshistory_checked']
                    .map(key => localStorage.getItem(key)?.length || 0);
                var sizes_after = ['kudosed', 'seen', 'bookmarked', 'skipped', 'checked']
                    .map(key => importedData[key]?.length || 0);

                localStorage.setItem('kudoshistory_kudosed',    importedData.kudosed);
                localStorage.setItem('kudoshistory_seen',       importedData.seen);
                localStorage.setItem('kudoshistory_bookmarked', importedData.bookmarked);
                localStorage.setItem('kudoshistory_skipped',    importedData.skipped);
                localStorage.setItem('kudoshistory_checked',    importedData.checked);

                var diff = sizes_after.reduce((a, b) => a + b, 0) - sizes_before.reduce((a, b) => a + b, 0);
                diff = diff == 0 ? "no change" :
                       diff > 0 ? "added +" + diff :
                       "removed " + diff;
                notes += "\n- Entries: " + diff;
                notes += "\n    " + sizes_before;
                notes += "\n    " + sizes_after;

                if (!importedData.username) {
                  notes += "\n- Username: not present in file ";
                } else 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 change"
                }

                if (!importedData.settings) {
                  notes += "\n- Settings: not present in file ";
                } else if (importedData.settings && importedData.settings != localStorage.getItem('kudoshistory_settings')) {
                  const oldSettings = localStorage.getItem('kudoshistory_settings');
                  localStorage.setItem('kudoshistory_settings', importedData.settings);
                  notes += "\n- Settings: updated to";
                  notes += "\n    old:  " + oldSettings;
                  notes += "\n    new: " + importedData.settings;
                } else {
                  notes += "\n- Settings: no change"
                }

                alert("[userscript:Extend AO3] Success" + notes);
            } catch (error) {
                alert("[userscript:Extend AO3] Error\nInvalid file format / missing data.");
            }
        };
        reader.readAsText(file);
    }


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

if (ENHANCED_SEEN_BUTTON) {
    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 (ENHANCED_MARK_SEEN_ON_OPEN) {
                      if (!IGNORE_EXTERNAL_LINKS || 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('[userscript:Extend AO3]\nUnknown 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";
        buttonElement.style.color = getComputedStyle(buttonElement).color;

        // Color title
        if (firstH1link) firstH1link.style.color = color;

        // Blink on open Unseen -> Seen
        if (isFirst && wasClicked) {
            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();
}
})();