From f6997c76e95cf3c1761568288c23cf7d6a79932a Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 28 Jul 2026 22:39:33 -0400 Subject: [PATCH 01/16] 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 9c05c07d2..563fb6fdb 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 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", From 035acbaf311e6587ea9d88bb88d8b0b4ae0a41ad Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 29 Jul 2026 15:44:10 -0400 Subject: [PATCH 02/16] ENG-2084 Inline Modal boilerplate in ImportSpecsModal (remove ReactRootModal abstraction) --- .../src/components/ImportSpecsModal.tsx | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/apps/obsidian/src/components/ImportSpecsModal.tsx b/apps/obsidian/src/components/ImportSpecsModal.tsx index 7558080e8..7195c3be0 100644 --- a/apps/obsidian/src/components/ImportSpecsModal.tsx +++ b/apps/obsidian/src/components/ImportSpecsModal.tsx @@ -1,5 +1,6 @@ -import { App, Notice } from "obsidian"; -import { useMemo, useState } from "react"; +import { App, Modal, Notice } from "obsidian"; +import { StrictMode, useMemo, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; import type DiscourseGraphPlugin from "~/index"; import { applySchemaImportSelection, @@ -15,7 +16,6 @@ import { } from "~/components/useSchemaSelection"; import { SchemaSelectionModalBody } from "~/components/SchemaSelectionModalBody"; import { ImportSchemaPreviewSummary } from "~/components/ImportSchemaPreviewSummary"; -import { ReactRootModal } from "~/components/ReactRootModal"; type ImportSpecsModalProps = { plugin: DiscourseGraphPlugin; @@ -197,17 +197,29 @@ const ImportSpecsContent = ({ plugin, onClose }: ImportSpecsModalProps) => { ); }; -export class ImportSpecsModal extends ReactRootModal { +export class ImportSpecsModal extends Modal { private plugin: DiscourseGraphPlugin; + private root: Root | null = null; constructor(app: App, plugin: DiscourseGraphPlugin) { super(app); this.plugin = plugin; } - protected renderContent() { - return ( - this.close()} /> + 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; + } + } } From 07175f5afd061e8a38ab3ca181e3279081c389c5 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 29 Jul 2026 18:32:24 -0400 Subject: [PATCH 03/16] ENG-2084 Show human-readable error when schema file fails Zod validation --- apps/obsidian/src/components/ImportSpecsModal.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/obsidian/src/components/ImportSpecsModal.tsx b/apps/obsidian/src/components/ImportSpecsModal.tsx index 7195c3be0..b0a4727a7 100644 --- a/apps/obsidian/src/components/ImportSpecsModal.tsx +++ b/apps/obsidian/src/components/ImportSpecsModal.tsx @@ -1,6 +1,7 @@ import { App, Modal, Notice } from "obsidian"; import { StrictMode, useMemo, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; +import { ZodError } from "zod"; import type DiscourseGraphPlugin from "~/index"; import { applySchemaImportSelection, @@ -143,10 +144,15 @@ const ImportSpecsContent = ({ plugin, onClose }: ImportSpecsModalProps) => { const nextPreview = await pickAndPreviewSchemaImport({ plugin }); setPreview(nextPreview); } catch (error) { - if (error instanceof NativeFileDialogCancelledError) { + 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; } - 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 { From a44a5fa881969775414c0bbaf322c6da8c351f29 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 29 Jul 2026 22:13:42 -0400 Subject: [PATCH 04/16] ENG-2084 Remove console.error (violates Obsidian plugin guidelines) --- apps/obsidian/src/components/ImportSpecsModal.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/obsidian/src/components/ImportSpecsModal.tsx b/apps/obsidian/src/components/ImportSpecsModal.tsx index b0a4727a7..f575bdaf0 100644 --- a/apps/obsidian/src/components/ImportSpecsModal.tsx +++ b/apps/obsidian/src/components/ImportSpecsModal.tsx @@ -101,7 +101,6 @@ const ImportPreviewSelection = ({ } 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 { From b3ea0824bc9997d2d966d90cc76d9d561e9f6832 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 29 Jul 2026 23:16:31 -0400 Subject: [PATCH 05/16] ENG-2084 Remove emptyTemplateText/beforePanel props; compose ImportSchemaPreviewSummary as sibling Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/ImportSpecsModal.tsx | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/apps/obsidian/src/components/ImportSpecsModal.tsx b/apps/obsidian/src/components/ImportSpecsModal.tsx index f575bdaf0..cad542594 100644 --- a/apps/obsidian/src/components/ImportSpecsModal.tsx +++ b/apps/obsidian/src/components/ImportSpecsModal.tsx @@ -109,26 +109,27 @@ const ImportPreviewSelection = ({ }; return ( - new Notice(message)} - beforePanel={ - - } - footerSecondaryLabel="Choose another file" - onFooterSecondaryClick={onResetPreview} - footerPrimaryLabel={isApplyingImport ? "Importing..." : "Import selected"} - onFooterPrimaryClick={() => void handleApplyImport()} - isFooterSecondaryDisabled={isApplyingImport} - isFooterPrimaryDisabled={isApplyingImport} - /> + <> + + new Notice(message)} + footerSecondaryLabel="Choose another file" + onFooterSecondaryClick={onResetPreview} + footerPrimaryLabel={ + isApplyingImport ? "Importing..." : "Import selected" + } + onFooterPrimaryClick={() => void handleApplyImport()} + isFooterSecondaryDisabled={isApplyingImport} + isFooterPrimaryDisabled={isApplyingImport} + /> + ); }; From 0c01d85aeed704dc98bc9272be6cd58488584a98 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 30 Jul 2026 12:53:07 -0400 Subject: [PATCH 06/16] ENG-2084 Address review: simplify ImportSpecsModal constructor, use SchemaSelection directly, batch warnings - Constructor takes only plugin (extracts app internally), matching ExportSpecsModal pattern - Pass payload directly to applySchemaImportSelection (no intermediate field mapping) - Batch import warnings into one Notice Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/ImportSpecsModal.tsx | 22 ++++++------------- 1 file changed, 7 insertions(+), 15 deletions(-) diff --git a/apps/obsidian/src/components/ImportSpecsModal.tsx b/apps/obsidian/src/components/ImportSpecsModal.tsx index cad542594..db41b9438 100644 --- a/apps/obsidian/src/components/ImportSpecsModal.tsx +++ b/apps/obsidian/src/components/ImportSpecsModal.tsx @@ -1,4 +1,4 @@ -import { App, Modal, Notice } from "obsidian"; +import { Modal, Notice } from "obsidian"; import { StrictMode, useMemo, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import { ZodError } from "zod"; @@ -24,7 +24,7 @@ type ImportSpecsModalProps = { }; export const openImportSpecsModal = (plugin: DiscourseGraphPlugin): void => { - new ImportSpecsModal(plugin.app, plugin).open(); + new ImportSpecsModal(plugin).open(); }; const ImportPreviewSelection = ({ @@ -64,7 +64,7 @@ const ImportPreviewSelection = ({ const hasAnySelection = selected.nodeTypeIds.length > 0 || selected.relationTypeIds.length > 0 || - selected.relationIds.length > 0 || + selected.discourseRelationIds.length > 0 || selected.templateNames.length > 0; if (!hasAnySelection) { new Notice("Select at least one item to import."); @@ -76,12 +76,7 @@ const ImportPreviewSelection = ({ const result = await applySchemaImportSelection({ plugin, loadedSchemaFile, - selection: { - nodeTypeIds: selected.nodeTypeIds, - relationTypeIds: selected.relationTypeIds, - discourseRelationIds: selected.relationIds, - templateNames: selected.templateNames, - }, + selection: selected, }); const { created } = result; @@ -92,12 +87,9 @@ const ImportPreviewSelection = ({ if (result.warnings.length > 0) { new Notice( - `Import completed with ${result.warnings.length} warning(s).`, + `Import warnings:\n${result.warnings.join("\n")}`, 6000, ); - for (const warning of result.warnings) { - new Notice(warning, 6000); - } } onClose(); } catch (error) { @@ -207,8 +199,8 @@ export class ImportSpecsModal extends Modal { private plugin: DiscourseGraphPlugin; private root: Root | null = null; - constructor(app: App, plugin: DiscourseGraphPlugin) { - super(app); + constructor(plugin: DiscourseGraphPlugin) { + super(plugin.app); this.plugin = plugin; } From 50644eab9928d742520f0c64309f993dfdd93184 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 30 Jul 2026 13:06:37 -0400 Subject: [PATCH 07/16] =?UTF-8?q?ENG-2084=20Remove=20useMemo=20from=20sour?= =?UTF-8?q?ce=20=E2=80=94=20schema=20file=20is=20stable=20for=20component?= =?UTF-8?q?=20lifetime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .../src/components/ImportSpecsModal.tsx | 23 ++++++++----------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/apps/obsidian/src/components/ImportSpecsModal.tsx b/apps/obsidian/src/components/ImportSpecsModal.tsx index db41b9438..40e8c6542 100644 --- a/apps/obsidian/src/components/ImportSpecsModal.tsx +++ b/apps/obsidian/src/components/ImportSpecsModal.tsx @@ -1,5 +1,5 @@ import { Modal, Notice } from "obsidian"; -import { StrictMode, useMemo, useState } from "react"; +import { StrictMode, useState } from "react"; import { createRoot, type Root } from "react-dom/client"; import { ZodError } from "zod"; import type DiscourseGraphPlugin from "~/index"; @@ -11,10 +11,7 @@ import { type SpecImportPreview, } from "~/utils/specImport"; import { NativeFileDialogCancelledError } from "~/utils/nativeJsonFileDialogs"; -import { - useSchemaSelection, - type SchemaSelectionSource, -} from "~/components/useSchemaSelection"; +import { useSchemaSelection } from "~/components/useSchemaSelection"; import { SchemaSelectionModalBody } from "~/components/SchemaSelectionModalBody"; import { ImportSchemaPreviewSummary } from "~/components/ImportSchemaPreviewSummary"; @@ -44,15 +41,13 @@ const ImportPreviewSelection = ({ 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 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, From 8f2f33fd7cfe5d9db14a69ea67a53586e6a25cd1 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 30 Jul 2026 13:18:14 -0400 Subject: [PATCH 08/16] ENG-2084 Use onWarning callback in applySchemaImportSelection Co-Authored-By: Claude Sonnet 4.6 --- apps/obsidian/src/components/ImportSpecsModal.tsx | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/apps/obsidian/src/components/ImportSpecsModal.tsx b/apps/obsidian/src/components/ImportSpecsModal.tsx index 40e8c6542..888acb1fe 100644 --- a/apps/obsidian/src/components/ImportSpecsModal.tsx +++ b/apps/obsidian/src/components/ImportSpecsModal.tsx @@ -67,24 +67,21 @@ const ImportPreviewSelection = ({ } setIsApplyingImport(true); + const warnings: string[] = []; try { - const result = await applySchemaImportSelection({ + const { created } = await applySchemaImportSelection({ plugin, loadedSchemaFile, selection: selected, + onWarning: (message) => warnings.push(message), }); - 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 warnings:\n${result.warnings.join("\n")}`, - 6000, - ); + if (warnings.length > 0) { + new Notice(`Import warnings:\n${warnings.join("\n")}`, 6000); } onClose(); } catch (error) { From b8c28be1b7b3a2fb59b2722bf66f9bcedc57e0ff Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 30 Jul 2026 16:23:05 -0400 Subject: [PATCH 09/16] ENG-2084 Drop schema import entry from settings Import stays reachable through the import-dg-schema command in the palette; the settings entry duplicated it. Co-Authored-By: Claude Opus 5 --- .../src/components/GeneralSettings.tsx | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/apps/obsidian/src/components/GeneralSettings.tsx b/apps/obsidian/src/components/GeneralSettings.tsx index 563fb6fdb..9c05c07d2 100644 --- a/apps/obsidian/src/components/GeneralSettings.tsx +++ b/apps/obsidian/src/components/GeneralSettings.tsx @@ -4,7 +4,6 @@ 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"; @@ -274,26 +273,6 @@ 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
From 2220b15f8e1abf2e4fa7d68b2150a6eff701e654 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 30 Jul 2026 16:27:27 -0400 Subject: [PATCH 10/16] ENG-2084 Reset applying flag only on the failure path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The success path calls onClose() and unmounts, so the finally block was setting state on an unmounted component. Harmless — React removed that warning in 18.0 and it is a silent no-op — but the ordering read as if the reset mattered after close. Only the catch stays mounted, so only it resets. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/components/ImportSpecsModal.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/obsidian/src/components/ImportSpecsModal.tsx b/apps/obsidian/src/components/ImportSpecsModal.tsx index 888acb1fe..0af30bef4 100644 --- a/apps/obsidian/src/components/ImportSpecsModal.tsx +++ b/apps/obsidian/src/components/ImportSpecsModal.tsx @@ -87,7 +87,7 @@ const ImportPreviewSelection = ({ } catch (error) { const message = error instanceof Error ? error.message : String(error); new Notice(`Failed to import schema: ${message}`, 6000); - } finally { + // Only the failure path stays mounted; the success path unmounted at onClose() setIsApplyingImport(false); } }; From 16675f1e50c0d696e855edc89f38d7f6e1541593 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 24 Aug 2026 11:21:03 -0400 Subject: [PATCH 11/16] ENG-2084 Add useSchemaMergePlan for per-field import choices MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holds which values the user chose to take from the imported file for items that already exist in the vault. Nothing is selected initially, so an untouched item keeps every local value — the state the apply path already treats an absent entry as. Kept out of useSchemaSelection deliberately. The two have different lifecycles: a choice only means anything for an item that is still selected, so this resets whenever the set of selected overlapping items changes, while the selection itself persists. It is also import-only — the export flow has nothing to choose between. Keyed by schema-file id rather than local id, matching the diff: the match plan collapses schema types that collide by normalized name, so two schema ids can share one local id. Co-Authored-By: Claude Opus 5 --- .../src/components/useSchemaMergePlan.ts | 202 ++++++++++++++++++ 1 file changed, 202 insertions(+) create mode 100644 apps/obsidian/src/components/useSchemaMergePlan.ts diff --git a/apps/obsidian/src/components/useSchemaMergePlan.ts b/apps/obsidian/src/components/useSchemaMergePlan.ts new file mode 100644 index 000000000..623f9392c --- /dev/null +++ b/apps/obsidian/src/components/useSchemaMergePlan.ts @@ -0,0 +1,202 @@ +import { useEffect, useState } from "react"; +import type { + SchemaConflict, + SchemaConflictCategory, +} from "~/utils/schemaFieldDiff"; +import type { SchemaMergePlan } from "~/utils/specImport"; + +/** + * Which values the user chose to take from the imported file for items that + * already exist in the vault. Import-only: the export flow has nothing to + * choose between. + * + * Deliberately separate from useSchemaSelection rather than folded into it. + * The two have different lifecycles — a choice only means anything for an item + * that is still selected, so this state resets whenever the set of selected + * overlapping items changes, while the selection itself persists. + * + * Nothing is selected initially: every field starts on the local value, which + * is what the apply path treats an absent entry as. + */ +export type SchemaMergePlanState = { + isFieldSelected: (args: { + category: SchemaConflictCategory; + schemaId: string; + field: string; + }) => boolean; + toggleField: (args: { + category: SchemaConflictCategory; + schemaId: string; + field: string; + shouldSelect: boolean; + }) => void; + /** Drives an item's header row: take every field from the file, or none of them. */ + setAllFields: (args: { + conflict: SchemaConflict; + shouldSelect: boolean; + }) => void; + countSelectedFields: (conflict: SchemaConflict) => number; + asMergePlan: () => SchemaMergePlan; +}; + +type FieldSelections = ReadonlyMap>; + +/** + * An empty entry and an absent one mean the same thing to the apply path, so + * emptying one removes it and the plan never carries entries selecting nothing. + */ +const withFields = ({ + selections, + schemaId, + fields, +}: { + selections: FieldSelections; + schemaId: string; + fields: ReadonlySet; +}): FieldSelections => { + const nextSelections = new Map(selections); + if (fields.size === 0) { + nextSelections.delete(schemaId); + } else { + nextSelections.set(schemaId, fields); + } + return nextSelections; +}; + +const withFieldToggled = ({ + selections, + schemaId, + field, + shouldSelect, +}: { + selections: FieldSelections; + schemaId: string; + field: string; + shouldSelect: boolean; +}): FieldSelections => { + const nextFields = new Set(selections.get(schemaId) ?? []); + if (shouldSelect) { + nextFields.add(field); + } else { + nextFields.delete(field); + } + return withFields({ selections, schemaId, fields: nextFields }); +}; + +const withNameToggled = ({ + names, + name, + shouldSelect, +}: { + names: ReadonlySet; + name: string; + shouldSelect: boolean; +}): ReadonlySet => { + const nextNames = new Set(names); + if (shouldSelect) { + nextNames.add(name); + } else { + nextNames.delete(name); + } + return nextNames; +}; + +export const useSchemaMergePlan = ({ + resetKey, +}: { + resetKey: string; +}): SchemaMergePlanState => { + const [nodeTypeFields, setNodeTypeFields] = useState( + () => new Map(), + ); + const [relationTypeFields, setRelationTypeFields] = useState( + () => new Map(), + ); + const [templateNames, setTemplateNames] = useState>( + () => new Set(), + ); + + useEffect(() => { + setNodeTypeFields(new Map()); + setRelationTypeFields(new Map()); + setTemplateNames(new Set()); + }, [resetKey]); + + const isFieldSelected: SchemaMergePlanState["isFieldSelected"] = ({ + category, + schemaId, + field, + }) => { + if (category === "template") return templateNames.has(schemaId); + const selections = + category === "nodeType" ? nodeTypeFields : relationTypeFields; + return selections.get(schemaId)?.has(field) ?? false; + }; + + const toggleField: SchemaMergePlanState["toggleField"] = ({ + category, + schemaId, + field, + shouldSelect, + }) => { + if (category === "template") { + setTemplateNames((previousNames) => + withNameToggled({ names: previousNames, name: schemaId, shouldSelect }), + ); + return; + } + const setSelections = + category === "nodeType" ? setNodeTypeFields : setRelationTypeFields; + setSelections((previousSelections) => + withFieldToggled({ + selections: previousSelections, + schemaId, + field, + shouldSelect, + }), + ); + }; + + return { + isFieldSelected, + toggleField, + setAllFields: ({ conflict, shouldSelect }) => { + if (conflict.category === "template") { + setTemplateNames((previousNames) => + withNameToggled({ + names: previousNames, + name: conflict.schemaId, + shouldSelect, + }), + ); + return; + } + const setSelections = + conflict.category === "nodeType" + ? setNodeTypeFields + : setRelationTypeFields; + setSelections((previousSelections) => + withFields({ + selections: previousSelections, + schemaId: conflict.schemaId, + fields: shouldSelect + ? new Set(conflict.changes.map((change) => change.field)) + : new Set(), + }), + ); + }, + countSelectedFields: (conflict) => + conflict.changes.filter((change) => + isFieldSelected({ + category: conflict.category, + schemaId: conflict.schemaId, + field: change.field, + }), + ).length, + asMergePlan: () => ({ + nodeTypeFields, + relationTypeFields, + templateNames, + }), + }; +}; From 3c7e23d41731ce5663fa50c3e5a8119417b27d82 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 24 Aug 2026 11:21:03 -0400 Subject: [PATCH 12/16] ENG-2084 Add the field choice step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One table per overlapping item: a row per differing field, with the local value and the imported value side by side and a radio pair to pick between them. The chosen cell is highlighted, so the default reads as "keeping mine" rather than as an undecided checkbox. Per-item header buttons apply a whole side at once. They are buttons with a pressed state rather than a third radio pair, so a bulk action does not look like another per-field choice, and they fall out of the pressed state as soon as one field differs. Grouped by category, since that is the one axis on which every item has exactly one home — a template can be referenced by several node types, so nesting items under node types would either duplicate a row or pick an arbitrary owner. Values wrap rather than truncate. Colors render as swatches, covering both node types' hex and relation types' tldraw names. Templates show their size and the name the copy will land under, taken from the same helper the apply path uses so the two cannot drift. Co-Authored-By: Claude Opus 5 --- .../src/components/SchemaFieldChoiceStep.tsx | 317 ++++++++++++++++++ 1 file changed, 317 insertions(+) create mode 100644 apps/obsidian/src/components/SchemaFieldChoiceStep.tsx diff --git a/apps/obsidian/src/components/SchemaFieldChoiceStep.tsx b/apps/obsidian/src/components/SchemaFieldChoiceStep.tsx new file mode 100644 index 000000000..02dd91e5e --- /dev/null +++ b/apps/obsidian/src/components/SchemaFieldChoiceStep.tsx @@ -0,0 +1,317 @@ +import type { + SchemaConflict, + SchemaFieldChange, +} from "~/utils/schemaFieldDiff"; +import type { SchemaMergePlanState } from "~/components/useSchemaMergePlan"; +import { getImportedTemplateFileName } from "~/utils/templates"; +import { COLOR_PALETTE } from "~/utils/tldrawColors"; + +/** Field names as they read in settings. Only mergeable fields reach this step. */ +const FIELD_LABELS: Record = { + format: "Format", + template: "Template", + description: "Description", + shortcut: "Shortcut", + color: "Color", + tag: "Tag", + keyImage: "Key image", + folderPath: "Folder path", + complement: "Complement", + content: "File contents", +}; + +const CATEGORY_HEADINGS: Record = { + nodeType: "Node types", + relationType: "Relation types", + template: "Templates", +}; + +const CATEGORY_ORDER: SchemaConflict["category"][] = [ + "nodeType", + "relationType", + "template", +]; + +const formatFieldValue = (value: SchemaFieldChange["localValue"]): string => { + if (value === undefined || value === "") return "empty"; + if (typeof value === "boolean") return value ? "on" : "off"; + // Collapsed rather than shortened: the cell wraps so the whole value stays + // readable, but embedded newlines would otherwise stretch the row. + const collapsed = value.replace(/\s+/g, " ").trim(); + return collapsed.length === 0 ? "empty" : collapsed; +}; + +const isEmptyValue = (value: SchemaFieldChange["localValue"]): boolean => { + return ( + value === undefined || (typeof value === "string" && value.trim() === "") + ); +}; + +/** + * Node types store a hex color, relation types a tldraw color name. Both render + * as a swatch so the two sides can be compared at a glance rather than by + * reading hex. + */ +const resolveSwatchColor = ({ + field, + value, +}: { + field: string; + value: SchemaFieldChange["localValue"]; +}): string | undefined => { + if (field !== "color" || typeof value !== "string") return undefined; + return COLOR_PALETTE[value] ?? (value.startsWith("#") ? value : undefined); +}; + +/** Templates are replaced whole, so size is the only useful summary of the body. */ +const describeTemplateBody = ( + value: SchemaFieldChange["localValue"], +): string => { + if (typeof value !== "string") return formatFieldValue(value); + const lineCount = value.split("\n").length; + return `${value.length} bytes, ${lineCount} line${lineCount === 1 ? "" : "s"}`; +}; + +const ChoiceCell = ({ + conflict, + change, + isImportedSide, + mergePlan, +}: { + conflict: SchemaConflict; + change: SchemaFieldChange; + isImportedSide: boolean; + mergePlan: SchemaMergePlanState; +}) => { + const takesImported = mergePlan.isFieldSelected({ + category: conflict.category, + schemaId: conflict.schemaId, + field: change.field, + }); + const isChosen = isImportedSide ? takesImported : !takesImported; + const value = isImportedSide ? change.importedValue : change.localValue; + const swatch = resolveSwatchColor({ field: change.field, value }); + + const isTemplateBody = + conflict.category === "template" && change.field === "content"; + const text = isTemplateBody + ? describeTemplateBody(value) + : formatFieldValue(value); + + return ( + + + + ); +}; + +const ItemChoiceTable = ({ + conflict, + mergePlan, + sourceVaultName, +}: { + conflict: SchemaConflict; + mergePlan: SchemaMergePlanState; + sourceVaultName: string; +}) => { + const selectedCount = mergePlan.countSelectedFields(conflict); + const isAllLocal = selectedCount === 0; + const isAllImported = selectedCount === conflict.changes.length; + + return ( +
+
+ {conflict.label} +
+ + + + + + + + + + + + + + + {conflict.changes.map((change) => ( + + + + + + ))} + +
+ Field + + + + +
+ {FIELD_LABELS[change.field] ?? change.field} +
+
+ ); +}; + +export const SchemaFieldChoiceStep = ({ + conflicts, + mergePlan, + sourceVaultName, +}: { + conflicts: SchemaConflict[]; + mergePlan: SchemaMergePlanState; + sourceVaultName: string; +}) => { + const totalFields = conflicts.reduce( + (total, conflict) => total + conflict.changes.length, + 0, + ); + const selectedFields = conflicts.reduce( + (total, conflict) => total + mergePlan.countSelectedFields(conflict), + 0, + ); + const templateConflicts = conflicts.filter( + (conflict) => conflict.category === "template", + ); + + return ( + <> +
+
+ {conflicts.length} item(s) already in this vault +
+

+ Every field starts on your value. Names are never changed by an + import, because renaming a type does not retag the pages already using + it. +

+
+ +
+ {CATEGORY_ORDER.map((category) => { + const categoryConflicts = conflicts.filter( + (conflict) => conflict.category === category, + ); + if (categoryConflicts.length === 0) return null; + + return ( +
+

+ {CATEGORY_HEADINGS[category]} ({categoryConflicts.length}) +

+ {categoryConflicts.map((conflict) => ( + + ))} +
+ ); + })} +
+ + {templateConflicts.length > 0 && ( +

+ ⧉ Your template file is never overwritten — the imported version is + added beside it as{" "} + {templateConflicts + .map((conflict) => + getImportedTemplateFileName({ + templateName: conflict.schemaId, + sourceName: sourceVaultName, + }), + ) + .map((name) => `${name}.md`) + .join(", ")} + , and node types using it are repointed at the copy. +

+ )} + +

+ {selectedFields} of {totalFields} field(s) will come from the file +

+ + ); +}; From 02bd08902359c92af4dae2983269887ee4f1ecbf Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 24 Aug 2026 11:21:14 -0400 Subject: [PATCH 13/16] ENG-2084 Add optional item notes to SchemaSelectionPanel Two optional props so the import flow can mark, during selection, which items the vault already has and how many of their fields differ. The export flow has nothing to compare against and passes neither, so both default to undefined and its rendering is unchanged. useSchemaSelection and SchemaSelectionModalBody are deliberately untouched. Co-Authored-By: Claude Opus 5 --- .../src/components/SchemaSelectionPanel.tsx | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/obsidian/src/components/SchemaSelectionPanel.tsx b/apps/obsidian/src/components/SchemaSelectionPanel.tsx index 23848e1da..1c91fd829 100644 --- a/apps/obsidian/src/components/SchemaSelectionPanel.tsx +++ b/apps/obsidian/src/components/SchemaSelectionPanel.tsx @@ -7,12 +7,21 @@ type SchemaSelectionPanelProps = { source: SchemaSelectionSource; selection: SchemaSelectionState; onDependencyViolation?: (message: string) => void; + /** + * Optional note rendered beside an item, keyed by id. The import flow uses + * these to mark what the vault already has; the export flow has nothing to + * compare against and passes neither. + */ + nodeTypeNotes?: ReadonlyMap; + relationTypeNotes?: ReadonlyMap; }; export const SchemaSelectionPanel = ({ source, selection, onDependencyViolation, + nodeTypeNotes, + relationTypeNotes, }: SchemaSelectionPanelProps) => { const { selectedNodeTypeIds, @@ -113,6 +122,11 @@ export const SchemaSelectionPanel = ({ disabled={isRequired} /> {nodeType.name} + {nodeTypeNotes?.get(nodeType.id) && ( + + {nodeTypeNotes.get(nodeType.id)} + + )} {isRequired && ( required by selected triple @@ -171,6 +185,11 @@ export const SchemaSelectionPanel = ({ disabled={isRequired} /> {relationType.label} + {relationTypeNotes?.get(relationType.id) && ( + + {relationTypeNotes.get(relationType.id)} + + )} {isRequired && ( required by selected triple From 44c802b398726663b4cad44db00d136e51e93354 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 24 Aug 2026 11:21:14 -0400 Subject: [PATCH 14/16] ENG-2084 Wire the field choice step into the import modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a step between selection and apply, entered only when selected items actually overlap with the vault; with nothing overlapping the import applies straight from selection as before. The resulting merge plan is passed to applySchemaImportSelection, which has accepted one since the data layer landed but never received one. The preview step now renders SchemaSelectionPanel directly rather than through SchemaSelectionModalBody. The shared body is fixed at a two-button footer and cannot forward the new note props, and the import flow now needs footer labels that vary by step. Overlaps are computed for the whole file, so they are filtered to the current selection before being offered — and the completion notice reports merged counts alongside created ones. Co-Authored-By: Claude Opus 5 --- .../src/components/ImportSpecsModal.tsx | 222 ++++++++++++++---- 1 file changed, 182 insertions(+), 40 deletions(-) diff --git a/apps/obsidian/src/components/ImportSpecsModal.tsx b/apps/obsidian/src/components/ImportSpecsModal.tsx index 0af30bef4..0715d6df8 100644 --- a/apps/obsidian/src/components/ImportSpecsModal.tsx +++ b/apps/obsidian/src/components/ImportSpecsModal.tsx @@ -8,26 +8,99 @@ import { pickAndPreviewSchemaImport, type ImportPreviewStats, type LoadedSchemaFile, + type SpecImportApplyResult, type SpecImportPreview, } from "~/utils/specImport"; +import type { SchemaConflict } from "~/utils/schemaFieldDiff"; import { NativeFileDialogCancelledError } from "~/utils/nativeJsonFileDialogs"; -import { useSchemaSelection } from "~/components/useSchemaSelection"; -import { SchemaSelectionModalBody } from "~/components/SchemaSelectionModalBody"; +import { + useSchemaSelection, + type SchemaSelectionState, +} from "~/components/useSchemaSelection"; +import { SchemaSelectionPanel } from "~/components/SchemaSelectionPanel"; import { ImportSchemaPreviewSummary } from "~/components/ImportSchemaPreviewSummary"; +import { SchemaFieldChoiceStep } from "~/components/SchemaFieldChoiceStep"; +import { useSchemaMergePlan } from "~/components/useSchemaMergePlan"; type ImportSpecsModalProps = { plugin: DiscourseGraphPlugin; onClose: () => void; }; +/** + * Choosing field values is a step of its own rather than part of the selection + * list: it only concerns the subset of selected items that already exist, and + * folding per-field choices into the selection list would bury the decision + * that actually changes existing data. + */ +type ImportStep = "select" | "choose"; + export const openImportSpecsModal = (plugin: DiscourseGraphPlugin): void => { new ImportSpecsModal(plugin).open(); }; +/** Overlaps are computed for the whole file, so drop the ones not being imported. */ +const filterConflictsToSelection = ({ + conflicts, + selection, +}: { + conflicts: SchemaConflict[]; + selection: SchemaSelectionState; +}): SchemaConflict[] => { + return conflicts.filter((conflict) => { + if (conflict.category === "nodeType") { + return selection.selectedNodeTypeIds.has(conflict.schemaId); + } + if (conflict.category === "relationType") { + return selection.selectedRelationTypeIds.has(conflict.schemaId); + } + return selection.selectedTemplateNames.has(conflict.schemaId); + }); +}; + +const buildExistingItemNotes = ({ + existingSchemaIds, + conflicts, + category, +}: { + existingSchemaIds: ReadonlySet; + conflicts: SchemaConflict[]; + category: SchemaConflict["category"]; +}): Map => { + const changeCountBySchemaId = new Map( + conflicts + .filter((conflict) => conflict.category === category) + .map((conflict) => [conflict.schemaId, conflict.changes.length]), + ); + return new Map( + [...existingSchemaIds].map((schemaId) => { + const changeCount = changeCountBySchemaId.get(schemaId); + return [ + schemaId, + changeCount + ? `in vault, ${changeCount} field(s) differ` + : "in vault, identical", + ]; + }), + ); +}; + +const buildImportCompleteMessage = ({ + created, + merged, +}: SpecImportApplyResult): string => { + const createdMessage = `Import complete: ${created.nodeTypes} node type(s), ${created.relationTypes} relation type(s), ${created.discourseRelations} relation triple(s), and ${created.templates} template(s) created.`; + const mergedTotal = + merged.nodeTypes + merged.relationTypes + merged.templates; + if (mergedTotal === 0) return createdMessage; + return `${createdMessage} Updated ${merged.nodeTypes} node type(s) and ${merged.relationTypes} relation type(s), and added ${merged.templates} template copy(ies).`; +}; + const ImportPreviewSelection = ({ plugin, loadedSchemaFile, previewStats, + conflicts, isApplyingImport, setIsApplyingImport, onResetPreview, @@ -36,11 +109,13 @@ const ImportPreviewSelection = ({ plugin: DiscourseGraphPlugin; loadedSchemaFile: LoadedSchemaFile; previewStats: ImportPreviewStats; + conflicts: SchemaConflict[]; isApplyingImport: boolean; setIsApplyingImport: (value: boolean) => void; onResetPreview: () => void; onClose: () => void; }) => { + const [step, setStep] = useState("select"); const schemaFile = loadedSchemaFile.schemaFile; const source = { nodeTypes: schemaFile.nodeTypes, @@ -54,32 +129,38 @@ const ImportPreviewSelection = ({ 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; - } + const selectedConflicts = filterConflictsToSelection({ + conflicts, + selection, + }); + + // A merge choice only means anything while its item is still selected, so + // changing which conflicting items are imported discards the choices made. + const mergePlan = useSchemaMergePlan({ + resetKey: `${loadedSchemaFile.sourcePath}|${selectedConflicts + .map((conflict) => `${conflict.category}:${conflict.schemaId}`) + .join(",")}`, + }); + + const hasAnySelection = + selection.selectedNodeTypeIds.size > 0 || + selection.selectedRelationTypeIds.size > 0 || + selection.selectedRelationIds.size > 0 || + selection.selectedTemplateNames.size > 0; + const handleApplyImport = async (): Promise => { setIsApplyingImport(true); const warnings: string[] = []; try { - const { created } = await applySchemaImportSelection({ + const result = await applySchemaImportSelection({ plugin, loadedSchemaFile, - selection: selected, + selection: selection.asSelectionPayload(), + mergePlan: mergePlan.asMergePlan(), 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, - ); + new Notice(buildImportCompleteMessage(result), 7000); if (warnings.length > 0) { new Notice(`Import warnings:\n${warnings.join("\n")}`, 6000); } @@ -92,28 +173,88 @@ const ImportPreviewSelection = ({ } }; + const handleAdvanceFromSelection = (): void => { + if (!hasAnySelection) { + new Notice("Select at least one item to import."); + return; + } + if (selectedConflicts.length > 0) { + setStep("choose"); + return; + } + void handleApplyImport(); + }; + + const isChoosingFields = step === "choose"; + const primaryLabel = isApplyingImport + ? "Importing..." + : isChoosingFields || selectedConflicts.length === 0 + ? "Import selected" + : `Choose what to keep (${selectedConflicts.length})`; + return ( - <> - - new Notice(message)} - footerSecondaryLabel="Choose another file" - onFooterSecondaryClick={onResetPreview} - footerPrimaryLabel={ - isApplyingImport ? "Importing..." : "Import selected" - } - onFooterPrimaryClick={() => void handleApplyImport()} - isFooterSecondaryDisabled={isApplyingImport} - isFooterPrimaryDisabled={isApplyingImport} - /> - +
+

+ {isChoosingFields ? "Choose what to keep" : "Import schema preview"} +

+

+ Source file: {loadedSchemaFile.sourcePath} +

+ + {isChoosingFields ? ( + + ) : ( + <> + + new Notice(message)} + nodeTypeNotes={buildExistingItemNotes({ + existingSchemaIds: loadedSchemaFile.matchPlan.existingNodeTypeIds, + conflicts, + category: "nodeType", + })} + relationTypeNotes={buildExistingItemNotes({ + existingSchemaIds: + loadedSchemaFile.matchPlan.existingRelationTypeIds, + conflicts, + category: "relationType", + })} + /> + + )} + +
+ + +
+
); }; @@ -179,6 +320,7 @@ const ImportSpecsContent = ({ plugin, onClose }: ImportSpecsModalProps) => { plugin={plugin} loadedSchemaFile={preview.loadedSchemaFile} previewStats={preview.previewStats} + conflicts={preview.conflicts} isApplyingImport={isApplyingImport} setIsApplyingImport={setIsApplyingImport} onResetPreview={() => setPreview(null)} From 8d70d4c088b141b7e0b1f6f6830ee1bd535ec5e9 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 25 Aug 2026 12:00:23 -0400 Subject: [PATCH 15/16] ENG-2084 Trim comments to the non-obvious why Each surviving comment is one line and explains something the code cannot: why the merge plan is separate from the selection, why an empty entry is dropped, why field choices are their own step, and why values are collapsed rather than truncated. Restatements of the code are gone. Co-Authored-By: Claude Opus 5 --- .../src/components/ImportSpecsModal.tsx | 9 +-------- .../src/components/SchemaFieldChoiceStep.tsx | 11 ++-------- .../src/components/SchemaSelectionPanel.tsx | 6 +----- .../src/components/useSchemaMergePlan.ts | 20 ++----------------- 4 files changed, 6 insertions(+), 40 deletions(-) diff --git a/apps/obsidian/src/components/ImportSpecsModal.tsx b/apps/obsidian/src/components/ImportSpecsModal.tsx index 0715d6df8..e1d457805 100644 --- a/apps/obsidian/src/components/ImportSpecsModal.tsx +++ b/apps/obsidian/src/components/ImportSpecsModal.tsx @@ -27,12 +27,7 @@ type ImportSpecsModalProps = { onClose: () => void; }; -/** - * Choosing field values is a step of its own rather than part of the selection - * list: it only concerns the subset of selected items that already exist, and - * folding per-field choices into the selection list would bury the decision - * that actually changes existing data. - */ +/** A step of its own: per-field choices folded into the selection list would bury the decision that changes existing data. */ type ImportStep = "select" | "choose"; export const openImportSpecsModal = (plugin: DiscourseGraphPlugin): void => { @@ -134,8 +129,6 @@ const ImportPreviewSelection = ({ selection, }); - // A merge choice only means anything while its item is still selected, so - // changing which conflicting items are imported discards the choices made. const mergePlan = useSchemaMergePlan({ resetKey: `${loadedSchemaFile.sourcePath}|${selectedConflicts .map((conflict) => `${conflict.category}:${conflict.schemaId}`) diff --git a/apps/obsidian/src/components/SchemaFieldChoiceStep.tsx b/apps/obsidian/src/components/SchemaFieldChoiceStep.tsx index 02dd91e5e..2bb666c44 100644 --- a/apps/obsidian/src/components/SchemaFieldChoiceStep.tsx +++ b/apps/obsidian/src/components/SchemaFieldChoiceStep.tsx @@ -6,7 +6,6 @@ import type { SchemaMergePlanState } from "~/components/useSchemaMergePlan"; import { getImportedTemplateFileName } from "~/utils/templates"; import { COLOR_PALETTE } from "~/utils/tldrawColors"; -/** Field names as they read in settings. Only mergeable fields reach this step. */ const FIELD_LABELS: Record = { format: "Format", template: "Template", @@ -35,8 +34,7 @@ const CATEGORY_ORDER: SchemaConflict["category"][] = [ const formatFieldValue = (value: SchemaFieldChange["localValue"]): string => { if (value === undefined || value === "") return "empty"; if (typeof value === "boolean") return value ? "on" : "off"; - // Collapsed rather than shortened: the cell wraps so the whole value stays - // readable, but embedded newlines would otherwise stretch the row. + // Collapse newlines so a multi-line value cannot stretch the row; the cell wraps rather than truncating. const collapsed = value.replace(/\s+/g, " ").trim(); return collapsed.length === 0 ? "empty" : collapsed; }; @@ -47,11 +45,7 @@ const isEmptyValue = (value: SchemaFieldChange["localValue"]): boolean => { ); }; -/** - * Node types store a hex color, relation types a tldraw color name. Both render - * as a swatch so the two sides can be compared at a glance rather than by - * reading hex. - */ +/** Node types store a hex color, relation types a tldraw color name; both resolve to a swatch. */ const resolveSwatchColor = ({ field, value, @@ -63,7 +57,6 @@ const resolveSwatchColor = ({ return COLOR_PALETTE[value] ?? (value.startsWith("#") ? value : undefined); }; -/** Templates are replaced whole, so size is the only useful summary of the body. */ const describeTemplateBody = ( value: SchemaFieldChange["localValue"], ): string => { diff --git a/apps/obsidian/src/components/SchemaSelectionPanel.tsx b/apps/obsidian/src/components/SchemaSelectionPanel.tsx index 1c91fd829..79dcc4e7e 100644 --- a/apps/obsidian/src/components/SchemaSelectionPanel.tsx +++ b/apps/obsidian/src/components/SchemaSelectionPanel.tsx @@ -7,11 +7,7 @@ type SchemaSelectionPanelProps = { source: SchemaSelectionSource; selection: SchemaSelectionState; onDependencyViolation?: (message: string) => void; - /** - * Optional note rendered beside an item, keyed by id. The import flow uses - * these to mark what the vault already has; the export flow has nothing to - * compare against and passes neither. - */ + /** Import-only: marks what the vault already has. Export has nothing to compare against and passes neither. */ nodeTypeNotes?: ReadonlyMap; relationTypeNotes?: ReadonlyMap; }; diff --git a/apps/obsidian/src/components/useSchemaMergePlan.ts b/apps/obsidian/src/components/useSchemaMergePlan.ts index 623f9392c..a085fdf2f 100644 --- a/apps/obsidian/src/components/useSchemaMergePlan.ts +++ b/apps/obsidian/src/components/useSchemaMergePlan.ts @@ -5,19 +5,7 @@ import type { } from "~/utils/schemaFieldDiff"; import type { SchemaMergePlan } from "~/utils/specImport"; -/** - * Which values the user chose to take from the imported file for items that - * already exist in the vault. Import-only: the export flow has nothing to - * choose between. - * - * Deliberately separate from useSchemaSelection rather than folded into it. - * The two have different lifecycles — a choice only means anything for an item - * that is still selected, so this state resets whenever the set of selected - * overlapping items changes, while the selection itself persists. - * - * Nothing is selected initially: every field starts on the local value, which - * is what the apply path treats an absent entry as. - */ +/** Kept out of useSchemaSelection: a choice only outlives the selection it belongs to, so it resets separately. */ export type SchemaMergePlanState = { isFieldSelected: (args: { category: SchemaConflictCategory; @@ -30,7 +18,6 @@ export type SchemaMergePlanState = { field: string; shouldSelect: boolean; }) => void; - /** Drives an item's header row: take every field from the file, or none of them. */ setAllFields: (args: { conflict: SchemaConflict; shouldSelect: boolean; @@ -41,10 +28,7 @@ export type SchemaMergePlanState = { type FieldSelections = ReadonlyMap>; -/** - * An empty entry and an absent one mean the same thing to the apply path, so - * emptying one removes it and the plan never carries entries selecting nothing. - */ +/** An empty entry and an absent one mean the same to the apply path, so empties are dropped. */ const withFields = ({ selections, schemaId, From bea02b5c950bd1303146712db7448e3e1a178a60 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 25 Aug 2026 17:25:46 -0400 Subject: [PATCH 16/16] ENG-2084 Separate the vault name from the bulk-control label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Obsidian styles buttons as inline-flex, and a flex container drops a whitespace-only text node between items, so the {" "} between "Use all from" and the vault name never rendered — the header read "USE ALL FROMlegacyVault". Uses a margin instead of a text node. Found by driving the import flow in a real vault over CDP. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/components/SchemaFieldChoiceStep.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/obsidian/src/components/SchemaFieldChoiceStep.tsx b/apps/obsidian/src/components/SchemaFieldChoiceStep.tsx index 2bb666c44..18b64fc83 100644 --- a/apps/obsidian/src/components/SchemaFieldChoiceStep.tsx +++ b/apps/obsidian/src/components/SchemaFieldChoiceStep.tsx @@ -194,7 +194,9 @@ const ItemChoiceTable = ({ mergePlan.setAllFields({ conflict, shouldSelect: true }) } > - Use all from{" "} + + Use all from + {sourceVaultName}