diff --git a/README.md b/README.md index ac90e6f..6923060 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ The `scratch-analysis` module will return an object containing high-level summar | `scripts` | `count` | | `blocks` | `count`, `unique`, `list`, `frequency` | | `sprites` | `count` | -| `variables` | `count` | +| `variables` | `count`, `id` | +| `cloud` | `count`, `id` | | `lists` | `count` | | `costumes` | `count`, `list`, `hash` | | `sounds` | `count`, `list`, `hash` | diff --git a/lib/sb2.js b/lib/sb2.js index 4831957..229a581 100644 --- a/lib/sb2.js +++ b/lib/sb2.js @@ -92,6 +92,24 @@ const blocks = function (project) { // Storage objects const result = []; + /** + * Determine if a argument is the name of a known cloud variable. + * @param {string} arg Argument (variable name) + * @return {boolean} Is cloud variable? + */ + const isArgCloudVar = function (arg) { + // Validate argument + // @note "Hacked" inputs here could be objects (arrays) + if (typeof arg !== 'string') return false; + + // Iterate over global variables and check to see if arg matches + for (let i in project.variables) { + const variable = project.variables[i]; + if (variable.name === arg && variable.isPersistent) return true; + } + return false; + }; + /** * Walk scripts array(s) and build block list. * @param {array} stack Stack of blocks @@ -108,11 +126,20 @@ const blocks = function (project) { continue; } + // Get opcode and check variable manipulation for the presence of + // cloud variables + let opcode = stack[i][0]; + if (opcode === 'setVar:to:' || opcode === 'changeVar:by:') { + if (isArgCloudVar(stack[i][1])) { + opcode += 'cloud:'; + } + } + // Add to block list - result.push(stack[i][0]); + result.push(opcode); // Don't pull in params from procedures - if (stack[i][0] === 'procDef') continue; + if (opcode === 'procDef') continue; // Move to next item and walk walk(stack[i].slice(1)); @@ -154,6 +181,35 @@ const extensions = function (project) { return result; }; +/** + * Extracts cloud variable information. + * @param {object} project Project object (SB2 format) + * @param {array} names Names of all variables in project + * @return {object} Cloud variable information + */ +const cloud = function (project, names) { + const obj = []; + + // Extract "isPersistent" parameter from all variables in project + const cloudyness = extract(project, 'variables', 'isPersistent').id; + + // Ensure that variable names and isPersistent parameter list are the same + // length + if (names.length !== cloudyness.length) return -1; + + // Iterate over isPersistent values, and extract names of any that are true + for (let i in cloudyness) { + if (cloudyness[i]) { + obj.push(names[i]); + } + } + + return { + count: obj.length, + id: obj + }; +}; + /** * Analyzes a project and returns summary information about the project. * @param {object} project Project object (SB2 format) @@ -171,6 +227,8 @@ module.exports = function (project, callback) { costumes: extract(project, 'costumes', 'costumeName', 'baseLayerMD5') }; + meta.cloud = cloud(project, meta.variables.id); + // Sprites meta.sprites = sprites(project); diff --git a/lib/sb3.js b/lib/sb3.js index b9c910c..811bfd5 100644 --- a/lib/sb3.js +++ b/lib/sb3.js @@ -21,9 +21,14 @@ const variables = function (targets, attribute) { let occurrences = 0; let idList = []; + // Cloud variables are a type of variable + const isCloud = (attribute === 'cloud'); + if (isCloud) attribute = 'variables'; + for (let t in targets) { for (let a in targets[t][attribute]) { const variable = targets[t][attribute][a]; + if (isCloud && (variable.length !== 3 || !variable[2])) continue; occurrences++; idList.push(variable[0]); } @@ -67,10 +72,37 @@ const blocks = function (targets) { // Storage object let result = []; + /** + * Determine if a argument is the name of a known cloud variable. + * @param {string} arg Argument (variable name) + * @return {boolean} Is cloud variable? + */ + const isArgCloudVar = function (arg) { + // Validate argument + if (typeof arg !== 'string') return false; + + // Check first target (stage) to determine if arg is a cloud variable id + const stage = targets[0]; + if (typeof stage.variables[arg] !== 'undefined') { + return stage.variables[arg].length === 3 && stage.variables[arg][2]; + } + }; + + // Iterate over all targets and push block opcodes to storage object for (let t in targets) { for (let a in targets[t].blocks) { const block = targets[t].blocks[a]; - if (!block.shadow) result.push(block.opcode); + + // Get opcode and check variable manipulation for the presence of + // cloud variables + let opcode = block.opcode; + if (opcode === 'data_setvariableto' || opcode === 'data_changevariableby') { + if (isArgCloudVar(block.fields.VARIABLE[1])) { + opcode += '_cloud'; + } + } + + if (!block.shadow) result.push(opcode); } } @@ -97,6 +129,7 @@ module.exports = function (project, callback) { const meta = { scripts: scripts(project.targets), variables: variables(project.targets, 'variables'), + cloud: variables(project.targets, 'cloud'), lists: variables(project.targets, 'lists'), comments: extract(project.targets, 'comments'), sounds: extract(project.targets, 'sounds', 'name', 'md5ext'), diff --git a/package.json b/package.json index 3397c6a..a654aba 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "scratch-analysis", - "version": "1.0.1", + "version": "2.0.0", "description": "Analysis tool for summarizing the structure, composition, and complexity of Scratch programs.", "main": "lib/index.js", "directories": { @@ -17,7 +17,7 @@ "author": "Scratch Foundation", "license": "BSD-3-Clause", "dependencies": { - "scratch-parser": "4.3.3" + "scratch-parser": "4.3.6" }, "devDependencies": { "babel-eslint": "^10.0.1", diff --git a/test/fixtures/sb2/cloud.sb2 b/test/fixtures/sb2/cloud.sb2 new file mode 100644 index 0000000..bcab352 Binary files /dev/null and b/test/fixtures/sb2/cloud.sb2 differ diff --git a/test/fixtures/sb2/cloud_complex.sb2 b/test/fixtures/sb2/cloud_complex.sb2 new file mode 100644 index 0000000..976474e Binary files /dev/null and b/test/fixtures/sb2/cloud_complex.sb2 differ diff --git a/test/fixtures/sb2/cloud_opcodes.sb2 b/test/fixtures/sb2/cloud_opcodes.sb2 new file mode 100644 index 0000000..da1ab6f Binary files /dev/null and b/test/fixtures/sb2/cloud_opcodes.sb2 differ diff --git a/test/fixtures/sb3/cloud.sb3 b/test/fixtures/sb3/cloud.sb3 new file mode 100644 index 0000000..60d584a Binary files /dev/null and b/test/fixtures/sb3/cloud.sb3 differ diff --git a/test/fixtures/sb3/cloud_complex.sb3 b/test/fixtures/sb3/cloud_complex.sb3 new file mode 100644 index 0000000..c6522c9 Binary files /dev/null and b/test/fixtures/sb3/cloud_complex.sb3 differ diff --git a/test/fixtures/sb3/cloud_opcodes.sb3 b/test/fixtures/sb3/cloud_opcodes.sb3 new file mode 100644 index 0000000..77e2158 Binary files /dev/null and b/test/fixtures/sb3/cloud_opcodes.sb3 differ diff --git a/test/unit/cloud.js b/test/unit/cloud.js new file mode 100644 index 0000000..793ad9d --- /dev/null +++ b/test/unit/cloud.js @@ -0,0 +1,79 @@ +const fs = require('fs'); +const path = require('path'); +const test = require('tap').test; +const analysis = require('../../lib/index'); + +const sb2 = fs.readFileSync( + path.resolve(__dirname, '../fixtures/sb2/cloud.sb2') +); +const sb3 = fs.readFileSync( + path.resolve(__dirname, '../fixtures/sb3/cloud.sb3') +); +const sb2Complex = fs.readFileSync( + path.resolve(__dirname, '../fixtures/sb2/cloud_complex.sb2') +); +const sb3Complex = fs.readFileSync( + path.resolve(__dirname, '../fixtures/sb3/cloud_complex.sb3') +); + +test('sb2', t => { + analysis(sb2, (err, result) => { + t.true(typeof err === 'undefined' || err === null); + t.type(result, 'object'); + t.type(result.cloud, 'object'); + t.equals(result.cloud.count, 1); + t.deepEquals(result.cloud.id, ['☁ baz']); + t.end(); + }); +}); + +test('sb3', t => { + analysis(sb3, (err, result) => { + t.true(typeof err === 'undefined' || err === null); + t.type(result, 'object'); + t.type(result.cloud, 'object'); + t.equals(result.cloud.count, 1); + t.deepEquals(result.cloud.id, ['☁ baz']); + t.end(); + }); +}); + +test('sb2 complex', t => { + analysis(sb2Complex, (err, result) => { + t.true(typeof err === 'undefined' || err === null); + t.type(result, 'object'); + t.type(result.cloud, 'object'); + t.equals(result.cloud.count, 8); + t.deepEquals(result.cloud.id, [ + '☁ Player_1', + '☁ Player_2', + '☁ Player_3', + '☁ Player_4', + '☁ Player_5', + '☁ GameData', + '☁ Player_6', + '☁ SAVE_DATA2' + ]); + t.end(); + }); +}); + +test('sb3 complex', t => { + analysis(sb3Complex, (err, result) => { + t.true(typeof err === 'undefined' || err === null); + t.type(result, 'object'); + t.type(result.cloud, 'object'); + t.equals(result.cloud.count, 8); + t.deepEquals(result.cloud.id, [ + '☁ Player_1', + '☁ Player_2', + '☁ Player_3', + '☁ Player_4', + '☁ Player_5', + '☁ GameData', + '☁ Player_6', + '☁ SAVE_DATA2' + ]); + t.end(); + }); +}); diff --git a/test/unit/cloud_opcodes.js b/test/unit/cloud_opcodes.js new file mode 100644 index 0000000..262823b --- /dev/null +++ b/test/unit/cloud_opcodes.js @@ -0,0 +1,59 @@ +const fs = require('fs'); +const path = require('path'); +const test = require('tap').test; +const analysis = require('../../lib/index'); + +const sb2 = fs.readFileSync( + path.resolve(__dirname, '../fixtures/sb2/cloud_opcodes.sb2') +); +const sb3 = fs.readFileSync( + path.resolve(__dirname, '../fixtures/sb3/cloud_opcodes.sb3') +); + +test('sb2', t => { + analysis(sb2, (err, result) => { + t.true(typeof err === 'undefined' || err === null); + t.type(result, 'object'); + t.type(result.blocks, 'object'); + t.type(result.blocks.id, 'object'); + t.deepEquals(result.blocks.id, [ + 'whenGreenFlag', + 'doForever', + 'setVar:to:', + 'randomFrom:to:', + 'changeVar:by:', + 'setVar:to:', + 'randomFrom:to:', + 'changeVar:by:', + 'setVar:to:cloud:', + 'randomFrom:to:', + 'changeVar:by:cloud:', + 'wait:elapsed:from:' + ]); + t.end(); + }); +}); + +test('sb3', t => { + analysis(sb3, (err, result) => { + t.true(typeof err === 'undefined' || err === null); + t.type(result, 'object'); + t.type(result.blocks, 'object'); + t.type(result.blocks.id, 'object'); + t.deepEquals(result.blocks.id, [ + 'event_whenflagclicked', + 'control_forever', + 'control_wait', + 'data_setvariableto', + 'data_setvariableto', + 'data_setvariableto_cloud', + 'operator_random', + 'operator_random', + 'operator_random', + 'data_changevariableby', + 'data_changevariableby', + 'data_changevariableby_cloud' + ]); + t.end(); + }); +});