@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.
- package/dist/chunks/{choose-DrXtu_96.js → choose-DT5da6OC.js} +2 -2
- package/dist/chunks/{choose-DrXtu_96.js.map → choose-DT5da6OC.js.map} +1 -1
- package/dist/chunks/{constants-ikG85dT8.js → constants-DowRD9ai.js} +2 -2
- package/dist/chunks/{constants-ikG85dT8.js.map → constants-DowRD9ai.js.map} +1 -1
- package/dist/chunks/{escape-D1HPTUrP.js → escape-DQC5HWqF.js} +2 -2
- package/dist/chunks/{escape-D1HPTUrP.js.map → escape-DQC5HWqF.js.map} +1 -1
- package/dist/chunks/{export-CHd4tQE2.js → export-CVi12U7J.js} +81 -60
- package/dist/chunks/export-CVi12U7J.js.map +1 -0
- package/dist/chunks/{exporter-D23CKNqF.js → exporter-C3Pa2PSd.js} +5 -5
- package/dist/chunks/exporter-C3Pa2PSd.js.map +1 -0
- package/dist/chunks/{exporter-DfWTgrti.js → exporter-CgsO2nSl.js} +4 -4
- package/dist/chunks/{exporter-DfWTgrti.js.map → exporter-CgsO2nSl.js.map} +1 -1
- package/dist/chunks/{exporter-IHv4nkCm.js → exporter-Cy_1da4c.js} +72 -48
- package/dist/chunks/exporter-Cy_1da4c.js.map +1 -0
- package/dist/chunks/{exporter-DhtW6FxK.js → exporter-JubAlNWi.js} +34 -34
- package/dist/chunks/exporter-JubAlNWi.js.map +1 -0
- package/dist/chunks/{importer-CIAzt-wo.js → importer-BA_hZKo7.js} +4 -4
- package/dist/chunks/{importer-CIAzt-wo.js.map → importer-BA_hZKo7.js.map} +1 -1
- package/dist/chunks/{importer-B1wmPQjr.js → importer-CDXlxXlS.js} +35 -9
- package/dist/chunks/importer-CDXlxXlS.js.map +1 -0
- package/dist/chunks/{importer-BVRE--Un.js → importer-DLz2Vb4q.js} +3 -3
- package/dist/chunks/{importer-BVRE--Un.js.map → importer-DLz2Vb4q.js.map} +1 -1
- package/dist/chunks/{importer-B2W_58pz.js → importer-DVxzzibh.js} +4 -4
- package/dist/chunks/{importer-B2W_58pz.js.map → importer-DVxzzibh.js.map} +1 -1
- package/dist/chunks/{importer-BSALvkui.js → importer-Dej72hIF.js} +6 -6
- package/dist/chunks/importer-Dej72hIF.js.map +1 -0
- package/dist/chunks/{importer-z_FL4VMb.js → importer-lLH9gQF_.js} +5 -5
- package/dist/chunks/{importer-z_FL4VMb.js.map → importer-lLH9gQF_.js.map} +1 -1
- package/dist/chunks/{importer-DpEW5zeG.js → importer-q-R-Q98w.js} +4 -4
- package/dist/chunks/{importer-DpEW5zeG.js.map → importer-q-R-Q98w.js.map} +1 -1
- package/dist/chunks/{json-elements-BAmNBZok.js → json-elements-1IUzkRNy.js} +3 -3
- package/dist/chunks/{json-elements-BAmNBZok.js.map → json-elements-1IUzkRNy.js.map} +1 -1
- package/dist/chunks/{ontology-export-Bab4eUA8.js → ontology-export-b0GLNIj-.js} +2 -2
- package/dist/chunks/{ontology-export-Bab4eUA8.js.map → ontology-export-b0GLNIj-.js.map} +1 -1
- package/dist/chunks/{records-CdlvL4RO.js → records-CdyqwXeY.js} +2 -2
- package/dist/chunks/{records-CdlvL4RO.js.map → records-CdyqwXeY.js.map} +1 -1
- package/dist/chunks/{text-DzXjAiTl.js → text-CDgjbkLC.js} +2 -2
- package/dist/chunks/{text-DzXjAiTl.js.map → text-CDgjbkLC.js.map} +1 -1
- package/dist/csv.js +33 -16
- package/dist/csv.js.map +1 -1
- package/dist/cx.js +1 -1
- package/dist/cx2.js +2 -2
- package/dist/cys.js +1 -1
- package/dist/dot.js +1 -1
- package/dist/gexf.js +2 -2
- package/dist/gml.js +18 -9
- package/dist/gml.js.map +1 -1
- package/dist/graph-io.js +26 -26
- package/dist/graph-io.js.map +1 -1
- package/dist/graphml.js +2 -2
- package/dist/json.js +1 -1
- package/dist/neo4j.js +3 -3
- package/dist/obo.js +1 -1
- package/dist/pajek.js +1 -1
- package/dist/src/common/declared-types.d.ts.map +1 -1
- package/dist/src/common/declared-types.js.map +1 -1
- package/dist/src/common/export.d.ts +6 -0
- package/dist/src/common/export.d.ts.map +1 -1
- package/dist/src/common/export.js +62 -28
- package/dist/src/common/export.js.map +1 -1
- package/dist/src/common/zip.js.map +1 -1
- package/dist/src/formats/csv/exporter.d.ts.map +1 -1
- package/dist/src/formats/csv/exporter.js +18 -7
- package/dist/src/formats/csv/exporter.js.map +1 -1
- package/dist/src/formats/csv/importer.d.ts.map +1 -1
- package/dist/src/formats/csv/importer.js +20 -2
- package/dist/src/formats/csv/importer.js.map +1 -1
- package/dist/src/formats/cx2/exporter.d.ts.map +1 -1
- package/dist/src/formats/cx2/exporter.js +84 -33
- package/dist/src/formats/cx2/exporter.js.map +1 -1
- package/dist/src/formats/dot/exporter.js +30 -4
- package/dist/src/formats/dot/exporter.js.map +1 -1
- package/dist/src/formats/dot/importer.js.map +1 -1
- package/dist/src/formats/gml/exporter.d.ts.map +1 -1
- package/dist/src/formats/gml/exporter.js +23 -6
- package/dist/src/formats/gml/exporter.js.map +1 -1
- package/dist/src/formats/obo/syntax.d.ts +4 -0
- package/dist/src/formats/obo/syntax.d.ts.map +1 -1
- package/dist/src/formats/obo/syntax.js +35 -31
- package/dist/src/formats/obo/syntax.js.map +1 -1
- package/dist/src/formats/pajek/importer.js.map +1 -1
- package/dist/src/registry.d.ts.map +1 -1
- package/dist/src/registry.js.map +1 -1
- package/dist/src/sniff.d.ts.map +1 -1
- package/dist/src/sniff.js.map +1 -1
- package/dist/src/types.d.ts +6 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/xgmml.js +2 -2
- package/package.json +2 -2
- package/src/common/declared-types.ts +1 -10
- package/src/common/export.ts +105 -62
- package/src/common/zip.ts +1 -1
- package/src/formats/csv/exporter.ts +19 -15
- package/src/formats/csv/importer.ts +21 -2
- package/src/formats/cx2/exporter.ts +123 -49
- package/src/formats/dot/exporter.ts +37 -4
- package/src/formats/dot/importer.ts +1 -1
- package/src/formats/gml/exporter.ts +24 -6
- package/src/formats/obo/syntax.ts +35 -31
- package/src/formats/pajek/importer.ts +1 -1
- package/src/registry.ts +9 -10
- package/src/sniff.ts +2 -21
- package/src/types.ts +7 -7
- package/dist/chunks/export-CHd4tQE2.js.map +0 -1
- package/dist/chunks/exporter-D23CKNqF.js.map +0 -1
- package/dist/chunks/exporter-DhtW6FxK.js.map +0 -1
- package/dist/chunks/exporter-IHv4nkCm.js.map +0 -1
- package/dist/chunks/importer-B1wmPQjr.js.map +0 -1
- package/dist/chunks/importer-BSALvkui.js.map +0 -1
package/src/common/export.ts
CHANGED
|
@@ -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
|
-
|
|
358
|
-
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
|
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
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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, {
|
|
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)
|
|
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
|
-
*
|
|
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
|
-
* @
|
|
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
|
|
280
|
-
|
|
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
|
|
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
|
-
|
|
316
|
-
|
|
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
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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);
|