diff --git a/package-lock.json b/package-lock.json
index 0c99f6a1204..8e85600d2cc 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,6 +10,7 @@
"license": "GPL-3.0",
"dependencies": {
"@microbit/microbit-universal-hex": "0.2.2",
+ "@turbowarp/jszip": "^3.11.1",
"@turbowarp/nanolog": "^0.2.0",
"@turbowarp/scratch-l10n": "^3.1001.0-202401241456-994097a5",
"@turbowarp/scratch-storage": "^0.0.202403251715",
@@ -2171,6 +2172,7 @@
"version": "3.11.1",
"resolved": "https://registry.npmjs.org/@turbowarp/jszip/-/jszip-3.11.1.tgz",
"integrity": "sha512-1tWXTxAac1T/g0VHC9lIY0Ij7Qyt7sORIaAT4L0/Y+pjU1ZtXD9ti/+RnXzTVHXp6AM8fM2O3mF22/aSEVPXiQ==",
+ "license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
diff --git a/package.json b/package.json
index fea7679c0db..0b40591800b 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
},
"dependencies": {
"@microbit/microbit-universal-hex": "0.2.2",
+ "@turbowarp/jszip": "^3.11.1",
"@turbowarp/nanolog": "^0.2.0",
"@turbowarp/scratch-l10n": "^3.1001.0-202401241456-994097a5",
"@turbowarp/scratch-storage": "^0.0.202403251715",
diff --git a/src/components/tw-restore-point-modal/export.svg b/src/components/tw-restore-point-modal/export.svg
new file mode 100644
index 00000000000..4ab3835c013
--- /dev/null
+++ b/src/components/tw-restore-point-modal/export.svg
@@ -0,0 +1,2 @@
+
+
\ No newline at end of file
diff --git a/src/components/tw-restore-point-modal/restore-point-modal.css b/src/components/tw-restore-point-modal/restore-point-modal.css
index 1c295003e7e..9a039f8c54f 100644
--- a/src/components/tw-restore-point-modal/restore-point-modal.css
+++ b/src/components/tw-restore-point-modal/restore-point-modal.css
@@ -2,7 +2,7 @@
@import "../../css/filters.css";
.modal-content {
- max-width: 550px;
+ max-width: 600px;
margin-top: 50px;
}
@@ -96,25 +96,34 @@
font-size: 2em;
}
-.delete-button {
+.restore-point-buttons {
+ margin-left: auto;
+ display: flex;
+ flex-direction: row;
+ gap: 0.5rem;
+}
+
+.restore-point-button {
appearance: none;
background: none;
border: none;
border-radius: 100%;
width: 2rem;
height: 2rem;
- margin-left: auto;
display: flex;
align-items: center;
justify-content: center;
}
-.delete-button img {
+.restore-point-button[disabled] {
+ opacity: 0.5;
+}
+.restore-point-button img {
display: block;
width: 75%;
height: 75%;
filter: $filter-icon-gray;
}
-.delete-button:hover {
+.restore-point-button:not([disabled]):hover {
background-color: $ui-black-transparent;
}
diff --git a/src/components/tw-restore-point-modal/restore-point-modal.jsx b/src/components/tw-restore-point-modal/restore-point-modal.jsx
index 886f1dcdb10..ae027eb97ff 100644
--- a/src/components/tw-restore-point-modal/restore-point-modal.jsx
+++ b/src/components/tw-restore-point-modal/restore-point-modal.jsx
@@ -166,7 +166,9 @@ const RestorePointModal = props => (
))}
@@ -221,7 +223,9 @@ RestorePointModal.propTypes = {
onClickCreate: PropTypes.func.isRequired,
onClickDelete: PropTypes.func.isRequired,
onClickDeleteAll: PropTypes.func.isRequired,
+ onClickExport: PropTypes.func.isRequired,
onClickLoad: PropTypes.func.isRequired,
+ isExporting: PropTypes.func.isRequired,
isLoading: PropTypes.bool.isRequired,
totalSize: PropTypes.number.isRequired,
restorePoints: PropTypes.arrayOf(PropTypes.shape({})),
diff --git a/src/components/tw-restore-point-modal/restore-point.jsx b/src/components/tw-restore-point-modal/restore-point.jsx
index 0ce1bf7e166..2dbcea8310a 100644
--- a/src/components/tw-restore-point-modal/restore-point.jsx
+++ b/src/components/tw-restore-point-modal/restore-point.jsx
@@ -6,6 +6,7 @@ import styles from './restore-point-modal.css';
import {formatBytes} from '../../lib/tw-bytes-utils';
import RestorePointAPI from '../../lib/tw-restore-point-api';
import log from '../../lib/log';
+import exportIcon from './export.svg';
import deleteIcon from './delete.svg';
// Browser support is not perfect yet
@@ -16,6 +17,7 @@ class RestorePoint extends React.Component {
super(props);
bindAll(this, [
'handleClickDelete',
+ 'handleClickExport',
'handleClickLoad'
]);
this.state = {
@@ -69,6 +71,11 @@ class RestorePoint extends React.Component {
this.props.onClickDelete(this.props.id);
}
+ handleClickExport (e) {
+ e.stopPropagation();
+ this.props.onClickExport(this.props.id);
+ }
+
handleClickLoad () {
this.props.onClickLoad(this.props.id);
}
@@ -129,16 +136,31 @@ class RestorePoint extends React.Component {
-
+
+
+
+
+
);
}
@@ -151,7 +173,9 @@ RestorePoint.propTypes = {
projectSize: PropTypes.number.isRequired,
thumbnailSize: PropTypes.number.isRequired,
assets: PropTypes.shape({}).isRequired, // Record
+ isExporting: PropTypes.bool.isRequired,
onClickDelete: PropTypes.func.isRequired,
+ onClickExport: PropTypes.func.isRequired,
onClickLoad: PropTypes.func.isRequired
};
diff --git a/src/containers/tw-restore-point-manager.jsx b/src/containers/tw-restore-point-manager.jsx
index 3dc6f8311d2..39745bc8846 100644
--- a/src/containers/tw-restore-point-manager.jsx
+++ b/src/containers/tw-restore-point-manager.jsx
@@ -10,6 +10,7 @@ import {setFileHandle} from '../reducers/tw';
import TWRestorePointModal from '../components/tw-restore-point-modal/restore-point-modal.jsx';
import RestorePointAPI from '../lib/tw-restore-point-api';
import log from '../lib/log';
+import downloadBlob from '../lib/download-blob.js';
/* eslint-disable no-alert */
@@ -38,6 +39,11 @@ const messages = defineMessages({
defaultMessage: 'Error loading restore point: {error}',
description: 'Error message when a restore point could not be loaded',
id: 'tw.restorePoints.error'
+ },
+ exportError: {
+ defaultMessage: 'Error exporting restore point: {error}',
+ description: 'Error message when a restore point could not be exported',
+ id: 'tw.restorePoints.exportError'
}
});
@@ -50,14 +56,17 @@ class TWRestorePointManager extends React.Component {
'handleClickDelete',
'handleClickDeleteAll',
'handleChangeInterval',
- 'handleClickLoad'
+ 'handleClickExport',
+ 'handleClickLoad',
+ 'isExportingRestorePoint'
]);
this.state = {
loading: true,
totalSize: 0,
restorePoints: [],
error: null,
- interval: RestorePointAPI.readInterval()
+ interval: RestorePointAPI.readInterval(),
+ exportingRestorePoints: []
};
this.timeout = null;
}
@@ -148,6 +157,39 @@ class TWRestorePointManager extends React.Component {
return true;
}
+ handleClickExport (id) {
+ if (this.isExportingRestorePoint(id)) {
+ return;
+ }
+
+ this.setState(oldState => ({
+ exportingRestorePoints: [...oldState.exportingRestorePoints, id]
+ }));
+
+ const removeFromExportingList = () => {
+ this.setState(oldState => ({
+ exportingRestorePoints: oldState.exportingRestorePoints.filter(i => i !== id)
+ }));
+ };
+
+ RestorePointAPI.exportRestorePoint(id)
+ .then(result => {
+ downloadBlob(`${result.title}.sb3`, result.blob);
+ removeFromExportingList();
+ })
+ .catch(error => {
+ log.error(error);
+ alert(this.props.intl.formatMessage(messages.exportError, {
+ error
+ }));
+ removeFromExportingList();
+ });
+ }
+
+ isExportingRestorePoint (id) {
+ return this.state.exportingRestorePoints.includes(id);
+ }
+
handleClickLoad (id) {
if (!this.canLoadProject()) {
return;
@@ -271,9 +313,11 @@ class TWRestorePointManager extends React.Component {
onClickCreate={this.handleClickCreate}
onClickDelete={this.handleClickDelete}
onClickDeleteAll={this.handleClickDeleteAll}
+ onClickExport={this.handleClickExport}
onClickLoad={this.handleClickLoad}
interval={this.state.interval}
onChangeInterval={this.handleChangeInterval}
+ isExporting={this.isExportingRestorePoint}
isLoading={this.state.loading}
totalSize={this.state.totalSize}
restorePoints={this.state.restorePoints}
diff --git a/src/lib/tw-restore-point-api.js b/src/lib/tw-restore-point-api.js
index 4f512fb3c91..045cd91f606 100644
--- a/src/lib/tw-restore-point-api.js
+++ b/src/lib/tw-restore-point-api.js
@@ -1,3 +1,4 @@
+import JSZip from '@turbowarp/jszip';
import {base64ToArrayBuffer} from './tw-base64-utils';
const TYPE_AUTOMATIC = 0;
@@ -435,6 +436,114 @@ const deleteAllRestorePoints = () => openDB().then(db => new Promise((resolveTra
deleteEverything();
}));
+/**
+ * @param {number} id the restore point's ID
+ * @returns {Promise<{title: string, blob: Blob}>} Resolves with compressed project data and title.
+ */
+const exportRestorePoint = async id => {
+ const db = await openDB();
+
+ /**
+ * @returns {Promise} Resolves with internal metadata.
+ */
+ const getMetadata = () => new Promise((resolve, reject) => {
+ const transaction = db.transaction([METADATA_STORE], 'readonly');
+ transaction.onerror = event => {
+ reject(new Error(`Getting restore point metadata: ${event.target.error}`));
+ };
+
+ const metadataStore = transaction.objectStore(METADATA_STORE);
+ const request = metadataStore.get(id);
+ request.onsuccess = () => {
+ if (request.result) {
+ resolve(request.result);
+ } else {
+ reject(new Error(`Restore point metadata ${id} does not exist`));
+ }
+ };
+ });
+
+ /**
+ * @returns {Promise} Resolves with binary data for project.json.
+ */
+ const getProjectJSON = () => new Promise((resolve, reject) => {
+ const transaction = db.transaction([PROJECT_STORE], 'readonly');
+ transaction.onerror = event => {
+ reject(new Error(`Getting restore point project: ${event.target.error}`));
+ };
+
+ const projectStore = transaction.objectStore(PROJECT_STORE);
+ const request = projectStore.get(id);
+ request.onsuccess = () => {
+ if (request.result) {
+ resolve(request.result);
+ } else {
+ reject(new Error(`Restore point project ${id} does not exist`));
+ }
+ };
+ });
+
+ /**
+ * @param {string[]} md5exts Assets to fetch
+ * @returns {Promise>} Resolves with asset IDs and binary data
+ */
+ const getAssets = md5exts => new Promise((resolveAssets, rejectAssets) => {
+ const transaction = db.transaction([ASSET_STORE], 'readonly');
+ transaction.onerror = event => {
+ rejectAssets(new Error(`Getting asset: ${event.target.error}`));
+ };
+
+ const projectStore = transaction.objectStore(ASSET_STORE);
+ const promises = [];
+ for (const md5ext of md5exts) {
+ promises.push(new Promise(resolveRequest => {
+ const request = projectStore.get(md5ext);
+ request.onsuccess = () => {
+ if (request.result) {
+ resolveRequest({
+ md5ext,
+ data: request.result
+ });
+ } else {
+ // We'll ignore this, so that a single asset missing somehow does not
+ // completely break exporting the restore point.
+ resolveRequest(null);
+ }
+ };
+ }));
+ }
+
+ // Don't resolve/reject the getAssets() promise until we're done so the transaction error handler still works.
+ Promise.all(promises)
+ .then(assets => {
+ resolveAssets(assets.filter(i => i !== null));
+ })
+ .catch(err => {
+ rejectAssets(err);
+ });
+ });
+
+ const metadata = await getMetadata();
+ const projectJSON = await getProjectJSON();
+ const assets = await getAssets(Object.keys(metadata.assets));
+
+ const zip = new JSZip();
+ zip.file('project.json', projectJSON);
+ for (const asset of assets) {
+ zip.file(asset.md5ext, asset.data);
+ }
+
+ const blob = await zip.generateAsync({
+ type: 'blob',
+ compression: 'DEFLATE'
+ });
+
+ return {
+ title: metadata.title,
+ blob
+ };
+};
+
/**
* @param {VirtualMachine} vm scratch-vm instance
* @param {number} id the restore point's ID
@@ -477,7 +586,7 @@ const loadRestorePoint = (vm, id) => openDB().then(db => new Promise((resolvePro
transaction.onerror = event => {
rejectProject(new Error(`Loading restore point JSON: ${event.target.error}`));
};
-
+
const projectStore = transaction.objectStore(PROJECT_STORE);
const request = projectStore.get(id);
request.onsuccess = () => {
@@ -630,6 +739,7 @@ export default {
deleteRestorePoint,
deleteAllRestorePoints,
getThumbnail,
+ exportRestorePoint,
loadRestorePoint,
deleteLegacyRestorePoint,
readInterval,