From bf29966a86ba9a4fd293f20cf4fe699e4ff8bfa9 Mon Sep 17 00:00:00 2001 From: LLLin000 <809867916@qq.com> Date: Fri, 10 Jul 2026 02:49:31 +0800 Subject: [PATCH] feat: real-time metadata search via sql.js with 200ms debounce Replace CLI spawn for metadata search with direct sql.js FTS5 queries. Input debounced at 200ms. Falls back to CLI when sql.js unavailable. @ Deep Search stays on CLI. --- paperforge/plugin/src/views/dashboard.ts | 149 ++++++++++++++++++----- paperforge/plugin/styles.css | 3 +- 2 files changed, 121 insertions(+), 31 deletions(-) diff --git a/paperforge/plugin/src/views/dashboard.ts b/paperforge/plugin/src/views/dashboard.ts index 9f0f072c..b6ffa9e9 100644 --- a/paperforge/plugin/src/views/dashboard.ts +++ b/paperforge/plugin/src/views/dashboard.ts @@ -51,6 +51,11 @@ import { restoreVersion, compareVersions, } from "../services/version-history"; +import { + initDatabase, + searchMetadata, + type SearchResultItem, +} from "../services/db"; // ── Interface for plugin ref used by static open ── @@ -95,7 +100,9 @@ export class PaperForgeStatusView extends ItemView { _searchContainer: HTMLElement | null = null; _searchInput: HTMLInputElement | null = null; _searchResultsEl: HTMLElement | null = null; - _searchTimer: ReturnType | null = null; + _searchTimer: ReturnType | undefined = undefined; + _sqlJsInitialized: boolean = false; + _sqlJsFailed: boolean = false; constructor(leaf: WorkspaceLeaf) { super(leaf); @@ -2389,7 +2396,7 @@ export class PaperForgeStatusView extends ItemView { cls: "paperforge-search-results", }); - // Detect @ mode prefix + // Detect @ mode prefix + debounced metadata search via sql.js this._searchInput.addEventListener("input", () => { const val = this._searchInput?.value || ""; if (val.startsWith("@") && !val.startsWith("@ ")) { @@ -2399,18 +2406,30 @@ export class PaperForgeStatusView extends ItemView { modeBadge.setText("M"); modeBadge.removeClass("deep"); } + // Cancel pending debounce + clearTimeout(this._searchTimer); + // Debounced metadata search for non-@ queries + if (!val.startsWith("@") && val.trim()) { + this._searchTimer = setTimeout(() => { + this.executeSearch({ source: "sqljs" }); + }, 200); + } }); - // Enter triggers search + // Enter triggers CLI search (full search for @ or explicit) this._searchInput.addEventListener("keydown", (e: KeyboardEvent) => { if (e.key === "Enter") { e.preventDefault(); - this.executeSearch(); + if (this._searchTimer) { + clearTimeout(this._searchTimer); + this._searchTimer = undefined; + } + this.executeSearch({ source: "cli" }); } }); } - executeSearch() { + async executeSearch(options: { source?: "auto" | "cli" | "sqljs" } = {}) { if (!this._searchInput || !this._searchResultsEl) return; const raw = this._searchInput.value.trim(); if (!raw) return; @@ -2421,19 +2440,48 @@ export class PaperForgeStatusView extends ItemView { const mode = isDeep ? "retrieve" : "search"; - this._searchResultsEl.empty(); - this._searchResultsEl.createEl("div", { - cls: "paperforge-search-loading", - text: "Searching...", - }); - - // Resolve vault path and plugin settings (avoid as any) + // Resolve vault path (used by both sql.js and CLI paths) const adapter = this.app.vault.adapter; let vaultPath = ""; if (adapter && typeof adapter === "object" && "basePath" in adapter) { const bp = (adapter as Record).basePath; vaultPath = typeof bp === "string" ? bp : ""; } + + this._searchResultsEl.empty(); + + // ── sql.js metadata search path (non-@ queries only) ── + if ( + mode === "search" && + (options.source === "auto" || options.source === "sqljs") + ) { + if (vaultPath) { + try { + if (!this._sqlJsInitialized && !this._sqlJsFailed) { + await initDatabase(vaultPath); + this._sqlJsInitialized = true; + } + if (this._sqlJsInitialized) { + const results = searchMetadata(query, 20); + if (results !== null) { + this.renderSearchResults(results, false); + return; + } + } + } catch (e) { + console.error("PaperForge sql.js search failed:", e); + this._sqlJsFailed = true; + } + } + // sql.js unavailable — fall through to CLI + } + + // ── CLI search path (deep search or sql.js fallback) ── + this._searchResultsEl.createEl("div", { + cls: "paperforge-search-loading", + text: "Searching...", + }); + if (!vaultPath) { this._renderSearchError("Could not determine vault path"); return; @@ -2506,14 +2554,22 @@ export class PaperForgeStatusView extends ItemView { return; } try { - const data = JSON.parse(jsonStr) as Record | unknown[]; + const parsed = JSON.parse(jsonStr) as Record; let results: unknown[] = []; - if (Array.isArray(data)) { - results = data; - } else if (data && typeof data === "object" && "results" in data) { - const r = (data as Record).results; - results = Array.isArray(r) ? r : []; + + if (parsed && typeof parsed === "object" && "data" in parsed) { + const d = (parsed as Record).data; + if (d && typeof d === "object") { + const dd = d as Record; + // search output: data.matches + if ("matches" in dd && Array.isArray(dd.matches)) { + results = dd.matches as unknown[]; + } else if ("results" in dd && Array.isArray(dd.results)) { + results = dd.results as unknown[]; + } + } } + this.renderSearchResults(results, isDeep); } catch (e) { const msg = e instanceof Error ? e.message : String(e); @@ -2551,30 +2607,65 @@ export class PaperForgeStatusView extends ItemView { for (const r of results) { if (!r || typeof r !== "object") continue; const rec = r as Record; - const card = this._searchResultsEl.createEl("div", { cls: "paperforge-search-result-card", + attr: { role: "button" }, }); - // Title — clickable + // Title const titleText = typeof rec["title"] === "string" ? rec["title"] : typeof rec["file_name"] === "string" ? rec["file_name"] : "(untitled)"; - const titleEl = card.createEl("div", { + card.createEl("div", { cls: "paperforge-search-result-title", text: titleText, }); - const filePath = - typeof rec["file_path"] === "string" ? rec["file_path"] : null; - if (filePath) { - titleEl.addEventListener("click", () => { - const abstractFile = this.app.vault.getAbstractFileByPath(filePath); - if (abstractFile instanceof TFile) { - this.app.workspace.getLeaf(false).openFile(abstractFile); - } + + // Note path resolution: main_note_path → note_path → zotero_key lookup in cached index + const zoteroKey = + typeof rec["zotero_key"] === "string" ? rec["zotero_key"] : ""; + const mainNotePath = + typeof rec["main_note_path"] === "string" && rec["main_note_path"] + ? rec["main_note_path"] + : null; + const notePath = + typeof rec["note_path"] === "string" && rec["note_path"] + ? rec["note_path"] + : null; + let resolvedPath: string | null = mainNotePath || notePath; + + // Fallback: look up zotero_key in cached index for main_note_path / note_path + if (!resolvedPath && zoteroKey) { + const cachedIndex = this._getCachedIndex(); + const entry = cachedIndex.find( + (item: unknown) => + item !== null && + typeof item === "object" && + "zotero_key" in item && + (item as Record).zotero_key === zoteroKey + ); + if (entry && typeof entry === "object") { + const e = entry as Record; + resolvedPath = + typeof e["main_note_path"] === "string" && e["main_note_path"] + ? e["main_note_path"] + : typeof e["note_path"] === "string" && e["note_path"] + ? e["note_path"] + : null; + } + } + + if (resolvedPath) { + card.addEventListener("click", (e: MouseEvent) => { + const newLeaf = e.ctrlKey || e.metaKey; + this.app.workspace.openLinkText(resolvedPath, "", newLeaf); + }); + } else { + card.addEventListener("click", () => { + new Notice("[!!] Note not found: " + (zoteroKey || "unknown"), 6000); }); } diff --git a/paperforge/plugin/styles.css b/paperforge/plugin/styles.css index d62306cb..ec320952 100644 --- a/paperforge/plugin/styles.css +++ b/paperforge/plugin/styles.css @@ -3902,7 +3902,7 @@ border: 1px solid var(--background-modifier-border); border-radius: var(--radius-m); padding: 10px 12px; - cursor: default; + cursor: pointer; transition: border-color 0.15s, background 0.15s; @@ -3916,7 +3916,6 @@ font-size: 14px; font-weight: 600; color: var(--text-normal); - cursor: pointer; margin-bottom: 4px; } .paperforge-search-result-title:hover {