diff --git a/apps/roam/src/utils/__tests__/conceptConversion.test.ts b/apps/roam/src/utils/__tests__/conceptConversion.test.ts index 67252fc56..c73c7c836 100644 --- a/apps/roam/src/utils/__tests__/conceptConversion.test.ts +++ b/apps/roam/src/utils/__tests__/conceptConversion.test.ts @@ -108,23 +108,50 @@ describe("discourseNodeSchemaToLocalConcept source slot", () => { }); }); + // Not covered: an upsert whose author_local_id doesn't resolve nulls author_id. it("carries the type author as author_local_id", () => { const concept = discourseNodeSchemaToLocalConcept(CONTEXT, nodeType({})); expect(concept.author_local_id).toBe("author-1"); }); +}); - it("keeps the label and template it already carried", () => { +describe("discourseNodeSchemaToLocalConcept label and template", () => { + it("writes the template body to template_content, with no template title", () => { const concept = discourseNodeSchemaToLocalConcept( CONTEXT, - nodeType({ template: [{ text: "Question:" }] }), + nodeType({ + template: [{ text: "Question:", children: [{ text: "Answer" }] }], + }), ); expect(concept.literal_content).toEqual({ label: "Evidence", format: "[[EVD]] - {content} - {Source}", - template: "* Question:\n", + template_content: "* Question:\n * Answer\n \n", roles: ["sourceDocument"], }); }); + + it.each([ + ["no template", undefined], + ["an empty template", []], + ["a template of only components", [{ text: "{{query block}}" }]], + ])("omits template_content for %s", (_label, template) => { + const concept = discourseNodeSchemaToLocalConcept( + CONTEXT, + nodeType({ template }), + ); + expect(concept.literal_content).not.toHaveProperty("template_content"); + expect(concept.literal_content).not.toHaveProperty("template"); + }); + + it("keeps a slash-separated name whole in label and name", () => { + const concept = discourseNodeSchemaToLocalConcept( + CONTEXT, + nodeType({ text: "Evidence/Figure" }), + ); + expect(concept.name).toBe("Evidence/Figure"); + expect(concept.literal_content).toMatchObject({ label: "Evidence/Figure" }); + }); }); describe("discourseNodeBlockToLocalConcept core title", () => { diff --git a/apps/roam/src/utils/__tests__/roamToCrossAppConverters.test.ts b/apps/roam/src/utils/__tests__/roamToCrossAppConverters.test.ts index a050e04cf..74992d828 100644 --- a/apps/roam/src/utils/__tests__/roamToCrossAppConverters.test.ts +++ b/apps/roam/src/utils/__tests__/roamToCrossAppConverters.test.ts @@ -221,6 +221,23 @@ describe("nodeSchemaToCrossApp format", () => { }); }); +describe("nodeSchemaToCrossApp template", () => { + it("carries the template body, with no template title", () => { + const schema = convertSchema( + nodeSchema({ template: [{ text: "Question:" }] }), + ); + expect(schema?.template).toBe("* Question:\n"); + expect(schema?.templateTitle).toBeUndefined(); + }); + + it.each([ + ["no template", undefined], + ["an empty template", []], + ])("leaves the template out for %s", (_label, template) => { + expect(convertSchema(nodeSchema({ template }))?.template).toBeUndefined(); + }); +}); + describe("nodeSchemaToCrossApp source slot", () => { it("adds a sourceDocument slot definition pointing at the Source node type", () => { mockedGetDiscourseNodes.mockReturnValue([ diff --git a/apps/roam/src/utils/__tests__/schemaMetadataParity.test.ts b/apps/roam/src/utils/__tests__/schemaMetadataParity.test.ts new file mode 100644 index 000000000..b9aa2d102 --- /dev/null +++ b/apps/roam/src/utils/__tests__/schemaMetadataParity.test.ts @@ -0,0 +1,111 @@ +import { describe, expect, it, vi } from "vitest"; +import type { LocalConceptDataInput } from "@repo/database/inputTypes"; +import type { DiscourseNode } from "~/utils/getDiscourseNodes"; + +vi.mock("roamjs-components/queries/getFullTreeByParentUid", () => ({ + default: () => ({ children: [] }), +})); +vi.mock("roamjs-components/queries/getPageViewType", () => ({ + default: () => "bullet", +})); +vi.mock("roamjs-components/queries/getPageTitleByPageUid", () => ({ + default: () => "", +})); +vi.mock("~/utils/pageToMarkdown", () => ({ toMarkdown: () => "" })); +vi.mock("~/utils/getDiscourseRelations", () => ({ default: () => [] })); +vi.mock("~/utils/getDiscourseNodes", () => ({ + default: () => [SOURCE_TYPE], +})); + +// Sync reads the author through q, publish through pull; both see one creator. +vi.hoisted(() => { + (globalThis as { window?: unknown }).window = { + roamAlphaAPI: { + util: { generateUID: () => "someUid" }, + q: () => [["author-1", "page-1", 1000, 2000]], + pull: () => ({ + ":create/time": 1000, + ":page/edit-time": 2000, + ":create/user": { ":user/uid": "author-1" }, + }), + }, + }; +}); + +import { crossAppNodeSchemaToDbConcept } from "@repo/database/lib/crossAppConverters"; +import { discourseNodeSchemaToLocalConcept } from "~/utils/conceptConversion"; +import { nodeSchemaToCrossApp } from "~/utils/roamToCrossAppConverters"; + +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}", +}); + +// Timestamps and space fields are transport details that differ by path. +const schemaMetadata = (concept: LocalConceptDataInput) => ({ + name: concept.name, + source_local_id: concept.source_local_id, + author_local_id: concept.author_local_id, + literal_content: concept.literal_content, + local_reference_content: concept.local_reference_content, +}); + +const viaSync = (node: DiscourseNode) => + schemaMetadata(discourseNodeSchemaToLocalConcept(CONTEXT, node)); + +const viaPublish = (node: DiscourseNode) => { + const schema = nodeSchemaToCrossApp(node); + if (!schema) throw new Error("publish produced no schema"); + return schemaMetadata(crossAppNodeSchemaToDbConcept(schema)); +}; + +describe("node schema metadata from sync and publish", () => { + it.each([ + [ + "a template and a source slot", + nodeType({ + template: [{ text: "Question:", children: [{ text: "Answer" }] }], + }), + ], + ["a source slot and no template", nodeType({})], + [ + "a template and no source slot", + nodeType({ + text: "Claim", + type: "clm", + format: "[[CLM]] - {content}", + template: [{ text: "Grounds:" }], + }), + ], + [ + "neither a template nor a source slot", + nodeType({ text: "Claim", type: "clm", format: "[[CLM]] - {content}" }), + ], + [ + "a template of only components", + nodeType({ template: [{ text: "{{x}}" }] }), + ], + ["a slash-separated name", nodeType({ text: "Evidence/Figure" })], + ])("match for a schema with %s", (_label, node) => { + expect(viaPublish(node)).toEqual(viaSync(node)); + }); + + it("both carry the creator as author_local_id", () => { + expect(viaSync(nodeType({})).author_local_id).toBe("author-1"); + expect(viaPublish(nodeType({})).author_local_id).toBe("author-1"); + }); +}); diff --git a/apps/roam/src/utils/conceptConversion.ts b/apps/roam/src/utils/conceptConversion.ts index 3792ce0d7..08a277c15 100644 --- a/apps/roam/src/utils/conceptConversion.ts +++ b/apps/roam/src/utils/conceptConversion.ts @@ -1,4 +1,3 @@ -import { InputTextNode } from "roamjs-components/types"; import getBlockProps from "./getBlockProps"; import { DiscourseNode } from "./getDiscourseNodes"; import { @@ -8,6 +7,7 @@ import { sourceIdOfNode, } from "./sourceSlot"; import extractContentFromTitle from "./extractContentFromTitle"; +import { nodeTemplateContent } from "./nodeTemplateContent"; import getDiscourseRelations from "./getDiscourseRelations"; import type { DiscourseRelation } from "./getDiscourseRelations"; import type { SupabaseContext } from "~/utils/supabaseContext"; @@ -65,33 +65,17 @@ const getNodeExtraData = ( /* eslint-enable @typescript-eslint/naming-convention */ }; -const indent = (s: string): string => - s - .split("\n") - .map((l) => " " + l) - .join("\n") + "\n"; - -const templateToText = (template: InputTextNode[]): string => - template - .filter((itn) => !itn.text.startsWith("{{")) - .map( - (itn) => - `* ${itn.text}\n${itn.children?.length ? indent(templateToText(itn.children)) : ""}`, - ) - .join(""); - export const discourseNodeSchemaToLocalConcept = ( context: SupabaseContext, node: DiscourseNode, ): LocalConceptDataInput => { - const titleParts = node.text.split("/"); - const label = titleParts[titleParts.length - 1] ?? node.text; const literalContent: Record = { - label, + label: node.text, format: node.format, }; - if (node.template !== undefined) - literalContent.template = templateToText(node.template); + const templateContent = nodeTemplateContent(node.template); + if (templateContent !== undefined) + literalContent.template_content = templateContent; const hasSourceSlot = schemaHasSourceSlot(node); if (hasSourceSlot) literalContent.roles = [SOURCE_SLOT]; return { diff --git a/apps/roam/src/utils/nodeTemplateContent.ts b/apps/roam/src/utils/nodeTemplateContent.ts new file mode 100644 index 000000000..b1782d286 --- /dev/null +++ b/apps/roam/src/utils/nodeTemplateContent.ts @@ -0,0 +1,24 @@ +import type { InputTextNode } from "roamjs-components/types"; + +const indent = (s: string): string => + s + .split("\n") + .map((l) => " " + l) + .join("\n") + "\n"; + +const templateToText = (template: InputTextNode[]): string => + template + .filter((itn) => !itn.text.startsWith("{{")) + .map( + (itn) => + `* ${itn.text}\n${itn.children?.length ? indent(templateToText(itn.children)) : ""}`, + ) + .join(""); + +// Shared by sync and publish so both write, or both omit, the same value. +export const nodeTemplateContent = ( + template: InputTextNode[] | undefined, +): string | undefined => { + const text = templateToText(template ?? []); + return text.length > 0 ? text : undefined; +}; diff --git a/apps/roam/src/utils/roamToCrossAppConverters.ts b/apps/roam/src/utils/roamToCrossAppConverters.ts index 5d9107469..8b076b40c 100644 --- a/apps/roam/src/utils/roamToCrossAppConverters.ts +++ b/apps/roam/src/utils/roamToCrossAppConverters.ts @@ -17,6 +17,7 @@ import getPageViewType from "roamjs-components/queries/getPageViewType"; import { contentTypes } from "@repo/content-model"; import getDiscourseNodes from "./getDiscourseNodes"; import extractContentFromTitle from "./extractContentFromTitle"; +import { nodeTemplateContent } from "./nodeTemplateContent"; import { SOURCE_SLOT, schemaHasSourceSlot, @@ -225,6 +226,7 @@ export const nodeSchemaToCrossApp = ( createdAt: new Date(createdTime), modifiedAt: new Date(Math.max(pageEditTime, createdTime)), format: s.format, + template: nodeTemplateContent(s.template), ...(hasSourceSlot ? { slotDefinitions: { [SOURCE_SLOT]: sourceSlotSchemaId() } } : {}), diff --git a/packages/database/src/lib/__tests__/crossAppConverters.test.ts b/packages/database/src/lib/__tests__/crossAppConverters.test.ts index c5f1b52c4..33be178f3 100644 --- a/packages/database/src/lib/__tests__/crossAppConverters.test.ts +++ b/packages/database/src/lib/__tests__/crossAppConverters.test.ts @@ -30,6 +30,7 @@ describe("crossAppNodeSchemaToDbConcept", () => { format: "[[CLM]] - {content}", }); expect(concept.literal_content).toEqual({ + label: "Some concept", format: "[[CLM]] - {content}", }); }); @@ -42,15 +43,17 @@ describe("crossAppNodeSchemaToDbConcept", () => { templateTitle: "Claim template", }); expect(concept.literal_content).toEqual({ + label: "Some concept", format: "[[CLM]] - {content}", template: "Claim template", template_content: "* Evidence\n", }); }); - it("omits literal_content when no keys are set", () => { + it("writes the label into literal_content as well as the name", () => { const concept = crossAppNodeSchemaToDbConcept(baseSchema); - expect(concept.literal_content).toBeUndefined(); + expect(concept.name).toBe("Some concept"); + expect(concept.literal_content).toEqual({ label: "Some concept" }); }); it("stores slot definitions as roles plus local reference content", () => { @@ -60,6 +63,7 @@ describe("crossAppNodeSchemaToDbConcept", () => { slotDefinitions: { evidence: "evidence-type", claim: "claim-type" }, }); expect(result.literal_content).toEqual({ + label: "Some concept", template: "Template Title", roles: ["evidence", "claim"], }); @@ -74,7 +78,7 @@ describe("crossAppNodeSchemaToDbConcept", () => { ...baseSchema, slotDefinitions: {}, }); - expect(result).not.toHaveProperty("literal_content"); + expect(result.literal_content).toEqual({ label: "Some concept" }); expect(result).not.toHaveProperty("local_reference_content"); }); }); diff --git a/packages/database/src/lib/crossAppConverters.ts b/packages/database/src/lib/crossAppConverters.ts index f6c71a822..471e0aa55 100644 --- a/packages/database/src/lib/crossAppConverters.ts +++ b/packages/database/src/lib/crossAppConverters.ts @@ -98,6 +98,7 @@ export const crossAppNodeSchemaToDbConcept = ( ): LocalConceptDataInput => { const slots = Object.keys(node.slotDefinitions ?? {}); const literalInfo = filterUndefined({ + label: node.label, template: node.templateTitle, template_content: node.template, format: node.format,