Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
d3e18ad
ENG-2157 Add decorateTitle helper for rebuilding titles from core_title
sid597 Aug 23, 2026
de2811d
ENG-2157 Decorate imported node titles in Obsidian from core_title
sid597 Aug 23, 2026
907869b
Keep the incoming title when the format has placeholders core_title c…
sid597 Aug 23, 2026
b44e862
ENG-2156 Decorate imported node titles in Roam from core_title
sid597 Aug 23, 2026
7df22d0
Refresh the legacy node type cache after creating a type from an import
sid597 Aug 23, 2026
fad4f74
ENG-2156 Treat Page, Block and Any as reserved node type names on import
sid597 Aug 31, 2026
555d8b5
Merge branch 'main' into eng-2156-decorate-imported-node-titles-in-ro…
sid597 Aug 31, 2026
43b9d4a
ENG-2156 Prefer the top-level schema format through a shared dual-rea…
sid597 Aug 31, 2026
03f9052
ENG-2156 Correct the resolveSchemaFormat comment on who writes the to…
sid597 Aug 31, 2026
f5fad36
ENG-2156 Note that Roam now writes the top-level schema format
sid597 Sep 2, 2026
e167222
Merge remote-tracking branch 'origin/main' into eng-2156-decorate-imp…
sid597 Sep 2, 2026
08e051e
ENG-2142 Give cross-space slot RIDs the platform subtype
sid597 Sep 2, 2026
31854b8
ENG-2142 Name the resolved source in imported Roam titles
sid597 Sep 2, 2026
07b61b2
ENG-2142 Show source warnings with warning intent and cover the remai…
sid597 Sep 2, 2026
6c32073
Merge remote-tracking branch 'origin/main' into eng-2156-decorate-imp…
sid597 Sep 4, 2026
e083e2c
Merge remote-tracking branch 'origin/main' into eng-2156-decorate-imp…
sid597 Sep 4, 2026
ebbb69f
Merge remote-tracking branch 'origin/eng-2156-decorate-imported-node-…
sid597 Sep 6, 2026
a83cdaf
Merge remote-tracking branch 'origin/main' into codex/workday-2026090…
sid597 Sep 6, 2026
d73e869
ENG-2142 Reuse relation schemas with multiple query patterns
sid597 Sep 6, 2026
a50f5c0
Return promises explicitly in relation import test mocks
sid597 Sep 6, 2026
80acda8
Bind shared source identifiers in ENG-2142 Roam queries
sid597 Sep 6, 2026
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
14 changes: 10 additions & 4 deletions apps/roam/src/components/DiscoverSharedNodesDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,17 +118,23 @@ const ImportResultsSummary = ({
(item) => item.status === "skipped",
).length;
const failedImports = results.filter(isFailedSharedNodeImport);
const warnings = results.flatMap((item) =>
item.status !== "failed" && item.warning
? [{ sharedNode: item.sharedNode, message: item.warning }]
: [],
);
const importNotices = [...failedImports, ...warnings];

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.

Warnings are listed with the failures in the same per-node form and counted in the title, so a yellow callout never shows bullets its title does not account for. The warning text says the title was kept, which is what distinguishes it from a failure.

return (
<Callout
intent={failedImports.length > 0 ? Intent.WARNING : Intent.SUCCESS}
title={`${importedCount} imported, ${skippedCount} skipped, ${failedImports.length} failed`}
intent={importNotices.length > 0 ? Intent.WARNING : Intent.SUCCESS}
title={`${importedCount} imported, ${skippedCount} skipped, ${failedImports.length} failed${warnings.length > 0 ? `, ${warnings.length} with warnings` : ""}`}
>
{skippedCount > 0 && (
<div>Skipped nodes were already up to date in this graph.</div>
)}
{failedImports.length > 0 && (
{importNotices.length > 0 && (
<ul className="mb-0 mt-2 list-disc pl-5">
{failedImports.map((item) => (
{importNotices.map((item) => (
<li key={item.sharedNode.rid}>
<span className="font-medium">{item.sharedNode.title}</span>:{" "}
{item.message}
Expand Down
6 changes: 4 additions & 2 deletions apps/roam/src/components/RefreshImportedNodeTitleButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ const RefreshImportedNodeTitleButton = ({
id: failed
? "refresh-imported-node-failed"
: "refresh-imported-node-success",
intent: failed ? "danger" : "success",
content: result.message,
intent: failed ? "danger" : result.warning ? "warning" : "success",
content: result.warning
? `${result.message} ${result.warning}`
: result.message,
});
} finally {
setRefreshing(false);
Expand Down
112 changes: 112 additions & 0 deletions apps/roam/src/utils/__tests__/findTargetUid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { findTargetUid, sharedReferenceRid } from "~/utils/findTargetUid";
import { findImportedNodeUidBySourceRid } from "~/utils/importedSourceIdentity";

vi.mock("~/utils/importedSourceIdentity", () => ({
findImportedNodeUidBySourceRid: vi.fn(),
}));

const mockedFindImportedNodeUidBySourceRid = vi.mocked(
findImportedNodeUidBySourceRid,
);

const LOCAL_GRAPH = "local-graph";
const LOCAL_SPACE_URI = `https://roamresearch.com/#/app/${LOCAL_GRAPH}`;
const OBSIDIAN_SPACE_URI = "obsidian:vault-a";
const roamQuery = vi.fn();

beforeEach(() => {
vi.clearAllMocks();
(globalThis as { window: unknown }).window = {
roamAlphaAPI: { graph: { name: LOCAL_GRAPH }, q: roamQuery },
};
roamQuery.mockReturnValue([]);
mockedFindImportedNodeUidBySourceRid.mockResolvedValue(null);
});

describe("sharedReferenceRid", () => {
it("passes a RID through", () => {
expect(
sharedReferenceRid(
"orn:obsidian.note:vault-b/node-6",
OBSIDIAN_SPACE_URI,
),
).toBe("orn:obsidian.note:vault-b/node-6");
});

it("builds a note RID from a local id in an Obsidian space", () => {
expect(sharedReferenceRid("node-9", OBSIDIAN_SPACE_URI)).toBe(
"orn:obsidian.note:vault-a/node-9",
);
});

it("builds a URL RID from a local id in a Roam space", () => {
expect(
sharedReferenceRid(
"page-uid",
"https://roamresearch.com/#/app/other-graph",
),
).toBe("https://roamresearch.com/#/app/other-graph/page-uid");
});
});

describe("findTargetUid", () => {
it("returns the local id of a RID in this graph when the page exists", async () => {
roamQuery.mockReturnValue([[1]]);

await expect(
findTargetUid(`${LOCAL_SPACE_URI}/page-uid`, OBSIDIAN_SPACE_URI),
).resolves.toBe("page-uid");
expect(roamQuery).toHaveBeenCalledWith(
"[:find (?e) :in $ ?uid :where [?e :block/uid ?uid]]",
"page-uid",
);
expect(mockedFindImportedNodeUidBySourceRid).not.toHaveBeenCalled();
});

it("passes a shared id containing query syntax as data", async () => {
const sharedId = 'page-uid"] [(= ?e ?e)] ; "';
await expect(findTargetUid(sharedId, LOCAL_SPACE_URI)).resolves.toBeNull();
expect(roamQuery).toHaveBeenCalledWith(
"[:find (?e) :in $ ?uid :where [?e :block/uid ?uid]]",
sharedId,
);
});

it("returns null for a RID in this graph whose page is missing", async () => {
await expect(
findTargetUid(`${LOCAL_SPACE_URI}/page-uid`, OBSIDIAN_SPACE_URI),
).resolves.toBeNull();
expect(mockedFindImportedNodeUidBySourceRid).not.toHaveBeenCalled();
});

it("treats a local id of this graph's space as a page uid", async () => {
roamQuery.mockReturnValue([[1]]);

await expect(findTargetUid("page-uid", LOCAL_SPACE_URI)).resolves.toBe(
"page-uid",
);
expect(mockedFindImportedNodeUidBySourceRid).not.toHaveBeenCalled();
});

it("looks up a local id of the publisher's space as an imported note", async () => {
mockedFindImportedNodeUidBySourceRid.mockResolvedValue("imported-uid");

await expect(findTargetUid("node-9", OBSIDIAN_SPACE_URI)).resolves.toBe(
"imported-uid",
);
expect(mockedFindImportedNodeUidBySourceRid).toHaveBeenCalledWith(
"orn:obsidian.note:vault-a/node-9",
);
expect(roamQuery).not.toHaveBeenCalled();
});

it("looks up a RID of another space as imported", async () => {
await expect(
findTargetUid("orn:obsidian.note:vault-b/node-6", OBSIDIAN_SPACE_URI),
).resolves.toBeNull();
expect(mockedFindImportedNodeUidBySourceRid).toHaveBeenCalledWith(
"orn:obsidian.note:vault-b/node-6",
);
});
});
57 changes: 57 additions & 0 deletions apps/roam/src/utils/__tests__/importSharedNodes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ vi.mock("~/utils/resolveSharedNodeTypes", () => ({
resolveSharedNodeTypes: vi.fn(),
}));

// Runs before the imports above: getDiscourseNodes calls generateUID at module load.
vi.hoisted(() => {
(globalThis as { window?: unknown }).window = {
roamAlphaAPI: { util: { generateUID: () => "someUid" } },
};
});

const mockedMaterializeSharedNode = vi.mocked(materializeSharedNode);
const mockedResolveSharedNodeTypes = vi.mocked(resolveSharedNodeTypes);

Expand Down Expand Up @@ -139,6 +146,56 @@ describe("importSharedNodes", () => {
});
});

it("materializes a node before the nodes that name it as their source", async () => {
const evidence = {
...makeSharedNode("node-1"),
slots: { sourceDocument: "node-2" },
};
const source = makeSharedNode("node-2");
const other = makeSharedNode("node-3");
mockedMaterializeSharedNode
.mockResolvedValueOnce(successResult(source, "created"))
.mockResolvedValueOnce(successResult(evidence, "created"))
.mockResolvedValueOnce(successResult(other, "created"));

const items = await importSharedNodes({
client,
sharedNodes: [evidence, source, other],
onProgress: vi.fn(),
});

expect(
mockedMaterializeSharedNode.mock.calls.map(([args]) => args.sharedNode),
).toEqual([source, evidence, other]);
expect(items.map((item) => item.sharedNode)).toEqual([
source,
evidence,
other,
]);
});

it("reports the materializer's warning on the imported node", async () => {
const sharedNodes = [makeSharedNode("node-1")];
mockedMaterializeSharedNode.mockResolvedValueOnce({
...successResult(sharedNodes[0], "created"),
warning: "No source was published with this node.",
});

const items = await importSharedNodes({
client,
sharedNodes,
onProgress: vi.fn(),
});

expect(items).toEqual([
{
sharedNode: sharedNodes[0],
status: "imported",
warning: "No source was published with this node.",
},
]);
});

it("keeps importing the remaining nodes when a materialization throws", async () => {
const sharedNodes = ["node-1", "node-2"].map(makeSharedNode);
mockedMaterializeSharedNode
Expand Down
98 changes: 98 additions & 0 deletions apps/roam/src/utils/__tests__/importSharedRelations.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { DGSupabaseClient } from "@repo/database/lib/client";
import type { DiscourseRelation } from "~/utils/getDiscourseRelations";
import { importSharedRelations } from "~/utils/importSharedRelations";
import getDiscourseRelations from "~/utils/getDiscourseRelations";
import { createRelationSchema } from "~/utils/createRelationSchema";

vi.hoisted(() => {
vi.stubGlobal("window", { roamAlphaAPI: { graph: { name: "local" } } });
});
vi.mock("~/utils/getDiscourseRelations", () => ({ default: vi.fn() }));
vi.mock("~/utils/getDiscourseNodes", () => ({
default: () => [{ type: "local-claim", text: "Claim" }],
}));
vi.mock("~/utils/importedSourceIdentity", () => ({
getImportedSourceRids: () => Promise.resolve(new Set<string>()),
findImportedNodeUidBySourceRid: vi.fn(),
writeImportedSourceIdentity: vi.fn(),
}));
vi.mock("~/components/settings/utils/accessors", () => ({
createDiscourseNodeType: vi.fn(),
}));
vi.mock("~/utils/createRelationSchema", () => ({
createRelationSchema: vi.fn(),
}));
vi.mock("~/utils/createReifiedBlock", () => ({
getReifiedRelations: () => Promise.resolve([]),
createReifiedRelation: vi.fn(),
}));
vi.mock("roamjs-components/writes", () => ({ deleteBlock: vi.fn() }));
vi.mock("~/utils/discoverSharedRelations", () => ({
discoverSharedRelations: () =>
Promise.resolve({
relations: [],
relTypeSchemas: [],
nodeSchemas: [
{
localId: "claim",
rid: "orn:obsidian.schema:remote/claim",
label: "Claim",
authorId: "author",
createdAt: new Date("2026-09-07"),
},
],
relTripleSchemas: [
{
localId: "supports",
rid: "orn:obsidian.schema:remote/supports",
label: "Supports",
complement: "Supported by",
sourceType: "claim",
destinationType: "claim",
authorId: "author",
createdAt: new Date("2026-09-07"),
},
],
}),
}));

const relation = (id: string): DiscourseRelation => ({
id,
label: "Supports",
complement: "Supported by",
source: "local-claim",
destination: "local-claim",
triples: [],
});
const client = {} as DGSupabaseClient;

beforeEach(() => vi.clearAllMocks());

describe("importSharedRelations schema matching", () => {
it("reuses one schema when its query patterns produce multiple matches", async () => {
vi.mocked(getDiscourseRelations).mockReturnValue([
{
...relation("local-supports"),
triples: [["source", "references", "destination"]],
},
{
...relation("local-supports"),
triples: [["source", "is in page", "destination"]],
},
]);
await expect(importSharedRelations(client, 7)).resolves.toBeUndefined();
expect(createRelationSchema).not.toHaveBeenCalled();
});

it("rejects matches to two different schemas", async () => {
vi.mocked(getDiscourseRelations).mockReturnValue([
relation("supports-one"),
relation("supports-two"),
]);
await expect(importSharedRelations(client, 7)).rejects.toThrow(
"multiple matches",
);
expect(createRelationSchema).not.toHaveBeenCalled();
});
});
Loading