const STATE = { mode: "global", source: null, query: "", page: 1, pageSize: 100, total: 0, results: [], sources: [], sourceMap: {}, folderTree: [], folderTreeSource: null, folderNodeMap: {}, folderCollapsed: {}, folderSelections: [], selectedSources: [], minSize: null, maxSize: null, exact: false, fulltext: false, searchPaths: true, fulltextManifest: null, fulltextLoaded: {}, historyEnabled: true, leftSidebarOpen: true, rightSidebarOpen: false, isMobile: false, isDark: true, isLoading: false, multiSelect: false, selectedIds: new Set(), }; const DOM = {}; const HISTORY_KEY = "vomebook_search_history"; const ICON_HTML = { source: '', folder: '', file: '', }; function $(selector) { return document.querySelector(selector); } function escapeHTML(value) { return String(value).replace(/[&<>"']/g, function(ch) { return ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch]; }); } function tokenize(text) { return Array.from(new Set(String(text || "").toLowerCase().match(/[a-z0-9]+|[\u4e00-\u9fff\u3400-\u4dbf]+/g) || [])); } function formatSize(bytes) { if (!bytes) return ""; if (bytes < 1024) return bytes + " B"; if (bytes < 1048576) return (bytes / 1024).toFixed(1).replace(/\.0$/, "") + " KB"; if (bytes < 1073741824) return (bytes / 1048576).toFixed(1).replace(/\.0$/, "") + " MB"; return (bytes / 1073741824).toFixed(2).replace(/\.00$/, "") + " GB"; } function highlightText(text, query) { const safeText = escapeHTML(text || ""); const tokens = tokenize(query).filter(Boolean); if (!safeText || !tokens.length) return safeText; let output = safeText; for (const token of tokens) { const pattern = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); output = output.replace(new RegExp(`(${pattern})`, "gi"), "$1"); } return output; } function bytesFromInput(value, unit) { const parsed = parseFloat(value); if (Number.isNaN(parsed) || parsed < 0) return null; if (unit === "KB") return Math.round(parsed * 1024); if (unit === "MB") return Math.round(parsed * 1048576); if (unit === "GB") return Math.round(parsed * 1073741824); return Math.round(parsed); } function buildPath(folder, file) { return folder.length ? folder.join("/") + "/" + file : file; } const API = { async getSources() { const resp = await fetch("/api/sources"); return resp.json(); }, async search(body, sourceSlug) { const url = sourceSlug ? `/api/search/${sourceSlug}` : "/api/search"; const resp = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); return resp.json(); }, async getFolders(sourceSlug) { const resp = await fetch(`/api/folders/${sourceSlug}`); return resp.json(); }, async getContents(sourceSlug, path) { const qs = path ? `?path=${encodeURIComponent(path)}` : ""; const resp = await fetch(`/api/folders/${sourceSlug}/contents${qs}`); return resp.json(); }, async preview(docId) { const resp = await fetch(`/api/preview/${encodeURIComponent(docId)}`); return resp.json(); }, async snippet(docId, query) { const resp = await fetch(`/api/snippet/${encodeURIComponent(docId)}?q=${encodeURIComponent(query || "")}`); return resp.json(); }, async random(sourceSlug) { const url = sourceSlug ? `/api/random?source=${encodeURIComponent(sourceSlug)}` : "/api/random"; const resp = await fetch(url); return resp.json(); }, async fulltextManifest() { const resp = await fetch("/api/fulltext-manifest"); return resp.json(); }, async fulltextIndex(path) { const resp = await fetch(`/data/${path}`); const blob = await resp.blob(); const ds = new DecompressionStream("gzip"); const stream = blob.stream().pipeThrough(ds); const text = await new Response(stream).text(); return JSON.parse(text); }, }; function cacheDom() { DOM.headerTitle = $("#header-title"); DOM.headerLogo = $("#header-logo"); DOM.searchInput = $("#search-input"); DOM.searchHistoryDropdown = $("#search-history-dropdown"); DOM.hamburgerBtn = $("#hamburger-btn"); DOM.settingsBtn = $("#settings-btn"); DOM.mobileToggleBtn = $("#mobile-toggle-btn"); DOM.mobileToggleIcon = $("#mobile-toggle-icon"); DOM.themeBtn = $("#theme-btn"); DOM.leftSidebar = $("#left-sidebar"); DOM.rightSidebar = $("#right-sidebar"); DOM.sidebarExpandBtn = $("#sidebar-expand-btn"); DOM.sidebarTitle = $("#sidebar-title"); DOM.sidebarContent = $("#sidebar-content"); DOM.resultCount = $("#result-count"); DOM.resultsContainer = $("#results-container"); DOM.resultsList = $("#results-list"); DOM.resultsLoading = $("#results-loading"); DOM.emptyState = $("#empty-state"); DOM.emptyDesc = $("#empty-desc"); DOM.emptyRandomBtn = $("#empty-random-btn"); DOM.loadInfo = $("#load-info"); DOM.loadedCount = $("#loaded-count"); DOM.totalCount = $("#total-count"); DOM.didYouMean = $("#did-you-mean"); DOM.clearFiltersBtn = $("#clear-filters-btn"); DOM.sortSelect = $("#sort-select"); DOM.previewPanel = $("#preview-panel"); DOM.closeFiltersBtn = $("#close-filters-btn"); DOM.fulltextToggle = $("#fulltext-toggle"); DOM.searchPathsToggle = $("#search-paths-toggle"); DOM.historyToggle = $("#history-toggle"); DOM.exactToggle = $("#exact-search-toggle"); DOM.filterSourceList = $("#filter-source-list"); DOM.filterFolderTree = $("#filter-folder-tree"); DOM.filterMinSize = $("#filter-min-size"); DOM.filterMaxSize = $("#filter-max-size"); DOM.filterMinUnit = $("#filter-min-unit"); DOM.filterMaxUnit = $("#filter-max-unit"); DOM.folderSelectAll = $("#folder-select-all"); DOM.folderDeselectAll = $("#folder-deselect-all"); DOM.multiToggleLabel = $("#multi-toggle-label"); DOM.multiSelectToggle = $("#multi-select-toggle"); DOM.multiActionBar = $("#multi-action-bar"); DOM.multiSelectAll = $("#multi-select-all"); DOM.multiDeselect = $("#multi-deselect"); DOM.multiBatchDownload = $("#multi-batch-download"); DOM.multiZipDownload = $("#multi-zip-download"); DOM.multiSelectedCount = $("#multi-selected-count"); DOM.randomBookBtn = $("#random-book-btn"); DOM.overlay = $("#overlay"); DOM.toast = $("#toast"); } function parseRoute() { const path = window.location.pathname.replace(/^\/+|\/+$/g, ""); if (!path) return { mode: "global", source: null }; return { mode: "source", source: decodeURIComponent(path) }; } function syncRoute() { const route = parseRoute(); STATE.mode = route.mode; STATE.source = route.source; if (STATE.source) { DOM.sidebarTitle.textContent = STATE.source; if (DOM.sidebarExpandBtn && !STATE.isMobile) DOM.sidebarExpandBtn.style.display = ""; } else { DOM.sidebarTitle.textContent = "数据包"; if (DOM.sidebarExpandBtn) { DOM.sidebarExpandBtn.style.display = "none"; DOM.leftSidebar.classList.remove("expanded-wide"); DOM.sidebarExpandBtn.textContent = "↔"; } } } function navigateToSource(sourceSlug) { history.pushState(null, "", `/${encodeURIComponent(sourceSlug)}`); STATE.page = 1; STATE.folderSelections = []; syncRoute(); renderSidebar(); renderFolderTree(); doSearch(); } function navigateHome() { history.pushState(null, "", "/"); STATE.page = 1; STATE.folderSelections = []; syncRoute(); renderSidebar(); renderFolderTree(); doSearch(); } function getSelectedSourcesForSearch() { if (STATE.source) return [STATE.source]; if (STATE.selectedSources.length) return STATE.selectedSources.slice(); return []; } function updateStatus() { DOM.resultCount.textContent = STATE.total ? `共 ${STATE.total.toLocaleString()} 条结果` : ""; DOM.loadedCount.textContent = STATE.results.length.toLocaleString(); DOM.totalCount.textContent = STATE.total.toLocaleString(); DOM.loadInfo.style.display = STATE.total ? "" : "none"; DOM.multiToggleLabel.style.display = STATE.total ? "" : "none"; const hasFilter = STATE.folderSelections.length || STATE.selectedSources.length || STATE.minSize !== null || STATE.maxSize !== null; DOM.clearFiltersBtn.style.display = hasFilter ? "" : "none"; } function updateFilterVisibility() { const sourceSection = $("#filter-source-section"); const folderSection = $("#filter-folder-section"); if (sourceSection) sourceSection.style.display = STATE.source ? "none" : ""; if (folderSection) folderSection.style.display = STATE.source ? "" : "none"; } function showToast(message, duration = 2000) { DOM.toast.textContent = message; DOM.toast.style.display = ""; clearTimeout(showToast._timer); showToast._timer = setTimeout(() => { DOM.toast.style.display = "none"; }, duration); } function setHistoryDropdownOpen(open) { DOM.searchHistoryDropdown.style.display = open ? "" : "none"; } function getHistory() { try { return JSON.parse(localStorage.getItem(HISTORY_KEY)) || []; } catch (_error) { return []; } } function saveHistory(items) { localStorage.setItem(HISTORY_KEY, JSON.stringify(items.slice(0, 20))); } function removeHistory(query) { const items = getHistory().filter((item) => item !== query); saveHistory(items); renderHistoryDropdown(); } function clearHistory() { saveHistory([]); renderHistoryDropdown(); } function addHistory(query) { if (!STATE.historyEnabled || !query) return; const items = getHistory().filter((item) => item !== query); items.unshift(query); saveHistory(items); } function renderHistoryDropdown() { const items = getHistory(); if (!items.length) { setHistoryDropdownOpen(false); return; } DOM.searchHistoryDropdown.innerHTML = items.map((item) => `
${escapeHTML(item)}
`).join("") + ''; setHistoryDropdownOpen(true); } function applyTheme() { document.body.classList.toggle("light", !STATE.isDark); } function applyMobileMode() { document.body.classList.toggle("mobile", STATE.isMobile); document.body.classList.toggle("force-desktop", !STATE.isMobile); if (DOM.mobileToggleIcon) { DOM.mobileToggleIcon.className = STATE.isMobile ? "ui-icon ui-icon-desktop" : "ui-icon ui-icon-phone"; } if (DOM.sidebarExpandBtn) { DOM.sidebarExpandBtn.style.display = (!STATE.isMobile && STATE.source) ? "" : "none"; if (STATE.isMobile) { DOM.leftSidebar.classList.remove("expanded-wide"); DOM.sidebarExpandBtn.textContent = "↔"; } } if (STATE.isMobile) { STATE.leftSidebarOpen = false; STATE.rightSidebarOpen = false; } else { STATE.leftSidebarOpen = true; STATE.rightSidebarOpen = false; } updateSidebarVisibility(); } function syncUrl(replace = true) { const params = new URLSearchParams(); if (STATE.query) params.set("q", STATE.query); if (STATE.selectedSources.length && !STATE.source) { for (const slug of STATE.selectedSources) params.append("source", slug); } if (STATE.folderSelections.length && STATE.source) { for (const path of STATE.folderSelections) params.append("folder", path); } if (STATE.minSize !== null) params.set("min_size", String(STATE.minSize)); if (STATE.maxSize !== null) params.set("max_size", String(STATE.maxSize)); if (DOM.sortSelect.value !== "relevance") params.set("sort", DOM.sortSelect.value); if (STATE.exact) params.set("exact", "1"); if (STATE.fulltext) params.set("fulltext", "1"); if (!STATE.searchPaths) params.set("search_paths", "0"); if (!STATE.historyEnabled) params.set("history", "0"); const basePath = STATE.source ? `/${encodeURIComponent(STATE.source)}` : "/"; const nextUrl = params.toString() ? `${basePath}?${params.toString()}` : basePath; if (replace) history.replaceState(null, "", nextUrl); else history.pushState(null, "", nextUrl); } function loadUrlState() { const params = new URLSearchParams(window.location.search); STATE.query = params.get("q") || ""; STATE.selectedSources = params.getAll("source"); STATE.folderSelections = params.getAll("folder"); STATE.minSize = params.get("min_size") ? parseInt(params.get("min_size"), 10) : null; STATE.maxSize = params.get("max_size") ? parseInt(params.get("max_size"), 10) : null; STATE.exact = params.get("exact") === "1"; STATE.fulltext = params.get("fulltext") === "1"; STATE.searchPaths = params.get("search_paths") !== "0"; STATE.historyEnabled = params.get("history") !== "0"; const sort = params.get("sort") || "relevance"; DOM.searchInput.value = STATE.query; DOM.exactToggle.checked = STATE.exact; DOM.fulltextToggle.checked = STATE.fulltext; DOM.searchPathsToggle.checked = STATE.searchPaths; DOM.historyToggle.checked = STATE.historyEnabled; DOM.sortSelect.value = sort; if (STATE.minSize !== null) DOM.filterMinSize.value = STATE.minSize; if (STATE.maxSize !== null) DOM.filterMaxSize.value = STATE.maxSize; } async function ensureFulltextLoaded() { if (!STATE.fulltextManifest) { STATE.fulltextManifest = await API.fulltextManifest(); } const sourceSlugs = STATE.source ? [STATE.source] : (STATE.selectedSources.length ? STATE.selectedSources : STATE.sources.map((source) => source.slug)); for (const slug of sourceSlugs) { if (STATE.fulltextLoaded[slug]) continue; const item = (STATE.fulltextManifest.sources || []).find((source) => source.slug === slug); if (!item) continue; showToast(`加载全文索引: ${slug}`); STATE.fulltextLoaded[slug] = await API.fulltextIndex(item.path); } } function scoreFulltextDoc(docId, payload, queryTokens, exactQuery) { if (exactQuery) { const snippet = (payload.snippets[docId] || "").toLowerCase(); return snippet.includes(exactQuery.toLowerCase()) ? 100 : 1; } let score = 0; for (const token of queryTokens) { if ((payload.snippets[docId] || "").toLowerCase().includes(token)) score += 4; if ((payload.docs[docId]?.display_name || "").toLowerCase().includes(token)) score += 1; } return score; } async function fulltextSearch() { await ensureFulltextLoaded(); const query = STATE.query.trim(); const queryTokens = tokenize(query); const sourceSlugs = STATE.source ? [STATE.source] : (STATE.selectedSources.length ? STATE.selectedSources : STATE.sources.map((source) => source.slug)); let results = []; for (const slug of sourceSlugs) { const payload = STATE.fulltextLoaded[slug]; if (!payload) continue; let matchedIds = null; if (STATE.exact) { matchedIds = Object.keys(payload.docs).filter((docId) => { const snippet = (payload.snippets[docId] || "") + " " + (payload.docs[docId]?.display_name || ""); return snippet.toLowerCase().includes(query.toLowerCase()); }); } else { for (const token of queryTokens) { const docIds = payload.index[token] || []; const set = new Set(docIds); if (matchedIds === null) matchedIds = set; else matchedIds = new Set(Array.from(matchedIds).filter((docId) => set.has(docId))); } matchedIds = Array.from(matchedIds || []); } for (const docId of matchedIds) { const meta = payload.docs[docId]; const folderParts = meta.display_rel_path.split("/"); const fileName = folderParts.pop(); const displayName = fileName.replace(/\.txt$/i, ""); results.push({ doc_id: docId, Source: slug, SourceName: STATE.sourceMap[slug]?.name || slug, File: displayName, Extension: "txt", Folder: folderParts, DisplayPath: meta.display_rel_path, Size: meta.size, HasTxt: true, snippet: payload.snippets[docId] || "", _score: scoreFulltextDoc(docId, payload, queryTokens, query), }); } } if (STATE.folderSelections.length) { results = results.filter((item) => { const folder = item.Folder.join("/"); return STATE.folderSelections.some((selected) => folder === selected || folder.startsWith(selected + "/")); }); } if (STATE.minSize !== null) results = results.filter((item) => item.Size >= STATE.minSize); if (STATE.maxSize !== null) results = results.filter((item) => item.Size <= STATE.maxSize); if (DOM.sortSelect.value === "name") results.sort((a, b) => a.DisplayPath.localeCompare(b.DisplayPath, "zh")); else if (DOM.sortSelect.value === "size") results.sort((a, b) => (b.Size - a.Size) || a.DisplayPath.localeCompare(b.DisplayPath, "zh")); else results.sort((a, b) => (b._score - a._score) || a.DisplayPath.localeCompare(b.DisplayPath, "zh")); const snippetTargets = results.slice(0, STATE.page * STATE.pageSize); await Promise.all(snippetTargets.map(async (item) => { const data = await API.snippet(item.doc_id, query); item.snippet = data && data.snippet ? data.snippet : ""; })); STATE.total = results.length; STATE.results = results.slice(0, STATE.page * STATE.pageSize); } async function pathSearch() { const body = { q: STATE.query, sources: getSelectedSourcesForSearch(), folders: STATE.folderSelections, min_size: STATE.minSize, max_size: STATE.maxSize, page: 1, page_size: STATE.page * STATE.pageSize, sort: DOM.sortSelect.value, exact: STATE.exact, search_paths: STATE.searchPaths, }; const data = await API.search(body, STATE.source); STATE.total = data.total || 0; STATE.results = data.results || []; } async function doSearch() { STATE.isLoading = true; DOM.resultsLoading.style.display = "flex"; DOM.previewPanel.style.display = "none"; try { if (STATE.fulltext && STATE.query.trim()) { await fulltextSearch(); } else { await pathSearch(); } renderResults(); updateStatus(); } catch (error) { console.error(error); showToast("搜索失败"); } finally { STATE.isLoading = false; DOM.resultsLoading.style.display = "none"; } } function resultPathHtml(item) { const sourcePrefix = `${highlightText(item.SourceName, STATE.fulltext ? "" : STATE.query)}`; const rest = (item.Folder || []).map((part, index) => { const path = item.Folder.slice(0, index + 1).join("/"); return `/${highlightText(part, STATE.fulltext ? "" : STATE.query)}`; }).join(""); return sourcePrefix + rest; } function renderResults() { if (!STATE.results.length) { DOM.resultsList.innerHTML = ""; DOM.emptyState.style.display = "flex"; DOM.emptyDesc.textContent = STATE.query ? `没有找到与“${STATE.query}”相关的结果` : "暂无数据"; DOM.multiActionBar.style.display = "none"; return; } DOM.emptyState.style.display = "none"; DOM.resultsList.innerHTML = STATE.results.map((item) => { const titleHtml = `${highlightText(item.File, STATE.fulltext ? "" : STATE.query)}.txt`; const snippetHtml = item.snippet ? `
${highlightText(item.snippet, STATE.query)}
` : ""; return `
${ICON_HTML.file}
${titleHtml}
${resultPathHtml(item)}
${escapeHTML(formatSize(item.Size))}
${snippetHtml}
下载
`; }).join(""); DOM.multiActionBar.style.display = STATE.multiSelect ? "" : "none"; } async function openPreview(docId, keyword) { try { const data = await API.preview(docId); if (data.error) { showToast("预览失败"); return; } let text = escapeHTML(data.text || ""); if (keyword) { const tokens = tokenize(keyword).filter(Boolean); for (const token of tokens) { text = text.replace(new RegExp(token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "gi"), (m) => `${m}`); } } DOM.previewPanel.innerHTML = `
${escapeHTML(data.title)}
${escapeHTML(data.source)} / ${escapeHTML(data.path)}
${text}
`; DOM.previewPanel.style.display = "block"; DOM.previewPanel.scrollIntoView({ behavior: "smooth", block: "start" }); } catch (error) { console.error(error); showToast("预览失败"); } } function renderCheckboxList(container, items, selected, onChange) { container.innerHTML = items.map((item) => ` `).join(""); container.querySelectorAll("input").forEach((input) => { input.addEventListener("change", () => { const values = Array.from(container.querySelectorAll("input:checked")).map((item) => item.value); onChange(values); }); }); } async function renderSourcesFilter() { renderCheckboxList(DOM.filterSourceList, STATE.sources.map((source) => ({ key: source.slug, label: source.name, count: source.count })), STATE.selectedSources, (values) => { STATE.selectedSources = values; STATE.page = 1; syncUrl(); doSearch(); }); } function flattenNodes(nodes, into) { for (const node of nodes) { if (node.path) into.push(node.path); flattenNodes(node.children || [], into); } } function buildFolderNodeMap(nodes, map = {}) { for (const node of nodes) { if (node.path) map[node.path] = node; buildFolderNodeMap(node.children || [], map); } return map; } function getVisibleFolderNodes(nodes, depth = 0, into = []) { for (const node of nodes) { into.push({ node, depth }); if (node.children && node.children.length && !STATE.folderCollapsed[node.path]) { getVisibleFolderNodes(node.children, depth + 1, into); } } return into; } function collectVisibleDescendantPaths(node, into = []) { for (const child of node.children || []) { if (!child.path) continue; into.push(child.path); if (child.children && child.children.length && !STATE.folderCollapsed[child.path]) { collectVisibleDescendantPaths(child, into); } } return into; } function normalizeFolderTree(nodes) { const roots = Array.isArray(nodes) ? nodes : []; if (roots.length === 1) { const only = roots[0]; const hasChildren = Array.isArray(only.children) && only.children.length; if ((only.isRoot || !only.path || only.path === STATE.source || only.name === STATE.source) && hasChildren) { return only.children; } } return roots; } function initializeFolderCollapsedState(nodes) { const next = {}; const visit = (items, depth = 0) => { for (const node of items) { if (node.path && !(node.path in STATE.folderCollapsed)) next[node.path] = depth > 0; visit(node.children || [], depth + 1); } }; visit(nodes, 0); STATE.folderCollapsed = { ...next, ...STATE.folderCollapsed }; } function isPathCovered(path, selectionSet) { if (!path) return false; if (selectionSet.has(path)) return true; const parts = path.split("/"); while (parts.length > 1) { parts.pop(); if (selectionSet.has(parts.join("/"))) return true; } return false; } function getFolderSelectionState(node, selectionSet, cache = new Map()) { const cacheKey = node.path || `__virtual__:${node.name}:${(node.children || []).length}`; if (cache.has(cacheKey)) return cache.get(cacheKey); let state = "unchecked"; if (node.path && isPathCovered(node.path, selectionSet)) { state = "checked"; } else { const children = node.children || []; if (children.length) { const childStates = children.map((child) => getFolderSelectionState(child, selectionSet, cache)); if (childStates.every((item) => item === "checked")) state = "checked"; else if (childStates.some((item) => item !== "unchecked")) state = "indeterminate"; } } cache.set(cacheKey, state); return state; } function normalizeFolderSelections(selections) { const unique = Array.from(new Set((selections || []).filter(Boolean))); return unique.filter((path) => !unique.some((other) => other !== path && path.startsWith(other + "/"))); } function selectFolderPath(path) { const selectionSet = new Set(normalizeFolderSelections(STATE.folderSelections)); if (isPathCovered(path, selectionSet)) return Array.from(selectionSet); const next = Array.from(selectionSet).filter((item) => !(item === path || item.startsWith(path + "/"))); next.push(path); return normalizeFolderSelections(next); } function collectSelectionsExcept(node, excludedPath, into) { if (!node.path) { for (const child of node.children || []) collectSelectionsExcept(child, excludedPath, into); return; } if (node.path === excludedPath || excludedPath.startsWith(node.path + "/")) { for (const child of node.children || []) collectSelectionsExcept(child, excludedPath, into); return; } into.push(node.path); } function deselectFolderPath(path) { let next = normalizeFolderSelections(STATE.folderSelections).filter((item) => !(item === path || item.startsWith(path + "/"))); const parts = path.split("/"); while (parts.length > 1) { parts.pop(); const ancestorPath = parts.join("/"); const ancestorIndex = next.indexOf(ancestorPath); if (ancestorIndex === -1) continue; next.splice(ancestorIndex, 1); const ancestorNode = STATE.folderNodeMap[ancestorPath]; if (ancestorNode) { const expanded = []; collectSelectionsExcept(ancestorNode, path, expanded); next.push(...expanded); } break; } return normalizeFolderSelections(next); } function invertFolderSelections() { const selectionSet = new Set(normalizeFolderSelections(STATE.folderSelections)); const stateCache = new Map(); const next = []; const invertNode = (node) => { const state = getFolderSelectionState(node, selectionSet, stateCache); if (state === "checked") return; if (state === "unchecked") { if (node.path) next.push(node.path); else for (const child of node.children || []) invertNode(child); return; } for (const child of node.children || []) invertNode(child); }; for (const node of STATE.folderTree) invertNode(node); return normalizeFolderSelections(next); } function refreshFolderTreeSelectionState() { if (!DOM.filterFolderTree) return; const selectionSet = new Set(normalizeFolderSelections(STATE.folderSelections)); const stateCache = new Map(); DOM.filterFolderTree.querySelectorAll("input[data-folder-path]").forEach((checkbox) => { const node = STATE.folderNodeMap[checkbox.dataset.folderPath]; if (!node) return; const state = getFolderSelectionState(node, selectionSet, stateCache); checkbox.checked = state === "checked"; checkbox.indeterminate = state === "indeterminate"; }); DOM.filterFolderTree.querySelectorAll(".tree-toggle[data-folder-path]").forEach((toggle) => { const path = toggle.dataset.folderPath; const collapsed = !!STATE.folderCollapsed[path]; toggle.classList.toggle("expanded", !collapsed); toggle.setAttribute("aria-label", collapsed ? "展开子文件夹" : "收起子文件夹"); toggle.setAttribute("title", collapsed ? "展开" : "收起"); }); } function applyFolderSelections(nextSelections) { STATE.folderSelections = normalizeFolderSelections(nextSelections); STATE.page = 1; refreshFolderTreeSelectionState(); syncUrl(); doSearch(); } function renderFolderTreeRows(animation = {}) { const container = DOM.filterFolderTree; if (!container) return; const firstRects = new Map(); container.querySelectorAll(".filter-folder-item[data-folder-path]").forEach((row) => { firstRects.set(row.dataset.folderPath, row.getBoundingClientRect()); }); container.innerHTML = ""; renderFolderNodes(container, STATE.folderTree, 0); refreshFolderTreeSelectionState(); const newRows = container.querySelectorAll(".filter-folder-item[data-folder-path]"); newRows.forEach((row) => { const path = row.dataset.folderPath; const lastRect = row.getBoundingClientRect(); const firstRect = firstRects.get(path); if (firstRect) { const deltaY = firstRect.top - lastRect.top; if (Math.abs(deltaY) > 0.5) { row.animate([ { transform: `translateY(${deltaY}px)` }, { transform: "translateY(0)" }, ], { duration: 220, easing: "ease", }); } } else { row.animate([ { opacity: 0, transform: "translateY(-8px)" }, { opacity: 1, transform: "translateY(0)" }, ], { duration: 220, easing: "ease", }); } if (animation.expanding && animation.toggledPath && row.dataset.folderPath === animation.toggledPath) { const glyph = row.querySelector(".tree-toggle-glyph"); if (glyph) { const fromTransform = "rotate(-45deg)"; const toTransform = "rotate(45deg)"; glyph.animate([ { transform: fromTransform }, { transform: toTransform }, ], { duration: 220, easing: "ease", }); } } }); } function animateFolderCollapse(nodePath, exitingPaths) { const container = DOM.filterFolderTree; if (!container) return 200; const duration = 200; const glyph = container.querySelector(`.filter-folder-item[data-folder-path="${CSS.escape(nodePath)}"] .tree-toggle-glyph`); if (glyph) { glyph.animate([ { transform: "rotate(45deg)" }, { transform: "rotate(-45deg)" }, ], { duration: 220, easing: "ease", fill: "forwards", }); } for (const path of exitingPaths) { const row = container.querySelector(`.filter-folder-item[data-folder-path="${CSS.escape(path)}"]`); if (!row) continue; row.animate([ { opacity: 1, transform: "translateY(0) scaleY(1)" }, { opacity: 0, transform: "translateY(-10px) scaleY(0.96)" }, ], { duration, easing: "ease", fill: "forwards", }); } return duration; } function renderFolderNodes(container, nodes, depth) { const visibleNodes = getVisibleFolderNodes(nodes, depth); for (const { node, depth: itemDepth } of visibleNodes) { const hasChildren = !!(node.children && node.children.length); const row = document.createElement("div"); row.className = "filter-folder-item"; row.dataset.folderPath = node.path; row.style.setProperty("--fdepth", itemDepth); row.innerHTML = `${hasChildren ? `` : ''}${escapeHTML(node.name)}${(node.count || 0).toLocaleString()}`; const toggle = row.querySelector(".tree-toggle"); if (toggle) { toggle.addEventListener("click", (event) => { event.stopPropagation(); const expanding = !!STATE.folderCollapsed[node.path]; if (expanding) { STATE.folderCollapsed[node.path] = false; renderFolderTreeRows({ toggledPath: node.path, expanding: true }); return; } const exitingPaths = collectVisibleDescendantPaths(node); const duration = animateFolderCollapse(node.path, exitingPaths); STATE.folderCollapsed[node.path] = true; window.setTimeout(() => { renderFolderTreeRows({ toggledPath: node.path, expanding: false }); }, duration); }); } const checkbox = row.querySelector("input"); checkbox.addEventListener("change", () => { const selectionSet = new Set(normalizeFolderSelections(STATE.folderSelections)); const state = getFolderSelectionState(node, selectionSet); if (state === "checked") applyFolderSelections(deselectFolderPath(node.path)); else applyFolderSelections(selectFolderPath(node.path)); }); container.appendChild(row); } } async function renderFolderTree(forceReload = false) { updateFilterVisibility(); if (!STATE.source) { DOM.filterFolderTree.innerHTML = '
进入单仓库后可按路径过滤
'; STATE.folderTree = []; STATE.folderTreeSource = null; STATE.folderNodeMap = {}; return; } if (forceReload || STATE.folderTreeSource !== STATE.source || !STATE.folderTree.length) { STATE.folderTree = normalizeFolderTree(await API.getFolders(STATE.source)); STATE.folderTreeSource = STATE.source; STATE.folderNodeMap = buildFolderNodeMap(STATE.folderTree); STATE.folderCollapsed = {}; initializeFolderCollapsedState(STATE.folderTree); renderFolderTreeRows(); } refreshFolderTreeSelectionState(); } async function renderSidebar() { if (!STATE.source) { DOM.sidebarTitle.textContent = "数据包"; DOM.sidebarContent.innerHTML = STATE.sources.map((source) => `
${ICON_HTML.source}${escapeHTML(source.name)}${(source.count || 0).toLocaleString()}
`).join(""); return; } DOM.sidebarTitle.textContent = STATE.sourceMap[STATE.source]?.name || STATE.source; const data = await API.getContents(STATE.source, ""); let html = '
返回全局搜索
'; html += (data.folders || []).map((item) => `
${ICON_HTML.folder}${escapeHTML(item.name)}${(item.count || 0).toLocaleString()}
`).join(""); html += (data.files || []).map((item) => `
${ICON_HTML.file}${escapeHTML(item.name)}.txt${escapeHTML(formatSize(item.size))}
`).join(""); DOM.sidebarContent.innerHTML = html; } async function openFolder(path) { const data = await API.getContents(STATE.source, path); const parts = path ? path.split("/") : []; let html = '
返回全局搜索
'; html += ``; html += (data.folders || []).map((item) => `
${ICON_HTML.folder}${escapeHTML(item.name)}${(item.count || 0).toLocaleString()}
`).join(""); html += (data.files || []).map((item) => `
${ICON_HTML.file}${escapeHTML(item.name)}.txt${escapeHTML(formatSize(item.size))}
`).join(""); DOM.sidebarContent.innerHTML = html; } function updateSidebarVisibility() { DOM.leftSidebar.classList.toggle("collapsed", !STATE.leftSidebarOpen); DOM.rightSidebar.classList.toggle("collapsed", !STATE.rightSidebarOpen); DOM.leftSidebar.classList.toggle("open", STATE.leftSidebarOpen); DOM.rightSidebar.classList.toggle("open", STATE.rightSidebarOpen); DOM.overlay.style.display = (STATE.isMobile && (STATE.leftSidebarOpen || STATE.rightSidebarOpen)) ? "" : "none"; } function clearFilters() { STATE.folderSelections = []; STATE.selectedSources = []; STATE.minSize = null; STATE.maxSize = null; DOM.filterMinSize.value = ""; DOM.filterMaxSize.value = ""; DOM.filterMinUnit.value = "MB"; DOM.filterMaxUnit.value = "MB"; renderSourcesFilter(); refreshFolderTreeSelectionState(); syncUrl(); doSearch(); } async function randomDoc() { const data = await API.random(STATE.source); if (!data.doc_id) { showToast("暂无可用文档"); return; } openPreview(data.doc_id, ""); } function updateMultiUi() { STATE.multiSelect = DOM.multiSelectToggle.checked; DOM.multiActionBar.style.display = STATE.multiSelect ? "" : "none"; document.body.classList.toggle("multiselect", STATE.multiSelect); if (!STATE.multiSelect) STATE.selectedIds.clear(); DOM.multiSelectedCount.textContent = STATE.selectedIds.size ? `已选 ${STATE.selectedIds.size} 项` : ""; renderResults(); } function attachEvents() { DOM.headerLogo.addEventListener("click", (event) => { if (window.location.pathname !== "/") return; event.preventDefault(); window.location.href = DOM.headerLogo.href; }); DOM.hamburgerBtn.addEventListener("click", () => { STATE.leftSidebarOpen = !STATE.leftSidebarOpen; if (STATE.isMobile && STATE.leftSidebarOpen) STATE.rightSidebarOpen = false; updateSidebarVisibility(); }); if (DOM.sidebarExpandBtn) { DOM.sidebarExpandBtn.addEventListener("click", () => { DOM.leftSidebar.classList.toggle("expanded-wide"); DOM.sidebarExpandBtn.textContent = DOM.leftSidebar.classList.contains("expanded-wide") ? "→" : "↔"; }); } DOM.settingsBtn.addEventListener("click", () => { STATE.rightSidebarOpen = !STATE.rightSidebarOpen; if (STATE.isMobile && STATE.rightSidebarOpen) STATE.leftSidebarOpen = false; updateSidebarVisibility(); }); DOM.closeFiltersBtn.addEventListener("click", () => { STATE.rightSidebarOpen = false; updateSidebarVisibility(); }); DOM.overlay.addEventListener("click", () => { STATE.leftSidebarOpen = false; STATE.rightSidebarOpen = false; updateSidebarVisibility(); }); DOM.themeBtn.addEventListener("click", () => { STATE.isDark = !STATE.isDark; applyTheme(); localStorage.setItem("theme", STATE.isDark ? "dark" : "light"); }); DOM.mobileToggleBtn.addEventListener("click", () => { STATE.isMobile = !STATE.isMobile; applyMobileMode(); }); let historyDropdownActive = false; DOM.searchInput.addEventListener("focus", renderHistoryDropdown); DOM.searchInput.addEventListener("blur", () => setTimeout(() => { if (!historyDropdownActive) setHistoryDropdownOpen(false); }, 120)); DOM.searchHistoryDropdown.addEventListener("mouseenter", () => { historyDropdownActive = true; }); DOM.searchHistoryDropdown.addEventListener("mouseleave", () => { historyDropdownActive = false; }); DOM.searchHistoryDropdown.addEventListener("click", (event) => { const delBtn = event.target.closest(".history-del"); if (delBtn) { event.stopPropagation(); removeHistory(delBtn.dataset.del); historyDropdownActive = true; DOM.searchInput.focus(); return; } const clearBtn = event.target.closest(".history-clear-all"); if (clearBtn) { clearHistory(); historyDropdownActive = true; DOM.searchInput.focus(); return; } const item = event.target.closest("[data-query]"); if (!item) return; DOM.searchInput.value = item.dataset.query; STATE.query = item.dataset.query; syncUrl(); doSearch(); }); let historyLongPressTimer = null; const cancelHistoryLongPress = () => { if (historyLongPressTimer) { clearTimeout(historyLongPressTimer); historyLongPressTimer = null; } }; DOM.searchHistoryDropdown.addEventListener("mousedown", (event) => { const item = event.target.closest("[data-query]"); if (!item || event.target.closest(".history-del") || event.target.closest(".history-clear-all")) return; cancelHistoryLongPress(); historyLongPressTimer = setTimeout(() => { removeHistory(item.dataset.query); historyLongPressTimer = null; }, 600); }); DOM.searchHistoryDropdown.addEventListener("mouseup", cancelHistoryLongPress); DOM.searchHistoryDropdown.addEventListener("mouseleave", cancelHistoryLongPress); DOM.searchHistoryDropdown.addEventListener("touchstart", (event) => { const item = event.target.closest("[data-query]"); if (!item || event.target.closest(".history-del") || event.target.closest(".history-clear-all")) return; cancelHistoryLongPress(); historyLongPressTimer = setTimeout(() => { removeHistory(item.dataset.query); historyLongPressTimer = null; }, 650); }, { passive: true }); DOM.searchHistoryDropdown.addEventListener("touchend", cancelHistoryLongPress, { passive: true }); DOM.searchHistoryDropdown.addEventListener("touchmove", cancelHistoryLongPress, { passive: true }); let timer = null; DOM.searchInput.addEventListener("input", () => { clearTimeout(timer); timer = setTimeout(() => { STATE.query = DOM.searchInput.value.trim(); STATE.page = 1; addHistory(STATE.query); renderHistoryDropdown(); syncUrl(); doSearch(); }, 250); }); DOM.sortSelect.addEventListener("change", () => { STATE.page = 1; syncUrl(); doSearch(); }); DOM.exactToggle.addEventListener("change", () => { STATE.exact = DOM.exactToggle.checked; STATE.page = 1; syncUrl(); doSearch(); }); DOM.historyToggle.addEventListener("change", () => { STATE.historyEnabled = DOM.historyToggle.checked; if (!STATE.historyEnabled) saveHistory([]); syncUrl(); }); DOM.fulltextToggle.addEventListener("change", async () => { STATE.fulltext = DOM.fulltextToggle.checked; if (DOM.searchPathsToggle) { DOM.searchPathsToggle.disabled = STATE.fulltext; if (STATE.fulltext) DOM.searchPathsToggle.closest(".toggle-row").style.opacity = "0.5"; else DOM.searchPathsToggle.closest(".toggle-row").style.opacity = ""; } DOM.searchInput.placeholder = STATE.fulltext ? "全文搜索 TXT 正文..." : "搜索 TXT 文件或路径..."; STATE.page = 1; if (STATE.fulltext) await ensureFulltextLoaded(); syncUrl(); doSearch(); }); DOM.searchPathsToggle.addEventListener("change", () => { STATE.searchPaths = DOM.searchPathsToggle.checked; STATE.page = 1; syncUrl(); doSearch(); }); DOM.clearFiltersBtn.addEventListener("click", clearFilters); DOM.emptyRandomBtn.addEventListener("click", randomDoc); if (DOM.randomBookBtn) DOM.randomBookBtn.addEventListener("click", randomDoc); DOM.filterMinSize.addEventListener("input", () => { STATE.minSize = bytesFromInput(DOM.filterMinSize.value, DOM.filterMinUnit.value); syncUrl(); doSearch(); }); DOM.filterMaxSize.addEventListener("input", () => { STATE.maxSize = bytesFromInput(DOM.filterMaxSize.value, DOM.filterMaxUnit.value); syncUrl(); doSearch(); }); DOM.filterMinUnit.addEventListener("change", () => { STATE.minSize = bytesFromInput(DOM.filterMinSize.value, DOM.filterMinUnit.value); syncUrl(); doSearch(); }); DOM.filterMaxUnit.addEventListener("change", () => { STATE.maxSize = bytesFromInput(DOM.filterMaxSize.value, DOM.filterMaxUnit.value); syncUrl(); doSearch(); }); DOM.folderSelectAll.addEventListener("click", () => { const paths = []; flattenNodes(STATE.folderTree, paths); applyFolderSelections(paths); }); DOM.folderDeselectAll.addEventListener("click", () => { applyFolderSelections(invertFolderSelections()); }); DOM.multiSelectToggle.addEventListener("change", updateMultiUi); DOM.multiSelectAll.addEventListener("click", () => { STATE.selectedIds = new Set(STATE.results.map((item) => item.doc_id)); updateMultiUi(); }); DOM.multiDeselect.addEventListener("click", () => { STATE.selectedIds.clear(); updateMultiUi(); }); DOM.multiBatchDownload.addEventListener("click", () => { const ids = Array.from(STATE.selectedIds); if (!ids.length) { showToast("未选中任何文件"); return; } ids.forEach((id, index) => setTimeout(() => { window.open(`/api/download/${encodeURIComponent(id)}`, "_blank"); }, index * 250)); }); DOM.multiZipDownload.addEventListener("click", async () => { const ids = Array.from(STATE.selectedIds); if (!ids.length) { showToast("未选中任何文件"); return; } showToast(`正在打包 ${ids.length} 个文件...`, 2500); try { const resp = await fetch("/api/zip", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ doc_ids: ids }), }); if (!resp.ok) { let message = `HTTP ${resp.status}`; try { const data = await resp.json(); if (data && data.error) message = data.error; } catch (_error) {} throw new Error(message); } const blob = await resp.blob(); const link = document.createElement("a"); const objectUrl = URL.createObjectURL(blob); link.href = objectUrl; link.download = `vomebook_batch_${Date.now()}.zip`; link.style.display = "none"; document.body.appendChild(link); link.click(); link.remove(); setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); showToast(`已开始下载 ${ids.length} 个文件的压缩包`); } catch (error) { console.error(error); showToast(`合并下载失败: ${error.message || '未知错误'}`, 3500); } }); DOM.resultsList.addEventListener("click", async (event) => { const checkbox = event.target.closest(".result-checkbox"); if (checkbox) { const docId = checkbox.dataset.docId; if (checkbox.checked) STATE.selectedIds.add(docId); else STATE.selectedIds.delete(docId); DOM.multiSelectedCount.textContent = STATE.selectedIds.size ? `已选 ${STATE.selectedIds.size} 项` : ""; return; } const action = event.target.closest("[data-action='preview']"); if (action) { event.preventDefault(); openPreview(action.dataset.docId, STATE.query); return; } const folder = event.target.closest(".path-folder[data-folder]"); if (folder) { const source = folder.dataset.source; const path = folder.dataset.folder; if (STATE.mode === "global" && source && !STATE.source) { navigateToSource(source); if (path) STATE.folderSelections = [path]; } else { STATE.folderSelections = path ? [path] : []; } refreshFolderTreeSelectionState(); syncUrl(); doSearch(); } }); DOM.sidebarContent.addEventListener("click", async (event) => { const sourceItem = event.target.closest("[data-source]"); if (sourceItem) { navigateToSource(sourceItem.dataset.source); return; } const backHome = event.target.closest("#back-home"); if (backHome) { navigateHome(); return; } const folderOpen = event.target.closest("[data-folder-open]"); if (folderOpen) { openFolder(folderOpen.dataset.folderOpen); return; } const folderNav = event.target.closest("[data-folder-nav]"); if (folderNav) { openFolder(folderNav.dataset.folderNav); return; } const docItem = event.target.closest("[data-doc-id]"); if (docItem) { openPreview(docItem.dataset.docId, STATE.query); } }); DOM.resultsContainer.addEventListener("scroll", () => { const nearBottom = DOM.resultsContainer.scrollTop + DOM.resultsContainer.clientHeight >= DOM.resultsContainer.scrollHeight - 200; if (nearBottom && STATE.results.length < STATE.total && !STATE.isLoading) { STATE.page += 1; doSearch(); } }); document.addEventListener("keydown", (event) => { if (event.key === "/" && document.activeElement !== DOM.searchInput) { event.preventDefault(); DOM.searchInput.focus(); DOM.searchInput.select(); } }); window.addEventListener("popstate", async () => { syncRoute(); await renderSidebar(); await renderFolderTree(); doSearch(); }); } async function init() { cacheDom(); STATE.isDark = localStorage.getItem("theme") !== "light"; applyTheme(); STATE.isMobile = window.innerWidth <= 768; syncRoute(); loadUrlState(); applyMobileMode(); STATE.sources = await API.getSources(); STATE.sourceMap = Object.fromEntries(STATE.sources.map((source) => [source.slug, source])); await renderSourcesFilter(); await renderSidebar(); await renderFolderTree(); updateFilterVisibility(); attachEvents(); if (DOM.searchPathsToggle) { DOM.searchPathsToggle.checked = STATE.searchPaths; DOM.searchPathsToggle.disabled = STATE.fulltext; DOM.searchPathsToggle.closest(".toggle-row").style.opacity = STATE.fulltext ? "0.5" : ""; } updateSidebarVisibility(); syncUrl(); doSearch(); } document.addEventListener("DOMContentLoaded", init);