/** * VoiceOfML Search — Frontend Application * MD3 Design System | SPA Router | Debounced Search | Keyboard Navigation * Left Sidebar: enter/exit browser navigation */ /* ═══════════════════════════════════════════════════════════ Constants ═══════════════════════════════════════════════════════════ */ const ORDERED_EXTENSIONS = [ "pdf", "txt", "epub", "mobi", "azw3", "fb2", "djvu", "chm", "caj", "doc", "docx", "odt", "rtf", "ppt", "xlsx", "jpg", "png", "gif", "tif", "html", "htm", "aspx", "css", "js", "xml", "mht", "mp4", "flv", "swf", "rm", "rmvb", "mp3", "wav", "iso", "dat", "exe", ]; const FILE_ICON_MAP = { pdf: "pdf", txt: "text", mht: "text", epub: "book", mobi: "book", azw3: "book", fb2: "book", djvu: "book", chm: "book", caj: "book", doc: "doc", docx: "doc", odt: "doc", rtf: "doc", ppt: "ppt", pptx: "ppt", pps: "ppt", xls: "xls", xlsx: "xls", csv: "csv", jpg: "image", jpeg: "image", png: "image", gif: "image", tif: "image", tiff: "image", bmp: "image", webp: "image", svg: "image", html: "code", htm: "code", aspx: "code", css: "code", js: "code", xml: "code", json: "code", ini: "code", bat: "code", mp4: "video", flv: "video", swf: "video", rm: "video", rmvb: "video", wmv: "video", mpg: "video", mts: "video", f4v: "video", asx: "video", mp3: "audio", wav: "audio", wma: "audio", ape: "audio", m4a: "audio", mpga: "audio", iso: "archive", msi: "archive", dat: "archive", exe: "file", db: "database", itf: "database", url: "text", vcf: "text", hhc: "text", md: "markdown", markdown: "markdown", }; const ICONS = { folder: '', pdf: '', doc: '', book: '', image: '', video: '', audio: '', archive: '', code: '', csv: '', markdown: '', text: '', ppt: '', xls: '', file: '', database: '', }; const MIRROR_HOST = "hf-mirror.com"; /* ═══════════════════════════════════════════════════════════ State ═══════════════════════════════════════════════════════════ */ const STATE = { mode: "global", repo: null, repoFull: null, query: "", page: 1, pageSize: 100, total: 0, results: [], didYouMean: null, filterRepos: [], filterExtensions: [], filterFolders: [], filterFolderSubtrees: [], filterFolderSelfs: [], filterMinSize: null, filterMaxSize: null, useMirrorLinks: true, leftSidebarOpen: true, rightSidebarOpen: false, isMobile: false, isDark: true, isLoading: false, hasMore: false, searchFolders: true, exact: true, recordHistory: true, resultsSkeletonActive: false, browserPath: "", repoList: [], extensionList: [], folderTree: null, folderTreeCollapsed: {}, folderNodeMap: {}, _pendingPage: 0, _loadedPage: 0, _pageCache: {}, }; let searchAbortController = null; let searchRequestId = 0; let scrollTicking = false; /* ═══════════════════════════════════════════════════════════ Size helpers (global — used by syncURLToState + syncStateToURL) ═══════════════════════════════════════════════════════════ */ function bytesToDisplay(bytes) { if (bytes === null || bytes === undefined || bytes === 0) return { value: "", unit: "MB" }; if (bytes >= 1073741824) return { value: (bytes / 1073741824).toFixed(2).replace(/\.?0+$/, ""), unit: "GB" }; if (bytes >= 1048576) return { value: (bytes / 1048576).toFixed(1).replace(/\.0$/, ""), unit: "MB" }; if (bytes >= 1024) return { value: (bytes / 1024).toFixed(1).replace(/\.0$/, ""), unit: "KB" }; return { value: String(bytes), unit: "B" }; } function fmtSizeUrl(bytes) { if (bytes === null || bytes === undefined) return null; var d = bytesToDisplay(bytes); return d.value + d.unit; } function parseSizeStr(str) { if (!str) return null; var m = String(str).match(/^([\d.]+)\s*(GB|MB|KB|B)?$/i); if (!m) return parseInt(str) || null; var val = parseFloat(m[1]); var unit = (m[2] || "B").toUpperCase(); if (unit === "GB") val *= 1073741824; else if (unit === "MB") val *= 1048576; else if (unit === "KB") val *= 1024; return Math.round(val); } /* ═══════════════════════════════════════════════════════════ Search History (localStorage) ═══════════════════════════════════════════════════════════ */ var HISTORY_KEY = "voml_search_history"; var HISTORY_MAX = 20; function getHistory() { try { return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; } catch (e) { return []; } } function saveHistory(list) { try { localStorage.setItem(HISTORY_KEY, JSON.stringify(list.slice(0, HISTORY_MAX))); } catch (e) {} } function addHistoryItem(q) { if (!q || !STATE.recordHistory) return; var list = getHistory(); var idx = list.indexOf(q); if (idx >= 0) list.splice(idx, 1); list.unshift(q); saveHistory(list); } function renderDropdown() { if (!DOM.historyDropdown) return; var list = getHistory(); if (list.length === 0) { DOM.historyDropdown.style.display = "none"; return; } var html = ""; for (var h = 0; h < list.length; h++) { html += '
' + '' + '' + escapeHTML(list[h]) + '' + '' + '
'; } html += ''; DOM.historyDropdown.innerHTML = html; DOM.historyDropdown.style.display = ""; } function removeHistoryItem(q) { var list = getHistory(); var idx = list.indexOf(q); if (idx >= 0) list.splice(idx, 1); saveHistory(list); renderDropdown(); } var selectedIndices = {}; var lastSelectedIndex = -1; function updateSelectionUI() { if (!DOM.multiSelectToggle || !DOM.multiActionBar) return; var count = Object.keys(selectedIndices).length; DOM.multiSelectedCount.textContent = count > 0 ? (STATE.isMobile ? "" : ("已选" + count + "项")) : ""; if (DOM.mobileSelectedCount) { DOM.mobileSelectedCount.textContent = count > 0 ? ("已选" + count) : ""; DOM.mobileSelectedCount.style.display = (STATE.isMobile && DOM.multiSelectToggle.checked && count > 0) ? "inline-block" : "none"; } DOM.multiActionBar.style.display = DOM.multiSelectToggle.checked ? "" : "none"; if (DOM.multiCopyLinks) DOM.multiCopyLinks.textContent = STATE.isMobile ? "复制" : "复制链接"; if (DOM.multiDeselect) DOM.multiDeselect.textContent = STATE.isMobile ? "取消" : "取消选择"; if (DOM.multiSelectToggle.checked) { document.body.classList.add("multiselect"); } else { document.body.classList.remove("multiselect"); selectedIndices = {}; lastSelectedIndex = -1; } var cbs = DOM.resultsList.querySelectorAll(".result-checkbox"); for (var ci = 0; ci < cbs.length; ci++) { var idx = parseInt(cbs[ci].dataset.index); cbs[ci].checked = !!selectedIndices[idx]; var item = cbs[ci].closest(".result-item"); if (item) item.classList.toggle("selected", !!selectedIndices[idx]); } } /* ═══════════════════════════════════════════════════════════ DOM References ═══════════════════════════════════════════════════════════ */ const $ = (sel) => document.querySelector(sel); const DOM = {}; function cacheDOM() { DOM.headerTitle = $("#header-title"); DOM.headerLogo = $("#header-logo"); DOM.searchInput = $("#search-input"); DOM.hamburgerBtn = $("#hamburger-btn"); DOM.settingsBtn = $("#settings-btn"); DOM.themeBtn = $("#theme-btn"); DOM.mobileToggleBtn = $("#mobile-toggle-btn"); DOM.themeIconLight = $("#theme-icon-light"); DOM.themeIconDark = $("#theme-icon-dark"); DOM.mobileIconPhone = $("#mobile-icon-phone"); DOM.mobileIconDesktop = $("#mobile-icon-desktop"); DOM.leftSidebar = $("#left-sidebar"); DOM.rightSidebar = $("#right-sidebar"); DOM.sidebarContent = $("#sidebar-content"); DOM.sidebarTitle = $("#sidebar-title"); DOM.resultsList = $("#results-list"); DOM.resultsContainer = $("#results-container"); DOM.resultsLoading = $("#results-loading"); DOM.emptyState = $("#empty-state"); DOM.emptyDesc = $("#empty-desc"); DOM.emptyRandomBtn = $("#empty-random-btn"); DOM.resultCount = $("#result-count"); DOM.didYouMean = $("#did-you-mean"); DOM.clearFiltersBtn = $("#clear-filters-btn"); DOM.sortSelect = $("#sort-select"); DOM.loadInfo = $("#load-info"); DOM.mobileSelectedCount = $("#mobile-selected-count"); DOM.loadedCount = $("#loaded-count"); DOM.totalCount = $("#total-count"); DOM.scrollTrack = $("#scroll-track"); DOM.scrollThumb = $("#scroll-thumb"); DOM.hitokoto = $("#hitokoto"); DOM.randomBookBtn = $("#random-book-btn"); DOM.overlay = $("#overlay"); DOM.toast = $("#toast"); DOM.filterRepoSection = $("#filter-repo-section"); DOM.filterRepoList = $("#filter-repo-list"); DOM.filterFolderSection = $("#filter-folder-section"); DOM.filterFolderTree = $("#filter-folder-tree"); DOM.filterExtList = $("#filter-ext-list"); DOM.filterMinSize = $("#filter-min-size"); DOM.filterMaxSize = $("#filter-max-size"); DOM.mirrorLinksToggle = $("#mirror-links-toggle"); DOM.filterMinUnit = $("#filter-min-unit"); DOM.filterMaxUnit = $("#filter-max-unit"); DOM.closeFiltersBtn = $("#close-filters-btn"); DOM.folderSelectAll = $("#folder-select-all"); DOM.folderDeselectAll = $("#folder-deselect-all"); DOM.extSelectAll = $("#ext-select-all"); DOM.extDeselectAll = $("#ext-deselect-all"); DOM.sidebarExpandBtn = $("#sidebar-expand-btn"); DOM.searchFoldersToggle = $("#search-folders-toggle"); DOM.exactSearchToggle = $("#exact-search-toggle"); DOM.historyToggle = $("#history-toggle"); DOM.historyDropdown = $("#search-history-dropdown"); DOM.multiToggleLabel = $("#multi-toggle-label"); DOM.multiSelectToggle = $("#multi-select-toggle"); DOM.multiActionBar = $("#multi-action-bar"); DOM.multiCopyLinks = $("#multi-copy-links"); DOM.multiBatchDownload = $("#multi-batch-download"); DOM.multiZipDownload = $("#multi-zip-download"); DOM.multiSelectAll = $("#multi-select-all"); DOM.multiSelectedCount = $("#multi-selected-count"); DOM.multiDeselect = $("#multi-deselect"); } /* ═══════════════════════════════════════════════════════════ Utilities ═══════════════════════════════════════════════════════════ */ const HTML_ESCAPE_MAP = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; function escapeHTML(str) { return String(str).replace(/[&<>"']/g, (ch) => HTML_ESCAPE_MAP[ch]); } const sizeCache = {}; function formatSize(bytes) { if (!bytes && bytes !== 0) return ""; if (sizeCache[bytes] !== undefined) return sizeCache[bytes]; if (typeof bytes === "string") bytes = parseInt(bytes); if (isNaN(bytes) || bytes === 0) return (sizeCache[bytes] = ""); let result; if (bytes < 1024) result = bytes + " B"; else if (bytes < 1048576) result = (bytes / 1024).toFixed(1) + " KB"; else if (bytes < 1073741824) result = (bytes / 1048576).toFixed(1) + " MB"; else result = (bytes / 1073741824).toFixed(2) + " GB"; return (sizeCache[bytes] = result); } function getFileIconType(ext) { const key = (ext || "").toLowerCase(); return FILE_ICON_MAP[key] || "file"; } function highlightText(text, query) { if (!query || !text) return escapeHTML(text); const escaped = escapeHTML(text); const tokens = query.split(/\s+/).filter((t) => t.length > 0); if (tokens.length === 0) return escaped; let result = escaped; for (const tok of tokens) { const escapedTok = escapeHTML(tok); const regex = new RegExp( `(${escapedTok.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi" ); result = result.replace(regex, "$1"); } return result; } function toMirrorURL(url) { if (!url) return url; try { const parsed = new URL(url, window.location.origin); if (parsed.hostname === "huggingface.co") parsed.hostname = MIRROR_HOST; return parsed.toString(); } catch (e) { return url; } } function getCopyableLink(link) { return STATE.useMirrorLinks ? toMirrorURL(link) : link; } function getPreviewLink(path) { return STATE.useMirrorLinks ? toMirrorURL(path) : path; } function buildDownloadUrl(filename, link) { return `/api/download?file=${encodeURIComponent(filename || "file")}&link=${encodeURIComponent(link || "")}`; } function getBrowserFileName(file) { const name = file && file.name ? String(file.name) : "file"; const ext = file && file.ext ? String(file.ext) : ""; if (!ext) return name; if (name.toLowerCase().endsWith(`.${ext.toLowerCase()}`)) return name; return `${name}.${ext}`; } function getBrowserFileLink(repo, folderPath, file) { if (file && file.link) return file.link; if (!repo) return ""; const fullName = getBrowserFileName(file); const relativePath = folderPath ? `${folderPath}/${fullName}` : fullName; return `https://huggingface.co/datasets/VoiceOfML/${repo}/resolve/main/${encodeURI(relativePath)}`; } /* ═══════════════════════════════════════════════════════════ API Client ═══════════════════════════════════════════════════════════ */ const API = { async search(params = {}) { const q = params.q || ""; const isRepo = !!STATE.repo; const base = isRepo ? `/api/search/${STATE.repo}` : "/api/search"; const body = {}; if (q) body.q = q; if (params.page) body.page = params.page; body.page_size = params.pageSize || STATE.pageSize; if (!isRepo && STATE.filterRepos.length > 0) body.repos = STATE.filterRepos; if (STATE.filterExtensions.length > 0) body.extensions = STATE.filterExtensions; if (STATE.filterFolderSelfs.length > 0 || STATE.filterFolderSubtrees.length > 0) { body.folders = STATE.filterFolderSelfs.concat(STATE.filterFolderSubtrees.filter(path => !STATE.filterFolderSelfs.includes(path))); body.folder_match_mode = "mixed"; body.folder_selfs = STATE.filterFolderSelfs; body.folder_subtrees = STATE.filterFolderSubtrees; } else if (STATE.filterFolders.length > 0) { body.folders = STATE.filterFolders; } if (STATE.filterMinSize !== null) body.min_size = STATE.filterMinSize; if (STATE.filterMaxSize !== null) body.max_size = STATE.filterMaxSize; body.sort = DOM.sortSelect.value; if (!STATE.searchFolders) body.search_folders = false; if (STATE.exact) body.exact = true; const fetchOptions = { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }; if (params.signal) fetchOptions.signal = params.signal; const resp = await fetch(base, fetchOptions); if (!resp.ok) throw new Error(`HTTP ${resp.status}`); return resp.json(); }, async getRepos() { const resp = await fetch("/api/repos"); return resp.json(); }, async getExtensions(repo) { const cacheKey = repo || "__global__"; if (this._extCache && this._extCache.key === cacheKey) return this._extCache.data; const url = repo ? `/api/extensions?repo=${repo}` : "/api/extensions"; let resp = await fetch(url); let data = await resp.json(); if ((!data || data.length === 0) && repo) { const fallbackResp = await fetch("/api/extensions"); data = await fallbackResp.json(); } this._extCache = { key: cacheKey, data }; return data; }, async getFolders(repo) { const resp = await fetch(`/api/folders/${repo}`); return resp.json(); }, async getContents(repo, path) { const cacheKey = repo + "|" + (path || ""); if (!this._browserCache) this._browserCache = new Map(); if (!this._browserPending) this._browserPending = new Map(); if (this._browserCache.has(cacheKey)) return this._browserCache.get(cacheKey); if (this._browserPending.has(cacheKey)) return this._browserPending.get(cacheKey); const qs = path ? `?path=${encodeURIComponent(path)}` : ""; const pending = fetch(`/api/folders/${repo}/contents${qs}`) .then(resp => resp.json()) .then(data => { this._browserPending.delete(cacheKey); this._browserCache.set(cacheKey, data); if (this._browserCache.size > 200) { const firstKey = this._browserCache.keys().next().value; this._browserCache.delete(firstKey); } return data; }) .catch(err => { this._browserPending.delete(cacheKey); throw err; }); this._browserPending.set(cacheKey, pending); return pending; }, async getRandom(repo) { const url = repo ? `/api/random?repo=${repo}` : "/api/random"; const resp = await fetch(url); return resp.json(); }, }; function scheduleBrowserRootPrefetch(repos) { const run = () => { for (const repo of repos) { const shortName = repo && repo.name ? repo.name.split("/").pop() : ""; if (shortName) API.getContents(shortName, "").catch(() => {}); } }; if (window.requestIdleCallback) window.requestIdleCallback(run, { timeout: 1500 }); else setTimeout(run, 200); } /* ═══════════════════════════════════════════════════════════ Router ═══════════════════════════════════════════════════════════ */ const ROUTER = { parse() { const path = window.location.pathname.replace(/\/+$/, ""); if (!path || path === "/") return { mode: "global", repo: null }; const parts = path.split("/").filter(Boolean); if (parts.length === 1) return { mode: "repo", repo: parts[0] }; return { mode: "global", repo: null }; }, navigate(mode, repo) { var params = new URLSearchParams(); if (STATE.query) params.set("q", STATE.query); if (STATE.filterExtensions.length) params.set("ext", STATE.filterExtensions.join(",")); if (STATE.filterMinSize !== null) params.set("min_size", fmtSizeUrl(STATE.filterMinSize)); if (STATE.filterMaxSize !== null) params.set("max_size", fmtSizeUrl(STATE.filterMaxSize)); if (!STATE.recordHistory) params.set("history", "0"); if (!STATE.useMirrorLinks) params.set("mirror", "0"); if (DOM.sortSelect.value !== "relevance") params.set("sort", DOM.sortSelect.value); if (!STATE.searchFolders) params.set("search_folders", "false"); if (!STATE.exact) params.set("exact", "0"); if (!STATE.leftSidebarOpen) params.set("sidebar", "0"); if (STATE.rightSidebarOpen) params.set("filters", "1"); if (DOM.leftSidebar.classList.contains("expanded-wide")) params.set("wide", "1"); var qs = params.toString(); var url = mode === "global" ? "/" : "/" + repo; if (qs) url += "?" + qs; history.pushState(null, "", url); this.apply(); }, apply() { const route = this.parse(); const prevMode = STATE.mode; const prevRepo = STATE.repo; STATE.mode = route.mode; STATE.repo = route.repo; STATE.repoFull = route.repo ? `VoiceOfML/${route.repo}` : null; if (prevMode !== STATE.mode || prevRepo !== STATE.repo) { STATE.page = 1; STATE.results = []; STATE.total = 0; STATE.filterFolders = []; STATE.filterFolderSubtrees = []; STATE.filterFolderSelfs = []; STATE.folderTree = null; STATE.folderTreeCollapsed = {}; STATE.browserPath = ""; API._extCache = null; syncURLToState(); } this.updateUI(); if (prevMode !== STATE.mode || prevRepo !== STATE.repo) { const wasWide = new URLSearchParams(window.location.search).get("wide") === "1"; this.onModeChanged(); if (wasWide) { DOM.leftSidebar.classList.add("expanded-wide"); DOM.sidebarExpandBtn.textContent = "→"; syncStateToURL(true); } } else { // 模式没变但 URL 参数变了(前进/后退) syncURLToState(); renderSidebar(); renderFilters(); doSearch(); } }, updateUI() { if (STATE.mode === "global") { DOM.headerTitle.textContent = "VoiceOfML"; DOM.headerLogo.href = "https://huggingface.co/VoiceOfML"; DOM.searchInput.placeholder = "搜索 VoiceOfML 数据仓库..."; DOM.sidebarTitle.textContent = "仓库列表"; } else { DOM.headerTitle.textContent = STATE.repo; DOM.headerLogo.href = "/"; DOM.searchInput.placeholder = `搜索 ${STATE.repo}...`; DOM.sidebarTitle.textContent = STATE.repo; } }, onModeChanged() { // 清除展开状态 DOM.leftSidebar.classList.remove("expanded-wide"); DOM.sidebarExpandBtn.textContent = "↔"; if (!STATE.isMobile) { DOM.resultsList.innerHTML = ""; DOM.emptyState.style.display = "none"; DOM.resultsLoading.style.display = "flex"; } renderSidebar(); renderFilters(); doSearch(); DOM.sidebarExpandBtn.style.display = (STATE.mode === "repo" && !STATE.isMobile) ? "" : "none"; }, }; // ── URL 同步 ────────────────────────────────────────── function syncStateToURL(replace = true) { const params = new URLSearchParams(); if (STATE.query) params.set("q", STATE.query); if (STATE.mode === "global") { STATE.filterRepos.forEach(r => { const short = r.split("/").pop(); params.append("repo", short); }); } if (STATE.filterExtensions.length > 0) { params.set("ext", STATE.filterExtensions.join(",")); } if (STATE.mode !== "global" && STATE.browserPath) { params.set("path", STATE.browserPath); } if (DOM.leftSidebar.classList.contains("expanded-wide")) { params.set("wide", "1"); } if (!STATE.leftSidebarOpen) params.set("sidebar", "0"); if (STATE.rightSidebarOpen) params.set("filters", "1"); if (DOM.sortSelect.value !== "relevance") { params.set("sort", DOM.sortSelect.value); } if (STATE.filterMinSize !== null) params.set("min_size", fmtSizeUrl(STATE.filterMinSize)); if (STATE.filterMaxSize !== null) params.set("max_size", fmtSizeUrl(STATE.filterMaxSize)); if (!STATE.searchFolders) params.set("search_folders", "false"); if (!STATE.exact) params.set("exact", "0"); if (!STATE.recordHistory) params.set("history", "0"); const qs = params.toString(); const path = STATE.mode === "global" ? "/" : `/${STATE.repo}`; const url = qs ? `${path}?${qs}` : path; if (replace) { history.replaceState(null, "", url); } else { history.pushState(null, "", url); } } function syncURLToState() { const params = new URLSearchParams(window.location.search); STATE.query = params.get("q") || ""; DOM.searchInput.value = STATE.query; STATE.filterRepos = params.getAll("repo").map(r => `VoiceOfML/${r}`); const extStr = params.get("ext") || ""; STATE.filterExtensions = extStr ? extStr.split(",").filter(Boolean) : []; STATE.browserPath = params.get("path") || ""; const sort = params.get("sort") || "relevance"; DOM.sortSelect.value = sort; const minSize = params.get("min_size"); if (minSize) { var parsed = parseSizeStr(minSize); STATE.filterMinSize = parsed; var disp = bytesToDisplay(parsed); DOM.filterMinSize.value = disp.value; DOM.filterMinUnit.value = disp.unit; } else { STATE.filterMinSize = null; DOM.filterMinSize.value = ""; DOM.filterMinUnit.value = "MB"; } const maxSize = params.get("max_size"); if (maxSize) { var parsedMx = parseSizeStr(maxSize); STATE.filterMaxSize = parsedMx; var dispMx = bytesToDisplay(parsedMx); DOM.filterMaxSize.value = dispMx.value; DOM.filterMaxUnit.value = dispMx.unit; } else { STATE.filterMaxSize = null; DOM.filterMaxSize.value = ""; DOM.filterMaxUnit.value = "MB"; } const searchFolders = params.get("search_folders"); STATE.searchFolders = searchFolders !== "false"; DOM.searchFoldersToggle.checked = STATE.searchFolders; STATE.exact = params.get("exact") !== "0"; DOM.exactSearchToggle.checked = STATE.exact; STATE.recordHistory = params.get("history") !== "0"; if (DOM.historyToggle) DOM.historyToggle.checked = STATE.recordHistory; STATE.useMirrorLinks = params.get("mirror") !== "0"; if (DOM.mirrorLinksToggle) DOM.mirrorLinksToggle.checked = STATE.useMirrorLinks; if (params.get("wide") === "1") { DOM.leftSidebar.classList.add("expanded-wide"); DOM.sidebarExpandBtn.textContent = "→"; } if (params.get("sidebar") === "0") { STATE.leftSidebarOpen = false; updateSidebarVisibility(); } STATE.rightSidebarOpen = params.get("filters") === "1"; updateSidebarVisibility(); STATE.page = 1; STATE.results = []; } /* ═══════════════════════════════════════════════════════════ Debounce & Search ═══════════════════════════════════════════════════════════ */ let searchTimer = null; async function renderSidebar() { if (STATE.mode === "global") { await renderRepoList(); } else { await renderBrowser(STATE.browserPath || ""); } } async function renderRepoList() { if (!STATE.repoList || STATE.repoList.length === 0) { try { STATE.repoList = await API.getRepos(); } catch (e) { DOM.sidebarContent.innerHTML = ''; return; } } let html = ""; for (const repo of STATE.repoList) { const shortName = repo.name.split("/").pop(); html += `
${escapeHTML(shortName)}${(repo.count || 0).toLocaleString()}
`; } DOM.sidebarContent.innerHTML = html; scheduleBrowserRootPrefetch(STATE.repoList || []); } async function renderBrowser(path) { STATE.browserPath = path; syncStateToURL(true); DOM.sidebarContent.innerHTML = ""; const currentRepo = STATE.repo; const backBtn = document.createElement("div"); backBtn.className = "back-to-global"; backBtn.innerHTML = '返回全局搜索'; backBtn.addEventListener("click", () => ROUTER.navigate("global")); DOM.sidebarContent.appendChild(backBtn); if (path) { const bc = document.createElement("div"); bc.className = "sidebar-breadcrumb"; const parts = path.split("/"); bc.innerHTML = `根目录` + parts.map((p, i) => { const pp = parts.slice(0, i + 1).join("/"); return `/${escapeHTML(p)}`; }).join(""); bc.querySelectorAll(".crumb-item").forEach(el => { el.addEventListener("click", () => { if (!el.classList.contains("current")) renderBrowser(el.dataset.path); }); }); DOM.sidebarContent.appendChild(bc); } const list = document.createElement("div"); list.className = "browser-list"; list.innerHTML = ''; DOM.sidebarContent.appendChild(list); try { const data = await API.getContents(STATE.repo, path); list.innerHTML = ""; for (const f of (data.folders || [])) { const div = document.createElement("div"); div.className = "browser-item"; div.innerHTML = `${ICONS.folder}${escapeHTML(f.name)}${(f.count || 0).toLocaleString()}`; div.addEventListener("click", () => renderBrowser(f.path)); list.appendChild(div); } for (const f of (data.files || [])) { const div = document.createElement("div"); div.className = "browser-item"; const iconType = getFileIconType(f.ext); const sizeStr = formatSize(f.size); div.innerHTML = `${ICONS[iconType] || ICONS.file}${escapeHTML(f.name)}${f.ext ? '.' + escapeHTML(f.ext) : ''}${sizeStr ? '' + sizeStr + '' : ''}${f.hasTxt ? '📖 阅读' : ''}`; div.addEventListener("click", (e) => { if (e.target.closest(".browser-action")) { e.stopPropagation(); const stem = f.ext ? (f.name) : f.name; const txtUrl = `/txt/${encodeURIComponent(path ? path + '/' + stem : stem)}.txt`; window.open(txtUrl, "_blank"); return; } const fileLink = getBrowserFileLink(currentRepo, path, f); if (fileLink) { const filename = getBrowserFileName(f); window.open(buildDownloadUrl(filename, fileLink), "_blank"); } }); list.appendChild(div); } } catch (e) { list.innerHTML = ''; } } function debouncedSearch() { clearTimeout(searchTimer); searchTimer = setTimeout(() => { STATE.query = DOM.searchInput.value.trim(); STATE.page = 1; STATE.results = []; addHistoryItem(STATE.query); renderDropdown(); doSearch(); }, 300); } function shouldShowResultsSkeleton(append = false) { if (append) return false; if (STATE.results.length > 0) return false; if (STATE.mode === "global") return true; return !STATE.isMobile && STATE.mode === "repo"; } function renderResultsSkeleton(count = 8) { let html = ""; for (let i = 0; i < count; i++) { html += ``; } DOM.resultsList.innerHTML = html; DOM.resultsList.classList.add("showing-skeleton"); } function clearResultsSkeleton() { DOM.resultsList.classList.remove("showing-skeleton"); } function animateResultsReveal() { DOM.resultsList.classList.remove("results-reveal"); void DOM.resultsList.offsetWidth; DOM.resultsList.classList.add("results-reveal"); requestAnimationFrame(() => { const items = DOM.resultsList.querySelectorAll(".result-item"); for (let i = 0; i < items.length; i++) { items[i].style.setProperty("--reveal-delay", `${Math.min(i * 28, 220)}ms`); } }); setTimeout(() => { DOM.resultsList.classList.remove("results-reveal"); }, 560); } /* ═══════════════════════════════════════════════════════════ Results Rendering ═══════════════════════════════════════════════════════════ */ function renderResults(newItems) { const items = newItems || STATE.results; if (!newItems) DOM.resultsList.innerHTML = ""; clearResultsSkeleton(); if (items.length === 0 && !newItems) { DOM.emptyState.style.display = "flex"; DOM.emptyDesc.textContent = STATE.query ? `没有找到与 "${STATE.query}" 相关的结果` : "暂无数据"; DOM.multiToggleLabel.style.display = "none"; DOM.multiActionBar.style.display = "none"; return; } DOM.emptyState.style.display = "none"; DOM.multiToggleLabel.style.display = ""; if (DOM.multiSelectToggle && DOM.multiSelectToggle.checked) updateSelectionUI(); if (!newItems && STATE.resultsSkeletonActive) animateResultsReveal(); var offset = newItems ? STATE.results.length - items.length : 0; const fragment = document.createDocumentFragment(); for (const rec of items) { const item = document.createElement("div"); item.className = "result-item"; const iconType = getFileIconType(rec.Extension); const titleHTML = highlightText(rec.File, STATE.query); const repoShort = (rec.Repo || "").split("/").pop(); const sizeStr = formatSize(rec.Size); const breadcrumb = (rec.Folder || []).map((f, i) => { const accum = (rec.Folder || []).slice(0, i + 1).join("/"); const folderDisplay = STATE.searchFolders ? highlightText(f, STATE.query) : escapeHTML(f); return `/${folderDisplay}`; }).join(""); var currentIdx = offset + fragment.children.length; item.innerHTML = `
${ICONS[iconType] || ICONS.file}
${titleHTML}${rec.Extension ? `.${escapeHTML(rec.Extension)}` : ""}
${repoShort}${breadcrumb}
${STATE.mode === "global" ? `${repoShort}` : ""} ${sizeStr ? `${sizeStr}` : ""}
下载 仓库查看 ${rec.HasTxt ? `` : ""}
`; fragment.appendChild(item); } DOM.resultsList.appendChild(fragment); } function updateStatusBar() { DOM.resultCount.textContent = STATE.total > 0 ? `共 ${STATE.total.toLocaleString()} 条结果` : ""; const has = STATE.filterRepos.length || STATE.filterExtensions.length || STATE.filterFolderSelfs.length || STATE.filterFolderSubtrees.length || STATE.filterMinSize !== null || STATE.filterMaxSize !== null; DOM.clearFiltersBtn.style.display = has ? "" : "none"; if (DOM.multiToggleLabel) DOM.multiToggleLabel.style.display = STATE.total > 0 ? "" : "none"; } function updateLoadInfo() { if (STATE.total === 0 && STATE.results.length === 0) { DOM.loadInfo.style.display = "none"; return; } DOM.loadInfo.style.display = ""; DOM.loadedCount.textContent = STATE.results.length.toLocaleString(); DOM.totalCount.textContent = STATE.total.toLocaleString(); } /* ═══════════════════════════════════════════════════════════ Prefetch next page (background, fire-and-forget) ═══════════════════════════════════════════════════════════ */ function prefetchNextPage() { var nextPage = STATE._loadedPage + 1; var totalPages = Math.ceil(STATE.total / STATE.pageSize); if (nextPage > totalPages) return; if (STATE._pageCache[nextPage]) return; var reqId = searchRequestId; var q = STATE.query || ""; var isRepo = !!STATE.repo; var base = isRepo ? "/api/search/" + STATE.repo : "/api/search"; var body = {}; if (q) body.q = q; body.page = nextPage; body.page_size = STATE.pageSize; if (!isRepo && STATE.filterRepos.length > 0) body.repos = STATE.filterRepos; if (STATE.filterExtensions.length > 0) body.extensions = STATE.filterExtensions; if (STATE.filterFolderSelfs.length > 0 || STATE.filterFolderSubtrees.length > 0) { body.folders = STATE.filterFolderSelfs.concat(STATE.filterFolderSubtrees.filter(path => !STATE.filterFolderSelfs.includes(path))); body.folder_match_mode = "mixed"; body.folder_selfs = STATE.filterFolderSelfs; body.folder_subtrees = STATE.filterFolderSubtrees; } else if (STATE.filterFolders.length > 0) { body.folders = STATE.filterFolders; } if (STATE.filterMinSize !== null) body.min_size = STATE.filterMinSize; if (STATE.filterMaxSize !== null) body.max_size = STATE.filterMaxSize; body.sort = DOM.sortSelect.value; if (!STATE.searchFolders) body.search_folders = false; if (STATE.exact) body.exact = true; fetch(base, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }).then(function(resp) { if (!resp.ok) return; return resp.json(); }).then(function(data) { if (reqId !== searchRequestId) return; if (data && data.results) { STATE._pageCache[nextPage] = data.results; } }).catch(function() {}); } /* ═══════════════════════════════════════════════════════════ Sidebar — Browser (Enter/Exit Navigation) ═══════════════════════════════════════════════════════════ */ async function doSearch(append = false) { // ── 1. 竞态控制 + 防重 ───────────────────────── // 新搜索时取消旧请求 if (!append) { selectedIndices = {}; lastSelectedIndex = -1; if (searchAbortController) searchAbortController.abort(); searchAbortController = new AbortController(); STATE._pageCache = {}; STATE._loadedPage = 0; STATE._pendingPage = 0; if (scrollTicking) { scrollTicking = false; } } // 防止同一页重复请求 if (append && STATE._pendingPage === STATE.page) return; // Cache hit: consume prefetched page directly, skip network if (append && STATE._pageCache[STATE.page]) { var cp = STATE.page; STATE.results = STATE.results.concat(STATE._pageCache[cp]); delete STATE._pageCache[cp]; STATE._loadedPage = cp; var np = cp + 1; while (STATE._pageCache[np]) { STATE.results = STATE.results.concat(STATE._pageCache[np]); delete STATE._pageCache[np]; np++; } STATE._loadedPage = np - 1; STATE.hasMore = STATE.results.length < STATE.total; STATE._pendingPage = 0; STATE.isLoading = false; var cachedItems = STATE.results.slice(-STATE.pageSize * (np - cp)); renderResults(cachedItems); updateStatusBar(); updateLoadInfo(); syncStateToURL(true); prefetchNextPage(); return; } STATE.isLoading = true; STATE._pendingPage = STATE.page; const currentRequestId = ++searchRequestId; const requestBody = { q: STATE.query, page: STATE.page, page_size: STATE.pageSize, sort: DOM.sortSelect.value, signal: searchAbortController.signal, }; if (STATE.mode === "global" && STATE.filterRepos.length > 0) requestBody.repos = STATE.filterRepos; if (STATE.filterExtensions.length > 0) requestBody.extensions = STATE.filterExtensions; if (STATE.filterFolderSelfs.length > 0 || STATE.filterFolderSubtrees.length > 0) { requestBody.folders = STATE.filterFolderSelfs.concat(STATE.filterFolderSubtrees.filter(path => !STATE.filterFolderSelfs.includes(path))); requestBody.folder_match_mode = "mixed"; requestBody.folder_selfs = STATE.filterFolderSelfs; requestBody.folder_subtrees = STATE.filterFolderSubtrees; } else if (STATE.filterFolders.length > 0) { requestBody.folders = STATE.filterFolders; } if (STATE.filterMinSize !== null) requestBody.min_size = STATE.filterMinSize; if (STATE.filterMaxSize !== null) requestBody.max_size = STATE.filterMaxSize; if (!STATE.searchFolders) requestBody.search_folders = false; if (STATE.exact) requestBody.exact = true; STATE.resultsSkeletonActive = shouldShowResultsSkeleton(append); DOM.resultsLoading.style.display = STATE.resultsSkeletonActive ? 'none' : 'flex'; if (!append) { DOM.emptyState.style.display = 'none'; DOM.didYouMean.style.display = 'none'; if (STATE.resultsSkeletonActive) { DOM.resultsContainer.scrollTop = 0; renderResultsSkeleton(); } else { clearResultsSkeleton(); } } try { const data = await API.search(requestBody); // 如果不是最新请求,直接丢弃 if (currentRequestId !== searchRequestId) return; STATE.total = data.total; STATE.didYouMean = data.did_you_mean || null; const newItems = data.results; if (append) { // ── 按序缓存 + 拼接 ────────────────────── STATE._pageCache[data.page] = newItems; let nextPage = STATE._loadedPage + 1; let combinedNewItems = []; while (STATE._pageCache[nextPage]) { const pageItems = STATE._pageCache[nextPage]; STATE.results = STATE.results.concat(pageItems); combinedNewItems = combinedNewItems.concat(pageItems); delete STATE._pageCache[nextPage]; nextPage++; } STATE._loadedPage = nextPage - 1; renderResults(combinedNewItems); } else { // 新搜索 STATE.results = newItems; STATE._loadedPage = 1; STATE._pageCache = {}; renderResults(null); } STATE.hasMore = STATE.results.length < STATE.total; updateStatusBar(); updateLoadInfo(); if (STATE.didYouMean) { DOM.didYouMean.textContent = `你是不是想找: ${STATE.didYouMean}`; DOM.didYouMean.style.display = 'inline'; } syncStateToURL(true); prefetchNextPage(); } catch (err) { if (err.name === 'AbortError') return; console.error('搜索失败:', err); showToast('搜索失败'); } finally { if (currentRequestId === searchRequestId) { STATE.isLoading = false; STATE.resultsSkeletonActive = false; DOM.resultsLoading.style.display = 'none'; } } } /* ═══════════════════════════════════════════════════════════ Filters (Right Sidebar) ═══════════════════════════════════════════════════════════ */ async function renderFilters() { // Repo filter if (STATE.mode === "global") { DOM.filterRepoSection.style.display = ""; if (!STATE.repoList || STATE.repoList.length === 0) try { STATE.repoList = await API.getRepos(); } catch (e) { } renderCheckboxList(DOM.filterRepoList, (STATE.repoList || []).map(r => ({ key: r.name, label: r.name.split("/").pop(), count: r.count })), STATE.filterRepos, (vals) => { STATE.filterRepos = vals; STATE.page = 1; STATE.results = []; doSearch(); }); } else { DOM.filterRepoSection.style.display = "none"; } // Folder tree if (STATE.mode === "repo") { DOM.filterFolderSection.style.display = ""; if (!STATE.folderTree) try { STATE.folderTree = normalizeFolderTreeRoots(await API.getFolders(STATE.repo)); STATE.folderTreeCollapsed = {}; STATE.folderNodeMap = buildFolderNodeMap(STATE.folderTree); initializeFolderTreeCollapsed(STATE.folderTree); } catch (e) { } renderFilterFolderTree(); } else { DOM.filterFolderSection.style.display = "none"; } // Extensions try { STATE.extensionList = await API.getExtensions(STATE.repo); } catch (e) { STATE.extensionList = []; } renderExtensionFilter(); } function renderExtensionFilter() { const allExt = STATE.extensionList || []; // Clean up extensions that don't exist in current repo if (STATE.filterExtensions.length > 0 && allExt.length > 0) { var availNames = {}; for (var axe = 0; axe < allExt.length; axe++) { availNames[allExt[axe].name] = true; } var cleaned = STATE.filterExtensions.filter(function(ext) { return availNames[ext]; }); if (cleaned.length !== STATE.filterExtensions.length) { STATE.filterExtensions = cleaned; STATE.page = 1; STATE.results = []; doSearch(); } } if (!Array.isArray(allExt) || allExt.length === 0) { DOM.filterExtList.innerHTML = '
暂无
'; return; } const ordered = []; const rest = []; for (const e of allExt) { if (!e || typeof e.name !== 'string') continue; const idx = ORDERED_EXTENSIONS.indexOf(e.name); if (idx >= 0) { ordered.push({ key: e.name, label: '.' + e.name, count: e.count || 0, _idx: idx }); } else { rest.push(e); } } ordered.sort((a, b) => a._idx - b._idx); const items = ordered.map(e => ({ key: e.key, label: e.label, count: e.count })); if (rest.length > 0) { const total = rest.reduce((s, e) => s + (e.count || 0), 0); items.push({ key: "__OTHER__", label: `其他 (${rest.length}种)`, count: total }); } renderCheckboxList(DOM.filterExtList, items, STATE.filterExtensions, (vals) => { STATE.filterExtensions = vals.filter(v => v !== "__OTHER__"); if (vals.includes("__OTHER__")) { for (const r of rest) STATE.filterExtensions.push(r.name); } STATE.page = 1; STATE.results = []; doSearch(); }); } function renderCheckboxList(container, items, selected, onChange) { if (items.length === 0) { container.innerHTML = '
暂无
'; container._itemsKey = ''; return; } const itemsKey = items.map(i => i.key).join(',') + '|' + items.map(i => i.count||0).join(','); if (container._itemsKey !== itemsKey) { container.innerHTML = items.map(item => ``).join(""); container._itemsKey = itemsKey; container._onChange = onChange; if (!container._hasDelegate) { container.addEventListener("change", () => { if (container._updating) return; container._onChange(Array.from(container.querySelectorAll("input:checked")).map(c => c.value)); }); container._hasDelegate = true; } } else { container._updating = true; container.querySelectorAll("input").forEach(cb => { cb.checked = selected.includes(cb.value); }); container._updating = false; } container._onChange = onChange; } function renderFilterFolderTree() { DOM.filterFolderTree.innerHTML = ""; if (!STATE.folderTree || STATE.folderTree.length === 0) { DOM.filterFolderTree.innerHTML = '
暂无目录
'; return; } renderFilterTreeNodes(DOM.filterFolderTree, STATE.folderTree, 0); } function buildFolderNodeMap(nodes, map = {}) { for (const node of nodes || []) { if (node.path) map[node.path] = node; if (node.children && node.children.length > 0) buildFolderNodeMap(node.children, map); } return map; } function collectVisibleDescendantPaths(node, into = []) { for (const child of node.children || []) { if (!child.path) continue; into.push(child.path); if (child.children && child.children.length > 0 && !STATE.folderTreeCollapsed[child.path]) { collectVisibleDescendantPaths(child, into); } } return into; } function toggleFolderChildrenAnimated(childContainer, toggle, expanding) { if (!childContainer || !toggle) return; childContainer.getAnimations().forEach((animation) => animation.cancel()); toggle.getAnimations().forEach((animation) => animation.cancel()); const glyph = toggle.querySelector(".tree-toggle-glyph"); if (glyph) glyph.getAnimations().forEach((animation) => animation.cancel()); const resetChildStyles = () => { childContainer.style.height = ""; childContainer.style.opacity = ""; childContainer.style.transform = ""; childContainer.style.overflow = ""; childContainer.style.transition = ""; }; const stopTransition = () => { if (childContainer._transitionCleanup) { childContainer.removeEventListener("transitionend", childContainer._transitionCleanup); childContainer._transitionCleanup = null; } if (childContainer._transitionTimer) { clearTimeout(childContainer._transitionTimer); childContainer._transitionTimer = null; } }; stopTransition(); resetChildStyles(); if (expanding) { childContainer.style.display = "block"; const targetHeight = childContainer.scrollHeight; childContainer.style.height = "0px"; childContainer.style.opacity = "0"; childContainer.style.transform = "translateY(-6px)"; childContainer.style.overflow = "hidden"; void childContainer.offsetHeight; childContainer.style.transition = "height 220ms cubic-bezier(0.22, 1, 0.36, 1), opacity 220ms cubic-bezier(0.22, 1, 0.36, 1), transform 220ms cubic-bezier(0.22, 1, 0.36, 1)"; childContainer.style.height = `${targetHeight}px`; childContainer.style.opacity = "1"; childContainer.style.transform = "translateY(0)"; childContainer._transitionCleanup = (event) => { if (event.target !== childContainer || event.propertyName !== "height") return; stopTransition(); resetChildStyles(); childContainer.style.display = "block"; }; childContainer.addEventListener("transitionend", childContainer._transitionCleanup); childContainer._transitionTimer = setTimeout(() => { if (childContainer._transitionCleanup) childContainer._transitionCleanup({ target: childContainer, propertyName: "height" }); }, 260); toggle.classList.add("expanded"); if (glyph) { glyph.animate([ { transform: "rotate(-45deg)" }, { transform: "rotate(45deg)" }, ], { duration: 220, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards", }); } return; } childContainer.style.display = "block"; const startHeight = childContainer.scrollHeight; childContainer.style.height = `${startHeight}px`; childContainer.style.opacity = "1"; childContainer.style.transform = "translateY(0)"; childContainer.style.overflow = "hidden"; void childContainer.offsetHeight; childContainer.style.transition = "height 190ms cubic-bezier(0.22, 1, 0.36, 1), opacity 190ms cubic-bezier(0.22, 1, 0.36, 1), transform 190ms cubic-bezier(0.22, 1, 0.36, 1)"; childContainer.style.height = "0px"; childContainer.style.opacity = "0"; childContainer.style.transform = "translateY(-6px)"; childContainer._transitionCleanup = (event) => { if (event.target !== childContainer || event.propertyName !== "height") return; stopTransition(); childContainer.style.display = "none"; resetChildStyles(); }; childContainer.addEventListener("transitionend", childContainer._transitionCleanup); childContainer._transitionTimer = setTimeout(() => { if (childContainer._transitionCleanup) childContainer._transitionCleanup({ target: childContainer, propertyName: "height" }); }, 230); toggle.classList.remove("expanded"); if (glyph) { glyph.animate([ { transform: "rotate(45deg)" }, { transform: "rotate(-45deg)" }, ], { duration: 190, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "forwards", }); } } function normalizeFolderTreeRoots(nodes) { const roots = Array.isArray(nodes) ? nodes : []; if (roots.length === 1) { const only = roots[0]; const hasChildren = Array.isArray(only.children) && only.children.length > 0; if ((only.isRoot || !only.path || only.path === STATE.repo || only.name === STATE.repo) && hasChildren) { return only.children; } } return roots; } function initializeFolderTreeCollapsed(nodes, depth = 0) { for (const node of nodes || []) { if (node.path && !(node.path in STATE.folderTreeCollapsed)) STATE.folderTreeCollapsed[node.path] = false; if (node.children && node.children.length > 0) initializeFolderTreeCollapsed(node.children, depth + 1); } } function getFolderSubtreeSet() { return new Set(STATE.filterFolderSubtrees || []); } function getFolderSelfSet() { return new Set(STATE.filterFolderSelfs || []); } function isNodeFullySelected(node, subtreeSet, selfSet) { if (!node) return false; if (node.isRoot) { const childNodes = node.children || []; if (childNodes.length === 0) return false; for (let i = 0; i < childNodes.length; i++) { if (!isNodeFullySelected(childNodes[i], subtreeSet, selfSet)) return false; } return true; } if (node.showSelfToggle && !selfSet.has(node.path)) return false; if (!node.hasChildren) { if (node.hasDirectFiles) return selfSet.has(node.path) || subtreeSet.has(node.path); return subtreeSet.has(node.path); } const childNodes = node.children || []; for (let i = 0; i < childNodes.length; i++) { if (!isNodeFullySelected(childNodes[i], subtreeSet, selfSet)) return false; } return !node.hasDirectFiles || selfSet.has(node.path); } function isNodePartiallySelected(node, subtreeSet, selfSet) { if (!node) return false; if (isNodeFullySelected(node, subtreeSet, selfSet)) return false; if (selfSet.has(node.path) || subtreeSet.has(node.path)) return true; const childNodes = node.children || []; for (let i = 0; i < childNodes.length; i++) { if (isNodeFullySelected(childNodes[i], subtreeSet, selfSet) || isNodePartiallySelected(childNodes[i], subtreeSet, selfSet)) return true; } return false; } function setNodeSubtreeSelection(node, enabled, subtreeSet, selfSet) { if (!node) return; if (!node.isRoot && enabled) subtreeSet.add(node.path); if (node.isRoot && !enabled) { subtreeSet.clear(); selfSet.clear(); } if (enabled) { if (node.hasDirectFiles && !node.isRoot) selfSet.add(node.path); const childNodes = node.children || []; for (let i = 0; i < childNodes.length; i++) { setNodeSubtreeSelection(childNodes[i], true, subtreeSet, selfSet); } return; } subtreeSet.delete(node.path); if (node.hasDirectFiles) selfSet.delete(node.path); const childNodes = node.children || []; for (let i = 0; i < childNodes.length; i++) { setNodeSubtreeSelection(childNodes[i], false, subtreeSet, selfSet); } } function persistFolderSelection(subtreeSet, selfSet) { STATE.filterFolderSubtrees = Array.from(subtreeSet); STATE.filterFolderSelfs = Array.from(selfSet); const merged = []; selfSet.forEach(path => { if (path) merged.push(path); }); subtreeSet.forEach(path => { if (path && !merged.includes(path)) merged.push(path); }); STATE.filterFolders = merged; STATE.page = 1; STATE.results = []; doSearch(); } function collectFolderNodePaths(nodes, subtreePaths, selfPaths) { for (let i = 0; i < nodes.length; i++) { const node = nodes[i]; if (!node.isRoot && node.path) subtreePaths.push(node.path); if (!node.isRoot && node.hasDirectFiles) selfPaths.push(node.path); if (node.children && node.children.length > 0) { collectFolderNodePaths(node.children, subtreePaths, selfPaths); } } } function applyFolderSelectionToNode(node, row, subtreeSet, selfSet) { const cb = row.querySelector("input[type='checkbox']"); if (!cb) return; const full = isNodeFullySelected(node, subtreeSet, selfSet); const partial = isNodePartiallySelected(node, subtreeSet, selfSet); cb.checked = full; cb.indeterminate = !full && partial; const selfBtn = row.querySelector(".folder-self-toggle"); if (selfBtn) { const selfOn = selfSet.has(node.path); selfBtn.classList.toggle("active", selfOn); selfBtn.setAttribute("aria-pressed", selfOn ? "true" : "false"); } } function renderFilterTreeNodes(container, nodes, depth) { const subtreeSet = getFolderSubtreeSet(); const selfSet = getFolderSelfSet(); for (const node of nodes) { const has = node.children && node.children.length > 0; const row = document.createElement("div"); row.className = "filter-folder-item"; row.style.setProperty("--fdepth", depth); row.dataset.path = node.path; const collapsed = !!STATE.folderTreeCollapsed[node.path]; row.innerHTML = `${has ? `` : ''}${escapeHTML(node.name)}${node.showSelfToggle ? `` : ''}${(node.count || 0).toLocaleString()}`; const toggle = row.querySelector(".tree-toggle"); const cb = row.querySelector("input"); const selfBtn = row.querySelector(".folder-self-toggle"); applyFolderSelectionToNode(node, row, subtreeSet, selfSet); cb.addEventListener("click", function(e) { e.preventDefault(); handleFolderCheckboxChange(node); }); if (selfBtn) { selfBtn.addEventListener("click", function(e) { e.preventDefault(); e.stopPropagation(); handleFolderSelfToggle(node); }); } if (has) { const childDiv = document.createElement("div"); childDiv.className = "tree-children"; if (collapsed) childDiv.style.display = "none"; renderFilterTreeNodes(childDiv, node.children, depth + 1); toggle.addEventListener("click", (e) => { e.stopPropagation(); const expanding = !!STATE.folderTreeCollapsed[node.path]; STATE.folderTreeCollapsed[node.path] = !expanding; toggleFolderChildrenAnimated(childDiv, toggle, expanding); }); container.appendChild(row); container.appendChild(childDiv); } else { container.appendChild(row); } } } function handleFolderCheckboxChange(node) { const subtreeSet = getFolderSubtreeSet(); const selfSet = getFolderSelfSet(); const full = isNodeFullySelected(node, subtreeSet, selfSet); setNodeSubtreeSelection(node, !full, subtreeSet, selfSet); persistFolderSelection(subtreeSet, selfSet); renderFilterFolderTree(); } function handleFolderSelfToggle(node) { const subtreeSet = getFolderSubtreeSet(); const selfSet = getFolderSelfSet(); if (selfSet.has(node.path)) selfSet.delete(node.path); else selfSet.add(node.path); persistFolderSelection(subtreeSet, selfSet); renderFilterFolderTree(); } /* ═══════════════════════════════════════════════════════════ Hitokoto ═══════════════════════════════════════════════════════════ */ async function fetchHitokoto() { try { const resp = await fetch("https://vomebook-hitokoto.hf.space/"); const data = await resp.json(); const text = data.hitokoto || data.text || data.content || data.sentence || ""; if (text) typewriter(DOM.hitokoto, text); } catch (e) { DOM.hitokoto.textContent = ""; } } function typewriter(el, text, speed = 60) { el.style.opacity = "0"; el.textContent = ""; setTimeout(() => { el.style.transition = "opacity 0.5s ease"; el.style.opacity = "0.55"; }, 100); let i = 0; const t = setInterval(() => { el.textContent = text.slice(0, i + 1); if (++i >= text.length) clearInterval(t); }, speed); } /* ═══════════════════════════════════════════════════════════ Random Book ═══════════════════════════════════════════════════════════ */ async function randomBook() { try { const rec = await API.getRandom(STATE.repo); const link = rec?.Link || rec?.link || ""; if (link) { const filename = (rec.File || "file") + (rec.Extension ? '.' + rec.Extension : ''); window.open(buildDownloadUrl(filename, link), "_blank"); } else { showToast("暂无可用记录"); } } catch (e) { console.error(e); showToast("获取失败"); } } /* ═══════════════════════════════════════════════════════════ Toast ═══════════════════════════════════════════════════════════ */ let toastTimer; function showToast(msg, dur = 2000) { DOM.toast.textContent = msg; DOM.toast.style.display = ""; DOM.toast.style.animation = "none"; void DOM.toast.offsetWidth; DOM.toast.style.animation = "toast-in 0.2s ease"; clearTimeout(toastTimer); toastTimer = setTimeout(() => { DOM.toast.style.display = "none"; }, dur); } /* ═══════════════════════════════════════════════════════════ Infinite Scroll & Quick Scroll ═══════════════════════════════════════════════════════════ */ scrollTicking = false; function setupInfiniteScroll() { DOM.resultsContainer.addEventListener("scroll", () => { if (!scrollTicking) { requestAnimationFrame(() => { updateScrollThumb(); if (!STATE.isLoading && STATE.hasMore) { const { scrollTop, scrollHeight, clientHeight } = DOM.resultsContainer; const loadedHeight = DOM.resultsList.scrollHeight; const triggerPoint = loadedHeight * 0.05; if (scrollTop >= triggerPoint) { STATE.page++; doSearch(true); } } scrollTicking = false; }); scrollTicking = true; } }); } function updateScrollThumb() { const { scrollTop, scrollHeight, clientHeight } = DOM.resultsContainer; if (scrollHeight <= clientHeight) { DOM.scrollTrack.classList.remove("visible"); return; } DOM.scrollTrack.classList.add("visible"); const th = Math.max(30, (clientHeight / scrollHeight) * DOM.scrollTrack.clientHeight); const tt = (scrollTop / (scrollHeight - clientHeight)) * (DOM.scrollTrack.clientHeight - th); DOM.scrollThumb.style.height = th + "px"; DOM.scrollThumb.style.top = tt + "px"; } function setupQuickScroll() { let dragging = false, startY, startST; DOM.scrollThumb.addEventListener("mousedown", (e) => { dragging = true; startY = e.clientY; startST = DOM.resultsContainer.scrollTop; e.preventDefault(); }); document.addEventListener("mousemove", (e) => { if (!dragging) return; const delta = e.clientY - startY; const ratio = delta / (DOM.scrollTrack.clientHeight - DOM.scrollThumb.clientHeight); DOM.resultsContainer.scrollTop = startST + ratio * (DOM.resultsContainer.scrollHeight - DOM.resultsContainer.clientHeight); }); document.addEventListener("mouseup", () => { dragging = false; }); DOM.scrollThumb.addEventListener("touchstart", (e) => { dragging = true; startY = e.touches[0].clientY; startST = DOM.resultsContainer.scrollTop; }); document.addEventListener("touchmove", (e) => { if (!dragging) return; const delta = e.touches[0].clientY - startY; const ratio = delta / (DOM.scrollTrack.clientHeight - DOM.scrollThumb.clientHeight); DOM.resultsContainer.scrollTop = startST + ratio * (DOM.resultsContainer.scrollHeight - DOM.resultsContainer.clientHeight); }); document.addEventListener("touchend", () => { dragging = false; }); } /* ═══════════════════════════════════════════════════════════ Theme & Mobile ═══════════════════════════════════════════════════════════ */ function toggleTheme() { const btn = DOM.themeBtn; const rect = btn.getBoundingClientRect(); const ripple = document.createElement("div"); ripple.className = "theme-ripple"; ripple.style.left = (rect.left + rect.width / 2) + "px"; ripple.style.top = (rect.top + rect.height / 2) + "px"; ripple.style.background = STATE.isDark ? "#fff" : "#1a1c1e"; ripple.style.marginLeft = "-0px"; ripple.style.marginTop = "-0px"; document.body.appendChild(ripple); document.body.classList.add("theme-transitioning"); STATE.isDark = !STATE.isDark; applyTheme(); localStorage.setItem("theme", STATE.isDark ? "dark" : "light"); ripple.addEventListener("animationend", () => { ripple.remove(); document.body.classList.remove("theme-transitioning"); }); } function applyTheme() { if (STATE.isDark) { document.body.classList.remove("light"); DOM.themeIconLight.style.display = "none"; DOM.themeIconDark.style.display = ""; } else { document.body.classList.add("light"); DOM.themeIconLight.style.display = ""; DOM.themeIconDark.style.display = "none"; } } function toggleMobile() { STATE.isMobile = !STATE.isMobile; applyMobileMode(); localStorage.setItem("mobileMode", STATE.isMobile ? "mobile" : "desktop"); } function applyMobileMode() { if (STATE.isMobile) { document.body.classList.add("mobile"); document.body.classList.remove("force-desktop"); DOM.mobileIconPhone.style.display = ""; DOM.mobileIconDesktop.style.display = "none"; STATE.leftSidebarOpen = false; STATE.rightSidebarOpen = false; } else { document.body.classList.remove("mobile"); document.body.classList.add("force-desktop"); DOM.mobileIconPhone.style.display = "none"; DOM.mobileIconDesktop.style.display = ""; STATE.leftSidebarOpen = true; STATE.rightSidebarOpen = false; } updateSidebarVisibility(); if (DOM.sidebarExpandBtn) DOM.sidebarExpandBtn.style.display = (STATE.mode === "repo" && !STATE.isMobile) ? "" : "none"; updateSelectionUI(); } function autoDetectMobile() { return window.innerWidth <= 768; } /* ═══════════════════════════════════════════════════════════ Sidebar Visibility ═══════════════════════════════════════════════════════════ */ function toggleLeftSidebar() { STATE.leftSidebarOpen = !STATE.leftSidebarOpen; if (!STATE.leftSidebarOpen) { DOM.leftSidebar.classList.remove("expanded-wide"); DOM.sidebarExpandBtn.textContent = "↔"; } syncStateToURL(true); if (STATE.isMobile && STATE.leftSidebarOpen && STATE.rightSidebarOpen) STATE.rightSidebarOpen = false; updateSidebarVisibility(); } function toggleRightSidebar() { STATE.rightSidebarOpen = !STATE.rightSidebarOpen; if (STATE.rightSidebarOpen && STATE.leftSidebarOpen && STATE.isMobile) STATE.leftSidebarOpen = false; updateSidebarVisibility(); syncStateToURL(true); } function updateSidebarVisibility() { DOM.leftSidebar.classList.toggle("collapsed", !STATE.leftSidebarOpen); DOM.leftSidebar.classList.toggle("open", STATE.leftSidebarOpen); DOM.rightSidebar.classList.toggle("collapsed", !STATE.rightSidebarOpen); DOM.rightSidebar.classList.toggle("open", STATE.rightSidebarOpen); DOM.overlay.style.display = (STATE.isMobile && (STATE.leftSidebarOpen || STATE.rightSidebarOpen)) ? "" : "none"; } /* ═══════════════════════════════════════════════════════════ Keyboard ═══════════════════════════════════════════════════════════ */ let keyboardResultIndex = -1; function setupKeyboard() { document.addEventListener("keydown", (e) => { const tag = document.activeElement.tagName; const isInput = tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT"; if (e.key === "/" && !isInput) { e.preventDefault(); DOM.searchInput.focus(); DOM.searchInput.select(); return; } if (e.key === "Escape") { if (STATE.rightSidebarOpen || STATE.leftSidebarOpen) { STATE.leftSidebarOpen = false; STATE.rightSidebarOpen = false; updateSidebarVisibility(); return; } if (DOM.searchInput.value) { DOM.searchInput.value = ""; STATE.query = ""; STATE.page = 1; STATE.results = []; doSearch(); return; } DOM.searchInput.blur(); return; } if (e.key === "b" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); toggleLeftSidebar(); return; } if (e.key === "ArrowDown" || e.key === "ArrowUp") { if (STATE.results.length === 0) return; e.preventDefault(); const items = DOM.resultsList.querySelectorAll(".result-item"); if (items.length === 0) return; items.forEach(it => it.style.background = ""); if (e.key === "ArrowDown") keyboardResultIndex = Math.min(keyboardResultIndex + 1, items.length - 1); else keyboardResultIndex = Math.max(keyboardResultIndex - 1, 0); items[keyboardResultIndex].style.background = "var(--surface-variant)"; items[keyboardResultIndex].scrollIntoView({ block: "nearest" }); return; } if (e.key === "Enter") { if (isInput && document.activeElement === DOM.searchInput) { e.preventDefault(); STATE.query = DOM.searchInput.value.trim(); STATE.page = 1; STATE.results = []; keyboardResultIndex = -1; addHistoryItem(STATE.query); doSearch(); DOM.searchInput.blur(); return; } if (keyboardResultIndex >= 0 && keyboardResultIndex < STATE.results.length) { const rec = STATE.results[keyboardResultIndex]; if (rec && rec.Link) window.open(rec.Link, "_blank"); return; } } }); DOM.searchInput.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); STATE.query = DOM.searchInput.value.trim(); STATE.page = 1; STATE.results = []; keyboardResultIndex = -1; doSearch(); DOM.searchInput.blur(); } }); } /* ═══════════════════════════════════════════════════════════ Clear Filters ═══════════════════════════════════════════════════════════ */ function clearAllFilters() { STATE.filterRepos = []; STATE.filterExtensions = []; STATE.filterFolders = []; STATE.filterFolderSubtrees = []; STATE.filterFolderSelfs = []; STATE.filterMinSize = null; STATE.filterMaxSize = null; STATE.page = 1; STATE.results = []; DOM.filterMinSize.value = ""; DOM.filterMaxSize.value = ""; DOM.filterMinUnit.value = "MB"; DOM.filterMaxUnit.value = "MB"; renderFilters(); doSearch(); showToast("已清空所有筛选条件"); syncStateToURL(true); } /* ═══════════════════════════════════════════════════════════ Event Delegation ═══════════════════════════════════════════════════════════ */ function setupResultDelegation() { DOM.resultsList.addEventListener("click", async (e) => { const actionBtn = e.target.closest("[data-action]"); if (actionBtn) { e.preventDefault(); const action = actionBtn.dataset.action; if (action === "copy") { try { await navigator.clipboard.writeText(actionBtn.dataset.link); showToast("链接已复制"); } catch { showToast("复制失败"); } return; } if (action === "read") { const link = actionBtn.dataset.link; const repoShort = actionBtn.dataset.repo || STATE.repo; const prefix = `https://huggingface.co/datasets/VoiceOfML/${repoShort}/resolve/main/`; let relPath = ""; if (link.startsWith(prefix)) relPath = decodeURIComponent(link.slice(prefix.length)); let stem = relPath; if (stem.includes(".")) { const lastDot = stem.lastIndexOf("."); const slashAfterDot = stem.indexOf("/", lastDot); if (slashAfterDot === -1) stem = stem.substring(0, lastDot); } window.open(`/txt/${encodeURI(stem)}.txt`, "_blank"); return; } } const repoTag = e.target.closest(".result-repo-tag"); if (repoTag) { ROUTER.navigate("repo", repoTag.dataset.repo); return; } const folderLink = e.target.closest(".path-folder"); if (folderLink) { const folder = folderLink.dataset.folder; const repo = folderLink.dataset.repo; if (repo && STATE.mode === "global") { ROUTER.navigate("repo", repo); } if (folder !== undefined) { STATE.filterFolders = folder ? [folder] : []; STATE.filterFolderSubtrees = []; STATE.filterFolderSelfs = folder ? [folder] : []; STATE.page = 1; STATE.results = []; renderFilters(); doSearch(); } return; } }); DOM.sidebarContent.addEventListener("click", (e) => { const repoItem = e.target.closest(".repo-list-item"); if (repoItem) { ROUTER.navigate("repo", repoItem.dataset.repo); } }); } /* ═══════════════════════════════════════════════════════════ Init ═══════════════════════════════════════════════════════════ */ async function init() { cacheDOM(); STATE.isDark = localStorage.getItem("theme") !== "light"; applyTheme(); const savedMobile = localStorage.getItem("mobileMode"); if (savedMobile === "mobile") STATE.isMobile = true; else if (savedMobile === "desktop") STATE.isMobile = false; else STATE.isMobile = autoDetectMobile(); applyMobileMode(); DOM.searchInput.addEventListener("input", debouncedSearch); const renderDropdown = () => { var list = getHistory(); if (list.length === 0) { DOM.historyDropdown.style.display = "none"; return; } var html = ""; for (var h = 0; h < list.length; h++) { html += '
' + '' + '' + escapeHTML(list[h]) + '' + '' + '
'; } html += ''; DOM.historyDropdown.innerHTML = html; DOM.historyDropdown.style.display = ""; }; const hideDropdown = () => { setTimeout(() => { if (!dropdownActive) DOM.historyDropdown.style.display = "none"; }, 150); }; var dropdownActive = false; DOM.searchInput.addEventListener("focus", () => { renderDropdown(); }); DOM.searchInput.addEventListener("blur", hideDropdown); var longPressTimer = null; DOM.historyDropdown.addEventListener("mouseenter", () => { dropdownActive = true; }); DOM.historyDropdown.addEventListener("mouseleave", () => { dropdownActive = false; }); DOM.historyDropdown.addEventListener("mousedown", (e) => { if (e.target.closest(".history-del")) return; if (e.target.closest(".history-clear-all")) { saveHistory([]); DOM.historyDropdown.style.display = "none"; return; } var item = e.target.closest(".history-item"); if (item) longPressTimer = setTimeout(() => { removeHistoryItem(item.dataset.query); }, 600); }); DOM.historyDropdown.addEventListener("mouseup", () => { clearTimeout(longPressTimer); }); DOM.historyDropdown.addEventListener("mouseleave", () => { clearTimeout(longPressTimer); }); DOM.historyDropdown.addEventListener("touchstart", (e) => { if (e.target.closest(".history-del")) return; var item = e.target.closest(".history-item"); if (item) longPressTimer = setTimeout(() => { removeHistoryItem(item.dataset.query); }, 600); }, { passive: true }); DOM.historyDropdown.addEventListener("touchend", () => { clearTimeout(longPressTimer); }); DOM.historyDropdown.addEventListener("touchmove", () => { clearTimeout(longPressTimer); }); DOM.historyDropdown.addEventListener("click", (e) => { var delBtn = e.target.closest(".history-del"); if (delBtn) { removeHistoryItem(delBtn.dataset.del); return; } var item = e.target.closest(".history-item"); if (item) { DOM.searchInput.value = item.dataset.query; STATE.query = item.dataset.query; STATE.page = 1; STATE.results = []; doSearch(); hideDropdown(); DOM.searchInput.blur(); return; } }); DOM.historyToggle.addEventListener("change", () => { STATE.recordHistory = DOM.historyToggle.checked; if (!STATE.recordHistory) saveHistory([]); }); if (DOM.mirrorLinksToggle) DOM.mirrorLinksToggle.addEventListener("change", () => { STATE.useMirrorLinks = DOM.mirrorLinksToggle.checked; syncStateToURL(true); if (STATE.results.length > 0) renderResults(); }); // Multi-select if (DOM.multiSelectToggle) DOM.multiSelectToggle.addEventListener("change", updateSelectionUI); DOM.resultsList.addEventListener("click", (e) => { if (!DOM.multiSelectToggle || !DOM.multiSelectToggle.checked) return; var cb = e.target.closest(".result-checkbox"); if (!cb) return; e.stopPropagation(); var idx = parseInt(cb.dataset.index); if (e.shiftKey && lastSelectedIndex >= 0) { var lo = Math.min(lastSelectedIndex, idx); var hi = Math.max(lastSelectedIndex, idx); for (var si = lo; si <= hi; si++) selectedIndices[si] = true; } else if (cb.checked) { selectedIndices[idx] = true; } else { delete selectedIndices[idx]; } lastSelectedIndex = idx; updateSelectionUI(); }); if (DOM.multiCopyLinks) DOM.multiCopyLinks.addEventListener("click", () => { var indices = Object.keys(selectedIndices).map(Number); var links = []; for (var li = 0; li < indices.length; li++) { var rec = STATE.results[indices[li]]; if (rec && rec.Link) links.push(getCopyableLink(rec.Link)); } if (links.length === 0) { showToast("未选中任何文件"); return; } navigator.clipboard.writeText(links.join("\n")).then(() => showToast("已复制 " + links.length + " 条链接")).catch(() => showToast("复制失败")); }); if (DOM.multiBatchDownload) DOM.multiBatchDownload.addEventListener("click", () => { var indices = Object.keys(selectedIndices).map(Number); if (indices.length === 0) { showToast("未选中任何文件"); return; } for (var bi = 0; bi < indices.length; bi++) { var rec = STATE.results[indices[bi]]; var filename = (rec.File || "file") + (rec.Extension ? "." + rec.Extension : ""); var proxyUrl = buildDownloadUrl(filename, rec.Link); setTimeout((u) => window.open(u, "_blank"), bi * 300, proxyUrl); } showToast("正在打开 " + indices.length + " 个下载"); }); if (DOM.multiZipDownload) DOM.multiZipDownload.addEventListener("click", () => { var indices = Object.keys(selectedIndices).map(Number); if (indices.length === 0) { showToast("未选中任何文件"); return; } showToast("正在打包 " + indices.length + " 个文件..."); var files = []; for (var zi = 0; zi < indices.length; zi++) { var rec = STATE.results[indices[zi]]; files.push({ name: rec.File + (rec.Extension ? "." + rec.Extension : ""), link: rec.Link }); } console.log("[zip] requesting", files.length, "files"); fetch("/api/zip", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ files: files }), }).then(resp => { if (!resp.ok) throw new Error("HTTP " + resp.status); return resp.blob(); }).then(blob => { var a = document.createElement("a"); a.href = URL.createObjectURL(blob); a.download = "voml_batch_" + Date.now() + ".zip"; a.click(); URL.revokeObjectURL(a.href); }).catch(e => { console.error(e); showToast("合并下载失败"); }); }); if (DOM.multiDeselect) DOM.multiDeselect.addEventListener("click", () => { selectedIndices = {}; lastSelectedIndex = -1; updateSelectionUI(); }); if (DOM.multiSelectAll) DOM.multiSelectAll.addEventListener("click", () => { for (var si = 0; si < STATE.results.length; si++) selectedIndices[si] = true; lastSelectedIndex = STATE.results.length > 0 ? STATE.results.length - 1 : -1; updateSelectionUI(); }); DOM.hamburgerBtn.addEventListener("click", toggleLeftSidebar); DOM.settingsBtn.addEventListener("click", toggleRightSidebar); DOM.closeFiltersBtn.addEventListener("click", () => { STATE.rightSidebarOpen = false; updateSidebarVisibility(); syncStateToURL(true); }); DOM.sidebarExpandBtn.addEventListener("click", () => { DOM.leftSidebar.classList.toggle("expanded-wide"); DOM.sidebarExpandBtn.textContent = DOM.leftSidebar.classList.contains("expanded-wide") ? "→" : "↔"; syncStateToURL(true); }); DOM.themeBtn.addEventListener("click", toggleTheme); DOM.mobileToggleBtn.addEventListener("click", toggleMobile); DOM.clearFiltersBtn.addEventListener("click", clearAllFilters); DOM.searchFoldersToggle.addEventListener("change", () => { STATE.searchFolders = DOM.searchFoldersToggle.checked; STATE.page = 1; STATE.results = []; doSearch(); }); DOM.exactSearchToggle.addEventListener("change", () => { STATE.exact = DOM.exactSearchToggle.checked; STATE.page = 1; STATE.results = []; doSearch(); }); DOM.sortSelect.addEventListener("change", () => { STATE.page = 1; STATE.results = []; doSearch(); syncStateToURL(true); }); DOM.overlay.addEventListener("click", () => { STATE.leftSidebarOpen = false; STATE.rightSidebarOpen = false; updateSidebarVisibility(); syncStateToURL(true); }); DOM.randomBookBtn.addEventListener('click', randomBook); DOM.emptyRandomBtn.addEventListener('click', randomBook); let sizeTimer; const sizeInputToBytes = (input, unitSelect) => { var val = parseFloat(input.value); if (isNaN(val) || val < 0) return null; var unit = unitSelect.value; if (unit === "KB") val *= 1024; else if (unit === "MB") val *= 1048576; else if (unit === "GB") val *= 1073741824; return Math.round(val); }; const applySizeFilter = () => { clearTimeout(sizeTimer); sizeTimer = setTimeout(() => { STATE.filterMinSize = sizeInputToBytes(DOM.filterMinSize, DOM.filterMinUnit); STATE.filterMaxSize = sizeInputToBytes(DOM.filterMaxSize, DOM.filterMaxUnit); STATE.page = 1; STATE.results = []; doSearch(); }, 500); }; DOM.filterMinSize.addEventListener("input", applySizeFilter); DOM.filterMaxSize.addEventListener("input", applySizeFilter); DOM.filterMinUnit.addEventListener("change", applySizeFilter); DOM.filterMaxUnit.addEventListener("change", applySizeFilter); DOM.extSelectAll.addEventListener("click", () => { STATE.filterExtensions = (STATE.extensionList || []).map(e => e.name); STATE.page = 1; STATE.results = []; doSearch(); renderExtensionFilter(); }); DOM.extDeselectAll.addEventListener("click", () => { const allExtNames = (STATE.extensionList || []).map(e => e.name); const currentSet = new Set(STATE.filterExtensions); // 反选:当前选中的取消,未选中的选中 STATE.filterExtensions = allExtNames.filter(e => !currentSet.has(e)); STATE.page = 1; STATE.results = []; doSearch(); renderExtensionFilter(); }); DOM.folderSelectAll.addEventListener("click", () => { if (!STATE.folderTree || STATE.folderTree.length === 0) return; const subtreeSet = new Set(); const selfSet = new Set(); for (let i = 0; i < STATE.folderTree.length; i++) { setNodeSubtreeSelection(STATE.folderTree[i], true, subtreeSet, selfSet); } persistFolderSelection(subtreeSet, selfSet); renderFilterFolderTree(); }); DOM.folderDeselectAll.addEventListener("click", () => { if (!STATE.folderTree || STATE.folderTree.length === 0) return; const subtreeSet = getFolderSubtreeSet(); const selfSet = getFolderSelfSet(); const allSubtreePaths = []; const allSelfPaths = []; collectFolderNodePaths(STATE.folderTree, allSubtreePaths, allSelfPaths); const nextSubtreeSet = new Set(); const nextSelfSet = new Set(); for (let i = 0; i < allSubtreePaths.length; i++) { if (!subtreeSet.has(allSubtreePaths[i])) nextSubtreeSet.add(allSubtreePaths[i]); } for (let i = 0; i < allSelfPaths.length; i++) { if (!selfSet.has(allSelfPaths[i])) nextSelfSet.add(allSelfPaths[i]); } persistFolderSelection(nextSubtreeSet, nextSelfSet); renderFilterFolderTree(); }); DOM.didYouMean.addEventListener("click", () => { if (STATE.didYouMean) { DOM.searchInput.value = STATE.didYouMean; STATE.query = STATE.didYouMean; STATE.page = 1; STATE.results = []; addHistoryItem(STATE.query); doSearch(); } }); setupInfiniteScroll(); setupQuickScroll(); setupKeyboard(); setupResultDelegation(); window.addEventListener("popstate", () => ROUTER.apply()); window.addEventListener("resize", () => { if (!localStorage.getItem("mobileMode")) { const wm = STATE.isMobile; STATE.isMobile = autoDetectMobile(); if (wm !== STATE.isMobile) applyMobileMode(); } }); ROUTER.apply(); fetchHitokoto(); setInterval(fetchHitokoto, 30000); await renderSidebar(); await renderFilters(); doSearch(); } document.addEventListener("DOMContentLoaded", init);