vomebook commited on
Commit
a69a7bc
·
1 Parent(s): c76eef5

Upload 5 files

Browse files
Files changed (4) hide show
  1. static/app.js +25 -134
  2. static/index.html +3 -3
  3. static/style.css +7 -0
  4. static/sw.js +1 -1
static/app.js CHANGED
@@ -18,16 +18,15 @@ const STATE = {
18
  minSize: null,
19
  maxSize: null,
20
  exact: true,
21
- fulltext: false,
22
  searchPaths: true,
23
- fulltextManifest: null,
24
- fulltextLoaded: {},
25
  historyEnabled: true,
26
  leftSidebarOpen: true,
27
  rightSidebarOpen: false,
28
  isMobile: false,
29
  isDark: true,
30
  isLoading: false,
 
31
  multiSelect: false,
32
  selectedIds: new Set(),
33
  };
@@ -137,12 +136,13 @@ const API = {
137
  const resp = await fetch("/api/sources");
138
  return resp.json();
139
  },
140
- async search(body, sourceSlug) {
141
  const url = sourceSlug ? `/api/search/${sourceSlug}` : "/api/search";
142
  const resp = await fetch(url, {
143
  method: "POST",
144
  headers: { "Content-Type": "application/json" },
145
  body: JSON.stringify(body),
 
146
  });
147
  return resp.json();
148
  },
@@ -159,27 +159,11 @@ const API = {
159
  const resp = await fetch(`/api/preview/${encodeURIComponent(docId)}`);
160
  return resp.json();
161
  },
162
- async snippet(docId, query) {
163
- const resp = await fetch(`/api/snippet/${encodeURIComponent(docId)}?q=${encodeURIComponent(query || "")}`);
164
- return resp.json();
165
- },
166
  async random(sourceSlug) {
167
  const url = sourceSlug ? `/api/random?source=${encodeURIComponent(sourceSlug)}` : "/api/random";
168
  const resp = await fetch(url);
169
  return resp.json();
170
  },
171
- async fulltextManifest() {
172
- const resp = await fetch("/api/fulltext-manifest");
173
- return resp.json();
174
- },
175
- async fulltextIndex(path) {
176
- const resp = await fetch(`/data/${path}`);
177
- const blob = await resp.blob();
178
- const ds = new DecompressionStream("gzip");
179
- const stream = blob.stream().pipeThrough(ds);
180
- const text = await new Response(stream).text();
181
- return JSON.parse(text);
182
- },
183
  };
184
 
