@graphty/graph-io 0.0.0 → 0.2.0

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 (339) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +250 -28
  3. package/dist/chunks/children-CL3Cy0ez.js +238 -0
  4. package/dist/chunks/children-CL3Cy0ez.js.map +1 -0
  5. package/dist/chunks/escape-DyI8JofU.js +938 -0
  6. package/dist/chunks/escape-DyI8JofU.js.map +1 -0
  7. package/dist/chunks/importer-CQnJuWJw.js +2987 -0
  8. package/dist/chunks/importer-CQnJuWJw.js.map +1 -0
  9. package/dist/chunks/importer-CpCpfbxr.js +2015 -0
  10. package/dist/chunks/importer-CpCpfbxr.js.map +1 -0
  11. package/dist/chunks/importer-DbnGYr3_.js +2342 -0
  12. package/dist/chunks/importer-DbnGYr3_.js.map +1 -0
  13. package/dist/chunks/importer-GozH8DkN.js +3050 -0
  14. package/dist/chunks/importer-GozH8DkN.js.map +1 -0
  15. package/dist/chunks/records-CGpxszm1.js +605 -0
  16. package/dist/chunks/records-CGpxszm1.js.map +1 -0
  17. package/dist/chunks/text-CajMdVFy.js +189 -0
  18. package/dist/chunks/text-CajMdVFy.js.map +1 -0
  19. package/dist/chunks/writer-DxSKC7TL.js +2842 -0
  20. package/dist/chunks/writer-DxSKC7TL.js.map +1 -0
  21. package/dist/csv.d.ts +1 -0
  22. package/dist/csv.js +1702 -0
  23. package/dist/csv.js.map +1 -0
  24. package/dist/dot.d.ts +1 -0
  25. package/dist/dot.js +8 -0
  26. package/dist/dot.js.map +1 -0
  27. package/dist/gexf.d.ts +1 -0
  28. package/dist/gexf.js +3466 -0
  29. package/dist/gexf.js.map +1 -0
  30. package/dist/gml.d.ts +1 -0
  31. package/dist/gml.js +2647 -0
  32. package/dist/gml.js.map +1 -0
  33. package/dist/graph-io.d.ts +1 -0
  34. package/dist/graph-io.js +790 -0
  35. package/dist/graph-io.js.map +1 -0
  36. package/dist/graphml.d.ts +1 -0
  37. package/dist/graphml.js +8 -0
  38. package/dist/graphml.js.map +1 -0
  39. package/dist/json.d.ts +1 -0
  40. package/dist/json.js +11 -0
  41. package/dist/json.js.map +1 -0
  42. package/dist/neo4j.d.ts +1 -0
  43. package/dist/neo4j.js +2046 -0
  44. package/dist/neo4j.js.map +1 -0
  45. package/dist/pajek.d.ts +1 -0
  46. package/dist/pajek.js +8 -0
  47. package/dist/pajek.js.map +1 -0
  48. package/dist/src/children.d.ts +134 -0
  49. package/dist/src/children.d.ts.map +1 -0
  50. package/dist/src/children.js +274 -0
  51. package/dist/src/children.js.map +1 -0
  52. package/dist/src/common/attributes.d.ts +229 -0
  53. package/dist/src/common/attributes.d.ts.map +1 -0
  54. package/dist/src/common/attributes.js +368 -0
  55. package/dist/src/common/attributes.js.map +1 -0
  56. package/dist/src/common/codes.d.ts +105 -0
  57. package/dist/src/common/codes.d.ts.map +1 -0
  58. package/dist/src/common/codes.js +107 -0
  59. package/dist/src/common/codes.js.map +1 -0
  60. package/dist/src/common/declared-types.d.ts +84 -0
  61. package/dist/src/common/declared-types.d.ts.map +1 -0
  62. package/dist/src/common/declared-types.js +326 -0
  63. package/dist/src/common/declared-types.js.map +1 -0
  64. package/dist/src/common/direction.d.ts +206 -0
  65. package/dist/src/common/direction.d.ts.map +1 -0
  66. package/dist/src/common/direction.js +370 -0
  67. package/dist/src/common/direction.js.map +1 -0
  68. package/dist/src/common/escape.d.ts +92 -0
  69. package/dist/src/common/escape.d.ts.map +1 -0
  70. package/dist/src/common/escape.js +212 -0
  71. package/dist/src/common/escape.js.map +1 -0
  72. package/dist/src/common/export.d.ts +249 -0
  73. package/dist/src/common/export.d.ts.map +1 -0
  74. package/dist/src/common/export.js +594 -0
  75. package/dist/src/common/export.js.map +1 -0
  76. package/dist/src/common/format.d.ts +59 -0
  77. package/dist/src/common/format.d.ts.map +1 -0
  78. package/dist/src/common/format.js +106 -0
  79. package/dist/src/common/format.js.map +1 -0
  80. package/dist/src/common/ids.d.ts +83 -0
  81. package/dist/src/common/ids.d.ts.map +1 -0
  82. package/dist/src/common/ids.js +158 -0
  83. package/dist/src/common/ids.js.map +1 -0
  84. package/dist/src/common/input.d.ts +100 -0
  85. package/dist/src/common/input.d.ts.map +1 -0
  86. package/dist/src/common/input.js +335 -0
  87. package/dist/src/common/input.js.map +1 -0
  88. package/dist/src/common/lists.d.ts +34 -0
  89. package/dist/src/common/lists.d.ts.map +1 -0
  90. package/dist/src/common/lists.js +185 -0
  91. package/dist/src/common/lists.js.map +1 -0
  92. package/dist/src/common/options.d.ts +108 -0
  93. package/dist/src/common/options.d.ts.map +1 -0
  94. package/dist/src/common/options.js +265 -0
  95. package/dist/src/common/options.js.map +1 -0
  96. package/dist/src/common/report.d.ts +187 -0
  97. package/dist/src/common/report.d.ts.map +1 -0
  98. package/dist/src/common/report.js +274 -0
  99. package/dist/src/common/report.js.map +1 -0
  100. package/dist/src/common/temporal.d.ts +71 -0
  101. package/dist/src/common/temporal.d.ts.map +1 -0
  102. package/dist/src/common/temporal.js +266 -0
  103. package/dist/src/common/temporal.js.map +1 -0
  104. package/dist/src/common/text.d.ts +104 -0
  105. package/dist/src/common/text.d.ts.map +1 -0
  106. package/dist/src/common/text.js +255 -0
  107. package/dist/src/common/text.js.map +1 -0
  108. package/dist/src/common/weights.d.ts +77 -0
  109. package/dist/src/common/weights.d.ts.map +1 -0
  110. package/dist/src/common/weights.js +156 -0
  111. package/dist/src/common/weights.js.map +1 -0
  112. package/dist/src/common/writer.d.ts +51 -0
  113. package/dist/src/common/writer.d.ts.map +1 -0
  114. package/dist/src/common/writer.js +108 -0
  115. package/dist/src/common/writer.js.map +1 -0
  116. package/dist/src/common/xml.d.ts +245 -0
  117. package/dist/src/common/xml.d.ts.map +1 -0
  118. package/dist/src/common/xml.js +942 -0
  119. package/dist/src/common/xml.js.map +1 -0
  120. package/dist/src/formats/csv/exporter.d.ts +70 -0
  121. package/dist/src/formats/csv/exporter.d.ts.map +1 -0
  122. package/dist/src/formats/csv/exporter.js +682 -0
  123. package/dist/src/formats/csv/exporter.js.map +1 -0
  124. package/dist/src/formats/csv/header.d.ts +66 -0
  125. package/dist/src/formats/csv/header.d.ts.map +1 -0
  126. package/dist/src/formats/csv/header.js +152 -0
  127. package/dist/src/formats/csv/header.js.map +1 -0
  128. package/dist/src/formats/csv/importer.d.ts +82 -0
  129. package/dist/src/formats/csv/importer.d.ts.map +1 -0
  130. package/dist/src/formats/csv/importer.js +849 -0
  131. package/dist/src/formats/csv/importer.js.map +1 -0
  132. package/dist/src/formats/csv/index.d.ts +60 -0
  133. package/dist/src/formats/csv/index.d.ts.map +1 -0
  134. package/dist/src/formats/csv/index.js +63 -0
  135. package/dist/src/formats/csv/index.js.map +1 -0
  136. package/dist/src/formats/csv/records.d.ts +188 -0
  137. package/dist/src/formats/csv/records.d.ts.map +1 -0
  138. package/dist/src/formats/csv/records.js +702 -0
  139. package/dist/src/formats/csv/records.js.map +1 -0
  140. package/dist/src/formats/csv/values.d.ts +105 -0
  141. package/dist/src/formats/csv/values.d.ts.map +1 -0
  142. package/dist/src/formats/csv/values.js +192 -0
  143. package/dist/src/formats/csv/values.js.map +1 -0
  144. package/dist/src/formats/dot/exporter.d.ts +52 -0
  145. package/dist/src/formats/dot/exporter.d.ts.map +1 -0
  146. package/dist/src/formats/dot/exporter.js +836 -0
  147. package/dist/src/formats/dot/exporter.js.map +1 -0
  148. package/dist/src/formats/dot/importer.d.ts +102 -0
  149. package/dist/src/formats/dot/importer.d.ts.map +1 -0
  150. package/dist/src/formats/dot/importer.js +1291 -0
  151. package/dist/src/formats/dot/importer.js.map +1 -0
  152. package/dist/src/formats/dot/index.d.ts +7 -0
  153. package/dist/src/formats/dot/index.d.ts.map +1 -0
  154. package/dist/src/formats/dot/index.js +7 -0
  155. package/dist/src/formats/dot/index.js.map +1 -0
  156. package/dist/src/formats/dot/names.d.ts +29 -0
  157. package/dist/src/formats/dot/names.d.ts.map +1 -0
  158. package/dist/src/formats/dot/names.js +28 -0
  159. package/dist/src/formats/dot/names.js.map +1 -0
  160. package/dist/src/formats/dot/tokenizer.d.ts +114 -0
  161. package/dist/src/formats/dot/tokenizer.d.ts.map +1 -0
  162. package/dist/src/formats/dot/tokenizer.js +341 -0
  163. package/dist/src/formats/dot/tokenizer.js.map +1 -0
  164. package/dist/src/formats/gexf/exporter.d.ts +56 -0
  165. package/dist/src/formats/gexf/exporter.d.ts.map +1 -0
  166. package/dist/src/formats/gexf/exporter.js +1395 -0
  167. package/dist/src/formats/gexf/exporter.js.map +1 -0
  168. package/dist/src/formats/gexf/importer.d.ts +73 -0
  169. package/dist/src/formats/gexf/importer.d.ts.map +1 -0
  170. package/dist/src/formats/gexf/importer.js +1880 -0
  171. package/dist/src/formats/gexf/importer.js.map +1 -0
  172. package/dist/src/formats/gexf/index.d.ts +96 -0
  173. package/dist/src/formats/gexf/index.d.ts.map +1 -0
  174. package/dist/src/formats/gexf/index.js +97 -0
  175. package/dist/src/formats/gexf/index.js.map +1 -0
  176. package/dist/src/formats/gexf/schema.d.ts +135 -0
  177. package/dist/src/formats/gexf/schema.d.ts.map +1 -0
  178. package/dist/src/formats/gexf/schema.js +323 -0
  179. package/dist/src/formats/gexf/schema.js.map +1 -0
  180. package/dist/src/formats/gml/exporter.d.ts +69 -0
  181. package/dist/src/formats/gml/exporter.d.ts.map +1 -0
  182. package/dist/src/formats/gml/exporter.js +1093 -0
  183. package/dist/src/formats/gml/exporter.js.map +1 -0
  184. package/dist/src/formats/gml/importer.d.ts +66 -0
  185. package/dist/src/formats/gml/importer.d.ts.map +1 -0
  186. package/dist/src/formats/gml/importer.js +1331 -0
  187. package/dist/src/formats/gml/importer.js.map +1 -0
  188. package/dist/src/formats/gml/index.d.ts +85 -0
  189. package/dist/src/formats/gml/index.d.ts.map +1 -0
  190. package/dist/src/formats/gml/index.js +88 -0
  191. package/dist/src/formats/gml/index.js.map +1 -0
  192. package/dist/src/formats/gml/syntax.d.ts +186 -0
  193. package/dist/src/formats/gml/syntax.d.ts.map +1 -0
  194. package/dist/src/formats/gml/syntax.js +467 -0
  195. package/dist/src/formats/gml/syntax.js.map +1 -0
  196. package/dist/src/formats/graphml/constants.d.ts +169 -0
  197. package/dist/src/formats/graphml/constants.d.ts.map +1 -0
  198. package/dist/src/formats/graphml/constants.js +165 -0
  199. package/dist/src/formats/graphml/constants.js.map +1 -0
  200. package/dist/src/formats/graphml/exporter.d.ts +34 -0
  201. package/dist/src/formats/graphml/exporter.d.ts.map +1 -0
  202. package/dist/src/formats/graphml/exporter.js +1176 -0
  203. package/dist/src/formats/graphml/exporter.js.map +1 -0
  204. package/dist/src/formats/graphml/importer.d.ts +31 -0
  205. package/dist/src/formats/graphml/importer.d.ts.map +1 -0
  206. package/dist/src/formats/graphml/importer.js +1607 -0
  207. package/dist/src/formats/graphml/importer.js.map +1 -0
  208. package/dist/src/formats/graphml/index.d.ts +8 -0
  209. package/dist/src/formats/graphml/index.d.ts.map +1 -0
  210. package/dist/src/formats/graphml/index.js +8 -0
  211. package/dist/src/formats/graphml/index.js.map +1 -0
  212. package/dist/src/formats/graphml/tree.d.ts +72 -0
  213. package/dist/src/formats/graphml/tree.d.ts.map +1 -0
  214. package/dist/src/formats/graphml/tree.js +290 -0
  215. package/dist/src/formats/graphml/tree.js.map +1 -0
  216. package/dist/src/formats/json/dialect.d.ts +125 -0
  217. package/dist/src/formats/json/dialect.d.ts.map +1 -0
  218. package/dist/src/formats/json/dialect.js +262 -0
  219. package/dist/src/formats/json/dialect.js.map +1 -0
  220. package/dist/src/formats/json/exporter.d.ts +89 -0
  221. package/dist/src/formats/json/exporter.d.ts.map +1 -0
  222. package/dist/src/formats/json/exporter.js +1358 -0
  223. package/dist/src/formats/json/exporter.js.map +1 -0
  224. package/dist/src/formats/json/importer.d.ts +108 -0
  225. package/dist/src/formats/json/importer.d.ts.map +1 -0
  226. package/dist/src/formats/json/importer.js +1838 -0
  227. package/dist/src/formats/json/importer.js.map +1 -0
  228. package/dist/src/formats/json/index.d.ts +8 -0
  229. package/dist/src/formats/json/index.d.ts.map +1 -0
  230. package/dist/src/formats/json/index.js +8 -0
  231. package/dist/src/formats/json/index.js.map +1 -0
  232. package/dist/src/formats/neo4j/exporter.d.ts +68 -0
  233. package/dist/src/formats/neo4j/exporter.d.ts.map +1 -0
  234. package/dist/src/formats/neo4j/exporter.js +1055 -0
  235. package/dist/src/formats/neo4j/exporter.js.map +1 -0
  236. package/dist/src/formats/neo4j/header.d.ts +52 -0
  237. package/dist/src/formats/neo4j/header.d.ts.map +1 -0
  238. package/dist/src/formats/neo4j/header.js +131 -0
  239. package/dist/src/formats/neo4j/header.js.map +1 -0
  240. package/dist/src/formats/neo4j/importer.d.ts +73 -0
  241. package/dist/src/formats/neo4j/importer.d.ts.map +1 -0
  242. package/dist/src/formats/neo4j/importer.js +932 -0
  243. package/dist/src/formats/neo4j/importer.js.map +1 -0
  244. package/dist/src/formats/neo4j/index.d.ts +79 -0
  245. package/dist/src/formats/neo4j/index.d.ts.map +1 -0
  246. package/dist/src/formats/neo4j/index.js +83 -0
  247. package/dist/src/formats/neo4j/index.js.map +1 -0
  248. package/dist/src/formats/pajek/exporter.d.ts +58 -0
  249. package/dist/src/formats/pajek/exporter.d.ts.map +1 -0
  250. package/dist/src/formats/pajek/exporter.js +825 -0
  251. package/dist/src/formats/pajek/exporter.js.map +1 -0
  252. package/dist/src/formats/pajek/importer.d.ts +88 -0
  253. package/dist/src/formats/pajek/importer.d.ts.map +1 -0
  254. package/dist/src/formats/pajek/importer.js +1047 -0
  255. package/dist/src/formats/pajek/importer.js.map +1 -0
  256. package/dist/src/formats/pajek/index.d.ts +7 -0
  257. package/dist/src/formats/pajek/index.d.ts.map +1 -0
  258. package/dist/src/formats/pajek/index.js +7 -0
  259. package/dist/src/formats/pajek/index.js.map +1 -0
  260. package/dist/src/formats/pajek/syntax.d.ts +112 -0
  261. package/dist/src/formats/pajek/syntax.d.ts.map +1 -0
  262. package/dist/src/formats/pajek/syntax.js +269 -0
  263. package/dist/src/formats/pajek/syntax.js.map +1 -0
  264. package/dist/src/index.d.ts +35 -0
  265. package/dist/src/index.d.ts.map +1 -0
  266. package/dist/src/index.js +39 -0
  267. package/dist/src/index.js.map +1 -0
  268. package/dist/src/registry.d.ts +207 -0
  269. package/dist/src/registry.d.ts.map +1 -0
  270. package/dist/src/registry.js +481 -0
  271. package/dist/src/registry.js.map +1 -0
  272. package/dist/src/sniff.d.ts +104 -0
  273. package/dist/src/sniff.d.ts.map +1 -0
  274. package/dist/src/sniff.js +357 -0
  275. package/dist/src/sniff.js.map +1 -0
  276. package/dist/src/types.d.ts +238 -0
  277. package/dist/src/types.d.ts.map +1 -0
  278. package/dist/src/types.js +29 -0
  279. package/dist/src/types.js.map +1 -0
  280. package/dist/tsconfig.build.tsbuildinfo +1 -0
  281. package/package.json +122 -7
  282. package/src/children.ts +335 -0
  283. package/src/common/attributes.ts +520 -0
  284. package/src/common/codes.ts +153 -0
  285. package/src/common/declared-types.ts +374 -0
  286. package/src/common/direction.ts +518 -0
  287. package/src/common/escape.ts +231 -0
  288. package/src/common/export.ts +817 -0
  289. package/src/common/format.ts +111 -0
  290. package/src/common/ids.ts +176 -0
  291. package/src/common/input.ts +378 -0
  292. package/src/common/lists.ts +196 -0
  293. package/src/common/options.ts +377 -0
  294. package/src/common/report.ts +352 -0
  295. package/src/common/temporal.ts +302 -0
  296. package/src/common/text.ts +294 -0
  297. package/src/common/weights.ts +202 -0
  298. package/src/common/writer.ts +123 -0
  299. package/src/common/xml.ts +1053 -0
  300. package/src/formats/csv/exporter.ts +894 -0
  301. package/src/formats/csv/header.ts +172 -0
  302. package/src/formats/csv/importer.ts +1104 -0
  303. package/src/formats/csv/index.ts +88 -0
  304. package/src/formats/csv/records.ts +813 -0
  305. package/src/formats/csv/values.ts +224 -0
  306. package/src/formats/dot/exporter.ts +1014 -0
  307. package/src/formats/dot/importer.ts +1549 -0
  308. package/src/formats/dot/index.ts +7 -0
  309. package/src/formats/dot/names.ts +40 -0
  310. package/src/formats/dot/tokenizer.ts +384 -0
  311. package/src/formats/gexf/exporter.ts +1696 -0
  312. package/src/formats/gexf/importer.ts +2333 -0
  313. package/src/formats/gexf/index.ts +142 -0
  314. package/src/formats/gexf/schema.ts +361 -0
  315. package/src/formats/gml/exporter.ts +1404 -0
  316. package/src/formats/gml/importer.ts +1591 -0
  317. package/src/formats/gml/index.ts +128 -0
  318. package/src/formats/gml/syntax.ts +545 -0
  319. package/src/formats/graphml/constants.ts +225 -0
  320. package/src/formats/graphml/exporter.ts +1458 -0
  321. package/src/formats/graphml/importer.ts +2027 -0
  322. package/src/formats/graphml/index.ts +8 -0
  323. package/src/formats/graphml/tree.ts +318 -0
  324. package/src/formats/json/dialect.ts +317 -0
  325. package/src/formats/json/exporter.ts +1616 -0
  326. package/src/formats/json/importer.ts +2271 -0
  327. package/src/formats/json/index.ts +8 -0
  328. package/src/formats/neo4j/exporter.ts +1287 -0
  329. package/src/formats/neo4j/header.ts +156 -0
  330. package/src/formats/neo4j/importer.ts +1220 -0
  331. package/src/formats/neo4j/index.ts +116 -0
  332. package/src/formats/pajek/exporter.ts +1000 -0
  333. package/src/formats/pajek/importer.ts +1311 -0
  334. package/src/formats/pajek/index.ts +7 -0
  335. package/src/formats/pajek/syntax.ts +307 -0
  336. package/src/index.ts +244 -0
  337. package/src/registry.ts +617 -0
  338. package/src/sniff.ts +397 -0
  339. package/src/types.ts +262 -0
