Extend "AO3: kudosed seen history": Export/Import, Redesign, Network fix | AO3 Light/Dark toggle

Add Export/Import buttons history to TXT. Fix back-navigation not collapsed. Redesigned skipped+seen combo button. Enhanced title. External links: do not mark seen on open. | AO3 Standalone: Light/Dark site-skin button.

目前为 2025-05-24 提交的版本,查看 最新版本

// ==UserScript==
// @name         Extend "AO3: kudosed seen history": Export/Import, Redesign, Network fix | AO3 Light/Dark toggle
// @description  Add Export/Import buttons history to TXT. Fix back-navigation not collapsed. Redesigned skipped+seen combo button. Enhanced title. External links: do not mark seen on open. | AO3 Standalone: Light/Dark site-skin button.
// @author       C89sd
// @version      1.46
// @match        https://archiveofourown.org/*
// @grant        GM_addStyle
// @namespace    https://greasyfork.org/users/1376767
// @run-at       document-start
// @noframes
// ==/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" ];


const DEBUG = false;

// ------------------------------------------------------------
//  Setup
// ------------------------------------------------------------

const workIdRegex = /^https:\/\/archiveofourown\.org(?:\/collections\/[^\/]+)?\/works\/(\d+)/;
const _match = workIdRegex.exec(document.URL);
const isWork = !!_match;
const workId = _match?.[1]; // undefined if no match

// ------------------------------------------------------------
//  Interecept Kudos Network Request Spam
// ------------------------------------------------------------
// To detect if the user has left a kudos, the script "AO3: Kudosed and seen history"
// makes 20x '/works/ID/kudos network' request when opening a page with unkown works.
// Those request seem to make AO3 block you.
// This intercepts those requests to return empty responses saying you didn't kudo it.

const originalXHRopen = XMLHttpRequest.prototype.open;
const originalXHRsend = XMLHttpRequest.prototype.send;

XMLHttpRequest.prototype.open = function(method, url, ...args) {
    this._xhrMethod = method;
    this._xhrUrl = url;
    return originalXHRopen.apply(this, arguments);
};
XMLHttpRequest.prototype.send = function(body) {
  const { _xhrUrl: url, _xhrMethod: method } = this;
  if (url && typeof url === 'string' && /\/works\/\d+\/kudos/.test(url)) {
    if (DEBUG) console.log(`%c[ExtendAO3KH]%c BLOCKED KUDOS CHECK - Method: ${method}, URL: ${url}`, 'color: #B22222;');

    this.status = 200;
    this.readyState = 4;
    this.responseText = '<div></div>'; // KH looks for #kudos with your username, it will think you didn't kudo this.

    setTimeout(() => {
      if (typeof this.onload === 'function') this.onload.call(this);
      if (typeof this.onreadystatechange === 'function') this.onreadystatechange.call(this);
    }, 0);
    return; // Request was blocked, so we stop here.
  }

  // if (url?.includes('/hit_count.json') && method === 'POST') { this.status = 200; this.readyState = 4; this.responseText = ''; setTimeout(() => { this.onload?.call(this); this.onreadystatechange?.call(this); }, 0); return; }

  if (DEBUG) console.log(`%c[ExtendAO3KH]%c Unblocked Request: Method: ${method}, URL: ${url}`, 'color: #9400D3;');
  if (DEBUG) console.trace(`%c[ExtendAO3KH]%c Call Stack for /hit_count.json:`, 'color: #9400D3;');

  return originalXHRsend.apply(this, arguments); // Allow the original send method to proceed.
};

//---------------------------------------------------------------------------
//  Start @run-at document-start patch, run after KH for '.kh-seen-button'
//---------------------------------------------------------------------------

