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

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

目前為 2025-06-04 提交的版本,檢視 最新版本

// ==UserScript==
// @name         KHX for "AO3: Kudosed and seen history" + Light/Dark skin toggle
// @description  Can work as an extension or partial replacement. History Export/Import buttons. Live updates (fix back-navigation not collapsing, 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.04
// @match        https://archiveofourown.org/*
// @grant        GM_addStyle
// @namespace    https://greasyfork.org/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://greasyfork.org/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] }

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

  hideKHSeenBtn()
  addSkipSeenBtnAndTitle()
}

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

let seen    = false;
let seenManual = false;
let skipped = false;
let skipBtn, seenBtn;
function hideKHSeenBtn() { GM_addStyle('.kh-seen-button { display: none !important; }') }
function addSkipSeenBtnAndTitle() {
  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)
    }
  }

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

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

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

  /* 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(isWork, forced = false) {
  // Debounce focus+visibility calls
  let now = Date.now();
  if (!forced && now - last < 500) return;
  last = now;

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

    let articles = document.querySelectorAll('li.work[role="article"], li.bookmark[role="article"]')
    for (let article of articles) {
      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) {
    GM_addStyle(`
    .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; padding-left:37px!important}
    .skipped-work{padding-left:37px!important}

    .skipped-work.marked-seen {background-image:linear-gradient(#dddddd44 0,#dddddd44 100%) !important;}

    .khx-collapsed>*:not(.header.module, .khx-toggle),.khx-collapsed .fandoms{display:none!important} .khx-collapsed .required-tags{transform: scale(0.44) !important; top: -3px !important; left: 0px !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{margin-left:calc(65px - 25px)!important}
    .skipped-work.khx-collapsed .heading{margin-left:calc(65px - 25px)!important}

    .skipped-work>*:not(.khx-toggle) {opacity: 0.6 !important;}
    .skipped-work.khx-collapsed .required-tags, .skipped-work .fandoms {display:none!important;}

    .khx-toggle-seen,.khx-toggle-skipped {
      border: none !important; display: block !important;
      line-height: 18px !important;
      text-decoration: underline !important;
      text-decoration:none !important;
      opacity: 0.5 !important;
    }
    .khx-toggle-dark { opacity: 1 !important; }

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

    .skipped-work.khx-collapsed>*:not(.khx-toggle) {display:none!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; }
    @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 6px left / 100% 17px repeat-x !important;
    }
    @media (max-width: 650px) {
      .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 one = true, border
    let articles = document.querySelectorAll('li.work[role="article"], li.bookmark[role="article"]')
    for (let article of articles) {
      if (one) { border = window.getComputedStyle(article).border; one = false }

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

      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)

      const seenSpan = document.createElement('span')
      seenSpan.style.border = border
      seenSpan.style.borderBottom = 'none';

      const seenLink = document.createElement('span')
      seenLink.className = 'khx-toggle-seen'
      seenLink.style.padding = '0 16px'
      seenLink.textContent = 'seen'
      seenLink.addEventListener('click', e => e.preventDefault())
      seenSpan.appendChild(seenLink)

      actionDiv.appendChild(skipSpan)
      actionDiv.appendChild(seenSpan)

      article.style.position = 'relative'
      article.style.marginTop = '25px'

      article.prepend(actionDiv)
    }
  }

  // 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[role="article"]: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 || 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();
}

// -----------------------------------------------------------------------
// 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) {
  var file = event.target.files[0];
  if (!file) return;

  var reader = new FileReader();
  reader.onload = function(e) {
    try {
      const cleanupChecked = JSON.parse(localStorage.getItem('kudoshistory_settings') || '{}').background_check !== 'yes';

      var importedData = JSON.parse(e.target.result);

      if (!importedData.kudosed || !importedData.seen || !importedData.bookmarked || !importedData.skipped) {
        throw new Error("Missing kudosed/seen/bookmarked/skipped data fields.");
      }

      const maybeKChecked = cleanupChecked ? [] : ['kudoshistory_checked'];
      const maybeChecked  = cleanupChecked ? [] : ['checked'];

      var notes = ""
      var sizes_before = ['kudoshistory_seen', 'kudoshistory_skipped', 'kudoshistory_bookmarked', 'kudoshistory_kudosed', ...maybeKChecked]
        .map(key => (String(localStorage.getItem(key)) || '').replace(strip, '').split(',').length - 1);
      var sizes_after = ['seen', 'skipped', 'bookmarked', 'kudosed', ...maybeChecked]
        .map(key => (String(importedData[key]) || '').replace(strip, '').split(',').length  - 1);

      let checkedCount = localStorage.getItem('kudoshistory_checked',    ',').replace(strip, '').split(',').length - 1;

      localStorage.setItem('kudoshistory_kudosed',    importedData.kudosed);
      localStorage.setItem('kudoshistory_bookmarked', importedData.bookmarked);
      localStorage.setItem('kudoshistory_skipped',    importedData.skipped);
      localStorage.setItem('kudoshistory_seen',       importedData.seen);
      if (cleanupChecked || importedData.checked) localStorage.setItem('kudoshistory_checked', cleanupChecked ? ',' : 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 (cleanupChecked) notes += "\n    " + checkedCount + ' "checked" cleaned up.';

      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    new: " + importedData.settings;
        notes += "\n    old:  " + oldSettings;
      } else {
        notes += "\n- Settings: no change"
      }

      alert("[ExtendAO3KH] Success" + notes);
    } catch (error) {
        alert("[ExtendAO3KH] Error\nInvalid file format / 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() {
  GM_addStyle(
    '.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) !important; }'
    ) : '')
  );
  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(isWork, true)
  }

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

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