// ==UserScript== // @name Sharty 'log Filter // @namespace https://soyjak.st/ // @version 1.0.0 // @description Filters out everything related to your current filter. You can still view threads if you wish to do so. // @match *://soyjak.st/*/catalog.html* // @match *://*.soyjak.st/*/catalog.html* // @run-at document-end // @grant none // ==/UserScript== (function () { 'use strict'; const grid = document.getElementById('Grid'); if (!grid) return; const STORAGE_KEY = 'postFilter'; const PURGE_INTERVAL = 86400; // same first re-check delay post-filter.js uses const TYPE_LABELS = { name: 'Name', trip: 'Tripcode', sub: 'Subject', com: 'Comment', flag: 'Flag', filename: 'Filename', filehash: 'File hash', }; const pageBoard = (typeof window.board_name === 'string' && window.board_name) || location.pathname.split('/')[1]; const apiData = new Map(); // "board/id" -> post data from catalog.json const domCache = new WeakMap(); let apiFailed = false; /* ---------- storage ---------- */ function now() { return Math.floor(Date.now() / 1000); } function readList() { const raw = localStorage.getItem(STORAGE_KEY); if (raw == null) { return { generalFilter: [], postFilter: {}, nextPurge: {}, lastPurge: now() }; } const list = JSON.parse(raw); list.generalFilter = list.generalFilter || []; list.postFilter = list.postFilter || {}; list.nextPurge = list.nextPurge || {}; return list; } function setManual(mix, hide) { const key = tileKey(mix); if (!key) return; let list; try { list = readList(); } catch (err) { alert('Catalog Filter: localStorage.postFilter could not be read, so nothing was changed.'); return; } const threads = list.postFilter[key.board] || (list.postFilter[key.board] = {}); const purge = list.nextPurge[key.board] || (list.nextPurge[key.board] = {}); const entries = threads[key.id] || (threads[key.id] = []); const i = entries.findIndex(e => e && e.post != null && String(e.post) === key.id); if (hide && i < 0) { entries.push({ post: key.id, hideReplies: false }); purge[key.id] = { timestamp: now(), interval: PURGE_INTERVAL }; } else if (!hide && i >= 0) { entries.splice(i, 1); } if (!entries.length) { delete threads[key.id]; delete purge[key.id]; } if (!Object.keys(threads).length) delete list.postFilter[key.board]; if (!Object.keys(purge).length) delete list.nextPurge[key.board]; localStorage.setItem(STORAGE_KEY, JSON.stringify(list)); refresh(); } /* ---------- thread data ---------- */ function tileKey(mix) { let id = mix.getAttribute('data-id'); if (!id) { const link = mix.querySelector('a[href*="/res/"]'); const m = link && link.getAttribute('href').match(/\/res\/(\d+)/); id = m && m[1]; } if (!id) return null; // the catalog template renders the board as the thumbnail's first class const img = mix.querySelector('img.thread-image'); const first = img && img.classList[0]; const board = first && first !== 'thread-image' ? first : pageBoard; return { board, id: String(id) }; } function bodyText(nodes) { return Array.from(nodes) .map(n => n.textContent) .filter(t => t !== '') .map(t => t.trim()) .join(' '); } function htmlText(html) { return bodyText(new DOMParser().parseFromString(html, 'text/html').body.childNodes); } function domData(mix) { let d = domCache.get(mix); if (d) return d; const img = mix.querySelector('img.thread-image'); const replies = mix.querySelector('.replies'); let com = ''; let sub = img ? img.getAttribute('data-subject') || '' : ''; if (replies) { const kids = Array.from(replies.childNodes); let start = 0; for (let seen = 0; start < kids.length && seen < 2; start++) { if (kids[start].nodeType === 1) seen++; } com = bodyText(kids.slice(start)); if (!sub) { const s = replies.querySelector('.subject'); sub = s ? s.textContent.trim() : ''; } } d = { name: img ? (img.getAttribute('data-name') || '').trim() : null, trip: '', sub, com, flag: '', uid: '', files: [], }; domCache.set(mix, d); return d; } function toHex(value) { if (!value) return ''; if (/^[0-9a-f]+$/i.test(value) && value.length % 2 === 0) return value.toLowerCase(); try { return Array.from(atob(value), c => c.charCodeAt(0).toString(16).padStart(2, '0')).join(''); } catch (err) { return ''; } } function apiPostData(t) { const files = [t].concat(t.extra_files || []) .filter(f => f.filename != null || f.md5 || f.pdq) .map(f => ({ name: f.filename != null ? f.filename + (f.ext || '') : '', hashes: [toHex(f.pdq), toHex(f.md5)].filter(Boolean), })); return { name: t.name != null ? String(t.name).trim() : null, trip: t.trip || '', sub: t.sub || '', com: htmlText(t.com || ''), flag: t.country_name || '', uid: t.id != null ? String(t.id) : '', files, }; } async function loadApi() { const url = location.pathname.replace(/catalog\.html$/, 'catalog.json'); try { const res = await fetch(url, { credentials: 'same-origin', cache: 'no-cache' }); if (!res.ok) throw new Error('HTTP ' + res.status); for (const page of await res.json()) { for (const t of page.threads || []) { apiData.set((t.board || pageBoard) + '/' + t.no, apiPostData(t)); } } } catch (err) { apiFailed = true; console.warn('[Catalog Filter] could not load ' + url + ':', err); } refresh(); } /* ---------- matching (mirrors js/post-filter.js) ---------- */ function safeRegExp(source) { try { return new RegExp(source); } catch (err) { return null; } } function compileRules(list) { return list.generalFilter.filter(Boolean).map(rule => { const value = String(rule.value == null ? '' : rule.value); let test; if (rule.regex) { const re = safeRegExp(value); test = re ? s => re.test(s) : () => false; } else if (rule.type === 'sub' || rule.type === 'com' || rule.type === 'flag') { // post-filter.js wraps plain sub/com/flag rules in word boundaries const re = safeRegExp('\\b' + value + '\\b') || new RegExp('\\b' + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b'); test = s => re.test(s); } else { test = s => s === value; } return { rule, test }; }); } function matches({ rule, test }, d) { switch (rule.type) { case 'name': return d.name != null && test(d.name); case 'trip': return d.trip !== '' && test(d.trip); case 'sub': return d.sub !== '' && test(d.sub); case 'com': return test(d.com); case 'flag': return d.flag !== '' && test(d.flag); case 'filename': return d.files.some(f => f.name !== '' && test(f.name)); case 'filehash': return d.files.some(f => f.hashes.some(test)); } return false; } function shorten(text, max) { return text.length > max ? text.slice(0, max - 1) + '…' : text; } function describe(rule) { const value = String(rule.value); const shown = rule.type === 'filehash' && !rule.regex ? shorten(value, 13) : shorten(value, 40); return (TYPE_LABELS[rule.type] || rule.type) + ': ' + (rule.regex ? '/' + shown + '/' : shown); } function statusOf(mix, list, compiled) { const key = tileKey(mix); if (!key) return null; const d = apiData.get(key.board + '/' + key.id) || domData(mix); const entries = (list.postFilter[key.board] || {})[key.id] || []; const manual = entries.some(e => e && e.post != null && String(e.post) === key.id); const reasons = []; if (manual) reasons.push('Hidden by you'); if (d.uid && entries.some(e => e && e.uid != null && String(e.uid) === d.uid)) { reasons.push('Poster ID: ' + d.uid); } for (const c of compiled) { if (matches(c, d)) reasons.push(describe(c.rule)); } return { manual, reasons }; } /* ---------- UI ---------- */ function el(tag, className, text) { const node = document.createElement(tag); if (className) node.className = className; if (text != null) node.textContent = text; return node; } function action(name, text) { const a = el('a', null, text); a.href = '#'; a.dataset.sjcf = name; return a; } function render(mix, st) { const hidden = !!st && st.reasons.length > 0; const host = mix.querySelector('.thread') || mix; let label = host.querySelector(':scope > .sjcf-label'); mix.classList.toggle('sjcf-hidden', hidden); if (!hidden) { if (label) label.remove(); return; } if (!label) { label = el('div', 'sjcf-label'); host.prepend(label); } label.title = st.manual ? 'Shift+click to unhide' : 'Matches your filters (Options → Filters on a board or thread page)'; label.replaceChildren( el('span', 'sjcf-tag', 'Hidden'), el('span', 'sjcf-why', st.reasons.join(' · ')) ); if (st.manual) label.append(action('unhide', 'unhide')); } function flash(mix) { mix.classList.remove('sjcf-flash'); void mix.offsetWidth; mix.classList.add('sjcf-flash'); setTimeout(() => mix.classList.remove('sjcf-flash'), 700); } let revealed = false; const showButton = el('button'); showButton.type = 'button'; showButton.id = 'sjcf-show'; showButton.addEventListener('click', () => { revealed = !revealed; document.body.classList.toggle('sjcf-reveal', revealed); refresh(); }); const sizeSelect = document.getElementById('image_size'); if (sizeSelect) sizeSelect.after(showButton); else grid.before(showButton); function refresh() { let list; try { list = readList(); } catch (err) { console.warn('[Catalog Filter] localStorage.postFilter is not valid JSON:', err); return; } const compiled = compileRules(list); let hidden = 0; for (const mix of grid.querySelectorAll('.mix')) { const st = statusOf(mix, list, compiled); render(mix, st); if (st && st.reasons.length) hidden++; } showButton.textContent = (revealed ? 'Hide again' : 'Show anyways') + ' (' + hidden + ')'; showButton.disabled = !revealed && hidden === 0; showButton.title = apiFailed ? "catalog.json didn't load, so tripcode, flag, poster ID, filename and file hash filters can't be checked." : ''; } let queued = false; function scheduleRefresh() { if (queued) return; queued = true; setTimeout(() => { queued = false; refresh(); }, 0); } /* ---------- events ---------- */ window.addEventListener('click', e => { const target = e.target instanceof Element ? e.target : null; if (!target) return; const unhide = target.closest('.sjcf-label [data-sjcf="unhide"]'); if (unhide && !e.shiftKey) { e.preventDefault(); e.stopPropagation(); setManual(unhide.closest('.mix'), false); return; } if (!e.shiftKey || e.button !== 0 || e.ctrlKey || e.metaKey || e.altKey) return; const mix = target.closest('.mix'); if (!mix || !grid.contains(mix)) return; e.preventDefault(); e.stopImmediatePropagation(); let st; try { const list = readList(); st = statusOf(mix, list, compileRules(list)); } catch (err) { alert('Catalog Filter: localStorage.postFilter could not be read, so nothing was changed.'); return; } if (!st) return; if (st.manual) setManual(mix, false); else if (st.reasons.length) flash(mix); // hidden by a filter rule; edit that in Options -> Filters else setManual(mix, true); }, true); window.addEventListener('mousedown', e => { if (e.shiftKey && e.button === 0 && e.target instanceof Element && e.target.closest('#Grid .mix')) { e.preventDefault(); } }, true); window.addEventListener('storage', e => { if (e.key === STORAGE_KEY || e.key === null) scheduleRefresh(); }); new MutationObserver(records => { let dirty = false; for (const r of records) { for (const node of r.removedNodes) { if (node.nodeType !== 1 || node.isConnected || !node.classList.contains('sjcf-hidden')) continue; const next = r.nextSibling && r.nextSibling.parentNode === r.target ? r.nextSibling : null; r.target.insertBefore(node, next); dirty = true; } for (const node of r.addedNodes) { if (node.nodeType === 1 && node.classList.contains('mix')) dirty = true; } } if (dirty) scheduleRefresh(); }).observe(grid, { childList: true }); /* ---------- styles ---------- */ const style = el('style', null, ` body:not(.sjcf-reveal) .mix.sjcf-hidden { display: none !important; } .sjcf-label { display: none; } body.sjcf-reveal .mix.sjcf-hidden .sjcf-label { display: flex; flex-wrap: wrap; justify-content: center; gap: 0 6px; margin-bottom: 4px; padding: 2px 4px; border: 1px dashed currentColor; border-radius: 3px; font-size: 11px; line-height: 1.35; } body.sjcf-reveal .mix.sjcf-hidden .thread > :not(.sjcf-label) { opacity: 0.6; } .sjcf-label .sjcf-tag { font-weight: bold; letter-spacing: 0.08em; text-transform: uppercase; } .sjcf-label .sjcf-why { opacity: 0.85; overflow-wrap: anywhere; } .mix.sjcf-flash .sjcf-label { outline: 2px solid currentColor; } #sjcf-show { margin-left: 8px; } `); document.head.append(style); refresh(); loadApi(); })();