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/schemaFieldDiff.ts b/apps/obsidian/src/utils/schemaFieldDiff.ts new file mode 100644 index 000000000..d5a9ac862 --- /dev/null +++ b/apps/obsidian/src/utils/schemaFieldDiff.ts @@ -0,0 +1,175 @@ +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]; + // 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 }]; + }); +}; + +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 new file mode 100644 index 000000000..12e5ee5da --- /dev/null +++ b/apps/obsidian/src/utils/schemaMatching.ts @@ -0,0 +1,115 @@ +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. + */ + +/** + * 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(); +}; + +/** + * 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..0aa680838 --- /dev/null +++ b/apps/obsidian/src/utils/specImport.ts @@ -0,0 +1,631 @@ +import type DiscourseGraphPlugin from "~/index"; +import { uuidv7 } from "uuidv7"; +import { parseDgSchemaFile } from "~/utils/specValidation"; +import { + createTemplateFile, + createTemplateFileWithUniqueName, + getTemplateFiles, + readTemplateContent, +} 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, + type SchemaImportMatchPlan, +} from "~/utils/schemaMatching"; +import { + buildSchemaConflicts, + MERGEABLE_NODE_TYPE_FIELDS, + MERGEABLE_RELATION_TYPE_FIELDS, + type SchemaConflict, +} from "~/utils/schemaFieldDiff"; + +export type { SchemaImportMatchPlan }; + +/** + * 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 SchemaMergePlan = { + nodeTypeFields: ReadonlyMap>; + relationTypeFields: ReadonlyMap>; + templateNames: ReadonlySet; +}; + +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; + conflicts: SchemaConflict[]; +}; + +/** Relation triples are absent from `merged` because endpoints are their identity. */ +export type SpecImportApplyResult = { + created: { + nodeTypes: number; + relationTypes: number; + discourseRelations: number; + templates: number; + }; + merged: { + nodeTypes: number; + relationTypes: 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, + }, + }; +}; + +/** + * 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, +}: { + 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, + }; + + 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, + }), + }; +}; + +/** + * 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, + importedTemplateNames, + localTemplateNames, +}: { + template: string | undefined; + importedTemplateNames: ReadonlyMap; + localTemplateNames: ReadonlySet; +}): string | undefined => { + if (!template) return undefined; + const importedName = importedTemplateNames.get(template); + if (importedName) return importedName; + return localTemplateNames.has(template) ? template : undefined; +}; + +const mergeNodeTypeFields = ({ + local, + imported, + fields, + importedTemplateNames, + localTemplateNames, +}: { + local: DiscourseNode; + imported: DiscourseNode; + fields: ReadonlySet; + importedTemplateNames: ReadonlyMap; + localTemplateNames: 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]; + } + // Same guard the create path applies, so a merged reference cannot dangle. + if (fields.has("template")) { + merged.template = resolveTemplateReference({ + template: merged.template, + importedTemplateNames, + localTemplateNames, + }); + } + 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; + 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; + 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]), + ); + for (const templateName of selectedTemplateNames) { + const template = templatesByName.get(templateName); + if (!template) { + onWarning( + `Template "${templateName}" was selected but not found in schema file.`, + ); + continue; + } + + if (matchPlan.existingTemplateNames.has(templateName)) { + if (!mergePlan?.templateNames.has(templateName)) { + continue; + } + + // 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 (copyResult.created) { + importedTemplateNames.set(template.name, copyResult.templateName); + templatesMerged += 1; + } else { + onWarning( + `Template "${template.name}" not imported: ${copyResult.reason}.`, + ); + } + continue; + } + + const result = await createTemplateFile({ + app: plugin.app, + templateName: template.name, + content: template.content, + }); + + if (result.created) { + importedTemplateNames.set(template.name, template.name); + 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; + let nodeTypesMerged = 0; + for (const nodeTypeId of selectedNodeTypeIds) { + const importedNodeType = schemaNodeTypesById.get(nodeTypeId); + if (!importedNodeType) { + onWarning( + `Node type "${nodeTypeId}" was selected but missing from schema file.`, + ); + 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]; + const mergedNodeType = mergeNodeTypeFields({ + local: nextNodeTypes[localIndex]!, + imported: importedNodeType, + fields: mergedFields, + importedTemplateNames, + localTemplateNames: matchPlan.localTemplateNames, + }); + if ( + mergedFields.has("template") && + importedNodeType.template && + !mergedNodeType.template + ) { + onWarning( + `Template "${importedNodeType.template}" was not imported and is not in this vault, so "${mergedNodeType.name}" was merged without a template reference.`, + ); + } + nextNodeTypes[localIndex] = mergedNodeType; + plugin.settings.nodeTypes = nextNodeTypes; + nodeTypesMerged += 1; + continue; + } + + const newNodeType: DiscourseNode = { + ...importedNodeType, + template: resolveTemplateReference({ + template: importedNodeType.template, + importedTemplateNames, + localTemplateNames: matchPlan.localTemplateNames, + }), + importedFromRid: buildSchemaRid({ + spaceUri: sourceSpaceUri, + localId: importedNodeType.id, + }), + modified: Date.now(), + }; + plugin.settings.nodeTypes = [...plugin.settings.nodeTypes, newNodeType]; + nodeTypesCreated += 1; + } + + let relationTypesCreated = 0; + let relationTypesMerged = 0; + for (const relationTypeId of selectedRelationTypeIds) { + const importedRelationType = schemaRelationTypesById.get(relationTypeId); + if (!importedRelationType) { + onWarning( + `Relation type "${relationTypeId}" was selected but missing from schema file.`, + ); + 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), + importedFromRid: buildSchemaRid({ + spaceUri: sourceSpaceUri, + localId: importedRelationType.id, + }), + // 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 = [ + ...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: "accepted", + 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, + }, + 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..2c9aa084a 100644 --- a/apps/obsidian/src/utils/templates.ts +++ b/apps/obsidian/src/utils/templates.ts @@ -272,6 +272,29 @@ 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); +}; + export const createTemplateFileWithUniqueName = async ({ app, templateName,