// ==UserScript==
// @name Extend "AO3: Kudosed and seen history" @Min_
// @description Add Export-Import buttons at the bottom of the page. Rename+color Seen/Unseen buttons and Title. Mark as seen on open from AO3 only.
// @author C89sd
// @version 1.8
// @match https://archiveofourown.org/*
// @namespace https://greasyfork.org/users/1376767
// ==/UserScript==
const MARK_SEEN_FROM_AO3_ONLY = true;
(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');
// Turn title into a link
const firstH1 = document.querySelector('h2.title.heading');
var firstH1link = null;
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 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 xFooter = document.getElementById('footer');
if (xFooter) {
xFooter.insertAdjacentElement('beforebegin', footer);
} else {
document.body.appendChild(footer);
}
// Export function
function exportToJson() {
var kudos_history = {
username: { data: localStorage.getItem('kudoshistory_username') },
settings: { data: localStorage.getItem('kudoshistory_settings') },
bookmarked: { data: localStorage.getItem('kudoshistory_bookmarked') || ',' },
kudosed: { data: localStorage.getItem('kudoshistory_kudosed') || ',' },
skipped: { data: localStorage.getItem('kudoshistory_skipped') || ',' },
seen: { data: localStorage.getItem('kudoshistory_seen') || ',' },
checked: { data: localStorage.getItem('kudoshistory_checked') || ',' }
};
var export_lists = {
username: kudos_history.username.data,
settings: kudos_history.settings.data,
bookmarked: kudos_history.bookmarked.data,
kudosed: kudos_history.kudosed.data,
skipped: kudos_history.skipped.data,
seen: kudos_history.seen.data,
checked: kudos_history.checked.data
};
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_kudoshistory.txt';
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 data.");
}
var notes = ""
var sizes_before = ['kudoshistory_kudosed', 'kudoshistory_seen', 'kudoshistory_bookmarked', 'kudoshistory_skipped', 'kudoshistory_checked']
.map(key => localStorage.getItem(key)?.length || 0);
var sizes_after = ['kudosed', 'seen', 'bookmarked', 'skipped', 'checked']
.map(key => importedData[key]?.length || 0);
if (importedData.kudosed) localStorage.setItem('kudoshistory_kudosed', importedData.kudosed);
if (importedData.seen) localStorage.setItem('kudoshistory_seen', importedData.seen);
if (importedData.bookmarked) localStorage.setItem('kudoshistory_bookmarked', importedData.bookmarked);
if (importedData.skipped) localStorage.setItem('kudoshistory_skipped', importedData.skipped);
if (importedData.checked) localStorage.setItem('kudoshistory_checked', importedData.checked);
var diff = sizes_after.reduce((a, b) => a + b, 0) - sizes_before.reduce((a, b) => a + b, 0);
notes += "\n- Entries: " + sizes_before + " → " + sizes_after + " (" + (diff >= 0 ? "+" : "") + diff + ")";
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 && importedData.settings != localStorage.getItem('kudoshistory_settings')) {
localStorage.setItem('kudoshistory_settings', importedData.settings);
notes += "\n- Settings: updated " + importedData.settings }
else {
notes += "\n- Settings: no change"
}
notes += "\n\n—— DATA ——\n"
notes += JSON.stringify(importedData).slice(0, 350) + '...';
alert("[userscript:Extend AO3] Success" + notes);
} catch (error) {
alert("[userscript:Extend AO3] Error\nInvalid file format / missing data.");
}
};
reader.readAsText(file);
}
// ==========================================
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 (!MARK_SEEN_FROM_AO3_ONLY || 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";
firstH1link.style.color = color;
// Mobile fix: the hover color stays stuck when clicking the button. This forces a state refresh on release.
if (!buttonElement.dataset.listenerAdded) {
console.log('ADD')
buttonElement.dataset.listenerAdded = "true"; // Flag to prevent re-adding
buttonElement.addEventListener("touchstart", () => {
buttonElement.classList.add("no-hover");
console.log('START')
});
buttonElement.addEventListener("touchend", () => {
setTimeout(() => {
buttonElement.classList.remove("no-hover");
console.log('END')
}, 100);
});
}
if (isFirst && wasClicked) { // blink
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();
})();