KHX for "AO3: Kudosed and seen history" + Light/Dark skin toggle

Can work as an extension or independent replacement. History Export/Import buttons. Live updates (collapse on back-navigation, alt-tab...). Redesigned Skipped/Seen combo button. Enhanced title. Mark-open ignores external links. | AO3 Standalone: Light/Dark site-skin button.

// ==UserScript==
// @name         KHX for "AO3: Kudosed and seen history" + Light/Dark skin toggle
// @description  Can work as an extension or independent replacement. History Export/Import buttons. Live updates (collapse on back-navigation, alt-tab...). Redesigned Skipped/Seen combo button. Enhanced title. Mark-open ignores external links. | AO3 Standalone: Light/Dark site-skin button.
// @author       C89sd
// @version      2.10
// @match        https://archiveofourown.org/*
// @grant        GM_addStyle
// @namespace    https://gf.qytechs.cn/users/1376767
// @run-at       document-start
// @noframes
// ==/UserScript==
'use strict';

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

const khx_version = '2.03'; // Min: @version	2.3
// 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.03' 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_major_version = true;
if (khx_version[0] < stored_version[0]) {
  same_major_version = false;
  const message_key = 'khx_version_mismatch_'+khx_version+stored_version;
  const message_seen = localStorage.getItem(message_key) || "false";
  if (message_seen === "false") {
    alert(`[ExtendAO3KH][ERROR] 𝗠𝗮𝗷𝗼𝗿 𝘃𝗲𝗿𝘀𝗶𝗼𝗻 𝗺𝗶𝘀𝗺𝗮𝘁𝗰𝗵 with Min's "AO3: Kudosed and seen history".\n\nmin 's script version = ${stored_version}\nextend script version = ${khx_version}\n\n𝗪𝗿𝗶𝘁𝗶𝗻𝗴 𝗵𝗮𝘀 𝗯𝗲𝗲𝗻 𝗱𝗶𝘀𝗮𝗯𝗹𝗲𝗱 to prevent accidental overwrite in case the data storage changed. The script will need to be reviewed and updated.\n\nThis message will not repeat.`)
    localStorage.setItem(message_key, "true");
  }
  console.log('[ExtendAO3KH] Writing disabled: version mismatch', khx_version, stored_version)
}

// Modified from @Min_ https://gf.qytechs.cn/en/scripts/5835-ao3-kudosed-and-seen-history
class KHList {
  constructor(name) {
    this.name = name;
    this.list = undefined;
  }
  load() {
    this.list = localStorage.getItem('kudoshistory_' + this.name) || ','
    return this
  }
  save() {
    if (same_major_version) localStorage.setItem('kudoshistory_' + this.name, this.list)
    return this
  }
  hasId(work_id) {
    return this.list.indexOf(',' + work_id + ',') > -1
  }
  add(work_id) {
    this.list = ',' + work_id + this.list.replace(',' + work_id + ',', ',')
    return this
  }
  remove(work_id) {
    this.list = this.list.replace(',' + work_id + ',', ',')
    return this
  }
  toggleAndSave(work_id) {
    if (!(typeof work_id === "string" && /^\d+$/.test(work_id))) throw new Error("invalid work_id");
    this.load()
    if (this.hasId(work_id)) {
      this.remove(work_id).save()
      return false
    } else {
      this.add(work_id).save()
      return true
    }
  }
}

//---------------------------------------------------------------------------
//  Works
//---------------------------------------------------------------------------
let idRegex = /\/works\/(\d+)/
function getWorkId(str) { return idRegex.exec(str)?.[1] }

const seenList    = new KHList('seen');
const skippedList = new KHList('skipped');

let workId
let seen    = false;
let skipped = false;
let skipBtn, seenBtn;

