// ==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.27 // @match https://archiveofourown.org/* // @grant GM_xmlhttpRequest // @namespace https://greasyfork.org/users/1376767 // @downloadURL none // ==/UserScript== 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" ]; // A fic is marked as seen after loading not when the title is clicked. // Thus the AO3 script requires a manual reload to refresh 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: // 1. When unloading a tab, store the current fic id and the seen sate. // Update this state every time the seen button is clicked. // Upon back navigation, check this data, try to find the fic link on-screen, and toggle its collapsed status. // Note: This is cheaper than reloading the page. // 2. Intercept clicks on links and manually trigger the 'seen' button for immediate collapse. let currentSeenState = null; // Updated down if (ENHANCED_SEEN_BUTTON) { const isWork = /^https:\/\/archiveofourown\.org(?:\/collections\/[^\/]+)?(\/works\/\d+)/ let state = {}; window.addEventListener("pagehide", function (event) { // Note: Doing this in 'unload'(desktop) or 'beforeunload'(mobile) caused 'event.persisted' to be false. const match = window.location.href.match(isWork); if (match) { localStorage.setItem("C89AO3_state", JSON.stringify({"ref": match[1], "state": currentSeenState})); } else { localStorage.setItem("C89AO3_state", '{}'); } }); window.addEventListener("pageshow", function (event) { // On page load/back navigation/etc. let data = localStorage.getItem("C89AO3_state"); state = JSON.parse(data ? data : '{}'); console.log(data, ', persisted=', event.persisted) }); function flashFilter(element) { element.style.transition = 'filter 250ms ease-in-out'; element.style.filter = 'brightness(0.8)'; setTimeout(() => { element.style.filter = 'brightness(1)'; setTimeout(() => { element.style.transition = ''; element.style.filter = ''; }, 250); }, 250); } function flashBg(element) { element.style.transition = 'background-color 250ms ease-in-out'; element.style.backgroundColor = 'rgba(169, 169, 169, 0.2)'; setTimeout(() => { element.style.backgroundColor = 'transparent'; setTimeout(() => { element.style.transition = ''; element.style.backgroundColor = ''; }, 250); }, 250); } window.addEventListener('pageshow', (event) => { if (event.persisted) { // If we navigated back. if (state?.ref) { // 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*="${state.ref}"]`); if (titleLink) { const article = titleLink.closest('li[role="article"]'); if (article) { if ( state?.state === true && !article.classList.contains('marked-seen')) { article.classList.add('marked-seen'); } else if (state?.state === false && article.classList.contains('marked-seen')) { article.classList.remove('marked-seen'); } if (article.classList.contains('marked-seen')) { flashFilter(article); // Can't override the bg so use a filter; the AO3 script adds an !important already. } else { flashBg(article); } } } } } }); } // 2. Intercept all clicks. If a title was clicked, manually dispatch the "seen" button before opening the fic. document.addEventListener('click', function(event) { 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) { // Click the seen button (unless the fic is collapsed - that would unmark it!). if (!article.classList.contains('marked-seen')) { seenButton.click() } // Give that "seen" action time to execute before loading the page. event.preventDefault(); setTimeout(function() { window.location.href = titleLink.href; }, 100); } } } }); // 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
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 element:', skinSelect); // Log the select const workSkinIds = []; let currentSkinId = null; let unmatchedSkins = [...SITE_SKINS]; // Loop through the 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) { //