Skip to content

Commit 12b2190

Browse files
authored
ENG-332 content to concept conversion functions (#220)
ENG-332: content to concept conversion Explain sync ordering with a `orderConceptsByDependency` function
1 parent 13c7ac3 commit 12b2190

File tree

1 file changed

+240
-0
lines changed

1 file changed

+240
-0
lines changed
Lines changed: 240 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,240 @@
1+
import { DiscourseNode } from "./getDiscourseNodes";
2+
import getDiscourseRelations from "./getDiscourseRelations";
3+
import type { DiscourseRelation } from "./getDiscourseRelations";
4+
import type { SupabaseContext } from "~/utils/supabaseContext";
5+
6+
import type { LocalConceptDataInput } from "@repo/database/inputTypes";
7+
8+
const getNodeExtraData = (
9+
node_uid: string,
10+
): {
11+
author_uid: string;
12+
created: string;
13+
last_modified: string;
14+
page_uid: string;
15+
} => {
16+
const result = window.roamAlphaAPI.q(
17+
`[
18+
:find
19+
?author_uid
20+
?page_uid
21+
?created
22+
?last_modified
23+
:in $ ?block_uid
24+
:where
25+
[?block :block/uid ?block_uid]
26+
[?block :create/user ?author_id]
27+
[?author_id :user/uid ?author_uid]
28+
[?block :create/time ?created]
29+
[?block :edit/time ?last_modified]
30+
[(get-else $ ?block :block/page ?block) ?page_id]
31+
[?page_id :block/uid ?page_uid]
32+
]`,
33+
node_uid,
34+
);
35+
if (result.length !== 1 || result[0].length !== 4)
36+
throw new Error("Invalid result from Roam query");
37+
38+
const [author_uid, page_uid, created_t, last_modified_t] = result[0] as [
39+
string,
40+
string,
41+
number,
42+
number,
43+
];
44+
const created = new Date(created_t).toISOString();
45+
const last_modified = new Date(last_modified_t).toISOString();
46+
return {
47+
author_uid,
48+
created,
49+
last_modified,
50+
page_uid,
51+
};
52+
};
53+
54+
export const discourseNodeSchemaToLocalConcept = (
55+
context: SupabaseContext,
56+
node: DiscourseNode,
57+
): LocalConceptDataInput => {
58+
const titleParts = node.text.split("/");
59+
return {
60+
space_id: context.spaceId,
61+
name: titleParts[titleParts.length - 1],
62+
represented_by_local_id: node.type,
63+
is_schema: true,
64+
...getNodeExtraData(node.type),
65+
};
66+
};
67+
68+
export const discourseNodeBlockToLocalConcept = (
69+
context: SupabaseContext,
70+
{
71+
nodeUid,
72+
schemaUid,
73+
text,
74+
}: {
75+
nodeUid: string;
76+
schemaUid: string;
77+
text: string;
78+
},
79+
): LocalConceptDataInput => {
80+
return {
81+
space_id: context.spaceId,
82+
name: text,
83+
represented_by_local_id: nodeUid,
84+
schema_represented_by_local_id: schemaUid,
85+
is_schema: false,
86+
...getNodeExtraData(nodeUid),
87+
};
88+
};
89+
90+
const STANDARD_ROLES = ["source", "target"];
91+
92+
export const discourseRelationSchemaToLocalConcept = (
93+
context: SupabaseContext,
94+
relation: DiscourseRelation,
95+
): LocalConceptDataInput => {
96+
return {
97+
space_id: context.spaceId,
98+
represented_by_local_id: relation.id,
99+
// Not using the label directly, because it is not unique and name should be unique
100+
name: `${relation.id}-${relation.label}`,
101+
is_schema: true,
102+
local_reference_content: Object.fromEntries(
103+
Object.entries(relation).filter(([key, v]) =>
104+
STANDARD_ROLES.includes(key),
105+
),
106+
) as { [key: string]: string },
107+
literal_content: {
108+
roles: STANDARD_ROLES,
109+
label: relation.label,
110+
complement: relation.complement,
111+
representation: relation.triples.map((t) => t[0]),
112+
},
113+
...getNodeExtraData(relation.id),
114+
};
115+
};
116+
117+
export const discourseRelationDataToLocalConcept = (
118+
context: SupabaseContext,
119+
relationSchemaUid: string,
120+
relationNodes: { [role: string]: string },
121+
): LocalConceptDataInput => {
122+
const roamRelation = getDiscourseRelations().find(
123+
(r) => r.id === relationSchemaUid,
124+
);
125+
if (roamRelation === undefined) {
126+
throw new Error(`Invalid roam relation id ${relationSchemaUid}`);
127+
}
128+
const relation = discourseRelationSchemaToLocalConcept(context, roamRelation);
129+
const litContent = (relation.literal_content
130+
? relation.literal_content
131+
: {}) as unknown as { [key: string]: any };
132+
const roles = (litContent["roles"] as string[] | undefined) || STANDARD_ROLES;
133+
const casting: { [role: string]: string } = Object.fromEntries(
134+
roles
135+
.map((role) => [role, relationNodes[role]])
136+
.filter(([, uid]) => uid !== undefined),
137+
);
138+
if (Object.keys(casting).length === 0) {
139+
throw new Error(
140+
`No valid node UIDs supplied for roles ${roles.join(", ")}`,
141+
);
142+
}
143+
// TODO: Also get the nodes from the representation, using QueryBuilder. That will likely give me the relation object
144+
const nodeData = Object.values(casting).map((v) => getNodeExtraData(v));
145+
// roundabout way to do a max from stringified dates
146+
const last_modified = new Date(
147+
Math.max(...nodeData.map((nd) => new Date(nd.last_modified).getTime())),
148+
).toISOString();
149+
// creation is actually creation of the relation node, not the rest of the cast, but this will do as a first approximation.
150+
// Still using max, since the relation cannot be created before its cast
151+
const created = new Date(
152+
Math.max(...nodeData.map((nd) => new Date(nd.created).getTime())),
153+
).toISOString();
154+
const author_local_id: string = nodeData[0].author_uid; // take any one; again until I get the relation object
155+
const represented_by_local_id =
156+
casting["target"] || Object.values(casting)[0]; // This one is tricky. Prefer the target for now.
157+
return {
158+
space_id: context.spaceId,
159+
represented_by_local_id,
160+
author_local_id,
161+
created,
162+
last_modified,
163+
name: `${relationSchemaUid}-${Object.values(casting).join("-")}`,
164+
is_schema: false,
165+
schema_represented_by_local_id: relationSchemaUid,
166+
local_reference_content: casting,
167+
};
168+
};
169+
170+
export const relatedConcepts = (concept: LocalConceptDataInput): string[] => {
171+
const relations = Object.values(
172+
concept.local_reference_content || {},
173+
).flat() as string[];
174+
if (concept.schema_represented_by_local_id) {
175+
relations.push(concept.schema_represented_by_local_id);
176+
}
177+
// remove duplicates
178+
return [...new Set(relations)];
179+
};
180+
181+
const orderConceptsRec = (
182+
ordered: LocalConceptDataInput[],
183+
concept: LocalConceptDataInput,
184+
remainder: { [key: string]: LocalConceptDataInput },
185+
): Set<string> => {
186+
const relatedConceptIds = relatedConcepts(concept);
187+
let missing: Set<string> = new Set();
188+
while (relatedConceptIds.length > 0) {
189+
const relatedConceptId = relatedConceptIds.shift()!;
190+
const relatedConcept = remainder[relatedConceptId];
191+
if (relatedConcept === undefined) {
192+
missing.add(relatedConceptId);
193+
} else {
194+
missing = missing.union(
195+
orderConceptsRec(ordered, relatedConcept, remainder),
196+
);
197+
delete remainder[relatedConceptId];
198+
}
199+
}
200+
ordered.push(concept);
201+
delete remainder[concept.represented_by_local_id!];
202+
return missing;
203+
};
204+
205+
/*
206+
If writing a concept upsert method, you want to insure that
207+
a node's dependencies are defined before the node itself is upserted.
208+
The dependencies are as defined in relatedConcepts.
209+
If you upsert in the following order: [node schemas, relation schemas, nodes, relations]
210+
then the depencies will be implicitly respected.
211+
(It will be tricker when we have recursive relations.)
212+
If you are starting from a random stream of nodes, you would want to order them with this function.
213+
It assumes all input has defined represented_by_local_id,
214+
and that nodes that are not in the upsert set are already in the database.
215+
the Id of those nodes is returned and can be used to check that assumption.
216+
We also assume that there are no dependency cycles.
217+
*/
218+
export const orderConceptsByDependency = (
219+
concepts: LocalConceptDataInput[],
220+
): { ordered: LocalConceptDataInput[]; missing: string[] } => {
221+
if (concepts.length === 0) return { ordered: concepts, missing: [] };
222+
const conceptById: { [key: string]: LocalConceptDataInput } =
223+
Object.fromEntries(concepts.map((c) => [c.represented_by_local_id, c]));
224+
const ordered: LocalConceptDataInput[] = [];
225+
let missing: Set<string> = new Set();
226+
while (Object.keys(conceptById).length > 0) {
227+
const first = Object.values(conceptById)[0];
228+
missing = missing.union(orderConceptsRec(ordered, first, conceptById));
229+
}
230+
return { ordered, missing: [...missing] };
231+
};
232+
233+
// the input to the upsert method would look like this:
234+
235+
// const idata: LocalConceptDataInput[] = [
236+
// { "name": "Claim", "author_local_id": "sR22zZ470dNPkIf9PpjQXXdTBjG2", "represented_by_local_id": "a_roam_uid", "created": "2000/01/01", "last_modified": "2001/01/02", "is_schema": true },
237+
// { "name": "A Claim", "author_local_id": "sR22zZ470dNPkIf9PpjQXXdTBjG2", "represented_by_local_id": "a_roam_uid2", "created": "2000/01/03", "last_modified": "2001/01/04", "is_schema": false, "schema_represented_by_local_id": "a_roam_uid" },
238+
// { "name": "test2", "author_local_id": "sR22zZ470dNPkIf9PpjQXXdTBjG2", "created": "2000/01/04", "last_modified": "2001/01/05", "is_schema": false, "literal_content": { "source": "a_roam_uid", "target": ["a_roam_uid", "a_roam_uid2"] }, "local_reference_content": { "source": "a_roam_uid", "target": ["a_roam_uid", "a_roam_uid2"] } }]
239+
240+
// const { data, error } = await supabase_client.rpc("upsert_concepts", { v_space_id: 12, data: idata });

0 commit comments

Comments
 (0)