`;
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();
}
// Initialize user notes manager
this.userNotesManager = new CustomUserNotesManager(this.storageManager, this.remoteSyncManager);
this.loadStoredIds();
// Update the work list upon initialization
this.updateWorkList();
// Listen for clicks on quick tag buttons
this.setupQuickTagListener();
this.setupOnPageSorting();
}
// 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 => {
// Skip deleted works that show the "deleted" message
if (work.querySelector('.message')?.textContent.includes('has been deleted')) {
DEBUG && console.log('[FicTracker] Skipping deleted work:', work.id);
return;
}
const workId = this.getWorkId(work);
// Skip if we couldn't get a valid work ID
if (!workId) {
DEBUG && console.log('[FicTracker] Skipping work - could not get work ID');
return;
}
// Only status highlighting for now, TBA
this.highlightWorkStatus(work, workId, true);
// Reload stored IDs to reflect any changes in storage (from fic card)
this.loadStoredIds();
this.addQuickTagDropdown(work);
// Display note management btn if enabled
if (settings.displayUserNotes) {
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, cardToStorageSync = false) {
let shouldBeCollapsable = false;
const appliedStatuses = new Set();
// First check localStorage statuses
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);
appliedStatuses.add(status);
const statusSettings = getStatusSettingsByStorageKey(status);
if (statusSettings?.collapse === true) {
shouldBeCollapsable = true;
}
} else {
work.classList.remove(statusClass);
}
});
// If no status was found in localStorage, check for bookmark tags in the card
if (appliedStatuses.size === 0 && cardToStorageSync === true) {
const userModule = work.querySelector('div.own.user.module.group');
DEBUG && console.debug(`[FicTracker] Checking bookmark card for work ${workId}`);
if (userModule) {
const tagsList = userModule.querySelector('ul.meta.tags.commas');
if (tagsList) {
const tagElements = tagsList.querySelectorAll('a.tag');
tagElements.forEach(tagElement => {
const tagText = tagElement.textContent.trim();
// Find matching status in settings
const matchingStatus = settings.statuses.find(status => status.tag === tagText);
if (matchingStatus) {
const statusClass = `glowing-border-${matchingStatus.storageKey}`;
work.classList.add(statusClass);
appliedStatuses.add(matchingStatus.storageKey);
DEBUG && console.log(`[FicTracker] Found status tag: ${tagText}`);
// Add the work ID to storage if it's not there yet
this.storageManager.addIdToCategory(matchingStatus.storageKey, workId);
DEBUG && console.log(`[FicTracker] Synced work ${workId} to storage for status: ${matchingStatus.storageKey}`);
if (matchingStatus.collapse === true) {
shouldBeCollapsable = true;
}
}
});
}
}
}
const ownBookmarksPage = isOwnBookmarksPage();
const collapseAllowed = !ownBookmarksPage || settings.collapseAndHideOnBookmarks;
// If at least one of the statuses of the work is set to be collapsable - let it be so
// But check if we're on own bookmarks page and collapse is disabled there
if (shouldBeCollapsable && collapseAllowed) {
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.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 functionality to the work
addNoteButton(work) {
const workId = this.getWorkId(work);
// div.header.module | ul.tags.commas
const container = work.querySelector('blockquote.userstuff.summary');
// Add the note block
//beforeend
container.insertAdjacentHTML('afterend',
this.userNotesManager.generateNoteHtml(workId)
);
}
// Setup note handlers for the works list
prefillNotes() {
if (!settings.displayUserNotes) return;
// div#main.filtered.region, div#main.works-search.region, div#main.series-show.region
const container = document.querySelector('div#main.region');
this.userNotesManager.setupNoteHandlers(container);
}
// 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);
}
}
// Setup on-page sorting functionality on own bookmarks page
setupOnPageSorting() {
if (isOwnBookmarksPage() && document.querySelector('form#bookmark-filters')) {
this.injectSortUI();
this.setupSortListener();
}
}
// Inject sorting UI into the filters form
injectSortUI() {
const filtersForm = document.querySelector('form#bookmark-filters fieldset dl');
if (filtersForm) {
const sortUI = `
`;
filtersForm.insertAdjacentHTML('afterbegin', sortUI);
}
}
// Setup listener for sort selection changes
setupSortListener() {
const sortSelect = document.getElementById('ft_onpage_sort');
if (sortSelect) {
sortSelect.addEventListener('change', (event) => {
const sortBy = event.target.value;
if (sortBy) {
this.sortBookmarks(sortBy);
}
});
}
}
// Sort bookmarks on the page based on selected criteria
sortBookmarks(sortBy) {
const container = document.querySelector('ol.bookmark.index.group');
if (!container) return;
const bookmarks = Array.from(container.querySelectorAll('li.bookmark.blurb'));
const getSortableValue = (bookmark, criteria) => {
let value;
switch (criteria) {
case 'authors_to_sort_on':
value = bookmark.querySelector('a[rel="author"]')?.textContent.trim().toLowerCase();
return value || '';
case 'title_to_sort_on':
value = bookmark.querySelector('h4.heading a')?.textContent.trim().toLowerCase();
return value || '';
case 'revised_at':
value = bookmark.querySelector('p.datetime').textContent.trim();
return new Date(bookmark.querySelector('p.datetime').textContent.trim()).getTime() || 0;
case 'word_count':
value = bookmark.querySelector('dd.words')?.textContent.replace(/,/g, '');
return parseInt(value) || 0;
case 'hits':
value = bookmark.querySelector('dd.hits')?.textContent.replace(/,/g, '');
return parseInt(value) || 0;
case 'kudos_count':
value = bookmark.querySelector('dd.kudos a')?.textContent.replace(/,/g, '');
return parseInt(value) || 0;
case 'comments_count':
value = bookmark.querySelector('dd.comments a')?.textContent.replace(/,/g, '');
return parseInt(value) || 0;
case 'bookmarks_count':
value = bookmark.querySelector('dd.bookmarks a')?.textContent.replace(/,/g, '');
return parseInt(value) || 0;
default:
return 0;
}
};
bookmarks.sort((a, b) => {
const valA = getSortableValue(a, sortBy);
const valB = getSortableValue(b, sortBy);
if (typeof valA === 'string') {
return valA.localeCompare(valB);
} else {
// For numeric values, sort descending (more is better)
return valB - valA;
}
});
// Re-append sorted bookmarks
bookmarks.forEach(bookmark => container.appendChild(bookmark));
}
}
// 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 = `
FicTracker Settings
Change Tag Order
Use arrows to change order. This will affect the order of buttons on work pages and in "Change Status" dropdown.
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 canDeleteSelected() {
// Prevent deleting built-ins
const builtInKeys = ['FT_finished', 'FT_favorites', 'FT_toread', 'FT_disliked'];
return !builtInKeys.includes(this.ficTrackerSettings.statuses[this.selectedStatus].storageKey);
},
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,
// Status CRUD
moveStatus(index, direction) {
const statuses = this.ficTrackerSettings.statuses;
const selectedObject = statuses[this.selectedStatus];
const newIndex = index + direction;
if (newIndex < 0 || newIndex >= statuses.length) return;
const [movedStatus] = statuses.splice(index, 1);
statuses.splice(newIndex, 0, movedStatus);
// Wait until Vue updates the DOM and reactivity system after the reorder,
// then recalculate the selected index to keep the correct status selected.
this.$nextTick(() => {
this.selectedStatus = statuses.indexOf(selectedObject);
});
},
addStatus() {
const baseKey = 'FT_custom_' + Date.now();
const newStatus = {
tag: 'New Tag',
dropdownLabel: 'My New Tag Fanfics',
positiveLabel: 'β Add Tag',
negativeLabel: 'π§Ή Remove Tag',
selector: baseKey + '_btn',
storageKey: baseKey,
enabled: true,
collapse: false,
displayInDropdown: true,
highlightColor: '#888888',
borderSize: 2,
opacity: 1,
hide: false
};
this.ficTrackerSettings.statuses.push(newStatus);
this.selectedStatus = this.ficTrackerSettings.statuses.length - 1;
},
deleteStatus() {
if (!this.canDeleteSelected) return;
const status = this.ficTrackerSettings.statuses[this.selectedStatus];
const confirmMsg = `Delete tag "${status.tag}" and its highlighting settings?\nThis will not remove any AO3 bookmarks or tags.`;
if (!confirm(confirmMsg)) return;
// Remove local storage lists for this custom tag if we used any
// We only stored lists under storageKey. Clean it.
try { localStorage.removeItem(status.storageKey); } catch (e) {}
// Remove from list and clamp selected index
this.ficTrackerSettings.statuses.splice(this.selectedStatus, 1);
this.selectedStatus = Math.max(0, Math.min(this.selectedStatus, this.ficTrackerSettings.statuses.length - 1));
this.saveSettings();
},
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') || '{}')),
};
try {
const allStatuses = this.ficTrackerSettings.statuses;
for (const s of allStatuses) {
initData[s.storageKey] = localStorage.getItem(s.storageKey) || '';
}
} catch (e) {
DEBUG && console.warn('[FicTracker] Failed to build initData for dynamic statuses:', e);
}
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 (all statuses, notes, and statuses config) into a JSON file
exportSettings() {
// Formatted timestamp for export
const exportTimestamp = new Date().toISOString().slice(0, 16).replace('T', ' ');
const exportData = {
FT_userNotes: localStorage.getItem('FT_userNotes'),
};
try {
const allStatuses = this.settings.statuses;
for (const s of allStatuses) {
exportData[s.storageKey] = localStorage.getItem(s.storageKey);
}
} catch (e) {
DEBUG && console.warn('[FicTracker] Failed to collect dynamic status keys for export:', e);
}
// Only include status configuration if the setting is enabled
if (this.settings.exportStatusesConfig) {
exportData.FT_statusesConfig = JSON.stringify(this.settings.statuses);
}
// Create a Blob object from the export data, converting it to JSON format
const blob = new Blob([JSON.stringify(exportData, null, 2)], {
type: 'application/json'
});
// Generate a URL for the Blob object to enable downloading
const url = URL.createObjectURL(blob);
// Create a temp link to download the generated 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;
// Warn user when Google Sheets sync is enabled to prevent imported data being overwritten
if (this.settings && this.settings.syncEnabled) {
const proceed = confirm("Google Sheets sync is currently ENABLED.\n\nIf you import now, the next sync may overwrite your imported data with what is currently stored in the Sheet.\n\nRecommended options:\n 1) Disable Google Sheets sync, import your file. Re-enabling with the same Sheet will overwrite your import.\n 2) OR: Temporarily Disable sync, import, then re-enable sync using a NEW Google Sheet URL to avoid pulling stale data.\n\nDo you still want to proceed with the import right now?");
if (!proceed) {
event.target.value = '';
return;
}
}
const reader = new FileReader();
reader.onload = (e) => {
try {
const importedData = JSON.parse(e.target.result);
this.mergeImportedData(importedData);
// Reset the file input to allow reimporting the same file
event.target.value = '';
} catch (err) {
DEBUG && console.error('[FicTracker] Error importing data:', err);
alert('Error importing data. Please check if the file is valid.');
}
};
reader.onerror = () => {
DEBUG && console.error('[FicTracker] Error reading file:', reader.error);
alert('Error reading file. Please try again.');
event.target.value = '';
};
reader.readAsText(file);
}
mergeImportedData(importedData) {
// First, if statuses config provided, load it so we know all dynamic keys
if (importedData.FT_statusesConfig) {
try {
const importedStatuses = JSON.parse(importedData.FT_statusesConfig);
this.settings.statuses = importedStatuses;
localStorage.setItem('FT_settings', JSON.stringify(this.settings));
} catch (err) {
DEBUG && console.error('[FicTracker] Error importing status configuration:', err);
}
}
// Track new entries per known keys + notes at the end
let newEntriesMap = {};
// Merge all status list keys found in the file that are in our configured statuses
const knownKeys = new Set((this.settings.statuses || []).map(s => s.storageKey));
Object.keys(importedData).forEach((key) => {
if (!knownKeys.has(key)) return;
const currentData = localStorage.getItem(key) ? localStorage.getItem(key).split(',') : [];
const newData = (importedData[key] || '').split(',').filter(Boolean);
const initialLen = currentData.length;
const mergedData = [...new Set([...currentData, ...newData])];
newEntriesMap[key] = mergedData.length - initialLen;
localStorage.setItem(key, mergedData.join(','));
});
// Handle user notes (JSON data)
if (importedData.FT_userNotes) {
try {
const currentNotes = JSON.parse(localStorage.getItem('FT_userNotes') || '{}');
const importedNotes = JSON.parse(importedData.FT_userNotes);
// Merge notes, keeping newer versions if there are conflicts
const mergedNotes = { ...currentNotes, ...importedNotes };
localStorage.setItem('FT_userNotes', JSON.stringify(mergedNotes));
const newNotesCount = Object.keys(importedNotes).length - Object.keys(currentNotes).length;
newEntriesMap['FT_userNotes'] = Math.max(0, newNotesCount);
} catch (err) {
DEBUG && console.error('[FicTracker] Error merging user notes:', err);
newEntriesMap['FT_userNotes'] = 0;
}
}
// Build a dynamic summary
let summaryLines = [];
(this.settings.statuses || []).forEach((s) => {
const count = newEntriesMap[s.storageKey] || 0;
summaryLines.push(`${s.tag}: ${count}`);
});
const notesAdded = newEntriesMap['FT_userNotes'] || 0;
summaryLines.push(`Notes: ${notesAdded}`);
alert(`Data imported successfully!\n` + summaryLines.join('\n'));
DEBUG && console.log('[FicTracker] Data imported successfully. Stats:', newEntriesMap);
}
}
// 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;
}
`);
}
// 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',
`