@graphty/graph-io 0.3.27 → 0.3.29

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 (109) hide show
  1. package/dist/chunks/{choose-DrXtu_96.js → choose-DT5da6OC.js} +2 -2
  2. package/dist/chunks/{choose-DrXtu_96.js.map → choose-DT5da6OC.js.map} +1 -1
  3. package/dist/chunks/{constants-ikG85dT8.js → constants-DowRD9ai.js} +2 -2
  4. package/dist/chunks/{constants-ikG85dT8.js.map → constants-DowRD9ai.js.map} +1 -1
  5. package/dist/chunks/{escape-D1HPTUrP.js → escape-DQC5HWqF.js} +2 -2
  6. package/dist/chunks/{escape-D1HPTUrP.js.map → escape-DQC5HWqF.js.map} +1 -1
  7. package/dist/chunks/{export-CHd4tQE2.js → export-CVi12U7J.js} +81 -60
  8. package/dist/chunks/export-CVi12U7J.js.map +1 -0
  9. package/dist/chunks/{exporter-D23CKNqF.js → exporter-C3Pa2PSd.js} +5 -5
  10. package/dist/chunks/exporter-C3Pa2PSd.js.map +1 -0
  11. package/dist/chunks/{exporter-DfWTgrti.js → exporter-CgsO2nSl.js} +4 -4
  12. package/dist/chunks/{exporter-DfWTgrti.js.map → exporter-CgsO2nSl.js.map} +1 -1
  13. package/dist/chunks/{exporter-IHv4nkCm.js → exporter-Cy_1da4c.js} +72 -48
  14. package/dist/chunks/exporter-Cy_1da4c.js.map +1 -0
  15. package/dist/chunks/{exporter-DhtW6FxK.js → exporter-JubAlNWi.js} +34 -34
  16. package/dist/chunks/exporter-JubAlNWi.js.map +1 -0
  17. package/dist/chunks/{importer-CIAzt-wo.js → importer-BA_hZKo7.js} +4 -4
  18. package/dist/chunks/{importer-CIAzt-wo.js.map → importer-BA_hZKo7.js.map} +1 -1
  19. package/dist/chunks/{importer-B1wmPQjr.js → importer-CDXlxXlS.js} +35 -9
  20. package/dist/chunks/importer-CDXlxXlS.js.map +1 -0
  21. package/dist/chunks/{importer-BVRE--Un.js → importer-DLz2Vb4q.js} +3 -3
  22. package/dist/chunks/{importer-BVRE--Un.js.map → importer-DLz2Vb4q.js.map} +1 -1
  23. package/dist/chunks/{importer-B2W_58pz.js → importer-DVxzzibh.js} +4 -4
  24. package/dist/chunks/{importer-B2W_58pz.js.map → importer-DVxzzibh.js.map} +1 -1
  25. package/dist/chunks/{importer-BSALvkui.js → importer-Dej72hIF.js} +6 -6
  26. package/dist/chunks/importer-Dej72hIF.js.map +1 -0
  27. package/dist/chunks/{importer-z_FL4VMb.js → importer-lLH9gQF_.js} +5 -5
  28. package/dist/chunks/{importer-z_FL4VMb.js.map → importer-lLH9gQF_.js.map} +1 -1
  29. package/dist/chunks/{importer-DpEW5zeG.js → importer-q-R-Q98w.js} +4 -4
  30. package/dist/chunks/{importer-DpEW5zeG.js.map → importer-q-R-Q98w.js.map} +1 -1
  31. package/dist/chunks/{json-elements-BAmNBZok.js → json-elements-1IUzkRNy.js} +3 -3
  32. package/dist/chunks/{json-elements-BAmNBZok.js.map → json-elements-1IUzkRNy.js.map} +1 -1
  33. package/dist/chunks/{ontology-export-Bab4eUA8.js → ontology-export-b0GLNIj-.js} +2 -2
  34. package/dist/chunks/{ontology-export-Bab4eUA8.js.map → ontology-export-b0GLNIj-.js.map} +1 -1
  35. package/dist/chunks/{records-CdlvL4RO.js → records-CdyqwXeY.js} +2 -2
  36. package/dist/chunks/{records-CdlvL4RO.js.map → records-CdyqwXeY.js.map} +1 -1
  37. package/dist/chunks/{text-DzXjAiTl.js → text-CDgjbkLC.js} +2 -2
  38. package/dist/chunks/{text-DzXjAiTl.js.map → text-CDgjbkLC.js.map} +1 -1
  39. package/dist/csv.js +33 -16
  40. package/dist/csv.js.map +1 -1
  41. package/dist/cx.js +1 -1
  42. package/dist/cx2.js +2 -2
  43. package/dist/cys.js +1 -1
  44. package/dist/dot.js +1 -1
  45. package/dist/gexf.js +2 -2
  46. package/dist/gml.js +18 -9
  47. package/dist/gml.js.map +1 -1
  48. package/dist/graph-io.js +26 -26
  49. package/dist/graph-io.js.map +1 -1
  50. package/dist/graphml.js +2 -2
  51. package/dist/json.js +1 -1
  52. package/dist/neo4j.js +3 -3
  53. package/dist/obo.js +1 -1
  54. package/dist/pajek.js +1 -1
  55. package/dist/src/common/declared-types.d.ts.map +1 -1
  56. package/dist/src/common/declared-types.js.map +1 -1
  57. package/dist/src/common/export.d.ts +6 -0
  58. package/dist/src/common/export.d.ts.map +1 -1
  59. package/dist/src/common/export.js +62 -28
  60. package/dist/src/common/export.js.map +1 -1
  61. package/dist/src/common/zip.js.map +1 -1
  62. package/dist/src/formats/csv/exporter.d.ts.map +1 -1
  63. package/dist/src/formats/csv/exporter.js +18 -7
  64. package/dist/src/formats/csv/exporter.js.map +1 -1
  65. package/dist/src/formats/csv/importer.d.ts.map +1 -1
  66. package/dist/src/formats/csv/importer.js +20 -2
  67. package/dist/src/formats/csv/importer.js.map +1 -1
  68. package/dist/src/formats/cx2/exporter.d.ts.map +1 -1
  69. package/dist/src/formats/cx2/exporter.js +84 -33
  70. package/dist/src/formats/cx2/exporter.js.map +1 -1
  71. package/dist/src/formats/dot/exporter.js +30 -4
  72. package/dist/src/formats/dot/exporter.js.map +1 -1
  73. package/dist/src/formats/dot/importer.js.map +1 -1
  74. package/dist/src/formats/gml/exporter.d.ts.map +1 -1
  75. package/dist/src/formats/gml/exporter.js +23 -6
  76. package/dist/src/formats/gml/exporter.js.map +1 -1
  77. package/dist/src/formats/obo/syntax.d.ts +4 -0
  78. package/dist/src/formats/obo/syntax.d.ts.map +1 -1
  79. package/dist/src/formats/obo/syntax.js +35 -31
  80. package/dist/src/formats/obo/syntax.js.map +1 -1
  81. package/dist/src/formats/pajek/importer.js.map +1 -1
  82. package/dist/src/registry.d.ts.map +1 -1
  83. package/dist/src/registry.js.map +1 -1
  84. package/dist/src/sniff.d.ts.map +1 -1
  85. package/dist/src/sniff.js.map +1 -1
  86. package/dist/src/types.d.ts +6 -0
  87. package/dist/src/types.d.ts.map +1 -1
  88. package/dist/xgmml.js +2 -2
  89. package/package.json +2 -2
  90. package/src/common/declared-types.ts +1 -10
  91. package/src/common/export.ts +105 -62
  92. package/src/common/zip.ts +1 -1
  93. package/src/formats/csv/exporter.ts +19 -15
  94. package/src/formats/csv/importer.ts +21 -2
  95. package/src/formats/cx2/exporter.ts +123 -49
  96. package/src/formats/dot/exporter.ts +37 -4
  97. package/src/formats/dot/importer.ts +1 -1
  98. package/src/formats/gml/exporter.ts +24 -6
  99. package/src/formats/obo/syntax.ts +35 -31
  100. package/src/formats/pajek/importer.ts +1 -1
  101. package/src/registry.ts +9 -10
  102. package/src/sniff.ts +2 -21
  103. package/src/types.ts +7 -7
  104. package/dist/chunks/export-CHd4tQE2.js.map +0 -1
  105. package/dist/chunks/exporter-D23CKNqF.js.map +0 -1
  106. package/dist/chunks/exporter-DhtW6FxK.js.map +0 -1
  107. package/dist/chunks/exporter-IHv4nkCm.js.map +0 -1
  108. package/dist/chunks/importer-B1wmPQjr.js.map +0 -1
  109. package/dist/chunks/importer-BSALvkui.js.map +0 -1