function doWork() {
  workId = getWorkId(window.location.pathname) ?? getWorkId(document.querySelector('.share a[href]').getAttribute('href'))
  if (!workId) throw new Error('!workId')

  GM_addStyle(
    '.kh-seen-button { display: none !important; }' + // hide KH seen button
    '.khx-green     { background-color: #33cc70 !important; }' +
    '.khx-darkgreen { background-color: #00a13a !important; }' +
    '.khx-red       { background-color: #ff6d50 !important; }' +
    (CONFIG.colorTitle ? (
      '.khx-title-base  { color: #ff6d50 !important; }' +
      '.khx-title-green { color: #33cc70 !important; }' +
      '.khx-title-skipped { text-decoration: line-through !important; text-decoration-color: rgb(238, 151, 40, 180) !important; text-decoration-thickness: 2.5px !important; }'
    ) : '')
  );

  // Color title
  if (CONFIG.colorTitle) {
    const title = document.querySelector('h2.title.heading');
    if (title) {
      workTitleLink = document.createElement('a');
      workTitleLink.href = window.location.origin + window.location.pathname + window.location.search; // Keep "?view_full_work=true", drop "#summary"
      while (title.firstChild) workTitleLink.appendChild(title.firstChild);
      title.appendChild(workTitleLink);
      workTitleLink.classList.add('khx-title-base');
      if (seen)    greenTitle()
      if (skipped) skippedTitle(true)
    }
  }

  // Skipped/Seen combo button
  const H = 0.24 // height
  const W = 0.5  // width
  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 { padding: ${H}em ${W}em !important; background-clip: padding-box !important; border-radius: 0 ${R}em ${R}em 0 !important; width: 8ch !important; }
.khx-skipped  { padding: ${H}em ${W}em !important; background-color: rgb(238, 151, 40) !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;
}
/*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;
}`)

  let container = document.createElement('div')
  container.style.display = 'inline-block'
  skipBtn = document.createElement('a')
  skipBtn.className = 'khx-skip-btn'
  skipBtn.addEventListener('click', doSkipBtn)
  seenBtn = document.createElement('a')
  seenBtn.className = 'khx-seen-btn'
  seenBtn.addEventListener('click', doSeenBtn)
  container.append(skipBtn, seenBtn)

  seen    = seenList   .load().hasId(workId)
  skipped = skippedList.load().hasId(workId)
  updateSkipSeenBtn(true)

  document.querySelector('li.bookmark').insertAdjacentElement('afterend', container)
}

let workTitleLink
function greenTitle() { if (CONFIG.colorTitle) workTitleLink.classList.add('khx-title-green') }
function redTitle()   { if (CONFIG.colorTitle) workTitleLink.classList.remove('khx-title-green') }
function skippedTitle(s) { if (CONFIG.colorTitle) {
    if (s) workTitleLink.classList.add('khx-title-skipped')
    else   workTitleLink.classList.remove('khx-title-skipped')
  }
}

function updateSkipSeenBtn(firstUpdate=false) {
  if (skipped) {
    skipBtn.textContent = 'skipped'
    skipBtn.classList.add('khx-skipped');
  } else {
    skipBtn.textContent = ''
    skipBtn.classList.remove('khx-skipped');
  }
  skippedTitle(skipped)

  seenBtn.classList.remove('khx-green', 'khx-darkgreen', 'khx-red')

  let newSeen = false;
  if (firstUpdate) {
    const isReload = performance.getEntriesByType("navigation")[0]?.type === 'reload'
    if (!seen && CONFIG.autoseen && !isReload && (document.referrer.includes('archiveofourown.org') || CONFIG.seeExternalLinks)) {
      seen = seenList.toggleAndSave(workId)
      newSeen = true
    } else if (seen) {
      let savedId = localStorage.getItem('khx_newid') || 0
      localStorage.setItem('khx_newid', 0)
      newSeen = (savedId === workId)
    }
  }

  if (seen) {
    if (newSeen)           { seenBtn.classList.add('khx-green');     seenBtn.innerHTML = 'Seen<em style="font-size: 0.85em;"> new</em>' }
    else if (!firstUpdate) { seenBtn.classList.add('khx-green');     seenBtn.innerHTML = 'Seen' }
    else                   { seenBtn.classList.add('khx-darkgreen'); seenBtn.innerHTML = 'Seen<em style="font-size: 0.85em;"> old</em>' }
    greenTitle()
  }
  else {
    seenBtn.classList.add('khx-red'); seenBtn.innerHTML = 'Unseen'
    redTitle()
  }
}

function doSkipBtn() {
  skipped = skippedList.toggleAndSave(workId)
  updateSkipSeenBtn()
}
function doSeenBtn() {
  seen = seenList.toggleAndSave(workId)
  updateSkipSeenBtn()
}

//---------------------------------------------------------------------------
//  Forum
//---------------------------------------------------------------------------
let last = 0;
function refreshSeenSkipped(forced = false) {
  // Debounce focus+visibility calls
  let now = Date.now();
  if (!forced && now - last < 500) return;
  last = now;

  if (isWork) {
    seen    = seenList   .load().hasId(workId)
    skipped = skippedList.load().hasId(workId)
    updateSkipSeenBtn()
  } else {
    seenList.load()
    skippedList.load()

    for (let article of document.getElementsByClassName('blurb')) { if (article.className.indexOf('work-') !== -1) {
      let titleLink = article.querySelector('h4.heading > a')
      if (titleLink) {
        let id = getWorkId(titleLink.getAttribute('href'))
        if (id) {
          let see = seenList.hasId(id)
          if (see  !== article.classList.contains('marked-seen'))  { blink(article); markSeen(article, see) }
          let skip = skippedList.hasId(id)
          if (skip !== article.classList.contains('skipped-work')) { blink(article); markSkipped(article, skip) }
        }
      }
    }}
  }
}

function doForum() {
  if (CONFIG.KHXonly) {
/* DIFF To put back uncollpased left margin:
+  .marked-seen, .skipped-work {padding-left:37px!important;}
-  .khx-collapsed {padding-left:37px!important;}
- .skipped-work:not(.khx-collapsed)::before,.marked-seen:not(.khx-collapsed)::before {padding-left:26.5px!important;}
*/
    GM_addStyle(`
.khx-collapsed {padding-left:37px!important;}
.marked-seen {background-image:linear-gradient(#ddd 0,#ddd 100%)!important;background-repeat:repeat-y!important;background-position:left!important;background-size:25px 100%!important;}

.khx-collapsed .required-tags {transform:scale(0.44)!important;top:-3px!important;left:0!important;margin:0;padding:0;transform-origin:0 0;}

.khx-collapsed .header {min-height:10px!important;}
.marked-seen .heading, .skipped-work .heading                             {margin-left:65px!important;}
.marked-seen.khx-collapsed .heading, .skipped-work.khx-collapsed .heading {margin-left:calc(65px - 26.5px)!important;}

.khx-collapsed>*:not(.header.module,.khx-toggle,.user.module.group,:has(>#bookmark-form)),.khx-collapsed .fandoms.skipped-work.khx-collapsed>*:not(.khx-toggle),.skipped-work .fandoms {display:none!important;}
.user.module.group>h5 { margin: 0 !important }
.user.module.group>.datetime { position: static !important; float: left !important; }

.skipped-work>*:not(.khx-toggle)     {opacity:0.6!important;}
.khx-toggle-seen,.khx-toggle-skipped {opacity:0.5!important;border:none!important;display:block!important;line-height:18px!important;text-decoration:none!important;}
.khx-toggle-dark                     {opacity:1.0!important;}

.skipped-work:hover,.marked-seen:hover {cursor:zoom-out;}
.khx-collapsed:hover                   {cursor:zoom-in;}

.skipped-work.marked-seen {background-image:linear-gradient(#dddddd44 0,#dddddd44 100%)!important;}
.skipped-work::before                    {content:"Skipped";       font-size:14px!important;}
.marked-seen:not(.khx-collapsed)::before {content:"Seen";          font-size:14px!important;}
.skipped-work.marked-seen::before        {content:"Skipped / Seen";font-size:14px!important;}

.skipped-work:not(.khx-collapsed),.marked-seen:not(.khx-collapsed) {background:linear-gradient(#dddddd55 0,#dddddd55 100%) top 6px left/100% 17px repeat-x!important;}
.skipped-work:not(.khx-collapsed)::before,.marked-seen:not(.khx-collapsed)::before {padding-left:26.5px!important;}
@media (max-width:650px){
  .skipped-work:not(.khx-collapsed)::before,.marked-seen:not(.khx-collapsed)::before{line-height:30px!important;}
  .skipped-work:not(.khx-collapsed),.marked-seen:not(.khx-collapsed){background:linear-gradient(#dddddd55 0,#dddddd55 100%) top 11px left/100% 17px repeat-x!important;}
}`)

    let BORDER
    let toggle
    let first = true;
    for (let article of document.getElementsByClassName('blurb')) { if (article.className.indexOf('work-') !== -1) {
      if (first) {
        first = false

        BORDER = window.getComputedStyle(article).border;

        toggle = document.createElement('div');
        toggle.className = 'khx-toggle';
        toggle.style.cssText = 'position:absolute;top:-19px;right:0;font-size:12px;display:flex;';

        // "Skipped"
        const skipSpan = document.createElement('span');
        skipSpan.style.border = BORDER;
        skipSpan.style.borderBottom = 'none';
        skipSpan.style.borderRight = 'none';
        const skipLink = document.createElement('span');
        skipLink.className = 'khx-toggle-skipped';
        skipLink.textContent = 'skipped';
        skipLink.style.padding = '0 6px';
        skipLink.addEventListener('click', e => e.preventDefault());
        skipSpan.appendChild(skipLink);

        // "Seen"
        const seenSpan = document.createElement('span');
        seenSpan.style.border = BORDER;
        seenSpan.style.borderBottom = 'none';
        const seenLink = document.createElement('span');
        seenLink.className = 'khx-toggle-seen';
        seenLink.textContent = 'seen';
        seenLink.style.padding = '0 16px';
        seenLink.addEventListener('click', e => e.preventDefault());
        seenSpan.appendChild(seenLink);

        toggle.append(skipSpan, seenSpan);
      }
      article.style.position = 'relative';
      article.style.marginTop = '25px';
      article.prepend(toggle.cloneNode(true));
    }}
  }

  // Blink CSS
  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:not(.marked-seen).blink div.header.module { transition: all 0.3s ease-out; }
