@turbowarp/sb3fix 0.1.1 → 0.2.1

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.
Files changed (3) hide show
  1. package/README.md +32 -15
  2. package/package.json +1 -2
  3. package/src/sb3fix.js +227 -212
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  https://turbowarp.github.io/sb3fix/
4
4
 
5
- Fix corrupted Scratch projects.
5
+ Fix corrupted Scratch 3 projects.
6
6
 
7
7
  ## API
8
8
 
@@ -19,20 +19,37 @@ const sb3fix = require('@turbowarp/sb3fix');
19
19
  const fs = require('fs');
20
20
 
21
21
  const run = async () => {
22
- const projectData = fs.readFileSync('your-broken-project.sb3');
23
- const result = await sb3fix(projectData); // projectData can be ArrayBuffer, Uint8Array-like, Blob, or File
24
-
25
- console.log(result);
26
-
27
- const success = result.success; // success is boolean
28
- if (success) {
29
- const fixedZip = result.fixedZip; // fixedZip is ArrayBuffer
30
- const log = result.log; // log is Array of strings
31
- } else {
32
- const error = result.error; // error is any type (probably an Error, but not necessarily)
33
- }
34
-
35
- // sb3fix is deterministic: the same input project always produces the same output
22
+ // Fix a .sb3 or .sprite3 with sb3fix.fixZip()
23
+ // Input can be an ArrayBuffer, Uint8Array, Blob, File, or Node.js Buffer.
24
+ // Output will be Uint8Array.
25
+ // This method returns a Promise. If there is an error, that promise will reject.
26
+ const brokenZip = fs.readFileSync('your-broken-project.sb3');
27
+ const fixedZip = await sb3fix.fixZip(brokenZip);
28
+ console.log(fixedZip);
29
+
30
+ // Fix just a project.json or sprite.json with sb3fix.fixJSON()
31
+ // Input can be a parsed project.json object or a parsed sprite.json object or a string.
32
+ // If the input is an object, that object will be modified in-place instead of being copied.
33
+ // Output will be a parsed project.json object or a parsed sprite.json object depending on input.
34
+ // This method is NOT async. If there is an error, a plain JavaScript error will be thrown.
35
+ const brokenJSON = fs.readFileSync('your-broken-project.json', 'utf-8');
36
+ const fixedJSON = sb3fix.fixJSON(brokenJSON);
37
+ console.log(fixedJSON);
38
+
39
+ // sb3fix is deterministic. The same input will always give the same output, bit-for-bit.
40
+
41
+ // Both sb3fix methods take in an optional options object.
42
+ const options = {
43
+ // When sb3fix runs, it'll log what it's doing and what it's found. You can monitor those
44
+ // using this callback. These messages are primarily a debugging tool, so the exact output
45
+ // is not considered part of the API. It may change without warning.
46
+ logCallback: (message) => {
47
+ console.log(message);
48
+ }
49
+ };
50
+ // To use the above options, just supply as the second argument when you call sb3fix:
51
+ await sb3fix.fixZip(brokenZip, options);
52
+ sb3fix.fixJSON(brokenJSON, options);
36
53
  };
37
54
 
38
55
  run();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@turbowarp/sb3fix",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Fix corrupted Scratch projects",
5
5
  "main": "src/sb3fix.js",
6
6
  "scripts": {
@@ -24,7 +24,6 @@
24
24
  },
25
25
  "devDependencies": {
26
26
  "copy-webpack-plugin": "^12.0.2",
27
- "rimraf": "^5.0.5",
28
27
  "webpack": "^5.90.3",
29
28
  "webpack-cli": "^5.1.4"
30
29
  }
package/src/sb3fix.js CHANGED
@@ -8,7 +8,10 @@ License, v. 2.0. If a copy of the MPL was not distributed with this
8
8
  file, You can obtain one at https://mozilla.org/MPL/2.0/.
9
9
  */
10
10
 
11
- const JSZip = require('jszip');
11
+ /**
12
+ * @typedef Options
13
+ * @property {(message: string) => void} [logCallback]
14
+ */
12
15
 
