Skip to content
Open
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
38 changes: 38 additions & 0 deletions apps/roam/src/utils/__tests__/conceptConversion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SupabaseContext } from "~/utils/supabaseContext";

vi.mock("~/utils/getBlockProps", () => ({ default: () => ({}) }));
vi.mock("~/utils/getDiscourseNodes", () => ({ default: () => [] }));
vi.mock("~/utils/getDiscourseRelations", () => ({ default: () => [] }));
vi.mock("~/utils/createReifiedBlock", () => ({
DISCOURSE_GRAPH_PROP_NAME: "discourse-graph",
}));
vi.mock("roamjs-components/queries/getPageTitleByPageUid", () => ({
default: () => "",
}));

import { discourseNodeBlockToLocalConcept } from "~/utils/conceptConversion";

const context = { spaceId: 42 } as SupabaseContext;

describe("discourseNodeBlockToLocalConcept", () => {
beforeEach(() => {
(globalThis as { window: unknown }).window = {
roamAlphaAPI: {
q: () => [["author-1", "page-1", 1000, 2000]],
},
};
});

it("writes the core title into literal_content", () => {
const concept = discourseNodeBlockToLocalConcept(context, {
nodeUid: "node-1",
schemaUid: "schema-1",
text: "CLM - my claim",
coreTitle: "my claim",
});
expect(concept.literal_content).toEqual({ core_title: "my claim" });
expect(concept.name).toBe("CLM - my claim");
expect(concept.source_local_id).toBe("node-1");
});
});
76 changes: 76 additions & 0 deletions apps/roam/src/utils/__tests__/extractContentFromTitle.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import extractContentFromTitle from "~/utils/extractContentFromTitle";

