Uppy.ts 65 KB

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