@graphty/graph-io 0.3.3 → 0.3.4
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 +17 -4
- package/dist/chunks/{importer-BELrICWM.js → importer-BOxwmef_.js} +352 -19
- package/dist/chunks/importer-BOxwmef_.js.map +1 -0
- package/dist/graph-io.js +2 -2
- package/dist/graph-io.js.map +1 -1
- package/dist/json.js +1 -1
- package/dist/src/formats/json/dialect.d.ts +21 -4
- package/dist/src/formats/json/dialect.d.ts.map +1 -1
- package/dist/src/formats/json/dialect.js +29 -4
- package/dist/src/formats/json/dialect.js.map +1 -1
- package/dist/src/formats/json/exporter.d.ts.map +1 -1
- package/dist/src/formats/json/exporter.js +27 -7
- package/dist/src/formats/json/exporter.js.map +1 -1
- package/dist/src/formats/json/importer.d.ts +17 -4
- package/dist/src/formats/json/importer.d.ts.map +1 -1
- package/dist/src/formats/json/importer.js +356 -8
- package/dist/src/formats/json/importer.js.map +1 -1
- package/dist/src/formats/json/index.d.ts +1 -1
- package/dist/src/formats/json/index.d.ts.map +1 -1
- package/dist/src/formats/json/index.js +1 -1
- package/dist/src/formats/json/index.js.map +1 -1
- package/dist/src/index.d.ts +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js.map +1 -1
- package/dist/src/sniff.d.ts +3 -3
- package/dist/src/sniff.d.ts.map +1 -1
- package/dist/src/sniff.js.map +1 -1
- package/package.json +1 -1
- package/src/common/escape.ts +5 -4
- package/src/formats/json/dialect.ts +40 -6
- package/src/formats/json/exporter.ts +28 -7
- package/src/formats/json/importer.ts +411 -21
- package/src/formats/json/index.ts +7 -1
- package/src/formats/neo4j/exporter.ts +43 -6
- package/src/formats/neo4j/importer.ts +56 -11
- package/src/formats/neo4j/index.ts +10 -3
- package/src/formats/pajek/exporter.ts +3 -1
- package/src/formats/pajek/importer.ts +284 -60
- package/src/formats/pajek/syntax.ts +98 -12
- package/src/index.ts +2 -0
- package/src/sniff.ts +3 -3
- package/dist/chunks/importer-BELrICWM.js.map +0 -1
|
@@ -5,7 +5,13 @@
|
|
|
5
5
|
* index endpoints), JSON Graph Format v2 (nodes keyed by id, per-edge `directed`, hyperedges),
|
|
6
6
|
* Cytoscape.js elements (`data.id` / `data.source` / `data.target`, `position`, `classes`,
|
|
7
7
|
* `data.parent`), graphology serialisation (`key` / `attributes`, `undirected` edges, `options`)
|
|
8
|
-
*
|
|
8
|
+
* vis.js (`from` / `to`), and NetworkX adjacency_data (`nodes` + `adjacency`) and tree_data (nested
|
|
9
|
+
* `id` / `children`) -- and pushes it scalar by scalar into the sink.
|
|
10
|
+
*
|
|
11
|
+
* Python's json module writes the bare tokens NaN, Infinity and -Infinity, which strict JSON
|
|
12
|
+
* refuses; they are read as the JavaScript numbers with one warning. An integer literal beyond
|
|
13
|
+
* 2^53 is read as its exact digits (a string, the canonical id rule of the text formats) rather
|
|
14
|
+
* than a rounded float, again with one warning.
|
|
9
15
|
*
|
|
10
16
|
* JSON awaits the whole text (design section 8.4: `JSON.parse` on 100 MB is fine; a streaming
|
|
11
17
|
* tokeniser is a later improvement). The parsed records are iterated in place; the importer never
|
|
@@ -33,7 +39,7 @@ import { readText, throwIfAborted } from "../../common/input.js";
|
|
|
33
39
|
import { reportSinkOptions, reportUnusedOptions, resolveImportOptions, SINK_OPTION_CODE, } from "../../common/options.js";
|
|
34
40
|
import { ImportReportBuilder } from "../../common/report.js";
|
|
35
41
|
import { weightFromValue } from "../../common/weights.js";
|
|
36
|
-
import { CLASSES_COLUMN, CYTOSCAPE_ELEMENT_KEYS, CYTOSCAPE_STRUCTURAL_KEYS, DIALECT_DEFAULT_DIRECTED, hasKey,
|
|
42
|
+
import { CLASSES_COLUMN, CYTOSCAPE_ELEMENT_KEYS, CYTOSCAPE_STRUCTURAL_KEYS, DIALECT_DEFAULT_DIRECTED, hasKey, isJsonImportDialect, isJsonObject, JSON_IMPORT_DIALECTS, META_KEY, NODE_LINK_SOURCE_KEYS, NODE_LINK_TARGET_KEYS, PARENT_COLUMN, POSITION_COLUMN, sniffJsonDialect, SUFFIX, } from "./dialect.js";
|
|
37
43
|
/**
|
|
38
44
|
* The issue codes the JSON importer records (design section 8.6), by name: the codes shared with
|
|
39
45
|
* the other importers (src/common/codes.ts) and the JSON-specific ones. A key is the code without
|
|
@@ -96,6 +102,10 @@ export const JSON_ISSUE = Object.freeze({
|
|
|
96
102
|
INVALID_ENCODING: INVALID_ENCODING_CODE,
|
|
97
103
|
/** Bytes that are not UTF-8 and declare no encoding were read as windows-1252. */
|
|
98
104
|
ENCODING_FALLBACK: ENCODING_FALLBACK_CODE,
|
|
105
|
+
/** The document uses the non-standard tokens NaN / Infinity / -Infinity (Python's json writes them); read as numbers. */
|
|
106
|
+
NONSTANDARD_NUMBER: "W_JSON_NONSTANDARD_NUMBER",
|
|
107
|
+
/** Integer literals beyond 2^53 were read as their exact digits (strings), not as rounded numbers. */
|
|
108
|
+
BIG_INTEGER: "W_JSON_BIG_INTEGER",
|
|
99
109
|
/** A declared encoding the platform cannot decode was ignored. */
|
|
100
110
|
UNKNOWN_ENCODING: UNKNOWN_ENCODING_CODE,
|
|
101
111
|
});
|
|
@@ -152,8 +162,8 @@ const VIS_TARGET_KEYS = Object.freeze(["to"]);
|
|
|
152
162
|
function resolveJsonOptions(options) {
|
|
153
163
|
const o = options ?? {};
|
|
154
164
|
const dialect = o.dialect ?? "auto";
|
|
155
|
-
if (dialect !== "auto" && !
|
|
156
|
-
throw unsupportedOption("dialect", dialect, [...
|
|
165
|
+
if (dialect !== "auto" && !isJsonImportDialect(dialect)) {
|
|
166
|
+
throw unsupportedOption("dialect", dialect, [...JSON_IMPORT_DIALECTS, "auto"]);
|
|
157
167
|
}
|
|
158
168
|
const indexLinks = o.indexLinks ?? "auto";
|
|
159
169
|
if (indexLinks !== "auto" && typeof indexLinks !== "boolean") {
|
|
@@ -730,13 +740,145 @@ function parseDocument(text, report) {
|
|
|
730
740
|
if (text.trim().length === 0) {
|
|
731
741
|
report.fail(JSON_ISSUE.EMPTY_INPUT, "the input is empty");
|
|
732
742
|
}
|
|
743
|
+
let root;
|
|
744
|
+
let syntaxError = null;
|
|
733
745
|
try {
|
|
734
|
-
|
|
746
|
+
root = JSON.parse(text);
|
|
735
747
|
}
|
|
736
748
|
catch (err) {
|
|
737
|
-
|
|
738
|
-
|
|
749
|
+
syntaxError = err instanceof Error ? err.message : String(err);
|
|
750
|
+
}
|
|
751
|
+
// the fast path: strict JSON without a digit run long enough to be an unsafe integer
|
|
752
|
+
if (syntaxError === null && !MAYBE_UNSAFE_INTEGER.test(text)) {
|
|
753
|
+
return root;
|
|
754
|
+
}
|
|
755
|
+
const scan = rewriteNumbers(text);
|
|
756
|
+
if (scan.tokens.size > 0 || scan.bigIntegers.length > 0) {
|
|
757
|
+
try {
|
|
758
|
+
root = JSON.parse(scan.text, scan.tokens.size > 0 ? reviveNonstandard : undefined);
|
|
759
|
+
syntaxError = null;
|
|
760
|
+
}
|
|
761
|
+
catch {
|
|
762
|
+
// the rewrite did not make it valid JSON: report the parser's message on the original text
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
if (syntaxError !== null) {
|
|
766
|
+
return report.fail(JSON_ISSUE.SYNTAX, `invalid JSON: ${syntaxError}`);
|
|
767
|
+
}
|
|
768
|
+
if (scan.tokens.size > 0) {
|
|
769
|
+
report.warning("coercion", JSON_ISSUE.NONSTANDARD_NUMBER, `the document uses the non-standard token(s) ${[...scan.tokens].join(", ")}, which strict JSON does not allow; read as numbers`);
|
|
770
|
+
}
|
|
771
|
+
if (scan.bigIntegers.length > 0) {
|
|
772
|
+
const shown = scan.bigIntegers.slice(0, BIG_INTEGERS_SHOWN).join(", ");
|
|
773
|
+
const more = scan.bigIntegers.length - BIG_INTEGERS_SHOWN;
|
|
774
|
+
report.warning("precision", JSON_ISSUE.BIG_INTEGER, `${scan.bigIntegers.length} integer(s) beyond 2^53 kept as text so no digit is lost: ${shown}${more > 0 ? ` and ${more} more` : ""}`);
|
|
775
|
+
}
|
|
776
|
+
return root;
|
|
777
|
+
}
|
|
778
|
+
/** A run of 16 digits not inside a fraction: the shortest integer literal that can exceed 2^53 (9007199254740992). */
|
|
779
|
+
const MAYBE_UNSAFE_INTEGER = /(?<![0-9.])[0-9]{16}/;
|
|
780
|
+
/** How many of the integers kept as text the warning lists. */
|
|
781
|
+
const BIG_INTEGERS_SHOWN = 10;
|
|
782
|
+
/**
|
|
783
|
+
* The prefix of the string a non-standard token is rewritten to (a NUL character first, which no
|
|
784
|
+
* sensible attribute value starts with); reviveNonstandard() turns it back into the number.
|
|
785
|
+
*/
|
|
786
|
+
const NONSTANDARD_SENTINEL = `${String.fromCharCode(0)}graph-io:`;
|
|
787
|
+
/** The non-standard tokens Python's json module writes, longest first so -Infinity wins over a bare minus. */
|
|
788
|
+
const NONSTANDARD_TOKENS = [
|
|
789
|
+
["-Infinity", -Infinity],
|
|
790
|
+
["Infinity", Infinity],
|
|
791
|
+
["NaN", NaN],
|
|
792
|
+
];
|
|
793
|
+
/** A JSON integer literal (no fraction, no exponent, no leading zero), as CANONICAL_INTEGER in common/ids.ts. */
|
|
794
|
+
const INTEGER_LITERAL = /^-?(0|[1-9][0-9]*)$/;
|
|
795
|
+
/**
|
|
796
|
+
* Rewrite the numbers JSON.parse cannot read exactly, outside strings and in value positions only:
|
|
797
|
+
* NaN / Infinity / -Infinity become sentinel strings, an integer literal that is not a safe
|
|
798
|
+
* integer becomes a string of its digits. A container stack tells a value position (after `:`,
|
|
799
|
+
* `[`, or `,` inside an array) from a key position, so `{NaN: 1}` stays invalid.
|
|
800
|
+
* @param text - the document text
|
|
801
|
+
* @returns the rewritten text, the non-standard tokens seen and the integer literals quoted
|
|
802
|
+
*/
|
|
803
|
+
function rewriteNumbers(text) {
|
|
804
|
+
const parts = [];
|
|
805
|
+
const tokens = new Set();
|
|
806
|
+
const bigIntegers = [];
|
|
807
|
+
const arrays = [];
|
|
808
|
+
let expectValue = true;
|
|
809
|
+
let copied = 0;
|
|
810
|
+
let i = 0;
|
|
811
|
+
const n = text.length;
|
|
812
|
+
while (i < n) {
|
|
813
|
+
const ch = text[i];
|
|
814
|
+
if (ch === '"') {
|
|
815
|
+
i++;
|
|
816
|
+
while (i < n && text[i] !== '"') {
|
|
817
|
+
i += text[i] === "\\" ? 2 : 1;
|
|
818
|
+
}
|
|
819
|
+
i++;
|
|
820
|
+
expectValue = false;
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
if (ch === "{" || ch === "[") {
|
|
824
|
+
arrays.push(ch === "[");
|
|
825
|
+
expectValue = ch === "[";
|
|
826
|
+
}
|
|
827
|
+
else if (ch === "}" || ch === "]") {
|
|
828
|
+
arrays.pop();
|
|
829
|
+
expectValue = false;
|
|
830
|
+
}
|
|
831
|
+
else if (ch === ":") {
|
|
832
|
+
expectValue = true;
|
|
833
|
+
}
|
|
834
|
+
else if (ch === ",") {
|
|
835
|
+
expectValue = arrays.length > 0 && arrays[arrays.length - 1];
|
|
836
|
+
}
|
|
837
|
+
else if (expectValue && ch !== " " && ch !== "\t" && ch !== "\n" && ch !== "\r") {
|
|
838
|
+
expectValue = false;
|
|
839
|
+
const token = NONSTANDARD_TOKENS.find(([word]) => text.startsWith(word, i));
|
|
840
|
+
let end = i;
|
|
841
|
+
let replacement = null;
|
|
842
|
+
if (token !== undefined) {
|
|
843
|
+
end = i + token[0].length;
|
|
844
|
+
tokens.add(token[0]);
|
|
845
|
+
replacement = JSON.stringify(`${NONSTANDARD_SENTINEL}${token[0]}`);
|
|
846
|
+
}
|
|
847
|
+
else if (ch === "-" || (ch >= "0" && ch <= "9")) {
|
|
848
|
+
end = i + 1;
|
|
849
|
+
while (end < n && "0123456789+-.eE".includes(text[end])) {
|
|
850
|
+
end++;
|
|
851
|
+
}
|
|
852
|
+
const literal = text.slice(i, end);
|
|
853
|
+
if (INTEGER_LITERAL.test(literal) && !Number.isSafeInteger(Number(literal))) {
|
|
854
|
+
bigIntegers.push(literal);
|
|
855
|
+
replacement = `"${literal}"`;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
if (replacement !== null) {
|
|
859
|
+
parts.push(text.slice(copied, i), replacement);
|
|
860
|
+
copied = end;
|
|
861
|
+
}
|
|
862
|
+
i = Math.max(end, i + 1);
|
|
863
|
+
continue;
|
|
864
|
+
}
|
|
865
|
+
i++;
|
|
866
|
+
}
|
|
867
|
+
parts.push(text.slice(copied));
|
|
868
|
+
return { text: parts.join(""), tokens, bigIntegers };
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* The JSON.parse reviver that turns the sentinel strings of rewriteNumbers() back into numbers.
|
|
872
|
+
* @param _key - the member key (unused)
|
|
873
|
+
* @param value - the parsed value
|
|
874
|
+
* @returns the number for a sentinel string, the value otherwise
|
|
875
|
+
*/
|
|
876
|
+
function reviveNonstandard(_key, value) {
|
|
877
|
+
if (typeof value === "string" && value.startsWith(NONSTANDARD_SENTINEL)) {
|
|
878
|
+
const found = NONSTANDARD_TOKENS.find(([word]) => word === value.slice(NONSTANDARD_SENTINEL.length));
|
|
879
|
+
return found === undefined ? value : found[1];
|
|
739
880
|
}
|
|
881
|
+
return value;
|
|
740
882
|
}
|
|
741
883
|
/**
|
|
742
884
|
* The dialect to read: the forced one, else the shape rule of sniffJsonDialect(); a document
|
|
@@ -760,7 +902,7 @@ function detectDialect(root, forced, report) {
|
|
|
760
902
|
if (!isJsonObject(root)) {
|
|
761
903
|
return report.fail(JSON_ISSUE.DIALECT, `the document is a JSON ${describe(root)}, not a graph object`);
|
|
762
904
|
}
|
|
763
|
-
return report.fail(JSON_ISSUE.DIALECT, "no known dialect: expected nodes / links / edges (node-link), elements (Cytoscape) or graph (JGF)");
|
|
905
|
+
return report.fail(JSON_ISSUE.DIALECT, "no known dialect: expected nodes / links / edges (node-link), nodes / adjacency (adjacency), children (tree), elements (Cytoscape) or graph (JGF)");
|
|
764
906
|
}
|
|
765
907
|
/**
|
|
766
908
|
* A section that must be an array: fail when it is something else.
|
|
@@ -1151,6 +1293,206 @@ function importVis(ctx, root) {
|
|
|
1151
1293
|
}
|
|
1152
1294
|
ctx.setMeta({ dialect: "vis", nodeIdKey, sourceKey: sourceKey ?? undefined, targetKey: targetKey ?? undefined }, ctx.weightOriginPatch());
|
|
1153
1295
|
}
|
|
1296
|
+
// ============================================================ NetworkX adjacency_data / tree_data
|
|
1297
|
+
/**
|
|
1298
|
+
* Read a NetworkX adjacency_data document: `nodes` as in node-link, and `adjacency[i]` the
|
|
1299
|
+
* neighbour list of `nodes[i]`, one `{ id, key?, ...attributes }` entry per edge. An undirected
|
|
1300
|
+
* file lists every edge from both ends, so an entry whose mirror (the same pair and `key`) was
|
|
1301
|
+
* already read is that edge again and is not pushed twice; a self-loop is listed once.
|
|
1302
|
+
* @param ctx - the context
|
|
1303
|
+
* @param root - the document
|
|
1304
|
+
*/
|
|
1305
|
+
function importAdjacency(ctx, root) {
|
|
1306
|
+
const { report } = ctx;
|
|
1307
|
+
const nodes = arraySection(root.nodes, "nodes", report) ?? [];
|
|
1308
|
+
const adjacency = arraySection(root.adjacency, "adjacency", report);
|
|
1309
|
+
if (adjacency === null) {
|
|
1310
|
+
report.error("missing-value", JSON_ISSUE.MISSING_SECTION, "the document has no adjacency array; the graph has no edges", {
|
|
1311
|
+
element: "adjacency",
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
const directed = flagOf(root.directed, "directed", ctx.defaultDirected("adjacency"), report);
|
|
1315
|
+
const multigraph = hasKey(root, "multigraph") ? flagOf(root.multigraph, "multigraph", false, report) : null;
|
|
1316
|
+
ctx.setHeader(directed);
|
|
1317
|
+
// adjacency_data writes the graph dict as a list of [key, value] pairs
|
|
1318
|
+
ctx.writeGraphDict(isPairList(root.graph) ? Object.fromEntries(root.graph) : root.graph, "graph");
|
|
1319
|
+
for (const key of Object.keys(root)) {
|
|
1320
|
+
if (key !== "nodes" && key !== "adjacency" && key !== "directed" && key !== "multigraph" && key !== "graph") {
|
|
1321
|
+
report.warning("unsupported", JSON_ISSUE.UNREAD_KEY, `top-level key ${key} (${describe(root[key])}) is not read; dropped`, {
|
|
1322
|
+
element: key,
|
|
1323
|
+
});
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
const lists = adjacency ?? [];
|
|
1327
|
+
ctx.sink.reserve(nodes.length, lists.reduce((sum, list) => sum + (Array.isArray(list) ? list.length : 0), 0));
|
|
1328
|
+
const idKey = ctx.json.nodeIdKey ?? "id";
|
|
1329
|
+
ctx.reportNodeIdFrom("adjacency", `the ${JSON.stringify(idKey)} key`);
|
|
1330
|
+
const owners = [];
|
|
1331
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
1332
|
+
const element = `nodes[${i}]`;
|
|
1333
|
+
const record = nodes[i];
|
|
1334
|
+
let pushed = null;
|
|
1335
|
+
if (!isJsonObject(record)) {
|
|
1336
|
+
ctx.badElement("node", element);
|
|
1337
|
+
}
|
|
1338
|
+
else {
|
|
1339
|
+
const id = ctx.coerceId(hasKey(record, idKey) ? record[idKey] : undefined, element);
|
|
1340
|
+
if (id === null) {
|
|
1341
|
+
ctx.countSkipped("node");
|
|
1342
|
+
}
|
|
1343
|
+
else {
|
|
1344
|
+
const index = ctx.pushNode(id, element);
|
|
1345
|
+
if (index >= 0) {
|
|
1346
|
+
pushed = id;
|
|
1347
|
+
writeFlat(ctx, ctx.nodes, index, record, id, (key) => key !== idKey);
|
|
1348
|
+
}
|
|
1349
|
+
}
|
|
1350
|
+
}
|
|
1351
|
+
owners.push(pushed);
|
|
1352
|
+
}
|
|
1353
|
+
throwIfAborted(ctx.options.signal);
|
|
1354
|
+
const kind = ctx.uniformKind();
|
|
1355
|
+
const { weightFrom } = ctx.options;
|
|
1356
|
+
// undirected: entries read once whose mirror is still to come, by pair and key
|
|
1357
|
+
const pending = new Map();
|
|
1358
|
+
for (let i = 0; i < lists.length; i++) {
|
|
1359
|
+
const element = `adjacency[${i}]`;
|
|
1360
|
+
const list = lists[i];
|
|
1361
|
+
if (!Array.isArray(list)) {
|
|
1362
|
+
report.error("validation-error", JSON_ISSUE.BAD_ELEMENT, `${element} is not an array`, { element });
|
|
1363
|
+
continue;
|
|
1364
|
+
}
|
|
1365
|
+
const source = i < owners.length ? owners[i] : null;
|
|
1366
|
+
if (source === null) {
|
|
1367
|
+
const why = i < nodes.length ? `nodes[${i}] was skipped` : `there is no nodes[${i}]`;
|
|
1368
|
+
report.error("missing-value", JSON_ISSUE.BAD_INDEX, `${element}: ${why}; its ${list.length} edge(s) are skipped`, {
|
|
1369
|
+
element,
|
|
1370
|
+
});
|
|
1371
|
+
report.counts.skippedEdges += list.length;
|
|
1372
|
+
continue;
|
|
1373
|
+
}
|
|
1374
|
+
for (let j = 0; j < list.length; j++) {
|
|
1375
|
+
const entry = `${element}[${j}]`;
|
|
1376
|
+
const record = list[j];
|
|
1377
|
+
if (!isJsonObject(record)) {
|
|
1378
|
+
ctx.badElement("edge", entry);
|
|
1379
|
+
continue;
|
|
1380
|
+
}
|
|
1381
|
+
if (!hasKey(record, idKey)) {
|
|
1382
|
+
ctx.missingEndpoint(entry, idKey);
|
|
1383
|
+
continue;
|
|
1384
|
+
}
|
|
1385
|
+
try {
|
|
1386
|
+
const target = ctx.coerceId(record[idKey], `${entry}.${idKey}`);
|
|
1387
|
+
if (target === null) {
|
|
1388
|
+
ctx.countSkipped("edge");
|
|
1389
|
+
continue;
|
|
1390
|
+
}
|
|
1391
|
+
if (!directed && isMirroredEntry(pending, source, target, record.key)) {
|
|
1392
|
+
continue;
|
|
1393
|
+
}
|
|
1394
|
+
const edge = ctx.pushEdge(source, target, kind, ctx.weightOf(record), entry);
|
|
1395
|
+
for (const key of Object.keys(record)) {
|
|
1396
|
+
if (key !== idKey && key !== weightFrom) {
|
|
1397
|
+
ctx.edges.write(edge, key, record[key], SUFFIX.data);
|
|
1398
|
+
}
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
catch (err) {
|
|
1402
|
+
ctx.skip(err, "edge", entry);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
ctx.setMeta({}, { declaredMultigraph: multigraph, ...ctx.weightOriginPatch() });
|
|
1407
|
+
}
|
|
1408
|
+
/**
|
|
1409
|
+
* Whether an undirected adjacency entry is the mirror of one already read (the same unordered
|
|
1410
|
+
* pair and key), consuming it; otherwise the entry is remembered as awaiting its mirror. A
|
|
1411
|
+
* self-loop is listed once and never awaits one.
|
|
1412
|
+
* @param pending - the entries awaiting their mirror, by owner, other end and key
|
|
1413
|
+
* @param source - the owner of the list
|
|
1414
|
+
* @param target - the entry's node
|
|
1415
|
+
* @param key - the entry's multigraph key, or undefined
|
|
1416
|
+
* @returns true when the entry is a mirror and must not be pushed
|
|
1417
|
+
*/
|
|
1418
|
+
function isMirroredEntry(pending, source, target, key) {
|
|
1419
|
+
const a = JSON.stringify(source);
|
|
1420
|
+
const b = JSON.stringify(target);
|
|
1421
|
+
if (a === b) {
|
|
1422
|
+
return false;
|
|
1423
|
+
}
|
|
1424
|
+
const k = JSON.stringify(key ?? null);
|
|
1425
|
+
// an entry is the mirror of one listed by the other end, never of a parallel entry of its own list
|
|
1426
|
+
const mirror = `${b} ${a} ${k}`;
|
|
1427
|
+
const waiting = pending.get(mirror) ?? 0;
|
|
1428
|
+
if (waiting > 0) {
|
|
1429
|
+
pending.set(mirror, waiting - 1);
|
|
1430
|
+
return true;
|
|
1431
|
+
}
|
|
1432
|
+
const own = `${a} ${b} ${k}`;
|
|
1433
|
+
pending.set(own, (pending.get(own) ?? 0) + 1);
|
|
1434
|
+
return false;
|
|
1435
|
+
}
|
|
1436
|
+
/**
|
|
1437
|
+
* Whether a value is a list of [string, value] pairs, the shape adjacency_data gives the graph dict.
|
|
1438
|
+
* @param value - the value
|
|
1439
|
+
* @returns true for an array whose items are all two-element arrays with a string first
|
|
1440
|
+
*/
|
|
1441
|
+
function isPairList(value) {
|
|
1442
|
+
return Array.isArray(value) && value.every((p) => Array.isArray(p) && p.length === 2 && typeof p[0] === "string");
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Read a NetworkX tree_data document: a nested record with the node id, its attributes and a
|
|
1446
|
+
* `children` array of records of the same shape; every child gets a directed edge from its parent.
|
|
1447
|
+
* Walked depth first in document order with an explicit stack. A record without a usable id is
|
|
1448
|
+
* reported and skipped, and its children are still read (as roots, without an edge).
|
|
1449
|
+
* @param ctx - the context
|
|
1450
|
+
* @param root - the root record
|
|
1451
|
+
*/
|
|
1452
|
+
function importTree(ctx, root) {
|
|
1453
|
+
const idKey = ctx.json.nodeIdKey ?? "id";
|
|
1454
|
+
ctx.setHeader(ctx.defaultDirected("tree"));
|
|
1455
|
+
ctx.reportNodeIdFrom("tree", `the ${JSON.stringify(idKey)} key`);
|
|
1456
|
+
const kind = ctx.uniformKind();
|
|
1457
|
+
const stack = [
|
|
1458
|
+
{ record: root, element: "root", parent: null },
|
|
1459
|
+
];
|
|
1460
|
+
for (let item = stack.pop(); item !== undefined; item = stack.pop()) {
|
|
1461
|
+
const { record, element, parent } = item;
|
|
1462
|
+
if (!isJsonObject(record)) {
|
|
1463
|
+
ctx.badElement("node", element);
|
|
1464
|
+
continue;
|
|
1465
|
+
}
|
|
1466
|
+
let id = ctx.coerceId(hasKey(record, idKey) ? record[idKey] : undefined, element);
|
|
1467
|
+
if (id === null) {
|
|
1468
|
+
ctx.countSkipped("node");
|
|
1469
|
+
}
|
|
1470
|
+
else if (ctx.pushNode(id, element) < 0) {
|
|
1471
|
+
id = null;
|
|
1472
|
+
}
|
|
1473
|
+
else {
|
|
1474
|
+
writeFlat(ctx, ctx.nodes, ctx.sink.indexOf(id), record, id, (key) => key !== idKey && key !== "children");
|
|
1475
|
+
if (parent !== null) {
|
|
1476
|
+
try {
|
|
1477
|
+
ctx.pushEdge(parent, id, kind, undefined, element);
|
|
1478
|
+
}
|
|
1479
|
+
catch (err) {
|
|
1480
|
+
ctx.skip(err, "edge", element);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
const { children } = record;
|
|
1485
|
+
if (Array.isArray(children)) {
|
|
1486
|
+
for (let k = children.length - 1; k >= 0; k--) {
|
|
1487
|
+
stack.push({ record: children[k], element: `${element}.children[${k}]`, parent: id });
|
|
1488
|
+
}
|
|
1489
|
+
}
|
|
1490
|
+
else if (children !== undefined && children !== null) {
|
|
1491
|
+
ctx.report.error("validation-error", JSON_ISSUE.BAD_VALUE, `${element}: children must be an array, found ${describe(children)}`, { element });
|
|
1492
|
+
}
|
|
1493
|
+
}
|
|
1494
|
+
ctx.setMeta({});
|
|
1495
|
+
}
|
|
1154
1496
|
// ============================================================ graphology
|
|
1155
1497
|
/**
|
|
1156
1498
|
* Read a graphology serialisation: `options.type` decides the header direction ("mixed" or absent:
|
|
@@ -1885,6 +2227,12 @@ function readGraph(root, dialect, sink, report, resolved, json, options) {
|
|
|
1885
2227
|
case "vis":
|
|
1886
2228
|
importVis(ctx, doc);
|
|
1887
2229
|
break;
|
|
2230
|
+
case "adjacency":
|
|
2231
|
+
importAdjacency(ctx, doc);
|
|
2232
|
+
break;
|
|
2233
|
+
case "tree":
|
|
2234
|
+
importTree(ctx, doc);
|
|
2235
|
+
break;
|
|
1888
2236
|
default: {
|
|
1889
2237
|
const name = dialect;
|
|
1890
2238
|
throw new GraphFormatError("E_UNSUPPORTED", `unknown dialect ${name}`, {
|