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",
diff --git a/apps/obsidian/src/utils/specImport.ts b/apps/obsidian/src/utils/specImport.ts
new file mode 100644
index 000000000..88914d57a
--- /dev/null
+++ b/apps/obsidian/src/utils/specImport.ts
@@ -0,0 +1,417 @@
+import type DiscourseGraphPlugin from "~/index";
+import { uuidv7 } from "uuidv7";
+import { parseDgSchemaFile } from "~/utils/specValidation";
+import { createTemplateFile, getTemplateFiles } from "~/utils/templates";
+import { openJsonFromUserLocation } from "~/utils/nativeJsonFileDialogs";
+import type {
+ DiscourseNode,
+ DiscourseRelation,
+ DiscourseRelationType,
+ DiscourseSchemaFile,
+} from "~/types";
+import { toTldrawColor } from "~/utils/tldrawColors";
+
+export type SchemaImportMatchPlan = {
+ nodeTypeIdMapping: Map;
+ relationTypeIdMapping: Map;
+ existingNodeTypeIds: Set;
+ existingRelationTypeIds: Set;
+ existingDiscourseRelationIds: Set;
+ existingTemplateNames: Set;
+};
+
+export type LoadedSchemaFile = {
+ sourcePath: string;
+ schemaFile: DiscourseSchemaFile;
+ matchPlan: SchemaImportMatchPlan;
+};
+
+export type ImportPreviewStats = {
+ nodeTypes: { total: number; new: number; existing: number };
+ relationTypes: { total: number; new: number; existing: number };
+ discourseRelations: { total: number; new: number; existing: number };
+ templates: { total: number; new: number; existing: number };
+};
+
+export type SpecImportPreview = {
+ loadedSchemaFile: LoadedSchemaFile;
+ previewStats: ImportPreviewStats;
+};
+
+export type SpecImportSelection = {
+ nodeTypeIds: string[];
+ relationTypeIds: string[];
+ discourseRelationIds: string[];
+ templateNames: string[];
+};
+
+export type SpecImportApplyResult = {
+ created: {
+ nodeTypes: number;
+ relationTypes: number;
+ discourseRelations: number;
+ templates: number;
+ };
+ warnings: string[];
+};
+
+const normalizeLabel = (value: string): string => {
+ return value.trim().toLowerCase();
+};
+
+const buildTripleKey = ({
+ sourceId,
+ relationshipTypeId,
+ destinationId,
+}: {
+ sourceId: string;
+ relationshipTypeId: string;
+ destinationId: string;
+}): string => {
+ return `${sourceId}::${relationshipTypeId}::${destinationId}`;
+};
+
+const buildSchemaImportMatchPlan = ({
+ schemaFile,
+ localNodeTypes,
+ localRelationTypes,
+ localDiscourseRelations,
+ localTemplateNames,
+}: {
+ schemaFile: DiscourseSchemaFile;
+ localNodeTypes: DiscourseNode[];
+ localRelationTypes: DiscourseRelationType[];
+ localDiscourseRelations: DiscourseRelation[];
+ localTemplateNames: Set;
+}): SchemaImportMatchPlan => {
+ const localNodeTypeById = new Map(
+ localNodeTypes.map((nodeType) => [nodeType.id, nodeType]),
+ );
+ const localNodeTypeByName = new Map(
+ localNodeTypes.map((nodeType) => [normalizeLabel(nodeType.name), nodeType]),
+ );
+ const localRelationTypeById = new Map(
+ localRelationTypes.map((relationType) => [relationType.id, relationType]),
+ );
+ const localRelationTypeByLabel = new Map(
+ localRelationTypes.map((relationType) => [
+ normalizeLabel(relationType.label),
+ relationType,
+ ]),
+ );
+
+ const nodeTypeIdMapping = new Map();
+ const existingNodeTypeIds = new Set();
+
+ for (const nodeType of schemaFile.nodeTypes) {
+ const matchById = localNodeTypeById.get(nodeType.id);
+ if (matchById) {
+ nodeTypeIdMapping.set(nodeType.id, matchById.id);
+ existingNodeTypeIds.add(nodeType.id);
+ continue;
+ }
+
+ const matchByName = localNodeTypeByName.get(normalizeLabel(nodeType.name));
+ if (matchByName) {
+ nodeTypeIdMapping.set(nodeType.id, matchByName.id);
+ existingNodeTypeIds.add(nodeType.id);
+ continue;
+ }
+
+ nodeTypeIdMapping.set(nodeType.id, nodeType.id);
+ }
+
+ const relationTypeIdMapping = new Map();
+ const existingRelationTypeIds = new Set();
+
+ for (const relationType of schemaFile.relationTypes) {
+ const matchById = localRelationTypeById.get(relationType.id);
+ if (matchById) {
+ relationTypeIdMapping.set(relationType.id, matchById.id);
+ existingRelationTypeIds.add(relationType.id);
+ continue;
+ }
+
+ const matchByLabel = localRelationTypeByLabel.get(
+ normalizeLabel(relationType.label),
+ );
+ if (matchByLabel) {
+ relationTypeIdMapping.set(relationType.id, matchByLabel.id);
+ existingRelationTypeIds.add(relationType.id);
+ continue;
+ }
+
+ relationTypeIdMapping.set(relationType.id, relationType.id);
+ }
+
+ const localTripleKeys = new Set(
+ localDiscourseRelations.map((relation) =>
+ buildTripleKey({
+ sourceId: relation.sourceId,
+ relationshipTypeId: relation.relationshipTypeId,
+ destinationId: relation.destinationId,
+ }),
+ ),
+ );
+
+ const existingDiscourseRelationIds = new Set();
+ for (const relation of schemaFile.discourseRelations) {
+ const mappedSourceId =
+ nodeTypeIdMapping.get(relation.sourceId) ?? relation.sourceId;
+ const mappedDestinationId =
+ nodeTypeIdMapping.get(relation.destinationId) ?? relation.destinationId;
+ const mappedRelationTypeId =
+ relationTypeIdMapping.get(relation.relationshipTypeId) ??
+ relation.relationshipTypeId;
+ const key = buildTripleKey({
+ sourceId: mappedSourceId,
+ relationshipTypeId: mappedRelationTypeId,
+ destinationId: mappedDestinationId,
+ });
+ if (localTripleKeys.has(key)) {
+ existingDiscourseRelationIds.add(relation.id);
+ }
+ }
+
+ const existingTemplateNames = new Set();
+ for (const template of schemaFile.templates) {
+ if (localTemplateNames.has(template.name)) {
+ existingTemplateNames.add(template.name);
+ }
+ }
+
+ return {
+ nodeTypeIdMapping,
+ relationTypeIdMapping,
+ existingNodeTypeIds,
+ existingRelationTypeIds,
+ existingDiscourseRelationIds,
+ existingTemplateNames,
+ };
+};
+
+const buildPreviewStats = ({
+ schemaFile,
+ matchPlan,
+}: {
+ schemaFile: DiscourseSchemaFile;
+ matchPlan: SchemaImportMatchPlan;
+}): ImportPreviewStats => {
+ return {
+ nodeTypes: {
+ total: schemaFile.nodeTypes.length,
+ existing: matchPlan.existingNodeTypeIds.size,
+ new: schemaFile.nodeTypes.length - matchPlan.existingNodeTypeIds.size,
+ },
+ relationTypes: {
+ total: schemaFile.relationTypes.length,
+ existing: matchPlan.existingRelationTypeIds.size,
+ new:
+ schemaFile.relationTypes.length -
+ matchPlan.existingRelationTypeIds.size,
+ },
+ discourseRelations: {
+ total: schemaFile.discourseRelations.length,
+ existing: matchPlan.existingDiscourseRelationIds.size,
+ new:
+ schemaFile.discourseRelations.length -
+ matchPlan.existingDiscourseRelationIds.size,
+ },
+ templates: {
+ total: schemaFile.templates.length,
+ existing: matchPlan.existingTemplateNames.size,
+ new: schemaFile.templates.length - matchPlan.existingTemplateNames.size,
+ },
+ };
+};
+
+export const pickAndPreviewSchemaImport = async ({
+ plugin,
+}: {
+ plugin: DiscourseGraphPlugin;
+}): Promise => {
+ const file = await openJsonFromUserLocation({
+ title: "Import discourse graph schema",
+ });
+ const schemaFile = parseDgSchemaFile(JSON.parse(file.content) as unknown);
+ const localTemplateNames = new Set(getTemplateFiles(plugin.app));
+ const matchPlan = buildSchemaImportMatchPlan({
+ schemaFile,
+ localNodeTypes: plugin.settings.nodeTypes,
+ localRelationTypes: plugin.settings.relationTypes,
+ localDiscourseRelations: plugin.settings.discourseRelations,
+ localTemplateNames,
+ });
+
+ const loadedSchemaFile: LoadedSchemaFile = {
+ sourcePath: file.sourcePath,
+ schemaFile,
+ matchPlan,
+ };
+
+ return {
+ loadedSchemaFile,
+ previewStats: buildPreviewStats({ schemaFile, matchPlan }),
+ };
+};
+
+export const applySchemaImportSelection = async ({
+ plugin,
+ loadedSchemaFile,
+ selection,
+}: {
+ plugin: DiscourseGraphPlugin;
+ loadedSchemaFile: LoadedSchemaFile;
+ selection: SpecImportSelection;
+}): Promise => {
+ const warnings: string[] = [];
+ const { schemaFile, matchPlan } = loadedSchemaFile;
+ const selectedTemplateNames = new Set(selection.templateNames);
+ const selectedNodeTypeIds = new Set(selection.nodeTypeIds);
+ const selectedRelationTypeIds = new Set(selection.relationTypeIds);
+ const selectedRelationIds = new Set(selection.discourseRelationIds);
+
+ let templatesCreated = 0;
+ const templatesByName = new Map(
+ schemaFile.templates.map((template) => [template.name, template]),
+ );
+ for (const templateName of selectedTemplateNames) {
+ if (matchPlan.existingTemplateNames.has(templateName)) {
+ continue;
+ }
+
+ const template = templatesByName.get(templateName);
+ if (!template) {
+ warnings.push(
+ `Template "${templateName}" was selected but not found in schema file.`,
+ );
+ continue;
+ }
+
+ const result = await createTemplateFile({
+ app: plugin.app,
+ templateName: template.name,
+ content: template.content,
+ });
+
+ if (result.created) {
+ templatesCreated += 1;
+ continue;
+ }
+
+ if (result.reason !== "template already exists") {
+ warnings.push(`Template "${template.name}" skipped: ${result.reason}.`);
+ }
+ }
+
+ const schemaNodeTypesById = new Map(
+ schemaFile.nodeTypes.map((nodeType) => [nodeType.id, nodeType]),
+ );
+ const schemaRelationTypesById = new Map(
+ schemaFile.relationTypes.map((relationType) => [
+ relationType.id,
+ relationType,
+ ]),
+ );
+
+ let nodeTypesCreated = 0;
+ for (const nodeTypeId of selectedNodeTypeIds) {
+ if (matchPlan.existingNodeTypeIds.has(nodeTypeId)) {
+ continue;
+ }
+
+ const importedNodeType = schemaNodeTypesById.get(nodeTypeId);
+ if (!importedNodeType) {
+ warnings.push(
+ `Node type "${nodeTypeId}" was selected but missing from schema file.`,
+ );
+ continue;
+ }
+
+ const newNodeType: DiscourseNode = {
+ ...importedNodeType,
+ template:
+ importedNodeType.template &&
+ (selectedTemplateNames.has(importedNodeType.template) ||
+ matchPlan.existingTemplateNames.has(importedNodeType.template))
+ ? importedNodeType.template
+ : undefined,
+ modified: Date.now(),
+ };
+ plugin.settings.nodeTypes = [...plugin.settings.nodeTypes, newNodeType];
+ nodeTypesCreated += 1;
+ }
+
+ let relationTypesCreated = 0;
+ for (const relationTypeId of selectedRelationTypeIds) {
+ if (matchPlan.existingRelationTypeIds.has(relationTypeId)) {
+ continue;
+ }
+
+ const importedRelationType = schemaRelationTypesById.get(relationTypeId);
+ if (!importedRelationType) {
+ warnings.push(
+ `Relation type "${relationTypeId}" was selected but missing from schema file.`,
+ );
+ continue;
+ }
+
+ const newRelationType: DiscourseRelationType = {
+ ...importedRelationType,
+ color: toTldrawColor(importedRelationType.color),
+ status: "provisional",
+ modified: Date.now(),
+ };
+ plugin.settings.relationTypes = [
+ ...plugin.settings.relationTypes,
+ newRelationType,
+ ];
+ relationTypesCreated += 1;
+ }
+
+ let discourseRelationsCreated = 0;
+ for (const relation of schemaFile.discourseRelations) {
+ if (!selectedRelationIds.has(relation.id)) {
+ continue;
+ }
+ if (matchPlan.existingDiscourseRelationIds.has(relation.id)) {
+ continue;
+ }
+
+ const mappedSourceId =
+ matchPlan.nodeTypeIdMapping.get(relation.sourceId) ?? relation.sourceId;
+ const mappedDestinationId =
+ matchPlan.nodeTypeIdMapping.get(relation.destinationId) ??
+ relation.destinationId;
+ const mappedRelationTypeId =
+ matchPlan.relationTypeIdMapping.get(relation.relationshipTypeId) ??
+ relation.relationshipTypeId;
+
+ const newRelation: DiscourseRelation = {
+ ...relation,
+ id: uuidv7(),
+ sourceId: mappedSourceId,
+ destinationId: mappedDestinationId,
+ relationshipTypeId: mappedRelationTypeId,
+ status: "provisional",
+ modified: Date.now(),
+ };
+ plugin.settings.discourseRelations = [
+ ...plugin.settings.discourseRelations,
+ newRelation,
+ ];
+ discourseRelationsCreated += 1;
+ }
+
+ await plugin.saveSettings();
+
+ return {
+ created: {
+ nodeTypes: nodeTypesCreated,
+ relationTypes: relationTypesCreated,
+ discourseRelations: discourseRelationsCreated,
+ templates: templatesCreated,
+ },
+ warnings,
+ };
+};