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

Add Export/Import history to TXT buttons at the bottom of the page │ Fix back-navigation not being collapsed │ Color and rename the Seen/Unseen buttons │ Add skip button │ Enhance the title │ Fix "Mark as seen on open" triggering on external links ║ Standalone feature: Light/Dark site skin toggle button.

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください。
// ==UserScript==
// @name         Extend "AO3: Kudosed and seen history" | Export/Import + Standalone Light/Dark mode toggle
// @description  Add Export/Import history to TXT buttons at the bottom of the page │ Fix back-navigation not being collapsed │ Color and rename the Seen/Unseen buttons │ Add skip button │ 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.38
// @match        https://archiveofourown.org/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @namespace    https://greasyforks.org/users/1376767
// ==/UserScript==
'use strict';

const ENHANCED_SEEN_BUTTON   = true; // Seen button is colored and renamed / Immediately mark seen / Blink when navigating back
    const COLORED_TITLE_LINK = true; // |- Title becomes a colored link

const ENHANCED_MARK_SEEN_ON_OPEN = true; // Autoclick the seen button on open. Change text to "SEEN Now" or "Old SEEN" based on original state (can only do that if original setting is disabled because this script runs after.)
    const IGNORE_EXTERNAL_LINKS  = true; // |- Mark as seen when a link is clicked on AO3, not from other sites (e.g. link on reddit). If false, autosee all links but still mark 'SEEN Now' to you if it was a new or old link.

const SITE_SKINS = [ "Default", "Reversi" ];


// --------------------------------------------------------------------------
//                                Skip Button
// --------------------------------------------------------------------------

let currentSkipState;

// Copied from @Min_ https://greasyforks.org/en/scripts/5835-ao3-kudosed-and-seen-history
var KHList = {
    init: function(name, max_length) {
        this.name = name;
        this.max_length = max_length || 200000;
        this.list = localStorage.getItem('kudoshistory_' + this.name) || ',';
        return this;
    },
    reload: function() {
        this.list = localStorage.getItem('kudoshistory_' + this.name) || this.list;
        return this;
    },
    save: function() {
        try {
            localStorage.setItem('kudoshistory_' + this.name, this.list.slice(0, this.max_length));
        } catch (e) {
            localStorage.setItem('kudoshistory_' + this.name, this.list.slice(0, this.list.length * 0.9));
        }
        return this;
    },
    hasId: function(work_id) {
        if (this.list.indexOf(',' + work_id + ',') > -1) {
            this.list = ',' + work_id + this.list.replace(',' + work_id + ',', ',');
            return true;
        }
        return false;
    },
    add: function(work_id) {
        this.list = ',' + work_id + this.list.replace(',' + work_id + ',', ',');
        return this;
    },
    remove: function(work_id) {
        this.list = this.list.replace(',' + work_id + ',', ',');
        return this;
    },
};

let skipped;

function getWorkId() {
    const match = location.pathname.match(/\/works\/(\d+)/);
    return match ? match[1] : null;
}

const skipOff = ''; // '·'
const skipOn  = 'skipped';
function createSkipButton(workId, seenBtn) {
    skipped = Object.create(KHList).init('skipped');

    const li = document.createElement('li');
    li.style.padding = '0'

    const a = document.createElement('a');
    currentSkipState = skipped.hasId(workId);
    a.textContent = currentSkipState ? skipOn : skipOff;
    a.className = 'khx-skip-btn';

    a.style.color = getComputedStyle(seenBtn).color; // default text color is gray? copy other buttons.

    if (currentSkipState) {
        a.classList.add('hkx-skipped');
    }

    a.addEventListener('click', function(e) {
        skipped.reload();
        currentSkipState = skipped.hasId(workId);
        if (currentSkipState) {
            skipped.remove(workId);
            a.textContent = skipOff;
            a.classList.remove('hkx-skipped');
        } else {
            skipped.add(workId);
            a.textContent = skipOn;
            a.classList.add('hkx-skipped');
        }
        currentSkipState = !currentSkipState;
        skipped.save();
        a.blur()
    });

    li.appendChild(a);
    return li;
}

function insertSkipButton() {
    const workId = getWorkId();
    if (!workId) return;

    const seenBtn = document.querySelector('#main .kh-seen-button');
    if (!seenBtn || !seenBtn.parentNode) return;

    const skipBtnLi = createSkipButton(workId, seenBtn);

    // wrap both buttons in container so they don't get separated
    const container = document.createElement('div');
    container.style.display = 'inline-block';
    seenBtn.parentNode.insertBefore(container, seenBtn);
    container.appendChild(skipBtnLi);
    container.appendChild(seenBtn);
    // seenBtn.parentNode.insertBefore(skipBtnLi, seenBtn.nextSibling);
}

