From 4771dda171b12acdf110115db6688bc7050a68c1 Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 7 Sep 2026 15:29:45 +0530 Subject: [PATCH 1/4] ENG-2140 Map source to an Obsidian relation on pull --- apps/obsidian/package.json | 4 +- .../src/utils/__tests__/importNodes.test.ts | 503 ++++++++++++++++++ .../src/utils/__tests__/mocks/obsidian.ts | 20 + apps/obsidian/src/utils/importNodes.ts | 225 +++++++- apps/obsidian/vitest.config.mts | 18 + pnpm-lock.yaml | 3 + 6 files changed, 761 insertions(+), 12 deletions(-) create mode 100644 apps/obsidian/src/utils/__tests__/importNodes.test.ts create mode 100644 apps/obsidian/src/utils/__tests__/mocks/obsidian.ts create mode 100644 apps/obsidian/vitest.config.mts diff --git a/apps/obsidian/package.json b/apps/obsidian/package.json index d7b167bc9..5e58ca3ea 100644 --- a/apps/obsidian/package.json +++ b/apps/obsidian/package.json @@ -10,7 +10,8 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "publish": "tsx scripts/publish.ts", - "check-types": "tsc --noEmit --skipLibCheck" + "check-types": "tsc --noEmit --skipLibCheck", + "test:unit": "vitest run" }, "keywords": [], "author": "", @@ -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/utils/__tests__/importNodes.test.ts b/apps/obsidian/src/utils/__tests__/importNodes.test.ts new file mode 100644 index 000000000..2bf9926aa --- /dev/null +++ b/apps/obsidian/src/utils/__tests__/importNodes.test.ts @@ -0,0 +1,503 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import matter from "gray-matter"; +import { Notice, TFile } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import type { DGSupabaseClient } from "@repo/database/lib/client"; +import type { ImportableNode, RelationInstance } from "~/types"; +import { spaceUriAndLocalIdToRid } from "@repo/database/lib/rid"; +import { getLoggedInClient, getSupabaseContext } from "../supabaseContext"; +import { importSelectedNodes, refreshImportedFile } from "../importNodes"; +import { loadRelations, saveRelations } from "../relationsStore"; + +vi.mock("../supabaseContext", () => ({ + getLoggedInClient: vi.fn(), + getSupabaseContext: vi.fn(), + getVaultId: () => "local-vault", + getLocalSpaceUri: () => "obsidian:local-vault", +})); +vi.mock("../publishNode", () => ({ + publishNewRelation: vi.fn(async () => false), +})); +vi.mock("../templates", () => ({ createTemplateFile: vi.fn() })); +vi.mock("../importFolderMetadata", () => ({ + resolveFolderForSpaceUri: vi.fn(async () => "import/Research"), +})); +vi.mock("../importRelations", () => ({ + importRelationsForImportedNodes: vi.fn(async () => ({ imported: 0 })), +})); + +const REMOTE_URI = "https://roamresearch.com/#/app/research"; +const evidenceRid = spaceUriAndLocalIdToRid(REMOTE_URI, "evidence", "note"); +const sourceRid = spaceUriAndLocalIdToRid(REMOTE_URI, "source", "note"); +const selectedNode: ImportableNode = { + nodeInstanceId: "evidence", + title: "Evidence title", + spaceId: 2, + spaceName: "Research", + groupId: "group", + selected: true, +}; + +type Row = Record; +const createHarness = () => { + const files = new Map(); + const contents = new Map(); + const schemas: Row[] = [ + { + id: 10, + space_id: 2, + source_local_id: "evidence-type", + name: "Evidence", + is_schema: true, + is_relation: false, + literal_content: {}, + }, + { + id: 11, + space_id: 2, + source_local_id: "source-type", + name: "Source", + is_schema: true, + is_relation: false, + literal_content: {}, + }, + ]; + const concepts: Row[] = [ + ...schemas, + { + id: 20, + space_id: 2, + source_local_id: "evidence", + schema_id: 10, + core_title: "Evidence title", + sourceDocument: 21, + is_schema: false, + is_relation: false, + }, + { + id: 21, + space_id: 2, + source_local_id: "source", + schema_id: 11, + core_title: "Source title", + sourceDocument: null, + is_schema: false, + is_relation: false, + }, + ]; + const contentRows: Row[] = ["evidence", "source"].flatMap((id) => [ + { + space_id: 2, + source_local_id: id, + variant: "direct", + text: `${id} incoming title`, + metadata: {}, + author_id: 1, + created: "2026-09-01T00:00:00", + last_modified: "2026-09-02T00:00:00", + }, + { + space_id: 2, + source_local_id: id, + variant: "full", + text: `${id} body`, + metadata: {}, + author_id: 1, + created: "2026-09-01T00:00:00", + last_modified: "2026-09-02T00:00:00", + }, + ]); + const spaces: Row[] = [ + { id: 1, url: "obsidian:local-vault", name: "Local" }, + { id: 2, url: REMOTE_URI, name: "Research" }, + ]; + const requests: { + table: string; + select: string; + filters: [string, unknown][]; + }[] = []; + let sourceQueryError = false; + const from = vi.fn((table: string) => { + const request = { table, select: "", filters: [] as [string, unknown][] }; + requests.push(request); + const rows = (): Row[] => { + const tableRows = + table === "my_concepts" + ? concepts + : table === "my_contents" || table === "Content" + ? contentRows + : table === "my_spaces" || table === "Space" + ? spaces + : []; + return tableRows.filter((row) => + request.filters.every(([key, value]) => + Array.isArray(value) ? value.includes(row[key]) : row[key] === value, + ), + ); + }; + const response = (single: boolean) => ({ + data: single ? (rows()[0] ?? null) : rows(), + error: + sourceQueryError && + table === "my_concepts" && + request.select === "id, source_local_id, space_id" + ? { message: "Source lookup failed" } + : null, + }); + const query = { + select: (columns: string) => { + request.select = columns; + return query; + }, + eq: (key: string, value: unknown) => { + request.filters.push([key, value]); + return query; + }, + in: (key: string, values: unknown[]) => { + request.filters.push([key, values]); + return query; + }, + maybeSingle: async () => response(true), + then: (resolve: (result: ReturnType) => unknown) => + Promise.resolve(response(false)).then(resolve), + }; + return query; + }); + const create = vi.fn(async (path: string, content: string) => { + if (files.has(path)) throw new Error(`File already exists: ${path}`); + const file = new TFile(); + file.path = path; + files.set(path, file); + contents.set(path, content); + return file; + }); + const renameFile = vi.fn(async (file: TFile, newPath: string) => { + const content = contents.get(file.path)!; + files.delete(file.path); + contents.delete(file.path); + file.path = newPath; + files.set(newPath, file); + contents.set(newPath, content); + }); + const getFileCache = vi.fn((file: TFile) => ({ + frontmatter: matter(contents.get(file.path) ?? "").data, + })); + const plugin = { + app: { + plugins: { plugins: {} }, + vault: { + create, + getFileByPath: (path: string) => files.get(path) ?? null, + getAbstractFileByPath: (path: string) => files.get(path) ?? null, + getMarkdownFiles: () => + [...files.values()].filter((file) => file.extension === "md"), + read: async (file: TFile) => contents.get(file.path)!, + modify: async (file: TFile, content: string) => { + contents.set(file.path, content); + }, + process: async (file: TFile, callback: (content: string) => string) => { + contents.set(file.path, callback(contents.get(file.path)!)); + }, + createFolder: vi.fn(), + adapter: { exists: async (path: string) => files.has(path) }, + }, + metadataCache: { getFileCache, getFirstLinkpathDest: () => null }, + fileManager: { + renameFile, + processFrontMatter: async ( + file: TFile, + callback: (frontmatter: Row) => void, + ) => { + const parsed = matter(contents.get(file.path)!); + callback(parsed.data); + contents.set( + file.path, + matter.stringify(parsed.content, parsed.data), + ); + }, + }, + }, + settings: { + nodeTypes: [ + { + id: "evidence-type", + name: "Evidence", + format: "EVD - {content}", + created: 0, + modified: 0, + }, + { + id: "source-type", + name: "Source", + format: "SRC - {content}", + created: 0, + modified: 0, + }, + ], + relationTypes: [ + { + id: "based-on", + label: "Based on", + complement: "Source of", + color: "black", + created: 0, + modified: 0, + }, + ], + discourseRelations: [ + { + id: "evidence-source", + sourceId: "evidence-type", + destinationId: "source-type", + relationshipTypeId: "based-on", + created: 0, + modified: 0, + }, + ], + }, + saveSettings: vi.fn(), + } as unknown as DiscourseGraphPlugin; + vi.mocked(getLoggedInClient).mockResolvedValue({ + from, + } as unknown as DGSupabaseClient); + vi.mocked(getSupabaseContext).mockResolvedValue({ + spaceId: 1, + platform: "Obsidian", + userId: 1, + spacePassword: "test", + }); + const pull = () => + importSelectedNodes({ plugin, selectedNodes: [selectedNode] }); + const seedSource = async (local = false): Promise => + create( + "My existing source.md", + matter.stringify("Original source body", { + nodeInstanceId: "source", + nodeTypeId: "source-type", + ...(local ? {} : { importedFromRid: sourceRid }), + }), + ); + return { + plugin, + concepts, + contentRows, + requests, + files, + contents, + create, + renameFile, + getFileCache, + pull, + seedSource, + failSourceQuery: () => { + sourceQueryError = true; + }, + }; +}; + +beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "warn").mockImplementation(() => undefined); +}); + +describe("source document import", () => { + it("imports an available Source and creates one local relation", async () => { + const h = createHarness(); + expect(await h.pull()).toEqual({ success: 1, failed: 0 }); + expect([...h.files.keys()]).toContain( + "import/Research/SRC - Source title.md", + ); + expect(Object.values((await loadRelations(h.plugin)).relations)).toEqual([ + expect.objectContaining({ + type: "based-on", + source: evidenceRid, + destination: sourceRid, + }), + ]); + expect(Notice).not.toHaveBeenCalled(); + expect( + h.requests.find( + (request) => request.select === "id, source_local_id, space_id", + )?.filters, + ).toEqual([ + ["is_schema", false], + ["is_relation", false], + ["id", [21]], + ]); + }); + + it.each([false, true])( + "reuses an existing Source (local=%s)", + async (local) => { + const h = createHarness(); + if (local) h.concepts.find((row) => row.id === 21)!.space_id = 1; + const sourceFile = await h.seedSource(local); + const original = h.contents.get(sourceFile.path); + await h.pull(); + expect(h.contents.get(sourceFile.path)).toBe(original); + expect( + [...h.files.values()].filter((file) => file.extension === "md"), + ).toHaveLength(2); + expect( + Object.values((await loadRelations(h.plugin)).relations)[0] + ?.destination, + ).toBe(local ? "source" : sourceRid); + expect( + h.requests.filter((request) => request.table === "my_contents"), + ).toHaveLength(1); + }, + ); + + it.each(["triple", "type"])( + "imports the Source without creating a missing relation %s", + async (missing) => { + const h = createHarness(); + if (missing === "triple") h.plugin.settings.discourseRelations = []; + else h.plugin.settings.relationTypes = []; + const settings = structuredClone(h.plugin.settings); + expect(await h.pull()).toEqual({ success: 1, failed: 0 }); + expect( + [...h.files.values()].filter((file) => file.extension === "md"), + ).toHaveLength(2); + expect((await loadRelations(h.plugin)).relations).toEqual({}); + expect(h.plugin.settings).toEqual(settings); + expect(h.plugin.saveSettings).not.toHaveBeenCalled(); + }, + ); + + it.each(["triple", "type"])( + "does not materialize a relation through a provisional %s", + async (provisional) => { + const h = createHarness(); + const schema = + provisional === "triple" + ? h.plugin.settings.discourseRelations[0]! + : h.plugin.settings.relationTypes[0]!; + schema.importedFromRid = "orn:obsidian.schema:remote/relation-type"; + schema.status = "provisional"; + expect(await h.pull()).toEqual({ success: 1, failed: 0 }); + expect( + [...h.files.values()].filter((file) => file.extension === "md"), + ).toHaveLength(2); + expect((await loadRelations(h.plugin)).relations).toEqual({}); + schema.status = "accepted"; + await h.pull(); + expect( + Object.values((await loadRelations(h.plugin)).relations), + ).toHaveLength(1); + }, + ); + + it("does nothing when the current node has no source value", async () => { + const h = createHarness(); + h.concepts.find((row) => row.id === 20)!.sourceDocument = null; + expect(await h.pull()).toEqual({ success: 1, failed: 0 }); + expect(h.files.size).toBe(1); + expect( + h.requests.some( + (request) => request.select === "id, source_local_id, space_id", + ), + ).toBe(false); + expect(Notice).not.toHaveBeenCalled(); + }); + + it.each(["not-shared", "no-content", "query-error"])( + "keeps the current node when the Source is unavailable: %s", + async (reason) => { + const h = createHarness(); + if (reason === "not-shared") + h.concepts.splice( + h.concepts.findIndex((row) => row.id === 21), + 1, + ); + if (reason === "no-content") h.contentRows.splice(2); + if (reason === "query-error") h.failSourceQuery(); + expect(await h.pull()).toEqual({ success: 1, failed: 0 }); + expect([...h.files.keys()]).toEqual([ + "import/Research/EVD - Evidence title.md", + ]); + expect((await loadRelations(h.plugin)).relations).toEqual({}); + expect(Notice).toHaveBeenCalledWith(expect.stringMatching(/source/i)); + expect(console.warn).toHaveBeenCalled(); + }, + ); + + it("repeated pull and refresh reuse both nodes and the relation without renaming", async () => { + const h = createHarness(); + await h.pull(); + const firstRelations = await loadRelations(h.plugin); + const paths = [...h.files.keys()]; + await h.pull(); + const file = h.files.get("import/Research/EVD - Evidence title.md")!; + expect(await refreshImportedFile({ plugin: h.plugin, file })).toEqual({ + success: true, + error: undefined, + }); + expect([...h.files.keys()]).toEqual(paths); + expect(await loadRelations(h.plugin)).toEqual(firstRelations); + expect(h.renameFile).not.toHaveBeenCalled(); + }); + + it.each([false, true])( + "reuses a manually created relation (local RIDs=%s)", + async (localRids) => { + const h = createHarness(); + await h.seedSource(); + const relation: RelationInstance = { + id: "manual", + type: "based-on", + source: localRids + ? spaceUriAndLocalIdToRid("obsidian:local-vault", "evidence", "note") + : "evidence", + destination: localRids + ? spaceUriAndLocalIdToRid("obsidian:local-vault", "source", "note") + : "source", + created: 1, + }; + await saveRelations(h.plugin, { + version: 1, + lastModified: 1, + relations: { manual: relation }, + }); + await h.pull(); + expect(Object.values((await loadRelations(h.plugin)).relations)).toEqual([ + relation, + ]); + }, + ); + + it("shares one Source across a batch even while the metadata cache is empty", async () => { + const h = createHarness(); + h.getFileCache.mockReturnValue({ frontmatter: {} }); + const evidence = h.concepts.find((row) => row.id === 20)!; + h.concepts.push({ + ...evidence, + id: 22, + source_local_id: "second", + core_title: "Second evidence", + }); + h.contentRows.push( + ...h.contentRows + .slice(0, 2) + .map((row) => ({ ...row, source_local_id: "second" })), + ); + expect( + await importSelectedNodes({ + plugin: h.plugin, + selectedNodes: [ + selectedNode, + { ...selectedNode, nodeInstanceId: "second" }, + ], + }), + ).toEqual({ success: 2, failed: 0 }); + expect( + [...h.files.values()].filter((file) => file.extension === "md"), + ).toHaveLength(3); + expect( + Object.values((await loadRelations(h.plugin)).relations), + ).toHaveLength(2); + expect( + h.create.mock.calls.filter(([path]) => path.includes("SRC -")), + ).toHaveLength(1); + }); +}); diff --git a/apps/obsidian/src/utils/__tests__/mocks/obsidian.ts b/apps/obsidian/src/utils/__tests__/mocks/obsidian.ts new file mode 100644 index 000000000..03bb724d3 --- /dev/null +++ b/apps/obsidian/src/utils/__tests__/mocks/obsidian.ts @@ -0,0 +1,20 @@ +import { vi } from "vitest"; + +export class TFile { + path = ""; + stat = { ctime: 0, mtime: 0, size: 0 }; + get basename(): string { + return this.path.split("/").pop()!.replace(/\.md$/, ""); + } + get name(): string { + return this.path.split("/").pop()!; + } + get extension(): string { + return this.path.split(".").pop()!; + } +} +export const Notice = vi.fn(); +export const normalizePath = (path: string): string => path; +export class App {} +export class Plugin {} +export const prepareFuzzySearch = vi.fn(); diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index bde0f108d..29d2d4852 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -4,10 +4,17 @@ import { App, Notice, TFile } from "obsidian"; import type { DGSupabaseClient } from "@repo/database/lib/client"; import { listGroupSharedNodes } from "@repo/database/lib/sharedNodes"; import type DiscourseGraphPlugin from "~/index"; -import { getLoggedInClient, getSupabaseContext } from "./supabaseContext"; +import { + getLocalSpaceUri, + getLoggedInClient, + getSupabaseContext, +} from "./supabaseContext"; import type { DiscourseNode, ImportableNode } from "~/types"; import { QueryEngine } from "~/services/QueryEngine"; import { + addRelationNoCheck, + findRelationBySourceDestinationType, + loadRelations, getImportedNodesInfo, getLocalNodeKeyToEndpointId, } from "~/utils/relationsStore"; @@ -21,7 +28,7 @@ import { } from "./importRelations"; import { createTemplateFile } from "./templates"; import { resolveFolderForSpaceUri } from "./importFolderMetadata"; -import { getNodeTypeById } from "./typeUtils"; +import { getNodeTypeById, isAcceptedSchema } from "./typeUtils"; import { decorateTitle } from "@repo/database/lib/decorateTitle"; type PublishedNode = { @@ -332,6 +339,7 @@ type NodeTypeSchemaForInstance = { type NodeInstanceImportInfo = { schema?: NodeTypeSchemaForInstance; coreTitle?: string; + sourceDocumentId?: number; }; export const fetchNodeImportInfoForInstances = async ({ @@ -348,7 +356,7 @@ export const fetchNodeImportInfoForInstances = async ({ const { data: instanceRows, error: instanceError } = await client .from("my_concepts") .select( - "source_local_id, schema_id, core_title:literal_content->>core_title", + "source_local_id, schema_id, core_title:literal_content->>core_title, sourceDocument:reference_content->sourceDocument", ) .eq("space_id", spaceId) .eq("is_schema", false) @@ -402,6 +410,8 @@ export const fetchNodeImportInfoForInstances = async ({ schema: row.schema_id === null ? undefined : schemasById.get(row.schema_id), coreTitle: row.core_title ?? undefined, + sourceDocumentId: + typeof row.sourceDocument === "number" ? row.sourceDocument : undefined, }); } @@ -1230,12 +1240,170 @@ const processFileContent = async ({ return file; }; -export const importSelectedNodes = async ({ +const importSourceDocumentRelations = async ({ plugin, - selectedNodes, - onProgress, - precomputedData, + client, + localSpaceId, + spaceUri, + nodeImportInfoByInstance, + importedFiles, }: { + plugin: DiscourseGraphPlugin; + client: DGSupabaseClient; + localSpaceId: number; + spaceUri: string; + nodeImportInfoByInstance: Map; + importedFiles: Map; +}): Promise => { + const nodesWithSource = [...nodeImportInfoByInstance].flatMap( + ([nodeId, info]) => { + const file = importedFiles.get( + spaceUriAndLocalIdToRid(spaceUri, nodeId, "note"), + ); + return file && info.sourceDocumentId !== undefined + ? [{ file, nodeId, sourceDocumentId: info.sourceDocumentId }] + : []; + }, + ); + if (nodesWithSource.length === 0) return; + + const { data: sources, error } = await client + .from("my_concepts") + .select("id, source_local_id, space_id") + .eq("is_schema", false) + .eq("is_relation", false) + .in("id", [ + ...new Set(nodesWithSource.map((node) => node.sourceDocumentId)), + ]); + if (error) throw error; + + const sourceSpaceIds = [ + ...new Set( + (sources ?? []).flatMap((source) => + source.space_id === null ? [] : [source.space_id], + ), + ), + ]; + const sourceSpaceUris = await getSpaceUris(client, sourceSpaceIds); + const queryEngine = new QueryEngine(plugin.app); + const sourceFiles = new Map(); + const pendingSources = new Map(); + const sourceRids = new Map(); + for (const source of sources ?? []) { + if ( + source.id === null || + source.space_id === null || + source.source_local_id === null + ) + continue; + const sourceSpaceUri = sourceSpaceUris.get(source.space_id); + if (!sourceSpaceUri) continue; + const rid = spaceUriAndLocalIdToRid( + sourceSpaceUri, + source.source_local_id, + "note", + ); + sourceRids.set(source.id, rid); + const file = + importedFiles.get(rid) ?? + (source.space_id === localSpaceId + ? queryEngine.getFileByEndpoint(source.source_local_id) + : queryEngine.getFileByImportedFromRid(rid)); + if (file) { + sourceFiles.set(source.id, file); + importedFiles.set(rid, file); + } else { + pendingSources.set(rid, { + nodeInstanceId: source.source_local_id, + spaceId: source.space_id, + title: "", + spaceName: "", + groupId: "", + selected: false, + }); + } + } + if (pendingSources.size > 0) { + await importNodes({ + plugin, + selectedNodes: [...pendingSources.values()], + importedFiles, + }); + } + for (const [id, rid] of sourceRids) { + const file = importedFiles.get(rid); + if (file) sourceFiles.set(id, file); + } + + const localSpaceUri = getLocalSpaceUri(plugin.app); + for (const { file, nodeId, sourceDocumentId } of nodesWithSource) { + const sourceFile = sourceFiles.get(sourceDocumentId); + if (!sourceFile) { + const warning = `Imported ${file.basename}, but its Source is unavailable. No source relation was created.`; + console.warn(warning); + new Notice(warning); + continue; + } + const { frontmatter: current } = parseFrontmatter( + await plugin.app.vault.read(file), + ); + const { frontmatter: source } = parseFrontmatter( + await plugin.app.vault.read(sourceFile), + ); + const sourceNodeType = plugin.settings.nodeTypes.find( + (type) => type.id === source.nodeTypeId, + ); + if (sourceNodeType?.name.toLowerCase() !== "source") continue; + const triple = plugin.settings.discourseRelations.find( + (relation) => + isAcceptedSchema(relation) && + relation.sourceId === current.nodeTypeId && + relation.destinationId === source.nodeTypeId && + plugin.settings.relationTypes.some( + (type) => + type.id === relation.relationshipTypeId && isAcceptedSchema(type), + ), + ); + if (!triple) continue; + if (typeof source.nodeInstanceId !== "string") continue; + const currentEndpoint = spaceUriAndLocalIdToRid(spaceUri, nodeId, "note"); + const sourceEndpoint = + typeof source.importedFromRid === "string" + ? source.importedFromRid + : source.nodeInstanceId; + const relations = await loadRelations(plugin); + const currentEndpoints = [ + currentEndpoint, + nodeId, + spaceUriAndLocalIdToRid(localSpaceUri, nodeId, "note"), + ]; + const sourceEndpoints = [ + sourceEndpoint, + source.nodeInstanceId, + spaceUriAndLocalIdToRid(localSpaceUri, source.nodeInstanceId, "note"), + ]; + if ( + currentEndpoints.some((from) => + sourceEndpoints.some((to) => + findRelationBySourceDestinationType( + relations, + from, + to, + triple.relationshipTypeId, + ), + ), + ) + ) + continue; + await addRelationNoCheck(plugin, { + type: triple.relationshipTypeId, + source: currentEndpoint, + destination: sourceEndpoint, + }); + } +}; + +type ImportSelectedNodesOptions = { plugin: DiscourseGraphPlugin; selectedNodes: ImportableNode[]; onProgress?: (current: number, total: number) => void; @@ -1245,6 +1413,21 @@ export const importSelectedNodes = async ({ keyToRelationEndpointId: Map; relationInstancesBySpace: Map; }; +}; + +export const importSelectedNodes = ( + options: ImportSelectedNodesOptions, +): Promise<{ success: number; failed: number }> => + importNodes({ ...options, importedFiles: new Map() }); + +const importNodes = async ({ + plugin, + selectedNodes, + onProgress, + precomputedData, + importedFiles, +}: ImportSelectedNodesOptions & { + importedFiles: Map; }): Promise<{ success: number; failed: number }> => { const client = await getLoggedInClient(plugin); if (!client) { @@ -1311,10 +1494,12 @@ export const importSelectedNodes = async ({ "note", ); // Check if file already exists by nodeInstanceId + importedFromRid - const existingFile = queryEngine.findExistingImportedFile( - node.nodeInstanceId, - importedFromRid, - ); + const existingFile = + importedFiles.get(importedFromRid) ?? + queryEngine.findExistingImportedFile( + node.nodeInstanceId, + importedFromRid, + ); const nodeContent = await fetchNodeContentForImport({ client, @@ -1460,6 +1645,8 @@ export const importSelectedNodes = async ({ await plugin.app.fileManager.renameFile(processedFile, targetPath); } + // The metadata cache can lag behind vault writes during a batch import. + importedFiles.set(importedFromRid, processedFile); successCount++; processedCount++; onProgress?.(processedCount, totalNodes); @@ -1471,6 +1658,22 @@ export const importSelectedNodes = async ({ } } + try { + await importSourceDocumentRelations({ + plugin, + client, + localSpaceId: context.spaceId, + spaceUri, + nodeImportInfoByInstance, + importedFiles, + }); + } catch (error) { + console.warn("Could not import source documents:", error); + new Notice( + "Nodes imported, but their source relations could not be imported.", + ); + } + // Import relations where both endpoints resolve in this vault (imported or local) try { let keyToRelationEndpointId: Map; diff --git a/apps/obsidian/vitest.config.mts b/apps/obsidian/vitest.config.mts new file mode 100644 index 000000000..acc9fa4ee --- /dev/null +++ b/apps/obsidian/vitest.config.mts @@ -0,0 +1,18 @@ +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({ + test: { + environment: "node", + include: ["src/utils/__tests__/**/*.test.ts"], + }, + resolve: { + alias: { + "~": path.resolve(dirname, "src"), + obsidian: path.resolve(dirname, "src/utils/__tests__/mocks/obsidian.ts"), + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8393d9435..bf4eeeea9 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.9.3))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2)) zod: specifier: ^3.24.1 version: 3.25.76 From 00ab1cfbec68ca20e6bd11301a085d24908b15df Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 7 Sep 2026 15:49:08 +0530 Subject: [PATCH 2/4] ENG-2140 Keep async test doubles lint-clean --- .../src/utils/__tests__/importNodes.test.ts | 37 +++++++++++-------- 1 file changed, 22 insertions(+), 15 deletions(-) diff --git a/apps/obsidian/src/utils/__tests__/importNodes.test.ts b/apps/obsidian/src/utils/__tests__/importNodes.test.ts index 2bf9926aa..ca75f8712 100644 --- a/apps/obsidian/src/utils/__tests__/importNodes.test.ts +++ b/apps/obsidian/src/utils/__tests__/importNodes.test.ts @@ -16,14 +16,14 @@ vi.mock("../supabaseContext", () => ({ getLocalSpaceUri: () => "obsidian:local-vault", })); vi.mock("../publishNode", () => ({ - publishNewRelation: vi.fn(async () => false), + publishNewRelation: vi.fn().mockResolvedValue(false), })); vi.mock("../templates", () => ({ createTemplateFile: vi.fn() })); vi.mock("../importFolderMetadata", () => ({ - resolveFolderForSpaceUri: vi.fn(async () => "import/Research"), + resolveFolderForSpaceUri: vi.fn().mockResolvedValue("import/Research"), })); vi.mock("../importRelations", () => ({ - importRelationsForImportedNodes: vi.fn(async () => ({ imported: 0 })), + importRelationsForImportedNodes: vi.fn().mockResolvedValue({ imported: 0 }), })); const REMOTE_URI = "https://roamresearch.com/#/app/research"; @@ -157,31 +157,34 @@ const createHarness = () => { request.filters.push([key, values]); return query; }, - maybeSingle: async () => response(true), + maybeSingle: () => Promise.resolve(response(true)), then: (resolve: (result: ReturnType) => unknown) => Promise.resolve(response(false)).then(resolve), }; return query; }); - const create = vi.fn(async (path: string, content: string) => { - if (files.has(path)) throw new Error(`File already exists: ${path}`); + const create = vi.fn((path: string, content: string) => { + if (files.has(path)) + return Promise.reject(new Error(`File already exists: ${path}`)); const file = new TFile(); file.path = path; files.set(path, file); contents.set(path, content); - return file; + return Promise.resolve(file); }); - const renameFile = vi.fn(async (file: TFile, newPath: string) => { + const renameFile = vi.fn((file: TFile, newPath: string) => { const content = contents.get(file.path)!; files.delete(file.path); contents.delete(file.path); file.path = newPath; files.set(newPath, file); contents.set(newPath, content); + return Promise.resolve(); }); const getFileCache = vi.fn((file: TFile) => ({ frontmatter: matter(contents.get(file.path) ?? "").data, })); + const saveSettings = vi.fn(); const plugin = { app: { plugins: { plugins: {} }, @@ -191,20 +194,22 @@ const createHarness = () => { getAbstractFileByPath: (path: string) => files.get(path) ?? null, getMarkdownFiles: () => [...files.values()].filter((file) => file.extension === "md"), - read: async (file: TFile) => contents.get(file.path)!, - modify: async (file: TFile, content: string) => { + read: (file: TFile) => Promise.resolve(contents.get(file.path)!), + modify: (file: TFile, content: string) => { contents.set(file.path, content); + return Promise.resolve(); }, - process: async (file: TFile, callback: (content: string) => string) => { + process: (file: TFile, callback: (content: string) => string) => { contents.set(file.path, callback(contents.get(file.path)!)); + return Promise.resolve(); }, createFolder: vi.fn(), - adapter: { exists: async (path: string) => files.has(path) }, + adapter: { exists: (path: string) => Promise.resolve(files.has(path)) }, }, metadataCache: { getFileCache, getFirstLinkpathDest: () => null }, fileManager: { renameFile, - processFrontMatter: async ( + processFrontMatter: ( file: TFile, callback: (frontmatter: Row) => void, ) => { @@ -214,6 +219,7 @@ const createHarness = () => { file.path, matter.stringify(parsed.content, parsed.data), ); + return Promise.resolve(); }, }, }, @@ -255,7 +261,7 @@ const createHarness = () => { }, ], }, - saveSettings: vi.fn(), + saveSettings, } as unknown as DiscourseGraphPlugin; vi.mocked(getLoggedInClient).mockResolvedValue({ from, @@ -279,6 +285,7 @@ const createHarness = () => { ); return { plugin, + saveSettings, concepts, contentRows, requests, @@ -361,7 +368,7 @@ describe("source document import", () => { ).toHaveLength(2); expect((await loadRelations(h.plugin)).relations).toEqual({}); expect(h.plugin.settings).toEqual(settings); - expect(h.plugin.saveSettings).not.toHaveBeenCalled(); + expect(h.saveSettings).not.toHaveBeenCalled(); }, ); From 1fb66a23a70c682934aa5bcd632cff5fdb9e4d1e Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 7 Sep 2026 16:30:32 +0530 Subject: [PATCH 3/4] ENG-2140 Preserve Source identity across filename and local ID collisions --- .../src/utils/__tests__/importNodes.test.ts | 159 ++++++++++++++++++ apps/obsidian/src/utils/importNodes.ts | 73 +++++++- 2 files changed, 224 insertions(+), 8 deletions(-) diff --git a/apps/obsidian/src/utils/__tests__/importNodes.test.ts b/apps/obsidian/src/utils/__tests__/importNodes.test.ts index ca75f8712..1f23f58f6 100644 --- a/apps/obsidian/src/utils/__tests__/importNodes.test.ts +++ b/apps/obsidian/src/utils/__tests__/importNodes.test.ts @@ -507,4 +507,163 @@ describe("source document import", () => { h.create.mock.calls.filter(([path]) => path.includes("SRC -")), ).toHaveLength(1); }); + + it("keeps distinct same-titled Sources and their relations through repeated pulls", async () => { + const h = createHarness(); + h.concepts.push( + { + ...h.concepts.find((row) => row.id === 20)!, + id: 22, + source_local_id: "second-evidence", + core_title: "Second evidence", + sourceDocument: 23, + }, + { + ...h.concepts.find((row) => row.id === 21)!, + id: 23, + source_local_id: "second-source", + }, + ); + h.contentRows.push( + ...h.contentRows.map((row) => ({ + ...row, + source_local_id: `second-${String(row.source_local_id)}`, + text: `Second ${String(row.text)}`, + })), + ); + const pull = () => + importSelectedNodes({ + plugin: h.plugin, + selectedNodes: [ + selectedNode, + { ...selectedNode, nodeInstanceId: "second-evidence" }, + ], + }); + expect(await pull()).toEqual({ success: 2, failed: 0 }); + const sources = [...h.files.values()].filter( + (file) => + matter(h.contents.get(file.path)!).data.nodeTypeId === "source-type", + ); + expect(sources).toHaveLength(2); + const sourceContents = sources.map((file) => h.contents.get(file.path)!); + expect( + sourceContents.some( + (content) => matter(content).content.trim() === "source body", + ), + ).toBe(true); + expect( + sourceContents.some( + (content) => matter(content).content.trim() === "Second source body", + ), + ).toBe(true); + const relations = Object.values((await loadRelations(h.plugin)).relations); + expect(relations).toHaveLength(2); + expect(new Set(relations.map((relation) => relation.destination))).toEqual( + new Set( + sourceContents.map((content) => + String(matter(content).data.importedFromRid), + ), + ), + ); + const paths = [...h.files.keys()]; + await pull(); + await refreshImportedFile({ plugin: h.plugin, file: sources[1]! }); + await pull(); + expect([...h.files.keys()]).toEqual(paths); + expect(Object.values((await loadRelations(h.plugin)).relations)).toEqual( + relations, + ); + expect(h.renameFile).not.toHaveBeenCalled(); + }); + + it.each([false, true])( + "resolves a local Source despite a same-ID import (Datacore=%s)", + async (datacore) => { + const h = createHarness(); + h.concepts.find((row) => row.id === 21)!.space_id = 1; + await h.seedSource(); + const localSource = await h.create( + "Local source.md", + matter.stringify("Local source", { + nodeInstanceId: "source", + nodeTypeId: "source-type", + }), + ); + if (datacore) + Object.assign(h.plugin.app, { + plugins: { + plugins: { + datacore: { + api: { + query: (query: string) => + [...h.files.values()] + .filter((file) => { + const frontmatter = matter( + h.contents.get(file.path)!, + ).data; + return ( + file.extension === "md" && + [ + ...query.matchAll( + /(nodeInstanceId|importedFromRid) = "([^"]+)"/g, + ), + ].every( + ([, key, value]) => frontmatter[key!] === value, + ) + ); + }) + .map((file) => ({ $path: file.path })), + }, + }, + }, + }, + }); + await h.pull(); + expect(Object.values((await loadRelations(h.plugin)).relations)).toEqual([ + expect.objectContaining({ source: evidenceRid, destination: "source" }), + ]); + expect(h.contents.get(localSource.path)).toContain("Local source"); + }, + ); + + it.each([false, true])( + "does not confuse same-ID local and imported relations (local RIDs=%s)", + async (localRids) => { + const h = createHarness(); + for (const id of ["evidence", "source"]) { + await h.create( + `Local ${id}.md`, + matter.stringify(`Local ${id} body`, { + nodeInstanceId: id, + nodeTypeId: `${id}-type`, + }), + ); + } + const manual: RelationInstance = { + id: "local-relation", + type: "based-on", + source: localRids + ? spaceUriAndLocalIdToRid("obsidian:local-vault", "evidence", "note") + : "evidence", + destination: localRids + ? spaceUriAndLocalIdToRid("obsidian:local-vault", "source", "note") + : "source", + created: 1, + }; + await saveRelations(h.plugin, { + version: 1, + lastModified: 1, + relations: { [manual.id]: manual }, + }); + await h.pull(); + await h.pull(); + expect(Object.values((await loadRelations(h.plugin)).relations)).toEqual([ + manual, + expect.objectContaining({ + source: evidenceRid, + destination: sourceRid, + }), + ]); + }, + ); }); diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index 29d2d4852..490ec33bb 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -18,7 +18,10 @@ import { getImportedNodesInfo, getLocalNodeKeyToEndpointId, } from "~/utils/relationsStore"; -import { spaceUriAndLocalIdToRid } from "@repo/database/lib/rid"; +import { + ridToSpaceUriAndLocalId, + spaceUriAndLocalIdToRid, +} from "@repo/database/lib/rid"; import type { PostgrestResponse } from "@supabase/supabase-js"; import type { Tables } from "@repo/database/dbTypes"; import { getSpaceNameIdFromRid } from "./spaceFromRid"; @@ -1307,7 +1310,13 @@ const importSourceDocumentRelations = async ({ const file = importedFiles.get(rid) ?? (source.space_id === localSpaceId - ? queryEngine.getFileByEndpoint(source.source_local_id) + ? queryEngine + .getFilesWithNodeTypeId({ excludeImported: true }) + .find( + (file) => + plugin.app.metadataCache.getFileCache(file)?.frontmatter + ?.nodeInstanceId === source.source_local_id, + ) : queryEngine.getFileByImportedFromRid(rid)); if (file) { sourceFiles.set(source.id, file); @@ -1336,6 +1345,34 @@ const importSourceDocumentRelations = async ({ } const localSpaceUri = getLocalSpaceUri(plugin.app); + const indexedFiles = queryEngine.getFilesWithNodeInstanceId(); + const legacyEndpointsForFile = ({ + file, + nodeInstanceId, + }: { + file: TFile; + nodeInstanceId: string; + }): string[] => { + // Bare IDs and vault RIDs are ambiguous when another space uses the same ID. + const hasOtherFile = + indexedFiles.some( + (candidate) => + candidate !== file && + plugin.app.metadataCache.getFileCache(candidate)?.frontmatter + ?.nodeInstanceId === nodeInstanceId, + ) || + [...importedFiles].some( + ([rid, candidate]) => + candidate !== file && + ridToSpaceUriAndLocalId(rid).sourceLocalId === nodeInstanceId, + ); + return hasOtherFile + ? [] + : [ + nodeInstanceId, + spaceUriAndLocalIdToRid(localSpaceUri, nodeInstanceId, "note"), + ]; + }; for (const { file, nodeId, sourceDocumentId } of nodesWithSource) { const sourceFile = sourceFiles.get(sourceDocumentId); if (!sourceFile) { @@ -1374,13 +1411,14 @@ const importSourceDocumentRelations = async ({ const relations = await loadRelations(plugin); const currentEndpoints = [ currentEndpoint, - nodeId, - spaceUriAndLocalIdToRid(localSpaceUri, nodeId, "note"), + ...legacyEndpointsForFile({ file, nodeInstanceId: nodeId }), ]; const sourceEndpoints = [ sourceEndpoint, - source.nodeInstanceId, - spaceUriAndLocalIdToRid(localSpaceUri, source.nodeInstanceId, "note"), + ...legacyEndpointsForFile({ + file: sourceFile, + nodeInstanceId: source.nodeInstanceId, + }), ]; if ( currentEndpoints.some((from) => @@ -1582,6 +1620,20 @@ const importNodes = async ({ : `${sanitizedFileName}.md`; finalFilePath = `${importFolderPath}/${pathUnderImport}`; + const desiredFilePath = finalFilePath; + let counter = 1; + let occupiedFile: TFile | null; + while ( + (occupiedFile = plugin.app.vault.getFileByPath(finalFilePath)) + ) { + const { frontmatter } = parseFrontmatter( + await plugin.app.vault.read(occupiedFile), + ); + if (frontmatter.importedFromRid === importedFromRid) break; + finalFilePath = `${desiredFilePath.slice(0, -3)} (${counter}).md`; + counter++; + } + // Ensure all parent folders exist (e.g. import/VaultName/Discourse Nodes/SubFolder) const dirParts = finalFilePath.split("/"); for (let i = 1; i < dirParts.length - 1; i++) { @@ -1638,11 +1690,16 @@ const importNodes = async ({ const newPath = `${currentDir}/${sanitizedFileName}.md`; let targetPath = newPath; let counter = 1; - while (await plugin.app.vault.adapter.exists(targetPath)) { + while ( + (await plugin.app.vault.adapter.exists(targetPath)) && + plugin.app.vault.getFileByPath(targetPath) !== processedFile + ) { targetPath = `${currentDir}/${sanitizedFileName} (${counter}).md`; counter++; } - await plugin.app.fileManager.renameFile(processedFile, targetPath); + if (targetPath !== processedFile.path) { + await plugin.app.fileManager.renameFile(processedFile, targetPath); + } } // The metadata cache can lag behind vault writes during a batch import. From aaa0416177a6256b4d24da885a10f75c97cd7115 Mon Sep 17 00:00:00 2001 From: sid597 Date: Mon, 7 Sep 2026 16:58:52 +0530 Subject: [PATCH 4/4] ENG-2140 Keep Obsidian test infrastructure in verification branch --- apps/obsidian/package.json | 4 +- .../src/utils/__tests__/importNodes.test.ts | 669 ------------------ .../src/utils/__tests__/mocks/obsidian.ts | 20 - apps/obsidian/vitest.config.mts | 18 - pnpm-lock.yaml | 3 - 5 files changed, 1 insertion(+), 713 deletions(-) delete mode 100644 apps/obsidian/src/utils/__tests__/importNodes.test.ts delete mode 100644 apps/obsidian/src/utils/__tests__/mocks/obsidian.ts delete mode 100644 apps/obsidian/vitest.config.mts diff --git a/apps/obsidian/package.json b/apps/obsidian/package.json index 5e58ca3ea..d7b167bc9 100644 --- a/apps/obsidian/package.json +++ b/apps/obsidian/package.json @@ -10,8 +10,7 @@ "lint": "eslint .", "lint:fix": "eslint . --fix", "publish": "tsx scripts/publish.ts", - "check-types": "tsc --noEmit --skipLibCheck", - "test:unit": "vitest run" + "check-types": "tsc --noEmit --skipLibCheck" }, "keywords": [], "author": "", @@ -36,7 +35,6 @@ "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/utils/__tests__/importNodes.test.ts b/apps/obsidian/src/utils/__tests__/importNodes.test.ts deleted file mode 100644 index 1f23f58f6..000000000 --- a/apps/obsidian/src/utils/__tests__/importNodes.test.ts +++ /dev/null @@ -1,669 +0,0 @@ -import { beforeEach, describe, expect, it, vi } from "vitest"; -import matter from "gray-matter"; -import { Notice, TFile } from "obsidian"; -import type DiscourseGraphPlugin from "~/index"; -import type { DGSupabaseClient } from "@repo/database/lib/client"; -import type { ImportableNode, RelationInstance } from "~/types"; -import { spaceUriAndLocalIdToRid } from "@repo/database/lib/rid"; -import { getLoggedInClient, getSupabaseContext } from "../supabaseContext"; -import { importSelectedNodes, refreshImportedFile } from "../importNodes"; -import { loadRelations, saveRelations } from "../relationsStore"; - -vi.mock("../supabaseContext", () => ({ - getLoggedInClient: vi.fn(), - getSupabaseContext: vi.fn(), - getVaultId: () => "local-vault", - getLocalSpaceUri: () => "obsidian:local-vault", -})); -vi.mock("../publishNode", () => ({ - publishNewRelation: vi.fn().mockResolvedValue(false), -})); -vi.mock("../templates", () => ({ createTemplateFile: vi.fn() })); -vi.mock("../importFolderMetadata", () => ({ - resolveFolderForSpaceUri: vi.fn().mockResolvedValue("import/Research"), -})); -vi.mock("../importRelations", () => ({ - importRelationsForImportedNodes: vi.fn().mockResolvedValue({ imported: 0 }), -})); - -const REMOTE_URI = "https://roamresearch.com/#/app/research"; -const evidenceRid = spaceUriAndLocalIdToRid(REMOTE_URI, "evidence", "note"); -const sourceRid = spaceUriAndLocalIdToRid(REMOTE_URI, "source", "note"); -const selectedNode: ImportableNode = { - nodeInstanceId: "evidence", - title: "Evidence title", - spaceId: 2, - spaceName: "Research", - groupId: "group", - selected: true, -}; - -type Row = Record; -const createHarness = () => { - const files = new Map(); - const contents = new Map(); - const schemas: Row[] = [ - { - id: 10, - space_id: 2, - source_local_id: "evidence-type", - name: "Evidence", - is_schema: true, - is_relation: false, - literal_content: {}, - }, - { - id: 11, - space_id: 2, - source_local_id: "source-type", - name: "Source", - is_schema: true, - is_relation: false, - literal_content: {}, - }, - ]; - const concepts: Row[] = [ - ...schemas, - { - id: 20, - space_id: 2, - source_local_id: "evidence", - schema_id: 10, - core_title: "Evidence title", - sourceDocument: 21, - is_schema: false, - is_relation: false, - }, - { - id: 21, - space_id: 2, - source_local_id: "source", - schema_id: 11, - core_title: "Source title", - sourceDocument: null, - is_schema: false, - is_relation: false, - }, - ]; - const contentRows: Row[] = ["evidence", "source"].flatMap((id) => [ - { - space_id: 2, - source_local_id: id, - variant: "direct", - text: `${id} incoming title`, - metadata: {}, - author_id: 1, - created: "2026-09-01T00:00:00", - last_modified: "2026-09-02T00:00:00", - }, - { - space_id: 2, - source_local_id: id, - variant: "full", - text: `${id} body`, - metadata: {}, - author_id: 1, - created: "2026-09-01T00:00:00", - last_modified: "2026-09-02T00:00:00", - }, - ]); - const spaces: Row[] = [ - { id: 1, url: "obsidian:local-vault", name: "Local" }, - { id: 2, url: REMOTE_URI, name: "Research" }, - ]; - const requests: { - table: string; - select: string; - filters: [string, unknown][]; - }[] = []; - let sourceQueryError = false; - const from = vi.fn((table: string) => { - const request = { table, select: "", filters: [] as [string, unknown][] }; - requests.push(request); - const rows = (): Row[] => { - const tableRows = - table === "my_concepts" - ? concepts - : table === "my_contents" || table === "Content" - ? contentRows - : table === "my_spaces" || table === "Space" - ? spaces - : []; - return tableRows.filter((row) => - request.filters.every(([key, value]) => - Array.isArray(value) ? value.includes(row[key]) : row[key] === value, - ), - ); - }; - const response = (single: boolean) => ({ - data: single ? (rows()[0] ?? null) : rows(), - error: - sourceQueryError && - table === "my_concepts" && - request.select === "id, source_local_id, space_id" - ? { message: "Source lookup failed" } - : null, - }); - const query = { - select: (columns: string) => { - request.select = columns; - return query; - }, - eq: (key: string, value: unknown) => { - request.filters.push([key, value]); - return query; - }, - in: (key: string, values: unknown[]) => { - request.filters.push([key, values]); - return query; - }, - maybeSingle: () => Promise.resolve(response(true)), - then: (resolve: (result: ReturnType) => unknown) => - Promise.resolve(response(false)).then(resolve), - }; - return query; - }); - const create = vi.fn((path: string, content: string) => { - if (files.has(path)) - return Promise.reject(new Error(`File already exists: ${path}`)); - const file = new TFile(); - file.path = path; - files.set(path, file); - contents.set(path, content); - return Promise.resolve(file); - }); - const renameFile = vi.fn((file: TFile, newPath: string) => { - const content = contents.get(file.path)!; - files.delete(file.path); - contents.delete(file.path); - file.path = newPath; - files.set(newPath, file); - contents.set(newPath, content); - return Promise.resolve(); - }); - const getFileCache = vi.fn((file: TFile) => ({ - frontmatter: matter(contents.get(file.path) ?? "").data, - })); - const saveSettings = vi.fn(); - const plugin = { - app: { - plugins: { plugins: {} }, - vault: { - create, - getFileByPath: (path: string) => files.get(path) ?? null, - getAbstractFileByPath: (path: string) => files.get(path) ?? null, - getMarkdownFiles: () => - [...files.values()].filter((file) => file.extension === "md"), - read: (file: TFile) => Promise.resolve(contents.get(file.path)!), - modify: (file: TFile, content: string) => { - contents.set(file.path, content); - return Promise.resolve(); - }, - process: (file: TFile, callback: (content: string) => string) => { - contents.set(file.path, callback(contents.get(file.path)!)); - return Promise.resolve(); - }, - createFolder: vi.fn(), - adapter: { exists: (path: string) => Promise.resolve(files.has(path)) }, - }, - metadataCache: { getFileCache, getFirstLinkpathDest: () => null }, - fileManager: { - renameFile, - processFrontMatter: ( - file: TFile, - callback: (frontmatter: Row) => void, - ) => { - const parsed = matter(contents.get(file.path)!); - callback(parsed.data); - contents.set( - file.path, - matter.stringify(parsed.content, parsed.data), - ); - return Promise.resolve(); - }, - }, - }, - settings: { - nodeTypes: [ - { - id: "evidence-type", - name: "Evidence", - format: "EVD - {content}", - created: 0, - modified: 0, - }, - { - id: "source-type", - name: "Source", - format: "SRC - {content}", - created: 0, - modified: 0, - }, - ], - relationTypes: [ - { - id: "based-on", - label: "Based on", - complement: "Source of", - color: "black", - created: 0, - modified: 0, - }, - ], - discourseRelations: [ - { - id: "evidence-source", - sourceId: "evidence-type", - destinationId: "source-type", - relationshipTypeId: "based-on", - created: 0, - modified: 0, - }, - ], - }, - saveSettings, - } as unknown as DiscourseGraphPlugin; - vi.mocked(getLoggedInClient).mockResolvedValue({ - from, - } as unknown as DGSupabaseClient); - vi.mocked(getSupabaseContext).mockResolvedValue({ - spaceId: 1, - platform: "Obsidian", - userId: 1, - spacePassword: "test", - }); - const pull = () => - importSelectedNodes({ plugin, selectedNodes: [selectedNode] }); - const seedSource = async (local = false): Promise => - create( - "My existing source.md", - matter.stringify("Original source body", { - nodeInstanceId: "source", - nodeTypeId: "source-type", - ...(local ? {} : { importedFromRid: sourceRid }), - }), - ); - return { - plugin, - saveSettings, - concepts, - contentRows, - requests, - files, - contents, - create, - renameFile, - getFileCache, - pull, - seedSource, - failSourceQuery: () => { - sourceQueryError = true; - }, - }; -}; - -beforeEach(() => { - vi.clearAllMocks(); - vi.spyOn(console, "warn").mockImplementation(() => undefined); -}); - -describe("source document import", () => { - it("imports an available Source and creates one local relation", async () => { - const h = createHarness(); - expect(await h.pull()).toEqual({ success: 1, failed: 0 }); - expect([...h.files.keys()]).toContain( - "import/Research/SRC - Source title.md", - ); - expect(Object.values((await loadRelations(h.plugin)).relations)).toEqual([ - expect.objectContaining({ - type: "based-on", - source: evidenceRid, - destination: sourceRid, - }), - ]); - expect(Notice).not.toHaveBeenCalled(); - expect( - h.requests.find( - (request) => request.select === "id, source_local_id, space_id", - )?.filters, - ).toEqual([ - ["is_schema", false], - ["is_relation", false], - ["id", [21]], - ]); - }); - - it.each([false, true])( - "reuses an existing Source (local=%s)", - async (local) => { - const h = createHarness(); - if (local) h.concepts.find((row) => row.id === 21)!.space_id = 1; - const sourceFile = await h.seedSource(local); - const original = h.contents.get(sourceFile.path); - await h.pull(); - expect(h.contents.get(sourceFile.path)).toBe(original); - expect( - [...h.files.values()].filter((file) => file.extension === "md"), - ).toHaveLength(2); - expect( - Object.values((await loadRelations(h.plugin)).relations)[0] - ?.destination, - ).toBe(local ? "source" : sourceRid); - expect( - h.requests.filter((request) => request.table === "my_contents"), - ).toHaveLength(1); - }, - ); - - it.each(["triple", "type"])( - "imports the Source without creating a missing relation %s", - async (missing) => { - const h = createHarness(); - if (missing === "triple") h.plugin.settings.discourseRelations = []; - else h.plugin.settings.relationTypes = []; - const settings = structuredClone(h.plugin.settings); - expect(await h.pull()).toEqual({ success: 1, failed: 0 }); - expect( - [...h.files.values()].filter((file) => file.extension === "md"), - ).toHaveLength(2); - expect((await loadRelations(h.plugin)).relations).toEqual({}); - expect(h.plugin.settings).toEqual(settings); - expect(h.saveSettings).not.toHaveBeenCalled(); - }, - ); - - it.each(["triple", "type"])( - "does not materialize a relation through a provisional %s", - async (provisional) => { - const h = createHarness(); - const schema = - provisional === "triple" - ? h.plugin.settings.discourseRelations[0]! - : h.plugin.settings.relationTypes[0]!; - schema.importedFromRid = "orn:obsidian.schema:remote/relation-type"; - schema.status = "provisional"; - expect(await h.pull()).toEqual({ success: 1, failed: 0 }); - expect( - [...h.files.values()].filter((file) => file.extension === "md"), - ).toHaveLength(2); - expect((await loadRelations(h.plugin)).relations).toEqual({}); - schema.status = "accepted"; - await h.pull(); - expect( - Object.values((await loadRelations(h.plugin)).relations), - ).toHaveLength(1); - }, - ); - - it("does nothing when the current node has no source value", async () => { - const h = createHarness(); - h.concepts.find((row) => row.id === 20)!.sourceDocument = null; - expect(await h.pull()).toEqual({ success: 1, failed: 0 }); - expect(h.files.size).toBe(1); - expect( - h.requests.some( - (request) => request.select === "id, source_local_id, space_id", - ), - ).toBe(false); - expect(Notice).not.toHaveBeenCalled(); - }); - - it.each(["not-shared", "no-content", "query-error"])( - "keeps the current node when the Source is unavailable: %s", - async (reason) => { - const h = createHarness(); - if (reason === "not-shared") - h.concepts.splice( - h.concepts.findIndex((row) => row.id === 21), - 1, - ); - if (reason === "no-content") h.contentRows.splice(2); - if (reason === "query-error") h.failSourceQuery(); - expect(await h.pull()).toEqual({ success: 1, failed: 0 }); - expect([...h.files.keys()]).toEqual([ - "import/Research/EVD - Evidence title.md", - ]); - expect((await loadRelations(h.plugin)).relations).toEqual({}); - expect(Notice).toHaveBeenCalledWith(expect.stringMatching(/source/i)); - expect(console.warn).toHaveBeenCalled(); - }, - ); - - it("repeated pull and refresh reuse both nodes and the relation without renaming", async () => { - const h = createHarness(); - await h.pull(); - const firstRelations = await loadRelations(h.plugin); - const paths = [...h.files.keys()]; - await h.pull(); - const file = h.files.get("import/Research/EVD - Evidence title.md")!; - expect(await refreshImportedFile({ plugin: h.plugin, file })).toEqual({ - success: true, - error: undefined, - }); - expect([...h.files.keys()]).toEqual(paths); - expect(await loadRelations(h.plugin)).toEqual(firstRelations); - expect(h.renameFile).not.toHaveBeenCalled(); - }); - - it.each([false, true])( - "reuses a manually created relation (local RIDs=%s)", - async (localRids) => { - const h = createHarness(); - await h.seedSource(); - const relation: RelationInstance = { - id: "manual", - type: "based-on", - source: localRids - ? spaceUriAndLocalIdToRid("obsidian:local-vault", "evidence", "note") - : "evidence", - destination: localRids - ? spaceUriAndLocalIdToRid("obsidian:local-vault", "source", "note") - : "source", - created: 1, - }; - await saveRelations(h.plugin, { - version: 1, - lastModified: 1, - relations: { manual: relation }, - }); - await h.pull(); - expect(Object.values((await loadRelations(h.plugin)).relations)).toEqual([ - relation, - ]); - }, - ); - - it("shares one Source across a batch even while the metadata cache is empty", async () => { - const h = createHarness(); - h.getFileCache.mockReturnValue({ frontmatter: {} }); - const evidence = h.concepts.find((row) => row.id === 20)!; - h.concepts.push({ - ...evidence, - id: 22, - source_local_id: "second", - core_title: "Second evidence", - }); - h.contentRows.push( - ...h.contentRows - .slice(0, 2) - .map((row) => ({ ...row, source_local_id: "second" })), - ); - expect( - await importSelectedNodes({ - plugin: h.plugin, - selectedNodes: [ - selectedNode, - { ...selectedNode, nodeInstanceId: "second" }, - ], - }), - ).toEqual({ success: 2, failed: 0 }); - expect( - [...h.files.values()].filter((file) => file.extension === "md"), - ).toHaveLength(3); - expect( - Object.values((await loadRelations(h.plugin)).relations), - ).toHaveLength(2); - expect( - h.create.mock.calls.filter(([path]) => path.includes("SRC -")), - ).toHaveLength(1); - }); - - it("keeps distinct same-titled Sources and their relations through repeated pulls", async () => { - const h = createHarness(); - h.concepts.push( - { - ...h.concepts.find((row) => row.id === 20)!, - id: 22, - source_local_id: "second-evidence", - core_title: "Second evidence", - sourceDocument: 23, - }, - { - ...h.concepts.find((row) => row.id === 21)!, - id: 23, - source_local_id: "second-source", - }, - ); - h.contentRows.push( - ...h.contentRows.map((row) => ({ - ...row, - source_local_id: `second-${String(row.source_local_id)}`, - text: `Second ${String(row.text)}`, - })), - ); - const pull = () => - importSelectedNodes({ - plugin: h.plugin, - selectedNodes: [ - selectedNode, - { ...selectedNode, nodeInstanceId: "second-evidence" }, - ], - }); - expect(await pull()).toEqual({ success: 2, failed: 0 }); - const sources = [...h.files.values()].filter( - (file) => - matter(h.contents.get(file.path)!).data.nodeTypeId === "source-type", - ); - expect(sources).toHaveLength(2); - const sourceContents = sources.map((file) => h.contents.get(file.path)!); - expect( - sourceContents.some( - (content) => matter(content).content.trim() === "source body", - ), - ).toBe(true); - expect( - sourceContents.some( - (content) => matter(content).content.trim() === "Second source body", - ), - ).toBe(true); - const relations = Object.values((await loadRelations(h.plugin)).relations); - expect(relations).toHaveLength(2); - expect(new Set(relations.map((relation) => relation.destination))).toEqual( - new Set( - sourceContents.map((content) => - String(matter(content).data.importedFromRid), - ), - ), - ); - const paths = [...h.files.keys()]; - await pull(); - await refreshImportedFile({ plugin: h.plugin, file: sources[1]! }); - await pull(); - expect([...h.files.keys()]).toEqual(paths); - expect(Object.values((await loadRelations(h.plugin)).relations)).toEqual( - relations, - ); - expect(h.renameFile).not.toHaveBeenCalled(); - }); - - it.each([false, true])( - "resolves a local Source despite a same-ID import (Datacore=%s)", - async (datacore) => { - const h = createHarness(); - h.concepts.find((row) => row.id === 21)!.space_id = 1; - await h.seedSource(); - const localSource = await h.create( - "Local source.md", - matter.stringify("Local source", { - nodeInstanceId: "source", - nodeTypeId: "source-type", - }), - ); - if (datacore) - Object.assign(h.plugin.app, { - plugins: { - plugins: { - datacore: { - api: { - query: (query: string) => - [...h.files.values()] - .filter((file) => { - const frontmatter = matter( - h.contents.get(file.path)!, - ).data; - return ( - file.extension === "md" && - [ - ...query.matchAll( - /(nodeInstanceId|importedFromRid) = "([^"]+)"/g, - ), - ].every( - ([, key, value]) => frontmatter[key!] === value, - ) - ); - }) - .map((file) => ({ $path: file.path })), - }, - }, - }, - }, - }); - await h.pull(); - expect(Object.values((await loadRelations(h.plugin)).relations)).toEqual([ - expect.objectContaining({ source: evidenceRid, destination: "source" }), - ]); - expect(h.contents.get(localSource.path)).toContain("Local source"); - }, - ); - - it.each([false, true])( - "does not confuse same-ID local and imported relations (local RIDs=%s)", - async (localRids) => { - const h = createHarness(); - for (const id of ["evidence", "source"]) { - await h.create( - `Local ${id}.md`, - matter.stringify(`Local ${id} body`, { - nodeInstanceId: id, - nodeTypeId: `${id}-type`, - }), - ); - } - const manual: RelationInstance = { - id: "local-relation", - type: "based-on", - source: localRids - ? spaceUriAndLocalIdToRid("obsidian:local-vault", "evidence", "note") - : "evidence", - destination: localRids - ? spaceUriAndLocalIdToRid("obsidian:local-vault", "source", "note") - : "source", - created: 1, - }; - await saveRelations(h.plugin, { - version: 1, - lastModified: 1, - relations: { [manual.id]: manual }, - }); - await h.pull(); - await h.pull(); - expect(Object.values((await loadRelations(h.plugin)).relations)).toEqual([ - manual, - expect.objectContaining({ - source: evidenceRid, - destination: sourceRid, - }), - ]); - }, - ); -}); diff --git a/apps/obsidian/src/utils/__tests__/mocks/obsidian.ts b/apps/obsidian/src/utils/__tests__/mocks/obsidian.ts deleted file mode 100644 index 03bb724d3..000000000 --- a/apps/obsidian/src/utils/__tests__/mocks/obsidian.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { vi } from "vitest"; - -export class TFile { - path = ""; - stat = { ctime: 0, mtime: 0, size: 0 }; - get basename(): string { - return this.path.split("/").pop()!.replace(/\.md$/, ""); - } - get name(): string { - return this.path.split("/").pop()!; - } - get extension(): string { - return this.path.split(".").pop()!; - } -} -export const Notice = vi.fn(); -export const normalizePath = (path: string): string => path; -export class App {} -export class Plugin {} -export const prepareFuzzySearch = vi.fn(); diff --git a/apps/obsidian/vitest.config.mts b/apps/obsidian/vitest.config.mts deleted file mode 100644 index acc9fa4ee..000000000 --- a/apps/obsidian/vitest.config.mts +++ /dev/null @@ -1,18 +0,0 @@ -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({ - test: { - environment: "node", - include: ["src/utils/__tests__/**/*.test.ts"], - }, - resolve: { - alias: { - "~": path.resolve(dirname, "src"), - obsidian: path.resolve(dirname, "src/utils/__tests__/mocks/obsidian.ts"), - }, - }, -}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bf4eeeea9..8393d9435 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -215,9 +215,6 @@ 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.9.3))(vite@7.3.3(@types/node@22.20.0)(jiti@1.21.7)(tsx@4.20.5)(yaml@2.8.2)) zod: specifier: ^3.24.1 version: 3.25.76