From ab2874891ae71133abafb696a87312d754ec4cda Mon Sep 17 00:00:00 2001 From: Trang Doan Date: Tue, 28 Jul 2026 22:39:22 -0400 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 6/6] 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();