@graphty/graph-io 0.3.40 → 0.3.42

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 (210) hide show
  1. package/README.md +2 -0
  2. package/dist/chunks/{children-CL3Cy0ez.js → children-CA4coBi0.js} +36 -27
  3. package/dist/chunks/children-CA4coBi0.js.map +1 -0
  4. package/dist/chunks/{choose-CS3fvGT1.js → choose-DvpbdRuz.js} +2 -2
  5. package/dist/chunks/{choose-CS3fvGT1.js.map → choose-DvpbdRuz.js.map} +1 -1
  6. package/dist/chunks/{constants-DqKZT6cH.js → constants-fT3E8JYY.js} +2 -2
  7. package/dist/chunks/{constants-DqKZT6cH.js.map → constants-fT3E8JYY.js.map} +1 -1
  8. package/dist/chunks/{escape-BJRLd3vJ.js → escape-B62ZGM_a.js} +2 -2
  9. package/dist/chunks/{escape-BJRLd3vJ.js.map → escape-B62ZGM_a.js.map} +1 -1
  10. package/dist/chunks/{export-B-aVCwyG.js → export-rKLeGQMo.js} +404 -243
  11. package/dist/chunks/export-rKLeGQMo.js.map +1 -0
  12. package/dist/chunks/{exporter-6ocL_9x_.js → exporter-8hLNemFk.js} +4 -4
  13. package/dist/chunks/{exporter-6ocL_9x_.js.map → exporter-8hLNemFk.js.map} +1 -1
  14. package/dist/chunks/{exporter-BVXnV9-9.js → exporter-BWwEuOPT.js} +29 -17
  15. package/dist/chunks/exporter-BWwEuOPT.js.map +1 -0
  16. package/dist/chunks/{exporter-Bcp-l4Bo.js → exporter-CpRyHZ3H.js} +574 -475
  17. package/dist/chunks/exporter-CpRyHZ3H.js.map +1 -0
  18. package/dist/chunks/{exporter-SFYkDzD0.js → exporter-yLxKBvLE.js} +23 -20
  19. package/dist/chunks/{exporter-SFYkDzD0.js.map → exporter-yLxKBvLE.js.map} +1 -1
  20. package/dist/chunks/{importer-DcKB97kW.js → importer-BbNb7eNg.js} +111 -71
  21. package/dist/chunks/importer-BbNb7eNg.js.map +1 -0
  22. package/dist/chunks/{importer-D_srHyj6.js → importer-C98dpSH4.js} +5 -5
  23. package/dist/chunks/{importer-D_srHyj6.js.map → importer-C98dpSH4.js.map} +1 -1
  24. package/dist/chunks/{importer-BJlGAOht.js → importer-D794XUTe.js} +3 -3
  25. package/dist/chunks/{importer-BJlGAOht.js.map → importer-D794XUTe.js.map} +1 -1
  26. package/dist/chunks/{importer-AjwOJrn2.js → importer-Dz7tI8Sa.js} +202 -181
  27. package/dist/chunks/importer-Dz7tI8Sa.js.map +1 -0
  28. package/dist/chunks/{importer-DEp_fYjf.js → importer-Ht7LNg8L.js} +92 -64
  29. package/dist/chunks/importer-Ht7LNg8L.js.map +1 -0
  30. package/dist/chunks/{importer-CPqnIY8m.js → importer-WrGRZb0N.js} +42 -29
  31. package/dist/chunks/importer-WrGRZb0N.js.map +1 -0
  32. package/dist/chunks/{importer-BpuS3J8C.js → importer-xZ-3suY1.js} +74 -61
  33. package/dist/chunks/importer-xZ-3suY1.js.map +1 -0
  34. package/dist/chunks/{json-elements-DpO6AXIc.js → json-elements-DKSqpNW1.js} +3 -3
  35. package/dist/chunks/{json-elements-DpO6AXIc.js.map → json-elements-DKSqpNW1.js.map} +1 -1
  36. package/dist/chunks/{ontology-export-50L9DOV4.js → ontology-export-CtRXjg_i.js} +2 -2
  37. package/dist/chunks/{ontology-export-50L9DOV4.js.map → ontology-export-CtRXjg_i.js.map} +1 -1
  38. package/dist/chunks/{records-r3SFHz11.js → records-Cu4bxYOm.js} +69 -36
  39. package/dist/chunks/records-Cu4bxYOm.js.map +1 -0
  40. package/dist/chunks/{text-Bjfa1xYL.js → text-DHOJXnED.js} +20 -11
  41. package/dist/chunks/text-DHOJXnED.js.map +1 -0
  42. package/dist/csv.js +323 -241
  43. package/dist/csv.js.map +1 -1
  44. package/dist/cx.js +1 -1
  45. package/dist/cx2.js +2 -2
  46. package/dist/cys.js +1 -1
  47. package/dist/dot.js +1 -1
  48. package/dist/gexf.js +195 -97
  49. package/dist/gexf.js.map +1 -1
  50. package/dist/gml.js +170 -149
  51. package/dist/gml.js.map +1 -1
  52. package/dist/graph-io.js +27 -27
  53. package/dist/graphml.js +2 -2
  54. package/dist/json.js +1 -1
  55. package/dist/neo4j.js +103 -65
  56. package/dist/neo4j.js.map +1 -1
  57. package/dist/obo.js +1 -1
  58. package/dist/pajek.js +1 -1
  59. package/dist/src/children.d.ts.map +1 -1
  60. package/dist/src/children.js +50 -27
  61. package/dist/src/children.js.map +1 -1
  62. package/dist/src/common/attributes.d.ts.map +1 -1
  63. package/dist/src/common/attributes.js +86 -42
  64. package/dist/src/common/attributes.js.map +1 -1
  65. package/dist/src/common/input.d.ts.map +1 -1
  66. package/dist/src/common/input.js +358 -184
  67. package/dist/src/common/input.js.map +1 -1
  68. package/dist/src/common/lists.js +30 -15
  69. package/dist/src/common/lists.js.map +1 -1
  70. package/dist/src/common/text.d.ts +7 -0
  71. package/dist/src/common/text.d.ts.map +1 -1
  72. package/dist/src/common/text.js +15 -7
  73. package/dist/src/common/text.js.map +1 -1
  74. package/dist/src/common/zip.d.ts.map +1 -1
  75. package/dist/src/common/zip.js +36 -19
  76. package/dist/src/common/zip.js.map +1 -1
  77. package/dist/src/formats/csv/exporter.d.ts.map +1 -1
  78. package/dist/src/formats/csv/exporter.js +288 -146
  79. package/dist/src/formats/csv/exporter.js.map +1 -1
  80. package/dist/src/formats/csv/importer.d.ts.map +1 -1
  81. package/dist/src/formats/csv/importer.js +129 -80
  82. package/dist/src/formats/csv/importer.js.map +1 -1
  83. package/dist/src/formats/csv/records.d.ts.map +1 -1
  84. package/dist/src/formats/csv/records.js +78 -36
  85. package/dist/src/formats/csv/records.js.map +1 -1
  86. package/dist/src/formats/cys/tables.d.ts.map +1 -1
  87. package/dist/src/formats/cys/tables.js +11 -8
  88. package/dist/src/formats/cys/tables.js.map +1 -1
  89. package/dist/src/formats/dot/tokenizer.d.ts +39 -0
  90. package/dist/src/formats/dot/tokenizer.d.ts.map +1 -1
  91. package/dist/src/formats/dot/tokenizer.js +112 -66
  92. package/dist/src/formats/dot/tokenizer.js.map +1 -1
  93. package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
  94. package/dist/src/formats/gexf/exporter.js +65 -37
  95. package/dist/src/formats/gexf/exporter.js.map +1 -1
  96. package/dist/src/formats/gexf/importer.d.ts.map +1 -1
  97. package/dist/src/formats/gexf/importer.js +138 -50
  98. package/dist/src/formats/gexf/importer.js.map +1 -1
  99. package/dist/src/formats/gml/exporter.d.ts.map +1 -1
  100. package/dist/src/formats/gml/exporter.js +51 -35
  101. package/dist/src/formats/gml/exporter.js.map +1 -1
  102. package/dist/src/formats/gml/importer.d.ts.map +1 -1
  103. package/dist/src/formats/gml/importer.js +103 -58
  104. package/dist/src/formats/gml/importer.js.map +1 -1
  105. package/dist/src/formats/gml/syntax.d.ts.map +1 -1
  106. package/dist/src/formats/gml/syntax.js +17 -8
  107. package/dist/src/formats/gml/syntax.js.map +1 -1
  108. package/dist/src/formats/graphml/exporter.d.ts.map +1 -1
  109. package/dist/src/formats/graphml/exporter.js +34 -24
  110. package/dist/src/formats/graphml/exporter.js.map +1 -1
  111. package/dist/src/formats/graphml/importer.d.ts.map +1 -1
  112. package/dist/src/formats/graphml/importer.js +17 -13
  113. package/dist/src/formats/graphml/importer.js.map +1 -1
  114. package/dist/src/formats/graphml/tree.js +23 -14
  115. package/dist/src/formats/graphml/tree.js.map +1 -1
  116. package/dist/src/formats/graphml/yfiles.d.ts.map +1 -1
  117. package/dist/src/formats/graphml/yfiles.js +22 -10
  118. package/dist/src/formats/graphml/yfiles.js.map +1 -1
  119. package/dist/src/formats/json/dialect.d.ts.map +1 -1
  120. package/dist/src/formats/json/dialect.js +54 -20
  121. package/dist/src/formats/json/dialect.js.map +1 -1
  122. package/dist/src/formats/json/exporter.d.ts.map +1 -1
  123. package/dist/src/formats/json/exporter.js +38 -21
  124. package/dist/src/formats/json/exporter.js.map +1 -1
  125. package/dist/src/formats/json/importer.d.ts.map +1 -1
  126. package/dist/src/formats/json/importer.js +536 -343
  127. package/dist/src/formats/json/importer.js.map +1 -1
  128. package/dist/src/formats/json/obographs.js +29 -17
  129. package/dist/src/formats/json/obographs.js.map +1 -1
  130. package/dist/src/formats/neo4j/exporter.d.ts.map +1 -1
  131. package/dist/src/formats/neo4j/exporter.js +73 -43
  132. package/dist/src/formats/neo4j/exporter.js.map +1 -1
  133. package/dist/src/formats/neo4j/header.d.ts.map +1 -1
  134. package/dist/src/formats/neo4j/header.js +37 -16
  135. package/dist/src/formats/neo4j/header.js.map +1 -1
  136. package/dist/src/formats/neo4j/importer.d.ts.map +1 -1
  137. package/dist/src/formats/neo4j/importer.js +21 -11
  138. package/dist/src/formats/neo4j/importer.js.map +1 -1
  139. package/dist/src/formats/obo/exporter.d.ts.map +1 -1
  140. package/dist/src/formats/obo/exporter.js +18 -7
  141. package/dist/src/formats/obo/exporter.js.map +1 -1
  142. package/dist/src/formats/pajek/exporter.d.ts.map +1 -1
  143. package/dist/src/formats/pajek/exporter.js +142 -73
  144. package/dist/src/formats/pajek/exporter.js.map +1 -1
  145. package/dist/src/formats/pajek/importer.d.ts.map +1 -1
  146. package/dist/src/formats/pajek/importer.js +26 -15
  147. package/dist/src/formats/pajek/importer.js.map +1 -1
  148. package/dist/src/formats/pajek/syntax.d.ts.map +1 -1
  149. package/dist/src/formats/pajek/syntax.js +85 -58
  150. package/dist/src/formats/pajek/syntax.js.map +1 -1
  151. package/dist/src/formats/xgmml/emit.d.ts +7 -0
  152. package/dist/src/formats/xgmml/emit.d.ts.map +1 -1
  153. package/dist/src/formats/xgmml/emit.js +79 -40
  154. package/dist/src/formats/xgmml/emit.js.map +1 -1
  155. package/dist/src/formats/xgmml/exporter.d.ts.map +1 -1
  156. package/dist/src/formats/xgmml/exporter.js +38 -12
  157. package/dist/src/formats/xgmml/exporter.js.map +1 -1
  158. package/dist/src/formats/xgmml/importer.d.ts.map +1 -1
  159. package/dist/src/formats/xgmml/importer.js +38 -18
  160. package/dist/src/formats/xgmml/importer.js.map +1 -1
  161. package/dist/src/sniff.js +109 -57
  162. package/dist/src/sniff.js.map +1 -1
  163. package/dist/xgmml.js +2 -2
  164. package/package.json +2 -2
  165. package/src/children.ts +54 -27
  166. package/src/common/attributes.ts +118 -49
  167. package/src/common/input.ts +439 -197
  168. package/src/common/lists.ts +29 -15
  169. package/src/common/text.ts +24 -15
  170. package/src/common/zip.ts +47 -18
  171. package/src/formats/csv/exporter.ts +377 -175
  172. package/src/formats/csv/importer.ts +155 -84
  173. package/src/formats/csv/records.ts +90 -35
  174. package/src/formats/cys/tables.ts +11 -10
  175. package/src/formats/dot/tokenizer.ts +118 -68
  176. package/src/formats/gexf/exporter.ts +67 -36
  177. package/src/formats/gexf/importer.ts +173 -63
  178. package/src/formats/gml/exporter.ts +78 -82
  179. package/src/formats/gml/importer.ts +119 -68
  180. package/src/formats/gml/syntax.ts +18 -8
  181. package/src/formats/graphml/exporter.ts +44 -24
  182. package/src/formats/graphml/importer.ts +18 -11
  183. package/src/formats/graphml/tree.ts +22 -12
  184. package/src/formats/graphml/yfiles.ts +23 -10
  185. package/src/formats/json/dialect.ts +61 -22
  186. package/src/formats/json/exporter.ts +40 -21
  187. package/src/formats/json/importer.ts +701 -385
  188. package/src/formats/json/obographs.ts +35 -15
  189. package/src/formats/neo4j/exporter.ts +79 -41
  190. package/src/formats/neo4j/header.ts +50 -16
  191. package/src/formats/neo4j/importer.ts +21 -10
  192. package/src/formats/obo/exporter.ts +34 -17
  193. package/src/formats/pajek/exporter.ts +180 -117
  194. package/src/formats/pajek/importer.ts +27 -15
  195. package/src/formats/pajek/syntax.ts +99 -58
  196. package/src/formats/xgmml/emit.ts +88 -40
  197. package/src/formats/xgmml/exporter.ts +40 -12
  198. package/src/formats/xgmml/importer.ts +45 -23
  199. package/src/sniff.ts +115 -59
  200. package/dist/chunks/children-CL3Cy0ez.js.map +0 -1
  201. package/dist/chunks/export-B-aVCwyG.js.map +0 -1
  202. package/dist/chunks/exporter-BVXnV9-9.js.map +0 -1
  203. package/dist/chunks/exporter-Bcp-l4Bo.js.map +0 -1
  204. package/dist/chunks/importer-AjwOJrn2.js.map +0 -1
  205. package/dist/chunks/importer-BpuS3J8C.js.map +0 -1
  206. package/dist/chunks/importer-CPqnIY8m.js.map +0 -1
  207. package/dist/chunks/importer-DEp_fYjf.js.map +0 -1
  208. package/dist/chunks/importer-DcKB97kW.js.map +0 -1
  209. package/dist/chunks/records-r3SFHz11.js.map +0 -1
  210. package/dist/chunks/text-Bjfa1xYL.js.map +0 -1
