diff --git a/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts b/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts new file mode 100644 index 000000000..4d161e54c --- /dev/null +++ b/apps/roam/src/utils/__tests__/importedSourceIdentity.test.ts @@ -0,0 +1,140 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { DISCOURSE_GRAPH_PROP_NAME } from "~/utils/createReifiedBlock"; +import { + findImportedNodeUidBySourceRid, + getImportedSourceRids, + IMPORTED_FROM_PROP_KEY, + parseImportedSourceIdentity, + readImportedSourceIdentity, + writeImportedSourceIdentity, +} from "~/utils/importedSourceIdentity"; +import type { json } from "~/utils/getBlockProps"; + +const SOURCE_NODE_RID = "orn:obsidian.note:vault-a/node-1"; +const SOURCE_MODIFIED_AT = "2026-06-14T15:00:00.000Z"; +const PAGE_UID = "page-uid"; + +const propsByUid = new Map>(); +const query = vi.fn(); + +const setRoamAlphaApi = (): void => { + (globalThis as { window: unknown }).window = { + roamAlphaAPI: { + data: { + async: { q: query }, + block: { + update: vi.fn( + ({ + block, + }: { + block: { props: Record; uid: string }; + }) => { + propsByUid.set(block.uid, block.props); + }, + ), + }, + }, + pull: (_pattern: string, [, uid]: [string, string]) => ({ + ":block/props": propsByUid.get(uid) ?? {}, + }), + }, + }; +}; + +beforeEach(() => { + propsByUid.clear(); + query.mockReset(); + setRoamAlphaApi(); +}); + +describe("imported source identity metadata", () => { + it("reads the source RID without depending on display metadata", () => { + const props = { + [DISCOURSE_GRAPH_PROP_NAME]: { + [IMPORTED_FROM_PROP_KEY]: { + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + sourceTitle: "Legacy title that may change", + }, + }, + }; + + expect(parseImportedSourceIdentity(props)).toEqual({ + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }); + }); + + it("returns undefined for missing or malformed source identity", () => { + expect(parseImportedSourceIdentity({})).toBeUndefined(); + expect( + parseImportedSourceIdentity({ + [DISCOURSE_GRAPH_PROP_NAME]: { + [IMPORTED_FROM_PROP_KEY]: { sourceNodeRid: 123 }, + }, + }), + ).toBeUndefined(); + }); + + it("writes the source RID and modified time while preserving sibling metadata", () => { + propsByUid.set(PAGE_UID, { + [DISCOURSE_GRAPH_PROP_NAME]: { + "relation-migration": { relationUid: 1718000000000 }, + }, + "other-extension": { enabled: true }, + }); + + writeImportedSourceIdentity({ + pageUid: PAGE_UID, + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }); + + expect(readImportedSourceIdentity(PAGE_UID)).toEqual({ + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }); + expect(propsByUid.get(PAGE_UID)).toEqual({ + [DISCOURSE_GRAPH_PROP_NAME]: { + "relation-migration": { relationUid: 1718000000000 }, + [IMPORTED_FROM_PROP_KEY]: { + sourceModifiedAt: SOURCE_MODIFIED_AT, + sourceNodeRid: SOURCE_NODE_RID, + }, + }, + "other-extension": { enabled: true }, + }); + }); +}); + +describe("imported source identity lookup", () => { + it("returns the stored RID set used for duplicate prevention", async () => { + query.mockResolvedValue([SOURCE_NODE_RID, 123, null]); + + await expect(getImportedSourceRids()).resolves.toEqual( + new Set([SOURCE_NODE_RID]), + ); + expect(query).toHaveBeenCalledOnce(); + expect(query.mock.calls[0]?.[0]).toContain(":sourceNodeRid"); + }); + + it("finds the imported Roam page by source RID", async () => { + query.mockResolvedValue([[PAGE_UID]]); + + await expect(findImportedNodeUidBySourceRid(SOURCE_NODE_RID)).resolves.toBe( + PAGE_UID, + ); + expect(query).toHaveBeenCalledWith( + expect.stringContaining(":sourceNodeRid"), + SOURCE_NODE_RID, + ); + }); + + it("returns null when the source RID has not been imported", async () => { + query.mockResolvedValue([]); + + await expect( + findImportedNodeUidBySourceRid(SOURCE_NODE_RID), + ).resolves.toBeNull(); + }); +}); diff --git a/apps/roam/src/utils/discoverSharedNodes.ts b/apps/roam/src/utils/discoverSharedNodes.ts index 80d4a8d3d..f2bf162d8 100644 --- a/apps/roam/src/utils/discoverSharedNodes.ts +++ b/apps/roam/src/utils/discoverSharedNodes.ts @@ -3,9 +3,7 @@ import { listGroupSharedNodes, type SharedNodeCandidate, } from "@repo/database/lib/sharedNodes"; -import { DISCOURSE_GRAPH_PROP_NAME } from "./createReifiedBlock"; - -const IMPORTED_FROM_PROP_KEY = "importedFrom"; +import { getImportedSourceRids } from "./importedSourceIdentity"; export type DiscoveredSharedNode = { alreadyImported: boolean; @@ -36,19 +34,6 @@ export const toDiscoveredSharedNodes = ({ title: candidate.title, })); -const getImportedSourceRids = async (): Promise> => { - const query = `[:find [?rid ...] - :where - [?page :block/props ?props] - [(get ?props :${DISCOURSE_GRAPH_PROP_NAME}) ?dgData] - [(get ?dgData :${IMPORTED_FROM_PROP_KEY}) ?imported] - [(get ?imported :sourceNodeRid) ?rid]]`; - const result = (await window.roamAlphaAPI.data.async.q(query)) as unknown[]; - return new Set( - result.filter((rid): rid is string => typeof rid === "string"), - ); -}; - export const discoverSharedNodes = async ({ client, currentSpaceId, diff --git a/apps/roam/src/utils/importedSourceIdentity.ts b/apps/roam/src/utils/importedSourceIdentity.ts new file mode 100644 index 000000000..ae5ba0a0f --- /dev/null +++ b/apps/roam/src/utils/importedSourceIdentity.ts @@ -0,0 +1,93 @@ +import { DISCOURSE_GRAPH_PROP_NAME } from "./createReifiedBlock"; +import getBlockProps, { type json } from "./getBlockProps"; +import setBlockProps from "./setBlockProps"; + +export type ImportedSourceIdentity = { + sourceModifiedAt: string; + sourceNodeRid: string; +}; + +export const IMPORTED_FROM_PROP_KEY = "importedFrom"; + +const isJsonObject = (value: json): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +export const parseImportedSourceIdentity = ( + props: Record, +): ImportedSourceIdentity | undefined => { + const discourseGraphProps = props[DISCOURSE_GRAPH_PROP_NAME]; + if (!isJsonObject(discourseGraphProps)) return undefined; + + const importedFrom = discourseGraphProps[IMPORTED_FROM_PROP_KEY]; + if (!isJsonObject(importedFrom)) return undefined; + + const { sourceModifiedAt, sourceNodeRid } = importedFrom; + if (typeof sourceModifiedAt !== "string" || typeof sourceNodeRid !== "string") + return undefined; + + return { sourceModifiedAt, sourceNodeRid }; +}; + +export const readImportedSourceIdentity = ( + pageUid: string, +): ImportedSourceIdentity | undefined => + parseImportedSourceIdentity(getBlockProps(pageUid)); + +export const writeImportedSourceIdentity = ({ + pageUid, + sourceModifiedAt, + sourceNodeRid, +}: { + pageUid: string; + sourceModifiedAt: string; + sourceNodeRid: string; +}): void => { + const existing = getBlockProps(pageUid)[DISCOURSE_GRAPH_PROP_NAME]; + const discourseGraphProps = isJsonObject(existing) ? existing : {}; + + setBlockProps(pageUid, { + [DISCOURSE_GRAPH_PROP_NAME]: { + ...discourseGraphProps, + [IMPORTED_FROM_PROP_KEY]: { sourceModifiedAt, sourceNodeRid }, + }, + }); +}; + +export const getImportedSourceRids = async (): Promise> => { + const query = `[:find [?rid ...] + :where + [?page :block/props ?props] + [(get ?props :${DISCOURSE_GRAPH_PROP_NAME}) ?dgData] + [(get ?dgData :${IMPORTED_FROM_PROP_KEY}) ?importedFrom] + [(get ?importedFrom :sourceNodeRid) ?rid]]`; + const result = (await window.roamAlphaAPI.data.async.q(query)) as unknown[]; + + return new Set( + result.filter((rid): rid is string => typeof rid === "string"), + ); +}; + +export const findImportedNodeUidBySourceRid = async ( + sourceNodeRid: string, +): Promise => { + const query = `[:find ?uid + :in $ ?sourceNodeRid + :where + [?page :block/uid ?uid] + [?page :block/props ?props] + [(get ?props :${DISCOURSE_GRAPH_PROP_NAME}) ?dgData] + [(get ?dgData :${IMPORTED_FROM_PROP_KEY}) ?importedFrom] + [(get ?importedFrom :sourceNodeRid) ?sourceNodeRid]]`; + const result = (await window.roamAlphaAPI.data.async.q( + query, + sourceNodeRid, + )) as [string][]; + + if (result.length > 1) { + console.warn( + `findImportedNodeUidBySourceRid: ${result.length} pages share source RID '${sourceNodeRid}'`, + ); + } + + return result[0]?.[0] ?? null; +};