@graphty/graph-io 0.3.40 → 0.3.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/README.md +2 -0
  2. package/dist/chunks/{children-CL3Cy0ez.js → children-CA4coBi0.js} +36 -27
  3. package/dist/chunks/children-CA4coBi0.js.map +1 -0
  4. package/dist/chunks/{choose-CS3fvGT1.js → choose-DvpbdRuz.js} +2 -2
  5. package/dist/chunks/{choose-CS3fvGT1.js.map → choose-DvpbdRuz.js.map} +1 -1
  6. package/dist/chunks/{constants-DqKZT6cH.js → constants-fT3E8JYY.js} +2 -2
  7. package/dist/chunks/{constants-DqKZT6cH.js.map → constants-fT3E8JYY.js.map} +1 -1
  8. package/dist/chunks/{escape-BJRLd3vJ.js → escape-B62ZGM_a.js} +2 -2
  9. package/dist/chunks/{escape-BJRLd3vJ.js.map → escape-B62ZGM_a.js.map} +1 -1
  10. package/dist/chunks/{export-B-aVCwyG.js → export-rKLeGQMo.js} +404 -243
  11. package/dist/chunks/export-rKLeGQMo.js.map +1 -0
  12. package/dist/chunks/{exporter-6ocL_9x_.js → exporter-8hLNemFk.js} +4 -4
  13. package/dist/chunks/{exporter-6ocL_9x_.js.map → exporter-8hLNemFk.js.map} +1 -1
  14. package/dist/chunks/{exporter-BVXnV9-9.js → exporter-BWwEuOPT.js} +29 -17
  15. package/dist/chunks/exporter-BWwEuOPT.js.map +1 -0
  16. package/dist/chunks/{exporter-Bcp-l4Bo.js → exporter-CpRyHZ3H.js} +574 -475
  17. package/dist/chunks/exporter-CpRyHZ3H.js.map +1 -0
  18. package/dist/chunks/{exporter-SFYkDzD0.js → exporter-yLxKBvLE.js} +23 -20
  19. package/dist/chunks/{exporter-SFYkDzD0.js.map → exporter-yLxKBvLE.js.map} +1 -1
  20. package/dist/chunks/{importer-DcKB97kW.js → importer-BbNb7eNg.js} +111 -71
  21. package/dist/chunks/importer-BbNb7eNg.js.map +1 -0
  22. package/dist/chunks/{importer-D_srHyj6.js → importer-C98dpSH4.js} +5 -5
  23. package/dist/chunks/{importer-D_srHyj6.js.map → importer-C98dpSH4.js.map} +1 -1
  24. package/dist/chunks/{importer-BJlGAOht.js → importer-D794XUTe.js} +3 -3
  25. package/dist/chunks/{importer-BJlGAOht.js.map → importer-D794XUTe.js.map} +1 -1
  26. package/dist/chunks/{importer-AjwOJrn2.js → importer-Dz7tI8Sa.js} +202 -181
  27. package/dist/chunks/importer-Dz7tI8Sa.js.map +1 -0
  28. package/dist/chunks/{importer-DEp_fYjf.js → importer-Ht7LNg8L.js} +92 -64
  29. package/dist/chunks/importer-Ht7LNg8L.js.map +1 -0
  30. package/dist/chunks/{importer-CPqnIY8m.js → importer-WrGRZb0N.js} +42 -29
  31. package/dist/chunks/importer-WrGRZb0N.js.map +1 -0
  32. package/dist/chunks/{importer-BpuS3J8C.js → importer-xZ-3suY1.js} +74 -61
  33. package/dist/chunks/importer-xZ-3suY1.js.map +1 -0
  34. package/dist/chunks/{json-elements-DpO6AXIc.js → json-elements-DKSqpNW1.js} +3 -3
  35. package/dist/chunks/{json-elements-DpO6AXIc.js.map → json-elements-DKSqpNW1.js.map} +1 -1
  36. package/dist/chunks/{ontology-export-50L9DOV4.js → ontology-export-CtRXjg_i.js} +2 -2
  37. package/dist/chunks/{ontology-export-50L9DOV4.js.map → ontology-export-CtRXjg_i.js.map} +1 -1
  38. package/dist/chunks/{records-r3SFHz11.js → records-Cu4bxYOm.js} +69 -36
  39. package/dist/chunks/records-Cu4bxYOm.js.map +1 -0
  40. package/dist/chunks/{text-Bjfa1xYL.js → text-DHOJXnED.js} +20 -11
  41. package/dist/chunks/text-DHOJXnED.js.map +1 -0
  42. package/dist/csv.js +323 -241
  43. package/dist/csv.js.map +1 -1
  44. package/dist/cx.js +1 -1
  45. package/dist/cx2.js +2 -2
  46. package/dist/cys.js +1 -1
  47. package/dist/dot.js +1 -1
  48. package/dist/gexf.js +195 -97
  49. package/dist/gexf.js.map +1 -1
  50. package/dist/gml.js +170 -149
  51. package/dist/gml.js.map +1 -1
  52. package/dist/graph-io.js +27 -27
  53. package/dist/graphml.js +2 -2
  54. package/dist/json.js +1 -1
  55. package/dist/neo4j.js +103 -65
  56. package/dist/neo4j.js.map +1 -1
  57. package/dist/obo.js +1 -1
  58. package/dist/pajek.js +1 -1
  59. package/dist/src/children.d.ts.map +1 -1
  60. package/dist/src/children.js +50 -27
  61. package/dist/src/children.js.map +1 -1
  62. package/dist/src/common/attributes.d.ts.map +1 -1
  63. package/dist/src/common/attributes.js +86 -42
  64. package/dist/src/common/attributes.js.map +1 -1
  65. package/dist/src/common/input.d.ts.map +1 -1
  66. package/dist/src/common/input.js +358 -184
  67. package/dist/src/common/input.js.map +1 -1
  68. package/dist/src/common/lists.js +30 -15
  69. package/dist/src/common/lists.js.map +1 -1
  70. package/dist/src/common/text.d.ts +7 -0
  71. package/dist/src/common/text.d.ts.map +1 -1
  72. package/dist/src/common/text.js +15 -7
  73. package/dist/src/common/text.js.map +1 -1
  74. package/dist/src/common/zip.d.ts.map +1 -1
  75. package/dist/src/common/zip.js +36 -19
  76. package/dist/src/common/zip.js.map +1 -1
  77. package/dist/src/formats/csv/exporter.d.ts.map +1 -1
  78. package/dist/src/formats/csv/exporter.js +288 -146
  79. package/dist/src/formats/csv/exporter.js.map +1 -1
  80. package/dist/src/formats/csv/importer.d.ts.map +1 -1
  81. package/dist/src/formats/csv/importer.js +129 -80
  82. package/dist/src/formats/csv/importer.js.map +1 -1
  83. package/dist/src/formats/csv/records.d.ts.map +1 -1
  84. package/dist/src/formats/csv/records.js +78 -36
  85. package/dist/src/formats/csv/records.js.map +1 -1
  86. package/dist/src/formats/cys/tables.d.ts.map +1 -1
  87. package/dist/src/formats/cys/tables.js +11 -8
  88. package/dist/src/formats/cys/tables.js.map +1 -1
  89. package/dist/src/formats/dot/tokenizer.d.ts +39 -0
  90. package/dist/src/formats/dot/tokenizer.d.ts.map +1 -1
  91. package/dist/src/formats/dot/tokenizer.js +112 -66
  92. package/dist/src/formats/dot/tokenizer.js.map +1 -1
  93. package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
  94. package/dist/src/formats/gexf/exporter.js +65 -37
  95. package/dist/src/formats/gexf/exporter.js.map +1 -1
  96. package/dist/src/formats/gexf/importer.d.ts.map +1 -1
  97. package/dist/src/formats/gexf/importer.js +138 -50
  98. package/dist/src/formats/gexf/importer.js.map +1 -1
  99. package/dist/src/formats/gml/exporter.d.ts.map +1 -1
  100. package/dist/src/formats/gml/exporter.js +51 -35
  101. package/dist/src/formats/gml/exporter.js.map +1 -1
  102. package/dist/src/formats/gml/importer.d.ts.map +1 -1
  103. package/dist/src/formats/gml/importer.js +103 -58
  104. package/dist/src/formats/gml/importer.js.map +1 -1
  105. package/dist/src/formats/gml/syntax.d.ts.map +1 -1
  106. package/dist/src/formats/gml/syntax.js +17 -8
  107. package/dist/src/formats/gml/syntax.js.map +1 -1
  108. package/dist/src/formats/graphml/exporter.d.ts.map +1 -1
  109. package/dist/src/formats/graphml/exporter.js +34 -24
  110. package/dist/src/formats/graphml/exporter.js.map +1 -1
  111. package/dist/src/formats/graphml/importer.d.ts.map +1 -1
  112. package/dist/src/formats/graphml/importer.js +17 -13
  113. package/dist/src/formats/graphml/importer.js.map +1 -1
  114. package/dist/src/formats/graphml/tree.js +23 -14
  115. package/dist/src/formats/graphml/tree.js.map +1 -1
  116. package/dist/src/formats/graphml/yfiles.d.ts.map +1 -1
  117. package/dist/src/formats/graphml/yfiles.js +22 -10
  118. package/dist/src/formats/graphml/yfiles.js.map +1 -1
  119. package/dist/src/formats/json/dialect.d.ts.map +1 -1
  120. package/dist/src/formats/json/dialect.js +54 -20
  121. package/dist/src/formats/json/dialect.js.map +1 -1
  122. package/dist/src/formats/json/exporter.d.ts.map +1 -1
  123. package/dist/src/formats/json/exporter.js +38 -21
  124. package/dist/src/formats/json/exporter.js.map +1 -1
  125. package/dist/src/formats/json/importer.d.ts.map +1 -1
  126. package/dist/src/formats/json/importer.js +536 -343
  127. package/dist/src/formats/json/importer.js.map +1 -1
  128. package/dist/src/formats/json/obographs.js +29 -17
  129. package/dist/src/formats/json/obographs.js.map +1 -1
  130. package/dist/src/formats/neo4j/exporter.d.ts.map +1 -1
  131. package/dist/src/formats/neo4j/exporter.js +73 -43
  132. package/dist/src/formats/neo4j/exporter.js.map +1 -1
  133. package/dist/src/formats/neo4j/header.d.ts.map +1 -1
  134. package/dist/src/formats/neo4j/header.js +37 -16
  135. package/dist/src/formats/neo4j/header.js.map +1 -1
  136. package/dist/src/formats/neo4j/importer.d.ts.map +1 -1
  137. package/dist/src/formats/neo4j/importer.js +21 -11
  138. package/dist/src/formats/neo4j/importer.js.map +1 -1
  139. package/dist/src/formats/obo/exporter.d.ts.map +1 -1
  140. package/dist/src/formats/obo/exporter.js +18 -7
  141. package/dist/src/formats/obo/exporter.js.map +1 -1
  142. package/dist/src/formats/pajek/exporter.d.ts.map +1 -1
  143. package/dist/src/formats/pajek/exporter.js +142 -73
  144. package/dist/src/formats/pajek/exporter.js.map +1 -1
  145. package/dist/src/formats/pajek/importer.d.ts.map +1 -1
  146. package/dist/src/formats/pajek/importer.js +26 -15
  147. package/dist/src/formats/pajek/importer.js.map +1 -1
  148. package/dist/src/formats/pajek/syntax.d.ts.map +1 -1
  149. package/dist/src/formats/pajek/syntax.js +85 -58
  150. package/dist/src/formats/pajek/syntax.js.map +1 -1
  151. package/dist/src/formats/xgmml/emit.d.ts +7 -0
  152. package/dist/src/formats/xgmml/emit.d.ts.map +1 -1
  153. package/dist/src/formats/xgmml/emit.js +79 -40
  154. package/dist/src/formats/xgmml/emit.js.map +1 -1
  155. package/dist/src/formats/xgmml/exporter.d.ts.map +1 -1
  156. package/dist/src/formats/xgmml/exporter.js +38 -12
  157. package/dist/src/formats/xgmml/exporter.js.map +1 -1
  158. package/dist/src/formats/xgmml/importer.d.ts.map +1 -1
  159. package/dist/src/formats/xgmml/importer.js +38 -18
  160. package/dist/src/formats/xgmml/importer.js.map +1 -1
  161. package/dist/src/sniff.js +109 -57
  162. package/dist/src/sniff.js.map +1 -1
  163. package/dist/xgmml.js +2 -2
  164. package/package.json +2 -2
  165. package/src/children.ts +54 -27
  166. package/src/common/attributes.ts +118 -49
  167. package/src/common/input.ts +439 -197
  168. package/src/common/lists.ts +29 -15
  169. package/src/common/text.ts +24 -15
  170. package/src/common/zip.ts +47 -18
  171. package/src/formats/csv/exporter.ts +377 -175
  172. package/src/formats/csv/importer.ts +155 -84
  173. package/src/formats/csv/records.ts +90 -35
  174. package/src/formats/cys/tables.ts +11 -10
  175. package/src/formats/dot/tokenizer.ts +118 -68
  176. package/src/formats/gexf/exporter.ts +67 -36
  177. package/src/formats/gexf/importer.ts +173 -63
  178. package/src/formats/gml/exporter.ts +78 -82
  179. package/src/formats/gml/importer.ts +119 -68
  180. package/src/formats/gml/syntax.ts +18 -8
  181. package/src/formats/graphml/exporter.ts +44 -24
  182. package/src/formats/graphml/importer.ts +18 -11
  183. package/src/formats/graphml/tree.ts +22 -12
  184. package/src/formats/graphml/yfiles.ts +23 -10
  185. package/src/formats/json/dialect.ts +61 -22
  186. package/src/formats/json/exporter.ts +40 -21
  187. package/src/formats/json/importer.ts +701 -385
  188. package/src/formats/json/obographs.ts +35 -15
  189. package/src/formats/neo4j/exporter.ts +79 -41
  190. package/src/formats/neo4j/header.ts +50 -16
  191. package/src/formats/neo4j/importer.ts +21 -10
  192. package/src/formats/obo/exporter.ts +34 -17
  193. package/src/formats/pajek/exporter.ts +180 -117
  194. package/src/formats/pajek/importer.ts +27 -15
  195. package/src/formats/pajek/syntax.ts +99 -58
  196. package/src/formats/xgmml/emit.ts +88 -40
  197. package/src/formats/xgmml/exporter.ts +40 -12
  198. package/src/formats/xgmml/importer.ts +45 -23
  199. package/src/sniff.ts +115 -59
  200. package/dist/chunks/children-CL3Cy0ez.js.map +0 -1
  201. package/dist/chunks/export-B-aVCwyG.js.map +0 -1
  202. package/dist/chunks/exporter-BVXnV9-9.js.map +0 -1
  203. package/dist/chunks/exporter-Bcp-l4Bo.js.map +0 -1
  204. package/dist/chunks/importer-AjwOJrn2.js.map +0 -1
  205. package/dist/chunks/importer-BpuS3J8C.js.map +0 -1
  206. package/dist/chunks/importer-CPqnIY8m.js.map +0 -1
  207. package/dist/chunks/importer-DEp_fYjf.js.map +0 -1
  208. package/dist/chunks/importer-DcKB97kW.js.map +0 -1
  209. package/dist/chunks/records-r3SFHz11.js.map +0 -1
  210. package/dist/chunks/text-Bjfa1xYL.js.map +0 -1
