@flowfuse/nr-assistant 0.14.0 → 0.14.1-5d7a461-202605121018.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -19
- package/package.json +1 -1
- package/resources/expertAutomations.js +443 -33
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
# CHANGELOG
|
|
2
|
+
|
|
1
3
|
## [0.14.0](https://github.com/FlowFuse/nr-assistant/compare/v0.13.1...v0.14.0) (2026-05-07)
|
|
2
4
|
|
|
3
5
|
- feat(actions): add automation/open-palette-manager action (#314) @andypalmi
|
|
@@ -10,7 +12,7 @@
|
|
|
10
12
|
- chore(deps): bump hono from 4.12.14 to 4.12.18 (#308) @app/dependabot
|
|
11
13
|
- build(deps): bump protobufjs from 7.5.3 to 7.5.5 (#272) @app/dependabot
|
|
12
14
|
|
|
13
|
-
## 0.13.0
|
|
15
|
+
## [0.13.0](https://github.com/FlowFuse/nr-assistant/compare/v0.12.0...v0.13.0) (2026-05-06)
|
|
14
16
|
|
|
15
17
|
- ci: Use new project-automation workflow (#303)
|
|
16
18
|
- ci: Replace PAT with GitHub Application token in `Projects automations` workflow (#300)
|
|
@@ -53,7 +55,7 @@
|
|
|
53
55
|
- Update lint scripts to include all subdirectories (#255) @Steve-Mcl
|
|
54
56
|
- Use git ref_name for version instead of npm info (#252) @allthedoll
|
|
55
57
|
|
|
56
|
-
## 0.12.0
|
|
58
|
+
## [0.12.0](https://github.com/FlowFuse/nr-assistant/compare/v0.11.0...v0.12.0) (2026-04-08)
|
|
57
59
|
|
|
58
60
|
- Bump actions/create-github-app-token from 2.2.1 to 3.0.0 (#191)
|
|
59
61
|
- Bump actions/setup-node from 6.2.0 to 6.3.0 (#177)
|
|
@@ -85,7 +87,7 @@
|
|
|
85
87
|
- Bump hono from 4.12.3 to 4.12.5 (#166) @app/dependabot
|
|
86
88
|
- Bump minimatch from 3.1.2 to 3.1.5 (#161) @app/dependabot
|
|
87
89
|
|
|
88
|
-
## 0.11.0
|
|
90
|
+
## [0.11.0](https://github.com/FlowFuse/nr-assistant/compare/v0.10.2...v0.11.0) (2026-02-26)
|
|
89
91
|
|
|
90
92
|
- Bump flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml (#149)
|
|
91
93
|
- Bump flowfuse/github-actions-workflows/.github/workflows/build_node_package.yml (#148)
|
|
@@ -98,7 +100,7 @@
|
|
|
98
100
|
- Bump hono from 4.11.7 to 4.12.0 (#152) @app/dependabot
|
|
99
101
|
- Bump qs from 6.14.1 to 6.14.2 (#146) @app/dependabot
|
|
100
102
|
|
|
101
|
-
## 0.10.2
|
|
103
|
+
## [0.10.2](https://github.com/FlowFuse/nr-assistant/compare/v0.10.1...v0.10.2) (2026-02-12)
|
|
102
104
|
|
|
103
105
|
- Bump JS-DevTools/npm-publish from 4.1.4 to 4.1.5 (#136)
|
|
104
106
|
- Bump flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml (#141)
|
|
@@ -107,13 +109,13 @@
|
|
|
107
109
|
- Bump @modelcontextprotocol/sdk from 1.25.3 to 1.26.0 (#138) @app/dependabot
|
|
108
110
|
- Update public catalouge on release (#144) @hardillb
|
|
109
111
|
|
|
110
|
-
## 0.10.1
|
|
112
|
+
## [0.10.1](https://github.com/FlowFuse/nr-assistant/compare/v0.10.0...v0.10.1) (2026-01-30)
|
|
111
113
|
|
|
112
114
|
- Bump hono from 4.11.5 to 4.11.7 (#129) @app/dependabot
|
|
113
115
|
- Improve discoverability of supported features (#131) @Steve-Mcl
|
|
114
116
|
- Replace hard coded event mapping with dynamic registrations (#132) @Steve-Mcl
|
|
115
117
|
|
|
116
|
-
## 0.10.0
|
|
118
|
+
## [0.10.0](https://github.com/FlowFuse/nr-assistant/compare/v0.9.0...v0.10.0) (2026-01-27)
|
|
117
119
|
|
|
118
120
|
- Bump JS-DevTools/npm-publish from 4.1.3 to 4.1.4 (#110)
|
|
119
121
|
- Bump actions/checkout from 6.0.1 to 6.0.2 (#124)
|
|
@@ -122,7 +124,7 @@
|
|
|
122
124
|
- Bump flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml (#121)
|
|
123
125
|
- Add selection handling: `view:selection-changed` notifier and `get-selection` handler (#125) @cstns
|
|
124
126
|
|
|
125
|
-
## 0.9.0
|
|
127
|
+
## [0.9.0](https://github.com/FlowFuse/nr-assistant/compare/v0.8.0...v0.9.0) (2026-01-24)
|
|
126
128
|
|
|
127
129
|
- Update dependencies (#119) @Steve-Mcl
|
|
128
130
|
- Bump actions/setup-node from 6.1.0 to 6.2.0 (#113)
|
|
@@ -131,7 +133,7 @@
|
|
|
131
133
|
- Expose installed packages to flowfuse expert (#114) @cstns
|
|
132
134
|
- ci: Enable SAST (#109) @ppawlowski
|
|
133
135
|
|
|
134
|
-
## 0.8.0
|
|
136
|
+
## [0.8.0](https://github.com/FlowFuse/nr-assistant/compare/v0.7.0...v0.8.0) (2026-01-14)
|
|
135
137
|
|
|
136
138
|
- Bump JS-DevTools/npm-publish from 4.1.1 to 4.1.3 (#105)
|
|
137
139
|
- Bump flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml (#96)
|
|
@@ -143,7 +145,7 @@
|
|
|
143
145
|
- Bump flowfuse/github-actions-workflows/.github/workflows/publish_node_package.yml from 0.44.0 to 0.45.0 (#98) @app/dependabot
|
|
144
146
|
- Bump flowfuse/github-actions-workflows/.github/workflows/build_node_package.yml from 0.44.0 to 0.45.0 (#97) @app/dependabot
|
|
145
147
|
|
|
146
|
-
## 0.7.0
|
|
148
|
+
## [0.7.0](https://github.com/FlowFuse/nr-assistant/compare/v0.6.0...v0.7.0) (2025-12-09)
|
|
147
149
|
|
|
148
150
|
- Allow the assistant to be installed in standalone Node-RED instances (#89) @knolleary
|
|
149
151
|
|
|
@@ -161,33 +163,33 @@
|
|
|
161
163
|
- Bump body-parser from 2.2.0 to 2.2.1 (#88) @app/dependabot
|
|
162
164
|
- Bump js-yaml from 4.1.0 to 4.1.1 (#86) @app/dependabot
|
|
163
165
|
|
|
164
|
-
## 0.6.0
|
|
166
|
+
## [0.6.0](https://github.com/FlowFuse/nr-assistant/compare/v0.5.0...v0.6.0) (2025-09-05)
|
|
165
167
|
- Fix relative script source path (#77) @Steve-Mcl
|
|
166
168
|
- Add inline completions feature (#75) @Steve-Mcl
|
|
167
169
|
|
|
168
|
-
## 0.5.0
|
|
170
|
+
## [0.5.0](https://github.com/FlowFuse/nr-assistant/compare/v0.4.0...v0.5.0) (2025-08-28)
|
|
169
171
|
- Bump actions/checkout from 4.2.2 to 5.0.0 (#69)
|
|
170
172
|
- Bump flowfuse/github-actions-workflows from 0.40.0 to 0.42.0 (#68)
|
|
171
173
|
- Add tables codelens feature (#72) @Steve-Mcl
|
|
172
174
|
|
|
173
|
-
## 0.4.0
|
|
175
|
+
## [0.4.0](https://github.com/FlowFuse/nr-assistant/compare/v0.3.0...v0.4.0) (2025-07-30)
|
|
174
176
|
- update package for 0.4.0 release
|
|
175
177
|
- Bump flowfuse/github-actions-workflows from 0.39.0 to 0.40.0 (#60)
|
|
176
178
|
- Update imports (#64) @Steve-Mcl
|
|
177
179
|
- Implement node suggestions (#62) @Steve-Mcl
|
|
178
180
|
- Add copy to clipboard and generate comment node to explain dialog (#61) @Steve-Mcl
|
|
179
181
|
|
|
180
|
-
## 0.3.0
|
|
182
|
+
## [0.3.0](https://github.com/FlowFuse/nr-assistant/compare/v0.2.1...v0.3.0) (2025-06-27)
|
|
181
183
|
- Change assistant button to menu for exposing new Flows Explainer by @Steve-Mcl in #53
|
|
182
184
|
- Add menu shortcuts for menu items by @Steve-Mcl in #54
|
|
183
185
|
- Show flow explanation in dialog by @Steve-Mcl in #52
|
|
184
186
|
- Add codelens for CSS and DB2 ui-template by @Steve-Mcl in #56
|
|
185
187
|
|
|
186
|
-
## 0.2.1
|
|
188
|
+
## [0.2.1](https://github.com/FlowFuse/nr-assistant/compare/v0.2.0...v0.2.1) (2025-06-12)
|
|
187
189
|
- Improve README with visuals of what it does by @Steve-Mcl in #49
|
|
188
190
|
- V0.2.1 by @Steve-Mcl in #50
|
|
189
191
|
|
|
190
|
-
## 0.2.0
|
|
192
|
+
## [0.2.0](https://github.com/FlowFuse/nr-assistant/compare/v0.1.3...v0.2.0) (2025-06-11)
|
|
191
193
|
|
|
192
194
|
- Bump flowfuse/github-actions-workflows from 0.19.0 to 0.28.0 by @dependabot in #32
|
|
193
195
|
- Bump flowfuse/github-actions-workflows from 0.28.0 to 0.29.0 by @dependabot in #33
|
|
@@ -202,17 +204,17 @@
|
|
|
202
204
|
- Add initial MCP support by @Steve-Mcl in #44
|
|
203
205
|
- V0.2.0 by @Steve-Mcl in #47
|
|
204
206
|
|
|
205
|
-
## 0.1.3
|
|
207
|
+
## [0.1.3](https://github.com/FlowFuse/nr-assistant/compare/v0.1.2...v0.1.3) (2024-07-17)
|
|
206
208
|
|
|
207
209
|
- Fix icon on device agent by @Steve-Mcl in #29
|
|
208
210
|
- bump for 0.1.3 by @Steve-Mcl in #30
|
|
209
211
|
|
|
210
|
-
## 0.1.2
|
|
212
|
+
## [0.1.2](https://github.com/FlowFuse/nr-assistant/compare/v0.1.1...v0.1.2) (2024-07-16)
|
|
211
213
|
|
|
212
214
|
- Fix height of new icon on NR3.x by @Steve-Mcl in #26
|
|
213
215
|
- bump for 0.1.2 by @Steve-Mcl in #27
|
|
214
216
|
|
|
215
|
-
## 0.1.1
|
|
217
|
+
## [0.1.1](https://github.com/FlowFuse/nr-assistant/compare/v0.1.0...v0.1.1) (2024-07-16)
|
|
216
218
|
|
|
217
219
|
- ci: Add build and publish nightly package workflow by @ppawlowski in #7
|
|
218
220
|
- Bump tibdex/github-app-token from 1 to 2 by @dependabot in #11
|
|
@@ -223,7 +225,7 @@
|
|
|
223
225
|
- Add comma to settings.js by @kazuhitoyokoi in #22
|
|
224
226
|
- Improved messaging for error responses by @Steve-Mcl in #24
|
|
225
227
|
|
|
226
|
-
## 0.1.0
|
|
228
|
+
## 0.1.0 (2024-07-03)
|
|
227
229
|
|
|
228
230
|
- add automations by @Steve-Mcl in #1
|
|
229
231
|
- Bump JS-DevTools/npm-publish from 2 to 3 by @dependabot in #5
|
package/package.json
CHANGED
|
@@ -25,6 +25,12 @@ const GET_NODE_TYPES = 'automation/get-node-types'
|
|
|
25
25
|
const GET_PALETTE = 'automation/get-palette'
|
|
26
26
|
const LIST_CONFIG_NODES = 'automation/list-config-nodes'
|
|
27
27
|
const OPEN_PALETTE_MANAGER = 'automation/open-palette-manager'
|
|
28
|
+
const MANAGE_GROUPS = 'automation/manage-groups'
|
|
29
|
+
|
|
30
|
+
const ERROR_CODES = Object.freeze({
|
|
31
|
+
GROUP_OPERATION_REQUIRED: 'GROUP_OPERATION_REQUIRED',
|
|
32
|
+
FORBIDDEN_PROPERTY: 'FORBIDDEN_PROPERTY'
|
|
33
|
+
})
|
|
28
34
|
|
|
29
35
|
/**
|
|
30
36
|
* @typedef {SELECT_NODES
|
|
@@ -50,7 +56,8 @@ const OPEN_PALETTE_MANAGER = 'automation/open-palette-manager'
|
|
|
50
56
|
* |GET_NODE_TYPES
|
|
51
57
|
* |GET_PALETTE
|
|
52
58
|
* |LIST_CONFIG_NODES
|
|
53
|
-
* |OPEN_PALETTE_MANAGER
|
|
59
|
+
* |OPEN_PALETTE_MANAGER
|
|
60
|
+
* |MANAGE_GROUPS} ExpertAutomationsActionsEnum
|
|
54
61
|
*/
|
|
55
62
|
|
|
56
63
|
export class ExpertAutomations extends ExpertActionsInterface {
|
|
@@ -187,6 +194,10 @@ export class ExpertAutomations extends ExpertActionsInterface {
|
|
|
187
194
|
tabId: {
|
|
188
195
|
type: 'string',
|
|
189
196
|
description: 'Optional parameter to only get nodes on a specific workspace. Exclude this parameter to get node from all workspaces.'
|
|
197
|
+
},
|
|
198
|
+
full: {
|
|
199
|
+
type: 'boolean',
|
|
200
|
+
description: 'When true, returns the raw node objects instead of condensed summaries.'
|
|
190
201
|
}
|
|
191
202
|
}
|
|
192
203
|
}
|
|
@@ -268,6 +279,10 @@ export class ExpertAutomations extends ExpertActionsInterface {
|
|
|
268
279
|
type: 'array',
|
|
269
280
|
items: { type: 'string' },
|
|
270
281
|
description: 'IDs of nodes to remove from the canvas'
|
|
282
|
+
},
|
|
283
|
+
reconnectWires: {
|
|
284
|
+
type: 'boolean',
|
|
285
|
+
description: 'If true, reconnects wires around removed nodes (pass-through). Default: false'
|
|
271
286
|
}
|
|
272
287
|
},
|
|
273
288
|
required: ['ids']
|
|
@@ -377,6 +392,61 @@ export class ExpertAutomations extends ExpertActionsInterface {
|
|
|
377
392
|
}
|
|
378
393
|
}
|
|
379
394
|
}
|
|
395
|
+
},
|
|
396
|
+
[MANAGE_GROUPS]: {
|
|
397
|
+
params: {
|
|
398
|
+
type: 'object',
|
|
399
|
+
properties: {
|
|
400
|
+
operations: {
|
|
401
|
+
type: 'array',
|
|
402
|
+
items: {
|
|
403
|
+
type: 'object',
|
|
404
|
+
properties: {
|
|
405
|
+
op: {
|
|
406
|
+
type: 'string',
|
|
407
|
+
enum: ['create', 'update', 'manage-members', 'delete'],
|
|
408
|
+
description: 'Operation to perform'
|
|
409
|
+
},
|
|
410
|
+
id: {
|
|
411
|
+
type: 'string',
|
|
412
|
+
description: 'Group ID (required for update, manage-members, and delete)'
|
|
413
|
+
},
|
|
414
|
+
nodeIds: {
|
|
415
|
+
type: 'array',
|
|
416
|
+
items: { type: 'string' },
|
|
417
|
+
description: 'Node IDs: required for create and manage-members'
|
|
418
|
+
},
|
|
419
|
+
name: {
|
|
420
|
+
type: 'string',
|
|
421
|
+
description: 'Group display name (optional for create and update)'
|
|
422
|
+
},
|
|
423
|
+
mode: {
|
|
424
|
+
type: 'string',
|
|
425
|
+
enum: ['add', 'remove'],
|
|
426
|
+
description: 'manage-members only — "add" moves nodes into the group, "remove" takes them out'
|
|
427
|
+
},
|
|
428
|
+
style: {
|
|
429
|
+
type: 'object',
|
|
430
|
+
description: 'Visual style: stroke/border-color, fill, color, stroke-opacity, fill-opacity, label, label-position',
|
|
431
|
+
properties: {
|
|
432
|
+
stroke: { type: 'string' },
|
|
433
|
+
'border-color': { type: 'string' },
|
|
434
|
+
fill: { type: 'string' },
|
|
435
|
+
color: { type: 'string' },
|
|
436
|
+
'stroke-opacity': { type: 'number' },
|
|
437
|
+
'fill-opacity': { type: 'number' },
|
|
438
|
+
label: { type: 'boolean' },
|
|
439
|
+
'label-position': { type: 'string', enum: ['nw', 'n', 'ne', 'sw', 's', 'se'] }
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
},
|
|
443
|
+
required: ['op']
|
|
444
|
+
},
|
|
445
|
+
description: 'Array of group operations to execute sequentially'
|
|
446
|
+
}
|
|
447
|
+
},
|
|
448
|
+
required: ['operations']
|
|
449
|
+
}
|
|
380
450
|
}
|
|
381
451
|
})
|
|
382
452
|
|
|
@@ -821,7 +891,7 @@ export class ExpertAutomations extends ExpertActionsInterface {
|
|
|
821
891
|
* @param {string} id - workspace ID to show
|
|
822
892
|
*/
|
|
823
893
|
showWorkspace (id) {
|
|
824
|
-
|
|
894
|
+
this._assertWorkspaceExists(id)
|
|
825
895
|
this.RED.workspaces.show(id)
|
|
826
896
|
}
|
|
827
897
|
|
|
@@ -831,9 +901,24 @@ export class ExpertAutomations extends ExpertActionsInterface {
|
|
|
831
901
|
* @returns {boolean} true if the workspace exists, false otherwise
|
|
832
902
|
*/
|
|
833
903
|
hasWorkspace (id) {
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
904
|
+
return !!this.RED.nodes.workspace(id)
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
/**
|
|
908
|
+
* Throw if the workspace does not exist.
|
|
909
|
+
* @param {string} id - workspace ID
|
|
910
|
+
*/
|
|
911
|
+
_assertWorkspaceExists (id) {
|
|
912
|
+
if (!this.hasWorkspace(id)) throw new Error(`Workspace ${id} not found`)
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
/**
|
|
916
|
+
* Throw if the workspace tab is locked. No-op when tabId is falsy (e.g. config nodes).
|
|
917
|
+
* @param {string|null|undefined} tabId - workspace tab ID
|
|
918
|
+
*/
|
|
919
|
+
_assertWorkspaceNotLocked (tabId) {
|
|
920
|
+
if (!tabId) return
|
|
921
|
+
if (this.RED.workspaces.isLocked(tabId)) throw new Error(`Workspace ${tabId} is locked`)
|
|
837
922
|
}
|
|
838
923
|
|
|
839
924
|
closeSearch () { this.RED.search.hide() }
|
|
@@ -918,9 +1003,8 @@ export class ExpertAutomations extends ExpertActionsInterface {
|
|
|
918
1003
|
// Validate all target tabs exist and are not locked
|
|
919
1004
|
const uniqueZs = [...new Set(prepared.map(n => n.z).filter(Boolean))]
|
|
920
1005
|
for (const z of uniqueZs) {
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
if (ws.locked) throw new Error(`Workspace tab ${z} is locked`)
|
|
1006
|
+
this._assertWorkspaceExists(z)
|
|
1007
|
+
this._assertWorkspaceNotLocked(z)
|
|
924
1008
|
}
|
|
925
1009
|
// Pre-import: reject if any node ID already exists on the canvas
|
|
926
1010
|
if (!generateIds) {
|
|
@@ -953,9 +1037,13 @@ export class ExpertAutomations extends ExpertActionsInterface {
|
|
|
953
1037
|
|
|
954
1038
|
/**
|
|
955
1039
|
* Remove one or more nodes from the live NR4 canvas by ID.
|
|
1040
|
+
* Delegates to Node-RED's core:delete-selection action to ensure all internal data
|
|
1041
|
+
* structures (including group membership arrays) are properly cleaned up.
|
|
956
1042
|
* @param {string[]} ids - node IDs to remove
|
|
1043
|
+
* @param {object} [options]
|
|
1044
|
+
* @param {boolean} [options.reconnectWires=false] - reconnect wires around removed nodes
|
|
957
1045
|
*/
|
|
958
|
-
removeNodes (ids) {
|
|
1046
|
+
removeNodes (ids, { reconnectWires = false } = {}) {
|
|
959
1047
|
// Resolve all nodes once and check for missing
|
|
960
1048
|
const nodes = ids.map(id => {
|
|
961
1049
|
const node = this.RED.nodes.node(id)
|
|
@@ -964,19 +1052,15 @@ export class ExpertAutomations extends ExpertActionsInterface {
|
|
|
964
1052
|
})
|
|
965
1053
|
// Check if any node's workspace is locked
|
|
966
1054
|
for (const node of nodes) {
|
|
967
|
-
|
|
968
|
-
throw new Error(`Cannot remove node ${node.id} — workspace ${node.z} is locked`)
|
|
969
|
-
}
|
|
1055
|
+
this._assertWorkspaceNotLocked(node.z)
|
|
970
1056
|
}
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
1057
|
+
this.RED.view.select({ nodes })
|
|
1058
|
+
this._verifySelection(nodes)
|
|
1059
|
+
if (reconnectWires) {
|
|
1060
|
+
this.RED.actions.invoke('core:delete-selection-and-reconnect')
|
|
1061
|
+
} else {
|
|
1062
|
+
this.RED.actions.invoke('core:delete-selection')
|
|
975
1063
|
}
|
|
976
|
-
this.RED.history.push({ t: 'delete', nodes, links: allRemovedLinks, dirty: this.RED.nodes.dirty() })
|
|
977
|
-
this.RED.nodes.dirty(true)
|
|
978
|
-
this.RED.view.updateActive()
|
|
979
|
-
this.RED.view.redraw()
|
|
980
1064
|
}
|
|
981
1065
|
|
|
982
1066
|
/**
|
|
@@ -998,9 +1082,7 @@ export class ExpertAutomations extends ExpertActionsInterface {
|
|
|
998
1082
|
throw new Error('Source and target nodes must be on the same tab')
|
|
999
1083
|
}
|
|
1000
1084
|
// Check workspace is not locked
|
|
1001
|
-
|
|
1002
|
-
throw new Error(`Cannot modify wires — workspace ${sourceNode.z} is locked`)
|
|
1003
|
-
}
|
|
1085
|
+
this._assertWorkspaceNotLocked(sourceNode.z)
|
|
1004
1086
|
// Validate output port exists on source
|
|
1005
1087
|
const port = output ?? 0
|
|
1006
1088
|
if (port >= (sourceNode.outputs || 0)) {
|
|
@@ -1069,12 +1151,8 @@ export class ExpertAutomations extends ExpertActionsInterface {
|
|
|
1069
1151
|
throw new Error(`Source node ${source} is a link call in dynamic mode and cannot have static links`)
|
|
1070
1152
|
}
|
|
1071
1153
|
// Check workspace locks for both nodes
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
}
|
|
1075
|
-
if (targetNode.z && targetNode.z !== sourceNode.z && this.RED.workspaces.isLocked(targetNode.z)) {
|
|
1076
|
-
throw new Error(`Cannot modify links — workspace ${targetNode.z} is locked`)
|
|
1077
|
-
}
|
|
1154
|
+
this._assertWorkspaceNotLocked(sourceNode.z)
|
|
1155
|
+
if (targetNode.z !== sourceNode.z) this._assertWorkspaceNotLocked(targetNode.z)
|
|
1078
1156
|
const isBidirectional = sourceNode.type === 'link out'
|
|
1079
1157
|
const sourceLinks = sourceNode.links || []
|
|
1080
1158
|
const targetLinks = targetNode.links || []
|
|
@@ -1197,6 +1275,18 @@ export class ExpertAutomations extends ExpertActionsInterface {
|
|
|
1197
1275
|
break
|
|
1198
1276
|
|
|
1199
1277
|
case UPDATE_NODE: {
|
|
1278
|
+
if (this.RED.nodes.group(params.id)) {
|
|
1279
|
+
result.error = `Node ${params.id} is a group — group nodes cannot be updated via this action`
|
|
1280
|
+
result.errorCode = ERROR_CODES.GROUP_OPERATION_REQUIRED
|
|
1281
|
+
result.success = false
|
|
1282
|
+
break
|
|
1283
|
+
}
|
|
1284
|
+
if (params.properties && 'g' in params.properties) {
|
|
1285
|
+
result.error = `Node ${params.id}: "g" cannot be set directly — group membership must be managed via a dedicated action`
|
|
1286
|
+
result.errorCode = ERROR_CODES.FORBIDDEN_PROPERTY
|
|
1287
|
+
result.success = false
|
|
1288
|
+
break
|
|
1289
|
+
}
|
|
1200
1290
|
await this.updateNode(params.id, params.properties, params.patches)
|
|
1201
1291
|
const updatedNode = this.RED.nodes.node(params.id)
|
|
1202
1292
|
result.data = this._summarizeNode(updatedNode)
|
|
@@ -1216,15 +1306,18 @@ export class ExpertAutomations extends ExpertActionsInterface {
|
|
|
1216
1306
|
case GET_FLOW:
|
|
1217
1307
|
result.flows = this.getFlow()
|
|
1218
1308
|
if (result.flows && Array.isArray(result.flows) && result.flows.length > 0) {
|
|
1219
|
-
if (params.type) {
|
|
1309
|
+
if (params && params.type) {
|
|
1220
1310
|
// filter by type if specified (e.g. "tab", "subflow", or any node type)
|
|
1221
1311
|
result.flows = result.flows.filter(f => f.type === params.type)
|
|
1222
1312
|
}
|
|
1223
|
-
if (params.tabId) {
|
|
1313
|
+
if (params && params.tabId) {
|
|
1224
1314
|
// filter by parent tab ID if specified (for nodes/config nodes)
|
|
1225
1315
|
result.flows = result.flows.filter(f => f.z === params.tabId)
|
|
1226
1316
|
}
|
|
1227
1317
|
}
|
|
1318
|
+
if (!params || !params.full) {
|
|
1319
|
+
result.flows = (result.flows || []).map(f => this._summarizeFlowItem(f))
|
|
1320
|
+
}
|
|
1228
1321
|
result.success = true
|
|
1229
1322
|
break
|
|
1230
1323
|
|
|
@@ -1265,6 +1358,20 @@ export class ExpertAutomations extends ExpertActionsInterface {
|
|
|
1265
1358
|
break
|
|
1266
1359
|
|
|
1267
1360
|
case ADD_NODES: {
|
|
1361
|
+
const groupNodes = (params.nodes || []).filter(n => n.type === 'group')
|
|
1362
|
+
if (groupNodes.length > 0) {
|
|
1363
|
+
result.error = `Nodes [${groupNodes.map(n => n.id).join(', ')}] are group nodes — group nodes cannot be added via this action`
|
|
1364
|
+
result.errorCode = ERROR_CODES.GROUP_OPERATION_REQUIRED
|
|
1365
|
+
result.success = false
|
|
1366
|
+
break
|
|
1367
|
+
}
|
|
1368
|
+
const gNode = (params.nodes || []).find(n => n.g !== undefined)
|
|
1369
|
+
if (gNode) {
|
|
1370
|
+
result.error = `Node ${gNode.id}: "g" cannot be set directly — group membership must be managed via a dedicated action`
|
|
1371
|
+
result.errorCode = ERROR_CODES.FORBIDDEN_PROPERTY
|
|
1372
|
+
result.success = false
|
|
1373
|
+
break
|
|
1374
|
+
}
|
|
1268
1375
|
this.addNodes(params.nodes, { generateIds: params.generateIds ?? false })
|
|
1269
1376
|
const addedNodes = params.nodes.map(n => this.RED.nodes.node(n.id)).filter(Boolean)
|
|
1270
1377
|
if (this.RED.editor?.validateNode) {
|
|
@@ -1276,10 +1383,25 @@ export class ExpertAutomations extends ExpertActionsInterface {
|
|
|
1276
1383
|
}
|
|
1277
1384
|
break
|
|
1278
1385
|
|
|
1279
|
-
case REMOVE_NODES:
|
|
1280
|
-
|
|
1386
|
+
case REMOVE_NODES: {
|
|
1387
|
+
const groupIds = (params.ids || []).filter(id => !!this.RED.nodes.group(id))
|
|
1388
|
+
if (groupIds.length > 0) {
|
|
1389
|
+
result.error = `IDs [${groupIds.join(', ')}] are group nodes — group nodes cannot be removed via this action`
|
|
1390
|
+
result.errorCode = ERROR_CODES.GROUP_OPERATION_REQUIRED
|
|
1391
|
+
result.success = false
|
|
1392
|
+
break
|
|
1393
|
+
}
|
|
1394
|
+
const groupedNodes = (params.ids || []).map(id => this.RED.nodes.node(id)).filter(n => n && n.g)
|
|
1395
|
+
if (groupedNodes.length > 0) {
|
|
1396
|
+
result.error = `Node ${groupedNodes[0].id}: cannot be removed while it is a member of a group — group membership must be managed via a dedicated action first`
|
|
1397
|
+
result.errorCode = ERROR_CODES.FORBIDDEN_PROPERTY
|
|
1398
|
+
result.success = false
|
|
1399
|
+
break
|
|
1400
|
+
}
|
|
1401
|
+
this.removeNodes(params.ids, { reconnectWires: params.reconnectWires ?? false })
|
|
1281
1402
|
result.data = { removed: params.ids }
|
|
1282
1403
|
result.success = true
|
|
1404
|
+
}
|
|
1283
1405
|
break
|
|
1284
1406
|
|
|
1285
1407
|
case SET_WIRES:
|
|
@@ -1362,6 +1484,57 @@ export class ExpertAutomations extends ExpertActionsInterface {
|
|
|
1362
1484
|
})
|
|
1363
1485
|
result.success = true
|
|
1364
1486
|
break
|
|
1487
|
+
case MANAGE_GROUPS: {
|
|
1488
|
+
const operations = params.operations
|
|
1489
|
+
if (!Array.isArray(operations) || operations.length === 0) {
|
|
1490
|
+
throw new Error('operations array is required and must not be empty')
|
|
1491
|
+
}
|
|
1492
|
+
const results = []
|
|
1493
|
+
for (const entry of operations) {
|
|
1494
|
+
const op = entry.op
|
|
1495
|
+
if (!op) throw new Error('op is required for each manage-groups operation')
|
|
1496
|
+
switch (op) {
|
|
1497
|
+
case 'create': {
|
|
1498
|
+
const group = this.createGroup(entry.nodeIds, entry.name, { style: entry.style, env: entry.env, id: entry.id })
|
|
1499
|
+
results.push({ op: 'create', ...this._summarizeGroup(group) })
|
|
1500
|
+
break
|
|
1501
|
+
}
|
|
1502
|
+
case 'update': {
|
|
1503
|
+
if (!entry.id) throw new Error('id is required for update')
|
|
1504
|
+
const group = this.updateGroup(entry.id, {
|
|
1505
|
+
name: entry.name,
|
|
1506
|
+
style: entry.style,
|
|
1507
|
+
env: entry.env
|
|
1508
|
+
})
|
|
1509
|
+
results.push({ op: 'update', ...this._summarizeGroup(group) })
|
|
1510
|
+
break
|
|
1511
|
+
}
|
|
1512
|
+
case 'manage-members': {
|
|
1513
|
+
if (!entry.id) throw new Error('id is required for manage-members')
|
|
1514
|
+
if (!entry.nodeIds || entry.nodeIds.length === 0) throw new Error('nodeIds is required for manage-members')
|
|
1515
|
+
const mode = entry.mode
|
|
1516
|
+
if (mode !== 'add' && mode !== 'remove') throw new Error('mode must be "add" or "remove"')
|
|
1517
|
+
const group = this.updateGroup(entry.id, {
|
|
1518
|
+
nodeIds: entry.nodeIds,
|
|
1519
|
+
remove: mode === 'remove'
|
|
1520
|
+
})
|
|
1521
|
+
results.push({ op: 'manage-members', mode, ...this._summarizeGroup(group) })
|
|
1522
|
+
break
|
|
1523
|
+
}
|
|
1524
|
+
case 'delete': {
|
|
1525
|
+
if (!entry.id) throw new Error('id is required for delete')
|
|
1526
|
+
this.deleteGroup(entry.id)
|
|
1527
|
+
results.push({ op: 'delete', deleted: entry.id })
|
|
1528
|
+
break
|
|
1529
|
+
}
|
|
1530
|
+
default:
|
|
1531
|
+
throw new Error(`Unknown manage-groups op: ${op}`)
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
result.data = results
|
|
1535
|
+
result.success = true
|
|
1536
|
+
break
|
|
1537
|
+
}
|
|
1365
1538
|
default:
|
|
1366
1539
|
result.handled = false
|
|
1367
1540
|
result.success = false
|
|
@@ -1411,6 +1584,185 @@ export class ExpertAutomations extends ExpertActionsInterface {
|
|
|
1411
1584
|
}
|
|
1412
1585
|
}
|
|
1413
1586
|
|
|
1587
|
+
/**
|
|
1588
|
+
* Create a group containing the specified nodes.
|
|
1589
|
+
* Uses Node-RED's core:group-selection action after selecting the target nodes.
|
|
1590
|
+
* @param {string[]} nodeIds - IDs of nodes to group
|
|
1591
|
+
* @param {string} [name] - optional group display name
|
|
1592
|
+
* @param {object} [opts]
|
|
1593
|
+
* @param {object} [opts.style] - visual style overrides
|
|
1594
|
+
* @param {Array} [opts.env] - environment variables for nodes inside the group
|
|
1595
|
+
* @param {string} [opts.id] - explicit group ID; auto-generated if omitted
|
|
1596
|
+
* @returns {object} the created group node
|
|
1597
|
+
*/
|
|
1598
|
+
createGroup (nodeIds, name, { style, env, id } = {}) {
|
|
1599
|
+
if (!nodeIds || nodeIds.length === 0) {
|
|
1600
|
+
throw new Error('nodeIds is required and must not be empty')
|
|
1601
|
+
}
|
|
1602
|
+
const nodes = nodeIds.map(id => {
|
|
1603
|
+
const n = this.RED.nodes.node(id)
|
|
1604
|
+
if (!n) throw new Error(`Node ${id} not found`)
|
|
1605
|
+
return n
|
|
1606
|
+
})
|
|
1607
|
+
const tabs = new Set(nodes.map(n => n.z).filter(Boolean))
|
|
1608
|
+
if (tabs.size > 1) {
|
|
1609
|
+
throw new Error('All nodes must be on the same tab to form a group')
|
|
1610
|
+
}
|
|
1611
|
+
const tabId = tabs.size === 1 ? [...tabs][0] : null
|
|
1612
|
+
this._assertWorkspaceNotLocked(tabId)
|
|
1613
|
+
|
|
1614
|
+
// Snapshot existing group IDs so we can identify the newly created one
|
|
1615
|
+
const existingGroupIds = new Set()
|
|
1616
|
+
this.RED.nodes.eachGroup(g => existingGroupIds.add(g.id))
|
|
1617
|
+
|
|
1618
|
+
this.RED.view.select({ nodes })
|
|
1619
|
+
this._verifySelection(nodes)
|
|
1620
|
+
this.RED.actions.invoke('core:group-selection')
|
|
1621
|
+
|
|
1622
|
+
const newGroups = []
|
|
1623
|
+
this.RED.nodes.eachGroup(g => {
|
|
1624
|
+
if (!existingGroupIds.has(g.id)) newGroups.push(g)
|
|
1625
|
+
})
|
|
1626
|
+
|
|
1627
|
+
if (newGroups.length === 0) {
|
|
1628
|
+
throw new Error('Group creation failed: core:group-selection did not create a new group')
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
const newGroup = newGroups[0]
|
|
1632
|
+
const missingIds = nodeIds.filter(nid => !newGroup.nodes?.some(n => (n.id || n) === nid))
|
|
1633
|
+
if (missingIds.length > 0) {
|
|
1634
|
+
throw new Error(`Group created but missing nodes: ${missingIds.join(', ')}`)
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// Rename to the requested ID if one was provided and differs from the auto-generated one
|
|
1638
|
+
if (id !== undefined && id !== newGroup.id) {
|
|
1639
|
+
if (this.RED.nodes.node(id) || this.RED.nodes.group(id)) {
|
|
1640
|
+
throw new Error(`Group ID ${id} is already in use`)
|
|
1641
|
+
}
|
|
1642
|
+
const oldId = newGroup.id
|
|
1643
|
+
this.RED.nodes.remove(oldId)
|
|
1644
|
+
newGroup.id = id
|
|
1645
|
+
;(newGroup.nodes || []).forEach(n => {
|
|
1646
|
+
const nodeRef = typeof n === 'object' ? n : this.RED.nodes.node(n)
|
|
1647
|
+
if (nodeRef && nodeRef.g === oldId) nodeRef.g = id
|
|
1648
|
+
})
|
|
1649
|
+
this.RED.nodes.add(newGroup)
|
|
1650
|
+
}
|
|
1651
|
+
|
|
1652
|
+
const changes = {}
|
|
1653
|
+
if (name) {
|
|
1654
|
+
changes.name = newGroup.name
|
|
1655
|
+
newGroup.name = name
|
|
1656
|
+
}
|
|
1657
|
+
if (style) {
|
|
1658
|
+
changes.style = { ...newGroup.style }
|
|
1659
|
+
newGroup.style = Object.assign({}, newGroup.style || {}, style)
|
|
1660
|
+
}
|
|
1661
|
+
if (env !== undefined) {
|
|
1662
|
+
changes.env = newGroup.env
|
|
1663
|
+
newGroup.env = env
|
|
1664
|
+
}
|
|
1665
|
+
if (Object.keys(changes).length > 0) {
|
|
1666
|
+
newGroup.changed = true
|
|
1667
|
+
newGroup.dirty = true
|
|
1668
|
+
this.RED.history.push({ t: 'edit', node: newGroup, changes, changed: false, dirty: this.RED.nodes.dirty() })
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
this.RED.nodes.dirty(true)
|
|
1672
|
+
this.RED.view.redraw()
|
|
1673
|
+
return newGroup
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
/**
|
|
1677
|
+
* Update an existing group: rename, add/remove nodes, change style.
|
|
1678
|
+
* @param {string} id - group ID
|
|
1679
|
+
* @param {object} updates - { name?, addNodeIds?, removeNodeIds?, style? }
|
|
1680
|
+
* @returns {object} the updated group node
|
|
1681
|
+
*/
|
|
1682
|
+
updateGroup (id, { name, nodeIds, remove, style, env } = {}) {
|
|
1683
|
+
let group = this.RED.nodes.group(id)
|
|
1684
|
+
if (!group) throw new Error(`Group ${id} not found`)
|
|
1685
|
+
this._assertWorkspaceNotLocked(group.z)
|
|
1686
|
+
|
|
1687
|
+
if (nodeIds && nodeIds.length > 0) {
|
|
1688
|
+
if (remove) {
|
|
1689
|
+
const nodes = nodeIds.map(nodeId => {
|
|
1690
|
+
const node = this.RED.nodes.node(nodeId)
|
|
1691
|
+
if (!node) throw new Error(`Node ${nodeId} not found`)
|
|
1692
|
+
if (node.g !== group.id) throw new Error(`Node ${nodeId} is not in group ${id}`)
|
|
1693
|
+
return node
|
|
1694
|
+
})
|
|
1695
|
+
this.RED.view.select({ nodes })
|
|
1696
|
+
this._verifySelection(nodes)
|
|
1697
|
+
this.RED.actions.invoke('core:remove-selection-from-group')
|
|
1698
|
+
} else {
|
|
1699
|
+
const existingGroupIds = new Set()
|
|
1700
|
+
this.RED.nodes.eachGroup(g => existingGroupIds.add(g.id))
|
|
1701
|
+
|
|
1702
|
+
const newNodes = nodeIds.map(nodeId => {
|
|
1703
|
+
const node = this.RED.nodes.node(nodeId)
|
|
1704
|
+
if (!node) throw new Error(`Node ${nodeId} not found`)
|
|
1705
|
+
if (node.z !== group.z) throw new Error(`Node ${nodeId} is on tab ${node.z} but group is on tab ${group.z}`)
|
|
1706
|
+
return node
|
|
1707
|
+
})
|
|
1708
|
+
this.RED.view.select({ nodes: [group, ...newNodes] })
|
|
1709
|
+
this._verifySelection([group, ...newNodes])
|
|
1710
|
+
this.RED.actions.invoke('core:merge-selection-to-group')
|
|
1711
|
+
|
|
1712
|
+
const newGroups = []
|
|
1713
|
+
this.RED.nodes.eachGroup(g => {
|
|
1714
|
+
if (!existingGroupIds.has(g.id)) newGroups.push(g)
|
|
1715
|
+
})
|
|
1716
|
+
if (newGroups.length === 0) {
|
|
1717
|
+
throw new Error('Add nodes failed: core:merge-selection-to-group did not create a new group')
|
|
1718
|
+
}
|
|
1719
|
+
group = newGroups[0]
|
|
1720
|
+
}
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
const changes = {}
|
|
1724
|
+
|
|
1725
|
+
if (name !== undefined) {
|
|
1726
|
+
changes.name = group.name
|
|
1727
|
+
group.name = name
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
if (style) {
|
|
1731
|
+
changes.style = { ...group.style }
|
|
1732
|
+
group.style = Object.assign({}, group.style || {}, style)
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
if (env !== undefined) {
|
|
1736
|
+
changes.env = group.env
|
|
1737
|
+
group.env = env
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
if (Object.keys(changes).length > 0) {
|
|
1741
|
+
group.changed = true
|
|
1742
|
+
group.dirty = true
|
|
1743
|
+
this.RED.history.push({ t: 'edit', node: group, changes, changed: false, dirty: this.RED.nodes.dirty() })
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
this.RED.nodes.dirty(true)
|
|
1747
|
+
this.RED.view.redraw()
|
|
1748
|
+
return group
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
/**
|
|
1752
|
+
* Delete a group (ungroup its nodes).
|
|
1753
|
+
* @param {string} id - group ID
|
|
1754
|
+
*/
|
|
1755
|
+
deleteGroup (id) {
|
|
1756
|
+
const group = this.RED.nodes.group(id)
|
|
1757
|
+
if (!group) throw new Error(`Group ${id} not found`)
|
|
1758
|
+
this._assertWorkspaceNotLocked(group.z)
|
|
1759
|
+
this.RED.view.select({ nodes: [group] })
|
|
1760
|
+
this._verifySelection([group])
|
|
1761
|
+
this.RED.actions.invoke('core:ungroup-selection')
|
|
1762
|
+
this.RED.nodes.dirty(true)
|
|
1763
|
+
this.RED.view.redraw()
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1414
1766
|
_summarizeNode (node) {
|
|
1415
1767
|
if (!node) return null
|
|
1416
1768
|
const s = { id: node.id }
|
|
@@ -1420,7 +1772,65 @@ export class ExpertAutomations extends ExpertActionsInterface {
|
|
|
1420
1772
|
if (node.x !== undefined) s.x = node.x
|
|
1421
1773
|
if (node.y !== undefined) s.y = node.y
|
|
1422
1774
|
if (node.z !== undefined) s.z = node.z
|
|
1775
|
+
if (node.wires !== undefined) s.wires = node.wires
|
|
1776
|
+
if (node.links !== undefined) s.links = node.links
|
|
1423
1777
|
if (node.valid !== undefined) s.valid = node.valid
|
|
1424
1778
|
return s
|
|
1425
1779
|
}
|
|
1780
|
+
|
|
1781
|
+
/**
|
|
1782
|
+
* Defensive check: verify the view selection contains exactly the expected nodes.
|
|
1783
|
+
* Guards against editor states where RED.view.select() is silently ignored,
|
|
1784
|
+
* which would cause core actions to act on unintended nodes.
|
|
1785
|
+
* @param {object[]} expectedNodes
|
|
1786
|
+
*/
|
|
1787
|
+
_verifySelection (expectedNodes) {
|
|
1788
|
+
const selection = this.RED.view.selection()
|
|
1789
|
+
const selectedNodes = selection?.nodes || []
|
|
1790
|
+
const expectedIds = expectedNodes.map(n => n.id)
|
|
1791
|
+
const selectedIds = selectedNodes.map(n => n.id)
|
|
1792
|
+
const expectedSet = new Set(expectedIds)
|
|
1793
|
+
const selectedSet = new Set(selectedIds)
|
|
1794
|
+
if (selectedSet.size !== expectedSet.size || ![...expectedSet].every(id => selectedSet.has(id))) {
|
|
1795
|
+
throw new Error(
|
|
1796
|
+
`Selection mismatch: expected [${expectedIds.join(', ')}] but selection is [${selectedIds.join(', ')}] — editor may be in an unexpected state`
|
|
1797
|
+
)
|
|
1798
|
+
}
|
|
1799
|
+
}
|
|
1800
|
+
|
|
1801
|
+
_summarizeSubflow (subflow) {
|
|
1802
|
+
if (!subflow) return null
|
|
1803
|
+
return {
|
|
1804
|
+
id: subflow.id,
|
|
1805
|
+
type: subflow.type,
|
|
1806
|
+
name: subflow.name,
|
|
1807
|
+
info: subflow.info,
|
|
1808
|
+
inputs: Array.isArray(subflow.in) ? subflow.in.length : 0,
|
|
1809
|
+
outputs: Array.isArray(subflow.out) ? subflow.out.length : 0
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
_summarizeFlowItem (item) {
|
|
1814
|
+
if (!item) return null
|
|
1815
|
+
if (item.type === 'tab') {
|
|
1816
|
+
const ws = this.RED.nodes.workspace(item.id)
|
|
1817
|
+
return this._summarizeWorkspace(ws)
|
|
1818
|
+
}
|
|
1819
|
+
if (item.type === 'subflow') return this._summarizeSubflow(item)
|
|
1820
|
+
if (item.type === 'group') return this._summarizeGroup(item)
|
|
1821
|
+
return this._summarizeNode(item)
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
_summarizeGroup (group) {
|
|
1825
|
+
if (!group) return null
|
|
1826
|
+
const s = { id: group.id, type: 'group' }
|
|
1827
|
+
if (group.name !== undefined) s.name = group.name
|
|
1828
|
+
if (group.z !== undefined) s.z = group.z
|
|
1829
|
+
if (Array.isArray(group.nodes)) {
|
|
1830
|
+
s.nodes = group.nodes.filter(Boolean).map(n => (typeof n === 'string' ? n : n.id))
|
|
1831
|
+
}
|
|
1832
|
+
if (group.style !== undefined) s.style = group.style
|
|
1833
|
+
if (Array.isArray(group.env) && group.env.length > 0) s.env = group.env
|
|
1834
|
+
return s
|
|
1835
|
+
}
|
|
1426
1836
|
}
|