Greasy Fork

动漫花园树状显示

将动漫花园的文件列表转换为树状视图,支持搜索、智能展开等功能

// ==UserScript==
// @name         动漫花园树状显示
// @name:zh-CN   动漫花园文件列表树状显示
// @name:en      DMHY Tree View
// @namespace    https://github.com/xkbkx5904/dmhy-tree-view
// @version      0.5.3
// @description  将动漫花园的文件列表转换为树状视图,支持搜索、智能展开等功能
// @description:zh-CN  将动漫花园的文件列表转换为树状视图,支持搜索、智能展开等功能
// @description:en  Convert DMHY file list into a tree view with search and smart collapse features
// @author       xkbkx5904
// @license      GPL-3.0
// @homepage     https://github.com/xkbkx5904/dmhy-tree-view
// @supportURL   https://github.com/xkbkx5904/dmhy-tree-view/issues
// @match        *://share.dmhy.org/topics/view/*
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jquery.min.js
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jstree.min.js
// @resource     customCSS https://cdn.jsdelivr.net/npm/[email protected]/dist/themes/default/style.min.css
// @icon         https://share.dmhy.org/favicon.ico
// @grant        GM_addStyle
// @grant        GM_getResourceText
// @run-at       document-end
// @originalAuthor TautCony
// @originalURL  https://greasyfork.org/zh-CN/scripts/26430-dmhy-tree-view
// ==/UserScript==

/* 更新日志
 * v0.5.3
 * - 刚刚更新了日志但是代码复制了旧版本的,幽默了一下,更新版本号从新推送一下代码
 *
 * v0.5.2
 * - 修复了当文件无法提取到文件名时,文件大小被识别为文件名的问题
 * - 修复了智能展开模式下,单层级目录没有自动打开的问题
 *
 * v0.5.1
 * - 修复智能模式下单层目录展开/折叠的问题
 * 
 * v0.5.0
 * - 添加文件名和大小排序功能
 * - 优化搜索性能
 * - 修复搜索和排序功能的冲突问题
 * - 添加动漫花园网站图标
 * 
 * v0.4.0
 * - 初始版本发布
 * - 实现树状显示功能
 * - 添加搜索功能
 * - 添加智能展开功能
 */
 
// 文件类型图标映射
const ICONS = {
    audio: "/images/icon/mp3.gif",
    bmp: "/images/icon/bmp.gif",
    image: "/images/icon/jpg.gif",
    png: "/images/icon/png.gif",
    rar: "/images/icon/rar.gif",
    text: "/images/icon/txt.gif",
    unknown: "/images/icon/unknown.gif",
    video: "/images/icon/mp4.gif"
};
 
// 文件扩展名分类
const FILE_TYPES = {
    audio: ["flac", "aac", "wav", "mp3", "m4a", "mka"],
    bmp: ["bmp"],
    image: ["jpg", "jpeg", "webp"],
    png: ["png", "gif"],
    rar: ["rar", "zip", "7z", "tar", "gz"],
    text: ["txt", "log", "cue", "ass", "ssa", "srt", "doc", "docx", "xls", "xlsx", "pdf"],
    video: ["mkv", "mp4", "avi", "wmv", "flv", "m2ts"]
};
 
// 设置样式
const setupCSS = () => {
    GM_addStyle(GM_getResourceText("customCSS"));
    GM_addStyle(`
        .jstree-node, .jstree-default .jstree-icon {
            background-image: url(https://cdn.jsdelivr.net/npm/[email protected]/dist/themes/default/32px.png);
        }
 
        .tree-container {
            background: #fff;
            border: 2px solid;
            border-color: #404040 #dfdfdf #dfdfdf #404040;
            padding: 5px;
        }
 
        .control-panel {
            background: #f0f0f0;
            border-bottom: 1px solid #ccc;
            padding: 5px;
            display: flex;
            align-items: center;
            gap: 10px;
        }
 
        .control-panel-left {
            display: flex;
            align-items: center;
            gap: 10px;
        }
 
        .control-panel-right {
            margin-left: auto;
            display: flex;
            align-items: center;
        }
 
        #search_input {
            border: 1px solid #ccc;
            padding: 2px 5px;
            width: 200px;
        }
 
        #switch {
            padding: 2px 5px;
            cursor: pointer;
        }
 
        #file_tree {
            padding: 5px;
            max-height: 600px;
            overflow: auto;
        }
 
        .filesize {
            padding-left: 8px;
            color: #666;
        }
 
        .smart-toggle {
            display: flex;
            align-items: center;
            gap: 4px;
            cursor: pointer;
            user-select: none;
        }
 
        .smart-toggle input {
            margin: 0;
        }
 
        .sort-controls {
            display: flex;
            align-items: center;
            gap: 5px;
        }
 
        .sort-btn {
            padding: 2px 8px;
            cursor: pointer;
            border: 1px solid #ccc;
            background: #f8f8f8;
            display: flex;
            align-items: center;
            gap: 4px;
        }
 
        .sort-btn.active {
            background: #e0e0e0;
        }
 
        .sort-direction {
            display: inline-block;
            width: 12px;
        }
    `);
};
 
