Skip to content

Commit fae7ad4

Browse files
trangdoan982devin-ai-integration[bot]maparent
authored
ENG-1301: Import relations when a node is imported (#847)
* wip * fix lint and format * Update apps/obsidian/src/utils/importNodes.ts Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> * use subqueries to avoid resolveRelationEndpoints * Update apps/obsidian/src/utils/importRelations.ts Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> * wip * separate and optimize query #1 * refactor query #2 * add import preview * checkpoint. have it work but need further restructuring * optimize queries with QueryEngine * add provisional field * address PR comment * format * address PR comments * address PR comments * address PR comment * delete file * lint --------- Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Marc-Antoine Parent <maparent@acm.org>
1 parent 1cb8ce1 commit fae7ad4

12 files changed

Lines changed: 1648 additions & 150 deletions

apps/obsidian/AGENTS.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,15 @@
11
You are working on the Obsidian plugin that implements the Discourse Graph protocol.
22

33
## Dependencies
4+
45
Prefer existing dependencies from package.json.
56

67
## Obsidian Style Guide
8+
79
Use the obsidian style guide from help.obsidian.md/style-guide and docs.obsidian.md/Developer+policies.
810

911
### Icons
12+
1013
Platform-native UI.
1114
Lucide and custom Obsidian icons can be used alongside detailed elements to provide a visual representation of a feature.
1215

@@ -25,3 +28,7 @@ Adjusting size and stroke in an SVG.
2528
Utilize the icon anchor in embedded images, to tweak the spacing around the icon so that it aligns neatly with the text in the vicinity.
2629
Icons should be surrounded by parenthesis. ( lucide-cog.svg > icon )
2730
Example: ( ![[lucide-cog.svg#icon]] )
31+
32+
### Function guides
33+
34+
- Any function that deals with querying vault's frontmatter, default to using Datacore API first, then write fallback where you use `plugin.app.vault.getMarkdownFiles()` to iterate through each file's frontmatter

apps/obsidian/src/components/ImportNodesModal.tsx

Lines changed: 181 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,30 @@ import {
1212
importSelectedNodes,
1313
} from "~/utils/importNodes";
1414
import { getLoggedInClient, getSupabaseContext } from "~/utils/supabaseContext";
15+
import {
16+
computeImportPreview,
17+
type ImportPreviewData,
18+
} from "~/utils/importPreview";
1519

1620
type ImportNodesModalProps = {
1721
plugin: DiscourseGraphPlugin;
1822
onClose: () => void;
1923
};
2024

2125
const ImportNodesContent = ({ plugin, onClose }: ImportNodesModalProps) => {
22-
const [step, setStep] = useState<"loading" | "select" | "importing">(
23-
"loading",
24-
);
26+
const [step, setStep] = useState<
27+
"loading" | "select" | "preview" | "importing"
28+
>("loading");
2529
const [groupsWithNodes, setGroupsWithNodes] = useState<GroupWithNodes[]>([]);
2630
const [isLoading, setIsLoading] = useState(true);
2731
const [importProgress, setImportProgress] = useState({
2832
current: 0,
2933
total: 0,
3034
});
35+
const [previewData, setPreviewData] = useState<ImportPreviewData | null>(
36+
null,
37+
);
38+
const [previewLoading, setPreviewLoading] = useState(false);
3139

3240
const loadImportableNodes = useCallback(async () => {
3341
setIsLoading(true);
@@ -146,16 +154,42 @@ const ImportNodesContent = ({ plugin, onClose }: ImportNodesModalProps) => {
146154
);
147155
};
148156

149-
const handleImport = async () => {
150-
const selectedNodes: ImportableNode[] = [];
157+
const getSelectedNodes = (): ImportableNode[] => {
158+
const selected: ImportableNode[] = [];
151159
for (const group of groupsWithNodes) {
152160
for (const node of group.nodes) {
153161
if (node.selected) {
154-
selectedNodes.push(node);
162+
selected.push(node);
155163
}
156164
}
157165
}
166+
return selected;
167+
};
168+
169+
const handleNext = async () => {
170+
const selectedNodes = getSelectedNodes();
171+
if (selectedNodes.length === 0) {
172+
new Notice("Please select at least one node to import");
173+
return;
174+
}
175+
176+
setPreviewLoading(true);
177+
try {
178+
const preview = await computeImportPreview({ plugin, selectedNodes });
179+
setPreviewData(preview);
180+
setStep("preview");
181+
} catch (error) {
182+
console.error("Error computing preview:", error);
183+
const errorMessage =
184+
error instanceof Error ? error.message : String(error);
185+
new Notice(`Failed to compute preview: ${errorMessage}`, 5000);
186+
} finally {
187+
setPreviewLoading(false);
188+
}
189+
};
158190

191+
const handleImport = async () => {
192+
const selectedNodes = getSelectedNodes();
159193
if (selectedNodes.length === 0) {
160194
new Notice("Please select at least one node to import");
161195
return;
@@ -171,6 +205,14 @@ const ImportNodesContent = ({ plugin, onClose }: ImportNodesModalProps) => {
171205
onProgress: (current, total) => {
172206
setImportProgress({ current, total });
173207
},
208+
precomputedData: previewData
209+
? {
210+
nodeKeys: previewData.nodeKeys,
211+
keyToRid: previewData.keyToRid,
212+
keyToRelationEndpointId: previewData.keyToRelationEndpointId,
213+
relationInstancesBySpace: previewData.relationInstancesBySpace,
214+
}
215+
: undefined,
174216
});
175217

176218
if (result.failed > 0) {
@@ -321,14 +363,143 @@ const ImportNodesContent = ({ plugin, onClose }: ImportNodesModalProps) => {
321363
<button onClick={onClose} className="px-4 py-2">
322364
Cancel
323365
</button>
366+
<button
367+
onClick={() => {
368+
void handleNext();
369+
}}
370+
className="!bg-accent !text-on-accent rounded px-4 py-2"
371+
disabled={selectedCount === 0 || previewLoading}
372+
>
373+
{previewLoading ? "Loading..." : `Next (${selectedCount})`}
374+
</button>
375+
</div>
376+
</div>
377+
);
378+
};
379+
380+
const renderPreviewStep = () => {
381+
if (!previewData) return null;
382+
383+
const hasNewNodeTypes = previewData.newNodeTypeSchemas.length > 0;
384+
const hasNewRelationTypes = previewData.newRelationTypeSchemas.length > 0;
385+
const newTriplets = previewData.relationTriplets.filter(
386+
(t) => t.isNewTriplet,
387+
);
388+
const hasNewTriplets = newTriplets.length > 0;
389+
const hasAnyNew = hasNewNodeTypes || hasNewRelationTypes || hasNewTriplets;
390+
391+
return (
392+
<div>
393+
<h3 className="mb-2">Import Preview</h3>
394+
<p className="text-muted mb-4 text-sm">
395+
Review what will be imported and created.
396+
</p>
397+
398+
<div className="max-h-96 overflow-y-auto">
399+
{/* Summary section */}
400+
<div className="mb-4 rounded border p-3">
401+
<div className="mb-1 text-sm font-medium uppercase tracking-wide opacity-60">
402+
Summary
403+
</div>
404+
<div className="flex gap-6 text-sm">
405+
<div>
406+
<span className="font-semibold">
407+
{previewData.selectedNodeCount}
408+
</span>{" "}
409+
node{previewData.selectedNodeCount !== 1 ? "s" : ""}
410+
</div>
411+
<div>
412+
<span className="font-semibold">
413+
{previewData.relationInstanceCount}
414+
</span>{" "}
415+
relation{previewData.relationInstanceCount !== 1 ? "s" : ""}
416+
</div>
417+
</div>
418+
</div>
419+
420+
{/* New schemas section */}
421+
{hasAnyNew && (
422+
<div className="mb-4 rounded border p-3">
423+
<div className="mb-2 text-sm font-medium uppercase tracking-wide opacity-60">
424+
New schemas to create
425+
</div>
426+
427+
{hasNewNodeTypes && (
428+
<div className="mb-2">
429+
<div className="mb-1 text-sm font-medium">Node types</div>
430+
<div className="flex flex-wrap gap-1">
431+
{previewData.newNodeTypeSchemas.map((nt) => (
432+
<span
433+
key={nt.id}
434+
className="bg-accent/15 text-accent rounded px-2 py-0.5 text-xs"
435+
>
436+
{nt.name}
437+
</span>
438+
))}
439+
</div>
440+
</div>
441+
)}
442+
443+
{hasNewRelationTypes && (
444+
<div className="mb-2">
445+
<div className="mb-1 text-sm font-medium">Relation types</div>
446+
<div className="flex flex-wrap gap-1">
447+
{previewData.newRelationTypeSchemas.map((rt) => (
448+
<span
449+
key={rt.id}
450+
className="bg-accent/15 text-accent rounded px-2 py-0.5 text-xs"
451+
>
452+
{rt.label}
453+
{rt.complement ? ` / ${rt.complement}` : ""}
454+
</span>
455+
))}
456+
</div>
457+
</div>
458+
)}
459+
460+
{hasNewTriplets && (
461+
<div>
462+
<div className="mb-1 text-sm font-medium">
463+
Discourse relations
464+
</div>
465+
<div className="space-y-1">
466+
{newTriplets.map((t, i) => (
467+
<div key={i} className="flex items-center gap-1 text-xs">
468+
<span className="rounded bg-secondary px-1.5 py-0.5">
469+
{t.sourceNodeTypeName}
470+
</span>
471+
<span className="text-accent font-medium">
472+
{t.relationTypeLabel}
473+
</span>
474+
<span className="rounded bg-secondary px-1.5 py-0.5">
475+
{t.destNodeTypeName}
476+
</span>
477+
</div>
478+
))}
479+
</div>
480+
</div>
481+
)}
482+
</div>
483+
)}
484+
485+
{!hasAnyNew && (
486+
<div className="text-muted rounded border p-3 text-center text-sm">
487+
No new schemas or relations will be created.
488+
</div>
489+
)}
490+
</div>
491+
492+
<div className="mt-6 flex justify-between">
493+
<button onClick={() => setStep("select")} className="px-4 py-2">
494+
Back
495+
</button>
324496
<button
325497
onClick={() => {
326498
void handleImport();
327499
}}
328500
className="!bg-accent !text-on-accent rounded px-4 py-2"
329-
disabled={selectedCount === 0}
330501
>
331-
Import ({selectedCount})
502+
Confirm Import
332503
</button>
333504
</div>
334505
</div>
@@ -361,6 +532,8 @@ const ImportNodesContent = ({ plugin, onClose }: ImportNodesModalProps) => {
361532
switch (step) {
362533
case "select":
363534
return renderSelectStep();
535+
case "preview":
536+
return renderPreviewStep();
364537
case "importing":
365538
return renderImportingStep();
366539
default:

0 commit comments

Comments
 (0)