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