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 settingslet 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 infolet DEBUG = settings.debug;// Utility class for injecting CSSclass StyleManager {// Method to add custom styles to the pagestatic 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 requestsclass RequestManager {constructor(baseApiUrl) {this.baseApiUrl = baseApiUrl;}// Retrieve the authenticity token from a meta taggetAuthenticityToken() {const metaTag = document.querySelector('meta[name="csrf-token"]');return metaTag ? metaTag.getAttribute('content') : null;}// Send an API request with the specified methodsendRequest(url, formData = null, headers = null, method = "POST") {const options = {method: method,mode: "cors",credentials: "include",};// Attach headers if there are anyif (headers) {options.headers = headers;}// If it's not a GET request, we include the formData in the request bodyif (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 datacreateBookmark(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 dataupdateBookmark(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 IDdeleteBookmark(bookmarkId, authenticityToken) {const url = `${this.baseApiUrl}/bookmarks/${bookmarkId}`;const headers = this.getRequestHeaders();// FormData for this one is minimalist, method call is not neededconst 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 headersgetRequestHeaders() {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 typecreateFormData(authenticityToken, bookmarkData, type = 'create') {const formData = new FormData();// Append required data to FormDataformData.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 typeformData.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 cachingclass StorageManager {// Store a value in local storagesetItem(key, value) {localStorage.setItem(key, value);}// Retrieve a value from local storagegetItem(key) {const value = localStorage.getItem(key);return value;}// Add an ID to a specific categoryaddIdToCategory(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 IDDEBUG && console.debug(`[FicTracker] Added ID to category "${category}": ${id}`);}}// Remove an ID from a specific categoryremoveIdFromCategory(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 IDthis.setItem(category, idsArray.join(',')); // Update the categoryDEBUG && console.debug(`[FicTracker] Removed ID from category "${category}": ${id}`);}}// Get IDs from a specific categorygetIdsFromCategory(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 DRYclass BookmarkTagManager {constructor(htmlSource) {// If it's already a document, use it directly, otherwise parse the HTML stringif (htmlSource instanceof Document) {this.doc = htmlSource;} else {// Use DOMParser to parse the HTML responseconst parser = new DOMParser();this.doc = parser.parseFromString(htmlSource, 'text/html');}}// Get the work ID from the DOMgetWorkId() {return this.doc.getElementById('kudo_commentable_id')?.value || null;}// Get the bookmark ID from the form's action attributegetBookmarkId() {const bookmarkForm = this.doc.querySelector('div#bookmark_form_placement form');return bookmarkForm ? bookmarkForm.getAttribute('action').split('/')[2] : null;}// Get the pseud ID from the inputgetPseudId() {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 bookmarkconst pseudSelect = this.doc.querySelector('select#bookmark_pseud_id');return pseudSelect?.value || null;}}// Gather all bookmark-related data into an objectgetBookmarkData() {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 actionif (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 oneif (bookmarkData.workId !== bookmarkData.bookmarkId) {// If bookmark becomes empty (no notes, tags, collections) after status change - delete itconst 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 bookmarkawait requestManager.updateBookmark(bookmarkData.bookmarkId, authenticityToken, bookmarkData);}} else {// Create a new bookmarkbookmarkData.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 updatesclass BookmarkManager {constructor(baseApiUrl) {this.requestManager = new RequestManager(baseApiUrl);this.storageManager = new StorageManager();this.bookmarkTagManager = new BookmarkTagManager(document);// Extract bookmark-related data from the DOMthis.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 settingsif (settings.hideDefaultToreadBtn) {document.querySelector('li.mark').style.display = "none";}this.addButtons();}// Add action buttons to the UI for each statusaddButtons() {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 bottomif (settings.bottom_action_buttons) {bottomActionsMenu.insertAdjacentHTML('beforeend', buttonHtml);}});this.setupClickListeners();}// Set up click listeners for each action buttonsetupClickListeners() {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 tagasync handleActionButton(tag, positiveLabel, negativeLabel, selector, storageKey) {const authenticityToken = this.requestManager.getAuthenticityToken();const isTagPresent = this.bookmarkData.bookmarkTags.includes(tag);// Consider button bottom menu duplicationconst buttons = document.querySelectorAll(`#${selector} a`);// Disable the buttons and show loading statebuttons.forEach((btn) => {btn.innerHTML = settings.loadingLabel;btn.disabled = true;});try {// Send tag toggle request and modify cached bookmark datathis.bookmarkData = await this.bookmarkTagManager.processTagToggle(tag, isTagPresent, this.bookmarkData, authenticityToken,storageKey, this.storageManager, this.requestManager);// Update the labels for all buttonsbuttons.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 pageclass WorksListHandler {constructor() {this.storageManager = new StorageManager();this.requestManager = new RequestManager('https://archiveofourown.org/');this.loadStoredIds();// Update the work list upon initializationthis.updateWorkList();// Listen for clicks on quick tag buttonsthis.setupQuickTagListener();}// Retrieve stored IDs for different statusesloadStoredIds() {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 pageupdateWorkList() {const works = document.querySelectorAll('li.work.blurb, li.bookmark.blurb');works.forEach(work => {const workId = this.getWorkId(work);// Only status highlighting for now, TBAthis.highlightWorkStatus(work, workId);this.addQuickTagDropdown(work);});}// Get the work ID from DOMgetWorkId(work) {const link = work.querySelector('h4.heading a');const workId = link.href.split('/').pop();return workId;}// Change the visuals of each work's statushighlightWorkStatus(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 workIdtoggleStatusClass(work, workId, statusIds, className) {if (statusIds.includes(workId)) {work.classList.add(className);} else {work.classList.remove(className);}}// Add quick tag toggler dropdown to the workaddQuickTagDropdown(work) {const workId = this.getWorkId(work);// Generate the dropdown options dynamically based on the status categoriesconst 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 itemssetupQuickTagListener() {const worksContainer = document.querySelector('div#main.filtered.region');// Event delegation for optimizationworksContainer.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 dataconst 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 datathis.bookmarkData = await this.bookmarkTagManager.processTagToggle(targetStatusTag, tagExists, bookmarkData, authenticityToken,storageKey, this.storageManager, this.requestManager);// Handle both search page and bookmarks page cases for work retrievalconst work = document.querySelector(`li#work_${workId}`) || document.querySelector(`li.work-${workId}`);// Update data from localStorage to properly highlight workthis.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 reqasync 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 textconst 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 panelclass SettingsPageHandler {constructor(settings) {this.settings = settings;this.init();}init() {// Inject PetiteVue & insert the UI afterthis.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-vueinjectVueScript(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 apploadSettingsPanel() {const container = document.createElement('fieldset');// Fetching the HTML for settings panel, outsourced for less cluttercontainer.innerHTML = GM_getResourceText('settingsPanelHtml');document.querySelector('#main').appendChild(container);// Initialize the Vue app instancePetiteVue.createApp({selectedStatus: 1,ficTrackerSettings: this.settings,// Computed prop for retrieving settings updatesget currentSettings() {return this.ficTrackerSettings.statuses[this.selectedStatus];},// Computed prop for updating the preview box stylesget 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 methodsexportData: this.exportSettings.bind(this),importData: this.importSettings.bind(this),// Save the settings to the storagesaveSettings() {localStorage.setItem('FT_settings', JSON.stringify(this.ficTrackerSettings));DEBUG && console.log('[FicTracker] Settings saved.');},// Reset settings to default valuesresetSettings() {// Confirm before resetting settingsconst 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 localStoragelocalStorage.removeItem('FT_settings');// Alert successalert("Settings have been reset to default.");}}}).mount();}// Exports user data (favorites, finished, toread) into a JSON fileexportSettings() {// Formatted timestamp for exportconst 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 formatconst blob = new Blob([JSON.stringify(exportData)], {type: 'application/json'});// Generate a URL for the Blob object to enable downloadingconst url = URL.createObjectURL(blob);// Create a temp link to downlad the generate file dataconst 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 downloada.click();// Cleanup after the downloaddocument.body.removeChild(a);URL.revokeObjectURL(url);// Update the last export timestampthis.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 appendedimportSettings(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 pathclass URLHandler {constructor() {this.handlers = [];}// Add a new handler with associated patterns to the handlers arrayaddHandler(patterns, handler) {this.handlers.push({patterns,handler});}// Iterate through registered handlers to find a match for the current pathmatchAndHandle(currentPath) {for (const {patterns,handler}of this.handlers) {if (patterns.some(pattern => pattern.test(currentPath))) {// Execute the corresponding handler if a match is foundhandler();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 FicTrackerclass FicTracker {constructor() {// Merge stored settings to match updated structure, assign default settings on fresh installationthis.mergeSettings();// Load settings and initialize other featuresthis.settings = this.loadSettings();// Filter out disabled statusesthis.settings.statuses = this.settings.statuses.filter(status => status.enabled !== false);this.initStyles();this.addDropdownOptions();this.setupURLHandlers();}// Method to merge settings / store the default onesmergeSettings() {// Check if settings already exist in localStoragelet storedSettings = JSON.parse(localStorage.getItem('FT_settings'));if (!storedSettings) {// No settings found, save default settingslocalStorage.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 metadataconst currentVersion = GM_info.script.version;if (!storedSettings.version || storedSettings.version !== currentVersion) {// If versions don't match, merge and update the versionstoredSettings = _.defaultsDeep(storedSettings, settings);// Update the version markerstoredSettings.version = currentVersion;// Save the updated settings back to localStoragelocalStorage.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 onesloadSettings() {// Measure performance of loading settings from localStorageconst 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 settingsinitStyles() {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 menuaddDropdownOptions() {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 optionsthis.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 pagessetupURLHandlers() {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 ficsurlHandler.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 pageurlHandler.addHandler([/\/users\/.+\/preferences/],() => {const settingsPage = new SettingsPageHandler(this.settings);});// Execute handler based on the current URLconst currentPath = window.location.href;urlHandler.matchAndHandle(currentPath);}}// Instantiate the FicTracker classconst ficTracker = new FicTracker();})();