// 树节点基础类
class TreeNode {
    constructor(name) {
        this.name = name;
        this.length = 0;
        this.childNode = new Map();
        this._cache = new Map();
    }
 
    // 插入节点
    insert(path, size) {
        let currentNode = this;
        for (const node of path) {
            if (!currentNode.childNode.has(node)) {
                currentNode.childNode.set(node, new TreeNode(node));
            }
            currentNode = currentNode.childNode.get(node);
        }
        currentNode.length = this.toLength(size);
        return currentNode;
    }
 
    // 转换为显示文本
    toString() {
        const size = this.childNode.size > 0 ? this.calculateTotalSize() : this.length;
        return `<span class="filename">${this.name}</span><span class="filesize">${this.toSize(size)}</span>`;
    }
 
    // 计算总大小
    calculateTotalSize() {
        if (this._cache.has('totalSize')) return this._cache.get('totalSize');
 
        let total = this.length;
        for (const node of this.childNode.values()) {
            total += node.childNode.size === 0 ? node.length : node.calculateTotalSize();
        }
 
        this._cache.set('totalSize', total);
        return total;
    }
 
    // 转换为jstree对象
    toObject() {
        if (this._cache.has('object')) return this._cache.get('object');
 
        const ret = {
            text: this.toString(),
            children: [],
            state: { opened: false }
        };
 
        // 分别处理文件夹和文件
        const folders = [];
        const files = [];
 
        for (const [, value] of this.childNode) {
            if (value.childNode.size === 0) {
                files.push({
                    icon: value.icon,
                    length: value.length,
                    text: value.toString()
                });
            } else {
                const inner = value.toObject();
                folders.push({
                    ...inner,
                    text: `<span class="filename">${value.name}</span><span class="filesize">${this.toSize(value.calculateTotalSize())}</span>`,
                    state: { opened: false }
                });
            }
        }
 
        ret.children = [...folders, ...files];
        this._cache.set('object', ret);
        return ret;
    }
 
    // 获取文件扩展名
    get ext() {
        if (this._ext !== undefined) return this._ext;
        const dotIndex = this.name.lastIndexOf(".");
        this._ext = dotIndex > 0 ? this.name.substr(dotIndex + 1).toLowerCase() : "";
        return this._ext;
    }
 
    // 获取文件图标
    get icon() {
        if (this._icon !== undefined) return this._icon;
        this._icon = ICONS.unknown;
        for (const [type, extensions] of Object.entries(FILE_TYPES)) {
            if (extensions.includes(this.ext)) {
                this._icon = ICONS[type];
                break;
            }
        }
        return this._icon;
    }
 
    // 转换文件大小字符串为字节数
    toLength(size) {
        if (!size) return -1;
        const match = size.toLowerCase().match(/^([\d.]+)\s*([kmgt]?b(?:ytes)?)$/);
        if (!match) return -1;
        const [, value, unit] = match;
        const factors = { b: 0, bytes: 0, kb: 10, mb: 20, gb: 30, tb: 40 };
        return parseFloat(value) * Math.pow(2, factors[unit] || 0);
    }
 
    // 转换字节数为可读大小
    toSize(length) {
        if (length < 0) return "";
        const units = [[40, "TiB"], [30, "GiB"], [20, "MiB"], [10, "KiB"], [0, "Bytes"]];
        for (const [factor, unit] of units) {
            if (length >= Math.pow(2, factor)) {
                return (length / Math.pow(2, factor)).toFixed(unit === "Bytes" ? 0 : 3) + unit;
            }
        }
        return "0 Bytes";
    }
}
 
