AO3 Quick Bookmarks

Auto-fills bookmarks with a clickable link "Title – Author – 0 words, Chapter 0/0". Adds a quick-save button that pops up in the corner when you scroll up, used to capture mid-read jumps. Compatible with Entire Work. Shows +new chapters/words on the bookmarks page.

// ==UserScript==
// @name        AO3 Quick Bookmarks
// @description Auto-fills bookmarks with a clickable link "Title – Author – 0 words, Chapter 0/0". Adds a quick-save button that pops up in the corner when you scroll up, used to capture mid-read jumps. Compatible with Entire Work. Shows +new chapters/words on the bookmarks page.
// @version     1.12
// @author      C89sd
// @namespace   https://greasyfork.org/users/1376767
// @match       https://archiveofourown.org/*
// @grant       GM_addStyle
// @run-at      document-start
// @noframes
// ==/UserScript==

let STORAGE_KEY = 'ao3BookmarkDB',
    DEFAULT_DB = { version: 1, works:{} };

// let db = loadDb();
let db = DEFAULT_DB;

let ourARegex = /(<a.*?>.*?words, Chapter.*?<\/a>)/i;

let url = location.href,
    isWork      = /\/(works|chapters)\/\d+/.test(url),
    isBookmarks = url.includes('/bookmarks'),
    isSearch    = !(isWork || isBookmarks);

if (isWork && location.hash === '#NEXT') pressNextButton();

addEventListener('DOMContentLoaded', () => {
    if (isBookmarks)   scrapeBookmarksPage();
    // else if (isSearch) annotateSearchPage();
    else if (isWork)   enhanceWorkPage();
});

// function loadDb () {
//     let raw = localStorage.getItem(STORAGE_KEY);
//     if (!raw) return DEFAULT_DB;
//     try {
//       let data = JSON.parse(raw);
//       // future migrations if (data.version == 1) {}
//       return data;
//     }
//     catch (e) { return DEFAULT_DB; }
// }
// function saveDb () { localStorage.setItem(STORAGE_KEY, JSON.stringify(db)); }
function saveDb () {}

/* ---------------------------- BOOKMARKS --------------------------- */
function scrapeBookmarksPage () {

    GM_addStyle(`
    .qbfav   { box-shadow: inset 0 0 2px 1px #ff7991; /* pink */  }
    .qbwatch { outline: 2px solid green; }
    `);

    let list = document.querySelectorAll("li[id^='bookmark_'][class*='work-']");

    for (let li of list){
        let workId = (li.querySelector("a[href^='/works/']")||{}).href.match(/\/works\/(\d+)/);
        if (!workId) continue;
        workId = workId[1];

        let tags = [];
        for (let tag of li.querySelectorAll(".meta.tags .tag")) {
          let text = tag.textContent.trim();
          tags.push(text);

          if (text.toLowerCase().includes("fav"))   li.classList.add("qbfav");
          if (text.toLowerCase().includes("watch")) li.classList.add("qbwatch");
        }

        let commentNode = li.querySelector(".userstuff.notes > p"),
            commentHtml = commentNode ? commentNode.innerHTML.trim() : '';

        db.works[workId] = {tags:tags, comment:commentHtml};

        /* check if this has one of our links */
        const match = commentHtml.match(ourARegex);
        if (match && match[1]){
            const temp = document.createElement('div');
            temp.innerHTML = match[1];
            let ourLink = temp.querySelector('a');

            /* stored state  "...  - 25,371 Words, Chapter 11/12"     */
            let m = ourLink.textContent.match(/ ([\d,]+) words, Chapter ([\d,]+)\/([\d,]+)/i) || [];
            let oldWords   = +(m[1]||'0').replace(/,/g,''),
                readCh = +(m[2]||'0').replace(/,/g,''),
                lastCh  = +(m[3]||'0').replace(/,/g,'');

            /* current state */
            let chNode = li.querySelector('dd.chapters'),
                wrdNode= li.querySelector('dd.words');

            let newLastCh = chNode ? +chNode.textContent.split('/')[0].replace(/,/g,'') : 0,
                newWords = wrdNode? +wrdNode.textContent.replace(/,/g,''): 0;

            /* add +N indicators besides chapters / words */
            let color;
            let plus = true;
            if (lastCh===readCh) { // caught up
                if (newLastCh>lastCh) { // updated
                    color = '#1bb900';
                    injectDiff(chNode , newLastCh - lastCh,  color, true,  true);
                    injectDiff(wrdNode, newWords - oldWords, color, false, false);
                }
            }
            else { // dropped
                if (newLastCh>lastCh) { // updated
                    color = 'rgb(242, 122, 15)';
                    injectDiff(chNode , newLastCh - readCh,  color, true,  true);
                    injectDiff(wrdNode, newWords - oldWords, color, false, false);
                }
                else if (lastCh>readCh) {
                    color = 'rgb(179, 170, 8)';
                    injectDiff(chNode , lastCh - readCh, color, true, true);
                    injectDiff(wrdNode, '?', color, false, false);
                }
            }
        }
        // console.log('Bookmark', workId, db.works[workId]);
    }

    function injectDiff(node, diff, color, bold, plus){
        if (!node) return;
        // if (!diff) return;
        let sp = document.createElement('span');
        sp.textContent = ' ' + (plus ? '+' : '') + diff.toLocaleString();
        sp.style.fontWeight= bold ? 'bold' : '';
        sp.style.color = color;

        sp.style.border = "1px solid";
        sp.style.borderRadius = "5px";
        sp.style.marginLeft = "5px";
        sp.style.paddingRight = "5px";

        node.appendChild(sp);
    }

    saveDb();

    /* display total bookmarks atop the page */
    let h2 = document.querySelector('h2');
    if (h2) h2.textContent += ' ('+Object.keys(db.works).length+' total)';
}

