= {
+ nodeType: "Node types",
+ relationType: "Relation types",
+ template: "Templates",
+};
+
+const CATEGORY_ORDER: SchemaConflict["category"][] = [
+ "nodeType",
+ "relationType",
+ "template",
+];
+
+const formatFieldValue = (value: SchemaFieldChange["localValue"]): string => {
+ if (value === undefined || value === "") return "empty";
+ if (typeof value === "boolean") return value ? "on" : "off";
+ // Collapse newlines so a multi-line value cannot stretch the row; the cell wraps rather than truncating.
+ const collapsed = value.replace(/\s+/g, " ").trim();
+ return collapsed.length === 0 ? "empty" : collapsed;
+};
+
+const isEmptyValue = (value: SchemaFieldChange["localValue"]): boolean => {
+ return (
+ value === undefined || (typeof value === "string" && value.trim() === "")
+ );
+};
+
+/** Node types store a hex color, relation types a tldraw color name; both resolve to a swatch. */
+const resolveSwatchColor = ({
+ field,
+ value,
+}: {
+ field: string;
+ value: SchemaFieldChange["localValue"];
+}): string | undefined => {
+ if (field !== "color" || typeof value !== "string") return undefined;
+ return COLOR_PALETTE[value] ?? (value.startsWith("#") ? value : undefined);
+};
+
+const describeTemplateBody = (
+ value: SchemaFieldChange["localValue"],
+): string => {
+ if (typeof value !== "string") return formatFieldValue(value);
+ const lineCount = value.split("\n").length;
+ return `${value.length} bytes, ${lineCount} line${lineCount === 1 ? "" : "s"}`;
+};
+
+const ChoiceCell = ({
+ conflict,
+ change,
+ isImportedSide,
+ mergePlan,
+}: {
+ conflict: SchemaConflict;
+ change: SchemaFieldChange;
+ isImportedSide: boolean;
+ mergePlan: SchemaMergePlanState;
+}) => {
+ const takesImported = mergePlan.isFieldSelected({
+ category: conflict.category,
+ schemaId: conflict.schemaId,
+ field: change.field,
+ });
+ const isChosen = isImportedSide ? takesImported : !takesImported;
+ const value = isImportedSide ? change.importedValue : change.localValue;
+ const swatch = resolveSwatchColor({ field: change.field, value });
+
+ const isTemplateBody =
+ conflict.category === "template" && change.field === "content";
+ const text = isTemplateBody
+ ? describeTemplateBody(value)
+ : formatFieldValue(value);
+
+ return (
+ |
+
+ |
+ );
+};
+
+const ItemChoiceTable = ({
+ conflict,
+ mergePlan,
+ sourceVaultName,
+}: {
+ conflict: SchemaConflict;
+ mergePlan: SchemaMergePlanState;
+ sourceVaultName: string;
+}) => {
+ const selectedCount = mergePlan.countSelectedFields(conflict);
+ const isAllLocal = selectedCount === 0;
+ const isAllImported = selectedCount === conflict.changes.length;
+
+ return (
+
+
+ {conflict.label}
+
+
+
+
+
+
+
+
+
+ |
+ Field
+ |
+
+
+ |
+
+
+ |
+
+
+
+ {conflict.changes.map((change) => (
+
+ |
+ {FIELD_LABELS[change.field] ?? change.field}
+ |
+
+
+
+ ))}
+
+
+
+ );
+};
+
+export const SchemaFieldChoiceStep = ({
+ conflicts,
+ mergePlan,
+ sourceVaultName,
+}: {
+ conflicts: SchemaConflict[];
+ mergePlan: SchemaMergePlanState;
+ sourceVaultName: string;
+}) => {
+ const totalFields = conflicts.reduce(
+ (total, conflict) => total + conflict.changes.length,
+ 0,
+ );
+ const selectedFields = conflicts.reduce(
+ (total, conflict) => total + mergePlan.countSelectedFields(conflict),
+ 0,
+ );
+ const templateConflicts = conflicts.filter(
+ (conflict) => conflict.category === "template",
+ );
+
+ return (
+ <>
+
+
+ {conflicts.length} item(s) already in this vault
+
+
+ Every field starts on your value. Names are never changed by an
+ import, because renaming a type does not retag the pages already using
+ it.
+
+
+
+
+ {CATEGORY_ORDER.map((category) => {
+ const categoryConflicts = conflicts.filter(
+ (conflict) => conflict.category === category,
+ );
+ if (categoryConflicts.length === 0) return null;
+
+ return (
+
+
+ {CATEGORY_HEADINGS[category]} ({categoryConflicts.length})
+
+ {categoryConflicts.map((conflict) => (
+
+ ))}
+
+ );
+ })}
+
+
+ {templateConflicts.length > 0 && (
+
+ ⧉ Your template file is never overwritten — the imported version is
+ added beside it as{" "}
+ {templateConflicts
+ .map((conflict) =>
+ getImportedTemplateFileName({
+ templateName: conflict.schemaId,
+ sourceName: sourceVaultName,
+ }),
+ )
+ .map((name) => `${name}.md`)
+ .join(", ")}
+ , and node types using it are repointed at the copy.
+
+ )}
+
+
+ {selectedFields} of {totalFields} field(s) will come from the file
+
+ >
+ );
+};
diff --git a/apps/obsidian/src/components/SchemaSelectionPanel.tsx b/apps/obsidian/src/components/SchemaSelectionPanel.tsx
index 23848e1da..79dcc4e7e 100644
--- a/apps/obsidian/src/components/SchemaSelectionPanel.tsx
+++ b/apps/obsidian/src/components/SchemaSelectionPanel.tsx
@@ -7,12 +7,17 @@ type SchemaSelectionPanelProps = {
source: SchemaSelectionSource;
selection: SchemaSelectionState;
onDependencyViolation?: (message: string) => void;
+ /** Import-only: marks what the vault already has. Export has nothing to compare against and passes neither. */
+ nodeTypeNotes?: ReadonlyMap;
+ relationTypeNotes?: ReadonlyMap;
};
export const SchemaSelectionPanel = ({
source,
selection,
onDependencyViolation,
+ nodeTypeNotes,
+ relationTypeNotes,
}: SchemaSelectionPanelProps) => {
const {
selectedNodeTypeIds,
@@ -113,6 +118,11 @@ export const SchemaSelectionPanel = ({
disabled={isRequired}
/>
{nodeType.name}
+ {nodeTypeNotes?.get(nodeType.id) && (
+
+ {nodeTypeNotes.get(nodeType.id)}
+
+ )}
{isRequired && (
required by selected triple
@@ -171,6 +181,11 @@ export const SchemaSelectionPanel = ({
disabled={isRequired}
/>
{relationType.label}
+ {relationTypeNotes?.get(relationType.id) && (
+
+ {relationTypeNotes.get(relationType.id)}
+
+ )}
{isRequired && (
required by selected triple
diff --git a/apps/obsidian/src/components/useSchemaMergePlan.ts b/apps/obsidian/src/components/useSchemaMergePlan.ts
new file mode 100644
index 000000000..a085fdf2f
--- /dev/null
+++ b/apps/obsidian/src/components/useSchemaMergePlan.ts
@@ -0,0 +1,186 @@
+import { useEffect, useState } from "react";
+import type {
+ SchemaConflict,
+ SchemaConflictCategory,
+} from "~/utils/schemaFieldDiff";
+import type { SchemaMergePlan } from "~/utils/specImport";
+
+/** Kept out of useSchemaSelection: a choice only outlives the selection it belongs to, so it resets separately. */
+export type SchemaMergePlanState = {
+ isFieldSelected: (args: {
+ category: SchemaConflictCategory;
+ schemaId: string;
+ field: string;
+ }) => boolean;
+ toggleField: (args: {
+ category: SchemaConflictCategory;
+ schemaId: string;
+ field: string;
+ shouldSelect: boolean;
+ }) => void;
+ setAllFields: (args: {
+ conflict: SchemaConflict;
+ shouldSelect: boolean;
+ }) => void;
+ countSelectedFields: (conflict: SchemaConflict) => number;
+ asMergePlan: () => SchemaMergePlan;
+};
+
+type FieldSelections = ReadonlyMap>;
+
+/** An empty entry and an absent one mean the same to the apply path, so empties are dropped. */
+const withFields = ({
+ selections,
+ schemaId,
+ fields,
+}: {
+ selections: FieldSelections;
+ schemaId: string;
+ fields: ReadonlySet;
+}): FieldSelections => {
+ const nextSelections = new Map(selections);
+ if (fields.size === 0) {
+ nextSelections.delete(schemaId);
+ } else {
+ nextSelections.set(schemaId, fields);
+ }
+ return nextSelections;
+};
+
+const withFieldToggled = ({
+ selections,
+ schemaId,
+ field,
+ shouldSelect,
+}: {
+ selections: FieldSelections;
+ schemaId: string;
+ field: string;
+ shouldSelect: boolean;
+}): FieldSelections => {
+ const nextFields = new Set(selections.get(schemaId) ?? []);
+ if (shouldSelect) {
+ nextFields.add(field);
+ } else {
+ nextFields.delete(field);
+ }
+ return withFields({ selections, schemaId, fields: nextFields });
+};
+
+const withNameToggled = ({
+ names,
+ name,
+ shouldSelect,
+}: {
+ names: ReadonlySet;
+ name: string;
+ shouldSelect: boolean;
+}): ReadonlySet => {
+ const nextNames = new Set(names);
+ if (shouldSelect) {
+ nextNames.add(name);
+ } else {
+ nextNames.delete(name);
+ }
+ return nextNames;
+};
+
+export const useSchemaMergePlan = ({
+ resetKey,
+}: {
+ resetKey: string;
+}): SchemaMergePlanState => {
+ const [nodeTypeFields, setNodeTypeFields] = useState(
+ () => new Map(),
+ );
+ const [relationTypeFields, setRelationTypeFields] = useState(
+ () => new Map(),
+ );
+ const [templateNames, setTemplateNames] = useState>(
+ () => new Set(),
+ );
+
+ useEffect(() => {
+ setNodeTypeFields(new Map());
+ setRelationTypeFields(new Map());
+ setTemplateNames(new Set());
+ }, [resetKey]);
+
+ const isFieldSelected: SchemaMergePlanState["isFieldSelected"] = ({
+ category,
+ schemaId,
+ field,
+ }) => {
+ if (category === "template") return templateNames.has(schemaId);
+ const selections =
+ category === "nodeType" ? nodeTypeFields : relationTypeFields;
+ return selections.get(schemaId)?.has(field) ?? false;
+ };
+
+ const toggleField: SchemaMergePlanState["toggleField"] = ({
+ category,
+ schemaId,
+ field,
+ shouldSelect,
+ }) => {
+ if (category === "template") {
+ setTemplateNames((previousNames) =>
+ withNameToggled({ names: previousNames, name: schemaId, shouldSelect }),
+ );
+ return;
+ }
+ const setSelections =
+ category === "nodeType" ? setNodeTypeFields : setRelationTypeFields;
+ setSelections((previousSelections) =>
+ withFieldToggled({
+ selections: previousSelections,
+ schemaId,
+ field,
+ shouldSelect,
+ }),
+ );
+ };
+
+ return {
+ isFieldSelected,
+ toggleField,
+ setAllFields: ({ conflict, shouldSelect }) => {
+ if (conflict.category === "template") {
+ setTemplateNames((previousNames) =>
+ withNameToggled({
+ names: previousNames,
+ name: conflict.schemaId,
+ shouldSelect,
+ }),
+ );
+ return;
+ }
+ const setSelections =
+ conflict.category === "nodeType"
+ ? setNodeTypeFields
+ : setRelationTypeFields;
+ setSelections((previousSelections) =>
+ withFields({
+ selections: previousSelections,
+ schemaId: conflict.schemaId,
+ fields: shouldSelect
+ ? new Set(conflict.changes.map((change) => change.field))
+ : new Set(),
+ }),
+ );
+ },
+ countSelectedFields: (conflict) =>
+ conflict.changes.filter((change) =>
+ isFieldSelected({
+ category: conflict.category,
+ schemaId: conflict.schemaId,
+ field: change.field,
+ }),
+ ).length,
+ asMergePlan: () => ({
+ nodeTypeFields,
+ relationTypeFields,
+ templateNames,
+ }),
+ };
+};
diff --git a/apps/obsidian/src/utils/registerCommands.ts b/apps/obsidian/src/utils/registerCommands.ts
index 29e7285d1..4d39c9ff4 100644
--- a/apps/obsidian/src/utils/registerCommands.ts
+++ b/apps/obsidian/src/utils/registerCommands.ts
@@ -15,6 +15,7 @@ import { addRelationIfRequested } from "~/components/canvas/utils/relationJsonUt
import type { DiscourseNode } from "~/types";
import { TldrawView } from "~/components/canvas/TldrawView";
import { createBaseForNodeType } from "./baseForNodeType";
+import { openImportSpecsModal } from "~/components/ImportSpecsModal";
type ModifyNodeSubmitParams = {
nodeType: DiscourseNode;
@@ -201,6 +202,14 @@ export const registerCommands = (plugin: DiscourseGraphPlugin) => {
callback: () => openExportSpecsModal(plugin),
});
+ plugin.addCommand({
+ id: "import-dg-schema",
+ name: "Import discourse graph schema",
+ callback: () => {
+ openImportSpecsModal(plugin);
+ },
+ });
+
plugin.addCommand({
id: "toggle-discourse-context",
name: "Toggle discourse context",