Core.js 16 KB

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