HTTP Index Sorter

Enhances pre-formatted HTTP directory listings (Nginx, lighttpd, seedbox indexes, etc.) with sortable columns (name, date, size), file type icons, date grouping, and per-site preferences. Configure which sites to activate on via URL patterns.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         HTTP Index Sorter
// @namespace    https://greasyfork.org/en/users/1574063-primetime43
// @version      1.0
// @description  Enhances pre-formatted HTTP directory listings (Nginx, lighttpd, seedbox indexes, etc.) with sortable columns (name, date, size), file type icons, date grouping, and per-site preferences. Configure which sites to activate on via URL patterns.
// @author       primetime43
// @match        *://*/*
// @homepageURL  https://greasyfork.org/en/users/1574063-primetime43
// @supportURL   https://greasyfork.org/en/users/1574063-primetime43
// @license      MIT
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// ==/UserScript==

(function () {
    'use strict';

    // --- File Type Icons ---

    const FILE_ICONS = {
        // Video
        video: { icon: '\uD83C\uDFA5', extensions: ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'mpg', 'mpeg', 'ts'] },
        // Audio
        audio: { icon: '\uD83C\uDFB5', extensions: ['mp3', 'flac', 'wav', 'aac', 'ogg', 'wma', 'm4a', 'opus'] },
        // Images
        image: { icon: '\uD83D\uDDBC\uFE0F', extensions: ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp', 'ico', 'tiff', 'tif'] },
        // Archives
        archive: { icon: '\uD83D\uDCE6', extensions: ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'zst', 'tgz', 'tar.gz'] },
        // Documents
        document: { icon: '\uD83D\uDCC4', extensions: ['pdf', 'doc', 'docx', 'odt', 'rtf', 'txt', 'epub', 'mobi'] },
        // Code
        code: { icon: '\uD83D\uDCDD', extensions: ['js', 'ts', 'py', 'java', 'c', 'cpp', 'h', 'cs', 'go', 'rs', 'rb', 'php', 'html', 'css', 'json', 'xml', 'yaml', 'yml', 'sh', 'bat'] },
        // Disk images
        disk: { icon: '\uD83D\uDCBF', extensions: ['iso', 'img', 'dmg', 'bin', 'cue', 'nrg'] },
        // Executables
        exe: { icon: '\u2699\uFE0F', extensions: ['exe', 'msi', 'apk', 'deb', 'rpm', 'appimage'] },
        // Subtitles
        subtitle: { icon: '\uD83D\uDCAC', extensions: ['srt', 'sub', 'ass', 'ssa', 'vtt'] },
        // Torrent
        torrent: { icon: '\uD83E\uDDF2', extensions: ['torrent'] },
        // NFO
        nfo: { icon: '\u2139\uFE0F', extensions: ['nfo', 'nzb'] },
    };

    const FOLDER_ICON = '\uD83D\uDCC1';
    const DEFAULT_FILE_ICON = '\uD83D\uDCC3';

    function getFileIcon(name) {
        if (name.endsWith('/')) return FOLDER_ICON;
        const lower = name.toLowerCase();
        for (const category of Object.values(FILE_ICONS)) {
            if (category.extensions.some(ext => lower.endsWith('.' + ext))) {
                return category.icon;
            }
        }
        return DEFAULT_FILE_ICON;
    }

    // --- URL Pattern Management ---

    function loadUrls() {
        const stored = GM_getValue('urls', null);
        if (stored === null) {
            GM_setValue('urls', JSON.stringify([]));
            return [];
        }
        try { return JSON.parse(stored); } catch { return []; }
    }

    function saveUrls(urls) {
        GM_setValue('urls', JSON.stringify(urls));
    }

    function globToRegex(pattern) {
        const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, '\\$&');
        const withWildcards = escaped.replace(/\*/g, '.*');
        return new RegExp('^' + withWildcards + '$');
    }

    function urlMatches(url, patterns) {
        return patterns.some(p => globToRegex(p).test(url));
    }

    // --- Per-Site Sort Preferences ---

    function getSortKey() {
        const patterns = loadUrls();
        for (const p of patterns) {
            if (globToRegex(p).test(location.href)) {
                return 'sort_' + p;
            }
        }
        return 'sort_global';
    }

    function loadSort() {
        const key = getSortKey();
        try { return JSON.parse(GM_getValue(key, 'null')); } catch { return null; }
    }

    function saveSort(column, ascending) {
        const key = getSortKey();
        GM_setValue(key, JSON.stringify({ column, ascending }));
    }

    function loadGroupByDate() {
        const key = getSortKey() + '_group';
        return GM_getValue(key, false);
    }

    function saveGroupByDate(enabled) {
        const key = getSortKey() + '_group';
        GM_setValue(key, enabled);
    }

    // --- Directory Listing Detection ---

    function isDirectoryListing() {
        const pre = document.querySelector('pre');
        if (!pre) return false;
        const links = pre.querySelectorAll('a[href]');
        if (links.length < 1) return false;
        const text = pre.textContent || '';
        return /\d{2}-[A-Za-z]{3}-\d{4}/.test(text) || /\d{4}-\d{2}-\d{2}/.test(text);
    }

    // --- Parsing ---

    function parseEntries() {
        const pre = document.querySelector('pre');
        const children = Array.from(pre.childNodes);
        const entries = [];
        let parentEntry = null;

        for (let i = 0; i < children.length; i++) {
            const node = children[i];
            if (node.nodeName !== 'A') continue;

            const href = node.getAttribute('href');
            const textNode = node.nextSibling;
            const meta = (textNode && textNode.nodeType === 3) ? textNode.textContent : '';

            if (href === '../' || href === '/') {
                parentEntry = { element: node, textNode: textNode, name: '../', date: null, size: -1, isParent: true };
                continue;
            }

            const name = node.textContent.trim();
            const date = parseDate(meta);
            const size = parseSize(meta);

            entries.push({ element: node, textNode: textNode, name, date, size, isParent: false });
        }

        return { entries, parentEntry, pre };
    }

    function parseDate(text) {
        // Format: 02-Jan-2024 12:34
        let m = text.match(/(\d{2})-([A-Za-z]{3})-(\d{4})\s+(\d{2}):(\d{2})/);
        if (m) {
            const months = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
            return new Date(+m[3], months[m[2]] ?? 0, +m[1], +m[4], +m[5]);
        }
        // Format: 2024-01-02 12:34
        m = text.match(/(\d{4})-(\d{2})-(\d{2})\s+(\d{2}):(\d{2})/);
        if (m) return new Date(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]);
        // Date only
        m = text.match(/(\d{2})-([A-Za-z]{3})-(\d{4})/);
        if (m) {
            const months = { Jan: 0, Feb: 1, Mar: 2, Apr: 3, May: 4, Jun: 5, Jul: 6, Aug: 7, Sep: 8, Oct: 9, Nov: 10, Dec: 11 };
            return new Date(+m[3], months[m[2]] ?? 0, +m[1]);
        }
        return null;
    }

    function parseSize(text) {
        const m = text.match(/([\d.]+)\s*([BKMGT]i?B?|[BKMGT])\b/i);
        if (!m) return -1;
        const val = parseFloat(m[1]);
        const unit = m[2].charAt(0).toUpperCase();
        const multipliers = { B: 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 };
        return val * (multipliers[unit] || 1);
    }

    // --- Sorting ---

    function sortEntries(entries, column, ascending) {
        const dir = ascending ? 1 : -1;
        entries.sort((a, b) => {
            switch (column) {
                case 'name':
                    return dir * a.name.localeCompare(b.name, undefined, { sensitivity: 'base' });
                case 'date': {
                    const da = a.date ? a.date.getTime() : 0;
                    const db = b.date ? b.date.getTime() : 0;
                    return dir * (da - db);
                }
                case 'size':
                    return dir * (a.size - b.size);
                default:
                    return 0;
            }
        });
    }

    function applySort(column, ascending) {
        const { entries, parentEntry, pre } = parseEntries();
        removeDateHeaders(pre);
        sortEntries(entries, column, ascending);

        // Rebuild content
        while (pre.firstChild) pre.removeChild(pre.firstChild);

        if (parentEntry) {
            pre.appendChild(parentEntry.element);
            if (parentEntry.textNode) pre.appendChild(parentEntry.textNode);
        }

        let lastDateLabel = null;
        for (const entry of entries) {
            if (groupByDate) {
                const label = getDateLabel(entry.date);
                if (label !== lastDateLabel) {
                    pre.appendChild(createDateHeader(label));
                    pre.appendChild(document.createTextNode('\n'));
                    lastDateLabel = label;
                }
            }
            pre.appendChild(entry.element);
            if (entry.textNode) pre.appendChild(entry.textNode);
        }

        // Save per-site preference
        saveSort(column, ascending);
        updateToolbarArrows(column, ascending);
        updateItemCount();
    }

    // --- Date Grouping ---

    let groupByDate = false;

    function getDateLabel(date) {
        if (!date) return 'Unknown Date';
        const now = new Date();
        const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
        const yesterday = new Date(today.getTime() - 86400000);
        const entryDay = new Date(date.getFullYear(), date.getMonth(), date.getDate());

        if (entryDay.getTime() === today.getTime()) return 'Today';
        if (entryDay.getTime() === yesterday.getTime()) return 'Yesterday';

        const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
        return months[date.getMonth()] + ' ' + date.getDate() + ', ' + date.getFullYear();
    }

    function createDateHeader(label) {
        const header = document.createElement('div');
        header.className = 'http-index-sorter-date-header';
        header.textContent = '\u2500\u2500 ' + label + ' \u2500\u2500';
        header.style.cssText = `
            font-family: monospace;
            font-size: 12px;
            color: #888;
            padding: 6px 0 2px 0;
            font-weight: bold;
        `;
        return header;
    }

    function removeDateHeaders(pre) {
        const headers = pre.querySelectorAll('.http-index-sorter-date-header');
        for (const h of headers) h.remove();
    }

    // --- UI: Toolbar ---

    let toolbarButtons = {};
    let itemCountSpan = null;
    let groupBtn = null;

    function updateItemCount() {
        if (!itemCountSpan) return;
        const pre = document.querySelector('pre');
        const allLinks = pre.querySelectorAll('a[href]');
        let total = 0;
        for (const link of allLinks) {
            const href = link.getAttribute('href');
            if (href === '../' || href === '/') continue;
            total++;
        }
        itemCountSpan.textContent = total + ' items';
    }

    function updateToolbarArrows(activeColumn, ascending) {
        for (const [col, btn] of Object.entries(toolbarButtons)) {
            const arrow = btn.querySelector('.sort-arrow');
            if (col === activeColumn) {
                arrow.textContent = ascending ? ' \u25B2' : ' \u25BC';
            } else {
                arrow.textContent = '';
            }
        }
    }

    function addFileIcons() {
        const pre = document.querySelector('pre');
        const links = pre.querySelectorAll('a[href]');
        for (const link of links) {
            const href = link.getAttribute('href');
            if (href === '../' || href === '/') continue;
            if (link.dataset.iconAdded) continue;
            const icon = getFileIcon(href);
            const iconSpan = document.createElement('span');
            iconSpan.textContent = icon + ' ';
            iconSpan.style.cssText = 'font-style: normal; margin-right: 2px;';
            link.insertBefore(iconSpan, link.firstChild);
            link.dataset.iconAdded = 'true';
        }
    }

    function createToolbar() {
        const pre = document.querySelector('pre');
        const toolbar = document.createElement('div');
        toolbar.id = 'http-index-sorter-toolbar';
        toolbar.style.cssText = `
            font-family: monospace;
            font-size: 13px;
            padding: 6px 8px;
            margin-bottom: 4px;
            background: #f5f5f5;
            border: 1px solid #ddd;
            border-radius: 3px;
            display: flex;
            align-items: center;
            gap: 4px;
            color: #333;
        `;

        const label = document.createElement('span');
        label.textContent = 'Sort:';
        label.style.cssText = 'margin-right: 4px; color: #666;';
        toolbar.appendChild(label);

        let currentSort = loadSort();

        const columns = [
            { key: 'name', label: 'Name' },
            { key: 'date', label: 'Date' },
            { key: 'size', label: 'Size' },
        ];

        for (const col of columns) {
            const btn = document.createElement('button');
            btn.style.cssText = `
                font-family: monospace;
                font-size: 13px;
                padding: 2px 8px;
                border: 1px solid #ccc;
                border-radius: 3px;
                background: #fff;
                cursor: pointer;
                color: #333;
            `;
            btn.onmouseenter = () => { btn.style.background = '#e8e8e8'; };
            btn.onmouseleave = () => { btn.style.background = '#fff'; };

            const textSpan = document.createElement('span');
            textSpan.textContent = col.label;
            btn.appendChild(textSpan);

            const arrow = document.createElement('span');
            arrow.className = 'sort-arrow';
            arrow.textContent = '';
            btn.appendChild(arrow);

            let ascending = true;
            if (currentSort && currentSort.column === col.key) {
                ascending = currentSort.ascending;
            }

            btn.addEventListener('click', () => {
                const parsed = loadSort();
                if (parsed && parsed.column === col.key) {
                    ascending = !parsed.ascending;
                } else {
                    ascending = true;
                }
                applySort(col.key, ascending);
            });

            toolbarButtons[col.key] = btn;
            toolbar.appendChild(btn);
        }

        // Separator
        const sep = document.createElement('span');
        sep.textContent = '|';
        sep.style.cssText = 'color: #ccc; margin: 0 2px;';
        toolbar.appendChild(sep);

        // Group by Date toggle
        groupBtn = document.createElement('button');
        groupBtn.title = 'Group entries by date';
        groupBtn.style.cssText = `
            font-family: monospace;
            font-size: 13px;
            padding: 2px 8px;
            border: 1px solid #ccc;
            border-radius: 3px;
            background: #fff;
            cursor: pointer;
            color: #333;
        `;
        function updateGroupBtnLabel() {
            groupBtn.textContent = groupByDate ? 'Group: ON' : 'Group: OFF';
            groupBtn.style.background = groupByDate ? '#e0ecff' : '#fff';
            groupBtn.style.borderColor = groupByDate ? '#8ab4f8' : '#ccc';
        }
        updateGroupBtnLabel();
        groupBtn.onmouseenter = () => { if (!groupByDate) groupBtn.style.background = '#e8e8e8'; };
        groupBtn.onmouseleave = () => { groupBtn.style.background = groupByDate ? '#e0ecff' : '#fff'; };
        groupBtn.addEventListener('click', () => {
            groupByDate = !groupByDate;
            saveGroupByDate(groupByDate);
            updateGroupBtnLabel();
            // When enabling grouping, force sort by date descending
            if (groupByDate) {
                applySort('date', false);
            } else {
                const parsed = loadSort();
                if (parsed && parsed.column) {
                    applySort(parsed.column, parsed.ascending);
                } else {
                    applySort('name', true);
                }
            }
        });
        toolbar.appendChild(groupBtn);

        // Spacer
        const spacer = document.createElement('span');
        spacer.style.flex = '1';
        toolbar.appendChild(spacer);

        // Item count
        itemCountSpan = document.createElement('span');
        itemCountSpan.style.cssText = 'color: #888; font-size: 12px; margin-right: 8px;';
        toolbar.appendChild(itemCountSpan);

        // Settings gear
        const gear = document.createElement('button');
        gear.textContent = '\u2699';
        gear.title = 'URL pattern settings';
        gear.style.cssText = `
            font-size: 16px;
            padding: 2px 6px;
            border: 1px solid #ccc;
            border-radius: 3px;
            background: #fff;
            cursor: pointer;
            color: #666;
        `;
        gear.onmouseenter = () => { gear.style.background = '#e8e8e8'; };
        gear.onmouseleave = () => { gear.style.background = '#fff'; };
        gear.addEventListener('click', openSettings);
        toolbar.appendChild(gear);

        pre.parentNode.insertBefore(toolbar, pre);

        return currentSort;
    }

    // --- UI: Settings Panel ---

    function openSettings() {
        if (document.getElementById('http-index-sorter-settings')) return;

        const overlay = document.createElement('div');
        overlay.id = 'http-index-sorter-settings';
        overlay.style.cssText = `
            position: fixed; top: 0; left: 0; width: 100%; height: 100%;
            background: rgba(0,0,0,0.4); z-index: 10000;
            display: flex; align-items: center; justify-content: center;
        `;

        const panel = document.createElement('div');
        panel.style.cssText = `
            background: #fff; border-radius: 6px; padding: 20px;
            min-width: 420px; max-width: 600px; max-height: 80vh;
            font-family: monospace; font-size: 13px; color: #333;
            box-shadow: 0 4px 20px rgba(0,0,0,0.3);
            display: flex; flex-direction: column; gap: 12px;
        `;

        const title = document.createElement('div');
        title.textContent = 'HTTP Index Sorter \u2014 URL Patterns';
        title.style.cssText = 'font-size: 15px; font-weight: bold; margin-bottom: 4px;';
        panel.appendChild(title);

        const desc = document.createElement('div');
        desc.textContent = 'The script activates on pages matching these patterns. Use * as a wildcard.';
        desc.style.cssText = 'color: #666; margin-bottom: 8px;';
        panel.appendChild(desc);

        const urls = loadUrls();
        const list = document.createElement('div');
        list.style.cssText = 'display: flex; flex-direction: column; gap: 6px; max-height: 300px; overflow-y: auto;';

        function renderList() {
            list.innerHTML = '';
            const currentUrls = loadUrls();
            for (let i = 0; i < currentUrls.length; i++) {
                const row = document.createElement('div');
                row.style.cssText = 'display: flex; align-items: center; gap: 6px;';

                const input = document.createElement('input');
                input.type = 'text';
                input.value = currentUrls[i];
                input.style.cssText = `
                    flex: 1; font-family: monospace; font-size: 12px;
                    padding: 4px 6px; border: 1px solid #ccc; border-radius: 3px;
                `;
                input.readOnly = true;
                row.appendChild(input);

                const removeBtn = document.createElement('button');
                removeBtn.textContent = '\u2715';
                removeBtn.title = 'Remove';
                removeBtn.style.cssText = `
                    padding: 4px 8px; border: 1px solid #ccc; border-radius: 3px;
                    background: #fff; cursor: pointer; color: #c00; font-weight: bold;
                `;
                const idx = i;
                removeBtn.addEventListener('click', () => {
                    const u = loadUrls();
                    u.splice(idx, 1);
                    saveUrls(u);
                    renderList();
                });
                row.appendChild(removeBtn);

                list.appendChild(row);
            }
        }

        renderList();
        panel.appendChild(list);

        // Add current URL button
        const addCurrentRow = document.createElement('div');
        addCurrentRow.style.cssText = 'display: flex; gap: 6px;';
        const addCurrentBtn = document.createElement('button');
        addCurrentBtn.textContent = '+ Add Current Page URL';
        addCurrentBtn.title = 'Add a pattern matching the current page';
        addCurrentBtn.style.cssText = `
            flex: 1; padding: 6px 12px; border: 1px solid #5a9e5a; border-radius: 3px;
            background: #5a9e5a; color: #fff; cursor: pointer; font-family: monospace;
            font-size: 12px;
        `;
        addCurrentBtn.onmouseenter = () => { addCurrentBtn.style.background = '#4a8e4a'; };
        addCurrentBtn.onmouseleave = () => { addCurrentBtn.style.background = '#5a9e5a'; };
        addCurrentBtn.addEventListener('click', () => {
            // Generate a pattern from the current URL: replace the last path segment with *
            const url = location.href.replace(/\/[^/]*$/, '/*');
            const u = loadUrls();
            if (!u.includes(url)) {
                u.push(url);
                saveUrls(u);
                location.reload();
            }
        });
        addCurrentRow.appendChild(addCurrentBtn);
        panel.appendChild(addCurrentRow);

        // Add new URL row
        const addRow = document.createElement('div');
        addRow.style.cssText = 'display: flex; gap: 6px;';
        const addInput = document.createElement('input');
        addInput.type = 'text';
        addInput.placeholder = 'https://example.com/files/*';
        addInput.style.cssText = `
            flex: 1; font-family: monospace; font-size: 12px;
            padding: 4px 6px; border: 1px solid #ccc; border-radius: 3px;
        `;
        addRow.appendChild(addInput);

        const addBtn = document.createElement('button');
        addBtn.textContent = 'Add';
        addBtn.style.cssText = `
            padding: 4px 12px; border: 1px solid #4a90d9; border-radius: 3px;
            background: #4a90d9; color: #fff; cursor: pointer; font-family: monospace;
        `;
        addBtn.addEventListener('click', () => {
            const val = addInput.value.trim();
            if (!val) return;
            const u = loadUrls();
            if (!u.includes(val)) {
                u.push(val);
                saveUrls(u);
            }
            addInput.value = '';
            renderList();
        });
        addRow.appendChild(addBtn);
        panel.appendChild(addRow);

        // Close button
        const closeRow = document.createElement('div');
        closeRow.style.cssText = 'text-align: right; margin-top: 4px;';
        const closeBtn = document.createElement('button');
        closeBtn.textContent = 'Close';
        closeBtn.style.cssText = `
            padding: 4px 16px; border: 1px solid #ccc; border-radius: 3px;
            background: #f5f5f5; cursor: pointer; font-family: monospace;
        `;
        closeBtn.addEventListener('click', () => overlay.remove());
        closeRow.appendChild(closeBtn);
        panel.appendChild(closeRow);

        overlay.appendChild(panel);
        overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); });
        document.body.appendChild(overlay);
    }

    // --- Menu Command ---

    GM_registerMenuCommand('HTTP Index Sorter Settings', openSettings);

    // --- Init ---

    const urls = loadUrls();
    if (!urlMatches(location.href, urls)) return;
    if (!isDirectoryListing()) return;

    addFileIcons();
    groupByDate = loadGroupByDate();
    const lastSort = createToolbar();
    updateItemCount();

    if (groupByDate) {
        applySort('date', false);
    } else if (lastSort && lastSort.column) {
        applySort(lastSort.column, lastSort.ascending);
    }

})();