@graphty/graph-io 0.3.25 → 0.3.27

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 (581) hide show
  1. package/README.md +49 -414
  2. package/dist/chunks/children-CL3Cy0ez.js.map +1 -1
  3. package/dist/chunks/choose-DrXtu_96.js +56 -0
  4. package/dist/chunks/choose-DrXtu_96.js.map +1 -0
  5. package/dist/chunks/constants-ikG85dT8.js +231 -0
  6. package/dist/chunks/constants-ikG85dT8.js.map +1 -0
  7. package/dist/chunks/{escape-B-tkk_Cl.js → escape-D1HPTUrP.js} +390 -99
  8. package/dist/chunks/escape-D1HPTUrP.js.map +1 -0
  9. package/dist/chunks/export-CHd4tQE2.js +4333 -0
  10. package/dist/chunks/export-CHd4tQE2.js.map +1 -0
  11. package/dist/chunks/{importer-eUREhBGj.js → exporter-D23CKNqF.js} +4969 -3201
  12. package/dist/chunks/exporter-D23CKNqF.js.map +1 -0
  13. package/dist/chunks/{exporter-DIeGJXAZ.js → exporter-DfWTgrti.js} +23 -21
  14. package/dist/chunks/exporter-DfWTgrti.js.map +1 -0
  15. package/dist/chunks/exporter-DhtW6FxK.js +3465 -0
  16. package/dist/chunks/exporter-DhtW6FxK.js.map +1 -0
  17. package/dist/chunks/{exporter-BGrsWamJ.js → exporter-IHv4nkCm.js} +38 -229
  18. package/dist/chunks/exporter-IHv4nkCm.js.map +1 -0
  19. package/dist/chunks/{importer-BWY2FFc5.js → importer-B1wmPQjr.js} +255 -77
  20. package/dist/chunks/importer-B1wmPQjr.js.map +1 -0
  21. package/dist/chunks/{importer-D_e7e7LX.js → importer-B2W_58pz.js} +399 -146
  22. package/dist/chunks/importer-B2W_58pz.js.map +1 -0
  23. package/dist/chunks/importer-BSALvkui.js +2811 -0
  24. package/dist/chunks/importer-BSALvkui.js.map +1 -0
  25. package/dist/chunks/{importer-vi3bSdtw.js → importer-BVRE--Un.js} +510 -77
  26. package/dist/chunks/importer-BVRE--Un.js.map +1 -0
  27. package/dist/chunks/{importer-DET1tg6s.js → importer-CIAzt-wo.js} +1689 -312
  28. package/dist/chunks/importer-CIAzt-wo.js.map +1 -0
  29. package/dist/chunks/{importer-DIrbbnAf.js → importer-DpEW5zeG.js} +585 -226
  30. package/dist/chunks/importer-DpEW5zeG.js.map +1 -0
  31. package/dist/chunks/{importer-zGLo8Dg_.js → importer-z_FL4VMb.js} +488 -161
  32. package/dist/chunks/importer-z_FL4VMb.js.map +1 -0
  33. package/dist/chunks/json-elements-BAmNBZok.js +1225 -0
  34. package/dist/chunks/json-elements-BAmNBZok.js.map +1 -0
  35. package/dist/chunks/ontology-export-Bab4eUA8.js +357 -0
  36. package/dist/chunks/ontology-export-Bab4eUA8.js.map +1 -0
  37. package/dist/chunks/{records-dbRkxwaq.js → records-CdlvL4RO.js} +298 -114
  38. package/dist/chunks/records-CdlvL4RO.js.map +1 -0
  39. package/dist/chunks/{text-Dr0Ifpag.js → text-DzXjAiTl.js} +29 -11
  40. package/dist/chunks/text-DzXjAiTl.js.map +1 -0
  41. package/dist/chunks/{writer-C7flM-Ih.js → writer-DxjgHwSe.js} +19 -1
  42. package/dist/chunks/writer-DxjgHwSe.js.map +1 -0
  43. package/dist/csv.js +952 -262
  44. package/dist/csv.js.map +1 -1
  45. package/dist/cx.js +6 -3
  46. package/dist/cx2.js +2 -2
  47. package/dist/cys.js +6 -3
  48. package/dist/dot.js +1 -1
  49. package/dist/gexf.js +554 -122
  50. package/dist/gexf.js.map +1 -1
  51. package/dist/gml.js +344 -87
  52. package/dist/gml.js.map +1 -1
  53. package/dist/graph-io.js +1105 -213
  54. package/dist/graph-io.js.map +1 -1
  55. package/dist/graphml.js +3 -2
  56. package/dist/graphml.js.map +1 -1
  57. package/dist/json.js +3 -2
  58. package/dist/neo4j.js +565 -158
  59. package/dist/neo4j.js.map +1 -1
  60. package/dist/obo.js +6 -3
  61. package/dist/pajek.js +1 -1
  62. package/dist/src/children.d.ts +18 -6
  63. package/dist/src/children.d.ts.map +1 -1
  64. package/dist/src/children.js +6 -3
  65. package/dist/src/children.js.map +1 -1
  66. package/dist/src/common/attributes.d.ts +29 -11
  67. package/dist/src/common/attributes.d.ts.map +1 -1
  68. package/dist/src/common/attributes.js +25 -7
  69. package/dist/src/common/attributes.js.map +1 -1
  70. package/dist/src/common/cell-budget.d.ts +9 -2
  71. package/dist/src/common/cell-budget.d.ts.map +1 -1
  72. package/dist/src/common/cell-budget.js +11 -3
  73. package/dist/src/common/cell-budget.js.map +1 -1
  74. package/dist/src/common/choose.d.ts +19 -0
  75. package/dist/src/common/choose.d.ts.map +1 -0
  76. package/dist/src/common/choose.js +72 -0
  77. package/dist/src/common/choose.js.map +1 -0
  78. package/dist/src/common/codes.d.ts +456 -66
  79. package/dist/src/common/codes.d.ts.map +1 -1
  80. package/dist/src/common/codes.js +458 -66
  81. package/dist/src/common/codes.js.map +1 -1
  82. package/dist/src/common/compare.d.ts +81 -0
  83. package/dist/src/common/compare.d.ts.map +1 -0
  84. package/dist/src/common/compare.js +394 -0
  85. package/dist/src/common/compare.js.map +1 -0
  86. package/dist/src/common/cx-export.d.ts +123 -0
  87. package/dist/src/common/cx-export.d.ts.map +1 -0
  88. package/dist/src/common/cx-export.js +334 -0
  89. package/dist/src/common/cx-export.js.map +1 -0
  90. package/dist/src/common/declared-types.d.ts +22 -2
  91. package/dist/src/common/declared-types.d.ts.map +1 -1
  92. package/dist/src/common/declared-types.js +21 -3
  93. package/dist/src/common/declared-types.js.map +1 -1
  94. package/dist/src/common/direction.d.ts +74 -50
  95. package/dist/src/common/direction.d.ts.map +1 -1
  96. package/dist/src/common/direction.js +47 -27
  97. package/dist/src/common/direction.js.map +1 -1
  98. package/dist/src/common/escape.d.ts +21 -9
  99. package/dist/src/common/escape.d.ts.map +1 -1
  100. package/dist/src/common/escape.js +39 -22
  101. package/dist/src/common/escape.js.map +1 -1
  102. package/dist/src/common/export.d.ts +144 -32
  103. package/dist/src/common/export.d.ts.map +1 -1
  104. package/dist/src/common/export.js +216 -42
  105. package/dist/src/common/export.js.map +1 -1
  106. package/dist/src/common/format.d.ts +23 -1
  107. package/dist/src/common/format.d.ts.map +1 -1
  108. package/dist/src/common/format.js +26 -1
  109. package/dist/src/common/format.js.map +1 -1
  110. package/dist/src/common/ids.d.ts +9 -2
  111. package/dist/src/common/ids.d.ts.map +1 -1
  112. package/dist/src/common/ids.js +6 -2
  113. package/dist/src/common/ids.js.map +1 -1
  114. package/dist/src/common/input.d.ts +122 -28
  115. package/dist/src/common/input.d.ts.map +1 -1
  116. package/dist/src/common/input.js +890 -124
  117. package/dist/src/common/input.js.map +1 -1
  118. package/dist/src/common/json-elements.d.ts +144 -20
  119. package/dist/src/common/json-elements.d.ts.map +1 -1
  120. package/dist/src/common/json-elements.js +869 -189
  121. package/dist/src/common/json-elements.js.map +1 -1
  122. package/dist/src/common/line-format.d.ts +90 -0
  123. package/dist/src/common/line-format.d.ts.map +1 -0
  124. package/dist/src/common/line-format.js +185 -0
  125. package/dist/src/common/line-format.js.map +1 -0
  126. package/dist/src/common/lists.d.ts +6 -1
  127. package/dist/src/common/lists.d.ts.map +1 -1
  128. package/dist/src/common/lists.js +3 -1
  129. package/dist/src/common/lists.js.map +1 -1
  130. package/dist/src/common/ontology-export.d.ts +167 -0
  131. package/dist/src/common/ontology-export.d.ts.map +1 -0
  132. package/dist/src/common/ontology-export.js +359 -0
  133. package/dist/src/common/ontology-export.js.map +1 -0
  134. package/dist/src/common/ontology.d.ts +25 -3
  135. package/dist/src/common/ontology.d.ts.map +1 -1
  136. package/dist/src/common/ontology.js +30 -9
  137. package/dist/src/common/ontology.js.map +1 -1
  138. package/dist/src/common/options.d.ts +62 -12
  139. package/dist/src/common/options.d.ts.map +1 -1
  140. package/dist/src/common/options.js +89 -11
  141. package/dist/src/common/options.js.map +1 -1
  142. package/dist/src/common/plural.d.ts +31 -0
  143. package/dist/src/common/plural.d.ts.map +1 -0
  144. package/dist/src/common/plural.js +46 -0
  145. package/dist/src/common/plural.js.map +1 -0
  146. package/dist/src/common/report.d.ts +72 -14
  147. package/dist/src/common/report.d.ts.map +1 -1
  148. package/dist/src/common/report.js +146 -22
  149. package/dist/src/common/report.js.map +1 -1
  150. package/dist/src/common/temporal.d.ts +22 -5
  151. package/dist/src/common/temporal.d.ts.map +1 -1
  152. package/dist/src/common/temporal.js +13 -2
  153. package/dist/src/common/temporal.js.map +1 -1
  154. package/dist/src/common/text.d.ts +39 -23
  155. package/dist/src/common/text.d.ts.map +1 -1
  156. package/dist/src/common/text.js +51 -21
  157. package/dist/src/common/text.js.map +1 -1
  158. package/dist/src/common/weights.d.ts +37 -4
  159. package/dist/src/common/weights.d.ts.map +1 -1
  160. package/dist/src/common/weights.js +61 -2
  161. package/dist/src/common/weights.js.map +1 -1
  162. package/dist/src/common/writer.d.ts +19 -3
  163. package/dist/src/common/writer.d.ts.map +1 -1
  164. package/dist/src/common/writer.js +30 -2
  165. package/dist/src/common/writer.js.map +1 -1
  166. package/dist/src/common/xml.d.ts +125 -9
  167. package/dist/src/common/xml.d.ts.map +1 -1
  168. package/dist/src/common/xml.js +402 -85
  169. package/dist/src/common/xml.js.map +1 -1
  170. package/dist/src/common/zip.d.ts +44 -4
  171. package/dist/src/common/zip.d.ts.map +1 -1
  172. package/dist/src/common/zip.js +134 -5
  173. package/dist/src/common/zip.js.map +1 -1
  174. package/dist/src/formats/csv/exporter.d.ts +80 -24
  175. package/dist/src/formats/csv/exporter.d.ts.map +1 -1
  176. package/dist/src/formats/csv/exporter.js +146 -33
  177. package/dist/src/formats/csv/exporter.js.map +1 -1
  178. package/dist/src/formats/csv/header.d.ts +38 -9
  179. package/dist/src/formats/csv/header.d.ts.map +1 -1
  180. package/dist/src/formats/csv/header.js +46 -10
  181. package/dist/src/formats/csv/header.js.map +1 -1
  182. package/dist/src/formats/csv/importer.d.ts +187 -37
  183. package/dist/src/formats/csv/importer.d.ts.map +1 -1
  184. package/dist/src/formats/csv/importer.js +835 -204
  185. package/dist/src/formats/csv/importer.js.map +1 -1
  186. package/dist/src/formats/csv/index.d.ts +100 -19
  187. package/dist/src/formats/csv/index.d.ts.map +1 -1
  188. package/dist/src/formats/csv/index.js +94 -21
  189. package/dist/src/formats/csv/index.js.map +1 -1
  190. package/dist/src/formats/csv/records.d.ts +95 -10
  191. package/dist/src/formats/csv/records.d.ts.map +1 -1
  192. package/dist/src/formats/csv/records.js +333 -122
  193. package/dist/src/formats/csv/records.js.map +1 -1
  194. package/dist/src/formats/csv/values.d.ts +1 -0
  195. package/dist/src/formats/csv/values.d.ts.map +1 -1
  196. package/dist/src/formats/csv/values.js +1 -0
  197. package/dist/src/formats/csv/values.js.map +1 -1
  198. package/dist/src/formats/cx/exporter.d.ts +102 -0
  199. package/dist/src/formats/cx/exporter.d.ts.map +1 -0
  200. package/dist/src/formats/cx/exporter.js +1217 -0
  201. package/dist/src/formats/cx/exporter.js.map +1 -0
  202. package/dist/src/formats/cx/importer.d.ts +79 -32
  203. package/dist/src/formats/cx/importer.d.ts.map +1 -1
  204. package/dist/src/formats/cx/importer.js +682 -287
  205. package/dist/src/formats/cx/importer.js.map +1 -1
  206. package/dist/src/formats/cx/index.d.ts +4 -3
  207. package/dist/src/formats/cx/index.d.ts.map +1 -1
  208. package/dist/src/formats/cx/index.js +4 -3
  209. package/dist/src/formats/cx/index.js.map +1 -1
  210. package/dist/src/formats/cx2/exporter.d.ts +29 -11
  211. package/dist/src/formats/cx2/exporter.d.ts.map +1 -1
  212. package/dist/src/formats/cx2/exporter.js +39 -268
  213. package/dist/src/formats/cx2/exporter.js.map +1 -1
  214. package/dist/src/formats/cx2/importer.d.ts +78 -28
  215. package/dist/src/formats/cx2/importer.d.ts.map +1 -1
  216. package/dist/src/formats/cx2/importer.js +227 -80
  217. package/dist/src/formats/cx2/importer.js.map +1 -1
  218. package/dist/src/formats/cx2/index.d.ts +2 -1
  219. package/dist/src/formats/cx2/index.d.ts.map +1 -1
  220. package/dist/src/formats/cx2/index.js +2 -1
  221. package/dist/src/formats/cx2/index.js.map +1 -1
  222. package/dist/src/formats/cys/constants.d.ts +66 -17
  223. package/dist/src/formats/cys/constants.d.ts.map +1 -1
  224. package/dist/src/formats/cys/constants.js +59 -18
  225. package/dist/src/formats/cys/constants.js.map +1 -1
  226. package/dist/src/formats/cys/exporter.d.ts +118 -0
  227. package/dist/src/formats/cys/exporter.d.ts.map +1 -0
  228. package/dist/src/formats/cys/exporter.js +1072 -0
  229. package/dist/src/formats/cys/exporter.js.map +1 -0
  230. package/dist/src/formats/cys/importer.d.ts +22 -8
  231. package/dist/src/formats/cys/importer.d.ts.map +1 -1
  232. package/dist/src/formats/cys/importer.js +510 -149
  233. package/dist/src/formats/cys/importer.js.map +1 -1
  234. package/dist/src/formats/cys/index.d.ts +4 -2
  235. package/dist/src/formats/cys/index.d.ts.map +1 -1
  236. package/dist/src/formats/cys/index.js +4 -2
  237. package/dist/src/formats/cys/index.js.map +1 -1
  238. package/dist/src/formats/cys/session.d.ts +46 -7
  239. package/dist/src/formats/cys/session.d.ts.map +1 -1
  240. package/dist/src/formats/cys/session.js +101 -27
  241. package/dist/src/formats/cys/session.js.map +1 -1
  242. package/dist/src/formats/cys/tables.d.ts +9 -2
  243. package/dist/src/formats/cys/tables.d.ts.map +1 -1
  244. package/dist/src/formats/cys/tables.js +29 -7
  245. package/dist/src/formats/cys/tables.js.map +1 -1
  246. package/dist/src/formats/dot/exporter.d.ts +42 -14
  247. package/dist/src/formats/dot/exporter.d.ts.map +1 -1
  248. package/dist/src/formats/dot/exporter.js +56 -29
  249. package/dist/src/formats/dot/exporter.js.map +1 -1
  250. package/dist/src/formats/dot/importer.d.ts +91 -29
  251. package/dist/src/formats/dot/importer.d.ts.map +1 -1
  252. package/dist/src/formats/dot/importer.js +190 -39
  253. package/dist/src/formats/dot/importer.js.map +1 -1
  254. package/dist/src/formats/dot/index.d.ts +2 -1
  255. package/dist/src/formats/dot/index.d.ts.map +1 -1
  256. package/dist/src/formats/dot/index.js +2 -1
  257. package/dist/src/formats/dot/index.js.map +1 -1
  258. package/dist/src/formats/dot/names.d.ts +44 -11
  259. package/dist/src/formats/dot/names.d.ts.map +1 -1
  260. package/dist/src/formats/dot/names.js +44 -11
  261. package/dist/src/formats/dot/names.js.map +1 -1
  262. package/dist/src/formats/dot/tokenizer.d.ts +9 -2
  263. package/dist/src/formats/dot/tokenizer.d.ts.map +1 -1
  264. package/dist/src/formats/dot/tokenizer.js +20 -5
  265. package/dist/src/formats/dot/tokenizer.js.map +1 -1
  266. package/dist/src/formats/gexf/exporter.d.ts +46 -15
  267. package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
  268. package/dist/src/formats/gexf/exporter.js +45 -15
  269. package/dist/src/formats/gexf/exporter.js.map +1 -1
  270. package/dist/src/formats/gexf/importer.d.ts +87 -23
  271. package/dist/src/formats/gexf/importer.d.ts.map +1 -1
  272. package/dist/src/formats/gexf/importer.js +471 -90
  273. package/dist/src/formats/gexf/importer.js.map +1 -1
  274. package/dist/src/formats/gexf/index.d.ts +84 -21
  275. package/dist/src/formats/gexf/index.d.ts.map +1 -1
  276. package/dist/src/formats/gexf/index.js +80 -23
  277. package/dist/src/formats/gexf/index.js.map +1 -1
  278. package/dist/src/formats/gexf/schema.d.ts +67 -15
  279. package/dist/src/formats/gexf/schema.d.ts.map +1 -1
  280. package/dist/src/formats/gexf/schema.js +59 -13
  281. package/dist/src/formats/gexf/schema.js.map +1 -1
  282. package/dist/src/formats/gml/exporter.d.ts +69 -21
  283. package/dist/src/formats/gml/exporter.d.ts.map +1 -1
  284. package/dist/src/formats/gml/exporter.js +74 -29
  285. package/dist/src/formats/gml/exporter.js.map +1 -1
  286. package/dist/src/formats/gml/importer.d.ts +116 -25
  287. package/dist/src/formats/gml/importer.d.ts.map +1 -1
  288. package/dist/src/formats/gml/importer.js +290 -61
  289. package/dist/src/formats/gml/importer.js.map +1 -1
  290. package/dist/src/formats/gml/index.d.ts +87 -24
  291. package/dist/src/formats/gml/index.d.ts.map +1 -1
  292. package/dist/src/formats/gml/index.js +79 -26
  293. package/dist/src/formats/gml/index.js.map +1 -1
  294. package/dist/src/formats/gml/syntax.d.ts +67 -18
  295. package/dist/src/formats/gml/syntax.d.ts.map +1 -1
  296. package/dist/src/formats/gml/syntax.js +110 -28
  297. package/dist/src/formats/gml/syntax.js.map +1 -1
  298. package/dist/src/formats/graphml/constants.d.ts +174 -44
  299. package/dist/src/formats/graphml/constants.d.ts.map +1 -1
  300. package/dist/src/formats/graphml/constants.js +165 -44
  301. package/dist/src/formats/graphml/constants.js.map +1 -1
  302. package/dist/src/formats/graphml/exporter.d.ts +17 -9
  303. package/dist/src/formats/graphml/exporter.d.ts.map +1 -1
  304. package/dist/src/formats/graphml/exporter.js +33 -12
  305. package/dist/src/formats/graphml/exporter.js.map +1 -1
  306. package/dist/src/formats/graphml/importer.d.ts +19 -7
  307. package/dist/src/formats/graphml/importer.d.ts.map +1 -1
  308. package/dist/src/formats/graphml/importer.js +466 -39
  309. package/dist/src/formats/graphml/importer.js.map +1 -1
  310. package/dist/src/formats/graphml/index.d.ts +2 -1
  311. package/dist/src/formats/graphml/index.d.ts.map +1 -1
  312. package/dist/src/formats/graphml/index.js +2 -1
  313. package/dist/src/formats/graphml/index.js.map +1 -1
  314. package/dist/src/formats/graphml/tree.d.ts +6 -2
  315. package/dist/src/formats/graphml/tree.d.ts.map +1 -1
  316. package/dist/src/formats/graphml/tree.js +6 -2
  317. package/dist/src/formats/graphml/tree.js.map +1 -1
  318. package/dist/src/formats/graphml/yfiles.d.ts +8 -3
  319. package/dist/src/formats/graphml/yfiles.d.ts.map +1 -1
  320. package/dist/src/formats/graphml/yfiles.js +36 -15
  321. package/dist/src/formats/graphml/yfiles.js.map +1 -1
  322. package/dist/src/formats/json/dialect.d.ts +88 -24
  323. package/dist/src/formats/json/dialect.d.ts.map +1 -1
  324. package/dist/src/formats/json/dialect.js +109 -19
  325. package/dist/src/formats/json/dialect.js.map +1 -1
  326. package/dist/src/formats/json/exporter.d.ts +134 -22
  327. package/dist/src/formats/json/exporter.d.ts.map +1 -1
  328. package/dist/src/formats/json/exporter.js +135 -54
  329. package/dist/src/formats/json/exporter.js.map +1 -1
  330. package/dist/src/formats/json/importer.d.ts +173 -52
  331. package/dist/src/formats/json/importer.d.ts.map +1 -1
  332. package/dist/src/formats/json/importer.js +899 -220
  333. package/dist/src/formats/json/importer.js.map +1 -1
  334. package/dist/src/formats/json/index.d.ts +3 -2
  335. package/dist/src/formats/json/index.d.ts.map +1 -1
  336. package/dist/src/formats/json/index.js +3 -2
  337. package/dist/src/formats/json/index.js.map +1 -1
  338. package/dist/src/formats/json/obographs-export.d.ts +68 -0
  339. package/dist/src/formats/json/obographs-export.d.ts.map +1 -0
  340. package/dist/src/formats/json/obographs-export.js +941 -0
  341. package/dist/src/formats/json/obographs-export.js.map +1 -0
  342. package/dist/src/formats/json/obographs.d.ts +26 -0
  343. package/dist/src/formats/json/obographs.d.ts.map +1 -1
  344. package/dist/src/formats/json/obographs.js +297 -80
  345. package/dist/src/formats/json/obographs.js.map +1 -1
  346. package/dist/src/formats/neo4j/exporter.d.ts +109 -21
  347. package/dist/src/formats/neo4j/exporter.d.ts.map +1 -1
  348. package/dist/src/formats/neo4j/exporter.js +239 -86
  349. package/dist/src/formats/neo4j/exporter.js.map +1 -1
  350. package/dist/src/formats/neo4j/header.d.ts +17 -4
  351. package/dist/src/formats/neo4j/header.d.ts.map +1 -1
  352. package/dist/src/formats/neo4j/header.js +36 -4
  353. package/dist/src/formats/neo4j/header.js.map +1 -1
  354. package/dist/src/formats/neo4j/importer.d.ts +107 -27
  355. package/dist/src/formats/neo4j/importer.d.ts.map +1 -1
  356. package/dist/src/formats/neo4j/importer.js +415 -63
  357. package/dist/src/formats/neo4j/importer.js.map +1 -1
  358. package/dist/src/formats/neo4j/index.d.ts +73 -23
  359. package/dist/src/formats/neo4j/index.d.ts.map +1 -1
  360. package/dist/src/formats/neo4j/index.js +65 -25
  361. package/dist/src/formats/neo4j/index.js.map +1 -1
  362. package/dist/src/formats/obo/exporter.d.ts +150 -0
  363. package/dist/src/formats/obo/exporter.d.ts.map +1 -0
  364. package/dist/src/formats/obo/exporter.js +1736 -0
  365. package/dist/src/formats/obo/exporter.js.map +1 -0
  366. package/dist/src/formats/obo/importer.d.ts +95 -19
  367. package/dist/src/formats/obo/importer.d.ts.map +1 -1
  368. package/dist/src/formats/obo/importer.js +475 -104
  369. package/dist/src/formats/obo/importer.js.map +1 -1
  370. package/dist/src/formats/obo/index.d.ts +6 -3
  371. package/dist/src/formats/obo/index.d.ts.map +1 -1
  372. package/dist/src/formats/obo/index.js +6 -3
  373. package/dist/src/formats/obo/index.js.map +1 -1
  374. package/dist/src/formats/obo/syntax.d.ts +108 -4
  375. package/dist/src/formats/obo/syntax.d.ts.map +1 -1
  376. package/dist/src/formats/obo/syntax.js +211 -34
  377. package/dist/src/formats/obo/syntax.js.map +1 -1
  378. package/dist/src/formats/pajek/exporter.d.ts +54 -16
  379. package/dist/src/formats/pajek/exporter.d.ts.map +1 -1
  380. package/dist/src/formats/pajek/exporter.js +67 -35
  381. package/dist/src/formats/pajek/exporter.js.map +1 -1
  382. package/dist/src/formats/pajek/importer.d.ts +84 -22
  383. package/dist/src/formats/pajek/importer.d.ts.map +1 -1
  384. package/dist/src/formats/pajek/importer.js +303 -83
  385. package/dist/src/formats/pajek/importer.js.map +1 -1
  386. package/dist/src/formats/pajek/index.d.ts +2 -1
  387. package/dist/src/formats/pajek/index.d.ts.map +1 -1
  388. package/dist/src/formats/pajek/index.js +2 -1
  389. package/dist/src/formats/pajek/index.js.map +1 -1
  390. package/dist/src/formats/pajek/syntax.d.ts +77 -14
  391. package/dist/src/formats/pajek/syntax.d.ts.map +1 -1
  392. package/dist/src/formats/pajek/syntax.js +186 -54
  393. package/dist/src/formats/pajek/syntax.js.map +1 -1
  394. package/dist/src/formats/xgmml/columns.d.ts +1 -0
  395. package/dist/src/formats/xgmml/columns.d.ts.map +1 -1
  396. package/dist/src/formats/xgmml/columns.js +4 -0
  397. package/dist/src/formats/xgmml/columns.js.map +1 -1
  398. package/dist/src/formats/xgmml/constants.d.ts +186 -56
  399. package/dist/src/formats/xgmml/constants.d.ts.map +1 -1
  400. package/dist/src/formats/xgmml/constants.js +181 -57
  401. package/dist/src/formats/xgmml/constants.js.map +1 -1
  402. package/dist/src/formats/xgmml/document.d.ts +65 -11
  403. package/dist/src/formats/xgmml/document.d.ts.map +1 -1
  404. package/dist/src/formats/xgmml/document.js +157 -28
  405. package/dist/src/formats/xgmml/document.js.map +1 -1
  406. package/dist/src/formats/xgmml/emit.d.ts +61 -3
  407. package/dist/src/formats/xgmml/emit.d.ts.map +1 -1
  408. package/dist/src/formats/xgmml/emit.js +129 -26
  409. package/dist/src/formats/xgmml/emit.js.map +1 -1
  410. package/dist/src/formats/xgmml/exporter.d.ts +13 -6
  411. package/dist/src/formats/xgmml/exporter.d.ts.map +1 -1
  412. package/dist/src/formats/xgmml/exporter.js +21 -22
  413. package/dist/src/formats/xgmml/exporter.js.map +1 -1
  414. package/dist/src/formats/xgmml/importer.d.ts +33 -12
  415. package/dist/src/formats/xgmml/importer.d.ts.map +1 -1
  416. package/dist/src/formats/xgmml/importer.js +35 -72
  417. package/dist/src/formats/xgmml/importer.js.map +1 -1
  418. package/dist/src/formats/xgmml/index.d.ts +1 -0
  419. package/dist/src/formats/xgmml/index.d.ts.map +1 -1
  420. package/dist/src/formats/xgmml/index.js +1 -0
  421. package/dist/src/formats/xgmml/index.js.map +1 -1
  422. package/dist/src/formats/xgmml/values.d.ts +8 -1
  423. package/dist/src/formats/xgmml/values.d.ts.map +1 -1
  424. package/dist/src/formats/xgmml/values.js +8 -3
  425. package/dist/src/formats/xgmml/values.js.map +1 -1
  426. package/dist/src/index.d.ts +23 -18
  427. package/dist/src/index.d.ts.map +1 -1
  428. package/dist/src/index.js +28 -16
  429. package/dist/src/index.js.map +1 -1
  430. package/dist/src/registry.d.ts +478 -92
  431. package/dist/src/registry.d.ts.map +1 -1
  432. package/dist/src/registry.js +807 -88
  433. package/dist/src/registry.js.map +1 -1
  434. package/dist/src/sniff.d.ts +61 -19
  435. package/dist/src/sniff.d.ts.map +1 -1
  436. package/dist/src/sniff.js +121 -29
  437. package/dist/src/sniff.js.map +1 -1
  438. package/dist/src/types.d.ts +362 -112
  439. package/dist/src/types.d.ts.map +1 -1
  440. package/dist/src/types.js +28 -7
  441. package/dist/src/types.js.map +1 -1
  442. package/dist/xgmml.js +2 -2
  443. package/docs/samples/README.md +25 -0
  444. package/docs/samples/got-edges.csv +353 -0
  445. package/docs/samples/got-network.graphml +1384 -0
  446. package/docs/samples/got-nodes.csv +108 -0
  447. package/docs/samples/got.cx +930 -0
  448. package/docs/samples/got.cx2 +465 -0
  449. package/docs/samples/got.gexf +471 -0
  450. package/docs/samples/got.gml +2650 -0
  451. package/docs/samples/got.json +2546 -0
  452. package/docs/samples/got.net +461 -0
  453. package/docs/samples/movies-nodes.csv +9 -0
  454. package/docs/samples/movies-rels.csv +8 -0
  455. package/docs/samples/networks.cys +0 -0
  456. package/docs/samples/proteins.xgmml +9 -0
  457. package/docs/samples/teams.gv +24 -0
  458. package/docs/samples/vehicles.obo +25 -0
  459. package/package.json +9 -4
  460. package/src/children.ts +18 -6
  461. package/src/common/attributes.ts +29 -11
  462. package/src/common/cell-budget.ts +11 -3
  463. package/src/common/choose.ts +89 -0
  464. package/src/common/codes.ts +490 -66
  465. package/src/common/compare.ts +472 -0
  466. package/src/common/cx-export.ts +432 -0
  467. package/src/common/declared-types.ts +40 -6
  468. package/src/common/direction.ts +78 -51
  469. package/src/common/escape.ts +39 -22
  470. package/src/common/export.ts +284 -47
  471. package/src/common/format.ts +30 -1
  472. package/src/common/ids.ts +14 -3
  473. package/src/common/input.ts +1057 -134
  474. package/src/common/json-elements.ts +1035 -199
  475. package/src/common/line-format.ts +249 -0
  476. package/src/common/lists.ts +7 -2
  477. package/src/common/ontology-export.ts +461 -0
  478. package/src/common/ontology.ts +31 -9
  479. package/src/common/options.ts +110 -15
  480. package/src/common/plural.ts +49 -0
  481. package/src/common/report.ts +168 -25
  482. package/src/common/temporal.ts +22 -5
  483. package/src/common/text.ts +62 -23
  484. package/src/common/weights.ts +74 -4
  485. package/src/common/writer.ts +37 -3
  486. package/src/common/xml.ts +479 -90
  487. package/src/common/zip.ts +169 -7
  488. package/src/formats/csv/exporter.ts +202 -47
  489. package/src/formats/csv/header.ts +50 -11
  490. package/src/formats/csv/importer.ts +1133 -245
  491. package/src/formats/csv/index.ts +106 -19
  492. package/src/formats/csv/records.ts +396 -122
  493. package/src/formats/csv/values.ts +1 -0
  494. package/src/formats/cx/exporter.ts +1483 -0
  495. package/src/formats/cx/importer.ts +890 -302
  496. package/src/formats/cx/index.ts +4 -3
  497. package/src/formats/cx2/exporter.ts +62 -339
  498. package/src/formats/cx2/importer.ts +318 -87
  499. package/src/formats/cx2/index.ts +2 -1
  500. package/src/formats/cys/constants.ts +62 -17
  501. package/src/formats/cys/exporter.ts +1368 -0
  502. package/src/formats/cys/importer.ts +659 -157
  503. package/src/formats/cys/index.ts +4 -2
  504. package/src/formats/cys/session.ts +142 -34
  505. package/src/formats/cys/tables.ts +47 -9
  506. package/src/formats/dot/exporter.ts +83 -37
  507. package/src/formats/dot/importer.ts +248 -54
  508. package/src/formats/dot/index.ts +2 -1
  509. package/src/formats/dot/names.ts +44 -11
  510. package/src/formats/dot/tokenizer.ts +25 -6
  511. package/src/formats/gexf/exporter.ts +57 -18
  512. package/src/formats/gexf/importer.ts +636 -105
  513. package/src/formats/gexf/index.ts +90 -21
  514. package/src/formats/gexf/schema.ts +67 -15
  515. package/src/formats/gml/exporter.ts +90 -36
  516. package/src/formats/gml/importer.ts +388 -67
  517. package/src/formats/gml/index.ts +85 -24
  518. package/src/formats/gml/syntax.ts +113 -28
  519. package/src/formats/graphml/constants.ts +178 -44
  520. package/src/formats/graphml/exporter.ts +52 -18
  521. package/src/formats/graphml/importer.ts +640 -60
  522. package/src/formats/graphml/index.ts +2 -1
  523. package/src/formats/graphml/tree.ts +6 -2
  524. package/src/formats/graphml/yfiles.ts +37 -15
  525. package/src/formats/json/dialect.ts +128 -29
  526. package/src/formats/json/exporter.ts +205 -68
  527. package/src/formats/json/importer.ts +1163 -253
  528. package/src/formats/json/index.ts +3 -1
  529. package/src/formats/json/obographs-export.ts +1197 -0
  530. package/src/formats/json/obographs.ts +353 -92
  531. package/src/formats/neo4j/exporter.ts +321 -102
  532. package/src/formats/neo4j/header.ts +44 -6
  533. package/src/formats/neo4j/importer.ts +512 -73
  534. package/src/formats/neo4j/index.ts +75 -23
  535. package/src/formats/obo/exporter.ts +2210 -0
  536. package/src/formats/obo/importer.ts +622 -109
  537. package/src/formats/obo/index.ts +6 -3
  538. package/src/formats/obo/syntax.ts +232 -37
  539. package/src/formats/pajek/exporter.ts +90 -42
  540. package/src/formats/pajek/importer.ts +407 -93
  541. package/src/formats/pajek/index.ts +2 -1
  542. package/src/formats/pajek/syntax.ts +216 -55
  543. package/src/formats/xgmml/columns.ts +10 -0
  544. package/src/formats/xgmml/constants.ts +183 -56
  545. package/src/formats/xgmml/document.ts +212 -34
  546. package/src/formats/xgmml/emit.ts +175 -34
  547. package/src/formats/xgmml/exporter.ts +36 -28
  548. package/src/formats/xgmml/importer.ts +64 -86
  549. package/src/formats/xgmml/index.ts +1 -0
  550. package/src/formats/xgmml/values.ts +12 -4
  551. package/src/index.ts +81 -11
  552. package/src/registry.ts +1121 -135
  553. package/src/sniff.ts +163 -32
  554. package/src/types.ts +385 -114
  555. package/dist/chunks/escape-B-tkk_Cl.js.map +0 -1
  556. package/dist/chunks/export-Bh60Dv-n.js +0 -582
  557. package/dist/chunks/export-Bh60Dv-n.js.map +0 -1
  558. package/dist/chunks/exporter-BGrsWamJ.js.map +0 -1
  559. package/dist/chunks/exporter-DIeGJXAZ.js.map +0 -1
  560. package/dist/chunks/importer-BWY2FFc5.js.map +0 -1
  561. package/dist/chunks/importer-DD-xv9_Y.js +0 -1471
  562. package/dist/chunks/importer-DD-xv9_Y.js.map +0 -1
  563. package/dist/chunks/importer-DET1tg6s.js.map +0 -1
  564. package/dist/chunks/importer-DEcKhsmQ.js +0 -1614
  565. package/dist/chunks/importer-DEcKhsmQ.js.map +0 -1
  566. package/dist/chunks/importer-DIrbbnAf.js.map +0 -1
  567. package/dist/chunks/importer-D_e7e7LX.js.map +0 -1
  568. package/dist/chunks/importer-eUREhBGj.js.map +0 -1
  569. package/dist/chunks/importer-vi3bSdtw.js.map +0 -1
  570. package/dist/chunks/importer-zGLo8Dg_.js.map +0 -1
  571. package/dist/chunks/json-elements-DDS8N2Dd.js +0 -779
  572. package/dist/chunks/json-elements-DDS8N2Dd.js.map +0 -1
  573. package/dist/chunks/ontology-BnrJ4I98.js +0 -113
  574. package/dist/chunks/ontology-BnrJ4I98.js.map +0 -1
  575. package/dist/chunks/records-dbRkxwaq.js.map +0 -1
  576. package/dist/chunks/report-B1z4WT9e.js +0 -2372
  577. package/dist/chunks/report-B1z4WT9e.js.map +0 -1
  578. package/dist/chunks/text-Dr0Ifpag.js.map +0 -1
  579. package/dist/chunks/weights-Dzba96G3.js +0 -176
  580. package/dist/chunks/weights-Dzba96G3.js.map +0 -1
  581. package/dist/chunks/writer-C7flM-Ih.js.map +0 -1
