@graphty/graph-io 0.0.0 → 0.2.0
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,2271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The JSON importer (design sections 8.2, 8.4 and 8.5): one GraphImporter that sniffs the dialect
|
|
3
|
+
* of a parsed document -- NetworkX node-link (old `links` and new `edges` key forms, graph-level
|
|
4
|
+
* `directed` / `multigraph` / `graph`), the d3 lineage of the same shape (`name` ids, integer
|
|
5
|
+
* index endpoints), JSON Graph Format v2 (nodes keyed by id, per-edge `directed`, hyperedges),
|
|
6
|
+
* Cytoscape.js elements (`data.id` / `data.source` / `data.target`, `position`, `classes`,
|
|
7
|
+
* `data.parent`), graphology serialisation (`key` / `attributes`, `undirected` edges, `options`)
|
|
8
|
+
* and vis.js (`from` / `to`) -- and pushes it scalar by scalar into the sink.
|
|
9
|
+
*
|
|
10
|
+
* JSON awaits the whole text (design section 8.4: `JSON.parse` on 100 MB is fine; a streaming
|
|
11
|
+
* tokeniser is a later improvement). The parsed records are iterated in place; the importer never
|
|
12
|
+
* builds an intermediate array of node or edge objects. Ids are coerced per `ids` ("keep" by
|
|
13
|
+
* default: JSON values are already typed); node ids that are JSON `true` / `false` / `null` are
|
|
14
|
+
* reported as `unsupported` and coerced with `String(v)` only under `ids: "string"`. Attribute
|
|
15
|
+
* columns are inferred per column by the sink (design section 5.1); the structural fields of a
|
|
16
|
+
* dialect (Cytoscape `position` / `classes` / `parent`, JGF `label` / `relation`, edge ids) are
|
|
17
|
+
* declared up front with their roles when the file uses them. Direction goes through the
|
|
18
|
+
* DirectionResolver of design section 8.4; per-element errors are aggregated into the ImportReport
|
|
19
|
+
* until the error limit (design section 8.6).
|
|
20
|
+
*
|
|
21
|
+
* Fatal errors (ImportError at once): empty input, invalid JSON, an unrecognised top-level shape, a
|
|
22
|
+
* section that is not an array / object. Recoverable errors (an issue, the element skipped): a
|
|
23
|
+
* missing nodes or edges array, a node without an id, an edge without an endpoint, a bad index
|
|
24
|
+
* endpoint, a declared field of the wrong type, an unknown Cytoscape parent, an id the coercion rule
|
|
25
|
+
* rejects.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import {
|
|
29
|
+
type ColumnDecl,
|
|
30
|
+
type ColumnHandle,
|
|
31
|
+
GraphFormatError,
|
|
32
|
+
type GraphMetaPatch,
|
|
33
|
+
type GraphSink,
|
|
34
|
+
INVALID_INDEX,
|
|
35
|
+
type NodeId,
|
|
36
|
+
} from "@graphty/graph-format";
|
|
37
|
+
|
|
38
|
+
import { uniqueColumnName } from "../../common/attributes.js";
|
|
39
|
+
import {
|
|
40
|
+
DUPLICATE_EDGE_ID_CODE,
|
|
41
|
+
DUPLICATE_NODE_CODE,
|
|
42
|
+
EMPTY_INPUT_CODE,
|
|
43
|
+
HYPEREDGE_CODE,
|
|
44
|
+
MISSING_ENDPOINT_CODE,
|
|
45
|
+
MISSING_ID_CODE,
|
|
46
|
+
MULTIPLE_GRAPHS_CODE,
|
|
47
|
+
OPTION_IGNORED_CODE,
|
|
48
|
+
SYNTAX_CODE,
|
|
49
|
+
UNKNOWN_PARENT_CODE,
|
|
50
|
+
} from "../../common/codes.js";
|
|
51
|
+
import { DirectionResolver, type EdgeKind } from "../../common/direction.js";
|
|
52
|
+
import { ID_MERGED_CODE, IdCoercer } from "../../common/ids.js";
|
|
53
|
+
import { readText, throwIfAborted } from "../../common/input.js";
|
|
54
|
+
import {
|
|
55
|
+
type ImportFormatDefaults,
|
|
56
|
+
reportSinkOptions,
|
|
57
|
+
reportUnusedOptions,
|
|
58
|
+
type ResolvedImportOptions,
|
|
59
|
+
resolveImportOptions,
|
|
60
|
+
SINK_OPTION_CODE,
|
|
61
|
+
} from "../../common/options.js";
|
|
62
|
+
import { ImportReportBuilder } from "../../common/report.js";
|
|
63
|
+
import { weightFromValue } from "../../common/weights.js";
|
|
64
|
+
import { type CommonImportOptions, type GraphImporter, type ImportInput, type ImportReport } from "../../types.js";
|
|
65
|
+
import {
|
|
66
|
+
CLASSES_COLUMN,
|
|
67
|
+
CYTOSCAPE_ELEMENT_KEYS,
|
|
68
|
+
CYTOSCAPE_STRUCTURAL_KEYS,
|
|
69
|
+
DIALECT_DEFAULT_DIRECTED,
|
|
70
|
+
hasKey,
|
|
71
|
+
isJsonDialect,
|
|
72
|
+
isJsonObject,
|
|
73
|
+
JSON_DIALECTS,
|
|
74
|
+
type JsonDialect,
|
|
75
|
+
type JsonShapeMeta,
|
|
76
|
+
META_KEY,
|
|
77
|
+
NODE_LINK_SOURCE_KEYS,
|
|
78
|
+
NODE_LINK_TARGET_KEYS,
|
|
79
|
+
PARENT_COLUMN,
|
|
80
|
+
POSITION_COLUMN,
|
|
81
|
+
sniffJsonDialect,
|
|
82
|
+
SUFFIX,
|
|
83
|
+
} from "./dialect.js";
|
|
84
|
+
|
|
85
|
+
/** The format-specific options of the JSON importer. */
|
|
86
|
+
export interface JsonImportOptions {
|
|
87
|
+
/** The dialect to read; "auto" (default) sniffs the parsed document. */
|
|
88
|
+
dialect?: JsonDialect | "auto" | undefined;
|
|
89
|
+
/** node-link / d3 / vis: the node key holding the id; auto: "id" when any node has it, else "name". */
|
|
90
|
+
nodeIdKey?: string | undefined;
|
|
91
|
+
/** node-link / d3: the top-level key holding the edges; auto: "edges" when present, else "links". */
|
|
92
|
+
edgesKey?: string | undefined;
|
|
93
|
+
/** node-link / d3 / vis: the edge key holding the source; auto: "source", "src" or "from" (vis: "from"). */
|
|
94
|
+
sourceKey?: string | undefined;
|
|
95
|
+
/** node-link / d3 / vis: the edge key holding the target; auto: "target", "dst" or "to" (vis: "to"). */
|
|
96
|
+
targetKey?: string | undefined;
|
|
97
|
+
/**
|
|
98
|
+
* node-link / d3: whether edge endpoints are node array positions; "auto" (default) says yes when
|
|
99
|
+
* every endpoint is an integer below the node count and no node id is a number.
|
|
100
|
+
*/
|
|
101
|
+
indexLinks?: boolean | "auto" | undefined;
|
|
102
|
+
/** jgf: which graph of a `graphs` array to read; 0 by default. */
|
|
103
|
+
graphIndex?: number | undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The issue codes the JSON importer records (design section 8.6), by name: the codes shared with
|
|
108
|
+
* the other importers (src/common/codes.ts) and the JSON-specific ones. A key is the code without
|
|
109
|
+
* its severity and format prefixes.
|
|
110
|
+
*/
|
|
111
|
+
export const JSON_ISSUE = Object.freeze({
|
|
112
|
+
/** The text is empty or whitespace (fatal). */
|
|
113
|
+
EMPTY_INPUT: EMPTY_INPUT_CODE,
|
|
114
|
+
/** JSON.parse refused the text (fatal). */
|
|
115
|
+
SYNTAX: SYNTAX_CODE,
|
|
116
|
+
/** No dialect matches the document's top-level shape. */
|
|
117
|
+
DIALECT: "E_JSON_DIALECT",
|
|
118
|
+
/** A section (nodes, edges, elements, graph) has the wrong JSON type. */
|
|
119
|
+
SHAPE: "E_JSON_SHAPE",
|
|
120
|
+
/** A node-link document lacks its nodes or its edges array, or a Cytoscape document its elements. */
|
|
121
|
+
MISSING_SECTION: "E_MISSING_SECTION",
|
|
122
|
+
/** A node record or an element is not an object. */
|
|
123
|
+
BAD_ELEMENT: "E_BAD_ELEMENT",
|
|
124
|
+
/** A node record has no id. */
|
|
125
|
+
MISSING_ID: MISSING_ID_CODE,
|
|
126
|
+
/** A node id is a JSON boolean or null (legal in NetworkX, not a NodeId); coerced only under ids "string". */
|
|
127
|
+
UNSUPPORTED_ID: "E_UNSUPPORTED_ID",
|
|
128
|
+
/** An edge record has no source or no target. */
|
|
129
|
+
MISSING_ENDPOINT: MISSING_ENDPOINT_CODE,
|
|
130
|
+
/** An index endpoint is not an integer below the node count, or names a skipped node. */
|
|
131
|
+
BAD_INDEX: "E_BAD_INDEX",
|
|
132
|
+
/** A declared field has the wrong JSON type (JGF label / relation / metadata, Cytoscape position / classes). */
|
|
133
|
+
BAD_VALUE: "E_BAD_VALUE",
|
|
134
|
+
/** A graph-level flag (`directed`, `multigraph`, graphology `options`) has the wrong type; the default is used. */
|
|
135
|
+
BAD_FLAG: "W_BAD_FLAG",
|
|
136
|
+
/** A Cytoscape `data.parent` names an unknown node. */
|
|
137
|
+
UNKNOWN_PARENT: UNKNOWN_PARENT_CODE,
|
|
138
|
+
/** A node id repeated by a later record; the records are merged (the later attributes win). */
|
|
139
|
+
DUPLICATE_NODE: DUPLICATE_NODE_CODE,
|
|
140
|
+
/** An edge id (Cytoscape data.id, graphology key, vis id) repeated by a later edge; the edge is skipped. */
|
|
141
|
+
DUPLICATE_EDGE_ID: DUPLICATE_EDGE_ID_CODE,
|
|
142
|
+
/** Two distinct id texts merged into one number under ids "number". */
|
|
143
|
+
ID_MERGED: ID_MERGED_CODE,
|
|
144
|
+
/** Edge ids of mixed JSON types were stored as text. */
|
|
145
|
+
EDGE_ID_STRINGIFIED: "W_EDGE_ID_STRINGIFIED",
|
|
146
|
+
/** A JGF `graphs` array holds more than one graph; only `graphIndex` is read. */
|
|
147
|
+
MULTIPLE_GRAPHS: MULTIPLE_GRAPHS_CODE,
|
|
148
|
+
/** JGF hyperedges under the "error" policy. */
|
|
149
|
+
HYPEREDGE: HYPEREDGE_CODE,
|
|
150
|
+
/** JGF hyperedges skipped under the default "skip" policy. */
|
|
151
|
+
HYPEREDGES_SKIPPED: "W_HYPEREDGES_SKIPPED",
|
|
152
|
+
/** A JGF hyperedge with neither a nodes array nor source / target arrays. */
|
|
153
|
+
HYPEREDGE_SHAPE: "E_HYPEREDGE_SHAPE",
|
|
154
|
+
/** The nodes have no id key at all; array positions became the ids. */
|
|
155
|
+
POSITIONAL_NODES: "W_POSITIONAL_NODES",
|
|
156
|
+
/** A builder-policy option (addMissingNodes, duplicateEdges, selfLoops, weightDtype) differs from the sink's (the shared W_SINK_OPTION). */
|
|
157
|
+
SINK_OPTION: SINK_OPTION_CODE,
|
|
158
|
+
/** A common option the dialect has no use for (nodeIdFrom outside node-link, long, restoreMangledIds). */
|
|
159
|
+
OPTION_IGNORED: OPTION_IGNORED_CODE,
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
/** The common options the JSON importer reads (the rest is reported by reportUnusedOptions). */
|
|
163
|
+
const USED_OPTIONS: ReadonlySet<keyof CommonImportOptions> = new Set<keyof CommonImportOptions>([
|
|
164
|
+
"ids",
|
|
165
|
+
"nodeIdFrom",
|
|
166
|
+
"addMissingNodes",
|
|
167
|
+
"duplicateEdges",
|
|
168
|
+
"selfLoops",
|
|
169
|
+
"onMixedDirection",
|
|
170
|
+
"defaultDirected",
|
|
171
|
+
"weightFrom",
|
|
172
|
+
"weightDtype",
|
|
173
|
+
"hyperedges",
|
|
174
|
+
"errorLimit",
|
|
175
|
+
"signal",
|
|
176
|
+
"onProgress",
|
|
177
|
+
]);
|
|
178
|
+
|
|
179
|
+
const FORMAT_DEFAULTS: ImportFormatDefaults = { ids: "keep", defaultDirected: false, weightFrom: "weight" };
|
|
180
|
+
|
|
181
|
+
/** Bytes of the head sniff() inspects. */
|
|
182
|
+
const SNIFF_BYTES = 4096;
|
|
183
|
+
|
|
184
|
+
/** Elements pushed between two checks of the cancellation signal (the whole document is one chunk). */
|
|
185
|
+
const ABORT_CHECK_INTERVAL = 64;
|
|
186
|
+
|
|
187
|
+
/** The top-level keys whose presence in the head marks a graph document rather than arbitrary JSON. */
|
|
188
|
+
const SNIFF_KEYS: readonly string[] = ['"nodes"', '"links"', '"edges"', '"elements"', '"graph"', '"graphs"'];
|
|
189
|
+
|
|
190
|
+
const BOM = String.fromCharCode(0xfeff);
|
|
191
|
+
|
|
192
|
+
/** The JGF edge keys that are not metadata. */
|
|
193
|
+
const JGF_EDGE_KEYS: ReadonlySet<string> = new Set([
|
|
194
|
+
"id",
|
|
195
|
+
"source",
|
|
196
|
+
"target",
|
|
197
|
+
"relation",
|
|
198
|
+
"directed",
|
|
199
|
+
"label",
|
|
200
|
+
"metadata",
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
/** The JGF hyperedge keys that are not metadata. */
|
|
204
|
+
const JGF_HYPEREDGE_KEYS: ReadonlySet<string> = new Set([...JGF_EDGE_KEYS, "nodes"]);
|
|
205
|
+
|
|
206
|
+
/** The JGF node keys that are not metadata. */
|
|
207
|
+
const JGF_NODE_KEYS: ReadonlySet<string> = new Set(["label", "metadata"]);
|
|
208
|
+
|
|
209
|
+
/** The graphology node keys that are not attributes. */
|
|
210
|
+
const GRAPHOLOGY_NODE_KEYS: ReadonlySet<string> = new Set(["key", "attributes"]);
|
|
211
|
+
|
|
212
|
+
/** The graphology edge keys that are not attributes. */
|
|
213
|
+
const GRAPHOLOGY_EDGE_KEYS: ReadonlySet<string> = new Set(["key", "source", "target", "attributes", "undirected"]);
|
|
214
|
+
|
|
215
|
+
/** The vis.js endpoint keys. */
|
|
216
|
+
const VIS_SOURCE_KEYS: readonly string[] = Object.freeze(["from"]);
|
|
217
|
+
const VIS_TARGET_KEYS: readonly string[] = Object.freeze(["to"]);
|
|
218
|
+
|
|
219
|
+
type JsonRecord = Record<string, unknown>;
|
|
220
|
+
|
|
221
|
+
/** An edge id column declared from a scan of the file's edge ids. */
|
|
222
|
+
interface EdgeIdColumn {
|
|
223
|
+
readonly handle: ColumnHandle;
|
|
224
|
+
/** Whether numeric ids are stored as text (the file mixes numbers and strings). */
|
|
225
|
+
readonly stringify: boolean;
|
|
226
|
+
/** The id texts seen so far when the dialect requires unique ids, else null. */
|
|
227
|
+
readonly seen: Set<string> | null;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** The resolved format-specific options. */
|
|
231
|
+
interface ResolvedJsonOptions {
|
|
232
|
+
readonly dialect: JsonDialect | "auto";
|
|
233
|
+
readonly nodeIdKey: string | null;
|
|
234
|
+
readonly edgesKey: string | null;
|
|
235
|
+
readonly sourceKey: string | null;
|
|
236
|
+
readonly targetKey: string | null;
|
|
237
|
+
readonly indexLinks: boolean | "auto";
|
|
238
|
+
readonly graphIndex: number;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Check the format-specific options.
|
|
243
|
+
* @param options - the caller's options
|
|
244
|
+
* @returns the resolved options; E_UNSUPPORTED for a bad value
|
|
245
|
+
*/
|
|
246
|
+
function resolveJsonOptions(options: (JsonImportOptions & CommonImportOptions) | undefined): ResolvedJsonOptions {
|
|
247
|
+
const o = options ?? {};
|
|
248
|
+
const dialect = o.dialect ?? "auto";
|
|
249
|
+
if (dialect !== "auto" && !isJsonDialect(dialect)) {
|
|
250
|
+
throw unsupportedOption("dialect", dialect, [...JSON_DIALECTS, "auto"]);
|
|
251
|
+
}
|
|
252
|
+
const indexLinks = o.indexLinks ?? "auto";
|
|
253
|
+
if (indexLinks !== "auto" && typeof indexLinks !== "boolean") {
|
|
254
|
+
throw unsupportedOption("indexLinks", indexLinks, ["true", "false", "auto"]);
|
|
255
|
+
}
|
|
256
|
+
const graphIndex = o.graphIndex ?? 0;
|
|
257
|
+
if (!Number.isInteger(graphIndex) || graphIndex < 0) {
|
|
258
|
+
throw unsupportedOption("graphIndex", graphIndex, ["a non-negative integer"]);
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
dialect,
|
|
262
|
+
nodeIdKey: keyOption("nodeIdKey", o.nodeIdKey),
|
|
263
|
+
edgesKey: keyOption("edgesKey", o.edgesKey),
|
|
264
|
+
sourceKey: keyOption("sourceKey", o.sourceKey),
|
|
265
|
+
targetKey: keyOption("targetKey", o.targetKey),
|
|
266
|
+
indexLinks,
|
|
267
|
+
graphIndex,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Check a key-valued option.
|
|
273
|
+
* @param name - the option name
|
|
274
|
+
* @param value - the caller's value
|
|
275
|
+
* @returns the key, or null when absent
|
|
276
|
+
*/
|
|
277
|
+
function keyOption(name: string, value: unknown): string | null {
|
|
278
|
+
if (value === undefined) {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
282
|
+
throw unsupportedOption(name, value, ["a non-empty key"]);
|
|
283
|
+
}
|
|
284
|
+
return value;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The E_UNSUPPORTED error of a bad format-specific option (the core's convention, as in the common
|
|
289
|
+
* option module).
|
|
290
|
+
* @param name - the option name
|
|
291
|
+
* @param found - the value
|
|
292
|
+
* @param supported - what is accepted
|
|
293
|
+
* @returns the error
|
|
294
|
+
*/
|
|
295
|
+
function unsupportedOption(name: string, found: unknown, supported: readonly string[]): GraphFormatError {
|
|
296
|
+
return new GraphFormatError(
|
|
297
|
+
"E_UNSUPPORTED",
|
|
298
|
+
`option ${name}: ${describe(found)} is not one of ${supported.join(", ")}`,
|
|
299
|
+
{
|
|
300
|
+
option: name,
|
|
301
|
+
found: typeof found === "string" ? found : typeof found,
|
|
302
|
+
supported: [...supported],
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* A short description of a value for messages.
|
|
309
|
+
* @param value - the value
|
|
310
|
+
* @returns JSON for primitives, "array" or the type name otherwise
|
|
311
|
+
*/
|
|
312
|
+
function describe(value: unknown): string {
|
|
313
|
+
if (value === null) {
|
|
314
|
+
return "null";
|
|
315
|
+
}
|
|
316
|
+
if (Array.isArray(value)) {
|
|
317
|
+
return "array";
|
|
318
|
+
}
|
|
319
|
+
switch (typeof value) {
|
|
320
|
+
case "string":
|
|
321
|
+
case "number":
|
|
322
|
+
case "boolean":
|
|
323
|
+
return JSON.stringify(value);
|
|
324
|
+
default:
|
|
325
|
+
return typeof value;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ============================================================ attribute writer
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Writes inferred attribute cells for one table, caching the handle of every column after its
|
|
333
|
+
* first write so the hot loop never looks a column up by name twice. Names that collide with a
|
|
334
|
+
* structural column declared up front are suffixed deterministically (design section 5.6).
|
|
335
|
+
*/
|
|
336
|
+
class AttributeWriter {
|
|
337
|
+
private readonly sink: GraphSink;
|
|
338
|
+
|
|
339
|
+
private readonly domain: "node" | "edge";
|
|
340
|
+
|
|
341
|
+
private readonly handles = new Map<string, ColumnHandle>();
|
|
342
|
+
|
|
343
|
+
private readonly reservedNames = new Set<string>();
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Create a writer.
|
|
347
|
+
* @param sink - the sink
|
|
348
|
+
* @param domain - node or edge
|
|
349
|
+
*/
|
|
350
|
+
constructor(sink: GraphSink, domain: "node" | "edge") {
|
|
351
|
+
this.sink = sink;
|
|
352
|
+
this.domain = domain;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Declare a structural column up front; attribute keys with its name are suffixed from now on.
|
|
357
|
+
* @param decl - the declaration
|
|
358
|
+
* @returns the handle
|
|
359
|
+
*/
|
|
360
|
+
declare(decl: ColumnDecl): ColumnHandle {
|
|
361
|
+
this.reservedNames.add(decl.name);
|
|
362
|
+
const handle = this.domain === "node" ? this.sink.declareNodeColumn(decl) : this.sink.declareEdgeColumn(decl);
|
|
363
|
+
this.handles.set(decl.name, handle);
|
|
364
|
+
return handle;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Declare a structural column only when the file uses it.
|
|
369
|
+
* @param decl - the declaration
|
|
370
|
+
* @param present - whether any element carries the field
|
|
371
|
+
* @returns the handle, or INVALID_INDEX when not declared
|
|
372
|
+
*/
|
|
373
|
+
declareIf(decl: ColumnDecl, present: boolean): ColumnHandle {
|
|
374
|
+
return present ? this.declare(decl) : (INVALID_INDEX as ColumnHandle);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Whether a name is taken in the sink's table (for the deterministic rename rule).
|
|
379
|
+
* @param name - the column name
|
|
380
|
+
* @returns true when a column of that name exists
|
|
381
|
+
*/
|
|
382
|
+
taken(name: string): boolean {
|
|
383
|
+
return this.lookup(name) !== INVALID_INDEX;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Write one attribute cell by its source key; null and undefined leave the row unset.
|
|
388
|
+
* @param row - the node or edge index
|
|
389
|
+
* @param key - the source key
|
|
390
|
+
* @param value - the JSON value
|
|
391
|
+
* @param suffix - the suffix applied when the key collides with a structural column
|
|
392
|
+
*/
|
|
393
|
+
write(row: number, key: string, value: unknown, suffix: string): void {
|
|
394
|
+
if (value === undefined || value === null) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
const name = this.reservedNames.has(key) ? `${key}${suffix}` : key;
|
|
398
|
+
const cached = this.handles.get(name);
|
|
399
|
+
if (cached !== undefined) {
|
|
400
|
+
this.set(cached, row, value);
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
this.set(name, row, value);
|
|
404
|
+
const handle = this.lookup(name);
|
|
405
|
+
if (handle !== INVALID_INDEX) {
|
|
406
|
+
this.handles.set(name, handle);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
/**
|
|
411
|
+
* Write through a handle or a name.
|
|
412
|
+
* @param column - the handle or name
|
|
413
|
+
* @param row - the row
|
|
414
|
+
* @param value - the value
|
|
415
|
+
*/
|
|
416
|
+
set(column: ColumnHandle | string, row: number, value: unknown): void {
|
|
417
|
+
if (this.domain === "node") {
|
|
418
|
+
this.sink.setNodeValue(column, row, value);
|
|
419
|
+
} else {
|
|
420
|
+
this.sink.setEdgeValue(column, row, value);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Look a column up by name.
|
|
426
|
+
* @param name - the column name
|
|
427
|
+
* @returns the handle, or INVALID_INDEX
|
|
428
|
+
*/
|
|
429
|
+
private lookup(name: string): ColumnHandle {
|
|
430
|
+
return this.domain === "node" ? this.sink.nodeColumn(name) : this.sink.edgeColumn(name);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
// ============================================================ the import context
|
|
435
|
+
|
|
436
|
+
/**
|
|
437
|
+
* Everything one import call shares between the dialect readers.
|
|
438
|
+
*/
|
|
439
|
+
class ImportContext {
|
|
440
|
+
readonly sink: GraphSink;
|
|
441
|
+
|
|
442
|
+
readonly report: ImportReportBuilder;
|
|
443
|
+
|
|
444
|
+
readonly options: ResolvedImportOptions;
|
|
445
|
+
|
|
446
|
+
readonly json: ResolvedJsonOptions;
|
|
447
|
+
|
|
448
|
+
readonly ids: IdCoercer;
|
|
449
|
+
|
|
450
|
+
readonly direction: DirectionResolver;
|
|
451
|
+
|
|
452
|
+
readonly nodes: AttributeWriter;
|
|
453
|
+
|
|
454
|
+
readonly edges: AttributeWriter;
|
|
455
|
+
|
|
456
|
+
/** Whether the caller passed `defaultDirected` explicitly (then it beats the dialect's convention). */
|
|
457
|
+
readonly explicitDefaultDirected: boolean;
|
|
458
|
+
|
|
459
|
+
/** Nodes and edges pushed since the signal was last checked. */
|
|
460
|
+
private elementsSinceCheck = 0;
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* Create the context.
|
|
464
|
+
* @param sink - the sink
|
|
465
|
+
* @param report - the report
|
|
466
|
+
* @param options - the resolved common options
|
|
467
|
+
* @param json - the resolved format options
|
|
468
|
+
* @param explicitDefaultDirected - whether the caller passed defaultDirected
|
|
469
|
+
*/
|
|
470
|
+
constructor(
|
|
471
|
+
sink: GraphSink,
|
|
472
|
+
report: ImportReportBuilder,
|
|
473
|
+
options: ResolvedImportOptions,
|
|
474
|
+
json: ResolvedJsonOptions,
|
|
475
|
+
explicitDefaultDirected: boolean,
|
|
476
|
+
) {
|
|
477
|
+
this.sink = sink;
|
|
478
|
+
this.report = report;
|
|
479
|
+
this.options = options;
|
|
480
|
+
this.json = json;
|
|
481
|
+
this.ids = new IdCoercer(options.ids);
|
|
482
|
+
this.direction = new DirectionResolver(sink, report, options.onMixedDirection);
|
|
483
|
+
this.nodes = new AttributeWriter(sink, "node");
|
|
484
|
+
this.edges = new AttributeWriter(sink, "edge");
|
|
485
|
+
this.explicitDefaultDirected = explicitDefaultDirected;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* Report a `nodeIdFrom` other than "id" for a dialect whose ids are unambiguous.
|
|
490
|
+
* @param dialect - the dialect
|
|
491
|
+
* @param idField - where the dialect's ids come from, for the message
|
|
492
|
+
*/
|
|
493
|
+
reportNodeIdFrom(dialect: JsonDialect, idField: string): void {
|
|
494
|
+
if (this.options.nodeIdFrom !== "id") {
|
|
495
|
+
this.report.warning(
|
|
496
|
+
"unsupported",
|
|
497
|
+
JSON_ISSUE.OPTION_IGNORED,
|
|
498
|
+
`nodeIdFrom "${this.options.nodeIdFrom}" does not apply to ${dialect}; ids are read from ${idField}`,
|
|
499
|
+
{ element: "nodeIdFrom" },
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* The direction assumed when the file declares none: the caller's `defaultDirected` when given,
|
|
506
|
+
* else the dialect's convention.
|
|
507
|
+
* @param dialect - the dialect
|
|
508
|
+
* @returns the direction
|
|
509
|
+
*/
|
|
510
|
+
defaultDirected(dialect: JsonDialect): boolean {
|
|
511
|
+
return this.explicitDefaultDirected ? this.options.defaultDirected : DIALECT_DEFAULT_DIRECTED[dialect];
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** The direction the file declares (or the default), set by setHeader(). */
|
|
515
|
+
private fileDirected = false;
|
|
516
|
+
|
|
517
|
+
/**
|
|
518
|
+
* Rule 1 of design section 8.4: set the sink's direction from the file's header (or the
|
|
519
|
+
* dialect's default) before the first edge, remembering the file's direction for uniformKind().
|
|
520
|
+
* @param directed - the file's direction
|
|
521
|
+
*/
|
|
522
|
+
setHeader(directed: boolean): void {
|
|
523
|
+
this.fileDirected = directed;
|
|
524
|
+
this.direction.setHeader(directed);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/**
|
|
528
|
+
* The edge kind every edge of a dialect without per-edge direction has: the file's direction
|
|
529
|
+
* (the resolver expands it when the sink's direction differs).
|
|
530
|
+
* @returns the kind
|
|
531
|
+
*/
|
|
532
|
+
uniformKind(): EdgeKind {
|
|
533
|
+
return this.fileDirected ? "directed" : "undirected";
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Coerce a node id value per the `ids` option, reporting what cannot be an id. A JSON boolean or
|
|
538
|
+
* null is `unsupported` unless `ids` is "string" (design section 8.5); anything the rule rejects
|
|
539
|
+
* is recorded through the per-element catch.
|
|
540
|
+
* @param raw - the JSON value; undefined when the record has no id key
|
|
541
|
+
* @param element - the element name for the issue
|
|
542
|
+
* @returns the id, or null when the value was reported and the element must be skipped
|
|
543
|
+
*/
|
|
544
|
+
coerceId(raw: unknown, element: string): NodeId | null {
|
|
545
|
+
if (raw === undefined) {
|
|
546
|
+
this.report.error("missing-value", JSON_ISSUE.MISSING_ID, `${element} has no id`, { element });
|
|
547
|
+
return null;
|
|
548
|
+
}
|
|
549
|
+
if ((typeof raw === "boolean" || raw === null) && this.options.ids !== "string") {
|
|
550
|
+
const kind = raw === null ? "null" : "boolean";
|
|
551
|
+
this.report.error(
|
|
552
|
+
"unsupported",
|
|
553
|
+
JSON_ISSUE.UNSUPPORTED_ID,
|
|
554
|
+
`${element}: a JSON ${kind} is not a node id (pass ids: "string" to coerce it)`,
|
|
555
|
+
{ element },
|
|
556
|
+
);
|
|
557
|
+
return null;
|
|
558
|
+
}
|
|
559
|
+
let id: NodeId;
|
|
560
|
+
try {
|
|
561
|
+
id = this.ids.value(this.options.ids === "string" && typeof raw === "number" ? String(raw) : raw);
|
|
562
|
+
} catch (err) {
|
|
563
|
+
this.report.recordError(err, { element });
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
const merge = this.ids.lastMerge;
|
|
567
|
+
if (merge !== null) {
|
|
568
|
+
this.report.warning(
|
|
569
|
+
"coercion",
|
|
570
|
+
JSON_ISSUE.ID_MERGED,
|
|
571
|
+
`id text ${JSON.stringify(merge.text)} merged with ${JSON.stringify(merge.previousText)} as ${merge.id}`,
|
|
572
|
+
{ element },
|
|
573
|
+
);
|
|
574
|
+
}
|
|
575
|
+
return id;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
/**
|
|
579
|
+
* Coerce an id that must be valid for the caller to proceed (hyperedge members): the rejections
|
|
580
|
+
* of coerceId() are thrown instead of recorded.
|
|
581
|
+
* @param raw - the JSON value
|
|
582
|
+
* @param element - the element name
|
|
583
|
+
* @returns the id
|
|
584
|
+
*/
|
|
585
|
+
requireId(raw: unknown, element: string): NodeId {
|
|
586
|
+
if ((typeof raw === "boolean" || raw === null) && this.options.ids !== "string") {
|
|
587
|
+
const kind = raw === null ? "null" : "boolean";
|
|
588
|
+
throw new GraphFormatError("E_INVALID_ID", `${element}: a JSON ${kind} is not a node id`, {
|
|
589
|
+
reason: "unsupported id",
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
return this.ids.value(this.options.ids === "string" && typeof raw === "number" ? String(raw) : raw);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/**
|
|
596
|
+
* Add a node, counting it or recording the failure.
|
|
597
|
+
* @param id - the node id
|
|
598
|
+
* @param element - the element name
|
|
599
|
+
* @returns the node index, or -1 when the sink refused the node
|
|
600
|
+
*/
|
|
601
|
+
pushNode(id: NodeId, element: string): number {
|
|
602
|
+
this.checkAbort();
|
|
603
|
+
const existing = this.sink.indexOf(id);
|
|
604
|
+
if (existing !== INVALID_INDEX) {
|
|
605
|
+
this.report.warning(
|
|
606
|
+
"merged",
|
|
607
|
+
JSON_ISSUE.DUPLICATE_NODE,
|
|
608
|
+
`${element}: node ${JSON.stringify(id)} already exists; its attributes are merged (the later values win)`,
|
|
609
|
+
{ element },
|
|
610
|
+
);
|
|
611
|
+
return existing;
|
|
612
|
+
}
|
|
613
|
+
let index: number;
|
|
614
|
+
try {
|
|
615
|
+
index = this.sink.addNode(id);
|
|
616
|
+
} catch (err) {
|
|
617
|
+
this.skip(err, "node", element);
|
|
618
|
+
return -1;
|
|
619
|
+
}
|
|
620
|
+
this.report.counts.nodes++;
|
|
621
|
+
return index;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Push one edge through the direction resolver, counting every logical edge the sink gained
|
|
626
|
+
* (both halves of an expanded edge, and the mirrors of an in-place expansion).
|
|
627
|
+
* @param source - the source id
|
|
628
|
+
* @param target - the target id
|
|
629
|
+
* @param kind - the edge's direction in the file
|
|
630
|
+
* @param weight - the weight, or undefined
|
|
631
|
+
* @param element - the element name for issues
|
|
632
|
+
* @returns the primary edge index
|
|
633
|
+
*/
|
|
634
|
+
pushEdge(source: NodeId, target: NodeId, kind: EdgeKind, weight: number | undefined, element: string): number {
|
|
635
|
+
this.checkAbort();
|
|
636
|
+
const before = this.sink.edgeCount;
|
|
637
|
+
// endpoints the sink creates (addMissingNodes) count as nodes too
|
|
638
|
+
let created = this.sink.indexOf(source) === INVALID_INDEX ? 1 : 0;
|
|
639
|
+
if (source !== target && this.sink.indexOf(target) === INVALID_INDEX) {
|
|
640
|
+
created++;
|
|
641
|
+
}
|
|
642
|
+
const edge = this.direction.addEdge(source, target, kind, weight, { element });
|
|
643
|
+
this.report.counts.edges += this.sink.edgeCount - before;
|
|
644
|
+
this.report.counts.nodes += created;
|
|
645
|
+
return edge;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/**
|
|
649
|
+
* Check the cancellation signal every ABORT_CHECK_INTERVAL pushed elements, so an abort raised
|
|
650
|
+
* while the whole in-memory document is being walked rejects promptly.
|
|
651
|
+
*/
|
|
652
|
+
checkAbort(): void {
|
|
653
|
+
if (++this.elementsSinceCheck >= ABORT_CHECK_INTERVAL) {
|
|
654
|
+
this.elementsSinceCheck = 0;
|
|
655
|
+
throwIfAborted(this.options.signal);
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/**
|
|
660
|
+
* Record a per-element failure and count the skipped element.
|
|
661
|
+
* @param err - the thrown value
|
|
662
|
+
* @param domain - which counter to bump
|
|
663
|
+
* @param element - the element name
|
|
664
|
+
*/
|
|
665
|
+
skip(err: unknown, domain: "node" | "edge", element: string): void {
|
|
666
|
+
this.report.recordError(err, { element });
|
|
667
|
+
if (domain === "node") {
|
|
668
|
+
this.report.counts.skippedNodes++;
|
|
669
|
+
} else {
|
|
670
|
+
this.report.counts.skippedEdges++;
|
|
671
|
+
}
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Report an element that is not an object and count it as skipped.
|
|
676
|
+
* @param domain - node or edge
|
|
677
|
+
* @param element - the element name
|
|
678
|
+
* @param what - what was expected
|
|
679
|
+
*/
|
|
680
|
+
badElement(domain: "node" | "edge", element: string, what = "an object"): void {
|
|
681
|
+
this.report.error("validation-error", JSON_ISSUE.BAD_ELEMENT, `${element} is not ${what}`, { element });
|
|
682
|
+
this.countSkipped(domain);
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Count a skipped element whose issue was already recorded.
|
|
687
|
+
* @param domain - node or edge
|
|
688
|
+
*/
|
|
689
|
+
countSkipped(domain: "node" | "edge"): void {
|
|
690
|
+
if (domain === "node") {
|
|
691
|
+
this.report.counts.skippedNodes++;
|
|
692
|
+
} else {
|
|
693
|
+
this.report.counts.skippedEdges++;
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Report an edge record without an endpoint and count it as skipped.
|
|
699
|
+
* @param element - the element name
|
|
700
|
+
* @param field - the missing field
|
|
701
|
+
*/
|
|
702
|
+
missingEndpoint(element: string, field: string): void {
|
|
703
|
+
this.report.error("missing-value", JSON_ISSUE.MISSING_ENDPOINT, `${element} has no ${field}`, { element });
|
|
704
|
+
this.report.counts.skippedEdges++;
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
/**
|
|
708
|
+
* Record the shape metadata, the source format and further metadata fields in one setMeta()
|
|
709
|
+
* call (the sink replaces `extra` as a whole).
|
|
710
|
+
* @param shape - the shape record
|
|
711
|
+
* @param patch - further metadata fields
|
|
712
|
+
*/
|
|
713
|
+
setMeta(shape: JsonShapeMeta, patch: GraphMetaPatch = {}): void {
|
|
714
|
+
const extra: Record<string, unknown> = {};
|
|
715
|
+
for (const [key, value] of Object.entries(shape)) {
|
|
716
|
+
if (value !== undefined) {
|
|
717
|
+
extra[key] = value;
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
this.sink.setMeta({ sourceFormat: "json", ...patch, extra: { [META_KEY]: extra } });
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
/**
|
|
724
|
+
* Record which source field the weight came from, so the exporter writes it back under the same
|
|
725
|
+
* key (design section 3.7, `meta.weightOrigin`).
|
|
726
|
+
* @returns the metadata patch, empty for an unweighted import
|
|
727
|
+
*/
|
|
728
|
+
weightOriginPatch(): GraphMetaPatch {
|
|
729
|
+
const { weightFrom } = this.options;
|
|
730
|
+
if (weightFrom === null) {
|
|
731
|
+
return {};
|
|
732
|
+
}
|
|
733
|
+
return { weightOrigin: { format: "json", id: weightFrom, title: null, type: null, namespace: null } };
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
/**
|
|
737
|
+
* Write the graph-level attributes of a dict (NetworkX `graph`, JGF `metadata`, graphology
|
|
738
|
+
* `attributes`, Cytoscape `data`), one column per key.
|
|
739
|
+
* @param dict - the dict, or anything else (then reported)
|
|
740
|
+
* @param what - the dict's name for the issue
|
|
741
|
+
*/
|
|
742
|
+
writeGraphDict(dict: unknown, what: string): void {
|
|
743
|
+
if (dict === undefined || dict === null) {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
if (!isJsonObject(dict)) {
|
|
747
|
+
this.report.warning("validation-error", JSON_ISSUE.BAD_FLAG, `${what} is not an object; ignored`, {
|
|
748
|
+
element: what,
|
|
749
|
+
});
|
|
750
|
+
return;
|
|
751
|
+
}
|
|
752
|
+
for (const key of Object.keys(dict)) {
|
|
753
|
+
const value = dict[key];
|
|
754
|
+
if (value === undefined || value === null) {
|
|
755
|
+
continue;
|
|
756
|
+
}
|
|
757
|
+
try {
|
|
758
|
+
this.sink.setGraphValue(key, value);
|
|
759
|
+
} catch (err) {
|
|
760
|
+
this.report.recordError(err, { element: `${what}.${key}` });
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Read the weight field of an edge record.
|
|
767
|
+
* @param record - the record holding the attributes
|
|
768
|
+
* @returns the weight, or undefined when absent or null; E_INVALID_WEIGHT otherwise
|
|
769
|
+
*/
|
|
770
|
+
weightOf(record: JsonRecord): number | undefined {
|
|
771
|
+
const { weightFrom } = this.options;
|
|
772
|
+
if (weightFrom === null || !hasKey(record, weightFrom)) {
|
|
773
|
+
return undefined;
|
|
774
|
+
}
|
|
775
|
+
return weightFromValue(record[weightFrom]);
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Write the attributes of a nested dict plus the element-level keys the dialect does not
|
|
780
|
+
* define (kept with the `#element` suffix).
|
|
781
|
+
* @param writer - the table writer
|
|
782
|
+
* @param row - the row
|
|
783
|
+
* @param record - the element record
|
|
784
|
+
* @param dict - the nested attribute dict
|
|
785
|
+
* @param structural - the element keys that are not attributes
|
|
786
|
+
* @param weightFrom - the weight key to skip in the dict, or null
|
|
787
|
+
*/
|
|
788
|
+
writeNested(
|
|
789
|
+
writer: AttributeWriter,
|
|
790
|
+
row: number,
|
|
791
|
+
record: JsonRecord,
|
|
792
|
+
dict: JsonRecord,
|
|
793
|
+
structural: ReadonlySet<string>,
|
|
794
|
+
weightFrom: string | null,
|
|
795
|
+
): void {
|
|
796
|
+
for (const key of Object.keys(dict)) {
|
|
797
|
+
if (key !== weightFrom) {
|
|
798
|
+
writer.write(row, key, dict[key], SUFFIX.data);
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
for (const key of Object.keys(record)) {
|
|
802
|
+
if (!structural.has(key)) {
|
|
803
|
+
writer.write(row, `${key}${SUFFIX.element}`, record[key], SUFFIX.data);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
/**
|
|
809
|
+
* Write a spec-typed string field (JGF label / relation), reporting a value of another type.
|
|
810
|
+
* @param writer - the table writer
|
|
811
|
+
* @param column - the declared column, or INVALID_INDEX when the file has no such field
|
|
812
|
+
* @param row - the row
|
|
813
|
+
* @param value - the value
|
|
814
|
+
* @param field - the field name
|
|
815
|
+
* @param element - the element name
|
|
816
|
+
*/
|
|
817
|
+
writeStringField(
|
|
818
|
+
writer: AttributeWriter,
|
|
819
|
+
column: ColumnHandle,
|
|
820
|
+
row: number,
|
|
821
|
+
value: unknown,
|
|
822
|
+
field: string,
|
|
823
|
+
element: string,
|
|
824
|
+
): void {
|
|
825
|
+
if (column === INVALID_INDEX || value === undefined || value === null) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (typeof value !== "string") {
|
|
829
|
+
this.report.error(
|
|
830
|
+
"validation-error",
|
|
831
|
+
JSON_ISSUE.BAD_VALUE,
|
|
832
|
+
`${element}: ${field} must be a string, found ${describe(value)}`,
|
|
833
|
+
{ element },
|
|
834
|
+
);
|
|
835
|
+
return;
|
|
836
|
+
}
|
|
837
|
+
writer.set(column, row, value);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
/**
|
|
841
|
+
* Declare an edge id column with role "id" from a scan of the file's edge ids: f64 when every
|
|
842
|
+
* id is a number, string otherwise (numbers are then stored as their text and reported once).
|
|
843
|
+
* @param edges - the edge records
|
|
844
|
+
* @param read - how to read an edge's raw id
|
|
845
|
+
* @param name - the column name
|
|
846
|
+
* @param unique - whether uniqueness is enforced at freeze
|
|
847
|
+
* @returns the column, or null when no edge has an id
|
|
848
|
+
*/
|
|
849
|
+
declareEdgeIds(
|
|
850
|
+
edges: readonly unknown[],
|
|
851
|
+
read: (edge: JsonRecord) => unknown,
|
|
852
|
+
name: string,
|
|
853
|
+
unique: boolean,
|
|
854
|
+
): EdgeIdColumn | null {
|
|
855
|
+
let numbers = 0;
|
|
856
|
+
let strings = 0;
|
|
857
|
+
for (const edge of edges) {
|
|
858
|
+
if (!isJsonObject(edge)) {
|
|
859
|
+
continue;
|
|
860
|
+
}
|
|
861
|
+
const raw = read(edge);
|
|
862
|
+
if (typeof raw === "number") {
|
|
863
|
+
numbers++;
|
|
864
|
+
} else if (typeof raw === "string") {
|
|
865
|
+
strings++;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
if (numbers + strings === 0) {
|
|
869
|
+
return null;
|
|
870
|
+
}
|
|
871
|
+
const dtype = strings === 0 ? "f64" : "string";
|
|
872
|
+
const columnName = uniqueColumnName(name, "id", (candidate) => this.edges.taken(candidate));
|
|
873
|
+
const handle = this.edges.declare({ name: columnName, dtype, role: "id", nullable: true, unique });
|
|
874
|
+
const stringify = strings > 0 && numbers > 0;
|
|
875
|
+
const seen = unique ? new Set<string>() : null;
|
|
876
|
+
if (stringify) {
|
|
877
|
+
this.report.warning(
|
|
878
|
+
"coercion",
|
|
879
|
+
JSON_ISSUE.EDGE_ID_STRINGIFIED,
|
|
880
|
+
`edge ids mix numbers and strings; ${numbers} numeric id(s) stored as text`,
|
|
881
|
+
{ element: columnName },
|
|
882
|
+
);
|
|
883
|
+
}
|
|
884
|
+
return { handle, stringify, seen };
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
/**
|
|
888
|
+
* The value an edge id column stores for a raw id, checked BEFORE the edge is pushed so a bad
|
|
889
|
+
* id skips the edge without touching the sink (design section 11.1).
|
|
890
|
+
* @param column - the column, or null when the file has no edge ids
|
|
891
|
+
* @param raw - the raw id value
|
|
892
|
+
* @returns the value to store, or null when there is nothing to store
|
|
893
|
+
*/
|
|
894
|
+
edgeIdValue(column: EdgeIdColumn | null, raw: unknown): string | number | null {
|
|
895
|
+
if (column === null || raw === undefined || raw === null) {
|
|
896
|
+
return null;
|
|
897
|
+
}
|
|
898
|
+
let value: string | number;
|
|
899
|
+
if (typeof raw === "number") {
|
|
900
|
+
value = column.stringify ? String(raw) : raw;
|
|
901
|
+
} else if (typeof raw === "string") {
|
|
902
|
+
value = raw;
|
|
903
|
+
} else {
|
|
904
|
+
throw new GraphFormatError(
|
|
905
|
+
"E_COLUMN_TYPE",
|
|
906
|
+
`an edge id must be a string or a number, found ${describe(raw)}`,
|
|
907
|
+
{ found: typeof raw },
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
if (column.seen !== null) {
|
|
911
|
+
const text = String(value);
|
|
912
|
+
if (column.seen.has(text)) {
|
|
913
|
+
throw new GraphFormatError(
|
|
914
|
+
JSON_ISSUE.DUPLICATE_EDGE_ID,
|
|
915
|
+
`edge id ${JSON.stringify(value)} is declared more than once; the edge is skipped`,
|
|
916
|
+
{ id: value },
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
column.seen.add(text);
|
|
920
|
+
}
|
|
921
|
+
return value;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
/**
|
|
925
|
+
* Write an edge id value from edgeIdValue().
|
|
926
|
+
* @param column - the column, or null
|
|
927
|
+
* @param edge - the edge index
|
|
928
|
+
* @param value - the value, or null for none
|
|
929
|
+
*/
|
|
930
|
+
setEdgeId(column: EdgeIdColumn | null, edge: number, value: string | number | null): void {
|
|
931
|
+
if (column !== null && value !== null) {
|
|
932
|
+
this.edges.set(column.handle, edge, value);
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
// ============================================================ document level
|
|
938
|
+
|
|
939
|
+
/**
|
|
940
|
+
* Parse the whole text; syntax errors and empty input abort the import.
|
|
941
|
+
* @param text - the decoded text
|
|
942
|
+
* @param report - the report
|
|
943
|
+
* @returns the parsed value
|
|
944
|
+
*/
|
|
945
|
+
function parseDocument(text: string, report: ImportReportBuilder): unknown {
|
|
946
|
+
if (text.trim().length === 0) {
|
|
947
|
+
report.fail(JSON_ISSUE.EMPTY_INPUT, "the input is empty");
|
|
948
|
+
}
|
|
949
|
+
try {
|
|
950
|
+
return JSON.parse(text) as unknown;
|
|
951
|
+
} catch (err) {
|
|
952
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
953
|
+
return report.fail(JSON_ISSUE.SYNTAX, `invalid JSON: ${message}`);
|
|
954
|
+
}
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* The dialect to read: the forced one, else the shape rule of sniffJsonDialect(); a document
|
|
959
|
+
* that matches no dialect is a fatal E_JSON_DIALECT.
|
|
960
|
+
* @param root - the parsed document
|
|
961
|
+
* @param forced - the caller's dialect option
|
|
962
|
+
* @param report - the report the failure is recorded in
|
|
963
|
+
* @returns the dialect
|
|
964
|
+
*/
|
|
965
|
+
function detectDialect(root: unknown, forced: JsonDialect | "auto", report: ImportReportBuilder): JsonDialect {
|
|
966
|
+
if (forced !== "auto") {
|
|
967
|
+
return forced;
|
|
968
|
+
}
|
|
969
|
+
const dialect = sniffJsonDialect(root);
|
|
970
|
+
if (dialect !== null) {
|
|
971
|
+
return dialect;
|
|
972
|
+
}
|
|
973
|
+
if (Array.isArray(root)) {
|
|
974
|
+
return report.fail(
|
|
975
|
+
JSON_ISSUE.DIALECT,
|
|
976
|
+
"a top-level array is only read as Cytoscape elements (objects with a data record)",
|
|
977
|
+
);
|
|
978
|
+
}
|
|
979
|
+
if (!isJsonObject(root)) {
|
|
980
|
+
return report.fail(JSON_ISSUE.DIALECT, `the document is a JSON ${describe(root)}, not a graph object`);
|
|
981
|
+
}
|
|
982
|
+
return report.fail(
|
|
983
|
+
JSON_ISSUE.DIALECT,
|
|
984
|
+
"no known dialect: expected nodes / links / edges (node-link), elements (Cytoscape) or graph (JGF)",
|
|
985
|
+
);
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
/**
|
|
989
|
+
* A section that must be an array: fail when it is something else.
|
|
990
|
+
* @param value - the section
|
|
991
|
+
* @param what - its name
|
|
992
|
+
* @param report - the report
|
|
993
|
+
* @returns the array, or null when absent
|
|
994
|
+
*/
|
|
995
|
+
function arraySection(value: unknown, what: string, report: ImportReportBuilder): readonly unknown[] | null {
|
|
996
|
+
if (value === undefined || value === null) {
|
|
997
|
+
return null;
|
|
998
|
+
}
|
|
999
|
+
if (!Array.isArray(value)) {
|
|
1000
|
+
return report.fail(JSON_ISSUE.SHAPE, `${what} must be an array, found ${describe(value)}`, { element: what });
|
|
1001
|
+
}
|
|
1002
|
+
return value;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
/**
|
|
1006
|
+
* A boolean flag with a default and a warning when it has another type.
|
|
1007
|
+
* @param value - the flag value
|
|
1008
|
+
* @param what - its name
|
|
1009
|
+
* @param fallback - the default
|
|
1010
|
+
* @param report - the report
|
|
1011
|
+
* @returns the flag
|
|
1012
|
+
*/
|
|
1013
|
+
function flagOf(value: unknown, what: string, fallback: boolean, report: ImportReportBuilder): boolean {
|
|
1014
|
+
if (value === undefined || value === null) {
|
|
1015
|
+
return fallback;
|
|
1016
|
+
}
|
|
1017
|
+
if (typeof value === "boolean") {
|
|
1018
|
+
return value;
|
|
1019
|
+
}
|
|
1020
|
+
report.warning(
|
|
1021
|
+
"validation-error",
|
|
1022
|
+
JSON_ISSUE.BAD_FLAG,
|
|
1023
|
+
`${what} is ${describe(value)}, not a boolean; ${fallback} assumed`,
|
|
1024
|
+
{ element: what },
|
|
1025
|
+
);
|
|
1026
|
+
return fallback;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
/**
|
|
1030
|
+
* The endpoint key and value of an edge record: the explicit key when given, else the first of the
|
|
1031
|
+
* default keys the record has.
|
|
1032
|
+
* @param record - the edge record
|
|
1033
|
+
* @param explicit - the caller's key, or null
|
|
1034
|
+
* @param defaults - the default keys
|
|
1035
|
+
* @returns the key used (null when none) and its value
|
|
1036
|
+
*/
|
|
1037
|
+
function endpointOf(
|
|
1038
|
+
record: JsonRecord,
|
|
1039
|
+
explicit: string | null,
|
|
1040
|
+
defaults: readonly string[],
|
|
1041
|
+
): { readonly key: string | null; readonly value: unknown } {
|
|
1042
|
+
if (explicit !== null) {
|
|
1043
|
+
return { key: hasKey(record, explicit) ? explicit : null, value: record[explicit] };
|
|
1044
|
+
}
|
|
1045
|
+
for (const key of defaults) {
|
|
1046
|
+
if (hasKey(record, key)) {
|
|
1047
|
+
return { key, value: record[key] };
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
return { key: null, value: undefined };
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Whether any object of an array has a key with a non-null value.
|
|
1055
|
+
* @param items - the array
|
|
1056
|
+
* @param key - the key
|
|
1057
|
+
* @returns true when some element carries the field
|
|
1058
|
+
*/
|
|
1059
|
+
function anyHas(items: readonly unknown[], key: string): boolean {
|
|
1060
|
+
return items.some((item) => isJsonObject(item) && item[key] !== undefined && item[key] !== null);
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Whether a value is a non-negative integer below a bound.
|
|
1065
|
+
* @param value - the value
|
|
1066
|
+
* @param bound - the exclusive bound
|
|
1067
|
+
* @returns true for an index
|
|
1068
|
+
*/
|
|
1069
|
+
function isIndexBelow(value: unknown, bound: number): boolean {
|
|
1070
|
+
return typeof value === "number" && Number.isInteger(value) && value >= 0 && value < bound;
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
// ============================================================ node-link / d3
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* Read a node-link or d3 document.
|
|
1077
|
+
* @param ctx - the context
|
|
1078
|
+
* @param root - the document
|
|
1079
|
+
* @param dialect - "node-link" or "d3"
|
|
1080
|
+
*/
|
|
1081
|
+
function importNodeLink(ctx: ImportContext, root: JsonRecord, dialect: JsonDialect): void {
|
|
1082
|
+
const { report, json } = ctx;
|
|
1083
|
+
let { edgesKey } = json;
|
|
1084
|
+
if (edgesKey === null) {
|
|
1085
|
+
edgesKey = hasKey(root, "edges") || !hasKey(root, "links") ? "edges" : "links";
|
|
1086
|
+
}
|
|
1087
|
+
const nodes = arraySection(root.nodes, "nodes", report);
|
|
1088
|
+
const edges = arraySection(root[edgesKey], edgesKey, report);
|
|
1089
|
+
if (nodes === null) {
|
|
1090
|
+
report.error(
|
|
1091
|
+
"missing-value",
|
|
1092
|
+
JSON_ISSUE.MISSING_SECTION,
|
|
1093
|
+
"the document has no nodes array; nodes come from the edges",
|
|
1094
|
+
{ element: "nodes" },
|
|
1095
|
+
);
|
|
1096
|
+
}
|
|
1097
|
+
if (edges === null) {
|
|
1098
|
+
report.error(
|
|
1099
|
+
"missing-value",
|
|
1100
|
+
JSON_ISSUE.MISSING_SECTION,
|
|
1101
|
+
`the document has no ${edgesKey} array; the graph has no edges`,
|
|
1102
|
+
{ element: edgesKey },
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
const directed = flagOf(root.directed, "directed", ctx.defaultDirected(dialect), report);
|
|
1106
|
+
const multigraph = hasKey(root, "multigraph") ? flagOf(root.multigraph, "multigraph", false, report) : null;
|
|
1107
|
+
ctx.setHeader(directed);
|
|
1108
|
+
ctx.writeGraphDict(root.graph, "graph");
|
|
1109
|
+
|
|
1110
|
+
const nodeList = nodes ?? [];
|
|
1111
|
+
const edgeList = edges ?? [];
|
|
1112
|
+
ctx.sink.reserve(nodeList.length, edgeList.length);
|
|
1113
|
+
|
|
1114
|
+
// the id key: the caller's, else "id" when any node has it, else "name" (d3); nodes without any
|
|
1115
|
+
// id key are positional (nodeIdFrom "index", or a d3 v3 file whose nodes carry no id at all)
|
|
1116
|
+
let { nodeIdKey } = json;
|
|
1117
|
+
let positional = ctx.options.nodeIdFrom === "index";
|
|
1118
|
+
if (!positional && nodeIdKey === null) {
|
|
1119
|
+
const candidates = ctx.options.nodeIdFrom === "label" ? ["label", "name", "id"] : ["id", "name"];
|
|
1120
|
+
nodeIdKey = candidates.find((key) => anyHas(nodeList, key)) ?? null;
|
|
1121
|
+
if (ctx.options.nodeIdFrom === "label" && nodeIdKey === "id") {
|
|
1122
|
+
report.warning(
|
|
1123
|
+
"unsupported",
|
|
1124
|
+
JSON_ISSUE.OPTION_IGNORED,
|
|
1125
|
+
'nodeIdFrom "label": no node has a label or name key; ids are read from "id"',
|
|
1126
|
+
{ element: "nodeIdFrom" },
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
if (nodeIdKey === null) {
|
|
1130
|
+
if (nodeList.length > 0) {
|
|
1131
|
+
positional = true;
|
|
1132
|
+
report.warning(
|
|
1133
|
+
"missing-value",
|
|
1134
|
+
JSON_ISSUE.POSITIONAL_NODES,
|
|
1135
|
+
"no node has an id or name key; array positions are the node ids",
|
|
1136
|
+
{ element: "nodes" },
|
|
1137
|
+
);
|
|
1138
|
+
} else {
|
|
1139
|
+
nodeIdKey = "id";
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
if (positional) {
|
|
1144
|
+
nodeIdKey = null;
|
|
1145
|
+
}
|
|
1146
|
+
let indexLinks: boolean;
|
|
1147
|
+
if (positional) {
|
|
1148
|
+
indexLinks = true;
|
|
1149
|
+
} else if (json.indexLinks === "auto") {
|
|
1150
|
+
indexLinks = nodeIdKey !== null && looksIndexLinked(nodeList, edgeList, nodeIdKey, json);
|
|
1151
|
+
} else {
|
|
1152
|
+
({ indexLinks } = json);
|
|
1153
|
+
}
|
|
1154
|
+
const positionIds: (NodeId | null)[] | null = indexLinks ? [] : null;
|
|
1155
|
+
|
|
1156
|
+
for (let i = 0; i < nodeList.length; i++) {
|
|
1157
|
+
const element = `nodes[${i}]`;
|
|
1158
|
+
const record = nodeList[i];
|
|
1159
|
+
let pushed: NodeId | null = null;
|
|
1160
|
+
if (!isJsonObject(record)) {
|
|
1161
|
+
ctx.badElement("node", element);
|
|
1162
|
+
} else {
|
|
1163
|
+
let id: NodeId | null;
|
|
1164
|
+
if (nodeIdKey === null) {
|
|
1165
|
+
id = i;
|
|
1166
|
+
} else {
|
|
1167
|
+
id = ctx.coerceId(hasKey(record, nodeIdKey) ? record[nodeIdKey] : undefined, element);
|
|
1168
|
+
}
|
|
1169
|
+
if (id === null) {
|
|
1170
|
+
ctx.countSkipped("node");
|
|
1171
|
+
} else {
|
|
1172
|
+
const index = ctx.pushNode(id, element);
|
|
1173
|
+
if (index >= 0) {
|
|
1174
|
+
pushed = id;
|
|
1175
|
+
writeFlat(ctx, ctx.nodes, index, record, id, (key) => key !== nodeIdKey);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
positionIds?.push(pushed);
|
|
1180
|
+
}
|
|
1181
|
+
throwIfAborted(ctx.options.signal);
|
|
1182
|
+
const endpointKeys = importNodeLinkEdges(ctx, edgeList, edgesKey, positionIds);
|
|
1183
|
+
ctx.setMeta(
|
|
1184
|
+
{
|
|
1185
|
+
dialect,
|
|
1186
|
+
edgesKey,
|
|
1187
|
+
nodeIdKey,
|
|
1188
|
+
indexLinks,
|
|
1189
|
+
sourceKey: endpointKeys.source ?? undefined,
|
|
1190
|
+
targetKey: endpointKeys.target ?? undefined,
|
|
1191
|
+
},
|
|
1192
|
+
{ declaredMultigraph: multigraph, ...ctx.weightOriginPatch() },
|
|
1193
|
+
);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
/**
|
|
1197
|
+
* Read the node-link / d3 edge records: endpoints by id or array position, the weight, the other
|
|
1198
|
+
* keys as attributes.
|
|
1199
|
+
* @param ctx - the context
|
|
1200
|
+
* @param edgeList - the edge records
|
|
1201
|
+
* @param edgesKey - the top-level key they came from, for issues
|
|
1202
|
+
* @param positionIds - the ids by array position under index links, or null
|
|
1203
|
+
* @returns the source and target keys the first well-formed edge used (null when none did)
|
|
1204
|
+
*/
|
|
1205
|
+
function importNodeLinkEdges(
|
|
1206
|
+
ctx: ImportContext,
|
|
1207
|
+
edgeList: readonly unknown[],
|
|
1208
|
+
edgesKey: string,
|
|
1209
|
+
positionIds: readonly (NodeId | null)[] | null,
|
|
1210
|
+
): { source: string | null; target: string | null } {
|
|
1211
|
+
const { json } = ctx;
|
|
1212
|
+
const kind = ctx.uniformKind();
|
|
1213
|
+
let sourceKey: string | null = null;
|
|
1214
|
+
let targetKey: string | null = null;
|
|
1215
|
+
for (let i = 0; i < edgeList.length; i++) {
|
|
1216
|
+
const element = `${edgesKey}[${i}]`;
|
|
1217
|
+
const record = edgeList[i];
|
|
1218
|
+
if (!isJsonObject(record)) {
|
|
1219
|
+
ctx.badElement("edge", element);
|
|
1220
|
+
continue;
|
|
1221
|
+
}
|
|
1222
|
+
const source = endpointOf(record, json.sourceKey, NODE_LINK_SOURCE_KEYS);
|
|
1223
|
+
const target = endpointOf(record, json.targetKey, NODE_LINK_TARGET_KEYS);
|
|
1224
|
+
if (source.key === null || target.key === null) {
|
|
1225
|
+
ctx.missingEndpoint(element, source.key === null ? "source" : "target");
|
|
1226
|
+
continue;
|
|
1227
|
+
}
|
|
1228
|
+
sourceKey ??= source.key;
|
|
1229
|
+
targetKey ??= target.key;
|
|
1230
|
+
try {
|
|
1231
|
+
const u = resolveEndpoint(ctx, source.value, positionIds, element, "source");
|
|
1232
|
+
const v = resolveEndpoint(ctx, target.value, positionIds, element, "target");
|
|
1233
|
+
if (u === null || v === null) {
|
|
1234
|
+
ctx.countSkipped("edge");
|
|
1235
|
+
continue;
|
|
1236
|
+
}
|
|
1237
|
+
const weight = ctx.weightOf(record);
|
|
1238
|
+
const edge = ctx.pushEdge(u, v, kind, weight, element);
|
|
1239
|
+
const { weightFrom } = ctx.options;
|
|
1240
|
+
for (const key of Object.keys(record)) {
|
|
1241
|
+
if (key !== source.key && key !== target.key && key !== weightFrom) {
|
|
1242
|
+
ctx.edges.write(edge, key, record[key], SUFFIX.data);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
} catch (err) {
|
|
1246
|
+
ctx.skip(err, "edge", element);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
return { source: sourceKey, target: targetKey };
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
/**
|
|
1253
|
+
* Write the flat attributes of a node record (every own key the filter keeps).
|
|
1254
|
+
* @param ctx - the context
|
|
1255
|
+
* @param writer - the node writer
|
|
1256
|
+
* @param index - the node index
|
|
1257
|
+
* @param record - the record
|
|
1258
|
+
* @param id - the node id, for issues
|
|
1259
|
+
* @param keep - which keys are attributes
|
|
1260
|
+
*/
|
|
1261
|
+
function writeFlat(
|
|
1262
|
+
ctx: ImportContext,
|
|
1263
|
+
writer: AttributeWriter,
|
|
1264
|
+
index: number,
|
|
1265
|
+
record: JsonRecord,
|
|
1266
|
+
id: NodeId,
|
|
1267
|
+
keep: (key: string) => boolean,
|
|
1268
|
+
): void {
|
|
1269
|
+
try {
|
|
1270
|
+
for (const key of Object.keys(record)) {
|
|
1271
|
+
if (keep(key)) {
|
|
1272
|
+
writer.write(index, key, record[key], SUFFIX.data);
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
} catch (err) {
|
|
1276
|
+
ctx.report.recordError(err, { element: String(id) });
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
/**
|
|
1281
|
+
* The d3 index-link heuristic: endpoints are array positions when every endpoint is a
|
|
1282
|
+
* non-negative integer and no node id is a number (a numeric id would make the endpoints ids;
|
|
1283
|
+
* research note 07: d3 links reference nodes by array index and are never coerced to ids). An
|
|
1284
|
+
* index at or beyond the node count is then E_BAD_INDEX, never a new numeric node.
|
|
1285
|
+
* @param nodes - the node records
|
|
1286
|
+
* @param edges - the edge records
|
|
1287
|
+
* @param nodeIdKey - the node id key
|
|
1288
|
+
* @param json - the format options (endpoint keys)
|
|
1289
|
+
* @returns true when endpoints are indices
|
|
1290
|
+
*/
|
|
1291
|
+
function looksIndexLinked(
|
|
1292
|
+
nodes: readonly unknown[],
|
|
1293
|
+
edges: readonly unknown[],
|
|
1294
|
+
nodeIdKey: string,
|
|
1295
|
+
json: ResolvedJsonOptions,
|
|
1296
|
+
): boolean {
|
|
1297
|
+
if (edges.length === 0 || nodes.length === 0) {
|
|
1298
|
+
return false;
|
|
1299
|
+
}
|
|
1300
|
+
for (const node of nodes) {
|
|
1301
|
+
if (isJsonObject(node) && typeof node[nodeIdKey] === "number") {
|
|
1302
|
+
return false;
|
|
1303
|
+
}
|
|
1304
|
+
}
|
|
1305
|
+
let seen = 0;
|
|
1306
|
+
for (const edge of edges) {
|
|
1307
|
+
if (!isJsonObject(edge)) {
|
|
1308
|
+
continue;
|
|
1309
|
+
}
|
|
1310
|
+
const s = endpointOf(edge, json.sourceKey, NODE_LINK_SOURCE_KEYS).value;
|
|
1311
|
+
const t = endpointOf(edge, json.targetKey, NODE_LINK_TARGET_KEYS).value;
|
|
1312
|
+
if (!isIndexBelow(s, Infinity) || !isIndexBelow(t, Infinity)) {
|
|
1313
|
+
return false;
|
|
1314
|
+
}
|
|
1315
|
+
seen++;
|
|
1316
|
+
}
|
|
1317
|
+
return seen > 0;
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
/**
|
|
1321
|
+
* Resolve one endpoint: a node id through the coercion rule, or an array position through the
|
|
1322
|
+
* ids pushed so far under index links.
|
|
1323
|
+
* @param ctx - the context
|
|
1324
|
+
* @param raw - the endpoint value
|
|
1325
|
+
* @param positionIds - the ids by array position under index links, or null
|
|
1326
|
+
* @param element - the edge element name
|
|
1327
|
+
* @param field - "source" or "target"
|
|
1328
|
+
* @returns the id, or null when reported
|
|
1329
|
+
*/
|
|
1330
|
+
function resolveEndpoint(
|
|
1331
|
+
ctx: ImportContext,
|
|
1332
|
+
raw: unknown,
|
|
1333
|
+
positionIds: readonly (NodeId | null)[] | null,
|
|
1334
|
+
element: string,
|
|
1335
|
+
field: string,
|
|
1336
|
+
): NodeId | null {
|
|
1337
|
+
if (positionIds === null) {
|
|
1338
|
+
return ctx.coerceId(raw, `${element}.${field}`);
|
|
1339
|
+
}
|
|
1340
|
+
if (!isIndexBelow(raw, positionIds.length)) {
|
|
1341
|
+
ctx.report.error(
|
|
1342
|
+
"validation-error",
|
|
1343
|
+
JSON_ISSUE.BAD_INDEX,
|
|
1344
|
+
`${element}: ${field} ${describe(raw)} is not a node index below ${positionIds.length}`,
|
|
1345
|
+
{ element },
|
|
1346
|
+
);
|
|
1347
|
+
return null;
|
|
1348
|
+
}
|
|
1349
|
+
const id = positionIds[raw as number];
|
|
1350
|
+
if (id === null) {
|
|
1351
|
+
ctx.report.error("missing-value", JSON_ISSUE.BAD_INDEX, `${element}: ${field} names a node that was skipped`, {
|
|
1352
|
+
element,
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
return id;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
// ============================================================ vis.js
|
|
1359
|
+
|
|
1360
|
+
/**
|
|
1361
|
+
* Read a vis.js document: `nodes` with `id`, `edges` with `from` / `to` and an optional `id`.
|
|
1362
|
+
* @param ctx - the context
|
|
1363
|
+
* @param root - the document
|
|
1364
|
+
*/
|
|
1365
|
+
function importVis(ctx: ImportContext, root: JsonRecord): void {
|
|
1366
|
+
const { report, json } = ctx;
|
|
1367
|
+
const nodes = arraySection(root.nodes, "nodes", report) ?? [];
|
|
1368
|
+
const edges = arraySection(root.edges, "edges", report) ?? [];
|
|
1369
|
+
ctx.setHeader(ctx.defaultDirected("vis"));
|
|
1370
|
+
ctx.sink.reserve(nodes.length, edges.length);
|
|
1371
|
+
const nodeIdKey = json.nodeIdKey ?? "id";
|
|
1372
|
+
ctx.reportNodeIdFrom("vis", `the ${JSON.stringify(nodeIdKey)} key`);
|
|
1373
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
1374
|
+
const element = `nodes[${i}]`;
|
|
1375
|
+
const record = nodes[i];
|
|
1376
|
+
if (!isJsonObject(record)) {
|
|
1377
|
+
ctx.badElement("node", element);
|
|
1378
|
+
continue;
|
|
1379
|
+
}
|
|
1380
|
+
const id = ctx.coerceId(hasKey(record, nodeIdKey) ? record[nodeIdKey] : undefined, element);
|
|
1381
|
+
if (id === null) {
|
|
1382
|
+
ctx.countSkipped("node");
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
const index = ctx.pushNode(id, element);
|
|
1386
|
+
if (index >= 0) {
|
|
1387
|
+
writeFlat(ctx, ctx.nodes, index, record, id, (key) => key !== nodeIdKey);
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
throwIfAborted(ctx.options.signal);
|
|
1391
|
+
const ids = ctx.declareEdgeIds(edges, (edge) => edge.id, "id", true);
|
|
1392
|
+
const kind = ctx.uniformKind();
|
|
1393
|
+
let sourceKey: string | null = null;
|
|
1394
|
+
let targetKey: string | null = null;
|
|
1395
|
+
for (let i = 0; i < edges.length; i++) {
|
|
1396
|
+
const element = `edges[${i}]`;
|
|
1397
|
+
const record = edges[i];
|
|
1398
|
+
if (!isJsonObject(record)) {
|
|
1399
|
+
ctx.badElement("edge", element);
|
|
1400
|
+
continue;
|
|
1401
|
+
}
|
|
1402
|
+
const source = endpointOf(record, json.sourceKey, VIS_SOURCE_KEYS);
|
|
1403
|
+
const target = endpointOf(record, json.targetKey, VIS_TARGET_KEYS);
|
|
1404
|
+
if (source.key === null || target.key === null) {
|
|
1405
|
+
ctx.missingEndpoint(element, source.key === null ? "from" : "to");
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
sourceKey ??= source.key;
|
|
1409
|
+
targetKey ??= target.key;
|
|
1410
|
+
try {
|
|
1411
|
+
const u = ctx.coerceId(source.value, `${element}.from`);
|
|
1412
|
+
const v = ctx.coerceId(target.value, `${element}.to`);
|
|
1413
|
+
if (u === null || v === null) {
|
|
1414
|
+
ctx.countSkipped("edge");
|
|
1415
|
+
continue;
|
|
1416
|
+
}
|
|
1417
|
+
const idValue = ctx.edgeIdValue(ids, record.id);
|
|
1418
|
+
const edge = ctx.pushEdge(u, v, kind, ctx.weightOf(record), element);
|
|
1419
|
+
ctx.setEdgeId(ids, edge, idValue);
|
|
1420
|
+
const { weightFrom } = ctx.options;
|
|
1421
|
+
for (const key of Object.keys(record)) {
|
|
1422
|
+
if (key !== source.key && key !== target.key && key !== "id" && key !== weightFrom) {
|
|
1423
|
+
ctx.edges.write(edge, key, record[key], SUFFIX.data);
|
|
1424
|
+
}
|
|
1425
|
+
}
|
|
1426
|
+
} catch (err) {
|
|
1427
|
+
ctx.skip(err, "edge", element);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
ctx.setMeta(
|
|
1431
|
+
{ dialect: "vis", nodeIdKey, sourceKey: sourceKey ?? undefined, targetKey: targetKey ?? undefined },
|
|
1432
|
+
ctx.weightOriginPatch(),
|
|
1433
|
+
);
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// ============================================================ graphology
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* Read a graphology serialisation: `options.type` decides the header direction ("mixed" or absent:
|
|
1440
|
+
* from the edges' `undirected` flags), `options.multi` the declared multigraph flag, node `key`
|
|
1441
|
+
* the id, `attributes` the columns, edge `key` the edge id.
|
|
1442
|
+
* @param ctx - the context
|
|
1443
|
+
* @param root - the document
|
|
1444
|
+
*/
|
|
1445
|
+
function importGraphology(ctx: ImportContext, root: JsonRecord): void {
|
|
1446
|
+
const { report } = ctx;
|
|
1447
|
+
const nodes = arraySection(root.nodes, "nodes", report) ?? [];
|
|
1448
|
+
const edges = arraySection(root.edges, "edges", report) ?? [];
|
|
1449
|
+
const options = isJsonObject(root.options) ? root.options : {};
|
|
1450
|
+
if (hasKey(root, "options") && !isJsonObject(root.options)) {
|
|
1451
|
+
report.warning("validation-error", JSON_ISSUE.BAD_FLAG, "options is not an object; ignored", {
|
|
1452
|
+
element: "options",
|
|
1453
|
+
});
|
|
1454
|
+
}
|
|
1455
|
+
let type: "directed" | "undirected" | "mixed";
|
|
1456
|
+
if (options.type === "directed" || options.type === "undirected" || options.type === "mixed") {
|
|
1457
|
+
({ type } = options);
|
|
1458
|
+
} else {
|
|
1459
|
+
if (options.type !== undefined && options.type !== null) {
|
|
1460
|
+
report.warning(
|
|
1461
|
+
"validation-error",
|
|
1462
|
+
JSON_ISSUE.BAD_FLAG,
|
|
1463
|
+
`options.type ${describe(options.type)} is not directed, undirected or mixed; mixed assumed`,
|
|
1464
|
+
{ element: "options.type" },
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
type = "mixed";
|
|
1468
|
+
}
|
|
1469
|
+
let directed: boolean;
|
|
1470
|
+
if (type === "mixed") {
|
|
1471
|
+
const objects = edges.filter((edge) => isJsonObject(edge)) as JsonRecord[];
|
|
1472
|
+
const undirectedEdges = objects.filter((edge) => edge.undirected === true).length;
|
|
1473
|
+
directed = objects.length === 0 ? ctx.defaultDirected("graphology") : undirectedEdges < objects.length;
|
|
1474
|
+
} else {
|
|
1475
|
+
directed = type === "directed";
|
|
1476
|
+
}
|
|
1477
|
+
ctx.setHeader(directed);
|
|
1478
|
+
const multi = hasKey(options, "multi") ? flagOf(options.multi, "options.multi", false, report) : null;
|
|
1479
|
+
const allowSelfLoops = hasKey(options, "allowSelfLoops")
|
|
1480
|
+
? flagOf(options.allowSelfLoops, "options.allowSelfLoops", true, report)
|
|
1481
|
+
: undefined;
|
|
1482
|
+
ctx.writeGraphDict(root.attributes, "attributes");
|
|
1483
|
+
ctx.reportNodeIdFrom("graphology", "the node key");
|
|
1484
|
+
ctx.sink.reserve(nodes.length, edges.length);
|
|
1485
|
+
|
|
1486
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
1487
|
+
const element = `nodes[${i}]`;
|
|
1488
|
+
const record = nodes[i];
|
|
1489
|
+
if (!isJsonObject(record)) {
|
|
1490
|
+
ctx.badElement("node", element);
|
|
1491
|
+
continue;
|
|
1492
|
+
}
|
|
1493
|
+
const id = ctx.coerceId(hasKey(record, "key") ? record.key : undefined, element);
|
|
1494
|
+
if (id === null) {
|
|
1495
|
+
ctx.countSkipped("node");
|
|
1496
|
+
continue;
|
|
1497
|
+
}
|
|
1498
|
+
pushNestedNode(ctx, id, record, "attributes", GRAPHOLOGY_NODE_KEYS, element);
|
|
1499
|
+
}
|
|
1500
|
+
throwIfAborted(ctx.options.signal);
|
|
1501
|
+
|
|
1502
|
+
const ids = ctx.declareEdgeIds(edges, (edge) => edge.key, "key", true);
|
|
1503
|
+
for (let i = 0; i < edges.length; i++) {
|
|
1504
|
+
const element = `edges[${i}]`;
|
|
1505
|
+
const record = edges[i];
|
|
1506
|
+
if (!isJsonObject(record)) {
|
|
1507
|
+
ctx.badElement("edge", element);
|
|
1508
|
+
continue;
|
|
1509
|
+
}
|
|
1510
|
+
if (!hasKey(record, "source") || !hasKey(record, "target")) {
|
|
1511
|
+
ctx.missingEndpoint(element, hasKey(record, "source") ? "target" : "source");
|
|
1512
|
+
continue;
|
|
1513
|
+
}
|
|
1514
|
+
try {
|
|
1515
|
+
const u = ctx.coerceId(record.source, `${element}.source`);
|
|
1516
|
+
const v = ctx.coerceId(record.target, `${element}.target`);
|
|
1517
|
+
if (u === null || v === null) {
|
|
1518
|
+
ctx.countSkipped("edge");
|
|
1519
|
+
continue;
|
|
1520
|
+
}
|
|
1521
|
+
let kind: EdgeKind;
|
|
1522
|
+
if (type === "mixed") {
|
|
1523
|
+
kind = flagOf(record.undirected, `${element}.undirected`, false, report) ? "undirected" : "directed";
|
|
1524
|
+
} else {
|
|
1525
|
+
kind = type;
|
|
1526
|
+
}
|
|
1527
|
+
const attributes = isJsonObject(record.attributes) ? record.attributes : {};
|
|
1528
|
+
const idValue = ctx.edgeIdValue(ids, record.key);
|
|
1529
|
+
const edge = ctx.pushEdge(u, v, kind, ctx.weightOf(attributes), element);
|
|
1530
|
+
ctx.setEdgeId(ids, edge, idValue);
|
|
1531
|
+
ctx.writeNested(ctx.edges, edge, record, attributes, GRAPHOLOGY_EDGE_KEYS, ctx.options.weightFrom);
|
|
1532
|
+
} catch (err) {
|
|
1533
|
+
ctx.skip(err, "edge", element);
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
ctx.setMeta({ dialect: "graphology", allowSelfLoops }, { declaredMultigraph: multi, ...ctx.weightOriginPatch() });
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
/**
|
|
1540
|
+
* Push a node whose attributes live in a nested dict (graphology `attributes`, JGF `metadata`);
|
|
1541
|
+
* element-level keys the dialect does not define are kept with the `#element` suffix.
|
|
1542
|
+
* @param ctx - the context
|
|
1543
|
+
* @param id - the node id
|
|
1544
|
+
* @param record - the element record
|
|
1545
|
+
* @param dictKey - the key of the nested dict
|
|
1546
|
+
* @param structural - the element keys that are not attributes
|
|
1547
|
+
* @param element - the element name
|
|
1548
|
+
* @returns the node index, or -1 when skipped
|
|
1549
|
+
*/
|
|
1550
|
+
function pushNestedNode(
|
|
1551
|
+
ctx: ImportContext,
|
|
1552
|
+
id: NodeId,
|
|
1553
|
+
record: JsonRecord,
|
|
1554
|
+
dictKey: string,
|
|
1555
|
+
structural: ReadonlySet<string>,
|
|
1556
|
+
element: string,
|
|
1557
|
+
): number {
|
|
1558
|
+
const index = ctx.pushNode(id, element);
|
|
1559
|
+
if (index < 0) {
|
|
1560
|
+
return index;
|
|
1561
|
+
}
|
|
1562
|
+
try {
|
|
1563
|
+
const dict = record[dictKey];
|
|
1564
|
+
if (dict !== undefined && dict !== null && !isJsonObject(dict)) {
|
|
1565
|
+
ctx.report.error(
|
|
1566
|
+
"validation-error",
|
|
1567
|
+
JSON_ISSUE.BAD_VALUE,
|
|
1568
|
+
`${element}: ${dictKey} must be an object, found ${describe(dict)}`,
|
|
1569
|
+
{ element },
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
ctx.writeNested(ctx.nodes, index, record, isJsonObject(dict) ? dict : {}, structural, null);
|
|
1573
|
+
} catch (err) {
|
|
1574
|
+
ctx.report.recordError(err, { element: String(id) });
|
|
1575
|
+
}
|
|
1576
|
+
return index;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// ============================================================ JSON Graph Format
|
|
1580
|
+
|
|
1581
|
+
/**
|
|
1582
|
+
* Read a JGF v2 document (`graph` or `graphs[graphIndex]`): nodes keyed by id (or a v1 array with
|
|
1583
|
+
* `id`), `label` with role "label", `metadata` as columns, edges with `id` / `relation` /
|
|
1584
|
+
* `directed` / `label` / `metadata`, hyperedges per the `hyperedges` option.
|
|
1585
|
+
* @param ctx - the context
|
|
1586
|
+
* @param root - the document
|
|
1587
|
+
*/
|
|
1588
|
+
function importJgf(ctx: ImportContext, root: JsonRecord): void {
|
|
1589
|
+
const { report } = ctx;
|
|
1590
|
+
const graph = jgfGraphOf(ctx, root);
|
|
1591
|
+
const edges = arraySection(graph.edges, "graph.edges", report) ?? [];
|
|
1592
|
+
const hyperedges = arraySection(graph.hyperedges, "graph.hyperedges", report) ?? [];
|
|
1593
|
+
const nodesRaw: unknown = graph.nodes;
|
|
1594
|
+
if (nodesRaw !== undefined && nodesRaw !== null && !isJsonObject(nodesRaw) && !Array.isArray(nodesRaw)) {
|
|
1595
|
+
report.fail(
|
|
1596
|
+
JSON_ISSUE.SHAPE,
|
|
1597
|
+
`graph.nodes must be an object keyed by id or an array, found ${describe(nodesRaw)}`,
|
|
1598
|
+
);
|
|
1599
|
+
}
|
|
1600
|
+
const nodeRecords: readonly unknown[] = Array.isArray(nodesRaw) ? nodesRaw : Object.values(nodesRaw ?? {});
|
|
1601
|
+
|
|
1602
|
+
let directed: boolean;
|
|
1603
|
+
if (typeof graph.directed === "boolean") {
|
|
1604
|
+
({ directed } = graph);
|
|
1605
|
+
} else if (ctx.explicitDefaultDirected) {
|
|
1606
|
+
directed = ctx.options.defaultDirected;
|
|
1607
|
+
} else {
|
|
1608
|
+
if (graph.directed !== undefined && graph.directed !== null) {
|
|
1609
|
+
report.warning(
|
|
1610
|
+
"validation-error",
|
|
1611
|
+
JSON_ISSUE.BAD_FLAG,
|
|
1612
|
+
`graph.directed is ${describe(graph.directed)}, not a boolean`,
|
|
1613
|
+
{ element: "graph.directed" },
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
// the spec default is true; a file whose every edge says directed: false is read as undirected
|
|
1617
|
+
const objects = edges.filter((edge) => isJsonObject(edge)) as JsonRecord[];
|
|
1618
|
+
directed = objects.length === 0 || !objects.every((edge) => edge.directed === false);
|
|
1619
|
+
}
|
|
1620
|
+
ctx.setHeader(directed);
|
|
1621
|
+
ctx.writeGraphDict(graph.metadata, "graph.metadata");
|
|
1622
|
+
const shape: JsonShapeMeta = {
|
|
1623
|
+
dialect: "jgf",
|
|
1624
|
+
id: typeof graph.id === "string" ? graph.id : undefined,
|
|
1625
|
+
type: typeof graph.type === "string" ? graph.type : undefined,
|
|
1626
|
+
};
|
|
1627
|
+
const label = typeof graph.label === "string" ? graph.label : null;
|
|
1628
|
+
|
|
1629
|
+
ctx.reportNodeIdFrom("jgf", "the node keys");
|
|
1630
|
+
ctx.sink.reserve(nodeRecords.length, edges.length);
|
|
1631
|
+
const labelColumn = ctx.nodes.declareIf(
|
|
1632
|
+
{ name: "label", dtype: "string", role: "label", nullable: true },
|
|
1633
|
+
anyHas(nodeRecords, "label"),
|
|
1634
|
+
);
|
|
1635
|
+
if (isJsonObject(nodesRaw)) {
|
|
1636
|
+
for (const key of Object.keys(nodesRaw)) {
|
|
1637
|
+
const element = `nodes[${JSON.stringify(key)}]`;
|
|
1638
|
+
const record = nodesRaw[key];
|
|
1639
|
+
if (record !== null && !isJsonObject(record)) {
|
|
1640
|
+
ctx.badElement("node", element);
|
|
1641
|
+
continue;
|
|
1642
|
+
}
|
|
1643
|
+
const id = ctx.coerceId(key, element);
|
|
1644
|
+
if (id === null) {
|
|
1645
|
+
ctx.countSkipped("node");
|
|
1646
|
+
continue;
|
|
1647
|
+
}
|
|
1648
|
+
pushJgfNode(ctx, id, record ?? {}, labelColumn, element);
|
|
1649
|
+
}
|
|
1650
|
+
} else {
|
|
1651
|
+
for (let i = 0; i < nodeRecords.length; i++) {
|
|
1652
|
+
const element = `nodes[${i}]`;
|
|
1653
|
+
const record = nodeRecords[i];
|
|
1654
|
+
if (!isJsonObject(record)) {
|
|
1655
|
+
ctx.badElement("node", element);
|
|
1656
|
+
continue;
|
|
1657
|
+
}
|
|
1658
|
+
const id = ctx.coerceId(hasKey(record, "id") ? record.id : undefined, element);
|
|
1659
|
+
if (id === null) {
|
|
1660
|
+
ctx.countSkipped("node");
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
pushJgfNode(ctx, id, record, labelColumn, element);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
throwIfAborted(ctx.options.signal);
|
|
1667
|
+
|
|
1668
|
+
const ids = ctx.declareEdgeIds([...edges, ...hyperedges], (edge) => edge.id, "id", false);
|
|
1669
|
+
const relationColumn = ctx.edges.declareIf(
|
|
1670
|
+
{ name: "relation", dtype: "string", role: "kind", nullable: true },
|
|
1671
|
+
anyHas(edges, "relation") || anyHas(hyperedges, "relation"),
|
|
1672
|
+
);
|
|
1673
|
+
const edgeLabelColumn = ctx.edges.declareIf(
|
|
1674
|
+
{ name: "label", dtype: "string", role: "label", nullable: true },
|
|
1675
|
+
anyHas(edges, "label") || anyHas(hyperedges, "label"),
|
|
1676
|
+
);
|
|
1677
|
+
const writeJgfEdge = (
|
|
1678
|
+
record: JsonRecord,
|
|
1679
|
+
u: NodeId,
|
|
1680
|
+
v: NodeId,
|
|
1681
|
+
kind: EdgeKind,
|
|
1682
|
+
element: string,
|
|
1683
|
+
structural: ReadonlySet<string> = JGF_EDGE_KEYS,
|
|
1684
|
+
): void => {
|
|
1685
|
+
const { metadata } = record;
|
|
1686
|
+
const dict = isJsonObject(metadata) ? metadata : {};
|
|
1687
|
+
const idValue = ctx.edgeIdValue(ids, record.id);
|
|
1688
|
+
const edge = ctx.pushEdge(u, v, kind, ctx.weightOf(dict), element);
|
|
1689
|
+
ctx.setEdgeId(ids, edge, idValue);
|
|
1690
|
+
ctx.writeStringField(ctx.edges, relationColumn, edge, record.relation, "relation", element);
|
|
1691
|
+
ctx.writeStringField(ctx.edges, edgeLabelColumn, edge, record.label, "label", element);
|
|
1692
|
+
if (metadata !== undefined && metadata !== null && !isJsonObject(metadata)) {
|
|
1693
|
+
report.error("validation-error", JSON_ISSUE.BAD_VALUE, `${element}: metadata must be an object`, {
|
|
1694
|
+
element,
|
|
1695
|
+
});
|
|
1696
|
+
}
|
|
1697
|
+
ctx.writeNested(ctx.edges, edge, record, dict, structural, ctx.options.weightFrom);
|
|
1698
|
+
};
|
|
1699
|
+
|
|
1700
|
+
for (let i = 0; i < edges.length; i++) {
|
|
1701
|
+
const element = `edges[${i}]`;
|
|
1702
|
+
const record = edges[i];
|
|
1703
|
+
if (!isJsonObject(record)) {
|
|
1704
|
+
ctx.badElement("edge", element);
|
|
1705
|
+
continue;
|
|
1706
|
+
}
|
|
1707
|
+
if (!hasKey(record, "source") || !hasKey(record, "target")) {
|
|
1708
|
+
ctx.missingEndpoint(element, hasKey(record, "source") ? "target" : "source");
|
|
1709
|
+
continue;
|
|
1710
|
+
}
|
|
1711
|
+
try {
|
|
1712
|
+
const u = ctx.coerceId(record.source, `${element}.source`);
|
|
1713
|
+
const v = ctx.coerceId(record.target, `${element}.target`);
|
|
1714
|
+
if (u === null || v === null) {
|
|
1715
|
+
ctx.countSkipped("edge");
|
|
1716
|
+
continue;
|
|
1717
|
+
}
|
|
1718
|
+
const edgeDirected = flagOf(record.directed, `${element}.directed`, directed, report);
|
|
1719
|
+
writeJgfEdge(record, u, v, edgeDirected ? "directed" : "undirected", element);
|
|
1720
|
+
} catch (err) {
|
|
1721
|
+
ctx.skip(err, "edge", element);
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
importHyperedges(ctx, hyperedges, directed, writeJgfEdge);
|
|
1726
|
+
ctx.setMeta(shape, { name: label, ...ctx.weightOriginPatch() });
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
/**
|
|
1730
|
+
* The graph object of a JGF document: `graph`, or `graphs[graphIndex]`.
|
|
1731
|
+
* @param ctx - the context
|
|
1732
|
+
* @param root - the document
|
|
1733
|
+
* @returns the graph object; the import fails when there is none
|
|
1734
|
+
*/
|
|
1735
|
+
function jgfGraphOf(ctx: ImportContext, root: JsonRecord): JsonRecord {
|
|
1736
|
+
const { report } = ctx;
|
|
1737
|
+
if (isJsonObject(root.graph)) {
|
|
1738
|
+
return root.graph;
|
|
1739
|
+
}
|
|
1740
|
+
const graphs = arraySection(root.graphs, "graphs", report) ?? [];
|
|
1741
|
+
if (graphs.length === 0) {
|
|
1742
|
+
report.fail(JSON_ISSUE.SHAPE, "a JGF document needs a graph object or a non-empty graphs array");
|
|
1743
|
+
}
|
|
1744
|
+
if (graphs.length > 1) {
|
|
1745
|
+
report.warning(
|
|
1746
|
+
"unsupported",
|
|
1747
|
+
JSON_ISSUE.MULTIPLE_GRAPHS,
|
|
1748
|
+
`the document holds ${graphs.length} graphs; only graphs[${ctx.json.graphIndex}] is read`,
|
|
1749
|
+
{ element: "graphs" },
|
|
1750
|
+
);
|
|
1751
|
+
}
|
|
1752
|
+
if (ctx.json.graphIndex >= graphs.length) {
|
|
1753
|
+
report.fail(JSON_ISSUE.SHAPE, `graphIndex ${ctx.json.graphIndex} is beyond the ${graphs.length} graph(s)`);
|
|
1754
|
+
}
|
|
1755
|
+
const graph = graphs[ctx.json.graphIndex];
|
|
1756
|
+
if (!isJsonObject(graph)) {
|
|
1757
|
+
return report.fail(JSON_ISSUE.SHAPE, `graphs[${ctx.json.graphIndex}] is not an object`);
|
|
1758
|
+
}
|
|
1759
|
+
return graph;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
/**
|
|
1763
|
+
* Push a JGF node: `label` into the declared column, `metadata` as attributes.
|
|
1764
|
+
* @param ctx - the context
|
|
1765
|
+
* @param id - the node id
|
|
1766
|
+
* @param record - the node record
|
|
1767
|
+
* @param labelColumn - the label column handle, or INVALID_INDEX
|
|
1768
|
+
* @param element - the element name
|
|
1769
|
+
*/
|
|
1770
|
+
function pushJgfNode(
|
|
1771
|
+
ctx: ImportContext,
|
|
1772
|
+
id: NodeId,
|
|
1773
|
+
record: JsonRecord,
|
|
1774
|
+
labelColumn: ColumnHandle,
|
|
1775
|
+
element: string,
|
|
1776
|
+
): void {
|
|
1777
|
+
const index = ctx.pushNode(id, element);
|
|
1778
|
+
if (index < 0) {
|
|
1779
|
+
return;
|
|
1780
|
+
}
|
|
1781
|
+
try {
|
|
1782
|
+
ctx.writeStringField(ctx.nodes, labelColumn, index, record.label, "label", element);
|
|
1783
|
+
const { metadata } = record;
|
|
1784
|
+
if (metadata !== undefined && metadata !== null && !isJsonObject(metadata)) {
|
|
1785
|
+
ctx.report.error("validation-error", JSON_ISSUE.BAD_VALUE, `${element}: metadata must be an object`, {
|
|
1786
|
+
element,
|
|
1787
|
+
});
|
|
1788
|
+
}
|
|
1789
|
+
ctx.writeNested(ctx.nodes, index, record, isJsonObject(metadata) ? metadata : {}, JGF_NODE_KEYS, null);
|
|
1790
|
+
} catch (err) {
|
|
1791
|
+
ctx.report.recordError(err, { element: String(id) });
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
/**
|
|
1796
|
+
* JGF hyperedges per the `hyperedges` option: "error" aborts, "skip" (default) records a warning
|
|
1797
|
+
* and a loss note, "star" and "clique" expand an undirected `{ nodes }` hyperedge into edges from
|
|
1798
|
+
* its first node (star) or between every pair (clique); a directed `{ source, target }` hyperedge
|
|
1799
|
+
* becomes every source -> target edge under both policies. Every expanded edge carries the
|
|
1800
|
+
* hyperedge's id, relation, label and metadata.
|
|
1801
|
+
* @param ctx - the context
|
|
1802
|
+
* @param hyperedges - the hyperedge records
|
|
1803
|
+
* @param directed - the graph's direction
|
|
1804
|
+
* @param push - the edge writer of the JGF reader
|
|
1805
|
+
*/
|
|
1806
|
+
function importHyperedges(
|
|
1807
|
+
ctx: ImportContext,
|
|
1808
|
+
hyperedges: readonly unknown[],
|
|
1809
|
+
directed: boolean,
|
|
1810
|
+
push: (
|
|
1811
|
+
record: JsonRecord,
|
|
1812
|
+
u: NodeId,
|
|
1813
|
+
v: NodeId,
|
|
1814
|
+
kind: EdgeKind,
|
|
1815
|
+
element: string,
|
|
1816
|
+
structural: ReadonlySet<string>,
|
|
1817
|
+
) => void,
|
|
1818
|
+
): void {
|
|
1819
|
+
if (hyperedges.length === 0) {
|
|
1820
|
+
return;
|
|
1821
|
+
}
|
|
1822
|
+
const { report } = ctx;
|
|
1823
|
+
const policy = ctx.options.hyperedges;
|
|
1824
|
+
if (policy === "error") {
|
|
1825
|
+
report.error("unsupported", JSON_ISSUE.HYPEREDGE, `${hyperedges.length} hyperedge(s) (hyperedges: "error")`, {
|
|
1826
|
+
element: "hyperedges",
|
|
1827
|
+
});
|
|
1828
|
+
throw report.abort("hyperedges refused", { code: JSON_ISSUE.HYPEREDGE, count: hyperedges.length });
|
|
1829
|
+
}
|
|
1830
|
+
if (policy === "skip") {
|
|
1831
|
+
report.warning("unsupported", JSON_ISSUE.HYPEREDGES_SKIPPED, `${hyperedges.length} hyperedge(s) skipped`, {
|
|
1832
|
+
element: "hyperedges",
|
|
1833
|
+
});
|
|
1834
|
+
report.loss(
|
|
1835
|
+
JSON_ISSUE.HYPEREDGES_SKIPPED,
|
|
1836
|
+
`${hyperedges.length} hyperedge(s) were not imported`,
|
|
1837
|
+
null,
|
|
1838
|
+
hyperedges.length,
|
|
1839
|
+
);
|
|
1840
|
+
return;
|
|
1841
|
+
}
|
|
1842
|
+
for (let i = 0; i < hyperedges.length; i++) {
|
|
1843
|
+
const element = `hyperedges[${i}]`;
|
|
1844
|
+
const record = hyperedges[i];
|
|
1845
|
+
if (!isJsonObject(record)) {
|
|
1846
|
+
ctx.badElement("edge", element);
|
|
1847
|
+
continue;
|
|
1848
|
+
}
|
|
1849
|
+
try {
|
|
1850
|
+
if (Array.isArray(record.nodes)) {
|
|
1851
|
+
const members = record.nodes.map((raw) => ctx.requireId(raw, element));
|
|
1852
|
+
if (members.length < 2) {
|
|
1853
|
+
throw new GraphFormatError(
|
|
1854
|
+
"E_INVALID_ID",
|
|
1855
|
+
`${element}: an undirected hyperedge needs two or more nodes`,
|
|
1856
|
+
{ reason: "hyperedge shape" },
|
|
1857
|
+
);
|
|
1858
|
+
}
|
|
1859
|
+
const kind: EdgeKind = directed ? "directed" : "undirected";
|
|
1860
|
+
if (policy === "star") {
|
|
1861
|
+
for (let k = 1; k < members.length; k++) {
|
|
1862
|
+
push(record, members[0], members[k], kind, element, JGF_HYPEREDGE_KEYS);
|
|
1863
|
+
}
|
|
1864
|
+
} else {
|
|
1865
|
+
for (let a = 0; a < members.length; a++) {
|
|
1866
|
+
for (let b = a + 1; b < members.length; b++) {
|
|
1867
|
+
push(record, members[a], members[b], kind, element, JGF_HYPEREDGE_KEYS);
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
} else if (Array.isArray(record.source) && Array.isArray(record.target)) {
|
|
1872
|
+
const sources = record.source.map((raw) => ctx.requireId(raw, element));
|
|
1873
|
+
const targets = record.target.map((raw) => ctx.requireId(raw, element));
|
|
1874
|
+
if (sources.length === 0 || targets.length === 0) {
|
|
1875
|
+
throw new GraphFormatError(
|
|
1876
|
+
"E_INVALID_ID",
|
|
1877
|
+
`${element}: a directed hyperedge needs sources and targets`,
|
|
1878
|
+
{ reason: "hyperedge shape" },
|
|
1879
|
+
);
|
|
1880
|
+
}
|
|
1881
|
+
for (const s of sources) {
|
|
1882
|
+
for (const t of targets) {
|
|
1883
|
+
push(record, s, t, "directed", element, JGF_HYPEREDGE_KEYS);
|
|
1884
|
+
}
|
|
1885
|
+
}
|
|
1886
|
+
} else {
|
|
1887
|
+
report.error(
|
|
1888
|
+
"validation-error",
|
|
1889
|
+
JSON_ISSUE.HYPEREDGE_SHAPE,
|
|
1890
|
+
`${element} has neither a nodes array nor source / target arrays`,
|
|
1891
|
+
{ element },
|
|
1892
|
+
);
|
|
1893
|
+
ctx.countSkipped("edge");
|
|
1894
|
+
}
|
|
1895
|
+
} catch (err) {
|
|
1896
|
+
ctx.skip(err, "edge", element);
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
// ============================================================ Cytoscape
|
|
1902
|
+
|
|
1903
|
+
/**
|
|
1904
|
+
* Read Cytoscape.js elements: `elements.nodes` / `elements.edges`, a flat `elements` array (group
|
|
1905
|
+
* from `group` or from the presence of source / target), or a top-level array. `data.id` is the id,
|
|
1906
|
+
* `data.parent` the parent (resolved after every node is known), `position` the position column,
|
|
1907
|
+
* `classes` the classes list; the other `data` keys are attributes and the element-level keys
|
|
1908
|
+
* (selected, locked, ...) are columns of the same name.
|
|
1909
|
+
* @param ctx - the context
|
|
1910
|
+
* @param root - the document
|
|
1911
|
+
*/
|
|
1912
|
+
function importCytoscape(ctx: ImportContext, root: unknown): void {
|
|
1913
|
+
const { report } = ctx;
|
|
1914
|
+
let elements: unknown;
|
|
1915
|
+
let extra: Record<string, unknown> | undefined;
|
|
1916
|
+
if (Array.isArray(root)) {
|
|
1917
|
+
elements = root;
|
|
1918
|
+
} else if (isJsonObject(root)) {
|
|
1919
|
+
({ elements } = root);
|
|
1920
|
+
ctx.writeGraphDict(root.data, "data");
|
|
1921
|
+
const rest: Record<string, unknown> = {};
|
|
1922
|
+
for (const key of Object.keys(root)) {
|
|
1923
|
+
if (key !== "elements" && key !== "data") {
|
|
1924
|
+
rest[key] = root[key];
|
|
1925
|
+
}
|
|
1926
|
+
}
|
|
1927
|
+
if (Object.keys(rest).length > 0) {
|
|
1928
|
+
extra = rest;
|
|
1929
|
+
}
|
|
1930
|
+
} else {
|
|
1931
|
+
report.fail(JSON_ISSUE.SHAPE, `a Cytoscape document must be an object or an array, found ${describe(root)}`);
|
|
1932
|
+
}
|
|
1933
|
+
const { nodes, edges } = cytoscapeSections(ctx, elements);
|
|
1934
|
+
ctx.setHeader(ctx.defaultDirected("cytoscape"));
|
|
1935
|
+
ctx.reportNodeIdFrom("cytoscape", "data.id");
|
|
1936
|
+
ctx.sink.reserve(nodes.length, edges.length);
|
|
1937
|
+
|
|
1938
|
+
const dataHas = (items: readonly unknown[], key: string): boolean =>
|
|
1939
|
+
items.some((item) => isJsonObject(item) && isJsonObject(item.data) && item.data[key] !== undefined);
|
|
1940
|
+
const positionColumn = ctx.nodes.declareIf(
|
|
1941
|
+
{
|
|
1942
|
+
name: POSITION_COLUMN,
|
|
1943
|
+
dtype: "f32",
|
|
1944
|
+
components: 3,
|
|
1945
|
+
role: "position",
|
|
1946
|
+
mutable: true,
|
|
1947
|
+
nullable: true,
|
|
1948
|
+
extra: { sourceDims: 2, units: "file" },
|
|
1949
|
+
origin: { format: "json", namespace: "cytoscape" },
|
|
1950
|
+
},
|
|
1951
|
+
anyHas(nodes, "position"),
|
|
1952
|
+
);
|
|
1953
|
+
const classesColumn = ctx.nodes.declareIf(
|
|
1954
|
+
{ name: CLASSES_COLUMN, dtype: "list", itemDtype: "string", role: "classes", nullable: true },
|
|
1955
|
+
anyHas(nodes, "classes"),
|
|
1956
|
+
);
|
|
1957
|
+
const parentColumn = ctx.nodes.declareIf(
|
|
1958
|
+
{ name: PARENT_COLUMN, dtype: "u32", role: "parent", refersTo: "node", nullable: true },
|
|
1959
|
+
dataHas(nodes, "parent"),
|
|
1960
|
+
);
|
|
1961
|
+
const edgeClassesColumn = ctx.edges.declareIf(
|
|
1962
|
+
{ name: CLASSES_COLUMN, dtype: "list", itemDtype: "string", role: "classes", nullable: true },
|
|
1963
|
+
anyHas(edges, "classes"),
|
|
1964
|
+
);
|
|
1965
|
+
const parents: { index: number; parent: NodeId; element: string }[] = [];
|
|
1966
|
+
const point: [number, number, number] = [0, 0, 0];
|
|
1967
|
+
|
|
1968
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
1969
|
+
const element = `nodes[${i}]`;
|
|
1970
|
+
const record = nodes[i];
|
|
1971
|
+
if (!isJsonObject(record) || !isJsonObject(record.data)) {
|
|
1972
|
+
ctx.badElement("node", element, "an element with a data object");
|
|
1973
|
+
continue;
|
|
1974
|
+
}
|
|
1975
|
+
const { data } = record;
|
|
1976
|
+
const id = ctx.coerceId(hasKey(data, "id") ? data.id : undefined, element);
|
|
1977
|
+
if (id === null) {
|
|
1978
|
+
ctx.countSkipped("node");
|
|
1979
|
+
continue;
|
|
1980
|
+
}
|
|
1981
|
+
const index = ctx.pushNode(id, element);
|
|
1982
|
+
if (index < 0) {
|
|
1983
|
+
continue;
|
|
1984
|
+
}
|
|
1985
|
+
try {
|
|
1986
|
+
for (const key of Object.keys(data)) {
|
|
1987
|
+
if (key === "id") {
|
|
1988
|
+
continue;
|
|
1989
|
+
}
|
|
1990
|
+
if (key === "parent") {
|
|
1991
|
+
const raw = data.parent;
|
|
1992
|
+
if (raw !== undefined && raw !== null) {
|
|
1993
|
+
const parent = ctx.coerceId(raw, `${element}.data.parent`);
|
|
1994
|
+
if (parent !== null) {
|
|
1995
|
+
parents.push({ index, parent, element });
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
continue;
|
|
1999
|
+
}
|
|
2000
|
+
ctx.nodes.write(index, key, data[key], SUFFIX.data);
|
|
2001
|
+
}
|
|
2002
|
+
const { position } = record;
|
|
2003
|
+
if (position !== undefined && position !== null) {
|
|
2004
|
+
if (isJsonObject(position) && typeof position.x === "number" && typeof position.y === "number") {
|
|
2005
|
+
point[0] = position.x;
|
|
2006
|
+
point[1] = position.y;
|
|
2007
|
+
ctx.nodes.set(positionColumn, index, point);
|
|
2008
|
+
} else {
|
|
2009
|
+
report.error(
|
|
2010
|
+
"validation-error",
|
|
2011
|
+
JSON_ISSUE.BAD_VALUE,
|
|
2012
|
+
`${element}: position must be an object with numeric x and y`,
|
|
2013
|
+
{ element },
|
|
2014
|
+
);
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
writeClasses(ctx, ctx.nodes, classesColumn, index, record.classes, element);
|
|
2018
|
+
writeElementKeys(ctx.nodes, index, record);
|
|
2019
|
+
} catch (err) {
|
|
2020
|
+
report.recordError(err, { element: String(id) });
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
for (const { index, parent, element } of parents) {
|
|
2024
|
+
const parentIndex = ctx.sink.indexOf(parent);
|
|
2025
|
+
if (parentIndex === INVALID_INDEX) {
|
|
2026
|
+
report.error(
|
|
2027
|
+
"missing-value",
|
|
2028
|
+
JSON_ISSUE.UNKNOWN_PARENT,
|
|
2029
|
+
`${element}: parent ${JSON.stringify(parent)} is not a node`,
|
|
2030
|
+
{ element },
|
|
2031
|
+
);
|
|
2032
|
+
continue;
|
|
2033
|
+
}
|
|
2034
|
+
ctx.nodes.set(parentColumn, index, parentIndex);
|
|
2035
|
+
}
|
|
2036
|
+
throwIfAborted(ctx.options.signal);
|
|
2037
|
+
|
|
2038
|
+
importCytoscapeEdges(ctx, edges, edgeClassesColumn);
|
|
2039
|
+
ctx.setMeta({ dialect: "cytoscape", cytoscape: extra }, ctx.weightOriginPatch());
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
/**
|
|
2043
|
+
* Read the Cytoscape edge elements: `data.id`, `data.source` / `data.target`, the weight, the
|
|
2044
|
+
* other data keys as attributes, `classes` and the element-level keys.
|
|
2045
|
+
* @param ctx - the context
|
|
2046
|
+
* @param edges - the edge elements
|
|
2047
|
+
* @param edgeClassesColumn - the edge classes column, or INVALID_INDEX
|
|
2048
|
+
*/
|
|
2049
|
+
function importCytoscapeEdges(ctx: ImportContext, edges: readonly unknown[], edgeClassesColumn: ColumnHandle): void {
|
|
2050
|
+
const ids = ctx.declareEdgeIds(edges, (edge) => (isJsonObject(edge.data) ? edge.data.id : undefined), "id", true);
|
|
2051
|
+
const kind = ctx.uniformKind();
|
|
2052
|
+
for (let i = 0; i < edges.length; i++) {
|
|
2053
|
+
const element = `edges[${i}]`;
|
|
2054
|
+
const record = edges[i];
|
|
2055
|
+
if (!isJsonObject(record) || !isJsonObject(record.data)) {
|
|
2056
|
+
ctx.badElement("edge", element, "an element with a data object");
|
|
2057
|
+
continue;
|
|
2058
|
+
}
|
|
2059
|
+
const { data } = record;
|
|
2060
|
+
if (!hasKey(data, "source") || !hasKey(data, "target")) {
|
|
2061
|
+
ctx.missingEndpoint(element, `data.${hasKey(data, "source") ? "target" : "source"}`);
|
|
2062
|
+
continue;
|
|
2063
|
+
}
|
|
2064
|
+
try {
|
|
2065
|
+
const u = ctx.coerceId(data.source, `${element}.data.source`);
|
|
2066
|
+
const v = ctx.coerceId(data.target, `${element}.data.target`);
|
|
2067
|
+
if (u === null || v === null) {
|
|
2068
|
+
ctx.countSkipped("edge");
|
|
2069
|
+
continue;
|
|
2070
|
+
}
|
|
2071
|
+
const idValue = ctx.edgeIdValue(ids, data.id);
|
|
2072
|
+
const edge = ctx.pushEdge(u, v, kind, ctx.weightOf(data), element);
|
|
2073
|
+
ctx.setEdgeId(ids, edge, idValue);
|
|
2074
|
+
const { weightFrom } = ctx.options;
|
|
2075
|
+
for (const key of Object.keys(data)) {
|
|
2076
|
+
if (key !== "id" && key !== "source" && key !== "target" && key !== weightFrom) {
|
|
2077
|
+
ctx.edges.write(edge, key, data[key], SUFFIX.data);
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
writeClasses(ctx, ctx.edges, edgeClassesColumn, edge, record.classes, element);
|
|
2081
|
+
writeElementKeys(ctx.edges, edge, record);
|
|
2082
|
+
} catch (err) {
|
|
2083
|
+
ctx.skip(err, "edge", element);
|
|
2084
|
+
}
|
|
2085
|
+
}
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
/**
|
|
2089
|
+
* The node and edge element arrays of a Cytoscape `elements` value: an object with `nodes` /
|
|
2090
|
+
* `edges`, a flat array split by isNodeElement(), or nothing (reported).
|
|
2091
|
+
* @param ctx - the context
|
|
2092
|
+
* @param elements - the `elements` value
|
|
2093
|
+
* @returns the two arrays; the import fails when elements has another type
|
|
2094
|
+
*/
|
|
2095
|
+
function cytoscapeSections(
|
|
2096
|
+
ctx: ImportContext,
|
|
2097
|
+
elements: unknown,
|
|
2098
|
+
): { readonly nodes: readonly unknown[]; readonly edges: readonly unknown[] } {
|
|
2099
|
+
const { report } = ctx;
|
|
2100
|
+
if (Array.isArray(elements)) {
|
|
2101
|
+
return {
|
|
2102
|
+
nodes: elements.filter((item) => isJsonObject(item) && isNodeElement(item)),
|
|
2103
|
+
edges: elements.filter((item) => isJsonObject(item) && !isNodeElement(item)),
|
|
2104
|
+
};
|
|
2105
|
+
}
|
|
2106
|
+
if (isJsonObject(elements)) {
|
|
2107
|
+
return {
|
|
2108
|
+
nodes: arraySection(elements.nodes, "elements.nodes", report) ?? [],
|
|
2109
|
+
edges: arraySection(elements.edges, "elements.edges", report) ?? [],
|
|
2110
|
+
};
|
|
2111
|
+
}
|
|
2112
|
+
if (elements === undefined || elements === null) {
|
|
2113
|
+
report.error("missing-value", JSON_ISSUE.MISSING_SECTION, "the document has no elements", {
|
|
2114
|
+
element: "elements",
|
|
2115
|
+
});
|
|
2116
|
+
return { nodes: [], edges: [] };
|
|
2117
|
+
}
|
|
2118
|
+
return report.fail(JSON_ISSUE.SHAPE, `elements must be an object or an array, found ${describe(elements)}`);
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
/**
|
|
2122
|
+
* Whether a flat Cytoscape element is a node: `group: "nodes"`, or no source / target in its data.
|
|
2123
|
+
* @param element - the element
|
|
2124
|
+
* @returns true for a node
|
|
2125
|
+
*/
|
|
2126
|
+
function isNodeElement(element: JsonRecord): boolean {
|
|
2127
|
+
if (element.group === "nodes") {
|
|
2128
|
+
return true;
|
|
2129
|
+
}
|
|
2130
|
+
if (element.group === "edges") {
|
|
2131
|
+
return false;
|
|
2132
|
+
}
|
|
2133
|
+
const { data } = element;
|
|
2134
|
+
return !(isJsonObject(data) && hasKey(data, "source") && hasKey(data, "target"));
|
|
2135
|
+
}
|
|
2136
|
+
|
|
2137
|
+
/**
|
|
2138
|
+
* Write the classes of an element: a space-separated string or an array of strings.
|
|
2139
|
+
* @param ctx - the context
|
|
2140
|
+
* @param writer - the table writer
|
|
2141
|
+
* @param column - the classes column, or INVALID_INDEX when no element has classes
|
|
2142
|
+
* @param row - the row
|
|
2143
|
+
* @param raw - the `classes` value
|
|
2144
|
+
* @param element - the element name
|
|
2145
|
+
*/
|
|
2146
|
+
function writeClasses(
|
|
2147
|
+
ctx: ImportContext,
|
|
2148
|
+
writer: AttributeWriter,
|
|
2149
|
+
column: ColumnHandle,
|
|
2150
|
+
row: number,
|
|
2151
|
+
raw: unknown,
|
|
2152
|
+
element: string,
|
|
2153
|
+
): void {
|
|
2154
|
+
if (column === INVALID_INDEX || raw === undefined || raw === null) {
|
|
2155
|
+
return;
|
|
2156
|
+
}
|
|
2157
|
+
let classes: string[];
|
|
2158
|
+
if (typeof raw === "string") {
|
|
2159
|
+
classes = raw.split(/\s+/).filter((c) => c.length > 0);
|
|
2160
|
+
} else if (Array.isArray(raw) && raw.every((c) => typeof c === "string")) {
|
|
2161
|
+
classes = raw;
|
|
2162
|
+
} else {
|
|
2163
|
+
ctx.report.error(
|
|
2164
|
+
"validation-error",
|
|
2165
|
+
JSON_ISSUE.BAD_VALUE,
|
|
2166
|
+
`${element}: classes must be a string or an array of strings`,
|
|
2167
|
+
{ element },
|
|
2168
|
+
);
|
|
2169
|
+
return;
|
|
2170
|
+
}
|
|
2171
|
+
writer.set(column, row, classes);
|
|
2172
|
+
}
|
|
2173
|
+
|
|
2174
|
+
/**
|
|
2175
|
+
* Write the element-level keys of a Cytoscape element other than the structural ones: the known
|
|
2176
|
+
* keys under their own name, unknown ones with the `#element` suffix.
|
|
2177
|
+
* @param writer - the table writer
|
|
2178
|
+
* @param row - the row
|
|
2179
|
+
* @param record - the element
|
|
2180
|
+
*/
|
|
2181
|
+
function writeElementKeys(writer: AttributeWriter, row: number, record: JsonRecord): void {
|
|
2182
|
+
for (const key of Object.keys(record)) {
|
|
2183
|
+
if (CYTOSCAPE_STRUCTURAL_KEYS.has(key)) {
|
|
2184
|
+
continue;
|
|
2185
|
+
}
|
|
2186
|
+
const name = CYTOSCAPE_ELEMENT_KEYS.has(key) ? key : `${key}${SUFFIX.element}`;
|
|
2187
|
+
writer.write(row, name, record[key], SUFFIX.data);
|
|
2188
|
+
}
|
|
2189
|
+
}
|
|
2190
|
+
|
|
2191
|
+
// ============================================================ the plugin
|
|
2192
|
+
|
|
2193
|
+
/**
|
|
2194
|
+
* The JSON importer plugin (design section 8.4).
|
|
2195
|
+
*/
|
|
2196
|
+
export const jsonImporter: GraphImporter<JsonImportOptions> = Object.freeze({
|
|
2197
|
+
format: "json",
|
|
2198
|
+
extensions: Object.freeze([".json"]),
|
|
2199
|
+
mimeTypes: Object.freeze(["application/json"]),
|
|
2200
|
+
|
|
2201
|
+
/**
|
|
2202
|
+
* Confidence that the head is a JSON graph document: 0 unless it starts with `{` or `[`, 0.5
|
|
2203
|
+
* for any JSON, 0.9 when a graph key (nodes, links, edges, elements, graph, graphs) appears in
|
|
2204
|
+
* the head.
|
|
2205
|
+
* @param head - the first bytes
|
|
2206
|
+
* @returns the confidence
|
|
2207
|
+
*/
|
|
2208
|
+
sniff(head: Uint8Array): number {
|
|
2209
|
+
const text = new TextDecoder("utf-8").decode(head.subarray(0, SNIFF_BYTES));
|
|
2210
|
+
const trimmed = (text.startsWith(BOM) ? text.slice(1) : text).trimStart();
|
|
2211
|
+
if (!trimmed.startsWith("{") && !trimmed.startsWith("[")) {
|
|
2212
|
+
return 0;
|
|
2213
|
+
}
|
|
2214
|
+
return SNIFF_KEYS.some((key) => trimmed.includes(key)) ? 0.9 : 0.5;
|
|
2215
|
+
},
|
|
2216
|
+
|
|
2217
|
+
/**
|
|
2218
|
+
* Read a JSON graph document into the sink.
|
|
2219
|
+
* @param input - the text, bytes or stream
|
|
2220
|
+
* @param sink - the sink
|
|
2221
|
+
* @param options - format-specific and common options
|
|
2222
|
+
* @returns the import report; ImportError on a fatal error or beyond the error limit
|
|
2223
|
+
*/
|
|
2224
|
+
async import(
|
|
2225
|
+
input: ImportInput,
|
|
2226
|
+
sink: GraphSink,
|
|
2227
|
+
options?: JsonImportOptions & CommonImportOptions,
|
|
2228
|
+
): Promise<ImportReport> {
|
|
2229
|
+
const resolved = resolveImportOptions(options, FORMAT_DEFAULTS);
|
|
2230
|
+
const json = resolveJsonOptions(options);
|
|
2231
|
+
const report = new ImportReportBuilder("json", resolved.errorLimit);
|
|
2232
|
+
const text = await readText(input, report, resolved);
|
|
2233
|
+
const root = parseDocument(text, report);
|
|
2234
|
+
const dialect = detectDialect(root, json.dialect, report);
|
|
2235
|
+
const ctx = new ImportContext(sink, report, resolved, json, options?.defaultDirected !== undefined);
|
|
2236
|
+
reportSinkOptions(sink, options, report);
|
|
2237
|
+
reportUnusedOptions(options, report, USED_OPTIONS);
|
|
2238
|
+
if (dialect === "cytoscape") {
|
|
2239
|
+
importCytoscape(ctx, root);
|
|
2240
|
+
throwIfAborted(resolved.signal);
|
|
2241
|
+
return report.finish();
|
|
2242
|
+
}
|
|
2243
|
+
const doc = isJsonObject(root)
|
|
2244
|
+
? root
|
|
2245
|
+
: report.fail(JSON_ISSUE.SHAPE, `a ${dialect} document must be a JSON object, found ${describe(root)}`);
|
|
2246
|
+
switch (dialect) {
|
|
2247
|
+
case "node-link":
|
|
2248
|
+
case "d3":
|
|
2249
|
+
importNodeLink(ctx, doc, dialect);
|
|
2250
|
+
break;
|
|
2251
|
+
case "jgf":
|
|
2252
|
+
importJgf(ctx, doc);
|
|
2253
|
+
break;
|
|
2254
|
+
case "graphology":
|
|
2255
|
+
importGraphology(ctx, doc);
|
|
2256
|
+
break;
|
|
2257
|
+
case "vis":
|
|
2258
|
+
importVis(ctx, doc);
|
|
2259
|
+
break;
|
|
2260
|
+
default: {
|
|
2261
|
+
const name: string = dialect;
|
|
2262
|
+
throw new GraphFormatError("E_UNSUPPORTED", `unknown dialect ${name}`, {
|
|
2263
|
+
option: "dialect",
|
|
2264
|
+
found: name,
|
|
2265
|
+
});
|
|
2266
|
+
}
|
|
2267
|
+
}
|
|
2268
|
+
throwIfAborted(resolved.signal);
|
|
2269
|
+
return report.finish();
|
|
2270
|
+
},
|
|
2271
|
+
});
|