@graphty/graph-io 0.3.18 → 0.3.20

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 (235) hide show
  1. package/README.md +80 -2
  2. package/dist/chunks/{escape-D-gZWO26.js → escape-B-tkk_Cl.js} +74 -16
  3. package/dist/chunks/escape-B-tkk_Cl.js.map +1 -0
  4. package/dist/chunks/{writer-GAdltGmC.js → export-Bh60Dv-n.js} +7 -252
  5. package/dist/chunks/export-Bh60Dv-n.js.map +1 -0
  6. package/dist/chunks/exporter-BGrsWamJ.js +736 -0
  7. package/dist/chunks/exporter-BGrsWamJ.js.map +1 -0
  8. package/dist/chunks/exporter-DIeGJXAZ.js +834 -0
  9. package/dist/chunks/exporter-DIeGJXAZ.js.map +1 -0
  10. package/dist/chunks/{importer-Br_QeAeE.js → importer-BWY2FFc5.js} +28 -6
  11. package/dist/chunks/importer-BWY2FFc5.js.map +1 -0
  12. package/dist/chunks/importer-BaQpCEcJ.js +2194 -0
  13. package/dist/chunks/importer-BaQpCEcJ.js.map +1 -0
  14. package/dist/chunks/importer-DD-xv9_Y.js +1471 -0
  15. package/dist/chunks/importer-DD-xv9_Y.js.map +1 -0
  16. package/dist/chunks/{importer-aNJfe0qu.js → importer-DEcKhsmQ.js} +2 -2
  17. package/dist/chunks/{importer-aNJfe0qu.js.map → importer-DEcKhsmQ.js.map} +1 -1
  18. package/dist/chunks/{importer-DHagxvDD.js → importer-DIrbbnAf.js} +6 -4
  19. package/dist/chunks/{importer-DHagxvDD.js.map → importer-DIrbbnAf.js.map} +1 -1
  20. package/dist/chunks/importer-D_e7e7LX.js +3055 -0
  21. package/dist/chunks/importer-D_e7e7LX.js.map +1 -0
  22. package/dist/chunks/{importer-Du5crN9l.js → importer-Wv4C4gaF.js} +278 -82
  23. package/dist/chunks/importer-Wv4C4gaF.js.map +1 -0
  24. package/dist/chunks/importer-vi3bSdtw.js +1713 -0
  25. package/dist/chunks/importer-vi3bSdtw.js.map +1 -0
  26. package/dist/chunks/{importer-d0uQxFp6.js → importer-zGLo8Dg_.js} +6 -4
  27. package/dist/chunks/{importer-d0uQxFp6.js.map → importer-zGLo8Dg_.js.map} +1 -1
  28. package/dist/chunks/json-elements-DDS8N2Dd.js +779 -0
  29. package/dist/chunks/json-elements-DDS8N2Dd.js.map +1 -0
  30. package/dist/chunks/{records-Bk9jgodz.js → records-dbRkxwaq.js} +2 -2
  31. package/dist/chunks/{records-Bk9jgodz.js.map → records-dbRkxwaq.js.map} +1 -1
  32. package/dist/chunks/{report-BOk0p5y8.js → report-B1z4WT9e.js} +143 -111
  33. package/dist/chunks/{report-BOk0p5y8.js.map → report-B1z4WT9e.js.map} +1 -1
  34. package/dist/chunks/weights-Dzba96G3.js +176 -0
  35. package/dist/chunks/weights-Dzba96G3.js.map +1 -0
  36. package/dist/chunks/writer-C7flM-Ih.js +77 -0
  37. package/dist/chunks/writer-C7flM-Ih.js.map +1 -0
  38. package/dist/csv.js +6 -4
  39. package/dist/csv.js.map +1 -1
  40. package/dist/cx.d.ts +1 -0
  41. package/dist/cx.js +6 -0
  42. package/dist/cx.js.map +1 -0
  43. package/dist/cx2.d.ts +1 -0
  44. package/dist/cx2.js +10 -0
  45. package/dist/cx2.js.map +1 -0
  46. package/dist/cys.d.ts +1 -0
  47. package/dist/cys.js +6 -0
  48. package/dist/cys.js.map +1 -0
  49. package/dist/dot.js +1 -1
  50. package/dist/gexf.js +16 -4
  51. package/dist/gexf.js.map +1 -1
  52. package/dist/gml.js +13 -14
  53. package/dist/gml.js.map +1 -1
  54. package/dist/graph-io.js +302 -399
  55. package/dist/graph-io.js.map +1 -1
  56. package/dist/graphml.js +1 -1
  57. package/dist/json.js +1 -1
  58. package/dist/neo4j.js +6 -4
  59. package/dist/neo4j.js.map +1 -1
  60. package/dist/obo.js +1 -1
  61. package/dist/pajek.js +1 -1
  62. package/dist/src/common/cell-budget.d.ts +84 -0
  63. package/dist/src/common/cell-budget.d.ts.map +1 -0
  64. package/dist/src/common/cell-budget.js +138 -0
  65. package/dist/src/common/cell-budget.js.map +1 -0
  66. package/dist/src/common/input.d.ts +10 -0
  67. package/dist/src/common/input.d.ts.map +1 -1
  68. package/dist/src/common/input.js +39 -0
  69. package/dist/src/common/input.js.map +1 -1
  70. package/dist/src/common/json-elements.d.ts +286 -0
  71. package/dist/src/common/json-elements.d.ts.map +1 -0
  72. package/dist/src/common/json-elements.js +926 -0
  73. package/dist/src/common/json-elements.js.map +1 -0
  74. package/dist/src/common/options.d.ts +6 -0
  75. package/dist/src/common/options.d.ts.map +1 -1
  76. package/dist/src/common/options.js +1 -1
  77. package/dist/src/common/options.js.map +1 -1
  78. package/dist/src/common/xml.d.ts +37 -3
  79. package/dist/src/common/xml.d.ts.map +1 -1
  80. package/dist/src/common/xml.js +86 -6
  81. package/dist/src/common/xml.js.map +1 -1
  82. package/dist/src/common/zip.d.ts +92 -0
  83. package/dist/src/common/zip.d.ts.map +1 -0
  84. package/dist/src/common/zip.js +399 -0
  85. package/dist/src/common/zip.js.map +1 -0
  86. package/dist/src/formats/cx/importer.d.ts +133 -0
  87. package/dist/src/formats/cx/importer.d.ts.map +1 -0
  88. package/dist/src/formats/cx/importer.js +2220 -0
  89. package/dist/src/formats/cx/importer.js.map +1 -0
  90. package/dist/src/formats/cx/index.d.ts +7 -0
  91. package/dist/src/formats/cx/index.d.ts.map +1 -0
  92. package/dist/src/formats/cx/index.js +7 -0
  93. package/dist/src/formats/cx/index.js.map +1 -0
  94. package/dist/src/formats/cx2/exporter.d.ts +59 -0
  95. package/dist/src/formats/cx2/exporter.d.ts.map +1 -0
  96. package/dist/src/formats/cx2/exporter.js +864 -0
  97. package/dist/src/formats/cx2/exporter.js.map +1 -0
  98. package/dist/src/formats/cx2/importer.d.ts +169 -0
  99. package/dist/src/formats/cx2/importer.d.ts.map +1 -0
  100. package/dist/src/formats/cx2/importer.js +1652 -0
  101. package/dist/src/formats/cx2/importer.js.map +1 -0
  102. package/dist/src/formats/cx2/index.d.ts +7 -0
  103. package/dist/src/formats/cx2/index.d.ts.map +1 -0
  104. package/dist/src/formats/cx2/index.js +7 -0
  105. package/dist/src/formats/cx2/index.js.map +1 -0
  106. package/dist/src/formats/cys/constants.d.ts +68 -0
  107. package/dist/src/formats/cys/constants.d.ts.map +1 -0
  108. package/dist/src/formats/cys/constants.js +76 -0
  109. package/dist/src/formats/cys/constants.js.map +1 -0
  110. package/dist/src/formats/cys/importer.d.ts +38 -0
  111. package/dist/src/formats/cys/importer.d.ts.map +1 -0
  112. package/dist/src/formats/cys/importer.js +830 -0
  113. package/dist/src/formats/cys/importer.js.map +1 -0
  114. package/dist/src/formats/cys/index.d.ts +7 -0
  115. package/dist/src/formats/cys/index.d.ts.map +1 -0
  116. package/dist/src/formats/cys/index.js +7 -0
  117. package/dist/src/formats/cys/index.js.map +1 -0
  118. package/dist/src/formats/cys/session.d.ts +132 -0
  119. package/dist/src/formats/cys/session.d.ts.map +1 -0
  120. package/dist/src/formats/cys/session.js +315 -0
  121. package/dist/src/formats/cys/session.js.map +1 -0
  122. package/dist/src/formats/cys/tables.d.ts +90 -0
  123. package/dist/src/formats/cys/tables.d.ts.map +1 -0
  124. package/dist/src/formats/cys/tables.js +293 -0
  125. package/dist/src/formats/cys/tables.js.map +1 -0
  126. package/dist/src/formats/dot/exporter.d.ts +2 -0
  127. package/dist/src/formats/dot/exporter.d.ts.map +1 -1
  128. package/dist/src/formats/dot/exporter.js +12 -0
  129. package/dist/src/formats/dot/exporter.js.map +1 -1
  130. package/dist/src/formats/dot/importer.js +6 -2
  131. package/dist/src/formats/dot/importer.js.map +1 -1
  132. package/dist/src/formats/gexf/exporter.d.ts +2 -0
  133. package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
  134. package/dist/src/formats/gexf/exporter.js +6 -0
  135. package/dist/src/formats/gexf/exporter.js.map +1 -1
  136. package/dist/src/formats/gexf/importer.d.ts +0 -2
  137. package/dist/src/formats/gexf/importer.d.ts.map +1 -1
  138. package/dist/src/formats/gexf/importer.js +1 -3
  139. package/dist/src/formats/gexf/importer.js.map +1 -1
  140. package/dist/src/formats/gexf/index.d.ts +1 -1
  141. package/dist/src/formats/gexf/index.js +2 -2
  142. package/dist/src/formats/gexf/index.js.map +1 -1
  143. package/dist/src/formats/gml/exporter.d.ts +0 -8
  144. package/dist/src/formats/gml/exporter.d.ts.map +1 -1
  145. package/dist/src/formats/gml/exporter.js +8 -18
  146. package/dist/src/formats/gml/exporter.js.map +1 -1
  147. package/dist/src/formats/json/importer.d.ts.map +1 -1
  148. package/dist/src/formats/json/importer.js +6 -104
  149. package/dist/src/formats/json/importer.js.map +1 -1
  150. package/dist/src/formats/xgmml/columns.d.ts +152 -0
  151. package/dist/src/formats/xgmml/columns.d.ts.map +1 -0
  152. package/dist/src/formats/xgmml/columns.js +593 -0
  153. package/dist/src/formats/xgmml/columns.js.map +1 -0
  154. package/dist/src/formats/xgmml/constants.d.ts +196 -0
  155. package/dist/src/formats/xgmml/constants.d.ts.map +1 -0
  156. package/dist/src/formats/xgmml/constants.js +197 -0
  157. package/dist/src/formats/xgmml/constants.js.map +1 -0
  158. package/dist/src/formats/xgmml/document.d.ts +361 -0
  159. package/dist/src/formats/xgmml/document.d.ts.map +1 -0
  160. package/dist/src/formats/xgmml/document.js +695 -0
  161. package/dist/src/formats/xgmml/document.js.map +1 -0
  162. package/dist/src/formats/xgmml/emit.d.ts +341 -0
  163. package/dist/src/formats/xgmml/emit.d.ts.map +1 -0
  164. package/dist/src/formats/xgmml/emit.js +1275 -0
  165. package/dist/src/formats/xgmml/emit.js.map +1 -0
  166. package/dist/src/formats/xgmml/exporter.d.ts +24 -0
  167. package/dist/src/formats/xgmml/exporter.d.ts.map +1 -0
  168. package/dist/src/formats/xgmml/exporter.js +999 -0
  169. package/dist/src/formats/xgmml/exporter.js.map +1 -0
  170. package/dist/src/formats/xgmml/importer.d.ts +58 -0
  171. package/dist/src/formats/xgmml/importer.d.ts.map +1 -0
  172. package/dist/src/formats/xgmml/importer.js +360 -0
  173. package/dist/src/formats/xgmml/importer.js.map +1 -0
  174. package/dist/src/formats/xgmml/index.d.ts +8 -0
  175. package/dist/src/formats/xgmml/index.d.ts.map +1 -0
  176. package/dist/src/formats/xgmml/index.js +8 -0
  177. package/dist/src/formats/xgmml/index.js.map +1 -0
  178. package/dist/src/formats/xgmml/values.d.ts +60 -0
  179. package/dist/src/formats/xgmml/values.d.ts.map +1 -0
  180. package/dist/src/formats/xgmml/values.js +148 -0
  181. package/dist/src/formats/xgmml/values.js.map +1 -0
  182. package/dist/src/index.d.ts +4 -0
  183. package/dist/src/index.d.ts.map +1 -1
  184. package/dist/src/index.js +4 -0
  185. package/dist/src/index.js.map +1 -1
  186. package/dist/src/registry.d.ts +7 -0
  187. package/dist/src/registry.d.ts.map +1 -1
  188. package/dist/src/registry.js +30 -10
  189. package/dist/src/registry.js.map +1 -1
  190. package/dist/src/sniff.d.ts +1 -1
  191. package/dist/src/sniff.d.ts.map +1 -1
  192. package/dist/src/sniff.js +4 -0
  193. package/dist/src/sniff.js.map +1 -1
  194. package/dist/xgmml.d.ts +1 -0
  195. package/dist/xgmml.js +9 -0
  196. package/dist/xgmml.js.map +1 -0
  197. package/package.json +25 -2
  198. package/src/common/cell-budget.ts +170 -0
  199. package/src/common/input.ts +40 -0
  200. package/src/common/json-elements.ts +1147 -0
  201. package/src/common/options.ts +1 -1
  202. package/src/common/xml.ts +127 -6
  203. package/src/common/zip.ts +472 -0
  204. package/src/formats/cx/importer.ts +2733 -0
  205. package/src/formats/cx/index.ts +7 -0
  206. package/src/formats/cx2/exporter.ts +1036 -0
  207. package/src/formats/cx2/importer.ts +2187 -0
  208. package/src/formats/cx2/index.ts +7 -0
  209. package/src/formats/cys/constants.ts +99 -0
  210. package/src/formats/cys/importer.ts +1100 -0
  211. package/src/formats/cys/index.ts +7 -0
  212. package/src/formats/cys/session.ts +428 -0
  213. package/src/formats/cys/tables.ts +400 -0
  214. package/src/formats/dot/exporter.ts +17 -0
  215. package/src/formats/dot/importer.ts +6 -2
  216. package/src/formats/gexf/exporter.ts +11 -0
  217. package/src/formats/gexf/importer.ts +1 -2
  218. package/src/formats/gexf/index.ts +1 -1
  219. package/src/formats/gml/exporter.ts +8 -19
  220. package/src/formats/json/importer.ts +6 -105
  221. package/src/formats/xgmml/columns.ts +747 -0
  222. package/src/formats/xgmml/constants.ts +265 -0
  223. package/src/formats/xgmml/document.ts +975 -0
  224. package/src/formats/xgmml/emit.ts +1563 -0
  225. package/src/formats/xgmml/exporter.ts +1215 -0
  226. package/src/formats/xgmml/importer.ts +491 -0
  227. package/src/formats/xgmml/index.ts +8 -0
  228. package/src/formats/xgmml/values.ts +183 -0
  229. package/src/index.ts +19 -0
  230. package/src/registry.ts +46 -15
  231. package/src/sniff.ts +18 -1
  232. package/dist/chunks/escape-D-gZWO26.js.map +0 -1
  233. package/dist/chunks/importer-Br_QeAeE.js.map +0 -1
  234. package/dist/chunks/importer-Du5crN9l.js.map +0 -1
  235. package/dist/chunks/writer-GAdltGmC.js.map +0 -1
