From 10c060fc8ddeff919f19dcf112eb1422251ee8cc Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 24 Jun 2026 11:13:50 -0400 Subject: [PATCH 1/5] FEE-840 add Obsidian schema export/import modal flow Add a shared schema selection UI for export and import, support native save/open dialog flows, and implement import apply logic with ID/name matching plus provisional relation schema creation. Co-authored-by: Cursor --- .../src/components/ExportSpecsModal.tsx | 319 ++++++++ .../src/components/GeneralSettings.tsx | 43 ++ .../src/components/ImportSpecsModal.tsx | 413 +++++++++++ .../src/components/SchemaSelectionPanel.tsx | 320 ++++++++ apps/obsidian/src/utils/registerCommands.ts | 18 + apps/obsidian/src/utils/specArchive.ts | 85 +++ apps/obsidian/src/utils/specExport.ts | 397 ++++++++++ apps/obsidian/src/utils/specImport.ts | 701 ++++++++++++++++++ 8 files changed, 2296 insertions(+) create mode 100644 apps/obsidian/src/components/ExportSpecsModal.tsx create mode 100644 apps/obsidian/src/components/ImportSpecsModal.tsx create mode 100644 apps/obsidian/src/components/SchemaSelectionPanel.tsx create mode 100644 apps/obsidian/src/utils/specArchive.ts create mode 100644 apps/obsidian/src/utils/specExport.ts create mode 100644 apps/obsidian/src/utils/specImport.ts diff --git a/apps/obsidian/src/components/ExportSpecsModal.tsx b/apps/obsidian/src/components/ExportSpecsModal.tsx new file mode 100644 index 000000000..90779878d --- /dev/null +++ b/apps/obsidian/src/components/ExportSpecsModal.tsx @@ -0,0 +1,319 @@ +import { App, Modal, Notice } from "obsidian"; +import { StrictMode, useEffect, useMemo, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import type DiscourseGraphPlugin from "~/index"; +import { + exportSchemaSelectionToVault, + ExportSaveCancelledError, +} from "~/utils/specExport"; +import { getDgSchemaFileName } from "~/utils/specArchive"; +import { getTemplateFiles } from "~/utils/templates"; +import { SchemaSelectionPanel } from "~/components/SchemaSelectionPanel"; + +type ExportSpecsModalProps = { + plugin: DiscourseGraphPlugin; + onClose: () => void; +}; + +const getAllNodeTypeIds = (plugin: DiscourseGraphPlugin): string[] => { + return plugin.settings.nodeTypes.map((nodeType) => nodeType.id); +}; + +const getAllRelationTypeIds = (plugin: DiscourseGraphPlugin): string[] => { + return plugin.settings.relationTypes.map((relationType) => relationType.id); +}; + +const getAllRelationIds = (plugin: DiscourseGraphPlugin): string[] => { + return plugin.settings.discourseRelations.map((relation) => relation.id); +}; + +const getReferencedTemplateNames = ( + nodeTypes: DiscourseGraphPlugin["settings"]["nodeTypes"], +): Set => { + return new Set( + nodeTypes + .map((nodeType) => nodeType.template) + .filter((template): template is string => !!template), + ); +}; + +export const openExportSpecsModal = (plugin: DiscourseGraphPlugin): void => { + new ExportSpecsModal(plugin.app, plugin).open(); +}; + +const ExportSpecsContent = ({ plugin, onClose }: ExportSpecsModalProps) => { + const [selectedNodeTypeIds, setSelectedNodeTypeIds] = useState>( + () => new Set(getAllNodeTypeIds(plugin)), + ); + const [selectedRelationTypeIds, setSelectedRelationTypeIds] = useState< + Set + >(() => new Set(getAllRelationTypeIds(plugin))); + const [selectedRelationIds, setSelectedRelationIds] = useState>( + () => new Set(getAllRelationIds(plugin)), + ); + const [selectedTemplateNames, setSelectedTemplateNames] = useState< + Set + >(() => getReferencedTemplateNames(plugin.settings.nodeTypes)); + const [isExporting, setIsExporting] = useState(false); + const outputFileName = getDgSchemaFileName(plugin.app.vault.getName()); + + const templateNames = useMemo(() => { + return getTemplateFiles(plugin.app); + }, [plugin.app]); + + const requiredRelationTypeIds = useMemo(() => { + const requiredIds = new Set(); + for (const relation of plugin.settings.discourseRelations) { + if (selectedRelationIds.has(relation.id)) { + requiredIds.add(relation.relationshipTypeId); + } + } + return requiredIds; + }, [plugin.settings.discourseRelations, selectedRelationIds]); + + const requiredNodeTypeIds = useMemo(() => { + const requiredIds = new Set(); + for (const relation of plugin.settings.discourseRelations) { + if (!selectedRelationIds.has(relation.id)) continue; + requiredIds.add(relation.sourceId); + requiredIds.add(relation.destinationId); + } + return requiredIds; + }, [plugin.settings.discourseRelations, selectedRelationIds]); + + useEffect(() => { + setSelectedRelationTypeIds((previousSet) => { + const nextSet = new Set(previousSet); + let didChange = false; + for (const relationTypeId of requiredRelationTypeIds) { + if (!nextSet.has(relationTypeId)) { + nextSet.add(relationTypeId); + didChange = true; + } + } + return didChange ? nextSet : previousSet; + }); + }, [requiredRelationTypeIds]); + + useEffect(() => { + setSelectedNodeTypeIds((previousSet) => { + const nextSet = new Set(previousSet); + let didChange = false; + for (const nodeTypeId of requiredNodeTypeIds) { + if (!nextSet.has(nodeTypeId)) { + nextSet.add(nodeTypeId); + didChange = true; + } + } + return didChange ? nextSet : previousSet; + }); + }, [requiredNodeTypeIds]); + + const updateSet = ( + previousSet: Set, + id: string, + shouldSelect: boolean, + ): Set => { + const nextSet = new Set(previousSet); + if (shouldSelect) { + nextSet.add(id); + } else { + nextSet.delete(id); + } + return nextSet; + }; + + const toggleNodeType = (nodeTypeId: string, shouldSelect: boolean): void => { + if (!shouldSelect && requiredNodeTypeIds.has(nodeTypeId)) { + new Notice( + "This node type is required by a selected relation triple. Remove the triple first.", + ); + return; + } + setSelectedNodeTypeIds((previousSet) => + updateSet(previousSet, nodeTypeId, shouldSelect), + ); + }; + + const toggleRelationType = ( + relationTypeId: string, + shouldSelect: boolean, + ): void => { + if (!shouldSelect && requiredRelationTypeIds.has(relationTypeId)) { + new Notice( + "This relation type is required by a selected relation triple. Remove the triple first.", + ); + return; + } + setSelectedRelationTypeIds((previousSet) => + updateSet(previousSet, relationTypeId, shouldSelect), + ); + }; + + const toggleRelationTriple = ( + relationId: string, + shouldSelect: boolean, + ): void => { + setSelectedRelationIds((previousSet) => + updateSet(previousSet, relationId, shouldSelect), + ); + }; + + const toggleTemplate = ( + templateName: string, + shouldSelect: boolean, + ): void => { + setSelectedTemplateNames((previousSet) => + updateSet(previousSet, templateName, shouldSelect), + ); + }; + + const handleExport = async (): Promise => { + const hasSelection = + selectedNodeTypeIds.size > 0 || + selectedRelationTypeIds.size > 0 || + selectedRelationIds.size > 0 || + selectedTemplateNames.size > 0; + if (!hasSelection) { + new Notice("Select at least one schema item or template to export."); + return; + } + + setIsExporting(true); + try { + const result = await exportSchemaSelectionToVault({ + plugin, + selection: { + nodeTypeIds: [...selectedNodeTypeIds], + relationTypeIds: [...selectedRelationTypeIds], + discourseRelationIds: [...selectedRelationIds], + templateNames: [...selectedTemplateNames], + }, + }); + + const autoIncludedCount = + result.dependencySummary.autoIncludedNodeTypeIds.length + + result.dependencySummary.autoIncludedRelationTypeIds.length; + const warningSuffix = + result.warnings.length > 0 + ? ` (${result.warnings.length} warning${result.warnings.length === 1 ? "" : "s"})` + : ""; + + new Notice( + `Exported schema to ${result.filePath}${warningSuffix}${ + autoIncludedCount > 0 + ? ` with ${autoIncludedCount} auto-included dependency item(s).` + : "." + }`, + 6000, + ); + + if (result.warnings.length > 0) { + for (const warning of result.warnings) { + new Notice(warning, 6000); + } + } + + onClose(); + } catch (error) { + if (error instanceof ExportSaveCancelledError) { + return; + } + console.error("Failed to export schema:", error); + const message = error instanceof Error ? error.message : String(error); + new Notice(`Schema export failed: ${message}`, 6000); + } finally { + setIsExporting(false); + } + }; + + return ( +
+

Export discourse graph schema

+

+ Select the node types, relation types, relation triples, and templates + to include in {outputFileName}. +

+ + + setSelectedNodeTypeIds(new Set(getAllNodeTypeIds(plugin))) + } + onDeselectOptionalNodeTypes={() => + setSelectedNodeTypeIds(new Set([...requiredNodeTypeIds])) + } + onToggleNodeType={toggleNodeType} + onSelectAllRelationTypes={() => + setSelectedRelationTypeIds(new Set(getAllRelationTypeIds(plugin))) + } + onDeselectOptionalRelationTypes={() => + setSelectedRelationTypeIds(new Set([...requiredRelationTypeIds])) + } + onToggleRelationType={toggleRelationType} + onSelectAllRelationTriples={() => + setSelectedRelationIds(new Set(getAllRelationIds(plugin))) + } + onDeselectAllRelationTriples={() => setSelectedRelationIds(new Set())} + onToggleRelationTriple={toggleRelationTriple} + onSelectAllTemplates={() => + setSelectedTemplateNames(new Set(templateNames)) + } + onDeselectAllTemplates={() => setSelectedTemplateNames(new Set())} + onToggleTemplate={toggleTemplate} + emptyTemplateText="No templates found in your Templates folder." + /> + +
+ + +
+
+ ); +}; + +export class ExportSpecsModal extends Modal { + private plugin: DiscourseGraphPlugin; + private root: Root | null = null; + + constructor(app: App, plugin: DiscourseGraphPlugin) { + super(app); + this.plugin = plugin; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + this.root = createRoot(contentEl); + this.root.render( + + this.close()} /> + , + ); + } + + onClose(): void { + if (this.root) { + this.root.unmount(); + this.root = null; + } + } +} diff --git a/apps/obsidian/src/components/GeneralSettings.tsx b/apps/obsidian/src/components/GeneralSettings.tsx index 9666a1217..79da34b62 100644 --- a/apps/obsidian/src/components/GeneralSettings.tsx +++ b/apps/obsidian/src/components/GeneralSettings.tsx @@ -3,6 +3,9 @@ import { usePlugin } from "./PluginContext"; 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/specArchive"; const DOCS_URL = "https://discoursegraphs.com/docs/obsidian"; const COMMUNITY_URL = @@ -148,6 +151,7 @@ const GeneralSettings = () => { const [nodeTagHotkey, setNodeTagHotkey] = useState( plugin.settings.nodeTagHotkey, ); + const schemaFileName = getDgSchemaFileName(plugin.app.vault.getName()); const handleToggleChange = (newValue: boolean) => { setShowIdsInFrontmatter(newValue); @@ -270,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
@@ -298,6 +322,25 @@ const GeneralSettings = () => {
+
+
+
Export discourse graph schema
+
+ Export selected node types, relation types, relation triples, and + templates to a JSON file named {schemaFileName}. +
+
+
+ +
+
+ ); diff --git a/apps/obsidian/src/components/ImportSpecsModal.tsx b/apps/obsidian/src/components/ImportSpecsModal.tsx new file mode 100644 index 000000000..96b02866c --- /dev/null +++ b/apps/obsidian/src/components/ImportSpecsModal.tsx @@ -0,0 +1,413 @@ +import { App, Modal, Notice } from "obsidian"; +import { StrictMode, useEffect, useMemo, useState } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import type DiscourseGraphPlugin from "~/index"; +import { + applySchemaImportSelection, + ImportFileSelectionCancelledError, + pickAndPreviewSchemaImport, + type SpecImportSelection, + type SpecImportPreview, +} from "~/utils/specImport"; +import { SchemaSelectionPanel } from "~/components/SchemaSelectionPanel"; + +type ImportSpecsModalProps = { + plugin: DiscourseGraphPlugin; + onClose: () => void; +}; + +const getNodeTypeIdsFromPreview = (preview: SpecImportPreview): string[] => { + return preview.archive.nodeTypes.map((nodeType) => nodeType.id); +}; + +const getRelationTypeIdsFromPreview = ( + preview: SpecImportPreview, +): string[] => { + return preview.archive.relationTypes.map((relationType) => relationType.id); +}; + +const getRelationIdsFromPreview = (preview: SpecImportPreview): string[] => { + return preview.archive.discourseRelations.map((relation) => relation.id); +}; + +const getTemplateNamesFromPreview = (preview: SpecImportPreview): string[] => { + return preview.archive.templates.map((template) => template.name); +}; + +export const openImportSpecsModal = (plugin: DiscourseGraphPlugin): void => { + new ImportSpecsModal(plugin.app, plugin).open(); +}; + +const ImportSpecsContent = ({ plugin, onClose }: ImportSpecsModalProps) => { + const [preview, setPreview] = useState(null); + const [isSelectingFile, setIsSelectingFile] = useState(false); + const [selectedNodeTypeIds, setSelectedNodeTypeIds] = useState>( + new Set(), + ); + const [selectedRelationTypeIds, setSelectedRelationTypeIds] = useState< + Set + >(new Set()); + const [selectedRelationIds, setSelectedRelationIds] = useState>( + new Set(), + ); + const [selectedTemplateNames, setSelectedTemplateNames] = useState< + Set + >(new Set()); + const [isApplyingImport, setIsApplyingImport] = useState(false); + + useEffect(() => { + if (!preview) { + setSelectedNodeTypeIds(new Set()); + setSelectedRelationTypeIds(new Set()); + setSelectedRelationIds(new Set()); + setSelectedTemplateNames(new Set()); + return; + } + setSelectedNodeTypeIds(new Set(getNodeTypeIdsFromPreview(preview))); + setSelectedRelationTypeIds(new Set(getRelationTypeIdsFromPreview(preview))); + setSelectedRelationIds(new Set(getRelationIdsFromPreview(preview))); + setSelectedTemplateNames(new Set(getTemplateNamesFromPreview(preview))); + }, [preview]); + + const requiredRelationTypeIds = useMemo(() => { + if (!preview) return new Set(); + const required = new Set(); + for (const relation of preview.archive.discourseRelations) { + if (selectedRelationIds.has(relation.id)) { + required.add(relation.relationshipTypeId); + } + } + return required; + }, [preview, selectedRelationIds]); + + const requiredNodeTypeIds = useMemo(() => { + if (!preview) return new Set(); + const required = new Set(); + for (const relation of preview.archive.discourseRelations) { + if (!selectedRelationIds.has(relation.id)) continue; + required.add(relation.sourceId); + required.add(relation.destinationId); + } + return required; + }, [preview, selectedRelationIds]); + + useEffect(() => { + if (!preview) return; + setSelectedRelationTypeIds((previousSet) => { + const nextSet = new Set(previousSet); + let didChange = false; + for (const relationTypeId of requiredRelationTypeIds) { + if (!nextSet.has(relationTypeId)) { + nextSet.add(relationTypeId); + didChange = true; + } + } + return didChange ? nextSet : previousSet; + }); + }, [preview, requiredRelationTypeIds]); + + useEffect(() => { + if (!preview) return; + setSelectedNodeTypeIds((previousSet) => { + const nextSet = new Set(previousSet); + let didChange = false; + for (const nodeTypeId of requiredNodeTypeIds) { + if (!nextSet.has(nodeTypeId)) { + nextSet.add(nodeTypeId); + didChange = true; + } + } + return didChange ? nextSet : previousSet; + }); + }, [preview, requiredNodeTypeIds]); + + const updateSet = ( + previousSet: Set, + id: string, + shouldSelect: boolean, + ): Set => { + const nextSet = new Set(previousSet); + if (shouldSelect) { + nextSet.add(id); + } else { + nextSet.delete(id); + } + return nextSet; + }; + + const handleSelectSchemaFile = async (): Promise => { + setIsSelectingFile(true); + try { + const nextPreview = await pickAndPreviewSchemaImport({ plugin }); + setPreview(nextPreview); + } catch (error) { + if (error instanceof ImportFileSelectionCancelledError) { + 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); + } + }; + + const buildSelection = (): SpecImportSelection => { + return { + nodeTypeIds: [...selectedNodeTypeIds], + relationTypeIds: [...selectedRelationTypeIds], + discourseRelationIds: [...selectedRelationIds], + templateNames: [...selectedTemplateNames], + }; + }; + + const handleApplyImport = async (): Promise => { + if (!preview) { + return; + } + const selection = buildSelection(); + const hasAnySelection = + selection.nodeTypeIds.length > 0 || + selection.relationTypeIds.length > 0 || + selection.discourseRelationIds.length > 0 || + selection.templateNames.length > 0; + if (!hasAnySelection) { + new Notice("Select at least one item to import."); + return; + } + + setIsApplyingImport(true); + try { + const result = await applySchemaImportSelection({ + plugin, + preview, + selection, + }); + + new Notice( + `Import complete: ${result.nodeTypes.created} node type(s), ${result.relationTypes.created} relation type(s), ${result.discourseRelations.created} relation triple(s), and ${result.templates.created} 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); + } + }; + + if (!preview) { + return ( +
+

Import discourse graph schema

+

+ Pick a dg-schema-*.json file from your computer to + preview and choose exactly what to import. +

+ +
+ This slice is preview + selection only. Apply import writes are next. +
+ +
+ + +
+
+ ); + } + + return ( +
+

Import schema preview

+

+ Source file: {preview.sourcePath} +

+

+ Same dependency rules as export: selected relation triples require their + relation type and endpoint node types. +

+ +
+
Archive metadata
+
+ Vault:{" "} + {preview.archive.vaultName} +
+
+ Exported at:{" "} + {preview.archive.exportedAt} +
+
+ Plugin version:{" "} + {preview.archive.pluginVersion} +
+
+ + template.name, + )} + selectedNodeTypeIds={selectedNodeTypeIds} + selectedRelationTypeIds={selectedRelationTypeIds} + selectedRelationIds={selectedRelationIds} + selectedTemplateNames={selectedTemplateNames} + requiredNodeTypeIds={requiredNodeTypeIds} + requiredRelationTypeIds={requiredRelationTypeIds} + onSelectAllNodeTypes={() => + setSelectedNodeTypeIds(new Set(getNodeTypeIdsFromPreview(preview))) + } + onDeselectOptionalNodeTypes={() => + setSelectedNodeTypeIds(new Set([...requiredNodeTypeIds])) + } + onToggleNodeType={(nodeTypeId, shouldSelect) => { + if (!shouldSelect && requiredNodeTypeIds.has(nodeTypeId)) { + new Notice( + "This node type is required by a selected relation triple. Remove the triple first.", + ); + return; + } + setSelectedNodeTypeIds((previousSet) => + updateSet(previousSet, nodeTypeId, shouldSelect), + ); + }} + onSelectAllRelationTypes={() => + setSelectedRelationTypeIds( + new Set(getRelationTypeIdsFromPreview(preview)), + ) + } + onDeselectOptionalRelationTypes={() => + setSelectedRelationTypeIds(new Set([...requiredRelationTypeIds])) + } + onToggleRelationType={(relationTypeId, shouldSelect) => { + if (!shouldSelect && requiredRelationTypeIds.has(relationTypeId)) { + new Notice( + "This relation type is required by a selected relation triple. Remove the triple first.", + ); + return; + } + setSelectedRelationTypeIds((previousSet) => + updateSet(previousSet, relationTypeId, shouldSelect), + ); + }} + onSelectAllRelationTriples={() => + setSelectedRelationIds(new Set(getRelationIdsFromPreview(preview))) + } + onDeselectAllRelationTriples={() => setSelectedRelationIds(new Set())} + onToggleRelationTriple={(relationId, shouldSelect) => + setSelectedRelationIds((previousSet) => + updateSet(previousSet, relationId, shouldSelect), + ) + } + onSelectAllTemplates={() => + setSelectedTemplateNames( + new Set(getTemplateNamesFromPreview(preview)), + ) + } + onDeselectAllTemplates={() => setSelectedTemplateNames(new Set())} + onToggleTemplate={(templateName, shouldSelect) => + setSelectedTemplateNames((previousSet) => + updateSet(previousSet, templateName, shouldSelect), + ) + } + emptyTemplateText="No templates found in this schema file." + /> + +
+
Current preview stats (full archive)
+
+ Node types: {preview.nodeTypes.total} total ( + {preview.nodeTypes.newCount} new, {preview.nodeTypes.matchedById} ID + matches, {preview.nodeTypes.matchedByName} name matches) +
+
+ Relation types: {preview.relationTypes.total} total ( + {preview.relationTypes.newCount} new,{" "} + {preview.relationTypes.matchedById} ID matches,{" "} + {preview.relationTypes.matchedByLabel} label matches) +
+
+ Relation triples: {preview.discourseRelations.total} total ( + {preview.discourseRelations.newCount} new,{" "} + {preview.discourseRelations.existingCount} existing) +
+
+ Templates: {preview.templates.total} total ( + {preview.templates.newCount} new, {preview.templates.existingCount}{" "} + existing) +
+
+ +
+ + +
+
+ ); +}; + +export class ImportSpecsModal extends Modal { + private plugin: DiscourseGraphPlugin; + private root: Root | null = null; + + constructor(app: App, plugin: DiscourseGraphPlugin) { + super(app); + this.plugin = plugin; + } + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + this.root = createRoot(contentEl); + this.root.render( + + this.close()} /> + , + ); + } + + onClose(): void { + if (this.root) { + this.root.unmount(); + this.root = null; + } + } +} diff --git a/apps/obsidian/src/components/SchemaSelectionPanel.tsx b/apps/obsidian/src/components/SchemaSelectionPanel.tsx new file mode 100644 index 000000000..c6fee8a98 --- /dev/null +++ b/apps/obsidian/src/components/SchemaSelectionPanel.tsx @@ -0,0 +1,320 @@ +type SchemaNodeTypeLike = { + id: string; + name: string; + template?: string; +}; + +type SchemaRelationTypeLike = { + id: string; + label: string; +}; + +type SchemaRelationTripleLike = { + id: string; + sourceId: string; + destinationId: string; + relationshipTypeId: string; +}; + +type SchemaSelectionPanelProps = { + nodeTypes: SchemaNodeTypeLike[]; + relationTypes: SchemaRelationTypeLike[]; + relationTriples: SchemaRelationTripleLike[]; + templateNames: string[]; + selectedNodeTypeIds: Set; + selectedRelationTypeIds: Set; + selectedRelationIds: Set; + selectedTemplateNames: Set; + requiredNodeTypeIds: Set; + requiredRelationTypeIds: Set; + onSelectAllNodeTypes: () => void; + onDeselectOptionalNodeTypes: () => void; + onToggleNodeType: (nodeTypeId: string, shouldSelect: boolean) => void; + onSelectAllRelationTypes: () => void; + onDeselectOptionalRelationTypes: () => void; + onToggleRelationType: (relationTypeId: string, shouldSelect: boolean) => void; + onSelectAllRelationTriples: () => void; + onDeselectAllRelationTriples: () => void; + onToggleRelationTriple: (relationId: string, shouldSelect: boolean) => void; + onSelectAllTemplates: () => void; + onDeselectAllTemplates: () => void; + onToggleTemplate: (templateName: string, shouldSelect: boolean) => void; + emptyTemplateText: string; +}; + +export const SchemaSelectionPanel = ({ + nodeTypes, + relationTypes, + relationTriples, + templateNames, + selectedNodeTypeIds, + selectedRelationTypeIds, + selectedRelationIds, + selectedTemplateNames, + requiredNodeTypeIds, + requiredRelationTypeIds, + onSelectAllNodeTypes, + onDeselectOptionalNodeTypes, + onToggleNodeType, + onSelectAllRelationTypes, + onDeselectOptionalRelationTypes, + onToggleRelationType, + onSelectAllRelationTriples, + onDeselectAllRelationTriples, + onToggleRelationTriple, + onSelectAllTemplates, + onDeselectAllTemplates, + onToggleTemplate, + emptyTemplateText, +}: SchemaSelectionPanelProps) => { + const nodeTypeById = new Map( + nodeTypes.map((nodeType) => [nodeType.id, nodeType]), + ); + const relationTypeById = new Map( + relationTypes.map((relationType) => [relationType.id, relationType]), + ); + const templateToNodeTypeNames = new Map(); + for (const nodeType of nodeTypes) { + if (!nodeType.template) continue; + const current = templateToNodeTypeNames.get(nodeType.template) ?? []; + current.push(nodeType.name); + templateToNodeTypeNames.set(nodeType.template, current); + } + for (const [ + templateName, + nodeTypeNames, + ] of templateToNodeTypeNames.entries()) { + templateToNodeTypeNames.set( + templateName, + [...new Set(nodeTypeNames)].sort((left, right) => + left.localeCompare(right), + ), + ); + } + const referencedTemplateNames = new Set(templateToNodeTypeNames.keys()); + + return ( + <> +
+
Selection summary
+
+ {selectedNodeTypeIds.size} node type(s) + {selectedRelationTypeIds.size} relation type(s) + {selectedRelationIds.size} relation triple(s) + {selectedTemplateNames.size} template(s) +
+
+ +
+
+
+

Node types

+
+ + +
+
+
+ {nodeTypes.map((nodeType) => { + const isRequired = requiredNodeTypeIds.has(nodeType.id); + return ( + + ); + })} +
+
+ +
+
+

Relation types

+
+ + +
+
+
+ {relationTypes.map((relationType) => { + const isRequired = requiredRelationTypeIds.has(relationType.id); + return ( + + ); + })} +
+
+ +
+
+

Relation triples

+
+ + +
+
+
+ {relationTriples.map((relation) => { + const sourceName = + nodeTypeById.get(relation.sourceId)?.name ?? relation.sourceId; + const destinationName = + nodeTypeById.get(relation.destinationId)?.name ?? + relation.destinationId; + const relationTypeLabel = + relationTypeById.get(relation.relationshipTypeId)?.label ?? + relation.relationshipTypeId; + + return ( + + ); + })} +
+
+ +
+
+

Templates

+
+ + +
+
+ {templateNames.length === 0 ? ( +

{emptyTemplateText}

+ ) : ( +
+ {templateNames.map((templateName) => ( + + ))} +
+ )} +
+
+ + ); +}; diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts index ea7e019f6..2f7716d6f 100644 --- a/apps/obsidian/src/utils/registerCommands.ts +++ b/apps/obsidian/src/utils/registerCommands.ts @@ -4,6 +4,8 @@ import { NodeTypeModal } from "~/components/NodeTypeModal"; import ModifyNodeModal from "~/components/ModifyNodeModal"; import { BulkIdentifyDiscourseNodesModal } from "~/components/BulkIdentifyDiscourseNodesModal"; import { ImportNodesModal } from "~/components/ImportNodesModal"; +import { openExportSpecsModal } from "~/components/ExportSpecsModal"; +import { openImportSpecsModal } from "~/components/ImportSpecsModal"; import { convertPageToDiscourseNode, createDiscourseNode } from "./createNode"; import { refreshAllImportedFiles } from "./importNodes"; import { VIEW_TYPE_MARKDOWN, VIEW_TYPE_TLDRAW_DG_PREVIEW } from "~/constants"; @@ -194,6 +196,22 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => { }, }); + plugin.addCommand({ + id: "export-dg-schema", + name: "Export discourse graph schema", + 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", diff --git a/apps/obsidian/src/utils/specArchive.ts b/apps/obsidian/src/utils/specArchive.ts new file mode 100644 index 000000000..44f475cb8 --- /dev/null +++ b/apps/obsidian/src/utils/specArchive.ts @@ -0,0 +1,85 @@ +import { z } from "zod"; + +export const DG_SCHEMA_EXPORT_VERSION = 1; + +const discourseNodeSchema = z.object({ + id: z.string(), + name: z.string(), + format: z.string(), + template: z.string().optional(), + description: z.string().optional(), + shortcut: z.string().optional(), + color: z.string().optional(), + tag: z.string().optional(), + keyImage: z.boolean().optional(), + folderPath: z.string().optional(), + created: z.number(), + modified: z.number(), + importedFromRid: z.string().optional(), + authorId: z.number().optional(), +}); + +const relationImportStatusSchema = z.enum(["provisional", "accepted"]); + +const discourseRelationTypeSchema = z.object({ + id: z.string(), + label: z.string(), + complement: z.string(), + color: z.string(), + created: z.number(), + modified: z.number(), + importedFromRid: z.string().optional(), + status: relationImportStatusSchema.optional(), + authorId: z.number().optional(), +}); + +const discourseRelationSchema = z.object({ + id: z.string(), + sourceId: z.string(), + destinationId: z.string(), + relationshipTypeId: z.string(), + created: z.number(), + modified: z.number(), + importedFromRid: z.string().optional(), + status: relationImportStatusSchema.optional(), + authorId: z.number().optional(), +}); + +const templateExportSchema = z.object({ + name: z.string(), + content: z.string(), +}); + +export const dgSchemaArchiveSchema = z.object({ + version: z.literal(DG_SCHEMA_EXPORT_VERSION), + exportedAt: z.string(), + pluginVersion: z.string(), + vaultName: z.string(), + nodeTypes: z.array(discourseNodeSchema), + relationTypes: z.array(discourseRelationTypeSchema), + discourseRelations: z.array(discourseRelationSchema), + templates: z.array(templateExportSchema), +}); + +export type DgSchemaArchive = z.infer; +export type TemplateExportRecord = z.infer; + +const normalizeToKebabCase = (value: string): string => { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .replace(/-{2,}/g, "-"); +}; + +export const getDgSchemaFileName = (vaultName?: string): string => { + const normalizedVaultName = vaultName ? normalizeToKebabCase(vaultName) : ""; + const safeVaultName = + normalizedVaultName.length > 0 ? normalizedVaultName : "vault"; + return `dg-schema-${safeVaultName}.json`; +}; + +export const parseDgSchemaArchive = (value: unknown): DgSchemaArchive => { + return dgSchemaArchiveSchema.parse(value); +}; diff --git a/apps/obsidian/src/utils/specExport.ts b/apps/obsidian/src/utils/specExport.ts new file mode 100644 index 000000000..516547b88 --- /dev/null +++ b/apps/obsidian/src/utils/specExport.ts @@ -0,0 +1,397 @@ +import { TFile } from "obsidian"; +import type DiscourseGraphPlugin from "~/index"; +import type { + DiscourseNode, + DiscourseRelation, + DiscourseRelationType, +} from "~/types"; +import { + DG_SCHEMA_EXPORT_VERSION, + getDgSchemaFileName, + parseDgSchemaArchive, + type DgSchemaArchive, + type TemplateExportRecord, +} from "~/utils/specArchive"; +import { getTemplatePluginInfo } from "~/utils/templates"; + +export type SpecExportSelection = { + nodeTypeIds: string[]; + relationTypeIds: string[]; + discourseRelationIds: string[]; + templateNames: string[]; +}; + +export type SpecExportDependencySummary = { + autoIncludedNodeTypeIds: string[]; + autoIncludedRelationTypeIds: string[]; +}; + +export type SpecExportResult = { + filePath: string; + payload: DgSchemaArchive; + dependencySummary: SpecExportDependencySummary; + warnings: string[]; +}; + +type BuildPayloadResult = { + payload: DgSchemaArchive; + dependencySummary: SpecExportDependencySummary; + warnings: string[]; +}; + +type SaveDialogResult = { + canceled: boolean; + filePath?: string; +}; + +type ElectronDialog = { + showSaveDialog: (options: { + title: string; + defaultPath: string; + filters: Array<{ name: string; extensions: string[] }>; + }) => Promise; +}; + +type ElectronLike = { + dialog?: ElectronDialog; + remote?: { + dialog?: ElectronDialog; + }; +}; + +type FsPromisesLike = { + writeFile: (path: string, data: string, encoding: string) => Promise; +}; + +class ExportSaveCancelledError extends Error { + constructor() { + super("Export cancelled"); + this.name = "ExportSaveCancelledError"; + } +} + +const asMap = (items: T[]): Map => { + return new Map(items.map((item) => [item.id, item])); +}; + +const isUserCancellationError = (error: unknown): boolean => { + return ( + error instanceof ExportSaveCancelledError || + (error instanceof Error && error.name === "AbortError") + ); +}; + +const isElectronDialog = (value: unknown): value is ElectronDialog => { + return ( + typeof value === "object" && + value !== null && + "showSaveDialog" in value && + typeof (value as { showSaveDialog: unknown }).showSaveDialog === "function" + ); +}; + +const getElectronDialog = (value: unknown): ElectronDialog | null => { + if (typeof value !== "object" || value === null) { + return null; + } + const electronLike = value as ElectronLike; + const directDialog = electronLike.dialog; + if (isElectronDialog(directDialog)) { + return directDialog; + } + const remoteDialog = electronLike.remote?.dialog; + if (isElectronDialog(remoteDialog)) { + return remoteDialog; + } + return null; +}; + +const saveWithFileSystemAccessApi = async ({ + fileName, + content, +}: { + fileName: string; + content: string; +}): Promise => { + if (typeof window === "undefined") { + return null; + } + + const picker = ( + window as Window & { + showSaveFilePicker?: (options: { + suggestedName: string; + types: Array<{ + description: string; + accept: Record; + }>; + }) => Promise<{ + name: string; + createWritable: () => Promise<{ + write: (data: string) => Promise; + close: () => Promise; + }>; + }>; + } + ).showSaveFilePicker; + + if (!picker) { + return null; + } + + try { + const fileHandle = await picker({ + suggestedName: fileName, + types: [ + { + description: "JSON files", + accept: { "application/json": [".json"] }, + }, + ], + }); + const writable = await fileHandle.createWritable(); + await writable.write(content); + await writable.close(); + return fileHandle.name; + } catch (error) { + if (isUserCancellationError(error)) { + throw new ExportSaveCancelledError(); + } + throw error; + } +}; + +const saveWithElectronDialog = async ({ + fileName, + content, +}: { + fileName: string; + content: string; +}): Promise => { + if (typeof window === "undefined") { + return null; + } + + const windowWithRequire = window as Window & { + require?: (name: string) => unknown; + }; + if (!windowWithRequire.require) { + return null; + } + + const electron = windowWithRequire.require("electron"); + const dialog = getElectronDialog(electron); + if (!dialog) { + return null; + } + + const result = await dialog.showSaveDialog({ + title: "Export discourse graph schema", + defaultPath: fileName, + filters: [{ name: "JSON files", extensions: ["json"] }], + }); + if (result.canceled || !result.filePath) { + throw new ExportSaveCancelledError(); + } + + const fsPromises = windowWithRequire.require("fs/promises"); + if ( + typeof fsPromises !== "object" || + fsPromises === null || + !("writeFile" in fsPromises) || + typeof (fsPromises as { writeFile: unknown }).writeFile !== "function" + ) { + throw new Error("Unable to access filesystem write API for export."); + } + const typedFsPromises = fsPromises as FsPromisesLike; + await typedFsPromises.writeFile(result.filePath, content, "utf8"); + return result.filePath; +}; + +const triggerBrowserDownload = ({ + fileName, + content, +}: { + fileName: string; + content: string; +}): string => { + const blob = new Blob([content], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement("a"); + anchor.href = url; + anchor.download = fileName; + anchor.style.display = "none"; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + URL.revokeObjectURL(url); + return fileName; +}; + +const getTemplateContents = async ({ + plugin, + templateNames, +}: { + plugin: DiscourseGraphPlugin; + templateNames: string[]; +}): Promise<{ templates: TemplateExportRecord[]; warnings: string[] }> => { + const warnings: string[] = []; + const templates: TemplateExportRecord[] = []; + const { isEnabled, folderPath } = getTemplatePluginInfo(plugin.app); + + if (!isEnabled || !folderPath) { + if (templateNames.length > 0) { + warnings.push( + "Templates plugin is not enabled or folder is not configured; template content was skipped.", + ); + } + return { templates, warnings }; + } + + for (const templateName of templateNames) { + const templatePath = `${folderPath}/${templateName}.md`; + const templateFile = plugin.app.vault.getAbstractFileByPath(templatePath); + + if (!(templateFile instanceof TFile)) { + warnings.push(`Template file not found: ${templateName}.md`); + continue; + } + + const content = await plugin.app.vault.read(templateFile); + templates.push({ name: templateName, content }); + } + + return { templates, warnings }; +}; + +export const buildSchemaExportPayload = async ({ + plugin, + selection, +}: { + plugin: DiscourseGraphPlugin; + selection: SpecExportSelection; +}): Promise => { + const nodeTypeMap = asMap(plugin.settings.nodeTypes); + const relationTypeMap = asMap(plugin.settings.relationTypes); + const discourseRelationMap = asMap(plugin.settings.discourseRelations); + + const selectedDiscourseRelations: DiscourseRelation[] = + selection.discourseRelationIds + .map((id) => discourseRelationMap.get(id)) + .filter((relation): relation is DiscourseRelation => !!relation); + + const dependencyRelationTypeIds = new Set(); + const dependencyNodeTypeIds = new Set(); + + for (const relation of selectedDiscourseRelations) { + dependencyRelationTypeIds.add(relation.relationshipTypeId); + dependencyNodeTypeIds.add(relation.sourceId); + dependencyNodeTypeIds.add(relation.destinationId); + } + + const selectedRelationTypeIds = new Set(selection.relationTypeIds); + for (const relationTypeId of dependencyRelationTypeIds) { + selectedRelationTypeIds.add(relationTypeId); + } + + const selectedNodeTypeIds = new Set(selection.nodeTypeIds); + for (const nodeTypeId of dependencyNodeTypeIds) { + selectedNodeTypeIds.add(nodeTypeId); + } + + const selectedNodeTypes: DiscourseNode[] = [...selectedNodeTypeIds] + .map((id) => nodeTypeMap.get(id)) + .filter((nodeType): nodeType is DiscourseNode => !!nodeType); + + const selectedRelationTypes: DiscourseRelationType[] = [ + ...selectedRelationTypeIds, + ] + .map((id) => relationTypeMap.get(id)) + .filter( + (relationType): relationType is DiscourseRelationType => !!relationType, + ); + + const { templates, warnings } = await getTemplateContents({ + plugin, + templateNames: selection.templateNames, + }); + + const payload = parseDgSchemaArchive({ + version: DG_SCHEMA_EXPORT_VERSION, + exportedAt: new Date().toISOString(), + pluginVersion: plugin.manifest.version, + vaultName: plugin.app.vault.getName(), + nodeTypes: selectedNodeTypes, + relationTypes: selectedRelationTypes, + discourseRelations: selectedDiscourseRelations, + templates, + }); + + return { + payload, + dependencySummary: { + autoIncludedNodeTypeIds: [...dependencyNodeTypeIds].filter( + (id) => !selection.nodeTypeIds.includes(id), + ), + autoIncludedRelationTypeIds: [...dependencyRelationTypeIds].filter( + (id) => !selection.relationTypeIds.includes(id), + ), + }, + warnings, + }; +}; + +const saveSchemaExportFile = async ({ + fileName, + content, +}: { + fileName: string; + content: string; +}): Promise => { + const fileSystemApiPath = await saveWithFileSystemAccessApi({ + fileName, + content, + }); + if (fileSystemApiPath) { + return fileSystemApiPath; + } + + const electronDialogPath = await saveWithElectronDialog({ + fileName, + content, + }); + if (electronDialogPath) { + return electronDialogPath; + } + + return triggerBrowserDownload({ fileName, content }); +}; + +export const exportSchemaSelectionToVault = async ({ + plugin, + selection, +}: { + plugin: DiscourseGraphPlugin; + selection: SpecExportSelection; +}): Promise => { + const { payload, dependencySummary, warnings } = + await buildSchemaExportPayload({ + plugin, + selection, + }); + const serializedPayload = JSON.stringify(payload, null, 2); + const fileName = getDgSchemaFileName(plugin.app.vault.getName()); + const filePath = await saveSchemaExportFile({ + fileName, + content: serializedPayload, + }); + + return { + filePath, + payload, + dependencySummary, + warnings, + }; +}; + +export { ExportSaveCancelledError }; diff --git a/apps/obsidian/src/utils/specImport.ts b/apps/obsidian/src/utils/specImport.ts new file mode 100644 index 000000000..f72157d27 --- /dev/null +++ b/apps/obsidian/src/utils/specImport.ts @@ -0,0 +1,701 @@ +import type DiscourseGraphPlugin from "~/index"; +import { uuidv7 } from "uuidv7"; +import { + parseDgSchemaArchive, + type DgSchemaArchive, +} from "~/utils/specArchive"; +import { createTemplateFile, getTemplateFiles } from "~/utils/templates"; +import type { + DiscourseNode, + DiscourseRelation, + DiscourseRelationType, +} from "~/types"; +import { toTldrawColor } from "~/utils/tldrawColors"; + +type SaveDialogOpenResult = { + canceled: boolean; + filePaths: string[]; +}; + +type ElectronDialog = { + showOpenDialog: (options: { + title: string; + properties: string[]; + filters: Array<{ name: string; extensions: string[] }>; + }) => Promise; +}; + +type ElectronLike = { + dialog?: ElectronDialog; + remote?: { + dialog?: ElectronDialog; + }; +}; + +type FsPromisesLike = { + readFile: (path: string, encoding: string) => Promise; +}; + +export class ImportFileSelectionCancelledError extends Error { + constructor() { + super("Import cancelled"); + this.name = "ImportFileSelectionCancelledError"; + } +} + +export type SpecImportPreview = { + archive: DgSchemaArchive; + sourcePath: string; + nodeTypes: { + total: number; + matchedById: number; + matchedByName: number; + newCount: number; + }; + relationTypes: { + total: number; + matchedById: number; + matchedByLabel: number; + newCount: number; + }; + discourseRelations: { + total: number; + existingCount: number; + newCount: number; + }; + templates: { + total: number; + existingCount: number; + newCount: number; + }; +}; + +export type SpecImportSelection = { + nodeTypeIds: string[]; + relationTypeIds: string[]; + discourseRelationIds: string[]; + templateNames: string[]; +}; + +export type SpecImportApplyResult = { + templates: { + created: number; + existing: number; + skipped: number; + }; + nodeTypes: { + created: number; + matchedById: number; + matchedByName: number; + templateAttachedToExisting: number; + }; + relationTypes: { + created: number; + matchedById: number; + matchedByLabel: number; + }; + discourseRelations: { + created: number; + existing: number; + }; + warnings: string[]; +}; + +const isElectronDialog = (value: unknown): value is ElectronDialog => { + return ( + typeof value === "object" && + value !== null && + "showOpenDialog" in value && + typeof (value as { showOpenDialog: unknown }).showOpenDialog === "function" + ); +}; + +const getElectronDialog = (value: unknown): ElectronDialog | null => { + if (typeof value !== "object" || value === null) { + return null; + } + const electronLike = value as ElectronLike; + const directDialog = electronLike.dialog; + if (isElectronDialog(directDialog)) { + return directDialog; + } + const remoteDialog = electronLike.remote?.dialog; + if (isElectronDialog(remoteDialog)) { + return remoteDialog; + } + return null; +}; + +const parseJsonArchiveContent = (content: string): DgSchemaArchive => { + const parsed = JSON.parse(content) as unknown; + return parseDgSchemaArchive(parsed); +}; + +const readWithFileSystemAccessApi = async (): Promise<{ + archive: DgSchemaArchive; + sourcePath: string; +} | null> => { + if (typeof window === "undefined") { + return null; + } + + const picker = ( + window as Window & { + showOpenFilePicker?: (options: { + multiple: boolean; + types: Array<{ + description: string; + accept: Record; + }>; + }) => Promise Promise }>>; + } + ).showOpenFilePicker; + + if (!picker) { + return null; + } + + try { + const [fileHandle] = await picker({ + multiple: false, + types: [ + { + description: "JSON files", + accept: { "application/json": [".json"] }, + }, + ], + }); + if (!fileHandle) { + throw new ImportFileSelectionCancelledError(); + } + const file = await fileHandle.getFile(); + const content = await file.text(); + return { + archive: parseJsonArchiveContent(content), + sourcePath: file.name, + }; + } catch (error) { + if (error instanceof ImportFileSelectionCancelledError) { + throw error; + } + if (error instanceof Error && error.name === "AbortError") { + throw new ImportFileSelectionCancelledError(); + } + throw error; + } +}; + +const readWithElectronDialog = async (): Promise<{ + archive: DgSchemaArchive; + sourcePath: string; +} | null> => { + if (typeof window === "undefined") { + return null; + } + + const windowWithRequire = window as Window & { + require?: (name: string) => unknown; + }; + if (!windowWithRequire.require) { + return null; + } + + const electron = windowWithRequire.require("electron"); + const dialog = getElectronDialog(electron); + if (!dialog) { + return null; + } + + const result = await dialog.showOpenDialog({ + title: "Import discourse graph schema", + properties: ["openFile"], + filters: [{ name: "JSON files", extensions: ["json"] }], + }); + if (result.canceled || !result.filePaths[0]) { + throw new ImportFileSelectionCancelledError(); + } + + const fsPromises = windowWithRequire.require("fs/promises"); + if ( + typeof fsPromises !== "object" || + fsPromises === null || + !("readFile" in fsPromises) || + typeof (fsPromises as { readFile: unknown }).readFile !== "function" + ) { + throw new Error("Unable to access filesystem read API for import."); + } + const typedFsPromises = fsPromises as FsPromisesLike; + const sourcePath = result.filePaths[0]; + const content = await typedFsPromises.readFile(sourcePath, "utf8"); + return { + archive: parseJsonArchiveContent(content), + sourcePath, + }; +}; + +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}`; +}; + +export const pickAndPreviewSchemaImport = async ({ + plugin, +}: { + plugin: DiscourseGraphPlugin; +}): Promise => { + const fromFilePicker = await readWithFileSystemAccessApi(); + const file = fromFilePicker ?? (await readWithElectronDialog()); + if (!file) { + throw new Error( + "Schema import requires a file picker. Your environment does not expose one.", + ); + } + + const localNodeTypeById = new Map( + plugin.settings.nodeTypes.map((nodeType) => [nodeType.id, nodeType]), + ); + const localNodeTypeByName = new Map( + plugin.settings.nodeTypes.map((nodeType) => [ + normalizeLabel(nodeType.name), + nodeType, + ]), + ); + + const localRelationTypeById = new Map( + plugin.settings.relationTypes.map((relationType) => [ + relationType.id, + relationType, + ]), + ); + const localRelationTypeByLabel = new Map( + plugin.settings.relationTypes.map((relationType) => [ + normalizeLabel(relationType.label), + relationType, + ]), + ); + + let nodeMatchedById = 0; + let nodeMatchedByName = 0; + const nodeTypeIdMapping = new Map(); + for (const nodeType of file.archive.nodeTypes) { + const matchById = localNodeTypeById.get(nodeType.id); + if (matchById) { + nodeMatchedById += 1; + nodeTypeIdMapping.set(nodeType.id, matchById.id); + continue; + } + + const matchByName = localNodeTypeByName.get(normalizeLabel(nodeType.name)); + if (matchByName) { + nodeMatchedByName += 1; + nodeTypeIdMapping.set(nodeType.id, matchByName.id); + continue; + } + + nodeTypeIdMapping.set(nodeType.id, nodeType.id); + } + + let relationTypeMatchedById = 0; + let relationTypeMatchedByLabel = 0; + const relationTypeIdMapping = new Map(); + for (const relationType of file.archive.relationTypes) { + const matchById = localRelationTypeById.get(relationType.id); + if (matchById) { + relationTypeMatchedById += 1; + relationTypeIdMapping.set(relationType.id, matchById.id); + continue; + } + + const matchByLabel = localRelationTypeByLabel.get( + normalizeLabel(relationType.label), + ); + if (matchByLabel) { + relationTypeMatchedByLabel += 1; + relationTypeIdMapping.set(relationType.id, matchByLabel.id); + continue; + } + + relationTypeIdMapping.set(relationType.id, relationType.id); + } + + const localTripleKeys = new Set( + plugin.settings.discourseRelations.map((relation) => + buildTripleKey({ + sourceId: relation.sourceId, + relationshipTypeId: relation.relationshipTypeId, + destinationId: relation.destinationId, + }), + ), + ); + + let existingRelationCount = 0; + for (const relation of file.archive.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)) { + existingRelationCount += 1; + } + } + + const localTemplateNames = new Set(getTemplateFiles(plugin.app)); + let existingTemplateCount = 0; + for (const template of file.archive.templates) { + if (localTemplateNames.has(template.name)) { + existingTemplateCount += 1; + } + } + + return { + archive: file.archive, + sourcePath: file.sourcePath, + nodeTypes: { + total: file.archive.nodeTypes.length, + matchedById: nodeMatchedById, + matchedByName: nodeMatchedByName, + newCount: + file.archive.nodeTypes.length - nodeMatchedById - nodeMatchedByName, + }, + relationTypes: { + total: file.archive.relationTypes.length, + matchedById: relationTypeMatchedById, + matchedByLabel: relationTypeMatchedByLabel, + newCount: + file.archive.relationTypes.length - + relationTypeMatchedById - + relationTypeMatchedByLabel, + }, + discourseRelations: { + total: file.archive.discourseRelations.length, + existingCount: existingRelationCount, + newCount: file.archive.discourseRelations.length - existingRelationCount, + }, + templates: { + total: file.archive.templates.length, + existingCount: existingTemplateCount, + newCount: file.archive.templates.length - existingTemplateCount, + }, + }; +}; + +const applyTemplateFiles = async ({ + plugin, + archive, + selectedTemplateNames, +}: { + plugin: DiscourseGraphPlugin; + archive: DgSchemaArchive; + selectedTemplateNames: Set; +}): Promise<{ + availability: Map; + created: number; + existing: number; + skipped: number; + warnings: string[]; +}> => { + const warnings: string[] = []; + const availability = new Map(); + let created = 0; + let existing = 0; + let skipped = 0; + + const templatesByName = new Map( + archive.templates.map((template) => [template.name, template]), + ); + for (const templateName of selectedTemplateNames) { + const template = templatesByName.get(templateName); + if (!template) { + skipped += 1; + warnings.push( + `Template "${templateName}" was selected but not found in import archive.`, + ); + availability.set(templateName, false); + continue; + } + + const result = await createTemplateFile({ + app: plugin.app, + templateName: template.name, + content: template.content, + }); + + if (result.created) { + created += 1; + availability.set(template.name, true); + continue; + } + + if (result.reason === "template already exists") { + existing += 1; + availability.set(template.name, true); + continue; + } + + skipped += 1; + availability.set(template.name, false); + warnings.push(`Template "${template.name}" skipped: ${result.reason}.`); + } + + return { availability, created, existing, skipped, warnings }; +}; + +export const applySchemaImportSelection = async ({ + plugin, + preview, + selection, +}: { + plugin: DiscourseGraphPlugin; + preview: SpecImportPreview; + selection: SpecImportSelection; +}): Promise => { + const warnings: string[] = []; + const archive = preview.archive; + + const selectedRelationIds = new Set(selection.discourseRelationIds); + const selectedNodeTypeIds = new Set(selection.nodeTypeIds); + const selectedRelationTypeIds = new Set(selection.relationTypeIds); + const selectedTemplateNames = new Set(selection.templateNames); + + for (const relation of archive.discourseRelations) { + if (!selectedRelationIds.has(relation.id)) continue; + selectedNodeTypeIds.add(relation.sourceId); + selectedNodeTypeIds.add(relation.destinationId); + selectedRelationTypeIds.add(relation.relationshipTypeId); + } + + const templatesResult = await applyTemplateFiles({ + plugin, + archive, + selectedTemplateNames, + }); + warnings.push(...templatesResult.warnings); + + const archiveNodeTypesById = new Map( + archive.nodeTypes.map((nodeType) => [nodeType.id, nodeType]), + ); + const archiveRelationTypesById = new Map( + archive.relationTypes.map((relationType) => [ + relationType.id, + relationType, + ]), + ); + + const nodeTypeIdMapping = new Map(); + let nodeTypesCreated = 0; + let nodeTypesMatchedById = 0; + let nodeTypesMatchedByName = 0; + let templateAttachedToExisting = 0; + + for (const nodeTypeId of selectedNodeTypeIds) { + const importedNodeType = archiveNodeTypesById.get(nodeTypeId); + if (!importedNodeType) { + warnings.push( + `Node type "${nodeTypeId}" was selected but missing from archive.`, + ); + continue; + } + + const matchById = plugin.settings.nodeTypes.find( + (nodeType) => nodeType.id === nodeTypeId, + ); + if (matchById) { + nodeTypesMatchedById += 1; + nodeTypeIdMapping.set(nodeTypeId, matchById.id); + if ( + importedNodeType.description && + (!matchById.description || !matchById.description.trim()) + ) { + matchById.description = importedNodeType.description; + matchById.modified = Date.now(); + } + if ( + importedNodeType.template && + selectedTemplateNames.has(importedNodeType.template) && + templatesResult.availability.get(importedNodeType.template) && + !matchById.template + ) { + matchById.template = importedNodeType.template; + matchById.modified = Date.now(); + templateAttachedToExisting += 1; + } + continue; + } + + const matchByName = plugin.settings.nodeTypes.find( + (nodeType) => + normalizeLabel(nodeType.name) === normalizeLabel(importedNodeType.name), + ); + if (matchByName) { + nodeTypesMatchedByName += 1; + nodeTypeIdMapping.set(nodeTypeId, matchByName.id); + if ( + importedNodeType.description && + (!matchByName.description || !matchByName.description.trim()) + ) { + matchByName.description = importedNodeType.description; + matchByName.modified = Date.now(); + } + if ( + importedNodeType.template && + selectedTemplateNames.has(importedNodeType.template) && + templatesResult.availability.get(importedNodeType.template) && + !matchByName.template + ) { + matchByName.template = importedNodeType.template; + matchByName.modified = Date.now(); + templateAttachedToExisting += 1; + } + continue; + } + + const newNodeType: DiscourseNode = { + ...importedNodeType, + template: + importedNodeType.template && + selectedTemplateNames.has(importedNodeType.template) && + templatesResult.availability.get(importedNodeType.template) + ? importedNodeType.template + : undefined, + modified: Date.now(), + }; + plugin.settings.nodeTypes = [...plugin.settings.nodeTypes, newNodeType]; + nodeTypesCreated += 1; + nodeTypeIdMapping.set(nodeTypeId, newNodeType.id); + } + + const relationTypeIdMapping = new Map(); + let relationTypesCreated = 0; + let relationTypesMatchedById = 0; + let relationTypesMatchedByLabel = 0; + + for (const relationTypeId of selectedRelationTypeIds) { + const importedRelationType = archiveRelationTypesById.get(relationTypeId); + if (!importedRelationType) { + warnings.push( + `Relation type "${relationTypeId}" was selected but missing from archive.`, + ); + continue; + } + + const matchById = plugin.settings.relationTypes.find( + (relationType) => relationType.id === relationTypeId, + ); + if (matchById) { + relationTypesMatchedById += 1; + relationTypeIdMapping.set(relationTypeId, matchById.id); + continue; + } + + const matchByLabel = plugin.settings.relationTypes.find( + (relationType) => + normalizeLabel(relationType.label) === + normalizeLabel(importedRelationType.label), + ); + if (matchByLabel) { + relationTypesMatchedByLabel += 1; + relationTypeIdMapping.set(relationTypeId, matchByLabel.id); + continue; + } + + const newRelationType: DiscourseRelationType = { + ...importedRelationType, + color: toTldrawColor(importedRelationType.color), + status: "provisional", + modified: Date.now(), + }; + plugin.settings.relationTypes = [ + ...plugin.settings.relationTypes, + newRelationType, + ]; + relationTypesCreated += 1; + relationTypeIdMapping.set(relationTypeId, newRelationType.id); + } + + let discourseRelationsCreated = 0; + let discourseRelationsExisting = 0; + for (const relation of archive.discourseRelations) { + if (!selectedRelationIds.has(relation.id)) { + continue; + } + + 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 exists = plugin.settings.discourseRelations.some( + (localRelation) => + localRelation.sourceId === mappedSourceId && + localRelation.destinationId === mappedDestinationId && + localRelation.relationshipTypeId === mappedRelationTypeId, + ); + if (exists) { + discourseRelationsExisting += 1; + continue; + } + + 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 { + templates: { + created: templatesResult.created, + existing: templatesResult.existing, + skipped: templatesResult.skipped, + }, + nodeTypes: { + created: nodeTypesCreated, + matchedById: nodeTypesMatchedById, + matchedByName: nodeTypesMatchedByName, + templateAttachedToExisting, + }, + relationTypes: { + created: relationTypesCreated, + matchedById: relationTypesMatchedById, + matchedByLabel: relationTypesMatchedByLabel, + }, + discourseRelations: { + created: discourseRelationsCreated, + existing: discourseRelationsExisting, + }, + warnings, + }; +}; From 9f8d35f9748b29257428b1ae4de9ebc3ecce746a Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 24 Jun 2026 11:29:59 -0400 Subject: [PATCH 2/5] FEE-840 refine schema modal architecture and validation naming Consolidate export/import selection flows behind shared modal and state primitives, move schema transfer types into core Obsidian types, and rename spec archive validation utilities to clearer spec validation naming. Co-authored-by: Cursor --- .../src/components/ExportSpecsModal.tsx | 295 +++--------- .../src/components/GeneralSettings.tsx | 2 +- .../components/ImportSchemaPreviewSummary.tsx | 52 +++ .../src/components/ImportSpecsModal.tsx | 420 +++++------------- .../src/components/ReactRootModal.tsx | 27 ++ .../components/SchemaSelectionModalBody.tsx | 77 ++++ .../src/components/SchemaSelectionPanel.tsx | 159 +++---- .../src/components/useSchemaSelection.ts | 244 ++++++++++ apps/obsidian/src/types.ts | 23 + apps/obsidian/src/utils/specExport.ts | 12 +- apps/obsidian/src/utils/specImport.ts | 17 +- .../{specArchive.ts => specValidation.ts} | 11 +- 12 files changed, 686 insertions(+), 653 deletions(-) create mode 100644 apps/obsidian/src/components/ImportSchemaPreviewSummary.tsx create mode 100644 apps/obsidian/src/components/ReactRootModal.tsx create mode 100644 apps/obsidian/src/components/SchemaSelectionModalBody.tsx create mode 100644 apps/obsidian/src/components/useSchemaSelection.ts rename apps/obsidian/src/utils/{specArchive.ts => specValidation.ts} (85%) diff --git a/apps/obsidian/src/components/ExportSpecsModal.tsx b/apps/obsidian/src/components/ExportSpecsModal.tsx index 90779878d..ce2640675 100644 --- a/apps/obsidian/src/components/ExportSpecsModal.tsx +++ b/apps/obsidian/src/components/ExportSpecsModal.tsx @@ -1,179 +1,67 @@ -import { App, Modal, Notice } from "obsidian"; -import { StrictMode, useEffect, useMemo, useState } from "react"; -import { createRoot, type Root } from "react-dom/client"; +import { App, Notice } from "obsidian"; +import { useMemo, useState } from "react"; import type DiscourseGraphPlugin from "~/index"; import { exportSchemaSelectionToVault, ExportSaveCancelledError, } from "~/utils/specExport"; -import { getDgSchemaFileName } from "~/utils/specArchive"; +import { getDgSchemaFileName } from "~/utils/specValidation"; import { getTemplateFiles } from "~/utils/templates"; -import { SchemaSelectionPanel } from "~/components/SchemaSelectionPanel"; +import { + getReferencedTemplateNames, + useSchemaSelection, + type SchemaSelectionSource, +} from "~/components/useSchemaSelection"; +import { SchemaSelectionModalBody } from "~/components/SchemaSelectionModalBody"; +import { ReactRootModal } from "~/components/ReactRootModal"; type ExportSpecsModalProps = { plugin: DiscourseGraphPlugin; onClose: () => void; }; -const getAllNodeTypeIds = (plugin: DiscourseGraphPlugin): string[] => { - return plugin.settings.nodeTypes.map((nodeType) => nodeType.id); -}; - -const getAllRelationTypeIds = (plugin: DiscourseGraphPlugin): string[] => { - return plugin.settings.relationTypes.map((relationType) => relationType.id); -}; - -const getAllRelationIds = (plugin: DiscourseGraphPlugin): string[] => { - return plugin.settings.discourseRelations.map((relation) => relation.id); -}; - -const getReferencedTemplateNames = ( - nodeTypes: DiscourseGraphPlugin["settings"]["nodeTypes"], -): Set => { - return new Set( - nodeTypes - .map((nodeType) => nodeType.template) - .filter((template): template is string => !!template), - ); -}; - export const openExportSpecsModal = (plugin: DiscourseGraphPlugin): void => { new ExportSpecsModal(plugin.app, plugin).open(); }; const ExportSpecsContent = ({ plugin, onClose }: ExportSpecsModalProps) => { - const [selectedNodeTypeIds, setSelectedNodeTypeIds] = useState>( - () => new Set(getAllNodeTypeIds(plugin)), - ); - const [selectedRelationTypeIds, setSelectedRelationTypeIds] = useState< - Set - >(() => new Set(getAllRelationTypeIds(plugin))); - const [selectedRelationIds, setSelectedRelationIds] = useState>( - () => new Set(getAllRelationIds(plugin)), - ); - const [selectedTemplateNames, setSelectedTemplateNames] = useState< - Set - >(() => getReferencedTemplateNames(plugin.settings.nodeTypes)); const [isExporting, setIsExporting] = useState(false); const outputFileName = getDgSchemaFileName(plugin.app.vault.getName()); - const templateNames = useMemo(() => { - return getTemplateFiles(plugin.app); - }, [plugin.app]); - - const requiredRelationTypeIds = useMemo(() => { - const requiredIds = new Set(); - for (const relation of plugin.settings.discourseRelations) { - if (selectedRelationIds.has(relation.id)) { - requiredIds.add(relation.relationshipTypeId); - } - } - return requiredIds; - }, [plugin.settings.discourseRelations, selectedRelationIds]); - - const requiredNodeTypeIds = useMemo(() => { - const requiredIds = new Set(); - for (const relation of plugin.settings.discourseRelations) { - if (!selectedRelationIds.has(relation.id)) continue; - requiredIds.add(relation.sourceId); - requiredIds.add(relation.destinationId); - } - return requiredIds; - }, [plugin.settings.discourseRelations, selectedRelationIds]); - - useEffect(() => { - setSelectedRelationTypeIds((previousSet) => { - const nextSet = new Set(previousSet); - let didChange = false; - for (const relationTypeId of requiredRelationTypeIds) { - if (!nextSet.has(relationTypeId)) { - nextSet.add(relationTypeId); - didChange = true; - } - } - return didChange ? nextSet : previousSet; - }); - }, [requiredRelationTypeIds]); - - useEffect(() => { - setSelectedNodeTypeIds((previousSet) => { - const nextSet = new Set(previousSet); - let didChange = false; - for (const nodeTypeId of requiredNodeTypeIds) { - if (!nextSet.has(nodeTypeId)) { - nextSet.add(nodeTypeId); - didChange = true; - } - } - return didChange ? nextSet : previousSet; - }); - }, [requiredNodeTypeIds]); - - const updateSet = ( - previousSet: Set, - id: string, - shouldSelect: boolean, - ): Set => { - const nextSet = new Set(previousSet); - if (shouldSelect) { - nextSet.add(id); - } else { - nextSet.delete(id); - } - return nextSet; - }; - - const toggleNodeType = (nodeTypeId: string, shouldSelect: boolean): void => { - if (!shouldSelect && requiredNodeTypeIds.has(nodeTypeId)) { - new Notice( - "This node type is required by a selected relation triple. Remove the triple first.", - ); - return; - } - setSelectedNodeTypeIds((previousSet) => - updateSet(previousSet, nodeTypeId, shouldSelect), - ); - }; - - const toggleRelationType = ( - relationTypeId: string, - shouldSelect: boolean, - ): void => { - if (!shouldSelect && requiredRelationTypeIds.has(relationTypeId)) { - new Notice( - "This relation type is required by a selected relation triple. Remove the triple first.", - ); - return; - } - setSelectedRelationTypeIds((previousSet) => - updateSet(previousSet, relationTypeId, shouldSelect), - ); - }; - - const toggleRelationTriple = ( - relationId: string, - shouldSelect: boolean, - ): void => { - setSelectedRelationIds((previousSet) => - updateSet(previousSet, relationId, shouldSelect), - ); - }; - - const toggleTemplate = ( - templateName: string, - shouldSelect: boolean, - ): void => { - setSelectedTemplateNames((previousSet) => - updateSet(previousSet, templateName, shouldSelect), - ); - }; + const source = useMemo(() => { + return { + nodeTypes: plugin.settings.nodeTypes, + relationTypes: plugin.settings.relationTypes, + relationTriples: plugin.settings.discourseRelations, + templateNames: getTemplateFiles(plugin.app), + }; + }, [ + plugin.app, + plugin.settings.discourseRelations, + plugin.settings.nodeTypes, + plugin.settings.relationTypes, + ]); + + const selection = useSchemaSelection({ + source, + resetKey: "export", + initialValues: { + nodeTypeIds: source.nodeTypes.map((nodeType) => nodeType.id), + relationTypeIds: source.relationTypes.map( + (relationType) => relationType.id, + ), + relationIds: source.relationTriples.map((relation) => relation.id), + templateNames: [...getReferencedTemplateNames(source.nodeTypes)], + }, + }); const handleExport = async (): Promise => { + const payload = selection.asSelectionPayload(); const hasSelection = - selectedNodeTypeIds.size > 0 || - selectedRelationTypeIds.size > 0 || - selectedRelationIds.size > 0 || - selectedTemplateNames.size > 0; + payload.nodeTypeIds.length > 0 || + payload.relationTypeIds.length > 0 || + payload.relationIds.length > 0 || + payload.templateNames.length > 0; if (!hasSelection) { new Notice("Select at least one schema item or template to export."); return; @@ -184,10 +72,10 @@ const ExportSpecsContent = ({ plugin, onClose }: ExportSpecsModalProps) => { const result = await exportSchemaSelectionToVault({ plugin, selection: { - nodeTypeIds: [...selectedNodeTypeIds], - relationTypeIds: [...selectedRelationTypeIds], - discourseRelationIds: [...selectedRelationIds], - templateNames: [...selectedTemplateNames], + nodeTypeIds: payload.nodeTypeIds, + relationTypeIds: payload.relationTypeIds, + discourseRelationIds: payload.relationIds, + templateNames: payload.templateNames, }, }); @@ -228,92 +116,33 @@ const ExportSpecsContent = ({ plugin, onClose }: ExportSpecsModalProps) => { }; return ( -
-

Export discourse graph schema

-

- Select the node types, relation types, relation triples, and templates - to include in {outputFileName}. -

- - - setSelectedNodeTypeIds(new Set(getAllNodeTypeIds(plugin))) - } - onDeselectOptionalNodeTypes={() => - setSelectedNodeTypeIds(new Set([...requiredNodeTypeIds])) - } - onToggleNodeType={toggleNodeType} - onSelectAllRelationTypes={() => - setSelectedRelationTypeIds(new Set(getAllRelationTypeIds(plugin))) - } - onDeselectOptionalRelationTypes={() => - setSelectedRelationTypeIds(new Set([...requiredRelationTypeIds])) - } - onToggleRelationType={toggleRelationType} - onSelectAllRelationTriples={() => - setSelectedRelationIds(new Set(getAllRelationIds(plugin))) - } - onDeselectAllRelationTriples={() => setSelectedRelationIds(new Set())} - onToggleRelationTriple={toggleRelationTriple} - onSelectAllTemplates={() => - setSelectedTemplateNames(new Set(templateNames)) - } - onDeselectAllTemplates={() => setSelectedTemplateNames(new Set())} - onToggleTemplate={toggleTemplate} - emptyTemplateText="No templates found in your Templates folder." - /> - -
- - -
-
+ new Notice(message)} + footerSecondaryLabel="Cancel" + onFooterSecondaryClick={onClose} + footerPrimaryLabel={isExporting ? "Exporting..." : "Export schema"} + onFooterPrimaryClick={() => void handleExport()} + isFooterPrimaryDisabled={isExporting} + /> ); }; -export class ExportSpecsModal extends Modal { +export class ExportSpecsModal extends ReactRootModal { private plugin: DiscourseGraphPlugin; - private root: Root | null = null; constructor(app: App, plugin: DiscourseGraphPlugin) { super(app); this.plugin = plugin; } - onOpen(): void { - const { contentEl } = this; - contentEl.empty(); - this.root = createRoot(contentEl); - this.root.render( - - this.close()} /> - , + protected renderContent() { + return ( + this.close()} /> ); } - - onClose(): void { - if (this.root) { - this.root.unmount(); - this.root = null; - } - } } diff --git a/apps/obsidian/src/components/GeneralSettings.tsx b/apps/obsidian/src/components/GeneralSettings.tsx index 79da34b62..62e062c73 100644 --- a/apps/obsidian/src/components/GeneralSettings.tsx +++ b/apps/obsidian/src/components/GeneralSettings.tsx @@ -5,7 +5,7 @@ import SuggestInput from "./SuggestInput"; import { DiscourseGraphLogoIcon, SlackLogoIcon } from "./Icons"; import { openExportSpecsModal } from "./ExportSpecsModal"; import { openImportSpecsModal } from "./ImportSpecsModal"; -import { getDgSchemaFileName } from "~/utils/specArchive"; +import { getDgSchemaFileName } from "~/utils/specValidation"; const DOCS_URL = "https://discoursegraphs.com/docs/obsidian"; const COMMUNITY_URL = diff --git a/apps/obsidian/src/components/ImportSchemaPreviewSummary.tsx b/apps/obsidian/src/components/ImportSchemaPreviewSummary.tsx new file mode 100644 index 000000000..9bb5ed153 --- /dev/null +++ b/apps/obsidian/src/components/ImportSchemaPreviewSummary.tsx @@ -0,0 +1,52 @@ +import type { SpecImportPreview } from "~/utils/specImport"; + +export const ImportSchemaPreviewSummary = ({ + preview, +}: { + preview: SpecImportPreview; +}) => { + return ( + <> +
+
Archive metadata
+
+ Vault:{" "} + {preview.archive.vaultName} +
+
+ Exported at:{" "} + {preview.archive.exportedAt} +
+
+ Plugin version:{" "} + {preview.archive.pluginVersion} +
+
+ +
+
Current preview stats (full archive)
+
+ Node types: {preview.nodeTypes.total} total ( + {preview.nodeTypes.newCount} new, {preview.nodeTypes.matchedById} ID + matches, {preview.nodeTypes.matchedByName} name matches) +
+
+ Relation types: {preview.relationTypes.total} total ( + {preview.relationTypes.newCount} new,{" "} + {preview.relationTypes.matchedById} ID matches,{" "} + {preview.relationTypes.matchedByLabel} label matches) +
+
+ Relation triples: {preview.discourseRelations.total} total ( + {preview.discourseRelations.newCount} new,{" "} + {preview.discourseRelations.existingCount} existing) +
+
+ Templates: {preview.templates.total} total ( + {preview.templates.newCount} new, {preview.templates.existingCount}{" "} + existing) +
+
+ + ); +}; diff --git a/apps/obsidian/src/components/ImportSpecsModal.tsx b/apps/obsidian/src/components/ImportSpecsModal.tsx index 96b02866c..c70483f43 100644 --- a/apps/obsidian/src/components/ImportSpecsModal.tsx +++ b/apps/obsidian/src/components/ImportSpecsModal.tsx @@ -1,176 +1,73 @@ -import { App, Modal, Notice } from "obsidian"; -import { StrictMode, useEffect, useMemo, useState } from "react"; -import { createRoot, type Root } from "react-dom/client"; +import { App, Notice } from "obsidian"; +import { useMemo, useState } from "react"; import type DiscourseGraphPlugin from "~/index"; import { applySchemaImportSelection, ImportFileSelectionCancelledError, pickAndPreviewSchemaImport, - type SpecImportSelection, type SpecImportPreview, } from "~/utils/specImport"; -import { SchemaSelectionPanel } from "~/components/SchemaSelectionPanel"; +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; }; -const getNodeTypeIdsFromPreview = (preview: SpecImportPreview): string[] => { - return preview.archive.nodeTypes.map((nodeType) => nodeType.id); -}; - -const getRelationTypeIdsFromPreview = ( - preview: SpecImportPreview, -): string[] => { - return preview.archive.relationTypes.map((relationType) => relationType.id); -}; - -const getRelationIdsFromPreview = (preview: SpecImportPreview): string[] => { - return preview.archive.discourseRelations.map((relation) => relation.id); -}; - -const getTemplateNamesFromPreview = (preview: SpecImportPreview): string[] => { - return preview.archive.templates.map((template) => template.name); -}; - export const openImportSpecsModal = (plugin: DiscourseGraphPlugin): void => { new ImportSpecsModal(plugin.app, plugin).open(); }; -const ImportSpecsContent = ({ plugin, onClose }: ImportSpecsModalProps) => { - const [preview, setPreview] = useState(null); - const [isSelectingFile, setIsSelectingFile] = useState(false); - const [selectedNodeTypeIds, setSelectedNodeTypeIds] = useState>( - new Set(), - ); - const [selectedRelationTypeIds, setSelectedRelationTypeIds] = useState< - Set - >(new Set()); - const [selectedRelationIds, setSelectedRelationIds] = useState>( - new Set(), - ); - const [selectedTemplateNames, setSelectedTemplateNames] = useState< - Set - >(new Set()); - const [isApplyingImport, setIsApplyingImport] = useState(false); - - useEffect(() => { - if (!preview) { - setSelectedNodeTypeIds(new Set()); - setSelectedRelationTypeIds(new Set()); - setSelectedRelationIds(new Set()); - setSelectedTemplateNames(new Set()); - return; - } - setSelectedNodeTypeIds(new Set(getNodeTypeIdsFromPreview(preview))); - setSelectedRelationTypeIds(new Set(getRelationTypeIdsFromPreview(preview))); - setSelectedRelationIds(new Set(getRelationIdsFromPreview(preview))); - setSelectedTemplateNames(new Set(getTemplateNamesFromPreview(preview))); - }, [preview]); - - const requiredRelationTypeIds = useMemo(() => { - if (!preview) return new Set(); - const required = new Set(); - for (const relation of preview.archive.discourseRelations) { - if (selectedRelationIds.has(relation.id)) { - required.add(relation.relationshipTypeId); - } - } - return required; - }, [preview, selectedRelationIds]); - - const requiredNodeTypeIds = useMemo(() => { - if (!preview) return new Set(); - const required = new Set(); - for (const relation of preview.archive.discourseRelations) { - if (!selectedRelationIds.has(relation.id)) continue; - required.add(relation.sourceId); - required.add(relation.destinationId); - } - return required; - }, [preview, selectedRelationIds]); - - useEffect(() => { - if (!preview) return; - setSelectedRelationTypeIds((previousSet) => { - const nextSet = new Set(previousSet); - let didChange = false; - for (const relationTypeId of requiredRelationTypeIds) { - if (!nextSet.has(relationTypeId)) { - nextSet.add(relationTypeId); - didChange = true; - } - } - return didChange ? nextSet : previousSet; - }); - }, [preview, requiredRelationTypeIds]); - - useEffect(() => { - if (!preview) return; - setSelectedNodeTypeIds((previousSet) => { - const nextSet = new Set(previousSet); - let didChange = false; - for (const nodeTypeId of requiredNodeTypeIds) { - if (!nextSet.has(nodeTypeId)) { - nextSet.add(nodeTypeId); - didChange = true; - } - } - return didChange ? nextSet : previousSet; - }); - }, [preview, requiredNodeTypeIds]); - - const updateSet = ( - previousSet: Set, - id: string, - shouldSelect: boolean, - ): Set => { - const nextSet = new Set(previousSet); - if (shouldSelect) { - nextSet.add(id); - } else { - nextSet.delete(id); - } - return nextSet; - }; - - const handleSelectSchemaFile = async (): Promise => { - setIsSelectingFile(true); - try { - const nextPreview = await pickAndPreviewSchemaImport({ plugin }); - setPreview(nextPreview); - } catch (error) { - if (error instanceof ImportFileSelectionCancelledError) { - 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); - } - }; - - const buildSelection = (): SpecImportSelection => { +const ImportPreviewSelection = ({ + plugin, + preview, + isApplyingImport, + setIsApplyingImport, + onResetPreview, + onClose, +}: { + plugin: DiscourseGraphPlugin; + preview: SpecImportPreview; + isApplyingImport: boolean; + setIsApplyingImport: (value: boolean) => void; + onResetPreview: () => void; + onClose: () => void; +}) => { + const source = useMemo(() => { return { - nodeTypeIds: [...selectedNodeTypeIds], - relationTypeIds: [...selectedRelationTypeIds], - discourseRelationIds: [...selectedRelationIds], - templateNames: [...selectedTemplateNames], + nodeTypes: preview.archive.nodeTypes, + relationTypes: preview.archive.relationTypes, + relationTriples: preview.archive.discourseRelations, + templateNames: preview.archive.templates.map((template) => template.name), }; - }; + }, [preview]); + + const selection = useSchemaSelection({ + source, + resetKey: preview.sourcePath, + initialValues: { + nodeTypeIds: source.nodeTypes.map((nodeType) => nodeType.id), + relationTypeIds: source.relationTypes.map( + (relationType) => relationType.id, + ), + relationIds: source.relationTriples.map((relation) => relation.id), + templateNames: source.templateNames, + }, + }); const handleApplyImport = async (): Promise => { - if (!preview) { - return; - } - const selection = buildSelection(); + const selected = selection.asSelectionPayload(); const hasAnySelection = - selection.nodeTypeIds.length > 0 || - selection.relationTypeIds.length > 0 || - selection.discourseRelationIds.length > 0 || - selection.templateNames.length > 0; + 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; @@ -181,7 +78,12 @@ const ImportSpecsContent = ({ plugin, onClose }: ImportSpecsModalProps) => { const result = await applySchemaImportSelection({ plugin, preview, - selection, + selection: { + nodeTypeIds: selected.nodeTypeIds, + relationTypeIds: selected.relationTypeIds, + discourseRelationIds: selected.relationIds, + templateNames: selected.templateNames, + }, }); new Notice( @@ -208,6 +110,47 @@ const ImportSpecsContent = ({ plugin, onClose }: ImportSpecsModalProps) => { } }; + 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 ImportFileSelectionCancelledError) { + 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 (
@@ -218,7 +161,7 @@ const ImportSpecsContent = ({ plugin, onClose }: ImportSpecsModalProps) => {

- This slice is preview + selection only. Apply import writes are next. + Same dependency rules as export apply here during selection.
@@ -239,175 +182,28 @@ const ImportSpecsContent = ({ plugin, onClose }: ImportSpecsModalProps) => { } return ( -
-

Import schema preview

-

- Source file: {preview.sourcePath} -

-

- Same dependency rules as export: selected relation triples require their - relation type and endpoint node types. -

- -
-
Archive metadata
-
- Vault:{" "} - {preview.archive.vaultName} -
-
- Exported at:{" "} - {preview.archive.exportedAt} -
-
- Plugin version:{" "} - {preview.archive.pluginVersion} -
-
- - template.name, - )} - selectedNodeTypeIds={selectedNodeTypeIds} - selectedRelationTypeIds={selectedRelationTypeIds} - selectedRelationIds={selectedRelationIds} - selectedTemplateNames={selectedTemplateNames} - requiredNodeTypeIds={requiredNodeTypeIds} - requiredRelationTypeIds={requiredRelationTypeIds} - onSelectAllNodeTypes={() => - setSelectedNodeTypeIds(new Set(getNodeTypeIdsFromPreview(preview))) - } - onDeselectOptionalNodeTypes={() => - setSelectedNodeTypeIds(new Set([...requiredNodeTypeIds])) - } - onToggleNodeType={(nodeTypeId, shouldSelect) => { - if (!shouldSelect && requiredNodeTypeIds.has(nodeTypeId)) { - new Notice( - "This node type is required by a selected relation triple. Remove the triple first.", - ); - return; - } - setSelectedNodeTypeIds((previousSet) => - updateSet(previousSet, nodeTypeId, shouldSelect), - ); - }} - onSelectAllRelationTypes={() => - setSelectedRelationTypeIds( - new Set(getRelationTypeIdsFromPreview(preview)), - ) - } - onDeselectOptionalRelationTypes={() => - setSelectedRelationTypeIds(new Set([...requiredRelationTypeIds])) - } - onToggleRelationType={(relationTypeId, shouldSelect) => { - if (!shouldSelect && requiredRelationTypeIds.has(relationTypeId)) { - new Notice( - "This relation type is required by a selected relation triple. Remove the triple first.", - ); - return; - } - setSelectedRelationTypeIds((previousSet) => - updateSet(previousSet, relationTypeId, shouldSelect), - ); - }} - onSelectAllRelationTriples={() => - setSelectedRelationIds(new Set(getRelationIdsFromPreview(preview))) - } - onDeselectAllRelationTriples={() => setSelectedRelationIds(new Set())} - onToggleRelationTriple={(relationId, shouldSelect) => - setSelectedRelationIds((previousSet) => - updateSet(previousSet, relationId, shouldSelect), - ) - } - onSelectAllTemplates={() => - setSelectedTemplateNames( - new Set(getTemplateNamesFromPreview(preview)), - ) - } - onDeselectAllTemplates={() => setSelectedTemplateNames(new Set())} - onToggleTemplate={(templateName, shouldSelect) => - setSelectedTemplateNames((previousSet) => - updateSet(previousSet, templateName, shouldSelect), - ) - } - emptyTemplateText="No templates found in this schema file." - /> - -
-
Current preview stats (full archive)
-
- Node types: {preview.nodeTypes.total} total ( - {preview.nodeTypes.newCount} new, {preview.nodeTypes.matchedById} ID - matches, {preview.nodeTypes.matchedByName} name matches) -
-
- Relation types: {preview.relationTypes.total} total ( - {preview.relationTypes.newCount} new,{" "} - {preview.relationTypes.matchedById} ID matches,{" "} - {preview.relationTypes.matchedByLabel} label matches) -
-
- Relation triples: {preview.discourseRelations.total} total ( - {preview.discourseRelations.newCount} new,{" "} - {preview.discourseRelations.existingCount} existing) -
-
- Templates: {preview.templates.total} total ( - {preview.templates.newCount} new, {preview.templates.existingCount}{" "} - existing) -
-
- -
- - -
-
+ setPreview(null)} + onClose={onClose} + /> ); }; -export class ImportSpecsModal extends Modal { +export class ImportSpecsModal extends ReactRootModal { private plugin: DiscourseGraphPlugin; - private root: Root | null = null; constructor(app: App, plugin: DiscourseGraphPlugin) { super(app); this.plugin = plugin; } - onOpen(): void { - const { contentEl } = this; - contentEl.empty(); - this.root = createRoot(contentEl); - this.root.render( - - this.close()} /> - , + protected renderContent() { + return ( + this.close()} /> ); } - - onClose(): void { - if (this.root) { - this.root.unmount(); - this.root = null; - } - } } diff --git a/apps/obsidian/src/components/ReactRootModal.tsx b/apps/obsidian/src/components/ReactRootModal.tsx new file mode 100644 index 000000000..566df4d77 --- /dev/null +++ b/apps/obsidian/src/components/ReactRootModal.tsx @@ -0,0 +1,27 @@ +import { App, Modal } from "obsidian"; +import { StrictMode, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; + +export abstract class ReactRootModal extends Modal { + private root: Root | null = null; + + constructor(app: App) { + super(app); + } + + protected abstract renderContent(): ReactNode; + + onOpen(): void { + const { contentEl } = this; + contentEl.empty(); + this.root = createRoot(contentEl); + this.root.render({this.renderContent()}); + } + + onClose(): void { + if (this.root) { + this.root.unmount(); + this.root = null; + } + } +} diff --git a/apps/obsidian/src/components/SchemaSelectionModalBody.tsx b/apps/obsidian/src/components/SchemaSelectionModalBody.tsx new file mode 100644 index 000000000..6a37e440a --- /dev/null +++ b/apps/obsidian/src/components/SchemaSelectionModalBody.tsx @@ -0,0 +1,77 @@ +import { SchemaSelectionPanel } from "~/components/SchemaSelectionPanel"; +import type { ReactNode } from "react"; +import type { + SchemaSelectionSource, + SchemaSelectionState, +} from "~/components/useSchemaSelection"; + +type SchemaSelectionModalBodyProps = { + title: string; + description: string; + source: SchemaSelectionSource; + selection: SchemaSelectionState; + emptyTemplateText: string; + onDependencyViolation?: (message: string) => void; + beforePanel?: ReactNode; + afterPanel?: ReactNode; + footerSecondaryLabel: string; + onFooterSecondaryClick: () => void; + footerPrimaryLabel: string; + onFooterPrimaryClick: () => void; + isFooterPrimaryDisabled?: boolean; + isFooterSecondaryDisabled?: boolean; +}; + +export const SchemaSelectionModalBody = ({ + title, + description, + source, + selection, + emptyTemplateText, + onDependencyViolation, + beforePanel, + afterPanel, + footerSecondaryLabel, + onFooterSecondaryClick, + footerPrimaryLabel, + onFooterPrimaryClick, + isFooterPrimaryDisabled = false, + isFooterSecondaryDisabled = false, +}: SchemaSelectionModalBodyProps) => { + return ( +
+

{title}

+

{description}

+ + {beforePanel} + + + + {afterPanel} + +
+ + +
+
+ ); +}; diff --git a/apps/obsidian/src/components/SchemaSelectionPanel.tsx b/apps/obsidian/src/components/SchemaSelectionPanel.tsx index c6fee8a98..fd113dce2 100644 --- a/apps/obsidian/src/components/SchemaSelectionPanel.tsx +++ b/apps/obsidian/src/components/SchemaSelectionPanel.tsx @@ -1,80 +1,50 @@ -type SchemaNodeTypeLike = { - id: string; - name: string; - template?: string; -}; - -type SchemaRelationTypeLike = { - id: string; - label: string; -}; - -type SchemaRelationTripleLike = { - id: string; - sourceId: string; - destinationId: string; - relationshipTypeId: string; -}; +import type { + SchemaSelectionSource, + SchemaSelectionState, +} from "~/components/useSchemaSelection"; type SchemaSelectionPanelProps = { - nodeTypes: SchemaNodeTypeLike[]; - relationTypes: SchemaRelationTypeLike[]; - relationTriples: SchemaRelationTripleLike[]; - templateNames: string[]; - selectedNodeTypeIds: Set; - selectedRelationTypeIds: Set; - selectedRelationIds: Set; - selectedTemplateNames: Set; - requiredNodeTypeIds: Set; - requiredRelationTypeIds: Set; - onSelectAllNodeTypes: () => void; - onDeselectOptionalNodeTypes: () => void; - onToggleNodeType: (nodeTypeId: string, shouldSelect: boolean) => void; - onSelectAllRelationTypes: () => void; - onDeselectOptionalRelationTypes: () => void; - onToggleRelationType: (relationTypeId: string, shouldSelect: boolean) => void; - onSelectAllRelationTriples: () => void; - onDeselectAllRelationTriples: () => void; - onToggleRelationTriple: (relationId: string, shouldSelect: boolean) => void; - onSelectAllTemplates: () => void; - onDeselectAllTemplates: () => void; - onToggleTemplate: (templateName: string, shouldSelect: boolean) => void; + source: SchemaSelectionSource; + selection: SchemaSelectionState; emptyTemplateText: string; + onDependencyViolation?: (message: string) => void; }; export const SchemaSelectionPanel = ({ - nodeTypes, - relationTypes, - relationTriples, - templateNames, - selectedNodeTypeIds, - selectedRelationTypeIds, - selectedRelationIds, - selectedTemplateNames, - requiredNodeTypeIds, - requiredRelationTypeIds, - onSelectAllNodeTypes, - onDeselectOptionalNodeTypes, - onToggleNodeType, - onSelectAllRelationTypes, - onDeselectOptionalRelationTypes, - onToggleRelationType, - onSelectAllRelationTriples, - onDeselectAllRelationTriples, - onToggleRelationTriple, - onSelectAllTemplates, - onDeselectAllTemplates, - onToggleTemplate, + source, + selection, emptyTemplateText, + onDependencyViolation, }: SchemaSelectionPanelProps) => { + const { + selectedNodeTypeIds, + selectedRelationTypeIds, + selectedRelationIds, + selectedTemplateNames, + requiredNodeTypeIds, + requiredRelationTypeIds, + selectAllNodeTypes, + deselectOptionalNodeTypes, + toggleNodeType, + selectAllRelationTypes, + deselectOptionalRelationTypes, + toggleRelationType, + selectAllRelationTriples, + deselectAllRelationTriples, + toggleRelationTriple, + selectAllTemplates, + deselectAllTemplates, + toggleTemplate, + } = selection; + const nodeTypeById = new Map( - nodeTypes.map((nodeType) => [nodeType.id, nodeType]), + source.nodeTypes.map((nodeType) => [nodeType.id, nodeType]), ); const relationTypeById = new Map( - relationTypes.map((relationType) => [relationType.id, relationType]), + source.relationTypes.map((relationType) => [relationType.id, relationType]), ); const templateToNodeTypeNames = new Map(); - for (const nodeType of nodeTypes) { + for (const nodeType of source.nodeTypes) { if (!nodeType.template) continue; const current = templateToNodeTypeNames.get(nodeType.template) ?? []; current.push(nodeType.name); @@ -113,21 +83,21 @@ export const SchemaSelectionPanel = ({
- {nodeTypes.map((nodeType) => { + {source.nodeTypes.map((nodeType) => { const isRequired = requiredNodeTypeIds.has(nodeType.id); return (
- {relationTypes.map((relationType) => { + {source.relationTypes.map((relationType) => { const isRequired = requiredRelationTypeIds.has(relationType.id); return (
- {relationTriples.map((relation) => { + {source.relationTriples.map((relation) => { const sourceName = nodeTypeById.get(relation.sourceId)?.name ?? relation.sourceId; const destinationName = @@ -245,7 +232,7 @@ export const SchemaSelectionPanel = ({ type="checkbox" checked={selectedRelationIds.has(relation.id)} onChange={(event) => - onToggleRelationTriple(relation.id, event.target.checked) + toggleRelationTriple(relation.id, event.target.checked) } /> @@ -270,24 +257,24 @@ export const SchemaSelectionPanel = ({
- {templateNames.length === 0 ? ( + {source.templateNames.length === 0 ? (

{emptyTemplateText}

) : (
- {templateNames.map((templateName) => ( + {source.templateNames.map((templateName) => (
-
- Current preview stats (full schema file) -
+
Preview (full schema file)
Node types: {previewStats.nodeTypes.total} total ( - {previewStats.nodeTypes.newCount} new,{" "} - {previewStats.nodeTypes.matchedById} ID matches,{" "} - {previewStats.nodeTypes.matchedByName} name matches) + {previewStats.nodeTypes.new} new, {previewStats.nodeTypes.existing}{" "} + existing)
Relation types: {previewStats.relationTypes.total} total ( - {previewStats.relationTypes.newCount} new,{" "} - {previewStats.relationTypes.matchedById} ID matches,{" "} - {previewStats.relationTypes.matchedByLabel} label matches) + {previewStats.relationTypes.new} new,{" "} + {previewStats.relationTypes.existing} existing)
Relation triples: {previewStats.discourseRelations.total} total ( - {previewStats.discourseRelations.newCount} new,{" "} - {previewStats.discourseRelations.existingCount} existing) + {previewStats.discourseRelations.new} new,{" "} + {previewStats.discourseRelations.existing} existing)
Templates: {previewStats.templates.total} total ( - {previewStats.templates.newCount} new,{" "} - {previewStats.templates.existingCount} existing) + {previewStats.templates.new} new, {previewStats.templates.existing}{" "} + existing)
diff --git a/apps/obsidian/src/components/ImportSpecsModal.tsx b/apps/obsidian/src/components/ImportSpecsModal.tsx index 61bd2aa68..bccee5ca8 100644 --- a/apps/obsidian/src/components/ImportSpecsModal.tsx +++ b/apps/obsidian/src/components/ImportSpecsModal.tsx @@ -3,12 +3,12 @@ import { useMemo, useState } from "react"; import type DiscourseGraphPlugin from "~/index"; import { applySchemaImportSelection, - ImportFileSelectionCancelledError, pickAndPreviewSchemaImport, type ImportPreviewStats, type LoadedSchemaFile, type SpecImportPreview, } from "~/utils/specImport"; +import { NativeFileDialogCancelledError } from "~/utils/nativeJsonFileDialogs"; import { useSchemaSelection, type SchemaSelectionSource, @@ -91,8 +91,9 @@ const ImportPreviewSelection = ({ }, }); + const { created } = result; new Notice( - `Import complete: ${result.nodeTypes.created} node type(s), ${result.relationTypes.created} relation type(s), ${result.discourseRelations.created} relation triple(s), and ${result.templates.created} template(s) created.`, + `Import complete: ${created.nodeTypes} node type(s), ${created.relationTypes} relation type(s), ${created.discourseRelations} relation triple(s), and ${created.templates} template(s) created.`, 7000, ); @@ -150,7 +151,7 @@ const ImportSpecsContent = ({ plugin, onClose }: ImportSpecsModalProps) => { const nextPreview = await pickAndPreviewSchemaImport({ plugin }); setPreview(nextPreview); } catch (error) { - if (error instanceof ImportFileSelectionCancelledError) { + if (error instanceof NativeFileDialogCancelledError) { return; } console.error("Failed to load schema import file:", error); diff --git a/apps/obsidian/src/types.ts b/apps/obsidian/src/types.ts index 86b31acf7..a43ba9ae3 100644 --- a/apps/obsidian/src/types.ts +++ b/apps/obsidian/src/types.ts @@ -122,20 +122,13 @@ export type DiscourseSchemaTemplate = { content: string; }; -export type DiscourseSchemaRelationType = Omit< - DiscourseRelationType, - "color" -> & { - color: string; -}; - export type DiscourseSchemaFile = { version: number; exportedAt: string; pluginVersion: string; vaultName: string; nodeTypes: DiscourseNode[]; - relationTypes: DiscourseSchemaRelationType[]; + relationTypes: DiscourseRelationType[]; discourseRelations: DiscourseRelation[]; templates: DiscourseSchemaTemplate[]; }; diff --git a/apps/obsidian/src/utils/nativeJsonFileDialogs.ts b/apps/obsidian/src/utils/nativeJsonFileDialogs.ts index c6486a18c..9a784c61e 100644 --- a/apps/obsidian/src/utils/nativeJsonFileDialogs.ts +++ b/apps/obsidian/src/utils/nativeJsonFileDialogs.ts @@ -9,12 +9,12 @@ type OpenDialogResult = { }; type ElectronDialog = { - showSaveDialog?: (options: { + showSaveDialog: (options: { title: string; defaultPath: string; filters: Array<{ name: string; extensions: string[] }>; }) => Promise; - showOpenDialog?: (options: { + showOpenDialog: (options: { title: string; properties: string[]; filters: Array<{ name: string; extensions: string[] }>; @@ -33,34 +33,8 @@ type FsPromisesLike = { writeFile: (path: string, data: string, encoding: string) => Promise; }; -type SaveFilePickerHandle = { - name: string; - createWritable: () => Promise<{ - write: (data: string) => Promise; - close: () => Promise; - }>; -}; - -type OpenFilePickerHandle = { - getFile: () => Promise; -}; - -type BrowserWindowWithPickers = Window & { - require?: (name: string) => unknown; - showSaveFilePicker?: (options: { - suggestedName: string; - types: Array<{ - description: string; - accept: Record; - }>; - }) => Promise; - showOpenFilePicker?: (options: { - multiple: boolean; - types: Array<{ - description: string; - accept: Record; - }>; - }) => Promise; +type ElectronWindow = Window & { + require: (name: string) => unknown; }; export class NativeFileDialogCancelledError extends Error { @@ -70,98 +44,48 @@ export class NativeFileDialogCancelledError extends Error { } } -const isAbortError = (error: unknown): boolean => { - return error instanceof Error && error.name === "AbortError"; -}; - -const getBrowserWindow = (): BrowserWindowWithPickers | null => { - if (typeof window === "undefined") { - return null; +const getElectronWindow = (): ElectronWindow => { + if (typeof window === "undefined" || !("require" in window)) { + throw new Error( + "Schema export/import requires Obsidian desktop (Electron).", + ); } - return window as BrowserWindowWithPickers; + return window as ElectronWindow; }; -const getFsPromises = (value: unknown): FsPromisesLike => { +const getFsPromises = (electronWindow: ElectronWindow): FsPromisesLike => { + const fsPromises = electronWindow.require("fs/promises"); if ( - typeof value !== "object" || - value === null || - !("readFile" in value) || - !("writeFile" in value) || - typeof (value as { readFile: unknown }).readFile !== "function" || - typeof (value as { writeFile: unknown }).writeFile !== "function" + typeof fsPromises !== "object" || + fsPromises === null || + !("readFile" in fsPromises) || + !("writeFile" in fsPromises) ) { throw new Error("Unable to access filesystem read/write APIs."); } - return value as FsPromisesLike; + return fsPromises as FsPromisesLike; }; -const getElectronDialog = (value: unknown): ElectronDialog | null => { - if (typeof value !== "object" || value === null) { - return null; - } - const electronLike = value as ElectronLike; - const directDialog = electronLike.dialog; - if (directDialog) { - return directDialog; - } - const remoteDialog = electronLike.remote?.dialog; - if (remoteDialog) { - return remoteDialog; - } - return null; -}; - -const saveWithFileSystemAccessApi = async ({ - fileName, - content, -}: { - fileName: string; - content: string; -}): Promise => { - const browserWindow = getBrowserWindow(); - if (!browserWindow?.showSaveFilePicker) { - return null; - } - try { - const fileHandle = await browserWindow.showSaveFilePicker({ - suggestedName: fileName, - types: [ - { - description: "JSON files", - accept: { "application/json": [".json"] }, - }, - ], - }); - const writable = await fileHandle.createWritable(); - await writable.write(content); - await writable.close(); - return fileHandle.name; - } catch (error) { - if (isAbortError(error)) { - throw new NativeFileDialogCancelledError(); - } - throw error; +const getElectronDialog = (electronWindow: ElectronWindow): ElectronDialog => { + const electron = electronWindow.require("electron") as ElectronLike; + const dialog = electron.dialog ?? electron.remote?.dialog; + if (!dialog?.showSaveDialog || !dialog.showOpenDialog) { + throw new Error("Unable to access Electron file dialogs."); } + return dialog; }; -const saveWithElectronDialog = async ({ +export const saveJsonToUserLocation = async ({ + title, fileName, content, - title, }: { + title: string; fileName: string; content: string; - title: string; -}): Promise => { - const browserWindow = getBrowserWindow(); - if (!browserWindow?.require) { - return null; - } - const electron = browserWindow.require("electron"); - const dialog = getElectronDialog(electron); - if (!dialog?.showSaveDialog) { - return null; - } +}): Promise => { + const electronWindow = getElectronWindow(); + const dialog = getElectronDialog(electronWindow); const result = await dialog.showSaveDialog({ title, defaultPath: fileName, @@ -170,79 +94,18 @@ const saveWithElectronDialog = async ({ if (result.canceled || !result.filePath) { throw new NativeFileDialogCancelledError(); } - const fsPromises = getFsPromises(browserWindow.require("fs/promises")); + const fsPromises = getFsPromises(electronWindow); await fsPromises.writeFile(result.filePath, content, "utf8"); return result.filePath; }; -const triggerBrowserDownload = ({ - fileName, - content, -}: { - fileName: string; - content: string; -}): string => { - const blob = new Blob([content], { type: "application/json" }); - const url = URL.createObjectURL(blob); - const anchor = document.createElement("a"); - anchor.href = url; - anchor.download = fileName; - anchor.style.display = "none"; - document.body.appendChild(anchor); - anchor.click(); - document.body.removeChild(anchor); - URL.revokeObjectURL(url); - return fileName; -}; - -const openWithFileSystemAccessApi = async (): Promise<{ - content: string; - sourcePath: string; -} | null> => { - const browserWindow = getBrowserWindow(); - if (!browserWindow?.showOpenFilePicker) { - return null; - } - try { - const [fileHandle] = await browserWindow.showOpenFilePicker({ - multiple: false, - types: [ - { - description: "JSON files", - accept: { "application/json": [".json"] }, - }, - ], - }); - if (!fileHandle) { - throw new NativeFileDialogCancelledError(); - } - const file = await fileHandle.getFile(); - return { - content: await file.text(), - sourcePath: file.name, - }; - } catch (error) { - if (isAbortError(error)) { - throw new NativeFileDialogCancelledError(); - } - throw error; - } -}; - -const openWithElectronDialog = async ({ +export const openJsonFromUserLocation = async ({ title, }: { title: string; -}): Promise<{ content: string; sourcePath: string } | null> => { - const browserWindow = getBrowserWindow(); - if (!browserWindow?.require) { - return null; - } - const electron = browserWindow.require("electron"); - const dialog = getElectronDialog(electron); - if (!dialog?.showOpenDialog) { - return null; - } +}): Promise<{ content: string; sourcePath: string }> => { + const electronWindow = getElectronWindow(); + const dialog = getElectronDialog(electronWindow); const result = await dialog.showOpenDialog({ title, properties: ["openFile"], @@ -251,53 +114,8 @@ const openWithElectronDialog = async ({ if (result.canceled || !result.filePaths[0]) { throw new NativeFileDialogCancelledError(); } - const fsPromises = getFsPromises(browserWindow.require("fs/promises")); + const fsPromises = getFsPromises(electronWindow); const sourcePath = result.filePaths[0]; const content = await fsPromises.readFile(sourcePath, "utf8"); return { content, sourcePath }; }; - -export const saveJsonToUserLocation = async ({ - title, - fileName, - content, -}: { - title: string; - fileName: string; - content: string; -}): Promise => { - const pathFromFsApi = await saveWithFileSystemAccessApi({ - fileName, - content, - }); - if (pathFromFsApi) { - return pathFromFsApi; - } - const pathFromElectron = await saveWithElectronDialog({ - title, - fileName, - content, - }); - if (pathFromElectron) { - return pathFromElectron; - } - return triggerBrowserDownload({ fileName, content }); -}; - -export const openJsonFromUserLocation = async ({ - title, -}: { - title: string; -}): Promise<{ content: string; sourcePath: string }> => { - const fromFsApi = await openWithFileSystemAccessApi(); - if (fromFsApi) { - return fromFsApi; - } - const fromElectron = await openWithElectronDialog({ title }); - if (fromElectron) { - return fromElectron; - } - throw new Error( - "Schema import requires a file picker. Your environment does not expose one.", - ); -}; diff --git a/apps/obsidian/src/utils/specExport.ts b/apps/obsidian/src/utils/specExport.ts index 8a2066680..6c70408c7 100644 --- a/apps/obsidian/src/utils/specExport.ts +++ b/apps/obsidian/src/utils/specExport.ts @@ -3,20 +3,15 @@ import type DiscourseGraphPlugin from "~/index"; import type { DiscourseNode, DiscourseRelation, - DiscourseRelationType, + DiscourseSchemaFile, + DiscourseSchemaTemplate, } from "~/types"; import { DG_SCHEMA_EXPORT_VERSION, getDgSchemaFileName, - parseDgSchemaFile, - type DgSchemaFile, - type TemplateExportRecord, } from "~/utils/specValidation"; import { getTemplatePluginInfo } from "~/utils/templates"; -import { - NativeFileDialogCancelledError, - saveJsonToUserLocation, -} from "~/utils/nativeJsonFileDialogs"; +import { saveJsonToUserLocation } from "~/utils/nativeJsonFileDialogs"; export type SpecExportSelection = { nodeTypeIds: string[]; @@ -25,21 +20,8 @@ export type SpecExportSelection = { templateNames: string[]; }; -export type SpecExportDependencySummary = { - autoIncludedNodeTypeIds: string[]; - autoIncludedRelationTypeIds: string[]; -}; - export type SpecExportResult = { filePath: string; - payload: DgSchemaFile; - dependencySummary: SpecExportDependencySummary; - warnings: string[]; -}; - -type BuildPayloadResult = { - payload: DgSchemaFile; - dependencySummary: SpecExportDependencySummary; warnings: string[]; }; @@ -53,9 +35,9 @@ const getTemplateContents = async ({ }: { plugin: DiscourseGraphPlugin; templateNames: string[]; -}): Promise<{ templates: TemplateExportRecord[]; warnings: string[] }> => { +}): Promise<{ templates: DiscourseSchemaTemplate[]; warnings: string[] }> => { const warnings: string[] = []; - const templates: TemplateExportRecord[] = []; + const templates: DiscourseSchemaTemplate[] = []; const { isEnabled, folderPath } = getTemplatePluginInfo(plugin.app); if (!isEnabled || !folderPath) { @@ -83,59 +65,36 @@ const getTemplateContents = async ({ return { templates, warnings }; }; -export const buildSchemaExportPayload = async ({ +const buildSchemaExportPayload = async ({ plugin, selection, }: { plugin: DiscourseGraphPlugin; selection: SpecExportSelection; -}): Promise => { +}): Promise<{ payload: DiscourseSchemaFile; warnings: string[] }> => { const nodeTypeMap = asMap(plugin.settings.nodeTypes); const relationTypeMap = asMap(plugin.settings.relationTypes); const discourseRelationMap = asMap(plugin.settings.discourseRelations); - const selectedDiscourseRelations: DiscourseRelation[] = - selection.discourseRelationIds - .map((id) => discourseRelationMap.get(id)) - .filter((relation): relation is DiscourseRelation => !!relation); - - const dependencyRelationTypeIds = new Set(); - const dependencyNodeTypeIds = new Set(); - - for (const relation of selectedDiscourseRelations) { - dependencyRelationTypeIds.add(relation.relationshipTypeId); - dependencyNodeTypeIds.add(relation.sourceId); - dependencyNodeTypeIds.add(relation.destinationId); - } - - const selectedRelationTypeIds = new Set(selection.relationTypeIds); - for (const relationTypeId of dependencyRelationTypeIds) { - selectedRelationTypeIds.add(relationTypeId); - } - - const selectedNodeTypeIds = new Set(selection.nodeTypeIds); - for (const nodeTypeId of dependencyNodeTypeIds) { - selectedNodeTypeIds.add(nodeTypeId); - } - - const selectedNodeTypes: DiscourseNode[] = [...selectedNodeTypeIds] + const selectedNodeTypes: DiscourseNode[] = selection.nodeTypeIds .map((id) => nodeTypeMap.get(id)) .filter((nodeType): nodeType is DiscourseNode => !!nodeType); - const selectedRelationTypes: DiscourseRelationType[] = [ - ...selectedRelationTypeIds, - ] + const selectedRelationTypes = selection.relationTypeIds .map((id) => relationTypeMap.get(id)) - .filter( - (relationType): relationType is DiscourseRelationType => !!relationType, - ); + .filter((relationType) => !!relationType); + + const selectedDiscourseRelations: DiscourseRelation[] = + selection.discourseRelationIds + .map((id) => discourseRelationMap.get(id)) + .filter((relation): relation is DiscourseRelation => !!relation); const { templates, warnings } = await getTemplateContents({ plugin, templateNames: selection.templateNames, }); - const payload = parseDgSchemaFile({ + const payload: DiscourseSchemaFile = { version: DG_SCHEMA_EXPORT_VERSION, exportedAt: new Date().toISOString(), pluginVersion: plugin.manifest.version, @@ -144,61 +103,29 @@ export const buildSchemaExportPayload = async ({ relationTypes: selectedRelationTypes, discourseRelations: selectedDiscourseRelations, templates, - }); - - return { - payload, - dependencySummary: { - autoIncludedNodeTypeIds: [...dependencyNodeTypeIds].filter( - (id) => !selection.nodeTypeIds.includes(id), - ), - autoIncludedRelationTypeIds: [...dependencyRelationTypeIds].filter( - (id) => !selection.relationTypeIds.includes(id), - ), - }, - warnings, }; -}; -const saveSchemaExportFile = async ({ - fileName, - content, -}: { - fileName: string; - content: string; -}): Promise => { - return saveJsonToUserLocation({ - title: "Export discourse graph schema", - fileName, - content, - }); + return { payload, warnings }; }; -export const exportSchemaSelectionToVault = async ({ +export const exportSchemaSelection = async ({ plugin, selection, }: { plugin: DiscourseGraphPlugin; selection: SpecExportSelection; }): Promise => { - const { payload, dependencySummary, warnings } = - await buildSchemaExportPayload({ - plugin, - selection, - }); + const { payload, warnings } = await buildSchemaExportPayload({ + plugin, + selection, + }); const serializedPayload = JSON.stringify(payload, null, 2); const fileName = getDgSchemaFileName(plugin.app.vault.getName()); - const filePath = await saveSchemaExportFile({ + const filePath = await saveJsonToUserLocation({ + title: "Export discourse graph schema", fileName, content: serializedPayload, }); - return { - filePath, - payload, - dependencySummary, - warnings, - }; + return { filePath, warnings }; }; - -export { NativeFileDialogCancelledError as ExportSaveCancelledError }; diff --git a/apps/obsidian/src/utils/specImport.ts b/apps/obsidian/src/utils/specImport.ts index e5ae3b39e..2335b110e 100644 --- a/apps/obsidian/src/utils/specImport.ts +++ b/apps/obsidian/src/utils/specImport.ts @@ -1,51 +1,41 @@ import type DiscourseGraphPlugin from "~/index"; import { uuidv7 } from "uuidv7"; -import { parseDgSchemaFile, type DgSchemaFile } from "~/utils/specValidation"; +import { parseDgSchemaFile } from "~/utils/specValidation"; import { createTemplateFile, getTemplateFiles } from "~/utils/templates"; -import { - NativeFileDialogCancelledError, - openJsonFromUserLocation, -} from "~/utils/nativeJsonFileDialogs"; +import { openJsonFromUserLocation } from "~/utils/nativeJsonFileDialogs"; import type { DiscourseNode, DiscourseRelation, DiscourseRelationType, + DiscourseSchemaFile, } from "~/types"; import { toTldrawColor } from "~/utils/tldrawColors"; -export type SpecImportPreview = { - loadedSchemaFile: LoadedSchemaFile; - previewStats: ImportPreviewStats; +export type SchemaImportMatchPlan = { + nodeTypeIdMapping: Map; + relationTypeIdMapping: Map; + existingNodeTypeIds: Set; + existingRelationTypeIds: Set; + existingDiscourseRelationIds: Set; + existingTemplateNames: Set; }; export type LoadedSchemaFile = { sourcePath: string; - schemaFile: DgSchemaFile; + schemaFile: DiscourseSchemaFile; + matchPlan: SchemaImportMatchPlan; }; export type ImportPreviewStats = { - nodeTypes: { - total: number; - matchedById: number; - matchedByName: number; - newCount: number; - }; - relationTypes: { - total: number; - matchedById: number; - matchedByLabel: number; - newCount: number; - }; - discourseRelations: { - total: number; - existingCount: number; - newCount: number; - }; - templates: { - total: number; - existingCount: number; - newCount: number; - }; + 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 = { @@ -56,34 +46,15 @@ export type SpecImportSelection = { }; export type SpecImportApplyResult = { - templates: { - created: number; - existing: number; - skipped: number; - }; - nodeTypes: { - created: number; - matchedById: number; - matchedByName: number; - templateAttachedToExisting: number; - }; - relationTypes: { - created: number; - matchedById: number; - matchedByLabel: number; - }; - discourseRelations: { - created: number; - existing: number; + created: { + nodeTypes: number; + relationTypes: number; + discourseRelations: number; + templates: number; }; warnings: string[]; }; -const parseSchemaFileContent = (content: string): DgSchemaFile => { - const parsed = JSON.parse(content) as unknown; - return parseDgSchemaFile(parsed); -}; - const normalizeLabel = (value: string): string => { return value.trim().toLowerCase(); }; @@ -100,68 +71,64 @@ const buildTripleKey = ({ return `${sourceId}::${relationshipTypeId}::${destinationId}`; }; -export const pickAndPreviewSchemaImport = async ({ - plugin, +const buildSchemaImportMatchPlan = ({ + schemaFile, + localNodeTypes, + localRelationTypes, + localDiscourseRelations, + localTemplateNames, }: { - plugin: DiscourseGraphPlugin; -}): Promise => { - const file = await openJsonFromUserLocation({ - title: "Import discourse graph schema", - }); - const schemaFile = parseSchemaFileContent(file.content); - + schemaFile: DiscourseSchemaFile; + localNodeTypes: DiscourseNode[]; + localRelationTypes: DiscourseRelationType[]; + localDiscourseRelations: DiscourseRelation[]; + localTemplateNames: Set; +}): SchemaImportMatchPlan => { const localNodeTypeById = new Map( - plugin.settings.nodeTypes.map((nodeType) => [nodeType.id, nodeType]), + localNodeTypes.map((nodeType) => [nodeType.id, nodeType]), ); const localNodeTypeByName = new Map( - plugin.settings.nodeTypes.map((nodeType) => [ - normalizeLabel(nodeType.name), - nodeType, - ]), + localNodeTypes.map((nodeType) => [normalizeLabel(nodeType.name), nodeType]), ); - const localRelationTypeById = new Map( - plugin.settings.relationTypes.map((relationType) => [ - relationType.id, - relationType, - ]), + localRelationTypes.map((relationType) => [relationType.id, relationType]), ); const localRelationTypeByLabel = new Map( - plugin.settings.relationTypes.map((relationType) => [ + localRelationTypes.map((relationType) => [ normalizeLabel(relationType.label), relationType, ]), ); - let nodeMatchedById = 0; - let nodeMatchedByName = 0; const nodeTypeIdMapping = new Map(); + const existingNodeTypeIds = new Set(); + for (const nodeType of schemaFile.nodeTypes) { const matchById = localNodeTypeById.get(nodeType.id); if (matchById) { - nodeMatchedById += 1; nodeTypeIdMapping.set(nodeType.id, matchById.id); + existingNodeTypeIds.add(nodeType.id); continue; } const matchByName = localNodeTypeByName.get(normalizeLabel(nodeType.name)); if (matchByName) { - nodeMatchedByName += 1; nodeTypeIdMapping.set(nodeType.id, matchByName.id); + existingNodeTypeIds.add(nodeType.id); continue; } nodeTypeIdMapping.set(nodeType.id, nodeType.id); } - let relationTypeMatchedById = 0; - let relationTypeMatchedByLabel = 0; const relationTypeIdMapping = new Map(); + const existingRelationTypeIds = new Set(); + for (const relationType of schemaFile.relationTypes) { const matchById = localRelationTypeById.get(relationType.id); if (matchById) { - relationTypeMatchedById += 1; relationTypeIdMapping.set(relationType.id, matchById.id); + existingRelationTypeIds.add(relationType.id); continue; } @@ -169,8 +136,8 @@ export const pickAndPreviewSchemaImport = async ({ normalizeLabel(relationType.label), ); if (matchByLabel) { - relationTypeMatchedByLabel += 1; relationTypeIdMapping.set(relationType.id, matchByLabel.id); + existingRelationTypeIds.add(relationType.id); continue; } @@ -178,7 +145,7 @@ export const pickAndPreviewSchemaImport = async ({ } const localTripleKeys = new Set( - plugin.settings.discourseRelations.map((relation) => + localDiscourseRelations.map((relation) => buildTripleKey({ sourceId: relation.sourceId, relationshipTypeId: relation.relationshipTypeId, @@ -187,7 +154,7 @@ export const pickAndPreviewSchemaImport = async ({ ), ); - let existingRelationCount = 0; + const existingDiscourseRelationIds = new Set(); for (const relation of schemaFile.discourseRelations) { const mappedSourceId = nodeTypeIdMapping.get(relation.sourceId) ?? relation.sourceId; @@ -202,90 +169,122 @@ export const pickAndPreviewSchemaImport = async ({ destinationId: mappedDestinationId, }); if (localTripleKeys.has(key)) { - existingRelationCount += 1; + existingDiscourseRelationIds.add(relation.id); } } - const localTemplateNames = new Set(getTemplateFiles(plugin.app)); - let existingTemplateCount = 0; + const existingTemplateNames = new Set(); for (const template of schemaFile.templates) { if (localTemplateNames.has(template.name)) { - existingTemplateCount += 1; + existingTemplateNames.add(template.name); } } - const loadedSchemaFile: LoadedSchemaFile = { - sourcePath: file.sourcePath, - schemaFile, + return { + nodeTypeIdMapping, + relationTypeIdMapping, + existingNodeTypeIds, + existingRelationTypeIds, + existingDiscourseRelationIds, + existingTemplateNames, }; +}; - const previewStats: ImportPreviewStats = { +const buildPreviewStats = ({ + schemaFile, + matchPlan, +}: { + schemaFile: DiscourseSchemaFile; + matchPlan: SchemaImportMatchPlan; +}): ImportPreviewStats => { + return { nodeTypes: { total: schemaFile.nodeTypes.length, - matchedById: nodeMatchedById, - matchedByName: nodeMatchedByName, - newCount: - schemaFile.nodeTypes.length - nodeMatchedById - nodeMatchedByName, + existing: matchPlan.existingNodeTypeIds.size, + new: schemaFile.nodeTypes.length - matchPlan.existingNodeTypeIds.size, }, relationTypes: { total: schemaFile.relationTypes.length, - matchedById: relationTypeMatchedById, - matchedByLabel: relationTypeMatchedByLabel, - newCount: + existing: matchPlan.existingRelationTypeIds.size, + new: schemaFile.relationTypes.length - - relationTypeMatchedById - - relationTypeMatchedByLabel, + matchPlan.existingRelationTypeIds.size, }, discourseRelations: { total: schemaFile.discourseRelations.length, - existingCount: existingRelationCount, - newCount: schemaFile.discourseRelations.length - existingRelationCount, + existing: matchPlan.existingDiscourseRelationIds.size, + new: + schemaFile.discourseRelations.length - + matchPlan.existingDiscourseRelationIds.size, }, templates: { total: schemaFile.templates.length, - existingCount: existingTemplateCount, - newCount: schemaFile.templates.length - existingTemplateCount, + 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, + previewStats: buildPreviewStats({ schemaFile, matchPlan }), }; }; -const applyTemplateFiles = async ({ +export const applySchemaImportSelection = async ({ plugin, - schemaFile, - selectedTemplateNames, + loadedSchemaFile, + selection, }: { plugin: DiscourseGraphPlugin; - schemaFile: DgSchemaFile; - selectedTemplateNames: Set; -}): Promise<{ - availability: Map; - created: number; - existing: number; - skipped: number; - warnings: string[]; -}> => { + loadedSchemaFile: LoadedSchemaFile; + selection: SpecImportSelection; +}): Promise => { const warnings: string[] = []; - const availability = new Map(); - let created = 0; - let existing = 0; - let skipped = 0; + 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) { - skipped += 1; warnings.push( `Template "${templateName}" was selected but not found in schema file.`, ); - availability.set(templateName, false); continue; } @@ -296,56 +295,15 @@ const applyTemplateFiles = async ({ }); if (result.created) { - created += 1; - availability.set(template.name, true); + templatesCreated += 1; continue; } - if (result.reason === "template already exists") { - existing += 1; - availability.set(template.name, true); - continue; + if (result.reason !== "template already exists") { + warnings.push(`Template "${template.name}" skipped: ${result.reason}.`); } - - skipped += 1; - availability.set(template.name, false); - warnings.push(`Template "${template.name}" skipped: ${result.reason}.`); - } - - return { availability, created, existing, skipped, warnings }; -}; - -export const applySchemaImportSelection = async ({ - plugin, - loadedSchemaFile, - selection, -}: { - plugin: DiscourseGraphPlugin; - loadedSchemaFile: LoadedSchemaFile; - selection: SpecImportSelection; -}): Promise => { - const warnings: string[] = []; - const schemaFile = loadedSchemaFile.schemaFile; - - const selectedRelationIds = new Set(selection.discourseRelationIds); - const selectedNodeTypeIds = new Set(selection.nodeTypeIds); - const selectedRelationTypeIds = new Set(selection.relationTypeIds); - const selectedTemplateNames = new Set(selection.templateNames); - - for (const relation of schemaFile.discourseRelations) { - if (!selectedRelationIds.has(relation.id)) continue; - selectedNodeTypeIds.add(relation.sourceId); - selectedNodeTypeIds.add(relation.destinationId); - selectedRelationTypeIds.add(relation.relationshipTypeId); } - const templatesResult = await applyTemplateFiles({ - plugin, - schemaFile, - selectedTemplateNames, - }); - warnings.push(...templatesResult.warnings); - const schemaNodeTypesById = new Map( schemaFile.nodeTypes.map((nodeType) => [nodeType.id, nodeType]), ); @@ -356,13 +314,12 @@ export const applySchemaImportSelection = async ({ ]), ); - const nodeTypeIdMapping = new Map(); let nodeTypesCreated = 0; - let nodeTypesMatchedById = 0; - let nodeTypesMatchedByName = 0; - let templateAttachedToExisting = 0; - for (const nodeTypeId of selectedNodeTypeIds) { + if (matchPlan.existingNodeTypeIds.has(nodeTypeId)) { + continue; + } + const importedNodeType = schemaNodeTypesById.get(nodeTypeId); if (!importedNodeType) { warnings.push( @@ -371,80 +328,25 @@ export const applySchemaImportSelection = async ({ continue; } - const matchById = plugin.settings.nodeTypes.find( - (nodeType) => nodeType.id === nodeTypeId, - ); - if (matchById) { - nodeTypesMatchedById += 1; - nodeTypeIdMapping.set(nodeTypeId, matchById.id); - if ( - importedNodeType.description && - (!matchById.description || !matchById.description.trim()) - ) { - matchById.description = importedNodeType.description; - matchById.modified = Date.now(); - } - if ( - importedNodeType.template && - selectedTemplateNames.has(importedNodeType.template) && - templatesResult.availability.get(importedNodeType.template) && - !matchById.template - ) { - matchById.template = importedNodeType.template; - matchById.modified = Date.now(); - templateAttachedToExisting += 1; - } - continue; - } - - const matchByName = plugin.settings.nodeTypes.find( - (nodeType) => - normalizeLabel(nodeType.name) === normalizeLabel(importedNodeType.name), - ); - if (matchByName) { - nodeTypesMatchedByName += 1; - nodeTypeIdMapping.set(nodeTypeId, matchByName.id); - if ( - importedNodeType.description && - (!matchByName.description || !matchByName.description.trim()) - ) { - matchByName.description = importedNodeType.description; - matchByName.modified = Date.now(); - } - if ( - importedNodeType.template && - selectedTemplateNames.has(importedNodeType.template) && - templatesResult.availability.get(importedNodeType.template) && - !matchByName.template - ) { - matchByName.template = importedNodeType.template; - matchByName.modified = Date.now(); - templateAttachedToExisting += 1; - } - continue; - } - const newNodeType: DiscourseNode = { ...importedNodeType, template: importedNodeType.template && - selectedTemplateNames.has(importedNodeType.template) && - templatesResult.availability.get(importedNodeType.template) + selectedTemplateNames.has(importedNodeType.template) ? importedNodeType.template : undefined, modified: Date.now(), }; plugin.settings.nodeTypes = [...plugin.settings.nodeTypes, newNodeType]; nodeTypesCreated += 1; - nodeTypeIdMapping.set(nodeTypeId, newNodeType.id); } - const relationTypeIdMapping = new Map(); let relationTypesCreated = 0; - let relationTypesMatchedById = 0; - let relationTypesMatchedByLabel = 0; - for (const relationTypeId of selectedRelationTypeIds) { + if (matchPlan.existingRelationTypeIds.has(relationTypeId)) { + continue; + } + const importedRelationType = schemaRelationTypesById.get(relationTypeId); if (!importedRelationType) { warnings.push( @@ -453,26 +355,6 @@ export const applySchemaImportSelection = async ({ continue; } - const matchById = plugin.settings.relationTypes.find( - (relationType) => relationType.id === relationTypeId, - ); - if (matchById) { - relationTypesMatchedById += 1; - relationTypeIdMapping.set(relationTypeId, matchById.id); - continue; - } - - const matchByLabel = plugin.settings.relationTypes.find( - (relationType) => - normalizeLabel(relationType.label) === - normalizeLabel(importedRelationType.label), - ); - if (matchByLabel) { - relationTypesMatchedByLabel += 1; - relationTypeIdMapping.set(relationTypeId, matchByLabel.id); - continue; - } - const newRelationType: DiscourseRelationType = { ...importedRelationType, color: toTldrawColor(importedRelationType.color), @@ -484,35 +366,26 @@ export const applySchemaImportSelection = async ({ newRelationType, ]; relationTypesCreated += 1; - relationTypeIdMapping.set(relationTypeId, newRelationType.id); } let discourseRelationsCreated = 0; - let discourseRelationsExisting = 0; for (const relation of schemaFile.discourseRelations) { if (!selectedRelationIds.has(relation.id)) { continue; } + if (matchPlan.existingDiscourseRelationIds.has(relation.id)) { + continue; + } const mappedSourceId = - nodeTypeIdMapping.get(relation.sourceId) ?? relation.sourceId; + matchPlan.nodeTypeIdMapping.get(relation.sourceId) ?? relation.sourceId; const mappedDestinationId = - nodeTypeIdMapping.get(relation.destinationId) ?? relation.destinationId; + matchPlan.nodeTypeIdMapping.get(relation.destinationId) ?? + relation.destinationId; const mappedRelationTypeId = - relationTypeIdMapping.get(relation.relationshipTypeId) ?? + matchPlan.relationTypeIdMapping.get(relation.relationshipTypeId) ?? relation.relationshipTypeId; - const exists = plugin.settings.discourseRelations.some( - (localRelation) => - localRelation.sourceId === mappedSourceId && - localRelation.destinationId === mappedDestinationId && - localRelation.relationshipTypeId === mappedRelationTypeId, - ); - if (exists) { - discourseRelationsExisting += 1; - continue; - } - const newRelation: DiscourseRelation = { ...relation, id: uuidv7(), @@ -532,28 +405,12 @@ export const applySchemaImportSelection = async ({ await plugin.saveSettings(); return { - templates: { - created: templatesResult.created, - existing: templatesResult.existing, - skipped: templatesResult.skipped, - }, - nodeTypes: { - created: nodeTypesCreated, - matchedById: nodeTypesMatchedById, - matchedByName: nodeTypesMatchedByName, - templateAttachedToExisting, - }, - relationTypes: { - created: relationTypesCreated, - matchedById: relationTypesMatchedById, - matchedByLabel: relationTypesMatchedByLabel, - }, - discourseRelations: { - created: discourseRelationsCreated, - existing: discourseRelationsExisting, + created: { + nodeTypes: nodeTypesCreated, + relationTypes: relationTypesCreated, + discourseRelations: discourseRelationsCreated, + templates: templatesCreated, }, warnings, }; }; - -export { NativeFileDialogCancelledError as ImportFileSelectionCancelledError }; diff --git a/apps/obsidian/src/utils/specValidation.ts b/apps/obsidian/src/utils/specValidation.ts index 68bb801cf..b9662e548 100644 --- a/apps/obsidian/src/utils/specValidation.ts +++ b/apps/obsidian/src/utils/specValidation.ts @@ -1,5 +1,5 @@ import { z } from "zod"; -import type { DiscourseSchemaFile, DiscourseSchemaTemplate } from "~/types"; +import type { DiscourseSchemaFile } from "~/types"; export const DG_SCHEMA_EXPORT_VERSION = 1; @@ -62,9 +62,6 @@ export const dgSchemaFileSchema = z.object({ templates: z.array(templateExportSchema), }); -export type DgSchemaFile = DiscourseSchemaFile; -export type TemplateExportRecord = DiscourseSchemaTemplate; - const normalizeToKebabCase = (value: string): string => { return value .trim() @@ -81,6 +78,6 @@ export const getDgSchemaFileName = (vaultName?: string): string => { return `dg-schema-${safeVaultName}.json`; }; -export const parseDgSchemaFile = (value: unknown): DgSchemaFile => { - return dgSchemaFileSchema.parse(value) as DgSchemaFile; +export const parseDgSchemaFile = (value: unknown): DiscourseSchemaFile => { + return dgSchemaFileSchema.parse(value) as DiscourseSchemaFile; }; From e6b21d00bda384f46399e856e5212cef4898750b Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 24 Jun 2026 18:15:38 -0400 Subject: [PATCH 5/5] FEE-840 Replace SchemaSelectionSource structural types with Pick<> from domain types. Remove redundant SchemaNodeTypeLike/SchemaRelationTypeLike/SchemaRelationTripleLike and SchemaSelectionInitialValues by expressing them as Pick<> expressions over the existing DiscourseNode/DiscourseRelationType/DiscourseRelation types. Fold initial selection into the hook with an optional initialTemplateNames override, and fix the useEffect reset to depend only on resetKey rather than on a new-each-render object. Co-authored-by: Cursor --- .../src/components/ExportSpecsModal.tsx | 9 +-- .../src/components/ImportSpecsModal.tsx | 8 -- .../src/components/useSchemaSelection.ts | 76 +++++++++---------- 3 files changed, 38 insertions(+), 55 deletions(-) diff --git a/apps/obsidian/src/components/ExportSpecsModal.tsx b/apps/obsidian/src/components/ExportSpecsModal.tsx index 755a85aef..e418237bf 100644 --- a/apps/obsidian/src/components/ExportSpecsModal.tsx +++ b/apps/obsidian/src/components/ExportSpecsModal.tsx @@ -43,14 +43,7 @@ const ExportSpecsContent = ({ plugin, onClose }: ExportSpecsModalProps) => { const selection = useSchemaSelection({ source, resetKey: "export", - initialValues: { - nodeTypeIds: source.nodeTypes.map((nodeType) => nodeType.id), - relationTypeIds: source.relationTypes.map( - (relationType) => relationType.id, - ), - relationIds: source.relationTriples.map((relation) => relation.id), - templateNames: [...getReferencedTemplateNames(source.nodeTypes)], - }, + initialTemplateNames: [...getReferencedTemplateNames(source.nodeTypes)], }); const handleExport = async (): Promise => { diff --git a/apps/obsidian/src/components/ImportSpecsModal.tsx b/apps/obsidian/src/components/ImportSpecsModal.tsx index bccee5ca8..7558080e8 100644 --- a/apps/obsidian/src/components/ImportSpecsModal.tsx +++ b/apps/obsidian/src/components/ImportSpecsModal.tsx @@ -56,14 +56,6 @@ const ImportPreviewSelection = ({ const selection = useSchemaSelection({ source, resetKey: loadedSchemaFile.sourcePath, - initialValues: { - nodeTypeIds: source.nodeTypes.map((nodeType) => nodeType.id), - relationTypeIds: source.relationTypes.map( - (relationType) => relationType.id, - ), - relationIds: source.relationTriples.map((relation) => relation.id), - templateNames: source.templateNames, - }, }); const handleApplyImport = async (): Promise => { diff --git a/apps/obsidian/src/components/useSchemaSelection.ts b/apps/obsidian/src/components/useSchemaSelection.ts index ae282da52..319f6047f 100644 --- a/apps/obsidian/src/components/useSchemaSelection.ts +++ b/apps/obsidian/src/components/useSchemaSelection.ts @@ -1,34 +1,17 @@ import { useEffect, useMemo, useState } from "react"; - -type SchemaNodeTypeLike = { - id: string; - name: string; - template?: string; -}; - -type SchemaRelationTypeLike = { - id: string; - label: string; -}; - -type SchemaRelationTripleLike = { - id: string; - sourceId: string; - destinationId: string; - relationshipTypeId: string; -}; +import type { + DiscourseNode, + DiscourseRelation, + DiscourseRelationType, +} from "~/types"; export type SchemaSelectionSource = { - nodeTypes: SchemaNodeTypeLike[]; - relationTypes: SchemaRelationTypeLike[]; - relationTriples: SchemaRelationTripleLike[]; - templateNames: string[]; -}; - -type SchemaSelectionInitialValues = { - nodeTypeIds: string[]; - relationTypeIds: string[]; - relationIds: string[]; + nodeTypes: Pick[]; + relationTypes: Pick[]; + relationTriples: Pick< + DiscourseRelation, + "id" | "sourceId" | "destinationId" | "relationshipTypeId" + >[]; templateNames: string[]; }; @@ -96,32 +79,47 @@ export const getReferencedTemplateNames = ( export const useSchemaSelection = ({ source, - initialValues, + initialTemplateNames, resetKey, }: { source: SchemaSelectionSource; - initialValues: SchemaSelectionInitialValues; + /** + * Template names to pre-select on mount and on reset. Defaults to all + * templates in source when not provided. + */ + initialTemplateNames?: string[]; resetKey: string; }): SchemaSelectionState => { const [selectedNodeTypeIds, setSelectedNodeTypeIds] = useState>( - () => new Set(initialValues.nodeTypeIds), + () => new Set(source.nodeTypes.map((nodeType) => nodeType.id)), ); const [selectedRelationTypeIds, setSelectedRelationTypeIds] = useState< Set - >(() => new Set(initialValues.relationTypeIds)); + >(() => new Set(source.relationTypes.map((relationType) => relationType.id))); const [selectedRelationIds, setSelectedRelationIds] = useState>( - () => new Set(initialValues.relationIds), + () => new Set(source.relationTriples.map((relation) => relation.id)), ); const [selectedTemplateNames, setSelectedTemplateNames] = useState< Set - >(() => new Set(initialValues.templateNames)); + >(() => new Set(initialTemplateNames ?? source.templateNames)); + // resetKey is the only trigger; source and initialTemplateNames are read + // from the current render's closure when resetKey changes. useEffect(() => { - setSelectedNodeTypeIds(new Set(initialValues.nodeTypeIds)); - setSelectedRelationTypeIds(new Set(initialValues.relationTypeIds)); - setSelectedRelationIds(new Set(initialValues.relationIds)); - setSelectedTemplateNames(new Set(initialValues.templateNames)); - }, [initialValues, resetKey]); + setSelectedNodeTypeIds( + new Set(source.nodeTypes.map((nodeType) => nodeType.id)), + ); + setSelectedRelationTypeIds( + new Set(source.relationTypes.map((relationType) => relationType.id)), + ); + setSelectedRelationIds( + new Set(source.relationTriples.map((relation) => relation.id)), + ); + setSelectedTemplateNames( + new Set(initialTemplateNames ?? source.templateNames), + ); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [resetKey]); const requiredRelationTypeIds = useMemo(() => { const requiredIds = new Set();