From 2716b485f14b92eea8cb26ffa9cd1602f959d716 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Fri, 26 Jun 2026 14:27:49 -0400 Subject: [PATCH 1/2] FEE-840 Add schema import for Obsidian. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an Import discourse graph schema command and settings entry point. Users pick a dg-schema-*.json file via the Electron open dialog; the preview step builds a match plan (id → name/label fallback) and shows per-category stats. On apply, new items are created as provisional and existing matches are skipped. Co-authored-by: Cursor --- .../src/components/GeneralSettings.tsx | 21 + .../components/ImportSchemaPreviewSummary.tsx | 59 +++ .../src/components/ImportSpecsModal.tsx | 213 +++++++++ apps/obsidian/src/utils/registerCommands.ts | 9 + apps/obsidian/src/utils/specImport.ts | 416 ++++++++++++++++++ 5 files changed, 718 insertions(+) create mode 100644 apps/obsidian/src/components/ImportSchemaPreviewSummary.tsx create mode 100644 apps/obsidian/src/components/ImportSpecsModal.tsx create mode 100644 apps/obsidian/src/utils/specImport.ts 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", diff --git a/apps/obsidian/src/utils/specImport.ts b/apps/obsidian/src/utils/specImport.ts new file mode 100644 index 000000000..2335b110e --- /dev/null +++ b/apps/obsidian/src/utils/specImport.ts @@ -0,0 +1,416 @@ +import type DiscourseGraphPlugin from "~/index"; +import { uuidv7 } from "uuidv7"; +import { parseDgSchemaFile } from "~/utils/specValidation"; +import { createTemplateFile, getTemplateFiles } from "~/utils/templates"; +import { openJsonFromUserLocation } from "~/utils/nativeJsonFileDialogs"; +import type { + DiscourseNode, + DiscourseRelation, + DiscourseRelationType, + DiscourseSchemaFile, +} from "~/types"; +import { toTldrawColor } from "~/utils/tldrawColors"; + +export type SchemaImportMatchPlan = { + nodeTypeIdMapping: Map; + relationTypeIdMapping: Map; + existingNodeTypeIds: Set; + existingRelationTypeIds: Set; + existingDiscourseRelationIds: Set; + existingTemplateNames: Set; +}; + +export type LoadedSchemaFile = { + sourcePath: string; + schemaFile: DiscourseSchemaFile; + matchPlan: SchemaImportMatchPlan; +}; + +export type ImportPreviewStats = { + nodeTypes: { total: number; new: number; existing: number }; + relationTypes: { total: number; new: number; existing: number }; + discourseRelations: { total: number; new: number; existing: number }; + templates: { total: number; new: number; existing: number }; +}; + +export type SpecImportPreview = { + loadedSchemaFile: LoadedSchemaFile; + previewStats: ImportPreviewStats; +}; + +export type SpecImportSelection = { + nodeTypeIds: string[]; + relationTypeIds: string[]; + discourseRelationIds: string[]; + templateNames: string[]; +}; + +export type SpecImportApplyResult = { + created: { + nodeTypes: number; + relationTypes: number; + discourseRelations: number; + templates: number; + }; + warnings: string[]; +}; + +const normalizeLabel = (value: string): string => { + return value.trim().toLowerCase(); +}; + +const buildTripleKey = ({ + sourceId, + relationshipTypeId, + destinationId, +}: { + sourceId: string; + relationshipTypeId: string; + destinationId: string; +}): string => { + return `${sourceId}::${relationshipTypeId}::${destinationId}`; +}; + +const buildSchemaImportMatchPlan = ({ + schemaFile, + localNodeTypes, + localRelationTypes, + localDiscourseRelations, + localTemplateNames, +}: { + schemaFile: DiscourseSchemaFile; + localNodeTypes: DiscourseNode[]; + localRelationTypes: DiscourseRelationType[]; + localDiscourseRelations: DiscourseRelation[]; + localTemplateNames: Set; +}): SchemaImportMatchPlan => { + const localNodeTypeById = new Map( + localNodeTypes.map((nodeType) => [nodeType.id, nodeType]), + ); + const localNodeTypeByName = new Map( + localNodeTypes.map((nodeType) => [normalizeLabel(nodeType.name), nodeType]), + ); + const localRelationTypeById = new Map( + localRelationTypes.map((relationType) => [relationType.id, relationType]), + ); + const localRelationTypeByLabel = new Map( + localRelationTypes.map((relationType) => [ + normalizeLabel(relationType.label), + relationType, + ]), + ); + + const nodeTypeIdMapping = new Map(); + const existingNodeTypeIds = new Set(); + + for (const nodeType of schemaFile.nodeTypes) { + const matchById = localNodeTypeById.get(nodeType.id); + if (matchById) { + nodeTypeIdMapping.set(nodeType.id, matchById.id); + existingNodeTypeIds.add(nodeType.id); + continue; + } + + const matchByName = localNodeTypeByName.get(normalizeLabel(nodeType.name)); + if (matchByName) { + nodeTypeIdMapping.set(nodeType.id, matchByName.id); + existingNodeTypeIds.add(nodeType.id); + continue; + } + + nodeTypeIdMapping.set(nodeType.id, nodeType.id); + } + + const relationTypeIdMapping = new Map(); + const existingRelationTypeIds = new Set(); + + for (const relationType of schemaFile.relationTypes) { + const matchById = localRelationTypeById.get(relationType.id); + if (matchById) { + relationTypeIdMapping.set(relationType.id, matchById.id); + existingRelationTypeIds.add(relationType.id); + continue; + } + + const matchByLabel = localRelationTypeByLabel.get( + normalizeLabel(relationType.label), + ); + if (matchByLabel) { + relationTypeIdMapping.set(relationType.id, matchByLabel.id); + existingRelationTypeIds.add(relationType.id); + continue; + } + + relationTypeIdMapping.set(relationType.id, relationType.id); + } + + const localTripleKeys = new Set( + localDiscourseRelations.map((relation) => + buildTripleKey({ + sourceId: relation.sourceId, + relationshipTypeId: relation.relationshipTypeId, + destinationId: relation.destinationId, + }), + ), + ); + + const existingDiscourseRelationIds = new Set(); + for (const relation of schemaFile.discourseRelations) { + const mappedSourceId = + nodeTypeIdMapping.get(relation.sourceId) ?? relation.sourceId; + const mappedDestinationId = + nodeTypeIdMapping.get(relation.destinationId) ?? relation.destinationId; + const mappedRelationTypeId = + relationTypeIdMapping.get(relation.relationshipTypeId) ?? + relation.relationshipTypeId; + const key = buildTripleKey({ + sourceId: mappedSourceId, + relationshipTypeId: mappedRelationTypeId, + destinationId: mappedDestinationId, + }); + if (localTripleKeys.has(key)) { + existingDiscourseRelationIds.add(relation.id); + } + } + + const existingTemplateNames = new Set(); + for (const template of schemaFile.templates) { + if (localTemplateNames.has(template.name)) { + existingTemplateNames.add(template.name); + } + } + + return { + nodeTypeIdMapping, + relationTypeIdMapping, + existingNodeTypeIds, + existingRelationTypeIds, + existingDiscourseRelationIds, + existingTemplateNames, + }; +}; + +const buildPreviewStats = ({ + schemaFile, + matchPlan, +}: { + schemaFile: DiscourseSchemaFile; + matchPlan: SchemaImportMatchPlan; +}): ImportPreviewStats => { + return { + nodeTypes: { + total: schemaFile.nodeTypes.length, + existing: matchPlan.existingNodeTypeIds.size, + new: schemaFile.nodeTypes.length - matchPlan.existingNodeTypeIds.size, + }, + relationTypes: { + total: schemaFile.relationTypes.length, + existing: matchPlan.existingRelationTypeIds.size, + new: + schemaFile.relationTypes.length - + matchPlan.existingRelationTypeIds.size, + }, + discourseRelations: { + total: schemaFile.discourseRelations.length, + existing: matchPlan.existingDiscourseRelationIds.size, + new: + schemaFile.discourseRelations.length - + matchPlan.existingDiscourseRelationIds.size, + }, + templates: { + total: schemaFile.templates.length, + existing: matchPlan.existingTemplateNames.size, + new: schemaFile.templates.length - matchPlan.existingTemplateNames.size, + }, + }; +}; + +export const pickAndPreviewSchemaImport = async ({ + plugin, +}: { + plugin: DiscourseGraphPlugin; +}): Promise => { + const file = await openJsonFromUserLocation({ + title: "Import discourse graph schema", + }); + const schemaFile = parseDgSchemaFile(JSON.parse(file.content) as unknown); + const localTemplateNames = new Set(getTemplateFiles(plugin.app)); + const matchPlan = buildSchemaImportMatchPlan({ + schemaFile, + localNodeTypes: plugin.settings.nodeTypes, + localRelationTypes: plugin.settings.relationTypes, + localDiscourseRelations: plugin.settings.discourseRelations, + localTemplateNames, + }); + + const loadedSchemaFile: LoadedSchemaFile = { + sourcePath: file.sourcePath, + schemaFile, + matchPlan, + }; + + return { + loadedSchemaFile, + previewStats: buildPreviewStats({ schemaFile, matchPlan }), + }; +}; + +export const applySchemaImportSelection = async ({ + plugin, + loadedSchemaFile, + selection, +}: { + plugin: DiscourseGraphPlugin; + loadedSchemaFile: LoadedSchemaFile; + selection: SpecImportSelection; +}): Promise => { + const warnings: string[] = []; + const { schemaFile, matchPlan } = loadedSchemaFile; + const selectedTemplateNames = new Set(selection.templateNames); + const selectedNodeTypeIds = new Set(selection.nodeTypeIds); + const selectedRelationTypeIds = new Set(selection.relationTypeIds); + const selectedRelationIds = new Set(selection.discourseRelationIds); + + let templatesCreated = 0; + const templatesByName = new Map( + schemaFile.templates.map((template) => [template.name, template]), + ); + for (const templateName of selectedTemplateNames) { + if (matchPlan.existingTemplateNames.has(templateName)) { + continue; + } + + const template = templatesByName.get(templateName); + if (!template) { + warnings.push( + `Template "${templateName}" was selected but not found in schema file.`, + ); + continue; + } + + const result = await createTemplateFile({ + app: plugin.app, + templateName: template.name, + content: template.content, + }); + + if (result.created) { + templatesCreated += 1; + continue; + } + + if (result.reason !== "template already exists") { + warnings.push(`Template "${template.name}" skipped: ${result.reason}.`); + } + } + + const schemaNodeTypesById = new Map( + schemaFile.nodeTypes.map((nodeType) => [nodeType.id, nodeType]), + ); + const schemaRelationTypesById = new Map( + schemaFile.relationTypes.map((relationType) => [ + relationType.id, + relationType, + ]), + ); + + let nodeTypesCreated = 0; + for (const nodeTypeId of selectedNodeTypeIds) { + if (matchPlan.existingNodeTypeIds.has(nodeTypeId)) { + continue; + } + + const importedNodeType = schemaNodeTypesById.get(nodeTypeId); + if (!importedNodeType) { + warnings.push( + `Node type "${nodeTypeId}" was selected but missing from schema file.`, + ); + continue; + } + + const newNodeType: DiscourseNode = { + ...importedNodeType, + template: + importedNodeType.template && + selectedTemplateNames.has(importedNodeType.template) + ? importedNodeType.template + : undefined, + modified: Date.now(), + }; + plugin.settings.nodeTypes = [...plugin.settings.nodeTypes, newNodeType]; + nodeTypesCreated += 1; + } + + let relationTypesCreated = 0; + for (const relationTypeId of selectedRelationTypeIds) { + if (matchPlan.existingRelationTypeIds.has(relationTypeId)) { + continue; + } + + const importedRelationType = schemaRelationTypesById.get(relationTypeId); + if (!importedRelationType) { + warnings.push( + `Relation type "${relationTypeId}" was selected but missing from schema file.`, + ); + continue; + } + + const newRelationType: DiscourseRelationType = { + ...importedRelationType, + color: toTldrawColor(importedRelationType.color), + status: "provisional", + modified: Date.now(), + }; + plugin.settings.relationTypes = [ + ...plugin.settings.relationTypes, + newRelationType, + ]; + relationTypesCreated += 1; + } + + let discourseRelationsCreated = 0; + for (const relation of schemaFile.discourseRelations) { + if (!selectedRelationIds.has(relation.id)) { + continue; + } + if (matchPlan.existingDiscourseRelationIds.has(relation.id)) { + continue; + } + + const mappedSourceId = + matchPlan.nodeTypeIdMapping.get(relation.sourceId) ?? relation.sourceId; + const mappedDestinationId = + matchPlan.nodeTypeIdMapping.get(relation.destinationId) ?? + relation.destinationId; + const mappedRelationTypeId = + matchPlan.relationTypeIdMapping.get(relation.relationshipTypeId) ?? + relation.relationshipTypeId; + + const newRelation: DiscourseRelation = { + ...relation, + id: uuidv7(), + sourceId: mappedSourceId, + destinationId: mappedDestinationId, + relationshipTypeId: mappedRelationTypeId, + status: "provisional", + modified: Date.now(), + }; + plugin.settings.discourseRelations = [ + ...plugin.settings.discourseRelations, + newRelation, + ]; + discourseRelationsCreated += 1; + } + + await plugin.saveSettings(); + + return { + created: { + nodeTypes: nodeTypesCreated, + relationTypes: relationTypesCreated, + discourseRelations: discourseRelationsCreated, + templates: templatesCreated, + }, + warnings, + }; +}; From 1f312fdc09ea2e8febf943843bac263fffdcaa34 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Mon, 29 Jun 2026 10:29:26 -0400 Subject: [PATCH 2/2] fix: preserve template reference on imported node types when template already exists locally Co-authored-by: Cursor --- apps/obsidian/src/utils/specImport.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/obsidian/src/utils/specImport.ts b/apps/obsidian/src/utils/specImport.ts index 2335b110e..88914d57a 100644 --- a/apps/obsidian/src/utils/specImport.ts +++ b/apps/obsidian/src/utils/specImport.ts @@ -332,7 +332,8 @@ export const applySchemaImportSelection = async ({ ...importedNodeType, template: importedNodeType.template && - selectedTemplateNames.has(importedNodeType.template) + (selectedTemplateNames.has(importedNodeType.template) || + matchPlan.existingTemplateNames.has(importedNodeType.template)) ? importedNodeType.template : undefined, modified: Date.now(),