uppy/packages/@uppy/core/src/index.js

1599 lines
46 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const Translator = require('@uppy/utils/lib/Translator')
const ee = require('namespace-emitter')
const cuid = require('cuid')
const throttle = require('lodash.throttle')
const prettierBytes = require('@transloadit/prettier-bytes')
const match = require('mime-match')
const DefaultStore = require('@uppy/store-default')
const getFileType = require('@uppy/utils/lib/getFileType')
const getFileNameAndExtension = require('@uppy/utils/lib/getFileNameAndExtension')
const generateFileID = require('@uppy/utils/lib/generateFileID')
const supportsUploadProgress = require('./supportsUploadProgress')
const { justErrorsLogger, debugLogger } = require('./loggers')
const Plugin = require('./Plugin') // Exported from here.
class RestrictionError extends Error {
constructor (...args) {
super(...args)
this.isRestriction = true
}
}
/**
* Uppy Core module.
* Manages plugins, state updates, acts as an event bus,
* adds/removes files and metadata.
*/
class Uppy {
static VERSION = require('../package.json').version
/**
* Instantiate Uppy
*
* @param {object} opts — Uppy options
*/
constructor (opts) {
this.defaultLocale = {
strings: {
addBulkFilesFailed: {
0: 'Failed to add %{smart_count} file due to an internal error',
1: 'Failed to add %{smart_count} files due to internal errors'
},
youCanOnlyUploadX: {
0: 'You can only upload %{smart_count} file',
1: 'You can only upload %{smart_count} files'
},
youHaveToAtLeastSelectX: {
0: 'You have to select at least %{smart_count} file',
1: 'You have to select at least %{smart_count} files'
},
// The default `exceedsSize2` string only combines the `exceedsSize` string (%{backwardsCompat}) with the size.
// Locales can override `exceedsSize2` to specify a different word order. This is for backwards compat with
// Uppy 1.9.x and below which did a naive concatenation of `exceedsSize2 + size` instead of using a locale-specific
// substitution.
// TODO: In 2.0 `exceedsSize2` should be removed in and `exceedsSize` updated to use substitution.
exceedsSize2: '%{backwardsCompat} %{size}',
exceedsSize: 'This file exceeds maximum allowed size of',
youCanOnlyUploadFileTypes: 'You can only upload: %{types}',
noNewAlreadyUploading: 'Cannot add new files: already uploading',
noDuplicates: 'Cannot add the duplicate file \'%{fileName}\', it already exists',
companionError: 'Connection with Companion failed',
companionUnauthorizeHint: 'To unauthorize to your %{provider} account, please go to %{url}',
failedToUpload: 'Failed to upload %{file}',
noInternetConnection: 'No Internet connection',
connectedToInternet: 'Connected to the Internet',
// Strings for remote providers
noFilesFound: 'You have no files or folders here',
selectX: {
0: 'Select %{smart_count}',
1: 'Select %{smart_count}'
},
selectAllFilesFromFolderNamed: 'Select all files from folder %{name}',
unselectAllFilesFromFolderNamed: 'Unselect all files from folder %{name}',
selectFileNamed: 'Select file %{name}',
unselectFileNamed: 'Unselect file %{name}',
openFolderNamed: 'Open folder %{name}',
cancel: 'Cancel',
logOut: 'Log out',
filter: 'Filter',
resetFilter: 'Reset filter',
loading: 'Loading...',
authenticateWithTitle: 'Please authenticate with %{pluginName} to select files',
authenticateWith: 'Connect to %{pluginName}',
emptyFolderAdded: 'No files were added from empty folder',
folderAdded: {
0: 'Added %{smart_count} file from %{folder}',
1: 'Added %{smart_count} files from %{folder}'
}
}
}
const defaultOptions = {
id: 'uppy',
autoProceed: false,
allowMultipleUploads: true,
debug: false,
restrictions: {
maxFileSize: null,
maxNumberOfFiles: null,
minNumberOfFiles: null,
allowedFileTypes: null
},
meta: {},
onBeforeFileAdded: (currentFile, files) => currentFile,
onBeforeUpload: (files) => files,
store: DefaultStore(),
logger: justErrorsLogger
}
// Merge default options with the ones set by user,
// making sure to merge restrictions too
this.opts = {
...defaultOptions,
...opts,
restrictions: {
...defaultOptions.restrictions,
...(opts && opts.restrictions)
}
}
// Support debug: true for backwards-compatability, unless logger is set in opts
// opts instead of this.opts to avoid comparing objects — we set logger: justErrorsLogger in defaultOptions
if (opts && opts.logger && opts.debug) {
this.log('You are using a custom `logger`, but also set `debug: true`, which uses built-in logger to output logs to console. Ignoring `debug: true` and using your custom `logger`.', 'warning')
} else if (opts && opts.debug) {
this.opts.logger = debugLogger
}
this.log(`Using Core v${this.constructor.VERSION}`)
if (this.opts.restrictions.allowedFileTypes &&
this.opts.restrictions.allowedFileTypes !== null &&
!Array.isArray(this.opts.restrictions.allowedFileTypes)) {
throw new TypeError('`restrictions.allowedFileTypes` must be an array')
}
this.i18nInit()
// Container for different types of plugins
this.plugins = {}
this.getState = this.getState.bind(this)
this.getPlugin = this.getPlugin.bind(this)
this.setFileMeta = this.setFileMeta.bind(this)
this.setFileState = this.setFileState.bind(this)
this.log = this.log.bind(this)
this.info = this.info.bind(this)
this.hideInfo = this.hideInfo.bind(this)
this.addFile = this.addFile.bind(this)
this.removeFile = this.removeFile.bind(this)
this.pauseResume = this.pauseResume.bind(this)
// ___Why throttle at 500ms?
// - We must throttle at >250ms for superfocus in Dashboard to work well (because animation takes 0.25s, and we want to wait for all animations to be over before refocusing).
// [Practical Check]: if thottle is at 100ms, then if you are uploading a file, and click 'ADD MORE FILES', - focus won't activate in Firefox.
// - We must throttle at around >500ms to avoid performance lags.
// [Practical Check] Firefox, try to upload a big file for a prolonged period of time. Laptop will start to heat up.
this._calculateProgress = throttle(this._calculateProgress.bind(this), 500, { leading: true, trailing: true })
this.updateOnlineStatus = this.updateOnlineStatus.bind(this)
this.resetProgress = this.resetProgress.bind(this)
this.pauseAll = this.pauseAll.bind(this)
this.resumeAll = this.resumeAll.bind(this)
this.retryAll = this.retryAll.bind(this)
this.cancelAll = this.cancelAll.bind(this)
this.retryUpload = this.retryUpload.bind(this)
this.upload = this.upload.bind(this)
this.emitter = ee()
this.on = this.on.bind(this)
this.off = this.off.bind(this)
this.once = this.emitter.once.bind(this.emitter)
this.emit = this.emitter.emit.bind(this.emitter)
this.preProcessors = []
this.uploaders = []
this.postProcessors = []
this.store = this.opts.store
this.setState({
plugins: {},
files: {},
currentUploads: {},
allowNewUpload: true,
capabilities: {
uploadProgress: supportsUploadProgress(),
individualCancellation: true,
resumableUploads: false
},
totalProgress: 0,
meta: { ...this.opts.meta },
info: {
isHidden: true,
type: 'info',
message: ''
}
})
this._storeUnsubscribe = this.store.subscribe((prevState, nextState, patch) => {
this.emit('state-update', prevState, nextState, patch)
this.updateAll(nextState)
})
// Exposing uppy object on window for debugging and testing
if (this.opts.debug && typeof window !== 'undefined') {
window[this.opts.id] = this
}
this._addListeners()
// Re-enable if well need some capabilities on boot, like isMobileDevice
// this._setCapabilities()
}
// _setCapabilities = () => {
// const capabilities = {
// isMobileDevice: isMobileDevice()
// }
// this.setState({
// ...this.getState().capabilities,
// capabilities
// })
// }
on (event, callback) {
this.emitter.on(event, callback)
return this
}
off (event, callback) {
this.emitter.off(event, callback)
return this
}
/**
* Iterate on all plugins and run `update` on them.
* Called each time state changes.
*
*/
updateAll (state) {
this.iteratePlugins(plugin => {
plugin.update(state)
})
}
/**
* Updates state with a patch
*
* @param {object} patch {foo: 'bar'}
*/
setState (patch) {
this.store.setState(patch)
}
/**
* Returns current state.
*
* @returns {object}
*/
getState () {
return this.store.getState()
}
/**
* Back compat for when uppy.state is used instead of uppy.getState().
*/
get state () {
return this.getState()
}
/**
* Shorthand to set state for a specific file.
*/
setFileState (fileID, state) {
if (!this.getState().files[fileID]) {
throw new Error(`Cant set state for ${fileID} (the file could have been removed)`)
}
this.setState({
files: Object.assign({}, this.getState().files, {
[fileID]: Object.assign({}, this.getState().files[fileID], state)
})
})
}
i18nInit () {
this.translator = new Translator([this.defaultLocale, this.opts.locale])
this.locale = this.translator.locale
this.i18n = this.translator.translate.bind(this.translator)
this.i18nArray = this.translator.translateArray.bind(this.translator)
}
setOptions (newOpts) {
this.opts = {
...this.opts,
...newOpts,
restrictions: {
...this.opts.restrictions,
...(newOpts && newOpts.restrictions)
}
}
if (newOpts.meta) {
this.setMeta(newOpts.meta)
}
this.i18nInit()
if (newOpts.locale) {
this.iteratePlugins((plugin) => {
plugin.setOptions()
})
}
this.setState() // so that UI re-renders with new options
}
resetProgress () {
const defaultProgress = {
percentage: 0,
bytesUploaded: 0,
uploadComplete: false,
uploadStarted: null
}
const files = Object.assign({}, this.getState().files)
const updatedFiles = {}
Object.keys(files).forEach(fileID => {
const updatedFile = Object.assign({}, files[fileID])
updatedFile.progress = Object.assign({}, updatedFile.progress, defaultProgress)
updatedFiles[fileID] = updatedFile
})
this.setState({
files: updatedFiles,
totalProgress: 0
})
this.emit('reset-progress')
}
addPreProcessor (fn) {
this.preProcessors.push(fn)
}
removePreProcessor (fn) {
const i = this.preProcessors.indexOf(fn)
if (i !== -1) {
this.preProcessors.splice(i, 1)
}
}
addPostProcessor (fn) {
this.postProcessors.push(fn)
}
removePostProcessor (fn) {
const i = this.postProcessors.indexOf(fn)
if (i !== -1) {
this.postProcessors.splice(i, 1)
}
}
addUploader (fn) {
this.uploaders.push(fn)
}
removeUploader (fn) {
const i = this.uploaders.indexOf(fn)
if (i !== -1) {
this.uploaders.splice(i, 1)
}
}
setMeta (data) {
const updatedMeta = Object.assign({}, this.getState().meta, data)
const updatedFiles = Object.assign({}, this.getState().files)
Object.keys(updatedFiles).forEach((fileID) => {
updatedFiles[fileID] = Object.assign({}, updatedFiles[fileID], {
meta: Object.assign({}, updatedFiles[fileID].meta, data)
})
})
this.log('Adding metadata:')
this.log(data)
this.setState({
meta: updatedMeta,
files: updatedFiles
})
}
setFileMeta (fileID, data) {
const updatedFiles = Object.assign({}, this.getState().files)
if (!updatedFiles[fileID]) {
this.log('Was trying to set metadata for a file that has been removed: ', fileID)
return
}
const newMeta = Object.assign({}, updatedFiles[fileID].meta, data)
updatedFiles[fileID] = Object.assign({}, updatedFiles[fileID], {
meta: newMeta
})
this.setState({ files: updatedFiles })
}
/**
* Get a file object.
*
* @param {string} fileID The ID of the file object to return.
*/
getFile (fileID) {
return this.getState().files[fileID]
}
/**
* Get all files in an array.
*/
getFiles () {
const { files } = this.getState()
return Object.keys(files).map((fileID) => files[fileID])
}
/**
* Check if minNumberOfFiles restriction is reached before uploading.
*
* @private
*/
_checkMinNumberOfFiles (files) {
const { minNumberOfFiles } = this.opts.restrictions
if (Object.keys(files).length < minNumberOfFiles) {
throw new RestrictionError(`${this.i18n('youHaveToAtLeastSelectX', { smart_count: minNumberOfFiles })}`)
}
}
/**
* Check if file passes a set of restrictions set in options: maxFileSize,
* maxNumberOfFiles and allowedFileTypes.
*
* @param {object} files Object of IDs → files already added
* @param {object} file object to check
* @private
*/
_checkRestrictions (files, file) {
const { maxFileSize, maxNumberOfFiles, allowedFileTypes } = this.opts.restrictions
if (maxNumberOfFiles) {
if (Object.keys(files).length + 1 > maxNumberOfFiles) {
throw new RestrictionError(`${this.i18n('youCanOnlyUploadX', { smart_count: maxNumberOfFiles })}`)
}
}
if (allowedFileTypes) {
const isCorrectFileType = allowedFileTypes.some((type) => {
// is this is a mime-type
if (type.indexOf('/') > -1) {
if (!file.type) return false
return match(file.type.replace(/;.*?$/, ''), type)
}
// otherwise this is likely an extension
if (type[0] === '.') {
return file.extension.toLowerCase() === type.substr(1).toLowerCase()
}
return false
})
if (!isCorrectFileType) {
const allowedFileTypesString = allowedFileTypes.join(', ')
throw new RestrictionError(this.i18n('youCanOnlyUploadFileTypes', { types: allowedFileTypesString }))
}
}
// We can't check maxFileSize if the size is unknown.
if (maxFileSize && file.data.size != null) {
if (file.data.size > maxFileSize) {
throw new RestrictionError(this.i18n('exceedsSize2', {
backwardsCompat: this.i18n('exceedsSize'),
size: prettierBytes(maxFileSize)
}))
}
}
}
/**
* Logs an error, sets Informer message, then throws the error.
* Emits a 'restriction-failed' event if its a restriction error
*
* @param {object | string} err — Error object or plain string message
* @param {object} [options]
* @param {boolean} [options.showInformer=true] — Sometimes developer might want to show Informer manually
* @param {object} [options.file=null] — File object used to emit the restriction error
* @param {boolean} [options.throwErr=true] — Errors shouldnt be thrown, for example, in `upload-error` event
* @private
*/
_showOrLogErrorAndThrow (err, { showInformer = true, file = null, throwErr = true } = {}) {
const message = typeof err === 'object' ? err.message : err
const details = (typeof err === 'object' && err.details) ? err.details : ''
// Restriction errors should be logged, but not as errors,
// as they are expected and shown in the UI.
let logMessageWithDetails = message
if (details) {
logMessageWithDetails += ' ' + details
}
if (err.isRestriction) {
this.log(logMessageWithDetails)
this.emit('restriction-failed', file, err)
} else {
this.log(logMessageWithDetails, 'error')
}
// Sometimes informer has to be shown manually by the developer,
// for example, in `onBeforeFileAdded`.
if (showInformer) {
this.info({ message: message, details: details }, 'error', 5000)
}
if (throwErr) {
throw (typeof err === 'object' ? err : new Error(err))
}
}
_assertNewUploadAllowed (file) {
const { allowNewUpload } = this.getState()
if (allowNewUpload === false) {
this._showOrLogErrorAndThrow(new RestrictionError(this.i18n('noNewAlreadyUploading')), { file })
}
}
/**
* Create a file state object based on user-provided `addFile()` options.
*
* Note this is extremely side-effectful and should only be done when a file state object will be added to state immediately afterward!
*
* The `files` value is passed in because it may be updated by the caller without updating the store.
*/
_checkAndCreateFileStateObject (files, file) {
const fileType = getFileType(file)
file.type = fileType
const onBeforeFileAddedResult = this.opts.onBeforeFileAdded(file, files)
if (onBeforeFileAddedResult === false) {
// Dont show UI info for this error, as it should be done by the developer
this._showOrLogErrorAndThrow(new RestrictionError('Cannot add the file because onBeforeFileAdded returned false.'), { showInformer: false, file })
}
if (typeof onBeforeFileAddedResult === 'object' && onBeforeFileAddedResult) {
file = onBeforeFileAddedResult
}
let fileName
if (file.name) {
fileName = file.name
} else if (fileType.split('/')[0] === 'image') {
fileName = fileType.split('/')[0] + '.' + fileType.split('/')[1]
} else {
fileName = 'noname'
}
const fileExtension = getFileNameAndExtension(fileName).extension
const isRemote = file.isRemote || false
const fileID = generateFileID(file)
if (files[fileID]) {
this._showOrLogErrorAndThrow(new RestrictionError(this.i18n('noDuplicates', { fileName })), { file })
}
const meta = file.meta || {}
meta.name = fileName
meta.type = fileType
// `null` means the size is unknown.
const size = isFinite(file.data.size) ? file.data.size : null
const newFile = {
source: file.source || '',
id: fileID,
name: fileName,
extension: fileExtension || '',
meta: {
...this.getState().meta,
...meta
},
type: fileType,
data: file.data,
progress: {
percentage: 0,
bytesUploaded: 0,
bytesTotal: size,
uploadComplete: false,
uploadStarted: null
},
size: size,
isRemote: isRemote,
remote: file.remote || '',
preview: file.preview
}
try {
this._checkRestrictions(files, newFile)
} catch (err) {
this._showOrLogErrorAndThrow(err, { file: newFile })
}
return newFile
}
// Schedule an upload if `autoProceed` is enabled.
_startIfAutoProceed () {
if (this.opts.autoProceed && !this.scheduledAutoProceed) {
this.scheduledAutoProceed = setTimeout(() => {
this.scheduledAutoProceed = null
this.upload().catch((err) => {
if (!err.isRestriction) {
this.log(err.stack || err.message || err)
}
})
}, 4)
}
}
/**
* Add a new file to `state.files`. This will run `onBeforeFileAdded`,
* try to guess file type in a clever way, check file against restrictions,
* and start an upload if `autoProceed === true`.
*
* @param {object} file object to add
* @returns {string} id for the added file
*/
addFile (file) {
this._assertNewUploadAllowed(file)
const { files } = this.getState()
const newFile = this._checkAndCreateFileStateObject(files, file)
this.setState({
files: {
...files,
[newFile.id]: newFile
}
})
this.emit('file-added', newFile)
this.log(`Added file: ${newFile.name}, ${newFile.id}, mime type: ${newFile.type}`)
this._startIfAutoProceed()
return newFile.id
}
/**
* Add multiple files to `state.files`. See the `addFile()` documentation.
*
* This cuts some corners for performance, so should typically only be used in cases where there may be a lot of files.
*
* If an error occurs while adding a file, it is logged and the user is notified. This is good for UI plugins, but not for programmatic use. Programmatic users should usually still use `addFile()` on individual files.
*/
addFiles (fileDescriptors) {
this._assertNewUploadAllowed()
// create a copy of the files object only once
const files = { ...this.getState().files }
const newFiles = []
const errors = []
for (let i = 0; i < fileDescriptors.length; i++) {
try {
const newFile = this._checkAndCreateFileStateObject(files, fileDescriptors[i])
newFiles.push(newFile)
files[newFile.id] = newFile
} catch (err) {
if (!err.isRestriction) {
errors.push(err)
}
}
}
this.setState({ files })
newFiles.forEach((newFile) => {
this.emit('file-added', newFile)
})
if (newFiles.length > 5) {
this.log(`Added batch of ${newFiles.length} files`)
} else {
Object.keys(newFiles).forEach(fileID => {
this.log(`Added file: ${newFiles[fileID].name}\n id: ${newFiles[fileID].id}\n type: ${newFiles[fileID].type}`)
})
}
if (newFiles.length > 0) {
this._startIfAutoProceed()
}
if (errors.length > 0) {
let message = 'Multiple errors occurred while adding files:\n'
errors.forEach((subError) => {
message += `\n * ${subError.message}`
})
this.info({
message: this.i18n('addBulkFilesFailed', { smart_count: errors.length }),
details: message
}, 'error', 5000)
const err = new Error(message)
err.errors = errors
throw err
}
}
removeFiles (fileIDs, reason) {
const { files, currentUploads } = this.getState()
const updatedFiles = { ...files }
const updatedUploads = { ...currentUploads }
const removedFiles = Object.create(null)
fileIDs.forEach((fileID) => {
if (files[fileID]) {
removedFiles[fileID] = files[fileID]
delete updatedFiles[fileID]
}
})
// Remove files from the `fileIDs` list in each upload.
function fileIsNotRemoved (uploadFileID) {
return removedFiles[uploadFileID] === undefined
}
const uploadsToRemove = []
Object.keys(updatedUploads).forEach((uploadID) => {
const newFileIDs = currentUploads[uploadID].fileIDs.filter(fileIsNotRemoved)
// Remove the upload if no files are associated with it anymore.
if (newFileIDs.length === 0) {
uploadsToRemove.push(uploadID)
return
}
updatedUploads[uploadID] = {
...currentUploads[uploadID],
fileIDs: newFileIDs
}
})
uploadsToRemove.forEach((uploadID) => {
delete updatedUploads[uploadID]
})
const stateUpdate = {
currentUploads: updatedUploads,
files: updatedFiles
}
// If all files were removed - allow new uploads!
if (Object.keys(updatedFiles).length === 0) {
stateUpdate.allowNewUpload = true
stateUpdate.error = null
}
this.setState(stateUpdate)
this._calculateTotalProgress()
const removedFileIDs = Object.keys(removedFiles)
removedFileIDs.forEach((fileID) => {
this.emit('file-removed', removedFiles[fileID], reason)
})
if (removedFileIDs.length > 5) {
this.log(`Removed ${removedFileIDs.length} files`)
} else {
this.log(`Removed files: ${removedFileIDs.join(', ')}`)
}
}
removeFile (fileID, reason = null) {
this.removeFiles([fileID], reason)
}
pauseResume (fileID) {
if (!this.getState().capabilities.resumableUploads ||
this.getFile(fileID).uploadComplete) {
return
}
const wasPaused = this.getFile(fileID).isPaused || false
const isPaused = !wasPaused
this.setFileState(fileID, {
isPaused: isPaused
})
this.emit('upload-pause', fileID, isPaused)
return isPaused
}
pauseAll () {
const updatedFiles = Object.assign({}, this.getState().files)
const inProgressUpdatedFiles = Object.keys(updatedFiles).filter((file) => {
return !updatedFiles[file].progress.uploadComplete &&
updatedFiles[file].progress.uploadStarted
})
inProgressUpdatedFiles.forEach((file) => {
const updatedFile = Object.assign({}, updatedFiles[file], {
isPaused: true
})
updatedFiles[file] = updatedFile
})
this.setState({ files: updatedFiles })
this.emit('pause-all')
}
resumeAll () {
const updatedFiles = Object.assign({}, this.getState().files)
const inProgressUpdatedFiles = Object.keys(updatedFiles).filter((file) => {
return !updatedFiles[file].progress.uploadComplete &&
updatedFiles[file].progress.uploadStarted
})
inProgressUpdatedFiles.forEach((file) => {
const updatedFile = Object.assign({}, updatedFiles[file], {
isPaused: false,
error: null
})
updatedFiles[file] = updatedFile
})
this.setState({ files: updatedFiles })
this.emit('resume-all')
}
retryAll () {
const updatedFiles = Object.assign({}, this.getState().files)
const filesToRetry = Object.keys(updatedFiles).filter(file => {
return updatedFiles[file].error
})
filesToRetry.forEach((file) => {
const updatedFile = Object.assign({}, updatedFiles[file], {
isPaused: false,
error: null
})
updatedFiles[file] = updatedFile
})
this.setState({
files: updatedFiles,
error: null
})
this.emit('retry-all', filesToRetry)
if (filesToRetry.length === 0) {
return Promise.resolve({
successful: [],
failed: []
})
}
const uploadID = this._createUpload(filesToRetry, {
forceAllowNewUpload: true // create new upload even if allowNewUpload: false
})
return this._runUpload(uploadID)
}
cancelAll () {
this.emit('cancel-all')
const { files } = this.getState()
const fileIDs = Object.keys(files)
if (fileIDs.length) {
this.removeFiles(fileIDs, 'cancel-all')
}
this.setState({
totalProgress: 0,
error: null
})
}
retryUpload (fileID) {
this.setFileState(fileID, {
error: null,
isPaused: false
})
this.emit('upload-retry', fileID)
const uploadID = this._createUpload([fileID], {
forceAllowNewUpload: true // create new upload even if allowNewUpload: false
})
return this._runUpload(uploadID)
}
reset () {
this.cancelAll()
}
_calculateProgress (file, data) {
if (!this.getFile(file.id)) {
this.log(`Not setting progress for a file that has been removed: ${file.id}`)
return
}
// bytesTotal may be null or zero; in that case we can't divide by it
const canHavePercentage = isFinite(data.bytesTotal) && data.bytesTotal > 0
this.setFileState(file.id, {
progress: {
...this.getFile(file.id).progress,
bytesUploaded: data.bytesUploaded,
bytesTotal: data.bytesTotal,
percentage: canHavePercentage
// TODO(goto-bus-stop) flooring this should probably be the choice of the UI?
// we get more accurate calculations if we don't round this at all.
? Math.round(data.bytesUploaded / data.bytesTotal * 100)
: 0
}
})
this._calculateTotalProgress()
}
_calculateTotalProgress () {
// calculate total progress, using the number of files currently uploading,
// multiplied by 100 and the summ of individual progress of each file
const files = this.getFiles()
const inProgress = files.filter((file) => {
return file.progress.uploadStarted ||
file.progress.preprocess ||
file.progress.postprocess
})
if (inProgress.length === 0) {
this.emit('progress', 0)
this.setState({ totalProgress: 0 })
return
}
const sizedFiles = inProgress.filter((file) => file.progress.bytesTotal != null)
const unsizedFiles = inProgress.filter((file) => file.progress.bytesTotal == null)
if (sizedFiles.length === 0) {
const progressMax = inProgress.length * 100
const currentProgress = unsizedFiles.reduce((acc, file) => {
return acc + file.progress.percentage
}, 0)
const totalProgress = Math.round(currentProgress / progressMax * 100)
this.setState({ totalProgress })
return
}
let totalSize = sizedFiles.reduce((acc, file) => {
return acc + file.progress.bytesTotal
}, 0)
const averageSize = totalSize / sizedFiles.length
totalSize += averageSize * unsizedFiles.length
let uploadedSize = 0
sizedFiles.forEach((file) => {
uploadedSize += file.progress.bytesUploaded
})
unsizedFiles.forEach((file) => {
uploadedSize += averageSize * (file.progress.percentage || 0) / 100
})
let totalProgress = totalSize === 0
? 0
: Math.round(uploadedSize / totalSize * 100)
// hot fix, because:
// uploadedSize ended up larger than totalSize, resulting in 1325% total
if (totalProgress > 100) {
totalProgress = 100
}
this.setState({ totalProgress })
this.emit('progress', totalProgress)
}
/**
* Registers listeners for all global actions, like:
* `error`, `file-removed`, `upload-progress`
*/
_addListeners () {
this.on('error', (error) => {
let errorMsg = 'Unknown error'
if (error.message) {
errorMsg = error.message
}
if (error.details) {
errorMsg += ' ' + error.details
}
this.setState({ error: errorMsg })
})
this.on('upload-error', (file, error, response) => {
let errorMsg = 'Unknown error'
if (error.message) {
errorMsg = error.message
}
if (error.details) {
errorMsg += ' ' + error.details
}
this.setFileState(file.id, {
error: errorMsg,
response
})
this.setState({ error: error.message })
if (typeof error === 'object' && error.message) {
const newError = new Error(error.message)
newError.details = error.message
if (error.details) {
newError.details += ' ' + error.details
}
newError.message = this.i18n('failedToUpload', { file: file.name })
this._showOrLogErrorAndThrow(newError, {
throwErr: false
})
} else {
this._showOrLogErrorAndThrow(error, {
throwErr: false
})
}
})
this.on('upload', () => {
this.setState({ error: null })
})
this.on('upload-started', (file, upload) => {
if (!this.getFile(file.id)) {
this.log(`Not setting progress for a file that has been removed: ${file.id}`)
return
}
this.setFileState(file.id, {
progress: {
uploadStarted: Date.now(),
uploadComplete: false,
percentage: 0,
bytesUploaded: 0,
bytesTotal: file.size
}
})
})
this.on('upload-progress', this._calculateProgress)
this.on('upload-success', (file, uploadResp) => {
if (!this.getFile(file.id)) {
this.log(`Not setting progress for a file that has been removed: ${file.id}`)
return
}
const currentProgress = this.getFile(file.id).progress
this.setFileState(file.id, {
progress: Object.assign({}, currentProgress, {
uploadComplete: true,
percentage: 100,
bytesUploaded: currentProgress.bytesTotal
}),
response: uploadResp,
uploadURL: uploadResp.uploadURL,
isPaused: false
})
this._calculateTotalProgress()
})
this.on('preprocess-progress', (file, progress) => {
if (!this.getFile(file.id)) {
this.log(`Not setting progress for a file that has been removed: ${file.id}`)
return
}
this.setFileState(file.id, {
progress: Object.assign({}, this.getFile(file.id).progress, {
preprocess: progress
})
})
})
this.on('preprocess-complete', (file) => {
if (!this.getFile(file.id)) {
this.log(`Not setting progress for a file that has been removed: ${file.id}`)
return
}
const files = Object.assign({}, this.getState().files)
files[file.id] = Object.assign({}, files[file.id], {
progress: Object.assign({}, files[file.id].progress)
})
delete files[file.id].progress.preprocess
this.setState({ files: files })
})
this.on('postprocess-progress', (file, progress) => {
if (!this.getFile(file.id)) {
this.log(`Not setting progress for a file that has been removed: ${file.id}`)
return
}
this.setFileState(file.id, {
progress: Object.assign({}, this.getState().files[file.id].progress, {
postprocess: progress
})
})
})
this.on('postprocess-complete', (file) => {
if (!this.getFile(file.id)) {
this.log(`Not setting progress for a file that has been removed: ${file.id}`)
return
}
const files = Object.assign({}, this.getState().files)
files[file.id] = Object.assign({}, files[file.id], {
progress: Object.assign({}, files[file.id].progress)
})
delete files[file.id].progress.postprocess
// TODO should we set some kind of `fullyComplete` property on the file object
// so it's easier to see that the file is upload…fully complete…rather than
// what we have to do now (`uploadComplete && !postprocess`)
this.setState({ files: files })
})
this.on('restored', () => {
// Files may have changed--ensure progress is still accurate.
this._calculateTotalProgress()
})
// show informer if offline
if (typeof window !== 'undefined' && window.addEventListener) {
window.addEventListener('online', () => this.updateOnlineStatus())
window.addEventListener('offline', () => this.updateOnlineStatus())
setTimeout(() => this.updateOnlineStatus(), 3000)
}
}
updateOnlineStatus () {
const online =
typeof window.navigator.onLine !== 'undefined'
? window.navigator.onLine
: true
if (!online) {
this.emit('is-offline')
this.info(this.i18n('noInternetConnection'), 'error', 0)
this.wasOffline = true
} else {
this.emit('is-online')
if (this.wasOffline) {
this.emit('back-online')
this.info(this.i18n('connectedToInternet'), 'success', 3000)
this.wasOffline = false
}
}
}
getID () {
return this.opts.id
}
/**
* Registers a plugin with Core.
*
* @param {object} Plugin object
* @param {object} [opts] object with options to be passed to Plugin
* @returns {object} self for chaining
*/
use (Plugin, opts) {
if (typeof Plugin !== 'function') {
const msg = `Expected a plugin class, but got ${Plugin === null ? 'null' : typeof Plugin}.` +
' Please verify that the plugin was imported and spelled correctly.'
throw new TypeError(msg)
}
// Instantiate
const plugin = new Plugin(this, opts)
const pluginId = plugin.id
this.plugins[plugin.type] = this.plugins[plugin.type] || []
if (!pluginId) {
throw new Error('Your plugin must have an id')
}
if (!plugin.type) {
throw new Error('Your plugin must have a type')
}
const existsPluginAlready = this.getPlugin(pluginId)
if (existsPluginAlready) {
const msg = `Already found a plugin named '${existsPluginAlready.id}'. ` +
`Tried to use: '${pluginId}'.\n` +
'Uppy plugins must have unique `id` options. See https://uppy.io/docs/plugins/#id.'
throw new Error(msg)
}
if (Plugin.VERSION) {
this.log(`Using ${pluginId} v${Plugin.VERSION}`)
}
this.plugins[plugin.type].push(plugin)
plugin.install()
return this
}
/**
* Find one Plugin by name.
*
* @param {string} id plugin id
* @returns {object|boolean}
*/
getPlugin (id) {
let foundPlugin = null
this.iteratePlugins((plugin) => {
if (plugin.id === id) {
foundPlugin = plugin
return false
}
})
return foundPlugin
}
/**
* Iterate through all `use`d plugins.
*
* @param {Function} method that will be run on each plugin
*/
iteratePlugins (method) {
Object.keys(this.plugins).forEach(pluginType => {
this.plugins[pluginType].forEach(method)
})
}
/**
* Uninstall and remove a plugin.
*
* @param {object} instance The plugin instance to remove.
*/
removePlugin (instance) {
this.log(`Removing plugin ${instance.id}`)
this.emit('plugin-remove', instance)
if (instance.uninstall) {
instance.uninstall()
}
const list = this.plugins[instance.type].slice()
const index = list.indexOf(instance)
if (index !== -1) {
list.splice(index, 1)
this.plugins[instance.type] = list
}
const updatedState = this.getState()
delete updatedState.plugins[instance.id]
this.setState(updatedState)
}
/**
* Uninstall all plugins and close down this Uppy instance.
*/
close () {
this.log(`Closing Uppy instance ${this.opts.id}: removing all files and uninstalling plugins`)
this.reset()
this._storeUnsubscribe()
this.iteratePlugins((plugin) => {
this.removePlugin(plugin)
})
}
/**
* Set info message in `state.info`, so that UI plugins like `Informer`
* can display the message.
*
* @param {string | object} message Message to be displayed by the informer
* @param {string} [type]
* @param {number} [duration]
*/
info (message, type = 'info', duration = 3000) {
const isComplexMessage = typeof message === 'object'
this.setState({
info: {
isHidden: false,
type: type,
message: isComplexMessage ? message.message : message,
details: isComplexMessage ? message.details : null
}
})
this.emit('info-visible')
clearTimeout(this.infoTimeoutID)
if (duration === 0) {
this.infoTimeoutID = undefined
return
}
// hide the informer after `duration` milliseconds
this.infoTimeoutID = setTimeout(this.hideInfo, duration)
}
hideInfo () {
const newInfo = Object.assign({}, this.getState().info, {
isHidden: true
})
this.setState({
info: newInfo
})
this.emit('info-hidden')
}
/**
* Passes messages to a function, provided in `opts.logger`.
* If `opts.logger: Uppy.debugLogger` or `opts.debug: true`, logs to the browser console.
*
* @param {string|object} message to log
* @param {string} [type] optional `error` or `warning`
*/
log (message, type) {
const { logger } = this.opts
switch (type) {
case 'error': logger.error(message); break
case 'warning': logger.warn(message); break
default: logger.debug(message); break
}
}
/**
* Obsolete, event listeners are now added in the constructor.
*/
run () {
this.log('Calling run() is no longer necessary.', 'warning')
return this
}
/**
* Restore an upload by its ID.
*/
restore (uploadID) {
this.log(`Core: attempting to restore upload "${uploadID}"`)
if (!this.getState().currentUploads[uploadID]) {
this._removeUpload(uploadID)
return Promise.reject(new Error('Nonexistent upload'))
}
return this._runUpload(uploadID)
}
/**
* Create an upload for a bunch of files.
*
* @param {Array<string>} fileIDs File IDs to include in this upload.
* @returns {string} ID of this upload.
*/
_createUpload (fileIDs, opts = {}) {
const {
forceAllowNewUpload = false // uppy.retryAll sets this to true — when retrying we want to ignore `allowNewUpload: false`
} = opts
const { allowNewUpload, currentUploads } = this.getState()
if (!allowNewUpload && !forceAllowNewUpload) {
throw new Error('Cannot create a new upload: already uploading.')
}
const uploadID = cuid()
this.emit('upload', {
id: uploadID,
fileIDs: fileIDs
})
this.setState({
allowNewUpload: this.opts.allowMultipleUploads !== false,
currentUploads: {
...currentUploads,
[uploadID]: {
fileIDs: fileIDs,
step: 0,
result: {}
}
}
})
return uploadID
}
_getUpload (uploadID) {
const { currentUploads } = this.getState()
return currentUploads[uploadID]
}
/**
* Add data to an upload's result object.
*
* @param {string} uploadID The ID of the upload.
* @param {object} data Data properties to add to the result object.
*/
addResultData (uploadID, data) {
if (!this._getUpload(uploadID)) {
this.log(`Not setting result for an upload that has been removed: ${uploadID}`)
return
}
const currentUploads = this.getState().currentUploads
const currentUpload = Object.assign({}, currentUploads[uploadID], {
result: Object.assign({}, currentUploads[uploadID].result, data)
})
this.setState({
currentUploads: Object.assign({}, currentUploads, {
[uploadID]: currentUpload
})
})
}
/**
* Remove an upload, eg. if it has been canceled or completed.
*
* @param {string} uploadID The ID of the upload.
*/
_removeUpload (uploadID) {
const currentUploads = { ...this.getState().currentUploads }
delete currentUploads[uploadID]
this.setState({
currentUploads: currentUploads
})
}
/**
* Run an upload. This picks up where it left off in case the upload is being restored.
*
* @private
*/
_runUpload (uploadID) {
const uploadData = this.getState().currentUploads[uploadID]
const restoreStep = uploadData.step
const steps = [
...this.preProcessors,
...this.uploaders,
...this.postProcessors
]
let lastStep = Promise.resolve()
steps.forEach((fn, step) => {
// Skip this step if we are restoring and have already completed this step before.
if (step < restoreStep) {
return
}
lastStep = lastStep.then(() => {
const { currentUploads } = this.getState()
const currentUpload = currentUploads[uploadID]
if (!currentUpload) {
return
}
const updatedUpload = Object.assign({}, currentUpload, {
step: step
})
this.setState({
currentUploads: Object.assign({}, currentUploads, {
[uploadID]: updatedUpload
})
})
// TODO give this the `updatedUpload` object as its only parameter maybe?
// Otherwise when more metadata may be added to the upload this would keep getting more parameters
return fn(updatedUpload.fileIDs, uploadID)
}).then((result) => {
return null
})
})
// Not returning the `catch`ed promise, because we still want to return a rejected
// promise from this method if the upload failed.
lastStep.catch((err) => {
this.emit('error', err, uploadID)
this._removeUpload(uploadID)
})
return lastStep.then(() => {
// Set result data.
const { currentUploads } = this.getState()
const currentUpload = currentUploads[uploadID]
if (!currentUpload) {
return
}
const files = currentUpload.fileIDs
.map((fileID) => this.getFile(fileID))
const successful = files.filter((file) => !file.error)
const failed = files.filter((file) => file.error)
this.addResultData(uploadID, { successful, failed, uploadID })
}).then(() => {
// Emit completion events.
// This is in a separate function so that the `currentUploads` variable
// always refers to the latest state. In the handler right above it refers
// to an outdated object without the `.result` property.
const { currentUploads } = this.getState()
if (!currentUploads[uploadID]) {
return
}
const currentUpload = currentUploads[uploadID]
const result = currentUpload.result
this.emit('complete', result)
this._removeUpload(uploadID)
return result
}).then((result) => {
if (result == null) {
this.log(`Not setting result for an upload that has been removed: ${uploadID}`)
}
return result
})
}
/**
* Start an upload for all the files that are not currently being uploaded.
*
* @returns {Promise}
*/
upload () {
if (!this.plugins.uploader) {
this.log('No uploader type plugins are used', 'warning')
}
let files = this.getState().files
const onBeforeUploadResult = this.opts.onBeforeUpload(files)
if (onBeforeUploadResult === false) {
return Promise.reject(new Error('Not starting the upload because onBeforeUpload returned false'))
}
if (onBeforeUploadResult && typeof onBeforeUploadResult === 'object') {
files = onBeforeUploadResult
// Updating files in state, because uploader plugins receive file IDs,
// and then fetch the actual file object from state
this.setState({
files: files
})
}
return Promise.resolve()
.then(() => this._checkMinNumberOfFiles(files))
.catch((err) => {
this._showOrLogErrorAndThrow(err)
})
.then(() => {
const { currentUploads } = this.getState()
// get a list of files that are currently assigned to uploads
const currentlyUploadingFiles = Object.keys(currentUploads).reduce((prev, curr) => prev.concat(currentUploads[curr].fileIDs), [])
const waitingFileIDs = []
Object.keys(files).forEach((fileID) => {
const file = this.getFile(fileID)
// if the file hasn't started uploading and hasn't already been assigned to an upload..
if ((!file.progress.uploadStarted) && (currentlyUploadingFiles.indexOf(fileID) === -1)) {
waitingFileIDs.push(file.id)
}
})
const uploadID = this._createUpload(waitingFileIDs)
return this._runUpload(uploadID)
})
.catch((err) => {
this._showOrLogErrorAndThrow(err, {
showInformer: false
})
})
}
}
module.exports = function (opts) {
return new Uppy(opts)
}
// Expose class constructor.
module.exports.Uppy = Uppy
module.exports.Plugin = Plugin
module.exports.debugLogger = debugLogger