@turbowarp/sb3fix 0.3.5 → 0.3.7
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.
- package/LICENSE +373 -373
- package/README.md +100 -100
- package/package.json +31 -31
- package/src/sb3fix.js +606 -552
package/src/sb3fix.js
CHANGED
|
@@ -1,552 +1,606 @@
|
|
|
1
|
-
/*!
|
|
2
|
-
sb3fix - https://github.com/TurboWarp/sb3fix
|
|
3
|
-
|
|
4
|
-
Copyright (C) 2023-
|
|
5
|
-
|
|
6
|
-
This Source Code Form is subject to the terms of the Mozilla Public
|
|
7
|
-
License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
8
|
-
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
/**
|
|
12
|
-
* @typedef Options
|
|
13
|
-
* @property {(message: string) => void} [logCallback]
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* @param {unknown} obj
|
|
18
|
-
* @returns {obj is object}
|
|
19
|
-
*/
|
|
20
|
-
const isObject = (obj) => !!obj && typeof obj === 'object';
|
|
21
|
-
|
|
22
|
-
const BUILTIN_EXTENSIONS = [
|
|
23
|
-
'control',
|
|
24
|
-
'data',
|
|
25
|
-
'event',
|
|
26
|
-
'looks',
|
|
27
|
-
'motion',
|
|
28
|
-
'operators',
|
|
29
|
-
'procedures',
|
|
30
|
-
'argument', // "argument_reporter_boolean" is technically not an extension but we should list here anyways
|
|
31
|
-
'sensing',
|
|
32
|
-
'sound',
|
|
33
|
-
'pen',
|
|
34
|
-
'wedo2',
|
|
35
|
-
'music',
|
|
36
|
-
'microbit',
|
|
37
|
-
'text2speech',
|
|
38
|
-
'translate',
|
|
39
|
-
'videoSensing',
|
|
40
|
-
'ev3',
|
|
41
|
-
'makeymakey',
|
|
42
|
-
'boost',
|
|
43
|
-
'gdxfor'
|
|
44
|
-
// intentionally not listing TurboWarp's 'tw' extension here.
|
|
45
|
-
];
|
|
46
|
-
|
|
47
|
-
/**
|
|
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 the `data` argument was an object, this will point to the same object.
|
|
71
|
-
*/
|
|
72
|
-
const fixJSON = (data, options = {}) => {
|
|
73
|
-
/**
|
|
74
|
-
* @param {string} message
|
|
75
|
-
*/
|
|
76
|
-
const log = (message) => {
|
|
77
|
-
if (options.logCallback) {
|
|
78
|
-
options.logCallback(message);
|
|
79
|
-
}
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* @param {string} id
|
|
84
|
-
* @param {unknown} variable
|
|
85
|
-
*/
|
|
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]);
|
|
101
|
-
}
|
|
102
|
-
};
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* @param {string} id
|
|
106
|
-
* @param {unknown} list
|
|
107
|
-
*/
|
|
108
|
-
const fixListInPlace = (id, list) => {
|
|
109
|
-
if (!Array.isArray(list)) {
|
|
110
|
-
throw new Error(`list object ${id} is not an array`);
|
|
111
|
-
}
|
|
112
|
-
|
|
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]);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (!Array.isArray(list[1])) {
|
|
120
|
-
log(`list ${id} value was not an array`);
|
|
121
|
-
list[1] = [];
|
|
122
|
-
}
|
|
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);
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
};
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
* @param {unknown[]} native
|
|
136
|
-
*/
|
|
137
|
-
const fixCompressedNativeInPlace = (native) => {
|
|
138
|
-
if (!Array.isArray(native)) {
|
|
139
|
-
throw new Error('native is not an array');
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
const type = native[0];
|
|
143
|
-
if (typeof type !== 'number') {
|
|
144
|
-
throw new Error('native type is not a number');
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
if (
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
const
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
if (!
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
const
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
};
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
};
|
|
1
|
+
/*!
|
|
2
|
+
sb3fix - https://github.com/TurboWarp/sb3fix
|
|
3
|
+
|
|
4
|
+
Copyright (C) 2023-2025 Thomas Weber
|
|
5
|
+
|
|
6
|
+
This Source Code Form is subject to the terms of the Mozilla Public
|
|
7
|
+
License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
8
|
+
file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef Options
|
|
13
|
+
* @property {(message: string) => void} [logCallback]
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* @param {unknown} obj
|
|
18
|
+
* @returns {obj is object}
|
|
19
|
+
*/
|
|
20
|
+
const isObject = (obj) => !!obj && typeof obj === 'object';
|
|
21
|
+
|
|
22
|
+
const BUILTIN_EXTENSIONS = [
|
|
23
|
+
'control',
|
|
24
|
+
'data',
|
|
25
|
+
'event',
|
|
26
|
+
'looks',
|
|
27
|
+
'motion',
|
|
28
|
+
'operators',
|
|
29
|
+
'procedures',
|
|
30
|
+
'argument', // "argument_reporter_boolean" is technically not an extension but we should list here anyways
|
|
31
|
+
'sensing',
|
|
32
|
+
'sound',
|
|
33
|
+
'pen',
|
|
34
|
+
'wedo2',
|
|
35
|
+
'music',
|
|
36
|
+
'microbit',
|
|
37
|
+
'text2speech',
|
|
38
|
+
'translate',
|
|
39
|
+
'videoSensing',
|
|
40
|
+
'ev3',
|
|
41
|
+
'makeymakey',
|
|
42
|
+
'boost',
|
|
43
|
+
'gdxfor'
|
|
44
|
+
// intentionally not listing TurboWarp's 'tw' extension here.
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
/**
|
|
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 the `data` argument was an object, this will point to the same object.
|
|
71
|
+
*/
|
|
72
|
+
const fixJSON = (data, options = {}) => {
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} message
|
|
75
|
+
*/
|
|
76
|
+
const log = (message) => {
|
|
77
|
+
if (options.logCallback) {
|
|
78
|
+
options.logCallback(message);
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* @param {string} id
|
|
84
|
+
* @param {unknown} variable
|
|
85
|
+
*/
|
|
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]);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @param {string} id
|
|
106
|
+
* @param {unknown} list
|
|
107
|
+
*/
|
|
108
|
+
const fixListInPlace = (id, list) => {
|
|
109
|
+
if (!Array.isArray(list)) {
|
|
110
|
+
throw new Error(`list object ${id} is not an array`);
|
|
111
|
+
}
|
|
112
|
+
|
|
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]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
if (!Array.isArray(list[1])) {
|
|
120
|
+
log(`list ${id} value was not an array`);
|
|
121
|
+
list[1] = [];
|
|
122
|
+
}
|
|
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);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* @param {unknown[]} native
|
|
136
|
+
*/
|
|
137
|
+
const fixCompressedNativeInPlace = (native) => {
|
|
138
|
+
if (!Array.isArray(native)) {
|
|
139
|
+
throw new Error('native is not an array');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const type = native[0];
|
|
143
|
+
if (typeof type !== 'number') {
|
|
144
|
+
throw new Error('native type is not a number');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
switch (type) {
|
|
148
|
+
// Number primitive: [4, string|number]
|
|
149
|
+
// Positive number primitive: [5, string|number]
|
|
150
|
+
// Whole number primitive: [6, string|number]
|
|
151
|
+
// Integer primitive: [7, string|number]
|
|
152
|
+
// Angle primitive: [8, string|number]
|
|
153
|
+
case 4:
|
|
154
|
+
case 5:
|
|
155
|
+
case 6:
|
|
156
|
+
case 7:
|
|
157
|
+
case 8: {
|
|
158
|
+
if (native.length !== 2) {
|
|
159
|
+
throw new Error(`Number native is of unexpected length: ${native.length}`);
|
|
160
|
+
}
|
|
161
|
+
const value = native[1];
|
|
162
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
163
|
+
log('number native had invalid value');
|
|
164
|
+
native[1] = String(value);
|
|
165
|
+
}
|
|
166
|
+
break;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Color: [9, hex color]
|
|
170
|
+
case 9: {
|
|
171
|
+
if (native.length !== 2) {
|
|
172
|
+
throw new Error(`Color native is of unexpected length: ${native.length}`);
|
|
173
|
+
}
|
|
174
|
+
const color = native[1];
|
|
175
|
+
if (typeof color !== 'string' || !/^#[a-f0-9]{6}$/i.test(color)) {
|
|
176
|
+
log('color native had invalid value');
|
|
177
|
+
native[1] = '#000000';
|
|
178
|
+
}
|
|
179
|
+
break;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Text: [10, string|number]
|
|
183
|
+
case 10: {
|
|
184
|
+
if (native.length !== 2) {
|
|
185
|
+
throw new Error(`Text native is of unexpected length: ${native.length}`);
|
|
186
|
+
}
|
|
187
|
+
const value = native[1];
|
|
188
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
189
|
+
log('text native had invalid value');
|
|
190
|
+
native[1] = String(value);
|
|
191
|
+
}
|
|
192
|
+
break;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Variable: [12, variable name, variable id, x?, y?]
|
|
196
|
+
// List: [13, list name, list id, x?, y?]
|
|
197
|
+
// x and y only present if the native is a top-level block
|
|
198
|
+
case 12:
|
|
199
|
+
case 13: {
|
|
200
|
+
if (native.length !== 3 && native.length !== 5) {
|
|
201
|
+
throw new Error(`Variable or list native is of unexpected length: ${native.length}`);
|
|
202
|
+
}
|
|
203
|
+
const name = native[1];
|
|
204
|
+
if (typeof name !== 'string') {
|
|
205
|
+
log(`variable or list native name was not a string`);
|
|
206
|
+
native[1] = String(native[1]);
|
|
207
|
+
}
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* @param {string} id
|
|
215
|
+
* @param {unknown} block
|
|
216
|
+
*/
|
|
217
|
+
const fixBlockInPlace = (id, block) => {
|
|
218
|
+
if (Array.isArray(block)) {
|
|
219
|
+
fixCompressedNativeInPlace(block);
|
|
220
|
+
} else if (isObject(block)) {
|
|
221
|
+
const inputs = block.inputs;
|
|
222
|
+
if (!isObject(inputs)) {
|
|
223
|
+
throw new Error('inputs is not an object');
|
|
224
|
+
}
|
|
225
|
+
for (const [inputName, input] of Object.entries(inputs)) {
|
|
226
|
+
if (!Array.isArray(input)) {
|
|
227
|
+
throw new Error(`block ${id} input ${inputName} is not an array`);
|
|
228
|
+
}
|
|
229
|
+
for (let i = 1; i < input.length; i++) {
|
|
230
|
+
if (Array.isArray(input[i])) {
|
|
231
|
+
fixCompressedNativeInPlace(input[i]);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const fields = block.fields;
|
|
237
|
+
if (!isObject(fields)) {
|
|
238
|
+
throw new Error('fields is not an object');
|
|
239
|
+
}
|
|
240
|
+
for (const [fieldName, field] of Object.entries(fields)) {
|
|
241
|
+
if (!Array.isArray(field)) {
|
|
242
|
+
throw new Error(`block ${id} field ${fieldName} is not an array`);
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
throw new Error(`block ${id} is not an object`);
|
|
247
|
+
}
|
|
248
|
+
};
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @param {string} id
|
|
252
|
+
* @param {unknown} comment
|
|
253
|
+
*/
|
|
254
|
+
const fixCommentInPlace = (id, comment) => {
|
|
255
|
+
if (!isObject(comment)) {
|
|
256
|
+
throw new Error('comment is not an object');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (typeof comment.text !== 'string') {
|
|
260
|
+
throw new Error('comment text is not a string');
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Scratch requires comments to not exceed 8000 characters.
|
|
264
|
+
// We'll store the excess in .extraText so the text won't be truncated if opened in TurboWarp.
|
|
265
|
+
const MAX_LENGTH = 8000;
|
|
266
|
+
if (comment.text.length > MAX_LENGTH) {
|
|
267
|
+
log(`comment ${id} had length ${comment.text.length}`);
|
|
268
|
+
comment.extraText = comment.text.substring(MAX_LENGTH);
|
|
269
|
+
comment.text = comment.text.substring(0, MAX_LENGTH);
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* @param {unknown} target
|
|
275
|
+
*/
|
|
276
|
+
const fixTargetInPlace = (target) => {
|
|
277
|
+
const costumes = target.costumes;
|
|
278
|
+
if (!Array.isArray(costumes)) {
|
|
279
|
+
throw new Error('costumes is not an array');
|
|
280
|
+
}
|
|
281
|
+
for (let i = costumes.length - 1; i >= 0; i--) {
|
|
282
|
+
const costume = costumes[i];
|
|
283
|
+
if (!isObject(costume)) {
|
|
284
|
+
throw new Error(`costume ${i} is not an object`);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (typeof costume.name !== 'string') {
|
|
288
|
+
log(`costume ${i} name was not a string`);
|
|
289
|
+
costume.name = String(costume.name);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// https://github.com/scratchfoundation/scratch-parser/blob/665f05d739a202d565a4af70a201909393d456b2/lib/sb3_definitions.json#L51
|
|
293
|
+
const knownCostumeFormats = ['png', 'svg', 'jpeg', 'jpg', 'bmp', 'gif'];
|
|
294
|
+
if (!knownCostumeFormats.includes(costume.dataFormat)) {
|
|
295
|
+
if (typeof costume.md5ext === 'string' && costume.md5ext.endsWith('.svg')) {
|
|
296
|
+
log(`costume ${i} is vector, had invalid dataFormat ${costume.dataFormat}`);
|
|
297
|
+
costume.dataFormat = 'svg';
|
|
298
|
+
} else {
|
|
299
|
+
log(`costume ${i} is bitmap, had invalid dataFormat ${costume.dataFormat}`);
|
|
300
|
+
// dataFormat is only really used to detect vector or bitmap, so we don't
|
|
301
|
+
// need to set this to the real format
|
|
302
|
+
costume.dataFormat = 'png';
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!('assetId' in costume)) {
|
|
307
|
+
log(`costume ${i} was missing assetId, deleted`);
|
|
308
|
+
costumes.splice(i, 1);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
if (costumes.length === 0) {
|
|
312
|
+
log(`costumes was empty, adding empty costume`);
|
|
313
|
+
costumes.push({
|
|
314
|
+
// Empty SVG costume
|
|
315
|
+
name: 'costume1',
|
|
316
|
+
bitmapResolution: 1,
|
|
317
|
+
dataFormat: 'svg',
|
|
318
|
+
assetId: 'cd21514d0531fdffb22204e0ec5ed84a',
|
|
319
|
+
md5ext: 'cd21514d0531fdffb22204e0ec5ed84a.svg',
|
|
320
|
+
rotationCenterX: 0,
|
|
321
|
+
rotationCenterY: 0
|
|
322
|
+
});
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const sounds = target.sounds;
|
|
326
|
+
if (!Array.isArray(sounds)) {
|
|
327
|
+
throw new Error('sounds is not an array');
|
|
328
|
+
}
|
|
329
|
+
for (let i = sounds.length - 1; i >= 0; i--) {
|
|
330
|
+
const sound = sounds[i];
|
|
331
|
+
if (!isObject(sound)) {
|
|
332
|
+
throw new Error(`sound ${i} is not an object`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// https://github.com/scratchfoundation/scratch-parser/blob/665f05d739a202d565a4af70a201909393d456b2/lib/sb3_definitions.json#L81
|
|
336
|
+
const knownSoundFormats = ['wav', 'wave', 'mp3'];
|
|
337
|
+
if (!knownSoundFormats.includes(sound.dataFormat)) {
|
|
338
|
+
log(`sound ${i} had invalid dataFormat ${sound.dataFormat}`);
|
|
339
|
+
sound.dataFormat = 'mp3';
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
if (typeof sound.name !== 'string') {
|
|
343
|
+
log(`sound ${i} name was not a string`);
|
|
344
|
+
sound.name = String(sound.name);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (!('assetId' in sound)) {
|
|
348
|
+
log(`sound ${i} was missing assetId, deleted`);
|
|
349
|
+
sounds.splice(i, 1);
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const blocks = target.blocks;
|
|
354
|
+
if (!isObject(blocks)) {
|
|
355
|
+
throw new Error('blocks is not an object');
|
|
356
|
+
}
|
|
357
|
+
for (const [blockId, block] of Object.entries(blocks)) {
|
|
358
|
+
fixBlockInPlace(blockId, block);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Comments are not required
|
|
362
|
+
const comments = target.comments;
|
|
363
|
+
if (comments) {
|
|
364
|
+
for (const [commentId, comment] of Object.entries(comments)) {
|
|
365
|
+
fixCommentInPlace(commentId, comment);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const variables = target.variables;
|
|
370
|
+
if (!isObject(variables)) {
|
|
371
|
+
throw new Error('variables is not an object');
|
|
372
|
+
}
|
|
373
|
+
for (const [variableId, variable] of Object.entries(variables)) {
|
|
374
|
+
fixVariableInPlace(variableId, variable);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const lists = target.lists;
|
|
378
|
+
if (!isObject(lists)) {
|
|
379
|
+
throw new Error('lists is not an object');
|
|
380
|
+
}
|
|
381
|
+
for (const [listId, list] of Object.entries(lists)) {
|
|
382
|
+
fixListInPlace(listId, list);
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (target.isStage) {
|
|
386
|
+
if (target.layerOrder !== 0) {
|
|
387
|
+
log('stage had invalid layerOrder');
|
|
388
|
+
target.layerOrder = 0;
|
|
389
|
+
}
|
|
390
|
+
} else {
|
|
391
|
+
if (target.layerOrder < 1) {
|
|
392
|
+
log('sprite had invalid layerOrder');
|
|
393
|
+
target.layerOrder = 1;
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
const ROTATION_STYLES = [
|
|
398
|
+
'all around',
|
|
399
|
+
'don\'t rotate',
|
|
400
|
+
'left-right'
|
|
401
|
+
];
|
|
402
|
+
if (!target.isStage && !ROTATION_STYLES.includes(target.rotationStyle)) {
|
|
403
|
+
log(`sprite had invalid rotation style ${target.rotationStyle}`);
|
|
404
|
+
target.rotationStyle = 'all around';
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
if (!target.isStage) {
|
|
408
|
+
const x = target.x;
|
|
409
|
+
if (typeof x !== 'number') {
|
|
410
|
+
log(`target x was ${typeof x}: ${x}`);
|
|
411
|
+
target.x = +x || 0;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
const y = target.y;
|
|
415
|
+
if (typeof y !== 'number') {
|
|
416
|
+
log(`target y was ${typeof y}: ${y}`);
|
|
417
|
+
target.y = +y || 0;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* @param {unknown} stage
|
|
424
|
+
*/
|
|
425
|
+
const fixStageInPlace = (stage) => {
|
|
426
|
+
// stage's name must match exactly
|
|
427
|
+
if (stage.name !== 'Stage') {
|
|
428
|
+
log(`stage had wrong name: ${stage.name}`);
|
|
429
|
+
stage.name = 'Stage';
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// In vanilla Scratch, "turn video < ... >" with anything that isn't the dropdown allows videoState
|
|
433
|
+
// to be set to something that isn't one of the expected strings. We'll play it safe and default to
|
|
434
|
+
// off.
|
|
435
|
+
const VIDEO_STATES = [
|
|
436
|
+
'on',
|
|
437
|
+
'off',
|
|
438
|
+
'on-flipped'
|
|
439
|
+
];
|
|
440
|
+
if (Object.prototype.hasOwnProperty.call(stage, 'videoState') && !VIDEO_STATES.includes(stage.videoState)) {
|
|
441
|
+
log(`stage had invalid videoState: ${stage.videoState}`);
|
|
442
|
+
stage.videoState = 'off';
|
|
443
|
+
}
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
/**
|
|
447
|
+
* @param {unknown} project
|
|
448
|
+
*/
|
|
449
|
+
const fixProjectInPlace = (project) => {
|
|
450
|
+
if ('objName' in project) {
|
|
451
|
+
throw new Error('Scratch 2 (sb2) projects not supported');
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
if (!isObject(project)) {
|
|
455
|
+
throw new Error('Root JSON is not an object');
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
if ('name' in project) {
|
|
459
|
+
// Not a project. Just a sprite.
|
|
460
|
+
log('project is a sprite');
|
|
461
|
+
fixTargetInPlace(project);
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const targets = project.targets;
|
|
466
|
+
if (!Array.isArray(targets)) {
|
|
467
|
+
throw new Error('targets is not an array');
|
|
468
|
+
}
|
|
469
|
+
if (targets.length < 1) {
|
|
470
|
+
throw new Error('targets is empty');
|
|
471
|
+
}
|
|
472
|
+
for (let i = 0; i < targets.length; i++) {
|
|
473
|
+
log(`checking target ${i}`);
|
|
474
|
+
const target = targets[i];
|
|
475
|
+
if (!isObject(target)) {
|
|
476
|
+
throw new Error('target is not an object');
|
|
477
|
+
}
|
|
478
|
+
fixTargetInPlace(target);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
const allStages = targets.filter((target) => target.isStage);
|
|
482
|
+
if (allStages.length === 0) {
|
|
483
|
+
log('stage is missing; adding an empty one');
|
|
484
|
+
targets.unshift({
|
|
485
|
+
isStage: true,
|
|
486
|
+
name: 'Stage',
|
|
487
|
+
variables: {},
|
|
488
|
+
lists: {},
|
|
489
|
+
broadcasts: {},
|
|
490
|
+
blocks: {},
|
|
491
|
+
currentCostume: 0,
|
|
492
|
+
costumes: [
|
|
493
|
+
{
|
|
494
|
+
name: 'backdrop1',
|
|
495
|
+
dataFormat: 'svg',
|
|
496
|
+
assetId: 'cd21514d0531fdffb22204e0ec5ed84a',
|
|
497
|
+
md5ext: 'cd21514d0531fdffb22204e0ec5ed84a.svg',
|
|
498
|
+
rotationCenterX: 240,
|
|
499
|
+
rotationCenterY: 180
|
|
500
|
+
}
|
|
501
|
+
],
|
|
502
|
+
sounds: [],
|
|
503
|
+
volume: 100,
|
|
504
|
+
layerOrder: 0,
|
|
505
|
+
tempo: 60,
|
|
506
|
+
videoTransparency: 50,
|
|
507
|
+
videoState: "on",
|
|
508
|
+
textToSpeechLanguage: null
|
|
509
|
+
});
|
|
510
|
+
} else {
|
|
511
|
+
// We will accept the first stage in targets as the real stage
|
|
512
|
+
const firstStageIndex = targets.findIndex((target) => target.isStage);
|
|
513
|
+
|
|
514
|
+
// Stage must be the first target
|
|
515
|
+
if (firstStageIndex !== 0) {
|
|
516
|
+
log(`stage was at wrong index: ${firstStageIndex}`);
|
|
517
|
+
const stage = targets[firstStageIndex];
|
|
518
|
+
targets.splice(firstStageIndex, 1);
|
|
519
|
+
targets.unshift(stage);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
// Remove all the other stages
|
|
523
|
+
for (let i = targets.length - 1; i > 0; i--) {
|
|
524
|
+
if (targets[i].isStage) {
|
|
525
|
+
log(`removing extra stage at index ${i}`);
|
|
526
|
+
targets.splice(i, 1);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
// Above checks ensure this invariant holds
|
|
532
|
+
const stage = targets[0];
|
|
533
|
+
fixStageInPlace(stage);
|
|
534
|
+
|
|
535
|
+
const knownExtensions = getKnownExtensions(project);
|
|
536
|
+
const monitors = project.monitors;
|
|
537
|
+
if (!Array.isArray(monitors)) {
|
|
538
|
+
throw new Error('monitors is not an array');
|
|
539
|
+
}
|
|
540
|
+
project.monitors = project.monitors.filter((monitor, i) => {
|
|
541
|
+
const opcode = monitor.opcode;
|
|
542
|
+
if (typeof opcode !== 'string') {
|
|
543
|
+
throw new Error(`monitor ${i} opcode is not a string`);
|
|
544
|
+
}
|
|
545
|
+
const extension = opcode.split('_')[0];
|
|
546
|
+
if (!knownExtensions.has(extension)) {
|
|
547
|
+
log(`removed monitor ${i} from unknown extension ${extension}`);
|
|
548
|
+
return false;
|
|
549
|
+
}
|
|
550
|
+
return true;
|
|
551
|
+
});
|
|
552
|
+
};
|
|
553
|
+
|
|
554
|
+
if (typeof data === 'object' && data !== null) {
|
|
555
|
+
// Already parsed.
|
|
556
|
+
fixProjectInPlace(data);
|
|
557
|
+
return data;
|
|
558
|
+
} else if (typeof data === 'string') {
|
|
559
|
+
// Need to parse.
|
|
560
|
+
const parsed = JSON.parse(data);
|
|
561
|
+
fixProjectInPlace(parsed);
|
|
562
|
+
return parsed;
|
|
563
|
+
} else {
|
|
564
|
+
throw new Error('Unable to tell how to interpret input as JSON');
|
|
565
|
+
}
|
|
566
|
+
};
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* @param {ArrayBuffer|Uint8Array|Blob} data A compressed .sb3 file.
|
|
570
|
+
* @param {Options} [options]
|
|
571
|
+
* @returns {Promise<ArrayBuffer>} A promise that resolves to a fixed compressed .sb3 file.
|
|
572
|
+
*/
|
|
573
|
+
const fixZip = async (data, options = {}) => {
|
|
574
|
+
// JSZip is not a small library, so we'll load it somewhat lazily.
|
|
575
|
+
const JSZip = require('@turbowarp/jszip');
|
|
576
|
+
|
|
577
|
+
const zip = await JSZip.loadAsync(data);
|
|
578
|
+
|
|
579
|
+
// json is not guaranteed to be stored in the root.
|
|
580
|
+
const jsonFile = zip.file(/(?:project|sprite)\.json/)[0];
|
|
581
|
+
if (!jsonFile) {
|
|
582
|
+
throw new Error('Could not find project.json or sprite.json.');
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
const jsonText = await jsonFile.async('text');
|
|
586
|
+
const fixedJSON = fixJSON(jsonText, options);
|
|
587
|
+
const newProjectJSONText = JSON.stringify(fixedJSON);
|
|
588
|
+
zip.file(jsonFile.name, newProjectJSONText);
|
|
589
|
+
|
|
590
|
+
// By default, JSZip will use the current date as the modified timestamp, which would generated zips non-deterministic.
|
|
591
|
+
const date = new Date('Thu, 14 Mar 2024 00:00:00 GMT');
|
|
592
|
+
for (const file of Object.values(zip.files)) {
|
|
593
|
+
file.date = date;
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const compressed = await zip.generateAsync({
|
|
597
|
+
type: 'uint8array',
|
|
598
|
+
compression: 'DEFLATE'
|
|
599
|
+
});
|
|
600
|
+
return compressed;
|
|
601
|
+
};
|
|
602
|
+
|
|
603
|
+
module.exports = {
|
|
604
|
+
fixJSON,
|
|
605
|
+
fixZip
|
|
606
|
+
};
|