Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 12 additions & 19 deletions apps/obsidian/src/utils/importNodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
48 changes: 23 additions & 25 deletions apps/obsidian/src/utils/importRelations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -133,12 +131,12 @@ const findOrCreateTriple = async ({
importedFromRid?: string;
authorId?: number;
}): Promise<DiscourseRelation> => {
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();
Expand Down
175 changes: 175 additions & 0 deletions apps/obsidian/src/utils/schemaFieldDiff.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
}): 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];
};
Loading