@graphty/graph-io 0.0.0 → 0.2.1
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/LICENSE +21 -0
- package/README.md +250 -28
- package/dist/chunks/children-CL3Cy0ez.js +238 -0
- package/dist/chunks/children-CL3Cy0ez.js.map +1 -0
- package/dist/chunks/escape-DyI8JofU.js +938 -0
- package/dist/chunks/escape-DyI8JofU.js.map +1 -0
- package/dist/chunks/importer-CQnJuWJw.js +2987 -0
- package/dist/chunks/importer-CQnJuWJw.js.map +1 -0
- package/dist/chunks/importer-CpCpfbxr.js +2015 -0
- package/dist/chunks/importer-CpCpfbxr.js.map +1 -0
- package/dist/chunks/importer-DbnGYr3_.js +2342 -0
- package/dist/chunks/importer-DbnGYr3_.js.map +1 -0
- package/dist/chunks/importer-GozH8DkN.js +3050 -0
- package/dist/chunks/importer-GozH8DkN.js.map +1 -0
- package/dist/chunks/records-CGpxszm1.js +605 -0
- package/dist/chunks/records-CGpxszm1.js.map +1 -0
- package/dist/chunks/text-CajMdVFy.js +189 -0
- package/dist/chunks/text-CajMdVFy.js.map +1 -0
- package/dist/chunks/writer-DxSKC7TL.js +2842 -0
- package/dist/chunks/writer-DxSKC7TL.js.map +1 -0
- package/dist/csv.d.ts +1 -0
- package/dist/csv.js +1702 -0
- package/dist/csv.js.map +1 -0
- package/dist/dot.d.ts +1 -0
- package/dist/dot.js +8 -0
- package/dist/dot.js.map +1 -0
- package/dist/gexf.d.ts +1 -0
- package/dist/gexf.js +3466 -0
- package/dist/gexf.js.map +1 -0
- package/dist/gml.d.ts +1 -0
- package/dist/gml.js +2647 -0
- package/dist/gml.js.map +1 -0
- package/dist/graph-io.d.ts +1 -0
- package/dist/graph-io.js +790 -0
- package/dist/graph-io.js.map +1 -0
- package/dist/graphml.d.ts +1 -0
- package/dist/graphml.js +8 -0
- package/dist/graphml.js.map +1 -0
- package/dist/json.d.ts +1 -0
- package/dist/json.js +11 -0
- package/dist/json.js.map +1 -0
- package/dist/neo4j.d.ts +1 -0
- package/dist/neo4j.js +2046 -0
- package/dist/neo4j.js.map +1 -0
- package/dist/pajek.d.ts +1 -0
- package/dist/pajek.js +8 -0
- package/dist/pajek.js.map +1 -0
- package/dist/src/children.d.ts +134 -0
- package/dist/src/children.d.ts.map +1 -0
- package/dist/src/children.js +274 -0
- package/dist/src/children.js.map +1 -0
- package/dist/src/common/attributes.d.ts +229 -0
- package/dist/src/common/attributes.d.ts.map +1 -0
- package/dist/src/common/attributes.js +368 -0
- package/dist/src/common/attributes.js.map +1 -0
- package/dist/src/common/codes.d.ts +105 -0
- package/dist/src/common/codes.d.ts.map +1 -0
- package/dist/src/common/codes.js +107 -0
- package/dist/src/common/codes.js.map +1 -0
- package/dist/src/common/declared-types.d.ts +84 -0
- package/dist/src/common/declared-types.d.ts.map +1 -0
- package/dist/src/common/declared-types.js +326 -0
- package/dist/src/common/declared-types.js.map +1 -0
- package/dist/src/common/direction.d.ts +206 -0
- package/dist/src/common/direction.d.ts.map +1 -0
- package/dist/src/common/direction.js +370 -0
- package/dist/src/common/direction.js.map +1 -0
- package/dist/src/common/escape.d.ts +92 -0
- package/dist/src/common/escape.d.ts.map +1 -0
- package/dist/src/common/escape.js +212 -0
- package/dist/src/common/escape.js.map +1 -0
- package/dist/src/common/export.d.ts +249 -0
- package/dist/src/common/export.d.ts.map +1 -0
- package/dist/src/common/export.js +594 -0
- package/dist/src/common/export.js.map +1 -0
- package/dist/src/common/format.d.ts +59 -0
- package/dist/src/common/format.d.ts.map +1 -0
- package/dist/src/common/format.js +106 -0
- package/dist/src/common/format.js.map +1 -0
- package/dist/src/common/ids.d.ts +83 -0
- package/dist/src/common/ids.d.ts.map +1 -0
- package/dist/src/common/ids.js +158 -0
- package/dist/src/common/ids.js.map +1 -0
- package/dist/src/common/input.d.ts +100 -0
- package/dist/src/common/input.d.ts.map +1 -0
- package/dist/src/common/input.js +335 -0
- package/dist/src/common/input.js.map +1 -0
- package/dist/src/common/lists.d.ts +34 -0
- package/dist/src/common/lists.d.ts.map +1 -0
- package/dist/src/common/lists.js +185 -0
- package/dist/src/common/lists.js.map +1 -0
- package/dist/src/common/options.d.ts +108 -0
- package/dist/src/common/options.d.ts.map +1 -0
- package/dist/src/common/options.js +265 -0
- package/dist/src/common/options.js.map +1 -0
- package/dist/src/common/report.d.ts +187 -0
- package/dist/src/common/report.d.ts.map +1 -0
- package/dist/src/common/report.js +274 -0
- package/dist/src/common/report.js.map +1 -0
- package/dist/src/common/temporal.d.ts +71 -0
- package/dist/src/common/temporal.d.ts.map +1 -0
- package/dist/src/common/temporal.js +266 -0
- package/dist/src/common/temporal.js.map +1 -0
- package/dist/src/common/text.d.ts +104 -0
- package/dist/src/common/text.d.ts.map +1 -0
- package/dist/src/common/text.js +255 -0
- package/dist/src/common/text.js.map +1 -0
- package/dist/src/common/weights.d.ts +77 -0
- package/dist/src/common/weights.d.ts.map +1 -0
- package/dist/src/common/weights.js +156 -0
- package/dist/src/common/weights.js.map +1 -0
- package/dist/src/common/writer.d.ts +51 -0
- package/dist/src/common/writer.d.ts.map +1 -0
- package/dist/src/common/writer.js +108 -0
- package/dist/src/common/writer.js.map +1 -0
- package/dist/src/common/xml.d.ts +245 -0
- package/dist/src/common/xml.d.ts.map +1 -0
- package/dist/src/common/xml.js +942 -0
- package/dist/src/common/xml.js.map +1 -0
- package/dist/src/formats/csv/exporter.d.ts +70 -0
- package/dist/src/formats/csv/exporter.d.ts.map +1 -0
- package/dist/src/formats/csv/exporter.js +682 -0
- package/dist/src/formats/csv/exporter.js.map +1 -0
- package/dist/src/formats/csv/header.d.ts +66 -0
- package/dist/src/formats/csv/header.d.ts.map +1 -0
- package/dist/src/formats/csv/header.js +152 -0
- package/dist/src/formats/csv/header.js.map +1 -0
- package/dist/src/formats/csv/importer.d.ts +82 -0
- package/dist/src/formats/csv/importer.d.ts.map +1 -0
- package/dist/src/formats/csv/importer.js +849 -0
- package/dist/src/formats/csv/importer.js.map +1 -0
- package/dist/src/formats/csv/index.d.ts +60 -0
- package/dist/src/formats/csv/index.d.ts.map +1 -0
- package/dist/src/formats/csv/index.js +63 -0
- package/dist/src/formats/csv/index.js.map +1 -0
- package/dist/src/formats/csv/records.d.ts +188 -0
- package/dist/src/formats/csv/records.d.ts.map +1 -0
- package/dist/src/formats/csv/records.js +702 -0
- package/dist/src/formats/csv/records.js.map +1 -0
- package/dist/src/formats/csv/values.d.ts +105 -0
- package/dist/src/formats/csv/values.d.ts.map +1 -0
- package/dist/src/formats/csv/values.js +192 -0
- package/dist/src/formats/csv/values.js.map +1 -0
- package/dist/src/formats/dot/exporter.d.ts +52 -0
- package/dist/src/formats/dot/exporter.d.ts.map +1 -0
- package/dist/src/formats/dot/exporter.js +836 -0
- package/dist/src/formats/dot/exporter.js.map +1 -0
- package/dist/src/formats/dot/importer.d.ts +102 -0
- package/dist/src/formats/dot/importer.d.ts.map +1 -0
- package/dist/src/formats/dot/importer.js +1291 -0
- package/dist/src/formats/dot/importer.js.map +1 -0
- package/dist/src/formats/dot/index.d.ts +7 -0
- package/dist/src/formats/dot/index.d.ts.map +1 -0
- package/dist/src/formats/dot/index.js +7 -0
- package/dist/src/formats/dot/index.js.map +1 -0
- package/dist/src/formats/dot/names.d.ts +29 -0
- package/dist/src/formats/dot/names.d.ts.map +1 -0
- package/dist/src/formats/dot/names.js +28 -0
- package/dist/src/formats/dot/names.js.map +1 -0
- package/dist/src/formats/dot/tokenizer.d.ts +114 -0
- package/dist/src/formats/dot/tokenizer.d.ts.map +1 -0
- package/dist/src/formats/dot/tokenizer.js +341 -0
- package/dist/src/formats/dot/tokenizer.js.map +1 -0
- package/dist/src/formats/gexf/exporter.d.ts +56 -0
- package/dist/src/formats/gexf/exporter.d.ts.map +1 -0
- package/dist/src/formats/gexf/exporter.js +1395 -0
- package/dist/src/formats/gexf/exporter.js.map +1 -0
- package/dist/src/formats/gexf/importer.d.ts +73 -0
- package/dist/src/formats/gexf/importer.d.ts.map +1 -0
- package/dist/src/formats/gexf/importer.js +1880 -0
- package/dist/src/formats/gexf/importer.js.map +1 -0
- package/dist/src/formats/gexf/index.d.ts +96 -0
- package/dist/src/formats/gexf/index.d.ts.map +1 -0
- package/dist/src/formats/gexf/index.js +97 -0
- package/dist/src/formats/gexf/index.js.map +1 -0
- package/dist/src/formats/gexf/schema.d.ts +135 -0
- package/dist/src/formats/gexf/schema.d.ts.map +1 -0
- package/dist/src/formats/gexf/schema.js +323 -0
- package/dist/src/formats/gexf/schema.js.map +1 -0
- package/dist/src/formats/gml/exporter.d.ts +69 -0
- package/dist/src/formats/gml/exporter.d.ts.map +1 -0
- package/dist/src/formats/gml/exporter.js +1093 -0
- package/dist/src/formats/gml/exporter.js.map +1 -0
- package/dist/src/formats/gml/importer.d.ts +66 -0
- package/dist/src/formats/gml/importer.d.ts.map +1 -0
- package/dist/src/formats/gml/importer.js +1331 -0
- package/dist/src/formats/gml/importer.js.map +1 -0
- package/dist/src/formats/gml/index.d.ts +85 -0
- package/dist/src/formats/gml/index.d.ts.map +1 -0
- package/dist/src/formats/gml/index.js +88 -0
- package/dist/src/formats/gml/index.js.map +1 -0
- package/dist/src/formats/gml/syntax.d.ts +186 -0
- package/dist/src/formats/gml/syntax.d.ts.map +1 -0
- package/dist/src/formats/gml/syntax.js +467 -0
- package/dist/src/formats/gml/syntax.js.map +1 -0
- package/dist/src/formats/graphml/constants.d.ts +169 -0
- package/dist/src/formats/graphml/constants.d.ts.map +1 -0
- package/dist/src/formats/graphml/constants.js +165 -0
- package/dist/src/formats/graphml/constants.js.map +1 -0
- package/dist/src/formats/graphml/exporter.d.ts +34 -0
- package/dist/src/formats/graphml/exporter.d.ts.map +1 -0
- package/dist/src/formats/graphml/exporter.js +1176 -0
- package/dist/src/formats/graphml/exporter.js.map +1 -0
- package/dist/src/formats/graphml/importer.d.ts +31 -0
- package/dist/src/formats/graphml/importer.d.ts.map +1 -0
- package/dist/src/formats/graphml/importer.js +1607 -0
- package/dist/src/formats/graphml/importer.js.map +1 -0
- package/dist/src/formats/graphml/index.d.ts +8 -0
- package/dist/src/formats/graphml/index.d.ts.map +1 -0
- package/dist/src/formats/graphml/index.js +8 -0
- package/dist/src/formats/graphml/index.js.map +1 -0
- package/dist/src/formats/graphml/tree.d.ts +72 -0
- package/dist/src/formats/graphml/tree.d.ts.map +1 -0
- package/dist/src/formats/graphml/tree.js +290 -0
- package/dist/src/formats/graphml/tree.js.map +1 -0
- package/dist/src/formats/json/dialect.d.ts +125 -0
- package/dist/src/formats/json/dialect.d.ts.map +1 -0
- package/dist/src/formats/json/dialect.js +262 -0
- package/dist/src/formats/json/dialect.js.map +1 -0
- package/dist/src/formats/json/exporter.d.ts +89 -0
- package/dist/src/formats/json/exporter.d.ts.map +1 -0
- package/dist/src/formats/json/exporter.js +1358 -0
- package/dist/src/formats/json/exporter.js.map +1 -0
- package/dist/src/formats/json/importer.d.ts +108 -0
- package/dist/src/formats/json/importer.d.ts.map +1 -0
- package/dist/src/formats/json/importer.js +1838 -0
- package/dist/src/formats/json/importer.js.map +1 -0
- package/dist/src/formats/json/index.d.ts +8 -0
- package/dist/src/formats/json/index.d.ts.map +1 -0
- package/dist/src/formats/json/index.js +8 -0
- package/dist/src/formats/json/index.js.map +1 -0
- package/dist/src/formats/neo4j/exporter.d.ts +68 -0
- package/dist/src/formats/neo4j/exporter.d.ts.map +1 -0
- package/dist/src/formats/neo4j/exporter.js +1055 -0
- package/dist/src/formats/neo4j/exporter.js.map +1 -0
- package/dist/src/formats/neo4j/header.d.ts +52 -0
- package/dist/src/formats/neo4j/header.d.ts.map +1 -0
- package/dist/src/formats/neo4j/header.js +131 -0
- package/dist/src/formats/neo4j/header.js.map +1 -0
- package/dist/src/formats/neo4j/importer.d.ts +73 -0
- package/dist/src/formats/neo4j/importer.d.ts.map +1 -0
- package/dist/src/formats/neo4j/importer.js +932 -0
- package/dist/src/formats/neo4j/importer.js.map +1 -0
- package/dist/src/formats/neo4j/index.d.ts +79 -0
- package/dist/src/formats/neo4j/index.d.ts.map +1 -0
- package/dist/src/formats/neo4j/index.js +83 -0
- package/dist/src/formats/neo4j/index.js.map +1 -0
- package/dist/src/formats/pajek/exporter.d.ts +58 -0
- package/dist/src/formats/pajek/exporter.d.ts.map +1 -0
- package/dist/src/formats/pajek/exporter.js +825 -0
- package/dist/src/formats/pajek/exporter.js.map +1 -0
- package/dist/src/formats/pajek/importer.d.ts +88 -0
- package/dist/src/formats/pajek/importer.d.ts.map +1 -0
- package/dist/src/formats/pajek/importer.js +1047 -0
- package/dist/src/formats/pajek/importer.js.map +1 -0
- package/dist/src/formats/pajek/index.d.ts +7 -0
- package/dist/src/formats/pajek/index.d.ts.map +1 -0
- package/dist/src/formats/pajek/index.js +7 -0
- package/dist/src/formats/pajek/index.js.map +1 -0
- package/dist/src/formats/pajek/syntax.d.ts +112 -0
- package/dist/src/formats/pajek/syntax.d.ts.map +1 -0
- package/dist/src/formats/pajek/syntax.js +269 -0
- package/dist/src/formats/pajek/syntax.js.map +1 -0
- package/dist/src/index.d.ts +35 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +39 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/registry.d.ts +207 -0
- package/dist/src/registry.d.ts.map +1 -0
- package/dist/src/registry.js +481 -0
- package/dist/src/registry.js.map +1 -0
- package/dist/src/sniff.d.ts +104 -0
- package/dist/src/sniff.d.ts.map +1 -0
- package/dist/src/sniff.js +357 -0
- package/dist/src/sniff.js.map +1 -0
- package/dist/src/types.d.ts +238 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +29 -0
- package/dist/src/types.js.map +1 -0
- package/dist/tsconfig.build.tsbuildinfo +1 -0
- package/package.json +122 -7
- package/src/children.ts +335 -0
- package/src/common/attributes.ts +520 -0
- package/src/common/codes.ts +153 -0
- package/src/common/declared-types.ts +374 -0
- package/src/common/direction.ts +518 -0
- package/src/common/escape.ts +231 -0
- package/src/common/export.ts +817 -0
- package/src/common/format.ts +111 -0
- package/src/common/ids.ts +176 -0
- package/src/common/input.ts +378 -0
- package/src/common/lists.ts +196 -0
- package/src/common/options.ts +377 -0
- package/src/common/report.ts +352 -0
- package/src/common/temporal.ts +302 -0
- package/src/common/text.ts +294 -0
- package/src/common/weights.ts +202 -0
- package/src/common/writer.ts +123 -0
- package/src/common/xml.ts +1053 -0
- package/src/formats/csv/exporter.ts +894 -0
- package/src/formats/csv/header.ts +172 -0
- package/src/formats/csv/importer.ts +1104 -0
- package/src/formats/csv/index.ts +88 -0
- package/src/formats/csv/records.ts +813 -0
- package/src/formats/csv/values.ts +224 -0
- package/src/formats/dot/exporter.ts +1014 -0
- package/src/formats/dot/importer.ts +1549 -0
- package/src/formats/dot/index.ts +7 -0
- package/src/formats/dot/names.ts +40 -0
- package/src/formats/dot/tokenizer.ts +384 -0
- package/src/formats/gexf/exporter.ts +1696 -0
- package/src/formats/gexf/importer.ts +2333 -0
- package/src/formats/gexf/index.ts +142 -0
- package/src/formats/gexf/schema.ts +361 -0
- package/src/formats/gml/exporter.ts +1404 -0
- package/src/formats/gml/importer.ts +1591 -0
- package/src/formats/gml/index.ts +128 -0
- package/src/formats/gml/syntax.ts +545 -0
- package/src/formats/graphml/constants.ts +225 -0
- package/src/formats/graphml/exporter.ts +1458 -0
- package/src/formats/graphml/importer.ts +2027 -0
- package/src/formats/graphml/index.ts +8 -0
- package/src/formats/graphml/tree.ts +318 -0
- package/src/formats/json/dialect.ts +317 -0
- package/src/formats/json/exporter.ts +1616 -0
- package/src/formats/json/importer.ts +2271 -0
- package/src/formats/json/index.ts +8 -0
- package/src/formats/neo4j/exporter.ts +1287 -0
- package/src/formats/neo4j/header.ts +156 -0
- package/src/formats/neo4j/importer.ts +1220 -0
- package/src/formats/neo4j/index.ts +116 -0
- package/src/formats/pajek/exporter.ts +1000 -0
- package/src/formats/pajek/importer.ts +1311 -0
- package/src/formats/pajek/index.ts +7 -0
- package/src/formats/pajek/syntax.ts +307 -0
- package/src/index.ts +244 -0
- package/src/registry.ts +617 -0
- package/src/sniff.ts +397 -0
- package/src/types.ts +262 -0
|
@@ -0,0 +1,1404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The GML exporter (design section 8.5, research note 07 sections 2.2 and 9): writes a snapshot in
|
|
3
|
+
* the NetworkX dialect of GML. Node ids must be integers (`idCharset: "integer"`; `sanitizeIds:
|
|
4
|
+
* "mangle"` renumbers the others and keeps the original in a `graphty_originalId` key the importer
|
|
5
|
+
* restores); columns are written per dtype (`int` for the integer dtypes, `real` with a decimal
|
|
6
|
+
* point guaranteed for f32 / f64 so the dtype survives a re-import, quoted strings with `&#NN;`
|
|
7
|
+
* references, repeated keys for lists with the `_networkx_list_start` marker for one-element
|
|
8
|
+
* lists and `"[]"` for empty ones, nested records for json objects); the position role becomes
|
|
9
|
+
* `graphics [ x y z ]` merged with the node's `graphics` json record; graph columns are written
|
|
10
|
+
* inside `graph [ ]` (or at the top level when the importer found them there); `Creator` and
|
|
11
|
+
* `Version` come from the metadata.
|
|
12
|
+
*
|
|
13
|
+
* check() reports, before anything is written, the capability gaps of the common check plus the
|
|
14
|
+
* GML-specific ones: json columns holding numbers (JSON cannot keep the int / real distinction:
|
|
15
|
+
* `W_GML_RECORD_NUMBER_TYPE`), booleans (written 1 / 0) or nulls (omitted), arrays nested in
|
|
16
|
+
* arrays (no GML spelling; export() throws), column names and record keys that are not GML keys
|
|
17
|
+
* or collide with the structural keys (`E_GML_INVALID_KEY` / `E_GML_RESERVED_KEY`, or
|
|
18
|
+
* `W_GML_KEY_MANGLED` under `sanitizeKeys: "mangle"`), and graphics / position overlaps.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {
|
|
22
|
+
type Column,
|
|
23
|
+
type ColumnMeta,
|
|
24
|
+
GraphFormatError,
|
|
25
|
+
type GraphSnapshot,
|
|
26
|
+
type JsonColumn,
|
|
27
|
+
type NodeId,
|
|
28
|
+
} from "@graphty/graph-format";
|
|
29
|
+
|
|
30
|
+
import { DICT_SAMPLE_ROWS, DictHeuristic } from "../../common/attributes.js";
|
|
31
|
+
import { type PairFolding, pairFolding } from "../../common/direction.js";
|
|
32
|
+
import { quoteGmlString } from "../../common/escape.js";
|
|
33
|
+
import {
|
|
34
|
+
capabilities,
|
|
35
|
+
checkCapabilities,
|
|
36
|
+
countMixedEdges,
|
|
37
|
+
LOSS,
|
|
38
|
+
type SanitizedIds,
|
|
39
|
+
sanitizeIds,
|
|
40
|
+
} from "../../common/export.js";
|
|
41
|
+
import { formatGmlReal, formatInteger } from "../../common/format.js";
|
|
42
|
+
import { type ResolvedExportOptions, resolveExportOptions } from "../../common/options.js";
|
|
43
|
+
import { type ExplicitWeights, explicitWeights } from "../../common/weights.js";
|
|
44
|
+
import { encodeChunks, joinText } from "../../common/writer.js";
|
|
45
|
+
import { type CommonExportOptions, type ExportCapabilities, type GraphExporter, type LossNote } from "../../types.js";
|
|
46
|
+
import { EMPTY_LIST_TEXT, isGmlKey, LIST_START_MARKER, mangleGmlKey, ORIGINAL_ID_KEY } from "./syntax.js";
|
|
47
|
+
|
|
48
|
+
/** The format-specific options of the GML exporter. */
|
|
49
|
+
export interface GmlExportOptions {
|
|
50
|
+
/**
|
|
51
|
+
* The edge key explicit weights are written under; by default the key the GML importer read
|
|
52
|
+
* them from (`meta.weightOrigin.id` when the snapshot came from GML), else `value`.
|
|
53
|
+
*/
|
|
54
|
+
weightKey?: string | undefined;
|
|
55
|
+
/**
|
|
56
|
+
* "error" (default): a column name or record key that is not a GML key (`[A-Za-z][0-9A-Za-z_]*`)
|
|
57
|
+
* or collides with a structural key makes export() throw; "mangle": such keys are rewritten
|
|
58
|
+
* (`.` and other characters become `_`, collisions get a `_2` suffix) and check() reports them.
|
|
59
|
+
*/
|
|
60
|
+
sanitizeKeys?: "error" | "mangle" | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Loss code: a json column holds numbers; JSON cannot keep GML's int / real distinction (design section 8.5). */
|
|
64
|
+
export const RECORD_NUMBER_TYPE_CODE = "W_GML_RECORD_NUMBER_TYPE";
|
|
65
|
+
/** Loss code: a json column holds booleans, written as 1 / 0. */
|
|
66
|
+
export const RECORD_BOOLEAN_CODE = "W_GML_RECORD_BOOLEAN";
|
|
67
|
+
/** Loss code: a json column holds nulls, which GML cannot write; the key (or the row) is omitted. */
|
|
68
|
+
export const RECORD_NULL_CODE = "W_GML_RECORD_NULL";
|
|
69
|
+
/** Loss code: a json column holds an array inside an array, which GML cannot write; export() throws. */
|
|
70
|
+
export const NESTED_ARRAY_CODE = "E_GML_NESTED_ARRAY";
|
|
71
|
+
/** Loss code: a json column holds arrays as row values; written as repeated keys, they re-import as a list column. */
|
|
72
|
+
export const JSON_ARRAY_CODE = "W_GML_JSON_ARRAY";
|
|
73
|
+
/** Loss code: a column name or record key is not a GML key; export() throws unless sanitizeKeys is "mangle". */
|
|
74
|
+
export const INVALID_KEY_CODE = "E_GML_INVALID_KEY";
|
|
75
|
+
/** Loss code: a column name collides with a structural GML key; export() throws unless sanitizeKeys is "mangle". */
|
|
76
|
+
export const RESERVED_KEY_CODE = "E_GML_RESERVED_KEY";
|
|
77
|
+
/** Loss code: keys rewritten under sanitizeKeys "mangle". */
|
|
78
|
+
export const KEY_MANGLED_CODE = "W_GML_KEY_MANGLED";
|
|
79
|
+
/** Loss code: a position column with more than three components; x, y and z are written. */
|
|
80
|
+
export const POSITION_COMPONENTS_CODE = "W_GML_POSITION_COMPONENTS";
|
|
81
|
+
/** Loss code: a node's graphics record has x / y / z keys the position column replaces. */
|
|
82
|
+
export const GRAPHICS_OVERRIDDEN_CODE = "W_GML_GRAPHICS_OVERRIDDEN";
|
|
83
|
+
/** Loss code: a node's graphics value is not a record and cannot hold the position; export() throws. */
|
|
84
|
+
export const GRAPHICS_CONFLICT_CODE = "E_GML_GRAPHICS_CONFLICT";
|
|
85
|
+
|
|
86
|
+
/** The default weight key (design section 8.4: GML weights are read from `value` by default). */
|
|
87
|
+
export const DEFAULT_WEIGHT_KEY = "value";
|
|
88
|
+
|
|
89
|
+
/** The roles GML has a key for (`label`, the edge `id` and `key`); every other role is reported. */
|
|
90
|
+
const KEPT_ROLES: ReadonlySet<string> = new Set(["label", "id", "key"]);
|
|
91
|
+
|
|
92
|
+
/** The key the importer maps each kept role back from (the column name after re-import). */
|
|
93
|
+
const ROLE_NAMES: Readonly<Record<string, string>> = Object.freeze({
|
|
94
|
+
label: "label",
|
|
95
|
+
id: "id",
|
|
96
|
+
key: "key",
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const GML_CAPABILITIES: ExportCapabilities = capabilities({
|
|
100
|
+
mixedDirection: false,
|
|
101
|
+
multiEdges: true,
|
|
102
|
+
selfLoops: true,
|
|
103
|
+
edgeIds: "optional",
|
|
104
|
+
idCharset: "integer",
|
|
105
|
+
dtypes: ["i32", "f64", "string", "dict", "json"],
|
|
106
|
+
components: false,
|
|
107
|
+
lists: true,
|
|
108
|
+
json: true,
|
|
109
|
+
defaults: false,
|
|
110
|
+
options: false,
|
|
111
|
+
hierarchy: false,
|
|
112
|
+
temporal: "none",
|
|
113
|
+
graphAttributes: true,
|
|
114
|
+
positions: true,
|
|
115
|
+
viz: false,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
/** Roles whose columns are never written as attributes (structural, or dropped per the capabilities). */
|
|
119
|
+
const SKIPPED_ROLES: ReadonlySet<string> = new Set([
|
|
120
|
+
"directed",
|
|
121
|
+
"pair",
|
|
122
|
+
"mutual",
|
|
123
|
+
"weight",
|
|
124
|
+
"timeText",
|
|
125
|
+
"originalId",
|
|
126
|
+
"position",
|
|
127
|
+
"color",
|
|
128
|
+
"size",
|
|
129
|
+
"shape",
|
|
130
|
+
"thickness",
|
|
131
|
+
"parent",
|
|
132
|
+
"parents",
|
|
133
|
+
"start",
|
|
134
|
+
"end",
|
|
135
|
+
"timestamp",
|
|
136
|
+
"timestamps",
|
|
137
|
+
"spells",
|
|
138
|
+
"open",
|
|
139
|
+
]);
|
|
140
|
+
|
|
141
|
+
const NODE_RESERVED: ReadonlySet<string> = new Set(["id"]);
|
|
142
|
+
const EDGE_RESERVED: ReadonlySet<string> = new Set(["source", "target"]);
|
|
143
|
+
const GRAPH_RESERVED: ReadonlySet<string> = new Set(["node", "edge", "directed", "multigraph"]);
|
|
144
|
+
const TOP_RESERVED: ReadonlySet<string> = new Set(["graph"]);
|
|
145
|
+
const NUMBER_TEXT = /^[+-]?(?:[0-9]+(?:\.[0-9]*)?|\.[0-9]+)(?:[eE][+-]?[0-9]+)?$/;
|
|
146
|
+
|
|
147
|
+
/** The resolved options of one export. */
|
|
148
|
+
interface GmlExportPlan {
|
|
149
|
+
readonly common: ResolvedExportOptions;
|
|
150
|
+
readonly weightKey: string;
|
|
151
|
+
readonly mangle: boolean;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** What one json column holds that GML cannot write exactly. */
|
|
155
|
+
interface JsonStats {
|
|
156
|
+
numbers: number;
|
|
157
|
+
booleans: number;
|
|
158
|
+
nulls: number;
|
|
159
|
+
nestedArrays: number;
|
|
160
|
+
arrays: number;
|
|
161
|
+
invalidKeys: number;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** The kinds of value already counted for one row while inspecting a json value. */
|
|
165
|
+
interface SeenFlags {
|
|
166
|
+
numbers: boolean;
|
|
167
|
+
booleans: boolean;
|
|
168
|
+
nulls: boolean;
|
|
169
|
+
nested: boolean;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The columns of one table selected for writing, with their keys. */
|
|
173
|
+
interface WrittenColumn {
|
|
174
|
+
readonly column: Column;
|
|
175
|
+
readonly key: string;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Resolve the options of one export call.
|
|
180
|
+
* @param snapshot - the snapshot
|
|
181
|
+
* @param options - the caller's options
|
|
182
|
+
* @returns the plan
|
|
183
|
+
*/
|
|
184
|
+
function planOf(snapshot: GraphSnapshot, options: (GmlExportOptions & CommonExportOptions) | undefined): GmlExportPlan {
|
|
185
|
+
const common = resolveExportOptions(options);
|
|
186
|
+
const mode = options?.sanitizeKeys ?? "error";
|
|
187
|
+
if (mode !== "error" && mode !== "mangle") {
|
|
188
|
+
throw new GraphFormatError(
|
|
189
|
+
"E_UNSUPPORTED",
|
|
190
|
+
`option sanitizeKeys: ${JSON.stringify(mode)} is not "error" or "mangle"`,
|
|
191
|
+
{
|
|
192
|
+
option: "sanitizeKeys",
|
|
193
|
+
found: mode,
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
let weightKey = options?.weightKey;
|
|
198
|
+
if (weightKey === undefined) {
|
|
199
|
+
const origin = snapshot.meta.weightOrigin;
|
|
200
|
+
weightKey = origin !== null && origin.format === "gml" && origin.id !== null ? origin.id : DEFAULT_WEIGHT_KEY;
|
|
201
|
+
} else if (typeof weightKey !== "string" || !isGmlKey(weightKey)) {
|
|
202
|
+
throw new GraphFormatError("E_UNSUPPORTED", `option weightKey: ${JSON.stringify(weightKey)} is not a GML key`, {
|
|
203
|
+
option: "weightKey",
|
|
204
|
+
found: weightKey,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
return { common, weightKey, mangle: mode === "mangle" };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Whether a column is written as an attribute (its role is neither structural nor dropped).
|
|
212
|
+
* @param column - the column
|
|
213
|
+
* @returns true when written
|
|
214
|
+
*/
|
|
215
|
+
function isWritten(column: Column): boolean {
|
|
216
|
+
const { role } = column.meta;
|
|
217
|
+
return role === null || !SKIPPED_ROLES.has(role);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* The key a column is written under.
|
|
222
|
+
* @param meta - the column metadata
|
|
223
|
+
* @returns origin.id when the column came from GML (the key it was read from), else the name
|
|
224
|
+
*/
|
|
225
|
+
function preferredKey(meta: ColumnMeta): string {
|
|
226
|
+
const { origin } = meta;
|
|
227
|
+
return origin !== null && origin.format === "gml" && origin.id !== null ? origin.id : meta.name;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Whether a snapshot has explicit weights to write.
|
|
232
|
+
* @param snapshot - the snapshot
|
|
233
|
+
* @returns true when weighted
|
|
234
|
+
*/
|
|
235
|
+
function hasWeights(snapshot: GraphSnapshot): boolean {
|
|
236
|
+
return snapshot.flags.weighted;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Whether the graph column is a top-level key (the importer's `extra.gmlTopLevel`).
|
|
241
|
+
* @param column - the column
|
|
242
|
+
* @returns true for a top-level key
|
|
243
|
+
*/
|
|
244
|
+
function isTopLevel(column: Column): boolean {
|
|
245
|
+
return column.meta.extra.gmlTopLevel === true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* The text of a `Version` value: bare when it is a number text, quoted otherwise.
|
|
250
|
+
* @param text - the version text
|
|
251
|
+
* @returns the GML value
|
|
252
|
+
*/
|
|
253
|
+
function versionText(text: string): string {
|
|
254
|
+
return NUMBER_TEXT.test(text) ? text : quoteGmlString(text);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Select the written columns of a table and assign their keys, recording invalid and reserved
|
|
259
|
+
* keys as notes (or rewriting them under mangle).
|
|
260
|
+
* @param table - the columns
|
|
261
|
+
* @param reserved - the structural keys of the table
|
|
262
|
+
* @param label - the table name for messages
|
|
263
|
+
* @param plan - the export plan
|
|
264
|
+
* @param notes - where to record
|
|
265
|
+
* @param filter - an extra selection predicate
|
|
266
|
+
* @returns the written columns with their keys
|
|
267
|
+
*/
|
|
268
|
+
function selectColumns(
|
|
269
|
+
table: Iterable<Column>,
|
|
270
|
+
reserved: ReadonlySet<string>,
|
|
271
|
+
label: string,
|
|
272
|
+
plan: GmlExportPlan,
|
|
273
|
+
notes: LossNote[] | null,
|
|
274
|
+
filter: (column: Column) => boolean = (): boolean => true,
|
|
275
|
+
): WrittenColumn[] {
|
|
276
|
+
const used = new Set<string>(reserved);
|
|
277
|
+
const out: WrittenColumn[] = [];
|
|
278
|
+
for (const column of table) {
|
|
279
|
+
if (!isWritten(column) || !filter(column)) {
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
const preferred = preferredKey(column.meta);
|
|
283
|
+
let key = preferred;
|
|
284
|
+
const valid = isGmlKey(preferred);
|
|
285
|
+
const taken = used.has(preferred);
|
|
286
|
+
if (!valid || taken) {
|
|
287
|
+
const code = valid ? RESERVED_KEY_CODE : INVALID_KEY_CODE;
|
|
288
|
+
let why = "is not a GML key";
|
|
289
|
+
if (valid) {
|
|
290
|
+
why = reserved.has(preferred) ? "collides with a structural GML key" : "repeats another column's key";
|
|
291
|
+
}
|
|
292
|
+
if (!plan.mangle) {
|
|
293
|
+
if (notes === null) {
|
|
294
|
+
throw new GraphFormatError(
|
|
295
|
+
"E_UNSUPPORTED",
|
|
296
|
+
`${label} column "${column.meta.name}" ${why}; pass sanitizeKeys: "mangle" to rewrite it`,
|
|
297
|
+
{
|
|
298
|
+
reason: "gml key",
|
|
299
|
+
column: column.meta.name,
|
|
300
|
+
key: preferred,
|
|
301
|
+
},
|
|
302
|
+
);
|
|
303
|
+
}
|
|
304
|
+
notes.push(
|
|
305
|
+
note(
|
|
306
|
+
code,
|
|
307
|
+
`${label} column "${column.meta.name}" ${why}; export() will throw unless sanitizeKeys is "mangle"`,
|
|
308
|
+
column.meta.name,
|
|
309
|
+
),
|
|
310
|
+
);
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
key = uniqueKey(valid ? preferred : mangleGmlKey(preferred), used);
|
|
314
|
+
notes?.push(
|
|
315
|
+
note(
|
|
316
|
+
KEY_MANGLED_CODE,
|
|
317
|
+
`${label} column "${column.meta.name}" is written as "${key}"`,
|
|
318
|
+
column.meta.name,
|
|
319
|
+
),
|
|
320
|
+
);
|
|
321
|
+
}
|
|
322
|
+
used.add(key);
|
|
323
|
+
out.push({ column, key });
|
|
324
|
+
}
|
|
325
|
+
return out;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* A key not yet used: the base, else base_2, base_3...
|
|
330
|
+
* @param base - a valid key
|
|
331
|
+
* @param used - the keys taken
|
|
332
|
+
* @returns a free key
|
|
333
|
+
*/
|
|
334
|
+
function uniqueKey(base: string, used: ReadonlySet<string>): string {
|
|
335
|
+
let candidate = base;
|
|
336
|
+
for (let k = 2; used.has(candidate); k++) {
|
|
337
|
+
candidate = `${base}_${k}`;
|
|
338
|
+
}
|
|
339
|
+
return candidate;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Build a frozen note.
|
|
344
|
+
* @param code - the code
|
|
345
|
+
* @param message - the message
|
|
346
|
+
* @param column - the column, or null
|
|
347
|
+
* @param count - the count, or null
|
|
348
|
+
* @returns the note
|
|
349
|
+
*/
|
|
350
|
+
function note(code: string, message: string, column: string | null = null, count: number | null = null): LossNote {
|
|
351
|
+
return Object.freeze({ code, message, column, count });
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Inspect a JSON value for what GML cannot write exactly.
|
|
356
|
+
* @param value - the value
|
|
357
|
+
* @param stats - the counters to update (each counted at most once per call for numbers / booleans / nulls)
|
|
358
|
+
* @param inArray - whether the value is an array item
|
|
359
|
+
* @param seen - the flags already counted for this row
|
|
360
|
+
*/
|
|
361
|
+
function inspectJson(value: unknown, stats: JsonStats, inArray: boolean, seen: SeenFlags): void {
|
|
362
|
+
if (value === null) {
|
|
363
|
+
if (!seen.nulls) {
|
|
364
|
+
seen.nulls = true;
|
|
365
|
+
stats.nulls++;
|
|
366
|
+
}
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
switch (typeof value) {
|
|
370
|
+
case "number":
|
|
371
|
+
if (!seen.numbers) {
|
|
372
|
+
seen.numbers = true;
|
|
373
|
+
stats.numbers++;
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
case "boolean":
|
|
377
|
+
if (!seen.booleans) {
|
|
378
|
+
seen.booleans = true;
|
|
379
|
+
stats.booleans++;
|
|
380
|
+
}
|
|
381
|
+
return;
|
|
382
|
+
case "string":
|
|
383
|
+
return;
|
|
384
|
+
default:
|
|
385
|
+
break;
|
|
386
|
+
}
|
|
387
|
+
if (Array.isArray(value)) {
|
|
388
|
+
if (inArray && !seen.nested) {
|
|
389
|
+
seen.nested = true;
|
|
390
|
+
stats.nestedArrays++;
|
|
391
|
+
}
|
|
392
|
+
for (const item of value) {
|
|
393
|
+
inspectJson(item, stats, true, seen);
|
|
394
|
+
}
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const record = value as Record<string, unknown>;
|
|
398
|
+
for (const key of Object.keys(record)) {
|
|
399
|
+
if (!isGmlKey(key)) {
|
|
400
|
+
stats.invalidKeys++;
|
|
401
|
+
}
|
|
402
|
+
inspectJson(record[key], stats, false, seen);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* The GML-specific notes of one table's json columns.
|
|
408
|
+
* @param columns - the written columns
|
|
409
|
+
* @param label - the table name
|
|
410
|
+
* @param plan - the export plan
|
|
411
|
+
* @param notes - where to record
|
|
412
|
+
*/
|
|
413
|
+
function jsonNotes(columns: readonly WrittenColumn[], label: string, plan: GmlExportPlan, notes: LossNote[]): void {
|
|
414
|
+
for (const { column } of columns) {
|
|
415
|
+
const stats = jsonStatsOf(column);
|
|
416
|
+
if (stats === null) {
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
const { name } = column.meta;
|
|
420
|
+
const where = `${label} column "${name}"`;
|
|
421
|
+
if (stats.numbers > 0) {
|
|
422
|
+
notes.push(
|
|
423
|
+
note(
|
|
424
|
+
RECORD_NUMBER_TYPE_CODE,
|
|
425
|
+
`${where} holds numbers in ${stats.numbers} row(s); GML records cannot keep the int / real distinction`,
|
|
426
|
+
name,
|
|
427
|
+
stats.numbers,
|
|
428
|
+
),
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
if (stats.booleans > 0) {
|
|
432
|
+
notes.push(
|
|
433
|
+
note(
|
|
434
|
+
RECORD_BOOLEAN_CODE,
|
|
435
|
+
`${where} holds booleans in ${stats.booleans} row(s); written as 1 / 0`,
|
|
436
|
+
name,
|
|
437
|
+
stats.booleans,
|
|
438
|
+
),
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
if (stats.nulls > 0) {
|
|
442
|
+
notes.push(
|
|
443
|
+
note(
|
|
444
|
+
RECORD_NULL_CODE,
|
|
445
|
+
`${where} holds nulls in ${stats.nulls} row(s); GML has no null, the key is omitted`,
|
|
446
|
+
name,
|
|
447
|
+
stats.nulls,
|
|
448
|
+
),
|
|
449
|
+
);
|
|
450
|
+
}
|
|
451
|
+
if (stats.arrays > 0) {
|
|
452
|
+
notes.push(
|
|
453
|
+
note(
|
|
454
|
+
JSON_ARRAY_CODE,
|
|
455
|
+
`${where} holds arrays as values in ${stats.arrays} row(s); written as repeated keys, they re-import as a list`,
|
|
456
|
+
name,
|
|
457
|
+
stats.arrays,
|
|
458
|
+
),
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
if (stats.nestedArrays > 0) {
|
|
462
|
+
notes.push(
|
|
463
|
+
note(
|
|
464
|
+
NESTED_ARRAY_CODE,
|
|
465
|
+
`${where} holds arrays nested in arrays in ${stats.nestedArrays} row(s); GML cannot write them and export() will throw`,
|
|
466
|
+
name,
|
|
467
|
+
stats.nestedArrays,
|
|
468
|
+
),
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
if (stats.invalidKeys > 0) {
|
|
472
|
+
notes.push(
|
|
473
|
+
plan.mangle
|
|
474
|
+
? note(
|
|
475
|
+
KEY_MANGLED_CODE,
|
|
476
|
+
`${where}: ${stats.invalidKeys} record key(s) are not GML keys and are rewritten`,
|
|
477
|
+
name,
|
|
478
|
+
stats.invalidKeys,
|
|
479
|
+
)
|
|
480
|
+
: note(
|
|
481
|
+
INVALID_KEY_CODE,
|
|
482
|
+
`${where}: ${stats.invalidKeys} record key(s) are not GML keys; export() will throw unless sanitizeKeys is "mangle"`,
|
|
483
|
+
name,
|
|
484
|
+
stats.invalidKeys,
|
|
485
|
+
),
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* The json statistics of a json column, or of a list column with json items; null for other dtypes.
|
|
493
|
+
* @param column - the column
|
|
494
|
+
* @returns the stats, or null
|
|
495
|
+
*/
|
|
496
|
+
function jsonStatsOf(column: Column): JsonStats | null {
|
|
497
|
+
const stats: JsonStats = { numbers: 0, booleans: 0, nulls: 0, nestedArrays: 0, arrays: 0, invalidKeys: 0 };
|
|
498
|
+
if (column.dtype === "json") {
|
|
499
|
+
for (let r = 0; r < column.length; r++) {
|
|
500
|
+
if (!column.isSet(r)) {
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
const value = column.values[r];
|
|
504
|
+
const seen: SeenFlags = { numbers: false, booleans: false, nulls: false, nested: false };
|
|
505
|
+
if (Array.isArray(value)) {
|
|
506
|
+
stats.arrays++;
|
|
507
|
+
for (const item of value) {
|
|
508
|
+
inspectJson(item, stats, true, seen);
|
|
509
|
+
}
|
|
510
|
+
} else {
|
|
511
|
+
inspectJson(value, stats, false, seen);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
return stats;
|
|
515
|
+
}
|
|
516
|
+
if (column.dtype === "list" && column.meta.itemDtype === "json") {
|
|
517
|
+
for (let r = 0; r < column.length; r++) {
|
|
518
|
+
if (!column.isSet(r)) {
|
|
519
|
+
continue;
|
|
520
|
+
}
|
|
521
|
+
const seen: SeenFlags = { numbers: false, booleans: false, nulls: false, nested: false };
|
|
522
|
+
for (const item of column.sliceOf(r)) {
|
|
523
|
+
inspectJson(item, stats, true, seen);
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
return stats;
|
|
527
|
+
}
|
|
528
|
+
return null;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* The node position column when it can be mapped to graphics x / y / z: a numeric scalar column
|
|
533
|
+
* with the position role.
|
|
534
|
+
* @param snapshot - the snapshot
|
|
535
|
+
* @returns the column, or null
|
|
536
|
+
*/
|
|
537
|
+
function positionColumn(snapshot: GraphSnapshot): Column | null {
|
|
538
|
+
const column = snapshot.nodes.byRole("position");
|
|
539
|
+
if (column === null) {
|
|
540
|
+
return null;
|
|
541
|
+
}
|
|
542
|
+
switch (column.dtype) {
|
|
543
|
+
case "f32":
|
|
544
|
+
case "f64":
|
|
545
|
+
case "i32":
|
|
546
|
+
case "u32":
|
|
547
|
+
case "u8":
|
|
548
|
+
return column;
|
|
549
|
+
default:
|
|
550
|
+
return null;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/**
|
|
555
|
+
* The node `graphics` json column, when present.
|
|
556
|
+
* @param snapshot - the snapshot
|
|
557
|
+
* @returns the column, or null
|
|
558
|
+
*/
|
|
559
|
+
function graphicsColumn(snapshot: GraphSnapshot): JsonColumn | null {
|
|
560
|
+
return snapshot.nodes.typed("graphics", "json");
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
/**
|
|
564
|
+
* The GML-specific notes about the position / graphics mapping.
|
|
565
|
+
* @param snapshot - the snapshot
|
|
566
|
+
* @param notes - where to record
|
|
567
|
+
*/
|
|
568
|
+
function graphicsNotes(snapshot: GraphSnapshot, notes: LossNote[]): void {
|
|
569
|
+
const position = positionColumn(snapshot);
|
|
570
|
+
if (position === null) {
|
|
571
|
+
return;
|
|
572
|
+
}
|
|
573
|
+
if (position.meta.components > 3) {
|
|
574
|
+
notes.push(
|
|
575
|
+
note(
|
|
576
|
+
POSITION_COMPONENTS_CODE,
|
|
577
|
+
`node column "${position.meta.name}" has ${position.meta.components} components; only x, y and z are written`,
|
|
578
|
+
position.meta.name,
|
|
579
|
+
position.length - position.nullCount,
|
|
580
|
+
),
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
const graphics = graphicsColumn(snapshot);
|
|
584
|
+
if (graphics === null) {
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
let overridden = 0;
|
|
588
|
+
let conflicts = 0;
|
|
589
|
+
for (let r = 0; r < snapshot.nodeCount; r++) {
|
|
590
|
+
if (!position.isSet(r) || !graphics.isSet(r)) {
|
|
591
|
+
continue;
|
|
592
|
+
}
|
|
593
|
+
const value = graphics.values[r];
|
|
594
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
|
595
|
+
conflicts++;
|
|
596
|
+
} else if ("x" in value || "y" in value || "z" in value) {
|
|
597
|
+
overridden++;
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
if (overridden > 0) {
|
|
601
|
+
notes.push(
|
|
602
|
+
note(
|
|
603
|
+
GRAPHICS_OVERRIDDEN_CODE,
|
|
604
|
+
`${overridden} node graphics record(s) have x / y / z keys the position column replaces`,
|
|
605
|
+
"graphics",
|
|
606
|
+
overridden,
|
|
607
|
+
),
|
|
608
|
+
);
|
|
609
|
+
}
|
|
610
|
+
if (conflicts > 0) {
|
|
611
|
+
notes.push(
|
|
612
|
+
note(
|
|
613
|
+
GRAPHICS_CONFLICT_CODE,
|
|
614
|
+
`${conflicts} node graphics value(s) are not records and cannot hold the position; export() will throw`,
|
|
615
|
+
"graphics",
|
|
616
|
+
conflicts,
|
|
617
|
+
),
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* What the importer's own rules change on re-import of the written columns: an all-unset column
|
|
624
|
+
* vanishes (GML writes cells, never declarations), a role-less column named like a role key
|
|
625
|
+
* gains the role, and the dictionary heuristic of design section 5.4 turns a low-cardinality
|
|
626
|
+
* string column into a dict (or a wide dict into a string).
|
|
627
|
+
* @param columns - the written columns
|
|
628
|
+
* @param domain - node or edge
|
|
629
|
+
* @param notes - where to record
|
|
630
|
+
*/
|
|
631
|
+
function reimportNotes(columns: readonly WrittenColumn[], domain: "node" | "edge", notes: LossNote[]): void {
|
|
632
|
+
for (const { column, key } of columns) {
|
|
633
|
+
const { meta } = column;
|
|
634
|
+
const set = column.length - column.nullCount;
|
|
635
|
+
if (set === 0) {
|
|
636
|
+
notes.push(
|
|
637
|
+
note(
|
|
638
|
+
LOSS.EMPTY_COLUMN,
|
|
639
|
+
`${domain} column "${meta.name}" has no set cell and is not written: GML writes cells, never declarations`,
|
|
640
|
+
meta.name,
|
|
641
|
+
0,
|
|
642
|
+
),
|
|
643
|
+
);
|
|
644
|
+
continue;
|
|
645
|
+
}
|
|
646
|
+
if (meta.role === null && (key === "label" || (domain === "edge" && (key === "id" || key === "key")))) {
|
|
647
|
+
notes.push(
|
|
648
|
+
note(
|
|
649
|
+
LOSS.ROLE_ASSUMED,
|
|
650
|
+
`${domain} column "${meta.name}" is written under "${key}" and reads back with the ${key === "label" ? "label" : key} role`,
|
|
651
|
+
meta.name,
|
|
652
|
+
set,
|
|
653
|
+
),
|
|
654
|
+
);
|
|
655
|
+
continue;
|
|
656
|
+
}
|
|
657
|
+
if (column.dtype === "list" || !(column.dtype === "string" || column.dtype === "dict")) {
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
const heuristic = new DictHeuristic(DICT_SAMPLE_ROWS);
|
|
661
|
+
for (let r = 0; r < column.length && !heuristic.decided; r++) {
|
|
662
|
+
if (column.isSet(r)) {
|
|
663
|
+
heuristic.observe(column.value(r) as string);
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
const readsAsDict = heuristic.decide() === "dict";
|
|
667
|
+
if (column.dtype === "string" && readsAsDict) {
|
|
668
|
+
notes.push(
|
|
669
|
+
note(
|
|
670
|
+
LOSS.STORAGE_CLASS,
|
|
671
|
+
`${domain} column "${meta.name}" reads back as dict (low cardinality)`,
|
|
672
|
+
meta.name,
|
|
673
|
+
null,
|
|
674
|
+
),
|
|
675
|
+
);
|
|
676
|
+
} else if (column.dtype === "dict" && !readsAsDict) {
|
|
677
|
+
notes.push(
|
|
678
|
+
note(
|
|
679
|
+
LOSS.STORAGE_CLASS,
|
|
680
|
+
`${domain} column "${meta.name}" reads back as string (cardinality too high for a dict)`,
|
|
681
|
+
meta.name,
|
|
682
|
+
null,
|
|
683
|
+
),
|
|
684
|
+
);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* The importer names the graphics-derived position column `position`, or `position#graphics`
|
|
691
|
+
* when a plain `position` key is written next to it (design section 5.6); a position column
|
|
692
|
+
* named otherwise reads back under that name.
|
|
693
|
+
* @param snapshot - the snapshot
|
|
694
|
+
* @param nodeColumns - the written node columns
|
|
695
|
+
* @param notes - where to record
|
|
696
|
+
*/
|
|
697
|
+
function positionNameNote(snapshot: GraphSnapshot, nodeColumns: readonly WrittenColumn[], notes: LossNote[]): void {
|
|
698
|
+
const position = positionColumn(snapshot);
|
|
699
|
+
if (position === null) {
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
const { name } = position.meta;
|
|
703
|
+
const plainPosition = nodeColumns.some((c) => c.key === "position");
|
|
704
|
+
const reimportName = plainPosition ? "position#graphics" : "position";
|
|
705
|
+
if (name !== reimportName) {
|
|
706
|
+
notes.push(
|
|
707
|
+
note(
|
|
708
|
+
LOSS.COLUMN_NAME_CHANGED,
|
|
709
|
+
`node column "${name}" (position) is written as the graphics x / y / z keys and reads back as "${reimportName}"`,
|
|
710
|
+
name,
|
|
711
|
+
position.length - position.nullCount,
|
|
712
|
+
),
|
|
713
|
+
);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
/**
|
|
718
|
+
* Pre-flight: the common capability notes plus the GML-specific ones.
|
|
719
|
+
* @param snapshot - the snapshot
|
|
720
|
+
* @param options - the options
|
|
721
|
+
* @returns the notes, empty when the export is exact
|
|
722
|
+
*/
|
|
723
|
+
function check(snapshot: GraphSnapshot, options?: GmlExportOptions & CommonExportOptions): readonly LossNote[] {
|
|
724
|
+
const plan = planOf(snapshot, options);
|
|
725
|
+
const notes = checkCapabilities(snapshot, GML_CAPABILITIES, plan.common, {
|
|
726
|
+
positionDtype: "f64",
|
|
727
|
+
roles: KEPT_ROLES,
|
|
728
|
+
roleNames: ROLE_NAMES,
|
|
729
|
+
});
|
|
730
|
+
const nodeReserved = new Set(NODE_RESERVED);
|
|
731
|
+
if (
|
|
732
|
+
(plan.common.sanitizeIds === "mangle" && countMangled(snapshot) > 0) ||
|
|
733
|
+
snapshot.nodes.byRole("originalId") !== null
|
|
734
|
+
) {
|
|
735
|
+
nodeReserved.add(ORIGINAL_ID_KEY);
|
|
736
|
+
}
|
|
737
|
+
const edgeIds = snapshot.edges.byRole("id");
|
|
738
|
+
const edgeReserved = edgeReservedKeys(snapshot, plan, edgeIds);
|
|
739
|
+
const topReserved = topReservedKeys(snapshot);
|
|
740
|
+
const nodeColumns = selectColumns(snapshot.nodes, nodeReserved, "node", plan, notes);
|
|
741
|
+
const edgeColumns = selectColumns(snapshot.edges, edgeReserved, "edge", plan, notes, (c) => c !== edgeIds);
|
|
742
|
+
const graphColumns = selectColumns(snapshot.graph, GRAPH_RESERVED, "graph", plan, notes, (c) => !isTopLevel(c));
|
|
743
|
+
const topColumns = selectColumns(snapshot.graph, topReserved, "top-level", plan, notes, isTopLevel);
|
|
744
|
+
jsonNotes(nodeColumns, "node", plan, notes);
|
|
745
|
+
jsonNotes(edgeColumns, "edge", plan, notes);
|
|
746
|
+
jsonNotes(graphColumns, "graph", plan, notes);
|
|
747
|
+
jsonNotes(topColumns, "top-level", plan, notes);
|
|
748
|
+
graphicsNotes(snapshot, notes);
|
|
749
|
+
positionNameNote(snapshot, nodeColumns, notes);
|
|
750
|
+
reimportNotes(nodeColumns, "node", notes);
|
|
751
|
+
reimportNotes(edgeColumns, "edge", notes);
|
|
752
|
+
if (!hasWeights(snapshot)) {
|
|
753
|
+
const clash = edgeColumns.find((c) => c.key === plan.weightKey);
|
|
754
|
+
if (clash !== undefined) {
|
|
755
|
+
notes.push(
|
|
756
|
+
note(
|
|
757
|
+
LOSS.WEIGHT_KEY_CLASH,
|
|
758
|
+
`edge column "${clash.column.meta.name}" is written under "${plan.weightKey}", the key the importer reads THE weight from; it reads back as the weight, not as a column`,
|
|
759
|
+
clash.column.meta.name,
|
|
760
|
+
clash.column.length - clash.column.nullCount,
|
|
761
|
+
),
|
|
762
|
+
);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
const folding = pairFolding(snapshot);
|
|
766
|
+
if (folding.mutualCount > 0) {
|
|
767
|
+
notes.push(
|
|
768
|
+
note(
|
|
769
|
+
LOSS.MUTUAL_EXPANDED,
|
|
770
|
+
`${folding.mutualCount} mutual pair(s) are written as two directed edges; the mutual mark is lost`,
|
|
771
|
+
null,
|
|
772
|
+
folding.mutualCount,
|
|
773
|
+
),
|
|
774
|
+
);
|
|
775
|
+
}
|
|
776
|
+
return Object.freeze(notes);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/**
|
|
780
|
+
* The structural keys of the edge table: source, target, the weight key when weights are written,
|
|
781
|
+
* and `id` when a role-id column is written as the edge id.
|
|
782
|
+
* @param snapshot - the snapshot
|
|
783
|
+
* @param plan - the export plan
|
|
784
|
+
* @param edgeIds - the role-id edge column, or null
|
|
785
|
+
* @returns the reserved keys
|
|
786
|
+
*/
|
|
787
|
+
function edgeReservedKeys(snapshot: GraphSnapshot, plan: GmlExportPlan, edgeIds: Column | null): Set<string> {
|
|
788
|
+
const reserved = new Set(EDGE_RESERVED);
|
|
789
|
+
if (hasWeights(snapshot)) {
|
|
790
|
+
reserved.add(plan.weightKey);
|
|
791
|
+
}
|
|
792
|
+
if (edgeIds !== null) {
|
|
793
|
+
reserved.add("id");
|
|
794
|
+
}
|
|
795
|
+
return reserved;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* The structural keys of the top level: graph, plus Creator and Version when the metadata writes them.
|
|
800
|
+
* @param snapshot - the snapshot
|
|
801
|
+
* @returns the reserved keys
|
|
802
|
+
*/
|
|
803
|
+
function topReservedKeys(snapshot: GraphSnapshot): Set<string> {
|
|
804
|
+
const reserved = new Set(TOP_RESERVED);
|
|
805
|
+
if (snapshot.meta.creator !== null) {
|
|
806
|
+
reserved.add("Creator");
|
|
807
|
+
}
|
|
808
|
+
if (snapshot.meta.sourceFormat === "gml" && snapshot.meta.sourceVersion !== null) {
|
|
809
|
+
reserved.add("Version");
|
|
810
|
+
}
|
|
811
|
+
return reserved;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
/**
|
|
815
|
+
* How many node ids are not safe integers (what sanitizeIds "mangle" rewrites).
|
|
816
|
+
* @param snapshot - the snapshot
|
|
817
|
+
* @returns the count
|
|
818
|
+
*/
|
|
819
|
+
function countMangled(snapshot: GraphSnapshot): number {
|
|
820
|
+
let count = 0;
|
|
821
|
+
for (let i = 0; i < snapshot.nodeCount; i++) {
|
|
822
|
+
const id = snapshot.ids.idOf(i);
|
|
823
|
+
if (typeof id !== "number" || !Number.isSafeInteger(id)) {
|
|
824
|
+
count++;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
return count;
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// ============================================================ writing
|
|
831
|
+
|
|
832
|
+
/**
|
|
833
|
+
* The GML text of a real: the shortest text of the dtype with a decimal point guaranteed
|
|
834
|
+
* (`2.0`, `1.0e-7`), the NetworkX spellings `+INF` / `-INF` / `NAN` for the non-finite values.
|
|
835
|
+
* @param value - the value
|
|
836
|
+
* @param dtype - the dtype the value came from (f32 uses the fround-shortest text)
|
|
837
|
+
* @returns the text
|
|
838
|
+
*/
|
|
839
|
+
export function gmlRealText(value: number, dtype: "f32" | "f64" | "i32" | "u32" | "u8" = "f64"): string {
|
|
840
|
+
return formatGmlReal(value, dtype);
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
/**
|
|
844
|
+
* The GML text of a number by the dtype and origin of its column: an integer text for the integer
|
|
845
|
+
* dtypes and for an f64 column that came from GML `int` values, a real text otherwise.
|
|
846
|
+
* @param value - the value
|
|
847
|
+
* @param meta - the column metadata
|
|
848
|
+
* @returns the text
|
|
849
|
+
*/
|
|
850
|
+
function numberText(value: number, meta: ColumnMeta): string {
|
|
851
|
+
switch (meta.dtype) {
|
|
852
|
+
case "i32":
|
|
853
|
+
case "u32":
|
|
854
|
+
case "u8":
|
|
855
|
+
return formatInteger(value);
|
|
856
|
+
case "f32":
|
|
857
|
+
return gmlRealText(value, "f32");
|
|
858
|
+
default:
|
|
859
|
+
if (meta.origin?.type === "int" && Number.isInteger(value)) {
|
|
860
|
+
return formatInteger(value);
|
|
861
|
+
}
|
|
862
|
+
return gmlRealText(value, "f64");
|
|
863
|
+
}
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* The GML text of a JSON scalar inside a record: integers as int, other numbers as real, strings
|
|
868
|
+
* quoted, booleans as 1 / 0.
|
|
869
|
+
* @param value - the scalar
|
|
870
|
+
* @returns the text
|
|
871
|
+
*/
|
|
872
|
+
function jsonScalarText(value: number | string | boolean): string {
|
|
873
|
+
switch (typeof value) {
|
|
874
|
+
case "number":
|
|
875
|
+
return Number.isInteger(value) ? formatInteger(value) : gmlRealText(value, "f64");
|
|
876
|
+
case "boolean":
|
|
877
|
+
return value ? "1" : "0";
|
|
878
|
+
default:
|
|
879
|
+
return quoteGmlString(value);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/**
|
|
884
|
+
* The GML text of a list item or components lane by the item dtype.
|
|
885
|
+
* @param item - the item
|
|
886
|
+
* @param itemDtype - the list's item dtype
|
|
887
|
+
* @param meta - the list column metadata
|
|
888
|
+
* @returns the text
|
|
889
|
+
*/
|
|
890
|
+
function itemText(item: unknown, itemDtype: string, meta: ColumnMeta): string {
|
|
891
|
+
switch (typeof item) {
|
|
892
|
+
case "number":
|
|
893
|
+
switch (itemDtype) {
|
|
894
|
+
case "i32":
|
|
895
|
+
case "u32":
|
|
896
|
+
case "u8":
|
|
897
|
+
return formatInteger(item);
|
|
898
|
+
case "f32":
|
|
899
|
+
return gmlRealText(item, "f32");
|
|
900
|
+
case "json":
|
|
901
|
+
return jsonScalarText(item);
|
|
902
|
+
default:
|
|
903
|
+
return meta.origin?.type === "int" && Number.isInteger(item)
|
|
904
|
+
? formatInteger(item)
|
|
905
|
+
: gmlRealText(item, "f64");
|
|
906
|
+
}
|
|
907
|
+
case "boolean":
|
|
908
|
+
return item ? "1" : "0";
|
|
909
|
+
case "string":
|
|
910
|
+
return quoteGmlString(item);
|
|
911
|
+
default:
|
|
912
|
+
throw new GraphFormatError(
|
|
913
|
+
"E_COLUMN_TYPE",
|
|
914
|
+
`column "${meta.name}": a ${typeof item} list item cannot be written as GML`,
|
|
915
|
+
{
|
|
916
|
+
column: meta.name,
|
|
917
|
+
found: typeof item,
|
|
918
|
+
},
|
|
919
|
+
);
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/** Writes lines with indentation into an array of parts. */
|
|
924
|
+
class GmlWriter {
|
|
925
|
+
private readonly lines: string[] = [];
|
|
926
|
+
|
|
927
|
+
private readonly mangle: boolean;
|
|
928
|
+
|
|
929
|
+
/**
|
|
930
|
+
* Create a writer.
|
|
931
|
+
* @param mangle - whether record keys are rewritten rather than refused
|
|
932
|
+
*/
|
|
933
|
+
constructor(mangle: boolean) {
|
|
934
|
+
this.mangle = mangle;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Take the lines written so far as one string.
|
|
939
|
+
* @returns the text; the writer is empty afterwards
|
|
940
|
+
*/
|
|
941
|
+
take(): string {
|
|
942
|
+
const text = this.lines.join("");
|
|
943
|
+
this.lines.length = 0;
|
|
944
|
+
return text;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* Write one `key value` line.
|
|
949
|
+
* @param indent - the indentation
|
|
950
|
+
* @param key - the key
|
|
951
|
+
* @param value - the value text
|
|
952
|
+
*/
|
|
953
|
+
line(indent: string, key: string, value: string): void {
|
|
954
|
+
this.lines.push(`${indent}${key} ${value}\n`);
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* Write a raw line.
|
|
959
|
+
* @param text - the line without its terminator
|
|
960
|
+
*/
|
|
961
|
+
raw(text: string): void {
|
|
962
|
+
this.lines.push(`${text}\n`);
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Write one column cell of a row.
|
|
967
|
+
* @param indent - the indentation
|
|
968
|
+
* @param key - the key
|
|
969
|
+
* @param column - the column
|
|
970
|
+
* @param row - the row
|
|
971
|
+
*/
|
|
972
|
+
cell(indent: string, key: string, column: Column, row: number): void {
|
|
973
|
+
const { meta, dtype } = column;
|
|
974
|
+
switch (dtype) {
|
|
975
|
+
case "f32":
|
|
976
|
+
case "f64":
|
|
977
|
+
case "i32":
|
|
978
|
+
case "u32":
|
|
979
|
+
case "u8": {
|
|
980
|
+
const { components } = meta;
|
|
981
|
+
if (components === 1) {
|
|
982
|
+
this.line(indent, key, numberText(column.data[row], meta));
|
|
983
|
+
} else {
|
|
984
|
+
for (let k = 0; k < components; k++) {
|
|
985
|
+
this.line(indent, key, numberText(column.data[row * components + k], meta));
|
|
986
|
+
}
|
|
987
|
+
}
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
case "bool":
|
|
991
|
+
this.line(indent, key, column.value(row) === true ? "1" : "0");
|
|
992
|
+
return;
|
|
993
|
+
case "dict":
|
|
994
|
+
case "string":
|
|
995
|
+
this.line(indent, key, quoteGmlString(column.value(row) ?? ""));
|
|
996
|
+
return;
|
|
997
|
+
case "list":
|
|
998
|
+
this.list(indent, key, column.sliceOf(row), meta.itemDtype ?? "string", meta);
|
|
999
|
+
return;
|
|
1000
|
+
case "json":
|
|
1001
|
+
this.json(indent, key, column.values[row], meta.name);
|
|
1002
|
+
return;
|
|
1003
|
+
default: {
|
|
1004
|
+
const name: string = dtype;
|
|
1005
|
+
throw new GraphFormatError("E_COLUMN_TYPE", `unknown dtype ${name}`, { dtype: name });
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/**
|
|
1011
|
+
* Write a list as repeated keys with the NetworkX conventions.
|
|
1012
|
+
* @param indent - the indentation
|
|
1013
|
+
* @param key - the key
|
|
1014
|
+
* @param items - the items
|
|
1015
|
+
* @param itemDtype - the item dtype
|
|
1016
|
+
* @param meta - the column metadata
|
|
1017
|
+
*/
|
|
1018
|
+
private list(indent: string, key: string, items: readonly unknown[], itemDtype: string, meta: ColumnMeta): void {
|
|
1019
|
+
if (items.length === 0) {
|
|
1020
|
+
this.line(indent, key, quoteGmlString(EMPTY_LIST_TEXT));
|
|
1021
|
+
return;
|
|
1022
|
+
}
|
|
1023
|
+
if (items.length === 1) {
|
|
1024
|
+
this.line(indent, key, quoteGmlString(LIST_START_MARKER));
|
|
1025
|
+
}
|
|
1026
|
+
for (const item of items) {
|
|
1027
|
+
if (itemDtype === "json" && typeof item === "object") {
|
|
1028
|
+
this.json(indent, key, item, meta.name, true);
|
|
1029
|
+
} else {
|
|
1030
|
+
this.line(indent, key, itemText(item, itemDtype, meta));
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/**
|
|
1036
|
+
* Write a JSON value: a record for an object, repeated keys for an array, a scalar otherwise;
|
|
1037
|
+
* null writes nothing.
|
|
1038
|
+
* @param indent - the indentation
|
|
1039
|
+
* @param key - the key
|
|
1040
|
+
* @param value - the value
|
|
1041
|
+
* @param column - the column name, for errors
|
|
1042
|
+
* @param inArray - whether the value is an array item (an array here has no GML spelling)
|
|
1043
|
+
*/
|
|
1044
|
+
private json(indent: string, key: string, value: unknown, column: string, inArray = false): void {
|
|
1045
|
+
if (value === null || value === undefined) {
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
if (Array.isArray(value)) {
|
|
1049
|
+
if (inArray) {
|
|
1050
|
+
throw new GraphFormatError(
|
|
1051
|
+
"E_UNSUPPORTED",
|
|
1052
|
+
`column "${column}": an array nested in an array cannot be written as GML`,
|
|
1053
|
+
{
|
|
1054
|
+
reason: "nested array",
|
|
1055
|
+
column,
|
|
1056
|
+
},
|
|
1057
|
+
);
|
|
1058
|
+
}
|
|
1059
|
+
if (value.length === 0) {
|
|
1060
|
+
this.line(indent, key, quoteGmlString(EMPTY_LIST_TEXT));
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
if (value.length === 1) {
|
|
1064
|
+
this.line(indent, key, quoteGmlString(LIST_START_MARKER));
|
|
1065
|
+
}
|
|
1066
|
+
for (const item of value) {
|
|
1067
|
+
this.json(indent, key, item, column, true);
|
|
1068
|
+
}
|
|
1069
|
+
return;
|
|
1070
|
+
}
|
|
1071
|
+
if (typeof value === "object") {
|
|
1072
|
+
this.record(indent, key, value as Record<string, unknown>, column);
|
|
1073
|
+
return;
|
|
1074
|
+
}
|
|
1075
|
+
if (typeof value === "number" || typeof value === "string" || typeof value === "boolean") {
|
|
1076
|
+
this.line(indent, key, jsonScalarText(value));
|
|
1077
|
+
return;
|
|
1078
|
+
}
|
|
1079
|
+
throw new GraphFormatError("E_COLUMN_TYPE", `column "${column}": a ${typeof value} cannot be written as GML`, {
|
|
1080
|
+
column,
|
|
1081
|
+
found: typeof value,
|
|
1082
|
+
});
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
/**
|
|
1086
|
+
* Write a record `key [ ... ]`.
|
|
1087
|
+
* @param indent - the indentation
|
|
1088
|
+
* @param key - the key
|
|
1089
|
+
* @param record - the object
|
|
1090
|
+
* @param column - the column name, for errors
|
|
1091
|
+
* @param extra - lines to write first (the position of a graphics record), or null
|
|
1092
|
+
*/
|
|
1093
|
+
record(
|
|
1094
|
+
indent: string,
|
|
1095
|
+
key: string,
|
|
1096
|
+
record: Record<string, unknown>,
|
|
1097
|
+
column: string,
|
|
1098
|
+
extra: readonly [string, string][] | null = null,
|
|
1099
|
+
): void {
|
|
1100
|
+
this.raw(`${indent}${key} [`);
|
|
1101
|
+
const inner = `${indent} `;
|
|
1102
|
+
const used = new Set<string>();
|
|
1103
|
+
if (extra !== null) {
|
|
1104
|
+
for (const [k, v] of extra) {
|
|
1105
|
+
this.line(inner, k, v);
|
|
1106
|
+
used.add(k);
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
for (const name of Object.keys(record)) {
|
|
1110
|
+
if (used.has(name) && extra !== null && (name === "x" || name === "y" || name === "z")) {
|
|
1111
|
+
continue;
|
|
1112
|
+
}
|
|
1113
|
+
const written = this.recordKey(name, used, column);
|
|
1114
|
+
used.add(written);
|
|
1115
|
+
this.json(inner, written, record[name], column);
|
|
1116
|
+
}
|
|
1117
|
+
this.raw(`${indent}]`);
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/**
|
|
1121
|
+
* The key a record field is written under.
|
|
1122
|
+
* @param name - the field name
|
|
1123
|
+
* @param used - the keys already written in the record
|
|
1124
|
+
* @param column - the column name, for errors
|
|
1125
|
+
* @returns a valid, unused key; E_UNSUPPORTED for an invalid key unless mangling
|
|
1126
|
+
*/
|
|
1127
|
+
private recordKey(name: string, used: ReadonlySet<string>, column: string): string {
|
|
1128
|
+
if (isGmlKey(name) && !used.has(name)) {
|
|
1129
|
+
return name;
|
|
1130
|
+
}
|
|
1131
|
+
if (!this.mangle) {
|
|
1132
|
+
throw new GraphFormatError(
|
|
1133
|
+
"E_UNSUPPORTED",
|
|
1134
|
+
`column "${column}": record key "${name}" ${isGmlKey(name) ? "repeats a key" : "is not a GML key"}; pass sanitizeKeys: "mangle" to rewrite it`,
|
|
1135
|
+
{
|
|
1136
|
+
reason: "gml key",
|
|
1137
|
+
column,
|
|
1138
|
+
key: name,
|
|
1139
|
+
},
|
|
1140
|
+
);
|
|
1141
|
+
}
|
|
1142
|
+
return uniqueKey(isGmlKey(name) ? name : mangleGmlKey(name), used);
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/** Everything the writer needs, resolved once before the first line. */
|
|
1147
|
+
interface WriteContext {
|
|
1148
|
+
readonly snapshot: GraphSnapshot;
|
|
1149
|
+
readonly plan: GmlExportPlan;
|
|
1150
|
+
readonly ids: SanitizedIds;
|
|
1151
|
+
readonly writeDirected: boolean;
|
|
1152
|
+
readonly foldPairs: boolean;
|
|
1153
|
+
readonly nodeColumns: readonly WrittenColumn[];
|
|
1154
|
+
readonly edgeColumns: readonly WrittenColumn[];
|
|
1155
|
+
readonly graphColumns: readonly WrittenColumn[];
|
|
1156
|
+
readonly topColumns: readonly WrittenColumn[];
|
|
1157
|
+
readonly position: Column | null;
|
|
1158
|
+
readonly graphics: JsonColumn | null;
|
|
1159
|
+
readonly originalIds: Column | null;
|
|
1160
|
+
readonly edgeIds: Column | null;
|
|
1161
|
+
readonly weights: ExplicitWeights;
|
|
1162
|
+
readonly folding: PairFolding;
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* Resolve everything export() needs, throwing for what check() reported as an error.
|
|
1167
|
+
* @param snapshot - the snapshot
|
|
1168
|
+
* @param options - the options
|
|
1169
|
+
* @returns the context
|
|
1170
|
+
*/
|
|
1171
|
+
function contextOf(
|
|
1172
|
+
snapshot: GraphSnapshot,
|
|
1173
|
+
options: (GmlExportOptions & CommonExportOptions) | undefined,
|
|
1174
|
+
): WriteContext {
|
|
1175
|
+
const plan = planOf(snapshot, options);
|
|
1176
|
+
const ids = sanitizeIds(snapshot, "integer", plan.common.sanitizeIds);
|
|
1177
|
+
const mixed = countMixedEdges(snapshot);
|
|
1178
|
+
if (mixed > 0 && plan.common.onMixedDirection === "error") {
|
|
1179
|
+
throw new GraphFormatError(
|
|
1180
|
+
"E_DIRECTED",
|
|
1181
|
+
`${mixed} undirected edge(s) in a directed graph; GML has no mixed direction (onMixedDirection: "error")`,
|
|
1182
|
+
{
|
|
1183
|
+
reason: "mixed direction",
|
|
1184
|
+
count: mixed,
|
|
1185
|
+
},
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
// design 8.5: exporters fold expanded pairs back; under "directed" the folded edge is written
|
|
1189
|
+
// once, as one directed edge, under "undirected" the whole graph is written undirected
|
|
1190
|
+
const foldPairs = mixed > 0;
|
|
1191
|
+
const writeDirected = snapshot.directed && plan.common.onMixedDirection !== "undirected";
|
|
1192
|
+
const originalIds = snapshot.nodes.byRole("originalId");
|
|
1193
|
+
const nodeReserved = new Set(NODE_RESERVED);
|
|
1194
|
+
if (ids.changed > 0 || originalIds !== null) {
|
|
1195
|
+
nodeReserved.add(ORIGINAL_ID_KEY);
|
|
1196
|
+
}
|
|
1197
|
+
const edgeIds = snapshot.edges.byRole("id");
|
|
1198
|
+
const edgeReserved = edgeReservedKeys(snapshot, plan, edgeIds);
|
|
1199
|
+
const topReserved = topReservedKeys(snapshot);
|
|
1200
|
+
return {
|
|
1201
|
+
snapshot,
|
|
1202
|
+
plan,
|
|
1203
|
+
ids,
|
|
1204
|
+
writeDirected,
|
|
1205
|
+
foldPairs,
|
|
1206
|
+
nodeColumns: selectColumns(
|
|
1207
|
+
snapshot.nodes,
|
|
1208
|
+
nodeReserved,
|
|
1209
|
+
"node",
|
|
1210
|
+
plan,
|
|
1211
|
+
null,
|
|
1212
|
+
(c) => !(c.meta.name === "graphics" && c.dtype === "json"),
|
|
1213
|
+
),
|
|
1214
|
+
edgeColumns: selectColumns(snapshot.edges, edgeReserved, "edge", plan, null, (c) => c !== edgeIds),
|
|
1215
|
+
graphColumns: selectColumns(snapshot.graph, GRAPH_RESERVED, "graph", plan, null, (c) => !isTopLevel(c)),
|
|
1216
|
+
topColumns: selectColumns(snapshot.graph, topReserved, "top-level", plan, null, isTopLevel),
|
|
1217
|
+
position: positionColumn(snapshot),
|
|
1218
|
+
graphics: graphicsColumn(snapshot),
|
|
1219
|
+
originalIds,
|
|
1220
|
+
edgeIds,
|
|
1221
|
+
weights: explicitWeights(snapshot),
|
|
1222
|
+
folding: pairFolding(snapshot),
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/**
|
|
1227
|
+
* The GML text of an id written into `graphty_originalId`: quoted for a string, a number text
|
|
1228
|
+
* otherwise (an integer as int, else real, so the importer restores the same typed value).
|
|
1229
|
+
* @param id - the original id
|
|
1230
|
+
* @returns the text
|
|
1231
|
+
*/
|
|
1232
|
+
function originalIdText(id: NodeId): string {
|
|
1233
|
+
if (typeof id === "string") {
|
|
1234
|
+
return quoteGmlString(id);
|
|
1235
|
+
}
|
|
1236
|
+
return Number.isSafeInteger(id) ? formatInteger(id) : gmlRealText(id, "f64");
|
|
1237
|
+
}
|
|
1238
|
+
|
|
1239
|
+
/**
|
|
1240
|
+
* The parts of the document.
|
|
1241
|
+
* @param context - the resolved context
|
|
1242
|
+
* @yields one string per header line, node or edge
|
|
1243
|
+
* @returns nothing
|
|
1244
|
+
*/
|
|
1245
|
+
function* gmlParts(context: WriteContext): Generator<string, void, undefined> {
|
|
1246
|
+
const { snapshot, plan, ids } = context;
|
|
1247
|
+
const w = new GmlWriter(plan.mangle);
|
|
1248
|
+
const { meta } = snapshot;
|
|
1249
|
+
if (meta.creator !== null) {
|
|
1250
|
+
w.line("", "Creator", quoteGmlString(meta.creator));
|
|
1251
|
+
}
|
|
1252
|
+
if (meta.sourceFormat === "gml" && meta.sourceVersion !== null) {
|
|
1253
|
+
w.line("", "Version", versionText(meta.sourceVersion));
|
|
1254
|
+
}
|
|
1255
|
+
for (const { column, key } of context.topColumns) {
|
|
1256
|
+
if (column.isSet(0)) {
|
|
1257
|
+
w.cell("", key, column, 0);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
w.raw("graph [");
|
|
1261
|
+
w.line(" ", "directed", context.writeDirected ? "1" : "0");
|
|
1262
|
+
const multigraph = meta.declaredMultigraph ?? (snapshot.flags.multigraph ? true : null);
|
|
1263
|
+
if (multigraph !== null) {
|
|
1264
|
+
w.line(" ", "multigraph", multigraph ? "1" : "0");
|
|
1265
|
+
}
|
|
1266
|
+
for (const { column, key } of context.graphColumns) {
|
|
1267
|
+
if (column.isSet(0)) {
|
|
1268
|
+
w.cell(" ", key, column, 0);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
yield w.take();
|
|
1272
|
+
for (let i = 0; i < snapshot.nodeCount; i++) {
|
|
1273
|
+
w.raw(" node [");
|
|
1274
|
+
w.line(" ", "id", formatInteger(ids.idAt(i) as number));
|
|
1275
|
+
if (ids.isChanged(i)) {
|
|
1276
|
+
w.line(" ", ORIGINAL_ID_KEY, originalIdText(ids.originalAt(i)));
|
|
1277
|
+
} else if (context.originalIds !== null && context.originalIds.isSet(i)) {
|
|
1278
|
+
const original = context.originalIds.value(i);
|
|
1279
|
+
if (typeof original === "string" || typeof original === "number") {
|
|
1280
|
+
w.line(" ", ORIGINAL_ID_KEY, originalIdText(original));
|
|
1281
|
+
}
|
|
1282
|
+
}
|
|
1283
|
+
for (const { column, key } of context.nodeColumns) {
|
|
1284
|
+
if (column.isSet(i)) {
|
|
1285
|
+
w.cell(" ", key, column, i);
|
|
1286
|
+
}
|
|
1287
|
+
}
|
|
1288
|
+
writeGraphics(w, context, i);
|
|
1289
|
+
w.raw(" ]");
|
|
1290
|
+
yield w.take();
|
|
1291
|
+
}
|
|
1292
|
+
const el = snapshot.edgeList();
|
|
1293
|
+
const origin = meta.weightOrigin;
|
|
1294
|
+
const integerWeights = origin !== null && origin.type === "int";
|
|
1295
|
+
for (let e = 0; e < snapshot.edgeCount; e++) {
|
|
1296
|
+
if (context.foldPairs && context.folding.folded(e)) {
|
|
1297
|
+
continue;
|
|
1298
|
+
}
|
|
1299
|
+
w.raw(" edge [");
|
|
1300
|
+
w.line(" ", "source", formatInteger(ids.idAt(el.src[e]) as number));
|
|
1301
|
+
w.line(" ", "target", formatInteger(ids.idAt(el.dst[e]) as number));
|
|
1302
|
+
if (context.edgeIds !== null && context.edgeIds.isSet(e)) {
|
|
1303
|
+
w.cell(" ", "id", context.edgeIds, e);
|
|
1304
|
+
}
|
|
1305
|
+
if (context.weights.isExplicit(e)) {
|
|
1306
|
+
const weight = context.weights.value(e);
|
|
1307
|
+
const text =
|
|
1308
|
+
integerWeights && Number.isInteger(weight)
|
|
1309
|
+
? formatInteger(weight)
|
|
1310
|
+
: gmlRealText(weight, context.weights.dtype);
|
|
1311
|
+
w.line(" ", plan.weightKey, text);
|
|
1312
|
+
}
|
|
1313
|
+
for (const { column, key } of context.edgeColumns) {
|
|
1314
|
+
if (column.isSet(e)) {
|
|
1315
|
+
w.cell(" ", key, column, e);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
w.raw(" ]");
|
|
1319
|
+
yield w.take();
|
|
1320
|
+
}
|
|
1321
|
+
w.raw("]");
|
|
1322
|
+
yield w.take();
|
|
1323
|
+
}
|
|
1324
|
+
|
|
1325
|
+
/**
|
|
1326
|
+
* Write a node's `graphics [ ... ]` record from the position column and the graphics json column.
|
|
1327
|
+
* @param w - the writer
|
|
1328
|
+
* @param context - the context
|
|
1329
|
+
* @param i - the node
|
|
1330
|
+
*/
|
|
1331
|
+
function writeGraphics(w: GmlWriter, context: WriteContext, i: number): void {
|
|
1332
|
+
const { position, graphics } = context;
|
|
1333
|
+
const hasPosition = position !== null && position.isSet(i);
|
|
1334
|
+
const graphicsValue = graphics !== null && graphics.isSet(i) ? graphics.values[i] : undefined;
|
|
1335
|
+
if (!hasPosition) {
|
|
1336
|
+
if (graphics !== null && graphicsValue !== undefined) {
|
|
1337
|
+
w.cell(" ", "graphics", graphics, i);
|
|
1338
|
+
}
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
const numeric = position as Column & { readonly data: ArrayLike<number> };
|
|
1342
|
+
const { components } = numeric.meta;
|
|
1343
|
+
const lanes = Math.min(components, 3);
|
|
1344
|
+
const extra: [string, string][] = [];
|
|
1345
|
+
const names = ["x", "y", "z"];
|
|
1346
|
+
for (let k = 0; k < lanes; k++) {
|
|
1347
|
+
extra.push([names[k], numberText(numeric.data[i * components + k], numeric.meta)]);
|
|
1348
|
+
}
|
|
1349
|
+
let record: Record<string, unknown> = {};
|
|
1350
|
+
if (graphicsValue !== undefined) {
|
|
1351
|
+
if (typeof graphicsValue !== "object" || graphicsValue === null || Array.isArray(graphicsValue)) {
|
|
1352
|
+
throw new GraphFormatError(
|
|
1353
|
+
"E_UNSUPPORTED",
|
|
1354
|
+
`node ${i}: the graphics value is not a record and cannot hold the position`,
|
|
1355
|
+
{
|
|
1356
|
+
reason: "graphics conflict",
|
|
1357
|
+
node: i,
|
|
1358
|
+
},
|
|
1359
|
+
);
|
|
1360
|
+
}
|
|
1361
|
+
record = graphicsValue as Record<string, unknown>;
|
|
1362
|
+
}
|
|
1363
|
+
w.record(" ", "graphics", record, "graphics", extra);
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/** The GML exporter plugin. */
|
|
1367
|
+
export const gmlExporter: GraphExporter<GmlExportOptions> = Object.freeze({
|
|
1368
|
+
format: "gml",
|
|
1369
|
+
capabilities: GML_CAPABILITIES,
|
|
1370
|
+
check,
|
|
1371
|
+
/**
|
|
1372
|
+
* Write the snapshot as UTF-8 chunks; the context is resolved on the first pull, so the
|
|
1373
|
+
* errors check() announced surface from the iteration.
|
|
1374
|
+
* @param snapshot - the snapshot
|
|
1375
|
+
* @param options - the options
|
|
1376
|
+
* @returns the chunks
|
|
1377
|
+
*/
|
|
1378
|
+
export(snapshot: GraphSnapshot, options?: GmlExportOptions & CommonExportOptions): AsyncIterable<Uint8Array> {
|
|
1379
|
+
return encodeChunks(lazyParts(snapshot, options));
|
|
1380
|
+
},
|
|
1381
|
+
/**
|
|
1382
|
+
* Write the snapshot as one string.
|
|
1383
|
+
* @param snapshot - the snapshot
|
|
1384
|
+
* @param options - the options
|
|
1385
|
+
* @returns the document
|
|
1386
|
+
*/
|
|
1387
|
+
exportToString(snapshot: GraphSnapshot, options?: GmlExportOptions & CommonExportOptions): Promise<string> {
|
|
1388
|
+
return joinText(lazyParts(snapshot, options));
|
|
1389
|
+
},
|
|
1390
|
+
});
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* The document parts, with the context resolved when iteration starts.
|
|
1394
|
+
* @param snapshot - the snapshot
|
|
1395
|
+
* @param options - the options
|
|
1396
|
+
* @yields the parts
|
|
1397
|
+
* @returns nothing
|
|
1398
|
+
*/
|
|
1399
|
+
function* lazyParts(
|
|
1400
|
+
snapshot: GraphSnapshot,
|
|
1401
|
+
options: (GmlExportOptions & CommonExportOptions) | undefined,
|
|
1402
|
+
): Generator<string, void, undefined> {
|
|
1403
|
+
yield* gmlParts(contextOf(snapshot, options));
|
|
1404
|
+
}
|