diff --git a/packages/noodl-editor/src/editor/src/utils/IODetector.ts b/packages/noodl-editor/src/editor/src/utils/IODetector.ts new file mode 100644 index 0000000..cfb77b4 --- /dev/null +++ b/packages/noodl-editor/src/editor/src/utils/IODetector.ts @@ -0,0 +1,189 @@ +/** + * IODetector + * + * Utility for detecting inputs, outputs, and signals from Blockly workspaces. + * Scans workspace JSON to find Input/Output definition blocks and extracts their configuration. + * + * @module utils + */ + +export interface DetectedInput { + name: string; + type: string; +} + +export interface DetectedOutput { + name: string; + type: string; +} + +export interface DetectedIO { + inputs: DetectedInput[]; + outputs: DetectedOutput[]; + signalInputs: string[]; + signalOutputs: string[]; +} + +interface BlocklyBlock { + type?: string; + fields?: Record; + inputs?: Record; + next?: { block?: BlocklyBlock }; +} + +/** + * Detect all I/O from a Blockly workspace JSON + * + * @param workspaceJson - Serialized Blockly workspace (JSON string or object) + * @returns Detected inputs, outputs, and signals + */ +export function detectIO(workspaceJson: string | object): DetectedIO { + const result: DetectedIO = { + inputs: [], + outputs: [], + signalInputs: [], + signalOutputs: [] + }; + + try { + const workspace = typeof workspaceJson === 'string' ? JSON.parse(workspaceJson) : workspaceJson; + + if (!workspace || !workspace.blocks || !workspace.blocks.blocks) { + return result; + } + + const blocks = workspace.blocks.blocks; + + for (const block of blocks) { + processBlock(block, result); + } + } catch (error) { + console.error('[IODetector] Failed to parse workspace:', error); + } + + // Remove duplicates + result.inputs = uniqueBy(result.inputs, 'name'); + result.outputs = uniqueBy(result.outputs, 'name'); + result.signalInputs = Array.from(new Set(result.signalInputs)); + result.signalOutputs = Array.from(new Set(result.signalOutputs)); + + return result; +} + +/** + * Recursively process a block and its children + */ +function processBlock(block: BlocklyBlock, result: DetectedIO): void { + if (!block || !block.type) return; + + switch (block.type) { + case 'noodl_define_input': + // Extract input definition + if (block.fields && block.fields.NAME && block.fields.TYPE) { + result.inputs.push({ + name: block.fields.NAME, + type: block.fields.TYPE + }); + } + break; + + case 'noodl_get_input': + // Auto-detect input from usage + if (block.fields && block.fields.NAME) { + const name = block.fields.NAME; + if (!result.inputs.find((i) => i.name === name)) { + result.inputs.push({ + name: name, + type: '*' // Default type + }); + } + } + break; + + case 'noodl_define_output': + // Extract output definition + if (block.fields && block.fields.NAME && block.fields.TYPE) { + result.outputs.push({ + name: block.fields.NAME, + type: block.fields.TYPE + }); + } + break; + + case 'noodl_set_output': + // Auto-detect output from usage + if (block.fields && block.fields.NAME) { + const name = block.fields.NAME; + if (!result.outputs.find((o) => o.name === name)) { + result.outputs.push({ + name: name, + type: '*' // Default type + }); + } + } + break; + + case 'noodl_define_signal_input': + // Extract signal input definition + if (block.fields && block.fields.NAME) { + result.signalInputs.push(block.fields.NAME); + } + break; + + case 'noodl_on_signal': + // Auto-detect signal input from event handler + if (block.fields && block.fields.SIGNAL) { + const name = block.fields.SIGNAL; + if (!result.signalInputs.includes(name)) { + result.signalInputs.push(name); + } + } + break; + + case 'noodl_define_signal_output': + // Extract signal output definition + if (block.fields && block.fields.NAME) { + result.signalOutputs.push(block.fields.NAME); + } + break; + + case 'noodl_send_signal': + // Auto-detect signal output from send blocks + if (block.fields && block.fields.NAME) { + const name = block.fields.NAME; + if (!result.signalOutputs.includes(name)) { + result.signalOutputs.push(name); + } + } + break; + } + + // Process nested blocks (inputs, next, etc.) + if (block.inputs) { + for (const inputKey in block.inputs) { + const input = block.inputs[inputKey]; + if (input && input.block) { + processBlock(input.block, result); + } + } + } + + if (block.next && block.next.block) { + processBlock(block.next.block, result); + } +} + +/** + * Remove duplicates from array based on key + */ +function uniqueBy(array: T[], key: keyof T): T[] { + const seen = new Set(); + return array.filter((item) => { + const k = item[key]; + if (seen.has(k)) { + return false; + } + seen.add(k); + return true; + }); +} diff --git a/packages/noodl-runtime/noodl-runtime.js b/packages/noodl-runtime/noodl-runtime.js index 5d53491..21483b6 100644 --- a/packages/noodl-runtime/noodl-runtime.js +++ b/packages/noodl-runtime/noodl-runtime.js @@ -28,6 +28,7 @@ function registerNodes(noodlRuntime) { // Custom code require('./src/nodes/std-library/expression'), require('./src/nodes/std-library/simplejavascript'), + require('./src/nodes/std-library/logic-builder'), // Records require('./src/nodes/std-library/data/dbcollectionnode2'), diff --git a/packages/noodl-runtime/src/nodelibraryexport.js b/packages/noodl-runtime/src/nodelibraryexport.js index 5d1d12a..d68268f 100644 --- a/packages/noodl-runtime/src/nodelibraryexport.js +++ b/packages/noodl-runtime/src/nodelibraryexport.js @@ -581,7 +581,7 @@ function generateNodeLibrary(nodeRegister) { subCategories: [ { name: '', - items: ['Expression', 'JavaScriptFunction', 'Javascript2', 'CSS Definition'] + items: ['Expression', 'JavaScriptFunction', 'Javascript2', 'Logic Builder', 'CSS Definition'] } ] }, diff --git a/packages/noodl-runtime/src/nodes/std-library/logic-builder.js b/packages/noodl-runtime/src/nodes/std-library/logic-builder.js new file mode 100644 index 0000000..9c4ac16 --- /dev/null +++ b/packages/noodl-runtime/src/nodes/std-library/logic-builder.js @@ -0,0 +1,336 @@ +'use strict'; + +/** + * Logic Builder Node + * + * Visual logic building using Google Blockly. + * Allows users to create complex logic without writing JavaScript. + * + * The node: + * - Stores a Blockly workspace as JSON + * - Auto-detects inputs/outputs from blocks + * - Generates and executes JavaScript from blocks + * - Provides full Noodl API access (Variables, Objects, Arrays) + */ + +const LogicBuilderNode = { + name: 'Logic Builder', + docs: 'https://docs.noodl.net/nodes/logic/logic-builder', + displayNodeName: 'Logic Builder', + shortDesc: 'Build logic visually with blocks', + category: 'Logic', + color: 'purple', + nodeDoubleClickAction: { + focusPort: 'workspace' + }, + searchTags: ['blockly', 'visual', 'logic', 'blocks', 'nocode'], + + initialize: function () { + const internal = this._internal; + + internal.workspace = ''; // Blockly workspace JSON + internal.compiledFunction = null; + internal.executionError = null; + internal.inputValues = {}; + internal.outputValues = {}; + }, + + methods: { + registerInputIfNeeded: function (name) { + if (this.hasInput(name)) { + return; + } + + const internal = this._internal; + + this.registerInput(name, { + set: function (value) { + internal.inputValues[name] = value; + // Don't auto-execute - wait for signal inputs + } + }); + }, + + registerOutputIfNeeded: function (name, type) { + if (this.hasOutput(name)) { + return; + } + + this.registerOutput(name, { + type: type || '*', + getter: function () { + return this._internal.outputValues[name]; + } + }); + }, + + registerSignalInputIfNeeded: function (name) { + if (this.hasInput(name)) { + return; + } + + this.registerInput(name, { + type: 'signal', + valueChangedToTrue: function () { + this._executeLogic(name); + } + }); + }, + + registerSignalOutputIfNeeded: function (name) { + if (this.hasOutput(name)) { + return; + } + + this.registerOutput(name, { + type: 'signal' + }); + }, + + _executeLogic: function (triggerSignal) { + const internal = this._internal; + + // Compile function if needed + if (!internal.compiledFunction && internal.workspace) { + internal.compiledFunction = this._compileFunction(); + } + + if (!internal.compiledFunction) { + console.warn('[Logic Builder] No logic to execute'); + return; + } + + try { + // Create execution context + const context = this._createExecutionContext(triggerSignal); + + // Execute generated code + internal.compiledFunction.call(context); + + // Update outputs + for (const outputName in context.Outputs) { + internal.outputValues[outputName] = context.Outputs[outputName]; + this.flagOutputDirty(outputName); + } + + internal.executionError = null; + } catch (error) { + console.error('[Logic Builder] Execution error:', error); + internal.executionError = error.message; + this.flagOutputDirty('error'); + } + }, + + _createExecutionContext: function (triggerSignal) { + const internal = this._internal; + // eslint-disable-next-line @typescript-eslint/no-this-alias + const self = this; + + // Create context with Noodl APIs + // eslint-disable-next-line @typescript-eslint/no-var-requires + const JavascriptNodeParser = require('../../javascriptnodeparser'); + const noodlAPI = JavascriptNodeParser.createNoodlAPI(this.context && this.context.modelScope); + + return { + // Inputs object + Inputs: internal.inputValues, + + // Outputs object (writable) + Outputs: {}, + + // Noodl global APIs + Noodl: noodlAPI, + Variables: noodlAPI.Variables, + Objects: noodlAPI.Objects, + Arrays: noodlAPI.Arrays, + + // Signal sending + sendSignalOnOutput: function (name) { + self.sendSignalOnOutput(name); + }, + + // Convenience alias + this: { + sendSignalOnOutput: function (name) { + self.sendSignalOnOutput(name); + } + }, + + // Trigger signal name (for conditional logic) + __triggerSignal__: triggerSignal + }; + }, + + _compileFunction: function () { + const internal = this._internal; + + if (!internal.workspace) { + return null; + } + + try { + // Generate JavaScript from Blockly workspace + // This will be done in the editor via code generation + // For now, we expect the 'generatedCode' parameter to be set + const code = internal.generatedCode || ''; + + if (!code) { + console.warn('[Logic Builder] No generated code available'); + return null; + } + + // Wrap code in a function + // Code will have access to: Inputs, Outputs, Noodl, Variables, Objects, Arrays, sendSignalOnOutput + const fn = new Function(code); + return fn; + } catch (error) { + console.error('[Logic Builder] Failed to compile function:', error); + return null; + } + } + }, + + getInspectInfo() { + const internal = this._internal; + if (internal.executionError) { + return `Error: ${internal.executionError}`; + } + return 'Logic Builder'; + }, + + inputs: { + workspace: { + group: 'General', + type: { + name: 'string', + allowEditOnly: true + }, + displayName: 'Workspace', + set: function (value) { + const internal = this._internal; + internal.workspace = value; + internal.compiledFunction = null; // Reset compiled function + } + }, + generatedCode: { + group: 'General', + type: 'string', + displayName: 'Generated Code', + set: function (value) { + const internal = this._internal; + internal.generatedCode = value; + internal.compiledFunction = null; // Reset compiled function + } + } + }, + + outputs: { + error: { + group: 'Status', + type: 'string', + displayName: 'Error', + getter: function () { + return this._internal.executionError || ''; + } + } + } +}; + +/** + * Update dynamic ports based on workspace + */ +function updatePorts(nodeId, workspace, editorConnection) { + if (!workspace) { + editorConnection.sendDynamicPorts(nodeId, []); + return; + } + + try { + // Detect I/O from workspace + // This imports the detectIO function from the editor + // In the editor context, this will work; in runtime it's a no-op + const detected = detectIOFromWorkspace(workspace); + + const ports = []; + + // Add detected inputs + detected.inputs.forEach((input) => { + ports.push({ + name: input.name, + type: input.type, + plug: 'input', + group: 'Inputs' + }); + }); + + // Add detected outputs + detected.outputs.forEach((output) => { + ports.push({ + name: output.name, + type: output.type, + plug: 'output', + group: 'Outputs' + }); + }); + + // Add detected signal inputs + detected.signalInputs.forEach((signalName) => { + ports.push({ + name: signalName, + type: 'signal', + plug: 'input', + group: 'Signal Inputs' + }); + }); + + // Add detected signal outputs + detected.signalOutputs.forEach((signalName) => { + ports.push({ + name: signalName, + type: 'signal', + plug: 'output', + group: 'Signal Outputs' + }); + }); + + editorConnection.sendDynamicPorts(nodeId, ports); + } catch (error) { + console.error('[Logic Builder] Failed to update ports:', error); + } +} + +/** + * Detect I/O from workspace + * This is a bridge function that calls the editor's IODetector + */ +function detectIOFromWorkspace() { + // In editor context, this will be replaced with actual detection + // For now, return empty structure + return { + inputs: [], + outputs: [], + signalInputs: [], + signalOutputs: [] + }; +} + +module.exports = { + node: LogicBuilderNode, + setup: function (context, graphModel) { + if (!context.editorConnection || !context.editorConnection.isRunningLocally()) { + return; + } + + graphModel.on('nodeAdded.Logic Builder', function (node) { + if (node.parameters.workspace) { + updatePorts(node.id, node.parameters.workspace, context.editorConnection); + } + + node.on('parameterUpdated', function (event) { + if (event.name === 'workspace') { + updatePorts(node.id, node.parameters.workspace, context.editorConnection); + } + }); + }); + } +};