addEventListener("load", (event) => {

//---------------------------------------------------------------------------
//  Local Storage
//---------------------------------------------------------------------------

const khx_version = '2.02'; // Min: @version	2.2.1

// Patch for the fact that Min's script only stores version on !username,
// but out Import function restores the username, so it can be left unset.
//
//    @TODO: Export the version, and when restoring set it to '2.02' if missing.
//
let stored_version = localStorage.getItem('kudoshistory_lastver');
if (!stored_version) {
  localStorage.setItem('kudoshistory_lastver', khx_version);
  stored_version = khx_version;
}

// Version mismatch safety.
let same_version = true;
if (khx_version < stored_version) {
  same_version = false;
  const message_key = 'khx_version_mismatch_' + khx_version;
  const message_seen = localStorage.getItem(message_key) || "false";
  if (message_seen === "false") {
    alert(`
[ExtendAO3KH][ERROR] Version mismatch with Min's "AO3: Kudosed and seen history".\n
min 's script version = ${stored_version}
extend script version = ${khx_version}\n
Writing has been disabled to prevent accidental overwrite in case the data storage changed. The script will need to be reviewed and updated.\n
This message will not repeat.
`)
    localStorage.setItem(message_key, "true");
  }
  console.log('[ExtendAO3KH] Writing disabled: version mismatch', khx_version, stored_version)
}

// Modified from @Min_ https://greasyfork.org/en/scripts/5835-ao3-kudosed-and-seen-history
// note: removed the 200_000 chars cutoff, we can store ~5_000_000 chars
//       KH loses entries above 25_000...
const KHList = {
  init: function(name) {
    this.name = name;
    this.list = localStorage.getItem('kudoshistory_' + this.name) || ',';
    return this;
  },
  reload: function() {
    this.list = localStorage.getItem('kudoshistory_' + this.name) || ',';
    return this;
  },
  save: function() {
    if (same_version) localStorage.setItem('kudoshistory_' + this.name, this.list);
    return this;
  },
  hasId: function(work_id) {
    if (this.list.indexOf(',' + work_id + ',') > -1) {
      // this.list = ',' + work_id + this.list.replace(',' + work_id + ',', ','); // Pointless if there isn't a cutoff.
      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;
  },
};

// -----------------------------------------------------------------------
//                     Add Skip Button on Work page
// -----------------------------------------------------------------------

let currentSkipState;
let skipped;

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 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);
}

if (isWork && workId) {
  const H = 0.24; // Vertical padding
  const W = 0.5;  // Horizontal padding
  const R = 0.25; // Radius

  const DM = window.getComputedStyle(document.body).color.match(/\d+/g)[0] > 128;
  if (DM) {
    document.body.classList.add('khx-dark-mode');
  } else {
    document.body.classList.remove('khx-dark-mode');
  }

  GM_addStyle(`
    .khx-skip-btn {
      padding: ${H}em ${W}em !important;
      background-clip: padding-box !important;
      border-radius: ${R}em 0 0 ${R}em !important;
      border-right: 0px !important;
    }
    .khx-seen-btn {
      border-radius: 0 ${R}em ${R}em 0 !important;
      padding: ${H}em ${W}em !important;
      background-clip: padding-box !important;
      width: 8ch !important;
    }
    .hkx-skipped {
      background-color: rgb(238, 151, 40) !important;
      padding: ${H}em ${W}em !important;
    }

    /* --- LIGHT MODE --- */
    .khx-skip-btn,
    .khx-seen-btn {
      linear-gradient(#aaa 0%,#b8b8b8 100%, #5a5a5a 100%) !important
      inset 0 -0.1px 0 0px rgb(0, 0, 0), inset 0 -5.5px 0.5px rgba(0, 0, 0, 0.025), inset 0 -3px 0px rgba(0, 0, 0, 0.03), inset 0 5.5px 0.5px rgba(255, 255, 255, 0.06), inset 0 3px 0px rgba(255, 255, 255, 0.07) !important;
      background-blend-mode: soft-light !important;
      background-image: linear-gradient(#eee 0%,#bbb 95%, #b8b8b8 100%) !important;
    }

    /*
    .khx-skip-btn,
    .khx-seen-btn {
      box-shadow: inset 0 -0.5px 0px rgba(0, 0, 0, 0.3), inset 0 0px 3.5px rgba(73, 73, 73, 0), inset 0 -3px 0px rgba(234, 234, 234, 0.12), inset 0 5.5px 3px rgba(255, 255, 255, 0.07), inset 0 3px 3px rgba(255, 255, 255, 0.17) !important;
      background-blend-mode: overlay !important;
      background-image: linear-gradient(#949494 0%,#777 95%, #707070 100%) !important;
    }
    */

    /* --- DARK MODE --- */
    body.khx-dark-mode .khx-skip-btn,
    body.khx-dark-mode .khx-seen-btn {
      box-shadow: inset 0 -0.5px 0px rgba(0, 0, 0, 0.9), inset 0 -5.5px 0.5px rgba(0, 0, 0, 0.025), inset 0 -3px 0px rgba(0, 0, 0, 0.03), inset 0 5.5px 0.5px rgba(255, 255, 255, 0.06), inset 0 3px 0px rgba(255, 255, 255, 0.07) !important;
      background-blend-mode: multiply !important;
      background-image: linear-gradient(#eee 0%,#bbb 95%, #111 100%) !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.

const passStorage = localStorage; // Note: sessionStorage repeats the dame value on navback!

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

if (ENHANCED_SEEN_BUTTON) {
  let refererData = {}; // {} | { workId + 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;
  let clickedLinkNewSeen = false;

  // 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 : document.URL).match(workIdRegex);
    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.
        passStorage.setItem("C89AO3_state", JSON.stringify({"workId": match[1], "seenState": (clickedLinkSeen), "skipState": (currentSkipState), "newSeen": (clickedLinkNewSeen)}));
      } else if (currentSeenState === null && refererData?.workId === match[1]) {
        // Carry seenState back over adult content warning pages (they have no seen button).
        passStorage.setItem("C89AO3_state", JSON.stringify({"workId": match[1], "seenState": (refererData?.seenState), "skipState": (refererData?.skipState), "newSeen": (refererData?.newSeen)}));
      } else {
        passStorage.setItem("C89AO3_state", JSON.stringify({"workId": match[1], "seenState": (currentSeenState), "skipState": (currentSkipState), "newSeen": (clickedLinkNewSeen)}));
      }
    }
    else {
      passStorage.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 = passStorage.getItem("C89AO3_state");
    refererData = JSON.parse(data ? data : '{}');

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

  // Blink functionality.
  GM_addStyle(`
    @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); }
    }
    /* Slide down when opening */
    li[role="article"]:not(.marked-seen).blink div.header.module { transition: all 0.25s ease-out; }
    /* Blink border */
    li.blink { animation: flash-glow 0.25s ease-in 1; }
  `);

  let blinkTimeout;
  function blink(article) {
    if (DEBUG) 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) => {
    if (event.persisted) { // If we navigated back.

      if (refererData?.workId) { // 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*="/works/${refererData.workId}"]`);
        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) {
    const article = e.target.closest('li[role="article"]');
    if (article) {
      if (e.target.textContent === 'seen') {
        blink(article);
        if (DEBUG) console.log("click (floating seen .kh-toggle) ", e.target)
      }
      if (e.target.textContent === 'skipped') {
        blink(article);
        if (DEBUG) console.log("click (floating skipped .kh-toggle) ", e.target)
      }
    }
  }
  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) {
    if (DEBUG) 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')) {
            clickedLinkNewSeen = true;
            seenButton.click();
          } else {
            clickedLinkNewSeen = false;
          }

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

}

