@graphty/graph-io 0.3.24 → 0.3.26

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-BbJI55dx.js +56 -0
  4. package/dist/chunks/choose-BbJI55dx.js.map +1 -0
  5. package/dist/chunks/constants-BCsFsMj4.js +231 -0
  6. package/dist/chunks/constants-BCsFsMj4.js.map +1 -0
  7. package/dist/chunks/{escape-B-tkk_Cl.js → escape-D0g6NNUy.js} +382 -92
  8. package/dist/chunks/escape-D0g6NNUy.js.map +1 -0
  9. package/dist/chunks/export-BTI4yQgy.js +4333 -0
  10. package/dist/chunks/export-BTI4yQgy.js.map +1 -0
  11. package/dist/chunks/exporter-BMqk5oxW.js +3465 -0
  12. package/dist/chunks/exporter-BMqk5oxW.js.map +1 -0
  13. package/dist/chunks/{importer-eUREhBGj.js → exporter-Cra2GYcN.js} +4969 -3201
  14. package/dist/chunks/exporter-Cra2GYcN.js.map +1 -0
  15. package/dist/chunks/{exporter-BGrsWamJ.js → exporter-Dxh231ZF.js} +38 -229
  16. package/dist/chunks/exporter-Dxh231ZF.js.map +1 -0
  17. package/dist/chunks/{exporter-DIeGJXAZ.js → exporter-tne475KK.js} +22 -20
  18. package/dist/chunks/exporter-tne475KK.js.map +1 -0
  19. package/dist/chunks/importer-90YWhJoF.js +2811 -0
  20. package/dist/chunks/importer-90YWhJoF.js.map +1 -0
  21. package/dist/chunks/{importer-D_e7e7LX.js → importer-Bcl1d4xH.js} +398 -145
  22. package/dist/chunks/importer-Bcl1d4xH.js.map +1 -0
  23. package/dist/chunks/{importer-BWY2FFc5.js → importer-BfIsp1f9.js} +255 -77
  24. package/dist/chunks/importer-BfIsp1f9.js.map +1 -0
  25. package/dist/chunks/{importer-zGLo8Dg_.js → importer-C_TEA1zJ.js} +484 -157
  26. package/dist/chunks/importer-C_TEA1zJ.js.map +1 -0
  27. package/dist/chunks/{importer-DET1tg6s.js → importer-DCjOvO7U.js} +1687 -310
  28. package/dist/chunks/importer-DCjOvO7U.js.map +1 -0
  29. package/dist/chunks/{importer-vi3bSdtw.js → importer-DfVu6j59.js} +509 -76
  30. package/dist/chunks/importer-DfVu6j59.js.map +1 -0
  31. package/dist/chunks/{importer-DIrbbnAf.js → importer-Dku_VAIY.js} +585 -226
  32. package/dist/chunks/importer-Dku_VAIY.js.map +1 -0
  33. package/dist/chunks/json-elements-3tnyt5uZ.js +1225 -0
  34. package/dist/chunks/json-elements-3tnyt5uZ.js.map +1 -0
  35. package/dist/chunks/ontology-export-B9Tcf37D.js +357 -0
  36. package/dist/chunks/ontology-export-B9Tcf37D.js.map +1 -0
  37. package/dist/chunks/{records-dbRkxwaq.js → records-CQXT__mY.js} +298 -114
  38. package/dist/chunks/records-CQXT__mY.js.map +1 -0
  39. package/dist/chunks/{text-Dr0Ifpag.js → text-DmcUs5_Q.js} +29 -11
  40. package/dist/chunks/text-DmcUs5_Q.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 +951 -261
  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 +342 -85
  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 +564 -157
  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 +19 -1
  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 +33 -17
  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 +868 -188
  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 +2 -0
  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 +400 -83
  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 +145 -32
  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 +680 -285
  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 +226 -79
  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 +98 -24
  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 +108 -26
  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 +238 -85
  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 +66 -34
  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 +183 -51
  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 +20 -21
  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 +7 -2
  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 +38 -4
  468. package/src/common/direction.ts +78 -51
  469. package/src/common/escape.ts +33 -17
  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 +1034 -198
  475. package/src/common/line-format.ts +249 -0
  476. package/src/common/lists.ts +6 -1
  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 +477 -88
  487. package/src/common/zip.ts +169 -7
  488. package/src/formats/csv/exporter.ts +201 -46
  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 +888 -300
  496. package/src/formats/cx/index.ts +4 -3
  497. package/src/formats/cx2/exporter.ts +61 -338
  498. package/src/formats/cx2/importer.ts +317 -86
  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 +139 -31
  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 +111 -26
  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 +320 -101
  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 +89 -41
  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 +213 -52
  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 +35 -27
  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 +11 -3
  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
@@ -2,7 +2,7 @@
2
2
  * The CSV / TSV importer (design sections 8.4 and 8.6; research note 07 section 2.6): a streaming
3
3
  * edge-list reader for the generic (`source,target[,weight,...]`), Gephi (`Source,Target,Type,Id,
4
4
  * Label,Weight,...`) and headerless (`u v [w]`) dialects, with an optional node table merged by id,
5
- * and, with `table: "adjacency"`, an adjacency table (`node,neighbour[:weight],...`).
5
+ * and, with `table: "adjacency"`, an adjacency table (`node,neighbor[:weight],...`).
6
6
  *
7
7
  * - The delimiter is sniffed from a preview unless given; LF, CRLF and lone-CR files all read.
8
8
  * - The first row is a header when it holds a known column name or when it is all text over a
@@ -21,7 +21,7 @@
21
21
  * all-text column of low cardinality becomes a dict (design section 5.4); an `id` column of the
22
22
  * edge table is the edge id (role id, unique); a `label` column is the label (role label).
23
23
  * - An adjacency table has no header (unless `header: true`, which skips the first row): each row
24
- * is a node followed by its neighbours, one edge per neighbour in row order. A neighbour cell
24
+ * is a node followed by its neighbors, one edge per neighbor in row order. A neighbor cell
25
25
  * `id:weight` carries the edge's weight when the text after its LAST colon is a number; a cell
26
26
  * ending in a bare colon (`a:1:`) is the id before it with no weight; any other cell is the id
27
27
  * as written (`http://x`). A row holding only its node adds an isolated node. An empty adjacency
@@ -54,6 +54,7 @@ import {
54
54
  ID_MERGED_CODE as SHARED_ID_MERGED_CODE,
55
55
  MISSING_ENDPOINT_CODE as SHARED_MISSING_ENDPOINT_CODE,
56
56
  MISSING_ID_CODE as SHARED_MISSING_ID_CODE,
57
+ OPTION_IGNORED_CODE,
57
58
  ROLE_TAKEN_CODE as SHARED_ROLE_TAKEN_CODE,
58
59
  } from "../../common/codes.js";
59
60
  import { DirectionResolver, type EdgeKind } from "../../common/direction.js";
@@ -65,6 +66,7 @@ import {
65
66
  type ResolvedImportOptions,
66
67
  resolveImportOptions,
67
68
  } from "../../common/options.js";
69
+ import { plural } from "../../common/plural.js";
68
70
  import { ImportReportBuilder } from "../../common/report.js";
69
71
  import { parseWeightText } from "../../common/weights.js";
70
72
  import { type CommonImportOptions, type GraphImporter, type ImportInput, type ImportReport } from "../../types.js";
@@ -82,70 +84,225 @@ import {
82
84
  TARGET_NAMES,
83
85
  TYPE_NAME,
84
86
  } from "./header.js";
85
- import { type CsvReaderOptions, CsvRecordReader, sniffDelimiter, sniffNewline } from "./records.js";
87
+ import {
88
+ checkRecordSyntax,
89
+ type CsvReaderOptions,
90
+ CsvRecordReader,
91
+ DELIMITER_CANDIDATES,
92
+ sniffDelimiter,
93
+ sniffNewline,
94
+ splitRecords,
95
+ stripLeadingComments,
96
+ } from "./records.js";
86
97
  import { InferredColumn } from "./values.js";
87
98
 
