From ab2874891ae71133abafb696a87312d754ec4cda Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 28 Jul 2026 22:39:22 -0400 Subject: [PATCH 01/10] ENG-1977 Add schema import data layer for Obsidian --- apps/obsidian/src/utils/specImport.ts | 417 ++++++++++++++++++++++++++ 1 file changed, 417 insertions(+) create mode 100644 apps/obsidian/src/utils/specImport.ts 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, + }; +}; From 01c7342230985add792718fdfdd862b504b1c00c Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 28 Jul 2026 22:59:18 -0400 Subject: [PATCH 02/10] ENG-1977 Fix template reference preserved against full local template set, not schema intersection --- apps/obsidian/src/utils/specImport.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/obsidian/src/utils/specImport.ts b/apps/obsidian/src/utils/specImport.ts index 88914d57a..a591ba186 100644 --- a/apps/obsidian/src/utils/specImport.ts +++ b/apps/obsidian/src/utils/specImport.ts @@ -18,6 +18,7 @@ export type SchemaImportMatchPlan = { existingRelationTypeIds: Set; existingDiscourseRelationIds: Set; existingTemplateNames: Set; + localTemplateNames: Set; }; export type LoadedSchemaFile = { @@ -187,6 +188,7 @@ const buildSchemaImportMatchPlan = ({ existingRelationTypeIds, existingDiscourseRelationIds, existingTemplateNames, + localTemplateNames, }; }; @@ -333,7 +335,7 @@ export const applySchemaImportSelection = async ({ template: importedNodeType.template && (selectedTemplateNames.has(importedNodeType.template) || - matchPlan.existingTemplateNames.has(importedNodeType.template)) + matchPlan.localTemplateNames.has(importedNodeType.template)) ? importedNodeType.template : undefined, modified: Date.now(), From e3bc26ceda811bbd34f714119e58f503a1960bb3 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 30 Jul 2026 12:52:26 -0400 Subject: [PATCH 03/10] ENG-1977 Drop SpecImportSelection, use shared SchemaSelection from ~/types Co-Authored-By: Claude Sonnet 4.6 --- apps/obsidian/src/utils/specImport.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/apps/obsidian/src/utils/specImport.ts b/apps/obsidian/src/utils/specImport.ts index a591ba186..748630683 100644 --- a/apps/obsidian/src/utils/specImport.ts +++ b/apps/obsidian/src/utils/specImport.ts @@ -8,6 +8,7 @@ import type { DiscourseRelation, DiscourseRelationType, DiscourseSchemaFile, + SchemaSelection, } from "~/types"; import { toTldrawColor } from "~/utils/tldrawColors"; @@ -39,12 +40,6 @@ export type SpecImportPreview = { previewStats: ImportPreviewStats; }; -export type SpecImportSelection = { - nodeTypeIds: string[]; - relationTypeIds: string[]; - discourseRelationIds: string[]; - templateNames: string[]; -}; export type SpecImportApplyResult = { created: { @@ -264,7 +259,7 @@ export const applySchemaImportSelection = async ({ }: { plugin: DiscourseGraphPlugin; loadedSchemaFile: LoadedSchemaFile; - selection: SpecImportSelection; + selection: SchemaSelection; }): Promise => { const warnings: string[] = []; const { schemaFile, matchPlan } = loadedSchemaFile; From 4c3871cf54f297fe10021eda372bc74f1c9325de Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 30 Jul 2026 13:17:50 -0400 Subject: [PATCH 04/10] ENG-1977 Replace warnings return value with onWarning callback in applySchemaImportSelection Co-Authored-By: Claude Sonnet 4.6 --- apps/obsidian/src/utils/specImport.ts | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/apps/obsidian/src/utils/specImport.ts b/apps/obsidian/src/utils/specImport.ts index 748630683..71c4423c3 100644 --- a/apps/obsidian/src/utils/specImport.ts +++ b/apps/obsidian/src/utils/specImport.ts @@ -48,7 +48,6 @@ export type SpecImportApplyResult = { discourseRelations: number; templates: number; }; - warnings: string[]; }; const normalizeLabel = (value: string): string => { @@ -256,12 +255,13 @@ export const applySchemaImportSelection = async ({ plugin, loadedSchemaFile, selection, + onWarning = () => {}, }: { plugin: DiscourseGraphPlugin; loadedSchemaFile: LoadedSchemaFile; selection: SchemaSelection; + onWarning?: (message: string) => void; }): Promise => { - const warnings: string[] = []; const { schemaFile, matchPlan } = loadedSchemaFile; const selectedTemplateNames = new Set(selection.templateNames); const selectedNodeTypeIds = new Set(selection.nodeTypeIds); @@ -279,7 +279,7 @@ export const applySchemaImportSelection = async ({ const template = templatesByName.get(templateName); if (!template) { - warnings.push( + onWarning( `Template "${templateName}" was selected but not found in schema file.`, ); continue; @@ -297,7 +297,7 @@ export const applySchemaImportSelection = async ({ } if (result.reason !== "template already exists") { - warnings.push(`Template "${template.name}" skipped: ${result.reason}.`); + onWarning(`Template "${template.name}" skipped: ${result.reason}.`); } } @@ -319,7 +319,7 @@ export const applySchemaImportSelection = async ({ const importedNodeType = schemaNodeTypesById.get(nodeTypeId); if (!importedNodeType) { - warnings.push( + onWarning( `Node type "${nodeTypeId}" was selected but missing from schema file.`, ); continue; @@ -347,7 +347,7 @@ export const applySchemaImportSelection = async ({ const importedRelationType = schemaRelationTypesById.get(relationTypeId); if (!importedRelationType) { - warnings.push( + onWarning( `Relation type "${relationTypeId}" was selected but missing from schema file.`, ); continue; @@ -409,6 +409,5 @@ export const applySchemaImportSelection = async ({ discourseRelations: discourseRelationsCreated, templates: templatesCreated, }, - warnings, }; }; From 2f98215f07ea0a53e680682d44ce139bea645a91 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 30 Jul 2026 13:49:11 -0400 Subject: [PATCH 05/10] ENG-1977 Share schema matching between file and Supabase import paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The remote-space import and the schema-file import both had to answer "does this incoming type already exist locally?", and answered it differently: the Supabase path compared names and labels case-sensitively while specImport lowercased them. The same vault reached through the two paths would dedupe differently. Extracts schemaMatching.ts with the id-then-name/label fallback, the triple identity check, and buildSchemaRid — which pins the "schema" RID subtype so both paths emit byte-identical RIDs. Node instance and relation instance RIDs keep their own "note"/"relation" subtypes and are untouched. Matching is now case-insensitive on both paths. This is a behavior change to the Supabase import: importing a "Claim" type into a vault holding "claim" now reuses the local type instead of creating a near-duplicate. specImport sets importedFromRid from the file's vaultId, so schema imported from a file and content imported from that same vault via Supabase resolve to the same RID. Like the Supabase path, this records the immediate source vault rather than preserving an older origin. Also fixes a duplicate-triple hole the case-insensitive matching widens: the apply loop guarded against triples that existed at plan time but not against ones created earlier in the same run, so two schema node types collapsing onto one local type produced duplicate triples. The check now runs against live settings. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/utils/importNodes.ts | 31 ++--- apps/obsidian/src/utils/importRelations.ts | 48 ++++---- apps/obsidian/src/utils/schemaMatching.ts | 95 +++++++++++++++ apps/obsidian/src/utils/specImport.ts | 135 +++++++++------------ 4 files changed, 185 insertions(+), 124 deletions(-) create mode 100644 apps/obsidian/src/utils/schemaMatching.ts diff --git a/apps/obsidian/src/utils/importNodes.ts b/apps/obsidian/src/utils/importNodes.ts index 06ba93edd..51d90802f 100644 --- a/apps/obsidian/src/utils/importNodes.ts +++ b/apps/obsidian/src/utils/importNodes.ts @@ -20,6 +20,7 @@ import { } from "./importRelations"; import { createTemplateFile } from "./templates"; import { resolveFolderForSpaceUri } from "./importFolderMetadata"; +import { buildSchemaRid, findLocalNodeTypeMatch } from "./schemaMatching"; type PublishedNode = { source_local_id: string; @@ -1067,20 +1068,13 @@ export const mapNodeTypeIdToLocal = async ({ const schemaName = schemaData.name; - // Prefer match by node type ID (imported type may already exist locally with same id) - const matchById = plugin.settings.nodeTypes.find( - (nt) => nt.id === sourceNodeTypeId, - ); - if (matchById) { - return matchById.id; - } - - // Fall back to match by name - const matchingLocalNodeType = plugin.settings.nodeTypes.find( - (nt) => nt.name === schemaName, - ); - if (matchingLocalNodeType) { - return matchingLocalNodeType.id; + const localMatch = findLocalNodeTypeMatch({ + localNodeTypes: plugin.settings.nodeTypes, + id: sourceNodeTypeId, + name: schemaName, + }); + if (localMatch) { + return localMatch.id; } // No matching local nodeType: create one from literal_content and add to settings @@ -1090,11 +1084,10 @@ export const mapNodeTypeIdToLocal = async ({ ); const now = new Date().getTime(); - const importedFromRid = spaceUriAndLocalIdToRid( - sourceSpaceUri, - sourceNodeTypeId, - "schema", - ); + const importedFromRid = buildSchemaRid({ + spaceUri: sourceSpaceUri, + localId: sourceNodeTypeId, + }); const newNodeType: DiscourseNode = { id: sourceNodeTypeId, diff --git a/apps/obsidian/src/utils/importRelations.ts b/apps/obsidian/src/utils/importRelations.ts index a3efd01e1..4a6d15536 100644 --- a/apps/obsidian/src/utils/importRelations.ts +++ b/apps/obsidian/src/utils/importRelations.ts @@ -11,6 +11,11 @@ import { } from "./relationsStore"; import { DEFAULT_TLDRAW_COLOR } from "./tldrawColors"; import { mapNodeTypeIdToLocal } from "./importNodes"; +import { + buildSchemaRid, + findExistingTriple, + findLocalRelationTypeMatch, +} from "./schemaMatching"; type ConceptInRelation = { id: number; @@ -66,29 +71,22 @@ const mapRelationTypeToLocal = async ({ const label = (obj.label as string) || schemaData.name; const complement = (obj.complement as string) || ""; - // Match by id first; if id exists locally with different label/complement, use local - const matchById = plugin.settings.relationTypes.find( - (rt) => rt.id === sourceRelationTypeId, - ); - if (matchById) { - return matchById.id; - } - - // Match by label - const matchByLabel = plugin.settings.relationTypes.find( - (rt) => rt.label === label, - ); - if (matchByLabel) { - return matchByLabel.id; + // A local match wins even when label/complement differ — local wording is authoritative + const localMatch = findLocalRelationTypeMatch({ + localRelationTypes: plugin.settings.relationTypes, + id: sourceRelationTypeId, + label, + }); + if (localMatch) { + return localMatch.id; } // Create new relation type const now = new Date().getTime(); - const importedFromRid = spaceUriAndLocalIdToRid( - sourceSpaceUri, - sourceRelationTypeId, - "schema", - ); + const importedFromRid = buildSchemaRid({ + spaceUri: sourceSpaceUri, + localId: sourceRelationTypeId, + }); const newRelationType: DiscourseRelationType = { id: sourceRelationTypeId, @@ -133,12 +131,12 @@ const findOrCreateTriple = async ({ importedFromRid?: string; authorId?: number; }): Promise => { - const existing = plugin.settings.discourseRelations?.find( - (dr) => - dr.sourceId === sourceNodeTypeId && - dr.destinationId === destNodeTypeId && - dr.relationshipTypeId === relationTypeId, - ); + const existing = findExistingTriple({ + discourseRelations: plugin.settings.discourseRelations ?? [], + sourceId: sourceNodeTypeId, + destinationId: destNodeTypeId, + relationshipTypeId: relationTypeId, + }); if (existing) return existing; const now = Date.now(); diff --git a/apps/obsidian/src/utils/schemaMatching.ts b/apps/obsidian/src/utils/schemaMatching.ts new file mode 100644 index 000000000..213e34155 --- /dev/null +++ b/apps/obsidian/src/utils/schemaMatching.ts @@ -0,0 +1,95 @@ +import { spaceUriAndLocalIdToRid } from "@repo/database/lib/rid"; +import type { + DiscourseNode, + DiscourseRelation, + DiscourseRelationType, +} from "~/types"; + +/** + * Shared matching primitives for the two schema import paths: importing from a + * remote Supabase space, and importing from an exported schema file. Both need + * to answer "does this incoming type already exist locally?" the same way, or + * the same vault reached through the two paths would dedupe differently. + */ + +export const normalizeSchemaLabel = (value: string): string => { + return value.trim().toLowerCase(); +}; + +/** + * Match by id first: an id collision means the type came from the same origin, + * which is stronger evidence than a name that two vaults happen to share. + */ +export const findLocalNodeTypeMatch = ({ + localNodeTypes, + id, + name, +}: { + localNodeTypes: DiscourseNode[]; + id: string; + name: string; +}): DiscourseNode | undefined => { + const matchById = localNodeTypes.find((nodeType) => nodeType.id === id); + if (matchById) return matchById; + + const normalizedName = normalizeSchemaLabel(name); + return localNodeTypes.find( + (nodeType) => normalizeSchemaLabel(nodeType.name) === normalizedName, + ); +}; + +export const findLocalRelationTypeMatch = ({ + localRelationTypes, + id, + label, +}: { + localRelationTypes: DiscourseRelationType[]; + id: string; + label: string; +}): DiscourseRelationType | undefined => { + const matchById = localRelationTypes.find( + (relationType) => relationType.id === id, + ); + if (matchById) return matchById; + + const normalizedLabel = normalizeSchemaLabel(label); + return localRelationTypes.find( + (relationType) => + normalizeSchemaLabel(relationType.label) === normalizedLabel, + ); +}; + +/** + * A discourse relation is identified by its endpoints and relation type, not by + * its own id — the id is regenerated per vault, so two vaults describing the + * same triple hold different ids for it. + */ +export const findExistingTriple = ({ + discourseRelations, + sourceId, + destinationId, + relationshipTypeId, +}: { + discourseRelations: DiscourseRelation[]; + sourceId: string; + destinationId: string; + relationshipTypeId: string; +}): DiscourseRelation | undefined => { + return discourseRelations.find( + (relation) => + relation.sourceId === sourceId && + relation.destinationId === destinationId && + relation.relationshipTypeId === relationshipTypeId, + ); +}; + +/** Pins the "schema" RID subtype so both import paths produce identical RIDs. */ +export const buildSchemaRid = ({ + spaceUri, + localId, +}: { + spaceUri: string; + localId: string; +}): string => { + return spaceUriAndLocalIdToRid(spaceUri, localId, "schema"); +}; diff --git a/apps/obsidian/src/utils/specImport.ts b/apps/obsidian/src/utils/specImport.ts index 71c4423c3..54f0b6da8 100644 --- a/apps/obsidian/src/utils/specImport.ts +++ b/apps/obsidian/src/utils/specImport.ts @@ -11,6 +11,13 @@ import type { SchemaSelection, } from "~/types"; import { toTldrawColor } from "~/utils/tldrawColors"; +import { canonicalObsidianUrl } from "~/utils/supabaseContext"; +import { + buildSchemaRid, + findExistingTriple, + findLocalNodeTypeMatch, + findLocalRelationTypeMatch, +} from "~/utils/schemaMatching"; export type SchemaImportMatchPlan = { nodeTypeIdMapping: Map; @@ -40,7 +47,6 @@ export type SpecImportPreview = { previewStats: ImportPreviewStats; }; - export type SpecImportApplyResult = { created: { nodeTypes: number; @@ -50,22 +56,6 @@ export type SpecImportApplyResult = { }; }; -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, @@ -79,36 +69,17 @@ const buildSchemaImportMatchPlan = ({ 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); + const localMatch = findLocalNodeTypeMatch({ + localNodeTypes, + id: nodeType.id, + name: nodeType.name, + }); + if (localMatch) { + nodeTypeIdMapping.set(nodeType.id, localMatch.id); existingNodeTypeIds.add(nodeType.id); continue; } @@ -120,18 +91,13 @@ const buildSchemaImportMatchPlan = ({ 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); + const localMatch = findLocalRelationTypeMatch({ + localRelationTypes, + id: relationType.id, + label: relationType.label, + }); + if (localMatch) { + relationTypeIdMapping.set(relationType.id, localMatch.id); existingRelationTypeIds.add(relationType.id); continue; } @@ -139,31 +105,18 @@ const buildSchemaImportMatchPlan = ({ 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, + const existing = findExistingTriple({ + discourseRelations: localDiscourseRelations, + sourceId: nodeTypeIdMapping.get(relation.sourceId) ?? relation.sourceId, + destinationId: + nodeTypeIdMapping.get(relation.destinationId) ?? relation.destinationId, + relationshipTypeId: + relationTypeIdMapping.get(relation.relationshipTypeId) ?? + relation.relationshipTypeId, }); - if (localTripleKeys.has(key)) { + if (existing) { existingDiscourseRelationIds.add(relation.id); } } @@ -263,6 +216,7 @@ export const applySchemaImportSelection = async ({ onWarning?: (message: string) => void; }): Promise => { const { schemaFile, matchPlan } = loadedSchemaFile; + const sourceSpaceUri = canonicalObsidianUrl(schemaFile.vaultId); const selectedTemplateNames = new Set(selection.templateNames); const selectedNodeTypeIds = new Set(selection.nodeTypeIds); const selectedRelationTypeIds = new Set(selection.relationTypeIds); @@ -333,6 +287,10 @@ export const applySchemaImportSelection = async ({ matchPlan.localTemplateNames.has(importedNodeType.template)) ? importedNodeType.template : undefined, + importedFromRid: buildSchemaRid({ + spaceUri: sourceSpaceUri, + localId: importedNodeType.id, + }), modified: Date.now(), }; plugin.settings.nodeTypes = [...plugin.settings.nodeTypes, newNodeType]; @@ -356,6 +314,10 @@ export const applySchemaImportSelection = async ({ const newRelationType: DiscourseRelationType = { ...importedRelationType, color: toTldrawColor(importedRelationType.color), + importedFromRid: buildSchemaRid({ + spaceUri: sourceSpaceUri, + localId: importedRelationType.id, + }), status: "provisional", modified: Date.now(), }; @@ -371,9 +333,6 @@ export const applySchemaImportSelection = async ({ if (!selectedRelationIds.has(relation.id)) { continue; } - if (matchPlan.existingDiscourseRelationIds.has(relation.id)) { - continue; - } const mappedSourceId = matchPlan.nodeTypeIdMapping.get(relation.sourceId) ?? relation.sourceId; @@ -384,12 +343,28 @@ export const applySchemaImportSelection = async ({ matchPlan.relationTypeIdMapping.get(relation.relationshipTypeId) ?? relation.relationshipTypeId; + // Checked against live settings, not the plan: distinct schema node types can + // collapse onto one local type, so two file relations can map to one triple. + const alreadyPresent = findExistingTriple({ + discourseRelations: plugin.settings.discourseRelations, + sourceId: mappedSourceId, + destinationId: mappedDestinationId, + relationshipTypeId: mappedRelationTypeId, + }); + if (alreadyPresent) { + continue; + } + const newRelation: DiscourseRelation = { ...relation, id: uuidv7(), sourceId: mappedSourceId, destinationId: mappedDestinationId, relationshipTypeId: mappedRelationTypeId, + importedFromRid: buildSchemaRid({ + spaceUri: sourceSpaceUri, + localId: relation.id, + }), status: "provisional", modified: Date.now(), }; From db127036fbc34b4b4d3a0faa39d3c5f05f96150e Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Thu, 30 Jul 2026 14:36:42 -0400 Subject: [PATCH 06/10] ENG-1977 Collapse schema types that collide by normalized name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A schema file holding both "Event" and "event" created two node types that matching then treats as one, because the existing-check only compared against the vault's types as they were before the import. Same hole for relation types by label. Fixed in the planner rather than at apply time: the known-set grows as types are planned, so the second type resolves to the first the same way it would resolve to a pre-existing local type. Keeping it in the planner means nodeTypeIdMapping stays correct — skipping the duplicate at apply time would leave discourse relations pointing at an id that was never created. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/utils/specImport.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/apps/obsidian/src/utils/specImport.ts b/apps/obsidian/src/utils/specImport.ts index 54f0b6da8..188ef4a6a 100644 --- a/apps/obsidian/src/utils/specImport.ts +++ b/apps/obsidian/src/utils/specImport.ts @@ -19,6 +19,13 @@ import { findLocalRelationTypeMatch, } from "~/utils/schemaMatching"; +/** + * Maps every id in the schema file to the local id it resolves to. The + * `existing*` sets are schema-file ids that will NOT be created — either + * because they already exist in the vault, or because they collapsed onto an + * earlier item in the same file. Callers should resolve references through the + * id mappings rather than assuming a schema id survives the import. + */ export type SchemaImportMatchPlan = { nodeTypeIdMapping: Map; relationTypeIdMapping: Map; @@ -71,10 +78,14 @@ const buildSchemaImportMatchPlan = ({ }): SchemaImportMatchPlan => { const nodeTypeIdMapping = new Map(); const existingNodeTypeIds = new Set(); + // Grows as types are planned for creation, so a schema file holding both + // "Event" and "event" collapses the second onto the first instead of creating + // two types that matching would treat as one. + const knownNodeTypes = [...localNodeTypes]; for (const nodeType of schemaFile.nodeTypes) { const localMatch = findLocalNodeTypeMatch({ - localNodeTypes, + localNodeTypes: knownNodeTypes, id: nodeType.id, name: nodeType.name, }); @@ -85,14 +96,16 @@ const buildSchemaImportMatchPlan = ({ } nodeTypeIdMapping.set(nodeType.id, nodeType.id); + knownNodeTypes.push(nodeType); } const relationTypeIdMapping = new Map(); const existingRelationTypeIds = new Set(); + const knownRelationTypes = [...localRelationTypes]; for (const relationType of schemaFile.relationTypes) { const localMatch = findLocalRelationTypeMatch({ - localRelationTypes, + localRelationTypes: knownRelationTypes, id: relationType.id, label: relationType.label, }); @@ -103,6 +116,7 @@ const buildSchemaImportMatchPlan = ({ } relationTypeIdMapping.set(relationType.id, relationType.id); + knownRelationTypes.push(relationType); } const existingDiscourseRelationIds = new Set(); From 3c1d120c7292d28324390492593c7009529030b6 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 5 Aug 2026 22:48:14 -0400 Subject: [PATCH 07/10] ENG-1977 Mark file-imported schema accepted rather than provisional Provisional exists so schema arriving from a Supabase space can be reviewed before it takes effect. A file import is different: the user chose the file and hand-picked the items, so there is nothing left to review. The importedFromRid is still recorded for provenance. The Supabase import path (importRelations.ts) is unchanged. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/utils/specImport.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/apps/obsidian/src/utils/specImport.ts b/apps/obsidian/src/utils/specImport.ts index 188ef4a6a..95e573577 100644 --- a/apps/obsidian/src/utils/specImport.ts +++ b/apps/obsidian/src/utils/specImport.ts @@ -332,7 +332,10 @@ export const applySchemaImportSelection = async ({ spaceUri: sourceSpaceUri, localId: importedRelationType.id, }), - status: "provisional", + // Accepted rather than provisional: unlike the Supabase space import, the + // user chose this file and hand-picked these items, so there is nothing + // left to review. The rid is kept for provenance only. + status: "accepted", modified: Date.now(), }; plugin.settings.relationTypes = [ @@ -379,7 +382,7 @@ export const applySchemaImportSelection = async ({ spaceUri: sourceSpaceUri, localId: relation.id, }), - status: "provisional", + status: "accepted", modified: Date.now(), }; plugin.settings.discourseRelations = [ From b2e0cf3ddd673cd6d12abaedb536daede5dbb0ba Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 5 Aug 2026 23:07:30 -0400 Subject: [PATCH 08/10] ENG-1977 Add field-level merge for schema items that already exist locally MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing items were silently skipped, so a user who already had a Claim node type could never take the imported format, template or color onto it. applySchemaImportSelection now accepts an optional merge plan and applies only the fields it names. An absent or empty plan keeps every local value, so import stays non-destructive unless the caller opts a field in. The plan is keyed by schema-file id rather than local id: buildSchemaImportMatchPlan deliberately collapses schema types that collide by normalized name, so two schema ids can share one local id and a local-keyed map would drop one of them. name and label are excluded from the mergeable sets. Matching is id-first, so an id match carrying a different name reads as a rename, but renaming a type does not retag pages already tagged with it — the vault would silently split. SchemaImportMatchPlan moves to schemaMatching.ts so the apply path and the field-diff path can both depend on it without a circular import. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/utils/schemaFieldDiff.ts | 170 ++++++++++++++++ apps/obsidian/src/utils/schemaMatching.ts | 20 ++ apps/obsidian/src/utils/specImport.ts | 220 ++++++++++++++++++--- apps/obsidian/src/utils/templates.ts | 58 ++++++ 4 files changed, 442 insertions(+), 26 deletions(-) create mode 100644 apps/obsidian/src/utils/schemaFieldDiff.ts diff --git a/apps/obsidian/src/utils/schemaFieldDiff.ts b/apps/obsidian/src/utils/schemaFieldDiff.ts new file mode 100644 index 000000000..85981fefe --- /dev/null +++ b/apps/obsidian/src/utils/schemaFieldDiff.ts @@ -0,0 +1,170 @@ +import type { + DiscourseNode, + DiscourseRelationType, + DiscourseSchemaFile, +} from "~/types"; +import type { SchemaImportMatchPlan } from "~/utils/schemaMatching"; + +/** + * Fields a schema import may overwrite on an item that already exists locally. + * + * `name` and `label` are deliberately absent. Matching is id-first, so an id + * match carrying a different name reads as a rename — but renaming a type does + * not retag the pages already tagged with it, so the vault would silently split + * into old-name and new-name halves. `id`, `created`, `authorId` and + * `importedFromRid` are identity and provenance rather than editable content. + * + * The `satisfies` clause pins each list to its type: dropping or renaming a + * field on DiscourseNode fails to compile here until the list is updated. + */ +export const MERGEABLE_NODE_TYPE_FIELDS = [ + "format", + "template", + "description", + "shortcut", + "color", + "tag", + "keyImage", + "folderPath", +] as const satisfies readonly (keyof DiscourseNode)[]; + +export const MERGEABLE_RELATION_TYPE_FIELDS = [ + "complement", + "color", +] as const satisfies readonly (keyof DiscourseRelationType)[]; + +/** Templates are all-or-nothing: the whole file body is replaced or kept. */ +export const TEMPLATE_CONTENT_FIELD = "content"; + +export type SchemaFieldChange = { + field: string; + localValue: string | boolean | undefined; + importedValue: string | boolean | undefined; +}; + +export type SchemaConflictCategory = "nodeType" | "relationType" | "template"; + +/** + * One locally-present item that the imported file also describes, plus the + * fields whose values disagree. Keyed by schema-file id, not local id: the match + * plan deliberately collapses schema types that collide by normalized name, so + * two schema ids can share one local id and a local-keyed structure would drop + * one of them. + */ +export type SchemaConflict = { + category: SchemaConflictCategory; + schemaId: string; + label: string; + changes: SchemaFieldChange[]; +}; + +const buildNodeTypeFieldChanges = ({ + local, + imported, +}: { + local: DiscourseNode; + imported: DiscourseNode; +}): SchemaFieldChange[] => { + return MERGEABLE_NODE_TYPE_FIELDS.flatMap((field) => { + const localValue = local[field]; + const importedValue = imported[field]; + if (localValue === importedValue) return []; + return [{ field, localValue, importedValue }]; + }); +}; + +const buildRelationTypeFieldChanges = ({ + local, + imported, +}: { + local: DiscourseRelationType; + imported: DiscourseRelationType; +}): SchemaFieldChange[] => { + return MERGEABLE_RELATION_TYPE_FIELDS.flatMap((field) => { + const localValue = local[field]; + const importedValue = imported[field]; + if (localValue === importedValue) return []; + return [{ field, localValue, importedValue }]; + }); +}; + +export const buildSchemaConflicts = ({ + schemaFile, + matchPlan, + localNodeTypes, + localRelationTypes, + localTemplateContents, +}: { + schemaFile: DiscourseSchemaFile; + matchPlan: SchemaImportMatchPlan; + localNodeTypes: DiscourseNode[]; + localRelationTypes: DiscourseRelationType[]; + localTemplateContents: ReadonlyMap; +}): SchemaConflict[] => { + const localNodeTypesById = new Map( + localNodeTypes.map((nodeType) => [nodeType.id, nodeType]), + ); + const localRelationTypesById = new Map( + localRelationTypes.map((relationType) => [relationType.id, relationType]), + ); + + const nodeTypeConflicts = schemaFile.nodeTypes.flatMap((imported) => { + if (!matchPlan.existingNodeTypeIds.has(imported.id)) return []; + const localId = matchPlan.nodeTypeIdMapping.get(imported.id); + const local = localId ? localNodeTypesById.get(localId) : undefined; + if (!local) return []; + + const changes = buildNodeTypeFieldChanges({ local, imported }); + if (changes.length === 0) return []; + return [ + { + category: "nodeType" as const, + schemaId: imported.id, + label: local.name, + changes, + }, + ]; + }); + + const relationTypeConflicts = schemaFile.relationTypes.flatMap((imported) => { + if (!matchPlan.existingRelationTypeIds.has(imported.id)) return []; + const localId = matchPlan.relationTypeIdMapping.get(imported.id); + const local = localId ? localRelationTypesById.get(localId) : undefined; + if (!local) return []; + + const changes = buildRelationTypeFieldChanges({ local, imported }); + if (changes.length === 0) return []; + return [ + { + category: "relationType" as const, + schemaId: imported.id, + label: local.label, + changes, + }, + ]; + }); + + const templateConflicts = schemaFile.templates.flatMap((imported) => { + if (!matchPlan.existingTemplateNames.has(imported.name)) return []; + const localContent = localTemplateContents.get(imported.name); + if (localContent === undefined || localContent === imported.content) { + return []; + } + return [ + { + category: "template" as const, + schemaId: imported.name, + label: `${imported.name}.md`, + changes: [ + { + field: TEMPLATE_CONTENT_FIELD, + localValue: localContent, + importedValue: imported.content, + }, + ], + }, + ]; + }); + + return [...nodeTypeConflicts, ...relationTypeConflicts, ...templateConflicts]; +}; diff --git a/apps/obsidian/src/utils/schemaMatching.ts b/apps/obsidian/src/utils/schemaMatching.ts index 213e34155..12e5ee5da 100644 --- a/apps/obsidian/src/utils/schemaMatching.ts +++ b/apps/obsidian/src/utils/schemaMatching.ts @@ -12,6 +12,26 @@ import type { * the same vault reached through the two paths would dedupe differently. */ +/** + * Maps every id in the schema file to the local id it resolves to. The + * `existing*` sets are schema-file ids that will NOT be created — either + * because they already exist in the vault, or because they collapsed onto an + * earlier item in the same file. Callers should resolve references through the + * id mappings rather than assuming a schema id survives the import. + * + * Lives here rather than beside the import apply logic so that both the apply + * path and the field-diff path can depend on it without a circular import. + */ +export type SchemaImportMatchPlan = { + nodeTypeIdMapping: Map; + relationTypeIdMapping: Map; + existingNodeTypeIds: Set; + existingRelationTypeIds: Set; + existingDiscourseRelationIds: Set; + existingTemplateNames: Set; + localTemplateNames: Set; +}; + export const normalizeSchemaLabel = (value: string): string => { return value.trim().toLowerCase(); }; diff --git a/apps/obsidian/src/utils/specImport.ts b/apps/obsidian/src/utils/specImport.ts index 95e573577..f18d2f516 100644 --- a/apps/obsidian/src/utils/specImport.ts +++ b/apps/obsidian/src/utils/specImport.ts @@ -1,7 +1,12 @@ import type DiscourseGraphPlugin from "~/index"; import { uuidv7 } from "uuidv7"; import { parseDgSchemaFile } from "~/utils/specValidation"; -import { createTemplateFile, getTemplateFiles } from "~/utils/templates"; +import { + createTemplateFile, + getTemplateFiles, + overwriteTemplateFile, + readTemplateContent, +} from "~/utils/templates"; import { openJsonFromUserLocation } from "~/utils/nativeJsonFileDialogs"; import type { DiscourseNode, @@ -17,23 +22,30 @@ import { findExistingTriple, findLocalNodeTypeMatch, findLocalRelationTypeMatch, + type SchemaImportMatchPlan, } from "~/utils/schemaMatching"; +import { + buildSchemaConflicts, + MERGEABLE_NODE_TYPE_FIELDS, + MERGEABLE_RELATION_TYPE_FIELDS, + type SchemaConflict, +} from "~/utils/schemaFieldDiff"; + +export type { SchemaImportMatchPlan }; /** - * Maps every id in the schema file to the local id it resolves to. The - * `existing*` sets are schema-file ids that will NOT be created — either - * because they already exist in the vault, or because they collapsed onto an - * earlier item in the same file. Callers should resolve references through the - * id mappings rather than assuming a schema id survives the import. + * Which fields the user opted to take from the imported file, for items that + * already exist locally. Keyed by schema-file id — template entries by name — + * because the match plan collapses schema types that collide by normalized + * name, so two schema ids can share one local id. + * + * An absent or empty entry means keep the local value: import is + * non-destructive unless the user explicitly ticked a field. */ -export type SchemaImportMatchPlan = { - nodeTypeIdMapping: Map; - relationTypeIdMapping: Map; - existingNodeTypeIds: Set; - existingRelationTypeIds: Set; - existingDiscourseRelationIds: Set; - existingTemplateNames: Set; - localTemplateNames: Set; +export type SchemaMergePlan = { + nodeTypeFields: ReadonlyMap>; + relationTypeFields: ReadonlyMap>; + templateNames: ReadonlySet; }; export type LoadedSchemaFile = { @@ -52,8 +64,10 @@ export type ImportPreviewStats = { export type SpecImportPreview = { loadedSchemaFile: LoadedSchemaFile; previewStats: ImportPreviewStats; + conflicts: SchemaConflict[]; }; +/** Relation triples are absent from `merged` because endpoints are their identity. */ export type SpecImportApplyResult = { created: { nodeTypes: number; @@ -61,6 +75,11 @@ export type SpecImportApplyResult = { discourseRelations: number; templates: number; }; + merged: { + nodeTypes: number; + relationTypes: number; + templates: number; + }; }; const buildSchemaImportMatchPlan = ({ @@ -188,6 +207,29 @@ const buildPreviewStats = ({ }; }; +/** + * Reads only the templates the file and the vault have in common — the rest + * cannot conflict, so their contents are never needed. + */ +const readOverlappingTemplateContents = async ({ + plugin, + matchPlan, +}: { + plugin: DiscourseGraphPlugin; + matchPlan: SchemaImportMatchPlan; +}): Promise> => { + const entries = await Promise.all( + [...matchPlan.existingTemplateNames].map(async (templateName) => { + const content = await readTemplateContent({ + app: plugin.app, + templateName, + }); + return content === null ? [] : [[templateName, content] as const]; + }), + ); + return new Map(entries.flat()); +}; + export const pickAndPreviewSchemaImport = async ({ plugin, }: { @@ -212,21 +254,75 @@ export const pickAndPreviewSchemaImport = async ({ matchPlan, }; + const localTemplateContents = await readOverlappingTemplateContents({ + plugin, + matchPlan, + }); + return { loadedSchemaFile, previewStats: buildPreviewStats({ schemaFile, matchPlan }), + conflicts: buildSchemaConflicts({ + schemaFile, + matchPlan, + localNodeTypes: plugin.settings.nodeTypes, + localRelationTypes: plugin.settings.relationTypes, + localTemplateContents, + }), }; }; +const mergeNodeTypeFields = ({ + local, + imported, + fields, +}: { + local: DiscourseNode; + imported: DiscourseNode; + fields: ReadonlySet; +}): DiscourseNode => { + const merged: DiscourseNode = { ...local, modified: Date.now() }; + for (const field of MERGEABLE_NODE_TYPE_FIELDS) { + if (!fields.has(field)) continue; + // TypeScript cannot correlate merged[field] with imported[field] across a + // key union. MERGEABLE_NODE_TYPE_FIELDS is pinned to DiscourseNode by a + // `satisfies` clause, so field is always a real key and the write is sound. + (merged as Record)[field] = imported[field]; + } + return merged; +}; + +const mergeRelationTypeFields = ({ + local, + imported, + fields, +}: { + local: DiscourseRelationType; + imported: DiscourseRelationType; + fields: ReadonlySet; +}): DiscourseRelationType => { + const merged: DiscourseRelationType = { ...local, modified: Date.now() }; + for (const field of MERGEABLE_RELATION_TYPE_FIELDS) { + if (!fields.has(field)) continue; + (merged as Record)[field] = imported[field]; + } + if (fields.has("color")) { + merged.color = toTldrawColor(merged.color); + } + return merged; +}; + export const applySchemaImportSelection = async ({ plugin, loadedSchemaFile, selection, + mergePlan, onWarning = () => {}, }: { plugin: DiscourseGraphPlugin; loadedSchemaFile: LoadedSchemaFile; selection: SchemaSelection; + mergePlan?: SchemaMergePlan; onWarning?: (message: string) => void; }): Promise => { const { schemaFile, matchPlan } = loadedSchemaFile; @@ -237,14 +333,11 @@ export const applySchemaImportSelection = async ({ const selectedRelationIds = new Set(selection.discourseRelationIds); let templatesCreated = 0; + let templatesMerged = 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) { onWarning( @@ -253,6 +346,26 @@ export const applySchemaImportSelection = async ({ continue; } + if (matchPlan.existingTemplateNames.has(templateName)) { + if (!mergePlan?.templateNames.has(templateName)) { + continue; + } + + const overwriteResult = await overwriteTemplateFile({ + app: plugin.app, + templateName: template.name, + content: template.content, + }); + if (overwriteResult.overwritten) { + templatesMerged += 1; + } else { + onWarning( + `Template "${template.name}" not overwritten: ${overwriteResult.reason}.`, + ); + } + continue; + } + const result = await createTemplateFile({ app: plugin.app, templateName: template.name, @@ -280,11 +393,8 @@ export const applySchemaImportSelection = async ({ ); let nodeTypesCreated = 0; + let nodeTypesMerged = 0; for (const nodeTypeId of selectedNodeTypeIds) { - if (matchPlan.existingNodeTypeIds.has(nodeTypeId)) { - continue; - } - const importedNodeType = schemaNodeTypesById.get(nodeTypeId); if (!importedNodeType) { onWarning( @@ -293,6 +403,34 @@ export const applySchemaImportSelection = async ({ continue; } + if (matchPlan.existingNodeTypeIds.has(nodeTypeId)) { + const mergedFields = mergePlan?.nodeTypeFields.get(nodeTypeId); + if (!mergedFields?.size) { + continue; + } + + const localId = matchPlan.nodeTypeIdMapping.get(nodeTypeId); + const localIndex = plugin.settings.nodeTypes.findIndex( + (nodeType) => nodeType.id === localId, + ); + if (localIndex === -1) { + onWarning( + `Node type "${importedNodeType.name}" matched an existing type that is no longer present.`, + ); + continue; + } + + const nextNodeTypes = [...plugin.settings.nodeTypes]; + nextNodeTypes[localIndex] = mergeNodeTypeFields({ + local: nextNodeTypes[localIndex]!, + imported: importedNodeType, + fields: mergedFields, + }); + plugin.settings.nodeTypes = nextNodeTypes; + nodeTypesMerged += 1; + continue; + } + const newNodeType: DiscourseNode = { ...importedNodeType, template: @@ -312,11 +450,8 @@ export const applySchemaImportSelection = async ({ } let relationTypesCreated = 0; + let relationTypesMerged = 0; for (const relationTypeId of selectedRelationTypeIds) { - if (matchPlan.existingRelationTypeIds.has(relationTypeId)) { - continue; - } - const importedRelationType = schemaRelationTypesById.get(relationTypeId); if (!importedRelationType) { onWarning( @@ -325,6 +460,34 @@ export const applySchemaImportSelection = async ({ continue; } + if (matchPlan.existingRelationTypeIds.has(relationTypeId)) { + const mergedFields = mergePlan?.relationTypeFields.get(relationTypeId); + if (!mergedFields?.size) { + continue; + } + + const localId = matchPlan.relationTypeIdMapping.get(relationTypeId); + const localIndex = plugin.settings.relationTypes.findIndex( + (relationType) => relationType.id === localId, + ); + if (localIndex === -1) { + onWarning( + `Relation type "${importedRelationType.label}" matched an existing type that is no longer present.`, + ); + continue; + } + + const nextRelationTypes = [...plugin.settings.relationTypes]; + nextRelationTypes[localIndex] = mergeRelationTypeFields({ + local: nextRelationTypes[localIndex]!, + imported: importedRelationType, + fields: mergedFields, + }); + plugin.settings.relationTypes = nextRelationTypes; + relationTypesMerged += 1; + continue; + } + const newRelationType: DiscourseRelationType = { ...importedRelationType, color: toTldrawColor(importedRelationType.color), @@ -401,5 +564,10 @@ export const applySchemaImportSelection = async ({ discourseRelations: discourseRelationsCreated, templates: templatesCreated, }, + merged: { + nodeTypes: nodeTypesMerged, + relationTypes: relationTypesMerged, + templates: templatesMerged, + }, }; }; diff --git a/apps/obsidian/src/utils/templates.ts b/apps/obsidian/src/utils/templates.ts index cc69b1c22..557756a89 100644 --- a/apps/obsidian/src/utils/templates.ts +++ b/apps/obsidian/src/utils/templates.ts @@ -272,6 +272,64 @@ export const createTemplateFile = async ({ return { created: true }; }; +export const readTemplateContent = async ({ + app, + templateName, +}: { + app: App; + templateName: string; +}): Promise => { + const { isEnabled, folderPath } = getTemplatePluginInfo(app); + if (!isEnabled || !folderPath) { + return null; + } + + const sanitizedName = sanitizeTemplateName(templateName); + const templateFile = app.vault.getAbstractFileByPath( + `${folderPath}/${sanitizedName}.md`, + ); + if (!(templateFile instanceof TFile)) { + return null; + } + + return app.vault.read(templateFile); +}; + +/** + * The deliberate opt-in counterpart to createTemplateFile, which refuses to + * clobber a local template. Only reachable when the user ticked this template's + * content in the import conflict step. + */ +export const overwriteTemplateFile = async ({ + app, + templateName, + content, +}: CreateTemplateFileInput): Promise< + { overwritten: true } | { overwritten: false; reason: string } +> => { + const { isEnabled, folderPath } = getTemplatePluginInfo(app); + if (!isEnabled) { + return { overwritten: false, reason: "Templates plugin is not enabled" }; + } + if (!folderPath) { + return { + overwritten: false, + reason: "Templates folder path is not configured", + }; + } + + const sanitizedName = sanitizeTemplateName(templateName); + const templateFile = app.vault.getAbstractFileByPath( + `${folderPath}/${sanitizedName}.md`, + ); + if (!(templateFile instanceof TFile)) { + return { overwritten: false, reason: "template not found" }; + } + + await app.vault.modify(templateFile, content); + return { overwritten: true }; +}; + export const createTemplateFileWithUniqueName = async ({ app, templateName, From 658f0ebb372f68f8692bfed59db00e2bc8cf4db2 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 5 Aug 2026 23:13:31 -0400 Subject: [PATCH 09/10] ENG-1977 Stop merged template references from dangling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The create path only keeps an imported template reference when the template will actually exist — imported in this run, or already in the vault. The merge path copied imported.template unguarded, so ticking template for a node type whose template is neither selected nor local left it pointing at a file that does not exist. Both paths now share resolveTemplateReference so they cannot drift apart, and merge warns when a ticked reference is dropped rather than silently ignoring an explicit choice. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/utils/specImport.ts | 57 +++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 7 deletions(-) diff --git a/apps/obsidian/src/utils/specImport.ts b/apps/obsidian/src/utils/specImport.ts index f18d2f516..cc1027530 100644 --- a/apps/obsidian/src/utils/specImport.ts +++ b/apps/obsidian/src/utils/specImport.ts @@ -272,14 +272,38 @@ export const pickAndPreviewSchemaImport = async ({ }; }; +/** + * A template reference only survives if the file will actually be there: either + * this run imports it, or the vault already has it. Otherwise the node type + * would point at a template that does not exist. + */ +const resolveTemplateReference = ({ + template, + selectedTemplateNames, + localTemplateNames, +}: { + template: string | undefined; + selectedTemplateNames: ReadonlySet; + localTemplateNames: ReadonlySet; +}): string | undefined => { + if (!template) return undefined; + const willExist = + selectedTemplateNames.has(template) || localTemplateNames.has(template); + return willExist ? template : undefined; +}; + const mergeNodeTypeFields = ({ local, imported, fields, + selectedTemplateNames, + localTemplateNames, }: { local: DiscourseNode; imported: DiscourseNode; fields: ReadonlySet; + selectedTemplateNames: ReadonlySet; + localTemplateNames: ReadonlySet; }): DiscourseNode => { const merged: DiscourseNode = { ...local, modified: Date.now() }; for (const field of MERGEABLE_NODE_TYPE_FIELDS) { @@ -289,6 +313,14 @@ const mergeNodeTypeFields = ({ // `satisfies` clause, so field is always a real key and the write is sound. (merged as Record)[field] = imported[field]; } + // Same guard the create path applies, so a merged reference cannot dangle. + if (fields.has("template")) { + merged.template = resolveTemplateReference({ + template: merged.template, + selectedTemplateNames, + localTemplateNames, + }); + } return merged; }; @@ -421,11 +453,23 @@ export const applySchemaImportSelection = async ({ } const nextNodeTypes = [...plugin.settings.nodeTypes]; - nextNodeTypes[localIndex] = mergeNodeTypeFields({ + const mergedNodeType = mergeNodeTypeFields({ local: nextNodeTypes[localIndex]!, imported: importedNodeType, fields: mergedFields, + selectedTemplateNames, + localTemplateNames: matchPlan.localTemplateNames, }); + if ( + mergedFields.has("template") && + importedNodeType.template && + !mergedNodeType.template + ) { + onWarning( + `Template "${importedNodeType.template}" is not in this vault and was not selected, so "${mergedNodeType.name}" was merged without a template reference.`, + ); + } + nextNodeTypes[localIndex] = mergedNodeType; plugin.settings.nodeTypes = nextNodeTypes; nodeTypesMerged += 1; continue; @@ -433,12 +477,11 @@ export const applySchemaImportSelection = async ({ const newNodeType: DiscourseNode = { ...importedNodeType, - template: - importedNodeType.template && - (selectedTemplateNames.has(importedNodeType.template) || - matchPlan.localTemplateNames.has(importedNodeType.template)) - ? importedNodeType.template - : undefined, + template: resolveTemplateReference({ + template: importedNodeType.template, + selectedTemplateNames, + localTemplateNames: matchPlan.localTemplateNames, + }), importedFromRid: buildSchemaRid({ spaceUri: sourceSpaceUri, localId: importedNodeType.id, From ad43925d98a95fc95f173037f281594f54149620 Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Wed, 5 Aug 2026 23:47:04 -0400 Subject: [PATCH 10/10] ENG-1977 Import conflicting templates as a copy, and never merge absent fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two changes, both making merge non-destructive. Templates: overwriteTemplateFile clobbered the local file. Instead reuse createTemplateFileWithUniqueName — the same helper the Supabase import path uses — so the imported version lands as "Claim (from their-vault)" and the node type is repointed at that copy. The user keeps both and can switch back by editing the node type. resolveTemplateReference now keys off what actually landed rather than what was selected, so a failed creation leaves no dangling reference. Version skew: a field the file has no value for is not an instruction to clear the local one. An export from an older plugin simply lacks fields it never knew about, and offering those as changes turned version skew into silent deletion. The diff now skips absent imported values, so merge only ever adds or overwrites. Fields the local item has never set are still offered. Co-Authored-By: Claude Opus 5 --- apps/obsidian/src/utils/schemaFieldDiff.ts | 5 +++ apps/obsidian/src/utils/specImport.ts | 51 ++++++++++++++-------- apps/obsidian/src/utils/templates.ts | 35 --------------- 3 files changed, 38 insertions(+), 53 deletions(-) diff --git a/apps/obsidian/src/utils/schemaFieldDiff.ts b/apps/obsidian/src/utils/schemaFieldDiff.ts index 85981fefe..d5a9ac862 100644 --- a/apps/obsidian/src/utils/schemaFieldDiff.ts +++ b/apps/obsidian/src/utils/schemaFieldDiff.ts @@ -68,6 +68,11 @@ const buildNodeTypeFieldChanges = ({ return MERGEABLE_NODE_TYPE_FIELDS.flatMap((field) => { const localValue = local[field]; const importedValue = imported[field]; + // A field the file has no value for is not an instruction to clear the local + // one. An export from an older plugin simply lacks fields it never knew + // about, and offering those as "changes" would turn version skew into + // silent deletion. Merge only ever adds or overwrites. + if (importedValue === undefined) return []; if (localValue === importedValue) return []; return [{ field, localValue, importedValue }]; }); diff --git a/apps/obsidian/src/utils/specImport.ts b/apps/obsidian/src/utils/specImport.ts index cc1027530..0aa680838 100644 --- a/apps/obsidian/src/utils/specImport.ts +++ b/apps/obsidian/src/utils/specImport.ts @@ -3,8 +3,8 @@ import { uuidv7 } from "uuidv7"; import { parseDgSchemaFile } from "~/utils/specValidation"; import { createTemplateFile, + createTemplateFileWithUniqueName, getTemplateFiles, - overwriteTemplateFile, readTemplateContent, } from "~/utils/templates"; import { openJsonFromUserLocation } from "~/utils/nativeJsonFileDialogs"; @@ -273,36 +273,39 @@ export const pickAndPreviewSchemaImport = async ({ }; /** - * A template reference only survives if the file will actually be there: either - * this run imports it, or the vault already has it. Otherwise the node type - * would point at a template that does not exist. + * Resolves what a node type's template field should point at once templates have + * been written. Keyed off what actually landed rather than what was selected, so + * a template whose creation failed leaves no dangling reference behind. + * + * An imported copy wins over a same-named local template: the user only gets a + * copy when they explicitly chose the imported version. */ const resolveTemplateReference = ({ template, - selectedTemplateNames, + importedTemplateNames, localTemplateNames, }: { template: string | undefined; - selectedTemplateNames: ReadonlySet; + importedTemplateNames: ReadonlyMap; localTemplateNames: ReadonlySet; }): string | undefined => { if (!template) return undefined; - const willExist = - selectedTemplateNames.has(template) || localTemplateNames.has(template); - return willExist ? template : undefined; + const importedName = importedTemplateNames.get(template); + if (importedName) return importedName; + return localTemplateNames.has(template) ? template : undefined; }; const mergeNodeTypeFields = ({ local, imported, fields, - selectedTemplateNames, + importedTemplateNames, localTemplateNames, }: { local: DiscourseNode; imported: DiscourseNode; fields: ReadonlySet; - selectedTemplateNames: ReadonlySet; + importedTemplateNames: ReadonlyMap; localTemplateNames: ReadonlySet; }): DiscourseNode => { const merged: DiscourseNode = { ...local, modified: Date.now() }; @@ -317,7 +320,7 @@ const mergeNodeTypeFields = ({ if (fields.has("template")) { merged.template = resolveTemplateReference({ template: merged.template, - selectedTemplateNames, + importedTemplateNames, localTemplateNames, }); } @@ -366,6 +369,12 @@ export const applySchemaImportSelection = async ({ let templatesCreated = 0; let templatesMerged = 0; + /** + * Schema-file template name to the file name it actually landed under. An + * imported copy keeps the local template intact, so the two names differ + * whenever the user chose the imported version of a template they already had. + */ + const importedTemplateNames = new Map(); const templatesByName = new Map( schemaFile.templates.map((template) => [template.name, template]), ); @@ -383,16 +392,21 @@ export const applySchemaImportSelection = async ({ continue; } - const overwriteResult = await overwriteTemplateFile({ + // Never clobber the local template. The imported version lands beside it + // under its own name and the node type is repointed at that copy, so the + // user keeps both and can fall back by editing the node type. + const copyResult = await createTemplateFileWithUniqueName({ app: plugin.app, templateName: template.name, + sourceName: schemaFile.vaultName, content: template.content, }); - if (overwriteResult.overwritten) { + if (copyResult.created) { + importedTemplateNames.set(template.name, copyResult.templateName); templatesMerged += 1; } else { onWarning( - `Template "${template.name}" not overwritten: ${overwriteResult.reason}.`, + `Template "${template.name}" not imported: ${copyResult.reason}.`, ); } continue; @@ -405,6 +419,7 @@ export const applySchemaImportSelection = async ({ }); if (result.created) { + importedTemplateNames.set(template.name, template.name); templatesCreated += 1; continue; } @@ -457,7 +472,7 @@ export const applySchemaImportSelection = async ({ local: nextNodeTypes[localIndex]!, imported: importedNodeType, fields: mergedFields, - selectedTemplateNames, + importedTemplateNames, localTemplateNames: matchPlan.localTemplateNames, }); if ( @@ -466,7 +481,7 @@ export const applySchemaImportSelection = async ({ !mergedNodeType.template ) { onWarning( - `Template "${importedNodeType.template}" is not in this vault and was not selected, so "${mergedNodeType.name}" was merged without a template reference.`, + `Template "${importedNodeType.template}" was not imported and is not in this vault, so "${mergedNodeType.name}" was merged without a template reference.`, ); } nextNodeTypes[localIndex] = mergedNodeType; @@ -479,7 +494,7 @@ export const applySchemaImportSelection = async ({ ...importedNodeType, template: resolveTemplateReference({ template: importedNodeType.template, - selectedTemplateNames, + importedTemplateNames, localTemplateNames: matchPlan.localTemplateNames, }), importedFromRid: buildSchemaRid({ diff --git a/apps/obsidian/src/utils/templates.ts b/apps/obsidian/src/utils/templates.ts index 557756a89..2c9aa084a 100644 --- a/apps/obsidian/src/utils/templates.ts +++ b/apps/obsidian/src/utils/templates.ts @@ -295,41 +295,6 @@ export const readTemplateContent = async ({ return app.vault.read(templateFile); }; -/** - * The deliberate opt-in counterpart to createTemplateFile, which refuses to - * clobber a local template. Only reachable when the user ticked this template's - * content in the import conflict step. - */ -export const overwriteTemplateFile = async ({ - app, - templateName, - content, -}: CreateTemplateFileInput): Promise< - { overwritten: true } | { overwritten: false; reason: string } -> => { - const { isEnabled, folderPath } = getTemplatePluginInfo(app); - if (!isEnabled) { - return { overwritten: false, reason: "Templates plugin is not enabled" }; - } - if (!folderPath) { - return { - overwritten: false, - reason: "Templates folder path is not configured", - }; - } - - const sanitizedName = sanitizeTemplateName(templateName); - const templateFile = app.vault.getAbstractFileByPath( - `${folderPath}/${sanitizedName}.md`, - ); - if (!(templateFile instanceof TFile)) { - return { overwritten: false, reason: "template not found" }; - } - - await app.vault.modify(templateFile, content); - return { overwritten: true }; -}; - export const createTemplateFileWithUniqueName = async ({ app, templateName,