diff --git a/apps/roam/src/utils/__tests__/conceptConversion.test.ts b/apps/roam/src/utils/__tests__/conceptConversion.test.ts new file mode 100644 index 000000000..ea2e983b5 --- /dev/null +++ b/apps/roam/src/utils/__tests__/conceptConversion.test.ts @@ -0,0 +1,188 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { DiscourseNode } from "~/utils/getDiscourseNodes"; + +const { mockedGetPageUidByPageTitle, mockedGetDiscourseNodes } = vi.hoisted( + () => ({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + mockedGetPageUidByPageTitle: vi.fn((_title: string) => ""), + mockedGetDiscourseNodes: vi.fn((): DiscourseNode[] => []), + }), +); +vi.mock("roamjs-components/queries/getPageUidByPageTitle", () => ({ + default: mockedGetPageUidByPageTitle, +})); +vi.mock("~/utils/getDiscourseNodes", () => ({ + default: mockedGetDiscourseNodes, +})); +vi.mock("~/utils/getDiscourseRelations", () => ({ default: () => [] })); +vi.mock("roamjs-components/queries/getPageTitleByPageUid", () => ({ + default: () => "", +})); + +// getNodeExtraData queries Roam for the author and timestamps of every concept. +vi.hoisted(() => { + (globalThis as { window?: unknown }).window = { + roamAlphaAPI: { + util: { generateUID: () => "someUid" }, + q: () => [["author-1", "page-1", 1000, 2000]], + }, + }; +}); + +import { + discourseNodeBlockToLocalConcept, + discourseNodeSchemaToLocalConcept, +} from "~/utils/conceptConversion"; + +const CONTEXT = { spaceId: 1, userId: 2 } as never; + +const nodeType = (overrides: Partial): DiscourseNode => ({ + text: "Evidence", + type: "_EVD-node", + shortcut: "e", + format: "[[EVD]] - {content} - {Source}", + specification: [], + backedBy: "user", + canvasSettings: {}, + ...overrides, +}); + +const SOURCE_TYPE = nodeType({ + text: "Source", + type: "src-node", + format: "@{content}", +}); + +// Roam-like lookup: only pages that exist resolve to a uid. +const PAGE_UIDS: Record = { + "@sun2019direct": "source-1", + "@sun2019direct/fig2": "source-2", +}; + +beforeEach(() => { + mockedGetPageUidByPageTitle.mockReset(); + mockedGetPageUidByPageTitle.mockImplementation( + (title: string) => PAGE_UIDS[title] ?? "", + ); + mockedGetDiscourseNodes.mockReturnValue([SOURCE_TYPE]); +}); + +describe("discourseNodeSchemaToLocalConcept source slot", () => { + it("declares a sourceDocument slot filled by the Source node type", () => { + const concept = discourseNodeSchemaToLocalConcept(CONTEXT, nodeType({})); + expect(concept.local_reference_content).toEqual({ + sourceDocument: "src-node", + }); + expect(concept.literal_content).toMatchObject({ + roles: ["sourceDocument"], + }); + }); + + it("falls back to the default source type when none is configured", () => { + mockedGetDiscourseNodes.mockReturnValue([]); + const concept = discourseNodeSchemaToLocalConcept(CONTEXT, nodeType({})); + expect(concept.local_reference_content).toEqual({ + sourceDocument: "_SRC-node", + }); + }); + + it("declares no slot when the format has no source placeholder", () => { + const concept = discourseNodeSchemaToLocalConcept( + CONTEXT, + nodeType({ text: "Claim", type: "clm", format: "[[CLM]] - {content}" }), + ); + expect(concept.local_reference_content).toBeUndefined(); + expect(concept.literal_content).toEqual({ label: "Claim" }); + }); + + it("keeps the label and template it already carried", () => { + const concept = discourseNodeSchemaToLocalConcept( + CONTEXT, + nodeType({ template: [{ text: "Question:" }] }), + ); + expect(concept.literal_content).toEqual({ + label: "Evidence", + template: "* Question:\n", + roles: ["sourceDocument"], + }); + }); +}); + +describe("discourseNodeBlockToLocalConcept source slot", () => { + const convert = (title: string, schema: DiscourseNode = nodeType({})) => + discourseNodeBlockToLocalConcept(CONTEXT, { + nodeUid: "node-1", + schemaUid: schema.type, + text: title, + title, + schema, + }); + + it("resolves the source page named in the title", () => { + const concept = convert( + "[[EVD]] - REM sleep aids recall - [[@sun2019direct]]", + ); + expect(concept.local_reference_content).toEqual({ + sourceDocument: "source-1", + }); + }); + + // Leniency on the target type: see sourceSlot.ts + it("accepts a source that is a node of another type", () => { + mockedGetDiscourseNodes.mockReturnValue([ + SOURCE_TYPE, + nodeType({ text: "Claim", type: "clm", format: "[[CLM]] - {content}" }), + ]); + mockedGetPageUidByPageTitle.mockImplementation(() => "claim-1"); + const concept = convert( + "[[EVD]] - REM sleep aids recall - [[CLM]] - a claim", + ); + expect(concept.local_reference_content).toEqual({ + sourceDocument: "claim-1", + }); + }); + + it("omits the slot when the source page is not a discourse node", () => { + mockedGetPageUidByPageTitle.mockImplementation(() => "some-page"); + const concept = convert( + "[[EVD]] - REM sleep aids recall - [[a plain page]]", + ); + expect(concept.local_reference_content).toBeUndefined(); + }); + + it("omits the slot when the source page does not exist", () => { + const concept = convert( + "[[EVD]] - REM sleep aids recall - [[@unknownref]]", + ); + expect(concept.local_reference_content).toBeUndefined(); + }); + + it("skips a source containing a slash, even when the page exists", () => { + const concept = convert( + "[[EVD]] - REM sleep aids recall - [[@sun2019direct/fig2]]", + ); + expect(concept.local_reference_content).toBeUndefined(); + expect(mockedGetPageUidByPageTitle).not.toHaveBeenCalled(); + }); + + it("omits the slot when the node type has no source placeholder", () => { + const concept = convert( + "[[CLM]] - REM sleep aids recall", + nodeType({ text: "Claim", type: "clm", format: "[[CLM]] - {content}" }), + ); + expect(concept.local_reference_content).toBeUndefined(); + }); + + it("uses the page title, not the block text, of a block-backed node", () => { + const concept = discourseNodeBlockToLocalConcept(CONTEXT, { + nodeUid: "node-1", + schemaUid: "_EVD-node", + text: "[[EVD]] - REM sleep aids recall - [[@sun2019direct]] the block text", + title: "[[EVD]] - REM sleep aids recall - [[@sun2019direct]]", + schema: nodeType({}), + }); + expect(concept.local_reference_content).toEqual({ + sourceDocument: "source-1", + }); + }); +}); diff --git a/apps/roam/src/utils/__tests__/roamToCrossAppConverters.test.ts b/apps/roam/src/utils/__tests__/roamToCrossAppConverters.test.ts index 54dd59dbb..fb9993f81 100644 --- a/apps/roam/src/utils/__tests__/roamToCrossAppConverters.test.ts +++ b/apps/roam/src/utils/__tests__/roamToCrossAppConverters.test.ts @@ -1,5 +1,6 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { Json } from "@repo/database/dbTypes"; +import defaultDiscourseNodes from "~/data/defaultDiscourseNodes"; vi.mock("roamjs-components/queries/getFullTreeByParentUid", () => ({ default: () => ({ children: [] }), @@ -8,12 +9,34 @@ vi.mock("roamjs-components/queries/getPageViewType", () => ({ default: () => "bullet", })); vi.mock("~/utils/pageToMarkdown", () => ({ toMarkdown: () => "" })); +vi.mock("~/utils/getDiscourseNodes", () => ({ + default: vi.fn(() => defaultDiscourseNodes), +})); + +const { mockedGetPageUidByPageTitle } = vi.hoisted(() => ({ + // eslint-disable-next-line @typescript-eslint/no-unused-vars + mockedGetPageUidByPageTitle: vi.fn((_title: string) => ""), +})); +vi.mock("roamjs-components/queries/getPageUidByPageTitle", () => ({ + default: mockedGetPageUidByPageTitle, +})); + +// Runs before the imports below: getDiscourseNodes calls generateUID at module load. +vi.hoisted(() => { + (globalThis as { window?: unknown }).window = { + roamAlphaAPI: { util: { generateUID: () => "someUid" } }, + }; +}); import { nodeSchemaToCrossApp, nodeUidsWithTypeToCrossApp, } from "~/utils/roamToCrossAppConverters"; -import type { DiscourseNode } from "~/utils/getDiscourseNodes"; +import getDiscourseNodes, { + type DiscourseNode, +} from "~/utils/getDiscourseNodes"; + +const mockedGetDiscourseNodes = vi.mocked(getDiscourseNodes); const USER_ROW = { ":db/id": 5, ":user/uid": "user-1" }; @@ -65,14 +88,15 @@ describe("nodeUidsWithTypeToCrossApp timestamps", () => { }); }); -const nodeSchema = (): DiscourseNode => ({ +const nodeSchema = (overrides: Partial): DiscourseNode => ({ text: "Evidence", type: "_EVD-node", shortcut: "e", - format: "[[EVD]] - {content}", + format: "[[EVD]] - {content} - {Source}", specification: [], backedBy: "user", canvasSettings: {}, + ...overrides, }); // For the timestamp tests: what Roam holds about one node type page. @@ -82,7 +106,7 @@ const convertSchemaPull = (pullResult: Record | null) => { pull: () => pullResult, }, }; - return nodeSchemaToCrossApp(nodeSchema()); + return nodeSchemaToCrossApp(nodeSchema({})); }; const schemaPull = { @@ -90,6 +114,19 @@ const schemaPull = { ":create/user": { ":user/uid": "user-1" }, }; +const convertSchema = (node: DiscourseNode) => { + (globalThis as { window: unknown }).window = { + roamAlphaAPI: { + pull: () => ({ + ":create/time": 1000, + ":edit/time": 2000, + ":create/user": { ":user/uid": "user-1" }, + }), + }, + }; + return nodeSchemaToCrossApp(node); +}; + describe("nodeSchemaToCrossApp timestamps", () => { it("takes the block edit time, as written when the page props change", () => { const schema = convertSchemaPull({ ...schemaPull, ":edit/time": 3000 }); @@ -124,3 +161,110 @@ describe("nodeSchemaToCrossApp timestamps", () => { expect(convertSchemaPull({ ":create/time": 1000 })).toBeNull(); }); }); + +describe("nodeSchemaToCrossApp source slot", () => { + it("adds a sourceDocument slot definition pointing at the Source node type", () => { + mockedGetDiscourseNodes.mockReturnValue([ + nodeSchema({ text: "Source", type: "src-node", format: "@{content}" }), + ]); + expect(convertSchema(nodeSchema({}))?.slotDefinitions).toEqual({ + sourceDocument: "src-node", + }); + }); + + it("falls back to the default source type when no Source node exists", () => { + mockedGetDiscourseNodes.mockReturnValue([]); + expect(convertSchema(nodeSchema({}))?.slotDefinitions).toEqual({ + sourceDocument: "_SRC-node", + }); + }); +}); + +describe("nodeUidsWithTypeToCrossApp source slot", () => { + const EVIDENCE_SCHEMA = nodeSchema({ + type: "schema-1", + }); + const SOURCE_SCHEMA = nodeSchema({ + text: "Source", + type: "src-node", + format: "@{content}", + }); + // Roam-like lookup: only existing pages resolve to a uid. + const PAGE_UIDS: Record = { + "@sun2019direct": "source-1", + "@sun2019direct/fig2": "source-2", + }; + + beforeEach(() => { + mockedGetPageUidByPageTitle.mockReset(); + mockedGetPageUidByPageTitle.mockImplementation( + (title: string) => PAGE_UIDS[title] ?? "", + ); + }); + + it("resolves the source page from the title into a sourceDocument slot", async () => { + mockedGetDiscourseNodes.mockReturnValue([EVIDENCE_SCHEMA, SOURCE_SCHEMA]); + const node = await convertRow({ + ...baseRow, + ":node/title": "[[EVD]] - REM sleep aids recall - [[@sun2019direct]]", + }); + expect(node.slots).toEqual({ sourceDocument: "source-1" }); + }); + + // Leniency on the target type: see sourceSlot.ts + it("accepts a source that is a node of another type", async () => { + mockedGetDiscourseNodes.mockReturnValue([ + EVIDENCE_SCHEMA, + SOURCE_SCHEMA, + nodeSchema({ text: "Claim", type: "clm", format: "[[CLM]] - {content}" }), + ]); + mockedGetPageUidByPageTitle.mockImplementation(() => "claim-1"); + const node = await convertRow({ + ...baseRow, + ":node/title": "[[EVD]] - REM sleep aids recall - [[CLM]] - a claim", + }); + expect(node.slots).toEqual({ sourceDocument: "claim-1" }); + }); + + it("omits slots when the source page is not a discourse node", async () => { + mockedGetDiscourseNodes.mockReturnValue([EVIDENCE_SCHEMA, SOURCE_SCHEMA]); + mockedGetPageUidByPageTitle.mockImplementation(() => "some-page"); + const node = await convertRow({ + ...baseRow, + ":node/title": "[[EVD]] - REM sleep aids recall - [[a plain page]]", + }); + expect(node.slots).toBeUndefined(); + }); + + it("omits slots when the source page does not exist", async () => { + mockedGetDiscourseNodes.mockReturnValue([EVIDENCE_SCHEMA, SOURCE_SCHEMA]); + const node = await convertRow({ + ...baseRow, + ":node/title": "[[EVD]] - REM sleep aids recall - [[@unknownref]]", + }); + expect(node.slots).toBeUndefined(); + }); + + it("skips sources containing a slash, even when the page exists", async () => { + mockedGetDiscourseNodes.mockReturnValue([EVIDENCE_SCHEMA, SOURCE_SCHEMA]); + const node = await convertRow({ + ...baseRow, + ":node/title": + "[[EVD]] - REM sleep aids recall - [[@sun2019direct/fig2]]", + }); + expect(node.slots).toBeUndefined(); + expect(mockedGetPageUidByPageTitle).not.toHaveBeenCalled(); + }); + + it("omits slots when the schema format has no source placeholder", async () => { + mockedGetDiscourseNodes.mockReturnValue([ + nodeSchema({ type: "schema-1", format: "[[CLM]] - {content}" }), + SOURCE_SCHEMA, + ]); + const node = await convertRow({ + ...baseRow, + ":node/title": "[[CLM]] - REM sleep aids recall", + }); + expect(node.slots).toBeUndefined(); + }); +}); diff --git a/apps/roam/src/utils/conceptConversion.ts b/apps/roam/src/utils/conceptConversion.ts index 83abd1a14..e10d980b6 100644 --- a/apps/roam/src/utils/conceptConversion.ts +++ b/apps/roam/src/utils/conceptConversion.ts @@ -1,6 +1,12 @@ import { InputTextNode } from "roamjs-components/types"; import getBlockProps from "./getBlockProps"; import { DiscourseNode } from "./getDiscourseNodes"; +import { + SOURCE_SLOT, + schemaHasSourceSlot, + sourceSlotSchemaId, + sourceUidOfNode, +} from "./sourceSlot"; import getDiscourseRelations from "./getDiscourseRelations"; import type { DiscourseRelation } from "./getDiscourseRelations"; import type { SupabaseContext } from "~/utils/supabaseContext"; @@ -79,23 +85,23 @@ export const discourseNodeSchemaToLocalConcept = ( ): LocalConceptDataInput => { const titleParts = node.text.split("/"); const label = titleParts[titleParts.length - 1] ?? node.text; - const result: LocalConceptDataInput = { + const literalContent: Record = { label }; + if (node.template !== undefined) + literalContent.template = templateToText(node.template); + const hasSourceSlot = schemaHasSourceSlot(node); + if (hasSourceSlot) literalContent.roles = [SOURCE_SLOT]; + return { space_id: context.spaceId, name: node.text, source_local_id: node.type, is_schema: true, - literal_content: { - label, - }, + literal_content: literalContent, + ...(hasSourceSlot + ? { local_reference_content: { [SOURCE_SLOT]: sourceSlotSchemaId() } } + : {}), /* eslint-enable @typescript-eslint/naming-convention */ ...getNodeExtraData(node.type), }; - if (node.template !== undefined) - result.literal_content = { - label, - template: templateToText(node.template), - }; - return result; }; export const discourseNodeBlockToLocalConcept = ( @@ -104,18 +110,28 @@ export const discourseNodeBlockToLocalConcept = ( nodeUid, schemaUid, text, + title, + schema, }: { nodeUid: string; schemaUid: string; text: string; + // The node's title, which is where a {source} placeholder is filled in. It differs + // from text for a node whose text lives in a block below its page. + title?: string; + schema?: DiscourseNode; }, ): LocalConceptDataInput => { + const sourceUid = title ? sourceUidOfNode(title, schema) : undefined; return { space_id: context.spaceId, name: text, source_local_id: nodeUid, schema_represented_by_local_id: schemaUid, is_schema: false, + ...(sourceUid + ? { local_reference_content: { [SOURCE_SLOT]: sourceUid } } + : {}), /* eslint-enable @typescript-eslint/naming-convention */ ...getNodeExtraData(nodeUid), }; diff --git a/apps/roam/src/utils/extractContentFromTitle.ts b/apps/roam/src/utils/extractContentFromTitle.ts index d88002776..407b89fdd 100644 --- a/apps/roam/src/utils/extractContentFromTitle.ts +++ b/apps/roam/src/utils/extractContentFromTitle.ts @@ -1,10 +1,14 @@ import getDiscourseNodeFormatExpression from "./getDiscourseNodeFormatExpression"; -const extractContentFromTitle = ( +// The text a node's title holds in a given placeholder of its node type's format: +// extractFieldFromTitle("[[EVD]] - a claim - [[@ref]]", evidence, "source") is +// "[[@ref]]". +export const extractFieldFromTitle = ( title: string, node: { format: string }, -): string => { - if (!node.format) return title; + field: string, +): string | undefined => { + if (!node.format) return undefined; const placeholderRegex = /{([\w\d-]+)}/g; const placeholders: string[] = []; let placeholderMatch: RegExpExecArray | null = null; @@ -14,15 +18,17 @@ const extractContentFromTitle = ( const expression = getDiscourseNodeFormatExpression(node.format); const expressionMatch = expression.exec(title); if (!expressionMatch || expressionMatch.length <= 1) { - return title; + return undefined; } const contentIndex = placeholders.findIndex( - (name) => name.toLowerCase() === "content", + (name) => name.toLowerCase() === field, ); - if (contentIndex >= 0) { - return expressionMatch[contentIndex + 1]?.trim() || title; - } - return expressionMatch[1]?.trim() || title; + if (contentIndex >= 0) return expressionMatch[contentIndex + 1]?.trim(); }; +const extractContentFromTitle = ( + title: string, + node: { format: string }, +): string => extractFieldFromTitle(title, node, "content") || title; + export default extractContentFromTitle; diff --git a/apps/roam/src/utils/roamToCrossAppConverters.ts b/apps/roam/src/utils/roamToCrossAppConverters.ts index cbce108cd..95ec1f528 100644 --- a/apps/roam/src/utils/roamToCrossAppConverters.ts +++ b/apps/roam/src/utils/roamToCrossAppConverters.ts @@ -15,6 +15,13 @@ import { toMarkdown } from "./pageToMarkdown"; import getFullTreeByParentUid from "roamjs-components/queries/getFullTreeByParentUid"; import getPageViewType from "roamjs-components/queries/getPageViewType"; import { contentTypes } from "@repo/content-model"; +import getDiscourseNodes from "./getDiscourseNodes"; +import { + SOURCE_SLOT, + schemaHasSourceSlot, + sourceSlotSchemaId, + sourceUidOfNode, +} from "./sourceSlot"; const FULL_MARKDOWN_OPTS = { refs: true, @@ -87,6 +94,9 @@ export const nodeUidsWithTypeToCrossApp = async ( nodes: NodeUidWithType[], ): Promise => { const typesByUid = Object.fromEntries(nodes.map((n) => [n.uid, n.type])); + const schemasById = Object.fromEntries( + getDiscourseNodes().map((s) => [s.type, s]), + ); const nodeRows = (await window.roamAlphaAPI.data.async.pull_many( `[:block/uid :create/user :create/time :edit/time :page/edit-time :node/title]`, nodes.map((n) => [":block/uid", n.uid]), @@ -115,10 +125,12 @@ export const nodeUidsWithTypeToCrossApp = async ( const editTime = (row[":edit/time"] as number | undefined) ?? createdTime; const pageEditTime = (row[":page/edit-time"] as number | undefined) ?? editTime; + const nodeType = typesByUid[uid]; + const sourceUid = sourceUidOfNode(title, schemasById[nodeType]); return { localId: uid, - nodeType: typesByUid[uid], + nodeType, authorId: userUid, createdAt: new Date(createdTime), modifiedAt: new Date(Math.max(editTime, pageEditTime)), @@ -129,6 +141,7 @@ export const nodeUidsWithTypeToCrossApp = async ( }, full: buildFullInlineContent({ uid, title }), }, + ...(sourceUid ? { slots: { [SOURCE_SLOT]: sourceUid } } : {}), }; }); return results; @@ -202,11 +215,16 @@ export const nodeSchemaToCrossApp = ( // :edit/time moves when the props are written, :page/edit-time when a block is. const editTime = relData[":edit/time"] ?? createdTime; const pageEditTime = relData[":page/edit-time"] ?? editTime; + const hasSourceSlot = schemaHasSourceSlot(s); + return { localId: s.type, label: s.text, authorId: userUid, createdAt: new Date(createdTime), modifiedAt: new Date(Math.max(editTime, pageEditTime, createdTime)), + ...(hasSourceSlot + ? { slotDefinitions: { [SOURCE_SLOT]: sourceSlotSchemaId() } } + : {}), }; }; diff --git a/apps/roam/src/utils/sourceSlot.ts b/apps/roam/src/utils/sourceSlot.ts new file mode 100644 index 000000000..78fbdb586 --- /dev/null +++ b/apps/roam/src/utils/sourceSlot.ts @@ -0,0 +1,67 @@ +import getDiscourseNodes, { type DiscourseNode } from "./getDiscourseNodes"; +import getPageUidByPageTitle from "roamjs-components/queries/getPageUidByPageTitle"; +import getDiscourseNodeFormatExpression from "./getDiscourseNodeFormatExpression"; +import { extractFieldFromTitle } from "./extractContentFromTitle"; + +// Temporary hack, until slots are a first-class node type setting: a node type whose +// format has a {source} placeholder (Evidence, among the default node types) is taken +// to have a sourceDocument slot, filled by the Source node named in a node's title. +// Both the sync and the publish path express this, so it lives here rather than in +// either of them. + +export const SOURCE_SLOT = "sourceDocument"; +const DEFAULT_SOURCE_SCHEMA_ID = "_SRC-node"; + +type NodeFormat = Pick; + +export const schemaHasSourceSlot = (schema: NodeFormat | undefined): boolean => + (schema?.format ?? "").toLowerCase().includes("{source}"); + +const sourceNodeType = (allNodes: DiscourseNode[]): DiscourseNode | undefined => + allNodes.find((node) => node.text.toLowerCase() === "source"); + +// The node type a sourceDocument slot points at. +export const sourceSlotSchemaId = (allNodes?: DiscourseNode[]): string => + sourceNodeType(allNodes ?? getDiscourseNodes())?.type ?? + DEFAULT_SOURCE_SCHEMA_ID; + +// Compiled once per format: these are matched against every node's source. +const formatMatchers = new Map(); +const matcherFor = (format: string): RegExp => { + const cached = formatMatchers.get(format); + if (cached) return cached; + const matcher = getDiscourseNodeFormatExpression(format); + formatMatchers.set(format, matcher); + return matcher; +}; + +// A slot may only hold a discourse node: the database resolves its value to a concept, +// and a page that is not a discourse node has none, which would be stored as a null +// reference. Better to leave the slot out than to fill it with that. +// +// Which node type it is, we do not check, and that leniency is part of the hack: the +// Source type is recognised here by being named "source", and a graph coming from +// another app may well name it otherwise. Rejecting anything but a local "Source" node +// would silently drop those. This goes away with slots as a real node type setting. +const isDiscourseNodeTitle = ( + title: string, + allNodes: DiscourseNode[], +): boolean => allNodes.some((node) => matcherFor(node.format).test(title)); + +// The page a node's {source} placeholder resolves to, when there is one. The +// placeholder is usually filled with a page reference, and a title holding a slash is +// a namespaced page rather than a source, so it is left alone. +export const sourceUidOfNode = ( + title: string, + schema: NodeFormat | undefined, + allNodes?: DiscourseNode[], +): string | undefined => { + if (!schemaHasSourceSlot(schema)) return undefined; + const sourceTitle = extractFieldFromTitle(title, schema!, "source") + ?.replace(/^\[\[(.*)\]\]$/s, "$1") + .trim(); + if (!sourceTitle || sourceTitle.includes("/")) return undefined; + if (!isDiscourseNodeTitle(sourceTitle, allNodes ?? getDiscourseNodes())) + return undefined; + return getPageUidByPageTitle(sourceTitle) || undefined; +}; diff --git a/apps/roam/src/utils/syncDgNodesToSupabase.ts b/apps/roam/src/utils/syncDgNodesToSupabase.ts index af20f4566..9cc55b35b 100644 --- a/apps/roam/src/utils/syncDgNodesToSupabase.ts +++ b/apps/roam/src/utils/syncDgNodesToSupabase.ts @@ -667,11 +667,17 @@ export const convertDgToSupabaseConcepts = async ({ return discourseNodeSchemaToLocalConcept(context, node); }); + const schemasByUid = new Map( + allNodeTypes.map((nodeType) => [nodeType.type, nodeType]), + ); + const nodeBlockToLocalConcepts = nodesSince.map((node) => { const localConcept = discourseNodeBlockToLocalConcept(context, { nodeUid: node.source_local_id, schemaUid: node.type, text: node.node_title ? `${node.node_title} ${node.text}` : node.text, + title: node.node_title ?? node.text, + schema: schemasByUid.get(node.type), }); return localConcept; }); diff --git a/packages/database/src/lib/__tests__/crossAppConverters.test.ts b/packages/database/src/lib/__tests__/crossAppConverters.test.ts new file mode 100644 index 000000000..55a88d69a --- /dev/null +++ b/packages/database/src/lib/__tests__/crossAppConverters.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import type { CrossAppNode, CrossAppNodeSchema } from "../../crossAppContracts"; +import { + crossAppNodeSchemaToDbConcept, + crossAppNodeToDbConcept, +} from "../crossAppConverters"; + +const baseSchema: CrossAppNodeSchema = { + localId: "concept-1", + rid: "orn:obsidian.schema:vault-a/concept-1", + createdAt: new Date("2026-06-14T11:00:00Z"), + authorId: "account-local-1", + label: "Some concept", +}; + +const baseNode: CrossAppNode = { + localId: "node-1", + rid: "orn:obsidian.note:vault-a/node-1", + createdAt: new Date("2026-06-14T11:00:00Z"), + authorId: "account-local-1", + nodeType: "concept-1", + content: { direct: { value: "EVD - REM sleep and recall" } }, +}; + +describe("crossAppNodeSchemaToDbConcept", () => { + it("stores slot definitions as roles plus local reference content", () => { + const result = crossAppNodeSchemaToDbConcept({ + ...baseSchema, + templateTitle: "Template Title", + slotDefinitions: { evidence: "evidence-type", claim: "claim-type" }, + }); + expect(result.literal_content).toEqual({ + template: "Template Title", + roles: ["evidence", "claim"], + }); + expect(result.local_reference_content).toEqual({ + evidence: "evidence-type", + claim: "claim-type", + }); + }); + + it("omits roles and reference content when there are no slot definitions", () => { + const result = crossAppNodeSchemaToDbConcept({ + ...baseSchema, + slotDefinitions: {}, + }); + expect(result).not.toHaveProperty("literal_content"); + expect(result).not.toHaveProperty("local_reference_content"); + }); +}); + +describe("crossAppNodeToDbConcept", () => { + it("stores node slots as local reference content", () => { + expect( + crossAppNodeToDbConcept({ + ...baseNode, + slots: { evidence: "node-5" }, + }).local_reference_content, + ).toEqual({ evidence: "node-5" }); + }); + + it("omits reference content when the node has no slots", () => { + expect(crossAppNodeToDbConcept(baseNode)).not.toHaveProperty( + "local_reference_content", + ); + }); +});