diff --git a/src/engine/runtime.js b/src/engine/runtime.js index a14f15469..773ef067e 100644 --- a/src/engine/runtime.js +++ b/src/engine/runtime.js @@ -1,9 +1,14 @@ const EventEmitter = require('events'); -const Sequencer = require('./sequencer'); -const Blocks = require('./blocks'); -const Thread = require('./thread'); const {OrderedMap} = require('immutable'); +const ScratchBlocks = require('scratch-blocks'); + +const ArgumentType = require('../extension-support/argument-type'); +const Blocks = require('./blocks'); +const BlockType = require('../extension-support/block-type'); +const Sequencer = require('./sequencer'); +const Thread = require('./thread'); + // Virtual I/O devices. const Clock = require('../io/clock'); const DeviceManager = require('../io/deviceManager'); @@ -24,6 +29,26 @@ const defaultBlockPackages = { scratch3_wedo2: require('../blocks/scratch3_wedo2') }; +/** + * Information used for converting Scratch argument types into scratch-blocks data. + * @type {object.}} + */ +const ArgumentTypeMap = (() => { + const map = {}; + map[ArgumentType.NUMBER] = { + shadowType: 'math_number', + fieldType: 'NUM' + }; + map[ArgumentType.STRING] = { + shadowType: 'text', + fieldType: 'TEXT' + }; + map[ArgumentType.BOOLEAN] = { + shadowType: '' + }; + return map; +})(); + /** * Manages targets, scripts, and the sequencer. * @constructor @@ -75,6 +100,13 @@ class Runtime extends EventEmitter { */ this._primitives = {}; + /** + * Map to look up all block information by extended opcode. + * @type {Object.} + * @private + */ + this._blockInfo = {}; + /** * Map to look up hat blocks' metadata. * Keys are opcode for hat, values are metadata objects. @@ -320,6 +352,125 @@ class Runtime extends EventEmitter { } } + /** + * Register the primitives provided by an extension. + * @param {ExtensionInfo} extensionInfo - information about the extension (id, blocks, etc.) + * @private + */ + _registerExtensionPrimitives (extensionInfo) { + const categoryInfo = { + id: extensionInfo.id, + name: extensionInfo.name, + color1: '#FF6680', + color2: '#FF4D6A', + color3: '#FF3355' + }; + + for (const blockInfo of extensionInfo.blocks) { + const convertedBlock = this._convertForScratchBlocks(blockInfo, categoryInfo); + const opcode = convertedBlock.json.id; + this._blockInfo[opcode] = convertedBlock; + this._primitives[opcode] = convertedBlock.info.func; + } + } + + /** + * Convert BlockInfo into scratch-blocks JSON & XML, and generate a proxy function. + * @param {BlockInfo} blockInfo - the block to convert + * @param {CategoryInfo} categoryInfo - the category for this block + * @returns {{info: BlockInfo, json: object, xml: string}} - the converted & original block information + * @private + */ + _convertForScratchBlocks (blockInfo, categoryInfo) { + const extendedOpcode = `${categoryInfo.id}.${blockInfo.opcode}`; + const blockJSON = { + id: extendedOpcode, + inputsInline: true, + previousStatement: null, // null = available connection; undefined = hat block + nextStatement: null, // null = available connection; undefined = terminal + category: categoryInfo.name, + colour: categoryInfo.color1, + colourSecondary: categoryInfo.color2, + colorTertiary: categoryInfo.color3, + args0: [] + }; + + const inputList = []; + + // TODO: store this somewhere so that we can map args appropriately after translation. + // This maps an arg name to its relative position in the original (usually English) block text. + // When displaying a block in another language we'll need to run a `replace` action similar to the one below, + // but each `[ARG]` will need to be replaced with the number in this map instead of `args0.length`. + const argsMap = {}; + + blockJSON.message0 = blockInfo.text.replace(/\[(.+?)]/g, (match, placeholder) => { + + // Sanitize the placeholder to ensure valid XML + placeholder = placeholder.replace(/[<"&]/, '_'); + + blockJSON.args0.push({ + type: 'input_value', + name: placeholder + }); + + // scratch-blocks uses 1-based argument indexing + const argNum = blockJSON.args0.length; + argsMap[placeholder] = argNum; + + const argInfo = blockInfo.arguments[placeholder] || {}; + const argTypeInfo = ArgumentTypeMap[argInfo.type] || {}; + const defaultValue = (typeof argInfo.defaultValue === 'undefined' ? '' : argInfo.defaultValue.toString()); + inputList.push(`${defaultValue + }` + ); + + return `%${argNum}`; + }); + + switch (blockInfo.blockType) { + case BlockType.COMMAND: + blockJSON.outputShape = ScratchBlocks.OUTPUT_SHAPE_SQUARE; + break; + case BlockType.REPORTER: + blockJSON.output = 'String'; // TODO: distinguish number & string here? + blockJSON.outputShape = ScratchBlocks.OUTPUT_SHAPE_ROUND; + break; + case BlockType.BOOLEAN: + blockJSON.output = 'Boolean'; + blockJSON.outputShape = ScratchBlocks.OUTPUT_SHAPE_HEXAGONAL; + break; + case BlockType.HAT: + blockJSON.outputShape = ScratchBlocks.OUTPUT_SHAPE_SQUARE; + delete blockJSON.previousStatement; + break; + case BlockType.CONDITIONAL: + // Statement inputs get names like 'SUBSTACK', 'SUBSTACK2', 'SUBSTACK3', ... + for (let branchNum = 1; branchNum <= blockInfo.branchCount; ++branchNum) { + blockJSON[`args${branchNum}`] = { + type: 'input_statement', + name: `SUBSTACK${branchNum > 1 ? branchNum : ''}` + }; + } + blockJSON.outputShape = ScratchBlocks.OUTPUT_SHAPE_SQUARE; + break; + } + + if (blockInfo.isTerminal) { + delete blockJSON.nextStatement; + } + + const blockXML = `${inputList.join('')}`; + + return { + info: blockInfo, + json: blockJSON, + xml: blockXML + }; + } + /** * Retrieve the function associated with the given opcode. * @param {!string} opcode The opcode to look up. diff --git a/src/extension-support/extension-manager.js b/src/extension-support/extension-manager.js index 1c01c69f2..a453fb3a8 100644 --- a/src/extension-support/extension-manager.js +++ b/src/extension-support/extension-manager.js @@ -1,9 +1,7 @@ -const centralDispatch = require('../dispatch/central-dispatch'); +const dispatch = require('../dispatch/central-dispatch'); const log = require('../util/log'); -const ArgumentType = require('./argument-type'); const BlockType = require('./block-type'); -const ScratchBlocks = require('scratch-blocks'); /** * @typedef {object} ArgumentInfo - Information about an extension block argument @@ -20,7 +18,7 @@ const ScratchBlocks = require('scratch-blocks'); * @property {Boolean|undefined} isTerminal - true if this block ends a stack (default: false) * @property {Boolean|undefined} blockAllThreads - true if all threads must wait for this block to run (default: false) * @property {object.|undefined} arguments - information about this block's arguments, if any - * @property {string|undefined} func - the method implementing this block on the extension service (default: opcode) + * @property {string|Function|undefined} func - the method for this block on the extension service (default: opcode) * @property {Array.|undefined} filter - the list of targets for which this block should appear (default: all) */ @@ -32,26 +30,6 @@ const ScratchBlocks = require('scratch-blocks'); * @property {string} color3 - the tertiary color for this category, in '#rrggbb' format */ -/** - * Information used for converting Scratch argument types into scratch-blocks data. - * @type {object.}} - */ -const ArgumentTypeMap = (() => { - const map = {}; - map[ArgumentType.NUMBER] = { - shadowType: 'math_number', - fieldType: 'NUM' - }; - map[ArgumentType.STRING] = { - shadowType: 'text', - fieldType: 'TEXT' - }; - map[ArgumentType.BOOLEAN] = { - shadowType: '' - }; - return map; -})(); - class ExtensionManager { constructor () { /** @@ -72,7 +50,9 @@ class ExtensionManager { */ this.pendingExtensionURLs = []; - centralDispatch.setService('extensions', this); + dispatch.setService('extensions', this).catch(e => { + log.error(`ExtensionManager was unable to register extension service: ${JSON.stringify(e)}`); + }); } foo () { @@ -84,7 +64,7 @@ class ExtensionManager { const ExtensionWorker = require('worker-loader!./extension-worker'); this.pendingExtensionURLs.push(extensionURL); - centralDispatch.addWorker(new ExtensionWorker()); + dispatch.addWorker(new ExtensionWorker()); } allocateWorker () { @@ -94,29 +74,16 @@ class ExtensionManager { } registerExtensionService (serviceName) { - centralDispatch.call(serviceName, 'getInfo').then(info => { + dispatch.call(serviceName, 'getInfo').then(info => { this._registerExtensionInfo(serviceName, info); }); } _registerExtensionInfo (serviceName, extensionInfo) { - const categoryInfo = { - id: extensionInfo.id, - name: extensionInfo.name, - color1: '#FF6680', - color2: '#FF4D6A', - color3: '#FF3355' - }; - extensionInfo = this._sanitizeExtensionInfo(extensionInfo); - for (let blockInfo of extensionInfo.blocks) { - if (!(blockInfo.opcode && blockInfo.text)) { - log.error(`Ignoring malformed extension block: ${JSON.stringify(blockInfo)}`); - continue; - } - blockInfo = this._sanitizeBlockInfo(blockInfo); - const convertedBlock = this._convertForScratchBlocks(blockInfo, serviceName, categoryInfo); - console.dir(convertedBlock); - } + extensionInfo = this._prepareExtensionInfo(serviceName, extensionInfo); + dispatch.call('runtime', '_registerExtensionPrimitives', extensionInfo).catch(e => { + log.error(`Failed to register primitives for extension on service ${serviceName}: ${JSON.stringify(e)}`); + }); } /** @@ -132,26 +99,37 @@ class ExtensionManager { /** * Apply minor cleanup and defaults for optional extension fields. * TODO: make the ID unique in cases where two copies of the same extension are loaded. + * @param {string} serviceName - the name of the service hosting this extension block * @param {ExtensionInfo} extensionInfo - the extension info to be sanitized * @returns {ExtensionInfo} - a new extension info object with cleaned-up values * @private */ - _sanitizeExtensionInfo (extensionInfo) { + _prepareExtensionInfo (serviceName, extensionInfo) { extensionInfo = Object.assign({}, extensionInfo); extensionInfo.id = this._sanitizeID(extensionInfo.id); extensionInfo.name = extensionInfo.name || extensionInfo.id; extensionInfo.blocks = extensionInfo.blocks || []; extensionInfo.targetTypes = extensionInfo.targetTypes || []; + extensionInfo.blocks = extensionInfo.blocks.reduce((result, blockInfo) => { + try { + result.push(this._prepareBlockInfo(serviceName, blockInfo)); + } catch (e) { + // TODO: more meaningful error reporting + log.error(`Skipping malformed block: ${e}`); + } + return result; + }, []); return extensionInfo; } /** * Apply defaults for optional block fields. + * @param {string} serviceName - the name of the service hosting this extension block * @param {BlockInfo} blockInfo - the block info from the extension * @returns {BlockInfo} - a new block info object which has values for all relevant optional fields. * @private */ - _sanitizeBlockInfo (blockInfo) { + _prepareBlockInfo (serviceName, blockInfo) { blockInfo = Object.assign({}, { blockType: BlockType.COMMAND, terminal: false, @@ -160,120 +138,9 @@ class ExtensionManager { }, blockInfo); blockInfo.opcode = this._sanitizeID(blockInfo.opcode); blockInfo.func = blockInfo.func ? this._sanitizeID(blockInfo.func) : blockInfo.opcode; + blockInfo.func = dispatch.call.bind(dispatch, serviceName, blockInfo.func); return blockInfo; } - - /** - * Convert BlockInfo into scratch-blocks JSON & XML, and generate a proxy function. - * @param {BlockInfo} blockInfo - the block to convert - * @param {string} serviceName - the name of the service hosting this extension - * @param {CategoryInfo} categoryInfo - the category for this block - * @returns {{json: object, xml: string, blockFunction: Function}} - the converted block information - * @private - */ - _convertForScratchBlocks (blockInfo, serviceName, categoryInfo) { - const extendedOpcode = `${categoryInfo.id}.${blockInfo.opcode}`; - const blockJSON = { - id: extendedOpcode, - inputsInline: true, - previousStatement: null, // null = available connection; undefined = hat block - nextStatement: null, // null = available connection; undefined = terminal - category: categoryInfo.name, - colour: categoryInfo.color1, - colourSecondary: categoryInfo.color2, - colorTertiary: categoryInfo.color3, - args0: [] - }; - - const inputList = []; - - // TODO: store this somewhere so that we can map args appropriately after translation. - // This maps an arg name to its relative position in the original (usually English) block text. - // When displaying a block in another language we'll need to run a `replace` action similar to the one below, - // but each `[ARG]` will need to be replaced with the number in this map instead of `args0.length`. - const argsMap = {}; - - blockJSON.message0 = blockInfo.text.replace(/\[(.+?)]/g, (match, placeholder) => { - - // Sanitize the placeholder to ensure valid XML - placeholder = placeholder.replace(/[<"&]/, '_'); - - blockJSON.args0.push({ - type: 'input_value', - name: placeholder - }); - - // scratch-blocks uses 1-based argument indexing - const argNum = blockJSON.args0.length; - argsMap[placeholder] = argNum; - - const argInfo = blockInfo.arguments[placeholder] || {}; - const argTypeInfo = ArgumentTypeMap[argInfo.type] || {}; - const defaultValue = (typeof argInfo.defaultValue === 'undefined' ? '' : argInfo.defaultValue.toString()); - inputList.push( - `` + - `` + - `${defaultValue}` + - `` + - `` - ); - - return `%${argNum}`; - }); - - switch (blockInfo.blockType) { - case BlockType.COMMAND: - blockJSON.outputShape = ScratchBlocks.OUTPUT_SHAPE_SQUARE; - break; - case BlockType.REPORTER: - blockJSON.output = 'String'; // TODO: distinguish number & string here? - blockJSON.outputShape = ScratchBlocks.OUTPUT_SHAPE_ROUND; - break; - case BlockType.BOOLEAN: - blockJSON.output = 'Boolean'; - blockJSON.outputShape = ScratchBlocks.OUTPUT_SHAPE_HEXAGONAL; - break; - case BlockType.HAT: - blockJSON.outputShape = ScratchBlocks.OUTPUT_SHAPE_SQUARE; - delete blockJSON.previousStatement; - break; - case BlockType.CONDITIONAL: - // Statement inputs get names like 'SUBSTACK', 'SUBSTACK2', 'SUBSTACK3', ... - for (let branchNum = 1; branchNum <= blockInfo.branchCount; ++branchNum) { - blockJSON[`args${branchNum}`] = { - type: 'input_statement', - name: `SUBSTACK${branchNum > 1 ? branchNum : ''}` - }; - } - blockJSON.outputShape = ScratchBlocks.OUTPUT_SHAPE_SQUARE; - break; - } - - if (blockInfo.isTerminal) { - delete blockJSON.nextStatement; - } - - const blockXML = `${inputList.join('')}`; - - return { - json: blockJSON, - xml: blockXML, - blockFunction: this._extensionProxy.bind(this, serviceName, blockInfo.opcode) - }; - } - - /** - * Run an opcode by proxying the call to an extension service. - * @param {string} serviceName - the name of the service hosting the extension - * @param {string} opcode - the opcode to run, also the name of the method on the extension service - * @param {object} blockArgs - the arguments provided to the block - * @returns {Promise} - a promise which will resolve after the block function executes. If the block function - * returns a value, this promise will resolve to that value. - * @private - */ - _extensionProxy (serviceName, opcode, blockArgs) { - return centralDispatch.call(serviceName, opcode, blockArgs); - } } module.exports = ExtensionManager; diff --git a/src/extensions/example-extension.js b/src/extensions/example-extension.js index e983808f7..fa585f5e1 100644 --- a/src/extensions/example-extension.js +++ b/src/extensions/example-extension.js @@ -5,7 +5,7 @@ class ExampleExtension { getInfo () { return { // Required: the machine-readable name of this extension. - // Will be used as the extension's namespace. + // Will be used as the extension's namespace. Must not contain a '.' character. id: 'someBlocks', // Optional: the human-readable name of this extension as string. @@ -35,7 +35,7 @@ class ExampleExtension { blocks: [ { // Required: the machine-readable name of this operation. - // This will appear in project JSON. + // This will appear in project JSON. Must not contain a '.' character. opcode: 'myReporter', // becomes 'someBlocks.myReporter' // Required: the kind of block we're defining, from a predefined list: diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 61afda386..8334b3092 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -1,5 +1,6 @@ const EventEmitter = require('events'); +const centralDispatch = require('./dispatch/central-dispatch'); const log = require('./util/log'); const Runtime = require('./engine/runtime'); const sb2 = require('./serialization/sb2'); @@ -24,6 +25,10 @@ class VirtualMachine extends EventEmitter { * @type {!Runtime} */ this.runtime = new Runtime(); + centralDispatch.setService('runtime', this.runtime).catch(e => { + log.error(`Failed to register runtime service: ${JSON.stringify(e)}`); + }); + /** * The "currently editing"/selected target ID for the VM. * Block events from any Blockly workspace are routed to this target. @@ -131,6 +136,13 @@ class VirtualMachine extends EventEmitter { }); } + /** + * Get the categorized list of all blocks currently available in the VM. + */ + getBlocks () { + + } + /** * Post I/O data to the virtual devices. * @param {?string} device Name of virtual I/O device.