index.js 39 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347
  1. const Translator = require('@uppy/utils/lib/Translator')
  2. const ee = require('namespace-emitter')
  3. const cuid = require('cuid')
  4. const throttle = require('lodash.throttle')
  5. const prettyBytes = require('@uppy/utils/lib/prettyBytes')
  6. const match = require('mime-match')
  7. const DefaultStore = require('@uppy/store-default')
  8. const getFileType = require('@uppy/utils/lib/getFileType')
  9. const getFileNameAndExtension = require('@uppy/utils/lib/getFileNameAndExtension')
  10. const generateFileID = require('@uppy/utils/lib/generateFileID')
  11. const supportsUploadProgress = require('./supportsUploadProgress')
  12. const { nullLogger, debugLogger } = require('./loggers')
  13. const Plugin = require('./Plugin') // Exported from here.
  14. class RestrictionError extends Error {
  15. constructor (...args) {
  16. super(...args)
  17. this.isRestriction = true
  18. }
  19. }
  20. /**
  21. * Uppy Core module.
  22. * Manages plugins, state updates, acts as an event bus,
  23. * adds/removes files and metadata.
  24. */
  25. class Uppy {
  26. static VERSION = require('../package.json').version
  27. /**
  28. * Instantiate Uppy
  29. *
  30. * @param {object} opts — Uppy options
  31. */
  32. constructor (opts) {
  33. this.defaultLocale = {
  34. strings: {
  35. youCanOnlyUploadX: {
  36. 0: 'You can only upload %{smart_count} file',
  37. 1: 'You can only upload %{smart_count} files',
  38. 2: 'You can only upload %{smart_count} files'
  39. },
  40. youHaveToAtLeastSelectX: {
  41. 0: 'You have to select at least %{smart_count} file',
  42. 1: 'You have to select at least %{smart_count} files',
  43. 2: 'You have to select at least %{smart_count} files'
  44. },
  45. exceedsSize: 'This file exceeds maximum allowed size of',
  46. youCanOnlyUploadFileTypes: 'You can only upload: %{types}',
  47. companionError: 'Connection with Companion failed',
  48. companionAuthError: 'Authorization required',
  49. companionUnauthorizeHint: 'To unauthorize to your %{provider} account, please go to %{url}',
  50. failedToUpload: 'Failed to upload %{file}',
  51. noInternetConnection: 'No Internet connection',
  52. connectedToInternet: 'Connected to the Internet',
  53. // Strings for remote providers
  54. noFilesFound: 'You have no files or folders here',
  55. selectX: {
  56. 0: 'Select %{smart_count}',
  57. 1: 'Select %{smart_count}',
  58. 2: 'Select %{smart_count}'
  59. },
  60. selectAllFilesFromFolderNamed: 'Select all files from folder %{name}',
  61. unselectAllFilesFromFolderNamed: 'Unselect all files from folder %{name}',
  62. selectFileNamed: 'Select file %{name}',
  63. unselectFileNamed: 'Unselect file %{name}',
  64. openFolderNamed: 'Open folder %{name}',
  65. cancel: 'Cancel',
  66. logOut: 'Log out',
  67. filter: 'Filter',
  68. resetFilter: 'Reset filter',
  69. loading: 'Loading...',
  70. authenticateWithTitle: 'Please authenticate with %{pluginName} to select files',
  71. authenticateWith: 'Connect to %{pluginName}',
  72. emptyFolderAdded: 'No files were added from empty folder',
  73. folderAdded: {
  74. 0: 'Added %{smart_count} file from %{folder}',
  75. 1: 'Added %{smart_count} files from %{folder}',
  76. 2: 'Added %{smart_count} files from %{folder}'
  77. }
  78. }
  79. }
  80. // set default options
  81. const defaultOptions = {
  82. id: 'uppy',
  83. autoProceed: false,
  84. allowMultipleUploads: true,
  85. debug: false,
  86. restrictions: {
  87. maxFileSize: null,
  88. maxNumberOfFiles: null,
  89. minNumberOfFiles: null,
  90. allowedFileTypes: null
  91. },
  92. meta: {},
  93. onBeforeFileAdded: (currentFile, files) => currentFile,
  94. onBeforeUpload: (files) => files,
  95. store: DefaultStore(),
  96. logger: nullLogger
  97. }
  98. // Merge default options with the ones set by user
  99. this.opts = Object.assign({}, defaultOptions, opts)
  100. this.opts.restrictions = Object.assign({}, defaultOptions.restrictions, this.opts.restrictions)
  101. // Support debug: true for backwards-compatability, unless logger is set in opts
  102. // opts instead of this.opts to avoid comparing objects — we set logger: nullLogger in defaultOptions
  103. if (opts && opts.logger && opts.debug) {
  104. 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')
  105. } else if (opts && opts.debug) {
  106. this.opts.logger = debugLogger
  107. }
  108. this.log(`Using Core v${this.constructor.VERSION}`)
  109. if (this.opts.restrictions.allowedFileTypes &&
  110. this.opts.restrictions.allowedFileTypes !== null &&
  111. !Array.isArray(this.opts.restrictions.allowedFileTypes)) {
  112. throw new TypeError('`restrictions.allowedFileTypes` must be an array')
  113. }
  114. // i18n
  115. this.translator = new Translator([this.defaultLocale, this.opts.locale])
  116. this.locale = this.translator.locale
  117. this.i18n = this.translator.translate.bind(this.translator)
  118. this.i18nArray = this.translator.translateArray.bind(this.translator)
  119. // Container for different types of plugins
  120. this.plugins = {}
  121. this.getState = this.getState.bind(this)
  122. this.getPlugin = this.getPlugin.bind(this)
  123. this.setFileMeta = this.setFileMeta.bind(this)
  124. this.setFileState = this.setFileState.bind(this)
  125. this.log = this.log.bind(this)
  126. this.info = this.info.bind(this)
  127. this.hideInfo = this.hideInfo.bind(this)
  128. this.addFile = this.addFile.bind(this)
  129. this.removeFile = this.removeFile.bind(this)
  130. this.pauseResume = this.pauseResume.bind(this)
  131. // ___Why throttle at 500ms?
  132. // - 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).
  133. // [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.
  134. // - We must throttle at around >500ms to avoid performance lags.
  135. // [Practical Check] Firefox, try to upload a big file for a prolonged period of time. Laptop will start to heat up.
  136. this._calculateProgress = throttle(this._calculateProgress.bind(this), 500, { leading: true, trailing: true })
  137. this.updateOnlineStatus = this.updateOnlineStatus.bind(this)
  138. this.resetProgress = this.resetProgress.bind(this)
  139. this.pauseAll = this.pauseAll.bind(this)
  140. this.resumeAll = this.resumeAll.bind(this)
  141. this.retryAll = this.retryAll.bind(this)
  142. this.cancelAll = this.cancelAll.bind(this)
  143. this.retryUpload = this.retryUpload.bind(this)
  144. this.upload = this.upload.bind(this)
  145. this.emitter = ee()
  146. this.on = this.on.bind(this)
  147. this.off = this.off.bind(this)
  148. this.once = this.emitter.once.bind(this.emitter)
  149. this.emit = this.emitter.emit.bind(this.emitter)
  150. this.preProcessors = []
  151. this.uploaders = []
  152. this.postProcessors = []
  153. this.store = this.opts.store
  154. this.setState({
  155. plugins: {},
  156. files: {},
  157. currentUploads: {},
  158. allowNewUpload: true,
  159. capabilities: {
  160. uploadProgress: supportsUploadProgress(),
  161. individualCancellation: true,
  162. resumableUploads: false
  163. },
  164. totalProgress: 0,
  165. meta: { ...this.opts.meta },
  166. info: {
  167. isHidden: true,
  168. type: 'info',
  169. message: ''
  170. }
  171. })
  172. this._storeUnsubscribe = this.store.subscribe((prevState, nextState, patch) => {
  173. this.emit('state-update', prevState, nextState, patch)
  174. this.updateAll(nextState)
  175. })
  176. // Exposing uppy object on window for debugging and testing
  177. if (this.opts.debug && typeof window !== 'undefined') {
  178. window[this.opts.id] = this
  179. }
  180. this._addListeners()
  181. }
  182. on (event, callback) {
  183. this.emitter.on(event, callback)
  184. return this
  185. }
  186. off (event, callback) {
  187. this.emitter.off(event, callback)
  188. return this
  189. }
  190. /**
  191. * Iterate on all plugins and run `update` on them.
  192. * Called each time state changes.
  193. *
  194. */
  195. updateAll (state) {
  196. this.iteratePlugins(plugin => {
  197. plugin.update(state)
  198. })
  199. }
  200. /**
  201. * Updates state with a patch
  202. *
  203. * @param {object} patch {foo: 'bar'}
  204. */
  205. setState (patch) {
  206. this.store.setState(patch)
  207. }
  208. /**
  209. * Returns current state.
  210. *
  211. * @returns {object}
  212. */
  213. getState () {
  214. return this.store.getState()
  215. }
  216. /**
  217. * Back compat for when uppy.state is used instead of uppy.getState().
  218. */
  219. get state () {
  220. return this.getState()
  221. }
  222. /**
  223. * Shorthand to set state for a specific file.
  224. */
  225. setFileState (fileID, state) {
  226. if (!this.getState().files[fileID]) {
  227. throw new Error(`Can’t set state for ${fileID} (the file could have been removed)`)
  228. }
  229. this.setState({
  230. files: Object.assign({}, this.getState().files, {
  231. [fileID]: Object.assign({}, this.getState().files[fileID], state)
  232. })
  233. })
  234. }
  235. resetProgress () {
  236. const defaultProgress = {
  237. percentage: 0,
  238. bytesUploaded: 0,
  239. uploadComplete: false,
  240. uploadStarted: null
  241. }
  242. const files = Object.assign({}, this.getState().files)
  243. const updatedFiles = {}
  244. Object.keys(files).forEach(fileID => {
  245. const updatedFile = Object.assign({}, files[fileID])
  246. updatedFile.progress = Object.assign({}, updatedFile.progress, defaultProgress)
  247. updatedFiles[fileID] = updatedFile
  248. })
  249. this.setState({
  250. files: updatedFiles,
  251. totalProgress: 0
  252. })
  253. // TODO Document on the website
  254. this.emit('reset-progress')
  255. }
  256. addPreProcessor (fn) {
  257. this.preProcessors.push(fn)
  258. }
  259. removePreProcessor (fn) {
  260. const i = this.preProcessors.indexOf(fn)
  261. if (i !== -1) {
  262. this.preProcessors.splice(i, 1)
  263. }
  264. }
  265. addPostProcessor (fn) {
  266. this.postProcessors.push(fn)
  267. }
  268. removePostProcessor (fn) {
  269. const i = this.postProcessors.indexOf(fn)
  270. if (i !== -1) {
  271. this.postProcessors.splice(i, 1)
  272. }
  273. }
  274. addUploader (fn) {
  275. this.uploaders.push(fn)
  276. }
  277. removeUploader (fn) {
  278. const i = this.uploaders.indexOf(fn)
  279. if (i !== -1) {
  280. this.uploaders.splice(i, 1)
  281. }
  282. }
  283. setMeta (data) {
  284. const updatedMeta = Object.assign({}, this.getState().meta, data)
  285. const updatedFiles = Object.assign({}, this.getState().files)
  286. Object.keys(updatedFiles).forEach((fileID) => {
  287. updatedFiles[fileID] = Object.assign({}, updatedFiles[fileID], {
  288. meta: Object.assign({}, updatedFiles[fileID].meta, data)
  289. })
  290. })
  291. this.log('Adding metadata:')
  292. this.log(data)
  293. this.setState({
  294. meta: updatedMeta,
  295. files: updatedFiles
  296. })
  297. }
  298. setFileMeta (fileID, data) {
  299. const updatedFiles = Object.assign({}, this.getState().files)
  300. if (!updatedFiles[fileID]) {
  301. this.log('Was trying to set metadata for a file that has been removed: ', fileID)
  302. return
  303. }
  304. const newMeta = Object.assign({}, updatedFiles[fileID].meta, data)
  305. updatedFiles[fileID] = Object.assign({}, updatedFiles[fileID], {
  306. meta: newMeta
  307. })
  308. this.setState({ files: updatedFiles })
  309. }
  310. /**
  311. * Get a file object.
  312. *
  313. * @param {string} fileID The ID of the file object to return.
  314. */
  315. getFile (fileID) {
  316. return this.getState().files[fileID]
  317. }
  318. /**
  319. * Get all files in an array.
  320. */
  321. getFiles () {
  322. const { files } = this.getState()
  323. return Object.keys(files).map((fileID) => files[fileID])
  324. }
  325. /**
  326. * Check if minNumberOfFiles restriction is reached before uploading.
  327. *
  328. * @private
  329. */
  330. _checkMinNumberOfFiles (files) {
  331. const { minNumberOfFiles } = this.opts.restrictions
  332. if (Object.keys(files).length < minNumberOfFiles) {
  333. throw new RestrictionError(`${this.i18n('youHaveToAtLeastSelectX', { smart_count: minNumberOfFiles })}`)
  334. }
  335. }
  336. /**
  337. * Check if file passes a set of restrictions set in options: maxFileSize,
  338. * maxNumberOfFiles and allowedFileTypes.
  339. *
  340. * @param {object} file object to check
  341. * @private
  342. */
  343. _checkRestrictions (file) {
  344. const { maxFileSize, maxNumberOfFiles, allowedFileTypes } = this.opts.restrictions
  345. if (maxNumberOfFiles) {
  346. if (Object.keys(this.getState().files).length + 1 > maxNumberOfFiles) {
  347. throw new RestrictionError(`${this.i18n('youCanOnlyUploadX', { smart_count: maxNumberOfFiles })}`)
  348. }
  349. }
  350. if (allowedFileTypes) {
  351. const isCorrectFileType = allowedFileTypes.some((type) => {
  352. // is this is a mime-type
  353. if (type.indexOf('/') > -1) {
  354. if (!file.type) return false
  355. return match(file.type, type)
  356. }
  357. // otherwise this is likely an extension
  358. if (type[0] === '.') {
  359. return file.extension.toLowerCase() === type.substr(1).toLowerCase()
  360. }
  361. return false
  362. })
  363. if (!isCorrectFileType) {
  364. const allowedFileTypesString = allowedFileTypes.join(', ')
  365. throw new RestrictionError(this.i18n('youCanOnlyUploadFileTypes', { types: allowedFileTypesString }))
  366. }
  367. }
  368. // We can't check maxFileSize if the size is unknown.
  369. if (maxFileSize && file.data.size != null) {
  370. if (file.data.size > maxFileSize) {
  371. throw new RestrictionError(`${this.i18n('exceedsSize')} ${prettyBytes(maxFileSize)}`)
  372. }
  373. }
  374. }
  375. _showOrLogErrorAndThrow (err, { showInformer = true, file = null } = {}) {
  376. const message = typeof err === 'object' ? err.message : err
  377. const details = (typeof err === 'object' && err.details) ? err.details : ''
  378. // Restriction errors should be logged, but not as errors,
  379. // as they are expected and shown in the UI.
  380. if (err.isRestriction) {
  381. this.log(`${message} ${details}`)
  382. this.emit('restriction-failed', file, err)
  383. } else {
  384. this.log(`${message} ${details}`, 'error')
  385. }
  386. // Sometimes informer has to be shown manually by the developer,
  387. // for example, in `onBeforeFileAdded`.
  388. if (showInformer) {
  389. this.info({ message: message, details: details }, 'error', 5000)
  390. }
  391. throw (typeof err === 'object' ? err : new Error(err))
  392. }
  393. /**
  394. * Add a new file to `state.files`. This will run `onBeforeFileAdded`,
  395. * try to guess file type in a clever way, check file against restrictions,
  396. * and start an upload if `autoProceed === true`.
  397. *
  398. * @param {object} file object to add
  399. * @returns {string} id for the added file
  400. */
  401. addFile (file) {
  402. const { files, allowNewUpload } = this.getState()
  403. if (allowNewUpload === false) {
  404. this._showOrLogErrorAndThrow(new RestrictionError('Cannot add new files: already uploading.'), { file })
  405. }
  406. const fileType = getFileType(file)
  407. file.type = fileType
  408. const onBeforeFileAddedResult = this.opts.onBeforeFileAdded(file, files)
  409. if (onBeforeFileAddedResult === false) {
  410. // Don’t show UI info for this error, as it should be done by the developer
  411. this._showOrLogErrorAndThrow(new RestrictionError('Cannot add the file because onBeforeFileAdded returned false.'), { showInformer: false, file })
  412. }
  413. if (typeof onBeforeFileAddedResult === 'object' && onBeforeFileAddedResult) {
  414. file = onBeforeFileAddedResult
  415. }
  416. let fileName
  417. if (file.name) {
  418. fileName = file.name
  419. } else if (fileType.split('/')[0] === 'image') {
  420. fileName = fileType.split('/')[0] + '.' + fileType.split('/')[1]
  421. } else {
  422. fileName = 'noname'
  423. }
  424. const fileExtension = getFileNameAndExtension(fileName).extension
  425. const isRemote = file.isRemote || false
  426. const fileID = generateFileID(file)
  427. if (files[fileID]) {
  428. this._showOrLogErrorAndThrow(new RestrictionError(`Cannot add the duplicate file '${fileName}', it already exists.`), { file })
  429. }
  430. const meta = file.meta || {}
  431. meta.name = fileName
  432. meta.type = fileType
  433. // `null` means the size is unknown.
  434. const size = isFinite(file.data.size) ? file.data.size : null
  435. const newFile = {
  436. source: file.source || '',
  437. id: fileID,
  438. name: fileName,
  439. extension: fileExtension || '',
  440. meta: Object.assign({}, this.getState().meta, meta),
  441. type: fileType,
  442. data: file.data,
  443. progress: {
  444. percentage: 0,
  445. bytesUploaded: 0,
  446. bytesTotal: size,
  447. uploadComplete: false,
  448. uploadStarted: null
  449. },
  450. size: size,
  451. isRemote: isRemote,
  452. remote: file.remote || '',
  453. preview: file.preview
  454. }
  455. try {
  456. this._checkRestrictions(newFile)
  457. } catch (err) {
  458. this._showOrLogErrorAndThrow(err, { file: newFile })
  459. }
  460. this.setState({
  461. files: Object.assign({}, files, {
  462. [fileID]: newFile
  463. })
  464. })
  465. this.emit('file-added', newFile)
  466. this.log(`Added file: ${fileName}, ${fileID}, mime type: ${fileType}`)
  467. if (this.opts.autoProceed && !this.scheduledAutoProceed) {
  468. this.scheduledAutoProceed = setTimeout(() => {
  469. this.scheduledAutoProceed = null
  470. this.upload().catch((err) => {
  471. if (!err.isRestriction) {
  472. this.log(err.stack || err.message || err)
  473. }
  474. })
  475. }, 4)
  476. }
  477. return fileID
  478. }
  479. removeFile (fileID) {
  480. const { files, currentUploads } = this.getState()
  481. const updatedFiles = Object.assign({}, files)
  482. const removedFile = updatedFiles[fileID]
  483. delete updatedFiles[fileID]
  484. // Remove this file from its `currentUpload`.
  485. const updatedUploads = Object.assign({}, currentUploads)
  486. const removeUploads = []
  487. Object.keys(updatedUploads).forEach((uploadID) => {
  488. const newFileIDs = currentUploads[uploadID].fileIDs.filter((uploadFileID) => uploadFileID !== fileID)
  489. // Remove the upload if no files are associated with it anymore.
  490. if (newFileIDs.length === 0) {
  491. removeUploads.push(uploadID)
  492. return
  493. }
  494. updatedUploads[uploadID] = Object.assign({}, currentUploads[uploadID], {
  495. fileIDs: newFileIDs
  496. })
  497. })
  498. this.setState({
  499. currentUploads: updatedUploads,
  500. files: updatedFiles,
  501. ...(
  502. // If this is the last file we just removed - allow new uploads!
  503. Object.keys(updatedFiles).length === 0 &&
  504. { allowNewUpload: true }
  505. )
  506. })
  507. removeUploads.forEach((uploadID) => {
  508. this._removeUpload(uploadID)
  509. })
  510. this._calculateTotalProgress()
  511. this.emit('file-removed', removedFile)
  512. this.log(`File removed: ${removedFile.id}`)
  513. }
  514. pauseResume (fileID) {
  515. if (!this.getState().capabilities.resumableUploads ||
  516. this.getFile(fileID).uploadComplete) {
  517. return
  518. }
  519. const wasPaused = this.getFile(fileID).isPaused || false
  520. const isPaused = !wasPaused
  521. this.setFileState(fileID, {
  522. isPaused: isPaused
  523. })
  524. this.emit('upload-pause', fileID, isPaused)
  525. return isPaused
  526. }
  527. pauseAll () {
  528. const updatedFiles = Object.assign({}, this.getState().files)
  529. const inProgressUpdatedFiles = Object.keys(updatedFiles).filter((file) => {
  530. return !updatedFiles[file].progress.uploadComplete &&
  531. updatedFiles[file].progress.uploadStarted
  532. })
  533. inProgressUpdatedFiles.forEach((file) => {
  534. const updatedFile = Object.assign({}, updatedFiles[file], {
  535. isPaused: true
  536. })
  537. updatedFiles[file] = updatedFile
  538. })
  539. this.setState({ files: updatedFiles })
  540. this.emit('pause-all')
  541. }
  542. resumeAll () {
  543. const updatedFiles = Object.assign({}, this.getState().files)
  544. const inProgressUpdatedFiles = Object.keys(updatedFiles).filter((file) => {
  545. return !updatedFiles[file].progress.uploadComplete &&
  546. updatedFiles[file].progress.uploadStarted
  547. })
  548. inProgressUpdatedFiles.forEach((file) => {
  549. const updatedFile = Object.assign({}, updatedFiles[file], {
  550. isPaused: false,
  551. error: null
  552. })
  553. updatedFiles[file] = updatedFile
  554. })
  555. this.setState({ files: updatedFiles })
  556. this.emit('resume-all')
  557. }
  558. retryAll () {
  559. const updatedFiles = Object.assign({}, this.getState().files)
  560. const filesToRetry = Object.keys(updatedFiles).filter(file => {
  561. return updatedFiles[file].error
  562. })
  563. filesToRetry.forEach((file) => {
  564. const updatedFile = Object.assign({}, updatedFiles[file], {
  565. isPaused: false,
  566. error: null
  567. })
  568. updatedFiles[file] = updatedFile
  569. })
  570. this.setState({
  571. files: updatedFiles,
  572. error: null
  573. })
  574. this.emit('retry-all', filesToRetry)
  575. const uploadID = this._createUpload(filesToRetry)
  576. return this._runUpload(uploadID)
  577. }
  578. cancelAll () {
  579. this.emit('cancel-all')
  580. const files = Object.keys(this.getState().files)
  581. files.forEach((fileID) => {
  582. this.removeFile(fileID)
  583. })
  584. this.setState({
  585. totalProgress: 0,
  586. error: null
  587. })
  588. }
  589. retryUpload (fileID) {
  590. this.setFileState(fileID, {
  591. error: null,
  592. isPaused: false
  593. })
  594. this.emit('upload-retry', fileID)
  595. const uploadID = this._createUpload([fileID])
  596. return this._runUpload(uploadID)
  597. }
  598. reset () {
  599. this.cancelAll()
  600. }
  601. _calculateProgress (file, data) {
  602. if (!this.getFile(file.id)) {
  603. this.log(`Not setting progress for a file that has been removed: ${file.id}`)
  604. return
  605. }
  606. // bytesTotal may be null or zero; in that case we can't divide by it
  607. const canHavePercentage = isFinite(data.bytesTotal) && data.bytesTotal > 0
  608. this.setFileState(file.id, {
  609. progress: Object.assign({}, this.getFile(file.id).progress, {
  610. bytesUploaded: data.bytesUploaded,
  611. bytesTotal: data.bytesTotal,
  612. percentage: canHavePercentage
  613. // TODO(goto-bus-stop) flooring this should probably be the choice of the UI?
  614. // we get more accurate calculations if we don't round this at all.
  615. ? Math.round(data.bytesUploaded / data.bytesTotal * 100)
  616. : 0
  617. })
  618. })
  619. this._calculateTotalProgress()
  620. }
  621. _calculateTotalProgress () {
  622. // calculate total progress, using the number of files currently uploading,
  623. // multiplied by 100 and the summ of individual progress of each file
  624. const files = this.getFiles()
  625. const inProgress = files.filter((file) => {
  626. return file.progress.uploadStarted
  627. })
  628. if (inProgress.length === 0) {
  629. this.emit('progress', 0)
  630. this.setState({ totalProgress: 0 })
  631. return
  632. }
  633. const sizedFiles = inProgress.filter((file) => file.progress.bytesTotal != null)
  634. const unsizedFiles = inProgress.filter((file) => file.progress.bytesTotal == null)
  635. if (sizedFiles.length === 0) {
  636. const progressMax = inProgress.length * 100
  637. const currentProgress = unsizedFiles.reduce((acc, file) => {
  638. return acc + file.progress.percentage
  639. }, 0)
  640. const totalProgress = Math.round(currentProgress / progressMax * 100)
  641. this.setState({ totalProgress })
  642. return
  643. }
  644. let totalSize = sizedFiles.reduce((acc, file) => {
  645. return acc + file.progress.bytesTotal
  646. }, 0)
  647. const averageSize = totalSize / sizedFiles.length
  648. totalSize += averageSize * unsizedFiles.length
  649. let uploadedSize = 0
  650. sizedFiles.forEach((file) => {
  651. uploadedSize += file.progress.bytesUploaded
  652. })
  653. unsizedFiles.forEach((file) => {
  654. uploadedSize += averageSize * (file.progress.percentage || 0) / 100
  655. })
  656. let totalProgress = totalSize === 0
  657. ? 0
  658. : Math.round(uploadedSize / totalSize * 100)
  659. // hot fix, because:
  660. // uploadedSize ended up larger than totalSize, resulting in 1325% total
  661. if (totalProgress > 100) {
  662. totalProgress = 100
  663. }
  664. this.setState({ totalProgress })
  665. this.emit('progress', totalProgress)
  666. }
  667. /**
  668. * Registers listeners for all global actions, like:
  669. * `error`, `file-removed`, `upload-progress`
  670. */
  671. _addListeners () {
  672. this.on('error', (error) => {
  673. this.setState({ error: error.message || 'Unknown error' })
  674. })
  675. this.on('upload-error', (file, error, response) => {
  676. this.setFileState(file.id, {
  677. error: error.message || 'Unknown error',
  678. response
  679. })
  680. this.setState({ error: error.message })
  681. let message = this.i18n('failedToUpload', { file: file.name })
  682. if (typeof error === 'object' && error.message) {
  683. message = { message: message, details: error.message }
  684. }
  685. this.info(message, 'error', 5000)
  686. })
  687. this.on('upload', () => {
  688. this.setState({ error: null })
  689. })
  690. this.on('upload-started', (file, upload) => {
  691. if (!this.getFile(file.id)) {
  692. this.log(`Not setting progress for a file that has been removed: ${file.id}`)
  693. return
  694. }
  695. this.setFileState(file.id, {
  696. progress: {
  697. uploadStarted: Date.now(),
  698. uploadComplete: false,
  699. percentage: 0,
  700. bytesUploaded: 0,
  701. bytesTotal: file.size
  702. }
  703. })
  704. })
  705. this.on('upload-progress', this._calculateProgress)
  706. this.on('upload-success', (file, uploadResp) => {
  707. const currentProgress = this.getFile(file.id).progress
  708. this.setFileState(file.id, {
  709. progress: Object.assign({}, currentProgress, {
  710. uploadComplete: true,
  711. percentage: 100,
  712. bytesUploaded: currentProgress.bytesTotal
  713. }),
  714. response: uploadResp,
  715. uploadURL: uploadResp.uploadURL,
  716. isPaused: false
  717. })
  718. this._calculateTotalProgress()
  719. })
  720. this.on('preprocess-progress', (file, progress) => {
  721. if (!this.getFile(file.id)) {
  722. this.log(`Not setting progress for a file that has been removed: ${file.id}`)
  723. return
  724. }
  725. this.setFileState(file.id, {
  726. progress: Object.assign({}, this.getFile(file.id).progress, {
  727. preprocess: progress
  728. })
  729. })
  730. })
  731. this.on('preprocess-complete', (file) => {
  732. if (!this.getFile(file.id)) {
  733. this.log(`Not setting progress for a file that has been removed: ${file.id}`)
  734. return
  735. }
  736. const files = Object.assign({}, this.getState().files)
  737. files[file.id] = Object.assign({}, files[file.id], {
  738. progress: Object.assign({}, files[file.id].progress)
  739. })
  740. delete files[file.id].progress.preprocess
  741. this.setState({ files: files })
  742. })
  743. this.on('postprocess-progress', (file, progress) => {
  744. if (!this.getFile(file.id)) {
  745. this.log(`Not setting progress for a file that has been removed: ${file.id}`)
  746. return
  747. }
  748. this.setFileState(file.id, {
  749. progress: Object.assign({}, this.getState().files[file.id].progress, {
  750. postprocess: progress
  751. })
  752. })
  753. })
  754. this.on('postprocess-complete', (file) => {
  755. if (!this.getFile(file.id)) {
  756. this.log(`Not setting progress for a file that has been removed: ${file.id}`)
  757. return
  758. }
  759. const files = Object.assign({}, this.getState().files)
  760. files[file.id] = Object.assign({}, files[file.id], {
  761. progress: Object.assign({}, files[file.id].progress)
  762. })
  763. delete files[file.id].progress.postprocess
  764. // TODO should we set some kind of `fullyComplete` property on the file object
  765. // so it's easier to see that the file is upload…fully complete…rather than
  766. // what we have to do now (`uploadComplete && !postprocess`)
  767. this.setState({ files: files })
  768. })
  769. this.on('restored', () => {
  770. // Files may have changed--ensure progress is still accurate.
  771. this._calculateTotalProgress()
  772. })
  773. // show informer if offline
  774. if (typeof window !== 'undefined' && window.addEventListener) {
  775. window.addEventListener('online', () => this.updateOnlineStatus())
  776. window.addEventListener('offline', () => this.updateOnlineStatus())
  777. setTimeout(() => this.updateOnlineStatus(), 3000)
  778. }
  779. }
  780. updateOnlineStatus () {
  781. const online =
  782. typeof window.navigator.onLine !== 'undefined'
  783. ? window.navigator.onLine
  784. : true
  785. if (!online) {
  786. this.emit('is-offline')
  787. this.info(this.i18n('noInternetConnection'), 'error', 0)
  788. this.wasOffline = true
  789. } else {
  790. this.emit('is-online')
  791. if (this.wasOffline) {
  792. this.emit('back-online')
  793. this.info(this.i18n('connectedToInternet'), 'success', 3000)
  794. this.wasOffline = false
  795. }
  796. }
  797. }
  798. getID () {
  799. return this.opts.id
  800. }
  801. /**
  802. * Registers a plugin with Core.
  803. *
  804. * @param {object} Plugin object
  805. * @param {object} [opts] object with options to be passed to Plugin
  806. * @returns {object} self for chaining
  807. */
  808. use (Plugin, opts) {
  809. if (typeof Plugin !== 'function') {
  810. const msg = `Expected a plugin class, but got ${Plugin === null ? 'null' : typeof Plugin}.` +
  811. ' Please verify that the plugin was imported and spelled correctly.'
  812. throw new TypeError(msg)
  813. }
  814. // Instantiate
  815. const plugin = new Plugin(this, opts)
  816. const pluginId = plugin.id
  817. this.plugins[plugin.type] = this.plugins[plugin.type] || []
  818. if (!pluginId) {
  819. throw new Error('Your plugin must have an id')
  820. }
  821. if (!plugin.type) {
  822. throw new Error('Your plugin must have a type')
  823. }
  824. const existsPluginAlready = this.getPlugin(pluginId)
  825. if (existsPluginAlready) {
  826. const msg = `Already found a plugin named '${existsPluginAlready.id}'. ` +
  827. `Tried to use: '${pluginId}'.\n` +
  828. 'Uppy plugins must have unique `id` options. See https://uppy.io/docs/plugins/#id.'
  829. throw new Error(msg)
  830. }
  831. if (Plugin.VERSION) {
  832. this.log(`Using ${pluginId} v${Plugin.VERSION}`)
  833. }
  834. this.plugins[plugin.type].push(plugin)
  835. plugin.install()
  836. return this
  837. }
  838. /**
  839. * Find one Plugin by name.
  840. *
  841. * @param {string} id plugin id
  842. * @returns {object|boolean}
  843. */
  844. getPlugin (id) {
  845. let foundPlugin = null
  846. this.iteratePlugins((plugin) => {
  847. if (plugin.id === id) {
  848. foundPlugin = plugin
  849. return false
  850. }
  851. })
  852. return foundPlugin
  853. }
  854. /**
  855. * Iterate through all `use`d plugins.
  856. *
  857. * @param {Function} method that will be run on each plugin
  858. */
  859. iteratePlugins (method) {
  860. Object.keys(this.plugins).forEach(pluginType => {
  861. this.plugins[pluginType].forEach(method)
  862. })
  863. }
  864. /**
  865. * Uninstall and remove a plugin.
  866. *
  867. * @param {object} instance The plugin instance to remove.
  868. */
  869. removePlugin (instance) {
  870. this.log(`Removing plugin ${instance.id}`)
  871. this.emit('plugin-remove', instance)
  872. if (instance.uninstall) {
  873. instance.uninstall()
  874. }
  875. const list = this.plugins[instance.type].slice()
  876. const index = list.indexOf(instance)
  877. if (index !== -1) {
  878. list.splice(index, 1)
  879. this.plugins[instance.type] = list
  880. }
  881. const updatedState = this.getState()
  882. delete updatedState.plugins[instance.id]
  883. this.setState(updatedState)
  884. }
  885. /**
  886. * Uninstall all plugins and close down this Uppy instance.
  887. */
  888. close () {
  889. this.log(`Closing Uppy instance ${this.opts.id}: removing all files and uninstalling plugins`)
  890. this.reset()
  891. this._storeUnsubscribe()
  892. this.iteratePlugins((plugin) => {
  893. this.removePlugin(plugin)
  894. })
  895. }
  896. /**
  897. * Set info message in `state.info`, so that UI plugins like `Informer`
  898. * can display the message.
  899. *
  900. * @param {string | object} message Message to be displayed by the informer
  901. * @param {string} [type]
  902. * @param {number} [duration]
  903. */
  904. info (message, type = 'info', duration = 3000) {
  905. const isComplexMessage = typeof message === 'object'
  906. this.setState({
  907. info: {
  908. isHidden: false,
  909. type: type,
  910. message: isComplexMessage ? message.message : message,
  911. details: isComplexMessage ? message.details : null
  912. }
  913. })
  914. this.emit('info-visible')
  915. clearTimeout(this.infoTimeoutID)
  916. if (duration === 0) {
  917. this.infoTimeoutID = undefined
  918. return
  919. }
  920. // hide the informer after `duration` milliseconds
  921. this.infoTimeoutID = setTimeout(this.hideInfo, duration)
  922. }
  923. hideInfo () {
  924. const newInfo = Object.assign({}, this.getState().info, {
  925. isHidden: true
  926. })
  927. this.setState({
  928. info: newInfo
  929. })
  930. this.emit('info-hidden')
  931. }
  932. /**
  933. * Passes messages to a function, provided in `opts.logger`.
  934. * If `opts.logger: Uppy.debugLogger` or `opts.debug: true`, logs to the browser console.
  935. *
  936. * @param {string|object} message to log
  937. * @param {string} [type] optional `error` or `warning`
  938. */
  939. log (message, type) {
  940. const { logger } = this.opts
  941. switch (type) {
  942. case 'error': logger.error(message); break
  943. case 'warning': logger.warn(message); break
  944. default: logger.debug(message); break
  945. }
  946. }
  947. /**
  948. * Obsolete, event listeners are now added in the constructor.
  949. */
  950. run () {
  951. this.log('Calling run() is no longer necessary.', 'warning')
  952. return this
  953. }
  954. /**
  955. * Restore an upload by its ID.
  956. */
  957. restore (uploadID) {
  958. this.log(`Core: attempting to restore upload "${uploadID}"`)
  959. if (!this.getState().currentUploads[uploadID]) {
  960. this._removeUpload(uploadID)
  961. return Promise.reject(new Error('Nonexistent upload'))
  962. }
  963. return this._runUpload(uploadID)
  964. }
  965. /**
  966. * Create an upload for a bunch of files.
  967. *
  968. * @param {Array<string>} fileIDs File IDs to include in this upload.
  969. * @returns {string} ID of this upload.
  970. */
  971. _createUpload (fileIDs) {
  972. const { allowNewUpload, currentUploads } = this.getState()
  973. if (!allowNewUpload) {
  974. throw new Error('Cannot create a new upload: already uploading.')
  975. }
  976. const uploadID = cuid()
  977. this.emit('upload', {
  978. id: uploadID,
  979. fileIDs: fileIDs
  980. })
  981. this.setState({
  982. allowNewUpload: this.opts.allowMultipleUploads !== false,
  983. currentUploads: {
  984. ...currentUploads,
  985. [uploadID]: {
  986. fileIDs: fileIDs,
  987. step: 0,
  988. result: {}
  989. }
  990. }
  991. })
  992. return uploadID
  993. }
  994. _getUpload (uploadID) {
  995. const { currentUploads } = this.getState()
  996. return currentUploads[uploadID]
  997. }
  998. /**
  999. * Add data to an upload's result object.
  1000. *
  1001. * @param {string} uploadID The ID of the upload.
  1002. * @param {object} data Data properties to add to the result object.
  1003. */
  1004. addResultData (uploadID, data) {
  1005. if (!this._getUpload(uploadID)) {
  1006. this.log(`Not setting result for an upload that has been removed: ${uploadID}`)
  1007. return
  1008. }
  1009. const currentUploads = this.getState().currentUploads
  1010. const currentUpload = Object.assign({}, currentUploads[uploadID], {
  1011. result: Object.assign({}, currentUploads[uploadID].result, data)
  1012. })
  1013. this.setState({
  1014. currentUploads: Object.assign({}, currentUploads, {
  1015. [uploadID]: currentUpload
  1016. })
  1017. })
  1018. }
  1019. /**
  1020. * Remove an upload, eg. if it has been canceled or completed.
  1021. *
  1022. * @param {string} uploadID The ID of the upload.
  1023. */
  1024. _removeUpload (uploadID) {
  1025. const currentUploads = Object.assign({}, this.getState().currentUploads)
  1026. delete currentUploads[uploadID]
  1027. this.setState({
  1028. currentUploads: currentUploads
  1029. })
  1030. }
  1031. /**
  1032. * Run an upload. This picks up where it left off in case the upload is being restored.
  1033. *
  1034. * @private
  1035. */
  1036. _runUpload (uploadID) {
  1037. const uploadData = this.getState().currentUploads[uploadID]
  1038. const restoreStep = uploadData.step
  1039. const steps = [
  1040. ...this.preProcessors,
  1041. ...this.uploaders,
  1042. ...this.postProcessors
  1043. ]
  1044. let lastStep = Promise.resolve()
  1045. steps.forEach((fn, step) => {
  1046. // Skip this step if we are restoring and have already completed this step before.
  1047. if (step < restoreStep) {
  1048. return
  1049. }
  1050. lastStep = lastStep.then(() => {
  1051. const { currentUploads } = this.getState()
  1052. const currentUpload = currentUploads[uploadID]
  1053. if (!currentUpload) {
  1054. return
  1055. }
  1056. const updatedUpload = Object.assign({}, currentUpload, {
  1057. step: step
  1058. })
  1059. this.setState({
  1060. currentUploads: Object.assign({}, currentUploads, {
  1061. [uploadID]: updatedUpload
  1062. })
  1063. })
  1064. // TODO give this the `updatedUpload` object as its only parameter maybe?
  1065. // Otherwise when more metadata may be added to the upload this would keep getting more parameters
  1066. return fn(updatedUpload.fileIDs, uploadID)
  1067. }).then((result) => {
  1068. return null
  1069. })
  1070. })
  1071. // Not returning the `catch`ed promise, because we still want to return a rejected
  1072. // promise from this method if the upload failed.
  1073. lastStep.catch((err) => {
  1074. this.emit('error', err, uploadID)
  1075. this._removeUpload(uploadID)
  1076. })
  1077. return lastStep.then(() => {
  1078. // Set result data.
  1079. const { currentUploads } = this.getState()
  1080. const currentUpload = currentUploads[uploadID]
  1081. if (!currentUpload) {
  1082. return
  1083. }
  1084. const files = currentUpload.fileIDs
  1085. .map((fileID) => this.getFile(fileID))
  1086. const successful = files.filter((file) => !file.error)
  1087. const failed = files.filter((file) => file.error)
  1088. this.addResultData(uploadID, { successful, failed, uploadID })
  1089. }).then(() => {
  1090. // Emit completion events.
  1091. // This is in a separate function so that the `currentUploads` variable
  1092. // always refers to the latest state. In the handler right above it refers
  1093. // to an outdated object without the `.result` property.
  1094. const { currentUploads } = this.getState()
  1095. if (!currentUploads[uploadID]) {
  1096. return
  1097. }
  1098. const currentUpload = currentUploads[uploadID]
  1099. const result = currentUpload.result
  1100. this.emit('complete', result)
  1101. this._removeUpload(uploadID)
  1102. return result
  1103. }).then((result) => {
  1104. if (result == null) {
  1105. this.log(`Not setting result for an upload that has been removed: ${uploadID}`)
  1106. }
  1107. return result
  1108. })
  1109. }
  1110. /**
  1111. * Start an upload for all the files that are not currently being uploaded.
  1112. *
  1113. * @returns {Promise}
  1114. */
  1115. upload () {
  1116. if (!this.plugins.uploader) {
  1117. this.log('No uploader type plugins are used', 'warning')
  1118. }
  1119. let files = this.getState().files
  1120. const onBeforeUploadResult = this.opts.onBeforeUpload(files)
  1121. if (onBeforeUploadResult === false) {
  1122. return Promise.reject(new Error('Not starting the upload because onBeforeUpload returned false'))
  1123. }
  1124. if (onBeforeUploadResult && typeof onBeforeUploadResult === 'object') {
  1125. files = onBeforeUploadResult
  1126. }
  1127. return Promise.resolve()
  1128. .then(() => this._checkMinNumberOfFiles(files))
  1129. .then(() => {
  1130. const { currentUploads } = this.getState()
  1131. // get a list of files that are currently assigned to uploads
  1132. const currentlyUploadingFiles = Object.keys(currentUploads).reduce((prev, curr) => prev.concat(currentUploads[curr].fileIDs), [])
  1133. const waitingFileIDs = []
  1134. Object.keys(files).forEach((fileID) => {
  1135. const file = this.getFile(fileID)
  1136. // if the file hasn't started uploading and hasn't already been assigned to an upload..
  1137. if ((!file.progress.uploadStarted) && (currentlyUploadingFiles.indexOf(fileID) === -1)) {
  1138. waitingFileIDs.push(file.id)
  1139. }
  1140. })
  1141. const uploadID = this._createUpload(waitingFileIDs)
  1142. return this._runUpload(uploadID)
  1143. })
  1144. .catch((err) => {
  1145. this._showOrLogErrorAndThrow(err)
  1146. })
  1147. }
  1148. }
  1149. module.exports = function (opts) {
  1150. return new Uppy(opts)
  1151. }
  1152. // Expose class constructor.
  1153. module.exports.Uppy = Uppy
  1154. module.exports.Plugin = Plugin
  1155. module.exports.debugLogger = debugLogger