@genrojs/bag 0.10.1
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 +176 -0
- package/NOTICE +7 -0
- package/README.md +191 -0
- package/docs/python-alignment.md +156 -0
- package/package.json +51 -0
- package/src/bag-node-container.js +459 -0
- package/src/bag-node.js +665 -0
- package/src/bag.js +2497 -0
- package/src/browser.js +7 -0
- package/src/devtools/html-repr.css +69 -0
- package/src/devtools/html-repr.js +78 -0
- package/src/devtools/index.js +1 -0
- package/src/index.js +9 -0
- package/src/resolver-wire.js +128 -0
- package/src/resolver.js +538 -0
- package/src/resolvers/StorageResolver.js +102 -0
- package/src/resolvers/UrlResolver.js +209 -0
- package/src/resolvers/UuidResolver.js +37 -0
- package/src/resolvers/index.js +9 -0
- package/src/subtype-wire.js +61 -0
package/src/bag.js
ADDED
|
@@ -0,0 +1,2497 @@
|
|
|
1
|
+
// Copyright 2025 Softwell S.r.l. - SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
import { BagNodeContainer } from './bag-node-container.js';
|
|
4
|
+
import { BagNode } from './bag-node.js';
|
|
5
|
+
import { toTytx as tytxEncode, fromTytx as tytxDecode, registerClass, getRegisteredType, getSubtypeDict, setSubtypeDict } from '@genrojs/tytx';
|
|
6
|
+
import { DOMParser as XmlDOMParser } from '@xmldom/xmldom';
|
|
7
|
+
import { BagCbResolver, BagResolver } from './resolver.js';
|
|
8
|
+
import { BagSerializationError, encodeResolver, decodeResolver, encodeAttrs, decodeAttrs } from './resolver-wire.js';
|
|
9
|
+
import { CLS_ATTRIBUTE, getClsMarker, getInheritedClass, getSubtypeClass } from './subtype-wire.js';
|
|
10
|
+
|
|
11
|
+
// Resolver caches are not structural branches on the wire: the descriptor
|
|
12
|
+
// owns that node, and serializing cached descendants would orphan them.
|
|
13
|
+
function* serializationNodes(bag, prefix = '') {
|
|
14
|
+
for (const node of bag) {
|
|
15
|
+
const path = prefix ? `${prefix}.${node.label}` : node.label;
|
|
16
|
+
yield [path, node];
|
|
17
|
+
const value = node.getValue(true);
|
|
18
|
+
if (!node.resolver && value instanceof Bag) yield* serializationNodes(value, path);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Bag - Hierarchical data container with path-based access.
|
|
24
|
+
*
|
|
25
|
+
* A Bag is an ordered container of BagNodes, accessible by label, numeric index,
|
|
26
|
+
* or hierarchical path. Nested elements can be accessed with dot-separated paths
|
|
27
|
+
* like 'a.b.c'.
|
|
28
|
+
*/
|
|
29
|
+
export class Bag {
|
|
30
|
+
static tytxSuffix = 'X';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Create a new Bag.
|
|
34
|
+
*
|
|
35
|
+
* @param {Object} [source=null] - Optional dict to initialize from.
|
|
36
|
+
*/
|
|
37
|
+
constructor(source = null) {
|
|
38
|
+
this._nodes = new BagNodeContainer();
|
|
39
|
+
this._backref = false;
|
|
40
|
+
this._parent = null;
|
|
41
|
+
this._parentNode = null;
|
|
42
|
+
this._rootAttributes = null;
|
|
43
|
+
// Subscriber dictionaries for events
|
|
44
|
+
this._updSubscribers = {};
|
|
45
|
+
this._insSubscribers = {};
|
|
46
|
+
this._delSubscribers = {};
|
|
47
|
+
|
|
48
|
+
if (source) {
|
|
49
|
+
this._loadSource(source);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// -------------------------------------------------------------------------
|
|
54
|
+
// Properties
|
|
55
|
+
// -------------------------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
get parent() {
|
|
58
|
+
return this._parent;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
set parent(value) {
|
|
62
|
+
this._parent = value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get parentNode() {
|
|
66
|
+
return this._parentNode;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
set parentNode(value) {
|
|
70
|
+
this._parentNode = value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
get backref() {
|
|
74
|
+
return Boolean(this._backref);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Node class used to create new nodes. Override in subclasses
|
|
79
|
+
* to use custom BagNode subclasses.
|
|
80
|
+
*
|
|
81
|
+
* @returns {Function} The BagNode constructor.
|
|
82
|
+
*/
|
|
83
|
+
get nodeClass() {
|
|
84
|
+
return BagNode;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
createChildBag() {
|
|
88
|
+
return new this.constructor();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Full path from root Bag to this Bag.
|
|
93
|
+
*
|
|
94
|
+
* Returns the dot-separated path from the root of the hierarchy to this
|
|
95
|
+
* Bag. Returns null if backref mode is not enabled or if this is the root.
|
|
96
|
+
*
|
|
97
|
+
* @returns {string|null} The full path or null.
|
|
98
|
+
*/
|
|
99
|
+
get fullpath() {
|
|
100
|
+
if (this._parent !== null && this._parentNode !== null) {
|
|
101
|
+
const parentFullpath = this._parent.fullpath;
|
|
102
|
+
if (parentFullpath) {
|
|
103
|
+
return `${parentFullpath}.${this._parentNode.label}`;
|
|
104
|
+
} else {
|
|
105
|
+
return this._parentNode.label;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Get dot-separated path from this Bag to a descendant node.
|
|
113
|
+
*
|
|
114
|
+
* Walks up from the node to this Bag collecting labels.
|
|
115
|
+
* Requires backref mode enabled.
|
|
116
|
+
*
|
|
117
|
+
* @param {BagNode} node - A descendant BagNode.
|
|
118
|
+
* @returns {string|null} The relative path, or null if not a descendant.
|
|
119
|
+
*/
|
|
120
|
+
relativePath(node) {
|
|
121
|
+
const parts = [];
|
|
122
|
+
let current = node;
|
|
123
|
+
while (current !== null) {
|
|
124
|
+
if (current.parentBag === this) {
|
|
125
|
+
parts.push(current.label);
|
|
126
|
+
parts.reverse();
|
|
127
|
+
return parts.join('.');
|
|
128
|
+
}
|
|
129
|
+
parts.push(current.label);
|
|
130
|
+
current = current.parentNode;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
get length() {
|
|
136
|
+
return this._nodes.length;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Root Bag of the hierarchy.
|
|
141
|
+
*
|
|
142
|
+
* Traverses the parent chain to find the topmost Bag. If this Bag has no
|
|
143
|
+
* parent, returns itself.
|
|
144
|
+
*
|
|
145
|
+
* @returns {Bag} The root Bag.
|
|
146
|
+
*/
|
|
147
|
+
get root() {
|
|
148
|
+
let curr = this;
|
|
149
|
+
while (curr.parent !== null) {
|
|
150
|
+
curr = curr.parent;
|
|
151
|
+
}
|
|
152
|
+
return curr;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Attributes of the node containing this Bag.
|
|
157
|
+
*
|
|
158
|
+
* Returns the attributes of the parent node that contains this Bag.
|
|
159
|
+
* Returns an empty object if this Bag has no parent node.
|
|
160
|
+
*
|
|
161
|
+
* @returns {Object} Attributes dictionary.
|
|
162
|
+
*/
|
|
163
|
+
get attributes() {
|
|
164
|
+
if (this._parentNode !== null) {
|
|
165
|
+
return this._parentNode.getAttr();
|
|
166
|
+
}
|
|
167
|
+
return {};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Root-level attributes for this Bag hierarchy.
|
|
172
|
+
*
|
|
173
|
+
* These are special attributes stored at the hierarchy level,
|
|
174
|
+
* independent of any node attributes.
|
|
175
|
+
*
|
|
176
|
+
* @returns {Object|null} Root attributes or null.
|
|
177
|
+
*/
|
|
178
|
+
get rootAttributes() {
|
|
179
|
+
return this._rootAttributes;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
set rootAttributes(attrs) {
|
|
183
|
+
this._rootAttributes = attrs !== null ? { ...attrs } : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// -------------------------------------------------------------------------
|
|
187
|
+
// _htraverse helpers
|
|
188
|
+
// -------------------------------------------------------------------------
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Parse path and handle #parent navigation.
|
|
192
|
+
*
|
|
193
|
+
* @param {string|Array} path - Dot-separated path or array of segments.
|
|
194
|
+
* @returns {Array} Tuple of [curr, pathlist].
|
|
195
|
+
*/
|
|
196
|
+
_htraverseBefore(path) {
|
|
197
|
+
let curr = this;
|
|
198
|
+
|
|
199
|
+
let pathlist;
|
|
200
|
+
if (typeof path === 'string') {
|
|
201
|
+
path = path.replace(/\.\.\//g, '#parent.');
|
|
202
|
+
pathlist = path.split('.').filter(x => x);
|
|
203
|
+
} else {
|
|
204
|
+
pathlist = [...path];
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Handle parent reference #parent at the beginning
|
|
208
|
+
while (pathlist.length && pathlist[0] === '#parent' && curr !== null) {
|
|
209
|
+
pathlist.shift();
|
|
210
|
+
curr = curr.parent;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return [curr, pathlist];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// -------------------------------------------------------------------------
|
|
217
|
+
// _htraverse
|
|
218
|
+
// -------------------------------------------------------------------------
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Traverse a hierarchical path.
|
|
222
|
+
*
|
|
223
|
+
* A `#parent` segment (also the `../` alias, expanded by _htraverseBefore)
|
|
224
|
+
* walks up to the parent Bag. It is handled both as a leading segment and
|
|
225
|
+
* inside the path; it requires backref/parent to be set, otherwise the
|
|
226
|
+
* traversal breaks (read → null; write → stops short).
|
|
227
|
+
*
|
|
228
|
+
* @param {string|Array} path - Path as dot-separated string or array.
|
|
229
|
+
* @param {boolean} [writeMode=false] - If true, create intermediate Bags.
|
|
230
|
+
* @param {boolean} [isStatic=true] - If true, don't trigger resolvers.
|
|
231
|
+
* @returns {Array} Tuple of [container, label].
|
|
232
|
+
*/
|
|
233
|
+
_htraverse(path, writeMode = false, isStatic = true) {
|
|
234
|
+
let [curr, pathlist] = this._htraverseBefore(path);
|
|
235
|
+
|
|
236
|
+
if (curr === null) {
|
|
237
|
+
return [null, null];
|
|
238
|
+
}
|
|
239
|
+
if (pathlist.length === 0) {
|
|
240
|
+
return [curr, ''];
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Traverse path segments
|
|
244
|
+
while (pathlist.length > 1 && curr instanceof Bag) {
|
|
245
|
+
const segment = pathlist[0];
|
|
246
|
+
|
|
247
|
+
// Inner #parent: walk up to the parent Bag, same behaviour as
|
|
248
|
+
// _htraverseBefore applies for leading segments. Without this
|
|
249
|
+
// branch the lookup below would fail (no node named '#parent')
|
|
250
|
+
// and traversal would break. Requires backref/parent to be set;
|
|
251
|
+
// otherwise it breaks (safe no-op). Aligns with Python #58.
|
|
252
|
+
if (segment === '#parent') {
|
|
253
|
+
if (curr.parent === null || curr.parent === undefined) {
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
pathlist.shift();
|
|
257
|
+
curr = curr.parent;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const node = curr._nodes.get(segment);
|
|
262
|
+
|
|
263
|
+
if (!node) {
|
|
264
|
+
break;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const value = node.getValue(isStatic);
|
|
268
|
+
|
|
269
|
+
if (value instanceof Bag) {
|
|
270
|
+
pathlist.shift();
|
|
271
|
+
curr = value;
|
|
272
|
+
} else if (writeMode) {
|
|
273
|
+
// Promote scalar to intermediate Bag
|
|
274
|
+
const newBag = curr.createChildBag();
|
|
275
|
+
node.setValue(newBag, true, null, true, true, 'autocreate');
|
|
276
|
+
pathlist.shift();
|
|
277
|
+
curr = newBag;
|
|
278
|
+
} else {
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Finalize
|
|
284
|
+
if (!writeMode) {
|
|
285
|
+
if (pathlist.length > 1) {
|
|
286
|
+
return [null, null];
|
|
287
|
+
}
|
|
288
|
+
return [curr, pathlist[0]];
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Write mode: create intermediate nodes
|
|
292
|
+
while (pathlist.length > 1) {
|
|
293
|
+
const label = pathlist.shift();
|
|
294
|
+
if (label.startsWith('#')) {
|
|
295
|
+
throw new BagException('Not existing index in #n syntax');
|
|
296
|
+
}
|
|
297
|
+
const newBag = curr.createChildBag();
|
|
298
|
+
curr._nodes.set(label, newBag, '>', null, curr, null, false, true, 'autocreate');
|
|
299
|
+
curr = newBag;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
return [curr, pathlist[0]];
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// -------------------------------------------------------------------------
|
|
306
|
+
// get (single level)
|
|
307
|
+
// -------------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* Get value at a single level (no path traversal).
|
|
311
|
+
*
|
|
312
|
+
* @param {string} label - Node label to look up.
|
|
313
|
+
* @param {*} [defaultValue=null] - Value to return if label not found.
|
|
314
|
+
* @param {boolean} [isStatic=true] - If true, don't trigger resolvers.
|
|
315
|
+
* @param {Object} [kwargs={}] - Additional kwargs passed to resolver.
|
|
316
|
+
* @returns {*} The node's value if found, otherwise default.
|
|
317
|
+
*/
|
|
318
|
+
get(label, defaultValue = null, isStatic = true, kwargs = {}) {
|
|
319
|
+
if (!label) {
|
|
320
|
+
return this;
|
|
321
|
+
}
|
|
322
|
+
if (label === '#parent') {
|
|
323
|
+
return this.parent;
|
|
324
|
+
}
|
|
325
|
+
// Parse queryString from label
|
|
326
|
+
let queryString = null;
|
|
327
|
+
if (label.includes('?')) {
|
|
328
|
+
[label, queryString] = label.split('?', 2);
|
|
329
|
+
}
|
|
330
|
+
const node = this._nodes.get(label);
|
|
331
|
+
if (!node) {
|
|
332
|
+
return defaultValue;
|
|
333
|
+
}
|
|
334
|
+
return node.getValue(isStatic, queryString, kwargs);
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// -------------------------------------------------------------------------
|
|
338
|
+
// getItem
|
|
339
|
+
// -------------------------------------------------------------------------
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Get value at a hierarchical path.
|
|
343
|
+
*
|
|
344
|
+
* @param {string} path - Hierarchical path like 'a.b.c'.
|
|
345
|
+
* @param {*} [defaultValue=null] - Value to return if path not found.
|
|
346
|
+
* @param {boolean} [isStatic=false] - If true, don't trigger resolvers.
|
|
347
|
+
* @param {Object} [kwargs={}] - Additional kwargs passed to resolver at final path.
|
|
348
|
+
* @returns {*} The value at the path if found, otherwise default.
|
|
349
|
+
*/
|
|
350
|
+
getItem(path, defaultValue = null, isStatic = false, kwargs = {}) {
|
|
351
|
+
if (!path) {
|
|
352
|
+
return this;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const [obj, label] = this._htraverse(path, false, isStatic);
|
|
356
|
+
|
|
357
|
+
if (obj instanceof Bag) {
|
|
358
|
+
return obj.get(label, defaultValue, isStatic, kwargs);
|
|
359
|
+
}
|
|
360
|
+
return defaultValue;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// -------------------------------------------------------------------------
|
|
364
|
+
// setItem
|
|
365
|
+
// -------------------------------------------------------------------------
|
|
366
|
+
|
|
367
|
+
/**
|
|
368
|
+
* Set value at a hierarchical path.
|
|
369
|
+
*
|
|
370
|
+
* An empty path raises RangeError without modifying the Bag.
|
|
371
|
+
*
|
|
372
|
+
* Resolver handling:
|
|
373
|
+
* - resolver=null (default): throw if node already has a resolver
|
|
374
|
+
* - resolver=false: remove existing resolver and set value
|
|
375
|
+
* - resolver=BagResolver: replace resolver
|
|
376
|
+
*
|
|
377
|
+
* @param {string} path - Hierarchical path like 'a.b.c'.
|
|
378
|
+
* @param {*} value - Value to set at the path.
|
|
379
|
+
* @param {Object} [attr=null] - Optional attributes to set on the node.
|
|
380
|
+
* @param {string|number|null} [nodePosition='>'] - Position for new nodes.
|
|
381
|
+
* @param {boolean} [updattr=false] - If false, clear existing attributes first.
|
|
382
|
+
* @param {boolean} [removeNullAttributes=true] - If true, remove null values from attributes.
|
|
383
|
+
* @param {string} [reason=null] - Reason for the change (for events).
|
|
384
|
+
* @param {boolean} [fired=false] - If true, reset value to null after setting.
|
|
385
|
+
* @param {boolean} [doTrigger=true] - If false, suppress events.
|
|
386
|
+
* @param {*} [resolver=null] - Resolver handling for existing nodes.
|
|
387
|
+
* @param {string} [nodeTag=null] - Semantic type tag for the node.
|
|
388
|
+
* @returns {BagNode} The created or updated BagNode.
|
|
389
|
+
*/
|
|
390
|
+
setItem(path, value, attr = null, nodePosition = '>', updattr = false,
|
|
391
|
+
removeNullAttributes = true, reason = null, fired = false,
|
|
392
|
+
doTrigger = true, resolver = null, nodeTag = null) {
|
|
393
|
+
if (path === '') {
|
|
394
|
+
throw new RangeError('setItem requires a non-empty path');
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const [obj, label] = this._htraverse(path, true);
|
|
398
|
+
|
|
399
|
+
return obj._nodes.set(label, value, nodePosition, attr, obj,
|
|
400
|
+
resolver, updattr, removeNullAttributes, reason, doTrigger, fired, nodeTag);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// -------------------------------------------------------------------------
|
|
404
|
+
// _pop (internal)
|
|
405
|
+
// -------------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Internal pop by label at current level.
|
|
409
|
+
*
|
|
410
|
+
* @param {string} label - Node label to remove.
|
|
411
|
+
* @param {string|null} [reason=null] - Reason for deletion (for events).
|
|
412
|
+
* @returns {BagNode|null} The removed BagNode, or null if not found.
|
|
413
|
+
*/
|
|
414
|
+
_pop(label, reason = null) {
|
|
415
|
+
const p = this._nodes.index(label);
|
|
416
|
+
if (p >= 0) {
|
|
417
|
+
const node = this._nodes.pop(p);
|
|
418
|
+
if (this.backref) {
|
|
419
|
+
this._onNodeDeleted(node, p, reason);
|
|
420
|
+
}
|
|
421
|
+
return node;
|
|
422
|
+
}
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// -------------------------------------------------------------------------
|
|
427
|
+
// pop
|
|
428
|
+
// -------------------------------------------------------------------------
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Remove a node and return its value.
|
|
432
|
+
*
|
|
433
|
+
* Traverses to the path, removes the node, and returns its value.
|
|
434
|
+
*
|
|
435
|
+
* @param {string} path - Hierarchical path to the node to remove.
|
|
436
|
+
* @param {*} [defaultValue=null] - Value to return if path not found.
|
|
437
|
+
* @param {string|null} [reason=null] - Reason for deletion (for events).
|
|
438
|
+
* @returns {*} The value of the removed node, or default if not found.
|
|
439
|
+
*/
|
|
440
|
+
pop(path, defaultValue = null, reason = null) {
|
|
441
|
+
const node = this.popNode(path, reason);
|
|
442
|
+
return node ? node.value : defaultValue;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
/**
|
|
446
|
+
* Alias for pop.
|
|
447
|
+
*/
|
|
448
|
+
delItem(path, defaultValue = null, reason = null) {
|
|
449
|
+
return this.pop(path, defaultValue, reason);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// -------------------------------------------------------------------------
|
|
453
|
+
// popNode
|
|
454
|
+
// -------------------------------------------------------------------------
|
|
455
|
+
|
|
456
|
+
/**
|
|
457
|
+
* Remove and return the BagNode at a path.
|
|
458
|
+
*
|
|
459
|
+
* Like pop(), but returns the entire BagNode instead of just its value.
|
|
460
|
+
*
|
|
461
|
+
* @param {string} path - Hierarchical path to the node to remove.
|
|
462
|
+
* @param {string|null} [reason=null] - Reason for deletion (for events).
|
|
463
|
+
* @returns {BagNode|null} The removed BagNode, or null if not found.
|
|
464
|
+
*/
|
|
465
|
+
popNode(path, reason = null) {
|
|
466
|
+
const [obj, label] = this._htraverse(path, false, true);
|
|
467
|
+
if (!obj || !label) return null;
|
|
468
|
+
const node = obj._nodes.get(label);
|
|
469
|
+
if (!node) return null;
|
|
470
|
+
try {
|
|
471
|
+
return obj._pop(label, reason);
|
|
472
|
+
} finally {
|
|
473
|
+
// Subscribers may fail, reinsert the node, or transfer ownership.
|
|
474
|
+
if (node.parentBag === obj && obj._nodes._dict[node.label] !== node) {
|
|
475
|
+
node.parentBag = null;
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// -------------------------------------------------------------------------
|
|
481
|
+
// clear
|
|
482
|
+
// -------------------------------------------------------------------------
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Remove all nodes from this Bag.
|
|
486
|
+
*
|
|
487
|
+
* Empties the Bag completely. In backref mode, triggers delete events
|
|
488
|
+
* for all removed nodes.
|
|
489
|
+
*/
|
|
490
|
+
clear() {
|
|
491
|
+
const oldNodes = [...this._nodes];
|
|
492
|
+
this._nodes.clear();
|
|
493
|
+
if (this.backref) {
|
|
494
|
+
this._onNodeDeleted(oldNodes, -1);
|
|
495
|
+
}
|
|
496
|
+
for (const node of oldNodes) {
|
|
497
|
+
node.parentBag = null;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// -------------------------------------------------------------------------
|
|
502
|
+
// getNode
|
|
503
|
+
// -------------------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
/**
|
|
506
|
+
* Get the BagNode at a path (not its value).
|
|
507
|
+
*
|
|
508
|
+
* @param {string|number|null} path - Hierarchical path like 'a.b.c', integer index, or null.
|
|
509
|
+
* @param {boolean} [isStatic=true] - If true, don't trigger resolvers.
|
|
510
|
+
* @param {boolean} [autocreate=false] - If true, create node if not found.
|
|
511
|
+
* @param {*} [defaultValue=null] - Default value for autocreated node.
|
|
512
|
+
* @returns {BagNode|null} The BagNode if found, null otherwise.
|
|
513
|
+
*/
|
|
514
|
+
getNode(path, isStatic = true, autocreate = false, defaultValue = null) {
|
|
515
|
+
if (path === null || path === undefined || path === '') {
|
|
516
|
+
return this.parentNode;
|
|
517
|
+
}
|
|
518
|
+
if (typeof path === 'number') {
|
|
519
|
+
return this._nodes.get(path);
|
|
520
|
+
}
|
|
521
|
+
if (typeof path === 'string') {
|
|
522
|
+
path = path.split('?', 1)[0];
|
|
523
|
+
}
|
|
524
|
+
const [obj, label] = this._htraverse(path, autocreate, isStatic);
|
|
525
|
+
if (obj instanceof Bag && label) {
|
|
526
|
+
const node = obj._nodes.get(label);
|
|
527
|
+
if (node) {
|
|
528
|
+
return node;
|
|
529
|
+
}
|
|
530
|
+
if (autocreate) {
|
|
531
|
+
obj._nodes.set(label, defaultValue, '>', null, obj, null, false, true,
|
|
532
|
+
'autocreate');
|
|
533
|
+
return obj._nodes.get(label);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// -------------------------------------------------------------------------
|
|
540
|
+
// Backref system
|
|
541
|
+
// -------------------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
/**
|
|
544
|
+
* Enable backref mode (tree-leaf model with parent references).
|
|
545
|
+
*
|
|
546
|
+
* @param {BagNode|null} [node=null] - The BagNode that contains this Bag.
|
|
547
|
+
* @param {Bag|null} [parent=null] - The parent Bag.
|
|
548
|
+
*/
|
|
549
|
+
setBackref(node = null, parent = null) {
|
|
550
|
+
const alreadyEnabled = this._backref;
|
|
551
|
+
this._backref = true;
|
|
552
|
+
this._parent = parent;
|
|
553
|
+
this._parentNode = node;
|
|
554
|
+
if (alreadyEnabled) return;
|
|
555
|
+
this._nodes._parentBag = this;
|
|
556
|
+
for (const n of this) {
|
|
557
|
+
n.parentBag = this;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* Clear parent reference and disable backref.
|
|
563
|
+
*/
|
|
564
|
+
delParentRef() {
|
|
565
|
+
this._parent = null;
|
|
566
|
+
this._parentNode = null;
|
|
567
|
+
this._backref = false;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Clear all backref assumptions recursively.
|
|
572
|
+
*/
|
|
573
|
+
clearBackref() {
|
|
574
|
+
if (this._backref) {
|
|
575
|
+
this._backref = false;
|
|
576
|
+
this._parent = null;
|
|
577
|
+
this._parentNode = null;
|
|
578
|
+
this._nodes._parentBag = null;
|
|
579
|
+
for (const node of this) {
|
|
580
|
+
node.parentBag = null;
|
|
581
|
+
const value = node.getValue(true);
|
|
582
|
+
if (value instanceof Bag) {
|
|
583
|
+
value.clearBackref();
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// -------------------------------------------------------------------------
|
|
590
|
+
// Event triggers
|
|
591
|
+
// -------------------------------------------------------------------------
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* Trigger for node change events.
|
|
595
|
+
*
|
|
596
|
+
* @param {BagNode} node - The changed node.
|
|
597
|
+
* @param {string[]} pathlist - Path to the node.
|
|
598
|
+
* @param {string} evt - Event type ('upd_value', 'upd_attrs', 'upd_value_attr').
|
|
599
|
+
* @param {*} [oldvalue=null] - Previous value (for value changes).
|
|
600
|
+
* @param {Object|null} [attrsDiff=null] - Attribute diff dict { name: { old, new } }
|
|
601
|
+
* (for attribute changes).
|
|
602
|
+
* @param {string|null} [reason=null] - Reason for change.
|
|
603
|
+
* @param {boolean} [fired=false] - True when the change is a fired write.
|
|
604
|
+
*/
|
|
605
|
+
_onNodeChanged(node, pathlist, evt, oldvalue = null, attrsDiff = null, reason = null,
|
|
606
|
+
fired = false) {
|
|
607
|
+
for (const s of Object.values(this._updSubscribers)) {
|
|
608
|
+
s({ node, pathlist, oldvalue, attrs_diff: attrsDiff, evt, reason, fired });
|
|
609
|
+
}
|
|
610
|
+
if (this._parent && this._parentNode) {
|
|
611
|
+
this._parent._onNodeChanged(
|
|
612
|
+
node,
|
|
613
|
+
[this._parentNode.label, ...pathlist],
|
|
614
|
+
evt,
|
|
615
|
+
oldvalue,
|
|
616
|
+
attrsDiff,
|
|
617
|
+
reason,
|
|
618
|
+
fired
|
|
619
|
+
);
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
/**
|
|
624
|
+
* Trigger for node insert events.
|
|
625
|
+
*
|
|
626
|
+
* @param {BagNode} node - The inserted node.
|
|
627
|
+
* @param {number} ind - Index where inserted.
|
|
628
|
+
* @param {string[]|null} [pathlist=null] - Path to the node.
|
|
629
|
+
* @param {string|null} [reason=null] - Reason for insertion.
|
|
630
|
+
* @param {boolean} [fired=false] - True when the insertion is a fired write.
|
|
631
|
+
*/
|
|
632
|
+
_onNodeInserted(node, ind, pathlist = null, reason = null, fired = false) {
|
|
633
|
+
const parent = node.parentBag;
|
|
634
|
+
const value = node.getValue(true);
|
|
635
|
+
if (parent !== null && parent.backref && value instanceof Bag) {
|
|
636
|
+
value.setBackref(node, parent);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
if (pathlist === null) {
|
|
640
|
+
pathlist = [];
|
|
641
|
+
}
|
|
642
|
+
for (const s of Object.values(this._insSubscribers)) {
|
|
643
|
+
s({ node, pathlist, ind, evt: 'ins', reason, fired });
|
|
644
|
+
}
|
|
645
|
+
if (this._parent && this._parentNode) {
|
|
646
|
+
this._parent._onNodeInserted(
|
|
647
|
+
node,
|
|
648
|
+
ind,
|
|
649
|
+
[this._parentNode.label, ...pathlist],
|
|
650
|
+
reason,
|
|
651
|
+
fired
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Trigger for node delete events.
|
|
658
|
+
*
|
|
659
|
+
* @param {BagNode|BagNode[]} node - The deleted node(s).
|
|
660
|
+
* @param {number} ind - Index where deleted (-1 for clear).
|
|
661
|
+
* @param {string[]|null} [pathlist=null] - Path to the node.
|
|
662
|
+
* @param {string|null} [reason=null] - Reason for deletion.
|
|
663
|
+
*/
|
|
664
|
+
_onNodeDeleted(node, ind, pathlist = null, reason = null) {
|
|
665
|
+
for (const s of Object.values(this._delSubscribers)) {
|
|
666
|
+
s({ node, pathlist, ind, evt: 'del', reason });
|
|
667
|
+
}
|
|
668
|
+
if (this._parent && this._parentNode) {
|
|
669
|
+
if (pathlist === null) {
|
|
670
|
+
pathlist = [];
|
|
671
|
+
}
|
|
672
|
+
this._parent._onNodeDeleted(
|
|
673
|
+
node,
|
|
674
|
+
ind,
|
|
675
|
+
[this._parentNode.label, ...pathlist],
|
|
676
|
+
reason
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
// -------------------------------------------------------------------------
|
|
682
|
+
// Subscription
|
|
683
|
+
// -------------------------------------------------------------------------
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* Internal subscribe helper.
|
|
687
|
+
*/
|
|
688
|
+
_subscribe(subscriberId, subscribersDict, callback) {
|
|
689
|
+
if (callback !== null && callback !== undefined) {
|
|
690
|
+
subscribersDict[subscriberId] = callback;
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/**
|
|
695
|
+
* Subscribe to bag events.
|
|
696
|
+
*
|
|
697
|
+
* @param {string} subscriberId - Unique identifier for this subscription.
|
|
698
|
+
* @param {Object} options - Subscription options.
|
|
699
|
+
* @param {Function} [options.update] - Callback for update events.
|
|
700
|
+
* @param {Function} [options.insert] - Callback for insert events.
|
|
701
|
+
* @param {Function} [options.delete] - Callback for delete events.
|
|
702
|
+
* @param {Function} [options.any] - Callback for all events.
|
|
703
|
+
*/
|
|
704
|
+
subscribe(subscriberId, { update = null, insert = null, delete: del = null, any = null } = {}) {
|
|
705
|
+
if (!this.backref) {
|
|
706
|
+
this.setBackref();
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
this._subscribe(subscriberId, this._updSubscribers, update || any);
|
|
710
|
+
this._subscribe(subscriberId, this._insSubscribers, insert || any);
|
|
711
|
+
this._subscribe(subscriberId, this._delSubscribers, del || any);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Unsubscribe from bag events.
|
|
716
|
+
*
|
|
717
|
+
* @param {string} subscriberId - The subscription identifier to remove.
|
|
718
|
+
* @param {Object} options - Unsubscription options.
|
|
719
|
+
* @param {boolean} [options.update=false] - Remove update subscription.
|
|
720
|
+
* @param {boolean} [options.insert=false] - Remove insert subscription.
|
|
721
|
+
* @param {boolean} [options.delete=false] - Remove delete subscription.
|
|
722
|
+
* @param {boolean} [options.any=false] - Remove all subscriptions.
|
|
723
|
+
*/
|
|
724
|
+
unsubscribe(subscriberId, { update = false, insert = false, delete: del = false, any = false } = {}) {
|
|
725
|
+
if (update || any) {
|
|
726
|
+
delete this._updSubscribers[subscriberId];
|
|
727
|
+
}
|
|
728
|
+
if (insert || any) {
|
|
729
|
+
delete this._insSubscribers[subscriberId];
|
|
730
|
+
}
|
|
731
|
+
if (del || any) {
|
|
732
|
+
delete this._delSubscribers[subscriberId];
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// -------------------------------------------------------------------------
|
|
737
|
+
// Iteration
|
|
738
|
+
// -------------------------------------------------------------------------
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
* Iterate over BagNodes.
|
|
742
|
+
*/
|
|
743
|
+
[Symbol.iterator]() {
|
|
744
|
+
return this._nodes[Symbol.iterator]();
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Return node labels in order.
|
|
749
|
+
*
|
|
750
|
+
* @returns {string[]} Array of labels.
|
|
751
|
+
*/
|
|
752
|
+
keys() {
|
|
753
|
+
return this._nodes.keys();
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Return node values in order.
|
|
758
|
+
*
|
|
759
|
+
* @returns {Array} Array of values.
|
|
760
|
+
*/
|
|
761
|
+
values() {
|
|
762
|
+
return this._nodes.values();
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/**
|
|
766
|
+
* Return key/value objects in node order, resolving values as needed.
|
|
767
|
+
*
|
|
768
|
+
* @returns {Array<{key: string, value: *}>} Array of key/value objects.
|
|
769
|
+
*/
|
|
770
|
+
items() {
|
|
771
|
+
return [...this._nodes].map(node => ({key: node.label, value: node.getValue()}));
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// -------------------------------------------------------------------------
|
|
775
|
+
// Node Access Methods
|
|
776
|
+
// -------------------------------------------------------------------------
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Property alias for getNodes().
|
|
780
|
+
*
|
|
781
|
+
* @returns {BagNode[]} List of BagNodes.
|
|
782
|
+
*/
|
|
783
|
+
get nodes() {
|
|
784
|
+
return this.getNodes();
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Get a first-level node by label or index.
|
|
789
|
+
*
|
|
790
|
+
* Sync method for quick access to direct child nodes.
|
|
791
|
+
* Does not traverse paths or trigger resolvers.
|
|
792
|
+
*
|
|
793
|
+
* @param {string|number} key - Node label (str) or index (int).
|
|
794
|
+
* @returns {BagNode|null} The BagNode if found, null otherwise.
|
|
795
|
+
*
|
|
796
|
+
* @example
|
|
797
|
+
* bag.node('a').value // 1
|
|
798
|
+
* bag.node(0).label // 'a'
|
|
799
|
+
*/
|
|
800
|
+
node(key) {
|
|
801
|
+
return this._nodes.get(key);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
/**
|
|
805
|
+
* Set attributes on a node at the given path.
|
|
806
|
+
*
|
|
807
|
+
* @param {string|null} [path=null] - Path to the node.
|
|
808
|
+
* @param {Object|null} [attr=null] - Dict of attributes to set.
|
|
809
|
+
* @param {boolean} [removeNullAttributes=true] - If true, remove attributes with null value.
|
|
810
|
+
*/
|
|
811
|
+
setAttr(path = null, attr = null, removeNullAttributes = true) {
|
|
812
|
+
// Use the native signature: compatibility subclasses may repurpose
|
|
813
|
+
// getNode's second argument as a reserved legacy slot.
|
|
814
|
+
const node = Bag.prototype.getNode.call(this, path, true, true);
|
|
815
|
+
if (node) {
|
|
816
|
+
node.setAttr(attr, true, true, removeNullAttributes);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/**
|
|
821
|
+
* Get an attribute from a node at the given path.
|
|
822
|
+
*
|
|
823
|
+
* @param {string|null} [path=null] - Path to the node.
|
|
824
|
+
* @param {string|null} [attr=null] - Attribute name to get.
|
|
825
|
+
* @param {*} [defaultVal=null] - Default value if node or attribute not found.
|
|
826
|
+
* @returns {*} Attribute value or default.
|
|
827
|
+
*/
|
|
828
|
+
getAttr(path = null, attr = null, defaultVal = null) {
|
|
829
|
+
const node = this.getNode(path);
|
|
830
|
+
if (node) {
|
|
831
|
+
return node.getAttr(attr, defaultVal);
|
|
832
|
+
}
|
|
833
|
+
return defaultVal;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Delete attributes from a node at the given path.
|
|
838
|
+
*
|
|
839
|
+
* @param {string|null} [path=null] - Path to the node.
|
|
840
|
+
* @param {...string} attrs - Attribute names to delete.
|
|
841
|
+
*/
|
|
842
|
+
delAttr(path = null, ...attrs) {
|
|
843
|
+
const node = this.getNode(path);
|
|
844
|
+
if (node) {
|
|
845
|
+
node.delAttr(...attrs);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/**
|
|
850
|
+
* Get inherited attributes from parent chain.
|
|
851
|
+
*
|
|
852
|
+
* @returns {Object} Dict of attributes inherited from parent nodes.
|
|
853
|
+
*/
|
|
854
|
+
getInheritedAttributes() {
|
|
855
|
+
if (this._parentNode) {
|
|
856
|
+
return this._parentNode.getInheritedAttributes();
|
|
857
|
+
}
|
|
858
|
+
return {};
|
|
859
|
+
}
|
|
860
|
+
|
|
861
|
+
// -------------------------------------------------------------------------
|
|
862
|
+
// Query Methods (BagQuery)
|
|
863
|
+
// -------------------------------------------------------------------------
|
|
864
|
+
|
|
865
|
+
/**
|
|
866
|
+
* Get the actual list of nodes contained in the Bag.
|
|
867
|
+
*
|
|
868
|
+
* The getNodes method works as the filter of a list.
|
|
869
|
+
*
|
|
870
|
+
* @param {Function|null} [condition=null] - Optional callable that takes a BagNode and returns bool.
|
|
871
|
+
* Returns a new list of shared nodes. List mutations do not change the Bag;
|
|
872
|
+
* node mutations do. Later structural changes are not reflected in the list.
|
|
873
|
+
* @returns {BagNode[]} Snapshot of BagNodes, optionally filtered by condition.
|
|
874
|
+
*/
|
|
875
|
+
getNodes(condition = null) {
|
|
876
|
+
if (!condition) {
|
|
877
|
+
return [...this._nodes];
|
|
878
|
+
}
|
|
879
|
+
return [...this._nodes].filter(n => condition(n));
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Return the first BagNode whose value contains key=value.
|
|
884
|
+
*
|
|
885
|
+
* Searches only direct children (not recursive).
|
|
886
|
+
* The node's value must be dict-like (Bag or dict).
|
|
887
|
+
*
|
|
888
|
+
* @param {string} key - Key to look for in node.value.
|
|
889
|
+
* @param {*} value - Value to match.
|
|
890
|
+
* @returns {BagNode|null} BagNode if found, null otherwise.
|
|
891
|
+
*/
|
|
892
|
+
getNodeByValue(key, value) {
|
|
893
|
+
for (const node of this._nodes) {
|
|
894
|
+
const nodeValue = node.value;
|
|
895
|
+
if (nodeValue instanceof Bag) {
|
|
896
|
+
if (nodeValue.getItem(key) === value) return node;
|
|
897
|
+
} else if (nodeValue && nodeValue.get && nodeValue.get(key) === value) {
|
|
898
|
+
return node;
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
return null;
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
/**
|
|
905
|
+
* Return the first BagNode with the requested attribute value.
|
|
906
|
+
*
|
|
907
|
+
* Search strategy (hybrid depth-first with level priority):
|
|
908
|
+
* 1. First checks all direct children of current Bag
|
|
909
|
+
* 2. Then recursively searches into sub-Bags (depth-first)
|
|
910
|
+
*
|
|
911
|
+
* This means a match at the current level is always found before
|
|
912
|
+
* descending into nested Bags, but once descent begins, it proceeds
|
|
913
|
+
* depth-first through the subtree before checking siblings.
|
|
914
|
+
*
|
|
915
|
+
* @param {string} attr - Attribute name to search.
|
|
916
|
+
* @param {*} value - Attribute value; undefined searches for presence.
|
|
917
|
+
* @param {boolean} [caseInsensitive=false] - Compare strings ignoring case.
|
|
918
|
+
* @param {boolean} [deep_first=false] - Visit subtrees before next siblings.
|
|
919
|
+
* @returns {BagNode|null} BagNode if found, null otherwise.
|
|
920
|
+
*/
|
|
921
|
+
getNodeByAttr(attr, value, caseInsensitive = false, deep_first = false) {
|
|
922
|
+
const existsOnly = value === undefined;
|
|
923
|
+
const expected = caseInsensitive && typeof value === 'string' ? value.toLowerCase() : value;
|
|
924
|
+
const search = bag => {
|
|
925
|
+
const subBags = [];
|
|
926
|
+
for (const node of bag._nodes) {
|
|
927
|
+
if (attr in node.attr) {
|
|
928
|
+
const current = caseInsensitive && typeof node.attr[attr] === 'string'
|
|
929
|
+
? node.attr[attr].toLowerCase() : node.attr[attr];
|
|
930
|
+
if (existsOnly || current == expected) return node;
|
|
931
|
+
}
|
|
932
|
+
if (node._value instanceof Bag) {
|
|
933
|
+
if (deep_first) {
|
|
934
|
+
const found = search(node._value);
|
|
935
|
+
if (found) return found;
|
|
936
|
+
} else {
|
|
937
|
+
subBags.push(node._value);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
for (const child of subBags) {
|
|
942
|
+
const found = search(child);
|
|
943
|
+
if (found) return found;
|
|
944
|
+
}
|
|
945
|
+
return null;
|
|
946
|
+
};
|
|
947
|
+
return search(this);
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
/**
|
|
951
|
+
* Check if the Bag is empty.
|
|
952
|
+
*
|
|
953
|
+
* A node is considered non-empty if:
|
|
954
|
+
* - It has a resolver (even if static value is null, the resolver
|
|
955
|
+
* represents potential content that can be loaded)
|
|
956
|
+
* - It has a non-null static value (unless zeroIsNone/blankIsNone apply)
|
|
957
|
+
*
|
|
958
|
+
* This method never triggers resolver I/O - it only checks static values
|
|
959
|
+
* and resolver presence.
|
|
960
|
+
*
|
|
961
|
+
* @param {boolean} [zeroIsNone=false] - If true, treat 0 values as empty.
|
|
962
|
+
* @param {boolean} [blankIsNone=false] - If true, treat blank strings as empty.
|
|
963
|
+
* @returns {boolean} True if Bag is empty according to criteria, false otherwise.
|
|
964
|
+
*/
|
|
965
|
+
isEmpty(zeroIsNone = false, blankIsNone = false) {
|
|
966
|
+
if (this._nodes.length === 0) {
|
|
967
|
+
return true;
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
for (const node of this._nodes) {
|
|
971
|
+
// A node with a resolver is not empty (has potential content)
|
|
972
|
+
if (node._resolver !== null) {
|
|
973
|
+
return false;
|
|
974
|
+
}
|
|
975
|
+
const v = node.getValue(true); // static=true
|
|
976
|
+
if (v === null || v === undefined) {
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
if (zeroIsNone && v === 0) {
|
|
980
|
+
continue;
|
|
981
|
+
}
|
|
982
|
+
if (blankIsNone && v === '') {
|
|
983
|
+
continue;
|
|
984
|
+
}
|
|
985
|
+
return false;
|
|
986
|
+
}
|
|
987
|
+
|
|
988
|
+
return true;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Query Bag elements, extracting specified data.
|
|
993
|
+
*
|
|
994
|
+
* @param {string|Array|null} [what=null] - String of special keys separated by comma, or array of keys.
|
|
995
|
+
* Special keys:
|
|
996
|
+
* - '#k': label of each item
|
|
997
|
+
* - '#v': value of each item
|
|
998
|
+
* - '#v.path': inner values of each item
|
|
999
|
+
* - '#__v': static value (always bypasses resolver)
|
|
1000
|
+
* - '#a': all attributes of each item
|
|
1001
|
+
* - '#a.attrname': specific attribute for each item
|
|
1002
|
+
* - '#p': path (full path from root, useful with deep=true)
|
|
1003
|
+
* - '#n': node (the BagNode itself)
|
|
1004
|
+
* - callable: custom function applied to each node
|
|
1005
|
+
* @param {Function|boolean|null} [condition=null] - Optional callable filter (receives BagNode, returns bool).
|
|
1006
|
+
* A boolean is the deprecated legacy asColumns argument and emits a warning.
|
|
1007
|
+
* @param {boolean} [iter=false] - If true, return a generator instead of an array.
|
|
1008
|
+
* @param {boolean} [deep=false] - If true, traverse recursively (depth-first) instead of first level only.
|
|
1009
|
+
* @param {boolean} [leaf=true] - If true (default), include leaf nodes (non-Bag values).
|
|
1010
|
+
* @param {boolean} [branch=true] - If true (default), include branch nodes (Bag values).
|
|
1011
|
+
* @param {number|null} [limit=null] - Maximum number of results to return. null means no limit.
|
|
1012
|
+
* @param {boolean} [isStatic=true] - If true (default), don't trigger resolvers during traversal.
|
|
1013
|
+
* @returns {Array|Generator} Array of tuples, or generator if iter=true.
|
|
1014
|
+
*/
|
|
1015
|
+
query(what = null, condition = null, iter = false, deep = false, leaf = true, branch = true, limit = null, isStatic = true) {
|
|
1016
|
+
if (!what) {
|
|
1017
|
+
what = '#k,#v,#a';
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
let obj = this;
|
|
1021
|
+
let whatsplit;
|
|
1022
|
+
|
|
1023
|
+
if (typeof what === 'string') {
|
|
1024
|
+
if (what.includes(':')) {
|
|
1025
|
+
const [where, whatPart] = what.split(':');
|
|
1026
|
+
obj = this.getItem(where);
|
|
1027
|
+
what = whatPart;
|
|
1028
|
+
}
|
|
1029
|
+
whatsplit = what.split(',').map(x => x.trim());
|
|
1030
|
+
} else {
|
|
1031
|
+
whatsplit = what;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
const _extractValue = (node, w, path, isDeep, readValue) => {
|
|
1035
|
+
if (w === '#k') {
|
|
1036
|
+
return node.label;
|
|
1037
|
+
} else if (w === '#p') {
|
|
1038
|
+
return path;
|
|
1039
|
+
} else if (w === '#n') {
|
|
1040
|
+
return node;
|
|
1041
|
+
} else if (typeof w === 'function') {
|
|
1042
|
+
return w(node);
|
|
1043
|
+
} else if (w === '#v') {
|
|
1044
|
+
const v = readValue();
|
|
1045
|
+
// With deep=true, Bag values return null (content comes in later iterations)
|
|
1046
|
+
return isDeep && v instanceof Bag ? null : v;
|
|
1047
|
+
} else if (w.startsWith('#v.')) {
|
|
1048
|
+
const innerPath = w.split('.').slice(1).join('.');
|
|
1049
|
+
const value = readValue();
|
|
1050
|
+
return value && value.getItem ? value.getItem(innerPath) : null;
|
|
1051
|
+
} else if (w === '#__v') {
|
|
1052
|
+
return node.getValue(true); // Always static
|
|
1053
|
+
} else if (w.startsWith('#a')) {
|
|
1054
|
+
const attr = w.includes('.') ? w.split('.').slice(1).join('.') : null;
|
|
1055
|
+
return node.getAttr(attr);
|
|
1056
|
+
} else {
|
|
1057
|
+
const value = readValue();
|
|
1058
|
+
return value && value.getItem ? value.getItem(w) : null;
|
|
1059
|
+
}
|
|
1060
|
+
};
|
|
1061
|
+
|
|
1062
|
+
function* _iterDigest() {
|
|
1063
|
+
let count = 0;
|
|
1064
|
+
function* visit(bag, prefix) {
|
|
1065
|
+
for (const node of bag._nodes) {
|
|
1066
|
+
const path = prefix ? `${prefix}.${node.label}` : node.label;
|
|
1067
|
+
// Per-visit state, independent of the resolver's own cache.
|
|
1068
|
+
let loaded = false;
|
|
1069
|
+
let value;
|
|
1070
|
+
const readValue = () => {
|
|
1071
|
+
if (!loaded) {
|
|
1072
|
+
value = node.getValue(isStatic);
|
|
1073
|
+
loaded = true;
|
|
1074
|
+
}
|
|
1075
|
+
return value;
|
|
1076
|
+
};
|
|
1077
|
+
const included = (leaf && branch) ||
|
|
1078
|
+
(readValue() instanceof Bag ? branch : leaf);
|
|
1079
|
+
if (included && (condition === null || condition(node))) {
|
|
1080
|
+
yield whatsplit.length === 1
|
|
1081
|
+
? _extractValue(node, whatsplit[0], path, deep, readValue)
|
|
1082
|
+
: whatsplit.map(w => _extractValue(node, w, path, deep, readValue));
|
|
1083
|
+
count++;
|
|
1084
|
+
if (limit !== null && count >= limit) return;
|
|
1085
|
+
}
|
|
1086
|
+
if (deep) {
|
|
1087
|
+
const child = readValue();
|
|
1088
|
+
if (child instanceof Bag) {
|
|
1089
|
+
yield* visit(child, path);
|
|
1090
|
+
if (limit !== null && count >= limit) return;
|
|
1091
|
+
}
|
|
1092
|
+
}
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
yield* visit(obj, '');
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
if (iter) {
|
|
1099
|
+
return _iterDigest();
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
return [..._iterDigest()];
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
/**
|
|
1106
|
+
* Return [relative path, value] pairs for non-Bag leaves.
|
|
1107
|
+
* Resolve each node once per occurrence. Empty Bags produce no leaf entry.
|
|
1108
|
+
* Paths do not require parent backrefs.
|
|
1109
|
+
*/
|
|
1110
|
+
getLeaves() {
|
|
1111
|
+
const result = [];
|
|
1112
|
+
const collect = (bag, prefix) => {
|
|
1113
|
+
for (const node of bag._nodes) {
|
|
1114
|
+
const path = prefix ? `${prefix}.${node.label}` : node.label;
|
|
1115
|
+
const value = node.getValue(false);
|
|
1116
|
+
if (value instanceof Bag) collect(value, path);
|
|
1117
|
+
else result.push([path, value]);
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
1120
|
+
collect(this, '');
|
|
1121
|
+
return result;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/**
|
|
1125
|
+
* Return a list of tuples with keys/values/attributes (backward compatible).
|
|
1126
|
+
*
|
|
1127
|
+
* This is an alias for query() with iter=false, deep=false for backward
|
|
1128
|
+
* compatibility. Use query() for new code.
|
|
1129
|
+
*
|
|
1130
|
+
* @param {string|Array|null} [what=null] - String of special keys separated by comma, or array of keys.
|
|
1131
|
+
* @param {Function|boolean|null} [condition=null] - Optional callable filter (receives BagNode, returns bool).
|
|
1132
|
+
* A boolean is the deprecated legacy asColumns argument and emits a warning.
|
|
1133
|
+
* @param {boolean} [asColumns=false] - If true, return array of arrays (transposed).
|
|
1134
|
+
* @returns {Array} Array of tuples (or array of arrays if asColumns=true).
|
|
1135
|
+
*/
|
|
1136
|
+
digest(what = null, condition = null, asColumns = false) {
|
|
1137
|
+
if (typeof condition === 'boolean') {
|
|
1138
|
+
console.warn('Bag.digest(what, asColumns) is deprecated; use digest(what, null, asColumns).');
|
|
1139
|
+
asColumns = condition;
|
|
1140
|
+
condition = null;
|
|
1141
|
+
}
|
|
1142
|
+
const result = this.query(what, condition, false, false, true, true, null, false);
|
|
1143
|
+
|
|
1144
|
+
if (asColumns) {
|
|
1145
|
+
if (!result || result.length === 0) {
|
|
1146
|
+
const whatStr = typeof what === 'string' ? what : '#k,#v,#a';
|
|
1147
|
+
const whatsplit = whatStr.split(',').map(x => x.trim());
|
|
1148
|
+
return whatsplit.map(() => []);
|
|
1149
|
+
}
|
|
1150
|
+
const resultList = [...result];
|
|
1151
|
+
if (resultList.length && Array.isArray(resultList[0])) {
|
|
1152
|
+
// Transpose: list of rows → list of columns
|
|
1153
|
+
const numCols = resultList[0].length;
|
|
1154
|
+
const columns = [];
|
|
1155
|
+
for (let i = 0; i < numCols; i++) {
|
|
1156
|
+
columns.push(resultList.map(row => row[i]));
|
|
1157
|
+
}
|
|
1158
|
+
return columns;
|
|
1159
|
+
}
|
|
1160
|
+
return [resultList];
|
|
1161
|
+
}
|
|
1162
|
+
return [...result];
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/**
|
|
1166
|
+
* Return digest result as columns.
|
|
1167
|
+
*
|
|
1168
|
+
* @param {string|Array} cols - Column names as comma-separated string or array.
|
|
1169
|
+
* @param {boolean} [attrMode=false] - If true, prefix columns with '#a.' for attribute access.
|
|
1170
|
+
* @returns {Array} Array of arrays (columns).
|
|
1171
|
+
*/
|
|
1172
|
+
columns(cols, attrMode = false) {
|
|
1173
|
+
if (typeof cols === 'string') {
|
|
1174
|
+
cols = cols.split(',');
|
|
1175
|
+
}
|
|
1176
|
+
const mode = attrMode ? '#a.' : '';
|
|
1177
|
+
const what = cols.map(col => `${mode}${col}`).join(',');
|
|
1178
|
+
return this.digest(what, null, true);
|
|
1179
|
+
}
|
|
1180
|
+
|
|
1181
|
+
/**
|
|
1182
|
+
* Sum selected values at the current level; never traverse recursively.
|
|
1183
|
+
* @param {string} [what='#v'] - Query criterion, or comma-separated criteria.
|
|
1184
|
+
* @param {boolean} [strict=false] - Return null for null/undefined/empty string.
|
|
1185
|
+
* Otherwise these values contribute zero. Non-numeric values are ignored
|
|
1186
|
+
* unless strict, which raises TypeError. Zero and false remain valid.
|
|
1187
|
+
* @param {Function|null} [condition=null] - Optional BagNode predicate.
|
|
1188
|
+
* @returns {number|null|Array} One result, or one result per criterion.
|
|
1189
|
+
*/
|
|
1190
|
+
sum(what = '#v', strict = false, condition = null) {
|
|
1191
|
+
if (strict != null && typeof strict !== 'boolean') {
|
|
1192
|
+
throw new TypeError('sum strict must be a boolean; pass condition as the third argument');
|
|
1193
|
+
}
|
|
1194
|
+
if (condition != null && typeof condition !== 'function') {
|
|
1195
|
+
throw new TypeError('sum condition must be callable; deep is no longer supported');
|
|
1196
|
+
}
|
|
1197
|
+
const total = criterion => {
|
|
1198
|
+
let result = 0;
|
|
1199
|
+
let missing = false;
|
|
1200
|
+
for (const value of this.query(criterion, condition)) {
|
|
1201
|
+
if (value == null || value === '') {
|
|
1202
|
+
missing = true;
|
|
1203
|
+
continue;
|
|
1204
|
+
}
|
|
1205
|
+
if (typeof value !== 'number' && typeof value !== 'boolean') {
|
|
1206
|
+
if (strict) throw new TypeError(`sum encountered non-numeric value: ${typeof value}`);
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
result += value;
|
|
1210
|
+
}
|
|
1211
|
+
return strict && missing ? null : result;
|
|
1212
|
+
};
|
|
1213
|
+
return what.includes(',') ? what.split(',').map(w => total(w.trim())) : total(what);
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* Sort nodes in place.
|
|
1218
|
+
*
|
|
1219
|
+
* @param {string|Function} [key='#k:a'] - Sort specification string or callable.
|
|
1220
|
+
* If callable, used directly as key function for sort.
|
|
1221
|
+
* If string, format is 'criterion:mode' or multiple 'c1:m1,c2:m2'.
|
|
1222
|
+
*
|
|
1223
|
+
* Criteria:
|
|
1224
|
+
* - '#k': sort by label
|
|
1225
|
+
* - '#v': sort by value
|
|
1226
|
+
* - '#a.attrname': sort by attribute
|
|
1227
|
+
* - 'fieldname': sort by field in value (if value is dict/Bag)
|
|
1228
|
+
*
|
|
1229
|
+
* Modes:
|
|
1230
|
+
* - 'a': ascending, case-insensitive (default)
|
|
1231
|
+
* - 'A': ascending, case-sensitive
|
|
1232
|
+
* - 'd': descending, case-insensitive
|
|
1233
|
+
* - 'D': descending, case-sensitive
|
|
1234
|
+
*
|
|
1235
|
+
* @returns {Bag} Self (for chaining).
|
|
1236
|
+
*
|
|
1237
|
+
* @example
|
|
1238
|
+
* bag.sort('#k') // by label ascending
|
|
1239
|
+
* bag.sort('#k:d') // by label descending
|
|
1240
|
+
* bag.sort('#v:A') // by value ascending, case-sensitive
|
|
1241
|
+
* bag.sort('#a.name:a') // by attribute 'name'
|
|
1242
|
+
* bag.sort('field:d') // by field in value
|
|
1243
|
+
* bag.sort('#k:a,#v:d') // multi-level sort
|
|
1244
|
+
* bag.sort(n => n.value) // custom key function
|
|
1245
|
+
*/
|
|
1246
|
+
sort(key = '#k:a') {
|
|
1247
|
+
/**
|
|
1248
|
+
* Create sort key handling null/undefined and case sensitivity.
|
|
1249
|
+
* @param {*} value - The value to create key for.
|
|
1250
|
+
* @param {boolean} caseInsensitive - Whether to ignore case for strings.
|
|
1251
|
+
* @returns {Array} Tuple [priority, value] where priority -1 means null (sort first ascending).
|
|
1252
|
+
*/
|
|
1253
|
+
const sortKey = (value, caseInsensitive) => {
|
|
1254
|
+
if (value === null || value === undefined) {
|
|
1255
|
+
return [-1, '']; // null/undefined first ascending, last descending
|
|
1256
|
+
}
|
|
1257
|
+
if (caseInsensitive && typeof value === 'string') {
|
|
1258
|
+
return [0, value.toLowerCase()];
|
|
1259
|
+
}
|
|
1260
|
+
return [0, value];
|
|
1261
|
+
};
|
|
1262
|
+
|
|
1263
|
+
/**
|
|
1264
|
+
* Compare two sort keys.
|
|
1265
|
+
* @param {Array} a - First sort key [priority, value].
|
|
1266
|
+
* @param {Array} b - Second sort key [priority, value].
|
|
1267
|
+
* @returns {number} Comparison result for Array.sort.
|
|
1268
|
+
*/
|
|
1269
|
+
const compareKeys = (a, b) => {
|
|
1270
|
+
// First compare by priority (nulls first ascending)
|
|
1271
|
+
if (a[0] !== b[0]) {
|
|
1272
|
+
return a[0] - b[0];
|
|
1273
|
+
}
|
|
1274
|
+
// Then compare by value
|
|
1275
|
+
if (a[1] < b[1]) return -1;
|
|
1276
|
+
if (a[1] > b[1]) return 1;
|
|
1277
|
+
return 0;
|
|
1278
|
+
};
|
|
1279
|
+
|
|
1280
|
+
if (typeof key === 'function') {
|
|
1281
|
+
this._nodes._list.sort((a, b) => {
|
|
1282
|
+
const ka = key(a);
|
|
1283
|
+
const kb = key(b);
|
|
1284
|
+
if (ka < kb) return -1;
|
|
1285
|
+
if (ka > kb) return 1;
|
|
1286
|
+
return 0;
|
|
1287
|
+
});
|
|
1288
|
+
} else {
|
|
1289
|
+
const levels = key.split(',');
|
|
1290
|
+
levels.reverse(); // process in reverse for stable multi-level sort
|
|
1291
|
+
for (const level of levels) {
|
|
1292
|
+
let what, mode;
|
|
1293
|
+
if (level.includes(':')) {
|
|
1294
|
+
[what, mode] = level.split(':', 2);
|
|
1295
|
+
} else {
|
|
1296
|
+
what = level;
|
|
1297
|
+
mode = 'a';
|
|
1298
|
+
}
|
|
1299
|
+
what = what.trim();
|
|
1300
|
+
mode = mode.trim();
|
|
1301
|
+
|
|
1302
|
+
const reverse = mode === 'd' || mode === 'D';
|
|
1303
|
+
const caseInsensitive = mode === 'a' || mode === 'd';
|
|
1304
|
+
|
|
1305
|
+
let keyFn;
|
|
1306
|
+
if (what.toLowerCase() === '#k') {
|
|
1307
|
+
keyFn = n => sortKey(n.label, caseInsensitive);
|
|
1308
|
+
} else if (what.toLowerCase() === '#v') {
|
|
1309
|
+
keyFn = n => sortKey(n.value, caseInsensitive);
|
|
1310
|
+
} else if (what.toLowerCase().startsWith('#a.')) {
|
|
1311
|
+
const attrname = what.slice(3);
|
|
1312
|
+
keyFn = n => sortKey(n.getAttr(attrname), caseInsensitive);
|
|
1313
|
+
} else {
|
|
1314
|
+
// Sort by field in value
|
|
1315
|
+
keyFn = n => {
|
|
1316
|
+
const value = n.value;
|
|
1317
|
+
const field = value instanceof Bag ? value.getItem(what) :
|
|
1318
|
+
(value ? value[what] : null);
|
|
1319
|
+
return sortKey(field, caseInsensitive);
|
|
1320
|
+
};
|
|
1321
|
+
}
|
|
1322
|
+
|
|
1323
|
+
this._nodes._list.sort((a, b) => {
|
|
1324
|
+
const result = compareKeys(keyFn(a), keyFn(b));
|
|
1325
|
+
return reverse ? -result : result;
|
|
1326
|
+
});
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
return this;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
/**
|
|
1333
|
+
* Check equality with another Bag.
|
|
1334
|
+
*
|
|
1335
|
+
* @param {Bag} other - Bag to compare with.
|
|
1336
|
+
* @returns {boolean} True if both Bags have same nodes with same values and attributes.
|
|
1337
|
+
*/
|
|
1338
|
+
equalTo(other) {
|
|
1339
|
+
if (!(other instanceof Bag)) return false;
|
|
1340
|
+
return this._nodes.isEqual(other._nodes);
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
/**
|
|
1344
|
+
* Check if a path or node exists in the Bag.
|
|
1345
|
+
*
|
|
1346
|
+
* Equivalent to Python's `__contains__` / `in` operator.
|
|
1347
|
+
*
|
|
1348
|
+
* With the `?attr` query syntax it checks the existence of the named
|
|
1349
|
+
* attribute on the target node; `?a&b` requires every named attribute
|
|
1350
|
+
* to be present. The check is static: it does not trigger resolvers
|
|
1351
|
+
* along the path.
|
|
1352
|
+
*
|
|
1353
|
+
* @param {string|BagNode} what - Path to check, optionally with a
|
|
1354
|
+
* `?attr` or `?a&b` suffix, or a BagNode to check if it's in this Bag.
|
|
1355
|
+
* @returns {boolean} True if the path/node (and named attributes if
|
|
1356
|
+
* provided) exists, false otherwise.
|
|
1357
|
+
*
|
|
1358
|
+
* @example
|
|
1359
|
+
* bag.setItem('a.b', 1);
|
|
1360
|
+
* bag.has('a.b') // true
|
|
1361
|
+
* bag.has('a.c') // false
|
|
1362
|
+
* bag.has('a.b?color') // true if node a.b has attribute 'color'
|
|
1363
|
+
* bag.has('a.b?x&y') // true only if both 'x' and 'y' are present
|
|
1364
|
+
*/
|
|
1365
|
+
has(what) {
|
|
1366
|
+
if (typeof what === 'string') {
|
|
1367
|
+
let queryString = null;
|
|
1368
|
+
if (what.includes('?')) {
|
|
1369
|
+
[what, queryString] = what.split('?', 2);
|
|
1370
|
+
}
|
|
1371
|
+
const node = this.getNode(what);
|
|
1372
|
+
if (node === null) {
|
|
1373
|
+
return false;
|
|
1374
|
+
}
|
|
1375
|
+
if (queryString === null) {
|
|
1376
|
+
return true;
|
|
1377
|
+
}
|
|
1378
|
+
return queryString.split('&').every(a => a in node.attr);
|
|
1379
|
+
} else if (what && what.label !== undefined) {
|
|
1380
|
+
// Assume it's a BagNode-like object
|
|
1381
|
+
return [...this._nodes].includes(what);
|
|
1382
|
+
}
|
|
1383
|
+
return false;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// -------------------------------------------------------------------------
|
|
1387
|
+
// Resolver Methods
|
|
1388
|
+
// -------------------------------------------------------------------------
|
|
1389
|
+
|
|
1390
|
+
/**
|
|
1391
|
+
* Get the resolver at the given path.
|
|
1392
|
+
*
|
|
1393
|
+
* @param {string} path - Path to the node.
|
|
1394
|
+
* @returns {*} The resolver, or null if path doesn't exist or has no resolver.
|
|
1395
|
+
*/
|
|
1396
|
+
getResolver(path) {
|
|
1397
|
+
const node = this.getNode(path);
|
|
1398
|
+
return node ? node.resolver : null;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
/**
|
|
1402
|
+
* Set a resolver at the given path.
|
|
1403
|
+
*
|
|
1404
|
+
* Creates the node if it doesn't exist, with value=null.
|
|
1405
|
+
*
|
|
1406
|
+
* @param {string} path - Path to the node.
|
|
1407
|
+
* @param {*} resolver - The resolver to set.
|
|
1408
|
+
*/
|
|
1409
|
+
setResolver(path, resolver) {
|
|
1410
|
+
let node = this.getNode(path);
|
|
1411
|
+
if (!node) {
|
|
1412
|
+
this.setItem(path, null);
|
|
1413
|
+
node = this.getNode(path);
|
|
1414
|
+
}
|
|
1415
|
+
node.resolver = resolver;
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/**
|
|
1419
|
+
* Set a callback resolver at the given path.
|
|
1420
|
+
*
|
|
1421
|
+
* Shortcut for creating a BagCbResolver and setting it on a node.
|
|
1422
|
+
*
|
|
1423
|
+
* @param {string} path - Path to the node.
|
|
1424
|
+
* @param {Function} callback - Callable that returns the value.
|
|
1425
|
+
* @param {Object} [options={}] - Options passed to BagCbResolver constructor.
|
|
1426
|
+
* - cacheTime: Cache duration in ms (default 0, no cache).
|
|
1427
|
+
* - readOnly: If true, value not saved in node (default false).
|
|
1428
|
+
*/
|
|
1429
|
+
setCallbackItem(path, callback, options = {}) {
|
|
1430
|
+
const resolver = new BagCbResolver({ callback, ...options });
|
|
1431
|
+
this.setResolver(path, resolver);
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
// -------------------------------------------------------------------------
|
|
1435
|
+
// Structure Manipulation Methods
|
|
1436
|
+
// -------------------------------------------------------------------------
|
|
1437
|
+
|
|
1438
|
+
/**
|
|
1439
|
+
* Move element(s) to a new position.
|
|
1440
|
+
*
|
|
1441
|
+
* @param {number|number[]} what - Index or list of indices to move.
|
|
1442
|
+
* @param {number} position - Target index position.
|
|
1443
|
+
* @param {boolean} [trigger=true] - If true, fire del/ins events.
|
|
1444
|
+
*
|
|
1445
|
+
* @example
|
|
1446
|
+
* bag.move(0, 2) // move first element to position 2
|
|
1447
|
+
* bag.move([0, 2], 1) // move indices 0 and 2 to position 1
|
|
1448
|
+
*/
|
|
1449
|
+
move(what, position, trigger = true) {
|
|
1450
|
+
this._nodes.move(what, position, trigger);
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* Convert Bag to plain object (first level only).
|
|
1455
|
+
*
|
|
1456
|
+
* @param {boolean} [ascii=false] - If true, convert keys to ASCII.
|
|
1457
|
+
* @param {boolean} [lower=false] - If true, convert keys to lowercase.
|
|
1458
|
+
* @param {boolean} [recursive=false] - Convert nested Bags (not ordinary objects/arrays).
|
|
1459
|
+
* @param {boolean} [excludeNullValues=false] - Omit null/undefined, retaining empty Bags.
|
|
1460
|
+
* @returns {Object} Plain JavaScript object with key-value pairs.
|
|
1461
|
+
*/
|
|
1462
|
+
asDict(ascii = false, lower = false, recursive = false, excludeNullValues = false) {
|
|
1463
|
+
const result = {};
|
|
1464
|
+
for (const el of this._nodes) {
|
|
1465
|
+
let value = el.value;
|
|
1466
|
+
if (excludeNullValues && value == null) continue;
|
|
1467
|
+
let key = el.label;
|
|
1468
|
+
if (ascii) {
|
|
1469
|
+
key = String(key);
|
|
1470
|
+
}
|
|
1471
|
+
if (lower) {
|
|
1472
|
+
key = key.toLowerCase();
|
|
1473
|
+
}
|
|
1474
|
+
if (recursive && value instanceof Bag) {
|
|
1475
|
+
value = Bag.prototype.asDict.call(value, ascii, lower, recursive, excludeNullValues);
|
|
1476
|
+
}
|
|
1477
|
+
Object.defineProperty(result, key, {
|
|
1478
|
+
value, enumerable: true, writable: true, configurable: true
|
|
1479
|
+
});
|
|
1480
|
+
}
|
|
1481
|
+
return result;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* Return value at path, setting it to default if not present.
|
|
1486
|
+
*
|
|
1487
|
+
* @param {string} path - Path to the value.
|
|
1488
|
+
* @param {*} [defaultVal=null] - Default value to set if path doesn't exist.
|
|
1489
|
+
* @returns {*} The value at path (existing or newly set default).
|
|
1490
|
+
*/
|
|
1491
|
+
setdefault(path, defaultVal = null) {
|
|
1492
|
+
const node = this.getNode(path);
|
|
1493
|
+
if (!node) {
|
|
1494
|
+
this.setItem(path, defaultVal);
|
|
1495
|
+
return defaultVal;
|
|
1496
|
+
}
|
|
1497
|
+
return node.value;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
/**
|
|
1501
|
+
* Return a deep copy of this Bag.
|
|
1502
|
+
*
|
|
1503
|
+
* Creates a new Bag with copies of all nodes. Nested Bags are
|
|
1504
|
+
* recursively deep copied. Values are copied by reference unless
|
|
1505
|
+
* they are Bags. Node attributes are copied as a new dict.
|
|
1506
|
+
*
|
|
1507
|
+
* @param {boolean} [resolve=false] - Resolve values before copying.
|
|
1508
|
+
* @returns {Bag} A new Bag with copied nodes.
|
|
1509
|
+
*
|
|
1510
|
+
* @example
|
|
1511
|
+
* const copy = bag.deepcopy();
|
|
1512
|
+
* copy.setItem('b.c', 3);
|
|
1513
|
+
* // Original bag['b.c'] unchanged
|
|
1514
|
+
*/
|
|
1515
|
+
deepcopy(resolve = false) {
|
|
1516
|
+
const result = this.createChildBag();
|
|
1517
|
+
for (const node of this._nodes) {
|
|
1518
|
+
let value = node.getValue(!resolve);
|
|
1519
|
+
if (value instanceof Bag) value = value.deepcopy(resolve);
|
|
1520
|
+
const copied = new result.nodeClass(result, node.label, value);
|
|
1521
|
+
copied.setAttr({...node.attr}, false, false, false);
|
|
1522
|
+
copied.nodeTag = node.nodeTag;
|
|
1523
|
+
copied.xmlTag = node.xmlTag;
|
|
1524
|
+
// Insert by position so repeated labels do not overwrite each other.
|
|
1525
|
+
result._nodes._list.push(copied);
|
|
1526
|
+
result._nodes._dict[copied.label] = copied;
|
|
1527
|
+
}
|
|
1528
|
+
return result;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
/**
|
|
1532
|
+
* Merge a Bag or object using the legacy JavaScript argument order.
|
|
1533
|
+
*
|
|
1534
|
+
* @param {Bag|Object} source - Incoming nodes or key/value properties.
|
|
1535
|
+
* @param {string|null} [mode=null] - 'static' preserves source resolvers;
|
|
1536
|
+
* otherwise their values are resolved before copying.
|
|
1537
|
+
* @param {*} [reason=null] - Modification reason passed to subscribers.
|
|
1538
|
+
* @param {boolean} [ignoreNone=false] - Optional extension: preserve existing
|
|
1539
|
+
* values when the incoming value is null. It is not the mode argument.
|
|
1540
|
+
*/
|
|
1541
|
+
update(source, mode = null, reason = null, ignoreNone = false) {
|
|
1542
|
+
if (!(source instanceof Bag)) {
|
|
1543
|
+
for (const label in source) {
|
|
1544
|
+
const value = source[label];
|
|
1545
|
+
if (!ignoreNone || value !== null || !this._nodes.has(label)) {
|
|
1546
|
+
this.setItem(label, value, null, '>', false, false,
|
|
1547
|
+
reason, false, reason !== false);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
for (const incoming of [...source]) {
|
|
1553
|
+
const label = incoming.label;
|
|
1554
|
+
const resolver = mode === 'static' ? incoming.resolver : null;
|
|
1555
|
+
const value = resolver ? null : incoming.getValue();
|
|
1556
|
+
const replaceContent = incoming.attr.__replace;
|
|
1557
|
+
delete incoming.attr.__replace;
|
|
1558
|
+
const current = this._nodes.get(label);
|
|
1559
|
+
if (current) {
|
|
1560
|
+
current.setAttr(incoming.attr, reason === null ? true : reason, true, false);
|
|
1561
|
+
if (incoming.nodeTag != null) current.nodeTag = incoming.nodeTag;
|
|
1562
|
+
if (incoming.xmlTag != null) current.xmlTag = incoming.xmlTag;
|
|
1563
|
+
if (resolver) {
|
|
1564
|
+
current.resolver = resolver;
|
|
1565
|
+
current.setValue(null, reason === null ? true : reason,
|
|
1566
|
+
null, null, false, reason);
|
|
1567
|
+
} else {
|
|
1568
|
+
if (incoming.resolver) current.resolver = null;
|
|
1569
|
+
const previous = current.getValue();
|
|
1570
|
+
if (value instanceof Bag && previous instanceof Bag && !replaceContent) {
|
|
1571
|
+
previous.update(value, mode, reason, ignoreNone);
|
|
1572
|
+
} else if (!ignoreNone || value !== null) {
|
|
1573
|
+
current.setValue(value, reason === null ? true : reason,
|
|
1574
|
+
null, null, false, reason);
|
|
1575
|
+
}
|
|
1576
|
+
}
|
|
1577
|
+
} else {
|
|
1578
|
+
const node = this.setItem(label, resolver || value, incoming.attr,
|
|
1579
|
+
'>', false, false, reason, false, reason !== false);
|
|
1580
|
+
node.nodeTag = incoming.nodeTag;
|
|
1581
|
+
node.xmlTag = incoming.xmlTag;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// -------------------------------------------------------------------------
|
|
1587
|
+
// Construction and Filling Methods
|
|
1588
|
+
// -------------------------------------------------------------------------
|
|
1589
|
+
|
|
1590
|
+
/**
|
|
1591
|
+
* Copy nodes from another Bag.
|
|
1592
|
+
*
|
|
1593
|
+
* Clears current contents and copies all nodes from the source Bag.
|
|
1594
|
+
* Nested Bags are deep copied.
|
|
1595
|
+
*
|
|
1596
|
+
* @param {Bag} other - Source Bag to copy from.
|
|
1597
|
+
* @private
|
|
1598
|
+
*/
|
|
1599
|
+
_fillFromBag(other) {
|
|
1600
|
+
this.clear();
|
|
1601
|
+
for (const node of other) {
|
|
1602
|
+
const copied = new this.nodeClass(this, node.label);
|
|
1603
|
+
copied.replace(node);
|
|
1604
|
+
copied.nodeTag = node.nodeTag;
|
|
1605
|
+
copied.xmlTag = node.xmlTag;
|
|
1606
|
+
this._nodes._list.push(copied);
|
|
1607
|
+
this._nodes._dict[copied.label] = copied;
|
|
1608
|
+
}
|
|
1609
|
+
}
|
|
1610
|
+
|
|
1611
|
+
/**
|
|
1612
|
+
* Populate bag from a plain object (dictionary).
|
|
1613
|
+
*
|
|
1614
|
+
* Clears current contents and creates nodes from object properties.
|
|
1615
|
+
* Nested objects are converted to nested Bags.
|
|
1616
|
+
*
|
|
1617
|
+
* @param {Object} data - Object where keys become labels and values become node values.
|
|
1618
|
+
* @private
|
|
1619
|
+
*/
|
|
1620
|
+
_fillFromDict(data) {
|
|
1621
|
+
this.clear();
|
|
1622
|
+
for (const [key, value] of Object.entries(data)) {
|
|
1623
|
+
if (value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
1624
|
+
&& !(value instanceof Bag) && !(value instanceof BagNode)) {
|
|
1625
|
+
this.setItem(key, this.createChildBag()._loadSource(value));
|
|
1626
|
+
} else {
|
|
1627
|
+
this.setItem(key, value);
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
/**
|
|
1633
|
+
* Fill this Bag from a source (another Bag or plain object).
|
|
1634
|
+
*
|
|
1635
|
+
* Prepares replacement contents before changing this Bag:
|
|
1636
|
+
* - If source is null/undefined: no-op
|
|
1637
|
+
* - If source is a Bag: copies all nodes (deep copy for nested Bags)
|
|
1638
|
+
* - If source is a plain object: keys become labels, values become node values.
|
|
1639
|
+
* Nested objects are recursively converted to Bags.
|
|
1640
|
+
*
|
|
1641
|
+
* Preparation errors leave this Bag unchanged. Attached Bags emit one
|
|
1642
|
+
* upd_value event after replacement, carrying the previous content.
|
|
1643
|
+
* Listener exceptions propagate after the replacement has committed.
|
|
1644
|
+
*
|
|
1645
|
+
* @param {Bag|Object|null} source - Source to fill from.
|
|
1646
|
+
* @returns {Bag} This Bag (for chaining).
|
|
1647
|
+
*
|
|
1648
|
+
* @example
|
|
1649
|
+
* const bag = new Bag();
|
|
1650
|
+
* bag.replace(new Bag({ a: 1, b: { c: 2 } }));
|
|
1651
|
+
* // bag has 'a' = 1, 'b' = Bag with 'c' = 2
|
|
1652
|
+
*
|
|
1653
|
+
* const other = new Bag();
|
|
1654
|
+
* other.setItem('x', 10);
|
|
1655
|
+
* bag.replace(other);
|
|
1656
|
+
* // bag now has only 'x' = 10
|
|
1657
|
+
*/
|
|
1658
|
+
_loadSource(source) {
|
|
1659
|
+
if (source == null) return this;
|
|
1660
|
+
const prepared = this.createChildBag();
|
|
1661
|
+
if (source instanceof Bag) {
|
|
1662
|
+
prepared._fillFromBag(source);
|
|
1663
|
+
} else if (typeof source === 'object' && !Array.isArray(source)) {
|
|
1664
|
+
prepared._fillFromDict(source);
|
|
1665
|
+
} else {
|
|
1666
|
+
return this;
|
|
1667
|
+
}
|
|
1668
|
+
return this._replacePrepared(prepared);
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
/** Replace all contents from a Bag, preserving identity. Returns this. */
|
|
1672
|
+
replace(other) {
|
|
1673
|
+
if (!(other instanceof Bag)) throw new TypeError('Bag.replace expects a Bag');
|
|
1674
|
+
if (other === this) return this;
|
|
1675
|
+
const prepared = this.createChildBag();
|
|
1676
|
+
prepared._fillFromBag(other);
|
|
1677
|
+
return this._replacePrepared(prepared);
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
_replacePrepared(prepared) {
|
|
1681
|
+
// Preparation must finish before touching the destination, including
|
|
1682
|
+
// when the source is this Bag. Keep the previous content as a snapshot.
|
|
1683
|
+
for (const node of this._nodes) {
|
|
1684
|
+
node.parentBag = null;
|
|
1685
|
+
const value = node.getValue(true);
|
|
1686
|
+
if (value instanceof Bag) value.clearBackref();
|
|
1687
|
+
}
|
|
1688
|
+
const oldNodes = this._nodes;
|
|
1689
|
+
this._nodes = prepared._nodes;
|
|
1690
|
+
prepared._nodes = oldNodes;
|
|
1691
|
+
this._nodes._parentBag = this.backref ? this : null;
|
|
1692
|
+
prepared._nodes._parentBag = null;
|
|
1693
|
+
for (const node of this._nodes) node.parentBag = this;
|
|
1694
|
+
for (const node of prepared._nodes) node._parentBag = prepared;
|
|
1695
|
+
|
|
1696
|
+
if (this.backref && this.parent && this.parentNode) {
|
|
1697
|
+
this.parent._onNodeChanged(this.parentNode,
|
|
1698
|
+
[this.parentNode.label], 'upd_value', prepared);
|
|
1699
|
+
}
|
|
1700
|
+
return this;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/**
|
|
1704
|
+
* Visit direct nodes, or the full tree with deep=true.
|
|
1705
|
+
* Null/undefined descends; falsey non-null results skip children;
|
|
1706
|
+
* a truthy result stops the visit and is returned.
|
|
1707
|
+
* Callback receives (node, kwargs, siblingIndex).
|
|
1708
|
+
*/
|
|
1709
|
+
forEach(callback, {static: isStatic = true, deep = false, kwargs = {}} = {}) {
|
|
1710
|
+
if (typeof callback !== 'function') throw new TypeError('forEach requires a callback');
|
|
1711
|
+
const visit = (bag, context) => {
|
|
1712
|
+
for (let index = 0; index < bag._nodes.length; index++) {
|
|
1713
|
+
const node = bag._nodes._list[index];
|
|
1714
|
+
const kw = {...context};
|
|
1715
|
+
if ('_pathlist' in context) kw._pathlist = [...context._pathlist, node.label];
|
|
1716
|
+
if ('_indexlist' in context) kw._indexlist = [...context._indexlist, index];
|
|
1717
|
+
const result = callback(node, kw, index);
|
|
1718
|
+
if (result) return result;
|
|
1719
|
+
if (result == null && deep) {
|
|
1720
|
+
const value = node.getValue(isStatic);
|
|
1721
|
+
if (value instanceof Bag) {
|
|
1722
|
+
const innerResult = visit(value, kw);
|
|
1723
|
+
if (innerResult) return innerResult;
|
|
1724
|
+
}
|
|
1725
|
+
}
|
|
1726
|
+
}
|
|
1727
|
+
return null;
|
|
1728
|
+
};
|
|
1729
|
+
return visit(this, kwargs);
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/**
|
|
1733
|
+
* Yield original nodes depth-first, parent before children.
|
|
1734
|
+
* Values are read statically: lazy resolvers are not invoked.
|
|
1735
|
+
* Shared subtrees are visited at each path, as in legacy Python.
|
|
1736
|
+
* @yields {BagNode}
|
|
1737
|
+
*/
|
|
1738
|
+
*traverse() {
|
|
1739
|
+
for (const node of this._nodes) {
|
|
1740
|
+
yield node;
|
|
1741
|
+
const value = node.getValue(true);
|
|
1742
|
+
if (value instanceof Bag) {
|
|
1743
|
+
yield* value.traverse();
|
|
1744
|
+
}
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
/** Stream path/node pairs for internal query and serialization use. */
|
|
1749
|
+
*_iterNodesWithPaths(isStatic = true, prefix = '') {
|
|
1750
|
+
for (const node of this._nodes) {
|
|
1751
|
+
const path = prefix ? `${prefix}.${node.label}` : node.label;
|
|
1752
|
+
yield [path, node];
|
|
1753
|
+
const value = node.getValue(isStatic);
|
|
1754
|
+
if (value instanceof Bag) yield* value._iterNodesWithPaths(isStatic, path);
|
|
1755
|
+
}
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
// -------------------------------------------------------------------------
|
|
1759
|
+
// TyTx Serialization
|
|
1760
|
+
// -------------------------------------------------------------------------
|
|
1761
|
+
|
|
1762
|
+
/**
|
|
1763
|
+
* Flatten nodes into (parent, label, tag, value, attr) tuples for TyTx.
|
|
1764
|
+
*
|
|
1765
|
+
* @param {Object|null} [pathRegistry=null] - If provided, enable compact mode.
|
|
1766
|
+
* @yields {Array} Tuples of [parent, label, tag, value, attr].
|
|
1767
|
+
*/
|
|
1768
|
+
*_nodeFlattener(pathRegistry = null) {
|
|
1769
|
+
const compact = pathRegistry !== null;
|
|
1770
|
+
const pathToCode = compact ? Object.create(null) : null;
|
|
1771
|
+
let codeCounter = 0;
|
|
1772
|
+
// Class of each branch by path: a node's parent class decides whether
|
|
1773
|
+
// its branch needs __cls (parentBag is null when the backref is off).
|
|
1774
|
+
const pathToClass = new Map([['', this.constructor]]);
|
|
1775
|
+
|
|
1776
|
+
for (const [path, node] of serializationNodes(this)) {
|
|
1777
|
+
const lastDot = path.lastIndexOf('.');
|
|
1778
|
+
const parentPath = lastDot >= 0 ? path.slice(0, lastDot) : '';
|
|
1779
|
+
|
|
1780
|
+
const nodeValue = node.getValue(true);
|
|
1781
|
+
|
|
1782
|
+
// Value encoding
|
|
1783
|
+
let value;
|
|
1784
|
+
let branchCls = null;
|
|
1785
|
+
if (node.resolver) {
|
|
1786
|
+
value = encodeResolver(node.resolver);
|
|
1787
|
+
} else if (nodeValue instanceof Bag) {
|
|
1788
|
+
const cls = nodeValue.constructor;
|
|
1789
|
+
const suffix = cls.tytxSuffix;
|
|
1790
|
+
pathToClass.set(path, cls);
|
|
1791
|
+
branchCls = getClsMarker(cls, getInheritedClass(pathToClass.get(parentPath), suffix));
|
|
1792
|
+
value = `::${suffix}`;
|
|
1793
|
+
} else if (nodeValue === null) {
|
|
1794
|
+
value = '::NN';
|
|
1795
|
+
} else {
|
|
1796
|
+
value = nodeValue;
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
if (node.attr && Object.hasOwn(node.attr, CLS_ATTRIBUTE)) {
|
|
1800
|
+
throw new BagSerializationError(
|
|
1801
|
+
`node '${path}': the attribute '${CLS_ATTRIBUTE}' is reserved for the Bag class name`);
|
|
1802
|
+
}
|
|
1803
|
+
const attr = encodeAttrs(node.attr);
|
|
1804
|
+
if (branchCls !== null) attr[CLS_ATTRIBUTE] = branchCls;
|
|
1805
|
+
const tag = node.nodeTag;
|
|
1806
|
+
|
|
1807
|
+
if (compact) {
|
|
1808
|
+
const parentRef = parentPath ? pathToCode[parentPath] : null;
|
|
1809
|
+
yield [parentRef, node.label, tag, value, attr];
|
|
1810
|
+
|
|
1811
|
+
if (nodeValue instanceof Bag) {
|
|
1812
|
+
pathToCode[path] = codeCounter;
|
|
1813
|
+
pathRegistry[codeCounter] = path;
|
|
1814
|
+
codeCounter++;
|
|
1815
|
+
}
|
|
1816
|
+
} else {
|
|
1817
|
+
// Use empty string for root level (matches Python behavior)
|
|
1818
|
+
yield [parentPath, node.label, tag, value, attr];
|
|
1819
|
+
}
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
|
|
1823
|
+
/**
|
|
1824
|
+
* Serialize Bag to TyTx format.
|
|
1825
|
+
*
|
|
1826
|
+
* Converts the Bag hierarchy into a flat list of row tuples,
|
|
1827
|
+
* then encodes using TyTx which preserves types (Decimal, Date, etc.).
|
|
1828
|
+
*
|
|
1829
|
+
* @param {string} [transport='json'] - Output format: 'json' or 'msgpack'.
|
|
1830
|
+
* @param {boolean} [compact=false] - If true, use numeric path codes.
|
|
1831
|
+
* @returns {string|Uint8Array} Serialized data.
|
|
1832
|
+
*/
|
|
1833
|
+
toTytx(transport = 'json', compact = false) {
|
|
1834
|
+
let data;
|
|
1835
|
+
|
|
1836
|
+
if (compact) {
|
|
1837
|
+
const paths = {};
|
|
1838
|
+
const rows = [...this._nodeFlattener(paths)];
|
|
1839
|
+
data = { rows, paths };
|
|
1840
|
+
} else {
|
|
1841
|
+
const rows = [...this._nodeFlattener(null)];
|
|
1842
|
+
data = { rows };
|
|
1843
|
+
}
|
|
1844
|
+
const rootClass = this.constructor;
|
|
1845
|
+
const rootCls = getClsMarker(rootClass, getInheritedClass(null, rootClass.tytxSuffix));
|
|
1846
|
+
if (rootCls !== null) data[CLS_ATTRIBUTE] = rootCls;
|
|
1847
|
+
|
|
1848
|
+
const tytxTransport = transport === 'json' ? null : transport;
|
|
1849
|
+
return tytxEncode(data, tytxTransport);
|
|
1850
|
+
}
|
|
1851
|
+
|
|
1852
|
+
/**
|
|
1853
|
+
* Deserialize Bag from TyTx format.
|
|
1854
|
+
*
|
|
1855
|
+
* @param {string|Uint8Array} data - Serialized data from toTytx().
|
|
1856
|
+
* @param {string} [transport='json'] - Input format: 'json' or 'msgpack'.
|
|
1857
|
+
* @returns {Bag} Reconstructed Bag.
|
|
1858
|
+
*/
|
|
1859
|
+
static fromTytx(data, transport = 'json') {
|
|
1860
|
+
// The payload alone decides the class of the root: without __cls it is
|
|
1861
|
+
// the class registered for the suffix (Bag for "X"), whatever this is.
|
|
1862
|
+
const suffix = this.tytxSuffix;
|
|
1863
|
+
// The registry passes an empty payload for a structural branch marker.
|
|
1864
|
+
if (data === '') return new (getInheritedClass(null, suffix))();
|
|
1865
|
+
const parsed = tytxDecode(data, transport === 'json' ? null : transport);
|
|
1866
|
+
if (!parsed || !Array.isArray(parsed.rows)) {
|
|
1867
|
+
throw new BagSerializationError('Invalid TYTX Bag: expected rows');
|
|
1868
|
+
}
|
|
1869
|
+
const paths = parsed.paths;
|
|
1870
|
+
const compact = paths != null;
|
|
1871
|
+
const rootClass = Object.hasOwn(parsed, CLS_ATTRIBUTE)
|
|
1872
|
+
? getSubtypeClass(suffix, parsed[CLS_ATTRIBUTE])
|
|
1873
|
+
: getInheritedClass(null, suffix);
|
|
1874
|
+
const bag = new rootClass();
|
|
1875
|
+
const pathToBag = new Map([['', bag]]);
|
|
1876
|
+
|
|
1877
|
+
for (const row of parsed.rows) {
|
|
1878
|
+
if (!Array.isArray(row) || row.length !== 5) {
|
|
1879
|
+
throw new BagSerializationError('Invalid TYTX Bag row');
|
|
1880
|
+
}
|
|
1881
|
+
const [parentRef, label, tag, rawValue, rawAttr] = row;
|
|
1882
|
+
let parentPath = parentRef ?? '';
|
|
1883
|
+
if (compact) {
|
|
1884
|
+
if (parentRef !== null && !Object.hasOwn(paths, parentRef)) {
|
|
1885
|
+
throw new BagSerializationError(`Unknown TYTX parent reference: ${parentRef}`);
|
|
1886
|
+
}
|
|
1887
|
+
parentPath = parentRef === null ? '' : paths[parentRef];
|
|
1888
|
+
}
|
|
1889
|
+
if (!pathToBag.has(parentPath)) {
|
|
1890
|
+
throw new BagSerializationError(`Missing or undecodable TYTX parent branch: ${parentPath}`);
|
|
1891
|
+
}
|
|
1892
|
+
const parentBag = pathToBag.get(parentPath);
|
|
1893
|
+
const fullPath = parentPath ? `${parentPath}.${label}` : label;
|
|
1894
|
+
let value = rawValue;
|
|
1895
|
+
// MessagePack does not rescan text. Only Bag-owned empty markers
|
|
1896
|
+
// are structural: arbitrary scalar decoders must never run here.
|
|
1897
|
+
if (transport === 'msgpack' && typeof value === 'string' && value.startsWith('::')) {
|
|
1898
|
+
const cls = getRegisteredType(value.slice(2));
|
|
1899
|
+
if (cls === Bag || cls?.prototype instanceof Bag) value = tytxDecode(value);
|
|
1900
|
+
}
|
|
1901
|
+
const resolver = decodeResolver(value);
|
|
1902
|
+
const attr = decodeAttrs(rawAttr);
|
|
1903
|
+
const branchCls = Object.hasOwn(attr, CLS_ATTRIBUTE) ? attr[CLS_ATTRIBUTE] : null;
|
|
1904
|
+
delete attr[CLS_ATTRIBUTE];
|
|
1905
|
+
if (value instanceof Bag) {
|
|
1906
|
+
// The class is the __cls name, or the one inherited from the parent.
|
|
1907
|
+
const branchSuffix = value.constructor.tytxSuffix;
|
|
1908
|
+
const cls = branchCls !== null
|
|
1909
|
+
? getSubtypeClass(branchSuffix, branchCls)
|
|
1910
|
+
: getInheritedClass(parentBag.constructor, branchSuffix);
|
|
1911
|
+
value = new cls();
|
|
1912
|
+
pathToBag.set(fullPath, value);
|
|
1913
|
+
} else if (branchCls !== null) {
|
|
1914
|
+
throw new BagSerializationError(
|
|
1915
|
+
`node '${fullPath}': the attribute '${CLS_ATTRIBUTE}' is reserved for the Bag class name of a branch`);
|
|
1916
|
+
} else if (value === '::NN') {
|
|
1917
|
+
value = null;
|
|
1918
|
+
}
|
|
1919
|
+
const node = parentBag.setItem(label, resolver || value, attr);
|
|
1920
|
+
node.nodeTag = tag ?? null;
|
|
1921
|
+
}
|
|
1922
|
+
return bag;
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
// -------------------------------------------------------------------------
|
|
1926
|
+
// XML Serialization
|
|
1927
|
+
// -------------------------------------------------------------------------
|
|
1928
|
+
|
|
1929
|
+
/**
|
|
1930
|
+
* Serialize Bag to XML format.
|
|
1931
|
+
*
|
|
1932
|
+
* All values are converted to strings without type information.
|
|
1933
|
+
* For type-preserving serialization, use toTytx() instead.
|
|
1934
|
+
*
|
|
1935
|
+
* @param {Object} [options={}] - Serialization options.
|
|
1936
|
+
* @param {boolean} [options.pretty=false] - If true, format with indentation.
|
|
1937
|
+
* @param {string} [options.encoding='UTF-8'] - XML encoding.
|
|
1938
|
+
* @param {boolean|string} [options.docHeader=null] - XML declaration.
|
|
1939
|
+
* @param {string[]} [options.selfClosedTags=null] - Tags to self-close when empty.
|
|
1940
|
+
* @returns {string} XML string.
|
|
1941
|
+
*/
|
|
1942
|
+
toXml(options = {}) {
|
|
1943
|
+
const { pretty = false, encoding = 'UTF-8', docHeader = null, selfClosedTags = null } = options;
|
|
1944
|
+
|
|
1945
|
+
let content = this._bagToXml(selfClosedTags, pretty);
|
|
1946
|
+
|
|
1947
|
+
if (docHeader === true) {
|
|
1948
|
+
content = `<?xml version='1.0' encoding='${encoding}'?>\n${content}`;
|
|
1949
|
+
} else if (typeof docHeader === 'string') {
|
|
1950
|
+
content = `${docHeader}\n${content}`;
|
|
1951
|
+
}
|
|
1952
|
+
|
|
1953
|
+
return content;
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
/**
|
|
1957
|
+
* Convert Bag to XML string (internal).
|
|
1958
|
+
* @private
|
|
1959
|
+
*/
|
|
1960
|
+
_bagToXml(selfClosedTags = null, pretty = false, depth = 0) {
|
|
1961
|
+
const parts = [];
|
|
1962
|
+
for (const node of this._nodes) {
|
|
1963
|
+
parts.push((pretty ? ' '.repeat(depth) : '') +
|
|
1964
|
+
this._nodeToXml(node, selfClosedTags, pretty, depth));
|
|
1965
|
+
}
|
|
1966
|
+
return parts.join(pretty ? '\n' : '');
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
/**
|
|
1970
|
+
* Convert a BagNode to XML string (internal).
|
|
1971
|
+
* @private
|
|
1972
|
+
*/
|
|
1973
|
+
_nodeToXml(node, selfClosedTags = null, pretty = false, depth = 0) {
|
|
1974
|
+
// Priority: xml_tag > node_tag > label (same as Python)
|
|
1975
|
+
const originalTag = node.xmlTag || node.nodeTag || node.label;
|
|
1976
|
+
const tag = this._sanitizeTag(originalTag);
|
|
1977
|
+
|
|
1978
|
+
// Build attributes string
|
|
1979
|
+
const attrsParts = [];
|
|
1980
|
+
|
|
1981
|
+
// If tag was sanitized, save original as _tag attribute
|
|
1982
|
+
if (tag !== originalTag) {
|
|
1983
|
+
attrsParts.push(`_tag="${this._escapeAttr(originalTag)}"`);
|
|
1984
|
+
}
|
|
1985
|
+
|
|
1986
|
+
if (node.resolver) {
|
|
1987
|
+
attrsParts.push(`_resolver="${this._escapeAttr(encodeResolver(node.resolver))}"`);
|
|
1988
|
+
}
|
|
1989
|
+
if (node.attr) {
|
|
1990
|
+
for (const [k, v] of Object.entries(encodeAttrs(node.attr))) {
|
|
1991
|
+
if (v !== null && v !== false && v !== undefined) {
|
|
1992
|
+
attrsParts.push(`${k}="${this._escapeAttr(String(v))}"`);
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
}
|
|
1996
|
+
const attrsStr = attrsParts.length ? ' ' + attrsParts.join(' ') : '';
|
|
1997
|
+
|
|
1998
|
+
// Handle value
|
|
1999
|
+
const value = node.resolver ? null : node.getValue(true); // static=true
|
|
2000
|
+
|
|
2001
|
+
// Check if value is a Bag
|
|
2002
|
+
if (value && typeof value._bagToXml === 'function') {
|
|
2003
|
+
// Never add whitespace inside a subtree that explicitly preserves it.
|
|
2004
|
+
pretty = pretty && node.attr?.['xml:space'] !== 'preserve';
|
|
2005
|
+
const inner = value._bagToXml(selfClosedTags, pretty, depth + 1);
|
|
2006
|
+
if (inner) {
|
|
2007
|
+
if (pretty) {
|
|
2008
|
+
return `<${tag}${attrsStr}>\n${inner}\n${' '.repeat(depth)}</${tag}>`;
|
|
2009
|
+
}
|
|
2010
|
+
return `<${tag}${attrsStr}>${inner}</${tag}>`;
|
|
2011
|
+
}
|
|
2012
|
+
// Empty Bag
|
|
2013
|
+
if (selfClosedTags === null || selfClosedTags.includes(tag)) {
|
|
2014
|
+
return `<${tag}${attrsStr}/>`;
|
|
2015
|
+
}
|
|
2016
|
+
return `<${tag}${attrsStr}></${tag}>`;
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
// Scalar value
|
|
2020
|
+
if (value === null || value === undefined || value === '') {
|
|
2021
|
+
if (selfClosedTags === null || selfClosedTags.includes(tag)) {
|
|
2022
|
+
return `<${tag}${attrsStr}/>`;
|
|
2023
|
+
}
|
|
2024
|
+
return `<${tag}${attrsStr}></${tag}>`;
|
|
2025
|
+
}
|
|
2026
|
+
|
|
2027
|
+
const text = this._escapeXml(String(value));
|
|
2028
|
+
return `<${tag}${attrsStr}>${text}</${tag}>`;
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
/**
|
|
2032
|
+
* Sanitize tag name for XML.
|
|
2033
|
+
* @private
|
|
2034
|
+
*/
|
|
2035
|
+
_sanitizeTag(tag) {
|
|
2036
|
+
if (!tag) return '_none_';
|
|
2037
|
+
// Replace invalid characters with underscore
|
|
2038
|
+
return tag.replace(/[^a-zA-Z0-9_\-.:]/g, '_');
|
|
2039
|
+
}
|
|
2040
|
+
|
|
2041
|
+
/**
|
|
2042
|
+
* Escape XML text content.
|
|
2043
|
+
* @private
|
|
2044
|
+
*/
|
|
2045
|
+
_escapeXml(str) {
|
|
2046
|
+
return str
|
|
2047
|
+
.replace(/&/g, '&')
|
|
2048
|
+
.replace(/</g, '<')
|
|
2049
|
+
.replace(/>/g, '>');
|
|
2050
|
+
}
|
|
2051
|
+
|
|
2052
|
+
/**
|
|
2053
|
+
* Escape XML attribute value.
|
|
2054
|
+
* @private
|
|
2055
|
+
*/
|
|
2056
|
+
_escapeAttr(str) {
|
|
2057
|
+
return str
|
|
2058
|
+
.replace(/&/g, '&')
|
|
2059
|
+
.replace(/</g, '<')
|
|
2060
|
+
.replace(/>/g, '>')
|
|
2061
|
+
.replace(/"/g, '"');
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
/**
|
|
2065
|
+
* Deserialize Bag from XML format.
|
|
2066
|
+
*
|
|
2067
|
+
* @param {string} source - XML string to parse.
|
|
2068
|
+
* @param {Object} [options={}] - Parsing options.
|
|
2069
|
+
* @param {string} [options.tagAttribute=null] - If set, save XML tag name as this attribute on each node.
|
|
2070
|
+
* @returns {Bag} Reconstructed Bag hierarchy.
|
|
2071
|
+
*/
|
|
2072
|
+
static fromXml(source, options = {}) {
|
|
2073
|
+
const { tagAttribute = null } = options;
|
|
2074
|
+
|
|
2075
|
+
// Use DOMParser (browser) or @xmldom/xmldom (Node.js)
|
|
2076
|
+
let doc;
|
|
2077
|
+
if (typeof DOMParser !== 'undefined') {
|
|
2078
|
+
const parser = new DOMParser();
|
|
2079
|
+
doc = parser.parseFromString(source, 'application/xml');
|
|
2080
|
+
// Check for parse errors (browser-specific)
|
|
2081
|
+
const parseError = doc.querySelector('parsererror');
|
|
2082
|
+
if (parseError) {
|
|
2083
|
+
throw new Error(`XML parse error: ${parseError.textContent}`);
|
|
2084
|
+
}
|
|
2085
|
+
} else {
|
|
2086
|
+
// Node.js environment - use @xmldom/xmldom
|
|
2087
|
+
const parser = new XmlDOMParser();
|
|
2088
|
+
doc = parser.parseFromString(source, 'application/xml');
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
return this._xmlElementToBag(doc.documentElement, tagAttribute);
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
/**
|
|
2095
|
+
* Convert XML element to Bag (recursive).
|
|
2096
|
+
* @param {Element} element - XML element to convert.
|
|
2097
|
+
* @param {string|null} [tagAttribute=null] - If set, save tag name as this attribute.
|
|
2098
|
+
* @private
|
|
2099
|
+
*/
|
|
2100
|
+
static _xmlElementToBag(element, tagAttribute = null) {
|
|
2101
|
+
const bag = new this();
|
|
2102
|
+
|
|
2103
|
+
// Use childNodes and filter for element nodes (nodeType === 1)
|
|
2104
|
+
// This works both in browser and @xmldom/xmldom
|
|
2105
|
+
const childElements = Array.from(element.childNodes).filter(n => n.nodeType === 1);
|
|
2106
|
+
|
|
2107
|
+
for (const child of childElements) {
|
|
2108
|
+
const originalXmlTag = child.tagName;
|
|
2109
|
+
const attr = {};
|
|
2110
|
+
|
|
2111
|
+
// Collect attributes
|
|
2112
|
+
for (let i = 0; i < child.attributes.length; i++) {
|
|
2113
|
+
const attrNode = child.attributes[i];
|
|
2114
|
+
attr[attrNode.name] = attrNode.value;
|
|
2115
|
+
}
|
|
2116
|
+
|
|
2117
|
+
const resolver = decodeResolver(attr._resolver);
|
|
2118
|
+
if (resolver) delete attr._resolver;
|
|
2119
|
+
const isBagValue = String(attr._T || '').toUpperCase() === 'BAG';
|
|
2120
|
+
Object.assign(attr, decodeAttrs(attr));
|
|
2121
|
+
|
|
2122
|
+
// Resolve label: _tag attribute > tagAttribute > XML tag name
|
|
2123
|
+
let label = originalXmlTag;
|
|
2124
|
+
if ('_tag' in attr) {
|
|
2125
|
+
label = attr._tag;
|
|
2126
|
+
delete attr._tag;
|
|
2127
|
+
}
|
|
2128
|
+
if (tagAttribute && tagAttribute in attr) {
|
|
2129
|
+
label = attr[tagAttribute];
|
|
2130
|
+
delete attr[tagAttribute];
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
// Repeated XML elements must not replace earlier siblings.
|
|
2134
|
+
// Keep their original tag separately for serialization.
|
|
2135
|
+
const requestedLabel = label;
|
|
2136
|
+
let suffix = 1;
|
|
2137
|
+
while (bag.getNode(label)) {
|
|
2138
|
+
label = `${requestedLabel}_${suffix++}`;
|
|
2139
|
+
}
|
|
2140
|
+
|
|
2141
|
+
// Check if has child elements (nested Bag)
|
|
2142
|
+
const childChildElements = Array.from(child.childNodes).filter(n => n.nodeType === 1);
|
|
2143
|
+
let node;
|
|
2144
|
+
if (childChildElements.length > 0 || isBagValue) {
|
|
2145
|
+
const childBag = this._xmlElementToBag(child, tagAttribute);
|
|
2146
|
+
node = bag.setItem(label, childBag, Object.keys(attr).length > 0 ? attr : null);
|
|
2147
|
+
} else {
|
|
2148
|
+
// Text content
|
|
2149
|
+
const value = child.textContent || '';
|
|
2150
|
+
node = bag.setItem(label, value, Object.keys(attr).length > 0 ? attr : null);
|
|
2151
|
+
}
|
|
2152
|
+
|
|
2153
|
+
if (resolver) node.resolver = resolver;
|
|
2154
|
+
// Save original XML tag for round-trip serialization
|
|
2155
|
+
node.xmlTag = originalXmlTag;
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
return bag;
|
|
2159
|
+
}
|
|
2160
|
+
|
|
2161
|
+
/**
|
|
2162
|
+
* Load Bag from URL. Auto-detects format from content-type.
|
|
2163
|
+
*
|
|
2164
|
+
* @param {string} url - HTTP/HTTPS URL to fetch.
|
|
2165
|
+
* @param {Object} [options={}] - Fetch options.
|
|
2166
|
+
* @param {number} [options.timeout=30] - Timeout in seconds.
|
|
2167
|
+
* @returns {Promise<Bag>} Parsed content as Bag.
|
|
2168
|
+
*/
|
|
2169
|
+
static async fromUrl(url, options = {}) {
|
|
2170
|
+
const { timeout = 30 } = options;
|
|
2171
|
+
const fetchOptions = {};
|
|
2172
|
+
if (timeout) {
|
|
2173
|
+
fetchOptions.signal = AbortSignal.timeout(timeout * 1000);
|
|
2174
|
+
}
|
|
2175
|
+
|
|
2176
|
+
const response = await fetch(url, fetchOptions);
|
|
2177
|
+
const contentType = response.headers.get('content-type') || '';
|
|
2178
|
+
const text = await response.text();
|
|
2179
|
+
|
|
2180
|
+
if (contentType.includes('json')) {
|
|
2181
|
+
return Bag.fromJson(text);
|
|
2182
|
+
}
|
|
2183
|
+
if (contentType.includes('xml')) {
|
|
2184
|
+
return Bag.fromXml(text);
|
|
2185
|
+
}
|
|
2186
|
+
// Try to auto-detect from content
|
|
2187
|
+
const trimmed = text.trim();
|
|
2188
|
+
if (trimmed.startsWith('<')) {
|
|
2189
|
+
return Bag.fromXml(text);
|
|
2190
|
+
}
|
|
2191
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
2192
|
+
return Bag.fromJson(text);
|
|
2193
|
+
}
|
|
2194
|
+
throw new BagException(`Unsupported content-type: ${contentType}`);
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
// -------------------------------------------------------------------------
|
|
2198
|
+
// JSON Serialization
|
|
2199
|
+
// -------------------------------------------------------------------------
|
|
2200
|
+
|
|
2201
|
+
/**
|
|
2202
|
+
* Serialize Bag to JSON string.
|
|
2203
|
+
*
|
|
2204
|
+
* Each node becomes {"label": ..., "value": ..., "attr": {...}}.
|
|
2205
|
+
* Nested Bags have value as a list of child nodes.
|
|
2206
|
+
*
|
|
2207
|
+
* @param {boolean} [typed=true] - If true, encode types for date/datetime/Decimal (TYTX).
|
|
2208
|
+
* @returns {string} JSON string representation.
|
|
2209
|
+
*/
|
|
2210
|
+
toJson(typed = true) {
|
|
2211
|
+
const result = [];
|
|
2212
|
+
for (const node of this._nodes) {
|
|
2213
|
+
result.push(this._nodeToJsonDict(node, typed));
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
if (typed) {
|
|
2217
|
+
return tytxEncode(result);
|
|
2218
|
+
}
|
|
2219
|
+
return JSON.stringify(result);
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
/**
|
|
2223
|
+
* Convert a BagNode to JSON-serializable dict (internal).
|
|
2224
|
+
* @private
|
|
2225
|
+
*/
|
|
2226
|
+
_nodeToJsonDict(node, typed) {
|
|
2227
|
+
let value = node.resolver ? null : node.getValue(true); // static=true
|
|
2228
|
+
|
|
2229
|
+
// Check if value is a Bag
|
|
2230
|
+
if (value && typeof value._nodeToJsonDict === 'function') {
|
|
2231
|
+
const childResult = [];
|
|
2232
|
+
for (const childNode of value._nodes) {
|
|
2233
|
+
childResult.push(value._nodeToJsonDict(childNode, typed));
|
|
2234
|
+
}
|
|
2235
|
+
value = childResult;
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
const result = { label: node.label, value, attr: encodeAttrs(node.attr) };
|
|
2239
|
+
if (node.nodeTag !== null) result.tag = node.nodeTag;
|
|
2240
|
+
if (node.resolver) result.resolver = encodeResolver(node.resolver);
|
|
2241
|
+
return result;
|
|
2242
|
+
}
|
|
2243
|
+
|
|
2244
|
+
/**
|
|
2245
|
+
* Deserialize JSON to Bag.
|
|
2246
|
+
*
|
|
2247
|
+
* Accepts JSON string, dict, or list. Recursively converts nested
|
|
2248
|
+
* structures to Bag hierarchy.
|
|
2249
|
+
*
|
|
2250
|
+
* @param {string|Object|Array} source - JSON string, dict or list to parse.
|
|
2251
|
+
* @param {string} [listJoiner=null] - If provided, join string arrays with this separator.
|
|
2252
|
+
* @returns {Bag} Deserialized Bag.
|
|
2253
|
+
*/
|
|
2254
|
+
static fromJson(source, listJoiner = null) {
|
|
2255
|
+
if (typeof source === 'string') {
|
|
2256
|
+
source = tytxDecode(source);
|
|
2257
|
+
}
|
|
2258
|
+
|
|
2259
|
+
if (!Array.isArray(source) && typeof source !== 'object') {
|
|
2260
|
+
source = { value: source };
|
|
2261
|
+
}
|
|
2262
|
+
|
|
2263
|
+
return Bag._fromJsonRecursive(source, listJoiner);
|
|
2264
|
+
}
|
|
2265
|
+
|
|
2266
|
+
/**
|
|
2267
|
+
* Recursively convert JSON data to Bag (internal).
|
|
2268
|
+
* @private
|
|
2269
|
+
*/
|
|
2270
|
+
static _fromJsonRecursive(data, listJoiner = null) {
|
|
2271
|
+
if (data instanceof Bag) return data;
|
|
2272
|
+
if (Array.isArray(data)) {
|
|
2273
|
+
// listJoiner: join string arrays into a single string
|
|
2274
|
+
if (listJoiner !== null && data.every(item => typeof item === 'string')) {
|
|
2275
|
+
return data.join(listJoiner);
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
if (data.length === 0) {
|
|
2279
|
+
return new Bag();
|
|
2280
|
+
}
|
|
2281
|
+
|
|
2282
|
+
// Check if list items have 'label' key (Bag node format)
|
|
2283
|
+
if (typeof data[0] === 'object' && data[0] !== null && 'label' in data[0]) {
|
|
2284
|
+
const result = new Bag();
|
|
2285
|
+
for (const item of data) {
|
|
2286
|
+
const label = item.label;
|
|
2287
|
+
const value = Bag._fromJsonRecursive(item.value, listJoiner);
|
|
2288
|
+
const attr = decodeAttrs(item.attr);
|
|
2289
|
+
const node = result.setItem(label, value, attr);
|
|
2290
|
+
node.nodeTag = item.tag ?? null;
|
|
2291
|
+
const resolver = decodeResolver(item.resolver);
|
|
2292
|
+
if (resolver) node.resolver = resolver;
|
|
2293
|
+
}
|
|
2294
|
+
return result;
|
|
2295
|
+
}
|
|
2296
|
+
|
|
2297
|
+
// Generic list -> Bag with r_N keys
|
|
2298
|
+
const result = new Bag();
|
|
2299
|
+
for (let n = 0; n < data.length; n++) {
|
|
2300
|
+
result.setItem(`r_${n}`, Bag._fromJsonRecursive(data[n], listJoiner));
|
|
2301
|
+
}
|
|
2302
|
+
return result;
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
if (typeof data === 'object' && data !== null) {
|
|
2306
|
+
if (Object.keys(data).length === 0) {
|
|
2307
|
+
return new Bag();
|
|
2308
|
+
}
|
|
2309
|
+
const result = new Bag();
|
|
2310
|
+
for (const [k, v] of Object.entries(data)) {
|
|
2311
|
+
result.setItem(k, Bag._fromJsonRecursive(v, listJoiner));
|
|
2312
|
+
}
|
|
2313
|
+
return result;
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
// Scalar value
|
|
2317
|
+
return data;
|
|
2318
|
+
}
|
|
2319
|
+
|
|
2320
|
+
// -------------------------------------------------------------------------
|
|
2321
|
+
// String representation
|
|
2322
|
+
// -------------------------------------------------------------------------
|
|
2323
|
+
|
|
2324
|
+
/**
|
|
2325
|
+
* Return ASCII tree representation of bag contents.
|
|
2326
|
+
*
|
|
2327
|
+
* Produces a visual tree structure showing all nodes, their values,
|
|
2328
|
+
* and attributes. Handles nested Bags recursively and detects
|
|
2329
|
+
* circular references.
|
|
2330
|
+
*
|
|
2331
|
+
* @param {boolean} [isStatic=true] - If false, triggers resolvers to get current values.
|
|
2332
|
+
* @param {Map} [_visited=null] - Internal: tracks visited nodes for circular refs.
|
|
2333
|
+
* @param {string} [_prefix=''] - Internal: indentation prefix for nested bags.
|
|
2334
|
+
* @param {boolean} [_isLast=true] - Internal: whether this is the last sibling.
|
|
2335
|
+
* @returns {string} ASCII tree representation.
|
|
2336
|
+
*
|
|
2337
|
+
* @example
|
|
2338
|
+
* const bag = new Bag();
|
|
2339
|
+
* bag.setItem('user.age', 30);
|
|
2340
|
+
* bag.setItem('user.city', 'Rome');
|
|
2341
|
+
* console.log(bag.toStringTree());
|
|
2342
|
+
* // user
|
|
2343
|
+
* // ├── age: 30
|
|
2344
|
+
* // └── city: 'Rome'
|
|
2345
|
+
*/
|
|
2346
|
+
toStringTree(isStatic = true, _visited = null, _prefix = '', _isLast = true) {
|
|
2347
|
+
if (_visited === null) {
|
|
2348
|
+
_visited = new Map();
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
const lines = [];
|
|
2352
|
+
const nodes = [...this._nodes];
|
|
2353
|
+
|
|
2354
|
+
for (let idx = 0; idx < nodes.length; idx++) {
|
|
2355
|
+
const node = nodes[idx];
|
|
2356
|
+
const isLast = idx === nodes.length - 1;
|
|
2357
|
+
const value = node.getValue(isStatic);
|
|
2358
|
+
|
|
2359
|
+
// Format attributes
|
|
2360
|
+
const attrs = node.getAttr();
|
|
2361
|
+
let attrStr = '';
|
|
2362
|
+
const attrKeys = Object.keys(attrs);
|
|
2363
|
+
if (attrKeys.length > 0) {
|
|
2364
|
+
const attrParts = attrKeys.map(k => `${k}=${JSON.stringify(attrs[k])}`);
|
|
2365
|
+
attrStr = ' [' + attrParts.join(', ') + ']';
|
|
2366
|
+
}
|
|
2367
|
+
|
|
2368
|
+
// Tree characters
|
|
2369
|
+
const branch = isLast ? '└── ' : '├── ';
|
|
2370
|
+
const childPrefix = _prefix + (isLast ? ' ' : '│ ');
|
|
2371
|
+
|
|
2372
|
+
if (value instanceof Bag) {
|
|
2373
|
+
const nodeId = node; // Use node object as key
|
|
2374
|
+
const backref = value.backref ? '(*)' : '';
|
|
2375
|
+
|
|
2376
|
+
if (_visited.has(nodeId)) {
|
|
2377
|
+
lines.push(`${_prefix}${branch}${node.label}${backref}${attrStr} → (circular ref)`);
|
|
2378
|
+
} else {
|
|
2379
|
+
_visited.set(nodeId, node.label);
|
|
2380
|
+
lines.push(`${_prefix}${branch}${node.label}${backref}${attrStr}`);
|
|
2381
|
+
const inner = value.toStringTree(isStatic, _visited, childPrefix, isLast);
|
|
2382
|
+
if (inner) {
|
|
2383
|
+
lines.push(inner);
|
|
2384
|
+
}
|
|
2385
|
+
}
|
|
2386
|
+
} else {
|
|
2387
|
+
// Format value representation
|
|
2388
|
+
let valueStr;
|
|
2389
|
+
if (value === null) {
|
|
2390
|
+
valueStr = 'null';
|
|
2391
|
+
} else if (value === undefined) {
|
|
2392
|
+
valueStr = 'undefined';
|
|
2393
|
+
} else if (value instanceof ArrayBuffer || value instanceof Uint8Array) {
|
|
2394
|
+
const decoder = new TextDecoder('utf-8', { fatal: false });
|
|
2395
|
+
const bytes = value instanceof ArrayBuffer ? new Uint8Array(value) : value;
|
|
2396
|
+
valueStr = decoder.decode(bytes);
|
|
2397
|
+
} else if (typeof value === 'string') {
|
|
2398
|
+
if (value.length > 50) {
|
|
2399
|
+
valueStr = JSON.stringify(value.slice(0, 47) + '...');
|
|
2400
|
+
} else {
|
|
2401
|
+
valueStr = JSON.stringify(value);
|
|
2402
|
+
}
|
|
2403
|
+
} else {
|
|
2404
|
+
valueStr = String(value);
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
lines.push(`${_prefix}${branch}${node.label}: ${valueStr}${attrStr}`);
|
|
2408
|
+
}
|
|
2409
|
+
}
|
|
2410
|
+
|
|
2411
|
+
return lines.join('\n');
|
|
2412
|
+
}
|
|
2413
|
+
|
|
2414
|
+
/**
|
|
2415
|
+
* Return ASCII tree representation of bag contents.
|
|
2416
|
+
*
|
|
2417
|
+
* @param {boolean} [isStatic=true] - If false, trigger resolvers.
|
|
2418
|
+
* @param {Object} [_visited=null] - Internal: tracks visited nodes for circular refs.
|
|
2419
|
+
* @param {string} [_prefix=''] - Internal: indentation prefix.
|
|
2420
|
+
* @returns {string} ASCII tree string.
|
|
2421
|
+
*/
|
|
2422
|
+
toString(isStatic = true, _visited = null, _prefix = '') {
|
|
2423
|
+
if (!_visited) {
|
|
2424
|
+
_visited = new Set();
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
const lines = [];
|
|
2428
|
+
const nodes = [...this._nodes];
|
|
2429
|
+
|
|
2430
|
+
for (let idx = 0; idx < nodes.length; idx++) {
|
|
2431
|
+
const node = nodes[idx];
|
|
2432
|
+
const isLast = idx === nodes.length - 1;
|
|
2433
|
+
const value = node.getValue(isStatic);
|
|
2434
|
+
|
|
2435
|
+
// Format attributes
|
|
2436
|
+
let attrStr = '';
|
|
2437
|
+
const attrs = node.attr;
|
|
2438
|
+
if (attrs && Object.keys(attrs).length > 0) {
|
|
2439
|
+
const parts = Object.entries(attrs).map(([k, v]) => `${k}=${JSON.stringify(v)}`);
|
|
2440
|
+
attrStr = ` [${parts.join(', ')}]`;
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
// Tree characters
|
|
2444
|
+
const branch = isLast ? '└── ' : '├── ';
|
|
2445
|
+
const childPrefix = _prefix + (isLast ? ' ' : '│ ');
|
|
2446
|
+
|
|
2447
|
+
if (value && typeof value._htraverse === 'function') {
|
|
2448
|
+
// Bag value
|
|
2449
|
+
const nodeId = node;
|
|
2450
|
+
if (_visited.has(nodeId)) {
|
|
2451
|
+
lines.push(`${_prefix}${branch}${node.label}${attrStr} → (circular ref)`);
|
|
2452
|
+
} else {
|
|
2453
|
+
_visited.add(nodeId);
|
|
2454
|
+
lines.push(`${_prefix}${branch}${node.label}${attrStr}`);
|
|
2455
|
+
const inner = value.toString(isStatic, _visited, childPrefix);
|
|
2456
|
+
if (inner) {
|
|
2457
|
+
lines.push(inner);
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
} else {
|
|
2461
|
+
// Scalar value
|
|
2462
|
+
let valueStr;
|
|
2463
|
+
if (value === null || value === undefined) {
|
|
2464
|
+
valueStr = 'null';
|
|
2465
|
+
} else if (typeof value === 'string' && value.length > 50) {
|
|
2466
|
+
valueStr = JSON.stringify(value.slice(0, 47) + '...');
|
|
2467
|
+
} else if (typeof value === 'string') {
|
|
2468
|
+
valueStr = JSON.stringify(value);
|
|
2469
|
+
} else {
|
|
2470
|
+
valueStr = String(value);
|
|
2471
|
+
}
|
|
2472
|
+
lines.push(`${_prefix}${branch}${node.label}: ${valueStr}${attrStr}`);
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
return lines.join('\n');
|
|
2477
|
+
}
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
/**
|
|
2481
|
+
* Exception raised for Bag-specific errors.
|
|
2482
|
+
*/
|
|
2483
|
+
export class BagException extends Error {
|
|
2484
|
+
constructor(message) {
|
|
2485
|
+
super(message);
|
|
2486
|
+
this.name = 'BagException';
|
|
2487
|
+
}
|
|
2488
|
+
}
|
|
2489
|
+
|
|
2490
|
+
// Register Bag class with BagResolver for asBag conversion (avoids circular import)
|
|
2491
|
+
BagResolver.registerBagClass(Bag);
|
|
2492
|
+
|
|
2493
|
+
// Shared TYTX registry: subclasses register their own wire suffix explicitly.
|
|
2494
|
+
registerClass(Bag);
|
|
2495
|
+
// Bag is the base class of the "X" type: its name must be in the subtype
|
|
2496
|
+
// dictionary like every subclass that travels as "::X" with a __cls name.
|
|
2497
|
+
setSubtypeDict(Bag.tytxSuffix, { ...getSubtypeDict(Bag.tytxSuffix), Bag });
|