/* Blink border */ li.blink { animation: flash-glow 0.3s ease-in 1; }
`);
  attachSeenSkippedClick()
  attachBgToggleTitleClick()
}

let blinkTimeout;
function blink(article) {
  clearTimeout(blinkTimeout);
  article.classList.remove('blink');

  void article.offsetWidth; // reflow

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

function attachSeenSkippedClick() {
  if (CONFIG.KHXonly) return;
  // KH calls event.stopPropagation() so document.addEventListener('click') wouldn't work
  function attachListeners() {
    // 100ms delay after load to ensure .kh-toggle elements are created
    setTimeout(() => {
      document.querySelectorAll('.kh-toggle').forEach(el => {
        if (!el.__khxAttached) {
          el.addEventListener('click', onToggleClick, true);
          el.__khxAttached = true;
        }
      });
    }, 100);
  }
  // 'load' delay to let the .kh-toggle be created
  if (document.readyState === 'loading') {
    document.addEventListener('load', attachListeners);
  } else {
    attachListeners();
  }
}

function onToggleClick(e) {
  const article = e?.target?.closest('li[role="article"]');
  const titleLink = e?.target?.closest('h4.heading > a');
  let id = getWorkId(titleLink.getAttribute('href'))

  if (e.target.textContent === 'skipped') {
    blink(article);
    markSkipped(article, skippedList.toggleAndSave(id))
  }
  else if (e.target.textContent === 'seen') {
    blink(article);
    markSeen(article, seenList.toggleAndSave(id))
  }
  e.stopPropagation()
}

function attachBgToggleTitleClick() {
  document.addEventListener('click', function(e) {
    const article = e?.target?.closest('li[role="article"]');
    const titleLink = e?.target?.closest('h4.heading > a');

    if (article && titleLink) {
      if (CONFIG.autoseen && !article.classList.contains('marked-seen')) {
        let id = getWorkId(titleLink.getAttribute('href'))
        seen = seenList.toggleAndSave(id)
        localStorage.setItem('khx_newid', id)
        markSeen(article, true)
      }
      blink(article);
    }
    else if (article)
    {
      if (CONFIG.KHXonly) {
        let id = getWorkId(article.querySelector('h4.heading > a').getAttribute('href'))
        if (e.target.closest('.khx-toggle-skipped')) {
          blink(article);
          markSkipped(article, skippedList.toggleAndSave(id))
        }
        if (e.target.closest('.khx-toggle-seen')) {
          blink(article);
          markSeen(article, seenList.toggleAndSave(id))
        }
      }

      if (e.target.closest('a, p, span')) return; // Uncollapse when clicking the bg
      if (article.classList.contains('marked-seen') || article.classList.contains('skipped-work')) article.classList.toggle('khx-collapsed')
    }
  });
}

function markSeen(article, s) {
  if (!s) {
    if (CONFIG.KHXonly) article.querySelector('.khx-toggle-seen').classList.remove('khx-toggle-dark')
    article.classList.remove('marked-seen')
    if (!article.classList.contains('skipped-work')) article.classList.remove('khx-collapsed')
  } else {
    if (CONFIG.KHXonly) article.querySelector('.khx-toggle-seen').classList.add('khx-toggle-dark')
    article.classList.add('marked-seen')
    article.classList.add('khx-collapsed')
  }
}

function markSkipped(article, s) {
  if (!s) {
    if (CONFIG.KHXonly) article.querySelector('.khx-toggle-skipped').classList.remove('khx-toggle-dark')
    article.classList.remove('skipped-work')
    if (!article.classList.contains('marked-seen')) article.classList.remove('khx-collapsed')
  } else {
    if (CONFIG.KHXonly) article.querySelector('.khx-toggle-skipped').classList.add('khx-toggle-dark')
    article.classList.add('skipped-work')
    article.classList.add('khx-collapsed')
  }
}

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

let SITE_SKINS

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.toLowerCase() || null;

  const Site_Skins = SITE_SKINS.map(skin => skin.toLowerCase());

  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.toLowerCase() === 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();
}

// -----------------------------------------------------------------------
// Export / Import
// -----------------------------------------------------------------------

const strip = /^\[?,?|,?\]?$/g;

function exportToJson() {
  const cleanupChecked = JSON.parse(localStorage.getItem('kudoshistory_settings') || '{}').background_check !== 'yes';
  const maybeChecked = cleanupChecked ? [] : ['checked'];

  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') || ','
  };
  if (cleanupChecked) delete export_lists.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 totalSeconds = now.getMinutes() * 60 + now.getSeconds();
  const minSecCode = `${String(now.getMinutes()).padStart(2,'0')}${Math.floor(now.getSeconds()  / 6)}`
  const user = export_lists.username||'none';

  var size = ['seen', 'skipped', 'bookmarked', 'kudosed',...maybeChecked]
             .map(key => (String(export_lists[key]) || '').replace(strip, '').split(',').length - 1);

  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}.${minSecCode} ${user}+${size}${cleanupChecked?',X':''}.txt`;
  document.body.appendChild(a);
  a.click();
  document.body.removeChild(a);
}