describe("extractContentFromTitle", () => {
it("extracts the content from a title matching the format", () => {
expect(
extractContentFromTitle("[[CLM]] - my claim", {
format: "[[CLM]] - {content}",
}),
).toBe("my claim");
});

it("returns the title when the type has no format", () => {
expect(extractContentFromTitle("my claim", { format: "" })).toBe(
"my claim",
);
});

it("returns the title when it does not match the format", () => {
expect(
extractContentFromTitle("random page", {
format: "[[CLM]] - {content}",
}),
).toBe("random page");
});

it("extracts the content from a format with a {Source} placeholder", () => {
expect(
extractContentFromTitle("[[EVD]] - finding - @smith2020", {
format: "[[EVD]] - {content} - {Source}",
}),
).toBe("finding");
});

it("preserves an empty content capture instead of falling back to the title", () => {
expect(
extractContentFromTitle("[[EVD]] - - @smith2020", {
format: "[[EVD]] - {content} - {Source}",
}),
).toBe("");
});

it('keeps a trailing content containing " - " whole', () => {
expect(
extractContentFromTitle("[[CLM]] - a - b", {
format: "[[CLM]] - {content}",
}),
).toBe("a - b");
});

it('extracts the shortest match when the content contains " - " before another placeholder (accepted for v0)', () => {
expect(
extractContentFromTitle("[[EVD]] - a - b - @smith2020", {
format: "[[EVD]] - {content} - {Source}",
}),
).toBe("a");
});

it("round trips a title built from the core title", () => {
const coreTitle = "sleep improves memory";
const simpleFormat = "[[CLM]] - {content}";
expect(
extractContentFromTitle(simpleFormat.replace("{content}", coreTitle), {
format: simpleFormat,
}),
).toBe(coreTitle);

const sourceFormat = "[[EVD]] - {content} - {Source}";
const title = sourceFormat
.replace("{content}", coreTitle)
.replace("{Source}", "@smith2020");
expect(extractContentFromTitle(title, { format: sourceFormat })).toBe(
coreTitle,
);
});
});
12 changes: 11 additions & 1 deletion apps/roam/src/utils/__tests__/publishNodesToGroups.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,15 @@ const claimSchema: DiscourseNode = {
const makeCrossAppNode = ({
uid,
title,
coreTitle = title,
}: {
uid: string;
title: string;
coreTitle?: string;
}): CrossAppNode => ({
localId: uid,
nodeType: SCHEMA_UID,
coreTitle,
authorId: "user-1",
createdAt: new Date("2026-01-02T00:00:00.000Z"),
modifiedAt: new Date("2026-01-03T00:00:00.000Z"),
Expand Down Expand Up @@ -160,7 +163,13 @@ describe("publishNodesToGroups", () => {
client,
spaceId: SPACE_ID,
groupIds: [GROUP_ID],
nodes: [makeCrossAppNode({ uid: "node-1", title: "CLM - new claim" })],
nodes: [
makeCrossAppNode({
uid: "node-1",
title: "CLM - new claim",
coreTitle: "new claim",
}),
],
});

expect(rpcCalls).toHaveLength(1);
Expand All @@ -177,6 +186,7 @@ describe("publishNodesToGroups", () => {
source_local_id: "node-1",
name: "CLM - new claim",
schema_represented_by_local_id: SCHEMA_UID,
literal_content: { core_title: "new claim" },
});
expect(data[1].contents_inline).toEqual([
expect.objectContaining({
Expand Down
82 changes: 80 additions & 2 deletions apps/roam/src/utils/__tests__/roamToCrossAppConverters.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Json } from "@repo/database/dbTypes";
import type { DiscourseNode } from "~/utils/getDiscourseNodes";

const mocks = vi.hoisted(() => ({
getDiscourseNodes: vi.fn(),
}));

vi.mock("roamjs-components/queries/getFullTreeByParentUid", () => ({
default: () => ({ children: [] }),
Expand All @@ -8,8 +13,29 @@ vi.mock("roamjs-components/queries/getPageViewType", () => ({
default: () => "bullet",
}));
vi.mock("~/utils/pageToMarkdown", () => ({ toMarkdown: () => "" }));
vi.mock("~/utils/getDiscourseNodes", () => ({
default: mocks.getDiscourseNodes,
}));

import {
fullContentNodeToCrossApp,
getFormatByNodeTypeUid,
nodeUidsWithTypeToCrossApp,
} from "~/utils/roamToCrossAppConverters";

import { nodeUidsWithTypeToCrossApp } from "~/utils/roamToCrossAppConverters";
const claimSchema: DiscourseNode = {
type: "schema-1",
text: "Claim",
shortcut: "C",
specification: [],
backedBy: "user",
canvasSettings: {},
format: "CLM - {content}",
};

beforeEach(() => {
mocks.getDiscourseNodes.mockReturnValue([claimSchema]);
});

const USER_ROW = { ":db/id": 5, ":user/uid": "user-1" };

Expand Down Expand Up @@ -60,3 +86,55 @@ describe("nodeUidsWithTypeToCrossApp timestamps", () => {
expect(node.modifiedAt).toEqual(new Date(1000));
});
});

describe("nodeUidsWithTypeToCrossApp coreTitle", () => {
it("extracts the content from a title matching the node type's format", async () => {
const node = await convertRow(baseRow);
expect(node.coreTitle).toBe("claim");
});

it("keeps the whole title when the node type is unknown", async () => {
mocks.getDiscourseNodes.mockReturnValue([]);
const node = await convertRow(baseRow);
expect(node.coreTitle).toBe("CLM - claim");
});
});

describe("fullContentNodeToCrossApp coreTitle", () => {
const baseNode = {
author_local_id: "user-1",
source_local_id: "node-1",
created: 1000,
last_modified: 2000,
node_type_id: "schema-1",
text: "CLM - claim",
};

it("extracts the content from the title", () => {
const node = fullContentNodeToCrossApp(baseNode, getFormatByNodeTypeUid());
expect(node.coreTitle).toBe("claim");
});

it("extracts from the page title when node_title is present", () => {
const node = fullContentNodeToCrossApp(
{
...baseNode,
text: "some block text",
node_title: "CLM - claim",
},
getFormatByNodeTypeUid(),
);
expect(node.coreTitle).toBe("claim");
});

it("keeps the whole title when it does not match the format", () => {
const node = fullContentNodeToCrossApp(
{
...baseNode,
text: "unrelated title",
},
getFormatByNodeTypeUid(),
);
expect(node.coreTitle).toBe("unrelated title");
});
});
5 changes: 5 additions & 0 deletions apps/roam/src/utils/conceptConversion.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,10 +104,12 @@ export const discourseNodeBlockToLocalConcept = (
nodeUid,
schemaUid,
text,
coreTitle,
}: {
nodeUid: string;
schemaUid: string;
text: string;
coreTitle: string;
},
): LocalConceptDataInput => {
return {
Expand All @@ -116,6 +118,9 @@ export const discourseNodeBlockToLocalConcept = (
source_local_id: nodeUid,
schema_represented_by_local_id: schemaUid,
is_schema: false,
literal_content: {
core_title: coreTitle,
},
/* eslint-enable @typescript-eslint/naming-convention */
...getNodeExtraData(nodeUid),
};
Expand Down
13 changes: 9 additions & 4 deletions apps/roam/src/utils/convertRoamNodeToFullContent.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { crossAppNodeToDbContent } from "@repo/database/lib/crossAppConverters";
import { fullContentNodeToCrossApp } from "./roamToCrossAppConverters";
import {
fullContentNodeToCrossApp,
getFormatByNodeTypeUid,
} from "./roamToCrossAppConverters";
import type { LocalContentDataInput } from "@repo/database/inputTypes";

export type RoamFullContentNode = {
Expand All @@ -16,10 +19,11 @@ export const convertRoamNodeToFullContent = ({
nodes,
}: {
nodes: RoamFullContentNode[];
}): LocalContentDataInput[] =>
nodes.flatMap((node) => {
}): LocalContentDataInput[] => {
const formatByNodeTypeUid = getFormatByNodeTypeUid();
return nodes.flatMap((node) => {
try {
const crossAppNode = fullContentNodeToCrossApp(node);
const crossAppNode = fullContentNodeToCrossApp(node, formatByNodeTypeUid);
const fullContent = crossAppNodeToDbContent(crossAppNode, "full");
return fullContent === undefined ? [] : [fullContent];
} catch (error) {
Expand All @@ -30,3 +34,4 @@ export const convertRoamNodeToFullContent = ({
return [];
}
});
};
7 changes: 3 additions & 4 deletions apps/roam/src/utils/extractContentFromTitle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,9 @@ const extractContentFromTitle = (
const contentIndex = placeholders.findIndex(
(name) => name.toLowerCase() === "content",
);
if (contentIndex >= 0) {
return expressionMatch[contentIndex + 1]?.trim() || title;
}
return expressionMatch[1]?.trim() || title;
const capture =
contentIndex >= 0 ? expressionMatch[contentIndex + 1] : expressionMatch[1];
return capture === undefined ? title : capture.trim();
};

export default extractContentFromTitle;
18 changes: 18 additions & 0 deletions apps/roam/src/utils/roamToCrossAppConverters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,20 @@ 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 extractContentFromTitle from "./extractContentFromTitle";

export const getFormatByNodeTypeUid = (): Map<string, string> =>
new Map(getDiscourseNodes().map((node) => [node.type, node.format]));

const getCoreTitle = (
title: string,
nodeTypeUid: string,
formatByNodeTypeUid: Map<string, string>,
): string =>
extractContentFromTitle(title, {
format: formatByNodeTypeUid.get(nodeTypeUid) ?? "",
});

const FULL_MARKDOWN_OPTS = {
refs: true,
Expand Down Expand Up @@ -64,6 +78,7 @@ const buildFullInlineContent = ({

export const fullContentNodeToCrossApp = (
node: RoamFullContentNode,
formatByNodeTypeUid: Map<string, string>,
): CrossAppNode => {
const title = node.node_title ?? node.text;

Expand All @@ -73,6 +88,7 @@ export const fullContentNodeToCrossApp = (
createdAt: new Date(node.created || Date.now()),
modifiedAt: new Date(node.last_modified || Date.now()),
nodeType: node.node_type_id,
coreTitle: getCoreTitle(title, node.node_type_id, formatByNodeTypeUid),

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing reads this coreTitle yet: this producer feeds upsert_content, not upsert_concepts. It is set so any CrossAppNode that reaches concept conversion carries it (the ticket lists all three producers).

content: {
direct: {
localId: node.source_local_id,
Expand Down Expand Up @@ -106,6 +122,7 @@ export const nodeUidsWithTypeToCrossApp = async (
const userUidByEid = Object.fromEntries(
userRows.map((r) => [r[":db/id"] as number, r[":user/uid"] as string]),
);
const formatByNodeTypeUid = getFormatByNodeTypeUid();
const results = nodeRows.map((row) => {
const uid = row[":block/uid"] as string;
const title = row[":node/title"] as string;
Expand All @@ -122,6 +139,7 @@ export const nodeUidsWithTypeToCrossApp = async (
authorId: userUid,
createdAt: new Date(createdTime),
modifiedAt: new Date(Math.max(editTime, pageEditTime)),
coreTitle: getCoreTitle(title, typesByUid[uid], formatByNodeTypeUid),
content: {
direct: {
localId: uid,
Expand Down
7 changes: 7 additions & 0 deletions apps/roam/src/utils/syncDgNodesToSupabase.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
nodeTypeSince,
} from "./getAllDiscourseNodesSince";
import getDiscourseNodeFormatExpression from "./getDiscourseNodeFormatExpression";
import extractContentFromTitle from "./extractContentFromTitle";
import { cleanupOrphanedNodes } from "./cleanupOrphanedNodes";
import {
getLoggedInClient,
Expand Down Expand Up @@ -667,11 +668,17 @@ export const convertDgToSupabaseConcepts = async ({
return discourseNodeSchemaToLocalConcept(context, node);
});

const formatByNodeTypeUid = new Map(
allNodeTypes.map((nodeType) => [nodeType.type, nodeType.format]),
);
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,
coreTitle: extractContentFromTitle(node.node_title ?? node.text, {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Inlined instead of reusing the publish-side helper because allNodeTypes is already in scope; the helper would refetch via getDiscourseNodes(). node_title ?? node.text matters for block-backed types: node_title is the format-matched page title, text is the block string.

format: formatByNodeTypeUid.get(node.type) ?? "",
}),
});
return localConcept;
});
Expand Down
Loading