13
16
  /**
14
17
  * @param {unknown} obj
@@ -42,102 +45,158 @@ const BUILTIN_EXTENSIONS = [
42
45
  ];
43
46
 
44
47
  /**
45
- * @param {ArrayBuffer|Uint8Array|Blob} data
46
- * @returns {Promise<{success: boolean; fixedZip: ArrayBuffer; log: string[]; error?: unknown;}>} fixed compressed sb3
48
+ * @param {object} project Parsed project.json.
49
+ * @returns {Set<string>} Set of valid extensions, including the primitive ones, that the project loads.
50
+ */
51
+ const getKnownExtensions = (project) => {
52
+ const extensions = project.extensions;
53
+ if (!Array.isArray(extensions)) {
54
+ throw new Error('extensions is not an array');
55
+ }
56
+ for (let i = 0; i < extensions.length; i++) {
57
+ if (typeof extensions[i] !== 'string') {
58
+ throw new Error(`extension ${i} is not a string`);
59
+ }
60
+ }
61
+ return new Set([
62
+ ...BUILTIN_EXTENSIONS,
63
+ ...extensions
64
+ ]);
65
+ };
66
+
67
+ /**
68
+ * @param {string|object} data project.json as a string or as a parsed object already. If object provided, it will be modified in-place.
69
+ * @param {Options} [options]
70
+ * @returns {object} Fixed project.json object. If an object was provided as `data`, the return value will be `data`.
47
71
  */
