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