@graphty/graph-io 0.0.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (339) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +250 -28
  3. package/dist/chunks/children-CL3Cy0ez.js +238 -0
  4. package/dist/chunks/children-CL3Cy0ez.js.map +1 -0
  5. package/dist/chunks/escape-DyI8JofU.js +938 -0
  6. package/dist/chunks/escape-DyI8JofU.js.map +1 -0
  7. package/dist/chunks/importer-CQnJuWJw.js +2987 -0
  8. package/dist/chunks/importer-CQnJuWJw.js.map +1 -0
  9. package/dist/chunks/importer-CpCpfbxr.js +2015 -0
  10. package/dist/chunks/importer-CpCpfbxr.js.map +1 -0
  11. package/dist/chunks/importer-DbnGYr3_.js +2342 -0
  12. package/dist/chunks/importer-DbnGYr3_.js.map +1 -0
  13. package/dist/chunks/importer-GozH8DkN.js +3050 -0
  14. package/dist/chunks/importer-GozH8DkN.js.map +1 -0
  15. package/dist/chunks/records-CGpxszm1.js +605 -0
  16. package/dist/chunks/records-CGpxszm1.js.map +1 -0
  17. package/dist/chunks/text-CajMdVFy.js +189 -0
  18. package/dist/chunks/text-CajMdVFy.js.map +1 -0
  19. package/dist/chunks/writer-DxSKC7TL.js +2842 -0
  20. package/dist/chunks/writer-DxSKC7TL.js.map +1 -0
  21. package/dist/csv.d.ts +1 -0
  22. package/dist/csv.js +1702 -0
  23. package/dist/csv.js.map +1 -0
  24. package/dist/dot.d.ts +1 -0
  25. package/dist/dot.js +8 -0
  26. package/dist/dot.js.map +1 -0
  27. package/dist/gexf.d.ts +1 -0
  28. package/dist/gexf.js +3466 -0
  29. package/dist/gexf.js.map +1 -0
  30. package/dist/gml.d.ts +1 -0
  31. package/dist/gml.js +2647 -0
  32. package/dist/gml.js.map +1 -0
  33. package/dist/graph-io.d.ts +1 -0
  34. package/dist/graph-io.js +790 -0
  35. package/dist/graph-io.js.map +1 -0
  36. package/dist/graphml.d.ts +1 -0
  37. package/dist/graphml.js +8 -0
  38. package/dist/graphml.js.map +1 -0
  39. package/dist/json.d.ts +1 -0
  40. package/dist/json.js +11 -0
  41. package/dist/json.js.map +1 -0
  42. package/dist/neo4j.d.ts +1 -0
  43. package/dist/neo4j.js +2046 -0
  44. package/dist/neo4j.js.map +1 -0
  45. package/dist/pajek.d.ts +1 -0
  46. package/dist/pajek.js +8 -0
  47. package/dist/pajek.js.map +1 -0
  48. package/dist/src/children.d.ts +134 -0
  49. package/dist/src/children.d.ts.map +1 -0
  50. package/dist/src/children.js +274 -0
  51. package/dist/src/children.js.map +1 -0
  52. package/dist/src/common/attributes.d.ts +229 -0
  53. package/dist/src/common/attributes.d.ts.map +1 -0
  54. package/dist/src/common/attributes.js +368 -0
  55. package/dist/src/common/attributes.js.map +1 -0
  56. package/dist/src/common/codes.d.ts +105 -0
  57. package/dist/src/common/codes.d.ts.map +1 -0
  58. package/dist/src/common/codes.js +107 -0
  59. package/dist/src/common/codes.js.map +1 -0
  60. package/dist/src/common/declared-types.d.ts +84 -0
  61. package/dist/src/common/declared-types.d.ts.map +1 -0
  62. package/dist/src/common/declared-types.js +326 -0
  63. package/dist/src/common/declared-types.js.map +1 -0
  64. package/dist/src/common/direction.d.ts +206 -0
  65. package/dist/src/common/direction.d.ts.map +1 -0
  66. package/dist/src/common/direction.js +370 -0
  67. package/dist/src/common/direction.js.map +1 -0
  68. package/dist/src/common/escape.d.ts +92 -0
  69. package/dist/src/common/escape.d.ts.map +1 -0
  70. package/dist/src/common/escape.js +212 -0
  71. package/dist/src/common/escape.js.map +1 -0
  72. package/dist/src/common/export.d.ts +249 -0
  73. package/dist/src/common/export.d.ts.map +1 -0
  74. package/dist/src/common/export.js +594 -0
  75. package/dist/src/common/export.js.map +1 -0
  76. package/dist/src/common/format.d.ts +59 -0
  77. package/dist/src/common/format.d.ts.map +1 -0
  78. package/dist/src/common/format.js +106 -0
  79. package/dist/src/common/format.js.map +1 -0
  80. package/dist/src/common/ids.d.ts +83 -0
  81. package/dist/src/common/ids.d.ts.map +1 -0
  82. package/dist/src/common/ids.js +158 -0
  83. package/dist/src/common/ids.js.map +1 -0
  84. package/dist/src/common/input.d.ts +100 -0
  85. package/dist/src/common/input.d.ts.map +1 -0
  86. package/dist/src/common/input.js +335 -0
  87. package/dist/src/common/input.js.map +1 -0
  88. package/dist/src/common/lists.d.ts +34 -0
  89. package/dist/src/common/lists.d.ts.map +1 -0
  90. package/dist/src/common/lists.js +185 -0
  91. package/dist/src/common/lists.js.map +1 -0
  92. package/dist/src/common/options.d.ts +108 -0
  93. package/dist/src/common/options.d.ts.map +1 -0
  94. package/dist/src/common/options.js +265 -0
  95. package/dist/src/common/options.js.map +1 -0
  96. package/dist/src/common/report.d.ts +187 -0
  97. package/dist/src/common/report.d.ts.map +1 -0
  98. package/dist/src/common/report.js +274 -0
  99. package/dist/src/common/report.js.map +1 -0
  100. package/dist/src/common/temporal.d.ts +71 -0
  101. package/dist/src/common/temporal.d.ts.map +1 -0
  102. package/dist/src/common/temporal.js +266 -0
  103. package/dist/src/common/temporal.js.map +1 -0
  104. package/dist/src/common/text.d.ts +104 -0
  105. package/dist/src/common/text.d.ts.map +1 -0
  106. package/dist/src/common/text.js +255 -0
  107. package/dist/src/common/text.js.map +1 -0
  108. package/dist/src/common/weights.d.ts +77 -0
  109. package/dist/src/common/weights.d.ts.map +1 -0
  110. package/dist/src/common/weights.js +156 -0
  111. package/dist/src/common/weights.js.map +1 -0
  112. package/dist/src/common/writer.d.ts +51 -0
  113. package/dist/src/common/writer.d.ts.map +1 -0
  114. package/dist/src/common/writer.js +108 -0
  115. package/dist/src/common/writer.js.map +1 -0
  116. package/dist/src/common/xml.d.ts +245 -0
  117. package/dist/src/common/xml.d.ts.map +1 -0
  118. package/dist/src/common/xml.js +942 -0
  119. package/dist/src/common/xml.js.map +1 -0
  120. package/dist/src/formats/csv/exporter.d.ts +70 -0
  121. package/dist/src/formats/csv/exporter.d.ts.map +1 -0
  122. package/dist/src/formats/csv/exporter.js +682 -0
  123. package/dist/src/formats/csv/exporter.js.map +1 -0
  124. package/dist/src/formats/csv/header.d.ts +66 -0
  125. package/dist/src/formats/csv/header.d.ts.map +1 -0
  126. package/dist/src/formats/csv/header.js +152 -0
  127. package/dist/src/formats/csv/header.js.map +1 -0
  128. package/dist/src/formats/csv/importer.d.ts +82 -0
  129. package/dist/src/formats/csv/importer.d.ts.map +1 -0
  130. package/dist/src/formats/csv/importer.js +849 -0
  131. package/dist/src/formats/csv/importer.js.map +1 -0
  132. package/dist/src/formats/csv/index.d.ts +60 -0
  133. package/dist/src/formats/csv/index.d.ts.map +1 -0
  134. package/dist/src/formats/csv/index.js +63 -0
  135. package/dist/src/formats/csv/index.js.map +1 -0
  136. package/dist/src/formats/csv/records.d.ts +188 -0
  137. package/dist/src/formats/csv/records.d.ts.map +1 -0
  138. package/dist/src/formats/csv/records.js +702 -0
  139. package/dist/src/formats/csv/records.js.map +1 -0
  140. package/dist/src/formats/csv/values.d.ts +105 -0
  141. package/dist/src/formats/csv/values.d.ts.map +1 -0
  142. package/dist/src/formats/csv/values.js +192 -0
  143. package/dist/src/formats/csv/values.js.map +1 -0
  144. package/dist/src/formats/dot/exporter.d.ts +52 -0
  145. package/dist/src/formats/dot/exporter.d.ts.map +1 -0
  146. package/dist/src/formats/dot/exporter.js +836 -0
  147. package/dist/src/formats/dot/exporter.js.map +1 -0
  148. package/dist/src/formats/dot/importer.d.ts +102 -0
  149. package/dist/src/formats/dot/importer.d.ts.map +1 -0
  150. package/dist/src/formats/dot/importer.js +1291 -0
  151. package/dist/src/formats/dot/importer.js.map +1 -0
  152. package/dist/src/formats/dot/index.d.ts +7 -0
  153. package/dist/src/formats/dot/index.d.ts.map +1 -0
  154. package/dist/src/formats/dot/index.js +7 -0
  155. package/dist/src/formats/dot/index.js.map +1 -0
  156. package/dist/src/formats/dot/names.d.ts +29 -0
  157. package/dist/src/formats/dot/names.d.ts.map +1 -0
  158. package/dist/src/formats/dot/names.js +28 -0
  159. package/dist/src/formats/dot/names.js.map +1 -0
  160. package/dist/src/formats/dot/tokenizer.d.ts +114 -0
  161. package/dist/src/formats/dot/tokenizer.d.ts.map +1 -0
  162. package/dist/src/formats/dot/tokenizer.js +341 -0
  163. package/dist/src/formats/dot/tokenizer.js.map +1 -0
  164. package/dist/src/formats/gexf/exporter.d.ts +56 -0
  165. package/dist/src/formats/gexf/exporter.d.ts.map +1 -0
  166. package/dist/src/formats/gexf/exporter.js +1395 -0
  167. package/dist/src/formats/gexf/exporter.js.map +1 -0
  168. package/dist/src/formats/gexf/importer.d.ts +73 -0
  169. package/dist/src/formats/gexf/importer.d.ts.map +1 -0
  170. package/dist/src/formats/gexf/importer.js +1880 -0
  171. package/dist/src/formats/gexf/importer.js.map +1 -0
  172. package/dist/src/formats/gexf/index.d.ts +96 -0
  173. package/dist/src/formats/gexf/index.d.ts.map +1 -0
  174. package/dist/src/formats/gexf/index.js +97 -0
  175. package/dist/src/formats/gexf/index.js.map +1 -0
  176. package/dist/src/formats/gexf/schema.d.ts +135 -0
  177. package/dist/src/formats/gexf/schema.d.ts.map +1 -0
  178. package/dist/src/formats/gexf/schema.js +323 -0
  179. package/dist/src/formats/gexf/schema.js.map +1 -0
  180. package/dist/src/formats/gml/exporter.d.ts +69 -0
  181. package/dist/src/formats/gml/exporter.d.ts.map +1 -0
  182. package/dist/src/formats/gml/exporter.js +1093 -0
  183. package/dist/src/formats/gml/exporter.js.map +1 -0
  184. package/dist/src/formats/gml/importer.d.ts +66 -0
  185. package/dist/src/formats/gml/importer.d.ts.map +1 -0
  186. package/dist/src/formats/gml/importer.js +1331 -0
  187. package/dist/src/formats/gml/importer.js.map +1 -0
  188. package/dist/src/formats/gml/index.d.ts +85 -0
  189. package/dist/src/formats/gml/index.d.ts.map +1 -0
  190. package/dist/src/formats/gml/index.js +88 -0
  191. package/dist/src/formats/gml/index.js.map +1 -0
  192. package/dist/src/formats/gml/syntax.d.ts +186 -0
  193. package/dist/src/formats/gml/syntax.d.ts.map +1 -0
  194. package/dist/src/formats/gml/syntax.js +467 -0
  195. package/dist/src/formats/gml/syntax.js.map +1 -0
  196. package/dist/src/formats/graphml/constants.d.ts +169 -0
  197. package/dist/src/formats/graphml/constants.d.ts.map +1 -0
  198. package/dist/src/formats/graphml/constants.js +165 -0
  199. package/dist/src/formats/graphml/constants.js.map +1 -0
  200. package/dist/src/formats/graphml/exporter.d.ts +34 -0
  201. package/dist/src/formats/graphml/exporter.d.ts.map +1 -0
  202. package/dist/src/formats/graphml/exporter.js +1176 -0
  203. package/dist/src/formats/graphml/exporter.js.map +1 -0
  204. package/dist/src/formats/graphml/importer.d.ts +31 -0
  205. package/dist/src/formats/graphml/importer.d.ts.map +1 -0
  206. package/dist/src/formats/graphml/importer.js +1607 -0
  207. package/dist/src/formats/graphml/importer.js.map +1 -0
  208. package/dist/src/formats/graphml/index.d.ts +8 -0
  209. package/dist/src/formats/graphml/index.d.ts.map +1 -0
  210. package/dist/src/formats/graphml/index.js +8 -0
  211. package/dist/src/formats/graphml/index.js.map +1 -0
  212. package/dist/src/formats/graphml/tree.d.ts +72 -0
  213. package/dist/src/formats/graphml/tree.d.ts.map +1 -0
  214. package/dist/src/formats/graphml/tree.js +290 -0
  215. package/dist/src/formats/graphml/tree.js.map +1 -0
  216. package/dist/src/formats/json/dialect.d.ts +125 -0
  217. package/dist/src/formats/json/dialect.d.ts.map +1 -0
  218. package/dist/src/formats/json/dialect.js +262 -0
  219. package/dist/src/formats/json/dialect.js.map +1 -0
  220. package/dist/src/formats/json/exporter.d.ts +89 -0
  221. package/dist/src/formats/json/exporter.d.ts.map +1 -0
  222. package/dist/src/formats/json/exporter.js +1358 -0
  223. package/dist/src/formats/json/exporter.js.map +1 -0
  224. package/dist/src/formats/json/importer.d.ts +108 -0
  225. package/dist/src/formats/json/importer.d.ts.map +1 -0
  226. package/dist/src/formats/json/importer.js +1838 -0
  227. package/dist/src/formats/json/importer.js.map +1 -0
  228. package/dist/src/formats/json/index.d.ts +8 -0
  229. package/dist/src/formats/json/index.d.ts.map +1 -0
  230. package/dist/src/formats/json/index.js +8 -0
  231. package/dist/src/formats/json/index.js.map +1 -0
  232. package/dist/src/formats/neo4j/exporter.d.ts +68 -0
  233. package/dist/src/formats/neo4j/exporter.d.ts.map +1 -0
  234. package/dist/src/formats/neo4j/exporter.js +1055 -0
  235. package/dist/src/formats/neo4j/exporter.js.map +1 -0
  236. package/dist/src/formats/neo4j/header.d.ts +52 -0
  237. package/dist/src/formats/neo4j/header.d.ts.map +1 -0
  238. package/dist/src/formats/neo4j/header.js +131 -0
  239. package/dist/src/formats/neo4j/header.js.map +1 -0
  240. package/dist/src/formats/neo4j/importer.d.ts +73 -0
  241. package/dist/src/formats/neo4j/importer.d.ts.map +1 -0
  242. package/dist/src/formats/neo4j/importer.js +932 -0
  243. package/dist/src/formats/neo4j/importer.js.map +1 -0
  244. package/dist/src/formats/neo4j/index.d.ts +79 -0
  245. package/dist/src/formats/neo4j/index.d.ts.map +1 -0
  246. package/dist/src/formats/neo4j/index.js +83 -0
  247. package/dist/src/formats/neo4j/index.js.map +1 -0
  248. package/dist/src/formats/pajek/exporter.d.ts +58 -0
  249. package/dist/src/formats/pajek/exporter.d.ts.map +1 -0
  250. package/dist/src/formats/pajek/exporter.js +825 -0
  251. package/dist/src/formats/pajek/exporter.js.map +1 -0
  252. package/dist/src/formats/pajek/importer.d.ts +88 -0
  253. package/dist/src/formats/pajek/importer.d.ts.map +1 -0
  254. package/dist/src/formats/pajek/importer.js +1047 -0
  255. package/dist/src/formats/pajek/importer.js.map +1 -0
  256. package/dist/src/formats/pajek/index.d.ts +7 -0
  257. package/dist/src/formats/pajek/index.d.ts.map +1 -0
  258. package/dist/src/formats/pajek/index.js +7 -0
  259. package/dist/src/formats/pajek/index.js.map +1 -0
  260. package/dist/src/formats/pajek/syntax.d.ts +112 -0
  261. package/dist/src/formats/pajek/syntax.d.ts.map +1 -0
  262. package/dist/src/formats/pajek/syntax.js +269 -0
  263. package/dist/src/formats/pajek/syntax.js.map +1 -0
  264. package/dist/src/index.d.ts +35 -0
  265. package/dist/src/index.d.ts.map +1 -0
  266. package/dist/src/index.js +39 -0
  267. package/dist/src/index.js.map +1 -0
  268. package/dist/src/registry.d.ts +207 -0
  269. package/dist/src/registry.d.ts.map +1 -0
  270. package/dist/src/registry.js +481 -0
  271. package/dist/src/registry.js.map +1 -0
  272. package/dist/src/sniff.d.ts +104 -0
  273. package/dist/src/sniff.d.ts.map +1 -0
  274. package/dist/src/sniff.js +357 -0
  275. package/dist/src/sniff.js.map +1 -0
  276. package/dist/src/types.d.ts +238 -0
  277. package/dist/src/types.d.ts.map +1 -0
  278. package/dist/src/types.js +29 -0
  279. package/dist/src/types.js.map +1 -0
  280. package/dist/tsconfig.build.tsbuildinfo +1 -0
  281. package/package.json +122 -7
  282. package/src/children.ts +335 -0
  283. package/src/common/attributes.ts +520 -0
  284. package/src/common/codes.ts +153 -0
  285. package/src/common/declared-types.ts +374 -0
  286. package/src/common/direction.ts +518 -0
  287. package/src/common/escape.ts +231 -0
  288. package/src/common/export.ts +817 -0
  289. package/src/common/format.ts +111 -0
  290. package/src/common/ids.ts +176 -0
  291. package/src/common/input.ts +378 -0
  292. package/src/common/lists.ts +196 -0
  293. package/src/common/options.ts +377 -0
  294. package/src/common/report.ts +352 -0
  295. package/src/common/temporal.ts +302 -0
  296. package/src/common/text.ts +294 -0
  297. package/src/common/weights.ts +202 -0
  298. package/src/common/writer.ts +123 -0
  299. package/src/common/xml.ts +1053 -0
  300. package/src/formats/csv/exporter.ts +894 -0
  301. package/src/formats/csv/header.ts +172 -0
  302. package/src/formats/csv/importer.ts +1104 -0
  303. package/src/formats/csv/index.ts +88 -0
  304. package/src/formats/csv/records.ts +813 -0
  305. package/src/formats/csv/values.ts +224 -0
  306. package/src/formats/dot/exporter.ts +1014 -0
  307. package/src/formats/dot/importer.ts +1549 -0
  308. package/src/formats/dot/index.ts +7 -0
  309. package/src/formats/dot/names.ts +40 -0
  310. package/src/formats/dot/tokenizer.ts +384 -0
  311. package/src/formats/gexf/exporter.ts +1696 -0
  312. package/src/formats/gexf/importer.ts +2333 -0
  313. package/src/formats/gexf/index.ts +142 -0
  314. package/src/formats/gexf/schema.ts +361 -0
  315. package/src/formats/gml/exporter.ts +1404 -0
  316. package/src/formats/gml/importer.ts +1591 -0
  317. package/src/formats/gml/index.ts +128 -0
  318. package/src/formats/gml/syntax.ts +545 -0
  319. package/src/formats/graphml/constants.ts +225 -0
  320. package/src/formats/graphml/exporter.ts +1458 -0
  321. package/src/formats/graphml/importer.ts +2027 -0
  322. package/src/formats/graphml/index.ts +8 -0
  323. package/src/formats/graphml/tree.ts +318 -0
  324. package/src/formats/json/dialect.ts +317 -0
  325. package/src/formats/json/exporter.ts +1616 -0
  326. package/src/formats/json/importer.ts +2271 -0
  327. package/src/formats/json/index.ts +8 -0
  328. package/src/formats/neo4j/exporter.ts +1287 -0
  329. package/src/formats/neo4j/header.ts +156 -0
  330. package/src/formats/neo4j/importer.ts +1220 -0
  331. package/src/formats/neo4j/index.ts +116 -0
  332. package/src/formats/pajek/exporter.ts +1000 -0
  333. package/src/formats/pajek/importer.ts +1311 -0
  334. package/src/formats/pajek/index.ts +7 -0
  335. package/src/formats/pajek/syntax.ts +307 -0
  336. package/src/index.ts +244 -0
  337. package/src/registry.ts +617 -0
  338. package/src/sniff.ts +397 -0
  339. package/src/types.ts +262 -0
