Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
160 changes: 160 additions & 0 deletions apps/obsidian/src/utils/schemaFieldDiff.ts
Original file line number Diff line number Diff line change
@@ -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<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];
};
93 changes: 93 additions & 0 deletions apps/obsidian/src/utils/schemaMatching.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>;
relationTypeIdMapping: Map<string, string>;
existingNodeTypeIds: Set<string>;
existingRelationTypeIds: Set<string>;
existingDiscourseRelationIds: Set<string>;
existingTemplateNames: Set<string>;
localTemplateNames: Set<string>;
};

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");
};
Loading