@graphty/graph-io 0.3.8 → 0.3.10
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.
- package/README.md +29 -5
- package/dist/chunks/{escape-BjmIaFFo.js → escape-D1f9-cwf.js} +4 -4
- package/dist/chunks/{escape-BjmIaFFo.js.map → escape-D1f9-cwf.js.map} +1 -1
- package/dist/chunks/{importer-BddBW3sf.js → importer-9PxhqH4v.js} +336 -92
- package/dist/chunks/importer-9PxhqH4v.js.map +1 -0
- package/dist/chunks/{importer-DfI8POXb.js → importer-B8lsjFWx.js} +6 -5
- package/dist/chunks/importer-B8lsjFWx.js.map +1 -0
- package/dist/chunks/{importer-BtHFICnf.js → importer-C3HcYWAn.js} +5 -3
- package/dist/chunks/importer-C3HcYWAn.js.map +1 -0
- package/dist/chunks/{importer-BOxwmef_.js → importer-C7mnGdr_.js} +125 -8
- package/dist/chunks/importer-C7mnGdr_.js.map +1 -0
- package/dist/chunks/{records-BKSMowhR.js → records-IHsCfv7s.js} +20 -7
- package/dist/chunks/records-IHsCfv7s.js.map +1 -0
- package/dist/chunks/{writer-BdMak4_J.js → writer-BtWpUaiH.js} +4 -4
- package/dist/chunks/{writer-BdMak4_J.js.map → writer-BtWpUaiH.js.map} +1 -1
- package/dist/csv.js +239 -18
- package/dist/csv.js.map +1 -1
- package/dist/dot.js +1 -1
- package/dist/gexf.js +72 -33
- package/dist/gexf.js.map +1 -1
- package/dist/gml.js +44 -23
- package/dist/gml.js.map +1 -1
- package/dist/graph-io.js +11 -11
- package/dist/graphml.js +1 -1
- package/dist/json.js +1 -1
- package/dist/neo4j.js +5 -4
- package/dist/neo4j.js.map +1 -1
- package/dist/pajek.js +1 -1
- package/dist/src/common/export.js +1 -1
- package/dist/src/common/export.js.map +1 -1
- package/dist/src/formats/csv/exporter.d.ts +15 -4
- package/dist/src/formats/csv/exporter.d.ts.map +1 -1
- package/dist/src/formats/csv/exporter.js +125 -6
- package/dist/src/formats/csv/exporter.js.map +1 -1
- package/dist/src/formats/csv/importer.d.ts +36 -4
- package/dist/src/formats/csv/importer.d.ts.map +1 -1
- package/dist/src/formats/csv/importer.js +169 -8
- package/dist/src/formats/csv/importer.js.map +1 -1
- package/dist/src/formats/csv/records.d.ts +14 -1
- package/dist/src/formats/csv/records.d.ts.map +1 -1
- package/dist/src/formats/csv/records.js +28 -7
- package/dist/src/formats/csv/records.js.map +1 -1
- package/dist/src/formats/dot/exporter.d.ts.map +1 -1
- package/dist/src/formats/dot/exporter.js +2 -0
- package/dist/src/formats/dot/exporter.js.map +1 -1
- package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
- package/dist/src/formats/gexf/exporter.js +36 -10
- package/dist/src/formats/gexf/exporter.js.map +1 -1
- package/dist/src/formats/gexf/importer.d.ts +9 -4
- package/dist/src/formats/gexf/importer.d.ts.map +1 -1
- package/dist/src/formats/gexf/importer.js +44 -21
- package/dist/src/formats/gexf/importer.js.map +1 -1
- package/dist/src/formats/gexf/index.d.ts +1 -1
- package/dist/src/formats/gexf/index.d.ts.map +1 -1
- package/dist/src/formats/gexf/index.js +1 -1
- package/dist/src/formats/gexf/index.js.map +1 -1
- package/dist/src/formats/gexf/schema.d.ts +2 -0
- package/dist/src/formats/gexf/schema.d.ts.map +1 -1
- package/dist/src/formats/gexf/schema.js +18 -0
- package/dist/src/formats/gexf/schema.js.map +1 -1
- package/dist/src/formats/gml/exporter.d.ts.map +1 -1
- package/dist/src/formats/gml/exporter.js +1 -0
- package/dist/src/formats/gml/exporter.js.map +1 -1
- package/dist/src/formats/gml/importer.d.ts +7 -4
- package/dist/src/formats/gml/importer.d.ts.map +1 -1
- package/dist/src/formats/gml/importer.js +32 -19
- package/dist/src/formats/gml/importer.js.map +1 -1
- package/dist/src/formats/gml/index.d.ts +3 -1
- package/dist/src/formats/gml/index.d.ts.map +1 -1
- package/dist/src/formats/gml/index.js +4 -2
- package/dist/src/formats/gml/index.js.map +1 -1
- package/dist/src/formats/graphml/constants.d.ts +2 -0
- package/dist/src/formats/graphml/constants.d.ts.map +1 -1
- package/dist/src/formats/graphml/constants.js +2 -0
- package/dist/src/formats/graphml/constants.js.map +1 -1
- package/dist/src/formats/graphml/exporter.d.ts.map +1 -1
- package/dist/src/formats/graphml/exporter.js +14 -2
- package/dist/src/formats/graphml/exporter.js.map +1 -1
- package/dist/src/formats/graphml/importer.d.ts +2 -1
- package/dist/src/formats/graphml/importer.d.ts.map +1 -1
- package/dist/src/formats/graphml/importer.js +91 -27
- package/dist/src/formats/graphml/importer.js.map +1 -1
- package/dist/src/formats/graphml/yfiles.d.ts +62 -0
- package/dist/src/formats/graphml/yfiles.d.ts.map +1 -0
- package/dist/src/formats/graphml/yfiles.js +269 -0
- package/dist/src/formats/graphml/yfiles.js.map +1 -0
- package/dist/src/formats/json/exporter.d.ts.map +1 -1
- package/dist/src/formats/json/exporter.js +9 -1
- package/dist/src/formats/json/exporter.js.map +1 -1
- package/dist/src/formats/json/importer.d.ts +14 -0
- package/dist/src/formats/json/importer.d.ts.map +1 -1
- package/dist/src/formats/json/importer.js +165 -4
- package/dist/src/formats/json/importer.js.map +1 -1
- package/dist/src/formats/neo4j/exporter.d.ts.map +1 -1
- package/dist/src/formats/neo4j/exporter.js +1 -0
- package/dist/src/formats/neo4j/exporter.js.map +1 -1
- package/dist/src/formats/pajek/exporter.d.ts.map +1 -1
- package/dist/src/formats/pajek/exporter.js +3 -2
- package/dist/src/formats/pajek/exporter.js.map +1 -1
- package/package.json +2 -2
- package/src/common/export.ts +1 -1
- package/src/formats/csv/exporter.ts +140 -10
- package/src/formats/csv/importer.ts +207 -18
- package/src/formats/csv/records.ts +41 -6
- package/src/formats/dot/exporter.ts +2 -0
- package/src/formats/gexf/exporter.ts +40 -11
- package/src/formats/gexf/importer.ts +47 -25
- package/src/formats/gexf/index.ts +1 -1
- package/src/formats/gexf/schema.ts +18 -0
- package/src/formats/gml/exporter.ts +1 -0
- package/src/formats/gml/importer.ts +47 -24
- package/src/formats/gml/index.ts +4 -1
- package/src/formats/graphml/constants.ts +2 -0
- package/src/formats/graphml/exporter.ts +21 -2
- package/src/formats/graphml/importer.ts +99 -28
- package/src/formats/graphml/yfiles.ts +292 -0
- package/src/formats/json/exporter.ts +9 -1
- package/src/formats/json/importer.ts +202 -4
- package/src/formats/neo4j/exporter.ts +1 -0
- package/src/formats/pajek/exporter.ts +3 -1
- package/dist/chunks/importer-BOxwmef_.js.map +0 -1
- package/dist/chunks/importer-BddBW3sf.js.map +0 -1
- package/dist/chunks/importer-BtHFICnf.js.map +0 -1
- package/dist/chunks/importer-DfI8POXb.js.map +0 -1
- 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,
|
|
84
|
-
*
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
* @
|
|
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(
|
|
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
|
-
|
|
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 =
|
|
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
|
-
{
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
*
|
|
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
|