// 查找树中第一个分叉节点
function findFirstForkNode(tree) {
    const findForkInNode = (nodeId) => {
        const node = tree.get_node(nodeId);
        if (!node || !node.children) return null;
        if (node.children.length > 1) return node;
        if (node.children.length === 1) return findForkInNode(node.children[0]);
        return null;
    };
    return findForkInNode('#');
}
 
// 获取到指定节点的路径
function getPathToNode(tree, targetNode) {
    const path = [];
    let currentNode = targetNode;
    while (currentNode.id !== '#') {
        path.unshift(currentNode.id);
        currentNode = tree.get_node(tree.get_parent(currentNode));
    }
    return path;
}
 
// 获取第一个分叉点及其路径信息
function getFirstForkInfo(tree) {
    const firstFork = findFirstForkNode(tree);
    if (!firstFork) return null;
    
    const pathToFork = getPathToNode(tree, firstFork);
    const protectedNodes = new Set(pathToFork);
    protectedNodes.add(firstFork.id);
    
    return {
        fork: firstFork,
        pathToFork,
        protectedNodes
    };
}
 
// 智能折叠:只折叠分叉点以下的节点
function smartCollapse(tree, treeDepth) {
    // 如果是单层目录,直接使用普通折叠
    if (treeDepth <= 1) {
        tree.close_all();
        return;
    }
    
    const forkInfo = getFirstForkInfo(tree);
    if (!forkInfo) return;

    // 获取所有打开的节点
    const openNodes = tree.get_json('#', { flat: true })
        .filter(node => tree.is_open(node.id))
        .map(node => node.id);
    
    // 只折叠不在保护名单中的节点
    openNodes.forEach(nodeId => {
        if (!forkInfo.protectedNodes.has(nodeId)) {
            tree.close_node(nodeId);
        }
    });
}
 
// 检查文件树的最大层级
function checkTreeDepth(tree) {
    const getNodeDepth = (nodeId, currentDepth = 0) => {
        const node = tree.get_node(nodeId);
        // 如果节点不存在,或者是文件节点(没有子节点),返回当前深度
        if (!node || !node.children || node.children.length === 0) {
            return currentDepth - 1; // 文件节点不计入深度
        }
        return Math.max(...node.children.map(childId => 
            getNodeDepth(childId, currentDepth + 1)
        ));
    };
    return Math.max(0, getNodeDepth('#'));
}
 
