@flowfuse/nr-assistant 0.8.1-317b279-202601221339.0 → 0.8.1-6512bd9-202601241141.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/index.html CHANGED
@@ -1,5 +1,7 @@
1
+ <script src="resources/@flowfuse/nr-assistant/expertComms.js"></script>
1
2
  <script src="resources/@flowfuse/nr-assistant/sharedUtils.js"></script>
2
3
  <script>
4
+ /* global FFExpertComms */ /* loaded from expertComms.js */
3
5
  /* global FFAssistantUtils */ /* loaded from sharedUtils.js */
4
6
  /* global RED, $ */ /* loaded from Node-RED core */
5
7
  (function (RED, n) {
@@ -160,6 +162,7 @@
160
162
  }
161
163
  })
162
164
  }
165
+
163
166
  function initAssistant (options) {
164
167
  debug('initialising...', assistantOptions)
165
168
  if (!initialisedInterlock) {
@@ -200,163 +203,7 @@
200
203
  }
201
204
 
202
205
  if (!assistantInitialised) {
203
- // Setup postMessage communication with parent window
204
- if (!window.parent?.postMessage || window.self === window.top) {
205
- console.warn('Parent window not detected - certain interactions with the FlowFuse Expert will not be available')
206
- } else {
207
- const MESSAGE_SOURCE = 'nr-assistant'
208
- const MESSAGE_TARGET = 'flowfuse-expert'
209
- const MESSAGE_SCOPE = 'flowfuse-expert'
210
-
211
- // proxy certain events from RED Events to the parent window (for state tracking)
212
- RED.events.on('editor:open', function () {
213
- window.parent.postMessage({
214
- type: 'editor:open',
215
- source: MESSAGE_SOURCE,
216
- scope: MESSAGE_SCOPE,
217
- target: MESSAGE_TARGET
218
- }, '*')
219
- })
220
- RED.events.on('editor:close', function () {
221
- window.parent.postMessage({
222
- type: 'editor:close',
223
- source: MESSAGE_SOURCE,
224
- scope: MESSAGE_SCOPE,
225
- target: MESSAGE_TARGET
226
- }, '*')
227
- })
228
-
229
- // Define supported actions and their parameter schemas
230
- const supportedActions = {
231
- 'core:manage-palette': {
232
- params: {
233
- type: 'object',
234
- properties: {
235
- view: {
236
- type: 'string',
237
- enum: ['nodes', 'install'],
238
- default: 'install'
239
- },
240
- filter: {
241
- description: 'Optional filter string. e.g. `"node-red-contrib-s7","node-red-contrib-other"` to pre-filter the palette view',
242
- type: 'string'
243
- }
244
- },
245
- required: ['filter']
246
- }
247
- },
248
- 'custom:import-flow': {
249
- params: {
250
- type: 'object',
251
- properties: {
252
- flow: {
253
- type: 'string',
254
- description: 'The flow JSON to import'
255
- },
256
- addFlow: {
257
- type: 'boolean',
258
- description: 'Whether to add the flow to the current workspace tab (false) or create a new tab (true). Default: false'
259
- }
260
- },
261
- required: ['flow']
262
- }
263
- }
264
- }
265
-
266
- // Listen for postMessages from parent window
267
- window.addEventListener('message', function (event) {
268
- // prevent own messages being processed
269
- if (event.source === window.self) {
270
- return
271
- }
272
-
273
- const { type, action, params, target, source, scope } = event.data || {}
274
-
275
- // Ensure scope and source match expected values
276
- if (target !== MESSAGE_SOURCE || source !== MESSAGE_TARGET || scope !== MESSAGE_SCOPE) {
277
- return
278
- }
279
-
280
- debug('Received postMessage:', event.data)
281
-
282
- const postReply = (message) => {
283
- debug('Posting reply message:', message)
284
- if (event.source && typeof event.source.postMessage === 'function') {
285
- event.source.postMessage({
286
- ...message,
287
- source: MESSAGE_SOURCE,
288
- scope: MESSAGE_SCOPE,
289
- target: MESSAGE_TARGET
290
- }, event.origin)
291
- } else {
292
- console.warn('Unable to post message reply, source not available', message)
293
- }
294
- }
295
-
296
- // handle version request
297
- if (type === 'get-assistant-version') {
298
- // Reply with the current version
299
- postReply({ type, version: assistantOptions.assistantVersion, success: true }, event.origin)
300
- return
301
- }
302
- // handle supported actions request
303
- if (type === 'get-supported-actions') {
304
- // Reply with the supported actions and their schemas
305
- postReply({ type, supportedActions, success: true }, event.origin)
306
- return
307
- }
308
-
309
- // handle action invocation requests (must be registered actions in supportedActions)
310
- if (type === 'invoke-action' && typeof action === 'string') {
311
- if (!supportedActions[action]) {
312
- console.warn(`Action "${action}" is not permitted to be invoked via postMessage`)
313
- postReply({ type, action, error: 'unknown-action' })
314
- return
315
- }
316
- // Validate params against permitted schema (native/naive parsing for now - may introduce a library later if more complex schemas are needed)
317
- const actionSchema = supportedActions[action].params
318
- if (actionSchema) {
319
- const validation = validateSchema(params, actionSchema)
320
- if (!validation || !validation.valid) {
321
- console.warn(`Params for action "${action}" did not validate against the expected schema`, params, actionSchema, validation)
322
- postReply({ type, action, error: validation.error || 'invalid-parameters' })
323
- return
324
- }
325
- }
326
-
327
- if (action === 'custom:import-flow') {
328
- // import-flow is a custom action - handle it here directly
329
- try {
330
- importNodes(params.flow, params.addFlow === true)
331
- postReply({ type, success: true })
332
- } catch (err) {
333
- postReply({ type, error: err?.message })
334
- }
335
- } else {
336
- // Handle (supported) native Node-RED actions
337
- try {
338
- RED.actions.invoke(action, params)
339
- postReply({ type, action, success: true })
340
- } catch (err) {
341
- postReply({ type, action, error: err?.message })
342
- }
343
- }
344
- return
345
- }
346
-
347
- // unknown message type
348
- postReply({ type: 'error', error: 'unknown-type', data: event.data })
349
- }, false)
350
-
351
- // Notify the parent window that the assistant is ready
352
- window.parent.postMessage({
353
- type: 'assistant-ready',
354
- source: MESSAGE_SOURCE,
355
- scope: MESSAGE_SCOPE,
356
- target: MESSAGE_TARGET,
357
- version: assistantOptions.assistantVersion
358
- }, '*')
359
- }
206
+ FFExpertComms.init(RED, assistantOptions)
360
207
 
361
208
  registerMonacoExtensions()
362
209
  RED.actions.add('flowfuse-nr-assistant:function-builder', showFunctionBuilderPrompt, { label: '@flowfuse/nr-assistant/flowfuse-nr-assistant:function-builder.action.label' })
@@ -2117,128 +1964,6 @@
2117
1964
  RED.menu.refreshShortcuts()
2118
1965
  }
2119
1966
 
2120
- /**
2121
- * Validates data against a simple schema
2122
- * NOTE: This is a very basic implementation and only supports a subset of JSON Schema for now.
2123
- * @param {any} data - The data to validate
2124
- * @param {object} schema - The schema to validate against
2125
- * @returns {{valid: boolean, error?: string}} - The validation result
2126
- */
2127
- function validateSchema (data, schema) {
2128
- if (schema.type === 'object') {
2129
- if (typeof data !== 'object') {
2130
- return {
2131
- valid: false,
2132
- error: 'Data is not of type object'
2133
- }
2134
- }
2135
- if (Array.isArray(data)) {
2136
- return {
2137
- valid: false,
2138
- error: 'Data is an array but an object was expected'
2139
- }
2140
- }
2141
- // check required properties
2142
- if (Array.isArray(schema.required)) {
2143
- for (const reqProp of schema.required) {
2144
- if (!(reqProp in data)) {
2145
- return {
2146
- valid: false,
2147
- error: `Data is missing required parameter "${reqProp}"`
2148
- }
2149
- }
2150
- }
2151
- }
2152
- // check properties & apply defaults
2153
- if (schema.properties) {
2154
- for (const [propName, propSchema] of Object.entries(schema.properties)) {
2155
- const propExists = propName in data
2156
- // check type
2157
- if (propSchema.type && propExists) {
2158
- const expectedType = propSchema.type
2159
- const actualType = Array.isArray(data[propName]) ? 'array' : typeof data[propName]
2160
- if (actualType !== expectedType) {
2161
- return {
2162
- valid: false,
2163
- error: `Data parameter "${propName}" is of type "${actualType}" but expected type is "${expectedType}"`
2164
- }
2165
- }
2166
- }
2167
- // check enum
2168
- if (propSchema.enum && propExists) {
2169
- if (!propSchema.enum.includes(data[propName])) {
2170
- return {
2171
- valid: false,
2172
- error: `Data parameter "${propName}" has invalid value "${data[propName]}". Should be one of: ${propSchema.enum.join(', ')}`
2173
- }
2174
- }
2175
- }
2176
- // apply defaults
2177
- if (propSchema.default !== undefined && !propExists) {
2178
- data[propName] = propSchema.default
2179
- }
2180
- }
2181
- }
2182
- }
2183
- return { valid: true }
2184
- }
2185
-
2186
- /// Function extracted from Node-RED source `editor-client/src/js/ui/clipboard.js`
2187
- /**
2188
- * Performs the import of nodes, handling any conflicts that may arise
2189
- * @param {string} nodesStr the nodes to import as a string
2190
- * @param {boolean} addFlow whether to add the nodes to a new flow or to the current flow
2191
- */
2192
- function importNodes (nodesStr, addFlow) {
2193
- let newNodes = nodesStr
2194
- if (typeof nodesStr === 'string') {
2195
- try {
2196
- nodesStr = nodesStr.trim()
2197
- if (nodesStr.length === 0) {
2198
- return
2199
- }
2200
- newNodes = validateFlowString(nodesStr)
2201
- } catch (err) {
2202
- const e = new Error(RED._('clipboard.invalidFlow', { message: 'test' }))
2203
- e.code = 'NODE_RED'
2204
- throw e
2205
- }
2206
- }
2207
- const importOptions = { generateIds: true, addFlow }
2208
- try {
2209
- RED.view.importNodes(newNodes, importOptions)
2210
- } catch (error) {
2211
- // Thrown for import_conflict
2212
- RED.notify('Import failed:' + error.message, 'error')
2213
- throw error
2214
- }
2215
- }
2216
-
2217
- /// Function extracted from Node-RED source `editor-client/src/js/ui/clipboard.js`
2218
- /**
2219
- * Validates if the provided string looks like valid flow json
2220
- * @param {string} flowString the string to validate
2221
- * @returns If valid, returns the node array
2222
- */
2223
- function validateFlowString (flowString) {
2224
- const res = JSON.parse(flowString)
2225
- if (!Array.isArray(res)) {
2226
- throw new Error(RED._('clipboard.import.errors.notArray'))
2227
- }
2228
- for (let i = 0; i < res.length; i++) {
2229
- if (typeof res[i] !== 'object') {
2230
- throw new Error(RED._('clipboard.import.errors.itemNotObject', { index: i }))
2231
- }
2232
- if (!Object.hasOwn(res[i], 'id')) {
2233
- throw new Error(RED._('clipboard.import.errors.missingId', { index: i }))
2234
- }
2235
- if (!Object.hasOwn(res[i], 'type')) {
2236
- throw new Error(RED._('clipboard.import.errors.missingType', { index: i }))
2237
- }
2238
- }
2239
- return res
2240
- }
2241
-
2242
1967
  function debug (...args) {
2243
1968
  if (RED.nrAssistant?.DEBUG) {
2244
1969
  const scriptName = 'assistant-index.html.js' // must match the sourceURL set in the script below
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flowfuse/nr-assistant",
3
- "version": "0.8.1-317b279-202601221339.0",
3
+ "version": "0.8.1-6512bd9-202601241141.0",
4
4
  "description": "FlowFuse Node-RED Expert plugin",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -37,7 +37,7 @@
37
37
  "node": ">=16.x"
38
38
  },
39
39
  "dependencies": {
40
- "@modelcontextprotocol/sdk": "^1.24.2",
40
+ "@modelcontextprotocol/sdk": "^1.25.3",
41
41
  "base64url": "^3.0.1",
42
42
  "got": "^11.8.6",
43
43
  "onnxruntime-web": "^1.22.0",
@@ -49,8 +49,8 @@
49
49
  "eslint-config-standard": "^17.1.0",
50
50
  "eslint-plugin-html": "7.1.0",
51
51
  "eslint-plugin-no-only-tests": "^3.1.0",
52
- "mocha": "^11.6.0",
52
+ "mocha": "^11.7.5",
53
53
  "should": "^13.2.3",
54
- "sinon": "^18.0.0"
54
+ "sinon": "^21.0.1"
55
55
  }
56
56
  }
@@ -0,0 +1,479 @@
1
+ /**
2
+ * FFAssistant Utils
3
+ * Expert Communication functions for the FlowFuse Assistant
4
+ * To import this in js backend code (although you shouldn't), use:
5
+ * const FFExpertComms = require('flowfuse-nr-assistant/resources/expertComms.js')
6
+ * To import this in frontend code, use:
7
+ * <script src="/resources/@flowfuse/nr-assistant/expertComms.js"></script>
8
+ * To use this in the browser, you can access it via:
9
+ * FFExpertComms.cleanFlow(nodeArray)
10
+ */
11
+
12
+ 'use strict';
13
+
14
+ (function (root, factory) {
15
+ if (typeof module === 'object' && module.exports) {
16
+ // Node.js / CommonJS
17
+ module.exports = factory()
18
+ } else {
19
+ // Browser
20
+ root.FFExpertComms = factory()
21
+ }
22
+ }(typeof self !== 'undefined' ? self : this, function () {
23
+ 'use strict'
24
+
25
+ class ExpertComms {
26
+ /** @type {import('node-red').NodeRedInstance} */
27
+ RED = null
28
+ assistantOptions = {}
29
+
30
+ MESSAGE_SOURCE = 'nr-assistant'
31
+ MESSAGE_TARGET = 'flowfuse-expert'
32
+ MESSAGE_SCOPE = 'flowfuse-expert'
33
+
34
+ /**
35
+ * targetOrigin is set to '*' by default, which allows messages to be sent and received from any origin.
36
+ * This is fine for the initial handshake with the FF Expert (will change to the origin of the expert page once it is loaded)
37
+ *
38
+ * @type {string}
39
+ */
40
+ targetOrigin = '*'
41
+
42
+ /**
43
+ * Define supported actions and their parameter schemas
44
+ */
45
+ supportedActions = {
46
+ 'core:manage-palette': {
47
+ params: {
48
+ type: 'object',
49
+ properties: {
50
+ view: {
51
+ type: 'string',
52
+ enum: ['nodes', 'install'],
53
+ default: 'install'
54
+ },
55
+ filter: {
56
+ description: 'Optional filter string. e.g. `"node-red-contrib-s7","node-red-contrib-other"` to pre-filter the palette view',
57
+ type: 'string'
58
+ }
59
+ },
60
+ required: ['filter']
61
+ }
62
+ },
63
+ 'custom:import-flow': {
64
+ params: {
65
+ type: 'object',
66
+ properties: {
67
+ flow: {
68
+ type: 'string',
69
+ description: 'The flow JSON to import'
70
+ },
71
+ addFlow: {
72
+ type: 'boolean',
73
+ description: 'Whether to add the flow to the current workspace tab (false) or create a new tab (true). Default: false'
74
+ }
75
+ },
76
+ required: ['flow']
77
+ }
78
+ }
79
+ }
80
+
81
+ /**
82
+ * A mapping of Node-RED core events to their respective handler logic.
83
+ *
84
+ * This map acts as a router for the assistant's event listeners:
85
+ * - Functions: Executed immediately when the event fires (e.g., notifying the parent of UI state).
86
+ * - Strings: Represent the name of a method within this class to be invoked (e.g., refreshing the palette).
87
+ * The method name being referenced must be appended with 'notify'
88
+ *
89
+ * @type {Object.<string, Function|string>}
90
+ */
91
+ nodeRedEventsMap = {
92
+ 'editor:open': () => {
93
+ this.postParent({ type: 'editor:open' })
94
+ },
95
+ 'editor:close': () => {
96
+ this.postParent({ type: 'editor:close' })
97
+ },
98
+ 'registry:node-set-added': 'notifyPaletteChange',
99
+ 'registry:node-set-removed': 'notifyPaletteChange',
100
+ 'registry:node-set-disabled': 'notifyPaletteChange',
101
+ 'registry:node-set-enabled': 'notifyPaletteChange'
102
+ }
103
+
104
+ /**
105
+ * A mapping of FlowFuse Expert events to their respective handler logic.
106
+ *
107
+ * This map acts as a router for the expert's event listeners:
108
+ * - Functions: Executed immediately when the event fires (e.g., notifying the parent of UI state).
109
+ * - Strings: Represent the name of a method within this class to be invoked (e.g., refreshing the palette).
110
+ * The method name being referenced must be appended with 'handle'
111
+ *
112
+ * @type {Object.<string, Function|string>}
113
+ */
114
+ expertEventsMap = {
115
+ 'get-assistant-version': ({ event, type, action, params } = {}) => {
116
+ // handle version request
117
+ this.postReply({ type, version: this.assistantOptions.assistantVersion, success: true }, event)
118
+ },
119
+ 'get-supported-actions': ({ event, type, action, params } = {}) => {
120
+ // handle supported actions request
121
+ this.postReply({ type, supportedActions: this.supportedActions, success: true }, event)
122
+ },
123
+ 'get-palette': async ({ event, type, action, params } = {}) => {
124
+ // handle palette request
125
+ this.postReply({ type: 'set-palette', palette: await this.getPalette(), success: true }, event)
126
+ },
127
+ 'invoke-action': 'handleActionInvocation'
128
+ }
129
+
130
+ init (RED, assistantOptions) {
131
+ /** @type {import('node-red').NodeRedInstance} */
132
+ this.RED = RED
133
+ this.assistantOptions = assistantOptions
134
+
135
+ if (!window.parent?.postMessage || window.self === window.top) {
136
+ console.warn('Parent window not detected - certain interactions with the FlowFuse Expert will not be available')
137
+ return
138
+ }
139
+
140
+ this.setNodeRedEventListeners()
141
+
142
+ this.setupMessageListeners()
143
+
144
+ // Notify the parent window that the assistant is ready
145
+ this.postParent({ type: 'assistant-ready', version: this.assistantOptions.assistantVersion })
146
+ }
147
+
148
+ setupMessageListeners () {
149
+ // Listen for postMessages from the parent window
150
+ window.addEventListener('message', async (event) => {
151
+ // prevent own messages being processed
152
+ if (event.source === window.self) {
153
+ return
154
+ }
155
+
156
+ const { type, action, params, target, source, scope } = event.data || {}
157
+
158
+ // Ensure scope and source match expected values
159
+ if (target !== this.MESSAGE_SOURCE || source !== this.MESSAGE_TARGET || scope !== this.MESSAGE_SCOPE) {
160
+ return
161
+ }
162
+
163
+ // Setting target origin for future calls
164
+ if (this.targetOrigin === '*') {
165
+ this.targetOrigin = event.origin
166
+ }
167
+
168
+ this.debug('Received postMessage:', event.data)
169
+
170
+ const payload = {
171
+ event,
172
+ type,
173
+ action,
174
+ params
175
+ }
176
+
177
+ for (const eventName in this.expertEventsMap) {
178
+ if (type === eventName && typeof this.expertEventsMap[eventName] === 'function') {
179
+ return this.expertEventsMap[eventName](payload)
180
+ }
181
+
182
+ if (
183
+ type === eventName &&
184
+ typeof this.expertEventsMap[eventName] === 'string' &&
185
+ this.expertEventsMap[eventName] in this
186
+ ) {
187
+ return this[this.expertEventsMap[eventName]](payload)
188
+ }
189
+ }
190
+
191
+ // handles unknown message type
192
+ this.postReply({ type: 'error', error: 'unknown-type', data: event.data }, event)
193
+ }, false)
194
+ }
195
+
196
+ setNodeRedEventListeners () {
197
+ Object.keys(this.nodeRedEventsMap).forEach(eventName => {
198
+ if (typeof this.nodeRedEventsMap[eventName] === 'function') {
199
+ this.RED.events.on(eventName, this.nodeRedEventsMap[eventName].bind(this))
200
+ }
201
+ if (typeof this.nodeRedEventsMap[eventName] === 'string' && this.nodeRedEventsMap[eventName] in this) {
202
+ this.RED.events.on(eventName, this[this.nodeRedEventsMap[eventName]].bind(this))
203
+ }
204
+ })
205
+ }
206
+
207
+ /**
208
+ * FlowFuse Expert Node-RED event notifiers
209
+ */
210
+ async notifyPaletteChange () {
211
+ this.postParent({
212
+ type: 'set-palette',
213
+ palette: await this.getPalette()
214
+ })
215
+ }
216
+
217
+ /**
218
+ * FlowFuse Expert message handlers
219
+ */
220
+ handleActionInvocation ({ event, type, action, params } = {}) {
221
+ // handle action invocation requests (must be registered actions in supportedActions)
222
+ if (typeof action !== 'string') {
223
+ return
224
+ }
225
+
226
+ if (!this.supportedActions[action]) {
227
+ console.warn(`Action "${action}" is not permitted to be invoked via postMessage`)
228
+ this.postReply({ type, action, error: 'unknown-action' }, event)
229
+ return
230
+ }
231
+
232
+ // Validate params against permitted schema (native/naive parsing for now - may introduce a library later if more complex schemas are needed)
233
+ const actionSchema = this.supportedActions[action].params
234
+ if (actionSchema) {
235
+ const validation = this.validateSchema(params, actionSchema)
236
+ if (!validation || !validation.valid) {
237
+ console.warn(`Params for action "${action}" did not validate against the expected schema`, params, actionSchema, validation)
238
+ this.postReply({ type, action, error: validation.error || 'invalid-parameters' }, event)
239
+ return
240
+ }
241
+ }
242
+
243
+ if (action === 'custom:import-flow') {
244
+ // import-flow is a custom action - handle it here directly
245
+ try {
246
+ this.importNodes(params.flow, params.addFlow === true)
247
+ this.postReply({ type, success: true }, event)
248
+ } catch (err) {
249
+ this.postReply({ type, error: err?.message }, event)
250
+ }
251
+ } else {
252
+ // Handle (supported) native Node-RED actions
253
+ try {
254
+ this.RED.actions.invoke(action, params)
255
+ this.postReply({ type, action, success: true }, event)
256
+ } catch (err) {
257
+ this.postReply({ type, action, error: err?.message }, event)
258
+ }
259
+ }
260
+ }
261
+
262
+ async getPalette () {
263
+ const palette = {}
264
+ const plugins = await $.ajax({
265
+ url: 'plugins',
266
+ method: 'GET',
267
+ headers: {
268
+ Accept: 'application/json'
269
+ }
270
+ })
271
+ const nodes = await $.ajax({
272
+ url: 'nodes',
273
+ method: 'GET',
274
+ headers: {
275
+ Accept: 'application/json'
276
+ }
277
+ })
278
+
279
+ plugins.forEach(plugin => {
280
+ if (Object.prototype.hasOwnProperty.call(palette, plugin.module)) {
281
+ palette[plugin.module].plugins.push(plugin)
282
+ } else {
283
+ palette[plugin.module] = {
284
+ version: plugin.version,
285
+ enabled: plugin.enabled,
286
+ module: plugin.module,
287
+ plugins: [
288
+ plugin
289
+ ],
290
+ nodes: []
291
+ }
292
+ }
293
+ })
294
+
295
+ nodes.forEach(node => {
296
+ if (Object.prototype.hasOwnProperty.call(palette, node.module)) {
297
+ palette[node.module].nodes.push(node)
298
+ } else {
299
+ palette[node.module] = {
300
+ version: node.version,
301
+ enabled: node.enabled,
302
+ module: node.module,
303
+ plugins: [],
304
+ nodes: [
305
+ node
306
+ ]
307
+ }
308
+ }
309
+ })
310
+
311
+ return palette
312
+ }
313
+
314
+ validateSchema (data, schema) {
315
+ if (schema.type === 'object') {
316
+ if (typeof data !== 'object') {
317
+ return {
318
+ valid: false,
319
+ error: 'Data is not of type object'
320
+ }
321
+ }
322
+ if (Array.isArray(data)) {
323
+ return {
324
+ valid: false,
325
+ error: 'Data is an array but an object was expected'
326
+ }
327
+ }
328
+ // check required properties
329
+ if (Array.isArray(schema.required)) {
330
+ for (const reqProp of schema.required) {
331
+ if (!(reqProp in data)) {
332
+ return {
333
+ valid: false,
334
+ error: `Data is missing required parameter "${reqProp}"`
335
+ }
336
+ }
337
+ }
338
+ }
339
+ // check properties & apply defaults
340
+ if (schema.properties) {
341
+ for (const [propName, propSchema] of Object.entries(schema.properties)) {
342
+ const propExists = propName in data
343
+ // check type
344
+ if (propSchema.type && propExists) {
345
+ const expectedType = propSchema.type
346
+ const actualType = Array.isArray(data[propName]) ? 'array' : typeof data[propName]
347
+ if (actualType !== expectedType) {
348
+ return {
349
+ valid: false,
350
+ error: `Data parameter "${propName}" is of type "${actualType}" but expected type is "${expectedType}"`
351
+ }
352
+ }
353
+ }
354
+ // check enum
355
+ if (propSchema.enum && propExists) {
356
+ if (!propSchema.enum.includes(data[propName])) {
357
+ return {
358
+ valid: false,
359
+ error: `Data parameter "${propName}" has invalid value "${data[propName]}". Should be one of: ${propSchema.enum.join(', ')}`
360
+ }
361
+ }
362
+ }
363
+ // apply defaults
364
+ if (propSchema.default !== undefined && !propExists) {
365
+ data[propName] = propSchema.default
366
+ }
367
+ }
368
+ }
369
+ }
370
+ return { valid: true }
371
+ }
372
+
373
+ /// Function extracted from Node-RED source `editor-client/src/js/ui/clipboard.js`
374
+ /**
375
+ * Performs the import of nodes, handling any conflicts that may arise
376
+ * @param {string} nodesStr the nodes to import as a string
377
+ * @param {boolean} addFlow whether to add the nodes to a new flow or to the current flow
378
+ */
379
+ importNodes (nodesStr, addFlow) {
380
+ let newNodes = nodesStr
381
+ if (typeof nodesStr === 'string') {
382
+ try {
383
+ nodesStr = nodesStr.trim()
384
+ if (nodesStr.length === 0) {
385
+ return
386
+ }
387
+ newNodes = this.validateFlowString(nodesStr)
388
+ } catch (err) {
389
+ const e = new Error(this.RED._('clipboard.invalidFlow', { message: 'test' }))
390
+ e.code = 'NODE_RED'
391
+ throw e
392
+ }
393
+ }
394
+ const importOptions = { generateIds: true, addFlow }
395
+ try {
396
+ this.RED.view.importNodes(newNodes, importOptions)
397
+ } catch (error) {
398
+ // Thrown for import_conflict
399
+ this.RED.notify('Import failed:' + error.message, 'error')
400
+ throw error
401
+ }
402
+ }
403
+
404
+ /// Function extracted from Node-RED source `editor-client/src/js/ui/clipboard.js`
405
+ /**
406
+ * Validates if the provided string looks like valid flow json
407
+ * @param {string} flowString the string to validate
408
+ * @returns If valid, returns the node array
409
+ */
410
+ validateFlowString (flowString) {
411
+ const res = JSON.parse(flowString)
412
+ if (!Array.isArray(res)) {
413
+ throw new Error(this.RED._('clipboard.import.errors.notArray'))
414
+ }
415
+ for (let i = 0; i < res.length; i++) {
416
+ if (typeof res[i] !== 'object') {
417
+ throw new Error(this.RED._('clipboard.import.errors.itemNotObject', { index: i }))
418
+ }
419
+ if (!Object.hasOwn(res[i], 'id')) {
420
+ throw new Error(this.RED._('clipboard.import.errors.missingId', { index: i }))
421
+ }
422
+ if (!Object.hasOwn(res[i], 'type')) {
423
+ throw new Error(this.RED._('clipboard.import.errors.missingType', { index: i }))
424
+ }
425
+ }
426
+ return res
427
+ }
428
+
429
+ debug (...args) {
430
+ if (this.RED.nrAssistant?.DEBUG) {
431
+ const scriptName = 'assistant-index.html.js' // must match the sourceURL set in the script below
432
+ const stackLine = new Error().stack.split('\n')[2].trim()
433
+ const match = stackLine.match(/\(?([^\s)]+):(\d+):(\d+)\)?$/) || stackLine.match(/@?([^@]+):(\d+):(\d+)$/)
434
+ const file = match?.[1] || 'anonymous'
435
+ const line = match?.[2] || '1'
436
+ const col = match?.[3] || '1'
437
+ let link = `${window.location.origin}/${scriptName}:${line}:${col}`
438
+ if (/^VM\d+$/.test(file)) {
439
+ link = `debugger:///${file}:${line}:${col}`
440
+ } else if (file !== 'anonymous' && file !== '<anonymous>' && file !== scriptName) {
441
+ link = `${file}:${line}:${col}`
442
+ if (!link.startsWith('http') && !link.includes('/')) {
443
+ link = `${window.location.origin}/${link}`
444
+ }
445
+ }
446
+ // eslint-disable-next-line no-console
447
+ console.log('[nr-assistant]', ...args, `\n at ${link}`)
448
+ }
449
+ }
450
+
451
+ /**
452
+ * Internal helper to send a formatted message to a target window
453
+ */
454
+ _post (payload, targetWindow) {
455
+ if (targetWindow && typeof targetWindow.postMessage === 'function') {
456
+ targetWindow.postMessage({
457
+ ...payload,
458
+ source: this.MESSAGE_SOURCE,
459
+ scope: this.MESSAGE_SCOPE,
460
+ target: this.MESSAGE_TARGET
461
+ }, this.targetOrigin)
462
+ } else {
463
+ console.warn('Unable to post message, target window not available', payload)
464
+ }
465
+ }
466
+
467
+ postParent (payload = {}) {
468
+ this.debug('Posting parent message', payload)
469
+ this._post(payload, window.parent)
470
+ }
471
+
472
+ postReply (payload, event) {
473
+ this.debug('Posting reply message:', payload)
474
+ this._post(payload, event.source)
475
+ }
476
+ }
477
+
478
+ return new ExpertComms()
479
+ }))