`;
actionsMenu.insertAdjacentHTML('beforeend', buttonHtml);
// insert button duplicate at the bottom
if (settings.displayBottomActionButtons) {
bottomActionsMenu.insertAdjacentHTML('beforeend', buttonHtml);
}
});
this.setupClickListeners();
}
// Set up click listeners for each action button
setupClickListeners() {
settings.statuses.forEach(({
selector,
tag,
positiveLabel,
negativeLabel,
storageKey,
enabled
}) => {
// Don't setup listener for disabled btn
if (!enabled) return;
// 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, this.remoteSyncManager);
// 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/');
// Start remote manager if enabled in settings
if (settings.syncEnabled) {
this.remoteSyncManager = new RemoteStorageSyncManager();
this.remoteSyncManager.init();
}
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);
// Display note management btn if enabled
if (settings.displayUserNotesBtn) {
this.addNoteButton(work);
}
});
// Prefill all notes, listen for edits
this.prefillNotes();
}
// 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) {
let shouldBeCollapsable = false;
Object.entries(this.worksStoredIds).forEach(([status, storedIds]) => {
const statusClass = `glowing-border-${status}`;
const hasStatus = storedIds.includes(workId);
if (hasStatus) {
// Add appropriate class for collapsable works
work.classList.add(statusClass);
const statusSettings = getStatusSettingsByStorageKey(status);
if (statusSettings?.collapse === true) {
shouldBeCollapsable = true;
}
} else {
work.classList.remove(statusClass);
}
});
// If at least one of the statuses of the work is set to be collapsable - let it be so
if (shouldBeCollapsable) {
work.classList.add('FT_collapsable');
} else {
work.classList.remove('FT_collapsable');
}
}
// 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) => {
let statusSettings = getStatusSettingsByStorageKey(status);
// Don't render disabled statuses
if (!statusSettings.enabled) return;
const statusLabel = statusSettings[storedIds.includes(workId) ? 'negativeLabel' : 'positiveLabel'];
return `
`;
});
// No status is enabled, dont render Change Status menu
if (dropdownItems.length === 0) return;
work.querySelector('dl.stats').insertAdjacentHTML('beforeend', `
β¨ Change Status βΌ
${dropdownItems.join('')}
`);
}
// 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 storageKey = event.target.dataset.statusName;
const statusSettings = getStatusSettingsByStorageKey(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, this.remoteSyncManager);
// 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 ?
statusSettings.positiveLabel :
statusSettings.negativeLabel;
} catch (error) {
console.error(`[FicTracker] Error during bookmark operation:`, error);
}
}
})
}
// Add note editor button to the work
addNoteButton(work) {
const workId = this.getWorkId(work);
work.insertAdjacentHTML('beforeend', `
`);
}
// Prefills all existing notes on the page, listens for note editing
prefillNotes() {
// Load all saved notes from localStorage
let allNotes = {};
try {
allNotes = JSON.parse(this.storageManager.getItem("FT_userNotes")) || {};
} catch (e) {
allNotes = {};
}
document.querySelectorAll(".note-keeper").forEach(noteEl => {
const workId = noteEl.dataset.workId;
const textarea = noteEl.querySelector("textarea");
// Pre-fill textarea if note exists
if (allNotes[workId]) {
const noteData = allNotes[workId];
const noteText = typeof noteData === 'string' ? noteData : noteData.text;
const noteDate = typeof noteData === 'string' ? null : noteData.date;
const displayDate = noteDate ? new Date(noteDate).toLocaleDateString() : 'Unknown date';
textarea.value = noteText;
// Display user note in work card
if (settings.displayUserNotes) {
this.renderUserNotePreview(workId, noteText, displayDate);
}
}
// Click handling per .note-keeper
noteEl.addEventListener("click", (e) => {
const btn = e.target.closest("button");
if (!btn) return;
const container = noteEl.querySelector(".note-container");
if (btn.classList.contains("toggle-note-btn")) {
container.style.display = container.style.display === "none" ? "block" : "none";
}
// Save note
if (btn.classList.contains("save-note-btn")) {
const noteText = textarea.value.trim();
let date = new Date().toISOString();
// If note is empty, delete from storage
if (noteText === "") {
delete allNotes[workId];
btn.textContent = "ποΈ Deleted";
} else {
// Save note with timestamp
allNotes[workId] = {
text: noteText,
date
};
}
// Save the note to storage and append note container
this.storageManager.setItem("FT_userNotes", JSON.stringify(allNotes));
if (this.remoteSyncManager) {
this.remoteSyncManager.addPendingNoteUpdate(workId, noteText, date);
}
this.renderUserNotePreview(workId, noteText, new Date().toISOString());
container.style.display = "none";
}
// Delete existing note
if (btn.classList.contains("clear-note-btn")) {
if (confirm("Clear this note?")) {
textarea.value = "";
delete allNotes[workId];
this.storageManager.setItem("FT_userNotes", JSON.stringify(allNotes));
if (this.remoteSyncManager) {
this.remoteSyncManager.addPendingNoteUpdate(workId, "", null);
}
// Remove note container from DOM
const workLi = noteEl.closest('li.work');
const existingNoteContainer = workLi?.querySelector(`.user-note-preview[data-work-id="${workId}"]`);
existingNoteContainer?.remove();
// Hide note textarea
container.style.display = "none";
}
}
});
});
}
// Renders HTML for user note (create, edit, remove)
renderUserNotePreview(workId, noteText, noteDate) {
const workEl = document.querySelector(`.note-keeper[data-work-id="${workId}"]`)?.closest('li.work');
if (!workEl) return;
const container = workEl.querySelector('div.header.module');
const existingPreview = container.querySelector(`.user-note-preview[data-work-id="${workId}"]`);
const displayDate = noteDate ? new Date(noteDate).toLocaleDateString() : 'Unknown date';
const detailsOpen = settings.expandUserNoteDetails ? 'open' : '';
const html = `
π Your Note
${noteText}
π Last updated: ${displayDate} | π ${noteText.length} characters
`;
if (existingPreview) {
existingPreview.outerHTML = html;
} else {
container.insertAdjacentHTML('beforeend', html);
}
}
// 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();
if (this.settings.syncEnabled) {
this.initRemoteSyncManager();
}
}
init() {
// Inject PetiteVue & insert the UI after
this.injectVueScript(() => {
this.loadSettingsPanel();
});
}
initRemoteSyncManager() {
if (!this.remoteSyncManager) {
this.remoteSyncManager = new RemoteStorageSyncManager();
this.remoteSyncManager.init();
}
}
// 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');
// HTML template for the settings panel
const settingsPanelHtml = `
Last data export: {{ ficTrackerSettings.lastExportTimestamp }}
`
// Fetching the HTML for settings panel, outsourced for less clutter
container.innerHTML = settingsPanelHtml;
document.querySelector('#main').appendChild(container);
// Initialize the Vue app instance
PetiteVue.createApp({
selectedStatus: 0,
ficTrackerSettings: this.settings,
lastSyncTime: null,
timeUntilSync: null,
sheetConnectionStatus: {},
syncFeedback: {},
initStatus: null,
readyToInitDB: false,
modalGoogleSyncInfo: "
What is Google Sheets Storage Sync?
This feature allows you to sync all your FicTracker data across multiple devices by using Google Sheets as the source of truth data storage. When you first initialize the database on a device, the storage fills with your current data.
Recommendation: If you only use FicTracker on one device, basic syncing via AO3 storage is sufficient. However, if you use multiple devices and want near real-time syncing (~60 seconds), connecting to Google Sheets is worth it. The setup takes only 2-3 minutes.
How to connect two devices:
Master device: Initialize the database by clicking Initialize DB to create your Google Sheets storage.
Second device: Use the same Google Sheets link and click Initialize DB. It will detect the storage is already initialized and sync your data.
After setup, syncing happens automatically and quickly, keeping your data up-to-date on all devices.
What is synced automatically? (Without Google Sheets connection)
Bookmarked fics with appropriate tags
Bookmark notes - these are stored directly on AO3 servers
Due to technical limitations, fic highlighting and custom user notes cannot be saved on AO3 and require external storage. Google Sheets provides a free, simple, and reliable way to store and sync this data across devices.
What requires Google Sheets DB connection?
Highlighting sync
User notes sync
",
// Loading states for different sync feature ops
loadingStates: {
testConnection: false,
sync: false,
initialize: false
},
// Computed
get currentSettings() {
return this.ficTrackerSettings.statuses[this.selectedStatus];
},
get previewStyle() {
const s = this.currentSettings;
const borderSize = s.borderSize ?? 0;
const hasBorder = borderSize > 0;
return {
height: '50px',
border: hasBorder ? `${s.borderSize}px solid ${s.highlightColor}` : 'none',
boxShadow: hasBorder ?
`0 0 10px ${s.highlightColor}, 0 0 20px ${s.highlightColor}` :
'none',
opacity: s.opacity
};
},
get lastSyncTimeFormatted() {
if (!this.lastSyncTime) return 'Never';
const ts = parseInt(this.lastSyncTime);
const date = isNaN(ts) ? null : new Date(ts);
return date ? date.toLocaleString() : 'Never';
},
// Core Methods
exportData: this.exportSettings.bind(this),
importData: this.importSettings.bind(this),
initRemoteSyncManager: this.initRemoteSyncManager.bind(this),
// Conditionally add sync method only if remote sync manager is initialized
performSync: async () => {
if (this.remoteSyncManager) {
return await this.remoteSyncManager.performSync();
} else {
console.warn('Sync is not available - sync manager not initialized');
throw new Error('Sync is not available');
}
},
// Pass func through global scope
displayModal: displayModal,
saveSettings() {
localStorage.setItem('FT_settings', JSON.stringify(this.ficTrackerSettings));
DEBUG && console.log('[FicTracker] Settings saved.');
},
resetSettings() {
const confirmed = confirm("Are you sure you want to reset all settings to default? This will delete all saved settings.");
if (confirmed) {
localStorage.removeItem('FT_settings');
alert("Settings have been reset to default.");
}
},
// Reset all seting related to cloud data sync
resetSyncSettings() {
const confirmed = window.confirm(
"This will disable the current database connection.\n\n" +
"You can still connect again later using a different link.\n\n" +
"Do you want to proceed?"
);
if (!confirmed) return;
this.ficTrackerSettings.sheetUrl = '';
this.ficTrackerSettings.syncDBInitialized = false;
this.ficTrackerSettings.syncEnabled = false;
localStorage.removeItem('FT_lastSync');
this.saveSettings();
// Clear any existing status messages
this.sheetConnectionStatus = {};
this.syncFeedback = {};
},
// New: Google Sheet Sync logic
async syncNow() {
DEBUG && console.log('[FicTracker] Manual sync initiated...');
// Indicate that a sync operation is in progress (for UI/loading indicators)
this.loadingStates.sync = true;
// Clear previous sync feedback and connection status indicators
this.syncFeedback = {};
this.sheetConnectionStatus = {};
try {
// Attempt to perform the sync and update the last successful sync timestamp
await this.performSync();
this.updateLastSyncTime();
// Set success feedback message to inform the user
this.syncFeedback = {
success: true,
message: 'Sync completed successfully!'
};
// Auto-clear success message after 5 seconds
setTimeout(() => {
if (this.syncFeedback && this.syncFeedback.success) {
this.syncFeedback = {};
}
}, 5000);
// Handle and log sync errors, provide user-facing error message
} catch (error) {
DEBUG && console.error('[FicTracker] Sync failed:', error);
this.syncFeedback = {
success: false,
message: `Sync failed: ${error.message}`
};
// Ensure loading state is reset whether sync succeeds or fails
} finally {
this.loadingStates.sync = false;
}
},
updateLastSyncTime() {
// Retrieve the last sync timestamp from local storage and update internal state
const ts = localStorage.getItem('FT_lastSync');
this.lastSyncTime = ts;
},
// Tests connectivity to the provided Google Sheets URL by sending a ping request.
// Updates the UI with the result and saves settings if successful.
testSheetConnection() {
const url = this.ficTrackerSettings.sheetUrl;
DEBUG && console.log('[FicTracker] Testing connection to Google Sheets URL:', url);
// Validate if the Google Sheets URL is provided
if (!url) {
// Indicate that a test connection is in progress and reset status messages
this.sheetConnectionStatus = {
success: false,
message: 'URL is empty'
};
return;
}
this.loadingStates.testConnection = true;
this.sheetConnectionStatus = {};
this.syncFeedback = {};
// Send a ping request to the Google Sheets endpoint to verify connection
GM_xmlhttpRequest({
method: 'GET',
url: `${url}?action=ping`,
onload: (response) => {
this.loadingStates.testConnection = false;
try {
// Parse the response and update connection status based on server reply
const data = JSON.parse(response.responseText);
if (data.status === 'success') {
DEBUG && console.log('[FicTracker] Sheet connection successful:', data);
// If connection is successful, save settings and display a confirmation message
this.sheetConnectionStatus = {
success: true,
message: data.data || 'Connection successful!'
};
this.readyToInitDB = true;
this.saveSettings();
// Auto-clear success message after 5 seconds
setTimeout(() => {
if (this.sheetConnectionStatus && this.sheetConnectionStatus.success) {
this.sheetConnectionStatus = {};
}
}, 5000);
} else {
DEBUG && console.warn('[FicTracker] Sheet connection failed:', data);
// Handle and display error message if the server returned a failure status
this.sheetConnectionStatus = {
success: false,
message: data.message || 'Unknown error'
};
}
// Catch JSON parsing errors and report invalid server response
} catch (e) {
DEBUG && console.error('[FicTracker] Failed to parse server response during test connection:', response.responseText);
this.sheetConnectionStatus = {
success: false,
message: 'Invalid response from server'
};
}
},
// Handle connection-level errors like CORS or unreachable URL
onerror: (err) => {
DEBUG && console.error('[FicTracker] Network error during sheet connection test:', err);
this.loadingStates.testConnection = false;
this.sheetConnectionStatus = {
success: false,
message: 'Network error - check your connection'
};
}
});
},
// Initializes Google Sheets storage by uploading current local FicTracker data.
// Marks DB as initialized and updates sync timestamp on success.
initializeSheetStorage() {
const url = this.ficTrackerSettings.sheetUrl;
// Validate that the Google Sheets URL is set
if (!url) {
this.sheetConnectionStatus = {
success: false,
message: 'URL is empty'
};
return;
}
// Set loading state and clear any previous status or feedback
this.loadingStates.initialize = true;
this.sheetConnectionStatus = {};
this.syncFeedback = {};
// Gather current local storage data to be uploaded to Google Sheets
const initData = {
FT_userNotes: JSON.stringify(JSON.parse(localStorage.getItem('FT_userNotes') || '{}')),
FT_favorites: localStorage.getItem('FT_favorites') || '',
FT_disliked: localStorage.getItem('FT_disliked') || '',
FT_toread: localStorage.getItem('FT_toread') || '',
FT_finished: localStorage.getItem('FT_finished') || '',
};
DEBUG && console.log('[FicTracker] Initializing Google Sheets with data:', initData);
// Send initialization request to Google Sheets endpoint
GM_xmlhttpRequest({
method: 'POST',
url: url,
headers: {
'Content-Type': 'application/json'
},
data: JSON.stringify({
action: 'initialize',
initData
}),
onload: (response) => {
this.loadingStates.initialize = false;
try {
// Parse and handle successful initialization response
const data = JSON.parse(response.responseText);
DEBUG && console.log('[FicTracker] DB Initialization response data:', data);
if (data.status === 'success') {
// Store sync initialization status, timestamp, and update UI
this.sheetConnectionStatus = {
success: true,
message: data.data?.message || 'Google Sheet initialized successfully!'
};
this.ficTrackerSettings.syncDBInitialized = true;
if (this.ficTrackerSettings.syncEnabled && !this.remoteSyncManager) {
this.initRemoteSyncManager();
}
localStorage.setItem('FT_lastSync', Date.now().toString());
this.saveSettings();
this.updateLastSyncTime();
// Auto-clear success message after 7 seconds
setTimeout(() => {
if (this.sheetConnectionStatus && this.sheetConnectionStatus.success) {
this.sheetConnectionStatus = {};
}
}, 7000);
} else {
// Handle error response from server
this.sheetConnectionStatus = {
success: false,
message: data.message || 'Initialization failed'
};
}
// Catch JSON parsing errors and log them
} catch (e) {
DEBUG && console.error('[FicTracker] Invalid JSON response during initialization:', response.responseText);
this.sheetConnectionStatus = {
success: false,
message: 'Invalid response from server'
};
}
},
// Handle connection errors like timeouts or offline state
onerror: (err) => {
DEBUG && console.error('[FicTracker] Network error during initialization:', err);
this.loadingStates.initialize = false;
this.sheetConnectionStatus = {
success: false,
message: 'Network error - check your connection'
};
}
});
},
// Lifecycle hook that sets up real-time countdown for next sync based on last sync timestamp.
onMounted() {
// Function to calculate and update time remaining until next sync
const trackSyncTime = () => {
this.updateLastSyncTime();
const elapsed = Date.now() - parseInt(this.lastSyncTime);
const remaining = this.ficTrackerSettings.syncInterval - elapsed / 1000;
this.timeUntilSync = Math.max(0, Math.round(remaining));
}
// Initial update on component mount
trackSyncTime();
// Update the countdown every second
setInterval(() => {
trackSyncTime();
}, 1000);
}
}).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() {
// Dynamic styles generation for each status, this will allow adding custom statuses in the future updates
const statusStyles = StyleManager.generateStatusStyles();
StyleManager.addCustomStyles(`
${statusStyles}
li.FT_collapsable .landmark,
li.FT_collapsable .tags,
li.FT_collapsable .series,
li.FT_collapsable h5.fandoms.heading,
li.FT_collapsable .userstuff {
display: none;
}
/* Uncollapse on hover */
li.FT_collapsable:hover .landmark,
li.FT_collapsable:hover .tags,
li.FT_collapsable:hover ul.series,
li.FT_collapsable:hover h5.fandoms.heading,
li.FT_collapsable:hover .userstuff {
display: block;
}
.note-container {
position: absolute;
bottom: 40px;
display: none;
width: 280px;
border-radius: 6px;
}
.note-container textarea {
width: 100%;
min-height: 150px;
font-size: 0.7em;
resize: vertical;
}
.save-note-btn {
position: absolute;
bottom: -20px;
right: -5px;
font-size: 0.8em;
border: 1px solid #ccc;
padding: 2px 6px;
border-radius: 4px;
cursor: pointer;
z-index: 1;
}
.clear-note-btn {
position: absolute;
bottom: -20px;
right: 95px;
font-size: 0.8em;
border: 1px solid #ccc;
padding: 2px 6px;
border-radius: 4px;
cursor: pointer;
z-index: 1;
}
.toggle-note-btn:hover {
color: #000;
}
`);
}
// 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((status) => {
if (status.displayInDropdown) {
userMenu.insertAdjacentHTML(
'beforeend',
`