GM_addStyle(`
.khx-skip-btn {
  padding: 0.23em 0.5em !important;
  box-shadow: none !important;
  background-image: none !important;
  background-clip: padding-box !important;
    border-radius: 0.25em 0 0 0.25em !important;
    border-right: 0px !important;
}
.khx-seen-btn {
    border-radius: 0 0.25em 0.25em 0 !important;
  padding: 0.23em 0.5em !important;
  box-shadow: none !important;
  background-image: none !important;
  background-clip: padding-box !important;
  width: 8ch !important;
}
.hkx-skipped {
  background-color: rgb(238, 151, 40) !important;
  padding: 0.23em 0.5em !important;
}
`);
insertSkipButton();


// --------------------------------------------------------------------------
//                Collapse links clicked from search page
// --------------------------------------------------------------------------

// The AO3 Kudosed History script requires a manual reload after a link is clicked:
// - Clicked fics are not collpased and when navigating back.
// - Seen changes made from the other page are not taken into account.
//
// To fix this:
//   Intercept clicks on links to immediately trigger the 'seen' button collapse and various blink effects.
//   Write the current fic id/state to localStorage from fics.
//   When back-navigating, read it back and try to find its link on-screen to update its collapsed status.

let currentSeenState = null; // Updated inside of fics (MutationObserver on the fic's seen button).

