diff --git a/apps/obsidian/package.json b/apps/obsidian/package.json index 1b715d839..6ce71bc0d 100644 --- a/apps/obsidian/package.json +++ b/apps/obsidian/package.json @@ -10,6 +10,7 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "publish": "tsx scripts/publish.ts --version 0.1.0", + "test": "vitest run --config vitest.config.mts", "check-types": "tsc --noEmit --skipLibCheck" }, "keywords": [], @@ -35,6 +36,7 @@ "tsx": "^4.19.2", "typescript": "5.5.4", "uuidv7": "1.1.0", + "vitest": "catalog:", "zod": "^3.24.1" }, "dependencies": { diff --git a/apps/obsidian/src/services/QueryEngine.ts b/apps/obsidian/src/services/QueryEngine.ts index 0edee84e6..7f0e9c98d 100644 --- a/apps/obsidian/src/services/QueryEngine.ts +++ b/apps/obsidian/src/services/QueryEngine.ts @@ -21,19 +21,22 @@ type DatacorePage = { $path?: string; }; +type DatacoreApi = { + core?: { + initialized?: boolean; + }; + query: (query: string) => DatacorePage[]; +}; + export class QueryEngine { private app: App; - private dc: - | { - query: (query: string) => DatacorePage[]; - } - | undefined; + private dc: DatacoreApi | undefined; private readonly MIN_QUERY_LENGTH = 2; constructor(app: App) { const appWithPlugins = app as AppWithPlugins; this.dc = appWithPlugins.plugins?.plugins?.["datacore"]?.api as - | { query: (query: string) => DatacorePage[] } + | DatacoreApi | undefined; this.app = app; } @@ -50,15 +53,16 @@ export class QueryEngine { if (!query || query.length < this.MIN_QUERY_LENGTH) { return []; } - if (!this.dc) { - return []; + const datacore = this.getReadyDatacore(); + if (!datacore) { + return this.fallbackSearchDiscourseNodesByTitle(query, nodeTypeId); } try { const dcQuery = nodeTypeId ? `@page and exists(nodeTypeId) and nodeTypeId = "${nodeTypeId}"` : "@page and exists(nodeTypeId)"; - const potentialNodes = this.dc.query(dcQuery); + const potentialNodes = datacore.query(dcQuery); const searchResults = potentialNodes.filter((p: DatacorePage) => this.fuzzySearch(p.$name, query), @@ -77,7 +81,7 @@ export class QueryEngine { return files.reverse(); } catch (error) { console.error("Error in searchDiscourseNodesByTitle:", error); - return []; + return this.fallbackSearchDiscourseNodesByTitle(query, nodeTypeId); } }; @@ -85,23 +89,25 @@ export class QueryEngine { * Search across all discourse nodes that have nodeInstanceId */ getDiscourseNodeById = (nodeInstanceId: string): TFile | null => { - if (!this.dc) { - return null; - } - if (!nodeInstanceId.match(/^[-.+\w]+$/)) { console.error("Malformed id:", nodeInstanceId); return null; } + + const datacore = this.getReadyDatacore(); + if (!datacore) { + return this.fallbackGetDiscourseNodeById(nodeInstanceId); + } + try { const dcQuery = `@page and exists(nodeInstanceId) and nodeInstanceId = "${nodeInstanceId}"`; - const potentialNodes = this.dc.query(dcQuery); + const potentialNodes = datacore.query(dcQuery); const path = potentialNodes.at(0)?.$path; if (!path) return null; return this.app.vault.getFileByPath(path); } catch (error) { console.error("Error in searchDiscourseNodeById:", error); - return null; + return this.fallbackGetDiscourseNodeById(nodeInstanceId); } }; @@ -119,8 +125,14 @@ export class QueryEngine { if (!query || query.length < this.MIN_QUERY_LENGTH) { return []; } - if (!this.dc) { - return []; + const datacore = this.getReadyDatacore(); + if (!datacore) { + return this.fallbackSearchCompatibleNodeByTitle({ + query, + compatibleNodeTypeIds, + activeFile, + selectedRelationType, + }); } try { @@ -128,7 +140,7 @@ export class QueryEngine { .map((id) => `nodeTypeId = "${id}"`) .join(" or ")}`; - const potentialNodes = this.dc.query(dcQuery); + const potentialNodes = datacore.query(dcQuery); const searchResults = potentialNodes.filter((p: DatacorePage) => { return this.fuzzySearch(p.$name, query); }); @@ -176,7 +188,12 @@ export class QueryEngine { return finalResults; } catch (error) { console.error("Error in searchNodeByTitle:", error); - return []; + return this.fallbackSearchCompatibleNodeByTitle({ + query, + compatibleNodeTypeIds, + activeFile, + selectedRelationType, + }); } }; @@ -236,7 +253,8 @@ export class QueryEngine { ): BulkImportCandidate[] { const candidates: BulkImportCandidate[] = []; - if (!this.dc) { + const datacore = this.getReadyDatacore(); + if (!datacore) { return this.fallbackScanVault(patterns, validNodeTypes); } @@ -253,7 +271,7 @@ export class QueryEngine { dcQuery = `@page and (!exists(nodeTypeId) or (${validIdConditions}))`; } - const potentialPages = this.dc.query(dcQuery); + const potentialPages = datacore.query(dcQuery); for (const page of potentialPages) { const fileName = page.$name; @@ -310,10 +328,11 @@ export class QueryEngine { * Uses DataCore when available; falls back to vault iteration otherwise. */ getImportedNodePages = (): TFile[] => { - if (this.dc) { + const datacore = this.getReadyDatacore(); + if (datacore) { try { const dcQuery = `@page and path("import") and exists(importedFromRid) and exists(nodeInstanceId)`; - const pages = this.dc.query(dcQuery); + const pages = datacore.query(dcQuery); const files: TFile[] = []; for (const page of pages) { if (page.$path) { @@ -334,10 +353,11 @@ export class QueryEngine { * Uses DataCore when available; falls back to vault iteration otherwise. */ getFilesWithNodeInstanceId = (): TFile[] => { - if (this.dc) { + const datacore = this.getReadyDatacore(); + if (datacore) { try { const dcQuery = `@page and exists(nodeInstanceId)`; - const pages = this.dc.query(dcQuery); + const pages = datacore.query(dcQuery); const files: TFile[] = []; for (const page of pages) { if (page.$path) { @@ -362,10 +382,11 @@ export class QueryEngine { * Uses DataCore when available; falls back to vault iteration otherwise. */ getFilesWithNodeTypeId = (opts?: { excludeImported?: boolean }): TFile[] => { - if (this.dc) { + const datacore = this.getReadyDatacore(); + if (datacore) { try { const dcQuery = `@page and exists(nodeTypeId)`; - const pages = this.dc.query(dcQuery); + const pages = datacore.query(dcQuery); const files: TFile[] = []; for (const page of pages) { if (!page.$path) continue; @@ -391,13 +412,14 @@ export class QueryEngine { * Uses DataCore when available; falls back to vault iteration otherwise. */ getFileByImportedFromRid = (importedFromRid: string): TFile | null => { - if (this.dc) { + const datacore = this.getReadyDatacore(); + if (datacore) { try { const safeUri = importedFromRid .replace(/\\/g, "\\\\") .replace(/"/g, '\\"'); const dcQuery = `@page and importedFromRid = "${safeUri}"`; - const results = this.dc.query(dcQuery); + const results = datacore.query(dcQuery); const path = results.at(0)?.$path; if (path) { const file = this.app.vault.getAbstractFileByPath(path); @@ -429,7 +451,7 @@ export class QueryEngine { * falls back to iterating files with nodeInstanceId and matching either field. */ getFileByEndpoint = (endpointId: string): TFile | null => { - if (this.dc) { + if (this.getReadyDatacore()) { const byId = this.getDiscourseNodeById(endpointId); if (byId) return byId; const byRid = this.getFileByImportedFromRid(endpointId); @@ -456,7 +478,8 @@ export class QueryEngine { nodeInstanceId: string, importedFromRid: string, ): TFile | null => { - if (this.dc) { + const datacore = this.getReadyDatacore(); + if (datacore) { try { const safeId = nodeInstanceId .replace(/\\/g, "\\\\") @@ -465,7 +488,7 @@ export class QueryEngine { .replace(/\\/g, "\\\\") .replace(/"/g, '\\"'); const dcQuery = `@page and nodeInstanceId = "${safeId}" and importedFromRid = "${safeUri}"`; - const results = this.dc.query(dcQuery); + const results = datacore.query(dcQuery); for (const page of results) { if (page.$path) { @@ -494,6 +517,83 @@ export class QueryEngine { return null; }; + private getReadyDatacore(): DatacoreApi | null { + return this.dc?.core?.initialized ? this.dc : null; + } + + private fallbackSearchDiscourseNodesByTitle( + query: string, + nodeTypeId?: string, + ): TFile[] { + return this.app.vault + .getMarkdownFiles() + .filter((file) => { + const fm = this.app.metadataCache.getFileCache(file)?.frontmatter as + | Record + | undefined; + if (!fm?.nodeTypeId) return false; + if (nodeTypeId && fm.nodeTypeId !== nodeTypeId) return false; + return this.fuzzySearch(file.basename, query); + }) + .reverse(); + } + + private fallbackGetDiscourseNodeById(nodeInstanceId: string): TFile | null { + for (const file of this.app.vault.getMarkdownFiles()) { + const fm = this.app.metadataCache.getFileCache(file)?.frontmatter as + | Record + | undefined; + if (fm?.nodeInstanceId === nodeInstanceId) return file; + } + return null; + } + + private fallbackSearchCompatibleNodeByTitle({ + query, + compatibleNodeTypeIds, + activeFile, + selectedRelationType, + }: { + query: string; + compatibleNodeTypeIds: string[]; + activeFile: TFile; + selectedRelationType: string; + }): TFile[] { + const fileCache = this.app.metadataCache.getFileCache(activeFile); + const frontmatter = fileCache?.frontmatter as + | Record + | undefined; + const rawExistingRelations = frontmatter?.[selectedRelationType]; + const existingRelations = Array.isArray(rawExistingRelations) + ? (rawExistingRelations as string[]) + : rawExistingRelations + ? [String(rawExistingRelations)] + : []; + const existingRelatedFiles = existingRelations.map((relation) => { + const match = relation.match(/\[\[(.*?)(?:\|.*?)?\]\]/); + return match?.[1] ?? relation.replace(/^\[\[|\]\]$/g, ""); + }); + + return this.app.vault + .getMarkdownFiles() + .filter((file) => { + if (file.path === activeFile.path) return false; + const fm = this.app.metadataCache.getFileCache(file)?.frontmatter as + | Record + | undefined; + if (!compatibleNodeTypeIds.includes(String(fm?.nodeTypeId ?? ""))) { + return false; + } + if (!this.fuzzySearch(file.basename, query)) return false; + return !existingRelatedFiles.some( + (existingFile) => + file.basename === existingFile.replace(/\.md$/, "") || + file.name === existingFile, + ); + }) + .reverse(); + } + private fallbackGetImportedNodePages(): TFile[] { const files: TFile[] = []; const allFiles = this.app.vault.getMarkdownFiles(); diff --git a/apps/obsidian/src/services/__tests__/QueryEngine.test.ts b/apps/obsidian/src/services/__tests__/QueryEngine.test.ts new file mode 100644 index 000000000..18d30e9c0 --- /dev/null +++ b/apps/obsidian/src/services/__tests__/QueryEngine.test.ts @@ -0,0 +1,177 @@ +import { TFile, type App } from "obsidian"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryEngine } from "~/services/QueryEngine"; + +type Frontmatter = Record; + +const createFile = (path: string): TFile => { + const file = new TFile(); + file.path = path; + file.name = path.split("/").at(-1) ?? path; + file.basename = file.name.replace(/\.md$/, ""); + return file; +}; + +const createApp = ({ + datacoreInitialized, + datacoreQuery, + files, + frontmatterByPath, +}: { + datacoreInitialized: boolean; + datacoreQuery: ReturnType; + files: TFile[]; + frontmatterByPath: Record; +}) => { + const getMarkdownFiles = vi.fn(() => files); + const filesByPath = new Map(files.map((file) => [file.path, file])); + const app = { + metadataCache: { + getFileCache: (file: TFile) => ({ + frontmatter: frontmatterByPath[file.path], + }), + }, + plugins: { + plugins: { + datacore: { + api: { + core: { + initialized: datacoreInitialized, + }, + query: datacoreQuery, + }, + }, + }, + }, + vault: { + getAbstractFileByPath: (path: string) => filesByPath.get(path) ?? null, + getFileByPath: (path: string) => filesByPath.get(path) ?? null, + getMarkdownFiles, + }, + } as unknown as App; + + return { app, getMarkdownFiles }; +}; + +describe("QueryEngine Datacore readiness", () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + it("uses the metadata cache without querying Datacore while it initializes", () => { + const claim = createFile("CLAIM - Microglia change in AD.md"); + const datacoreQuery = vi.fn(() => []); + const { app, getMarkdownFiles } = createApp({ + datacoreInitialized: false, + datacoreQuery, + files: [claim], + frontmatterByPath: { + [claim.path]: { nodeTypeId: "claim" }, + }, + }); + + const results = new QueryEngine(app).searchDiscourseNodesByTitle( + "Microglia", + ); + + expect(results).toEqual([claim]); + expect(datacoreQuery).not.toHaveBeenCalled(); + expect(getMarkdownFiles).toHaveBeenCalledOnce(); + }); + + it("treats an empty result as authoritative after Datacore initializes", () => { + const claim = createFile("CLAIM - Microglia change in AD.md"); + const datacoreQuery = vi.fn(() => []); + const { app, getMarkdownFiles } = createApp({ + datacoreInitialized: true, + datacoreQuery, + files: [claim], + frontmatterByPath: { + [claim.path]: { nodeTypeId: "claim" }, + }, + }); + + const results = new QueryEngine(app).searchDiscourseNodesByTitle( + "Microglia", + ); + + expect(results).toEqual([]); + expect(datacoreQuery).toHaveBeenCalledOnce(); + expect(getMarkdownFiles).not.toHaveBeenCalled(); + }); + + it("falls back to the metadata cache when an initialized query throws", () => { + const claim = createFile("CLAIM - Microglia change in AD.md"); + const datacoreQuery = vi.fn(() => { + throw new Error("Datacore query failed"); + }); + const { app, getMarkdownFiles } = createApp({ + datacoreInitialized: true, + datacoreQuery, + files: [claim], + frontmatterByPath: { + [claim.path]: { nodeTypeId: "claim" }, + }, + }); + vi.spyOn(console, "error").mockImplementation(() => undefined); + + const results = new QueryEngine(app).searchDiscourseNodesByTitle( + "Microglia", + ); + + expect(results).toEqual([claim]); + expect(datacoreQuery).toHaveBeenCalledOnce(); + expect(getMarkdownFiles).toHaveBeenCalledOnce(); + }); + + it("enumerates discourse nodes from the metadata cache during initialization", () => { + const claim = createFile("CLAIM - Microglia change in AD.md"); + const plainNote = createFile("Meeting notes.md"); + const datacoreQuery = vi.fn(() => []); + const { app, getMarkdownFiles } = createApp({ + datacoreInitialized: false, + datacoreQuery, + files: [claim, plainNote], + frontmatterByPath: { + [claim.path]: { nodeTypeId: "claim" }, + [plainNote.path]: {}, + }, + }); + + const results = new QueryEngine(app).getFilesWithNodeTypeId(); + + expect(results).toEqual([claim]); + expect(datacoreQuery).not.toHaveBeenCalled(); + expect(getMarkdownFiles).toHaveBeenCalledOnce(); + }); + + it("finds compatible nodes from the metadata cache during initialization", () => { + const activeClaim = createFile("CLAIM - Active claim.md"); + const existingEvidence = createFile("EVIDENCE - Existing result.md"); + const matchingEvidence = createFile("EVIDENCE - Microglia result.md"); + const datacoreQuery = vi.fn(() => []); + const { app } = createApp({ + datacoreInitialized: false, + datacoreQuery, + files: [activeClaim, existingEvidence, matchingEvidence], + frontmatterByPath: { + [activeClaim.path]: { + nodeTypeId: "claim", + supports: `[[${existingEvidence.basename}]]`, + }, + [existingEvidence.path]: { nodeTypeId: "evidence" }, + [matchingEvidence.path]: { nodeTypeId: "evidence" }, + }, + }); + + const results = new QueryEngine(app).searchCompatibleNodeByTitle({ + query: "result", + compatibleNodeTypeIds: ["evidence"], + activeFile: activeClaim, + selectedRelationType: "supports", + }); + + expect(results).toEqual([matchingEvidence]); + expect(datacoreQuery).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/obsidian/src/test/mocks/obsidian.ts b/apps/obsidian/src/test/mocks/obsidian.ts new file mode 100644 index 000000000..acde5e0ad --- /dev/null +++ b/apps/obsidian/src/test/mocks/obsidian.ts @@ -0,0 +1,5 @@ +export class TFile { + basename = ""; + name = ""; + path = ""; +} diff --git a/apps/obsidian/vitest.config.mts b/apps/obsidian/vitest.config.mts new file mode 100644 index 000000000..9c8884edb --- /dev/null +++ b/apps/obsidian/vitest.config.mts @@ -0,0 +1,19 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { defineConfig } from "vitest/config"; + +const dirname = path.dirname(fileURLToPath(import.meta.url)); + +export default defineConfig({ + root: dirname, + test: { + environment: "node", + include: ["src/**/__tests__/**/*.test.ts"], + }, + resolve: { + alias: { + "~": path.resolve(dirname, "src"), + obsidian: path.resolve(dirname, "src/test/mocks/obsidian.ts"), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e80fe1c3a..23e9309f3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,6 +215,9 @@ importers: uuidv7: specifier: 1.1.0 version: 1.1.0 + vitest: + specifier: 'catalog:' + version: 4.1.6(@edge-runtime/vm@3.2.0)(@opentelemetry/api@1.9.0)(@types/node@22.20.0)(jsdom@20.0.3)(msw@2.11.1(@types/node@22.20.0)(typescript@5.5.4))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.21.0)(yaml@2.8.2)) zod: specifier: ^3.24.1 version: 3.25.76