Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 84 additions & 1 deletion apps/obsidian/src/services/QueryEngine.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -21,6 +21,22 @@ type DatacorePage = {
$path?: string;
};

export type DiscourseNodeCandidate = {
file: TFile;
/**
* 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;
};

export type RankedDiscourseNode = DiscourseNodeCandidate & {
match: SearchResult;
};

export class QueryEngine {
private app: App;
private dc:
Expand All @@ -40,6 +56,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: Record<string, unknown> | undefined =
this.app.metadataCache.getFileCache(file)?.frontmatter;
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)
*/
Expand Down Expand Up @@ -602,6 +638,53 @@ export class QueryEngine {
}
}

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
Expand Down