Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 30 additions & 3 deletions apps/roam/src/utils/__tests__/conceptConversion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
17 changes: 17 additions & 0 deletions apps/roam/src/utils/__tests__/roamToCrossAppConverters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
111 changes: 111 additions & 0 deletions apps/roam/src/utils/__tests__/schemaMetadataParity.test.ts
Original file line number Diff line number Diff line change
@@ -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>): 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");
});
});
26 changes: 5 additions & 21 deletions apps/roam/src/utils/conceptConversion.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import { InputTextNode } from "roamjs-components/types";
import getBlockProps from "./getBlockProps";
import { DiscourseNode } from "./getDiscourseNodes";
import {
Expand All @@ -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";
Expand Down Expand Up @@ -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<string, Json> = {
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 {
Expand Down
24 changes: 24 additions & 0 deletions apps/roam/src/utils/nodeTemplateContent.ts
Original file line number Diff line number Diff line change
@@ -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;
};
2 changes: 2 additions & 0 deletions apps/roam/src/utils/roamToCrossAppConverters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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() } }
: {}),
Expand Down
10 changes: 7 additions & 3 deletions packages/database/src/lib/__tests__/crossAppConverters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ describe("crossAppNodeSchemaToDbConcept", () => {
format: "[[CLM]] - {content}",
});
expect(concept.literal_content).toEqual({
label: "Some concept",
format: "[[CLM]] - {content}",
});
});
Expand All @@ -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", () => {
Expand All @@ -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"],
});
Expand All @@ -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");
});
});
Expand Down
1 change: 1 addition & 0 deletions packages/database/src/lib/crossAppConverters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading