index.js 50 KB

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