// ==UserScript== // @name Sort Reports (no-freeze, resilient UI) — fixed WeakMap // @namespace http://tampermonkey.net/ // @version 4.1 // @description Fast, chunked sorting with robust UI injection // @match https://f95zone.to/reports/queue/* // @icon https://www.google.com/s2/favicons?sz=64&domain=f95zone.to // @grant none // @run-at document-idle // ==/UserScript== (function () { 'use strict'; // Wait for a selector with MutationObserver fallback function waitForElement(selector, root = document, timeoutMs = 15000) { return new Promise((resolve, reject) => { const first = root.querySelector(selector); if (first) return resolve(first); const obs = new MutationObserver(() => { const hit = root.querySelector(selector); if (hit) { obs.disconnect(); resolve(hit); } }); obs.observe(root === document ? document.documentElement : root, { childList: true, subtree: true }); const t = setTimeout(() => { obs.disconnect(); reject(new Error(`Timeout waiting for ${selector}`)); }, timeoutMs); // ensure timer cleared on resolve const originalResolve = resolve; resolve = (v) => { clearTimeout(t); originalResolve(v); }; }); } const rIC = window.requestIdleCallback || function (cb) { return setTimeout(cb, 17); }; function debounce(fn, ms) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), ms); }; } const collator = new Intl.Collator(undefined, { sensitivity: 'base', numeric: true }); // Caches let rowIndex = []; // [{rowEl, i, dateMs, section, prefix}] let rowMap = new WeakMap(); // rowEl -> cached keys function computeKeys(rowEl, i) { const dateEl = rowEl.querySelector('.structItem-latestDate'); const forumEl = rowEl.querySelector('.structItem-forum a'); const titleEl = rowEl.querySelector('.structItem-title'); const dateMs = dateEl ? (parseInt(dateEl.getAttribute('data-time') || '0', 10) * 1000) : 0; const section = forumEl ? forumEl.textContent.trim() : ''; let prefix = ''; if (titleEl) { const t = titleEl.textContent.trim(); const mBracket = t.match(/^\s*\[([^\]]+)\]/); if (mBracket && mBracket[1]) prefix = mBracket[1].trim(); else { const parts = t.split(' - '); prefix = (parts.length > 1 ? parts[0] : t).trim(); } } const obj = { rowEl, i, dateMs, section, prefix }; rowMap.set(rowEl, obj); return obj; } function rebuildIndex(reportTable) { rowIndex = []; rowMap = new WeakMap(); // recreate instead of .clear() const rows = reportTable.querySelectorAll('.structItem'); let i = 0; for (const r of rows) rowIndex.push(computeKeys(r, i++)); } function showBusy(where) { let badge = document.getElementById('sr-busy'); if (!badge) { badge = document.createElement('span'); badge.id = 'sr-busy'; badge.style.cssText = 'margin-left:8px;font-size:12px;opacity:.8;'; where.appendChild(badge); } badge.textContent = 'Sorting…'; } function hideBusy() { const badge = document.getElementById('sr-busy'); if (badge) badge.textContent = ''; } // Stream DOM writes across frames function renderChunked(reportTable, orderedRows) { return new Promise((resolve) => { const total = orderedRows.length; const chunk = Math.max(50, Math.floor(total / 20)); // ~20 frames worst case let idx = 0; reportTable.textContent = ''; function step() { const frag = document.createDocumentFragment(); for (let n = 0; n < chunk && idx < total; n++, idx++) { frag.appendChild(orderedRows[idx]); } reportTable.appendChild(frag); if (idx < total) requestAnimationFrame(step); else resolve(); } requestAnimationFrame(step); }); } // Cancelable sort let sortAbort = { aborted: false }; async function sortAndRender(reportTable, kind, asc) { sortAbort.aborted = true; sortAbort = { aborted: false }; const token = sortAbort; showBusy(document.getElementById('sortType')?.parentElement || reportTable); await new Promise(res => rIC(res, { timeout: 100 })); let cmp; if (kind === 'date') { cmp = (a, b) => (asc ? a.dateMs - b.dateMs : b.dateMs - a.dateMs) || (a.i - b.i); } else if (kind === 'section') { cmp = (a, b) => { const c = asc ? collator.compare(a.section, b.section) : collator.compare(b.section, a.section); return c || (a.i - b.i); }; } else if (kind === 'prefix') { cmp = (a, b) => { const c = asc ? collator.compare(a.prefix, b.prefix) : collator.compare(b.prefix, a.prefix); return c || (a.i - b.i); }; } else return; const items = rowIndex.slice(); items.sort(cmp); if (token.aborted) return; const nodes = items.map(it => it.rowEl); await renderChunked(reportTable, nodes); hideBusy(); } (async function boot() { const reportTable = await waitForElement('.structItemContainer').catch(() => null); if (!reportTable) return; let host = document.querySelector('.block-outer .block-outer-opposite .buttonGroup') || document.querySelector('.block-outer .block-outer-opposite') || document.querySelector('.block-outer') || document.body; const sortWrapper = document.createElement('span'); sortWrapper.style.display = 'inline-flex'; sortWrapper.style.alignItems = 'center'; sortWrapper.style.gap = '6px'; sortWrapper.style.marginLeft = '8px'; sortWrapper.innerHTML = ` `; host.insertBefore(sortWrapper, host.firstChild || null); rebuildIndex(reportTable); const scheduleReindex = debounce(() => rebuildIndex(reportTable), 150); const mo = new MutationObserver(muts => { for (const m of muts) { if (m.addedNodes.length || m.removedNodes.length) { scheduleReindex(); break; } } }); mo.observe(reportTable, { childList: true, subtree: true }); const select = document.getElementById('sortType'); select.addEventListener('change', () => { const v = select.value; if (v === 'dateAsc') sortAndRender(reportTable, 'date', true); else if (v === 'dateDesc') sortAndRender(reportTable, 'date', false); else if (v === 'sectionAsc') sortAndRender(reportTable, 'section', true); else if (v === 'sectionDesc') sortAndRender(reportTable, 'section', false); else if (v === 'prefixAsc') sortAndRender(reportTable, 'prefix', true); else if (v === 'prefixDesc') sortAndRender(reportTable, 'prefix', false); }); rIC(() => sortAndRender(reportTable, 'date', false), { timeout: 200 }); })(); })();