@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
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
// Copyright 2025 Softwell S.r.l. - SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
import { BagNode } from './bag-node.js';
|
|
4
|
+
import { fromTytx } from '@genrojs/tytx';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* BagNodeContainer - Ordered container for BagNodes with positional insert.
|
|
8
|
+
*
|
|
9
|
+
* Combines dict-like access with list-like ordering. Elements can be
|
|
10
|
+
* accessed by label, numeric index, or '#n' string index.
|
|
11
|
+
*/
|
|
12
|
+
export class BagNodeContainer {
|
|
13
|
+
constructor() {
|
|
14
|
+
this._dict = Object.create(null); // maps label -> BagNode
|
|
15
|
+
this._list = []; // BagNodes in order
|
|
16
|
+
this._parentBag = null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Return the index of a label in this container.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} label - The label or special syntax to look up.
|
|
23
|
+
* - 'label': exact label match
|
|
24
|
+
* - '#n': numeric index (e.g., '#0', '#1')
|
|
25
|
+
* - '#attr=value': find by attribute value (e.g., '#id=34')
|
|
26
|
+
* - '#=value': find by node value (e.g., '#=target')
|
|
27
|
+
* @returns {number} Index position (0-based), or -1 if not found.
|
|
28
|
+
*/
|
|
29
|
+
index(label) {
|
|
30
|
+
if (label in this._dict) {
|
|
31
|
+
return this._list.findIndex(node => node.label === label);
|
|
32
|
+
}
|
|
33
|
+
// Handle #n syntax
|
|
34
|
+
let match = label.match(/^#(\d+)$/);
|
|
35
|
+
if (match) {
|
|
36
|
+
const idx = parseInt(match[1], 10);
|
|
37
|
+
return idx < this._list.length ? idx : -1;
|
|
38
|
+
}
|
|
39
|
+
// Handle #attr=value or #=value syntax
|
|
40
|
+
match = label.match(/^#(\w*)=(.*)$/);
|
|
41
|
+
if (match) {
|
|
42
|
+
const [, attr, rawValue] = match;
|
|
43
|
+
const value = rawValue.includes('::') ? fromTytx(rawValue) : rawValue;
|
|
44
|
+
if (attr) {
|
|
45
|
+
// #attr=value - find by attribute
|
|
46
|
+
return this._list.findIndex(node => node.getAttr(attr) === value);
|
|
47
|
+
} else {
|
|
48
|
+
// #=value - find by node value
|
|
49
|
+
return this._list.findIndex(node => node._value === value);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return -1;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Parse position syntax and return insertion index.
|
|
57
|
+
*
|
|
58
|
+
* Supported formats:
|
|
59
|
+
* - null or '>': append at end
|
|
60
|
+
* - '<': insert at beginning
|
|
61
|
+
* - int n: insert at index n. Negative values count from the end
|
|
62
|
+
* (Python-style: -1 = before last). Out-of-range values clamp to [0, len].
|
|
63
|
+
* - '#n': insert at non-negative index n (clamped to len)
|
|
64
|
+
* - '<label': insert before node with given label
|
|
65
|
+
* - '>label': insert after node with given label
|
|
66
|
+
* - '<#n' / '>#n': insert before/after non-negative index n
|
|
67
|
+
*
|
|
68
|
+
* Fails fast on malformed input instead of silently appending, so a bad
|
|
69
|
+
* position is reported to the caller (aligns with Python _parse_position).
|
|
70
|
+
*
|
|
71
|
+
* @param {string|number|null} position - Position specification.
|
|
72
|
+
* @returns {number} Index where to insert (always valid for splice).
|
|
73
|
+
* @throws {Error} If the string position is malformed (e.g. '#abc', '#-1',
|
|
74
|
+
* '@foo') or references a non-existent label (e.g. '<missing').
|
|
75
|
+
*/
|
|
76
|
+
_parsePosition(position) {
|
|
77
|
+
const n = this._list.length;
|
|
78
|
+
|
|
79
|
+
if (position === null || position === undefined || position === '>') {
|
|
80
|
+
return n;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (typeof position === 'number') {
|
|
84
|
+
if (position < 0) {
|
|
85
|
+
position = n + position;
|
|
86
|
+
}
|
|
87
|
+
return Math.max(0, Math.min(position, n));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (position === '<') {
|
|
91
|
+
return 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (position.startsWith('#')) {
|
|
95
|
+
const idx = this._parseSharpIndex(position.slice(1), position);
|
|
96
|
+
return Math.min(idx, n);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (position.startsWith('<')) {
|
|
100
|
+
const ref = position.slice(1);
|
|
101
|
+
if (ref.startsWith('#')) {
|
|
102
|
+
const idx = this._parseSharpIndex(ref.slice(1), position);
|
|
103
|
+
return Math.min(idx, n);
|
|
104
|
+
}
|
|
105
|
+
const labelIdx = this.index(ref);
|
|
106
|
+
if (labelIdx < 0) {
|
|
107
|
+
throw new Error(
|
|
108
|
+
`Invalid node_position '${position}': label '${ref}' not found`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
return labelIdx;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (position.startsWith('>')) {
|
|
115
|
+
const ref = position.slice(1);
|
|
116
|
+
if (ref.startsWith('#')) {
|
|
117
|
+
const idx = this._parseSharpIndex(ref.slice(1), position);
|
|
118
|
+
return Math.min(idx + 1, n);
|
|
119
|
+
}
|
|
120
|
+
const labelIdx = this.index(ref);
|
|
121
|
+
if (labelIdx < 0) {
|
|
122
|
+
throw new Error(
|
|
123
|
+
`Invalid node_position '${position}': label '${ref}' not found`
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
return labelIdx + 1;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
throw new Error(`Invalid node_position '${position}': unrecognized syntax`);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Parse a non-negative integer from '#n' syntax.
|
|
134
|
+
*
|
|
135
|
+
* @param {string} raw - The part after '#' (e.g. '3' from '#3').
|
|
136
|
+
* @param {string} original - Full position string for error messages.
|
|
137
|
+
* @returns {number} The parsed non-negative integer.
|
|
138
|
+
* @throws {Error} If raw is not a non-negative integer.
|
|
139
|
+
*/
|
|
140
|
+
_parseSharpIndex(raw, original) {
|
|
141
|
+
if (!/^-?\d+$/.test(raw)) {
|
|
142
|
+
throw new Error(
|
|
143
|
+
`Invalid node_position '${original}': '${raw}' is not an integer`
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
const idx = parseInt(raw, 10);
|
|
147
|
+
if (idx < 0) {
|
|
148
|
+
throw new Error(
|
|
149
|
+
`Invalid node_position '${original}': negative index not allowed in '#n' syntax`
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return idx;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Get item by label or index.
|
|
157
|
+
*
|
|
158
|
+
* @param {string|number} key - Label string or integer index.
|
|
159
|
+
* @returns {BagNode|null} The BagNode if found, null otherwise.
|
|
160
|
+
*/
|
|
161
|
+
get(key) {
|
|
162
|
+
if (typeof key === 'number') {
|
|
163
|
+
return (key >= 0 && key < this._list.length) ? this._list[key] : null;
|
|
164
|
+
}
|
|
165
|
+
if (key.startsWith('#')) {
|
|
166
|
+
const idx = this.index(key);
|
|
167
|
+
return idx >= 0 ? this._list[idx] : null;
|
|
168
|
+
}
|
|
169
|
+
return this._dict[key] || null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Set or create a BagNode with optional position.
|
|
174
|
+
*
|
|
175
|
+
* Supports ?attr syntax to set attributes instead of value (always
|
|
176
|
+
* merged with existing attributes, regardless of `updattr`):
|
|
177
|
+
* - 'label?myattr' → sets attribute 'myattr' to value
|
|
178
|
+
* - 'label?x&y&z' → sets attributes from tuple (value must be array with matching length)
|
|
179
|
+
*
|
|
180
|
+
* @param {string} label - The node label. Can contain ?attr suffix.
|
|
181
|
+
* @param {*} value - The value to set. With ?attr syntax, becomes the attribute value.
|
|
182
|
+
* @param {string|number|null} [nodePosition='>'] - Position specification.
|
|
183
|
+
* @param {Object} [attr=null] - Optional attributes.
|
|
184
|
+
* @param {Object} [parentBag=null] - Parent Bag reference.
|
|
185
|
+
* @param {BagResolver} [resolver=null] - Resolver to attach to node.
|
|
186
|
+
* @param {boolean} [updattr=false] - If false, clear existing attributes first.
|
|
187
|
+
* @param {boolean} [removeNullAttributes=true] - If true, remove null values from attributes.
|
|
188
|
+
* @param {string} [reason=null] - Optional reason string for events.
|
|
189
|
+
* @param {boolean} [doTrigger=true] - If false, suppress events.
|
|
190
|
+
* @param {boolean} [fired=false] - If true, reset value to null after creation.
|
|
191
|
+
* The event of the write carries fired=true; the reset emits no event.
|
|
192
|
+
* @param {string} [nodeTag=null] - Semantic type tag for the node.
|
|
193
|
+
* @returns {BagNode} The created or updated BagNode.
|
|
194
|
+
*/
|
|
195
|
+
set(label, value, nodePosition = '>', attr = null, parentBag = null,
|
|
196
|
+
resolver = null, updattr = false, removeNullAttributes = true,
|
|
197
|
+
reason = null, doTrigger = true, fired = false, nodeTag = null) {
|
|
198
|
+
// Parse query string from label (like Python)
|
|
199
|
+
let queryString = null;
|
|
200
|
+
if (label.includes('?')) {
|
|
201
|
+
[label, queryString] = label.split('?', 2);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Validate label
|
|
205
|
+
if (label === null || label === undefined || label.startsWith('#')) {
|
|
206
|
+
throw new Error('Cannot create new node with #n syntax');
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// Handle query string: convert value to attributes
|
|
210
|
+
if (queryString) {
|
|
211
|
+
const qs = queryString.split('&');
|
|
212
|
+
if (qs.length === 1) {
|
|
213
|
+
attr = { [qs[0]]: value };
|
|
214
|
+
} else {
|
|
215
|
+
if (!Array.isArray(value) || value.length !== qs.length) {
|
|
216
|
+
throw new Error('Wrong attributes assignment');
|
|
217
|
+
}
|
|
218
|
+
attr = {};
|
|
219
|
+
for (let i = 0; i < qs.length; i++) {
|
|
220
|
+
attr[qs[i]] = value[i];
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
value = null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let node = this._dict[label];
|
|
227
|
+
|
|
228
|
+
if (node) {
|
|
229
|
+
// Existing node — update
|
|
230
|
+
if (nodeTag) {
|
|
231
|
+
node.nodeTag = nodeTag;
|
|
232
|
+
}
|
|
233
|
+
if (resolver !== null) {
|
|
234
|
+
if (resolver === false) {
|
|
235
|
+
node.resolver = null;
|
|
236
|
+
} else {
|
|
237
|
+
node.resolver = resolver;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
if (queryString) {
|
|
241
|
+
// Only set_attr, don't touch value. Force merge (updattr=true):
|
|
242
|
+
// the ?attr query syntax is an alias of setAttr for the named
|
|
243
|
+
// attribute(s) and must preserve the other attributes,
|
|
244
|
+
// regardless of the updattr argument (aligns with Python #56).
|
|
245
|
+
node.setAttr(attr, doTrigger, true, removeNullAttributes);
|
|
246
|
+
} else {
|
|
247
|
+
// Update value with all propagated params
|
|
248
|
+
node.setValue(value, doTrigger, attr, updattr, removeNullAttributes, reason, fired);
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
// New node — use parentBag.nodeClass if available
|
|
252
|
+
const NodeClass = (parentBag && parentBag.nodeClass) ? parentBag.nodeClass : BagNode;
|
|
253
|
+
node = new NodeClass(parentBag, label, queryString ? null : value, attr,
|
|
254
|
+
resolver, nodeTag, null, removeNullAttributes);
|
|
255
|
+
const idx = this._parsePosition(nodePosition);
|
|
256
|
+
this._dict[label] = node;
|
|
257
|
+
this._list.splice(idx, 0, node);
|
|
258
|
+
|
|
259
|
+
// Trigger insert event if backref enabled.
|
|
260
|
+
// reason is the 4th argument (3rd is pathlist) — passing it in the
|
|
261
|
+
// wrong slot would corrupt the subscriber's pathlist.
|
|
262
|
+
if (doTrigger && parentBag && parentBag.backref) {
|
|
263
|
+
parentBag._onNodeInserted(node, idx, null, reason, fired);
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// fired pattern: reset value after creation
|
|
268
|
+
if (fired) {
|
|
269
|
+
node.setValue(null, false);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return node;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Remove and return item.
|
|
277
|
+
*
|
|
278
|
+
* @param {string|number} key - Label, index, or '#n'.
|
|
279
|
+
* @returns {BagNode|null} The removed BagNode, or null if not found.
|
|
280
|
+
*/
|
|
281
|
+
pop(key) {
|
|
282
|
+
const node = this.get(key);
|
|
283
|
+
if (node) {
|
|
284
|
+
delete this._dict[node.label];
|
|
285
|
+
const idx = this._list.indexOf(node);
|
|
286
|
+
if (idx >= 0) {
|
|
287
|
+
this._list.splice(idx, 1);
|
|
288
|
+
}
|
|
289
|
+
return node;
|
|
290
|
+
}
|
|
291
|
+
return null;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Check if label exists.
|
|
296
|
+
*
|
|
297
|
+
* @param {string} key - Label to check.
|
|
298
|
+
* @returns {boolean} True if label exists.
|
|
299
|
+
*/
|
|
300
|
+
has(key) {
|
|
301
|
+
return key in this._dict;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Return number of elements.
|
|
306
|
+
*
|
|
307
|
+
* @returns {number} Number of elements.
|
|
308
|
+
*/
|
|
309
|
+
get length() {
|
|
310
|
+
return this._list.length;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Clear all elements.
|
|
315
|
+
*/
|
|
316
|
+
clear() {
|
|
317
|
+
this._dict = Object.create(null);
|
|
318
|
+
this._list = [];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Return node labels in order.
|
|
323
|
+
*
|
|
324
|
+
* @returns {string[]} Array of labels.
|
|
325
|
+
*/
|
|
326
|
+
keys() {
|
|
327
|
+
return this._list.map(node => node.label);
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Return node values in order.
|
|
332
|
+
*
|
|
333
|
+
* @returns {Array} Array of values.
|
|
334
|
+
*/
|
|
335
|
+
values() {
|
|
336
|
+
return this._list.map(node => node.getValue());
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Return [label, value] tuples in order.
|
|
341
|
+
*
|
|
342
|
+
* @returns {Array} Array of [label, value] tuples.
|
|
343
|
+
*/
|
|
344
|
+
items() {
|
|
345
|
+
return this._list.map(node => [node.label, node.getValue()]);
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Make container iterable.
|
|
350
|
+
*/
|
|
351
|
+
[Symbol.iterator]() {
|
|
352
|
+
return this._list[Symbol.iterator]();
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Move element(s) to a new position.
|
|
357
|
+
*
|
|
358
|
+
* @param {number|number[]} what - Index or list of indices to move.
|
|
359
|
+
* @param {number} position - Target index position.
|
|
360
|
+
* @param {boolean} [trigger=true] - If true, fire del/ins events.
|
|
361
|
+
*/
|
|
362
|
+
move(what, position, trigger = true) {
|
|
363
|
+
if (position < 0) {
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Normalize to array
|
|
368
|
+
const indices = Array.isArray(what) ? what : [what];
|
|
369
|
+
if (indices.length === 0) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Get destination label BEFORE any removal
|
|
374
|
+
if (position >= this._list.length) {
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
const destLabel = this._list[position].label;
|
|
378
|
+
|
|
379
|
+
if (indices.length > 1) {
|
|
380
|
+
// Multi-node move
|
|
381
|
+
const sortedIndices = [...indices].sort((a, b) => a - b);
|
|
382
|
+
const delta = sortedIndices[0] < position ? 1 : 0;
|
|
383
|
+
|
|
384
|
+
// Pop nodes in reverse order (highest index first)
|
|
385
|
+
const popped = [];
|
|
386
|
+
for (let i = sortedIndices.length - 1; i >= 0; i--) {
|
|
387
|
+
const idx = sortedIndices[i];
|
|
388
|
+
if (idx >= 0 && idx < this._list.length) {
|
|
389
|
+
const node = this._list[idx];
|
|
390
|
+
this._list.splice(idx, 1);
|
|
391
|
+
popped.push(node);
|
|
392
|
+
if (trigger && this._parentBag && this._parentBag.backref) {
|
|
393
|
+
this._parentBag._onNodeDeleted(node, idx);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
// Find new position based on destLabel + delta
|
|
399
|
+
let newPos = this.index(destLabel);
|
|
400
|
+
if (newPos < 0) {
|
|
401
|
+
newPos = this._list.length;
|
|
402
|
+
}
|
|
403
|
+
newPos += delta;
|
|
404
|
+
|
|
405
|
+
// Insert all popped nodes at new position
|
|
406
|
+
for (const node of popped) {
|
|
407
|
+
this._list.splice(newPos, 0, node);
|
|
408
|
+
if (trigger && this._parentBag && this._parentBag.backref) {
|
|
409
|
+
this._parentBag._onNodeInserted(node, newPos);
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
} else {
|
|
413
|
+
// Single node move
|
|
414
|
+
const fromIdx = indices[0];
|
|
415
|
+
if (fromIdx === position) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
if (fromIdx < 0 || fromIdx >= this._list.length) {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const node = this._list[fromIdx];
|
|
423
|
+
this._list.splice(fromIdx, 1);
|
|
424
|
+
|
|
425
|
+
if (trigger && this._parentBag && this._parentBag.backref) {
|
|
426
|
+
this._parentBag._onNodeDeleted(node, fromIdx);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
this._list.splice(position, 0, node);
|
|
430
|
+
|
|
431
|
+
if (trigger && this._parentBag && this._parentBag.backref) {
|
|
432
|
+
this._parentBag._onNodeInserted(node, position);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/**
|
|
438
|
+
* Check equality with another BagNodeContainer.
|
|
439
|
+
*
|
|
440
|
+
* @param {BagNodeContainer} other - Container to compare with.
|
|
441
|
+
* @returns {boolean} True if equal (same nodes in same order).
|
|
442
|
+
*/
|
|
443
|
+
isEqual(other) {
|
|
444
|
+
if (!(other instanceof BagNodeContainer)) {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
447
|
+
if (this._list.length !== other._list.length) {
|
|
448
|
+
return false;
|
|
449
|
+
}
|
|
450
|
+
for (let i = 0; i < this._list.length; i++) {
|
|
451
|
+
const thisNode = this._list[i];
|
|
452
|
+
const otherNode = other._list[i];
|
|
453
|
+
if (!thisNode.isEqual(otherNode)) {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return true;
|
|
458
|
+
}
|
|
459
|
+
}
|