@@ -2310,18 +2310,7 @@ function importAdjacency(ctx: ImportContext, root: JsonRecord): void {
2310
2310
  ctx.setHeader(directed);
2311
2311
  // adjacency_data writes the graph dict as a list of [key, value] pairs
2312
2312
  ctx.writeGraphDict(isPairList(root.graph) ? Object.fromEntries(root.graph) : root.graph, "graph");
2313
- for (const key of Object.keys(root)) {
2314
- if (key !== "nodes" && key !== "adjacency" && key !== "directed" && key !== "multigraph" && key !== "graph") {
2315
- report.warning(
2316
- "unsupported",
2317
- JSON_ISSUE.UNREAD_KEY,
2318
- `top-level key ${key} (${describe(root[key])}) is not read; dropped`,
2319
- {
2320
- element: key,
2321
- },
2322
- );
2323
- }
2324
- }
2313
+ reportUnreadAdjacencyKeys(ctx, root);
2325
2314
  const lists = adjacency ?? [];
2326
2315
  ctx.sink.reserve(
2327
2316
  nodes.length,
@@ -2329,91 +2318,179 @@ function importAdjacency(ctx: ImportContext, root: JsonRecord): void {
2329
2318
  );
2330
2319
  const idKey = ctx.json.nodeIdKey ?? "id";
2331
2320
  ctx.reportNodeIdFrom("adjacency", `the ${JSON.stringify(idKey)} key`);
2332
- const owners: (NodeId | null)[] = [];
2333
- for (let i = 0; i < nodes.length; i++) {
2334
- const element = `nodes[${i}]`;
2335
- const record = nodes[i];
2336
- let pushed: NodeId | null = null;
2337
- if (!isJsonObject(record)) {
2338
- ctx.badElement("node", element);
2339
- } else {
2340
- const id = ctx.coerceId(hasKey(record, idKey) ? record[idKey] : undefined, element);
2341
- if (id === null) {
2342
- ctx.countSkipped("node");
2343
- } else {
2344
- const index = ctx.pushNode(id, element);
2345
- if (index >= 0) {
2346
- pushed = id;
2347
- writeFlat(ctx.nodes, index, record, id, (key) => key !== idKey);
2348
- }
2349
- }
2350
- }
2351
- owners.push(pushed);
2352
- }
2321
+ const owners = nodes.map((record, i) => pushAdjacencyNode(ctx, record, `nodes[${i}]`, idKey));
2353
2322
  throwIfAborted(ctx.options.signal);
2354
- const kind = ctx.uniformKind();
2355
- const { weightFrom } = ctx.options;
2356
- // undirected: entries read once whose mirror is still to come, by pair and key, with their attributes
2357
- const pending = new Map<string, string[]>();
2358
- let disagreeing = 0;
2323
+ const reading: AdjacencyReading = {
2324
+ owners,
2325
+ nodeCount: nodes.length,
2326
+ directed,
2327
+ kind: ctx.uniformKind(),
2328
+ idKey,
2329
+ // undirected: entries read once whose mirror is still to come, by pair and key, with their attributes
2330
+ pending: new Map<string, string[]>(),
2331
+ disagreeing: 0,
2332
+ };
2359
2333
  for (let i = 0; i < lists.length; i++) {
2360
- const element = `adjacency[${i}]`;
2361
- const list = lists[i];
2362
- if (!Array.isArray(list)) {
2363
- report.error("validation-error", JSON_ISSUE.BAD_ELEMENT, `${element} is not an array`, { element });
2364
- continue;
2365
- }
2366
- const source = i < owners.length ? owners[i] : null;
2367
- if (source === null) {
2368
- const why = i < nodes.length ? `nodes[${i}] was skipped` : `there is no nodes[${i}]`;
2369
- report.error(
2370
- "missing-value",
2371
- JSON_ISSUE.BAD_INDEX,
2372
- `${element}: ${why}; its ${list.length} edge${plural(list.length)} ${agree(list.length, "is", "are")} skipped`,
2334
+ readAdjacencyList(ctx, reading, i, lists[i]);
2335
+ }
2336
+ reportAdjacencyShape(
2337
+ ctx,
2338
+ nodes.length,
2339
+ adjacency === null ? null : lists.length,
2340
+ reading.disagreeing,
2341
+ reading.pending,
2342
+ );
2343
+ ctx.setMeta({}, { declaredMultigraph: multigraph, ...ctx.weightOriginPatch() });
2344
+ }
2345
+
2346
+ /** The top-level keys of an adjacency_data document that are read. */
2347
+ const ADJACENCY_DOCUMENT_KEYS: ReadonlySet<string> = new Set(["nodes", "adjacency", "directed", "multigraph", "graph"]);
2348
+
2349
+ /** The state of an adjacency_data read, shared by its lists. */
2350
+ interface AdjacencyReading {
2351
+ /** The id of each node record, or null when it was skipped. */
2352
+ readonly owners: readonly (NodeId | null)[];
2353
+ /** The number of node records. */
2354
+ readonly nodeCount: number;
2355
+ readonly directed: boolean;
2356
+ readonly kind: EdgeKind;
2357
+ /** The entries' id key. */
2358
+ readonly idKey: string;
2359
+ /** Undirected entries awaiting their mirror (mirroredEntry()). */
2360
+ readonly pending: Map<string, string[]>;
2361
+ /** The mirrors whose attributes differ. */
2362
+ disagreeing: number;
2363
+ }
2364
+
2365
+ /**
2366
+ * Report the top-level keys of an adjacency_data document that are not read.
2367
+ * @param ctx - the context
2368
+ * @param root - the document
2369
+ */
2370
+ function reportUnreadAdjacencyKeys(ctx: ImportContext, root: JsonRecord): void {
2371
+ for (const key of Object.keys(root)) {
2372
+ if (!ADJACENCY_DOCUMENT_KEYS.has(key)) {
2373
+ ctx.report.warning(
2374
+ "unsupported",
2375
+ JSON_ISSUE.UNREAD_KEY,
2376
+ `top-level key ${key} (${describe(root[key])}) is not read; dropped`,
2373
2377
  {
2374
- element,
2378
+ element: key,
2375
2379
  },
2376
2380
  );
2377
- report.counts.skippedEdges += list.length;
2378
- continue;
2379
2381
  }
2380
- for (let j = 0; j < list.length; j++) {
2381
- const entry = `${element}[${j}]`;
2382
- const record = list[j];
2383
- if (!isJsonObject(record)) {
2384
- ctx.badElement("edge", entry);
2385
- continue;
2386
- }
2387
- if (!hasKey(record, idKey)) {
2388
- ctx.missingEndpoint(entry, idKey);
2389
- continue;
2382
+ }
2383
+ }
2384
+
2385
+ /**
2386
+ * Push one node record of an adjacency_data document.
2387
+ * @param ctx - the context
2388
+ * @param record - the node record
2389
+ * @param element - the element name
2390
+ * @param idKey - the key that holds the id
2391
+ * @returns the node's id, or null when it was skipped
2392
+ */
2393
+ function pushAdjacencyNode(ctx: ImportContext, record: unknown, element: string, idKey: string): NodeId | null {
2394
+ if (!isJsonObject(record)) {
2395
+ ctx.badElement("node", element);
2396
+ return null;
2397
+ }
2398
+ const id = ctx.coerceId(hasKey(record, idKey) ? record[idKey] : undefined, element);
2399
+ if (id === null) {
2400
+ ctx.countSkipped("node");
2401
+ return null;
2402
+ }
2403
+ const index = ctx.pushNode(id, element);
2404
+ if (index < 0) {
2405
+ return null;
2406
+ }
2407
+ writeFlat(ctx.nodes, index, record, id, (key) => key !== idKey);
2408
+ return id;
2409
+ }
2410
+
2411
+ /**
2412
+ * Read one adjacency list: the edges of nodes[i]. A list whose owner was skipped is skipped whole.
2413
+ * @param ctx - the context
2414
+ * @param reading - the read's state
2415
+ * @param i - the list's position
2416
+ * @param list - the list
2417
+ */
2418
+ function readAdjacencyList(ctx: ImportContext, reading: AdjacencyReading, i: number, list: unknown): void {
2419
+ const { report } = ctx;
2420
+ const element = `adjacency[${i}]`;
2421
+ if (!Array.isArray(list)) {
2422
+ report.error("validation-error", JSON_ISSUE.BAD_ELEMENT, `${element} is not an array`, { element });
2423
+ return;
2424
+ }
2425
+ const source = i < reading.owners.length ? reading.owners[i] : null;
2426
+ if (source === null) {
2427
+ const why = i < reading.nodeCount ? `nodes[${i}] was skipped` : `there is no nodes[${i}]`;
2428
+ report.error(
2429
+ "missing-value",
2430
+ JSON_ISSUE.BAD_INDEX,
2431
+ `${element}: ${why}; its ${list.length} edge${plural(list.length)} ${agree(list.length, "is", "are")} skipped`,
2432
+ {
2433
+ element,
2434
+ },
2435
+ );
2436
+ report.counts.skippedEdges += list.length;
2437
+ return;
2438
+ }
2439
+ for (let j = 0; j < list.length; j++) {
2440
+ readAdjacencyEntry(ctx, reading, source, list[j], element, `${element}[${j}]`);
2441
+ }
2442
+ }
2443
+
2444
+ /**
2445
+ * Read one adjacency entry as an edge from the list's owner, unless it is the mirror of an
2446
+ * undirected edge already read.
2447
+ * @param ctx - the context
2448
+ * @param reading - the read's state
2449
+ * @param source - the list's owner
2450
+ * @param record - the entry
2451
+ * @param element - the list's element name (attribute issues name it)
2452
+ * @param entry - the entry's element name
2453
+ */
2454
+ function readAdjacencyEntry(
2455
+ ctx: ImportContext,
2456
+ reading: AdjacencyReading,
2457
+ source: NodeId,
2458
+ record: unknown,
2459
+ element: string,
2460
+ entry: string,
2461
+ ): void {
2462
+ const { idKey } = reading;
2463
+ if (!isJsonObject(record)) {
2464
+ ctx.badElement("edge", entry);
2465
+ return;
2466
+ }
2467
+ if (!hasKey(record, idKey)) {
2468
+ ctx.missingEndpoint(entry, idKey);
2469
+ return;
2470
+ }
2471
+ try {
2472
+ const target = ctx.coerceId(record[idKey], `${entry}.${idKey}`);
2473
+ if (target === null) {
2474
+ ctx.countSkipped("edge");
2475
+ return;
2476
+ }
2477
+ if (!reading.directed) {
2478
+ const mirror = mirroredEntry(reading.pending, source, target, record, idKey);
2479
+ reading.disagreeing += mirror === "disagrees" ? 1 : 0;
2480
+ if (mirror !== "new") {
2481
+ return;
2390
2482
  }
2391
- try {
2392
- const target = ctx.coerceId(record[idKey], `${entry}.${idKey}`);
2393
- if (target === null) {
2394
- ctx.countSkipped("edge");
2395
- continue;
2396
- }
2397
- if (!directed) {
2398
- const mirror = mirroredEntry(pending, source, target, record, idKey);
2399
- disagreeing += mirror === "disagrees" ? 1 : 0;
2400
- if (mirror !== "new") {
2401
- continue;
2402
- }
2403
- }
2404
- const edge = ctx.pushEdge(source, target, kind, ctx.weightOf(record), entry);
2405
- for (const key of Object.keys(record)) {
2406
- if (key !== idKey && key !== weightFrom) {
2407
- ctx.edges.write(edge, key, record[key], SUFFIX.data, element);
2408
- }
2409
- }
2410
- } catch (err) {
2411
- ctx.skip(err, "edge", entry);
2483
+ }
2484
+ const edge = ctx.pushEdge(source, target, reading.kind, ctx.weightOf(record), entry);
2485
+ const { weightFrom } = ctx.options;
2486
+ for (const key of Object.keys(record)) {
2487
+ if (key !== idKey && key !== weightFrom) {
2488
+ ctx.edges.write(edge, key, record[key], SUFFIX.data, element);
2412
2489
  }
2413
2490
  }
2491
+ } catch (err) {
2492
+ ctx.skip(err, "edge", entry);
2414
2493
  }
2415
- reportAdjacencyShape(ctx, nodes.length, adjacency === null ? null : lists.length, disagreeing, pending);
2416
- ctx.setMeta({}, { declaredMultigraph: multigraph, ...ctx.weightOriginPatch() });
2417
2494
  }
2418
2495
 
2419
2496
  /**
@@ -2603,29 +2680,8 @@ function importGraphology(ctx: ImportContext, root: JsonRecord): void {
2603
2680
  element: "options",
2604
2681
  });
2605
2682
  }
2606
- let type: "directed" | "undirected" | "mixed";
2607
- if (options.type === "directed" || options.type === "undirected" || options.type === "mixed") {
2608
- ({ type } = options);
2609
- } else {
2610
- if (options.type !== undefined && options.type !== null) {
2611
- report.warning(
2612
- "validation-error",
2613
- JSON_ISSUE.BAD_FLAG,
2614
- `options.type ${describe(options.type)} is not directed, undirected or mixed; mixed assumed`,
2615
- { element: "options.type" },
2616
- );
2617
- }
2618
- type = "mixed";
2619
- }
2620
- let directed: boolean;
2621
- if (type === "mixed") {
2622
- const objects = edges.filter((edge) => isJsonObject(edge)) as JsonRecord[];
2623
- const undirectedEdges = objects.filter((edge) => edge.undirected === true).length;
2624
- directed = objects.length === 0 ? ctx.defaultDirected("graphology") : undirectedEdges < objects.length;
2625
- } else {
2626
- directed = type === "directed";
2627
- }
2628
- ctx.setHeader(directed);
2683
+ const type = graphologyType(ctx, options);
2684
+ ctx.setHeader(graphologyDirected(ctx, type, edges));
2629
2685
  const multi = hasKey(options, "multi") ? flagOf(options.multi, "options.multi", false, report) : null;
2630
2686
  const allowSelfLoops = hasKey(options, "allowSelfLoops")
2631
2687
  ? flagOf(options.allowSelfLoops, "options.allowSelfLoops", true, report)
@@ -2635,80 +2691,184 @@ function importGraphology(ctx: ImportContext, root: JsonRecord): void {
2635
2691
  ctx.sink.reserve(nodes.length, edges.length);
2636
2692
 
2637
2693
  for (let i = 0; i < nodes.length; i++) {
2638
- const element = `nodes[${i}]`;
2639
- const record = nodes[i];
2640
- if (!isJsonObject(record)) {
2641
- ctx.badElement("node", element);
2642
- continue;
2643
- }
2644
- const id = ctx.coerceId(hasKey(record, "key") ? record.key : undefined, element);
2645
- if (id === null) {
2646
- ctx.countSkipped("node");
2647
- continue;
2648
- }
2649
- pushNestedNode(ctx, id, record, "attributes", GRAPHOLOGY_NODE_KEYS, element);
2694
+ pushGraphologyNode(ctx, nodes[i], `nodes[${i}]`);
2650
2695
  }
2651
2696
  throwIfAborted(ctx.options.signal);
2652
2697
 
2653
- const ids = ctx.declareEdgeIds(edges, (edge) => edge.key, "key", true);
2654
- // what the edges do against the declared options: graphology itself refuses such a document
2655
- const violations = { selfLoops: 0, parallels: 0, flags: 0 };
2656
- const pairs = multi === false ? new Set<string>() : null;
2698
+ const reading: GraphologyReading = {
2699
+ ids: ctx.declareEdgeIds(edges, (edge) => edge.key, "key", true),
2700
+ type,
2701
+ allowSelfLoops,
2702
+ // what the edges do against the declared options: graphology itself refuses such a document
2703
+ violations: { selfLoops: 0, parallels: 0, flags: 0 },
2704
+ pairs: multi === false ? new Set<string>() : null,
2705
+ };
2657
2706
  for (let i = 0; i < edges.length; i++) {
2658
- const element = `edges[${i}]`;
2659
- const record = edges[i];
2660
- if (!isJsonObject(record)) {
2661
- ctx.badElement("edge", element);
2662
- continue;
2707
+ readGraphologyEdge(ctx, reading, edges[i], `edges[${i}]`);
2708
+ }
2709
+ reportGraphologyViolations(ctx, reading.violations, type);
2710
+ ctx.setMeta({ dialect: "graphology", allowSelfLoops }, { declaredMultigraph: multi, ...ctx.weightOriginPatch() });
2711
+ }
2712
+
2713
+ /** The graph types a graphology document declares in `options.type`. */
2714
+ type GraphologyType = "directed" | "undirected" | "mixed";
2715
+
2716
+ /** The state of a graphology read, shared by its edges. */
2717
+ interface GraphologyReading {
2718
+ /** The edge id column, from declareEdgeIds(). */
2719
+ readonly ids: EdgeIdColumn | null;
2720
+ readonly type: GraphologyType;
2721
+ /** options.allowSelfLoops, or undefined when absent. */
2722
+ readonly allowSelfLoops: boolean | undefined;
2723
+ /** The edges that contradict the declared options (reportGraphologyViolations()). */
2724
+ readonly violations: { selfLoops: number; parallels: number; flags: number };
2725
+ /** The pairs seen so far under multi false, or null. */
2726
+ readonly pairs: Set<string> | null;
2727
+ }
2728
+
2729
+ /**
2730
+ * A graphology document's `options.type`; anything else is reported and read as "mixed".
2731
+ * @param ctx - the context
2732
+ * @param options - the options object
2733
+ * @returns the type
2734
+ */
2735
+ function graphologyType(ctx: ImportContext, options: JsonRecord): GraphologyType {
2736
+ if (options.type === "directed" || options.type === "undirected" || options.type === "mixed") {
2737
+ return options.type;
2738
+ }
2739
+ if (options.type !== undefined && options.type !== null) {
2740
+ ctx.report.warning(
2741
+ "validation-error",
2742
+ JSON_ISSUE.BAD_FLAG,
2743
+ `options.type ${describe(options.type)} is not directed, undirected or mixed; mixed assumed`,
2744
+ { element: "options.type" },
2745
+ );
2746
+ }
2747
+ return "mixed";
2748
+ }
2749
+
2750
+ /**
2751
+ * The header direction of a graphology document: its type, or for "mixed" directed unless every
2752
+ * edge object says undirected (the dialect default when there is none).
2753
+ * @param ctx - the context
2754
+ * @param type - options.type
2755
+ * @param edges - the edge records
2756
+ * @returns whether the header is directed
2757
+ */
2758
+ function graphologyDirected(ctx: ImportContext, type: GraphologyType, edges: readonly unknown[]): boolean {
2759
+ if (type !== "mixed") {
2760
+ return type === "directed";
2761
+ }
2762
+ const objects = edges.filter((edge) => isJsonObject(edge)) as JsonRecord[];
2763
+ const undirectedEdges = objects.filter((edge) => edge.undirected === true).length;
2764
+ return objects.length === 0 ? ctx.defaultDirected("graphology") : undirectedEdges < objects.length;
2765
+ }
2766
+
2767
+ /**
2768
+ * Push one graphology node record: `key` the id, `attributes` the columns.
2769
+ * @param ctx - the context
2770
+ * @param record - the node record
2771
+ * @param element - the element name
2772
+ */
2773
+ function pushGraphologyNode(ctx: ImportContext, record: unknown, element: string): void {
2774
+ if (!isJsonObject(record)) {
2775
+ ctx.badElement("node", element);
2776
+ return;
2777
+ }
2778
+ const id = ctx.coerceId(hasKey(record, "key") ? record.key : undefined, element);
2779
+ if (id === null) {
2780
+ ctx.countSkipped("node");
2781
+ return;
2782
+ }
2783
+ pushNestedNode(ctx, id, record, "attributes", GRAPHOLOGY_NODE_KEYS, element);
2784
+ }
2785
+
2786
+ /**
2787
+ * Read one graphology edge record, counting what it does against the declared options.
2788
+ * @param ctx - the context
2789
+ * @param reading - the read's state
2790
+ * @param record - the edge record
2791
+ * @param element - the element name
2792
+ */
2793
+ function readGraphologyEdge(ctx: ImportContext, reading: GraphologyReading, record: unknown, element: string): void {
2794
+ if (!isJsonObject(record)) {
2795
+ ctx.badElement("edge", element);
2796
+ return;
2797
+ }
2798
+ if (!present(record, "source") || !present(record, "target")) {
2799
+ ctx.missingEndpoint(element, present(record, "source") ? "target" : "source");
2800
+ return;
2801
+ }
2802
+ try {
2803
+ const u = ctx.coerceId(record.source, `${element}.source`);
2804
+ const v = ctx.coerceId(record.target, `${element}.target`);
2805
+ if (u === null || v === null) {
2806
+ ctx.countSkipped("edge");
2807
+ return;
2663
2808
  }
2664
- if (!present(record, "source") || !present(record, "target")) {
2665
- ctx.missingEndpoint(element, present(record, "source") ? "target" : "source");
2666
- continue;
2809
+ const kind = graphologyEdgeKind(ctx, reading, record, element);
2810
+ const attributes = graphologyEdgeAttributes(ctx, record, element);
2811
+ const idValue = ctx.edgeIdValue(reading.ids, record.key);
2812
+ const edge = ctx.pushEdge(u, v, kind, ctx.weightOf(attributes), element);
2813
+ ctx.setEdgeId(reading.ids, edge, idValue);
2814
+ ctx.writeNested(ctx.edges, edge, record, attributes, GRAPHOLOGY_EDGE_KEYS, ctx.options.weightFrom, element);
2815
+ if (reading.allowSelfLoops === false && u === v) {
2816
+ reading.violations.selfLoops++;
2667
2817
  }
2668
- try {
2669
- const u = ctx.coerceId(record.source, `${element}.source`);
2670
- const v = ctx.coerceId(record.target, `${element}.target`);
2671
- if (u === null || v === null) {
2672
- ctx.countSkipped("edge");
2673
- continue;
2674
- }
2675
- let kind: EdgeKind;
2676
- if (type === "mixed") {
2677
- kind = flagOf(record.undirected, `${element}.undirected`, false, report) ? "undirected" : "directed";
2678
- } else {
2679
- kind = type;
2680
- if (typeof record.undirected === "boolean" && record.undirected !== (type === "undirected")) {
2681
- violations.flags++;
2682
- }
2683
- }
2684
- const { attributes: rawAttributes } = record;
2685
- if (rawAttributes !== undefined && rawAttributes !== null && !isJsonObject(rawAttributes)) {
2686
- report.error(
2687
- "validation-error",
2688
- JSON_ISSUE.BAD_VALUE,
2689
- `${element}: attributes must be an object, found ${describe(rawAttributes)}`,
2690
- { element },
2691
- );
2692
- }
2693
- const attributes = isJsonObject(rawAttributes) ? rawAttributes : {};
2694
- const idValue = ctx.edgeIdValue(ids, record.key);
2695
- const edge = ctx.pushEdge(u, v, kind, ctx.weightOf(attributes), element);
2696
- ctx.setEdgeId(ids, edge, idValue);
2697
- ctx.writeNested(ctx.edges, edge, record, attributes, GRAPHOLOGY_EDGE_KEYS, ctx.options.weightFrom, element);
2698
- if (allowSelfLoops === false && u === v) {
2699
- violations.selfLoops++;
2700
- }
2701
- if (pairs !== null) {
2702
- const pair = `${kind} ${pairKey(u, v, kind === "directed")}`;
2703
- violations.parallels += pairs.has(pair) ? 1 : 0;
2704
- pairs.add(pair);
2705
- }
2706
- } catch (err) {
2707
- ctx.skip(err, "edge", element);
2818
+ if (reading.pairs !== null) {
2819
+ const pair = `${kind} ${pairKey(u, v, kind === "directed")}`;
2820
+ reading.violations.parallels += reading.pairs.has(pair) ? 1 : 0;
2821
+ reading.pairs.add(pair);
2708
2822
  }
2823
+ } catch (err) {
2824
+ ctx.skip(err, "edge", element);
2709
2825
  }
2710
- reportGraphologyViolations(ctx, violations, type);
2711
- ctx.setMeta({ dialect: "graphology", allowSelfLoops }, { declaredMultigraph: multi, ...ctx.weightOriginPatch() });
2826
+ }
2827
+
2828
+ /**
2829
+ * The direction of one graphology edge: its `undirected` flag under type "mixed", else the type
2830
+ * (a contrary flag is counted as a violation).
2831
+ * @param ctx - the context
2832
+ * @param reading - the read's state
2833
+ * @param record - the edge record
2834
+ * @param element - the element name
2835
+ * @returns the edge's kind
2836
+ */
2837
+ function graphologyEdgeKind(
2838
+ ctx: ImportContext,
2839
+ reading: GraphologyReading,
2840
+ record: JsonRecord,
2841
+ element: string,
2842
+ ): EdgeKind {
2843
+ const { type } = reading;
2844
+ if (type === "mixed") {
2845
+ return flagOf(record.undirected, `${element}.undirected`, false, ctx.report) ? "undirected" : "directed";
2846
+ }
2847
+ if (typeof record.undirected === "boolean" && record.undirected !== (type === "undirected")) {
2848
+ reading.violations.flags++;
2849
+ }
2850
+ return type;
2851
+ }
2852
+
2853
+ /**
2854
+ * The `attributes` object of a graphology edge; any other non-null value is reported and read as
2855
+ * no attributes.
2856
+ * @param ctx - the context
2857
+ * @param record - the edge record
2858
+ * @param element - the element name
2859
+ * @returns the attributes
2860
+ */
2861
+ function graphologyEdgeAttributes(ctx: ImportContext, record: JsonRecord, element: string): JsonRecord {
2862
+ const { attributes } = record;
2863
+ if (attributes !== undefined && attributes !== null && !isJsonObject(attributes)) {
2864
+ ctx.report.error(
2865
+ "validation-error",
2866
+ JSON_ISSUE.BAD_VALUE,
2867
+ `${element}: attributes must be an object, found ${describe(attributes)}`,
2868
+ { element },
2869
+ );
2870
+ }
2871
+ return isJsonObject(attributes) ? attributes : {};
2712
2872
  }
2713
2873
 
2714
2874
  /**
@@ -2812,24 +2972,7 @@ function importJgf(ctx: ImportContext, root: JsonRecord): void {
2812
2972
  }
2813
2973
  const nodeRecords: readonly unknown[] = Array.isArray(nodesRaw) ? nodesRaw : Object.values(nodesRaw ?? {});
2814
2974
 
2815
- let directed: boolean;
2816
- if (typeof graph.directed === "boolean") {
2817
- ({ directed } = graph);
2818
- } else if (ctx.explicitDefaultDirected) {
2819
- directed = ctx.options.defaultDirected;
2820
- } else {
2821
- if (graph.directed !== undefined && graph.directed !== null) {
2822
- report.warning(
2823
- "validation-error",
2824
- JSON_ISSUE.BAD_FLAG,
2825
- `graph.directed is ${describe(graph.directed)}, not a boolean`,
2826
- { element: "graph.directed" },
2827
- );
2828
- }
2829
- // the spec default is true; a file whose every edge says directed: false is read as undirected
2830
- const objects = edges.filter((edge) => isJsonObject(edge)) as JsonRecord[];
2831
- directed = objects.length === 0 || !objects.every((edge) => edge.directed === false);
2832
- }
2975
+ const directed = jgfDirected(ctx, graph, edges);
2833
2976
  ctx.setHeader(directed);
2834
2977
  ctx.writeGraphDict(graph.metadata, "graph.metadata");
2835
2978
  const shape: JsonShapeMeta = {
@@ -2842,50 +2985,125 @@ function importJgf(ctx: ImportContext, root: JsonRecord): void {
2842
2985
  ctx.reportNodeIdFrom("jgf", "the node keys");
2843
2986
  ctx.sink.reserve(nodeRecords.length, edges.length);
2844
2987
  const labelColumn = ctx.nodes.declareIf(
2845
- { name: "label", dtype: "string", role: "label", nullable: true },
2846
- anyHas(nodeRecords, "label"),
2847
- );
2848
- if (isJsonObject(nodesRaw)) {
2849
- for (const key of Object.keys(nodesRaw)) {
2850
- const element = `nodes[${JSON.stringify(key)}]`;
2851
- const record = nodesRaw[key];
2852
- if (record !== null && !isJsonObject(record)) {
2853
- ctx.badElement("node", element);
2854
- continue;
2855
- }
2856
- const id = ctx.coerceId(key, element);
2857
- if (id === null) {
2858
- ctx.countSkipped("node");
2859
- continue;
2860
- }
2861
- if (record !== null && hasKey(record, "id") && record.id !== key) {
2862
- report.warning(
2863
- "validation-error",
2864
- JSON_ISSUE.INCONSISTENT,
2865
- `${element}: the inner id ${describe(record.id)} differs from the key; the key is the id, the inner one is kept as id${SUFFIX.element}`,
2866
- { element },
2867
- );
2868
- }
2869
- pushJgfNode(ctx, id, record ?? {}, labelColumn, element);
2988
+ { name: "label", dtype: "string", role: "label", nullable: true },
2989
+ anyHas(nodeRecords, "label"),
2990
+ );
2991
+ if (isJsonObject(nodesRaw)) {
2992
+ for (const key of Object.keys(nodesRaw)) {
2993
+ pushJgfKeyedNode(ctx, key, nodesRaw[key], labelColumn);
2870
2994
  }
2871
2995
  } else {
2872
2996
  for (let i = 0; i < nodeRecords.length; i++) {
2873
- const element = `nodes[${i}]`;
2874
- const record = nodeRecords[i];
2875
- if (!isJsonObject(record)) {
2876
- ctx.badElement("node", element);
2877
- continue;
2878
- }
2879
- const id = ctx.coerceId(hasKey(record, "id") ? record.id : undefined, element);
2880
- if (id === null) {
2881
- ctx.countSkipped("node");
2882
- continue;
2883
- }
2884
- pushJgfNode(ctx, id, record, labelColumn, element);
2997
+ pushJgfListedNode(ctx, nodeRecords[i], labelColumn, `nodes[${i}]`);
2885
2998
  }
2886
2999
  }
2887
3000
  throwIfAborted(ctx.options.signal);
2888
3001
 
3002
+ const writeJgfEdge = jgfEdgeWriter(ctx, edges, hyperedges);
3003
+ for (let i = 0; i < edges.length; i++) {
3004
+ readJgfEdge(ctx, edges[i], `edges[${i}]`, directed, writeJgfEdge);
3005
+ }
3006
+
3007
+ importHyperedges(ctx, hyperedges, directed, writeJgfEdge);
3008
+ ctx.setMeta(shape, { name: label, ...ctx.weightOriginPatch() });
3009
+ }
3010
+
3011
+ /** Push one JGF edge (or one edge of a hyperedge) with its id, relation, label and metadata. */
3012
+ type JgfEdgeWriter = (
3013
+ record: JsonRecord,
3014
+ u: NodeId,
3015
+ v: NodeId,
3016
+ kind: EdgeKind,
3017
+ element: string,
3018
+ structural?: ReadonlySet<string>,
3019
+ ) => void;
3020
+
3021
+ /**
3022
+ * The header direction of a JGF graph: `directed`, else the defaultDirected option when given,
3023
+ * else the spec default true unless every edge says directed: false.
3024
+ * @param ctx - the context
3025
+ * @param graph - the graph object
3026
+ * @param edges - the edge records
3027
+ * @returns whether the header is directed
3028
+ */
3029
+ function jgfDirected(ctx: ImportContext, graph: JsonRecord, edges: readonly unknown[]): boolean {
3030
+ if (typeof graph.directed === "boolean") {
3031
+ return graph.directed;
3032
+ }
3033
+ if (ctx.explicitDefaultDirected) {
3034
+ return ctx.options.defaultDirected;
3035
+ }
3036
+ if (graph.directed !== undefined && graph.directed !== null) {
3037
+ ctx.report.warning(
3038
+ "validation-error",
3039
+ JSON_ISSUE.BAD_FLAG,
3040
+ `graph.directed is ${describe(graph.directed)}, not a boolean`,
3041
+ { element: "graph.directed" },
3042
+ );
3043
+ }
3044
+ // the spec default is true; a file whose every edge says directed: false is read as undirected
3045
+ const objects = edges.filter((edge) => isJsonObject(edge)) as JsonRecord[];
3046
+ return objects.length === 0 || !objects.every((edge) => edge.directed === false);
3047
+ }
3048
+
3049
+ /**
3050
+ * Push one node of a JGF v2 `nodes` object, keyed by its id (a null record is a node with nothing).
3051
+ * @param ctx - the context
3052
+ * @param key - the node's key
3053
+ * @param record - the node record
3054
+ * @param labelColumn - the label column
3055
+ */
3056
+ function pushJgfKeyedNode(ctx: ImportContext, key: string, record: unknown, labelColumn: ColumnHandle): void {
3057
+ const element = `nodes[${JSON.stringify(key)}]`;
3058
+ if (record !== null && !isJsonObject(record)) {
3059
+ ctx.badElement("node", element);
3060
+ return;
3061
+ }
3062
+ const id = ctx.coerceId(key, element);
3063
+ if (id === null) {
3064
+ ctx.countSkipped("node");
3065
+ return;
3066
+ }
3067
+ if (record !== null && hasKey(record, "id") && record.id !== key) {
3068
+ ctx.report.warning(
3069
+ "validation-error",
3070
+ JSON_ISSUE.INCONSISTENT,
3071
+ `${element}: the inner id ${describe(record.id)} differs from the key; the key is the id, the inner one is kept as id${SUFFIX.element}`,
3072
+ { element },
3073
+ );
3074
+ }
3075
+ pushJgfNode(ctx, id, record ?? {}, labelColumn, element);
3076
+ }
3077
+
3078
+ /**
3079
+ * Push one node of a JGF v1 `nodes` array, with its `id`.
3080
+ * @param ctx - the context
3081
+ * @param record - the node record
3082
+ * @param labelColumn - the label column
3083
+ * @param element - the element name
3084
+ */
3085
+ function pushJgfListedNode(ctx: ImportContext, record: unknown, labelColumn: ColumnHandle, element: string): void {
3086
+ if (!isJsonObject(record)) {
3087
+ ctx.badElement("node", element);
3088
+ return;
3089
+ }
3090
+ const id = ctx.coerceId(hasKey(record, "id") ? record.id : undefined, element);
3091
+ if (id === null) {
3092
+ ctx.countSkipped("node");
3093
+ return;
3094
+ }
3095
+ pushJgfNode(ctx, id, record, labelColumn, element);
3096
+ }
3097
+
3098
+ /**
3099
+ * The writer of JGF edges: declares the edge id, relation and label columns the edges and
3100
+ * hyperedges need, and returns the function that pushes one edge into them.
3101
+ * @param ctx - the context
3102
+ * @param edges - the edge records
3103
+ * @param hyperedges - the hyperedge records
3104
+ * @returns the edge writer
3105
+ */
3106
+ function jgfEdgeWriter(ctx: ImportContext, edges: readonly unknown[], hyperedges: readonly unknown[]): JgfEdgeWriter {
2889
3107
  const ids = ctx.declareEdgeIds([...edges, ...hyperedges], (edge) => edge.id, "id", false);
2890
3108
  const relationColumn = ctx.edges.declareIf(
2891
3109
  { name: "relation", dtype: "string", role: "kind", nullable: true },
@@ -2895,14 +3113,7 @@ function importJgf(ctx: ImportContext, root: JsonRecord): void {
2895
3113
  { name: "label", dtype: "string", role: "label", nullable: true },
2896
3114
  anyHas(edges, "label") || anyHas(hyperedges, "label"),
2897
3115
  );
2898
- const writeJgfEdge = (
2899
- record: JsonRecord,
2900
- u: NodeId,
2901
- v: NodeId,
2902
- kind: EdgeKind,
2903
- element: string,
2904
- structural: ReadonlySet<string> = JGF_EDGE_KEYS,
2905
- ): void => {
3116
+ return (record, u, v, kind, element, structural = JGF_EDGE_KEYS): void => {
2906
3117
  const { metadata } = record;
2907
3118
  const dict = isJsonObject(metadata) ? metadata : {};
2908
3119
  const idValue = ctx.edgeIdValue(ids, record.id);
@@ -2911,40 +3122,49 @@ function importJgf(ctx: ImportContext, root: JsonRecord): void {
2911
3122
  ctx.writeStringField(ctx.edges, relationColumn, edge, record.relation, "relation", element);
2912
3123
  ctx.writeStringField(ctx.edges, edgeLabelColumn, edge, record.label, "label", element);
2913
3124
  if (metadata !== undefined && metadata !== null && !isJsonObject(metadata)) {
2914
- report.error("validation-error", JSON_ISSUE.BAD_VALUE, `${element}: metadata must be an object`, {
3125
+ ctx.report.error("validation-error", JSON_ISSUE.BAD_VALUE, `${element}: metadata must be an object`, {
2915
3126
  element,
2916
3127
  });
2917
3128
  }
2918
3129
  ctx.writeNested(ctx.edges, edge, record, dict, structural, ctx.options.weightFrom, element);
2919
3130
  };
3131
+ }
2920
3132
 
2921
- for (let i = 0; i < edges.length; i++) {
2922
- const element = `edges[${i}]`;
2923
- const record = edges[i];
2924
- if (!isJsonObject(record)) {
2925
- ctx.badElement("edge", element);
2926
- continue;
2927
- }
2928
- if (!present(record, "source") || !present(record, "target")) {
2929
- ctx.missingEndpoint(element, present(record, "source") ? "target" : "source");
2930
- continue;
2931
- }
2932
- try {
2933
- const u = ctx.coerceId(record.source, `${element}.source`);
2934
- const v = ctx.coerceId(record.target, `${element}.target`);
2935
- if (u === null || v === null) {
2936
- ctx.countSkipped("edge");
2937
- continue;
2938
- }
2939
- const edgeDirected = flagOf(record.directed, `${element}.directed`, directed, report);
2940
- writeJgfEdge(record, u, v, edgeDirected ? "directed" : "undirected", element);
2941
- } catch (err) {
2942
- ctx.skip(err, "edge", element);
3133
+ /**
3134
+ * Read one JGF edge record: its ends and its own `directed` flag (the header's by default).
3135
+ * @param ctx - the context
3136
+ * @param record - the edge record
3137
+ * @param element - the element name
3138
+ * @param directed - the header direction
3139
+ * @param writeJgfEdge - the edge writer
3140
+ */
3141
+ function readJgfEdge(
3142
+ ctx: ImportContext,
3143
+ record: unknown,
3144
+ element: string,
3145
+ directed: boolean,
3146
+ writeJgfEdge: JgfEdgeWriter,
3147
+ ): void {
3148
+ if (!isJsonObject(record)) {
3149
+ ctx.badElement("edge", element);
3150
+ return;
3151
+ }
3152
+ if (!present(record, "source") || !present(record, "target")) {
3153
+ ctx.missingEndpoint(element, present(record, "source") ? "target" : "source");
3154
+ return;
3155
+ }
3156
+ try {
3157
+ const u = ctx.coerceId(record.source, `${element}.source`);
3158
+ const v = ctx.coerceId(record.target, `${element}.target`);
3159
+ if (u === null || v === null) {
3160
+ ctx.countSkipped("edge");
3161
+ return;
2943
3162
  }
3163
+ const edgeDirected = flagOf(record.directed, `${element}.directed`, directed, ctx.report);
3164
+ writeJgfEdge(record, u, v, edgeDirected ? "directed" : "undirected", element);
3165
+ } catch (err) {
3166
+ ctx.skip(err, "edge", element);
2944
3167
  }
2945
-
2946
- importHyperedges(ctx, hyperedges, directed, writeJgfEdge);
2947
- ctx.setMeta(shape, { name: label, ...ctx.weightOriginPatch() });
2948
3168
  }
2949
3169
 
2950
3170
  /**
@@ -3339,26 +3559,7 @@ function distinctMembers(ctx: ImportContext, members: readonly NodeId[], element
3339
3559
  * @param root - the document
3340
3560
  */
3341
3561
  function importCytoscape(ctx: ImportContext, root: unknown): void {
3342
- const { report } = ctx;
3343
- let elements: unknown;
3344
- let extra: Record<string, unknown> | undefined;
3345
- if (Array.isArray(root)) {
3346
- elements = root;
3347
- } else if (isJsonObject(root)) {
3348
- ({ elements } = root);
3349
- ctx.writeGraphDict(root.data, "data");
3350
- const rest: Record<string, unknown> = {};
3351
- for (const key of Object.keys(root)) {
3352
- if (key !== "elements" && key !== "data") {
3353
- rest[key] = root[key];
3354
- }
3355
- }
3356
- if (Object.keys(rest).length > 0) {
3357
- extra = rest;
3358
- }
3359
- } else {
3360
- report.fail(JSON_ISSUE.SHAPE, `a Cytoscape document must be an object or an array, found ${describe(root)}`);
3361
- }
3562
+ const { elements, extra } = cytoscapeRoot(ctx, root);
3362
3563
  const { nodes, edges } = cytoscapeSections(ctx, elements);
3363
3564
  ctx.setHeader(ctx.defaultDirected("cytoscape"));
3364
3565
  ctx.reportNodeIdFrom("cytoscape", "data.id");
@@ -3366,24 +3567,28 @@ function importCytoscape(ctx: ImportContext, root: unknown): void {
3366
3567
 
3367
3568
  const dataHas = (items: readonly unknown[], key: string): boolean =>
3368
3569
  items.some((item) => isJsonObject(item) && isJsonObject(item.data) && item.data[key] !== undefined);
3369
- const positionColumn = ctx.nodes.declareIf(
3370
- {
3371
- name: POSITION_COLUMN,
3372
- dtype: "f64",
3373
- components: 3,
3374
- role: "position",
3375
- mutable: true,
3376
- nullable: true,
3377
- // 3D layouts written by Cytoscape-compatible tools carry a z
3378
- extra: { sourceDims: nodes.some(hasPositionZ) ? 3 : 2, units: "file" },
3379
- origin: { format: "json", namespace: "cytoscape" },
3380
- },
3381
- anyHas(nodes, "position"),
3382
- );
3383
- const classesColumn = ctx.nodes.declareIf(
3384
- { name: CLASSES_COLUMN, dtype: "list", itemDtype: "string", role: "classes", nullable: true },
3385
- anyHas(nodes, "classes"),
3386
- );
3570
+ const reading: CytoscapeNodeReading = {
3571
+ positionColumn: ctx.nodes.declareIf(
3572
+ {
3573
+ name: POSITION_COLUMN,
3574
+ dtype: "f64",
3575
+ components: 3,
3576
+ role: "position",
3577
+ mutable: true,
3578
+ nullable: true,
3579
+ // 3D layouts written by Cytoscape-compatible tools carry a z
3580
+ extra: { sourceDims: nodes.some(hasPositionZ) ? 3 : 2, units: "file" },
3581
+ origin: { format: "json", namespace: "cytoscape" },
3582
+ },
3583
+ anyHas(nodes, "position"),
3584
+ ),
3585
+ classesColumn: ctx.nodes.declareIf(
3586
+ { name: CLASSES_COLUMN, dtype: "list", itemDtype: "string", role: "classes", nullable: true },
3587
+ anyHas(nodes, "classes"),
3588
+ ),
3589
+ parents: [],
3590
+ point: [0, 0, 0],
3591
+ };
3387
3592
  const parentColumn = ctx.nodes.declareIf(
3388
3593
  { name: PARENT_COLUMN, dtype: "u32", role: "parent", refersTo: "node", nullable: true },
3389
3594
  dataHas(nodes, "parent"),
@@ -3392,80 +3597,165 @@ function importCytoscape(ctx: ImportContext, root: unknown): void {
3392
3597
  { name: CLASSES_COLUMN, dtype: "list", itemDtype: "string", role: "classes", nullable: true },
3393
3598
  anyHas(edges, "classes"),
3394
3599
  );
3395
- const parents: { index: number; parent: NodeId; element: string }[] = [];
3396
- const point: [number, number, number] = [0, 0, 0];
3397
3600
 
3398
3601
  for (let i = 0; i < nodes.length; i++) {
3399
- const element = `nodes[${i}]`;
3400
- const record = nodes[i];
3401
- if (!isJsonObject(record) || !isJsonObject(record.data)) {
3402
- ctx.badElement("node", element, "an element with a data object");
3403
- continue;
3602
+ readCytoscapeNode(ctx, reading, nodes[i], `nodes[${i}]`);
3603
+ }
3604
+ resolveCytoscapeParents(ctx, reading.parents, parentColumn);
3605
+ throwIfAborted(ctx.options.signal);
3606
+
3607
+ importCytoscapeEdges(ctx, edges, edgeClassesColumn);
3608
+ ctx.setMeta({ dialect: "cytoscape", cytoscape: extra }, ctx.weightOriginPatch());
3609
+ }
3610
+
3611
+ /** The state of a read of Cytoscape node elements. */
3612
+ interface CytoscapeNodeReading {
3613
+ readonly positionColumn: ColumnHandle;
3614
+ readonly classesColumn: ColumnHandle;
3615
+ /** The parent links, resolved once every node is known. */
3616
+ readonly parents: { index: number; parent: NodeId; element: string }[];
3617
+ /** A scratch position. */
3618
+ readonly point: [number, number, number];
3619
+ }
3620
+
3621
+ /**
3622
+ * The elements of a Cytoscape document (the document itself when it is an array) and, for an
3623
+ * object, its graph `data` written and its other top-level keys kept.
3624
+ * @param ctx - the context
3625
+ * @param root - the document
3626
+ * @returns the elements and the other top-level keys, if any
3627
+ */
3628
+ function cytoscapeRoot(ctx: ImportContext, root: unknown): { elements: unknown; extra?: Record<string, unknown> } {
3629
+ if (Array.isArray(root)) {
3630
+ return { elements: root };
3631
+ }
3632
+ if (!isJsonObject(root)) {
3633
+ ctx.report.fail(
3634
+ JSON_ISSUE.SHAPE,
3635
+ `a Cytoscape document must be an object or an array, found ${describe(root)}`,
3636
+ );
3637
+ }
3638
+ ctx.writeGraphDict(root.data, "data");
3639
+ const rest: Record<string, unknown> = {};
3640
+ for (const key of Object.keys(root)) {
3641
+ if (key !== "elements" && key !== "data") {
3642
+ rest[key] = root[key];
3404
3643
  }
3405
- const { data } = record;
3406
- const id = ctx.coerceId(hasKey(data, "id") ? data.id : undefined, element);
3407
- if (id === null) {
3408
- ctx.countSkipped("node");
3644
+ }
3645
+ return Object.keys(rest).length > 0 ? { elements: root.elements, extra: rest } : { elements: root.elements };
3646
+ }
3647
+
3648
+ /**
3649
+ * Read one Cytoscape node element: `data.id` the id, the other `data` keys attributes (`parent`
3650
+ * kept for later), `position`, `classes` and the element-level keys.
3651
+ * @param ctx - the context
3652
+ * @param reading - the read's state
3653
+ * @param record - the element
3654
+ * @param element - the element name
3655
+ */
3656
+ function readCytoscapeNode(ctx: ImportContext, reading: CytoscapeNodeReading, record: unknown, element: string): void {
3657
+ const { report } = ctx;
3658
+ if (!isJsonObject(record) || !isJsonObject(record.data)) {
3659
+ ctx.badElement("node", element, "an element with a data object");
3660
+ return;
3661
+ }
3662
+ const { data } = record;
3663
+ const id = ctx.coerceId(hasKey(data, "id") ? data.id : undefined, element);
3664
+ if (id === null) {
3665
+ ctx.countSkipped("node");
3666
+ return;
3667
+ }
3668
+ const index = ctx.pushNode(id, element);
3669
+ if (index < 0) {
3670
+ return;
3671
+ }
3672
+ if (present(data, "source") && present(data, "target")) {
3673
+ report.warning(
3674
+ "validation-error",
3675
+ JSON_ISSUE.INCONSISTENT,
3676
+ `${element} has data.source and data.target, the shape of an edge, but its section or group says node; read as a node`,
3677
+ { element },
3678
+ );
3679
+ }
3680
+ try {
3681
+ writeCytoscapeData(ctx, reading, index, data, element);
3682
+ writeCytoscapePosition(ctx, reading, index, record.position, element);
3683
+ writeClasses(ctx, ctx.nodes, reading.classesColumn, index, record.classes, element);
3684
+ writeElementKeys(ctx.nodes, index, record, element);
3685
+ } catch (err) {
3686
+ report.recordError(err, { element: String(id) });
3687
+ }
3688
+ }
3689
+
3690
+ /**
3691
+ * Write a Cytoscape node's `data` keys as attributes; `parent` is kept as a link to resolve later.
3692
+ * @param ctx - the context
3693
+ * @param reading - the read's state
3694
+ * @param index - the node index
3695
+ * @param data - the element's data object
3696
+ * @param element - the element name
3697
+ */
3698
+ function writeCytoscapeData(
3699
+ ctx: ImportContext,
3700
+ reading: CytoscapeNodeReading,
3701
+ index: number,
3702
+ data: JsonRecord,
3703
+ element: string,
3704
+ ): void {
3705
+ for (const key of Object.keys(data)) {
3706
+ if (key === "id") {
3409
3707
  continue;
3410
3708
  }
3411
- const index = ctx.pushNode(id, element);
3412
- if (index < 0) {
3709
+ if (key !== "parent") {
3710
+ ctx.nodes.write(index, key, data[key], SUFFIX.data, element);
3413
3711
  continue;
3414
3712
  }
3415
- if (present(data, "source") && present(data, "target")) {
3416
- report.warning(
3417
- "validation-error",
3418
- JSON_ISSUE.INCONSISTENT,
3419
- `${element} has data.source and data.target, the shape of an edge, but its section or group says node; read as a node`,
3420
- { element },
3421
- );
3713
+ const raw = data.parent;
3714
+ if (raw === undefined || raw === null) {
3715
+ continue;
3422
3716
  }
3423
- try {
3424
- for (const key of Object.keys(data)) {
3425
- if (key === "id") {
3426
- continue;
3427
- }
3428
- if (key === "parent") {
3429
- const raw = data.parent;
3430
- if (raw !== undefined && raw !== null) {
3431
- const parent = ctx.coerceId(raw, `${element}.data.parent`);
3432
- if (parent !== null) {
3433
- parents.push({ index, parent, element });
3434
- }
3435
- }
3436
- continue;
3437
- }
3438
- ctx.nodes.write(index, key, data[key], SUFFIX.data, element);
3439
- }
3440
- const { position } = record;
3441
- if (position !== undefined && position !== null) {
3442
- const z = isJsonObject(position) ? (position.z ?? 0) : 0;
3443
- if (isJsonObject(position) && isCoordinate(position.x) && isCoordinate(position.y) && isCoordinate(z)) {
3444
- point[0] = position.x;
3445
- // Cytoscape's y grows downward; positions are stored y-up (the exporter flips back)
3446
- point[1] = flipY(position.y);
3447
- point[2] = z;
3448
- ctx.nodes.set(positionColumn, index, point);
3449
- } else {
3450
- report.error(
3451
- "validation-error",
3452
- JSON_ISSUE.BAD_VALUE,
3453
- `${element}: position must be an object with numeric x and y (and z) that are finite numbers`,
3454
- { element },
3455
- );
3456
- }
3457
- }
3458
- writeClasses(ctx, ctx.nodes, classesColumn, index, record.classes, element);
3459
- writeElementKeys(ctx.nodes, index, record, element);
3460
- } catch (err) {
3461
- report.recordError(err, { element: String(id) });
3717
+ const parent = ctx.coerceId(raw, `${element}.data.parent`);
3718
+ if (parent !== null) {
3719
+ reading.parents.push({ index, parent, element });
3462
3720
  }
3463
3721
  }
3464
- resolveCytoscapeParents(ctx, parents, parentColumn);
3465
- throwIfAborted(ctx.options.signal);
3722
+ }
3466
3723
 
3467
- importCytoscapeEdges(ctx, edges, edgeClassesColumn);
3468
- ctx.setMeta({ dialect: "cytoscape", cytoscape: extra }, ctx.weightOriginPatch());
3724
+ /**
3725
+ * Write a Cytoscape node's `position` ({ x, y, z? }, y flipped to grow upward); any other value is
3726
+ * reported.
3727
+ * @param ctx - the context
3728
+ * @param reading - the read's state
3729
+ * @param index - the node index
3730
+ * @param position - the element's position
3731
+ * @param element - the element name
3732
+ */
3733
+ function writeCytoscapePosition(
3734
+ ctx: ImportContext,
3735
+ reading: CytoscapeNodeReading,
3736
+ index: number,
3737
+ position: unknown,
3738
+ element: string,
3739
+ ): void {
3740
+ if (position === undefined || position === null) {
3741
+ return;
3742
+ }
3743
+ const z = isJsonObject(position) ? (position.z ?? 0) : 0;
3744
+ if (isJsonObject(position) && isCoordinate(position.x) && isCoordinate(position.y) && isCoordinate(z)) {
3745
+ const { point } = reading;
3746
+ point[0] = position.x;
3747
+ // Cytoscape's y grows downward; positions are stored y-up (the exporter flips back)
3748
+ point[1] = flipY(position.y);
3749
+ point[2] = z;
3750
+ ctx.nodes.set(reading.positionColumn, index, point);
3751
+ } else {
3752
+ ctx.report.error(
3753
+ "validation-error",
3754
+ JSON_ISSUE.BAD_VALUE,
3755
+ `${element}: position must be an object with numeric x and y (and z) that are finite numbers`,
3756
+ { element },
3757
+ );
3758
+ }
3469
3759
  }
3470
3760
 
3471
3761
  /** One Cytoscape parent link: the parent's index and id, its document order and element name. */
@@ -3579,27 +3869,53 @@ function importCytoscapeEdges(ctx: ImportContext, edges: readonly unknown[], edg
3579
3869
  ctx.missingEndpoint(element, `data.${present(data, "source") ? "target" : "source"}`);
3580
3870
  continue;
3581
3871
  }
3582
- try {
3583
- const u = ctx.coerceId(data.source, `${element}.data.source`);
3584
- const v = ctx.coerceId(data.target, `${element}.data.target`);
3585
- if (u === null || v === null) {
3586
- ctx.countSkipped("edge");
3587
- continue;
3588
- }
3589
- const idValue = ctx.edgeIdValue(ids, data.id);
3590
- const edge = ctx.pushEdge(u, v, kind, ctx.weightOf(data), element);
3591
- ctx.setEdgeId(ids, edge, idValue);
3592
- const { weightFrom } = ctx.options;
3593
- for (const key of Object.keys(data)) {
3594
- if (key !== "id" && key !== "source" && key !== "target" && key !== weightFrom) {
3595
- ctx.edges.write(edge, key, data[key], SUFFIX.data, element);
3596
- }
3872
+ importCytoscapeEdge(ctx, { ids, kind, edgeClassesColumn }, record, data, element);
3873
+ }
3874
+ }
3875
+
3876
+ /**
3877
+ * Read one Cytoscape edge element whose data object holds both endpoints.
3878
+ * @param ctx - the context
3879
+ * @param shared - the edge ids, the uniform kind and the classes column of the whole section
3880
+ * @param shared.ids - the declared edge ids
3881
+ * @param shared.kind - the uniform edge kind
3882
+ * @param shared.edgeClassesColumn - the edge classes column, or INVALID_INDEX
3883
+ * @param record - the edge element
3884
+ * @param data - its data object
3885
+ * @param element - the element's name in messages
3886
+ */
3887
+ function importCytoscapeEdge(
3888
+ ctx: ImportContext,
3889
+ shared: {
3890
+ readonly ids: ReturnType<ImportContext["declareEdgeIds"]>;
3891
+ readonly kind: ReturnType<ImportContext["uniformKind"]>;
3892
+ readonly edgeClassesColumn: ColumnHandle;
3893
+ },
3894
+ record: JsonRecord,
3895
+ data: JsonRecord,
3896
+ element: string,
3897
+ ): void {
3898
+ const { ids, kind, edgeClassesColumn } = shared;
3899
+ try {
3900
+ const u = ctx.coerceId(data.source, `${element}.data.source`);
3901
+ const v = ctx.coerceId(data.target, `${element}.data.target`);
3902
+ if (u === null || v === null) {
3903
+ ctx.countSkipped("edge");
3904
+ return;
3905
+ }
3906
+ const idValue = ctx.edgeIdValue(ids, data.id);
3907
+ const edge = ctx.pushEdge(u, v, kind, ctx.weightOf(data), element);
3908
+ ctx.setEdgeId(ids, edge, idValue);
3909
+ const { weightFrom } = ctx.options;
3910
+ for (const key of Object.keys(data)) {
3911
+ if (key !== "id" && key !== "source" && key !== "target" && key !== weightFrom) {
3912
+ ctx.edges.write(edge, key, data[key], SUFFIX.data, element);
3597
3913
  }
3598
- writeClasses(ctx, ctx.edges, edgeClassesColumn, edge, record.classes, element);
3599
- writeElementKeys(ctx.edges, edge, record, element);
3600
- } catch (err) {
3601
- ctx.skip(err, "edge", element);
3602
3914
  }
3915
+ writeClasses(ctx, ctx.edges, edgeClassesColumn, edge, record.classes, element);
3916
+ writeElementKeys(ctx.edges, edge, record, element);
3917
+ } catch (err) {
3918
+ ctx.skip(err, "edge", element);
3603
3919
  }
3604
3920
  }
3605
3921