您需要先安装一个扩展,例如 篡改猴、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.2.0 // @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 = 10; // 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 = `/* Content states - only one visible at a time */ .fictrail-content-state { padding-top: 1em; } /* Loading spinner */ .fictrail-spinner { width: 40px; height: 40px; border: 3px solid; border-color: inherit; border-top-color: transparent; border-radius: 50%; animation: fictrail-spin 1s linear infinite; margin: 0 auto 1em; opacity: 0.6; } @keyframes fictrail-spin { to { transform: rotate(360deg); } } /* Content containers */ .loading-content, .error-content { text-align: center; max-width: 800px; margin: 0 auto; } /* Center buttons in error state */ .error-content .actions { justify-content: center; display: flex; } /* Subtitle styling in results */ #fictrail-subtitle { display: flex; flex-wrap: wrap; gap: 0px 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; } /* Form layout */ .fictrail-fieldset { margin-left: 0; margin-right: 0; } .fictrail-search-row { display: flex; gap: 1em; align-items: end; } .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-search-field label, .fictrail-filter-field label { font-weight: bold; font-size: 0.9em; } .fictrail-actions { display: flex; justify-content: center; } /* Results counter */ .fictrail-results-counter { padding-top: 0.8em; font-size: 0.9em; text-align: center; font-style: italic; } /* Form inputs */ #fictrail-search-input, #fictrail-fandom-filter { width: auto !important; max-width: 100%; height: 2.5em !important; 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; } /* Search result highlighting */ .tag.fictrail-highlight { font-weight: bold !important; filter: brightness(1.4) saturate(1.3) !important; } .tag.fictrail-highlight:hover { filter: brightness(1.4) saturate(1.3) !important; } .fictrail-highlight-text { font-weight: bold !important; text-decoration: underline !important; } /* Slider styling */ .fictrail-slider-container { display: flex; flex-direction: column; gap: 0.5em; margin: 1em 0; } .fictrail-slider-track { display: flex; align-items: center; justify-content: center; gap: 1em; } .fictrail-slider-min, .fictrail-slider-max { font-size: 0.8em; min-width: 1.5em; text-align: center; } .fictrail-slider { width: 200px; } .fictrail-load-more-container { margin-bottom: 2em; width: 100%; text-align: center; } /* Mobile responsive styles */ @media (max-width: 768px) { /* Search form responsive */ .fictrail-search-row { flex-direction: column; align-items: stretch; } .fictrail-search-field, .fictrail-filter-field { flex: none; max-width: none; } /* Subtitle responsive */ #fictrail-subtitle { flex-direction: column; align-items: flex-start; } #fictrail-subtitle span:not(:first-child)::before { display: none; } /* Hide favorite tags summary on smaller screens */ #fictrail-favorite-tags-summary-container { display: none; } /* Slider responsive */ .fictrail-slider-track { gap: 0.5em; } .fictrail-slider { width: 100%; } } `; const style = document.createElement('style'); style.textContent = css; document.head.appendChild(style); } // Scraper Module - AO3 history fetching and parsing // Safely extract text content while preserving line breaks from HTML elements function extractTextWithLineBreaks(element) { // Clone the element to avoid modifying the original const clone = element.cloneNode(true); // Replace block elements with newlines const blockElements = clone.querySelectorAll('p, div, br'); blockElements.forEach(el => { if (el.tagName === 'BR') { el.replaceWith('\n'); } else { // Add newline after block elements el.insertAdjacentText('afterend', '\n'); } }); // Get text content and clean up extra whitespace return clone.textContent.replace(/\n\s*\n/g, '\n').trim(); } 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 fetchMultiplePages(username, maxPagesToFetch = MAX_PAGES_FETCH) { let totalPages; let firstPageWorks = []; 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'); // Detect logged-out state on the first page fetch if (isLoggedOutDoc(doc)) { throw new Error('NOT_LOGGED_IN'); } totalPages = getTotalPages(doc); firstPageWorks = scrapeHistoryFromPage(doc); } catch (error) { console.error('Error fetching first page:', error); if (error.message === 'NOT_LOGGED_IN') { // Propagate to caller so UI can show proper login prompt throw error; } return { works: [], totalPages: 1 }; } const pagesToFetch = Math.min(maxPagesToFetch, totalPages); const works = [...firstPageWorks]; // Start from page 2 since we already have page 1 for (let page = 2; page <= pagesToFetch; page++) { showFicTrailLoading(`Loading page ${page} of ${pagesToFetch}...`); const pageWorks = await fetchHistoryPage(username, page); works.push(...pageWorks); await new Promise(resolve => setTimeout(resolve, 500)); } return { works: works, totalPages: totalPages }; } // 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 // 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; // Subnav not found } // Create list item for the button const listItem = document.createElement('li'); // Create the button using AO3's button styles const button = document.createElement('a'); button.id = 'fictrail-history-btn'; button.textContent = '📚 FicTrail'; button.style.cursor = 'pointer'; button.tabIndex = 0; button.addEventListener('click', openFicTrail); // Add keyboard support button.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openFicTrail(); } }); listItem.appendChild(button); // Insert at the beginning of the subnav 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 = document.createElement('div'); fictrailDiv.id = 'fictrail-container'; // HTML template will be injected here during build fictrailDiv.innerHTML = `<div class="fictrail-header"> <h2 class="heading">📚 FicTrail</h2> <ul class="navigation actions" role="navigation"> <li> <a id="fictrail-close" style="cursor: pointer;" tabindex="0">← Back to Plain History</a> </li> </ul> </div> <!-- 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> <!-- Results Counter --> <div id="fictrail-results-count" class="fictrail-results-counter"></div> </fieldset> </form> <!-- Works List --> <div id="fictrail-works-container" style="display: none;"> <ol id="fictrail-works-list" class="reading work index group"></ol> </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> <h4 class="landmark heading">Pages to Load</h4> <!-- Footer with Page Selector --> <form id="fictrail-footer" class="fictrail-form" style="display: none;"> <fieldset class="fictrail-fieldset"> <label for="fictrail-pages-slider" id="fictrail-pages-label">You have ? pages of history. How deep should we search?</label> <p class="note">Loading multiple pages can be slow. Start with fewer pages for better performance, then reload with more if needed.</p> <div class="fictrail-slider-container"> <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>`; // Insert FicTrail inside #main mainElement.appendChild(fictrailDiv); // Add event listeners with error checking const closeBtn = document.getElementById('fictrail-close'); const loadBtn = document.getElementById('fictrail-load-btn'); const retryBtn = document.getElementById('fictrail-retry-btn'); const searchInput = document.getElementById('fictrail-search-input'); const fandomFilter = document.getElementById('fictrail-fandom-filter'); const pagesSlider = document.getElementById('fictrail-pages-slider'); if (closeBtn) { closeBtn.addEventListener('click', closeFicTrail); closeBtn.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); closeFicTrail(); } }); } if (loadBtn) { loadBtn.addEventListener('click', reloadHistory); loadBtn.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); reloadHistory(); } }); } if (retryBtn) { retryBtn.addEventListener('click', retryLastAction); retryBtn.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); retryLastAction(); } }); } if (searchInput) searchInput.addEventListener('input', debounce(performSearch, 300)); if (fandomFilter) fandomFilter.addEventListener('change', applyFilter); if (pagesSlider) pagesSlider.addEventListener('input', updatePagesValue); } function openFicTrail() { // Create FicTrail if it doesn't exist if (!document.getElementById('fictrail-container')) { createOverlay(); } const mainElement = document.getElementById('main'); const fictrailContainer = document.getElementById('fictrail-container'); if (mainElement) { // Hide all children of #main except FicTrail Array.from(mainElement.children).forEach(child => { if (child.id !== 'fictrail-container') { child.style.display = 'none'; } }); } if (fictrailContainer) fictrailContainer.style.display = 'block'; // Only load data if we don't have any works yet if (allWorks.length === 0) { // Show loading state immediately showFicTrailLoading(); setTimeout(() => { loadFirstPage(); }, 100); } else { // Show existing results showFicTrailResults(); } } function closeFicTrail() { const mainElement = document.getElementById('main'); const fictrailContainer = document.getElementById('fictrail-container'); if (mainElement) { // Show all children of #main except FicTrail Array.from(mainElement.children).forEach(child => { if (child.id !== 'fictrail-container') { child.style.display = ''; } }); } if (fictrailContainer) fictrailContainer.style.display = 'none'; } // 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'); } function updatePagesValue() { updateReloadButtonText(); } function updateReloadButtonText() { const currentPages = parseInt(document.getElementById('fictrail-pages-slider').value); const footer = document.getElementById('fictrail-footer'); const loadBtn = document.getElementById('fictrail-load-btn'); // Check if we're in reload mode (footer is visible) if (footer.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, default to 1 page if (!slider) return 1; 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'); if (works.length === 0) { worksListContainer.style.display = 'none'; noResults.style.display = 'block'; // Remove existing load more button and message when there are no results removeLoadMoreElements(); return; } worksListContainer.style.display = 'block'; noResults.style.display = 'none'; // 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; // Calculate starting index for work numbers const startIndex = append ? worksList.children.length : 0; const worksHTML = worksToShow .slice(append ? currentDisplayCount - ITEMS_PER_PAGE : 0) .map((work, index) => ` <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"> <!--title, author, fandom--> <div class="header module"> <h4 class="heading"> <a href="${work.url}" target="_blank" rel="noopener">${escapeHtml(work.title)}</a> by ${work.authorUrl ? `<a rel="author" href="${work.authorUrl}" target="_blank" rel="noopener">${escapeHtml(work.author)}</a>` : escapeHtml(work.author)} </h4> <h5 class="fandoms heading"> <span class="landmark">Fandoms:</span> ${work.fandoms.map(fandom => `<a class="tag" href="/tags/${encodeURIComponent(fandom)}/works" target="_blank" rel="noopener">${escapeHtml(fandom)}</a>`).join(', ')} </h5> <!--required tags--> <ul class="required-tags"> ${work.rating && work.ratingClass ? `<li><a class="help symbol question modal modal-attached" title="Symbols key" href="/help/symbols-key.html" aria-controls="modal"><span class="${work.ratingClass}" title="${escapeHtml(work.rating)}"><span class="text">${escapeHtml(work.rating)}</span></span></a></li>` : ''} ${work.warnings && work.warningClasses ? work.warnings.map((warning, index) => `<li><a class="help symbol question modal modal-attached" title="Symbols key" href="/help/symbols-key.html" aria-controls="modal"><span class="${work.warningClasses[index] || ''}" title="${escapeHtml(warning)}"><span class="text">${escapeHtml(warning)}</span></span></a></li>`).join('') : ''} ${work.categories && work.categoryClasses ? work.categories.map((category, index) => `<li><a class="help symbol question modal modal-attached" title="Symbols key" href="/help/symbols-key.html" aria-controls="modal"><span class="${work.categoryClasses[index] || ''}" title="${escapeHtml(category)}"><span class="text">${escapeHtml(category)}</span></span></a></li>`).join('') : ''} ${work.status && work.statusClass ? `<li><a class="help symbol question modal modal-attached" title="Symbols key" href="/help/symbols-key.html" aria-controls="modal"><span class="${work.statusClass}" title="${escapeHtml(work.status)}"><span class="text">${escapeHtml(work.status)}</span></span></a></li>` : ''} </ul> ${work.publishDate ? `<p class="datetime">${escapeHtml(work.publishDate)}</p>` : ''} </div> <!--warnings again, cast, freeform tags--> ${(() => { // Always show all tags, but highlight matching ones const allTags = [ ...(work.warnings || []).map(tag => ({ type: 'warning', value: tag })), ...(work.relationships || []).map(tag => ({ type: 'relationship', value: tag })), ...(work.characters || []).map(tag => ({ type: 'character', value: tag })), ...(work.freeforms || []).map(tag => ({ type: 'freeform', value: tag })) ]; if (allTags.length === 0) return ''; // Create a set of matching tag values for quick lookup const matchingTagValues = new Set((work.matchingTags || []).map(tag => tag.value)); return `<h6 class="landmark heading">Tags</h6> <ul class="tags commas"> ${allTags.map(tag => { let className = ''; if (tag.type === 'relationship') className = 'relationships'; else if (tag.type === 'character') className = 'characters'; else if (tag.type === 'freeform') className = 'freeforms'; else if (tag.type === 'warning') className = 'warnings'; // Add highlight class if this tag matches the search const isMatching = matchingTagValues.has(tag.value); const tagClass = isMatching ? 'tag fictrail-highlight' : 'tag'; return `<li class="${className}"><a class="${tagClass}" href="/tags/${encodeURIComponent(tag.value)}/works" target="_blank" rel="noopener">${escapeHtml(tag.value)}</a></li>`; }).join(' ')} </ul>`; })()} <!--summary--> ${work.summary ? `<h6 class="landmark heading">Summary</h6> <blockquote class="userstuff summary"> ${(() => { let summaryHTML = work.summary; // Get current search query and highlight matching text const searchInput = document.getElementById('fictrail-search-input'); if (searchInput && searchInput.value.trim()) { const searchQuery = searchInput.value.trim(); const escapedQuery = searchQuery.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); summaryHTML = summaryHTML.replace( new RegExp(`(${escapedQuery})`, 'gi'), '<span class="fictrail-highlight fictrail-highlight-text">$1</span>' ); } return summaryHTML; })()} </blockquote>` : ''} <!--series--> ${work.series && work.series.length > 0 ? `<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>` : ''} <!--stats--> ${(() => { const stats = work.stats || {}; const hasStats = Object.values(stats).some(value => value && value.trim()); if (!hasStats) return ''; return `<dl class="stats"> ${stats.language ? `<dt class="language">Language:</dt> <dd class="language" lang="en">${escapeHtml(stats.language)}</dd>` : ''} ${stats.words ? `<dt class="words">Words:</dt> <dd class="words">${escapeHtml(stats.words)}</dd>` : ''} ${stats.chapters ? `<dt class="chapters">Chapters:</dt> <dd class="chapters">${escapeHtml(stats.chapters)}</dd>` : ''} ${stats.collections ? `<dt class="collections">Collections:</dt> <dd class="collections">${escapeHtml(stats.collections)}</dd>` : ''} ${stats.comments ? `<dt class="comments">Comments:</dt> <dd class="comments">${escapeHtml(stats.comments)}</dd>` : ''} ${stats.kudos ? `<dt class="kudos">Kudos:</dt> <dd class="kudos">${escapeHtml(stats.kudos)}</dd>` : ''} ${stats.bookmarks ? `<dt class="bookmarks">Bookmarks:</dt> <dd class="bookmarks">${escapeHtml(stats.bookmarks)}</dd>` : ''} ${stats.hits ? `<dt class="hits">Hits:</dt> <dd class="hits">${escapeHtml(stats.hits)}</dd>` : ''} </dl>`; })()} <div class="user module group"> <h4 class="viewed heading"> <span>Last visited:</span> ${work.lastVisited || 'Unknown'} </h4> </div> </li> `) .join(''); if (append) { worksList.insertAdjacentHTML('beforeend', worksHTML); } else { worksList.innerHTML = worksHTML; } // Remove existing load more elements before adding new ones removeLoadMoreElements(); // Add load more button if there are more results if (hasMoreResults) { createLoadMoreButton(works, currentDisplayCount); } } function removeLoadMoreElements() { const existingContainer = document.getElementById('fictrail-load-more-container'); if (existingContainer) existingContainer.remove(); } function createLoadMoreButton(works, currentCount) { const worksList = document.getElementById('fictrail-works-list'); const remainingCount = works.length - currentCount; const nextBatchSize = Math.min(ITEMS_PER_PAGE, remainingCount); // Create container for load more elements using AO3 structure const containerDiv = document.createElement('div'); containerDiv.id = 'fictrail-load-more-container'; containerDiv.className = 'fictrail-load-more-container'; // Create load more message const messageDiv = document.createElement('div'); messageDiv.id = 'fictrail-load-more-message'; messageDiv.innerHTML = ` <p>Showing ${currentCount} of ${works.length} ${works.length === 1 ? 'result' : 'results'}</p> `; // Create load more button using AO3 actions structure const buttonDiv = document.createElement('div'); buttonDiv.className = 'actions'; buttonDiv.innerHTML = ` <a id="fictrail-load-more-button" style="cursor: pointer;" tabindex="0"> Load ${nextBatchSize} More ${nextBatchSize === 1 ? 'Result' : 'Results'} </a> `; // Add message and button to container containerDiv.appendChild(messageDiv); containerDiv.appendChild(buttonDiv); // Insert container after the works-list div worksList.parentNode.insertBefore(containerDiv, worksList.nextSibling); // Add event listener to the load more button const loadMoreButton = document.getElementById('fictrail-load-more-button'); loadMoreButton.addEventListener('click', loadMoreWorks); loadMoreButton.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); loadMoreWorks(); } }); } 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 if (work.relationships) { work.relationships.forEach(tag => { tagCounts[tag] = (tagCounts[tag] || 0) + 1; }); } // Count characters if (work.characters) { work.characters.forEach(tag => { tagCounts[tag] = (tagCounts[tag] || 0) + 1; }); } // Count freeforms if (work.freeforms) { work.freeforms.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 const [mostPopularTag] = sortedTags[0]; // Create summary element using AO3 structure const summaryDiv = document.createElement('p'); summaryDiv.id = 'fictrail-favorite-tags-summary'; summaryDiv.innerHTML = `So you've been really into ${escapeHtml(mostPopularTag)} lately. Love it for you.`; // Insert in the designated container const summaryContainer = document.getElementById('fictrail-favorite-tags-summary-container'); summaryContainer.appendChild(summaryDiv); } } // Core Module - Main functionality and history loading let allWorks = []; let filteredWorks = []; let lastFailedAction = null; // 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.'); } function retryLastAction() { if (lastFailedAction === 'reloadHistory') { reloadHistory(); } else { loadFirstPage(); } } async function loadFirstPage() { lastFailedAction = 'loadFirstPage'; const username = getUsername(); if (!username) { showLoginError(); return; } try { // Check if we're on page 1 of readings - if so, parse current DOM instantly const urlParams = new URLSearchParams(window.location.search); const currentPage = parseInt(urlParams.get('page')) || 1; if (window.location.pathname.includes('/readings') && currentPage === 1 && !urlParams.has('show')) { const works = scrapeHistoryFromPage(document); const totalPages = getTotalPages(document); if (works && works.length > 0) { displayHistory(username, works, totalPages, 1); return; } } // Fallback: use reloadHistory to fetch first page await reloadHistory(); } catch (error) { if (error.message === 'NOT_LOGGED_IN') { showLoginError(ERROR_MESSAGES.LOGGED_OUT); return; } console.error('Error loading first page:', error); showFicTrailError(ERROR_MESSAGES.FETCH_FAILED); } } async function reloadHistory() { lastFailedAction = '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 : ''; // Get pages to load from slider const pagesToLoad = getPagesToLoad(); showFicTrailLoading(`Loading ${pagesToLoad} ${pagesToLoad === 1 ? 'page' : 'pages'} of ${username}'s fic history...`); try { const result = await fetchMultiplePages(username, pagesToLoad); if (result.works && result.works.length > 0) { displayHistory(username, result.works, result.totalPages, pagesToLoad, preservedSearchValue, preservedFandomValue); } else { showFicTrailError(ERROR_MESSAGES.NO_DATA); } } catch (error) { if (error.message === 'NOT_LOGGED_IN') { showLoginError(ERROR_MESSAGES.LOGGED_OUT); return; } console.error('Error loading history:', error); showFicTrailError(ERROR_MESSAGES.FETCH_FAILED); } finally { // Re-enable buttons after loading completes const loadBtn = document.getElementById('fictrail-load-btn'); const retryBtn = document.getElementById('fictrail-retry-btn'); if (loadBtn) loadBtn.disabled = false; if (retryBtn) retryBtn.disabled = false; } } function displayHistory(username, works, totalPages, actualPagesLoaded, preservedSearchValue = '', preservedFandomValue = '') { showFicTrailResults(); allWorks = works; filteredWorks = [...works]; // Reset pagination when loading new history 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 individual subtitle elements with proper plural/singular forms 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 max value to match user's actual page count if (totalPages && totalPages > 0) { const slider = document.getElementById('fictrail-pages-slider'); const sliderMax = document.querySelector('.fictrail-slider-max'); const pagesLabel = document.getElementById('fictrail-pages-label'); if (slider) slider.max = totalPages; if (sliderMax) sliderMax.textContent = totalPages; // Update the label with actual page count and current loaded pages if (pagesLabel) { if (actualPagesLoaded === totalPages) { pagesLabel.textContent = `You have ${totalPages} ${totalPages === 1 ? 'page' : 'pages'} of history. All ${totalPages === 1 ? 'page' : 'pages'} loaded.`; } else { pagesLabel.textContent = `You have ${totalPages} ${totalPages === 1 ? 'page' : 'pages'} of history. Now ${actualPagesLoaded} ${actualPagesLoaded === 1 ? 'page is' : 'pages are'} loaded. Shall we go deeper?`; } } // Set slider value to the actual pages loaded (for initial load) or keep current value (for reload) if (slider) { if (actualPagesLoaded !== undefined) { slider.value = actualPagesLoaded; } else { const newValue = Math.min(parseInt(slider.value), totalPages); slider.value = newValue; } } } // Show footer with page selector and update button for reload functionality const footer = document.getElementById('fictrail-footer'); const loadBtn = document.getElementById('fictrail-load-btn'); if (footer) footer.style.display = 'block'; if (loadBtn) { loadBtn.textContent = 'Reload History'; loadBtn.onclick = reloadHistory; } updateReloadButtonText(); // Add favorite tags summary addFavoriteTagsSummary(works); // Add search and display functionality populateFandomFilter(works); // Restore preserved search and filter values 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; } // Apply search and filter if values were preserved if (preservedSearchValue || preservedFandomValue) { performSearch(); // This will also apply the filter } else { updateResultsCount(works.length); displayWorks(works); } console.log(`Loaded ${works.length} works from ${actualPagesLoaded} pages`); } // 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(); } })();