From 869f1c60c12cc006c42f3154dd00092456677377 Mon Sep 17 00:00:00 2001 From: Thomas Weber Date: Sat, 18 Jan 2025 14:00:49 -0600 Subject: [PATCH] Add export restore point button --- package-lock.json | 2 + package.json | 1 + .../tw-restore-point-modal/export.svg | 2 + .../restore-point-modal.css | 19 ++- .../restore-point-modal.jsx | 4 + .../tw-restore-point-modal/restore-point.jsx | 44 +++++-- src/containers/tw-restore-point-manager.jsx | 48 +++++++- src/lib/tw-restore-point-api.js | 112 +++++++++++++++++- 8 files changed, 214 insertions(+), 18 deletions(-) create mode 100644 src/components/tw-restore-point-modal/export.svg 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,