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와 같은 확장 프로그램이 필요합니다.

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

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

Advertisement:

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

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

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

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

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

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

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

Advertisement:

// ==UserScript==
// @name         HTTP Index Sorter
// @namespace    https://greasyfork.org/en/users/1574063-primetime43
// @version      1.3
// @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
// @run-at       document-idle
// ==/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 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 };

    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 ---

    let _urlsCache = null;

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

    function saveUrls(urls) {
        _urlsCache = urls;
        _sortKeyCache = null; // invalidate since matching pattern may change
        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 ---

    let _sortKeyCache = null;

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

    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');
            // Skip past any copy button spans to find the text node
            let sibling = node.nextSibling;
            let copyBtnNode = null;
            if (sibling && sibling.nodeType === 1 && sibling.classList &&
                sibling.classList.contains('http-index-sorter-copy-btn')) {
                copyBtnNode = sibling;
                sibling = sibling.nextSibling;
            }
            const textNode = sibling;
            const meta = (textNode && textNode.nodeType === 3) ? textNode.textContent : '';

            if (href === '../' || href === '/') {
                parentEntry = { element: node, copyBtn: copyBtnNode, 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, copyBtn: copyBtnNode, 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) 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) 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) {
            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);
        }
        // Plain byte count with no unit suffix (e.g. "1234")
        const plain = text.match(/\b(\d+)\b/);
        if (plain) return parseInt(plain[1], 10);
        return -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.copyBtn) pre.appendChild(parentEntry.copyBtn);
            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.copyBtn) pre.appendChild(entry.copyBtn);
            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 createSectionActionBtn(text, title) {
        const btn = document.createElement('span');
        btn.textContent = text;
        btn.title = title;
        btn.style.cssText = `
            cursor: pointer; margin-left: 8px; font-size: 11px;
            padding: 1px 6px; border: 1px solid #aaa; border-radius: 3px;
            background: #f0f0f0; color: #555; user-select: none;
            font-weight: normal;
        `;
        btn.onmouseenter = () => { btn.style.background = '#e0e0e0'; };
        btn.onmouseleave = () => { btn.style.background = '#f0f0f0'; };
        return btn;
    }

    function getLinksInSection(header) {
        const links = [];
        let node = header.nextSibling;
        while (node) {
            if (node.classList && node.classList.contains('http-index-sorter-date-header')) break;
            if (node.nodeName === 'A') {
                const href = node.getAttribute('href');
                if (href && href !== '../' && href !== '/') links.push(node);
            }
            node = node.nextSibling;
        }
        return links;
    }

    function getLinksInSectionDeep(header) {
        const links = [];
        let node = header.nextSibling;
        while (node) {
            if (node.classList && node.classList.contains('http-index-sorter-date-header')) break;
            if (node.nodeName === 'A') {
                const href = node.getAttribute('href');
                if (href && href !== '../' && href !== '/') links.push(node);
            } else if (node.classList && node.classList.contains('http-index-sorter-expansion')) {
                const subLinks = node.querySelectorAll('a[href]');
                for (const a of subLinks) {
                    const href = a.getAttribute('href');
                    if (href && href !== '../' && href !== '/') links.push(a);
                }
            }
            node = node.nextSibling;
        }
        return links;
    }

    function showCopyByTypeMenu(header, anchorBtn) {
        const existing = document.getElementById('http-index-sorter-type-menu');
        if (existing) { existing.remove(); return; }

        const links = getLinksInSectionDeep(header);
        const byExt = new Map();
        for (const a of links) {
            const href = a.getAttribute('href') || '';
            if (href.endsWith('/')) continue;
            const name = decodeURIComponent(href.split('/').pop().split('?')[0]);
            const m = name.match(/\.([a-z0-9]+)$/i);
            const ext = m ? m[1].toLowerCase() : '(no ext)';
            if (!byExt.has(ext)) byExt.set(ext, []);
            byExt.get(ext).push(resolveHref(a));
        }

        if (byExt.size === 0) return;

        const entries = Array.from(byExt.entries()).sort((a, b) => b[1].length - a[1].length);

        const menu = document.createElement('div');
        menu.id = 'http-index-sorter-type-menu';
        const rect = anchorBtn.getBoundingClientRect();
        menu.style.cssText = `
            position: absolute;
            top: ${rect.bottom + window.scrollY + 4}px;
            left: ${rect.left + window.scrollX}px;
            background: #fff;
            border: 1px solid #ccc;
            border-radius: 4px;
            box-shadow: 0 2px 8px rgba(0,0,0,0.15);
            font-family: monospace;
            font-size: 12px;
            z-index: 9999;
            min-width: 160px;
            padding: 4px 0;
        `;

        // "All files" option at top
        const allUrls = entries.filter(([ext]) => ext !== '(no ext)' || true)
                               .flatMap(([, urls]) => urls);
        const allItem = document.createElement('div');
        allItem.style.cssText = `
            padding: 4px 12px; cursor: pointer; display: flex;
            justify-content: space-between; gap: 16px; color: #333;
            border-bottom: 1px solid #eee;
        `;
        const allName = document.createElement('span');
        allName.textContent = 'All files';
        allName.style.fontWeight = 'bold';
        const allCount = document.createElement('span');
        allCount.textContent = allUrls.length;
        allCount.style.color = '#888';
        allItem.appendChild(allName);
        allItem.appendChild(allCount);
        allItem.onmouseenter = () => { allItem.style.background = '#f0f0f0'; };
        allItem.onmouseleave = () => { allItem.style.background = '#fff'; };
        allItem.addEventListener('click', () => {
            navigator.clipboard.writeText(allUrls.join('\n'));
            allName.textContent = '\u2713 copied ' + allUrls.length;
            setTimeout(() => menu.remove(), 700);
        });
        menu.appendChild(allItem);

        for (const [ext, urls] of entries) {
            const item = document.createElement('div');
            item.style.cssText = `
                padding: 4px 12px; cursor: pointer; display: flex;
                justify-content: space-between; gap: 16px; color: #333;
            `;
            const nameSpan = document.createElement('span');
            nameSpan.textContent = ext === '(no ext)' ? '(no extension)' : '.' + ext;
            const countSpan = document.createElement('span');
            countSpan.textContent = urls.length;
            countSpan.style.color = '#888';
            item.appendChild(nameSpan);
            item.appendChild(countSpan);
            item.onmouseenter = () => { item.style.background = '#f0f0f0'; };
            item.onmouseleave = () => { item.style.background = '#fff'; };
            item.addEventListener('click', () => {
                navigator.clipboard.writeText(urls.join('\n'));
                nameSpan.textContent = '\u2713 copied ' + urls.length;
                setTimeout(() => menu.remove(), 700);
            });
            menu.appendChild(item);
        }

        document.body.appendChild(menu);

        setTimeout(() => {
            const dismissHandler = (e) => {
                if (!menu.contains(e.target) && e.target !== anchorBtn) {
                    menu.remove();
                    document.removeEventListener('click', dismissHandler);
                }
            };
            document.addEventListener('click', dismissHandler);
        }, 0);
    }

    function createDateHeader(label) {
        const header = document.createElement('div');
        header.className = 'http-index-sorter-date-header';
        header.style.cssText = `
            font-family: monospace;
            font-size: 12px;
            color: #888;
            padding: 6px 0 2px 0;
            font-weight: bold;
            display: flex;
            align-items: center;
        `;

        const labelSpan = document.createElement('span');
        labelSpan.textContent = '\u2500\u2500 ' + label + ' \u2500\u2500';
        header.appendChild(labelSpan);

        // Expand folders button — fetches and displays each subfolder's contents inline
        const expandBtn = createSectionActionBtn('\uD83D\uDCC2 Expand folders', 'Fetch and display all folder contents inline');
        let expanded = false;
        expandBtn.addEventListener('click', async () => {
            const folders = getLinksInSection(header).filter(a => a.getAttribute('href').endsWith('/'));
            if (folders.length === 0) return;

            if (expanded) {
                folders.forEach(collapseFolderInline);
                expanded = false;
                expandBtn.textContent = '\uD83D\uDCC2 Expand folders';
                return;
            }

            expanded = true;
            expandBtn.textContent = '\uD83D\uDCC1 Collapse folders';
            await Promise.all(folders.map(expandFolderInline));
        });
        header.appendChild(expandBtn);

        // Copy URLs button (includes expanded contents if any)
        const copyBtn = createSectionActionBtn('\uD83D\uDCCB Copy URLs', 'Copy all URLs in this section (including expanded folder contents)');
        copyBtn.addEventListener('click', () => {
            const urls = getLinksInSectionDeep(header).map(a => resolveHref(a));
            navigator.clipboard.writeText(urls.join('\n'));
            showCopyFeedback(copyBtn, '\uD83D\uDCCB Copy URLs');
        });
        header.appendChild(copyBtn);

        // Copy by type button
        const copyTypeBtn = createSectionActionBtn('\uD83C\uDFF7\uFE0F Copy by type', 'Copy URLs filtered by file extension');
        copyTypeBtn.addEventListener('click', (e) => {
            e.stopPropagation();
            showCopyByTypeMenu(header, copyTypeBtn);
        });
        header.appendChild(copyTypeBtn);

        return header;
    }

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

    // --- Inline Folder Expansion ---

    async function expandFolderInline(folderLink) {
        if (folderLink._expansionContainer) return;

        const url = resolveHref(folderLink);
        const container = document.createElement('div');
        container.className = 'http-index-sorter-expansion';
        container.style.cssText = `
            padding: 4px 0 4px 24px;
            margin: 2px 0 4px 0;
            border-left: 2px solid #8ab4f8;
            background: #fafbff;
            color: #555;
            font-family: monospace;
            font-size: 12px;
            white-space: pre;
        `;
        container.textContent = 'Loading...';

        // Insert after the folder's metadata text node
        let cursor = folderLink.nextSibling;
        if (cursor && cursor.nodeType === 1 &&
            cursor.classList && cursor.classList.contains('http-index-sorter-copy-btn')) {
            cursor = cursor.nextSibling;
        }
        const insertBefore = (cursor && cursor.nodeType === 3) ? cursor.nextSibling : cursor;
        folderLink.parentNode.insertBefore(container, insertBefore);
        folderLink._expansionContainer = container;

        try {
            const resp = await fetch(url);
            if (!resp.ok) throw new Error('HTTP ' + resp.status);
            const html = await resp.text();
            const doc = new DOMParser().parseFromString(html, 'text/html');
            const pre = doc.querySelector('pre');
            if (!pre) throw new Error('no directory listing at target');

            container.innerHTML = '';
            const nodes = Array.from(pre.childNodes);
            let count = 0;
            for (let i = 0; i < nodes.length; i++) {
                const node = nodes[i];
                if (node.nodeName !== 'A') continue;
                const href = node.getAttribute('href');
                if (href === '../' || href === '/') continue;

                const newA = document.createElement('a');
                newA.href = new URL(href, url).href;
                const iconSpan = document.createElement('span');
                iconSpan.textContent = getFileIcon(href) + ' ';
                iconSpan.style.marginRight = '2px';
                newA.appendChild(iconSpan);
                newA.appendChild(document.createTextNode(node.textContent.trim()));
                container.appendChild(newA);
                container.appendChild(createHoverCopyButton(newA));

                const next = nodes[i + 1];
                if (next && next.nodeType === 3) {
                    container.appendChild(document.createTextNode(next.textContent));
                    i++;
                } else {
                    container.appendChild(document.createTextNode('\n'));
                }
                count++;
            }

            if (count === 0) {
                container.textContent = '(empty)';
                container.style.fontStyle = 'italic';
            }
        } catch (err) {
            container.textContent = 'Failed to load: ' + (err.message || err);
            container.style.color = '#c00';
            container.style.borderLeftColor = '#c00';
        }
    }

    function collapseFolderInline(folderLink) {
        if (folderLink._expansionContainer) {
            folderLink._expansionContainer.remove();
            folderLink._expansionContainer = null;
        }
    }

    // --- 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 resolveHref(link) {
        return new URL(link.getAttribute('href'), location.href).href;
    }

    function showCopyFeedback(btn, originalText) {
        btn.textContent = '\u2713';
        setTimeout(() => { btn.textContent = originalText; }, 1000);
    }

    function createHoverCopyButton(link) {
        const copyBtn = document.createElement('span');
        copyBtn.className = 'http-index-sorter-copy-btn';
        copyBtn.textContent = '\uD83D\uDCCB';
        copyBtn.title = 'Copy URL';
        copyBtn.style.cssText = `
            cursor: pointer; margin-left: 4px; font-size: 12px;
            opacity: 0; transition: opacity 0.15s;
            vertical-align: middle; user-select: none;
        `;
        copyBtn.addEventListener('click', (e) => {
            e.preventDefault();
            e.stopPropagation();
            navigator.clipboard.writeText(resolveHref(link));
            showCopyFeedback(copyBtn, '\uD83D\uDCCB');
        });
        link.addEventListener('mouseenter', () => { copyBtn.style.opacity = '1'; });
        link.addEventListener('mouseleave', (e) => {
            if (e.relatedTarget !== copyBtn) copyBtn.style.opacity = '0';
        });
        copyBtn.addEventListener('mouseenter', () => { copyBtn.style.opacity = '1'; });
        copyBtn.addEventListener('mouseleave', () => { copyBtn.style.opacity = '0'; });
        return copyBtn;
    }

    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';

            const copyBtn = createHoverCopyButton(link);
            if (link.nextSibling) {
                link.parentNode.insertBefore(copyBtn, link.nextSibling);
            } else {
                link.parentNode.appendChild(copyBtn);
            }
        }
    }

    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 pattern = currentUrls[i];
                removeBtn.addEventListener('click', () => {
                    const u = loadUrls();
                    const removeIdx = u.indexOf(pattern);
                    if (removeIdx !== -1) {
                        u.splice(removeIdx, 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);
    }

})();