From 5f5edc05a9ccf41638dabcd940482e928db384bf Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 28 Jul 2026 22:39:33 -0400 Subject: [PATCH] ENG-2084 Add schema import UI for Obsidian --- .../src/components/GeneralSettings.tsx | 21 ++ .../components/ImportSchemaPreviewSummary.tsx | 59 +++++ .../src/components/ImportSpecsModal.tsx | 213 ++++++++++++++++++ apps/obsidian/src/utils/registerCommands.ts | 9 + 4 files changed, 302 insertions(+) create mode 100644 apps/obsidian/src/components/ImportSchemaPreviewSummary.tsx create mode 100644 apps/obsidian/src/components/ImportSpecsModal.tsx diff --git a/apps/obsidian/src/components/GeneralSettings.tsx b/apps/obsidian/src/components/GeneralSettings.tsx index 067aad072..62e062c73 100644 --- a/apps/obsidian/src/components/GeneralSettings.tsx +++ b/apps/obsidian/src/components/GeneralSettings.tsx @@ -4,6 +4,7 @@ import { setIcon } from "obsidian"; import SuggestInput from "./SuggestInput"; import { DiscourseGraphLogoIcon, SlackLogoIcon } from "./Icons"; import { openExportSpecsModal } from "./ExportSpecsModal"; +import { openImportSpecsModal } from "./ImportSpecsModal"; import { getDgSchemaFileName } from "~/utils/specValidation"; const DOCS_URL = "https://discoursegraphs.com/docs/obsidian"; @@ -273,6 +274,26 @@ const GeneralSettings = () => { +
+
+
Import discourse graph schema
+
+ Choose a schema JSON file from your computer and preview how it maps + to your existing node types, relation types, relation triples, and + templates. +
+
+
+ +
+
+
Node tag hotkey
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..7558080e8 --- /dev/null +++ b/apps/obsidian/src/components/ImportSpecsModal.tsx @@ -0,0 +1,213 @@ +import { App, Notice } from "obsidian"; +import { useMemo, useState } from "react"; +import type DiscourseGraphPlugin from "~/index"; +import { + applySchemaImportSelection, + pickAndPreviewSchemaImport, + type ImportPreviewStats, + type LoadedSchemaFile, + type SpecImportPreview, +} from "~/utils/specImport"; +import { NativeFileDialogCancelledError } from "~/utils/nativeJsonFileDialogs"; +import { + useSchemaSelection, + type SchemaSelectionSource, +} from "~/components/useSchemaSelection"; +import { SchemaSelectionModalBody } from "~/components/SchemaSelectionModalBody"; +import { ImportSchemaPreviewSummary } from "~/components/ImportSchemaPreviewSummary"; +import { ReactRootModal } from "~/components/ReactRootModal"; + +type ImportSpecsModalProps = { + plugin: DiscourseGraphPlugin; + onClose: () => void; +}; + +export const openImportSpecsModal = (plugin: DiscourseGraphPlugin): void => { + new ImportSpecsModal(plugin.app, 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 source = useMemo(() => { + const schemaFile = loadedSchemaFile.schemaFile; + return { + nodeTypes: schemaFile.nodeTypes, + relationTypes: schemaFile.relationTypes, + relationTriples: schemaFile.discourseRelations, + templateNames: schemaFile.templates.map((template) => template.name), + }; + }, [loadedSchemaFile]); + + 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.relationIds.length > 0 || + selected.templateNames.length > 0; + if (!hasAnySelection) { + new Notice("Select at least one item to import."); + return; + } + + setIsApplyingImport(true); + try { + const result = await applySchemaImportSelection({ + plugin, + loadedSchemaFile, + selection: { + nodeTypeIds: selected.nodeTypeIds, + relationTypeIds: selected.relationTypeIds, + discourseRelationIds: selected.relationIds, + templateNames: selected.templateNames, + }, + }); + + const { created } = result; + 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 (result.warnings.length > 0) { + new Notice( + `Import completed with ${result.warnings.length} warning(s).`, + 6000, + ); + for (const warning of result.warnings) { + new Notice(warning, 6000); + } + } + onClose(); + } catch (error) { + console.error("Failed to apply schema import:", error); + const message = error instanceof Error ? error.message : String(error); + new Notice(`Failed to import schema: ${message}`, 6000); + } finally { + setIsApplyingImport(false); + } + }; + + return ( + new Notice(message)} + beforePanel={ + + } + 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; + } + console.error("Failed to load schema import file:", error); + 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 ReactRootModal { + private plugin: DiscourseGraphPlugin; + + constructor(app: App, plugin: DiscourseGraphPlugin) { + super(app); + this.plugin = plugin; + } + + protected renderContent() { + return ( + this.close()} /> + ); + } +} diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index 962008695..91026dd70 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; @@ -203,6 +204,14 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { }, }); + plugin.addCommand({ + id: "import-dg-schema", + name: "Import discourse graph schema", + callback: () => { + openImportSpecsModal(plugin); + }, + }); + plugin.addCommand({ id: "toggle-discourse-context", name: "Toggle discourse context",