// ==UserScript==
// @name Extend "AO3: Kudosed and seen history" | Export/Import + Standalone Light/Dark mode toggle
// @description Add Export/Import history to TXT buttons at the bottom of the page. │ Fix back-navigation not being collapsed. │ Color and rename the confusing Seen/Unseen buttons. │ Enhance the title. │ Fix "Mark as seen on open" triggering on external links. ║ Standalone feature: Light/Dark site skin toggle button.
// @author C89sd
// @version 1.30
// @match https://archiveofourown.org/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @namespace https://greasyforks.org/users/1376767
// ==/UserScript==
'use strict';
const ENHANCED_SEEN_BUTTON = true; // Seen button is colored and renamed / Immediately mark seen / Blink when navigating back
const COLORED_TITLE_LINK = true; // Title becomes a colored link
const ENHANCED_MARK_SEEN_ON_OPEN = true; // Enable improved "Mark seen on open" feature with a distinction between SEEN Now and Old SEEN
const IGNORE_EXTERNAL_LINKS = true; // Mark as seen when a link is clicked on AO3 but not from other sites (e.g. reddit). If false, 'New SEEN' will tell you if it was a new or old link.
const SITE_SKINS = [ "Default", "Reversi" ];
// The AO3 Kudosed History script requires a manual reload after a link is clicked:
// - Clicked fics are not collpased and when navigating back.
// - Seen changes made from the other page are not taken into account.
//
// To fix this:
// Intercept clicks on links to immediately trigger the 'seen' button collapse and various blink effects.
// Write the current fic id/state to localStorage from fics.
// When back-navigating, read it back and try to find its link on-screen to update its collapsed status.
let currentSeenState = null; // Updated inside of fics (MutationObserver on the fic's seen button).
if (ENHANCED_SEEN_BUTTON) {
const isWork = /^https:\/\/archiveofourown\.org(?:\/collections\/[^\/]+)?(\/works\/\d+)/
let refererData = {}; // {} | { workSlashId + seenState } of the referrer page (lastest when navigating back and forth).
// When clicking a link & navigating back before the page is loaded, it doesn't blink.
// To make it blink, we push the clicked link to localStorage, notifying ourselves.
let clickedLink = false;
let clickedLinkHref;
let clickedLinkSeen;
// About to leave page: write state for the next page to load.
window.addEventListener("pagehide", function (event) {
// Note: Doing this in 'unload'(desktop) or 'beforeunload'(mobile) caused 'event.persisted' to be false.
const match = (clickedLink ? clickedLinkHref : window.location.href).match(isWork);
if (match) {
// Note: sessionStorage did not work on desktop; and GM_setValue bechmarked 27% slower than localStorage.
if (clickedLink) {
localStorage.setItem("C89AO3_state", JSON.stringify({"workSlashId": match[1], "seenState": (clickedLinkSeen)}));
} else if (currentSeenState === null && refererData?.workSlashId === match[1]) {
// Carry seenState back over adult content warning pages (they have no seen button).
localStorage.setItem("C89AO3_state", JSON.stringify({"workSlashId": match[1], "seenState": (refererData?.seenState)}));
} else {
localStorage.setItem("C89AO3_state", JSON.stringify({"workSlashId": match[1], "seenState": (currentSeenState)}));
}
}
else {
localStorage.setItem("C89AO3_state", '{}');
}
});
// Navigated back: load state communicated from originating page.
// updated on page load/back navigation/etc.
window.addEventListener("pageshow", function (event) {
let data = localStorage.getItem("C89AO3_state");
refererData = JSON.parse(data ? data : '{}');
//console.log('navigated back: data=', data, ', persisted=', event.persisted)
});
// Blink functionality.
const flashCSS = `
@keyframes flash-glow {
0% {
box-shadow: 0 0 4px currentColor;
}
100% {
box-shadow: 0 0 4px transparent;
}
}
@keyframes slide-left {
0% {
transform: translateX(6px);
}
100% {
transform: translateX(0);
}
}
/* When opening, slide down */
li[role="article"]:not(.marked-seen).blink div.header.module {
transition: all 0.25s ease-out; //0.15s
}
/* Always blink border */
li.blink {
animation: flash-glow 0.25s ease-in 1;
}
/* When closing, slide title left */
//li.blink.marked-seen div.header.module {
// animation: slide-left 0.15s ease-out 1;
//}
//li.blink.marked-seen {
// animation: flash-glow 0.2s ease-out 1;
//}
/* When collapsing, slide title in */
//li[role="article"].blink.marked-seen * h4.heading {
// transition: all 0.300s ease-out;
//}
//li[role="article"]:not(.marked-seen) * ul.required-tags {
// transition: all 0.1s ease-out;
//}`;
GM_addStyle(flashCSS);
let blinkTimeout;
function blink(article) {
// console.log("BLINK from ", article)
clearTimeout(blinkTimeout);
article.classList.remove('blink');
void article.offsetWidth; // reflow
article.classList.add('blink');
blinkTimeout = setTimeout(() => {
article.classList.remove('blink');
}, 250);
}
// Navigated back: blink + update seen state.
window.addEventListener('pageshow', (event) => {
// console.log("navigated back, persisted=", event.persisted)
if (event.persisted) { // If we navigated back.
if (refererData?.workSlashId) { // If we read a fic id from localStorage.
// Try finding the link of the fic we navigated back from and toggle its parent visibility.
// Note: use *= because there can be: '.com/works/123' or '.com/collections/u1/works/132' or ?foo at the end.
const titleLink = document.querySelector(`h4.heading > a[href*="${refererData.workSlashId}"]`);
if (titleLink) {
const article = titleLink.closest('li[role="article"]');
if (article) {
blink(article);
if ( refererData?.seenState === true && !article.classList.contains('marked-seen')) {
article.classList.add('marked-seen');
} else if (refererData?.seenState === false && article.classList.contains('marked-seen')) {
article.classList.remove('marked-seen');
}
}
}
}
}
});
// Floating seen button click: blink.
// The AO3 script calls event.stopPropagation() so document.addEventListener('click') does not work, we do his:
function onKhToggleClick(e) {
// console.log("click (floating seen .kh-toggle) ", event.target)
const article = event.target.closest('li[role="article"]');
if (article) {
if (e.target.textContent === 'seen') blink(article);
}
}
function attachToAll() {
document.querySelectorAll('.kh-toggle').forEach(el => {
// avoid double-binding
if (!el.__khListenerAttached) {
el.addEventListener('click', onKhToggleClick, /* capture */ true);
el.__khListenerAttached = true;
}
});
}
attachToAll();
// Title click: blink + send click event to floating seen button + redirect.
document.addEventListener('click', function(event) {
// console.log("click (title) ", event.target)
const titleLink = event.target.closest('h4.heading > a');
if (titleLink) {
const article = titleLink.closest('li[role="article"]');
if (article) {
const seenButton = article.querySelector('div.kh-toggles>a')
if (seenButton) {
// Give the "seen" action time to execute before loading the page.
event.preventDefault();
blink(article);
// Click the seen button (unless the fic is collapsed - that would unmark it!).
if (!article.classList.contains('marked-seen')) {
seenButton.click();
}
// Wait for seenButton.click() to complete before reloading.
requestIdleCallback(() => {
clickedLink = true;
clickedLinkHref = titleLink.href;
clickedLinkSeen = article.classList.contains('marked-seen');
window.location.href = titleLink.href;
});
}
}
}
});
}
// GET the preferences form, find the current skin_id, and POST the next skin_id.
function getPreferencesForm(user) {
// GET the preferences
fetch(`https://archiveofourown.org/users/${user}/preferences`, {
method: 'GET',
headers: {
'Content-Type': 'text/html'
}
})
.then(response => response.text())
.then(responseText => {
const doc = new DOMParser().parseFromString(responseText, 'text/html');
// Extract the authenticity token
const authenticity_token = doc.querySelector('input[name="authenticity_token"]')?.value;
if (authenticity_token) {
// console.log('authenticity_token: ', authenticity_token); // Log the token
} else {
alert('[userscript:Extend AO3] Error\n[authenticity_token] not found!');
return;
}
// Find the <form class="edit_preference">
const form = doc.querySelector('form.edit_preference');
if (form) {
// console.log('Form:', form); // Log the form
// Extract the action URL for the form submission
const formAction = form.getAttribute('action');
// console.log('Form Action:', formAction);
// Find the <select id="preference_skin_id"> list
const skinSelect = form.querySelector('#preference_skin_id');
if (skinSelect) {
// console.log('Found skin_id <select> element:', skinSelect); // Log the select
const workSkinIds = [];
let currentSkinId = null;
let unmatchedSkins = [...SITE_SKINS];
// Loop through the <option value="skinId">skinName</option>
const options = skinSelect.querySelectorAll('option');
options.forEach(option => {
const optionValue = option.value;
const optionText = option.textContent.trim();
if (SITE_SKINS.includes(optionText)) {
// console.log('- option: value=', optionValue, ", text=", optionText, option.selected ? "SELECTED" : ".");
workSkinIds.push(optionValue);
// Remove matched name from unmatchedSkins
unmatchedSkins = unmatchedSkins.filter(name => name !== optionText);
if (option.selected) { // <option selected="selected"> is the current one
currentSkinId = optionValue;
}
}
});
// console.log('SKINS: ', SITE_SKINS, ", workSkinIds: ", workSkinIds);
// Alert if any SITE_SKINS was not matched to an ID
if (unmatchedSkins.length > 0) {
alert("ERROR.\nThe following skins were not found in the list under 'My Preferences > Your site skin'. Please check for spelling mistakes:\n[" + unmatchedSkins.join(", ") + "]\nThey will be skipped for now.");
}
// Cycle the ids: find the current ID in the list and pick the next modulo the array length
if (workSkinIds.length > 0) {
let nextSkinId = null;
let currentIndex = workSkinIds.indexOf(currentSkinId);
if (currentSkinId === null || currentIndex === -1) {
// If currentSkinId is null or not found, select the first workSkinId
nextSkinId = workSkinIds[0];
alert("Current skin was not in list, first skin \"" + SITE_SKINS[0] + "\" will be applied.")
} else {
let nextIndex = (currentIndex + 1) % workSkinIds.length;
nextSkinId = workSkinIds[nextIndex];
}
// console.log('Next skin ID:', nextSkinId);
// ------ POST settings update
// NOTE: This triggers mutiple redirects ending in 404 .. but it works !
// so we manualy handle and reload the page at the first redirect instead.
// // This approach is way simpler but I did not find how to get the current selected skin id, and I need it to decide the next skin to use. This approach seems to not update the settings, but maybe the id can be found on the page?
// fetch(`https://archiveofourown.org/skins/${nextSkinId}/set`, {
// credentials: 'include'
// })
// .then(() => window.location.reload())
// .catch(error => {
// console.error('Error setting the skin:', error);
// alert('[userscript:Extend AO3] Error\nError setting the skin: ' + error);
// });
const formData = new URLSearchParams();
formData.append('_method', 'patch');
formData.append('authenticity_token', authenticity_token);
formData.append('preference[skin_id]', nextSkinId);
formData.append('commit', 'Update'); // Ensure the commit button is also included
fetch(formAction, {
method: 'POST',
body: formData.toString(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', //'application/json',
'Sec-Fetch-Dest': 'document',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Site': 'same-origin',
'Sec-Fetch-User': '?1',
'Upgrade-Insecure-Requests': '1',
'Referer': `https://archiveofourown.org/users/${user}/preferences`
},
credentials: 'include',
redirect: 'manual' // Prevents automatic redirect handling
})
.then(response => {
// If there is a redirect, response will have status code 3xx
if (response.type === 'opaqueredirect') {
// console.log('Redirect blocked, handling manually.');
window.location.reload(); // reload the page
return;
} else {
return response.text();
}
})
.then(responseText => {
// console.log('Form submitted successfully:', responseText);
window.location.reload(); // reload the page
return;
})
.catch(error => {
console.error('Error submitting the form:', error);
alert('[userscript:Extend AO3] Error\nError submitting the form: ' + error);
});
}
} else {
alert('[userscript:Extend AO3] Error\nNo <select> element with id="preference_skin_id" found in the form');
}
} else {
alert('[userscript:Extend AO3] Error\nNo form found with class "edit_preference"');
}
})
.catch(error => {
alert('[userscript:Extend AO3] Error\nError fetching preferences form: ' + error);
});
}
// Button callback
function toggleLightDark() {
const greetingElement = document.querySelector('#greeting a');
if (!greetingElement) {
alert('[userscript:Extend AO3] Error\nUsername not found in top right corner "Hi, user!"');
return;
}
const user = greetingElement.href.split('/').pop();
getPreferencesForm(user);
}
(function() {
const footer = document.createElement('div');
footer.style.width = '100%';
footer.style.paddingTop = '5px';
footer.style.paddingBottom = '5px';
footer.style.display = 'flex';
footer.style.justifyContent = 'center';
footer.style.gap = '10px';
footer.classList.add('footer');
var firstH1link = null;
if (ENHANCED_SEEN_BUTTON && COLORED_TITLE_LINK) {
// Turn title into a link
const firstH1 = document.querySelector('h2.title.heading');
if (firstH1) {
const title = firstH1.lastChild ? firstH1.lastChild : firstH1;
const titleLink = document.createElement('a');
titleLink.href = window.location.origin + window.location.pathname + window.location.search; // Keeps "?view_full_work=true", drops "#summary"
if (title) {
const titleClone = title.cloneNode(true);
titleLink.appendChild(titleClone);
title.parentNode.replaceChild(titleLink, title);
}
firstH1link = titleLink;
}
}
const BTN_1 = ['button'];
const BTN_2 = ['button', 'button--link'];
// Create Light/Dark Button
const lightDarkButton = document.createElement('button');
lightDarkButton.textContent = 'Light/Dark';
lightDarkButton.classList.add(...['button']);
lightDarkButton.addEventListener('click', toggleLightDark);
footer.appendChild(lightDarkButton);
// Create Export Button
const exportButton = document.createElement('button');
exportButton.textContent = 'Export';
exportButton.classList.add(...BTN_1);
exportButton.addEventListener('click', exportToJson);
footer.appendChild(exportButton);
// Create Import Button
const importButton = document.createElement('button');
importButton.textContent = 'Import';
importButton.classList.add(...BTN_1);
// Create hidden file input
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.accept = '.txt, .json';
fileInput.style.display = 'none'; // Hide the input element
// Trigger file input on "Restore" button click
importButton.addEventListener('click', () => {
fileInput.click(); // Open the file dialog when the button is clicked
});
// Listen for file selection and handle the import
fileInput.addEventListener('change', importFromJson);
footer.appendChild(importButton);
// Append footer to the page
const ao3Footer = document.querySelector('body > div > div#footer');
if (ao3Footer) {
ao3Footer.insertAdjacentElement('beforebegin', footer);
} else {
document.body.appendChild(footer);
}
const strip = /^\[?,?|,?\]?$/g;
// Export function
function exportToJson() {
const export_lists = {
username: localStorage.getItem('kudoshistory_username'),
settings: localStorage.getItem('kudoshistory_settings'),
kudosed: localStorage.getItem('kudoshistory_kudosed') || ',',
bookmarked: localStorage.getItem('kudoshistory_bookmarked') || ',',
skipped: localStorage.getItem('kudoshistory_skipped') || ',',
seen: localStorage.getItem('kudoshistory_seen') || ',',
checked: localStorage.getItem('kudoshistory_checked') || ','
};
const pad = (num) => String(num).padStart(2, '0');
const now = new Date();
const year = now.getFullYear();
const month = pad(now.getMonth() + 1);
const day = pad(now.getDate());
const hours = pad(now.getHours());
const minutes = pad(now.getMinutes());
const seconds = pad(now.getSeconds()); // Add seconds
const username = export_lists.username || "none";
var size = ['kudosed', 'bookmarked', 'skipped', 'seen', 'checked']
.map(key => (String(export_lists[key]) || '').replace(strip, '').split(',').length);
var textToSave = JSON.stringify(export_lists, null, 2);
var blob = new Blob([textToSave], {
type: "text/plain"
});
var a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = `AO3_history_${year}_${month}_${day}_${hours}${minutes}${seconds} ${username}+${size}.txt`; //Include seconds
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
// Import function
function importFromJson(event) {
var file = event.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(e) {
try {
var importedData = JSON.parse(e.target.result);
if (!importedData.kudosed || !importedData.seen || !importedData.bookmarked || !importedData.skipped || !importedData.checked) {
throw new Error("Missing kudosed/seen/bookmarked/skipped/checked data fields.");
}
var notes = ""
var sizes_before = ['kudoshistory_kudosed', 'kudoshistory_bookmarked', 'kudoshistory_skipped', 'kudoshistory_seen', 'kudoshistory_checked']
.map(key => (String(localStorage.getItem(key)) || '').replace(strip, '').split(',').length);
var sizes_after = ['kudosed', 'bookmarked', 'skipped', 'seen', 'checked']
.map(key => (String(importedData[key]) || '').replace(strip, '').split(',').length);
localStorage.setItem('kudoshistory_kudosed', importedData.kudosed);
localStorage.setItem('kudoshistory_bookmarked', importedData.bookmarked);
localStorage.setItem('kudoshistory_skipped', importedData.skipped);
localStorage.setItem('kudoshistory_seen', importedData.seen);
localStorage.setItem('kudoshistory_checked', importedData.checked);
var diff = sizes_after.reduce((a, b) => a + b, 0) - sizes_before.reduce((a, b) => a + b, 0);
diff = diff == 0 ? "no change" :
diff > 0 ? "added +" + diff :
"removed " + diff;
notes += "\n- Entries: " + diff;
notes += "\n " + sizes_before;
notes += "\n " + sizes_after;
if (!importedData.username) {
notes += "\n- Username: not present in file ";
} else if (localStorage.getItem('kudoshistory_username') == "null" && importedData.username && importedData.username != "null") {
localStorage.setItem('kudoshistory_username', importedData.username);
notes += "\n- Username: updated to " + importedData.username;
} else {
notes += "\n- Username: no change"
}
if (!importedData.settings) {
notes += "\n- Settings: not present in file ";
} else if (importedData.settings && importedData.settings != localStorage.getItem('kudoshistory_settings')) {
const oldSettings = localStorage.getItem('kudoshistory_settings');
localStorage.setItem('kudoshistory_settings', importedData.settings);
notes += "\n- Settings: updated to";
notes += "\n old: " + oldSettings;
notes += "\n new: " + importedData.settings;
} else {
notes += "\n- Settings: no change"
}
alert("[userscript:Extend AO3] Success" + notes);
} catch (error) {
alert("[userscript:Extend AO3] Error\nInvalid file format / missing data.");
}
};
reader.readAsText(file);
}
// ==========================================
if (ENHANCED_SEEN_BUTTON) {
let wasClicked = false;
// Step 1: Wait for the button to exist and click it if it shows "Seen"
function waitForSeenButton() {
let attempts = 0;
const maxAttempts = 100; // Stop after ~5 seconds (100 * 50ms)
const buttonCheckInterval = setInterval(function() {
attempts++;
const seenButton = document.querySelector('.kh-seen-button a');
if (seenButton) {
clearInterval(buttonCheckInterval);
if (seenButton.textContent.includes('Seen ✓')) {
if (ENHANCED_MARK_SEEN_ON_OPEN) {
if (!IGNORE_EXTERNAL_LINKS || document.referrer.includes("archiveofourown.org")) {
seenButton.click();
wasClicked = true;
}
}
} else {
wasClicked = false;
}
// Move to Step 2
setupButtonObserver();
} else if (attempts >= maxAttempts) {
clearInterval(buttonCheckInterval);
}
}, 50);
}
// Step 2: Monitor the button text and toggle it
function setupButtonObserver() {
toggleButtonText(true, wasClicked);
// Button to observe
const targetNode = document.querySelector('.kh-seen-button');
if (!targetNode) {
return;
}
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
if (mutation.type === 'childList' || mutation.type === 'characterData') {
toggleButtonText(false, false);
}
});
});
const config = {
childList: true,
characterData: true,
subtree: true
};
observer.observe(targetNode, config);
}
function toggleButtonText(isFirst = false, wasClicked = false) {
const buttonElement = document.querySelector('.kh-seen-button a');
if (!buttonElement) return;
const UNSEEN = "UNSEEN [mark]";
const SEEN = "SEEN [unmark]";
const SEEN_NOW = "SEEN NOW [unmark]";
const SEEN_OLD = "SEEN OLD [unmark]";
// const UNSEEN = "UNSEEN [<strong>mark</strong>]";
// const SEEN = "SEEN [<strong>unmark</strong>]";
// const SEEN_NOW = "SEEN NOW [<strong>unmark</strong>]";
// const SEEN_OLD = "SEEN OLD [<strong>unmark</strong>]";
// Ignore changes we made ourselves.
// (Since this is a mutation callback it is called again after modifying the button below.)
if (buttonElement.innerHTML === UNSEEN ||
buttonElement.innerHTML === SEEN ||
buttonElement.innerHTML === SEEN_NOW ||
buttonElement.innerHTML === SEEN_OLD) {
return;
}
const state_seen = buttonElement.textContent.includes('Unseen ✗') ? true :
buttonElement.textContent.includes('Seen ✓') ? false : null;
if (state_seen === null) {
alert('[userscript:Extend AO3]\nUnknown text: ' + buttonElement.textContent);
return;
}
currentSeenState = state_seen;
const GREEN = "#33cc70"; // "#33cc70";
const GREEN_DARKER = "#00a13a"; // "#149b49";
const RED = "#ff6d50";
buttonElement.innerHTML =
state_seen ?
(isFirst ?
(wasClicked ? SEEN_NOW : SEEN_OLD) :
SEEN) :
UNSEEN;
const color = state_seen ? (isFirst && !wasClicked ? GREEN_DARKER : GREEN) : RED;
buttonElement.style.backgroundColor = color;
buttonElement.style.padding = "2px 6px";
buttonElement.style.borderRadius = "3px";
buttonElement.style.boxShadow = "none";
buttonElement.style.backgroundImage = "none";
buttonElement.style.color = getComputedStyle(buttonElement).color;
// Color title
if (firstH1link) firstH1link.style.color = color;
// Blink on open Unseen -> Seen
if (isFirst && wasClicked) {
buttonElement.style.transition = "background-color 150ms ease";
buttonElement.style.backgroundColor = GREEN;
setTimeout(() => {
buttonElement.style.backgroundColor = "#00e64b";
}, 150);
setTimeout(() => {
buttonElement.style.transition = "background-color 200ms linear";
buttonElement.style.backgroundColor = GREEN;
}, 200);
} else if (!isFirst) {
buttonElement.style.transition = "none"; // Clear transition for subsequent calls
buttonElement.style.backgroundColor = state_seen ? GREEN : RED;
}
}
// Start the process
waitForSeenButton();
}
})();