diff --git a/apps/obsidian/src/utils/conceptConversion.ts b/apps/obsidian/src/utils/conceptConversion.ts index 1c7c55cc4..238c5f7db 100644 --- a/apps/obsidian/src/utils/conceptConversion.ts +++ b/apps/obsidian/src/utils/conceptConversion.ts @@ -1,11 +1,15 @@ -/* eslint-disable @typescript-eslint/naming-convention */ import type { TFile } from "obsidian"; -import type { DiscourseNode } from "~/types"; +import type { + DiscourseNode, + DiscourseRelation, + DiscourseRelationType, + RelationInstance, +} from "~/types"; import type { SupabaseContext } from "./supabaseContext"; +import type { DiscourseNodeInVault } from "./getDiscourseNodes"; import type { LocalConceptDataInput } from "@repo/database/inputTypes"; import type { ObsidianDiscourseNodeData } from "./syncDgNodesToSupabase"; import type { Json } from "@repo/database/dbTypes"; -import DiscourseGraphPlugin from ".."; /** * Get extra data (author, timestamps) from file metadata @@ -14,6 +18,7 @@ const getNodeExtraData = ( file: TFile, accountLocalId: string, ): { + /* eslint-disable @typescript-eslint/naming-convention */ author_local_id: string; created: string; last_modified: string; @@ -23,6 +28,7 @@ const getNodeExtraData = ( created: new Date(file.stat.ctime).toISOString(), last_modified: new Date(file.stat.mtime).toISOString(), }; + /* eslint-enable @typescript-eslint/naming-convention */ }; export const discourseNodeSchemaToLocalConcept = ({ @@ -34,8 +40,23 @@ export const discourseNodeSchemaToLocalConcept = ({ node: DiscourseNode; accountLocalId: string; }): LocalConceptDataInput => { - const { description, template, id, name, created, modified, ...otherData } = - node; + const { + description, + template, + id, + name, + created, + modified, + importedFromRid, + ...otherData + } = node; + /* eslint-disable @typescript-eslint/naming-convention */ + const literal_content: Record = { + label: name, + source_data: otherData, + }; + if (template) literal_content.template = template; + if (importedFromRid) literal_content.importedFromRid = importedFromRid; return { space_id: context.spaceId, name, @@ -45,11 +66,106 @@ export const discourseNodeSchemaToLocalConcept = ({ created: new Date(created).toISOString(), last_modified: new Date(modified).toISOString(), description: description, - literal_content: { - label: name, - template: template, - source_data: otherData, + literal_content, + /* eslint-enable @typescript-eslint/naming-convention */ + }; +}; + +const STANDARD_ROLES = ["source", "destination"]; + +export const discourseRelationTypeToLocalConcept = ({ + context, + relationType, + accountLocalId, +}: { + context: SupabaseContext; + relationType: DiscourseRelationType; + accountLocalId: string; +}): LocalConceptDataInput => { + const { + id, + label, + complement, + created, + modified, + importedFromRid, + ...otherData + } = relationType; + // eslint-disable-next-line @typescript-eslint/naming-convention + const literal_content: Record = { + roles: STANDARD_ROLES, + label, + complement, + // eslint-disable-next-line @typescript-eslint/naming-convention + source_data: otherData, + }; + if (importedFromRid) literal_content.importedFromRid = importedFromRid; + + return { + /* eslint-disable @typescript-eslint/naming-convention */ + space_id: context.spaceId, + name: label, + source_local_id: id, + is_schema: true, + author_local_id: accountLocalId, + created: new Date(created).toISOString(), + last_modified: new Date(modified).toISOString(), + literal_content, + /* eslint-enable @typescript-eslint/naming-convention */ + }; +}; + +export const discourseRelationTripleSchemaToLocalConcept = ({ + context, + relation, + accountLocalId, + nodeTypesById, + relationTypesById, +}: { + context: SupabaseContext; + relation: DiscourseRelation; + accountLocalId: string; + nodeTypesById: Record; + relationTypesById: Record; +}): LocalConceptDataInput | null => { + const { + id, + relationshipTypeId, + sourceId, + destinationId, + created, + modified, + importedFromRid, + } = relation; + const sourceName = nodeTypesById[sourceId]?.name ?? sourceId; + const destinationName = nodeTypesById[destinationId]?.name ?? destinationId; + const relationType = relationTypesById[relationshipTypeId]; + if (!relationType) return null; + const { label, complement } = relationType; + // eslint-disable-next-line @typescript-eslint/naming-convention + const literal_content: Record = { + roles: STANDARD_ROLES, + label, + complement, + }; + if (importedFromRid) literal_content.importedFromRid = importedFromRid; + + return { + /* eslint-disable @typescript-eslint/naming-convention */ + space_id: context.spaceId, + name: `${sourceName} -${label}-> ${destinationName}`, + source_local_id: id, + is_schema: true, + author_local_id: accountLocalId, + created: new Date(created).toISOString(), + last_modified: new Date(modified).toISOString(), + literal_content, + local_reference_content: { + relation_type: relationshipTypeId, + source: sourceId, + destination: destinationId, }, + /* eslint-enable @typescript-eslint/naming-convention */ }; }; @@ -66,21 +182,83 @@ export const discourseNodeInstanceToLocalConcept = ({ accountLocalId: string; }): LocalConceptDataInput => { const extraData = getNodeExtraData(nodeData.file, accountLocalId); - const { nodeInstanceId, nodeTypeId, ...otherData } = nodeData.frontmatter; + const { nodeInstanceId, nodeTypeId, importedFromRid, ...otherData } = + nodeData.frontmatter; + // eslint-disable-next-line @typescript-eslint/naming-convention + const literal_content: Record = { + label: nodeData.file.basename, + // eslint-disable-next-line @typescript-eslint/naming-convention + source_data: otherData as unknown as Json, + }; + if (importedFromRid && typeof importedFromRid === "string") + literal_content.importedFromRid = importedFromRid; return { + /* eslint-disable @typescript-eslint/naming-convention */ space_id: context.spaceId, name: nodeData.file.path, source_local_id: nodeInstanceId as string, schema_represented_by_local_id: nodeTypeId as string, is_schema: false, - literal_content: { - label: nodeData.file.basename, - source_data: otherData as unknown as Json, - }, + literal_content, + /* eslint-enable @typescript-eslint/naming-convention */ ...extraData, }; }; +export const relationInstanceToLocalConcept = ({ + context, + relationTypesById, + allNodesById, + relationInstanceData, +}: { + context: SupabaseContext; + relationTypesById: Record; + allNodesById: Record; + relationInstanceData: RelationInstance; +}): LocalConceptDataInput | null => { + const { type, created, lastModified, source, destination, importedFromRid } = + relationInstanceData; + const relationType = relationTypesById[type]; + + if (!relationType) { + console.error("Missing relationType id " + type); + return null; + } + const sourceNode = allNodesById[source]; + const destinationNode = allNodesById[destination]; + if (sourceNode === undefined || destinationNode === undefined) { + console.error("Cannot find the nodes"); + return null; + } + + if ( + sourceNode.frontmatter.importedFromRid || + destinationNode.frontmatter.importedFromRid + ) + return null; // punt relation to imported nodes for now. + // otherwise put the importedFromRid in source, dest. + + /* eslint-disable @typescript-eslint/naming-convention */ + const literal_content: Record = {}; + if (importedFromRid) literal_content.importedFromRid = importedFromRid; + return { + space_id: context.spaceId, + name: `[[${sourceNode.file.basename}]] -${relationType.label}-> [[${destinationNode.file.basename}]]`, + source_local_id: relationInstanceData.id, + author_local_id: relationInstanceData.author, + schema_represented_by_local_id: type, + is_schema: false, + created: new Date(created).toISOString(), + last_modified: new Date(lastModified ?? created).toISOString(), + literal_content, + local_reference_content: { + source, + destination, + }, + /* eslint-enable @typescript-eslint/naming-convention */ + }; +}; + export const relatedConcepts = (concept: LocalConceptDataInput): string[] => { const relations = Object.values( concept.local_reference_content || {}, @@ -99,27 +277,40 @@ export const relatedConcepts = (concept: LocalConceptDataInput): string[] => { * schema_represented_by_local_id or local_reference_content — so that id * must equal some concept's source_local_id or it is reported as "missing". */ -const orderConceptsRec = ( - ordered: LocalConceptDataInput[], - concept: LocalConceptDataInput, - remainder: { [key: string]: LocalConceptDataInput }, -): Set => { +const orderConceptsRec = ({ + ordered, + concept, + remainder, + processed, +}: { + ordered: LocalConceptDataInput[]; + concept: LocalConceptDataInput; + remainder: { [key: string]: LocalConceptDataInput }; + processed: Set; +}): Set => { const relatedConceptIds = relatedConcepts(concept); let missing: Set = new Set(); while (relatedConceptIds.length > 0) { const relatedConceptId = relatedConceptIds.shift()!; + if (processed.has(relatedConceptId)) continue; const relatedConcept = remainder[relatedConceptId]; if (relatedConcept === undefined) { missing.add(relatedConceptId); } else { missing = new Set([ ...missing, - ...orderConceptsRec(ordered, relatedConcept, remainder), + ...orderConceptsRec({ + ordered, + concept: relatedConcept, + remainder, + processed, + }), ]); delete remainder[relatedConceptId]; } } ordered.push(concept); + processed.add(concept.source_local_id!); delete remainder[concept.source_local_id!]; return missing; }; @@ -143,14 +334,20 @@ export const orderConceptsByDependency = ( ); const ordered: LocalConceptDataInput[] = []; let missing: Set = new Set(); + const processed: Set = new Set(); while (Object.keys(conceptById).length > 0) { const first = Object.values(conceptById)[0]; if (!first) break; missing = new Set([ ...missing, - ...orderConceptsRec(ordered, first, conceptById), + ...orderConceptsRec({ + ordered, + concept: first, + remainder: conceptById, + processed, + }), ]); - if (missing.size > 0) console.error(`missing: ${[...missing]}`); + if (missing.size > 0) console.error(`missing: ${[...missing].join(", ")}`); } return { ordered, missing: Array.from(missing) }; }; diff --git a/apps/obsidian/src/utils/relationsStore.ts b/apps/obsidian/src/utils/relationsStore.ts index 90cfd8256..6bb079adf 100644 --- a/apps/obsidian/src/utils/relationsStore.ts +++ b/apps/obsidian/src/utils/relationsStore.ts @@ -4,7 +4,7 @@ import type DiscourseGraphPlugin from "~/index"; import { ensureNodeInstanceId } from "~/utils/nodeInstanceId"; import { checkAndCreateFolder } from "~/utils/file"; import { getVaultId } from "./supabaseContext"; -import { RelationInstance } from "../types"; +import type { RelationInstance } from "~/types"; const RELATIONS_FILE_NAME = "relations.json"; const RELATIONS_FILE_VERSION = 1; diff --git a/apps/obsidian/src/utils/syncDgNodesToSupabase.ts b/apps/obsidian/src/utils/syncDgNodesToSupabase.ts index 8d0e55b07..7a2ec59a5 100644 --- a/apps/obsidian/src/utils/syncDgNodesToSupabase.ts +++ b/apps/obsidian/src/utils/syncDgNodesToSupabase.ts @@ -15,7 +15,11 @@ import { orderConceptsByDependency, discourseNodeInstanceToLocalConcept, discourseNodeSchemaToLocalConcept, + discourseRelationTripleSchemaToLocalConcept, + discourseRelationTypeToLocalConcept, + relationInstanceToLocalConcept, } from "./conceptConversion"; +import { loadRelations } from "~/utils/relationsStore"; import type { LocalConceptDataInput } from "@repo/database/inputTypes"; import { type DiscourseNodeInVault, @@ -166,7 +170,7 @@ const getLastContentSyncTime = async ( return new Date((data?.last_modified || DEFAULT_TIME) + "Z"); }; -const getLastSchemaSyncTime = async ( +const getLastNodeSchemaSyncTime = async ( supabaseClient: DGSupabaseClient, spaceId: number, ): Promise => { @@ -175,6 +179,39 @@ const getLastSchemaSyncTime = async ( .select("last_modified") .eq("space_id", spaceId) .eq("is_schema", true) + .eq("arity", 0) + .order("last_modified", { ascending: false }) + .limit(1) + .maybeSingle(); + return new Date((data?.last_modified || DEFAULT_TIME) + "Z"); +}; + +const getLastRelationSchemaSyncTime = async ( + supabaseClient: DGSupabaseClient, + spaceId: number, +): Promise => { + const { data } = await supabaseClient + .from("Concept") + .select("last_modified") + .eq("space_id", spaceId) + .eq("is_schema", true) + .gt("arity", 0) + .order("last_modified", { ascending: false }) + .limit(1) + .maybeSingle(); + return new Date((data?.last_modified || DEFAULT_TIME) + "Z"); +}; + +const getLastRelationSyncTime = async ( + supabaseClient: DGSupabaseClient, + spaceId: number, +): Promise => { + const { data } = await supabaseClient + .from("Concept") + .select("last_modified") + .eq("space_id", spaceId) + .eq("is_schema", false) + .gt("arity", 0) .order("last_modified", { ascending: false }) .limit(1) .maybeSingle(); @@ -467,32 +504,109 @@ const convertDgToSupabaseConcepts = async ({ accountLocalId: string; plugin: DiscourseGraphPlugin; }): Promise => { - const lastSchemaSync = ( - await getLastSchemaSyncTime(supabaseClient, context.spaceId) + const lastNodeSchemaSync = ( + await getLastNodeSchemaSyncTime(supabaseClient, context.spaceId) + ).getTime(); + const lastRelationSchemaSync = ( + await getLastRelationSchemaSyncTime(supabaseClient, context.spaceId) + ).getTime(); + const lastRelationsSync = ( + await getLastRelationSyncTime(supabaseClient, context.spaceId) ).getTime(); - const newNodeTypes = (plugin.settings.nodeTypes ?? []).filter( - (n) => n.modified > lastSchemaSync, + const nodeTypes = plugin.settings.nodeTypes ?? []; + const relationTypes = plugin.settings.relationTypes ?? []; + const discourseRelations = plugin.settings.discourseRelations ?? []; + const allNodes = await collectDiscourseNodesFromVault(plugin); + const allNodesById = Object.fromEntries( + allNodes.map((n) => [n.nodeInstanceId, n]), ); - const nodesTypesToLocalConcepts = newNodeTypes.map((nodeType) => - discourseNodeSchemaToLocalConcept({ - context, - node: nodeType, - accountLocalId, - }), + const nodeTypesById = Object.fromEntries( + nodeTypes.map((nodeType) => [nodeType.id, nodeType]), ); - const nodeInstanceToLocalConcepts = nodesSince.map((node) => - discourseNodeInstanceToLocalConcept({ + const nodesTypesToLocalConcepts = nodeTypes + .filter((nodeType) => nodeType.modified > lastNodeSchemaSync) + .map((nodeType) => + discourseNodeSchemaToLocalConcept({ + context, + node: nodeType, + accountLocalId, + }), + ); + + const relationTypesById = Object.fromEntries( + relationTypes.map((relationType) => [relationType.id, relationType]), + ); + + const relationTypesToLocalConcepts = relationTypes + .filter((relationType) => relationType.modified > lastRelationSchemaSync) + .map((relationType) => + discourseRelationTypeToLocalConcept({ + context, + relationType, + accountLocalId, + }), + ); + + const discourseRelationTriplesToLocalConcepts = discourseRelations + .filter( + (relationTriple) => + relationTriple.modified > lastRelationSchemaSync || + // resync if type was changed, to update labels in triple + (relationTypesById[relationTriple.relationshipTypeId]?.modified ?? 0) > + lastRelationSchemaSync || + // resync if source or destination node type was changed, to update names in triple + (nodeTypesById[relationTriple.sourceId]?.modified ?? 0) > + lastNodeSchemaSync || + (nodeTypesById[relationTriple.destinationId]?.modified ?? 0) > + lastNodeSchemaSync, + ) + .map((relation) => + discourseRelationTripleSchemaToLocalConcept({ + context, + relation, + accountLocalId, + nodeTypesById, + relationTypesById, + }), + ) + .filter((n) => !!n); + + const nodeInstanceToLocalConcepts = nodesSince.map((node) => { + return discourseNodeInstanceToLocalConcept({ context, nodeData: node, accountLocalId, - }), - ); + }); + }); + + const relationInstancesData = await loadRelations(plugin); + const relationInstanceToLocalConcepts = Object.values( + relationInstancesData.relations, + ) + .filter( + (relationInstanceData) => + !relationInstanceData.importedFromRid && + (relationInstanceData.lastModified || relationInstanceData.created) > + lastRelationsSync, + ) + .map((relationInstanceData) => + relationInstanceToLocalConcept({ + context, + relationTypesById, + allNodesById, + relationInstanceData, + }), + ) + .filter((n) => !!n); const conceptsToUpsert: LocalConceptDataInput[] = [ ...nodesTypesToLocalConcepts, + ...relationTypesToLocalConcepts, + ...discourseRelationTriplesToLocalConcepts, ...nodeInstanceToLocalConcepts, + ...relationInstanceToLocalConcepts, ]; if (conceptsToUpsert.length > 0) { diff --git a/apps/roam/src/utils/conceptConversion.ts b/apps/roam/src/utils/conceptConversion.ts index e257e1880..15037fef6 100644 --- a/apps/roam/src/utils/conceptConversion.ts +++ b/apps/roam/src/utils/conceptConversion.ts @@ -1,17 +1,24 @@ +import { InputTextNode } from "roamjs-components/types"; +import getBlockProps from "./getBlockProps"; import { DiscourseNode } from "./getDiscourseNodes"; import getDiscourseRelations from "./getDiscourseRelations"; import type { DiscourseRelation } from "./getDiscourseRelations"; import type { SupabaseContext } from "~/utils/supabaseContext"; +import { DISCOURSE_GRAPH_PROP_NAME } from "~/utils/createReifiedBlock"; import type { LocalConceptDataInput } from "@repo/database/inputTypes"; +import type { Json } from "@repo/database/dbTypes"; +import getPageTitleByPageUid from "roamjs-components/queries/getPageTitleByPageUid"; const getNodeExtraData = ( + /* eslint-disable @typescript-eslint/naming-convention */ node_uid: string, ): { author_uid: string; created: string; last_modified: string; page_uid: string; + /* eslint-enable @typescript-eslint/naming-convention */ } => { const result = window.roamAlphaAPI.q( `[ @@ -35,6 +42,7 @@ const getNodeExtraData = ( if (result.length !== 1 || result[0].length !== 4) throw new Error("Invalid result from Roam query"); + /* eslint-disable @typescript-eslint/naming-convention */ const [author_uid, page_uid, created_t, last_modified_t] = result[0] as [ string, string, @@ -49,20 +57,44 @@ const getNodeExtraData = ( last_modified, page_uid, }; + /* eslint-enable @typescript-eslint/naming-convention */ }; +const indent = (s: string): string => + s + .split("\n") + .map((l) => " " + l) + .join("\n") + "\n"; + +const templateToText = (template: InputTextNode[]): string => + template + .filter((itn) => !itn.text.startsWith("{{")) + .map( + (itn) => + `* ${itn.text}\n${itn.children?.length ? indent(templateToText(itn.children)) : ""}`, + ) + .join(""); + export const discourseNodeSchemaToLocalConcept = ( context: SupabaseContext, node: DiscourseNode, ): LocalConceptDataInput => { const titleParts = node.text.split("/"); - return { + const result: LocalConceptDataInput = { + /* eslint-disable @typescript-eslint/naming-convention */ space_id: context.spaceId, - name: titleParts[titleParts.length - 1], + name: node.text, source_local_id: node.type, is_schema: true, + /* eslint-enable @typescript-eslint/naming-convention */ ...getNodeExtraData(node.type), }; + if (node.template !== undefined) + result.literal_content = { + label: titleParts[titleParts.length - 1], + template: templateToText(node.template), + }; + return result; }; export const discourseNodeBlockToLocalConcept = ( @@ -78,47 +110,62 @@ export const discourseNodeBlockToLocalConcept = ( }, ): LocalConceptDataInput => { return { + /* eslint-disable @typescript-eslint/naming-convention */ space_id: context.spaceId, name: text, source_local_id: nodeUid, schema_represented_by_local_id: schemaUid, is_schema: false, + /* eslint-enable @typescript-eslint/naming-convention */ ...getNodeExtraData(nodeUid), }; }; -const STANDARD_ROLES = ["source", "target"]; +const STANDARD_ROLES = ["source", "destination"]; export const discourseRelationSchemaToLocalConcept = ( context: SupabaseContext, relation: DiscourseRelation, ): LocalConceptDataInput => { + const { id, label, complement, source, destination, ...otherData } = relation; return { + /* eslint-disable @typescript-eslint/naming-convention */ space_id: context.spaceId, - source_local_id: relation.id, - // Not using the label directly, because it is not unique and name should be unique - name: `${relation.id}-${relation.label}`, + source_local_id: id, + name: getPageTitleByPageUid(id), is_schema: true, - local_reference_content: Object.fromEntries( - Object.entries(relation).filter(([key, v]) => - STANDARD_ROLES.includes(key), - ), - ) as { [key: string]: string }, + local_reference_content: { + source, + destination, + }, literal_content: { + source_data: otherData as unknown as Json, + /* eslint-enable @typescript-eslint/naming-convention */ roles: STANDARD_ROLES, - label: relation.label, - complement: relation.complement, - representation: relation.triples.map((t) => t[0]), + label: label, + complement: complement, }, - ...getNodeExtraData(relation.id), + ...getNodeExtraData(id), }; }; export const discourseRelationDataToLocalConcept = ( context: SupabaseContext, - relationSchemaUid: string, - relationNodes: { [role: string]: string }, + relationUid: string, ): LocalConceptDataInput => { + // assuming reified + const relationProps = getBlockProps(relationUid); + const relationSchemaData = relationProps[DISCOURSE_GRAPH_PROP_NAME] as Record< + string, + string + >; + if (!relationSchemaData) { + throw new Error(`Missing relation data for ${relationUid}`); + } + const relationSchemaUid = relationSchemaData.hasSchema; + if (!relationSchemaUid) { + throw new Error(`Missing relation schema uid for ${relationUid}`); + } const roamRelation = getDiscourseRelations().find( (r) => r.id === relationSchemaUid, ); @@ -126,15 +173,15 @@ export const discourseRelationDataToLocalConcept = ( throw new Error(`Invalid roam relation id ${relationSchemaUid}`); } const relation = discourseRelationSchemaToLocalConcept(context, roamRelation); - const litContent = (relation.literal_content - ? relation.literal_content - : {}) as unknown as { [key: string]: any }; + const litContent = ( + relation.literal_content ? relation.literal_content : {} + ) as { [key: string]: Json }; const roles = (litContent["roles"] as string[] | undefined) || STANDARD_ROLES; - const casting: { [role: string]: string } = Object.fromEntries( + const casting = Object.fromEntries( roles - .map((role) => [role, relationNodes[role]]) + .map((role) => [role, relationSchemaData[role + "Uid"]]) .filter(([, uid]) => uid !== undefined), - ); + ) as { [role: string]: string }; if (Object.keys(casting).length === 0) { throw new Error( `No valid node UIDs supplied for roles ${roles.join(", ")}`, @@ -143,6 +190,7 @@ export const discourseRelationDataToLocalConcept = ( // TODO: Also get the nodes from the representation, using QueryBuilder. That will likely give me the relation object const nodeData = Object.values(casting).map((v) => getNodeExtraData(v)); // roundabout way to do a max from stringified dates + // eslint-disable-next-line @typescript-eslint/naming-convention const last_modified = new Date( Math.max(...nodeData.map((nd) => new Date(nd.last_modified).getTime())), ).toISOString(); @@ -151,19 +199,20 @@ export const discourseRelationDataToLocalConcept = ( const created = new Date( Math.max(...nodeData.map((nd) => new Date(nd.created).getTime())), ).toISOString(); + /* eslint-disable @typescript-eslint/naming-convention */ const author_local_id: string = nodeData[0].author_uid; // take any one; again until I get the relation object - const source_local_id = casting["target"] || Object.values(casting)[0]; // This one is tricky. Prefer the target for now. return { space_id: context.spaceId, - source_local_id, + source_local_id: relationUid, author_local_id, created, last_modified, - name: `${relationSchemaUid}-${Object.values(casting).join("-")}`, + name: relationUid, is_schema: false, schema_represented_by_local_id: relationSchemaUid, local_reference_content: casting, }; + /* eslint-enable @typescript-eslint/naming-convention */ }; export const relatedConcepts = (concept: LocalConceptDataInput): string[] => { @@ -177,27 +226,40 @@ export const relatedConcepts = (concept: LocalConceptDataInput): string[] => { return [...new Set(relations)]; }; -const orderConceptsRec = ( - ordered: LocalConceptDataInput[], - concept: LocalConceptDataInput, - remainder: { [key: string]: LocalConceptDataInput }, -): Set => { +const orderConceptsRec = ({ + ordered, + concept, + remainder, + processed, +}: { + ordered: LocalConceptDataInput[]; + concept: LocalConceptDataInput; + remainder: { [key: string]: LocalConceptDataInput }; + processed: Set; +}): Set => { const relatedConceptIds = relatedConcepts(concept); let missing: Set = new Set(); while (relatedConceptIds.length > 0) { const relatedConceptId = relatedConceptIds.shift()!; + if (processed.has(relatedConceptId)) continue; const relatedConcept = remainder[relatedConceptId]; if (relatedConcept === undefined) { missing.add(relatedConceptId); } else { missing = new Set([ ...missing, - ...orderConceptsRec(ordered, relatedConcept, remainder), + ...orderConceptsRec({ + ordered, + concept: relatedConcept, + remainder, + processed, + }), ]); delete remainder[relatedConceptId]; } } ordered.push(concept); + processed.add(concept.source_local_id!); delete remainder[concept.source_local_id!]; return missing; }; @@ -219,15 +281,22 @@ export const orderConceptsByDependency = ( concepts: LocalConceptDataInput[], ): { ordered: LocalConceptDataInput[]; missing: string[] } => { if (concepts.length === 0) return { ordered: concepts, missing: [] }; - const conceptById: { [key: string]: LocalConceptDataInput } = - Object.fromEntries(concepts.map((c) => [c.source_local_id, c])); + const conceptById = Object.fromEntries( + concepts.map((c) => [c.source_local_id, c]), + ) as { [key: string]: LocalConceptDataInput }; const ordered: LocalConceptDataInput[] = []; let missing: Set = new Set(); + const processed: Set = new Set(); while (Object.keys(conceptById).length > 0) { const first = Object.values(conceptById)[0]; missing = new Set([ ...missing, - ...orderConceptsRec(ordered, first, conceptById), + ...orderConceptsRec({ + ordered, + concept: first, + remainder: conceptById, + processed, + }), ]); } return { ordered, missing: [...missing] };