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