// -----------------------------------------------------------------------
//                      Light / Dark Skin Toggle
// -----------------------------------------------------------------------

async function toggleLightDark(e) {
  e.target.disabled = true;
  e.target.style.filter = 'brightness(30%)';

  // Get the username
  const greetingEl = document.querySelector('#greeting a');
  if (!greetingEl) {
    alert('[ExtendAO3KH][ERROR][light/dark toggle] username not found in top right corner "Hi, $user!"');
    return;
  }
  const user = greetingEl.href.split('/').pop();

  // ---------- GET preferences

  let html = await fetch(`https://archiveofourown.org/users/${user}/preferences`, {
    credentials: 'include'
  }).then(response => {
    if (response.ok) return response.text();
    alert('[ExtendAO3KH][ERROR][light/dark toggle] preferences !ok Error: ' + response.status);
    return null;
  }).catch(err => {
    alert('[ExtendAO3KH][ERROR][light/dark toggle] preferences Network Error: ' + err.message);
    return null;
  });
  if (!html) return;

  // ---------- Find nextSkinId

  const parser = new DOMParser();
  const doc = parser.parseFromString(html, 'text/html');

  const form = doc.querySelector('.edit_preference');
  if (!form) { alert(`[ExtendAO3KH][ERROR][light/dark] form not found`); throw new Error("form not found"); }

  const form_url = form.getAttribute('action');
  if (!form_url) { alert(`[ExtendAO3KH][ERROR][light/dark] form_url not found`); throw new Error("form_url not found"); }
  // const form_url2 = 'https://archiveofourown.org' + form_url;

  const skin_list = form.querySelector('#preference_skin_id');
  if (!skin_list) { alert(`[ExtendAO3KH][ERROR][light/dark] skin_list not found`); throw new Error("skin_list not found"); }

  const options = Array.from(skin_list.options);
  // options.forEach(opt => { console.log(`Skin: ${opt.text}, Value: ${opt.value}, Selected: ${opt.selected}`); });
  const currentSkinName = options.find(opt => opt.selected)?.text || null;

  let nextSkinName;
  if (!currentSkinName) {
    nextSkinName = SITE_SKINS[0];
    alert(`[ExtendAO3KH][INFO][light/dark] no skin selected, applying first skin "${nextSkinName}"`)
  } else {
    const currentIndex = SITE_SKINS.indexOf(currentSkinName);
    if (currentIndex === -1) {
      nextSkinName = SITE_SKINS[0];
      alert(`[ExtendAO3KH][INFO][light/dark] "${currentSkinName}" is not part of the cycle, applying first skin "${nextSkinName}"`)
    } else {
      nextSkinName = SITE_SKINS[(currentIndex + 1) % SITE_SKINS.length];
    }
  }

  const nextSkinOption = options.find(opt => opt.text === nextSkinName);
  if (!nextSkinOption) {
    alert(`[ExtendAO3KH][ERROR][light/dark] next skin "${nextSkinName}" has an invalid name, it does not exist in the preferences list`);
    return;
  }
  const nextSkinId = nextSkinOption.value;

  // ---------- POST form

  // Note: the form's token doesn't work, this one does.
  const authenticity_token2 = document.querySelector('meta[name="csrf-token"]')?.content;
  if (!authenticity_token2) { alert(`[ExtendAO3KH][ERROR][light/dark] authenticity_token2 not found.`); throw new Error("Authenticity token 2 not found."); }

  // Emulate the form data at https://archiveofourown.org/users/$user/skins
  const formData = new URLSearchParams();
  formData.append('_method', 'put');
  formData.append('authenticity_token', authenticity_token2);
  formData.append('preference[skin_id]', nextSkinId);
  formData.append('commit', 'Use'); // skin 'Use' VS pref 'Update'?

  let reload = false;
  await fetch(form_url, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: formData.toString(),
    credentials: 'include',
    redirect: 'manual'
  }).then(response => {
    // For some reason does a redirect !ok, so we kill the redirect and treat it as ok.
    if (response.type === 'opaqueredirect' || response.ok) {
      reload = true;
      return;
    }
    else alert('[ExtendAO3KH][ERROR][light/dark toggle] skins !ok Error: ' + response.status);
  }).catch(err => {
    alert('[ExtendAO3KH][ERROR][light/dark toggle] skins Network Error: ' + err.message);
  });

  e.target.disabled = false;
  e.target.style.filter = '';
  if (reload) window.location.reload();
}

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

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("[ExtendAO3KH] Success" + notes);
    } catch (error) {
        alert("[ExtendAO3KH] 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"
  // Manual polling, this should really be a MutationObserver on the container.
  function waitForSeenButton() {
    const delays = [0, 100, 250, 500, 1000, 2000]; // exponential delays in ms
    if (DEBUG) console.log("Planned delays (ms):", delays);

    let step = 0;
    function tryCheckButton() {
      if (DEBUG) 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
  // Monitor any clicks made to the button and call the toggleButtonText function.
  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);
        }
      });
    });

    observer.observe(targetNode, {
      childList: true,
      characterData: true,
      subtree: true
    });
  }

  // This is a hack to get Seen new on first click,
  // 'load' fires before 'pageshow'
  // 'pageshow' fires on bfcache but 'load doesnt'
  // So we duplicate this code from pageshow.
  let newSeen;
  if (isWork) {
    let data = passStorage.getItem("C89AO3_state");
    let refererData2 = JSON.parse(data ? data : '{}');
    newSeen = (refererData2.workId == workId) && refererData2.newSeen;
  }

  // When the button is clicked, "AO3 Kudosed History" will change its text.
  // We monitor changes tot he button and read the text value to determine its new state.
  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('[ExtendAO3KH]\nUnknown text: ' + buttonElement.textContent);
      return;
    }

    currentSeenState = state_seen; // Keep track of current state for when we nagivate back.

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

    if (newSeen) {
      wasClicked = true;
      newSeen = false;
    }

    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/
  // Start the process
  if (isWork) waitForSeenButton();

}
//---------------------------------------------------------------------------
//  End @run-at document-start patch
//---------------------------------------------------------------------------

});