@@ -177,129 +177,192 @@ interface PlannedColumn {
177
177
  */
178
178
  function plan(table: Iterable<Column>, domain: "node" | "edge", notes: LossNote[] | null): PlannedColumn[] {
179
179
  const planned: PlannedColumn[] = [];
180
- const rows = (column: Column): number => column.length - column.nullCount;
181
180
  for (const column of table) {
182
- const { meta } = column;
183
- const { role, name, dtype } = meta;
184
- if (role === "label" && domain === "node") {
185
- if (dtype !== "string" && dtype !== "dict" && notes !== null) {
186
- notes.push(
187
- note(
188
- PAJEK_LOSS.LABEL_AS_TEXT,
189
- `node column "${name}" (label) is ${dtype}; labels are text and re-import as string`,
190
- name,
191
- rows(column),
192
- ),
193
- );
194
- }
195
- planned.push({ column, slot: "label" });
196
- continue;
197
- }
198
- if (role === "position") {
199
- if (domain === "node" && (meta.components === 2 || meta.components === 3)) {
200
- planned.push({ column, slot: "position" });
201
- } else if (notes !== null && domain === "node") {
202
- notes.push(
203
- note(
204
- PAJEK_LOSS.POSITION_STRIDE,
205
- `node column "${name}" (position) has ${meta.components} components; Pajek coordinates are x y [z]`,
206
- name,
207
- rows(column),
208
- ),
209
- );
210
- } else if (notes !== null) {
211
- notes.push(
212
- note(LOSS.POSITIONS, `edge column "${name}" (position) cannot be written`, name, rows(column)),
213
- );
214
- }
215
- continue;
216
- }
217
- if (role === "spells") {
218
- if (
219
- dtype === "list" &&
220
- (meta.itemDtype === "f64" || meta.itemDtype === "i32" || meta.itemDtype === "f32")
221
- ) {
222
- planned.push({ column, slot: "spells" });
223
- continue;
224
- }
225
- if (notes !== null) {
226
- notes.push(
227
- note(
228
- LOSS.SPELLS,
229
- `${domain} column "${name}" (spells) is not a list of number pairs; it cannot be written`,
230
- name,
231
- rows(column),
232
- ),
233
- );
234
- }
235
- continue;
181
+ const slot = columnSlot(column, domain, notes);
182
+ if (slot !== null) {
183
+ planned.push({ column, slot });
236
184
  }
237
- if (role !== null && DROPPED_TEMPORAL_ROLES.has(role)) {
238
- if (notes !== null) {
239
- notes.push(
240
- note(
241
- PAJEK_LOSS.TEMPORAL_DROPPED,
242
- `${domain} column "${name}" (${role}) cannot be written; Pajek intervals come from the spells role`,
243
- name,
244
- rows(column),
245
- ),
246
- );
247
- }
248
- continue;
249
- }
250
- if (role !== null && (STRUCTURAL_ROLES.has(role) || CHECKED_ROLES.has(role))) {
251
- // structural columns are folded into the topology; viz and hierarchy roles are reported by the generic checker
252
- continue;
253
- }
254
- if (domain === "edge" && role === "id") {
255
- continue;
256
- }
257
- if (dtype === "list" || dtype === "json") {
258
- // reported by the generic checker (lists: false, json: false)
259
- continue;
260
- }
261
- if (domain === "node" && name === SHAPE_COLUMN && (dtype === "dict" || dtype === "string")) {
262
- if (allShapeKeywords(column)) {
263
- planned.push({ column, slot: "shape" });
264
- continue;
265
- }
266
- // a value outside the keywords: the whole column is a `shape "<text>"` parameter, which the
267
- // importer reads back as one string column (a change for a dict column only)
268
- if (notes !== null && dtype === "dict") {
269
- notes.push(
270
- note(
271
- PAJEK_LOSS.SHAPE_AS_PARAMETER,
272
- `node column "${name}" holds values outside the Pajek shape keywords; it is written as a parameter and reads back as string`,
273
- name,
274
- rows(column),
275
- ),
276
- );
277
- }
278
- planned.push({ column, slot: "param" });
279
- continue;
280
- }
281
- if (domain === "edge" && name === RELATION_COLUMN && (dtype === "dict" || dtype === "string")) {
282
- planned.push({ column, slot: "relation" });
283
- continue;
284
- }
285
- if (!isParameterKey(name)) {
286
- if (notes !== null) {
287
- notes.push(
288
- note(
289
- PAJEK_LOSS.KEY_DROPPED,
290
- `${domain} column "${name}" cannot be a Pajek parameter key; it is not written`,
291
- name,
292
- rows(column),
293
- ),
294
- );
295
- }
296
- continue;
297
- }
298
- planned.push({ column, slot: "param" });
299
185
  }
300
186
  return planned;
301
187
  }
302
188
 
189
+ /**
190
+ * Record a note about a whole column, counting its set values; nothing when writing.
191
+ * @param notes - the note list check() fills; null when writing
192
+ * @param code - the code
193
+ * @param message - the message
194
+ * @param column - the column the note is about
195
+ */
196
+ function columnNote(notes: LossNote[] | null, code: string, message: string, column: Column): void {
197
+ notes?.push(note(code, message, column.meta.name, column.length - column.nullCount));
198
+ }
199
+
200
+ /**
201
+ * The slot of one column, recording a note for a column that is not written as it is.
202
+ * @param column - the column
203
+ * @param domain - node or edge
204
+ * @param notes - the note list check() fills; null when writing
205
+ * @returns the slot, or null when the column is not written
206
+ */
207
+ function columnSlot(column: Column, domain: "node" | "edge", notes: LossNote[] | null): Slot | null {
208
+ const { role, name } = column.meta;
209
+ if (role === "label" && domain === "node") {
210
+ return labelSlot(column, notes);
211
+ }
212
+ if (role === "position") {
213
+ return positionSlot(column, domain, notes);
214
+ }
215
+ if (role === "spells") {
216
+ return spellsSlot(column, domain, notes);
217
+ }
218
+ if (role !== null && DROPPED_TEMPORAL_ROLES.has(role)) {
219
+ columnNote(
220
+ notes,
221
+ PAJEK_LOSS.TEMPORAL_DROPPED,
222
+ `${domain} column "${name}" (${role}) cannot be written; Pajek intervals come from the spells role`,
223
+ column,
224
+ );
225
+ return null;
226
+ }
227
+ if (writtenElsewhere(column, domain)) {
228
+ return null;
229
+ }
230
+ return namedSlot(column, domain, notes);
231
+ }
232
+
233
+ /**
234
+ * The node label column: the label slot, noting a label that is not text.
235
+ * @param column - the label role column
236
+ * @param notes - the note list check() fills; null when writing
237
+ * @returns the label slot
238
+ */
239
+ function labelSlot(column: Column, notes: LossNote[] | null): Slot {
240
+ const { name, dtype } = column.meta;
241
+ if (dtype !== "string" && dtype !== "dict") {
242
+ columnNote(
243
+ notes,
244
+ PAJEK_LOSS.LABEL_AS_TEXT,
245
+ `node column "${name}" (label) is ${dtype}; labels are text and re-import as string`,
246
+ column,
247
+ );
248
+ }
249
+ return "label";
250
+ }
251
+
252
+ /**
253
+ * A position role column: the coordinates of a node with 2 or 3 components, else not written.
254
+ * @param column - the position role column
255
+ * @param domain - node or edge
256
+ * @param notes - the note list check() fills; null when writing
257
+ * @returns the position slot, or null
258
+ */
259
+ function positionSlot(column: Column, domain: "node" | "edge", notes: LossNote[] | null): Slot | null {
260
+ const { name, components } = column.meta;
261
+ if (domain === "edge") {
262
+ columnNote(notes, LOSS.POSITIONS, `edge column "${name}" (position) cannot be written`, column);
263
+ return null;
264
+ }
265
+ if (components === 2 || components === 3) {
266
+ return "position";
267
+ }
268
+ columnNote(
269
+ notes,
270
+ PAJEK_LOSS.POSITION_STRIDE,
271
+ `node column "${name}" (position) has ${components} components; Pajek coordinates are x y [z]`,
272
+ column,
273
+ );
274
+ return null;
275
+ }
276
+
277
+ /**
278
+ * A spells role column: the interval slot for a list of numbers, else not written.
279
+ * @param column - the spells role column
280
+ * @param domain - node or edge
281
+ * @param notes - the note list check() fills; null when writing
282
+ * @returns the spells slot, or null
283
+ */
284
+ function spellsSlot(column: Column, domain: "node" | "edge", notes: LossNote[] | null): Slot | null {
285
+ const { name, dtype, itemDtype } = column.meta;
286
+ if (dtype === "list" && (itemDtype === "f64" || itemDtype === "i32" || itemDtype === "f32")) {
287
+ return "spells";
288
+ }
289
+ columnNote(
290
+ notes,
291
+ LOSS.SPELLS,
292
+ `${domain} column "${name}" (spells) is not a list of number pairs; it cannot be written`,
293
+ column,
294
+ );
295
+ return null;
296
+ }
297
+
298
+ /**
299
+ * Whether a column is folded into the topology, reported by the generic checker, or an edge id,
300
+ * so it is never a parameter.
301
+ * @param column - the column
302
+ * @param domain - node or edge
303
+ * @returns true when the column has no slot of its own
304
+ */
305
+ function writtenElsewhere(column: Column, domain: "node" | "edge"): boolean {
306
+ const { role, dtype } = column.meta;
307
+ // structural columns are folded into the topology; viz and hierarchy roles, lists and json are
308
+ // reported by the generic checker (lists: false, json: false)
309
+ if (role !== null && (STRUCTURAL_ROLES.has(role) || CHECKED_ROLES.has(role))) {
310
+ return true;
311
+ }
312
+ return (domain === "edge" && role === "id") || dtype === "list" || dtype === "json";
313
+ }
314
+
315
+ /**
316
+ * The slot of a column written by its name: the shape keyword, the relation name, or a parameter.
317
+ * @param column - the column
318
+ * @param domain - node or edge
319
+ * @param notes - the note list check() fills; null when writing
320
+ * @returns the slot, or null when the name cannot be a parameter key
321
+ */
322
+ function namedSlot(column: Column, domain: "node" | "edge", notes: LossNote[] | null): Slot | null {
323
+ const { name, dtype } = column.meta;
324
+ const text = dtype === "dict" || dtype === "string";
325
+ if (text && domain === "node" && name === SHAPE_COLUMN) {
326
+ return shapeSlot(column, notes);
327
+ }
328
+ if (text && domain === "edge" && name === RELATION_COLUMN) {
329
+ return "relation";
330
+ }
331
+ if (!isParameterKey(name)) {
332
+ columnNote(
333
+ notes,
334
+ PAJEK_LOSS.KEY_DROPPED,
335
+ `${domain} column "${name}" cannot be a Pajek parameter key; it is not written`,
336
+ column,
337
+ );
338
+ return null;
339
+ }
340
+ return "param";
341
+ }
342
+
343
+ /**
344
+ * A text node column named `shape`: the shape slot when every value is a keyword, else the whole
345
+ * column is a `shape "<text>"` parameter, which the importer reads back as one string column (a
346
+ * change for a dict column only).
347
+ * @param column - the shape column (string or dict)
348
+ * @param notes - the note list check() fills; null when writing
349
+ * @returns the shape or param slot
350
+ */
351
+ function shapeSlot(column: Column, notes: LossNote[] | null): Slot {
352
+ if (allShapeKeywords(column)) {
353
+ return "shape";
354
+ }
355
+ if (column.meta.dtype === "dict") {
356
+ columnNote(
357
+ notes,
358
+ PAJEK_LOSS.SHAPE_AS_PARAMETER,
359
+ `node column "${column.meta.name}" holds values outside the Pajek shape keywords; it is written as a parameter and reads back as string`,
360
+ column,
361
+ );
362
+ }
363
+ return "param";
364
+ }
365
+
303
366
  /**
304
367
  * The notes about vertex labels: Pajek's grammar puts the label second, so a vertex line that
305
368
  * carries coordinates, a shape, parameters or an interval needs one, and a node without a label
@@ -686,21 +686,7 @@ class PajekParser {
686
686
  this.section = "none";
687
687
  return;
688
688
  case "vertices":
689
- if (afterObjectHeader) {
690
- // the `*Vertices N` line of a project object (*Partition, *Vector, a skipped
691
- // section), wherever it stands: never the network's own
692
- this.section = previous;
693
- if (previous === "object") {
694
- this.objects[this.objects.length - 1].count = h.count;
695
- }
696
- return;
697
- }
698
- if (this.vertexCount >= 0) {
699
- // a second `*Vertices` without a `*Network` header starts the next network
700
- this.ended = true;
701
- return;
702
- }
703
- this.verticesHeader(h, line);
689
+ this.verticesSectionLine(h, line, previous, afterObjectHeader);
704
690
  return;
705
691
  case "arcs":
706
692
  case "edges":
@@ -739,6 +725,32 @@ class PajekParser {
739
725
  }
740
726
  }
741
727
 
728
+ /**
729
+ * A `*Vertices` line: a project object's own count, the start of the next network, or the
730
+ * network's vertices header.
731
+ * @param h - the header
732
+ * @param line - the line number
733
+ * @param previous - the section before this header
734
+ * @param afterObjectHeader - the previous line was a `*Partition`, `*Vector` or skipped section header
735
+ */
736
+ private verticesSectionLine(h: SectionHeader, line: number, previous: Section, afterObjectHeader: boolean): void {
737
+ if (afterObjectHeader) {
738
+ // the `*Vertices N` line of a project object (*Partition, *Vector, a skipped
739
+ // section), wherever it stands: never the network's own
740
+ this.section = previous;
741
+ if (previous === "object") {
742
+ this.objects[this.objects.length - 1].count = h.count;
743
+ }
744
+ return;
745
+ }
746
+ if (this.vertexCount >= 0) {
747
+ // a second `*Vertices` without a `*Network` header starts the next network
748
+ this.ended = true;
749
+ return;
750
+ }
751
+ this.verticesHeader(h, line);
752
+ }
753
+
742
754
  /**
743
755
  * `*Vertices N [N1]`.
744
756
  * @param h - the header
@@ -350,57 +350,88 @@ export function parseSectionHeader(line: string): SectionHeader | null {
350
350
  }
351
351
  const keyword = tokens[0];
352
352
  const kind = SECTION_KINDS.get(keyword.toLowerCase()) ?? "unsupported";
353
- let count: number | null = null;
354
- let secondCount: number | null = null;
355
- let relation: number | null = null;
356
- let name: string | null = null;
357
- const extra: string[] = [];
358
- let i = 1;
353
+ let args: HeaderArgs;
359
354
  switch (kind) {
360
355
  case "vertices":
361
- if (i < tokens.length && INTEGER_TEXT.test(tokens[i])) {
362
- count = Number(tokens[i]);
363
- i++;
364
- if (i < tokens.length && INTEGER_TEXT.test(tokens[i])) {
365
- secondCount = Number(tokens[i]);
366
- i++;
367
- }
368
- }
356
+ args = verticesArgs(tokens);
369
357
  break;
370
358
  case "arcs":
371
359
  case "edges":
372
360
  case "arcslist":
373
361
  case "edgeslist":
374
362
  case "matrix":
375
- if (i < tokens.length && tokens[i].startsWith(":") && INTEGER_TEXT.test(tokens[i].slice(1))) {
376
- relation = Number(tokens[i].slice(1));
377
- i++;
378
- if (i < tokens.length) {
379
- name = tokens[i];
380
- i++;
381
- }
382
- }
363
+ args = relationArgs(tokens);
383
364
  break;
384
365
  case "network":
385
366
  case "partition":
386
367
  case "vector":
387
- if (i < tokens.length) {
388
- name = tokens.slice(i).join(" ");
389
- i = tokens.length;
390
- }
368
+ args = nameArgs(tokens);
391
369
  break;
392
370
  case "unsupported":
393
- i = tokens.length;
371
+ args = { ...NO_ARGS, next: tokens.length };
394
372
  break;
395
373
  default: {
396
374
  const unknown: string = kind;
397
375
  throw new Error(`unknown section kind ${unknown}`);
398
376
  }
399
377
  }
400
- for (; i < tokens.length; i++) {
401
- extra.push(tokens[i]);
378
+ const { count, secondCount, relation, name, next } = args;
379
+ return { kind, keyword, count, secondCount, relation, name, extra: tokens.slice(next) };
380
+ }
381
+
382
+ /** The arguments a section header's keyword takes, and the index of the first token after them. */
383
+ interface HeaderArgs {
384
+ readonly count: number | null;
385
+ readonly secondCount: number | null;
386
+ readonly relation: number | null;
387
+ readonly name: string | null;
388
+ readonly next: number;
389
+ }
390
+
391
+ const NO_ARGS: HeaderArgs = { count: null, secondCount: null, relation: null, name: null, next: 1 };
392
+
393
+ /**
394
+ * `*Vertices N [N1]`: the vertex count and the optional first-mode count.
395
+ * @param tokens - the header tokens, the keyword first
396
+ * @returns the arguments
397
+ */
398
+ function verticesArgs(tokens: readonly string[]): HeaderArgs {
399
+ if (tokens.length < 2 || !INTEGER_TEXT.test(tokens[1])) {
400
+ return NO_ARGS;
401
+ }
402
+ const count = Number(tokens[1]);
403
+ if (tokens.length < 3 || !INTEGER_TEXT.test(tokens[2])) {
404
+ return { ...NO_ARGS, count, next: 2 };
402
405
  }
403
- return { kind, keyword, count, secondCount, relation, name, extra };
406
+ return { ...NO_ARGS, count, secondCount: Number(tokens[2]), next: 3 };
407
+ }
408
+
409
+ /**
410
+ * `*Arcs [:k ["name"]]` and the other line sections: the relation number and its name.
411
+ * @param tokens - the header tokens, the keyword first
412
+ * @returns the arguments
413
+ */
414
+ function relationArgs(tokens: readonly string[]): HeaderArgs {
415
+ if (tokens.length < 2 || !tokens[1].startsWith(":") || !INTEGER_TEXT.test(tokens[1].slice(1))) {
416
+ return NO_ARGS;
417
+ }
418
+ const relation = Number(tokens[1].slice(1));
419
+ if (tokens.length < 3) {
420
+ return { ...NO_ARGS, relation, next: 2 };
421
+ }
422
+ return { ...NO_ARGS, relation, name: tokens[2], next: 3 };
423
+ }
424
+
425
+ /**
426
+ * `*Network name`, `*Partition name`, `*Vector name`: every token after the keyword is the name.
427
+ * @param tokens - the header tokens, the keyword first
428
+ * @returns the arguments
429
+ */
430
+ function nameArgs(tokens: readonly string[]): HeaderArgs {
431
+ if (tokens.length < 2) {
432
+ return NO_ARGS;
433
+ }
434
+ return { ...NO_ARGS, name: tokens.slice(1).join(" "), next: tokens.length };
404
435
  }
405
436
 
406
437
  /**
@@ -447,38 +478,48 @@ export function parseIntervals(token: string): [number, number][] {
447
478
  }
448
479
  const spells: [number, number][] = [];
449
480
  for (const part of body.split(",")) {
450
- const match = TIME_POINT.exec(part.replaceAll(/\s+/g, ""));
451
- if (match === null) {
452
- throw new Error(`malformed time interval ${token}: "${part}" is not a-b, a-* or a`);
453
- }
454
- const [, startText, endText] = match;
455
- if (startText === "*" && endText === undefined) {
456
- throw new Error(`malformed time interval ${token}: "*" alone is not a time point`);
457
- }
458
- const start = startText === "*" ? -Infinity : Number(startText);
459
- let end: number;
460
- if (endText === undefined) {
461
- end = start;
462
- } else if (endText === "*") {
463
- end = Infinity;
464
- } else {
465
- end = Number(endText);
466
- }
467
- // only `*` means an open end: a numeral beyond the f64 range is not a time
468
- if (
469
- (startText !== "*" && !Number.isFinite(start)) ||
470
- (endText !== undefined && endText !== "*" && !Number.isFinite(end))
471
- ) {
472
- throw new Error(`malformed time interval ${token}: "${part}" is beyond the number range`);
473
- }
474
- if (start > end) {
475
- throw new Error(`malformed time interval ${token}: ${startText} is after ${endText ?? ""}`);
476
- }
477
- spells.push([start, end]);
481
+ spells.push(parseSpell(token, part));
478
482
  }
479
483
  return spells;
480
484
  }
481
485
 
486
+ /**
487
+ * One part of a Pajek time interval token: `a-b`, `a-*`, `*-b` or a single time point `a`.
488
+ * @param token - the whole token, for the error message
489
+ * @param part - the part between commas
490
+ * @returns the spell as a [start, end] pair
491
+ */
492
+ function parseSpell(token: string, part: string): [number, number] {
493
+ const match = TIME_POINT.exec(part.replaceAll(/\s+/g, ""));
494
+ if (match === null) {
495
+ throw new Error(`malformed time interval ${token}: "${part}" is not a-b, a-* or a`);
496
+ }
497
+ const [, startText, endText] = match;
498
+ if (startText === "*" && endText === undefined) {
499
+ throw new Error(`malformed time interval ${token}: "*" alone is not a time point`);
500
+ }
501
+ const start = startText === "*" ? -Infinity : Number(startText);
502
+ let end: number;
503
+ if (endText === undefined) {
504
+ end = start;
505
+ } else if (endText === "*") {
506
+ end = Infinity;
507
+ } else {
508
+ end = Number(endText);
509
+ }
510
+ // only `*` means an open end: a numeral beyond the f64 range is not a time
511
+ if (
512
+ (startText !== "*" && !Number.isFinite(start)) ||
513
+ (endText !== undefined && endText !== "*" && !Number.isFinite(end))
514
+ ) {
515
+ throw new Error(`malformed time interval ${token}: "${part}" is beyond the number range`);
516
+ }
517
+ if (start > end) {
518
+ throw new Error(`malformed time interval ${token}: ${startText} is after ${endText ?? ""}`);
519
+ }
520
+ return [start, end];
521
+ }
522
+
482
523
  // any length, so a reference beyond U+10FFFF is seen (and kept as written with a warning)
483
524
  const CHARACTER_REFERENCE = /&#(?:[xX]([\da-fA-F]+)|(\d+));/g;
484
525