// ==UserScript== // @name Soybooru - Pending Approval Fix // @namespace https://soybooru.com/ // @version 2.3.0 // @description Ultimate Pending Approval tab // @author nophono // @match https://soybooru.com/* // @run-at document-start // @grant none // ==/UserScript== (() => {   "use strict";   // --- 1. CONFIGURATION ---   // Simple toggles to enable or disable features   const SWITCHES = {     textExchange: true, // Appends the (Count) to the "Pending Approval" link     headerNotif: true, // Shows the (Count) in the browser tab title     browserNotif: false, // Enables desktop OS notifications     glowLink: true, // Toggles background color changes     glowText: true // Toggles text color changes   };   // Aesthetic colors for the "Glow" states   const COLORS = {     glow1: "#d9f9d9", // Background for new items     text1: "#1b5e20", // Text color for new items     glow2: "#ffd9d9", // Background for disapproved items     text2: "#b71c1c" // Text color for disapproved items   };   // Timing and background behavior settings   const CONFIG = {     ACTIVE_RATE: 2000, // Refresh every 2s when looking at the tab     HIDDEN_RATE: 5000, // Refresh every 5s when tab is in background     JITTER: 500, // Small random delay to prevent robotic request patterns     SYNC_COOLDOWN: 1500, // Minimum time between requests to prevent spamming     NOTIF_COOLDOWN: 60000 // Limit desktop alerts to 1 per minute   };   // --- 2. STATE MANAGEMENT ---   const state = {     // ANTI-FLICKER: Load counts from storage immediately so the count     // doesn't reset to (0) for a split second on page refresh.     u1: parseInt(localStorage.getItem("sb_last_u1") || "0", 10),     n1: parseInt(localStorage.getItem("sb_last_n1") || "0", 10), errorCount: 0, // Tracks 404s to reset stuck counts     lastTabTotal: 0, // Used to trigger notifications only when count increases     // Persistent notification data     notifSentCount: parseInt(localStorage.getItem("sb_notif_count") || "0", 10),     lastNotifTime: parseInt(localStorage.getItem("sb_last_notif_time") || "0", 10),     // USER HISTORY: A Set of post IDs you have physically viewed or clicked.     // The "Glow" only stays active if there's an ID in the pool that ISN'T in this Set.     seenIDs: new Set(JSON.parse(localStorage.getItem("sb_seen_ids") || "[]")),     lastSyncTime: 0,     unapprovedPool: new Set(), // Stores all current pending IDs     disapprovedPool: new Set(), // Stores all current disapproved IDs     path: "/post/list/approved%3Ano/1",     isSyncing: false,     el: null // Cached reference to the navigation element   };   // Capture original title (e.g., "Soybooru") to restore it when count is 0   const baseTitle = document.title.replace(/^\(\d+\)\s*/, "");   // --- 3. UTILITIES ---   // Saves seen history to LocalStorage   const saveSeen = () => {     const list = Array.from(state.seenIDs).slice(-500); // Keep last 500 for performance     localStorage.setItem("sb_seen_ids", JSON.stringify(list));   };   // ANTI-FLICKER: Saves the current counts to storage for the next page load   const saveCounts = () => {     localStorage.setItem("sb_last_u1", state.u1);     localStorage.setItem("sb_last_n1", state.n1);   };   // Handles sending OS notifications   const sendNotification = (title, body) => {     const now = Date.now();     if (!SWITCHES.browserNotif || Notification.permission !== "granted") return;     if (state.lastNotifTime > 0 && (now - state.lastNotifTime < CONFIG.NOTIF_COOLDOWN)) return;     state.notifSentCount++;     state.lastNotifTime = now;     localStorage.setItem("sb_notif_count", state.notifSentCount);     localStorage.setItem("sb_last_notif_time", state.lastNotifTime);     new Notification(`${title} [#${state.notifSentCount}]`, {         body,         icon: 'https://soybooru.com/favicon.ico'     });   };   // --- 4. UI ENGINE ---   // Injects the CSS classes for the glow effects   function applyStyles() {     if (document.getElementById('sb-style')) return;     const s = document.createElement('style');     s.id = 'sb-style';     s.textContent = `       a[href*="approved"] { transition: background 0.1s ease, color 0.1s ease; }       .sb-u { ${SWITCHES.glowLink ? `background:${COLORS.glow1}!important;`:''} ${SWITCHES.glowText ? `color:${COLORS.text1}!important;`:''} border-radius:3px; padding:0 2px; }       .sb-d { ${SWITCHES.glowLink ? `background:${COLORS.glow2}!important;`:''} ${SWITCHES.glowText ? `color:${COLORS.text2}!important;`:''} border-radius:3px; padding:0 2px; }     `;     document.head.appendChild(s);   }   // Updates the DOM elements (Link text, Glow, Tab title)   function updateUI() {     const el = state.el && document.contains(state.el) ? state.el : document.querySelector('a[href*="approved:no"], a[href*="approved%3Ano"]');     if (!el) return;     state.el = el;     if (!el.dataset.orig) el.dataset.orig = "Pending Approval";     // SECURITY MEASURE: Ensure we never display negative numbers or NaN     const safeU1 = Math.max(0, state.u1 || 0);     const safeN1 = Math.max(0, state.n1 || 0);     const currentTotal = safeU1 + safeN1;     // Trigger notification if total increased     if (currentTotal > state.lastTabTotal && currentTotal > 0) {         // SMART NOTIFICATION BODY: Only shows counts that are greater than zero         let details = [];         if (safeU1 > 0) details.push(`${safeU1} Unapproved`);         if (safeN1 > 0) details.push(`${safeN1} Disapproved`);         const bodyText = `${currentTotal} items pending: ${details.join(", ")}`;         sendNotification(`Soybooru Update`, bodyText);     }     state.lastTabTotal = currentTotal;     // GLOW LOGIC: If background pool contains an ID not in seenIDs, activate glow.     let hasUnseen = false;     for (let id of state.unapprovedPool) {         if (!state.seenIDs.has(id)) {             hasUnseen = true;             break;         }     }     // Assign visual state priority: Disapproved (Red) > Unseen (Green) > Normal     let targetClass = '';     if (safeN1 > 0) targetClass = 'sb-d';     else if (hasUnseen) targetClass = 'sb-u';     // Build the string: "Pending Approval (5)"     const newText = SWITCHES.textExchange ? `${el.dataset.orig} (${currentTotal})` : el.dataset.orig;     // Batch UI updates into a single frame for performance     requestAnimationFrame(() => {         if (el.textContent !== newText) el.textContent = newText;         if (targetClass === 'sb-d' && !el.classList.contains('sb-d')) {             el.classList.add('sb-d'); el.classList.remove('sb-u');         } else if (targetClass === 'sb-u' && !el.classList.contains('sb-u')) {             el.classList.add('sb-u'); el.classList.remove('sb-d');         } else if (targetClass === '') {             el.classList.remove('sb-u', 'sb-d');         }         if (SWITCHES.headerNotif && !window.location.pathname.includes("approved")) {             const newTitle = (currentTotal > 0) ? `(${currentTotal}) ${baseTitle}` : baseTitle;             if (document.title !== newTitle) document.title = newTitle;         }     });   }   // --- 5. DATA SYNCHRONIZATION ---   const sync = async (force = false) => {     const now = Date.now();     // Throttle syncs to prevent spamming the Booru server     if (state.isSyncing || (!force && (now - state.lastSyncTime < CONFIG.SYNC_COOLDOWN))) return;     state.lastSyncTime = now;     // AIR-GAP: If user is actively viewing a post, mark it as seen immediately     const currentID = window.location.pathname.match(/\/post\/view\/(\d+)/)?.[1];     if (currentID && !state.seenIDs.has(currentID)) {         state.seenIDs.add(currentID);         saveSeen();     }     state.isSyncing = true;     try {       // Fetch the pending list as text in the background       const res = await fetch(`${state.path}?v=${now}`, { cache: "no-store", redirect: "follow" });       if (res.ok) { state.errorCount = 0; // Success, reset recovery tracker         const text = await res.text();         const nextUnapproved = new Set();         const nextDisapproved = new Set();         // Regex scanning for post IDs in the raw HTML         if (res.url.includes("/post/view/")) {             const m = res.url.match(/\/post\/view\/(\d+)/);             if (m) text.toLowerCase().includes("disapproved") ? nextDisapproved.add(m[1]) : nextUnapproved.add(m[1]);         } else {             [...text.matchAll(/data-post-id="(\d+)"/g)].forEach(m => nextUnapproved.add(m[1]));             [...text.matchAll(/class="[^"]*disapproved[^"]*".*?data-post-id="(\d+)"/g)].forEach(m => nextDisapproved.add(m[1]));         }         // SWAP STATE: Update background counts and pools         state.unapprovedPool = nextUnapproved;         state.disapprovedPool = nextDisapproved;         state.u1 = nextUnapproved.size;         state.n1 = nextDisapproved.size;         saveCounts(); // Persist for next refresh         updateUI();       } else if (res.status === 404) { // 404 Recovery: Clear counts if the search page vanishes (queue empty) state.errorCount++; if (state.errorCount >= 2) { state.u1 = 0; state.n1 = 0; saveCounts(); updateUI(); } }     } catch (e) {}     state.isSyncing = false;     // Set timer for the next check     setTimeout(sync, (document.hidden ? CONFIG.HIDDEN_RATE : CONFIG.ACTIVE_RATE) + (Math.random() * CONFIG.JITTER));   };   // --- 6. INITIALIZATION ---   const start = () => {     applyStyles(); // EXPOSE DEBUG TOOL: Type 'sbDebug()' in browser console to test UI window.sbDebug = (u, d) => { state.u1 = u; state.n1 = d; console.log(`Debug: Set Unapproved to ${u}, Disapproved to ${d}`); updateUI(); };     // Initial run happens immediately using persisted counts from storage     updateUI();     if (SWITCHES.browserNotif && Notification.permission === "default") {         Notification.requestPermission();     }     // Global Click Listener for interaction-based clearing     document.addEventListener('click', e => {         // Trigger 1: Clicking the main "Pending" link clears all current IDs         const pendingLink = e.target.closest('a[href*="approved:no"], a[href*="approved%3Ano"]');         if (pendingLink) {             state.unapprovedPool.forEach(id => state.seenIDs.add(id));             saveSeen();             updateUI();         }         // Trigger 2: Clicking a specific post link marks only that ID as seen         const postLink = e.target.closest('a[href*="/post/view/"]');         if (postLink) {             const id = postLink.href.match(/\/post\/view\/(\d+)/)?.[1];             if (id && !state.seenIDs.has(id)) {                 state.seenIDs.add(id);                 saveSeen();                 updateUI();             }         }         // Trigger 3: Clicking "Approve" button forces a sync shortly after         if (e.target.value === "Approve" || e.target.value === "Disapprove") {             setTimeout(() => sync(true), 1500);         }     }, true);     // Sync whenever the user focuses back on the tab     window.addEventListener('focus', () => sync(true));     sync(); // Start the sync loop   };   // Run start logic as soon as DOM is interactive   if (document.readyState === 'loading') {       document.addEventListener('DOMContentLoaded', start);   } else {       start();   } })();