88
- /** The format-specific options of the CSV importer. */
89
- export interface CsvImportOptions {
90
- /** The field delimiter; sniffed from the first rows when omitted (`,`, tab, `;`, `|`, space). */
99
+ /**
100
+ * The format-specific options of the CSV importer.
101
+ * @category Built-in formats
102
+ */
103
+ export interface CsvImportOptions extends CommonImportOptions {
104
+ /**
105
+ * The field delimiter. The default is to detect it from the first rows: `,`, tab, `;`, `|` or
106
+ * space. It applies to the `nodes` table too.
107
+ * @defaultValue detected
108
+ */
91
109
  delimiter?: string | undefined;
92
- /** Whether the first row is a header; "auto" (default) decides from its content. */
110
+ /**
111
+ * The decimal separator of numbers. With ",", a cell such as `2,5` (digits, a comma, digits) is
112
+ * read as the number 2.5, for weights and attributes alike, and the delimiter is detected among
113
+ * the others (`;`, tab, `|`, space), so a spreadsheet saved in a locale that writes decimal
114
+ * commas reads as it is. A thousands separator is not read.
115
+ * @defaultValue "."
116
+ */
117
+ decimal?: "." | "," | undefined;
118
+ /**
119
+ * Whether the first row is a header; "auto" decides from its content (a header names columns, a
120
+ * data row holds ids and numbers). It applies to the `nodes` table too.
121
+ * @defaultValue "auto"
122
+ */
93
123
  header?: boolean | "auto" | undefined;
94
124
  /**
95
- * What the input is: an edge table, a node table, an adjacency table (`node,neighbour[:weight],...`
96
- * per row, no header by default), or "auto" (default): an edge table when source and target
97
- * columns resolve, a node table when only an id column does. An adjacency table is never
98
- * guessed: nothing in its rows tells it from an edge list.
125
+ * What the input is: "edges" (one edge per row), "nodes" (one node per row), "adjacency" (a
126
+ * node and its neighbors per row, `node,neighbor[:weight],...`, with no header unless you pass
127
+ * `header: true`), or "auto": an edge table when source and target columns can be found, a node
128
+ * table when only an id column can. An adjacency table is never detected, because its rows look
129
+ * like an edge list.
130
+ * @defaultValue "auto"
99
131
  */
100
132
  table?: "edges" | "nodes" | "adjacency" | "auto" | undefined;
101
- /** The source column, by name or 0-based position; resolved from the header by default. */
133
+ /**
134
+ * The source column, by name or 0-based position. The default is the column whose header is a
135
+ * recognized source name (`source`, `from`, `src` and others; the CSV format page lists them
136
+ * all); in a file without a header, the first column. A header with no recognized source and
137
+ * target names needs this option and `targetColumn`.
138
+ * @defaultValue from the header
139
+ */
102
140
  sourceColumn?: CsvColumnRef | undefined;
103
- /** The target column, by name or 0-based position; resolved from the header by default. */
141
+ /**
142
+ * The target column, by name or 0-based position. The default is the column whose header is a
143
+ * recognized target name (`target`, `to`, `dst` and others; the CSV format page lists them
144
+ * all); in a file without a header, the second column.
145
+ * @defaultValue from the header
146
+ */
104
147
  targetColumn?: CsvColumnRef | undefined;
105
148
  /**
106
- * The per-row direction column (Directed / Undirected / Mutual); by default the exact `Type`
107
- * column of a Gephi table (exact `Source` and `Target` headers); null reads no such column.
149
+ * The column that gives each edge's direction (Directed / Undirected / Mutual), by name or
150
+ * position. The default is the `Type` column of a Gephi table (one whose header has exactly
151
+ * `Source` and `Target`); null reads no such column.
152
+ * @defaultValue Gephi's Type
108
153
  */
109
154
  typeColumn?: CsvColumnRef | null | undefined;
110
- /** The id column of a node table, by name or position; resolved from the header by default. */
155
+ /**
156
+ * The id column of a node table, by name or position. The default is the column the header
157
+ * names `id` (or `node`, `name`, `key`).
158
+ * @defaultValue from the header
159
+ */
111
160
  idColumn?: CsvColumnRef | undefined;
112
- /** A node table read before the edges: its ids become nodes and its other columns node attributes. */
113
- nodes?: ImportInput | undefined;
114
161
  /**
115
- * A node table whose header has no id column gets its ids from the row numbers (0 for the first
116
- * data row), coerced by `ids`, instead of failing with E_CSV_NO_ID_COLUMN. The node table is the
117
- * `nodes` input when one is given (the edge table is then read as without this option), else
118
- * the input itself; its first row is a header even under `header: "auto"`. False by default.
162
+ * The label column of a node table, by name or position. The default is the column the header
163
+ * names `label` (in any case). With `nodeIdFrom: "label"`, the node ids come from this column,
164
+ * and the edge table must name its nodes by these labels too.
165
+ * @defaultValue from the header
166
+ */
167
+ labelColumn?: CsvColumnRef | undefined;
168
+ /**
169
+ * A node table to read before the edges: its ids become nodes and its other columns node
170
+ * attributes. Pass it as a string, bytes, a stream, or a `File` or `Blob` (for example, the second file a
171
+ * user picked).
172
+ */
173
+ nodes?: ImportInput | Blob | undefined;
174
+ /**
175
+ * Give the nodes of a node table without an id column the row number as id (0 for the first
176
+ * data row, turned into an id by `ids`), instead of failing with E_CSV_NO_ID_COLUMN. It applies
177
+ * to the `nodes` table when you pass one, else to the input. Under `header: "auto"` that
178
+ * table's first row is then read as a header; under `header: false` the option is ignored with
179
+ * a warning, because a table without a header takes its ids from its first column.
180
+ * @defaultValue false
119
181
  */
120
182
  rowNumberIds?: boolean | undefined;
121
183
  }
122
184
 
123
- /** Issue code: the input holds no header row at all. */
185
+ /**
186
+ * The input holds no header row at all.
187
+ * @category Issue and loss codes
188
+ */
124
189
  export const EMPTY_INPUT_CODE = SHARED_EMPTY_INPUT_CODE;
125
- /** Issue code: the header names no source / target (or, for a node table, no id) column. */
190
+ /**
191
+ * The header names no source / target (or, for a node table, no id) column.
192
+ * @category Issue and loss codes
193
+ */
126
194
  export const NO_ENDPOINT_COLUMNS_CODE = "E_CSV_NO_ENDPOINT_COLUMNS";
127
- /** Issue code: a node table without an id column. */
195
+ /**
196
+ * A node table without an id column.
197
+ * @category Issue and loss codes
198
+ */
128
199
  export const NO_ID_COLUMN_CODE = "E_CSV_NO_ID_COLUMN";
129
- /** Issue code: a row with a different number of fields than the header. */
200
+ /**
201
+ * A row with a different number of fields than the header.
202
+ * @category Issue and loss codes
203
+ */
130
204
  export const FIELD_COUNT_CODE = "E_CSV_FIELD_COUNT";
131
- /** Issue code: an edge row with a blank source or target cell. */
205
+ /**
206
+ * An edge row with a blank source or target cell.
207
+ * @category Issue and loss codes
208
+ */
132
209
  export const MISSING_ENDPOINT_CODE = SHARED_MISSING_ENDPOINT_CODE;
133
- /** Issue code: a node row with a blank id cell. */
210
+ /**
211
+ * A node row with a blank id cell.
212
+ * @category Issue and loss codes
213
+ */
134
214
  export const MISSING_ID_CODE = SHARED_MISSING_ID_CODE;
135
- /** Issue code: a Type cell that is not Directed, Undirected or Mutual. */
215
+ /**
216
+ * A Type cell that is not Directed, Undirected or Mutual.
217
+ * @category Issue and loss codes
218
+ */
136
219
  export const BAD_TYPE_CODE = "E_CSV_BAD_TYPE";
137
- /** Issue code: the table has a header and no data rows. */
220
+ /**
221
+ * The table has a header and no data rows.
222
+ * @category Issue and loss codes
223
+ */
138
224
  export const NO_DATA_ROWS_CODE = "W_CSV_NO_DATA_ROWS";
139
- /** Issue code: a node table row repeats an id; its attributes overwrite the earlier row's. */
225
+ /**
226
+ * A node table row repeats an id; its attributes overwrite the earlier row's.
227
+ * @category Issue and loss codes
228
+ */
140
229
  export const DUPLICATE_NODE_CODE = SHARED_DUPLICATE_NODE_CODE;
141
- /** Issue code: two distinct id cells became one id under `ids: "number"` (design section 4.1). */
230
+ /**
231
+ * Two different id cells became the same number because `ids` is "number", so their nodes were merged.
232
+ * @category Issue and loss codes
233
+ */
142
234
  export const ID_MERGED_CODE = SHARED_ID_MERGED_CODE;
143
- /** Issue code: an explicitly named weight column the file does not have. */
235
+ /**
236
+ * An explicitly named weight column the file does not have.
237
+ * @category Issue and loss codes
238
+ */
144
239
  export const COLUMN_MISSING_CODE = "W_CSV_COLUMN_MISSING";
145
- /** Issue code: a column whose role (id, label) is already held by another column of the sink. */
240
+ /**
241
+ * You read into a graph builder that already has an id or label attribute, so this file's one is kept without its
242
+ * role.
243
+ * @category Issue and loss codes
244
+ */
146
245
  export const ROLE_TAKEN_CODE = SHARED_ROLE_TAKEN_CODE;
147
- /** Issue code: a repeated edge id (the column is unique); the edge is skipped. */
246
+ /**
247
+ * A repeated edge id (the column is unique); the edge is skipped.
248
+ * @category Issue and loss codes
249
+ */
148
250
  export const DUPLICATE_EDGE_ID_CODE = SHARED_DUPLICATE_EDGE_ID_CODE;
251
+ /**
252
+ * The input starts like another format (XML or HTML, JSON, GML, DOT or Pajek), not CSV. The import stops.
253
+ * @category Issue and loss codes
254
+ */
255
+ export const OTHER_FORMAT_CODE = "E_CSV_OTHER_FORMAT";
256
+ /**
257
+ * A quote inside an unquoted field (RFC 4180 forbids it); the quote is kept as text.
258
+ * @category Issue and loss codes
259
+ */
260
+ export const STRAY_QUOTE_CODE = "W_CSV_STRAY_QUOTE";
261
+ /**
262
+ * A leading comment's direction disagrees with defaultDirected or with an earlier comment.
263
+ * @category Issue and loss codes
264
+ */
265
+ export const COMMENT_DIRECTION_CODE = "W_CSV_COMMENT_DIRECTION";
266
+ /**
267
+ * A column named like Gephi's Type holds direction words but is read as a plain attribute.
268
+ * @category Issue and loss codes
269
+ */
270
+ export const TYPE_COLUMN_IGNORED_CODE = "W_CSV_TYPE_COLUMN_IGNORED";
271
+ /**
272
+ * The header ends in a delimiter; rows without the empty last cell are read as complete.
273
+ * @category Issue and loss codes
274
+ */
275
+ export const TRAILING_HEADER_DELIMITER_CODE = "W_CSV_TRAILING_HEADER_DELIMITER";
276
+ /**
277
+ * Every row is one cell that another delimiter would split (a likely wrong delimiter).
278
+ * @category Issue and loss codes
279
+ */
280
+ export const SINGLE_COLUMN_CODE = "W_CSV_SINGLE_COLUMN";
281
+ /**
282
+ * Several header columns name the same role; the one not chosen is a plain attribute.
283
+ * @category Issue and loss codes
284
+ */
285
+ export const AMBIGUOUS_COLUMN_CODE = "W_CSV_AMBIGUOUS_COLUMN";
286
+ /**
287
+ * An unquoted id with leading or trailing whitespace (kept, RFC 4180), distinct from the bare id.
288
+ * @category Issue and loss codes
289
+ */
290
+ export const PADDED_ID_CODE = "W_CSV_PADDED_ID";
291
+ /**
292
+ * A leading `#` / `%` line skipped as a comment has the fields of a record.
293
+ * @category Issue and loss codes
294
+ */
295
+ export const COMMENT_LIKE_RECORD_CODE = "W_CSV_COMMENT_LIKE_RECORD";
296
+ /**
297
+ * Data rows end in one extra empty cell (a trailing delimiter); the cell is dropped.
298
+ * @category Issue and loss codes
299
+ */
300
+ export const TRAILING_DELIMITER_CODE = "W_CSV_TRAILING_DELIMITER";
301
+ /**
302
+ * A headerless three-column table's third column holds text, so it is an attribute, not the weight.
303
+ * @category Issue and loss codes
304
+ */
305
+ export const WEIGHT_AS_ATTRIBUTE_CODE = "W_CSV_WEIGHT_AS_ATTRIBUTE";
149
306
 
150
307
  const TABLE_MODES: ReadonlySet<string> = new Set(["edges", "nodes", "adjacency", "auto"]);
151
308
 
@@ -169,11 +326,11 @@ const USED_OPTIONS_WITH_NODES: ReadonlySet<keyof CommonImportOptions> = new Set<
169
326
  ...USED_OPTIONS,
170
327
  "nodeIdFrom",
171
328
  ]);
172
- const BAD_DELIMITERS: ReadonlySet<string> = new Set(['"', "\n", "\r"]);
173
329
 
174
330
  /** The CSV options with defaults applied. */
