From 73cd5b30190f9710e06b62ccf0ccfaa36e9e9843 Mon Sep 17 00:00:00 2001
From: Karishma Chadha <kchadha@scratch.mit.edu>
Date: Mon, 23 Apr 2018 15:16:28 -0400
Subject: [PATCH] File uploading abstractions

---
 src/containers/costume-tab.jsx    |  72 ++-----------
 src/containers/sound-tab.jsx      |  38 ++-----
 src/containers/stage-selector.jsx |  72 ++-----------
 src/lib/fileUploader.js           | 174 ++++++++++++++++++++++++++++++
 4 files changed, 196 insertions(+), 160 deletions(-)
 create mode 100644 src/lib/fileUploader.js

diff --git a/src/containers/costume-tab.jsx b/src/containers/costume-tab.jsx
index e33bf5c77..da325362c 100644
--- a/src/containers/costume-tab.jsx
+++ b/src/containers/costume-tab.jsx
@@ -2,15 +2,15 @@ import PropTypes from 'prop-types';
 import React from 'react';
 import bindAll from 'lodash.bindall';
 import {defineMessages, intlShape, injectIntl} from 'react-intl';
-import {importBitmap} from 'scratch-svg-renderer';
 import VM from 'scratch-vm';
 
+
 import AssetPanel from '../components/asset-panel/asset-panel.jsx';
 import PaintEditorWrapper from './paint-editor-wrapper.jsx';
 import CostumeLibrary from './costume-library.jsx';
 import BackdropLibrary from './backdrop-library.jsx';
 import {connect} from 'react-redux';