@@ -0,0 +1,2733 @@
1
+ /**
2
+ * The CX version 1 importer (design/graph-io/cytoscape-and-obo/design.md section 1.2; the feature
3
+ * and error inventory is research-cx.md): the JSON exchange format of NDEx and Cytoscape's CX
4
+ * support. A CX document is an array of aspect fragments (`nodes`, `edges`, `nodeAttributes`,
5
+ * `cartesianLayout`, `cySubNetworks`, `cyGroups`, `cyVisualProperties`, ...) that refer to each
6
+ * other by integer ids, in any order.
7
+ *
8
+ * The document is read through the streaming scanner of common/json-elements.ts, so its text is
9
+ * never held as one string; the parsed aspect elements are collected and each graph is built at the
10
+ * end, because a reference may precede what it names.
11
+ *
12
+ * Mapping: every edge is directed; ids follow the CX id rule (design section 1.0.2); `n` is the
13
+ * `name` column (the label), `r` is `represents`, `i` is `interaction`; attributes become columns
14
+ * typed by their `d` (Cytoscape's value rule: "" and "null" are unset, "NaN" is NaN in a double); a
15
+ * subnetwork's values are the unscoped ones and its own (`s`), its own winning. A collection (several
16
+ * `cySubNetworks`) holds one graph per subnetwork: `importAll()` returns them, `import()` reads the
17
+ * one `graphIndex` / `graphName` picks, `listGraphs()` lists them. Positions come from the
18
+ * subnetwork's view, y flipped to y-up, `z` into the `z` column; `cyGroups` give `parent` (or
19
+ * `parents`); per-element visual properties are one column per property (origin namespace
20
+ * "cx.bypass") and style rules are kept verbatim in `meta.extra.cx` and not applied
21
+ * (W_STYLES_NOT_IMPORTED, issue #706); provenance aspects become extension tables and list columns.
22
+ */
23
+
24
+ import {
25
+ type ColumnDecl,
26
+ type ColumnHandle,
27
+ type Dtype,
28
+ type GraphMetaPatch,
29
+ type GraphSink,
30
+ INVALID_INDEX,
31
+ type NodeId,
32
+ type ScalarDtype,
33
+ } from "@graphty/graph-format";
34
+
35
+ import { declareResolved, uniqueColumnName } from "../../common/attributes.js";
36
+ import {
37
+ AMBIGUOUS_GRAPH_NAME_CODE,
38
+ ASPECT_ORDER_CODE,
39
+ BAD_ASPECT_BLOCK_CODE,
40
+ BAD_VALUE_CODE,
41
+ COLUMN_RENAMED_CODE,
42
+ COUNT_MISMATCH_CODE,
43
+ DANGLING_REFERENCE_CODE,
44
+ DIRECTION_FORCED_CODE,
45
+ DIRECTION_REFUSED_CODE,
46
+ DUPLICATE_ATTRIBUTE_CODE,
47
+ DUPLICATE_EDGE_ID_CODE,
48
+ DUPLICATE_NODE_CODE,
49
+ EMPTY_INPUT_CODE,
50
+ ENCODING_FALLBACK_CODE,
51
+ GRAPH_NOT_FOUND_CODE,
52
+ ID_MERGED_CODE,
53
+ ID_TEXT_TYPE_CODE,
54
+ INVALID_ENCODING_CODE,
55
+ INVALID_UTF8_CODE,
56
+ MISSING_ENDPOINT_CODE,
57
+ MISSING_ID_CODE,
58
+ MULTIPLE_GRAPHS_CODE,
59
+ NO_GRAPH_CODE,
60
+ OPTION_IGNORED_CODE,
61
+ PARENT_CYCLE_CODE,
62
+ PRECISION_CODE,
63
+ ROLE_TAKEN_CODE,
64
+ SINK_OPTION_CODE,
65
+ STATUS_FAILED_CODE,
66
+ STATUS_WARNING_CODE,
67
+ STYLES_NOT_IMPORTED_CODE,
68
+ SYNTAX_CODE,
69
+ TOO_LARGE_CODE,
70
+ UNKNOWN_ATTR_TYPE_CODE,
71
+ UNKNOWN_ENCODING_CODE,
72
+ UNKNOWN_PARENT_CODE,
73
+ WIDENED_CODE,
74
+ } from "../../common/codes.js";
75
+ import { DirectionResolver } from "../../common/direction.js";
76
+ import { IdCoercer } from "../../common/ids.js";
77
+ import { textChunks, throwIfAborted } from "../../common/input.js";
78
+ import {
79
+ cxId,
80
+ CxStructure,
81
+ declareFresh,
82
+ ExactInteger,
83
+ flipY,
84
+ inexactLiteral,
85
+ isRecord,
86
+ JsonScanError,
87
+ plainJson,
88
+ positionDecl,
89
+ reportTooDeep,
90
+ scanAspects,
91
+ zDecl,
92
+ } from "../../common/json-elements.js";
93
+ import {
94
+ chooseGraph,
95
+ type ImportFormatDefaults,
96
+ reportSinkOptions,
97
+ reportUnusedOptions,
98
+ type ResolvedImportOptions,
99
+ resolveImportOptions,
100
+ } from "../../common/options.js";
101
+ import { ImportReportBuilder } from "../../common/report.js";
102
+ import { weightFromValue } from "../../common/weights.js";
103
+ import {
104
+ type CommonImportOptions,
105
+ type GraphChoiceOptions,
106
+ type GraphImporter,
107
+ type GraphListing,
108
+ type ImportInput,
109
+ type ImportReport,
110
+ } from "../../types.js";
111
+ import { zAsOption } from "../cx2/importer.js";
112
+
113
+ /** The format name. */
114
+ const CX_FORMAT = "cx";
115
+
116
+ /** The origin namespace of the per-element visual property columns. */
117
+ const CX_BYPASS_NAMESPACE = "cx.bypass";
118
+
119
+ /** The format-specific options of the CX importer. */
120
+ export interface CxImportOptions extends GraphChoiceOptions {
121
+ /**
122
+ * Where a node's `z` goes: "column" (default) keeps it in the f64 node column `z` (Cytoscape
123
+ * writes a stacking order there); "position" makes it the third component of the position.
124
+ */
125
+ zAs?: "column" | "position" | undefined;
126
+ }
127
+
128
+ /**
129
+ * The issue codes the CX importer records (design section 1.2), by name: the codes shared with
130
+ * the other importers (src/common/codes.ts) and the CX-specific ones. A key is the code without
131
+ * its severity and format prefixes.
132
+ */
133
+ export const CX_ISSUE = Object.freeze({
134
+ /** The input is empty (fatal). */
135
+ EMPTY_INPUT: EMPTY_INPUT_CODE,
136
+ /** The text is not JSON (fatal). */
137
+ SYNTAX: SYNTAX_CODE,
138
+ /** Invalid UTF-8 (fatal). */
139
+ INVALID_UTF8: INVALID_UTF8_CODE,
140
+ /** Invalid bytes in the encoding a BOM or the encoding option chose (fatal). */
141
+ INVALID_ENCODING: INVALID_ENCODING_CODE,
142
+ /** Bytes that are not UTF-8 were read as windows-1252. */
143
+ ENCODING_FALLBACK: ENCODING_FALLBACK_CODE,
144
+ /** An encoding the platform cannot decode was ignored. */
145
+ UNKNOWN_ENCODING: UNKNOWN_ENCODING_CODE,
146
+ /** The document is not a CX array, or it is CX2 (fatal; the message names CX2). */
147
+ NOT_CX: "E_CX_NOT_CX",
148
+ /** numberVerification holds another value than 2^48 - 1, or comes twice. */
149
+ NUMBER_VERIFICATION: "W_CX_NUMBER_VERIFICATION",
150
+ /** An old Cytoscape aspect name (visualProperties, subNetworks, ...) read under its cy name. */
151
+ OLD_ASPECT_NAME: "W_CX_OLD_ASPECT_NAME",
152
+ /** A cyGroups group whose id is not a node: the group node is added. */
153
+ GROUP_NODE_ADDED: "W_CX_GROUP_NODE_ADDED",
154
+ /** Nodes or edges of the root network that no subnetwork holds: Cytoscape shows them in no network; not read. */
155
+ ROOT_ONLY: "W_CX_ROOT_ONLY",
156
+ /** The producer marked the document as failed (fatal). */
157
+ STATUS_FAILED: STATUS_FAILED_CODE,
158
+ /** The producer marked the document as successful with an error text. */
159
+ STATUS_WARNING: STATUS_WARNING_CODE,
160
+ /** A member of the array that is not a one-key aspect block, or an element that is not an object. */
161
+ BAD_ASPECT_BLOCK: BAD_ASPECT_BLOCK_CODE,
162
+ /** An aspect after the post-metadata or after the status, a third metaData. */
163
+ ASPECT_ORDER: ASPECT_ORDER_CODE,
164
+ /** A metaData element count disagrees with what was read. */
165
+ COUNT_MISMATCH: COUNT_MISMATCH_CODE,
166
+ /** A value that does not parse as its data type; the cell is unset. */
167
+ BAD_VALUE: BAD_VALUE_CODE,
168
+ /** One attribute name with several data types: the column takes the wider one. */
169
+ WIDENED: WIDENED_CODE,
170
+ /** A data type CX does not define; the value is kept as text. */
171
+ UNKNOWN_ATTR_TYPE: UNKNOWN_ATTR_TYPE_CODE,
172
+ /** An attribute, layout, bypass, subnetwork member, view or provenance entry naming nothing. */
173
+ DANGLING_REFERENCE: DANGLING_REFERENCE_CODE,
174
+ /** The same attribute twice on one element (or n and a different name attribute); the later wins. */
175
+ DUPLICATE_ATTRIBUTE: DUPLICATE_ATTRIBUTE_CODE,
176
+ /** A group member naming no node. */
177
+ UNKNOWN_PARENT: UNKNOWN_PARENT_CODE,
178
+ /** A group membership that would close a parent cycle; dropped. */
179
+ PARENT_CYCLE: PARENT_CYCLE_CODE,
180
+ /** The style rules of cyVisualProperties are not applied (issue #706). */
181
+ STYLES_NOT_IMPORTED: STYLES_NOT_IMPORTED_CODE,
182
+ /** A node without an @id. */
183
+ MISSING_ID: MISSING_ID_CODE,
184
+ /** An edge without s or t. */
185
+ MISSING_ENDPOINT: MISSING_ENDPOINT_CODE,
186
+ /** A node id declared twice; the second merges into the first. */
187
+ DUPLICATE_NODE: DUPLICATE_NODE_CODE,
188
+ /** An edge id declared twice; the second edge is skipped. */
189
+ DUPLICATE_EDGE_ID: DUPLICATE_EDGE_ID_CODE,
190
+ /** An id that is not an integer. */
191
+ INVALID_ID: "E_INVALID_ID",
192
+ /** An edge endpoint naming no node of the graph (addMissingNodes false, the default). */
193
+ UNKNOWN_NODE: "E_UNKNOWN_NODE",
194
+ /** A weight that is not a number. */
195
+ INVALID_WEIGHT: "E_INVALID_WEIGHT",
196
+ /** An id spelled as a string or a non-integer literal; read as the integer. */
197
+ ID_TEXT_TYPE: ID_TEXT_TYPE_CODE,
198
+ /** An integer beyond 2^53: an id kept as its digits, a value stored as the nearest f64. */
199
+ PRECISION: PRECISION_CODE,
200
+ /** Two id texts merged under `ids: "number"`. */
201
+ ID_MERGED: ID_MERGED_CODE,
202
+ /** A collection read by import(): the other subnetworks are skipped. */
203
+ MULTIPLE_GRAPHS: MULTIPLE_GRAPHS_CODE,
204
+ /** graphIndex or graphName names no subnetwork (fatal). */
205
+ GRAPH_NOT_FOUND: GRAPH_NOT_FOUND_CODE,
206
+ /** graphName names several subnetworks (fatal). */
207
+ AMBIGUOUS_GRAPH_NAME: AMBIGUOUS_GRAPH_NAME_CODE,
208
+ /** The input holds no graph (fatal). */
209
+ NO_GRAPH: NO_GRAPH_CODE,
210
+ /** A column renamed because its name was taken. */
211
+ COLUMN_RENAMED: COLUMN_RENAMED_CODE,
212
+ /** A column that lost its role because another column holds it. */
213
+ ROLE_TAKEN: ROLE_TAKEN_CODE,
214
+ /** The sink refused the direction. */
215
+ DIRECTION_REFUSED: DIRECTION_REFUSED_CODE,
216
+ /** Edges forced to the policy's direction. */
217
+ DIRECTION_FORCED: DIRECTION_FORCED_CODE,
218
+ /** A common option CX has no use for. */
219
+ OPTION_IGNORED: OPTION_IGNORED_CODE,
220
+ /** A builder option the caller's sink does not honour. */
221
+ SINK_OPTION: SINK_OPTION_CODE,
222
+ /** The input is beyond a size limit (fatal). */
223
+ TOO_LARGE: TOO_LARGE_CODE,
224
+ });
225
+
226
+ const USED_OPTIONS: ReadonlySet<keyof CommonImportOptions> = new Set<keyof CommonImportOptions>([
227
+ "ids",
228
+ "addMissingNodes",
229
+ "duplicateEdges",
230
+ "selfLoops",
231
+ "onMixedDirection",
232
+ "weightFrom",
233
+ "weightDtype",
234
+ "long",
235
+ "errorLimit",
236
+ "signal",
237
+ "onProgress",
238
+ ]);
239
+
240
+ /** CX has no undirected edge: defaultDirected never applies (reported W_OPTION_IGNORED). */
241
+ const FORMAT_DEFAULTS: ImportFormatDefaults = {
242
+ ids: "keep",
243
+ defaultDirected: true,
244
+ weightFrom: "weight",
245
+ addMissingNodes: false,
246
+ };
247
+
248
+ const ABORT_CHECK_INTERVAL = 64;
249
+
250
+ /** The numberVerification values a reader accepts: 2^48 - 1 and Java's Long.MAX_VALUE. */
251
+ const NUMBER_VERIFICATION_VALUES: ReadonlySet<string> = new Set(["281474976710655", "9223372036854775807"]);
252
+
253
+ /** Old Cytoscape aspect names and the names they are read under. */
254
+ const OLD_ASPECT_NAMES: Readonly<Record<string, string>> = {
255
+ visualProperties: "cyVisualProperties",
256
+ subNetworks: "cySubNetworks",
257
+ networkRelations: "cyNetworkRelations",
258
+ hiddenAttributes: "cyHiddenAttributes",
259
+ };
260
+
261
+ /** The aspects the importer reads into the graph (everything else is kept verbatim). */
262
+ const READ_ASPECTS: ReadonlySet<string> = new Set([
263
+ "nodes",
264
+ "edges",
265
+ "nodeAttributes",
266
+ "edgeAttributes",
267
+ "networkAttributes",
268
+ "cartesianLayout",
269
+ "cySubNetworks",
270
+ "cyNetworkRelations",
271
+ "cyViews",
272
+ "cyGroups",
273
+ "cyVisualProperties",
274
+ "cyTableColumn",
275
+ "citations",
276
+ "supports",
277
+ "nodeCitations",
278
+ "edgeCitations",
279
+ "nodeSupports",
280
+ "edgeSupports",
281
+ "functionTerms",
282
+ "reifiedEdges",
283
+ "numberVerification",
284
+ "metaData",
285
+ "status",
286
+ ]);
287
+
288
+ /** The aspects also kept verbatim in meta.extra.cx although they are read. */
289
+ const ALSO_KEPT: ReadonlySet<string> = new Set([
290
+ "cySubNetworks",
291
+ "cyNetworkRelations",
292
+ "cyViews",
293
+ "cyVisualProperties",
294
+ "cyTableColumn",
295
+ "numberVerification",
296
+ "metaData",
297
+ ]);
298
+
299
+ /** Cytoscape's table-cell style aspects (both spellings): style rules, kept and reported, never read. */
300
+ const TABLE_STYLE_ASPECTS: readonly string[] = ["tableVisualProperties", "cyTableVisualProperties"];
301
+
302
+ /** The first keys that make a head CX for sure, and the other aspect names a CX document may start with. */
303
+ const CX_FIRST_KEYS: readonly string[] = ["numberVerification", "metaData"];
304
+ const CX_ASPECT_KEYS: readonly string[] = [
305
+ "nodes",
306
+ "edges",
307
+ "nodeAttributes",
308
+ "edgeAttributes",
309
+ "networkAttributes",
310
+ "cartesianLayout",
311
+ "@context",
312
+ "cySubNetworks",
313
+ "subNetworks",
314
+ "cyNetworkRelations",
315
+ "networkRelations",
316
+ "cyVisualProperties",
317
+ "visualProperties",
318
+ "cyHiddenAttributes",
319
+ "hiddenAttributes",
320
+ "cyTableColumn",
321
+ "cyGroups",
322
+ "cyViews",
323
+ "ndexStatus",
324
+ "provenanceHistory",
325
+ "citations",
326
+ "supports",
327
+ "functionTerms",
328
+ ];
329
+
330
+ // ============================================================ values
331
+
332
+ /** The canonical CX scalar types. */
333
+ type CxScalar = "string" | "boolean" | "integer" | "long" | "double";
334
+
335
+ /** A CX data type. */
336
+ interface CxType {
337
+ readonly scalar: CxScalar;
338
+ readonly list: boolean;
339
+ }
340
+
341
+ /** The CX data types, with the Java reader's float aliases. */
342
+ const SCALARS: Readonly<Record<string, CxScalar>> = {
343
+ string: "string",
344
+ boolean: "boolean",
345
+ integer: "integer",
346
+ long: "long",
347
+ double: "double",
348
+ float: "double",
349
+ };
350
+
351
+ /**
352
+ * Resolve a `d` value.
353
+ * @param d - the data type (undefined: string)
354
+ * @returns the type, or null when CX does not define it
355
+ */
356
+ function cxType(d: unknown): CxType | null {
357
+ if (d === undefined || d === null) {
358
+ return { scalar: "string", list: false };
359
+ }
360
+ if (typeof d !== "string") {
361
+ return null;
362
+ }
363
+ const list = d.startsWith("list_of_");
364
+ const scalar = SCALARS[list ? d.slice("list_of_".length) : d] as CxScalar | undefined;
365
+ return scalar === undefined ? null : { scalar, list };
366
+ }
367
+
368
+ /**
369
+ * The type text of a type.
370
+ * @param type - the type
371
+ * @returns `list_of_double`, `string`, ...
372
+ */
373
+ function typeText(type: CxType): string {
374
+ return type.list ? `list_of_${type.scalar}` : type.scalar;
375
+ }
376
+
377
+ /** The widening rank of the scalar types (design 5.1 order; boolean and numbers widen to string). */
378
+ const RANK: Readonly<Record<CxScalar, number>> = { boolean: 0, integer: 1, long: 2, double: 3, string: 4 };
379
+
380
+ /**
381
+ * Widen two types to one that holds both.
382
+ * @param a - the type so far
383
+ * @param b - another type
384
+ * @returns the wider type, or null when they cannot share a column (a list and a scalar)
385
+ */
386
+ function widenType(a: CxType, b: CxType): CxType | null {
387
+ if (a.list !== b.list) {
388
+ return null;
389
+ }
390
+ if (a.scalar === b.scalar) {
391
+ return a;
392
+ }
393
+ const numeric = (s: CxScalar): boolean => s === "integer" || s === "long" || s === "double";
394
+ if (numeric(a.scalar) && numeric(b.scalar)) {
395
+ return RANK[a.scalar] > RANK[b.scalar] ? a : b;
396
+ }
397
+ return { scalar: "string", list: a.list };
398
+ }
399
+
400
+ /** What parseValue() returns for a value that does not parse. */
401
+ const BAD = Symbol("bad");
402
+
403
+ /** What parseValue() returns for a value Cytoscape reads as null ("", "null"). */
404
+ const UNSET = Symbol("unset");
405
+
406
+ const I32_MIN = -2147483648;
407
+ const I32_MAX = 2147483647;
408
+ const INTEGER_TEXT = /^-?[0-9]+$/;
409
+ const DECIMAL_TEXT = /^-?([0-9]+(\.[0-9]*)?|\.[0-9]+)([eE][+-]?[0-9]+)?$/;
410
+
411
+ /**
412
+ * Whether a text is Cytoscape's null: the empty string or "null" in any case.
413
+ * @param text - the text
414
+ * @returns true for a null text
415
+ */
416
+ function isNullText(text: string): boolean {
417
+ return text === "" || text.toLowerCase() === "null";
418
+ }
419
+
420
+ /**
421
+ * Parse one scalar value (or list item) of a CX attribute by its type, with Cytoscape's value
422
+ * rule: "" and "null" are unset, "NaN" is NaN in a double (unset in an integer or long), native JSON
423
+ * numbers and booleans are accepted, anything else that does not parse is BAD.
424
+ * @param value - the value (never null)
425
+ * @param scalar - the type
426
+ * @param long - the importer's `long` option
427
+ * @param onPrecision - called for a long beyond 2^53
428
+ * @returns the value, UNSET or BAD
429
+ */
430
+ function parseScalar(
431
+ value: unknown,
432
+ scalar: CxScalar,
433
+ long: "f64" | "string",
434
+ onPrecision: (digits: string) => void,
435
+ ): unknown {
436
+ if (value instanceof ExactInteger) {
437
+ if (scalar === "string" || (scalar === "long" && long === "string")) {
438
+ return value.digits;
439
+ }
440
+ if (scalar === "long" || scalar === "double") {
441
+ if (scalar === "long") {
442
+ onPrecision(value.digits);
443
+ }
444
+ return Number(value.digits);
445
+ }
446
+ return BAD;
447
+ }
448
+ if (typeof value === "number" || typeof value === "boolean") {
449
+ return parseNative(value, scalar, long);
450
+ }
451
+ if (typeof value !== "string") {
452
+ return BAD;
453
+ }
454
+ if (scalar === "string") {
455
+ return value;
456
+ }
457
+ if (isNullText(value)) {
458
+ return UNSET;
459
+ }
460
+ switch (scalar) {
461
+ case "boolean": {
462
+ const lower = value.toLowerCase();
463
+ if (lower === "true" || lower === "false") {
464
+ return lower === "true";
465
+ }
466
+ return BAD;
467
+ }
468
+ case "integer":
469
+ case "long": {
470
+ if (value === "NaN") {
471
+ return UNSET;
472
+ }
473
+ if (!INTEGER_TEXT.test(value)) {
474
+ return BAD;
475
+ }
476
+ const n = Number(value);
477
+ if (scalar === "integer") {
478
+ return n >= I32_MIN && n <= I32_MAX ? n : BAD;
479
+ }
480
+ if (long === "string") {
481
+ return value;
482
+ }
483
+ if (!Number.isSafeInteger(n)) {
484
+ onPrecision(value);
485
+ }
486
+ return n;
487
+ }
488
+ default: {
489
+ if (value === "NaN" || value === "nan") {
490
+ return NaN;
491
+ }
492
+ if (value === "Infinity" || value === "-Infinity") {
493
+ return Number(value);
494
+ }
495
+ return DECIMAL_TEXT.test(value) ? Number(value) : BAD;
496
+ }
497
+ }
498
+ }
499
+
500
+ /**
501
+ * Parse a native JSON number or boolean by a type.
502
+ * @param value - the value
503
+ * @param scalar - the type
504
+ * @param long - the importer's `long` option
505
+ * @returns the value or BAD
506
+ */
507
+ function parseNative(value: number | boolean, scalar: CxScalar, long: "f64" | "string"): unknown {
508
+ switch (scalar) {
509
+ case "string":
510
+ return String(value);
511
+ case "boolean":
512
+ return typeof value === "boolean" ? value : BAD;
513
+ case "integer":
514
+ return typeof value === "number" && Number.isInteger(value) && value >= I32_MIN && value <= I32_MAX
515
+ ? value
516
+ : BAD;
517
+ case "long":
518
+ if (typeof value !== "number" || !Number.isInteger(value)) {
519
+ return BAD;
520
+ }
521
+ return long === "string" ? String(value) : value;
522
+ default:
523
+ return typeof value === "number" ? value : BAD;
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Parse an attribute value by its type: a scalar, or a list whose items parse one by one (a null
529
+ * item is NaN in a double list and BAD elsewhere).
530
+ * @param value - the value (never null)
531
+ * @param type - the type
532
+ * @param long - the importer's `long` option
533
+ * @param onPrecision - called for a long beyond 2^53
534
+ * @returns the value, UNSET or BAD
535
+ */
536
+ function parseValue(
537
+ value: unknown,
538
+ type: CxType,
539
+ long: "f64" | "string",
540
+ onPrecision: (digits: string) => void,
541
+ ): unknown {
542
+ if (Array.isArray(value) !== type.list) {
543
+ return BAD;
544
+ }
545
+ if (!type.list) {
546
+ return parseScalar(value, type.scalar, long, onPrecision);
547
+ }
548
+ const out: unknown[] = [];
549
+ for (const item of value as unknown[]) {
550
+ let parsed = item === null ? UNSET : parseScalar(item, type.scalar, long, onPrecision);
551
+ if (parsed === UNSET) {
552
+ parsed = type.scalar === "double" ? NaN : BAD;
553
+ }
554
+ if (parsed === BAD) {
555
+ return BAD;
556
+ }
557
+ out.push(parsed);
558
+ }
559
+ return out;
560
+ }
561
+
562
+ /**
563
+ * The column dtype of a type.
564
+ * @param scalar - the scalar type
565
+ * @param long - the importer's `long` option
566
+ * @returns the dtype
567
+ */
568
+ function scalarDtype(scalar: CxScalar, long: "f64" | "string"): ScalarDtype {
569
+ switch (scalar) {
570
+ case "boolean":
571
+ return "bool";
572
+ case "integer":
573
+ return "i32";
574
+ case "long":
575
+ return long === "string" ? "string" : "f64";
576
+ case "double":
577
+ return "f64";
578
+ default:
579
+ return "string";
580
+ }
581
+ }
582
+
583
+ /**
584
+ * A parsed value converted to the column's type (a narrower value in a widened column).
585
+ * @param value - the parsed value
586
+ * @param type - the column's type
587
+ * @returns the value to store
588
+ */
589
+ function toColumn(value: unknown, type: CxType): unknown {
590
+ if (type.scalar !== "string") {
591
+ return value;
592
+ }
593
+ if (type.list) {
594
+ return (value as unknown[]).map((item) => (typeof item === "string" ? item : String(item)));
595
+ }
596
+ return typeof value === "string" ? value : String(value);
597
+ }
598
+
599
+ /**
600
+ * A short JSON rendering of a value for a message.
601
+ * @param value - the value
602
+ * @returns the text, at most 60 characters
603
+ */
604
+ function shown(value: unknown): string {
605
+ if (value instanceof ExactInteger) {
606
+ return value.digits;
607
+ }
608
+ let text: string;
609
+ try {
610
+ text = JSON.stringify(value) ?? String(value);
611
+ } catch {
612
+ text = String(value);
613
+ }
614
+ return text.length > 60 ? `${text.slice(0, 57)}...` : text;
615
+ }
616
+
617
+ // ============================================================ the document as read
618
+
619
+ /** A parsed aspect element with where it came from. */
620
+ interface Held {
621
+ readonly value: unknown;
622
+ readonly line: number;
623
+ /** Bit 1: @id, bit 2: s, bit 4: t was written as a non-integer literal. */
624
+ readonly inexact: number;
625
+ }
626
+
627
+ /** Everything the scan collects. */
628
+ interface CxDocument {
629
+ /** The read aspects by their (cy) name. */
630
+ readonly aspects: Map<string, Held[]>;
631
+ /** Attribute elements by table and attribute name. */
632
+ readonly attributes: {
633
+ readonly node: Map<string, Held[]>;
634
+ readonly edge: Map<string, Held[]>;
635
+ readonly network: Map<string, Held[]>;
636
+ };
637
+ /** Aspects kept verbatim in meta.extra.cx. */
638
+ readonly kept: Map<string, unknown[]>;
639
+ readonly structure: CxStructure;
640
+ }
641
+
642
+ /**
643
+ * The elements of a read aspect.
644
+ * @param doc - the document
645
+ * @param name - the aspect
646
+ * @returns the elements, empty when absent
647
+ */
648
+ function aspect(doc: CxDocument, name: string): readonly Held[] {
649
+ return doc.aspects.get(name) ?? [];
650
+ }
651
+
652
+ /**
653
+ * The inexact-literal bits of an element.
654
+ * @param text - the element's JSON text
655
+ * @param keys - the id keys to check, bit 1, 2, 4 in order
656
+ * @returns the bits
657
+ */
658
+ function inexactBits(text: string, keys: readonly string[]): number {
659
+ if (!/[0-9][.eE]/.test(text)) {
660
+ return 0;
661
+ }
662
+ let bits = 0;
663
+ keys.forEach((key, i) => {
664
+ if (inexactLiteral(text, key)) {
665
+ bits |= 1 << i;
666
+ }
667
+ });
668
+ return bits;
669
+ }
670
+
671
+ /**
672
+ * The attribute map of an attribute aspect.
673
+ * @param doc - the document
674
+ * @param name - the aspect name
675
+ * @returns the map, or null for another aspect
676
+ */
677
+ function attributeTable(doc: CxDocument, name: string): Map<string, Held[]> | null {
678
+ switch (name) {
679
+ case "nodeAttributes":
680
+ return doc.attributes.node;
681
+ case "edgeAttributes":
682
+ return doc.attributes.edge;
683
+ case "networkAttributes":
684
+ return doc.attributes.network;
685
+ default:
686
+ return null;
687
+ }
688
+ }
689
+
690
+ /**
691
+ * Check a numberVerification element: 2^48 - 1 (or Java's Long.MAX_VALUE), and only one.
692
+ * @param value - the element
693
+ * @param count - how many numberVerification elements were read, this one included
694
+ * @param report - the report
695
+ * @param line - its line
696
+ */
697
+ function checkNumberVerification(value: unknown, count: number, report: ImportReportBuilder, line: number): void {
698
+ const raw = isRecord(value) ? value.longNumber : undefined;
699
+ const digits = raw instanceof ExactInteger ? raw.digits : String(raw);
700
+ if (count > 1 || !NUMBER_VERIFICATION_VALUES.has(digits)) {
701
+ report.warning(
702
+ "validation-error",
703
+ CX_ISSUE.NUMBER_VERIFICATION,
704
+ count > 1
705
+ ? "a second numberVerification element; ignored"
706
+ : `numberVerification holds ${shown(raw === undefined ? null : digits)}, not 281474976710655`,
707
+ { line, element: "numberVerification" },
708
+ );
709
+ }
710
+ }
711
+
712
+ /**
713
+ * Read the whole document through the streaming scanner, checking its structure.
714
+ * @param input - the input
715
+ * @param report - the report
716
+ * @param options - the resolved options
717
+ * @returns what was read
718
+ */
719
+ async function readDocument(
720
+ input: ImportInput,
721
+ report: ImportReportBuilder,
722
+ options: ResolvedImportOptions,
723
+ ): Promise<CxDocument> {
724
+ const structure = new CxStructure(report);
725
+ const doc: CxDocument = {
726
+ aspects: new Map(),
727
+ attributes: { node: new Map(), edge: new Map(), network: new Map() },
728
+ kept: new Map(),
729
+ structure,
730
+ };
731
+ let sinceCheck = 0;
732
+ let verifications = 0;
733
+ const canonical = (name: string, line: number): string => {
734
+ const renamed = OLD_ASPECT_NAMES[name] as string | undefined;
735
+ if (renamed === undefined) {
736
+ return name;
737
+ }
738
+ report.warnOnce(
739
+ "coercion",
740
+ CX_ISSUE.OLD_ASPECT_NAME,
741
+ `the old aspect name "${name}" is read as "${renamed}"`,
742
+ { line, element: name },
743
+ `${CX_ISSUE.OLD_ASPECT_NAME}:${name}`,
744
+ );
745
+ return renamed;
746
+ };
747
+ const collect = (name: string, value: unknown, text: string, line: number, exact: boolean): void => {
748
+ structure.element(name, value, line);
749
+ if (ALSO_KEPT.has(name) || !READ_ASPECTS.has(name)) {
750
+ if (!doc.kept.has(name)) {
751
+ doc.kept.set(name, []);
752
+ }
753
+ doc.kept.get(name)?.push(exact ? plainJson(value) : value);
754
+ }
755
+ if (!READ_ASPECTS.has(name) || name === "metaData" || name === "status") {
756
+ return;
757
+ }
758
+ if (name === "numberVerification") {
759
+ checkNumberVerification(value, ++verifications, report, line);
760
+ return;
761
+ }
762
+ let inexact = 0;
763
+ if (name === "nodes") {
764
+ inexact = inexactBits(text, ["@id"]);
765
+ } else if (name === "edges") {
766
+ inexact = inexactBits(text, ["@id", "s", "t"]);
767
+ }
768
+ const held: Held = { value, line, inexact };
769
+ const table = attributeTable(doc, name);
770
+ if (table !== null) {
771
+ if (!isRecord(value) || typeof value.n !== "string") {
772
+ report.error("missing-value", MISSING_ID_CODE, `a ${name} element has no attribute name n; skipped`, {
773
+ line,
774
+ element: name,
775
+ });
776
+ return;
777
+ }
778
+ const key = value.n;
779
+ if (!table.has(key)) {
780
+ table.set(key, []);
781
+ }
782
+ table.get(key)?.push(held);
783
+ return;
784
+ }
785
+ if (!doc.aspects.has(name)) {
786
+ doc.aspects.set(name, []);
787
+ }
788
+ doc.aspects.get(name)?.push(held);
789
+ };
790
+ try {
791
+ for await (const event of scanAspects(textChunks(input, report, options))) {
792
+ if (++sinceCheck >= ABORT_CHECK_INTERVAL) {
793
+ sinceCheck = 0;
794
+ throwIfAborted(options.signal);
795
+ }
796
+ switch (event.kind) {
797
+ case "root":
798
+ report.fail(
799
+ CX_ISSUE.NOT_CX,
800
+ `a CX document is a JSON array of aspects; found ${isRecord(event.value) ? "an object" : shown(event.value)}`,
801
+ { line: event.line },
802
+ );
803
+ break;
804
+ case "member": {
805
+ const { value } = event;
806
+ if (isRecord(value) && "CXVersion" in value) {
807
+ report.fail(
808
+ CX_ISSUE.NOT_CX,
809
+ `the document is CX2 (CXVersion ${shown(value.CXVersion)}), not CX version 1; read it with the cx2 importer`,
810
+ { line: event.line },
811
+ );
812
+ }
813
+ const keys = isRecord(value) ? Object.keys(value) : [];
814
+ if (isRecord(value) && keys.length === 1 && isRecord(value[keys[0]])) {
815
+ const name = canonical(keys[0], event.line);
816
+ structure.block(name, event.line);
817
+ collect(name, value[keys[0]], "", event.line, event.exact);
818
+ break;
819
+ }
820
+ report.error(
821
+ "parse-error",
822
+ BAD_ASPECT_BLOCK_CODE,
823
+ `a member of the document is ${keys.length > 1 ? `an object with ${keys.length} keys (${keys.join(", ")})` : shown(value)}, not a one-key aspect fragment; skipped`,
824
+ { line: event.line, element: keys[0] ?? null },
825
+ );
826
+ break;
827
+ }
828
+ case "block":
829
+ structure.block(canonical(event.aspect, event.line), event.line);
830
+ break;
831
+ case "element": {
832
+ const name = OLD_ASPECT_NAMES[event.aspect] ?? event.aspect;
833
+ collect(name, event.value, event.text, event.line, event.exact);
834
+ break;
835
+ }
836
+ case "deep":
837
+ reportTooDeep(report, event);
838
+ break;
839
+ case "extraKeys":
840
+ report.error(
841
+ "parse-error",
842
+ BAD_ASPECT_BLOCK_CODE,
843
+ `the "${event.aspect}" fragment holds more keys (${event.keys.join(", ")}); a fragment has one key, the others are skipped`,
844
+ { line: event.line, element: event.aspect },
845
+ );
846
+ break;
847
+ default:
848
+ break;
849
+ }
850
+ }
851
+ } catch (err) {
852
+ if (err instanceof JsonScanError) {
853
+ report.fail(err.empty ? EMPTY_INPUT_CODE : SYNTAX_CODE, err.message, { line: err.line });
854
+ }
855
+ throw err;
856
+ }
857
+ structure.checkCounts();
858
+ return doc;
859
+ }
860
+
861
+ // ============================================================ graphs (subnetworks)
862
+
863
+ /** One graph of a document: the root network, or one subnetwork of a collection. */
864
+ interface GraphPlan {
865
+ readonly index: number;
866
+ readonly name: string | null;
867
+ /** The subnetwork's id, or null for the root network. */
868
+ readonly subnetwork: NodeId | null;
869
+ /** The member node ids, or null for every node. */
870
+ readonly nodes: ReadonlySet<NodeId> | null;
871
+ /** The member edge ids, or null for every edge. */
872
+ readonly edges: ReadonlySet<NodeId> | null;
873
+ /** The views of the graph, the first one first. */
874
+ readonly views: readonly NodeId[];
875
+ }
876
+
877
+ /**
878
+ * An id of a reference, or null when it is not one.
879
+ * @param raw - the parsed reference
880
+ * @returns the id
881
+ */
882
+ function refId(raw: unknown): NodeId | null {
883
+ try {
884
+ return cxId(raw).id;
885
+ } catch {
886
+ return null;
887
+ }
888
+ }
889
+
890
+ /**
891
+ * A member list: the ids of an array, or null for "all".
892
+ * @param raw - the list
893
+ * @returns the set, or null for every element
894
+ */
895
+ function memberSet(raw: unknown): Set<NodeId> | null {
896
+ if (raw === "all") {
897
+ return null;
898
+ }
899
+ const out = new Set<NodeId>();
900
+ if (Array.isArray(raw)) {
901
+ for (const item of raw) {
902
+ const id = refId(item);
903
+ if (id !== null) {
904
+ out.add(id);
905
+ }
906
+ }
907
+ }
908
+ return out;
909
+ }
910
+
911
+ /**
912
+ * The graphs of a document, in the order Cytoscape opens them.
913
+ * @param doc - the document
914
+ * @returns one plan per graph (at least one)
915
+ */
916
+ function graphPlans(doc: CxDocument): GraphPlan[] {
917
+ const subs = new Map<NodeId, { nodes: Set<NodeId> | null; edges: Set<NodeId> | null }>();
918
+ for (const { value } of aspect(doc, "cySubNetworks")) {
919
+ const id = isRecord(value) ? refId(value["@id"]) : null;
920
+ if (id !== null && isRecord(value) && !subs.has(id)) {
921
+ subs.set(id, { nodes: memberSet(value.nodes), edges: memberSet(value.edges) });
922
+ }
923
+ }
924
+ const relationNames = new Map<NodeId, string>();
925
+ const order: NodeId[] = [];
926
+ const views = new Map<NodeId, NodeId[]>();
927
+ const addView = (sub: NodeId, view: NodeId): void => {
928
+ const list = views.get(sub) ?? [];
929
+ if (!list.includes(view)) {
930
+ list.push(view);
931
+ }
932
+ views.set(sub, list);
933
+ };
934
+ for (const { value } of aspect(doc, "cyNetworkRelations")) {
935
+ if (!isRecord(value)) {
936
+ continue;
937
+ }
938
+ const child = refId(value.c);
939
+ if (child === null) {
940
+ continue;
941
+ }
942
+ if (value.r === "view") {
943
+ const parent = refId(value.p);
944
+ if (parent !== null) {
945
+ addView(parent, child);
946
+ }
947
+ continue;
948
+ }
949
+ if (typeof value.name === "string") {
950
+ relationNames.set(child, value.name);
951
+ }
952
+ if (subs.has(child) && !order.includes(child)) {
953
+ order.push(child);
954
+ }
955
+ }
956
+ for (const { value } of aspect(doc, "cyViews")) {
957
+ const view = isRecord(value) ? refId(value["@id"]) : null;
958
+ const sub = isRecord(value) ? refId(value.s) : null;
959
+ if (view !== null && sub !== null) {
960
+ addView(sub, view);
961
+ }
962
+ }
963
+ for (const id of subs.keys()) {
964
+ if (!order.includes(id)) {
965
+ order.push(id);
966
+ }
967
+ }
968
+ const networkName = (sub: NodeId | null): string | null => {
969
+ let found: string | null = null;
970
+ for (const { value } of doc.attributes.network.get("name") ?? []) {
971
+ if (!isRecord(value) || typeof value.v !== "string") {
972
+ continue;
973
+ }
974
+ const scope = value.s === undefined ? null : refId(value.s);
975
+ if (scope === sub) {
976
+ return value.v;
977
+ }
978
+ if (scope === null) {
979
+ found ??= value.v;
980
+ }
981
+ }
982
+ return found;
983
+ };
984
+ if (order.length <= 1) {
985
+ const sub = order.length === 1 ? order[0] : null;
986
+ const members = sub === null ? undefined : subs.get(sub);
987
+ let graphViews = sub === null ? [] : (views.get(sub) ?? []);
988
+ if (graphViews.length === 0) {
989
+ graphViews = [...new Set([...views.values()].flat())];
990
+ }
991
+ if (graphViews.length === 0) {
992
+ graphViews = layoutViews(doc);
993
+ }
994
+ return [
995
+ {
996
+ index: 0,
997
+ name: (sub === null ? null : (relationNames.get(sub) ?? null)) ?? networkName(sub),
998
+ subnetwork: sub,
999
+ nodes: members?.nodes ?? null,
1000
+ edges: members?.edges ?? null,
1001
+ views: graphViews,
1002
+ },
1003
+ ];
1004
+ }
1005
+ return order.map((sub, index) => ({
1006
+ index,
1007
+ name: relationNames.get(sub) ?? networkName(sub),
1008
+ subnetwork: sub,
1009
+ nodes: subs.get(sub)?.nodes ?? null,
1010
+ edges: subs.get(sub)?.edges ?? null,
1011
+ views: views.get(sub) ?? [],
1012
+ }));
1013
+ }
1014
+
1015
+ /**
1016
+ * The distinct views the layout entries name, in order of appearance.
1017
+ * @param doc - the document
1018
+ * @returns the view ids
1019
+ */
1020
+ function layoutViews(doc: CxDocument): NodeId[] {
1021
+ const out: NodeId[] = [];
1022
+ for (const { value } of aspect(doc, "cartesianLayout")) {
1023
+ const view = isRecord(value) && value.view !== undefined ? refId(value.view) : null;
1024
+ if (view !== null && !out.includes(view)) {
1025
+ out.push(view);
1026
+ }
1027
+ }
1028
+ return out;
1029
+ }
1030
+
1031
+ /**
1032
+ * How many elements a member set holds.
1033
+ * @param members - the set, or null for every element
1034
+ * @param total - the element count of the root network
1035
+ * @returns the count
1036
+ */
1037
+ function memberCount(members: ReadonlySet<NodeId> | null, total: number): number {
1038
+ return members === null ? total : members.size;
1039
+ }
1040
+
1041
+ // ============================================================ the build
1042
+
1043
+ /** One attribute column of the graph being built. */
1044
+ interface AttributeColumn {
1045
+ readonly name: string;
1046
+ readonly type: CxType | null;
1047
+ /** null: values are kept as text (an unknown or conflicting type). */
1048
+ handle: ColumnHandle;
1049
+ }
1050
+
1051
+ /** Reads one graph of a collected document into a sink. */
1052
+ class CxReader {
1053
+ private readonly sink: GraphSink;
1054
+
1055
+ private readonly report: ImportReportBuilder;
1056
+
1057
+ private readonly options: ResolvedImportOptions;
1058
+
1059
+ private readonly zAs: "column" | "position";
1060
+
1061
+ private readonly doc: CxDocument;
1062
+
1063
+ private readonly plan: GraphPlan;
1064
+
1065
+ private readonly direction: DirectionResolver;
1066
+
1067
+ private readonly coercer: IdCoercer;
1068
+
1069
+ /** Every node id of the root network. */
1070
+ private readonly rootNodes = new Set<NodeId>();
1071
+
1072
+ /** Every edge id of the root network. */
1073
+ private readonly rootEdges = new Set<NodeId>();
1074
+
1075
+ /** CX node id -> sink node index, for this graph. */
1076
+ private readonly nodeRows = new Map<NodeId, number>();
1077
+
1078
+ /** CX edge id -> sink edge index, for this graph. */
1079
+ private readonly edgeRows = new Map<NodeId, number>();
1080
+
1081
+ /** The subnetwork ids of the document. */
1082
+ private readonly subnetworks = new Set<NodeId>();
1083
+
1084
+ private readonly dangling = new Map<string, number>();
1085
+
1086
+ private nameHandle: ColumnHandle = INVALID_INDEX as ColumnHandle;
1087
+
1088
+ private representsHandle: ColumnHandle = INVALID_INDEX as ColumnHandle;
1089
+
1090
+ private interactionHandle: ColumnHandle = INVALID_INDEX as ColumnHandle;
1091
+
1092
+ private edgeIdHandle: ColumnHandle = INVALID_INDEX as ColumnHandle;
1093
+
1094
+ private sinceCheck = 0;
1095
+
1096
+ private idType: "integer" | "mixed" = "integer";
1097
+
1098
+ private weighted = false;
1099
+
1100
+ /**
1101
+ * Create the reader.
1102
+ * @param sink - the sink
1103
+ * @param report - the report
1104
+ * @param options - the resolved options
1105
+ * @param zAs - where z goes
1106
+ * @param doc - the collected document
1107
+ * @param plan - the graph to read
1108
+ */
1109
+ constructor(
1110
+ sink: GraphSink,
1111
+ report: ImportReportBuilder,
1112
+ options: ResolvedImportOptions,
1113
+ zAs: "column" | "position",
1114
+ doc: CxDocument,
1115
+ plan: GraphPlan,
1116
+ ) {
1117
+ this.sink = sink;
1118
+ this.report = report;
1119
+ this.options = options;
1120
+ this.zAs = zAs;
1121
+ this.doc = doc;
1122
+ this.plan = plan;
1123
+ this.direction = new DirectionResolver(sink, report, options.onMixedDirection);
1124
+ this.coercer = new IdCoercer(options.ids);
1125
+ for (const { value } of aspect(doc, "nodes")) {
1126
+ const id = isRecord(value) ? refId(value["@id"]) : null;
1127
+ if (id !== null) {
1128
+ this.rootNodes.add(id);
1129
+ }
1130
+ }
1131
+ for (const { value } of aspect(doc, "edges")) {
1132
+ const id = isRecord(value) ? refId(value["@id"]) : null;
1133
+ if (id !== null) {
1134
+ this.rootEdges.add(id);
1135
+ }
1136
+ }
1137
+ for (const { value } of aspect(doc, "cySubNetworks")) {
1138
+ const id = isRecord(value) ? refId(value["@id"]) : null;
1139
+ if (id !== null) {
1140
+ this.subnetworks.add(id);
1141
+ }
1142
+ }
1143
+ }
1144
+
1145
+ /** Build the graph. */
1146
+ build(): void {
1147
+ const { report } = this;
1148
+ this.direction.setHeader(true);
1149
+ this.checkMembers();
1150
+ this.checkRootOnly();
1151
+ const nodeColumns = this.declareAttributes("node");
1152
+ this.readNodes();
1153
+ this.readGroups();
1154
+ this.writeAttributes("node", nodeColumns);
1155
+ this.readLayout();
1156
+ const edgeColumns = this.declareAttributes("edge");
1157
+ this.readEdges();
1158
+ this.writeAttributes("edge", edgeColumns);
1159
+ this.readNetworkAttributes();
1160
+ this.readVisualProperties();
1161
+ this.readProvenance();
1162
+ for (const [kind, count] of this.dangling) {
1163
+ report.warning("validation-error", DANGLING_REFERENCE_CODE, `${count} ${kind}(s) name nothing; ignored`, {
1164
+ element: kind,
1165
+ });
1166
+ }
1167
+ this.setMeta();
1168
+ }
1169
+
1170
+ // ------------------------------------------------------------ helpers
1171
+
1172
+ /**
1173
+ * Count a reference that names nothing.
1174
+ * @param kind - what referred
1175
+ */
1176
+ private dangle(kind: string): void {
1177
+ this.dangling.set(kind, (this.dangling.get(kind) ?? 0) + 1);
1178
+ }
1179
+
1180
+ /** Check the cancellation signal every ABORT_CHECK_INTERVAL elements. */
1181
+ private checkAbort(): void {
1182
+ if (++this.sinceCheck >= ABORT_CHECK_INTERVAL) {
1183
+ this.sinceCheck = 0;
1184
+ throwIfAborted(this.options.signal);
1185
+ }
1186
+ }
1187
+
1188
+ /**
1189
+ * Whether a node id belongs to this graph.
1190
+ * @param id - the CX id
1191
+ * @returns true for a member
1192
+ */
1193
+ private inGraph(id: NodeId): boolean {
1194
+ return this.plan.nodes === null || this.plan.nodes.has(id);
1195
+ }
1196
+
1197
+ /**
1198
+ * Whether a scoped value applies to this graph: unscoped, or scoped to its subnetwork. A scope
1199
+ * naming no subnetwork is counted as dangling.
1200
+ * @param scope - the element's s
1201
+ * @returns 1 for an unscoped value, 2 for this graph's own, 0 for another graph's
1202
+ */
1203
+ private scopeOf(scope: unknown): 0 | 1 | 2 {
1204
+ if (scope === undefined || scope === null) {
1205
+ return 1;
1206
+ }
1207
+ const id = refId(scope);
1208
+ if (id !== null && id === this.plan.subnetwork) {
1209
+ return 2;
1210
+ }
1211
+ if (id === null || !this.subnetworks.has(id)) {
1212
+ this.dangle("attribute subnetwork reference");
1213
+ }
1214
+ return 0;
1215
+ }
1216
+
1217
+ /**
1218
+ * The id of an element, by the CX id rule and the `ids` option.
1219
+ * @param raw - the parsed id
1220
+ * @param inexact - whether the literal was not a plain integer
1221
+ * @param element - the element name for issues
1222
+ * @param edgeId - whether this is an edge's own id (stored in the f64 id column, not kept as digits)
1223
+ * @returns the id, or null when it was reported
1224
+ */
1225
+ private idOf(raw: unknown, inexact: boolean, element: string, edgeId = false): NodeId | null {
1226
+ try {
1227
+ const parsed = cxId(raw, inexact);
1228
+ if (parsed.note === "text") {
1229
+ this.report.warnOnce(
1230
+ "coercion",
1231
+ ID_TEXT_TYPE_CODE,
1232
+ `${element}: the id ${shown(raw)} is not written as an integer; read as ${String(parsed.id)}`,
1233
+ { element },
1234
+ );
1235
+ } else if (parsed.note === "precision" && edgeId) {
1236
+ // the edge is told apart by its digits, but the id column is f64
1237
+ this.report.warnOnce(
1238
+ "precision",
1239
+ PRECISION_CODE,
1240
+ `${element}: the edge id ${String(parsed.id)} is beyond 2^53; the id column holds the nearest double`,
1241
+ { element },
1242
+ `${PRECISION_CODE}:edge`,
1243
+ );
1244
+ } else if (parsed.note === "precision") {
1245
+ this.idType = "mixed";
1246
+ this.report.warnOnce(
1247
+ "precision",
1248
+ PRECISION_CODE,
1249
+ `${element}: the id ${String(parsed.id)} is beyond 2^53; kept as its digits (a string id)`,
1250
+ { element },
1251
+ );
1252
+ }
1253
+ return this.options.ids === "keep" ? parsed.id : this.coercer.value(parsed.id);
1254
+ } catch (err) {
1255
+ this.report.recordError(err, { element });
1256
+ return null;
1257
+ }
1258
+ }
1259
+
1260
+ /**
1261
+ * Record a long value beyond 2^53 (once).
1262
+ * @param name - the attribute
1263
+ * @param digits - the value
1264
+ */
1265
+ private precision(name: string, digits: string): void {
1266
+ this.report.warnOnce(
1267
+ "precision",
1268
+ PRECISION_CODE,
1269
+ `"${name}": ${digits} is beyond 2^53; stored as the nearest double`,
1270
+ { element: name },
1271
+ `${PRECISION_CODE}:value`,
1272
+ );
1273
+ }
1274
+
1275
+ /** Count the subnetwork members that name no node or edge. */
1276
+ private checkMembers(): void {
1277
+ for (const id of this.plan.nodes ?? []) {
1278
+ if (!this.rootNodes.has(id)) {
1279
+ this.dangle("subnetwork node member");
1280
+ }
1281
+ }
1282
+ for (const id of this.plan.edges ?? []) {
1283
+ if (!this.rootEdges.has(id)) {
1284
+ this.dangle("subnetwork edge member");
1285
+ }
1286
+ }
1287
+ }
1288
+
1289
+ /**
1290
+ * Report the root network's nodes and edges that no subnetwork holds (W_CX_ROOT_ONLY): they
1291
+ * belong to no graph Cytoscape opens, so no graph of this file reads them.
1292
+ */
1293
+ private checkRootOnly(): void {
1294
+ const subs = aspect(this.doc, "cySubNetworks");
1295
+ if (subs.length === 0) {
1296
+ return;
1297
+ }
1298
+ const held = { nodes: new Set<NodeId>(), edges: new Set<NodeId>() };
1299
+ const all = { nodes: false, edges: false };
1300
+ for (const { value } of subs) {
1301
+ if (!isRecord(value)) {
1302
+ continue;
1303
+ }
1304
+ for (const key of ["nodes", "edges"] as const) {
1305
+ const members = memberSet(value[key]);
1306
+ if (members === null) {
1307
+ all[key] = true;
1308
+ } else {
1309
+ members.forEach((id) => held[key].add(id));
1310
+ }
1311
+ }
1312
+ }
1313
+ const lost = (key: "nodes" | "edges", root: ReadonlySet<NodeId>): number =>
1314
+ all[key] ? 0 : [...root].filter((id) => !held[key].has(id)).length;
1315
+ const nodes = lost("nodes", this.rootNodes);
1316
+ const edges = lost("edges", this.rootEdges);
1317
+ if (nodes + edges > 0) {
1318
+ this.report.warning(
1319
+ "unsupported",
1320
+ CX_ISSUE.ROOT_ONLY,
1321
+ `${nodes} node(s) and ${edges} edge(s) of the root network belong to no subnetwork (cySubNetworks); they are not read`,
1322
+ { element: "cySubNetworks" },
1323
+ );
1324
+ }
1325
+ }
1326
+
1327
+ /**
1328
+ * The handle of a structural column, declared on first use.
1329
+ * @param domain - node or edge
1330
+ * @param current - the handle so far
1331
+ * @param decl - the declaration
1332
+ * @returns the handle
1333
+ */
1334
+ private structural(domain: "node" | "edge", current: ColumnHandle, decl: ColumnDecl): ColumnHandle {
1335
+ return current === INVALID_INDEX ? declareResolved(this.sink, domain, decl, this.report).handle : current;
1336
+ }
1337
+
1338
+ // ------------------------------------------------------------ attribute columns
1339
+
1340
+ /**
1341
+ * Type and declare the attribute columns of a table for this graph: each name's type is the
1342
+ * wider of the types its applicable elements and its cyTableColumn entries give (W_WIDENED when
1343
+ * they differ); a name with a type CX does not define keeps its values as text.
1344
+ * @param domain - node or edge
1345
+ * @returns the columns by attribute name
1346
+ */
1347
+ private declareAttributes(domain: "node" | "edge"): Map<string, AttributeColumn> {
1348
+ const columns = new Map<string, AttributeColumn>();
1349
+ const declared = this.tableColumns(domain === "node" ? "node_table" : "edge_table");
1350
+ const names = new Set<string>([...declared.keys(), ...this.doc.attributes[domain].keys()]);
1351
+ for (const name of names) {
1352
+ if (name === "" || (domain === "edge" && name === this.options.weightFrom)) {
1353
+ continue;
1354
+ }
1355
+ const types: CxType[] = [];
1356
+ let unknown = false;
1357
+ const tableType = declared.get(name);
1358
+ if (tableType !== undefined) {
1359
+ types.push(tableType);
1360
+ }
1361
+ for (const { value } of this.doc.attributes[domain].get(name) ?? []) {
1362
+ if (!isRecord(value) || this.scopePeek(value.s) === 0) {
1363
+ continue;
1364
+ }
1365
+ const type = cxType(value.d);
1366
+ if (type === null) {
1367
+ unknown = true;
1368
+ continue;
1369
+ }
1370
+ if (!types.some((t) => t.scalar === type.scalar && t.list === type.list)) {
1371
+ types.push(type);
1372
+ }
1373
+ }
1374
+ if (unknown) {
1375
+ this.report.warnOnce(
1376
+ "unsupported",
1377
+ UNKNOWN_ATTR_TYPE_CODE,
1378
+ `a ${domain} attribute "${name}" declares a data type CX does not define; its values are kept as text`,
1379
+ { element: name },
1380
+ `${UNKNOWN_ATTR_TYPE_CODE}:${domain}:${name}`,
1381
+ );
1382
+ types.push({ scalar: "string", list: false });
1383
+ }
1384
+ let type: CxType | null = types[0] ?? { scalar: "string", list: false };
1385
+ for (const other of types.slice(1)) {
1386
+ type = type === null ? null : widenType(type, other);
1387
+ }
1388
+ if (types.length > 1) {
1389
+ this.report.warnOnce(
1390
+ "coercion",
1391
+ WIDENED_CODE,
1392
+ `the ${domain} attribute "${name}" has the data types ${types.map(typeText).join(", ")}; the column holds ${type === null ? "their values as JSON" : typeText(type)}`,
1393
+ { element: name },
1394
+ `${WIDENED_CODE}:${domain}:${name}`,
1395
+ );
1396
+ }
1397
+ if (this.isStructural(domain, name)) {
1398
+ columns.set(name, {
1399
+ name,
1400
+ type: { scalar: "string", list: false },
1401
+ handle: INVALID_INDEX as ColumnHandle,
1402
+ });
1403
+ continue;
1404
+ }
1405
+ columns.set(name, { name, type, handle: this.declareColumn(domain, name, type) });
1406
+ }
1407
+ return columns;
1408
+ }
1409
+
1410
+ /**
1411
+ * Whether an attribute name is the column of a core field (n / r on nodes, i on edges).
1412
+ * @param domain - node or edge
1413
+ * @param name - the attribute name
1414
+ * @returns true for name, represents and interaction
1415
+ */
1416
+ private isStructural(domain: "node" | "edge", name: string): boolean {
1417
+ return domain === "node" ? name === "name" || name === "represents" : name === "interaction";
1418
+ }
1419
+
1420
+ /**
1421
+ * The scope of a value without counting a dangling one (for typing, which runs before the values).
1422
+ * @param scope - the element's s
1423
+ * @returns as scopeOf()
1424
+ */
1425
+ private scopePeek(scope: unknown): 0 | 1 | 2 {
1426
+ if (scope === undefined || scope === null) {
1427
+ return 1;
1428
+ }
1429
+ const id = refId(scope);
1430
+ return id !== null && id === this.plan.subnetwork ? 2 : 0;
1431
+ }
1432
+
1433
+ /**
1434
+ * The column types cyTableColumn declares for one table and this graph.
1435
+ * @param table - node_table, edge_table or network_table
1436
+ * @returns name -> type
1437
+ */
1438
+ private tableColumns(table: string): Map<string, CxType> {
1439
+ const out = new Map<string, CxType>();
1440
+ for (const { value } of aspect(this.doc, "cyTableColumn")) {
1441
+ if (!isRecord(value) || value.applies_to !== table || typeof value.n !== "string") {
1442
+ continue;
1443
+ }
1444
+ if (this.scopePeek(value.s) === 0) {
1445
+ continue;
1446
+ }
1447
+ const type = cxType(value.d);
1448
+ if (type !== null) {
1449
+ out.set(value.n, type);
1450
+ }
1451
+ }
1452
+ return out;
1453
+ }
1454
+
1455
+ /**
1456
+ * Declare one attribute column.
1457
+ * @param domain - node or edge
1458
+ * @param name - the attribute name
1459
+ * @param type - the type, or null for a json column
1460
+ * @returns the handle
1461
+ */
1462
+ private declareColumn(domain: "node" | "edge", name: string, type: CxType | null): ColumnHandle {
1463
+ const { long } = this.options;
1464
+ const decl: ColumnDecl =
1465
+ type === null
1466
+ ? { name, dtype: "json", nullable: true, origin: { format: CX_FORMAT, id: null } }
1467
+ : {
1468
+ name,
1469
+ dtype: type.list ? "list" : scalarDtype(type.scalar, long),
1470
+ ...(type.list ? { itemDtype: scalarDtype(type.scalar, long) } : {}),
1471
+ nullable: true,
1472
+ origin: { format: CX_FORMAT, id: null, type: typeText(type) },
1473
+ };
1474
+ return declareResolved(this.sink, domain, decl, this.report).handle;
1475
+ }
1476
+
1477
+ /**
1478
+ * Write the attribute values of a table: per element, its own subnetwork's value beats the
1479
+ * unscoped one; a repeated value of the same scope is the later one (W_DUPLICATE_ATTRIBUTE
1480
+ * when they differ).
1481
+ * @param domain - node or edge
1482
+ * @param columns - the columns from declareAttributes()
1483
+ */
1484
+ private writeAttributes(domain: "node" | "edge", columns: ReadonlyMap<string, AttributeColumn>): void {
1485
+ const rows = domain === "node" ? this.nodeRows : this.edgeRows;
1486
+ const root = domain === "node" ? this.rootNodes : this.rootEdges;
1487
+ for (const column of columns.values()) {
1488
+ const chosen = new Map<number, { scope: 1 | 2; value: unknown; d: unknown; line: number }>();
1489
+ for (const held of this.doc.attributes[domain].get(column.name) ?? []) {
1490
+ this.checkAbort();
1491
+ const { value, line } = held;
1492
+ if (!isRecord(value)) {
1493
+ this.report.error(
1494
+ "parse-error",
1495
+ BAD_ASPECT_BLOCK_CODE,
1496
+ `a ${domain}Attributes element is not an object`,
1497
+ {
1498
+ line,
1499
+ element: `${domain}Attributes`,
1500
+ },
1501
+ );
1502
+ continue;
1503
+ }
1504
+ const scope = this.scopeOf(value.s);
1505
+ if (scope === 0) {
1506
+ continue;
1507
+ }
1508
+ const target = refId(value.po);
1509
+ const row =
1510
+ target === null
1511
+ ? undefined
1512
+ : rows.get(this.options.ids === "keep" ? target : this.coercer.value(target));
1513
+ if (row === undefined) {
1514
+ if (target === null || !root.has(target)) {
1515
+ this.dangle(`${domain} attribute target`);
1516
+ }
1517
+ continue;
1518
+ }
1519
+ if (value.v === undefined) {
1520
+ this.report.error(
1521
+ "missing-value",
1522
+ BAD_VALUE_CODE,
1523
+ `a ${domain}Attributes element for "${column.name}" has no v; skipped`,
1524
+ {
1525
+ line,
1526
+ element: column.name,
1527
+ },
1528
+ );
1529
+ continue;
1530
+ }
1531
+ if (value.v === null) {
1532
+ continue;
1533
+ }
1534
+ const previous = chosen.get(row);
1535
+ if (previous !== undefined && previous.scope > scope) {
1536
+ continue;
1537
+ }
1538
+ if (
1539
+ previous?.scope === scope &&
1540
+ JSON.stringify(previous.value) !== JSON.stringify(plainJson(value.v))
1541
+ ) {
1542
+ this.report.warnOnce(
1543
+ "validation-error",
1544
+ DUPLICATE_ATTRIBUTE_CODE,
1545
+ `${domain} ${shown(value.po)} has the attribute "${column.name}" twice; the later value wins`,
1546
+ { line, element: column.name },
1547
+ `${DUPLICATE_ATTRIBUTE_CODE}:${domain}:${column.name}`,
1548
+ );
1549
+ }
1550
+ chosen.set(row, { scope, value: value.v, d: value.d, line });
1551
+ }
1552
+ for (const [row, { value, d, line }] of chosen) {
1553
+ this.writeCell(domain, column, row, value, d, line);
1554
+ }
1555
+ }
1556
+ }
1557
+
1558
+ /**
1559
+ * Parse and write one attribute cell. The core fields' attributes (name, represents,
1560
+ * interaction) go into their columns; a name attribute that differs from n wins with
1561
+ * W_DUPLICATE_ATTRIBUTE.
1562
+ * @param domain - node or edge
1563
+ * @param column - the column
1564
+ * @param row - the row
1565
+ * @param raw - the value
1566
+ * @param d - the data type the value was written with
1567
+ * @param line - its line
1568
+ */
1569
+ private writeCell(
1570
+ domain: "node" | "edge",
1571
+ column: AttributeColumn,
1572
+ row: number,
1573
+ raw: unknown,
1574
+ d: unknown,
1575
+ line: number,
1576
+ ): void {
1577
+ const element = `${domain} attribute "${column.name}"`;
1578
+ let value: unknown;
1579
+ if (column.type === null) {
1580
+ value = plainJson(raw, (digits) => {
1581
+ this.precision(column.name, digits);
1582
+ });
1583
+ } else {
1584
+ const own = cxType(d) ?? { scalar: "string", list: false };
1585
+ const parsed = parseValue(raw, own, this.options.long, (digits) => {
1586
+ this.precision(column.name, digits);
1587
+ });
1588
+ if (parsed === UNSET) {
1589
+ return;
1590
+ }
1591
+ if (parsed === BAD) {
1592
+ this.report.error(
1593
+ "validation-error",
1594
+ BAD_VALUE_CODE,
1595
+ `${element}: ${shown(raw)} is not a ${typeText(own)}; the cell is unset`,
1596
+ { line, element: column.name },
1597
+ );
1598
+ return;
1599
+ }
1600
+ value = toColumn(parsed, column.type);
1601
+ }
1602
+ let { handle } = column;
1603
+ if (this.isStructural(domain, column.name)) {
1604
+ handle = this.coreColumn(domain, column.name);
1605
+ const prior = this.coreValue(domain, row, column.name);
1606
+ if (prior !== undefined && prior !== value) {
1607
+ this.report.warnOnce(
1608
+ "validation-error",
1609
+ DUPLICATE_ATTRIBUTE_CODE,
1610
+ `a ${domain} has "${column.name}" both as its core field and as an attribute with another value; the attribute wins`,
1611
+ { line, element: column.name },
1612
+ `${DUPLICATE_ATTRIBUTE_CODE}:core:${column.name}`,
1613
+ );
1614
+ }
1615
+ }
1616
+ try {
1617
+ if (domain === "node") {
1618
+ this.sink.setNodeValue(handle, row, value);
1619
+ } else {
1620
+ this.sink.setEdgeValue(handle, row, value);
1621
+ }
1622
+ } catch (err) {
1623
+ this.report.recordError(err, { line, element: column.name });
1624
+ }
1625
+ }
1626
+
1627
+ /** The core field values written per row (n, r), to detect a differing name attribute. */
1628
+ private readonly coreValues = new Map<string, Map<number, string>>();
1629
+
1630
+ /**
1631
+ * The core field value of a row.
1632
+ * @param domain - node or edge
1633
+ * @param row - the row
1634
+ * @param name - name, represents or interaction
1635
+ * @returns the value, or undefined
1636
+ */
1637
+ private coreValue(domain: "node" | "edge", row: number, name: string): string | undefined {
1638
+ return this.coreValues.get(`${domain}:${name}`)?.get(row);
1639
+ }
1640
+
1641
+ /**
1642
+ * The column of a core field.
1643
+ * @param domain - node or edge
1644
+ * @param name - name, represents or interaction
1645
+ * @returns the handle
1646
+ */
1647
+ private coreColumn(domain: "node" | "edge", name: string): ColumnHandle {
1648
+ if (domain === "edge") {
1649
+ this.interactionHandle = this.structural("edge", this.interactionHandle, {
1650
+ name: "interaction",
1651
+ dtype: "string",
1652
+ nullable: true,
1653
+ origin: { format: CX_FORMAT, id: "i", type: "string" },
1654
+ });
1655
+ return this.interactionHandle;
1656
+ }
1657
+ if (name === "name") {
1658
+ this.nameHandle = this.structural("node", this.nameHandle, {
1659
+ name: "name",
1660
+ dtype: "string",
1661
+ role: "label",
1662
+ nullable: true,
1663
+ origin: { format: CX_FORMAT, id: "n", type: "string" },
1664
+ });
1665
+ return this.nameHandle;
1666
+ }
1667
+ this.representsHandle = this.structural("node", this.representsHandle, {
1668
+ name: "represents",
1669
+ dtype: "string",
1670
+ nullable: true,
1671
+ origin: { format: CX_FORMAT, id: "r", type: "string" },
1672
+ });
1673
+ return this.representsHandle;
1674
+ }
1675
+
1676
+ // ------------------------------------------------------------ nodes, groups, layout
1677
+
1678
+ /** Read the nodes of this graph. */
1679
+ private readNodes(): void {
1680
+ const { report, sink } = this;
1681
+ for (const held of aspect(this.doc, "nodes")) {
1682
+ this.checkAbort();
1683
+ const { value, line } = held;
1684
+ if (!isRecord(value)) {
1685
+ report.error(
1686
+ "parse-error",
1687
+ BAD_ASPECT_BLOCK_CODE,
1688
+ `a nodes element is ${shown(value)}, not an object`,
1689
+ {
1690
+ line,
1691
+ element: "nodes",
1692
+ },
1693
+ );
1694
+ report.counts.skippedNodes++;
1695
+ continue;
1696
+ }
1697
+ if (value["@id"] === undefined || value["@id"] === null) {
1698
+ report.error("missing-value", MISSING_ID_CODE, "a node has no @id", { line, element: "nodes" });
1699
+ report.counts.skippedNodes++;
1700
+ continue;
1701
+ }
1702
+ const element = `node ${shown(value["@id"])}`;
1703
+ const id = this.idOf(value["@id"], (held.inexact & 1) !== 0, element);
1704
+ if (id === null) {
1705
+ report.counts.skippedNodes++;
1706
+ continue;
1707
+ }
1708
+ if (!this.inGraph(id)) {
1709
+ continue;
1710
+ }
1711
+ let row = this.nodeRows.get(id);
1712
+ if (row !== undefined) {
1713
+ report.warning(
1714
+ "merged",
1715
+ DUPLICATE_NODE_CODE,
1716
+ `${element} is declared more than once; its fields are merged (the later values win)`,
1717
+ { line, element },
1718
+ );
1719
+ } else {
1720
+ try {
1721
+ row = sink.addNode(id);
1722
+ } catch (err) {
1723
+ report.recordError(err, { line, element });
1724
+ report.counts.skippedNodes++;
1725
+ continue;
1726
+ }
1727
+ this.nodeRows.set(id, row);
1728
+ report.counts.nodes++;
1729
+ }
1730
+ this.writeCore("node", row, "name", value.n, element, line);
1731
+ this.writeCore("node", row, "represents", value.r, element, line);
1732
+ }
1733
+ }
1734
+
1735
+ /**
1736
+ * Write a core string field (n, r, i).
1737
+ * @param domain - node or edge
1738
+ * @param row - the row
1739
+ * @param name - the column
1740
+ * @param raw - the value
1741
+ * @param element - the element name
1742
+ * @param line - its line
1743
+ */
1744
+ private writeCore(
1745
+ domain: "node" | "edge",
1746
+ row: number,
1747
+ name: string,
1748
+ raw: unknown,
1749
+ element: string,
1750
+ line: number,
1751
+ ): void {
1752
+ if (raw === undefined || raw === null) {
1753
+ return;
1754
+ }
1755
+ if (typeof raw !== "string") {
1756
+ this.report.error(
1757
+ "validation-error",
1758
+ BAD_VALUE_CODE,
1759
+ `${element}: ${name} is ${shown(raw)}, not a string`,
1760
+ {
1761
+ line,
1762
+ element,
1763
+ },
1764
+ );
1765
+ return;
1766
+ }
1767
+ const handle = this.coreColumn(domain, name);
1768
+ if (domain === "node") {
1769
+ this.sink.setNodeValue(handle, row, raw);
1770
+ } else {
1771
+ this.sink.setEdgeValue(handle, row, raw);
1772
+ }
1773
+ const key = `${domain}:${name}`;
1774
+ let values = this.coreValues.get(key);
1775
+ if (values === undefined) {
1776
+ values = new Map();
1777
+ this.coreValues.set(key, values);
1778
+ }
1779
+ values.set(row, raw);
1780
+ }
1781
+
1782
+ /**
1783
+ * Read cyGroups: a group's members get the group node as their parent (parents when a node is in
1784
+ * several groups); a group whose id is not a node gets one (W_CX_GROUP_NODE_ADDED); collapsed is
1785
+ * a bool column on the group node.
1786
+ */
1787
+ private readGroups(): void {
1788
+ const groups = aspect(this.doc, "cyGroups");
1789
+ if (groups.length === 0) {
1790
+ return;
1791
+ }
1792
+ const parents = new Map<number, number[]>();
1793
+ const collapsed: [number, boolean][] = [];
1794
+ for (const { value, line } of groups) {
1795
+ if (!isRecord(value)) {
1796
+ continue;
1797
+ }
1798
+ const id = refId(value["@id"]);
1799
+ if (id === null) {
1800
+ this.report.error("missing-value", MISSING_ID_CODE, "a cyGroups element has no @id", {
1801
+ line,
1802
+ element: "cyGroups",
1803
+ });
1804
+ continue;
1805
+ }
1806
+ let groupRow = this.nodeRows.get(id);
1807
+ if (groupRow === undefined) {
1808
+ if (this.rootNodes.has(id) || !this.hasMemberIn(value.nodes)) {
1809
+ continue;
1810
+ }
1811
+ try {
1812
+ groupRow = this.sink.addNode(id);
1813
+ } catch (err) {
1814
+ this.report.recordError(err, { line, element: String(id) });
1815
+ continue;
1816
+ }
1817
+ this.nodeRows.set(id, groupRow);
1818
+ this.report.counts.nodes++;
1819
+ this.report.warning(
1820
+ "coercion",
1821
+ CX_ISSUE.GROUP_NODE_ADDED,
1822
+ `group ${String(id)} is not a node; its group node is added`,
1823
+ { line, element: String(id) },
1824
+ );
1825
+ }
1826
+ if (typeof value.n === "string" && this.coreValue("node", groupRow, "name") === undefined) {
1827
+ this.writeCore("node", groupRow, "name", value.n, `group ${String(id)}`, line);
1828
+ }
1829
+ if (typeof value.collapsed === "boolean") {
1830
+ collapsed.push([groupRow, value.collapsed]);
1831
+ }
1832
+ for (const raw of Array.isArray(value.nodes) ? (value.nodes as unknown[]) : []) {
1833
+ const member = refId(raw);
1834
+ const row = member === null ? undefined : this.nodeRows.get(member);
1835
+ if (row === undefined) {
1836
+ if (member === null || !this.rootNodes.has(member)) {
1837
+ this.report.error(
1838
+ "missing-value",
1839
+ UNKNOWN_PARENT_CODE,
1840
+ `group ${String(id)}: the member ${shown(raw)} is not a node`,
1841
+ { line, element: String(id) },
1842
+ );
1843
+ }
1844
+ continue;
1845
+ }
1846
+ if (this.closesCycle(parents, row, groupRow)) {
1847
+ this.report.error(
1848
+ "validation-error",
1849
+ PARENT_CYCLE_CODE,
1850
+ `group ${String(id)}: making ${shown(raw)} a member would close a parent cycle; that membership is dropped`,
1851
+ { line, element: String(id) },
1852
+ );
1853
+ continue;
1854
+ }
1855
+ const list = parents.get(row) ?? [];
1856
+ if (!list.includes(groupRow)) {
1857
+ list.push(groupRow);
1858
+ }
1859
+ parents.set(row, list);
1860
+ }
1861
+ }
1862
+ this.writeParents(parents);
1863
+ if (collapsed.length > 0) {
1864
+ const { handle } = declareResolved(
1865
+ this.sink,
1866
+ "node",
1867
+ {
1868
+ name: "collapsed",
1869
+ dtype: "bool",
1870
+ nullable: true,
1871
+ origin: { format: CX_FORMAT, namespace: "cyGroups" },
1872
+ },
1873
+ this.report,
1874
+ );
1875
+ for (const [row, flag] of collapsed) {
1876
+ this.sink.setNodeValue(handle, row, flag);
1877
+ }
1878
+ }
1879
+ }
1880
+
1881
+ /**
1882
+ * Whether a member list names a node of this graph.
1883
+ * @param raw - the list
1884
+ * @returns true when one member is in the graph
1885
+ */
1886
+ private hasMemberIn(raw: unknown): boolean {
1887
+ return (
1888
+ Array.isArray(raw) &&
1889
+ raw.some((item) => {
1890
+ const id = refId(item);
1891
+ return id !== null && this.nodeRows.has(id);
1892
+ })
1893
+ );
1894
+ }
1895
+
1896
+ /**
1897
+ * Whether making `child` a member of `group` closes a cycle: the group already is, through its
1898
+ * own parents, below the child.
1899
+ * @param parents - the memberships so far
1900
+ * @param child - the member row
1901
+ * @param group - the group row
1902
+ * @returns true for a cycle
1903
+ */
1904
+ private closesCycle(parents: ReadonlyMap<number, readonly number[]>, child: number, group: number): boolean {
1905
+ const stack = [group];
1906
+ const seen = new Set<number>();
1907
+ while (stack.length > 0) {
1908
+ const row = stack.pop() as number;
1909
+ if (row === child) {
1910
+ return true;
1911
+ }
1912
+ if (seen.has(row)) {
1913
+ continue;
1914
+ }
1915
+ seen.add(row);
1916
+ stack.push(...(parents.get(row) ?? []));
1917
+ }
1918
+ return false;
1919
+ }
1920
+
1921
+ /**
1922
+ * Write the group memberships: a parent column, or a parents list column when a node is in
1923
+ * several groups.
1924
+ * @param parents - member row -> group rows
1925
+ */
1926
+ private writeParents(parents: ReadonlyMap<number, readonly number[]>): void {
1927
+ if (parents.size === 0) {
1928
+ return;
1929
+ }
1930
+ const several = [...parents.values()].some((list) => list.length > 1);
1931
+ const decl: ColumnDecl = several
1932
+ ? { name: "parents", dtype: "list", itemDtype: "u32", role: "parents", refersTo: "node", nullable: true }
1933
+ : { name: "parent", dtype: "u32", role: "parent", refersTo: "node", nullable: true };
1934
+ const { handle } = declareResolved(this.sink, "node", decl, this.report);
1935
+ for (const [row, list] of parents) {
1936
+ this.sink.setNodeValue(handle, row, several ? list : list[0]);
1937
+ }
1938
+ }
1939
+
1940
+ /**
1941
+ * Read cartesianLayout for this graph's views: the first view is the position (y flipped), z the
1942
+ * z column, every other view a position@n column.
1943
+ */
1944
+ private readLayout(): void {
1945
+ const entries = aspect(this.doc, "cartesianLayout");
1946
+ if (entries.length === 0) {
1947
+ return;
1948
+ }
1949
+ const { views } = this.plan;
1950
+ const knownViews = this.allViews();
1951
+ const columns = new Map<number, ColumnHandle>();
1952
+ const zHandle = { current: INVALID_INDEX as ColumnHandle };
1953
+ const seen = new Set<string>();
1954
+ const point: [number, number, number] = [0, 0, 0];
1955
+ for (const { value, line } of entries) {
1956
+ this.checkAbort();
1957
+ if (!isRecord(value)) {
1958
+ continue;
1959
+ }
1960
+ const view = value.view === undefined || value.view === null ? null : refId(value.view);
1961
+ if (view !== null && knownViews.size > 0 && !knownViews.has(view)) {
1962
+ this.dangle("layout view reference");
1963
+ continue;
1964
+ }
1965
+ const slot = view === null ? 0 : views.indexOf(view);
1966
+ if (slot < 0) {
1967
+ continue;
1968
+ }
1969
+ if (typeof value.x !== "number" || typeof value.y !== "number") {
1970
+ this.report.error(
1971
+ "validation-error",
1972
+ BAD_VALUE_CODE,
1973
+ `a cartesianLayout element for node ${shown(value.node)} has no numeric x and y; skipped`,
1974
+ { line, element: "cartesianLayout" },
1975
+ );
1976
+ continue;
1977
+ }
1978
+ const node = refId(value.node);
1979
+ const row = node === null ? undefined : this.nodeRows.get(node);
1980
+ if (row === undefined) {
1981
+ if (node === null || !this.rootNodes.has(node)) {
1982
+ this.dangle("layout entry");
1983
+ }
1984
+ continue;
1985
+ }
1986
+ const key = `${slot}:${row}`;
1987
+ if (seen.has(key)) {
1988
+ this.report.warnOnce(
1989
+ "validation-error",
1990
+ DUPLICATE_ATTRIBUTE_CODE,
1991
+ `node ${shown(value.node)} has two layout entries for one view; the later wins`,
1992
+ { line, element: "cartesianLayout" },
1993
+ `${DUPLICATE_ATTRIBUTE_CODE}:layout`,
1994
+ );
1995
+ }
1996
+ seen.add(key);
1997
+ const z = typeof value.z === "number" ? value.z : null;
1998
+ point[0] = value.x;
1999
+ point[1] = flipY(value.y);
2000
+ point[2] = slot === 0 && this.zAs === "position" && z !== null ? z : 0;
2001
+ this.sink.setNodeValue(this.layoutColumn(columns, slot, views), row, point);
2002
+ if (slot === 0 && z !== null && this.zAs === "column") {
2003
+ zHandle.current = this.structural("node", zHandle.current, zDecl(CX_FORMAT));
2004
+ this.sink.setNodeValue(zHandle.current, row, z);
2005
+ }
2006
+ }
2007
+ }
2008
+
2009
+ /**
2010
+ * Every view id the document names.
2011
+ * @returns the set (empty when the document names none)
2012
+ */
2013
+ private allViews(): Set<NodeId> {
2014
+ const out = new Set<NodeId>();
2015
+ for (const { value } of aspect(this.doc, "cyNetworkRelations")) {
2016
+ if (isRecord(value) && value.r === "view") {
2017
+ const id = refId(value.c);
2018
+ if (id !== null) {
2019
+ out.add(id);
2020
+ }
2021
+ }
2022
+ }
2023
+ for (const { value } of aspect(this.doc, "cyViews")) {
2024
+ const id = isRecord(value) ? refId(value["@id"]) : null;
2025
+ if (id !== null) {
2026
+ out.add(id);
2027
+ }
2028
+ }
2029
+ return out;
2030
+ }
2031
+
2032
+ /**
2033
+ * The position column of a view slot, declared on first use.
2034
+ * @param columns - slot -> handle
2035
+ * @param slot - 0 for the position, n - 1 for position@n
2036
+ * @param views - the graph's views
2037
+ * @returns the handle
2038
+ */
2039
+ private layoutColumn(columns: Map<number, ColumnHandle>, slot: number, views: readonly NodeId[]): ColumnHandle {
2040
+ let handle = columns.get(slot);
2041
+ if (handle === undefined) {
2042
+ const base = positionDecl(CX_FORMAT, this.zAs === "position" && slot === 0 ? 3 : 2);
2043
+ const decl: ColumnDecl =
2044
+ slot === 0
2045
+ ? base
2046
+ : {
2047
+ ...base,
2048
+ name: `position@${slot + 1}`,
2049
+ role: undefined,
2050
+ origin: { format: CX_FORMAT, namespace: "cytoscape", id: String(views[slot]) },
2051
+ };
2052
+ ({ handle } = declareResolved(this.sink, "node", decl, this.report));
2053
+ columns.set(slot, handle);
2054
+ }
2055
+ return handle;
2056
+ }
2057
+
2058
+ // ------------------------------------------------------------ edges
2059
+
2060
+ /** Read the edges of this graph. */
2061
+ private readEdges(): void {
2062
+ const { report, sink } = this;
2063
+ const members = this.plan.edges;
2064
+ for (const held of aspect(this.doc, "edges")) {
2065
+ this.checkAbort();
2066
+ const { value, line } = held;
2067
+ if (!isRecord(value)) {
2068
+ report.error(
2069
+ "parse-error",
2070
+ BAD_ASPECT_BLOCK_CODE,
2071
+ `an edges element is ${shown(value)}, not an object`,
2072
+ {
2073
+ line,
2074
+ element: "edges",
2075
+ },
2076
+ );
2077
+ report.counts.skippedEdges++;
2078
+ continue;
2079
+ }
2080
+ if (value["@id"] === undefined || value["@id"] === null) {
2081
+ report.error("missing-value", MISSING_ID_CODE, "an edge has no @id", { line, element: "edges" });
2082
+ report.counts.skippedEdges++;
2083
+ continue;
2084
+ }
2085
+ const element = `edge ${shown(value["@id"])}`;
2086
+ const id = this.idOf(value["@id"], (held.inexact & 1) !== 0, element, true);
2087
+ if (id === null) {
2088
+ report.counts.skippedEdges++;
2089
+ continue;
2090
+ }
2091
+ if (members !== null && !members.has(id)) {
2092
+ continue;
2093
+ }
2094
+ if (value.s === undefined || value.s === null || value.t === undefined || value.t === null) {
2095
+ report.error(
2096
+ "missing-value",
2097
+ MISSING_ENDPOINT_CODE,
2098
+ `${element} has no ${value.s === undefined || value.s === null ? "s" : "t"}`,
2099
+ {
2100
+ line,
2101
+ element,
2102
+ },
2103
+ );
2104
+ report.counts.skippedEdges++;
2105
+ continue;
2106
+ }
2107
+ const s = this.idOf(value.s, (held.inexact & 2) !== 0, element);
2108
+ const t = s === null ? null : this.idOf(value.t, (held.inexact & 4) !== 0, element);
2109
+ if (s === null || t === null) {
2110
+ report.counts.skippedEdges++;
2111
+ continue;
2112
+ }
2113
+ if (this.edgeRows.has(id)) {
2114
+ report.error(
2115
+ "validation-error",
2116
+ DUPLICATE_EDGE_ID_CODE,
2117
+ `${element} is declared more than once; the later edge is skipped`,
2118
+ { line, element },
2119
+ );
2120
+ report.counts.skippedEdges++;
2121
+ continue;
2122
+ }
2123
+ if (!this.endpoint(s, element, line) || !this.endpoint(t, element, line)) {
2124
+ report.counts.skippedEdges++;
2125
+ continue;
2126
+ }
2127
+ let weight: number | undefined;
2128
+ try {
2129
+ weight = this.weightOf(id);
2130
+ } catch (err) {
2131
+ report.recordError(err, { line, element });
2132
+ report.counts.skippedEdges++;
2133
+ continue;
2134
+ }
2135
+ const before = sink.edgeCount;
2136
+ let edge: number;
2137
+ try {
2138
+ edge = this.direction.addEdge(s, t, "directed", weight, { line, element });
2139
+ } catch (err) {
2140
+ report.recordError(err, { line, element });
2141
+ report.counts.skippedEdges++;
2142
+ continue;
2143
+ }
2144
+ if (weight !== undefined) {
2145
+ this.weighted = true;
2146
+ }
2147
+ report.counts.edges += sink.edgeCount - before;
2148
+ this.edgeRows.set(id, edge);
2149
+ this.edgeIdHandle = this.structural("edge", this.edgeIdHandle, {
2150
+ name: uniqueColumnName("id", "cx", (n) => sink.edgeColumn(n) !== INVALID_INDEX),
2151
+ dtype: "f64",
2152
+ role: "id",
2153
+ nullable: true,
2154
+ });
2155
+ sink.setEdgeValue(this.edgeIdHandle, edge, typeof id === "number" ? id : Number(id));
2156
+ this.writeCore("edge", edge, "interaction", value.i, element, line);
2157
+ }
2158
+ }
2159
+
2160
+ /**
2161
+ * Whether an endpoint is a node of this graph; under addMissingNodes it is created.
2162
+ * @param id - the endpoint
2163
+ * @param element - the edge name
2164
+ * @param line - its line
2165
+ * @returns false when the edge is to be skipped (reported)
2166
+ */
2167
+ private endpoint(id: NodeId, element: string, line: number): boolean {
2168
+ if (this.nodeRows.has(id)) {
2169
+ return true;
2170
+ }
2171
+ if (!this.options.addMissingNodes) {
2172
+ this.report.error(
2173
+ "missing-value",
2174
+ CX_ISSUE.UNKNOWN_NODE,
2175
+ `${element}: the endpoint ${String(id)} is not a node of the graph; the edge is skipped`,
2176
+ { line, element },
2177
+ );
2178
+ return false;
2179
+ }
2180
+ try {
2181
+ this.nodeRows.set(id, this.sink.addNode(id));
2182
+ } catch (err) {
2183
+ this.report.recordError(err, { line, element });
2184
+ return false;
2185
+ }
2186
+ this.report.counts.nodes++;
2187
+ return true;
2188
+ }
2189
+
2190
+ /** The weight attribute values of the edges, by edge id (built once). */
2191
+ private weights: Map<NodeId, unknown> | null = null;
2192
+
2193
+ /**
2194
+ * The weight of an edge: its weightFrom attribute, parsed as a number.
2195
+ * @param id - the edge id
2196
+ * @returns the weight, or undefined; E_INVALID_WEIGHT for a non-number
2197
+ */
2198
+ private weightOf(id: NodeId): number | undefined {
2199
+ const { weightFrom } = this.options;
2200
+ if (weightFrom === null) {
2201
+ return undefined;
2202
+ }
2203
+ if (this.weights === null) {
2204
+ this.weights = new Map();
2205
+ for (const { value } of this.doc.attributes.edge.get(weightFrom) ?? []) {
2206
+ if (isRecord(value) && this.scopePeek(value.s) !== 0) {
2207
+ const po = refId(value.po);
2208
+ if (po !== null && value.v !== undefined && value.v !== null) {
2209
+ this.weights.set(po, plainJson(value.v));
2210
+ }
2211
+ }
2212
+ }
2213
+ }
2214
+ const raw = this.weights.get(id);
2215
+ if (typeof raw === "string" && isNullText(raw)) {
2216
+ return undefined;
2217
+ }
2218
+ return weightFromValue(raw);
2219
+ }
2220
+
2221
+ // ------------------------------------------------------------ network, visual properties, provenance
2222
+
2223
+ /** Write the network attributes (unscoped and this subnetwork's, its own winning) as graph columns. */
2224
+ private readNetworkAttributes(): void {
2225
+ for (const [name, elements] of this.doc.attributes.network) {
2226
+ let chosen: Record<string, unknown> | null = null;
2227
+ let chosenScope = 0;
2228
+ for (const { value } of elements) {
2229
+ if (!isRecord(value)) {
2230
+ continue;
2231
+ }
2232
+ const scope = this.scopeOf(value.s);
2233
+ if (scope !== 0 && scope >= chosenScope) {
2234
+ chosen = value;
2235
+ chosenScope = scope;
2236
+ }
2237
+ }
2238
+ if (chosen === null || chosen.v === undefined || chosen.v === null || name === "") {
2239
+ continue;
2240
+ }
2241
+ const type = cxType(chosen.d);
2242
+ if (type === null) {
2243
+ this.report.warnOnce(
2244
+ "unsupported",
2245
+ UNKNOWN_ATTR_TYPE_CODE,
2246
+ `the network attribute "${name}" declares a data type CX does not define; kept as text`,
2247
+ { element: name },
2248
+ `${UNKNOWN_ATTR_TYPE_CODE}:network:${name}`,
2249
+ );
2250
+ }
2251
+ const effective = type ?? { scalar: "string", list: false };
2252
+ const parsed = parseValue(chosen.v, effective, this.options.long, (digits) => {
2253
+ this.precision(name, digits);
2254
+ });
2255
+ if (parsed === UNSET) {
2256
+ continue;
2257
+ }
2258
+ if (parsed === BAD) {
2259
+ this.report.error(
2260
+ "validation-error",
2261
+ BAD_VALUE_CODE,
2262
+ `the network attribute "${name}" is ${shown(chosen.v)}, not a ${typeText(effective)}; unset`,
2263
+ { element: name },
2264
+ );
2265
+ continue;
2266
+ }
2267
+ if ((name === "name" || name === "description") && typeof parsed === "string") {
2268
+ continue;
2269
+ }
2270
+ const scalar = scalarDtype(effective.scalar, this.options.long);
2271
+ try {
2272
+ this.sink.setGraphValue(name, parsed, {
2273
+ dtype: effective.list ? "list" : scalar,
2274
+ ...(effective.list ? { itemDtype: scalar } : {}),
2275
+ origin: { format: CX_FORMAT, id: null, type: typeText(effective) },
2276
+ });
2277
+ } catch (err) {
2278
+ this.report.recordError(err, { element: name });
2279
+ }
2280
+ }
2281
+ }
2282
+
2283
+ /**
2284
+ * Read cyVisualProperties for this graph's views: per-element and network values become columns
2285
+ * (origin namespace cx.bypass); defaults, mappings and dependencies are style rules, kept and not
2286
+ * applied.
2287
+ */
2288
+ private readVisualProperties(): void {
2289
+ const entries = aspect(this.doc, "cyVisualProperties");
2290
+ // Cytoscape's table-cell styles: a visual property aspect graph-io keeps but does not read
2291
+ const tableStyles = TABLE_STYLE_ASPECTS.reduce((n, name) => n + (this.doc.kept.get(name)?.length ?? 0), 0);
2292
+ // the graph's own view (its first): the one its positions come from; the others stay in meta.extra.cx
2293
+ const views = new Set(this.plan.views.slice(0, 1));
2294
+ const columns = { node: new Map<string, [number, string][]>(), edge: new Map<string, [number, string][]>() };
2295
+ const network = new Map<string, string>();
2296
+ let defaults = 0;
2297
+ let mappings = 0;
2298
+ let dependencies = 0;
2299
+ for (const { value } of entries) {
2300
+ if (!isRecord(value)) {
2301
+ continue;
2302
+ }
2303
+ const view = value.view === undefined || value.view === null ? null : refId(value.view);
2304
+ if (view !== null && views.size > 0 && !views.has(view)) {
2305
+ continue;
2306
+ }
2307
+ const properties = isRecord(value.properties) ? value.properties : {};
2308
+ mappings += isRecord(value.mappings) ? Object.keys(value.mappings).length : 0;
2309
+ dependencies += isRecord(value.dependencies) ? Object.keys(value.dependencies).length : 0;
2310
+ switch (value.properties_of) {
2311
+ case "nodes":
2312
+ case "edges": {
2313
+ const domain = value.properties_of === "nodes" ? "node" : "edge";
2314
+ const target = refId(value.applies_to);
2315
+ const row =
2316
+ target === null ? undefined : (domain === "node" ? this.nodeRows : this.edgeRows).get(target);
2317
+ if (row === undefined) {
2318
+ if (target === null || !(domain === "node" ? this.rootNodes : this.rootEdges).has(target)) {
2319
+ this.dangle(`${domain} visual property entry`);
2320
+ }
2321
+ continue;
2322
+ }
2323
+ for (const [property, raw] of Object.entries(properties)) {
2324
+ const list = columns[domain].get(property) ?? [];
2325
+ list.push([row, typeof raw === "string" ? raw : JSON.stringify(raw)]);
2326
+ columns[domain].set(property, list);
2327
+ }
2328
+ break;
2329
+ }
2330
+ case "network":
2331
+ for (const [property, raw] of Object.entries(properties)) {
2332
+ network.set(property, typeof raw === "string" ? raw : JSON.stringify(raw));
2333
+ }
2334
+ break;
2335
+ default:
2336
+ defaults += Object.keys(properties).length;
2337
+ break;
2338
+ }
2339
+ }
2340
+ for (const domain of ["node", "edge"] as const) {
2341
+ for (const [property, cells] of columns[domain]) {
2342
+ const handle = declareFresh(
2343
+ this.sink,
2344
+ domain,
2345
+ {
2346
+ name: property,
2347
+ dtype: "string",
2348
+ nullable: true,
2349
+ origin: { format: CX_FORMAT, id: null, namespace: CX_BYPASS_NAMESPACE },
2350
+ },
2351
+ this.report,
2352
+ );
2353
+ for (const [row, text] of cells) {
2354
+ if (domain === "node") {
2355
+ this.sink.setNodeValue(handle, row, text);
2356
+ } else {
2357
+ this.sink.setEdgeValue(handle, row, text);
2358
+ }
2359
+ }
2360
+ }
2361
+ }
2362
+ for (const [property, text] of network) {
2363
+ try {
2364
+ this.sink.setGraphValue(property, text, {
2365
+ dtype: "string",
2366
+ origin: { format: CX_FORMAT, id: null, namespace: CX_BYPASS_NAMESPACE },
2367
+ });
2368
+ } catch (err) {
2369
+ this.report.recordError(err, { element: property });
2370
+ }
2371
+ }
2372
+ if (defaults + mappings + dependencies + tableStyles > 0) {
2373
+ const parts = [
2374
+ `cyVisualProperties: ${defaults} default(s), ${mappings} mapping(s), ${dependencies} dependenc(ies)`,
2375
+ ];
2376
+ if (tableStyles > 0) {
2377
+ parts.push(`${tableStyles} table style element(s)`);
2378
+ }
2379
+ this.report.warning(
2380
+ "unsupported",
2381
+ STYLES_NOT_IMPORTED_CODE,
2382
+ `the file's style rules are not applied (${parts.join("; ")}); they are kept in meta.extra.cx (style import is issue #706)`,
2383
+ { element: "cyVisualProperties" },
2384
+ );
2385
+ }
2386
+ }
2387
+
2388
+ /**
2389
+ * Read the provenance aspects: citations and supports as extension tables cx:citations and
2390
+ * cx:supports, the node / edge links to them as list columns, functionTerms as a json column,
2391
+ * reifiedEdges as a u32 column referring to edges.
2392
+ */
2393
+ private readProvenance(): void {
2394
+ for (const name of ["citations", "supports"] as const) {
2395
+ this.extensionTable(`cx:${name}`, aspect(this.doc, name));
2396
+ }
2397
+ for (const [aspectName, domain, column] of [
2398
+ ["nodeCitations", "node", "citations"],
2399
+ ["edgeCitations", "edge", "citations"],
2400
+ ["nodeSupports", "node", "supports"],
2401
+ ["edgeSupports", "edge", "supports"],
2402
+ ] as const) {
2403
+ this.linkColumn(aspectName, domain, column);
2404
+ }
2405
+ const terms: [number, unknown][] = [];
2406
+ for (const { value } of aspect(this.doc, "functionTerms")) {
2407
+ if (!isRecord(value)) {
2408
+ continue;
2409
+ }
2410
+ const po = refId(value.po);
2411
+ const row = po === null ? undefined : this.nodeRows.get(po);
2412
+ if (row === undefined) {
2413
+ if (po === null || !this.rootNodes.has(po)) {
2414
+ this.dangle("functionTerms entry");
2415
+ }
2416
+ continue;
2417
+ }
2418
+ const { po: _po, ...term } = value;
2419
+ terms.push([row, plainJson(term)]);
2420
+ }
2421
+ this.writeColumn("node", "functionTerm", "json", null, terms);
2422
+ const reified: [number, number][] = [];
2423
+ for (const { value } of aspect(this.doc, "reifiedEdges")) {
2424
+ if (!isRecord(value)) {
2425
+ continue;
2426
+ }
2427
+ const node = refId(value.node);
2428
+ const edge = refId(value.edge);
2429
+ const row = node === null ? undefined : this.nodeRows.get(node);
2430
+ const edgeRow = edge === null ? undefined : this.edgeRows.get(edge);
2431
+ if (row === undefined || edgeRow === undefined) {
2432
+ if (node === null || !this.rootNodes.has(node) || edge === null || !this.rootEdges.has(edge)) {
2433
+ this.dangle("reifiedEdges entry");
2434
+ }
2435
+ continue;
2436
+ }
2437
+ reified.push([row, edgeRow]);
2438
+ }
2439
+ if (reified.length > 0) {
2440
+ const { handle } = declareResolved(
2441
+ this.sink,
2442
+ "node",
2443
+ {
2444
+ name: "reifiedEdge",
2445
+ dtype: "u32",
2446
+ refersTo: "edge",
2447
+ nullable: true,
2448
+ origin: { format: CX_FORMAT, namespace: "reifiedEdges" },
2449
+ },
2450
+ this.report,
2451
+ );
2452
+ for (const [row, edge] of reified) {
2453
+ this.sink.setNodeValue(handle, row, edge);
2454
+ }
2455
+ }
2456
+ }
2457
+
2458
+ /**
2459
+ * An extension table of a provenance aspect: an f64 `id` column and one json column per field.
2460
+ * @param name - the table name
2461
+ * @param elements - the elements
2462
+ */
2463
+ private extensionTable(name: string, elements: readonly Held[]): void {
2464
+ const records = elements.map((h) => h.value).filter(isRecord);
2465
+ if (records.length === 0) {
2466
+ return;
2467
+ }
2468
+ const fields = [...new Set(records.flatMap((r) => Object.keys(r)).filter((k) => k !== "@id"))];
2469
+ const decls: ColumnDecl[] = [
2470
+ { name: "id", dtype: "f64", nullable: true },
2471
+ ...fields.map((field): ColumnDecl => ({ name: field, dtype: "json", nullable: true })),
2472
+ ];
2473
+ const table = this.sink.addExtensionTable(name, decls);
2474
+ for (const record of records) {
2475
+ const id = refId(record["@id"]);
2476
+ this.sink.addExtensionRow(table, [
2477
+ id === null ? null : Number(id),
2478
+ ...fields.map((field) => (record[field] === undefined ? null : plainJson(record[field]))),
2479
+ ]);
2480
+ }
2481
+ }
2482
+
2483
+ /**
2484
+ * A list column of the citation or support ids a node or edge links to.
2485
+ * @param aspectName - nodeCitations, edgeCitations, nodeSupports or edgeSupports
2486
+ * @param domain - node or edge
2487
+ * @param column - citations or supports
2488
+ */
2489
+ private linkColumn(aspectName: string, domain: "node" | "edge", column: "citations" | "supports"): void {
2490
+ const rows = domain === "node" ? this.nodeRows : this.edgeRows;
2491
+ const root = domain === "node" ? this.rootNodes : this.rootEdges;
2492
+ const links = new Map<number, number[]>();
2493
+ for (const { value } of aspect(this.doc, aspectName)) {
2494
+ if (!isRecord(value)) {
2495
+ continue;
2496
+ }
2497
+ const targets = Array.isArray(value.po) ? (value.po as unknown[]) : [value.po];
2498
+ const ids = (Array.isArray(value[column]) ? (value[column] as unknown[]) : [])
2499
+ .map(refId)
2500
+ .filter((id): id is NodeId => id !== null)
2501
+ .map(Number);
2502
+ for (const raw of targets) {
2503
+ const target = refId(raw);
2504
+ const row = target === null ? undefined : rows.get(target);
2505
+ if (row === undefined) {
2506
+ if (target === null || !root.has(target)) {
2507
+ this.dangle(`${aspectName} entry`);
2508
+ }
2509
+ continue;
2510
+ }
2511
+ links.set(row, [...(links.get(row) ?? []), ...ids]);
2512
+ }
2513
+ }
2514
+ this.writeColumn(domain, column, "list", "f64", [...links]);
2515
+ }
2516
+
2517
+ /**
2518
+ * Declare a column and write its cells (nothing when there are none).
2519
+ * @param domain - node or edge
2520
+ * @param name - the column name
2521
+ * @param dtype - the dtype
2522
+ * @param itemDtype - the list item dtype, or null
2523
+ * @param cells - row and value pairs
2524
+ */
2525
+ private writeColumn(
2526
+ domain: "node" | "edge",
2527
+ name: string,
2528
+ dtype: Dtype,
2529
+ itemDtype: ScalarDtype | null,
2530
+ cells: readonly (readonly [number, unknown])[],
2531
+ ): void {
2532
+ if (cells.length === 0) {
2533
+ return;
2534
+ }
2535
+ const decl: ColumnDecl = {
2536
+ name,
2537
+ dtype,
2538
+ ...(itemDtype === null ? {} : { itemDtype }),
2539
+ nullable: true,
2540
+ origin: { format: CX_FORMAT, namespace: "provenance" },
2541
+ };
2542
+ const { handle } = declareResolved(this.sink, domain, decl, this.report);
2543
+ for (const [row, value] of cells) {
2544
+ if (domain === "node") {
2545
+ this.sink.setNodeValue(handle, row, value);
2546
+ } else {
2547
+ this.sink.setEdgeValue(handle, row, value);
2548
+ }
2549
+ }
2550
+ }
2551
+
2552
+ /** Record the metadata: source format, name, description, id type, the kept aspects. */
2553
+ private setMeta(): void {
2554
+ const describe = (key: string): string | null => {
2555
+ let found: string | null = null;
2556
+ for (const { value } of this.doc.attributes.network.get(key) ?? []) {
2557
+ if (isRecord(value) && typeof value.v === "string") {
2558
+ const scope = this.scopePeek(value.s);
2559
+ if (scope === 2) {
2560
+ return value.v;
2561
+ }
2562
+ if (scope === 1) {
2563
+ found ??= value.v;
2564
+ }
2565
+ }
2566
+ }
2567
+ return found;
2568
+ };
2569
+ const extra: Record<string, unknown> = Object.fromEntries(this.doc.kept);
2570
+ const groups = aspect(this.doc, "cyGroups").map((h) => plainJson(h.value));
2571
+ if (groups.length > 0) {
2572
+ extra.groups = groups;
2573
+ }
2574
+ if (this.plan.subnetwork !== null) {
2575
+ extra.subnetwork = this.plan.subnetwork;
2576
+ }
2577
+ const { weightFrom } = this.options;
2578
+ const name = this.plan.name ?? describe("name");
2579
+ const description = describe("description");
2580
+ const patch: GraphMetaPatch = {
2581
+ sourceFormat: CX_FORMAT,
2582
+ sourceVersion: "1",
2583
+ idType: this.idType,
2584
+ ...(name === null ? {} : { name }),
2585
+ ...(description === null ? {} : { description }),
2586
+ ...(this.weighted && weightFrom !== null
2587
+ ? { weightOrigin: { format: CX_FORMAT, id: null, title: weightFrom, type: "double", namespace: null } }
2588
+ : {}),
2589
+ extra: { cx: extra },
2590
+ };
2591
+ this.sink.setMeta(patch);
2592
+ }
2593
+ }
2594
+
2595
+ // ============================================================ the plugin
2596
+
2597
+ /**
2598
+ * Read a document, then build the graphs `pick` selects.
2599
+ * @param input - the input
2600
+ * @param options - the caller's options
2601
+ * @param sinkFor - the sink of graph i, or null to skip it
2602
+ * @param pick - which graph indexes to build, given the plans and the first report
2603
+ * @returns one report per built graph
2604
+ */
2605
+ async function run(
2606
+ input: ImportInput,
2607
+ options: (CxImportOptions & CommonImportOptions) | undefined,
2608
+ sinkFor: (index: number) => GraphSink,
2609
+ pick: (plans: readonly GraphPlan[], report: ImportReportBuilder) => readonly number[],
2610
+ ): Promise<ImportReport[]> {
2611
+ const resolved = resolveImportOptions(options, FORMAT_DEFAULTS);
2612
+ const zAs = zAsOption(options?.zAs);
2613
+ const first = new ImportReportBuilder(CX_FORMAT, resolved.errorLimit);
2614
+ reportUnusedOptions(options, first, USED_OPTIONS);
2615
+ const doc = await readDocument(input, first, resolved);
2616
+ const plans = graphPlans(doc);
2617
+ const chosen = pick(plans, first);
2618
+ const reports: ImportReport[] = [];
2619
+ chosen.forEach((index, k) => {
2620
+ const report = k === 0 ? first : new ImportReportBuilder(CX_FORMAT, resolved.errorLimit);
2621
+ const sink = sinkFor(index);
2622
+ reportSinkOptions(sink, options, report, true);
2623
+ new CxReader(sink, report, resolved, zAs, doc, plans[index]).build();
2624
+ throwIfAborted(resolved.signal);
2625
+ reports.push(report.finish());
2626
+ });
2627
+ return reports;
2628
+ }
2629
+
2630
+ /**
2631
+ * The CX version 1 importer plugin (design section 1.2).
2632
+ */
2633
+ export const cxImporter: GraphImporter<CxImportOptions> = Object.freeze({
2634
+ format: CX_FORMAT,
2635
+ extensions: Object.freeze([".cx"]),
2636
+ mimeTypes: Object.freeze(["application/json"]),
2637
+
2638
+ /**
2639
+ * Confidence that the head is CX version 1: 0.95 when a top-level array starts with an object
2640
+ * whose first key is numberVerification or metaData (Cytoscape's file filter), 0.7 for another
2641
+ * CX aspect name.
2642
+ * @param head - the first bytes
2643
+ * @returns the confidence
2644
+ */
2645
+ sniff(head: Uint8Array): number {
2646
+ const text = new TextDecoder("utf-8").decode(head.subarray(0, 1024)).replace(/^\uFEFF/, "");
2647
+ const match = /^\s*\[\s*\{\s*"((?:[^"\\]|\\.)*)"\s*:/.exec(text);
2648
+ if (match === null) {
2649
+ return 0;
2650
+ }
2651
+ if (CX_FIRST_KEYS.includes(match[1])) {
2652
+ return 0.95;
2653
+ }
2654
+ return CX_ASPECT_KEYS.includes(match[1]) ? 0.7 : 0;
2655
+ },
2656
+
2657
+ /**
2658
+ * Read one graph of a CX document into the sink: the root network, or the subnetwork
2659
+ * graphIndex / graphName picks (the first by default; W_MULTIPLE_GRAPHS names the others).
2660
+ * @param input - the text, bytes or stream
2661
+ * @param sink - the sink
2662
+ * @param options - format-specific and common options
2663
+ * @returns the import report; ImportError on a fatal error or beyond the error limit
2664
+ */
2665
+ async import(
2666
+ input: ImportInput,
2667
+ sink: GraphSink,
2668
+ options?: CxImportOptions & CommonImportOptions,
2669
+ ): Promise<ImportReport> {
2670
+ const [report] = await run(
2671
+ input,
2672
+ options,
2673
+ () => sink,
2674
+ (plans, first) => {
2675
+ const index = chooseGraph(
2676
+ plans.map((p) => p.name),
2677
+ options,
2678
+ first,
2679
+ );
2680
+ if (plans.length > 1) {
2681
+ first.warning(
2682
+ "unsupported",
2683
+ MULTIPLE_GRAPHS_CODE,
2684
+ `the collection holds ${plans.length} subnetworks; graph ${index} is read and ${plans.length - 1} skipped (importAll() reads every one)`,
2685
+ );
2686
+ }
2687
+ return [index];
2688
+ },
2689
+ );
2690
+ return report;
2691
+ },
2692
+
2693
+ /**
2694
+ * Read every graph of a CX document: one per subnetwork of a collection, in the order of
2695
+ * cyNetworkRelations; the root network for any other document.
2696
+ * @param input - the text, bytes or stream
2697
+ * @param sinkFor - the sink of the graph with this index, called before its first push
2698
+ * @param options - format-specific and common options
2699
+ * @returns one report per graph
2700
+ */
2701
+ importAll(
2702
+ input: ImportInput,
2703
+ sinkFor: (index: number) => GraphSink,
2704
+ options?: CxImportOptions & CommonImportOptions,
2705
+ ): Promise<ImportReport[]> {
2706
+ return run(input, options, sinkFor, (plans) => plans.map((p) => p.index));
2707
+ },
2708
+
2709
+ /**
2710
+ * List the graphs of a CX document with their names and member counts.
2711
+ * @param input - the text, bytes or stream
2712
+ * @param options - format-specific and common options
2713
+ * @returns one listing per graph
2714
+ */
2715
+ async listGraphs(
2716
+ input: ImportInput,
2717
+ options?: CxImportOptions & CommonImportOptions,
2718
+ ): Promise<readonly GraphListing[]> {
2719
+ const resolved = resolveImportOptions(options, FORMAT_DEFAULTS);
2720
+ const report = new ImportReportBuilder(CX_FORMAT, resolved.errorLimit);
2721
+ const doc = await readDocument(input, report, resolved);
2722
+ const nodes = aspect(doc, "nodes").length;
2723
+ const edges = aspect(doc, "edges").length;
2724
+ return graphPlans(doc).map((plan) =>
2725
+ Object.freeze({
2726
+ index: plan.index,
2727
+ name: plan.name,
2728
+ nodes: memberCount(plan.nodes, nodes),
2729
+ edges: memberCount(plan.edges, edges),
2730
+ }),
2731
+ );
2732
+ },
2733
+ });