UNPKG

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