AO3 Res

Tweaks to the Archive!

// ==UserScript==
// @name         AO3 Res
// @namespace    https://archiveofourown.org/
// @version      1.5
// @description  Tweaks to the Archive!
// @author       dxudz
// @match        https://archiveofourown.org/*
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

// Add "Marked for Later" and "Skins" on the user actions dropdown

  const LINK_CLASS = 'custom-added-link';
  let debounceTimer = null;

  // Robustly find the user dropdown menu element (AO3 variations)
  function getDropdownMenu() {
    return document.querySelector(
      'ul.user.navigation.actions li.dropdown ul.menu.dropdown-menu'
    ) || document.querySelector(
      'ul.user.navigation.actions li.dropdown .dropdown-menu'
    ) || document.querySelector(
      'li.dropdown ul.menu.dropdown-menu'
    );
  }

  // Find an anchor that points to a user profile anywhere in the header
  function findProfileAnchor() {
    // prefer anchors inside the user navigation but fall back to any profile link
    return document.querySelector('ul.user.navigation.actions a[href^="/users/"]') ||
           document.querySelector('a[href^="/users/"]');
  }

  function extractUsernameFromHref(href) {
    if (!href) return null;
    const m = href.match(/\/users\/([^\/?#]+)/);
    if (m && m[1]) {
      try {
        return decodeURIComponent(m[1]);
      } catch (e) {
        return m[1];
      }
    }
    return null;
  }

  // Try several places to obtain the username
  function findUsername() {
    const profileAnchor = findProfileAnchor();
    if (profileAnchor) {
      const fromHref = extractUsernameFromHref(profileAnchor.getAttribute('href'));
      if (fromHref) return fromHref;
      const text = profileAnchor.textContent.trim();
      if (text) return text;
    }

    // fallback: greeting like "Hi, username!"
    const greeting = document.querySelector('ul.user.navigation.actions li.dropdown > a.dropdown-toggle') ||
                     document.querySelector('a.dropdown-toggle');
    if (greeting) {
      const txt = greeting.textContent.replace(/\s+/g, ' ').trim();
      const m = txt.match(/^Hi,\s*(.+?)!$/i);
      if (m && m[1]) return m[1].trim();
    }

    return null;
  }

  function createLi(href, text, username) {
    const li = document.createElement('li');
    li.className = LINK_CLASS;
    li.setAttribute('role', 'menuitem');
    li.setAttribute('data-for-user', username);
    const a = document.createElement('a');
    a.href = href;
    a.textContent = text;
    li.appendChild(a);
    return li;
  }

  // Add the two links once (idempotent)
  function addLinksOnce() {
    const menu = getDropdownMenu();
    if (!menu) return false;

    const username = findUsername();
    if (!username) return false;

    // If we already have links for this username, nothing to do
    const existing = Array.from(menu.querySelectorAll(`li.${LINK_CLASS}`));
    if (existing.some(li => li.getAttribute('data-for-user') === username)) {
      return true;
    }

    // Remove any leftover custom links for other users (avoid duplicates/stale items)
    existing.forEach(li => li.remove());

    // Create list items
    const markedLi = createLi(
      `https://archiveofourown.org/users/${encodeURIComponent(username)}/readings?show=to-read`,
      'Marked for Later',
      username
    );
    const skinsLi = createLi(
      `https://archiveofourown.org/users/${encodeURIComponent(username)}/skins`,
      'My Skins',
      username
    );

    // Insert at top and preserve original order: Marked for Later, then My Skins
    menu.insertBefore(skinsLi, menu.firstElementChild || null);
    menu.insertBefore(markedLi, menu.firstElementChild || null);

    return true;
  }

  // Debounced scheduler for MutationObserver callbacks
  function scheduleAdd() {
    if (debounceTimer) clearTimeout(debounceTimer);
    debounceTimer = setTimeout(() => {
      try { addLinksOnce(); } catch (e) { /* swallow */ }
    }, 120);
  }

  function startWatching() {
    // try immediately
    addLinksOnce();

    if (window.MutationObserver) {
      const observer = new MutationObserver(scheduleAdd);
      observer.observe(document.documentElement, { childList: true, subtree: true });
    } else {
      // fallback polling
      setInterval(addLinksOnce, 1500);
    }
  }

  // Run right away if ready, otherwise on load
  if (document.readyState === 'complete' || document.readyState === 'interactive') {
    startWatching();
  } else {
    window.addEventListener('load', startWatching, { once: true });
  }

// Change AO3's tab icon

const newFaviconURL = "https://i.ibb.co/vxQsC1XT/archive-of-our-own-svgrepo-com.png";

function replaceFavicon() {
  const head = document.querySelector("head");
  if (!head) return;

  // Remove existing favicons
  head.querySelectorAll("link[rel*='icon']").forEach(icon => icon.remove());

  // Add the new one
  const newIcon = document.createElement("link");
  newIcon.rel = "icon";
  newIcon.type = "image/png";
  newIcon.href = newFaviconURL;
  newIcon.className = "custom-favicon"; // mark it
  head.appendChild(newIcon);
}

// Run immediately if head is ready
if (document.head) replaceFavicon();

// Also run on load
window.addEventListener("load", replaceFavicon);

// Keep watching in case AO3 replaces the favicon later
if (window.MutationObserver) {
  const observer = new MutationObserver(() => {
    const current = document.querySelector("link.custom-favicon");
    if (!current) {
      replaceFavicon();
    }
  });
  observer.observe(document.head || document.documentElement, {
    childList: true,
    subtree: true
  });
}


// Censor your username

    let username = "";
let isCensored = true;
const spans = [];

function toggle() {
  isCensored = !isCensored;
  spans.forEach(span => {
    span.textContent = isCensored ? "▇▇" : span.dataset.username;
  });
}

function makeCensoredSpan(name) {
  const span = document.createElement("span");
  span.textContent = "▇▇";
  span.style.cursor = "pointer";
  span.title = "Click to toggle username";
  span.dataset.username = name;
  span.dataset.censored = "1";
  span.addEventListener("click", toggle);
  spans.push(span);
  return span;
}

/** Try several places to get the logged-in username, once */
function detectUsername() {
  if (username) return username;

  // A) Greeting "Hi, <name>!"
  const topUser = document.querySelector("ul.user.navigation.actions li.dropdown > a.dropdown-toggle");
  if (topUser) {
    const txt = topUser.textContent.replace(/\s+/g, " ").trim();
    const m = txt.match(/^Hi,\s*(.+?)!$/i);
    if (m && m[1]) {
      username = m[1].trim();
      return username;
    }
  }

  // B) Any user link in the user menu
  const userLink = document.querySelector('ul.user.navigation.actions a[href^="/users/"]');
  if (userLink) {
    // Prefer link text if it looks like a username
    const t = userLink.textContent.replace(/\s+/g, " ").trim();
    if (t && !/[^\w\-_.]/.test(t)) {
      username = t;
      return username;
    }
    // Fallback: extract from href
    const href = userLink.getAttribute("href");
    const m2 = href && href.match(/\/users\/([^\/?#]+)/);
    if (m2 && m2[1]) {
      username = decodeURIComponent(m2[1]);
      return username;
    }
  }

  // C) Comment header: "Comment as <name>"
  const commentHead = document.querySelector("#add_comment_placeholder #add_comment fieldset > h4.heading");
  if (commentHead) {
    const txt = commentHead.textContent.replace(/\s+/g, " ").trim();
    const m3 = txt.match(/Comment as\s+(.+)/i);
    if (m3 && m3[1]) {
      username = m3[1].trim();
      return username;
    }
  }

  return "";
}

function applyCensorship() {
  detectUsername();
  if (!username) return; // Wait until we know it

  // 1) Top right "Hi, username!"
  const topUser = document.querySelector("ul.user.navigation.actions li.dropdown > a.dropdown-toggle");
  if (topUser && !topUser.querySelector('span[data-censored="1"]')) {
    const span = makeCensoredSpan(username);
    // Normalize greeting safely
    const before = document.createTextNode("Hi, ");
    const after = document.createTextNode("!");
    // Clear and rebuild
    while (topUser.firstChild) topUser.removeChild(topUser.firstChild);
    topUser.append(before, span, after);
  }

  // 2) Comment as username?
  const commentHead = document.querySelector("#add_comment_placeholder #add_comment fieldset > h4.heading");
  if (commentHead && !commentHead.querySelector('span[data-censored="1"]')) {
    const txt = commentHead.textContent.replace(/\s+/g, " ").trim();
    if (/^Comment as\b/i.test(txt)) {
      const span = makeCensoredSpan(username);
      commentHead.textContent = "Comment as ";
      commentHead.appendChild(span);
    }
  }

  // 3) Profile page username
  const profileHead = document.querySelector("div.primary.header.module > h2.heading");
  if (profileHead && !profileHead.querySelector('span[data-censored="1"]')) {
    const txt = profileHead.textContent.replace(/\s+/g, " ").trim();
    if (txt === username || txt.includes(username)) {
      profileHead.textContent = "";
      profileHead.appendChild(makeCensoredSpan(username));
    }
  }

  // 4) Bookmarks page header "Bookmarks by <name>"
  const bookmarksHeading = document.querySelector("div#main.bookmarks-index h2.heading");
  if (bookmarksHeading && !bookmarksHeading.querySelector('span[data-censored="1"]')) {
    const txt = bookmarksHeading.textContent.replace(/\s+/g, " ").trim();
    const m = txt.match(/^(.*Bookmarks by )(.+)$/i);
    if (m) {
      const [, prefix, name] = m;
      if (!username) username = name.trim();
      bookmarksHeading.textContent = prefix;
      bookmarksHeading.appendChild(makeCensoredSpan(username));
    }
  }

  // 5) In bookmark lists → "Bookmarked by [username]" → "Bookmarked by you"
  document.querySelectorAll("h5.byline.heading").forEach(h5 => {
    if (h5.dataset.censored === "1") return;
    const link = h5.querySelector("a[href*='/bookmarks']");
    if (!link) return;

    const linkName = link.textContent.replace(/\s+/g, " ").trim();
    const href = link.getAttribute("href") || "";
    const matchesName = username && linkName === username;
    const matchesHref = username && href.includes(`/users/${encodeURIComponent(username)}/bookmarks`);

    if (matchesName || matchesHref) {
      link.textContent = "you";
      h5.dataset.censored = "1";
    }
  });
}

function start() {
  // First pass
  applyCensorship();

  // Observe DOM changes (AO3 uses partial reloads)
  const observer = new MutationObserver(() => {
    applyCensorship();
  });
  observer.observe(document.documentElement, { childList: true, subtree: true });

  // Also run when the page reports it's fully complete
  if (document.readyState !== "complete") {
    window.addEventListener("load", applyCensorship, { once: true });
  }
}

start();


// Add wordcount into chapters


    const WPM = 250; // words per minute for estimation (< you can change this!)

  function countTime(numWords) {
    if (!numWords) return '?';
    numWords = Math.round(Number(numWords) / WPM);
    const h = Math.floor(numWords / 60);
    const m = numWords % 60;
    return `${h > 0 ? `${h}hr ` : ''}${m > 0 ? `${m}min` : ''}` || '<1min';
  }

  // Check if we're on a valid work page (with or without chapter)
  if (/\/works\/\d+(\/chapters\/\d+)?(?!.*navigate)/.test(window.location.pathname)) {
    const wordCountElem = document.querySelector('dl.stats dd.words');
    const wordCountText = wordCountElem?.textContent?.replace(/,/g, '');
    const numWords = parseInt(wordCountText) || 0;

    if (wordCountElem && numWords) {
      const timeEstimate = countTime(numWords);
      wordCountElem.insertAdjacentHTML('afterend', `<dt>Time:</dt><dd>${timeEstimate}</dd>`);
    }

    // Add per-chapter word count and reading time
    const chapterBlocks = document.querySelectorAll('#chapters > .chapter > div.userstuff.module');
    chapterBlocks.forEach(chapter => {
      const rawText = chapter.textContent.replace(/['’‘-]/g, '');
      const wordCount = (rawText.match(/\w+/g) || []).length - 2;
      const time = countTime(wordCount);

      // Get work and chapter info
      const workIdMatch = location.pathname.match(/\/works\/(\d+)/);
      const chapterSelect = document.querySelector('#selected_id');
      const currentChapterNumber = chapterSelect
        ? parseInt(chapterSelect.selectedIndex + 1)
        : 1;

      const STORAGE_KEY = 'ao3_chapter_wordcounts';
      const storedData = JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}');
      const workId = workIdMatch?.[1];
      if (!workId) return;

      if (!storedData[workId]) storedData[workId] = {};
      storedData[workId][currentChapterNumber] = wordCount;
      localStorage.setItem(STORAGE_KEY, JSON.stringify(storedData));

      // Sum all previously read chapters with index < current
      let totalRead = 0;
      const chapterNumbers = Object.keys(storedData[workId])
        .map(Number)
        .filter(n => n < currentChapterNumber);

      for (let num of chapterNumbers) {
        totalRead += storedData[workId][num];
      }

      let finalLine = '';
      if (chapterNumbers.length > 0) {
        finalLine = `${totalRead.toLocaleString()} words read in total. This chapter has ${wordCount.toLocaleString()} words (Estimated reading time: ${time}).`;
      } else {
        finalLine = `This chapter has ${wordCount.toLocaleString()} words (Estimated reading time: ${time}).`;
      }

      chapter.parentElement.insertAdjacentHTML('afterbegin',
        `<div style="font-size: 0.7em; text-transform: uppercase; text-align: center; color: #fff; margin: 3em 0 1em;">
          ${finalLine}
        </div>`);
    });
  }

  // For listings: add "Time: X" next to the word count
  function addTimeToListings() {
    document.querySelectorAll('li.work').forEach(work => {
      const stats = work.querySelector('dl.stats');
      const wordDD = stats?.querySelector('dd.words');

      if (!wordDD || wordDD.dataset.timeAdded) return; // already processed

      const wordText = wordDD.textContent.replace(/,/g, '');
      const wordNum = parseInt(wordText);
      if (!wordNum) return;

      const timeEstimate = countTime(wordNum);
      wordDD.insertAdjacentHTML('afterend', `<dt>Time: </dt><dd>${timeEstimate}</dd>`);
      wordDD.dataset.timeAdded = 'true';
    });
  }

  // Run once on load
  addTimeToListings();

  // Also rerun on mutations (e.g., infinite scroll)
  const observer = new MutationObserver(addTimeToListings);
  observer.observe(document.body, { childList: true, subtree: true });

})();