@@ -318,6 +318,12 @@ export interface CheckExtras {
318
318
  * W_WEIGHTS_DROPPED note. Default true.
319
319
  */
320
320
  readonly weights?: boolean | undefined;
321
+ /**
322
+ * The tables the export writes, of "node", "edge" and "graph"; a gap in a table it does not
323
+ * write is not a loss of this export and gets no note. Default all three. A CSV node table is
324
+ * `["node"]`, so its notes are about node ids and node columns only.
325
+ */
326
+ readonly tables?: readonly ("node" | "edge" | "graph")[] | undefined;
321
327
  /**
322
328
  * How the format's importer turns id text back into ids ("canonical" for most text formats), so
323
329
  * an id that comes back with another type (the text "7" as the number 7) is reported as
@@ -353,60 +359,11 @@ export function checkCapabilities(
353
359
  const note = (code: string, message: string, column: string | null = null, count: number | null = null): void => {
354
360
  notes.push(Object.freeze({ code, message, column, count }));
355
361
  };
362
+ const tables = extras.tables ?? ["node", "edge", "graph"];
363
+ const edges = tables.includes("edge");
356
364
 
357
- // mixed direction (design section 3.6): an expanded snapshot carries the pair / directed roles
358
- const mixed = countMixedEdges(snapshot);
359
- if (mixed > 0 && !caps.mixedDirection) {
360
- if (options.onMixedDirection === "error") {
361
- note(
362
- LOSS.MIXED_DIRECTION_ERROR,
363
- `${mixed} undirected edge${plural(mixed)} in a directed graph, and the format holds one direction per file, so the save fails unless onMixedDirection is "directed" or "undirected"`,
364
- null,
365
- mixed,
366
- );
367
- } else {
368
- note(
369
- LOSS.MIXED_DIRECTION,
370
- `${mixed} undirected edge${plural(mixed)} in a directed graph ${agree(mixed, "is", "are")} written as ${options.onMixedDirection} (onMixedDirection), and the format holds one direction per file`,
371
- null,
372
- mixed,
373
- );
374
- }
375
- }
376
-
377
- if (snapshot.flags.multigraph && !caps.multiEdges) {
378
- const parallel = countParallelArcs(snapshot);
379
- note(
380
- LOSS.MULTI_EDGES,
381
- `${parallel} parallel edge${plural(parallel)}; the format has no parallel edges`,
382
- null,
383
- parallel,
384
- );
385
- }
386
- if (snapshot.selfLoopCount > 0 && !caps.selfLoops) {
387
- note(
388
- LOSS.SELF_LOOPS,
389
- `${snapshot.selfLoopCount} self-loop${plural(snapshot.selfLoopCount)}; the format has no self-loops`,
390
- null,
391
- snapshot.selfLoopCount,
392
- );
393
- }
394
-
395
- const edgeIds = snapshot.edges.byRole("id");
396
- if (caps.edgeIds === "required" && edgeIds === null && snapshot.edgeCount > 0) {
397
- note(
398
- LOSS.EDGE_IDS_GENERATED,
399
- "the format requires edge ids, and the graph has none; the edges are written with generated ids, which read back as a new edge id column",
400
- null,
401
- snapshot.edgeCount,
402
- );
403
- } else if (caps.edgeIds === "none" && edgeIds !== null) {
404
- note(
405
- LOSS.EDGE_IDS_DROPPED,
406
- `edge id column "${edgeIds.meta.name}" cannot be written`,
407
- edgeIds.meta.name,
408
- snapshot.edgeCount,
409
- );
365
+ if (edges) {
366
+ checkEdgeStructure(snapshot, caps, options, note);
410
367
  }
411
368
 
412
369
  const unrepresentable = countUnrepresentableIds(snapshot, caps.idCharset);
@@ -455,24 +412,40 @@ export function checkCapabilities(
455
412
  }
456
413
  }
457
414
 
458
- checkWeights(snapshot, extras, note);
459
- checkColumns(snapshot.nodes, "node", caps, extras, note);
460
- checkColumns(snapshot.edges, "edge", caps, extras, note);
461
- const graphColumns = snapshot.graph.names();
415
+ if (edges) {
416
+ checkWeights(snapshot, extras, note);
417
+ }
418
+ if (tables.includes("node")) {
419
+ checkColumns(snapshot.nodes, "node", caps, extras, note);
420
+ }
421
+ if (edges) {
422
+ checkColumns(snapshot.edges, "edge", caps, extras, note);
423
+ }
424
+ const graphColumns = tables.includes("graph") ? snapshot.graph.names() : [];
462
425
  if (graphColumns.length > 0) {
463
426
  if (caps.graphAttributes) {
464
427
  checkColumns(snapshot.graph, "graph", caps, extras, note);
465
428
  } else {
466
- note(
467
- LOSS.GRAPH_ATTRIBUTES,
468
- `${graphColumns.length} graph attribute${plural(graphColumns.length)} cannot be written`,
469
- null,
470
- graphColumns.length,
429
+ notes.push(
430
+ Object.freeze({
431
+ code: LOSS.GRAPH_ATTRIBUTES,
432
+ message: `${graphColumns.length} graph attribute${plural(graphColumns.length)} cannot be written`,
433
+ column: null,
434
+ count: graphColumns.length,
435
+ columns: Object.freeze([...graphColumns]),
436
+ }),
471
437
  );
472
438
  }
473
439
  }
474
440
 
475
441
  for (const [name, table] of snapshot.extensions) {
442
+ // "temporal:node:<column>" is the node table's, "temporal:edge:<column>" the edge table's;
443
+ // any other extension table is the graph's.
444
+ const [, kind] = name.split(":");
445
+ const owner = name.startsWith("temporal:") && (kind === "node" || kind === "edge") ? kind : "graph";
446
+ if (!tables.includes(owner)) {
447
+ continue;
448
+ }
476
449
  if (name.startsWith("temporal:")) {
477
450
  if (caps.temporal !== "dynamic-values") {
478
451
  note(
@@ -494,6 +467,76 @@ export function checkCapabilities(
494
467
  return notes;
495
468
  }
496
469
 
470
+ /**
471
+ * The edge-structure checks of checkCapabilities(): direction, parallel edges, self-loops and
472
+ * edge ids. Skipped for an export that writes no edge table.
473
+ * @param snapshot - the snapshot about to be exported
474
+ * @param caps - the exporter's capabilities
475
+ * @param options - the resolved common export options
476
+ * @param note - the recorder
477
+ */
478
+ function checkEdgeStructure(
479
+ snapshot: GraphSnapshot,
480
+ caps: ExportCapabilities,
481
+ options: ResolvedExportOptions,
482
+ note: NoteFn,
483
+ ): void {
484
+ // mixed direction (design section 3.6): an expanded snapshot carries the pair / directed roles
485
+ const mixed = countMixedEdges(snapshot);
486
+ if (mixed > 0 && !caps.mixedDirection) {
487
+ if (options.onMixedDirection === "error") {
488
+ note(
489
+ LOSS.MIXED_DIRECTION_ERROR,
490
+ `${mixed} undirected edge${plural(mixed)} in a directed graph, and the format holds one direction per file, so the save fails unless onMixedDirection is "directed" or "undirected"`,
491
+ null,
492
+ mixed,
493
+ );
494
+ } else {
495
+ note(
496
+ LOSS.MIXED_DIRECTION,
497
+ `${mixed} undirected edge${plural(mixed)} in a directed graph ${agree(mixed, "is", "are")} written as ${options.onMixedDirection} (onMixedDirection), and the format holds one direction per file`,
498
+ null,
499
+ mixed,
500
+ );
501
+ }
502
+ }
503
+
504
+ if (snapshot.flags.multigraph && !caps.multiEdges) {
505
+ const parallel = countParallelArcs(snapshot);
506
+ note(
507
+ LOSS.MULTI_EDGES,
508
+ `${parallel} parallel edge${plural(parallel)}; the format has no parallel edges`,
509
+ null,
510
+ parallel,
511
+ );
512
+ }
513
+ if (snapshot.selfLoopCount > 0 && !caps.selfLoops) {
514
+ note(
515
+ LOSS.SELF_LOOPS,
516
+ `${snapshot.selfLoopCount} self-loop${plural(snapshot.selfLoopCount)}; the format has no self-loops`,
517
+ null,
518
+ snapshot.selfLoopCount,
519
+ );
520
+ }
521
+
522
+ const edgeIds = snapshot.edges.byRole("id");
523
+ if (caps.edgeIds === "required" && edgeIds === null && snapshot.edgeCount > 0) {
524
+ note(
525
+ LOSS.EDGE_IDS_GENERATED,
526
+ "the format requires edge ids, and the graph has none; the edges are written with generated ids, which read back as a new edge id column",
527
+ null,
528
+ snapshot.edgeCount,
529
+ );
530
+ } else if (caps.edgeIds === "none" && edgeIds !== null) {
531
+ note(
532
+ LOSS.EDGE_IDS_DROPPED,
533
+ `edge id column "${edgeIds.meta.name}" cannot be written`,
534
+ edgeIds.meta.name,
535
+ snapshot.edgeCount,
536
+ );
537
+ }
538
+ }
539
+
497
540
  /**
498
541
  * The ids a charset cannot hold, as a message phrase.
499
542
  * @param charset - the format's id charset
package/src/common/zip.ts CHANGED
@@ -317,7 +317,7 @@ function zip64Extra(
317
317
  fields: { compressedSize: number; size: number; localOffset: number },
318
318
  ): { compressedSize: number; size: number; localOffset: number } {
319
319
  const out = { ...fields };
320
- for (let p = at; p + 4 <= at + length; ) {
320
+ for (let p = at; p + 4 <= at + length;) {
321
321
  const id = view.getUint16(p, true);
322
322
  const dataLength = view.getUint16(p + 2, true);
323
323
  if (id === 1) {
@@ -385,13 +385,9 @@ function cellText(column: Column, row: number): string | null {
385
385
  * W_CSV_NODE_TABLE: the node columns an edge-table or adjacency export leaves for a second, node-table export.
386
386
  * @param names - the node columns, label first
387
387
  * @param table - the table being written ("edges" or "adjacency")
388
- * @param note - the recorder
388
+ * @param notes - receives the note
389
389
  */
390
- function nodeTableNote(
391
- names: readonly string[],
392
- table: string,
393
- note: (code: string, message: string, column?: string | null, count?: number | null) => void,
394
- ): void {
390
+ function nodeTableNote(names: readonly string[], table: string, notes: LossNote[]): void {
395
391
  const written = names.length;
396
392
  if (written > 0) {
397
393
  const shown =
@@ -399,11 +395,14 @@ function nodeTableNote(
399
395
  .slice(0, 3)
400
396
  .map((n) => JSON.stringify(n))
401
397
  .join(", ") + (written > 3 ? ", ..." : "");
402
- note(
403
- CSV_LOSS.NODE_TABLE,
404
- `the ${table === "adjacency" ? "adjacency" : "edge"} table has no room for node attributes: ${written} node column${plural(written)} (${shown}) ${agree(written, "is", "are")} written only by a second export with table: "nodes"`,
405
- null,
406
- written,
398
+ notes.push(
399
+ Object.freeze({
400
+ code: CSV_LOSS.NODE_TABLE,
401
+ message: `the ${table === "adjacency" ? "adjacency" : "edge"} table has no room for node attributes: ${written} node column${plural(written)} (${shown}) ${agree(written, "is", "are")} written only by a second export with table: "nodes"`,
402
+ column: null,
403
+ count: written,
404
+ columns: Object.freeze([...names]),
405
+ }),
407
406
  );
408
407
  }
409
408
  }
@@ -483,7 +482,8 @@ function planExport(
483
482
  edgeRows.push(e);
484
483
  }
485
484
  }
486
- if (csv.dialect.type === null || csv.table === "adjacency") {
485
+ // A node table holds no edge: the direction and mutual notes are the edge table's.
486
+ if (csv.table !== "nodes" && (csv.dialect.type === null || csv.table === "adjacency")) {
487
487
  const where = csv.table === "adjacency" ? "an adjacency table" : "the generic dialect";
488
488
  const mixed = countMixedEdges(snapshot);
489
489
  if (!snapshot.directed) {
@@ -509,7 +509,7 @@ function planExport(
509
509
  0,
510
510
  );
511
511
  }
512
- if (folding.mutualCount > 0) {
512
+ if (folding.mutualCount > 0 && csv.table !== "nodes") {
513
513
  note(
514
514
  CSV_LOSS.MUTUAL_EXPANDED,
515
515
  `${folding.mutualCount} mutual pair${plural(folding.mutualCount)} ${agree(folding.mutualCount, "is", "are")} written as two directed rows; the mutual mark is lost`,
@@ -586,7 +586,7 @@ function planExport(
586
586
  nodeTableNote(
587
587
  [...(nodeLabel === null ? [] : [nodeLabel.meta.name]), ...nodeColumns.map((c) => c.column.meta.name)],
588
588
  csv.table,
589
- note,
589
+ notes,
590
590
  );
591
591
  }
592
592
 
@@ -1116,6 +1116,7 @@ function headerlessNotes(plan: Plan): LossNote[] {
1116
1116
  message: `without a header the file is read back by position (${csv.table === "nodes" ? "id" : "source, target, weight"}, then unnamed columns), so ${names.length === 1 ? "the column" : "the columns"} ${shown} ${agree(names.length, "reads", "read")} back unnamed or in another column's place; write the header, or for an edge list pass dialect: "generic" and write only source, target and weight`,
1117
1117
  column: null,
1118
1118
  count: names.length,
1119
+ columns: Object.freeze([...names]),
1119
1120
  }),
1120
1121
  ];
1121
1122
  }
@@ -1166,7 +1167,10 @@ function headerlessEdgeColumns(plan: Plan): string[] {
1166
1167
  function check(snapshot: GraphSnapshot, options?: CsvExportOptions & CommonExportOptions): readonly LossNote[] {
1167
1168
  const plan = planExport(snapshot, options, true);
1168
1169
  return Object.freeze([
1169
- ...checkCapabilities(snapshot, CSV_CAPABILITIES, plan.common, { roles: KEPT_ROLES }),
1170
+ ...checkCapabilities(snapshot, CSV_CAPABILITIES, plan.common, {
1171
+ roles: KEPT_ROLES,
1172
+ ...(plan.csv.table === "nodes" ? { tables: ["node"] as const } : {}),
1173
+ }),
1170
1174
  ...plan.notes,
1171
1175
  ...headerlessNotes(plan),
1172
1176
  ]);
@@ -2016,7 +2016,8 @@ const OTHER_FORMATS: readonly RegExp[] = [
2016
2016
  /**
2017
2017
  * Sniff confidence for the registry: 0 for XML, JSON, GML, DOT (also a leading C comment) and Pajek
2018
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
2019
+ * only such comments (the comments of another format, too long to see past), and 0 for text
2020
+ * holding a C0 control character (hasControlCharacter); otherwise a
2020
2021
  * delimited first row with endpoint headers is 0.9 (even when a later row has the wrong width, which
2021
2022
  * the importer reports), with an id header 0.6, and other consistently delimited rows 0.3, except
2022
2023
  * rows of more than three space-separated words, which is plain text such as a sentence, not a
@@ -2033,7 +2034,7 @@ function sniff(head: Uint8Array): number {
2033
2034
  }
2034
2035
  // the decoder drops the BOM it was chosen by; the reader skips leading comment lines
2035
2036
  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))) {
2037
+ if (body.trim().length === 0 || hasControlCharacter(body) || OTHER_FORMATS.some((r) => r.test(body))) {
2037
2038
  return 0;
2038
2039
  }
2039
2040
  const newline = sniffNewline(body);
@@ -2060,6 +2061,24 @@ function sniff(head: Uint8Array): number {
2060
2061
  return looksLikeProse(body, newline, delimiter) ? 0 : 0.3;
2061
2062
  }
2062
2063
 
2064
+ /**
2065
+ * Whether text holds a C0 control character other than TAB, LF and CR (a final Ctrl-Z, the DOS
2066
+ * end-of-file marker, aside): such text is binary data or another format, never a CSV a reader
2067
+ * means to load, so it is only read as CSV when the caller names the format.
2068
+ * @param text - the decoded head
2069
+ * @returns whether it holds one
2070
+ */
2071
+ function hasControlCharacter(text: string): boolean {
2072
+ const last = text.length - 1;
2073
+ for (let i = 0; i <= last; i++) {
2074
+ const c = text.codePointAt(i) ?? 0;
2075
+ if (c < 0x20 && c !== 0x09 && c !== 0x0a && c !== 0x0d && !(c === 0x1a && i === last)) {
2076
+ return true;
2077
+ }
2078
+ }
2079
+ return false;
2080
+ }
2081
+
2063
2082
  /** A cell that reads as part of a sentence: words with a sentence end after a letter (`Monday.`, `world. Thanks`). */
2064
2083
  const SENTENCE = /[A-Za-z]{2}[.!?](?:\s|$)/;
2065
2084
 
@@ -270,18 +270,26 @@ function planAttributes(
270
270
  return plans;
271
271
  }
272
272
 
273
+ /** A note-recording callback. */
274
+ type Note = (code: string, message: string, column?: string | null, count?: number | null) => void;
275
+
273
276
  /**
274
- * Plan an export: the notes, the fatal condition and what is written.
277
+ * The generic notes of checkCapabilities(), adapted to CX2: bypass columns hold any JSON value,
278
+ * ids are counted by planNodeIds(), nested values are written as JSON text, and only the node
279
+ * label is written into the "n" slot (an edge label keeps its own name but loses its role).
275
280
  * @param snapshot - the snapshot
276
281
  * @param common - the resolved common options
277
- * @returns the plan
282
+ * @param notes - the notes so far, appended to
283
+ * @param note - the recorder
284
+ * @returns the fatal condition, or null
278
285
  */
279
- function plan(snapshot: GraphSnapshot, common: ResolvedExportOptions): Plan {
280
- const notes: LossNote[] = [];
286
+ function capabilityNotes(
287
+ snapshot: GraphSnapshot,
288
+ common: ResolvedExportOptions,
289
+ notes: LossNote[],
290
+ note: Note,
291
+ ): GraphFormatError | null {
281
292
  let fatal: GraphFormatError | null = null;
282
- const note = (code: string, message: string, column: string | null = null, count: number | null = null): void => {
283
- notes.push(Object.freeze({ code, message, column, count }));
284
- };
285
293
  // bypass columns are written as bypasses, which hold any JSON value
286
294
  const bypassNames = new Set(
287
295
  [...snapshot.nodes, ...snapshot.edges].filter((c) => isBypass(c) || isZ(c)).map((c) => c.meta.name),
@@ -290,11 +298,7 @@ function plan(snapshot: GraphSnapshot, common: ResolvedExportOptions): Plan {
290
298
  roles: SLOT_ROLES,
291
299
  roleNames: ROLE_NAMES,
292
300
  })) {
293
- if (gen.column !== null && bypassNames.has(gen.column) && (gen.code === LOSS.JSON || gen.code === LOSS.DTYPE)) {
294
- continue;
295
- }
296
- if (gen.code === LOSS.ID_CHARSET || gen.code === LOSS.ID_MANGLED) {
297
- // counted below: CX2 also keeps integer ids beyond 2^53, which the generic rule refuses
301
+ if (!keepGenericNote(gen, bypassNames)) {
298
302
  continue;
299
303
  }
300
304
  if (gen.code === LOSS.JSON) {
@@ -311,27 +315,83 @@ function plan(snapshot: GraphSnapshot, common: ResolvedExportOptions): Plan {
311
315
  }
312
316
  notes.push(gen);
313
317
  }
318
+ const edgeLabel = snapshot.edges.byRole("label");
319
+ if (edgeLabel !== null) {
320
+ note(
321
+ LOSS.ROLE,
322
+ `edge column "${edgeLabel.meta.name}" (label) is written as a plain attribute; CX2 edges have no label slot and the role is lost`,
323
+ edgeLabel.meta.name,
324
+ edgeLabel.length - edgeLabel.nullCount,
325
+ );
326
+ }
327
+ return fatal;
328
+ }
314
329
 
315
- const folding = pairFolding(snapshot);
316
- directionNotes(snapshot, folding, common, CX2_LOSS.UNDIRECTED_AS_DIRECTED, note);
330
+ /**
331
+ * Whether a generic note applies to CX2 as it stands.
332
+ * @param gen - the generic note
333
+ * @param bypassNames - the names of the columns written as bypasses
334
+ * @returns false for a note CX2 drops or replaces
335
+ */
336
+ function keepGenericNote(gen: LossNote, bypassNames: ReadonlySet<string>): boolean {
337
+ if (gen.column !== null && bypassNames.has(gen.column) && (gen.code === LOSS.JSON || gen.code === LOSS.DTYPE)) {
338
+ return false;
339
+ }
340
+ if (gen.code === LOSS.COLUMN_NAME_CHANGED && gen.message.startsWith("edge column")) {
341
+ // only the node label is written into the "n" slot; an edge label keeps its own name
342
+ return false;
343
+ }
344
+ // ids are counted by planNodeIds(): CX2 also keeps integer ids beyond 2^53, which the generic rule refuses
345
+ return gen.code !== LOSS.ID_CHARSET && gen.code !== LOSS.ID_MANGLED;
346
+ }
317
347
 
318
- const planned = planNodeIds(snapshot, common, "CX2", ORIGINAL_ID_ATTRIBUTE, note);
319
- const ids = planned instanceof GraphFormatError ? null : planned;
320
- if (planned instanceof GraphFormatError) {
321
- fatal ??= planned;
348
+ /**
349
+ * Note the NaN and infinite values written as null: attribute cells, explicit weights, positions.
350
+ * @param snapshot - the snapshot
351
+ * @param attrs - every planned attribute
352
+ * @param weights - the explicit weights
353
+ * @param position - the position column, or null
354
+ * @param note - the recorder
355
+ */
356
+ function nonFiniteNote(
357
+ snapshot: GraphSnapshot,
358
+ attrs: readonly AttributePlan[],
359
+ weights: ExplicitWeights,
360
+ position: Column | null,
361
+ note: Note,
362
+ ): void {
363
+ let nonfinite = 0;
364
+ for (const p of attrs) {
365
+ nonfinite += nonFiniteCount(p.column);
366
+ }
367
+ for (let e = 0; e < snapshot.edgeCount; e++) {
368
+ if (weights.isExplicit(e) && !Number.isFinite(weights.value(e))) {
369
+ nonfinite++;
370
+ }
322
371
  }
372
+ if (position !== null) {
373
+ nonfinite += nonFinitePositions(position);
374
+ }
375
+ if (nonfinite > 0) {
376
+ note(
377
+ CX2_LOSS.NONFINITE_AS_NULL,
378
+ `${nonfinite} NaN or infinite value${plural(nonfinite)} cannot be written in CX2; written as null, they read back unset`,
379
+ null,
380
+ nonfinite,
381
+ );
382
+ }
383
+ }
323
384
 
324
- const weights = explicitWeights(snapshot);
325
- const label = snapshot.nodes.byRole("label");
326
- const nodeAttrs = planAttributes(
327
- snapshot.nodes,
328
- (c) => (c.meta.role !== null && SKIPPED_ROLES.has(c.meta.role)) || isBypass(c) || isZ(c),
329
- (c) => (c === label && !snapshot.nodes.has("name") ? "name" : c.meta.name),
330
- ids !== null && ids.changed > 0 ? new Set([ORIGINAL_ID_ATTRIBUTE]) : new Set(),
331
- );
385
+ /**
386
+ * Plan the edge attributes, noting a plain `weight` column that clashes with the weight key.
387
+ * @param snapshot - the snapshot
388
+ * @param weights - the explicit weights
389
+ * @param note - the recorder
390
+ * @returns the edge attribute plans
391
+ */
392
+ function planEdgeAttributes(snapshot: GraphSnapshot, weights: ExplicitWeights, note: Note): AttributePlan[] {
332
393
  const plainWeight = snapshot.edges.get(WEIGHT_ATTRIBUTE);
333
- const weightColumnClash = plainWeight !== null && plainWeight.meta.role === null;
334
- if (weightColumnClash) {
394
+ if (plainWeight !== null && plainWeight.meta.role === null) {
335
395
  note(
336
396
  LOSS.WEIGHT_KEY_CLASH,
337
397
  weights.weighted
@@ -341,7 +401,7 @@ function plan(snapshot: GraphSnapshot, common: ResolvedExportOptions): Plan {
341
401
  null,
342
402
  );
343
403
  }
344
- const edgeAttrs = planAttributes(
404
+ return planAttributes(
345
405
  snapshot.edges,
346
406
  (c) =>
347
407
  (c.meta.role !== null && (SKIPPED_ROLES.has(c.meta.role) || c.meta.role === "id")) ||
@@ -350,33 +410,47 @@ function plan(snapshot: GraphSnapshot, common: ResolvedExportOptions): Plan {
350
410
  (c) => c.meta.name,
351
411
  weights.weighted ? new Set([WEIGHT_ATTRIBUTE]) : new Set(),
352
412
  );
413
+ }
414
+
415
+ /**
416
+ * Plan an export: the notes, the fatal condition and what is written.
417
+ * @param snapshot - the snapshot
418
+ * @param common - the resolved common options
419
+ * @returns the plan
420
+ */
421
+ function plan(snapshot: GraphSnapshot, common: ResolvedExportOptions): Plan {
422
+ const notes: LossNote[] = [];
423
+ const note: Note = (code, message, column = null, count = null) => {
424
+ notes.push(Object.freeze({ code, message, column, count }));
425
+ };
426
+ let fatal = capabilityNotes(snapshot, common, notes, note);
427
+
428
+ const folding = pairFolding(snapshot);
429
+ directionNotes(snapshot, folding, common, CX2_LOSS.UNDIRECTED_AS_DIRECTED, note);
430
+
431
+ const planned = planNodeIds(snapshot, common, "CX2", ORIGINAL_ID_ATTRIBUTE, note);
432
+ const ids = planned instanceof GraphFormatError ? null : planned;
433
+ if (planned instanceof GraphFormatError) {
434
+ fatal ??= planned;
435
+ }
436
+
437
+ const weights = explicitWeights(snapshot);
438
+ const label = snapshot.nodes.byRole("label");
439
+ const nodeAttrs = planAttributes(
440
+ snapshot.nodes,
441
+ (c) => (c.meta.role !== null && SKIPPED_ROLES.has(c.meta.role)) || isBypass(c) || isZ(c),
442
+ (c) => (c === label && !snapshot.nodes.has("name") ? "name" : c.meta.name),
443
+ ids !== null && ids.changed > 0 ? new Set([ORIGINAL_ID_ATTRIBUTE]) : new Set(),
444
+ );
445
+ const edgeAttrs = planEdgeAttributes(snapshot, weights, note);
353
446
  const graphAttrs = planAttributes(
354
447
  snapshot.graph,
355
448
  () => false,
356
449
  (c) => c.meta.name,
357
450
  ).map((p) => ({ ...p, key: p.name }));
358
451
 
359
- let nonfinite = 0;
360
- for (const p of [...nodeAttrs, ...edgeAttrs, ...graphAttrs]) {
361
- nonfinite += nonFiniteCount(p.column);
362
- }
363
- for (let e = 0; e < snapshot.edgeCount; e++) {
364
- if (weights.isExplicit(e) && !Number.isFinite(weights.value(e))) {
365
- nonfinite++;
366
- }
367
- }
368
452
  const position = snapshot.nodes.byRole("position");
369
- if (position !== null) {
370
- nonfinite += nonFinitePositions(position);
371
- }
372
- if (nonfinite > 0) {
373
- note(
374
- CX2_LOSS.NONFINITE_AS_NULL,
375
- `${nonfinite} NaN or infinite value${plural(nonfinite)} cannot be written in CX2; written as null, they read back unset`,
376
- null,
377
- nonfinite,
378
- );
379
- }
453
+ nonFiniteNote(snapshot, [...nodeAttrs, ...edgeAttrs, ...graphAttrs], weights, position, note);
380
454
 
381
455
  const edgeIds = planEdgeIds(snapshot, note);
382
456
  const z = [...snapshot.nodes].find(isZ) ?? null;
@@ -377,6 +377,11 @@ class ExportPlan {
377
377
 
378
378
  private readonly targetPort: Column | null;
379
379
 
380
+ /** The node and edge label role columns, written under `label` whatever their name. */
381
+ private readonly nodeLabel: Column | null;
382
+
383
+ private readonly edgeLabel: Column | null;
384
+
380
385
  private readonly children: ChildrenCsr;
381
386
 
382
387
  private readonly extraNotes: LossNote[] = [];
@@ -417,6 +422,8 @@ class ExportPlan {
417
422
  this.folding = pairFolding(snapshot);
418
423
  this.sourcePort = edges.byRole("sourcePort");
419
424
  this.targetPort = edges.byRole("targetPort");
425
+ this.nodeLabel = nodes.byRole("label");
426
+ this.edgeLabel = edges.byRole("label");
420
427
  this.nodeColumns = [...nodes].filter((c) => this.writesNodeColumn(c));
421
428
  this.edgeColumns = [...edges].filter((c) => this.writesEdgeColumn(c));
422
429
  this.graphColumns = [...graph].filter(
@@ -444,7 +451,7 @@ class ExportPlan {
444
451
  this.clash("node", name, "the position role is written as pos");
445
452
  return false;
446
453
  }
447
- return true;
454
+ return this.labelFree("node", column, this.nodeLabel);
448
455
  }
449
456
 
450
457
  /**
@@ -468,7 +475,23 @@ class ExportPlan {
468
475
  this.clash("edge", name, "the weight is written as weight");
469
476
  return false;
470
477
  }
471
- return true;
478
+ return this.labelFree("edge", column, this.edgeLabel);
479
+ }
480
+
481
+ /**
482
+ * Whether a column may be written: false (reported) for a plain `label` column when the label
483
+ * role column is named otherwise, since that one is written as `label`.
484
+ * @param domain - the table
485
+ * @param column - the column
486
+ * @param labelRole - the table's label role column, or null
487
+ * @returns true when written
488
+ */
489
+ private labelFree(domain: string, column: Column, labelRole: Column | null): boolean {
490
+ if (column.meta.name !== LABEL_ATTRIBUTE || labelRole === null || labelRole === column) {
491
+ return true;
492
+ }
493
+ this.clash(domain, column.meta.name, "the label role is written as label");
494
+ return false;
472
495
  }
473
496
 
474
497
  /**
@@ -703,7 +726,7 @@ class ExportPlan {
703
726
  }
704
727
  for (const columns of [this.nodeColumns, this.edgeColumns, this.graphColumns]) {
705
728
  for (const column of columns) {
706
- visit(column.meta.name, "attribute name");
729
+ visit(this.attributeName(column), "attribute name");
707
730
  if (column.dtype === "string" || column.dtype === "dict") {
708
731
  for (let r = 0; r < column.length; r++) {
709
732
  if (column.isSet(r)) {
@@ -844,6 +867,16 @@ class ExportPlan {
844
867
  return parts.join(",");
845
868
  }
846
869
 
870
+ /**
871
+ * The attribute a column is written as: `label` for a node or edge label role column (the
872
+ * importer gives that attribute the role back), the column name otherwise.
873
+ * @param column - the column
874
+ * @returns the attribute name
875
+ */
876
+ private attributeName(column: Column): string {
877
+ return column === this.nodeLabel || column === this.edgeLabel ? LABEL_ATTRIBUTE : column.meta.name;
878
+ }
879
+
847
880
  /**
848
881
  * The `name=value` attributes of one row over a column list (set cells only).
849
882
  * @param columns - the columns
@@ -858,7 +891,7 @@ class ExportPlan {
858
891
  }
859
892
  const text = cellText(column, row);
860
893
  if (text !== null) {
861
- out.push(`${quoteDotId(column.meta.name)}=${writeId(text)}`);
894
+ out.push(`${quoteDotId(this.attributeName(column))}=${writeId(text)}`);
862
895
  }
863
896
  }
864
897
  return out;
@@ -514,7 +514,7 @@ function countGraphs(lexer: DotTokenizer, first: DotToken): number {
514
514
  if (!isPunct(token, "{")) {
515
515
  throw new DotSyntaxError(`expected "{" after the graph header, found ${describeToken(token)}`, token.line);
516
516
  }
517
- for (let depth = 1; depth > 0; ) {
517
+ for (let depth = 1; depth > 0;) {
518
518
  token = lexer.next();
519
519
  if (token.kind === "eof") {
520
520
  throw new DotSyntaxError('missing "}" at the end of a graph', token.line);