UNPKG

50.1 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 const filesArray = Object.keys(files).map(i => files[i])
657 this._checkRestrictions(newFile, filesArray)
658 } catch (err) {
659 this._showOrLogErrorAndThrow(err, { file: newFile })
660 }
661
662 return newFile
663 }
664
665 // Schedule an upload if `autoProceed` is enabled.
666 _startIfAutoProceed () {
667 if (this.opts.autoProceed && !this.scheduledAutoProceed) {
668 this.scheduledAutoProceed = setTimeout(() => {
669 this.scheduledAutoProceed = null
670 this.upload().catch((err) => {
671 if (!err.isRestriction) {
672 this.log(err.stack || err.message || err)
673 }
674 })
675 }, 4)
676 }
677 }
678
679 /**
680 * Add a new file to `state.files`. This will run `onBeforeFileAdded`,
681 * try to guess file type in a clever way, check file against restrictions,
682 * and start an upload if `autoProceed === true`.
683 *
684 * @param {object} file object to add
685 * @returns {string} id for the added file
686 */
687 addFile (file) {
688 this._assertNewUploadAllowed(file)
689
690 const { files } = this.getState()
691 const newFile = this._checkAndCreateFileStateObject(files, file)
692
693 this.setState({
694 files: {
695 ...files,
696 [newFile.id]: newFile
697 }
698 })
699
700 this.emit('file-added', newFile)
701 this.emit('files-added', [newFile])
702 this.log(`Added file: ${newFile.name}, ${newFile.id}, mime type: ${newFile.type}`)
703
704 this._startIfAutoProceed()
705
706 return newFile.id
707 }
708
709 /**
710 * Add multiple files to `state.files`. See the `addFile()` documentation.
711 *
712 * This cuts some corners for performance, so should typically only be used in cases where there may be a lot of files.
713 *
714 * 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.
715 */
716 addFiles (fileDescriptors) {
717 this._assertNewUploadAllowed()
718
719 // create a copy of the files object only once
720 const files = { ...this.getState().files }
721 const newFiles = []
722 const errors = []
723 for (let i = 0; i < fileDescriptors.length; i++) {
724 try {
725 const newFile = this._checkAndCreateFileStateObject(files, fileDescriptors[i])
726 newFiles.push(newFile)
727 files[newFile.id] = newFile
728 } catch (err) {
729 if (!err.isRestriction) {
730 errors.push(err)
731 }
732 }
733 }
734
735 this.setState({ files })
736
737 newFiles.forEach((newFile) => {
738 this.emit('file-added', newFile)
739 })
740
741 this.emit('files-added', newFiles)
742
743 if (newFiles.length > 5) {
744 this.log(`Added batch of ${newFiles.length} files`)
745 } else {
746 Object.keys(newFiles).forEach(fileID => {
747 this.log(`Added file: ${newFiles[fileID].name}\n id: ${newFiles[fileID].id}\n type: ${newFiles[fileID].type}`)
748 })
749 }
750
751 if (newFiles.length > 0) {
752 this._startIfAutoProceed()
753 }
754
755 if (errors.length > 0) {
756 let message = 'Multiple errors occurred while adding files:\n'
757 errors.forEach((subError) => {
758 message += `\n * ${subError.message}`
759 })
760
761 this.info({
762 message: this.i18n('addBulkFilesFailed', { smart_count: errors.length }),
763 details: message
764 }, 'error', this.opts.infoTimeout)
765
766 const err = new Error(message)
767 err.errors = errors
768 throw err
769 }
770 }
771
772 removeFiles (fileIDs, reason) {
773 const { files, currentUploads } = this.getState()
774 const updatedFiles = { ...files }
775 const updatedUploads = { ...currentUploads }
776
777 const removedFiles = Object.create(null)
778 fileIDs.forEach((fileID) => {
779 if (files[fileID]) {
780 removedFiles[fileID] = files[fileID]
781 delete updatedFiles[fileID]
782 }
783 })
784
785 // Remove files from the `fileIDs` list in each upload.
786 function fileIsNotRemoved (uploadFileID) {
787 return removedFiles[uploadFileID] === undefined
788 }
789 const uploadsToRemove = []
790 Object.keys(updatedUploads).forEach((uploadID) => {
791 const newFileIDs = currentUploads[uploadID].fileIDs.filter(fileIsNotRemoved)
792
793 // Remove the upload if no files are associated with it anymore.
794 if (newFileIDs.length === 0) {
795 uploadsToRemove.push(uploadID)
796 return
797 }
798
799 updatedUploads[uploadID] = {
800 ...currentUploads[uploadID],
801 fileIDs: newFileIDs
802 }
803 })
804
805 uploadsToRemove.forEach((uploadID) => {
806 delete updatedUploads[uploadID]
807 })
808
809 const stateUpdate = {
810 currentUploads: updatedUploads,
811 files: updatedFiles
812 }
813
814 // If all files were removed - allow new uploads!
815 if (Object.keys(updatedFiles).length === 0) {
816 stateUpdate.allowNewUpload = true
817 stateUpdate.error = null
818 }
819
820 this.setState(stateUpdate)
821 this._calculateTotalProgress()
822
823 const removedFileIDs = Object.keys(removedFiles)
824 removedFileIDs.forEach((fileID) => {
825 this.emit('file-removed', removedFiles[fileID], reason)
826 })
827
828 if (removedFileIDs.length > 5) {
829 this.log(`Removed ${removedFileIDs.length} files`)
830 } else {
831 this.log(`Removed files: ${removedFileIDs.join(', ')}`)
832 }
833 }
834
835 removeFile (fileID, reason = null) {
836 this.removeFiles([fileID], reason)
837 }
838
839 pauseResume (fileID) {
840 if (!this.getState().capabilities.resumableUploads ||
841 this.getFile(fileID).uploadComplete) {
842 return
843 }
844
845 const wasPaused = this.getFile(fileID).isPaused || false
846 const isPaused = !wasPaused
847
848 this.setFileState(fileID, {
849 isPaused: isPaused
850 })
851
852 this.emit('upload-pause', fileID, isPaused)
853
854 return isPaused
855 }
856
857 pauseAll () {
858 const updatedFiles = Object.assign({}, this.getState().files)
859 const inProgressUpdatedFiles = Object.keys(updatedFiles).filter((file) => {
860 return !updatedFiles[file].progress.uploadComplete &&
861 updatedFiles[file].progress.uploadStarted
862 })
863
864 inProgressUpdatedFiles.forEach((file) => {
865 const updatedFile = Object.assign({}, updatedFiles[file], {
866 isPaused: true
867 })
868 updatedFiles[file] = updatedFile
869 })
870
871 this.setState({ files: updatedFiles })
872 this.emit('pause-all')
873 }
874
875 resumeAll () {
876 const updatedFiles = Object.assign({}, this.getState().files)
877 const inProgressUpdatedFiles = Object.keys(updatedFiles).filter((file) => {
878 return !updatedFiles[file].progress.uploadComplete &&
879 updatedFiles[file].progress.uploadStarted
880 })
881
882 inProgressUpdatedFiles.forEach((file) => {
883 const updatedFile = Object.assign({}, updatedFiles[file], {
884 isPaused: false,
885 error: null
886 })
887 updatedFiles[file] = updatedFile
888 })
889 this.setState({ files: updatedFiles })
890
891 this.emit('resume-all')
892 }
893
894 retryAll () {
895 const updatedFiles = Object.assign({}, this.getState().files)
896 const filesToRetry = Object.keys(updatedFiles).filter(file => {
897 return updatedFiles[file].error
898 })
899
900 filesToRetry.forEach((file) => {
901 const updatedFile = Object.assign({}, updatedFiles[file], {
902 isPaused: false,
903 error: null
904 })
905 updatedFiles[file] = updatedFile
906 })
907 this.setState({
908 files: updatedFiles,
909 error: null
910 })
911
912 this.emit('retry-all', filesToRetry)
913
914 if (filesToRetry.length === 0) {
915 return Promise.resolve({
916 successful: [],
917 failed: []
918 })
919 }
920
921 const uploadID = this._createUpload(filesToRetry, {
922 forceAllowNewUpload: true // create new upload even if allowNewUpload: false
923 })
924 return this._runUpload(uploadID)
925 }
926
927 cancelAll () {
928 this.emit('cancel-all')
929
930 const { files } = this.getState()
931
932 const fileIDs = Object.keys(files)
933 if (fileIDs.length) {
934 this.removeFiles(fileIDs, 'cancel-all')
935 }
936
937 this.setState({
938 totalProgress: 0,
939 error: null
940 })
941 }
942
943 retryUpload (fileID) {
944 this.setFileState(fileID, {
945 error: null,
946 isPaused: false
947 })
948
949 this.emit('upload-retry', fileID)
950
951 const uploadID = this._createUpload([fileID], {
952 forceAllowNewUpload: true // create new upload even if allowNewUpload: false
953 })
954 return this._runUpload(uploadID)
955 }
956
957 reset () {
958 this.cancelAll()
959 }
960
961 _calculateProgress (file, data) {
962 if (!this.getFile(file.id)) {
963 this.log(`Not setting progress for a file that has been removed: ${file.id}`)
964 return
965 }
966
967 // bytesTotal may be null or zero; in that case we can't divide by it
968 const canHavePercentage = isFinite(data.bytesTotal) && data.bytesTotal > 0
969 this.setFileState(file.id, {
970 progress: {
971 ...this.getFile(file.id).progress,
972 bytesUploaded: data.bytesUploaded,
973 bytesTotal: data.bytesTotal,
974 percentage: canHavePercentage
975 // TODO(goto-bus-stop) flooring this should probably be the choice of the UI?
976 // we get more accurate calculations if we don't round this at all.
977 ? Math.round(data.bytesUploaded / data.bytesTotal * 100)
978 : 0
979 }
980 })
981
982 this._calculateTotalProgress()
983 }
984
985 _calculateTotalProgress () {
986 // calculate total progress, using the number of files currently uploading,
987 // multiplied by 100 and the summ of individual progress of each file
988 const files = this.getFiles()
989
990 const inProgress = files.filter((file) => {
991 return file.progress.uploadStarted ||
992 file.progress.preprocess ||
993 file.progress.postprocess
994 })
995
996 if (inProgress.length === 0) {
997 this.emit('progress', 0)
998 this.setState({ totalProgress: 0 })
999 return
1000 }
1001
1002 const sizedFiles = inProgress.filter((file) => file.progress.bytesTotal != null)
1003 const unsizedFiles = inProgress.filter((file) => file.progress.bytesTotal == null)
1004
1005 if (sizedFiles.length === 0) {
1006 const progressMax = inProgress.length * 100
1007 const currentProgress = unsizedFiles.reduce((acc, file) => {
1008 return acc + file.progress.percentage
1009 }, 0)
1010 const totalProgress = Math.round(currentProgress / progressMax * 100)
1011 this.setState({ totalProgress })
1012 return
1013 }
1014
1015 let totalSize = sizedFiles.reduce((acc, file) => {
1016 return acc + file.progress.bytesTotal
1017 }, 0)
1018 const averageSize = totalSize / sizedFiles.length
1019 totalSize += averageSize * unsizedFiles.length
1020
1021 let uploadedSize = 0
1022 sizedFiles.forEach((file) => {
1023 uploadedSize += file.progress.bytesUploaded
1024 })
1025 unsizedFiles.forEach((file) => {
1026 uploadedSize += averageSize * (file.progress.percentage || 0) / 100
1027 })
1028
1029 let totalProgress = totalSize === 0
1030 ? 0
1031 : Math.round(uploadedSize / totalSize * 100)
1032
1033 // hot fix, because:
1034 // uploadedSize ended up larger than totalSize, resulting in 1325% total
1035 if (totalProgress > 100) {
1036 totalProgress = 100
1037 }
1038
1039 this.setState({ totalProgress })
1040 this.emit('progress', totalProgress)
1041 }
1042
1043 /**
1044 * Registers listeners for all global actions, like:
1045 * `error`, `file-removed`, `upload-progress`
1046 */
1047 _addListeners () {
1048 this.on('error', (error) => {
1049 let errorMsg = 'Unknown error'
1050 if (error.message) {
1051 errorMsg = error.message
1052 }
1053
1054 if (error.details) {
1055 errorMsg += ' ' + error.details
1056 }
1057
1058 this.setState({ error: errorMsg })
1059 })
1060
1061 this.on('upload-error', (file, error, response) => {
1062 let errorMsg = 'Unknown error'
1063 if (error.message) {
1064 errorMsg = error.message
1065 }
1066
1067 if (error.details) {
1068 errorMsg += ' ' + error.details
1069 }
1070
1071 this.setFileState(file.id, {
1072 error: errorMsg,
1073 response
1074 })
1075
1076 this.setState({ error: error.message })
1077
1078 if (typeof error === 'object' && error.message) {
1079 const newError = new Error(error.message)
1080 newError.details = error.message
1081 if (error.details) {
1082 newError.details += ' ' + error.details
1083 }
1084 newError.message = this.i18n('failedToUpload', { file: file.name })
1085 this._showOrLogErrorAndThrow(newError, {
1086 throwErr: false
1087 })
1088 } else {
1089 this._showOrLogErrorAndThrow(error, {
1090 throwErr: false
1091 })
1092 }
1093 })
1094
1095 this.on('upload', () => {
1096 this.setState({ error: null })
1097 })
1098
1099 this.on('upload-started', (file, upload) => {
1100 if (!this.getFile(file.id)) {
1101 this.log(`Not setting progress for a file that has been removed: ${file.id}`)
1102 return
1103 }
1104 this.setFileState(file.id, {
1105 progress: {
1106 uploadStarted: Date.now(),
1107 uploadComplete: false,
1108 percentage: 0,
1109 bytesUploaded: 0,
1110 bytesTotal: file.size
1111 }
1112 })
1113 })
1114
1115 this.on('upload-progress', this._calculateProgress)
1116
1117 this.on('upload-success', (file, uploadResp) => {
1118 if (!this.getFile(file.id)) {
1119 this.log(`Not setting progress for a file that has been removed: ${file.id}`)
1120 return
1121 }
1122
1123 const currentProgress = this.getFile(file.id).progress
1124 this.setFileState(file.id, {
1125 progress: Object.assign({}, currentProgress, {
1126 postprocess: this.postProcessors.length > 0 ? {
1127 mode: 'indeterminate'
1128 } : null,
1129 uploadComplete: true,
1130 percentage: 100,
1131 bytesUploaded: currentProgress.bytesTotal
1132 }),
1133 response: uploadResp,
1134 uploadURL: uploadResp.uploadURL,
1135 isPaused: false
1136 })
1137
1138 this._calculateTotalProgress()
1139 })
1140
1141 this.on('preprocess-progress', (file, progress) => {
1142 if (!this.getFile(file.id)) {
1143 this.log(`Not setting progress for a file that has been removed: ${file.id}`)
1144 return
1145 }
1146 this.setFileState(file.id, {
1147 progress: Object.assign({}, this.getFile(file.id).progress, {
1148 preprocess: progress
1149 })
1150 })
1151 })
1152
1153 this.on('preprocess-complete', (file) => {
1154 if (!this.getFile(file.id)) {
1155 this.log(`Not setting progress for a file that has been removed: ${file.id}`)
1156 return
1157 }
1158 const files = Object.assign({}, this.getState().files)
1159 files[file.id] = Object.assign({}, files[file.id], {
1160 progress: Object.assign({}, files[file.id].progress)
1161 })
1162 delete files[file.id].progress.preprocess
1163
1164 this.setState({ files: files })
1165 })
1166
1167 this.on('postprocess-progress', (file, progress) => {
1168 if (!this.getFile(file.id)) {
1169 this.log(`Not setting progress for a file that has been removed: ${file.id}`)
1170 return
1171 }
1172 this.setFileState(file.id, {
1173 progress: Object.assign({}, this.getState().files[file.id].progress, {
1174 postprocess: progress
1175 })
1176 })
1177 })
1178
1179 this.on('postprocess-complete', (file) => {
1180 if (!this.getFile(file.id)) {
1181 this.log(`Not setting progress for a file that has been removed: ${file.id}`)
1182 return
1183 }
1184 const files = {
1185 ...this.getState().files
1186 }
1187 files[file.id] = {
1188 ...files[file.id],
1189 progress: {
1190 ...files[file.id].progress
1191 }
1192 }
1193 delete files[file.id].progress.postprocess
1194 // TODO should we set some kind of `fullyComplete` property on the file object
1195 // so it's easier to see that the file is upload…fully complete…rather than
1196 // what we have to do now (`uploadComplete && !postprocess`)
1197
1198 this.setState({ files: files })
1199 })
1200
1201 this.on('restored', () => {
1202 // Files may have changed--ensure progress is still accurate.
1203 this._calculateTotalProgress()
1204 })
1205
1206 // show informer if offline
1207 if (typeof window !== 'undefined' && window.addEventListener) {
1208 window.addEventListener('online', () => this.updateOnlineStatus())
1209 window.addEventListener('offline', () => this.updateOnlineStatus())
1210 setTimeout(() => this.updateOnlineStatus(), 3000)
1211 }
1212 }
1213
1214 updateOnlineStatus () {
1215 const online =
1216 typeof window.navigator.onLine !== 'undefined'
1217 ? window.navigator.onLine
1218 : true
1219 if (!online) {
1220 this.emit('is-offline')
1221 this.info(this.i18n('noInternetConnection'), 'error', 0)
1222 this.wasOffline = true
1223 } else {
1224 this.emit('is-online')
1225 if (this.wasOffline) {
1226 this.emit('back-online')
1227 this.info(this.i18n('connectedToInternet'), 'success', 3000)
1228 this.wasOffline = false
1229 }
1230 }
1231 }
1232
1233 getID () {
1234 return this.opts.id
1235 }
1236
1237 /**
1238 * Registers a plugin with Core.
1239 *
1240 * @param {object} Plugin object
1241 * @param {object} [opts] object with options to be passed to Plugin
1242 * @returns {object} self for chaining
1243 */
1244 use (Plugin, opts) {
1245 if (typeof Plugin !== 'function') {
1246 const msg = `Expected a plugin class, but got ${Plugin === null ? 'null' : typeof Plugin}.` +
1247 ' Please verify that the plugin was imported and spelled correctly.'
1248 throw new TypeError(msg)
1249 }
1250
1251 // Instantiate
1252 const plugin = new Plugin(this, opts)
1253 const pluginId = plugin.id
1254 this.plugins[plugin.type] = this.plugins[plugin.type] || []
1255
1256 if (!pluginId) {
1257 throw new Error('Your plugin must have an id')
1258 }
1259
1260 if (!plugin.type) {
1261 throw new Error('Your plugin must have a type')
1262 }
1263
1264 const existsPluginAlready = this.getPlugin(pluginId)
1265 if (existsPluginAlready) {
1266 const msg = `Already found a plugin named '${existsPluginAlready.id}'. ` +
1267 `Tried to use: '${pluginId}'.\n` +
1268 'Uppy plugins must have unique `id` options. See https://uppy.io/docs/plugins/#id.'
1269 throw new Error(msg)
1270 }
1271
1272 if (Plugin.VERSION) {
1273 this.log(`Using ${pluginId} v${Plugin.VERSION}`)
1274 }
1275
1276 this.plugins[plugin.type].push(plugin)
1277 plugin.install()
1278
1279 return this
1280 }
1281
1282 /**
1283 * Find one Plugin by name.
1284 *
1285 * @param {string} id plugin id
1286 * @returns {object|boolean}
1287 */
1288 getPlugin (id) {
1289 let foundPlugin = null
1290 this.iteratePlugins((plugin) => {
1291 if (plugin.id === id) {
1292 foundPlugin = plugin
1293 return false
1294 }
1295 })
1296 return foundPlugin
1297 }
1298
1299 /**
1300 * Iterate through all `use`d plugins.
1301 *
1302 * @param {Function} method that will be run on each plugin
1303 */
1304 iteratePlugins (method) {
1305 Object.keys(this.plugins).forEach(pluginType => {
1306 this.plugins[pluginType].forEach(method)
1307 })
1308 }
1309
1310 /**
1311 * Uninstall and remove a plugin.
1312 *
1313 * @param {object} instance The plugin instance to remove.
1314 */
1315 removePlugin (instance) {
1316 this.log(`Removing plugin ${instance.id}`)
1317 this.emit('plugin-remove', instance)
1318
1319 if (instance.uninstall) {
1320 instance.uninstall()
1321 }
1322
1323 const list = this.plugins[instance.type].slice()
1324 const index = list.indexOf(instance)
1325 if (index !== -1) {
1326 list.splice(index, 1)
1327 this.plugins[instance.type] = list
1328 }
1329
1330 const updatedState = this.getState()
1331 delete updatedState.plugins[instance.id]
1332 this.setState(updatedState)
1333 }
1334
1335 /**
1336 * Uninstall all plugins and close down this Uppy instance.
1337 */
1338 close () {
1339 this.log(`Closing Uppy instance ${this.opts.id}: removing all files and uninstalling plugins`)
1340
1341 this.reset()
1342
1343 this._storeUnsubscribe()
1344
1345 this.iteratePlugins((plugin) => {
1346 this.removePlugin(plugin)
1347 })
1348 }
1349
1350 /**
1351 * Set info message in `state.info`, so that UI plugins like `Informer`
1352 * can display the message.
1353 *
1354 * @param {string | object} message Message to be displayed by the informer
1355 * @param {string} [type]
1356 * @param {number} [duration]
1357 */
1358
1359 info (message, type = 'info', duration = 3000) {
1360 const isComplexMessage = typeof message === 'object'
1361
1362 this.setState({
1363 info: {
1364 isHidden: false,
1365 type: type,
1366 message: isComplexMessage ? message.message : message,
1367 details: isComplexMessage ? message.details : null
1368 }
1369 })
1370
1371 this.emit('info-visible')
1372
1373 clearTimeout(this.infoTimeoutID)
1374 if (duration === 0) {
1375 this.infoTimeoutID = undefined
1376 return
1377 }
1378
1379 // hide the informer after `duration` milliseconds
1380 this.infoTimeoutID = setTimeout(this.hideInfo, duration)
1381 }
1382
1383 hideInfo () {
1384 const newInfo = Object.assign({}, this.getState().info, {
1385 isHidden: true
1386 })
1387 this.setState({
1388 info: newInfo
1389 })
1390 this.emit('info-hidden')
1391 }
1392
1393 /**
1394 * Passes messages to a function, provided in `opts.logger`.
1395 * If `opts.logger: Uppy.debugLogger` or `opts.debug: true`, logs to the browser console.
1396 *
1397 * @param {string|object} message to log
1398 * @param {string} [type] optional `error` or `warning`
1399 */
1400 log (message, type) {
1401 const { logger } = this.opts
1402 switch (type) {
1403 case 'error': logger.error(message); break
1404 case 'warning': logger.warn(message); break
1405 default: logger.debug(message); break
1406 }
1407 }
1408
1409 /**
1410 * Obsolete, event listeners are now added in the constructor.
1411 */
1412 run () {
1413 this.log('Calling run() is no longer necessary.', 'warning')
1414 return this
1415 }
1416
1417 /**
1418 * Restore an upload by its ID.
1419 */
1420 restore (uploadID) {
1421 this.log(`Core: attempting to restore upload "${uploadID}"`)
1422
1423 if (!this.getState().currentUploads[uploadID]) {
1424 this._removeUpload(uploadID)
1425 return Promise.reject(new Error('Nonexistent upload'))
1426 }
1427
1428 return this._runUpload(uploadID)
1429 }
1430
1431 /**
1432 * Create an upload for a bunch of files.
1433 *
1434 * @param {Array<string>} fileIDs File IDs to include in this upload.
1435 * @returns {string} ID of this upload.
1436 */
1437 _createUpload (fileIDs, opts = {}) {
1438 const {
1439 forceAllowNewUpload = false // uppy.retryAll sets this to true — when retrying we want to ignore `allowNewUpload: false`
1440 } = opts
1441
1442 const { allowNewUpload, currentUploads } = this.getState()
1443 if (!allowNewUpload && !forceAllowNewUpload) {
1444 throw new Error('Cannot create a new upload: already uploading.')
1445 }
1446
1447 const uploadID = cuid()
1448
1449 this.emit('upload', {
1450 id: uploadID,
1451 fileIDs: fileIDs
1452 })
1453
1454 this.setState({
1455 allowNewUpload: this.opts.allowMultipleUploads !== false,
1456
1457 currentUploads: {
1458 ...currentUploads,
1459 [uploadID]: {
1460 fileIDs: fileIDs,
1461 step: 0,
1462 result: {}
1463 }
1464 }
1465 })
1466
1467 return uploadID
1468 }
1469
1470 _getUpload (uploadID) {
1471 const { currentUploads } = this.getState()
1472
1473 return currentUploads[uploadID]
1474 }
1475
1476 /**
1477 * Add data to an upload's result object.
1478 *
1479 * @param {string} uploadID The ID of the upload.
1480 * @param {object} data Data properties to add to the result object.
1481 */
1482 addResultData (uploadID, data) {
1483 if (!this._getUpload(uploadID)) {
1484 this.log(`Not setting result for an upload that has been removed: ${uploadID}`)
1485 return
1486 }
1487 const currentUploads = this.getState().currentUploads
1488 const currentUpload = Object.assign({}, currentUploads[uploadID], {
1489 result: Object.assign({}, currentUploads[uploadID].result, data)
1490 })
1491 this.setState({
1492 currentUploads: Object.assign({}, currentUploads, {
1493 [uploadID]: currentUpload
1494 })
1495 })
1496 }
1497
1498 /**
1499 * Remove an upload, eg. if it has been canceled or completed.
1500 *
1501 * @param {string} uploadID The ID of the upload.
1502 */
1503 _removeUpload (uploadID) {
1504 const currentUploads = { ...this.getState().currentUploads }
1505 delete currentUploads[uploadID]
1506
1507 this.setState({
1508 currentUploads: currentUploads
1509 })
1510 }
1511
1512 /**
1513 * Run an upload. This picks up where it left off in case the upload is being restored.
1514 *
1515 * @private
1516 */
1517 _runUpload (uploadID) {
1518 const uploadData = this.getState().currentUploads[uploadID]
1519 const restoreStep = uploadData.step
1520
1521 const steps = [
1522 ...this.preProcessors,
1523 ...this.uploaders,
1524 ...this.postProcessors
1525 ]
1526 let lastStep = Promise.resolve()
1527 steps.forEach((fn, step) => {
1528 // Skip this step if we are restoring and have already completed this step before.
1529 if (step < restoreStep) {
1530 return
1531 }
1532
1533 lastStep = lastStep.then(() => {
1534 const { currentUploads } = this.getState()
1535 const currentUpload = currentUploads[uploadID]
1536 if (!currentUpload) {
1537 return
1538 }
1539
1540 const updatedUpload = {
1541 ...currentUpload,
1542 step
1543 }
1544
1545 this.setState({
1546 currentUploads: {
1547 ...currentUploads,
1548 [uploadID]: updatedUpload
1549 }
1550 })
1551
1552 // TODO give this the `updatedUpload` object as its only parameter maybe?
1553 // Otherwise when more metadata may be added to the upload this would keep getting more parameters
1554 return fn(updatedUpload.fileIDs, uploadID)
1555 }).then((result) => {
1556 return null
1557 })
1558 })
1559
1560 // Not returning the `catch`ed promise, because we still want to return a rejected
1561 // promise from this method if the upload failed.
1562 lastStep.catch((err) => {
1563 this.emit('error', err, uploadID)
1564 this._removeUpload(uploadID)
1565 })
1566
1567 return lastStep.then(() => {
1568 // Set result data.
1569 const { currentUploads } = this.getState()
1570 const currentUpload = currentUploads[uploadID]
1571 if (!currentUpload) {
1572 return
1573 }
1574
1575 // Mark postprocessing step as complete if necessary; this addresses a case where we might get
1576 // stuck in the postprocessing UI while the upload is fully complete.
1577 // If the postprocessing steps do not do any work, they may not emit postprocessing events at
1578 // all, and never mark the postprocessing as complete. This is fine on its own but we
1579 // introduced code in the @uppy/core upload-success handler to prepare postprocessing progress
1580 // state if any postprocessors are registered. That is to avoid a "flash of completed state"
1581 // before the postprocessing plugins can emit events.
1582 //
1583 // So, just in case an upload with postprocessing plugins *has* completed *without* emitting
1584 // postprocessing completion, we do it instead.
1585 currentUpload.fileIDs.forEach((fileID) => {
1586 const file = this.getFile(fileID)
1587 if (file && file.progress.postprocess) {
1588 this.emit('postprocess-complete', file)
1589 }
1590 })
1591
1592 const files = currentUpload.fileIDs.map((fileID) => this.getFile(fileID))
1593 const successful = files.filter((file) => !file.error)
1594 const failed = files.filter((file) => file.error)
1595 this.addResultData(uploadID, { successful, failed, uploadID })
1596 }).then(() => {
1597 // Emit completion events.
1598 // This is in a separate function so that the `currentUploads` variable
1599 // always refers to the latest state. In the handler right above it refers
1600 // to an outdated object without the `.result` property.
1601 const { currentUploads } = this.getState()
1602 if (!currentUploads[uploadID]) {
1603 return
1604 }
1605 const currentUpload = currentUploads[uploadID]
1606 const result = currentUpload.result
1607 this.emit('complete', result)
1608
1609 this._removeUpload(uploadID)
1610
1611 return result
1612 }).then((result) => {
1613 if (result == null) {
1614 this.log(`Not setting result for an upload that has been removed: ${uploadID}`)
1615 }
1616 return result
1617 })
1618 }
1619
1620 /**
1621 * Start an upload for all the files that are not currently being uploaded.
1622 *
1623 * @returns {Promise}
1624 */
1625 upload () {
1626 if (!this.plugins.uploader) {
1627 this.log('No uploader type plugins are used', 'warning')
1628 }
1629
1630 let files = this.getState().files
1631
1632 const onBeforeUploadResult = this.opts.onBeforeUpload(files)
1633
1634 if (onBeforeUploadResult === false) {
1635 return Promise.reject(new Error('Not starting the upload because onBeforeUpload returned false'))
1636 }
1637
1638 if (onBeforeUploadResult && typeof onBeforeUploadResult === 'object') {
1639 files = onBeforeUploadResult
1640 // Updating files in state, because uploader plugins receive file IDs,
1641 // and then fetch the actual file object from state
1642 this.setState({
1643 files: files
1644 })
1645 }
1646
1647 return Promise.resolve()
1648 .then(() => this._checkMinNumberOfFiles(files))
1649 .catch((err) => {
1650 this._showOrLogErrorAndThrow(err)
1651 })
1652 .then(() => {
1653 const { currentUploads } = this.getState()
1654 // get a list of files that are currently assigned to uploads
1655 const currentlyUploadingFiles = Object.keys(currentUploads).reduce((prev, curr) => prev.concat(currentUploads[curr].fileIDs), [])
1656
1657 const waitingFileIDs = []
1658 Object.keys(files).forEach((fileID) => {
1659 const file = this.getFile(fileID)
1660 // if the file hasn't started uploading and hasn't already been assigned to an upload..
1661 if ((!file.progress.uploadStarted) && (currentlyUploadingFiles.indexOf(fileID) === -1)) {
1662 waitingFileIDs.push(file.id)
1663 }
1664 })
1665
1666 const uploadID = this._createUpload(waitingFileIDs)
1667 return this._runUpload(uploadID)
1668 })
1669 .catch((err) => {
1670 this._showOrLogErrorAndThrow(err, {
1671 showInformer: false
1672 })
1673 })
1674 }
1675}
1676
1677module.exports = function (opts) {
1678 return new Uppy(opts)
1679}
1680
1681// Expose class constructor.
1682module.exports.Uppy = Uppy
1683module.exports.Plugin = Plugin
1684module.exports.debugLogger = debugLogger