@graphty/graph-io 0.3.9 → 0.3.11

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 (125) hide show
  1. package/README.md +29 -5
  2. package/dist/chunks/{escape-BjmIaFFo.js → escape-D1f9-cwf.js} +4 -4
  3. package/dist/chunks/{escape-BjmIaFFo.js.map → escape-D1f9-cwf.js.map} +1 -1
  4. package/dist/chunks/{importer-BddBW3sf.js → importer-9PxhqH4v.js} +336 -92
  5. package/dist/chunks/importer-9PxhqH4v.js.map +1 -0
  6. package/dist/chunks/{importer-DfI8POXb.js → importer-B8lsjFWx.js} +6 -5
  7. package/dist/chunks/importer-B8lsjFWx.js.map +1 -0
  8. package/dist/chunks/{importer-BtHFICnf.js → importer-C3HcYWAn.js} +5 -3
  9. package/dist/chunks/importer-C3HcYWAn.js.map +1 -0
  10. package/dist/chunks/{importer-BOxwmef_.js → importer-C7mnGdr_.js} +125 -8
  11. package/dist/chunks/importer-C7mnGdr_.js.map +1 -0
  12. package/dist/chunks/{records-BKSMowhR.js → records-IHsCfv7s.js} +20 -7
  13. package/dist/chunks/records-IHsCfv7s.js.map +1 -0
  14. package/dist/chunks/{writer-BdMak4_J.js → writer-BtWpUaiH.js} +4 -4
  15. package/dist/chunks/{writer-BdMak4_J.js.map → writer-BtWpUaiH.js.map} +1 -1
  16. package/dist/csv.js +239 -18
  17. package/dist/csv.js.map +1 -1
  18. package/dist/dot.js +1 -1
  19. package/dist/gexf.js +72 -33
  20. package/dist/gexf.js.map +1 -1
  21. package/dist/gml.js +44 -23
  22. package/dist/gml.js.map +1 -1
  23. package/dist/graph-io.js +11 -11
  24. package/dist/graphml.js +1 -1
  25. package/dist/json.js +1 -1
  26. package/dist/neo4j.js +5 -4
  27. package/dist/neo4j.js.map +1 -1
  28. package/dist/pajek.js +1 -1
  29. package/dist/src/common/export.js +1 -1
  30. package/dist/src/common/export.js.map +1 -1
  31. package/dist/src/formats/csv/exporter.d.ts +15 -4
  32. package/dist/src/formats/csv/exporter.d.ts.map +1 -1
  33. package/dist/src/formats/csv/exporter.js +125 -6
  34. package/dist/src/formats/csv/exporter.js.map +1 -1
  35. package/dist/src/formats/csv/importer.d.ts +36 -4
  36. package/dist/src/formats/csv/importer.d.ts.map +1 -1
  37. package/dist/src/formats/csv/importer.js +169 -8
  38. package/dist/src/formats/csv/importer.js.map +1 -1
  39. package/dist/src/formats/csv/records.d.ts +14 -1
  40. package/dist/src/formats/csv/records.d.ts.map +1 -1
  41. package/dist/src/formats/csv/records.js +28 -7
  42. package/dist/src/formats/csv/records.js.map +1 -1
  43. package/dist/src/formats/dot/exporter.d.ts.map +1 -1
  44. package/dist/src/formats/dot/exporter.js +2 -0
  45. package/dist/src/formats/dot/exporter.js.map +1 -1
  46. package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
  47. package/dist/src/formats/gexf/exporter.js +36 -10
  48. package/dist/src/formats/gexf/exporter.js.map +1 -1
  49. package/dist/src/formats/gexf/importer.d.ts +9 -4
  50. package/dist/src/formats/gexf/importer.d.ts.map +1 -1
  51. package/dist/src/formats/gexf/importer.js +44 -21
  52. package/dist/src/formats/gexf/importer.js.map +1 -1
  53. package/dist/src/formats/gexf/index.d.ts +1 -1
  54. package/dist/src/formats/gexf/index.d.ts.map +1 -1
  55. package/dist/src/formats/gexf/index.js +1 -1
  56. package/dist/src/formats/gexf/index.js.map +1 -1
  57. package/dist/src/formats/gexf/schema.d.ts +2 -0
  58. package/dist/src/formats/gexf/schema.d.ts.map +1 -1
  59. package/dist/src/formats/gexf/schema.js +18 -0
  60. package/dist/src/formats/gexf/schema.js.map +1 -1
  61. package/dist/src/formats/gml/exporter.d.ts.map +1 -1
  62. package/dist/src/formats/gml/exporter.js +1 -0
  63. package/dist/src/formats/gml/exporter.js.map +1 -1
  64. package/dist/src/formats/gml/importer.d.ts +7 -4
  65. package/dist/src/formats/gml/importer.d.ts.map +1 -1
  66. package/dist/src/formats/gml/importer.js +32 -19
  67. package/dist/src/formats/gml/importer.js.map +1 -1
  68. package/dist/src/formats/gml/index.d.ts +3 -1
  69. package/dist/src/formats/gml/index.d.ts.map +1 -1
  70. package/dist/src/formats/gml/index.js +4 -2
  71. package/dist/src/formats/gml/index.js.map +1 -1
  72. package/dist/src/formats/graphml/constants.d.ts +2 -0
  73. package/dist/src/formats/graphml/constants.d.ts.map +1 -1
  74. package/dist/src/formats/graphml/constants.js +2 -0
  75. package/dist/src/formats/graphml/constants.js.map +1 -1
  76. package/dist/src/formats/graphml/exporter.d.ts.map +1 -1
  77. package/dist/src/formats/graphml/exporter.js +14 -2
  78. package/dist/src/formats/graphml/exporter.js.map +1 -1
  79. package/dist/src/formats/graphml/importer.d.ts +2 -1
  80. package/dist/src/formats/graphml/importer.d.ts.map +1 -1
  81. package/dist/src/formats/graphml/importer.js +91 -27
  82. package/dist/src/formats/graphml/importer.js.map +1 -1
  83. package/dist/src/formats/graphml/yfiles.d.ts +62 -0
  84. package/dist/src/formats/graphml/yfiles.d.ts.map +1 -0
  85. package/dist/src/formats/graphml/yfiles.js +269 -0
  86. package/dist/src/formats/graphml/yfiles.js.map +1 -0
  87. package/dist/src/formats/json/exporter.d.ts.map +1 -1
  88. package/dist/src/formats/json/exporter.js +9 -1
  89. package/dist/src/formats/json/exporter.js.map +1 -1
  90. package/dist/src/formats/json/importer.d.ts +14 -0
  91. package/dist/src/formats/json/importer.d.ts.map +1 -1
  92. package/dist/src/formats/json/importer.js +165 -4
  93. package/dist/src/formats/json/importer.js.map +1 -1
  94. package/dist/src/formats/neo4j/exporter.d.ts.map +1 -1
  95. package/dist/src/formats/neo4j/exporter.js +1 -0
  96. package/dist/src/formats/neo4j/exporter.js.map +1 -1
  97. package/dist/src/formats/pajek/exporter.d.ts.map +1 -1
  98. package/dist/src/formats/pajek/exporter.js +3 -2
  99. package/dist/src/formats/pajek/exporter.js.map +1 -1
  100. package/package.json +2 -2
  101. package/src/common/export.ts +1 -1
  102. package/src/formats/csv/exporter.ts +140 -10
  103. package/src/formats/csv/importer.ts +207 -18
  104. package/src/formats/csv/records.ts +41 -6
  105. package/src/formats/dot/exporter.ts +2 -0
  106. package/src/formats/gexf/exporter.ts +40 -11
  107. package/src/formats/gexf/importer.ts +47 -25
  108. package/src/formats/gexf/index.ts +1 -1
  109. package/src/formats/gexf/schema.ts +18 -0
  110. package/src/formats/gml/exporter.ts +1 -0
  111. package/src/formats/gml/importer.ts +47 -24
  112. package/src/formats/gml/index.ts +4 -1
  113. package/src/formats/graphml/constants.ts +2 -0
  114. package/src/formats/graphml/exporter.ts +21 -2
  115. package/src/formats/graphml/importer.ts +99 -28
  116. package/src/formats/graphml/yfiles.ts +292 -0
  117. package/src/formats/json/exporter.ts +9 -1
  118. package/src/formats/json/importer.ts +202 -4
  119. package/src/formats/neo4j/exporter.ts +1 -0
  120. package/src/formats/pajek/exporter.ts +3 -1
  121. package/dist/chunks/importer-BOxwmef_.js.map +0 -1
  122. package/dist/chunks/importer-BddBW3sf.js.map +0 -1
  123. package/dist/chunks/importer-BtHFICnf.js.map +0 -1
  124. package/dist/chunks/importer-DfI8POXb.js.map +0 -1
  125. package/dist/chunks/records-BKSMowhR.js.map +0 -1
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The CSV / TSV importer (design sections 8.4 and 8.6; research note 07 section 2.6): a streaming
3
3
  * edge-list reader for the generic (`source,target[,weight,...]`), Gephi (`Source,Target,Type,Id,
4
- * Label,Weight,...`) and headerless (`u v [w]`) dialects, with an optional node table merged by id.
4
+ * Label,Weight,...`) and headerless (`u v [w]`) dialects, with an optional node table merged by id,
5
+ * and, with `table: "adjacency"`, an adjacency table (`node,neighbour[:weight],...`).
5
6
  *
6
7
  * - The delimiter is sniffed from a preview unless given; LF, CRLF and lone-CR files all read.
7
8
  * - The first row is a header when it holds a known column name or when it is all text over a
@@ -19,6 +20,17 @@
19
20
  * section 5.1 and the sink infers the column dtype (widening per column, never per cell); an
20
21
  * all-text column of low cardinality becomes a dict (design section 5.4); an `id` column of the
21
22
  * edge table is the edge id (role id, unique); a `label` column is the label (role label).
23
+ * - An adjacency table has no header (unless `header: true`, which skips the first row): each row
24
+ * is a node followed by its neighbours, one edge per neighbour in row order. A neighbour cell
25
+ * `id:weight` carries the edge's weight when the text after its LAST colon is a number; a cell
26
+ * ending in a bare colon (`a:1:`) is the id before it with no weight; any other cell is the id
27
+ * as written (`http://x`). A row holding only its node adds an isolated node. An empty adjacency
28
+ * table is the empty graph. The delimiter sniff skips a candidate under which a closing quote is
29
+ * followed by other text, since the field counts it otherwise relies on vary by row.
30
+ * - A node table without an id column is refused unless `rowNumberIds` is set: then each data row's
31
+ * 0-based number is its id, coerced by `ids` like any other id cell. The option applies to the
32
+ * node table (the `nodes` input, or the input itself when nothing is paired with it), whose first
33
+ * row it makes a header under `header: "auto"`; a paired edge table is read as without it.
22
34
  * - Per-row problems (wrong field count, blank endpoint, invalid weight, bad Type, refused id)
