Add files via upload

This commit is contained in:
BrenBroZAYT
2021-11-20 15:07:00 +02:00
committed by GitHub
parent b56b675e4f
commit 0b20a13590
5 changed files with 1005 additions and 1 deletions
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2019-2020 Thomas Weber
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+5 -1
View File
@@ -1 +1,5 @@
# ScratchYTOG.github.io
# `.sb` downloader
https://forkphorus.github.io/sb-downloader/
A downloader for Scratch 1, 2, or 3 projects.
+528
View File
@@ -0,0 +1,528 @@
<!DOCTYPE html>
<html>
<head>
<title>.sb downloader</title>
<meta charset="utf8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="referrer" content="no-referrer">
<style>
/* basic styles to make things look nicer */
body {
font-family: sans-serif;
width: 480px;
margin: 24px auto;
}
p {
margin: 8px 0 16px 0;
}
h1 {
font-size: 54px;
line-height: 72px;
margin: 0;
}
h2 {
font-size: 24px;
line-height: 32px;
margin: 16px 0 0 0;
}
h1, h2 {
font-weight: normal;
}
a {
color: #25d;
text-decoration: underline;
cursor: pointer;
}
a:visited {
color: #73c;
}
a:active {
color: #03a;
}
code {
font-family: Menlo, Monaco, Consolas, Courier New, monospace;
background: #f1f1f1;
border-radius: 3px;
margin: 3px;
color: #000;
}
/* project ID input */
#project-select {
font-size: 24px;
line-height: 32px;
border: none;
width: 100%;
color: rgba(0, 0, 0, 0.4);
}
#project-select:focus {
color: rgba(0, 0, 0, 1);
}
#project-select:disabled {
background: white;
}
/* project type input */
#type-container {
width: 100%;
text-align: center;
}
:disabled {
cursor: not-allowed;
}
/* progress bar */
#progress-bar {
position: relative;
width: 100%;
height: 35px;
margin-top: 10px;
}
#progress-bar-fill {
position: absolute;
height: 100%;
top: 0;
bottom: 0;
left: 0;
width: 0;
background-color: #cde;
transition: background-color 0.3s, width 0.3s;
}
#progress-bar-fill[state=error] {
background-color: #eaa;
}
#progress-bar-fill[state=success] {
background-color: #aea;
}
#progress-bar-text {
position: absolute;
top: 50%;
left: 5px;
transform: translateY(-50%);
z-index: 10;
}
/* asset list */
#asset-list-container {
margin-top: 10px;
}
#asset-list .name {
font-size: 10px;
}
#asset-list .preview {
max-width: 360px;
}
#asset-list {
text-align: center;
}
#asset-list:empty:after {
content: 'Nothing to preview';
font-style: italic;
}
#asset-list tr:hover td > * {
outline: 1px solid black;
}
</style>
</head>
<body>
<h1><code>.sb</code> downloader</h1>
<p><code>.sb</code> downloader downloads <a href="https://scratch.mit.edu/">Scratch</a> 1, 2, or 3 projects. Enter the project ID or URL then choose the format.</p>
<input id="project-select" value="https://scratch.mit.edu/projects/">
<div id="type-container">
<b>Download project as</b>
<button class="download-button" data-type="sb">.sb</button>
<button class="download-button" data-type="sb2">.sb2</button>
<button class="download-button" data-type="sb3">.sb3</button>
</div>
<div id="progress-bar" hidden>
<div id="progress-bar-fill"></div>
<div id="progress-bar-text"></div>
</div>
<div id="download-link"></div>
<h2>Asset Viewer</h2>
<label>
<input type="checkbox" id="asset-list-toggle"> Enable asset viewer
</label>
<div id="asset-list-container" hidden>
<i><small>Some projects cannot be previewed.</small></i>
<table width="100%">
<thead>
<tr>
<th width="50%">Name</th>
<th width="50%">Preview</th>
</tr>
</thead>
<tbody id="asset-list">
<!-- javascript will insert the assets here -->
</tbody>
</table>
</div>
<h2>Code</h2>
<p><code>.sb</code> downloader is <a href="https://github.com/forkphorus/sb-downloader">open source</a>.</p>
<h2>Credits</h2>
<p>Thanks to <a href="https://scratch.mit.edu/">Scratch</a> for providing a sufficient public API. The general look of the downloader is inspired by <a href="https://phosphorus.github.io/">phosphorus</a>. The <a href="https://stuk.github.io/jszip/">JSZip</a> library is used for creating zip archives.</p>
<script src="jszip.min.js"></script>
<script src="loader.js"></script>
<script>
(function() {
'use strict';
// Element references
const projectInput = document.querySelector('#project-select');
const progressBarFill = document.querySelector('#progress-bar-fill');
const progressBarText = document.querySelector('#progress-bar-text');
const progressBarContainer = document.querySelector('#progress-bar');
const assetTableContainer = document.querySelector('#asset-list-container');
const assetTable = document.querySelector('#asset-list');
const assetTableToggle = document.querySelector('#asset-list-toggle');
const downloadLinkEl = document.querySelector('#download-link');
const downloadButtons = document.getElementsByClassName('download-button');
// The last loaded project, if any.
let lastResult = null;
// Returns an object containing all the query string parameters
function getQuery(queryString) {
queryString = queryString || window.location.search;
const query = {};
const pairs = (queryString[0] === '?' ? queryString.substr(1) : queryString).split('&');
for (var i = 0; i < pairs.length; i++) {
const pair = pairs[i].split('=');
const key = decodeURIComponent(pair[0]);
const value = decodeURIComponent(pair[1] || '');
// If either key or value is empty, omit it.
if (key && value) {
query[key] = value;
}
}
return query;
}
// Sets a query string value without reloading the page.
function setQuery(key, value) {
const query = getQuery();
query[key] = value;
let url = '?';
for (const key of Object.keys(query)) {
const value = query[key];
// omit parameters with empty keys or values
// allows '' as a value to remove a parameter
if (!key || !value) {
continue;
}
if (url.length > 1) {
url += '&';
}
url += encodeURIComponent(key) + '=' + encodeURIComponent(value);
}
history.replaceState({}, '', url);
}
// Project ID input
projectInput.addEventListener('focus', function() {
if (getId()) {
projectInput.select();
}
})
projectInput.addEventListener('input', function() {
const id = getId() || '';
projectInput.value = 'https://scratch.mit.edu/projects/' + id;
setButtonsEnabled(!!id);
resetAssets();
hideProgress();
removeDownloadLink();
});
function extractId(input) {
const match = projectInput.value.match(/\d+/);
return match ? match[0] : null;
}
function getId() {
return extractId(projectInput.value);
}
// Type input
for (const button of downloadButtons) {
button.addEventListener('click', function() {
loadInput(getId(), button.dataset.type);
});
}
function setInputEnabled(enabled) {
projectInput.disabled = !enabled;
}
function setButtonsEnabled(enabled) {
for (const el of downloadButtons) {
el.disabled = !enabled;
}
}
function disableInputs() {
setInputEnabled(false);
setButtonsEnabled(false);
}
function enableInputs() {
setInputEnabled(true);
setButtonsEnabled(true);
}
// Progress bar methods
// Sets the current progress. Expects a number between 0 and 1
function setProgress(progress) {
progressBarFill.style.width = (10 + progress * 90) + '%';
}
// Resets the progress bar
function resetProgress() {
setProgress(0);
setProgressState('Loading...', '');
}
// Shows the progress bar
function showProgress() {
progressBarContainer.hidden = false;
}
// Hides the progress bar
function hideProgress() {
progressBarContainer.hidden = true;
}
// Sets the text and 'state' of the progress bar (usually changes color)
function setProgressState(message, state) {
progressBarText.textContent = message;
progressBarFill.setAttribute('state', state || '');
}
// Install progress hooks
let finishedTasks = 0;
let totalTasks = 0;
function updateProgressBarHooks() {
setProgress(finishedTasks / totalTasks);
setProgressState('\u23f3 Downloading project files (' + finishedTasks + '/' + totalTasks + ')');
}
SBDL.progressHooks.start = function() {
finishedTasks = 0;
totalTasks = 0;
};
SBDL.progressHooks.newTask = function() {
totalTasks++;
updateProgressBarHooks();
};
SBDL.progressHooks.finishTask = function() {
finishedTasks++;
updateProgressBarHooks();
};
// Asset listing
assetTableToggle.addEventListener('change', function(e) {
toggleAssets(e.target.checked);
e.preventDefault();
});
// Toggles the asset viewer. Might load or reset the asset list.
function toggleAssets(enabled) {
if (enabled) {
assetTableContainer.hidden = false;
setQuery('assets', 'on');
if (lastResult && lastResult.files) {
displayAssets(lastResult.files);
}
} else {
assetTableContainer.hidden = true;
setQuery('assets', '');
resetAssets();
}
assetTableToggle.checked = enabled;
}
// Removes all existing assets in the list
function resetAssets() {
while (assetTable.firstChild) {
assetTable.removeChild(assetTable.firstChild);
}
}
// Displays a list of files
function displayAssets(files) {
resetAssets();
// Used to decode text in bufferToString()
const decoder = new TextDecoder('utf8');
function bufferToString(buffer) {
return decoder.decode(new Uint8Array(buffer));
}
function getPreview(file) {
const path = file.path;
const extension = path.split('.').pop();
const IMAGE_EXTENSIONS = ['png', 'jpg', 'jpeg', 'svg'];
const AUDIO_EXTENSIONS = ['mp3', 'wav'];
const TEXT_EXTENSIONS = ['json'];
// Returns an element indicating that this asset cannot be previewed.
function cannotPreview() {
const text = document.createElement('i');
text.textContent = 'cannot preview';
return text;
}
if (IMAGE_EXTENSIONS.includes(extension)) {
if (!(file.data instanceof ArrayBuffer)) {
return cannotPreview();
}
// Images are loaded by setting an <img> src to a Blob URL
const type = extension === 'svg' ? 'svg+xml' : extension;
const blob = new Blob([file.data], {type: 'image/' + type});
const url = URL.createObjectURL(blob);
const img = document.createElement('img');
img.src = url;
return img;
} else if (AUDIO_EXTENSIONS.includes(extension)) {
if (!(file.data instanceof ArrayBuffer)) {
return cannotPreview();
}
// Sounds are loaded by loading a Blob URL in an <audio> with controls enabled
const blob = new Blob([file.data]);
const url = URL.createObjectURL(blob);
const audio = document.createElement('audio');
audio.src = url;
// enabling controls ensures that it will be visible
audio.controls = true;
audio.autoplay = false;
return audio;
} else if (TEXT_EXTENSIONS.includes(extension)) {
// Text things are loaded using a readonly <textarea>
const textarea = document.createElement('textarea');
textarea.readOnly = true;
textarea.textContent = file.data;
return textarea;
}
return cannotPreview();
}
for (const file of files) {
const row = document.createElement('tr');
const nameCell = document.createElement('td');
const previewCell = document.createElement('td');
nameCell.classList.add('name');
nameCell.appendChild(document.createTextNode(file.path));
previewCell.classList.add('preview');
previewCell.appendChild(getPreview(file));
row.appendChild(nameCell);
row.appendChild(previewCell);
assetTable.appendChild(row);
}
}
// Loads the currently input project
function loadInput(id, type) {
setQuery('id', id);
setQuery('type', type);
removeDownloadLink();
disableInputs();
resetAssets();
resetProgress();
showProgress();
downloadProject(id, type)
.then(() => {
setProgressState('\u2705 Done', 'success');
enableInputs();
setProgress(1);
})
.catch((err) => {
console.error(err);
let error = '\u274c Project is not available as a .' + type;
if (err && err.message) {
if (err.probableType) {
error += ' (It is available as a .' + err.probableType + ')';
} else if (err.message.includes('404') && type === 'sb3') {
error += ' (Project does not exist)';
}
}
setProgressState(error, 'error');
enableInputs();
setProgress(1);
});
}
// Starts loading a project. Downloads it when complete.
function downloadProject(id, type) {
lastResult = null;
return SBDL.loadProject(id, type)
.then((r) => {
lastResult = r;
setProgressState('Creating archive...');
// Convert the result to a Blob so it's easier to download.
// The result can either give us a list of files to put in an archive, or an ArrayBuffer.
if (r.type === 'zip') {
return SBDL.createArchive(r.files, setProgress);
} else if (r.type === 'buffer') {
return new Blob([r.buffer]);
} else {
throw new Error('unknown type: ' + r.type);
}
})
.then((blob) => {
// Only display assets if there are some files to preview and they will be visible.
if (lastResult.files && !assetTableContainer.hidden) {
displayAssets(lastResult.files);
}
const url = URL.createObjectURL(blob);
const filename = lastResult.title + '.' + lastResult.extension;
const size = blob.size / 1024 / 1024;
const a = document.createElement('a');
a.href = url;
a.download = filename;
a.textContent = 'Download ' + filename + ' (' + size.toFixed(2) + ' MiB)';
downloadLinkEl.appendChild(a);
a.click();
});
}
function removeDownloadLink() {
if (downloadLinkEl.firstChild) {
downloadLinkEl.removeChild(downloadLinkEl.firstChild);
}
}
// Load URL parameters
const searchQuery = getQuery();
if (searchQuery.id) {
projectInput.value = 'https://scratch.mit.edu/projects/' + searchQuery.id;
} else {
projectInput.focus();
setTimeout(function() {
projectInput.selectionStart = projectInput.selectionEnd = projectInput.value.length;
});
}
setButtonsEnabled(!!getId());
if (searchQuery.assets === 'on') {
toggleAssets(true);
}
if (searchQuery.id && searchQuery.type) {
loadInput(searchQuery.id, searchQuery.type);
}
}());
</script>
</body>
</html>
+15
View File
File diff suppressed because one or more lines are too long
+436
View File
@@ -0,0 +1,436 @@
// 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,
};
}());