@@ -4,7 +4,7 @@
4
4
  * `directed` / `multigraph` / `graph`), the d3 lineage of the same shape (`name` ids, integer
5
5
  * index endpoints), JSON Graph Format v2 (nodes keyed by id, per-edge `directed`, hyperedges),
6
6
  * Cytoscape.js elements (`data.id` / `data.source` / `data.target`, `position`, `classes`,
7
- * `data.parent`), graphology serialisation (`key` / `attributes`, `undirected` edges, `options`)
7
+ * `data.parent`), graphology serialization (`key` / `attributes`, `undirected` edges, `options`)
8
8
  * vis.js (`from` / `to`), and NetworkX adjacency_data (`nodes` + `adjacency`) and tree_data (nested
9
9
  * `id` / `children`) -- and pushes it scalar by scalar into the sink.
10
10
  *
@@ -45,30 +45,47 @@ import { uniqueColumnName } from "../../common/attributes.js";
45
45
  import {
46
46
  AMBIGUOUS_GRAPH_NAME_CODE,
47
47
  BAD_VALUE_CODE,
48
+ COLUMN_RENAMED_CODE,
48
49
  DANGLING_REFERENCE_CODE,
50
+ DUPLICATE_ATTRIBUTE_CODE,
49
51
  DUPLICATE_EDGE_ID_CODE,
50
52
  DUPLICATE_NODE_CODE,
53
+ ELEMENT_ISSUE,
54
+ EMPTY_COLUMN_DROPPED_CODE,
51
55
  EMPTY_INPUT_CODE,
52
56
  ENCODING_FALLBACK_CODE,
53
57
  GRAPH_NOT_FOUND_CODE,
54
58
  HYPEREDGE_CODE,
59
+ INPUT_ISSUE,
55
60
  INVALID_ENCODING_CODE,
56
61
  INVALID_UTF8_CODE,
62
+ JSON_NONSTANDARD_NUMBER_CODE,
57
63
  MISSING_ENDPOINT_CODE,
58
64
  MISSING_ID_CODE,
59
65
  MULTIPLE_GRAPHS_CODE,
60
66
  OPTION_IGNORED_CODE,
67
+ PARENT_CYCLE_CODE,
68
+ PRECISION_CODE,
69
+ ROLE_TAKEN_CODE,
61
70
  SYNTAX_CODE,
62
71
  TOO_LARGE_CODE,
72
+ UNKNOWN_ELEMENT_CODE,
63
73
  UNKNOWN_ENCODING_CODE,
64
74
  UNKNOWN_PARENT_CODE,
65
75
  } from "../../common/codes.js";
