AO3 FicTracker

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 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 = `<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.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() {

            // 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();

})();