function importFromJson(event) {
  const file = event.target.files[0];
  if (!file) return;

  const reader = new FileReader();

  reader.onload = ({ target }) => {
    try {
      const imported  = JSON.parse(target.result);
      const settings  = JSON.parse(localStorage.getItem('kudoshistory_settings') || '{}');
      const cleanupChecked = settings.background_check !== 'yes';

      const csvLen   = csv => csv.replace(strip, '').split(',').filter(Boolean).length;
      const deltaStr = (o, n) => { const d = n - o; return '(' + (d > 0 ? '+' : '') + d + ')'; };
      const notes = [];
      [
        ['seen',       'kudoshistory_seen'      ],
        ['skipped',    'kudoshistory_skipped'   ],
        ['bookmarked', 'kudoshistory_bookmarked'],
        ['kudosed',    'kudoshistory_kudosed'   ],
        ['checked',    'kudoshistory_checked'   ]
      ].forEach(([name, key]) => {
        const oldVal = String(localStorage.getItem(key) || ',');
        let   newVal = imported[name] !== undefined ? imported[name] : oldVal;
        if (name === 'checked' && cleanupChecked) newVal = ',';
        if (newVal !== oldVal) localStorage.setItem(key, newVal);
        const oldCnt = csvLen(oldVal);
        const newCnt = csvLen(newVal);
        if (name === 'checked' && cleanupChecked) {
          notes.push(`- checked: ${oldCnt} entries cleaned`);
        } else {
          notes.push(`- ${name}: ${newCnt} ${deltaStr(oldCnt, newCnt)}${oldCnt === newCnt ? '' : ' <---- change'}`);
        }
      });
      // username
      if (imported.username && imported.username !== localStorage.getItem('kudoshistory_username')) {
        localStorage.setItem('kudoshistory_username', imported.username);
        notes.push(`- username: set to "${imported.username}"`);
      } else {
        notes.push('- username: no change');
      }
      // settings
      if (imported.settings && imported.settings !== localStorage.getItem('kudoshistory_settings')) {
        const oldObj = JSON.parse(localStorage.getItem('kudoshistory_settings') || '{}');
        const newObj = JSON.parse(imported.settings);
        const added   = {};
        const removed = {};
        Object.keys(newObj).forEach(k => { if (!(k in oldObj) || oldObj[k] !== newObj[k]) added[k] = newObj[k]; });
        Object.keys(oldObj).forEach(k => { if (!(k in newObj)) removed[k] = oldObj[k]; });

        localStorage.setItem('kudoshistory_settings', imported.settings);
        const lines = ['- settings:'];
        if (Object.keys(added).length)   lines.push('    _added   ' + JSON.stringify(added));
        if (Object.keys(removed).length) lines.push('    _removed ' + JSON.stringify(removed));
        notes.push(...lines);
      } else {
        notes.push('- settings: no change');
      }

      alert('[ExtendAO3KH] Success\n' + notes.join('\n'));
    } catch {
      alert('[ExtendAO3KH] Error\nInvalid file format or missing data.');
    }
  };

  reader.readAsText(file);
}

