diff --git a/src/serialization/sb2.js b/src/serialization/sb2.js index 5225614a4..b5b8030b1 100644 --- a/src/serialization/sb2.js +++ b/src/serialization/sb2.js @@ -78,15 +78,16 @@ const flatten = function (blocks) { * or a list of blocks in an argument (e.g., move [pick random...]). * @param {Array.} blockList SB2 JSON-format block list. * @param {Function} getVariableId function to retreive a variable's ID based on name + * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. * @return {Array.} Scratch VM-format block list. */ -const parseBlockList = function (blockList, getVariableId) { +const parseBlockList = function (blockList, getVariableId, extensions) { const resultingList = []; let previousBlock = null; // For setting next. for (let i = 0; i < blockList.length; i++) { const block = blockList[i]; // eslint-disable-next-line no-use-before-define - const parsedBlock = parseBlock(block, getVariableId); + const parsedBlock = parseBlock(block, getVariableId, extensions); if (typeof parsedBlock === 'undefined') continue; if (previousBlock) { parsedBlock.parent = previousBlock.id; @@ -104,14 +105,15 @@ const parseBlockList = function (blockList, getVariableId) { * @param {!object} scripts Scripts object from SB2 JSON. * @param {!Blocks} blocks Blocks object to load parsed blocks into. * @param {Function} getVariableId function to retreive a variable's ID based on name + * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. */ -const parseScripts = function (scripts, blocks, getVariableId) { +const parseScripts = function (scripts, blocks, getVariableId, extensions) { for (let i = 0; i < scripts.length; i++) { const script = scripts[i]; const scriptX = script[0]; const scriptY = script[1]; const blockList = script[2]; - const parsedBlockList = parseBlockList(blockList, getVariableId); + const parsedBlockList = parseBlockList(blockList, getVariableId, extensions); if (parsedBlockList[0]) { // Adjust script coordinates to account for // larger block size in scratch-blocks. @@ -155,12 +157,14 @@ const generateVariableIdGetter = (function () { /** * Parse a single "Scratch object" and create all its in-memory VM objects. - * @param {!object} object From-JSON "Scratch object:" sprite, stage, watcher. - * @param {!Runtime} runtime Runtime object to load all structures into. - * @param {boolean} topLevel Whether this is the top-level object (stage). - * @return {?Promise} Promise that resolves to the loaded targets when ready. + * TODO: parse the "info" section, especially "savedExtensions" + * @param {!object} object - From-JSON "Scratch object:" sprite, stage, watcher. + * @param {!Runtime} runtime - Runtime object to load all structures into. + * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. + * @param {boolean} topLevel - Whether this is the top-level object (stage). + * @return {!Promise.>} Promise for the loaded targets when ready, or null for unsupported objects. */ -const parseScratchObject = function (object, runtime, topLevel) { +const parseScratchObject = function (object, runtime, extensions, topLevel) { if (!object.hasOwnProperty('objName')) { // Watcher/monitor - skip this object until those are implemented in VM. // @todo @@ -228,7 +232,7 @@ const parseScratchObject = function (object, runtime, topLevel) { // If included, parse any and all scripts/blocks on the object. if (object.hasOwnProperty('scripts')) { - parseScripts(object.scripts, blocks, getVariableId); + parseScripts(object.scripts, blocks, getVariableId, extensions); } if (object.hasOwnProperty('lists')) { @@ -287,7 +291,7 @@ const parseScratchObject = function (object, runtime, topLevel) { const childrenPromises = []; if (object.children) { for (let m = 0; m < object.children.length; m++) { - childrenPromises.push(parseScratchObject(object.children[m], runtime, false)); + childrenPromises.push(parseScratchObject(object.children[m], runtime, extensions, false)); } } @@ -312,31 +316,42 @@ const parseScratchObject = function (object, runtime, topLevel) { * @param {!object} json SB2-format JSON to load. * @param {!Runtime} runtime Runtime object to load all structures into. * @param {boolean=} optForceSprite If set, treat as sprite (Sprite2). - * @return {?Promise} Promise that resolves to the loaded targets when ready. + * @return {Promise.} Promise that resolves to the loaded targets when ready. */ const sb2import = function (json, runtime, optForceSprite) { - return parseScratchObject( - json, - runtime, - !optForceSprite - ); + const extensions = { + extensionIDs: new Set(), + extensionURLs: new Map() + }; + return parseScratchObject(json, runtime, extensions, !optForceSprite) + .then(targets => ({ + targets, + extensions + })); }; /** * Parse a single SB2 JSON-formatted block and its children. * @param {!object} sb2block SB2 JSON-formatted block. - * @param {Function} getVariableId function to retreive a variable's ID based on name - * @return {object} Scratch VM format block. + * @param {Function} getVariableId function to retrieve a variable's ID based on name + * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. + * @return {object} Scratch VM format block, or null if unsupported object. */ -const parseBlock = function (sb2block, getVariableId) { +const parseBlock = function (sb2block, getVariableId, extensions) { // First item in block object is the old opcode (e.g., 'forward:'). const oldOpcode = sb2block[0]; // Convert the block using the specMap. See sb2specmap.js. if (!oldOpcode || !specMap[oldOpcode]) { log.warn('Couldn\'t find SB2 block: ', oldOpcode); - return; + return null; } const blockMetadata = specMap[oldOpcode]; + // If the block is from an extension, record it. + const dotIndex = blockMetadata.opcode.indexOf('.'); + if (dotIndex >= 0) { + const extension = blockMetadata.opcode.substring(0, dotIndex); + extensions.extensionIDs.add(extension); + } // Block skeleton. const activeBlock = { id: uid(), // Generate a new block unique ID. @@ -373,10 +388,10 @@ const parseBlock = function (sb2block, getVariableId) { let innerBlocks; if (typeof providedArg[0] === 'object' && providedArg[0]) { // Block list occupies the input. - innerBlocks = parseBlockList(providedArg, getVariableId); + innerBlocks = parseBlockList(providedArg, getVariableId, extensions); } else { // Single block occupies the input. - innerBlocks = [parseBlock(providedArg, getVariableId)]; + innerBlocks = [parseBlock(providedArg, getVariableId, extensions)]; } let previousBlock = null; for (let j = 0; j < innerBlocks.length; j++) { diff --git a/src/serialization/sb2_specmap.js b/src/serialization/sb2_specmap.js index 1b697934e..6257bc1d1 100644 --- a/src/serialization/sb2_specmap.js +++ b/src/serialization/sb2_specmap.js @@ -21,6 +21,24 @@ * properties. By hand, I matched the opcode name to the 3.0 opcode. * Finally, I filled in the expected arguments as below. */ + +/** + * @typedef {object} SB2SpecMap_blockInfo + * @property {string} opcode - the Scratch 3.0 block opcode. Use 'extensionID.opcode' for extension opcodes. + * @property {Array.} argMap - metadata for this block's arguments. + */ + +/** + * @typedef {object} SB2SpecMap_argInfo + * @property {string} type - the type of this arg (such as 'input' or 'field') + * @property {string} inputOp - the scratch-blocks shadow type for this arg + * @property {string} inputName - the name this argument will take when provided to the block implementation + */ + +/** + * Mapping of Scratch 2.0 opcode to Scratch 3.0 block metadata. + * @type {object.} + */ const specMap = { 'forward:': { opcode: 'motion_movesteps', @@ -1376,4 +1394,179 @@ const specMap = { argMap: [] } }; + +/** + * Add to the specMap entries for an opcode from a Scratch 2.0 extension. Two entries will be made with the same + * metadata; this is done to support projects saved by both older and newer versions of the Scratch 2.0 editor. + * @param {string} sb2Extension - the Scratch 2.0 name of the extension + * @param {string} sb2Opcode - the Scratch 2.0 opcode + * @param {SB2SpecMap_blockInfo} blockInfo - the Scratch 3.0 block info + */ +const addExtensionOp = function (sb2Extension, sb2Opcode, blockInfo) { + /** + * This string separates the name of an extension and the name of an opcode in more recent Scratch 2.0 projects. + * Earlier projects used '.' as a separator, up until we added the 'LEGO WeDo 2.0' extension... + * @type {string} + */ + const sep = '\u001F'; // Unicode Unit Separator + + // make one entry for projects saved by recent versions of the Scratch 2.0 editor + specMap[`${sb2Extension}${sep}${sb2Opcode}`] = blockInfo; + + // make a second for projects saved by older versions of the Scratch 2.0 editor + specMap[`${sb2Extension}.${sb2Opcode}`] = blockInfo; +}; + +const weDo2 = 'LEGO WeDo 2.0'; + +addExtensionOp(weDo2, 'motorOnFor', { + opcode: 'wedo2.motorOnFor', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'MOTOR_ID' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'DURATION' + } + ] +}); + +addExtensionOp(weDo2, 'motorOn', { + opcode: 'wedo2.motorOn', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'MOTOR_ID' + } + ] +}); + +addExtensionOp(weDo2, 'motorOff', { + opcode: 'wedo2.motorOff', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'MOTOR_ID' + } + ] +}); + +addExtensionOp(weDo2, 'startMotorPower', { + opcode: 'wedo2.startMotorPower', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'MOTOR_ID' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'POWER' + } + ] +}); + +addExtensionOp(weDo2, 'setMotorDirection', { + opcode: 'wedo2.setMotorDirection', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'MOTOR_ID' + }, + { + type: 'input', + inputOp: 'text', + inputName: 'DIRECTION' + } + ] +}); + +addExtensionOp(weDo2, 'setLED', { + opcode: 'wedo2.setLightHue', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'HUE' + } + ] +}); + +addExtensionOp(weDo2, 'playNote', { + opcode: 'wedo2.playNoteFor', + argMap: [ + { + type: 'input', + inputOp: 'math_number', + inputName: 'NOTE' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'DURATION' + } + ] +}); + +addExtensionOp(weDo2, 'whenDistance', { + opcode: 'wedo2.whenDistance', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'OP' + }, + { + type: 'input', + inputOp: 'math_number', + inputName: 'REFERENCE' + } + ] +}); + +addExtensionOp(weDo2, 'whenTilted', { + opcode: 'wedo2.whenTilted', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'DIRECTION' + } + ] +}); + +addExtensionOp(weDo2, 'getDistance', { + opcode: 'wedo2.motorOn' +}); + +addExtensionOp(weDo2, 'isTilted', { + opcode: 'wedo2.motorOn', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'DIRECTION' + } + ] +}); + +addExtensionOp(weDo2, 'getTilt', { + opcode: 'getTiltAngle', + argMap: [ + { + type: 'input', + inputOp: 'text', + inputName: 'DIRECTION' + } + ] +}); + module.exports = specMap; diff --git a/src/serialization/sb3.js b/src/serialization/sb3.js index d73a472f7..13bdd2e72 100644 --- a/src/serialization/sb3.js +++ b/src/serialization/sb3.js @@ -13,6 +13,18 @@ const List = require('../engine/list'); const {loadCostume} = require('../import/load-costume.js'); const {loadSound} = require('../import/load-sound.js'); +/** + * @typedef {object} ImportedProject + * @property {Array.} targets - the imported Scratch 3.0 target objects. + * @property {ImportedExtensionsInfo} extensionsInfo - the ID of each extension actually used by this project. + */ + +/** + * @typedef {object} ImportedExtensionsInfo + * @property {Set.} extensionIDs - the ID of each extension actually in use by blocks in this project. + * @property {Map.} extensionURLs - map of ID => URL from project metadata. May not match extensionIDs. + */ + /** * Serializes the specified VM runtime. * @param {!Runtime} runtime VM runtime instance to be serialized. @@ -41,13 +53,14 @@ const serialize = function (runtime) { * Parse a single "Scratch object" and create all its in-memory VM objects. * @param {!object} object From-JSON "Scratch object:" sprite, stage, watcher. * @param {!Runtime} runtime Runtime object to load all structures into. - * @return {?Target} Target created (stage or sprite). + * @param {ImportedExtensionsInfo} extensions - (in/out) parsed extension information will be stored here. + * @return {!Promise.} Promise for the target created (stage or sprite), or null for unsupported objects. */ -const parseScratchObject = function (object, runtime) { +const parseScratchObject = function (object, runtime, extensions) { if (!object.hasOwnProperty('name')) { // Watcher/monitor - skip this object until those are implemented in VM. // @todo - return; + return Promise.resolve(null); } // Blocks container for this object. const blocks = new Blocks(); @@ -61,7 +74,14 @@ const parseScratchObject = function (object, runtime) { } if (object.hasOwnProperty('blocks')) { for (const blockId in object.blocks) { - blocks.createBlock(object.blocks[blockId]); + const blockJSON = object.blockType[blockId]; + blocks.createBlock(blockJSON); + + const dotIndex = blockJSON.opcode.indexOf('.'); + if (dotIndex >= 0) { + const extensionId = blockJSON.opcode.substring(0, dotIndex); + extensions.extensionIDs.add(extensionId); + } } // console.log(blocks); } @@ -155,14 +175,23 @@ const parseScratchObject = function (object, runtime) { }; /** - * Deserializes the specified representation of a VM runtime and loads it into - * the provided runtime instance. - * @param {object} json JSON representation of a VM runtime. - * @param {Runtime} runtime Runtime instance - * @returns {Promise} Promise that resolves to the list of targets after the project is deserialized + * Deserialize the specified representation of a VM runtime and loads it into the provided runtime instance. + * TODO: parse extension info (also, design extension info storage...) + * @param {object} json - JSON representation of a VM runtime. + * @param {Runtime} runtime - Runtime instance + * @returns {Promise.} Promise that resolves to the list of targets after the project is deserialized */ const deserialize = function (json, runtime) { - return Promise.all((json.targets || []).map(target => parseScratchObject(target, runtime))); + const extensions = { + extensionIDs: new Set(), + extensionURLs: new Map() + }; + return Promise.all( + (json.targets || []).map(target => parseScratchObject(target, runtime, extensions)) + ).then(targets => ({ + targets, + extensions + })); }; module.exports = { diff --git a/src/virtual-machine.js b/src/virtual-machine.js index 6f0658414..a1dac6089 100644 --- a/src/virtual-machine.js +++ b/src/virtual-machine.js @@ -33,7 +33,7 @@ class VirtualMachine extends EventEmitter { /** * The "currently editing"/selected target ID for the VM. * Block events from any Blockly workspace are routed to this target. - * @type {!string} + * @type {Target} */ this.editingTarget = null; // Runtime emits are passed along as VM emits. @@ -228,19 +228,41 @@ class VirtualMachine extends EventEmitter { deserializer = sb2; } - return deserializer.deserialize(json, this.runtime).then(targets => { - this.clear(); - for (let n = 0; n < targets.length; n++) { - if (targets[n] !== null) { - this.runtime.targets.push(targets[n]); - targets[n].updateAllDrawableProperties(); - } + return deserializer.deserialize(json, this.runtime) + .then(({targets, extensions}) => + this.installTargets(targets, extensions, true)); + } + + /** + * Install `deserialize` results: zero or more targets after the extensions (if any) used by those targets. + * @param {Array.} targets - the targets to be installed + * @param {ImportedExtensionsInfo} extensions - metadata about extensions used by these targets + * @param {boolean} wholeProject - set to true if installing a whole project, as opposed to a single sprite. + */ + installTargets (targets, extensions, wholeProject) { + const extensionPromises = []; + extensions.extensionIDs.forEach(extensionID => { + if (!this.extensionManager.isExtensionLoaded(extensionID)) { + const extensionURL = extensions.extensionURLs.get(extensionID) || extensionID; + extensionPromises.push(this.extensionManager.loadExtensionURL(extensionURL)); } + }); + + targets = targets.filter(target => !!target); + + Promise.all(extensionPromises).then(() => { + if (wholeProject) { + this.clear(); + } + targets.forEach(target => { + this.runtime.targets.push(target); + (/** @type RenderedTarget */ target).updateAllDrawableProperties(); + }); // Select the first target for editing, e.g., the first sprite. - if (this.runtime.targets.length > 1) { - this.editingTarget = this.runtime.targets[1]; + if (wholeProject && (targets.length > 1)) { + this.editingTarget = targets[1]; } else { - this.editingTarget = this.runtime.targets[0]; + this.editingTarget = targets[0]; } // Update the VM user's knowledge of targets and blocks on the workspace. @@ -267,17 +289,9 @@ class VirtualMachine extends EventEmitter { return; } - // Select new sprite. - return sb2.deserialize(json, this.runtime, true).then(targets => { - this.runtime.targets.push(targets[0]); - this.editingTarget = targets[0]; - this.editingTarget.updateAllDrawableProperties(); - - // Update the VM user's knowledge of targets and blocks on the workspace. - this.emitTargetsUpdate(); - this.emitWorkspaceUpdate(); - this.runtime.setEditingTarget(this.editingTarget); - }); + return sb2.deserialize(json, this.runtime, true) + .then(({targets, extensions}) => + this.installTargets(targets, extensions, false)); } /**