@@ -0,0 +1,352 @@
1
+ /**
2
+ * The ImportReport builder shared by every importer (design section 8.6): issues by category and
3
+ * severity, the element counts, loss notes, the error limit with truncation, and the ImportError
4
+ * that carries the partial report when the importer aborts.
5
+ *
6
+ * Layering (design section 8.6): the builder throws on the first hard error; the importer catches
7
+ * per element, records an ImportIssue through this class, skips the element and continues until the
8
+ * error limit, then aborts with E_IMPORT. `recordError()` is that catch: it turns a thrown
9
+ * GraphFormatError into an issue with the category its code implies and re-throws everything else
10
+ * (an ImportError, an abort reason, a programming error) unchanged.
11
+ */
12
+
13
+ import { GraphFormatError, type GraphFormatErrorCode } from "@graphty/graph-format";
14
+
15
+ import { ImportError, type ImportIssue, type ImportReport, type IssueCategory, type LossNote } from "../types.js";
16
+
17
+ /**
18
+ * Where an issue was found: the 1-based line and the element (id or attribute name) when known.
19
+ * Consumed by the per-format importers and exporters under src/formats.
20
+ * @public
21
+ */
22
+ export interface IssueLocation {
23
+ /** The 1-based source line, when known. */
24
+ readonly line?: number | null | undefined;
25
+ /** The element the issue is about, when known. */
26
+ readonly element?: string | null | undefined;
27
+ }
28
+
29
+ /**
30
+ * The mutable element counters of a report in progress; importers increment them directly in hot loops.
31
+ * Consumed by the per-format importers and exporters under src/formats.
32
+ * @public
33
+ */
34
+ export interface MutableCounts {
35
+ /** Nodes pushed. */
36
+ nodes: number;
37
+ /** Logical edges pushed (both halves of an expanded edge count). */
38
+ edges: number;
39
+ /** Nodes skipped after an error. */
40
+ skippedNodes: number;
41
+ /** Edges skipped after an error. */
42
+ skippedEdges: number;
43
+ /** Source edges expanded into two logical edges (design section 3.6). */
44
+ expandedMixed: number;
45
+ }
46
+
47
+ /**
48
+ * The category a thrown GraphFormatError maps to when an importer catches it per element: the codes
49
+ * that mean "the file referenced something it never declared" are missing values, a refused
50
+ * direction change is a coercion, size and feature limits are unsupported, everything else is a
51
+ * validation error of the element.
52
+ */
53
+ const CATEGORY_BY_CODE: Readonly<Partial<Record<GraphFormatErrorCode, IssueCategory>>> = {
54
+ E_UNKNOWN_NODE: "missing-value",
55
+ E_UNKNOWN_COLUMN: "missing-value",
56
+ E_DIRECTED: "coercion",
57
+ E_TOO_LARGE: "unsupported",
58
+ E_UNSUPPORTED: "unsupported",
59
+ E_GPU_INELIGIBLE: "unsupported",
60
+ };
61
+
62
+ /**
63
+ * The code a report used to give a thrown value that was not a GraphFormatError. Since audit round
64
+ * 1 such a value (a TypeError, a RangeError: a bug in an importer or a sink, never a defect of the
65
+ * input) propagates out of import() untouched, so no importer records this code any more; the
66
+ * constant stays for consumers that switch on the codes of earlier reports.
67
+ */
68
+ export const PARSE_ERROR_CODE = "E_PARSE";
69
+
70
+ /**
71
+ * Accumulates an ImportReport while an importer runs. Errors count toward the error limit; the
72
+ * error that takes the count beyond the limit is still recorded, `truncated` is set, and an
73
+ * ImportError carrying the report so far is thrown (design section 8.4: "beyond it the importer
74
+ * aborts with E_IMPORT"). Warnings never abort.
75
+ */
76
+ export class ImportReportBuilder {
77
+ /** The importer's format name. */
78
+ readonly format: string;
79
+
80
+ /** The error limit the report was created with. */
81
+ readonly errorLimit: number;
82
+
83
+ /** The element counters; importers increment them directly. */
84
+ readonly counts: MutableCounts = { nodes: 0, edges: 0, skippedNodes: 0, skippedEdges: 0, expandedMixed: 0 };
85
+
86
+ private readonly issueList: ImportIssue[] = [];
87
+
88
+ private readonly lossList: LossNote[] = [];
89
+
90
+ private readonly onceCodes = new Set<string>();
91
+
92
+ private errors = 0;
93
+
94
+ private warnings = 0;
95
+
96
+ private truncatedFlag = false;
97
+
98
+ private readonly startedAt: number;
99
+
100
+ /**
101
+ * Create a builder for one import call.
102
+ * @param format - the importer's format name
103
+ * @param errorLimit - errors tolerated before the import aborts; Infinity for no limit
104
+ */
105
+ constructor(format: string, errorLimit: number) {
106
+ this.format = format;
107
+ this.errorLimit = errorLimit;
108
+ this.startedAt = now();
109
+ }
110
+
111
+ /**
112
+ * Issues recorded so far, in order.
113
+ * @returns the live list (not copied; finish() copies)
114
+ */
115
+ get issues(): readonly ImportIssue[] {
116
+ return this.issueList;
117
+ }
118
+
119
+ /**
120
+ * Issues with severity "error".
121
+ * @returns the count
122
+ */
123
+ get errorCount(): number {
124
+ return this.errors;
125
+ }
126
+
127
+ /**
128
+ * Issues with severity "warning".
129
+ * @returns the count
130
+ */
131
+ get warningCount(): number {
132
+ return this.warnings;
133
+ }
134
+
135
+ /**
136
+ * Whether the error limit was exceeded.
137
+ * @returns true once an error beyond the limit was recorded
138
+ */
139
+ get truncated(): boolean {
140
+ return this.truncatedFlag;
141
+ }
142
+
143
+ /**
144
+ * Loss notes recorded so far.
145
+ * @returns the live list
146
+ */
147
+ get lossy(): readonly LossNote[] {
148
+ return this.lossList;
149
+ }
150
+
151
+ /**
152
+ * Record an error. When the count goes beyond the error limit the report is marked truncated
153
+ * and an ImportError carrying it is thrown; the importer does not catch that.
154
+ * @param category - the issue category
155
+ * @param code - a stable code such as "E_UNKNOWN_NODE"
156
+ * @param message - a plain-ASCII message
157
+ * @param where - the line and element, when known
158
+ * @returns the recorded issue
159
+ */
160
+ error(category: IssueCategory, code: string, message: string, where?: IssueLocation): ImportIssue {
161
+ const issue = makeIssue(category, "error", code, message, where);
162
+ this.issueList.push(issue);
163
+ this.errors++;
164
+ if (this.errors > this.errorLimit) {
165
+ this.truncatedFlag = true;
166
+ throw this.abort(`error limit of ${this.errorLimit} exceeded: ${message}`, {
167
+ code,
168
+ limit: this.errorLimit,
169
+ });
170
+ }
171
+ return issue;
172
+ }
173
+
174
+ /**
175
+ * Record a warning; warnings never count toward the limit.
176
+ * @param category - the issue category
177
+ * @param code - a stable code such as "W_WIDENED"
178
+ * @param message - a plain-ASCII message
179
+ * @param where - the line and element, when known
180
+ * @returns the recorded issue
181
+ */
182
+ warning(category: IssueCategory, code: string, message: string, where?: IssueLocation): ImportIssue {
183
+ const issue = makeIssue(category, "warning", code, message, where);
184
+ this.issueList.push(issue);
185
+ this.warnings++;
186
+ return issue;
187
+ }
188
+
189
+ /**
190
+ * Record a warning the first time its key is seen and ignore later repeats, for conditions
191
+ * that would otherwise produce one warning per row.
192
+ * @param category - the issue category
193
+ * @param code - the stable code
194
+ * @param message - a plain-ASCII message
195
+ * @param where - the line and element, when known
196
+ * @param key - what repeats are deduplicated on; the code itself by default (pass `code:column`
197
+ * to warn once per column)
198
+ * @returns the recorded issue, or null when the key was already recorded
199
+ */
200
+ warnOnce(
201
+ category: IssueCategory,
202
+ code: string,
203
+ message: string,
204
+ where?: IssueLocation,
205
+ key: string = code,
206
+ ): ImportIssue | null {
207
+ if (this.onceCodes.has(key)) {
208
+ return null;
209
+ }
210
+ this.onceCodes.add(key);
211
+ return this.warning(category, code, message, where);
212
+ }
213
+
214
+ /**
215
+ * Record something the importer could not represent.
216
+ * @param code - a stable code
217
+ * @param message - a plain-ASCII message
218
+ * @param column - the affected column, or null
219
+ * @param count - the affected count, or null
220
+ * @returns the recorded note
221
+ */
222
+ loss(code: string, message: string, column: string | null = null, count: number | null = null): LossNote {
223
+ const note: LossNote = Object.freeze({ code, message, column, count });
224
+ this.lossList.push(note);
225
+ return note;
226
+ }
227
+
228
+ /**
229
+ * The per-element catch of design section 8.6: record a thrown GraphFormatError as an error
230
+ * issue with its code and the category the code implies. Anything else is re-thrown unchanged:
231
+ * an ImportError (the import already aborted), an abort reason, and any other thrown value (a
232
+ * TypeError or RangeError is a programming error in the importer or the sink, never a defect of
233
+ * the input, so it must surface as an exception rather than as an issue blamed on the file).
234
+ * @param err - the thrown value
235
+ * @param where - the line and element, when known
236
+ * @returns the recorded issue
237
+ */
238
+ recordError(err: unknown, where?: IssueLocation): ImportIssue {
239
+ if (err instanceof GraphFormatError && !(err instanceof ImportError)) {
240
+ const category = CATEGORY_BY_CODE[err.code] ?? "validation-error";
241
+ return this.error(category, err.code, err.message, where);
242
+ }
243
+ throw err;
244
+ }
245
+
246
+ /**
247
+ * Build the ImportError that aborts the import, carrying the report so far. The caller throws
248
+ * it; this method only constructs it so it can be used in expression position.
249
+ * @param message - a plain-ASCII message
250
+ * @param details - optional machine-readable context
251
+ * @returns the error to throw
252
+ */
253
+ abort(message: string, details?: Readonly<Record<string, unknown>>): ImportError {
254
+ return new ImportError(message, this.finish(), details);
255
+ }
256
+
257
+ /**
258
+ * Record a fatal parse error and abort: the issue is recorded (even beyond the error limit)
259
+ * and an ImportError carrying the report is thrown.
260
+ * @param code - a stable code such as "E_INVALID_UTF8"
261
+ * @param message - a plain-ASCII message
262
+ * @param where - the line and element, when known
263
+ * @param details - optional machine-readable context for the ImportError
264
+ */
265
+ fail(code: string, message: string, where?: IssueLocation, details?: Readonly<Record<string, unknown>>): never {
266
+ const issue = makeIssue("parse-error", "error", code, message, where);
267
+ this.issueList.push(issue);
268
+ this.errors++;
269
+ if (this.errors > this.errorLimit) {
270
+ this.truncatedFlag = true;
271
+ }
272
+ throw this.abort(message, { code, ...details });
273
+ }
274
+
275
+ /**
276
+ * The report as it stands: a frozen snapshot with the parse duration so far. Can be called more
277
+ * than once; each call reflects everything recorded up to that point.
278
+ * @returns the report
279
+ */
280
+ finish(): ImportReport {
281
+ const { nodes, edges, skippedNodes, skippedEdges, expandedMixed } = this.counts;
282
+ return Object.freeze({
283
+ format: this.format,
284
+ counts: Object.freeze({ nodes, edges, skippedNodes, skippedEdges, expandedMixed }),
285
+ issues: Object.freeze([...this.issueList]),
286
+ errorCount: this.errors,
287
+ warningCount: this.warnings,
288
+ truncated: this.truncatedFlag,
289
+ lossy: Object.freeze([...this.lossList]),
290
+ durationMs: now() - this.startedAt,
291
+ });
292
+ }
293
+ }
294
+
295
+ /**
296
+ * Build a frozen ImportIssue.
297
+ * @param category - the category
298
+ * @param severity - error or warning
299
+ * @param code - the stable code
300
+ * @param message - the message
301
+ * @param where - the location, when known
302
+ * @returns the issue
303
+ */
304
+ function makeIssue(
305
+ category: IssueCategory,
306
+ severity: "error" | "warning",
307
+ code: string,
308
+ message: string,
309
+ where: IssueLocation | undefined,
310
+ ): ImportIssue {
311
+ return Object.freeze({
312
+ category,
313
+ severity,
314
+ code,
315
+ message,
316
+ line: where?.line ?? null,
317
+ element: where?.element ?? null,
318
+ });
319
+ }
320
+
321
+ /**
322
+ * Whether a thrown value is an abort reason (the DOMException or Error named "AbortError" that
323
+ * AbortSignal.reason holds), which an importer must let through untouched.
324
+ * @param err - the thrown value
325
+ * @returns true for an AbortError
326
+ */
327
+ export function isAbortError(err: unknown): boolean {
328
+ return typeof err === "object" && err !== null && (err as { name?: unknown }).name === "AbortError";
329
+ }
330
+
331
+ /**
332
+ * The message of a thrown value: an Error's message, a string as is, anything else described by type.
333
+ * @param err - the thrown value
334
+ * @returns a plain message
335
+ */
336
+ export function messageOf(err: unknown): string {
337
+ if (err instanceof Error) {
338
+ return err.message;
339
+ }
340
+ if (typeof err === "string") {
341
+ return err;
342
+ }
343
+ return `non-error thrown (${typeof err})`;
344
+ }
345
+
346
+ /**
347
+ * A monotonic millisecond clock: performance.now() where it exists, Date.now() otherwise.
348
+ * @returns milliseconds
349
+ */
350
+ function now(): number {
351
+ return typeof performance === "object" ? performance.now() : Date.now();
352
+ }
@@ -0,0 +1,302 @@
1
+ /**
2
+ * Temporal values (design section 5.1): `date` / `dateTime` attributes, GEXF time bounds and Neo4j
3
+ * temporal types become f64 epoch milliseconds (or the raw numeric time when the file's timeformat
4
+ * is integer / double). When a value's canonical re-formatting would differ from the source text (a
5
+ * UTC offset, fractional seconds beyond milliseconds, a date without a time in a dateTime column)
6
+ * the importer also keeps the lexical form in a companion `<column>.text` column with role
7
+ * `timeText`; the exporter emits the text when present and formats the number otherwise.
8
+ *
9
+ * Only ISO-8601 forms are parsed (XSD date / dateTime / time and Neo4j's literals), by a fixed
10
+ * grammar rather than Date.parse, so two hosts agree on every value.
11
+ */
12
+
13
+ import { type ColumnDecl, type ColumnRole, GraphFormatError } from "@graphty/graph-format";
14
+
15
+ /** The temporal kinds a declared type can map to; each fixes the parse grammar and the canonical text. */
16
+ export type TemporalKind = "date" | "dateTime" | "localDateTime" | "time" | "localTime";
17
+
18
+ /**
19
+ * The GEXF `timeformat` values (design section 5.9).
20
+ * Consumed by the per-format importers and exporters under src/formats.
21
+ * @public
22
+ */
23
+ export type TimeFormat = "integer" | "double" | "date" | "dateTime";
24
+
25
+ /** A parsed temporal value: the number stored in the column and the source text when it must be kept. */
26
+ export interface TemporalValue {
27
+ /** Epoch milliseconds (date, dateTime, localDateTime), milliseconds since midnight (time, localTime), or the raw number. */
28
+ readonly value: number;
29
+ /** The source text when formatTemporal(value) would not reproduce it; null otherwise. */
30
+ readonly text: string | null;
31
+ }
32
+
33
+ /** The role of a companion text column. */
34
+ export const TIME_TEXT_ROLE: ColumnRole = "timeText";
35
+
36
+ /** The suffix of a companion text column's name. */
37
+ export const TIME_TEXT_SUFFIX = ".text";
38
+
39
+ const DATE_TIME =
40
+ /^(-?[0-9]{4,})-([0-9]{2})-([0-9]{2})(?:[T ]([0-9]{2}):([0-9]{2})(?::([0-9]{2})(?:\.([0-9]{1,9}))?)?)?(Z|z|[+-][0-9]{2}(?::?[0-9]{2})?)?$/;
41
+ const TIME = /^([0-9]{2}):([0-9]{2})(?::([0-9]{2})(?:\.([0-9]{1,9}))?)?(Z|z|[+-][0-9]{2}(?::?[0-9]{2})?)?$/;
42
+ const NUMERIC = /^[+-]?([0-9]+(\.[0-9]*)?|\.[0-9]+)([eE][+-]?[0-9]+)?$/;
43
+
44
+ const MS_PER_MINUTE = 60_000;
45
+ const MS_PER_DAY = 86_400_000;
46
+
47
+ /**
48
+ * Parse an ISO-8601 temporal text of one kind.
49
+ * @param text - the source text (already trimmed)
50
+ * @param kind - the temporal kind
51
+ * @returns the value and, when the canonical form differs, the source text; E_COLUMN_TYPE when the text is not of the kind
52
+ */
53
+ export function parseTemporal(text: string, kind: TemporalKind): TemporalValue {
54
+ let value: number;
55
+ switch (kind) {
56
+ case "date":
57
+ case "dateTime":
58
+ case "localDateTime":
59
+ value = parseDateTime(text, kind);
60
+ break;
61
+ case "time":
62
+ case "localTime":
63
+ value = parseTime(text, kind);
64
+ break;
65
+ default: {
66
+ const name: string = kind;
67
+ throw new GraphFormatError("E_COLUMN_TYPE", `unknown temporal kind ${name}`, { kind: name });
68
+ }
69
+ }
70
+ const canonical = formatTemporal(value, kind);
71
+ return { value, text: canonical === text ? null : text };
72
+ }
73
+
74
+ /**
75
+ * Parse a GEXF time bound (`start`, `end`, `timestamp`, a spell or an attvalue time) under the
76
+ * graph's timeformat: integer / double values are numbers as written; date / dateTime values are
77
+ * ISO text; when the format is unknown (absent header) numeric text is a number and anything else
78
+ * is tried as dateTime.
79
+ * @param text - the source text
80
+ * @param timeFormat - the graph's timeformat, or null when the file declares none
81
+ * @returns the value and the source text when it must be kept
82
+ */
83
+ export function parseTimeText(text: string, timeFormat: TimeFormat | null): TemporalValue {
84
+ const trimmed = text.trim();
85
+ switch (timeFormat) {
86
+ case "integer":
87
+ case "double":
88
+ return parseNumericTime(trimmed);
89
+ case "date":
90
+ return parseTemporal(trimmed, "date");
91
+ case "dateTime":
92
+ return parseTemporal(trimmed, "dateTime");
93
+ case null:
94
+ return NUMERIC.test(trimmed) ? parseNumericTime(trimmed) : parseTemporal(trimmed, "dateTime");
95
+ default: {
96
+ const name: string = timeFormat;
97
+ throw new GraphFormatError("E_COLUMN_TYPE", `unknown timeformat ${name}`, { timeFormat: name });
98
+ }
99
+ }
100
+ }
101
+
102
+ /**
103
+ * Format a temporal value in its canonical ISO-8601 form.
104
+ * @param value - the number as stored
105
+ * @param kind - the temporal kind
106
+ * @returns the canonical text: `YYYY-MM-DD`, `YYYY-MM-DDTHH:MM:SS[.mmm]Z`, `YYYY-MM-DDTHH:MM:SS[.mmm]`,
107
+ * `HH:MM:SS[.mmm]Z` or `HH:MM:SS[.mmm]`
108
+ */
109
+ export function formatTemporal(value: number, kind: TemporalKind): string {
110
+ switch (kind) {
111
+ case "date": {
112
+ const d = new Date(value);
113
+ return `${year(d)}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
114
+ }
115
+ case "dateTime":
116
+ case "localDateTime": {
117
+ const d = new Date(value);
118
+ const date = `${year(d)}-${pad2(d.getUTCMonth() + 1)}-${pad2(d.getUTCDate())}`;
119
+ const time = clock(d.getUTCHours(), d.getUTCMinutes(), d.getUTCSeconds(), d.getUTCMilliseconds());
120
+ return kind === "dateTime" ? `${date}T${time}Z` : `${date}T${time}`;
121
+ }
122
+ case "time":
123
+ case "localTime": {
124
+ const ms = ((value % MS_PER_DAY) + MS_PER_DAY) % MS_PER_DAY;
125
+ const h = Math.floor(ms / 3_600_000);
126
+ const m = Math.floor((ms % 3_600_000) / MS_PER_MINUTE);
127
+ const s = Math.floor((ms % MS_PER_MINUTE) / 1000);
128
+ const text = clock(h, m, s, ms % 1000);
129
+ return kind === "time" ? `${text}Z` : text;
130
+ }
131
+ default: {
132
+ const name: string = kind;
133
+ throw new GraphFormatError("E_COLUMN_TYPE", `unknown temporal kind ${name}`, { kind: name });
134
+ }
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Format a GEXF time bound under the graph's timeformat: the number's shortest text for integer /
140
+ * double, the canonical ISO form otherwise.
141
+ * @param value - the value as stored
142
+ * @param timeFormat - the graph's timeformat, or null for a file that declares none (double)
143
+ * @returns the text
144
+ */
145
+ export function formatTimeValue(value: number, timeFormat: TimeFormat | null): string {
146
+ switch (timeFormat) {
147
+ case "date":
148
+ return formatTemporal(value, "date");
149
+ case "dateTime":
150
+ return formatTemporal(value, "dateTime");
151
+ case "integer":
152
+ case "double":
153
+ case null:
154
+ return String(value);
155
+ default: {
156
+ const name: string = timeFormat;
157
+ throw new GraphFormatError("E_COLUMN_TYPE", `unknown timeformat ${name}`, { timeFormat: name });
158
+ }
159
+ }
160
+ }
161
+
162
+ /**
163
+ * The declaration of the companion text column of a temporal column (design section 5.1).
164
+ * @param column - the temporal column's name
165
+ * @returns a string column named `<column>.text` with role timeText and `extra.for` naming the column
166
+ */
167
+ export function timeTextCompanion(column: string): ColumnDecl {
168
+ return {
169
+ name: column + TIME_TEXT_SUFFIX,
170
+ dtype: "string",
171
+ role: TIME_TEXT_ROLE,
172
+ nullable: true,
173
+ extra: { for: column },
174
+ };
175
+ }
176
+
177
+ /**
178
+ * Parse a numeric time bound.
179
+ * @param text - the trimmed source text
180
+ * @returns the number; the text is kept when String(value) differs from it
181
+ */
182
+ function parseNumericTime(text: string): TemporalValue {
183
+ if (!NUMERIC.test(text)) {
184
+ throw new GraphFormatError("E_COLUMN_TYPE", `"${text}" is not a numeric time`, { value: text });
185
+ }
186
+ const value = Number(text);
187
+ return { value, text: String(value) === text ? null : text };
188
+ }
189
+
190
+ /**
191
+ * Parse a date or date-time text into epoch milliseconds.
192
+ * @param text - the source text
193
+ * @param kind - date (a time part is accepted and kept in the companion), dateTime or localDateTime
194
+ * @returns epoch milliseconds
195
+ */
196
+ function parseDateTime(text: string, kind: "date" | "dateTime" | "localDateTime"): number {
197
+ const m = DATE_TIME.exec(text);
198
+ if (m === null) {
199
+ throw new GraphFormatError("E_COLUMN_TYPE", `"${text}" is not an ISO-8601 ${kind}`, { value: text, kind });
200
+ }
201
+ const y = Number(m[1]);
202
+ const month = Number(m[2]);
203
+ const day = Number(m[3]);
204
+ const hour = m[4] === undefined ? 0 : Number(m[4]);
205
+ const minute = m[5] === undefined ? 0 : Number(m[5]);
206
+ const second = m[6] === undefined ? 0 : Number(m[6]);
207
+ const ms = m[7] === undefined ? 0 : fractionMs(m[7]);
208
+ if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 24 || minute > 59 || second > 60) {
209
+ throw new GraphFormatError("E_COLUMN_TYPE", `"${text}" is out of range for a ${kind}`, { value: text, kind });
210
+ }
211
+ // Date.UTC maps years 0..99 to 1900..1999; the setters do not
212
+ const d = new Date(0);
213
+ d.setUTCFullYear(y, month - 1, day);
214
+ d.setUTCHours(hour, minute, second, ms);
215
+ if (d.getUTCDate() !== day && hour !== 24) {
216
+ throw new GraphFormatError("E_COLUMN_TYPE", `"${text}" is not a calendar date`, { value: text, kind });
217
+ }
218
+ const offset = m[8] === undefined ? 0 : offsetMinutes(m[8]);
219
+ return d.getTime() - offset * MS_PER_MINUTE;
220
+ }
221
+
222
+ /**
223
+ * Parse a time-of-day text into milliseconds since midnight (UTC for `time` with a zone).
224
+ * @param text - the source text
225
+ * @param kind - time or localTime
226
+ * @returns milliseconds since midnight in 0..86400000
227
+ */
228
+ function parseTime(text: string, kind: "time" | "localTime"): number {
229
+ const m = TIME.exec(text);
230
+ if (m === null) {
231
+ throw new GraphFormatError("E_COLUMN_TYPE", `"${text}" is not an ISO-8601 ${kind}`, { value: text, kind });
232
+ }
233
+ const hour = Number(m[1]);
234
+ const minute = Number(m[2]);
235
+ const second = m[3] === undefined ? 0 : Number(m[3]);
236
+ const ms = m[4] === undefined ? 0 : fractionMs(m[4]);
237
+ if (hour > 24 || minute > 59 || second > 60) {
238
+ throw new GraphFormatError("E_COLUMN_TYPE", `"${text}" is out of range for a ${kind}`, { value: text, kind });
239
+ }
240
+ const local = ((hour * 60 + minute) * 60 + second) * 1000 + ms;
241
+ const offset = m[5] === undefined ? 0 : offsetMinutes(m[5]);
242
+ const utc = local - offset * MS_PER_MINUTE;
243
+ return ((utc % MS_PER_DAY) + MS_PER_DAY) % MS_PER_DAY;
244
+ }
245
+
246
+ /**
247
+ * Milliseconds of a fractional-second text (1 to 9 digits), truncated to whole milliseconds.
248
+ * @param digits - the digits after the decimal point
249
+ * @returns 0..999
250
+ */
251
+ function fractionMs(digits: string): number {
252
+ return Math.floor(Number(`0.${digits}`) * 1000);
253
+ }
254
+
255
+ /**
256
+ * The minutes of a zone designator.
257
+ * @param zone - "Z", "+HH:MM", "+HHMM" or "+HH"
258
+ * @returns signed minutes east of UTC
259
+ */
260
+ function offsetMinutes(zone: string): number {
261
+ if (zone === "Z" || zone === "z") {
262
+ return 0;
263
+ }
264
+ const sign = zone.startsWith("-") ? -1 : 1;
265
+ const digits = zone.slice(1).replace(":", "");
266
+ const hours = Number(digits.slice(0, 2));
267
+ const minutes = digits.length > 2 ? Number(digits.slice(2, 4)) : 0;
268
+ return sign * (hours * 60 + minutes);
269
+ }
270
+
271
+ /**
272
+ * Two-digit zero-padded text.
273
+ * @param n - 0..99
274
+ * @returns the text
275
+ */
276
+ function pad2(n: number): string {
277
+ return n < 10 ? `0${n}` : String(n);
278
+ }
279
+
280
+ /**
281
+ * The year of a UTC date as at least four digits (negative years keep their sign).
282
+ * @param d - the date
283
+ * @returns the text
284
+ */
285
+ function year(d: Date): string {
286
+ const y = d.getUTCFullYear();
287
+ const abs = String(Math.abs(y)).padStart(4, "0");
288
+ return y < 0 ? `-${abs}` : abs;
289
+ }
290
+
291
+ /**
292
+ * `HH:MM:SS` with `.mmm` appended when the milliseconds are not zero.
293
+ * @param h - hours
294
+ * @param m - minutes
295
+ * @param s - seconds
296
+ * @param ms - milliseconds
297
+ * @returns the text
298
+ */
299
+ function clock(h: number, m: number, s: number, ms: number): string {
300
+ const base = `${pad2(h)}:${pad2(m)}:${pad2(s)}`;
301
+ return ms === 0 ? base : `${base}.${String(ms).padStart(3, "0")}`;
302
+ }