UNPKG

@uppy/core

Version:

Core module for the extensible JavaScript file upload widget with support for drag&drop, resumable uploads, previews, restrictions, file processing/encoding, remote providers like Instagram, Dropbox, Google Drive, S3 and more :dog:

1,728 lines (1,496 loc) 68.9 kB
/* global AggregateError */ import DefaultStore, { type Store } from '@uppy/store-default' import type { Body, CompanionClientProvider, CompanionClientSearchProvider, CompanionFile, FileProgressNotStarted, FileProgressStarted, I18n, Locale, Meta, MinimalRequiredUppyFile, OptionalPluralizeLocale, UppyFile, } from '@uppy/utils' import { getFileNameAndExtension, getFileType, getSafeFileId, Translator, } from '@uppy/utils' import throttle from 'lodash/throttle.js' // @ts-ignore untyped import ee from 'namespace-emitter' import { nanoid } from 'nanoid/non-secure' import type { h } from 'preact' import packageJson from '../package.json' with { type: 'json' } import type BasePlugin from './BasePlugin.js' import getFileName from './getFileName.js' import locale from './locale.js' import { debugLogger, justErrorsLogger } from './loggers.js' import type { Restrictions, ValidateableFile } from './Restricter.js' import { defaultOptions as defaultRestrictionOptions, Restricter, RestrictionError, } from './Restricter.js' import supportsUploadProgress from './supportsUploadProgress.js' type Processor = ( fileIDs: string[], uploadID: string, // biome-ignore lint/suspicious/noConfusingVoidType: ... ) => Promise<unknown> | void type LogLevel = 'info' | 'warning' | 'error' | 'success' export type UnknownPlugin< M extends Meta, B extends Body, PluginState extends Record<string, unknown> = Record<string, unknown>, > = BasePlugin<any, M, B, PluginState> /** * ids are always `string`s, except the root folder's id can be `null` */ export type PartialTreeId = string | null export type PartialTreeStatusFile = 'checked' | 'unchecked' export type PartialTreeStatus = PartialTreeStatusFile | 'partial' export type PartialTreeFile = { type: 'file' id: string /** * There exist two types of restrictions: * - individual restrictions (`allowedFileTypes`, `minFileSize`, `maxFileSize`), and * - aggregate restrictions (`maxNumberOfFiles`, `maxTotalFileSize`). * * `.restrictionError` reports whether this file passes individual restrictions. * */ restrictionError: string | null status: PartialTreeStatusFile parentId: PartialTreeId data: CompanionFile } export type PartialTreeFolderNode = { type: 'folder' id: string /** * Consider `(.nextPagePath, .cached)` a composite key that can represent 4 states: * - `{ cached: true, nextPagePath: null }` - we fetched all pages in this folder * - `{ cached: true, nextPagePath: 'smth' }` - we fetched 1st page, and there are still pages left to fetch in this folder * - `{ cached: false, nextPagePath: null }` - we didn't fetch the 1st page in this folder * - `{ cached: false, nextPagePath: 'someString' }` - ❌ CAN'T HAPPEN ❌ */ cached: boolean nextPagePath: PartialTreeId status: PartialTreeStatus parentId: PartialTreeId data: CompanionFile } export type PartialTreeFolderRoot = { type: 'root' id: PartialTreeId cached: boolean nextPagePath: PartialTreeId } export type PartialTreeFolder = PartialTreeFolderNode | PartialTreeFolderRoot /** * PartialTree has the following structure. * * FolderRoot * ┌─────┴─────┐ * FolderNode File * ┌─────┴────┐ * File File * * Root folder is called `PartialTreeFolderRoot`, * all other folders are called `PartialTreeFolderNode`, because they are "internal nodes". * * It's possible for `PartialTreeFolderNode` to be a leaf node if it doesn't contain any files. */ export type PartialTree = (PartialTreeFile | PartialTreeFolder)[] export type UnknownProviderPluginState = { authenticated: boolean | undefined didFirstRender: boolean searchString: string loading: boolean | string partialTree: PartialTree currentFolderId: PartialTreeId username: string | null } export interface AsyncStore { getItem: (key: string) => Promise<string | null> setItem: (key: string, value: string) => Promise<void> removeItem: (key: string) => Promise<void> } /** * This is a base for a provider that does not necessarily use the Companion-assisted OAuth2 flow */ export interface BaseProviderPlugin { title: string icon: () => h.JSX.Element storage: AsyncStore } /* * UnknownProviderPlugin can be any Companion plugin (such as Google Drive) * that uses the Companion-assisted OAuth flow. * As the plugins are passed around throughout Uppy we need a generic type for this. * It may seems like duplication, but this type safe. Changing the type of `storage` * will error in the `Provider` class of @uppy/companion-client and vice versa. * * Note that this is the *plugin* class, not a version of the `Provider` class. * `Provider` does operate on Companion plugins with `uppy.getPlugin()`. */ export type UnknownProviderPlugin< M extends Meta, B extends Body, > = UnknownPlugin<M, B, UnknownProviderPluginState> & BaseProviderPlugin & { rootFolderId: string | null files: UppyFile<M, B>[] provider: CompanionClientProvider // Can't be typed unfortunately, we can't depend on `provider-views` in `core`. view: any } /* * UnknownSearchProviderPlugin can be any search Companion plugin (such as Unsplash). * As the plugins are passed around throughout Uppy we need a generic type for this. * It may seems like duplication, but this type safe. Changing the type of `title` * will error in the `SearchProvider` class of @uppy/companion-client and vice versa. * * Note that this is the *plugin* class, not a version of the `SearchProvider` class. * `SearchProvider` does operate on Companion plugins with `uppy.getPlugin()`. */ export type UnknownSearchProviderPluginState = { isInputMode: boolean } & Pick< UnknownProviderPluginState, 'loading' | 'searchString' | 'partialTree' | 'currentFolderId' > export type UnknownSearchProviderPlugin< M extends Meta, B extends Body, > = UnknownPlugin<M, B, UnknownSearchProviderPluginState> & BaseProviderPlugin & { provider: CompanionClientSearchProvider } export interface UploadResult<M extends Meta, B extends Body> { successful?: UppyFile<M, B>[] failed?: UppyFile<M, B>[] uploadID?: string [key: string]: unknown } interface CurrentUpload<M extends Meta, B extends Body> { fileIDs: string[] step: number result: UploadResult<M, B> } // TODO: can we use namespaces in other plugins to populate this? interface Plugins extends Record<string, Record<string, unknown> | undefined> {} export interface State<M extends Meta, B extends Body> extends Record<string, unknown> { meta: M capabilities: { uploadProgress: boolean individualCancellation: boolean resumableUploads: boolean isMobileDevice?: boolean darkMode?: boolean } currentUploads: Record<string, CurrentUpload<M, B>> allowNewUpload: boolean recoveredState: null | Required<Pick<State<M, B>, 'files' | 'currentUploads'>> error: string | null files: { [key: string]: UppyFile<M, B> } info: Array<{ isHidden?: boolean type: LogLevel message: string details?: string | Record<string, string> | null }> plugins: Plugins totalProgress: number companion?: Record<string, string> } export interface UppyOptions<M extends Meta, B extends Body> { id?: string autoProceed?: boolean /** * @deprecated Use allowMultipleUploadBatches */ allowMultipleUploads?: boolean allowMultipleUploadBatches?: boolean logger?: typeof debugLogger debug?: boolean restrictions: Restrictions meta?: M onBeforeFileAdded?: ( currentFile: UppyFile<M, B>, files: { [key: string]: UppyFile<M, B> }, ) => UppyFile<M, B> | boolean | undefined onBeforeUpload?: (files: { [key: string]: UppyFile<M, B> }) => { [key: string]: UppyFile<M, B> } | boolean locale?: Locale store?: Store<State<M, B>> infoTimeout?: number } export interface UppyOptionsWithOptionalRestrictions< M extends Meta, B extends Body, > extends Omit<UppyOptions<M, B>, 'restrictions'> { restrictions?: Partial<Restrictions> } // The user facing type for UppyOptions used in uppy.setOptions() type MinimalRequiredOptions<M extends Meta, B extends Body> = Partial< Omit<UppyOptions<M, B>, 'locale' | 'meta' | 'restrictions'> & { locale: OptionalPluralizeLocale meta: Partial<M> restrictions: Partial<Restrictions> } > export type NonNullableUppyOptions<M extends Meta, B extends Body> = Required< UppyOptions<M, B> > export interface _UppyEventMap<M extends Meta, B extends Body> { 'back-online': () => void 'cancel-all': () => void complete: (result: UploadResult<M, B>) => void error: ( error: { name: string; message: string; details?: string }, file?: UppyFile<M, B>, response?: UppyFile<M, B>['response'], ) => void 'file-added': (file: UppyFile<M, B>) => void 'file-removed': (file: UppyFile<M, B>) => void 'files-added': (files: UppyFile<M, B>[]) => void 'info-hidden': () => void 'info-visible': () => void 'is-offline': () => void 'is-online': () => void 'pause-all': () => void 'plugin-added': (plugin: UnknownPlugin<any, any>) => void 'plugin-remove': (plugin: UnknownPlugin<any, any>) => void 'postprocess-complete': ( file: UppyFile<M, B> | undefined, progress?: NonNullable<FileProgressStarted['preprocess']>, ) => void 'postprocess-progress': ( file: UppyFile<M, B> | undefined, progress: NonNullable<FileProgressStarted['postprocess']>, ) => void 'preprocess-complete': ( file: UppyFile<M, B> | undefined, progress?: NonNullable<FileProgressStarted['preprocess']>, ) => void 'preprocess-progress': ( file: UppyFile<M, B> | undefined, progress: NonNullable<FileProgressStarted['preprocess']>, ) => void progress: (progress: number) => void restored: (pluginData: any) => void 'restore-confirmed': () => void 'restore-canceled': () => void 'restriction-failed': (file: UppyFile<M, B> | undefined, error: Error) => void 'resume-all': () => void 'retry-all': (files: UppyFile<M, B>[]) => void 'state-update': ( prevState: State<M, B>, nextState: State<M, B>, patch?: Partial<State<M, B>>, ) => void upload: (uploadID: string, files: UppyFile<M, B>[]) => void 'upload-error': ( file: UppyFile<M, B> | undefined, error: { name: string; message: string; details?: string }, response?: | Omit<NonNullable<UppyFile<M, B>['response']>, 'uploadURL'> | undefined, ) => void 'upload-pause': (file: UppyFile<M, B> | undefined, isPaused: boolean) => void 'upload-progress': ( file: UppyFile<M, B> | undefined, progress: FileProgressStarted, ) => void 'upload-retry': (file: UppyFile<M, B>) => void 'upload-stalled': ( error: { message: string; details?: string }, files: UppyFile<M, B>[], ) => void 'upload-success': ( file: UppyFile<M, B> | undefined, response: NonNullable<UppyFile<M, B>['response']>, ) => void } export interface UppyEventMap<M extends Meta, B extends Body> extends _UppyEventMap<M, B> { 'upload-start': (files: UppyFile<M, B>[]) => void } /** `OmitFirstArg<typeof someArray>` is the type of the returned value of `someArray.slice(1)`. */ type OmitFirstArg<T> = T extends [any, ...infer U] ? U : never const defaultUploadState = { totalProgress: 0, allowNewUpload: true, error: null, recoveredState: null, } /** * Uppy Core module. * Manages plugins, state updates, acts as an event bus, * adds/removes files and metadata. */ export class Uppy< M extends Meta = Meta, B extends Body = Record<string, never>, > { static VERSION = packageJson.version #plugins: Record<string, UnknownPlugin<M, B>[]> = Object.create(null) #restricter #storeUnsubscribe #emitter = ee() #preProcessors: Set<Processor> = new Set() #uploaders: Set<Processor> = new Set() #postProcessors: Set<Processor> = new Set() defaultLocale: OptionalPluralizeLocale locale!: Locale // The user optionally passes in options, but we set defaults for missing options. // We consider all options present after the contructor has run. opts: NonNullableUppyOptions<M, B> store: NonNullableUppyOptions<M, B>['store'] // Warning: do not use this from a plugin, as it will cause the plugins' translations to be missing i18n!: I18n i18nArray!: Translator['translateArray'] scheduledAutoProceed: ReturnType<typeof setTimeout> | null = null wasOffline = false /** * Instantiate Uppy */ constructor(opts?: UppyOptionsWithOptionalRestrictions<M, B>) { this.defaultLocale = locale const defaultOptions: UppyOptions<Record<string, unknown>, B> = { id: 'uppy', autoProceed: false, allowMultipleUploadBatches: true, debug: false, restrictions: defaultRestrictionOptions, meta: {}, onBeforeFileAdded: (file, files) => !Object.hasOwn(files, file.id), onBeforeUpload: (files) => files, store: new DefaultStore(), logger: justErrorsLogger, infoTimeout: 5000, } const merged = { ...defaultOptions, ...opts } as Omit< NonNullableUppyOptions<M, B>, 'restrictions' > // Merge default options with the ones set by user, // making sure to merge restrictions too this.opts = { ...merged, restrictions: { ...(defaultOptions.restrictions as Restrictions), ...opts?.restrictions, }, } // Support debug: true for backwards-compatability, unless logger is set in opts // opts instead of this.opts to avoid comparing objects — we set logger: justErrorsLogger in defaultOptions if (opts?.logger && opts.debug) { this.log( '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`.', 'warning', ) } else if (opts?.debug) { this.opts.logger = debugLogger } this.log(`Using Core v${Uppy.VERSION}`) this.i18nInit() this.store = this.opts.store this.setState({ ...defaultUploadState, plugins: {}, files: {}, currentUploads: {}, capabilities: { uploadProgress: supportsUploadProgress(), individualCancellation: true, resumableUploads: false, }, meta: { ...this.opts.meta }, info: [], }) this.#restricter = new Restricter<M, B>( () => this.opts, () => this.i18n, ) this.#storeUnsubscribe = this.store.subscribe( (prevState, nextState, patch) => { this.emit('state-update', prevState, nextState, patch) this.updateAll(nextState) }, ) // Exposing uppy object on window for debugging and testing if (this.opts.debug && typeof window !== 'undefined') { // @ts-ignore Mutating the global object for debug purposes window[this.opts.id] = this } this.#addListeners() } emit<T extends keyof UppyEventMap<M, B>>( event: T, ...args: Parameters<UppyEventMap<M, B>[T]> ): void { this.#emitter.emit(event, ...args) } on<K extends keyof UppyEventMap<M, B>>( event: K, callback: UppyEventMap<M, B>[K], ): this { this.#emitter.on(event, callback) return this } once<K extends keyof UppyEventMap<M, B>>( event: K, callback: UppyEventMap<M, B>[K], ): this { this.#emitter.once(event, callback) return this } off<K extends keyof UppyEventMap<M, B>>( event: K, callback: UppyEventMap<M, B>[K], ): this { this.#emitter.off(event, callback) return this } /** * Iterate on all plugins and run `update` on them. * Called each time state changes. * */ updateAll(state: Partial<State<M, B>>): void { this.iteratePlugins((plugin: UnknownPlugin<M, B>) => { plugin.update(state) }) } /** * Updates state with a patch */ setState(patch?: Partial<State<M, B>>): void { this.store.setState(patch) } /** * Returns current state. */ getState(): State<M, B> { return this.store.getState() } patchFilesState(filesWithNewState: { [id: string]: Partial<UppyFile<M, B>> }): void { const existingFilesState = this.getState().files this.setState({ files: { ...existingFilesState, ...Object.fromEntries( Object.entries(filesWithNewState).map(([fileID, newFileState]) => [ fileID, { ...existingFilesState[fileID], ...newFileState, }, ]), ), }, }) } /** * Shorthand to set state for a specific file. */ setFileState(fileID: string, state: Partial<UppyFile<M, B>>): void { if (!this.getState().files[fileID]) { throw new Error( `Can’t set state for ${fileID} (the file could have been removed)`, ) } this.patchFilesState({ [fileID]: state }) } i18nInit(): void { const onMissingKey = (key: string): void => this.log(`Missing i18n string: ${key}`, 'error') const translator = new Translator([this.defaultLocale, this.opts.locale], { onMissingKey, }) this.i18n = translator.translate.bind(translator) this.i18nArray = translator.translateArray.bind(translator) this.locale = translator.locale } setOptions(newOpts: MinimalRequiredOptions<M, B>): void { this.opts = { ...this.opts, ...(newOpts as UppyOptions<M, B>), restrictions: { ...this.opts.restrictions, ...(newOpts?.restrictions as Restrictions), }, } if (newOpts.meta) { this.setMeta(newOpts.meta) } this.i18nInit() if (newOpts.locale) { this.iteratePlugins((plugin) => { plugin.setOptions(newOpts) }) } // Note: this is not the preact `setState`, it's an internal function that has the same name. this.setState(undefined) // so that UI re-renders with new options } resetProgress(): void { const defaultProgress: Omit<FileProgressNotStarted, 'bytesTotal'> = { percentage: 0, bytesUploaded: false, uploadComplete: false, uploadStarted: null, } const files = { ...this.getState().files } const updatedFiles: State<M, B>['files'] = Object.create(null) Object.keys(files).forEach((fileID) => { updatedFiles[fileID] = { ...files[fileID], progress: { ...files[fileID].progress, ...defaultProgress, }, // @ts-expect-error these typed are inserted // into the namespace in their respective packages // but core isn't ware of those tus: undefined, transloadit: undefined, } }) this.setState({ files: updatedFiles, ...defaultUploadState }) } clear(): void { const { capabilities, currentUploads } = this.getState() if ( Object.keys(currentUploads).length > 0 && !capabilities.individualCancellation ) { throw new Error( 'The installed uploader plugin does not allow removing files during an upload.', ) } this.setState({ ...defaultUploadState, files: {} }) } addPreProcessor(fn: Processor): void { this.#preProcessors.add(fn) } removePreProcessor(fn: Processor): boolean { return this.#preProcessors.delete(fn) } addPostProcessor(fn: Processor): void { this.#postProcessors.add(fn) } removePostProcessor(fn: Processor): boolean { return this.#postProcessors.delete(fn) } addUploader(fn: Processor): void { this.#uploaders.add(fn) } removeUploader(fn: Processor): boolean { return this.#uploaders.delete(fn) } setMeta(data: Partial<M>): void { const updatedMeta = { ...this.getState().meta, ...data } const updatedFiles = { ...this.getState().files } Object.keys(updatedFiles).forEach((fileID) => { updatedFiles[fileID] = { ...updatedFiles[fileID], meta: { ...updatedFiles[fileID].meta, ...data }, } }) this.log('Adding metadata:') this.log(data) this.setState({ meta: updatedMeta, files: updatedFiles, }) } setFileMeta(fileID: string, data: State<M, B>['meta']): void { const updatedFiles = { ...this.getState().files } if (!updatedFiles[fileID]) { this.log( `Was trying to set metadata for a file that has been removed: ${fileID}`, ) return } const newMeta = { ...updatedFiles[fileID].meta, ...data } updatedFiles[fileID] = { ...updatedFiles[fileID], meta: newMeta } this.setState({ files: updatedFiles }) } /** * Get a file object. */ getFile(fileID: string): UppyFile<M, B> { return this.getState().files[fileID] } /** * Get all files in an array. */ getFiles(): UppyFile<M, B>[] { const { files } = this.getState() return Object.values(files) } getFilesByIds(ids: string[]): UppyFile<M, B>[] { return ids.map((id) => this.getFile(id)) } getObjectOfFilesPerState(): { newFiles: UppyFile<M, B>[] startedFiles: UppyFile<M, B>[] uploadStartedFiles: UppyFile<M, B>[] pausedFiles: UppyFile<M, B>[] completeFiles: UppyFile<M, B>[] erroredFiles: UppyFile<M, B>[] inProgressFiles: UppyFile<M, B>[] inProgressNotPausedFiles: UppyFile<M, B>[] processingFiles: UppyFile<M, B>[] isUploadStarted: boolean isAllComplete: boolean isAllErrored: boolean isAllPaused: boolean isUploadInProgress: boolean isSomeGhost: boolean } { const { files: filesObject, totalProgress, error } = this.getState() const files = Object.values(filesObject) const inProgressFiles: UppyFile<M, B>[] = [] const newFiles: UppyFile<M, B>[] = [] const startedFiles: UppyFile<M, B>[] = [] const uploadStartedFiles: UppyFile<M, B>[] = [] const pausedFiles: UppyFile<M, B>[] = [] const completeFiles: UppyFile<M, B>[] = [] const erroredFiles: UppyFile<M, B>[] = [] const inProgressNotPausedFiles: UppyFile<M, B>[] = [] const processingFiles: UppyFile<M, B>[] = [] for (const file of files) { const { progress } = file if (!progress.uploadComplete && progress.uploadStarted) { inProgressFiles.push(file) if (!file.isPaused) { inProgressNotPausedFiles.push(file) } } if (!progress.uploadStarted) { newFiles.push(file) } if ( progress.uploadStarted || progress.preprocess || progress.postprocess ) { startedFiles.push(file) } if (progress.uploadStarted) { uploadStartedFiles.push(file) } if (file.isPaused) { pausedFiles.push(file) } if (progress.uploadComplete) { completeFiles.push(file) } if (file.error) { erroredFiles.push(file) } if (progress.preprocess || progress.postprocess) { processingFiles.push(file) } } return { newFiles, startedFiles, uploadStartedFiles, pausedFiles, completeFiles, erroredFiles, inProgressFiles, inProgressNotPausedFiles, processingFiles, isUploadStarted: uploadStartedFiles.length > 0, isAllComplete: totalProgress === 100 && completeFiles.length === files.length && processingFiles.length === 0, isAllErrored: !!error && erroredFiles.length === files.length, isAllPaused: inProgressFiles.length !== 0 && pausedFiles.length === inProgressFiles.length, isUploadInProgress: inProgressFiles.length > 0, isSomeGhost: files.some((file) => file.isGhost), } } #informAndEmit( errors: { name: string message: string isUserFacing?: boolean details?: string isRestriction?: boolean file?: UppyFile<M, B> }[], ): void { for (const error of errors) { if (error.isRestriction) { this.emit( 'restriction-failed', error.file, error as RestrictionError<M, B>, ) } else { this.emit('error', error, error.file) } this.log(error, 'warning') } const userFacingErrors = errors.filter((error) => error.isUserFacing) // don't flood the user: only show the first 4 toasts const maxNumToShow = 4 const firstErrors = userFacingErrors.slice(0, maxNumToShow) const additionalErrors = userFacingErrors.slice(maxNumToShow) firstErrors.forEach(({ message, details = '' }) => { this.info({ message, details }, 'error', this.opts.infoTimeout) }) if (additionalErrors.length > 0) { this.info({ message: this.i18n('additionalRestrictionsFailed', { count: additionalErrors.length, }), }) } } validateRestrictions( file: ValidateableFile<M, B>, files: ValidateableFile<M, B>[] = this.getFiles(), ): RestrictionError<M, B> | null { try { this.#restricter.validate(files, [file]) } catch (err) { return err as any } return null } validateSingleFile(file: ValidateableFile<M, B>): string | null { try { this.#restricter.validateSingleFile(file) } catch (err) { return err.message } return null } validateAggregateRestrictions( files: ValidateableFile<M, B>[], ): string | null { const existingFiles = this.getFiles() try { this.#restricter.validateAggregateRestrictions(existingFiles, files) } catch (err) { return err.message } return null } #checkRequiredMetaFieldsOnFile(file: UppyFile<M, B>): boolean { const { missingFields, error } = this.#restricter.getMissingRequiredMetaFields(file) if (missingFields.length > 0) { this.setFileState(file.id, { missingRequiredMetaFields: missingFields, error: error.message, }) this.log(error.message) this.emit('restriction-failed', file, error) return false } if (missingFields.length === 0 && file.missingRequiredMetaFields) { this.setFileState(file.id, { missingRequiredMetaFields: [], }) } return true } #checkRequiredMetaFields(files: State<M, B>['files']): boolean { let success = true for (const file of Object.values(files)) { if (!this.#checkRequiredMetaFieldsOnFile(file)) { success = false } } return success } #assertNewUploadAllowed(file?: UppyFile<M, B>): void { const { allowNewUpload } = this.getState() if (allowNewUpload === false) { const error = new RestrictionError<M, B>( this.i18n('noMoreFilesAllowed'), { file, }, ) this.#informAndEmit([error]) throw error } } checkIfFileAlreadyExists(fileID: string): boolean { const { files } = this.getState() if (files[fileID] && !files[fileID].isGhost) { return true } return false } /** * Create a file state object based on user-provided `addFile()` options. */ #transformFile(fileDescriptorOrFile: File | UppyFile<M, B>): UppyFile<M, B> { // Uppy expects files in { name, type, size, data } format. // If the actual File object is passed from input[type=file] or drag-drop, // we normalize it to match Uppy file object const file = ( fileDescriptorOrFile instanceof File ? { name: fileDescriptorOrFile.name, type: fileDescriptorOrFile.type, size: fileDescriptorOrFile.size, data: fileDescriptorOrFile, } : fileDescriptorOrFile ) as UppyFile<M, B> const fileType = getFileType(file) const fileName = getFileName(fileType, file) const fileExtension = getFileNameAndExtension(fileName).extension const id = getSafeFileId(file, this.getID()) const meta = file.meta || {} meta.name = fileName meta.type = fileType // `null` means the size is unknown. const size = Number.isFinite(file.data.size) ? file.data.size : (null as never) return { source: file.source || '', id, name: fileName, extension: fileExtension || '', meta: { ...this.getState().meta, ...meta, }, type: fileType, data: file.data, progress: { percentage: 0, bytesUploaded: false, bytesTotal: size, uploadComplete: false, uploadStarted: null, }, size, isGhost: false, isRemote: file.isRemote || false, remote: file.remote, preview: file.preview, } } // Schedule an upload if `autoProceed` is enabled. #startIfAutoProceed(): void { if (this.opts.autoProceed && !this.scheduledAutoProceed) { this.scheduledAutoProceed = setTimeout(() => { this.scheduledAutoProceed = null this.upload().catch((err) => { if (!err.isRestriction) { this.log(err.stack || err.message || err) } }) }, 4) } } #checkAndUpdateFileState(filesToAdd: UppyFile<M, B>[]): { nextFilesState: State<M, B>['files'] validFilesToAdd: UppyFile<M, B>[] errors: RestrictionError<M, B>[] } { let { files: existingFiles } = this.getState() // create a copy of the files object only once let nextFilesState = { ...existingFiles } const validFilesToAdd: UppyFile<M, B>[] = [] const errors: RestrictionError<M, B>[] = [] for (const fileToAdd of filesToAdd) { try { let newFile = this.#transformFile(fileToAdd) // If a file has been recovered (Golden Retriever), but we were unable to recover its data (probably too large), // users are asked to re-select these half-recovered files and then this method will be called again. // In order to keep the progress, meta and everything else, we keep the existing file, // but we replace `data`, and we remove `isGhost`, because the file is no longer a ghost now const isGhost = existingFiles[newFile.id]?.isGhost if (isGhost) { const existingFileState = existingFiles[newFile.id] newFile = { ...existingFileState, isGhost: false, data: fileToAdd.data, } this.log( `Replaced the blob in the restored ghost file: ${newFile.name}, ${newFile.id}`, ) } const onBeforeFileAddedResult = this.opts.onBeforeFileAdded( newFile, nextFilesState, ) // update state after onBeforeFileAdded existingFiles = this.getState().files nextFilesState = { ...existingFiles, ...nextFilesState } if ( !onBeforeFileAddedResult && this.checkIfFileAlreadyExists(newFile.id) ) { throw new RestrictionError( this.i18n('noDuplicates', { fileName: newFile.name ?? this.i18n('unnamed'), }), { file: fileToAdd }, ) } // Pass through reselected files from Golden Retriever if (onBeforeFileAddedResult === false && !isGhost) { // Don’t show UI info for this error, as it should be done by the developer throw new RestrictionError( 'Cannot add the file because onBeforeFileAdded returned false.', { isUserFacing: false, file: fileToAdd }, ) } else if ( typeof onBeforeFileAddedResult === 'object' && onBeforeFileAddedResult !== null ) { newFile = onBeforeFileAddedResult } this.#restricter.validateSingleFile(newFile) // need to add it to the new local state immediately, so we can use the state to validate the next files too nextFilesState[newFile.id] = newFile validFilesToAdd.push(newFile) } catch (err) { errors.push(err as any) } } try { // 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 // when many files are added this.#restricter.validateAggregateRestrictions( Object.values(existingFiles), validFilesToAdd, ) } catch (err) { errors.push(err as any) // If we have any aggregate error, don't allow adding this batch return { nextFilesState: existingFiles, validFilesToAdd: [], errors, } } return { nextFilesState, validFilesToAdd, errors, } } /** * Add a new file to `state.files`. This will run `onBeforeFileAdded`, * try to guess file type in a clever way, check file against restrictions, * and start an upload if `autoProceed === true`. */ addFile(file: File | MinimalRequiredUppyFile<M, B>): UppyFile<M, B>['id'] { this.#assertNewUploadAllowed(file as UppyFile<M, B>) const { nextFilesState, validFilesToAdd, errors } = this.#checkAndUpdateFileState([file as UppyFile<M, B>]) const restrictionErrors = errors.filter((error) => error.isRestriction) this.#informAndEmit(restrictionErrors) if (errors.length > 0) throw errors[0] this.setState({ files: nextFilesState }) const [firstValidFileToAdd] = validFilesToAdd this.emit('file-added', firstValidFileToAdd) this.emit('files-added', validFilesToAdd) this.log( `Added file: ${firstValidFileToAdd.name}, ${firstValidFileToAdd.id}, mime type: ${firstValidFileToAdd.type}`, ) this.#startIfAutoProceed() return firstValidFileToAdd.id } /** * Add multiple files to `state.files`. See the `addFile()` documentation. * * If an error occurs while adding a file, it is logged and the user is notified. * This is good for UI plugins, but not for programmatic use. * Programmatic users should usually still use `addFile()` on individual files. */ addFiles(fileDescriptors: MinimalRequiredUppyFile<M, B>[]): void { this.#assertNewUploadAllowed() const { nextFilesState, validFilesToAdd, errors } = this.#checkAndUpdateFileState(fileDescriptors as UppyFile<M, B>[]) const restrictionErrors = errors.filter((error) => error.isRestriction) this.#informAndEmit(restrictionErrors) const nonRestrictionErrors = errors.filter((error) => !error.isRestriction) if (nonRestrictionErrors.length > 0) { let message = 'Multiple errors occurred while adding files:\n' nonRestrictionErrors.forEach((subError) => { message += `\n * ${subError.message}` }) this.info( { message: this.i18n('addBulkFilesFailed', { smart_count: nonRestrictionErrors.length, }), details: message, }, 'error', this.opts.infoTimeout, ) if (typeof AggregateError === 'function') { throw new AggregateError(nonRestrictionErrors, message) } else { const err = new Error(message) // @ts-expect-error fallback when AggregateError is not available err.errors = nonRestrictionErrors throw err } } // OK, we haven't thrown an error, we can start updating state and emitting events now: this.setState({ files: nextFilesState }) validFilesToAdd.forEach((file) => { this.emit('file-added', file) }) this.emit('files-added', validFilesToAdd) if (validFilesToAdd.length > 5) { this.log(`Added batch of ${validFilesToAdd.length} files`) } else { Object.values(validFilesToAdd).forEach((file) => { this.log( `Added file: ${file.name}\n id: ${file.id}\n type: ${file.type}`, ) }) } if (validFilesToAdd.length > 0) { this.#startIfAutoProceed() } } removeFiles(fileIDs: string[]): void { const { files, currentUploads } = this.getState() const updatedFiles = { ...files } const updatedUploads = { ...currentUploads } const removedFiles = Object.create(null) fileIDs.forEach((fileID) => { if (files[fileID]) { removedFiles[fileID] = files[fileID] delete updatedFiles[fileID] } }) // Remove files from the `fileIDs` list in each upload. function fileIsNotRemoved(uploadFileID: string): boolean { return removedFiles[uploadFileID] === undefined } Object.keys(updatedUploads).forEach((uploadID) => { const newFileIDs = currentUploads[uploadID].fileIDs.filter(fileIsNotRemoved) // Remove the upload if no files are associated with it anymore. if (newFileIDs.length === 0) { delete updatedUploads[uploadID] return } const { capabilities } = this.getState() if ( newFileIDs.length !== currentUploads[uploadID].fileIDs.length && !capabilities.individualCancellation ) { throw new Error( 'The installed uploader plugin does not allow removing files during an upload.', ) } updatedUploads[uploadID] = { ...currentUploads[uploadID], fileIDs: newFileIDs, } }) const stateUpdate: Partial<State<M, B>> = { currentUploads: updatedUploads, files: updatedFiles, } // If all files were removed - allow new uploads, // and clear recoveredState if (Object.keys(updatedFiles).length === 0) { stateUpdate.allowNewUpload = true stateUpdate.error = null stateUpdate.recoveredState = null } this.setState(stateUpdate) this.#updateTotalProgressThrottled() const removedFileIDs = Object.keys(removedFiles) removedFileIDs.forEach((fileID) => { this.emit('file-removed', removedFiles[fileID]) }) if (removedFileIDs.length > 5) { this.log(`Removed ${removedFileIDs.length} files`) } else { this.log(`Removed files: ${removedFileIDs.join(', ')}`) } } removeFile(fileID: string): void { this.removeFiles([fileID]) } pauseResume(fileID: string): boolean | undefined { if ( !this.getState().capabilities.resumableUploads || this.getFile(fileID).progress.uploadComplete ) { return undefined } const file = this.getFile(fileID) const wasPaused = file.isPaused || false const isPaused = !wasPaused this.setFileState(fileID, { isPaused, }) this.emit('upload-pause', file, isPaused) return isPaused } pauseAll(): void { const updatedFiles = { ...this.getState().files } const inProgressUpdatedFiles = Object.keys(updatedFiles).filter((file) => { return ( !updatedFiles[file].progress.uploadComplete && updatedFiles[file].progress.uploadStarted ) }) inProgressUpdatedFiles.forEach((file) => { const updatedFile = { ...updatedFiles[file], isPaused: true } updatedFiles[file] = updatedFile }) this.setState({ files: updatedFiles }) this.emit('pause-all') } resumeAll(): void { const updatedFiles = { ...this.getState().files } const inProgressUpdatedFiles = Object.keys(updatedFiles).filter((file) => { return ( !updatedFiles[file].progress.uploadComplete && updatedFiles[file].progress.uploadStarted ) }) inProgressUpdatedFiles.forEach((file) => { const updatedFile = { ...updatedFiles[file], isPaused: false, error: null, } updatedFiles[file] = updatedFile }) this.setState({ files: updatedFiles }) this.emit('resume-all') } #getFilesToRetry() { const { files } = this.getState() return Object.keys(files).filter((fileId) => { const file = files[fileId] // Only retry files that have errors AND don't have missing required metadata return ( file.error && (!file.missingRequiredMetaFields || file.missingRequiredMetaFields.length === 0) ) }) } async #doRetryAll(): Promise<UploadResult<M, B> | undefined> { const filesToRetry = this.#getFilesToRetry() const updatedFiles = { ...this.getState().files } filesToRetry.forEach((fileID) => { updatedFiles[fileID] = { ...updatedFiles[fileID], isPaused: false, error: null, } }) this.setState({ files: updatedFiles, error: null, }) this.emit('retry-all', this.getFilesByIds(filesToRetry)) if (filesToRetry.length === 0) { return { successful: [], failed: [], } } const uploadID = this.#createUpload(filesToRetry, { forceAllowNewUpload: true, // create new upload even if allowNewUpload: false }) return this.#runUpload(uploadID) } async retryAll(): Promise<UploadResult<M, B> | undefined> { const result = await this.#doRetryAll() this.emit('complete', result!) return result } cancelAll(): void { this.emit('cancel-all') const { files } = this.getState() const fileIDs = Object.keys(files) if (fileIDs.length) { this.removeFiles(fileIDs) } this.setState(defaultUploadState) } retryUpload(fileID: string): Promise<UploadResult<M, B> | undefined> { this.setFileState(fileID, { error: null, isPaused: false, }) this.emit('upload-retry', this.getFile(fileID)) const uploadID = this.#createUpload([fileID], { forceAllowNewUpload: true, // create new upload even if allowNewUpload: false }) return this.#runUpload(uploadID) } logout(): void { this.iteratePlugins((plugin) => { ;(plugin as UnknownProviderPlugin<M, B>).provider?.logout?.() }) } #handleUploadProgress = ( file: UppyFile<M, B> | undefined, progress: FileProgressStarted, ) => { const fileInState = file ? this.getFile(file.id) : undefined if (file == null || !fileInState) { this.log( `Not setting progress for a file that has been removed: ${file?.id}`, ) return } if (fileInState.progress.percentage === 100) { this.log( `Not setting progress for a file that has been already uploaded: ${file.id}`, ) return } const newProgress = { bytesTotal: progress.bytesTotal, // bytesTotal may be null or zero; in that case we can't divide by it percentage: progress.bytesTotal != null && Number.isFinite(progress.bytesTotal) && progress.bytesTotal > 0 ? Math.round((progress.bytesUploaded / progress.bytesTotal) * 100) : undefined, } if (fileInState.progress.uploadStarted != null) { this.setFileState(file.id, { progress: { ...fileInState.progress, ...newProgress, bytesUploaded: progress.bytesUploaded, }, }) } else { this.setFileState(file.id, { progress: { ...fileInState.progress, ...newProgress, }, }) } this.#updateTotalProgressThrottled() } #updateTotalProgress() { const totalProgress = this.#calculateTotalProgress() let totalProgressPercent: number | null = null if (totalProgress != null) { totalProgressPercent = Math.round(totalProgress * 100) if (totalProgressPercent > 100) totalProgressPercent = 100 else if (totalProgressPercent < 0) totalProgressPercent = 0 } this.emit('progress', totalProgressPercent ?? 0) this.setState({ totalProgress: totalProgressPercent ?? 0, }) } // ___Why throttle at 500ms? // - We must throttle at >250ms for superfocus in Dashboard to work well // (because animation takes 0.25s, and we want to wait for all animations to be over before refocusing). // [Practical Check]: if thottle is at 100ms, then if you are uploading a file, // and click 'ADD MORE FILES', - focus won't activate in Firefox. // - We must throttle at around >500ms to avoid performance lags. // [Practical Check] Firefox, try to upload a big file for a prolonged period of time. Laptop will start to heat up. #updateTotalProgressThrottled = throttle( () => this.#updateTotalProgress(), 500, { leading: true, trailing: true }, ) private [Symbol.for('uppy test: updateTotalProgress')]() { return this.#updateTotalProgress() } #calculateTotalProgress() { // calculate total progress, using the number of files currently uploading, // between 0 and 1 and sum of individual progress of each file const files = this.getFiles() // note: also includes files that have completed uploading: const filesInProgress = files.filter((file) => { return ( file.progress.uploadStarted || file.progress.preprocess || file.progress.postprocess ) }) if (filesInProgress.length === 0) { return 0 } if (filesInProgress.every((file) => file.progress.uploadComplete)) { // If every uploading file is complete, and we're still getting progress, it probably means // there's a bug somewhere in some progress reporting code (maybe not even our code) // and we're still getting progress, so let's just assume it means a 100% progress return 1 } const isSizedFile = (file: UppyFile<M, B>) => file.progress.bytesTotal != null && file.progress.bytesTotal !== 0 const sizedFilesInProgress = filesInProgress.filter(isSizedFile) const unsizedFilesInProgress = filesInProgress.filter( (file) => !isSizedFile(file), ) if ( sizedFilesInProgress.every((file) => file.progress.uploadComplete) && unsizedFilesInProgress.length > 0 && !unsizedFilesInProgress.every((file) => file.progress.uploadComplete) ) { // we are done with uploading all files of known size, however // there is at least one file with unknown size still uploading, // and we cannot say anything about their progress // In any case, return null because it doesn't make any sense to show a progress return null } const totalFilesSize = sizedFilesInProgress.reduce( (acc, file) => acc + (file.progress.bytesTotal ?? 0), 0, ) const totalUploadedSize = sizedFilesInProgress.reduce( (acc, file) => acc + (file.progress.bytesUploaded || 0), 0, ) return totalFilesSize === 0 ? 0 : totalUploadedSize / totalFilesSize } /** * Registers listeners for all global actions, like: * `error`, `file-removed`, `upload-progress` */ #addListeners(): void { // Type inference only works for inline functions so we have to type it again const errorHandler: UppyEventMap<M, B>['error'] = ( error, file, response, ) => { let errorMsg = error.message || 'Unknown error' if (error.details) { errorMsg += ` ${error.details}` } this.setState({ error: errorMsg }) if (file != null && file.id in this.getState().files) { this.setFileState(file.id, { error: errorMsg, response, }) } } this.on('error', errorHandler) this.on('upload-error', (file, error, response) => { errorHandler(error, file, response) if (typeof error === 'object' && error.message) { this.log(error.message, 'error') const newError = new Error( this.i18n('failedToUpload', { file: file?.name ?? '' }), ) as any // we may want a new custom error here newError.isUserFacing = true // todo maybe don't do this with all errors? newError.details = error.message if (error.details) { newError.details += ` ${error.details}` } this.#informAndEmit([newError]) } else { this.#informAndEmit([error]) } }) let uploadStalledWarningRecentlyEmitted: ReturnType< typeof setTimeout > | null = null this.on('upload-stalled', (error, files) => { const { message } = error const details = files.map((file) => file.meta.name).join(', ') if (!uploadStalledWarningRecentlyEmitted) { this.info({ message, details }, 'warning', this.opts.infoTimeout) uploadStalledWarningRecentlyEmitted = setTimeout(() => { uploadStalledWarningRecentlyEmitted = null }, this.opts.infoTimeout) } this.log(`${message} ${details}`.trim(), 'warning') }) this.on('upload', () => { this.setState({ error: null }) }) const onUploadStarted = (files: UppyFile<M, B>[]): void => { const filesFiltered = files.filter((file) => { const exists = file != null && this.getFile(file.id) if (!exists) this.log( `Not setting progress for a file that has been removed: ${file?.id}`, ) return exists }) const filesState = Object.fromEntries( filesFiltered.map((file) => [ file.id, { progress: { uploadStarted: Date.now(), uploadComplete: false, bytesUploaded: 0, bytesTotal: file.size, } as FileProgressStarted, }, ]), ) this.patchFilesState(filesState) } this.on('upload-start', onUploadStarted) this.on('upload-progress', this.#handleUploadProgress) this.on('upload-success', (file, uploadResp) => { if (file == null || !this.getFile(file.id)) { this.log( `Not setting progress for a file that has been removed: ${file?.id}`, ) return } const currentProgress = this.getFile(file.id).progress this.setFileState(file.id, { progress: { ...currentProgress, postprocess: this.#postProcessors.size > 0 ? { mode: 'indeterminate', } : undefined, uploadComplete: true, percentage: 100, bytesUploaded: currentProgress.bytesTotal, } as FileProgressStarted, response: uploadResp, uploadURL: uploadResp.uploadURL, isPaused: false, }) // Remote providers somet