Core.js 17 KB

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