// 主程序入口
(() => {
    // 设置样式
    setupCSS();
 
    // 创建树数据
    const data = new TreeNode($(".topic-title > h3").text());
    const pattern = /^(.+?) (\d+(?:\.\d+)?[TGMK]?B(?:ytes)?)$/;
 
    // 解析文件列表
    let unnamedCounter = 1;  // 添加计数器用于区分未命名文件

    $(".file_list:first > ul li").each(function() {
        const text = $(this).text().trim();
        const line = text.replace(/\t+/i, "\t").split("\t");
 
        if (line.length === 2) {
            // 标准格式:文件名和大小被制表符分隔
            data.insert(line[0].split("/"), line[1]);
        } else if (line.length === 1) {
            // 尝试解析可能的文件名和大小格式
            const match = pattern.exec(text);
            if (match) {
                // 成功匹配到文件名和大小
                data.insert(match[1].split("/"), match[2]);
            } else {
                // 检查是否只是一个文件大小
                const sizeMatch = /^\d+(?:\.\d+)?[TGMK]?B(?:ytes)?$/.test(text);
                if (sizeMatch) {
                    // 如果只是文件大小,使用带编号的占位符作为文件名
                    data.insert([`unknown (${unnamedCounter++})`], text);
                } else {
                    // 如果不是文件大小,则视为纯文件名
                    data.insert(text.split("/"), "");
                }
            }
        }
    });
 
    // 创建UI
    const fragment = document.createDocumentFragment();
    const treeContainer = $('<div class="tree-container"></div>').appendTo(fragment);
    
    const controlPanel = $('<div class="control-panel"></div>')
        .append($('<div class="control-panel-left"></div>')
            .append('<input type="text" id="search_input" placeholder="搜索文件..." />')
            .append('<button id="switch">展开全部</button>')
            .append($('<div class="sort-controls"></div>')
                .append('<button class="sort-btn" data-sort="name">名称<span class="sort-direction">↑</span></button>')
                .append('<button class="sort-btn" data-sort="size">大小<span class="sort-direction">↓</span></button>')
            )
        )
        .append($('<div class="control-panel-right"></div>')
            .append('<label class="smart-toggle"><input type="checkbox" id="smart_mode" />智能展开</label>')
        )
        .appendTo(treeContainer);
    
    const fileTree = $('<div id="file_tree"></div>').appendTo(treeContainer);
    $('.file_list:first').replaceWith(fragment);
 
    // 创建树实例
    const treeInstance = fileTree.jstree({
        core: { 
            data: data.toObject(),
            themes: { variant: "large" }
        },
        plugins: ["search", "wholerow", "contextmenu"],
        contextmenu: {
            select_node: false,
            show_at_node: false,
            items: {
                getText: {
                    label: "复制",
                    action: selected => {
                        const text = selected.reference.find(".filename").text();
                        navigator.clipboard.writeText(text);
                    }
                }
            }
        }
    });
 
    // 绑定事件
    treeInstance.on("ready.jstree", function() {
        const tree = treeInstance.jstree(true);
        const isSmartMode = localStorage.getItem('dmhy_smart_mode') !== 'false';
        
        if (isSmartMode) {
            const treeDepth = checkTreeDepth(tree);
            
            if (treeDepth > 1) {
                // 多层目录时执行智能展开
                const firstFork = findFirstForkNode(tree);
                if (firstFork) {
                    const pathToFork = getPathToNode(tree, firstFork);
                    pathToFork.forEach(nodeId => tree.open_node(nodeId));
                }
            } else {
                // 单层目录时全部展开
                tree.open_all();
            }
        }
    });
 
    treeInstance.on("loaded.jstree", function() {
        const tree = treeInstance.jstree(true);
        let isExpanded = false;
        let isSmartMode = localStorage.getItem('dmhy_smart_mode') !== 'false';
        let previousState = null;
        let hasSearched = false;
        let searchTimeout = null;
        let treeNodes = null;

        // 1. 更新展开/折叠按钮状态的函数
        const updateSwitchButton = () => {
            $("#switch").text(isExpanded ? "折叠全部" : "展开全部");
        };

        // 2. 绑定展开/折叠按钮事件
        $("#switch").click(function() {
            isExpanded = !isExpanded;
            const treeDepth = checkTreeDepth(tree);
            
            if (isSmartMode) {
                if (isExpanded) {
                    tree.open_all();
                } else {
                    if (treeDepth > 1) {
                        // 多层目录时使用智能折叠
                        smartCollapse(tree, treeDepth);
                    } else {
                        // 单层目录时全部折叠
                        tree.close_all();
                    }
                }
            } else {
                if (isExpanded) {
                    tree.open_all();
                } else {
                    tree.close_all();
                }
            }
            
            updateSwitchButton();
        });

        // 3. 绑定智能模式切换事件
        $("#smart_mode").prop('checked', isSmartMode).change(function() {
            isSmartMode = this.checked;
            localStorage.setItem('dmhy_smart_mode', isSmartMode);
            
            isExpanded = false;
            localStorage.setItem('dmhy_tree_expanded', isExpanded);
            
            if (isSmartMode) {
                tree.close_all();
                const firstFork = findFirstForkNode(tree);
                if (firstFork) {
                    const pathToFork = getPathToNode(tree, firstFork);
                    pathToFork.forEach(nodeId => tree.open_node(nodeId));
                }
            } else {
                tree.close_all();
            }
            
            updateSwitchButton();
        });

        // 4. 初始化排序
        const rootNode = tree.get_node('#');
        $('.sort-btn[data-sort="name"]').addClass('active').find('.sort-direction').text('↑');

        const sortNodes = (node, sortType, isAsc) => {
            if (node.children && node.children.length) {
                node.children.sort((a, b) => {
                    const nodeA = tree.get_node(a);
                    const nodeB = tree.get_node(b);
                    
                    // 文件夹始终排在前面
                    const isAFolder = nodeA.children.length > 0;
                    const isBFolder = nodeB.children.length > 0;
                    if (isAFolder !== isBFolder) {
                        return isAFolder ? -1 : 1;
                    }

                    let result = 0;
                    if (sortType === 'size') {
                        const sizeA = parseFloat(nodeA.text.match(/[\d.]+(?=[TGMK]iB|Bytes)/)) || 0;
                        const sizeB = parseFloat(nodeB.text.match(/[\d.]+(?=[TGMK]iB|Bytes)/)) || 0;
                        const unitA = nodeA.text.match(/[TGMK]iB|Bytes/)?.[0] || '';
                        const unitB = nodeB.text.match(/[TGMK]iB|Bytes/)?.[0] || '';
                        
                        const units = { 'TiB': 4, 'GiB': 3, 'MiB': 2, 'KiB': 1, 'Bytes': 0 };
                        const unitCompare = (units[unitA] || 0) - (units[unitB] || 0);
                        
                        result = unitCompare !== 0 ? unitCompare : sizeA - sizeB;
                    } else {
                        const nameA = nodeA.text.match(/class="filename">([^<]+)/)?.[1] || '';
                        const nameB = nodeB.text.match(/class="filename">([^<]+)/)?.[1] || '';
                        result = nameA.localeCompare(nameB, undefined, { numeric: true });
                    }
                    
                    return isAsc ? result : -result;
                });

                node.children.forEach(childId => {
                    sortNodes(tree.get_node(childId), sortType, isAsc);
                });
            }
        };

        // 执行初始排序(按文件名升序)
        sortNodes(rootNode, 'name', true);
        tree.redraw(true);

        // 绑定排序按钮事件
        $('.sort-btn').on('click', function() {
            const $this = $(this);
            const $direction = $this.find('.sort-direction');
            const sortType = $this.data('sort');
            
            if ($this.hasClass('active')) {
                $direction.text($direction.text() === '↑' ? '↓' : '↑');
            } else {
                $('.sort-btn').removeClass('active').find('.sort-direction').text('↓');
                $this.addClass('active');
            }
            
            const isAsc = $direction.text() === '↑';
            sortNodes(rootNode, sortType, isAsc);
            tree.redraw(true);
        });

        // 5. 初始化搜索功能
        treeNodes = tree.get_json('#', { flat: true });  // 缓存已排序的节点
        const searchDebounceTime = 250;

        $('#search_input').keyup(function() {
            if (searchTimeout) {
                clearTimeout(searchTimeout);
            }
            
            searchTimeout = setTimeout(() => {
                const searchText = $(this).val().toLowerCase();
                
                if (searchText) {
                    if (!hasSearched) {
                        previousState = {
                            isExpanded,
                            openNodes: treeNodes.filter(node => tree.is_open(node.id))
                                .map(node => node.id)
                        };
                        hasSearched = true;
                    }
                    
                    const matchedNodes = new Set();
                    treeNodes.forEach(node => {
                        const nodeText = tree.get_text(node.id).toLowerCase();
                        if (nodeText.includes(searchText)) {
                            matchedNodes.add(node.id);
                            let parent = tree.get_parent(node.id);
                            while (parent !== '#') {
                                matchedNodes.add(parent);
                                parent = tree.get_parent(parent);
                            }
                        }
                    });

                    const operations = [];
                    treeNodes.forEach(node => {
                        if (matchedNodes.has(node.id)) {
                            operations.push(() => {
                                tree.show_node(node.id);
                                tree.open_node(node.id);
                            });
                        } else {
                            operations.push(() => tree.hide_node(node.id));
                        }
                    });

                    const batchSize = 50;
                    const executeBatch = (startIndex) => {
                        const endIndex = Math.min(startIndex + batchSize, operations.length);
                        for (let i = startIndex; i < endIndex; i++) {
                            operations[i]();
                        }
                        if (endIndex < operations.length) {
                            requestAnimationFrame(() => executeBatch(endIndex));
                        }
                    };
                    executeBatch(0);
                    
                    isExpanded = true;
                } else {
                    if (previousState) {
                        tree.show_all();
                        tree.close_all();
                        
                        const restoreNodes = () => {
                            const batch = previousState.openNodes.splice(0, 50);
                            batch.forEach(nodeId => tree.open_node(nodeId, false));
                            if (previousState.openNodes.length > 0) {
                                requestAnimationFrame(restoreNodes);
                            }
                        };
                        restoreNodes();
                        
                        isExpanded = previousState.isExpanded;
                        previousState = null;
                        hasSearched = false;
                    }
                }
                
                updateSwitchButton();
            }, searchDebounceTime);
        });
    });
})();