UNPKG

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