From 318d1aa0262fa0d5daaa7918b1109a1e4c6adb3e Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 7 Aug 2026 16:53:52 -0400 Subject: [PATCH 1/3] 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 From bcffdce00ac62eff0dc0f9d3ee1fc47205bc8a9e Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 21 Aug 2026 23:46:41 -0400 Subject: [PATCH 2/3] ENG-2108 Address review: clarify title doc, unexport internal filter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrite the `title` JSDoc to state the actual constraint: match offsets index into this exact string, so `renderResults` must receive the same value. - Drop the `export` on `filterCandidatesByNodeTypeIds` — no caller in the stack uses it directly, so the memoisation rationale never materialised. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/services/QueryEngine.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/apps/obsidian/src/services/QueryEngine.ts b/apps/obsidian/src/services/QueryEngine.ts index 5b67cae09..74705385e 100644 --- a/apps/obsidian/src/services/QueryEngine.ts +++ b/apps/obsidian/src/services/QueryEngine.ts @@ -23,7 +23,12 @@ type DatacorePage = { export type DiscourseNodeCandidate = { file: TFile; - /** Scored and rendered as-is: `renderResults` re-slices whatever was scored. */ + /** + * The exact string the fuzzy scorer sees, so the offsets in + * `RankedDiscourseNode.match.matches` index into it. Callers must hand this same + * value to Obsidian's `renderResults`, or the highlights land on the wrong + * characters. + */ title: string; nodeTypeId: string; }; @@ -633,8 +638,7 @@ export class QueryEngine { } } -/** Exported so callers can memoise the filtered array against their selected ids. */ -export const filterCandidatesByNodeTypeIds = ( +const filterCandidatesByNodeTypeIds = ( candidates: DiscourseNodeCandidate[], nodeTypeIds?: string[], ): DiscourseNodeCandidate[] => { From fe7ab8a0bfb2618025b7ea0c16707ce8b495f98d Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 21 Aug 2026 23:49:06 -0400 Subject: [PATCH 3/3] ENG-2108 Annotate frontmatter instead of asserting its type `CachedMetadata.frontmatter` is already assignable to `Record | undefined`, so the assertion changed nothing and tripped @typescript-eslint/no-unnecessary-type-assertion in CI. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/services/QueryEngine.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/obsidian/src/services/QueryEngine.ts b/apps/obsidian/src/services/QueryEngine.ts index 74705385e..5d448c18b 100644 --- a/apps/obsidian/src/services/QueryEngine.ts +++ b/apps/obsidian/src/services/QueryEngine.ts @@ -65,8 +65,8 @@ export class QueryEngine { const candidates: DiscourseNodeCandidate[] = []; for (const file of this.getFilesWithNodeTypeId()) { - const frontmatter = this.app.metadataCache.getFileCache(file) - ?.frontmatter as Record | undefined; + const frontmatter: Record | undefined = + this.app.metadataCache.getFileCache(file)?.frontmatter; const nodeTypeId = frontmatter?.nodeTypeId; if (typeof nodeTypeId !== "string" || !nodeTypeId) continue;