196 lines
6.2 KiB
TypeScript
Executable File
196 lines
6.2 KiB
TypeScript
Executable File
import {create} from 'zustand';
|
|
import {immer} from 'zustand/middleware/immer';
|
|
import {Draft, enableMapSet} from 'immer';
|
|
import {UploadedFile} from '../uploaded-file';
|
|
import {UploadStrategy, UploadStrategyConfig} from './strategy/upload-strategy';
|
|
import {MessageDescriptor} from '../../i18n/message-descriptor';
|
|
import {FileEntry} from '../file-entry';
|
|
import {S3MultipartUpload} from './strategy/s3-multipart-upload';
|
|
import {Settings} from '../../core/settings/settings';
|
|
import {TusUpload} from './strategy/tus-upload';
|
|
import {ProgressTimeout} from './progress-timeout';
|
|
import {startUploading} from './start-uploading';
|
|
import {createUpload} from './create-file-upload';
|
|
|
|
enableMapSet();
|
|
|
|
export interface FileUpload {
|
|
file: UploadedFile;
|
|
percentage: number;
|
|
bytesUploaded: number;
|
|
status: 'pending' | 'inProgress' | 'aborted' | 'failed' | 'completed';
|
|
errorMessage?: string | MessageDescriptor | null;
|
|
entry?: FileEntry;
|
|
request?: UploadStrategy;
|
|
timer?: ProgressTimeout;
|
|
options: UploadStrategyConfig;
|
|
meta?: unknown;
|
|
}
|
|
|
|
interface State {
|
|
concurrency: number;
|
|
fileUploads: Map<string, FileUpload>;
|
|
// uploads with pending and inProgress status
|
|
activeUploadsCount: number;
|
|
completedUploadsCount: number;
|
|
}
|
|
|
|
const initialState: State = {
|
|
concurrency: 3,
|
|
fileUploads: new Map(),
|
|
activeUploadsCount: 0,
|
|
completedUploadsCount: 0,
|
|
};
|
|
|
|
interface Actions {
|
|
uploadMultiple: (
|
|
files: (File | UploadedFile)[] | FileList,
|
|
options?: Omit<
|
|
UploadStrategyConfig,
|
|
// progress would be called for each upload simultaneously
|
|
'onProgress' | 'showToastOnRestrictionFail'
|
|
>,
|
|
) => string[];
|
|
uploadSingle: (
|
|
file: File | UploadedFile,
|
|
options?: UploadStrategyConfig,
|
|
) => string;
|
|
clearInactive: () => void;
|
|
abortUpload: (id: string) => void;
|
|
updateFileUpload: (id: string, state: Partial<FileUpload>) => void;
|
|
getUpload: (id: string) => FileUpload | undefined;
|
|
runQueue: () => void;
|
|
reset: () => void;
|
|
}
|
|
|
|
export type FileUploadState = State & Actions;
|
|
|
|
interface StoreProps {
|
|
settings: Settings;
|
|
}
|
|
export const createFileUploadStore = ({settings}: StoreProps) =>
|
|
create<FileUploadState>()(
|
|
immer((set, get) => {
|
|
return {
|
|
...initialState,
|
|
reset: () => {
|
|
set(initialState);
|
|
},
|
|
|
|
getUpload: uploadId => {
|
|
return get().fileUploads.get(uploadId);
|
|
},
|
|
|
|
clearInactive: () => {
|
|
set(state => {
|
|
state.fileUploads.forEach((upload, key) => {
|
|
if (upload.status !== 'inProgress') {
|
|
state.fileUploads.delete(key);
|
|
}
|
|
});
|
|
});
|
|
get().runQueue();
|
|
},
|
|
|
|
abortUpload: id => {
|
|
const upload = get().fileUploads.get(id);
|
|
if (upload) {
|
|
upload.request?.abort();
|
|
get().updateFileUpload(id, {status: 'aborted', percentage: 0});
|
|
get().runQueue();
|
|
}
|
|
},
|
|
|
|
updateFileUpload: (id, newUploadState) => {
|
|
set(state => {
|
|
const fileUpload = state.fileUploads.get(id);
|
|
if (fileUpload) {
|
|
state.fileUploads.set(id, {
|
|
...fileUpload,
|
|
...newUploadState,
|
|
});
|
|
|
|
// only need to update inProgress count if status of the uploads in queue change
|
|
if ('status' in newUploadState) {
|
|
updateTotals(state);
|
|
}
|
|
}
|
|
});
|
|
},
|
|
|
|
uploadSingle: (file, userOptions) => {
|
|
const upload = createUpload(file, userOptions);
|
|
const fileUploads = new Map(get().fileUploads);
|
|
fileUploads.set(upload.file.id, upload);
|
|
|
|
set(state => {
|
|
updateTotals(state);
|
|
state.fileUploads = fileUploads;
|
|
});
|
|
|
|
get().runQueue();
|
|
|
|
return upload.file.id;
|
|
},
|
|
|
|
uploadMultiple: (files, options) => {
|
|
// create file upload items from specified files
|
|
const uploads = new Map<string, FileUpload>(get().fileUploads);
|
|
[...files].forEach(file => {
|
|
const upload = createUpload(file, options);
|
|
uploads.set(upload.file.id, upload);
|
|
});
|
|
|
|
// set state only once, there might be thousands of files, don't want to trigger a rerender for each one
|
|
set(state => {
|
|
updateTotals(state);
|
|
state.fileUploads = uploads;
|
|
});
|
|
|
|
get().runQueue();
|
|
return [...uploads.keys()];
|
|
},
|
|
|
|
runQueue: async () => {
|
|
const uploads = [...get().fileUploads.values()];
|
|
const activeUploads = uploads.filter(u => u.status === 'inProgress');
|
|
|
|
let concurrency = get().concurrency;
|
|
if (
|
|
activeUploads.filter(
|
|
activeUpload =>
|
|
// only upload one file from folder at a time to avoid creating duplicate folders
|
|
activeUpload.file.relativePath ||
|
|
// only allow one s3 multipart upload at a time, it will already upload multiple parts in parallel
|
|
activeUpload.request instanceof S3MultipartUpload ||
|
|
// only allow one tus upload if file is larger than chunk size, tus will have parallel uploads already in that case
|
|
(activeUpload.request instanceof TusUpload &&
|
|
settings.uploads.chunk_size &&
|
|
activeUpload.file.size > settings.uploads.chunk_size),
|
|
).length
|
|
) {
|
|
concurrency = 1;
|
|
}
|
|
|
|
if (activeUploads.length < concurrency) {
|
|
//const pendingUploads = uploads.filter(u => u.status === 'pending');
|
|
//const next = pendingUploads.find(a => !!a.request);
|
|
const next = uploads.find(u => u.status === 'pending');
|
|
if (next) {
|
|
await startUploading(next, get());
|
|
}
|
|
}
|
|
},
|
|
};
|
|
}),
|
|
);
|
|
|
|
const updateTotals = (state: Draft<FileUploadState>) => {
|
|
state.completedUploadsCount = [...state.fileUploads.values()].filter(
|
|
u => u.status === 'completed',
|
|
).length;
|
|
state.activeUploadsCount = [...state.fileUploads.values()].filter(
|
|
u => u.status === 'inProgress' || u.status === 'pending',
|
|
).length;
|
|
};
|