From 34a3d383401b174f7e9d45d555c6d1216bebe434 Mon Sep 17 00:00:00 2001 From: Muffin Date: Mon, 31 Jul 2023 20:18:38 -0500 Subject: [PATCH 01/21] Rewrite restore points - v0 --- src/components/gui/gui.jsx | 2 + src/components/menu-bar/menu-bar.jsx | 29 +- .../restore-point-modal.css | 113 ++++++ .../restore-point-modal.jsx | 66 ++++ .../tw-restore-point-modal/restore-point.jsx | 43 +++ src/containers/alert.jsx | 1 - src/containers/tw-restore-point-loader.jsx | 92 ----- src/containers/tw-restore-point-manager.jsx | 206 ++++++++++ src/lib/alerts/index.jsx | 6 +- src/lib/tw-restore-point-api.js | 355 +++++++++++++----- src/lib/tw-restore-point-hoc.jsx | 111 ------ src/playground/render-interface.jsx | 2 - src/reducers/modals.js | 14 +- 13 files changed, 716 insertions(+), 324 deletions(-) create mode 100644 src/components/tw-restore-point-modal/restore-point-modal.css create mode 100644 src/components/tw-restore-point-modal/restore-point-modal.jsx create mode 100644 src/components/tw-restore-point-modal/restore-point.jsx delete mode 100644 src/containers/tw-restore-point-loader.jsx create mode 100644 src/containers/tw-restore-point-manager.jsx delete mode 100644 src/lib/tw-restore-point-hoc.jsx diff --git a/src/components/gui/gui.jsx b/src/components/gui/gui.jsx index 48b7266989f..a49afd3a242 100644 --- a/src/components/gui/gui.jsx +++ b/src/components/gui/gui.jsx @@ -35,6 +35,7 @@ import TWUsernameModal from '../../containers/tw-username-modal.jsx'; import TWSettingsModal from '../../containers/tw-settings-modal.jsx'; import TWSecurityManager from '../../containers/tw-security-manager.jsx'; import TWCustomExtensionModal from '../../containers/tw-custom-extension-modal.jsx'; +import TWRestorePointManager from '../../containers/tw-restore-point-manager.jsx'; import layout, {STAGE_SIZE_MODES} from '../../lib/layout-constants'; import {resolveStageSize} from '../../lib/screen-utils'; @@ -167,6 +168,7 @@ const GUIComponent = props => { const alwaysEnabledModals = ( + {usernameModalVisible && } {settingsModalVisible && } {customExtensionModalVisible && } diff --git a/src/components/menu-bar/menu-bar.jsx b/src/components/menu-bar/menu-bar.jsx index 31551392e1f..3376ebd0060 100644 --- a/src/components/menu-bar/menu-bar.jsx +++ b/src/components/menu-bar/menu-bar.jsx @@ -29,10 +29,9 @@ import MenuBarHOC from '../../containers/menu-bar-hoc.jsx'; import FramerateChanger from '../../containers/tw-framerate-changer.jsx'; import ChangeUsername from '../../containers/tw-change-username.jsx'; import CloudVariablesToggler from '../../containers/tw-cloud-toggler.jsx'; -import TWRestorePointLoader from '../../containers/tw-restore-point-loader.jsx'; import TWSaveStatus from './tw-save-status.jsx'; -import {openTipsLibrary, openSettingsModal} from '../../reducers/modals'; +import {openTipsLibrary, openSettingsModal, openRestorePointModal} from '../../reducers/modals'; import {setPlayer} from '../../reducers/mode'; import { autoUpdateProject, @@ -205,6 +204,7 @@ class MenuBar extends React.Component { 'handleClickSave', 'handleClickSaveAsCopy', 'handleClickPackager', + 'handleClickRestorePoints', 'handleClickSeeCommunity', 'handleClickShare', 'handleKeyPress', @@ -251,6 +251,10 @@ class MenuBar extends React.Component { this.props.onClickPackager(); this.props.onRequestCloseFile(); } + handleClickRestorePoints () { + this.props.onClickRestorePoints(); + this.props.onRequestCloseFile(); + } handleClickSeeCommunity (waitForUpdate) { if (this.props.shouldSaveBeforeTransition()) { this.props.autoUpdateProject(); // save before transitioning to project page @@ -644,18 +648,13 @@ class MenuBar extends React.Component { )} - {(className, loadRestorePoint) => ( - - - - )} + + + @@ -963,6 +962,7 @@ MenuBar.propTypes = { onClickAddonSettings: PropTypes.func, onClickTheme: PropTypes.func, onClickPackager: PropTypes.func, + onClickRestorePoints: PropTypes.func, onClickEdit: PropTypes.func, onClickFile: PropTypes.func, onClickLanguage: PropTypes.func, @@ -1061,6 +1061,7 @@ const mapDispatchToProps = dispatch => ({ onClickRemix: () => dispatch(remixProject()), onClickSave: () => dispatch(manualUpdateProject()), onClickSaveAsCopy: () => dispatch(saveProjectAsCopy()), + onClickRestorePoints: () => dispatch(openRestorePointModal()), onClickSettings: () => { dispatch(openSettingsModal()); dispatch(closeEditMenu()); diff --git a/src/components/tw-restore-point-modal/restore-point-modal.css b/src/components/tw-restore-point-modal/restore-point-modal.css new file mode 100644 index 00000000000..1f180b9fd5a --- /dev/null +++ b/src/components/tw-restore-point-modal/restore-point-modal.css @@ -0,0 +1,113 @@ +@import "../../css/colors.css"; + +.modal-content { + max-width: 550px; + margin-top: 50px; +} + +.body { + background: $ui-white; + padding: 1.5rem 2.25rem; +} +[theme="dark"] .body { + color: $text-primary; + background: $ui-primary; +} + +.body p, +.unsandboxed-container, +.url-input, +.text-code-input { + margin: 1rem 0; + display: block; +} + +.type-selector-container { + display: flex; + justify-content: space-around; +} +.type-selector-button { + width: 100%; + cursor: pointer; + border-bottom: 0.25rem solid $ui-tertiary; + margin: 0 1rem; + padding: 0.5rem 0; + text-align: center; + display: flex; + align-items: center; + justify-content: center; +} +.type-selector-button[data-active="true"] { + border-color: $motion-primary; +} +.type-selector-button:active { + border-color: $motion-transparent; +} + +.url-input, +.text-code-input { + width: 100%; + border: 1px solid $ui-black-transparent; + border-radius: 0.25rem; + padding: 0.5rem; + font-size: inherit; +} +[theme="dark"] .url-input, +[theme="dark"] .text-code-input { + background: $ui-secondary; + color: white; +} +.url-input { + height: 3rem; +} +.text-code-input { + min-height: 3rem; + height: 8rem; + min-width: 100%; + max-width: 100%; + font-family: monospace; +} + +.unsandboxed-container { + display: flex; + align-items: center; +} +.unsandboxed-checkbox { + margin-right: 0.5rem; +} +.trusted-extension, +.unsandboxed-warning { + padding: 0.5rem; + border-radius: 0.25rem; +} +.trusted-extension { + background-color: rgba(94, 255, 94, 0.25); + border: 1px solid green; +} +.unsandboxed-warning { + background-color: rgba(255, 81, 81, 0.25); + border: 1px solid red; +} +.unsandboxed-warning > *:not(:last-child) { + display: block; + margin-bottom: 4px; +} + +.button-row { + display: flex; + justify-content: flex-end; +} +.load-button { + font: inherit; + color: inherit; + padding: 0.75rem 1rem; + border-radius: 0.25rem; + border: 1px solid $ui-black-transparent; + font-weight: 600; + font-size: 0.85rem; + color: $ui-white; + background: $motion-primary; +} +.load-button:disabled { + opacity: 0.8; +} diff --git a/src/components/tw-restore-point-modal/restore-point-modal.jsx b/src/components/tw-restore-point-modal/restore-point-modal.jsx new file mode 100644 index 00000000000..7aaf77ff12a --- /dev/null +++ b/src/components/tw-restore-point-modal/restore-point-modal.jsx @@ -0,0 +1,66 @@ +import {defineMessages, FormattedMessage, intlShape, injectIntl} from 'react-intl'; +import PropTypes from 'prop-types'; +import React from 'react'; +import Box from '../box/box.jsx'; +import Modal from '../../containers/modal.jsx'; +import RestorePoint from './restore-point.jsx'; +import styles from './restore-point-modal.css'; + +const messages = defineMessages({ + title: { + defaultMessage: 'Restore Points', + description: 'Title of restore point management modal', + id: 'tw.restorePoints.title' + } +}); + +const RestorePointModal = props => ( + + + + + + {props.isLoading ? ( +

Loading...

+ ) : props.error ? ( +

Error: {props.error}

+ ) : props.restorePoints.length === 0 ? ( +

No restore points

+ ) : props.restorePoints.map(restorePoint => ( + + ))} +
+
+); + +RestorePointModal.propTypes = { + intl: intlShape, + onClose: PropTypes.func.isRequired, + onClickCreate: PropTypes.func.isRequired, + onClickDelete: PropTypes.func.isRequired, + onClickDeleteAll: PropTypes.func.isRequired, + onClickLoad: PropTypes.func.isRequired, + isLoading: PropTypes.bool.isRequired, + restorePoints: PropTypes.arrayOf(PropTypes.shape({ + id: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + assets: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired + })), + error: PropTypes.string +}; + +export default injectIntl(RestorePointModal); diff --git a/src/components/tw-restore-point-modal/restore-point.jsx b/src/components/tw-restore-point-modal/restore-point.jsx new file mode 100644 index 00000000000..842108188b1 --- /dev/null +++ b/src/components/tw-restore-point-modal/restore-point.jsx @@ -0,0 +1,43 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import bindAll from 'lodash.bindall'; + +class RestorePoint extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleClickDelete', + 'handleClickLoad' + ]); + } + handleClickDelete () { + this.props.onClickDelete(this.props.id); + } + handleClickLoad () { + this.props.onClickLoad(this.props.id); + } + render () { + return ( +
+ {this.props.id} {this.props.title} {this.props.assets.length} + + + +
+ ); + } +} + +RestorePoint.propTypes = { + id: PropTypes.string.isRequired, + title: PropTypes.string.isRequired, + assets: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, + onClickDelete: PropTypes.func.isRequired, + onClickLoad: PropTypes.func.isRequired +}; + +export default RestorePoint; diff --git a/src/containers/alert.jsx b/src/containers/alert.jsx index 03a5d6f31d9..08909a7b7d5 100644 --- a/src/containers/alert.jsx +++ b/src/containers/alert.jsx @@ -3,7 +3,6 @@ import bindAll from 'lodash.bindall'; import PropTypes from 'prop-types'; import {connect} from 'react-redux'; import SB3Downloader from './sb3-downloader.jsx'; -import TWRestorePointLoader from './tw-restore-point-loader.jsx'; import AlertComponent from '../components/alerts/alert.jsx'; import {openConnectionModal} from '../reducers/modals'; import {setConnectionModalExtensionId} from '../reducers/connection-modal'; diff --git a/src/containers/tw-restore-point-loader.jsx b/src/containers/tw-restore-point-loader.jsx deleted file mode 100644 index 58943e843d8..00000000000 --- a/src/containers/tw-restore-point-loader.jsx +++ /dev/null @@ -1,92 +0,0 @@ -import bindAll from 'lodash.bindall'; -import PropTypes from 'prop-types'; -import React from 'react'; -import {connect} from 'react-redux'; -import {defineMessages, injectIntl, intlShape} from 'react-intl'; -import VM from 'scratch-vm'; -import {closeFileMenu} from '../reducers/menus'; -import {closeLoadingProject, openLoadingProject} from '../reducers/modals'; -import {onLoadedProject, requestProjectUpload} from '../reducers/project-state'; -import {setFileHandle} from '../reducers/tw'; -import RestorePointAPI from '../lib/tw-restore-point-api'; - -const messages = defineMessages({ - error: { - defaultMessage: 'Could not load restore point: {error}', - description: 'Alert displayed when restore point loading failed', - id: 'tw.restorePoint.loadFail' - }, - confirm: { - // eslint-disable-next-line max-len - defaultMessage: 'The editor automatically records one restore point in case something goes wrong and you forget to save. You shouldn\'t rely on this and we can\'t guarantee it will recover your project. Try to load it?', - description: 'Confirmation to load restore point', - id: 'tw.restorePoint.confirm' - } -}); - -class RestorePointLoader extends React.Component { - constructor (props) { - super(props); - bindAll(this, [ - 'loadRestorePoint' - ]); - } - loadRestorePoint () { - // eslint-disable-next-line no-alert - if (!confirm(this.props.intl.formatMessage(messages.confirm))) { - return; - } - this.props.onLoadingStarted(); - this.props.requestProjectUpload(this.props.loadingState); - RestorePointAPI.load() - .then(arrayBuffer => this.props.vm.loadProject(arrayBuffer)) - .then(() => { - this.props.onLoadingFinished(this.props.loadingState, true); - }) - .catch(error => { - this.props.onLoadingFinished(this.props.loadingState, false); - // eslint-disable-next-line no-alert - alert(this.props.intl.formatMessage(messages.error, { - error - })); - }); - } - render () { - return this.props.children( - this.props.className, - this.loadRestorePoint - ); - } -} - -RestorePointLoader.propTypes = { - intl: intlShape, - children: PropTypes.func, - className: PropTypes.string, - loadingState: PropTypes.string, - onLoadingStarted: PropTypes.func, - onLoadingFinished: PropTypes.func, - requestProjectUpload: PropTypes.func, - vm: PropTypes.instanceOf(VM) -}; - -const mapStateToProps = state => ({ - loadingState: state.scratchGui.projectState.loadingState, - vm: state.scratchGui.vm -}); - -const mapDispatchToProps = dispatch => ({ - onLoadingFinished: (loadingState, success) => { - dispatch(onLoadedProject(loadingState, false, success)); - dispatch(closeLoadingProject()); - dispatch(closeFileMenu()); - dispatch(setFileHandle(null)); - }, - requestProjectUpload: loadingState => dispatch(requestProjectUpload(loadingState)), - onLoadingStarted: () => dispatch(openLoadingProject()) -}); - -export default injectIntl(connect( - mapStateToProps, - mapDispatchToProps -)(RestorePointLoader)); diff --git a/src/containers/tw-restore-point-manager.jsx b/src/containers/tw-restore-point-manager.jsx new file mode 100644 index 00000000000..87a9d0ad24b --- /dev/null +++ b/src/containers/tw-restore-point-manager.jsx @@ -0,0 +1,206 @@ +import React from 'react'; +import {connect} from 'react-redux'; +import PropTypes from 'prop-types'; +import bindAll from 'lodash.bindall'; +import {closeAlertWithId, showStandardAlert} from '../reducers/alerts'; +import {closeLoadingProject, closeRestorePointModal, openLoadingProject} from '../reducers/modals'; +import {LoadingStates, getIsShowingProject, onLoadedProject, requestProjectUpload} from '../reducers/project-state'; +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'; + +const AUTOMATIC_INTERVAL = 1000 * 5; + +class TWRestorePointManager extends React.Component { + constructor (props) { + super(props); + bindAll(this, [ + 'handleClickCreate', + 'handleClickDelete', + 'handleClickDeleteAll', + 'handleClickLoad' + ]); + this.state = { + loading: true, + restorePoints: [], + error: null + }; + this.timeout = null; + } + + componentWillReceiveProps (nextProps) { + if (nextProps.isModalVisible && !this.props.isModalVisible) { + this.refreshState(); + } + } + + componentDidUpdate (prevProps) { + if ( + this.props.projectChanged !== prevProps.projectChanged || + this.props.isShowingProject !== prevProps.isShowingProject + ) { + if (this.props.projectChanged && this.props.isShowingProject) { + // Project was modified + if (!this.timeout) { + this.queueRestorePoint(); + } + } else { + // Project was saved + clearTimeout(this.timeout); + this.timeout = null; + } + } + } + + componentWillUnmount () { + clearTimeout(this.timeout); + } + + handleClickCreate () { + this.setState({ + loading: true + }); + this.createRestorePoint().then(() => { + this.refreshState(); + }); + } + + handleClickDelete (id) { + this.setState({ + loading: true + }); + RestorePointAPI.deleteRestorePoint(id).then(() => { + this.refreshState(); + }); + } + + handleClickDeleteAll () { + this.setState({ + loading: true + }); + RestorePointAPI.deleteAllRestorePoints().then(() => { + this.refreshState(); + }); + } + + handleClickLoad (id) { + this.props.onCloseModal(); + this.props.onStartLoadingRestorePoint(this.props.loadingState); + RestorePointAPI.loadRestorePoint(id) + .then(buffer => this.props.vm.loadProject(buffer)) + .then(() => { + this.props.onFinishLoadingRestorePoint(true, this.props.loadingState); + }) + .catch(error => { + // eslint-disable-next-line no-alert + alert(error); + + this.props.onFinishLoadingRestorePoint(false, this.props.loadingState); + }); + } + + queueRestorePoint () { + this.timeout = setTimeout(() => { + this.createRestorePoint().then(() => { + this.timeout = null; + + if (this.props.projectChanged && this.props.isShowingProject) { + // Still not saved + this.queueRestorePoint(); + } + }); + }, AUTOMATIC_INTERVAL); + } + + createRestorePoint () { + this.props.onStartCreatingRestorePoint(); + return RestorePointAPI.createRestorePoint(this.props.vm, this.props.projectTitle) + .then(() => { + this.props.onFinishCreatingRestorePoint(); + }); + } + + refreshState () { + this.setState({ + loading: true, + restorePoints: [], + error: null + }); + RestorePointAPI.readManifest() + .then(manifest => { + this.setState({ + loading: false, + restorePoints: manifest.restorePoints + }); + }) + .catch(error => { + this.setState({ + loading: false, + error + }); + }); + } + + render () { + if (this.props.isModalVisible) { + return ( + + ); + } + return null; + } +} + +TWRestorePointManager.propTypes = { + projectChanged: PropTypes.bool.isRequired, + projectTitle: PropTypes.string.isRequired, + onStartCreatingRestorePoint: PropTypes.func.isRequired, + onFinishCreatingRestorePoint: PropTypes.func.isRequired, + onStartLoadingRestorePoint: PropTypes.func.isRequired, + onFinishLoadingRestorePoint: PropTypes.func.isRequired, + onCloseModal: PropTypes.func.isRequired, + loadingState: PropTypes.oneOf(LoadingStates).isRequired, + isShowingProject: PropTypes.bool.isRequired, + isModalVisible: PropTypes.bool.isRequired, + vm: PropTypes.shape({ + loadProject: PropTypes.func.isRequired + }).isRequired +}; + +const mapStateToProps = state => ({ + projectChanged: state.scratchGui.projectChanged, + projectTitle: state.scratchGui.projectTitle, + loadingState: state.scratchGui.projectState.loadingState, + isShowingProject: getIsShowingProject(state.scratchGui.projectState.loadingState), + isModalVisible: state.scratchGui.modals.restorePointModal, + vm: state.scratchGui.vm +}); + +const mapDispatchToProps = dispatch => ({ + onStartCreatingRestorePoint: () => dispatch(showStandardAlert('twCreatingRestorePoint')), + onFinishCreatingRestorePoint: () => dispatch(closeAlertWithId('twCreatingRestorePoint')), + onStartLoadingRestorePoint: loadingState => { + dispatch(openLoadingProject()); + dispatch(requestProjectUpload(loadingState)); + }, + onFinishLoadingRestorePoint: (success, loadingState) => { + dispatch(onLoadedProject(loadingState, false, success)); + dispatch(closeLoadingProject()); + dispatch(setFileHandle(null)); + }, + onCloseModal: () => dispatch(closeRestorePointModal()) +}); + +export default connect( + mapStateToProps, + mapDispatchToProps +)(TWRestorePointManager); diff --git a/src/lib/alerts/index.jsx b/src/lib/alerts/index.jsx index b2d89930659..7e6e71adbd8 100644 --- a/src/lib/alerts/index.jsx +++ b/src/lib/alerts/index.jsx @@ -185,13 +185,13 @@ const alerts = [ level: AlertLevels.INFO }, { - alertId: 'twAutosaving', + alertId: 'twCreatingRestorePoint', alertType: AlertTypes.INLINE, content: ( ), iconSpinner: true, diff --git a/src/lib/tw-restore-point-api.js b/src/lib/tw-restore-point-api.js index e85daa844b8..586efcf2aef 100644 --- a/src/lib/tw-restore-point-api.js +++ b/src/lib/tw-restore-point-api.js @@ -1,126 +1,283 @@ import JSZip from 'jszip'; +import log from './log'; -// Special constants -- do not change without care. -const DATABASE_NAME = 'TW_AutoSave'; -const DATABASE_VERSION = 1; -const STORE_NAME = 'project'; +/** + * Deletes all data created by the old and buggy version of restore points. + * Old data is simply not worth migrating, especially as accessing this data is prone to cause crashes + * to due indexed DB not handling large data very well. + */ +const deleteLegacyData = () => { + try { + if (typeof indexedDB !== 'undefined') { + const request = indexedDB.deleteDatabase('TW_AutoSave'); + request.onerror = () => { + log.error('Error deleting legacy restore point data'); + }; + } + } catch (e) { + log.error('Error deleting legacy restore point data', e); + } +}; +deleteLegacyData(); + +/** + * @typedef Manifest + * @property {{id: string; title: string; assets: string[]}[]} restorePoints + */ + +/* +Directory structure: +[*] root + [*] restore-points.json + {"restorePoints":[...]} + [*] projects + [*] 1234.json + {"targets":[...],...} + [*] ... + [*] assets + [*] 0123456789abcdef....svg + [*] ... +*/ + +const ROOT_DIRECTORY = 'tw-restore-points-v2'; +const MANIFEST_NAME = 'restore-points.json'; +const PROJECT_DIRECTORY = 'projects'; +const ASSET_DIRECTORY = 'assets'; +const MAX_RESTORE_POINTS = 5; + +const uniques = arr => Array.from(new Set(arr)); + +const getDirectories = async () => { + const root = await navigator.storage.getDirectory(); + const subdirectory = await root.getDirectoryHandle(ROOT_DIRECTORY, { + create: true + }); + const projects = await subdirectory.getDirectoryHandle(PROJECT_DIRECTORY, { + create: true + }); + const assets = await subdirectory.getDirectoryHandle(ASSET_DIRECTORY, { + create: true + }); + return { + root: subdirectory, + projects, + assets + }; +}; + +/** + * @param {FileSystemDirectoryHandle} directory the directory + * @returns {Promise} a list of files in the directory + */ +const readDirectory = directory => new Promise((resolve, reject) => { + /** @type {string} */ + const files = []; -let _db; + /** @type {AsyncIterator} */ + const iterator = directory.keys(); + + const getNext = () => { + iterator.next() + .then(result => { + if (result.done) { + resolve(files); + } else { + files.push(result.value); + getNext(); + } + }) + .catch(error => { + reject(error); + }); + }; + + getNext(); +}); + +/** + * @param {FileSystemDirectoryHandle} directory the directory + * @param {string} name the name of the file + * @param {Uint8Array} data the contents to write + */ +const writeToFile = async (directory, name, data) => { + const fileHandle = await directory.getFileHandle(name, { + create: true + }); + const writable = await fileHandle.createWritable(); + await writable.write(data); + await writable.close(); +}; + +/** + * @param {FileSystemDirectoryHandle} directory the directory + * @param {string} name the name of the file + */ +const deleteFile = async (directory, name) => { + await directory.removeEntry(name); +}; + +/** + * @param {FileSystemDirectoryHandle} directory the directory + * @param {string} filename the name of the file + * @returns {Promise} file object + */ +const readFile = async (directory, filename) => { + const fileHandle = await directory.getFileHandle(filename); + const file = await fileHandle.getFile(); + return file; +}; -const openDB = () => { - if (_db) { - return _db; +/** + * @param {Manifest} obj unknown object + * @returns {boolean} true if obj is manifest + */ +const isValidManifest = obj => Array.isArray(obj.restorePoints) && obj.restorePoints.every(point => ( + typeof point.id === 'string' && + typeof point.title === 'string' && + Array.isArray(point.assets) && + point.assets.every(asset => typeof asset === 'string') +)); + +/** + * @param {FileSystemDirectoryHandle} root the root restore point directory + * @returns {Promise} Parsed or default manifest + */ +const readManifest = async () => { + try { + const directories = await getDirectories(); + const file = await readFile(directories.root, MANIFEST_NAME); + const text = await file.text(); + const parsed = JSON.parse(text); + if (isValidManifest(parsed)) { + return parsed; + } + } catch (e) { + // ignore } + return { + restorePoints: [] + }; +}; + +/** + * @param {FileSystemDirectoryHandle} root the root restore point directory + * @param {Manifest} manifest Manifest to write. + */ +const writeManifest = async (root, manifest) => { + const fileHandle = await root.getFileHandle(MANIFEST_NAME, { + create: true + }); + const writable = await fileHandle.createWritable(); + await writable.write(JSON.stringify(manifest)); + await writable.close(); +}; + +/** + * @param {Manifest} manifest the manifest + */ +const removeExtraneous = async manifest => { + const directories = await getDirectories(); - if (!window.indexedDB) { - throw new Error('indexedDB is not supported'); + const expectedProjectFiles = manifest.restorePoints.map(i => `${i.id}.json`); + const allSavedProjects = await readDirectory(directories.projects); + const projectFilesToDelete = allSavedProjects.filter(i => !expectedProjectFiles.includes(i)); + for (const projectFile of projectFilesToDelete) { + await deleteFile(directories.projects, projectFile); } - return new Promise((resolve, reject) => { - const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + const expectedAssetFiles = uniques(manifest.restorePoints.map(i => i.assets).flat()); + const allSavedAssets = await readDirectory(directories.assets); + const assetsToDelete = allSavedAssets.filter(i => !expectedAssetFiles.includes(i)); + for (const assetName of assetsToDelete) { + await deleteFile(directories.assets, assetName); + } +}; - request.onupgradeneeded = e => { - const db = e.target.result; - db.createObjectStore(STORE_NAME, { - keyPath: 'file' - }); - }; +/** + * @param {VirtualMachine} vm scratch-vm instance + * @param {string} title project title + */ +const createRestorePoint = async (vm, title) => { + const directories = await getDirectories(); + + const id = `${Date.now()}-${Math.round(Math.random() * 1000)}`; - request.onsuccess = e => { - _db = e.target.result; - resolve(_db); - }; + /** @type {Record} */ + const projectFiles = vm.saveProjectSb3DontZip(); + const projectAssets = Object.keys(projectFiles).filter(i => i !== 'project.json'); - request.onerror = () => { - reject(new Error(`DB error: ${request.error}`)); - }; + const manifest = await readManifest(directories.root); + manifest.restorePoints.unshift({ + id, + title, + assets: projectAssets }); + while (manifest.restorePoints.length > MAX_RESTORE_POINTS) { + manifest.restorePoints.pop(); + } + await writeManifest(directories.root, manifest); + + const jsonData = projectFiles['project.json']; + await writeToFile(directories.projects, `${id}.json`, jsonData); + + const alreadySavedAssets = await readDirectory(directories.assets); + const assetsToSave = projectAssets.filter(asset => !alreadySavedAssets.includes(asset)); + for (const assetName of assetsToSave) { + const data = projectFiles[assetName]; + await writeToFile(directories.assets, assetName, data); + } + + await removeExtraneous(manifest); }; /** - * Save a project to IDB. - * @param {VirtualMachine} vm Scratch VM + * @param {string} id the restore point's ID */ -const save = async vm => { - // To save a project, we will get all the assets inside it and save them to IDB. - // We will not actually generate a zip as that is slow. - const files = vm.saveProjectSb3DontZip(); - const db = await openDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(STORE_NAME, 'readwrite'); - transaction.onerror = () => { - reject(new Error(`Save transaction error: ${transaction.error}`)); - }; - - // Remove unused assets and don't waste time updating assets that are already in IDB. - const exists = []; - const projectStore = transaction.objectStore(STORE_NAME); - const request = projectStore.openCursor(); - request.onsuccess = e => { - const cursor = e.target.result; - if (cursor) { - const key = cursor.key; - if (files[key]) { - exists.push(key); - } else { - cursor.delete(); - } - cursor.continue(); - } else { - // Cursor is done, save all new files and project.json to IDB. - for (const file of Object.keys(files)) { - if (file === 'project.json' || !exists.includes(file)) { - projectStore.put({ - file, - data: files[file].buffer - }); - } - } +const deleteRestorePoint = async id => { + const directories = await getDirectories(); + const manifest = await readManifest(id); + manifest.restorePoints = manifest.restorePoints.filter(i => i.id !== id); + await writeManifest(directories.root, manifest); + await removeExtraneous(manifest); +}; - resolve(); - } - }; +const deleteAllRestorePoints = async () => { + const directories = await getDirectories(); + await directories.root.removeEntry(MANIFEST_NAME); + await directories.root.removeEntry(PROJECT_DIRECTORY, { + recursive: true + }); + await directories.root.removeEntry(ASSET_DIRECTORY, { + recursive: true }); }; /** - * Load a project from IDB. - * @returns {Promise} sb3 project to load. + * @param {string} id the restore point id + * @returns {Promise} sb3 file */ -const load = async () => { - // To load a project, read the files from IDB and generate a .sb3 - const db = await openDB(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(STORE_NAME, 'readonly'); - transaction.onerror = () => { - reject(new Error(`Load transaction error: ${transaction.error}`)); - }; - - const zip = new JSZip(); - const projectStore = transaction.objectStore(STORE_NAME); - const request = projectStore.openCursor(); - request.onsuccess = e => { - const cursor = e.target.result; - if (cursor) { - zip.file(cursor.key, cursor.value.data); - cursor.continue(); - } else { - // Cursor is done, all files added to zip. - const hasJSON = zip.file('project.json'); - if (hasJSON) { - resolve(zip.generateAsync({ - type: 'arraybuffer' - // No reason to compress this zip. - })); - } else { - reject(new Error('Could not find project')); - } - } - }; +const loadRestorePoint = async id => { + const directories = await getDirectories(); + const manifest = await readManifest(directories.root); + const manifestEntry = manifest.restorePoints.find(i => i.id === id); + + const zip = new JSZip(); + const projectFile = await readFile(directories.projects, `${id}.json`); + zip.file('project.json', projectFile); + for (const asset of manifestEntry.assets) { + zip.file(asset, await readFile(directories.assets, asset)); + } + + return zip.generateAsync({ + // no reason to spend time compresing it + type: 'arraybuffer' }); }; export default { - save, - load + readManifest, + createRestorePoint, + deleteRestorePoint, + deleteAllRestorePoints, + loadRestorePoint }; diff --git a/src/lib/tw-restore-point-hoc.jsx b/src/lib/tw-restore-point-hoc.jsx deleted file mode 100644 index 9bb3cb50306..00000000000 --- a/src/lib/tw-restore-point-hoc.jsx +++ /dev/null @@ -1,111 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import {connect} from 'react-redux'; -import {showStandardAlert, closeAlertWithId} from '../reducers/alerts'; -import {getIsShowingProject} from '../reducers/project-state'; -import bindAll from 'lodash.bindall'; -import VM from 'scratch-vm'; -import RestorePointAPI from './tw-restore-point-api'; - -/** - * @fileoverview - * HOC responsible for automatically creating restore points. - */ - -const INTERVAL = 1000 * 60 * 5; - -let bailed = false; - -const disabled = () => bailed || window.DISABLE_RESTORE_POINTS; - -const TWRestorePointHOC = function (WrappedComponent) { - class RestorePointComponent extends React.Component { - constructor (props) { - super(props); - bindAll(this, [ - 'createRestorePoint' - ]); - this.timeout = null; - } - componentDidUpdate (prevProps) { - if (disabled()) { - return; - } - if ( - this.props.projectChanged !== prevProps.projectChanged || - this.props.isShowingProject !== prevProps.isShowingProject - ) { - if (this.props.projectChanged && this.props.isShowingProject) { - // Project was modified; queue restore point. - this.timeout = setTimeout(this.createRestorePoint, INTERVAL); - } else { - // Project was saved; abort restore point. - clearTimeout(this.timeout); - this.timeout = null; - } - } - } - componentWillUnmount () { - clearTimeout(this.timeout); - } - async createRestorePoint () { - if (disabled()) { - return; - } - try { - this.props.onAutosavingStart(); - await RestorePointAPI.save(this.props.vm); - } catch (error) { - bailed = true; - } - this.timeout = null; - // Intentional delay. - setTimeout(() => { - this.props.onAutosavingFinish(); - if (this.timeout === null && !bailed && this.props.projectChanged && this.props.isShowingProject) { - this.timeout = setTimeout(this.createRestorePoint, INTERVAL); - } - }, 250); - } - render () { - const { - /* eslint-disable no-unused-vars */ - projectChanged, - onAutosavingStart, - onAutosavingFinish, - vm, - /* eslint-enable no-unused-vars */ - ...props - } = this.props; - return ( - - ); - } - } - RestorePointComponent.propTypes = { - isShowingProject: PropTypes.bool, - projectChanged: PropTypes.bool, - onAutosavingStart: PropTypes.func, - onAutosavingFinish: PropTypes.func, - vm: PropTypes.instanceOf(VM) - }; - const mapStateToProps = state => ({ - isShowingProject: getIsShowingProject(state.scratchGui.projectState.loadingState), - projectChanged: state.scratchGui.projectChanged, - vm: state.scratchGui.vm - }); - const mapDispatchToProps = dispatch => ({ - onAutosavingStart: () => dispatch(showStandardAlert('twAutosaving')), - onAutosavingFinish: () => dispatch(closeAlertWithId('twAutosaving')) - }); - return connect( - mapStateToProps, - mapDispatchToProps - )(RestorePointComponent); -}; - -export { - TWRestorePointHOC as default -}; diff --git a/src/playground/render-interface.jsx b/src/playground/render-interface.jsx index f1f0dac5687..3612352a676 100644 --- a/src/playground/render-interface.jsx +++ b/src/playground/render-interface.jsx @@ -29,7 +29,6 @@ import TWStateManagerHOC from '../lib/tw-state-manager-hoc.jsx'; import TWThemeHOC from '../lib/tw-theme-hoc.jsx'; import SBFileUploaderHOC from '../lib/sb-file-uploader-hoc.jsx'; import TWPackagerIntegrationHOC from '../lib/tw-packager-integration-hoc.jsx'; -import TWRestorePointHOC from '../lib/tw-restore-point-hoc.jsx'; import SettingsStore from '../addons/settings-store-singleton'; import '../lib/tw-fix-history-api'; import GUI from './render-gui.jsx'; @@ -385,7 +384,6 @@ const WrappedInterface = compose( TWProjectMetaFetcherHOC, TWStateManagerHOC, TWThemeHOC, - TWRestorePointHOC, TWPackagerIntegrationHOC )(ConnectedInterface); diff --git a/src/reducers/modals.js b/src/reducers/modals.js index a6bdfecc8ef..032d4c95350 100644 --- a/src/reducers/modals.js +++ b/src/reducers/modals.js @@ -14,6 +14,7 @@ const MODAL_TIPS_LIBRARY = 'tipsLibrary'; const MODAL_USERNAME = 'usernameModal'; const MODAL_SETTINGS = 'settingsModal'; const MODAL_CUSTOM_EXTENSION = 'customExtensionModal'; +const MODAL_RESTORE_POINTS = 'restorePointModal'; const initialState = { [MODAL_BACKDROP_LIBRARY]: false, @@ -28,7 +29,8 @@ const initialState = { [MODAL_TIPS_LIBRARY]: false, [MODAL_USERNAME]: false, [MODAL_SETTINGS]: false, - [MODAL_CUSTOM_EXTENSION]: false + [MODAL_CUSTOM_EXTENSION]: false, + [MODAL_RESTORE_POINTS]: false }; const reducer = function (state, action) { @@ -97,6 +99,9 @@ const openSettingsModal = function () { const openCustomExtensionModal = function () { return openModal(MODAL_CUSTOM_EXTENSION); }; +const openRestorePointModal = function () { + return openModal(MODAL_RESTORE_POINTS); +}; const closeBackdropLibrary = function () { return closeModal(MODAL_BACKDROP_LIBRARY); }; @@ -136,6 +141,9 @@ const closeSettingsModal = function () { const closeCustomExtensionModal = function () { return closeModal(MODAL_CUSTOM_EXTENSION); }; +const closeRestorePointModal = function () { + return closeModal(MODAL_RESTORE_POINTS); +}; export { reducer as default, initialState as modalsInitialState, @@ -152,6 +160,7 @@ export { openUsernameModal, openSettingsModal, openCustomExtensionModal, + openRestorePointModal, closeBackdropLibrary, closeCostumeLibrary, closeExtensionLibrary, @@ -164,5 +173,6 @@ export { closeConnectionModal, closeUsernameModal, closeSettingsModal, - closeCustomExtensionModal + closeCustomExtensionModal, + closeRestorePointModal }; From 364b34636a945c7e1f18069127fb427b20d6c37a Mon Sep 17 00:00:00 2001 From: Muffin Date: Mon, 31 Jul 2023 22:09:48 -0500 Subject: [PATCH 02/21] Significantly improve the new restore points --- .../restore-point-modal.css | 140 +++++++++--------- .../restore-point-modal.jsx | 123 +++++++++++---- .../tw-restore-point-modal/restore-point.jsx | 45 +++++- .../tw-restore-point-modal/unsupported.css | 8 + .../tw-restore-point-modal/unsupported.jsx | 30 ++++ src/containers/tw-restore-point-manager.jsx | 79 +++++++--- src/lib/tw-restore-point-api.js | 43 ++++-- 7 files changed, 320 insertions(+), 148 deletions(-) create mode 100644 src/components/tw-restore-point-modal/unsupported.css create mode 100644 src/components/tw-restore-point-modal/unsupported.jsx 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 1f180b9fd5a..805fc9adb08 100644 --- a/src/components/tw-restore-point-modal/restore-point-modal.css +++ b/src/components/tw-restore-point-modal/restore-point-modal.css @@ -14,100 +14,94 @@ background: $ui-primary; } -.body p, -.unsandboxed-container, -.url-input, -.text-code-input { - margin: 1rem 0; - display: block; +.body p:not(:first-child), +.button-container, +.loading, +.error, +.empty, +.restore-point-container { + margin: 1rem 0 0 0; } -.type-selector-container { +.button-container { display: flex; - justify-content: space-around; + flex-direction: row; + justify-content: space-between; } -.type-selector-button { - width: 100%; - cursor: pointer; - border-bottom: 0.25rem solid $ui-tertiary; - margin: 0 1rem; - padding: 0.5rem 0; - text-align: center; - display: flex; - align-items: center; - justify-content: center; -} -.type-selector-button[data-active="true"] { - border-color: $motion-primary; +.button { + font: inherit; + color: inherit; + padding: 0.75rem 1rem; + border-radius: 0.25rem; + border: 1px solid $ui-black-transparent; + font-weight: 600; + font-size: 0.85rem; + color: $ui-white; + background: $motion-primary; } -.type-selector-button:active { - border-color: $motion-transparent; +.button:disabled { + opacity: 0.8; } +.create-button { -.url-input, -.text-code-input { - width: 100%; - border: 1px solid $ui-black-transparent; - border-radius: 0.25rem; - padding: 0.5rem; - font-size: inherit; } -[theme="dark"] .url-input, -[theme="dark"] .text-code-input { - background: $ui-secondary; - color: white; +.delete-all-button { + background-color: $data-primary; } -.url-input { - height: 3rem; -} -.text-code-input { - min-height: 3rem; - height: 8rem; - min-width: 100%; - max-width: 100%; + +.error-message { font-family: monospace; + user-select: text; } -.unsandboxed-container { +.restore-point-container { display: flex; - align-items: center; + flex-direction: column; } -.unsandboxed-checkbox { - margin-right: 0.5rem; -} -.trusted-extension, -.unsandboxed-warning { +.restore-point { + appearance: none; + background: none; + width: 100%; + text-align: left; + cursor: pointer; + display: flex; + align-items: center; + justify-content: space-between; + border: 1px solid $ui-black-transparent; + margin: 0.25rem 0; padding: 0.5rem; border-radius: 0.25rem; } -.trusted-extension { - background-color: rgba(94, 255, 94, 0.25); - border: 1px solid green; +.restore-point:hover { + border-color: $motion-primary; } -.unsandboxed-warning { - background-color: rgba(255, 81, 81, 0.25); - border: 1px solid red; +.restore-point-details { + display: flex; + flex-direction: column; } -.unsandboxed-warning > *:not(:last-child) { - display: block; - margin-bottom: 4px; +.restore-point-title { + font-weight: bold; } +.restore-point-date { -.button-row { - display: flex; - justify-content: flex-end; } -.load-button { - font: inherit; - color: inherit; - padding: 0.75rem 1rem; - border-radius: 0.25rem; - border: 1px solid $ui-black-transparent; - font-weight: 600; - font-size: 0.85rem; - color: $ui-white; - background: $motion-primary; +.restore-point-assets { + } -.load-button:disabled { - opacity: 0.8; + +.delete-button { + appearance: none; + background: none; + border: none; + display: flex; + align-items: center; + justify-content: center; + border-radius: 100%; + width: 1.5em; + height: 1.5em; + font-size: 1.5em; + line-height: 1; +} +.delete-button: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 7aaf77ff12a..1aa5136d203 100644 --- a/src/components/tw-restore-point-modal/restore-point-modal.jsx +++ b/src/components/tw-restore-point-modal/restore-point-modal.jsx @@ -1,10 +1,12 @@ import {defineMessages, FormattedMessage, intlShape, injectIntl} from 'react-intl'; import PropTypes from 'prop-types'; import React from 'react'; -import Box from '../box/box.jsx'; import Modal from '../../containers/modal.jsx'; import RestorePoint from './restore-point.jsx'; +import Unsupported from './unsupported.jsx'; import styles from './restore-point-modal.css'; +import classNames from 'classnames'; +import {APP_NAME} from '../../lib/brand'; const messages = defineMessages({ title: { @@ -21,29 +23,97 @@ const RestorePointModal = props => ( contentLabel={props.intl.formatMessage(messages.title)} id="restorePointModal" > - - - +
+ {props.isSupported ? ( +
+

+ +

- {props.isLoading ? ( -

Loading...

- ) : props.error ? ( -

Error: {props.error}

- ) : props.restorePoints.length === 0 ? ( -

No restore points

- ) : props.restorePoints.map(restorePoint => ( - - ))} - +
+ + +
+ + {props.error ? ( +
+

+ +

+

+ {props.error} +

+
+ ) : props.isLoading ? ( +
+ +
+ ) : props.restorePoints.length === 0 ? ( +
+ +
+ ) : ( +
+ {props.restorePoints.map(restorePoint => ( + + ))} +
+ )} +
+ ) : ( + + )} +
); @@ -54,12 +124,9 @@ RestorePointModal.propTypes = { onClickDelete: PropTypes.func.isRequired, onClickDeleteAll: PropTypes.func.isRequired, onClickLoad: PropTypes.func.isRequired, + isSupported: PropTypes.bool.isRequired, isLoading: PropTypes.bool.isRequired, - restorePoints: PropTypes.arrayOf(PropTypes.shape({ - id: PropTypes.string.isRequired, - title: PropTypes.string.isRequired, - assets: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired - })), + restorePoints: PropTypes.arrayOf(PropTypes.shape({})), error: PropTypes.string }; diff --git a/src/components/tw-restore-point-modal/restore-point.jsx b/src/components/tw-restore-point-modal/restore-point.jsx index 842108188b1..c264ba8db5f 100644 --- a/src/components/tw-restore-point-modal/restore-point.jsx +++ b/src/components/tw-restore-point-modal/restore-point.jsx @@ -1,6 +1,8 @@ import React from 'react'; import PropTypes from 'prop-types'; +import {FormattedMessage} from 'react-intl'; import bindAll from 'lodash.bindall'; +import styles from './restore-point-modal.css'; class RestorePoint extends React.Component { constructor (props) { @@ -10,7 +12,8 @@ class RestorePoint extends React.Component { 'handleClickLoad' ]); } - handleClickDelete () { + handleClickDelete (e) { + e.stopPropagation(); this.props.onClickDelete(this.props.id); } handleClickLoad () { @@ -18,14 +21,39 @@ class RestorePoint extends React.Component { } render () { return ( -
- {this.props.id} {this.props.title} {this.props.assets.length} +
+
+
+ {this.props.title} +
+
+ + {new Date(this.props.created * 1000).toLocaleString()} + + {' '} + + + +
+
- -
); @@ -36,6 +64,7 @@ RestorePoint.propTypes = { id: PropTypes.string.isRequired, title: PropTypes.string.isRequired, assets: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, + created: PropTypes.number.isRequired, onClickDelete: PropTypes.func.isRequired, onClickLoad: PropTypes.func.isRequired }; diff --git a/src/components/tw-restore-point-modal/unsupported.css b/src/components/tw-restore-point-modal/unsupported.css new file mode 100644 index 00000000000..001d7b98ffb --- /dev/null +++ b/src/components/tw-restore-point-modal/unsupported.css @@ -0,0 +1,8 @@ +.container p, +.container ul { + margin: 0.5em 0; +} + +.title { + font-weight: bold; +} diff --git a/src/components/tw-restore-point-modal/unsupported.jsx b/src/components/tw-restore-point-modal/unsupported.jsx new file mode 100644 index 00000000000..88ca8648108 --- /dev/null +++ b/src/components/tw-restore-point-modal/unsupported.jsx @@ -0,0 +1,30 @@ +import React from 'react'; +import {FormattedMessage} from 'react-intl'; +import styles from './unsupported.css'; + +const Unsupported = () => ( +
+

+ +

+

+ +

+
    +
  • {'Chrome 86'}
  • +
  • {'Edge 86'}
  • +
  • {'Firefox 111'}
  • +
  • {'Safari 15.2'}
  • +
+
+); + +export default Unsupported; diff --git a/src/containers/tw-restore-point-manager.jsx b/src/containers/tw-restore-point-manager.jsx index 87a9d0ad24b..53dcccd46ae 100644 --- a/src/containers/tw-restore-point-manager.jsx +++ b/src/containers/tw-restore-point-manager.jsx @@ -8,6 +8,7 @@ import {LoadingStates, getIsShowingProject, onLoadedProject, requestProjectUploa 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'; const AUTOMATIC_INTERVAL = 1000 * 5; @@ -36,8 +37,10 @@ class TWRestorePointManager extends React.Component { componentDidUpdate (prevProps) { if ( - this.props.projectChanged !== prevProps.projectChanged || - this.props.isShowingProject !== prevProps.isShowingProject + RestorePointAPI.isSupported && ( + this.props.projectChanged !== prevProps.projectChanged || + this.props.isShowingProject !== prevProps.isShowingProject + ) ) { if (this.props.projectChanged && this.props.isShowingProject) { // Project was modified @@ -57,30 +60,33 @@ class TWRestorePointManager extends React.Component { } handleClickCreate () { - this.setState({ - loading: true - }); - this.createRestorePoint().then(() => { - this.refreshState(); - }); + this.createRestorePoint(); } handleClickDelete (id) { this.setState({ loading: true }); - RestorePointAPI.deleteRestorePoint(id).then(() => { - this.refreshState(); - }); + RestorePointAPI.deleteRestorePoint(id) + .then(() => { + this.refreshState(); + }) + .catch(error => { + this.handleError(error); + }); } handleClickDeleteAll () { this.setState({ loading: true }); - RestorePointAPI.deleteAllRestorePoints().then(() => { - this.refreshState(); - }); + RestorePointAPI.deleteAllRestorePoints() + .then(() => { + this.refreshState(); + }) + .catch(error => { + this.handleError(error); + }); } handleClickLoad (id) { @@ -92,9 +98,7 @@ class TWRestorePointManager extends React.Component { this.props.onFinishLoadingRestorePoint(true, this.props.loadingState); }) .catch(error => { - // eslint-disable-next-line no-alert - alert(error); - + this.handleError(error); this.props.onFinishLoadingRestorePoint(false, this.props.loadingState); }); } @@ -105,7 +109,7 @@ class TWRestorePointManager extends React.Component { this.timeout = null; if (this.props.projectChanged && this.props.isShowingProject) { - // Still not saved + // Project is still not saved this.queueRestorePoint(); } }); @@ -113,18 +117,33 @@ class TWRestorePointManager extends React.Component { } createRestorePoint () { + if (this.props.isModalVisible) { + this.setState({ + loading: true + }); + } + this.props.onStartCreatingRestorePoint(); return RestorePointAPI.createRestorePoint(this.props.vm, this.props.projectTitle) .then(() => { + if (this.props.isModalVisible) { + this.refreshState(); + } + this.props.onFinishCreatingRestorePoint(); + }) + .catch(error => { + this.handleError(error); }); } refreshState () { + if (this.state.error) { + return; + } this.setState({ loading: true, - restorePoints: [], - error: null + restorePoints: [] }); RestorePointAPI.readManifest() .then(manifest => { @@ -134,13 +153,24 @@ class TWRestorePointManager extends React.Component { }); }) .catch(error => { - this.setState({ - loading: false, - error - }); + this.handleError(error); }); } + handleError (error) { + log.error('restore point error', error); + this.setState({ + error: `${error}` + }); + clearTimeout(this.timeout); + + if (!this.props.isModalVisible) { + // TODO + // eslint-disable-next-line no-alert + alert(`${error}`); + } + } + render () { if (this.props.isModalVisible) { return ( @@ -150,6 +180,7 @@ class TWRestorePointManager extends React.Component { onClickDelete={this.handleClickDelete} onClickDeleteAll={this.handleClickDeleteAll} onClickLoad={this.handleClickLoad} + isSupported={RestorePointAPI.isSupported} isLoading={this.state.loading} restorePoints={this.state.restorePoints} error={this.state.error} diff --git a/src/lib/tw-restore-point-api.js b/src/lib/tw-restore-point-api.js index 586efcf2aef..8bbc7041d97 100644 --- a/src/lib/tw-restore-point-api.js +++ b/src/lib/tw-restore-point-api.js @@ -1,11 +1,7 @@ import JSZip from 'jszip'; import log from './log'; -/** - * Deletes all data created by the old and buggy version of restore points. - * Old data is simply not worth migrating, especially as accessing this data is prone to cause crashes - * to due indexed DB not handling large data very well. - */ +// TODO const deleteLegacyData = () => { try { if (typeof indexedDB !== 'undefined') { @@ -18,11 +14,11 @@ const deleteLegacyData = () => { log.error('Error deleting legacy restore point data', e); } }; -deleteLegacyData(); +// deleteLegacyData(); /** * @typedef Manifest - * @property {{id: string; title: string; assets: string[]}[]} restorePoints + * @property {{id: string; title: string; assets: string[]; created: number}[]} restorePoints */ /* @@ -47,6 +43,8 @@ const MAX_RESTORE_POINTS = 5; const uniques = arr => Array.from(new Set(arr)); +const isSupported = !!navigator.storage && !!navigator.storage.getDirectory; + const getDirectories = async () => { const root = await navigator.storage.getDirectory(); const subdirectory = await root.getDirectoryHandle(ROOT_DIRECTORY, { @@ -134,6 +132,7 @@ const readFile = async (directory, filename) => { const isValidManifest = obj => Array.isArray(obj.restorePoints) && obj.restorePoints.every(point => ( typeof point.id === 'string' && typeof point.title === 'string' && + typeof point.created === 'number' && Array.isArray(point.assets) && point.assets.every(asset => typeof asset === 'string') )); @@ -200,7 +199,7 @@ const removeExtraneous = async manifest => { const createRestorePoint = async (vm, title) => { const directories = await getDirectories(); - const id = `${Date.now()}-${Math.round(Math.random() * 1000)}`; + const id = `${Date.now()}-${Math.round(Math.random() * 1e5)}`; /** @type {Record} */ const projectFiles = vm.saveProjectSb3DontZip(); @@ -210,6 +209,7 @@ const createRestorePoint = async (vm, title) => { manifest.restorePoints.unshift({ id, title, + created: Math.round(Date.now() / 1000), assets: projectAssets }); while (manifest.restorePoints.length > MAX_RESTORE_POINTS) { @@ -243,13 +243,25 @@ const deleteRestorePoint = async id => { const deleteAllRestorePoints = async () => { const directories = await getDirectories(); - await directories.root.removeEntry(MANIFEST_NAME); - await directories.root.removeEntry(PROJECT_DIRECTORY, { - recursive: true - }); - await directories.root.removeEntry(ASSET_DIRECTORY, { - recursive: true - }); + try { + await directories.root.removeEntry(MANIFEST_NAME); + } catch (e) { + // ignore + } + try { + await directories.root.removeEntry(PROJECT_DIRECTORY, { + recursive: true + }); + } catch (e) { + // ignore + } + try { + await directories.root.removeEntry(ASSET_DIRECTORY, { + recursive: true + }); + } catch (e) { + // ignore + } }; /** @@ -275,6 +287,7 @@ const loadRestorePoint = async id => { }; export default { + isSupported, readManifest, createRestorePoint, deleteRestorePoint, From e8a958e9ed3414d8ae872f2336f896a0dfeaa307 Mon Sep 17 00:00:00 2001 From: Muffin Date: Mon, 31 Jul 2023 22:49:46 -0500 Subject: [PATCH 03/21] Refactor base64 utilities --- src/lib/tw-base64-utils.js | 19 +++++++++++++++++++ src/lib/tw-local-backpack-api.js | 21 +-------------------- 2 files changed, 20 insertions(+), 20 deletions(-) create mode 100644 src/lib/tw-base64-utils.js diff --git a/src/lib/tw-base64-utils.js b/src/lib/tw-base64-utils.js new file mode 100644 index 00000000000..ca886dce777 --- /dev/null +++ b/src/lib/tw-base64-utils.js @@ -0,0 +1,19 @@ +export const base64ToArrayBuffer = base64 => { + const binaryString = atob(base64); + const len = binaryString.length; + const array = new Uint8Array(len); + for (let i = 0; i < len; i++) { + array[i] = binaryString.charCodeAt(i); + } + return array.buffer; +}; + +export const arrayBufferToBase64 = buffer => { + let binary = ''; + const bytes = new Uint8Array(buffer); + const len = bytes.byteLength; + for (let i = 0; i < len; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +}; diff --git a/src/lib/tw-local-backpack-api.js b/src/lib/tw-local-backpack-api.js index c8afb88cfcf..6149f3b14b9 100644 --- a/src/lib/tw-local-backpack-api.js +++ b/src/lib/tw-local-backpack-api.js @@ -1,32 +1,13 @@ import storage from './storage'; import md5 from 'js-md5'; import {soundThumbnail} from './backpack/sound-payload'; +import {arrayBufferToBase64, base64ToArrayBuffer} from './tw-base64-utils'; // Special constants -- do not change without care. const DATABASE_NAME = 'TW_Backpack'; const DATABASE_VERSION = 1; const STORE_NAME = 'backpack'; -const base64ToArrayBuffer = base64 => { - const binaryString = atob(base64); - const len = binaryString.length; - const array = new Uint8Array(len); - for (let i = 0; i < len; i++) { - array[i] = binaryString.charCodeAt(i); - } - return array.buffer; -}; - -const arrayBufferToBase64 = buffer => { - let binary = ''; - const bytes = new Uint8Array(buffer); - const len = bytes.byteLength; - for (let i = 0; i < len; i++) { - binary += String.fromCharCode(bytes[i]); - } - return btoa(binary); -}; - const idbItemToBackpackItem = item => { // convert id to string item.id = `${item.id}`; From 5b872953c3ce906c9093612fa129a56f0f5ff19d Mon Sep 17 00:00:00 2001 From: Muffin Date: Mon, 31 Jul 2023 23:23:39 -0500 Subject: [PATCH 04/21] More improvements --- .../restore-point-modal.css | 16 + .../restore-point-modal.jsx | 15 + src/containers/tw-restore-point-manager.jsx | 96 +++++- src/lib/tw-restore-point-api.js | 279 +++++++++++------- 4 files changed, 291 insertions(+), 115 deletions(-) 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 805fc9adb08..c98a904821f 100644 --- a/src/components/tw-restore-point-modal/restore-point-modal.css +++ b/src/components/tw-restore-point-modal/restore-point-modal.css @@ -105,3 +105,19 @@ .delete-button:hover { background-color: $ui-black-transparent; } + +.legacy-transition { + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 0.5rem; + border-radius: 0.25rem; + background-color: rgba(128, 0, 128, 0.18); + border: 1px solid rgba(128, 0, 128, 0.568); + text-align: center; + font-weight: bold; +} +.load-legacy-button { + margin-left: 1rem; + background-color: $pen-primary; +} 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 1aa5136d203..2d9e1a82155 100644 --- a/src/components/tw-restore-point-modal/restore-point-modal.jsx +++ b/src/components/tw-restore-point-modal/restore-point-modal.jsx @@ -26,6 +26,20 @@ const RestorePointModal = props => (
{props.isSupported ? (
+
+ {/* Don't translate -- this will be removed before it can be meaningfully translated */} + + {/* eslint-disable-next-line max-len */} + {'Restore points have been rewritten. If your project is not listed below, try loading the old restore point instead:'} + + +
+

i.id === id).title; + if (!confirm(this.props.intl.formatMessage(messages.confirmDelete, {projectTitle}))) { + return; + } + this.setState({ loading: true }); @@ -77,6 +109,10 @@ class TWRestorePointManager extends React.Component { } handleClickDeleteAll () { + if (!confirm(this.props.intl.formatMessage(messages.confirmDeleteAll))) { + return; + } + this.setState({ loading: true }); @@ -89,17 +125,45 @@ class TWRestorePointManager extends React.Component { }); } - handleClickLoad (id) { + _startLoading () { this.props.onCloseModal(); this.props.onStartLoadingRestorePoint(this.props.loadingState); + } + + _finishLoading (success) { + this.props.onFinishLoadingRestorePoint(success, this.props.loadingState); + } + + handleClickLoad (id) { + if (this.props.projectChanged && !confirm(this.props.intl.formatMessage(messages.confirmLoad))) { + return; + } + this._startLoading(); RestorePointAPI.loadRestorePoint(id) .then(buffer => this.props.vm.loadProject(buffer)) .then(() => { - this.props.onFinishLoadingRestorePoint(true, this.props.loadingState); + this._finishLoading(true); }) .catch(error => { this.handleError(error); - this.props.onFinishLoadingRestorePoint(false, this.props.loadingState); + this._finishLoading(false); + }); + } + + handleClickLoadLegacy () { + if (this.props.projectChanged && !confirm(this.props.intl.formatMessage(messages.confirmLoad))) { + return; + } + this._startLoading(); + RestorePointAPI.loadLegacyRestorePoint() + .then(buffer => this.props.vm.loadProject(buffer)) + .then(() => { + this._finishLoading(true); + }) + .catch(error => { + // Don't handleError on this because we're expecting error 90% of the time + alert(error); + this._finishLoading(false); }); } @@ -124,7 +188,14 @@ class TWRestorePointManager extends React.Component { } this.props.onStartCreatingRestorePoint(); - return RestorePointAPI.createRestorePoint(this.props.vm, this.props.projectTitle) + return Promise.all([ + RestorePointAPI.createRestorePoint(this.props.vm, this.props.projectTitle), + + // Force saves to not be instant so people can see that we're making a restore point + // It also makes refreshes less likely to cause accidental clicks in the modal + // TODO: is this actually a good idea? + new Promise(resolve => setTimeout(resolve, MINIMUM_SAVE_TIME)) + ]) .then(() => { if (this.props.isModalVisible) { this.refreshState(); @@ -145,11 +216,11 @@ class TWRestorePointManager extends React.Component { loading: true, restorePoints: [] }); - RestorePointAPI.readManifest() - .then(manifest => { + RestorePointAPI.getAllRestorePoints() + .then(restorePoints => { this.setState({ loading: false, - restorePoints: manifest.restorePoints + restorePoints }); }) .catch(error => { @@ -166,7 +237,6 @@ class TWRestorePointManager extends React.Component { if (!this.props.isModalVisible) { // TODO - // eslint-disable-next-line no-alert alert(`${error}`); } } @@ -180,6 +250,7 @@ class TWRestorePointManager extends React.Component { onClickDelete={this.handleClickDelete} onClickDeleteAll={this.handleClickDeleteAll} onClickLoad={this.handleClickLoad} + onClickLoadLegacy={this.handleClickLoadLegacy} isSupported={RestorePointAPI.isSupported} isLoading={this.state.loading} restorePoints={this.state.restorePoints} @@ -192,6 +263,7 @@ class TWRestorePointManager extends React.Component { } TWRestorePointManager.propTypes = { + intl: intlShape, projectChanged: PropTypes.bool.isRequired, projectTitle: PropTypes.string.isRequired, onStartCreatingRestorePoint: PropTypes.func.isRequired, @@ -231,7 +303,7 @@ const mapDispatchToProps = dispatch => ({ onCloseModal: () => dispatch(closeRestorePointModal()) }); -export default connect( +export default injectIntl(connect( mapStateToProps, mapDispatchToProps -)(TWRestorePointManager); +)(TWRestorePointManager)); diff --git a/src/lib/tw-restore-point-api.js b/src/lib/tw-restore-point-api.js index 8bbc7041d97..250b71fa4b5 100644 --- a/src/lib/tw-restore-point-api.js +++ b/src/lib/tw-restore-point-api.js @@ -1,7 +1,7 @@ import JSZip from 'jszip'; -import log from './log'; // TODO +/* const deleteLegacyData = () => { try { if (typeof indexedDB !== 'undefined') { @@ -14,7 +14,8 @@ const deleteLegacyData = () => { log.error('Error deleting legacy restore point data', e); } }; -// deleteLegacyData(); +deleteLegacyData(); +*/ /** * @typedef Manifest @@ -39,30 +40,33 @@ const ROOT_DIRECTORY = 'tw-restore-points-v2'; const MANIFEST_NAME = 'restore-points.json'; const PROJECT_DIRECTORY = 'projects'; const ASSET_DIRECTORY = 'assets'; + const MAX_RESTORE_POINTS = 5; const uniques = arr => Array.from(new Set(arr)); const isSupported = !!navigator.storage && !!navigator.storage.getDirectory; -const getDirectories = async () => { +/** + * @returns {Promise} The root directory to store all restore point data in. + */ +const getRootDirectory = async () => { const root = await navigator.storage.getDirectory(); const subdirectory = await root.getDirectoryHandle(ROOT_DIRECTORY, { create: true }); - const projects = await subdirectory.getDirectoryHandle(PROJECT_DIRECTORY, { - create: true - }); - const assets = await subdirectory.getDirectoryHandle(ASSET_DIRECTORY, { - create: true - }); - return { - root: subdirectory, - projects, - assets - }; + return subdirectory; }; +/** + * @param {FileSystemDirectoryHandle} root root + * @param {string} directoryName name of the directory + * @returns {Promise} project directory + */ +const getDirectory = (root, directoryName) => root.getDirectoryHandle(directoryName, { + create: true +}); + /** * @param {FileSystemDirectoryHandle} directory the directory * @returns {Promise} a list of files in the directory @@ -94,11 +98,22 @@ const readDirectory = directory => new Promise((resolve, reject) => { /** * @param {FileSystemDirectoryHandle} directory the directory - * @param {string} name the name of the file + * @param {string} filename the name of the file + * @returns {Promise} file object + */ +const readFile = async (directory, filename) => { + const fileHandle = await directory.getFileHandle(filename); + const file = await fileHandle.getFile(); + return file; +}; + +/** + * @param {FileSystemDirectoryHandle} directory the directory + * @param {string} filename the name of the file * @param {Uint8Array} data the contents to write */ -const writeToFile = async (directory, name, data) => { - const fileHandle = await directory.getFileHandle(name, { +const writeFile = async (directory, filename, data) => { + const fileHandle = await directory.getFileHandle(filename, { create: true }); const writable = await fileHandle.createWritable(); @@ -108,54 +123,54 @@ const writeToFile = async (directory, name, data) => { /** * @param {FileSystemDirectoryHandle} directory the directory - * @param {string} name the name of the file - */ -const deleteFile = async (directory, name) => { - await directory.removeEntry(name); -}; - -/** - * @param {FileSystemDirectoryHandle} directory the directory - * @param {string} filename the name of the file - * @returns {Promise} file object + * @param {string} name the name of the file or directory to delete */ -const readFile = async (directory, filename) => { - const fileHandle = await directory.getFileHandle(filename); - const file = await fileHandle.getFile(); - return file; +const deleteEntry = async (directory, name) => { + try { + await directory.removeEntry(name, { + recursive: true + }); + } catch (e) { + if (e.name === 'NotFoundError') { + // already deleted, can ignore + } else { + throw e; + } + } }; /** * @param {Manifest} obj unknown object - * @returns {boolean} true if obj is manifest + * @returns {Manifest} parsed manifest, known good format */ -const isValidManifest = obj => Array.isArray(obj.restorePoints) && obj.restorePoints.every(point => ( - typeof point.id === 'string' && - typeof point.title === 'string' && - typeof point.created === 'number' && - Array.isArray(point.assets) && - point.assets.every(asset => typeof asset === 'string') -)); +const parseManifest = obj => { + const parsed = { + restorePoints: Array.isArray(obj.restorePoints) ? obj.restorePoints : [] + }; + parsed.restorePoints = parsed.restorePoints.filter(point => ( + typeof point.id === 'string' && + typeof point.title === 'string' && + typeof point.created === 'number' && + Array.isArray(point.assets) && + point.assets.every(asset => typeof asset === 'string') + )); + return parsed; +}; /** * @param {FileSystemDirectoryHandle} root the root restore point directory * @returns {Promise} Parsed or default manifest */ -const readManifest = async () => { +const readManifest = async root => { try { - const directories = await getDirectories(); - const file = await readFile(directories.root, MANIFEST_NAME); + const file = await readFile(root, MANIFEST_NAME); const text = await file.text(); - const parsed = JSON.parse(text); - if (isValidManifest(parsed)) { - return parsed; - } + const json = JSON.parse(text); + return parseManifest(json); } catch (e) { // ignore } - return { - restorePoints: [] - }; + return parseManifest({}); }; /** @@ -172,23 +187,27 @@ const writeManifest = async (root, manifest) => { }; /** + * @param {FileSystemDirectoryEntry} root the root directory * @param {Manifest} manifest the manifest */ -const removeExtraneous = async manifest => { - const directories = await getDirectories(); +const removeExtraneousFiles = async (root, manifest) => { + const projectRoot = await getDirectory(root, PROJECT_DIRECTORY); + const assetRoot = await getDirectory(root, ASSET_DIRECTORY); const expectedProjectFiles = manifest.restorePoints.map(i => `${i.id}.json`); - const allSavedProjects = await readDirectory(directories.projects); - const projectFilesToDelete = allSavedProjects.filter(i => !expectedProjectFiles.includes(i)); - for (const projectFile of projectFilesToDelete) { - await deleteFile(directories.projects, projectFile); + const allSavedProjects = await readDirectory(projectRoot); + for (const projectFile of allSavedProjects) { + if (!expectedProjectFiles.includes(projectFile)) { + await deleteEntry(projectRoot, projectFile); + } } const expectedAssetFiles = uniques(manifest.restorePoints.map(i => i.assets).flat()); - const allSavedAssets = await readDirectory(directories.assets); - const assetsToDelete = allSavedAssets.filter(i => !expectedAssetFiles.includes(i)); - for (const assetName of assetsToDelete) { - await deleteFile(directories.assets, assetName); + const allSavedAssets = await readDirectory(assetRoot); + for (const assetName of allSavedAssets) { + if (!expectedAssetFiles.includes(assetName)) { + await deleteEntry(assetRoot, assetName); + } } }; @@ -197,71 +216,67 @@ const removeExtraneous = async manifest => { * @param {string} title project title */ const createRestorePoint = async (vm, title) => { - const directories = await getDirectories(); + const root = await getRootDirectory(); + const projectRoot = await getDirectory(root, PROJECT_DIRECTORY); + const assetRoot = await getDirectory(root, ASSET_DIRECTORY); const id = `${Date.now()}-${Math.round(Math.random() * 1e5)}`; /** @type {Record} */ const projectFiles = vm.saveProjectSb3DontZip(); - const projectAssets = Object.keys(projectFiles).filter(i => i !== 'project.json'); + const projectAssetNames = Object.keys(projectFiles).filter(i => i !== 'project.json'); + + // There's no guarantee that this code will finish all the way, so the order *does* matter. + // The lack of significant parallelization is also intentional as we don't want to slam the + // browser with massive amounts of data all at once, which could increase memory usage and + // eventually causes crashes and data loss. - const manifest = await readManifest(directories.root); + // Updating manifest must happen first, otherwise this restore point will never be recognized. + const manifest = await readManifest(root); manifest.restorePoints.unshift({ id, title, created: Math.round(Date.now() / 1000), - assets: projectAssets + assets: projectAssetNames }); while (manifest.restorePoints.length > MAX_RESTORE_POINTS) { manifest.restorePoints.pop(); } - await writeManifest(directories.root, manifest); + await writeManifest(root, manifest); + // Scripts are the next most important thing -- without this the assets can't be loaded. const jsonData = projectFiles['project.json']; - await writeToFile(directories.projects, `${id}.json`, jsonData); - - const alreadySavedAssets = await readDirectory(directories.assets); - const assetsToSave = projectAssets.filter(asset => !alreadySavedAssets.includes(asset)); - for (const assetName of assetsToSave) { - const data = projectFiles[assetName]; - await writeToFile(directories.assets, assetName, data); + await writeFile(projectRoot, `${id}.json`, jsonData); + + // Assets are saved next in the order the VM gives us, which we trust to be logical. + const alreadySavedAssets = await readDirectory(assetRoot); + for (const assetName of projectAssetNames) { + if (!alreadySavedAssets.includes(assetName)) { + const data = projectFiles[assetName]; + await writeFile(assetRoot, assetName, data); + } } - await removeExtraneous(manifest); + // Removing old data is the last priority + await removeExtraneousFiles(root, manifest); }; /** * @param {string} id the restore point's ID */ const deleteRestorePoint = async id => { - const directories = await getDirectories(); - const manifest = await readManifest(id); + const root = await getRootDirectory(); + const manifest = await readManifest(root); manifest.restorePoints = manifest.restorePoints.filter(i => i.id !== id); - await writeManifest(directories.root, manifest); - await removeExtraneous(manifest); + await writeManifest(root, manifest); + await removeExtraneousFiles(root, manifest); }; const deleteAllRestorePoints = async () => { - const directories = await getDirectories(); - try { - await directories.root.removeEntry(MANIFEST_NAME); - } catch (e) { - // ignore - } - try { - await directories.root.removeEntry(PROJECT_DIRECTORY, { - recursive: true - }); - } catch (e) { - // ignore - } - try { - await directories.root.removeEntry(ASSET_DIRECTORY, { - recursive: true - }); - } catch (e) { - // ignore - } + const root = await getRootDirectory(); + await deleteEntry(root, MANIFEST_NAME); + await deleteEntry(root, PROJECT_DIRECTORY); + await deleteEntry(root, ASSET_DIRECTORY); }; /** @@ -269,28 +284,86 @@ const deleteAllRestorePoints = async () => { * @returns {Promise} sb3 file */ const loadRestorePoint = async id => { - const directories = await getDirectories(); - const manifest = await readManifest(directories.root); + const root = await getRootDirectory(); + const projectRoot = await getDirectory(root, PROJECT_DIRECTORY); + const assetRoot = await getDirectory(root, ASSET_DIRECTORY); + + const manifest = await readManifest(root); const manifestEntry = manifest.restorePoints.find(i => i.id === id); const zip = new JSZip(); - const projectFile = await readFile(directories.projects, `${id}.json`); + const projectFile = await readFile(projectRoot, `${id}.json`); zip.file('project.json', projectFile); for (const asset of manifestEntry.assets) { - zip.file(asset, await readFile(directories.assets, asset)); + zip.file(asset, await readFile(assetRoot, asset)); } return zip.generateAsync({ - // no reason to spend time compresing it + // no reason to spend time compresing the zip since it will immediately be decompressed type: 'arraybuffer' }); }; +const getAllRestorePoints = async () => { + const root = await getRootDirectory(); + const manifest = await readManifest(root); + return manifest.restorePoints; +}; + +const loadLegacyRestorePoint = () => new Promise((resolve, reject) => { + if (!window.indexedDB) { + reject(new Error('indexedDB not supported')); + return; + } + + const DATABASE_NAME = 'TW_AutoSave'; + const DATABASE_VERSION = 1; + const STORE_NAME = 'project'; + + const openRequest = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + openRequest.onerror = () => { + reject(new Error(`Error opening DB: ${openRequest.error}`)); + }; + openRequest.onsuccess = () => { + const db = openRequest.result; + if (!db.objectStoreNames.contains(STORE_NAME)) { + reject(new Error('Object store does not exist')); + return; + } + + const transaction = db.transaction(STORE_NAME, 'readonly'); + transaction.onerror = () => { + reject(new Error(`Transaction error: ${transaction.error}`)); + }; + + const zip = new JSZip(); + const projectStore = transaction.objectStore(STORE_NAME); + const cursorRequest = projectStore.openCursor(); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + if (cursor) { + zip.file(cursor.key, cursor.value.data); + cursor.continue(); + } else { + const hasJSON = !!zip.file('project.json'); + if (hasJSON) { + resolve(zip.generateAsync({ + type: 'arraybuffer' + })); + } else { + reject(new Error('Could not find project.json')); + } + } + }; + }; +}); + export default { isSupported, - readManifest, + getAllRestorePoints, createRestorePoint, deleteRestorePoint, deleteAllRestorePoints, - loadRestorePoint + loadRestorePoint, + loadLegacyRestorePoint }; From 3e3f65a18d75002b17b8a0149553d3143d8b5f8b Mon Sep 17 00:00:00 2001 From: Muffin Date: Mon, 31 Jul 2023 23:45:50 -0500 Subject: [PATCH 05/21] keepExistingData for firefox --- src/lib/tw-restore-point-api.js | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/lib/tw-restore-point-api.js b/src/lib/tw-restore-point-api.js index 250b71fa4b5..800b5f65b46 100644 --- a/src/lib/tw-restore-point-api.js +++ b/src/lib/tw-restore-point-api.js @@ -116,7 +116,10 @@ const writeFile = async (directory, filename, data) => { const fileHandle = await directory.getFileHandle(filename, { create: true }); - const writable = await fileHandle.createWritable(); + const writable = await fileHandle.createWritable({ + // TODO: Firefox seems to not do this by default? + keepExistingData: false + }); await writable.write(data); await writable.close(); }; From 290445922b29b75f7b61e9ee4e5eef9ad5cd2f21 Mon Sep 17 00:00:00 2001 From: Muffin Date: Tue, 1 Aug 2023 19:39:48 -0500 Subject: [PATCH 06/21] Go back to indexedDB IDB has better browser support and solves the locking issue for us IDB seems to work fine for this -- we were just using it wrong (openCursor would read the entire restore point from disk!) --- .../restore-point-modal.jsx | 182 +++--- .../tw-restore-point-modal/restore-point.jsx | 2 +- .../tw-restore-point-modal/unsupported.css | 8 - .../tw-restore-point-modal/unsupported.jsx | 30 - src/containers/tw-restore-point-manager.jsx | 7 +- src/lib/tw-restore-point-api.js | 529 +++++++++--------- 6 files changed, 366 insertions(+), 392 deletions(-) delete mode 100644 src/components/tw-restore-point-modal/unsupported.css delete mode 100644 src/components/tw-restore-point-modal/unsupported.jsx 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 2d9e1a82155..244111f5f45 100644 --- a/src/components/tw-restore-point-modal/restore-point-modal.jsx +++ b/src/components/tw-restore-point-modal/restore-point-modal.jsx @@ -3,7 +3,6 @@ import PropTypes from 'prop-types'; import React from 'react'; import Modal from '../../containers/modal.jsx'; import RestorePoint from './restore-point.jsx'; -import Unsupported from './unsupported.jsx'; import styles from './restore-point-modal.css'; import classNames from 'classnames'; import {APP_NAME} from '../../lib/brand'; @@ -24,108 +23,102 @@ const RestorePointModal = props => ( id="restorePointModal" >

- {props.isSupported ? ( -
-
- {/* Don't translate -- this will be removed before it can be meaningfully translated */} - - {/* eslint-disable-next-line max-len */} - {'Restore points have been rewritten. If your project is not listed below, try loading the old restore point instead:'} - - -
+
+ {/* Don't translate -- this will be removed before it can be meaningfully translated */} + + {/* eslint-disable-next-line max-len */} + {'Restore points have been rewritten. If your project is not listed below, try loading the old restore point instead:'} + + +
+

+ +

+ +
+ + +
+ + {props.error ? ( +

- -
- - -
- - {props.error ? ( -
-

- -

-

- {props.error} -

-
- ) : props.isLoading ? ( -
- -
- ) : props.restorePoints.length === 0 ? ( -
- -
- ) : ( -
- {props.restorePoints.map(restorePoint => ( - - ))} -
- )} +

+ {props.error} +

+
+ ) : props.isLoading ? ( +
+ +
+ ) : props.restorePoints.length === 0 ? ( +
+
) : ( - +
+ {props.restorePoints.map(restorePoint => ( + + ))} +
)}
@@ -139,7 +132,6 @@ RestorePointModal.propTypes = { onClickDeleteAll: PropTypes.func.isRequired, onClickLoad: PropTypes.func.isRequired, onClickLoadLegacy: PropTypes.func.isRequired, - isSupported: PropTypes.bool.isRequired, isLoading: PropTypes.bool.isRequired, restorePoints: PropTypes.arrayOf(PropTypes.shape({})), error: PropTypes.string diff --git a/src/components/tw-restore-point-modal/restore-point.jsx b/src/components/tw-restore-point-modal/restore-point.jsx index c264ba8db5f..ffb227d884a 100644 --- a/src/components/tw-restore-point-modal/restore-point.jsx +++ b/src/components/tw-restore-point-modal/restore-point.jsx @@ -61,7 +61,7 @@ class RestorePoint extends React.Component { } RestorePoint.propTypes = { - id: PropTypes.string.isRequired, + id: PropTypes.number.isRequired, title: PropTypes.string.isRequired, assets: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, created: PropTypes.number.isRequired, diff --git a/src/components/tw-restore-point-modal/unsupported.css b/src/components/tw-restore-point-modal/unsupported.css deleted file mode 100644 index 001d7b98ffb..00000000000 --- a/src/components/tw-restore-point-modal/unsupported.css +++ /dev/null @@ -1,8 +0,0 @@ -.container p, -.container ul { - margin: 0.5em 0; -} - -.title { - font-weight: bold; -} diff --git a/src/components/tw-restore-point-modal/unsupported.jsx b/src/components/tw-restore-point-modal/unsupported.jsx deleted file mode 100644 index 88ca8648108..00000000000 --- a/src/components/tw-restore-point-modal/unsupported.jsx +++ /dev/null @@ -1,30 +0,0 @@ -import React from 'react'; -import {FormattedMessage} from 'react-intl'; -import styles from './unsupported.css'; - -const Unsupported = () => ( -
-

- -

-

- -

-
    -
  • {'Chrome 86'}
  • -
  • {'Edge 86'}
  • -
  • {'Firefox 111'}
  • -
  • {'Safari 15.2'}
  • -
-
-); - -export default Unsupported; diff --git a/src/containers/tw-restore-point-manager.jsx b/src/containers/tw-restore-point-manager.jsx index 3115675b3d0..a2f3df14aa7 100644 --- a/src/containers/tw-restore-point-manager.jsx +++ b/src/containers/tw-restore-point-manager.jsx @@ -64,10 +64,8 @@ class TWRestorePointManager extends React.Component { componentDidUpdate (prevProps) { if ( - RestorePointAPI.isSupported && ( - this.props.projectChanged !== prevProps.projectChanged || - this.props.isShowingProject !== prevProps.isShowingProject - ) + this.props.projectChanged !== prevProps.projectChanged || + this.props.isShowingProject !== prevProps.isShowingProject ) { if (this.props.projectChanged && this.props.isShowingProject) { // Project was modified @@ -251,7 +249,6 @@ class TWRestorePointManager extends React.Component { onClickDeleteAll={this.handleClickDeleteAll} onClickLoad={this.handleClickLoad} onClickLoadLegacy={this.handleClickLoadLegacy} - isSupported={RestorePointAPI.isSupported} isLoading={this.state.loading} restorePoints={this.state.restorePoints} error={this.state.error} diff --git a/src/lib/tw-restore-point-api.js b/src/lib/tw-restore-point-api.js index 800b5f65b46..7db7db5eb40 100644 --- a/src/lib/tw-restore-point-api.js +++ b/src/lib/tw-restore-point-api.js @@ -18,329 +18,353 @@ deleteLegacyData(); */ /** - * @typedef Manifest - * @property {{id: string; title: string; assets: string[]; created: number}[]} restorePoints + * @typedef Metadata + * @property {string} title + * @property {number} created Unix seconds + * @property {string[]} assets md5exts */ -/* -Directory structure: -[*] root - [*] restore-points.json - {"restorePoints":[...]} - [*] projects - [*] 1234.json - {"targets":[...],...} - [*] ... - [*] assets - [*] 0123456789abcdef....svg - [*] ... -*/ - -const ROOT_DIRECTORY = 'tw-restore-points-v2'; -const MANIFEST_NAME = 'restore-points.json'; -const PROJECT_DIRECTORY = 'projects'; -const ASSET_DIRECTORY = 'assets'; - -const MAX_RESTORE_POINTS = 5; +const DATABASE_NAME = 'TW_RestorePoints'; +const DATABASE_VERSION = 2; +const METADATA_STORE = 'meta'; +const PROJECT_STORE = 'projects'; +const ASSET_STORE = 'assets'; -const uniques = arr => Array.from(new Set(arr)); +// TODO +const MAX_AUTOMATIC_RESTORE_POINTS = 5; -const isSupported = !!navigator.storage && !!navigator.storage.getDirectory; +/** @type {IDBDatabase|null} */ +let _cachedDB = null; /** - * @returns {Promise} The root directory to store all restore point data in. + * @returns {Promise} IDB database with all stores created. */ -const getRootDirectory = async () => { - const root = await navigator.storage.getDirectory(); - const subdirectory = await root.getDirectoryHandle(ROOT_DIRECTORY, { - create: true - }); - return subdirectory; -}; +const openDB = () => { + if (_cachedDB) { + return Promise.resolve(_cachedDB); + } -/** - * @param {FileSystemDirectoryHandle} root root - * @param {string} directoryName name of the directory - * @returns {Promise} project directory - */ -const getDirectory = (root, directoryName) => root.getDirectoryHandle(directoryName, { - create: true -}); + if (typeof indexedDB === 'undefined') { + return Promise.resolve(null); + } -/** - * @param {FileSystemDirectoryHandle} directory the directory - * @returns {Promise} a list of files in the directory - */ -const readDirectory = directory => new Promise((resolve, reject) => { - /** @type {string} */ - const files = []; - - /** @type {AsyncIterator} */ - const iterator = directory.keys(); - - const getNext = () => { - iterator.next() - .then(result => { - if (result.done) { - resolve(files); - } else { - files.push(result.value); - getNext(); - } - }) - .catch(error => { - reject(error); + return new Promise((resolve, reject) => { + const request = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + + request.onupgradeneeded = () => { + const db = request.result; + db.createObjectStore(METADATA_STORE, { + autoIncrement: true }); - }; + db.createObjectStore(PROJECT_STORE); + db.createObjectStore(ASSET_STORE); + }; - getNext(); -}); + request.onsuccess = () => { + _cachedDB = request.result; + resolve(request.result); + }; -/** - * @param {FileSystemDirectoryHandle} directory the directory - * @param {string} filename the name of the file - * @returns {Promise} file object - */ -const readFile = async (directory, filename) => { - const fileHandle = await directory.getFileHandle(filename); - const file = await fileHandle.getFile(); - return file; + request.onerror = () => { + reject(new Error(`Could not open database: ${request.error}`)); + }; + }); }; /** - * @param {FileSystemDirectoryHandle} directory the directory - * @param {string} filename the name of the file - * @param {Uint8Array} data the contents to write + * Converts a possibly unknown or corrupted object to a known-good metadata object. + * @param {Partial} obj Unknown object + * @returns {Metadata} Metadata object with ID */ -const writeFile = async (directory, filename, data) => { - const fileHandle = await directory.getFileHandle(filename, { - create: true - }); - const writable = await fileHandle.createWritable({ - // TODO: Firefox seems to not do this by default? - keepExistingData: false - }); - await writable.write(data); - await writable.close(); +const parseMetadata = obj => { + // Must not throw -- always return the most salvageable object possible. + if (!obj || typeof obj !== 'object') { + obj = {}; + } + obj.title = typeof obj.title === 'string' ? obj.title : '?'; + obj.created = typeof obj.created === 'number' ? obj.created : 0; + obj.assets = Array.isArray(obj.assets) ? obj.assets : []; + obj.assets = obj.assets.filter(i => typeof i === 'string'); + return obj; }; /** - * @param {FileSystemDirectoryHandle} directory the directory - * @param {string} name the name of the file or directory to delete + * @param {IDBObjectStore} objectStore IDB object store + * @param {Set} keysToKeep IDB keys that should continue to exist. Type sensitive. + * @returns {Promise} Resolves when unused items have been deleted */ -const deleteEntry = async (directory, name) => { - try { - await directory.removeEntry(name, { - recursive: true - }); - } catch (e) { - if (e.name === 'NotFoundError') { - // already deleted, can ignore - } else { - throw e; +const deleteUnknownKeys = (objectStore, keysToKeep) => new Promise(resolve => { + const keysRequest = objectStore.getAllKeys(); + keysRequest.onsuccess = async () => { + const allKeys = keysRequest.result; + + for (const key of allKeys) { + if (!keysToKeep.has(key)) { + await new Promise(innerResolve => { + const deleteRequest = objectStore.delete(key); + deleteRequest.onsuccess = () => { + innerResolve(); + }; + }); + } } - } -}; + + resolve(); + }; +}); /** - * @param {Manifest} obj unknown object - * @returns {Manifest} parsed manifest, known good format + * @param {IDBTransaction} transaction readwrite transaction with access to all stores + * @returns {Promise} Resolves when files have finished being removed. */ -const parseManifest = obj => { - const parsed = { - restorePoints: Array.isArray(obj.restorePoints) ? obj.restorePoints : [] +const removeExtraneousFiles = transaction => new Promise(resolve => { + const metadataStore = transaction.objectStore(METADATA_STORE); + const projectStore = transaction.objectStore(PROJECT_STORE); + const assetStore = transaction.objectStore(ASSET_STORE); + + const requiredProjects = new Set(); + const requiredAssetIDs = new Set(); + + const request = metadataStore.openCursor(); + request.onsuccess = () => { + const cursor = request.result; + if (cursor) { + requiredProjects.add(cursor.key); + const metadata = parseMetadata(cursor.value); + for (const assetId of metadata.assets) { + requiredAssetIDs.add(assetId); + } + cursor.continue(); + } else { + deleteUnknownKeys(projectStore, requiredProjects) + .then(() => deleteUnknownKeys(assetStore, requiredAssetIDs)) + .then(() => resolve()); + } }; - parsed.restorePoints = parsed.restorePoints.filter(point => ( - typeof point.id === 'string' && - typeof point.title === 'string' && - typeof point.created === 'number' && - Array.isArray(point.assets) && - point.assets.every(asset => typeof asset === 'string') - )); - return parsed; -}; +}); /** - * @param {FileSystemDirectoryHandle} root the root restore point directory - * @returns {Promise} Parsed or default manifest + * @param {VirtualMachine} vm scratch-vm instance + * @param {string} title project title + * @returns {Promise} resolves when the restore point is created */ -const readManifest = async root => { - try { - const file = await readFile(root, MANIFEST_NAME); - const text = await file.text(); - const json = JSON.parse(text); - return parseManifest(json); - } catch (e) { - // ignore - } - return parseManifest({}); -}; +const createRestorePoint = (vm, title) => openDB().then(db => { + /** @type {Record} */ + const projectFiles = vm.saveProjectSb3DontZip(); + const projectAssetIDs = Object.keys(projectFiles) + .filter(i => i !== 'project.json'); + + const transaction = db.transaction([METADATA_STORE, PROJECT_STORE, ASSET_STORE], 'readwrite'); + return new Promise((resolveTransaction, rejectTransaction) => { + transaction.onerror = () => { + rejectTransaction(new Error(`Transaction error: ${transaction.error}`)); + }; + + let generatedId = null; + + const writeMissingAssets = async missingAssets => { + const assetStore = transaction.objectStore(ASSET_STORE); + for (const assetId of missingAssets) { + await new Promise(resolveAsset => { + // TODO: should we insert arraybuffer or uint8array? + const assetDataArray = projectFiles[assetId]; + const request = assetStore.put(assetDataArray, assetId); + request.onsuccess = () => { + resolveAsset(); + }; + }); + } + + resolveTransaction(); + }; + + const checkMissingAssets = () => { + const assetStore = transaction.objectStore(ASSET_STORE); + const keyRequest = assetStore.getAllKeys(); + keyRequest.onsuccess = () => { + const savedAssets = keyRequest.result; + const missingAssets = projectAssetIDs.filter(assetId => !savedAssets.includes(assetId)); + writeMissingAssets(missingAssets); + }; + }; + + const writeProjectJSON = () => { + const jsonData = projectFiles['project.json']; + const projectStore = transaction.objectStore(PROJECT_STORE); + const request = projectStore.add(jsonData, generatedId); + request.onsuccess = () => { + checkMissingAssets(); + }; + }; + + const writeMetadata = () => { + /** @type {Metadata} */ + const metadata = { + title, + created: Math.round(Date.now() / 1000), + assets: projectAssetIDs + }; + + const metadataStore = transaction.objectStore(METADATA_STORE); + const request = metadataStore.add(metadata); + request.onsuccess = () => { + generatedId = request.result; + writeProjectJSON(); + }; + }; + + writeMetadata(); + }); +}); /** - * @param {FileSystemDirectoryHandle} root the root restore point directory - * @param {Manifest} manifest Manifest to write. + * @param {number} id the restore point's ID + * @returns {Promise} Resovles when the restore point has been deleted. */ -const writeManifest = async (root, manifest) => { - const fileHandle = await root.getFileHandle(MANIFEST_NAME, { - create: true - }); - const writable = await fileHandle.createWritable(); - await writable.write(JSON.stringify(manifest)); - await writable.close(); -}; +const deleteRestorePoint = id => openDB().then(db => new Promise((resolve, reject) => { + const transaction = db.transaction([METADATA_STORE, PROJECT_STORE, ASSET_STORE], 'readwrite'); + transaction.onerror = () => { + reject(new Error(`Transaction error: ${transaction.error}`)); + }; + + const metadataStore = transaction.objectStore(METADATA_STORE); + const request = metadataStore.delete(id); + request.onsuccess = () => { + removeExtraneousFiles(transaction) + .then(() => resolve()); + }; +})); /** - * @param {FileSystemDirectoryEntry} root the root directory - * @param {Manifest} manifest the manifest + * @returns {Promise} Resolves when the database has been deleted. */ -const removeExtraneousFiles = async (root, manifest) => { - const projectRoot = await getDirectory(root, PROJECT_DIRECTORY); - const assetRoot = await getDirectory(root, ASSET_DIRECTORY); - - const expectedProjectFiles = manifest.restorePoints.map(i => `${i.id}.json`); - const allSavedProjects = await readDirectory(projectRoot); - for (const projectFile of allSavedProjects) { - if (!expectedProjectFiles.includes(projectFile)) { - await deleteEntry(projectRoot, projectFile); - } - } +const deleteAllRestorePoints = () => new Promise((resolve, reject) => { + _cachedDB = null; - const expectedAssetFiles = uniques(manifest.restorePoints.map(i => i.assets).flat()); - const allSavedAssets = await readDirectory(assetRoot); - for (const assetName of allSavedAssets) { - if (!expectedAssetFiles.includes(assetName)) { - await deleteEntry(assetRoot, assetName); - } - } -}; + const request = indexedDB.deleteDatabase(DATABASE_NAME); + request.onerror = () => { + reject(new Error(`Database error: ${request.error}`)); + }; + request.onsuccess = () => { + resolve(); + }; +}); /** - * @param {VirtualMachine} vm scratch-vm instance - * @param {string} title project title + * @param {number} id the restore point's ID + * @returns {Promise} Resolves with sb3 file */ -const createRestorePoint = async (vm, title) => { - const root = await getRootDirectory(); - const projectRoot = await getDirectory(root, PROJECT_DIRECTORY); - const assetRoot = await getDirectory(root, ASSET_DIRECTORY); +const loadRestorePoint = id => openDB().then(db => new Promise((resolveTransaction, rejectTransaction) => { + const transaction = db.transaction([METADATA_STORE, PROJECT_STORE, ASSET_STORE], 'readonly'); + transaction.onerror = () => { + rejectTransaction(new Error(`Transaction error: ${transaction.error}`)); + }; - const id = `${Date.now()}-${Math.round(Math.random() * 1e5)}`; + const zip = new JSZip(); + /** @type {Metadata} */ + let metadata; + + const generate = () => { + resolveTransaction(zip.generateAsync({ + // Don't bother compressing it since it will be immediately decompressed + type: 'arraybuffer' + })); + }; - /** @type {Record} */ - const projectFiles = vm.saveProjectSb3DontZip(); - const projectAssetNames = Object.keys(projectFiles).filter(i => i !== 'project.json'); - - // There's no guarantee that this code will finish all the way, so the order *does* matter. - // The lack of significant parallelization is also intentional as we don't want to slam the - // browser with massive amounts of data all at once, which could increase memory usage and - // eventually causes crashes and data loss. - - // Updating manifest must happen first, otherwise this restore point will never be recognized. - const manifest = await readManifest(root); - manifest.restorePoints.unshift({ - id, - title, - created: Math.round(Date.now() / 1000), - assets: projectAssetNames - }); - while (manifest.restorePoints.length > MAX_RESTORE_POINTS) { - manifest.restorePoints.pop(); - } - await writeManifest(root, manifest); - - // Scripts are the next most important thing -- without this the assets can't be loaded. - const jsonData = projectFiles['project.json']; - await writeFile(projectRoot, `${id}.json`, jsonData); - - // Assets are saved next in the order the VM gives us, which we trust to be logical. - const alreadySavedAssets = await readDirectory(assetRoot); - for (const assetName of projectAssetNames) { - if (!alreadySavedAssets.includes(assetName)) { - const data = projectFiles[assetName]; - await writeFile(assetRoot, assetName, data); + const loadAssets = async () => { + const assetStore = transaction.objectStore(ASSET_STORE); + for (const assetId of metadata.assets) { + await new Promise(resolve => { + const request = assetStore.get(assetId); + request.onsuccess = () => { + const data = request.result; + zip.file(assetId, data); + resolve(); + }; + }); } - } - // Removing old data is the last priority - await removeExtraneousFiles(root, manifest); -}; + generate(); + }; -/** - * @param {string} id the restore point's ID - */ -const deleteRestorePoint = async id => { - const root = await getRootDirectory(); - const manifest = await readManifest(root); - manifest.restorePoints = manifest.restorePoints.filter(i => i.id !== id); - await writeManifest(root, manifest); - await removeExtraneousFiles(root, manifest); -}; + const loadProjectJSON = () => { + const projectStore = transaction.objectStore(PROJECT_STORE); + const request = projectStore.get(id); + request.onsuccess = () => { + zip.file('project.json', request.result); + loadAssets(); + }; + }; -const deleteAllRestorePoints = async () => { - const root = await getRootDirectory(); - await deleteEntry(root, MANIFEST_NAME); - await deleteEntry(root, PROJECT_DIRECTORY); - await deleteEntry(root, ASSET_DIRECTORY); -}; + const loadMetadata = () => { + const metadataStore = transaction.objectStore(METADATA_STORE); + const request = metadataStore.get(id); + request.onsuccess = () => { + metadata = parseMetadata(request.result); + loadProjectJSON(); + }; + }; + + loadMetadata(); +})); +// eslint-disable-next-line valid-jsdoc /** - * @param {string} id the restore point id - * @returns {Promise} sb3 file + * @returns {Promise>} List of restore points sorted newest first. */ -const loadRestorePoint = async id => { - const root = await getRootDirectory(); - const projectRoot = await getDirectory(root, PROJECT_DIRECTORY); - const assetRoot = await getDirectory(root, ASSET_DIRECTORY); - - const manifest = await readManifest(root); - const manifestEntry = manifest.restorePoints.find(i => i.id === id); +const getAllRestorePoints = () => openDB().then(db => new Promise((resolve, reject) => { + const transaction = db.transaction([METADATA_STORE], 'readonly'); + transaction.onerror = () => { + reject(new Error(`Transaction error: ${transaction.error}`)); + }; - const zip = new JSZip(); - const projectFile = await readFile(projectRoot, `${id}.json`); - zip.file('project.json', projectFile); - for (const asset of manifestEntry.assets) { - zip.file(asset, await readFile(assetRoot, asset)); - } + /** @type {Metadata[]} */ + const restorePoints = []; - return zip.generateAsync({ - // no reason to spend time compresing the zip since it will immediately be decompressed - type: 'arraybuffer' - }); -}; + const metadataStore = transaction.objectStore(METADATA_STORE); + const request = metadataStore.openCursor(); + request.onsuccess = () => { + const cursor = request.result; + if (cursor) { + const parsed = parseMetadata(cursor.value); + parsed.id = cursor.key; + restorePoints.push(parsed); -const getAllRestorePoints = async () => { - const root = await getRootDirectory(); - const manifest = await readManifest(root); - return manifest.restorePoints; -}; + cursor.continue(); + } else { + resolve(restorePoints); + } + }; +})); const loadLegacyRestorePoint = () => new Promise((resolve, reject) => { - if (!window.indexedDB) { + if (typeof indexedDB === 'undefined') { reject(new Error('indexedDB not supported')); return; } - const DATABASE_NAME = 'TW_AutoSave'; - const DATABASE_VERSION = 1; - const STORE_NAME = 'project'; + const LEGACY_DATABASE_NAME = 'TW_AutoSave'; + const LEGACY_DATABASE_VERSION = 1; + const LEGACY_STORE_NAME = 'project'; - const openRequest = indexedDB.open(DATABASE_NAME, DATABASE_VERSION); + const openRequest = indexedDB.open(LEGACY_DATABASE_NAME, LEGACY_DATABASE_VERSION); openRequest.onerror = () => { reject(new Error(`Error opening DB: ${openRequest.error}`)); }; openRequest.onsuccess = () => { const db = openRequest.result; - if (!db.objectStoreNames.contains(STORE_NAME)) { + if (!db.objectStoreNames.contains(LEGACY_STORE_NAME)) { reject(new Error('Object store does not exist')); return; } - const transaction = db.transaction(STORE_NAME, 'readonly'); + const transaction = db.transaction(LEGACY_STORE_NAME, 'readonly'); transaction.onerror = () => { reject(new Error(`Transaction error: ${transaction.error}`)); }; const zip = new JSZip(); - const projectStore = transaction.objectStore(STORE_NAME); + const projectStore = transaction.objectStore(LEGACY_STORE_NAME); const cursorRequest = projectStore.openCursor(); cursorRequest.onsuccess = () => { const cursor = cursorRequest.result; @@ -362,7 +386,6 @@ const loadLegacyRestorePoint = () => new Promise((resolve, reject) => { }); export default { - isSupported, getAllRestorePoints, createRestorePoint, deleteRestorePoint, From 7465e539948497db4418d2bf4af8ae74474846ee Mon Sep 17 00:00:00 2001 From: Muffin Date: Tue, 1 Aug 2023 20:06:22 -0500 Subject: [PATCH 07/21] Save type and size information --- .../restore-point-modal.css | 8 +- .../restore-point-modal.jsx | 5 +- .../tw-restore-point-modal/restore-point.jsx | 52 ++++++++---- src/containers/tw-restore-point-manager.jsx | 9 +- src/lib/tw-restore-point-api.js | 84 +++++++++++++++++-- 5 files changed, 128 insertions(+), 30 deletions(-) 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 c98a904821f..cdfc4c94b68 100644 --- a/src/components/tw-restore-point-modal/restore-point-modal.css +++ b/src/components/tw-restore-point-modal/restore-point-modal.css @@ -78,14 +78,20 @@ .restore-point-details { display: flex; flex-direction: column; +} +.restore-point-title-outer { + } .restore-point-title { font-weight: bold; +} +.restore-point-type { + } .restore-point-date { } -.restore-point-assets { +.restore-point-size { } 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 244111f5f45..fec5dc5e4f8 100644 --- a/src/components/tw-restore-point-modal/restore-point-modal.jsx +++ b/src/components/tw-restore-point-modal/restore-point-modal.jsx @@ -112,10 +112,7 @@ const RestorePointModal = props => ( key={restorePoint.id} onClickDelete={props.onClickDelete} onClickLoad={props.onClickLoad} - id={restorePoint.id} - title={restorePoint.title} - assets={restorePoint.assets} - created={restorePoint.created} + {...restorePoint} /> ))}
diff --git a/src/components/tw-restore-point-modal/restore-point.jsx b/src/components/tw-restore-point-modal/restore-point.jsx index ffb227d884a..a1bedcf77e6 100644 --- a/src/components/tw-restore-point-modal/restore-point.jsx +++ b/src/components/tw-restore-point-modal/restore-point.jsx @@ -3,6 +3,7 @@ import PropTypes from 'prop-types'; import {FormattedMessage} from 'react-intl'; import bindAll from 'lodash.bindall'; import styles from './restore-point-modal.css'; +import RestorePointAPI from '../../lib/tw-restore-point-api'; class RestorePoint extends React.Component { constructor (props) { @@ -12,13 +13,29 @@ class RestorePoint extends React.Component { 'handleClickLoad' ]); } + handleClickDelete (e) { e.stopPropagation(); this.props.onClickDelete(this.props.id); } + handleClickLoad () { this.props.onClickLoad(this.props.id); } + + formatDate () { + // TODO: react-intl should have a proper way to do this? + return new Date(this.props.created * 1000).toLocaleString(); + } + + formatSize () { + const size = this.props.size; + if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(2)}KB`; + } + return `${(size / 1024 / 1024).toFixed(2)}MB`; + } + render () { return (
-
- {this.props.title} +
+ + {this.props.title} + + {this.props.type === RestorePointAPI.TYPE_AUTOMATIC && ( + + {' '} + + + )}
- {new Date(this.props.created * 1000).toLocaleString()} + {this.formatDate()} - {' '} - - + {', '} + + {this.formatSize()}
@@ -63,8 +85,10 @@ class RestorePoint extends React.Component { RestorePoint.propTypes = { id: PropTypes.number.isRequired, title: PropTypes.string.isRequired, - assets: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, created: PropTypes.number.isRequired, + type: PropTypes.oneOf([RestorePointAPI.TYPE_AUTOMATIC, RestorePointAPI.TYPE_MANUAL]).isRequired, + size: PropTypes.number.isRequired, + assets: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, onClickDelete: 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 a2f3df14aa7..d3b715db230 100644 --- a/src/containers/tw-restore-point-manager.jsx +++ b/src/containers/tw-restore-point-manager.jsx @@ -85,7 +85,7 @@ class TWRestorePointManager extends React.Component { } handleClickCreate () { - this.createRestorePoint(); + this.createRestorePoint(RestorePointAPI.TYPE_MANUAL); } handleClickDelete (id) { @@ -167,7 +167,7 @@ class TWRestorePointManager extends React.Component { queueRestorePoint () { this.timeout = setTimeout(() => { - this.createRestorePoint().then(() => { + this.createRestorePoint(RestorePointAPI.TYPE_AUTOMATIC).then(() => { this.timeout = null; if (this.props.projectChanged && this.props.isShowingProject) { @@ -178,7 +178,7 @@ class TWRestorePointManager extends React.Component { }, AUTOMATIC_INTERVAL); } - createRestorePoint () { + createRestorePoint (type) { if (this.props.isModalVisible) { this.setState({ loading: true @@ -187,7 +187,8 @@ class TWRestorePointManager extends React.Component { this.props.onStartCreatingRestorePoint(); return Promise.all([ - RestorePointAPI.createRestorePoint(this.props.vm, this.props.projectTitle), + RestorePointAPI.createRestorePoint(this.props.vm, this.props.projectTitle, type) + .then(() => RestorePointAPI.removeExtraneousRestorePoints()), // Force saves to not be instant so people can see that we're making a restore point // It also makes refreshes less likely to cause accidental clicks in the modal diff --git a/src/lib/tw-restore-point-api.js b/src/lib/tw-restore-point-api.js index 7db7db5eb40..81c72ac214d 100644 --- a/src/lib/tw-restore-point-api.js +++ b/src/lib/tw-restore-point-api.js @@ -17,10 +17,19 @@ const deleteLegacyData = () => { deleteLegacyData(); */ +const TYPE_AUTOMATIC = 0; +const TYPE_MANUAL = 1; + +/** + * @typedef {0|1} MetadataType + */ + /** * @typedef Metadata * @property {string} title * @property {number} created Unix seconds + * @property {Type} type + * @property {number} size in bytes * @property {string[]} assets md5exts */ @@ -30,7 +39,6 @@ const METADATA_STORE = 'meta'; const PROJECT_STORE = 'projects'; const ASSET_STORE = 'assets'; -// TODO const MAX_AUTOMATIC_RESTORE_POINTS = 5; /** @type {IDBDatabase|null} */ @@ -83,6 +91,7 @@ const parseMetadata = obj => { } obj.title = typeof obj.title === 'string' ? obj.title : '?'; obj.created = typeof obj.created === 'number' ? obj.created : 0; + obj.type = [TYPE_AUTOMATIC, TYPE_MANUAL].includes(obj.type) ? obj.type : 1; obj.assets = Array.isArray(obj.assets) ? obj.assets : []; obj.assets = obj.assets.filter(i => typeof i === 'string'); return obj; @@ -115,9 +124,9 @@ const deleteUnknownKeys = (objectStore, keysToKeep) => new Promise(resolve => { /** * @param {IDBTransaction} transaction readwrite transaction with access to all stores - * @returns {Promise} Resolves when files have finished being removed. + * @returns {Promise} Resolves when data has finished being removed. */ -const removeExtraneousFiles = transaction => new Promise(resolve => { +const removeExtraneousData = transaction => new Promise(resolve => { const metadataStore = transaction.objectStore(METADATA_STORE); const projectStore = transaction.objectStore(PROJECT_STORE); const assetStore = transaction.objectStore(ASSET_STORE); @@ -143,17 +152,68 @@ const removeExtraneousFiles = transaction => new Promise(resolve => { }; }); +/** + * @returns {Promise} Resolves when extraneous restore points have been removed. + */ +const removeExtraneousRestorePoints = () => openDB().then(db => new Promise((resolveTransaction, rejectTransaction) => { + const transaction = db.transaction([METADATA_STORE, PROJECT_STORE, ASSET_STORE], 'readwrite'); + transaction.onerror = () => { + rejectTransaction(new Error(`Transaction error: ${transaction.error}`)); + }; + + const metadataStore = transaction.objectStore(METADATA_STORE); + const projectsToDelete = []; + let automaticCount = 0; + + const deleteProjects = async () => { + for (const key of projectsToDelete) { + await new Promise(resolve => { + const deleteRequest = metadataStore.delete(key); + deleteRequest.onsuccess = () => { + resolve(); + }; + }); + } + + removeExtraneousData(transaction) + .then(() => resolveTransaction()); + }; + + const getRequest = metadataStore.openCursor(null, 'prev'); + getRequest.onsuccess = () => { + const cursor = getRequest.result; + if (cursor) { + const manifest = parseMetadata(cursor.value); + if (manifest.type === TYPE_AUTOMATIC) { + if (automaticCount <= MAX_AUTOMATIC_RESTORE_POINTS) { + automaticCount++; + } else { + projectsToDelete.push(cursor.key); + } + } + cursor.continue(); + } else { + deleteProjects(); + } + }; +})); + /** * @param {VirtualMachine} vm scratch-vm instance * @param {string} title project title + * @param {MetadataType} type restore point type * @returns {Promise} resolves when the restore point is created */ -const createRestorePoint = (vm, title) => openDB().then(db => { +const createRestorePoint = (vm, title, type) => openDB().then(db => { /** @type {Record} */ const projectFiles = vm.saveProjectSb3DontZip(); const projectAssetIDs = Object.keys(projectFiles) .filter(i => i !== 'project.json'); + if (projectAssetIDs.length === 0) { + throw new Error('There are no assets in this project'); + } + const transaction = db.transaction([METADATA_STORE, PROJECT_STORE, ASSET_STORE], 'readwrite'); return new Promise((resolveTransaction, rejectTransaction) => { transaction.onerror = () => { @@ -168,7 +228,7 @@ const createRestorePoint = (vm, title) => openDB().then(db => { await new Promise(resolveAsset => { // TODO: should we insert arraybuffer or uint8array? const assetDataArray = projectFiles[assetId]; - const request = assetStore.put(assetDataArray, assetId); + const request = assetStore.add(assetDataArray, assetId); request.onsuccess = () => { resolveAsset(); }; @@ -198,10 +258,17 @@ const createRestorePoint = (vm, title) => openDB().then(db => { }; const writeMetadata = () => { + let size = 0; + for (const data of Object.values(projectFiles)) { + size += data.byteLength; + } + /** @type {Metadata} */ const metadata = { title, created: Math.round(Date.now() / 1000), + type, + size, assets: projectAssetIDs }; @@ -230,7 +297,7 @@ const deleteRestorePoint = id => openDB().then(db => new Promise((resolve, rejec const metadataStore = transaction.objectStore(METADATA_STORE); const request = metadataStore.delete(id); request.onsuccess = () => { - removeExtraneousFiles(transaction) + removeExtraneousData(transaction) .then(() => resolve()); }; })); @@ -322,7 +389,7 @@ const getAllRestorePoints = () => openDB().then(db => new Promise((resolve, reje const restorePoints = []; const metadataStore = transaction.objectStore(METADATA_STORE); - const request = metadataStore.openCursor(); + const request = metadataStore.openCursor(null, 'prev'); request.onsuccess = () => { const cursor = request.result; if (cursor) { @@ -386,8 +453,11 @@ const loadLegacyRestorePoint = () => new Promise((resolve, reject) => { }); export default { + TYPE_AUTOMATIC, + TYPE_MANUAL, getAllRestorePoints, createRestorePoint, + removeExtraneousRestorePoints, deleteRestorePoint, deleteAllRestorePoints, loadRestorePoint, From 6e25edb1267d33d1afa11cc52585b72bfa92288a Mon Sep 17 00:00:00 2001 From: Muffin Date: Tue, 1 Aug 2023 20:18:00 -0500 Subject: [PATCH 08/21] Various interface tuning --- .../restore-point-modal.jsx | 8 +++-- .../tw-restore-point-modal/restore-point.jsx | 12 +++---- src/containers/tw-restore-point-manager.jsx | 31 +++++++------------ 3 files changed, 21 insertions(+), 30 deletions(-) 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 fec5dc5e4f8..8e30e833196 100644 --- a/src/components/tw-restore-point-modal/restore-point-modal.jsx +++ b/src/components/tw-restore-point-modal/restore-point-modal.jsx @@ -40,7 +40,7 @@ const RestorePointModal = props => (

( -

-

( />

-
- - -
- {props.error ? (

@@ -100,23 +59,56 @@ const RestorePointModal = props => ( />

) : props.restorePoints.length === 0 ? ( -
- +
+
+ +
+ +
+ {/* This is going away within a few days */} + {/* No reason to bother translating */} + + {/* eslint-disable-next-line max-len */} + {'We just rewrote restore points from the ground up. If you were expecting to find a project here, try loading the old restore point:'} + + +
) : ( -
- {props.restorePoints.map(restorePoint => ( - - ))} +
+
+ {props.restorePoints.map(restorePoint => ( + + ))} +
+ +
+ +
)}
diff --git a/src/components/tw-restore-point-modal/restore-point.jsx b/src/components/tw-restore-point-modal/restore-point.jsx index a03cd0e683b..03fd0b25f96 100644 --- a/src/components/tw-restore-point-modal/restore-point.jsx +++ b/src/components/tw-restore-point-modal/restore-point.jsx @@ -12,6 +12,30 @@ class RestorePoint extends React.Component { 'handleClickDelete', 'handleClickLoad' ]); + this.state = { + thumbnail: null + }; + this.unmounted = false; + } + + componentDidMount () { + RestorePointAPI.getThumbnail(this.props.id) + .then(url => { + if (this.unmounted) { + URL.revokeObjectURL(url); + } else { + this.setState({ + thumbnail: url + }); + } + }); + } + + componentWillUnmount () { + if (this.state.thumbnail) { + URL.revokeObjectURL(this.state.thumbnail); + } + this.unmounted = true; } handleClickDelete (e) { @@ -25,10 +49,10 @@ class RestorePoint extends React.Component { formatSize () { const size = this.props.size; - if (size < 1024 * 1024) { - return `${(size / 1024).toFixed(2)}KB`; + if (size < 1000 * 1000) { + return `${(size / 1000).toFixed(2)}KB`; } - return `${(size / 1024 / 1024).toFixed(2)}MB`; + return `${(size / 1000 / 1000).toFixed(2)}MB`; } render () { @@ -40,33 +64,48 @@ class RestorePoint extends React.Component { className={styles.restorePoint} onClick={this.handleClickLoad} > + +
-
- - {this.props.title} - - {this.props.type === RestorePointAPI.TYPE_AUTOMATIC && ( - - {' '} - - - )} +
+ {this.props.title}
+ +
+ + {', '} + +
+
- - - {', '} - - + {this.formatSize()} {', '} - - {this.formatSize()} - +
+ + {this.props.type === RestorePointAPI.TYPE_AUTOMATIC && ( +
+ +
+ )}
- -
- {/* This is going away within a few days */} - {/* No reason to bother translating */} - - {/* eslint-disable-next-line max-len */} - {'We just rewrote restore points from the ground up. If you were expecting to find a project here, try loading the old restore point:'} - - -
) : (
@@ -111,6 +96,21 @@ const RestorePointModal = props => (
)} + +
+ {/* This is going away within a few days */} + {/* No reason to bother translating */} + + {/* eslint-disable-next-line max-len */} + {'We just rewrote restore points from the ground up. If you can\'t find your project in the list, try loading the old restore point:'} + + +
); From 68c4e512401180d70a9af22895e9695825430385 Mon Sep 17 00:00:00 2001 From: Muffin Date: Wed, 2 Aug 2023 17:44:15 -0500 Subject: [PATCH 12/21] Implement stuff for disable restore point addon addon change to be pulled later --- src/addons/hooks.js | 3 ++- .../tw-restore-point-modal/restore-point-modal.css | 10 +++++++++- .../tw-restore-point-modal/restore-point-modal.jsx | 13 +++++++++++++ src/containers/tw-restore-point-manager.jsx | 10 ++++++++++ 4 files changed, 34 insertions(+), 2 deletions(-) diff --git a/src/addons/hooks.js b/src/addons/hooks.js index 77db3cb035f..68c8f14c370 100644 --- a/src/addons/hooks.js +++ b/src/addons/hooks.js @@ -2,7 +2,8 @@ const AddonHooks = { appStateReducer: () => {}, appStateStore: null, blockly: null, - blocklyCallbacks: [] + blocklyCallbacks: [], + disableRestorePoints: false }; export default AddonHooks; 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 0f46071b081..55ab134e47f 100644 --- a/src/components/tw-restore-point-modal/restore-point-modal.css +++ b/src/components/tw-restore-point-modal/restore-point-modal.css @@ -17,6 +17,7 @@ } .button-container, +.disabled, .loading, .error, .empty, @@ -38,7 +39,6 @@ font-weight: 600; font-size: 0.85rem; color: $ui-white; - background: $motion-primary; } .button:disabled { opacity: 0.8; @@ -104,6 +104,14 @@ background-color: $ui-black-transparent; } +.disabled { + padding: 0.5rem; + border-radius: 0.5rem; + background-color: rgba(255, 0, 0, 0.18); + border: 2px solid rgba(255, 0, 0, 0.568); + font-weight: bold; +} + .legacy-transition { display: flex; justify-content: space-between; 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 ac57a3bcf9f..0826a307398 100644 --- a/src/components/tw-restore-point-modal/restore-point-modal.jsx +++ b/src/components/tw-restore-point-modal/restore-point-modal.jsx @@ -34,6 +34,18 @@ const RestorePointModal = props => ( />

+ {props.disabled && ( +

+ +

+ )} + {props.error ? (

@@ -123,6 +135,7 @@ RestorePointModal.propTypes = { onClickDeleteAll: PropTypes.func.isRequired, onClickLoad: PropTypes.func.isRequired, onClickLoadLegacy: PropTypes.func.isRequired, + disabled: PropTypes.bool.isRequired, isLoading: PropTypes.bool.isRequired, restorePoints: PropTypes.arrayOf(PropTypes.shape({})), error: PropTypes.string diff --git a/src/containers/tw-restore-point-manager.jsx b/src/containers/tw-restore-point-manager.jsx index 7c9c74573aa..aa6a4234f8c 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 AddonHooks from '../addons/hooks'; /* eslint-disable no-alert */ @@ -96,6 +97,10 @@ class TWRestorePointManager extends React.Component { return this.props.projectChanged && this.props.isShowingProject; } + isDisabled () { + return AddonHooks.disableRestorePoints; + } + handleClickCreate () { this.createRestorePoint(RestorePointAPI.TYPE_MANUAL) .catch(error => { @@ -208,6 +213,10 @@ class TWRestorePointManager extends React.Component { } createRestorePoint (type) { + if (this.isDisabled()) { + return Promise.reject(new Error('Disabled')); + } + if (this.props.isModalVisible) { this.setState({ loading: true @@ -269,6 +278,7 @@ class TWRestorePointManager extends React.Component { onClickDeleteAll={this.handleClickDeleteAll} onClickLoad={this.handleClickLoad} onClickLoadLegacy={this.handleClickLoadLegacy} + disabled={this.isDisabled()} isLoading={this.state.loading} restorePoints={this.state.restorePoints} error={this.state.error} From f732e8f191a64f786a4f8b2612b74dd4dd678ac9 Mon Sep 17 00:00:00 2001 From: Muffin Date: Wed, 2 Aug 2023 18:05:44 -0500 Subject: [PATCH 13/21] Add more size metadata --- .../restore-point-modal.css | 10 ++- .../restore-point-modal.jsx | 15 ++++- .../tw-restore-point-modal/restore-point.jsx | 29 ++++---- src/containers/tw-restore-point-manager.jsx | 7 +- src/lib/tw-bytes-utils.js | 6 ++ src/lib/tw-restore-point-api.js | 66 +++++++++++++------ 6 files changed, 95 insertions(+), 38 deletions(-) create mode 100644 src/lib/tw-bytes-utils.js 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 55ab134e47f..56a10ac3728 100644 --- a/src/components/tw-restore-point-modal/restore-point-modal.css +++ b/src/components/tw-restore-point-modal/restore-point-modal.css @@ -16,7 +16,7 @@ background: $ui-primary; } -.button-container, +.extra-container, .disabled, .loading, .error, @@ -26,9 +26,13 @@ margin: 1rem 0 0 0; } -.button-container { +.extra-container { display: flex; - justify-content: flex-end; + justify-content: space-between; + align-items: center; +} +.total-size { + } .button { font: inherit; 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 0826a307398..c2fac73bdf6 100644 --- a/src/components/tw-restore-point-modal/restore-point-modal.jsx +++ b/src/components/tw-restore-point-modal/restore-point-modal.jsx @@ -6,6 +6,7 @@ import RestorePoint from './restore-point.jsx'; import styles from './restore-point-modal.css'; import classNames from 'classnames'; import {APP_NAME} from '../../lib/brand'; +import {formatBytes} from '../../lib/tw-bytes-utils'; const messages = defineMessages({ title: { @@ -93,7 +94,18 @@ const RestorePointModal = props => ( ))}

-
+
+
+ +
+
+ {relativeTimeSupported() && ( + + + {' ('} + + )} {', '} + {relativeTimeSupported() && ')'}
@@ -100,16 +110,6 @@ class RestorePoint extends React.Component { }} />
- - {this.props.type === RestorePointAPI.TYPE_AUTOMATIC && ( -
- -
- )}
)} -
- {/* This is going away within a few days */} - {/* No reason to bother translating */} - - {/* eslint-disable-next-line max-len */} - {'We just rewrote restore points from the ground up. If you can\'t find your project in the list, try loading the old restore point:'} - - -
+ {!props.error && !props.isLoading && ( +
+ {/* This is going away within a few days */} + {/* No reason to bother translating */} + + {/* eslint-disable-next-line max-len */} + {'We just rewrote restore points from the ground up. If you can\'t find your project in the list, try loading the old restore point:'} + + +
+ )}
); From a387926f330bfc04eba6f0317efa2bf73787a374 Mon Sep 17 00:00:00 2001 From: Muffin Date: Wed, 2 Aug 2023 18:55:51 -0500 Subject: [PATCH 17/21] draw after loading --- src/containers/tw-restore-point-manager.jsx | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/containers/tw-restore-point-manager.jsx b/src/containers/tw-restore-point-manager.jsx index e0834058284..49c39a23d03 100644 --- a/src/containers/tw-restore-point-manager.jsx +++ b/src/containers/tw-restore-point-manager.jsx @@ -150,6 +150,9 @@ class TWRestorePointManager extends React.Component { } _finishLoading (success) { + setTimeout(() => { + this.props.vm.renderer.draw(); + }); this.props.onFinishLoadingRestorePoint(success, this.props.loadingState); } @@ -305,7 +308,10 @@ TWRestorePointManager.propTypes = { isShowingProject: PropTypes.bool.isRequired, isModalVisible: PropTypes.bool.isRequired, vm: PropTypes.shape({ - loadProject: PropTypes.func.isRequired + loadProject: PropTypes.func.isRequired, + renderer: PropTypes.shape({ + draw: PropTypes.func.isRequired + }).isRequired }).isRequired }; From d225ffc750c7ee5a22eec1520d5f08be7698f179 Mon Sep 17 00:00:00 2001 From: Muffin Date: Wed, 2 Aug 2023 19:09:41 -0500 Subject: [PATCH 18/21] Smarter VM stuff --- src/containers/tw-restore-point-manager.jsx | 3 +- src/lib/tw-restore-point-api.js | 38 +++++++++++++++------ 2 files changed, 29 insertions(+), 12 deletions(-) diff --git a/src/containers/tw-restore-point-manager.jsx b/src/containers/tw-restore-point-manager.jsx index 49c39a23d03..f7065390996 100644 --- a/src/containers/tw-restore-point-manager.jsx +++ b/src/containers/tw-restore-point-manager.jsx @@ -172,8 +172,7 @@ class TWRestorePointManager extends React.Component { return; } this._startLoading(); - RestorePointAPI.loadRestorePoint(id) - .then(buffer => this.props.vm.loadProject(buffer)) + RestorePointAPI.loadRestorePoint(this.props.vm, id) .then(() => { this._finishLoading(true); }) diff --git a/src/lib/tw-restore-point-api.js b/src/lib/tw-restore-point-api.js index c2ab9695b8a..65ddf1037d0 100644 --- a/src/lib/tw-restore-point-api.js +++ b/src/lib/tw-restore-point-api.js @@ -194,7 +194,14 @@ const removeExtraneousRestorePoints = () => openDB().then(db => new Promise((res * @returns {Promise<{type: string; data: ArrayBuffer;}>} Thumbnail data */ const generateThumbnail = vm => new Promise(resolve => { + // Piggyback off of the next draw if we can, otherwise just force it to render + const drawTimeout = setTimeout(() => { + vm.renderer.draw(); + }, 100); + vm.renderer.requestSnapshot(dataURL => { + clearTimeout(drawTimeout); + const index = dataURL.indexOf(','); const base64 = dataURL.substring(index + 1); const arrayBuffer = base64ToArrayBuffer(base64); @@ -204,9 +211,6 @@ const generateThumbnail = vm => new Promise(resolve => { data: arrayBuffer }); }); - - // Force the snapshot to be processed immediately, even if the project is not running yet. - vm.renderer.draw(); }); /** @@ -356,10 +360,11 @@ const deleteAllRestorePoints = () => openDB().then(db => new Promise((resolveTra })); /** + * @param {VirtualMachine} vm scratch-vm instance * @param {number} id the restore point's ID * @returns {Promise} Resolves with sb3 file */ -const loadRestorePoint = id => openDB().then(db => new Promise((resolveTransaction, rejectTransaction) => { +const loadRestorePoint = (vm, id) => openDB().then(db => new Promise((resolveTransaction, rejectTransaction) => { const transaction = db.transaction([METADATA_STORE, PROJECT_STORE, ASSET_STORE], 'readonly'); transaction.onerror = () => { rejectTransaction(new Error(`Transaction error: ${transaction.error}`)); @@ -369,11 +374,22 @@ const loadRestorePoint = id => openDB().then(db => new Promise((resolveTransacti /** @type {Metadata} */ let metadata; - const generate = () => { - resolveTransaction(zip.generateAsync({ - // Don't bother compressing it since it will be immediately decompressed - type: 'arraybuffer' - })); + // TODO: we should be able to use a custom scratch-storage helper to avoid putting the + // zip in memory. + + const loadVM = () => { + resolveTransaction( + zip.generateAsync({ + // Don't bother compressing it since it will be immediately decompressed + type: 'arraybuffer' + }) + .then(sb3 => vm.loadProject(sb3)) + .then(() => { + setTimeout(() => { + vm.renderer.draw(); + }); + }) + ); }; const loadAssets = async () => { @@ -389,7 +405,7 @@ const loadRestorePoint = id => openDB().then(db => new Promise((resolveTransacti }); } - generate(); + loadVM(); }; const loadProjectJSON = () => { @@ -410,6 +426,8 @@ const loadRestorePoint = id => openDB().then(db => new Promise((resolveTransacti }; }; + vm.stop(); + loadMetadata(); })); From 86b4d9c5d4b5532229e5ce1ea28739b04375703d Mon Sep 17 00:00:00 2001 From: Muffin Date: Wed, 2 Aug 2023 19:46:13 -0500 Subject: [PATCH 19/21] Various error handling improvements --- .../restore-point-modal.jsx | 7 ++-- src/containers/tw-restore-point-manager.jsx | 42 ++++++++++++++----- src/lib/alerts/index.jsx | 21 +++++++++- 3 files changed, 55 insertions(+), 15 deletions(-) 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 4ed4a75fd18..b4cf7f9d2b3 100644 --- a/src/components/tw-restore-point-modal/restore-point-modal.jsx +++ b/src/components/tw-restore-point-modal/restore-point-modal.jsx @@ -51,8 +51,9 @@ const RestorePointModal = props => (

(

)} - {!props.error && !props.isLoading && ( + {!props.isLoading && (
{/* This is going away within a few days */} {/* No reason to bother translating */} diff --git a/src/containers/tw-restore-point-manager.jsx b/src/containers/tw-restore-point-manager.jsx index f7065390996..afe447b2c59 100644 --- a/src/containers/tw-restore-point-manager.jsx +++ b/src/containers/tw-restore-point-manager.jsx @@ -35,6 +35,11 @@ const messages = defineMessages({ defaultMessage: 'Are you sure you want to delete ALL restore points? This cannot be undone.', description: 'Confirmation that appears when deleting ALL restore points.', id: 'tw.restorePoints.confirmDeleteAll' + }, + loadError: { + defaultMessage: 'Error loading restore point: {error}', + description: 'Error message when a restore point could not be loaded', + id: 'tw.restorePoints.error' } }); @@ -177,8 +182,7 @@ class TWRestorePointManager extends React.Component { this._finishLoading(true); }) .catch(error => { - this.handleModalError(error); - this._finishLoading(false); + this.handleLoadError(error); }); } @@ -187,18 +191,28 @@ class TWRestorePointManager extends React.Component { return; } this._startLoading(); + this.props.vm.stop(); RestorePointAPI.loadLegacyRestorePoint() .then(buffer => this.props.vm.loadProject(buffer)) .then(() => { + setTimeout(() => { + this.props.vm.renderer.draw(); + }); this._finishLoading(true); }) .catch(error => { - // Don't handleError on this because we're expecting error 90% of the time - alert(error); - this._finishLoading(false); + this.handleLoadError(error); }); } + handleLoadError (error) { + log.error(error); + alert(this.props.intl.formatMessage(messages.loadError, { + error + })); + this._finishLoading(false); + } + queueRestorePoint () { if (this.timeout) { return; @@ -206,9 +220,7 @@ class TWRestorePointManager extends React.Component { this.timeout = setTimeout(() => { this.createRestorePoint(RestorePointAPI.TYPE_AUTOMATIC).then(() => { this.timeout = null; - if (this.shouldBeAutosaving()) { - // Project is still not saved this.queueRestorePoint(); } }); @@ -238,11 +250,17 @@ class TWRestorePointManager extends React.Component { sleep(MINIMUM_SAVE_TIME) ]) .then(() => { + this.props.onFinishCreatingRestorePoint(); + if (this.props.isModalVisible) { + this.refreshState(); + } + }) + .catch(error => { + log.error(error); + this.props.onErrorCreatingRestorePoint(); if (this.props.isModalVisible) { this.refreshState(); } - - this.props.onFinishCreatingRestorePoint(); }); } @@ -268,7 +286,8 @@ class TWRestorePointManager extends React.Component { handleModalError (error) { log.error('Restore point error', error); this.setState({ - error: `${error}` + error: `${error}`, + loading: false }); } @@ -300,6 +319,7 @@ TWRestorePointManager.propTypes = { projectTitle: PropTypes.string.isRequired, onStartCreatingRestorePoint: PropTypes.func.isRequired, onFinishCreatingRestorePoint: PropTypes.func.isRequired, + onErrorCreatingRestorePoint: PropTypes.func.isRequired, onStartLoadingRestorePoint: PropTypes.func.isRequired, onFinishLoadingRestorePoint: PropTypes.func.isRequired, onCloseModal: PropTypes.func.isRequired, @@ -308,6 +328,7 @@ TWRestorePointManager.propTypes = { isModalVisible: PropTypes.bool.isRequired, vm: PropTypes.shape({ loadProject: PropTypes.func.isRequired, + stop: PropTypes.func.isRequired, renderer: PropTypes.shape({ draw: PropTypes.func.isRequired }).isRequired @@ -326,6 +347,7 @@ const mapStateToProps = state => ({ const mapDispatchToProps = dispatch => ({ onStartCreatingRestorePoint: () => dispatch(showStandardAlert('twCreatingRestorePoint')), onFinishCreatingRestorePoint: () => showAlertWithTimeout(dispatch, 'twRestorePointSuccess'), + onErrorCreatingRestorePoint: () => showAlertWithTimeout(dispatch, 'twRestorePointError'), onStartLoadingRestorePoint: loadingState => { dispatch(openLoadingProject()); dispatch(requestProjectUpload(loadingState)); diff --git a/src/lib/alerts/index.jsx b/src/lib/alerts/index.jsx index c09af8bd5bc..b1e2da42516 100644 --- a/src/lib/alerts/index.jsx +++ b/src/lib/alerts/index.jsx @@ -187,10 +187,11 @@ const alerts = [ { alertId: 'twCreatingRestorePoint', alertType: AlertTypes.INLINE, + clearList: ['twRestorePointSuccess', 'twRestorePointError'], content: ( ), @@ -205,7 +206,7 @@ const alerts = [ ), @@ -213,6 +214,22 @@ const alerts = [ level: AlertLevels.SUCCESS, maxDisplaySecs: 3 }, + { + alertId: 'twRestorePointError', + alertType: AlertTypes.INLINE, + clearList: ['twCreatingRestorePoint'], + content: ( + + ), + iconURL: successImage, + level: AlertLevels.WARN, + maxDisplaySecs: 5 + }, { alertId: 'cloudInfo', alertType: AlertTypes.STANDARD, From 529fb2025c92d1db7b7e67c70de2e5fa7f4d4a32 Mon Sep 17 00:00:00 2001 From: Muffin Date: Wed, 2 Aug 2023 19:50:33 -0500 Subject: [PATCH 20/21] prop type error --- src/containers/tw-restore-point-manager.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/containers/tw-restore-point-manager.jsx b/src/containers/tw-restore-point-manager.jsx index afe447b2c59..e0e23a566cf 100644 --- a/src/containers/tw-restore-point-manager.jsx +++ b/src/containers/tw-restore-point-manager.jsx @@ -331,7 +331,7 @@ TWRestorePointManager.propTypes = { stop: PropTypes.func.isRequired, renderer: PropTypes.shape({ draw: PropTypes.func.isRequired - }).isRequired + }) }).isRequired }; From c06958cf955ecacf05e503e06d92cff3ff73b87e Mon Sep 17 00:00:00 2001 From: Muffin Date: Wed, 2 Aug 2023 19:50:40 -0500 Subject: [PATCH 21/21] Increase interval to 5 minutes --- src/containers/tw-restore-point-manager.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/containers/tw-restore-point-manager.jsx b/src/containers/tw-restore-point-manager.jsx index e0e23a566cf..51b9b40ba4b 100644 --- a/src/containers/tw-restore-point-manager.jsx +++ b/src/containers/tw-restore-point-manager.jsx @@ -14,7 +14,7 @@ import AddonHooks from '../addons/hooks'; /* eslint-disable no-alert */ -const AUTOMATIC_INTERVAL = 1000 * 5; // TODO: increase this when testing is done +const AUTOMATIC_INTERVAL = 1000 * 60 * 5; const SAVE_DELAY = 250; const MINIMUM_SAVE_TIME = 750;