@turbowarp/sb3fix 0.3.7 → 0.5.0
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 +106 -100
- package/package.json +31 -31
- package/src/sb3fix.js +794 -606
package/src/sb3fix.js
CHANGED
|
@@ -1,606 +1,794 @@
|
|
|
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
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
* @
|
|
18
|
-
* @
|
|
19
|
-
*/
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
};
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
if (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
const
|
|
466
|
-
if (!Array.isArray(
|
|
467
|
-
throw new Error('
|
|
468
|
-
}
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
const
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
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 {'scratch'|'turbowarp'} Platform
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* @typedef Options
|
|
17
|
+
* @property {Platform} [platform] Defaults to 'scratch'.
|
|
18
|
+
* @property {(message: string) => void} [logCallback]
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {unknown} obj
|
|
23
|
+
* @returns {obj is object}
|
|
24
|
+
*/
|
|
25
|
+
const isObject = (obj) => !!obj && typeof obj === 'object';
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Object.hasOwn() but it works in more browsers.
|
|
29
|
+
* @param {unknown} obj
|
|
30
|
+
* @param {string} property
|
|
31
|
+
* @returns {boolean} true if Object.hasOwn(obj, property)
|
|
32
|
+
*/
|
|
33
|
+
const hasOwn = (obj, property) => Object.prototype.hasOwnProperty.call(obj, property);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef PlatformInfo
|
|
37
|
+
* @property {boolean} [allowsNonScalarVariables] If true, variables don't need to be a single string/number/boolean.
|
|
38
|
+
* @property {boolean} [nativelyUnderstandsSpork] If true, the platform's editor can natively understand spork-isms in project.json and thus compatibility fixes can be skipped.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @type {Record<Platform, PlatformInfo>}
|
|
43
|
+
*/
|
|
44
|
+
const platforms = {
|
|
45
|
+
scratch: {
|
|
46
|
+
// Although the Scratch website uses spork, the desktop app still doesn't so for now, we'll keep applying the spork fixes there too.
|
|
47
|
+
nativelyUnderstandsSpork: false,
|
|
48
|
+
allowsNonScalarVariables: false
|
|
49
|
+
},
|
|
50
|
+
turbowarp: {
|
|
51
|
+
nativelyUnderstandsSpork: false,
|
|
52
|
+
allowsNonScalarVariables: true
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {Options} options
|
|
58
|
+
* @returns {PlatformInfo}
|
|
59
|
+
*/
|
|
60
|
+
const getPlatform = (options) => {
|
|
61
|
+
if (options && hasOwn(options, 'platform')) {
|
|
62
|
+
if (hasOwn(platforms, options.platform)) {
|
|
63
|
+
return platforms[options.platform];
|
|
64
|
+
}
|
|
65
|
+
throw new Error(`Unknown platform: ${options.platform}`);
|
|
66
|
+
}
|
|
67
|
+
return platforms.scratch;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const BUILTIN_EXTENSIONS = [
|
|
71
|
+
'control',
|
|
72
|
+
'data',
|
|
73
|
+
'event',
|
|
74
|
+
'looks',
|
|
75
|
+
'motion',
|
|
76
|
+
'operators',
|
|
77
|
+
'procedures',
|
|
78
|
+
'argument', // "argument_reporter_boolean" is technically not an extension but we should list here anyways
|
|
79
|
+
'sensing',
|
|
80
|
+
'sound',
|
|
81
|
+
'pen',
|
|
82
|
+
'wedo2',
|
|
83
|
+
'music',
|
|
84
|
+
'microbit',
|
|
85
|
+
'text2speech',
|
|
86
|
+
'translate',
|
|
87
|
+
'videoSensing',
|
|
88
|
+
'ev3',
|
|
89
|
+
'makeymakey',
|
|
90
|
+
'boost',
|
|
91
|
+
'gdxfor'
|
|
92
|
+
// intentionally not listing TurboWarp's 'tw' extension here.
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* @param {object} project Parsed project.json.
|
|
97
|
+
* @returns {Set<string>} Set of valid extensions, including the primitive ones, that the project loads.
|
|
98
|
+
*/
|
|
99
|
+
const getKnownExtensions = (project) => {
|
|
100
|
+
const extensions = project.extensions;
|
|
101
|
+
if (!Array.isArray(extensions)) {
|
|
102
|
+
throw new Error('extensions is not an array');
|
|
103
|
+
}
|
|
104
|
+
for (let i = 0; i < extensions.length; i++) {
|
|
105
|
+
if (typeof extensions[i] !== 'string') {
|
|
106
|
+
throw new Error(`extension ${i} is not a string`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return new Set([
|
|
110
|
+
...BUILTIN_EXTENSIONS,
|
|
111
|
+
...extensions
|
|
112
|
+
]);
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @param {string|object} data project.json as a string or as a parsed object already. If object provided, it will be modified in-place.
|
|
117
|
+
* @param {Options} [options]
|
|
118
|
+
* @returns {object} Fixed project.json object. If the `data` argument was an object, this will point to the same object.
|
|
119
|
+
*/
|
|
120
|
+
const fixJSON = (data, options = {}) => {
|
|
121
|
+
const platform = getPlatform(options);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* @param {string} message
|
|
125
|
+
*/
|
|
126
|
+
const log = (message) => {
|
|
127
|
+
if (options.logCallback) {
|
|
128
|
+
options.logCallback(message);
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* @param {string} id
|
|
134
|
+
* @param {unknown} variable
|
|
135
|
+
*/
|
|
136
|
+
const fixVariableInPlace = (id, variable) => {
|
|
137
|
+
if (!Array.isArray(variable)) {
|
|
138
|
+
throw new Error(`variable object ${id} is not an array`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const name = variable[0];
|
|
142
|
+
if (typeof name !== 'string') {
|
|
143
|
+
log(`variable or list ${id} name was not a string`);
|
|
144
|
+
variable[0] = String(variable[0]);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (!platform.allowsNonScalarVariables) {
|
|
148
|
+
const value = variable[1];
|
|
149
|
+
if (typeof value !== 'number' && typeof value !== 'string' && typeof value !== 'boolean') {
|
|
150
|
+
log(`variable ${id} value was not a Scratch-compatible value`);
|
|
151
|
+
variable[1] = String(variable[1]);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* @param {string} id
|
|
158
|
+
* @param {unknown} list
|
|
159
|
+
*/
|
|
160
|
+
const fixListInPlace = (id, list) => {
|
|
161
|
+
if (!Array.isArray(list)) {
|
|
162
|
+
throw new Error(`list object ${id} is not an array`);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const name = list[0];
|
|
166
|
+
if (typeof name !== 'string') {
|
|
167
|
+
log(`list ${id} name was not a string`);
|
|
168
|
+
list[0] = String(list[0]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (!Array.isArray(list[1])) {
|
|
172
|
+
log(`list ${id} value was not an array`);
|
|
173
|
+
list[1] = [];
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const listValue = list[1];
|
|
177
|
+
for (let i = 0; i < listValue.length; i++) {
|
|
178
|
+
const value = listValue[i];
|
|
179
|
+
if (typeof value !== 'number' && typeof value !== 'string' && typeof value !== 'boolean') {
|
|
180
|
+
log(`list ${id} index ${i} was not a Scratch-compatible value`);
|
|
181
|
+
listValue[i] = String(value);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* @param {unknown[]} native
|
|
188
|
+
* @returns {boolean} true to keep, false to delete
|
|
189
|
+
*/
|
|
190
|
+
const fixCompressedNativeInPlace = (native) => {
|
|
191
|
+
if (!Array.isArray(native)) {
|
|
192
|
+
throw new Error('native is not an array');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const type = native[0];
|
|
196
|
+
if (typeof type !== 'number') {
|
|
197
|
+
throw new Error('native type is not a number');
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
switch (type) {
|
|
201
|
+
// Number primitive: [4, string|number]
|
|
202
|
+
// Positive number primitive: [5, string|number]
|
|
203
|
+
// Whole number primitive: [6, string|number]
|
|
204
|
+
// Integer primitive: [7, string|number]
|
|
205
|
+
// Angle primitive: [8, string|number]
|
|
206
|
+
case 4:
|
|
207
|
+
case 5:
|
|
208
|
+
case 6:
|
|
209
|
+
case 7:
|
|
210
|
+
case 8: {
|
|
211
|
+
if (native.length !== 2) {
|
|
212
|
+
throw new Error(`Number native is of unexpected length: ${native.length}`);
|
|
213
|
+
}
|
|
214
|
+
const value = native[1];
|
|
215
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
216
|
+
log('number native had invalid value');
|
|
217
|
+
native[1] = String(value);
|
|
218
|
+
}
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// Color: [9, hex color]
|
|
223
|
+
case 9: {
|
|
224
|
+
if (native.length !== 2) {
|
|
225
|
+
throw new Error(`Color native is of unexpected length: ${native.length}`);
|
|
226
|
+
}
|
|
227
|
+
const color = native[1];
|
|
228
|
+
if (typeof color !== 'string' || !/^#[a-f0-9]{6}$/i.test(color)) {
|
|
229
|
+
log('color native had invalid value');
|
|
230
|
+
native[1] = '#000000';
|
|
231
|
+
}
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Text: [10, string|number]
|
|
236
|
+
case 10: {
|
|
237
|
+
if (native.length !== 2) {
|
|
238
|
+
throw new Error(`Text native is of unexpected length: ${native.length}`);
|
|
239
|
+
}
|
|
240
|
+
const value = native[1];
|
|
241
|
+
if (typeof value !== 'string' && typeof value !== 'number') {
|
|
242
|
+
log('text native had invalid value');
|
|
243
|
+
native[1] = String(value);
|
|
244
|
+
}
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Broadcast: [11, name, id]
|
|
249
|
+
case 11: {
|
|
250
|
+
if (native.length !== 3) {
|
|
251
|
+
throw new Error(`Broadcast native is of unexpected length: ${native.length}`);
|
|
252
|
+
}
|
|
253
|
+
const name = native[1];
|
|
254
|
+
if (typeof name !== 'string') {
|
|
255
|
+
log(`broadcast native name was not a string`);
|
|
256
|
+
native[1] = String(native[1]);
|
|
257
|
+
}
|
|
258
|
+
const id = native[2];
|
|
259
|
+
if (typeof id !== 'string') {
|
|
260
|
+
log(`deleting compressed broadcast reference with non-string ID`);
|
|
261
|
+
return false;
|
|
262
|
+
}
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Variable: [12, variable name, variable id, x?, y?]
|
|
267
|
+
// List: [13, list name, list id, x?, y?]
|
|
268
|
+
// x and y only present if the native is a top-level block
|
|
269
|
+
case 12:
|
|
270
|
+
case 13: {
|
|
271
|
+
if (native.length !== 3 && native.length !== 5) {
|
|
272
|
+
throw new Error(`Variable or list native is of unexpected length: ${native.length}`);
|
|
273
|
+
}
|
|
274
|
+
const name = native[1];
|
|
275
|
+
if (typeof name !== 'string') {
|
|
276
|
+
log(`variable or list native name was not a string`);
|
|
277
|
+
native[1] = String(native[1]);
|
|
278
|
+
}
|
|
279
|
+
const id = native[2];
|
|
280
|
+
if (typeof id !== 'string') {
|
|
281
|
+
log(`deleting compressed variable or list reference with non-string ID`);
|
|
282
|
+
return false;
|
|
283
|
+
}
|
|
284
|
+
return true;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
default:
|
|
288
|
+
throw new Error(`Unknown native type: ${type}`);
|
|
289
|
+
}
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* @param {string} id
|
|
294
|
+
* @param {unknown} block
|
|
295
|
+
* @returns {boolean} true to keep, false to delete
|
|
296
|
+
*/
|
|
297
|
+
const fixBlockInPlace = (id, block) => {
|
|
298
|
+
if (Array.isArray(block)) {
|
|
299
|
+
return fixCompressedNativeInPlace(block);
|
|
300
|
+
} else if (isObject(block)) {
|
|
301
|
+
const inputs = block.inputs;
|
|
302
|
+
if (!isObject(inputs)) {
|
|
303
|
+
throw new Error('inputs is not an object');
|
|
304
|
+
}
|
|
305
|
+
for (const [inputName, input] of Object.entries(inputs)) {
|
|
306
|
+
if (!Array.isArray(input)) {
|
|
307
|
+
throw new Error(`block ${id} input ${inputName} is not an array`);
|
|
308
|
+
}
|
|
309
|
+
for (let i = 1; i < input.length; i++) {
|
|
310
|
+
if (Array.isArray(input[i])) {
|
|
311
|
+
if (!fixCompressedNativeInPlace(input[i])) {
|
|
312
|
+
// Logging happens in fixCompressedNativeInPlace
|
|
313
|
+
input[i] = null;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const fields = block.fields;
|
|
320
|
+
if (!isObject(fields)) {
|
|
321
|
+
throw new Error('fields is not an object');
|
|
322
|
+
}
|
|
323
|
+
for (const [fieldName, field] of Object.entries(fields)) {
|
|
324
|
+
if (!Array.isArray(field)) {
|
|
325
|
+
throw new Error(`block ${id} field ${fieldName} is not an array`);
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
return true;
|
|
330
|
+
} else {
|
|
331
|
+
throw new Error(`block ${id} is not an object`);
|
|
332
|
+
}
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Fix backwards-incompatible changes that Scratch made in the spork migration.
|
|
337
|
+
* @param {object} blocks
|
|
338
|
+
*/
|
|
339
|
+
const fixSporkBackwardsIncompatibilityInPlace = (blocks) => {
|
|
340
|
+
for (const [blockId, block] of Object.entries(blocks)) {
|
|
341
|
+
if (!isObject(block)) {
|
|
342
|
+
continue;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
switch (block.opcode) {
|
|
346
|
+
// Custom block definition prototype blocks used to be marked as shadow: true, but spork marks as shadow: false.
|
|
347
|
+
// Our scratch-blocks relies on it being shadow: true to prevent moving, so we'll force it to be that way.
|
|
348
|
+
case 'procedures_prototype':
|
|
349
|
+
if (block.shadow !== true) {
|
|
350
|
+
log(`(spork) forcing shadow on procedures_prototype block ${blockId}`);
|
|
351
|
+
block.shadow = true;
|
|
352
|
+
}
|
|
353
|
+
break;
|
|
354
|
+
|
|
355
|
+
// For completeness with the above, set the argument reporter generators to be shadow: true as well.
|
|
356
|
+
case 'argument_reporter_string_number':
|
|
357
|
+
case 'argument_reporter_boolean': {
|
|
358
|
+
const parent = blocks[block.parent];
|
|
359
|
+
if (isObject(parent) && parent.opcode === 'procedures_prototype' && block.shadow !== true) {
|
|
360
|
+
log(`(spork) forcing shadow on ${block.opcode} block ${blockId}`);
|
|
361
|
+
block.shadow = true;
|
|
362
|
+
}
|
|
363
|
+
break;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// control_stop used to define a mutation for whether it has a connection below, which is what old
|
|
367
|
+
// scratch-blocks relies on to determine if there is another conneciton below or not. Spork does not define
|
|
368
|
+
// this mutation and relies only on the STOP_OPTION field. We will generate the mutation if it's missing so
|
|
369
|
+
// that a "stop other scripts in sprite" block doesn't cause the workspace to fail to load.
|
|
370
|
+
case 'control_stop': {
|
|
371
|
+
if (!block.mutation) {
|
|
372
|
+
const field = block.fields && block.fields.STOP_OPTION;
|
|
373
|
+
const stopOption = Array.isArray(field) ? field[0] : null;
|
|
374
|
+
const hasNext = stopOption === 'other scripts in sprite' || stopOption === 'other scripts in stage';
|
|
375
|
+
if (hasNext) {
|
|
376
|
+
log(`(spork) generating mutation on control_stop block ${blockId}`);
|
|
377
|
+
block.mutation = {
|
|
378
|
+
tagName: 'mutation',
|
|
379
|
+
hasnext: hasNext ? 'true' : 'false',
|
|
380
|
+
children: []
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
break;
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* @param {string} id
|
|
392
|
+
* @param {unknown} comment
|
|
393
|
+
*/
|
|
394
|
+
const fixCommentInPlace = (id, comment) => {
|
|
395
|
+
if (!isObject(comment)) {
|
|
396
|
+
throw new Error('comment is not an object');
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (typeof comment.text !== 'string') {
|
|
400
|
+
throw new Error('comment text is not a string');
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
// Scratch requires comments to not exceed 8000 characters.
|
|
404
|
+
// We'll store the excess in .extraText so the text won't be truncated if opened in TurboWarp.
|
|
405
|
+
const MAX_LENGTH = 8000;
|
|
406
|
+
if (comment.text.length > MAX_LENGTH) {
|
|
407
|
+
log(`comment ${id} had length ${comment.text.length}`);
|
|
408
|
+
comment.extraText = comment.text.substring(MAX_LENGTH);
|
|
409
|
+
comment.text = comment.text.substring(0, MAX_LENGTH);
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* @param {unknown} target
|
|
415
|
+
*/
|
|
416
|
+
const fixTargetInPlace = (target) => {
|
|
417
|
+
const costumes = target.costumes;
|
|
418
|
+
if (!Array.isArray(costumes)) {
|
|
419
|
+
throw new Error('costumes is not an array');
|
|
420
|
+
}
|
|
421
|
+
for (let i = costumes.length - 1; i >= 0; i--) {
|
|
422
|
+
const costume = costumes[i];
|
|
423
|
+
if (!isObject(costume)) {
|
|
424
|
+
throw new Error(`costume ${i} is not an object`);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
if (typeof costume.name !== 'string') {
|
|
428
|
+
log(`costume ${i} name was not a string`);
|
|
429
|
+
costume.name = String(costume.name);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// https://github.com/scratchfoundation/scratch-parser/blob/665f05d739a202d565a4af70a201909393d456b2/lib/sb3_definitions.json#L51
|
|
433
|
+
const knownCostumeFormats = ['png', 'svg', 'jpeg', 'jpg', 'bmp', 'gif'];
|
|
434
|
+
if (!knownCostumeFormats.includes(costume.dataFormat)) {
|
|
435
|
+
if (typeof costume.md5ext === 'string' && costume.md5ext.endsWith('.svg')) {
|
|
436
|
+
log(`costume ${i} is vector, had invalid dataFormat ${costume.dataFormat}`);
|
|
437
|
+
costume.dataFormat = 'svg';
|
|
438
|
+
} else {
|
|
439
|
+
log(`costume ${i} is bitmap, had invalid dataFormat ${costume.dataFormat}`);
|
|
440
|
+
// dataFormat is only really used to detect vector or bitmap, so we don't
|
|
441
|
+
// need to set this to the real format
|
|
442
|
+
costume.dataFormat = 'png';
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
if (!('assetId' in costume)) {
|
|
447
|
+
log(`costume ${i} was missing assetId, deleted`);
|
|
448
|
+
costumes.splice(i, 1);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
if (costumes.length === 0) {
|
|
452
|
+
log(`costumes was empty, adding empty costume`);
|
|
453
|
+
costumes.push({
|
|
454
|
+
// Empty SVG costume
|
|
455
|
+
name: 'costume1',
|
|
456
|
+
bitmapResolution: 1,
|
|
457
|
+
dataFormat: 'svg',
|
|
458
|
+
assetId: 'cd21514d0531fdffb22204e0ec5ed84a',
|
|
459
|
+
md5ext: 'cd21514d0531fdffb22204e0ec5ed84a.svg',
|
|
460
|
+
rotationCenterX: 0,
|
|
461
|
+
rotationCenterY: 0
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const sounds = target.sounds;
|
|
466
|
+
if (!Array.isArray(sounds)) {
|
|
467
|
+
throw new Error('sounds is not an array');
|
|
468
|
+
}
|
|
469
|
+
for (let i = sounds.length - 1; i >= 0; i--) {
|
|
470
|
+
const sound = sounds[i];
|
|
471
|
+
if (!isObject(sound)) {
|
|
472
|
+
throw new Error(`sound ${i} is not an object`);
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// https://github.com/scratchfoundation/scratch-parser/blob/665f05d739a202d565a4af70a201909393d456b2/lib/sb3_definitions.json#L81
|
|
476
|
+
const knownSoundFormats = ['wav', 'wave', 'mp3'];
|
|
477
|
+
if (!knownSoundFormats.includes(sound.dataFormat)) {
|
|
478
|
+
log(`sound ${i} had invalid dataFormat ${sound.dataFormat}`);
|
|
479
|
+
sound.dataFormat = 'mp3';
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
if (typeof sound.name !== 'string') {
|
|
483
|
+
log(`sound ${i} name was not a string`);
|
|
484
|
+
sound.name = String(sound.name);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
if (!('assetId' in sound)) {
|
|
488
|
+
log(`sound ${i} was missing assetId, deleted`);
|
|
489
|
+
sounds.splice(i, 1);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
const blocks = target.blocks;
|
|
494
|
+
if (!isObject(blocks)) {
|
|
495
|
+
throw new Error('blocks is not an object');
|
|
496
|
+
}
|
|
497
|
+
for (const [blockId, block] of Object.entries(blocks)) {
|
|
498
|
+
if (!fixBlockInPlace(blockId, block)) {
|
|
499
|
+
// Logging happens in fixBlockInPlace
|
|
500
|
+
delete blocks[blockId];
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
if (!platform.nativelyUnderstandsSpork) {
|
|
505
|
+
fixSporkBackwardsIncompatibilityInPlace(blocks);
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// Comments are not required
|
|
509
|
+
const comments = target.comments;
|
|
510
|
+
if (comments) {
|
|
511
|
+
for (const [commentId, comment] of Object.entries(comments)) {
|
|
512
|
+
fixCommentInPlace(commentId, comment);
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
const variables = target.variables;
|
|
517
|
+
if (!isObject(variables)) {
|
|
518
|
+
throw new Error('variables is not an object');
|
|
519
|
+
}
|
|
520
|
+
for (const [variableId, variable] of Object.entries(variables)) {
|
|
521
|
+
fixVariableInPlace(variableId, variable);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const lists = target.lists;
|
|
525
|
+
if (!isObject(lists)) {
|
|
526
|
+
throw new Error('lists is not an object');
|
|
527
|
+
}
|
|
528
|
+
for (const [listId, list] of Object.entries(lists)) {
|
|
529
|
+
fixListInPlace(listId, list);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
if (target.isStage) {
|
|
533
|
+
if (target.layerOrder !== 0) {
|
|
534
|
+
log('stage had invalid layerOrder');
|
|
535
|
+
target.layerOrder = 0;
|
|
536
|
+
}
|
|
537
|
+
} else {
|
|
538
|
+
if (target.layerOrder < 1) {
|
|
539
|
+
log('sprite had invalid layerOrder');
|
|
540
|
+
target.layerOrder = 1;
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
const ROTATION_STYLES = [
|
|
545
|
+
'all around',
|
|
546
|
+
'don\'t rotate',
|
|
547
|
+
'left-right'
|
|
548
|
+
];
|
|
549
|
+
if (!target.isStage && !ROTATION_STYLES.includes(target.rotationStyle)) {
|
|
550
|
+
log(`sprite had invalid rotation style ${target.rotationStyle}`);
|
|
551
|
+
target.rotationStyle = 'all around';
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (!target.isStage) {
|
|
555
|
+
const x = target.x;
|
|
556
|
+
if (typeof x !== 'number') {
|
|
557
|
+
log(`target x was ${typeof x}: ${x}`);
|
|
558
|
+
target.x = +x || 0;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
const y = target.y;
|
|
562
|
+
if (typeof y !== 'number') {
|
|
563
|
+
log(`target y was ${typeof y}: ${y}`);
|
|
564
|
+
target.y = +y || 0;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* @param {unknown} stage
|
|
571
|
+
*/
|
|
572
|
+
const fixStageInPlace = (stage) => {
|
|
573
|
+
// stage's name must match exactly
|
|
574
|
+
if (stage.name !== 'Stage') {
|
|
575
|
+
log(`stage had wrong name: ${stage.name}`);
|
|
576
|
+
stage.name = 'Stage';
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
// In vanilla Scratch, "turn video < ... >" with anything that isn't the dropdown allows videoState
|
|
580
|
+
// to be set to something that isn't one of the expected strings. We'll play it safe and default to
|
|
581
|
+
// off.
|
|
582
|
+
const VIDEO_STATES = [
|
|
583
|
+
'on',
|
|
584
|
+
'off',
|
|
585
|
+
'on-flipped'
|
|
586
|
+
];
|
|
587
|
+
if (hasOwn(stage, 'videoState') && !VIDEO_STATES.includes(stage.videoState)) {
|
|
588
|
+
log(`stage had invalid videoState: ${stage.videoState}`);
|
|
589
|
+
stage.videoState = 'off';
|
|
590
|
+
}
|
|
591
|
+
};
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* @param {unknown} project
|
|
595
|
+
*/
|
|
596
|
+
const fixProjectInPlace = (project) => {
|
|
597
|
+
if ('objName' in project) {
|
|
598
|
+
throw new Error('Scratch 2 (sb2) projects not supported');
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (!isObject(project)) {
|
|
602
|
+
throw new Error('Root JSON is not an object');
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if ('name' in project) {
|
|
606
|
+
// Not a project. Just a sprite.
|
|
607
|
+
log('project is a sprite');
|
|
608
|
+
fixTargetInPlace(project);
|
|
609
|
+
return;
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
const targets = project.targets;
|
|
613
|
+
if (!Array.isArray(targets)) {
|
|
614
|
+
throw new Error('targets is not an array');
|
|
615
|
+
}
|
|
616
|
+
if (targets.length < 1) {
|
|
617
|
+
throw new Error('targets is empty');
|
|
618
|
+
}
|
|
619
|
+
for (let i = 0; i < targets.length; i++) {
|
|
620
|
+
log(`checking target ${i}`);
|
|
621
|
+
const target = targets[i];
|
|
622
|
+
if (!isObject(target)) {
|
|
623
|
+
throw new Error('target is not an object');
|
|
624
|
+
}
|
|
625
|
+
fixTargetInPlace(target);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
const allStages = targets.filter((target) => target.isStage);
|
|
629
|
+
if (allStages.length === 0) {
|
|
630
|
+
log('stage is missing; adding an empty one');
|
|
631
|
+
targets.unshift({
|
|
632
|
+
isStage: true,
|
|
633
|
+
name: 'Stage',
|
|
634
|
+
variables: {},
|
|
635
|
+
lists: {},
|
|
636
|
+
broadcasts: {},
|
|
637
|
+
blocks: {},
|
|
638
|
+
currentCostume: 0,
|
|
639
|
+
costumes: [
|
|
640
|
+
{
|
|
641
|
+
name: 'backdrop1',
|
|
642
|
+
dataFormat: 'svg',
|
|
643
|
+
assetId: 'cd21514d0531fdffb22204e0ec5ed84a',
|
|
644
|
+
md5ext: 'cd21514d0531fdffb22204e0ec5ed84a.svg',
|
|
645
|
+
rotationCenterX: 240,
|
|
646
|
+
rotationCenterY: 180
|
|
647
|
+
}
|
|
648
|
+
],
|
|
649
|
+
sounds: [],
|
|
650
|
+
volume: 100,
|
|
651
|
+
layerOrder: 0,
|
|
652
|
+
tempo: 60,
|
|
653
|
+
videoTransparency: 50,
|
|
654
|
+
videoState: "on",
|
|
655
|
+
textToSpeechLanguage: null
|
|
656
|
+
});
|
|
657
|
+
} else {
|
|
658
|
+
// We will accept the first stage in targets as the real stage
|
|
659
|
+
const firstStageIndex = targets.findIndex((target) => target.isStage);
|
|
660
|
+
|
|
661
|
+
// Stage must be the first target
|
|
662
|
+
if (firstStageIndex !== 0) {
|
|
663
|
+
log(`stage was at wrong index: ${firstStageIndex}`);
|
|
664
|
+
const stage = targets[firstStageIndex];
|
|
665
|
+
targets.splice(firstStageIndex, 1);
|
|
666
|
+
targets.unshift(stage);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Remove all the other stages
|
|
670
|
+
for (let i = targets.length - 1; i > 0; i--) {
|
|
671
|
+
if (targets[i].isStage) {
|
|
672
|
+
log(`removing extra stage at index ${i}`);
|
|
673
|
+
targets.splice(i, 1);
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Above checks ensure this invariant holds
|
|
679
|
+
const stage = targets[0];
|
|
680
|
+
fixStageInPlace(stage);
|
|
681
|
+
|
|
682
|
+
const knownExtensions = getKnownExtensions(project);
|
|
683
|
+
const monitors = project.monitors;
|
|
684
|
+
if (!Array.isArray(monitors)) {
|
|
685
|
+
throw new Error('monitors is not an array');
|
|
686
|
+
}
|
|
687
|
+
project.monitors = project.monitors.filter((monitor, i) => {
|
|
688
|
+
const opcode = monitor.opcode;
|
|
689
|
+
if (typeof opcode !== 'string') {
|
|
690
|
+
throw new Error(`monitor ${i} opcode is not a string`);
|
|
691
|
+
}
|
|
692
|
+
const extension = opcode.split('_')[0];
|
|
693
|
+
if (!knownExtensions.has(extension)) {
|
|
694
|
+
log(`removed monitor ${i} from unknown extension ${extension}`);
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
return true;
|
|
698
|
+
});
|
|
699
|
+
};
|
|
700
|
+
|
|
701
|
+
if (typeof data === 'object' && data !== null) {
|
|
702
|
+
// Already parsed.
|
|
703
|
+
fixProjectInPlace(data);
|
|
704
|
+
return data;
|
|
705
|
+
} else if (typeof data === 'string') {
|
|
706
|
+
// Need to parse.
|
|
707
|
+
const parsed = JSON.parse(data);
|
|
708
|
+
fixProjectInPlace(parsed);
|
|
709
|
+
return parsed;
|
|
710
|
+
} else {
|
|
711
|
+
throw new Error('Unable to tell how to interpret input as JSON');
|
|
712
|
+
}
|
|
713
|
+
};
|
|
714
|
+
|
|
715
|
+
/**
|
|
716
|
+
* @param {ArrayBuffer|Uint8Array|Blob} data A compressed .sb3 file.
|
|
717
|
+
* @param {Options} [options]
|
|
718
|
+
* @returns {Promise<Uint8Array>} A promise that resolves to a fixed compressed .sb3 file.
|
|
719
|
+
*/
|
|
720
|
+
const fixZip = async (data, options = {}) => {
|
|
721
|
+
/**
|
|
722
|
+
* @param {string} message
|
|
723
|
+
*/
|
|
724
|
+
const log = (message) => {
|
|
725
|
+
if (options.logCallback) {
|
|
726
|
+
options.logCallback(message);
|
|
727
|
+
}
|
|
728
|
+
};
|
|
729
|
+
|
|
730
|
+
// JSZip is not a small library, so we'll load it somewhat lazily.
|
|
731
|
+
const JSZip = require('@turbowarp/jszip');
|
|
732
|
+
|
|
733
|
+
let zip = await JSZip.loadAsync(data, {
|
|
734
|
+
recoverCorrupted: true,
|
|
735
|
+
onCorruptCentralDirectory: (error) => {
|
|
736
|
+
log(`zip had corrupt central directory: ${error}`);
|
|
737
|
+
},
|
|
738
|
+
onUnrecoverableFileEntry: (error) => {
|
|
739
|
+
log(`zip had unrecoverable file entry: ${error}`);
|
|
740
|
+
}
|
|
741
|
+
});
|
|
742
|
+
|
|
743
|
+
// Remove any unreadable files from the zip. This can notably happen if the compressed data in the zip was
|
|
744
|
+
// corrupted, which would make the uncompressed data size field not match. Scratch/JSZip will refuse to
|
|
745
|
+
// keep loading the project if that happens. If we remove the asset, at least there's a chance it can now
|
|
746
|
+
// be downloaded from the asset server instead.
|
|
747
|
+
for (const [relativePath, file] of Object.entries(zip.files)) {
|
|
748
|
+
if (file.dir) {
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
if (/(?:project|sprite)\.json/.test(relativePath) || /[0-9a-f]{32}\./.test(relativePath)) {
|
|
753
|
+
try {
|
|
754
|
+
// If the file is corrupt, this will throw.
|
|
755
|
+
await file.async('uint8array');
|
|
756
|
+
} catch (error) {
|
|
757
|
+
log(`zip had unreadable file ${relativePath}: ${error}`);
|
|
758
|
+
zip.remove(relativePath);
|
|
759
|
+
}
|
|
760
|
+
} else {
|
|
761
|
+
log(`zip had extraneous file ${relativePath}`);
|
|
762
|
+
zip.remove(relativePath);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// json is not guaranteed to be stored in the root.
|
|
767
|
+
const jsonFile = zip.file(/(?:project|sprite)\.json/)[0];
|
|
768
|
+
if (!jsonFile) {
|
|
769
|
+
throw new Error('Could not find project.json or sprite.json.');
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
const jsonText = await jsonFile.async('text');
|
|
773
|
+
const fixedJSON = fixJSON(jsonText, options);
|
|
774
|
+
const newProjectJSONText = JSON.stringify(fixedJSON);
|
|
775
|
+
zip.file(jsonFile.name, newProjectJSONText);
|
|
776
|
+
|
|
777
|
+
// By default, JSZip will use the current date as the modified timestamp, which would generated zips non-deterministic.
|
|
778
|
+
const date = new Date('Thu, 14 Mar 2024 00:00:00 GMT');
|
|
779
|
+
for (const file of Object.values(zip.files)) {
|
|
780
|
+
file.date = date;
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const compressed = await zip.generateAsync({
|
|
784
|
+
type: 'uint8array',
|
|
785
|
+
compression: 'DEFLATE'
|
|
786
|
+
});
|
|
787
|
+
return compressed;
|
|
788
|
+
};
|
|
789
|
+
|
|
790
|
+
module.exports = {
|
|
791
|
+
fixJSON,
|
|
792
|
+
fixZip,
|
|
793
|
+
platforms
|
|
794
|
+
};
|