/* ----------------------------- SEARCH ----------------------------- */

/* no enhancements for now */
// function annotateSearchPage () {
//     let works = document.querySelectorAll("li.blurb.work");
//     for (let work of works){
//         let id = (work.querySelector("a[href^='/works/']")||{}).href.match(/\/works\/(\d+)/);
//         if (!id) continue;
//         id=id[1];
//         console.log('Search result',id, db.works[id]);
//     }
// }

/* ------------------------------ WORK ------------------------------ */

function enhanceWorkPage () {

    /* set bookmark private */
    let privateBox = document.getElementById('bookmark_private');
    if (privateBox) privateBox.checked = true;

    let form    = document.getElementById('bookmark-form'),  // bookmark container
        notes   = document.getElementById('bookmark_notes'); // comment textarea

    /* display bookmark above the title */
    if (notes && notes.value.trim()){
        let header = document.createElement('h1');
        header.innerHTML = '<hr>Bookmark: '+notes.value +'<hr>';
        header.style = "color: #cf77ef; text-align: center; margin-bottom:-30px; margin-top:15px; font-size: 25px;";
        (document.getElementById('workskin')||document.body).prepend(header);
    }

    /* live preview at the top of the textarea
       detect any <a> in the box and inject it atop the area
       allows jumping back up if you accidentally pressed the bookmark button */
    if (notes){
        notes.addEventListener('input', updatePreview);
        updatePreview();
    }
    function updatePreview(){
        let head = form ? form.querySelector('h4.heading') : null;
        if (!head) return;
        let link = notes.value.match(/(<a.*?<\/a>)/);
        head.innerHTML = 'Bookmark: '+(link?link[1]:'');

        let linkEl = head.querySelector('a')
        if (linkEl) {
          linkEl.addEventListener('click', () => document.querySelector('.bookmark_form_placement_close')?.click())
           // disable jumping if theres is no mid-jump text search
          if (!linkEl.href.includes('#')) linkEl.style = 'pointer-events: none;'
           // limit jumping to the current page
          if (linkEl.href.includes('#'))  linkEl.href = window.location.pathname + window.location.search + '#' + linkEl.href.split('#')[1];
        }
    }

    /* floating bookmark button, shows when you scrolls up */
    injectFloatingButton();

    /* enchance default Bookmark button */
    let buttons = document.querySelectorAll('.bookmark_form_placement_open');
    for (let button of buttons) { button.addEventListener('click', onClick, true); } // capture: true => assemble link before we move to bottom

    /* -------- internal helpers ------------------------------------- */

    function injectFloatingButton(){
        let css = document.createElement('style');
        css.textContent =
          '.ao3-float {position:fixed;bottom:10px;left:10px;padding:6px 12px;background:#89a;opacity:0;border-radius:3px;color:#fff;cursor:pointer;transition:opacity 0.3s ease-in}'+
          '.ao3-float.show{opacity:.5;transition:opacity 0.2s ease-in}'+
          '.ao3-float:not(.show){transition:opacity 0.2s}';
        document.head.appendChild(css);

        let btn = document.createElement('div');
        btn.textContent='Bookmark';
        btn.className='ao3-float';
        btn.style.userSelect = 'none';
        document.body.appendChild(btn);

        /* show / hide depending on scroll direction */
        let lastY = scrollY;
        addEventListener('scroll', function(){
            if (scrollY < lastY) btn.classList.add('show');  // user heads back up
            else                  btn.classList.remove('show');
            lastY = scrollY;
        });

        /* highlight while button is held */
        let highlightSpan = null;
        function highlightParagraph(){
            if (highlightSpan) return;

            let [node, snippet] = findUniqueVisibleLeaf(); // Text node chosen by algorithm
            if (!snippet) return;

            let span = document.createElement('span');
            span.style.background = 'rgba(255,255,255,0.3)';  // 30% gray overlay
            span.style.mixBlendMode = 'difference';           // darken on light bg / lighten on dark bg
            node.parentNode.insertBefore(span, node);
            span.appendChild(node);
            highlightSpan = span;
        }
        function clearHighlight(){
            if (!highlightSpan) return;
            const parent = highlightSpan.parentNode;
            parent.replaceChild(highlightSpan.firstChild, highlightSpan);
            highlightSpan = null;
        }

        btn.addEventListener('pointerdown', highlightParagraph, {passive:true});
        addEventListener('pointerup',       clearHighlight, {passive:true});
        addEventListener('pointercancel',   clearHighlight, {passive:true});

        btn.addEventListener('click', () => {
            let button = document.querySelector('.bookmark_form_placement_open');
            if (button) button.click(); // let AO3 scroll the page for us
        });
    }

    /* when the floating button is clicked */
    function onClick () {
        let link = assembleLink();
        if (!link) return;

        /* update textarea, replacing <a> inplace but keeping other text */
        if (ourARegex.test(notes.value))
             notes.value = notes.value.replace(ourARegex, link);
        else notes.value = link + ' ' + notes.value;

        updatePreview();                       // keep preview in sync
    }

    /* gather stats to create the link */
    function assembleLink () {

        const workTitle = (document.querySelector('h2.title')   || {textContent:'__error__'}).textContent.trim(),
              author    = (document.querySelector('h3.byline')  || {textContent:'Anonymous'}).textContent.trim(),
              wordsNow  = (document.querySelector('dd.words')   || {textContent:'0'}).textContent,
              chTot     = (document.querySelector('dd.chapters')|| {textContent:'0'}).textContent.split('/')[0];

        /* all chapters currently loaded on the page */
        const chapters = [...document.querySelectorAll('#workskin div.chapter[id^="chapter-"]')],
              firstChapter = chapters[0],
              lastChapter  = chapters[chapters.length - 1];

        const screenTop = 0,
              screenBottom =  window.innerHeight,
              firstChapterTop = firstChapter.getBoundingClientRect().top,
              lastChapterBottom = lastChapter.getBoundingClientRect().bottom;

        function parentChapter(node) { return node.parentElement.closest('#workskin div.chapter[id^="chapter-"]'); }

        const [node, snippet] = findUniqueVisibleLeaf(),
              beforeFirst = screenTop < firstChapterTop,
              afterLast = screenBottom > lastChapterBottom,
              hasSnippet = node && snippet;

        let chapter, suffix, ending;
        /* START of work is visible or above */
        if (beforeFirst)     { chapter = firstChapter;        ending = 'Start';  suffix = ''; }
        /* END of work is visible or below */
        else if (afterLast)  { chapter = lastChapter;         ending = 'End';    suffix = '#NEXT'; }
        /* MIDDLE snippet jump */
        else if (hasSnippet) { chapter = parentChapter(node); ending = 'Middle'; suffix = '#:~:text=' + encodeURIComponent(snippet); }
        /* ERROR */
        else  {
            alert('AO3 Quick Bookmarks: Error, in middle of chapter but no text is visible.');
            return '';
        }

        const chNow   = chapter.id.match(/chapter-(\d+)/)[1],
              baseUrl = chapter.querySelector('a').href.match(/(\/works\/\d+\/chapters\/\d+)/)[1];
        return `<a href="${baseUrl+suffix}">${workTitle} - ${author} - ${wordsNow} words, Chapter ${chNow}/${chTot} ${ending}</a>`;
    }

    /*  first strictly visible text node that is unique on the page  */
    function findUniqueVisibleLeaf(){

        /*  WALK all TEXT_NODEs inside *all* chapter-text bodies  */
        const containers = document.querySelectorAll('#workskin div.userstuff.module');
        if (!containers.length) return [null, ''];

        /*  one TreeWalker per container with a custom nextTextNode() function that bridges them  */
        const walkers = [...containers].map(c => document.createTreeWalker(c, NodeFilter.SHOW_TEXT, null));

        let wi = 0; // walker index
        function nextTextNode(){
            let n;
            while (wi < walkers.length){
                n = walkers[wi].nextNode();
                if (n) return n;
                wi++;
            }
            return null;
        }

        /*  find first TEXT_NODE whose top is visible                 */
        let node;
        while ((node = nextTextNode())){
            if (!node.nodeValue.trim()) continue;
            const rect = textNodeRect(node);
            if (rect.top > 0) break;
        }
        if (!node) return [null, ''];

        /*  helper : get the client-rect for a TEXT_NODE              */
        function textNodeRect(txt){
            const range = document.createRange();
            range.selectNodeContents(txt);
            const rect = range.getBoundingClientRect();
            range.detach?.();
            return rect;
        }

        /*  scan downward from this TEXT_NODE until a unique snippet is found  */
        while (node){
            const txt = node.nodeValue.trim();
            if (txt){
                const snippet = txt.split(/\s+/).slice(0, 10).join(' ');
                if (isUnique(snippet)){
                    return [node, snippet];
                }
            }
            node = nextTextNode();
        }
        return [null, ''];

        /*  ensure snippet appears exactly once on the current page   */
        function isUnique(snippet){
            if (!snippet) return false;
            let cnt = 0, n;
            const w = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null);
            while ((n = w.nextNode())){
                if (n.nodeValue.trim().includes(snippet)){
                    cnt++;
                    if (cnt > 1) break;
                }
            }
            return cnt === 1;
        }
    }
}

