@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-node.js
ADDED
|
@@ -0,0 +1,665 @@
|
|
|
1
|
+
// Copyright 2025 Softwell S.r.l. - SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
import { fromTytx } from '@genrojs/tytx';
|
|
4
|
+
import { BagResolver } from './resolver.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* BagNode - individual node in a Bag hierarchy.
|
|
8
|
+
*
|
|
9
|
+
* A BagNode gathers within itself three main things:
|
|
10
|
+
* - label: can be only a string
|
|
11
|
+
* - value: can be anything, even a Bag for hierarchical structure
|
|
12
|
+
* - attr: dictionary that contains node's metadata
|
|
13
|
+
*/
|
|
14
|
+
export class BagNode {
|
|
15
|
+
/**
|
|
16
|
+
* Create a new BagNode.
|
|
17
|
+
*
|
|
18
|
+
* @param {Object} parentBag - The parent Bag containing this node.
|
|
19
|
+
* @param {string} label - The node's key/name within the parent Bag.
|
|
20
|
+
* @param {*} [value=null] - The node's value (can be scalar or Bag).
|
|
21
|
+
* @param {Object} [attr=null] - Dict of attributes to set.
|
|
22
|
+
* @param {BagResolver} [resolver=null] - Resolver for lazy value loading.
|
|
23
|
+
* @param {string} [nodeTag=null] - Semantic type tag for the node.
|
|
24
|
+
* @param {string} [xmlTag=null] - Original XML tag name (for serialization).
|
|
25
|
+
* @param {boolean} [removeNullAttributes=true] - Remove null attributes during construction.
|
|
26
|
+
*/
|
|
27
|
+
constructor(parentBag, label, value = null, attr = null, resolver = null, nodeTag = null, xmlTag = null,
|
|
28
|
+
removeNullAttributes = true) {
|
|
29
|
+
this.label = label;
|
|
30
|
+
this._value = null;
|
|
31
|
+
this._attr = {};
|
|
32
|
+
this._parentBag = null;
|
|
33
|
+
this._resolver = null;
|
|
34
|
+
this._nodeSubscribers = {};
|
|
35
|
+
this._onChangedValue = null;
|
|
36
|
+
this.nodeTag = nodeTag;
|
|
37
|
+
this.xmlTag = xmlTag;
|
|
38
|
+
this._compiled = null;
|
|
39
|
+
|
|
40
|
+
// Set parent
|
|
41
|
+
this.parentBag = parentBag;
|
|
42
|
+
|
|
43
|
+
// Process attributes - trigger=false during construction
|
|
44
|
+
if (attr) {
|
|
45
|
+
this.setAttr(attr, false, true, removeNullAttributes);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Process value - trigger=false during construction
|
|
49
|
+
if (value !== null) {
|
|
50
|
+
this.setValue(value, false);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Set resolver after value (same order as Python)
|
|
54
|
+
if (resolver) {
|
|
55
|
+
this.resolver = resolver;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// -------------------------------------------------------------------------
|
|
60
|
+
// Parent Bag Property
|
|
61
|
+
// -------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
get parentBag() {
|
|
64
|
+
return this._parentBag;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
set parentBag(parentBag) {
|
|
68
|
+
if (parentBag === null && this._value?.parentNode === this) {
|
|
69
|
+
this._value.setBackref();
|
|
70
|
+
}
|
|
71
|
+
this._parentBag = parentBag;
|
|
72
|
+
if (parentBag?.backref && this._value && typeof this._value._htraverse === "function") {
|
|
73
|
+
this._value.setBackref(this, parentBag);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// -------------------------------------------------------------------------
|
|
78
|
+
// Value Property and Methods
|
|
79
|
+
// -------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
get value() {
|
|
82
|
+
return this.getValue();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
set value(value) {
|
|
86
|
+
this.setValue(value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get the node's value.
|
|
91
|
+
*
|
|
92
|
+
* @param {boolean} [isStatic=false] - If true, return cached value without triggering resolver.
|
|
93
|
+
* @param {string} [queryString=null] - Query string from path suffix (after '?').
|
|
94
|
+
* - null: return node value
|
|
95
|
+
* - '': return all attributes as an object
|
|
96
|
+
* - 'attr': return single attribute value (resolving attribute resolvers)
|
|
97
|
+
* - 'attr1&attr2': return tuple of attribute values
|
|
98
|
+
* - 'key=val::T&key2=val2::T': kwargs for resolver (parsed via tytx ::QS)
|
|
99
|
+
* @param {Object} [kwargs={}] - Additional kwargs passed to resolver.
|
|
100
|
+
* @returns {*} The value/attributes, or a Promise of the complete query result.
|
|
101
|
+
* Static attribute queries preserve raw resolver objects without executing them.
|
|
102
|
+
*/
|
|
103
|
+
getValue(isStatic = false, queryString = null, kwargs = {}) {
|
|
104
|
+
if (queryString !== null) {
|
|
105
|
+
// Use tytx ::QS to parse queryString (like Python)
|
|
106
|
+
const parsedQs = fromTytx(`${queryString}::QS`);
|
|
107
|
+
|
|
108
|
+
if (Array.isArray(parsedQs)) {
|
|
109
|
+
// Attribute resolvers use their own defaults, without node context.
|
|
110
|
+
const keys = parsedQs.length ? parsedQs : Object.keys(this._attr);
|
|
111
|
+
const values = keys.map(key => {
|
|
112
|
+
const value = this.getAttr(key);
|
|
113
|
+
return !isStatic && value instanceof BagResolver ? value.resolve() : value;
|
|
114
|
+
});
|
|
115
|
+
const result = attrs => parsedQs.length === 0
|
|
116
|
+
? Object.fromEntries(keys.map((key, index) => [key, attrs[index]]))
|
|
117
|
+
: attrs.length === 1 ? attrs[0] : attrs;
|
|
118
|
+
return values.some(value => value && typeof value.then === 'function')
|
|
119
|
+
? Promise.all(values).then(result) : result(values);
|
|
120
|
+
} else {
|
|
121
|
+
// Dict → kwargs for resolver: ?x=1&y=2 → {x: 1, y: 2}
|
|
122
|
+
if (!this._resolver) {
|
|
123
|
+
throw new Error('Cannot use kwargs syntax without resolver');
|
|
124
|
+
}
|
|
125
|
+
kwargs = { ...kwargs, ...parsedQs };
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Resolver support
|
|
130
|
+
if (this._resolver !== null) {
|
|
131
|
+
return this._resolver.resolve({ static: isStatic, ...kwargs });
|
|
132
|
+
}
|
|
133
|
+
return this._value;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Set the node's value.
|
|
138
|
+
*
|
|
139
|
+
* @param {*} value - The value to set.
|
|
140
|
+
* @param {boolean} [trigger=true] - If true, notify subscribers of the change.
|
|
141
|
+
* @param {Object} [attributes=null] - Optional attributes to set along with value.
|
|
142
|
+
* @param {boolean|null} [updattr=null] - If falsy (default null), replace
|
|
143
|
+
* existing attributes; if true, merge. Note: unlike setAttr called
|
|
144
|
+
* directly (which merges by default), setValue replaces by default,
|
|
145
|
+
* matching Python's _updattr=None.
|
|
146
|
+
* @param {boolean} [removeNullAttributes=true] - If true, remove null values from attributes.
|
|
147
|
+
* @param {string} [reason=null] - Optional reason string for the trigger.
|
|
148
|
+
* @param {boolean} [fired=false] - True when the write is a fired write;
|
|
149
|
+
* carried as `fired` in the Bag change event.
|
|
150
|
+
*/
|
|
151
|
+
setValue(value, trigger = true, attributes = null, updattr = null, removeNullAttributes = true, reason = null,
|
|
152
|
+
fired = false) {
|
|
153
|
+
// Handle BagResolver passed as value
|
|
154
|
+
if (value instanceof BagResolver) {
|
|
155
|
+
this.resolver = value;
|
|
156
|
+
value = null;
|
|
157
|
+
}
|
|
158
|
+
// Handle BagNode passed as value - extract its value and attrs
|
|
159
|
+
else if (value instanceof BagNode) {
|
|
160
|
+
attributes = attributes || {};
|
|
161
|
+
Object.assign(attributes, value._attr);
|
|
162
|
+
if (value.resolver) {
|
|
163
|
+
this.resolver = value.resolver;
|
|
164
|
+
}
|
|
165
|
+
value = value._value;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const oldvalue = this._value;
|
|
169
|
+
if (oldvalue !== value && oldvalue?.parentNode === this) {
|
|
170
|
+
oldvalue.setBackref();
|
|
171
|
+
}
|
|
172
|
+
this._value = value;
|
|
173
|
+
|
|
174
|
+
const valueChanged = oldvalue !== this._value;
|
|
175
|
+
const callbackTrigger = trigger == null ? true : trigger;
|
|
176
|
+
let attrsDiff = null;
|
|
177
|
+
|
|
178
|
+
if (attributes !== null) {
|
|
179
|
+
// Apply attributes silently; emit one event for the actual changes.
|
|
180
|
+
const oldattrSnapshot = { ...this._attr };
|
|
181
|
+
this.setAttr(attributes, false, updattr, removeNullAttributes);
|
|
182
|
+
const diff = this._buildAttrDiff(oldattrSnapshot, this._attr);
|
|
183
|
+
attrsDiff = Object.keys(diff).length > 0 ? diff : null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
trigger = callbackTrigger && (valueChanged || attrsDiff !== null);
|
|
187
|
+
const evt = attrsDiff ? (valueChanged ? 'upd_value_attr' : 'upd_attrs') : 'upd_value';
|
|
188
|
+
|
|
189
|
+
// Legacy value hook also runs for silent and unchanged assignments.
|
|
190
|
+
if (this._onChangedValue) {
|
|
191
|
+
this._onChangedValue(this, value, oldvalue, callbackTrigger);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// Node subscribers receive the real event type and an info object:
|
|
195
|
+
// { oldvalue } for upd_value, plus { attrs_diff } for upd_value_attr
|
|
196
|
+
if (trigger) {
|
|
197
|
+
const info = attrsDiff !== null
|
|
198
|
+
? { oldvalue, attrs_diff: attrsDiff }
|
|
199
|
+
: { oldvalue };
|
|
200
|
+
for (const subscriber of Object.values(this._nodeSubscribers)) {
|
|
201
|
+
subscriber({ node: this, info, evt });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (this._parentBag !== null && this._parentBag.backref) {
|
|
206
|
+
// If value is a Bag, set up backref
|
|
207
|
+
if (value && typeof value._htraverse === 'function') {
|
|
208
|
+
value.setBackref(this, this._parentBag);
|
|
209
|
+
}
|
|
210
|
+
if (trigger) {
|
|
211
|
+
this._parentBag._onNodeChanged(
|
|
212
|
+
this, [this.label], evt, oldvalue, attrsDiff, reason, fired
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Copy value, attributes and resolver; retain label, identity and position. */
|
|
219
|
+
replace(other) {
|
|
220
|
+
if (!(other instanceof BagNode)) throw new TypeError('BagNode.replace expects a BagNode');
|
|
221
|
+
if (other === this) return this;
|
|
222
|
+
let value = other.staticValue;
|
|
223
|
+
if (value && typeof value._htraverse === 'function') {
|
|
224
|
+
value = value.createChildBag().replace(value);
|
|
225
|
+
}
|
|
226
|
+
// Copy resolver state without executing it or rebinding the source.
|
|
227
|
+
let resolver = null;
|
|
228
|
+
if (other.resolver) {
|
|
229
|
+
resolver = Object.create(Object.getPrototypeOf(other.resolver),
|
|
230
|
+
Object.getOwnPropertyDescriptors(other.resolver));
|
|
231
|
+
resolver._node = null;
|
|
232
|
+
resolver._kw = { ...other.resolver._kw };
|
|
233
|
+
}
|
|
234
|
+
const oldvalue = this._value;
|
|
235
|
+
const oldattr = { ...this._attr };
|
|
236
|
+
const oldresolver = this.resolver;
|
|
237
|
+
if (oldresolver && oldresolver._node === this) oldresolver._node = null;
|
|
238
|
+
this.resolver = resolver;
|
|
239
|
+
this.setValue(value, false, { ...other.attr }, false, false, null);
|
|
240
|
+
const diff = this._buildAttrDiff(oldattr, this._attr);
|
|
241
|
+
const attrsDiff = Object.keys(diff).length ? diff : null;
|
|
242
|
+
const valueChanged = oldvalue !== value || oldresolver !== resolver;
|
|
243
|
+
if (valueChanged || attrsDiff) {
|
|
244
|
+
const evt = attrsDiff ? (valueChanged ? 'upd_value_attr' : 'upd_attrs') : 'upd_value';
|
|
245
|
+
const info = attrsDiff ? {oldvalue, attrs_diff: attrsDiff} : {oldvalue};
|
|
246
|
+
for (const callback of Object.values(this._nodeSubscribers)) {
|
|
247
|
+
callback({node: this, info, evt});
|
|
248
|
+
}
|
|
249
|
+
if (this._parentBag !== null && this._parentBag.backref) {
|
|
250
|
+
this._parentBag._onNodeChanged(this, [this.label], evt, oldvalue, attrsDiff);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return this;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Get node's raw _value (bypassing resolver).
|
|
258
|
+
*/
|
|
259
|
+
get staticValue() {
|
|
260
|
+
return this._value;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
set staticValue(value) {
|
|
264
|
+
this._value = value;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Get the resolver attached to this node.
|
|
269
|
+
*
|
|
270
|
+
* @returns {BagResolver|null} The resolver or null.
|
|
271
|
+
*/
|
|
272
|
+
get resolver() {
|
|
273
|
+
return this._resolver;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Set the resolver for this node.
|
|
278
|
+
*
|
|
279
|
+
* @param {BagResolver|null} resolver - The resolver to attach.
|
|
280
|
+
*/
|
|
281
|
+
set resolver(resolver) {
|
|
282
|
+
this._resolver = resolver;
|
|
283
|
+
if (resolver && resolver.setNode) {
|
|
284
|
+
resolver.setNode(this);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Reset the resolver and clear the node value.
|
|
290
|
+
*/
|
|
291
|
+
resetResolver() {
|
|
292
|
+
if (!this._resolver) {
|
|
293
|
+
throw new Error('Cannot reset resolver: node has no resolver');
|
|
294
|
+
}
|
|
295
|
+
this._resolver.reset();
|
|
296
|
+
this.setValue(null);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// -------------------------------------------------------------------------
|
|
300
|
+
// Attribute Methods
|
|
301
|
+
// -------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
get attr() {
|
|
304
|
+
return this._attr;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Get attribute value or all attributes.
|
|
309
|
+
*
|
|
310
|
+
* @param {string} [label=null] - The attribute's label. If null, returns all attributes.
|
|
311
|
+
* @param {*} [defaultValue=null] - Default value if attribute not found.
|
|
312
|
+
* @returns {*} Attribute value, default, or dict of all attributes.
|
|
313
|
+
*/
|
|
314
|
+
getAttr(label = null, defaultValue = null) {
|
|
315
|
+
if (!label) {
|
|
316
|
+
return this._attr;
|
|
317
|
+
}
|
|
318
|
+
return label in this._attr ? this._attr[label] : defaultValue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Compute the symmetric diff between two attribute snapshots.
|
|
323
|
+
*
|
|
324
|
+
* Returns a dict mapping each changed key to { old, new }, covering
|
|
325
|
+
* added (old=null), removed (new=null) and modified entries. Keys whose
|
|
326
|
+
* value did not change are omitted.
|
|
327
|
+
*
|
|
328
|
+
* @param {Object} oldattr - Attribute snapshot before the change.
|
|
329
|
+
* @param {Object} newattr - Attribute snapshot after the change.
|
|
330
|
+
* @returns {Object} Diff dict { name: { old, new } }.
|
|
331
|
+
*/
|
|
332
|
+
_buildAttrDiff(oldattr, newattr) {
|
|
333
|
+
const diff = {};
|
|
334
|
+
const keys = new Set([...Object.keys(oldattr), ...Object.keys(newattr)]);
|
|
335
|
+
for (const key of keys) {
|
|
336
|
+
const oldVal = key in oldattr ? oldattr[key] : null;
|
|
337
|
+
const newVal = key in newattr ? newattr[key] : null;
|
|
338
|
+
if (oldVal !== newVal) {
|
|
339
|
+
diff[key] = { old: oldVal, new: newVal };
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
return diff;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Set attributes on the node.
|
|
347
|
+
*
|
|
348
|
+
* @param {Object} [attr=null] - Dictionary of attributes to set.
|
|
349
|
+
* @param {boolean} [trigger=true] - If true, notify subscribers of the change.
|
|
350
|
+
* @param {boolean} [updattr=true] - If false, clear existing attributes first.
|
|
351
|
+
* @param {boolean} [removeNullAttributes=true] - If true, remove null values from attributes.
|
|
352
|
+
*/
|
|
353
|
+
setAttr(attr = null, trigger = true, updattr = true, removeNullAttributes = true) {
|
|
354
|
+
const newAttr = attr || {};
|
|
355
|
+
|
|
356
|
+
// Save old state BEFORE any modification (only if needed for subscribers
|
|
357
|
+
// or for the parent's backref propagation)
|
|
358
|
+
const hasNodeSubscribers = Object.keys(this._nodeSubscribers).length > 0;
|
|
359
|
+
const needDiff = trigger && (hasNodeSubscribers ||
|
|
360
|
+
(this._parentBag !== null && this._parentBag.backref));
|
|
361
|
+
const oldattr = needDiff ? { ...this._attr } : null;
|
|
362
|
+
|
|
363
|
+
if (updattr) {
|
|
364
|
+
Object.assign(this._attr, newAttr);
|
|
365
|
+
} else {
|
|
366
|
+
this._attr = { ...newAttr };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (removeNullAttributes) {
|
|
370
|
+
for (const key of Object.keys(this._attr)) {
|
|
371
|
+
if (this._attr[key] === null) {
|
|
372
|
+
delete this._attr[key];
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (trigger && oldattr !== null) {
|
|
378
|
+
const diff = this._buildAttrDiff(oldattr, this._attr);
|
|
379
|
+
|
|
380
|
+
if (Object.keys(diff).length > 0 && hasNodeSubscribers) {
|
|
381
|
+
for (const subscriber of Object.values(this._nodeSubscribers)) {
|
|
382
|
+
subscriber({ node: this, info: { attrs_diff: diff }, evt: 'upd_attrs' });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
if (Object.keys(diff).length > 0 &&
|
|
387
|
+
this._parentBag !== null && this._parentBag.backref) {
|
|
388
|
+
const reason = trigger === true ? 'true' : String(trigger);
|
|
389
|
+
this._parentBag._onNodeChanged(
|
|
390
|
+
this, [this.label], 'upd_attrs', null, diff, reason
|
|
391
|
+
);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Delete attributes from the node.
|
|
398
|
+
*
|
|
399
|
+
* @param {...string} attrsToDelete - Attribute labels to remove.
|
|
400
|
+
* Each can be a single label or a comma-separated string.
|
|
401
|
+
*/
|
|
402
|
+
delAttr(...attrsToDelete) {
|
|
403
|
+
for (const attr of attrsToDelete) {
|
|
404
|
+
if (typeof attr === 'string' && attr.includes(',')) {
|
|
405
|
+
for (const a of attr.split(',')) {
|
|
406
|
+
delete this._attr[a.trim()];
|
|
407
|
+
}
|
|
408
|
+
} else {
|
|
409
|
+
delete this._attr[attr];
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Check if a node has the given attribute.
|
|
416
|
+
*
|
|
417
|
+
* @param {string} label - Attribute label to check.
|
|
418
|
+
* @param {*} [value=null] - If provided, also check if attribute has this value.
|
|
419
|
+
* @returns {boolean} True if attribute exists (and matches value if provided).
|
|
420
|
+
*/
|
|
421
|
+
hasAttr(label, value = null) {
|
|
422
|
+
if (!(label in this._attr)) {
|
|
423
|
+
return false;
|
|
424
|
+
}
|
|
425
|
+
if (value !== null) {
|
|
426
|
+
return this._attr[label] === value;
|
|
427
|
+
}
|
|
428
|
+
return true;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
/**
|
|
432
|
+
* Get attributes inherited from ancestors.
|
|
433
|
+
*
|
|
434
|
+
* @returns {Object} Dict with all inherited attributes merged with this node's attributes.
|
|
435
|
+
*/
|
|
436
|
+
getInheritedAttributes() {
|
|
437
|
+
let inherited = {};
|
|
438
|
+
if (this._parentBag && this._parentBag.parentNode) {
|
|
439
|
+
inherited = this._parentBag.parentNode.getInheritedAttributes();
|
|
440
|
+
}
|
|
441
|
+
return { ...inherited, ...this._attr };
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// -------------------------------------------------------------------------
|
|
445
|
+
// Subscription Methods
|
|
446
|
+
// -------------------------------------------------------------------------
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Subscribe to changes on this specific node.
|
|
450
|
+
*
|
|
451
|
+
* @param {string} subscriberId - Unique identifier for this subscription.
|
|
452
|
+
* @param {Function} callback - Function to call on changes.
|
|
453
|
+
*
|
|
454
|
+
* Callback signature: callback({ node, info, evt })
|
|
455
|
+
* - node: This BagNode
|
|
456
|
+
* - info: an object with semantic keys:
|
|
457
|
+
* - 'upd_value' → { oldvalue }
|
|
458
|
+
* - 'upd_attrs' → { attrs_diff } (diff dict { name: { old, new } })
|
|
459
|
+
* - 'upd_value_attr' → { oldvalue, attrs_diff }
|
|
460
|
+
* - evt: Event type ('upd_value', 'upd_attrs' or 'upd_value_attr')
|
|
461
|
+
*/
|
|
462
|
+
subscribe(subscriberId, callback) {
|
|
463
|
+
this._nodeSubscribers[subscriberId] = callback;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Unsubscribe from changes on this node.
|
|
468
|
+
*
|
|
469
|
+
* @param {string} subscriberId - The subscription identifier to remove.
|
|
470
|
+
*/
|
|
471
|
+
unsubscribe(subscriberId) {
|
|
472
|
+
delete this._nodeSubscribers[subscriberId];
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// -------------------------------------------------------------------------
|
|
476
|
+
// Compilation Properties
|
|
477
|
+
// -------------------------------------------------------------------------
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Lazy-initialized compiled data storage.
|
|
481
|
+
* External systems (compilers) store compiled data here.
|
|
482
|
+
*
|
|
483
|
+
* @returns {Object} The compiled data dictionary.
|
|
484
|
+
*/
|
|
485
|
+
get compiled() {
|
|
486
|
+
if (!this._compiled) {
|
|
487
|
+
this._compiled = {};
|
|
488
|
+
}
|
|
489
|
+
return this._compiled;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* Check if this node's value is a Bag (branch node).
|
|
494
|
+
* Uses duck typing via _htraverse to avoid circular import.
|
|
495
|
+
*
|
|
496
|
+
* @returns {boolean} True if value is a Bag.
|
|
497
|
+
*/
|
|
498
|
+
get isBranch() {
|
|
499
|
+
return this._value != null && typeof this._value._htraverse === 'function';
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// -------------------------------------------------------------------------
|
|
503
|
+
// Navigation Properties
|
|
504
|
+
// -------------------------------------------------------------------------
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Get this node's index position within parent Bag.
|
|
508
|
+
*
|
|
509
|
+
* @returns {number|null} The 0-based index of this node in the parent's node list,
|
|
510
|
+
* or null if this node has no parent.
|
|
511
|
+
*/
|
|
512
|
+
get position() {
|
|
513
|
+
if (this._parentBag === null) {
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
return this._parentBag._nodes.index(this.label);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Get dot-separated path from root to this node.
|
|
521
|
+
*
|
|
522
|
+
* @returns {string|null} Full path or null if no parent.
|
|
523
|
+
*/
|
|
524
|
+
get fullpath() {
|
|
525
|
+
if (this._parentBag !== null) {
|
|
526
|
+
const parentFullpath = this._parentBag.fullpath;
|
|
527
|
+
return parentFullpath ? `${parentFullpath}.${this.label}` : this.label;
|
|
528
|
+
}
|
|
529
|
+
return null;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Get the node that contains this node's parent Bag.
|
|
534
|
+
*
|
|
535
|
+
* In the hierarchy: grandparent_bag contains parent_node, whose value
|
|
536
|
+
* is parent_bag, which contains this node.
|
|
537
|
+
*
|
|
538
|
+
* @returns {BagNode|null} The parent node or null.
|
|
539
|
+
*/
|
|
540
|
+
get parentNode() {
|
|
541
|
+
if (this._parentBag) {
|
|
542
|
+
return this._parentBag.parentNode;
|
|
543
|
+
}
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* Find the ancestor node that owns a given attribute.
|
|
549
|
+
*
|
|
550
|
+
* @param {string} attrname - Attribute name to search for.
|
|
551
|
+
* @param {*} [attrvalue=null] - If provided, also match this value.
|
|
552
|
+
* @returns {BagNode|null} The node that owns the attribute, or null.
|
|
553
|
+
*/
|
|
554
|
+
attributeOwnerNode(attrname, attrvalue = null) {
|
|
555
|
+
let curr = this;
|
|
556
|
+
if (attrvalue === null) {
|
|
557
|
+
while (curr && !(attrname in curr._attr)) {
|
|
558
|
+
curr = curr.parentNode;
|
|
559
|
+
}
|
|
560
|
+
} else {
|
|
561
|
+
while (curr && curr._attr[attrname] !== attrvalue) {
|
|
562
|
+
curr = curr.parentNode;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return curr;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Return node data as a tuple (array).
|
|
570
|
+
*
|
|
571
|
+
* @returns {Array} Array of [label, value, attr, resolver].
|
|
572
|
+
*/
|
|
573
|
+
asTuple() {
|
|
574
|
+
return [this.label, this.value, this._attr, this._resolver || null];
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// -------------------------------------------------------------------------
|
|
578
|
+
// Comparison
|
|
579
|
+
// -------------------------------------------------------------------------
|
|
580
|
+
|
|
581
|
+
/**
|
|
582
|
+
* Check equality with another BagNode.
|
|
583
|
+
*
|
|
584
|
+
* @param {BagNode} other - Node to compare with.
|
|
585
|
+
* @returns {boolean} True if label, attr and value match.
|
|
586
|
+
*/
|
|
587
|
+
isEqual(other) {
|
|
588
|
+
if (!(other instanceof BagNode)) {
|
|
589
|
+
return false;
|
|
590
|
+
}
|
|
591
|
+
if (this.label !== other.label) {
|
|
592
|
+
return false;
|
|
593
|
+
}
|
|
594
|
+
// Compare attributes
|
|
595
|
+
const thisKeys = Object.keys(this._attr);
|
|
596
|
+
const otherKeys = Object.keys(other._attr);
|
|
597
|
+
if (thisKeys.length !== otherKeys.length) {
|
|
598
|
+
return false;
|
|
599
|
+
}
|
|
600
|
+
for (const key of thisKeys) {
|
|
601
|
+
if (this._attr[key] !== other._attr[key]) {
|
|
602
|
+
return false;
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
// If has resolver, compare resolver identity
|
|
606
|
+
if (this._resolver !== null) {
|
|
607
|
+
return this._resolver === other._resolver;
|
|
608
|
+
}
|
|
609
|
+
// Prefer content equality for nested Bags, including framework subclasses
|
|
610
|
+
// whose compatibility isEqual method deliberately compares identity.
|
|
611
|
+
if (this._value && typeof this._value.equalTo === 'function') {
|
|
612
|
+
return this._value.equalTo(other._value);
|
|
613
|
+
}
|
|
614
|
+
// Preserve existing comparison hooks for other value types.
|
|
615
|
+
if (this._value && typeof this._value.isEqual === 'function') {
|
|
616
|
+
return this._value.isEqual(other._value);
|
|
617
|
+
}
|
|
618
|
+
return this._value === other._value;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* Compare this node with another and return differences.
|
|
623
|
+
*
|
|
624
|
+
* @param {BagNode} other - Another BagNode to compare with.
|
|
625
|
+
* @returns {string|null} Description of differences, or null if equal.
|
|
626
|
+
*/
|
|
627
|
+
diff(other) {
|
|
628
|
+
if (this.label !== other.label) {
|
|
629
|
+
return `Other label: ${other.label}`;
|
|
630
|
+
}
|
|
631
|
+
// Compare attributes
|
|
632
|
+
const thisKeys = Object.keys(this._attr);
|
|
633
|
+
const otherKeys = Object.keys(other._attr);
|
|
634
|
+
if (thisKeys.length !== otherKeys.length ||
|
|
635
|
+
thisKeys.some(k => this._attr[k] !== other._attr[k])) {
|
|
636
|
+
return `attributes self:${JSON.stringify(this._attr)} --- other:${JSON.stringify(other._attr)}`;
|
|
637
|
+
}
|
|
638
|
+
if (this._value !== other._value) {
|
|
639
|
+
return `value self:${this._value} --- other:${other._value}`;
|
|
640
|
+
}
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
* Convert node to JSON-serializable dict.
|
|
646
|
+
*
|
|
647
|
+
* @param {boolean} [typed=true] - If true, include type information.
|
|
648
|
+
* @returns {Object} Dict with keys 'label', 'value', and 'attr'.
|
|
649
|
+
*/
|
|
650
|
+
toJson(typed = true) {
|
|
651
|
+
let value = this.value;
|
|
652
|
+
if (value && typeof value.toJson === 'function') {
|
|
653
|
+
value = value.toJson(typed, true);
|
|
654
|
+
}
|
|
655
|
+
return { label: this.label, value: value, attr: this._attr };
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// -------------------------------------------------------------------------
|
|
659
|
+
// String representation
|
|
660
|
+
// -------------------------------------------------------------------------
|
|
661
|
+
|
|
662
|
+
toString() {
|
|
663
|
+
return `BagNode : ${this.label}`;
|
|
664
|
+
}
|
|
665
|
+
}
|