@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,289 lines • 60.3 kB
JavaScript
/* global AggregateError */
import DefaultStore, {} from '@uppy/store-default';
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 packageJson from '../package.json' with { type: 'json' };
import getFileName from './getFileName.js';
import locale from './locale.js';
import { debugLogger, justErrorsLogger } from './loggers.js';
import { defaultOptions as defaultRestrictionOptions, Restricter, RestrictionError, } from './Restricter.js';
import supportsUploadProgress from './supportsUploadProgress.js';
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 {
static VERSION = packageJson.version;
#plugins = Object.create(null);
#restricter;
#storeUnsubscribe;
#emitter = ee();
#preProcessors = new Set();
#uploaders = new Set();
#postProcessors = new Set();
defaultLocale;
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;
store;
// Warning: do not use this from a plugin, as it will cause the plugins' translations to be missing
i18n;
i18nArray;
scheduledAutoProceed = null;
wasOffline = false;
/**
* Instantiate Uppy
*/
constructor(opts) {
this.defaultLocale = locale;
const defaultOptions = {
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 };
// Merge default options with the ones set by user,
// making sure to merge restrictions too
this.opts = {
...merged,
restrictions: {
...defaultOptions.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(() => 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(event, ...args) {
this.#emitter.emit(event, ...args);
}
on(event, callback) {
this.#emitter.on(event, callback);
return this;
}
once(event, callback) {
this.#emitter.once(event, callback);
return this;
}
off(event, callback) {
this.#emitter.off(event, callback);
return this;
}
/**
* Iterate on all plugins and run `update` on them.
* Called each time state changes.
*
*/
updateAll(state) {
this.iteratePlugins((plugin) => {
plugin.update(state);
});
}
/**
* Updates state with a patch
*/
setState(patch) {
this.store.setState(patch);
}
/**
* Returns current state.
*/
getState() {
return this.store.getState();
}
patchFilesState(filesWithNewState) {
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, state) {
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() {
const onMissingKey = (key) => 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) {
this.opts = {
...this.opts,
...newOpts,
restrictions: {
...this.opts.restrictions,
...newOpts?.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() {
const defaultProgress = {
percentage: 0,
bytesUploaded: false,
uploadComplete: false,
uploadStarted: null,
};
const files = { ...this.getState().files };
const updatedFiles = 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() {
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) {
this.#preProcessors.add(fn);
}
removePreProcessor(fn) {
return this.#preProcessors.delete(fn);
}
addPostProcessor(fn) {
this.#postProcessors.add(fn);
}
removePostProcessor(fn) {
return this.#postProcessors.delete(fn);
}
addUploader(fn) {
this.#uploaders.add(fn);
}
removeUploader(fn) {
return this.#uploaders.delete(fn);
}
setMeta(data) {
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, data) {
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) {
return this.getState().files[fileID];
}
/**
* Get all files in an array.
*/
getFiles() {
const { files } = this.getState();
return Object.values(files);
}
getFilesByIds(ids) {
return ids.map((id) => this.getFile(id));
}
getObjectOfFilesPerState() {
const { files: filesObject, totalProgress, error } = this.getState();
const files = Object.values(filesObject);
const inProgressFiles = [];
const newFiles = [];
const startedFiles = [];
const uploadStartedFiles = [];
const pausedFiles = [];
const completeFiles = [];
const erroredFiles = [];
const inProgressNotPausedFiles = [];
const processingFiles = [];
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) {
for (const error of errors) {
if (error.isRestriction) {
this.emit('restriction-failed', error.file, error);
}
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, files = this.getFiles()) {
try {
this.#restricter.validate(files, [file]);
}
catch (err) {
return err;
}
return null;
}
validateSingleFile(file) {
try {
this.#restricter.validateSingleFile(file);
}
catch (err) {
return err.message;
}
return null;
}
validateAggregateRestrictions(files) {
const existingFiles = this.getFiles();
try {
this.#restricter.validateAggregateRestrictions(existingFiles, files);
}
catch (err) {
return err.message;
}
return null;
}
#checkRequiredMetaFieldsOnFile(file) {
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) {
let success = true;
for (const file of Object.values(files)) {
if (!this.#checkRequiredMetaFieldsOnFile(file)) {
success = false;
}
}
return success;
}
#assertNewUploadAllowed(file) {
const { allowNewUpload } = this.getState();
if (allowNewUpload === false) {
const error = new RestrictionError(this.i18n('noMoreFilesAllowed'), {
file,
});
this.#informAndEmit([error]);
throw error;
}
}
checkIfFileAlreadyExists(fileID) {
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) {
// 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,
meta: {},
isRemote: false,
source: undefined,
preview: undefined,
}
: fileDescriptorOrFile;
const fileType = getFileType(file);
const fileName = getFileName(fileType, file);
const fileExtension = getFileNameAndExtension(fileName).extension;
const id = getSafeFileId(file, this.getID());
const meta = {
...file.meta,
name: fileName,
type: fileType,
};
// `null` means the size is unknown.
const size = Number.isFinite(file.data.size) ? file.data.size : null;
return {
source: file.source || '',
id,
name: fileName,
extension: fileExtension || '',
meta: {
...this.getState().meta,
...meta,
},
type: fileType,
progress: {
percentage: 0,
bytesUploaded: false,
bytesTotal: size,
uploadComplete: false,
uploadStarted: null,
},
size,
isGhost: false,
...(file.isRemote
? {
isRemote: true,
remote: file.remote,
data: file.data,
}
: {
isRemote: false,
data: file.data,
}),
preview: file.preview,
};
}
// Schedule an upload if `autoProceed` is enabled.
#startIfAutoProceed() {
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) {
let { files: existingFiles } = this.getState();
// create a copy of the files object only once
let nextFilesState = { ...existingFiles };
const validFilesToAdd = [];
const errors = [];
for (const fileToAdd of filesToAdd) {
try {
let newFile = this.#transformFile(fileToAdd);
this.#assertNewUploadAllowed(newFile);
// 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 existingFile = existingFiles[newFile.id];
const isGhost = existingFile?.isGhost;
if (isGhost && !newFile.isRemote) {
if (newFile.data == null)
throw new Error('File data is missing');
newFile = {
...existingFile,
isGhost: false,
data: newFile.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: newFile });
}
// 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: newFile });
}
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);
}
}
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);
// 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) {
const { nextFilesState, validFilesToAdd, errors } = this.#checkAndUpdateFileState([file]);
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) {
const { nextFilesState, validFilesToAdd, errors } = this.#checkAndUpdateFileState(fileDescriptors);
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) {
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) {
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 = {
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) {
this.removeFiles([fileID]);
}
pauseResume(fileID) {
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() {
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() {
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() {
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() {
const result = await this.#doRetryAll();
this.emit('complete', result);
return result;
}
cancelAll() {
this.emit('cancel-all');
const { files } = this.getState();
const fileIDs = Object.keys(files);
if (fileIDs.length) {
this.removeFiles(fileIDs);
}
this.setState(defaultUploadState);
}
/**
* Retry a specific file that has errored.
*/
retryUpload(fileID) {
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() {
this.iteratePlugins((plugin) => {
;
plugin.provider?.logout?.();
});
}
#handleUploadProgress = (file, progress) => {
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 = 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 });
[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) => 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() {
// Type inference only works for inline functions so we have to type it again
const errorHandler = (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 ?? '' })); // 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 = 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) => {
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,
},
},
]));
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;
const needsPostProcessing = this.#postProcessors.size > 0;
this.setFileState(file.id, {
progress: {
...currentProgress,
postprocess: needsPostProcessing
? {
mode: 'indeterminate',
}
: undefined,
uploadComplete: true,
...(!needsPostProcessing && { complete: true }),
percentage: 100,
bytesUploaded: currentProgress.bytesTotal,
},
response: uploadResp,
uploadURL: uploadResp.uploadURL,
isPaused: false,
});
// Remote providers sometimes don't tell us the file size,
// but we can know how many bytes we uploaded once the upload is complete.
if (file.size == null) {
this.setFileState(file.id, {
size: uploadResp.bytesUploaded || currentProgress.bytesTotal,
});
}
this.#updateTotalProgressThrottled();
});
this.on('preprocess-progress', (file, progress) => {
if (file == null || !this.getFile(file.id)) {
this.log(`Not setting progress for a file that has been removed: ${file?.id}`);
return;
}
this.setFileState(file.id, {
progress: { ...this.getFile(file.id).progress, preprocess: progress },
});
});
this.on('preprocess-complete', (file) => {
if (file == null || !this.getFile(file.id)) {
this.log(`Not setting progress for a file that has been removed: ${file?.id}`);
return;
}
const files = { ...this.getState().files };
files[file.id] = {
...files[file.id],
progress: { ...files[file.id].progress },
};
delete files[file.id].progress.preprocess;
this.setState({ files });
});
this.on('postprocess-progress', (file, progress) => {
if (file == null || !this.getFile(file.id)) {
this.log(`Not setting progress for a file that has been removed: ${file?.id}`);
return;
}
this.setFileState(file.id, {
progress: {
...this.getState().files[file.id].progress,
postprocess: progress,
},
});
});
this.on('postprocess-complete', (fileIn) => {
const file = fileIn && this.getFile(fileIn.id);
if (file == null) {
this.log(`Not setting progress for a file that has been removed: ${fileIn?.id}`);
return;
}
const { postprocess: _deleted, ...newProgress } = file.progress;
this.patchFilesState({
[file.id]: {
progress: {
...newProgress,
complete: true,
},
},
});
});
this.on('restored', () => {
// Files may have changed--ensure progress is still accurate.
this.#updateTotalProgressThrottled();
});
// @ts-expect-error should fix itself when dashboard it typed (also this doesn't belong here)
this.on('dashboard:file-edit-complete', (file) => {
if (file) {
this.#checkRequiredMetaFieldsOnFile(file);
}
});
// show informer if offline
if (typeof window !== 'undefined' && window.addEventListener) {
window.addEventListener('online', this.#updateOnlineStatus);
window.addEventListener('offline', this.#updateOnlineStatus);
setTimeout(this.#updateOnlineStatus, 3000);
}
}
updateOnlineStatus() {
const online = window.navigator.onLine ?? true;
if (!online) {
this.emit('is-offline');
this.info(this.i18n('noInternetConnection'), 'error', 0);
this.wasOffline = true;
}
else {
this.emit('is-online');
if (this.wasOffline) {
this.emit('back-online');
this.info(this.i18n('connectedToInternet'), 'success', 3000);
this.wasOffline = false;
}
}
}
#updateOnlineStatus = this.updateOnlineStatus.bind(this);
getID() {
return this.opts.id;
}
/**
* Registers a plugin with Core.
*/
use(Plugin,
// We want to let the plugin decide whether `opts` is optional or not
// so we spread the argument rather than defining `opts:` ourselves.
...args) {
if (typeof Plugin !== 'function') {
const msg = `Expected a plugin class, but got ${Plugin === null ? 'null' : typeof Plugin}.` +
' Please verify that the plugin was imported and spelled correctly.';
throw new TypeError(msg);
}
// Instantiate
const plugin = new Plugin(this, ...args);
const pluginId = plugin.id;
if (!pluginId) {
throw new Error('Your plugin must have an id');
}
if (!plugin.type) {
throw new Error('Your plugin must have a type');
}
const existsPluginAlready = this.getPlugin(pluginId);
if (existsPluginAlready) {
const msg = `Already found a plugin named '${existsPluginAlready.id}'. ` +
`Tried to use: '${pluginId}'.\n` +
'Uppy plugins must have unique `id` options.';
throw new Error(msg);
}
// @ts-expect-error does exist
if (Plugin.VERSION) {
// @ts-expect-error does exist
this.log(`Using ${pluginId} v${Plugin.VERSION}`);
}
if (plugin.type in this.#plugins) {
this.#plugins[plugin.type].push(plugin);
}
else {
this.#plugins[plugin.type] = [plugin];
}
plugin.install();
this.emit('plugin-added', plugin);
return this;
}
getPlugin(id) {
for (const plugins of Object.values(this.#plugins)) {
const foundPlugin = plugins.find((plugin) => plugin.id === id);
if (foundPlugin != null) {
return foundPlugin;
}
}
return undefined;
}
[Symbol.for('uppy test: getPlugins')](type) {
return this.#plugins[type];
}
/**
* Iterate through all `use`d plugins.
*
*/
iteratePlugins(method) {
Object.values(this.#plugins).flat(1).forEach(method);
}
/**
* Uninstall and remove a plugin.
*
* @param {object} instance The plugin instance to remove.
*/
removePlugin(instance) {
this.log(`Removing plugin ${instance.id}`);
this.emit('plugin-remove', instance);
if (instance.uninstall) {
instance.uninstall();
}
const list = this.#plugins[instance.type];
// list.indexOf failed here, because Vue3 converted the plugin instance
// to a Proxy object, which failed the strict comparison test:
// obj !== objProxy
const index = list.findIndex((item) => item.id === instance.id);
if (index !== -1) {
list.splice(index, 1);
}
const state = this.getState();
const updatedState = {
plugins: {
...state.plugins,
[instance.id]: undefined,
},
};
this.setState(updatedState);
}
/**
* Uninstall all plugins and close down this Uppy instance.
*/
destroy() {
this.log(`Closing Uppy instance ${this.opts.id}: removing all files and uninstalling plugins`);
this.cancelAll();
this.#storeUnsubscribe();
this.iteratePlugins((plugin) => {
this.removePlugin(plugin);
});
if (typeof window !== 'undefined' && window.removeEventListener) {
window.removeEventListener('online', this.#updateOnlineStatus);
window.removeEventListener('offline', this.#updateOnlineStatus);
}
}
hideInfo() {
const { info } = this.getState();
this.setState({ info: info.slice(1) });
this.emit('info-hidden');
}
/**
* Set info message in `state.info`, so that UI plugins like `Informer`
* can display the message.
*/
info(message, type = 'info', duration = 3000) {
const isComplexMessage = typeof message === 'object';
this.setState({
info: [
...this.getState().info,
{
type,
message: isComplexMessage ? message.message : message,
details: isComplexMessage ? message.details : null,
},
],
});
setTimeout(() => this.hideInfo(), duration);
this.emit('info-visible');
}
/**
* Passes messages to a function, provided in `opts.logger`.
* If `opts.logger: Uppy.debugLogger` or `opts.debug: true`, logs to the browser console.
*/
log(message, type) {
const { logger } = this.opts;
switch (type) {
case 'error':
logger.error(message);
break;
case 'warning':
logger.warn(message);
break;
default:
logger.debug(message);
break;
}
}
// We need to store request clients by a unique ID, so we can share RequestClient instances across files
// this allows us to do rate limiting and synchronous operations like refreshing provider tokens
// example: refreshing tokens: if each file has their ow