if (ENHANCED_SEEN_BUTTON) {
  const isWork = /^https:\/\/archiveofourown\.org(?:\/collections\/[^\/]+)?(\/works\/\d+)/

  let refererData = {}; // {} | { workSlashId + seenState + skipState } of the referrer page (lastest when navigating back and forth).

  // When clicking a link & navigating back before the page is loaded, it doesn't blink.
  // To make it blink, we push the clicked link to localStorage, notifying ourselves.
  let clickedLink = false;
  let clickedLinkHref;
  let clickedLinkSeen;

  // About to leave page: write state for the next page to load.
  window.addEventListener("pagehide", function (event) {
    // Note: Doing this in 'unload'(desktop) or 'beforeunload'(mobile) caused 'event.persisted' to be false.
    const match = (clickedLink ? clickedLinkHref : window.location.href).match(isWork);
    if (match) {
      // Note: sessionStorage did not work on desktop; and GM_setValue bechmarked 27% slower than localStorage.

      if (clickedLink) {
        // Link clicked on AO3. The DB will be updated after the original script finises loading (work loaded). If we navigate back before, it will not be marked seen!
        // To prevent this, we write ourselves a message that the work was clicked. I we navigate back before the next page load we will see this message and close it ourselves, else it will be overwritten on the other side.
        localStorage.setItem("C89AO3_state", JSON.stringify({"workSlashId": match[1], "seenState": (clickedLinkSeen), "skipState": (currentSkipState)}));
      } else if (currentSeenState === null && refererData?.workSlashId === match[1]) {
        // Carry seenState back over adult content warning pages (they have no seen button).
        localStorage.setItem("C89AO3_state", JSON.stringify({"workSlashId": match[1], "seenState": (refererData?.seenState), "skipState": (refererData?.skipState)}));
      } else {
        localStorage.setItem("C89AO3_state", JSON.stringify({"workSlashId": match[1], "seenState": (currentSeenState), "skipState": (currentSkipState)}));
      }
    }
    else {
      localStorage.setItem("C89AO3_state", '{}');
    }
  });

  // Navigated back: load state communicated from originating page.
  // updated on page load/back navigation/etc.
  window.addEventListener("pageshow", function (event) {
    let data = localStorage.getItem("C89AO3_state");
    refererData = JSON.parse(data ? data : '{}');

    //console.log('navigated back: data=', data, ', persisted=', event.persisted)
  });

  // Blink functionality.
  const flashCSS = `
@keyframes flash-glow {
  0% {
    box-shadow: 0 0 4px currentColor;
  }
  100% {
    box-shadow: 0 0 4px transparent;
  }
}

@keyframes slide-left {
  0% {
    transform: translateX(6px);
  }
  100% {
    transform: translateX(0);
  }
}

/* When opening, slide down */
li[role="article"]:not(.marked-seen).blink div.header.module {
  transition: all 0.25s ease-out; //0.15s
}

/* Always blink border */
li.blink {
  animation: flash-glow 0.25s ease-in 1;
}

/* When closing, slide title left */
//li.blink.marked-seen div.header.module {
//  animation: slide-left 0.15s ease-out 1;
//}
//li.blink.marked-seen {
//  animation: flash-glow 0.2s ease-out 1;
//}

/* When collapsing, slide title in */
//li[role="article"].blink.marked-seen * h4.heading {
//  transition: all 0.300s ease-out;
//}
//li[role="article"]:not(.marked-seen) * ul.required-tags {
//  transition: all 0.1s ease-out;
//}`;

  GM_addStyle(flashCSS);

  let blinkTimeout;
  function blink(article) {
    // console.log("BLINK from ", article)
    clearTimeout(blinkTimeout);
    article.classList.remove('blink');

    void article.offsetWidth; // reflow

    article.classList.add('blink');
    blinkTimeout = setTimeout(() => {
      article.classList.remove('blink');
    }, 250);
  }

  // Navigated back: blink + update seen state.
  window.addEventListener('pageshow', (event) => {
    //console.log("navigated back, persisted=", event.persisted, ', referer=', refererData)

    if (event.persisted) { // If we navigated back.
      if (refererData?.workSlashId) { // If we read a fic id from localStorage.

        // Try finding the link of the fic we navigated back from and toggle its parent visibility.
        // Note: use *= because there can be: '.com/works/123' or '.com/collections/u1/works/132' or ?foo at the end.
        const titleLink = document.querySelector(`h4.heading > a[href*="${refererData.workSlashId}"]`);
        if (titleLink) {

          const article = titleLink.closest('li[role="article"]');
          if (article) {

            blink(article);

            if (       refererData?.seenState === true && !article.classList.contains('marked-seen')) {
              article.classList.add('marked-seen');
            } else if (refererData?.seenState === false && article.classList.contains('marked-seen')) {
              article.classList.remove('marked-seen');
            }

            if (     refererData?.skipState === true ) { article.classList.add('skipped-work'); }
            else if (refererData?.skipState === false) { article.classList.remove('skipped-work'); }
          }
        }
      }
    }
  });

  // Floating seen button click: blink.
  // The AO3 script calls event.stopPropagation() so document.addEventListener('click') does not work, we do his:
  function onKhToggleClick(e) {
    // console.log("click (floating seen .kh-toggle) ", event.target)
    const article = event.target.closest('li[role="article"]');
    if (article) {
      if (e.target.textContent === 'seen') blink(article);
    }
  }
  function attachToAll() {
    document.querySelectorAll('.kh-toggle').forEach(el => {
      // avoid double-binding
      if (!el.__khListenerAttached) {
        el.addEventListener('click', onKhToggleClick, /* capture */ true);
        el.__khListenerAttached = true;
      }
    });
  }
  attachToAll();

  // Title click: blink + send click event to floating seen button + redirect.
  document.addEventListener('click', function(event) {
    // console.log("click (title) ", event.target)

    const titleLink = event.target.closest('h4.heading > a');
    if (titleLink) {
      const article = titleLink.closest('li[role="article"]');
      if (article) {
        const seenButton = article.querySelector('div.kh-toggles>a')
        if (seenButton) {

          // Give the "seen" action time to execute before loading the page.
          event.preventDefault();

          blink(article);

          // Click the seen button (unless the fic is collapsed - that would unmark it!).
          if (!article.classList.contains('marked-seen')) {
            seenButton.click();
          }

          // Wait for seenButton.click() to complete before reloading.
          requestIdleCallback(() => {
            clickedLink     = true;
            clickedLinkHref = titleLink.href;
            clickedLinkSeen = article.classList.contains('marked-seen');

            window.location.href = titleLink.href;
          });
        }
      }
    }
  });

}

// --------------------------------------------------------------------------
//                         Dark/Light mode toggle
// --------------------------------------------------------------------------

// GET the preferences form, find the current skin_id, and POST the preferences form with updated next skin_id.
function toggleSiteSkin(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();
    toggleSiteSkin(user);
}

// --------------------------------------------------------------------------
//                     Bottom bar + Import/Export
// --------------------------------------------------------------------------