@@ -0,0 +1,2271 @@
1
+ /**
2
+ * The JSON importer (design sections 8.2, 8.4 and 8.5): one GraphImporter that sniffs the dialect
3
+ * of a parsed document -- NetworkX node-link (old `links` and new `edges` key forms, graph-level
4
+ * `directed` / `multigraph` / `graph`), the d3 lineage of the same shape (`name` ids, integer
5
+ * index endpoints), JSON Graph Format v2 (nodes keyed by id, per-edge `directed`, hyperedges),
6
+ * Cytoscape.js elements (`data.id` / `data.source` / `data.target`, `position`, `classes`,
7
+ * `data.parent`), graphology serialisation (`key` / `attributes`, `undirected` edges, `options`)
8
+ * and vis.js (`from` / `to`) -- and pushes it scalar by scalar into the sink.
9
+ *
10
+ * JSON awaits the whole text (design section 8.4: `JSON.parse` on 100 MB is fine; a streaming
11
+ * tokeniser is a later improvement). The parsed records are iterated in place; the importer never
12
+ * builds an intermediate array of node or edge objects. Ids are coerced per `ids` ("keep" by
13
+ * default: JSON values are already typed); node ids that are JSON `true` / `false` / `null` are
14
+ * reported as `unsupported` and coerced with `String(v)` only under `ids: "string"`. Attribute
15
+ * columns are inferred per column by the sink (design section 5.1); the structural fields of a
16
+ * dialect (Cytoscape `position` / `classes` / `parent`, JGF `label` / `relation`, edge ids) are
17
+ * declared up front with their roles when the file uses them. Direction goes through the
18
+ * DirectionResolver of design section 8.4; per-element errors are aggregated into the ImportReport
19
+ * until the error limit (design section 8.6).
20
+ *
21
+ * Fatal errors (ImportError at once): empty input, invalid JSON, an unrecognised top-level shape, a
22
+ * section that is not an array / object. Recoverable errors (an issue, the element skipped): a
23
+ * missing nodes or edges array, a node without an id, an edge without an endpoint, a bad index
24
+ * endpoint, a declared field of the wrong type, an unknown Cytoscape parent, an id the coercion rule
25
+ * rejects.
26
+ */
27
+
28
+ import {
29
+ type ColumnDecl,
30
+ type ColumnHandle,
31
+ GraphFormatError,
32
+ type GraphMetaPatch,
33
+ type GraphSink,
34
+ INVALID_INDEX,
35
+ type NodeId,
36
+ } from "@graphty/graph-format";
37
+
38
+ import { uniqueColumnName } from "../../common/attributes.js";
39
+ import {
40
+ DUPLICATE_EDGE_ID_CODE,
41
+ DUPLICATE_NODE_CODE,
42
+ EMPTY_INPUT_CODE,
43
+ HYPEREDGE_CODE,
44
+ MISSING_ENDPOINT_CODE,
45
+ MISSING_ID_CODE,
46
+ MULTIPLE_GRAPHS_CODE,
47
+ OPTION_IGNORED_CODE,
48
+ SYNTAX_CODE,
49
+ UNKNOWN_PARENT_CODE,
50
+ } from "../../common/codes.js";
51
+ import { DirectionResolver, type EdgeKind } from "../../common/direction.js";
52
+ import { ID_MERGED_CODE, IdCoercer } from "../../common/ids.js";
53
+ import { readText, throwIfAborted } from "../../common/input.js";
54
+ import {
55
+ type ImportFormatDefaults,
56
+ reportSinkOptions,
57
+ reportUnusedOptions,
58
+ type ResolvedImportOptions,
59
+ resolveImportOptions,
60
+ SINK_OPTION_CODE,
61
+ } from "../../common/options.js";
62
+ import { ImportReportBuilder } from "../../common/report.js";
63
+ import { weightFromValue } from "../../common/weights.js";
64
+ import { type CommonImportOptions, type GraphImporter, type ImportInput, type ImportReport } from "../../types.js";
65
+ import {
66
+ CLASSES_COLUMN,
67
+ CYTOSCAPE_ELEMENT_KEYS,
68
+ CYTOSCAPE_STRUCTURAL_KEYS,
69
+ DIALECT_DEFAULT_DIRECTED,
70
+ hasKey,
71
+ isJsonDialect,
72
+ isJsonObject,
73
+ JSON_DIALECTS,
74
+ type JsonDialect,
75
+ type JsonShapeMeta,
76
+ META_KEY,
77
+ NODE_LINK_SOURCE_KEYS,
78
+ NODE_LINK_TARGET_KEYS,
79
+ PARENT_COLUMN,
80
+ POSITION_COLUMN,
81
+ sniffJsonDialect,
82
+ SUFFIX,
83
+ } from "./dialect.js";
84
+
85
+ /** The format-specific options of the JSON importer. */
86
+ export interface JsonImportOptions {
87
+ /** The dialect to read; "auto" (default) sniffs the parsed document. */
88
+ dialect?: JsonDialect | "auto" | undefined;
89
+ /** node-link / d3 / vis: the node key holding the id; auto: "id" when any node has it, else "name". */
90
+ nodeIdKey?: string | undefined;
91
+ /** node-link / d3: the top-level key holding the edges; auto: "edges" when present, else "links". */
92
+ edgesKey?: string | undefined;
93
+ /** node-link / d3 / vis: the edge key holding the source; auto: "source", "src" or "from" (vis: "from"). */
94
+ sourceKey?: string | undefined;
95
+ /** node-link / d3 / vis: the edge key holding the target; auto: "target", "dst" or "to" (vis: "to"). */
96
+ targetKey?: string | undefined;
97
+ /**
98
+ * node-link / d3: whether edge endpoints are node array positions; "auto" (default) says yes when
99
+ * every endpoint is an integer below the node count and no node id is a number.
100
+ */
101
+ indexLinks?: boolean | "auto" | undefined;
102
+ /** jgf: which graph of a `graphs` array to read; 0 by default. */
103
+ graphIndex?: number | undefined;
104
+ }
105
+
106
+ /**
107
+ * The issue codes the JSON importer records (design section 8.6), by name: the codes shared with
108
+ * the other importers (src/common/codes.ts) and the JSON-specific ones. A key is the code without
109
+ * its severity and format prefixes.
110
+ */
111
+ export const JSON_ISSUE = Object.freeze({
112
+ /** The text is empty or whitespace (fatal). */
113
+ EMPTY_INPUT: EMPTY_INPUT_CODE,
114
+ /** JSON.parse refused the text (fatal). */
115
+ SYNTAX: SYNTAX_CODE,
116
+ /** No dialect matches the document's top-level shape. */
117
+ DIALECT: "E_JSON_DIALECT",
118
+ /** A section (nodes, edges, elements, graph) has the wrong JSON type. */
119
+ SHAPE: "E_JSON_SHAPE",
120
+ /** A node-link document lacks its nodes or its edges array, or a Cytoscape document its elements. */
121
+ MISSING_SECTION: "E_MISSING_SECTION",
122
+ /** A node record or an element is not an object. */
123
+ BAD_ELEMENT: "E_BAD_ELEMENT",
124
+ /** A node record has no id. */
125
+ MISSING_ID: MISSING_ID_CODE,
126
+ /** A node id is a JSON boolean or null (legal in NetworkX, not a NodeId); coerced only under ids "string". */
127
+ UNSUPPORTED_ID: "E_UNSUPPORTED_ID",
128
+ /** An edge record has no source or no target. */
129
+ MISSING_ENDPOINT: MISSING_ENDPOINT_CODE,
130
+ /** An index endpoint is not an integer below the node count, or names a skipped node. */
131
+ BAD_INDEX: "E_BAD_INDEX",
132
+ /** A declared field has the wrong JSON type (JGF label / relation / metadata, Cytoscape position / classes). */
133
+ BAD_VALUE: "E_BAD_VALUE",
134
+ /** A graph-level flag (`directed`, `multigraph`, graphology `options`) has the wrong type; the default is used. */
135
+ BAD_FLAG: "W_BAD_FLAG",
136
+ /** A Cytoscape `data.parent` names an unknown node. */
137
+ UNKNOWN_PARENT: UNKNOWN_PARENT_CODE,
138
+ /** A node id repeated by a later record; the records are merged (the later attributes win). */
139
+ DUPLICATE_NODE: DUPLICATE_NODE_CODE,
140
+ /** An edge id (Cytoscape data.id, graphology key, vis id) repeated by a later edge; the edge is skipped. */
141
+ DUPLICATE_EDGE_ID: DUPLICATE_EDGE_ID_CODE,
142
+ /** Two distinct id texts merged into one number under ids "number". */
143
+ ID_MERGED: ID_MERGED_CODE,
144
+ /** Edge ids of mixed JSON types were stored as text. */
145
+ EDGE_ID_STRINGIFIED: "W_EDGE_ID_STRINGIFIED",
146
+ /** A JGF `graphs` array holds more than one graph; only `graphIndex` is read. */
147
+ MULTIPLE_GRAPHS: MULTIPLE_GRAPHS_CODE,
148
+ /** JGF hyperedges under the "error" policy. */
149
+ HYPEREDGE: HYPEREDGE_CODE,
150
+ /** JGF hyperedges skipped under the default "skip" policy. */
151
+ HYPEREDGES_SKIPPED: "W_HYPEREDGES_SKIPPED",
152
+ /** A JGF hyperedge with neither a nodes array nor source / target arrays. */
153
+ HYPEREDGE_SHAPE: "E_HYPEREDGE_SHAPE",
154
+ /** The nodes have no id key at all; array positions became the ids. */
155
+ POSITIONAL_NODES: "W_POSITIONAL_NODES",
156
+ /** A builder-policy option (addMissingNodes, duplicateEdges, selfLoops, weightDtype) differs from the sink's (the shared W_SINK_OPTION). */
157
+ SINK_OPTION: SINK_OPTION_CODE,
158
+ /** A common option the dialect has no use for (nodeIdFrom outside node-link, long, restoreMangledIds). */
159
+ OPTION_IGNORED: OPTION_IGNORED_CODE,
160
+ });
161
+
162
+ /** The common options the JSON importer reads (the rest is reported by reportUnusedOptions). */
163
+ const USED_OPTIONS: ReadonlySet<keyof CommonImportOptions> = new Set<keyof CommonImportOptions>([
164
+ "ids",
165
+ "nodeIdFrom",
166
+ "addMissingNodes",
167
+ "duplicateEdges",
168
+ "selfLoops",
169
+ "onMixedDirection",
170
+ "defaultDirected",
171
+ "weightFrom",
172
+ "weightDtype",
173
+ "hyperedges",
174
+ "errorLimit",
175
+ "signal",
176
+ "onProgress",
177
+ ]);
178
+
179
+ const FORMAT_DEFAULTS: ImportFormatDefaults = { ids: "keep", defaultDirected: false, weightFrom: "weight" };
180
+
181
+ /** Bytes of the head sniff() inspects. */
182
+ const SNIFF_BYTES = 4096;
183
+
184
+ /** Elements pushed between two checks of the cancellation signal (the whole document is one chunk). */
185
+ const ABORT_CHECK_INTERVAL = 64;
186
+
187
+ /** The top-level keys whose presence in the head marks a graph document rather than arbitrary JSON. */
188
+ const SNIFF_KEYS: readonly string[] = ['"nodes"', '"links"', '"edges"', '"elements"', '"graph"', '"graphs"'];
189
+
190
+ const BOM = String.fromCharCode(0xfeff);
191
+
192
+ /** The JGF edge keys that are not metadata. */
193
+ const JGF_EDGE_KEYS: ReadonlySet<string> = new Set([
194
+ "id",
195
+ "source",
196
+ "target",
197
+ "relation",
198
+ "directed",
199
+ "label",
200
+ "metadata",
201
+ ]);
202
+
203
+ /** The JGF hyperedge keys that are not metadata. */
204
+ const JGF_HYPEREDGE_KEYS: ReadonlySet<string> = new Set([...JGF_EDGE_KEYS, "nodes"]);
205
+
206
+ /** The JGF node keys that are not metadata. */
207
+ const JGF_NODE_KEYS: ReadonlySet<string> = new Set(["label", "metadata"]);
208
+
209
+ /** The graphology node keys that are not attributes. */
210
+ const GRAPHOLOGY_NODE_KEYS: ReadonlySet<string> = new Set(["key", "attributes"]);
211
+
212
+ /** The graphology edge keys that are not attributes. */
213
+ const GRAPHOLOGY_EDGE_KEYS: ReadonlySet<string> = new Set(["key", "source", "target", "attributes", "undirected"]);
214
+
215
+ /** The vis.js endpoint keys. */
216
+ const VIS_SOURCE_KEYS: readonly string[] = Object.freeze(["from"]);
217
+ const VIS_TARGET_KEYS: readonly string[] = Object.freeze(["to"]);
218
+
219
+ type JsonRecord = Record<string, unknown>;
220
+
221
+ /** An edge id column declared from a scan of the file's edge ids. */
222
+ interface EdgeIdColumn {
223
+ readonly handle: ColumnHandle;
224
+ /** Whether numeric ids are stored as text (the file mixes numbers and strings). */
225
+ readonly stringify: boolean;
226
+ /** The id texts seen so far when the dialect requires unique ids, else null. */
227
+ readonly seen: Set<string> | null;
228
+ }
229
+
230
+ /** The resolved format-specific options. */
231
+ interface ResolvedJsonOptions {
232
+ readonly dialect: JsonDialect | "auto";
233
+ readonly nodeIdKey: string | null;
234
+ readonly edgesKey: string | null;
235
+ readonly sourceKey: string | null;
236
+ readonly targetKey: string | null;
237
+ readonly indexLinks: boolean | "auto";
238
+ readonly graphIndex: number;
239
+ }
240
+
241
+ /**
242
+ * Check the format-specific options.
243
+ * @param options - the caller's options
244
+ * @returns the resolved options; E_UNSUPPORTED for a bad value
245
+ */
246
+ function resolveJsonOptions(options: (JsonImportOptions & CommonImportOptions) | undefined): ResolvedJsonOptions {
247
+ const o = options ?? {};
248
+ const dialect = o.dialect ?? "auto";
249
+ if (dialect !== "auto" && !isJsonDialect(dialect)) {
250
+ throw unsupportedOption("dialect", dialect, [...JSON_DIALECTS, "auto"]);
251
+ }
252
+ const indexLinks = o.indexLinks ?? "auto";
253
+ if (indexLinks !== "auto" && typeof indexLinks !== "boolean") {
254
+ throw unsupportedOption("indexLinks", indexLinks, ["true", "false", "auto"]);
255
+ }
256
+ const graphIndex = o.graphIndex ?? 0;
257
+ if (!Number.isInteger(graphIndex) || graphIndex < 0) {
258
+ throw unsupportedOption("graphIndex", graphIndex, ["a non-negative integer"]);
259
+ }
260
+ return {
261
+ dialect,
262
+ nodeIdKey: keyOption("nodeIdKey", o.nodeIdKey),
263
+ edgesKey: keyOption("edgesKey", o.edgesKey),
264
+ sourceKey: keyOption("sourceKey", o.sourceKey),
265
+ targetKey: keyOption("targetKey", o.targetKey),
266
+ indexLinks,
267
+ graphIndex,
268
+ };
269
+ }
270
+
271
+ /**
272
+ * Check a key-valued option.
273
+ * @param name - the option name
274
+ * @param value - the caller's value
275
+ * @returns the key, or null when absent
276
+ */
277
+ function keyOption(name: string, value: unknown): string | null {
278
+ if (value === undefined) {
279
+ return null;
280
+ }
281
+ if (typeof value !== "string" || value.length === 0) {
282
+ throw unsupportedOption(name, value, ["a non-empty key"]);
283
+ }
284
+ return value;
285
+ }
286
+
287
+ /**
288
+ * The E_UNSUPPORTED error of a bad format-specific option (the core's convention, as in the common
289
+ * option module).
290
+ * @param name - the option name
291
+ * @param found - the value
292
+ * @param supported - what is accepted
293
+ * @returns the error
294
+ */
295
+ function unsupportedOption(name: string, found: unknown, supported: readonly string[]): GraphFormatError {
296
+ return new GraphFormatError(
297
+ "E_UNSUPPORTED",
298
+ `option ${name}: ${describe(found)} is not one of ${supported.join(", ")}`,
299
+ {
300
+ option: name,
301
+ found: typeof found === "string" ? found : typeof found,
302
+ supported: [...supported],
303
+ },
304
+ );
305
+ }
306
+
307
+ /**
308
+ * A short description of a value for messages.
309
+ * @param value - the value
310
+ * @returns JSON for primitives, "array" or the type name otherwise
311
+ */
312
+ function describe(value: unknown): string {
313
+ if (value === null) {
314
+ return "null";
315
+ }
316
+ if (Array.isArray(value)) {
317
+ return "array";
318
+ }
319
+ switch (typeof value) {
320
+ case "string":
321
+ case "number":
322
+ case "boolean":
323
+ return JSON.stringify(value);
324
+ default:
325
+ return typeof value;
326
+ }
327
+ }
328
+
329
+ // ============================================================ attribute writer
330
+
331
+ /**
332
+ * Writes inferred attribute cells for one table, caching the handle of every column after its
333
+ * first write so the hot loop never looks a column up by name twice. Names that collide with a
334
+ * structural column declared up front are suffixed deterministically (design section 5.6).
335
+ */
336
+ class AttributeWriter {
337
+ private readonly sink: GraphSink;
338
+
339
+ private readonly domain: "node" | "edge";
340
+
341
+ private readonly handles = new Map<string, ColumnHandle>();
342
+
343
+ private readonly reservedNames = new Set<string>();
344
+
345
+ /**
346
+ * Create a writer.
347
+ * @param sink - the sink
348
+ * @param domain - node or edge
349
+ */
350
+ constructor(sink: GraphSink, domain: "node" | "edge") {
351
+ this.sink = sink;
352
+ this.domain = domain;
353
+ }
354
+
355
+ /**
356
+ * Declare a structural column up front; attribute keys with its name are suffixed from now on.
357
+ * @param decl - the declaration
358
+ * @returns the handle
359
+ */
360
+ declare(decl: ColumnDecl): ColumnHandle {
361
+ this.reservedNames.add(decl.name);
362
+ const handle = this.domain === "node" ? this.sink.declareNodeColumn(decl) : this.sink.declareEdgeColumn(decl);
363
+ this.handles.set(decl.name, handle);
364
+ return handle;
365
+ }
366
+
367
+ /**
368
+ * Declare a structural column only when the file uses it.
369
+ * @param decl - the declaration
370
+ * @param present - whether any element carries the field
371
+ * @returns the handle, or INVALID_INDEX when not declared
372
+ */
373
+ declareIf(decl: ColumnDecl, present: boolean): ColumnHandle {
374
+ return present ? this.declare(decl) : (INVALID_INDEX as ColumnHandle);
375
+ }
376
+
377
+ /**
378
+ * Whether a name is taken in the sink's table (for the deterministic rename rule).
379
+ * @param name - the column name
380
+ * @returns true when a column of that name exists
381
+ */
382
+ taken(name: string): boolean {
383
+ return this.lookup(name) !== INVALID_INDEX;
384
+ }
385
+
386
+ /**
387
+ * Write one attribute cell by its source key; null and undefined leave the row unset.
388
+ * @param row - the node or edge index
389
+ * @param key - the source key
390
+ * @param value - the JSON value
391
+ * @param suffix - the suffix applied when the key collides with a structural column
392
+ */
393
+ write(row: number, key: string, value: unknown, suffix: string): void {
394
+ if (value === undefined || value === null) {
395
+ return;
396
+ }
397
+ const name = this.reservedNames.has(key) ? `${key}${suffix}` : key;
398
+ const cached = this.handles.get(name);
399
+ if (cached !== undefined) {
400
+ this.set(cached, row, value);
401
+ return;
402
+ }
403
+ this.set(name, row, value);
404
+ const handle = this.lookup(name);
405
+ if (handle !== INVALID_INDEX) {
406
+ this.handles.set(name, handle);
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Write through a handle or a name.
412
+ * @param column - the handle or name
413
+ * @param row - the row
414
+ * @param value - the value
415
+ */
416
+ set(column: ColumnHandle | string, row: number, value: unknown): void {
417
+ if (this.domain === "node") {
418
+ this.sink.setNodeValue(column, row, value);
419
+ } else {
420
+ this.sink.setEdgeValue(column, row, value);
421
+ }
422
+ }
423
+
424
+ /**
425
+ * Look a column up by name.
426
+ * @param name - the column name
427
+ * @returns the handle, or INVALID_INDEX
428
+ */
429
+ private lookup(name: string): ColumnHandle {
430
+ return this.domain === "node" ? this.sink.nodeColumn(name) : this.sink.edgeColumn(name);
431
+ }
432
+ }
433
+
434
+ // ============================================================ the import context
435
+
436
+ /**
437
+ * Everything one import call shares between the dialect readers.
438
+ */
439
+ class ImportContext {
440
+ readonly sink: GraphSink;
441
+
442
+ readonly report: ImportReportBuilder;
443
+
444
+ readonly options: ResolvedImportOptions;
445
+
446
+ readonly json: ResolvedJsonOptions;
447
+
448
+ readonly ids: IdCoercer;
449
+
450
+ readonly direction: DirectionResolver;
451
+
452
+ readonly nodes: AttributeWriter;
453
+
454
+ readonly edges: AttributeWriter;
455
+
456
+ /** Whether the caller passed `defaultDirected` explicitly (then it beats the dialect's convention). */
457
+ readonly explicitDefaultDirected: boolean;
458
+
459
+ /** Nodes and edges pushed since the signal was last checked. */
460
+ private elementsSinceCheck = 0;
461
+
462
+ /**
463
+ * Create the context.
464
+ * @param sink - the sink
465
+ * @param report - the report
466
+ * @param options - the resolved common options
467
+ * @param json - the resolved format options
468
+ * @param explicitDefaultDirected - whether the caller passed defaultDirected
469
+ */
470
+ constructor(
471
+ sink: GraphSink,
472
+ report: ImportReportBuilder,
473
+ options: ResolvedImportOptions,
474
+ json: ResolvedJsonOptions,
475
+ explicitDefaultDirected: boolean,
476
+ ) {
477
+ this.sink = sink;
478
+ this.report = report;
479
+ this.options = options;
480
+ this.json = json;
481
+ this.ids = new IdCoercer(options.ids);
482
+ this.direction = new DirectionResolver(sink, report, options.onMixedDirection);
483
+ this.nodes = new AttributeWriter(sink, "node");
484
+ this.edges = new AttributeWriter(sink, "edge");
485
+ this.explicitDefaultDirected = explicitDefaultDirected;
486
+ }
487
+
488
+ /**
489
+ * Report a `nodeIdFrom` other than "id" for a dialect whose ids are unambiguous.
490
+ * @param dialect - the dialect
491
+ * @param idField - where the dialect's ids come from, for the message
492
+ */
493
+ reportNodeIdFrom(dialect: JsonDialect, idField: string): void {
494
+ if (this.options.nodeIdFrom !== "id") {
495
+ this.report.warning(
496
+ "unsupported",
497
+ JSON_ISSUE.OPTION_IGNORED,
498
+ `nodeIdFrom "${this.options.nodeIdFrom}" does not apply to ${dialect}; ids are read from ${idField}`,
499
+ { element: "nodeIdFrom" },
500
+ );
501
+ }
502
+ }
503
+
504
+ /**
505
+ * The direction assumed when the file declares none: the caller's `defaultDirected` when given,
506
+ * else the dialect's convention.
507
+ * @param dialect - the dialect
508
+ * @returns the direction
509
+ */
510
+ defaultDirected(dialect: JsonDialect): boolean {
511
+ return this.explicitDefaultDirected ? this.options.defaultDirected : DIALECT_DEFAULT_DIRECTED[dialect];
512
+ }
513
+
514
+ /** The direction the file declares (or the default), set by setHeader(). */
515
+ private fileDirected = false;
516
+
517
+ /**
518
+ * Rule 1 of design section 8.4: set the sink's direction from the file's header (or the
519
+ * dialect's default) before the first edge, remembering the file's direction for uniformKind().
520
+ * @param directed - the file's direction
521
+ */
522
+ setHeader(directed: boolean): void {
523
+ this.fileDirected = directed;
524
+ this.direction.setHeader(directed);
525
+ }
526
+
527
+ /**
528
+ * The edge kind every edge of a dialect without per-edge direction has: the file's direction
529
+ * (the resolver expands it when the sink's direction differs).
530
+ * @returns the kind
531
+ */
532
+ uniformKind(): EdgeKind {
533
+ return this.fileDirected ? "directed" : "undirected";
534
+ }
535
+
536
+ /**
537
+ * Coerce a node id value per the `ids` option, reporting what cannot be an id. A JSON boolean or
538
+ * null is `unsupported` unless `ids` is "string" (design section 8.5); anything the rule rejects
539
+ * is recorded through the per-element catch.
540
+ * @param raw - the JSON value; undefined when the record has no id key
541
+ * @param element - the element name for the issue
542
+ * @returns the id, or null when the value was reported and the element must be skipped
543
+ */
544
+ coerceId(raw: unknown, element: string): NodeId | null {
545
+ if (raw === undefined) {
546
+ this.report.error("missing-value", JSON_ISSUE.MISSING_ID, `${element} has no id`, { element });
547
+ return null;
548
+ }
549
+ if ((typeof raw === "boolean" || raw === null) && this.options.ids !== "string") {
550
+ const kind = raw === null ? "null" : "boolean";
551
+ this.report.error(
552
+ "unsupported",
553
+ JSON_ISSUE.UNSUPPORTED_ID,
554
+ `${element}: a JSON ${kind} is not a node id (pass ids: "string" to coerce it)`,
555
+ { element },
556
+ );
557
+ return null;
558
+ }
559
+ let id: NodeId;
560
+ try {
561
+ id = this.ids.value(this.options.ids === "string" && typeof raw === "number" ? String(raw) : raw);
562
+ } catch (err) {
563
+ this.report.recordError(err, { element });
564
+ return null;
565
+ }
566
+ const merge = this.ids.lastMerge;
567
+ if (merge !== null) {
568
+ this.report.warning(
569
+ "coercion",
570
+ JSON_ISSUE.ID_MERGED,
571
+ `id text ${JSON.stringify(merge.text)} merged with ${JSON.stringify(merge.previousText)} as ${merge.id}`,
572
+ { element },
573
+ );
574
+ }
575
+ return id;
576
+ }
577
+
578
+ /**
579
+ * Coerce an id that must be valid for the caller to proceed (hyperedge members): the rejections
580
+ * of coerceId() are thrown instead of recorded.
581
+ * @param raw - the JSON value
582
+ * @param element - the element name
583
+ * @returns the id
584
+ */
585
+ requireId(raw: unknown, element: string): NodeId {
586
+ if ((typeof raw === "boolean" || raw === null) && this.options.ids !== "string") {
587
+ const kind = raw === null ? "null" : "boolean";
588
+ throw new GraphFormatError("E_INVALID_ID", `${element}: a JSON ${kind} is not a node id`, {
589
+ reason: "unsupported id",
590
+ });
591
+ }
592
+ return this.ids.value(this.options.ids === "string" && typeof raw === "number" ? String(raw) : raw);
593
+ }
594
+
595
+ /**
596
+ * Add a node, counting it or recording the failure.
597
+ * @param id - the node id
598
+ * @param element - the element name
599
+ * @returns the node index, or -1 when the sink refused the node
600
+ */
601
+ pushNode(id: NodeId, element: string): number {
602
+ this.checkAbort();
603
+ const existing = this.sink.indexOf(id);
604
+ if (existing !== INVALID_INDEX) {
605
+ this.report.warning(
606
+ "merged",
607
+ JSON_ISSUE.DUPLICATE_NODE,
608
+ `${element}: node ${JSON.stringify(id)} already exists; its attributes are merged (the later values win)`,
609
+ { element },
610
+ );
611
+ return existing;
612
+ }
613
+ let index: number;
614
+ try {
615
+ index = this.sink.addNode(id);
616
+ } catch (err) {
617
+ this.skip(err, "node", element);
618
+ return -1;
619
+ }
620
+ this.report.counts.nodes++;
621
+ return index;
622
+ }
623
+
624
+ /**
625
+ * Push one edge through the direction resolver, counting every logical edge the sink gained
626
+ * (both halves of an expanded edge, and the mirrors of an in-place expansion).
627
+ * @param source - the source id
628
+ * @param target - the target id
629
+ * @param kind - the edge's direction in the file
630
+ * @param weight - the weight, or undefined
631
+ * @param element - the element name for issues
632
+ * @returns the primary edge index
633
+ */
634
+ pushEdge(source: NodeId, target: NodeId, kind: EdgeKind, weight: number | undefined, element: string): number {
635
+ this.checkAbort();
636
+ const before = this.sink.edgeCount;
637
+ // endpoints the sink creates (addMissingNodes) count as nodes too
638
+ let created = this.sink.indexOf(source) === INVALID_INDEX ? 1 : 0;
639
+ if (source !== target && this.sink.indexOf(target) === INVALID_INDEX) {
640
+ created++;
641
+ }
642
+ const edge = this.direction.addEdge(source, target, kind, weight, { element });
643
+ this.report.counts.edges += this.sink.edgeCount - before;
644
+ this.report.counts.nodes += created;
645
+ return edge;
646
+ }
647
+
648
+ /**
649
+ * Check the cancellation signal every ABORT_CHECK_INTERVAL pushed elements, so an abort raised
650
+ * while the whole in-memory document is being walked rejects promptly.
651
+ */
652
+ checkAbort(): void {
653
+ if (++this.elementsSinceCheck >= ABORT_CHECK_INTERVAL) {
654
+ this.elementsSinceCheck = 0;
655
+ throwIfAborted(this.options.signal);
656
+ }
657
+ }
658
+
659
+ /**
660
+ * Record a per-element failure and count the skipped element.
661
+ * @param err - the thrown value
662
+ * @param domain - which counter to bump
663
+ * @param element - the element name
664
+ */
665
+ skip(err: unknown, domain: "node" | "edge", element: string): void {
666
+ this.report.recordError(err, { element });
667
+ if (domain === "node") {
668
+ this.report.counts.skippedNodes++;
669
+ } else {
670
+ this.report.counts.skippedEdges++;
671
+ }
672
+ }
673
+
674
+ /**
675
+ * Report an element that is not an object and count it as skipped.
676
+ * @param domain - node or edge
677
+ * @param element - the element name
678
+ * @param what - what was expected
679
+ */
680
+ badElement(domain: "node" | "edge", element: string, what = "an object"): void {
681
+ this.report.error("validation-error", JSON_ISSUE.BAD_ELEMENT, `${element} is not ${what}`, { element });
682
+ this.countSkipped(domain);
683
+ }
684
+
685
+ /**
686
+ * Count a skipped element whose issue was already recorded.
687
+ * @param domain - node or edge
688
+ */
689
+ countSkipped(domain: "node" | "edge"): void {
690
+ if (domain === "node") {
691
+ this.report.counts.skippedNodes++;
692
+ } else {
693
+ this.report.counts.skippedEdges++;
694
+ }
695
+ }
696
+
697
+ /**
698
+ * Report an edge record without an endpoint and count it as skipped.
699
+ * @param element - the element name
700
+ * @param field - the missing field
701
+ */
702
+ missingEndpoint(element: string, field: string): void {
703
+ this.report.error("missing-value", JSON_ISSUE.MISSING_ENDPOINT, `${element} has no ${field}`, { element });
704
+ this.report.counts.skippedEdges++;
705
+ }
706
+
707
+ /**
708
+ * Record the shape metadata, the source format and further metadata fields in one setMeta()
709
+ * call (the sink replaces `extra` as a whole).
710
+ * @param shape - the shape record
711
+ * @param patch - further metadata fields
712
+ */
713
+ setMeta(shape: JsonShapeMeta, patch: GraphMetaPatch = {}): void {
714
+ const extra: Record<string, unknown> = {};
715
+ for (const [key, value] of Object.entries(shape)) {
716
+ if (value !== undefined) {
717
+ extra[key] = value;
718
+ }
719
+ }
720
+ this.sink.setMeta({ sourceFormat: "json", ...patch, extra: { [META_KEY]: extra } });
721
+ }
722
+
723
+ /**
724
+ * Record which source field the weight came from, so the exporter writes it back under the same
725
+ * key (design section 3.7, `meta.weightOrigin`).
726
+ * @returns the metadata patch, empty for an unweighted import
727
+ */
728
+ weightOriginPatch(): GraphMetaPatch {
729
+ const { weightFrom } = this.options;
730
+ if (weightFrom === null) {
731
+ return {};
732
+ }
733
+ return { weightOrigin: { format: "json", id: weightFrom, title: null, type: null, namespace: null } };
734
+ }
735
+
736
+ /**
737
+ * Write the graph-level attributes of a dict (NetworkX `graph`, JGF `metadata`, graphology
738
+ * `attributes`, Cytoscape `data`), one column per key.
739
+ * @param dict - the dict, or anything else (then reported)
740
+ * @param what - the dict's name for the issue
741
+ */
742
+ writeGraphDict(dict: unknown, what: string): void {
743
+ if (dict === undefined || dict === null) {
744
+ return;
745
+ }
746
+ if (!isJsonObject(dict)) {
747
+ this.report.warning("validation-error", JSON_ISSUE.BAD_FLAG, `${what} is not an object; ignored`, {
748
+ element: what,
749
+ });
750
+ return;
751
+ }
752
+ for (const key of Object.keys(dict)) {
753
+ const value = dict[key];
754
+ if (value === undefined || value === null) {
755
+ continue;
756
+ }
757
+ try {
758
+ this.sink.setGraphValue(key, value);
759
+ } catch (err) {
760
+ this.report.recordError(err, { element: `${what}.${key}` });
761
+ }
762
+ }
763
+ }
764
+
765
+ /**
766
+ * Read the weight field of an edge record.
767
+ * @param record - the record holding the attributes
768
+ * @returns the weight, or undefined when absent or null; E_INVALID_WEIGHT otherwise
769
+ */
770
+ weightOf(record: JsonRecord): number | undefined {
771
+ const { weightFrom } = this.options;
772
+ if (weightFrom === null || !hasKey(record, weightFrom)) {
773
+ return undefined;
774
+ }
775
+ return weightFromValue(record[weightFrom]);
776
+ }
777
+
778
+ /**
779
+ * Write the attributes of a nested dict plus the element-level keys the dialect does not
780
+ * define (kept with the `#element` suffix).
781
+ * @param writer - the table writer
782
+ * @param row - the row
783
+ * @param record - the element record
784
+ * @param dict - the nested attribute dict
785
+ * @param structural - the element keys that are not attributes
786
+ * @param weightFrom - the weight key to skip in the dict, or null
787
+ */
788
+ writeNested(
789
+ writer: AttributeWriter,
790
+ row: number,
791
+ record: JsonRecord,
792
+ dict: JsonRecord,
793
+ structural: ReadonlySet<string>,
794
+ weightFrom: string | null,
795
+ ): void {
796
+ for (const key of Object.keys(dict)) {
797
+ if (key !== weightFrom) {
798
+ writer.write(row, key, dict[key], SUFFIX.data);
799
+ }
800
+ }
801
+ for (const key of Object.keys(record)) {
802
+ if (!structural.has(key)) {
803
+ writer.write(row, `${key}${SUFFIX.element}`, record[key], SUFFIX.data);
804
+ }
805
+ }
806
+ }
807
+
808
+ /**
809
+ * Write a spec-typed string field (JGF label / relation), reporting a value of another type.
810
+ * @param writer - the table writer
811
+ * @param column - the declared column, or INVALID_INDEX when the file has no such field
812
+ * @param row - the row
813
+ * @param value - the value
814
+ * @param field - the field name
815
+ * @param element - the element name
816
+ */
817
+ writeStringField(
818
+ writer: AttributeWriter,
819
+ column: ColumnHandle,
820
+ row: number,
821
+ value: unknown,
822
+ field: string,
823
+ element: string,
824
+ ): void {
825
+ if (column === INVALID_INDEX || value === undefined || value === null) {
826
+ return;
827
+ }
828
+ if (typeof value !== "string") {
829
+ this.report.error(
830
+ "validation-error",
831
+ JSON_ISSUE.BAD_VALUE,
832
+ `${element}: ${field} must be a string, found ${describe(value)}`,
833
+ { element },
834
+ );
835
+ return;
836
+ }
837
+ writer.set(column, row, value);
838
+ }
839
+
840
+ /**
841
+ * Declare an edge id column with role "id" from a scan of the file's edge ids: f64 when every
842
+ * id is a number, string otherwise (numbers are then stored as their text and reported once).
843
+ * @param edges - the edge records
844
+ * @param read - how to read an edge's raw id
845
+ * @param name - the column name
846
+ * @param unique - whether uniqueness is enforced at freeze
847
+ * @returns the column, or null when no edge has an id
848
+ */
849
+ declareEdgeIds(
850
+ edges: readonly unknown[],
851
+ read: (edge: JsonRecord) => unknown,
852
+ name: string,
853
+ unique: boolean,
854
+ ): EdgeIdColumn | null {
855
+ let numbers = 0;
856
+ let strings = 0;
857
+ for (const edge of edges) {
858
+ if (!isJsonObject(edge)) {
859
+ continue;
860
+ }
861
+ const raw = read(edge);
862
+ if (typeof raw === "number") {
863
+ numbers++;
864
+ } else if (typeof raw === "string") {
865
+ strings++;
866
+ }
867
+ }
868
+ if (numbers + strings === 0) {
869
+ return null;
870
+ }
871
+ const dtype = strings === 0 ? "f64" : "string";
872
+ const columnName = uniqueColumnName(name, "id", (candidate) => this.edges.taken(candidate));
873
+ const handle = this.edges.declare({ name: columnName, dtype, role: "id", nullable: true, unique });
874
+ const stringify = strings > 0 && numbers > 0;
875
+ const seen = unique ? new Set<string>() : null;
876
+ if (stringify) {
877
+ this.report.warning(
878
+ "coercion",
879
+ JSON_ISSUE.EDGE_ID_STRINGIFIED,
880
+ `edge ids mix numbers and strings; ${numbers} numeric id(s) stored as text`,
881
+ { element: columnName },
882
+ );
883
+ }
884
+ return { handle, stringify, seen };
885
+ }
886
+
887
+ /**
888
+ * The value an edge id column stores for a raw id, checked BEFORE the edge is pushed so a bad
889
+ * id skips the edge without touching the sink (design section 11.1).
890
+ * @param column - the column, or null when the file has no edge ids
891
+ * @param raw - the raw id value
892
+ * @returns the value to store, or null when there is nothing to store
893
+ */
894
+ edgeIdValue(column: EdgeIdColumn | null, raw: unknown): string | number | null {
895
+ if (column === null || raw === undefined || raw === null) {
896
+ return null;
897
+ }
898
+ let value: string | number;
899
+ if (typeof raw === "number") {
900
+ value = column.stringify ? String(raw) : raw;
901
+ } else if (typeof raw === "string") {
902
+ value = raw;
903
+ } else {
904
+ throw new GraphFormatError(
905
+ "E_COLUMN_TYPE",
906
+ `an edge id must be a string or a number, found ${describe(raw)}`,
907
+ { found: typeof raw },
908
+ );
909
+ }
910
+ if (column.seen !== null) {
911
+ const text = String(value);
912
+ if (column.seen.has(text)) {
913
+ throw new GraphFormatError(
914
+ JSON_ISSUE.DUPLICATE_EDGE_ID,
915
+ `edge id ${JSON.stringify(value)} is declared more than once; the edge is skipped`,
916
+ { id: value },
917
+ );
918
+ }
919
+ column.seen.add(text);
920
+ }
921
+ return value;
922
+ }
923
+
924
+ /**
925
+ * Write an edge id value from edgeIdValue().
926
+ * @param column - the column, or null
927
+ * @param edge - the edge index
928
+ * @param value - the value, or null for none
929
+ */
930
+ setEdgeId(column: EdgeIdColumn | null, edge: number, value: string | number | null): void {
931
+ if (column !== null && value !== null) {
932
+ this.edges.set(column.handle, edge, value);
933
+ }
934
+ }
935
+ }
936
+
937
+ // ============================================================ document level
938
+
939
+ /**
940
+ * Parse the whole text; syntax errors and empty input abort the import.
941
+ * @param text - the decoded text
942
+ * @param report - the report
943
+ * @returns the parsed value
944
+ */
945
+ function parseDocument(text: string, report: ImportReportBuilder): unknown {
946
+ if (text.trim().length === 0) {
947
+ report.fail(JSON_ISSUE.EMPTY_INPUT, "the input is empty");
948
+ }
949
+ try {
950
+ return JSON.parse(text) as unknown;
951
+ } catch (err) {
952
+ const message = err instanceof Error ? err.message : String(err);
953
+ return report.fail(JSON_ISSUE.SYNTAX, `invalid JSON: ${message}`);
954
+ }
955
+ }
956
+
957
+ /**
958
+ * The dialect to read: the forced one, else the shape rule of sniffJsonDialect(); a document
959
+ * that matches no dialect is a fatal E_JSON_DIALECT.
960
+ * @param root - the parsed document
961
+ * @param forced - the caller's dialect option
962
+ * @param report - the report the failure is recorded in
963
+ * @returns the dialect
964
+ */
965
+ function detectDialect(root: unknown, forced: JsonDialect | "auto", report: ImportReportBuilder): JsonDialect {
966
+ if (forced !== "auto") {
967
+ return forced;
968
+ }
969
+ const dialect = sniffJsonDialect(root);
970
+ if (dialect !== null) {
971
+ return dialect;
972
+ }
973
+ if (Array.isArray(root)) {
974
+ return report.fail(
975
+ JSON_ISSUE.DIALECT,
976
+ "a top-level array is only read as Cytoscape elements (objects with a data record)",
977
+ );
978
+ }
979
+ if (!isJsonObject(root)) {
980
+ return report.fail(JSON_ISSUE.DIALECT, `the document is a JSON ${describe(root)}, not a graph object`);
981
+ }
982
+ return report.fail(
983
+ JSON_ISSUE.DIALECT,
984
+ "no known dialect: expected nodes / links / edges (node-link), elements (Cytoscape) or graph (JGF)",
985
+ );
986
+ }
987
+
988
+ /**
989
+ * A section that must be an array: fail when it is something else.
990
+ * @param value - the section
991
+ * @param what - its name
992
+ * @param report - the report
993
+ * @returns the array, or null when absent
994
+ */
995
+ function arraySection(value: unknown, what: string, report: ImportReportBuilder): readonly unknown[] | null {
996
+ if (value === undefined || value === null) {
997
+ return null;
998
+ }
999
+ if (!Array.isArray(value)) {
1000
+ return report.fail(JSON_ISSUE.SHAPE, `${what} must be an array, found ${describe(value)}`, { element: what });
1001
+ }
1002
+ return value;
1003
+ }
1004
+
1005
+ /**
1006
+ * A boolean flag with a default and a warning when it has another type.
1007
+ * @param value - the flag value
1008
+ * @param what - its name
1009
+ * @param fallback - the default
1010
+ * @param report - the report
1011
+ * @returns the flag
1012
+ */
1013
+ function flagOf(value: unknown, what: string, fallback: boolean, report: ImportReportBuilder): boolean {
1014
+ if (value === undefined || value === null) {
1015
+ return fallback;
1016
+ }
1017
+ if (typeof value === "boolean") {
1018
+ return value;
1019
+ }
1020
+ report.warning(
1021
+ "validation-error",
1022
+ JSON_ISSUE.BAD_FLAG,
1023
+ `${what} is ${describe(value)}, not a boolean; ${fallback} assumed`,
1024
+ { element: what },
1025
+ );
1026
+ return fallback;
1027
+ }
1028
+
1029
+ /**
1030
+ * The endpoint key and value of an edge record: the explicit key when given, else the first of the
1031
+ * default keys the record has.
1032
+ * @param record - the edge record
1033
+ * @param explicit - the caller's key, or null
1034
+ * @param defaults - the default keys
1035
+ * @returns the key used (null when none) and its value
1036
+ */
1037
+ function endpointOf(
1038
+ record: JsonRecord,
1039
+ explicit: string | null,
1040
+ defaults: readonly string[],
1041
+ ): { readonly key: string | null; readonly value: unknown } {
1042
+ if (explicit !== null) {
1043
+ return { key: hasKey(record, explicit) ? explicit : null, value: record[explicit] };
1044
+ }
1045
+ for (const key of defaults) {
1046
+ if (hasKey(record, key)) {
1047
+ return { key, value: record[key] };
1048
+ }
1049
+ }
1050
+ return { key: null, value: undefined };
1051
+ }
1052
+
1053
+ /**
1054
+ * Whether any object of an array has a key with a non-null value.
1055
+ * @param items - the array
1056
+ * @param key - the key
1057
+ * @returns true when some element carries the field
1058
+ */
1059
+ function anyHas(items: readonly unknown[], key: string): boolean {
1060
+ return items.some((item) => isJsonObject(item) && item[key] !== undefined && item[key] !== null);
1061
+ }
1062
+
1063
+ /**
1064
+ * Whether a value is a non-negative integer below a bound.
1065
+ * @param value - the value
1066
+ * @param bound - the exclusive bound
1067
+ * @returns true for an index
1068
+ */
1069
+ function isIndexBelow(value: unknown, bound: number): boolean {
1070
+ return typeof value === "number" && Number.isInteger(value) && value >= 0 && value < bound;
1071
+ }
1072
+
1073
+ // ============================================================ node-link / d3
1074
+
1075
+ /**
1076
+ * Read a node-link or d3 document.
1077
+ * @param ctx - the context
1078
+ * @param root - the document
1079
+ * @param dialect - "node-link" or "d3"
1080
+ */
1081
+ function importNodeLink(ctx: ImportContext, root: JsonRecord, dialect: JsonDialect): void {
1082
+ const { report, json } = ctx;
1083
+ let { edgesKey } = json;
1084
+ if (edgesKey === null) {
1085
+ edgesKey = hasKey(root, "edges") || !hasKey(root, "links") ? "edges" : "links";
1086
+ }
1087
+ const nodes = arraySection(root.nodes, "nodes", report);
1088
+ const edges = arraySection(root[edgesKey], edgesKey, report);
1089
+ if (nodes === null) {
1090
+ report.error(
1091
+ "missing-value",
1092
+ JSON_ISSUE.MISSING_SECTION,
1093
+ "the document has no nodes array; nodes come from the edges",
1094
+ { element: "nodes" },
1095
+ );
1096
+ }
1097
+ if (edges === null) {
1098
+ report.error(
1099
+ "missing-value",
1100
+ JSON_ISSUE.MISSING_SECTION,
1101
+ `the document has no ${edgesKey} array; the graph has no edges`,
1102
+ { element: edgesKey },
1103
+ );
1104
+ }
1105
+ const directed = flagOf(root.directed, "directed", ctx.defaultDirected(dialect), report);
1106
+ const multigraph = hasKey(root, "multigraph") ? flagOf(root.multigraph, "multigraph", false, report) : null;
1107
+ ctx.setHeader(directed);
1108
+ ctx.writeGraphDict(root.graph, "graph");
1109
+
1110
+ const nodeList = nodes ?? [];
1111
+ const edgeList = edges ?? [];
1112
+ ctx.sink.reserve(nodeList.length, edgeList.length);
1113
+
1114
+ // the id key: the caller's, else "id" when any node has it, else "name" (d3); nodes without any
1115
+ // id key are positional (nodeIdFrom "index", or a d3 v3 file whose nodes carry no id at all)
1116
+ let { nodeIdKey } = json;
1117
+ let positional = ctx.options.nodeIdFrom === "index";
1118
+ if (!positional && nodeIdKey === null) {
1119
+ const candidates = ctx.options.nodeIdFrom === "label" ? ["label", "name", "id"] : ["id", "name"];
1120
+ nodeIdKey = candidates.find((key) => anyHas(nodeList, key)) ?? null;
1121
+ if (ctx.options.nodeIdFrom === "label" && nodeIdKey === "id") {
1122
+ report.warning(
1123
+ "unsupported",
1124
+ JSON_ISSUE.OPTION_IGNORED,
1125
+ 'nodeIdFrom "label": no node has a label or name key; ids are read from "id"',
1126
+ { element: "nodeIdFrom" },
1127
+ );
1128
+ }
1129
+ if (nodeIdKey === null) {
1130
+ if (nodeList.length > 0) {
1131
+ positional = true;
1132
+ report.warning(
1133
+ "missing-value",
1134
+ JSON_ISSUE.POSITIONAL_NODES,
1135
+ "no node has an id or name key; array positions are the node ids",
1136
+ { element: "nodes" },
1137
+ );
1138
+ } else {
1139
+ nodeIdKey = "id";
1140
+ }
1141
+ }
1142
+ }
1143
+ if (positional) {
1144
+ nodeIdKey = null;
1145
+ }
1146
+ let indexLinks: boolean;
1147
+ if (positional) {
1148
+ indexLinks = true;
1149
+ } else if (json.indexLinks === "auto") {
1150
+ indexLinks = nodeIdKey !== null && looksIndexLinked(nodeList, edgeList, nodeIdKey, json);
1151
+ } else {
1152
+ ({ indexLinks } = json);
1153
+ }
1154
+ const positionIds: (NodeId | null)[] | null = indexLinks ? [] : null;
1155
+
1156
+ for (let i = 0; i < nodeList.length; i++) {
1157
+ const element = `nodes[${i}]`;
1158
+ const record = nodeList[i];
1159
+ let pushed: NodeId | null = null;
1160
+ if (!isJsonObject(record)) {
1161
+ ctx.badElement("node", element);
1162
+ } else {
1163
+ let id: NodeId | null;
1164
+ if (nodeIdKey === null) {
1165
+ id = i;
1166
+ } else {
1167
+ id = ctx.coerceId(hasKey(record, nodeIdKey) ? record[nodeIdKey] : undefined, element);
1168
+ }
1169
+ if (id === null) {
1170
+ ctx.countSkipped("node");
1171
+ } else {
1172
+ const index = ctx.pushNode(id, element);
1173
+ if (index >= 0) {
1174
+ pushed = id;
1175
+ writeFlat(ctx, ctx.nodes, index, record, id, (key) => key !== nodeIdKey);
1176
+ }
1177
+ }
1178
+ }
1179
+ positionIds?.push(pushed);
1180
+ }
1181
+ throwIfAborted(ctx.options.signal);
1182
+ const endpointKeys = importNodeLinkEdges(ctx, edgeList, edgesKey, positionIds);
1183
+ ctx.setMeta(
1184
+ {
1185
+ dialect,
1186
+ edgesKey,
1187
+ nodeIdKey,
1188
+ indexLinks,
1189
+ sourceKey: endpointKeys.source ?? undefined,
1190
+ targetKey: endpointKeys.target ?? undefined,
1191
+ },
1192
+ { declaredMultigraph: multigraph, ...ctx.weightOriginPatch() },
1193
+ );
1194
+ }
1195
+
1196
+ /**
1197
+ * Read the node-link / d3 edge records: endpoints by id or array position, the weight, the other
1198
+ * keys as attributes.
1199
+ * @param ctx - the context
1200
+ * @param edgeList - the edge records
1201
+ * @param edgesKey - the top-level key they came from, for issues
1202
+ * @param positionIds - the ids by array position under index links, or null
1203
+ * @returns the source and target keys the first well-formed edge used (null when none did)
1204
+ */
1205
+ function importNodeLinkEdges(
1206
+ ctx: ImportContext,
1207
+ edgeList: readonly unknown[],
1208
+ edgesKey: string,
1209
+ positionIds: readonly (NodeId | null)[] | null,
1210
+ ): { source: string | null; target: string | null } {
1211
+ const { json } = ctx;
1212
+ const kind = ctx.uniformKind();
1213
+ let sourceKey: string | null = null;
1214
+ let targetKey: string | null = null;
1215
+ for (let i = 0; i < edgeList.length; i++) {
1216
+ const element = `${edgesKey}[${i}]`;
1217
+ const record = edgeList[i];
1218
+ if (!isJsonObject(record)) {
1219
+ ctx.badElement("edge", element);
1220
+ continue;
1221
+ }
1222
+ const source = endpointOf(record, json.sourceKey, NODE_LINK_SOURCE_KEYS);
1223
+ const target = endpointOf(record, json.targetKey, NODE_LINK_TARGET_KEYS);
1224
+ if (source.key === null || target.key === null) {
1225
+ ctx.missingEndpoint(element, source.key === null ? "source" : "target");
1226
+ continue;
1227
+ }
1228
+ sourceKey ??= source.key;
1229
+ targetKey ??= target.key;
1230
+ try {
1231
+ const u = resolveEndpoint(ctx, source.value, positionIds, element, "source");
1232
+ const v = resolveEndpoint(ctx, target.value, positionIds, element, "target");
1233
+ if (u === null || v === null) {
1234
+ ctx.countSkipped("edge");
1235
+ continue;
1236
+ }
1237
+ const weight = ctx.weightOf(record);
1238
+ const edge = ctx.pushEdge(u, v, kind, weight, element);
1239
+ const { weightFrom } = ctx.options;
1240
+ for (const key of Object.keys(record)) {
1241
+ if (key !== source.key && key !== target.key && key !== weightFrom) {
1242
+ ctx.edges.write(edge, key, record[key], SUFFIX.data);
1243
+ }
1244
+ }
1245
+ } catch (err) {
1246
+ ctx.skip(err, "edge", element);
1247
+ }
1248
+ }
1249
+ return { source: sourceKey, target: targetKey };
1250
+ }
1251
+
1252
+ /**
1253
+ * Write the flat attributes of a node record (every own key the filter keeps).
1254
+ * @param ctx - the context
1255
+ * @param writer - the node writer
1256
+ * @param index - the node index
1257
+ * @param record - the record
1258
+ * @param id - the node id, for issues
1259
+ * @param keep - which keys are attributes
1260
+ */
1261
+ function writeFlat(
1262
+ ctx: ImportContext,
1263
+ writer: AttributeWriter,
1264
+ index: number,
1265
+ record: JsonRecord,
1266
+ id: NodeId,
1267
+ keep: (key: string) => boolean,
1268
+ ): void {
1269
+ try {
1270
+ for (const key of Object.keys(record)) {
1271
+ if (keep(key)) {
1272
+ writer.write(index, key, record[key], SUFFIX.data);
1273
+ }
1274
+ }
1275
+ } catch (err) {
1276
+ ctx.report.recordError(err, { element: String(id) });
1277
+ }
1278
+ }
1279
+
1280
+ /**
1281
+ * The d3 index-link heuristic: endpoints are array positions when every endpoint is a
1282
+ * non-negative integer and no node id is a number (a numeric id would make the endpoints ids;
1283
+ * research note 07: d3 links reference nodes by array index and are never coerced to ids). An
1284
+ * index at or beyond the node count is then E_BAD_INDEX, never a new numeric node.
1285
+ * @param nodes - the node records
1286
+ * @param edges - the edge records
1287
+ * @param nodeIdKey - the node id key
1288
+ * @param json - the format options (endpoint keys)
1289
+ * @returns true when endpoints are indices
1290
+ */
1291
+ function looksIndexLinked(
1292
+ nodes: readonly unknown[],
1293
+ edges: readonly unknown[],
1294
+ nodeIdKey: string,
1295
+ json: ResolvedJsonOptions,
1296
+ ): boolean {
1297
+ if (edges.length === 0 || nodes.length === 0) {
1298
+ return false;
1299
+ }
1300
+ for (const node of nodes) {
1301
+ if (isJsonObject(node) && typeof node[nodeIdKey] === "number") {
1302
+ return false;
1303
+ }
1304
+ }
1305
+ let seen = 0;
1306
+ for (const edge of edges) {
1307
+ if (!isJsonObject(edge)) {
1308
+ continue;
1309
+ }
1310
+ const s = endpointOf(edge, json.sourceKey, NODE_LINK_SOURCE_KEYS).value;
1311
+ const t = endpointOf(edge, json.targetKey, NODE_LINK_TARGET_KEYS).value;
1312
+ if (!isIndexBelow(s, Infinity) || !isIndexBelow(t, Infinity)) {
1313
+ return false;
1314
+ }
1315
+ seen++;
1316
+ }
1317
+ return seen > 0;
1318
+ }
1319
+
1320
+ /**
1321
+ * Resolve one endpoint: a node id through the coercion rule, or an array position through the
1322
+ * ids pushed so far under index links.
1323
+ * @param ctx - the context
1324
+ * @param raw - the endpoint value
1325
+ * @param positionIds - the ids by array position under index links, or null
1326
+ * @param element - the edge element name
1327
+ * @param field - "source" or "target"
1328
+ * @returns the id, or null when reported
1329
+ */
1330
+ function resolveEndpoint(
1331
+ ctx: ImportContext,
1332
+ raw: unknown,
1333
+ positionIds: readonly (NodeId | null)[] | null,
1334
+ element: string,
1335
+ field: string,
1336
+ ): NodeId | null {
1337
+ if (positionIds === null) {
1338
+ return ctx.coerceId(raw, `${element}.${field}`);
1339
+ }
1340
+ if (!isIndexBelow(raw, positionIds.length)) {
1341
+ ctx.report.error(
1342
+ "validation-error",
1343
+ JSON_ISSUE.BAD_INDEX,
1344
+ `${element}: ${field} ${describe(raw)} is not a node index below ${positionIds.length}`,
1345
+ { element },
1346
+ );
1347
+ return null;
1348
+ }
1349
+ const id = positionIds[raw as number];
1350
+ if (id === null) {
1351
+ ctx.report.error("missing-value", JSON_ISSUE.BAD_INDEX, `${element}: ${field} names a node that was skipped`, {
1352
+ element,
1353
+ });
1354
+ }
1355
+ return id;
1356
+ }
1357
+
1358
+ // ============================================================ vis.js
1359
+
1360
+ /**
1361
+ * Read a vis.js document: `nodes` with `id`, `edges` with `from` / `to` and an optional `id`.
1362
+ * @param ctx - the context
1363
+ * @param root - the document
1364
+ */
1365
+ function importVis(ctx: ImportContext, root: JsonRecord): void {
1366
+ const { report, json } = ctx;
1367
+ const nodes = arraySection(root.nodes, "nodes", report) ?? [];
1368
+ const edges = arraySection(root.edges, "edges", report) ?? [];
1369
+ ctx.setHeader(ctx.defaultDirected("vis"));
1370
+ ctx.sink.reserve(nodes.length, edges.length);
1371
+ const nodeIdKey = json.nodeIdKey ?? "id";
1372
+ ctx.reportNodeIdFrom("vis", `the ${JSON.stringify(nodeIdKey)} key`);
1373
+ for (let i = 0; i < nodes.length; i++) {
1374
+ const element = `nodes[${i}]`;
1375
+ const record = nodes[i];
1376
+ if (!isJsonObject(record)) {
1377
+ ctx.badElement("node", element);
1378
+ continue;
1379
+ }
1380
+ const id = ctx.coerceId(hasKey(record, nodeIdKey) ? record[nodeIdKey] : undefined, element);
1381
+ if (id === null) {
1382
+ ctx.countSkipped("node");
1383
+ continue;
1384
+ }
1385
+ const index = ctx.pushNode(id, element);
1386
+ if (index >= 0) {
1387
+ writeFlat(ctx, ctx.nodes, index, record, id, (key) => key !== nodeIdKey);
1388
+ }
1389
+ }
1390
+ throwIfAborted(ctx.options.signal);
1391
+ const ids = ctx.declareEdgeIds(edges, (edge) => edge.id, "id", true);
1392
+ const kind = ctx.uniformKind();
1393
+ let sourceKey: string | null = null;
1394
+ let targetKey: string | null = null;
1395
+ for (let i = 0; i < edges.length; i++) {
1396
+ const element = `edges[${i}]`;
1397
+ const record = edges[i];
1398
+ if (!isJsonObject(record)) {
1399
+ ctx.badElement("edge", element);
1400
+ continue;
1401
+ }
1402
+ const source = endpointOf(record, json.sourceKey, VIS_SOURCE_KEYS);
1403
+ const target = endpointOf(record, json.targetKey, VIS_TARGET_KEYS);
1404
+ if (source.key === null || target.key === null) {
1405
+ ctx.missingEndpoint(element, source.key === null ? "from" : "to");
1406
+ continue;
1407
+ }
1408
+ sourceKey ??= source.key;
1409
+ targetKey ??= target.key;
1410
+ try {
1411
+ const u = ctx.coerceId(source.value, `${element}.from`);
1412
+ const v = ctx.coerceId(target.value, `${element}.to`);
1413
+ if (u === null || v === null) {
1414
+ ctx.countSkipped("edge");
1415
+ continue;
1416
+ }
1417
+ const idValue = ctx.edgeIdValue(ids, record.id);
1418
+ const edge = ctx.pushEdge(u, v, kind, ctx.weightOf(record), element);
1419
+ ctx.setEdgeId(ids, edge, idValue);
1420
+ const { weightFrom } = ctx.options;
1421
+ for (const key of Object.keys(record)) {
1422
+ if (key !== source.key && key !== target.key && key !== "id" && key !== weightFrom) {
1423
+ ctx.edges.write(edge, key, record[key], SUFFIX.data);
1424
+ }
1425
+ }
1426
+ } catch (err) {
1427
+ ctx.skip(err, "edge", element);
1428
+ }
1429
+ }
1430
+ ctx.setMeta(
1431
+ { dialect: "vis", nodeIdKey, sourceKey: sourceKey ?? undefined, targetKey: targetKey ?? undefined },
1432
+ ctx.weightOriginPatch(),
1433
+ );
1434
+ }
1435
+
1436
+ // ============================================================ graphology
1437
+
1438
+ /**
1439
+ * Read a graphology serialisation: `options.type` decides the header direction ("mixed" or absent:
1440
+ * from the edges' `undirected` flags), `options.multi` the declared multigraph flag, node `key`
1441
+ * the id, `attributes` the columns, edge `key` the edge id.
1442
+ * @param ctx - the context
1443
+ * @param root - the document
1444
+ */
1445
+ function importGraphology(ctx: ImportContext, root: JsonRecord): void {
1446
+ const { report } = ctx;
1447
+ const nodes = arraySection(root.nodes, "nodes", report) ?? [];
1448
+ const edges = arraySection(root.edges, "edges", report) ?? [];
1449
+ const options = isJsonObject(root.options) ? root.options : {};
1450
+ if (hasKey(root, "options") && !isJsonObject(root.options)) {
1451
+ report.warning("validation-error", JSON_ISSUE.BAD_FLAG, "options is not an object; ignored", {
1452
+ element: "options",
1453
+ });
1454
+ }
1455
+ let type: "directed" | "undirected" | "mixed";
1456
+ if (options.type === "directed" || options.type === "undirected" || options.type === "mixed") {
1457
+ ({ type } = options);
1458
+ } else {
1459
+ if (options.type !== undefined && options.type !== null) {
1460
+ report.warning(
1461
+ "validation-error",
1462
+ JSON_ISSUE.BAD_FLAG,
1463
+ `options.type ${describe(options.type)} is not directed, undirected or mixed; mixed assumed`,
1464
+ { element: "options.type" },
1465
+ );
1466
+ }
1467
+ type = "mixed";
1468
+ }
1469
+ let directed: boolean;
1470
+ if (type === "mixed") {
1471
+ const objects = edges.filter((edge) => isJsonObject(edge)) as JsonRecord[];
1472
+ const undirectedEdges = objects.filter((edge) => edge.undirected === true).length;
1473
+ directed = objects.length === 0 ? ctx.defaultDirected("graphology") : undirectedEdges < objects.length;
1474
+ } else {
1475
+ directed = type === "directed";
1476
+ }
1477
+ ctx.setHeader(directed);
1478
+ const multi = hasKey(options, "multi") ? flagOf(options.multi, "options.multi", false, report) : null;
1479
+ const allowSelfLoops = hasKey(options, "allowSelfLoops")
1480
+ ? flagOf(options.allowSelfLoops, "options.allowSelfLoops", true, report)
1481
+ : undefined;
1482
+ ctx.writeGraphDict(root.attributes, "attributes");
1483
+ ctx.reportNodeIdFrom("graphology", "the node key");
1484
+ ctx.sink.reserve(nodes.length, edges.length);
1485
+
1486
+ for (let i = 0; i < nodes.length; i++) {
1487
+ const element = `nodes[${i}]`;
1488
+ const record = nodes[i];
1489
+ if (!isJsonObject(record)) {
1490
+ ctx.badElement("node", element);
1491
+ continue;
1492
+ }
1493
+ const id = ctx.coerceId(hasKey(record, "key") ? record.key : undefined, element);
1494
+ if (id === null) {
1495
+ ctx.countSkipped("node");
1496
+ continue;
1497
+ }
1498
+ pushNestedNode(ctx, id, record, "attributes", GRAPHOLOGY_NODE_KEYS, element);
1499
+ }
1500
+ throwIfAborted(ctx.options.signal);
1501
+
1502
+ const ids = ctx.declareEdgeIds(edges, (edge) => edge.key, "key", true);
1503
+ for (let i = 0; i < edges.length; i++) {
1504
+ const element = `edges[${i}]`;
1505
+ const record = edges[i];
1506
+ if (!isJsonObject(record)) {
1507
+ ctx.badElement("edge", element);
1508
+ continue;
1509
+ }
1510
+ if (!hasKey(record, "source") || !hasKey(record, "target")) {
1511
+ ctx.missingEndpoint(element, hasKey(record, "source") ? "target" : "source");
1512
+ continue;
1513
+ }
1514
+ try {
1515
+ const u = ctx.coerceId(record.source, `${element}.source`);
1516
+ const v = ctx.coerceId(record.target, `${element}.target`);
1517
+ if (u === null || v === null) {
1518
+ ctx.countSkipped("edge");
1519
+ continue;
1520
+ }
1521
+ let kind: EdgeKind;
1522
+ if (type === "mixed") {
1523
+ kind = flagOf(record.undirected, `${element}.undirected`, false, report) ? "undirected" : "directed";
1524
+ } else {
1525
+ kind = type;
1526
+ }
1527
+ const attributes = isJsonObject(record.attributes) ? record.attributes : {};
1528
+ const idValue = ctx.edgeIdValue(ids, record.key);
1529
+ const edge = ctx.pushEdge(u, v, kind, ctx.weightOf(attributes), element);
1530
+ ctx.setEdgeId(ids, edge, idValue);
1531
+ ctx.writeNested(ctx.edges, edge, record, attributes, GRAPHOLOGY_EDGE_KEYS, ctx.options.weightFrom);
1532
+ } catch (err) {
1533
+ ctx.skip(err, "edge", element);
1534
+ }
1535
+ }
1536
+ ctx.setMeta({ dialect: "graphology", allowSelfLoops }, { declaredMultigraph: multi, ...ctx.weightOriginPatch() });
1537
+ }
1538
+
1539
+ /**
1540
+ * Push a node whose attributes live in a nested dict (graphology `attributes`, JGF `metadata`);
1541
+ * element-level keys the dialect does not define are kept with the `#element` suffix.
1542
+ * @param ctx - the context
1543
+ * @param id - the node id
1544
+ * @param record - the element record
1545
+ * @param dictKey - the key of the nested dict
1546
+ * @param structural - the element keys that are not attributes
1547
+ * @param element - the element name
1548
+ * @returns the node index, or -1 when skipped
1549
+ */
1550
+ function pushNestedNode(
1551
+ ctx: ImportContext,
1552
+ id: NodeId,
1553
+ record: JsonRecord,
1554
+ dictKey: string,
1555
+ structural: ReadonlySet<string>,
1556
+ element: string,
1557
+ ): number {
1558
+ const index = ctx.pushNode(id, element);
1559
+ if (index < 0) {
1560
+ return index;
1561
+ }
1562
+ try {
1563
+ const dict = record[dictKey];
1564
+ if (dict !== undefined && dict !== null && !isJsonObject(dict)) {
1565
+ ctx.report.error(
1566
+ "validation-error",
1567
+ JSON_ISSUE.BAD_VALUE,
1568
+ `${element}: ${dictKey} must be an object, found ${describe(dict)}`,
1569
+ { element },
1570
+ );
1571
+ }
1572
+ ctx.writeNested(ctx.nodes, index, record, isJsonObject(dict) ? dict : {}, structural, null);
1573
+ } catch (err) {
1574
+ ctx.report.recordError(err, { element: String(id) });
1575
+ }
1576
+ return index;
1577
+ }
1578
+
1579
+ // ============================================================ JSON Graph Format
1580
+
1581
+ /**
1582
+ * Read a JGF v2 document (`graph` or `graphs[graphIndex]`): nodes keyed by id (or a v1 array with
1583
+ * `id`), `label` with role "label", `metadata` as columns, edges with `id` / `relation` /
1584
+ * `directed` / `label` / `metadata`, hyperedges per the `hyperedges` option.
1585
+ * @param ctx - the context
1586
+ * @param root - the document
1587
+ */
1588
+ function importJgf(ctx: ImportContext, root: JsonRecord): void {
1589
+ const { report } = ctx;
1590
+ const graph = jgfGraphOf(ctx, root);
1591
+ const edges = arraySection(graph.edges, "graph.edges", report) ?? [];
1592
+ const hyperedges = arraySection(graph.hyperedges, "graph.hyperedges", report) ?? [];
1593
+ const nodesRaw: unknown = graph.nodes;
1594
+ if (nodesRaw !== undefined && nodesRaw !== null && !isJsonObject(nodesRaw) && !Array.isArray(nodesRaw)) {
1595
+ report.fail(
1596
+ JSON_ISSUE.SHAPE,
1597
+ `graph.nodes must be an object keyed by id or an array, found ${describe(nodesRaw)}`,
1598
+ );
1599
+ }
1600
+ const nodeRecords: readonly unknown[] = Array.isArray(nodesRaw) ? nodesRaw : Object.values(nodesRaw ?? {});
1601
+
1602
+ let directed: boolean;
1603
+ if (typeof graph.directed === "boolean") {
1604
+ ({ directed } = graph);
1605
+ } else if (ctx.explicitDefaultDirected) {
1606
+ directed = ctx.options.defaultDirected;
1607
+ } else {
1608
+ if (graph.directed !== undefined && graph.directed !== null) {
1609
+ report.warning(
1610
+ "validation-error",
1611
+ JSON_ISSUE.BAD_FLAG,
1612
+ `graph.directed is ${describe(graph.directed)}, not a boolean`,
1613
+ { element: "graph.directed" },
1614
+ );
1615
+ }
1616
+ // the spec default is true; a file whose every edge says directed: false is read as undirected
1617
+ const objects = edges.filter((edge) => isJsonObject(edge)) as JsonRecord[];
1618
+ directed = objects.length === 0 || !objects.every((edge) => edge.directed === false);
1619
+ }
1620
+ ctx.setHeader(directed);
1621
+ ctx.writeGraphDict(graph.metadata, "graph.metadata");
1622
+ const shape: JsonShapeMeta = {
1623
+ dialect: "jgf",
1624
+ id: typeof graph.id === "string" ? graph.id : undefined,
1625
+ type: typeof graph.type === "string" ? graph.type : undefined,
1626
+ };
1627
+ const label = typeof graph.label === "string" ? graph.label : null;
1628
+
1629
+ ctx.reportNodeIdFrom("jgf", "the node keys");
1630
+ ctx.sink.reserve(nodeRecords.length, edges.length);
1631
+ const labelColumn = ctx.nodes.declareIf(
1632
+ { name: "label", dtype: "string", role: "label", nullable: true },
1633
+ anyHas(nodeRecords, "label"),
1634
+ );
1635
+ if (isJsonObject(nodesRaw)) {
1636
+ for (const key of Object.keys(nodesRaw)) {
1637
+ const element = `nodes[${JSON.stringify(key)}]`;
1638
+ const record = nodesRaw[key];
1639
+ if (record !== null && !isJsonObject(record)) {
1640
+ ctx.badElement("node", element);
1641
+ continue;
1642
+ }
1643
+ const id = ctx.coerceId(key, element);
1644
+ if (id === null) {
1645
+ ctx.countSkipped("node");
1646
+ continue;
1647
+ }
1648
+ pushJgfNode(ctx, id, record ?? {}, labelColumn, element);
1649
+ }
1650
+ } else {
1651
+ for (let i = 0; i < nodeRecords.length; i++) {
1652
+ const element = `nodes[${i}]`;
1653
+ const record = nodeRecords[i];
1654
+ if (!isJsonObject(record)) {
1655
+ ctx.badElement("node", element);
1656
+ continue;
1657
+ }
1658
+ const id = ctx.coerceId(hasKey(record, "id") ? record.id : undefined, element);
1659
+ if (id === null) {
1660
+ ctx.countSkipped("node");
1661
+ continue;
1662
+ }
1663
+ pushJgfNode(ctx, id, record, labelColumn, element);
1664
+ }
1665
+ }
1666
+ throwIfAborted(ctx.options.signal);
1667
+
1668
+ const ids = ctx.declareEdgeIds([...edges, ...hyperedges], (edge) => edge.id, "id", false);
1669
+ const relationColumn = ctx.edges.declareIf(
1670
+ { name: "relation", dtype: "string", role: "kind", nullable: true },
1671
+ anyHas(edges, "relation") || anyHas(hyperedges, "relation"),
1672
+ );
1673
+ const edgeLabelColumn = ctx.edges.declareIf(
1674
+ { name: "label", dtype: "string", role: "label", nullable: true },
1675
+ anyHas(edges, "label") || anyHas(hyperedges, "label"),
1676
+ );
1677
+ const writeJgfEdge = (
1678
+ record: JsonRecord,
1679
+ u: NodeId,
1680
+ v: NodeId,
1681
+ kind: EdgeKind,
1682
+ element: string,
1683
+ structural: ReadonlySet<string> = JGF_EDGE_KEYS,
1684
+ ): void => {
1685
+ const { metadata } = record;
1686
+ const dict = isJsonObject(metadata) ? metadata : {};
1687
+ const idValue = ctx.edgeIdValue(ids, record.id);
1688
+ const edge = ctx.pushEdge(u, v, kind, ctx.weightOf(dict), element);
1689
+ ctx.setEdgeId(ids, edge, idValue);
1690
+ ctx.writeStringField(ctx.edges, relationColumn, edge, record.relation, "relation", element);
1691
+ ctx.writeStringField(ctx.edges, edgeLabelColumn, edge, record.label, "label", element);
1692
+ if (metadata !== undefined && metadata !== null && !isJsonObject(metadata)) {
1693
+ report.error("validation-error", JSON_ISSUE.BAD_VALUE, `${element}: metadata must be an object`, {
1694
+ element,
1695
+ });
1696
+ }
1697
+ ctx.writeNested(ctx.edges, edge, record, dict, structural, ctx.options.weightFrom);
1698
+ };
1699
+
1700
+ for (let i = 0; i < edges.length; i++) {
1701
+ const element = `edges[${i}]`;
1702
+ const record = edges[i];
1703
+ if (!isJsonObject(record)) {
1704
+ ctx.badElement("edge", element);
1705
+ continue;
1706
+ }
1707
+ if (!hasKey(record, "source") || !hasKey(record, "target")) {
1708
+ ctx.missingEndpoint(element, hasKey(record, "source") ? "target" : "source");
1709
+ continue;
1710
+ }
1711
+ try {
1712
+ const u = ctx.coerceId(record.source, `${element}.source`);
1713
+ const v = ctx.coerceId(record.target, `${element}.target`);
1714
+ if (u === null || v === null) {
1715
+ ctx.countSkipped("edge");
1716
+ continue;
1717
+ }
1718
+ const edgeDirected = flagOf(record.directed, `${element}.directed`, directed, report);
1719
+ writeJgfEdge(record, u, v, edgeDirected ? "directed" : "undirected", element);
1720
+ } catch (err) {
1721
+ ctx.skip(err, "edge", element);
1722
+ }
1723
+ }
1724
+
1725
+ importHyperedges(ctx, hyperedges, directed, writeJgfEdge);
1726
+ ctx.setMeta(shape, { name: label, ...ctx.weightOriginPatch() });
1727
+ }
1728
+
1729
+ /**
1730
+ * The graph object of a JGF document: `graph`, or `graphs[graphIndex]`.
1731
+ * @param ctx - the context
1732
+ * @param root - the document
1733
+ * @returns the graph object; the import fails when there is none
1734
+ */
1735
+ function jgfGraphOf(ctx: ImportContext, root: JsonRecord): JsonRecord {
1736
+ const { report } = ctx;
1737
+ if (isJsonObject(root.graph)) {
1738
+ return root.graph;
1739
+ }
1740
+ const graphs = arraySection(root.graphs, "graphs", report) ?? [];
1741
+ if (graphs.length === 0) {
1742
+ report.fail(JSON_ISSUE.SHAPE, "a JGF document needs a graph object or a non-empty graphs array");
1743
+ }
1744
+ if (graphs.length > 1) {
1745
+ report.warning(
1746
+ "unsupported",
1747
+ JSON_ISSUE.MULTIPLE_GRAPHS,
1748
+ `the document holds ${graphs.length} graphs; only graphs[${ctx.json.graphIndex}] is read`,
1749
+ { element: "graphs" },
1750
+ );
1751
+ }
1752
+ if (ctx.json.graphIndex >= graphs.length) {
1753
+ report.fail(JSON_ISSUE.SHAPE, `graphIndex ${ctx.json.graphIndex} is beyond the ${graphs.length} graph(s)`);
1754
+ }
1755
+ const graph = graphs[ctx.json.graphIndex];
1756
+ if (!isJsonObject(graph)) {
1757
+ return report.fail(JSON_ISSUE.SHAPE, `graphs[${ctx.json.graphIndex}] is not an object`);
1758
+ }
1759
+ return graph;
1760
+ }
1761
+
1762
+ /**
1763
+ * Push a JGF node: `label` into the declared column, `metadata` as attributes.
1764
+ * @param ctx - the context
1765
+ * @param id - the node id
1766
+ * @param record - the node record
1767
+ * @param labelColumn - the label column handle, or INVALID_INDEX
1768
+ * @param element - the element name
1769
+ */
1770
+ function pushJgfNode(
1771
+ ctx: ImportContext,
1772
+ id: NodeId,
1773
+ record: JsonRecord,
1774
+ labelColumn: ColumnHandle,
1775
+ element: string,
1776
+ ): void {
1777
+ const index = ctx.pushNode(id, element);
1778
+ if (index < 0) {
1779
+ return;
1780
+ }
1781
+ try {
1782
+ ctx.writeStringField(ctx.nodes, labelColumn, index, record.label, "label", element);
1783
+ const { metadata } = record;
1784
+ if (metadata !== undefined && metadata !== null && !isJsonObject(metadata)) {
1785
+ ctx.report.error("validation-error", JSON_ISSUE.BAD_VALUE, `${element}: metadata must be an object`, {
1786
+ element,
1787
+ });
1788
+ }
1789
+ ctx.writeNested(ctx.nodes, index, record, isJsonObject(metadata) ? metadata : {}, JGF_NODE_KEYS, null);
1790
+ } catch (err) {
1791
+ ctx.report.recordError(err, { element: String(id) });
1792
+ }
1793
+ }
1794
+
1795
+ /**
1796
+ * JGF hyperedges per the `hyperedges` option: "error" aborts, "skip" (default) records a warning
1797
+ * and a loss note, "star" and "clique" expand an undirected `{ nodes }` hyperedge into edges from
1798
+ * its first node (star) or between every pair (clique); a directed `{ source, target }` hyperedge
1799
+ * becomes every source -> target edge under both policies. Every expanded edge carries the
1800
+ * hyperedge's id, relation, label and metadata.
1801
+ * @param ctx - the context
1802
+ * @param hyperedges - the hyperedge records
1803
+ * @param directed - the graph's direction
1804
+ * @param push - the edge writer of the JGF reader
1805
+ */
1806
+ function importHyperedges(
1807
+ ctx: ImportContext,
1808
+ hyperedges: readonly unknown[],
1809
+ directed: boolean,
1810
+ push: (
1811
+ record: JsonRecord,
1812
+ u: NodeId,
1813
+ v: NodeId,
1814
+ kind: EdgeKind,
1815
+ element: string,
1816
+ structural: ReadonlySet<string>,
1817
+ ) => void,
1818
+ ): void {
1819
+ if (hyperedges.length === 0) {
1820
+ return;
1821
+ }
1822
+ const { report } = ctx;
1823
+ const policy = ctx.options.hyperedges;
1824
+ if (policy === "error") {
1825
+ report.error("unsupported", JSON_ISSUE.HYPEREDGE, `${hyperedges.length} hyperedge(s) (hyperedges: "error")`, {
1826
+ element: "hyperedges",
1827
+ });
1828
+ throw report.abort("hyperedges refused", { code: JSON_ISSUE.HYPEREDGE, count: hyperedges.length });
1829
+ }
1830
+ if (policy === "skip") {
1831
+ report.warning("unsupported", JSON_ISSUE.HYPEREDGES_SKIPPED, `${hyperedges.length} hyperedge(s) skipped`, {
1832
+ element: "hyperedges",
1833
+ });
1834
+ report.loss(
1835
+ JSON_ISSUE.HYPEREDGES_SKIPPED,
1836
+ `${hyperedges.length} hyperedge(s) were not imported`,
1837
+ null,
1838
+ hyperedges.length,
1839
+ );
1840
+ return;
1841
+ }
1842
+ for (let i = 0; i < hyperedges.length; i++) {
1843
+ const element = `hyperedges[${i}]`;
1844
+ const record = hyperedges[i];
1845
+ if (!isJsonObject(record)) {
1846
+ ctx.badElement("edge", element);
1847
+ continue;
1848
+ }
1849
+ try {
1850
+ if (Array.isArray(record.nodes)) {
1851
+ const members = record.nodes.map((raw) => ctx.requireId(raw, element));
1852
+ if (members.length < 2) {
1853
+ throw new GraphFormatError(
1854
+ "E_INVALID_ID",
1855
+ `${element}: an undirected hyperedge needs two or more nodes`,
1856
+ { reason: "hyperedge shape" },
1857
+ );
1858
+ }
1859
+ const kind: EdgeKind = directed ? "directed" : "undirected";
1860
+ if (policy === "star") {
1861
+ for (let k = 1; k < members.length; k++) {
1862
+ push(record, members[0], members[k], kind, element, JGF_HYPEREDGE_KEYS);
1863
+ }
1864
+ } else {
1865
+ for (let a = 0; a < members.length; a++) {
1866
+ for (let b = a + 1; b < members.length; b++) {
1867
+ push(record, members[a], members[b], kind, element, JGF_HYPEREDGE_KEYS);
1868
+ }
1869
+ }
1870
+ }
1871
+ } else if (Array.isArray(record.source) && Array.isArray(record.target)) {
1872
+ const sources = record.source.map((raw) => ctx.requireId(raw, element));
1873
+ const targets = record.target.map((raw) => ctx.requireId(raw, element));
1874
+ if (sources.length === 0 || targets.length === 0) {
1875
+ throw new GraphFormatError(
1876
+ "E_INVALID_ID",
1877
+ `${element}: a directed hyperedge needs sources and targets`,
1878
+ { reason: "hyperedge shape" },
1879
+ );
1880
+ }
1881
+ for (const s of sources) {
1882
+ for (const t of targets) {
1883
+ push(record, s, t, "directed", element, JGF_HYPEREDGE_KEYS);
1884
+ }
1885
+ }
1886
+ } else {
1887
+ report.error(
1888
+ "validation-error",
1889
+ JSON_ISSUE.HYPEREDGE_SHAPE,
1890
+ `${element} has neither a nodes array nor source / target arrays`,
1891
+ { element },
1892
+ );
1893
+ ctx.countSkipped("edge");
1894
+ }
1895
+ } catch (err) {
1896
+ ctx.skip(err, "edge", element);
1897
+ }
1898
+ }
1899
+ }
1900
+
1901
+ // ============================================================ Cytoscape
1902
+
1903
+ /**
1904
+ * Read Cytoscape.js elements: `elements.nodes` / `elements.edges`, a flat `elements` array (group
1905
+ * from `group` or from the presence of source / target), or a top-level array. `data.id` is the id,
1906
+ * `data.parent` the parent (resolved after every node is known), `position` the position column,
1907
+ * `classes` the classes list; the other `data` keys are attributes and the element-level keys
1908
+ * (selected, locked, ...) are columns of the same name.
1909
+ * @param ctx - the context
1910
+ * @param root - the document
1911
+ */
1912
+ function importCytoscape(ctx: ImportContext, root: unknown): void {
1913
+ const { report } = ctx;
1914
+ let elements: unknown;
1915
+ let extra: Record<string, unknown> | undefined;
1916
+ if (Array.isArray(root)) {
1917
+ elements = root;
1918
+ } else if (isJsonObject(root)) {
1919
+ ({ elements } = root);
1920
+ ctx.writeGraphDict(root.data, "data");
1921
+ const rest: Record<string, unknown> = {};
1922
+ for (const key of Object.keys(root)) {
1923
+ if (key !== "elements" && key !== "data") {
1924
+ rest[key] = root[key];
1925
+ }
1926
+ }
1927
+ if (Object.keys(rest).length > 0) {
1928
+ extra = rest;
1929
+ }
1930
+ } else {
1931
+ report.fail(JSON_ISSUE.SHAPE, `a Cytoscape document must be an object or an array, found ${describe(root)}`);
1932
+ }
1933
+ const { nodes, edges } = cytoscapeSections(ctx, elements);
1934
+ ctx.setHeader(ctx.defaultDirected("cytoscape"));
1935
+ ctx.reportNodeIdFrom("cytoscape", "data.id");
1936
+ ctx.sink.reserve(nodes.length, edges.length);
1937
+
1938
+ const dataHas = (items: readonly unknown[], key: string): boolean =>
1939
+ items.some((item) => isJsonObject(item) && isJsonObject(item.data) && item.data[key] !== undefined);
1940
+ const positionColumn = ctx.nodes.declareIf(
1941
+ {
1942
+ name: POSITION_COLUMN,
1943
+ dtype: "f32",
1944
+ components: 3,
1945
+ role: "position",
1946
+ mutable: true,
1947
+ nullable: true,
1948
+ extra: { sourceDims: 2, units: "file" },
1949
+ origin: { format: "json", namespace: "cytoscape" },
1950
+ },
1951
+ anyHas(nodes, "position"),
1952
+ );
1953
+ const classesColumn = ctx.nodes.declareIf(
1954
+ { name: CLASSES_COLUMN, dtype: "list", itemDtype: "string", role: "classes", nullable: true },
1955
+ anyHas(nodes, "classes"),
1956
+ );
1957
+ const parentColumn = ctx.nodes.declareIf(
1958
+ { name: PARENT_COLUMN, dtype: "u32", role: "parent", refersTo: "node", nullable: true },
1959
+ dataHas(nodes, "parent"),
1960
+ );
1961
+ const edgeClassesColumn = ctx.edges.declareIf(
1962
+ { name: CLASSES_COLUMN, dtype: "list", itemDtype: "string", role: "classes", nullable: true },
1963
+ anyHas(edges, "classes"),
1964
+ );
1965
+ const parents: { index: number; parent: NodeId; element: string }[] = [];
1966
+ const point: [number, number, number] = [0, 0, 0];
1967
+
1968
+ for (let i = 0; i < nodes.length; i++) {
1969
+ const element = `nodes[${i}]`;
1970
+ const record = nodes[i];
1971
+ if (!isJsonObject(record) || !isJsonObject(record.data)) {
1972
+ ctx.badElement("node", element, "an element with a data object");
1973
+ continue;
1974
+ }
1975
+ const { data } = record;
1976
+ const id = ctx.coerceId(hasKey(data, "id") ? data.id : undefined, element);
1977
+ if (id === null) {
1978
+ ctx.countSkipped("node");
1979
+ continue;
1980
+ }
1981
+ const index = ctx.pushNode(id, element);
1982
+ if (index < 0) {
1983
+ continue;
1984
+ }
1985
+ try {
1986
+ for (const key of Object.keys(data)) {
1987
+ if (key === "id") {
1988
+ continue;
1989
+ }
1990
+ if (key === "parent") {
1991
+ const raw = data.parent;
1992
+ if (raw !== undefined && raw !== null) {
1993
+ const parent = ctx.coerceId(raw, `${element}.data.parent`);
1994
+ if (parent !== null) {
1995
+ parents.push({ index, parent, element });
1996
+ }
1997
+ }
1998
+ continue;
1999
+ }
2000
+ ctx.nodes.write(index, key, data[key], SUFFIX.data);
2001
+ }
2002
+ const { position } = record;
2003
+ if (position !== undefined && position !== null) {
2004
+ if (isJsonObject(position) && typeof position.x === "number" && typeof position.y === "number") {
2005
+ point[0] = position.x;
2006
+ point[1] = position.y;
2007
+ ctx.nodes.set(positionColumn, index, point);
2008
+ } else {
2009
+ report.error(
2010
+ "validation-error",
2011
+ JSON_ISSUE.BAD_VALUE,
2012
+ `${element}: position must be an object with numeric x and y`,
2013
+ { element },
2014
+ );
2015
+ }
2016
+ }
2017
+ writeClasses(ctx, ctx.nodes, classesColumn, index, record.classes, element);
2018
+ writeElementKeys(ctx.nodes, index, record);
2019
+ } catch (err) {
2020
+ report.recordError(err, { element: String(id) });
2021
+ }
2022
+ }
2023
+ for (const { index, parent, element } of parents) {
2024
+ const parentIndex = ctx.sink.indexOf(parent);
2025
+ if (parentIndex === INVALID_INDEX) {
2026
+ report.error(
2027
+ "missing-value",
2028
+ JSON_ISSUE.UNKNOWN_PARENT,
2029
+ `${element}: parent ${JSON.stringify(parent)} is not a node`,
2030
+ { element },
2031
+ );
2032
+ continue;
2033
+ }
2034
+ ctx.nodes.set(parentColumn, index, parentIndex);
2035
+ }
2036
+ throwIfAborted(ctx.options.signal);
2037
+
2038
+ importCytoscapeEdges(ctx, edges, edgeClassesColumn);
2039
+ ctx.setMeta({ dialect: "cytoscape", cytoscape: extra }, ctx.weightOriginPatch());
2040
+ }
2041
+
2042
+ /**
2043
+ * Read the Cytoscape edge elements: `data.id`, `data.source` / `data.target`, the weight, the
2044
+ * other data keys as attributes, `classes` and the element-level keys.
2045
+ * @param ctx - the context
2046
+ * @param edges - the edge elements
2047
+ * @param edgeClassesColumn - the edge classes column, or INVALID_INDEX
2048
+ */
2049
+ function importCytoscapeEdges(ctx: ImportContext, edges: readonly unknown[], edgeClassesColumn: ColumnHandle): void {
2050
+ const ids = ctx.declareEdgeIds(edges, (edge) => (isJsonObject(edge.data) ? edge.data.id : undefined), "id", true);
2051
+ const kind = ctx.uniformKind();
2052
+ for (let i = 0; i < edges.length; i++) {
2053
+ const element = `edges[${i}]`;
2054
+ const record = edges[i];
2055
+ if (!isJsonObject(record) || !isJsonObject(record.data)) {
2056
+ ctx.badElement("edge", element, "an element with a data object");
2057
+ continue;
2058
+ }
2059
+ const { data } = record;
2060
+ if (!hasKey(data, "source") || !hasKey(data, "target")) {
2061
+ ctx.missingEndpoint(element, `data.${hasKey(data, "source") ? "target" : "source"}`);
2062
+ continue;
2063
+ }
2064
+ try {
2065
+ const u = ctx.coerceId(data.source, `${element}.data.source`);
2066
+ const v = ctx.coerceId(data.target, `${element}.data.target`);
2067
+ if (u === null || v === null) {
2068
+ ctx.countSkipped("edge");
2069
+ continue;
2070
+ }
2071
+ const idValue = ctx.edgeIdValue(ids, data.id);
2072
+ const edge = ctx.pushEdge(u, v, kind, ctx.weightOf(data), element);
2073
+ ctx.setEdgeId(ids, edge, idValue);
2074
+ const { weightFrom } = ctx.options;
2075
+ for (const key of Object.keys(data)) {
2076
+ if (key !== "id" && key !== "source" && key !== "target" && key !== weightFrom) {
2077
+ ctx.edges.write(edge, key, data[key], SUFFIX.data);
2078
+ }
2079
+ }
2080
+ writeClasses(ctx, ctx.edges, edgeClassesColumn, edge, record.classes, element);
2081
+ writeElementKeys(ctx.edges, edge, record);
2082
+ } catch (err) {
2083
+ ctx.skip(err, "edge", element);
2084
+ }
2085
+ }
2086
+ }
2087
+
2088
+ /**
2089
+ * The node and edge element arrays of a Cytoscape `elements` value: an object with `nodes` /
2090
+ * `edges`, a flat array split by isNodeElement(), or nothing (reported).
2091
+ * @param ctx - the context
2092
+ * @param elements - the `elements` value
2093
+ * @returns the two arrays; the import fails when elements has another type
2094
+ */
2095
+ function cytoscapeSections(
2096
+ ctx: ImportContext,
2097
+ elements: unknown,
2098
+ ): { readonly nodes: readonly unknown[]; readonly edges: readonly unknown[] } {
2099
+ const { report } = ctx;
2100
+ if (Array.isArray(elements)) {
2101
+ return {
2102
+ nodes: elements.filter((item) => isJsonObject(item) && isNodeElement(item)),
2103
+ edges: elements.filter((item) => isJsonObject(item) && !isNodeElement(item)),
2104
+ };
2105
+ }
2106
+ if (isJsonObject(elements)) {
2107
+ return {
2108
+ nodes: arraySection(elements.nodes, "elements.nodes", report) ?? [],
2109
+ edges: arraySection(elements.edges, "elements.edges", report) ?? [],
2110
+ };
2111
+ }
2112
+ if (elements === undefined || elements === null) {
2113
+ report.error("missing-value", JSON_ISSUE.MISSING_SECTION, "the document has no elements", {
2114
+ element: "elements",
2115
+ });
2116
+ return { nodes: [], edges: [] };
2117
+ }
2118
+ return report.fail(JSON_ISSUE.SHAPE, `elements must be an object or an array, found ${describe(elements)}`);
2119
+ }
2120
+
2121
+ /**
2122
+ * Whether a flat Cytoscape element is a node: `group: "nodes"`, or no source / target in its data.
2123
+ * @param element - the element
2124
+ * @returns true for a node
2125
+ */
2126
+ function isNodeElement(element: JsonRecord): boolean {
2127
+ if (element.group === "nodes") {
2128
+ return true;
2129
+ }
2130
+ if (element.group === "edges") {
2131
+ return false;
2132
+ }
2133
+ const { data } = element;
2134
+ return !(isJsonObject(data) && hasKey(data, "source") && hasKey(data, "target"));
2135
+ }
2136
+
2137
+ /**
2138
+ * Write the classes of an element: a space-separated string or an array of strings.
2139
+ * @param ctx - the context
2140
+ * @param writer - the table writer
2141
+ * @param column - the classes column, or INVALID_INDEX when no element has classes
2142
+ * @param row - the row
2143
+ * @param raw - the `classes` value
2144
+ * @param element - the element name
2145
+ */
2146
+ function writeClasses(
2147
+ ctx: ImportContext,
2148
+ writer: AttributeWriter,
2149
+ column: ColumnHandle,
2150
+ row: number,
2151
+ raw: unknown,
2152
+ element: string,
2153
+ ): void {
2154
+ if (column === INVALID_INDEX || raw === undefined || raw === null) {
2155
+ return;
2156
+ }
2157
+ let classes: string[];
2158
+ if (typeof raw === "string") {
2159
+ classes = raw.split(/\s+/).filter((c) => c.length > 0);
2160
+ } else if (Array.isArray(raw) && raw.every((c) => typeof c === "string")) {
2161
+ classes = raw;
2162
+ } else {
2163
+ ctx.report.error(
2164
+ "validation-error",
2165
+ JSON_ISSUE.BAD_VALUE,
2166
+ `${element}: classes must be a string or an array of strings`,
2167
+ { element },
2168
+ );
2169
+ return;
2170
+ }
2171
+ writer.set(column, row, classes);
2172
+ }
2173
+
2174
+ /**
2175
+ * Write the element-level keys of a Cytoscape element other than the structural ones: the known
2176
+ * keys under their own name, unknown ones with the `#element` suffix.
2177
+ * @param writer - the table writer
2178
+ * @param row - the row
2179
+ * @param record - the element
2180
+ */
2181
+ function writeElementKeys(writer: AttributeWriter, row: number, record: JsonRecord): void {
2182
+ for (const key of Object.keys(record)) {
2183
+ if (CYTOSCAPE_STRUCTURAL_KEYS.has(key)) {
2184
+ continue;
2185
+ }
2186
+ const name = CYTOSCAPE_ELEMENT_KEYS.has(key) ? key : `${key}${SUFFIX.element}`;
2187
+ writer.write(row, name, record[key], SUFFIX.data);
2188
+ }
2189
+ }
2190
+
2191
+ // ============================================================ the plugin
2192
+
2193
+ /**
2194
+ * The JSON importer plugin (design section 8.4).
2195
+ */
2196
+ export const jsonImporter: GraphImporter<JsonImportOptions> = Object.freeze({
2197
+ format: "json",
2198
+ extensions: Object.freeze([".json"]),
2199
+ mimeTypes: Object.freeze(["application/json"]),
2200
+
2201
+ /**
2202
+ * Confidence that the head is a JSON graph document: 0 unless it starts with `{` or `[`, 0.5
2203
+ * for any JSON, 0.9 when a graph key (nodes, links, edges, elements, graph, graphs) appears in
2204
+ * the head.
2205
+ * @param head - the first bytes
2206
+ * @returns the confidence
2207
+ */
2208
+ sniff(head: Uint8Array): number {
2209
+ const text = new TextDecoder("utf-8").decode(head.subarray(0, SNIFF_BYTES));
2210
+ const trimmed = (text.startsWith(BOM) ? text.slice(1) : text).trimStart();
2211
+ if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
2212
+ return 0;
2213
+ }
2214
+ return SNIFF_KEYS.some((key) => trimmed.includes(key)) ? 0.9 : 0.5;
2215
+ },
2216
+
2217
+ /**
2218
+ * Read a JSON graph document into the sink.
2219
+ * @param input - the text, bytes or stream
2220
+ * @param sink - the sink
2221
+ * @param options - format-specific and common options
2222
+ * @returns the import report; ImportError on a fatal error or beyond the error limit
2223
+ */
2224
+ async import(
2225
+ input: ImportInput,
2226
+ sink: GraphSink,
2227
+ options?: JsonImportOptions & CommonImportOptions,
2228
+ ): Promise<ImportReport> {
2229
+ const resolved = resolveImportOptions(options, FORMAT_DEFAULTS);
2230
+ const json = resolveJsonOptions(options);
2231
+ const report = new ImportReportBuilder("json", resolved.errorLimit);
2232
+ const text = await readText(input, report, resolved);
2233
+ const root = parseDocument(text, report);
2234
+ const dialect = detectDialect(root, json.dialect, report);
2235
+ const ctx = new ImportContext(sink, report, resolved, json, options?.defaultDirected !== undefined);
2236
+ reportSinkOptions(sink, options, report);
2237
+ reportUnusedOptions(options, report, USED_OPTIONS);
2238
+ if (dialect === "cytoscape") {
2239
+ importCytoscape(ctx, root);
2240
+ throwIfAborted(resolved.signal);
2241
+ return report.finish();
2242
+ }
2243
+ const doc = isJsonObject(root)
2244
+ ? root
2245
+ : report.fail(JSON_ISSUE.SHAPE, `a ${dialect} document must be a JSON object, found ${describe(root)}`);
2246
+ switch (dialect) {
2247
+ case "node-link":
2248
+ case "d3":
2249
+ importNodeLink(ctx, doc, dialect);
2250
+ break;
2251
+ case "jgf":
2252
+ importJgf(ctx, doc);
2253
+ break;
2254
+ case "graphology":
2255
+ importGraphology(ctx, doc);
2256
+ break;
2257
+ case "vis":
2258
+ importVis(ctx, doc);
2259
+ break;
2260
+ default: {
2261
+ const name: string = dialect;
2262
+ throw new GraphFormatError("E_UNSUPPORTED", `unknown dialect ${name}`, {
2263
+ option: "dialect",
2264
+ found: name,
2265
+ });
2266
+ }
2267
+ }
2268
+ throwIfAborted(resolved.signal);
2269
+ return report.finish();
2270
+ },
2271
+ });