//---------------------------------------------------------------------------
//  Main
//---------------------------------------------------------------------------

let CONFIG
function loadConfig() {
  let DEFAULT = {
    colorTitle: true,
    autoseen: true,
    seeExternalLinks: false,
    siteSkins: 'Default, Reversi',
    KHXonly: false,
  }
  let saved = JSON.parse(localStorage.getItem('khx_config')) || DEFAULT
  const config = { ...DEFAULT, ...saved } // merge new keys

  // Disable Mark as seen always (override)
  let settings = JSON.parse(localStorage.getItem('kudoshistory_settings')) || {};
  if (settings.autoseen === 'yes') {
    settings.autoseen = 'no'
    localStorage.setItem('kudoshistory_settings', JSON.stringify(settings));
  }
  return config
}
function saveConfig() { localStorage.setItem('khx_config', JSON.stringify(CONFIG)); }
function skinArrayFromStr(str) { return str.split(',').map(s => s.trim()).filter(Boolean); }

function showConfigPanel() {
  // Close if already open
  const existing = document.getElementById('config-panel');
  if (existing) { existing.remove(); return; }

  const gear = document.getElementById('gear-btn');

  const panel = document.createElement('div');
  panel.id = 'config-panel';
  panel.innerHTML = `
    <label style="display:flex; align-items:center; gap:4px; white-space:nowrap;"><input type="checkbox" id="toggle-khxonly" ${CONFIG.KHXonly ? 'checked' : ''} ><span title="Fully replace Kudos History.">KHX only</span></label>
    <hr style="margin: 0; border: none; border-top: 1px solid currentColor;">
    <label style="display:flex; align-items:center; gap:4px; white-space:nowrap;"><input type="checkbox" id="toggle-autoseen" ${CONFIG.autoseen ? 'checked' : ''}>Mark as seen on open</label>
    <label style="display:flex; align-items:center; gap:4px; white-space:nowrap;"><input type="checkbox" id="toggle-external" ${CONFIG.seeExternalLinks ? 'checked' : ''}>From external links</label>
    <label>Skins: <input type="text" id="site-skins-input" value="${CONFIG.siteSkins || ''}" style="min-width:160px; width:160px;"  autocapitalize="off"></label>
    <label style="display:flex; align-items:center; gap:4px; white-space:nowrap;"><input type="checkbox" id="toggle-titlecol" ${CONFIG.colorTitle ? 'checked' : ''}>Color title</label>
    `;

  panel.querySelector('#toggle-titlecol').onchange = (e) => { CONFIG.colorTitle       = e.target.checked; saveConfig() }
  panel.querySelector('#toggle-autoseen').onchange = (e) => { CONFIG.autoseen         = e.target.checked; saveConfig() }
  panel.querySelector('#toggle-external').onchange = (e) => { CONFIG.seeExternalLinks = e.target.checked; saveConfig() };
  panel.querySelector('#toggle-khxonly').onchange  = (e) => { CONFIG.KHXonly          = e.target.checked; saveConfig() };
  panel.querySelector('#site-skins-input').onblur = () => {
    CONFIG.siteSkins = panel.querySelector('#site-skins-input').value.trim();
    SITE_SKINS = skinArrayFromStr(CONFIG.siteSkins);
    saveConfig();
  };

  panel.style.cssText = `
    position:fixed; background:#222; color:white; padding:8px;
    display:flex; flex-direction:column; gap:6px; z-index:9999; border-radius:6px;
    border:1px solid #555; min-width:180px;
  `;

  document.body.appendChild(panel);

  function updatePosition() {
    const rect = gear.getBoundingClientRect();
    panel.style.left = rect.left + 'px';
    panel.style.top = (rect.top - panel.offsetHeight - 5) + 'px';
  }

  updatePosition();

  // Update position on scroll
  const scrollHandler = () => updatePosition();
  window.addEventListener('scroll', scrollHandler);

  // Close when clicking outside
  document.addEventListener('click', function closePanel(e) {
    if (!panel.contains(e.target) && e.target !== gear) {
      panel.remove();
      window.removeEventListener('scroll', scrollHandler);
      document.removeEventListener('click', closePanel);
    }
  });
}