/* ------------------------------ NEXT ------------------------------ */
const WAIT = 400, SLIDE = 800;

function pressNextButton() {
    new MutationObserver((_, obs) => {
        const next = document.querySelector('.next.chapter a');
        if (next) {
          obs.disconnect();
          setTimeout(() => {
              registerUndoSlide();
              doSlide(next);
          }, WAIT);
        }
  }).observe(document.documentElement, {childList:true, subtree:true});
}

function doSlide(next) {
  /* copy of the page to the right + title + spinner */
  if (!document.getElementById('spinCSS'))
    document.head.insertAdjacentHTML('beforeend',
      '<style id="spinCSS">@keyframes spin{to{transform:rotate(360deg)}}</style>');
  document.body.insertAdjacentHTML('beforeend',
    '<div id="pageClone" style="position:fixed;top:0;left:100vw;width:100vw;height:100vh;overflow:hidden;background:#fff;opacity:.8;filter:brightness(.9);pointer-events:none;z-index:9;display:flex;flex-direction:column;justify-content:center;align-items:center;">' +
      '<div style="font:700 8vw/1 sans-serif;color:#444;">Loading<br>Next&nbsp;Chapter</div>' +
      '<div style="width:64px;height:64px;margin-top:1rem;border:8px solid #bbb;border-top-color:#444;border-radius:50%;animation:spin 1s linear infinite;"></div>' +
    '</div>');

  /* slide page left  */
  document.head.insertAdjacentHTML('beforeend',
    `<style id="slideCSS">html{overflow:hidden}body{transition:transform ${SLIDE}ms ease;transform:translateX(-100vw)}</style>`);

  // setTimeout(() => next.click(), SLIDE);
  setTimeout(() => window.location.href = next.href.split('#')[0], SLIDE);
}

/* undo the slide when we return from BF-cache */
function registerUndoSlide() {
    addEventListener('pageshow', (e) => {
        if (!e.persisted) return;

        const clone = document.getElementById('pageClone');
        if (!clone) return;

        requestAnimationFrame(() => document.body.style.transform = 'translateX(0)');
        setTimeout(() => {
            clone.remove();
            const css = document.getElementById('slideCSS');
            css && css.remove();
            document.documentElement.style.overflow = '';
            document.body.style.transform = '';
        }, SLIDE);
    })
}