Files
ScratchYTOG.github.io/loader.js
T
2021-11-20 15:07:00 +02:00

437 lines
14 KiB
JavaScript

// MIT Licensed.
// https://github.com/forkphorus/sb-downloader
// Note: The API of SBDL is not very easy to integrate. Please consider another API first.
// The API is only designed for web environments, and the progress monitoring API is very strange and doesn't support concurrent downloads properly.
// Also the API can return two types of results (zip or buffer) and you have to handle both of them in different ways.
// If you want to use this library still, see index.html for a pretty complete usage example (notably downloadProject())
// Converting projects to archives may require JSZip: https://stuk.github.io/jszip/ (tested on 3.1.5)
window.SBDL = (function() {
'use strict';
/**
* An error where the project cannot be loaded as the desired type, but it is likely that this project is of another format.
*/
class ProjectFormatError extends Error {
constructor(message, probableType) {
super(message + ' (probably a .' + probableType + ')');
this.probableType = probableType;
}
}
const SB_MAGIC = 'ScratchV0';
const ZIP_MAGIC = 'PK';
const fetchQueue = {
concurrentRequests: 0,
maxConcurrentRequests: 30,
queue: [],
add(url) {
return new Promise((resolve, reject) => {
this.queue.push({ url: url, resolve: resolve, reject: reject });
if (this.concurrentRequests < this.maxConcurrentRequests) {
this.processNext();
}
});
},
processNext() {
if (this.queue.length === 0) {
return;
}
const request = this.queue.shift();
this.concurrentRequests++;
window.fetch(request.url)
.then((r) => {
this.concurrentRequests--;
this.processNext();
request.resolve(r);
})
.catch((err) => {
this.concurrentRequests--;
this.processNext();
request.reject(err);
});
},
};
function fetch(url) {
return fetchQueue.add(url);
}
// Customizable hooks that can be overridden by other scripts to measure progress.
const progressHooks = {
// Indicates a loader has just started
start() {},
// Indicates a new task has started.
newTask() {},
// Indicates a task has finished
finishTask() {},
};
function checkMagic(buffer, magic) {
const header = new Uint8Array(buffer.slice(0, magic.length));
for (let i = 0; i < magic.length; i++) {
if (header[i] !== magic.charCodeAt(i)) {
return false;
}
}
return true;
}
// Sorts a list of files in-place.
function sortFiles(files) {
files.sort((a, b) => {
const nameA = a.path;
const nameB = b.path;
// project.json always the top
if (nameA === "project.json") {
return -1;
} else if (nameB === "project.json") {
return 1;
}
const valueA = +nameA.split('.').shift() || 0;
const valueB = +nameB.split('.').shift() || 0;
if (valueA < valueB) {
return -1;
} else if (valueA > valueB) {
return 1;
}
// Fallback to just a string compare
return nameA.localeCompare(nameB);
});
}
// Loads a Scratch 1 project
function loadScratch1Project(id) {
const PROJECTS_API = 'https://projects.scratch.mit.edu/internalapi/project/$id/get/';
const result = {
title: id.toString(),
extension: 'sb',
// Scratch 1 projects load as buffers because they use a custom format that I don't want to implement.
// The API only responds with the full file.
type: 'buffer',
buffer: null,
};
return fetch(PROJECTS_API.replace('$id', id))
.then((data) => data.arrayBuffer())
.then((buffer) => {
if (!checkMagic(buffer, SB_MAGIC)) {
throw new Error('Project is not a valid .sb file (failed magic check)');
}
result.buffer = buffer;
return result;
});
}
// Loads a Scratch 2 project
function loadScratch2Project(id) {
const PROJECTS_API = 'https://projects.scratch.mit.edu/internalapi/project/$id/get/';
// Scratch 2 projects can either by stored as JSON (project.json) or binary (sb2 file)
// JSON example: https://scratch.mit.edu/projects/15832807 (most Scratch 2 projects are like this)
// Binary example: https://scratch.mit.edu/projects/250740608
progressHooks.start();
progressHooks.newTask();
let blob;
// The fetch routine is rather complicated because we have to determine which type of project we are looking at.
return fetch(PROJECTS_API.replace('$id', id))
.then((request) => {
if (request.status !== 200) {
throw new Error('Returned status code: ' + request.status);
}
return request.blob();
})
.then((b) => {
blob = b;
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = () => resolve(fileReader.result);
fileReader.onerror = () => reject(new Error('Cannot read blob as text'));
fileReader.readAsText(blob);
});
})
.then((text) => {
let projectData;
try {
projectData = JSON.parse(text);
} catch (e) {
return loadScratch2BinaryProject(id, blob);
}
return loadScratch2JSONProject(id, projectData);
})
.then((result) => {
progressHooks.finishTask();
return result;
});
}
// Loads a Scratch 2 binary-type project
function loadScratch2BinaryProject(id, blob) {
return new Promise((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = () => {
if (!checkMagic(fileReader.result, ZIP_MAGIC)) {
if (checkMagic(fileReader.result, SB_MAGIC)) {
reject(new ProjectFormatError('File is not a valid .sb2 (failed magic check)', 'sb'))
}
reject(new Error('File is not a valid .sb2 (failed magic check)'));
return;
}
resolve({
title: id.toString(),
extension: 'sb2',
type: 'buffer',
buffer: fileReader.result,
});
};
fileReader.onerror = () => reject(new Error('Cannot read blob as array buffer'));
fileReader.readAsArrayBuffer(blob);
});
}
// Loads a Scratch 2 JSON-type project
function loadScratch2JSONProject(id, projectData) {
const ASSETS_API = 'https://cdn.assets.scratch.mit.edu/internalapi/asset/$path/get/';
const IMAGE_EXTENSIONS = [
'svg',
'png',
'jpg',
'jpeg',
'bmp',
'gif'
];
const SOUND_EXTENSIONS = [
'wav',
'mp3',
];
const result = {
title: id.toString(),
extension: 'sb2',
files: [],
type: 'zip',
};
// sb2 files have two ways of storing references to files.
// In the online editor they use md5 hashes which point to an API destination.
// In the offline editor they use separate accumulative file IDs for images and sounds.
// The files served from the Scratch API don't contain the file IDs we need to export a valid .sb2, so we must create those ourselves.
let soundAccumulator = 0;
let imageAccumulator = 0;
// Gets the md5 and extension of an object.
function md5Of(thing) {
// Search for any of the possible md5 attributes, falling back to just stringifying the input.
return thing.md5 || thing.baseLayerMD5 || thing.penLayerMD5 || thing.toString();
}
function claimAccumulatedID(extension) {
if (IMAGE_EXTENSIONS.includes(extension)) {
return imageAccumulator++;
} else if (SOUND_EXTENSIONS.includes(extension)) {
return soundAccumulator++;
} else {
throw new Error('unknown extension: ' + extension);
}
}
function addAsset(asset) {
progressHooks.newTask();
const md5 = asset.md5;
const extension = asset.extension;
const accumulator = claimAccumulatedID(extension);
const path = accumulator + '.' + extension;
// Update IDs in all references to match the accumulator
// Downloaded projects usually use -1 for all of these, but sometimes they exist and are just wrong since we're redoing them all.
for (const reference of asset.references) {
if ('baseLayerID' in reference) {
reference.baseLayerID = accumulator;
}
if ('soundID' in reference) {
reference.soundID = accumulator;
}
if ('penLayerID' in reference) {
reference.penLayerID = accumulator;
}
}
return fetch(ASSETS_API.replace('$path', md5))
.then((request) => request.arrayBuffer())
.then((buffer) => {
result.files.push({
path: path,
data: buffer,
});
progressHooks.finishTask();
});
}
// Processes a list of assets
// Finds and groups duplicate assets.
function processAssets(assets) {
// Records a list of all unique asset md5s and stores all references to an asset.
const hashToAssetMap = Object.create(null);
const allAssets = [];
for (const data of assets) {
const md5ext = md5Of(data);
if (!(md5ext in hashToAssetMap)) {
const asset = {
md5: md5ext,
extension: md5ext.split('.').pop(),
references: [],
};
hashToAssetMap[md5ext] = asset;
allAssets.push(asset);
}
hashToAssetMap[md5ext].references.push(data);
}
return allAssets;
}
const children = projectData.children.filter((c) => !c.listName && !c.target);
const targets = [].concat.apply([], [projectData, children]);
const costumes = [].concat.apply([], targets.map((c) => c.costumes || []));
const sounds = [].concat.apply([], targets.map((c) => c.sounds || []));
const assets = processAssets([].concat.apply([], [costumes, sounds, projectData]));
return Promise.all(assets.map((a) => addAsset(a)))
.then(() => {
// We must add the project JSON at the end because it was probably changed during the loading from updating asset IDs
result.files.push({path: 'project.json', data: JSON.stringify(projectData)});
sortFiles(result.files);
return result;
});
}
// Loads a Scratch 3 project
function loadScratch3Project(id) {
const PROJECTS_API = 'https://projects.scratch.mit.edu/$id';
const ASSETS_API = 'https://assets.scratch.mit.edu/internalapi/asset/$path/get/';
const result = {
title: id.toString(),
extension: 'sb3',
files: [],
type: 'zip',
};
function addFile(data) {
progressHooks.newTask();
const path = data.md5ext || data.assetId + '.' + data.dataFormat;
return fetch(ASSETS_API.replace('$path', path))
.then((request) => request.arrayBuffer())
.then((buffer) => {
result.files.push({path: path, data: buffer});
progressHooks.finishTask();
});
}
// Removes assets with the same ID
function dedupeAssets(assets) {
const result = [];
const knownIds = new Set();
for (const i of assets) {
const id = i.assetId;
if (knownIds.has(id)) {
continue;
}
knownIds.add(id);
result.push(i);
}
return result;
}
progressHooks.start();
progressHooks.newTask();
return fetch(PROJECTS_API.replace('$id', id))
.then((request) => request.json())
.then((projectData) => {
if (typeof projectData.objName === 'string') {
throw new ProjectFormatError('Not a Scratch 3 project (found objName)', 'sb2');
}
if (!Array.isArray(projectData.targets)) {
throw new Error('Not a Scratch 3 project, missing targets');
}
result.files.push({path: 'project.json', data: JSON.stringify(projectData)});
const targets = projectData.targets;
const costumes = [].concat.apply([], targets.map((t) => t.costumes || []));
const sounds = [].concat.apply([], targets.map((t) => t.sounds || []));
const assets = dedupeAssets([].concat.apply([], [costumes, sounds]));
return Promise.all(assets.map((a) => addFile(a)));
})
.then(() => {
sortFiles(result.files);
progressHooks.finishTask();
return result;
});
}
// Adds a list of files to a JSZip archive.
// This is a convenience method to make the library less painful to use. It's not used by SBDL internally.
// If a 'zip' type result is returned, pass result.files into here to get a Blob out.
// progressCallback (optional) will be called when the progress changes
function createArchive(files, progressCallback) {
const zip = new JSZip();
for (let i = 0; i < files.length; i++) {
const file = files[i];
const path = file.path;
const data = file.data;
zip.file(path, data);
}
return zip.generateAsync({
type: 'blob',
compression: 'DEFLATE',
}, function(metadata) {
if (progressCallback) {
progressCallback(metadata.percent / 100);
}
});
}
// Loads a project, automatically choses the loader
function loadProject(id, type) {
const loaders = {
sb: loadScratch1Project,
sb2: loadScratch2Project,
sb3: loadScratch3Project,
};
type = type.toString();
if (!(type in loaders)) {
return Promise.reject(new Error('Unknown type: ' + type));
}
return loaders[type](id);
}
return {
loadScratch1Project: loadScratch1Project,
loadScratch2Project: loadScratch2Project,
loadScratch3Project: loadScratch3Project,
loadProject: loadProject,
createArchive: createArchive,
progressHooks: progressHooks,
};
}());