function doFooterAndCSS() {
  const footer = document.createElement('div');
  Object.assign(footer.style, {
    width:'100%', padding:'5px 0',
    display:'flex', justifyContent:'center', gap:'10px', alignItems:'center'
  });

  // ⚙ settings
  footer.appendChild(Object.assign(document.createElement('button'), {
    id: 'gear-btn', textContent:'⚙', title:'Settings', onclick: (e) => { e.stopPropagation(); showConfigPanel(); }
  }));

  // Light/Dark toggle
  footer.appendChild(Object.assign(document.createElement('button'), {
    textContent:'Light/Dark',
    onclick: (e) => {
      SITE_SKINS = skinArrayFromStr(CONFIG.siteSkins)
      toggleLightDark(e)
    }
  }));

  // Export / Import (unchanged)
  footer.appendChild(Object.assign(document.createElement('button'), { textContent:'Export', onclick:exportToJson }));
  footer.appendChild(Object.assign(document.createElement('button'), {
    textContent:'Import',
    onclick: () => {
      const fi = Object.assign(document.createElement('input'), { type:'file', accept:'.txt,.json', style:'display:none' });
      fi.addEventListener('change', importFromJson);
      footer.appendChild(fi); fi.click();
    }
  }));

  document.getElementById('footer').before(footer);
}

// --------------- Main

let isWork
addEventListener("DOMContentLoaded", (event) => {
  CONFIG = loadConfig()

  isWork = Boolean(document.getElementById('workskin'))
  doFooterAndCSS()
  if (isWork) doWork()
  else {
    doForum()
    refreshSeenSkipped(true)
  }

  // Apply styles when navigating back
  window.addEventListener('pageshow', (e) => {
    if (e.persisted) refreshSeenSkipped(true);
  });

  // Apply styles on tab change.
  document.addEventListener('focus', () => {
    refreshSeenSkipped();
  });
  document.addEventListener("visibilitychange", () => {
    if (!document.hidden) refreshSeenSkipped();
  });
})

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址