Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 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
0e54037
ENG-2142 Use async source queries and existing dependency ordering
sid597 Sep 14, 2026
56a67fc
ENG-2142 Keep dependency-order test doubles lint clean
sid597 Sep 14, 2026
95eab0d
ENG-2142 Preserve sourced node formats with a placeholder reference
sid597 Sep 14, 2026
8240477
ENG-2142 Keep missing sources out of published references
sid597 Sep 16, 2026
ac606ea
ENG-2142 Use a [[@placeholder]] reference for a missing Source
sid597 Sep 18, 2026
dc553fa
Merge remote-tracking branch 'origin/main' into eng-2142-map-source-t…
sid597 Sep 18, 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
115 changes: 115 additions & 0 deletions apps/roam/src/utils/__tests__/findTargetUid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
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 },
data: { async: { fast: { q: roamQuery } } },
},
};
roamQuery.mockResolvedValue([]);
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.mockResolvedValue([[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.mockResolvedValue([[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",
);
});
});
111 changes: 111 additions & 0 deletions apps/roam/src/utils/__tests__/importSharedNodes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,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 @@ -141,6 +148,110 @@ 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("orders a chain of sources across spaces before its dependents", async () => {
const root = makeSharedNode("root");
const source = {
...makeSharedNode("source"),
slots: { sourceDocument: root.rid },
};
const evidence = {
...makeSharedNode("evidence"),
rid: "orn:obsidian.note:vault-b/evidence",
spaceUri: "obsidian:vault-b",
slots: { sourceDocument: source.rid },
};
mockedMaterializeSharedNode.mockImplementation(({ sharedNode }) =>
Promise.resolve(successResult(sharedNode, "created")),
);
await importSharedNodes({
client,
sharedNodes: [evidence, source, root],
onProgress: vi.fn(),
});
expect(
mockedMaterializeSharedNode.mock.calls.map(([args]) => args.sharedNode),
).toEqual([root, source, evidence]);
});

it("keeps absent references non-blocking and visits cycles only once", async () => {
const first = {
...makeSharedNode("first"),
slots: { sourceDocument: "second" },
};
const second = {
...makeSharedNode("second"),
slots: { sourceDocument: "first" },
};
const missing = {
...makeSharedNode("missing"),
slots: { sourceDocument: "absent" },
};
mockedMaterializeSharedNode.mockImplementation(({ sharedNode }) =>
Promise.resolve(successResult(sharedNode, "created")),
);
const items = await importSharedNodes({
client,
sharedNodes: [first, second, missing],
onProgress: vi.fn(),
});
expect(items.map(({ sharedNode }) => sharedNode.rid)).toEqual([
second.rid,
first.rid,
missing.rid,
]);
expect(mockedMaterializeSharedNode).toHaveBeenCalledTimes(3);
});

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
Loading
Loading