TorrentBD Activities and Breakdown Page Sorter

clickable column headers to sort torrent data by name, size, seedtime, seedbonus, and ratio on TorrentBD's seedbonus and activities pages.

// ==UserScript==
// @name         TorrentBD Activities and Breakdown Page Sorter
// @namespace    ThermaL -userscripts
// @version      1.2
// @description  clickable column headers to sort torrent data by name, size, seedtime, seedbonus, and ratio on TorrentBD's seedbonus and activities pages.
// @author       ThermaL
// @match        https://www.torrentbd.net/seedbonus-breakdown.php*
// @match        https://www.torrentbd.com/seedbonus-breakdown.php*
// @match        https://www.torrentbd.me/seedbonus-breakdown.php*
// @match        https://www.torrentbd.org/seedbonus-breakdown.php*
// @match        https://www.torrentbd.net/activities.php*
// @match        https://www.torrentbd.com/activities.php*
// @match        https://www.torrentbd.me/activities.php*
// @match        https://www.torrentbd.org/activities.php*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=torrentbd.net
// @grant        GM_addStyle
// @grant        GM_log
// ==/UserScript==

(function() {
    'use strict';

    // Add CSS styles that match TorrentBD's theme
    GM_addStyle(`
        th.sortable {
            cursor: pointer;
            position: relative;
            padding-right: 20px !important;
            user-select: none;
            color: #fff;  /* Always white */
        }

        th.sortable:hover {
            background-color: #222;
        }

        th.sortable .sort-icon {
            position: absolute;
            right: 5px;
            opacity: 0.5;
            font-size: 12px;
            color: #fff;  /* Always white */
        }

        th.sortable.sort-active .sort-icon {
            opacity: 1;
            color: #fff;  /* Keep white when active */
        }

        th.sortable.sort-active {
            color: #fff;  /* Keep white when active */
        }

        /* Add specific styles for ascending/descending states */
        th.sortable.sort-desc .sort-icon,
        th.sortable.sort-asc .sort-icon {
            opacity: 1;
            color: #fff;  /* Keep white when sorting */
        }

        th.sortable.sort-desc,
        th.sortable.sort-asc {
            color: #fff;  /* Keep white when sorting */
        }

        /* Add hover effect for better visual feedback */
        th.sortable:hover .sort-icon {
            opacity: 0.8;
        }

        /* Loading spinner styles */
        #sort-spinner {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            z-index: 9999;
            background-color: rgba(0, 0, 0, 0.8);
            border-radius: 8px;
            padding: 20px;
            display: none;
            flex-direction: column;
            align-items: center;
            box-shadow: 0 0 10px rgba(0, 0, 0, 0.5);
        }

        .spinner {
            border: 4px solid rgba(75, 75, 75, 0.3);
            border-radius: 50%;
            border-top: 4px solid #3a7ca5;
            width: 40px;
            height: 40px;
            animation: spin 1s linear infinite;
            margin-bottom: 10px;
        }

        @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
        }

        .spinner-text {
            color: #ccc;
            font-weight: bold;
        }

        /* Header spinner */
        .header-spinner {
            position: absolute;
            right: 5px;
            width: 16px;
            height: 16px;
            border: 2px solid #3a7ca5;
            border-top-color: transparent;
            border-radius: 50%;
            animation: header-spin 0.8s linear infinite;
            display: none;
        }

        @keyframes header-spin {
            to { transform: rotate(360deg); }
        }

        th.sortable.sorting .header-spinner {
            display: block;
        }

        th.sortable.sorting .sort-icon {
            display: none;
        }
    `);

    // Add logging utility
    function log(message, level = 'info') {
        const prefix = `[TorrentSort] ${level.toUpperCase()}: `;
        if (typeof GM_log !== 'undefined') {
            GM_log(prefix + message);
        } else {
            console.log(prefix + message);
        }
    }

    // Determine page type
    function getPageType() {
        if (window.location.href.includes('seedbonus-breakdown.php')) {
            return 'seedbonus';
        } else if (window.location.href.includes('activities.php')) {
            return 'activities';
        }
        return null;
    }

    // Extract torrent name
    function extractTorrentName(row) {
        try {
            const link = row.cells[0]?.querySelector('a');
            if (!link) return '';

            // Get the text content and normalize it
            let name = link.textContent.trim();

            // Convert Bangla numerals to ASCII numerals
            name = name.replace(/[০-৯]/g, function(d) {
                return String.fromCharCode(d.charCodeAt(0) - 2534);
            });

            // Normalize the string for better sorting
            return name.normalize('NFKC').toLowerCase();
        } catch (error) {
            log(`Error extracting torrent name: ${error.message}`, 'error');
            return '';
        }
    }

    function convertToBytes(sizeText) {
        try {
            if (!sizeText || sizeText === '-' || sizeText === '0') return 0;

            // Normalize the input
            const normalizedText = sizeText.replace(/,/g, '').trim();

            // Extract numeric part and unit
            const match = normalizedText.match(/^([\d.]+)\s*([A-Za-z]+)$/i);
            if (!match) {
                log(`Unable to parse size format: ${sizeText}`, 'warn');
                return 0;
            }

            const [, value, unit] = match;
            const numericValue = parseFloat(value);

            if (isNaN(numericValue)) {
                log(`Invalid numeric value: ${value}`, 'warn');
                return 0;
            }

            // Define unit multipliers (both binary and decimal)
            const units = {
                'B': 1,
                'KB': 1000,
                'MB': 1000 * 1000,
                'GB': 1000 * 1000 * 1000,
                'TB': 1000 * 1000 * 1000 * 1000,
                'KIB': 1024,
                'MIB': 1024 * 1024,
                'GIB': 1024 * 1024 * 1024,
                'TIB': 1024 * 1024 * 1024 * 1024
            };

            // Normalize unit to uppercase for comparison
            const normalizedUnit = unit.toUpperCase();
            const multiplier = units[normalizedUnit];

            if (multiplier === undefined) {
                log(`Unknown unit: ${unit}, assuming bytes`, 'warn');
                return numericValue;
            }

            return numericValue * multiplier;
        } catch (error) {
            log(`Error converting size: ${error.message}`, 'error');
            return 0;
        }
    }

    // Extract size in bytes
    function extractSize(row) {
        try {
            const sizeText = row.cells[1]?.textContent.trim() || '';
            if (!sizeText) {
                log(`Empty size text in cell`, 'warn');
                return 0;
            }
            return convertToBytes(sizeText);
        } catch (error) {
            log(`Error extracting size: ${error.message}`, 'error');
            return 0;
        }
    }

    function convertSeedtimeToDays(seedtimeText) {
        try {
            // Handle null, undefined, or empty input
            if (!seedtimeText || typeof seedtimeText !== 'string') {
                console.warn('Invalid seedtime input:', seedtimeText);
                return 0;
            }

            // Handle special cases
            if (seedtimeText === '-' || seedtimeText === '0' || seedtimeText === 'N/A' ||
                seedtimeText.toLowerCase() === 'none' || seedtimeText.toLowerCase() === 'unknown') {
                return 0;
            }

            // Normalize the input - handle various formats
            let normalized = seedtimeText.toLowerCase()
                .replace(/\s+/g, '')  // Remove all whitespace
                .replace(/,/g, '')    // Remove commas
                .replace(/\./g, '')   // Remove periods
                .trim();

            // Handle Bangla numerals
            normalized = normalized.replace(/[০-৯]/g, function(d) {
                return String.fromCharCode(d.charCodeAt(0) - 2534);
            });

            let totalDays = 0;

            // Handle combined formats (e.g., "1y2mo3w4d5h6m7s")
            const combinedMatch = normalized.match(/(\d+)(y|mo|w|d|h|m|s)/g);
            if (combinedMatch) {
                for (const match of combinedMatch) {
                    const value = parseInt(match.match(/\d+/)[0]);
                    const unit = match.match(/[a-z]+/)[0];

                    switch (unit) {
                        case 'y':
                            totalDays += value * 365;
                            break;
                        case 'mo':
                            totalDays += value * 30;
                            break;
                        case 'w':
                            totalDays += value * 7;
                            break;
                        case 'd':
                            totalDays += value;
                            break;
                        case 'h':
                            totalDays += value / 24;
                            break;
                        case 'm':
                            totalDays += value / (24 * 60);
                            break;
                        case 's':
                            totalDays += value / (24 * 60 * 60);
                            break;
                    }
                }
            }

            // Handle individual units (for backward compatibility)
            if (totalDays === 0) {
                // Handle months (must be checked before minutes)
                const monthsMatch = normalized.match(/(\d+)mo/);
                if (monthsMatch) {
                    const value = parseInt(monthsMatch[1]);
                    if (!isNaN(value)) totalDays += value * 30;
                }

                // Handle years
                const yearsMatch = normalized.match(/(\d+)y/);
                if (yearsMatch) {
                    const value = parseInt(yearsMatch[1]);
                    if (!isNaN(value)) totalDays += value * 365;
                }

                // Handle weeks
                const weeksMatch = normalized.match(/(\d+)w/);
                if (weeksMatch) {
                    const value = parseInt(weeksMatch[1]);
                    if (!isNaN(value)) totalDays += value * 7;
                }

                // Handle days
                const daysMatch = normalized.match(/(\d+)d/);
                if (daysMatch) {
                    const value = parseInt(daysMatch[1]);
                    if (!isNaN(value)) totalDays += value;
                }

                // Handle hours
                const hoursMatch = normalized.match(/(\d+)h/);
                if (hoursMatch) {
                    const value = parseInt(hoursMatch[1]);
                    if (!isNaN(value)) totalDays += value / 24;
                }

                // Handle minutes (must be checked after months)
                const minutesMatch = normalized.match(/(\d+)m(?!o)/);
                if (minutesMatch) {
                    const value = parseInt(minutesMatch[1]);
                    if (!isNaN(value)) totalDays += value / (24 * 60);
                }

                // Handle seconds
                const secondsMatch = normalized.match(/(\d+)s/);
                if (secondsMatch) {
                    const value = parseInt(secondsMatch[1]);
                    if (!isNaN(value)) totalDays += value / (24 * 60 * 60);
                }
            }

            // Handle simple number of days (if no units found)
            if (totalDays === 0) {
                const simpleMatch = normalized.match(/^(\d+)$/);
                if (simpleMatch) {
                    const value = parseInt(simpleMatch[1]);
                    if (!isNaN(value)) totalDays = value;
                }
            }

            // Validate the result
            if (isNaN(totalDays) || !isFinite(totalDays)) {
                console.warn('Invalid seedtime calculation result:', totalDays, 'from input:', seedtimeText);
                return 0;
            }

            return totalDays;
        } catch (error) {
            console.error('Error converting seedtime:', error, 'Input:', seedtimeText);
            return 0;
        }
    }

    // Extract seedtime in days
    function extractSeedtime(row) {
        try {
            if (!row || !row.cells) {
                console.warn('Invalid row structure');
                return 0;
            }

            const pageType = getPageType();
            let seedtimeText;

            if (pageType === 'activities') {
                // For activities page, get the first line of the second column
                const cell = row.cells[1];
                if (!cell) {
                    console.warn('Missing seedtime cell in activities page');
                    return 0;
                }

                // Handle multi-line content and remove any size information
                seedtimeText = cell.textContent
                    .split('\n')[0]  // Get first line
                    .replace(/size:.*$/i, '')  // Remove any size information
                    .trim();
            } else {
                // For seedbonus page, get the third column
                const cell = row.cells[2];
                if (!cell) {
                    console.warn('Missing seedtime cell in seedbonus page');
                    return 0;
                }
                seedtimeText = cell.textContent.trim();
            }

            if (!seedtimeText) {
                console.warn('Empty seedtime text');
                return 0;
            }

            const days = convertSeedtimeToDays(seedtimeText);
            if (days === 0) {
                console.warn('Could not convert seedtime:', seedtimeText);
            }
            return days;
        } catch (error) {
            console.error('Error extracting seedtime:', error);
            return 0;
        }
    }

    // Extract hourly seedbonus
    function extractHourlySeedbonus(row) {
        try {
            const text = row.cells[3]?.textContent.trim() || '';
            if (!text || text === '-' || text === '0') return 0;

            // Handle decimal numbers with optional leading/trailing spaces
            const match = text.match(/^\s*(\d*\.?\d+)\s*$/);
            if (match) {
                const value = parseFloat(match[1]);
                return isNaN(value) ? 0 : value;
            }
            return 0;
        } catch (error) {
            log(`Error extracting hourly seedbonus: ${error.message}`, 'error');
            return 0;
        }
    }

    // Extract uploaded data
    function extractUploaded(row) {
        try {
            const cells = row.cells;
            if (!cells || cells.length < 4) return 0;

            const uploadedText = cells[3].textContent.trim();
            if (!uploadedText || uploadedText === '-' || uploadedText === '0') return 0;

            return convertToBytes(uploadedText);
        } catch (error) {
            log(`Error extracting uploaded: ${error.message}`, 'error');
            return 0;
        }
    }

    // Extract downloaded data
    function extractDownloaded(row) {
        try {
            const cells = row.cells;
            if (!cells || cells.length < 3) return 0;

            const downloadedText = cells[2].textContent.trim();
            if (!downloadedText || downloadedText === '-' || downloadedText === '0') return 0;

            return convertToBytes(downloadedText);
        } catch (error) {
            log(`Error extracting downloaded: ${error.message}`, 'error');
            return 0;
        }
    }

    // Extract ratio
    function extractRatio(row) {
        try {
            const cells = row.cells;
            if (!cells || cells.length < 5) {
                return { value: 0, isInfinity: false };
            }

            const text = cells[4].textContent.trim();
            if (!text || text === '-' || text === '0') {
                return { value: 0, isInfinity: false };
            }

            // Handle infinity cases
            if (text === '∞' || text.toLowerCase().includes('inf')) {
                return { value: Infinity, isInfinity: true };
            }

            // Try to parse the ratio directly
            const ratio = parseFloat(text.replace(/[^0-9.]/g, ''));
            if (!isNaN(ratio)) {
                return { value: ratio, isInfinity: false };
            }

            // If direct parsing fails, calculate from uploaded/downloaded
            const uploaded = extractUploaded(row);
            const downloaded = extractDownloaded(row);

            if (downloaded === 0) {
                if (uploaded > 0) {
                    return { value: Infinity, isInfinity: true };
                }
                return { value: 0, isInfinity: false };
            }

            return { value: uploaded / downloaded, isInfinity: false };
        } catch (error) {
            log(`Error extracting ratio: ${error.message}`, 'error');
            return { value: 0, isInfinity: false };
        }
    }

    function compareRatios(a, b, descending) {
        try {
            const getGroup = (x) => {
                if (x.isInfinity) return 2;
                if (x.value === 0) return 0;
                return 1;
            };

            const groupA = getGroup(a);
            const groupB = getGroup(b);

            if (groupA !== groupB) {
                return descending ? groupB - groupA : groupA - groupB;
            }

            if (groupA === 1) {
                return descending ? b.value - a.value : a.value - b.value;
            }

            return 0;
        } catch (error) {
            log(`Error comparing ratios: ${error.message}`, 'error');
            return 0;
        }
    }

    // Create loading spinner
    function createLoadingSpinner() {
        if (document.getElementById('sort-spinner')) return;

        const spinner = document.createElement('div');
        spinner.id = 'sort-spinner';

        const spinnerCircle = document.createElement('div');
        spinnerCircle.className = 'spinner';
        spinner.appendChild(spinnerCircle);

        const spinnerText = document.createElement('div');
        spinnerText.className = 'spinner-text';
        spinnerText.textContent = 'Sorting...';
        spinner.appendChild(spinnerText);

        document.body.appendChild(spinner);
    }

    function showLoadingSpinner() {
        const spinner = document.getElementById('sort-spinner');
        if (spinner) spinner.style.display = 'flex';
    }

    function hideLoadingSpinner() {
        const spinner = document.getElementById('sort-spinner');
        if (spinner) spinner.style.display = 'none';
    }

    // Sort the table
    function sortTable(table, columnIndex, ascending, headerElement) {
        const pageType = getPageType();
        if (!pageType) return;

        const headerRow = table.querySelector('tr:first-child');
        if (!headerRow) {
            if (headerElement) headerElement.classList.remove('sorting');
            return;
        }

        // Get all rows except headers
        const allRows = Array.from(table.querySelectorAll('tr')).slice(1);
        if (allRows.length === 0) {
            if (headerElement) headerElement.classList.remove('sorting');
            return;
        }

        // Find the summary row by checking for specific patterns
        const summaryRow = allRows.find(row => {
            const cells = Array.from(row.cells);
            if (cells.length < 5) return false;

            // Check for summary row patterns
            const hasSizeLabel = cells[0].textContent.includes('Size:');
            const hasAvgLabel = cells[1].textContent.includes('Avg:');
            const hasDownloadedLabel = cells[2].textContent.includes('Downloaded:');
            const hasUploadedLabel = cells[3].textContent.includes('Uploaded:');
            const hasRatioLabel = cells[4].textContent.includes('Ratio:');

            return hasSizeLabel && hasAvgLabel && hasDownloadedLabel && hasUploadedLabel && hasRatioLabel;
        });

        // Filter out the summary row from the rows to sort
        const dataRows = allRows.filter(row => row !== summaryRow);

        const options = config[pageType].sortOptions[columnIndex];
        if (!options) {
            if (headerElement) headerElement.classList.remove('sorting');
            return;
        }

        // Use setTimeout to ensure the spinner is visible before heavy computation
        setTimeout(() => {
            try {
                // Sort the data rows
                dataRows.sort((a, b) => {
                    const valueA = options.extractor(a);
                    const valueB = options.extractor(b);

                    if (options.customCompare) {
                        return options.customCompare(valueA, valueB, !ascending);
                    }

                    if (options.isNumeric) {
                        return ascending ? valueA - valueB : valueB - valueA;
                    } else if (options.isAlpha) {
                        if (!valueA) return ascending ? -1 : 1;
                        if (!valueB) return ascending ? 1 : -1;
                        const comparison = valueA.localeCompare(valueB);
                        return ascending ? comparison : -comparison;
                    }

                    return 0;
                });

                // Remove all data rows (including summary if it exists)
                allRows.forEach(row => row.remove());

                // Re-insert sorted rows
                dataRows.forEach(row => headerRow.parentNode.appendChild(row));

                // Always add summary row at the end if it exists
                if (summaryRow) {
                    headerRow.parentNode.appendChild(summaryRow);
                }
            } catch (error) {
                console.error('Error during sorting:', error);
            } finally {
                if (headerElement) {
                    headerElement.classList.remove('sorting');
                    // Update sort icon based on final state
                    const sortIcon = headerElement.querySelector('.sort-icon');
                    if (sortIcon) {
                        sortIcon.textContent = ascending ? '▲' : '▼';
                    }
                }
            }
        }, 50);
    }

    // Add sort controls to table headers
    function addSortControls(table) {
        const pageType = getPageType();
        if (!pageType) return;

        const headerRow = table.querySelector('tr:first-child');
        if (!headerRow) return;

        // Clear any existing click handlers
        headerRow.querySelectorAll('th').forEach(th => {
            th.onclick = null;
        });

        headerRow.querySelectorAll('th').forEach((th, index) => {
            const col = config[pageType].sortOptions[index];
            if (!col) return;

            // Remove existing sortable class and icons
            th.classList.remove('sortable', 'sort-active', 'sort-desc', 'sorting');
            const existingIcon = th.querySelector('.sort-icon');
            if (existingIcon) {
                existingIcon.remove();
            }
            const existingSpinner = th.querySelector('.header-spinner');
            if (existingSpinner) {
                existingSpinner.remove();
            }

            th.classList.add('sortable');

            // Add sort icon
            const sortIcon = document.createElement('span');
            sortIcon.className = 'sort-icon';
            sortIcon.textContent = '▼';
            th.appendChild(sortIcon);

            // Add spinner
            const spinner = document.createElement('div');
            spinner.className = 'header-spinner';
            th.appendChild(spinner);

            // Initialize state
            th.dataset.column = index;
            th.dataset.state = 'none';

            // Use addEventListener instead of onclick for better event handling
            th.addEventListener('click', function(e) {
                e.preventDefault();
                e.stopPropagation();

                // Reset all headers first
                headerRow.querySelectorAll('th.sortable').forEach(otherHeader => {
                    if (otherHeader !== this) {
                        otherHeader.querySelector('.sort-icon').textContent = '▼';
                        otherHeader.classList.remove('sort-active', 'sort-desc', 'sorting');
                        otherHeader.dataset.state = 'none';
                    }
                });

                // Show spinner
                this.classList.add('sorting');

                // Simple toggle logic for all columns
                const isAscending = this.dataset.state === 'none' ? false : this.dataset.state !== 'asc';

                // Update the header state
                this.dataset.state = isAscending ? 'asc' : 'desc';
                this.classList.remove('sort-active', 'sort-desc');
                this.classList.add(isAscending ? 'sort-active' : 'sort-desc');

                // Update the sort icon
                const sortIcon = this.querySelector('.sort-icon');
                if (sortIcon) {
                    sortIcon.textContent = isAscending ? '▲' : '▼';
                }

                // Sort the table immediately
                sortTable(table, index, isAscending, this);
            });
        });
    }

    // Find the main table
    function findTable() {
        try {
            const pageType = getPageType();
            if (!pageType) {
                log('Unsupported page type', 'warn');
                return null;
            }

            const tables = document.querySelectorAll('table');
            const requiredHeaders = config[pageType].requiredHeaders;

            for (const table of tables) {
                const headers = Array.from(table.querySelectorAll('th')).map(th =>
                    th.textContent.trim().toLowerCase()
                );

                if (requiredHeaders.every(required =>
                    headers.some(header => header.includes(required))
                )) {
                    log('Found matching table');
                    return table;
                }
            }
            log('No table found matching required headers', 'warn');
            return null;
        } catch (error) {
            log(`Error finding table: ${error.message}`, 'error');
            return null;
        }
    }

    // Configuration for different page types
    const config = {
        seedbonus: {
            requiredHeaders: ['torrent', 'size', 'seedtime', 'hourly seedbonus'],
            sortOptions: {
                0: { label: 'Torrent', extractor: extractTorrentName, isAlpha: true },
                1: { label: 'Size', extractor: extractSize, isNumeric: true },
                2: { label: 'Seedtime', extractor: extractSeedtime, isNumeric: true },
                3: { label: 'Hourly Seedbonus', extractor: extractHourlySeedbonus, isNumeric: true }
            }
        },
        activities: {
            requiredHeaders: ['torrent', 'seedtime', 'downloaded', 'uploaded', 'ratio'],
            sortOptions: {
                0: { label: 'Torrent', extractor: extractTorrentName, isAlpha: true },
                1: { label: 'Seedtime', extractor: extractSeedtime, isNumeric: true, forceFirstClick: true },
                2: { label: 'Downloaded', extractor: extractDownloaded, isNumeric: true },
                3: { label: 'Uploaded', extractor: extractUploaded, isNumeric: true },
                4: { label: 'Ratio', extractor: extractRatio, customCompare: compareRatios }
            }
        }
    };

    // Initialize the script
    function init() {
        const table = findTable();
        if (table) {
            createLoadingSpinner(); // Ensure the spinner is created
            addSortControls(table);
        }
    }

    // Run the script
    init();
})();