AH/SB/SV/QQ Bookmarks list

List bookmarks before each thread.

// ==UserScript==
// @name        AH/SB/SV/QQ Bookmarks list
// @description List bookmarks before each thread.
// @version     1.15
// @author      C89sd
// @namespace   https://greasyfork.org/users/1376767
// @match       https://*.alternatehistory.com/*
// @match       https://*.questionablequesting.com/*
// @match       https://*.spacebattles.com/*
// @match       https://*.sufficientvelocity.com/*
// @grant       GM_addStyle
// @grant       unsafeWindow
// @noframes
// ==/UserScript==
let ROUNDNESS = 1; // 0=square, 1=round

const DB_KEY      = '_bookmarks1'
const DEFAULT_DB  = '{}';
const THREAD_ID = location.href.match(/\/threads\/[^\/]*?\.(\d+)\//)?.[1];

let site = location.hostname.split('.').slice(-2, -1)[0];
let IS_AH = site==='alternatehistory';
let baseUrl = IS_AH ? '/forum/' : '/';

// ==================================================== FORUM / SEARCH
if (!location.pathname.includes("/threads/")) {
    GM_addStyle(`
    .bmBubble {
        display: inline-block;
        vertical-align: baseline;
        box-sizing: border-box;
        border-radius: ${ 0.55 * ROUNDNESS }em;
/*         margin-right: 1px; */
        margin-left: 3px;
        font-family: sans-serif;
        font-weight: 400;
        width: auto;
        height: auto;
        text-align: center;
        word-wrap: normal;
        word-break: normal;
        outline: 1px solid rgb(166, 116, 199, 0.9);
        padding: 0px 3px;
        color: rgb(166, 116, 199);
        user-select: none;
        text-decoration: none !important;

        font-size:  0.75em !important;
        line-height: 1.1em;
        min-width:   1.1em;
           position: relative;
           top: -0.60px;
    }
    .bmBubble:hover, .bmBubble:focus { color: rgb(122, 61, 150); outline: 1px solid rgb(122, 61, 150); }
    `);

    const threads = document.querySelectorAll('.structItem--thread');
    if (!threads) return;

    const db   = loadDB();

    for (let thread of threads) {
        let threadid = thread.className.match(/\bjs-threadListItem-(\d+)/)?.[1];
        if (!threadid) continue;

        let list     = db[threadid] || [];
        if (list.length === 0) continue;

        let [postid, _] = list[0];
        let url = `${baseUrl}posts/${postid}`

        const title = thread.querySelector('.structItem-title');
        const viewers = title.querySelector('.sv-user-activity--viewer-count');

        const bubble = document.createElement('a');
        bubble.className = 'bmBubble';
        bubble.textContent = list.length;
        bubble.href = url;

        if (viewers) {
            title.insertBefore(bubble, viewers);
            bubble.style.marginRight = '6px';
        }
        else title.append(bubble);
    }

}
// ==================================================== THREADS
if (location.pathname.includes("/threads/") && THREAD_ID) {
    GM_addStyle(`article.message.hasBookmark.hasBookmark { border: solid 2px #9c46c4; border-radius: 4px; }`);
    applyBorderClassOnStart();
    rebuildIndicators();
    !IS_AH && setupXHRListener();
    IS_AH  && setupFetchListener();
}

function loadDB()   { return JSON.parse(localStorage.getItem(DB_KEY) || DEFAULT_DB); }
function saveDB(db) { localStorage.setItem(DB_KEY, JSON.stringify(db)); }
function rebuildIndicators() {
    if (!THREAD_ID) return;                 // not on a thread page

    const db   = loadDB();
    const list = db[THREAD_ID] || [];       // [[postId, date], ...]

    const host = getContainer();
    host.innerHTML = '';                    // clear old indicators
    host.className = '';                    // reset anything left over
    host.style.marginBottom = '0px';

    if (list.length === 0) return;

    host.style.marginBottom = '20px';

    /* ---------------------------------------------------------------
       1. Heading
       --------------------------------------------------------------- */
    const h1 = document.createElement('h1');
    h1.className = 'block-header';          // ← previous nicer style
    h1.textContent = 'Bookmarks:';
    h1.style.padding = '8px 10px';
    h1.style.background = 'none'
    h1.style.backgroundColor = '#9c46c4'
    h1.style.color = 'rgb(254, 254, 254)'
    h1.style.border = 'none'
    h1.style.fontSize = '18px'
    h1.style.fontFamily = "Lato,Segoe UI,Helvetica Neue,Helvetica,Roboto,Oxygen,Ubuntu,Cantarell,Fira Sans,Droid Sans,sans-serif"
    h1.style.fontWeight = '400'

    host.appendChild(h1);

    /* ---------------------------------------------------------------
       2. Indicators  (first = blue, rest = secondary)
       --------------------------------------------------------------- */
    const DM = window.getComputedStyle(document.body).color.match(/\d+/g)[0] > 128;

    list.forEach(([postId, unixTimestamp], idx) => {
      const a = document.createElement('a');

    const formattedDate = formatDateTime(unixTimestamp);
    const relativeTime = getRelativeTime(unixTimestamp);


      a.href        = `${baseUrl}posts/${postId}`;
      a.textContent = `#${postId} - ${formattedDate} (${relativeTime})`;

        a.classList.add('button', 'u-fullWidth', 'u-mbSm', 'button--link');
        a.style.paddingTop = '1px';
        a.style.paddingBottom = '3px';
        a.style.justifyContent = 'left';
        // a.style.background = 'none'
        a.style.backgroundColor = DM ? '#272727' : '#fefefe';   // 'rgb(242, 242, 242)'
        a.style.border = 'solid 1px'
        a.style.marginBottom = '1px'

        if (idx === 0) {
            // a.classList.add('button--cta');   // call-to-action = always pops
            a.style.paddingTop = '10px';
            a.style.paddingBottom = '10px';
            a.style.textDecoration = 'underline';
            a.style.fontWeight = 'bold';
            a.style.color = '#9c46c4'
        } else {
            a.style.color = 'rgb(148, 116, 163)'
        }

      host.appendChild(a);
    });
}

function formatDateTime(unixTimestamp) {
    const date = new Date(unixTimestamp * 1000);
    const year = date.getFullYear();
    const month = (date.getMonth() + 1).toString().padStart(2, '0');
    const day = date.getDate().toString().padStart(2, '0');
    const hours = date.getHours().toString().padStart(2, '0');
    const minutes = date.getMinutes().toString().padStart(2, '0');
    const seconds = date.getSeconds().toString().padStart(2, '0');

    return `${year}/${month}/${day}`; // ${hours}:${minutes}:${seconds}
}

function getRelativeTime(unixTimestamp) {
    const now = new Date();
    const date = new Date(unixTimestamp * 1000);
    const diffInSeconds = Math.floor((now - date) / 1000);

    if (diffInSeconds < 60) return 'just now';

    const minutes = Math.floor(diffInSeconds / 60);
    if (minutes < 60) return `${minutes} minute${minutes !== 1 ? 's' : ''} ago`;

    const hours = Math.floor(diffInSeconds / 3600);
    if (hours < 24) return `${hours} hour${hours !== 1 ? 's' : ''} ago`;

    const days = Math.floor(diffInSeconds / 86400);
    if (days < 30) return `${days} day${days !== 1 ? 's' : ''} ago`;

    const months = Math.floor(days / 30);
    const remainingDays = days % 30;

    if (remainingDays === 0) {
        return `${months} month${months !== 1 ? 's' : ''} ago`;
    }
    return `${months} month${months !== 1 ? 's' : ''} ${remainingDays} day${remainingDays !== 1 ? 's' : ''} ago`;
}


function getContainer() {
    let host = document.getElementById('bookmark-indicators');
    if (!host) {
        host = document.createElement('div');
        host.id = 'bookmark-indicators';
        host.style.display = 'flex';
        host.style.flexDirection = 'column';

        // insert right above the XenForo .p-body-main (or fall back to body)
        const bodyMain = document.querySelector('.p-body-main');
        (bodyMain?.parentElement).insertBefore(host, bodyMain || document.body.firstChild);
    }
    return host;
}

function applyBorderClassOnStart() {
    let articles = document.querySelectorAll('article.message');
    for (let a of articles) {
        let isBookmarked = !!a.querySelector('.bookmarkLink.is-bookmarked');
        if (isBookmarked) {
            a.classList.add('hasBookmark')
        }
    }
}

const bookmarkObservers = new Map();
function onBookmarkChange(postId, removed, method) {
    //console.log(removed ? 'REMOVED' : 'ADDED', postId, method, list);

    /* shut down any observer watching this post */
    bookmarkObservers.get(postId)?.disconnect()

    /* find the post */
    const post = document.querySelector(`#js-post-${postId}`);
    if (!post) { alert('Bookmark error: post with id ' + postId + 'not found.');  return; }

    const observer = new MutationObserver(records => {
      for (const r of records) {
        // console.log(r)
        let el = r.target;
        if (el.nodeType === 1 && el.classList.contains('bookmarkLink')) {
          const isBookmarked = el.classList.contains('is-bookmarked')

          const article = el.closest('article.message');
          article?.classList.toggle('hasBookmark', isBookmarked)

          /* ---- commit change based on `isBookmarked` instead of `removed` */
          const db   = loadDB();
          let   list = db[THREAD_ID] || [];

          if (!isBookmarked) { // ---- Remove entry
              list = list.filter(([id]) => id != postId); // loose !=, also removes accidental strings
          }
          else { // ---- Deduplicate / overwrite
              const unixNow = Math.floor(Date.now() / 1000);
              const idx = list.findIndex(([id]) => id === postId);
              if (idx !== -1) {
                  list[idx] = [postId, unixNow]; // entry exists -> update date
              } else {
                  list.push([postId, unixNow]);  // brand-new entry
              }
          }
          list.sort((a, b) => Number(b[0]) - Number(a[0]));

          if (list.length) { db[THREAD_ID] = list; }
          else             { delete db[THREAD_ID]; }
          saveDB(db);

          rebuildIndicators();
          /* ---- */

          observer.disconnect();
          bookmarkObservers.delete(postId);
          return;
        }
      }
    })
    bookmarkObservers.set(postId, observer)
    observer.observe(post, { childList: true, subtree: true, attributeFilter: ['class'] });
}

function setupXHRListener() {
    const NativeXHR = window.XMLHttpRequest;

    /* move the hooks to the prototype */
    const _open = NativeXHR.prototype.open;
    NativeXHR.prototype.open = function (m, u) {
        // console.log(m, u);
        this._method = m;              // remember for send()
        this._url    = u;
        return _open.apply(this, arguments);
    };

    const _send = NativeXHR.prototype.send; /* intercept send() so we can look at the body */
    NativeXHR.prototype.send = function (body) {
        try {
            // console.log(this._method, this._url);
            if (this._method === 'POST') {
                const postid = this._url.match(/\/posts\/(\d+)\/bookmark/)?.[1];
                if (postid) {
                    let removed = body && body instanceof FormData && body.has('delete');
                    onBookmarkChange(parseInt(postid, 10), removed, 'xhr');
                }
            }
        } catch (err) {
            alert('XHR listener error:' + err);
        }
        return _send.apply(this, arguments);
    };
}


function setupFetchListener() {
    const native = unsafeWindow.fetch;
    unsafeWindow.fetch = function (...a) {
        const r     = a[0] instanceof Request ? a[0] : null;
        const init  = a[1] || {};
        const url   = r ? r.url : String(a[0]);

        const postid = url.match(/\/posts\/(\d+)\/bookmark/)?.[1];
        if (postid) {
          (async () => {
            let removed = false;
            let isMultipart = r && r.headers.get('content-type')?.startsWith('multipart/form-data');
            if (isMultipart) {
                removed = (await r.clone().text()).includes('name="delete"');
            }
                onBookmarkChange(parseInt(postid, 10), removed, 'fetch');
          })();
        }
        return native.apply(this, a);
    };
}

// ==================================================== ACCOUNT
if (location.pathname.includes("/account")) {
    let sidebarBookmarks = document.querySelector('.blockLink[href$="/account/bookmarks"]')
    sidebarBookmarks.insertAdjacentHTML('beforeend','<span style="float:right">🔖</span>');

    // let sidebarLikes = document.querySelector('.blockLink').cloneNode(false);
    // sidebarLikes.textContent = 'Likes';
    // sidebarLikes.href += '#latest-activity';
    // sidebarLikes.insertAdjacentHTML('beforeend', '<span style="float:right">👍</span>');
    // sidebarBookmarks.after(sidebarLikes);
}


// ==================================================== BOOKMARKS
(async () => {

// Knowable: - threadmark count + page-ratio ~~ new chapters; is-last-threadmark
// TODO then resolving skip existing DB

function tempExists(val) { return localStorage.getItem('_temp_scrape') !== null; }
function saveTemp(val) { localStorage.setItem('_temp_scrape', JSON.stringify(val)); }
function loadTemp()    { return JSON.parse(localStorage.getItem('_temp_scrape') || 'null'); }
function removeTemp()  { localStorage.removeItem('_temp_scrape'); }

// ------------------- BOOKMARKS -------------------
let DELAY = 1_500;
async function sleep(ms){ return new Promise(r => setTimeout(r, ms)); }
async function resolveRedirect (url) {
  let res = await fetch(url, { method: 'HEAD' });
  if (res.status === 429) { // Too Many Requests
    drawText(`Too Many Request: Retrying in 2min... do not interrupt`)
    const wait = (+res.headers.get('retry-after') || 120) * 1000;
    DELAY += 1000;
    await sleep(wait);
    return resolveRedirect(url); // retry
  }
  return res.url;
}

if (location.pathname.includes("/bookmarks")) {
    // --------------- Start Button ---------------
    document.querySelector('.filterBar').insertAdjacentHTML('afterbegin', '<a id="bbtn" class="button" style="padding: 1px 5px; color: #eee; background-color: #f98a28">Fetch bookmarks</a>');
    const BTN = document.getElementById("bbtn");
    function drawText(t) { BTN.textContent = t; }
    BTN.onclick = () => {
        saveTemp([]);
        location.href = baseUrl+"account/bookmarks?page=1";
    }

    // ---------------  Auto Scrape ---------------
    let current = location.search.match(/[\?&]page=(\d+)/)?.[1];
    if (!current) {
        removeTemp(); // Delete the key on main page to disable scraping unless the button was pressed to access ?page=1.
        return;
    }
    if (!tempExists()) { // page 1+ page accessed without clicking on the link.
        console.log('temp doesnt exsit')
        return;
    }

    let next = document.querySelector('.pageNav-jump--next');
    BTN.onclick = null;

    let lastCount = [...document.querySelectorAll('.pageNav-page')].pop()?.textContent || 1;
    drawText(`Loading ${current} of ${lastCount}...`);

    let temp = loadTemp();
    let bookmarks = document.querySelectorAll('.p-body-pageContent .block-row');
    for (let [i, b] of [...bookmarks].entries()) {
        let author = b.querySelector('.username').textContent;
        // 2 types of title: Post in thread '...', Thread '..'
        let rawTitle = b.querySelector('.contentRow-title a').textContent;
        let isThread = rawTitle.startsWith('Thread');
        let title    = rawTitle.match(/^(?:Post in thread|Thread) '(.*)'$/)[1]
        let url = b.querySelector('.contentRow-title a').href;
        let postid = isThread ? 0 : parseInt(url.match(/\/posts\/(\d+)/)[1], 10);
        let date = parseInt(b.querySelector('time').getAttribute('data-time'), 10);

        let entry = {author, title, isThread, url, postid, date};
        temp.push(entry);
        console.log(entry);
    }
    saveTemp(temp);

    if (next) {
        await sleep(DELAY);
        next.click(); return;
    }

    // ---------------  Last Step - Resolve URLs ---------------
    temp = loadTemp();
    let DB = loadDB();
    let final = {};

    // Group and deduplicate
    const grouped = {};
    for (const e of temp) {     // 1. group entries by [author++id] in a Map[postid] to deudplicate repeated entries
      const key = `${e.author}::${e.title}`;
      if (!grouped[key]) grouped[key] = new Map();
      grouped[key].set(e.postid, e);
    }
    for (const key in grouped) { // 2. turn every bucket's Map into an array
      grouped[key] = [...grouped[key].values()];
    }
    for (const key in grouped) { // 3. sort each array by postid decreasing
      grouped[key].sort((a, b) => b.postid - a.postid);
    }

    // Build final
    let i = 0;
    let N = Object.keys(grouped).length;
    for (const arr of Object.values(grouped)) {
        drawText(`Processing ${i} of ${N}... do not interrupt`);
        let first = arr[0];
        let last  = arr[arr.length-1];

        // Check if we dont already have this postid in the DB
        let threadIdFromDB;
        for (const [tid, tarray] of Object.entries(DB)) {
          for (const [pid, _] of tarray) {
            if (first.postid === pid) {
              threadIdFromDB = tid;
              console.log("From DB:", threadIdFromDB)
              break;
            }
          }
        }

        let threadUrl;
        if (!threadIdFromDB) {
            if (last.isThread) { // If there was a thread link it will be last/postid=0. It can tell us the thread directly.
                threadUrl = last.url;
                console.log("Is Thread:", first.url, threadUrl)
            }
            else {
                // resolve the first postid to know the page
                drawText(`Resolving ${i} of ${N}... do not interrupt` );
                const start = Date.now();
                threadUrl = await resolveRedirect(first.url);
                console.log("Resolved:", first.url, threadUrl)
                const elapsed = Date.now() - start;
                await sleep(Math.max(0, DELAY - elapsed));
            }
        }

        let threadid = threadIdFromDB ? threadIdFromDB
                     : threadUrl.match(/\/threads\/[^\/]*?\.(\d+)\//)[1]

        final[threadid] = arr.map(({postid, date}) => [postid, date]);
        i++;
    }

    console.log(final)
    saveDB(final);
    console.log(localStorage.getItem(DB_KEY))
    drawText(`Done. All bookmarks saved.`);
    removeTemp();

    return;
}

})();
// // ==================================================== LIKES
// if (location.pathname.includes("/members/")) {
//     if (location.hash === "#latest-activity") {
//         localStorage.removeItem('_scrape');
//     }
//     if (location.pathname.endsWith("/latest-activity")) {

//     }
// }