Uppy.ts 64 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227
  1. /* eslint-disable max-classes-per-file */
  2. /* global AggregateError */
  3. import type { h } from 'preact'
  4. import Translator from '@uppy/utils/lib/Translator'
  5. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  6. // @ts-ignore untyped
  7. import ee from 'namespace-emitter'
  8. import { nanoid } from 'nanoid/non-secure'
  9. import throttle from 'lodash/throttle.js'
  10. import DefaultStore from '@uppy/store-default'
  11. import getFileType from '@uppy/utils/lib/getFileType'
  12. import getFileNameAndExtension from '@uppy/utils/lib/getFileNameAndExtension'
  13. import { getSafeFileId } from '@uppy/utils/lib/generateFileID'
  14. import type {
  15. UppyFile,
  16. Meta,
  17. Body,
  18. MinimalRequiredUppyFile,
  19. } from '@uppy/utils/lib/UppyFile'
  20. import type { CompanionFile } from '@uppy/utils/lib/CompanionFile'
  21. import type {
  22. CompanionClientProvider,
  23. CompanionClientSearchProvider,
  24. } from '@uppy/utils/lib/CompanionClientProvider'
  25. import type { FileProgressStarted } from '@uppy/utils/lib/FileProgress'
  26. import type {
  27. Locale,
  28. I18n,
  29. OptionalPluralizeLocale,
  30. } from '@uppy/utils/lib/Translator'
  31. import supportsUploadProgress from './supportsUploadProgress.ts'
  32. import getFileName from './getFileName.ts'
  33. import { justErrorsLogger, debugLogger } from './loggers.ts'
  34. import {
  35. Restricter,
  36. defaultOptions as defaultRestrictionOptions,
  37. RestrictionError,
  38. } from './Restricter.ts'
  39. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  40. // @ts-ignore We don't want TS to generate types for the package.json
  41. import packageJson from '../package.json'
  42. import locale from './locale.ts'
  43. import type BasePlugin from './BasePlugin.js'
  44. import type { Restrictions, ValidateableFile } from './Restricter.js'
  45. type Processor = (
  46. fileIDs: string[],
  47. uploadID: string,
  48. ) => Promise<unknown> | void
  49. type LogLevel = 'info' | 'warning' | 'error' | 'success'
  50. export type UnknownPlugin<
  51. M extends Meta,
  52. B extends Body,
  53. PluginState extends Record<string, unknown> = Record<string, unknown>,
  54. > = BasePlugin<any, M, B, PluginState>
  55. /**
  56. * ids are always `string`s, except the root folder's id can be `null`
  57. */
  58. export type PartialTreeId = string | null
  59. export type PartialTreeStatusFile = 'checked' | 'unchecked'
  60. export type PartialTreeStatus = PartialTreeStatusFile | 'partial'
  61. export type PartialTreeFile = {
  62. type: 'file'
  63. id: string
  64. /**
  65. * There exist two types of restrictions:
  66. * - individual restrictions (`allowedFileTypes`, `minFileSize`, `maxFileSize`), and
  67. * - aggregate restrictions (`maxNumberOfFiles`, `maxTotalFileSize`).
  68. *
  69. * `.restrictionError` reports whether this file passes individual restrictions.
  70. *
  71. */
  72. restrictionError: string | null
  73. status: PartialTreeStatusFile
  74. parentId: PartialTreeId
  75. data: CompanionFile
  76. }
  77. export type PartialTreeFolderNode = {
  78. type: 'folder'
  79. id: string
  80. /**
  81. * Consider `(.nextPagePath, .cached)` a composite key that can represent 4 states:
  82. * - `{ cached: true, nextPagePath: null }` - we fetched all pages in this folder
  83. * - `{ cached: true, nextPagePath: 'smth' }` - we fetched 1st page, and there are still pages left to fetch in this folder
  84. * - `{ cached: false, nextPagePath: null }` - we didn't fetch the 1st page in this folder
  85. * - `{ cached: false, nextPagePath: 'someString' }` - ❌ CAN'T HAPPEN ❌
  86. */
  87. cached: boolean
  88. nextPagePath: PartialTreeId
  89. status: PartialTreeStatus
  90. parentId: PartialTreeId
  91. data: CompanionFile
  92. }
  93. export type PartialTreeFolderRoot = {
  94. type: 'root'
  95. id: PartialTreeId
  96. cached: boolean
  97. nextPagePath: PartialTreeId
  98. }
  99. export type PartialTreeFolder = PartialTreeFolderNode | PartialTreeFolderRoot
  100. /**
  101. * PartialTree has the following structure.
  102. *
  103. * FolderRoot
  104. * ┌─────┴─────┐
  105. * FolderNode File
  106. * ┌─────┴────┐
  107. * File File
  108. *
  109. * Root folder is called `PartialTreeFolderRoot`,
  110. * all other folders are called `PartialTreeFolderNode`, because they are "internal nodes".
  111. *
  112. * It's possible for `PartialTreeFolderNode` to be a leaf node if it doesn't contain any files.
  113. */
  114. export type PartialTree = (PartialTreeFile | PartialTreeFolder)[]
  115. export type UnknownProviderPluginState = {
  116. authenticated: boolean | undefined
  117. didFirstRender: boolean
  118. searchString: string
  119. loading: boolean | string
  120. partialTree: PartialTree
  121. currentFolderId: PartialTreeId
  122. username: string | null
  123. }
  124. /*
  125. * UnknownProviderPlugin can be any Companion plugin (such as Google Drive).
  126. * As the plugins are passed around throughout Uppy we need a generic type for this.
  127. * It may seems like duplication, but this type safe. Changing the type of `storage`
  128. * will error in the `Provider` class of @uppy/companion-client and vice versa.
  129. *
  130. * Note that this is the *plugin* class, not a version of the `Provider` class.
  131. * `Provider` does operate on Companion plugins with `uppy.getPlugin()`.
  132. */
  133. export type UnknownProviderPlugin<
  134. M extends Meta,
  135. B extends Body,
  136. > = UnknownPlugin<M, B, UnknownProviderPluginState> & {
  137. title: string
  138. rootFolderId: string | null
  139. files: UppyFile<M, B>[]
  140. icon: () => h.JSX.Element
  141. provider: CompanionClientProvider
  142. storage: {
  143. getItem: (key: string) => Promise<string | null>
  144. setItem: (key: string, value: string) => Promise<void>
  145. removeItem: (key: string) => Promise<void>
  146. }
  147. }
  148. /*
  149. * UnknownSearchProviderPlugin can be any search Companion plugin (such as Unsplash).
  150. * As the plugins are passed around throughout Uppy we need a generic type for this.
  151. * It may seems like duplication, but this type safe. Changing the type of `title`
  152. * will error in the `SearchProvider` class of @uppy/companion-client and vice versa.
  153. *
  154. * Note that this is the *plugin* class, not a version of the `SearchProvider` class.
  155. * `SearchProvider` does operate on Companion plugins with `uppy.getPlugin()`.
  156. */
  157. export type UnknownSearchProviderPluginState = {
  158. isInputMode: boolean
  159. } & Pick<
  160. UnknownProviderPluginState,
  161. 'loading' | 'searchString' | 'partialTree' | 'currentFolderId'
  162. >
  163. export type UnknownSearchProviderPlugin<
  164. M extends Meta,
  165. B extends Body,
  166. > = UnknownPlugin<M, B, UnknownSearchProviderPluginState> & {
  167. title: string
  168. icon: () => h.JSX.Element
  169. provider: CompanionClientSearchProvider
  170. }
  171. export interface UploadResult<M extends Meta, B extends Body> {
  172. successful?: UppyFile<M, B>[]
  173. failed?: UppyFile<M, B>[]
  174. uploadID?: string
  175. [key: string]: unknown
  176. }
  177. interface CurrentUpload<M extends Meta, B extends Body> {
  178. fileIDs: string[]
  179. step: number
  180. result: UploadResult<M, B>
  181. }
  182. // TODO: can we use namespaces in other plugins to populate this?
  183. // eslint-disable-next-line @typescript-eslint/no-empty-interface
  184. interface Plugins extends Record<string, Record<string, unknown> | undefined> {}
  185. export interface State<M extends Meta, B extends Body>
  186. extends Record<string, unknown> {
  187. meta: M
  188. capabilities: {
  189. uploadProgress: boolean
  190. individualCancellation: boolean
  191. resumableUploads: boolean
  192. isMobileDevice?: boolean
  193. darkMode?: boolean
  194. }
  195. currentUploads: Record<string, CurrentUpload<M, B>>
  196. allowNewUpload: boolean
  197. recoveredState: null | Required<Pick<State<M, B>, 'files' | 'currentUploads'>>
  198. error: string | null
  199. files: {
  200. [key: string]: UppyFile<M, B>
  201. }
  202. info: Array<{
  203. isHidden?: boolean
  204. type: LogLevel
  205. message: string
  206. details?: string | Record<string, string> | null
  207. }>
  208. plugins: Plugins
  209. totalProgress: number
  210. companion?: Record<string, string>
  211. }
  212. export interface UppyOptions<M extends Meta, B extends Body> {
  213. id?: string
  214. autoProceed?: boolean
  215. /**
  216. * @deprecated Use allowMultipleUploadBatches
  217. */
  218. allowMultipleUploads?: boolean
  219. allowMultipleUploadBatches?: boolean
  220. logger?: typeof debugLogger
  221. debug?: boolean
  222. restrictions: Restrictions
  223. meta?: M
  224. onBeforeFileAdded?: (
  225. currentFile: UppyFile<M, B>,
  226. files: { [key: string]: UppyFile<M, B> },
  227. ) => UppyFile<M, B> | boolean | undefined
  228. onBeforeUpload?: (files: {
  229. [key: string]: UppyFile<M, B>
  230. }) => { [key: string]: UppyFile<M, B> } | boolean
  231. locale?: Locale
  232. store?: DefaultStore<State<M, B>>
  233. infoTimeout?: number
  234. }
  235. export interface UppyOptionsWithOptionalRestrictions<
  236. M extends Meta,
  237. B extends Body,
  238. > extends Omit<UppyOptions<M, B>, 'restrictions'> {
  239. restrictions?: Partial<Restrictions>
  240. }
  241. // The user facing type for UppyOptions used in uppy.setOptions()
  242. type MinimalRequiredOptions<M extends Meta, B extends Body> = Partial<
  243. Omit<UppyOptions<M, B>, 'locale' | 'meta' | 'restrictions'> & {
  244. locale: OptionalPluralizeLocale
  245. meta: Partial<M>
  246. restrictions: Partial<Restrictions>
  247. }
  248. >
  249. export type NonNullableUppyOptions<M extends Meta, B extends Body> = Required<
  250. UppyOptions<M, B>
  251. >
  252. export interface _UppyEventMap<M extends Meta, B extends Body> {
  253. 'back-online': () => void
  254. 'cancel-all': () => void
  255. complete: (result: UploadResult<M, B>) => void
  256. error: (
  257. error: { name: string; message: string; details?: string },
  258. file?: UppyFile<M, B>,
  259. response?: UppyFile<M, B>['response'],
  260. ) => void
  261. 'file-added': (file: UppyFile<M, B>) => void
  262. 'file-removed': (file: UppyFile<M, B>) => void
  263. 'files-added': (files: UppyFile<M, B>[]) => void
  264. 'info-hidden': () => void
  265. 'info-visible': () => void
  266. 'is-offline': () => void
  267. 'is-online': () => void
  268. 'pause-all': () => void
  269. 'plugin-added': (plugin: UnknownPlugin<any, any>) => void
  270. 'plugin-remove': (plugin: UnknownPlugin<any, any>) => void
  271. 'postprocess-complete': (
  272. file: UppyFile<M, B> | undefined,
  273. progress?: NonNullable<FileProgressStarted['preprocess']>,
  274. ) => void
  275. 'postprocess-progress': (
  276. file: UppyFile<M, B> | undefined,
  277. progress: NonNullable<FileProgressStarted['postprocess']>,
  278. ) => void
  279. 'preprocess-complete': (
  280. file: UppyFile<M, B> | undefined,
  281. progress?: NonNullable<FileProgressStarted['preprocess']>,
  282. ) => void
  283. 'preprocess-progress': (
  284. file: UppyFile<M, B> | undefined,
  285. progress: NonNullable<FileProgressStarted['preprocess']>,
  286. ) => void
  287. progress: (progress: number) => void
  288. restored: (pluginData: any) => void
  289. 'restore-confirmed': () => void
  290. 'restore-canceled': () => void
  291. 'restriction-failed': (file: UppyFile<M, B> | undefined, error: Error) => void
  292. 'resume-all': () => void
  293. 'retry-all': (files: UppyFile<M, B>[]) => void
  294. 'state-update': (
  295. prevState: State<M, B>,
  296. nextState: State<M, B>,
  297. patch?: Partial<State<M, B>>,
  298. ) => void
  299. upload: (uploadID: string, files: UppyFile<M, B>[]) => void
  300. 'upload-error': (
  301. file: UppyFile<M, B> | undefined,
  302. error: { name: string; message: string; details?: string },
  303. response?:
  304. | Omit<NonNullable<UppyFile<M, B>['response']>, 'uploadURL'>
  305. | undefined,
  306. ) => void
  307. 'upload-pause': (file: UppyFile<M, B> | undefined, isPaused: boolean) => void
  308. 'upload-progress': (
  309. file: UppyFile<M, B> | undefined,
  310. progress: FileProgressStarted,
  311. ) => void
  312. 'upload-retry': (file: UppyFile<M, B>) => void
  313. 'upload-stalled': (
  314. error: { message: string; details?: string },
  315. files: UppyFile<M, B>[],
  316. ) => void
  317. 'upload-success': (
  318. file: UppyFile<M, B> | undefined,
  319. response: NonNullable<UppyFile<M, B>['response']>,
  320. ) => void
  321. }
  322. export interface UppyEventMap<M extends Meta, B extends Body>
  323. extends _UppyEventMap<M, B> {
  324. 'upload-start': (files: UppyFile<M, B>[]) => void
  325. }
  326. /** `OmitFirstArg<typeof someArray>` is the type of the returned value of `someArray.slice(1)`. */
  327. type OmitFirstArg<T> = T extends [any, ...infer U] ? U : never
  328. const defaultUploadState = {
  329. totalProgress: 0,
  330. allowNewUpload: true,
  331. error: null,
  332. recoveredState: null,
  333. }
  334. /**
  335. * Uppy Core module.
  336. * Manages plugins, state updates, acts as an event bus,
  337. * adds/removes files and metadata.
  338. */
  339. export class Uppy<M extends Meta, B extends Body = Record<string, never>> {
  340. static VERSION = packageJson.version
  341. #plugins: Record<string, UnknownPlugin<M, B>[]> = Object.create(null)
  342. #restricter
  343. #storeUnsubscribe
  344. #emitter = ee()
  345. #preProcessors: Set<Processor> = new Set()
  346. #uploaders: Set<Processor> = new Set()
  347. #postProcessors: Set<Processor> = new Set()
  348. defaultLocale: Locale
  349. locale!: Locale
  350. // The user optionally passes in options, but we set defaults for missing options.
  351. // We consider all options present after the contructor has run.
  352. opts: NonNullableUppyOptions<M, B>
  353. store: NonNullableUppyOptions<M, B>['store']
  354. i18n!: I18n
  355. i18nArray!: Translator['translateArray']
  356. scheduledAutoProceed: ReturnType<typeof setTimeout> | null = null
  357. wasOffline = false
  358. /**
  359. * Instantiate Uppy
  360. */
  361. constructor(opts?: UppyOptionsWithOptionalRestrictions<M, B>) {
  362. this.defaultLocale = locale as any as Locale
  363. const defaultOptions: UppyOptions<Record<string, unknown>, B> = {
  364. id: 'uppy',
  365. autoProceed: false,
  366. allowMultipleUploadBatches: true,
  367. debug: false,
  368. restrictions: defaultRestrictionOptions,
  369. meta: {},
  370. onBeforeFileAdded: (file, files) => !Object.hasOwn(files, file.id),
  371. onBeforeUpload: (files) => files,
  372. store: new DefaultStore(),
  373. logger: justErrorsLogger,
  374. infoTimeout: 5000,
  375. }
  376. const merged = { ...defaultOptions, ...opts } as Omit<
  377. NonNullableUppyOptions<M, B>,
  378. 'restrictions'
  379. >
  380. // Merge default options with the ones set by user,
  381. // making sure to merge restrictions too
  382. this.opts = {
  383. ...merged,
  384. restrictions: {
  385. ...(defaultOptions.restrictions as Restrictions),
  386. ...(opts && opts.restrictions),
  387. },
  388. }
  389. // Support debug: true for backwards-compatability, unless logger is set in opts
  390. // opts instead of this.opts to avoid comparing objects — we set logger: justErrorsLogger in defaultOptions
  391. if (opts && opts.logger && opts.debug) {
  392. this.log(
  393. '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`.',
  394. 'warning',
  395. )
  396. } else if (opts && opts.debug) {
  397. this.opts.logger = debugLogger
  398. }
  399. this.log(`Using Core v${Uppy.VERSION}`)
  400. this.i18nInit()
  401. this.store = this.opts.store
  402. this.setState({
  403. ...defaultUploadState,
  404. plugins: {},
  405. files: {},
  406. currentUploads: {},
  407. capabilities: {
  408. uploadProgress: supportsUploadProgress(),
  409. individualCancellation: true,
  410. resumableUploads: false,
  411. },
  412. meta: { ...this.opts.meta },
  413. info: [],
  414. })
  415. this.#restricter = new Restricter<M, B>(
  416. () => this.opts,
  417. () => this.i18n,
  418. )
  419. this.#storeUnsubscribe = this.store.subscribe(
  420. (prevState, nextState, patch) => {
  421. this.emit('state-update', prevState, nextState, patch)
  422. this.updateAll(nextState)
  423. },
  424. )
  425. // Exposing uppy object on window for debugging and testing
  426. if (this.opts.debug && typeof window !== 'undefined') {
  427. // eslint-disable-next-line @typescript-eslint/ban-ts-comment
  428. // @ts-ignore Mutating the global object for debug purposes
  429. window[this.opts.id] = this
  430. }
  431. this.#addListeners()
  432. }
  433. emit<T extends keyof UppyEventMap<M, B>>(
  434. event: T,
  435. ...args: Parameters<UppyEventMap<M, B>[T]>
  436. ): void {
  437. this.#emitter.emit(event, ...args)
  438. }
  439. on<K extends keyof UppyEventMap<M, B>>(
  440. event: K,
  441. callback: UppyEventMap<M, B>[K],
  442. ): this {
  443. this.#emitter.on(event, callback)
  444. return this
  445. }
  446. once<K extends keyof UppyEventMap<M, B>>(
  447. event: K,
  448. callback: UppyEventMap<M, B>[K],
  449. ): this {
  450. this.#emitter.once(event, callback)
  451. return this
  452. }
  453. off<K extends keyof UppyEventMap<M, B>>(
  454. event: K,
  455. callback: UppyEventMap<M, B>[K],
  456. ): this {
  457. this.#emitter.off(event, callback)
  458. return this
  459. }
  460. /**
  461. * Iterate on all plugins and run `update` on them.
  462. * Called each time state changes.
  463. *
  464. */
  465. updateAll(state: Partial<State<M, B>>): void {
  466. this.iteratePlugins((plugin: UnknownPlugin<M, B>) => {
  467. plugin.update(state)
  468. })
  469. }
  470. /**
  471. * Updates state with a patch
  472. */
  473. setState(patch?: Partial<State<M, B>>): void {
  474. this.store.setState(patch)
  475. }
  476. /**
  477. * Returns current state.
  478. */
  479. getState(): State<M, B> {
  480. return this.store.getState()
  481. }
  482. patchFilesState(filesWithNewState: {
  483. [id: string]: Partial<UppyFile<M, B>>
  484. }): void {
  485. const existingFilesState = this.getState().files
  486. this.setState({
  487. files: {
  488. ...existingFilesState,
  489. ...Object.fromEntries(
  490. Object.entries(filesWithNewState).map(([fileID, newFileState]) => [
  491. fileID,
  492. {
  493. ...existingFilesState[fileID],
  494. ...newFileState,
  495. },
  496. ]),
  497. ),
  498. },
  499. })
  500. }
  501. /**
  502. * Shorthand to set state for a specific file.
  503. */
  504. setFileState(fileID: string, state: Partial<UppyFile<M, B>>): void {
  505. if (!this.getState().files[fileID]) {
  506. throw new Error(
  507. `Can’t set state for ${fileID} (the file could have been removed)`,
  508. )
  509. }
  510. this.patchFilesState({ [fileID]: state })
  511. }
  512. i18nInit(): void {
  513. const onMissingKey = (key: string): void =>
  514. this.log(`Missing i18n string: ${key}`, 'error')
  515. const translator = new Translator([this.defaultLocale, this.opts.locale], {
  516. onMissingKey,
  517. })
  518. this.i18n = translator.translate.bind(translator)
  519. this.i18nArray = translator.translateArray.bind(translator)
  520. this.locale = translator.locale
  521. }
  522. setOptions(newOpts: MinimalRequiredOptions<M, B>): void {
  523. this.opts = {
  524. ...this.opts,
  525. ...(newOpts as UppyOptions<M, B>),
  526. restrictions: {
  527. ...this.opts.restrictions,
  528. ...(newOpts?.restrictions as Restrictions),
  529. },
  530. }
  531. if (newOpts.meta) {
  532. this.setMeta(newOpts.meta)
  533. }
  534. this.i18nInit()
  535. if (newOpts.locale) {
  536. this.iteratePlugins((plugin) => {
  537. plugin.setOptions(newOpts)
  538. })
  539. }
  540. // Note: this is not the preact `setState`, it's an internal function that has the same name.
  541. this.setState(undefined) // so that UI re-renders with new options
  542. }
  543. clear(): void {
  544. const { capabilities, currentUploads } = this.getState()
  545. if (
  546. Object.keys(currentUploads).length > 0 &&
  547. !capabilities.individualCancellation
  548. ) {
  549. throw new Error(
  550. 'The installed uploader plugin does not allow removing files during an upload.',
  551. )
  552. }
  553. this.setState({ ...defaultUploadState, files: {} })
  554. }
  555. addPreProcessor(fn: Processor): void {
  556. this.#preProcessors.add(fn)
  557. }
  558. removePreProcessor(fn: Processor): boolean {
  559. return this.#preProcessors.delete(fn)
  560. }
  561. addPostProcessor(fn: Processor): void {
  562. this.#postProcessors.add(fn)
  563. }
  564. removePostProcessor(fn: Processor): boolean {
  565. return this.#postProcessors.delete(fn)
  566. }
  567. addUploader(fn: Processor): void {
  568. this.#uploaders.add(fn)
  569. }
  570. removeUploader(fn: Processor): boolean {
  571. return this.#uploaders.delete(fn)
  572. }
  573. setMeta(data: Partial<M>): void {
  574. const updatedMeta = { ...this.getState().meta, ...data }
  575. const updatedFiles = { ...this.getState().files }
  576. Object.keys(updatedFiles).forEach((fileID) => {
  577. updatedFiles[fileID] = {
  578. ...updatedFiles[fileID],
  579. meta: { ...updatedFiles[fileID].meta, ...data },
  580. }
  581. })
  582. this.log('Adding metadata:')
  583. this.log(data)
  584. this.setState({
  585. meta: updatedMeta,
  586. files: updatedFiles,
  587. })
  588. }
  589. setFileMeta(fileID: string, data: State<M, B>['meta']): void {
  590. const updatedFiles = { ...this.getState().files }
  591. if (!updatedFiles[fileID]) {
  592. this.log(
  593. 'Was trying to set metadata for a file that has been removed: ',
  594. fileID,
  595. )
  596. return
  597. }
  598. const newMeta = { ...updatedFiles[fileID].meta, ...data }
  599. updatedFiles[fileID] = { ...updatedFiles[fileID], meta: newMeta }
  600. this.setState({ files: updatedFiles })
  601. }
  602. /**
  603. * Get a file object.
  604. */
  605. getFile(fileID: string): UppyFile<M, B> {
  606. return this.getState().files[fileID]
  607. }
  608. /**
  609. * Get all files in an array.
  610. */
  611. getFiles(): UppyFile<M, B>[] {
  612. const { files } = this.getState()
  613. return Object.values(files)
  614. }
  615. getFilesByIds(ids: string[]): UppyFile<M, B>[] {
  616. return ids.map((id) => this.getFile(id))
  617. }
  618. getObjectOfFilesPerState(): {
  619. newFiles: UppyFile<M, B>[]
  620. startedFiles: UppyFile<M, B>[]
  621. uploadStartedFiles: UppyFile<M, B>[]
  622. pausedFiles: UppyFile<M, B>[]
  623. completeFiles: UppyFile<M, B>[]
  624. erroredFiles: UppyFile<M, B>[]
  625. inProgressFiles: UppyFile<M, B>[]
  626. inProgressNotPausedFiles: UppyFile<M, B>[]
  627. processingFiles: UppyFile<M, B>[]
  628. isUploadStarted: boolean
  629. isAllComplete: boolean
  630. isAllErrored: boolean
  631. isAllPaused: boolean
  632. isUploadInProgress: boolean
  633. isSomeGhost: boolean
  634. } {
  635. const { files: filesObject, totalProgress, error } = this.getState()
  636. const files = Object.values(filesObject)
  637. const inProgressFiles: UppyFile<M, B>[] = []
  638. const newFiles: UppyFile<M, B>[] = []
  639. const startedFiles: UppyFile<M, B>[] = []
  640. const uploadStartedFiles: UppyFile<M, B>[] = []
  641. const pausedFiles: UppyFile<M, B>[] = []
  642. const completeFiles: UppyFile<M, B>[] = []
  643. const erroredFiles: UppyFile<M, B>[] = []
  644. const inProgressNotPausedFiles: UppyFile<M, B>[] = []
  645. const processingFiles: UppyFile<M, B>[] = []
  646. for (const file of files) {
  647. const { progress } = file
  648. if (!progress.uploadComplete && progress.uploadStarted) {
  649. inProgressFiles.push(file)
  650. if (!file.isPaused) {
  651. inProgressNotPausedFiles.push(file)
  652. }
  653. }
  654. if (!progress.uploadStarted) {
  655. newFiles.push(file)
  656. }
  657. if (
  658. progress.uploadStarted ||
  659. progress.preprocess ||
  660. progress.postprocess
  661. ) {
  662. startedFiles.push(file)
  663. }
  664. if (progress.uploadStarted) {
  665. uploadStartedFiles.push(file)
  666. }
  667. if (file.isPaused) {
  668. pausedFiles.push(file)
  669. }
  670. if (progress.uploadComplete) {
  671. completeFiles.push(file)
  672. }
  673. if (file.error) {
  674. erroredFiles.push(file)
  675. }
  676. if (progress.preprocess || progress.postprocess) {
  677. processingFiles.push(file)
  678. }
  679. }
  680. return {
  681. newFiles,
  682. startedFiles,
  683. uploadStartedFiles,
  684. pausedFiles,
  685. completeFiles,
  686. erroredFiles,
  687. inProgressFiles,
  688. inProgressNotPausedFiles,
  689. processingFiles,
  690. isUploadStarted: uploadStartedFiles.length > 0,
  691. isAllComplete:
  692. totalProgress === 100 &&
  693. completeFiles.length === files.length &&
  694. processingFiles.length === 0,
  695. isAllErrored: !!error && erroredFiles.length === files.length,
  696. isAllPaused:
  697. inProgressFiles.length !== 0 &&
  698. pausedFiles.length === inProgressFiles.length,
  699. isUploadInProgress: inProgressFiles.length > 0,
  700. isSomeGhost: files.some((file) => file.isGhost),
  701. }
  702. }
  703. #informAndEmit(
  704. errors: {
  705. name: string
  706. message: string
  707. isUserFacing?: boolean
  708. details?: string
  709. isRestriction?: boolean
  710. file?: UppyFile<M, B>
  711. }[],
  712. ): void {
  713. for (const error of errors) {
  714. if (error.isRestriction) {
  715. this.emit(
  716. 'restriction-failed',
  717. error.file,
  718. error as RestrictionError<M, B>,
  719. )
  720. } else {
  721. this.emit('error', error, error.file)
  722. }
  723. this.log(error, 'warning')
  724. }
  725. const userFacingErrors = errors.filter((error) => error.isUserFacing)
  726. // don't flood the user: only show the first 4 toasts
  727. const maxNumToShow = 4
  728. const firstErrors = userFacingErrors.slice(0, maxNumToShow)
  729. const additionalErrors = userFacingErrors.slice(maxNumToShow)
  730. firstErrors.forEach(({ message, details = '' }) => {
  731. this.info({ message, details }, 'error', this.opts.infoTimeout)
  732. })
  733. if (additionalErrors.length > 0) {
  734. this.info({
  735. message: this.i18n('additionalRestrictionsFailed', {
  736. count: additionalErrors.length,
  737. }),
  738. })
  739. }
  740. }
  741. validateSingleFile(file: ValidateableFile<M, B>): string | null {
  742. try {
  743. this.#restricter.validateSingleFile(file)
  744. } catch (err) {
  745. return err.message
  746. }
  747. return null
  748. }
  749. validateAggregateRestrictions(
  750. files: ValidateableFile<M, B>[],
  751. ): string | null {
  752. const existingFiles = this.getFiles()
  753. try {
  754. this.#restricter.validateAggregateRestrictions(existingFiles, files)
  755. } catch (err) {
  756. return err.message
  757. }
  758. return null
  759. }
  760. #checkRequiredMetaFieldsOnFile(file: UppyFile<M, B>): boolean {
  761. const { missingFields, error } =
  762. this.#restricter.getMissingRequiredMetaFields(file)
  763. if (missingFields.length > 0) {
  764. this.setFileState(file.id, { missingRequiredMetaFields: missingFields })
  765. this.log(error.message)
  766. this.emit('restriction-failed', file, error)
  767. return false
  768. }
  769. return true
  770. }
  771. #checkRequiredMetaFields(files: State<M, B>['files']): boolean {
  772. let success = true
  773. for (const file of Object.values(files)) {
  774. if (!this.#checkRequiredMetaFieldsOnFile(file)) {
  775. success = false
  776. }
  777. }
  778. return success
  779. }
  780. #assertNewUploadAllowed(file?: UppyFile<M, B>): void {
  781. const { allowNewUpload } = this.getState()
  782. if (allowNewUpload === false) {
  783. const error = new RestrictionError<M, B>(
  784. this.i18n('noMoreFilesAllowed'),
  785. {
  786. file,
  787. },
  788. )
  789. this.#informAndEmit([error])
  790. throw error
  791. }
  792. }
  793. checkIfFileAlreadyExists(fileID: string): boolean {
  794. const { files } = this.getState()
  795. if (files[fileID] && !files[fileID].isGhost) {
  796. return true
  797. }
  798. return false
  799. }
  800. /**
  801. * Create a file state object based on user-provided `addFile()` options.
  802. */
  803. #transformFile(fileDescriptorOrFile: File | UppyFile<M, B>): UppyFile<M, B> {
  804. // Uppy expects files in { name, type, size, data } format.
  805. // If the actual File object is passed from input[type=file] or drag-drop,
  806. // we normalize it to match Uppy file object
  807. const file = (
  808. fileDescriptorOrFile instanceof File ?
  809. {
  810. name: fileDescriptorOrFile.name,
  811. type: fileDescriptorOrFile.type,
  812. size: fileDescriptorOrFile.size,
  813. data: fileDescriptorOrFile,
  814. }
  815. : fileDescriptorOrFile) as UppyFile<M, B>
  816. const fileType = getFileType(file)
  817. const fileName = getFileName(fileType, file)
  818. const fileExtension = getFileNameAndExtension(fileName).extension
  819. const id = getSafeFileId(file, this.getID())
  820. const meta = file.meta || {}
  821. meta.name = fileName
  822. meta.type = fileType
  823. // `null` means the size is unknown.
  824. const size =
  825. Number.isFinite(file.data.size) ? file.data.size : (null as never)
  826. return {
  827. source: file.source || '',
  828. id,
  829. name: fileName,
  830. extension: fileExtension || '',
  831. meta: {
  832. ...this.getState().meta,
  833. ...meta,
  834. },
  835. type: fileType,
  836. data: file.data,
  837. progress: {
  838. percentage: 0,
  839. bytesUploaded: false,
  840. bytesTotal: size,
  841. uploadComplete: false,
  842. uploadStarted: null,
  843. },
  844. size,
  845. isGhost: false,
  846. isRemote: file.isRemote || false,
  847. remote: file.remote,
  848. preview: file.preview,
  849. }
  850. }
  851. // Schedule an upload if `autoProceed` is enabled.
  852. #startIfAutoProceed(): void {
  853. if (this.opts.autoProceed && !this.scheduledAutoProceed) {
  854. this.scheduledAutoProceed = setTimeout(() => {
  855. this.scheduledAutoProceed = null
  856. this.upload().catch((err) => {
  857. if (!err.isRestriction) {
  858. this.log(err.stack || err.message || err)
  859. }
  860. })
  861. }, 4)
  862. }
  863. }
  864. #checkAndUpdateFileState(filesToAdd: UppyFile<M, B>[]): {
  865. nextFilesState: State<M, B>['files']
  866. validFilesToAdd: UppyFile<M, B>[]
  867. errors: RestrictionError<M, B>[]
  868. } {
  869. const { files: existingFiles } = this.getState()
  870. // create a copy of the files object only once
  871. const nextFilesState = { ...existingFiles }
  872. const validFilesToAdd: UppyFile<M, B>[] = []
  873. const errors: RestrictionError<M, B>[] = []
  874. for (const fileToAdd of filesToAdd) {
  875. try {
  876. let newFile = this.#transformFile(fileToAdd)
  877. // If a file has been recovered (Golden Retriever), but we were unable to recover its data (probably too large),
  878. // users are asked to re-select these half-recovered files and then this method will be called again.
  879. // In order to keep the progress, meta and everything else, we keep the existing file,
  880. // but we replace `data`, and we remove `isGhost`, because the file is no longer a ghost now
  881. const isGhost = existingFiles[newFile.id]?.isGhost
  882. if (isGhost) {
  883. const existingFileState = existingFiles[newFile.id]
  884. newFile = {
  885. ...existingFileState,
  886. isGhost: false,
  887. data: fileToAdd.data,
  888. }
  889. this.log(
  890. `Replaced the blob in the restored ghost file: ${newFile.name}, ${newFile.id}`,
  891. )
  892. }
  893. const onBeforeFileAddedResult = this.opts.onBeforeFileAdded(
  894. newFile,
  895. nextFilesState,
  896. )
  897. if (
  898. !onBeforeFileAddedResult &&
  899. this.checkIfFileAlreadyExists(newFile.id)
  900. ) {
  901. throw new RestrictionError(
  902. this.i18n('noDuplicates', { fileName: newFile.name }),
  903. { file: fileToAdd },
  904. )
  905. }
  906. // Pass through reselected files from Golden Retriever
  907. if (onBeforeFileAddedResult === false && !isGhost) {
  908. // Don’t show UI info for this error, as it should be done by the developer
  909. throw new RestrictionError(
  910. 'Cannot add the file because onBeforeFileAdded returned false.',
  911. { isUserFacing: false, file: fileToAdd },
  912. )
  913. } else if (
  914. typeof onBeforeFileAddedResult === 'object' &&
  915. onBeforeFileAddedResult !== null
  916. ) {
  917. newFile = onBeforeFileAddedResult
  918. }
  919. this.#restricter.validateSingleFile(newFile)
  920. // need to add it to the new local state immediately, so we can use the state to validate the next files too
  921. nextFilesState[newFile.id] = newFile
  922. validFilesToAdd.push(newFile)
  923. } catch (err) {
  924. errors.push(err as any)
  925. }
  926. }
  927. try {
  928. // need to run this separately because it's much more slow, so if we run it inside the for-loop it will be very slow
  929. // when many files are added
  930. this.#restricter.validateAggregateRestrictions(
  931. Object.values(existingFiles),
  932. validFilesToAdd,
  933. )
  934. } catch (err) {
  935. errors.push(err as any)
  936. // If we have any aggregate error, don't allow adding this batch
  937. return {
  938. nextFilesState: existingFiles,
  939. validFilesToAdd: [],
  940. errors,
  941. }
  942. }
  943. return {
  944. nextFilesState,
  945. validFilesToAdd,
  946. errors,
  947. }
  948. }
  949. /**
  950. * Add a new file to `state.files`. This will run `onBeforeFileAdded`,
  951. * try to guess file type in a clever way, check file against restrictions,
  952. * and start an upload if `autoProceed === true`.
  953. */
  954. addFile(file: File | MinimalRequiredUppyFile<M, B>): UppyFile<M, B>['id'] {
  955. this.#assertNewUploadAllowed(file as UppyFile<M, B>)
  956. const { nextFilesState, validFilesToAdd, errors } =
  957. this.#checkAndUpdateFileState([file as UppyFile<M, B>])
  958. const restrictionErrors = errors.filter((error) => error.isRestriction)
  959. this.#informAndEmit(restrictionErrors)
  960. if (errors.length > 0) throw errors[0]
  961. this.setState({ files: nextFilesState })
  962. const [firstValidFileToAdd] = validFilesToAdd
  963. this.emit('file-added', firstValidFileToAdd)
  964. this.emit('files-added', validFilesToAdd)
  965. this.log(
  966. `Added file: ${firstValidFileToAdd.name}, ${firstValidFileToAdd.id}, mime type: ${firstValidFileToAdd.type}`,
  967. )
  968. this.#startIfAutoProceed()
  969. return firstValidFileToAdd.id
  970. }
  971. /**
  972. * Add multiple files to `state.files`. See the `addFile()` documentation.
  973. *
  974. * If an error occurs while adding a file, it is logged and the user is notified.
  975. * This is good for UI plugins, but not for programmatic use.
  976. * Programmatic users should usually still use `addFile()` on individual files.
  977. */
  978. addFiles(fileDescriptors: MinimalRequiredUppyFile<M, B>[]): void {
  979. this.#assertNewUploadAllowed()
  980. const { nextFilesState, validFilesToAdd, errors } =
  981. this.#checkAndUpdateFileState(fileDescriptors as UppyFile<M, B>[])
  982. const restrictionErrors = errors.filter((error) => error.isRestriction)
  983. this.#informAndEmit(restrictionErrors)
  984. const nonRestrictionErrors = errors.filter((error) => !error.isRestriction)
  985. if (nonRestrictionErrors.length > 0) {
  986. let message = 'Multiple errors occurred while adding files:\n'
  987. nonRestrictionErrors.forEach((subError) => {
  988. message += `\n * ${subError.message}`
  989. })
  990. this.info(
  991. {
  992. message: this.i18n('addBulkFilesFailed', {
  993. smart_count: nonRestrictionErrors.length,
  994. }),
  995. details: message,
  996. },
  997. 'error',
  998. this.opts.infoTimeout,
  999. )
  1000. if (typeof AggregateError === 'function') {
  1001. throw new AggregateError(nonRestrictionErrors, message)
  1002. } else {
  1003. const err = new Error(message)
  1004. // @ts-expect-error fallback when AggregateError is not available
  1005. err.errors = nonRestrictionErrors
  1006. throw err
  1007. }
  1008. }
  1009. // OK, we haven't thrown an error, we can start updating state and emitting events now:
  1010. this.setState({ files: nextFilesState })
  1011. validFilesToAdd.forEach((file) => {
  1012. this.emit('file-added', file)
  1013. })
  1014. this.emit('files-added', validFilesToAdd)
  1015. if (validFilesToAdd.length > 5) {
  1016. this.log(`Added batch of ${validFilesToAdd.length} files`)
  1017. } else {
  1018. Object.values(validFilesToAdd).forEach((file) => {
  1019. this.log(
  1020. `Added file: ${file.name}\n id: ${file.id}\n type: ${file.type}`,
  1021. )
  1022. })
  1023. }
  1024. if (validFilesToAdd.length > 0) {
  1025. this.#startIfAutoProceed()
  1026. }
  1027. }
  1028. removeFiles(fileIDs: string[]): void {
  1029. const { files, currentUploads } = this.getState()
  1030. const updatedFiles = { ...files }
  1031. const updatedUploads = { ...currentUploads }
  1032. const removedFiles = Object.create(null)
  1033. fileIDs.forEach((fileID) => {
  1034. if (files[fileID]) {
  1035. removedFiles[fileID] = files[fileID]
  1036. delete updatedFiles[fileID]
  1037. }
  1038. })
  1039. // Remove files from the `fileIDs` list in each upload.
  1040. function fileIsNotRemoved(uploadFileID: string): boolean {
  1041. return removedFiles[uploadFileID] === undefined
  1042. }
  1043. Object.keys(updatedUploads).forEach((uploadID) => {
  1044. const newFileIDs =
  1045. currentUploads[uploadID].fileIDs.filter(fileIsNotRemoved)
  1046. // Remove the upload if no files are associated with it anymore.
  1047. if (newFileIDs.length === 0) {
  1048. delete updatedUploads[uploadID]
  1049. return
  1050. }
  1051. const { capabilities } = this.getState()
  1052. if (
  1053. newFileIDs.length !== currentUploads[uploadID].fileIDs.length &&
  1054. !capabilities.individualCancellation
  1055. ) {
  1056. throw new Error(
  1057. 'The installed uploader plugin does not allow removing files during an upload.',
  1058. )
  1059. }
  1060. updatedUploads[uploadID] = {
  1061. ...currentUploads[uploadID],
  1062. fileIDs: newFileIDs,
  1063. }
  1064. })
  1065. const stateUpdate: Partial<State<M, B>> = {
  1066. currentUploads: updatedUploads,
  1067. files: updatedFiles,
  1068. }
  1069. // If all files were removed - allow new uploads,
  1070. // and clear recoveredState
  1071. if (Object.keys(updatedFiles).length === 0) {
  1072. stateUpdate.allowNewUpload = true
  1073. stateUpdate.error = null
  1074. stateUpdate.recoveredState = null
  1075. }
  1076. this.setState(stateUpdate)
  1077. this.calculateTotalProgress()
  1078. const removedFileIDs = Object.keys(removedFiles)
  1079. removedFileIDs.forEach((fileID) => {
  1080. this.emit('file-removed', removedFiles[fileID])
  1081. })
  1082. if (removedFileIDs.length > 5) {
  1083. this.log(`Removed ${removedFileIDs.length} files`)
  1084. } else {
  1085. this.log(`Removed files: ${removedFileIDs.join(', ')}`)
  1086. }
  1087. }
  1088. removeFile(fileID: string): void {
  1089. this.removeFiles([fileID])
  1090. }
  1091. pauseResume(fileID: string): boolean | undefined {
  1092. if (
  1093. !this.getState().capabilities.resumableUploads ||
  1094. this.getFile(fileID).progress.uploadComplete
  1095. ) {
  1096. return undefined
  1097. }
  1098. const file = this.getFile(fileID)
  1099. const wasPaused = file.isPaused || false
  1100. const isPaused = !wasPaused
  1101. this.setFileState(fileID, {
  1102. isPaused,
  1103. })
  1104. this.emit('upload-pause', file, isPaused)
  1105. return isPaused
  1106. }
  1107. pauseAll(): void {
  1108. const updatedFiles = { ...this.getState().files }
  1109. const inProgressUpdatedFiles = Object.keys(updatedFiles).filter((file) => {
  1110. return (
  1111. !updatedFiles[file].progress.uploadComplete &&
  1112. updatedFiles[file].progress.uploadStarted
  1113. )
  1114. })
  1115. inProgressUpdatedFiles.forEach((file) => {
  1116. const updatedFile = { ...updatedFiles[file], isPaused: true }
  1117. updatedFiles[file] = updatedFile
  1118. })
  1119. this.setState({ files: updatedFiles })
  1120. this.emit('pause-all')
  1121. }
  1122. resumeAll(): void {
  1123. const updatedFiles = { ...this.getState().files }
  1124. const inProgressUpdatedFiles = Object.keys(updatedFiles).filter((file) => {
  1125. return (
  1126. !updatedFiles[file].progress.uploadComplete &&
  1127. updatedFiles[file].progress.uploadStarted
  1128. )
  1129. })
  1130. inProgressUpdatedFiles.forEach((file) => {
  1131. const updatedFile = {
  1132. ...updatedFiles[file],
  1133. isPaused: false,
  1134. error: null,
  1135. }
  1136. updatedFiles[file] = updatedFile
  1137. })
  1138. this.setState({ files: updatedFiles })
  1139. this.emit('resume-all')
  1140. }
  1141. retryAll(): Promise<UploadResult<M, B> | undefined> {
  1142. const updatedFiles = { ...this.getState().files }
  1143. const filesToRetry = Object.keys(updatedFiles).filter((file) => {
  1144. return updatedFiles[file].error
  1145. })
  1146. filesToRetry.forEach((file) => {
  1147. const updatedFile = {
  1148. ...updatedFiles[file],
  1149. isPaused: false,
  1150. error: null,
  1151. }
  1152. updatedFiles[file] = updatedFile
  1153. })
  1154. this.setState({
  1155. files: updatedFiles,
  1156. error: null,
  1157. })
  1158. this.emit('retry-all', Object.values(updatedFiles))
  1159. if (filesToRetry.length === 0) {
  1160. return Promise.resolve({
  1161. successful: [],
  1162. failed: [],
  1163. })
  1164. }
  1165. const uploadID = this.#createUpload(filesToRetry, {
  1166. forceAllowNewUpload: true, // create new upload even if allowNewUpload: false
  1167. })
  1168. return this.#runUpload(uploadID)
  1169. }
  1170. cancelAll(): void {
  1171. this.emit('cancel-all')
  1172. const { files } = this.getState()
  1173. const fileIDs = Object.keys(files)
  1174. if (fileIDs.length) {
  1175. this.removeFiles(fileIDs)
  1176. }
  1177. this.setState(defaultUploadState)
  1178. }
  1179. retryUpload(fileID: string): Promise<UploadResult<M, B> | undefined> {
  1180. this.setFileState(fileID, {
  1181. error: null,
  1182. isPaused: false,
  1183. })
  1184. this.emit('upload-retry', this.getFile(fileID))
  1185. const uploadID = this.#createUpload([fileID], {
  1186. forceAllowNewUpload: true, // create new upload even if allowNewUpload: false
  1187. })
  1188. return this.#runUpload(uploadID)
  1189. }
  1190. logout(): void {
  1191. this.iteratePlugins((plugin) => {
  1192. ;(plugin as UnknownProviderPlugin<M, B>).provider?.logout?.()
  1193. })
  1194. }
  1195. // ___Why throttle at 500ms?
  1196. // - We must throttle at >250ms for superfocus in Dashboard to work well
  1197. // (because animation takes 0.25s, and we want to wait for all animations to be over before refocusing).
  1198. // [Practical Check]: if thottle is at 100ms, then if you are uploading a file,
  1199. // and click 'ADD MORE FILES', - focus won't activate in Firefox.
  1200. // - We must throttle at around >500ms to avoid performance lags.
  1201. // [Practical Check] Firefox, try to upload a big file for a prolonged period of time. Laptop will start to heat up.
  1202. // todo when uploading multiple files, this will cause problems because they share the same throttle,
  1203. // meaning some files might never get their progress reported (eaten up by progress events from other files)
  1204. calculateProgress = throttle(
  1205. (file, data) => {
  1206. const fileInState = this.getFile(file?.id)
  1207. if (file == null || !fileInState) {
  1208. this.log(
  1209. `Not setting progress for a file that has been removed: ${file?.id}`,
  1210. )
  1211. return
  1212. }
  1213. if (fileInState.progress.percentage === 100) {
  1214. this.log(
  1215. `Not setting progress for a file that has been already uploaded: ${file.id}`,
  1216. )
  1217. return
  1218. }
  1219. // bytesTotal may be null or zero; in that case we can't divide by it
  1220. const canHavePercentage =
  1221. Number.isFinite(data.bytesTotal) && data.bytesTotal > 0
  1222. this.setFileState(file.id, {
  1223. progress: {
  1224. ...fileInState.progress,
  1225. bytesUploaded: data.bytesUploaded,
  1226. bytesTotal: data.bytesTotal,
  1227. percentage:
  1228. canHavePercentage ?
  1229. Math.round((data.bytesUploaded / data.bytesTotal) * 100)
  1230. : 0,
  1231. },
  1232. })
  1233. this.calculateTotalProgress()
  1234. },
  1235. 500,
  1236. { leading: true, trailing: true },
  1237. )
  1238. calculateTotalProgress(): void {
  1239. // calculate total progress, using the number of files currently uploading,
  1240. // multiplied by 100 and the summ of individual progress of each file
  1241. const files = this.getFiles()
  1242. const inProgress = files.filter((file) => {
  1243. return (
  1244. file.progress.uploadStarted ||
  1245. file.progress.preprocess ||
  1246. file.progress.postprocess
  1247. )
  1248. })
  1249. if (inProgress.length === 0) {
  1250. this.emit('progress', 0)
  1251. this.setState({ totalProgress: 0 })
  1252. return
  1253. }
  1254. const sizedFiles = inProgress.filter(
  1255. (file) => file.progress.bytesTotal != null,
  1256. )
  1257. const unsizedFiles = inProgress.filter(
  1258. (file) => file.progress.bytesTotal == null,
  1259. )
  1260. if (sizedFiles.length === 0) {
  1261. const progressMax = inProgress.length * 100
  1262. const currentProgress = unsizedFiles.reduce((acc, file) => {
  1263. return acc + (file.progress.percentage as number)
  1264. }, 0)
  1265. const totalProgress = Math.round((currentProgress / progressMax) * 100)
  1266. this.setState({ totalProgress })
  1267. return
  1268. }
  1269. let totalSize = sizedFiles.reduce((acc, file) => {
  1270. return (acc + (file.progress.bytesTotal ?? 0)) as number
  1271. }, 0)
  1272. const averageSize = totalSize / sizedFiles.length
  1273. totalSize += averageSize * unsizedFiles.length
  1274. let uploadedSize = 0
  1275. sizedFiles.forEach((file) => {
  1276. uploadedSize += file.progress.bytesUploaded as number
  1277. })
  1278. unsizedFiles.forEach((file) => {
  1279. uploadedSize += (averageSize * (file.progress.percentage || 0)) / 100
  1280. })
  1281. let totalProgress =
  1282. totalSize === 0 ? 0 : Math.round((uploadedSize / totalSize) * 100)
  1283. // hot fix, because:
  1284. // uploadedSize ended up larger than totalSize, resulting in 1325% total
  1285. if (totalProgress > 100) {
  1286. totalProgress = 100
  1287. }
  1288. this.setState({ totalProgress })
  1289. this.emit('progress', totalProgress)
  1290. }
  1291. /**
  1292. * Registers listeners for all global actions, like:
  1293. * `error`, `file-removed`, `upload-progress`
  1294. */
  1295. #addListeners(): void {
  1296. // Type inference only works for inline functions so we have to type it again
  1297. const errorHandler: UppyEventMap<M, B>['error'] = (
  1298. error,
  1299. file,
  1300. response,
  1301. ) => {
  1302. let errorMsg = error.message || 'Unknown error'
  1303. if (error.details) {
  1304. errorMsg += ` ${error.details}`
  1305. }
  1306. this.setState({ error: errorMsg })
  1307. if (file != null && file.id in this.getState().files) {
  1308. this.setFileState(file.id, {
  1309. error: errorMsg,
  1310. response,
  1311. })
  1312. }
  1313. }
  1314. this.on('error', errorHandler)
  1315. this.on('upload-error', (file, error, response) => {
  1316. errorHandler(error, file, response)
  1317. if (typeof error === 'object' && error.message) {
  1318. this.log(error.message, 'error')
  1319. const newError = new Error(
  1320. this.i18n('failedToUpload', { file: file?.name ?? '' }),
  1321. ) as any // we may want a new custom error here
  1322. newError.isUserFacing = true // todo maybe don't do this with all errors?
  1323. newError.details = error.message
  1324. if (error.details) {
  1325. newError.details += ` ${error.details}`
  1326. }
  1327. this.#informAndEmit([newError])
  1328. } else {
  1329. this.#informAndEmit([error])
  1330. }
  1331. })
  1332. let uploadStalledWarningRecentlyEmitted: ReturnType<
  1333. typeof setTimeout
  1334. > | null = null
  1335. this.on('upload-stalled', (error, files) => {
  1336. const { message } = error
  1337. const details = files.map((file) => file.meta.name).join(', ')
  1338. if (!uploadStalledWarningRecentlyEmitted) {
  1339. this.info({ message, details }, 'warning', this.opts.infoTimeout)
  1340. uploadStalledWarningRecentlyEmitted = setTimeout(() => {
  1341. uploadStalledWarningRecentlyEmitted = null
  1342. }, this.opts.infoTimeout)
  1343. }
  1344. this.log(`${message} ${details}`.trim(), 'warning')
  1345. })
  1346. this.on('upload', () => {
  1347. this.setState({ error: null })
  1348. })
  1349. const onUploadStarted = (files: UppyFile<M, B>[]): void => {
  1350. const filesFiltered = files.filter((file) => {
  1351. const exists = file != null && this.getFile(file.id)
  1352. if (!exists)
  1353. this.log(
  1354. `Not setting progress for a file that has been removed: ${file?.id}`,
  1355. )
  1356. return exists
  1357. })
  1358. const filesState = Object.fromEntries(
  1359. filesFiltered.map((file) => [
  1360. file.id,
  1361. {
  1362. progress: {
  1363. uploadStarted: Date.now(),
  1364. uploadComplete: false,
  1365. percentage: 0,
  1366. bytesUploaded: 0,
  1367. bytesTotal: file.size,
  1368. } as FileProgressStarted,
  1369. },
  1370. ]),
  1371. )
  1372. this.patchFilesState(filesState)
  1373. }
  1374. this.on('upload-start', onUploadStarted)
  1375. this.on('upload-progress', this.calculateProgress)
  1376. this.on('upload-success', (file, uploadResp) => {
  1377. if (file == null || !this.getFile(file.id)) {
  1378. this.log(
  1379. `Not setting progress for a file that has been removed: ${file?.id}`,
  1380. )
  1381. return
  1382. }
  1383. const currentProgress = this.getFile(file.id).progress
  1384. this.setFileState(file.id, {
  1385. progress: {
  1386. ...currentProgress,
  1387. postprocess:
  1388. this.#postProcessors.size > 0 ?
  1389. {
  1390. mode: 'indeterminate',
  1391. }
  1392. : undefined,
  1393. uploadComplete: true,
  1394. percentage: 100,
  1395. bytesUploaded: currentProgress.bytesTotal,
  1396. } as FileProgressStarted,
  1397. response: uploadResp,
  1398. uploadURL: uploadResp.uploadURL,
  1399. isPaused: false,
  1400. })
  1401. // Remote providers sometimes don't tell us the file size,
  1402. // but we can know how many bytes we uploaded once the upload is complete.
  1403. if (file.size == null) {
  1404. this.setFileState(file.id, {
  1405. size: uploadResp.bytesUploaded || currentProgress.bytesTotal,
  1406. })
  1407. }
  1408. this.calculateTotalProgress()
  1409. })
  1410. this.on('preprocess-progress', (file, progress) => {
  1411. if (file == null || !this.getFile(file.id)) {
  1412. this.log(
  1413. `Not setting progress for a file that has been removed: ${file?.id}`,
  1414. )
  1415. return
  1416. }
  1417. this.setFileState(file.id, {
  1418. progress: { ...this.getFile(file.id).progress, preprocess: progress },
  1419. })
  1420. })
  1421. this.on('preprocess-complete', (file) => {
  1422. if (file == null || !this.getFile(file.id)) {
  1423. this.log(
  1424. `Not setting progress for a file that has been removed: ${file?.id}`,
  1425. )
  1426. return
  1427. }
  1428. const files = { ...this.getState().files }
  1429. files[file.id] = {
  1430. ...files[file.id],
  1431. progress: { ...files[file.id].progress },
  1432. }
  1433. delete files[file.id].progress.preprocess
  1434. this.setState({ files })
  1435. })
  1436. this.on('postprocess-progress', (file, progress) => {
  1437. if (file == null || !this.getFile(file.id)) {
  1438. this.log(
  1439. `Not setting progress for a file that has been removed: ${file?.id}`,
  1440. )
  1441. return
  1442. }
  1443. this.setFileState(file.id, {
  1444. progress: {
  1445. ...this.getState().files[file.id].progress,
  1446. postprocess: progress,
  1447. },
  1448. })
  1449. })
  1450. this.on('postprocess-complete', (file) => {
  1451. if (file == null || !this.getFile(file.id)) {
  1452. this.log(
  1453. `Not setting progress for a file that has been removed: ${file?.id}`,
  1454. )
  1455. return
  1456. }
  1457. const files = {
  1458. ...this.getState().files,
  1459. }
  1460. files[file.id] = {
  1461. ...files[file.id],
  1462. progress: {
  1463. ...files[file.id].progress,
  1464. },
  1465. }
  1466. delete files[file.id].progress.postprocess
  1467. this.setState({ files })
  1468. })
  1469. this.on('restored', () => {
  1470. // Files may have changed--ensure progress is still accurate.
  1471. this.calculateTotalProgress()
  1472. })
  1473. // @ts-expect-error should fix itself when dashboard it typed (also this doesn't belong here)
  1474. this.on('dashboard:file-edit-complete', (file) => {
  1475. if (file) {
  1476. this.#checkRequiredMetaFieldsOnFile(file)
  1477. }
  1478. })
  1479. // show informer if offline
  1480. if (typeof window !== 'undefined' && window.addEventListener) {
  1481. window.addEventListener('online', this.#updateOnlineStatus)
  1482. window.addEventListener('offline', this.#updateOnlineStatus)
  1483. setTimeout(this.#updateOnlineStatus, 3000)
  1484. }
  1485. }
  1486. updateOnlineStatus(): void {
  1487. const online = window.navigator.onLine ?? true
  1488. if (!online) {
  1489. this.emit('is-offline')
  1490. this.info(this.i18n('noInternetConnection'), 'error', 0)
  1491. this.wasOffline = true
  1492. } else {
  1493. this.emit('is-online')
  1494. if (this.wasOffline) {
  1495. this.emit('back-online')
  1496. this.info(this.i18n('connectedToInternet'), 'success', 3000)
  1497. this.wasOffline = false
  1498. }
  1499. }
  1500. }
  1501. #updateOnlineStatus = this.updateOnlineStatus.bind(this)
  1502. getID(): string {
  1503. return this.opts.id
  1504. }
  1505. /**
  1506. * Registers a plugin with Core.
  1507. */
  1508. use<T extends typeof BasePlugin<any, M, B>>(
  1509. Plugin: T,
  1510. // We want to let the plugin decide whether `opts` is optional or not
  1511. // so we spread the argument rather than defining `opts:` ourselves.
  1512. ...args: OmitFirstArg<ConstructorParameters<T>>
  1513. ): this {
  1514. if (typeof Plugin !== 'function') {
  1515. const msg =
  1516. `Expected a plugin class, but got ${
  1517. Plugin === null ? 'null' : typeof Plugin
  1518. }.` +
  1519. ' Please verify that the plugin was imported and spelled correctly.'
  1520. throw new TypeError(msg)
  1521. }
  1522. // Instantiate
  1523. const plugin = new Plugin(this, ...args)
  1524. const pluginId = plugin.id
  1525. if (!pluginId) {
  1526. throw new Error('Your plugin must have an id')
  1527. }
  1528. if (!plugin.type) {
  1529. throw new Error('Your plugin must have a type')
  1530. }
  1531. const existsPluginAlready = this.getPlugin(pluginId)
  1532. if (existsPluginAlready) {
  1533. const msg =
  1534. `Already found a plugin named '${existsPluginAlready.id}'. ` +
  1535. `Tried to use: '${pluginId}'.\n` +
  1536. 'Uppy plugins must have unique `id` options. See https://uppy.io/docs/plugins/#id.'
  1537. throw new Error(msg)
  1538. }
  1539. // @ts-expect-error does exist
  1540. if (Plugin.VERSION) {
  1541. // @ts-expect-error does exist
  1542. this.log(`Using ${pluginId} v${Plugin.VERSION}`)
  1543. }
  1544. if (plugin.type in this.#plugins) {
  1545. this.#plugins[plugin.type].push(plugin)
  1546. } else {
  1547. this.#plugins[plugin.type] = [plugin]
  1548. }
  1549. plugin.install()
  1550. this.emit('plugin-added', plugin)
  1551. return this
  1552. }
  1553. /**
  1554. * Find one Plugin by name.
  1555. */
  1556. getPlugin<T extends UnknownPlugin<M, B> = UnknownPlugin<M, B>>(
  1557. id: string,
  1558. ): T | undefined {
  1559. for (const plugins of Object.values(this.#plugins)) {
  1560. const foundPlugin = plugins.find((plugin) => plugin.id === id)
  1561. if (foundPlugin != null) return foundPlugin as T
  1562. }
  1563. return undefined
  1564. }
  1565. private [Symbol.for('uppy test: getPlugins')](
  1566. type: string,
  1567. ): UnknownPlugin<M, B>[] {
  1568. return this.#plugins[type]
  1569. }
  1570. /**
  1571. * Iterate through all `use`d plugins.
  1572. *
  1573. */
  1574. iteratePlugins(method: (plugin: UnknownPlugin<M, B>) => void): void {
  1575. Object.values(this.#plugins).flat(1).forEach(method)
  1576. }
  1577. /**
  1578. * Uninstall and remove a plugin.
  1579. *
  1580. * @param {object} instance The plugin instance to remove.
  1581. */
  1582. removePlugin(instance: UnknownPlugin<M, B>): void {
  1583. this.log(`Removing plugin ${instance.id}`)
  1584. this.emit('plugin-remove', instance)
  1585. if (instance.uninstall) {
  1586. instance.uninstall()
  1587. }
  1588. const list = this.#plugins[instance.type]
  1589. // list.indexOf failed here, because Vue3 converted the plugin instance
  1590. // to a Proxy object, which failed the strict comparison test:
  1591. // obj !== objProxy
  1592. const index = list.findIndex((item) => item.id === instance.id)
  1593. if (index !== -1) {
  1594. list.splice(index, 1)
  1595. }
  1596. const state = this.getState()
  1597. const updatedState = {
  1598. plugins: {
  1599. ...state.plugins,
  1600. [instance.id]: undefined,
  1601. },
  1602. }
  1603. this.setState(updatedState)
  1604. }
  1605. /**
  1606. * Uninstall all plugins and close down this Uppy instance.
  1607. */
  1608. destroy(): void {
  1609. this.log(
  1610. `Closing Uppy instance ${this.opts.id}: removing all files and uninstalling plugins`,
  1611. )
  1612. this.cancelAll()
  1613. this.#storeUnsubscribe()
  1614. this.iteratePlugins((plugin) => {
  1615. this.removePlugin(plugin)
  1616. })
  1617. if (typeof window !== 'undefined' && window.removeEventListener) {
  1618. window.removeEventListener('online', this.#updateOnlineStatus)
  1619. window.removeEventListener('offline', this.#updateOnlineStatus)
  1620. }
  1621. }
  1622. hideInfo(): void {
  1623. const { info } = this.getState()
  1624. this.setState({ info: info.slice(1) })
  1625. this.emit('info-hidden')
  1626. }
  1627. /**
  1628. * Set info message in `state.info`, so that UI plugins like `Informer`
  1629. * can display the message.
  1630. */
  1631. info(
  1632. message:
  1633. | string
  1634. | { message: string; details?: string | Record<string, string> },
  1635. type: LogLevel = 'info',
  1636. duration = 3000,
  1637. ): void {
  1638. const isComplexMessage = typeof message === 'object'
  1639. this.setState({
  1640. info: [
  1641. ...this.getState().info,
  1642. {
  1643. type,
  1644. message: isComplexMessage ? message.message : message,
  1645. details: isComplexMessage ? message.details : null,
  1646. },
  1647. ],
  1648. })
  1649. setTimeout(() => this.hideInfo(), duration)
  1650. this.emit('info-visible')
  1651. }
  1652. /**
  1653. * Passes messages to a function, provided in `opts.logger`.
  1654. * If `opts.logger: Uppy.debugLogger` or `opts.debug: true`, logs to the browser console.
  1655. */
  1656. log(message: string | Record<any, any> | Error, type?: string): void {
  1657. const { logger } = this.opts
  1658. switch (type) {
  1659. case 'error':
  1660. logger.error(message)
  1661. break
  1662. case 'warning':
  1663. logger.warn(message)
  1664. break
  1665. default:
  1666. logger.debug(message)
  1667. break
  1668. }
  1669. }
  1670. // We need to store request clients by a unique ID, so we can share RequestClient instances across files
  1671. // this allows us to do rate limiting and synchronous operations like refreshing provider tokens
  1672. // example: refreshing tokens: if each file has their own requestclient,
  1673. // we don't have any way to synchronize all requests in order to
  1674. // - block all requests
  1675. // - refresh the token
  1676. // - unblock all requests and allow them to run with a the new access token
  1677. // back when we had a requestclient per file, once an access token expired,
  1678. // all 6 files would go ahead and refresh the token at the same time
  1679. // (calling /refresh-token up to 6 times), which will probably fail for some providers
  1680. #requestClientById = new Map<string, unknown>()
  1681. registerRequestClient(id: string, client: unknown): void {
  1682. this.#requestClientById.set(id, client)
  1683. }
  1684. /** @protected */
  1685. getRequestClientForFile<Client>(file: UppyFile<M, B>): Client {
  1686. if (!file.remote)
  1687. throw new Error(
  1688. `Tried to get RequestClient for a non-remote file ${file.id}`,
  1689. )
  1690. const requestClient = this.#requestClientById.get(
  1691. file.remote.requestClientId,
  1692. )
  1693. if (requestClient == null)
  1694. throw new Error(
  1695. `requestClientId "${file.remote.requestClientId}" not registered for file "${file.id}"`,
  1696. )
  1697. return requestClient as Client
  1698. }
  1699. /**
  1700. * Restore an upload by its ID.
  1701. */
  1702. restore(uploadID: string): Promise<UploadResult<M, B> | undefined> {
  1703. this.log(`Core: attempting to restore upload "${uploadID}"`)
  1704. if (!this.getState().currentUploads[uploadID]) {
  1705. this.#removeUpload(uploadID)
  1706. return Promise.reject(new Error('Nonexistent upload'))
  1707. }
  1708. return this.#runUpload(uploadID)
  1709. }
  1710. /**
  1711. * Create an upload for a bunch of files.
  1712. *
  1713. */
  1714. #createUpload(
  1715. fileIDs: string[],
  1716. opts: { forceAllowNewUpload?: boolean } = {},
  1717. ): string {
  1718. // uppy.retryAll sets this to true — when retrying we want to ignore `allowNewUpload: false`
  1719. const { forceAllowNewUpload = false } = opts
  1720. const { allowNewUpload, currentUploads } = this.getState()
  1721. if (!allowNewUpload && !forceAllowNewUpload) {
  1722. throw new Error('Cannot create a new upload: already uploading.')
  1723. }
  1724. const uploadID = nanoid()
  1725. this.emit('upload', uploadID, this.getFilesByIds(fileIDs))
  1726. this.setState({
  1727. allowNewUpload:
  1728. this.opts.allowMultipleUploadBatches !== false &&
  1729. this.opts.allowMultipleUploads !== false,
  1730. currentUploads: {
  1731. ...currentUploads,
  1732. [uploadID]: {
  1733. fileIDs,
  1734. step: 0,
  1735. result: {},
  1736. },
  1737. },
  1738. })
  1739. return uploadID
  1740. }
  1741. private [Symbol.for('uppy test: createUpload')](...args: any[]): string {
  1742. // @ts-expect-error https://github.com/microsoft/TypeScript/issues/47595
  1743. return this.#createUpload(...args)
  1744. }
  1745. #getUpload(uploadID: string): CurrentUpload<M, B> {
  1746. const { currentUploads } = this.getState()
  1747. return currentUploads[uploadID]
  1748. }
  1749. /**
  1750. * Add data to an upload's result object.
  1751. */
  1752. addResultData(uploadID: string, data: CurrentUpload<M, B>['result']): void {
  1753. if (!this.#getUpload(uploadID)) {
  1754. this.log(
  1755. `Not setting result for an upload that has been removed: ${uploadID}`,
  1756. )
  1757. return
  1758. }
  1759. const { currentUploads } = this.getState()
  1760. const currentUpload = {
  1761. ...currentUploads[uploadID],
  1762. result: { ...currentUploads[uploadID].result, ...data },
  1763. }
  1764. this.setState({
  1765. currentUploads: { ...currentUploads, [uploadID]: currentUpload },
  1766. })
  1767. }
  1768. /**
  1769. * Remove an upload, eg. if it has been canceled or completed.
  1770. *
  1771. */
  1772. #removeUpload(uploadID: string): void {
  1773. const currentUploads = { ...this.getState().currentUploads }
  1774. delete currentUploads[uploadID]
  1775. this.setState({
  1776. currentUploads,
  1777. })
  1778. }
  1779. /**
  1780. * Run an upload. This picks up where it left off in case the upload is being restored.
  1781. */
  1782. async #runUpload(uploadID: string): Promise<UploadResult<M, B> | undefined> {
  1783. const getCurrentUpload = (): CurrentUpload<M, B> => {
  1784. const { currentUploads } = this.getState()
  1785. return currentUploads[uploadID]
  1786. }
  1787. let currentUpload = getCurrentUpload()
  1788. const steps = [
  1789. ...this.#preProcessors,
  1790. ...this.#uploaders,
  1791. ...this.#postProcessors,
  1792. ]
  1793. try {
  1794. for (let step = currentUpload.step || 0; step < steps.length; step++) {
  1795. if (!currentUpload) {
  1796. break
  1797. }
  1798. const fn = steps[step]
  1799. this.setState({
  1800. currentUploads: {
  1801. ...this.getState().currentUploads,
  1802. [uploadID]: {
  1803. ...currentUpload,
  1804. step,
  1805. },
  1806. },
  1807. })
  1808. const { fileIDs } = currentUpload
  1809. // TODO give this the `updatedUpload` object as its only parameter maybe?
  1810. // Otherwise when more metadata may be added to the upload this would keep getting more parameters
  1811. await fn(fileIDs, uploadID)
  1812. // Update currentUpload value in case it was modified asynchronously.
  1813. currentUpload = getCurrentUpload()
  1814. }
  1815. } catch (err) {
  1816. this.#removeUpload(uploadID)
  1817. throw err
  1818. }
  1819. // Set result data.
  1820. if (currentUpload) {
  1821. // Mark postprocessing step as complete if necessary; this addresses a case where we might get
  1822. // stuck in the postprocessing UI while the upload is fully complete.
  1823. // If the postprocessing steps do not do any work, they may not emit postprocessing events at
  1824. // all, and never mark the postprocessing as complete. This is fine on its own but we
  1825. // introduced code in the @uppy/core upload-success handler to prepare postprocessing progress
  1826. // state if any postprocessors are registered. That is to avoid a "flash of completed state"
  1827. // before the postprocessing plugins can emit events.
  1828. //
  1829. // So, just in case an upload with postprocessing plugins *has* completed *without* emitting
  1830. // postprocessing completion, we do it instead.
  1831. currentUpload.fileIDs.forEach((fileID) => {
  1832. const file = this.getFile(fileID)
  1833. if (file && file.progress.postprocess) {
  1834. this.emit('postprocess-complete', file)
  1835. }
  1836. })
  1837. const files = currentUpload.fileIDs.map((fileID) => this.getFile(fileID))
  1838. const successful = files.filter((file) => !file.error)
  1839. const failed = files.filter((file) => file.error)
  1840. this.addResultData(uploadID, { successful, failed, uploadID })
  1841. // Update currentUpload value in case it was modified asynchronously.
  1842. currentUpload = getCurrentUpload()
  1843. }
  1844. // Emit completion events.
  1845. // This is in a separate function so that the `currentUploads` variable
  1846. // always refers to the latest state. In the handler right above it refers
  1847. // to an outdated object without the `.result` property.
  1848. let result
  1849. if (currentUpload) {
  1850. result = currentUpload.result
  1851. this.emit('complete', result)
  1852. this.#removeUpload(uploadID)
  1853. }
  1854. if (result == null) {
  1855. this.log(
  1856. `Not setting result for an upload that has been removed: ${uploadID}`,
  1857. )
  1858. }
  1859. return result
  1860. }
  1861. /**
  1862. * Start an upload for all the files that are not currently being uploaded.
  1863. */
  1864. upload(): Promise<NonNullable<UploadResult<M, B>> | undefined> {
  1865. if (!this.#plugins['uploader']?.length) {
  1866. this.log('No uploader type plugins are used', 'warning')
  1867. }
  1868. let { files } = this.getState()
  1869. const onBeforeUploadResult = this.opts.onBeforeUpload(files)
  1870. if (onBeforeUploadResult === false) {
  1871. return Promise.reject(
  1872. new Error(
  1873. 'Not starting the upload because onBeforeUpload returned false',
  1874. ),
  1875. )
  1876. }
  1877. if (onBeforeUploadResult && typeof onBeforeUploadResult === 'object') {
  1878. files = onBeforeUploadResult
  1879. // Updating files in state, because uploader plugins receive file IDs,
  1880. // and then fetch the actual file object from state
  1881. this.setState({
  1882. files,
  1883. })
  1884. }
  1885. return Promise.resolve()
  1886. .then(() => this.#restricter.validateMinNumberOfFiles(files))
  1887. .catch((err) => {
  1888. this.#informAndEmit([err])
  1889. throw err
  1890. })
  1891. .then(() => {
  1892. if (!this.#checkRequiredMetaFields(files)) {
  1893. throw new RestrictionError(this.i18n('missingRequiredMetaField'))
  1894. }
  1895. })
  1896. .catch((err) => {
  1897. // Doing this in a separate catch because we already emited and logged
  1898. // all the errors in `checkRequiredMetaFields` so we only throw a generic
  1899. // missing fields error here.
  1900. throw err
  1901. })
  1902. .then(() => {
  1903. const { currentUploads } = this.getState()
  1904. // get a list of files that are currently assigned to uploads
  1905. const currentlyUploadingFiles = Object.values(currentUploads).flatMap(
  1906. (curr) => curr.fileIDs,
  1907. )
  1908. const waitingFileIDs: string[] = []
  1909. Object.keys(files).forEach((fileID) => {
  1910. const file = this.getFile(fileID)
  1911. // if the file hasn't started uploading and hasn't already been assigned to an upload..
  1912. if (
  1913. !file.progress.uploadStarted &&
  1914. currentlyUploadingFiles.indexOf(fileID) === -1
  1915. ) {
  1916. waitingFileIDs.push(file.id)
  1917. }
  1918. })
  1919. const uploadID = this.#createUpload(waitingFileIDs)
  1920. return this.#runUpload(uploadID)
  1921. })
  1922. .catch((err) => {
  1923. this.emit('error', err)
  1924. this.log(err, 'error')
  1925. throw err
  1926. })
  1927. }
  1928. }
  1929. export default Uppy