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 new file mode 100644 index 000000000..188ef4a6a --- /dev/null +++ b/apps/obsidian/src/utils/specImport.ts @@ -0,0 +1,402 @@ +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, + SchemaSelection, +} from "~/types"; +import { toTldrawColor } from "~/utils/tldrawColors"; +import { canonicalObsidianUrl } from "~/utils/supabaseContext"; +import { + buildSchemaRid, + findExistingTriple, + findLocalNodeTypeMatch, + 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; + existingNodeTypeIds: Set; + existingRelationTypeIds: Set; + existingDiscourseRelationIds: Set; + existingTemplateNames: Set; + localTemplateNames: 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 SpecImportApplyResult = { + created: { + nodeTypes: number; + relationTypes: number; + discourseRelations: number; + templates: number; + }; +}; + +const buildSchemaImportMatchPlan = ({ + schemaFile, + localNodeTypes, + localRelationTypes, + localDiscourseRelations, + localTemplateNames, +}: { + schemaFile: DiscourseSchemaFile; + localNodeTypes: DiscourseNode[]; + localRelationTypes: DiscourseRelationType[]; + localDiscourseRelations: DiscourseRelation[]; + localTemplateNames: Set; +}): 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: knownNodeTypes, + id: nodeType.id, + name: nodeType.name, + }); + if (localMatch) { + nodeTypeIdMapping.set(nodeType.id, localMatch.id); + existingNodeTypeIds.add(nodeType.id); + continue; + } + + 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: knownRelationTypes, + id: relationType.id, + label: relationType.label, + }); + if (localMatch) { + relationTypeIdMapping.set(relationType.id, localMatch.id); + existingRelationTypeIds.add(relationType.id); + continue; + } + + relationTypeIdMapping.set(relationType.id, relationType.id); + knownRelationTypes.push(relationType); + } + + const existingDiscourseRelationIds = new Set(); + for (const relation of schemaFile.discourseRelations) { + 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 (existing) { + 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, + localTemplateNames, + }; +}; + +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, + onWarning = () => {}, +}: { + plugin: DiscourseGraphPlugin; + loadedSchemaFile: LoadedSchemaFile; + selection: SchemaSelection; + 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); + 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) { + onWarning( + `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") { + onWarning(`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) { + onWarning( + `Node type "${nodeTypeId}" was selected but missing from schema file.`, + ); + continue; + } + + const newNodeType: DiscourseNode = { + ...importedNodeType, + template: + importedNodeType.template && + (selectedTemplateNames.has(importedNodeType.template) || + 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]; + nodeTypesCreated += 1; + } + + let relationTypesCreated = 0; + for (const relationTypeId of selectedRelationTypeIds) { + if (matchPlan.existingRelationTypeIds.has(relationTypeId)) { + continue; + } + + const importedRelationType = schemaRelationTypesById.get(relationTypeId); + if (!importedRelationType) { + onWarning( + `Relation type "${relationTypeId}" was selected but missing from schema file.`, + ); + continue; + } + + const newRelationType: DiscourseRelationType = { + ...importedRelationType, + color: toTldrawColor(importedRelationType.color), + importedFromRid: buildSchemaRid({ + spaceUri: sourceSpaceUri, + localId: importedRelationType.id, + }), + 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; + } + + 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; + + // 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(), + }; + plugin.settings.discourseRelations = [ + ...plugin.settings.discourseRelations, + newRelation, + ]; + discourseRelationsCreated += 1; + } + + await plugin.saveSettings(); + + return { + created: { + nodeTypes: nodeTypesCreated, + relationTypes: relationTypesCreated, + discourseRelations: discourseRelationsCreated, + templates: templatesCreated, + }, + }; +};