diff --git a/apps/obsidian/src/components/ImportSchemaPreviewSummary.tsx b/apps/obsidian/src/components/ImportSchemaPreviewSummary.tsx
new file mode 100644
index 000000000..5ba1f0e5f
--- /dev/null
+++ b/apps/obsidian/src/components/ImportSchemaPreviewSummary.tsx
@@ -0,0 +1,59 @@
+import type { ImportPreviewStats, LoadedSchemaFile } from "~/utils/specImport";
+
+export const ImportSchemaPreviewSummary = ({
+ loadedSchemaFile,
+ previewStats,
+}: {
+ loadedSchemaFile: LoadedSchemaFile;
+ previewStats: ImportPreviewStats;
+}) => {
+ return (
+ <>
+
+
Schema file metadata
+
+ Vault:{" "}
+
+ {loadedSchemaFile.schemaFile.vaultName}
+
+
+
+ Exported at:{" "}
+
+ {loadedSchemaFile.schemaFile.exportedAt}
+
+
+
+ Plugin version:{" "}
+
+ {loadedSchemaFile.schemaFile.pluginVersion}
+
+
+
+
+
+
Preview (full schema file)
+
+ Node types: {previewStats.nodeTypes.total} total (
+ {previewStats.nodeTypes.new} new, {previewStats.nodeTypes.existing}{" "}
+ existing)
+
+
+ Relation types: {previewStats.relationTypes.total} total (
+ {previewStats.relationTypes.new} new,{" "}
+ {previewStats.relationTypes.existing} existing)
+
+
+ Relation triples: {previewStats.discourseRelations.total} total (
+ {previewStats.discourseRelations.new} new,{" "}
+ {previewStats.discourseRelations.existing} existing)
+
+
+ Templates: {previewStats.templates.total} total (
+ {previewStats.templates.new} new, {previewStats.templates.existing}{" "}
+ existing)
+
+
+ >
+ );
+};
diff --git a/apps/obsidian/src/components/ImportSpecsModal.tsx b/apps/obsidian/src/components/ImportSpecsModal.tsx
new file mode 100644
index 000000000..0af30bef4
--- /dev/null
+++ b/apps/obsidian/src/components/ImportSpecsModal.tsx
@@ -0,0 +1,215 @@
+import { Modal, Notice } from "obsidian";
+import { StrictMode, useState } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { ZodError } from "zod";
+import type DiscourseGraphPlugin from "~/index";
+import {
+ applySchemaImportSelection,
+ pickAndPreviewSchemaImport,
+ type ImportPreviewStats,
+ type LoadedSchemaFile,
+ type SpecImportPreview,
+} from "~/utils/specImport";
+import { NativeFileDialogCancelledError } from "~/utils/nativeJsonFileDialogs";
+import { useSchemaSelection } from "~/components/useSchemaSelection";
+import { SchemaSelectionModalBody } from "~/components/SchemaSelectionModalBody";
+import { ImportSchemaPreviewSummary } from "~/components/ImportSchemaPreviewSummary";
+
+type ImportSpecsModalProps = {
+ plugin: DiscourseGraphPlugin;
+ onClose: () => void;
+};
+
+export const openImportSpecsModal = (plugin: DiscourseGraphPlugin): void => {
+ new ImportSpecsModal(plugin).open();
+};
+
+const ImportPreviewSelection = ({
+ plugin,
+ loadedSchemaFile,
+ previewStats,
+ isApplyingImport,
+ setIsApplyingImport,
+ onResetPreview,
+ onClose,
+}: {
+ plugin: DiscourseGraphPlugin;
+ loadedSchemaFile: LoadedSchemaFile;
+ previewStats: ImportPreviewStats;
+ isApplyingImport: boolean;
+ setIsApplyingImport: (value: boolean) => void;
+ onResetPreview: () => void;
+ onClose: () => void;
+}) => {
+ const schemaFile = loadedSchemaFile.schemaFile;
+ const source = {
+ nodeTypes: schemaFile.nodeTypes,
+ relationTypes: schemaFile.relationTypes,
+ relationTriples: schemaFile.discourseRelations,
+ templateNames: schemaFile.templates.map((template) => template.name),
+ };
+
+ const selection = useSchemaSelection({
+ source,
+ resetKey: loadedSchemaFile.sourcePath,
+ });
+
+ const handleApplyImport = async (): Promise => {
+ const selected = selection.asSelectionPayload();
+ const hasAnySelection =
+ selected.nodeTypeIds.length > 0 ||
+ selected.relationTypeIds.length > 0 ||
+ selected.discourseRelationIds.length > 0 ||
+ selected.templateNames.length > 0;
+ if (!hasAnySelection) {
+ new Notice("Select at least one item to import.");
+ return;
+ }
+
+ setIsApplyingImport(true);
+ const warnings: string[] = [];
+ try {
+ const { created } = await applySchemaImportSelection({
+ plugin,
+ loadedSchemaFile,
+ selection: selected,
+ onWarning: (message) => warnings.push(message),
+ });
+
+ new Notice(
+ `Import complete: ${created.nodeTypes} node type(s), ${created.relationTypes} relation type(s), ${created.discourseRelations} relation triple(s), and ${created.templates} template(s) created.`,
+ 7000,
+ );
+ if (warnings.length > 0) {
+ new Notice(`Import warnings:\n${warnings.join("\n")}`, 6000);
+ }
+ onClose();
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error);
+ new Notice(`Failed to import schema: ${message}`, 6000);
+ // Only the failure path stays mounted; the success path unmounted at onClose()
+ setIsApplyingImport(false);
+ }
+ };
+
+ return (
+ <>
+
+ new Notice(message)}
+ footerSecondaryLabel="Choose another file"
+ onFooterSecondaryClick={onResetPreview}
+ footerPrimaryLabel={
+ isApplyingImport ? "Importing..." : "Import selected"
+ }
+ onFooterPrimaryClick={() => void handleApplyImport()}
+ isFooterSecondaryDisabled={isApplyingImport}
+ isFooterPrimaryDisabled={isApplyingImport}
+ />
+ >
+ );
+};
+
+const ImportSpecsContent = ({ plugin, onClose }: ImportSpecsModalProps) => {
+ const [preview, setPreview] = useState(null);
+ const [isSelectingFile, setIsSelectingFile] = useState(false);
+ const [isApplyingImport, setIsApplyingImport] = useState(false);
+
+ const handleSelectSchemaFile = async (): Promise => {
+ setIsSelectingFile(true);
+ try {
+ const nextPreview = await pickAndPreviewSchemaImport({ plugin });
+ setPreview(nextPreview);
+ } catch (error) {
+ if (error instanceof NativeFileDialogCancelledError) return;
+ if (error instanceof ZodError) {
+ const fields = error.issues.map((i) => i.path.join(".")).join(", ");
+ new Notice(
+ `Schema file is incompatible with this version of the plugin. Invalid or missing fields: ${fields}`,
+ 8000,
+ );
+ return;
+ }
+ const message = error instanceof Error ? error.message : String(error);
+ new Notice(`Failed to load schema file: ${message}`, 6000);
+ } finally {
+ setIsSelectingFile(false);
+ }
+ };
+
+ if (!preview) {
+ return (
+
+
Import discourse graph schema
+
+ Pick a dg-schema-*.json file from your computer to
+ preview and choose exactly what to import.
+
+
+
+ Same dependency rules as export apply here during selection.
+
+
+
+
+
+
+
+ );
+ }
+
+ return (
+ setPreview(null)}
+ onClose={onClose}
+ />
+ );
+};
+
+export class ImportSpecsModal extends Modal {
+ private plugin: DiscourseGraphPlugin;
+ private root: Root | null = null;
+
+ constructor(plugin: DiscourseGraphPlugin) {
+ super(plugin.app);
+ this.plugin = plugin;
+ }
+
+ onOpen(): void {
+ this.contentEl.empty();
+ this.root = createRoot(this.contentEl);
+ this.root.render(
+
+ this.close()} />
+ ,
+ );
+ }
+
+ onClose(): void {
+ if (this.root) {
+ this.root.unmount();
+ this.root = null;
+ }
+ }
+}
diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts
index 29e7285d1..4d39c9ff4 100644
--- a/apps/obsidian/src/utils/registerCommands.ts
+++ b/apps/obsidian/src/utils/registerCommands.ts
@@ -15,6 +15,7 @@ import { addRelationIfRequested } from "~/components/canvas/utils/relationJsonUt
import type { DiscourseNode } from "~/types";
import { TldrawView } from "~/components/canvas/TldrawView";
import { createBaseForNodeType } from "./baseForNodeType";
+import { openImportSpecsModal } from "~/components/ImportSpecsModal";
type ModifyNodeSubmitParams = {
nodeType: DiscourseNode;
@@ -201,6 +202,14 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => {
callback: () => openExportSpecsModal(plugin),
});
+ plugin.addCommand({
+ id: "import-dg-schema",
+ name: "Import discourse graph schema",
+ callback: () => {
+ openImportSpecsModal(plugin);
+ },
+ });
+
plugin.addCommand({
id: "toggle-discourse-context",
name: "Toggle discourse context",