Core.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715
  1. const Utils = require('../core/Utils')
  2. const Translator = require('../core/Translator')
  3. const UppySocket = require('./UppySocket')
  4. const ee = require('namespace-emitter')
  5. const throttle = require('lodash.throttle')
  6. const prettyBytes = require('prettier-bytes')
  7. const match = require('mime-match')
  8. // const en_US = require('../locales/en_US')
  9. // const deepFreeze = require('deep-freeze-strict')
  10. /**
  11. * Main Uppy core
  12. *
  13. * @param {object} opts general options, like locales, to show modal or not to show
  14. */
  15. class Uppy {
  16. constructor (opts) {
  17. const defaultLocale = {
  18. strings: {
  19. youCanOnlyUploadX: {
  20. 0: 'You can only upload %{smart_count} file',
  21. 1: 'You can only upload %{smart_count} files'
  22. },
  23. youHaveToAtLeastSelectX: {
  24. 0: 'You have to select at least %{smart_count} file',
  25. 1: 'You have to select at least %{smart_count} files'
  26. },
  27. exceedsSize: 'This file exceeds maximum allowed size of',
  28. youCanOnlyUploadFileTypes: 'You can only upload:'
  29. }
  30. }
  31. // set default options
  32. const defaultOptions = {
  33. // load English as the default locale
  34. // locale: en_US,
  35. autoProceed: true,
  36. debug: false,
  37. restrictions: {
  38. maxFileSize: false,
  39. maxNumberOfFiles: false,
  40. minNumberOfFiles: false,
  41. allowedFileTypes: false
  42. },
  43. onBeforeFileAdded: (currentFile, files) => Promise.resolve(),
  44. onBeforeUpload: (files, done) => Promise.resolve(),
  45. locale: defaultLocale
  46. }
  47. // Merge default options with the ones set by user
  48. this.opts = Object.assign({}, defaultOptions, opts)
  49. // // Dictates in what order different plugin types are ran:
  50. // this.types = [ 'presetter', 'orchestrator', 'progressindicator',
  51. // 'acquirer', 'modifier', 'uploader', 'presenter', 'debugger']
  52. this.locale = Object.assign({}, defaultLocale, this.opts.locale)
  53. this.locale.strings = Object.assign({}, defaultLocale.strings, this.opts.locale.strings)
  54. // i18n
  55. this.translator = new Translator({locale: this.locale})
  56. this.i18n = this.translator.translate.bind(this.translator)
  57. // Container for different types of plugins
  58. this.plugins = {}
  59. this.translator = new Translator({locale: this.opts.locale})
  60. this.i18n = this.translator.translate.bind(this.translator)
  61. this.getState = this.getState.bind(this)
  62. this.updateMeta = this.updateMeta.bind(this)
  63. this.initSocket = this.initSocket.bind(this)
  64. this.log = this.log.bind(this)
  65. this.addFile = this.addFile.bind(this)
  66. this.calculateProgress = this.calculateProgress.bind(this)
  67. this.bus = this.emitter = ee()
  68. this.on = this.bus.on.bind(this.bus)
  69. this.emit = this.bus.emit.bind(this.bus)
  70. this.preProcessors = []
  71. this.uploaders = []
  72. this.postProcessors = []
  73. this.state = {
  74. files: {},
  75. capabilities: {
  76. resumableUploads: false
  77. },
  78. totalProgress: 0,
  79. meta: Object.assign({}, this.opts.meta)
  80. }
  81. // for debugging and testing
  82. this.updateNum = 0
  83. if (this.opts.debug) {
  84. global.UppyState = this.state
  85. global.uppyLog = ''
  86. global.UppyAddFile = this.addFile.bind(this)
  87. global._Uppy = this
  88. }
  89. }
  90. /**
  91. * Iterate on all plugins and run `update` on them. Called each time state changes
  92. *
  93. */
  94. updateAll (state) {
  95. Object.keys(this.plugins).forEach((pluginType) => {
  96. this.plugins[pluginType].forEach((plugin) => {
  97. plugin.update(state)
  98. })
  99. })
  100. }
  101. /**
  102. * Updates state
  103. *
  104. * @param {newState} object
  105. */
  106. setState (stateUpdate) {
  107. const newState = Object.assign({}, this.state, stateUpdate)
  108. this.emit('core:state-update', this.state, newState, stateUpdate)
  109. this.state = newState
  110. this.updateAll(this.state)
  111. }
  112. /**
  113. * Returns current state
  114. *
  115. */
  116. getState () {
  117. // use deepFreeze for debugging
  118. // return deepFreeze(this.state)
  119. return this.state
  120. }
  121. reset () {
  122. this.emit('core:pause-all')
  123. this.emit('core:cancel-all')
  124. this.setState({
  125. totalProgress: 0
  126. })
  127. }
  128. addPreProcessor (fn) {
  129. this.preProcessors.push(fn)
  130. }
  131. removePreProcessor (fn) {
  132. const i = this.preProcessors.indexOf(fn)
  133. if (i !== -1) {
  134. this.preProcessors.splice(i, 1)
  135. }
  136. }
  137. addPostProcessor (fn) {
  138. this.postProcessors.push(fn)
  139. }
  140. removePostProcessor (fn) {
  141. const i = this.postProcessors.indexOf(fn)
  142. if (i !== -1) {
  143. this.postProcessors.splice(i, 1)
  144. }
  145. }
  146. addUploader (fn) {
  147. this.uploaders.push(fn)
  148. }
  149. removeUploader (fn) {
  150. const i = this.uploaders.indexOf(fn)
  151. if (i !== -1) {
  152. this.uploaders.splice(i, 1)
  153. }
  154. }
  155. setMeta (data) {
  156. const newMeta = Object.assign({}, this.getState().meta, data)
  157. this.log('Adding metadata:')
  158. this.log(data)
  159. this.setState({meta: newMeta})
  160. }
  161. updateMeta (data, fileID) {
  162. const updatedFiles = Object.assign({}, this.getState().files)
  163. const newMeta = Object.assign({}, updatedFiles[fileID].meta, data)
  164. updatedFiles[fileID] = Object.assign({}, updatedFiles[fileID], {
  165. meta: newMeta
  166. })
  167. this.setState({files: updatedFiles})
  168. }
  169. checkRestrictions (checkMinNumberOfFiles, file, fileType) {
  170. const {maxFileSize, maxNumberOfFiles, minNumberOfFiles, allowedFileTypes} = this.opts.restrictions
  171. if (checkMinNumberOfFiles && minNumberOfFiles) {
  172. console.log(Object.keys(this.state.files).length)
  173. if (Object.keys(this.state.files).length < minNumberOfFiles) {
  174. this.emit('informer', `${this.i18n('youHaveToAtLeastSelectX', {smart_count: minNumberOfFiles})}`, 'error', 5000)
  175. return false
  176. }
  177. return true
  178. }
  179. if (maxNumberOfFiles) {
  180. if (Object.keys(this.state.files).length + 1 > maxNumberOfFiles) {
  181. this.emit('informer', `${this.i18n('youCanOnlyUploadX', {smart_count: maxNumberOfFiles})}`, 'error', 5000)
  182. return false
  183. }
  184. }
  185. if (allowedFileTypes) {
  186. const isCorrectFileType = allowedFileTypes.filter(match(fileType.join('/'))).length > 0
  187. if (!isCorrectFileType) {
  188. const allowedFileTypesString = allowedFileTypes.join(', ')
  189. this.emit('informer', `${this.i18n('youCanOnlyUploadFileTypes')} ${allowedFileTypesString}`, 'error', 5000)
  190. return false
  191. }
  192. }
  193. if (maxFileSize) {
  194. if (file.data.size > maxFileSize) {
  195. this.emit('informer', `${this.i18n('exceedsSize')} ${prettyBytes(maxFileSize)}`, 'error', 5000)
  196. return false
  197. }
  198. }
  199. return true
  200. }
  201. addFile (file) {
  202. return this.opts.onBeforeFileAdded(file, this.getState().files).then(() => {
  203. return Utils.getFileType(file).then((fileType) => {
  204. const updatedFiles = Object.assign({}, this.state.files)
  205. const fileName = file.name || 'noname'
  206. const fileExtension = Utils.getFileNameAndExtension(fileName)[1]
  207. const isRemote = file.isRemote || false
  208. const fileID = Utils.generateFileID(fileName)
  209. const fileTypeGeneral = fileType[0]
  210. const fileTypeSpecific = fileType[1]
  211. const newFile = {
  212. source: file.source || '',
  213. id: fileID,
  214. name: fileName,
  215. extension: fileExtension || '',
  216. meta: Object.assign({}, { name: fileName }, this.getState().meta),
  217. type: {
  218. general: fileTypeGeneral,
  219. specific: fileTypeSpecific
  220. },
  221. data: file.data,
  222. progress: {
  223. percentage: 0,
  224. bytesUploaded: 0,
  225. bytesTotal: file.data.size || 0,
  226. uploadComplete: false,
  227. uploadStarted: false
  228. },
  229. size: file.data.size || 'N/A',
  230. isRemote: isRemote,
  231. remote: file.remote || '',
  232. preview: file.preview
  233. }
  234. if (Utils.isPreviewSupported(fileTypeSpecific) && !isRemote) {
  235. newFile.preview = Utils.getThumbnail(file)
  236. }
  237. const isFileAllowed = this.checkRestrictions(false, newFile, fileType)
  238. if (!isFileAllowed) return
  239. updatedFiles[fileID] = newFile
  240. this.setState({files: updatedFiles})
  241. this.emit('core:file-added', fileID)
  242. this.log(`Added file: ${fileName}, ${fileID}, mime type: ${fileType}`)
  243. if (this.opts.autoProceed && !this.scheduledAutoProceed) {
  244. this.scheduledAutoProceed = setTimeout(() => {
  245. this.scheduledAutoProceed = null
  246. this.upload().catch((err) => {
  247. console.error(err.stack || err.message || err)
  248. })
  249. }, 4)
  250. }
  251. })
  252. })
  253. .catch((err) => {
  254. this.emit('informer', err, 'error', 5000)
  255. return Promise.reject(`onBeforeFileAdded: ${err}`)
  256. })
  257. }
  258. removeFile (fileID) {
  259. const updatedFiles = Object.assign({}, this.getState().files)
  260. delete updatedFiles[fileID]
  261. this.setState({files: updatedFiles})
  262. this.calculateTotalProgress()
  263. this.log(`Removed file: ${fileID}`)
  264. }
  265. calculateProgress (data) {
  266. const fileID = data.id
  267. const updatedFiles = Object.assign({}, this.getState().files)
  268. // skip progress event for a file that’s been removed
  269. if (!updatedFiles[fileID]) {
  270. this.log('Trying to set progress for a file that’s not with us anymore: ', fileID)
  271. return
  272. }
  273. const updatedFile = Object.assign({}, updatedFiles[fileID],
  274. Object.assign({}, {
  275. progress: Object.assign({}, updatedFiles[fileID].progress, {
  276. bytesUploaded: data.bytesUploaded,
  277. bytesTotal: data.bytesTotal,
  278. percentage: Math.floor((data.bytesUploaded / data.bytesTotal * 100).toFixed(2))
  279. })
  280. }
  281. ))
  282. updatedFiles[data.id] = updatedFile
  283. this.setState({
  284. files: updatedFiles
  285. })
  286. this.calculateTotalProgress()
  287. }
  288. calculateTotalProgress () {
  289. // calculate total progress, using the number of files currently uploading,
  290. // multiplied by 100 and the summ of individual progress of each file
  291. const files = Object.assign({}, this.getState().files)
  292. const inProgress = Object.keys(files).filter((file) => {
  293. return files[file].progress.uploadStarted
  294. })
  295. const progressMax = inProgress.length * 100
  296. let progressAll = 0
  297. inProgress.forEach((file) => {
  298. progressAll = progressAll + files[file].progress.percentage
  299. })
  300. const totalProgress = Math.floor((progressAll * 100 / progressMax).toFixed(2))
  301. this.setState({
  302. totalProgress: totalProgress
  303. })
  304. }
  305. /**
  306. * Registers listeners for all global actions, like:
  307. * `file-add`, `file-remove`, `upload-progress`, `reset`
  308. *
  309. */
  310. actions () {
  311. // this.bus.on('*', (payload) => {
  312. // console.log('emitted: ', this.event)
  313. // console.log('with payload: ', payload)
  314. // })
  315. // stress-test re-rendering
  316. // setInterval(() => {
  317. // this.setState({bla: 'bla'})
  318. // }, 20)
  319. this.on('core:error', (error) => {
  320. this.setState({ error })
  321. })
  322. this.on('core:upload', () => {
  323. this.setState({ error: null })
  324. })
  325. this.on('core:file-add', (data) => {
  326. this.addFile(data)
  327. })
  328. // `remove-file` removes a file from `state.files`, for example when
  329. // a user decides not to upload particular file and clicks a button to remove it
  330. this.on('core:file-remove', (fileID) => {
  331. this.removeFile(fileID)
  332. })
  333. this.on('core:cancel-all', () => {
  334. let updatedFiles = this.getState().files
  335. updatedFiles = {}
  336. this.setState({files: updatedFiles})
  337. })
  338. this.on('core:upload-started', (fileID, upload) => {
  339. const updatedFiles = Object.assign({}, this.getState().files)
  340. const updatedFile = Object.assign({}, updatedFiles[fileID],
  341. Object.assign({}, {
  342. progress: Object.assign({}, updatedFiles[fileID].progress, {
  343. uploadStarted: Date.now()
  344. })
  345. }
  346. ))
  347. updatedFiles[fileID] = updatedFile
  348. this.setState({files: updatedFiles})
  349. })
  350. // upload progress events can occur frequently, especially when you have a good
  351. // connection to the remote server. Therefore, we are throtteling them to
  352. // prevent accessive function calls.
  353. // see also: https://github.com/tus/tus-js-client/commit/9940f27b2361fd7e10ba58b09b60d82422183bbb
  354. const throttledCalculateProgress = throttle(this.calculateProgress, 100, {leading: true, trailing: false})
  355. this.on('core:upload-progress', (data) => {
  356. // this.calculateProgress(data)
  357. throttledCalculateProgress(data)
  358. })
  359. this.on('core:upload-success', (fileID, uploadResp, uploadURL) => {
  360. const updatedFiles = Object.assign({}, this.getState().files)
  361. const updatedFile = Object.assign({}, updatedFiles[fileID], {
  362. progress: Object.assign({}, updatedFiles[fileID].progress, {
  363. uploadComplete: true,
  364. // good or bad idea? setting the percentage to 100 if upload is successful,
  365. // so that if we lost some progress events on the way, its still marked “compete”?
  366. percentage: 100
  367. }),
  368. uploadURL: uploadURL
  369. })
  370. updatedFiles[fileID] = updatedFile
  371. this.setState({
  372. files: updatedFiles
  373. })
  374. this.calculateTotalProgress()
  375. if (this.getState().totalProgress === 100) {
  376. const completeFiles = Object.keys(updatedFiles).filter((file) => {
  377. return updatedFiles[file].progress.uploadComplete
  378. })
  379. this.emit('core:upload-complete', completeFiles.length)
  380. }
  381. })
  382. this.on('core:update-meta', (data, fileID) => {
  383. this.updateMeta(data, fileID)
  384. })
  385. this.on('core:preprocess-progress', (fileID, progress) => {
  386. const files = Object.assign({}, this.getState().files)
  387. files[fileID] = Object.assign({}, files[fileID], {
  388. progress: Object.assign({}, files[fileID].progress, {
  389. preprocess: progress
  390. })
  391. })
  392. this.setState({ files: files })
  393. })
  394. this.on('core:preprocess-complete', (fileID) => {
  395. const files = Object.assign({}, this.getState().files)
  396. files[fileID] = Object.assign({}, files[fileID], {
  397. progress: Object.assign({}, files[fileID].progress)
  398. })
  399. delete files[fileID].progress.preprocess
  400. this.setState({ files: files })
  401. })
  402. this.on('core:postprocess-progress', (fileID, progress) => {
  403. const files = Object.assign({}, this.getState().files)
  404. files[fileID] = Object.assign({}, files[fileID], {
  405. progress: Object.assign({}, files[fileID].progress, {
  406. postprocess: progress
  407. })
  408. })
  409. this.setState({ files: files })
  410. })
  411. this.on('core:postprocess-complete', (fileID) => {
  412. const files = Object.assign({}, this.getState().files)
  413. files[fileID] = Object.assign({}, files[fileID], {
  414. progress: Object.assign({}, files[fileID].progress)
  415. })
  416. delete files[fileID].progress.postprocess
  417. // TODO should we set some kind of `fullyComplete` property on the file object
  418. // so it's easier to see that the file is upload…fully complete…rather than
  419. // what we have to do now (`uploadComplete && !postprocess`)
  420. this.setState({ files: files })
  421. })
  422. // show informer if offline
  423. if (typeof window !== 'undefined') {
  424. window.addEventListener('online', () => this.isOnline(true))
  425. window.addEventListener('offline', () => this.isOnline(false))
  426. setTimeout(() => this.isOnline(), 3000)
  427. }
  428. }
  429. isOnline (status) {
  430. const online = status || window.navigator.onLine
  431. if (!online) {
  432. this.emit('is-offline')
  433. this.emit('informer', 'No internet connection', 'error', 0)
  434. this.wasOffline = true
  435. } else {
  436. this.emit('is-online')
  437. if (this.wasOffline) {
  438. this.emit('back-online')
  439. this.emit('informer', 'Connected!', 'success', 3000)
  440. this.wasOffline = false
  441. }
  442. }
  443. }
  444. /**
  445. * Registers a plugin with Core
  446. *
  447. * @param {Class} Plugin object
  448. * @param {Object} options object that will be passed to Plugin later
  449. * @return {Object} self for chaining
  450. */
  451. use (Plugin, opts) {
  452. // Instantiate
  453. const plugin = new Plugin(this, opts)
  454. const pluginName = plugin.id
  455. this.plugins[plugin.type] = this.plugins[plugin.type] || []
  456. if (!pluginName) {
  457. throw new Error('Your plugin must have a name')
  458. }
  459. if (!plugin.type) {
  460. throw new Error('Your plugin must have a type')
  461. }
  462. let existsPluginAlready = this.getPlugin(pluginName)
  463. if (existsPluginAlready) {
  464. let msg = `Already found a plugin named '${existsPluginAlready.name}'.
  465. Tried to use: '${pluginName}'.
  466. Uppy is currently limited to running one of every plugin.
  467. Share your use case with us over at
  468. https://github.com/transloadit/uppy/issues/
  469. if you want us to reconsider.`
  470. throw new Error(msg)
  471. }
  472. this.plugins[plugin.type].push(plugin)
  473. plugin.install()
  474. return this
  475. }
  476. /**
  477. * Find one Plugin by name
  478. *
  479. * @param string name description
  480. */
  481. getPlugin (name) {
  482. let foundPlugin = false
  483. this.iteratePlugins((plugin) => {
  484. const pluginName = plugin.id
  485. if (pluginName === name) {
  486. foundPlugin = plugin
  487. return false
  488. }
  489. })
  490. return foundPlugin
  491. }
  492. /**
  493. * Iterate through all `use`d plugins
  494. *
  495. * @param function method description
  496. */
  497. iteratePlugins (method) {
  498. Object.keys(this.plugins).forEach((pluginType) => {
  499. this.plugins[pluginType].forEach(method)
  500. })
  501. }
  502. /**
  503. * Uninstall and remove a plugin.
  504. *
  505. * @param {Plugin} instance The plugin instance to remove.
  506. */
  507. removePlugin (instance) {
  508. const list = this.plugins[instance.type]
  509. if (instance.uninstall) {
  510. instance.uninstall()
  511. }
  512. const index = list.indexOf(instance)
  513. if (index !== -1) {
  514. list.splice(index, 1)
  515. }
  516. }
  517. /**
  518. * Uninstall all plugins and close down this Uppy instance.
  519. */
  520. close () {
  521. this.reset()
  522. this.iteratePlugins((plugin) => {
  523. plugin.uninstall()
  524. })
  525. if (this.socket) {
  526. this.socket.close()
  527. }
  528. }
  529. /**
  530. * Logs stuff to console, only if `debug` is set to true. Silent in production.
  531. *
  532. * @return {String|Object} to log
  533. */
  534. log (msg, type) {
  535. if (!this.opts.debug) {
  536. return
  537. }
  538. if (type === 'error') {
  539. console.error(`LOG: ${msg}`)
  540. return
  541. }
  542. if (msg === `${msg}`) {
  543. console.log(`LOG: ${msg}`)
  544. } else {
  545. console.dir(msg)
  546. }
  547. global.uppyLog = global.uppyLog + '\n' + 'DEBUG LOG: ' + msg
  548. }
  549. initSocket (opts) {
  550. if (!this.socket) {
  551. this.socket = new UppySocket(opts)
  552. }
  553. return this.socket
  554. }
  555. /**
  556. * Initializes actions, installs all plugins (by iterating on them and calling `install`), sets options
  557. *
  558. */
  559. run () {
  560. this.log('Core is run, initializing actions...')
  561. this.actions()
  562. // Forse set `autoProceed` option to false if there are multiple selector Plugins active
  563. // if (this.plugins.acquirer && this.plugins.acquirer.length > 1) {
  564. // this.opts.autoProceed = false
  565. // }
  566. // Install all plugins
  567. // this.installAll()
  568. return
  569. }
  570. upload () {
  571. const isMinNumberOfFilesReached = this.checkRestrictions(true)
  572. if (!isMinNumberOfFilesReached) {
  573. return Promise.reject('Minimum number of files has not been reached')
  574. }
  575. return this.opts.onBeforeUpload(this.getState().files).then(() => {
  576. this.emit('core:upload')
  577. const waitingFileIDs = []
  578. Object.keys(this.state.files).forEach((fileID) => {
  579. const file = this.state.files[fileID]
  580. // TODO: replace files[file].isRemote with some logic
  581. //
  582. // filter files that are now yet being uploaded / haven’t been uploaded
  583. // and remote too
  584. if (!file.progress.uploadStarted || file.isRemote) {
  585. waitingFileIDs.push(file.id)
  586. }
  587. })
  588. const promise = Utils.runPromiseSequence(
  589. [...this.preProcessors, ...this.uploaders, ...this.postProcessors],
  590. waitingFileIDs
  591. )
  592. // Not returning the `catch`ed promise, because we still want to return a rejected
  593. // promise from this method if the upload failed.
  594. promise.catch((err) => {
  595. this.emit('core:error', err)
  596. })
  597. return promise.then(() => {
  598. // return number of uploaded files
  599. this.emit('core:success', waitingFileIDs)
  600. })
  601. })
  602. .catch((err) => {
  603. this.emit('informer', err, 'error', 5000)
  604. return Promise.reject(`onBeforeUpload: ${err}`)
  605. })
  606. }
  607. }
  608. module.exports = function (opts) {
  609. if (!(this instanceof Uppy)) {
  610. return new Uppy(opts)
  611. }
  612. }