// ==UserScript==
// @name AO3 FicTracker
// @author infiniMotis
// @version 1.4.5
// @namespace https://github.com/infiniMotis/AO3-FicTracker
// @description Track your favorite, finished, to-read and disliked fanfics on AO3 with sync across devices. Customizable tags and highlights make it easy to manage and spot your tracked works. Full UI customization on the preferences page.
// @license GNU GPLv3
// @icon https://archiveofourown.org/favicon.ico
// @match *://archiveofourown.org/*
// @run-at document-end
// @require https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js
// @supportURL https://github.com/infiniMotis/AO3-FicTracker/issues
// @contributionURL https://ko-fi.com/infinimotis
// @contributionAmount 1 USD
// @downloadURL https://update.greasyfork.icu/scripts/513435/AO3%20FicTracker.user.js
// @updateURL https://update.greasyfork.icu/scripts/513435/AO3%20FicTracker.meta.js
// ==/UserScript==
// Description:
// FicTracker is designed for you to effectively manage their fanfics on AO3.
// It allows you to mark fics as finished, favorite, to-read, or disliked, providing an easy way to organize their reading list.
// Key Features:
// **Custom "To-Read" Feature:** Users can filter and search through their to-read list, enhancing the experience beyond AO3's default functionality.
// **Data Synchronization:** Information is linked to the user's AO3 account, enabling seamless syncing across devices.
// **User-Friendly Access:** Users can conveniently access tracking options from a dropdown menu, making the process intuitive and straightforward.
// **Optimized:** The script runs features only on relevant pages, ensuring quick and efficient performance.
// Usage Instructions:
// 1. **Tracking Fics:** On the fics page, click the status button, on search result/fics listing pages - in the right bottom corner of each work there is a dropdown.
// 2. **Settings Panel:** At the end of the user preferences page, you will find a settings panel to customize your tracking options.
// 3. **Accessing Your Lists:** In the dropdown menu at the top right corner, you'll find links to your tracked lists for easy access.
(function() {
'use strict';
// Default script settings
let settings = {
version: GM_info.script.version,
statuses: [
{
tag: 'Finished Reading',
dropdownLabel: 'My Finished Fanfics',
positiveLabel: '✔️ Mark as Finished',
negativeLabel: '🗑️ Remove from Finished',
selector: 'finished_reading_btn',
storageKey: 'FT_finished',
enabled: true,
highlightColor: "#000",
borderSize: 2
},
{
tag: 'Favorite',
dropdownLabel: 'My Favorite Fanfics',
positiveLabel: '❤️ Mark as Favorite',
negativeLabel: '💔 Remove from Favorites',
selector: 'favorite_btn',
storageKey: 'FT_favorites',
enabled: true,
highlightColor: "#F95454",
borderSize: 2
},
{
tag: 'To Read',
dropdownLabel: 'My To Read Fanfics',
positiveLabel: '📚 Mark as To Read',
negativeLabel: '🧹 Remove from To Read',
selector: 'to_read_btn',
storageKey: 'FT_toread',
enabled: true,
highlightColor: "#3BA7C4",
borderSize: 2
},
{
tag: 'Disliked Work',
dropdownLabel: 'My Disliked Fanfics',
positiveLabel: '👎 Mark as Disliked',
negativeLabel: '🧹 Remove from Disliked',
selector: 'disliked_btn',
storageKey: 'FT_disliked',
enabled: true,
highlightColor: "#FF5C5C",
borderSize: 2
}
],
loadingLabel: '⏳Loading...',
hideDefaultToreadBtn: true,
newBookmarksPrivate: true,
newBookmarksRec: false,
lastExportTimestamp: null,
bottom_action_buttons: true,
delete_empty_bookmarks: true,
debug: false
};
// Toggle debug info
let DEBUG = settings.debug;
// Utility class for injecting CSS
class StyleManager {
// Method to add custom styles to the page
static addCustomStyles(styles) {
const customStyle = document.createElement('style');
customStyle.innerHTML = styles;
document.head.appendChild(customStyle);
DEBUG && console.info('[FicTracker] Custom styles added successfully.');
}
}
// Class for handling API requests
class RequestManager {
constructor(baseApiUrl) {
this.baseApiUrl = baseApiUrl;
}
// Retrieve the authenticity token from a meta tag
getAuthenticityToken() {
const metaTag = document.querySelector('meta[name="csrf-token"]');
return metaTag ? metaTag.getAttribute('content') : null;
}
// Send an API request with the specified method
sendRequest(url, formData = null, headers = null, method = "POST") {
const options = {
method: method,
mode: "cors",
credentials: "include",
};
// Attach headers if there are any
if (headers) {
options.headers = headers;
}
// If it's not a GET request, we include the formData in the request body
if (method !== "GET" && formData) {
options.body = formData;
}
return fetch(url, options)
.then(response => {
if (!response.ok) {
throw new Error(`Request failed with status ${response.status}`);
}
return response;
})
.catch(error => {
DEBUG && console.error('[FicTracker] Error during API request:', error);
throw error;
});
}
// Create a bookmark for fanfic with given data
createBookmark(workId, authenticityToken, bookmarkData) {
const url = `${this.baseApiUrl}/works/${workId}/bookmarks`;
const headers = this.getRequestHeaders();
const formData = this.createFormData(authenticityToken, bookmarkData);
DEBUG && console.info('[FicTracker] Sending CREATE request for bookmark:', {
url,
headers,
bookmarkData
});
return this.sendRequest(url, formData, headers)
.then(response => {
if (response.ok) {
const bookmarkId = response.url.split('/').pop();
DEBUG && console.log('[FicTracker] Created bookmark ID:', bookmarkId);
return bookmarkId;
} else {
throw new Error("Failed to create bookmark. Status: " + response.status);
}
})
.catch(error => {
DEBUG && console.error('[FicTracker] Error creating bookmark:', error);
throw error;
});
}
// Update a bookmark for fanfic with given data
updateBookmark(bookmarkId, authenticityToken, updatedData) {
const url = `${this.baseApiUrl}/bookmarks/${bookmarkId}`;
const headers = this.getRequestHeaders();
const formData = this.createFormData(authenticityToken, updatedData, 'update');
DEBUG && console.info('[FicTracker] Sending UPDATE request for bookmark:', {
url,
headers,
updatedData
});
return this.sendRequest(url, formData, headers)
.then(data => {
DEBUG && console.log('[FicTracker] Bookmark updated successfully:', data);
})
.catch(error => {
DEBUG && console.error('[FicTracker] Error updating bookmark:', error);
});
}
// Delete a bookmark by ID
deleteBookmark(bookmarkId, authenticityToken) {
const url = `${this.baseApiUrl}/bookmarks/${bookmarkId}`;
const headers = this.getRequestHeaders();
// FormData for this one is minimalist, method call is not needed
const formData = new FormData();
formData.append('authenticity_token', authenticityToken);
formData.append('_method', 'delete');
DEBUG && console.info('[FicTracker] Sending DELETE request for bookmark:', {
url,
headers,
authenticityToken
});
return this.sendRequest(url, formData, headers)
.then(data => {
DEBUG && console.log('[FicTracker] Bookmark deleted successfully:', data);
})
.catch(error => {
DEBUG && console.error('[FicTracker] Error deleting bookmark:', error);
});
}
// Retrieve the request headers
getRequestHeaders() {
const headers = {
"Accept": "text/html", // Accepted content type
"Cache-Control": "no-cache", // Prevent caching
"Pragma": "no-cache", // HTTP 1.0 compatibility
};
DEBUG && console.log('[FicTracker] Retrieving request headers:', headers);
return headers;
}
// Create FormData for bookmarking actions based on action type
createFormData(authenticityToken, bookmarkData, type = 'create') {
const formData = new FormData();
// Append required data to FormData
formData.append('authenticity_token', authenticityToken);
formData.append("bookmark[pseud_id]", bookmarkData.pseudId);
formData.append("bookmark[bookmarker_notes]", bookmarkData.notes);
formData.append("bookmark[tag_string]", bookmarkData.bookmarkTags.join(','));
formData.append("bookmark[collection_names]", bookmarkData.collections.join(','));
formData.append("bookmark[private]", +bookmarkData.isPrivate);
formData.append("bookmark[rec]", +bookmarkData.isRec);
// Append action type
formData.append("commit", type === 'create' ? "Create" : "Update");
if (type === 'update') {
formData.append("_method", "put");
}
DEBUG && console.log('[FicTracker] FormData created successfully:');
DEBUG && console.table(Array.from(formData.entries()));
return formData;
}
}
// Class for managing storage caching
class StorageManager {
// Store a value in local storage
setItem(key, value) {
localStorage.setItem(key, value);
}
// Retrieve a value from local storage
getItem(key) {
const value = localStorage.getItem(key);
return value;
}
// Add an ID to a specific category
addIdToCategory(category, id) {
const existingIds = this.getItem(category);
const idsArray = existingIds ? existingIds.split(',') : [];
if (!idsArray.includes(id)) {
idsArray.push(id);
this.setItem(category, idsArray.join(',')); // Update the category with new ID
DEBUG && console.debug(`[FicTracker] Added ID to category "${category}": ${id}`);
}
}
// Remove an ID from a specific category
removeIdFromCategory(category, id) {
const existingIds = this.getItem(category);
const idsArray = existingIds ? existingIds.split(',') : [];
const idx = idsArray.indexOf(id);
if (idx !== -1) {
idsArray.splice(idx, 1); // Remove the ID
this.setItem(category, idsArray.join(',')); // Update the category
DEBUG && console.debug(`[FicTracker] Removed ID from category "${category}": ${id}`);
}
}
// Get IDs from a specific category
getIdsFromCategory(category) {
const existingIds = this.getItem(category) || '';
const idsArray = existingIds.split(',');
DEBUG && console.debug(`[FicTracker] Retrieved IDs from category "${category}"`);
return idsArray;
}
}
// Class for bookmark data and tag management abstraction to keep things DRY
class BookmarkTagManager {
constructor(htmlSource) {
// If it's already a document, use it directly, otherwise parse the HTML string
if (htmlSource instanceof Document) {
this.doc = htmlSource;
} else {
// Use DOMParser to parse the HTML response
const parser = new DOMParser();
this.doc = parser.parseFromString(htmlSource, 'text/html');
}
}
// Get the work ID from the DOM
getWorkId() {
return this.doc.getElementById('kudo_commentable_id')?.value || null;
}
// Get the bookmark ID from the form's action attribute
getBookmarkId() {
const bookmarkForm = this.doc.querySelector('div#bookmark_form_placement form');
return bookmarkForm ? bookmarkForm.getAttribute('action').split('/')[2] : null;
}
// Get the pseud ID from the input
getPseudId() {
const singlePseud = this.doc.querySelector('input#bookmark_pseud_id');
if (singlePseud) {
return singlePseud.value;
} else {
// If user has multiple pseuds - use the default one to create bookmark
const pseudSelect = this.doc.querySelector('select#bookmark_pseud_id');
return pseudSelect?.value || null;
}
}
// Gather all bookmark-related data into an object
getBookmarkData() {
return {
workId: this.getWorkId(),
bookmarkId: this.getBookmarkId(),
pseudId: this.getPseudId(),
bookmarkTags: this.getBookmarkTags(),
notes: this.getBookmarkNotes(),
collections: this.getBookmarkCollections(),
isPrivate: this.isBookmarkPrivate(),
isRec: this.isBookmarkRec()
};
}
getBookmarkTags() {
return this.doc.querySelector('#bookmark_tag_string').value.split(', ').filter(tag => tag.length > 0);;
}
getBookmarkNotes() {
return this.doc.querySelector('textarea#bookmark_notes').textContent;
}
getBookmarkCollections() {
return this.doc.querySelector('#bookmark_collection_names').value.split(',').filter(col => col.length > 0);;
}
isBookmarkPrivate() {
return this.doc.querySelector('#bookmark_private')?.checked || false;
}
isBookmarkRec() {
return this.doc.querySelector('#bookmark_recommendation')?.checked || false;
}
async processTagToggle(tag, isTagPresent, bookmarkData, authenticityToken, storageKey, storageManager, requestManager) {
// Toggle the bookmark tag and log the action
if (isTagPresent) {
DEBUG && console.log(`[FicTracker] Removing tag: ${tag}`);
bookmarkData.bookmarkTags.splice(bookmarkData.bookmarkTags.indexOf(tag), 1);
storageManager.removeIdFromCategory(storageKey, bookmarkData.workId);
} else {
DEBUG && console.log(`[FicTracker] Adding tag: ${tag}`);
bookmarkData.bookmarkTags.push(tag);
storageManager.addIdToCategory(storageKey, bookmarkData.workId);
}
// If the bookmark exists - update it, if not - create a new one
if (bookmarkData.workId !== bookmarkData.bookmarkId) {
// If bookmark becomes empty (no notes, tags, collections) after status change - delete it
const hasNoData = bookmarkData.notes === "" && bookmarkData.bookmarkTags.length === 0 && bookmarkData.collections.length === 0;
if (settings.delete_empty_bookmarks && hasNoData) {
DEBUG && console.log(`[FicTracker] Deleting empty bookmark ID: ${bookmarkData.bookmarkId}`);
await requestManager.deleteBookmark(bookmarkData.bookmarkId, authenticityToken);
bookmarkData.bookmarkId = bookmarkData.workId;
} else {
// Update the existing bookmark
await requestManager.updateBookmark(bookmarkData.bookmarkId, authenticityToken, bookmarkData);
}
} else {
// Create a new bookmark
bookmarkData.isPrivate = settings.newBookmarksPrivate;
bookmarkData.isRec = settings.newBookmarksRec;
bookmarkData.bookmarkId = await requestManager.createBookmark(bookmarkData.workId, authenticityToken, bookmarkData);
DEBUG && console.log(`[FicTracker] Created bookmark ID: ${bookmarkData.bookmarkId}`);
}
return bookmarkData
}
}
// Class for managing bookmark status updates
class BookmarkManager {
constructor(baseApiUrl) {
this.requestManager = new RequestManager(baseApiUrl);
this.storageManager = new StorageManager();
this.bookmarkTagManager = new BookmarkTagManager(document);
// Extract bookmark-related data from the DOM
this.bookmarkData = this.bookmarkTagManager.getBookmarkData();
DEBUG && console.log(`[FicTracker] Initialized BookmarkManager with data:`);
DEBUG && console.table(this.bookmarkData)
// Hide the default "to read" button if specified in settings
if (settings.hideDefaultToreadBtn) {
document.querySelector('li.mark').style.display = "none";
}
this.addButtons();
}
// Add action buttons to the UI for each status
addButtons() {
const actionsMenu = document.querySelector('ul.work.navigation.actions');
const bottomActionsMenu = document.querySelector('div#feedback > ul');
settings.statuses.forEach(({
tag,
positiveLabel,
negativeLabel,
selector
}) => {
const isTagged = this.bookmarkData.bookmarkTags.includes(tag);
const buttonHtml = `
`;
actionsMenu.insertAdjacentHTML('beforeend', buttonHtml);
// insert button duplicate at the bottom
if (settings.bottom_action_buttons) {
bottomActionsMenu.insertAdjacentHTML('beforeend', buttonHtml);
}
});
this.setupClickListeners();
}
// Set up click listeners for each action button
setupClickListeners() {
settings.statuses.forEach(({ selector, tag, positiveLabel, negativeLabel, storageKey }) => {
// Use querySelectorAll to get all elements with the duplicate ID (bottom menu)
document.querySelectorAll(`#${selector}`).forEach(button => {
button.addEventListener('click', (event) => {
event.preventDefault();
this.handleActionButton(tag, positiveLabel, negativeLabel, selector, storageKey);
});
});
});
}
// Handle the action for adding/removing/deleting a bookmark tag
async handleActionButton(tag, positiveLabel, negativeLabel, selector, storageKey) {
const authenticityToken = this.requestManager.getAuthenticityToken();
const isTagPresent = this.bookmarkData.bookmarkTags.includes(tag);
// Consider button bottom menu duplication
const buttons = document.querySelectorAll(`#${selector} a`);
// Disable the buttons and show loading state
buttons.forEach((btn) => {
btn.innerHTML = settings.loadingLabel;
btn.disabled = true;
});
try {
// Send tag toggle request and modify cached bookmark data
this.bookmarkData = await this.bookmarkTagManager.processTagToggle(tag, isTagPresent, this.bookmarkData, authenticityToken,
storageKey, this.storageManager, this.requestManager);
// Update the labels for all buttons
buttons.forEach((btn) => {
btn.innerHTML = isTagPresent ? positiveLabel : negativeLabel;
});
} catch (error) {
console.error(`[FicTracker] Error during bookmark operation:`, error);
buttons.forEach((btn) => {
btn.innerHTML = 'Error! Try Again';
});
} finally {
buttons.forEach((btn) => {
btn.disabled = false;
});
}
}
}
// Class for handling features on works list page
class WorksListHandler {
constructor() {
this.storageManager = new StorageManager();
this.requestManager = new RequestManager('https://archiveofourown.org/');
this.loadStoredIds();
// Update the work list upon initialization
this.updateWorkList();
// Listen for clicks on quick tag buttons
this.setupQuickTagListener();
}
// Retrieve stored IDs for different statuses
loadStoredIds() {
this.worksStoredIds = settings.statuses.reduce((acc, status) => {
if (status.enabled) {
acc[status.storageKey] = this.storageManager.getIdsFromCategory(status.storageKey);
}
return acc;
}, {});
}
// Execute features for each work on the page
updateWorkList() {
const works = document.querySelectorAll('li.work.blurb, li.bookmark.blurb');
works.forEach(work => {
const workId = this.getWorkId(work);
// Only status highlighting for now, TBA
this.highlightWorkStatus(work, workId);
this.addQuickTagDropdown(work);
});
}
// Get the work ID from DOM
getWorkId(work) {
const link = work.querySelector('h4.heading a');
const workId = link.href.split('/').pop();
return workId;
}
// Change the visuals of each work's status
highlightWorkStatus(work, workId) {
// Loop through the object properties using Object.entries()
Object.entries(this.worksStoredIds).forEach(([status, storedIds]) => {
const statusClass = `glowing-border-${status}`;
this.toggleStatusClass(work, workId, storedIds, statusClass);
});
}
// Toggle the status class based on workId
toggleStatusClass(work, workId, statusIds, className) {
if (statusIds.includes(workId)) {
work.classList.add(className);
} else {
work.classList.remove(className);
}
}
// Add quick tag toggler dropdown to the work
addQuickTagDropdown(work) {
const workId = this.getWorkId(work);
// Generate the dropdown options dynamically based on the status categories
const dropdownItems = Object.entries(this.worksStoredIds).map(([status, storedIds], index) => {
const statusLabel = settings.statuses[index][storedIds.includes(workId) ? 'negativeLabel' : 'positiveLabel'];
return `