185
  function cacheDom() {
@@ -400,7 +384,7 @@ function syncUrl(replace = true) {
400
  if (STATE.maxSize !== null) params.set("max_size", String(STATE.maxSize));
401
  if (DOM.sortSelect.value !== "relevance") params.set("sort", DOM.sortSelect.value);
402
  if (!STATE.exact) params.set("exact", "0");
403
- if (STATE.fulltext) params.set("fulltext", "1");
404
  if (!STATE.searchPaths) params.set("search_paths", "0");
405
  if (!STATE.historyEnabled) params.set("history", "0");
406
  if (!STATE.leftSidebarOpen) params.set("sidebar", "0");
@@ -415,13 +399,14 @@ function syncUrl(replace = true) {
415
 
416
  function loadUrlState() {
417
  const params = new URLSearchParams(window.location.search);
 
418
  STATE.query = params.get("q") || "";
419
  STATE.selectedSources = params.getAll("source");
420
  STATE.folderSelections = params.getAll("folder");
421
  STATE.minSize = bytesFromUrl(params.get("min_size"));
422
  STATE.maxSize = bytesFromUrl(params.get("max_size"));
423
  STATE.exact = params.get("exact") !== "0";
424
- STATE.fulltext = params.get("fulltext") === "1";
425
  STATE.searchPaths = params.get("search_paths") !== "0";
426
  STATE.historyEnabled = params.get("history") !== "0";
427
  const sort = params.get("sort") || "relevance";
@@ -440,147 +425,53 @@ function loadUrlState() {
440
  restoreSizeInput(DOM.filterMaxSize, DOM.filterMaxUnit, STATE.maxSize);
441
  }
442
 
443
- async function ensureFulltextLoaded() {
444
- let loadedCount = 0;
445
- if (!STATE.fulltextManifest) {
446
- showToast("正在加载全文索引,首次可能较慢...", 5000);
447
- STATE.fulltextManifest = await API.fulltextManifest();
448
- }
449
- const sourceSlugs = STATE.source ? [STATE.source] : (STATE.selectedSources.length ? STATE.selectedSources : STATE.sources.map((source) => source.slug));
450
- const pendingSlugs = sourceSlugs.filter((slug) => !STATE.fulltextLoaded[slug]);
451
- if (pendingSlugs.length) showToast("正在加载全文索引,首次可能较慢...", 5000);
452
- for (const slug of sourceSlugs) {
453
- if (STATE.fulltextLoaded[slug]) continue;
454
- const item = (STATE.fulltextManifest.sources || []).find((source) => source.slug === slug);
455
- if (!item) continue;
456
- showToast(`加载全文索引: ${slug}`, 5000);
457
- STATE.fulltextLoaded[slug] = await API.fulltextIndex(item.path);
458
- loadedCount += 1;
459
- }
460
- if (loadedCount) showToast("全文索引加载完成");
461
- }
462
-
463
- function scoreFulltextDoc(docId, payload, queryTokens, exactQuery) {
464
- if (exactQuery) {
465
- const snippet = (payload.snippets[docId] || "").toLowerCase();
466
- return snippet.includes(exactQuery.toLowerCase()) ? 100 : 1;
467
- }
468
- let score = 0;
469
- for (const token of queryTokens) {
470
- if ((payload.snippets[docId] || "").toLowerCase().includes(token)) score += 4;
471
- if ((payload.docs[docId]?.display_name || "").toLowerCase().includes(token)) score += 1;
472
- }
473
- return score;
474
- }
475
-
476
- async function fulltextSearch() {
477
- await ensureFulltextLoaded();
478
- const query = STATE.query.trim();
479
- const queryTokens = tokenize(query);
480
- const sourceSlugs = STATE.source ? [STATE.source] : (STATE.selectedSources.length ? STATE.selectedSources : STATE.sources.map((source) => source.slug));
481
- let results = [];
482
- for (const slug of sourceSlugs) {
483
- const payload = STATE.fulltextLoaded[slug];
484
- if (!payload) continue;
485
- let matchedIds = null;
486
- if (STATE.exact) {
487
- matchedIds = Object.keys(payload.docs).filter((docId) => {
488
- const snippet = (payload.snippets[docId] || "") + " " + (payload.docs[docId]?.display_name || "");
489
- return matchesExactQuery(snippet, query);
490
- });
491
- } else {
492
- for (const token of queryTokens) {
493
- const docIds = payload.index[token] || [];
494
- const set = new Set(docIds);
495
- if (matchedIds === null) matchedIds = set;
496
- else matchedIds = new Set(Array.from(matchedIds).filter((docId) => set.has(docId)));
497
- }
498
- matchedIds = Array.from(matchedIds || []);
499
- }
500
- for (const docId of matchedIds) {
501
- const meta = payload.docs[docId];
502
- const folderParts = meta.display_rel_path.split("/");
503
- const fileName = folderParts.pop();
504
- const displayName = fileName.replace(/\.txt$/i, "");
505
- results.push({
506
- doc_id: docId,
507
- Source: slug,
508
- SourceName: STATE.sourceMap[slug]?.name || slug,
509
- File: displayName,
510
- Extension: "txt",
511
- Folder: folderParts,
512
- DisplayPath: meta.display_rel_path,
513
- Size: meta.size,
514
- HasTxt: true,
515
- snippet: payload.snippets[docId] || "",
516
- _score: scoreFulltextDoc(docId, payload, queryTokens, query),
517
- });
518
- }
519
- }
520
- if (STATE.folderSelections.length) {
521
- results = results.filter((item) => {
522
- const folder = item.Folder.join("/");
523
- return STATE.folderSelections.some((selected) => folder === selected || folder.startsWith(selected + "/"));
524
- });
525
- }
526
- if (STATE.minSize !== null) results = results.filter((item) => item.Size >= STATE.minSize);
527
- if (STATE.maxSize !== null) results = results.filter((item) => item.Size <= STATE.maxSize);
528
- if (DOM.sortSelect.value === "name") results.sort((a, b) => a.DisplayPath.localeCompare(b.DisplayPath, "zh"));
529
- else if (DOM.sortSelect.value === "size") results.sort((a, b) => (b.Size - a.Size) || a.DisplayPath.localeCompare(b.DisplayPath, "zh"));
530
- else results.sort((a, b) => (b._score - a._score) || a.DisplayPath.localeCompare(b.DisplayPath, "zh"));
531
- const snippetTargets = results.slice(0, STATE.page * STATE.pageSize);
532
- await Promise.all(snippetTargets.map(async (item) => {
533
- const data = await API.snippet(item.doc_id, query);
534
- item.snippet = data && data.snippet ? data.snippet : "";
535
- }));
536
- STATE.total = results.length;
537
- STATE.results = results.slice(0, STATE.page * STATE.pageSize);
538
- }
539
-
540
- async function pathSearch() {
541
  const body = {
542
  q: STATE.query,
543
  sources: getSelectedSourcesForSearch(),
544
  folders: STATE.folderSelections,
545
  min_size: STATE.minSize,
546
  max_size: STATE.maxSize,
547
- page: 1,
548
- page_size: STATE.page * STATE.pageSize,
549
  sort: DOM.sortSelect.value,
550
  exact: STATE.exact,
551
  search_paths: STATE.searchPaths,
 
552
  };
553
- const data = await API.search(body, STATE.source);
554
  STATE.total = data.total || 0;
555
- STATE.results = data.results || [];
556
  }
557
 
558
  async function doSearch() {
 
 
 
559
  STATE.isLoading = true;
560
  DOM.resultsLoading.style.display = "flex";
561
  DOM.previewPanel.style.display = "none";
562
  try {
563
- if (STATE.fulltext && STATE.query.trim()) {
564
- await fulltextSearch();
565
- } else {
566
- await pathSearch();
567
- }
568
  renderResults();
569
  updateStatus();
570
  } catch (error) {
 
571
  console.error(error);
572
  showToast("搜索失败");
573
  } finally {
 
 
574
  STATE.isLoading = false;
575
  DOM.resultsLoading.style.display = "none";
576
  }
577
  }
578
 
579
  function resultPathHtml(item) {
580
- const sourcePrefix = `<span class="path-folder" data-source="${escapeHTML(item.Source)}" data-folder="">${highlightText(item.SourceName, STATE.fulltext ? "" : STATE.query)}</span>`;
581
  const rest = (item.Folder || []).map((part, index) => {
582
  const path = item.Folder.slice(0, index + 1).join("/");
583
- 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>`;
584
  }).join("");
585
  return sourcePrefix + rest;
586
  }
@@ -595,7 +486,7 @@ function renderResults() {
595
  }
596
  DOM.emptyState.style.display = "none";
597
  DOM.resultsList.innerHTML = STATE.results.map((item) => {
598
- const titleHtml = `${highlightText(item.File, STATE.fulltext ? "" : STATE.query)}<span style="opacity:0.5;font-size:12px">.txt</span>`;
599
  const snippetHtml = item.snippet ? `<div class="result-snippet">${highlightText(item.snippet, STATE.query)}</div>` : "";
600
  return `
601
  <div class="result-item" data-doc-id="${escapeHTML(item.doc_id)}">
@@ -1042,6 +933,7 @@ function updateSidebarVisibility() {
1042
  }
1043
 
1044
  function clearFilters() {
 
1045
  STATE.folderSelections = [];
1046
  STATE.selectedSources = [];
1047
  STATE.minSize = null;
@@ -1208,7 +1100,7 @@ function attachEvents() {
1208
  if (!STATE.historyEnabled) saveHistory([]);
1209
  syncUrl();
1210
  });
1211
- DOM.fulltextToggle.addEventListener("change", async () => {
1212
  STATE.fulltext = DOM.fulltextToggle.checked;
1213
  if (DOM.searchPathsToggle) {
1214
  DOM.searchPathsToggle.disabled = STATE.fulltext;
@@ -1217,7 +1109,6 @@ function attachEvents() {
1217
  }
1218
  DOM.searchInput.placeholder = STATE.fulltext ? "全文搜索 TXT 正文..." : "搜索 TXT 文件或路径...";
1219
  STATE.page = 1;
1220
- if (STATE.fulltext) await ensureFulltextLoaded();
1221
  syncUrl();
1222
  doSearch();
1223
  });
 
18
  minSize: null,
19
  maxSize: null,
20
  exact: true,
21
+ fulltext: true,
22
  searchPaths: true,
 
 
23
  historyEnabled: true,
24
  leftSidebarOpen: true,
25
  rightSidebarOpen: false,
26
  isMobile: false,
27
  isDark: true,
28
  isLoading: false,
29
+ searchController: null,
30
  multiSelect: false,
31
  selectedIds: new Set(),
32
  };
 
136
  const resp = await fetch("/api/sources");
137
  return resp.json();
138
  },
139
+ async search(body, sourceSlug, signal) {
140
  const url = sourceSlug ? `/api/search/${sourceSlug}` : "/api/search";
141
  const resp = await fetch(url, {
142
  method: "POST",
143
  headers: { "Content-Type": "application/json" },
144
  body: JSON.stringify(body),
145
+ signal,
146
  });
147
  return resp.json();
148
  },
 
159
  const resp = await fetch(`/api/preview/${encodeURIComponent(docId)}`);
160
  return resp.json();
161
  },
 
 
 
 
162
  async random(sourceSlug) {
163
  const url = sourceSlug ? `/api/random?source=${encodeURIComponent(sourceSlug)}` : "/api/random";
164
  const resp = await fetch(url);
165
  return resp.json();
166
  },
 
 
 
 
 
 
 
 
 
 
 
 
167
  };
168
 
169
  function cacheDom() {
 
384
  if (STATE.maxSize !== null) params.set("max_size", String(STATE.maxSize));
385
  if (DOM.sortSelect.value !== "relevance") params.set("sort", DOM.sortSelect.value);
386
  if (!STATE.exact) params.set("exact", "0");
387
+ if (!STATE.fulltext) params.set("fulltext", "0");
388
  if (!STATE.searchPaths) params.set("search_paths", "0");
389
  if (!STATE.historyEnabled) params.set("history", "0");
390
  if (!STATE.leftSidebarOpen) params.set("sidebar", "0");
 
399
 
400
  function loadUrlState() {
401
  const params = new URLSearchParams(window.location.search);
402
+ STATE.page = 1;
403
  STATE.query = params.get("q") || "";
404
  STATE.selectedSources = params.getAll("source");
405
  STATE.folderSelections = params.getAll("folder");
406
  STATE.minSize = bytesFromUrl(params.get("min_size"));
407
  STATE.maxSize = bytesFromUrl(params.get("max_size"));
408
  STATE.exact = params.get("exact") !== "0";
409
+ STATE.fulltext = params.get("fulltext") !== "0";
410
  STATE.searchPaths = params.get("search_paths") !== "0";
411
  STATE.historyEnabled = params.get("history") !== "0";
412
  const sort = params.get("sort") || "relevance";
 
425
  restoreSizeInput(DOM.filterMaxSize, DOM.filterMaxUnit, STATE.maxSize);
426
  }
427
 
428
+ async function pathSearch(signal) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
429
  const body = {
430
  q: STATE.query,
431
  sources: getSelectedSourcesForSearch(),
432
  folders: STATE.folderSelections,
433
  min_size: STATE.minSize,
434
  max_size: STATE.maxSize,
435
+ page: STATE.page,
436
+ page_size: STATE.pageSize,
437
  sort: DOM.sortSelect.value,
438
  exact: STATE.exact,
439
  search_paths: STATE.searchPaths,
440
+ fulltext: STATE.fulltext,
441
  };
442
+ const data = await API.search(body, STATE.source, signal);
443
  STATE.total = data.total || 0;
444
+ STATE.results = STATE.page === 1 ? (data.results || []) : STATE.results.concat(data.results || []);
445
  }
446
 
447
  async function doSearch() {
448
+ if (STATE.searchController) STATE.searchController.abort();
449
+ const controller = new AbortController();
450
+ STATE.searchController = controller;
451
  STATE.isLoading = true;
452
  DOM.resultsLoading.style.display = "flex";
453
  DOM.previewPanel.style.display = "none";
454
  try {
455
+ await pathSearch(controller.signal);
 
 
 
 
456
  renderResults();
457
  updateStatus();
458
  } catch (error) {
459
+ if (error.name === "AbortError") return;
460
  console.error(error);
461
  showToast("搜索失败");
462
  } finally {
463
+ if (STATE.searchController !== controller) return;
464
+ STATE.searchController = null;
465
  STATE.isLoading = false;
466
  DOM.resultsLoading.style.display = "none";
467
  }
468
  }
469
 
470
  function resultPathHtml(item) {
471
+ const sourcePrefix = `<span class="path-folder" data-source="${escapeHTML(item.Source)}" data-folder="">${highlightText(item.SourceName, STATE.query)}</span>`;
472
  const rest = (item.Folder || []).map((part, index) => {
473
  const path = item.Folder.slice(0, index + 1).join("/");
474
+ return `<span class="path-sep">/</span><span class="path-folder" data-source="${escapeHTML(item.Source)}" data-folder="${escapeHTML(path)}">${highlightText(part, STATE.query)}</span>`;
475
  }).join("");
476
  return sourcePrefix + rest;
477
  }
 
486
  }
487
  DOM.emptyState.style.display = "none";
488
  DOM.resultsList.innerHTML = STATE.results.map((item) => {
489
+ const titleHtml = `${highlightText(item.File, STATE.query)}<span style="opacity:0.5;font-size:12px">.txt</span>`;
490
  const snippetHtml = item.snippet ? `<div class="result-snippet">${highlightText(item.snippet, STATE.query)}</div>` : "";
491
  return `
492
  <div class="result-item" data-doc-id="${escapeHTML(item.doc_id)}">
 
933
  }
934
 
935
  function clearFilters() {
936
+ STATE.page = 1;
937
  STATE.folderSelections = [];
938
  STATE.selectedSources = [];
939
  STATE.minSize = null;
 
1100
  if (!STATE.historyEnabled) saveHistory([]);
1101
  syncUrl();
1102
  });
1103
+ DOM.fulltextToggle.addEventListener("change", () => {
1104
  STATE.fulltext = DOM.fulltextToggle.checked;
1105
  if (DOM.searchPathsToggle) {
1106
  DOM.searchPathsToggle.disabled = STATE.fulltext;
 
1109
  }
1110
  DOM.searchInput.placeholder = STATE.fulltext ? "全文搜索 TXT 正文..." : "搜索 TXT 文件或路径...";
1111
  STATE.page = 1;
 
1112
  syncUrl();
1113
  doSearch();
1114
  });
static/index.html CHANGED
@@ -22,7 +22,7 @@
22
  <div class="header-center">
23
  <div class="search-box">
24
  <span class="search-icon"><span class="ui-icon ui-icon-search" aria-hidden="true"></span></span>
25
- <input type="search" id="search-input" class="search-input" placeholder="搜索 TXT 文件或路径..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
26
  <kbd class="search-kbd">/</kbd>
27
  <div id="search-history-dropdown" class="search-history-dropdown" style="display:none"></div>
28
  </div>
@@ -88,7 +88,7 @@
88
  <div class="filter-section" id="fulltext-toggle-section">
89
  <label class="toggle-row">
90
  <span class="toggle-label">全文搜索</span>
91
- <input type="checkbox" id="fulltext-toggle">
92
  <span class="toggle-switch"></span>
93
  </label>
94
  </div>
@@ -108,7 +108,7 @@
108
  </div>
109
  <div class="filter-section" id="exact-search-section">
110
  <label class="toggle-row">
111
- <span class="toggle-label">精准搜索(支持通配符*和?)</span>
112
  <input type="checkbox" id="exact-search-toggle" checked>
113
  <span class="toggle-switch"></span>
114
  </label>
 
22
  <div class="header-center">
23
  <div class="search-box">
24
  <span class="search-icon"><span class="ui-icon ui-icon-search" aria-hidden="true"></span></span>
25
+ <input type="search" id="search-input" class="search-input" placeholder="全文搜索 TXT 文..." autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false">
26
  <kbd class="search-kbd">/</kbd>
27
  <div id="search-history-dropdown" class="search-history-dropdown" style="display:none"></div>
28
  </div>
 
88
  <div class="filter-section" id="fulltext-toggle-section">
89
  <label class="toggle-row">
90
  <span class="toggle-label">全文搜索</span>
91
+ <input type="checkbox" id="fulltext-toggle" checked>
92
  <span class="toggle-switch"></span>
93
  </label>
94
  </div>
 
108
  </div>
109
  <div class="filter-section" id="exact-search-section">
110
  <label class="toggle-row">
111
+ <span class="toggle-label">精准搜索</span>
112
  <input type="checkbox" id="exact-search-toggle" checked>
113
  <span class="toggle-switch"></span>
114
  </label>
static/style.css CHANGED
@@ -489,6 +489,13 @@ ul, li {
489
  overflow: hidden;
490
  }
491
 
 
 
 
 
 
 
 
492
  /* ═══════════════════════════════════════════════════════════
493
  Layout
494
  ═══════════════════════════════════════════════════════════ */
 
489
  overflow: hidden;
490
  }
491
 
492
+ .result-snippet mark {
493
+ background: var(--primary-container);
494
+ color: var(--on-primary-container);
495
+ border-radius: 2px;
496
+ padding: 0 1px;
497
+ }
498
+
499
  /* ═══════════════════════════════════════════════════════════
500
  Layout
501
  ═══════════════════════════════════════════════════════════ */
static/sw.js CHANGED
@@ -1,4 +1,4 @@
1
- const CACHE_NAME = "vomebook-search-v1";
2
 
3
  const PRECACHE_URLS = [
4
  "/",
 
1
+ const CACHE_NAME = "vomebook-search-v2";
2
 
3
  const PRECACHE_URLS = [
4
  "/",