diff --git "a/static/app.js" "b/static/app.js" --- "a/static/app.js" +++ "b/static/app.js" @@ -1,245 +1,280 @@ -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"; - const STATE = { mode: "global", - repo: null, - repoFull: null, + source: null, query: "", page: 1, pageSize: 100, total: 0, results: [], - filterRepos: [], - filterExtensions: [], - filterFolders: [], - filterFolderSubtrees: [], - filterFolderSelfs: [], - filterMinSize: null, - filterMaxSize: null, - useMirrorLinks: true, + previewDocId: null, + sources: [], + sourceMap: {}, + folderTree: [], + folderTreeSource: null, + folderNodeMap: {}, + folderCollapsed: {}, + folderSelections: [], + folderContentsCache: new Map(), + selectedSources: [], + minSize: null, + maxSize: null, + exact: true, + fulltext: true, + searchPaths: true, + historyEnabled: true, leftSidebarOpen: true, rightSidebarOpen: false, isMobile: false, isDark: true, isLoading: false, - hasMore: false, - searchFolders: true, - exact: true, - recordHistory: true, - resultsSkeletonActive: false, - browserPath: "", - repoList: [], - extensionList: [], - extensionOtherCollapsed: true, - folderTree: null, - folderTreeCollapsed: {}, - _pendingPage: 0, - _loadedPage: 0, - _pageCache: {}, - _deferredAppendWhileDragging: false, + suppressAutoLoad: false, + searchController: null, + searchSequence: 0, + searchCache: new Map(), + searchUrlWithoutPreview: null, + highlightQuery: null, + highlightPatterns: [], + multiSelect: false, + selectedIds: new Set(), }; -let searchAbortController = null; -let searchPrefetchAbortController = null; -let searchPrefetchKey = null; -let searchRequestId = 0; -let routeRenderId = 0; -let scrollTicking = false; -let scrollLoadTimer = null; -let scrollRecoveryTimer = null; -let searchComposing = false; -let keepalivePending = null; -let lastKeepaliveAt = 0; + +const DOM = {}; +const HISTORY_KEY = "vomebook_search_history"; +const SEARCH_CACHE_LIMIT = 60; +const SEARCH_CACHE_TTL = 8 * 60 * 1000; +const REQUEST_TIMEOUT_MS = 12000; const KEEPALIVE_INTERVAL_MS = 45 * 1000; const KEEPALIVE_MIN_GAP_MS = 30 * 1000; +const MAX_HIGHLIGHTS_PER_TEXT = 16; +const SEARCH_DEBOUNCE_MS = 120; +const SNIPPET_CONCURRENCY = 4; +const folderContentsPending = new Map(); +const folderTreePending = new Map(); const sidebarRetryCounts = new Map(); -const SEARCH_CACHE_TTL = 8 * 60 * 1000; -const SEARCH_CACHE_MAX = 60; -const searchResponseCache = new Map(); const initialPayloadCache = new Map(); const sidebarInitialCache = new Map(); -const sidebarInitialPending = new Map(); -const folderTreeCache = new Map(); -const SEARCH_REQUEST_TIMEOUT = 25000; -const DOWNLOAD_CHECK_TIMEOUT = 8000; - -const VSCROLL = { - renderStart: 0, - renderEnd: 0, - heights: [], - heightsDirty: true, - heightTree: [], - templateCache: new Map(), - templateCacheKey: "", - contentVersion: 0, - measuredWindowKey: "", - measuredRowKeys: [], - renderFrame: 0, - estimatedHeight: 60, - isDraggingThumb: false, - lastScrollTop: 0, - lastScrollTime: 0, - scrollVelocity: 0, -}; -let pendingResultEntrance = false; +let searchPrefetchController = null; +let searchPrefetchCacheKey = ""; +let routeRenderId = 0; +let sidebarCurrentPath = ""; +let scrollRecoveryTimer = null; +let searchComposing = false; +let initComplete = false; +let snippetObserver = null; +let snippetActive = 0; +let previewController = null; +let keepalivePending = null; +let lastKeepaliveAt = Date.now(); +const snippetQueue = []; +const snippetCache = new Map(); +const snippetControllers = new Set(); -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 motionAllowed() { + return !window.matchMedia("(prefers-reduced-motion: reduce)").matches; } -function fmtSizeUrl(bytes) { - if (bytes === null || bytes === undefined) return null; - var d = bytesToDisplay(bytes); - return d.value + d.unit; +function motionDuration(duration) { + return motionAllowed() ? duration : 0; } -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); +function themeIcon(isDark) { + const shape = isDark + ? '' + : ''; + return ``; } -var HISTORY_KEY = "voml_search_history"; -var HISTORY_MAX = 20; -var EXT_FILTER_STORAGE_KEY = "voml_ext_filter:global"; -function getHistory() { - try { return JSON.parse(sessionStorage.getItem(HISTORY_KEY)) || []; } - catch (e) { return []; } +const ICON_HTML = { + source: '', + folder: '', + file: '', +}; + +function $(selector) { + return document.querySelector(selector); } -function saveHistory(list) { - try { sessionStorage.setItem(HISTORY_KEY, JSON.stringify(list.slice(0, HISTORY_MAX))); } - catch (e) {} +function escapeHTML(value) { + return String(value).replace(/[&<>"']/g, function(ch) { + return ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[ch]; + }); } -function mergeFolderFilters(selfs, subtrees) { - return (selfs || []).concat((subtrees || []).filter(path => !(selfs || []).includes(path))); +function tokenize(text) { + return Array.from(new Set(String(text || "").toLowerCase().match(/[a-z0-9]+|[\u4e00-\u9fff\u3400-\u4dbf]+/g) || [])); } -function loadStoredExtensionFilters() { - try { - const data = JSON.parse(sessionStorage.getItem(EXT_FILTER_STORAGE_KEY) || "{}"); - return Array.isArray(data.values) ? data.values.filter(Boolean) : []; - } catch (e) { - return []; - } +function wildcardPatternToRegExp(pattern) { + const escaped = String(pattern || "").replace(/[.+^${}()|[\]\\]/g, "\\$&"); + return new RegExp(escaped.replace(/\*/g, ".*").replace(/\?/g, "."), "i"); } -function saveStoredExtensionFilters() { - const values = (STATE.filterExtensions || []).filter(Boolean); - try { - if (!values.length) { - sessionStorage.removeItem(EXT_FILTER_STORAGE_KEY); - } else { - sessionStorage.setItem(EXT_FILTER_STORAGE_KEY, JSON.stringify({ values })); - } - } catch (e) {} +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, highlights = null) { + const safeText = escapeHTML(text || ""); + const patterns = highlights + ? highlights.filter(Boolean).map((value) => new RegExp(`(${escapeHTML(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi")) + : getHighlightPatterns(query); + if (!safeText || !patterns.length) return safeText; + let output = safeText; + let remaining = MAX_HIGHLIGHTS_PER_TEXT; + for (const pattern of patterns) { + if (!remaining) break; + output = output.replace(pattern, (match) => { + if (!remaining) return match; + remaining -= 1; + return `${match}`; + }); + } + return output; } -function stableSearchStringify(value) { - if (Array.isArray(value)) return '[' + value.map(stableSearchStringify).join(',') + ']'; - if (value && typeof value === 'object') { - return '{' + Object.keys(value).sort().map(k => JSON.stringify(k) + ':' + stableSearchStringify(value[k])).join(',') + '}'; +function getHighlightPatterns(query) { + const normalized = String(query || ""); + if (STATE.highlightQuery === normalized) return STATE.highlightPatterns; + STATE.highlightQuery = normalized; + if (normalized.includes("*") || normalized.includes("?")) { + STATE.highlightPatterns = [new RegExp(`(${wildcardPatternToRegExp(normalized).source})`, "gi")]; + } else { + STATE.highlightPatterns = tokenize(normalized) + .filter(Boolean) + .map((token) => new RegExp(`(${token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi")); } - return JSON.stringify(value); + return STATE.highlightPatterns; } -function cloneSearchData(data) { - if (!data || !Array.isArray(data.results)) return data; - return { - results: data.results.slice(), - total: data.total, - page: data.page, - page_size: data.page_size, - }; +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 getCachedSearchResponse(key) { - const cached = searchResponseCache.get(key); - if (!cached) return null; - if (Date.now() - cached.time > SEARCH_CACHE_TTL) { - searchResponseCache.delete(key); - return null; - } - searchResponseCache.delete(key); - searchResponseCache.set(key, cached); - return cloneSearchData(cached.data); +function bytesFromUrl(value) { + if (value === null || value === "") return null; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : null; } -function setCachedSearchResponse(key, data) { - searchResponseCache.set(key, { time: Date.now(), data: cloneSearchData(data) }); - while (searchResponseCache.size > SEARCH_CACHE_MAX) { - searchResponseCache.delete(searchResponseCache.keys().next().value); +function restoreSizeInput(input, unitSelect, bytes) { + if (bytes === null) { + input.value = ""; + unitSelect.value = "MB"; + return; } + const units = [ + ["GB", 1073741824], + ["MB", 1048576], + ["KB", 1024], + ["B", 1], + ]; + let [unit, multiplier] = units[units.length - 1]; + let value = bytes; + for (const [candidateUnit, candidateMultiplier] of units) { + if (bytes < candidateMultiplier) continue; + const candidateValue = Number((bytes / candidateMultiplier).toFixed(6)); + if (Math.round(candidateValue * candidateMultiplier) !== bytes) continue; + unit = candidateUnit; + multiplier = candidateMultiplier; + value = candidateValue; + break; + } + input.value = String(value); + unitSelect.value = unit; } +const API = { + async getSources() { + const resp = await fetch("/api/sources"); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + return resp.json(); + }, + async search(body, sourceSlug, signal) { + const url = sourceSlug ? `/api/search/${sourceSlug}` : "/api/search"; + const resp = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + signal, + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + return resp.json(); + }, + async getFolders(sourceSlug) { + if (folderTreePending.has(sourceSlug)) return folderTreePending.get(sourceSlug); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const pending = fetch(`/api/folders/${sourceSlug}`, { signal: controller.signal }) + .then((resp) => resp.json()) + .finally(() => { + clearTimeout(timeoutId); + folderTreePending.delete(sourceSlug); + }); + folderTreePending.set(sourceSlug, pending); + return pending; + }, + async getContents(sourceSlug, path) { + const cacheKey = `${sourceSlug}\n${path || ""}`; + if (STATE.folderContentsCache.has(cacheKey)) return STATE.folderContentsCache.get(cacheKey); + if (folderContentsPending.has(cacheKey)) return folderContentsPending.get(cacheKey); + const qs = path ? `?path=${encodeURIComponent(path)}` : ""; + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + const pending = fetch(`/api/folders/${sourceSlug}/contents${qs}`, { signal: controller.signal }) + .then((resp) => resp.json()) + .then((data) => { + if (data && (Array.isArray(data.folders) || Array.isArray(data.files))) { + STATE.folderContentsCache.set(cacheKey, data); + } + return data; + }) + .finally(() => { + clearTimeout(timeoutId); + folderContentsPending.delete(cacheKey); + }); + folderContentsPending.set(cacheKey, pending); + return pending; + }, + async preview(docId, signal) { + const resp = await fetch(`/api/preview/${encodeURIComponent(docId)}`, { + signal, + priority: "high", + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + return resp.json(); + }, + async snippet(docId, query, exact, signal) { + const resp = await fetch(`/api/snippet/${encodeURIComponent(docId)}?q=${encodeURIComponent(query || "")}&exact=${exact ? "true" : "false"}`, { + signal, + priority: "low", + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + return resp.json(); + }, + async random(sourceSlug) { + const url = sourceSlug ? `/api/random?source=${encodeURIComponent(sourceSlug)}` : "/api/random"; + const resp = await fetch(url); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + return resp.json(); + }, +}; + function warmConnection(force = false) { if (document.hidden || !navigator.onLine || keepalivePending) return keepalivePending; const now = Date.now(); if (!force && now - lastKeepaliveAt < KEEPALIVE_MIN_GAP_MS) return null; lastKeepaliveAt = now; const controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 12000); + const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); keepalivePending = fetch("/api/ping", { cache: "no-store", signal: controller.signal, @@ -250,3003 +285,1831 @@ function warmConnection(force = false) { return keepalivePending; } -function buildCurrentSearchBodyForCache(page) { - const body = { page: page || STATE.page || 1, page_size: STATE.pageSize, sort: DOM.sortSelect ? DOM.sortSelect.value : "relevance" }; - if (STATE.query) body.q = STATE.query; - if (STATE.mode === "global" && 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; - if (!STATE.searchFolders) body.search_folders = false; - if (STATE.exact) body.exact = true; - return body; -} - -function getCurrentSearchCacheKey(page) { - const base = STATE.repo ? `/api/search/${STATE.repo}` : "/api/search"; - return base + "|" + stableSearchStringify(buildCurrentSearchBodyForCache(page)); -} - -function abortSignalWithTimeout(externalSignal, timeoutMs) { - const controller = new AbortController(); - const abortFromExternal = () => controller.abort(); - if (externalSignal) { - if (externalSignal.aborted) controller.abort(); - else externalSignal.addEventListener("abort", abortFromExternal, { once: true }); - } - const timeoutId = setTimeout(() => controller.abort(), timeoutMs); - return { - signal: controller.signal, - cleanup() { - clearTimeout(timeoutId); - if (externalSignal) externalSignal.removeEventListener("abort", abortFromExternal); - }, - }; -} - -async function fetchJsonWithTimeout(url, timeoutMs) { - const timed = abortSignalWithTimeout(null, timeoutMs); - try { - const resp = await fetch(url, { signal: timed.signal }); - if (!resp.ok) return null; - return await resp.json(); - } finally { - timed.cleanup(); - } -} - -async function fetchWithTimeout(url, timeoutMs) { - const timed = abortSignalWithTimeout(null, timeoutMs); - try { - return await fetch(url, { signal: timed.signal }); - } finally { - timed.cleanup(); - } -} - -function canUseInitialSearchPayload() { - return (STATE.page || 1) === 1 - && !STATE.query - && (DOM.sortSelect ? DOM.sortSelect.value : "relevance") === "relevance" - && STATE.searchFolders - && STATE.exact - && STATE.filterRepos.length === 0 - && STATE.filterExtensions.length === 0 - && STATE.filterFolders.length === 0 - && STATE.filterFolderSelfs.length === 0 - && STATE.filterFolderSubtrees.length === 0 - && STATE.filterMinSize === null - && STATE.filterMaxSize === null; -} - -function readInitialSearchPayload() { - const el = document.getElementById("initial-search-data"); - if (!el || !el.textContent) return null; - try { return JSON.parse(el.textContent); } - catch (e) { return null; } -} - -function getInitialPayloadUrl() { - if (STATE.mode === "repo" && STATE.repo) { - return "/data/initial/repos/" + encodeURIComponent(STATE.repo) + ".json"; +function stableStringify(value) { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`; } - return "/data/initial/global.json"; -} - -function applyInitialSearchPayload(data) { - if (!data || !Array.isArray(data.results) || !canUseInitialSearchPayload()) return false; - if (STATE.mode === "repo" && data.repo !== STATE.repoFull) return false; - if (STATE.mode === "global" && data.mode !== "global") return false; - if (searchAbortController) searchAbortController.abort(); - if (searchPrefetchAbortController) searchPrefetchAbortController.abort(); - searchAbortController = new AbortController(); - searchPrefetchAbortController = null; - searchPrefetchKey = null; - searchRequestId++; - STATE.total = data.total || 0; - STATE.page = 1; - STATE.results = data.results.slice(); - STATE._loadedPage = 1; - STATE._pageCache = {}; - STATE._pendingPage = 0; - STATE.hasMore = STATE.results.length < STATE.total; - STATE.isLoading = false; - STATE.resultsSkeletonActive = false; - DOM.resultsLoading.style.display = "none"; - DOM.resultsList.classList.remove("results-pending"); - setSearchVisualLoading(false); - setCachedSearchResponse(getCurrentSearchCacheKey(1), { - results: STATE.results, - total: STATE.total, - page: 1, - page_size: STATE.pageSize, - }); - resetVirtualScrollState(); - clearResultsSkeleton(); - if (STATE.results.length === 0) { - DOM.resultsList.innerHTML = ""; - DOM.emptyState.style.display = "flex"; - } else { - DOM.emptyState.style.display = "none"; - renderResults(); - } - updateStatusBar(); - updateLoadInfo(); - syncStateToURL(true); - prefetchNextPage(); - return true; + return JSON.stringify(value); } -async function searchWithInitialFallback() { - const searchMode = STATE.mode; - const searchRepo = STATE.repo; - const isCurrentSearchRoute = () => STATE.mode === searchMode && STATE.repo === searchRepo; - if (!canUseInitialSearchPayload()) { - if (!isCurrentSearchRoute()) return; - doSearch(); - return; +function getCachedSearch(key) { + if (!STATE.searchCache.has(key)) return null; + const entry = STATE.searchCache.get(key); + if (!entry || Date.now() - entry.time > SEARCH_CACHE_TTL) { + STATE.searchCache.delete(key); + return null; } - if (!isCurrentSearchRoute()) return; - if (applyInitialSearchPayload(readInitialSearchPayload())) return; - try { - const url = getInitialPayloadUrl(); - let data = initialPayloadCache.get(url); - if (!data) { - data = await fetchJsonWithTimeout(url, 6000); - if (!isCurrentSearchRoute()) return; - if (data) initialPayloadCache.set(url, data); - } - if (!isCurrentSearchRoute()) return; - if (data && applyInitialSearchPayload(data)) return; - } catch (e) {} - if (!isCurrentSearchRoute()) return; - doSearch(); + STATE.searchCache.delete(key); + STATE.searchCache.set(key, entry); + return entry.value; } -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 = "复制链接"; - if (DOM.multiDeselect) DOM.multiDeselect.textContent = "取消选择"; - 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]); +function setCachedSearch(key, value) { + if (value && value.indexing) return; + STATE.searchCache.set(key, { value, time: Date.now() }); + while (STATE.searchCache.size > SEARCH_CACHE_LIMIT) { + STATE.searchCache.delete(STATE.searchCache.keys().next().value); } } -const $ = (sel) => document.querySelector(sel); - -const DOM = {}; - -function cacheDOM() { +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.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.mobileToggleIcon = $("#mobile-toggle-icon"); + DOM.themeBtn = $("#theme-btn"); DOM.leftSidebar = $("#left-sidebar"); DOM.rightSidebar = $("#right-sidebar"); - DOM.sidebarContent = $("#sidebar-content"); + DOM.sidebarExpandBtn = $("#sidebar-expand-btn"); DOM.sidebarTitle = $("#sidebar-title"); - DOM.resultsList = $("#results-list"); + 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.resultCount = $("#result-count"); - 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.randomTxtBtn = $("#random-txt-btn"); - DOM.overlay = $("#overlay"); - DOM.toast = $("#toast"); - DOM.filterRepoSection = $("#filter-repo-section"); - DOM.filterRepoList = $("#filter-repo-list"); - DOM.filterFolderSection = $("#filter-folder-section"); + 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.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.multiSelectAll = $("#multi-select-all"); - DOM.multiSelectedCount = $("#multi-selected-count"); 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"); } -const HTML_ESCAPE_MAP = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }; - -function escapeHTML(str) { - return String(str).replace(/[&<>"']/g, (ch) => HTML_ESCAPE_MAP[ch]); +function parseRoute() { + const path = window.location.pathname.replace(/^\/+|\/+$/g, ""); + if (!path) return { mode: "global", source: null }; + return { mode: "source", source: decodeURIComponent(path) }; } -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"; -} - -const highlightRegexCache = new Map(); - -function getHighlightRegexes(query) { - const cached = highlightRegexCache.get(query); - if (cached) return cached; - const regexes = query.split(/\s+/).filter((t) => t.length > 0).map(function(tok) { - const escapedTok = escapeHTML(tok); - return new RegExp( - `(${escapedTok.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, - "gi" - ); - }); - if (highlightRegexCache.size >= 20) highlightRegexCache.delete(highlightRegexCache.keys().next().value); - highlightRegexCache.set(query, regexes); - return regexes; +function clearSourceScopedState() { + STATE.folderSelections = []; + STATE.folderTree = []; + STATE.folderTreeSource = null; + STATE.folderNodeMap = {}; + STATE.folderCollapsed = {}; + sidebarCurrentPath = ""; + if (DOM.filterFolderTree) { + DOM.filterFolderTree.querySelectorAll("input[data-folder-path]").forEach((checkbox) => { + checkbox.checked = false; + checkbox.indeterminate = false; + }); + } } -function highlightText(text, query) { - if (!query || !text) return escapeHTML(text); - const escaped = escapeHTML(text); - const regexes = getHighlightRegexes(query); - if (regexes.length === 0) return escaped; - let result = escaped; - for (const regex of regexes) { - result = result.replace(regex, "$1"); +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 { + clearSourceScopedState(); + DOM.sidebarTitle.textContent = "数据包"; + if (DOM.sidebarExpandBtn) { + DOM.sidebarExpandBtn.style.display = "none"; + DOM.leftSidebar.classList.remove("expanded-wide"); + DOM.sidebarExpandBtn.textContent = "↔"; + } } - return result; } -function toMirrorURL(url) { - if (!url) return url; +function navigateToSource(sourceSlug) { + history.pushState(null, "", `/${encodeURIComponent(sourceSlug)}`); + STATE.page = 1; + STATE.folderSelections = []; + syncRoute(); + const routeId = ++routeRenderId; + sidebarCurrentPath = ""; + renderSidebar(routeId); + renderFolderTree(false, routeId); + doSearch(); +} + +async function navigateToSourceFolder(sourceSlug, folderPath) { + history.pushState(null, "", `/${encodeURIComponent(sourceSlug)}`); + STATE.page = 1; + STATE.folderSelections = folderPath ? [folderPath] : []; + syncRoute(); + const routeId = ++routeRenderId; + sidebarCurrentPath = ""; + await renderSidebar(routeId); + await renderFolderTree(false, routeId); + refreshFolderTreeSelectionState(); + syncUrl(); + doSearch(); +} + +function navigateHome() { + history.pushState(null, "", "/"); + STATE.page = 1; + syncRoute(); + clearSourceScopedState(); + syncUrl(); + const routeId = ++routeRenderId; + sidebarCurrentPath = ""; + renderSidebar(routeId); + renderFolderTree(false, routeId); + doSearch(); +} + +function getSelectedSourcesForSearch() { + if (STATE.source) return [STATE.source]; + if (STATE.selectedSources.length) return STATE.selectedSources.slice(); + return null; +} + +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.source && 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) { + clearTimeout(showToast._timer); + clearTimeout(showToast._hideTimer); + DOM.toast.classList.remove("is-leaving"); + DOM.toast.textContent = message; + DOM.toast.style.display = ""; + showToast._timer = setTimeout(() => { + const exitDuration = motionDuration(150); + if (exitDuration) DOM.toast.classList.add("is-leaving"); + showToast._hideTimer = setTimeout(() => { + DOM.toast.style.display = "none"; + DOM.toast.classList.remove("is-leaving"); + }, exitDuration); + }, duration); +} + +function setHistoryDropdownOpen(open) { + DOM.searchHistoryDropdown.style.display = open ? "" : "none"; +} + +function getHistory() { 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; + return JSON.parse(sessionStorage.getItem(HISTORY_KEY)) || []; + } catch (_error) { + return []; } } -function getCopyableLink(link) { - return STATE.useMirrorLinks ? toMirrorURL(link) : link; +function saveHistory(items) { + sessionStorage.setItem(HISTORY_KEY, JSON.stringify(items.slice(0, 20))); } -function getPreviewLink(path) { - return STATE.useMirrorLinks ? toMirrorURL(path) : path; +function removeHistory(query) { + const items = getHistory().filter((item) => item !== query); + saveHistory(items); + renderHistoryDropdown(); } -function openExternalWindow(url) { - const popup = window.open(url, "_blank", "noopener,noreferrer"); - if (popup) popup.opener = null; - return popup; +function clearHistory() { + saveHistory([]); + renderHistoryDropdown(); } -function buildDownloadUrl(filename, link) { - return `/api/download?file=${encodeURIComponent(filename || "file")}&link=${encodeURIComponent(link || "")}`; +function addHistory(query) { + if (!STATE.historyEnabled || !query) return; + const items = getHistory().filter((item) => item !== query); + items.unshift(query); + saveHistory(items); } -function triggerDownload(url) { - const iframe = document.createElement("iframe"); - iframe.src = url; - iframe.style.display = "none"; - iframe.setAttribute("aria-hidden", "true"); - document.body.appendChild(iframe); - setTimeout(() => iframe.remove(), 60000); +function renderHistoryDropdown() { + const items = getHistory(); + if (!items.length) { + setHistoryDropdownOpen(false); + return; + } + DOM.searchHistoryDropdown.innerHTML = items.map((item) => ` +
+ ${escapeHTML(item)} + +
`).join("") + ''; + setHistoryDropdownOpen(true); } -async function downloadFile(filename, link, options = {}) { - showToast("开始下载..."); - try { - if (!options.skipCheck) { - const resp = await fetchWithTimeout(`/api/download/check?link=${encodeURIComponent(link || "")}`, DOWNLOAD_CHECK_TIMEOUT); - if (!resp.ok) { - let message = "下载失败"; - try { - const data = await resp.json(); - if (data && data.error) message = data.error; - } catch (e) {} - showToast(message, 3500); - return false; - } +function applyTheme() { + document.body.classList.toggle("light", !STATE.isDark); + DOM.themeBtn.innerHTML = themeIcon(STATE.isDark); + DOM.themeBtn.setAttribute("aria-label", STATE.isDark ? "切换到白天模式" : "切换到黑夜模式"); + DOM.themeBtn.title = STATE.isDark ? "白天模式" : "黑夜模式"; + const previewThemeBtn = DOM.previewPanel ? DOM.previewPanel.querySelector("[data-action='toggle-preview-theme']") : null; + if (previewThemeBtn) { + previewThemeBtn.setAttribute("aria-label", STATE.isDark ? "切换到白天模式" : "切换到黑夜模式"); + previewThemeBtn.title = STATE.isDark ? "白天模式" : "黑夜模式"; + previewThemeBtn.innerHTML = themeIcon(STATE.isDark); + } +} + +function toggleTheme() { + const animate = motionAllowed(); + DOM.resultsList.classList.add("theme-static"); + STATE.isDark = !STATE.isDark; + applyTheme(); + localStorage.setItem("theme", STATE.isDark ? "dark" : "light"); + requestAnimationFrame(() => DOM.resultsList.classList.remove("theme-static")); + if (animate) { + document.querySelectorAll(".theme-icon-svg").forEach((icon) => { + icon.getAnimations().forEach((animation) => animation.cancel()); + icon.animate([ + { opacity: 0.15, transform: "rotate(-160deg) scale(0.5)" }, + { opacity: 1, transform: "rotate(14deg) scale(1.16)", offset: 0.64 }, + { opacity: 1, transform: "rotate(0) scale(1)" }, + ], { duration: 520, easing: "cubic-bezier(0.05, 0.7, 0.1, 1)" }); + }); + } +} + +function applyMobileMode() { + document.body.classList.toggle("mobile", STATE.isMobile); + document.body.classList.toggle("force-desktop", !STATE.isMobile); + if (STATE.previewDocId && DOM.previewPanel.style.display !== "none") { + DOM.previewPanel.style.display = STATE.isMobile ? "flex" : "block"; + } + if (DOM.mobileToggleIcon) { + DOM.mobileToggleIcon.className = STATE.isMobile ? "ui-icon ui-icon-phone" : "ui-icon ui-icon-desktop"; + } + if (DOM.sidebarExpandBtn) { + DOM.sidebarExpandBtn.style.display = (!STATE.isMobile && STATE.source) ? "" : "none"; + if (STATE.isMobile) { + DOM.leftSidebar.classList.remove("expanded-wide"); + DOM.sidebarExpandBtn.textContent = "↔"; } - triggerDownload(buildDownloadUrl(filename, link)); - return true; - } catch (e) { - console.error(e); - showToast("下载失败,请稍后重试", 3500); - return false; } + 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", "0"); + if (!STATE.fulltext) params.set("fulltext", "0"); + if (!STATE.searchPaths) params.set("search_paths", "0"); + if (!STATE.historyEnabled) params.set("history", "0"); + if (!STATE.leftSidebarOpen) params.set("sidebar", "0"); + if (DOM.leftSidebar.classList.contains("expanded-wide")) params.set("wide", "1"); + if (STATE.rightSidebarOpen) params.set("filters", "1"); + if (STATE.previewDocId) params.set("preview", STATE.previewDocId); + const basePath = STATE.source ? `/${encodeURIComponent(STATE.source)}` : "/"; + const nextUrl = params.toString() ? `${basePath}?${params.toString()}` : basePath; + STATE.searchUrlWithoutPreview = searchUrlKey(nextUrl); + const previewEntry = STATE.previewDocId && (!replace || (history.state && history.state.previewEntry === true)); + const historyState = STATE.previewDocId ? { preview: true, previewEntry } : null; + if (replace) history.replaceState(historyState, "", nextUrl); + else history.pushState(historyState, "", nextUrl); } -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 searchUrlKey(value = window.location.href) { + const url = new URL(value, window.location.origin); + for (const key of ["preview", "sidebar", "wide", "filters", "history"]) { + url.searchParams.delete(key); + } + return `${url.pathname}${url.searchParams.toString() ? `?${url.searchParams.toString()}` : ""}`; } -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/${encodeRecordPath(relativePath)}`; +function applyUiUrlState(params = new URLSearchParams(window.location.search)) { + STATE.historyEnabled = params.get("history") !== "0"; + DOM.historyToggle.checked = STATE.historyEnabled; + STATE.leftSidebarOpen = !STATE.isMobile && params.get("sidebar") !== "0"; + STATE.rightSidebarOpen = params.get("filters") === "1"; + STATE.previewDocId = params.get("preview") || null; + DOM.leftSidebar.classList.toggle("expanded-wide", params.get("wide") === "1" && !STATE.isMobile); + if (DOM.sidebarExpandBtn) DOM.sidebarExpandBtn.textContent = params.get("wide") === "1" && !STATE.isMobile ? "→" : "↔"; } -function buildRecordRelativePath(rec) { - const filename = rec.File || ""; - const extension = rec.Extension || ""; - const fullName = extension ? `${filename}.${extension}` : filename; - const folders = Array.isArray(rec.Folder) ? rec.Folder : []; - return folders.length > 0 ? `${folders.join("/")}/${fullName}` : fullName; +function loadUrlState() { + const params = new URLSearchParams(window.location.search); + STATE.page = 1; + STATE.query = params.get("q") || ""; + STATE.selectedSources = params.getAll("source"); + STATE.folderSelections = STATE.source ? params.getAll("folder") : []; + STATE.minSize = bytesFromUrl(params.get("min_size")); + STATE.maxSize = bytesFromUrl(params.get("max_size")); + STATE.exact = params.get("exact") !== "0"; + STATE.fulltext = params.get("fulltext") !== "0"; + STATE.searchPaths = params.get("search_paths") !== "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.sortSelect.value = sort; + applyUiUrlState(params); + restoreSizeInput(DOM.filterMinSize, DOM.filterMinUnit, STATE.minSize); + restoreSizeInput(DOM.filterMaxSize, DOM.filterMaxUnit, STATE.maxSize); } -function encodeRecordPath(path) { - return String(path || "").split("/").map(encodeURIComponent).join("/"); +function buildSearchBody(page = STATE.page) { + return { + q: STATE.query, + sources: getSelectedSourcesForSearch(), + folders: STATE.source ? STATE.folderSelections : [], + min_size: STATE.minSize, + max_size: STATE.maxSize, + page, + page_size: STATE.pageSize, + sort: DOM.sortSelect.value, + exact: STATE.exact, + search_paths: STATE.searchPaths, + fulltext: STATE.fulltext, + }; } -let readerAssets = null; -let readerAssetsPending = null; +function searchCacheKey(body, sourceSlug = STATE.source) { + return `${sourceSlug || ""}\n${stableStringify(body)}`; +} -function loadReaderAssets() { - if (readerAssets) return Promise.resolve(readerAssets); - if (readerAssetsPending) return readerAssetsPending; - readerAssetsPending = fetchWithTimeout("/api/reader-assets", 10000) - .then(resp => resp.ok ? resp.json() : null) - .then(data => { - if (!data || data.v !== 1 || !data.f || typeof data.f !== "object") throw new Error("READER_ASSETS_UNAVAILABLE"); - readerAssets = data.f; - return readerAssets; - }) - .catch(() => ({})) - .finally(() => { readerAssetsPending = null; }); - return readerAssetsPending; -} - -function applyReaderAsset(record, repo, relativePath, originalLink) { - const asset = readerAssets && readerAssets[`${repo}\0${relativePath}`]; - if (!asset || asset.s !== 2 || !["p", "e", "d", "h"].includes(asset.m) || !/^objects\/[0-9a-f]{2}\/[0-9a-f]{64}\/(?:[a-z0-9-]+\/)?(document\.pdf|book\.epub|document\.docx|document\.html)$/.test(asset.p || "")) return record; - return Object.assign({}, record, { - ReaderLink: `https://huggingface.co/datasets/vomebook/Reader-Assets/resolve/main/${asset.p}`, - ReaderExtension: asset.m === "p" ? "pdf" : asset.m === "e" ? "epub" : asset.m === "d" ? "docx" : "html", - DownloadLink: originalLink, - }); +function initialUrlForSource(sourceSlug) { + return sourceSlug ? `/data/initial/sources/${encodeURIComponent(sourceSlug)}.json` : "/data/initial/global.json"; } -function getRecordLink(rec) { - if (rec.Link) return rec.Link; - return `https://huggingface.co/datasets/${rec.Repo || ""}/resolve/main/${encodeRecordPath(buildRecordRelativePath(rec))}`; +function sidebarInitialUrlForSource(sourceSlug) { + return sourceSlug ? `/data/sidebar/sources/${encodeURIComponent(sourceSlug)}.json` : "/data/sidebar/global.json"; } -function getReaderFolderUrl(rec) { - const repo = String(rec.Repo || "").split("/").pop(); - if (!repo) return ""; - const target = new URL(location.href); - target.pathname = `/${encodeURIComponent(repo)}`; - target.hash = ""; - for (const key of ["repo", "folder_self", "folder_subtree", "path"]) target.searchParams.delete(key); - const folder = Array.isArray(rec.Folder) ? rec.Folder.join("/") : ""; - if (folder) target.searchParams.append("folder_self", folder); - return target.href; +function readInlineInitialPayload() { + const node = document.getElementById("initial-search-data"); + if (!node || node.dataset.consumed === "1") return null; + node.dataset.consumed = "1"; + try { + return JSON.parse(node.textContent || "null"); + } catch (_error) { + return null; + } } -function getReaderLink(rec, returnUrl = location.href) { - const readerRecord = Object.assign({}, rec, { Link: getRecordLink(rec), ReturnUrl: returnUrl, FolderUrl: getReaderFolderUrl(rec) }); - if (rec.HasTxt && String(rec.Extension || "").toLowerCase() !== "txt") { - const relPath = buildRecordRelativePath(rec); - const stem = relPath.includes(".") ? relPath.slice(0, relPath.lastIndexOf(".")) : relPath; - readerRecord.OcrUrl = `/txt/${encodeRecordPath(stem)}.txt`; +async function loadSidebarInitial(sourceSlug) { + const url = sidebarInitialUrlForSource(sourceSlug); + if (sidebarInitialCache.has(url)) return sidebarInitialCache.get(url); + const inlineData = readInlineJson("initial-sidebar-data"); + if (inlineData && ((inlineData.source || null) === (sourceSlug || null))) { + sidebarInitialCache.set(url, inlineData); + return inlineData; } - return VoiceOfMLReader.readerUrl(readerRecord, "/static/reader.html"); + const pending = fetch(url).then((resp) => resp.ok ? resp.json() : null); + sidebarInitialCache.set(url, pending); + const data = await pending; + sidebarInitialCache.set(url, data); + return data; } -function navigateToReader(rawUrl, returnUrl = location.href) { - const url = new URL(rawUrl, location.origin); - if (url.origin !== location.origin || url.pathname !== "/static/reader.html") return false; - url.searchParams.set("return", returnUrl); +function readInlineJson(id) { + const node = document.getElementById(id); + if (!node || node.dataset.consumed === "1") return null; + node.dataset.consumed = "1"; try { - const token = typeof crypto.randomUUID === "function" - ? crypto.randomUUID() - : Array.from(crypto.getRandomValues(new Uint32Array(4)), (value) => value.toString(16).padStart(8, "0")).join(""); - sessionStorage.setItem(`reader-return:${token}`, new URL(returnUrl, location.origin).href); - url.searchParams.set("nav", token); - } catch (_) {} - location.assign(url.href); - return true; + return JSON.parse(node.textContent || "null"); + } catch (_error) { + return null; + } } -const warmedReaderAssets = new Set(); -const warmedReaderSources = new Set(); -function warmReaderIntent(rawUrl) { - if (!rawUrl) return; - let extension = "", sourceUrl = ""; +function currentStateCanUseInitial() { + return STATE.page === 1 + && STATE.query === "" + && DOM.sortSelect.value === "relevance" + && STATE.exact === true + && STATE.searchPaths === true + && STATE.fulltext === true + && !STATE.folderSelections.length + && !STATE.selectedSources.length + && STATE.minSize === null + && STATE.maxSize === null; +} + +function initialMatchesCurrentState(data) { + if (!data) return false; + return data.q === STATE.query + && data.page === 1 + && data.page_size === STATE.pageSize + && data.sort === DOM.sortSelect.value + && data.exact === STATE.exact + && data.search_paths === STATE.searchPaths + && data.fulltext === STATE.fulltext + && (data.source || null) === (STATE.source || null) + && !STATE.folderSelections.length + && !STATE.selectedSources.length + && STATE.minSize === null + && STATE.maxSize === null; +} + +async function loadInitialPayload() { + const url = initialUrlForSource(STATE.source); + if (initialPayloadCache.has(url)) return initialPayloadCache.get(url); + const inlineData = readInlineInitialPayload(); + if (inlineData && (inlineData.source || null) === (STATE.source || null)) { + initialPayloadCache.set(url, inlineData); + return inlineData; + } + const resp = await fetch(url); + if (!resp.ok) return null; + const data = await resp.json(); + initialPayloadCache.set(url, data); + return data; +} + +async function applyInitialSearch(sequence) { + if (!currentStateCanUseInitial()) return false; + let data; try { - const readerUrl = new URL(rawUrl, location.origin); - extension = (readerUrl.searchParams.get("ext") || "").toLowerCase(); - sourceUrl = readerUrl.searchParams.get("url") || ""; - } catch (_) { return; } - const shellAssets = ["/static/reader.css", "/static/reader-contract.js", "/static/reader-store.js", "/static/reader.js"]; - const engineAssets = extension === "pdf" - ? ["/static/vendor/pdf.min.e0be3863c23c.mjs", "/static/pdf-worker-wrapper.mjs", "/static/vendor/pdf.worker.min.0613f41490dd.mjs"] - : extension === "epub" ? ["/static/vendor/jszip.min.acc7e41455a8.js", "/static/vendor/epub.min.06eae1574510.js"] - : extension === "docx" ? ["/static/vendor/jszip.min.acc7e41455a8.js", "/static/vendor/docx-preview.min.3573b8d99344.js"] - : ["md", "markdown", "html", "htm"].includes(extension) ? ["/static/vendor/marked.min.eaccee2fb9fb.js", "/static/vendor/purify.min.c2f26ea4fc0d.js"] : []; - for (const href of shellAssets.concat(engineAssets)) { - if (warmedReaderAssets.has(href)) continue; - warmedReaderAssets.add(href); - const link = document.createElement("link"); link.rel = "prefetch"; link.href = href; document.head.appendChild(link); - } - if (sourceUrl && warmedReaderSources.size < 8 && !warmedReaderSources.has(sourceUrl)) { - warmedReaderSources.add(sourceUrl); - fetch(`/api/reader-content?url=${encodeURIComponent(sourceUrl)}`, { - method: "HEAD", cache: "no-store", keepalive: true, - }).catch(() => {}); - } - try { fetch("/api/ping", { cache: "no-store" }).catch(() => {}); } catch (_) {} -} - -function setupReaderIntentWarming() { - const warm = (event) => { - const target = event.target.closest("[data-reader-url], [data-read-url]"); - if (target) warmReaderIntent(target.dataset.readerUrl || target.dataset.readUrl); - }; - for (const type of ["pointerover", "pointerdown", "focusin"]) document.addEventListener(type, warm, { passive: true }); + data = await loadInitialPayload(); + } catch (_error) { + return false; + } + if (sequence !== STATE.searchSequence || !initialMatchesCurrentState(data)) return false; + const body = buildSearchBody(1); + const cacheKey = searchCacheKey(body); + setCachedSearch(cacheKey, data); + STATE.total = data.total || 0; + STATE.results = data.results || []; + STATE.isLoading = false; + DOM.resultsLoading.style.display = "none"; + setSearchMotion(false); + renderResults(); + updateStatus(); + DOM.resultsContainer.scrollTop = 0; + requestAnimationFrame(() => { STATE.suppressAutoLoad = false; }); + prefetchNextPage(); + return true; } -function isReadableRecord(rec) { - if (String(rec && (rec.ReaderExtension || rec.Extension) || "").toLowerCase() === "docx" && !rec.ReaderLink) return false; - return VoiceOfMLReader.capability(rec && (rec.ReaderExtension || rec.Extension)).article; +async function pathSearch(signal, page = STATE.page) { + const body = buildSearchBody(page); + const cacheKey = searchCacheKey(body); + const cached = getCachedSearch(cacheKey); + if (cached) return { data: cached, page, cached: true }; + const data = await API.search(body, STATE.source, signal); + setCachedSearch(cacheKey, data); + return { data, page }; } -function getRecordPath(rec) { - if (rec.Path) return rec.Path; - return `https://huggingface.co/datasets/${rec.Repo || ""}/blob/main/${encodeRecordPath(buildRecordRelativePath(rec))}`; +function abortSearchPrefetch() { + if (searchPrefetchController) searchPrefetchController.abort(); + searchPrefetchController = null; + searchPrefetchCacheKey = ""; } -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.page_size || STATE.pageSize; - if (!isRepo && params.repos && params.repos.length > 0) - body.repos = params.repos; - if (params.extensions && params.extensions.length > 0) - body.extensions = params.extensions; - if (params.folder_match_mode === "mixed") { - body.folders = params.folders; - body.folder_match_mode = "mixed"; - body.folder_selfs = params.folder_selfs; - body.folder_subtrees = params.folder_subtrees; - } else if (params.folders && params.folders.length > 0) { - body.folders = params.folders; +function prefetchNextPage() { + if (STATE.isLoading || STATE.results.length >= STATE.total) return; + const nextPage = Math.floor(STATE.results.length / STATE.pageSize) + 1; + if (nextPage <= STATE.page) return; + const body = buildSearchBody(nextPage); + const cacheKey = searchCacheKey(body); + if (getCachedSearch(cacheKey)) return; + if (searchPrefetchController && searchPrefetchCacheKey === cacheKey) return; + abortSearchPrefetch(); + const controller = new AbortController(); + searchPrefetchController = controller; + searchPrefetchCacheKey = cacheKey; + API.search(body, STATE.source, controller.signal) + .then((data) => { + if (!controller.signal.aborted) setCachedSearch(cacheKey, data); + }) + .catch((error) => { + if (error.name !== "AbortError") console.warn("搜索预取失败", error); + }) + .finally(() => { + if (searchPrefetchController === controller) { + searchPrefetchController = null; + searchPrefetchCacheKey = ""; } - if (params.min_size != null) body.min_size = params.min_size; - if (params.max_size != null) body.max_size = params.max_size; - body.sort = params.sort || DOM.sortSelect.value; - if (params.search_folders === false) body.search_folders = false; - if (params.exact) body.exact = true; - const cacheKey = base + "|" + stableSearchStringify(body); - const cached = getCachedSearchResponse(cacheKey); - if (cached) return cached; - const fetchOptions = { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify(body), - }; - const timed = abortSignalWithTimeout(params.signal, SEARCH_REQUEST_TIMEOUT); - fetchOptions.signal = timed.signal; - try { - const resp = await fetch(base, fetchOptions); - if (!resp.ok) throw new Error(`HTTP ${resp.status}`); - const data = await resp.json(); - setCachedSearchResponse(cacheKey, data); - return cloneSearchData(data); - } finally { - timed.cleanup(); - } - }, - async getRepos() { - if (this._repoCache) return this._repoCache; - if (this._repoPending) return this._repoPending; - this._repoPending = this.getBootstrap() - .then(data => data && Array.isArray(data.repos) ? data.repos : null) - .then(data => data || fetch("/api/repos").then(resp => resp.json())) - .then(data => { - this._repoCache = data; - return data; - }) - .finally(() => { this._repoPending = null; }); - return this._repoPending; - }, - async getExtensions(repo) { - const cacheKey = repo || "__global__"; - if (this._extCache && this._extCache.key === cacheKey) return this._extCache.data; - if (!repo) { - try { - const bootstrap = await this.getBootstrap(); - if (bootstrap && Array.isArray(bootstrap.extensions)) { - this._extCache = { key: cacheKey, data: bootstrap.extensions }; - return bootstrap.extensions; - } - } catch (e) { } - } - const url = repo ? `/api/extensions?repo=${repo}` : "/api/extensions"; - let resp = await fetch(url); - let data = await resp.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 controller = new AbortController(); - const timeoutId = setTimeout(() => controller.abort(), 12000); - const pending = fetch(`/api/folders/${repo}/contents${qs}`, { signal: controller.signal }) - .then(resp => { - if (!resp.ok) return null; - return resp.json(); - }) - .then(data => { - clearTimeout(timeoutId); - this._browserPending.delete(cacheKey); - if (!data || (!data.folders && !data.files)) return null; - 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 => { - clearTimeout(timeoutId); - 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(); - }, - async getBootstrap() { - if (this._bootstrapCache) return this._bootstrapCache; - if (this._bootstrapPending) return this._bootstrapPending; - this._bootstrapPending = fetchJsonWithTimeout("/api/bootstrap", 4000) - .then(data => { - if (data && typeof data === "object") this._bootstrapCache = data; - return data; - }) - .finally(() => { this._bootstrapPending = null; }); - return this._bootstrapPending; - }, -}; - -function sidebarInitialUrlForRepo(repo) { - return repo ? `/data/sidebar/repos/${encodeURIComponent(repo)}.json` : "/data/sidebar/global.json"; -} - -function normalizeSidebarPayload(data, path = "") { - if (!data || typeof data !== "object") return data; - if (Array.isArray(data.repos)) return data; - return { - repo: data.repo || "", - path: data.path || path || "", - folders: (data.folders || []).map((item) => { - const name = item.n || ""; - return { - name, - path: path ? `${path}/${name}` : name, - count: item.c || 0, - }; - }), - files: (data.files || []).map((item) => ({ - name: item.n || "", - ext: item.e || "", - hasTxt: Boolean(item.t), - size: item.s === undefined ? "" : item.s, - link: "", - })), - }; -} - -async function loadSidebarInitial(repo) { - const key = repo || "__global__"; - if (sidebarInitialCache.has(key)) return sidebarInitialCache.get(key); - if (sidebarInitialPending.has(key)) return sidebarInitialPending.get(key); - const pending = (repo ? Promise.resolve(null) : API.getBootstrap().then(data => data && data.sidebar || null)) - .then(data => data || fetchJsonWithTimeout(sidebarInitialUrlForRepo(repo), 4000)) - .then(raw => { - if (!raw) return null; - const data = normalizeSidebarPayload(raw); - sidebarInitialCache.set(key, data); - return data; - }) - .catch(() => null) - .finally(() => { sidebarInitialPending.delete(key); }); - sidebarInitialPending.set(key, pending); - return pending; -} - -let routeInitialized = false; -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.filterMinSize !== null) params.set("min_size", fmtSizeUrl(STATE.filterMinSize)); - if (STATE.filterMaxSize !== null) params.set("max_size", fmtSizeUrl(STATE.filterMaxSize)); - if (STATE.filterExtensions.length > 0) params.set("ext", STATE.filterExtensions.join(",")); - 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; - if (STATE.results.length === 0) STATE.total = 0; - prepareRouteTransitionResults(); - STATE.folderTree = null; - STATE.filterFolders = []; - STATE.filterFolderSubtrees = []; - STATE.filterFolderSelfs = []; - STATE.folderTreeCollapsed = {}; - STATE.browserPath = ""; - API._extCache = null; - syncURLToState(); - } - this.updateUI(); - updateRandomTxtVisibility(); - 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 { - syncURLToState(); - const routeId = ++routeRenderId; - searchWithInitialFallback(); - renderSidebar(routeId); - renderFilters(routeId); - } - routeInitialized = true; - }, - 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 && STATE.results.length === 0) { - DOM.resultsList.innerHTML = ""; - DOM.emptyState.style.display = "none"; - DOM.resultsLoading.style.display = "none"; - clearResultsSkeleton(); - } - const routeId = ++routeRenderId; - searchWithInitialFallback(); - renderSidebar(routeId); - renderFilters(routeId); - DOM.sidebarExpandBtn.style.display = (STATE.mode === "repo" && !STATE.isMobile) ? "" : "none"; - }, -}; - -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 (STATE.mode !== "global") { - STATE.filterFolderSelfs.forEach(folder => params.append("folder_self", folder)); - STATE.filterFolderSubtrees.forEach(folder => params.append("folder_subtree", folder)); - } - 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"); - if (!STATE.useMirrorLinks) params.set("mirror", "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}`); - STATE.browserPath = params.get("path") || ""; - const sort = params.get("sort") || "relevance"; - DOM.sortSelect.value = sort; - const extStr = params.get("ext"); - if (extStr !== null) { - STATE.filterExtensions = extStr ? extStr.split(",").filter(Boolean) : []; - saveStoredExtensionFilters(); - } else if (!routeInitialized) { - STATE.filterExtensions = loadStoredExtensionFilters(); - } else { - STATE.filterExtensions = []; - saveStoredExtensionFilters(); - } - 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; - if (STATE.mode !== "global") { - const urlSelfs = params.getAll("folder_self").filter(Boolean); - const urlSubtrees = params.getAll("folder_subtree").filter(Boolean); - if (urlSelfs.length || urlSubtrees.length) { - STATE.filterFolderSelfs = urlSelfs; - STATE.filterFolderSubtrees = urlSubtrees; - STATE.filterFolders = mergeFolderFilters(STATE.filterFolderSelfs, STATE.filterFolderSubtrees); - } else { - STATE.filterFolderSelfs = []; - STATE.filterFolderSubtrees = []; - STATE.filterFolders = []; - } - } else { - STATE.filterFolderSelfs = []; - STATE.filterFolderSubtrees = []; - STATE.filterFolders = []; - } - 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 = "→"; - } else { - DOM.leftSidebar.classList.remove("expanded-wide"); - DOM.sidebarExpandBtn.textContent = "↔"; - } - STATE.leftSidebarOpen = params.get("sidebar") !== "0"; - STATE.rightSidebarOpen = params.get("filters") === "1"; - updateSidebarVisibility(); - STATE.page = 1; -} -let searchTimer = null; -let randomTxtStatusId = 0; - -async function updateRandomTxtVisibility() { - if (!DOM.randomTxtBtn) return; - const id = ++randomTxtStatusId; - DOM.randomTxtBtn.style.display = "none"; - const repo = STATE.mode === "repo" && STATE.repo ? STATE.repo : ""; - try { - const url = repo ? `/api/random-reader/status?repo=${encodeURIComponent(repo)}` : "/api/random-reader/status"; - const data = await fetchJsonWithTimeout(url, 4000); - if (!data) throw new Error("reader status unavailable"); - if (id !== randomTxtStatusId) return; - DOM.randomTxtBtn.style.display = data && data.available ? "" : "none"; - } catch (e) { - if (id === randomTxtStatusId) DOM.randomTxtBtn.style.display = "none"; - } -} - -async function renderSidebar(routeId) { - if (STATE.mode === "global") { - await renderRepoList(routeId); - } else { - await renderBrowser(STATE.browserPath || "", routeId); - } +function setSearchMotion(loading, refreshing = false) { + DOM.resultsContainer.classList.toggle("is-searching", loading); + DOM.resultsContainer.classList.toggle("is-refreshing", loading && refreshing); + DOM.resultsContainer.setAttribute("aria-busy", loading ? "true" : "false"); } -function renderRepoListItems(repos) { - let html = ""; - for (const repo of repos) { - const shortName = repo.name.split("/").pop(); - html += `
${escapeHTML(shortName)}${(repo.count || 0).toLocaleString()}
`; +async function doSearch() { + if (STATE.searchController) STATE.searchController.abort(); + abortSearchPrefetch(); + const sequence = ++STATE.searchSequence; + if (STATE.page === 1 && STATE.selectedIds.size) { + STATE.selectedIds.clear(); + DOM.multiSelectedCount.textContent = ""; } - DOM.sidebarContent.innerHTML = html; -} - -async function renderRepoList(routeId) { - let renderedInitial = false; - if (!STATE.repoList || STATE.repoList.length === 0) { - const initial = await loadSidebarInitial(null); - if (initial && Array.isArray(initial.repos) && initial.repos.length) { - if (routeId && routeId !== routeRenderId) return; - renderRepoListItems(initial.repos); - renderedInitial = true; - } - try { - const repos = await API.getRepos(); - if (routeId && routeId !== routeRenderId) return; - STATE.repoList = repos; - } catch (e) { - if (renderedInitial) return; - if (!routeId || routeId === routeRenderId) DOM.sidebarContent.innerHTML = ''; - return; - } - } - if (routeId && routeId !== routeRenderId) return; - const repos = Array.isArray(STATE.repoList) ? STATE.repoList : []; - renderRepoListItems(repos); -} - -function renderBrowserListItems(list, data, currentRepo, path) { - let html = ""; - for (const f of (data.folders || [])) { - html += `
${ICONS.folder}${escapeHTML(f.name)}${(f.count || 0).toLocaleString()}
`; - } - for (const f of (data.files || [])) { - const iconType = getFileIconType(f.ext); - const sizeStr = formatSize(f.size); - const displayName = getBrowserFileName(f); - const fileLink = getBrowserFileLink(currentRepo, path, f); - const sourceRepo = currentRepo.startsWith("VoiceOfML/") ? currentRepo : `VoiceOfML/${currentRepo}`; - const assetPath = path ? `${path}/${displayName}` : displayName; - let browserRecord = { File: f.name, Extension: f.ext, Link: fileLink, ReturnUrl: location.href }; - if (f.hasTxt && String(f.ext || "").toLowerCase() !== "txt") { - const relativePath = path ? `${path}/${f.name}` : f.name; - const stem = f.ext ? relativePath.replace(new RegExp(`\\.${f.ext}$`, "i"), "") : relativePath; - browserRecord.OcrUrl = `/txt/${encodeRecordPath(stem)}.txt`; - } - browserRecord = applyReaderAsset(browserRecord, sourceRepo, assetPath, fileLink); - const readUrl = isReadableRecord(browserRecord) ? VoiceOfMLReader.readerUrl(browserRecord, "/static/reader.html") : ""; - html += `
${ICONS[iconType] || ICONS.file}${escapeHTML(displayName)}下载${sizeStr ? '' + sizeStr + '' : ''}
`; - } - list.innerHTML = html; -} - -async function renderBrowser(path, routeId) { - if (routeId && routeId !== routeRenderId) return; - STATE.browserPath = path; - syncStateToURL(true); - DOM.sidebarContent.innerHTML = ""; - const currentRepo = STATE.repo; - await loadReaderAssets(); - if (routeId && routeId !== routeRenderId) return; - 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, ++routeRenderId); - }); - }); - DOM.sidebarContent.appendChild(bc); + if (await applyInitialSearch(sequence)) return; + const controller = new AbortController(); + let timedOut = false; + const timeoutId = setTimeout(() => { + timedOut = true; + controller.abort(); + }, REQUEST_TIMEOUT_MS); + STATE.searchController = controller; + STATE.isLoading = true; + DOM.resultsLoading.style.display = "flex"; + setSearchMotion(true, STATE.page === 1 && STATE.results.length > 0); + if (STATE.page === 1) { + STATE.suppressAutoLoad = true; + DOM.resultsContainer.scrollTop = 0; } - const list = document.createElement("div"); - list.className = "browser-list"; - list.innerHTML = ''; - DOM.sidebarContent.appendChild(list); - let renderedInitial = false; try { - const repo = STATE.repo; - if (!path) { - const initial = await loadSidebarInitial(repo); - if (initial && (!routeId || routeId === routeRenderId) && STATE.mode === "repo" && STATE.repo === repo && STATE.browserPath === path) { - renderBrowserListItems(list, initial, currentRepo, path); - renderedInitial = true; - } - } - const data = await API.getContents(repo, path); - if (routeId && routeId !== routeRenderId) return; - if (STATE.mode !== "repo" || STATE.repo !== repo || STATE.browserPath !== path) return; - sidebarRetryCounts.delete(repo + "|" + (path || "")); - renderBrowserListItems(list, data || {}, currentRepo, path); - } catch (e) { - if (renderedInitial) return; - if ((!routeId || routeId === routeRenderId) && STATE.mode === "repo" && STATE.browserPath === path) { - const retryKey = STATE.repo + "|" + (path || ""); - const tries = sidebarRetryCounts.get(retryKey) || 0; - if (tries < 2) { - sidebarRetryCounts.set(retryKey, tries + 1); - list.innerHTML = ''; - setTimeout(() => { - if ((!routeId || routeId === routeRenderId) && STATE.mode === "repo" && STATE.browserPath === path) renderBrowser(path, routeId || routeRenderId); - }, 1200); - } else { - list.innerHTML = ''; - } - } + const { data, page } = await pathSearch(controller.signal); + if (sequence !== STATE.searchSequence) return; + STATE.total = data.total || 0; + const newResults = data.results || []; + if (page === 1) { + STATE.results = newResults; + renderResults({ animate: true }); + DOM.resultsContainer.scrollTop = 0; + } else { + STATE.results = STATE.results.concat(newResults); + appendResults(newResults); + } + updateStatus(); + } catch (error) { + if (error.name === "AbortError" && !timedOut) return; + if (STATE.page > 1) STATE.page -= 1; + console.error(error); + showToast(timedOut ? "搜索超时,请重试" : "搜索失败"); + } finally { + clearTimeout(timeoutId); + if (STATE.searchController !== controller) return; + STATE.searchController = null; + STATE.isLoading = false; + DOM.resultsLoading.style.display = "none"; + setSearchMotion(false); + requestAnimationFrame(() => { STATE.suppressAutoLoad = false; }); + prefetchNextPage(); } } -function debouncedSearch() { - if (searchComposing) return; - clearTimeout(searchTimer); - searchTimer = setTimeout(() => { - STATE.query = DOM.searchInput.value.trim(); - STATE.page = 1; - addHistoryItem(STATE.query); - renderDropdown(); - doSearch(); - }, 100); -} - -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 resultPathHtml(item) { + const sourceSeparator = (item.Folder || []).length ? '/' : ""; + const sourcePrefix = `${highlightText(item.SourceName, STATE.query)}${sourceSeparator}`; + const rest = (item.Folder || []).map((part, index) => { + const path = item.Folder.slice(0, index + 1).join("/"); + const separator = index < item.Folder.length - 1 ? '/' : ""; + return `${highlightText(part, STATE.query)}${separator}`; + }).join(""); + return sourcePrefix + rest; } -function renderResultsSkeleton(count = 8) { - let html = ""; - for (let i = 0; i < count; i++) { - html += `