48
- const sb3fix = async (data) => {
49
- const logMessages = [];
72
+ const fixJSON = (data, options = {}) => {
50
73
  /**
51
74
  * @param {string} message
52
75
  */
53
76
  const log = (message) => {
54
- console.log(message);
55
- logMessages.push(message);
77
+ if (options.logCallback) {
78
+ options.logCallback(message);
79
+ }
56
80
  };
57
81
 
58
82
  /**
59
- * @returns {Set<string>}
83
+ * @param {string} id
84
+ * @param {unknown} variable
60
85
  */
61
- const getKnownExtensions = (project) => {
62
- const extensions = project.extensions;
63
- if (!Array.isArray(extensions)) {
64
- throw new Error('extensions is not an array');
65
- }
66
- for (let i = 0; i < extensions.length; i++) {
67
- if (typeof extensions[i] !== 'string') {
68
- throw new Error(`extension ${i} is not a string`);
69
- }
86
+ const fixVariableInPlace = (id, variable) => {
87
+ if (!Array.isArray(variable)) {
88
+ throw new Error(`variable object ${id} is not an array`);
89
+ }
90
+
91
+ const name = variable[0];
92
+ if (typeof name !== 'string') {
93
+ log(`variable or list ${id} name was not a string`);
94
+ variable[0] = String(variable[0]);
95
+ }
96
+
97
+ const value = variable[1];
98
+ if (typeof value !== 'number' && typeof value !== 'string' && typeof value !== 'boolean') {
99
+ log(`variable ${id} value was not a Scratch-compatible value`);
100
+ variable[1] = String(variable[1]);
70
101
  }
71
- return new Set([
72
- ...BUILTIN_EXTENSIONS,
73
- ...extensions
74
- ]);
75
102
  };
76
103
 
77
104
  /**
78
- * @param {unknown} project
105
+ * @param {string} id
106
+ * @param {unknown} list
79
107
  */
80
- const fixProjectInPlace = (project) => {
81
- if ('objName' in project) {
82
- throw new Error('Scratch 2 (sb2) projects not supported');
108
+ const fixListInPlace = (id, list) => {
109
+ if (!Array.isArray(list)) {
110
+ throw new Error(`list object ${id} is not an array`);
83
111
  }
84
112
 
85
- if (!isObject(project)) {
86
- throw new Error('project.json is not an object');
113
+ const name = list[0];
114
+ if (typeof name !== 'string') {
115
+ log(`list ${id} name was not a string`);
116
+ list[0] = String(list[0]);
87
117
  }
88
118
 
89
- const targets = project.targets;
90
- if (!Array.isArray(targets)) {
91
- throw new Error('targets is not an array');
92
- }
93
- if (targets.length < 1) {
94
- throw new Error('targets is empty');
119
+ if (!Array.isArray(list[1])) {
120
+ log(`list ${id} value was not an array`);
121
+ list[1] = [];
95
122
  }
96
- for (let i = 0; i < targets.length; i++) {
97
- log(`checking target ${i}`);
98
- const target = targets[i];
99
- if (!isObject(target)) {
100
- throw new Error('target is not an object');
123
+
124
+ const listValue = list[1];
125
+ for (let i = 0; i < listValue.length; i++) {
126
+ const value = listValue[i];
127
+ if (typeof value !== 'number' && typeof value !== 'string' && typeof value !== 'boolean') {
128
+ log(`list ${id} index ${i} was not a Scratch-compatible value`);
129
+ listValue[i] = String(value);
101
130
  }
102
- fixTargetInPlace(target);
103
131
  }
132
+ };
104
133
 
105
- const allStages = targets.filter((target) => target.isStage);
106
- if (allStages.length !== 1) {
107
- throw new Error(`wrong number of stages: ${allStages.length}`);
134
+ /**
135
+ * @param {unknown[]} native
136
+ */
137
+ const fixCompressedNativeInPlace = (native) => {
138
+ if (!Array.isArray(native)) {
139
+ throw new Error('native is not an array');
108
140
  }
109
- const stageIndex = targets.findIndex((target) => target.isStage);
110
- // stageIndex guaranteed to not be -1 by earlier check
111
- const stage = targets[stageIndex];
112
- // stage must be the first target
113
- if (stageIndex !== 0) {
114
- log('stage was not at start');
115
- targets.splice(stageIndex, 1);
116
- targets.unshift(stage);
141
+
142
+ const type = native[0];
143
+ if (typeof type !== 'number') {
144
+ throw new Error('native type is not a number');
117
145
  }
118
- // stage's name must match exactly
119
- if (stage.name !== 'Stage') {
120
- stage.name = 'Stage';
121
- log('stage had wrong name');
146
+ switch (type) {
147
+ // Variable: [12, variable name, variable id, x?, y?]
148
+ // List: [13, list name, list id, x?, y?]
149
+ // x and y only present if the native is a top-level block
150
+ case 12:
151
+ case 13: {
152
+ if (native.length !== 3 && native.length !== 5) {
153
+ throw new Error(`Variable or list native is of unexpected length: ${native.length}`);
154
+ }
155
+ const name = native[1];
156
+ if (typeof name !== 'string') {
157
+ log(`variable or list native name was not a string`);
158
+ native[1] = String(native[1]);
159
+ }
160
+ break;
161
+ }
122
162
  }
163
+ };
123
164
 
124
- const knownExtensions = getKnownExtensions(project);
125
- const monitors = project.monitors;
126
- if (!Array.isArray(monitors)) {
127
- throw new Error('monitors is not an array');
128
- }
129
- project.monitors = project.monitors.filter((monitor, i) => {
130
- const opcode = monitor.opcode;
131
- if (typeof opcode !== 'string') {
132
- throw new Error(`monitor ${i} opcode is not a string`);
165
+ /**
166
+ * @param {string} id
167
+ * @param {unknown} block
168
+ */
169
+ const fixBlockInPlace = (id, block) => {
170
+ if (Array.isArray(block)) {
171
+ fixCompressedNativeInPlace(block);
172
+ } else if (isObject(block)) {
173
+ const inputs = block.inputs;
174
+ if (!isObject(inputs)) {
175
+ throw new Error('inputs is not an object');
133
176
  }
134
- const extension = opcode.split('_')[0];
135
- if (!knownExtensions.has(extension)) {
136
- log(`removed monitor ${i} from unknown extension ${extension}`);
137
- return false;
177
+ for (const [inputName, input] of Object.entries(inputs)) {
178
+ if (!Array.isArray(input)) {
179
+ throw new Error(`block ${id} input ${inputName} is not an array`);
180
+ }
181
+ for (let i = 1; i < input.length; i++) {
182
+ if (Array.isArray(input[i])) {
183
+ fixCompressedNativeInPlace(input[i]);
184
+ }
185
+ }
138
186
  }
139
- return true;
140
- });
187
+
188
+ const fields = block.fields;
189
+ if (!isObject(fields)) {
190
+ throw new Error('fields is not an object');
191
+ }
192
+ for (const [fieldName, field] of Object.entries(fields)) {
193
+ if (!Array.isArray(field)) {
194
+ throw new Error(`block ${id} field ${fieldName} is not an array`);
195
+ }
196
+ }
197
+ } else {
198
+ throw new Error(`block ${id} is not an object`);
199
+ }
141
200
  };
142
201
 
143
202
  /**
@@ -237,172 +296,128 @@ const sb3fix = async (data) => {
237
296
  };
238
297
 
239
298
  /**
240
- * @param {string} id
241
- * @param {unknown} block
299
+ * @param {unknown} project
242
300
  */
243
- const fixBlockInPlace = (id, block) => {
244
- if (Array.isArray(block)) {
245
- fixNativeInPlace(block);
246
- } else if (isObject(block)) {
247
- const inputs = block.inputs;
248
- if (!isObject(inputs)) {
249
- throw new Error('inputs is not an object');
250
- }
251
- for (const [inputName, input] of Object.entries(inputs)) {
252
- if (!Array.isArray(input)) {
253
- throw new Error(`block ${id} input ${inputName} is not an array`);
254
- }
255
- for (let i = 1; i < input.length; i++) {
256
- if (Array.isArray(input[i])) {
257
- fixNativeInPlace(input[i]);
258
- }
259
- }
260
- }
261
-
262
- const fields = block.fields;
263
- if (!isObject(fields)) {
264
- throw new Error('fields is not an object');
265
- }
266
- for (const [fieldName, field] of Object.entries(fields)) {
267
- if (!Array.isArray(field)) {
268
- throw new Error(`block ${id} field ${fieldName} is not an array`);
269
- }
270
- }
271
- } else {
272
- throw new Error(`block ${id} is not an object`);
301
+ const fixProjectInPlace = (project) => {
302
+ if ('objName' in project) {
303
+ throw new Error('Scratch 2 (sb2) projects not supported');
273
304
  }
274
- };
275
305
 
276
- /**
277
- * @param {unknown[]} native
278
- */
279
- const fixNativeInPlace = (native) => {
280
- if (!Array.isArray(native)) {
281
- throw new Error('native is not an array');
306
+ if (!isObject(project)) {
307
+ throw new Error('Root JSON is not an object');
282
308
  }
283
309
 
284
- const type = native[0];
285
- if (typeof type !== 'number') {
286
- throw new Error('native type is not a number');
287
- }
288
- switch (type) {
289
- case 12: // Variable: [12, variable name, variable id]
290
- case 13: // List: [13, list name, list id]
291
- if (native.length !== 3) {
292
- throw new Error('variable or list native is of wrong length');
293
- }
294
- const name = native[1];
295
- if (typeof name !== 'string') {
296
- log(`variable or list native name was not a string`);
297
- native[1] = String(native[1]);
298
- }
299
- break;
310
+ if ('name' in project) {
311
+ // Not a project. Just a sprite.
312
+ log('project is a sprite');
313
+ fixTargetInPlace(project);
314
+ return;
300
315
  }
301
- };
302
316
 
303
- /**
304
- * @param {string} id
305
- * @param {unknown} variable
306
- */
307
- const fixVariableInPlace = (id, variable) => {
308
- if (!Array.isArray(variable)) {
309
- throw new Error(`variable object ${id} is not an array`);
317
+ const targets = project.targets;
318
+ if (!Array.isArray(targets)) {
319
+ throw new Error('targets is not an array');
310
320
  }
311
-
312
- const name = variable[0];
313
- if (typeof name !== 'string') {
314
- log(`variable or list ${id} name was not a string`);
315
- variable[0] = String(variable[0]);
321
+ if (targets.length < 1) {
322
+ throw new Error('targets is empty');
316
323
  }
317
-
318
- const value = variable[1];
319
- if (typeof value !== 'number' && typeof value !== 'string' && typeof value !== 'boolean') {
320
- log(`variable ${id} value was not a Scratch-compatible value`);
321
- variable[1] = String(variable[1]);
324
+ for (let i = 0; i < targets.length; i++) {
325
+ log(`checking target ${i}`);
326
+ const target = targets[i];
327
+ if (!isObject(target)) {
328
+ throw new Error('target is not an object');
329
+ }
330
+ fixTargetInPlace(target);
322
331
  }
323
- };
324
332
 
325
- /**
326
- * @param {string} id
327
- * @param {unknown} list
328
- */
329
- const fixListInPlace = (id, list) => {
330
- if (!Array.isArray(list)) {
331
- throw new Error(`list object ${id} is not an array`);
333
+ const allStages = targets.filter((target) => target.isStage);
334
+ if (allStages.length !== 1) {
335
+ throw new Error(`wrong number of stages: ${allStages.length}`);
332
336
  }
333
-
334
- const name = list[0];
335
- if (typeof name !== 'string') {
336
- log(`list ${id} name was not a string`);
337
- list[0] = String(list[0]);
337
+ const stageIndex = targets.findIndex((target) => target.isStage);
338
+ // stageIndex guaranteed to not be -1 by earlier check
339
+ const stage = targets[stageIndex];
340
+ // stage must be the first target
341
+ if (stageIndex !== 0) {
342
+ log('stage was not at start');
343
+ targets.splice(stageIndex, 1);
344
+ targets.unshift(stage);
338
345
  }
339
-
340
- if (!Array.isArray(list[1])) {
341
- log(`list ${id} value was not an array`);
342
- list[1] = [];
346
+ // stage's name must match exactly
347
+ if (stage.name !== 'Stage') {
348
+ stage.name = 'Stage';
349
+ log('stage had wrong name');
343
350
  }
344
351
 
345
- const listValue = list[1];
346
- for (let i = 0; i < listValue.length; i++) {
347
- const value = listValue[i];
348
- if (typeof value !== 'number' && typeof value !== 'string' && typeof value !== 'boolean') {
349
- log(`list ${id} index ${i} was not a Scratch-compatible value`);
350
- listValue[i] = String(value);
351
- }
352
+ const knownExtensions = getKnownExtensions(project);
353
+ const monitors = project.monitors;
354
+ if (!Array.isArray(monitors)) {
355
+ throw new Error('monitors is not an array');
352
356
  }
357
+ project.monitors = project.monitors.filter((monitor, i) => {
358
+ const opcode = monitor.opcode;
359
+ if (typeof opcode !== 'string') {
360
+ throw new Error(`monitor ${i} opcode is not a string`);
361
+ }
362
+ const extension = opcode.split('_')[0];
363
+ if (!knownExtensions.has(extension)) {
364
+ log(`removed monitor ${i} from unknown extension ${extension}`);
365
+ return false;
366
+ }
367
+ return true;
368
+ });
353
369
  };
354
370
 
355
- /**
356
- * @returns {Promise<JSZip>}
357
- */
358
- const fixZip = async () => {
359
- // @ts-expect-error
360
- const zip = await JSZip.loadAsync(data);
361
-
362
- // project.json is not guaranteed to be stored in the root
363
- const projectJSONFile = zip.file(/project\.json/)[0];
364
- if (!projectJSONFile) {
365
- throw new Error('Could not find project.json.');
366
- }
367
-
368
- const projectJSONText = await projectJSONFile.async('text');
369
- const projectJSON = JSON.parse(projectJSONText);
371
+ if (typeof data === 'object' && data !== null) {
372
+ // Already parsed.
373
+ fixProjectInPlace(data);
374
+ return data;
375
+ } else if (typeof data === 'string') {
376
+ // Need to parse.
377
+ const parsed = JSON.parse(data);
378
+ fixProjectInPlace(parsed);
379
+ return parsed;
380
+ } else {
381
+ throw new Error('Unable to tell how to interpret input as JSON');
382
+ }
383
+ };
370
384
 
371
- fixProjectInPlace(projectJSON);
385
+ /**
386
+ * @param {ArrayBuffer|Uint8Array|Blob} data A compressed .sb3 file.
387
+ * @param {Options} [options]
388
+ * @returns {Promise<ArrayBuffer>} A promise that resolves to a fixed compressed .sb3 file.
389
+ */
390
+ const fixZip = async (data, options = {}) => {
391
+ // JSZip is not a small library, so we'll load it somewhat lazily.
392
+ const JSZip = require('jszip');
372
393
 
373
- const newProjectJSONText = JSON.stringify(projectJSON);
374
- zip.file(projectJSONFile.name, newProjectJSONText);
394
+ const zip = await JSZip.loadAsync(data);
375
395
 
376
- return zip;
377
- };
396
+ // json is not guaranteed to be stored in the root.
397
+ const jsonFile = zip.file(/(?:project|sprite)\.json/)[0];
398
+ if (!jsonFile) {
399
+ throw new Error('Could not find project.json or sprite.json.');
400
+ }
378
401
 
379
- const makeZipDeterministicInPlace = (zip) => {
380
- // By default, JSZip will use the current date, which makes the zips non-deterministic
381
- const date = new Date('Thu, 14 Mar 2024 00:00:00 GMT');
382
- for (const file of Object.values(zip.files)) {
383
- file.date = date;
384
- }
385
- };
402
+ const jsonText = await jsonFile.async('text');
403
+ const fixedJSON = fixJSON(jsonText, options);
404
+ const newProjectJSONText = JSON.stringify(fixedJSON);
405
+ zip.file(jsonFile.name, newProjectJSONText);
386
406
 
387
- try {
388
- const zip = await fixZip();
389
- makeZipDeterministicInPlace(zip);
390
- const compressedZip = await zip.generateAsync({
391
- type: 'arraybuffer',
392
- compression: 'DEFLATE'
393
- });
394
- return {
395
- success: true,
396
- fixedZip: compressedZip,
397
- log: logMessages
398
- };
399
- } catch (error) {
400
- return {
401
- success: false,
402
- error,
403
- log: logMessages
404
- };
407
+ // By default, JSZip will use the current date as the modified timestamp, which would generated zips non-deterministic.
408
+ const date = new Date('Thu, 14 Mar 2024 00:00:00 GMT');
409
+ for (const file of Object.values(zip.files)) {
410
+ file.date = date;
405
411
  }
412
+
413
+ const compressed = await zip.generateAsync({
414
+ type: 'uint8array',
415
+ compression: 'DEFLATE'
416
+ });
417
+ return compressed;
406
418
  };
407
419
 
408
- module.exports = sb3fix;
420
+ module.exports = {
421
+ fixJSON,
422
+ fixZip
423
+ };