From 9708e952c981070bc3b0778b24daa5305891b4fa Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 7 Aug 2026 16:53:52 -0400 Subject: [PATCH] ENG-2108 Add ranked search to QueryEngine with a vault-iteration fallback Add a Scorer seam over Obsidian's prepareFuzzySearch, plus the candidate fetch and ranking functions the advanced node search panel needs. Separate the candidate fetch from scoring so the vault scan runs once per search-surface open rather than once per keystroke, and route it through getFilesWithNodeTypeId, which already falls back to vault iteration when Datacore is unavailable. Score and render the same string (file.basename) so SearchResult.matches offsets stay aligned with what renderResults re-slices. Filter by node type before scoring, which leaves results identical but shrinks the number of scorer calls on the per-keystroke path. Rank on SearchResult.score alone; Array.prototype.sort is stable, so equal scores keep candidate order without an explicit tie-break. An empty query returns the full filtered set in title order rather than nothing, so a type filter alone still narrows to a visible list. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/services/QueryEngine.ts | 81 ++++++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/apps/obsidian/src/services/QueryEngine.ts b/apps/obsidian/src/services/QueryEngine.ts index 0edee84e6..5b67cae09 100644 --- a/apps/obsidian/src/services/QueryEngine.ts +++ b/apps/obsidian/src/services/QueryEngine.ts @@ -1,4 +1,4 @@ -import { TFile, App } from "obsidian"; +import { TFile, App, prepareFuzzySearch, type SearchResult } from "obsidian"; import type DiscourseGraphPlugin from "~/index"; import { BulkImportPattern, BulkImportCandidate, DiscourseNode } from "~/types"; import { getDiscourseNodeFormatExpression } from "~/utils/getDiscourseNodeFormatExpression"; @@ -21,6 +21,17 @@ type DatacorePage = { $path?: string; }; +export type DiscourseNodeCandidate = { + file: TFile; + /** Scored and rendered as-is: `renderResults` re-slices whatever was scored. */ + title: string; + nodeTypeId: string; +}; + +export type RankedDiscourseNode = DiscourseNodeCandidate & { + match: SearchResult; +}; + export class QueryEngine { private app: App; private dc: @@ -40,6 +51,26 @@ export class QueryEngine { functional = () => !!this.dc; + /** + * Datacore when installed, vault iteration otherwise — `getFilesWithNodeTypeId` + * owns that fallback. Call once per open, not per keystroke: the scan is the + * pipeline's most expensive step, and staying unfiltered keeps filter changes free. + */ + getDiscourseNodeCandidates = (): DiscourseNodeCandidate[] => { + const candidates: DiscourseNodeCandidate[] = []; + + for (const file of this.getFilesWithNodeTypeId()) { + const frontmatter = this.app.metadataCache.getFileCache(file) + ?.frontmatter as Record | undefined; + const nodeTypeId = frontmatter?.nodeTypeId; + if (typeof nodeTypeId !== "string" || !nodeTypeId) continue; + + candidates.push({ file, title: file.basename, nodeTypeId }); + } + + return candidates; + }; + /** * Search across all discourse nodes (files that have frontmatter nodeTypeId) */ @@ -602,6 +633,54 @@ export class QueryEngine { } } +/** Exported so callers can memoise the filtered array against their selected ids. */ +export const filterCandidatesByNodeTypeIds = ( + candidates: DiscourseNodeCandidate[], + nodeTypeIds?: string[], +): DiscourseNodeCandidate[] => { + if (!nodeTypeIds?.length) return candidates; + const selected = new Set(nodeTypeIds); + return candidates.filter((candidate) => selected.has(candidate.nodeTypeId)); +}; + +/** + * Best match first, uncapped — capping is the caller's, so a later re-sort orders the + * whole set rather than a top slice. Filters before scoring: same results, less work. + */ +export const rankDiscourseNodesByTitle = ({ + candidates, + query, + nodeTypeIds, +}: { + candidates: DiscourseNodeCandidate[]; + query: string; + nodeTypeIds?: string[]; +}): RankedDiscourseNode[] => { + const filtered = filterCandidatesByNodeTypeIds(candidates, nodeTypeIds); + const trimmedQuery = query.trim(); + + // Filter-only searches still need a list, so an empty query is not an empty result. + if (!trimmedQuery) { + return [...filtered] + .sort((a, b) => a.title.localeCompare(b.title)) + .map((candidate) => ({ + ...candidate, + match: { score: 0, matches: [] }, + })); + } + + const score = prepareFuzzySearch(trimmedQuery); + const ranked: RankedDiscourseNode[] = []; + + for (const candidate of filtered) { + const match = score(candidate.title); + if (match) ranked.push({ ...candidate, match }); + } + + // Sort is stable, so equal scores keep candidate order. + return ranked.sort((a, b) => b.match.score - a.match.score); +}; + /** * Returns raw imported node entries from import/ folder (no DB). * Uses DataCore when available; otherwise iterates vault. Only includes files