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