- // ==UserScript==
- // @name Extend "AO3: Kudosed and seen history" | Export/Import + Standalone Light/Dark mode toggle
- // @description Add Export/Import history buttons at the bottom of the page. Color&rename the confusingly named 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.17
- // @match https://archiveofourown.org/*
- // @grant GM_xmlhttpRequest
- // @namespace https://gf.qytechs.cn/users/1376767
- // ==/UserScript==
-
- const ENHANCED_SEEN_BUTTON = true; // Seen button is colored and renamed
- 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; // Ignore external links, only Mark SEEN within archiveofourown.org
- const SITE_SKINS = [ "Default", "Reversi" ];
-
-
- // Function to fetch the preferences form, extract the authenticity token, and get the 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() {
- 'use strict';
-
- 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.href;
- 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;
-
- // Ignore changes we made ourselves
- if (buttonElement.textContent === "SEEN Now (click to unmark)" ||
- buttonElement.textContent === "Old SEEN (click to unmark)" ||
- buttonElement.textContent === "SEEN (click to unmark)" ||
- buttonElement.textContent === "NOT SEEN (click to mark)" ||
- buttonElement.textContent === "UNSEEN (click to mark)") {
- 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;
- }
-
- const GREEN = "#33cc70"; // "#33cc70";
- const GREEN_DARKER = "#00a13a"; // "#149b49";
- const RED = "#ff6d50";
-
- buttonElement.textContent =
- state_seen ?
- (isFirst ?
- (wasClicked ? "SEEN Now (click to unmark)" : "Old SEEN (click to unmark)") :
- "SEEN (click to unmark)") :
- "UNSEEN (click to mark)";
-
- 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();
- }
- })();