23
35
  * are recorded and the row skipped; the import aborts with ImportError once `errorLimit` is
24
36
  * exceeded, on a malformed or unterminated quoted field, on an empty input and on a header
@@ -80,10 +92,12 @@ export interface CsvImportOptions {
80
92
  /** Whether the first row is a header; "auto" (default) decides from its content. */
81
93
  header?: boolean | "auto" | undefined;
82
94
  /**
83
- * What the input is: an edge table, a node table, or "auto" (default): an edge table when
84
- * source and target columns resolve, a node table when only an id column does.
95
+ * What the input is: an edge table, a node table, an adjacency table (`node,neighbour[:weight],...`
96
+ * per row, no header by default), or "auto" (default): an edge table when source and target
97
+ * columns resolve, a node table when only an id column does. An adjacency table is never
98
+ * guessed: nothing in its rows tells it from an edge list.
85
99
  */
86
- table?: "edges" | "nodes" | "auto" | undefined;
100
+ table?: "edges" | "nodes" | "adjacency" | "auto" | undefined;
87
101
  /** The source column, by name or 0-based position; resolved from the header by default. */
88
102
  sourceColumn?: CsvColumnRef | undefined;
89
103
  /** The target column, by name or 0-based position; resolved from the header by default. */
@@ -97,6 +111,13 @@ export interface CsvImportOptions {
97
111
  idColumn?: CsvColumnRef | undefined;
98
112
  /** A node table read before the edges: its ids become nodes and its other columns node attributes. */
99
113
  nodes?: ImportInput | undefined;
114
+ /**
115
+ * A node table whose header has no id column gets its ids from the row numbers (0 for the first
116
+ * data row), coerced by `ids`, instead of failing with E_CSV_NO_ID_COLUMN. The node table is the
117
+ * `nodes` input when one is given (the edge table is then read as without this option), else
118
+ * the input itself; its first row is a header even under `header: "auto"`. False by default.
119
+ */
120
+ rowNumberIds?: boolean | undefined;
100
121
  }
101
122
 
102
123
  /** Issue code: the input holds no header row at all. */
@@ -126,7 +147,7 @@ export const ROLE_TAKEN_CODE = SHARED_ROLE_TAKEN_CODE;
126
147
  /** Issue code: a repeated edge id (the column is unique); the edge is skipped. */
127
148
  export const DUPLICATE_EDGE_ID_CODE = SHARED_DUPLICATE_EDGE_ID_CODE;
128
149
 
129
- const TABLE_MODES: ReadonlySet<string> = new Set(["edges", "nodes", "auto"]);
150
+ const TABLE_MODES: ReadonlySet<string> = new Set(["edges", "nodes", "adjacency", "auto"]);
130
151
 
131
152
  /** The common options an edge-table import reads (the rest is reported by reportUnusedOptions). */
132
153
  const USED_OPTIONS: ReadonlySet<keyof CommonImportOptions> = new Set<keyof CommonImportOptions>([
@@ -154,13 +175,14 @@ const BAD_DELIMITERS: ReadonlySet<string> = new Set(['"', "\n", "\r"]);
154
175
  interface ResolvedCsvOptions {
155
176
  readonly delimiter: string | null;
156
177
  readonly header: boolean | "auto";
157
- readonly table: "edges" | "nodes" | "auto";
178
+ readonly table: "edges" | "nodes" | "adjacency" | "auto";
158
179
  readonly sourceColumn: CsvColumnRef | null;
159
180
  readonly targetColumn: CsvColumnRef | null;
160
181
  /** The direction column reference; null for none; undefined for the Gephi rule. */
161
182
  readonly typeColumn: CsvColumnRef | null | undefined;
162
183
  readonly idColumn: CsvColumnRef | null;
163
184
  readonly nodes: ImportInput | null;
185
+ readonly rowNumberIds: boolean;
164
186
  }
165
187
 
166
188
  /** The columns of an edge table, by index. */
@@ -182,12 +204,20 @@ interface NodePlan {
182
204
  readonly kind: "nodes";
183
205
  readonly names: readonly string[];
184
206
  readonly width: number;
185
- /** The column ids are read from, or -1 under nodeIdFrom "index". */
207
+ /** The column ids are read from, or -1 under nodeIdFrom "index" or rowNumberIds. */
186
208
  readonly id: number;
209
+ /** Whether a row's id is its row number coerced by `ids` (rowNumberIds); else the raw ordinal under -1. */
210
+ readonly rowNumber: boolean;
187
211
  readonly label: number;
188
212
  readonly attributes: readonly number[];
189
213
  }
190
214
 
215
+ /** An adjacency table: no columns, a node and its neighbours per row. */
216
+ interface AdjacencyPlan {
217
+ readonly kind: "adjacency";
218
+ readonly names: readonly string[];
219
+ }
220
+
191
221
  /** Everything one import call shares between its tables. */
192
222
  interface ImportState {
193
223
  readonly sink: GraphSink;
@@ -255,7 +285,7 @@ function resolveCsvOptions(options: (CsvImportOptions & CommonImportOptions) | u
255
285
  });
256
286
  }
257
287
  if (o.table !== undefined && !TABLE_MODES.has(o.table)) {
258
- throw new GraphFormatError("E_UNSUPPORTED", 'option table: expected "edges", "nodes" or "auto"', {
288
+ throw new GraphFormatError("E_UNSUPPORTED", 'option table: expected "edges", "nodes", "adjacency" or "auto"', {
259
289
  option: "table",
260
290
  found: o.table,
261
291
  });
@@ -266,6 +296,28 @@ function resolveCsvOptions(options: (CsvImportOptions & CommonImportOptions) | u
266
296
  if (o.typeColumn !== null) {
267
297
  checkColumnRef("typeColumn", o.typeColumn);
268
298
  }
299
+ if (o.table === "adjacency") {
300
+ for (const name of ["sourceColumn", "targetColumn", "typeColumn", "idColumn"] as const) {
301
+ if (o[name] !== undefined) {
302
+ throw new GraphFormatError("E_UNSUPPORTED", `option ${name}: an adjacency table has no columns`, {
303
+ option: name,
304
+ found: o[name],
305
+ });
306
+ }
307
+ }
308
+ if (o.rowNumberIds === true) {
309
+ throw new GraphFormatError("E_UNSUPPORTED", "option rowNumberIds: an adjacency table names its nodes", {
310
+ option: "rowNumberIds",
311
+ found: o.rowNumberIds,
312
+ });
313
+ }
314
+ }
315
+ if (o.rowNumberIds !== undefined && typeof o.rowNumberIds !== "boolean") {
316
+ throw new GraphFormatError("E_UNSUPPORTED", "option rowNumberIds: expected a boolean", {
317
+ option: "rowNumberIds",
318
+ found: o.rowNumberIds,
319
+ });
320
+ }
269
321
  return {
270
322
  delimiter: o.delimiter ?? null,
271
323
  header: o.header ?? "auto",
@@ -275,6 +327,7 @@ function resolveCsvOptions(options: (CsvImportOptions & CommonImportOptions) | u
275
327
  typeColumn: o.typeColumn,
276
328
  idColumn: o.idColumn ?? null,
277
329
  nodes: o.nodes ?? null,
330
+ rowNumberIds: o.rowNumberIds ?? false,
278
331
  };
279
332
  }
280
333
 
@@ -411,6 +464,40 @@ function parseKind(text: string): EdgeKind | null | undefined {
411
464
  }
412
465
  }
413
466
 
467
+ /**
468
+ * Split an adjacency neighbour cell into its id and weight: the text after the LAST colon is the
469
+ * weight when it is a number, nothing when it is empty (`a:1:` is the id `a:1`, unweighted, the
470
+ * form the exporter writes for an id containing a colon), and part of the id otherwise.
471
+ * @param text - the cell text
472
+ * @returns the id text and the weight (undefined when the cell has none)
473
+ */
474
+ export function splitNeighbour(text: string): { readonly id: string; readonly weight: number | undefined } {
475
+ const colon = text.lastIndexOf(":");
476
+ if (colon < 0) {
477
+ return { id: text, weight: undefined };
478
+ }
479
+ const suffix = text.slice(colon + 1);
480
+ if (suffix.length === 0) {
481
+ return { id: text.slice(0, colon), weight: undefined };
482
+ }
483
+ const weight = numberOrNull(suffix);
484
+ return weight === null ? { id: text, weight: undefined } : { id: text.slice(0, colon), weight };
485
+ }
486
+
487
+ /**
488
+ * A weight suffix as a number, or null when the text is not one.
489
+ * @param text - the suffix
490
+ * @returns the number, or null
491
+ */
492
+ function numberOrNull(text: string): number | null {
493
+ try {
494
+ return parseWeightText(text) ?? null;
495
+ } catch {
496
+ // not a number: the colon belongs to the id
497
+ return null;
498
+ }
499
+ }
500
+
414
501
  /**
415
502
  * Read one CSV table into the sink.
416
503
  */
@@ -419,9 +506,16 @@ class TableReader {
419
506
 
420
507
  private readonly reader: CsvRecordReader;
421
508
 
422
- private readonly kind: "edges" | "nodes" | "auto";
509
+ private readonly kind: "edges" | "nodes" | "adjacency" | "auto";
423
510
 
424
- private plan: EdgePlan | NodePlan | null = null;
511
+ /**
512
+ * Whether rowNumberIds applies to this table: a node table, or an "auto" table with no paired
513
+ * node table. Never the edge table of a paired import, which must still fail loudly when its
514
+ * header names no endpoints.
515
+ */
516
+ private readonly rowNumbers: boolean;
517
+
518
+ private plan: EdgePlan | NodePlan | AdjacencyPlan | null = null;
425
519
 
426
520
  private writers: (InferredColumn | null)[] = [];
427
521
 
@@ -445,12 +539,20 @@ class TableReader {
445
539
  * @param kind - what the table is, or "auto"
446
540
  * @param progress - whether this table reports byte progress
447
541
  */
448
- constructor(state: ImportState, input: ImportInput, kind: "edges" | "nodes" | "auto", progress: boolean) {
542
+ constructor(
543
+ state: ImportState,
544
+ input: ImportInput,
545
+ kind: "edges" | "nodes" | "adjacency" | "auto",
546
+ progress: boolean,
547
+ ) {
449
548
  this.state = state;
450
549
  this.kind = kind;
550
+ this.rowNumbers = state.csv.rowNumberIds && (kind === "nodes" || (kind === "auto" && state.csv.nodes === null));
451
551
  const readerOptions: CsvReaderOptions = {
452
552
  delimiter: state.csv.delimiter,
453
553
  comments: COMMENT_CHARS,
554
+ // field counts say nothing about an adjacency table, whose rows vary in width
555
+ skipQuoteErrors: kind === "adjacency",
454
556
  signal: state.common.signal,
455
557
  onProgress: progress ? state.common.onProgress : null,
456
558
  encoding: state.common.encoding,
@@ -477,6 +579,10 @@ class TableReader {
477
579
  private async readRows(iterator: AsyncGenerator<string[], void, undefined>): Promise<void> {
478
580
  const { report } = this.state;
479
581
  const first = await iterator.next();
582
+ if (first.done && this.kind === "adjacency") {
583
+ // an adjacency table has no header: an empty one is the empty graph
584
+ return;
585
+ }
480
586
  const firstRow: string[] = first.done
481
587
  ? report.fail(EMPTY_INPUT_CODE, "the input is empty: no header row and no records")
482
588
  : first.value;
@@ -484,7 +590,14 @@ class TableReader {
484
590
  const firstQuoted = this.reader.quoted.slice(0, firstRow.length);
485
591
  const pending: { row: string[]; quoted: readonly boolean[]; line: number }[] = [];
486
592
  let header: boolean;
487
- const { header: mode } = this.state.csv;
593
+ // an adjacency table has no header unless the caller says so: its rows vary in width; a
594
+ // table numbered by rowNumberIds has one, since a headerless table takes its ids from column 0
595
+ let mode = this.state.csv.header;
596
+ if (mode === "auto" && this.kind === "adjacency") {
597
+ mode = false;
598
+ } else if (mode === "auto" && this.rowNumbers) {
599
+ mode = true;
600
+ }
488
601
  if (mode === "auto") {
489
602
  const second = await iterator.next();
490
603
  const secondRow: string[] | null = second.done ? null : second.value;
@@ -542,8 +655,11 @@ class TableReader {
542
655
  * @param line - the header line
543
656
  * @returns the plan; the import aborts when no endpoints (or id) resolve
544
657
  */
545
- private resolvePlan(names: readonly string[], header: boolean, line: number): EdgePlan | NodePlan {
658
+ private resolvePlan(names: readonly string[], header: boolean, line: number): EdgePlan | NodePlan | AdjacencyPlan {
546
659
  const { csv, report } = this.state;
660
+ if (this.kind === "adjacency") {
661
+ return { kind: "adjacency", names: [] };
662
+ }
547
663
  const width = names.length;
548
664
  let source = -1;
549
665
  let target = -1;
@@ -585,7 +701,7 @@ class TableReader {
585
701
  );
586
702
  }
587
703
  const idResolves = header ? findColumn(names, ID_NAMES) >= 0 : width >= 1;
588
- if (this.kind === "auto" && csv.idColumn === null && !idResolves) {
704
+ if (this.kind === "auto" && csv.idColumn === null && !idResolves && !this.rowNumbers) {
589
705
  report.fail(
590
706
  NO_ENDPOINT_COLUMNS_CODE,
591
707
  `no source / target columns and no id column in the header (${shown}); the input is neither an edge table nor a node table`,
@@ -696,6 +812,10 @@ class TableReader {
696
812
  id = -1;
697
813
  break;
698
814
  default:
815
+ if (idColumn < 0 && this.rowNumbers) {
816
+ id = -1;
817
+ break;
818
+ }
699
819
  if (idColumn < 0) {
700
820
  report.fail(
701
821
  NO_ID_COLUMN_CODE,
@@ -717,7 +837,8 @@ class TableReader {
717
837
  attributes.push(i);
718
838
  }
719
839
  }
720
- return { kind: "nodes", names, width: names.length, id, label, attributes };
840
+ const rowNumber = id < 0 && common.nodeIdFrom !== "index";
841
+ return { kind: "nodes", names, width: names.length, id, rowNumber, label, attributes };
721
842
  }
722
843
 
723
844
  /**
@@ -726,6 +847,9 @@ class TableReader {
726
847
  */
727
848
  private prepareColumns(line: number): void {
728
849
  const plan = this.requirePlan();
850
+ if (plan.kind === "adjacency") {
851
+ return;
852
+ }
729
853
  const { sink, report } = this.state;
730
854
  const domain = plan.kind === "edges" ? "edge" : "node";
731
855
  const origin = { format: "csv" };
@@ -759,7 +883,7 @@ class TableReader {
759
883
  * The plan, which exists once the header was read.
760
884
  * @returns the plan
761
885
  */
762
- private requirePlan(): EdgePlan | NodePlan {
886
+ private requirePlan(): EdgePlan | NodePlan | AdjacencyPlan {
763
887
  if (this.plan === null) {
764
888
  throw new GraphFormatError("E_UNSUPPORTED", "the header has not been read", { reason: "no plan" });
765
889
  }
@@ -777,8 +901,73 @@ class TableReader {
777
901
  this.dataRows++;
778
902
  if (plan.kind === "edges") {
779
903
  this.processEdgeRow(plan, row, quoted, line);
780
- } else {
904
+ } else if (plan.kind === "nodes") {
781
905
  this.processNodeRow(plan, row, quoted, line);
906
+ } else {
907
+ this.processAdjacencyRow(row, quoted, line);
908
+ }
909
+ }
910
+
911
+ /**
912
+ * Push one adjacency row: the node, then one edge per set neighbour cell, in row order.
913
+ * @param row - the cells
914
+ * @param quoted - whether each cell was quoted
915
+ * @param line - the row's line
916
+ */
917
+ private processAdjacencyRow(row: string[], quoted: readonly boolean[], line: number): void {
918
+ const { report, sink, resolver, common } = this.state;
919
+ const { counts } = report;
920
+ const { where } = this;
921
+ where.line = line;
922
+ where.element = null;
923
+ let neighbours = 0;
924
+ for (let k = 1; k < row.length; k++) {
925
+ if (!isUnset(row[k], quoted[k])) {
926
+ neighbours++;
927
+ }
928
+ }
929
+ if (isUnset(row[0], quoted[0])) {
930
+ report.error("missing-value", MISSING_ID_CODE, `line ${line}: blank node cell`, { line });
931
+ counts.skippedNodes++;
932
+ counts.skippedEdges += neighbours;
933
+ return;
934
+ }
935
+ const kind: EdgeKind = (this.state.commentDirected ?? common.defaultDirected) ? "directed" : "undirected";
936
+ if (!this.state.headerSet) {
937
+ this.state.headerSet = true;
938
+ resolver.setHeader(kind !== "undirected", where);
939
+ }
940
+ let source: NodeId;
941
+ try {
942
+ where.element = row[0];
943
+ source = this.coerce(row[0]);
944
+ if (sink.indexOf(source) === INVALID_INDEX) {
945
+ counts.nodes++;
946
+ }
947
+ sink.addNode(source);
948
+ } catch (err) {
949
+ report.recordError(err, where);
950
+ counts.skippedNodes++;
951
+ counts.skippedEdges += neighbours;
952
+ return;
953
+ }
954
+ for (let k = 1; k < row.length; k++) {
955
+ if (isUnset(row[k], quoted[k])) {
956
+ continue;
957
+ }
958
+ where.element = row[k];
959
+ try {
960
+ const cell = splitNeighbour(row[k]);
961
+ const target = this.coerce(cell.id);
962
+ const targetNew = sink.indexOf(target) === INVALID_INDEX;
963
+ const before = sink.edgeCount;
964
+ resolver.addEdge(source, target, kind, common.weightFrom === null ? undefined : cell.weight, where);
965
+ counts.edges += sink.edgeCount - before;
966
+ counts.nodes += targetNew ? 1 : 0;
967
+ } catch (err) {
968
+ report.recordError(err, where);
969
+ counts.skippedEdges++;
970
+ }
782
971
  }
783
972
  }
784
973
 
@@ -911,7 +1100,7 @@ class TableReader {
911
1100
  where.element = idText;
912
1101
  let index: number;
913
1102
  try {
914
- const id = plan.id >= 0 ? this.coerce(idText) : ordinal;
1103
+ const id = plan.id >= 0 || plan.rowNumber ? this.coerce(idText) : ordinal;
915
1104
  if (sink.indexOf(id) !== INVALID_INDEX) {
916
1105
  report.warning(
917
1106
  "merged",
@@ -27,7 +27,7 @@ export const UNCLOSED_QUOTE_CODE = "E_CSV_UNCLOSED_QUOTE";
27
27
  export const BAD_QUOTE_CODE = "E_CSV_QUOTE";
28
28
 
29
29
  /** The delimiters tried, in priority order, when none is given. */
30
- const DELIMITER_CANDIDATES: readonly string[] = Object.freeze([",", "\t", ";", "|", " "]);
30
+ export const DELIMITER_CANDIDATES: readonly string[] = Object.freeze([",", "\t", ";", "|", " "]);
31
31
 
32
32
  /** Rows the delimiter sniff looks at. */
33
33
  const PREVIEW_ROWS = 10;
@@ -67,6 +67,11 @@ export interface RecordSyntax {
67
67
  * sniff. A later line starting with one of them is an ordinary record. Default: none.
68
68
  */
69
69
  readonly comments?: readonly string[] | undefined;
70
+ /**
71
+ * Whether the delimiter sniff skips a candidate under which a closing quote is followed by
72
+ * other text (see sniffDelimiter). False by default: that text then aborts the read.
73
+ */
74
+ readonly skipQuoteErrors?: boolean | undefined;
70
75
  }
71
76
 
72
77
  /**
@@ -117,9 +122,17 @@ export function sniffNewline(text: string): "\n" | "\r" {
117
122
  * @param delimiter - the delimiter
118
123
  * @param quote - the quote character
119
124
  * @param maxRows - the most rows to return
120
- * @returns the rows as cell arrays (blank lines skipped)
125
+ * @param strictQuotes - return null when a closing quote is followed by text other than the
126
+ * delimiter or a line break (the import would abort under this delimiter)
127
+ * @returns the rows as cell arrays (blank lines skipped), or null (see strictQuotes)
121
128
  */
122
- function splitRecords(text: string, delimiter: string, quote: string, maxRows: number): string[][] {
129
+ function splitRecords(
130
+ text: string,
131
+ delimiter: string,
132
+ quote: string,
133
+ maxRows: number,
134
+ strictQuotes = false,
135
+ ): string[][] | null {
123
136
  const rows: string[][] = [];
124
137
  const delimiterCode = delimiter.charCodeAt(0);
125
138
  const quoteCode = quote.charCodeAt(0);
@@ -144,6 +157,9 @@ function splitRecords(text: string, delimiter: string, quote: string, maxRows: n
144
157
  state = QUOTED;
145
158
  continue;
146
159
  }
160
+ if (strictQuotes && c !== delimiterCode && c !== LF && c !== CR) {
161
+ return null;
162
+ }
147
163
  state = AFTER_QUOTED;
148
164
  segment = i;
149
165
  }
@@ -227,6 +243,10 @@ function stripLeadingComments(text: string, comments: readonly string[]): string
227
243
  * kept for callers that sniffed it)
228
244
  * @param candidates - the delimiters to try, in priority order
229
245
  * @param quote - the quote character
246
+ * @param skipQuoteErrors - also never choose a candidate under which a closing quote is followed
247
+ * by other text. Only for inputs whose field counts say nothing (an adjacency table's rows vary in
248
+ * width): elsewhere a stray character after a quote would make a worse candidate win silently
249
+ * instead of aborting the read
230
250
  * @returns the delimiter, or null
231
251
  */
232
252
  export function sniffDelimiter(
@@ -234,6 +254,7 @@ export function sniffDelimiter(
234
254
  newline: "\n" | "\r" = "\n",
235
255
  candidates: readonly string[] = DELIMITER_CANDIDATES,
236
256
  quote = '"',
257
+ skipQuoteErrors = false,
237
258
  ): string | null {
238
259
  let best: string | null = null;
239
260
  let bestDelta = Infinity;
@@ -243,7 +264,12 @@ export function sniffDelimiter(
243
264
  if (delimiter === quote || delimiter === newline) {
244
265
  continue;
245
266
  }
246
- let rows = splitRecords(text, delimiter, quote, PREVIEW_ROWS).filter((row) => !isBlankRow(row));
267
+ const split = splitRecords(text, delimiter, quote, PREVIEW_ROWS, skipQuoteErrors);
268
+ if (split === null) {
269
+ // a quoted cell this delimiter does not close: it is not the file's delimiter
270
+ continue;
271
+ }
272
+ let rows = split.filter((row) => !isBlankRow(row));
247
273
  if (rows.length > 1 && !terminated) {
248
274
  // the preview is a prefix of the file: its last row may be cut short
249
275
  rows = rows.slice(0, -1);
@@ -454,7 +480,9 @@ export class RecordReader implements AsyncIterable<number> {
454
480
  // the sniff is a heuristic over the first rows: a single chunk holding a huge quoted cell
455
481
  // is capped so the candidate scans stay bounded
456
482
  const body = stripLeadingComments(text.slice(0, PREVIEW_CHARS), this.syntax.comments ?? []);
457
- this.delimiterText = sniffDelimiter(body, sniffNewline(body), candidates, this.syntax.quote) ?? candidates[0];
483
+ this.delimiterText =
484
+ sniffDelimiter(body, sniffNewline(body), candidates, this.syntax.quote, this.syntax.skipQuoteErrors) ??
485
+ candidates[0];
458
486
  }
459
487
  }
460
488
 
@@ -736,6 +764,8 @@ export interface CsvReaderOptions extends ReadOptions {
736
764
  readonly delimiter?: string | null | undefined;
737
765
  /** The characters opening a leading comment line (RecordSyntax.comments); none by default. */
738
766
  readonly comments?: readonly string[] | undefined;
767
+ /** RecordSyntax.skipQuoteErrors; false by default. */
768
+ readonly skipQuoteErrors?: boolean | undefined;
739
769
  }
740
770
 
741
771
  /**
@@ -759,7 +789,12 @@ export class CsvRecordReader implements AsyncIterable<string[]> {
759
789
  this.inner = new RecordReader(
760
790
  input,
761
791
  report,
762
- { delimiter: options.delimiter ?? null, quote: '"', comments: options.comments },
792
+ {
793
+ delimiter: options.delimiter ?? null,
794
+ quote: '"',
795
+ comments: options.comments,
796
+ skipQuoteErrors: options.skipQuoteErrors,
797
+ },
763
798
  { signal: options.signal, onProgress: options.onProgress, encoding: options.encoding },
764
799
  );
765
800
  }
@@ -123,6 +123,7 @@ const SKIPPED_NODE_ROLES: ReadonlySet<string> = new Set([
123
123
  "timestamps",
124
124
  "spells",
125
125
  "open",
126
+ "spellsOpen",
126
127
  "timeText",
127
128
  "directed",
128
129
  "pair",
@@ -148,6 +149,7 @@ const SKIPPED_EDGE_ROLES: ReadonlySet<string> = new Set([
148
149
  "timestamps",
149
150
  "spells",
150
151
  "open",
152
+ "spellsOpen",
151
153
  "timeText",
152
154
  "parent",
153
155
  "parents",
@@ -172,6 +172,7 @@ interface RoleColumns {
172
172
  end: Column | null;
173
173
  timestamp: Column | null;
174
174
  spells: Column | null;
175
+ spellsOpen: Column | null;
175
176
  timestamps: Column | null;
176
177
  open: Column | null;
177
178
  position: Column | null;
@@ -417,6 +418,8 @@ function roleShapeOk(role: string, column: Column, domain: "node" | "edge"): boo
417
418
  return domain === "edge" && isNumericScalar(column);
418
419
  case "spells":
419
420
  return isNumericList(column, 2);
421
+ case "spellsOpen":
422
+ return column.dtype === "list" && column.meta.itemDtype === "u8";
420
423
  case "timestamps":
421
424
  return isNumericList(column, 1);
422
425
  case "position":
@@ -442,6 +445,7 @@ const MAPPED_ROLES: ReadonlySet<string> = new Set([
442
445
  "end",
443
446
  "timestamp",
444
447
  "spells",
448
+ "spellsOpen",
445
449
  "timestamps",
446
450
  "open",
447
451
  "position",
@@ -462,6 +466,7 @@ const ROLE_NAMES: Readonly<Record<string, string>> = Object.freeze({
462
466
  end: NODE_COLUMNS.end,
463
467
  timestamp: NODE_COLUMNS.timestamp,
464
468
  spells: NODE_COLUMNS.spells,
469
+ spellsOpen: NODE_COLUMNS.spellsOpen,
465
470
  timestamps: NODE_COLUMNS.timestamps,
466
471
  open: NODE_COLUMNS.open,
467
472
  position: NODE_COLUMNS.position,
@@ -495,6 +500,7 @@ function collectRoles(
495
500
  end: null,
496
501
  timestamp: null,
497
502
  spells: null,
503
+ spellsOpen: null,
498
504
  timestamps: null,
499
505
  open: null,
500
506
  position: null,
@@ -1197,6 +1203,8 @@ function lifetimeAttrs(roles: RoleColumns, row: number, plan: ExportPlan): strin
1197
1203
 
1198
1204
  /**
1199
1205
  * The `<spells>` element of one element: its spells column, plus (1.2) its timestamps as [t, t].
1206
+ * In 1.2 a spell whose spells.open bits mark a bound open writes that bound as `startopen` /
1207
+ * `endopen`; 1.3 has no open bounds, and check() reports the loss.
1200
1208
  * @param roles - the domain's role columns
1201
1209
  * @param row - the element's row
1202
1210
  * @param plan - the plan
@@ -1204,29 +1212,35 @@ function lifetimeAttrs(roles: RoleColumns, row: number, plan: ExportPlan): strin
1204
1212
  * @returns the lines, or an empty string
1205
1213
  */
1206
1214
  function spellsElement(roles: RoleColumns, row: number, plan: ExportPlan, indent: string): string {
1207
- const pairs: (readonly [number, number])[] = [];
1215
+ const pairs: (readonly [number, number, number])[] = [];
1208
1216
  if (roles.spells !== null && roles.spells.isSet(row)) {
1209
- for (const pair of listItems(roles.spells, row)) {
1217
+ const open =
1218
+ plan.version === "1.2" && roles.spellsOpen !== null && roles.spellsOpen.isSet(row)
1219
+ ? listItems(roles.spellsOpen, row)
1220
+ : [];
1221
+ listItems(roles.spells, row).forEach((pair, i) => {
1210
1222
  const [s, e] = Array.from(pair as ArrayLike<number>);
1211
- pairs.push([s, e]);
1212
- }
1223
+ pairs.push([s, e, (open[i] as number | undefined) ?? 0]);
1224
+ });
1213
1225
  }
1214
1226
  if (plan.version === "1.2" && roles.timestamps !== null && roles.timestamps.isSet(row)) {
1215
1227
  for (const t of listItems(roles.timestamps, row)) {
1216
- pairs.push([t as number, t as number]);
1228
+ pairs.push([t as number, t as number, 0]);
1217
1229
  }
1218
1230
  }
1219
1231
  if (pairs.length === 0) {
1220
1232
  return "";
1221
1233
  }
1222
1234
  let out = `${indent}<spells>\n`;
1223
- for (const [s, e] of pairs) {
1235
+ for (const [s, e, open] of pairs) {
1224
1236
  let attrs = "";
1225
1237
  if (Number.isFinite(s)) {
1226
- attrs += ` start="${escapeXmlAttribute(formatTimeValue(s, plan.timeFormat))}"`;
1238
+ const name = (open & OPEN_START) === 0 ? "start" : "startopen";
1239
+ attrs += ` ${name}="${escapeXmlAttribute(formatTimeValue(s, plan.timeFormat))}"`;
1227
1240
  }
1228
1241
  if (Number.isFinite(e)) {
1229
- attrs += ` end="${escapeXmlAttribute(formatTimeValue(e, plan.timeFormat))}"`;
1242
+ const name = (open & OPEN_END) === 0 ? "end" : "endopen";
1243
+ attrs += ` ${name}="${escapeXmlAttribute(formatTimeValue(e, plan.timeFormat))}"`;
1230
1244
  }
1231
1245
  out += `${indent} <spell${attrs}/>\n`;
1232
1246
  }
@@ -1551,7 +1565,7 @@ function* writeGexf(
1551
1565
  const edgeList = snapshot.edgeList();
1552
1566
  const folding = pairFolding(snapshot, { foldMutual: true });
1553
1567
  const weights = explicitWeights(snapshot);
1554
- const defaultType: GexfEdgeType = snapshot.directed ? "directed" : "undirected";
1568
+ const defaultType = defaultEdgeType(snapshot);
1555
1569
  let written = 0;
1556
1570
  for (let e = 0; e < snapshot.edgeCount; e++) {
1557
1571
  if (edgeType(snapshot, e, folding) !== null) {
@@ -1626,7 +1640,7 @@ function metaElement(meta: GraphSnapshot["meta"]): string {
1626
1640
  */
1627
1641
  function graphStart(snapshot: GraphSnapshot, plan: ExportPlan): string {
1628
1642
  const { meta } = snapshot;
1629
- let attrs = ` defaultedgetype="${snapshot.directed ? "directed" : "undirected"}"`;
1643
+ let attrs = ` defaultedgetype="${defaultEdgeType(snapshot)}"`;
1630
1644
  let mode = meta.mode ?? "static";
1631
1645
  if (plan.temporal && mode === "static") {
1632
1646
  mode = "dynamic";
@@ -1676,7 +1690,22 @@ function graphStart(snapshot: GraphSnapshot, plan: ExportPlan): string {
1676
1690
  }
1677
1691
 
1678
1692
  /**
1679
- * A graph header value the GEXF importer recorded in `meta.extra.gexf` (start, end, timestamp).
1693
+ * The `defaultedgetype` to write: the snapshot's direction, or `mutual` for a directed snapshot
1694
+ * whose source file declared a mutual default (the importer reads that default as directed, with
1695
+ * every untyped edge mutual), so the default survives a round trip.
1696
+ * @param snapshot - the snapshot
1697
+ * @returns the edge type the header declares, and every edge without its own `type` takes
1698
+ */
1699
+ function defaultEdgeType(snapshot: GraphSnapshot): GexfEdgeType {
1700
+ if (!snapshot.directed) {
1701
+ return "undirected";
1702
+ }
1703
+ return graphExtraText(snapshot, "defaultedgetype")?.trim().toLowerCase() === "mutual" ? "mutual" : "directed";
1704
+ }
1705
+
1706
+ /**
1707
+ * A graph header value the GEXF importer recorded in `meta.extra.gexf` (defaultedgetype, start,
1708
+ * end, timestamp).
1680
1709
  * @param snapshot - the snapshot
1681
1710
  * @param key - the attribute name
1682
1711
  * @returns the text, or null when absent