Node tag hotkey
diff --git a/apps/obsidian/src/components/ImportSchemaPreviewSummary.tsx b/apps/obsidian/src/components/ImportSchemaPreviewSummary.tsx
new file mode 100644
index 000000000..5ba1f0e5f
--- /dev/null
+++ b/apps/obsidian/src/components/ImportSchemaPreviewSummary.tsx
@@ -0,0 +1,59 @@
+import type { ImportPreviewStats, LoadedSchemaFile } from "~/utils/specImport";
+
+export const ImportSchemaPreviewSummary = ({
+ loadedSchemaFile,
+ previewStats,
+}: {
+ loadedSchemaFile: LoadedSchemaFile;
+ previewStats: ImportPreviewStats;
+}) => {
+ return (
+ <>
+
+
Schema file metadata
+
+ Vault:{" "}
+
+ {loadedSchemaFile.schemaFile.vaultName}
+
+
+
+ Exported at:{" "}
+
+ {loadedSchemaFile.schemaFile.exportedAt}
+
+
+
+ Plugin version:{" "}
+
+ {loadedSchemaFile.schemaFile.pluginVersion}
+
+
+
+
+
+
Preview (full schema file)
+
+ Node types: {previewStats.nodeTypes.total} total (
+ {previewStats.nodeTypes.new} new, {previewStats.nodeTypes.existing}{" "}
+ existing)
+
+
+ Relation types: {previewStats.relationTypes.total} total (
+ {previewStats.relationTypes.new} new,{" "}
+ {previewStats.relationTypes.existing} existing)
+
+
+ Relation triples: {previewStats.discourseRelations.total} total (
+ {previewStats.discourseRelations.new} new,{" "}
+ {previewStats.discourseRelations.existing} existing)
+
+
+ Templates: {previewStats.templates.total} total (
+ {previewStats.templates.new} new, {previewStats.templates.existing}{" "}
+ existing)
+
+
+ >
+ );
+};
diff --git a/apps/obsidian/src/components/ImportSpecsModal.tsx b/apps/obsidian/src/components/ImportSpecsModal.tsx
new file mode 100644
index 000000000..7558080e8
--- /dev/null
+++ b/apps/obsidian/src/components/ImportSpecsModal.tsx
@@ -0,0 +1,213 @@
+import { App, Notice } from "obsidian";
+import { useMemo, useState } from "react";
+import type DiscourseGraphPlugin from "~/index";
+import {
+ applySchemaImportSelection,
+ pickAndPreviewSchemaImport,
+ type ImportPreviewStats,
+ type LoadedSchemaFile,
+ type SpecImportPreview,
+} from "~/utils/specImport";
+import { NativeFileDialogCancelledError } from "~/utils/nativeJsonFileDialogs";
+import {
+ useSchemaSelection,
+ type SchemaSelectionSource,
+} from "~/components/useSchemaSelection";
+import { SchemaSelectionModalBody } from "~/components/SchemaSelectionModalBody";
+import { ImportSchemaPreviewSummary } from "~/components/ImportSchemaPreviewSummary";
+import { ReactRootModal } from "~/components/ReactRootModal";
+
+type ImportSpecsModalProps = {
+ plugin: DiscourseGraphPlugin;
+ onClose: () => void;
+};
+
+export const openImportSpecsModal = (plugin: DiscourseGraphPlugin): void => {
+ new ImportSpecsModal(plugin.app, plugin).open();
+};
+
+const ImportPreviewSelection = ({
+ plugin,
+ loadedSchemaFile,
+ previewStats,
+ isApplyingImport,
+ setIsApplyingImport,
+ onResetPreview,
+ onClose,
+}: {
+ plugin: DiscourseGraphPlugin;
+ loadedSchemaFile: LoadedSchemaFile;
+ previewStats: ImportPreviewStats;
+ isApplyingImport: boolean;
+ setIsApplyingImport: (value: boolean) => void;
+ onResetPreview: () => void;
+ onClose: () => void;
+}) => {
+ const source = useMemo
(() => {
+ const schemaFile = loadedSchemaFile.schemaFile;
+ return {
+ nodeTypes: schemaFile.nodeTypes,
+ relationTypes: schemaFile.relationTypes,
+ relationTriples: schemaFile.discourseRelations,
+ templateNames: schemaFile.templates.map((template) => template.name),
+ };
+ }, [loadedSchemaFile]);
+
+ const selection = useSchemaSelection({
+ source,
+ resetKey: loadedSchemaFile.sourcePath,
+ });
+
+ const handleApplyImport = async (): Promise => {
+ const selected = selection.asSelectionPayload();
+ const hasAnySelection =
+ selected.nodeTypeIds.length > 0 ||
+ selected.relationTypeIds.length > 0 ||
+ selected.relationIds.length > 0 ||
+ selected.templateNames.length > 0;
+ if (!hasAnySelection) {
+ new Notice("Select at least one item to import.");
+ return;
+ }
+
+ setIsApplyingImport(true);
+ try {
+ const result = await applySchemaImportSelection({
+ plugin,
+ loadedSchemaFile,
+ selection: {
+ nodeTypeIds: selected.nodeTypeIds,
+ relationTypeIds: selected.relationTypeIds,
+ discourseRelationIds: selected.relationIds,
+ templateNames: selected.templateNames,
+ },
+ });
+
+ const { created } = result;
+ new Notice(
+ `Import complete: ${created.nodeTypes} node type(s), ${created.relationTypes} relation type(s), ${created.discourseRelations} relation triple(s), and ${created.templates} template(s) created.`,
+ 7000,
+ );
+
+ if (result.warnings.length > 0) {
+ new Notice(
+ `Import completed with ${result.warnings.length} warning(s).`,
+ 6000,
+ );
+ for (const warning of result.warnings) {
+ new Notice(warning, 6000);
+ }
+ }
+ onClose();
+ } catch (error) {
+ console.error("Failed to apply schema import:", error);
+ const message = error instanceof Error ? error.message : String(error);
+ new Notice(`Failed to import schema: ${message}`, 6000);
+ } finally {
+ setIsApplyingImport(false);
+ }
+ };
+
+ return (
+ new Notice(message)}
+ beforePanel={
+
+ }
+ footerSecondaryLabel="Choose another file"
+ onFooterSecondaryClick={onResetPreview}
+ footerPrimaryLabel={isApplyingImport ? "Importing..." : "Import selected"}
+ onFooterPrimaryClick={() => void handleApplyImport()}
+ isFooterSecondaryDisabled={isApplyingImport}
+ isFooterPrimaryDisabled={isApplyingImport}
+ />
+ );
+};
+
+const ImportSpecsContent = ({ plugin, onClose }: ImportSpecsModalProps) => {
+ const [preview, setPreview] = useState(null);
+ const [isSelectingFile, setIsSelectingFile] = useState(false);
+ const [isApplyingImport, setIsApplyingImport] = useState(false);
+
+ const handleSelectSchemaFile = async (): Promise => {
+ setIsSelectingFile(true);
+ try {
+ const nextPreview = await pickAndPreviewSchemaImport({ plugin });
+ setPreview(nextPreview);
+ } catch (error) {
+ if (error instanceof NativeFileDialogCancelledError) {
+ return;
+ }
+ console.error("Failed to load schema import file:", error);
+ const message = error instanceof Error ? error.message : String(error);
+ new Notice(`Failed to load schema file: ${message}`, 6000);
+ } finally {
+ setIsSelectingFile(false);
+ }
+ };
+
+ if (!preview) {
+ return (
+
+
Import discourse graph schema
+
+ Pick a dg-schema-*.json file from your computer to
+ preview and choose exactly what to import.
+
+
+
+ Same dependency rules as export apply here during selection.
+
+
+
+
+ Cancel
+
+ void handleSelectSchemaFile()}
+ disabled={isSelectingFile}
+ >
+ {isSelectingFile ? "Opening..." : "Choose schema file"}
+
+
+
+ );
+ }
+
+ return (
+ setPreview(null)}
+ onClose={onClose}
+ />
+ );
+};
+
+export class ImportSpecsModal extends ReactRootModal {
+ private plugin: DiscourseGraphPlugin;
+
+ constructor(app: App, plugin: DiscourseGraphPlugin) {
+ super(app);
+ this.plugin = plugin;
+ }
+
+ protected renderContent() {
+ return (
+ this.close()} />
+ );
+ }
+}
diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts
index 962008695..91026dd70 100644
--- a/apps/obsidian/src/utils/registerCommands.ts
+++ b/apps/obsidian/src/utils/registerCommands.ts
@@ -15,6 +15,7 @@ import { addRelationIfRequested } from "~/components/canvas/utils/relationJsonUt
import type { DiscourseNode } from "~/types";
import { TldrawView } from "~/components/canvas/TldrawView";
import { createBaseForNodeType } from "./baseForNodeType";
+import { openImportSpecsModal } from "~/components/ImportSpecsModal";
type ModifyNodeSubmitParams = {
nodeType: DiscourseNode;
@@ -203,6 +204,14 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => {
},
});
+ plugin.addCommand({
+ id: "import-dg-schema",
+ name: "Import discourse graph schema",
+ callback: () => {
+ openImportSpecsModal(plugin);
+ },
+ });
+
plugin.addCommand({
id: "toggle-discourse-context",
name: "Toggle discourse context",