175
331
  interface ResolvedCsvOptions {
176
332
  readonly delimiter: string | null;
333
+ readonly decimalComma: boolean;
177
334
  readonly header: boolean | "auto";
178
335
  readonly table: "edges" | "nodes" | "adjacency" | "auto";
179
336
  readonly sourceColumn: CsvColumnRef | null;
@@ -181,8 +338,11 @@ interface ResolvedCsvOptions {
181
338
  /** The direction column reference; null for none; undefined for the Gephi rule. */
182
339
  readonly typeColumn: CsvColumnRef | null | undefined;
183
340
  readonly idColumn: CsvColumnRef | null;
341
+ readonly labelColumn: CsvColumnRef | null;
184
342
  readonly nodes: ImportInput | null;
185
343
  readonly rowNumberIds: boolean;
344
+ /** The caller's explicit defaultDirected; null when it was left to its default. */
345
+ readonly explicitDirected: boolean | null;
186
346
  }
187
347
 
188
348
  /** The columns of an edge table, by index. */
@@ -194,9 +354,13 @@ interface EdgePlan {
194
354
  readonly target: number;
195
355
  readonly weight: number;
196
356
  readonly type: number;
357
+ /** A column named like Type (any case) left as an attribute, or -1; its direction words are reported. */
358
+ readonly typeHint: number;
197
359
  readonly id: number;
198
360
  readonly label: number;
199
361
  readonly attributes: readonly number[];
362
+ /** Whether a row may omit the last cell (the header ends in a delimiter). */
363
+ readonly optionalLast: boolean;
200
364
  }
201
365
 
202
366
  /** The columns of a node table, by index. */
@@ -210,9 +374,11 @@ interface NodePlan {
210
374
  readonly rowNumber: boolean;
211
375
  readonly label: number;
212
376
  readonly attributes: readonly number[];
377
+ /** Whether a row may omit the last cell (the header ends in a delimiter). */
378
+ readonly optionalLast: boolean;
213
379
  }
214
380
 
215
- /** An adjacency table: no columns, a node and its neighbours per row. */
381
+ /** An adjacency table: no columns, a node and its neighbors per row. */
216
382
  interface AdjacencyPlan {
217
383
  readonly kind: "adjacency";
218
384
  readonly names: readonly string[];
@@ -234,31 +400,54 @@ interface ImportState {
234
400
  * (the `defaultDirected` option applies).
235
401
  */
236
402
  commentDirected: boolean | null;
403
+ /** The comment line that set commentDirected. */
404
+ commentText: string;
237
405
  }
238
406
 
239
407
  /** The comment characters of the SNAP (`#`) and KONECT (`%`) headers (research note 07 section 2.6). */
240
408
  const COMMENT_CHARS: readonly string[] = Object.freeze(["#", "%"]);
241
409
 
410
+ /** A number written with a decimal comma: digits, one comma, digits, an optional exponent. */
411
+ const DECIMAL_COMMA = /^\s*[+-]?\d+,\d+(?:[eE][+-]?\d+)?\s*$/;
412
+
242
413
  /**
243
- * The direction a SNAP or KONECT comment header declares (research note 07 section 2.6): SNAP
414
+ * The direction a SNAP or KONECT comment line declares (research note 07 section 2.6): SNAP
244
415
  * pages write `# Directed graph` / `# Undirected graph`, KONECT's first line is `% sym` (undirected),
245
- * `% asym` (directed) or `% bip` (bipartite, undirected).
246
- * @param comments - the leading comment lines
247
- * @returns true / false when a line declares the direction, null otherwise
416
+ * `% asym` (directed) or `% bip` (bipartite, undirected). Only whole words count, so
417
+ * `% symbols` or `# bipartite-ish` declare nothing.
418
+ * @param comment - a leading comment line
419
+ * @returns true / false when the line declares the direction, null otherwise
248
420
  */
249
- function commentDirection(comments: readonly string[]): boolean | null {
250
- for (const comment of comments) {
251
- const text = comment.slice(1).trim().toLowerCase();
252
- if (text.startsWith("directed graph") || text.startsWith("asym")) {
253
- return true;
254
- }
255
- if (text.startsWith("undirected graph") || text.startsWith("sym") || text.startsWith("bip")) {
256
- return false;
257
- }
421
+ function commentDirection(comment: string): boolean | null {
422
+ const text = comment.slice(1).trim().toLowerCase();
423
+ if (/^(?:directed graph|asym)\b/.test(text)) {
424
+ return true;
425
+ }
426
+ if (/^(?:undirected graph|sym|bip)\b/.test(text)) {
427
+ return false;
258
428
  }
259
429
  return null;
260
430
  }
261
431
 
432
+ /**
433
+ * The name of a direction, for messages.
434
+ * @param directed - the direction
435
+ * @returns "directed" or "undirected"
436
+ */
437
+ function directionName(directed: boolean): string {
438
+ return directed ? "directed" : "undirected";
439
+ }
440
+
441
+ /**
442
+ * Whether an unquoted id cell has leading or trailing whitespace (kept as written, RFC 4180).
443
+ * @param text - the cell text
444
+ * @param quoted - whether the cell was quoted
445
+ * @returns true when padded
446
+ */
447
+ function isPadded(text: string, quoted: boolean | undefined): boolean {
448
+ return quoted !== true && text.trim().length !== text.length;
449
+ }
450
+
262
451
  /**
263
452
  * Apply the defaults of the CSV options and check every value.
264
453
  * @param options - the caller's options
@@ -272,12 +461,22 @@ function resolveCsvOptions(options: (CsvImportOptions & CommonImportOptions) | u
272
461
  found: o.delimiter,
273
462
  });
274
463
  }
275
- if (o.delimiter !== undefined && BAD_DELIMITERS.has(o.delimiter)) {
276
- throw new GraphFormatError("E_UNSUPPORTED", "option delimiter: a quote or a line break cannot delimit", {
277
- option: "delimiter",
278
- found: o.delimiter,
464
+ if (o.decimal !== undefined && o.decimal !== "." && o.decimal !== ",") {
465
+ throw new GraphFormatError("E_UNSUPPORTED", 'option decimal: expected "." or ","', {
466
+ option: "decimal",
467
+ found: o.decimal,
468
+ });
469
+ }
470
+ if (o.decimal === "," && o.delimiter === ",") {
471
+ throw new GraphFormatError("E_UNSUPPORTED", 'options delimiter and decimal are both ","; pass delimiter: ";"', {
472
+ option: "decimal",
473
+ found: o.decimal,
279
474
  });
280
475
  }
476
+ if (o.delimiter !== undefined) {
477
+ // one character, not a line break, not the quote
478
+ checkRecordSyntax({ delimiter: o.delimiter, quote: '"' });
479
+ }
281
480
  if (o.header !== undefined && o.header !== "auto" && typeof o.header !== "boolean") {
282
481
  throw new GraphFormatError("E_UNSUPPORTED", 'option header: expected true, false or "auto"', {
283
482
  option: "header",
@@ -290,14 +489,14 @@ function resolveCsvOptions(options: (CsvImportOptions & CommonImportOptions) | u
290
489
  found: o.table,
291
490
  });
292
491
  }
293
- for (const name of ["sourceColumn", "targetColumn", "idColumn"] as const) {
492
+ for (const name of ["sourceColumn", "targetColumn", "idColumn", "labelColumn"] as const) {
294
493
  checkColumnRef(name, o[name]);
295
494
  }
296
495
  if (o.typeColumn !== null) {
297
496
  checkColumnRef("typeColumn", o.typeColumn);
298
497
  }
299
498
  if (o.table === "adjacency") {
300
- for (const name of ["sourceColumn", "targetColumn", "typeColumn", "idColumn"] as const) {
499
+ for (const name of ["sourceColumn", "targetColumn", "typeColumn", "idColumn", "labelColumn"] as const) {
301
500
  if (o[name] !== undefined) {
302
501
  throw new GraphFormatError("E_UNSUPPORTED", `option ${name}: an adjacency table has no columns`, {
303
502
  option: name,
@@ -320,14 +519,17 @@ function resolveCsvOptions(options: (CsvImportOptions & CommonImportOptions) | u
320
519
  }
321
520
  return {
322
521
  delimiter: o.delimiter ?? null,
522
+ decimalComma: o.decimal === ",",
323
523
  header: o.header ?? "auto",
324
524
  table: o.table ?? "auto",
325
525
  sourceColumn: o.sourceColumn ?? null,
326
526
  targetColumn: o.targetColumn ?? null,
327
527
  typeColumn: o.typeColumn,
328
528
  idColumn: o.idColumn ?? null,
329
- nodes: o.nodes ?? null,
529
+ labelColumn: o.labelColumn ?? null,
530
+ nodes: nodesInput(o.nodes),
330
531
  rowNumberIds: o.rowNumberIds ?? false,
532
+ explicitDirected: typeof options?.defaultDirected === "boolean" ? options.defaultDirected : null,
331
533
  };
332
534
  }
333
535
 
@@ -465,11 +667,12 @@ function parseKind(text: string): EdgeKind | null | undefined {
465
667
  }
466
668
 
467
669
  /**
468
- * Split an adjacency neighbour cell into its id and weight: the text after the LAST colon is the
670
+ * Split an adjacency neighbor cell into its id and weight: the text after the LAST colon is the
469
671
  * weight when it is a number, nothing when it is empty (`a:1:` is the id `a:1`, unweighted, the
470
672
  * form the exporter writes for an id containing a colon), and part of the id otherwise.
471
673
  * @param text - the cell text
472
674
  * @returns the id text and the weight (undefined when the cell has none)
675
+ * @category Plugin helpers
473
676
  */
474
677
  export function splitNeighbour(text: string): { readonly id: string; readonly weight: number | undefined } {
475
678
  const colon = text.lastIndexOf(":");
@@ -526,6 +729,8 @@ class TableReader {
526
729
  private dataRows = 0;
527
730
 
528
731
  private nodeOrdinal = 0;
732
+ /** Whether a field-count error has already suggested `table: "adjacency"`. */
733
+ private adjacencyHinted = false;
529
734
 
530
735
  /** The edge ids seen so far (the id column is unique; a repeat is skipped with an issue). */
531
736
  private readonly edgeIds = new Set<string>();
@@ -550,18 +755,28 @@ class TableReader {
550
755
  this.rowNumbers = state.csv.rowNumberIds && (kind === "nodes" || (kind === "auto" && state.csv.nodes === null));
551
756
  const readerOptions: CsvReaderOptions = {
552
757
  delimiter: state.csv.delimiter,
758
+ // a decimal comma is never the delimiter
759
+ ...(state.csv.decimalComma ? { candidates: DELIMITER_CANDIDATES.filter((d) => d !== ",") } : {}),
553
760
  comments: COMMENT_CHARS,
554
761
  // field counts say nothing about an adjacency table, whose rows vary in width
555
762
  skipQuoteErrors: kind === "adjacency",
556
763
  signal: state.common.signal,
557
764
  onProgress: progress ? state.common.onProgress : null,
558
765
  encoding: state.common.encoding,
766
+ // an adjacency table has no header: an empty one is the empty graph
767
+ allowEmpty: kind === "adjacency",
768
+ // explicit dialect options say the caller knows the input is a table
769
+ inspect: hasExplicitDialect(state.csv)
770
+ ? undefined
771
+ : (preview, line) => {
772
+ this.refuseOtherFormat(preview, line);
773
+ },
559
774
  };
560
775
  this.reader = new CsvRecordReader(input, state.report, readerOptions);
561
776
  }
562
777
 
563
778
  /**
564
- * Read every row; the reader is closed (and a stream cancelled) when the import aborts midway.
779
+ * Read every row; the reader is closed (and a stream canceled) when the import aborts midway.
565
780
  */
566
781
  async read(): Promise<void> {
567
782
  const iterator = this.reader[Symbol.asyncIterator]();
@@ -578,7 +793,11 @@ class TableReader {
578
793
  */
579
794
  private async readRows(iterator: AsyncGenerator<string[], void, undefined>): Promise<void> {
580
795
  const { report } = this.state;
581
- const first = await iterator.next();
796
+ let first = await iterator.next();
797
+ if (!first.done && (this.reader.sepDirective || this.isSepDirective(first.value))) {
798
+ // Excel's `sep=;` line: the reader took the delimiter from it; it is not a row
799
+ first = await iterator.next();
800
+ }
582
801
  if (first.done && this.kind === "adjacency") {
583
802
  // an adjacency table has no header: an empty one is the empty graph
584
803
  return;
@@ -603,7 +822,8 @@ class TableReader {
603
822
  const secondRow: string[] | null = second.done ? null : second.value;
604
823
  const secondLine = this.reader.line;
605
824
  const secondQuoted = this.reader.quoted.slice(0, secondRow?.length ?? 0);
606
- header = looksLikeHeader(firstRow, secondRow);
825
+ // a column option naming a cell of the first row makes that row the header
826
+ header = looksLikeHeader(firstRow, secondRow) || this.namesExplicitColumn(firstRow);
607
827
  if (!header) {
608
828
  pending.push({ row: firstRow, quoted: firstQuoted, line: firstLine });
609
829
  }
@@ -616,11 +836,39 @@ class TableReader {
616
836
  pending.push({ row: firstRow, quoted: firstQuoted, line: firstLine });
617
837
  }
618
838
  }
839
+ if (this.rowNumbers && !header) {
840
+ report.warning(
841
+ "unsupported",
842
+ OPTION_IGNORED_CODE,
843
+ "option rowNumberIds ignored: a headerless node table takes its ids from column 0",
844
+ { line: firstLine, element: "rowNumberIds" },
845
+ );
846
+ }
619
847
  const names = header ? uniqueNames(headerNames(firstRow), report, firstLine) : positionalNames(firstRow.length);
620
- if (this.kind !== "nodes" && this.state.commentDirected === null) {
621
- this.state.commentDirected = commentDirection(this.reader.leadingComments);
848
+ const last = firstRow.length - 1;
849
+ const optionalLast = header && last > 0 && isUnset(firstRow[last], firstQuoted[last]);
850
+ this.applyCommentDirection();
851
+ this.plan = this.resolvePlan(
852
+ names,
853
+ header,
854
+ firstLine,
855
+ pending.map((p) => p.row),
856
+ optionalLast,
857
+ );
858
+ if (optionalLast) {
859
+ report.warning(
860
+ "validation-error",
861
+ TRAILING_HEADER_DELIMITER_CODE,
862
+ `the header ends in a delimiter; rows without the empty last cell are read as complete`,
863
+ { line: firstLine, element: names[last] },
864
+ );
622
865
  }
623
- this.plan = this.resolvePlan(names, header, firstLine);
866
+ this.reportCommentLikeRecords(names.length);
867
+ this.reportSingleColumn(
868
+ names.length,
869
+ pending.map((p) => p.row),
870
+ firstLine,
871
+ );
624
872
  this.prepareColumns(firstLine);
625
873
  for (const { row, quoted, line } of pending) {
626
874
  this.processRow(row, quoted, line);
@@ -646,6 +894,151 @@ class TableReader {
646
894
  line: firstLine,
647
895
  });
648
896
  }
897
+ this.reportReaderFindings();
898
+ }
899
+
900
+ /**
901
+ * Whether a row is Excel's delimiter directive (`sep=;`) under a delimiter the caller gave (a
902
+ * sniffed one comes from the directive itself: `reader.sepDirective`).
903
+ * @param row - the first row
904
+ * @returns true for the directive
905
+ */
906
+ private isSepDirective(row: readonly string[]): boolean {
907
+ return (
908
+ this.state.csv.delimiter !== null &&
909
+ !this.reader.quoted[0] &&
910
+ /^sep=.$/i.test(row.join(this.state.csv.delimiter))
911
+ );
912
+ }
913
+
914
+ /**
915
+ * Fail when the input opens like another format (an HTML error page, a JSON, GML, DOT or Pajek
916
+ * file handed to the CSV importer), which any delimiter would otherwise read as rows of text.
917
+ * Runs on the raw preview before any record is parsed, so a quoting error in the other
918
+ * format's syntax cannot abort first.
919
+ * @param preview - the start of the input, leading comments removed
920
+ * @param line - the line it starts on
921
+ */
922
+ private refuseOtherFormat(preview: string, line: number): void {
923
+ if (OTHER_FORMATS.some((r) => r.test(preview))) {
924
+ this.state.report.fail(
925
+ OTHER_FORMAT_CODE,
926
+ `the input opens like another format (XML / HTML, JSON, GML, DOT or Pajek), not CSV: ${JSON.stringify(preview.trimStart().slice(0, 60))}`,
927
+ { line },
928
+ );
929
+ }
930
+ }
931
+
932
+ /**
933
+ * Whether a column option names a cell of the first row by text.
934
+ * @param row - the first row
935
+ * @returns true when sourceColumn, targetColumn, idColumn or typeColumn names one of its cells
936
+ */
937
+ private namesExplicitColumn(row: readonly string[]): boolean {
938
+ const { csv } = this.state;
939
+ const names = headerNames(row);
940
+ return [csv.sourceColumn, csv.targetColumn, csv.idColumn, csv.typeColumn, csv.labelColumn].some(
941
+ (ref) => typeof ref === "string" && findColumn(names, [ref]) >= 0,
942
+ );
943
+ }
944
+
945
+ /**
946
+ * Take the file-level direction from the leading SNAP / KONECT comments of this table: the first
947
+ * declaring line wins; a later line declaring the other direction, and a declaration that
948
+ * overrides the caller's explicit defaultDirected, are reported.
949
+ */
950
+ private applyCommentDirection(): void {
951
+ const { state } = this;
952
+ const { leadingComments, leadingCommentLines } = this.reader;
953
+ for (let i = 0; i < leadingComments.length; i++) {
954
+ const comment = leadingComments[i];
955
+ const directed = commentDirection(comment);
956
+ if (directed === null) {
957
+ continue;
958
+ }
959
+ const where = { line: leadingCommentLines[i], element: comment };
960
+ if (state.commentDirected === null) {
961
+ state.commentDirected = directed;
962
+ state.commentText = comment;
963
+ const explicit = state.csv.explicitDirected;
964
+ if (explicit !== null && explicit !== directed) {
965
+ state.report.warning(
966
+ "validation-error",
967
+ COMMENT_DIRECTION_CODE,
968
+ `${JSON.stringify(comment)} declares a ${directionName(directed)} graph, which overrides defaultDirected: ${explicit}`,
969
+ where,
970
+ );
971
+ }
972
+ } else if (state.commentDirected !== directed) {
973
+ state.report.warning(
974
+ "validation-error",
975
+ COMMENT_DIRECTION_CODE,
976
+ `${JSON.stringify(comment)} declares a ${directionName(directed)} graph but ${JSON.stringify(state.commentText)} declared a ${directionName(state.commentDirected)} one; the first applies`,
977
+ where,
978
+ );
979
+ }
980
+ }
981
+ }
982
+
983
+ /**
984
+ * Report a leading `#` / `%` line that has the fields of a record (a first id starting with `#`,
985
+ * swallowed as a comment): its text directly follows the comment character and splits into as
986
+ * many cells as the table has columns. SNAP and KONECT comment lines put a space after it.
987
+ * @param width - the number of columns
988
+ */
989
+ private reportCommentLikeRecords(width: number): void {
990
+ const { delimiter, leadingComments, leadingCommentLines } = this.reader;
991
+ if (width < 2 || delimiter === null) {
992
+ return;
993
+ }
994
+ for (let i = 0; i < leadingComments.length; i++) {
995
+ const comment = leadingComments[i];
996
+ if (comment.length > 1 && comment[1].trim().length > 0 && comment.split(delimiter).length === width) {
997
+ this.state.report.warnOnce(
998
+ "parse-error",
999
+ COMMENT_LIKE_RECORD_CODE,
1000
+ `${JSON.stringify(comment)} was skipped as a leading comment but has the fields of a record; quote an id that starts with # or %`,
1001
+ { line: leadingCommentLines[i], element: comment },
1002
+ );
1003
+ }
1004
+ }
1005
+ }
1006
+
1007
+ /**
1008
+ * Report a one-column table whose cells another delimiter would split consistently (a delimiter
1009
+ * option that does not match the file).
1010
+ * @param width - the number of columns
1011
+ * @param rows - the data rows read so far
1012
+ * @param line - the first line
1013
+ */
1014
+ private reportSingleColumn(width: number, rows: readonly (readonly string[])[], line: number): void {
1015
+ if (width !== 1 || rows.length === 0 || this.plan?.kind === "adjacency") {
1016
+ return;
1017
+ }
1018
+ const candidates = DELIMITER_CANDIDATES.filter((d) => d !== this.reader.delimiter);
1019
+ const other = sniffDelimiter(`${rows.map((r) => r[0]).join("\n")}\n`, "\n", candidates);
1020
+ if (other !== null) {
1021
+ this.state.report.warning(
1022
+ "parse-error",
1023
+ SINGLE_COLUMN_CODE,
1024
+ `every row read so far is one cell that ${JSON.stringify(other)} splits consistently; pass delimiter: ${JSON.stringify(other)} if that is the file's delimiter`,
1025
+ { line },
1026
+ );
1027
+ }
1028
+ }
1029
+
1030
+ /** Report what the record reader noticed while reading: a stray quote. */
1031
+ private reportReaderFindings(): void {
1032
+ const { report } = this.state;
1033
+ const { strayQuoteLine } = this.reader;
1034
+ if (strayQuoteLine > 0) {
1035
+ report.warnOnce(
1036
+ "parse-error",
1037
+ STRAY_QUOTE_CODE,
1038
+ `a quote inside an unquoted field is kept as text (RFC 4180 quotes a whole field)`,
1039
+ { line: strayQuoteLine },
1040
+ );
1041
+ }
649
1042
  }
650
1043
 
651
1044
  /**
@@ -653,31 +1046,26 @@ class TableReader {
653
1046
  * @param names - the column names
654
1047
  * @param header - whether the file has a header row
655
1048
  * @param line - the header line
1049
+ * @param sample - the data rows read so far
1050
+ * @param optionalLast - whether a row may omit the last cell
656
1051
  * @returns the plan; the import aborts when no endpoints (or id) resolve
657
1052
  */
658
- private resolvePlan(names: readonly string[], header: boolean, line: number): EdgePlan | NodePlan | AdjacencyPlan {
659
- const { csv, report } = this.state;
1053
+ private resolvePlan(
1054
+ names: readonly string[],
1055
+ header: boolean,
1056
+ line: number,
1057
+ sample: readonly (readonly string[])[],
1058
+ optionalLast: boolean,
1059
+ ): EdgePlan | NodePlan | AdjacencyPlan {
1060
+ const { csv } = this.state;
660
1061
  if (this.kind === "adjacency") {
661
1062
  return { kind: "adjacency", names: [] };
662
1063
  }
663
- const width = names.length;
664
1064
  let source = -1;
665
1065
  let target = -1;
666
1066
  if (this.kind !== "nodes") {
667
- if (csv.sourceColumn !== null) {
668
- source = resolveColumnRef(names, csv.sourceColumn, "sourceColumn");
669
- } else if (header) {
670
- source = findColumn(names, SOURCE_NAMES);
671
- } else {
672
- source = width >= 2 ? 0 : -1;
673
- }
674
- if (csv.targetColumn !== null) {
675
- target = resolveColumnRef(names, csv.targetColumn, "targetColumn");
676
- } else if (header) {
677
- target = findColumn(names, TARGET_NAMES);
678
- } else {
679
- target = width >= 2 ? 1 : -1;
680
- }
1067
+ source = endpointColumn(names, header, csv.sourceColumn, "sourceColumn", SOURCE_NAMES, 0);
1068
+ target = endpointColumn(names, header, csv.targetColumn, "targetColumn", TARGET_NAMES, 1);
681
1069
  if (source >= 0 && target >= 0 && source === target) {
682
1070
  throw new GraphFormatError("E_UNSUPPORTED", "sourceColumn and targetColumn name the same column", {
683
1071
  option: "targetColumn",
@@ -686,12 +1074,43 @@ class TableReader {
686
1074
  }
687
1075
  }
688
1076
  if (source >= 0 && target >= 0) {
689
- return this.edgePlan(names, header, source, target);
1077
+ return this.edgePlan(names, header, source, target, line, sample, optionalLast);
690
1078
  }
1079
+ this.refuseWithoutEndpoints(names, header, source, target, line);
1080
+ return this.nodePlan(names, header, line, optionalLast);
1081
+ }
1082
+
1083
+ /**
1084
+ * Refuse a table without both endpoint columns, unless it can be a node table: an edge table
1085
+ * was asked for, a single endpoint column was found, or nothing names an id.
1086
+ * @param names - the column names
1087
+ * @param header - whether the file has a header row
1088
+ * @param source - the source column, or -1
1089
+ * @param target - the target column, or -1
1090
+ * @param line - the header line
1091
+ */
1092
+ private refuseWithoutEndpoints(
1093
+ names: readonly string[],
1094
+ header: boolean,
1095
+ source: number,
1096
+ target: number,
1097
+ line: number,
1098
+ ): void {
1099
+ const { csv, report } = this.state;
691
1100
  const shown = names.map((n) => JSON.stringify(n)).join(", ");
1101
+ if (source >= 0 || target >= 0) {
1102
+ // one endpoint column is a broken edge table, never a node table
1103
+ const [has, missing] = source >= 0 ? ["source", "target"] : ["target", "source"];
1104
+ report.fail(
1105
+ NO_ENDPOINT_COLUMNS_CODE,
1106
+ `the header has a ${has} column (${JSON.stringify(names[Math.max(source, target)])}) but no ${missing} column (${shown})`,
1107
+ { line },
1108
+ { columns: [...names] },
1109
+ );
1110
+ }
692
1111
  if (
693
1112
  this.kind === "edges" ||
694
- (this.kind === "auto" && (csv.sourceColumn !== null || csv.targetColumn !== null))
1113
+ (this.kind === "auto" && (csv.sourceColumn !== null || csv.targetColumn !== null || csv.nodes !== null))
695
1114
  ) {
696
1115
  report.fail(
697
1116
  NO_ENDPOINT_COLUMNS_CODE,
@@ -700,7 +1119,7 @@ class TableReader {
700
1119
  { columns: [...names] },
701
1120
  );
702
1121
  }
703
- const idResolves = header ? findColumn(names, ID_NAMES) >= 0 : width >= 1;
1122
+ const idResolves = header ? findColumn(names, ID_NAMES) >= 0 : names.length >= 1;
704
1123
  if (this.kind === "auto" && csv.idColumn === null && !idResolves && !this.rowNumbers) {
705
1124
  report.fail(
706
1125
  NO_ENDPOINT_COLUMNS_CODE,
@@ -709,7 +1128,6 @@ class TableReader {
709
1128
  { columns: [...names] },
710
1129
  );
711
1130
  }
712
- return this.nodePlan(names, header, line);
713
1131
  }
714
1132
 
715
1133
  /**
@@ -718,50 +1136,35 @@ class TableReader {
718
1136
  * @param header - whether the file has a header row
719
1137
  * @param source - the source column
720
1138
  * @param target - the target column
1139
+ * @param line - the header (or first) line
1140
+ * @param sample - the data rows read so far
1141
+ * @param optionalLast - whether a row may omit the last cell
721
1142
  * @returns the plan
722
1143
  */
723
- private edgePlan(names: readonly string[], header: boolean, source: number, target: number): EdgePlan {
724
- const { csv, common, report } = this.state;
1144
+ private edgePlan(
1145
+ names: readonly string[],
1146
+ header: boolean,
1147
+ source: number,
1148
+ target: number,
1149
+ line: number,
1150
+ sample: readonly (readonly string[])[],
1151
+ optionalLast: boolean,
1152
+ ): EdgePlan {
1153
+ const { csv } = this.state;
725
1154
  const claimed = new Set<number>([source, target]);
726
- let weight = -1;
727
- if (common.weightFrom !== null) {
728
- if (header) {
729
- weight = findFree(names, [common.weightFrom], claimed);
730
- if (weight < 0 && this.state.weightFromExplicit) {
731
- report.warning(
732
- "missing-value",
733
- COLUMN_MISSING_CODE,
734
- `weight column ${JSON.stringify(common.weightFrom)} not found; edges are unweighted`,
735
- { line: this.reader.line, element: common.weightFrom },
736
- );
737
- }
738
- } else if (names.length >= 3 && !claimed.has(2)) {
739
- weight = 2;
740
- }
741
- }
1155
+ const weight = this.weightColumn(names, header, claimed, line, sample);
742
1156
  if (weight >= 0) {
743
1157
  claimed.add(weight);
744
1158
  }
745
- let type = -1;
746
- if (csv.typeColumn === undefined) {
747
- if (header && names[source] === "Source" && names[target] === "Target") {
748
- type = findFree(names, [TYPE_NAME], claimed);
749
- if (type >= 0 && names[type] !== TYPE_NAME) {
750
- type = -1;
751
- }
752
- }
753
- } else if (csv.typeColumn !== null) {
754
- type = resolveColumnRef(names, csv.typeColumn, "typeColumn");
755
- if (claimed.has(type)) {
756
- throw new GraphFormatError("E_UNSUPPORTED", "typeColumn names an endpoint or weight column", {
757
- option: "typeColumn",
758
- found: names[type],
759
- });
760
- }
1159
+ if (header) {
1160
+ this.reportEndpointRivals(names, source, target, weight, line);
761
1161
  }
1162
+ const type = this.typeColumn(names, header, source, target, claimed);
762
1163
  if (type >= 0) {
763
1164
  claimed.add(type);
764
1165
  }
1166
+ const typeHint =
1167
+ header && type < 0 && csv.typeColumn === undefined ? findFree(names, [TYPE_NAME], claimed) : -1;
765
1168
  const id = header ? findFree(names, EDGE_ID_NAMES, claimed) : -1;
766
1169
  if (id >= 0) {
767
1170
  claimed.add(id);
@@ -776,7 +1179,174 @@ class TableReader {
776
1179
  attributes.push(i);
777
1180
  }
778
1181
  }
779
- return { kind: "edges", names, width: names.length, source, target, weight, type, id, label, attributes };
1182
+ return {
1183
+ kind: "edges",
1184
+ names,
1185
+ width: names.length,
1186
+ source,
1187
+ target,
1188
+ weight,
1189
+ type,
1190
+ typeHint,
1191
+ id,
1192
+ label,
1193
+ attributes,
1194
+ optionalLast,
1195
+ };
1196
+ }
1197
+
1198
+ /**
1199
+ * The weight column of an edge table: the weightFrom column of a header (a warning when it was
1200
+ * asked for and is missing), else the third column of a headerless edge list unless it holds text.
1201
+ * @param names - the column names
1202
+ * @param header - whether the file has a header row
1203
+ * @param claimed - the endpoint columns
1204
+ * @param line - the header (or first) line
1205
+ * @param sample - the data rows read so far
1206
+ * @returns the column index, or -1
1207
+ */
1208
+ private weightColumn(
1209
+ names: readonly string[],
1210
+ header: boolean,
1211
+ claimed: ReadonlySet<number>,
1212
+ line: number,
1213
+ sample: readonly (readonly string[])[],
1214
+ ): number {
1215
+ const { common, report } = this.state;
1216
+ if (common.weightFrom === null) {
1217
+ return -1;
1218
+ }
1219
+ if (header) {
1220
+ const weight = findFree(names, [common.weightFrom], claimed);
1221
+ if (weight < 0 && this.state.weightFromExplicit) {
1222
+ report.warning(
1223
+ "missing-value",
1224
+ COLUMN_MISSING_CODE,
1225
+ `weight column ${JSON.stringify(common.weightFrom)} not found; edges are unweighted`,
1226
+ { line, element: common.weightFrom },
1227
+ );
1228
+ }
1229
+ return weight;
1230
+ }
1231
+ if (names.length < 3 || claimed.has(2)) {
1232
+ return -1;
1233
+ }
1234
+ if (isTextTriple(names.length, sample)) {
1235
+ report.warnOnce(
1236
+ "validation-error",
1237
+ WEIGHT_AS_ATTRIBUTE_CODE,
1238
+ `the third column holds text in the first rows, so it is read as the attribute ${names[2]}, not the weight`,
1239
+ { line, element: names[2] },
1240
+ );
1241
+ return -1;
1242
+ }
1243
+ return 2;
1244
+ }
1245
+
1246
+ /**
1247
+ * Warn about header columns that name the source, target or weight beside the chosen one.
1248
+ * @param names - the header names
1249
+ * @param source - the source column
1250
+ * @param target - the target column
1251
+ * @param weight - the weight column, or -1
1252
+ * @param line - the header line
1253
+ */
1254
+ private reportEndpointRivals(
1255
+ names: readonly string[],
1256
+ source: number,
1257
+ target: number,
1258
+ weight: number,
1259
+ line: number,
1260
+ ): void {
1261
+ const { csv, common } = this.state;
1262
+ if (csv.sourceColumn === null) {
1263
+ this.reportRivals(names, SOURCE_NAMES, source, "source", line);
1264
+ }
1265
+ if (csv.targetColumn === null) {
1266
+ this.reportRivals(names, TARGET_NAMES, target, "target", line);
1267
+ }
1268
+ if (weight >= 0 && common.weightFrom !== null) {
1269
+ this.reportRivals(names, [common.weightFrom], weight, "weight", line);
1270
+ }
1271
+ }
1272
+
1273
+ /**
1274
+ * The edge type column: the typeColumn option, else Gephi's `Type` beside `Source` and `Target`.
1275
+ * @param names - the column names
1276
+ * @param header - whether the file has a header row
1277
+ * @param source - the source column
1278
+ * @param target - the target column
1279
+ * @param claimed - the columns other roles took
1280
+ * @returns the column index, or -1
1281
+ */
1282
+ private typeColumn(
1283
+ names: readonly string[],
1284
+ header: boolean,
1285
+ source: number,
1286
+ target: number,
1287
+ claimed: ReadonlySet<number>,
1288
+ ): number {
1289
+ const { typeColumn } = this.state.csv;
1290
+ if (typeColumn === null) {
1291
+ return -1;
1292
+ }
1293
+ if (typeColumn === undefined) {
1294
+ const gephi = header && names[source] === "Source" && names[target] === "Target";
1295
+ const type = gephi ? findFree(names, [TYPE_NAME], claimed) : -1;
1296
+ return type >= 0 && names[type] === TYPE_NAME ? type : -1;
1297
+ }
1298
+ const type = resolveColumnRef(names, typeColumn, "typeColumn");
1299
+ if (claimed.has(type)) {
1300
+ throw new GraphFormatError("E_UNSUPPORTED", "typeColumn names an endpoint or weight column", {
1301
+ option: "typeColumn",
1302
+ found: names[type],
1303
+ });
1304
+ }
1305
+ return type;
1306
+ }
1307
+
1308
+ /**
1309
+ * Warn about header columns that name a role another column was chosen for (`Source,source`,
1310
+ * `from,to,source`, `Weight,weight`): the one not chosen is read as a plain attribute.
1311
+ * @param names - the header names
1312
+ * @param candidates - the names of the role
1313
+ * @param chosen - the column chosen
1314
+ * @param role - the role, for the message
1315
+ * @param line - the header line
1316
+ */
1317
+ private reportRivals(
1318
+ names: readonly string[],
1319
+ candidates: readonly string[],
1320
+ chosen: number,
1321
+ role: string,
1322
+ line: number,
1323
+ ): void {
1324
+ const lower = new Set(candidates.map((c) => c.toLowerCase()));
1325
+ names.forEach((name, i) => {
1326
+ if (i !== chosen && lower.has(name.toLowerCase())) {
1327
+ this.state.report.warning(
1328
+ "validation-error",
1329
+ AMBIGUOUS_COLUMN_CODE,
1330
+ `columns ${JSON.stringify(names[chosen])} and ${JSON.stringify(name)} both name the ${role}; ${JSON.stringify(names[chosen])} is used and ${JSON.stringify(name)} is a plain attribute`,
1331
+ { line, element: name },
1332
+ );
1333
+ }
1334
+ });
1335
+ }
1336
+
1337
+ /**
1338
+ * The label column of a node table: the labelColumn option, else a header column named `label`.
1339
+ * @param names - the column names
1340
+ * @param header - whether the file has a header row
1341
+ * @param claimed - columns another role took
1342
+ * @returns the column index, or -1
1343
+ */
1344
+ private labelOf(names: readonly string[], header: boolean, claimed: ReadonlySet<number>): number {
1345
+ const { labelColumn } = this.state.csv;
1346
+ if (labelColumn !== null) {
1347
+ return resolveColumnRef(names, labelColumn, "labelColumn");
1348
+ }
1349
+ return header ? findFree(names, LABEL_NAMES, claimed) : -1;
780
1350
  }
781
1351
 
782
1352
  /**
@@ -784,49 +1354,17 @@ class TableReader {
784
1354
  * @param names - the column names
785
1355
  * @param header - whether the file has a header row
786
1356
  * @param line - the header line
1357
+ * @param optionalLast - whether a row may omit the last cell
787
1358
  * @returns the plan; the import aborts when no id column resolves
788
1359
  */
789
- private nodePlan(names: readonly string[], header: boolean, line: number): NodePlan {
790
- const { csv, common, report } = this.state;
1360
+ private nodePlan(names: readonly string[], header: boolean, line: number, optionalLast: boolean): NodePlan {
1361
+ const { common } = this.state;
791
1362
  const claimed = new Set<number>();
792
- let idColumn = -1;
793
- if (csv.idColumn !== null) {
794
- idColumn = resolveColumnRef(names, csv.idColumn, "idColumn");
795
- } else if (header) {
796
- idColumn = findColumn(names, ID_NAMES);
797
- } else if (names.length >= 1) {
798
- idColumn = 0;
799
- }
800
- const label = header ? findFree(names, LABEL_NAMES, new Set(idColumn >= 0 ? [idColumn] : [])) : -1;
801
- let id: number;
802
- switch (common.nodeIdFrom) {
803
- case "label":
804
- if (label < 0) {
805
- report.fail(NO_ID_COLUMN_CODE, 'nodeIdFrom is "label" but the node table has no label column', {
806
- line,
807
- });
808
- }
809
- id = label;
810
- break;
811
- case "index":
812
- id = -1;
813
- break;
814
- default:
815
- if (idColumn < 0 && this.rowNumbers) {
816
- id = -1;
817
- break;
818
- }
819
- if (idColumn < 0) {
820
- report.fail(
821
- NO_ID_COLUMN_CODE,
822
- `no id column in the node table header (${names.map((n) => JSON.stringify(n)).join(", ")})`,
823
- { line },
824
- { columns: [...names] },
825
- );
826
- }
827
- id = idColumn;
828
- claimed.add(idColumn);
829
- break;
1363
+ const idColumn = this.idColumnOf(names, header);
1364
+ const label = this.labelOf(names, header, new Set(idColumn >= 0 ? [idColumn] : []));
1365
+ const id = this.nodeIdColumn(names, idColumn, label, line);
1366
+ if (id >= 0) {
1367
+ claimed.add(id);
830
1368
  }
831
1369
  if (label >= 0) {
832
1370
  claimed.add(label);
@@ -838,7 +1376,62 @@ class TableReader {
838
1376
  }
839
1377
  }
840
1378
  const rowNumber = id < 0 && common.nodeIdFrom !== "index";
841
- return { kind: "nodes", names, width: names.length, id, rowNumber, label, attributes };
1379
+ return { kind: "nodes", names, width: names.length, id, rowNumber, label, attributes, optionalLast };
1380
+ }
1381
+
1382
+ /**
1383
+ * The id column of a node table: the idColumn option, else a header column with an id name,
1384
+ * else the first column of a headerless table.
1385
+ * @param names - the column names
1386
+ * @param header - whether the file has a header row
1387
+ * @returns the column index, or -1
1388
+ */
1389
+ private idColumnOf(names: readonly string[], header: boolean): number {
1390
+ const { idColumn } = this.state.csv;
1391
+ if (idColumn !== null) {
1392
+ return resolveColumnRef(names, idColumn, "idColumn");
1393
+ }
1394
+ if (header) {
1395
+ return findColumn(names, ID_NAMES);
1396
+ }
1397
+ return names.length >= 1 ? 0 : -1;
1398
+ }
1399
+
1400
+ /**
1401
+ * The column node ids come from, as nodeIdFrom says: the label, none (row numbers), or the id
1402
+ * column. The import aborts when that column is missing.
1403
+ * @param names - the column names
1404
+ * @param idColumn - the id column, or -1
1405
+ * @param label - the label column, or -1
1406
+ * @param line - the header line
1407
+ * @returns the column index, or -1 for row numbers
1408
+ */
1409
+ private nodeIdColumn(names: readonly string[], idColumn: number, label: number, line: number): number {
1410
+ const { common, report } = this.state;
1411
+ const shown = names.map((n) => JSON.stringify(n)).join(", ");
1412
+ if (common.nodeIdFrom === "index") {
1413
+ return -1;
1414
+ }
1415
+ if (common.nodeIdFrom === "label") {
1416
+ if (label < 0) {
1417
+ report.fail(
1418
+ NO_ID_COLUMN_CODE,
1419
+ `nodeIdFrom is "label", but the node table has no column named "label" (${shown}); pass labelColumn to name the column that holds the labels`,
1420
+ { line },
1421
+ { columns: [...names] },
1422
+ );
1423
+ }
1424
+ return label;
1425
+ }
1426
+ if (idColumn < 0 && !this.rowNumbers) {
1427
+ report.fail(
1428
+ NO_ID_COLUMN_CODE,
1429
+ `no id column in the node table header (${shown})`,
1430
+ { line },
1431
+ { columns: [...names] },
1432
+ );
1433
+ }
1434
+ return idColumn;
842
1435
  }
843
1436
 
844
1437
  /**
@@ -899,6 +1492,13 @@ class TableReader {
899
1492
  private processRow(row: string[], quoted: readonly boolean[], line: number): void {
900
1493
  const plan = this.requirePlan();
901
1494
  this.dataRows++;
1495
+ if (this.state.csv.decimalComma) {
1496
+ for (let i = 0; i < row.length; i++) {
1497
+ if (DECIMAL_COMMA.test(row[i])) {
1498
+ row[i] = row[i].replace(",", ".");
1499
+ }
1500
+ }
1501
+ }
902
1502
  if (plan.kind === "edges") {
903
1503
  this.processEdgeRow(plan, row, quoted, line);
904
1504
  } else if (plan.kind === "nodes") {
@@ -909,7 +1509,7 @@ class TableReader {
909
1509
  }
910
1510
 
911
1511
  /**
912
- * Push one adjacency row: the node, then one edge per set neighbour cell, in row order.
1512
+ * Push one adjacency row: the node, then one edge per set neighbor cell, in row order.
913
1513
  * @param row - the cells
914
1514
  * @param quoted - whether each cell was quoted
915
1515
  * @param line - the row's line
@@ -920,16 +1520,16 @@ class TableReader {
920
1520
  const { where } = this;
921
1521
  where.line = line;
922
1522
  where.element = null;
923
- let neighbours = 0;
1523
+ let neighbors = 0;
924
1524
  for (let k = 1; k < row.length; k++) {
925
1525
  if (!isUnset(row[k], quoted[k])) {
926
- neighbours++;
1526
+ neighbors++;
927
1527
  }
928
1528
  }
929
1529
  if (isUnset(row[0], quoted[0])) {
930
- report.error("missing-value", MISSING_ID_CODE, `line ${line}: blank node cell`, { line });
1530
+ report.error("missing-value", MISSING_ID_CODE, `blank node cell`, { line });
931
1531
  counts.skippedNodes++;
932
- counts.skippedEdges += neighbours;
1532
+ counts.skippedEdges += neighbors;
933
1533
  return;
934
1534
  }
935
1535
  const kind: EdgeKind = (this.state.commentDirected ?? common.defaultDirected) ? "directed" : "undirected";
@@ -948,7 +1548,7 @@ class TableReader {
948
1548
  } catch (err) {
949
1549
  report.recordError(err, where);
950
1550
  counts.skippedNodes++;
951
- counts.skippedEdges += neighbours;
1551
+ counts.skippedEdges += neighbors;
952
1552
  return;
953
1553
  }
954
1554
  for (let k = 1; k < row.length; k++) {
@@ -975,19 +1575,15 @@ class TableReader {
975
1575
  * Push one edge row: endpoints, weight, direction, then the attribute cells.
976
1576
  * @param plan - the edge plan
977
1577
  * @param row - the cells
978
- * @param quoted - whether each cell was quoted
1578
+ * @param cellQuoted - whether each cell was quoted
979
1579
  * @param line - the row's line
980
1580
  */
981
- private processEdgeRow(plan: EdgePlan, row: string[], quoted: readonly boolean[], line: number): void {
982
- const { report, sink, resolver, common } = this.state;
1581
+ private processEdgeRow(plan: EdgePlan, row: string[], cellQuoted: readonly boolean[], line: number): void {
1582
+ const { report } = this.state;
983
1583
  const { counts } = report;
1584
+ const quoted = completeRow(plan, row, cellQuoted, report, line);
984
1585
  if (row.length !== plan.width) {
985
- report.error(
986
- "validation-error",
987
- FIELD_COUNT_CODE,
988
- `line ${line}: ${row.length} field(s), the header has ${plan.width}`,
989
- { line },
990
- );
1586
+ this.reportRowWidth(plan, row, line);
991
1587
  counts.skippedEdges++;
992
1588
  return;
993
1589
  }
@@ -995,64 +1591,36 @@ class TableReader {
995
1591
  const targetText = row[plan.target];
996
1592
  const sourceMissing = isUnset(sourceText, quoted[plan.source]);
997
1593
  if (sourceMissing || isUnset(targetText, quoted[plan.target])) {
998
- report.error(
999
- "missing-value",
1000
- MISSING_ENDPOINT_CODE,
1001
- `line ${line}: blank ${sourceMissing ? "source" : "target"} cell`,
1002
- { line },
1003
- );
1594
+ report.error("missing-value", MISSING_ENDPOINT_CODE, `blank ${sourceMissing ? "source" : "target"} cell`, {
1595
+ line,
1596
+ });
1004
1597
  counts.skippedEdges++;
1005
1598
  return;
1006
1599
  }
1007
- let kind: EdgeKind = (this.state.commentDirected ?? common.defaultDirected) ? "directed" : "undirected";
1008
- if (plan.type >= 0) {
1009
- const parsed = parseKind(row[plan.type]);
1010
- if (parsed === null) {
1011
- report.error(
1012
- "validation-error",
1013
- BAD_TYPE_CODE,
1014
- `line ${line}: Type ${JSON.stringify(row[plan.type])} is not Directed, Undirected or Mutual`,
1015
- { line },
1016
- );
1017
- counts.skippedEdges++;
1018
- return;
1019
- }
1020
- if (parsed !== undefined) {
1021
- kind = parsed;
1022
- }
1600
+ const kind = this.rowKind(plan, row, line);
1601
+ if (kind === null) {
1602
+ counts.skippedEdges++;
1603
+ return;
1023
1604
  }
1024
1605
  const { where } = this;
1025
1606
  where.line = line;
1026
1607
  where.element = null;
1027
1608
  const idText = plan.id >= 0 && !isUnset(row[plan.id], quoted[plan.id]) ? row[plan.id] : null;
1028
- if (idText !== null) {
1029
- if (this.edgeIds.has(idText)) {
1030
- report.error(
1031
- "validation-error",
1032
- DUPLICATE_EDGE_ID_CODE,
1033
- `line ${line}: edge id ${JSON.stringify(idText)} repeats an earlier row's; the row is skipped`,
1034
- { line, element: idText },
1035
- );
1036
- counts.skippedEdges++;
1037
- return;
1038
- }
1039
- this.edgeIds.add(idText);
1609
+ if (idText !== null && this.edgeIds.has(idText)) {
1610
+ report.error(
1611
+ "validation-error",
1612
+ DUPLICATE_EDGE_ID_CODE,
1613
+ `edge id ${JSON.stringify(idText)} repeats an earlier row's; the row is skipped`,
1614
+ { line, element: idText },
1615
+ );
1616
+ counts.skippedEdges++;
1617
+ return;
1040
1618
  }
1619
+ this.checkPadded(sourceText, quoted[plan.source], line);
1620
+ this.checkPadded(targetText, quoted[plan.target], line);
1041
1621
  let edge: number;
1042
1622
  try {
1043
- const source = this.coerce(sourceText);
1044
- const target = this.coerce(targetText);
1045
- const weight = plan.weight >= 0 ? parseWeightText(row[plan.weight]) : undefined;
1046
- if (!this.state.headerSet) {
1047
- this.state.headerSet = true;
1048
- resolver.setHeader(kind !== "undirected", where);
1049
- }
1050
- const sourceNew = sink.indexOf(source) === INVALID_INDEX;
1051
- const targetNew = source !== target && sink.indexOf(target) === INVALID_INDEX;
1052
- const before = sink.edgeCount;
1053
- edge = resolver.addEdge(source, target, kind, weight, where);
1054
- counts.edges += sink.edgeCount - before;
1055
- counts.nodes += (sourceNew ? 1 : 0) + (targetNew ? 1 : 0);
1623
+ edge = this.addEdge(sourceText, targetText, kind, plan.weight >= 0 ? row[plan.weight] : null);
1056
1624
  } catch (err) {
1057
1625
  where.element = idText ?? `${sourceText}->${targetText}`;
1058
1626
  report.recordError(err, where);
@@ -1060,41 +1628,142 @@ class TableReader {
1060
1628
  return;
1061
1629
  }
1062
1630
  if (idText !== null) {
1631
+ // only an imported edge holds its id: a skipped row leaves it free
1632
+ this.edgeIds.add(idText);
1063
1633
  this.writeRole(this.idHandle, "edge", edge, idText, plan.names[plan.id], line);
1064
1634
  }
1635
+ this.warnTypeHint(plan, row, line);
1065
1636
  if (plan.label >= 0 && !isUnset(row[plan.label], quoted[plan.label])) {
1066
1637
  this.writeRole(this.labelHandle, "edge", edge, row[plan.label], plan.names[plan.label], line);
1067
1638
  }
1068
1639
  this.writeAttributes(plan, row, quoted, edge, line);
1069
1640
  }
1070
1641
 
1642
+ /**
1643
+ * Warn, once, when a column that is not the type column holds a direction word.
1644
+ * @param plan - the edge plan
1645
+ * @param row - the cells
1646
+ * @param line - the row's line
1647
+ */
1648
+ private warnTypeHint(plan: EdgePlan, row: readonly string[], line: number): void {
1649
+ if (plan.typeHint < 0 || typeof parseKind(row[plan.typeHint]) !== "string") {
1650
+ return;
1651
+ }
1652
+ this.state.report.warnOnce(
1653
+ "unsupported",
1654
+ TYPE_COLUMN_IGNORED_CODE,
1655
+ `column ${JSON.stringify(plan.names[plan.typeHint])} holds the direction word ${JSON.stringify(row[plan.typeHint])} but is a plain attribute; the per-row direction is read from an exact "Type" column beside "Source" and "Target", or from the typeColumn option`,
1656
+ { line, element: plan.names[plan.typeHint] },
1657
+ );
1658
+ }
1659
+
1660
+ /**
1661
+ * Report a row with the wrong number of cells. The first row too long for an auto table says
1662
+ * what an adjacency list needs, since one reads this way.
1663
+ * @param plan - the edge plan
1664
+ * @param row - the cells
1665
+ * @param line - the row's line
1666
+ */
1667
+ private reportRowWidth(plan: EdgePlan, row: readonly string[], line: number): void {
1668
+ const hint =
1669
+ this.state.csv.table === "auto" && row.length > plan.width && !this.adjacencyHinted
1670
+ ? '; if each line is a node followed by its neighbors, pass table: "adjacency"'
1671
+ : "";
1672
+ this.adjacencyHinted ||= hint !== "";
1673
+ this.state.report.error(
1674
+ "validation-error",
1675
+ FIELD_COUNT_CODE,
1676
+ `${row.length} field${plural(row.length)}, expected ${plan.width}${hint}`,
1677
+ { line },
1678
+ );
1679
+ }
1680
+
1681
+ /**
1682
+ * The direction of an edge row: its Type cell, else the file's or the default direction.
1683
+ * @param plan - the edge plan
1684
+ * @param row - the cells
1685
+ * @param line - the row's line
1686
+ * @returns the kind, or null when the Type cell is not a direction (reported)
1687
+ */
1688
+ private rowKind(plan: EdgePlan, row: readonly string[], line: number): EdgeKind | null {
1689
+ const directed = this.state.commentDirected ?? this.state.common.defaultDirected;
1690
+ const kind: EdgeKind = directed ? "directed" : "undirected";
1691
+ if (plan.type < 0) {
1692
+ return kind;
1693
+ }
1694
+ const parsed = parseKind(row[plan.type]);
1695
+ if (parsed === null) {
1696
+ this.state.report.error(
1697
+ "validation-error",
1698
+ BAD_TYPE_CODE,
1699
+ `Type ${JSON.stringify(row[plan.type])} is not Directed, Undirected or Mutual`,
1700
+ { line },
1701
+ );
1702
+ return null;
1703
+ }
1704
+ return parsed ?? kind;
1705
+ }
1706
+
1707
+ /**
1708
+ * Add an edge row's edge, counting the edge and the nodes it brings in.
1709
+ * @param sourceText - the source cell
1710
+ * @param targetText - the target cell
1711
+ * @param kind - the direction
1712
+ * @param weightText - the weight cell, or null
1713
+ * @returns the edge's index
1714
+ */
1715
+ private addEdge(sourceText: string, targetText: string, kind: EdgeKind, weightText: string | null): number {
1716
+ const { report, sink, resolver } = this.state;
1717
+ const { where } = this;
1718
+ const source = this.coerce(sourceText);
1719
+ const target = this.coerce(targetText);
1720
+ const weight = weightText === null ? undefined : parseWeightText(weightText, report);
1721
+ if (!this.state.headerSet) {
1722
+ this.state.headerSet = true;
1723
+ resolver.setHeader(kind !== "undirected", where);
1724
+ }
1725
+ const sourceNew = sink.indexOf(source) === INVALID_INDEX;
1726
+ const targetNew = source !== target && sink.indexOf(target) === INVALID_INDEX;
1727
+ const before = sink.edgeCount;
1728
+ const edge = resolver.addEdge(source, target, kind, weight, where);
1729
+ report.counts.edges += sink.edgeCount - before;
1730
+ report.counts.nodes += (sourceNew ? 1 : 0) + (targetNew ? 1 : 0);
1731
+ return edge;
1732
+ }
1733
+
1071
1734
  /**
1072
1735
  * Push one node row: the id, then the label and attribute cells.
1073
1736
  * @param plan - the node plan
1074
1737
  * @param row - the cells
1075
- * @param quoted - whether each cell was quoted
1738
+ * @param cellQuoted - whether each cell was quoted
1076
1739
  * @param line - the row's line
1077
1740
  */
1078
- private processNodeRow(plan: NodePlan, row: string[], quoted: readonly boolean[], line: number): void {
1741
+ private processNodeRow(plan: NodePlan, row: string[], cellQuoted: readonly boolean[], line: number): void {
1079
1742
  const { report, sink } = this.state;
1080
1743
  const { counts } = report;
1081
1744
  const ordinal = this.nodeOrdinal++;
1745
+ const quoted = completeRow(plan, row, cellQuoted, report, line);
1082
1746
  if (row.length !== plan.width) {
1083
1747
  report.error(
1084
1748
  "validation-error",
1085
1749
  FIELD_COUNT_CODE,
1086
- `line ${line}: ${row.length} field(s), the header has ${plan.width}`,
1087
- { line },
1750
+ `${row.length} field${plural(row.length)}, expected ${plan.width}`,
1751
+ {
1752
+ line,
1753
+ },
1088
1754
  );
1089
1755
  counts.skippedNodes++;
1090
1756
  return;
1091
1757
  }
1092
1758
  const idText = plan.id >= 0 ? row[plan.id] : String(ordinal);
1093
1759
  if (plan.id >= 0 && isUnset(idText, quoted[plan.id])) {
1094
- report.error("missing-value", MISSING_ID_CODE, `line ${line}: blank id cell`, { line });
1760
+ report.error("missing-value", MISSING_ID_CODE, `blank id cell`, { line });
1095
1761
  counts.skippedNodes++;
1096
1762
  return;
1097
1763
  }
1764
+ if (plan.id >= 0) {
1765
+ this.checkPadded(idText, quoted[plan.id], line);
1766
+ }
1098
1767
  const { where } = this;
1099
1768
  where.line = line;
1100
1769
  where.element = idText;
@@ -1105,7 +1774,7 @@ class TableReader {
1105
1774
  report.warning(
1106
1775
  "merged",
1107
1776
  DUPLICATE_NODE_CODE,
1108
- `line ${line}: node ${JSON.stringify(id)} already exists; its attributes are overwritten`,
1777
+ `node ${JSON.stringify(id)} already exists; its attributes are overwritten`,
1109
1778
  where,
1110
1779
  );
1111
1780
  } else {
@@ -1123,6 +1792,24 @@ class TableReader {
1123
1792
  this.writeAttributes(plan, row, quoted, index, line);
1124
1793
  }
1125
1794
 
1795
+ /**
1796
+ * Warn once per import about an unquoted id cell with leading or trailing whitespace, which is
1797
+ * a different id from the bare text (RFC 4180 keeps spaces).
1798
+ * @param text - the cell text
1799
+ * @param quoted - whether the cell was quoted
1800
+ * @param line - the row's line
1801
+ */
1802
+ private checkPadded(text: string, quoted: boolean | undefined, line: number): void {
1803
+ if (isPadded(text, quoted)) {
1804
+ this.state.report.warnOnce(
1805
+ "validation-error",
1806
+ PADDED_ID_CODE,
1807
+ `id ${JSON.stringify(text)} has leading or trailing whitespace and is kept as written, a different id from the trimmed text`,
1808
+ { line, element: text },
1809
+ );
1810
+ }
1811
+ }
1812
+
1126
1813
  /**
1127
1814
  * Coerce an id cell, reporting a merge under `ids: "number"`.
1128
1815
  * @param text - the cell text
@@ -1204,37 +1891,206 @@ class TableReader {
1204
1891
  }
1205
1892
  }
1206
1893
 
1894
+ /**
1895
+ * Whether a headerless table is a triple list whose third column is text (`a b knows`): every row
1896
+ * read so far has exactly three cells and a third cell that is not a number. That column is then an
1897
+ * attribute rather than the positional weight. A wider or ragged table keeps the positional weight,
1898
+ * so prose or an adjacency list read as edges still fails per row instead of importing.
1899
+ * @param width - the number of columns
1900
+ * @param sample - the data rows read so far
1901
+ * @returns true for a text triple list
1902
+ */
1903
+ function isTextTriple(width: number, sample: readonly (readonly string[])[]): boolean {
1904
+ return (
1905
+ width === 3 &&
1906
+ sample.length > 0 &&
1907
+ sample.every((row) => row.length === 3 && !isBlank(row[2]) && numberOrNull(row[2]) === null)
1908
+ );
1909
+ }
1910
+
1911
+ /**
1912
+ * Whether the caller fixed how the input is read (a delimiter, the header, the table kind or a
1913
+ * column): it is then a table, and its first line is never refused as another format.
1914
+ * @param csv - the resolved options
1915
+ * @returns true when any of them is explicit
1916
+ */
1917
+ function hasExplicitDialect(csv: ResolvedCsvOptions): boolean {
1918
+ return (
1919
+ csv.delimiter !== null ||
1920
+ csv.header !== "auto" ||
1921
+ csv.table !== "auto" ||
1922
+ csv.sourceColumn !== null ||
1923
+ csv.targetColumn !== null ||
1924
+ csv.idColumn !== null ||
1925
+ (csv.typeColumn !== undefined && csv.typeColumn !== null)
1926
+ );
1927
+ }
1928
+
1929
+ /**
1930
+ * Fit a row whose width differs from the header's by one empty last cell: a row that omits the
1931
+ * empty last cell of a header ending in a delimiter gets an unset cell appended, and a row ending
1932
+ * in a delimiter the header does not have (Excel, pandas) loses that empty cell, reported once.
1933
+ * @param plan - the plan
1934
+ * @param row - the cells (changed in place)
1935
+ * @param quoted - whether each cell was quoted
1936
+ * @param report - the report
1937
+ * @param line - the row's line
1938
+ * @returns the quoted flags of the fitted row
1939
+ */
1940
+ function completeRow(
1941
+ plan: EdgePlan | NodePlan,
1942
+ row: string[],
1943
+ quoted: readonly boolean[],
1944
+ report: ImportReportBuilder,
1945
+ line: number,
1946
+ ): readonly boolean[] {
1947
+ if (row.length === plan.width + 1 && isUnset(row[plan.width], quoted[plan.width])) {
1948
+ row.pop();
1949
+ report.warnOnce(
1950
+ "validation-error",
1951
+ TRAILING_DELIMITER_CODE,
1952
+ `the row ends in a delimiter the header does not have; the empty last cell is dropped`,
1953
+ { line },
1954
+ );
1955
+ return quoted;
1956
+ }
1957
+ if (!plan.optionalLast || row.length !== plan.width - 1) {
1958
+ return quoted;
1959
+ }
1960
+ const flags = quoted.slice(0, row.length);
1961
+ row.push("");
1962
+ flags.push(false);
1963
+ return flags;
1964
+ }
1965
+
1207
1966
  const HEAD_BYTES = 4096;
1208
- const OTHER_FORMAT = /^\s*(<|[[{]|(strict\s+)?(di)?graph(\s+\S+)?\s*\{|\*vertices|creator\b|graph\s*\[)/i;
1209
1967
 
1210
1968
  /**
1211
- * Sniff confidence for the registry: 0 for XML, JSON, GML, DOT and Pajek openings; otherwise a
1212
- * delimited first row with endpoint headers is 0.9, with an id header 0.6, any consistently
1213
- * delimited rows 0.3, a single column 0.
1969
+ * The openings of the formats a CSV reader would otherwise split into rows of text: an XML / HTML
1970
+ * tag or declaration (not an IRI such as `<http://a>`, nor a bracketed id such as `<alice smith>`
1971
+ * or `<a>,b`: a tag needs an attribute, or another tag after it), a JSON object (`{"` or `{}`) or
1972
+ * array, a DOT graph, a Pajek section, GML's `Creator "..."` or `graph [`. Matched against the
1973
+ * start of the input.
1974
+ */
1975
+ /**
1976
+ * An endpoint column: the one the option names, else (with a header) the first with a known
1977
+ * endpoint name, else (without one) the column at its position in a headerless edge list.
1978
+ * @param names - the column names
1979
+ * @param header - whether the file has a header row
1980
+ * @param option - the sourceColumn / targetColumn option, or null
1981
+ * @param optionName - the option's name, for its error
1982
+ * @param known - the endpoint names to look for
1983
+ * @param position - the column of this endpoint in a headerless edge list
1984
+ * @returns the column index, or -1
1985
+ */
1986
+ function endpointColumn(
1987
+ names: readonly string[],
1988
+ header: boolean,
1989
+ option: CsvColumnRef | null,
1990
+ optionName: string,
1991
+ known: readonly string[],
1992
+ position: number,
1993
+ ): number {
1994
+ if (option !== null) {
1995
+ return resolveColumnRef(names, option, optionName);
1996
+ }
1997
+ if (header) {
1998
+ return findColumn(names, known);
1999
+ }
2000
+ return names.length >= 2 ? position : -1;
2001
+ }
2002
+
2003
+ const TAG_NAME = String.raw`<[a-z_][\w.-]*(?::[a-z_][\w.-]*)?`;
2004
+ const OTHER_FORMATS: readonly RegExp[] = [
2005
+ /^\s*<(?:\?xml|!doctype|!--)/i, // an XML declaration, a doctype or a comment
2006
+ new RegExp(String.raw`^\s*${TAG_NAME}\s*\/?>\s*(?:[^,;|\s]*<|$)`, "i"), // a tag, then another tag
2007
+ new RegExp(String.raw`^\s*${TAG_NAME}\s+[a-z_][\w.:-]*\s*=`, "i"), // a tag with an attribute
2008
+ /^\s*\/[*/]/, // a C comment (DOT)
2009
+ /^\s*\{\s*(?:["}]|$)/, // a JSON object
2010
+ /^\s*\[\s*(?:[[{"\]\d-]|$)/, // a JSON array
2011
+ /^\s*(?:strict\s+)?(?:di)?graph(?:\s+\S+)?\s*\{/i, // a DOT graph
2012
+ /^\s*\*(?:vertices|network|arcs|edges)\b/i, // a Pajek section
2013
+ /^\s*(?:creator\s+"|graph\s*\[)/i, // GML
2014
+ ];
2015
+
2016
+ /**
2017
+ * Sniff confidence for the registry: 0 for XML, JSON, GML, DOT (also a leading C comment) and Pajek
2018
+ * openings after the leading `#` / `%` comment lines the reader skips, and 0 for a head that is
2019
+ * only such comments (the comments of another format, too long to see past); otherwise a
2020
+ * delimited first row with endpoint headers is 0.9 (even when a later row has the wrong width, which
2021
+ * the importer reports), with an id header 0.6, and other consistently delimited rows 0.3, except
2022
+ * rows of more than three space-separated words, which is plain text such as a sentence, not a
2023
+ * headerless edge list (source, target, weight), so it is 0.
1214
2024
  * @param head - the first bytes of the input
1215
2025
  * @returns a confidence in 0..1
1216
2026
  */
1217
2027
  function sniff(head: Uint8Array): number {
1218
- const text = new TextDecoder("utf-8").decode(head.subarray(0, HEAD_BYTES));
1219
- const body = text.startsWith(String.fromCharCode(0xfeff)) ? text.slice(1) : text;
1220
- if (body.trim().length === 0 || OTHER_FORMAT.test(body)) {
2028
+ let label = "utf-8";
2029
+ if (head[0] === 0xff && head[1] === 0xfe) {
2030
+ label = "utf-16le";
2031
+ } else if (head[0] === 0xfe && head[1] === 0xff) {
2032
+ label = "utf-16be";
2033
+ }
2034
+ // the decoder drops the BOM it was chosen by; the reader skips leading comment lines
2035
+ const body = stripLeadingComments(new TextDecoder(label).decode(head.subarray(0, HEAD_BYTES)), COMMENT_CHARS);
2036
+ if (body.trim().length === 0 || OTHER_FORMATS.some((r) => r.test(body))) {
1221
2037
  return 0;
1222
2038
  }
1223
2039
  const newline = sniffNewline(body);
1224
- const delimiter = sniffDelimiter(body, newline);
2040
+ const delimiter = sniffDelimiter(body, newline, DELIMITER_CANDIDATES, '"', false, true);
1225
2041
  if (delimiter === null) {
1226
- return 0;
2042
+ // a header naming both endpoints is evidence enough; a short or long row later is the
2043
+ // importer's to report, not a reason to refuse the file
2044
+ const first = body.split(newline, 1)[0];
2045
+ return DELIMITER_CANDIDATES.some((d) => hasEndpointHeader(first, d)) ? 0.9 : 0;
1227
2046
  }
1228
- const end = body.indexOf(newline);
1229
- const firstLine = end < 0 ? body : body.slice(0, end);
1230
- const names = headerNames(firstLine.replace(/\r$/, "").split(delimiter));
2047
+ // the first record, honoring quotes (R and pandas quote every header name)
2048
+ const records = splitRecords(body, delimiter, '"', 2, false, true) ?? [];
2049
+ const names = headerNames(records[0] ?? []);
1231
2050
  if (findColumn(names, SOURCE_NAMES) >= 0 && findColumn(names, TARGET_NAMES) >= 0) {
1232
2051
  return 0.9;
1233
2052
  }
1234
- if (findColumn(names, ID_NAMES) >= 0) {
2053
+ // a space-split row naming an id is as likely a line of text ("1 First node") as a header
2054
+ if (delimiter !== " " && findColumn(names, ID_NAMES) >= 0) {
1235
2055
  return 0.6;
1236
2056
  }
1237
- return 0.3;
2057
+ if (delimiter === " " && names.length > 3) {
2058
+ return 0;
2059
+ }
2060
+ return looksLikeProse(body, newline, delimiter) ? 0 : 0.3;
2061
+ }
2062
+
2063
+ /** A cell that reads as part of a sentence: words with a sentence end after a letter (`Monday.`, `world. Thanks`). */
2064
+ const SENTENCE = /[A-Za-z]{2}[.!?](?:\s|$)/;
2065
+
2066
+ /**
2067
+ * Whether headerless delimited text is more likely a line of prose than an edge list: a single line
2068
+ * ("hello world", "Dear team, the meeting is on Monday."), or a cell of more than one word that
2069
+ * ends a sentence. An edge list a reader means to load has more than one row and ids, not sentences;
2070
+ * such input is still read as CSV when the caller names the format.
2071
+ * @param body - the head, decoded, without its leading comments
2072
+ * @param newline - the head's newline
2073
+ * @param delimiter - the sniffed delimiter
2074
+ * @returns true for prose
2075
+ */
2076
+ function looksLikeProse(body: string, newline: string, delimiter: string): boolean {
2077
+ const lines = body.split(newline).filter((l) => l.trim() !== "");
2078
+ if (lines.length < 2) {
2079
+ return true;
2080
+ }
2081
+ const rows = splitRecords(body, delimiter, '"', 8, false, true) ?? [];
2082
+ return rows.some((row) => row.some((cell) => /\S\s+\S/.test(cell.trim()) && SENTENCE.test(cell.trim())));
2083
+ }
2084
+
2085
+ /**
2086
+ * Whether one line, split on a delimiter, names a source and a target column.
2087
+ * @param line - the first line of the input
2088
+ * @param delimiter - the delimiter to split on
2089
+ * @returns true for an edge table header
2090
+ */
2091
+ function hasEndpointHeader(line: string, delimiter: string): boolean {
2092
+ const names = headerNames(splitRecords(line, delimiter, '"', 1, false, true)?.[0] ?? []);
2093
+ return names.length > 1 && findColumn(names, SOURCE_NAMES) >= 0 && findColumn(names, TARGET_NAMES) >= 0;
1238
2094
  }
1239
2095
 
1240
2096
  /**
@@ -1268,6 +2124,7 @@ async function importCsv(
1268
2124
  resolver: new DirectionResolver(sink, report, common.onMixedDirection),
1269
2125
  headerSet: false,
1270
2126
  commentDirected: null,
2127
+ commentText: "",
1271
2128
  };
1272
2129
  if (csv.nodes !== null) {
1273
2130
  await new TableReader(state, csv.nodes, "nodes", false).read();
@@ -1277,18 +2134,49 @@ async function importCsv(
1277
2134
  report.warning(
1278
2135
  "coercion",
1279
2136
  ID_MERGED_CODE,
1280
- `${state.coercer.mergeCount} id cell(s) merged into ids other cells already produced under ids: "number"`,
2137
+ `${state.coercer.mergeCount} id cell${plural(state.coercer.mergeCount)} merged into ids other cells already produced under ids: "number"`,
1281
2138
  );
1282
2139
  }
1283
2140
  throwIfAborted(common.signal);
1284
2141
  return report.finish();
1285
2142
  }
1286
2143
 
1287
- /** The CSV / TSV importer plugin (subpath `@graphty/graph-io/csv`). */
2144
+ /**
2145
+ * The CSV / TSV importer plugin (subpath `@graphty/graph-io/csv`).
2146
+ * @category Built-in formats
2147
+ */
1288
2148
  export const csvImporter: GraphImporter<CsvImportOptions> = Object.freeze({
1289
2149
  format: "csv",
2150
+ options: Object.freeze([
2151
+ "decimal",
2152
+ "delimiter",
2153
+ "header",
2154
+ "idColumn",
2155
+ "labelColumn",
2156
+ "nodes",
2157
+ "rowNumberIds",
2158
+ "sourceColumn",
2159
+ "table",
2160
+ "targetColumn",
2161
+ "typeColumn",
2162
+ ]),
1290
2163
  extensions: Object.freeze([".csv", ".tsv", ".edges", ".edgelist"]),
1291
2164
  mimeTypes: Object.freeze(["text/csv", "text/tab-separated-values", "text/plain"]),
1292
2165
  sniff,
1293
2166
  import: importCsv,
1294
2167
  });
2168
+
2169
+ /**
2170
+ * The `nodes` option as an input: a Blob or File is read through its stream. Checked by shape, so a
2171
+ * Blob from another realm or from Node's `fs.openAsBlob()` counts too.
2172
+ * @param value - the option value
2173
+ * @returns the input, or null when there is no node table
2174
+ */
2175
+ function nodesInput(value: ImportInput | Blob | undefined): ImportInput | null {
2176
+ if (value === undefined) {
2177
+ return null;
2178
+ }
2179
+ const object = value as { stream?: unknown; size?: unknown };
2180
+ const blob = typeof value === "object" && typeof object.stream === "function" && typeof object.size === "number";
2181
+ return blob ? (value as Blob).stream() : (value as ImportInput);
2182
+ }