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