Greasy Fork is available in English.
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.
// ==UserScript== // @name AO3 FicTracker // @author infiniMotis // @version 1.4.0 // @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 // @grant GM_getResourceText // @resource settingsPanelHtml https://raw.githubusercontent.com/infiniMotis/AO3-FicTracker/refs/heads/main/settingsPanel.html // @require https://cdn.jsdelivr.net/npm/[email protected]/lodash.min.js // @supportURL https://github.com/infiniMotis/AO3-FicTracker/issues // @contributionURL https://ko-fi.com/infinimotis // @contributionAmount 1 USD // ==/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 r###lt/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 = `<li class="mark-as-read" id="${selector}"><a href="#">${isTagged ? negativeLabel : positiveLabel}</a></li>`; 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 `<li><a href="#" class="work_quicktag_btn" data-work-id="${workId}" data-status-tag="${settings.statuses[index].tag}" data-settings-id="${index}">${statusLabel}</a></li>`; }); work.querySelector('dl.stats').insertAdjacentHTML('beforeend', ` <header id="header" class="region" style="padding: 0; font-size: 1em !important; cursor: pointer; opacity: 1;"> <ul class="navigation actions"> <li class="dropdown" aria-haspopup="true" style="position: relative !important;> <a href="#" class="dropdown-toggle" data-toggle="dropdown" data-target="#">✨ Change Status ▼</a> <ul class="menu dropdown-menu" style="width: auto !important;"> ${dropdownItems.join('')} </ul> </li> </ul> </header> `); } // Listen for clicks on quicktag dropdown items setupQuickTagListener() { const worksContainer = document.querySelector('div#main.filtered.region'); // Event delegation for optimization worksContainer.addEventListener('click', async (event) => { if (event.target.matches('a.work_quicktag_btn')) { const targetStatusTag = event.target.dataset.statusTag; const workId = event.target.dataset.workId; const statusId = event.target.dataset.settingsId; const storageKey = settings.statuses[statusId].storageKey; event.target.innerHTML = settings.loadingLabel; // Get request to retrieve work bookmark data const bookmarkData = await this.getRemoteBookmarkData(event.target); const authenticityToken = this.requestManager.getAuthenticityToken(); const tagExists = bookmarkData.bookmarkTags.includes(targetStatusTag); try { // Send tag toggle request and modify cached bookmark data this.bookmarkData = await this.bookmarkTagManager.processTagToggle(targetStatusTag, tagExists, bookmarkData, authenticityToken, storageKey, this.storageManager, this.requestManager); // Handle both search page and bookmarks page cases for work retrieval const work = document.querySelector(`li#work_${workId}`) || document.querySelector(`li.work-${workId}`); // Update data from localStorage to properly highlight work this.loadStoredIds(); this.highlightWorkStatus(work, workId); event.target.innerHTML = tagExists ? settings.statuses[statusId].positiveLabel : settings.statuses[statusId].negativeLabel; } catch (error) { console.error(`[FicTracker] Error during bookmark operation:`, error); } } }) } // Retrieves bookmark data (if exists) for a given work, by sending HTTP GET req async getRemoteBookmarkData(workElem) { DEBUG && console.log(`[FicTracker] Quicktag status change, requesting bookmark data workId=${workElem.dataset.workId}`); try { const data = await this.requestManager.sendRequest(`/works/${workElem.dataset.workId}`, null, null, 'GET'); DEBUG && console.log('[FicTracker] Bookmark data request successful:'); DEBUG && console.table(data); // Read the response body as text const html = await data.text(); this.bookmarkTagManager = new BookmarkTagManager(html); const bookmarkData = this.bookmarkTagManager.getBookmarkData(); DEBUG && console.log('[FicTracker] HTML parsed successfully:'); DEBUG && console.table(bookmarkData); return bookmarkData; } catch (error) { DEBUG && console.error('[FicTracker] Error retrieving bookmark data:', error); } } } // 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.'); }, // Reset settings to default values resetSettings() { // Confirm before resetting settings const confirmed = confirm("Are you sure you want to reset all settings to default? This will delete all saved settings."); if (confirmed) { // Remove the FT_settings key from localStorage localStorage.removeItem('FT_settings'); // Alert success alert("Settings have been reset to default."); } } }).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.r###lt); 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() { // Merge stored settings to match updated structure, assign default settings on fresh installation this.mergeSettings(); // Load settings and initialize other features this.settings = this.loadSettings(); // Filter out disabled statuses this.settings.statuses = this.settings.statuses.filter(status => status.enabled !== false); this.initStyles(); this.addDropdownOptions(); this.setupURLHandlers(); } // Method to merge settings / store the default ones mergeSettings() { // Check if settings already exist in localStorage let storedSettings = JSON.parse(localStorage.getItem('FT_settings')); if (!storedSettings) { // No settings found, save default settings localStorage.setItem('FT_settings', JSON.stringify(settings)); console.log('[FicTracker] Default settings have been stored.'); } else { // Check if the version matches the current version from Tampermonkey metadata const currentVersion = GM_info.script.version; if (!storedSettings.version || storedSettings.version !== currentVersion) { // If versions don't match, merge and update the version storedSettings = _.defaultsDeep(storedSettings, settings); // Update the version marker storedSettings.version = currentVersion; // Save the updated settings back to localStorage localStorage.setItem('FT_settings', JSON.stringify(storedSettings)); console.log('[FicTracker] Settings have been merged and updated to the latest version.'); } else { console.log('[FicTracker] Settings are up to date, no merge needed.'); } } } // 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 = settings.debug; 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-FT_favorites { 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-FT_favorites:hover { box-shadow: 0 0 15px ${favColor}, 0 0 30px ${favColor} !important; } .glowing-border-FT_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-FT_toread:hover { box-shadow: 0 0 15px ${toReadColor}, 0 0 30px ${toReadColor} !important; } .glowing-border-FT_finished { opacity: 0.6; transition: opacity 0.3s ease !important; } .glowing-border-FT_finished:hover { opacity: 1; } .glowing-border-FT_disliked { opacity: 0.2; transition: opacity 0.3s ease !important; } .glowing-border-FT_disliked:hover { opacity: 1; } `); } // 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', `<li><a href="https://archiveofourown.org/bookmarks?bookmark_search%5Bother_bookmark_tag_names%5D=${tag}&user_id=${username}">${dropdownLabel}</a></li>` ); }); } else { DEBUG && console.warn('[FicTracker] Cannot parse the username!'); } DEBUG && console.log('[FicTracker] Successfully added dropdown options!'); } // 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/, /\/bookmarks\?page=/, /\/bookmarks\?bookmark_search/, /\/bookmarks\?commit=Sort\+and\+Filter&bookmark_search/, /\/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(); })();