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..ed3eac260 --- /dev/null +++ b/apps/obsidian/src/utils/schemaFieldDiff.ts @@ -0,0 +1,160 @@ +import type { + DiscourseNode, + DiscourseRelationType, + DiscourseSchemaFile, +} from "~/types"; +import type { SchemaImportMatchPlan } from "~/utils/schemaMatching"; + +/** Fields an import may overwrite. `name`/`label` are excluded: renaming a type does not retag its pages, so the vault would silently split. */ +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"; + +/** Keyed by schema-file id, not local id: the match plan can collapse two schema ids onto one local id. */ +export type SchemaConflict = { + category: SchemaConflictCategory; + schemaId: string; + label: string; + changes: SchemaFieldChange[]; +}; + +/** A file with no value for a field is not asking to clear it; "" counts as absent, since settings persist a cleared field as "" but omit an unset one. */ +const hasNoImportedValue = ( + value: SchemaFieldChange["importedValue"], +): boolean => value === undefined || value === ""; + +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 (hasNoImportedValue(importedValue)) 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 (hasNoImportedValue(importedValue)) return []; + 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..e4c4d24bf --- /dev/null +++ b/apps/obsidian/src/utils/schemaMatching.ts @@ -0,0 +1,93 @@ +/** Shared by both import paths (Supabase space and schema file) so the same vault dedupes identically either way. */ +import { spaceUriAndLocalIdToRid } from "@repo/database/lib/rid"; +import type { + DiscourseNode, + DiscourseRelation, + DiscourseRelationType, +} from "~/types"; + +/** `existing*` hold schema-file ids that will not be created; resolve references through the id mappings, as a schema id may not survive the 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 is stronger evidence than a name 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 triple is identified by its endpoints and relation type, not its id — ids are regenerated per vault. */ +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..fc229995b --- /dev/null +++ b/apps/obsidian/src/utils/specImport.ts @@ -0,0 +1,626 @@ +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 }; + +/** Keyed by schema-file id, templates by name, since two schema ids can share one local id. An absent entry keeps the local value. */ +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, so "Event" and "event" in one file collapse instead of creating two. + 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, + }, + }; +}; + +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, + }), + }; +}; + +/** Keyed off what actually landed, so a failed creation leaves no dangling reference; an imported copy wins over a same-named local one. */ +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; + // TS cannot correlate merged[field] with imported[field] across a key union; the `satisfies` clause makes the write 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 name it actually landed under; these differ when the copy sits beside a local template. */ + 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 copy lands beside it and the node type is repointed at the copy. + 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, not provisional: the user chose this file and hand-picked these items, so nothing is left to review. + status: "accepted", + modified: Date.now(), + }; + plugin.settings.relationTypes = [ + ...plugin.settings.relationTypes, + newRelationType, + ]; + relationTypesCreated += 1; + } + + const hasNodeType = (id: string): boolean => + plugin.settings.nodeTypes.some((nodeType) => nodeType.id === id); + const hasRelationType = (id: string): boolean => + plugin.settings.relationTypes.some( + (relationType) => relationType.id === id, + ); + + 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: two file relations can map to one triple after collapsing. + const alreadyPresent = findExistingTriple({ + discourseRelations: plugin.settings.discourseRelations, + sourceId: mappedSourceId, + destinationId: mappedDestinationId, + relationshipTypeId: mappedRelationTypeId, + }); + if (alreadyPresent) { + continue; + } + + // The selection UI keeps a triple's endpoints selected, but this layer owns settings integrity, so a dangling triple is refused rather than written. + if ( + !hasNodeType(mappedSourceId) || + !hasNodeType(mappedDestinationId) || + !hasRelationType(mappedRelationTypeId) + ) { + const sourceName = + schemaNodeTypesById.get(relation.sourceId)?.name ?? relation.sourceId; + const destinationName = + schemaNodeTypesById.get(relation.destinationId)?.name ?? + relation.destinationId; + const relationLabel = + schemaRelationTypesById.get(relation.relationshipTypeId)?.label ?? + relation.relationshipTypeId; + onWarning( + `Relation "${sourceName} ${relationLabel} ${destinationName}" skipped: it references a type that is not in this vault.`, + ); + 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/specValidation.ts b/apps/obsidian/src/utils/specValidation.ts index 09f96f7e7..5cc1851a5 100644 --- a/apps/obsidian/src/utils/specValidation.ts +++ b/apps/obsidian/src/utils/specValidation.ts @@ -6,7 +6,7 @@ import type { DiscourseSchemaFile, DiscourseSchemaTemplate, } from "~/types"; -import { TLDRAW_COLOR_NAMES } from "~/utils/tldrawColors"; +import { toTldrawColor } from "~/utils/tldrawColors"; export const DG_SCHEMA_EXPORT_VERSION = 1; @@ -31,12 +31,21 @@ const discourseNodeSchema: z.ZodType = z const relationImportStatusSchema = z.enum(["provisional", "accepted"]); -const discourseRelationTypeSchema: z.ZodType = z +const discourseRelationTypeSchema: z.ZodType< + DiscourseRelationType, + z.ZodTypeDef, + unknown +> = z .object({ id: z.string(), label: z.string(), complement: z.string(), - color: z.enum(TLDRAW_COLOR_NAMES), + // Coerced, not rejected: vaults predating tldraw color names hold hex, and export copies settings verbatim, so a strict enum here fails a file the apply path could read. + color: z + .unknown() + .transform((value) => + toTldrawColor(typeof value === "string" ? value : undefined), + ), created: z.number(), modified: z.number(), importedFromRid: z.string().optional(), @@ -63,7 +72,11 @@ const templateExportSchema: z.ZodType = z .object({ name: z.string(), content: z.string() }) .passthrough(); -export const dgSchemaFileSchema: z.ZodType = z +export const dgSchemaFileSchema: z.ZodType< + DiscourseSchemaFile, + z.ZodTypeDef, + unknown +> = z .object({ version: z.literal(DG_SCHEMA_EXPORT_VERSION), exportedAt: z.string(), 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,