-import log from '../lib/log.js';
+import {handleFileUpload, costumeUpload} from '../lib/fileUploader.js';
 
 import {
     closeCostumeLibrary,
@@ -169,68 +169,12 @@ class CostumeTab extends React.Component {
         this.props.vm.addCostume(item.md5, vmCostume);
     }
     handleCostumeUpload (e) {
-        const thisFileInput = e.target;
-        let thisFile = null;
-        const reader = new FileReader();
-        reader.onload = () => {
-            // Reset the file input value now that we have everything we need
-            // so that the user can upload the same image multiple times
-            // if they choose
-            thisFileInput.value = null;
-
-
-            const storage = this.props.vm.runtime.storage;
-            const fileType = thisFile.type; // check what the browser thinks this is
-            // Only handling png and svg right now
-            let costumeFormat = null;
-            let assetType = null;
-            if (fileType === 'image/svg+xml') {
-                costumeFormat = storage.DataFormat.SVG;
-                assetType = storage.AssetType.ImageVector;
-            } else if (fileType === 'image/jpeg') {
-                costumeFormat = storage.DataFormat.JPG;
-                assetType = storage.AssetType.ImageBitmap;
-            } else if (fileType === 'image/png') {
-                costumeFormat = storage.DataFormat.PNG;
-                assetType = storage.AssetType.ImageBitmap;
-            }
-            if (!costumeFormat) return;
-
-            const addCostumeFromBuffer = (function (error, costumeBuffer) {
-                if (error) {
-                    log.warn(`An error occurred while trying to extract image data: ${error}`);
-                    return;
-                }
-
-                const md5 = storage.builtinHelper.cache(
-                    assetType, costumeFormat, costumeBuffer);
-
-                const md5Ext = `${md5}.${costumeFormat}`;
-
-                const vmCostume = {
-                    name: 'costume1',
-                    dataFormat: costumeFormat,
-                    md5: `${md5Ext}`
-                };
-
-                this.props.vm.addCostume(md5Ext, vmCostume);
-            }).bind(this);
-
-            if (costumeFormat === storage.DataFormat.SVG) {
-                // Must pass in file data as a Uint8Array,
-                // passing in an array buffer causes the sprite/costume
-                // thumbnails to not display because the data URI for the costume
-                // is invalid
-                addCostumeFromBuffer(null, new Uint8Array(reader.result));
-            } else {
-                // otherwise it's a bitmap
-                importBitmap(reader.result, addCostumeFromBuffer);
-            }
-        };
-        if (thisFileInput.files) {
-            thisFile = thisFileInput.files[0];
-            reader.readAsArrayBuffer(thisFile);
-        }
+        const storage = this.props.vm.runtime.storage;
+        const handleNewCostume = function (md5Ext, vmCostume) {
+            this.props.vm.addCostume(md5Ext, vmCostume);
+        }.bind(this);
+        const costumeOnload = costumeUpload.bind(this, storage, handleNewCostume);
+        handleFileUpload(e.target, costumeOnload);
     }
     handleFileUploadClick () {
         this.fileInput.click();
diff --git a/src/containers/sound-tab.jsx b/src/containers/sound-tab.jsx
index 94e72125d..a74101924 100644
--- a/src/containers/sound-tab.jsx
+++ b/src/containers/sound-tab.jsx
@@ -16,6 +16,7 @@ import SoundEditor from './sound-editor.jsx';
 import SoundLibrary from './sound-library.jsx';
 
 import soundLibraryContent from '../lib/libraries/sounds.json';
+import {handleFileUpload, soundUpload} from '../lib/fileUploader.js';
 
 import {connect} from 'react-redux';
 
@@ -106,41 +107,14 @@ class SoundTab extends React.Component {
     }
 
     handleSoundUpload (e) {
-        const thisFileInput = e.target;
-        let thisFile = null;
-        const reader = new FileReader();
-        reader.onload = () => {
-            // Reset the file input value now that we have everything we need
-            // so that the user can upload the same sound multiple times if
-            // they choose
-            thisFileInput.value = null;
-            // Cache the sound in storage
-            const soundBuffer = reader.result;
-            const storage = this.props.vm.runtime.storage;
-
-            const fileType = thisFile.type; // what file type does the browser think this is
-            const soundFormat = fileType === 'audio/mp3' ? storage.DataFormat.MP3 : storage.DataFormat.WAV;
-            const md5 = storage.builtinHelper.cache(
-                storage.AssetType.Sound,
-                soundFormat,
-                new Uint8Array(soundBuffer),
-            );
-            // Add the sound to vm
-            const newSound = {
-                format: '',
-                name: 'sound1',
-                dataFormat: soundFormat,
-                md5: `${md5}.${soundFormat}`
-            };
-
+        const storage = this.props.vm.runtime.storage;
+        const handleSound = function (newSound) {
             this.props.vm.addSound(newSound).then(() => {
                 this.handleNewSound();
             });
-        };
-        if (thisFileInput.files) {
-            thisFile = thisFileInput.files[0];
-            reader.readAsArrayBuffer(thisFile);
-        }
+        }.bind(this);
+        const soundOnload = soundUpload.bind(this, storage, handleSound);
+        handleFileUpload(e.target, soundOnload);
     }
 
     setFileInput (input) {
diff --git a/src/containers/stage-selector.jsx b/src/containers/stage-selector.jsx
index bac150fe6..40d632b17 100644
--- a/src/containers/stage-selector.jsx
+++ b/src/containers/stage-selector.jsx
@@ -10,8 +10,7 @@ import StageSelectorComponent from '../components/stage-selector/stage-selector.
 
 import backdropLibraryContent from '../lib/libraries/backdrops.json';
 import costumeLibraryContent from '../lib/libraries/costumes.json';
-import {importBitmap} from 'scratch-svg-renderer';
-import log from '../lib/log.js';
+import {handleFileUpload, costumeUpload} from '../lib/fileUploader';
 
 class StageSelector extends React.Component {
     constructor (props) {
@@ -56,68 +55,13 @@ class StageSelector extends React.Component {
         }
     }
     handleBackdropUpload (e) {
-        const thisFileInput = e.target;
-        let thisFile = null;
-        const reader = new FileReader();
-        reader.onload = () => {
-            // Reset the file input value now that we have everything we need
-            // so that the user can upload the same image multiple times
-            // if they choose
-            thisFileInput.value = null;
-            // Cache the image in storage
-            const storage = this.props.vm.runtime.storage;
-            const fileType = thisFile.type; // check what the browser thinks this is
-            // Only handling png and svg right now
-            let backdropFormat = null;
-            let assetType = null;
-            if (fileType === 'image/svg+xml') {
-                backdropFormat = storage.DataFormat.SVG;
-                assetType = storage.AssetType.ImageVector;
-            } else if (fileType === 'image/jpeg') {
-                backdropFormat = storage.DataFormat.JPG;
-                assetType = storage.AssetType.ImageBitmap;
-            } else if (fileType === 'image/png') {
-                backdropFormat = storage.DataFormat.PNG;
-                assetType = storage.AssetType.ImageBitmap;
-            }
-            if (!backdropFormat) return;
-
-            const addBackdropFromBuffer = (function (error, backdropBuffer) {
-                if (error) {
-                    log.warn(`An error occurred while trying to extract image data: ${error}`);
-                    return;
-                }
-
-                const md5 = storage.builtinHelper.cache(
-                    assetType, backdropFormat, backdropBuffer);
-
-                const md5Ext = `${md5}.${backdropFormat}`;
-
-                const vmBackdrop = {
-                    name: 'backdrop1',
-                    dataFormat: backdropFormat,
-                    md5: `${md5Ext}`
-                };
-
-                this.props.vm.addBackdrop(md5Ext, vmBackdrop);
-                this.props.onActivateTab(COSTUMES_TAB_INDEX);
-            }).bind(this);
-
-            if (backdropFormat === storage.DataFormat.SVG) {
-                // Must pass in file data as a Uint8Array,
-                // passing in an array buffer causes the sprite/costume
-                // thumbnails to not display because the data URI for the costume
-                // is invalid
-                addBackdropFromBuffer(null, new Uint8Array(reader.result));
-            } else {
-                // otherwise it's a bitmap
-                importBitmap(reader.result, addBackdropFromBuffer);
-            }
-        };
-        if (thisFileInput.files) {
-            thisFile = thisFileInput.files[0];
-            reader.readAsArrayBuffer(thisFile);
-        }
+        const storage = this.props.vm.runtime.storage;
+        const handleNewBackdrop = function (md5Ext, vmBackdrop) {
+            this.props.vm.addBackdrop(md5Ext, vmBackdrop);
+            this.props.onActivateTab(COSTUMES_TAB_INDEX);
+        }.bind(this);
+        const costumeOnload = costumeUpload.bind(this, storage, handleNewBackdrop);
+        handleFileUpload(e.target, costumeOnload);
     }
     handleFileUploadClick () {
         this.fileInput.click();
diff --git a/src/lib/fileUploader.js b/src/lib/fileUploader.js
new file mode 100644
index 000000000..cfdff91f3
--- /dev/null
+++ b/src/lib/fileUploader.js
@@ -0,0 +1,174 @@
+import {importBitmap} from 'scratch-svg-renderer';
+import log from './log.js';
+
+/**
+ * Extract the file name given a string of the form fileName + ext
+ * @param {string} nameExt File name + extension (e.g. 'my_image.png')
+ * @return {string} The name without the extension, or the full name if
+ * there was no '.' in the string (e.g. 'my_image')
+ */
+const extractFileName = function (nameExt) {
+    // There could be multiple dots, but get the stuff before the first .
+    const nameParts = nameExt.split('.', 1); // we only care about the first .
+    return nameParts[0];
+};
+
+/**
+ * Handle a file upload given the input element that contains the file,
+ * and a function to handle loading the file.
+ * @param {Input} fileInput The <input/> element that contains the file being loaded
+ * @param {Function} onload The function that handles loading the file
+ */
+const handleFileUpload = function (fileInput, onload) {
+    let thisFile = null;
+    const reader = new FileReader();
+    reader.onload = () => {
+        // Reset the file input value now that we have everything we need
+        // so that the user can upload the same sound multiple times if
+        // they choose
+        fileInput.value = null;
+        const fileType = thisFile.type;
+        const fileName = extractFileName(thisFile.name);
+
+        onload(reader.result, fileType, fileName);
+    };
+    if (fileInput.files) {
+        thisFile = fileInput.files[0];
+        reader.readAsArrayBuffer(thisFile);
+    }
+};
+
+/**
+ * @typedef VMAsset
+ * @property {string} name The user-readable name of this asset - This will
+ * automatically get translated to a fresh name if this one already exists in the
+ * scope of this vm asset (e.g. if a sound already exists with the same name for
+ * the same target)
+ * @property {string} dataFormat The data format of this asset, typically
+ * the extension to be used for that particular asset, e.g. 'svg' for vector images
+ * @property {string} md5 The md5 hash of the asset data, followed by '.'' and dataFormat
+ */
+
+/**
+ * Cache an asset (costume, sound) in storage and return an object representation
+ * of the asset to track in the VM.
+ * @param {ScratchStorage} storage The storage to cache the asset in
+ * @param {string} fileName The name of the asset
+ * @param {AssetType} assetType A ScratchStorage AssetType indicating what kind of
+ * asset this is.
+ * @param {string} dataFormat The format of this data (typically the file extension)
+ * @param {UInt8Array} data The asset data buffer
+ * @return {VMAsset} An object representing this asset and relevant information
+ * which can be used to look up the data in storage
+ */
+const cacheAsset = function (storage, fileName, assetType, dataFormat, data) {
+    const md5 = storage.builtinHelper.cache(
+        assetType,
+        dataFormat,
+        data
+    );
+
+    return {
+        name: fileName,
+        dataFormat: dataFormat,
+        md5: `${md5}.${dataFormat}`
+    };
+};
+
+/**
+ * Handles loading a costume or a backdrop using the provided, context-relevant information.
+ * @param {ScratchStorage} storage The ScratchStorage instance to cache the costume data
+ * @param {Function} then The function to execute on the costume object returned after
+ * caching this costume in storage - This function should be responsible for
+ * adding the costume to the VM and handling other UI flow that should come after adding the costume
+ * @param {ArrayBuffer} fileData The costume data to load
+ * @param {string} fileType The MIME type of this file
+ * @param {string} costumeName The user-readable name to use for the costume.
+ */
+const costumeUpload = function (storage, then, fileData, fileType, costumeName) {
+    // Only handling png and svg right now
+    let costumeFormat = null;
+    let assetType = null;
+    switch (fileType) {
+    case 'image/svg+xml': {
+        costumeFormat = storage.DataFormat.SVG;
+        assetType = storage.AssetType.ImageVector;
+        break;
+    }
+    case 'image/jpeg': {
+        costumeFormat = storage.DataFormat.JPG;
+        assetType = storage.AssetType.ImageBitmap;
+        break;
+    }
+    case 'image/png': {
+        costumeFormat = storage.DataFormat.PNG;
+        assetType = storage.AssetType.ImageBitmap;
+        break;
+    }
+    default:
+        return;
+    }
+
+    const addCostumeFromBuffer = (function (error, costumeBuffer) {
+        if (error) {
+            log.warn(`An error occurred while trying to extract image data: ${error}`);
+            return;
+        }
+
+        const vmCostume = cacheAsset(storage, costumeName, assetType, costumeFormat, costumeBuffer);
+        then(vmCostume.md5, vmCostume);
+    });
+
+    if (costumeFormat === storage.DataFormat.SVG) {
+        // Must pass in file data as a Uint8Array,
+        // passing in an array buffer causes the sprite/costume
+        // thumbnails to not display because the data URI for the costume
+        // is invalid
+        addCostumeFromBuffer(null, new Uint8Array(fileData));
+    } else {
+        // otherwise it's a bitmap
+        importBitmap(fileData, addCostumeFromBuffer);
+    }
+};
+
+/**
+ * Handles loading a sound using the provided, context-relevant information.
+ * @param {ScratchStorage} storage The ScratchStorage instance to cache the sound data
+ * @param {Function} then The function to execute on the sound object of type VMAsset
+ * This function should be responsible for adding the sound to the VM
+ * as well as handling other UI flow that should come after adding the sound
+ * @param {ArrayBuffer} fileData The sound data to load
+ * @param {string} fileType The MIME type of this file; This function will exit
+ * early if the fileType is unexpected.
+ * @param {string} soundName The user-readable name to use for the sound.
+ */
+const soundUpload = function (storage, then, fileData, fileType, soundName) {
+    let soundFormat;
+    switch (fileType) {
+    case 'audio/mp3': {
+        soundFormat = storage.DataFormat.MP3;
+        break;
+    }
+    case 'audio/wav': { // TODO support audio/x-wav? Do we see this in the wild?
+        soundFormat = storage.DataFormat.WAV;
+        break;
+    }
+    default:
+        return;
+    }
+
+    const vmSound = cacheAsset(
+        storage,
+        soundName,
+        storage.AssetType.Sound,
+        soundFormat,
+        new Uint8Array(fileData));
+
+    then(vmSound);
+};
+
+export {
+    handleFileUpload,
+    costumeUpload,
+    soundUpload
+};
-- 
GitLab