@graphty/graph-io 0.3.18 → 0.3.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +80 -2
- package/dist/chunks/{escape-D-gZWO26.js → escape-B-tkk_Cl.js} +74 -16
- package/dist/chunks/escape-B-tkk_Cl.js.map +1 -0
- package/dist/chunks/{writer-GAdltGmC.js → export-Bh60Dv-n.js} +7 -252
- package/dist/chunks/export-Bh60Dv-n.js.map +1 -0
- package/dist/chunks/exporter-BGrsWamJ.js +736 -0
- package/dist/chunks/exporter-BGrsWamJ.js.map +1 -0
- package/dist/chunks/exporter-DIeGJXAZ.js +834 -0
- package/dist/chunks/exporter-DIeGJXAZ.js.map +1 -0
- package/dist/chunks/{importer-Br_QeAeE.js → importer-BWY2FFc5.js} +28 -6
- package/dist/chunks/importer-BWY2FFc5.js.map +1 -0
- package/dist/chunks/importer-BaQpCEcJ.js +2194 -0
- package/dist/chunks/importer-BaQpCEcJ.js.map +1 -0
- package/dist/chunks/importer-DD-xv9_Y.js +1471 -0
- package/dist/chunks/importer-DD-xv9_Y.js.map +1 -0
- package/dist/chunks/{importer-aNJfe0qu.js → importer-DEcKhsmQ.js} +2 -2
- package/dist/chunks/{importer-aNJfe0qu.js.map → importer-DEcKhsmQ.js.map} +1 -1
- package/dist/chunks/{importer-DHagxvDD.js → importer-DIrbbnAf.js} +6 -4
- package/dist/chunks/{importer-DHagxvDD.js.map → importer-DIrbbnAf.js.map} +1 -1
- package/dist/chunks/importer-D_e7e7LX.js +3055 -0
- package/dist/chunks/importer-D_e7e7LX.js.map +1 -0
- package/dist/chunks/{importer-Du5crN9l.js → importer-Wv4C4gaF.js} +278 -82
- package/dist/chunks/importer-Wv4C4gaF.js.map +1 -0
- package/dist/chunks/importer-vi3bSdtw.js +1713 -0
- package/dist/chunks/importer-vi3bSdtw.js.map +1 -0
- package/dist/chunks/{importer-d0uQxFp6.js → importer-zGLo8Dg_.js} +6 -4
- package/dist/chunks/{importer-d0uQxFp6.js.map → importer-zGLo8Dg_.js.map} +1 -1
- package/dist/chunks/json-elements-DDS8N2Dd.js +779 -0
- package/dist/chunks/json-elements-DDS8N2Dd.js.map +1 -0
- package/dist/chunks/{records-Bk9jgodz.js → records-dbRkxwaq.js} +2 -2
- package/dist/chunks/{records-Bk9jgodz.js.map → records-dbRkxwaq.js.map} +1 -1
- package/dist/chunks/{report-BOk0p5y8.js → report-B1z4WT9e.js} +143 -111
- package/dist/chunks/{report-BOk0p5y8.js.map → report-B1z4WT9e.js.map} +1 -1
- package/dist/chunks/weights-Dzba96G3.js +176 -0
- package/dist/chunks/weights-Dzba96G3.js.map +1 -0
- package/dist/chunks/writer-C7flM-Ih.js +77 -0
- package/dist/chunks/writer-C7flM-Ih.js.map +1 -0
- package/dist/csv.js +6 -4
- package/dist/csv.js.map +1 -1
- package/dist/cx.d.ts +1 -0
- package/dist/cx.js +6 -0
- package/dist/cx.js.map +1 -0
- package/dist/cx2.d.ts +1 -0
- package/dist/cx2.js +10 -0
- package/dist/cx2.js.map +1 -0
- package/dist/cys.d.ts +1 -0
- package/dist/cys.js +6 -0
- package/dist/cys.js.map +1 -0
- package/dist/dot.js +1 -1
- package/dist/gexf.js +16 -4
- package/dist/gexf.js.map +1 -1
- package/dist/gml.js +13 -14
- package/dist/gml.js.map +1 -1
- package/dist/graph-io.js +302 -399
- package/dist/graph-io.js.map +1 -1
- package/dist/graphml.js +1 -1
- package/dist/json.js +1 -1
- package/dist/neo4j.js +6 -4
- package/dist/neo4j.js.map +1 -1
- package/dist/obo.js +1 -1
- package/dist/pajek.js +1 -1
- package/dist/src/common/cell-budget.d.ts +84 -0
- package/dist/src/common/cell-budget.d.ts.map +1 -0
- package/dist/src/common/cell-budget.js +138 -0
- package/dist/src/common/cell-budget.js.map +1 -0
- package/dist/src/common/input.d.ts +10 -0
- package/dist/src/common/input.d.ts.map +1 -1
- package/dist/src/common/input.js +39 -0
- package/dist/src/common/input.js.map +1 -1
- package/dist/src/common/json-elements.d.ts +286 -0
- package/dist/src/common/json-elements.d.ts.map +1 -0
- package/dist/src/common/json-elements.js +926 -0
- package/dist/src/common/json-elements.js.map +1 -0
- package/dist/src/common/options.d.ts +6 -0
- package/dist/src/common/options.d.ts.map +1 -1
- package/dist/src/common/options.js +1 -1
- package/dist/src/common/options.js.map +1 -1
- package/dist/src/common/xml.d.ts +37 -3
- package/dist/src/common/xml.d.ts.map +1 -1
- package/dist/src/common/xml.js +86 -6
- package/dist/src/common/xml.js.map +1 -1
- package/dist/src/common/zip.d.ts +92 -0
- package/dist/src/common/zip.d.ts.map +1 -0
- package/dist/src/common/zip.js +399 -0
- package/dist/src/common/zip.js.map +1 -0
- package/dist/src/formats/cx/importer.d.ts +133 -0
- package/dist/src/formats/cx/importer.d.ts.map +1 -0
- package/dist/src/formats/cx/importer.js +2220 -0
- package/dist/src/formats/cx/importer.js.map +1 -0
- package/dist/src/formats/cx/index.d.ts +7 -0
- package/dist/src/formats/cx/index.d.ts.map +1 -0
- package/dist/src/formats/cx/index.js +7 -0
- package/dist/src/formats/cx/index.js.map +1 -0
- package/dist/src/formats/cx2/exporter.d.ts +59 -0
- package/dist/src/formats/cx2/exporter.d.ts.map +1 -0
- package/dist/src/formats/cx2/exporter.js +864 -0
- package/dist/src/formats/cx2/exporter.js.map +1 -0
- package/dist/src/formats/cx2/importer.d.ts +169 -0
- package/dist/src/formats/cx2/importer.d.ts.map +1 -0
- package/dist/src/formats/cx2/importer.js +1652 -0
- package/dist/src/formats/cx2/importer.js.map +1 -0
- package/dist/src/formats/cx2/index.d.ts +7 -0
- package/dist/src/formats/cx2/index.d.ts.map +1 -0
- package/dist/src/formats/cx2/index.js +7 -0
- package/dist/src/formats/cx2/index.js.map +1 -0
- package/dist/src/formats/cys/constants.d.ts +68 -0
- package/dist/src/formats/cys/constants.d.ts.map +1 -0
- package/dist/src/formats/cys/constants.js +76 -0
- package/dist/src/formats/cys/constants.js.map +1 -0
- package/dist/src/formats/cys/importer.d.ts +38 -0
- package/dist/src/formats/cys/importer.d.ts.map +1 -0
- package/dist/src/formats/cys/importer.js +830 -0
- package/dist/src/formats/cys/importer.js.map +1 -0
- package/dist/src/formats/cys/index.d.ts +7 -0
- package/dist/src/formats/cys/index.d.ts.map +1 -0
- package/dist/src/formats/cys/index.js +7 -0
- package/dist/src/formats/cys/index.js.map +1 -0
- package/dist/src/formats/cys/session.d.ts +132 -0
- package/dist/src/formats/cys/session.d.ts.map +1 -0
- package/dist/src/formats/cys/session.js +315 -0
- package/dist/src/formats/cys/session.js.map +1 -0
- package/dist/src/formats/cys/tables.d.ts +90 -0
- package/dist/src/formats/cys/tables.d.ts.map +1 -0
- package/dist/src/formats/cys/tables.js +293 -0
- package/dist/src/formats/cys/tables.js.map +1 -0
- package/dist/src/formats/dot/exporter.d.ts +2 -0
- package/dist/src/formats/dot/exporter.d.ts.map +1 -1
- package/dist/src/formats/dot/exporter.js +12 -0
- package/dist/src/formats/dot/exporter.js.map +1 -1
- package/dist/src/formats/dot/importer.js +6 -2
- package/dist/src/formats/dot/importer.js.map +1 -1
- package/dist/src/formats/gexf/exporter.d.ts +2 -0
- package/dist/src/formats/gexf/exporter.d.ts.map +1 -1
- package/dist/src/formats/gexf/exporter.js +6 -0
- package/dist/src/formats/gexf/exporter.js.map +1 -1
- package/dist/src/formats/gexf/importer.d.ts +0 -2
- package/dist/src/formats/gexf/importer.d.ts.map +1 -1
- package/dist/src/formats/gexf/importer.js +1 -3
- package/dist/src/formats/gexf/importer.js.map +1 -1
- package/dist/src/formats/gexf/index.d.ts +1 -1
- package/dist/src/formats/gexf/index.js +2 -2
- package/dist/src/formats/gexf/index.js.map +1 -1
- package/dist/src/formats/gml/exporter.d.ts +0 -8
- package/dist/src/formats/gml/exporter.d.ts.map +1 -1
- package/dist/src/formats/gml/exporter.js +8 -18
- package/dist/src/formats/gml/exporter.js.map +1 -1
- package/dist/src/formats/json/importer.d.ts.map +1 -1
- package/dist/src/formats/json/importer.js +6 -104
- package/dist/src/formats/json/importer.js.map +1 -1
- package/dist/src/formats/xgmml/columns.d.ts +152 -0
- package/dist/src/formats/xgmml/columns.d.ts.map +1 -0
- package/dist/src/formats/xgmml/columns.js +593 -0
- package/dist/src/formats/xgmml/columns.js.map +1 -0
- package/dist/src/formats/xgmml/constants.d.ts +196 -0
- package/dist/src/formats/xgmml/constants.d.ts.map +1 -0
- package/dist/src/formats/xgmml/constants.js +197 -0
- package/dist/src/formats/xgmml/constants.js.map +1 -0
- package/dist/src/formats/xgmml/document.d.ts +361 -0
- package/dist/src/formats/xgmml/document.d.ts.map +1 -0
- package/dist/src/formats/xgmml/document.js +695 -0
- package/dist/src/formats/xgmml/document.js.map +1 -0
- package/dist/src/formats/xgmml/emit.d.ts +341 -0
- package/dist/src/formats/xgmml/emit.d.ts.map +1 -0
- package/dist/src/formats/xgmml/emit.js +1275 -0
- package/dist/src/formats/xgmml/emit.js.map +1 -0
- package/dist/src/formats/xgmml/exporter.d.ts +24 -0
- package/dist/src/formats/xgmml/exporter.d.ts.map +1 -0
- package/dist/src/formats/xgmml/exporter.js +999 -0
- package/dist/src/formats/xgmml/exporter.js.map +1 -0
- package/dist/src/formats/xgmml/importer.d.ts +58 -0
- package/dist/src/formats/xgmml/importer.d.ts.map +1 -0
- package/dist/src/formats/xgmml/importer.js +360 -0
- package/dist/src/formats/xgmml/importer.js.map +1 -0
- package/dist/src/formats/xgmml/index.d.ts +8 -0
- package/dist/src/formats/xgmml/index.d.ts.map +1 -0
- package/dist/src/formats/xgmml/index.js +8 -0
- package/dist/src/formats/xgmml/index.js.map +1 -0
- package/dist/src/formats/xgmml/values.d.ts +60 -0
- package/dist/src/formats/xgmml/values.d.ts.map +1 -0
- package/dist/src/formats/xgmml/values.js +148 -0
- package/dist/src/formats/xgmml/values.js.map +1 -0
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/registry.d.ts +7 -0
- package/dist/src/registry.d.ts.map +1 -1
- package/dist/src/registry.js +30 -10
- package/dist/src/registry.js.map +1 -1
- package/dist/src/sniff.d.ts +1 -1
- package/dist/src/sniff.d.ts.map +1 -1
- package/dist/src/sniff.js +4 -0
- package/dist/src/sniff.js.map +1 -1
- package/dist/xgmml.d.ts +1 -0
- package/dist/xgmml.js +9 -0
- package/dist/xgmml.js.map +1 -0
- package/package.json +25 -2
- package/src/common/cell-budget.ts +170 -0
- package/src/common/input.ts +40 -0
- package/src/common/json-elements.ts +1147 -0
- package/src/common/options.ts +1 -1
- package/src/common/xml.ts +127 -6
- package/src/common/zip.ts +472 -0
- package/src/formats/cx/importer.ts +2733 -0
- package/src/formats/cx/index.ts +7 -0
- package/src/formats/cx2/exporter.ts +1036 -0
- package/src/formats/cx2/importer.ts +2187 -0
- package/src/formats/cx2/index.ts +7 -0
- package/src/formats/cys/constants.ts +99 -0
- package/src/formats/cys/importer.ts +1100 -0
- package/src/formats/cys/index.ts +7 -0
- package/src/formats/cys/session.ts +428 -0
- package/src/formats/cys/tables.ts +400 -0
- package/src/formats/dot/exporter.ts +17 -0
- package/src/formats/dot/importer.ts +6 -2
- package/src/formats/gexf/exporter.ts +11 -0
- package/src/formats/gexf/importer.ts +1 -2
- package/src/formats/gexf/index.ts +1 -1
- package/src/formats/gml/exporter.ts +8 -19
- package/src/formats/json/importer.ts +6 -105
- package/src/formats/xgmml/columns.ts +747 -0
- package/src/formats/xgmml/constants.ts +265 -0
- package/src/formats/xgmml/document.ts +975 -0
- package/src/formats/xgmml/emit.ts +1563 -0
- package/src/formats/xgmml/exporter.ts +1215 -0
- package/src/formats/xgmml/importer.ts +491 -0
- package/src/formats/xgmml/index.ts +8 -0
- package/src/formats/xgmml/values.ts +183 -0
- package/src/index.ts +19 -0
- package/src/registry.ts +46 -15
- package/src/sniff.ts +18 -1
- package/dist/chunks/escape-D-gZWO26.js.map +0 -1
- package/dist/chunks/importer-Br_QeAeE.js.map +0 -1
- package/dist/chunks/importer-Du5crN9l.js.map +0 -1
- package/dist/chunks/writer-GAdltGmC.js.map +0 -1
|
@@ -0,0 +1,2187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CX2 importer (design/graph-io/cytoscape-and-obo/design.md section 1.3; the feature and error
|
|
3
|
+
* inventory is research-cx2.md): the JSON exchange format of NDEx, Cytoscape 3.10+ and Cytoscape
|
|
4
|
+
* Web. A CX2 document is one array: a descriptor (`CXVersion`), aspect blocks (`nodes`, `edges`,
|
|
5
|
+
* `attributeDeclarations`, `networkAttributes`, `nodeBypasses`, `visualProperties`, opaque
|
|
6
|
+
* aspects) and a final `status`.
|
|
7
|
+
*
|
|
8
|
+
* The document is read through the streaming scanner of common/json-elements.ts, so its text is
|
|
9
|
+
* never held as one string; the parsed aspect elements are collected and the graph is built at the
|
|
10
|
+
* end, which makes every aspect order the format allows (declarations after the nodes, edges
|
|
11
|
+
* before their nodes) read the same.
|
|
12
|
+
*
|
|
13
|
+
* Mapping: every edge is directed (CX2 has no undirected edge); node ids follow the CX id rule
|
|
14
|
+
* (design section 1.0.2); declared attributes become typed columns named by their full name, with
|
|
15
|
+
* the alias in `origin.id` and the declared default in `meta.default`; `name` is the label; node
|
|
16
|
+
* `x` / `y` become the position column with y flipped to y-up, `z` the `z` column; per-element
|
|
17
|
+
* bypasses become one column per visual property (origin namespace "cx2.bypass"); style rules
|
|
18
|
+
* (`visualProperties`, `visualEditorProperties`) and every opaque aspect are kept verbatim in
|
|
19
|
+
* `meta.extra.cx2.opaque` and are not applied (W_STYLES_NOT_IMPORTED, issue #706).
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import {
|
|
23
|
+
type ColumnDecl,
|
|
24
|
+
type ColumnHandle,
|
|
25
|
+
type Dtype,
|
|
26
|
+
GraphFormatError,
|
|
27
|
+
type GraphMetaPatch,
|
|
28
|
+
type GraphSink,
|
|
29
|
+
INVALID_INDEX,
|
|
30
|
+
type NodeId,
|
|
31
|
+
type ScalarDtype,
|
|
32
|
+
} from "@graphty/graph-format";
|
|
33
|
+
|
|
34
|
+
import { declareResolved, uniqueColumnName } from "../../common/attributes.js";
|
|
35
|
+
import {
|
|
36
|
+
ASPECT_ORDER_CODE,
|
|
37
|
+
BAD_ASPECT_BLOCK_CODE,
|
|
38
|
+
BAD_DEFAULT_CODE,
|
|
39
|
+
BAD_VALUE_CODE,
|
|
40
|
+
COLUMN_RENAMED_CODE,
|
|
41
|
+
COUNT_MISMATCH_CODE,
|
|
42
|
+
DANGLING_REFERENCE_CODE,
|
|
43
|
+
DIRECTION_FORCED_CODE,
|
|
44
|
+
DIRECTION_REFUSED_CODE,
|
|
45
|
+
DUPLICATE_ATTRIBUTE_CODE,
|
|
46
|
+
DUPLICATE_EDGE_ID_CODE,
|
|
47
|
+
DUPLICATE_NODE_CODE,
|
|
48
|
+
EMPTY_INPUT_CODE,
|
|
49
|
+
ENCODING_FALLBACK_CODE,
|
|
50
|
+
ID_MERGED_CODE,
|
|
51
|
+
ID_TEXT_TYPE_CODE,
|
|
52
|
+
INVALID_ENCODING_CODE,
|
|
53
|
+
INVALID_UTF8_CODE,
|
|
54
|
+
MISSING_ENDPOINT_CODE,
|
|
55
|
+
MISSING_ID_CODE,
|
|
56
|
+
OPTION_IGNORED_CODE,
|
|
57
|
+
PRECISION_CODE,
|
|
58
|
+
ROLE_TAKEN_CODE,
|
|
59
|
+
SINK_OPTION_CODE,
|
|
60
|
+
STATUS_FAILED_CODE,
|
|
61
|
+
STATUS_WARNING_CODE,
|
|
62
|
+
STYLES_NOT_IMPORTED_CODE,
|
|
63
|
+
SYNTAX_CODE,
|
|
64
|
+
TOO_LARGE_CODE,
|
|
65
|
+
UNKNOWN_ATTR_TYPE_CODE,
|
|
66
|
+
UNKNOWN_ELEMENT_CODE,
|
|
67
|
+
UNKNOWN_ENCODING_CODE,
|
|
68
|
+
} from "../../common/codes.js";
|
|
69
|
+
import { DirectionResolver } from "../../common/direction.js";
|
|
70
|
+
import { IdCoercer } from "../../common/ids.js";
|
|
71
|
+
import { textChunks, throwIfAborted } from "../../common/input.js";
|
|
72
|
+
import {
|
|
73
|
+
cxId,
|
|
74
|
+
CxStructure,
|
|
75
|
+
declareFresh,
|
|
76
|
+
ExactInteger,
|
|
77
|
+
flipY,
|
|
78
|
+
inexactLiteral,
|
|
79
|
+
isRecord,
|
|
80
|
+
JsonScanError,
|
|
81
|
+
plainJson,
|
|
82
|
+
positionDecl,
|
|
83
|
+
reportTooDeep,
|
|
84
|
+
scanAspects,
|
|
85
|
+
zDecl,
|
|
86
|
+
} from "../../common/json-elements.js";
|
|
87
|
+
import {
|
|
88
|
+
type ImportFormatDefaults,
|
|
89
|
+
reportSinkOptions,
|
|
90
|
+
reportUnusedOptions,
|
|
91
|
+
type ResolvedImportOptions,
|
|
92
|
+
resolveImportOptions,
|
|
93
|
+
} from "../../common/options.js";
|
|
94
|
+
import { ImportReportBuilder } from "../../common/report.js";
|
|
95
|
+
import { weightFromValue } from "../../common/weights.js";
|
|
96
|
+
import { type CommonImportOptions, type GraphImporter, type ImportInput, type ImportReport } from "../../types.js";
|
|
97
|
+
|
|
98
|
+
/** The format name. */
|
|
99
|
+
export const CX2_FORMAT = "cx2";
|
|
100
|
+
|
|
101
|
+
/** The node attribute the exporter's `sanitizeIds: "mangle"` keeps an original id in. */
|
|
102
|
+
export const ORIGINAL_ID_ATTRIBUTE = "graphty:originalId";
|
|
103
|
+
|
|
104
|
+
/** The origin namespace of the per-element visual property columns. */
|
|
105
|
+
export const BYPASS_NAMESPACE = "cx2.bypass";
|
|
106
|
+
|
|
107
|
+
/** The format-specific options of the CX2 importer. */
|
|
108
|
+
export interface Cx2ImportOptions {
|
|
109
|
+
/**
|
|
110
|
+
* Where a node's `z` goes: "column" (default) keeps it in the f64 node column `z` (Cytoscape
|
|
111
|
+
* writes a stacking order there); "position" makes it the third component of the position.
|
|
112
|
+
*/
|
|
113
|
+
zAs?: "column" | "position" | undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The issue codes the CX2 importer records (design section 1.3), by name: the codes shared with
|
|
118
|
+
* the other importers (src/common/codes.ts) and the CX2-specific ones. A key is the code without
|
|
119
|
+
* its severity and format prefixes.
|
|
120
|
+
*/
|
|
121
|
+
export const CX2_ISSUE = Object.freeze({
|
|
122
|
+
/** The input is empty (fatal). */
|
|
123
|
+
EMPTY_INPUT: EMPTY_INPUT_CODE,
|
|
124
|
+
/** The text is not JSON (fatal). */
|
|
125
|
+
SYNTAX: SYNTAX_CODE,
|
|
126
|
+
/** Invalid UTF-8 (fatal). */
|
|
127
|
+
INVALID_UTF8: INVALID_UTF8_CODE,
|
|
128
|
+
/** Invalid bytes in the encoding a BOM or the encoding option chose (fatal). */
|
|
129
|
+
INVALID_ENCODING: INVALID_ENCODING_CODE,
|
|
130
|
+
/** Bytes that are not UTF-8 were read as windows-1252. */
|
|
131
|
+
ENCODING_FALLBACK: ENCODING_FALLBACK_CODE,
|
|
132
|
+
/** An encoding the platform cannot decode was ignored. */
|
|
133
|
+
UNKNOWN_ENCODING: UNKNOWN_ENCODING_CODE,
|
|
134
|
+
/** The document is not an array or does not start with a descriptor holding CXVersion (fatal). */
|
|
135
|
+
NO_DESCRIPTOR: "E_CX2_NO_DESCRIPTOR",
|
|
136
|
+
/** CXVersion names a major version other than 2 (fatal); "1.x" says the file is CX1. */
|
|
137
|
+
VERSION: "E_CX2_VERSION",
|
|
138
|
+
/** CXVersion is a 2.x other than "2.0" (or not a string); the document is read. */
|
|
139
|
+
MINOR_VERSION: "W_CX2_MINOR_VERSION",
|
|
140
|
+
/** The document has no status block, or a malformed one. */
|
|
141
|
+
NO_STATUS: "E_CX2_NO_STATUS",
|
|
142
|
+
/** The producer marked the document as failed (fatal). */
|
|
143
|
+
STATUS_FAILED: STATUS_FAILED_CODE,
|
|
144
|
+
/** The producer marked the document as successful with an error text. */
|
|
145
|
+
STATUS_WARNING: STATUS_WARNING_CODE,
|
|
146
|
+
/** An aspect appears in several blocks although the descriptor does not declare fragments. */
|
|
147
|
+
UNDECLARED_FRAGMENTS: "W_CX2_UNDECLARED_FRAGMENTS",
|
|
148
|
+
/** An attribute declared twice with two different types; the first declaration wins. */
|
|
149
|
+
DECLARATION_CONFLICT: "E_CX2_DECLARATION_CONFLICT",
|
|
150
|
+
/** An attribute value whose name no declaration covers; its column is inferred. */
|
|
151
|
+
UNDECLARED_ATTRIBUTE: "W_CX2_UNDECLARED_ATTRIBUTE",
|
|
152
|
+
/** A node or edge attribute named `id`, which the specification reserves. */
|
|
153
|
+
RESERVED_KEY: "W_CX2_RESERVED_KEY",
|
|
154
|
+
/** An alias that is another attribute's name or another attribute's alias; the alias is ignored. */
|
|
155
|
+
ALIAS_CONFLICT: "E_CX2_ALIAS_CONFLICT",
|
|
156
|
+
/** A network attribute declaration with an alias or a default, which CX2 forbids; both ignored. */
|
|
157
|
+
NETWORK_DECLARATION: "W_CX2_NETWORK_DECLARATION",
|
|
158
|
+
/** An aspect that holds one element holds several; the first is read. */
|
|
159
|
+
EXTRA_ELEMENTS: "W_CX2_EXTRA_ELEMENTS",
|
|
160
|
+
/** Some nodes have coordinates and others none, or a node has x without y. */
|
|
161
|
+
PARTIAL_LAYOUT: "W_CX2_PARTIAL_LAYOUT",
|
|
162
|
+
/** A full attribute name used where its alias is declared; read as the same attribute. */
|
|
163
|
+
ALIAS_BYPASSED: "W_CX2_ALIAS_BYPASSED",
|
|
164
|
+
/** A CX1 cartesianLayout aspect next to node coordinates; kept, not applied. */
|
|
165
|
+
LEGACY_LAYOUT: "W_CX2_LEGACY_LAYOUT",
|
|
166
|
+
/** The file's style rules are not applied (issue #706). */
|
|
167
|
+
STYLES_NOT_IMPORTED: STYLES_NOT_IMPORTED_CODE,
|
|
168
|
+
/** A member of the top-level array that is not a one-key aspect block, or an element that is not an object. */
|
|
169
|
+
BAD_ASPECT_BLOCK: BAD_ASPECT_BLOCK_CODE,
|
|
170
|
+
/** An aspect out of its place (after the post-metadata or the status, a third metaData, late declarations). */
|
|
171
|
+
ASPECT_ORDER: ASPECT_ORDER_CODE,
|
|
172
|
+
/** A metaData element count disagrees with what was read. */
|
|
173
|
+
COUNT_MISMATCH: COUNT_MISMATCH_CODE,
|
|
174
|
+
/** A value that does not match its declared type; the cell is unset. */
|
|
175
|
+
BAD_VALUE: BAD_VALUE_CODE,
|
|
176
|
+
/** A declared default that does not match its type. */
|
|
177
|
+
BAD_DEFAULT: BAD_DEFAULT_CODE,
|
|
178
|
+
/** A declared type CX2 does not define; the column is inferred from the values. */
|
|
179
|
+
UNKNOWN_ATTR_TYPE: UNKNOWN_ATTR_TYPE_CODE,
|
|
180
|
+
/** A bypass or a layout entry naming no node or edge. */
|
|
181
|
+
DANGLING_REFERENCE: DANGLING_REFERENCE_CODE,
|
|
182
|
+
/** The same attribute twice on one element (its alias and its full name). */
|
|
183
|
+
DUPLICATE_ATTRIBUTE: DUPLICATE_ATTRIBUTE_CODE,
|
|
184
|
+
/** A node without an id. */
|
|
185
|
+
MISSING_ID: MISSING_ID_CODE,
|
|
186
|
+
/** An edge without s or t. */
|
|
187
|
+
MISSING_ENDPOINT: MISSING_ENDPOINT_CODE,
|
|
188
|
+
/** A node id declared twice; the second merges into the first. */
|
|
189
|
+
DUPLICATE_NODE: DUPLICATE_NODE_CODE,
|
|
190
|
+
/** An edge id declared twice; the second edge is skipped. */
|
|
191
|
+
DUPLICATE_EDGE_ID: DUPLICATE_EDGE_ID_CODE,
|
|
192
|
+
/** An id that is not an integer. */
|
|
193
|
+
INVALID_ID: "E_INVALID_ID",
|
|
194
|
+
/** An edge endpoint naming no node (addMissingNodes false, the default). */
|
|
195
|
+
UNKNOWN_NODE: "E_UNKNOWN_NODE",
|
|
196
|
+
/** A weight that is not a number. */
|
|
197
|
+
INVALID_WEIGHT: "E_INVALID_WEIGHT",
|
|
198
|
+
/** An id spelled as a string or a non-integer literal; read as the integer. */
|
|
199
|
+
ID_TEXT_TYPE: ID_TEXT_TYPE_CODE,
|
|
200
|
+
/** An integer beyond 2^53: an id kept as its digits, a value stored as the nearest f64. */
|
|
201
|
+
PRECISION: PRECISION_CODE,
|
|
202
|
+
/** Two id texts merged under `ids: "number"`. */
|
|
203
|
+
ID_MERGED: ID_MERGED_CODE,
|
|
204
|
+
/** An element key CX2 does not define. */
|
|
205
|
+
UNKNOWN_ELEMENT: UNKNOWN_ELEMENT_CODE,
|
|
206
|
+
/** A column renamed because its name was taken. */
|
|
207
|
+
COLUMN_RENAMED: COLUMN_RENAMED_CODE,
|
|
208
|
+
/** A column that lost its role because another column holds it. */
|
|
209
|
+
ROLE_TAKEN: ROLE_TAKEN_CODE,
|
|
210
|
+
/** The sink refused the direction. */
|
|
211
|
+
DIRECTION_REFUSED: DIRECTION_REFUSED_CODE,
|
|
212
|
+
/** Edges forced to the policy's direction. */
|
|
213
|
+
DIRECTION_FORCED: DIRECTION_FORCED_CODE,
|
|
214
|
+
/** A common option CX2 has no use for. */
|
|
215
|
+
OPTION_IGNORED: OPTION_IGNORED_CODE,
|
|
216
|
+
/** A builder option the caller's sink does not honour. */
|
|
217
|
+
SINK_OPTION: SINK_OPTION_CODE,
|
|
218
|
+
/** The input is beyond a size limit (fatal). */
|
|
219
|
+
TOO_LARGE: TOO_LARGE_CODE,
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
const USED_OPTIONS: ReadonlySet<keyof CommonImportOptions> = new Set<keyof CommonImportOptions>([
|
|
223
|
+
"ids",
|
|
224
|
+
"addMissingNodes",
|
|
225
|
+
"duplicateEdges",
|
|
226
|
+
"selfLoops",
|
|
227
|
+
"onMixedDirection",
|
|
228
|
+
"weightFrom",
|
|
229
|
+
"weightDtype",
|
|
230
|
+
"long",
|
|
231
|
+
"restoreMangledIds",
|
|
232
|
+
"errorLimit",
|
|
233
|
+
"signal",
|
|
234
|
+
"onProgress",
|
|
235
|
+
]);
|
|
236
|
+
|
|
237
|
+
/** CX2 has no undirected edge: defaultDirected never applies (reported W_OPTION_IGNORED). */
|
|
238
|
+
const FORMAT_DEFAULTS: ImportFormatDefaults = {
|
|
239
|
+
ids: "keep",
|
|
240
|
+
defaultDirected: true,
|
|
241
|
+
weightFrom: "weight",
|
|
242
|
+
addMissingNodes: false,
|
|
243
|
+
};
|
|
244
|
+
|
|
245
|
+
const ABORT_CHECK_INTERVAL = 64;
|
|
246
|
+
|
|
247
|
+
/** The keys a node element defines. */
|
|
248
|
+
const NODE_KEYS: ReadonlySet<string> = new Set(["id", "x", "y", "z", "v"]);
|
|
249
|
+
|
|
250
|
+
/** The keys an edge element defines. */
|
|
251
|
+
const EDGE_KEYS: ReadonlySet<string> = new Set(["id", "s", "t", "v"]);
|
|
252
|
+
|
|
253
|
+
/** The aspects that hold one element. */
|
|
254
|
+
const SINGLE_ELEMENT_ASPECTS: readonly string[] = ["networkAttributes", "visualProperties", "visualEditorProperties"];
|
|
255
|
+
|
|
256
|
+
/** The aspects whose content is a style rule (design section 2). */
|
|
257
|
+
const STYLE_ASPECTS: readonly string[] = [
|
|
258
|
+
"visualProperties",
|
|
259
|
+
"visualEditorProperties",
|
|
260
|
+
"cyVisualProperties",
|
|
261
|
+
"tableVisualProperties",
|
|
262
|
+
"cyTableVisualProperties",
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
/** The aspects the stream structure owns; never kept as opaque. */
|
|
266
|
+
const STRUCTURE_ASPECTS: ReadonlySet<string> = new Set(["metaData", "status"]);
|
|
267
|
+
|
|
268
|
+
/** The canonical CX2 scalar types. */
|
|
269
|
+
type Cx2Scalar = "string" | "long" | "integer" | "double" | "boolean";
|
|
270
|
+
|
|
271
|
+
/** A declared CX2 type. */
|
|
272
|
+
interface Cx2Type {
|
|
273
|
+
/** The canonical type text (`list_of_double`). */
|
|
274
|
+
readonly d: string;
|
|
275
|
+
/** The scalar (or item) type. */
|
|
276
|
+
readonly scalar: Cx2Scalar;
|
|
277
|
+
/** Whether the type is a list. */
|
|
278
|
+
readonly list: boolean;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The type spellings CX2 defines, plus the Python spellings ndex2 writes. */
|
|
282
|
+
const SCALARS: Readonly<Record<string, Cx2Scalar>> = {
|
|
283
|
+
string: "string",
|
|
284
|
+
long: "long",
|
|
285
|
+
integer: "integer",
|
|
286
|
+
double: "double",
|
|
287
|
+
boolean: "boolean",
|
|
288
|
+
str: "string",
|
|
289
|
+
int: "integer",
|
|
290
|
+
bool: "boolean",
|
|
291
|
+
float: "double",
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Resolve a declared type text.
|
|
296
|
+
* @param d - the `d` value
|
|
297
|
+
* @returns the type, or null when CX2 does not define it
|
|
298
|
+
*/
|
|
299
|
+
export function cx2Type(d: string): Cx2Type | null {
|
|
300
|
+
const list = d.startsWith("list_of_");
|
|
301
|
+
const scalar = SCALARS[list ? d.slice("list_of_".length) : d] as Cx2Scalar | undefined;
|
|
302
|
+
if (scalar === undefined) {
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
return { d: list ? `list_of_${scalar}` : scalar, scalar, list };
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* The column dtype of a scalar type.
|
|
310
|
+
* @param scalar - the type
|
|
311
|
+
* @param long - the importer's `long` option
|
|
312
|
+
* @returns the dtype
|
|
313
|
+
*/
|
|
314
|
+
function scalarDtype(scalar: Cx2Scalar, long: "f64" | "string"): ScalarDtype {
|
|
315
|
+
switch (scalar) {
|
|
316
|
+
case "string":
|
|
317
|
+
return "string";
|
|
318
|
+
case "long":
|
|
319
|
+
return long === "string" ? "string" : "f64";
|
|
320
|
+
case "integer":
|
|
321
|
+
return "i32";
|
|
322
|
+
case "double":
|
|
323
|
+
return "f64";
|
|
324
|
+
case "boolean":
|
|
325
|
+
return "bool";
|
|
326
|
+
default: {
|
|
327
|
+
const name: string = scalar;
|
|
328
|
+
return name as ScalarDtype;
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** What convertScalar() returns for a value of the wrong type. */
|
|
334
|
+
const BAD = Symbol("bad");
|
|
335
|
+
|
|
336
|
+
const I32_MIN = -2147483648;
|
|
337
|
+
const I32_MAX = 2147483647;
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Convert one JSON value (or list item) by a declared scalar type.
|
|
341
|
+
* @param value - the value (never null)
|
|
342
|
+
* @param scalar - the type
|
|
343
|
+
* @param long - the importer's `long` option
|
|
344
|
+
* @param onPrecision - called for a long beyond 2^53
|
|
345
|
+
* @returns the value to store, or BAD
|
|
346
|
+
*/
|
|
347
|
+
function convertScalar(
|
|
348
|
+
value: unknown,
|
|
349
|
+
scalar: Cx2Scalar,
|
|
350
|
+
long: "f64" | "string",
|
|
351
|
+
onPrecision: (digits: string) => void,
|
|
352
|
+
): unknown {
|
|
353
|
+
switch (scalar) {
|
|
354
|
+
case "string":
|
|
355
|
+
return typeof value === "string" ? value : BAD;
|
|
356
|
+
case "boolean":
|
|
357
|
+
return typeof value === "boolean" ? value : BAD;
|
|
358
|
+
case "double":
|
|
359
|
+
if (value instanceof ExactInteger) {
|
|
360
|
+
return Number(value.digits);
|
|
361
|
+
}
|
|
362
|
+
return typeof value === "number" ? value : BAD;
|
|
363
|
+
case "integer":
|
|
364
|
+
return typeof value === "number" && Number.isInteger(value) && value >= I32_MIN && value <= I32_MAX
|
|
365
|
+
? value
|
|
366
|
+
: BAD;
|
|
367
|
+
case "long":
|
|
368
|
+
if (value instanceof ExactInteger) {
|
|
369
|
+
if (long === "string") {
|
|
370
|
+
return value.digits;
|
|
371
|
+
}
|
|
372
|
+
onPrecision(value.digits);
|
|
373
|
+
return Number(value.digits);
|
|
374
|
+
}
|
|
375
|
+
if (typeof value === "number" && Number.isInteger(value)) {
|
|
376
|
+
return long === "string" ? String(value) : value;
|
|
377
|
+
}
|
|
378
|
+
return BAD;
|
|
379
|
+
default:
|
|
380
|
+
return BAD;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Convert one attribute value by its declared type.
|
|
386
|
+
* @param value - the value (never null)
|
|
387
|
+
* @param type - the declared type
|
|
388
|
+
* @param long - the importer's `long` option
|
|
389
|
+
* @param onPrecision - called for a long beyond 2^53
|
|
390
|
+
* @returns the value to store, or BAD
|
|
391
|
+
*/
|
|
392
|
+
function convertValue(
|
|
393
|
+
value: unknown,
|
|
394
|
+
type: Cx2Type,
|
|
395
|
+
long: "f64" | "string",
|
|
396
|
+
onPrecision: (digits: string) => void,
|
|
397
|
+
): unknown {
|
|
398
|
+
if (!type.list) {
|
|
399
|
+
return convertScalar(value, type.scalar, long, onPrecision);
|
|
400
|
+
}
|
|
401
|
+
if (!Array.isArray(value)) {
|
|
402
|
+
return BAD;
|
|
403
|
+
}
|
|
404
|
+
const out: unknown[] = [];
|
|
405
|
+
for (const item of value) {
|
|
406
|
+
const converted = item === null ? BAD : convertScalar(item, type.scalar, long, onPrecision);
|
|
407
|
+
if (converted === BAD) {
|
|
408
|
+
return BAD;
|
|
409
|
+
}
|
|
410
|
+
out.push(converted);
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* A short JSON rendering of a value for a message.
|
|
417
|
+
* @param value - the value
|
|
418
|
+
* @returns the text, at most 60 characters
|
|
419
|
+
*/
|
|
420
|
+
function shown(value: unknown): string {
|
|
421
|
+
if (value instanceof ExactInteger) {
|
|
422
|
+
return value.digits;
|
|
423
|
+
}
|
|
424
|
+
let text: string;
|
|
425
|
+
try {
|
|
426
|
+
text = JSON.stringify(value) ?? String(value);
|
|
427
|
+
} catch {
|
|
428
|
+
text = String(value);
|
|
429
|
+
}
|
|
430
|
+
return text.length > 60 ? `${text.slice(0, 57)}...` : text;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ============================================================ the document as read
|
|
434
|
+
|
|
435
|
+
/** A parsed aspect element with where it came from. */
|
|
436
|
+
interface Held {
|
|
437
|
+
readonly value: unknown;
|
|
438
|
+
readonly line: number;
|
|
439
|
+
/** Bit 1: the id, bit 2: s, bit 4: t was written as a non-integer literal. */
|
|
440
|
+
readonly inexact: number;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Everything the scan collects. */
|
|
444
|
+
interface Cx2Document {
|
|
445
|
+
version: string;
|
|
446
|
+
hasFragments: boolean;
|
|
447
|
+
readonly nodes: Held[];
|
|
448
|
+
readonly edges: Held[];
|
|
449
|
+
readonly declarations: Held[];
|
|
450
|
+
readonly networkAttributes: Held[];
|
|
451
|
+
readonly nodeBypasses: Held[];
|
|
452
|
+
readonly edgeBypasses: Held[];
|
|
453
|
+
readonly cartesianLayout: Held[];
|
|
454
|
+
/** Opaque aspects (including the style rules), verbatim, in order of first appearance. */
|
|
455
|
+
readonly opaque: Map<string, unknown[]>;
|
|
456
|
+
readonly structure: CxStructure;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* The inexact-literal bits of an element.
|
|
461
|
+
* @param text - the element's JSON text
|
|
462
|
+
* @param keys - the id keys to check, bit 1, 2, 4 in order
|
|
463
|
+
* @returns the bits
|
|
464
|
+
*/
|
|
465
|
+
function inexactBits(text: string, keys: readonly string[]): number {
|
|
466
|
+
if (!/[0-9][.eE]/.test(text)) {
|
|
467
|
+
return 0;
|
|
468
|
+
}
|
|
469
|
+
let bits = 0;
|
|
470
|
+
keys.forEach((key, i) => {
|
|
471
|
+
if (inexactLiteral(text, key)) {
|
|
472
|
+
bits |= 1 << i;
|
|
473
|
+
}
|
|
474
|
+
});
|
|
475
|
+
return bits;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Read the whole document through the streaming scanner, checking its structure.
|
|
480
|
+
* @param input - the input
|
|
481
|
+
* @param report - the report
|
|
482
|
+
* @param options - the resolved options
|
|
483
|
+
* @returns what was read
|
|
484
|
+
*/
|
|
485
|
+
async function readDocument(
|
|
486
|
+
input: ImportInput,
|
|
487
|
+
report: ImportReportBuilder,
|
|
488
|
+
options: ResolvedImportOptions,
|
|
489
|
+
): Promise<Cx2Document> {
|
|
490
|
+
const structure = new CxStructure(report);
|
|
491
|
+
const doc: Cx2Document = {
|
|
492
|
+
version: "",
|
|
493
|
+
hasFragments: false,
|
|
494
|
+
nodes: [],
|
|
495
|
+
edges: [],
|
|
496
|
+
declarations: [],
|
|
497
|
+
networkAttributes: [],
|
|
498
|
+
nodeBypasses: [],
|
|
499
|
+
edgeBypasses: [],
|
|
500
|
+
cartesianLayout: [],
|
|
501
|
+
opaque: new Map(),
|
|
502
|
+
structure,
|
|
503
|
+
};
|
|
504
|
+
let descriptor = false;
|
|
505
|
+
let elementsSeen = false;
|
|
506
|
+
let sinceCheck = 0;
|
|
507
|
+
const collect = (aspect: string, value: unknown, text: string, line: number, exact: boolean): void => {
|
|
508
|
+
structure.element(aspect, value, line);
|
|
509
|
+
switch (aspect) {
|
|
510
|
+
case "nodes":
|
|
511
|
+
elementsSeen = true;
|
|
512
|
+
doc.nodes.push({ value, line, inexact: inexactBits(text, ["id"]) });
|
|
513
|
+
return;
|
|
514
|
+
case "edges":
|
|
515
|
+
elementsSeen = true;
|
|
516
|
+
doc.edges.push({ value, line, inexact: inexactBits(text, ["id", "s", "t"]) });
|
|
517
|
+
return;
|
|
518
|
+
case "attributeDeclarations":
|
|
519
|
+
if (elementsSeen) {
|
|
520
|
+
report.warnOnce(
|
|
521
|
+
"validation-error",
|
|
522
|
+
ASPECT_ORDER_CODE,
|
|
523
|
+
"attributeDeclarations comes after the elements it types; the declarations are applied to them",
|
|
524
|
+
{ line, element: aspect },
|
|
525
|
+
`${ASPECT_ORDER_CODE}:declarations`,
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
doc.declarations.push({ value: plainJson(value), line, inexact: 0 });
|
|
529
|
+
return;
|
|
530
|
+
case "networkAttributes":
|
|
531
|
+
elementsSeen = true;
|
|
532
|
+
doc.networkAttributes.push({ value, line, inexact: 0 });
|
|
533
|
+
return;
|
|
534
|
+
case "nodeBypasses":
|
|
535
|
+
doc.nodeBypasses.push({ value, line, inexact: 0 });
|
|
536
|
+
return;
|
|
537
|
+
case "edgeBypasses":
|
|
538
|
+
doc.edgeBypasses.push({ value, line, inexact: 0 });
|
|
539
|
+
return;
|
|
540
|
+
case "cartesianLayout":
|
|
541
|
+
doc.cartesianLayout.push({ value, line, inexact: 0 });
|
|
542
|
+
return;
|
|
543
|
+
default:
|
|
544
|
+
if (STRUCTURE_ASPECTS.has(aspect)) {
|
|
545
|
+
return;
|
|
546
|
+
}
|
|
547
|
+
if (!doc.opaque.has(aspect)) {
|
|
548
|
+
doc.opaque.set(aspect, []);
|
|
549
|
+
}
|
|
550
|
+
doc.opaque.get(aspect)?.push(exact ? plainJson(value, () => undefined) : value);
|
|
551
|
+
}
|
|
552
|
+
};
|
|
553
|
+
try {
|
|
554
|
+
for await (const event of scanAspects(textChunks(input, report, options))) {
|
|
555
|
+
if (++sinceCheck >= ABORT_CHECK_INTERVAL) {
|
|
556
|
+
sinceCheck = 0;
|
|
557
|
+
throwIfAborted(options.signal);
|
|
558
|
+
}
|
|
559
|
+
switch (event.kind) {
|
|
560
|
+
case "root":
|
|
561
|
+
report.fail(
|
|
562
|
+
CX2_ISSUE.NO_DESCRIPTOR,
|
|
563
|
+
`a CX2 document is a JSON array; found ${isRecord(event.value) ? "an object" : shown(event.value)}`,
|
|
564
|
+
{ line: event.line },
|
|
565
|
+
);
|
|
566
|
+
break;
|
|
567
|
+
case "member":
|
|
568
|
+
if (!descriptor) {
|
|
569
|
+
readDescriptor(event.value, event.line, doc, report);
|
|
570
|
+
descriptor = true;
|
|
571
|
+
break;
|
|
572
|
+
}
|
|
573
|
+
memberAspect(event.value, event.line, structure, report, (aspect, value) => {
|
|
574
|
+
collect(aspect, value, JSON.stringify(value) ?? "", event.line, event.exact);
|
|
575
|
+
});
|
|
576
|
+
break;
|
|
577
|
+
case "block":
|
|
578
|
+
if (!descriptor) {
|
|
579
|
+
report.fail(
|
|
580
|
+
CX2_ISSUE.NO_DESCRIPTOR,
|
|
581
|
+
`the document starts with a "${event.aspect}" block, not a descriptor with CXVersion${event.aspect === "numberVerification" || event.aspect === "metaData" ? "; this looks like CX version 1 (read it with the cx importer)" : ""}`,
|
|
582
|
+
{ line: event.line },
|
|
583
|
+
);
|
|
584
|
+
}
|
|
585
|
+
structure.block(event.aspect, event.line);
|
|
586
|
+
break;
|
|
587
|
+
case "element":
|
|
588
|
+
collect(event.aspect, event.value, event.text, event.line, event.exact);
|
|
589
|
+
break;
|
|
590
|
+
case "deep":
|
|
591
|
+
reportTooDeep(report, event);
|
|
592
|
+
if (!descriptor) {
|
|
593
|
+
readDescriptor(null, event.line, doc, report);
|
|
594
|
+
}
|
|
595
|
+
break;
|
|
596
|
+
case "extraKeys":
|
|
597
|
+
report.error(
|
|
598
|
+
"parse-error",
|
|
599
|
+
BAD_ASPECT_BLOCK_CODE,
|
|
600
|
+
`the "${event.aspect}" block holds more keys (${event.keys.join(", ")}); an aspect block has one key, the others are skipped`,
|
|
601
|
+
{ line: event.line, element: event.aspect },
|
|
602
|
+
);
|
|
603
|
+
break;
|
|
604
|
+
default:
|
|
605
|
+
break;
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
} catch (err) {
|
|
609
|
+
if (err instanceof JsonScanError) {
|
|
610
|
+
report.fail(err.empty ? EMPTY_INPUT_CODE : SYNTAX_CODE, err.message, { line: err.line });
|
|
611
|
+
}
|
|
612
|
+
throw err;
|
|
613
|
+
}
|
|
614
|
+
if (!descriptor) {
|
|
615
|
+
report.fail(CX2_ISSUE.NO_DESCRIPTOR, "the document is an empty array; a CX2 document starts with a descriptor");
|
|
616
|
+
}
|
|
617
|
+
return doc;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
* Check the descriptor, the first member of the array.
|
|
622
|
+
* @param value - the member
|
|
623
|
+
* @param line - its line
|
|
624
|
+
* @param doc - the document, which receives the version and the fragment flag
|
|
625
|
+
* @param report - the report
|
|
626
|
+
*/
|
|
627
|
+
function readDescriptor(value: unknown, line: number, doc: Cx2Document, report: ImportReportBuilder): void {
|
|
628
|
+
if (!isRecord(value) || !("CXVersion" in value)) {
|
|
629
|
+
const keys = isRecord(value) ? Object.keys(value) : [];
|
|
630
|
+
report.fail(
|
|
631
|
+
CX2_ISSUE.NO_DESCRIPTOR,
|
|
632
|
+
`the first member of the array is not a descriptor with CXVersion${keys.length > 0 ? ` (keys: ${keys.join(", ")})` : ""}`,
|
|
633
|
+
{ line },
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
const raw = value.CXVersion;
|
|
637
|
+
const text = typeof raw === "string" || typeof raw === "number" ? String(raw) : "";
|
|
638
|
+
const match = /^([0-9]+)(?:\.([0-9]+))?$/.exec(text);
|
|
639
|
+
const major = match === null ? NaN : Number(match[1]);
|
|
640
|
+
if (major === 1) {
|
|
641
|
+
report.fail(
|
|
642
|
+
CX2_ISSUE.VERSION,
|
|
643
|
+
`CXVersion ${shown(raw)} is CX version 1, a different document shape; read it with the cx importer`,
|
|
644
|
+
{ line, element: "CXVersion" },
|
|
645
|
+
);
|
|
646
|
+
}
|
|
647
|
+
if (major !== 2) {
|
|
648
|
+
report.fail(CX2_ISSUE.VERSION, `CXVersion ${shown(raw)} is not a CX2 version`, { line, element: "CXVersion" });
|
|
649
|
+
}
|
|
650
|
+
doc.version = text;
|
|
651
|
+
if (raw !== "2.0") {
|
|
652
|
+
report.warning(
|
|
653
|
+
"validation-error",
|
|
654
|
+
CX2_ISSUE.MINOR_VERSION,
|
|
655
|
+
`CXVersion ${shown(raw)} is not "2.0"; the document is read as CX2 2.0`,
|
|
656
|
+
{ line, element: "CXVersion" },
|
|
657
|
+
);
|
|
658
|
+
}
|
|
659
|
+
for (const key of Object.keys(value)) {
|
|
660
|
+
if (key === "hasFragments") {
|
|
661
|
+
if (typeof value.hasFragments === "boolean") {
|
|
662
|
+
doc.hasFragments = value.hasFragments;
|
|
663
|
+
} else {
|
|
664
|
+
report.error(
|
|
665
|
+
"validation-error",
|
|
666
|
+
BAD_VALUE_CODE,
|
|
667
|
+
`hasFragments is ${shown(value.hasFragments)}, not a boolean; read as false`,
|
|
668
|
+
{ line, element: "hasFragments" },
|
|
669
|
+
);
|
|
670
|
+
}
|
|
671
|
+
} else if (key !== "CXVersion") {
|
|
672
|
+
report.warning("validation-error", UNKNOWN_ELEMENT_CODE, `the descriptor key "${key}" is not defined`, {
|
|
673
|
+
line,
|
|
674
|
+
element: key,
|
|
675
|
+
});
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
/**
|
|
681
|
+
* Read a member that is not a streamed block: a one-key object holding an object is a
|
|
682
|
+
* one-element aspect (`{"ndexStatus": {...}}`); anything else is E_BAD_ASPECT_BLOCK.
|
|
683
|
+
* @param value - the member
|
|
684
|
+
* @param line - its line
|
|
685
|
+
* @param structure - the structure checker
|
|
686
|
+
* @param report - the report
|
|
687
|
+
* @param collect - receives the aspect name and its one element
|
|
688
|
+
*/
|
|
689
|
+
function memberAspect(
|
|
690
|
+
value: unknown,
|
|
691
|
+
line: number,
|
|
692
|
+
structure: CxStructure,
|
|
693
|
+
report: ImportReportBuilder,
|
|
694
|
+
collect: (aspect: string, value: unknown) => void,
|
|
695
|
+
): void {
|
|
696
|
+
const keys = isRecord(value) ? Object.keys(value) : [];
|
|
697
|
+
if (isRecord(value) && keys.length === 1 && isRecord(value[keys[0]])) {
|
|
698
|
+
structure.block(keys[0], line);
|
|
699
|
+
collect(keys[0], value[keys[0]]);
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
report.error(
|
|
703
|
+
"parse-error",
|
|
704
|
+
BAD_ASPECT_BLOCK_CODE,
|
|
705
|
+
`a member of the document is ${keys.length > 1 ? `an object with ${keys.length} keys (${keys.join(", ")})` : shown(value)}, not a one-key aspect block; skipped`,
|
|
706
|
+
{ line, element: keys[0] ?? null },
|
|
707
|
+
);
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
// ============================================================ declarations
|
|
711
|
+
|
|
712
|
+
/** One declared attribute. */
|
|
713
|
+
interface Declaration {
|
|
714
|
+
readonly name: string;
|
|
715
|
+
/** The type: declared, or inferred from the values (null: nested or mixed values, a json column). */
|
|
716
|
+
type: Cx2Type | null;
|
|
717
|
+
alias: string | null;
|
|
718
|
+
hasDefault: boolean;
|
|
719
|
+
defaultValue: unknown;
|
|
720
|
+
/** The default as written, while the type is still to be inferred (a type CX2 does not define). */
|
|
721
|
+
readonly rawDefault: unknown;
|
|
722
|
+
handle: ColumnHandle;
|
|
723
|
+
/** Whether the file does not declare the attribute (W_CX2_UNDECLARED_ATTRIBUTE). */
|
|
724
|
+
readonly undeclared: boolean;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/**
|
|
728
|
+
* The scalar kind of one value for inference.
|
|
729
|
+
* @param value - the value
|
|
730
|
+
* @returns a CX2 scalar, or null for anything else
|
|
731
|
+
*/
|
|
732
|
+
function scalarKind(value: unknown): Cx2Scalar | null {
|
|
733
|
+
switch (typeof value) {
|
|
734
|
+
case "boolean":
|
|
735
|
+
return "boolean";
|
|
736
|
+
case "string":
|
|
737
|
+
return "string";
|
|
738
|
+
case "number":
|
|
739
|
+
return Number.isInteger(value) && value >= I32_MIN && value <= I32_MAX ? "integer" : "double";
|
|
740
|
+
default:
|
|
741
|
+
return value instanceof ExactInteger ? "double" : null;
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/** What inference has seen of an attribute's values so far. */
|
|
746
|
+
interface Seen {
|
|
747
|
+
/** The scalar kind so far (undefined: nothing yet, null: no CX2 type fits). */
|
|
748
|
+
kind: Cx2Scalar | null | undefined;
|
|
749
|
+
/** Whether the values are lists (undefined: nothing yet). */
|
|
750
|
+
list: boolean | undefined;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/**
|
|
754
|
+
* Widen two scalar kinds: integer and double give double; anything else that differs has no
|
|
755
|
+
* CX2 type.
|
|
756
|
+
* @param a - the kind so far
|
|
757
|
+
* @param b - a new kind
|
|
758
|
+
* @returns the widened kind
|
|
759
|
+
*/
|
|
760
|
+
function widenKind(a: Cx2Scalar | null | undefined, b: Cx2Scalar | null): Cx2Scalar | null {
|
|
761
|
+
if (a === undefined || a === b) {
|
|
762
|
+
return b;
|
|
763
|
+
}
|
|
764
|
+
if (a === null || b === null) {
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
767
|
+
return (a === "integer" && b === "double") || (a === "double" && b === "integer") ? "double" : null;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* Observe one value of an undeclared attribute.
|
|
772
|
+
* @param seen - the state
|
|
773
|
+
* @param value - the value (never null)
|
|
774
|
+
*/
|
|
775
|
+
function observe(seen: Seen, value: unknown): void {
|
|
776
|
+
const list = Array.isArray(value);
|
|
777
|
+
if (seen.list !== undefined && seen.list !== list) {
|
|
778
|
+
seen.kind = null;
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
seen.list = list;
|
|
782
|
+
if (!list) {
|
|
783
|
+
seen.kind = widenKind(seen.kind, scalarKind(value));
|
|
784
|
+
return;
|
|
785
|
+
}
|
|
786
|
+
for (const item of value as unknown[]) {
|
|
787
|
+
seen.kind = widenKind(seen.kind, item === null || Array.isArray(item) ? null : scalarKind(item));
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/**
|
|
792
|
+
* The CX2 type inference settles on.
|
|
793
|
+
* @param seen - the state
|
|
794
|
+
* @returns the type, or null for a json column
|
|
795
|
+
*/
|
|
796
|
+
function inferredType(seen: Seen): Cx2Type | null {
|
|
797
|
+
if (seen.kind === null) {
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
const scalar = seen.kind ?? "string";
|
|
801
|
+
return cx2Type(seen.list === true ? `list_of_${scalar}` : scalar);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/** The declarations of one table. */
|
|
805
|
+
class DeclarationTable {
|
|
806
|
+
readonly byName = new Map<string, Declaration>();
|
|
807
|
+
|
|
808
|
+
readonly byAlias = new Map<string, Declaration>();
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* The declaration a key of an element's `v` names.
|
|
812
|
+
* @param key - the key
|
|
813
|
+
* @returns the declaration and whether the key was its alias, or null
|
|
814
|
+
*/
|
|
815
|
+
resolve(key: string): { readonly decl: Declaration; readonly viaAlias: boolean } | null {
|
|
816
|
+
const viaAlias = this.byAlias.get(key);
|
|
817
|
+
if (viaAlias !== undefined) {
|
|
818
|
+
return { decl: viaAlias, viaAlias: true };
|
|
819
|
+
}
|
|
820
|
+
const decl = this.byName.get(key);
|
|
821
|
+
return decl === undefined ? null : { decl, viaAlias: false };
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** The three declaration tables of CX2 and what the declarations element holds besides. */
|
|
826
|
+
interface Declarations {
|
|
827
|
+
readonly node: DeclarationTable;
|
|
828
|
+
readonly edge: DeclarationTable;
|
|
829
|
+
readonly network: DeclarationTable;
|
|
830
|
+
/** Declarations for opaque aspects, kept verbatim. */
|
|
831
|
+
readonly other: Record<string, unknown>;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
/** The domain key of attributeDeclarations for each table. */
|
|
835
|
+
const DECLARATION_KEYS = { nodes: "node", edges: "edge", networkAttributes: "network" } as const;
|
|
836
|
+
|
|
837
|
+
// ============================================================ the build
|
|
838
|
+
|
|
839
|
+
/** Reads a collected document into a sink. */
|
|
840
|
+
class Cx2Reader {
|
|
841
|
+
private readonly sink: GraphSink;
|
|
842
|
+
|
|
843
|
+
private readonly report: ImportReportBuilder;
|
|
844
|
+
|
|
845
|
+
private readonly options: ResolvedImportOptions;
|
|
846
|
+
|
|
847
|
+
private readonly zAs: "column" | "position";
|
|
848
|
+
|
|
849
|
+
private readonly doc: Cx2Document;
|
|
850
|
+
|
|
851
|
+
private readonly direction: DirectionResolver;
|
|
852
|
+
|
|
853
|
+
private readonly coercer: IdCoercer;
|
|
854
|
+
|
|
855
|
+
private readonly decls: Declarations = {
|
|
856
|
+
node: new DeclarationTable(),
|
|
857
|
+
edge: new DeclarationTable(),
|
|
858
|
+
network: new DeclarationTable(),
|
|
859
|
+
other: {},
|
|
860
|
+
};
|
|
861
|
+
|
|
862
|
+
/** CX node id -> sink node index. */
|
|
863
|
+
private readonly nodeRows = new Map<NodeId, number>();
|
|
864
|
+
|
|
865
|
+
/** CX node id -> the id the sink holds it under (differs when a mangled id is restored). */
|
|
866
|
+
private readonly sinkIds = new Map<NodeId, NodeId>();
|
|
867
|
+
|
|
868
|
+
/** CX edge id -> sink edge index. */
|
|
869
|
+
private readonly edgeRows = new Map<NodeId, number>();
|
|
870
|
+
|
|
871
|
+
private positionHandle: ColumnHandle = INVALID_INDEX as ColumnHandle;
|
|
872
|
+
|
|
873
|
+
private zHandle: ColumnHandle = INVALID_INDEX as ColumnHandle;
|
|
874
|
+
|
|
875
|
+
private edgeIdHandle: ColumnHandle = INVALID_INDEX as ColumnHandle;
|
|
876
|
+
|
|
877
|
+
/** The rows that have a position (a set: a duplicate node may carry coordinates twice). */
|
|
878
|
+
private readonly positioned = new Set<number>();
|
|
879
|
+
|
|
880
|
+
private sinceCheck = 0;
|
|
881
|
+
|
|
882
|
+
private idType: "integer" | "mixed" | "string" = "integer";
|
|
883
|
+
|
|
884
|
+
private weighted = false;
|
|
885
|
+
|
|
886
|
+
private readonly dangling = new Map<string, number>();
|
|
887
|
+
|
|
888
|
+
private readonly point: [number, number, number] = [0, 0, 0];
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* Create the reader.
|
|
892
|
+
* @param sink - the sink
|
|
893
|
+
* @param report - the report
|
|
894
|
+
* @param options - the resolved options
|
|
895
|
+
* @param zAs - where z goes
|
|
896
|
+
* @param doc - the collected document
|
|
897
|
+
*/
|
|
898
|
+
constructor(
|
|
899
|
+
sink: GraphSink,
|
|
900
|
+
report: ImportReportBuilder,
|
|
901
|
+
options: ResolvedImportOptions,
|
|
902
|
+
zAs: "column" | "position",
|
|
903
|
+
doc: Cx2Document,
|
|
904
|
+
) {
|
|
905
|
+
this.sink = sink;
|
|
906
|
+
this.report = report;
|
|
907
|
+
this.options = options;
|
|
908
|
+
this.zAs = zAs;
|
|
909
|
+
this.doc = doc;
|
|
910
|
+
this.direction = new DirectionResolver(sink, report, options.onMixedDirection);
|
|
911
|
+
this.coercer = new IdCoercer(options.ids);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/** Build the graph. */
|
|
915
|
+
build(): void {
|
|
916
|
+
const { doc, report } = this;
|
|
917
|
+
this.direction.setHeader(true);
|
|
918
|
+
this.readDeclarations();
|
|
919
|
+
this.declareColumns();
|
|
920
|
+
this.sink.reserve(doc.nodes.length, doc.edges.length);
|
|
921
|
+
for (const held of doc.nodes) {
|
|
922
|
+
this.readNode(held);
|
|
923
|
+
}
|
|
924
|
+
this.readLegacyLayout();
|
|
925
|
+
if (this.positioned.size > 0 && this.positioned.size < this.nodeRows.size) {
|
|
926
|
+
report.warnOnce(
|
|
927
|
+
"missing-value",
|
|
928
|
+
CX2_ISSUE.PARTIAL_LAYOUT,
|
|
929
|
+
`${this.nodeRows.size - this.positioned.size} of ${this.nodeRows.size} node(s) have no coordinates; their positions are unset`,
|
|
930
|
+
{ element: "nodes" },
|
|
931
|
+
);
|
|
932
|
+
}
|
|
933
|
+
for (const held of doc.edges) {
|
|
934
|
+
this.readEdge(held);
|
|
935
|
+
}
|
|
936
|
+
this.readNetworkAttributes();
|
|
937
|
+
this.readBypasses("node", doc.nodeBypasses);
|
|
938
|
+
this.readBypasses("edge", doc.edgeBypasses);
|
|
939
|
+
for (const [kind, count] of this.dangling) {
|
|
940
|
+
report.warning(
|
|
941
|
+
"validation-error",
|
|
942
|
+
DANGLING_REFERENCE_CODE,
|
|
943
|
+
`${count} ${kind} name(s) no element; ignored`,
|
|
944
|
+
{ element: kind },
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
this.checkAspects();
|
|
948
|
+
this.setMeta();
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// ------------------------------------------------------------ declarations
|
|
952
|
+
|
|
953
|
+
/** Merge every attributeDeclarations element (fragments in order) into the tables. */
|
|
954
|
+
private readDeclarations(): void {
|
|
955
|
+
const { report } = this;
|
|
956
|
+
for (const held of this.doc.declarations) {
|
|
957
|
+
if (!isRecord(held.value)) {
|
|
958
|
+
report.error(
|
|
959
|
+
"parse-error",
|
|
960
|
+
BAD_ASPECT_BLOCK_CODE,
|
|
961
|
+
"an attributeDeclarations element is not an object",
|
|
962
|
+
{
|
|
963
|
+
line: held.line,
|
|
964
|
+
element: "attributeDeclarations",
|
|
965
|
+
},
|
|
966
|
+
);
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
for (const [key, table] of Object.entries(held.value)) {
|
|
970
|
+
const domain = DECLARATION_KEYS[key as keyof typeof DECLARATION_KEYS] as
|
|
971
|
+
| "node"
|
|
972
|
+
| "edge"
|
|
973
|
+
| "network"
|
|
974
|
+
| undefined;
|
|
975
|
+
if (domain === undefined) {
|
|
976
|
+
this.decls.other[key] = table;
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
if (!isRecord(table)) {
|
|
980
|
+
report.error(
|
|
981
|
+
"validation-error",
|
|
982
|
+
BAD_VALUE_CODE,
|
|
983
|
+
`the ${key} declarations are not an object; ignored`,
|
|
984
|
+
{ line: held.line, element: key },
|
|
985
|
+
);
|
|
986
|
+
continue;
|
|
987
|
+
}
|
|
988
|
+
for (const [name, decl] of Object.entries(table)) {
|
|
989
|
+
this.declare(domain, name, decl, held.line);
|
|
990
|
+
}
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
for (const domain of ["node", "edge"] as const) {
|
|
994
|
+
this.checkAliases(domain);
|
|
995
|
+
}
|
|
996
|
+
this.inferUndeclared("node", this.doc.nodes);
|
|
997
|
+
this.inferUndeclared("edge", this.doc.edges);
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* Type the attributes no declaration types (undeclared, or declared with a type CX2 does not
|
|
1002
|
+
* define) from their values, so every attribute column is declared with its CX2 type.
|
|
1003
|
+
* @param domain - node or edge
|
|
1004
|
+
* @param elements - the elements
|
|
1005
|
+
*/
|
|
1006
|
+
private inferUndeclared(domain: "node" | "edge", elements: readonly Held[]): void {
|
|
1007
|
+
const table = this.decls[domain];
|
|
1008
|
+
const seen = new Map<string, Seen>();
|
|
1009
|
+
for (const { value } of elements) {
|
|
1010
|
+
if (!isRecord(value) || !isRecord(value.v)) {
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
for (const [key, raw] of Object.entries(value.v)) {
|
|
1014
|
+
const resolved = table.resolve(key);
|
|
1015
|
+
if ((resolved !== null && resolved.decl.type !== null) || raw === null || raw === undefined) {
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
const name = resolved?.decl.name ?? key;
|
|
1019
|
+
let state = seen.get(name);
|
|
1020
|
+
if (state === undefined) {
|
|
1021
|
+
state = { kind: undefined, list: undefined };
|
|
1022
|
+
seen.set(name, state);
|
|
1023
|
+
}
|
|
1024
|
+
observe(state, raw);
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
for (const [name, state] of seen) {
|
|
1028
|
+
const type = inferredType(state);
|
|
1029
|
+
const decl = table.byName.get(name);
|
|
1030
|
+
if (decl !== undefined) {
|
|
1031
|
+
decl.type = type;
|
|
1032
|
+
if (decl.hasDefault && type !== null) {
|
|
1033
|
+
// the default of a type CX2 does not define, converted now the values typed the column
|
|
1034
|
+
decl.defaultValue = this.convertDefault(name, decl.rawDefault, type, { element: name });
|
|
1035
|
+
decl.hasDefault = decl.defaultValue !== BAD;
|
|
1036
|
+
}
|
|
1037
|
+
continue;
|
|
1038
|
+
}
|
|
1039
|
+
table.byName.set(name, {
|
|
1040
|
+
name,
|
|
1041
|
+
type,
|
|
1042
|
+
alias: null,
|
|
1043
|
+
hasDefault: false,
|
|
1044
|
+
defaultValue: undefined,
|
|
1045
|
+
rawDefault: undefined,
|
|
1046
|
+
handle: INVALID_INDEX as ColumnHandle,
|
|
1047
|
+
undeclared: true,
|
|
1048
|
+
});
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* Add one declaration to a table.
|
|
1054
|
+
* @param domain - the table
|
|
1055
|
+
* @param name - the attribute name
|
|
1056
|
+
* @param raw - the declaration object
|
|
1057
|
+
* @param line - its line
|
|
1058
|
+
*/
|
|
1059
|
+
private declare(domain: "node" | "edge" | "network", name: string, raw: unknown, line: number): void {
|
|
1060
|
+
const { report } = this;
|
|
1061
|
+
const table = this.decls[domain];
|
|
1062
|
+
const where = { line, element: name };
|
|
1063
|
+
if (!isRecord(raw)) {
|
|
1064
|
+
report.error(
|
|
1065
|
+
"validation-error",
|
|
1066
|
+
BAD_VALUE_CODE,
|
|
1067
|
+
`the declaration of "${name}" is not an object; ignored`,
|
|
1068
|
+
where,
|
|
1069
|
+
);
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
let type: Cx2Type | null;
|
|
1073
|
+
if (raw.d === undefined) {
|
|
1074
|
+
type = cx2Type("string");
|
|
1075
|
+
report.warning(
|
|
1076
|
+
"unsupported",
|
|
1077
|
+
UNKNOWN_ATTR_TYPE_CODE,
|
|
1078
|
+
`the declaration of "${name}" has no type; read as string (the Java reader's rule)`,
|
|
1079
|
+
where,
|
|
1080
|
+
);
|
|
1081
|
+
} else {
|
|
1082
|
+
type = typeof raw.d === "string" ? cx2Type(raw.d) : null;
|
|
1083
|
+
if (type === null) {
|
|
1084
|
+
report.warning(
|
|
1085
|
+
"unsupported",
|
|
1086
|
+
UNKNOWN_ATTR_TYPE_CODE,
|
|
1087
|
+
`the type ${shown(raw.d)} of "${name}" is not a CX2 type; the column is inferred from its values`,
|
|
1088
|
+
where,
|
|
1089
|
+
);
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
const existing = table.byName.get(name);
|
|
1093
|
+
if (existing !== undefined) {
|
|
1094
|
+
if (existing.type?.d !== type?.d) {
|
|
1095
|
+
report.error(
|
|
1096
|
+
"validation-error",
|
|
1097
|
+
CX2_ISSUE.DECLARATION_CONFLICT,
|
|
1098
|
+
`"${name}" is declared ${existing.type?.d ?? "untyped"} and again ${type?.d ?? "untyped"}; the first declaration wins`,
|
|
1099
|
+
where,
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
let alias = typeof raw.a === "string" ? raw.a : null;
|
|
1105
|
+
let hasDefault = raw.v !== undefined && raw.v !== null;
|
|
1106
|
+
if (domain === "network" && (raw.a !== undefined || raw.v !== undefined)) {
|
|
1107
|
+
report.warning(
|
|
1108
|
+
"validation-error",
|
|
1109
|
+
CX2_ISSUE.NETWORK_DECLARATION,
|
|
1110
|
+
`the network attribute "${name}" declares an alias or a default, which CX2 forbids; ignored`,
|
|
1111
|
+
where,
|
|
1112
|
+
);
|
|
1113
|
+
alias = null;
|
|
1114
|
+
hasDefault = false;
|
|
1115
|
+
}
|
|
1116
|
+
let defaultValue: unknown = undefined;
|
|
1117
|
+
if (hasDefault && type !== null) {
|
|
1118
|
+
defaultValue = this.convertDefault(name, raw.v, type, where);
|
|
1119
|
+
hasDefault = defaultValue !== BAD;
|
|
1120
|
+
}
|
|
1121
|
+
table.byName.set(name, {
|
|
1122
|
+
name,
|
|
1123
|
+
type,
|
|
1124
|
+
alias,
|
|
1125
|
+
hasDefault,
|
|
1126
|
+
defaultValue,
|
|
1127
|
+
rawDefault: hasDefault && type === null ? plainJson(raw.v) : undefined,
|
|
1128
|
+
handle: INVALID_INDEX as ColumnHandle,
|
|
1129
|
+
undeclared: false,
|
|
1130
|
+
});
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Convert a declared default; a numeric string for a numeric type (the specification's own
|
|
1135
|
+
* `"v": "0.0"`) is coerced, anything else that does not match drops the default.
|
|
1136
|
+
* @param name - the attribute
|
|
1137
|
+
* @param value - the default
|
|
1138
|
+
* @param type - its type
|
|
1139
|
+
* @param where - the location
|
|
1140
|
+
* @param where.line - the line
|
|
1141
|
+
* @param where.element - the attribute
|
|
1142
|
+
* @returns the default, or BAD when dropped
|
|
1143
|
+
*/
|
|
1144
|
+
private convertDefault(
|
|
1145
|
+
name: string,
|
|
1146
|
+
value: unknown,
|
|
1147
|
+
type: Cx2Type,
|
|
1148
|
+
where: { line?: number; element: string },
|
|
1149
|
+
): unknown {
|
|
1150
|
+
const converted = convertValue(value, type, this.options.long, () => undefined);
|
|
1151
|
+
if (converted !== BAD) {
|
|
1152
|
+
return converted;
|
|
1153
|
+
}
|
|
1154
|
+
const numeric = type.scalar === "double" || type.scalar === "integer" || type.scalar === "long";
|
|
1155
|
+
if (
|
|
1156
|
+
!type.list &&
|
|
1157
|
+
numeric &&
|
|
1158
|
+
typeof value === "string" &&
|
|
1159
|
+
value.trim() !== "" &&
|
|
1160
|
+
Number.isFinite(Number(value))
|
|
1161
|
+
) {
|
|
1162
|
+
const coerced = convertValue(Number(value), type, this.options.long, () => undefined);
|
|
1163
|
+
if (coerced !== BAD) {
|
|
1164
|
+
this.report.warning(
|
|
1165
|
+
"validation-error",
|
|
1166
|
+
BAD_DEFAULT_CODE,
|
|
1167
|
+
`the default ${shown(value)} of "${name}" is a string, not a ${type.d}; read as ${shown(coerced)}`,
|
|
1168
|
+
where,
|
|
1169
|
+
);
|
|
1170
|
+
return coerced;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
this.report.warning(
|
|
1174
|
+
"validation-error",
|
|
1175
|
+
BAD_DEFAULT_CODE,
|
|
1176
|
+
`the default ${shown(value)} of "${name}" is not a ${type.d}; the column has no default`,
|
|
1177
|
+
where,
|
|
1178
|
+
);
|
|
1179
|
+
return BAD;
|
|
1180
|
+
}
|
|
1181
|
+
|
|
1182
|
+
/**
|
|
1183
|
+
* Check the aliases of a table in declaration order: an alias that is another attribute's
|
|
1184
|
+
* name or another attribute's alias is E_CX2_ALIAS_CONFLICT and ignored.
|
|
1185
|
+
* @param domain - the table
|
|
1186
|
+
*/
|
|
1187
|
+
private checkAliases(domain: "node" | "edge"): void {
|
|
1188
|
+
const table = this.decls[domain];
|
|
1189
|
+
for (const decl of table.byName.values()) {
|
|
1190
|
+
const { alias } = decl;
|
|
1191
|
+
if (alias === null || alias === decl.name) {
|
|
1192
|
+
if (alias !== null) {
|
|
1193
|
+
table.byAlias.set(alias, decl);
|
|
1194
|
+
}
|
|
1195
|
+
continue;
|
|
1196
|
+
}
|
|
1197
|
+
const other = table.byName.get(alias) ?? table.byAlias.get(alias);
|
|
1198
|
+
if (other !== undefined) {
|
|
1199
|
+
this.report.error(
|
|
1200
|
+
"validation-error",
|
|
1201
|
+
CX2_ISSUE.ALIAS_CONFLICT,
|
|
1202
|
+
`the alias "${alias}" of the ${domain} attribute "${decl.name}" is also ${other.name === alias ? "an attribute's name" : `the alias of "${other.name}"`}; the alias is ignored`,
|
|
1203
|
+
{ element: decl.name },
|
|
1204
|
+
);
|
|
1205
|
+
decl.alias = null;
|
|
1206
|
+
continue;
|
|
1207
|
+
}
|
|
1208
|
+
table.byAlias.set(alias, decl);
|
|
1209
|
+
}
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/** Declare the declared node and edge columns. */
|
|
1213
|
+
private declareColumns(): void {
|
|
1214
|
+
const restore = this.options.restoreMangledIds;
|
|
1215
|
+
for (const domain of ["node", "edge"] as const) {
|
|
1216
|
+
for (const decl of this.decls[domain].byName.values()) {
|
|
1217
|
+
if (domain === "node" && restore && decl.name === ORIGINAL_ID_ATTRIBUTE) {
|
|
1218
|
+
continue;
|
|
1219
|
+
}
|
|
1220
|
+
if (domain === "edge" && decl.name === this.options.weightFrom) {
|
|
1221
|
+
continue;
|
|
1222
|
+
}
|
|
1223
|
+
const column: ColumnDecl =
|
|
1224
|
+
decl.type === null
|
|
1225
|
+
? {
|
|
1226
|
+
name: decl.name,
|
|
1227
|
+
dtype: "json",
|
|
1228
|
+
nullable: true,
|
|
1229
|
+
origin: { format: CX2_FORMAT, id: decl.alias },
|
|
1230
|
+
}
|
|
1231
|
+
: {
|
|
1232
|
+
...this.columnDecl(decl.type),
|
|
1233
|
+
name: decl.name,
|
|
1234
|
+
origin: { format: CX2_FORMAT, id: decl.alias, type: decl.type.d },
|
|
1235
|
+
};
|
|
1236
|
+
if (decl.hasDefault) {
|
|
1237
|
+
// a type nothing settled (no values) keeps its default as written, in the json column
|
|
1238
|
+
column.default = decl.type === null ? decl.rawDefault : decl.defaultValue;
|
|
1239
|
+
}
|
|
1240
|
+
if (domain === "node" && decl.name === "name") {
|
|
1241
|
+
column.role = "label";
|
|
1242
|
+
}
|
|
1243
|
+
decl.handle = declareResolved(this.sink, domain, column, this.report).handle;
|
|
1244
|
+
}
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* The edge id column, declared with the first edge.
|
|
1250
|
+
* @returns the handle
|
|
1251
|
+
*/
|
|
1252
|
+
private edgeIdColumn(): ColumnHandle {
|
|
1253
|
+
if (this.edgeIdHandle === INVALID_INDEX) {
|
|
1254
|
+
const taken = (name: string): boolean => this.sink.edgeColumn(name) !== INVALID_INDEX;
|
|
1255
|
+
this.edgeIdHandle = declareResolved(
|
|
1256
|
+
this.sink,
|
|
1257
|
+
"edge",
|
|
1258
|
+
{ name: uniqueColumnName("id", "cx2", taken), dtype: "f64", role: "id", nullable: true },
|
|
1259
|
+
this.report,
|
|
1260
|
+
).handle;
|
|
1261
|
+
}
|
|
1262
|
+
return this.edgeIdHandle;
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
/**
|
|
1266
|
+
* The dtype part of a declared column.
|
|
1267
|
+
* @param type - the CX2 type
|
|
1268
|
+
* @returns the dtype and item dtype
|
|
1269
|
+
*/
|
|
1270
|
+
private columnDecl(type: Cx2Type): { name: string; dtype: Dtype; itemDtype?: ScalarDtype; nullable: true } {
|
|
1271
|
+
const scalar = scalarDtype(type.scalar, this.options.long);
|
|
1272
|
+
return type.list
|
|
1273
|
+
? { name: "", dtype: "list", itemDtype: scalar, nullable: true }
|
|
1274
|
+
: { name: "", dtype: scalar, nullable: true };
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
// ------------------------------------------------------------ ids
|
|
1278
|
+
|
|
1279
|
+
/**
|
|
1280
|
+
* The id of an element, by the CX id rule and the `ids` option.
|
|
1281
|
+
* @param raw - the parsed id
|
|
1282
|
+
* @param inexact - whether the literal was not a plain integer
|
|
1283
|
+
* @param element - the element name for issues
|
|
1284
|
+
* @param edgeId - whether this is an edge's own id (stored in the f64 id column, not kept as digits)
|
|
1285
|
+
* @returns the id, or null when it was reported
|
|
1286
|
+
*/
|
|
1287
|
+
private idOf(raw: unknown, inexact: boolean, element: string, edgeId = false): NodeId | null {
|
|
1288
|
+
let id: NodeId;
|
|
1289
|
+
try {
|
|
1290
|
+
const parsed = cxId(raw, inexact);
|
|
1291
|
+
if (parsed.note === "text") {
|
|
1292
|
+
this.report.warnOnce(
|
|
1293
|
+
"coercion",
|
|
1294
|
+
ID_TEXT_TYPE_CODE,
|
|
1295
|
+
`${element}: the id ${shown(raw)} is not written as an integer; read as ${String(parsed.id)}`,
|
|
1296
|
+
{ element },
|
|
1297
|
+
);
|
|
1298
|
+
} else if (parsed.note === "precision" && edgeId) {
|
|
1299
|
+
// the edge is told apart by its digits, but the id column is f64
|
|
1300
|
+
this.report.warnOnce(
|
|
1301
|
+
"precision",
|
|
1302
|
+
PRECISION_CODE,
|
|
1303
|
+
`${element}: the edge id ${String(parsed.id)} is beyond 2^53; the id column holds the nearest double`,
|
|
1304
|
+
{ element },
|
|
1305
|
+
`${PRECISION_CODE}:edge`,
|
|
1306
|
+
);
|
|
1307
|
+
} else if (parsed.note === "precision") {
|
|
1308
|
+
this.idType = "mixed";
|
|
1309
|
+
this.report.warnOnce(
|
|
1310
|
+
"precision",
|
|
1311
|
+
PRECISION_CODE,
|
|
1312
|
+
`${element}: the id ${String(parsed.id)} is beyond 2^53; kept as its digits (a string id)`,
|
|
1313
|
+
{ element },
|
|
1314
|
+
);
|
|
1315
|
+
}
|
|
1316
|
+
id = this.options.ids === "keep" ? parsed.id : this.coercer.value(parsed.id);
|
|
1317
|
+
} catch (err) {
|
|
1318
|
+
this.report.recordError(err, { element });
|
|
1319
|
+
return null;
|
|
1320
|
+
}
|
|
1321
|
+
return id;
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/**
|
|
1325
|
+
* Check the cancellation signal every ABORT_CHECK_INTERVAL elements.
|
|
1326
|
+
*/
|
|
1327
|
+
private checkAbort(): void {
|
|
1328
|
+
if (++this.sinceCheck >= ABORT_CHECK_INTERVAL) {
|
|
1329
|
+
this.sinceCheck = 0;
|
|
1330
|
+
throwIfAborted(this.options.signal);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
/**
|
|
1335
|
+
* Report the keys of an element CX2 does not define, once per key.
|
|
1336
|
+
* @param record - the element
|
|
1337
|
+
* @param known - the keys it defines
|
|
1338
|
+
* @param what - nodes or edges
|
|
1339
|
+
* @param line - its line
|
|
1340
|
+
*/
|
|
1341
|
+
private unknownKeys(record: Record<string, unknown>, known: ReadonlySet<string>, what: string, line: number): void {
|
|
1342
|
+
for (const key of Object.keys(record)) {
|
|
1343
|
+
if (!known.has(key)) {
|
|
1344
|
+
this.report.warnOnce(
|
|
1345
|
+
"unsupported",
|
|
1346
|
+
UNKNOWN_ELEMENT_CODE,
|
|
1347
|
+
`the ${what} key "${key}" is not defined by CX2; its values are not read`,
|
|
1348
|
+
{ line, element: key },
|
|
1349
|
+
`${UNKNOWN_ELEMENT_CODE}:${what}:${key}`,
|
|
1350
|
+
);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
// ------------------------------------------------------------ nodes
|
|
1356
|
+
|
|
1357
|
+
/**
|
|
1358
|
+
* Read one node element.
|
|
1359
|
+
* @param held - the element
|
|
1360
|
+
*/
|
|
1361
|
+
private readNode(held: Held): void {
|
|
1362
|
+
this.checkAbort();
|
|
1363
|
+
const { report, sink } = this;
|
|
1364
|
+
const { value, line } = held;
|
|
1365
|
+
if (!isRecord(value)) {
|
|
1366
|
+
report.error("parse-error", BAD_ASPECT_BLOCK_CODE, `a nodes element is ${shown(value)}, not an object`, {
|
|
1367
|
+
line,
|
|
1368
|
+
element: "nodes",
|
|
1369
|
+
});
|
|
1370
|
+
report.counts.skippedNodes++;
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
this.unknownKeys(value, NODE_KEYS, "node", line);
|
|
1374
|
+
if (value.id === undefined || value.id === null) {
|
|
1375
|
+
report.error("missing-value", MISSING_ID_CODE, "a node has no id", { line, element: "nodes" });
|
|
1376
|
+
report.counts.skippedNodes++;
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
const element = `node ${shown(value.id)}`;
|
|
1380
|
+
const cx = this.idOf(value.id, (held.inexact & 1) !== 0, element);
|
|
1381
|
+
if (cx === null) {
|
|
1382
|
+
report.counts.skippedNodes++;
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
const attrs = isRecord(value.v) ? value.v : null;
|
|
1386
|
+
if (value.v !== undefined && value.v !== null && attrs === null) {
|
|
1387
|
+
report.error("validation-error", BAD_VALUE_CODE, `${element}: v is ${shown(value.v)}, not an object`, {
|
|
1388
|
+
line,
|
|
1389
|
+
element,
|
|
1390
|
+
});
|
|
1391
|
+
}
|
|
1392
|
+
let row = this.nodeRows.get(cx);
|
|
1393
|
+
if (row !== undefined) {
|
|
1394
|
+
report.warning(
|
|
1395
|
+
"merged",
|
|
1396
|
+
DUPLICATE_NODE_CODE,
|
|
1397
|
+
`${element} is declared more than once; its attributes are merged (the later values win)`,
|
|
1398
|
+
{ line, element },
|
|
1399
|
+
);
|
|
1400
|
+
} else {
|
|
1401
|
+
const original = this.originalIdOf(attrs);
|
|
1402
|
+
this.idTypeOf(original ?? cx);
|
|
1403
|
+
try {
|
|
1404
|
+
row = sink.addNode(original ?? cx);
|
|
1405
|
+
} catch (err) {
|
|
1406
|
+
report.recordError(err, { line, element });
|
|
1407
|
+
report.counts.skippedNodes++;
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
this.nodeRows.set(cx, row);
|
|
1411
|
+
this.sinkIds.set(cx, original ?? cx);
|
|
1412
|
+
report.counts.nodes++;
|
|
1413
|
+
}
|
|
1414
|
+
this.readCoordinates(value, row, element, line);
|
|
1415
|
+
if (attrs !== null) {
|
|
1416
|
+
this.writeAttributes("node", row, attrs, element, line);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
/**
|
|
1421
|
+
* The original id the exporter's `sanitizeIds: "mangle"` kept, when it is to be restored.
|
|
1422
|
+
* @param attrs - the node's v
|
|
1423
|
+
* @returns the original id, or null
|
|
1424
|
+
*/
|
|
1425
|
+
private originalIdOf(attrs: Record<string, unknown> | null): NodeId | null {
|
|
1426
|
+
if (attrs === null || !this.options.restoreMangledIds) {
|
|
1427
|
+
return null;
|
|
1428
|
+
}
|
|
1429
|
+
const alias = this.decls.node.byName.get(ORIGINAL_ID_ATTRIBUTE)?.alias ?? null;
|
|
1430
|
+
const raw = attrs[ORIGINAL_ID_ATTRIBUTE] ?? (alias === null ? undefined : attrs[alias]);
|
|
1431
|
+
return typeof raw === "string" ? raw : null;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/**
|
|
1435
|
+
* Track the idType of the metadata.
|
|
1436
|
+
* @param id - a node id
|
|
1437
|
+
*/
|
|
1438
|
+
private idTypeOf(id: NodeId): void {
|
|
1439
|
+
if (typeof id === "string" && this.idType === "integer") {
|
|
1440
|
+
this.idType = "mixed";
|
|
1441
|
+
}
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
/**
|
|
1445
|
+
* Read a node's x / y / z.
|
|
1446
|
+
* @param value - the node element
|
|
1447
|
+
* @param row - the node
|
|
1448
|
+
* @param element - the element name
|
|
1449
|
+
* @param line - its line
|
|
1450
|
+
*/
|
|
1451
|
+
private readCoordinates(value: Record<string, unknown>, row: number, element: string, line: number): void {
|
|
1452
|
+
const coordinate = (key: "x" | "y" | "z"): number | null => {
|
|
1453
|
+
const raw = value[key];
|
|
1454
|
+
if (raw === undefined || raw === null) {
|
|
1455
|
+
return null;
|
|
1456
|
+
}
|
|
1457
|
+
if (typeof raw === "number") {
|
|
1458
|
+
return raw;
|
|
1459
|
+
}
|
|
1460
|
+
this.report.error("validation-error", BAD_VALUE_CODE, `${element}: ${key} is ${shown(raw)}, not a number`, {
|
|
1461
|
+
line,
|
|
1462
|
+
element,
|
|
1463
|
+
});
|
|
1464
|
+
return null;
|
|
1465
|
+
};
|
|
1466
|
+
const x = coordinate("x");
|
|
1467
|
+
const y = coordinate("y");
|
|
1468
|
+
const z = coordinate("z");
|
|
1469
|
+
if ((x === null) !== (y === null)) {
|
|
1470
|
+
this.report.warnOnce(
|
|
1471
|
+
"missing-value",
|
|
1472
|
+
CX2_ISSUE.PARTIAL_LAYOUT,
|
|
1473
|
+
`${element} has ${x === null ? "y without x" : "x without y"}; its coordinates are dropped`,
|
|
1474
|
+
{ line, element },
|
|
1475
|
+
`${CX2_ISSUE.PARTIAL_LAYOUT}:half`,
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
if (z !== null && x === null && y === null) {
|
|
1479
|
+
// the specification requires x and y with z
|
|
1480
|
+
this.report.warnOnce(
|
|
1481
|
+
"missing-value",
|
|
1482
|
+
CX2_ISSUE.PARTIAL_LAYOUT,
|
|
1483
|
+
`${element} has z without x and y; ${this.zAs === "column" ? "z is kept in the z column" : "with no position, z is dropped"}`,
|
|
1484
|
+
{ line, element },
|
|
1485
|
+
`${CX2_ISSUE.PARTIAL_LAYOUT}:z`,
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
if (x !== null && y !== null) {
|
|
1489
|
+
this.setPosition(row, x, y, z);
|
|
1490
|
+
}
|
|
1491
|
+
if (z !== null && this.zAs === "column") {
|
|
1492
|
+
if (this.zHandle === INVALID_INDEX) {
|
|
1493
|
+
this.zHandle = declareResolved(this.sink, "node", zDecl(CX2_FORMAT), this.report).handle;
|
|
1494
|
+
}
|
|
1495
|
+
this.sink.setNodeValue(this.zHandle, row, z);
|
|
1496
|
+
}
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
/**
|
|
1500
|
+
* Write a node's position, y flipped to y-up.
|
|
1501
|
+
* @param row - the node
|
|
1502
|
+
* @param x - screen x
|
|
1503
|
+
* @param y - screen y
|
|
1504
|
+
* @param z - z, used only under zAs "position"
|
|
1505
|
+
*/
|
|
1506
|
+
private setPosition(row: number, x: number, y: number, z: number | null): void {
|
|
1507
|
+
if (this.positionHandle === INVALID_INDEX) {
|
|
1508
|
+
this.positionHandle = declareResolved(
|
|
1509
|
+
this.sink,
|
|
1510
|
+
"node",
|
|
1511
|
+
positionDecl(CX2_FORMAT, this.zAs === "position" ? 3 : 2),
|
|
1512
|
+
this.report,
|
|
1513
|
+
).handle;
|
|
1514
|
+
}
|
|
1515
|
+
this.point[0] = x;
|
|
1516
|
+
this.point[1] = flipY(y);
|
|
1517
|
+
this.point[2] = this.zAs === "position" && z !== null ? z : 0;
|
|
1518
|
+
this.sink.setNodeValue(this.positionHandle, row, this.point);
|
|
1519
|
+
this.positioned.add(row);
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/** Apply a CX1 cartesianLayout aspect when no node carries coordinates; keep and warn otherwise. */
|
|
1523
|
+
private readLegacyLayout(): void {
|
|
1524
|
+
const { cartesianLayout } = this.doc;
|
|
1525
|
+
if (cartesianLayout.length === 0) {
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
if (this.positioned.size > 0) {
|
|
1529
|
+
this.doc.opaque.set(
|
|
1530
|
+
"cartesianLayout",
|
|
1531
|
+
cartesianLayout.map((h) => plainJson(h.value)),
|
|
1532
|
+
);
|
|
1533
|
+
this.report.warning(
|
|
1534
|
+
"unsupported",
|
|
1535
|
+
CX2_ISSUE.LEGACY_LAYOUT,
|
|
1536
|
+
`a CX1 cartesianLayout aspect (${cartesianLayout.length} element(s)) next to node coordinates; the node coordinates are used, the aspect is kept in meta.extra.cx2`,
|
|
1537
|
+
{ element: "cartesianLayout" },
|
|
1538
|
+
);
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
for (const held of cartesianLayout) {
|
|
1542
|
+
const entry = held.value;
|
|
1543
|
+
if (!isRecord(entry) || typeof entry.x !== "number" || typeof entry.y !== "number") {
|
|
1544
|
+
this.report.error(
|
|
1545
|
+
"validation-error",
|
|
1546
|
+
BAD_VALUE_CODE,
|
|
1547
|
+
"a cartesianLayout element has no numeric x and y; skipped",
|
|
1548
|
+
{ line: held.line, element: "cartesianLayout" },
|
|
1549
|
+
);
|
|
1550
|
+
continue;
|
|
1551
|
+
}
|
|
1552
|
+
const row = this.rowOf(this.nodeRows, entry.node);
|
|
1553
|
+
if (row === undefined) {
|
|
1554
|
+
this.dangle("cartesianLayout entry");
|
|
1555
|
+
continue;
|
|
1556
|
+
}
|
|
1557
|
+
const z = typeof entry.z === "number" ? entry.z : null;
|
|
1558
|
+
this.setPosition(row, entry.x, entry.y, z);
|
|
1559
|
+
if (z !== null && this.zAs === "column") {
|
|
1560
|
+
if (this.zHandle === INVALID_INDEX) {
|
|
1561
|
+
this.zHandle = declareResolved(this.sink, "node", zDecl(CX2_FORMAT), this.report).handle;
|
|
1562
|
+
}
|
|
1563
|
+
this.sink.setNodeValue(this.zHandle, row, z);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
/**
|
|
1569
|
+
* The sink row of a referenced id.
|
|
1570
|
+
* @param rows - the id map
|
|
1571
|
+
* @param raw - the parsed reference
|
|
1572
|
+
* @returns the row, or undefined
|
|
1573
|
+
*/
|
|
1574
|
+
private rowOf(rows: ReadonlyMap<NodeId, number>, raw: unknown): number | undefined {
|
|
1575
|
+
try {
|
|
1576
|
+
const { id } = cxId(raw);
|
|
1577
|
+
return rows.get(this.options.ids === "keep" ? id : this.coercer.value(id));
|
|
1578
|
+
} catch {
|
|
1579
|
+
return undefined;
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
/**
|
|
1584
|
+
* Count a reference that names nothing.
|
|
1585
|
+
* @param kind - what referred
|
|
1586
|
+
*/
|
|
1587
|
+
private dangle(kind: string): void {
|
|
1588
|
+
this.dangling.set(kind, (this.dangling.get(kind) ?? 0) + 1);
|
|
1589
|
+
}
|
|
1590
|
+
|
|
1591
|
+
// ------------------------------------------------------------ attributes
|
|
1592
|
+
|
|
1593
|
+
/**
|
|
1594
|
+
* Write the attributes of an element's v.
|
|
1595
|
+
* @param domain - node or edge
|
|
1596
|
+
* @param row - the row
|
|
1597
|
+
* @param attrs - the v object
|
|
1598
|
+
* @param element - the element name
|
|
1599
|
+
* @param line - its line
|
|
1600
|
+
*/
|
|
1601
|
+
private writeAttributes(
|
|
1602
|
+
domain: "node" | "edge",
|
|
1603
|
+
row: number,
|
|
1604
|
+
attrs: Record<string, unknown>,
|
|
1605
|
+
element: string,
|
|
1606
|
+
line: number,
|
|
1607
|
+
): void {
|
|
1608
|
+
const { report } = this;
|
|
1609
|
+
const table = this.decls[domain];
|
|
1610
|
+
for (const key of Object.keys(attrs)) {
|
|
1611
|
+
const value = attrs[key];
|
|
1612
|
+
const resolved = table.resolve(key);
|
|
1613
|
+
const name = resolved?.decl.name ?? key;
|
|
1614
|
+
if (domain === "node" && this.options.restoreMangledIds && name === ORIGINAL_ID_ATTRIBUTE) {
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1617
|
+
if (domain === "edge" && name === this.options.weightFrom) {
|
|
1618
|
+
continue;
|
|
1619
|
+
}
|
|
1620
|
+
if (key === "id") {
|
|
1621
|
+
report.warnOnce(
|
|
1622
|
+
"validation-error",
|
|
1623
|
+
CX2_ISSUE.RESERVED_KEY,
|
|
1624
|
+
`${element}: the attribute name "id" is reserved in v; read as an ordinary attribute`,
|
|
1625
|
+
{ line, element },
|
|
1626
|
+
`${CX2_ISSUE.RESERVED_KEY}:${domain}`,
|
|
1627
|
+
);
|
|
1628
|
+
}
|
|
1629
|
+
if (
|
|
1630
|
+
resolved !== null &&
|
|
1631
|
+
!resolved.viaAlias &&
|
|
1632
|
+
resolved.decl.alias !== null &&
|
|
1633
|
+
resolved.decl.alias !== key
|
|
1634
|
+
) {
|
|
1635
|
+
if (resolved.decl.alias in attrs) {
|
|
1636
|
+
report.warning(
|
|
1637
|
+
"validation-error",
|
|
1638
|
+
DUPLICATE_ATTRIBUTE_CODE,
|
|
1639
|
+
`${element}: "${name}" is given under its alias and its full name; the alias wins`,
|
|
1640
|
+
{ line, element },
|
|
1641
|
+
);
|
|
1642
|
+
continue;
|
|
1643
|
+
}
|
|
1644
|
+
report.warnOnce(
|
|
1645
|
+
"coercion",
|
|
1646
|
+
CX2_ISSUE.ALIAS_BYPASSED,
|
|
1647
|
+
`${element}: the full name "${name}" is used although its alias "${resolved.decl.alias}" is declared; read as the same attribute`,
|
|
1648
|
+
{ line, element },
|
|
1649
|
+
`${CX2_ISSUE.ALIAS_BYPASSED}:${domain}:${name}`,
|
|
1650
|
+
);
|
|
1651
|
+
}
|
|
1652
|
+
if (value === null || value === undefined) {
|
|
1653
|
+
continue;
|
|
1654
|
+
}
|
|
1655
|
+
if (resolved === null) {
|
|
1656
|
+
continue;
|
|
1657
|
+
}
|
|
1658
|
+
const { decl } = resolved;
|
|
1659
|
+
if (decl.undeclared) {
|
|
1660
|
+
report.warnOnce(
|
|
1661
|
+
"validation-error",
|
|
1662
|
+
CX2_ISSUE.UNDECLARED_ATTRIBUTE,
|
|
1663
|
+
`the ${domain} attribute "${key}" is not declared; its type is inferred from the values`,
|
|
1664
|
+
{ line, element: key },
|
|
1665
|
+
`${CX2_ISSUE.UNDECLARED_ATTRIBUTE}:${domain}:${key}`,
|
|
1666
|
+
);
|
|
1667
|
+
}
|
|
1668
|
+
const converted =
|
|
1669
|
+
decl.type === null
|
|
1670
|
+
? plainJson(value, (digits) => {
|
|
1671
|
+
this.precision(name, digits);
|
|
1672
|
+
})
|
|
1673
|
+
: convertValue(value, decl.type, this.options.long, (digits) => {
|
|
1674
|
+
this.precision(name, digits);
|
|
1675
|
+
});
|
|
1676
|
+
if (converted === BAD) {
|
|
1677
|
+
report.error(
|
|
1678
|
+
"validation-error",
|
|
1679
|
+
BAD_VALUE_CODE,
|
|
1680
|
+
`${element}: "${name}" is ${shown(value)}, not a ${decl.type?.d ?? "value"}; the cell is unset`,
|
|
1681
|
+
{ line, element },
|
|
1682
|
+
);
|
|
1683
|
+
continue;
|
|
1684
|
+
}
|
|
1685
|
+
try {
|
|
1686
|
+
if (domain === "node") {
|
|
1687
|
+
this.sink.setNodeValue(decl.handle, row, converted);
|
|
1688
|
+
} else {
|
|
1689
|
+
this.sink.setEdgeValue(decl.handle, row, converted);
|
|
1690
|
+
}
|
|
1691
|
+
} catch (err) {
|
|
1692
|
+
report.recordError(err, { line, element });
|
|
1693
|
+
}
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
/**
|
|
1698
|
+
* Record a long value beyond 2^53 (once).
|
|
1699
|
+
* @param name - the attribute
|
|
1700
|
+
* @param digits - the value
|
|
1701
|
+
*/
|
|
1702
|
+
private precision(name: string, digits: string): void {
|
|
1703
|
+
this.report.warnOnce(
|
|
1704
|
+
"precision",
|
|
1705
|
+
PRECISION_CODE,
|
|
1706
|
+
`"${name}": ${digits} is beyond 2^53; stored as the nearest double`,
|
|
1707
|
+
{ element: name },
|
|
1708
|
+
`${PRECISION_CODE}:value`,
|
|
1709
|
+
);
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
// ------------------------------------------------------------ edges
|
|
1713
|
+
|
|
1714
|
+
/**
|
|
1715
|
+
* Read one edge element.
|
|
1716
|
+
* @param held - the element
|
|
1717
|
+
*/
|
|
1718
|
+
private readEdge(held: Held): void {
|
|
1719
|
+
this.checkAbort();
|
|
1720
|
+
const { report, sink } = this;
|
|
1721
|
+
const { value, line } = held;
|
|
1722
|
+
if (!isRecord(value)) {
|
|
1723
|
+
report.error("parse-error", BAD_ASPECT_BLOCK_CODE, `an edges element is ${shown(value)}, not an object`, {
|
|
1724
|
+
line,
|
|
1725
|
+
element: "edges",
|
|
1726
|
+
});
|
|
1727
|
+
report.counts.skippedEdges++;
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
this.unknownKeys(value, EDGE_KEYS, "edge", line);
|
|
1731
|
+
if (value.id === undefined || value.id === null) {
|
|
1732
|
+
report.error("missing-value", MISSING_ID_CODE, "an edge has no id", { line, element: "edges" });
|
|
1733
|
+
report.counts.skippedEdges++;
|
|
1734
|
+
return;
|
|
1735
|
+
}
|
|
1736
|
+
const element = `edge ${shown(value.id)}`;
|
|
1737
|
+
for (const key of ["s", "t"] as const) {
|
|
1738
|
+
if (value[key] === undefined || value[key] === null) {
|
|
1739
|
+
report.error("missing-value", MISSING_ENDPOINT_CODE, `${element} has no ${key}`, { line, element });
|
|
1740
|
+
report.counts.skippedEdges++;
|
|
1741
|
+
return;
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
const id = this.idOf(value.id, (held.inexact & 1) !== 0, element, true);
|
|
1745
|
+
const s = id === null ? null : this.idOf(value.s, (held.inexact & 2) !== 0, element);
|
|
1746
|
+
const t = s === null ? null : this.idOf(value.t, (held.inexact & 4) !== 0, element);
|
|
1747
|
+
if (id === null || s === null || t === null) {
|
|
1748
|
+
report.counts.skippedEdges++;
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
if (this.edgeRows.has(id)) {
|
|
1752
|
+
report.error(
|
|
1753
|
+
"validation-error",
|
|
1754
|
+
DUPLICATE_EDGE_ID_CODE,
|
|
1755
|
+
`${element} is declared more than once; the later edge is skipped`,
|
|
1756
|
+
{ line, element },
|
|
1757
|
+
);
|
|
1758
|
+
report.counts.skippedEdges++;
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
const source = this.endpoint(s, element, line);
|
|
1762
|
+
const target = source === null ? null : this.endpoint(t, element, line);
|
|
1763
|
+
if (source === null || target === null) {
|
|
1764
|
+
report.counts.skippedEdges++;
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
const attrs = isRecord(value.v) ? value.v : null;
|
|
1768
|
+
let weight: number | undefined;
|
|
1769
|
+
const weightKey = this.weightKey(attrs);
|
|
1770
|
+
try {
|
|
1771
|
+
weight = weightKey === null || attrs === null ? undefined : weightFromValue(plainJson(attrs[weightKey]));
|
|
1772
|
+
} catch (err) {
|
|
1773
|
+
report.recordError(err, { line, element });
|
|
1774
|
+
report.counts.skippedEdges++;
|
|
1775
|
+
return;
|
|
1776
|
+
}
|
|
1777
|
+
if (weight !== undefined) {
|
|
1778
|
+
this.weighted = true;
|
|
1779
|
+
}
|
|
1780
|
+
const before = sink.edgeCount;
|
|
1781
|
+
let edge: number;
|
|
1782
|
+
try {
|
|
1783
|
+
edge = this.direction.addEdge(source, target, "directed", weight, { line, element });
|
|
1784
|
+
} catch (err) {
|
|
1785
|
+
report.recordError(err, { line, element });
|
|
1786
|
+
report.counts.skippedEdges++;
|
|
1787
|
+
return;
|
|
1788
|
+
}
|
|
1789
|
+
report.counts.edges += sink.edgeCount - before;
|
|
1790
|
+
this.edgeRows.set(id, edge);
|
|
1791
|
+
sink.setEdgeValue(this.edgeIdColumn(), edge, typeof id === "number" ? id : Number(id));
|
|
1792
|
+
if (attrs !== null) {
|
|
1793
|
+
this.writeAttributes("edge", edge, attrs, element, line);
|
|
1794
|
+
}
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
/**
|
|
1798
|
+
* The key of an edge's v that holds the weight: weightFrom, or its declared alias.
|
|
1799
|
+
* @param attrs - the edge's v
|
|
1800
|
+
* @returns the key present in attrs, or null
|
|
1801
|
+
*/
|
|
1802
|
+
private weightKey(attrs: Record<string, unknown> | null): string | null {
|
|
1803
|
+
const { weightFrom } = this.options;
|
|
1804
|
+
if (weightFrom === null || attrs === null) {
|
|
1805
|
+
return null;
|
|
1806
|
+
}
|
|
1807
|
+
const alias = this.decls.edge.byName.get(weightFrom)?.alias ?? null;
|
|
1808
|
+
if (alias !== null && alias in attrs) {
|
|
1809
|
+
return alias;
|
|
1810
|
+
}
|
|
1811
|
+
return weightFrom in attrs ? weightFrom : null;
|
|
1812
|
+
}
|
|
1813
|
+
|
|
1814
|
+
/**
|
|
1815
|
+
* The sink id of an edge endpoint: a known node, or a node created under addMissingNodes.
|
|
1816
|
+
* @param cx - the endpoint's CX id
|
|
1817
|
+
* @param element - the edge name
|
|
1818
|
+
* @param line - its line
|
|
1819
|
+
* @returns the sink id, or null when the endpoint was reported
|
|
1820
|
+
*/
|
|
1821
|
+
private endpoint(cx: NodeId, element: string, line: number): NodeId | null {
|
|
1822
|
+
const known = this.sinkIds.get(cx);
|
|
1823
|
+
if (known !== undefined) {
|
|
1824
|
+
return known;
|
|
1825
|
+
}
|
|
1826
|
+
if (!this.options.addMissingNodes) {
|
|
1827
|
+
this.report.error(
|
|
1828
|
+
"missing-value",
|
|
1829
|
+
CX2_ISSUE.UNKNOWN_NODE,
|
|
1830
|
+
`${element}: the endpoint ${String(cx)} is not a node; the edge is skipped`,
|
|
1831
|
+
{ line, element },
|
|
1832
|
+
);
|
|
1833
|
+
return null;
|
|
1834
|
+
}
|
|
1835
|
+
try {
|
|
1836
|
+
this.nodeRows.set(cx, this.sink.addNode(cx));
|
|
1837
|
+
} catch (err) {
|
|
1838
|
+
this.report.recordError(err, { line, element });
|
|
1839
|
+
return null;
|
|
1840
|
+
}
|
|
1841
|
+
this.sinkIds.set(cx, cx);
|
|
1842
|
+
this.report.counts.nodes++;
|
|
1843
|
+
return cx;
|
|
1844
|
+
}
|
|
1845
|
+
|
|
1846
|
+
// ------------------------------------------------------------ network attributes, bypasses
|
|
1847
|
+
|
|
1848
|
+
/** Write the network attributes (the first element; others are reported). */
|
|
1849
|
+
private readNetworkAttributes(): void {
|
|
1850
|
+
const { networkAttributes } = this.doc;
|
|
1851
|
+
if (networkAttributes.length === 0) {
|
|
1852
|
+
return;
|
|
1853
|
+
}
|
|
1854
|
+
if (networkAttributes.length > 1) {
|
|
1855
|
+
this.report.warning(
|
|
1856
|
+
"validation-error",
|
|
1857
|
+
CX2_ISSUE.EXTRA_ELEMENTS,
|
|
1858
|
+
`networkAttributes holds ${networkAttributes.length} elements; the first is read`,
|
|
1859
|
+
{ line: networkAttributes[1].line, element: "networkAttributes" },
|
|
1860
|
+
);
|
|
1861
|
+
}
|
|
1862
|
+
const { value, line } = networkAttributes[0];
|
|
1863
|
+
if (!isRecord(value)) {
|
|
1864
|
+
this.report.error("parse-error", BAD_ASPECT_BLOCK_CODE, "the networkAttributes element is not an object", {
|
|
1865
|
+
line,
|
|
1866
|
+
element: "networkAttributes",
|
|
1867
|
+
});
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
for (const [name, raw] of Object.entries(value)) {
|
|
1871
|
+
if (raw === null || raw === undefined) {
|
|
1872
|
+
continue;
|
|
1873
|
+
}
|
|
1874
|
+
const decl = this.decls.network.byName.get(name);
|
|
1875
|
+
const element = `networkAttributes.${name}`;
|
|
1876
|
+
if (decl === undefined) {
|
|
1877
|
+
this.report.warnOnce(
|
|
1878
|
+
"validation-error",
|
|
1879
|
+
CX2_ISSUE.UNDECLARED_ATTRIBUTE,
|
|
1880
|
+
`the network attribute "${name}" is not declared; its column is inferred from the value`,
|
|
1881
|
+
{ line, element: name },
|
|
1882
|
+
`${CX2_ISSUE.UNDECLARED_ATTRIBUTE}:network:${name}`,
|
|
1883
|
+
);
|
|
1884
|
+
}
|
|
1885
|
+
if (
|
|
1886
|
+
(name === "name" || name === "description") &&
|
|
1887
|
+
typeof raw === "string" &&
|
|
1888
|
+
decl?.type?.d !== "list_of_string"
|
|
1889
|
+
) {
|
|
1890
|
+
// the graph's own name and description: GraphMeta, written back from there
|
|
1891
|
+
continue;
|
|
1892
|
+
}
|
|
1893
|
+
try {
|
|
1894
|
+
const seen: Seen = { kind: undefined, list: undefined };
|
|
1895
|
+
observe(seen, raw);
|
|
1896
|
+
const type = decl?.type ?? inferredType(seen);
|
|
1897
|
+
if (type === null) {
|
|
1898
|
+
this.sink.setGraphValue(
|
|
1899
|
+
name,
|
|
1900
|
+
plainJson(raw, (digits) => {
|
|
1901
|
+
this.precision(name, digits);
|
|
1902
|
+
}),
|
|
1903
|
+
{
|
|
1904
|
+
dtype: "json",
|
|
1905
|
+
origin: { format: CX2_FORMAT, id: null },
|
|
1906
|
+
},
|
|
1907
|
+
);
|
|
1908
|
+
continue;
|
|
1909
|
+
}
|
|
1910
|
+
const converted = convertValue(raw, type, this.options.long, (digits) => {
|
|
1911
|
+
this.precision(name, digits);
|
|
1912
|
+
});
|
|
1913
|
+
if (converted === BAD) {
|
|
1914
|
+
this.report.error(
|
|
1915
|
+
"validation-error",
|
|
1916
|
+
BAD_VALUE_CODE,
|
|
1917
|
+
`${element} is ${shown(raw)}, not a ${type.d}; unset`,
|
|
1918
|
+
{ line, element },
|
|
1919
|
+
);
|
|
1920
|
+
continue;
|
|
1921
|
+
}
|
|
1922
|
+
const scalar = scalarDtype(type.scalar, this.options.long);
|
|
1923
|
+
this.sink.setGraphValue(name, converted, {
|
|
1924
|
+
dtype: type.list ? "list" : scalar,
|
|
1925
|
+
...(type.list ? { itemDtype: scalar } : {}),
|
|
1926
|
+
origin: { format: CX2_FORMAT, id: null, type: type.d },
|
|
1927
|
+
});
|
|
1928
|
+
} catch (err) {
|
|
1929
|
+
this.report.recordError(err, { line, element });
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
/**
|
|
1935
|
+
* Write the per-element visual property values: one column per property, typed by its values.
|
|
1936
|
+
* @param domain - node or edge
|
|
1937
|
+
* @param entries - the bypass elements
|
|
1938
|
+
*/
|
|
1939
|
+
private readBypasses(domain: "node" | "edge", entries: readonly Held[]): void {
|
|
1940
|
+
const rows = domain === "node" ? this.nodeRows : this.edgeRows;
|
|
1941
|
+
const columns = new Map<string, { rows: number[]; values: unknown[] }>();
|
|
1942
|
+
for (const held of entries) {
|
|
1943
|
+
this.checkAbort();
|
|
1944
|
+
const { value, line } = held;
|
|
1945
|
+
if (!isRecord(value) || !isRecord(value.v)) {
|
|
1946
|
+
this.report.error(
|
|
1947
|
+
"parse-error",
|
|
1948
|
+
BAD_ASPECT_BLOCK_CODE,
|
|
1949
|
+
`a ${domain}Bypasses element is not an object with an id and a v object; skipped`,
|
|
1950
|
+
{ line, element: `${domain}Bypasses` },
|
|
1951
|
+
);
|
|
1952
|
+
continue;
|
|
1953
|
+
}
|
|
1954
|
+
const row = this.rowOf(rows, value.id);
|
|
1955
|
+
if (row === undefined) {
|
|
1956
|
+
this.dangle(`${domain} bypass`);
|
|
1957
|
+
continue;
|
|
1958
|
+
}
|
|
1959
|
+
for (const [property, raw] of Object.entries(value.v)) {
|
|
1960
|
+
if (raw === null || raw === undefined) {
|
|
1961
|
+
continue;
|
|
1962
|
+
}
|
|
1963
|
+
let column = columns.get(property);
|
|
1964
|
+
if (column === undefined) {
|
|
1965
|
+
column = { rows: [], values: [] };
|
|
1966
|
+
columns.set(property, column);
|
|
1967
|
+
}
|
|
1968
|
+
column.rows.push(row);
|
|
1969
|
+
column.values.push(plainJson(raw));
|
|
1970
|
+
}
|
|
1971
|
+
}
|
|
1972
|
+
for (const [property, { rows: targets, values }] of columns) {
|
|
1973
|
+
const decl: ColumnDecl = {
|
|
1974
|
+
name: property,
|
|
1975
|
+
dtype: bypassDtype(values),
|
|
1976
|
+
nullable: true,
|
|
1977
|
+
// origin.id keeps the property's name: a column renamed for a clash is written back under it
|
|
1978
|
+
origin: { format: CX2_FORMAT, id: property, namespace: BYPASS_NAMESPACE },
|
|
1979
|
+
};
|
|
1980
|
+
const handle = declareFresh(this.sink, domain, decl, this.report);
|
|
1981
|
+
for (let i = 0; i < targets.length; i++) {
|
|
1982
|
+
if (domain === "node") {
|
|
1983
|
+
this.sink.setNodeValue(handle, targets[i], values[i]);
|
|
1984
|
+
} else {
|
|
1985
|
+
this.sink.setEdgeValue(handle, targets[i], values[i]);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
}
|
|
1989
|
+
}
|
|
1990
|
+
|
|
1991
|
+
// ------------------------------------------------------------ aspects, status, metadata
|
|
1992
|
+
|
|
1993
|
+
/** The style-rule loss, the single-element aspects, the status and the metadata counts. */
|
|
1994
|
+
private checkAspects(): void {
|
|
1995
|
+
const { doc, report } = this;
|
|
1996
|
+
const { structure } = doc;
|
|
1997
|
+
const styles: string[] = [];
|
|
1998
|
+
for (const aspect of STYLE_ASPECTS) {
|
|
1999
|
+
const elements = doc.opaque.get(aspect);
|
|
2000
|
+
if (elements !== undefined) {
|
|
2001
|
+
styles.push(describeStyle(aspect, elements));
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
if (styles.length > 0) {
|
|
2005
|
+
report.warning(
|
|
2006
|
+
"unsupported",
|
|
2007
|
+
STYLES_NOT_IMPORTED_CODE,
|
|
2008
|
+
`the file's style rules are not applied (${styles.join("; ")}); they are kept in meta.extra.cx2.opaque (style import is issue #706)`,
|
|
2009
|
+
{ element: STYLE_ASPECTS.filter((a) => doc.opaque.has(a)).join(",") },
|
|
2010
|
+
);
|
|
2011
|
+
}
|
|
2012
|
+
for (const aspect of SINGLE_ELEMENT_ASPECTS.slice(1)) {
|
|
2013
|
+
const count = doc.opaque.get(aspect)?.length ?? 0;
|
|
2014
|
+
if (count > 1) {
|
|
2015
|
+
report.warning(
|
|
2016
|
+
"validation-error",
|
|
2017
|
+
CX2_ISSUE.EXTRA_ELEMENTS,
|
|
2018
|
+
`${aspect} holds ${count} elements where CX2 allows one; all are kept, a reader uses the first`,
|
|
2019
|
+
{ element: aspect },
|
|
2020
|
+
);
|
|
2021
|
+
}
|
|
2022
|
+
}
|
|
2023
|
+
if (!doc.hasFragments) {
|
|
2024
|
+
for (const [aspect, blocks] of structure.blocks) {
|
|
2025
|
+
if (blocks > 1 && !STRUCTURE_ASPECTS.has(aspect)) {
|
|
2026
|
+
report.warnOnce(
|
|
2027
|
+
"validation-error",
|
|
2028
|
+
CX2_ISSUE.UNDECLARED_FRAGMENTS,
|
|
2029
|
+
`"${aspect}" comes in ${blocks} blocks although the descriptor does not declare hasFragments; the blocks are joined in order`,
|
|
2030
|
+
{ element: aspect },
|
|
2031
|
+
);
|
|
2032
|
+
}
|
|
2033
|
+
}
|
|
2034
|
+
}
|
|
2035
|
+
if (!structure.hasStatus) {
|
|
2036
|
+
report.error("validation-error", CX2_ISSUE.NO_STATUS, "the document ends without a status block", {
|
|
2037
|
+
element: "status",
|
|
2038
|
+
});
|
|
2039
|
+
} else if (!structure.statusWellFormed() || structure.counts.get("status") !== 1) {
|
|
2040
|
+
// the specification: exactly one status aspect holding exactly one element
|
|
2041
|
+
const count = structure.counts.get("status") ?? 0;
|
|
2042
|
+
report.error(
|
|
2043
|
+
"validation-error",
|
|
2044
|
+
CX2_ISSUE.NO_STATUS,
|
|
2045
|
+
`the status is malformed (${count === 1 ? shown(structure.status) : `${count} elements`}): a document ends with one status block holding one object with a boolean success`,
|
|
2046
|
+
{ element: "status" },
|
|
2047
|
+
);
|
|
2048
|
+
}
|
|
2049
|
+
structure.checkCounts();
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
/** Record the metadata: source format and version, name, description, id type, the kept aspects. */
|
|
2053
|
+
private setMeta(): void {
|
|
2054
|
+
const { doc } = this;
|
|
2055
|
+
const attrs = doc.networkAttributes[0]?.value;
|
|
2056
|
+
const named = isRecord(attrs) ? attrs : {};
|
|
2057
|
+
const extra: Record<string, unknown> = {};
|
|
2058
|
+
if (doc.opaque.size > 0) {
|
|
2059
|
+
extra.opaque = Object.fromEntries(doc.opaque);
|
|
2060
|
+
}
|
|
2061
|
+
if (Object.keys(this.decls.other).length > 0) {
|
|
2062
|
+
extra.declarations = this.decls.other;
|
|
2063
|
+
}
|
|
2064
|
+
const { weightFrom } = this.options;
|
|
2065
|
+
const weightDecl = weightFrom === null ? undefined : this.decls.edge.byName.get(weightFrom);
|
|
2066
|
+
const patch: GraphMetaPatch = {
|
|
2067
|
+
sourceFormat: CX2_FORMAT,
|
|
2068
|
+
sourceVersion: doc.version,
|
|
2069
|
+
idType: this.idType,
|
|
2070
|
+
...(typeof named.name === "string" ? { name: named.name } : {}),
|
|
2071
|
+
...(typeof named.description === "string" ? { description: named.description } : {}),
|
|
2072
|
+
...(this.weighted && weightFrom !== null
|
|
2073
|
+
? {
|
|
2074
|
+
weightOrigin: {
|
|
2075
|
+
format: CX2_FORMAT,
|
|
2076
|
+
id: weightDecl?.alias ?? null,
|
|
2077
|
+
title: weightFrom,
|
|
2078
|
+
type: weightDecl?.type?.d ?? null,
|
|
2079
|
+
namespace: null,
|
|
2080
|
+
},
|
|
2081
|
+
}
|
|
2082
|
+
: {}),
|
|
2083
|
+
extra: { cx2: extra },
|
|
2084
|
+
};
|
|
2085
|
+
this.sink.setMeta(patch);
|
|
2086
|
+
}
|
|
2087
|
+
}
|
|
2088
|
+
|
|
2089
|
+
/**
|
|
2090
|
+
* The dtype of a bypass column: f64 when every value is a number, bool when every one is a
|
|
2091
|
+
* boolean, string when every one is a string, json otherwise.
|
|
2092
|
+
* @param values - the values
|
|
2093
|
+
* @returns the dtype
|
|
2094
|
+
*/
|
|
2095
|
+
function bypassDtype(values: readonly unknown[]): Dtype {
|
|
2096
|
+
if (values.every((v) => typeof v === "number")) {
|
|
2097
|
+
return "f64";
|
|
2098
|
+
}
|
|
2099
|
+
if (values.every((v) => typeof v === "boolean")) {
|
|
2100
|
+
return "bool";
|
|
2101
|
+
}
|
|
2102
|
+
if (values.every((v) => typeof v === "string")) {
|
|
2103
|
+
return "string";
|
|
2104
|
+
}
|
|
2105
|
+
return "json";
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
/**
|
|
2109
|
+
* What a style aspect holds, for the W_STYLES_NOT_IMPORTED message.
|
|
2110
|
+
* @param aspect - the aspect name
|
|
2111
|
+
* @param elements - its elements
|
|
2112
|
+
* @returns a short description
|
|
2113
|
+
*/
|
|
2114
|
+
function describeStyle(aspect: string, elements: readonly unknown[]): string {
|
|
2115
|
+
const first = elements[0];
|
|
2116
|
+
if (aspect === "visualProperties" && isRecord(first)) {
|
|
2117
|
+
const count = (key: string): number => (isRecord(first[key]) ? Object.keys(first[key]).length : 0);
|
|
2118
|
+
const defaults = isRecord(first.default)
|
|
2119
|
+
? Object.values(first.default).reduce<number>((n, v) => n + (isRecord(v) ? Object.keys(v).length : 0), 0)
|
|
2120
|
+
: 0;
|
|
2121
|
+
return `visualProperties: ${defaults} default(s), ${count("nodeMapping")} node mapping(s), ${count("edgeMapping")} edge mapping(s)`;
|
|
2122
|
+
}
|
|
2123
|
+
return `${aspect}: ${elements.length} element(s)`;
|
|
2124
|
+
}
|
|
2125
|
+
|
|
2126
|
+
/**
|
|
2127
|
+
* Resolve the zAs option.
|
|
2128
|
+
* @param value - the option
|
|
2129
|
+
* @returns the mode; E_UNSUPPORTED for any other value
|
|
2130
|
+
*/
|
|
2131
|
+
export function zAsOption(value: unknown): "column" | "position" {
|
|
2132
|
+
if (value === undefined) {
|
|
2133
|
+
return "column";
|
|
2134
|
+
}
|
|
2135
|
+
if (value === "column" || value === "position") {
|
|
2136
|
+
return value;
|
|
2137
|
+
}
|
|
2138
|
+
throw new GraphFormatError("E_UNSUPPORTED", `option zAs: ${shown(value)} is not one of "column", "position"`, {
|
|
2139
|
+
option: "zAs",
|
|
2140
|
+
found: typeof value === "string" ? value : typeof value,
|
|
2141
|
+
});
|
|
2142
|
+
}
|
|
2143
|
+
|
|
2144
|
+
/**
|
|
2145
|
+
* The CX2 importer plugin (design section 1.3).
|
|
2146
|
+
*/
|
|
2147
|
+
export const cx2Importer: GraphImporter<Cx2ImportOptions> = Object.freeze({
|
|
2148
|
+
format: CX2_FORMAT,
|
|
2149
|
+
extensions: Object.freeze([".cx2"]),
|
|
2150
|
+
mimeTypes: Object.freeze(["application/json"]),
|
|
2151
|
+
|
|
2152
|
+
/**
|
|
2153
|
+
* Confidence that the head is CX2: 0.97 when the first member of a top-level array is an
|
|
2154
|
+
* object with a CXVersion key, in any key order.
|
|
2155
|
+
* @param head - the first bytes
|
|
2156
|
+
* @returns the confidence
|
|
2157
|
+
*/
|
|
2158
|
+
sniff(head: Uint8Array): number {
|
|
2159
|
+
const text = new TextDecoder("utf-8").decode(head.subarray(0, 1024)).replace(/^\uFEFF/, "");
|
|
2160
|
+
return /^\s*\[\s*\{(?:\s*"[^"]*"\s*:\s*(?:true|false|"[^"]*"|[0-9.]+)\s*,)*\s*"CXVersion"\s*:/.test(text)
|
|
2161
|
+
? 0.97
|
|
2162
|
+
: 0;
|
|
2163
|
+
},
|
|
2164
|
+
|
|
2165
|
+
/**
|
|
2166
|
+
* Read a CX2 document into the sink.
|
|
2167
|
+
* @param input - the text, bytes or stream
|
|
2168
|
+
* @param sink - the sink
|
|
2169
|
+
* @param options - format-specific and common options
|
|
2170
|
+
* @returns the import report; ImportError on a fatal error or beyond the error limit
|
|
2171
|
+
*/
|
|
2172
|
+
async import(
|
|
2173
|
+
input: ImportInput,
|
|
2174
|
+
sink: GraphSink,
|
|
2175
|
+
options?: Cx2ImportOptions & CommonImportOptions,
|
|
2176
|
+
): Promise<ImportReport> {
|
|
2177
|
+
const resolved = resolveImportOptions(options, FORMAT_DEFAULTS);
|
|
2178
|
+
const zAs = zAsOption(options?.zAs);
|
|
2179
|
+
const report = new ImportReportBuilder(CX2_FORMAT, resolved.errorLimit);
|
|
2180
|
+
reportSinkOptions(sink, options, report, true);
|
|
2181
|
+
reportUnusedOptions(options, report, USED_OPTIONS);
|
|
2182
|
+
const doc = await readDocument(input, report, resolved);
|
|
2183
|
+
new Cx2Reader(sink, report, resolved, zAs, doc).build();
|
|
2184
|
+
throwIfAborted(resolved.signal);
|
|
2185
|
+
return report.finish();
|
|
2186
|
+
},
|
|
2187
|
+
});
|