(function() {
    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.origin + window.location.pathname + window.location.search; // Keeps "?view_full_work=true", drops "#summary"
          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);
    }

    const strip = /^\[?,?|,?\]?$/g;
    // Export function
    function exportToJson() {
        const export_lists = {
            username:   localStorage.getItem('kudoshistory_username'),
            settings:   localStorage.getItem('kudoshistory_settings'),
            kudosed:    localStorage.getItem('kudoshistory_kudosed')    || ',',
            bookmarked: localStorage.getItem('kudoshistory_bookmarked') || ',',
            skipped:    localStorage.getItem('kudoshistory_skipped')    || ',',
            seen:       localStorage.getItem('kudoshistory_seen')       || ',',
            checked:    localStorage.getItem('kudoshistory_checked')    || ','
        };

        const pad = (num) => String(num).padStart(2, '0');
        const now = new Date();
        const year = now.getFullYear();
        const month = pad(now.getMonth() + 1);
        const day = pad(now.getDate());
        const hours = pad(now.getHours());
        const minutes = pad(now.getMinutes());
        const seconds = pad(now.getSeconds()); // Add seconds
        const username = export_lists.username || "none";
        var size = ['kudosed', 'bookmarked', 'skipped', 'seen', 'checked']
            .map(key => (String(export_lists[key]) || '').replace(strip, '').split(',').length);

        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_history_${year}_${month}_${day}_${hours}${minutes}${seconds} ${username}+${size}.txt`; //Include seconds
        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_bookmarked', 'kudoshistory_skipped', 'kudoshistory_seen', 'kudoshistory_checked']
                    .map(key => (String(localStorage.getItem(key)) || '').replace(strip, '').split(',').length);
                var sizes_after = ['kudosed', 'bookmarked', 'skipped', 'seen', 'checked']
                    .map(key => (String(importedData[key]) || '').replace(strip, '').split(',').length);

                localStorage.setItem('kudoshistory_kudosed',    importedData.kudosed);
                localStorage.setItem('kudoshistory_bookmarked', importedData.bookmarked);
                localStorage.setItem('kudoshistory_skipped',    importedData.skipped);
                localStorage.setItem('kudoshistory_seen',       importedData.seen);
                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);
    }


// --------------------------------------------------------------------------
//                       Monitor+Enhance seen button
// --------------------------------------------------------------------------

if (ENHANCED_SEEN_BUTTON) {
    let wasClicked = false;

    // Step 1: Wait for the button to exist and click it if it shows "Seen"
    function waitForSeenButton() {
        const delays = [0, 100, 250, 500, 1000, 2000]; // exponential delays in ms
        //console.log("Planned delays (ms):", delays);

        let step = 0;
        function tryCheckButton() {
            //console.log('Attempt', step + 1);
            const seenButton = document.querySelector('.kh-seen-button a');

            if (seenButton) {
                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;
                }
                setupButtonObserver();
            } else if (step < delays.length - 1) {
                step++;
                setTimeout(tryCheckButton, delays[step]);
            }
        }
        setTimeout(tryCheckButton, delays[0]);
    }

    // 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;

        const UNSEEN   = 'Unseen';
        const SEEN     = 'Seen';
        const SEEN_NOW = 'Seen<em style="font-size: 0.85em;"> new</em>';
        const SEEN_OLD = 'Seen<em style="font-size: 0.85em;"> old</em>';

        // const UNSEEN   = "UNSEEN [<strong>mark</strong>]";
        // const SEEN     = "SEEN [<strong>unmark</strong>]";
        // const SEEN_NOW = "SEEN NOW [<strong>unmark</strong>]";
        // const SEEN_OLD = "SEEN OLD [<strong>unmark</strong>]";

        // Ignore changes we made ourselves.
        // (Since this is a mutation callback it is called again after modifying the button below.)
        if (buttonElement.innerHTML === UNSEEN   ||
            buttonElement.innerHTML === SEEN     ||
            buttonElement.innerHTML === SEEN_NOW ||
            buttonElement.innerHTML === SEEN_OLD) {
            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;
        }

        currentSeenState = state_seen;

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

        buttonElement.innerHTML =
          state_seen ?
            (isFirst ?
              (wasClicked ? SEEN_NOW : SEEN_OLD) :
              SEEN) :
            UNSEEN;

        const color = state_seen ? (isFirst && !wasClicked ? GREEN_DARKER : GREEN) : RED;
        buttonElement.classList.add('khx-seen-btn')
        buttonElement.style.backgroundColor = color;
        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;
        }
    }

  // waitForSeenButton() polls the button, only do that on /works/
  const isWork = /^\/(?:collections\/[^\/]+\/)?works\/\d+(?:\/chapters\/\d+)?\/?$/.test(location.pathname);
  // Start the process
  if (isWork) waitForSeenButton();
}
})();
长期地址
遇到问题?请前往 GitHub 提 Issues。