@kgdiff/formats 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +68 -2
- package/dist/detect.d.ts +47 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +425 -0
- package/dist/detect.js.map +1 -0
- package/dist/index.d.ts +203 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +114 -0
- package/dist/index.js.map +1 -0
- package/dist/iri.d.ts +13 -0
- package/dist/iri.d.ts.map +1 -0
- package/dist/iri.js +91 -0
- package/dist/iri.js.map +1 -0
- package/dist/json.d.ts +31 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +787 -0
- package/dist/json.js.map +1 -0
- package/dist/jsonld.d.ts +16 -0
- package/dist/jsonld.d.ts.map +1 -0
- package/dist/jsonld.js +1249 -0
- package/dist/jsonld.js.map +1 -0
- package/dist/obo.d.ts +25 -0
- package/dist/obo.d.ts.map +1 -0
- package/dist/obo.js +478 -0
- package/dist/obo.js.map +1 -0
- package/dist/prefixes.d.ts +49 -0
- package/dist/prefixes.d.ts.map +1 -0
- package/dist/prefixes.js +177 -0
- package/dist/prefixes.js.map +1 -0
- package/dist/rdf.d.ts +46 -0
- package/dist/rdf.d.ts.map +1 -0
- package/dist/rdf.js +1047 -0
- package/dist/rdf.js.map +1 -0
- package/dist/rdfxml.d.ts +8 -0
- package/dist/rdfxml.d.ts.map +1 -0
- package/dist/rdfxml.js +529 -0
- package/dist/rdfxml.js.map +1 -0
- package/dist/shared.d.ts +117 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +355 -0
- package/dist/shared.js.map +1 -0
- package/dist/xml.d.ts +50 -0
- package/dist/xml.d.ts.map +1 -0
- package/dist/xml.js +508 -0
- package/dist/xml.js.map +1 -0
- package/package.json +55 -3
- package/src/detect.ts +435 -0
- package/src/index.ts +296 -0
- package/src/iri.ts +86 -0
- package/src/json.ts +889 -0
- package/src/jsonld.ts +1376 -0
- package/src/obo.ts +557 -0
- package/src/prefixes.ts +195 -0
- package/src/rdf.ts +1181 -0
- package/src/rdfxml.ts +633 -0
- package/src/shared.ts +408 -0
- package/src/xml.ts +550 -0
package/dist/json.js
ADDED
|
@@ -0,0 +1,787 @@
|
|
|
1
|
+
import { KgdiffError } from '@kgdiff/core';
|
|
2
|
+
import { AttrBag, assertNever, compareStrings, getOwn, invalidInput, isRecord, lineAndColumn, MAX_NESTING, makeEdge, makeMeta, makeNode, plural, setOwn, sortEdges, sortNodes, stripBom, toAttrValue, } from './shared.js';
|
|
3
|
+
/**
|
|
4
|
+
* Maps a parsed JSON graph (see `parseJsonText`) in any supported dialect. Throws
|
|
5
|
+
* `KgdiffError('invalid-input')` for unrecognised shapes.
|
|
6
|
+
*/
|
|
7
|
+
export function parseJson(data, options, warnings) {
|
|
8
|
+
const shape = detectShape(data);
|
|
9
|
+
const mapper = new JsonMapper(shape, options?.fields ?? {}, warnings);
|
|
10
|
+
// Nodes first: d3 links may reference nodes by index.
|
|
11
|
+
const nodes = sortNodes(mapper.nodes());
|
|
12
|
+
const edges = sortEdges(mapper.edges());
|
|
13
|
+
mapper.reportDropped();
|
|
14
|
+
const graph = { nodes, edges };
|
|
15
|
+
const meta = graphMeta(shape);
|
|
16
|
+
const labels = meta?.predicateLabels;
|
|
17
|
+
const predicateLabels = isRecord(labels) && Object.values(labels).every((label) => typeof label === 'string')
|
|
18
|
+
? labels
|
|
19
|
+
: {};
|
|
20
|
+
return { graph: meta ? { ...graph, meta } : graph, dialect: shape.dialect, predicateLabels };
|
|
21
|
+
}
|
|
22
|
+
/** `JSON.parse`, with line / column in the `KgdiffError('invalid-input')` on bad input. */
|
|
23
|
+
export function parseJsonText(text) {
|
|
24
|
+
const source = stripBom(text);
|
|
25
|
+
try {
|
|
26
|
+
return JSON.parse(source);
|
|
27
|
+
}
|
|
28
|
+
catch (error) {
|
|
29
|
+
// Engines word (and locate) JSON errors differently; find the position ourselves.
|
|
30
|
+
const { offset, expected } = locateJsonError(source);
|
|
31
|
+
const at = lineAndColumn(source, offset);
|
|
32
|
+
const found = offset < source.length ? JSON.stringify(source[offset]) : 'end of input';
|
|
33
|
+
const reason = expected ? `expected ${expected}, found ${found}` : String(error);
|
|
34
|
+
throw invalidInput(`Invalid JSON on line ${at.line}, column ${at.column}: ${reason}.`, at);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/** Internal signal of `locateJsonError`; never escapes this module. */
|
|
38
|
+
class JsonSyntaxError {
|
|
39
|
+
offset;
|
|
40
|
+
expected;
|
|
41
|
+
constructor(offset, expected) {
|
|
42
|
+
this.offset = offset;
|
|
43
|
+
this.expected = expected;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const JSON_NUMBER = /-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/y;
|
|
47
|
+
const JSON_ESCAPE = /["\\/bfnrt]|u[0-9a-fA-F]{4}/y;
|
|
48
|
+
/**
|
|
49
|
+
* Finds the first syntax error in invalid JSON text (RFC 8259) and what was expected there.
|
|
50
|
+
* Only runs after `JSON.parse` failed, so it favours clarity over speed.
|
|
51
|
+
*/
|
|
52
|
+
function locateJsonError(text) {
|
|
53
|
+
let i = 0;
|
|
54
|
+
const fail = (expected) => {
|
|
55
|
+
throw new JsonSyntaxError(i, expected);
|
|
56
|
+
};
|
|
57
|
+
const space = () => {
|
|
58
|
+
while (i < text.length && ' \t\n\r'.includes(text[i] ?? ''))
|
|
59
|
+
i++;
|
|
60
|
+
};
|
|
61
|
+
const string = () => {
|
|
62
|
+
i++;
|
|
63
|
+
for (;;) {
|
|
64
|
+
const c = text[i];
|
|
65
|
+
if (c === undefined)
|
|
66
|
+
fail('a closing quote');
|
|
67
|
+
else if (c === '"') {
|
|
68
|
+
i++;
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
else if (c === '\\') {
|
|
72
|
+
i++;
|
|
73
|
+
JSON_ESCAPE.lastIndex = i;
|
|
74
|
+
if (!JSON_ESCAPE.test(text))
|
|
75
|
+
fail('an escape sequence');
|
|
76
|
+
i = JSON_ESCAPE.lastIndex;
|
|
77
|
+
}
|
|
78
|
+
else if (c < ' ')
|
|
79
|
+
fail('an escaped control character');
|
|
80
|
+
else
|
|
81
|
+
i++;
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
const value = (depth) => {
|
|
85
|
+
space();
|
|
86
|
+
const c = text[i];
|
|
87
|
+
if ((c === '{' || c === '[') && depth >= MAX_NESTING) {
|
|
88
|
+
const at = lineAndColumn(text, i);
|
|
89
|
+
throw invalidInput(`Invalid JSON on line ${at.line}, column ${at.column}: nested deeper than ${MAX_NESTING} levels.`, at);
|
|
90
|
+
}
|
|
91
|
+
if (c === '{') {
|
|
92
|
+
i++;
|
|
93
|
+
space();
|
|
94
|
+
if (text[i] === '}') {
|
|
95
|
+
i++;
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
for (;;) {
|
|
99
|
+
space();
|
|
100
|
+
if (text[i] !== '"')
|
|
101
|
+
fail('a property name in double quotes');
|
|
102
|
+
string();
|
|
103
|
+
space();
|
|
104
|
+
if (text[i] !== ':')
|
|
105
|
+
fail("':'");
|
|
106
|
+
i++;
|
|
107
|
+
value(depth + 1);
|
|
108
|
+
space();
|
|
109
|
+
if (text[i] === ',')
|
|
110
|
+
i++;
|
|
111
|
+
else if (text[i] === '}') {
|
|
112
|
+
i++;
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
else
|
|
116
|
+
fail("',' or '}'");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
if (c === '[') {
|
|
120
|
+
i++;
|
|
121
|
+
space();
|
|
122
|
+
if (text[i] === ']') {
|
|
123
|
+
i++;
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
for (;;) {
|
|
127
|
+
value(depth + 1);
|
|
128
|
+
space();
|
|
129
|
+
if (text[i] === ',')
|
|
130
|
+
i++;
|
|
131
|
+
else if (text[i] === ']') {
|
|
132
|
+
i++;
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
else
|
|
136
|
+
fail("',' or ']'");
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
if (c === '"') {
|
|
140
|
+
string();
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
for (const literal of ['true', 'false', 'null']) {
|
|
144
|
+
if (text.startsWith(literal, i)) {
|
|
145
|
+
i += literal.length;
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
JSON_NUMBER.lastIndex = i;
|
|
150
|
+
if (JSON_NUMBER.test(text)) {
|
|
151
|
+
i = JSON_NUMBER.lastIndex;
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
fail('a value');
|
|
155
|
+
};
|
|
156
|
+
try {
|
|
157
|
+
value(0);
|
|
158
|
+
space();
|
|
159
|
+
if (i < text.length)
|
|
160
|
+
fail('end of input');
|
|
161
|
+
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (error instanceof JsonSyntaxError)
|
|
164
|
+
return error;
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
return { offset: text.length, expected: undefined };
|
|
168
|
+
}
|
|
169
|
+
/** Top-level keys of AntV G6 v5 nodes / combos and edges. */
|
|
170
|
+
const G6_NODE_KEYS = new Set([
|
|
171
|
+
'id',
|
|
172
|
+
'type',
|
|
173
|
+
'data',
|
|
174
|
+
'style',
|
|
175
|
+
'states',
|
|
176
|
+
'combo',
|
|
177
|
+
'children',
|
|
178
|
+
'depth',
|
|
179
|
+
]);
|
|
180
|
+
const G6_EDGE_KEYS = new Set([
|
|
181
|
+
'id',
|
|
182
|
+
'source',
|
|
183
|
+
'target',
|
|
184
|
+
'type',
|
|
185
|
+
'data',
|
|
186
|
+
'style',
|
|
187
|
+
'states',
|
|
188
|
+
]);
|
|
189
|
+
const SHAPES_HELP = 'expected { nodes, edges } (kgdiff, AntV G6, graphology, vis-network), { nodes, links } (d3, ' +
|
|
190
|
+
'NetworkX) or { elements } (Cytoscape.js)';
|
|
191
|
+
/**
|
|
192
|
+
* Reads the kgdiff JSON marker: true for `kgdiff.graph` version 1, false when there is none.
|
|
193
|
+
* Only `kgdiff.*` formats are kgdiff's; any other `format` value is left to shape detection.
|
|
194
|
+
*/
|
|
195
|
+
function hasGraphMarker(data) {
|
|
196
|
+
const format = getOwn(data, 'format');
|
|
197
|
+
if (typeof format !== 'string' || !format.startsWith('kgdiff.'))
|
|
198
|
+
return false;
|
|
199
|
+
switch (format) {
|
|
200
|
+
case 'kgdiff.graph':
|
|
201
|
+
break;
|
|
202
|
+
case 'kgdiff.diff':
|
|
203
|
+
throw new KgdiffError('unsupported-version', 'This is a kgdiff diff (format "kgdiff.diff", as `kgdiff --json` writes it), not a graph.', { format });
|
|
204
|
+
case 'kgdiff.delta':
|
|
205
|
+
throw new KgdiffError('unsupported-version', 'This is a kgdiff delta (format "kgdiff.delta"), not a graph: apply it to a graph with `kgdiff apply`.', { format });
|
|
206
|
+
default:
|
|
207
|
+
throw new KgdiffError('unsupported-version', `Unknown kgdiff format "${format}"; expected "kgdiff.graph".`, { format });
|
|
208
|
+
}
|
|
209
|
+
const version = getOwn(data, 'version');
|
|
210
|
+
if (version !== 1) {
|
|
211
|
+
throw new KgdiffError('unsupported-version', `Unsupported kgdiff graph version ${String(version)}; this reader supports version 1.`, { version });
|
|
212
|
+
}
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
215
|
+
function detectShape(data) {
|
|
216
|
+
if (isRecord(data) && hasGraphMarker(data)) {
|
|
217
|
+
if (!Array.isArray(data.nodes))
|
|
218
|
+
throw invalidInput('A kgdiff graph needs a `nodes` array.');
|
|
219
|
+
const edges = arrayField(data, 'edges');
|
|
220
|
+
return {
|
|
221
|
+
dialect: 'kgdiff',
|
|
222
|
+
root: data,
|
|
223
|
+
nodes: data.nodes,
|
|
224
|
+
edges,
|
|
225
|
+
nodesKey: 'nodes',
|
|
226
|
+
edgesKey: 'edges',
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
if (Array.isArray(data)) {
|
|
230
|
+
if (data.length > 0 && data.every((element) => isRecord(element) && isRecord(element.data))) {
|
|
231
|
+
return cytoscapeShape(undefined, data);
|
|
232
|
+
}
|
|
233
|
+
throw invalidInput(`Unrecognized JSON graph: got an array; ${SHAPES_HELP}.`);
|
|
234
|
+
}
|
|
235
|
+
if (!isRecord(data))
|
|
236
|
+
throw invalidInput(`Unrecognized JSON graph: ${SHAPES_HELP}.`);
|
|
237
|
+
if ('elements' in data)
|
|
238
|
+
return cytoscapeShape(data, data.elements);
|
|
239
|
+
const nodes = data.nodes;
|
|
240
|
+
if (!Array.isArray(nodes)) {
|
|
241
|
+
throw invalidInput(`Unrecognized JSON graph: ${'nodes' in data ? '`nodes` is not an array' : 'no `nodes` array'}; ${SHAPES_HELP}.`);
|
|
242
|
+
}
|
|
243
|
+
if ('links' in data) {
|
|
244
|
+
return {
|
|
245
|
+
dialect: 'd3',
|
|
246
|
+
root: data,
|
|
247
|
+
nodes,
|
|
248
|
+
edges: arrayField(data, 'links'),
|
|
249
|
+
nodesKey: 'nodes',
|
|
250
|
+
edgesKey: 'links',
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
const edges = 'edges' in data ? arrayField(data, 'edges') : [];
|
|
254
|
+
const base = { root: data, nodes, edges, nodesKey: 'nodes', edgesKey: 'edges' };
|
|
255
|
+
const records = (list) => list.filter(isRecord);
|
|
256
|
+
const has = (element, key) => Object.hasOwn(element, key);
|
|
257
|
+
if (records(edges).some((edge) => has(edge, 'from') && has(edge, 'to') && !has(edge, 'source'))) {
|
|
258
|
+
return { ...base, dialect: 'vis' };
|
|
259
|
+
}
|
|
260
|
+
if (records(nodes).some((node) => has(node, 'key') && !has(node, 'id'))) {
|
|
261
|
+
return { ...base, dialect: 'graphology' };
|
|
262
|
+
}
|
|
263
|
+
const nodeRecords = records(nodes);
|
|
264
|
+
if (nodeRecords.length > 0 &&
|
|
265
|
+
nodeRecords.every((node) => !has(node, 'id') && isRecord(node.data) && has(node.data, 'id'))) {
|
|
266
|
+
return { ...base, dialect: 'cytoscape' };
|
|
267
|
+
}
|
|
268
|
+
const combos = Array.isArray(data.combos) ? data.combos : [];
|
|
269
|
+
const comboRecords = records(combos);
|
|
270
|
+
const edgeRecords = records(edges);
|
|
271
|
+
const wrapped = [...nodeRecords, ...edgeRecords, ...comboRecords].some((element) => isRecord(element.data) || isRecord(element.style));
|
|
272
|
+
// G6 v5 elements have nothing but these keys; any other key (label, predicate, attrs,
|
|
273
|
+
// types, …) means native kgdiff JSON that merely has a `data` or `style` attribute.
|
|
274
|
+
const only = (list, allowed) => list.every((element) => Object.keys(element).every((key) => allowed.has(key)));
|
|
275
|
+
if (wrapped &&
|
|
276
|
+
only(nodeRecords, G6_NODE_KEYS) &&
|
|
277
|
+
only(edgeRecords, G6_EDGE_KEYS) &&
|
|
278
|
+
only(comboRecords, G6_NODE_KEYS)) {
|
|
279
|
+
return { ...base, nodes: [...nodes, ...combos], dialect: 'g6' };
|
|
280
|
+
}
|
|
281
|
+
return { ...base, dialect: 'kgdiff' };
|
|
282
|
+
}
|
|
283
|
+
function cytoscapeShape(root, elements) {
|
|
284
|
+
if (Array.isArray(elements)) {
|
|
285
|
+
const nodes = [];
|
|
286
|
+
const edges = [];
|
|
287
|
+
for (const element of elements) {
|
|
288
|
+
const group = isRecord(element) ? element.group : undefined;
|
|
289
|
+
const data = isRecord(element) ? element.data : undefined;
|
|
290
|
+
const isEdge = group === 'edges' ||
|
|
291
|
+
(group !== 'nodes' && isRecord(data) && 'source' in data && 'target' in data);
|
|
292
|
+
(isEdge ? edges : nodes).push(element);
|
|
293
|
+
}
|
|
294
|
+
return { dialect: 'cytoscape', root, nodes, edges, nodesKey: 'elements', edgesKey: 'elements' };
|
|
295
|
+
}
|
|
296
|
+
if (isRecord(elements)) {
|
|
297
|
+
return {
|
|
298
|
+
dialect: 'cytoscape',
|
|
299
|
+
root,
|
|
300
|
+
nodes: Array.isArray(elements.nodes) ? elements.nodes : [],
|
|
301
|
+
edges: Array.isArray(elements.edges) ? elements.edges : [],
|
|
302
|
+
nodesKey: 'elements.nodes',
|
|
303
|
+
edgesKey: 'elements.edges',
|
|
304
|
+
};
|
|
305
|
+
}
|
|
306
|
+
throw invalidInput('Cytoscape.js `elements` must be an array or an object with `nodes` / `edges`.');
|
|
307
|
+
}
|
|
308
|
+
function arrayField(data, key) {
|
|
309
|
+
const value = data[key];
|
|
310
|
+
if (value === undefined || value === null)
|
|
311
|
+
return [];
|
|
312
|
+
if (!Array.isArray(value))
|
|
313
|
+
throw invalidInput(`\`${key}\` must be an array.`);
|
|
314
|
+
return value;
|
|
315
|
+
}
|
|
316
|
+
function graphMeta(shape) {
|
|
317
|
+
const root = shape.root;
|
|
318
|
+
if (!root)
|
|
319
|
+
return undefined;
|
|
320
|
+
if (shape.dialect === 'kgdiff') {
|
|
321
|
+
if (!isRecord(root.meta))
|
|
322
|
+
return undefined;
|
|
323
|
+
const meta = {};
|
|
324
|
+
for (const [key, value] of Object.entries(root.meta))
|
|
325
|
+
setOwn(meta, key, toAttrValue(value));
|
|
326
|
+
return makeMeta(meta);
|
|
327
|
+
}
|
|
328
|
+
const source = shape.dialect === 'graphology'
|
|
329
|
+
? root.attributes
|
|
330
|
+
: shape.dialect === 'd3'
|
|
331
|
+
? root.graph
|
|
332
|
+
: shape.dialect === 'cytoscape'
|
|
333
|
+
? root.data
|
|
334
|
+
: undefined;
|
|
335
|
+
if (!isRecord(source))
|
|
336
|
+
return undefined;
|
|
337
|
+
const text = (value) => typeof value === 'string' || typeof value === 'number' ? String(value) : undefined;
|
|
338
|
+
return makeMeta({ name: text(source.name), version: text(source.version) });
|
|
339
|
+
}
|
|
340
|
+
// ─── Element mapping ────────────────────────────────────────────────────────
|
|
341
|
+
/** Rendering / simulation state that would only add noise to a diff. */
|
|
342
|
+
const IGNORED_KEYS = {
|
|
343
|
+
kgdiff: { nodes: new Set(), edges: new Set() },
|
|
344
|
+
d3: { nodes: new Set(['index', 'x', 'y', 'vx', 'vy', 'fx', 'fy']), edges: new Set(['index']) },
|
|
345
|
+
// Cytoscape desktop session ids and selection state.
|
|
346
|
+
cytoscape: { nodes: new Set(['SUID', 'selected']), edges: new Set(['SUID', 'selected']) },
|
|
347
|
+
g6: { nodes: new Set(), edges: new Set() },
|
|
348
|
+
graphology: {
|
|
349
|
+
nodes: new Set(['x', 'y', 'size', 'color', 'hidden', 'forceLabel', 'zIndex', 'highlighted']),
|
|
350
|
+
edges: new Set(['size', 'color', 'hidden', 'forceLabel', 'zIndex']),
|
|
351
|
+
},
|
|
352
|
+
vis: {
|
|
353
|
+
nodes: new Set([
|
|
354
|
+
'x',
|
|
355
|
+
'y',
|
|
356
|
+
'fixed',
|
|
357
|
+
'physics',
|
|
358
|
+
'shape',
|
|
359
|
+
'color',
|
|
360
|
+
'size',
|
|
361
|
+
'font',
|
|
362
|
+
'borderWidth',
|
|
363
|
+
'borderWidthSelected',
|
|
364
|
+
'image',
|
|
365
|
+
'brokenImage',
|
|
366
|
+
'shadow',
|
|
367
|
+
'margin',
|
|
368
|
+
'widthConstraint',
|
|
369
|
+
'heightConstraint',
|
|
370
|
+
'level',
|
|
371
|
+
'mass',
|
|
372
|
+
'hidden',
|
|
373
|
+
'opacity',
|
|
374
|
+
'chosen',
|
|
375
|
+
'icon',
|
|
376
|
+
'scaling',
|
|
377
|
+
'shapeProperties',
|
|
378
|
+
'imagePadding',
|
|
379
|
+
'labelHighlightBold',
|
|
380
|
+
]),
|
|
381
|
+
edges: new Set([
|
|
382
|
+
'arrows',
|
|
383
|
+
'arrowStrikethrough',
|
|
384
|
+
'color',
|
|
385
|
+
'dashes',
|
|
386
|
+
'width',
|
|
387
|
+
'smooth',
|
|
388
|
+
'physics',
|
|
389
|
+
'hidden',
|
|
390
|
+
'length',
|
|
391
|
+
'font',
|
|
392
|
+
'chosen',
|
|
393
|
+
'endPointOffset',
|
|
394
|
+
'hoverWidth',
|
|
395
|
+
'selectionWidth',
|
|
396
|
+
'shadow',
|
|
397
|
+
'scaling',
|
|
398
|
+
'background',
|
|
399
|
+
'widthConstraint',
|
|
400
|
+
'selfReference',
|
|
401
|
+
'labelHighlightBold',
|
|
402
|
+
]),
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
/**
|
|
406
|
+
* One JSON element: the raw object and the record that holds its properties (`data` for
|
|
407
|
+
* Cytoscape / G6, `attributes` for graphology, the element itself otherwise). Tracks which
|
|
408
|
+
* keys were mapped, so unmapped record keys become attrs and unmapped raw keys are reported.
|
|
409
|
+
*/
|
|
410
|
+
class ElementReader {
|
|
411
|
+
raw;
|
|
412
|
+
record;
|
|
413
|
+
container;
|
|
414
|
+
ignored;
|
|
415
|
+
consumed = new Set();
|
|
416
|
+
consumedRaw = new Set();
|
|
417
|
+
constructor(raw, record,
|
|
418
|
+
/** Raw key holding the record, or undefined when the record is the element itself. */
|
|
419
|
+
container, ignored) {
|
|
420
|
+
this.raw = raw;
|
|
421
|
+
this.record = record;
|
|
422
|
+
this.container = container;
|
|
423
|
+
this.ignored = ignored;
|
|
424
|
+
}
|
|
425
|
+
/** Marks a record key as mapped, so it does not reappear in attrs. */
|
|
426
|
+
consume(key) {
|
|
427
|
+
this.consumed.add(key);
|
|
428
|
+
if (this.container === undefined)
|
|
429
|
+
this.consumedRaw.add(key);
|
|
430
|
+
}
|
|
431
|
+
/** Reads (and marks as mapped) a key of the raw element, outside the record. */
|
|
432
|
+
rawValue(key) {
|
|
433
|
+
this.consumedRaw.add(key);
|
|
434
|
+
if (this.container === undefined)
|
|
435
|
+
this.consumed.add(key);
|
|
436
|
+
return getOwn(this.raw, key);
|
|
437
|
+
}
|
|
438
|
+
/** First of `keys` holding a usable value (per `accept`); that key is consumed. */
|
|
439
|
+
first(keys, accept) {
|
|
440
|
+
for (const key of keys) {
|
|
441
|
+
const value = accept(getOwn(this.record, key));
|
|
442
|
+
if (value !== undefined) {
|
|
443
|
+
this.consume(key);
|
|
444
|
+
return value;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
return undefined;
|
|
448
|
+
}
|
|
449
|
+
/** Value at a dotted path, looked up in the record, then in the raw element. */
|
|
450
|
+
path(path, accept) {
|
|
451
|
+
const top = path.split('.')[0] ?? path;
|
|
452
|
+
const inRecord = accept(getPath(this.record, path));
|
|
453
|
+
if (inRecord !== undefined) {
|
|
454
|
+
if (top === path)
|
|
455
|
+
this.consume(path);
|
|
456
|
+
return inRecord;
|
|
457
|
+
}
|
|
458
|
+
const inRaw = accept(getPath(this.raw, path));
|
|
459
|
+
if (inRaw !== undefined)
|
|
460
|
+
this.consumedRaw.add(top);
|
|
461
|
+
return inRaw;
|
|
462
|
+
}
|
|
463
|
+
/** Remaining record keys as attributes (JSON values only, object keys sorted). */
|
|
464
|
+
rest(into) {
|
|
465
|
+
for (const key of Object.keys(this.record)) {
|
|
466
|
+
if (this.consumed.has(key) || this.ignored.has(key))
|
|
467
|
+
continue;
|
|
468
|
+
const value = toAttrValue(this.record[key]);
|
|
469
|
+
if (value !== undefined)
|
|
470
|
+
into.add(key, value);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
/** Keys that did not make it into the graph: rendering state and unmapped raw keys. */
|
|
474
|
+
dropped() {
|
|
475
|
+
const keys = Object.keys(this.record).filter((key) => this.ignored.has(key) && !this.consumed.has(key));
|
|
476
|
+
if (this.container !== undefined) {
|
|
477
|
+
for (const key of Object.keys(this.raw)) {
|
|
478
|
+
if (key !== this.container && !this.consumedRaw.has(key))
|
|
479
|
+
keys.push(key);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return keys;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/** G6 v5 keeps the visible label in `style.labelText`. */
|
|
486
|
+
function g6Label(reader) {
|
|
487
|
+
const label = asText(asRecord(getOwn(reader.raw, 'style'))?.labelText);
|
|
488
|
+
if (label !== undefined)
|
|
489
|
+
reader.rawValue('style');
|
|
490
|
+
return label;
|
|
491
|
+
}
|
|
492
|
+
function getPath(source, path) {
|
|
493
|
+
let current = source;
|
|
494
|
+
for (const part of path.split('.')) {
|
|
495
|
+
if (!isRecord(current))
|
|
496
|
+
return undefined;
|
|
497
|
+
current = getOwn(current, part);
|
|
498
|
+
}
|
|
499
|
+
return current;
|
|
500
|
+
}
|
|
501
|
+
const asId = (value) => typeof value === 'string'
|
|
502
|
+
? value
|
|
503
|
+
: typeof value === 'number' && Number.isFinite(value)
|
|
504
|
+
? String(value)
|
|
505
|
+
: undefined;
|
|
506
|
+
const asText = asId;
|
|
507
|
+
const asStrings = (value) => {
|
|
508
|
+
if (typeof value === 'string')
|
|
509
|
+
return [value];
|
|
510
|
+
if (Array.isArray(value) && value.every((item) => typeof item === 'string'))
|
|
511
|
+
return value;
|
|
512
|
+
return undefined;
|
|
513
|
+
};
|
|
514
|
+
const asIds = (value) => {
|
|
515
|
+
const single = asId(value);
|
|
516
|
+
if (single !== undefined)
|
|
517
|
+
return [single];
|
|
518
|
+
if (!Array.isArray(value))
|
|
519
|
+
return undefined;
|
|
520
|
+
const ids = value.map(asId);
|
|
521
|
+
return ids.every((id) => id !== undefined) ? ids : undefined;
|
|
522
|
+
};
|
|
523
|
+
const asBoolean = (value) => typeof value === 'boolean' ? value : undefined;
|
|
524
|
+
const asPoint = (value) => isRecord(value) && typeof value.x === 'number' && typeof value.y === 'number'
|
|
525
|
+
? { x: value.x, y: value.y }
|
|
526
|
+
: undefined;
|
|
527
|
+
const asRecord = (value) => (isRecord(value) ? value : undefined);
|
|
528
|
+
const LABEL_KEYS = ['label', 'name', 'title'];
|
|
529
|
+
const TYPE_KEYS = ['type', 'types', 'category', 'labels'];
|
|
530
|
+
const VIS_TYPE_KEYS = [...TYPE_KEYS, 'group'];
|
|
531
|
+
const ALIAS_KEYS = ['aliases', 'synonyms'];
|
|
532
|
+
/** `interaction` is Cytoscape's (desktop) name for the relation type. */
|
|
533
|
+
const PREDICATE_KEYS = ['predicate', 'relation', 'rel', 'type', 'interaction'];
|
|
534
|
+
class JsonMapper {
|
|
535
|
+
shape;
|
|
536
|
+
fields;
|
|
537
|
+
warnings;
|
|
538
|
+
ignored;
|
|
539
|
+
extraEdges = [];
|
|
540
|
+
/** d3 only: node ids by array index, for links that reference nodes by index. */
|
|
541
|
+
idsByIndex = [];
|
|
542
|
+
idSet = new Set();
|
|
543
|
+
/** Dropped field name → number of elements that had it. */
|
|
544
|
+
dropped = new Map();
|
|
545
|
+
constructor(shape, fields, warnings) {
|
|
546
|
+
this.shape = shape;
|
|
547
|
+
this.fields = fields;
|
|
548
|
+
this.warnings = warnings;
|
|
549
|
+
this.ignored = IGNORED_KEYS[shape.dialect];
|
|
550
|
+
}
|
|
551
|
+
nodes() {
|
|
552
|
+
const nodes = [];
|
|
553
|
+
this.shape.nodes.forEach((raw, index) => {
|
|
554
|
+
const node = this.node(raw, index);
|
|
555
|
+
if (node)
|
|
556
|
+
nodes.push(node);
|
|
557
|
+
});
|
|
558
|
+
return nodes;
|
|
559
|
+
}
|
|
560
|
+
edges() {
|
|
561
|
+
const edges = this.shape.edges.map((raw, index) => this.edge(raw, index));
|
|
562
|
+
return [...edges, ...this.extraEdges];
|
|
563
|
+
}
|
|
564
|
+
where(kind, index) {
|
|
565
|
+
return `${kind === 'node' ? this.shape.nodesKey : this.shape.edgesKey}[${index}]`;
|
|
566
|
+
}
|
|
567
|
+
reader(raw, kind, index) {
|
|
568
|
+
if (!isRecord(raw))
|
|
569
|
+
throw invalidInput(`${this.where(kind, index)} is not an object.`);
|
|
570
|
+
const ignored = kind === 'node' ? this.ignored.nodes : this.ignored.edges;
|
|
571
|
+
switch (this.shape.dialect) {
|
|
572
|
+
case 'cytoscape':
|
|
573
|
+
case 'g6': {
|
|
574
|
+
const data = getOwn(raw, 'data') ?? {};
|
|
575
|
+
if (!isRecord(data))
|
|
576
|
+
throw invalidInput(`${this.where(kind, index)}.data is not an object.`);
|
|
577
|
+
const reader = new ElementReader(raw, data, 'data', ignored);
|
|
578
|
+
if (this.shape.dialect === 'cytoscape')
|
|
579
|
+
reader.rawValue('group');
|
|
580
|
+
return reader;
|
|
581
|
+
}
|
|
582
|
+
case 'graphology': {
|
|
583
|
+
const attributes = getOwn(raw, 'attributes') ?? {};
|
|
584
|
+
if (!isRecord(attributes)) {
|
|
585
|
+
throw invalidInput(`${this.where(kind, index)}.attributes is not an object.`);
|
|
586
|
+
}
|
|
587
|
+
return new ElementReader(raw, attributes, 'attributes', ignored);
|
|
588
|
+
}
|
|
589
|
+
case 'kgdiff':
|
|
590
|
+
case 'd3':
|
|
591
|
+
case 'vis':
|
|
592
|
+
return new ElementReader(raw, raw, undefined, ignored);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
nodeId(reader, index) {
|
|
596
|
+
let id;
|
|
597
|
+
if (this.fields.id !== undefined)
|
|
598
|
+
id = reader.path(this.fields.id, asId);
|
|
599
|
+
else {
|
|
600
|
+
switch (this.shape.dialect) {
|
|
601
|
+
case 'cytoscape':
|
|
602
|
+
id = reader.first(['id'], asId);
|
|
603
|
+
break;
|
|
604
|
+
case 'g6':
|
|
605
|
+
id = asId(reader.rawValue('id')) ?? reader.first(['id'], asId);
|
|
606
|
+
break;
|
|
607
|
+
case 'graphology':
|
|
608
|
+
id = asId(reader.rawValue('key'));
|
|
609
|
+
break;
|
|
610
|
+
case 'kgdiff':
|
|
611
|
+
case 'd3':
|
|
612
|
+
case 'vis':
|
|
613
|
+
id = reader.first(['id'], asId);
|
|
614
|
+
break;
|
|
615
|
+
default:
|
|
616
|
+
assertNever(this.shape.dialect);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
if (id !== undefined)
|
|
620
|
+
return id;
|
|
621
|
+
if (this.shape.dialect === 'd3' && this.fields.id === undefined) {
|
|
622
|
+
this.warnings.count('json-index-ids', (n) => `${plural(n, 'node')} without an id: using the array index as id (d3 index links).`);
|
|
623
|
+
return String(index);
|
|
624
|
+
}
|
|
625
|
+
const expected = this.fields.id !== undefined
|
|
626
|
+
? `\`${this.fields.id}\``
|
|
627
|
+
: this.shape.dialect === 'graphology'
|
|
628
|
+
? '`key`'
|
|
629
|
+
: this.shape.dialect === 'cytoscape'
|
|
630
|
+
? '`data.id`'
|
|
631
|
+
: '`id`';
|
|
632
|
+
throw invalidInput(`${this.where('node', index)} has no id (expected ${expected} as a string or number).`, { index });
|
|
633
|
+
}
|
|
634
|
+
node(raw, index) {
|
|
635
|
+
const reader = this.reader(raw, 'node', index);
|
|
636
|
+
const id = this.nodeId(reader, index);
|
|
637
|
+
this.idsByIndex[index] = id;
|
|
638
|
+
this.idSet.add(id);
|
|
639
|
+
const { fields } = this;
|
|
640
|
+
const dialect = this.shape.dialect;
|
|
641
|
+
const label = fields.label !== undefined
|
|
642
|
+
? reader.path(fields.label, asText)
|
|
643
|
+
: (reader.first(LABEL_KEYS, asText) ?? (dialect === 'g6' ? g6Label(reader) : undefined));
|
|
644
|
+
const types = fields.type !== undefined
|
|
645
|
+
? reader.path(fields.type, asStrings)
|
|
646
|
+
: reader.first(dialect === 'vis' ? VIS_TYPE_KEYS : TYPE_KEYS, asStrings);
|
|
647
|
+
const aliases = reader.first(ALIAS_KEYS, asStrings);
|
|
648
|
+
const deprecated = reader.first(['deprecated'], asBoolean);
|
|
649
|
+
const replaces = reader.first(['replaces'], asIds);
|
|
650
|
+
const replacedBy = reader.first(['replacedBy'], asIds);
|
|
651
|
+
const position = dialect === 'kgdiff' ? reader.first(['position'], asPoint) : undefined;
|
|
652
|
+
// Core's graphToJSON marks nodes it created for dangling edge ends; normalize recreates them.
|
|
653
|
+
const implicit = dialect === 'kgdiff' ? reader.first(['implicit'], asBoolean) : undefined;
|
|
654
|
+
const parent = this.parentOf(reader);
|
|
655
|
+
if (parent !== undefined) {
|
|
656
|
+
this.extraEdges.push(makeEdge({ source: id, target: parent, predicate: 'parent' }));
|
|
657
|
+
}
|
|
658
|
+
const attrs = new AttrBag();
|
|
659
|
+
if (dialect === 'kgdiff') {
|
|
660
|
+
const own = reader.first(['attrs'], asRecord);
|
|
661
|
+
reader.rest(attrs);
|
|
662
|
+
// Explicit `attrs` win over loose top-level fields with the same key.
|
|
663
|
+
if (own)
|
|
664
|
+
mergeAttrs(attrs, own);
|
|
665
|
+
}
|
|
666
|
+
else
|
|
667
|
+
reader.rest(attrs);
|
|
668
|
+
this.noteDropped(reader);
|
|
669
|
+
const node = makeNode({
|
|
670
|
+
id,
|
|
671
|
+
label,
|
|
672
|
+
types,
|
|
673
|
+
aliases,
|
|
674
|
+
attrs: attrs.build(),
|
|
675
|
+
deprecated: deprecated === true,
|
|
676
|
+
replaces,
|
|
677
|
+
replacedBy,
|
|
678
|
+
position,
|
|
679
|
+
});
|
|
680
|
+
return implicit === true && Object.keys(node).length === 1 ? undefined : node;
|
|
681
|
+
}
|
|
682
|
+
/** Counts the keys an element loses, for one `json-ignored-fields` warning per parse. */
|
|
683
|
+
noteDropped(reader) {
|
|
684
|
+
for (const key of reader.dropped())
|
|
685
|
+
this.dropped.set(key, (this.dropped.get(key) ?? 0) + 1);
|
|
686
|
+
}
|
|
687
|
+
/** Reports fields that were dropped (rendering state, shape types, unknown wrapper keys). */
|
|
688
|
+
reportDropped() {
|
|
689
|
+
if (this.dropped.size === 0)
|
|
690
|
+
return;
|
|
691
|
+
const fields = [...this.dropped.entries()]
|
|
692
|
+
.sort(([a], [b]) => compareStrings(a, b))
|
|
693
|
+
.map(([key, count]) => `${key} (${count})`);
|
|
694
|
+
this.warnings.add('json-ignored-fields', `Ignored ${this.shape.dialect} fields that are not part of the graph: ${fields.join(', ')}.`);
|
|
695
|
+
}
|
|
696
|
+
/** Compound parents (Cytoscape `data.parent`, G6 `combo`) become `parent` edges. */
|
|
697
|
+
parentOf(reader) {
|
|
698
|
+
switch (this.shape.dialect) {
|
|
699
|
+
case 'cytoscape':
|
|
700
|
+
return reader.first(['parent'], asId);
|
|
701
|
+
case 'g6':
|
|
702
|
+
return asId(reader.rawValue('combo'));
|
|
703
|
+
default:
|
|
704
|
+
return undefined;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
endpoint(reader, end, index) {
|
|
708
|
+
const field = end === 'source' ? this.fields.source : this.fields.target;
|
|
709
|
+
const dialect = this.shape.dialect;
|
|
710
|
+
let value;
|
|
711
|
+
if (field !== undefined) {
|
|
712
|
+
value = reader.path(field, (v) => (v === undefined || v === null ? undefined : v));
|
|
713
|
+
}
|
|
714
|
+
else if (dialect === 'cytoscape') {
|
|
715
|
+
value = getOwn(reader.record, end);
|
|
716
|
+
reader.consume(end);
|
|
717
|
+
}
|
|
718
|
+
else {
|
|
719
|
+
value = reader.rawValue(dialect === 'vis' ? (end === 'source' ? 'from' : 'to') : end);
|
|
720
|
+
}
|
|
721
|
+
const id = dialect === 'd3' ? this.d3Endpoint(value) : asId(value);
|
|
722
|
+
if (id === undefined) {
|
|
723
|
+
const expected = field ?? (dialect === 'vis' ? (end === 'source' ? 'from' : 'to') : end);
|
|
724
|
+
throw invalidInput(`${this.where('edge', index)} has no ${end} (expected \`${expected}\` as a node id).`, { index });
|
|
725
|
+
}
|
|
726
|
+
return id;
|
|
727
|
+
}
|
|
728
|
+
/** d3 link ends: a node id, a node index (when no node has that id) or a node object. */
|
|
729
|
+
d3Endpoint(value) {
|
|
730
|
+
if (isRecord(value)) {
|
|
731
|
+
const id = asId(value.id);
|
|
732
|
+
if (id !== undefined)
|
|
733
|
+
return id;
|
|
734
|
+
return typeof value.index === 'number' ? this.idsByIndex[value.index] : undefined;
|
|
735
|
+
}
|
|
736
|
+
if (typeof value === 'number' && !this.idSet.has(String(value))) {
|
|
737
|
+
if (Number.isInteger(value) && value >= 0 && value < this.idsByIndex.length) {
|
|
738
|
+
return this.idsByIndex[value];
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
return asId(value);
|
|
742
|
+
}
|
|
743
|
+
edge(raw, index) {
|
|
744
|
+
const reader = this.reader(raw, 'edge', index);
|
|
745
|
+
const dialect = this.shape.dialect;
|
|
746
|
+
const source = this.endpoint(reader, 'source', index);
|
|
747
|
+
const target = this.endpoint(reader, 'target', index);
|
|
748
|
+
let id;
|
|
749
|
+
if (dialect === 'graphology')
|
|
750
|
+
id = asId(reader.rawValue('key'));
|
|
751
|
+
else if (dialect === 'g6')
|
|
752
|
+
id = asId(reader.rawValue('id')) ?? reader.first(['id'], asId);
|
|
753
|
+
else
|
|
754
|
+
id = reader.first(['id'], asId);
|
|
755
|
+
let predicate = this.fields.predicate !== undefined
|
|
756
|
+
? reader.path(this.fields.predicate, asText)
|
|
757
|
+
: reader.first(PREDICATE_KEYS, asText);
|
|
758
|
+
const label = reader.first(['label'], asText);
|
|
759
|
+
// Visualisation formats often name the relation only in `label`; kgdiff JSON never does
|
|
760
|
+
// (core omits a default label and an empty predicate), so it must round-trip as is.
|
|
761
|
+
if (dialect !== 'kgdiff')
|
|
762
|
+
predicate ??= label;
|
|
763
|
+
const attrs = new AttrBag();
|
|
764
|
+
if (dialect === 'kgdiff') {
|
|
765
|
+
const own = reader.first(['attrs'], asRecord);
|
|
766
|
+
reader.rest(attrs);
|
|
767
|
+
if (own)
|
|
768
|
+
mergeAttrs(attrs, own);
|
|
769
|
+
}
|
|
770
|
+
else
|
|
771
|
+
reader.rest(attrs);
|
|
772
|
+
if (dialect === 'graphology' && reader.rawValue('undirected') === true) {
|
|
773
|
+
attrs.add('undirected', true);
|
|
774
|
+
}
|
|
775
|
+
this.noteDropped(reader);
|
|
776
|
+
return makeEdge({ id, source, target, predicate, label, attrs: attrs.build() });
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
/** Overwrites bag entries with `own` (the explicit `attrs` object). */
|
|
780
|
+
function mergeAttrs(bag, own) {
|
|
781
|
+
for (const [key, value] of Object.entries(own)) {
|
|
782
|
+
const converted = toAttrValue(value);
|
|
783
|
+
if (converted !== undefined)
|
|
784
|
+
bag.set(key, converted);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
//# sourceMappingURL=json.js.map
|