UNPKG

49 kBJavaScriptView Raw
1const Translator = require('@uppy/utils/lib/Translator')
2const ee = require('namespace-emitter')
3const cuid = require('cuid')
4const throttle = require('lodash.throttle')
5const prettierBytes = require('@transloadit/prettier-bytes')
6const match = require('mime-match')
7const DefaultStore = require('@uppy/store-default')
8const getFileType = require('@uppy/utils/lib/getFileType')
9const getFileNameAndExtension = require('@uppy/utils/lib/getFileNameAndExtension')
10const generateFileID = require('@uppy/utils/lib/generateFileID')
11const supportsUploadProgress = require('./supportsUploadProgress')
12const { justErrorsLogger, debugLogger } = require('./loggers')
13const Plugin = require('./Plugin') // Exported from here.
14
15class RestrictionError extends Error {
16 constructor (...args) {
17 super(...args)
18 this.isRestriction = true
19 }
20}
21
22/**
23 * Uppy Core module.
24 * Manages plugins, state updates, acts as an event bus,
25 * adds/removes files and metadata.
26 */
27class Uppy {
28 static VERSION = require('../package.json').version
29
30 /**
31 * Instantiate Uppy
32 *
33 * @param {object} opts — Uppy options
34 */
35 constructor (opts) {
36 this.defaultLocale = {
37 strings: {
38 addBulkFilesFailed: {
39 0: 'Failed to add %{smart_count} file due to an internal error',
40 1: 'Failed to add %{smart_count} files due to internal errors'
41 },
42 youCanOnlyUploadX: {
43 0: 'You can only upload %{smart_count} file',
44 1: 'You can only upload %{smart_count} files'
45 },
46 youHaveToAtLeastSelectX: {
47 0: 'You have to select at least %{smart_count} file',
48 1: 'You have to select at least %{smart_count} files'
49 },
50 // The default `exceedsSize2` string only combines the `exceedsSize` string (%{backwardsCompat}) with the size.
51 // Locales can override `exceedsSize2` to specify a different word order. This is for backwards compat with
52 // Uppy 1.9.x and below which did a naive concatenation of `exceedsSize2 + size` instead of using a locale-specific
53 // substitution.
54 // TODO: In 2.0 `exceedsSize2` should be removed in and `exceedsSize` updated to use substitution.
55 exceedsSize2: '%{backwardsCompat} %{size}',
56 exceedsSize: 'This file exceeds maximum allowed size of',
57 inferiorSize: 'This file is smaller than the allowed size of %{size}',
58 youCanOnlyUploadFileTypes: 'You can only upload: %{types}',
59 noNewAlreadyUploading: 'Cannot add new files: already uploading',
60 noDuplicates: 'Cannot add the duplicate file \'%{fileName}\', it already exists',
61 companionError: 'Connection with Companion failed',
62 companionUnauthorizeHint: 'To unauthorize to your %{provider} account, please go to %{url}',
63 failedToUpload: 'Failed to upload %{file}',
64 noInternetConnection: 'No Internet connection',
65 connectedToInternet: 'Connected to the Internet',
66 // Strings for remote providers
67 noFilesFound: 'You have no files or folders here',
68 selectX: {
69 0: 'Select %{smart_count}',
70 1: 'Select %{smart_count}'
71 },
72 selectAllFilesFromFolderNamed: 'Select all files from folder %{name}',
73 unselectAllFilesFromFolderNamed: 'Unselect all files from folder %{name}',
74 selectFileNamed: 'Select file %{name}',
75 unselectFileNamed: 'Unselect file %{name}',
76 openFolderNamed: 'Open folder %{name}',
77 cancel: 'Cancel',
78 logOut: 'Log out',
79 filter: 'Filter',
80 resetFilter: 'Reset filter',
81 loading: 'Loading...',
82 authenticateWithTitle: 'Please authenticate with %{pluginName} to select files',
83 authenticateWith: 'Connect to %{pluginName}',
84 searchImages: 'Search for images',
85 enterTextToSearch: 'Enter text to search for images',
86 backToSearch: 'Back to Search',
87 emptyFolderAdded: 'No files were added from empty folder',
88 folderAdded: {
89 0: 'Added %{smart_count} file from %{folder}',
90 1: 'Added %{smart_count} files from %{folder}'
91 }
92 }
93 }
94
95 const defaultOptions = {
96 id: 'uppy',
97 autoProceed: false,
98 allowMultipleUploads: true,
99 debug: false,
100 restrictions: {
101 maxFileSize: null,
102 minFileSize: null,
103 maxTotalFileSize: null,
104 maxNumberOfFiles: null,
105 minNumberOfFiles: null,
106 allowedFileTypes: null
107 },
108 meta: {},
109 onBeforeFileAdded: (currentFile, files) => currentFile,
110 onBeforeUpload: (files) => files,
111 store: DefaultStore(),
112 logger: justErrorsLogger,
113 infoTimeout: 5000
114 }
115
116 // Merge default options with the ones set by user,
117 // making sure to merge restrictions too
118 this.opts = {
119 ...defaultOptions,
120 ...opts,
121 restrictions: {
122 ...defaultOptions.restrictions,
123 ...(opts && opts.restrictions)
124 }
125 }
126
127 // Support debug: true for backwards-compatability, unless logger is set in opts
128 // opts instead of this.opts to avoid comparing objects — we set logger: justErrorsLogger in defaultOptions
129 if (opts && opts.logger && opts.debug) {
130 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')
131 } else if (opts && opts.debug) {
132 this.opts.logger = debugLogger
133 }
134
135 this.log(`Using Core v${this.constructor.VERSION}`)
136
137 if (this.opts.restrictions.allowedFileTypes &&
138 this.opts.restrictions.allowedFileTypes !== null &&
139 !Array.isArray(this.opts.restrictions.allowedFileTypes)) {
140 throw new TypeError('`restrictions.allowedFileTypes` must be an array')
141 }
142
143 this.i18nInit()
144
145 // Container for different types of plugins
146 this.plugins = {}
147
148 this.getState = this.getState.bind(this)
149 this.getPlugin = this.getPlugin.bind(this)
150 this.setFileMeta = this.setFileMeta.bind(this)
151 this.setFileState = this.setFileState.bind(this)
152 this.log = this.log.bind(this)
153 this.info = this.info.bind(this)
154 this.hideInfo = this.hideInfo.bind(this)
155 this.addFile = this.addFile.bind(this)
156 this.removeFile = this.removeFile.bind(this)
157 this.pauseResume = this.pauseResume.bind(this)
158 this.validateRestrictions = this.validateRestrictions.bind(this)
159
160 // ___Why throttle at 500ms?
161 // - 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).
162 // [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.
163 // - We must throttle at around >500ms to avoid performance lags.
164 // [Practical Check] Firefox, try to upload a big file for a prolonged period of time. Laptop will start to heat up.
165 this._calculateProgress = throttle(this._calculateProgress.bind(this), 500, { leading: true, trailing: true })
166
167 this.updateOnlineStatus = this.updateOnlineStatus.bind(this)
168 this.resetProgress = this.resetProgress.bind(this)
169
170 this.pauseAll = this.pauseAll.bind(this)
171 this.resumeAll = this.resumeAll.bind(this)
172 this.retryAll = this.retryAll.bind(this)
173 this.cancelAll = this.cancelAll.bind(this)
174 this.retryUpload = this.retryUpload.bind(this)
175 this.upload = this.upload.bind(this)
176
177 this.emitter = ee()
178 this.on = this.on.bind(this)
179 this.off = this.off.bind(this)
180 this.once = this.emitter.once.bind(this.emitter)
181 this.emit = this.emitter.emit.bind(this.emitter)
182
183 this.preProcessors = []
184 this.uploaders = []
185 this.postProcessors = []
186
187 this.store = this.opts.store
188 this.setState({
189 plugins: {},
190 files: {},
191 currentUploads: {},
192 allowNewUpload: true,
193 capabilities: {
194 uploadProgress: supportsUploadProgress(),
195 individualCancellation: true,
196 resumableUploads: false
197 },
198 totalProgress: 0,
199 meta: { ...this.opts.meta },
200 info: {
201 isHidden: true,
202 type: 'info',
203 message: ''
204 }
205 })
206
207 this._storeUnsubscribe = this.store.subscribe((prevState, nextState, patch) => {
208 this.emit('state-update', prevState, nextState, patch)
209 this.updateAll(nextState)
210 })
211
212 // Exposing uppy object on window for debugging and testing
213 if (this.opts.debug && typeof window !== 'undefined') {
214 window[this.opts.id] = this
215 }
216
217 this._addListeners()
218
219 // Re-enable if we’ll need some capabilities on boot, like isMobileDevice
220 // this._setCapabilities()
221 }
222
223 // _setCapabilities = () => {
224 // const capabilities = {
225 // isMobileDevice: isMobileDevice()
226 // }
227
228 // this.setState({
229 // ...this.getState().capabilities,
230 // capabilities
231 // })
232 // }
233
234 on (event, callback) {
235 this.emitter.on(event, callback)
236 return this
237 }
238
239 off (event, callback) {
240 this.emitter.off(event, callback)
241 return this
242 }
243
244 /**
245 * Iterate on all plugins and run `update` on them.
246 * Called each time state changes.
247 *
248 */
249 updateAll (state) {
250 this.iteratePlugins(plugin => {
251 plugin.update(state)
252 })
253 }
254
255 /**
256 * Updates state with a patch
257 *
258 * @param {object} patch {foo: 'bar'}
259 */
260 setState (patch) {
261 this.store.setState(patch)
262 }
263
264 /**
265 * Returns current state.
266 *
267 * @returns {object}
268 */
269 getState () {
270 return this.store.getState()
271 }
272
273 /**
274 * Back compat for when uppy.state is used instead of uppy.getState().
275 */
276 get state () {
277 return this.getState()
278 }
279
280 /**
281 * Shorthand to set state for a specific file.
282 */
283 setFileState (fileID, state) {
284 if (!this.getState().files[fileID]) {
285 throw new Error(`Can’t set state for ${fileID} (the file could have been removed)`)
286 }
287
288 this.setState({
289 files: Object.assign({}, this.getState().files, {
290 [fileID]: Object.assign({}, this.getState().files[fileID], state)
291 })
292 })
293 }
294
295 i18nInit () {
296 this.translator = new Translator([this.defaultLocale, this.opts.locale])
297 this.locale = this.translator.locale
298 this.i18n = this.translator.translate.bind(this.translator)
299 this.i18nArray = this.translator.translateArray.bind(this.translator)
300 }
301
302 setOptions (newOpts) {
303 this.opts = {
304 ...this.opts,
305 ...newOpts,
306 restrictions: {
307 ...this.opts.restrictions,
308 ...(newOpts && newOpts.restrictions)
309 }
310 }
311
312 if (newOpts.meta) {
313 this.setMeta(newOpts.meta)
314 }
315
316 this.i18nInit()
317
318 if (newOpts.locale) {
319 this.iteratePlugins((plugin) => {
320 plugin.setOptions()
321 })
322 }
323
324 this.setState() // so that UI re-renders with new options
325 }
326
327 resetProgress () {
328 const defaultProgress = {
329 percentage: 0,
330 bytesUploaded: 0,
331 uploadComplete: false,
332 uploadStarted: null
333 }
334 const files = Object.assign({}, this.getState().files)
335 const updatedFiles = {}
336 Object.keys(files).forEach(fileID => {
337 const updatedFile = Object.assign({}, files[fileID])
338 updatedFile.progress = Object.assign({}, updatedFile.progress, defaultProgress)
339 updatedFiles[fileID] = updatedFile
340 })
341
342 this.setState({
343 files: updatedFiles,
344 totalProgress: 0
345 })
346
347 this.emit('reset-progress')
348 }
349
350 addPreProcessor (fn) {
351 this.preProcessors.push(fn)
352 }
353
354 removePreProcessor (fn) {
355 const i = this.preProcessors.indexOf(fn)
356 if (i !== -1) {
357 this.preProcessors.splice(i, 1)
358 }
359 }
360
361 addPostProcessor (fn) {
362 this.postProcessors.push(fn)
363 }
364
365 removePostProcessor (fn) {
366 const i = this.postProcessors.indexOf(fn)
367 if (i !== -1) {
368 this.postProcessors.splice(i, 1)
369 }
370 }
371
372 addUploader (fn) {
373 this.uploaders.push(fn)
374 }
375
376 removeUploader (fn) {
377 const i = this.uploaders.indexOf(fn)
378 if (i !== -1) {
379 this.uploaders.splice(i, 1)
380 }
381 }
382
383 setMeta (data) {
384 const updatedMeta = Object.assign({}, this.getState().meta, data)
385 const updatedFiles = Object.assign({}, this.getState().files)
386
387 Object.keys(updatedFiles).forEach((fileID) => {
388 updatedFiles[fileID] = Object.assign({}, updatedFiles[fileID], {
389 meta: Object.assign({}, updatedFiles[fileID].meta, data)
390 })
391 })
392
393 this.log('Adding metadata:')
394 this.log(data)
395
396 this.setState({
397 meta: updatedMeta,
398 files: updatedFiles
399 })
400 }
401
402 setFileMeta (fileID, data) {
403 const updatedFiles = Object.assign({}, this.getState().files)
404 if (!updatedFiles[fileID]) {
405 this.log('Was trying to set metadata for a file that has been removed: ', fileID)
406 return
407 }
408 const newMeta = Object.assign({}, updatedFiles[fileID].meta, data)
409 updatedFiles[fileID] = Object.assign({}, updatedFiles[fileID], {
410 meta: newMeta
411 })
412 this.setState({ files: updatedFiles })
413 }
414
415 /**
416 * Get a file object.
417 *
418 * @param {string} fileID The ID of the file object to return.
419 */
420 getFile (fileID) {
421 return this.getState().files[fileID]
422 }
423
424 /**
425 * Get all files in an array.
426 */
427 getFiles () {
428 const { files } = this.getState()
429 return Object.keys(files).map((fileID) => files[fileID])
430 }
431
432 /**
433 * A public wrapper for _checkRestrictions — checks if a file passes a set of restrictions.
434 * For use in UI pluigins (like Providers), to disallow selecting files that won’t pass restrictions.
435 *
436 * @param {object} file object to check
437 * @param {Array} [files] array to check maxNumberOfFiles and maxTotalFileSize
438 * @returns {object} { result: true/false, reason: why file didn’t pass restrictions }
439 */
440 validateRestrictions (file, files) {
441 try {
442 this._checkRestrictions(file, files)
443 return {
444 result: true
445 }
446 } catch (err) {
447 return {
448 result: false,
449 reason: err.message
450 }
451 }
452 }
453
454 /**
455 * Check if file passes a set of restrictions set in options: maxFileSize, minFileSize,
456 * maxNumberOfFiles and allowedFileTypes.
457 *
458 * @param {object} file object to check
459 * @param {Array} [files] array to check maxNumberOfFiles and maxTotalFileSize
460 * @private
461 */
462 _checkRestrictions (file, files = this.getFiles()) {
463 const { maxFileSize, minFileSize, maxTotalFileSize, maxNumberOfFiles, allowedFileTypes } = this.opts.restrictions
464
465 if (maxNumberOfFiles) {
466 if (files.length + 1 > maxNumberOfFiles) {
467 throw new RestrictionError(`${this.i18n('youCanOnlyUploadX', { smart_count: maxNumberOfFiles })}`)
468 }
469 }
470
471 if (allowedFileTypes) {
472 const isCorrectFileType = allowedFileTypes.some((type) => {
473 // check if this is a mime-type
474 if (type.indexOf('/') > -1) {
475 if (!file.type) return false
476 return match(file.type.replace(/;.*?$/, ''), type)
477 }
478
479 // otherwise this is likely an extension
480 if (type[0] === '.' && file.extension) {
481 return file.extension.toLowerCase() === type.substr(1).toLowerCase()
482 }
483 return false
484 })
485
486 if (!isCorrectFileType) {
487 const allowedFileTypesString = allowedFileTypes.join(', ')
488 throw new RestrictionError(this.i18n('youCanOnlyUploadFileTypes', { types: allowedFileTypesString }))
489 }
490 }
491
492 // We can't check maxTotalFileSize if the size is unknown.
493 if (maxTotalFileSize && file.size != null) {
494 let totalFilesSize = 0
495 totalFilesSize += file.size
496 files.forEach((file) => {
497 totalFilesSize += file.size
498 })
499 if (totalFilesSize > maxTotalFileSize) {
500 throw new RestrictionError(this.i18n('exceedsSize2', {
501 backwardsCompat: this.i18n('exceedsSize'),
502 size: prettierBytes(maxTotalFileSize)
503 }))
504 }
505 }
506
507 // We can't check maxFileSize if the size is unknown.
508 if (maxFileSize && file.size != null) {
509 if (file.size > maxFileSize) {
510 throw new RestrictionError(this.i18n('exceedsSize2', {
511 backwardsCompat: this.i18n('exceedsSize'),
512 size: prettierBytes(maxFileSize)
513 }))
514 }
515 }
516
517 // We can't check minFileSize if the size is unknown.
518 if (minFileSize && file.size != null) {
519 if (file.size < minFileSize) {
520 throw new RestrictionError(this.i18n('inferiorSize', {
521 size: prettierBytes(minFileSize)
522 }))
523 }
524 }
525 }
526
527 /**
528 * Check if minNumberOfFiles restriction is reached before uploading.
529 *
530 * @private
531 */
532 _checkMinNumberOfFiles (files) {
533 const { minNumberOfFiles } = this.opts.restrictions
534 if (Object.keys(files).length < minNumberOfFiles) {
535 throw new RestrictionError(`${this.i18n('youHaveToAtLeastSelectX', { smart_count: minNumberOfFiles })}`)
536 }
537 }
538
539 /**
540 * Logs an error, sets Informer message, then throws the error.
541 * Emits a 'restriction-failed' event if it’s a restriction error
542 *
543 * @param {object | string} err — Error object or plain string message
544 * @param {object} [options]
545 * @param {boolean} [options.showInformer=true] — Sometimes developer might want to show Informer manually
546 * @param {object} [options.file=null] — File object used to emit the restriction error
547 * @param {boolean} [options.throwErr=true] — Errors shouldn’t be thrown, for example, in `upload-error` event
548 * @private
549 */
550 _showOrLogErrorAndThrow (err, { showInformer = true, file = null, throwErr = true } = {}) {
551 const message = typeof err === 'object' ? err.message : err
552 const details = (typeof err === 'object' && err.details) ? err.details : ''
553
554 // Restriction errors should be logged, but not as errors,
555 // as they are expected and shown in the UI.
556 let logMessageWithDetails = message
557 if (details) {
558 logMessageWithDetails += ' ' + details
559 }
560 if (err.isRestriction) {
561 this.log(logMessageWithDetails)
562 this.emit('restriction-failed', file, err)
563 } else {
564 this.log(logMessageWithDetails, 'error')
565 }
566
567 // Sometimes informer has to be shown manually by the developer,
568 // for example, in `onBeforeFileAdded`.
569 if (showInformer) {
570 this.info({ message: message, details: details }, 'error', this.opts.infoTimeout)
571 }
572
573 if (throwErr) {
574 throw (typeof err === 'object' ? err : new Error(err))
575 }
576 }
577
578 _assertNewUploadAllowed (file) {
579 const { allowNewUpload } = this.getState()
580
581 if (allowNewUpload === false) {
582 this._showOrLogErrorAndThrow(new RestrictionError(this.i18n('noNewAlreadyUploading')), { file })
583 }
584 }
585
586 /**
587 * Create a file state object based on user-provided `addFile()` options.
588 *
589 * Note this is extremely side-effectful and should only be done when a file state object will be added to state immediately afterward!
590 *
591 * The `files` value is passed in because it may be updated by the caller without updating the store.
592 */
593 _checkAndCreateFileStateObject (files, file) {
594 const fileType = getFileType(file)
595 file.type = fileType
596
597 const onBeforeFileAddedResult = this.opts.onBeforeFileAdded(file, files)
598
599 if (onBeforeFileAddedResult === false) {
600 // Don’t show UI info for this error, as it should be done by the developer
601 this._showOrLogErrorAndThrow(new RestrictionError('Cannot add the file because onBeforeFileAdded returned false.'), { showInformer: false, file })
602 }
603
604 if (typeof onBeforeFileAddedResult === 'object' && onBeforeFileAddedResult) {
605 file = onBeforeFileAddedResult
606 }
607
608 let fileName
609 if (file.name) {
610 fileName = file.name
611 } else if (fileType.split('/')[0] === 'image') {
612 fileName = fileType.split('/')[0] + '.' + fileType.split('/')[1]
613 } else {
614 fileName = 'noname'
615 }
616 const fileExtension = getFileNameAndExtension(fileName).extension
617 const isRemote = file.isRemote || false
618
619 const fileID = generateFileID(file)
620
621 if (files[fileID]) {
622 this._showOrLogErrorAndThrow(new RestrictionError(this.i18n('noDuplicates', { fileName })), { file })
623 }
624
625 const meta = file.meta || {}
626 meta.name = fileName
627 meta.type = fileType
628
629 // `null` means the size is unknown.
630 const size = isFinite(file.data.size) ? file.data.size : null
631 const newFile = {
632 source: file.source || '',
633 id: fileID,
634 name: fileName,
635 extension: fileExtension || '',
636 meta: {
637 ...this.getState().meta,
638 ...meta
639 },
640 type: fileType,
641 data: file.data,
642 progress: {
643 percentage: 0,
644 bytesUploaded: 0,
645 bytesTotal: size,
646 uploadComplete: false,
647 uploadStarted: null
648 },
649 size: size,
650 isRemote: isRemote,
651 remote: file.remote || '',
652 preview: file.preview
653 }
654
655 try {
656 this._checkRestrictions(newFile)
657 } catch (err) {
658 this._showOrLogErrorAndThrow(err, { file: newFile })
659 }
660
661 return newFile
662 }
663
664 // Schedule an upload if `autoProceed` is enabled.
665 _startIfAutoProceed () {
666 if (this.opts.autoProceed && !this.scheduledAutoProceed) {
667 this.scheduledAutoProceed = setTimeout(() => {
668 this.scheduledAutoProceed = null
669 this.upload().catch((err) => {
670 if (!err.isRestriction) {
671 this.log(err.stack || err.message || err)
672 }
673 })
674 }, 4)
675 }
676 }
677
678 /**
679 * Add a new file to `state.files`. This will run `onBeforeFileAdded`,
680 * try to guess file type in a clever way, check file against restrictions,
681 * and start an upload if `autoProceed === true`.
682 *
683 * @param {object} file object to add
684 * @returns {string} id for the added file
685 */
686 addFile (file) {
687 this._assertNewUploadAllowed(file)
688
689 const { files } = this.getState()
690 const newFile = this._checkAndCreateFileStateObject(files, file)
691
692 this.setState({
693 files: {
694 ...files,
695 [newFile.id]: newFile
696 }
697 })
698
699 this.emit('file-added', newFile)
700 this.log(`Added file: ${newFile.name}, ${newFile.id}, mime type: ${newFile.type}`)
701
702 this._startIfAutoProceed()
703
704 return newFile.id
705 }
706
707 /**
708 * Add multiple files to `state.files`. See the `addFile()` documentation.
709 *
710 * This cuts some corners for performance, so should typically only be used in cases where there may be a lot of files.
711 *
712 * 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.
713 */
714 addFiles (fileDescriptors) {
715 this._assertNewUploadAllowed()
716
717 // create a copy of the files object only once
718 const files = { ...this.getState().files }
719 const newFiles = []
720 const errors = []
721 for (let i = 0; i < fileDescriptors.length; i++) {
722 try {
723 const newFile = this._checkAndCreateFileStateObject(files, fileDescriptors[i])
724 newFiles.push(newFile)
725 files[newFile.id] = newFile
726 } catch (err) {
727 if (!err.isRestriction) {
728 errors.push(err)
729 }
730 }
731 }
732
733 this.setState({ files })
734
735 newFiles.forEach((newFile) => {
736 this.emit('file-added', newFile)
737 })
738
739 if (newFiles.length > 5) {
740 this.log(`Added batch of ${newFiles.length} files`)
741 } else {
742 Object.keys(newFiles).forEach(fileID => {
743 this.log(`Added file: ${newFiles[fileID].name}\n id: ${newFiles[fileID].id}\n type: ${newFiles[fileID].type}`)
744 })
745 }
746
747 if (newFiles.length > 0) {
748 this._startIfAutoProceed()
749 }
750
751 if (errors.length > 0) {
752 let message = 'Multiple errors occurred while adding files:\n'
753 errors.forEach((subError) => {
754 message += `\n * ${subError.message}`
755 })
756
757 this.info({
758 message: this.i18n('addBulkFilesFailed', { smart_count: errors.length }),
759 details: message
760 }, 'error', this.opts.infoTimeout)
761
762 const err = new Error(message)
763 err.errors = errors
764 throw err
765 }
766 }
767
768 removeFiles (fileIDs, reason) {
769 const { files, currentUploads } = this.getState()
770 const updatedFiles = { ...files }
771 const updatedUploads = { ...currentUploads }
772
773 const removedFiles = Object.create(null)
774 fileIDs.forEach((fileID) => {
775 if (files[fileID]) {
776 removedFiles[fileID] = files[fileID]
777 delete updatedFiles[fileID]
778 }
779 })
780
781 // Remove files from the `fileIDs` list in each upload.
782 function fileIsNotRemoved (uploadFileID) {
783 return removedFiles[uploadFileID] === undefined
784 }
785 const uploadsToRemove = []
786 Object.keys(updatedUploads).forEach((uploadID) => {
787 const newFileIDs = currentUploads[uploadID].fileIDs.filter(fileIsNotRemoved)
788
789 // Remove the upload if no files are associated with it anymore.
790 if (newFileIDs.length === 0) {
791 uploadsToRemove.push(uploadID)
792 return
793 }
794
795 updatedUploads[uploadID] = {
796 ...currentUploads[uploadID],
797 fileIDs: newFileIDs
798 }
799 })
800
801 uploadsToRemove.forEach((uploadID) => {
802 delete updatedUploads[uploadID]
803 })
804
805 const stateUpdate = {
806 currentUploads: updatedUploads,
807 files: updatedFiles
808 }
809
810 // If all files were removed - allow new uploads!
811 if (Object.keys(updatedFiles).length === 0) {
812 stateUpdate.allowNewUpload = true
813 stateUpdate.error = null
814 }
815
816 this.setState(stateUpdate)
817 this._calculateTotalProgress()
818
819 const removedFileIDs = Object.keys(removedFiles)
820 removedFileIDs.forEach((fileID) => {
821 this.emit('file-removed', removedFiles[fileID], reason)
822 })
823
824 if (removedFileIDs.length > 5) {
825 this.log(`Removed ${removedFileIDs.length} files`)
826 } else {
827 this.log(`Removed files: ${removedFileIDs.join(', ')}`)
828 }
829 }
830
831 removeFile (fileID, reason = null) {
832 this.removeFiles([fileID], reason)
833 }
834
835 pauseResume (fileID) {
836 if (!this.getState().capabilities.resumableUploads ||
837 this.getFile(fileID).uploadComplete) {
838 return
839 }
840
841 const wasPaused = this.getFile(fileID).isPaused || false
842 const isPaused = !wasPaused
843
844 this.setFileState(fileID, {
845 isPaused: isPaused
846 })
847
848 this.emit('upload-pause', fileID, isPaused)
849
850 return isPaused
851 }
852
853 pauseAll () {
854 const updatedFiles = Object.assign({}, this.getState().files)
855 const inProgressUpdatedFiles = Object.keys(updatedFiles).filter((file) => {
856 return !updatedFiles[file].progress.uploadComplete &&
857 updatedFiles[file].progress.uploadStarted
858 })
859
860 inProgressUpdatedFiles.forEach((file) => {
861 const updatedFile = Object.assign({}, updatedFiles[file], {
862 isPaused: true
863 })
864 updatedFiles[file] = updatedFile
865 })
866
867 this.setState({ files: updatedFiles })
868 this.emit('pause-all')
869 }
870
871 resumeAll () {
872 const updatedFiles = Object.assign({}, this.getState().files)
873 const inProgressUpdatedFiles = Object.keys(updatedFiles).filter((file) => {
874 return !updatedFiles[file].progress.uploadComplete &&
875 updatedFiles[file].progress.uploadStarted
876 })
877
878 inProgressUpdatedFiles.forEach((file) => {
879 const updatedFile = Object.assign({}, updatedFiles[file], {
880 isPaused: false,
881 error: null
882 })
883 updatedFiles[file] = updatedFile
884 })
885 this.setState({ files: updatedFiles })
886
887 this.emit('resume-all')
888 }
889
890 retryAll () {
891 const updatedFiles = Object.assign({}, this.getState().files)
892 const filesToRetry = Object.keys(updatedFiles).filter(file => {
893 return updatedFiles[file].error
894 })
895
896 filesToRetry.forEach((file) => {
897 const updatedFile = Object.assign({}, updatedFiles[file], {
898 isPaused: false,
899 error: null
900 })
901 updatedFiles[file] = updatedFile
902 })
903 this.setState({
904 files: updatedFiles,
905 error: null
906 })
907
908 this.emit('retry-all', filesToRetry)
909
910 if (filesToRetry.length === 0) {
911 return Promise.resolve({
912 successful: [],
913 failed: []
914 })
915 }
916
917 const uploadID = this._createUpload(filesToRetry, {
918 forceAllowNewUpload: true // create new upload even if allowNewUpload: false
919 })
920 return this._runUpload(uploadID)
921 }
922
923 cancelAll () {
924 this.emit('cancel-all')
925
926 const { files } = this.getState()
927
928 const fileIDs = Object.keys(files)
929 if (fileIDs.length) {
930 this.removeFiles(fileIDs, 'cancel-all')
931 }
932
933 this.setState({
934 totalProgress: 0,
935 error: null
936 })
937 }
938
939 retryUpload (fileID) {
940 this.setFileState(fileID, {
941 error: null,
942 isPaused: false
943 })
944
945 this.emit('upload-retry', fileID)
946
947 const uploadID = this._createUpload([fileID], {
948 forceAllowNewUpload: true // create new upload even if allowNewUpload: false
949 })
950 return this._runUpload(uploadID)
951 }
952
953 reset () {
954 this.cancelAll()
955 }
956
957 _calculateProgress (file, data) {
958 if (!this.getFile(file.id)) {
959 this.log(`Not setting progress for a file that has been removed: ${file.id}`)
960 return
961 }
962
963 // bytesTotal may be null or zero; in that case we can't divide by it
964 const canHavePercentage = isFinite(data.bytesTotal) && data.bytesTotal > 0
965 this.setFileState(file.id, {
966 progress: {
967 ...this.getFile(file.id).progress,
968 bytesUploaded: data.bytesUploaded,
969 bytesTotal: data.bytesTotal,
970 percentage: canHavePercentage
971 // TODO(goto-bus-stop) flooring this should probably be the choice of the UI?
972 // we get more accurate calculations if we don't round this at all.
973 ? Math.round(data.bytesUploaded / data.bytesTotal * 100)
974 : 0
975 }
976 })
977
978 this._calculateTotalProgress()
979 }
980
981 _calculateTotalProgress () {
982 // calculate total progress, using the number of files currently uploading,
983 // multiplied by 100 and the summ of individual progress of each file
984 const files = this.getFiles()
985
986 const inProgress = files.filter((file) => {
987 return file.progress.uploadStarted ||
988 file.progress.preprocess ||
989 file.progress.postprocess
990 })
991
992 if (inProgress.length === 0) {
993 this.emit('progress', 0)
994 this.setState({ totalProgress: 0 })
995 return
996 }
997
998 const sizedFiles = inProgress.filter((file) => file.progress.bytesTotal != null)
999 const unsizedFiles = inProgress.filter((file) => file.progress.bytesTotal == null)
1000
1001 if (sizedFiles.length === 0) {
1002 const progressMax = inProgress.length * 100
1003 const currentProgress = unsizedFiles.reduce((acc, file) => {
1004 return acc + file.progress.percentage
1005 }, 0)
1006 const totalProgress = Math.round(currentProgress / progressMax * 100)
1007 this.setState({ totalProgress })
1008 return
1009 }
1010
1011 let totalSize = sizedFiles.reduce((acc, file) => {
1012 return acc + file.progress.bytesTotal
1013 }, 0)
1014 const averageSize = totalSize / sizedFiles.length
1015 totalSize += averageSize * unsizedFiles.length
1016
1017 let uploadedSize = 0
1018 sizedFiles.forEach((file) => {
1019 uploadedSize += file.progress.bytesUploaded
1020 })
1021 unsizedFiles.forEach((file) => {
1022 uploadedSize += averageSize * (file.progress.percentage || 0) / 100
1023 })
1024
1025 let totalProgress = totalSize === 0
1026 ? 0
1027 : Math.round(uploadedSize / totalSize * 100)
1028
1029 // hot fix, because:
1030 // uploadedSize ended up larger than totalSize, resulting in 1325% total
1031 if (totalProgress > 100) {
1032 totalProgress = 100
1033 }
1034
1035 this.setState({ totalProgress })
1036 this.emit('progress', totalProgress)
1037 }
1038
1039 /**
1040 * Registers listeners for all global actions, like:
1041 * `error`, `file-removed`, `upload-progress`
1042 */
1043 _addListeners () {
1044 this.on('error', (error) => {
1045 let errorMsg = 'Unknown error'
1046 if (error.message) {
1047 errorMsg = error.message
1048 }
1049
1050 if (error.details) {
1051 errorMsg += ' ' + error.details
1052 }
1053
1054 this.setState({ error: errorMsg })
1055 })
1056
1057 this.on('upload-error', (file, error, response) => {
1058 let errorMsg = 'Unknown error'
1059 if (error.message) {
1060 errorMsg = error.message
1061 }
1062
1063 if (error.details) {
1064 errorMsg += ' ' + error.details
1065 }
1066
1067 this.setFileState(file.id, {
1068 error: errorMsg,
1069 response
1070 })
1071
1072 this.setState({ error: error.message })
1073
1074 if (typeof error === 'object' && error.message) {
1075 const newError = new Error(error.message)
1076 newError.details = error.message
1077 if (error.details) {
1078 newError.details += ' ' + error.details
1079 }
1080 newError.message = this.i18n('failedToUpload', { file: file.name })
1081 this._showOrLogErrorAndThrow(newError, {
1082 throwErr: false
1083 })
1084 } else {
1085 this._showOrLogErrorAndThrow(error, {
1086 throwErr: false
1087 })
1088 }
1089 })
1090
1091 this.on('upload', () => {
1092 this.setState({ error: null })
1093 })
1094
1095 this.on('upload-started', (file, upload) => {
1096 if (!this.getFile(file.id)) {
1097 this.log(`Not setting progress for a file that has been removed: ${file.id}`)
1098 return
1099 }
1100 this.setFileState(file.id, {
1101 progress: {
1102 uploadStarted: Date.now(),
1103 uploadComplete: false,
1104 percentage: 0,
1105 bytesUploaded: 0,
1106 bytesTotal: file.size
1107 }
1108 })
1109 })
1110
1111 this.on('upload-progress', this._calculateProgress)
1112
1113 this.on('upload-success', (file, uploadResp) => {
1114 if (!this.getFile(file.id)) {
1115 this.log(`Not setting progress for a file that has been removed: ${file.id}`)
1116 return
1117 }
1118
1119 const currentProgress = this.getFile(file.id).progress
1120 this.setFileState(file.id, {
1121 progress: Object.assign({}, currentProgress, {
1122 postprocess: this.postProcessors.length > 0 ? {
1123 mode: 'indeterminate'
1124 } : null,
1125 uploadComplete: true,
1126 percentage: 100,
1127 bytesUploaded: currentProgress.bytesTotal
1128 }),
1129 response: uploadResp,
1130 uploadURL: uploadResp.uploadURL,
1131 isPaused: false
1132 })
1133
1134 this._calculateTotalProgress()
1135 })
1136
1137 this.on('preprocess-progress', (file, progress) => {
1138 if (!this.getFile(file.id)) {
1139 this.log(`Not setting progress for a file that has been removed: ${file.id}`)
1140 return
1141 }
1142 this.setFileState(file.id, {
1143 progress: Object.assign({}, this.getFile(file.id).progress, {
1144 preprocess: progress
1145 })
1146 })
1147 })
1148
1149 this.on('preprocess-complete', (file) => {
1150 if (!this.getFile(file.id)) {
1151 this.log(`Not setting progress for a file that has been removed: ${file.id}`)
1152 return
1153 }
1154 const files = Object.assign({}, this.getState().files)
1155 files[file.id] = Object.assign({}, files[file.id], {
1156 progress: Object.assign({}, files[file.id].progress)
1157 })
1158 delete files[file.id].progress.preprocess
1159
1160 this.setState({ files: files })
1161 })
1162
1163 this.on('postprocess-progress', (file, progress) => {
1164 if (!this.getFile(file.id)) {
1165 this.log(`Not setting progress for a file that has been removed: ${file.id}`)
1166 return
1167 }
1168 this.setFileState(file.id, {
1169 progress: Object.assign({}, this.getState().files[file.id].progress, {
1170 postprocess: progress
1171 })
1172 })
1173 })
1174
1175 this.on('postprocess-complete', (file) => {
1176 if (!this.getFile(file.id)) {
1177 this.log(`Not setting progress for a file that has been removed: ${file.id}`)
1178 return
1179 }
1180 const files = Object.assign({}, this.getState().files)
1181 files[file.id] = Object.assign({}, files[file.id], {
1182 progress: Object.assign({}, files[file.id].progress)
1183 })
1184 delete files[file.id].progress.postprocess
1185 // TODO should we set some kind of `fullyComplete` property on the file object
1186 // so it's easier to see that the file is upload…fully complete…rather than
1187 // what we have to do now (`uploadComplete && !postprocess`)
1188
1189 this.setState({ files: files })
1190 })
1191
1192 this.on('restored', () => {
1193 // Files may have changed--ensure progress is still accurate.
1194 this._calculateTotalProgress()
1195 })
1196
1197 // show informer if offline
1198 if (typeof window !== 'undefined' && window.addEventListener) {
1199 window.addEventListener('online', () => this.updateOnlineStatus())
1200 window.addEventListener('offline', () => this.updateOnlineStatus())
1201 setTimeout(() => this.updateOnlineStatus(), 3000)
1202 }
1203 }
1204
1205 updateOnlineStatus () {
1206 const online =
1207 typeof window.navigator.onLine !== 'undefined'
1208 ? window.navigator.onLine
1209 : true
1210 if (!online) {
1211 this.emit('is-offline')
1212 this.info(this.i18n('noInternetConnection'), 'error', 0)
1213 this.wasOffline = true
1214 } else {
1215 this.emit('is-online')
1216 if (this.wasOffline) {
1217 this.emit('back-online')
1218 this.info(this.i18n('connectedToInternet'), 'success', 3000)
1219 this.wasOffline = false
1220 }
1221 }
1222 }
1223
1224 getID () {
1225 return this.opts.id
1226 }
1227
1228 /**
1229 * Registers a plugin with Core.
1230 *
1231 * @param {object} Plugin object
1232 * @param {object} [opts] object with options to be passed to Plugin
1233 * @returns {object} self for chaining
1234 */
1235 use (Plugin, opts) {
1236 if (typeof Plugin !== 'function') {
1237 const msg = `Expected a plugin class, but got ${Plugin === null ? 'null' : typeof Plugin}.` +
1238 ' Please verify that the plugin was imported and spelled correctly.'
1239 throw new TypeError(msg)
1240 }
1241
1242 // Instantiate
1243 const plugin = new Plugin(this, opts)
1244 const pluginId = plugin.id
1245 this.plugins[plugin.type] = this.plugins[plugin.type] || []
1246
1247 if (!pluginId) {
1248 throw new Error('Your plugin must have an id')
1249 }
1250
1251 if (!plugin.type) {
1252 throw new Error('Your plugin must have a type')
1253 }
1254
1255 const existsPluginAlready = this.getPlugin(pluginId)
1256 if (existsPluginAlready) {
1257 const msg = `Already found a plugin named '${existsPluginAlready.id}'. ` +
1258 `Tried to use: '${pluginId}'.\n` +
1259 'Uppy plugins must have unique `id` options. See https://uppy.io/docs/plugins/#id.'
1260 throw new Error(msg)
1261 }
1262
1263 if (Plugin.VERSION) {
1264 this.log(`Using ${pluginId} v${Plugin.VERSION}`)
1265 }
1266
1267 this.plugins[plugin.type].push(plugin)
1268 plugin.install()
1269
1270 return this
1271 }
1272
1273 /**
1274 * Find one Plugin by name.
1275 *
1276 * @param {string} id plugin id
1277 * @returns {object|boolean}
1278 */
1279 getPlugin (id) {
1280 let foundPlugin = null
1281 this.iteratePlugins((plugin) => {
1282 if (plugin.id === id) {
1283 foundPlugin = plugin
1284 return false
1285 }
1286 })
1287 return foundPlugin
1288 }
1289
1290 /**
1291 * Iterate through all `use`d plugins.
1292 *
1293 * @param {Function} method that will be run on each plugin
1294 */
1295 iteratePlugins (method) {
1296 Object.keys(this.plugins).forEach(pluginType => {
1297 this.plugins[pluginType].forEach(method)
1298 })
1299 }
1300
1301 /**
1302 * Uninstall and remove a plugin.
1303 *
1304 * @param {object} instance The plugin instance to remove.
1305 */
1306 removePlugin (instance) {
1307 this.log(`Removing plugin ${instance.id}`)
1308 this.emit('plugin-remove', instance)
1309
1310 if (instance.uninstall) {
1311 instance.uninstall()
1312 }
1313
1314 const list = this.plugins[instance.type].slice()
1315 const index = list.indexOf(instance)
1316 if (index !== -1) {
1317 list.splice(index, 1)
1318 this.plugins[instance.type] = list
1319 }
1320
1321 const updatedState = this.getState()
1322 delete updatedState.plugins[instance.id]
1323 this.setState(updatedState)
1324 }
1325
1326 /**
1327 * Uninstall all plugins and close down this Uppy instance.
1328 */
1329 close () {
1330 this.log(`Closing Uppy instance ${this.opts.id}: removing all files and uninstalling plugins`)
1331
1332 this.reset()
1333
1334 this._storeUnsubscribe()
1335
1336 this.iteratePlugins((plugin) => {
1337 this.removePlugin(plugin)
1338 })
1339 }
1340
1341 /**
1342 * Set info message in `state.info`, so that UI plugins like `Informer`
1343 * can display the message.
1344 *
1345 * @param {string | object} message Message to be displayed by the informer
1346 * @param {string} [type]
1347 * @param {number} [duration]
1348 */
1349
1350 info (message, type = 'info', duration = 3000) {
1351 const isComplexMessage = typeof message === 'object'
1352
1353 this.setState({
1354 info: {
1355 isHidden: false,
1356 type: type,
1357 message: isComplexMessage ? message.message : message,
1358 details: isComplexMessage ? message.details : null
1359 }
1360 })
1361
1362 this.emit('info-visible')
1363
1364 clearTimeout(this.infoTimeoutID)
1365 if (duration === 0) {
1366 this.infoTimeoutID = undefined
1367 return
1368 }
1369
1370 // hide the informer after `duration` milliseconds
1371 this.infoTimeoutID = setTimeout(this.hideInfo, duration)
1372 }
1373
1374 hideInfo () {
1375 const newInfo = Object.assign({}, this.getState().info, {
1376 isHidden: true
1377 })
1378 this.setState({
1379 info: newInfo
1380 })
1381 this.emit('info-hidden')
1382 }
1383
1384 /**
1385 * Passes messages to a function, provided in `opts.logger`.
1386 * If `opts.logger: Uppy.debugLogger` or `opts.debug: true`, logs to the browser console.
1387 *
1388 * @param {string|object} message to log
1389 * @param {string} [type] optional `error` or `warning`
1390 */
1391 log (message, type) {
1392 const { logger } = this.opts
1393 switch (type) {
1394 case 'error': logger.error(message); break
1395 case 'warning': logger.warn(message); break
1396 default: logger.debug(message); break
1397 }
1398 }
1399
1400 /**
1401 * Obsolete, event listeners are now added in the constructor.
1402 */
1403 run () {
1404 this.log('Calling run() is no longer necessary.', 'warning')
1405 return this
1406 }
1407
1408 /**
1409 * Restore an upload by its ID.
1410 */
1411 restore (uploadID) {
1412 this.log(`Core: attempting to restore upload "${uploadID}"`)
1413
1414 if (!this.getState().currentUploads[uploadID]) {
1415 this._removeUpload(uploadID)
1416 return Promise.reject(new Error('Nonexistent upload'))
1417 }
1418
1419 return this._runUpload(uploadID)
1420 }
1421
1422 /**
1423 * Create an upload for a bunch of files.
1424 *
1425 * @param {Array<string>} fileIDs File IDs to include in this upload.
1426 * @returns {string} ID of this upload.
1427 */
1428 _createUpload (fileIDs, opts = {}) {
1429 const {
1430 forceAllowNewUpload = false // uppy.retryAll sets this to true — when retrying we want to ignore `allowNewUpload: false`
1431 } = opts
1432
1433 const { allowNewUpload, currentUploads } = this.getState()
1434 if (!allowNewUpload && !forceAllowNewUpload) {
1435 throw new Error('Cannot create a new upload: already uploading.')
1436 }
1437
1438 const uploadID = cuid()
1439
1440 this.emit('upload', {
1441 id: uploadID,
1442 fileIDs: fileIDs
1443 })
1444
1445 this.setState({
1446 allowNewUpload: this.opts.allowMultipleUploads !== false,
1447
1448 currentUploads: {
1449 ...currentUploads,
1450 [uploadID]: {
1451 fileIDs: fileIDs,
1452 step: 0,
1453 result: {}
1454 }
1455 }
1456 })
1457
1458 return uploadID
1459 }
1460
1461 _getUpload (uploadID) {
1462 const { currentUploads } = this.getState()
1463
1464 return currentUploads[uploadID]
1465 }
1466
1467 /**
1468 * Add data to an upload's result object.
1469 *
1470 * @param {string} uploadID The ID of the upload.
1471 * @param {object} data Data properties to add to the result object.
1472 */
1473 addResultData (uploadID, data) {
1474 if (!this._getUpload(uploadID)) {
1475 this.log(`Not setting result for an upload that has been removed: ${uploadID}`)
1476 return
1477 }
1478 const currentUploads = this.getState().currentUploads
1479 const currentUpload = Object.assign({}, currentUploads[uploadID], {
1480 result: Object.assign({}, currentUploads[uploadID].result, data)
1481 })
1482 this.setState({
1483 currentUploads: Object.assign({}, currentUploads, {
1484 [uploadID]: currentUpload
1485 })
1486 })
1487 }
1488
1489 /**
1490 * Remove an upload, eg. if it has been canceled or completed.
1491 *
1492 * @param {string} uploadID The ID of the upload.
1493 */
1494 _removeUpload (uploadID) {
1495 const currentUploads = { ...this.getState().currentUploads }
1496 delete currentUploads[uploadID]
1497
1498 this.setState({
1499 currentUploads: currentUploads
1500 })
1501 }
1502
1503 /**
1504 * Run an upload. This picks up where it left off in case the upload is being restored.
1505 *
1506 * @private
1507 */
1508 _runUpload (uploadID) {
1509 const uploadData = this.getState().currentUploads[uploadID]
1510 const restoreStep = uploadData.step
1511
1512 const steps = [
1513 ...this.preProcessors,
1514 ...this.uploaders,
1515 ...this.postProcessors
1516 ]
1517 let lastStep = Promise.resolve()
1518 steps.forEach((fn, step) => {
1519 // Skip this step if we are restoring and have already completed this step before.
1520 if (step < restoreStep) {
1521 return
1522 }
1523
1524 lastStep = lastStep.then(() => {
1525 const { currentUploads } = this.getState()
1526 const currentUpload = currentUploads[uploadID]
1527 if (!currentUpload) {
1528 return
1529 }
1530
1531 const updatedUpload = Object.assign({}, currentUpload, {
1532 step: step
1533 })
1534 this.setState({
1535 currentUploads: Object.assign({}, currentUploads, {
1536 [uploadID]: updatedUpload
1537 })
1538 })
1539
1540 // TODO give this the `updatedUpload` object as its only parameter maybe?
1541 // Otherwise when more metadata may be added to the upload this would keep getting more parameters
1542 return fn(updatedUpload.fileIDs, uploadID)
1543 }).then((result) => {
1544 return null
1545 })
1546 })
1547
1548 // Not returning the `catch`ed promise, because we still want to return a rejected
1549 // promise from this method if the upload failed.
1550 lastStep.catch((err) => {
1551 this.emit('error', err, uploadID)
1552 this._removeUpload(uploadID)
1553 })
1554
1555 return lastStep.then(() => {
1556 // Set result data.
1557 const { currentUploads } = this.getState()
1558 const currentUpload = currentUploads[uploadID]
1559 if (!currentUpload) {
1560 return
1561 }
1562
1563 const files = currentUpload.fileIDs
1564 .map((fileID) => this.getFile(fileID))
1565 const successful = files.filter((file) => !file.error)
1566 const failed = files.filter((file) => file.error)
1567 this.addResultData(uploadID, { successful, failed, uploadID })
1568 }).then(() => {
1569 // Emit completion events.
1570 // This is in a separate function so that the `currentUploads` variable
1571 // always refers to the latest state. In the handler right above it refers
1572 // to an outdated object without the `.result` property.
1573 const { currentUploads } = this.getState()
1574 if (!currentUploads[uploadID]) {
1575 return
1576 }
1577 const currentUpload = currentUploads[uploadID]
1578 const result = currentUpload.result
1579 this.emit('complete', result)
1580
1581 this._removeUpload(uploadID)
1582
1583 return result
1584 }).then((result) => {
1585 if (result == null) {
1586 this.log(`Not setting result for an upload that has been removed: ${uploadID}`)
1587 }
1588 return result
1589 })
1590 }
1591
1592 /**
1593 * Start an upload for all the files that are not currently being uploaded.
1594 *
1595 * @returns {Promise}
1596 */
1597 upload () {
1598 if (!this.plugins.uploader) {
1599 this.log('No uploader type plugins are used', 'warning')
1600 }
1601
1602 let files = this.getState().files
1603
1604 const onBeforeUploadResult = this.opts.onBeforeUpload(files)
1605
1606 if (onBeforeUploadResult === false) {
1607 return Promise.reject(new Error('Not starting the upload because onBeforeUpload returned false'))
1608 }
1609
1610 if (onBeforeUploadResult && typeof onBeforeUploadResult === 'object') {
1611 files = onBeforeUploadResult
1612 // Updating files in state, because uploader plugins receive file IDs,
1613 // and then fetch the actual file object from state
1614 this.setState({
1615 files: files
1616 })
1617 }
1618
1619 return Promise.resolve()
1620 .then(() => this._checkMinNumberOfFiles(files))
1621 .catch((err) => {
1622 this._showOrLogErrorAndThrow(err)
1623 })
1624 .then(() => {
1625 const { currentUploads } = this.getState()
1626 // get a list of files that are currently assigned to uploads
1627 const currentlyUploadingFiles = Object.keys(currentUploads).reduce((prev, curr) => prev.concat(currentUploads[curr].fileIDs), [])
1628
1629 const waitingFileIDs = []
1630 Object.keys(files).forEach((fileID) => {
1631 const file = this.getFile(fileID)
1632 // if the file hasn't started uploading and hasn't already been assigned to an upload..
1633 if ((!file.progress.uploadStarted) && (currentlyUploadingFiles.indexOf(fileID) === -1)) {
1634 waitingFileIDs.push(file.id)
1635 }
1636 })
1637
1638 const uploadID = this._createUpload(waitingFileIDs)
1639 return this._runUpload(uploadID)
1640 })
1641 .catch((err) => {
1642 this._showOrLogErrorAndThrow(err, {
1643 showInformer: false
1644 })
1645 })
1646 }
1647}
1648
1649module.exports = function (opts) {
1650 return new Uppy(opts)
1651}
1652
1653// Expose class constructor.
1654module.exports.Uppy = Uppy
1655module.exports.Plugin = Plugin
1656module.exports.debugLogger = debugLogger