FicTrail - AO3 History Viewer

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(', ')}
                    &nbsp;
                </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();
}



})();