// ==UserScript== // @name UNIT3D Table Sorter // @namespace https://github.com/flowerey/unit3d-scripts // @version 1.3.2 // @description Sort torrent tables by size, age, seeders, and more. // @author blueberry // @match https://*/torrents // @match https://*/torrents/* // @downloadURL https://raw.githubusercontent.com/flowerey/unit3d-scripts/main/table-sorting.user.js // @updateURL https://raw.githubusercontent.com/flowerey/unit3d-scripts/main/table-sorting.user.js.meta.js // @grant none // @run-at document-end // ==/UserScript== (function () { 'use strict'; const SORT_STORAGE_KEY = 'unit3d-sort-prefs'; const Utils = { parseSize(text) { const units = { 'B': 1, 'KB': 1e3, 'MB': 1e6, 'GB': 1e9, 'TB': 1e12, 'KiB': 1024, 'MiB': 1048576, 'GiB': 1073741824, 'TiB': 1099511627776 }; const match = text.match(/([\d.]+)\s*([A-Za-z]+)/); if (!match) return 0; const [_, val, unit] = match; return parseFloat(val) * (units[unit] || 1); }, parseNumber(text) { return parseInt(text.replace(/[^0-9]/g, '')) || 0; }, parseDate(el) { const time = el.querySelector('time'); return time ? new Date(time.getAttribute('datetime')).getTime() : 0; } }; class TableSorter { constructor(table, prefs) { this.table = table; this.tbody = table.querySelector('tbody'); this.headers = Array.from(table.querySelectorAll('thead th')); this.currentSort = { index: -1, desc: true }; this.secondarySort = { index: -1, desc: true }; this.prefs = prefs; this.config = [ { name: 'Type', parser: el => el.textContent.trim(), type: 'string' }, { name: 'Name', parser: el => el.textContent.trim(), type: 'string' }, { name: 'Size', parser: el => Utils.parseSize(el.textContent), type: 'number' }, { name: 'Age', parser: el => Utils.parseDate(el), type: 'number' }, { name: 'Seeders', parser: el => Utils.parseNumber(el.textContent), type: 'number' }, { name: 'Leechers', parser: el => Utils.parseNumber(el.textContent), type: 'number' }, { name: 'Completed', parser: el => Utils.parseNumber(el.textContent), type: 'number' } ]; this.init(); } // UNIT3D v8+ renders the Seeders/Leechers/Completed column headers as icons // only (no visible text). Resolve the column identity from the visible text, // the `title` attribute, or the header class (`torrent-search--list__*-header`). headerName(th) { const text = (th.textContent || '').trim(); if (text) return text; if (th.getAttribute('title')) return th.getAttribute('title').trim(); const cls = Array.from(th.classList).find(c => c.startsWith('torrent-search--list__') && c.endsWith('-header')); if (cls) { const word = cls.replace('torrent-search--list__', '').replace('-header', ''); if (word === 'seeders') return 'Seeders'; if (word === 'leechers') return 'Leechers'; if (word === 'completed') return 'Completed'; if (word === 'name') return 'Name'; if (word === 'size') return 'Size'; if (word === 'age') return 'Age'; return word; } return text; } init() { this.headers.forEach((th, i) => { const name = this.headerName(th); const spec = this.config.find(c => name.includes(c.name)); if (spec) { th.style.cursor = 'pointer'; th.style.userSelect = 'none'; th.title = `Sort by ${spec.name} (Shift+Click for secondary)`; th.addEventListener('click', (e) => this.sort(i, spec, e.shiftKey)); } }); if (this.prefs && this.prefs.index >= 0 && this.prefs.index < this.headers.length) { const spec = this.config.find(c => this.headerName(this.headers[this.prefs.index]).includes(c.name)); if (spec) { this.sort(this.prefs.index, spec, false, this.prefs.desc); } } } sort(index, spec, isSecondary, forceDesc) { const rows = Array.from(this.tbody.querySelectorAll('tr')); if (isSecondary) { const isDesc = forceDesc !== undefined ? forceDesc : (this.secondarySort.index === index ? !this.secondarySort.desc : true); this.secondarySort = { index, desc: isDesc }; this.performSort(rows); this.updateUI(); return; } const isDesc = forceDesc !== undefined ? forceDesc : (this.currentSort.index === index ? !this.currentSort.desc : true); this.currentSort = { index, desc: isDesc }; this.secondarySort = { index: -1, desc: true }; this.performSort(rows); this.updateUI(); this.savePrefs(index, isDesc); } performSort(rows) { const primary = this.currentSort; const secondary = this.secondarySort; rows.sort((a, b) => { let result = this.compareRows(a, b, primary); if (result === 0 && secondary.index >= 0) { result = this.compareRows(a, b, secondary); } return result; }); rows.forEach(row => this.tbody.appendChild(row)); } compareRows(a, b, sort) { if (sort.index < 0) return 0; const spec = this.config.find(c => this.headerName(this.headers[sort.index]).includes(c.name)); if (!spec) return 0; const cellA = a.children[sort.index]; const cellB = b.children[sort.index]; if (!cellA || !cellB) return 0; const valA = spec.parser(cellA); const valB = spec.parser(cellB); let result; if (spec.type === 'string') { result = valA.localeCompare(valB); } else { result = valA - valB; } return sort.desc ? -result : result; } savePrefs(index, desc) { try { localStorage.setItem(SORT_STORAGE_KEY, JSON.stringify({ index, desc })); } catch (e) { /* ignore */ } } updateUI() { this.headers.forEach((th, i) => { const indicator = th.querySelector('.sort-indicator'); if (indicator) indicator.remove(); if (i === this.currentSort.index) { const span = document.createElement('span'); span.className = 'sort-indicator'; span.textContent = this.currentSort.desc ? ' \u25BC' : ' \u25B2'; span.style.color = '#2ecc71'; th.appendChild(span); } else if (i === this.secondarySort.index) { const span = document.createElement('span'); span.className = 'sort-indicator'; span.textContent = this.secondarySort.desc ? ' \u25B2' : ' \u25BC'; span.style.color = '#888'; span.style.fontSize = '10px'; th.appendChild(span); } }); } } class QuickFilter { constructor() { this.filterInput = null; this.filterBtn = null; this.active = false; } inject() { const panel = document.querySelector('.panel__heading'); if (!panel || document.getElementById('quick-filter-container')) return; const container = document.createElement('div'); container.id = 'quick-filter-container'; Object.assign(container.style, { display: 'inline-flex', alignItems: 'center', gap: '6px', marginLeft: '12px', verticalAlign: 'middle' }); this.filterInput = document.createElement('input'); this.filterInput.type = 'text'; this.filterInput.placeholder = 'Filter torrents...'; this.filterInput.id = 'quick-filter-input'; Object.assign(this.filterInput.style, { padding: '4px 10px', border: '1px solid #444', borderRadius: '4px', backgroundColor: '#1a1a1a', color: '#ddd', fontSize: '11px', width: '180px', outline: 'none', transition: 'border-color 0.2s' }); this.filterInput.onfocus = () => this.filterInput.style.borderColor = '#2ecc71'; this.filterInput.onblur = () => this.filterInput.style.borderColor = '#444'; this.filterBtn = document.createElement('button'); this.filterBtn.innerHTML = ''; this.filterBtn.title = 'Toggle filter'; Object.assign(this.filterBtn.style, { padding: '4px 8px', backgroundColor: '#444', color: '#fff', border: 'none', borderRadius: '4px', fontSize: '11px', cursor: 'pointer', transition: 'background 0.2s' }); this.filterBtn.onmouseover = () => { if (!this.active) this.filterBtn.style.backgroundColor = '#555'; }; this.filterBtn.onmouseout = () => { if (!this.active) this.filterBtn.style.backgroundColor = '#444'; }; this.filterBtn.onclick = () => { this.active = !this.active; if (this.active) { this.filterBtn.style.backgroundColor = '#2ecc71'; this.applyFilter(); } else { this.filterBtn.style.backgroundColor = '#444'; this.clearFilter(); } }; this.filterInput.addEventListener('input', () => { if (this.active) this.applyFilter(); }); const clearBtn = document.createElement('button'); clearBtn.innerHTML = ''; clearBtn.title = 'Clear filter'; Object.assign(clearBtn.style, { padding: '4px 8px', backgroundColor: 'transparent', color: '#888', border: '1px solid #444', borderRadius: '4px', fontSize: '11px', cursor: 'pointer', transition: 'color 0.2s' }); clearBtn.onmouseover = () => clearBtn.style.color = '#e74c3c'; clearBtn.onmouseout = () => clearBtn.style.color = '#888'; clearBtn.onclick = () => { this.filterInput.value = ''; this.active = false; this.filterBtn.style.backgroundColor = '#444'; this.clearFilter(); }; container.appendChild(this.filterInput); container.appendChild(this.filterBtn); container.appendChild(clearBtn); panel.appendChild(container); this.filterInput.addEventListener('keydown', (e) => { if (e.key === 'Escape') { this.filterInput.value = ''; this.active = false; this.filterBtn.style.backgroundColor = '#444'; this.clearFilter(); this.filterInput.blur(); } }); } applyFilter() { const query = this.filterInput.value.toLowerCase().trim(); const tables = document.querySelectorAll(SORTABLE_TABLES); tables.forEach(table => { const rows = table.querySelectorAll('tbody tr'); rows.forEach(row => { const text = row.textContent.toLowerCase(); row.style.display = text.includes(query) ? '' : 'none'; }); }); } clearFilter() { const tables = document.querySelectorAll(SORTABLE_TABLES); tables.forEach(table => { const rows = table.querySelectorAll('tbody tr'); rows.forEach(row => { row.style.display = ''; }); }); } } const SORTABLE_TABLES = [ 'table.data-table', '.similar-torrents__torrents' ].join(', '); let savedPrefs = null; try { savedPrefs = JSON.parse(localStorage.getItem(SORT_STORAGE_KEY)); } catch (e) { /* ignore */ } const filter = new QuickFilter(); const setupUI = () => { const panel = document.querySelector('.panel__heading'); if (!panel || document.getElementById('enable-sort-btn')) return; const btn = document.createElement('button'); btn.id = 'enable-sort-btn'; btn.innerHTML = ' Enable Sorting'; Object.assign(btn.style, { marginLeft: '15px', padding: '4px 12px', backgroundColor: '#2ecc71', color: '#fff', border: 'none', borderRadius: '4px', fontSize: '11px', fontWeight: 'bold', cursor: 'pointer', transition: 'background 0.2s' }); btn.onmouseover = () => btn.style.backgroundColor = '#27ae60'; btn.onmouseout = () => btn.style.backgroundColor = '#2ecc71'; btn.onclick = () => { const tables = document.querySelectorAll(SORTABLE_TABLES); tables.forEach(t => new TableSorter(t, savedPrefs)); btn.remove(); }; panel.appendChild(btn); filter.inject(); }; if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', setupUI); } else { setupUI(); } })();