66
76
  import { DirectionResolver, type EdgeKind } from "../../common/direction.js";
67
77
  import { ID_MERGED_CODE, IdCoercer } from "../../common/ids.js";
68
- import { readText, textChunks, throwIfAborted } from "../../common/input.js";
69
- import { flipY, MAYBE_UNSAFE_INTEGER, reviveNonstandard, rewriteNumbers } from "../../common/json-elements.js";
78
+ import { readText, throwIfAborted } from "../../common/input.js";
79
+ import {
80
+ findDuplicateKeys,
81
+ flipY,
82
+ MAYBE_INEXACT_EXPONENT,
83
+ MAYBE_UNSAFE_INTEGER,
84
+ rewriteNumbers,
85
+ } from "../../common/json-elements.js";
70
86
  import {
71
87
  chooseGraph,
88
+ graphChosen,
72
89
  type ImportFormatDefaults,
73
90
  reportSinkOptions,
74
91
  reportUnusedOptions,
@@ -76,9 +93,10 @@ import {
76
93
  resolveImportOptions,
77
94
  SINK_OPTION_CODE,
78
95
  } from "../../common/options.js";
96
+ import { agree, plural } from "../../common/plural.js";
79
97
  import { ImportReportBuilder } from "../../common/report.js";
80
98
  import { weightFromValue } from "../../common/weights.js";
81
- import { sniffJsonDialectHead } from "../../sniff.js";
99
+ import { headBytes, sniffJsonDialectHead } from "../../sniff.js";
82
100
  import {
83
101
  type CommonImportOptions,
84
102
  type GraphChoiceOptions,
@@ -112,75 +130,109 @@ import { importObographs } from "./obographs.js";
112
130
  * The format-specific options of the JSON importer. `graphIndex` / `graphName` choose one graph of
113
131
  * a JGF or OBO Graphs `graphs` array (the first by default); a graph's name is its `id`, else its
114
132
  * label.
133
+ * @category Built-in formats
115
134
  */
116
- export interface JsonImportOptions extends GraphChoiceOptions {
117
- /** The dialect to read; "auto" (default) sniffs the parsed document. */
135
+ export interface JsonImportOptions extends GraphChoiceOptions, CommonImportOptions {
136
+ /**
137
+ * The dialect to read; "auto" detects it from the document. `jsonShapeOf(snapshot).dialect` is
138
+ * the dialect that was read.
139
+ * @defaultValue "auto"
140
+ */
118
141
  dialect?: JsonImportDialect | "auto" | undefined;
119
142
  /**
120
- * node-link / d3 / vis / adjacency / tree: the node key holding the id; auto: "id" (node-link /
121
- * d3: "id" when any node has it, else "name").
143
+ * node-link, d3, vis, adjacency and tree documents: the node key that holds the id. The default
144
+ * is "id" (for node-link and d3, "name" when no node has an "id").
145
+ * @defaultValue "id"
122
146
  */
123
147
  nodeIdKey?: string | undefined;
124
- /** node-link / d3: the top-level key holding the edges; auto: "edges" when present, else "links". */
148
+ /**
149
+ * node-link and d3 documents: the top-level key that holds the edges. The default is "edges"
150
+ * when the document has it, else "links".
151
+ * @defaultValue "edges" or "links"
152
+ */
125
153
  edgesKey?: string | undefined;
126
- /** node-link / d3 / vis: the edge key holding the source; auto: "source", "src" or "from" (vis: "from"). */
154
+ /**
155
+ * node-link, d3 and vis documents: the edge key that holds the source. The default is the first
156
+ * of "source", "src" and "from" the edges use (vis: "from").
157
+ * @defaultValue "source"
158
+ */
127
159
  sourceKey?: string | undefined;
128
- /** node-link / d3 / vis: the edge key holding the target; auto: "target", "dst" or "to" (vis: "to"). */
160
+ /**
161
+ * node-link, d3 and vis documents: the edge key that holds the target. The default is the first
162
+ * of "target", "dst" and "to" the edges use (vis: "to").
163
+ * @defaultValue "target"
164
+ */
129
165
  targetKey?: string | undefined;
130
166
  /**
131
- * node-link / d3: whether edge endpoints are node array positions; "auto" (default) says yes when
132
- * every endpoint is an integer below the node count and no node id is a number.
167
+ * node-link and d3 documents: whether edge ends are positions in the node array rather than
168
+ * ids; "auto" says yes when every end is an integer below the number of nodes and no node id is
169
+ * a number.
170
+ * @defaultValue "auto"
133
171
  */
134
172
  indexLinks?: boolean | "auto" | undefined;
135
173
  /**
136
- * obographs: "curie" (default) reads `http://purl.obolibrary.org/obo/GO_0008150` as `GO:0008150`
137
- * and `.../obo/go#regulates` as `regulates`, the identifiers the `.obo` file of the same ontology
138
- * writes; "iri" keeps every IRI as written.
174
+ * OBO Graphs documents: "curie" reads `http://purl.obolibrary.org/obo/GO_0008150` as
175
+ * `GO:0008150` and `.../obo/go#regulates` as `regulates`, the ids the `.obo` file of the same
176
+ * ontology uses; "iri" keeps every IRI as written.
177
+ * @defaultValue "curie"
139
178
  */
140
179
  oboIds?: "curie" | "iri" | undefined;
141
180
  /**
142
- * obographs: "metadata" (default) keeps PROPERTY nodes and their subPropertyOf / inverseOf edges
143
- * in `meta.extra.obographs`, as the OBO importer keeps `[Typedef]` frames; "nodes" makes them
144
- * nodes and edges.
181
+ * OBO Graphs documents: "metadata" keeps the relation definitions (PROPERTY nodes, with their
182
+ * subPropertyOf and inverseOf edges) in `snapshot.meta.extra.obographs`, the way the OBO
183
+ * importer keeps `[Typedef]` frames; "nodes" makes them nodes and edges of the graph.
184
+ * @defaultValue "metadata"
145
185
  */
146
186
  typedefs?: "metadata" | "nodes" | undefined;
147
187
  /**
148
- * node-link / d3 / vis / graphology: where the node array is, as a dotted path of object keys
149
- * from the document root (`"data.nodes"`); the object holding it is read as the graph record
150
- * (its `directed`, `multigraph`, `graph` and edge keys). "nodes" by default. A path that names
151
- * nothing is an E_MISSING_SECTION issue and the graph has no node records.
188
+ * node-link, d3, vis and graphology documents: where the node array is, as a dotted path of
189
+ * keys and array positions from the top of the document (`"data.nodes"`, `"graphs.0.nodes"`).
190
+ * The object that holds the array is read as the graph (its `directed`, `multigraph`, `graph`
191
+ * and edge keys). A path that leads nowhere is reported as E_MISSING_SECTION and the graph has
192
+ * no nodes.
193
+ * @defaultValue "nodes"
152
194
  */
153
195
  nodesPath?: string | undefined;
154
196
  /**
155
- * node-link / d3 / vis / graphology: where the edge array is, as a dotted path of object keys
156
- * from the document root (`"data.links"`); by default the edges or links key of the object
157
- * holding the nodes. A path that names nothing is an E_MISSING_SECTION issue and the graph has
158
- * no edge records.
197
+ * node-link, d3, vis and graphology documents: where the edge array is, as a dotted path
198
+ * (`"data.links"`). The default is the edges or links key next to the node array. A path that
199
+ * leads nowhere is reported as E_MISSING_SECTION and the graph has no edges.
200
+ * @defaultValue next to the nodes
159
201
  */
160
202
  edgesPath?: string | undefined;
161
203
  }
162
204
 
163
205
  /**
164
- * The issue codes the JSON importer records (design section 8.6), by name: the codes shared with
165
- * the other importers (src/common/codes.ts) and the JSON-specific ones. A key is the code without
206
+ * The issue codes the JSON importer records, by name: the codes shared with
207
+ * the other importers and the JSON-specific ones. A key is the code without
166
208
  * its severity and format prefixes.
209
+ * @category Built-in formats
167
210
  */
168
211
  export const JSON_ISSUE = Object.freeze({
169
- /** The text is empty or whitespace (fatal). */
212
+ ...INPUT_ISSUE,
213
+ ...ELEMENT_ISSUE,
214
+ /** The text is empty or whitespace. The import stops. */
170
215
  EMPTY_INPUT: EMPTY_INPUT_CODE,
171
- /** JSON.parse refused the text (fatal). */
216
+ /** The text is not valid JSON. The import stops. */
172
217
  SYNTAX: SYNTAX_CODE,
173
- /** No dialect matches the document's top-level shape. */
218
+ /** No dialect matches the document's top-level shape. The import stops. */
174
219
  DIALECT: "E_JSON_DIALECT",
175
220
  /** A section (nodes, edges, elements, graph) has the wrong JSON type. */
176
221
  SHAPE: "E_JSON_SHAPE",
177
- /** A node-link document lacks its nodes or its edges array, or a Cytoscape document its elements. */
178
- MISSING_SECTION: "E_MISSING_SECTION",
222
+ /**
223
+ * A section the dialect expects is not there: a node-link document without its nodes or its edges array, an
224
+ * adjacency document without lists for some nodes, or a `nodesPath` / `edgesPath` that names nothing. Nothing is
225
+ * skipped: the graph is read without that section (nodes come from the edges, or there are no edges).
226
+ */
227
+ MISSING_SECTION: "W_MISSING_SECTION",
179
228
  /** A node record or an element is not an object. */
180
229
  BAD_ELEMENT: "E_BAD_ELEMENT",
181
230
  /** A node record has no id. */
182
231
  MISSING_ID: MISSING_ID_CODE,
183
- /** A node id is a JSON boolean or null (legal in NetworkX, not a NodeId); coerced only under ids "string". */
232
+ /**
233
+ * A node id is a JSON boolean or null, which graph-io cannot use as an id; the node is skipped. With `ids:
234
+ * "string"` it is read as the text "true", "false" or "null".
235
+ */
184
236
  UNSUPPORTED_ID: "E_UNSUPPORTED_ID",
185
237
  /** An edge record has no source or no target. */
186
238
  MISSING_ENDPOINT: MISSING_ENDPOINT_CODE,
@@ -196,25 +248,35 @@ export const JSON_ISSUE = Object.freeze({
196
248
  DUPLICATE_NODE: DUPLICATE_NODE_CODE,
197
249
  /** An edge id (Cytoscape data.id, graphology key, vis id) repeated by a later edge; the edge is skipped. */
198
250
  DUPLICATE_EDGE_ID: DUPLICATE_EDGE_ID_CODE,
199
- /** Two distinct id texts merged into one number under ids "number". */
251
+ /** Two different id texts became the same number because `ids` is "number", so their nodes were merged. */
200
252
  ID_MERGED: ID_MERGED_CODE,
201
253
  /** Edge ids of mixed JSON types were stored as text. */
202
254
  EDGE_ID_STRINGIFIED: "W_EDGE_ID_STRINGIFIED",
203
- /** A JGF or OBO Graphs `graphs` array holds more than one graph; only the chosen one is read. */
255
+ /**
256
+ * The file holds several graphs and only the first was read. It is not added when `graphIndex` or `graphName` chose the graph.
257
+ * `importAllGraphs()` reads every one.
258
+ */
204
259
  MULTIPLE_GRAPHS: MULTIPLE_GRAPHS_CODE,
205
- /** `graphIndex` is beyond the `graphs` array, or `graphName` names none of its graphs (fatal). */
260
+ /**
261
+ * `graphIndex` is past the end of the `graphs` array, or `graphName` matches none of its graphs. The import stops.
262
+ */
206
263
  GRAPH_NOT_FOUND: GRAPH_NOT_FOUND_CODE,
207
- /** `graphName` names more than one graph of the `graphs` array (fatal). */
264
+ /** `graphName` matches more than one graph of the `graphs` array. The import stops. */
208
265
  AMBIGUOUS_GRAPH_NAME: AMBIGUOUS_GRAPH_NAME_CODE,
209
- /** obographs: an edge uses the outdated `subj` key of the OBO Graphs README; it is read as `sub`. */
266
+ /** Obographs: an edge uses the outdated `subj` key of the OBO Graphs README; it is read as `sub`. */
210
267
  OBOGRAPHS_SUBJ: "W_JSON_OBOGRAPHS_SUBJ",
211
- /** obographs: an edge endpoint missing from `nodes` (a placeholder node is made, or the edge dropped under addMissingNodes false). */
268
+ /**
269
+ * Obographs: an edge endpoint missing from `nodes` (a placeholder node is made, or the edge dropped under
270
+ * addMissingNodes false).
271
+ */
212
272
  DANGLING_REFERENCE: DANGLING_REFERENCE_CODE,
213
- /** The document is longer than one JavaScript string can hold (fatal; category unsupported). */
273
+ /** The document is longer than a JavaScript string can hold. The import stops. */
214
274
  TOO_LARGE: TOO_LARGE_CODE,
215
- /** JGF hyperedges under the "error" policy. */
275
+ /** A JGF hyperedge (an edge with more than two ends) while `hyperedges` is "error". */
216
276
  HYPEREDGE: HYPEREDGE_CODE,
217
- /** JGF hyperedges skipped under the default "skip" policy. */
277
+ /**
278
+ * JGF hyperedges were skipped, because `hyperedges` is "skip" (the default). Pass "star" or "clique" to keep them.
279
+ */
218
280
  HYPEREDGES_SKIPPED: "W_HYPEREDGES_SKIPPED",
219
281
  /** A JGF hyperedge with neither a nodes array nor source / target arrays. */
220
282
  HYPEREDGE_SHAPE: "E_HYPEREDGE_SHAPE",
@@ -222,22 +284,59 @@ export const JSON_ISSUE = Object.freeze({
222
284
  POSITIONAL_NODES: "W_POSITIONAL_NODES",
223
285
  /** A node-link / d3 top-level key the importer does not read (the other of edges / links, an unknown key); it is dropped. */
224
286
  UNREAD_KEY: "W_JSON_UNREAD_KEY",
225
- /** A builder-policy option (addMissingNodes, duplicateEdges, selfLoops, weightDtype) differs from the sink's (the shared W_SINK_OPTION). */
287
+ /**
288
+ * You read into your own graph builder, which was created with a different `addMissingNodes`, `duplicateEdges`,
289
+ * `selfLoops` or `weightDtype` than the option you passed; the builder's setting applies.
290
+ */
226
291
  SINK_OPTION: SINK_OPTION_CODE,
227
- /** A common option the dialect has no use for (nodeIdFrom outside node-link, long, restoreMangledIds). */
292
+ /** You set an option this format does not use; it had no effect. The message names the option. */
228
293
  OPTION_IGNORED: OPTION_IGNORED_CODE,
229
- /** The input holds invalid UTF-8 (fatal). */
294
+ /** The input is not valid UTF-8. The import stops. */
230
295
  INVALID_UTF8: INVALID_UTF8_CODE,
231
- /** Invalid bytes in the encoding a BOM, a declaration or the encoding option chose (fatal). */
296
+ /**
297
+ * Some bytes are not valid in the encoding that was chosen (by a byte order mark, the file's declaration or the
298
+ * `encoding` option). The import stops.
299
+ */
232
300
  INVALID_ENCODING: INVALID_ENCODING_CODE,
233
301
  /** Bytes that are not UTF-8 and declare no encoding were read as windows-1252. */
234
302
  ENCODING_FALLBACK: ENCODING_FALLBACK_CODE,
235
303
  /** The document uses the non-standard tokens NaN / Infinity / -Infinity (Python's json writes them); read as numbers. */
236
- NONSTANDARD_NUMBER: "W_JSON_NONSTANDARD_NUMBER",
304
+ NONSTANDARD_NUMBER: JSON_NONSTANDARD_NUMBER_CODE,
237
305
  /** Integer literals beyond 2^53 were read as their exact digits (strings), not as rounded numbers. */
238
306
  BIG_INTEGER: "W_JSON_BIG_INTEGER",
239
307
  /** A declared encoding the platform cannot decode was ignored. */
240
308
  UNKNOWN_ENCODING: UNKNOWN_ENCODING_CODE,
309
+ /** An integer beyond 2^53 was stored as the nearest 64-bit float; pass `long: "string"` to keep every digit. */
310
+ PRECISION: PRECISION_CODE,
311
+ /**
312
+ * The document contradicts itself: a declared option its edges break (multigraph false with
313
+ * parallel links, graphology's options), a record whose section disagrees with its shape, a
314
+ * JGF inner id other than its key, the two listings of one adjacency edge.
315
+ */
316
+ INCONSISTENT: "W_JSON_INCONSISTENT",
317
+ /** IndexLinks "auto" read integer endpoints as array positions although they also name node ids. */
318
+ INDEX_LINKS: "W_JSON_INDEX_LINKS",
319
+ /** A Cytoscape parent link that would close a cycle; that link is dropped. */
320
+ PARENT_CYCLE: PARENT_CYCLE_CODE,
321
+ /** An attribute key that is null on every element makes no column (NetworkX writes None as null). */
322
+ EMPTY_COLUMN_DROPPED: EMPTY_COLUMN_DROPPED_CODE,
323
+ /** OBO Graphs: a node type or synonym predicate outside the schema's set; kept as written, once per name. */
324
+ UNKNOWN_ELEMENT: UNKNOWN_ELEMENT_CODE,
325
+ /**
326
+ * A key repeated in one JSON object (JSON.parse keeps the last value, the earlier is dropped),
327
+ * or, in OBO Graphs, a single-valued OBO tag given twice in basicPropertyValues (the first is kept).
328
+ */
329
+ DUPLICATE_ATTRIBUTE: DUPLICATE_ATTRIBUTE_CODE,
330
+ /**
331
+ * An attribute was renamed `<name>#<suffix>` because another attribute already has its name, for example
332
+ * two attributes declared with the same name.
333
+ */
334
+ COLUMN_RENAMED: COLUMN_RENAMED_CODE,
335
+ /**
336
+ * You read into a graph builder that already has an id, label or position attribute, so this file's one is kept as
337
+ * a plain attribute.
338
+ */
339
+ ROLE_TAKEN: ROLE_TAKEN_CODE,
241
340
  });
242
341
 
243
342
  /** The common options the JSON importer reads (the rest is reported by reportUnusedOptions). */
@@ -262,6 +361,9 @@ const FORMAT_DEFAULTS: ImportFormatDefaults = { ids: "keep", defaultDirected: fa
262
361
  /** Bytes of the head sniff() inspects. */
263
362
  const SNIFF_BYTES = 4096;
264
363
 
364
+ /** How many dangling endpoints the W_DANGLING_REFERENCE message names. */
365
+ const DANGLING_SHOWN = 5;
366
+
265
367
  /** Elements pushed between two checks of the cancellation signal (the whole document is one chunk). */
266
368
  const ABORT_CHECK_INTERVAL = 64;
267
369
 
@@ -405,18 +507,22 @@ function pathOption(name: string, value: unknown): readonly string[] | null {
405
507
  }
406
508
 
407
509
  /**
408
- * The value at a path of object keys, or undefined when a step is missing or not an object.
510
+ * The value at a path of object keys and array positions (`graphs.0.nodes`), or undefined when a
511
+ * step is missing.
409
512
  * @param root - the document
410
- * @param segments - the keys
513
+ * @param segments - the keys; a decimal integer indexes an array
411
514
  * @returns the value
412
515
  */
413
516
  function valueAt(root: unknown, segments: readonly string[]): unknown {
414
517
  let value = root;
415
518
  for (const segment of segments) {
416
- if (!isJsonObject(value) || !hasKey(value, segment)) {
519
+ if (Array.isArray(value) && /^(0|[1-9]\d*)$/.test(segment) && Number(segment) < value.length) {
520
+ value = value[Number(segment)];
521
+ } else if (isJsonObject(value) && hasKey(value, segment)) {
522
+ value = value[segment];
523
+ } else {
417
524
  return undefined;
418
525
  }
419
- value = value[segment];
420
526
  }
421
527
  return value;
422
528
  }
@@ -440,7 +546,7 @@ function applyPaths(root: unknown, json: ResolvedJsonOptions, report: ImportRepo
440
546
  const value = valueAt(root, segments);
441
547
  if (value === undefined) {
442
548
  const path = segments.join(".");
443
- report.error(
549
+ report.warning(
444
550
  "missing-value",
445
551
  JSON_ISSUE.MISSING_SECTION,
446
552
  `${option} ${JSON.stringify(path)} names nothing in the document`,
@@ -517,12 +623,23 @@ function pathEdgesKey(json: ResolvedJsonOptions, edgesPath: readonly string[]):
517
623
  * rule over the record, else node-link.
518
624
  * @param record - the graph record
519
625
  * @param forced - the caller's dialect option
626
+ * @param placedEdges - whether edgesPath placed the record's edge array (under links or edges)
520
627
  * @returns the dialect
521
628
  */
522
- function pathDialect(record: unknown, forced: JsonImportDialect | "auto"): JsonImportDialect {
629
+ function pathDialect(record: unknown, forced: JsonImportDialect | "auto", placedEdges: boolean): JsonImportDialect {
523
630
  if (forced !== "auto") {
524
631
  return forced;
525
632
  }
633
+ // edgesPath puts the edge array under links only so a bare d3 document stays d3; under edges,
634
+ // vis and graphology edges are told apart from NetworkX links again. A links key the document
635
+ // itself holds is NetworkX's and settles node-link.
636
+ if (placedEdges && isJsonObject(record) && hasKey(record, "links") && !hasKey(record, "edges")) {
637
+ const { links, ...rest } = record;
638
+ const asEdges = sniffJsonDialect({ ...rest, edges: links });
639
+ if (asEdges === "vis" || asEdges === "graphology") {
640
+ return asEdges;
641
+ }
642
+ }
526
643
  const sniffed = sniffJsonDialect(record);
527
644
  return sniffed !== null && PATH_DIALECTS.has(sniffed) ? sniffed : "node-link";
528
645
  }
@@ -544,7 +661,7 @@ function documentOf(
544
661
  return { root: parsed, dialect: detectDialect(parsed, json.dialect, report) };
545
662
  }
546
663
  const root = applyPaths(parsed, json, report);
547
- const dialect = pathDialect(root, json.dialect);
664
+ const dialect = pathDialect(root, json.dialect, json.edgesPath !== null);
548
665
  // vis and graphology read their edges from the edges key only
549
666
  if (json.edgesPath !== null && (dialect === "vis" || dialect === "graphology") && isJsonObject(root)) {
550
667
  const key = pathEdgesKey(json, json.edgesPath);
@@ -628,14 +745,21 @@ class AttributeWriter {
628
745
 
629
746
  private readonly reservedNames = new Set<string>();
630
747
 
748
+ private readonly report: ImportReportBuilder;
749
+
750
+ /** Column names written only with null so far (NetworkX writes None as null). */
751
+ private readonly nullOnly = new Set<string>();
752
+
631
753
  /**
632
754
  * Create a writer.
633
755
  * @param sink - the sink
634
756
  * @param domain - node or edge
757
+ * @param report - where a refused cell is recorded
635
758
  */
636
- constructor(sink: GraphSink, domain: "node" | "edge") {
759
+ constructor(sink: GraphSink, domain: "node" | "edge", report: ImportReportBuilder) {
637
760
  this.sink = sink;
638
761
  this.domain = domain;
762
+ this.report = report;
639
763
  }
640
764
 
641
765
  /**
@@ -670,29 +794,59 @@ class AttributeWriter {
670
794
  }
671
795
 
672
796
  /**
673
- * Write one attribute cell by its source key; null and undefined leave the row unset.
797
+ * Write one attribute cell by its source key; null and undefined leave the row unset. A cell the
798
+ * sink refuses is recorded and skipped, so the element's other keys are still written.
674
799
  * @param row - the node or edge index
675
800
  * @param key - the source key
676
801
  * @param value - the JSON value
677
802
  * @param suffix - the suffix applied when the key collides with a structural column
803
+ * @param element - the element name for an issue
678
804
  */
679
- write(row: number, key: string, value: unknown, suffix: string): void {
680
- if (value === undefined || value === null) {
805
+ write(row: number, key: string, value: unknown, suffix: string, element: string): void {
806
+ if (value === undefined) {
681
807
  return;
682
808
  }
683
809
  const name = this.reservedNames.has(key) ? `${key}${suffix}` : key;
684
- const cached = this.handles.get(name);
685
- if (cached !== undefined) {
686
- this.set(cached, row, value);
810
+ if (value === null) {
811
+ this.nullOnly.add(name);
812
+ return;
813
+ }
814
+ try {
815
+ const cached = this.handles.get(name);
816
+ if (cached !== undefined) {
817
+ this.set(cached, row, value);
818
+ return;
819
+ }
820
+ this.set(name, row, value);
821
+ } catch (err) {
822
+ if (!(err instanceof GraphFormatError)) {
823
+ throw err;
824
+ }
825
+ this.report.recordError(err, { element: `${element}.${key}` });
687
826
  return;
688
827
  }
689
- this.set(name, row, value);
690
828
  const handle = this.lookup(name);
691
829
  if (handle !== INVALID_INDEX) {
692
830
  this.handles.set(name, handle);
693
831
  }
694
832
  }
695
833
 
834
+ /**
835
+ * Report the keys that were null on every element that had them: they made no column, so a
836
+ * round trip loses them (W_EMPTY_COLUMN_DROPPED, one warning per table).
837
+ */
838
+ reportNullOnly(): void {
839
+ const names = [...this.nullOnly].filter((name) => this.lookup(name) === INVALID_INDEX);
840
+ if (names.length > 0) {
841
+ this.report.warning(
842
+ "missing-value",
843
+ EMPTY_COLUMN_DROPPED_CODE,
844
+ `${this.domain} key${plural(names.length)} ${names.map((n) => JSON.stringify(n)).join(", ")} ${agree(names.length, "is", "are")} null wherever they appear; no column is made for them`,
845
+ { element: names[0] },
846
+ );
847
+ }
848
+ }
849
+
696
850
  /**
697
851
  * Write through a handle or a name.
698
852
  * @param column - the handle or name
@@ -721,6 +875,7 @@ class AttributeWriter {
721
875
 
722
876
  /**
723
877
  * Everything one import call shares between the dialect readers.
878
+ * @category Plugin helpers
724
879
  */
725
880
  export class ImportContext {
726
881
  readonly sink: GraphSink;
@@ -766,8 +921,8 @@ export class ImportContext {
766
921
  this.json = json;
767
922
  this.ids = new IdCoercer(options.ids);
768
923
  this.direction = new DirectionResolver(sink, report, options.onMixedDirection);
769
- this.nodes = new AttributeWriter(sink, "node");
770
- this.edges = new AttributeWriter(sink, "edge");
924
+ this.nodes = new AttributeWriter(sink, "node", report);
925
+ this.edges = new AttributeWriter(sink, "edge", report);
771
926
  this.explicitDefaultDirected = explicitDefaultDirected;
772
927
  }
773
928
 
@@ -882,16 +1037,17 @@ export class ImportContext {
882
1037
  * Add a node, counting it or recording the failure.
883
1038
  * @param id - the node id
884
1039
  * @param element - the element name
1040
+ * @param why - more about a repeated id, for the duplicate warning
885
1041
  * @returns the node index, or -1 when the sink refused the node
886
1042
  */
887
- pushNode(id: NodeId, element: string): number {
1043
+ pushNode(id: NodeId, element: string, why = ""): number {
888
1044
  this.checkAbort();
889
1045
  const existing = this.sink.indexOf(id);
890
1046
  if (existing !== INVALID_INDEX) {
891
1047
  this.report.warning(
892
1048
  "merged",
893
1049
  JSON_ISSUE.DUPLICATE_NODE,
894
- `${element}: node ${JSON.stringify(id)} already exists; its attributes are merged (the later values win)`,
1050
+ `${element}: node ${JSON.stringify(id)} already exists${why}; its attributes are merged (the later values win)`,
895
1051
  { element },
896
1052
  );
897
1053
  return existing;
@@ -920,17 +1076,72 @@ export class ImportContext {
920
1076
  pushEdge(source: NodeId, target: NodeId, kind: EdgeKind, weight: number | undefined, element: string): number {
921
1077
  this.checkAbort();
922
1078
  const before = this.sink.edgeCount;
923
- // endpoints the sink creates (addMissingNodes) count as nodes too
924
- let created = this.sink.indexOf(source) === INVALID_INDEX ? 1 : 0;
925
- if (source !== target && this.sink.indexOf(target) === INVALID_INDEX) {
926
- created++;
927
- }
1079
+ // endpoints the sink creates (addMissingNodes) count as nodes too, and are reported
1080
+ const missingSource = this.sink.indexOf(source) === INVALID_INDEX;
1081
+ const missingTarget = source !== target && this.sink.indexOf(target) === INVALID_INDEX;
928
1082
  const edge = this.direction.addEdge(source, target, kind, weight, { element });
929
1083
  this.report.counts.edges += this.sink.edgeCount - before;
930
- this.report.counts.nodes += created;
1084
+ if (missingSource) {
1085
+ this.dangling(source);
1086
+ }
1087
+ if (missingTarget) {
1088
+ this.dangling(target);
1089
+ }
931
1090
  return edge;
932
1091
  }
933
1092
 
1093
+ /** Endpoints that named no node and became placeholder nodes, the first few kept for the message. */
1094
+ private readonly danglingIds: NodeId[] = [];
1095
+
1096
+ private danglingCount = 0;
1097
+
1098
+ /** Set when the document has no node section: every node comes from an edge (E_MISSING_SECTION says so). */
1099
+ nodesFromEdges = false;
1100
+
1101
+ /** Dangling endpoints whose id, as the other JSON type, is a node (`"1"` next to the node 1). */
1102
+ private readonly nearMatches: NodeId[] = [];
1103
+
1104
+ /**
1105
+ * Count a placeholder node an edge endpoint created.
1106
+ * @param id - the endpoint
1107
+ */
1108
+ private dangling(id: NodeId): void {
1109
+ this.report.counts.nodes++;
1110
+ this.danglingCount++;
1111
+ if (this.danglingIds.length < DANGLING_SHOWN) {
1112
+ this.danglingIds.push(id);
1113
+ }
1114
+ const other = typeof id === "number" ? String(id) : Number(id);
1115
+ if (
1116
+ this.nearMatches.length < DANGLING_SHOWN &&
1117
+ (typeof other === "string" || String(other) === id) &&
1118
+ this.sink.indexOf(other) !== INVALID_INDEX
1119
+ ) {
1120
+ this.nearMatches.push(id);
1121
+ }
1122
+ }
1123
+
1124
+ /**
1125
+ * Report the edge endpoints that named no node (the shared W_DANGLING_REFERENCE, once with the
1126
+ * count): each became a placeholder node, which is rarely what the file meant.
1127
+ */
1128
+ reportDangling(): void {
1129
+ if (this.danglingCount === 0 || this.nodesFromEdges) {
1130
+ return;
1131
+ }
1132
+ const shown = this.danglingIds.map((id) => JSON.stringify(id)).join(", ");
1133
+ const near =
1134
+ this.nearMatches.length > 0
1135
+ ? `; ${this.nearMatches.map((id) => JSON.stringify(id)).join(", ")} ${agree(this.nearMatches.length, "names", "name")} a node id of another JSON type (a string next to a number), which is a different id`
1136
+ : "";
1137
+ this.report.warning(
1138
+ "validation-error",
1139
+ JSON_ISSUE.DANGLING_REFERENCE,
1140
+ `${this.danglingCount} edge endpoint${plural(this.danglingCount)} ${agree(this.danglingCount, "names", "name")} no node and became placeholder nodes: ${shown}${this.danglingCount > DANGLING_SHOWN ? ", ..." : ""}${near}`,
1141
+ { element: String(this.danglingIds[0]) },
1142
+ );
1143
+ }
1144
+
934
1145
  /**
935
1146
  * Check the cancellation signal every ABORT_CHECK_INTERVAL pushed elements, so an abort raised
936
1147
  * while the whole in-memory document is being walked rejects promptly.
@@ -1070,6 +1281,7 @@ export class ImportContext {
1070
1281
  * @param dict - the nested attribute dict
1071
1282
  * @param structural - the element keys that are not attributes
1072
1283
  * @param weightFrom - the weight key to skip in the dict, or null
1284
+ * @param element - the element name for issues
1073
1285
  */
1074
1286
  writeNested(
1075
1287
  writer: AttributeWriter,
@@ -1078,15 +1290,16 @@ export class ImportContext {
1078
1290
  dict: JsonRecord,
1079
1291
  structural: ReadonlySet<string>,
1080
1292
  weightFrom: string | null,
1293
+ element: string,
1081
1294
  ): void {
1082
1295
  for (const key of Object.keys(dict)) {
1083
1296
  if (key !== weightFrom) {
1084
- writer.write(row, key, dict[key], SUFFIX.data);
1297
+ writer.write(row, key, dict[key], SUFFIX.data, element);
1085
1298
  }
1086
1299
  }
1087
1300
  for (const key of Object.keys(record)) {
1088
1301
  if (!structural.has(key)) {
1089
- writer.write(row, `${key}${SUFFIX.element}`, record[key], SUFFIX.data);
1302
+ writer.write(row, `${key}${SUFFIX.element}`, record[key], SUFFIX.data, element);
1090
1303
  }
1091
1304
  }
1092
1305
  }
@@ -1140,6 +1353,7 @@ export class ImportContext {
1140
1353
  ): EdgeIdColumn | null {
1141
1354
  let numbers = 0;
1142
1355
  let strings = 0;
1356
+ let others = 0;
1143
1357
  for (const edge of edges) {
1144
1358
  if (!isJsonObject(edge)) {
1145
1359
  continue;
@@ -1149,12 +1363,15 @@ export class ImportContext {
1149
1363
  numbers++;
1150
1364
  } else if (typeof raw === "string") {
1151
1365
  strings++;
1366
+ } else if (raw !== undefined && raw !== null) {
1367
+ others++;
1152
1368
  }
1153
1369
  }
1154
- if (numbers + strings === 0) {
1370
+ if (numbers + strings + others === 0) {
1155
1371
  return null;
1156
1372
  }
1157
- const dtype = strings === 0 ? "f64" : "string";
1373
+ // ids of no usable type still get a column, so edgeIdValue() reports each instead of dropping it
1374
+ const dtype = strings === 0 && numbers > 0 ? "f64" : "string";
1158
1375
  const columnName = uniqueColumnName(name, "id", (candidate) => this.edges.taken(candidate));
1159
1376
  const handle = this.edges.declare({ name: columnName, dtype, role: "id", nullable: true, unique });
1160
1377
  const stringify = strings > 0 && numbers > 0;
@@ -1163,7 +1380,7 @@ export class ImportContext {
1163
1380
  this.report.warning(
1164
1381
  "coercion",
1165
1382
  JSON_ISSUE.EDGE_ID_STRINGIFIED,
1166
- `edge ids mix numbers and strings; ${numbers} numeric id(s) stored as text`,
1383
+ `edge ids mix numbers and strings; ${numbers} numeric id${plural(numbers)} stored as text`,
1167
1384
  { element: columnName },
1168
1385
  );
1169
1386
  }
@@ -1223,7 +1440,8 @@ export class ImportContext {
1223
1440
  // ============================================================ document level
1224
1441
 
1225
1442
  /**
1226
- * Parse the whole text; syntax errors and empty input abort the import.
1443
+ * Parse the whole text; syntax errors and empty input abort the import. A key repeated in one
1444
+ * object (which JSON.parse silently reduces to its last value) is reported per repetition.
1227
1445
  * @param text - the decoded text
1228
1446
  * @param report - the report
1229
1447
  * @returns the parsed value
@@ -1234,46 +1452,189 @@ function parseDocument(text: string, report: ImportReportBuilder): unknown {
1234
1452
  }
1235
1453
  let root: unknown;
1236
1454
  let syntaxError: string | null = null;
1455
+ // the text the syntax error was found in: the rewrite's when it parsed further than the original
1456
+ let errorText = text;
1237
1457
  try {
1238
1458
  root = JSON.parse(text) as unknown;
1239
1459
  } catch (err) {
1240
1460
  syntaxError = err instanceof Error ? err.message : String(err);
1241
1461
  }
1242
1462
  // the fast path: strict JSON without a digit run long enough to be an unsafe integer
1243
- if (syntaxError === null && !MAYBE_UNSAFE_INTEGER.test(text)) {
1463
+ if (syntaxError === null && !MAYBE_UNSAFE_INTEGER.test(text) && !MAYBE_INEXACT_EXPONENT.test(text)) {
1464
+ reportDuplicateKeys(text, report);
1244
1465
  return root;
1245
1466
  }
1246
- const scan = rewriteNumbers(text);
1247
- if (scan.tokens.size > 0 || scan.bigIntegers.length > 0) {
1248
- try {
1249
- root = JSON.parse(scan.text, scan.tokens.size > 0 ? reviveNonstandard : undefined) as unknown;
1250
- syntaxError = null;
1251
- } catch {
1252
- // the rewrite did not make it valid JSON: report the parser's message on the original text
1467
+ let scan: ReturnType<typeof rewriteNumbers>;
1468
+ try {
1469
+ scan = rewriteNumbers(text);
1470
+ if (scan.tokens.size > 0 || scan.bigIntegers.length > 0) {
1471
+ try {
1472
+ root = JSON.parse(scan.text, scan.tokens.size > 0 ? scan.revive : undefined) as unknown;
1473
+ syntaxError = null;
1474
+ } catch (err) {
1475
+ if (err instanceof RangeError) {
1476
+ throw err;
1477
+ }
1478
+ // the rewrite read past the non-standard tokens: its error is the real one
1479
+ syntaxError = err instanceof Error ? err.message : String(err);
1480
+ errorText = scan.text;
1481
+ }
1482
+ }
1483
+ } catch (err) {
1484
+ if (!(err instanceof RangeError)) {
1485
+ throw err;
1253
1486
  }
1487
+ // the rewrite (quoted big integers, sentinel strings) is longer than one string can hold
1488
+ const message = `the document with its numbers rewritten is longer than the most one JavaScript string holds`;
1489
+ report.error("unsupported", JSON_ISSUE.TOO_LARGE, message);
1490
+ throw report.abort(message, { code: JSON_ISSUE.TOO_LARGE });
1254
1491
  }
1255
1492
  if (syntaxError !== null) {
1256
- return report.fail(JSON_ISSUE.SYNTAX, `invalid JSON: ${syntaxError}`);
1493
+ return report.fail(JSON_ISSUE.SYNTAX, syntaxMessage(text, syntaxError), {
1494
+ line: syntaxLine(errorText, syntaxError),
1495
+ });
1257
1496
  }
1497
+ reportDuplicateKeys(text, report);
1258
1498
  if (scan.tokens.size > 0) {
1259
1499
  report.warning(
1260
1500
  "coercion",
1261
1501
  JSON_ISSUE.NONSTANDARD_NUMBER,
1262
- `the document uses the non-standard token(s) ${[...scan.tokens].join(", ")}, which strict JSON does not allow; read as numbers`,
1502
+ `the document uses the non-standard token${plural(scan.tokens.size)} ${[...scan.tokens].join(", ")}, which strict JSON does not allow; read as numbers`,
1263
1503
  );
1264
1504
  }
1265
1505
  if (scan.bigIntegers.length > 0) {
1266
- const shown = scan.bigIntegers.slice(0, BIG_INTEGERS_SHOWN).join(", ");
1267
- const more = scan.bigIntegers.length - BIG_INTEGERS_SHOWN;
1268
1506
  report.warning(
1269
1507
  "precision",
1270
1508
  JSON_ISSUE.BIG_INTEGER,
1271
- `${scan.bigIntegers.length} integer(s) beyond 2^53 kept as text so no digit is lost: ${shown}${more > 0 ? ` and ${more} more` : ""}`,
1509
+ `${scan.bigIntegers.length} integer${plural(scan.bigIntegers.length)} beyond 2^53 kept as text so no digit is lost: ${listed(scan.bigIntegers)}`,
1510
+ );
1511
+ }
1512
+ if (scan.inexact.length > 0) {
1513
+ report.warning(
1514
+ "precision",
1515
+ JSON_ISSUE.PRECISION,
1516
+ `${scan.inexact.length} number literal${plural(scan.inexact.length)} no double holds exactly, read as the nearest double (an infinity beyond the range, 0 below it): ${listed(scan.inexact)}`,
1272
1517
  );
1273
1518
  }
1274
1519
  return root;
1275
1520
  }
1276
1521
 
1522
+ /**
1523
+ * The first literals of a list for a message, with the count of the rest.
1524
+ * @param literals - the literals
1525
+ * @returns the text
1526
+ */
1527
+ function listed(literals: readonly string[]): string {
1528
+ const more = literals.length - BIG_INTEGERS_SHOWN;
1529
+ const shown = literals.slice(0, BIG_INTEGERS_SHOWN).join(", ");
1530
+ return more > 0 ? `${shown} and ${more} more` : shown;
1531
+ }
1532
+
1533
+ /** How many repeated keys are reported one by one before the rest is summed up. */
1534
+ const DUPLICATE_KEYS_SHOWN = 10;
1535
+
1536
+ /**
1537
+ * Report every key repeated in one object (JSON.parse keeps the last value, so the earlier one is
1538
+ * gone): one warning per repetition up to DUPLICATE_KEYS_SHOWN, then one with the remaining count.
1539
+ * @param text - the document text
1540
+ * @param report - the report
1541
+ */
1542
+ function reportDuplicateKeys(text: string, report: ImportReportBuilder): void {
1543
+ const found = findDuplicateKeys(text);
1544
+ let line = 1;
1545
+ let counted = 0;
1546
+ for (const { key, offset } of found.slice(0, DUPLICATE_KEYS_SHOWN)) {
1547
+ line += countLines(text, counted, offset);
1548
+ counted = offset;
1549
+ report.warning(
1550
+ "merged",
1551
+ JSON_ISSUE.DUPLICATE_ATTRIBUTE,
1552
+ `key ${JSON.stringify(key)} appears twice in one object; the earlier value is dropped (JSON.parse keeps the last)`,
1553
+ { line, element: key },
1554
+ );
1555
+ }
1556
+ if (found.length > DUPLICATE_KEYS_SHOWN) {
1557
+ report.warning(
1558
+ "merged",
1559
+ JSON_ISSUE.DUPLICATE_ATTRIBUTE,
1560
+ `${found.length - DUPLICATE_KEYS_SHOWN} more repeated key${plural(found.length - DUPLICATE_KEYS_SHOWN)}; each earlier value is dropped`,
1561
+ );
1562
+ }
1563
+ }
1564
+
1565
+ /**
1566
+ * The line breaks in a range of the text.
1567
+ * @param text - the text
1568
+ * @param from - the start offset
1569
+ * @param to - the end offset
1570
+ * @returns how many \n the range holds
1571
+ */
1572
+ function countLines(text: string, from: number, to: number): number {
1573
+ let lines = 0;
1574
+ for (let i = text.indexOf("\n", from); i >= 0 && i < to; i = text.indexOf("\n", i + 1)) {
1575
+ lines++;
1576
+ }
1577
+ return lines;
1578
+ }
1579
+
1580
+ /** The longest text the line of an unpositioned syntax error is searched in (a bisection of JSON.parse calls). */
1581
+ const SYNTAX_SEARCH_LIMIT = 64 * 1024 * 1024;
1582
+
1583
+ /**
1584
+ * The 1-based line of a JSON.parse error: from the message's "(line L" or "position N" when the
1585
+ * engine gives one, the last line for an unexpected end, else the shortest prefix that fails the
1586
+ * same way (V8 names no position for "Unexpected token").
1587
+ * @param text - the document
1588
+ * @param message - the parser's message
1589
+ * @returns the line
1590
+ */
1591
+ function syntaxLine(text: string, message: string): number {
1592
+ const line = /\(line (\d+)/.exec(message);
1593
+ if (line !== null) {
1594
+ return Number(line[1]);
1595
+ }
1596
+ const position = /position (\d+)/.exec(message);
1597
+ if (position !== null) {
1598
+ return countLines(text, 0, Number(position[1])) + 1;
1599
+ }
1600
+ if (!message.startsWith("Unexpected token") || text.length > SYNTAX_SEARCH_LIMIT) {
1601
+ // ponytail: an unexpected end (or a huge text) is placed on the last line
1602
+ return countLines(text, 0, text.length) + 1;
1603
+ }
1604
+ // the shortest prefix that holds the offending token: shorter prefixes end early instead
1605
+ let lo = 0;
1606
+ let hi = text.length;
1607
+ while (lo < hi) {
1608
+ const mid = (lo + hi) >>> 1;
1609
+ let failsOnToken = false;
1610
+ try {
1611
+ JSON.parse(text.slice(0, mid + 1));
1612
+ } catch (err) {
1613
+ failsOnToken = err instanceof Error && err.message.startsWith("Unexpected token");
1614
+ }
1615
+ if (failsOnToken) {
1616
+ hi = mid;
1617
+ } else {
1618
+ lo = mid + 1;
1619
+ }
1620
+ }
1621
+ return countLines(text, 0, lo) + 1;
1622
+ }
1623
+
1624
+ /**
1625
+ * The E_SYNTAX message: the parser's, with a hint when the text is JSON Lines (one document per
1626
+ * line, as apoc.export.json and many loggers write), which this importer does not read.
1627
+ * @param text - the document
1628
+ * @param message - the parser's message
1629
+ * @returns the message
1630
+ */
1631
+ function syntaxMessage(text: string, message: string): string {
1632
+ if (looksLikeJsonLines(text)) {
1633
+ return `invalid JSON: the input is JSON Lines (one JSON value per line), which is not a graph format graph-io reads; convert it to one JSON document (${message})`;
1634
+ }
1635
+ return `invalid JSON: ${message}`;
1636
+ }
1637
+
1277
1638
  /** How many of the integers kept as text the warning lists. */
1278
1639
  const BIG_INTEGERS_SHOWN = 10;
1279
1640
 
@@ -1355,7 +1716,7 @@ function flagOf(value: unknown, what: string, fallback: boolean, report: ImportR
1355
1716
 
1356
1717
  /**
1357
1718
  * The endpoint key and value of an edge record: the explicit key when given, else the first of the
1358
- * default keys the record has.
1719
+ * default keys the record has (a null value counts as absent).
1359
1720
  * @param record - the edge record
1360
1721
  * @param explicit - the caller's key, or null
1361
1722
  * @param defaults - the default keys
@@ -1367,16 +1728,68 @@ function endpointOf(
1367
1728
  defaults: readonly string[],
1368
1729
  ): { readonly key: string | null; readonly value: unknown } {
1369
1730
  if (explicit !== null) {
1370
- return { key: hasKey(record, explicit) ? explicit : null, value: record[explicit] };
1731
+ return { key: present(record, explicit) ? explicit : null, value: record[explicit] };
1371
1732
  }
1372
1733
  for (const key of defaults) {
1373
- if (hasKey(record, key)) {
1734
+ if (present(record, key)) {
1374
1735
  return { key, value: record[key] };
1375
1736
  }
1376
1737
  }
1377
1738
  return { key: null, value: undefined };
1378
1739
  }
1379
1740
 
1741
+ /**
1742
+ * Whether a record has a key with a value other than null: a null endpoint is an absent one.
1743
+ * @param record - the record
1744
+ * @param key - the key
1745
+ * @returns true when the key holds a non-null value
1746
+ */
1747
+ function present(record: JsonRecord, key: string): boolean {
1748
+ return hasKey(record, key) && record[key] !== null;
1749
+ }
1750
+
1751
+ /**
1752
+ * Report every key of a record the dialect does not read (W_JSON_UNREAD_KEY): it is dropped.
1753
+ * @param ctx - the context
1754
+ * @param record - the document or graph record
1755
+ * @param known - the keys the dialect reads
1756
+ * @param prefix - the record's path for the messages (`""` for the document, `"graph."`)
1757
+ */
1758
+ function reportUnreadKeys(ctx: ImportContext, record: JsonRecord, known: ReadonlySet<string>, prefix: string): void {
1759
+ for (const key of Object.keys(record)) {
1760
+ if (known.has(key)) {
1761
+ continue;
1762
+ }
1763
+ const value = record[key];
1764
+ const entries = Array.isArray(value) && value.length === 1 ? "entry" : "entries";
1765
+ const what = Array.isArray(value) ? `${String(value.length)} ${entries}` : describe(value);
1766
+ ctx.report.warning(
1767
+ "unsupported",
1768
+ JSON_ISSUE.UNREAD_KEY,
1769
+ `${prefix.length === 0 ? "top-level key" : "key"} ${prefix}${key} (${what}) is not read; dropped`,
1770
+ { element: `${prefix}${key}` },
1771
+ );
1772
+ }
1773
+ }
1774
+
1775
+ /** The keys of a vis.js document the reader reads. */
1776
+ const VIS_DOCUMENT_KEYS: ReadonlySet<string> = new Set(["nodes", "edges"]);
1777
+
1778
+ /** The keys of a graphology serialization the reader reads. */
1779
+ const GRAPHOLOGY_DOCUMENT_KEYS: ReadonlySet<string> = new Set(["nodes", "edges", "options", "attributes"]);
1780
+
1781
+ /** The keys of a JGF graph object the reader reads. */
1782
+ const JGF_GRAPH_KEYS: ReadonlySet<string> = new Set([
1783
+ "id",
1784
+ "type",
1785
+ "label",
1786
+ "directed",
1787
+ "metadata",
1788
+ "nodes",
1789
+ "edges",
1790
+ "hyperedges",
1791
+ ]);
1792
+
1380
1793
  /**
1381
1794
  * Whether any object of an array has a key with a non-null value.
1382
1795
  * @param items - the array
@@ -1414,15 +1827,16 @@ function importNodeLink(ctx: ImportContext, root: JsonRecord, dialect: "node-lin
1414
1827
  const nodes = arraySection(root.nodes, "nodes", report);
1415
1828
  const edges = arraySection(root[edgesKey], edgesKey, report);
1416
1829
  if (nodes === null) {
1417
- report.error(
1830
+ report.warning(
1418
1831
  "missing-value",
1419
1832
  JSON_ISSUE.MISSING_SECTION,
1420
1833
  "the document has no nodes array; nodes come from the edges",
1421
1834
  { element: "nodes" },
1422
1835
  );
1836
+ ctx.nodesFromEdges = true;
1423
1837
  }
1424
1838
  if (edges === null) {
1425
- report.error(
1839
+ report.warning(
1426
1840
  "missing-value",
1427
1841
  JSON_ISSUE.MISSING_SECTION,
1428
1842
  `the document has no ${edgesKey} array; the graph has no edges`,
@@ -1433,22 +1847,7 @@ function importNodeLink(ctx: ImportContext, root: JsonRecord, dialect: "node-lin
1433
1847
  const multigraph = hasKey(root, "multigraph") ? flagOf(root.multigraph, "multigraph", false, report) : null;
1434
1848
  ctx.setHeader(directed);
1435
1849
  ctx.writeGraphDict(root.graph, "graph");
1436
- for (const key of Object.keys(root)) {
1437
- if (key !== "nodes" && key !== edgesKey && key !== "directed" && key !== "multigraph" && key !== "graph") {
1438
- const value = root[key];
1439
- const what = Array.isArray(value)
1440
- ? `${String(value.length)} ${value.length === 1 ? "entry" : "entries"}`
1441
- : describe(value);
1442
- report.warning(
1443
- "unsupported",
1444
- JSON_ISSUE.UNREAD_KEY,
1445
- `top-level key ${key} (${what}) is not read; dropped`,
1446
- {
1447
- element: key,
1448
- },
1449
- );
1450
- }
1451
- }
1850
+ reportUnreadKeys(ctx, root, new Set(["nodes", edgesKey, "directed", "multigraph", "graph"]), "");
1452
1851
 
1453
1852
  const nodeList = nodes ?? [];
1454
1853
  const edgeList = edges ?? [];
@@ -1461,11 +1860,13 @@ function importNodeLink(ctx: ImportContext, root: JsonRecord, dialect: "node-lin
1461
1860
  if (!positional && nodeIdKey === null) {
1462
1861
  const candidates = ctx.options.nodeIdFrom === "label" ? ["label", "name", "id"] : ["id", "name"];
1463
1862
  nodeIdKey = candidates.find((key) => anyHas(nodeList, key)) ?? null;
1464
- if (ctx.options.nodeIdFrom === "label" && nodeIdKey === "id") {
1863
+ if (ctx.options.nodeIdFrom === "label" && (nodeIdKey === "id" || nodeIdKey === "name")) {
1465
1864
  report.warning(
1466
1865
  "unsupported",
1467
1866
  JSON_ISSUE.OPTION_IGNORED,
1468
- 'nodeIdFrom "label": no node has a label or name key; ids are read from "id"',
1867
+ nodeIdKey === "id"
1868
+ ? 'nodeIdFrom "label": no node has a label or name key; ids are read from "id"'
1869
+ : 'nodeIdFrom "label": no node has a "label" key; ids are read from "name"',
1469
1870
  { element: "nodeIdFrom" },
1470
1871
  );
1471
1872
  }
@@ -1495,6 +1896,9 @@ function importNodeLink(ctx: ImportContext, root: JsonRecord, dialect: "node-lin
1495
1896
  ({ indexLinks } = json);
1496
1897
  }
1497
1898
  const positionIds: (NodeId | null)[] | null = indexLinks ? [] : null;
1899
+ if (indexLinks && !positional && json.indexLinks === "auto" && nodeIdKey !== null) {
1900
+ reportAmbiguousIndexLinks(ctx, nodeList, edgeList, nodeIdKey);
1901
+ }
1498
1902
 
1499
1903
  for (let i = 0; i < nodeList.length; i++) {
1500
1904
  const element = `nodes[${i}]`;
@@ -1515,14 +1919,14 @@ function importNodeLink(ctx: ImportContext, root: JsonRecord, dialect: "node-lin
1515
1919
  const index = ctx.pushNode(id, element);
1516
1920
  if (index >= 0) {
1517
1921
  pushed = id;
1518
- writeFlat(ctx, ctx.nodes, index, record, id, (key) => key !== nodeIdKey);
1922
+ writeFlat(ctx.nodes, index, record, id, (key) => key !== nodeIdKey);
1519
1923
  }
1520
1924
  }
1521
1925
  }
1522
1926
  positionIds?.push(pushed);
1523
1927
  }
1524
1928
  throwIfAborted(ctx.options.signal);
1525
- const endpointKeys = importNodeLinkEdges(ctx, edgeList, edgesKey, positionIds);
1929
+ const endpointKeys = importNodeLinkEdges(ctx, edgeList, edgesKey, positionIds, multigraph);
1526
1930
  ctx.setMeta(
1527
1931
  {
1528
1932
  dialect,
@@ -1543,6 +1947,7 @@ function importNodeLink(ctx: ImportContext, root: JsonRecord, dialect: "node-lin
1543
1947
  * @param edgeList - the edge records
1544
1948
  * @param edgesKey - the top-level key they came from, for issues
1545
1949
  * @param positionIds - the ids by array position under index links, or null
1950
+ * @param multigraph - the declared multigraph flag, or null when the document has none
1546
1951
  * @returns the source and target keys the first well-formed edge used (null when none did)
1547
1952
  */
1548
1953
  function importNodeLinkEdges(
@@ -1550,11 +1955,17 @@ function importNodeLinkEdges(
1550
1955
  edgeList: readonly unknown[],
1551
1956
  edgesKey: string,
1552
1957
  positionIds: readonly (NodeId | null)[] | null,
1958
+ multigraph: boolean | null,
1553
1959
  ): { source: string | null; target: string | null } {
1554
1960
  const { json } = ctx;
1555
1961
  const kind = ctx.uniformKind();
1556
1962
  let sourceKey: string | null = null;
1557
1963
  let targetKey: string | null = null;
1964
+ // multigraph false: the pairs seen, to report parallel links; true: the (pair, key) triples, since
1965
+ // NetworkX reads a repeated (u, v, key) as one edge
1966
+ // ponytail: one string per edge whenever the flag is declared; a hash of the index pair if it shows in profiles
1967
+ const pairs = multigraph === null ? null : new Set<string>();
1968
+ let parallels = 0;
1558
1969
  for (let i = 0; i < edgeList.length; i++) {
1559
1970
  const element = `${edgesKey}[${i}]`;
1560
1971
  const record = edgeList[i];
@@ -1578,23 +1989,101 @@ function importNodeLinkEdges(
1578
1989
  continue;
1579
1990
  }
1580
1991
  const weight = ctx.weightOf(record);
1992
+ if (pairs !== null) {
1993
+ const pair = pairKey(u, v, kind === "directed");
1994
+ if (multigraph === false) {
1995
+ parallels += pairs.has(pair) ? 1 : 0;
1996
+ pairs.add(pair);
1997
+ } else if (hasKey(record, "key")) {
1998
+ const triple = `${pair} ${JSON.stringify(record.key)}`;
1999
+ if (pairs.has(triple)) {
2000
+ throw new GraphFormatError(
2001
+ JSON_ISSUE.DUPLICATE_EDGE_ID,
2002
+ `${element}: key ${JSON.stringify(record.key)} is repeated for the same pair; NetworkX reads it as the same edge, so it is skipped`,
2003
+ { id: String(record.key) },
2004
+ );
2005
+ }
2006
+ pairs.add(triple);
2007
+ }
2008
+ }
1581
2009
  const edge = ctx.pushEdge(u, v, kind, weight, element);
1582
2010
  const { weightFrom } = ctx.options;
1583
2011
  for (const key of Object.keys(record)) {
1584
2012
  if (key !== source.key && key !== target.key && key !== weightFrom) {
1585
- ctx.edges.write(edge, key, record[key], SUFFIX.data);
2013
+ ctx.edges.write(edge, key, record[key], SUFFIX.data, element);
1586
2014
  }
1587
2015
  }
1588
2016
  } catch (err) {
1589
2017
  ctx.skip(err, "edge", element);
1590
2018
  }
1591
2019
  }
2020
+ if (parallels > 0) {
2021
+ ctx.report.warning(
2022
+ "validation-error",
2023
+ JSON_ISSUE.INCONSISTENT,
2024
+ `the document declares multigraph false, but ${parallels} link${plural(parallels)} ${agree(parallels, "repeats", "repeat")} the endpoints of an earlier one; all are kept`,
2025
+ { element: edgesKey },
2026
+ );
2027
+ }
1592
2028
  return { source: sourceKey, target: targetKey };
1593
2029
  }
1594
2030
 
1595
2031
  /**
1596
- * Write the flat attributes of a node record (every own key the filter keeps).
2032
+ * The key of an endpoint pair: ordered for a directed edge, unordered for an undirected one.
2033
+ * @param u - the source
2034
+ * @param v - the target
2035
+ * @param directed - whether the order matters
2036
+ * @returns the key
2037
+ */
2038
+ function pairKey(u: NodeId, v: NodeId, directed: boolean): string {
2039
+ const a = JSON.stringify(u);
2040
+ const b = JSON.stringify(v);
2041
+ return directed || a <= b ? `${a} ${b}` : `${b} ${a}`;
2042
+ }
2043
+
2044
+ /**
2045
+ * Warn when indexLinks "auto" read integer endpoints as array positions although some also equal
2046
+ * a node id's text (`"2"`): the file may mean the ids, and the edges are then off by the positions.
1597
2047
  * @param ctx - the context
2048
+ * @param nodes - the node records
2049
+ * @param edges - the edge records
2050
+ * @param nodeIdKey - the node id key
2051
+ */
2052
+ function reportAmbiguousIndexLinks(
2053
+ ctx: ImportContext,
2054
+ nodes: readonly unknown[],
2055
+ edges: readonly unknown[],
2056
+ nodeIdKey: string,
2057
+ ): void {
2058
+ const ids = new Set<string>();
2059
+ for (const node of nodes) {
2060
+ if (isJsonObject(node) && typeof node[nodeIdKey] === "string") {
2061
+ ids.add(node[nodeIdKey]);
2062
+ }
2063
+ }
2064
+ for (const edge of edges) {
2065
+ if (!isJsonObject(edge)) {
2066
+ continue;
2067
+ }
2068
+ for (const value of [
2069
+ endpointOf(edge, ctx.json.sourceKey, NODE_LINK_SOURCE_KEYS).value,
2070
+ endpointOf(edge, ctx.json.targetKey, NODE_LINK_TARGET_KEYS).value,
2071
+ ]) {
2072
+ if (typeof value === "number" && ids.has(String(value))) {
2073
+ ctx.report.warning(
2074
+ "coercion",
2075
+ JSON_ISSUE.INDEX_LINKS,
2076
+ `integer endpoints are read as array positions, but ${value} is also a node id; pass indexLinks: false to read the endpoints as ids`,
2077
+ { element: "indexLinks" },
2078
+ );
2079
+ return;
2080
+ }
2081
+ }
2082
+ }
2083
+ }
2084
+
2085
+ /**
2086
+ * Write the flat attributes of a node record (every own key the filter keeps).
1598
2087
  * @param writer - the node writer
1599
2088
  * @param index - the node index
1600
2089
  * @param record - the record
@@ -1602,27 +2091,22 @@ function importNodeLinkEdges(
1602
2091
  * @param keep - which keys are attributes
1603
2092
  */
1604
2093
  function writeFlat(
1605
- ctx: ImportContext,
1606
2094
  writer: AttributeWriter,
1607
2095
  index: number,
1608
2096
  record: JsonRecord,
1609
2097
  id: NodeId,
1610
2098
  keep: (key: string) => boolean,
1611
2099
  ): void {
1612
- try {
1613
- for (const key of Object.keys(record)) {
1614
- if (keep(key)) {
1615
- writer.write(index, key, record[key], SUFFIX.data);
1616
- }
2100
+ for (const key of Object.keys(record)) {
2101
+ if (keep(key)) {
2102
+ writer.write(index, key, record[key], SUFFIX.data, String(id));
1617
2103
  }
1618
- } catch (err) {
1619
- ctx.report.recordError(err, { element: String(id) });
1620
2104
  }
1621
2105
  }
1622
2106
 
1623
2107
  /**
1624
- * The d3 index-link heuristic: endpoints are array positions when every endpoint is a
1625
- * non-negative integer and no node id is a number (a numeric id would make the endpoints ids;
2108
+ * The d3 index-link heuristic: endpoints are array positions when every endpoint is a number, at
2109
+ * least one is a non-negative integer, and no node id is a number (a numeric id would make the endpoints ids;
1626
2110
  * research note 07: d3 links reference nodes by array index and are never coerced to ids). An
1627
2111
  * index at or beyond the node count is then E_BAD_INDEX, never a new numeric node.
1628
2112
  * @param nodes - the node records
@@ -1652,10 +2136,14 @@ function looksIndexLinked(
1652
2136
  }
1653
2137
  const s = endpointOf(edge, json.sourceKey, NODE_LINK_SOURCE_KEYS).value;
1654
2138
  const t = endpointOf(edge, json.targetKey, NODE_LINK_TARGET_KEYS).value;
1655
- if (!isIndexBelow(s, Infinity) || !isIndexBelow(t, Infinity)) {
2139
+ // a fractional or negative number is a bad index (E_BAD_INDEX), never a reason to read every
2140
+ // other endpoint as an id; a missing endpoint is reported on its own
2141
+ if ((s !== undefined && typeof s !== "number") || (t !== undefined && typeof t !== "number")) {
1656
2142
  return false;
1657
2143
  }
1658
- seen++;
2144
+ if (isIndexBelow(s, Infinity) || isIndexBelow(t, Infinity)) {
2145
+ seen++;
2146
+ }
1659
2147
  }
1660
2148
  return seen > 0;
1661
2149
  }
@@ -1709,6 +2197,8 @@ function importVis(ctx: ImportContext, root: JsonRecord): void {
1709
2197
  const { report, json } = ctx;
1710
2198
  const nodes = arraySection(root.nodes, "nodes", report) ?? [];
1711
2199
  const edges = arraySection(root.edges, "edges", report) ?? [];
2200
+ // vis.js options and groups are rendering settings, not graph data
2201
+ reportUnreadKeys(ctx, root, VIS_DOCUMENT_KEYS, "");
1712
2202
  ctx.setHeader(ctx.defaultDirected("vis"));
1713
2203
  ctx.sink.reserve(nodes.length, edges.length);
1714
2204
  const nodeIdKey = json.nodeIdKey ?? "id";
@@ -1727,7 +2217,7 @@ function importVis(ctx: ImportContext, root: JsonRecord): void {
1727
2217
  }
1728
2218
  const index = ctx.pushNode(id, element);
1729
2219
  if (index >= 0) {
1730
- writeFlat(ctx, ctx.nodes, index, record, id, (key) => key !== nodeIdKey);
2220
+ writeFlat(ctx.nodes, index, record, id, (key) => key !== nodeIdKey);
1731
2221
  }
1732
2222
  }
1733
2223
  throwIfAborted(ctx.options.signal);
@@ -1763,13 +2253,28 @@ function importVis(ctx: ImportContext, root: JsonRecord): void {
1763
2253
  const { weightFrom } = ctx.options;
1764
2254
  for (const key of Object.keys(record)) {
1765
2255
  if (key !== source.key && key !== target.key && key !== "id" && key !== weightFrom) {
1766
- ctx.edges.write(edge, key, record[key], SUFFIX.data);
2256
+ ctx.edges.write(edge, key, record[key], SUFFIX.data, element);
1767
2257
  }
1768
2258
  }
1769
2259
  } catch (err) {
1770
2260
  ctx.skip(err, "edge", element);
1771
2261
  }
1772
2262
  }
2263
+ // vis.js draws arrows from the `arrows` edge option; the file declares no graph direction, so the
2264
+ // arrows stay an attribute and the graph is read as the dialect's default
2265
+ const arrows =
2266
+ kind === "undirected"
2267
+ ? edges.filter((e) => isJsonObject(e) && e.arrows !== undefined && e.arrows !== null && e.arrows !== "")
2268
+ .length
2269
+ : 0;
2270
+ if (arrows > 0) {
2271
+ report.warning(
2272
+ "validation-error",
2273
+ JSON_ISSUE.INCONSISTENT,
2274
+ `${arrows} edge${plural(arrows)} ${agree(arrows, "carries", "carry")} vis.js arrows but the graph is read undirected; the arrows are kept as an attribute (pass defaultDirected: true to read the edges as directed)`,
2275
+ { element: "arrows" },
2276
+ );
2277
+ }
1773
2278
  ctx.setMeta(
1774
2279
  { dialect: "vis", nodeIdKey, sourceKey: sourceKey ?? undefined, targetKey: targetKey ?? undefined },
1775
2280
  ctx.weightOriginPatch(),
@@ -1780,7 +2285,7 @@ function importVis(ctx: ImportContext, root: JsonRecord): void {
1780
2285
 
1781
2286
  /**
1782
2287
  * Read a NetworkX adjacency_data document: `nodes` as in node-link, and `adjacency[i]` the
1783
- * neighbour list of `nodes[i]`, one `{ id, key?, ...attributes }` entry per edge. An undirected
2288
+ * neighbor list of `nodes[i]`, one `{ id, key?, ...attributes }` entry per edge. An undirected
1784
2289
  * file lists every edge from both ends, so an entry whose mirror (the same pair and `key`) was
1785
2290
  * already read is that edge again and is not pushed twice; a self-loop is listed once.
1786
2291
  * @param ctx - the context
@@ -1791,7 +2296,7 @@ function importAdjacency(ctx: ImportContext, root: JsonRecord): void {
1791
2296
  const nodes = arraySection(root.nodes, "nodes", report) ?? [];
1792
2297
  const adjacency = arraySection(root.adjacency, "adjacency", report);
1793
2298
  if (adjacency === null) {
1794
- report.error(
2299
+ report.warning(
1795
2300
  "missing-value",
1796
2301
  JSON_ISSUE.MISSING_SECTION,
1797
2302
  "the document has no adjacency array; the graph has no edges",
@@ -1839,7 +2344,7 @@ function importAdjacency(ctx: ImportContext, root: JsonRecord): void {
1839
2344
  const index = ctx.pushNode(id, element);
1840
2345
  if (index >= 0) {
1841
2346
  pushed = id;
1842
- writeFlat(ctx, ctx.nodes, index, record, id, (key) => key !== idKey);
2347
+ writeFlat(ctx.nodes, index, record, id, (key) => key !== idKey);
1843
2348
  }
1844
2349
  }
1845
2350
  }
@@ -1848,8 +2353,9 @@ function importAdjacency(ctx: ImportContext, root: JsonRecord): void {
1848
2353
  throwIfAborted(ctx.options.signal);
1849
2354
  const kind = ctx.uniformKind();
1850
2355
  const { weightFrom } = ctx.options;
1851
- // undirected: entries read once whose mirror is still to come, by pair and key
1852
- const pending = new Map<string, number>();
2356
+ // undirected: entries read once whose mirror is still to come, by pair and key, with their attributes
2357
+ const pending = new Map<string, string[]>();
2358
+ let disagreeing = 0;
1853
2359
  for (let i = 0; i < lists.length; i++) {
1854
2360
  const element = `adjacency[${i}]`;
1855
2361
  const list = lists[i];
@@ -1863,7 +2369,7 @@ function importAdjacency(ctx: ImportContext, root: JsonRecord): void {
1863
2369
  report.error(
1864
2370
  "missing-value",
1865
2371
  JSON_ISSUE.BAD_INDEX,
1866
- `${element}: ${why}; its ${list.length} edge(s) are skipped`,
2372
+ `${element}: ${why}; its ${list.length} edge${plural(list.length)} ${agree(list.length, "is", "are")} skipped`,
1867
2373
  {
1868
2374
  element,
1869
2375
  },
@@ -1888,13 +2394,17 @@ function importAdjacency(ctx: ImportContext, root: JsonRecord): void {
1888
2394
  ctx.countSkipped("edge");
1889
2395
  continue;
1890
2396
  }
1891
- if (!directed && isMirroredEntry(pending, source, target, record.key)) {
1892
- continue;
2397
+ if (!directed) {
2398
+ const mirror = mirroredEntry(pending, source, target, record, idKey);
2399
+ disagreeing += mirror === "disagrees" ? 1 : 0;
2400
+ if (mirror !== "new") {
2401
+ continue;
2402
+ }
1893
2403
  }
1894
2404
  const edge = ctx.pushEdge(source, target, kind, ctx.weightOf(record), entry);
1895
2405
  for (const key of Object.keys(record)) {
1896
2406
  if (key !== idKey && key !== weightFrom) {
1897
- ctx.edges.write(edge, key, record[key], SUFFIX.data);
2407
+ ctx.edges.write(edge, key, record[key], SUFFIX.data, element);
1898
2408
  }
1899
2409
  }
1900
2410
  } catch (err) {
@@ -1902,36 +2412,112 @@ function importAdjacency(ctx: ImportContext, root: JsonRecord): void {
1902
2412
  }
1903
2413
  }
1904
2414
  }
2415
+ reportAdjacencyShape(ctx, nodes.length, adjacency === null ? null : lists.length, disagreeing, pending);
1905
2416
  ctx.setMeta({}, { declaredMultigraph: multigraph, ...ctx.weightOriginPatch() });
1906
2417
  }
1907
2418
 
1908
2419
  /**
1909
- * Whether an undirected adjacency entry is the mirror of one already read (the same unordered
1910
- * pair and key), consuming it; otherwise the entry is remembered as awaiting its mirror. A
1911
- * self-loop is listed once and never awaits one.
1912
- * @param pending - the entries awaiting their mirror, by owner, other end and key
2420
+ * Report what an adjacency_data document leaves inconsistent: nodes without an adjacency list
2421
+ * (NetworkX writes one per node, so the file was cut), undirected edges whose two listings carry
2422
+ * different attributes (the first is kept), and undirected entries whose mirror never came.
2423
+ * @param ctx - the context
2424
+ * @param nodes - the number of node records
2425
+ * @param lists - the number of adjacency lists, or null when the section is missing (reported already)
2426
+ * @param disagreeing - the mirrors whose attributes differ
2427
+ * @param pending - the entries still awaiting their mirror
2428
+ */
2429
+ function reportAdjacencyShape(
2430
+ ctx: ImportContext,
2431
+ nodes: number,
2432
+ lists: number | null,
2433
+ disagreeing: number,
2434
+ pending: ReadonlyMap<string, readonly string[]>,
2435
+ ): void {
2436
+ const { report } = ctx;
2437
+ if (lists !== null && lists < nodes) {
2438
+ report.warning(
2439
+ "missing-value",
2440
+ JSON_ISSUE.MISSING_SECTION,
2441
+ `${nodes - lists} node${plural(nodes - lists)} from nodes[${lists}] on ${agree(nodes - lists, "has", "have")} no adjacency list (NetworkX writes one per node); their edges listed elsewhere are kept`,
2442
+ { element: "adjacency" },
2443
+ );
2444
+ }
2445
+ if (disagreeing > 0) {
2446
+ report.warning(
2447
+ "validation-error",
2448
+ JSON_ISSUE.INCONSISTENT,
2449
+ `the two listings of ${disagreeing} undirected edge${plural(disagreeing)} ${agree(disagreeing, "disagrees", "disagree")} on their attributes; the first listing is kept`,
2450
+ { element: "adjacency" },
2451
+ );
2452
+ }
2453
+ let unmatched = 0;
2454
+ for (const waiting of pending.values()) {
2455
+ unmatched += waiting.length;
2456
+ }
2457
+ if (unmatched > 0) {
2458
+ report.warning(
2459
+ "validation-error",
2460
+ JSON_ISSUE.INCONSISTENT,
2461
+ `${unmatched} undirected adjacency entr(ies) have no mirror in the other node's list; each is read as one edge`,
2462
+ { element: "adjacency" },
2463
+ );
2464
+ }
2465
+ }
2466
+
2467
+ /**
2468
+ * A JSON.stringify replacer that writes every object's keys in sorted order.
2469
+ * @param _key - the key
2470
+ * @param value - the value
2471
+ * @returns the value, an object with its keys sorted
2472
+ */
2473
+ function sortedKeys(_key: string, value: unknown): unknown {
2474
+ return isJsonObject(value) ? Object.fromEntries(Object.entries(value).sort(([x], [y]) => (x < y ? -1 : 1))) : value;
2475
+ }
2476
+
2477
+ /**
2478
+ * Whether an undirected adjacency entry is the mirror of one already read (the same unordered pair
2479
+ * and key), consuming it; otherwise the entry is remembered as awaiting its mirror. A self-loop is
2480
+ * listed once and never awaits one.
2481
+ * @param pending - the entries awaiting their mirror, by owner, other end and key: their attributes
1913
2482
  * @param source - the owner of the list
1914
2483
  * @param target - the entry's node
1915
- * @param key - the entry's multigraph key, or undefined
1916
- * @returns true when the entry is a mirror and must not be pushed
2484
+ * @param record - the entry
2485
+ * @param idKey - the entry's id key (not an attribute)
2486
+ * @returns "new" for an entry to push, "mirror" for the second listing of one read, "disagrees"
2487
+ * for a second listing with other attributes
1917
2488
  */
1918
- function isMirroredEntry(pending: Map<string, number>, source: NodeId, target: NodeId, key: unknown): boolean {
2489
+ function mirroredEntry(
2490
+ pending: Map<string, string[]>,
2491
+ source: NodeId,
2492
+ target: NodeId,
2493
+ record: JsonRecord,
2494
+ idKey: string,
2495
+ ): "new" | "mirror" | "disagrees" {
1919
2496
  const a = JSON.stringify(source);
1920
2497
  const b = JSON.stringify(target);
1921
2498
  if (a === b) {
1922
- return false;
2499
+ return "new";
1923
2500
  }
1924
- const k = JSON.stringify(key ?? null);
2501
+ const k = JSON.stringify(record.key ?? null);
2502
+ // key order is not part of a JSON object, so two listings in another order still agree
2503
+ const attributes = JSON.stringify(
2504
+ Object.fromEntries(Object.entries(record).filter(([key]) => key !== idKey)),
2505
+ sortedKeys,
2506
+ );
1925
2507
  // an entry is the mirror of one listed by the other end, never of a parallel entry of its own list
1926
- const mirror = `${b} ${a} ${k}`;
1927
- const waiting = pending.get(mirror) ?? 0;
1928
- if (waiting > 0) {
1929
- pending.set(mirror, waiting - 1);
1930
- return true;
2508
+ const waiting = pending.get(`${b} ${a} ${k}`);
2509
+ if (waiting !== undefined && waiting.length > 0) {
2510
+ const first = waiting.shift();
2511
+ return first === attributes ? "mirror" : "disagrees";
1931
2512
  }
1932
2513
  const own = `${a} ${b} ${k}`;
1933
- pending.set(own, (pending.get(own) ?? 0) + 1);
1934
- return false;
2514
+ const list = pending.get(own);
2515
+ if (list === undefined) {
2516
+ pending.set(own, [attributes]);
2517
+ } else {
2518
+ list.push(attributes);
2519
+ }
2520
+ return "new";
1935
2521
  }
1936
2522
 
1937
2523
  /**
@@ -1971,7 +2557,7 @@ function importTree(ctx: ImportContext, root: JsonRecord): void {
1971
2557
  } else if (ctx.pushNode(id, element) < 0) {
1972
2558
  id = null;
1973
2559
  } else {
1974
- writeFlat(ctx, ctx.nodes, ctx.sink.indexOf(id), record, id, (key) => key !== idKey && key !== "children");
2560
+ writeFlat(ctx.nodes, ctx.sink.indexOf(id), record, id, (key) => key !== idKey && key !== "children");
1975
2561
  if (parent !== null) {
1976
2562
  try {
1977
2563
  ctx.pushEdge(parent, id, kind, undefined, element);
@@ -2000,7 +2586,7 @@ function importTree(ctx: ImportContext, root: JsonRecord): void {
2000
2586
  // ============================================================ graphology
2001
2587
 
2002
2588
  /**
2003
- * Read a graphology serialisation: `options.type` decides the header direction ("mixed" or absent:
2589
+ * Read a graphology serialization: `options.type` decides the header direction ("mixed" or absent:
2004
2590
  * from the edges' `undirected` flags), `options.multi` the declared multigraph flag, node `key`
2005
2591
  * the id, `attributes` the columns, edge `key` the edge id.
2006
2592
  * @param ctx - the context
@@ -2010,6 +2596,7 @@ function importGraphology(ctx: ImportContext, root: JsonRecord): void {
2010
2596
  const { report } = ctx;
2011
2597
  const nodes = arraySection(root.nodes, "nodes", report) ?? [];
2012
2598
  const edges = arraySection(root.edges, "edges", report) ?? [];
2599
+ reportUnreadKeys(ctx, root, GRAPHOLOGY_DOCUMENT_KEYS, "");
2013
2600
  const options = isJsonObject(root.options) ? root.options : {};
2014
2601
  if (hasKey(root, "options") && !isJsonObject(root.options)) {
2015
2602
  report.warning("validation-error", JSON_ISSUE.BAD_FLAG, "options is not an object; ignored", {
@@ -2064,6 +2651,9 @@ function importGraphology(ctx: ImportContext, root: JsonRecord): void {
2064
2651
  throwIfAborted(ctx.options.signal);
2065
2652
 
2066
2653
  const ids = ctx.declareEdgeIds(edges, (edge) => edge.key, "key", true);
2654
+ // what the edges do against the declared options: graphology itself refuses such a document
2655
+ const violations = { selfLoops: 0, parallels: 0, flags: 0 };
2656
+ const pairs = multi === false ? new Set<string>() : null;
2067
2657
  for (let i = 0; i < edges.length; i++) {
2068
2658
  const element = `edges[${i}]`;
2069
2659
  const record = edges[i];
@@ -2071,8 +2661,8 @@ function importGraphology(ctx: ImportContext, root: JsonRecord): void {
2071
2661
  ctx.badElement("edge", element);
2072
2662
  continue;
2073
2663
  }
2074
- if (!hasKey(record, "source") || !hasKey(record, "target")) {
2075
- ctx.missingEndpoint(element, hasKey(record, "source") ? "target" : "source");
2664
+ if (!present(record, "source") || !present(record, "target")) {
2665
+ ctx.missingEndpoint(element, present(record, "source") ? "target" : "source");
2076
2666
  continue;
2077
2667
  }
2078
2668
  try {
@@ -2087,19 +2677,78 @@ function importGraphology(ctx: ImportContext, root: JsonRecord): void {
2087
2677
  kind = flagOf(record.undirected, `${element}.undirected`, false, report) ? "undirected" : "directed";
2088
2678
  } else {
2089
2679
  kind = type;
2680
+ if (typeof record.undirected === "boolean" && record.undirected !== (type === "undirected")) {
2681
+ violations.flags++;
2682
+ }
2090
2683
  }
2091
- const attributes = isJsonObject(record.attributes) ? record.attributes : {};
2684
+ const { attributes: rawAttributes } = record;
2685
+ if (rawAttributes !== undefined && rawAttributes !== null && !isJsonObject(rawAttributes)) {
2686
+ report.error(
2687
+ "validation-error",
2688
+ JSON_ISSUE.BAD_VALUE,
2689
+ `${element}: attributes must be an object, found ${describe(rawAttributes)}`,
2690
+ { element },
2691
+ );
2692
+ }
2693
+ const attributes = isJsonObject(rawAttributes) ? rawAttributes : {};
2092
2694
  const idValue = ctx.edgeIdValue(ids, record.key);
2093
2695
  const edge = ctx.pushEdge(u, v, kind, ctx.weightOf(attributes), element);
2094
2696
  ctx.setEdgeId(ids, edge, idValue);
2095
- ctx.writeNested(ctx.edges, edge, record, attributes, GRAPHOLOGY_EDGE_KEYS, ctx.options.weightFrom);
2697
+ ctx.writeNested(ctx.edges, edge, record, attributes, GRAPHOLOGY_EDGE_KEYS, ctx.options.weightFrom, element);
2698
+ if (allowSelfLoops === false && u === v) {
2699
+ violations.selfLoops++;
2700
+ }
2701
+ if (pairs !== null) {
2702
+ const pair = `${kind} ${pairKey(u, v, kind === "directed")}`;
2703
+ violations.parallels += pairs.has(pair) ? 1 : 0;
2704
+ pairs.add(pair);
2705
+ }
2096
2706
  } catch (err) {
2097
2707
  ctx.skip(err, "edge", element);
2098
2708
  }
2099
2709
  }
2710
+ reportGraphologyViolations(ctx, violations, type);
2100
2711
  ctx.setMeta({ dialect: "graphology", allowSelfLoops }, { declaredMultigraph: multi, ...ctx.weightOriginPatch() });
2101
2712
  }
2102
2713
 
2714
+ /**
2715
+ * Report the edges that contradict a graphology document's declared options (W_JSON_INCONSISTENT,
2716
+ * one warning per kind); they are kept.
2717
+ * @param ctx - the context
2718
+ * @param violations - the counts
2719
+ * @param violations.selfLoops - self-loops under allowSelfLoops false
2720
+ * @param violations.parallels - parallel edges under multi false
2721
+ * @param violations.flags - per-edge undirected flags against options.type
2722
+ * @param type - options.type
2723
+ */
2724
+ function reportGraphologyViolations(
2725
+ ctx: ImportContext,
2726
+ violations: { readonly selfLoops: number; readonly parallels: number; readonly flags: number },
2727
+ type: string,
2728
+ ): void {
2729
+ const notes: string[] = [];
2730
+ if (violations.selfLoops > 0) {
2731
+ notes.push(
2732
+ `options.allowSelfLoops is false, but ${violations.selfLoops} self-loop${plural(violations.selfLoops)} ${agree(violations.selfLoops, "is", "are")} listed`,
2733
+ );
2734
+ }
2735
+ if (violations.parallels > 0) {
2736
+ notes.push(
2737
+ `options.multi is false, but ${violations.parallels} parallel edge${plural(violations.parallels)} ${agree(violations.parallels, "is", "are")} listed`,
2738
+ );
2739
+ }
2740
+ if (violations.flags > 0) {
2741
+ notes.push(
2742
+ `options.type is ${type}, but ${violations.flags} edge${plural(violations.flags)} ${agree(violations.flags, "carries", "carry")} the other undirected flag; read as ${type}`,
2743
+ );
2744
+ }
2745
+ for (const note of notes) {
2746
+ ctx.report.warning("validation-error", JSON_ISSUE.INCONSISTENT, `${note}; every edge is kept`, {
2747
+ element: "options",
2748
+ });
2749
+ }
2750
+ }
2751
+
2103
2752
  /**
2104
2753
  * Push a node whose attributes live in a nested dict (graphology `attributes`, JGF `metadata`);
2105
2754
  * element-level keys the dialect does not define are kept with the `#element` suffix.
@@ -2133,7 +2782,7 @@ function pushNestedNode(
2133
2782
  { element },
2134
2783
  );
2135
2784
  }
2136
- ctx.writeNested(ctx.nodes, index, record, isJsonObject(dict) ? dict : {}, structural, null);
2785
+ ctx.writeNested(ctx.nodes, index, record, isJsonObject(dict) ? dict : {}, structural, null, element);
2137
2786
  } catch (err) {
2138
2787
  ctx.report.recordError(err, { element: String(id) });
2139
2788
  }
@@ -2209,6 +2858,14 @@ function importJgf(ctx: ImportContext, root: JsonRecord): void {
2209
2858
  ctx.countSkipped("node");
2210
2859
  continue;
2211
2860
  }
2861
+ if (record !== null && hasKey(record, "id") && record.id !== key) {
2862
+ report.warning(
2863
+ "validation-error",
2864
+ JSON_ISSUE.INCONSISTENT,
2865
+ `${element}: the inner id ${describe(record.id)} differs from the key; the key is the id, the inner one is kept as id${SUFFIX.element}`,
2866
+ { element },
2867
+ );
2868
+ }
2212
2869
  pushJgfNode(ctx, id, record ?? {}, labelColumn, element);
2213
2870
  }
2214
2871
  } else {
@@ -2258,7 +2915,7 @@ function importJgf(ctx: ImportContext, root: JsonRecord): void {
2258
2915
  element,
2259
2916
  });
2260
2917
  }
2261
- ctx.writeNested(ctx.edges, edge, record, dict, structural, ctx.options.weightFrom);
2918
+ ctx.writeNested(ctx.edges, edge, record, dict, structural, ctx.options.weightFrom, element);
2262
2919
  };
2263
2920
 
2264
2921
  for (let i = 0; i < edges.length; i++) {
@@ -2268,8 +2925,8 @@ function importJgf(ctx: ImportContext, root: JsonRecord): void {
2268
2925
  ctx.badElement("edge", element);
2269
2926
  continue;
2270
2927
  }
2271
- if (!hasKey(record, "source") || !hasKey(record, "target")) {
2272
- ctx.missingEndpoint(element, hasKey(record, "source") ? "target" : "source");
2928
+ if (!present(record, "source") || !present(record, "target")) {
2929
+ ctx.missingEndpoint(element, present(record, "source") ? "target" : "source");
2273
2930
  continue;
2274
2931
  }
2275
2932
  try {
@@ -2298,7 +2955,23 @@ function importJgf(ctx: ImportContext, root: JsonRecord): void {
2298
2955
  * @returns the graph object; the import fails when there is none
2299
2956
  */
2300
2957
  function jgfGraphOf(ctx: ImportContext, root: JsonRecord): JsonRecord {
2301
- return isJsonObject(root.graph) ? root.graph : chosenGraph(ctx, root, "JGF");
2958
+ const single = isJsonObject(root.graph);
2959
+ // a document with both graph and graphs is read by its graph; the graphs are not
2960
+ reportUnreadKeys(ctx, root, new Set([single ? "graph" : "graphs"]), "");
2961
+ const graph = single ? (root.graph as JsonRecord) : chosenGraph(ctx, root, "JGF");
2962
+ const prefix = single ? "graph." : `graphs[${(root.graphs as unknown[]).indexOf(graph)}].`;
2963
+ reportUnreadKeys(ctx, graph, JGF_GRAPH_KEYS, prefix);
2964
+ for (const key of ["id", "type", "label"]) {
2965
+ if (graph[key] !== undefined && graph[key] !== null && typeof graph[key] !== "string") {
2966
+ ctx.report.error(
2967
+ "validation-error",
2968
+ JSON_ISSUE.BAD_VALUE,
2969
+ `${prefix}${key} must be a string, found ${describe(graph[key])}; ignored`,
2970
+ { element: `${prefix}${key}` },
2971
+ );
2972
+ }
2973
+ }
2974
+ return graph;
2302
2975
  }
2303
2976
 
2304
2977
  /**
@@ -2308,6 +2981,7 @@ function jgfGraphOf(ctx: ImportContext, root: JsonRecord): JsonRecord {
2308
2981
  * @param root - the document
2309
2982
  * @param what - the dialect's name, for the messages
2310
2983
  * @returns the graph object; the import fails when there is none
2984
+ * @category Plugin helpers
2311
2985
  */
2312
2986
  export function chosenGraph(ctx: ImportContext, root: JsonRecord, what: string): JsonRecord {
2313
2987
  const { report } = ctx;
@@ -2317,17 +2991,24 @@ export function chosenGraph(ctx: ImportContext, root: JsonRecord, what: string):
2317
2991
  }
2318
2992
  const index =
2319
2993
  ctx.json.all === true ? ctx.json.graphIndex : chooseGraph(graphs.map(graphNameOf), ctx.json.choice, report);
2320
- if (graphs.length > 1 && ctx.json.all !== true) {
2994
+ if (graphs.length > 1 && ctx.json.all !== true && !graphChosen(ctx.json.choice)) {
2321
2995
  report.warning(
2322
2996
  "unsupported",
2323
2997
  JSON_ISSUE.MULTIPLE_GRAPHS,
2324
- `the document holds ${graphs.length} graphs; only graphs[${index}] is read (${graphs.length - 1} skipped), importAll() reads every one`,
2998
+ `the document holds ${graphs.length} graphs; only graphs[${index}] is read (${graphs.length - 1} skipped); importAllGraphs() reads every one`,
2325
2999
  { element: "graphs" },
2326
3000
  );
2327
3001
  }
2328
3002
  const graph = graphs[index];
2329
3003
  if (!isJsonObject(graph)) {
2330
- return report.fail(JSON_ISSUE.SHAPE, `graphs[${index}] is not an object`);
3004
+ if (ctx.json.all !== true) {
3005
+ return report.fail(JSON_ISSUE.SHAPE, `graphs[${index}] is not an object`);
3006
+ }
3007
+ // importAll(): one bad graph is that graph's error, read as empty; the others are still read
3008
+ report.error("validation-error", JSON_ISSUE.SHAPE, `graphs[${index}] is not an object; it is read as empty`, {
3009
+ element: `graphs[${index}]`,
3010
+ });
3011
+ return {};
2331
3012
  }
2332
3013
  return graph;
2333
3014
  }
@@ -2391,38 +3072,29 @@ function listingsOf(root: unknown, dialect: JsonImportDialect): GraphListing[] {
2391
3072
  return [{ index: 0, name: null, nodes: null, edges: null }];
2392
3073
  }
2393
3074
 
2394
- /** The longest string V8 makes (2^29 - 24 UTF-16 code units); a longer document cannot be one JSON.parse input. */
2395
- const MAX_TEXT_LENGTH = 2 ** 29 - 24;
2396
-
2397
3075
  /**
2398
- * Read the whole input as one string, failing with E_TOO_LARGE (category unsupported) before the
2399
- * join when it is longer than one JavaScript string can hold (OBO Graphs files such as
2400
- * ncbitaxon.json are; design 7.1 defers streaming the JSON reader).
2401
- * @param input - the input
2402
- * @param report - the report
2403
- * @param options - cancellation, progress and encoding
2404
- * @returns the text
3076
+ * Whether a text that is not one JSON document is JSON Lines / NDJSON: its first two non-blank
3077
+ * lines are each a JSON object or array.
3078
+ * @param text - the document
3079
+ * @returns true for JSON Lines
2405
3080
  */
2406
- async function readJsonText(
2407
- input: ImportInput,
2408
- report: ImportReportBuilder,
2409
- options: ResolvedImportOptions,
2410
- ): Promise<string> {
2411
- if (typeof input === "string") {
2412
- return readText(input, report, options);
2413
- }
2414
- const parts: string[] = [];
2415
- let length = 0;
2416
- for await (const chunk of textChunks(input, report, options)) {
2417
- length += chunk.length;
2418
- if (length > MAX_TEXT_LENGTH) {
2419
- const message = `the document is longer than ${MAX_TEXT_LENGTH} characters, the most one JavaScript string holds`;
2420
- report.error("unsupported", JSON_ISSUE.TOO_LARGE, message);
2421
- throw report.abort(message, { code: JSON_ISSUE.TOO_LARGE });
2422
- }
2423
- parts.push(chunk);
2424
- }
2425
- return parts.length === 1 ? parts[0] : parts.join("");
3081
+ function looksLikeJsonLines(text: string): boolean {
3082
+ const lines = text
3083
+ .slice(0, 64 * 1024)
3084
+ .split(/\r?\n/)
3085
+ .filter((line) => line.trim().length > 0)
3086
+ .slice(0, 2);
3087
+ return (
3088
+ lines.length === 2 &&
3089
+ lines.every((line) => {
3090
+ try {
3091
+ const value: unknown = JSON.parse(line);
3092
+ return typeof value === "object" && value !== null;
3093
+ } catch {
3094
+ return false;
3095
+ }
3096
+ })
3097
+ );
2426
3098
  }
2427
3099
 
2428
3100
  /**
@@ -2452,7 +3124,7 @@ function pushJgfNode(
2452
3124
  element,
2453
3125
  });
2454
3126
  }
2455
- ctx.writeNested(ctx.nodes, index, record, isJsonObject(metadata) ? metadata : {}, JGF_NODE_KEYS, null);
3127
+ ctx.writeNested(ctx.nodes, index, record, isJsonObject(metadata) ? metadata : {}, JGF_NODE_KEYS, null, element);
2456
3128
  } catch (err) {
2457
3129
  ctx.report.recordError(err, { element: String(id) });
2458
3130
  }
@@ -2488,23 +3160,52 @@ function importHyperedges(
2488
3160
  const { report } = ctx;
2489
3161
  const policy = ctx.options.hyperedges;
2490
3162
  if (policy === "error") {
2491
- report.error("unsupported", JSON_ISSUE.HYPEREDGE, `${hyperedges.length} hyperedge(s) (hyperedges: "error")`, {
2492
- element: "hyperedges",
2493
- });
3163
+ report.error(
3164
+ "unsupported",
3165
+ JSON_ISSUE.HYPEREDGE,
3166
+ `${hyperedges.length} hyperedge${plural(hyperedges.length)} (hyperedges: "error")`,
3167
+ {
3168
+ element: "hyperedges",
3169
+ },
3170
+ );
2494
3171
  throw report.abort("hyperedges refused", { code: JSON_ISSUE.HYPEREDGE, count: hyperedges.length });
2495
3172
  }
2496
3173
  if (policy === "skip") {
2497
- report.warning("unsupported", JSON_ISSUE.HYPEREDGES_SKIPPED, `${hyperedges.length} hyperedge(s) skipped`, {
2498
- element: "hyperedges",
2499
- });
3174
+ report.warning(
3175
+ "unsupported",
3176
+ JSON_ISSUE.HYPEREDGES_SKIPPED,
3177
+ `${hyperedges.length} hyperedge${plural(hyperedges.length)} skipped`,
3178
+ {
3179
+ element: "hyperedges",
3180
+ },
3181
+ );
2500
3182
  report.loss(
2501
3183
  JSON_ISSUE.HYPEREDGES_SKIPPED,
2502
- `${hyperedges.length} hyperedge(s) were not imported`,
3184
+ `${hyperedges.length} hyperedge${plural(hyperedges.length)} ${agree(hyperedges.length, "was", "were")} not imported`,
2503
3185
  null,
2504
3186
  hyperedges.length,
2505
3187
  );
2506
3188
  return;
2507
3189
  }
3190
+ // each expanded edge is pushed on its own, so one the sink refuses is reported and counted
3191
+ // while the others stay
3192
+ const expand = (
3193
+ record: JsonRecord,
3194
+ pairs: readonly (readonly [NodeId, NodeId])[],
3195
+ kind: EdgeKind,
3196
+ element: string,
3197
+ ): void => {
3198
+ for (const [u, v] of pairs) {
3199
+ try {
3200
+ push(record, u, v, kind, element, JGF_HYPEREDGE_KEYS);
3201
+ } catch (err) {
3202
+ ctx.skip(err, "edge", element);
3203
+ }
3204
+ }
3205
+ };
3206
+ // the edges expanded so far: the cap is on the whole import, so many hyperedges just under it
3207
+ // cannot amplify without bound either
3208
+ const budget = { used: 0 };
2508
3209
  for (let i = 0; i < hyperedges.length; i++) {
2509
3210
  const element = `hyperedges[${i}]`;
2510
3211
  const record = hyperedges[i];
@@ -2514,7 +3215,11 @@ function importHyperedges(
2514
3215
  }
2515
3216
  try {
2516
3217
  if (Array.isArray(record.nodes)) {
2517
- const members = record.nodes.map((raw) => ctx.requireId(raw, element));
3218
+ const members = distinctMembers(
3219
+ ctx,
3220
+ record.nodes.map((raw) => ctx.requireId(raw, element)),
3221
+ element,
3222
+ );
2518
3223
  if (members.length < 2) {
2519
3224
  throw new GraphFormatError(
2520
3225
  "E_INVALID_ID",
@@ -2522,18 +3227,21 @@ function importHyperedges(
2522
3227
  { reason: "hyperedge shape" },
2523
3228
  );
2524
3229
  }
2525
- const kind: EdgeKind = directed ? "directed" : "undirected";
3230
+ const n = members.length;
3231
+ checkExpansion(policy === "star" ? n - 1 : (n * (n - 1)) / 2, budget, element);
3232
+ const pairs: [NodeId, NodeId][] = [];
2526
3233
  if (policy === "star") {
2527
- for (let k = 1; k < members.length; k++) {
2528
- push(record, members[0], members[k], kind, element, JGF_HYPEREDGE_KEYS);
3234
+ for (let k = 1; k < n; k++) {
3235
+ pairs.push([members[0], members[k]]);
2529
3236
  }
2530
3237
  } else {
2531
- for (let a = 0; a < members.length; a++) {
2532
- for (let b = a + 1; b < members.length; b++) {
2533
- push(record, members[a], members[b], kind, element, JGF_HYPEREDGE_KEYS);
3238
+ for (let a = 0; a < n; a++) {
3239
+ for (let b = a + 1; b < n; b++) {
3240
+ pairs.push([members[a], members[b]]);
2534
3241
  }
2535
3242
  }
2536
3243
  }
3244
+ expand(record, pairs, directed ? "directed" : "undirected", element);
2537
3245
  } else if (Array.isArray(record.source) && Array.isArray(record.target)) {
2538
3246
  const sources = record.source.map((raw) => ctx.requireId(raw, element));
2539
3247
  const targets = record.target.map((raw) => ctx.requireId(raw, element));
@@ -2544,11 +3252,13 @@ function importHyperedges(
2544
3252
  { reason: "hyperedge shape" },
2545
3253
  );
2546
3254
  }
2547
- for (const s of sources) {
2548
- for (const t of targets) {
2549
- push(record, s, t, "directed", element, JGF_HYPEREDGE_KEYS);
2550
- }
2551
- }
3255
+ checkExpansion(sources.length * targets.length, budget, element);
3256
+ expand(
3257
+ record,
3258
+ sources.flatMap((s) => targets.map((t) => [s, t] as const)),
3259
+ "directed",
3260
+ element,
3261
+ );
2552
3262
  } else {
2553
3263
  report.error(
2554
3264
  "validation-error",
@@ -2559,11 +3269,64 @@ function importHyperedges(
2559
3269
  ctx.countSkipped("edge");
2560
3270
  }
2561
3271
  } catch (err) {
2562
- ctx.skip(err, "edge", element);
3272
+ if (err instanceof ExpansionRefused) {
3273
+ // one hyperedge over the cap is skipped; the shared E_TOO_LARGE rule would stop the import
3274
+ report.error("unsupported", err.code, err.message, { element });
3275
+ ctx.countSkipped("edge");
3276
+ } else {
3277
+ ctx.skip(err, "edge", element);
3278
+ }
2563
3279
  }
2564
3280
  }
2565
3281
  }
2566
3282
 
3283
+ /** A hyperedge whose expansion would pass MAX_HYPEREDGE_EXPANSION: that hyperedge alone is skipped. */
3284
+ class ExpansionRefused extends GraphFormatError {}
3285
+
3286
+ /** The most edges the hyperedges of one import expand into; a 20k-member clique would be 200M edges. */
3287
+ // ponytail: a fixed cap; make it an option if a real file needs more
3288
+ const MAX_HYPEREDGE_EXPANSION = 1_000_000;
3289
+
3290
+ /**
3291
+ * Refuse an expansion that would take the import beyond MAX_HYPEREDGE_EXPANSION expanded edges,
3292
+ * before any of it is pushed; otherwise count it against the budget.
3293
+ * @param count - the number of edges the expansion makes
3294
+ * @param budget - the edges the import's hyperedges expanded into so far
3295
+ * @param budget.used - that count, updated
3296
+ * @param element - the hyperedge's name
3297
+ */
3298
+ function checkExpansion(count: number, budget: { used: number }, element: string): void {
3299
+ if (budget.used + count > MAX_HYPEREDGE_EXPANSION) {
3300
+ throw new ExpansionRefused(
3301
+ "E_TOO_LARGE",
3302
+ `${element}: the expansion makes ${count} edges, which with the ${budget.used} already expanded is more than ${MAX_HYPEREDGE_EXPANSION}; the hyperedge is skipped`,
3303
+ { count, used: budget.used, limit: MAX_HYPEREDGE_EXPANSION },
3304
+ );
3305
+ }
3306
+ budget.used += count;
3307
+ }
3308
+
3309
+ /**
3310
+ * The members of an undirected hyperedge with repeats removed (a repeat would expand into a
3311
+ * self-loop and parallel edges), each repeat reported as E_HYPEREDGE_SHAPE.
3312
+ * @param ctx - the context
3313
+ * @param members - the members as listed
3314
+ * @param element - the hyperedge's name
3315
+ * @returns the distinct members in first-listed order
3316
+ */
3317
+ function distinctMembers(ctx: ImportContext, members: readonly NodeId[], element: string): NodeId[] {
3318
+ const distinct = [...new Set(members)];
3319
+ if (distinct.length < members.length) {
3320
+ ctx.report.error(
3321
+ "validation-error",
3322
+ JSON_ISSUE.HYPEREDGE_SHAPE,
3323
+ `${element} lists ${members.length - distinct.length} member${plural(members.length - distinct.length)} more than once; each is read once`,
3324
+ { element },
3325
+ );
3326
+ }
3327
+ return distinct;
3328
+ }
3329
+
2567
3330
  // ============================================================ Cytoscape
2568
3331
 
2569
3332
  /**
@@ -2611,7 +3374,8 @@ function importCytoscape(ctx: ImportContext, root: unknown): void {
2611
3374
  role: "position",
2612
3375
  mutable: true,
2613
3376
  nullable: true,
2614
- extra: { sourceDims: 2, units: "file" },
3377
+ // 3D layouts written by Cytoscape-compatible tools carry a z
3378
+ extra: { sourceDims: nodes.some(hasPositionZ) ? 3 : 2, units: "file" },
2615
3379
  origin: { format: "json", namespace: "cytoscape" },
2616
3380
  },
2617
3381
  anyHas(nodes, "position"),
@@ -2648,6 +3412,14 @@ function importCytoscape(ctx: ImportContext, root: unknown): void {
2648
3412
  if (index < 0) {
2649
3413
  continue;
2650
3414
  }
3415
+ if (present(data, "source") && present(data, "target")) {
3416
+ report.warning(
3417
+ "validation-error",
3418
+ JSON_ISSUE.INCONSISTENT,
3419
+ `${element} has data.source and data.target, the shape of an edge, but its section or group says node; read as a node`,
3420
+ { element },
3421
+ );
3422
+ }
2651
3423
  try {
2652
3424
  for (const key of Object.keys(data)) {
2653
3425
  if (key === "id") {
@@ -2663,31 +3435,65 @@ function importCytoscape(ctx: ImportContext, root: unknown): void {
2663
3435
  }
2664
3436
  continue;
2665
3437
  }
2666
- ctx.nodes.write(index, key, data[key], SUFFIX.data);
3438
+ ctx.nodes.write(index, key, data[key], SUFFIX.data, element);
2667
3439
  }
2668
3440
  const { position } = record;
2669
3441
  if (position !== undefined && position !== null) {
2670
- if (isJsonObject(position) && typeof position.x === "number" && typeof position.y === "number") {
3442
+ const z = isJsonObject(position) ? (position.z ?? 0) : 0;
3443
+ if (isJsonObject(position) && isCoordinate(position.x) && isCoordinate(position.y) && isCoordinate(z)) {
2671
3444
  point[0] = position.x;
2672
3445
  // Cytoscape's y grows downward; positions are stored y-up (the exporter flips back)
2673
3446
  point[1] = flipY(position.y);
3447
+ point[2] = z;
2674
3448
  ctx.nodes.set(positionColumn, index, point);
2675
3449
  } else {
2676
3450
  report.error(
2677
3451
  "validation-error",
2678
3452
  JSON_ISSUE.BAD_VALUE,
2679
- `${element}: position must be an object with numeric x and y`,
3453
+ `${element}: position must be an object with numeric x and y (and z) that are finite and within the f32 range`,
2680
3454
  { element },
2681
3455
  );
2682
3456
  }
2683
3457
  }
2684
3458
  writeClasses(ctx, ctx.nodes, classesColumn, index, record.classes, element);
2685
- writeElementKeys(ctx.nodes, index, record);
3459
+ writeElementKeys(ctx.nodes, index, record, element);
2686
3460
  } catch (err) {
2687
3461
  report.recordError(err, { element: String(id) });
2688
3462
  }
2689
3463
  }
2690
- for (const { index, parent, element } of parents) {
3464
+ resolveCytoscapeParents(ctx, parents, parentColumn);
3465
+ throwIfAborted(ctx.options.signal);
3466
+
3467
+ importCytoscapeEdges(ctx, edges, edgeClassesColumn);
3468
+ ctx.setMeta({ dialect: "cytoscape", cytoscape: extra }, ctx.weightOriginPatch());
3469
+ }
3470
+
3471
+ /** One Cytoscape parent link: the parent's index and id, its document order and element name. */
3472
+ interface ParentLink {
3473
+ readonly parent: number;
3474
+ readonly id: NodeId;
3475
+ readonly order: number;
3476
+ readonly element: string;
3477
+ }
3478
+
3479
+ /**
3480
+ * Set the parent of each Cytoscape node once every node is known (a parent may come later): an
3481
+ * unknown parent is E_UNKNOWN_PARENT, a link that would make a node its own ancestor E_PARENT_CYCLE
3482
+ * (Cytoscape.js refuses those too); either link is dropped and the node kept.
3483
+ * @param ctx - the context
3484
+ * @param parents - the parent links in document order
3485
+ * @param parentColumn - the parent column
3486
+ */
3487
+ function resolveCytoscapeParents(
3488
+ ctx: ImportContext,
3489
+ parents: readonly { readonly index: number; readonly parent: NodeId; readonly element: string }[],
3490
+ parentColumn: ColumnHandle,
3491
+ ): void {
3492
+ const { report } = ctx;
3493
+ // the last link per child (a duplicate node's later parent wins), child -> its link
3494
+ const linkOf = new Map<number, ParentLink>();
3495
+ for (let order = 0; order < parents.length; order++) {
3496
+ const { index, parent, element } = parents[order];
2691
3497
  const parentIndex = ctx.sink.indexOf(parent);
2692
3498
  if (parentIndex === INVALID_INDEX) {
2693
3499
  report.error(
@@ -2698,12 +3504,57 @@ function importCytoscape(ctx: ImportContext, root: unknown): void {
2698
3504
  );
2699
3505
  continue;
2700
3506
  }
2701
- ctx.nodes.set(parentColumn, index, parentIndex);
3507
+ linkOf.set(index, { parent: parentIndex, id: parent, order, element });
2702
3508
  }
2703
- throwIfAborted(ctx.options.signal);
3509
+ // one walk per chain, each node visited once: on a cycle the link that came last in the document
3510
+ // (the one that closed it) is dropped
3511
+ const done = new Set<number>();
3512
+ for (const start of linkOf.keys()) {
3513
+ const path: number[] = [];
3514
+ const onPath = new Set<number>();
3515
+ let node: number | undefined = start;
3516
+ while (node !== undefined && !done.has(node) && !onPath.has(node)) {
3517
+ path.push(node);
3518
+ onPath.add(node);
3519
+ node = linkOf.get(node)?.parent;
3520
+ }
3521
+ if (node !== undefined && onPath.has(node)) {
3522
+ dropClosingLink(report, linkOf, path.slice(path.indexOf(node)));
3523
+ }
3524
+ for (const member of path) {
3525
+ done.add(member);
3526
+ }
3527
+ }
3528
+ for (const [index, { parent }] of linkOf) {
3529
+ ctx.nodes.set(parentColumn, index, parent);
3530
+ }
3531
+ }
2704
3532
 
2705
- importCytoscapeEdges(ctx, edges, edgeClassesColumn);
2706
- ctx.setMeta({ dialect: "cytoscape", cytoscape: extra }, ctx.weightOriginPatch());
3533
+ /**
3534
+ * Drop the link of a parent cycle that came last in the document (the one that closed it).
3535
+ * @param report - the report
3536
+ * @param linkOf - the links by child, the dropped one removed
3537
+ * @param cycle - the nodes of the cycle
3538
+ */
3539
+ function dropClosingLink(report: ImportReportBuilder, linkOf: Map<number, ParentLink>, cycle: readonly number[]): void {
3540
+ let closing: [number, ParentLink] | null = null;
3541
+ for (const member of cycle) {
3542
+ const link = linkOf.get(member);
3543
+ if (link !== undefined && (closing === null || link.order > closing[1].order)) {
3544
+ closing = [member, link];
3545
+ }
3546
+ }
3547
+ if (closing === null) {
3548
+ return;
3549
+ }
3550
+ const { id, element } = closing[1];
3551
+ report.error(
3552
+ "validation-error",
3553
+ JSON_ISSUE.PARENT_CYCLE,
3554
+ `${element}: parent ${JSON.stringify(id)} would make the node its own ancestor; the link is dropped`,
3555
+ { element },
3556
+ );
3557
+ linkOf.delete(closing[0]);
2707
3558
  }
2708
3559
 
2709
3560
  /**
@@ -2724,8 +3575,8 @@ function importCytoscapeEdges(ctx: ImportContext, edges: readonly unknown[], edg
2724
3575
  continue;
2725
3576
  }
2726
3577
  const { data } = record;
2727
- if (!hasKey(data, "source") || !hasKey(data, "target")) {
2728
- ctx.missingEndpoint(element, `data.${hasKey(data, "source") ? "target" : "source"}`);
3578
+ if (!present(data, "source") || !present(data, "target")) {
3579
+ ctx.missingEndpoint(element, `data.${present(data, "source") ? "target" : "source"}`);
2729
3580
  continue;
2730
3581
  }
2731
3582
  try {
@@ -2741,11 +3592,11 @@ function importCytoscapeEdges(ctx: ImportContext, edges: readonly unknown[], edg
2741
3592
  const { weightFrom } = ctx.options;
2742
3593
  for (const key of Object.keys(data)) {
2743
3594
  if (key !== "id" && key !== "source" && key !== "target" && key !== weightFrom) {
2744
- ctx.edges.write(edge, key, data[key], SUFFIX.data);
3595
+ ctx.edges.write(edge, key, data[key], SUFFIX.data, element);
2745
3596
  }
2746
3597
  }
2747
3598
  writeClasses(ctx, ctx.edges, edgeClassesColumn, edge, record.classes, element);
2748
- writeElementKeys(ctx.edges, edge, record);
3599
+ writeElementKeys(ctx.edges, edge, record, element);
2749
3600
  } catch (err) {
2750
3601
  ctx.skip(err, "edge", element);
2751
3602
  }
@@ -2765,10 +3616,21 @@ function cytoscapeSections(
2765
3616
  ): { readonly nodes: readonly unknown[]; readonly edges: readonly unknown[] } {
2766
3617
  const { report } = ctx;
2767
3618
  if (Array.isArray(elements)) {
2768
- return {
2769
- nodes: elements.filter((item) => isJsonObject(item) && isNodeElement(item)),
2770
- edges: elements.filter((item) => isJsonObject(item) && !isNodeElement(item)),
2771
- };
3619
+ const nodes: unknown[] = [];
3620
+ const edges: unknown[] = [];
3621
+ elements.forEach((item: unknown, i) => {
3622
+ if (isJsonObject(item) && item.group !== undefined && item.group !== "nodes" && item.group !== "edges") {
3623
+ report.error(
3624
+ "validation-error",
3625
+ JSON_ISSUE.BAD_VALUE,
3626
+ `elements[${i}]: group ${describe(item.group)} is neither "nodes" nor "edges"; classed by its endpoints`,
3627
+ { element: `elements[${i}]` },
3628
+ );
3629
+ }
3630
+ // a non-object item stays with the nodes, whose reader reports it as E_BAD_ELEMENT
3631
+ (isJsonObject(item) && !isNodeElement(item) ? edges : nodes).push(item);
3632
+ });
3633
+ return { nodes, edges };
2772
3634
  }
2773
3635
  if (isJsonObject(elements)) {
2774
3636
  return {
@@ -2777,7 +3639,7 @@ function cytoscapeSections(
2777
3639
  };
2778
3640
  }
2779
3641
  if (elements === undefined || elements === null) {
2780
- report.error("missing-value", JSON_ISSUE.MISSING_SECTION, "the document has no elements", {
3642
+ report.warning("missing-value", JSON_ISSUE.MISSING_SECTION, "the document has no elements", {
2781
3643
  element: "elements",
2782
3644
  });
2783
3645
  return { nodes: [], edges: [] };
@@ -2785,6 +3647,27 @@ function cytoscapeSections(
2785
3647
  return report.fail(JSON_ISSUE.SHAPE, `elements must be an object or an array, found ${describe(elements)}`);
2786
3648
  }
2787
3649
 
3650
+ /**
3651
+ * Whether a Cytoscape element's position has a numeric z.
3652
+ * @param element - the element
3653
+ * @returns true when position.z is a number
3654
+ */
3655
+ function hasPositionZ(element: unknown): boolean {
3656
+ return isJsonObject(element) && isJsonObject(element.position) && typeof element.position.z === "number";
3657
+ }
3658
+
3659
+ /** The largest finite f32, the bound of a position coordinate. */
3660
+ const F32_MAX = 3.4028234663852886e38;
3661
+
3662
+ /**
3663
+ * Whether a value is a coordinate the f32 position column holds: a finite number within the f32 range.
3664
+ * @param value - the value
3665
+ * @returns true for a storable coordinate
3666
+ */
3667
+ function isCoordinate(value: unknown): value is number {
3668
+ return typeof value === "number" && Number.isFinite(value) && Math.abs(value) <= F32_MAX;
3669
+ }
3670
+
2788
3671
  /**
2789
3672
  * Whether a flat Cytoscape element is a node: `group: "nodes"`, or no source / target in its data.
2790
3673
  * @param element - the element
@@ -2844,24 +3727,38 @@ function writeClasses(
2844
3727
  * @param writer - the table writer
2845
3728
  * @param row - the row
2846
3729
  * @param record - the element
3730
+ * @param element - the element name for issues
2847
3731
  */
2848
- function writeElementKeys(writer: AttributeWriter, row: number, record: JsonRecord): void {
3732
+ function writeElementKeys(writer: AttributeWriter, row: number, record: JsonRecord, element: string): void {
2849
3733
  for (const key of Object.keys(record)) {
2850
3734
  if (CYTOSCAPE_STRUCTURAL_KEYS.has(key)) {
2851
3735
  continue;
2852
3736
  }
2853
3737
  const name = CYTOSCAPE_ELEMENT_KEYS.has(key) ? key : `${key}${SUFFIX.element}`;
2854
- writer.write(row, name, record[key], SUFFIX.data);
3738
+ writer.write(row, name, record[key], SUFFIX.data, element);
2855
3739
  }
2856
3740
  }
2857
3741
 
2858
3742
  // ============================================================ the plugin
2859
3743
 
2860
3744
  /**
2861
- * The JSON importer plugin (design section 8.4).
3745
+ * The JSON importer plugin.
3746
+ * @category Built-in formats
2862
3747
  */
2863
3748
  export const jsonImporter: GraphImporter<JsonImportOptions> = Object.freeze({
2864
3749
  format: "json",
3750
+ options: Object.freeze([
3751
+ "dialect",
3752
+ "edgesKey",
3753
+ "edgesPath",
3754
+ "indexLinks",
3755
+ "nodeIdKey",
3756
+ "nodesPath",
3757
+ "oboIds",
3758
+ "sourceKey",
3759
+ "targetKey",
3760
+ "typedefs",
3761
+ ]),
2865
3762
  extensions: Object.freeze([".json"]),
2866
3763
  mimeTypes: Object.freeze(["application/json"]),
2867
3764
 
@@ -2873,7 +3770,8 @@ export const jsonImporter: GraphImporter<JsonImportOptions> = Object.freeze({
2873
3770
  * @returns the confidence
2874
3771
  */
2875
3772
  sniff(head: Uint8Array): number {
2876
- const text = new TextDecoder("utf-8").decode(head.subarray(0, SNIFF_BYTES));
3773
+ // headBytes transcodes a UTF-16 head with a BOM, which import() decodes too
3774
+ const text = new TextDecoder("utf-8").decode(headBytes(head).subarray(0, SNIFF_BYTES));
2877
3775
  const trimmed = (text.startsWith(BOM) ? text.slice(1) : text).trimStart();
2878
3776
  if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
2879
3777
  return 0;
@@ -2899,7 +3797,7 @@ export const jsonImporter: GraphImporter<JsonImportOptions> = Object.freeze({
2899
3797
  const resolved = resolveImportOptions(options, FORMAT_DEFAULTS);
2900
3798
  const json = resolveJsonOptions(options);
2901
3799
  const report = new ImportReportBuilder("json", resolved.errorLimit);
2902
- const text = await readJsonText(input, report, resolved);
3800
+ const text = await readText(input, report, { ...resolved, bomlessUtf16: true });
2903
3801
  const { root, dialect } = documentOf(parseDocument(text, report), json, report);
2904
3802
  readGraph(root, dialect, sink, report, resolved, json, options);
2905
3803
  return report.finish();
@@ -2920,7 +3818,7 @@ export const jsonImporter: GraphImporter<JsonImportOptions> = Object.freeze({
2920
3818
  const resolved = resolveImportOptions(options, FORMAT_DEFAULTS);
2921
3819
  const json = resolveJsonOptions(options);
2922
3820
  const report = new ImportReportBuilder("json", resolved.errorLimit);
2923
- const text = await readJsonText(input, report, resolved);
3821
+ const text = await readText(input, report, { ...resolved, bomlessUtf16: true });
2924
3822
  const { root, dialect } = documentOf(parseDocument(text, report), json, report);
2925
3823
  return listingsOf(root, dialect);
2926
3824
  },
@@ -2941,7 +3839,7 @@ export const jsonImporter: GraphImporter<JsonImportOptions> = Object.freeze({
2941
3839
  const resolved = resolveImportOptions(options, FORMAT_DEFAULTS);
2942
3840
  const json = resolveJsonOptions(options);
2943
3841
  const first = new ImportReportBuilder("json", resolved.errorLimit);
2944
- const text = await readJsonText(input, first, resolved);
3842
+ const text = await readText(input, first, { ...resolved, bomlessUtf16: true });
2945
3843
  const { root, dialect } = documentOf(parseDocument(text, first), json, first);
2946
3844
  const graphs = listingsOf(root, dialect).length;
2947
3845
  const reports: ImportReport[] = [];
@@ -2977,14 +3875,27 @@ function readGraph(
2977
3875
  // the obographs reader refuses missing endpoints itself, so addMissingNodes false holds on any sink
2978
3876
  reportSinkOptions(sink, options, report, dialect === "obographs");
2979
3877
  reportUnusedOptions(options, report, USED_OPTIONS);
3878
+ readDialect(ctx, root, dialect);
3879
+ ctx.reportDangling();
3880
+ ctx.nodes.reportNullOnly();
3881
+ ctx.edges.reportNullOnly();
3882
+ throwIfAborted(resolved.signal);
3883
+ }
3884
+
3885
+ /**
3886
+ * Read one graph with the reader of its dialect.
3887
+ * @param ctx - the import context
3888
+ * @param root - the parsed document
3889
+ * @param dialect - its dialect
3890
+ */
3891
+ function readDialect(ctx: ImportContext, root: unknown, dialect: JsonImportDialect): void {
2980
3892
  if (dialect === "cytoscape") {
2981
3893
  importCytoscape(ctx, root);
2982
- throwIfAborted(resolved.signal);
2983
3894
  return;
2984
3895
  }
2985
3896
  const doc = isJsonObject(root)
2986
3897
  ? root
2987
- : report.fail(JSON_ISSUE.SHAPE, `a ${dialect} document must be a JSON object, found ${describe(root)}`);
3898
+ : ctx.report.fail(JSON_ISSUE.SHAPE, `a ${dialect} document must be a JSON object, found ${describe(root)}`);
2988
3899
  switch (dialect) {
2989
3900
  case "node-link":
2990
3901
  case "d3":
@@ -3016,5 +3927,4 @@ function readGraph(
3016
3927
  });
3017
3928
  }
3018
3929
  }
3019
- throwIfAborted(resolved.signal);
3020
3930
  }