您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Advanced search and filtering for your reading history. Find fics by title, author, fandom, tags, or summary.
// ==UserScript== // @name FicTrail - AO3 History Viewer // @namespace https://github.com/serpentineegg/fictrail // @version 0.3.3 // @description Advanced search and filtering for your reading history. Find fics by title, author, fandom, tags, or summary. // @author serpentineegg // @match https://archiveofourown.org/users/*/readings* // @grant GM_addStyle // @run-at document-idle // @license MIT // ==/UserScript== (function() { 'use strict'; // Constants Module - Configuration and constants const AO3_BASE_URL = 'https://archiveofourown.org'; const MAX_PAGES_FETCH = 100; const ITEMS_PER_PAGE = 20; const DEFAULT_PAGES_TO_LOAD = 2; const PAGE_FETCH_DELAY = 100; const CACHE_EXPIRY_MS = 30 * 60 * 1000; // 30 minutes // Error Messages const ERROR_MESSAGES = { FETCH_FAILED: 'Uh oh! Something went wrong while fetching your reading adventures. Let\'s try again?', NO_DATA: 'Hmm, we didn\'t get any fic data back. Want to try that again?' }; // Utils Module - Helper functions function escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; } // Check if we're on AO3 and user is logged in function getUsername() { const greetingLink = document.querySelector('#greeting .user a[href*="/users/"]'); if (greetingLink) { const href = greetingLink.getAttribute('href'); const match = href.match(/\/users\/([^/]+)/); return match ? match[1] : null; } return null; } // Detect logged-out state based on AO3 login indicators within a fetched Document function isLoggedOutDoc(doc) { const loginLink = doc.querySelector('a[href*="/users/login"]'); const loggedOutMessage = doc.querySelector('.flash.notice'); return Boolean(loginLink || (loggedOutMessage && loggedOutMessage.textContent.includes('log in'))); } // Styles Module - CSS will be injected during build function addStyles() { // CSS content will be injected here during build const css = `/* Subtitle styling */ #fictrail-subtitle { display: flex; flex-wrap: wrap; gap: 0 8px; align-items: center; justify-content: center; } #fictrail-subtitle span:not(:first-child)::before { content: " • "; margin-right: 4px; } /* Favorite tags summary */ #fictrail-favorite-tags-summary-container { text-align: center; font-style: italic; } /* === REUSABLE COMPONENTS === */ .fictrail-content-state { padding-top: 1em; } .fictrail-spinner { width: 40px; height: 40px; border: 3px solid inherit; border-top-color: transparent; border-radius: 50%; animation: fictrail-spin 1s linear infinite; margin: 0 auto 1em; opacity: 0.6; } /* === LAYOUT PATTERNS === */ .fictrail-search-row { display: flex; gap: 1em; align-items: stretch; } .fictrail-search-field { flex: 2; display: flex; flex-direction: column; gap: 0.3em; } .fictrail-filter-field { flex: 0 0 250px; max-width: 250px; display: flex; flex-direction: column; gap: 0.3em; } .fictrail-slider-field { display: flex; flex-direction: column; gap: 0.5em; margin: 1em 0; align-items: center; } .fictrail-slider-track { display: flex; align-items: center; justify-content: center; gap: 1em; } /* === COMPONENT VARIATIONS === */ .fictrail-slider-min, .fictrail-slider-max { font-size: 0.8em; min-width: 1.5em; text-align: center; } .fictrail-pages-section { margin: 1em 0; } .fictrail-pages-toggle { cursor: pointer; user-select: none; padding: 0.8em 1em; border-bottom: 1px solid transparent; transition: background-color 0.2s ease; } .fictrail-pages-toggle:hover, .fictrail-pages-toggle:focus { background: rgba(0, 0, 0, 0.05); outline: 2px solid; outline-color: inherit; outline-offset: -2px; } .fictrail-toggle-header { margin: 0; display: flex; align-items: center; gap: 0.5em; font-size: 1em; } .fictrail-toggle-icon { font-family: monospace; font-size: 0.8em; transition: transform 0.2s ease; display: inline-block; } /* === STATE-BASED STYLING === */ .fictrail-pages-toggle.expanded .fictrail-toggle-icon { transform: rotate(90deg); } .fictrail-pages-content { padding: 0; overflow: hidden; max-height: 0; opacity: 0; } .fictrail-pages-content.expanded { padding: 1em 0 0; max-height: 500px; opacity: 1; } /* === CONTENT-SPECIFIC === */ .loading-content, .error-content { text-align: center; max-width: 800px; margin: 0 auto; } .error-content .actions { display: flex; justify-content: center; } .fictrail-fieldset { margin: 0; } .fictrail-actions { display: flex; justify-content: center; } .fictrail-results-counter { padding-top: 0.8em; font-size: 0.9em; text-align: center; font-style: italic; } .fictrail-load-more-container { width: 100%; text-align: center; margin-top: 1.5em; } .fictrail-summary { max-height: 120px; overflow-y: auto; padding: 0.5em; border: 1px solid rgba(0, 0, 0, 0.1); border-radius: 3px; background: rgba(0, 0, 0, 0.02); margin: 0.5em 0; line-height: 1.4; } .fictrail-bottom-actions { margin-top: 2em; padding-top: 1.5em; } .fictrail-bottom-actions .heading { margin-bottom: 0.5em; } .fictrail-bottom-actions .actions { display: flex; justify-content: center; margin: 0; } .fictrail-bottom-actions .actions li { margin: 0; } /* === ANIMATIONS === */ @keyframes fictrail-spin { to { transform: rotate(360deg); } } /* === RESPONSIVE OVERRIDES === */ @media (max-width: 768px) { .fictrail-search-row { flex-direction: column; align-items: stretch; } .fictrail-search-field, .fictrail-filter-field { flex: none; max-width: none; } .fictrail-search-field label, .fictrail-filter-field label { margin-bottom: 0.3em; } .fictrail-search-field input, .fictrail-filter-field select { margin-top: 0; } #fictrail-subtitle { flex-direction: column; align-items: flex-start; margin-bottom: 1em; } #fictrail-subtitle span:not(:first-child)::before { display: none; } #fictrail-favorite-tags-summary-container { display: none; } .fictrail-slider-field { align-items: normal; } .fictrail-slider-track { gap: 0.5em; } .fictrail-slider { width: 100%; } .fictrail-bottom-actions { margin-top: 1.5em; padding-top: 1em; } .fictrail-bottom-actions .heading { font-size: 1em; text-align: center; } } /* === DARK MODE === */ @media (prefers-color-scheme: dark) { .fictrail-summary { border-color: rgba(255, 255, 255, 0.2); background: rgba(255, 255, 255, 0.05); } .fictrail-bottom-actions { border-top-color: rgba(255, 255, 255, 0.2); } } /* === LARGE SCREENS === */ @media (min-width: 1800px) { #fictrail-works-list { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5em; align-items: stretch; } #fictrail-works-list li.work { margin: 0; break-inside: avoid; } } /* === FOCUS AND INPUT STYLING === */ #fictrail-search-input, #fictrail-fandom-filter { width: auto !important; max-width: 100%; box-sizing: border-box; font-family: inherit; font-size: inherit; line-height: 1.2; } #fictrail-search-input:focus, #fictrail-fandom-filter:focus { outline: 2px solid; outline-color: inherit; outline-offset: 1px; } .fictrail-slider { width: 200px; } /* === WORKS LIST STYLING === */ #fictrail-works-container::after { content: ""; display: table; clear: both; } #fictrail-works-list { margin: 1.5em 0 0; } #fictrail-works-list li.work { margin: 0 0 1em; }`; const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); } // Scraper Module - AO3 history fetching and parsing function scrapeHistoryFromPage(doc) { const works = []; const workItems = doc.querySelectorAll('ol.reading li.work'); workItems.forEach((item) => { const titleLink = item.querySelector('h4.heading a[href*="/works/"]'); const authorLink = item.querySelector('h4.heading a[rel="author"]'); const fandomLinks = item.querySelectorAll('h5.fandoms a.tag'); const lastVisitedEl = item.querySelector('h4.viewed.heading'); const summaryEl = item.querySelector('.userstuff.summary'); const statsEl = item.querySelector('.stats'); const dateEl = item.querySelector('.datetime'); const tagsEl = item.querySelector('.tags.commas'); const seriesEl = item.querySelector('.series'); // Extract required tags from the required-tags ul const requiredTagsEl = item.querySelector('.required-tags'); // Extract rating const ratingSpan = requiredTagsEl?.querySelector('.rating'); const ratingText = ratingSpan?.querySelector('.text')?.textContent.trim() || ''; const ratingClass = ratingSpan?.className || ''; // Extract warnings const warningSpans = requiredTagsEl?.querySelectorAll('.warnings') || []; const warnings = Array.from(warningSpans).flatMap(span => { const textEl = span.querySelector('.text'); const text = textEl ? textEl.textContent.trim() : ''; // Split by commas and clean up each warning return text ? text.split(',').map(w => w.trim()).filter(w => w) : []; }); const warningClasses = Array.from(warningSpans).map(el => el.className); // Extract categories const categorySpans = requiredTagsEl?.querySelectorAll('.category') || []; const categories = Array.from(categorySpans).map(span => { const textEl = span.querySelector('.text'); return textEl ? textEl.textContent.trim() : ''; }).filter(c => c); const categoryClasses = Array.from(categorySpans).map(el => el.className); // Extract status (Complete/WIP) const statusSpan = requiredTagsEl?.querySelector('.iswip'); const status = statusSpan?.querySelector('.text')?.textContent.trim() || ''; const statusClass = statusSpan?.className || ''; if (titleLink) { // Extract last visited date let lastVisited = ''; if (lastVisitedEl) { const fullText = lastVisitedEl.textContent; const dateMatch = fullText.match(/Last visited:\s*([^(]+)/); if (dateMatch) { lastVisited = dateMatch[1].trim(); } } // Extract stats const stats = {}; if (statsEl) { stats.language = statsEl.querySelector('dd.language')?.textContent.trim() || ''; stats.words = statsEl.querySelector('dd.words')?.textContent.trim() || ''; stats.chapters = statsEl.querySelector('dd.chapters')?.textContent.trim() || ''; stats.collections = statsEl.querySelector('dd.collections')?.textContent.trim() || ''; stats.comments = statsEl.querySelector('dd.comments')?.textContent.trim() || ''; stats.kudos = statsEl.querySelector('dd.kudos')?.textContent.trim() || ''; stats.bookmarks = statsEl.querySelector('dd.bookmarks')?.textContent.trim() || ''; stats.hits = statsEl.querySelector('dd.hits')?.textContent.trim() || ''; } // Extract series information const series = []; if (seriesEl) { const seriesLinks = seriesEl.querySelectorAll('li'); seriesLinks.forEach(li => { const seriesLink = li.querySelector('a[href*="/series/"]'); const partMatch = li.textContent.match(/Part\s+(\d+)\s+of/); if (seriesLink && partMatch) { series.push({ title: seriesLink.textContent.trim(), url: AO3_BASE_URL + seriesLink.getAttribute('href'), part: partMatch[1] }); } }); } const work = { title: titleLink.textContent.trim(), url: AO3_BASE_URL + titleLink.getAttribute('href'), author: authorLink ? authorLink.textContent.trim() : 'Anonymous', authorUrl: authorLink ? AO3_BASE_URL + authorLink.getAttribute('href') : null, fandoms: Array.from(fandomLinks).map(link => link.textContent.trim()), lastVisited: lastVisited, summary: summaryEl ? summaryEl.innerHTML : '', publishDate: dateEl ? dateEl.textContent.trim() : '', tags: tagsEl ? Array.from(tagsEl.querySelectorAll('a.tag')).map(tag => tag.textContent.trim()) : [], relationships: tagsEl ? Array.from(tagsEl.querySelectorAll('.relationships a.tag')).map(rel => rel.textContent.trim()) : [], characters: tagsEl ? Array.from(tagsEl.querySelectorAll('.characters a.tag')).map(char => char.textContent.trim()) : [], freeforms: tagsEl ? Array.from(tagsEl.querySelectorAll('.freeforms a.tag')).map(tag => tag.textContent.trim()) : [], // Required tags with text and CSS classes rating: ratingText, ratingClass: ratingClass, warnings: warnings, warningClasses: warningClasses, categories: categories, categoryClasses: categoryClasses, status: status, statusClass: statusClass, // Stats and series stats: stats, series: series }; works.push(work); } }); return works; } async function fetchHistoryPage(username, page = 1) { const url = `${AO3_BASE_URL}/users/${username}/readings?page=${page}`; try { const response = await fetch(url); const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); // Check logged-out state using shared helper if (isLoggedOutDoc(doc)) { throw new Error('NOT_LOGGED_IN'); } return scrapeHistoryFromPage(doc); } catch (error) { console.error(`Error fetching page ${page}:`, error); if (error.message === 'NOT_LOGGED_IN') { throw error; } return []; } } function getTotalPages(doc = document) { const pagination = doc.querySelector('.pagination'); if (!pagination) return 1; const pageLinks = pagination.querySelectorAll('a'); let maxPage = 1; pageLinks.forEach(link => { const pageNum = parseInt(link.textContent.trim()); if (!isNaN(pageNum) && pageNum > maxPage) { maxPage = pageNum; } }); const nextLink = pagination.querySelector('a[rel="next"]'); if (nextLink && maxPage === 1) { maxPage = 2; } return maxPage; } async function fetchMultiplePagesWithCache(username, maxPagesToFetch = MAX_PAGES_FETCH) { let totalPages = cachedTotalPages; const works = []; // Determine which pages we need to fetch const cachedPages = getMaxCachedPage(); const startPage = isCacheValid() ? Math.max(1, cachedPages + 1) : 1; const endPage = Math.min(maxPagesToFetch, totalPages || MAX_PAGES_FETCH); // If we need to fetch page 1 or cache is invalid, start fresh if (startPage === 1 || !isCacheValid()) { console.log('Fetching fresh data starting from page 1'); clearCache(); try { const firstPageUrl = `${AO3_BASE_URL}/users/${username}/readings?page=1`; const response = await fetch(firstPageUrl); const html = await response.text(); const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html'); if (isLoggedOutDoc(doc)) { throw new Error('NOT_LOGGED_IN'); } totalPages = getTotalPages(doc); cachedTotalPages = totalPages; cacheTimestamp = Date.now(); const firstPageWorks = scrapeHistoryFromPage(doc); pageCache.set(1, { works: firstPageWorks, timestamp: Date.now() }); works.push(...firstPageWorks); console.log(`Cached page 1 with ${firstPageWorks.length} works`); } catch (error) { console.error('Error fetching first page:', error); if (error.message === 'NOT_LOGGED_IN') { throw error; } return { works: [], totalPages: 1 }; } } else { // Use existing cached data console.log(`Using cached data for pages 1-${cachedPages}`); for (let page = 1; page <= Math.min(cachedPages, maxPagesToFetch); page++) { if (pageCache.has(page)) { works.push(...pageCache.get(page).works); } } } // Fetch additional pages if needed const actualStartPage = Math.max(startPage, 2); const pagesToFetch = Math.min(maxPagesToFetch, totalPages || MAX_PAGES_FETCH); if (actualStartPage <= pagesToFetch) { console.log(`Fetching pages ${actualStartPage}-${pagesToFetch}`); for (let page = actualStartPage; page <= pagesToFetch; page++) { // Skip if we already have this page cached if (pageCache.has(page)) { console.log(`Page ${page} already cached, skipping`); continue; } showFicTrailLoading(`Loading page ${page} of ${pagesToFetch}...`); const pageWorks = await fetchHistoryPage(username, page); if (pageWorks.length > 0) { pageCache.set(page, { works: pageWorks, timestamp: Date.now() }); works.push(...pageWorks); console.log(`Cached page ${page} with ${pageWorks.length} works`); } await new Promise(resolve => setTimeout(resolve, PAGE_FETCH_DELAY)); } } console.log(`Total works loaded: ${works.length} from ${Math.min(maxPagesToFetch, totalPages || 1)} pages`); console.log(`Cache now contains ${pageCache.size} pages`); return { works: works, totalPages: totalPages || 1 }; } // Search Module - Search and filtering functionality function performSearch() { const query = document.getElementById('fictrail-search-input').value.toLowerCase().trim(); if (query === '') { filteredWorks = [...allWorks]; filteredWorks.forEach(work => { work.matchingTags = []; }); // Apply filter which will show the count applyFilter(); return; } else { filteredWorks = allWorks.filter(work => { const matchingTags = []; if (work.relationships) { work.relationships.forEach(rel => { if (rel.toLowerCase().includes(query)) { matchingTags.push({ type: 'relationship', value: rel }); } }); } if (work.characters) { work.characters.forEach(char => { if (char.toLowerCase().includes(query)) { matchingTags.push({ type: 'character', value: char }); } }); } if (work.freeforms) { work.freeforms.forEach(tag => { if (tag.toLowerCase().includes(query)) { matchingTags.push({ type: 'freeform', value: tag }); } }); } work.matchingTags = matchingTags; // Extract text from HTML summary for searching const tempDiv = document.createElement('div'); tempDiv.innerHTML = work.summary || ''; const summaryText = tempDiv.textContent || tempDiv.innerText || ''; return work.title.toLowerCase().includes(query) || work.author.toLowerCase().includes(query) || work.fandoms.some(fandom => fandom.toLowerCase().includes(query)) || summaryText.toLowerCase().includes(query) || matchingTags.length > 0 || (work.tags && work.tags.some(tag => tag.toLowerCase().includes(query))); }); } applyFilter(); } function applyFilter() { const selectedFandom = document.getElementById('fictrail-fandom-filter').value; let worksToDisplay = [...filteredWorks]; if (selectedFandom) { worksToDisplay = worksToDisplay.filter(work => work.fandoms.includes(selectedFandom) ); } worksToDisplay.sort((a, b) => { if (a.lastVisited && b.lastVisited) { return new Date(b.lastVisited) - new Date(a.lastVisited); } return 0; }); // Reset pagination for new search/filter currentDisplayCount = ITEMS_PER_PAGE; // Show results count updateResultsCount(worksToDisplay.length); displayWorks(worksToDisplay); } function updateResultsCount(count) { const resultsCountElement = document.getElementById('fictrail-results-count'); if (resultsCountElement) { if (count > 0) { resultsCountElement.textContent = `${count} result${count === 1 ? '' : 's'}`; resultsCountElement.style.display = 'block'; } else { resultsCountElement.style.display = 'none'; } } } function populateFandomFilter(works) { const fandomFilter = document.getElementById('fictrail-fandom-filter'); const allFandoms = new Set(); works.forEach(work => { work.fandoms.forEach(fandom => allFandoms.add(fandom)); }); const sortedFandoms = Array.from(allFandoms).sort((a, b) => a.localeCompare(b)); fandomFilter.innerHTML = '<option value="">All Fandoms</option>'; sortedFandoms.forEach(fandom => { const option = document.createElement('option'); option.value = fandom; option.textContent = fandom; fandomFilter.appendChild(option); }); } // UI Module - DOM creation and event handling // HTML Template Functions const Templates = { workItem(work, index) { return ` <li id="work_${work.url.match(/\/works\/(\d+)/)?.[1] || 'unknown'}" class="reading work blurb group work-${work.url.match(/\/works\/(\d+)/)?.[1] || 'unknown'}" role="article"> ${this.workHeader(work)} ${this.workTags(work)} ${this.workSummary(work)} ${this.workSeries(work)} ${this.workStats(work)} ${this.workUserModule(work)} </li> `; }, workHeader(work) { // Get current search query for highlighting const searchInput = document.getElementById('fictrail-search-input'); const searchQuery = searchInput ? searchInput.value.trim() : ''; // Apply highlighting to title and author const highlightedTitle = searchQuery ? highlightSearchTerms(escapeHtml(work.title), searchQuery) : escapeHtml(work.title); const highlightedAuthor = searchQuery ? highlightSearchTerms(escapeHtml(work.author), searchQuery) : escapeHtml(work.author); return ` <div class="header module"> <h4 class="heading"> <a href="${work.url}" target="_blank" rel="noopener">${highlightedTitle}</a> by ${work.authorUrl ? `<a rel="author" href="${work.authorUrl}" target="_blank" rel="noopener">${highlightedAuthor}</a>` : highlightedAuthor } </h4> ${this.workFandoms(work)} ${this.workRequiredTags(work)} ${work.publishDate ? `<p class="datetime">${escapeHtml(work.publishDate)}</p>` : ''} </div> `; }, workFandoms(work) { // Get current search query for highlighting const searchInput = document.getElementById('fictrail-search-input'); const searchQuery = searchInput ? searchInput.value.trim() : ''; return ` <h5 class="fandoms heading"> <span class="landmark">Fandoms:</span> ${work.fandoms.map(fandom => { const highlightedFandom = searchQuery ? highlightSearchTerms(escapeHtml(fandom), searchQuery) : escapeHtml(fandom); return `<a class="tag" href="/tags/${encodeURIComponent(fandom)}/works" target="_blank" rel="noopener">${highlightedFandom}</a>`; }).join(', ')} </h5> `; }, workRequiredTags(work) { const tags = []; if (work.rating && work.ratingClass) { tags.push(this.requiredTag(work.rating, work.ratingClass)); } if (work.warnings && work.warningClasses) { work.warnings.forEach((warning, index) => { tags.push(this.requiredTag(warning, work.warningClasses[index] || '')); }); } if (work.categories && work.categoryClasses) { work.categories.forEach((category, index) => { tags.push(this.requiredTag(category, work.categoryClasses[index] || '')); }); } if (work.status && work.statusClass) { tags.push(this.requiredTag(work.status, work.statusClass)); } return tags.length > 0 ? `<ul class="required-tags">${tags.join('')}</ul>` : ''; }, requiredTag(title, cssClass) { return ` <li> <a class="help symbol question modal modal-attached" title="Symbols key" href="/help/symbols-key.html" aria-controls="modal"> <span class="${cssClass}" title="${escapeHtml(title)}"> <span class="text">${escapeHtml(title)}</span> </span> </a> </li> `; }, workTags(work) { const tagsToShow = getTagsToDisplay(work); if (tagsToShow.length === 0) return ''; return ` <h6 class="landmark heading">Tags</h6> <ul class="tags commas"> ${tagsToShow.map(tag => this.tagItem(tag)).join(' ')} </ul> `; }, tagItem(tag) { const cssClass = getTagCssClass(tag.type); const encodedValue = encodeURIComponent(tag.value); let escapedValue = escapeHtml(tag.value); // Highlight search terms if there's a search query const searchInput = document.getElementById('fictrail-search-input'); const searchQuery = searchInput ? searchInput.value.trim() : ''; if (searchQuery) { escapedValue = highlightSearchTerms(escapedValue, searchQuery); } return ` <li class="${cssClass}"> <a class="tag" href="/tags/${encodedValue}/works" target="_blank" rel="noopener">${escapedValue}</a> </li> `; }, workSummary(work) { if (!work.summary) return ''; let summaryHTML = work.summary; const searchInput = document.getElementById('fictrail-search-input'); if (searchInput && searchInput.value.trim()) { summaryHTML = highlightSearchTerms(summaryHTML, searchInput.value.trim()); } return ` <h6 class="landmark heading">Summary</h6> <blockquote class="userstuff summary fictrail-summary"> ${summaryHTML} </blockquote> `; }, workSeries(work) { if (!work.series || work.series.length === 0) return ''; return ` <h6 class="landmark heading">Series</h6> <ul class="series"> ${work.series.map(series => ` <li> Part <strong>${series.part}</strong> of <a href="${series.url}" target="_blank" rel="noopener">${escapeHtml(series.title)}</a> </li> `).join('')} </ul> `; }, workStats(work) { const stats = work.stats || {}; const hasStats = Object.values(stats).some(value => value && value.trim()); if (!hasStats) return ''; const statItems = []; const statFields = [ { key: 'language', label: 'Language' }, { key: 'words', label: 'Words' }, { key: 'chapters', label: 'Chapters' }, { key: 'collections', label: 'Collections' }, { key: 'comments', label: 'Comments' }, { key: 'kudos', label: 'Kudos' }, { key: 'bookmarks', label: 'Bookmarks' }, { key: 'hits', label: 'Hits' } ]; statFields.forEach(field => { if (stats[field.key]) { statItems.push(` <dt class="${field.key.toLowerCase()}">${field.label}:</dt> <dd class="${field.key.toLowerCase()}" ${field.key === 'language' ? 'lang="en"' : ''}> ${escapeHtml(stats[field.key])} </dd> `); } }); return statItems.length > 0 ? `<dl class="stats">${statItems.join('')}</dl>` : ''; }, workUserModule(work) { return ` <div class="user module group"> <h4 class="viewed heading"> <span>Last visited:</span> ${work.lastVisited || 'Unknown'} </h4> </div> `; }, loadMoreSection(works, currentCount) { const remainingCount = works.length - currentCount; const nextBatchSize = Math.min(ITEMS_PER_PAGE, remainingCount); return { message: `<p>Showing ${currentCount} of ${works.length} ${works.length === 1 ? 'result' : 'results'}</p>`, buttonText: `Load ${nextBatchSize} More ${nextBatchSize === 1 ? 'Result' : 'Results'}` }; }, favoriteTagsSummary(tag) { return `So you've been really into ${escapeHtml(tag)} lately. Love it for you.`; } }; // DOM Element Creation Functions const DOMHelpers = { createElement(tag, attributes = {}, textContent = '') { const element = document.createElement(tag); Object.entries(attributes).forEach(([key, value]) => { if (key === 'style' && typeof value === 'object') { Object.assign(element.style, value); } else { element.setAttribute(key, value); } }); if (textContent) { element.textContent = textContent; } return element; }, createButton(id, text, clickHandler, keydownHandler = null) { const button = this.createElement('a', { id, style: { cursor: 'pointer' }, tabIndex: 0 }, text); button.addEventListener('click', clickHandler); if (keydownHandler) { button.addEventListener('keydown', keydownHandler); } else { button.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); clickHandler(); } }); } return button; } }; // Create FicTrail button - try history page placement first function createFicTrailButton() { // Only add to history page if we're on the readings page if (window.location.pathname.includes('/readings')) { addToHistoryPage(); } } // Add FicTrail button in front of "Full History" in subnav function addToHistoryPage() { const subNav = document.querySelector('ul.navigation.actions[role="navigation"]'); if (!subNav) return false; const listItem = DOMHelpers.createElement('li'); const button = DOMHelpers.createButton('fictrail-history-btn', 'FicTrail', openFicTrail); listItem.appendChild(button); subNav.insertBefore(listItem, subNav.firstChild); return true; } // Create FicTrail content inside #main function createOverlay() { // Check if overlay already exists if (document.getElementById('fictrail-container')) return; const mainElement = document.getElementById('main'); if (!mainElement) { console.error('Could not find #main element'); return; } // Create FicTrail container const fictrailDiv = DOMHelpers.createElement('div', { id: 'fictrail-container' }); // HTML template will be injected here during build fictrailDiv.innerHTML = `<!--Descriptive page name, messages and instructions--> <h2 class="heading"> History + FicTrail </h2> <!--/descriptions--> <!--subnav--> <ul class="navigation actions" role="navigation"> <li> <span class="current">FicTrail</span> </li> <li> <a href="/users/painless/readings">Full History</a> </li> <li> <a href="/users/painless/readings?show=to-read">Marked for Later</a> </li> <li> <a data-confirm="Are you sure you want to clear your History and Marked for Later lists? This cannot be undone!" rel="nofollow" data-method="post" href="/users/painless/readings/clear">Clear History</a> </li> </ul> <!--/subnav--> <!--main content--> <h3 class="landmark heading"> History Items + FicTrail </h3> <!-- Loading State --> <div id="fictrail-loading" class="fictrail-content-state" style="display: none;"> <div class="loading-content"> <div class="fictrail-spinner"></div> <h3>Summoning your fic history...</h3> <p id="fictrail-loading-status">Diving deep into your AO3 rabbit hole...</p> </div> </div> <!-- Error State --> <div id="fictrail-error" class="fictrail-content-state" style="display: none;"> <div class="error-content"> <h3>Plot Twist!</h3> <p id="fictrail-error-message">Something went wrong...</p> <div class="actions"> <a id="fictrail-retry-btn" style="cursor: pointer;" tabindex="0">Try Again (Please?)</a> </div> </div> </div> <!-- Search Results State --> <div id="fictrail-results" class="fictrail-content-state" style="display: none;"> <div id="fictrail-subtitle" class="subtitle"> <span id="fictrail-works-count"></span> <span id="fictrail-fandoms-count"></span> <span id="fictrail-authors-count"></span> </div> <div id="fictrail-favorite-tags-summary-container"></div> <!-- Search and Filter Form --> <form class="fictrail-form"> <fieldset class="fictrail-fieldset"> <legend>Search and Filter</legend> <div class="fictrail-search-row"> <div class="fictrail-search-field"> <label for="fictrail-search-input">Search</label> <input type="text" id="fictrail-search-input" placeholder="Search works, authors, fandoms, tags..."/> </div> <div class="fictrail-filter-field"> <label for="fictrail-fandom-filter">Fandom</label> <select id="fictrail-fandom-filter"> <option value="">All Fandoms</option> </select> </div> </div> </fieldset> </form> <!-- Pages Loaded Info - Collapsible --> <div id="fictrail-pages-info" class="fictrail-pages-section" style="display: none;"> <!-- Toggle Header --> <div class="fictrail-pages-toggle" id="fictrail-pages-toggle" tabindex="0"> <h4 class="fictrail-toggle-header"> <span class="fictrail-toggle-icon">▶</span> <span class="fictrail-toggle-text" id="fictrail-toggle-text">History Pages Loaded</span> </h4> </div> <!-- Collapsible Content --> <div class="fictrail-pages-content" id="fictrail-pages-content"> <form class="fictrail-form"> <fieldset class="fictrail-fieldset"> <p class="note">Loading many pages can be slow. Start with fewer pages for better performance, then reload with more if needed.</p> <div class="fictrail-slider-field"> <label for="fictrail-pages-slider" class="fictrail-slider-label">Pages to load</label> <div class="fictrail-slider-track"> <span class="fictrail-slider-min">1</span> <input type="range" id="fictrail-pages-slider" min="1" max="100" value="1" class="fictrail-slider"/> <span class="fictrail-slider-max">100</span> </div> </div> <div class="fictrail-actions actions"> <a id="fictrail-load-btn" style="cursor: pointer;" tabindex="0">Load History</a> </div> </fieldset> </form> </div> </div> <!-- Results Counter --> <div id="fictrail-results-count" class="fictrail-results-counter"></div> <!-- Works List --> <div id="fictrail-works-container" style="display: none;"> <ol id="fictrail-works-list" class="reading work index group"></ol> </div> <!-- Load More Container --> <div id="fictrail-load-more-container" class="fictrail-load-more-container" style="display: none;"> <div id="fictrail-load-more-message"></div> <div class="actions"> <a id="fictrail-load-more-button" style="cursor: pointer;" tabindex="0"></a> </div> </div> <!-- No Results --> <div id="fictrail-no-results" class="notice" style="display: none;"> <h4>No Results Found</h4> <span>Your search came up empty! Try different keywords or maybe you haven't read that trope yet?</span> </div> <!-- Bottom Actions with Top Button --> <div id="fictrail-bottom-actions" class="fictrail-bottom-actions" style="display: none;"> <h3 class="landmark heading">Actions</h3> <ul class="actions" role="navigation"> <li><a id="fictrail-top-btn" href="#main" style="cursor: pointer;" tabindex="0">↑ Top</a></li> </ul> </div> </div> <!--/content-->`; // Insert FicTrail inside #main mainElement.appendChild(fictrailDiv); // Set default slider value after creating the overlay setTimeout(() => { const slider = document.getElementById('fictrail-pages-slider'); if (slider) { slider.value = DEFAULT_PAGES_TO_LOAD; updateReloadButtonText(); } }, 0); // Add event listeners attachEventListeners(); } // Centralized event listener attachment function attachEventListeners() { const eventMap = [ { id: 'fictrail-load-btn', event: 'click', handler: reloadHistory }, { id: 'fictrail-retry-btn', event: 'click', handler: reloadHistory }, { id: 'fictrail-search-input', event: 'input', handler: debounce(performSearch, 300) }, { id: 'fictrail-fandom-filter', event: 'change', handler: applyFilter }, { id: 'fictrail-pages-slider', event: 'input', handler: updatePagesValue }, { id: 'fictrail-pages-toggle', event: 'click', handler: togglePagesSection }, { id: 'fictrail-top-btn', event: 'click', handler: scrollToTop } ]; eventMap.forEach(({ id, event, handler }) => { const element = document.getElementById(id); if (element) { element.addEventListener(event, handler); // Add keyboard support for clickable elements if (event === 'click') { element.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handler(); } }); } } }); } function openFicTrail() { if (!document.getElementById('fictrail-container')) { createOverlay(); } const mainElement = document.getElementById('main'); const fictrailContainer = document.getElementById('fictrail-container'); if (mainElement) { Array.from(mainElement.children).forEach(child => { if (child.id !== 'fictrail-container') { child.style.display = 'none'; } }); } if (fictrailContainer) fictrailContainer.style.display = 'block'; // Check if we have valid cached data if (isCacheValid() && getCachedPageCount() > 0) { console.log('Reopening FicTrail with cached data'); const works = []; const maxCachedPage = getMaxCachedPage(); // Load all cached works for (let page = 1; page <= maxCachedPage; page++) { if (pageCache.has(page)) { works.push(...pageCache.get(page).works); } } if (works.length > 0) { displayHistory(getUsername(), works, cachedTotalPages, maxCachedPage); return; } } // No valid cache or no works, load fresh data if (allWorks.length === 0) { showFicTrailLoading(); setTimeout(() => { reloadHistory(); }, 100); } else { showFicTrailResults(); } } // Helper functions to show different content states function showFicTrailState(stateId) { const states = ['fictrail-loading', 'fictrail-error', 'fictrail-results']; states.forEach(id => { const element = document.getElementById(id); if (element) { element.style.display = id === stateId ? 'block' : 'none'; } }); } function showFicTrailLoading(message = 'Summoning your fic history...') { showFicTrailState('fictrail-loading'); const statusElement = document.getElementById('fictrail-loading-status'); if (statusElement) { statusElement.textContent = message; } } function showFicTrailError(message) { showFicTrailState('fictrail-error'); const errorElement = document.getElementById('fictrail-error-message'); if (errorElement) { errorElement.innerHTML = message; } } function showFicTrailResults() { showFicTrailState('fictrail-results'); // Show bottom actions when results are displayed const bottomActions = document.getElementById('fictrail-bottom-actions'); if (bottomActions) { bottomActions.style.display = 'block'; } } function updatePagesValue() { updateReloadButtonText(); } function updateReloadButtonText() { const slider = document.getElementById('fictrail-pages-slider'); if (!slider) return; const currentPages = parseInt(slider.value); const pagesInfo = document.getElementById('fictrail-pages-info'); const loadBtn = document.getElementById('fictrail-load-btn'); if (!loadBtn) return; // Check if we're in reload mode (pagesInfo is visible) if (pagesInfo && pagesInfo.style.display === 'block') { loadBtn.textContent = `Reload History (${currentPages} ${currentPages === 1 ? 'page' : 'pages'})`; } } function getPagesToLoad() { const slider = document.getElementById('fictrail-pages-slider'); // If slider doesn't exist yet, use default if (!slider) return DEFAULT_PAGES_TO_LOAD; return parseInt(slider.value); } function displayWorks(works, append = false) { const worksListContainer = document.getElementById('fictrail-works-container'); const worksList = document.getElementById('fictrail-works-list'); const noResults = document.getElementById('fictrail-no-results'); const bottomActions = document.getElementById('fictrail-bottom-actions'); if (works.length === 0) { worksListContainer.style.display = 'none'; noResults.style.display = 'block'; hideLoadMoreButton(); // Hide bottom actions when no results if (bottomActions) bottomActions.style.display = 'none'; return; } worksListContainer.style.display = 'block'; noResults.style.display = 'none'; // Show bottom actions when there are results if (bottomActions) bottomActions.style.display = 'block'; // Reset display count if not appending (new search/filter) if (!append) { currentDisplayCount = ITEMS_PER_PAGE; } const worksToShow = works.slice(0, currentDisplayCount); const hasMoreResults = works.length > currentDisplayCount; // Generate HTML for works to display const worksToRender = worksToShow.slice(append ? currentDisplayCount - ITEMS_PER_PAGE : 0); const worksHTML = worksToRender.map((work, index) => Templates.workItem(work, index)).join(''); if (append) { worksList.insertAdjacentHTML('beforeend', worksHTML); } else { worksList.innerHTML = worksHTML; } // Show or hide load more button based on remaining results if (hasMoreResults) { showLoadMoreButton(works, currentDisplayCount); } else { hideLoadMoreButton(); } } function loadMoreWorks() { currentDisplayCount += ITEMS_PER_PAGE; displayWorks(filteredWorks, true); } function addFavoriteTagsSummary(works) { // Only consider the most recent works (approximately first 2 pages worth) const recentWorksLimit = 40; const recentWorks = works.slice(0, recentWorksLimit); // Count all tags across recent works only const tagCounts = {}; recentWorks.forEach(work => { // Count relationships, characters, and freeforms ['relationships', 'characters', 'freeforms'].forEach(tagType => { if (work[tagType]) { work[tagType].forEach(tag => { tagCounts[tag] = (tagCounts[tag] || 0) + 1; }); } }); }); // Sort tags by frequency and get the most popular one const sortedTags = Object.entries(tagCounts).sort((a, b) => b[1] - a[1]); if (sortedTags.length > 0) { // Remove existing summary if it exists const existingSummary = document.getElementById('fictrail-favorite-tags-summary'); if (existingSummary) { existingSummary.remove(); } // Get the most popular tag and create summary element const [mostPopularTag] = sortedTags[0]; const summaryDiv = DOMHelpers.createElement('p', { id: 'fictrail-favorite-tags-summary' }); summaryDiv.innerHTML = Templates.favoriteTagsSummary(mostPopularTag); // Insert in the designated container const summaryContainer = document.getElementById('fictrail-favorite-tags-summary-container'); if (summaryContainer) { summaryContainer.appendChild(summaryDiv); } } } /** * Determines which tags to display based on search state * @param {Object} work - The work object * @returns {Array} Array of tag objects with type and value */ function getTagsToDisplay(work) { const searchInput = document.getElementById('fictrail-search-input'); const hasSearchQuery = searchInput && searchInput.value.trim(); // Always include warnings const warningTags = (work.warnings || []).map(tag => ({ type: 'warning', value: tag })); if (hasSearchQuery) { // Show warnings plus matching tags during search const matchingTags = work.matchingTags || []; // Filter out warnings from matchingTags to avoid duplicates const nonWarningMatchingTags = matchingTags.filter(tag => tag.type !== 'warning'); return [...warningTags, ...nonWarningMatchingTags]; } else { // Show all tags when no search query return [ ...warningTags, ...(work.relationships || []).map(tag => ({ type: 'relationship', value: tag })), ...(work.characters || []).map(tag => ({ type: 'character', value: tag })), ...(work.freeforms || []).map(tag => ({ type: 'freeform', value: tag })) ]; } } /** * Gets the CSS class name for a tag type * @param {string} tagType - The type of tag (warning, relationship, character, freeform) * @returns {string} The CSS class name */ function getTagCssClass(tagType) { const classMap = { 'relationship': 'relationships', 'character': 'characters', 'freeform': 'freeforms', 'warning': 'warnings' }; return classMap[tagType] || ''; } function showLoadMoreButton(works, currentCount) { const loadMoreContainer = document.getElementById('fictrail-load-more-container'); const loadMoreMessage = document.getElementById('fictrail-load-more-message'); const loadMoreButton = document.getElementById('fictrail-load-more-button'); if (!loadMoreContainer || !loadMoreMessage || !loadMoreButton) return; const loadMoreContent = Templates.loadMoreSection(works, currentCount); // Update message and button loadMoreMessage.innerHTML = loadMoreContent.message; loadMoreButton.textContent = loadMoreContent.buttonText; // Show the container loadMoreContainer.style.display = 'block'; // Remove existing event listeners and add new one const newButton = loadMoreButton.cloneNode(true); loadMoreButton.parentNode.replaceChild(newButton, loadMoreButton); // Add event listeners newButton.addEventListener('click', loadMoreWorks); newButton.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); loadMoreWorks(); } }); } function hideLoadMoreButton() { const loadMoreContainer = document.getElementById('fictrail-load-more-container'); if (loadMoreContainer) { loadMoreContainer.style.display = 'none'; } } function togglePagesSection() { const toggle = document.getElementById('fictrail-pages-toggle'); const content = document.getElementById('fictrail-pages-content'); if (!toggle || !content) return; const isExpanded = content.classList.contains('expanded'); if (isExpanded) { // Collapse toggle.classList.remove('expanded'); content.classList.remove('expanded'); } else { // Expand toggle.classList.add('expanded'); content.classList.add('expanded'); } } function highlightSearchTerms(html, searchQuery) { if (!searchQuery.trim()) return html; // Create a temporary div to work with the HTML content const tempDiv = document.createElement('div'); tempDiv.innerHTML = html; // Function to highlight text in text nodes only function highlightInTextNodes(node) { if (node.nodeType === Node.TEXT_NODE) { const text = node.textContent; const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`(${escapedQuery})`, 'gi'); if (regex.test(text)) { const highlightedText = text.replace(regex, '<mark class="fictrail-highlight">$1</mark>'); // Create a document fragment to avoid extra wrapper spans const fragment = document.createDocumentFragment(); const tempContainer = document.createElement('div'); tempContainer.innerHTML = highlightedText; // Move all child nodes from temp container to fragment while (tempContainer.firstChild) { fragment.appendChild(tempContainer.firstChild); } // Replace the text node with the fragment contents node.parentNode.replaceChild(fragment, node); } } else if (node.nodeType === Node.ELEMENT_NODE) { // Recursively process child nodes (need to convert to array first since we're modifying) const children = Array.from(node.childNodes); children.forEach(child => highlightInTextNodes(child)); } } highlightInTextNodes(tempDiv); return tempDiv.innerHTML; } function scrollToTop(event) { event.preventDefault(); // Scroll to the top of the main element (where FicTrail is) const mainElement = document.getElementById('main'); if (mainElement) { mainElement.scrollIntoView({ behavior: 'smooth', block: 'start' }); } else { // Fallback to scrolling to top of page window.scrollTo({ top: 0, behavior: 'smooth' }); } } // Core Module - Main functionality and history loading let allWorks = []; let filteredWorks = []; // Pagination state let currentDisplayCount = 20; function showLoginError() { showFicTrailError('Oops! It looks like you\'ve been logged out of AO3. <a href="https://archiveofourown.org/users/login" target="_blank" rel="noopener" style="color: inherit; text-decoration: underline;">Log in to AO3</a> and then try again.'); } async function reloadHistory() { const username = getUsername(); if (!username) { showLoginError(); return; } // Disable buttons while loading const loadBtn = document.getElementById('fictrail-load-btn'); const retryBtn = document.getElementById('fictrail-retry-btn'); if (loadBtn) loadBtn.disabled = true; if (retryBtn) retryBtn.disabled = true; // Preserve search and filter values when reloading const searchInput = document.getElementById('fictrail-search-input'); const fandomFilter = document.getElementById('fictrail-fandom-filter'); const preservedSearchValue = searchInput ? searchInput.value : ''; const preservedFandomValue = fandomFilter ? fandomFilter.value : ''; const pagesToLoad = getPagesToLoad(); // Check if we can use cached data if (isCacheValid() && getMaxCachedPage() >= pagesToLoad) { console.log(`Using cached data for ${pagesToLoad} pages`); showFicTrailLoading('Loading from cache...'); // Combine cached pages up to the requested amount const works = []; for (let page = 1; page <= pagesToLoad; page++) { if (pageCache.has(page)) { works.push(...pageCache.get(page).works); } } displayHistory(username, works, cachedTotalPages, pagesToLoad, preservedSearchValue, preservedFandomValue); // Re-enable buttons if (loadBtn) loadBtn.disabled = false; if (retryBtn) retryBtn.disabled = false; return; } try { showFicTrailLoading(`Loading ${pagesToLoad} ${pagesToLoad === 1 ? 'page' : 'pages'} of ${username}'s fic history...`); const result = await fetchMultiplePagesWithCache(username, pagesToLoad); if (result.works && result.works.length > 0) { displayHistory(username, result.works, result.totalPages, Math.min(pagesToLoad, result.totalPages), preservedSearchValue, preservedFandomValue); } else { showFicTrailError(ERROR_MESSAGES.NO_DATA); } } catch (error) { if (error.message === 'NOT_LOGGED_IN') { showLoginError(); return; } console.error('Error loading history:', error); showFicTrailError(ERROR_MESSAGES.FETCH_FAILED); } finally { // Re-enable buttons after loading completes if (loadBtn) loadBtn.disabled = false; if (retryBtn) retryBtn.disabled = false; } } function displayHistory(username, works, totalPages, actualPagesLoaded, preservedSearchValue = '', preservedFandomValue = '') { showFicTrailResults(); allWorks = works; filteredWorks = [...works]; currentDisplayCount = ITEMS_PER_PAGE; const workCount = works.length; const uniqueAuthors = new Set(works.map(work => work.author)).size; const uniqueFandoms = new Set(works.flatMap(work => work.fandoms)).size; // Update subtitle with cache status const worksCountEl = document.getElementById('fictrail-works-count'); const fandomsCountEl = document.getElementById('fictrail-fandoms-count'); const authorsCountEl = document.getElementById('fictrail-authors-count'); if (worksCountEl) worksCountEl.textContent = `${workCount} ${workCount === 1 ? 'work' : 'works'}`; if (fandomsCountEl) fandomsCountEl.textContent = `${uniqueFandoms} ${uniqueFandoms === 1 ? 'fandom' : 'fandoms'}`; if (authorsCountEl) authorsCountEl.textContent = `${uniqueAuthors} ${uniqueAuthors === 1 ? 'author' : 'authors'}`; // Update slider and pages info if (totalPages && totalPages > 0) { const slider = document.getElementById('fictrail-pages-slider'); const sliderMax = document.querySelector('.fictrail-slider-max'); if (slider) slider.max = totalPages; if (sliderMax) sliderMax.textContent = totalPages; if (slider) { if (actualPagesLoaded !== undefined) { slider.value = actualPagesLoaded; } else { slider.value = Math.min(parseInt(slider.value), totalPages); } } // Update toggle text with cache information const cachedPageCount = getCachedPageCount(); const toggleText = document.getElementById('fictrail-toggle-text'); if (toggleText) { toggleText.textContent = `${actualPagesLoaded}/${totalPages} History Pages Loaded`; } } // Show pages info and update button const pagesInfo = document.getElementById('fictrail-pages-info'); const loadBtn = document.getElementById('fictrail-load-btn'); if (pagesInfo) pagesInfo.style.display = 'block'; if (loadBtn) { loadBtn.textContent = 'Reload History'; loadBtn.onclick = reloadHistory; } updateReloadButtonText(); addFavoriteTagsSummary(works); populateFandomFilter(works); // Restore preserved values and apply search/filter const searchInput = document.getElementById('fictrail-search-input'); const fandomFilter = document.getElementById('fictrail-fandom-filter'); if (searchInput && preservedSearchValue) { searchInput.value = preservedSearchValue; } if (fandomFilter && preservedFandomValue) { fandomFilter.value = preservedFandomValue; } if (preservedSearchValue || preservedFandomValue) { performSearch(); } else { updateResultsCount(works.length); displayWorks(works); } console.log(`Loaded ${works.length} works from ${actualPagesLoaded} pages (${getCachedPageCount()} pages cached)`); } // Initialize when page loads function init() { addStyles(); createFicTrailButton(); // Don't create overlay until button is clicked } // Auto-initialization if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } // --- Cache management BEGIN --- const pageCache = new Map(); // Map of page numbers to {works: [], timestamp: number} let cachedTotalPages = null; let cacheTimestamp = null; function isCacheValid() { return cacheTimestamp && (Date.now() - cacheTimestamp) < CACHE_EXPIRY_MS; } function clearCache() { pageCache.clear(); cachedTotalPages = null; cacheTimestamp = null; console.log('Cache cleared'); } function getCachedPageCount() { return pageCache.size; } function getMaxCachedPage() { if (pageCache.size === 0) return 0; return Math.max(...pageCache.keys()); } // --- Cache management END --- })();