// ==UserScript== // @name AO3 FicTracker // @author infiniMotis // @version 1.0.3 // @namespace https://github.com/infiniMotis/AO3-FicTracker // @description Enhances the AO3 experience by allowing users to track their favorite, finished, and to-read fanfics. The tracking data is stored as bookmark tags, ensuring sync with the user's AO3 profile across devices, making it resilient to cache clearing. Custom to-read tag enables users to filter and search through their tracked fanfics. Tracked works are highlighted on search and listing pages, making them easy to spot. All options are fully customizable through UI on the preferences page // @license GNU GPLv3 // @icon https://archiveofourown.org/favicon.ico // @match *://archiveofourown.org/* // @run-at document-end // @grant GM_getResourceText // @resource settingsPanelHtml https://raw.githubusercontent.com/infiniMotis/AO3-FicTracker/refs/heads/main/settingsPanel.html // @supportURL https://github.com/infiniMotis/AO3-FicTracker/issues // @contributionURL https://ko-fi.com/infinimotis // @contributionAmount 1 USD // @downloadURL none // ==/UserScript== // Description: // FicTracker is designed for you to effectively manage their fanfics on AO3. // It allows you to mark fics as finished, favorite, or to-read, 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. This means users can access their tracked fics anywhere without worrying about data loss or cache clearing. // **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 button to mark a work as finished, favorite, or to-read. // 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'; // Toggle debug info const DEBUG = false; // Default script settings let settings = { statuses: [{ tag: 'Finished Reading', dropdownLabel: 'My Finished Fanfics', positiveLabel: '✔️ Mark as Finished', negativeLabel: '🗑️ Remove from Finished', selector: 'finished_reading_btn', storageKey: 'FT_finished', highlight: 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', highlight: 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', highlight: true, highlightColor: "#3BA7C4", borderSize: 2 } ], loadingLabel: '⏳Loading...', hideDefaultToreadBtn: true, newBookmarksPrivate: true, newBookmarksRec: false, lastExportTimestamp: null }; // 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; } // Send an API request with the specified method sendRequest(url, formData, headers, method = "POST") { return fetch(url, { method: method, mode: "cors", credentials: "include", headers: headers, body: formData }) .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.tags.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 managing bookmark status updates class BookmarkManager { constructor(baseApiUrl) { this.requestManager = new RequestManager(baseApiUrl); this.storageManager = new StorageManager(); // Extract bookmark-related data from the DOM this.workId = this.getWorkId(); this.bookmarkId = this.getBookmarkId(); this.pseudId = this.getPseudId(); this.bookmarkDataItems = document.querySelectorAll('form dd'); this.bookmarkNotes = this.bookmarkDataItems[0].querySelector('textarea').innerHTML; this.bookmarkTags = Array.from(this.bookmarkDataItems[1].querySelectorAll('li.added.tag')).map(element => { return element.textContent.slice(0, -2).trim(); }); this.bookmarkCollections = Array.from(this.bookmarkDataItems[2].querySelectorAll('li.added.tag')).map(element => { return element.textContent.slice(0, -2).trim(); }); this.isBookmarkPrivate = document.querySelector('#bookmark_private').checked; this.isBookmarkRec = document.querySelector('#bookmark_rec').checked; DEBUG && console.log(`[FicTracker] Initialized BookmarkManager with data:`); DEBUG && console.table({ bookmarkId: this.bookmarkId, notes: this.bookmarkNotes, tags: this.bookmarkTags, collections: this.bookmarkCollections, isPrivate: this.isBookmarkPrivate, isRec: this.isBookmarkRec, }) // 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'); settings.statuses.forEach(({ tag, positiveLabel, negativeLabel, selector }) => { const isTagged = this.bookmarkTags.includes(tag); actionsMenu.insertAdjacentHTML('beforeend', `
  • ${isTagged ? negativeLabel : positiveLabel}
  • `); }); this.setupClickListeners(); } // Set up click listeners for each action button setupClickListeners() { settings.statuses.forEach(({ tag, positiveLabel, negativeLabel, selector, storageKey }) => { document.querySelector(`#${selector} a`).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.getAuthenticityToken(); const bookmarkData = this.getBookmarkData(); const button = document.querySelector(`#${selector} a`); // Disable the button and show loading state button.innerHTML = settings.loadingLabel; button.disabled = true; try { const isTagPresent = this.bookmarkTags.includes(tag); // Toggle the bookmark tag and log the action if (isTagPresent) { console.log(`[FicTracker] Removing tag: ${tag}`); this.bookmarkTags.splice(this.bookmarkTags.indexOf(tag), 1); this.storageManager.removeIdFromCategory(storageKey, this.workId); } else { console.log(`[FicTracker] Adding tag: ${tag}`); this.bookmarkTags.push(tag); this.storageManager.addIdToCategory(storageKey, this.workId); } // If the bookmark exists - update it, if not - create a new one if (this.workId !== this.bookmarkId) { // Update the existing bookmark await this.requestManager.updateBookmark(this.bookmarkId, authenticityToken, bookmarkData); button.innerHTML = isTagPresent ? positiveLabel : negativeLabel; } else { // Create a new bookmark bookmarkData.isPrivate = settings.newBookmarksPrivate; bookmarkData.isRec = settings.newBookmarksRec; this.bookmarkId = await this.requestManager.createBookmark(this.workId, authenticityToken, bookmarkData); DEBUG && console.log(`[FicTracker] Created bookmark ID: ${this.bookmarkId}`); button.innerHTML = negativeLabel; } } catch (error) { console.error(`[FicTracker] Error during bookmark operation:`, error); button.innerHTML = 'Error! Try Again'; } finally { button.disabled = false; } } // Get the work ID from the DOM getWorkId() { return document.getElementById('kudo_commentable_id')?.value || null; } // Get the bookmark ID from the form's action attribute getBookmarkId() { const bookmarkForm = document.querySelector('div#bookmark_form_placement form'); return bookmarkForm ? bookmarkForm.getAttribute('action').split('/')[2] : null; } // Get the pseud ID from the input getPseudId() { return document.querySelector('input#bookmark_pseud_id').value; } // Gather all bookmark-related data into an obj getBookmarkData() { return { workId: this.workId, id: this.bookmarkId, pseudId: this.pseudId, items: this.bookmarkDataItems, notes: this.bookmarkNotes, tags: this.bookmarkTags, collections: this.bookmarkCollections, isPrivate: this.isBookmarkPrivate, isRec: this.isBookmarkRec }; } // Retrieve the authenticity token from a meta tag getAuthenticityToken() { const metaTag = document.querySelector('meta[name="csrf-token"]'); return metaTag ? metaTag.getAttribute('content') : null; } } // Class for handling features on works list page class WorksListHandler { constructor() { this.storageManager = new StorageManager(); // Retrieve stored IDs for different statuses this.finishedReadingIds = this.storageManager.getIdsFromCategory(settings.statuses[0].storageKey); this.favoriteWorksIds = this.storageManager.getIdsFromCategory(settings.statuses[1].storageKey); this.toReadWorksIds = this.storageManager.getIdsFromCategory(settings.statuses[2].storageKey); // Update the work list upon initialization this.updateWorkList(); } // 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); }); } // 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) { if (this.favoriteWorksIds.includes(workId)) { this.highlightFavorite(work); } if (this.toReadWorksIds.includes(workId)) { this.highlightToRead(work); } if (this.finishedReadingIds.includes(workId)) { this.highlightFinishedReading(work); } } // Highlight the work as a favorite highlightFavorite(work) { this.addStatusEmoji(work, '❤️'); work.classList.add('glowing-border-favorite'); } // Highlight the work as "to read" highlightToRead(work) { this.addStatusEmoji(work, '📚'); work.classList.add('glowing-border-toread'); } // Highlight the work as finished reading highlightFinishedReading(work) { this.addStatusEmoji(work, '✔️'); work.style.opacity = '0.3'; // Dim the work to indicate completion } // Add an Emoji to indicate work status addStatusEmoji(work, emoji) { let statusContainer = work.querySelector('#work_status_container'); if (!statusContainer) { console.debug(`[FicTracker] Adding status container to work: ${this.getWorkId(work)}`); statusContainer = document.createElement('div'); statusContainer.id = 'work_status_container'; statusContainer.style.marginLeft = '10px'; statusContainer.style.position = 'absolute'; statusContainer.style.top = '20px'; statusContainer.style.right = 0; work.querySelector('div.header.module').appendChild(statusContainer); } console.debug(`[FicTracker] Adding status emoji: ${emoji} to work: ${this.getWorkId(work)}`); const statusLabel = document.createElement('span'); statusLabel.textContent = `${emoji}`; statusLabel.style.fontSize = '1.2rem'; statusContainer.appendChild(statusLabel); } } // Class for handling the UI & logic for the script settings panel class SettingsPageHandler { constructor(settings) { this.settings = settings; this.init(); } init() { // Inject PetiteVue & insert the UI after this.injectVueScript(() => { this.loadSettingsPanel(); }); } // Adding lightweight Vue.js fork (6kb) via CDN // Using it saves a ton of repeated LOC to attach event handlers & data binding // PetiteVue Homepage: https://github.com/vuejs/petite-vue injectVueScript(callback) { const vueScript = document.createElement('script'); vueScript.src = 'https://unpkg.com/petite-vue'; document.head.appendChild(vueScript); vueScript.onload = callback; } // Load HTML template for the settings panel from GitHub repo // Insert into the AO3 preferences page & attach Vue app loadSettingsPanel() { const container = document.createElement('fieldset'); // Fetching the HTML for settings panel, outsourced for less clutter container.innerHTML = GM_getResourceText('settingsPanelHtml'); document.querySelector('#main').appendChild(container); // Initialize the Vue app instance PetiteVue.createApp({ selectedStatus: 1, ficTrackerSettings: this.settings, // Computed prop for retrieving settings updates get currentSettings() { return this.ficTrackerSettings.statuses[this.selectedStatus]; }, // Computed prop for updating the preview box styles get previewStyle() { return { height: '50px', border: `${this.currentSettings.borderSize}px solid ${this.currentSettings.highlightColor}`, 'box-shadow': `0 0 10px ${this.currentSettings.highlightColor}, 0 0 20px ${this.currentSettings.highlightColor}`, }; }, // Bind exportData and importData directly to class methods exportData: this.exportSettings.bind(this), importData: this.importSettings.bind(this), // Save the settings to the storage saveSettings() { localStorage.setItem('FT_settings', JSON.stringify(this.ficTrackerSettings)); DEBUG && console.log('[FicTracker] Settings saved.'); }, }).mount(); } // Exports user data (favorites, finished, toread) into a JSON file exportSettings() { // Formatted timestamp for export const exportTimestamp = new Date().toISOString().slice(0, 16).replace('T', ' '); const exportData = { FT_favorites: localStorage.getItem('FT_favorites'), FT_finished: localStorage.getItem('FT_finished'), FT_toread: localStorage.getItem('FT_toread'), }; // Create a Blob object from the export data, converting it to JSON format const blob = new Blob([JSON.stringify(exportData)], { type: 'application/json' }); // Generate a URL for the Blob object to enable downloading const url = URL.createObjectURL(blob); // Create a temp link to downlad the generate file data const a = document.createElement('a'); a.href = url; a.download = `fictracker_export_${exportTimestamp}.json`; document.body.appendChild(a); // Trigger a click on the link to initiate the download a.click(); // Cleanup after the download document.body.removeChild(a); URL.revokeObjectURL(url); // Update the last export timestamp this.settings.lastExportTimestamp = exportTimestamp; localStorage.setItem('FT_settings', JSON.stringify(this.settings)); DEBUG && console.log('[FicTracker] Data exported at:', exportTimestamp); } // Imports user data (favorites, finished, toread) from a JSON file // Existing storage data is not removed, only new items from file are appended importSettings(event) { const file = event.target.files[0]; if (!file) return; const reader = new FileReader(); reader.onload = (e) => { try { const importedData = JSON.parse(e.target.result); this.mergeImportedData(importedData); } catch (err) { DEBUG && console.error('[FicTracker] Error importing data:', err); } }; reader.readAsText(file); } mergeImportedData(importedData) { const keys = ['FT_favorites', 'FT_finished', 'FT_toread']; let newEntries = []; for (const key of keys) { const currentData = localStorage.getItem(key) ? localStorage.getItem(key).split(',') : []; const newData = importedData[key].split(',') || []; const initialLen = currentData.length; const mergedData = [...new Set([...currentData, ...newData])]; newEntries.push(mergedData.length - initialLen); localStorage.setItem(key, mergedData.join(',')); } alert(`Data imported successfully!\nNew favorite entries: ${newEntries[0]}\nNew finished entries: ${newEntries[1]}\nNew To-Read entries: ${newEntries[2]}`); DEBUG && console.log('[FicTracker] Data imported successfully. Stats:', newEntries); } } // Class for managing URL patterns and executing corresponding handlers based on the current path class URLHandler { constructor() { this.handlers = []; } // Add a new handler with associated patterns to the handlers array addHandler(patterns, handler) { this.handlers.push({ patterns, handler }); } // Iterate through registered handlers to find a match for the current path matchAndHandle(currentPath) { for (const { patterns, handler } of this.handlers) { if (patterns.some(pattern => pattern.test(currentPath))) { // Execute the corresponding handler if a match is found handler(); DEBUG && console.log('[FicTracker] Matched pattern for path:', currentPath); return true; } } DEBUG && console.log('[FicTracker] Unrecognized page', currentPath); return false; } } // Main controller that integrates all components of the AO3 FicTracker class FicTracker { constructor() { // Load settings and initialize other features this.settings = this.loadSettings(); this.initStyles(); this.addDropdownOptions(); this.setupURLHandlers(); } // Load settings from the storage or fallback to default ones loadSettings() { // Measure performance of loading settings from localStorage const startTime = performance.now(); let savedSettings = localStorage.getItem('FT_settings'); if (savedSettings) { try { settings = JSON.parse(savedSettings); DEBUG && console.log(`[FicTracker] Settings loaded successfully:`, savedSettings); } catch (error) { DEBUG && console.error(`[FicTracker] Error parsing settings: ${error}`); } } else { DEBUG && console.warn(`[FicTracker] No saved settings found, using default settings.`); } const endTime = performance.now(); DEBUG && console.log(`[FicTracker] Settings loaded in ${endTime - startTime} ms`); return settings; } // Initialize custom styles based on loaded settings initStyles() { const favColor = this.settings.statuses[1].highlightColor; const toReadColor = this.settings.statuses[2].highlightColor; StyleManager.addCustomStyles(` .glowing-border-favorite { border: ${this.settings.statuses[1].borderSize}px solid ${favColor} !important; border-radius: 8px !important; padding: 15px !important; background-color: transparent !important; box-shadow: 0 0 10px ${favColor}, 0 0 20px ${favColor} !important; transition: box-shadow 0.3s ease !important; } .glowing-border-favorite:hover { box-shadow: 0 0 15px ${favColor}, 0 0 30px ${favColor} !important; } .glowing-border-toread { border: ${this.settings.statuses[2].borderSize}px solid ${toReadColor} !important; border-radius: 8px !important; padding: 15px !important; background-color: transparent !important; box-shadow: 0 0 10px ${toReadColor}, 0 0 20px ${toReadColor} !important; transition: box-shadow 0.3s ease !important; } .glowing-border-toread:hover { box-shadow: 0 0 15px ${toReadColor}, 0 0 30px ${toReadColor} !important; } `); } // Add new dropdown options for each status to the user menu addDropdownOptions() { const userMenu = document.querySelector('ul.menu.dropdown-menu'); const username = userMenu?.previousElementSibling?.getAttribute('href')?.split('/').pop() ?? ''; if (username) { // Loop through each status and add corresponding dropdown options this.settings.statuses.forEach(({ tag, dropdownLabel }) => { userMenu.insertAdjacentHTML( 'beforeend', `
  • ${dropdownLabel}
  • ` ); }); } else { DEBUG && console.warn('[FicTracker] Cannot parse the username!'); } } // Setup URL handlers for different pages setupURLHandlers() { const urlHandler = new URLHandler(); // Handler for fanfic pages (chapters, entire work, one shot) urlHandler.addHandler( [/\/works\/.*(?:chapters|view_full_work)/, /works\/\d+(#\w+-?\w*)?$/], () => { const bookmarkManager = new BookmarkManager("https://archiveofourown.org/"); } ); // Handler for fanfics search/tag list pages & other pages that include a list of fics urlHandler.addHandler([ /\/works\/search/, /\/works\?.*/, /\/bookmarks$/, /\/users\/bookmarks/, /\/series\/.+/, /\/collections\/.+/, /\/works\?commit=Sort/, /\/works\?work_search/, /\/tags\/.*\/works/ ], () => { const worksListHandler = new WorksListHandler(); } ); // Handler for user preferences page urlHandler.addHandler( [/\/users\/.+\/preferences/], () => { const settingsPage = new SettingsPageHandler(this.settings); } ); // Execute handler based on the current URL const currentPath = window.location.href; urlHandler.matchAndHandle(currentPath); } } // Instantiate the FicTracker class const ficTracker = new FicTracker(); })();