web/satellite: introduce files pinia module
Added new module to replace old Vuex module Change-Id: I220769ca6c53e9d9b10f84eaf147f2904bbd5500
This commit is contained in:
parent
d8724d67f3
commit
2557b9a72f
658
web/satellite/src/store/modules/filesStore.ts
Normal file
658
web/satellite/src/store/modules/filesStore.ts
Normal file
@ -0,0 +1,658 @@
|
||||
// Copyright (C) 2023 Storj Labs, Inc.
|
||||
// See LICENSE for copying information.
|
||||
|
||||
import { computed, reactive } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import S3, { CommonPrefix } from 'aws-sdk/clients/s3';
|
||||
|
||||
import { AnalyticsErrorEventSource } from '@/utils/constants/analyticsEventNames';
|
||||
import { MODALS } from '@/utils/constants/appStatePopUps';
|
||||
import { useAppStore } from '@/store/modules/appStore';
|
||||
import { useNotificationsStore } from '@/store/modules/notificationsStore';
|
||||
|
||||
const listCache = new Map();
|
||||
|
||||
type Promisable<T> = T | PromiseLike<T>;
|
||||
|
||||
export type BrowserObject = {
|
||||
Key: string;
|
||||
Size: number;
|
||||
LastModified: number;
|
||||
type?: 'file' | 'folder';
|
||||
progress?: number;
|
||||
upload?: {
|
||||
abort: () => void;
|
||||
};
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export class FilesState {
|
||||
s3: S3 | null = null;
|
||||
accessKey: null | string = null;
|
||||
path = '';
|
||||
bucket = '';
|
||||
browserRoot = '/';
|
||||
files: BrowserObject[] = [];
|
||||
uploadChain: Promise<void> = Promise.resolve();
|
||||
uploading: BrowserObject[] = [];
|
||||
selectedAnchorFile: BrowserObject | null = null;
|
||||
unselectedAnchorFile: null | string = null;
|
||||
selectedFiles: BrowserObject[] = [];
|
||||
shiftSelectedFiles: BrowserObject[] = [];
|
||||
filesToBeDeleted: BrowserObject[] = [];
|
||||
fetchSharedLink: (arg0: string) => Promisable<string> = () => 'javascript:null';
|
||||
fetchPreviewAndMapUrl: (arg0: string) => Promisable<string> = () => 'javascript:null';
|
||||
openedDropdown: null | string = null;
|
||||
headingSorted = 'name';
|
||||
orderBy: 'asc' | 'desc' = 'asc';
|
||||
openModalOnFirstUpload = false;
|
||||
objectPathForModal = '';
|
||||
objectsCount = 0;
|
||||
}
|
||||
|
||||
type InitializedFilesState = FilesState & {
|
||||
s3: S3;
|
||||
};
|
||||
|
||||
function assertIsInitialized(
|
||||
state: FilesState,
|
||||
): asserts state is InitializedFilesState {
|
||||
if (state.s3 === null) {
|
||||
throw new Error(
|
||||
'FilesModule: S3 Client is uninitialized. "state.s3" is null.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface FileSystemEntry {
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/FileSystemFileEntry/file
|
||||
file: (
|
||||
successCallback: (arg0: File) => void,
|
||||
errorCallback?: (arg0: Error) => void
|
||||
) => void;
|
||||
createReader: () => FileSystemDirectoryReader;
|
||||
}
|
||||
}
|
||||
|
||||
export const useFilesStore = defineStore('files', () => {
|
||||
const state = reactive<FilesState>(new FilesState());
|
||||
|
||||
const sortedFiles = computed(() => {
|
||||
// key-specific sort cases
|
||||
const fns = {
|
||||
date: (a: BrowserObject, b: BrowserObject): number =>
|
||||
new Date(a.LastModified).getTime() - new Date(b.LastModified).getTime(),
|
||||
name: (a: BrowserObject, b: BrowserObject): number =>
|
||||
a.Key.localeCompare(b.Key),
|
||||
size: (a: BrowserObject, b: BrowserObject): number => a.Size - b.Size,
|
||||
};
|
||||
|
||||
// TODO(performance): avoid several passes over the slice.
|
||||
|
||||
// sort by appropriate function
|
||||
const sortedFiles = state.files.slice();
|
||||
sortedFiles.sort(fns[state.headingSorted]);
|
||||
// reverse if descending order
|
||||
if (state.orderBy !== 'asc') {
|
||||
sortedFiles.reverse();
|
||||
}
|
||||
|
||||
// display folders and then files
|
||||
return [
|
||||
...sortedFiles.filter((file) => file.type === 'folder'),
|
||||
...sortedFiles.filter((file) => file.type === 'file'),
|
||||
];
|
||||
});
|
||||
|
||||
const isInitialized = computed(() => {
|
||||
return state.s3 !== null;
|
||||
});
|
||||
|
||||
const uploadingLength = computed(() => {
|
||||
return state.uploading.length;
|
||||
});
|
||||
|
||||
function init({
|
||||
accessKey,
|
||||
secretKey,
|
||||
bucket,
|
||||
endpoint,
|
||||
browserRoot,
|
||||
openModalOnFirstUpload = true,
|
||||
fetchSharedLink = () => 'javascript:null',
|
||||
fetchPreviewAndMapUrl = () => 'javascript:null',
|
||||
}: {
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
bucket: string;
|
||||
endpoint: string;
|
||||
browserRoot: string;
|
||||
openModalOnFirstUpload: boolean;
|
||||
fetchSharedLink: (arg0: string) => Promisable<string>;
|
||||
fetchPreviewAndMapUrl: (arg0: string) => Promisable<string>;
|
||||
}): void {
|
||||
const s3Config = {
|
||||
accessKeyId: accessKey,
|
||||
secretAccessKey: secretKey,
|
||||
endpoint,
|
||||
s3ForcePathStyle: true,
|
||||
signatureVersion: 'v4',
|
||||
connectTimeout: 0,
|
||||
httpOptions: { timeout: 0 },
|
||||
};
|
||||
|
||||
state.s3 = new S3(s3Config);
|
||||
state.accessKey = accessKey;
|
||||
state.bucket = bucket;
|
||||
state.browserRoot = browserRoot;
|
||||
state.openModalOnFirstUpload = openModalOnFirstUpload;
|
||||
state.fetchSharedLink = fetchSharedLink;
|
||||
state.fetchPreviewAndMapUrl = fetchPreviewAndMapUrl;
|
||||
state.path = '';
|
||||
state.files = [];
|
||||
}
|
||||
|
||||
function reinit({
|
||||
accessKey,
|
||||
secretKey,
|
||||
endpoint,
|
||||
}: {
|
||||
accessKey: string;
|
||||
secretKey: string;
|
||||
endpoint: string;
|
||||
}): void {
|
||||
const s3Config = {
|
||||
accessKeyId: accessKey,
|
||||
secretAccessKey: secretKey,
|
||||
endpoint,
|
||||
s3ForcePathStyle: true,
|
||||
signatureVersion: 'v4',
|
||||
connectTimeout: 0,
|
||||
httpOptions: { timeout: 0 },
|
||||
};
|
||||
|
||||
state.files = [];
|
||||
state.s3 = new S3(s3Config);
|
||||
state.accessKey = accessKey;
|
||||
}
|
||||
|
||||
function updateFiles(path: string, files: BrowserObject[]): void {
|
||||
state.path = path;
|
||||
state.files = files;
|
||||
}
|
||||
|
||||
async function list(path = state.path) {
|
||||
if (listCache.has(path)) {
|
||||
updateFiles(path, listCache.get(path));
|
||||
}
|
||||
|
||||
assertIsInitialized(state);
|
||||
|
||||
const response = await state.s3
|
||||
.listObjects({
|
||||
Bucket: state.bucket,
|
||||
Delimiter: '/',
|
||||
Prefix: path,
|
||||
})
|
||||
.promise();
|
||||
|
||||
const { Contents, CommonPrefixes } = response;
|
||||
|
||||
if (Contents === undefined) {
|
||||
throw new Error('Bad S3 listObjects() response: "Contents" undefined');
|
||||
}
|
||||
|
||||
if (CommonPrefixes === undefined) {
|
||||
throw new Error(
|
||||
'Bad S3 listObjects() response: "CommonPrefixes" undefined',
|
||||
);
|
||||
}
|
||||
|
||||
Contents.sort((a, b) => {
|
||||
if (
|
||||
a === undefined ||
|
||||
a.LastModified === undefined ||
|
||||
b === undefined ||
|
||||
b.LastModified === undefined ||
|
||||
a.LastModified === b.LastModified
|
||||
) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return a.LastModified < b.LastModified ? -1 : 1;
|
||||
});
|
||||
|
||||
type DefinedCommonPrefix = CommonPrefix & {
|
||||
Prefix: string;
|
||||
};
|
||||
|
||||
const isPrefixDefined = (
|
||||
value: CommonPrefix,
|
||||
): value is DefinedCommonPrefix => value.Prefix !== undefined;
|
||||
|
||||
const prefixToFolder = ({
|
||||
Prefix,
|
||||
}: {
|
||||
Prefix: string;
|
||||
}): BrowserObject => ({
|
||||
Key: Prefix.slice(path.length, -1),
|
||||
LastModified: 0,
|
||||
Size: 0,
|
||||
type: 'folder',
|
||||
});
|
||||
|
||||
const makeFileRelative = (file) => ({
|
||||
...file,
|
||||
Key: file.Key.slice(path.length),
|
||||
type: 'file',
|
||||
});
|
||||
|
||||
const isFileVisible = (file) =>
|
||||
file.Key.length > 0 && file.Key !== '.file_placeholder';
|
||||
|
||||
const files: BrowserObject[] = [
|
||||
...CommonPrefixes.filter(isPrefixDefined).map(prefixToFolder),
|
||||
...Contents.map(makeFileRelative).filter(isFileVisible),
|
||||
];
|
||||
|
||||
listCache.set(path, files);
|
||||
updateFiles(path, files);
|
||||
}
|
||||
|
||||
async function back(): Promise<void> {
|
||||
const getParentDirectory = (path) => {
|
||||
let i = path.length - 2;
|
||||
|
||||
while (path[i - 1] !== '/' && i > 0) {
|
||||
i--;
|
||||
}
|
||||
|
||||
return path.slice(0, i);
|
||||
};
|
||||
|
||||
list(getParentDirectory(state.path));
|
||||
}
|
||||
|
||||
async function getObjectCount() {
|
||||
assertIsInitialized(state);
|
||||
|
||||
const responseV2 = await state.s3
|
||||
.listObjectsV2({
|
||||
Bucket: state.bucket,
|
||||
})
|
||||
.promise();
|
||||
|
||||
state.objectsCount = responseV2.KeyCount === undefined ? 0 : responseV2.KeyCount;
|
||||
}
|
||||
|
||||
async function upload({ e }: { e: DragEvent }) {
|
||||
assertIsInitialized(state);
|
||||
|
||||
type Item = DataTransferItem | FileSystemEntry;
|
||||
|
||||
const items: Item[] = e.dataTransfer
|
||||
? [...e.dataTransfer.items]
|
||||
: e.target !== null
|
||||
? ((e.target as unknown) as { files: FileSystemEntry[] }).files
|
||||
: [];
|
||||
|
||||
async function* traverse(item: Item | Item[], path = '') {
|
||||
if ('isFile' in item && item.isFile === true) {
|
||||
const file = await new Promise(item.file.bind(item));
|
||||
yield { path, file };
|
||||
} else if (item instanceof File) {
|
||||
let relativePath = '';
|
||||
// on Firefox mobile, item.webkitRelativePath might be `undefined`
|
||||
if (item.webkitRelativePath) {
|
||||
relativePath = item.webkitRelativePath
|
||||
.split('/')
|
||||
.slice(0, -1)
|
||||
.join('/');
|
||||
}
|
||||
|
||||
if (relativePath.length) {
|
||||
relativePath += '/';
|
||||
}
|
||||
|
||||
yield { path: relativePath, file: item };
|
||||
} else if ('isFile' in item && item.isDirectory) {
|
||||
const dirReader = item.createReader();
|
||||
|
||||
const entries = await new Promise(
|
||||
dirReader.readEntries.bind(dirReader),
|
||||
);
|
||||
|
||||
for (const entry of entries) {
|
||||
yield* traverse(
|
||||
(entry as FileSystemEntry) as Item,
|
||||
path + item.name + '/',
|
||||
);
|
||||
}
|
||||
} else if ('length' in item && typeof item.length === 'number') {
|
||||
for (const i of item) {
|
||||
yield* traverse(i);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Item is not directory or file');
|
||||
}
|
||||
}
|
||||
|
||||
const isFileSystemEntry = (
|
||||
a: FileSystemEntry | null,
|
||||
): a is FileSystemEntry => a !== null;
|
||||
|
||||
const iterator = [...items]
|
||||
.map((item) =>
|
||||
'webkitGetAsEntry' in item ? item.webkitGetAsEntry() : item,
|
||||
)
|
||||
.filter(isFileSystemEntry) as FileSystemEntry[];
|
||||
|
||||
const fileNames = state.files.map((file) => file.Key);
|
||||
|
||||
function getUniqueFileName(fileName) {
|
||||
for (let count = 1; fileNames.includes(fileName); count++) {
|
||||
if (count > 1) {
|
||||
fileName = fileName.replace(/\((\d+)\)(.*)/, `(${count})$2`);
|
||||
} else {
|
||||
fileName = fileName.replace(/([^.]*)(.*)/, `$1 (${count})$2`);
|
||||
}
|
||||
}
|
||||
|
||||
return fileName;
|
||||
}
|
||||
|
||||
for await (const { path, file } of traverse(iterator)) {
|
||||
const directories = path.split('/');
|
||||
directories[0] = getUniqueFileName(directories[0]);
|
||||
|
||||
const fileName = getUniqueFileName(directories.join('/') + file.name);
|
||||
|
||||
const params = {
|
||||
Bucket: state.bucket,
|
||||
Key: state.path + fileName,
|
||||
Body: file,
|
||||
};
|
||||
|
||||
const upload = state.s3.upload(
|
||||
{ ...params },
|
||||
{ partSize: 64 * 1024 * 1024 },
|
||||
);
|
||||
|
||||
upload.on('httpUploadProgress', async (progress) => {
|
||||
const file = state.uploading.find((file) => file.Key === params.Key);
|
||||
|
||||
if (file === undefined) {
|
||||
throw new Error(`No file found with key ${JSON.stringify(params.Key)}`);
|
||||
}
|
||||
|
||||
file.progress = Math.round((progress.loaded / progress.total) * 100);
|
||||
});
|
||||
|
||||
state.uploading.push({
|
||||
...params,
|
||||
upload,
|
||||
progress: 0,
|
||||
Size: 0,
|
||||
LastModified: 0,
|
||||
});
|
||||
|
||||
state.uploadChain = state.uploadChain.then(async () => {
|
||||
if (
|
||||
state.uploading.findIndex((file) => file.Key === params.Key) === -1
|
||||
) {
|
||||
// upload cancelled or removed
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await upload.promise();
|
||||
} catch (error) {
|
||||
const { notifyError } = useNotificationsStore();
|
||||
const limitExceededError = 'storage limit exceeded';
|
||||
if (error.message.includes(limitExceededError)) {
|
||||
notifyError({ message: `Error: ${limitExceededError}`, source: AnalyticsErrorEventSource.OBJECT_UPLOAD_ERROR });
|
||||
} else {
|
||||
notifyError({ message: error.message, source: AnalyticsErrorEventSource.OBJECT_UPLOAD_ERROR });
|
||||
}
|
||||
}
|
||||
|
||||
await list();
|
||||
|
||||
const uploadedFiles = state.files.filter(
|
||||
(file) => file.type === 'file',
|
||||
);
|
||||
|
||||
if (uploadedFiles.length === 1) {
|
||||
if (state.openModalOnFirstUpload) {
|
||||
state.objectPathForModal = params.Key;
|
||||
|
||||
const { updateActiveModal } = useAppStore();
|
||||
updateActiveModal(MODALS.objectDetails);
|
||||
}
|
||||
}
|
||||
|
||||
state.uploading = state.uploading.filter((file) => file.Key !== params.Key);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function createFolder(name) {
|
||||
assertIsInitialized(state);
|
||||
|
||||
await state.s3
|
||||
.putObject({
|
||||
Bucket: state.bucket,
|
||||
Key: state.path + name + '/.file_placeholder',
|
||||
})
|
||||
.promise();
|
||||
|
||||
list();
|
||||
}
|
||||
|
||||
async function deleteObject(path: string, file?: S3.Object | BrowserObject, isFolder = false) {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
assertIsInitialized(state);
|
||||
|
||||
await state.s3
|
||||
.deleteObject({
|
||||
Bucket: state.bucket,
|
||||
Key: path + file.Key,
|
||||
})
|
||||
.promise();
|
||||
|
||||
if (!isFolder) {
|
||||
await list();
|
||||
removeFileFromToBeDeleted(file);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteFolder(file: BrowserObject, path: string) {
|
||||
assertIsInitialized(state);
|
||||
|
||||
async function recurse(filePath) {
|
||||
assertIsInitialized(state);
|
||||
|
||||
const { Contents, CommonPrefixes } = await state.s3
|
||||
.listObjects({
|
||||
Bucket: state.bucket,
|
||||
Delimiter: '/',
|
||||
Prefix: filePath,
|
||||
})
|
||||
.promise();
|
||||
|
||||
if (Contents === undefined) {
|
||||
throw new Error(
|
||||
'Bad S3 listObjects() response: "Contents" undefined',
|
||||
);
|
||||
}
|
||||
|
||||
if (CommonPrefixes === undefined) {
|
||||
throw new Error(
|
||||
'Bad S3 listObjects() response: "CommonPrefixes" undefined',
|
||||
);
|
||||
}
|
||||
|
||||
async function thread() {
|
||||
if (Contents === undefined) {
|
||||
throw new Error(
|
||||
'Bad S3 listObjects() response: "Contents" undefined',
|
||||
);
|
||||
}
|
||||
|
||||
while (Contents.length) {
|
||||
const file = Contents.pop();
|
||||
|
||||
await deleteObject('', file, true);
|
||||
}
|
||||
}
|
||||
|
||||
await Promise.all([thread(), thread(), thread()]);
|
||||
|
||||
for (const { Prefix } of CommonPrefixes) {
|
||||
await recurse(Prefix);
|
||||
}
|
||||
}
|
||||
|
||||
await recurse(path.length > 0 ? path + file.Key : file.Key + '/');
|
||||
|
||||
removeFileFromToBeDeleted(file);
|
||||
await list();
|
||||
}
|
||||
|
||||
async function deleteSelected() {
|
||||
const filesToDelete = [
|
||||
...state.selectedFiles,
|
||||
...state.shiftSelectedFiles,
|
||||
];
|
||||
|
||||
if (state.selectedAnchorFile) {
|
||||
filesToDelete.push(state.selectedAnchorFile);
|
||||
}
|
||||
|
||||
addFileToBeDeleted(filesToDelete);
|
||||
|
||||
await Promise.all(
|
||||
filesToDelete.map(async (file) => {
|
||||
if (file.type === 'file') {
|
||||
await deleteObject(state.path, file);
|
||||
} else {
|
||||
await deleteFolder(file, state.path);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
clearAllSelectedFiles();
|
||||
}
|
||||
|
||||
async function download(file) {
|
||||
assertIsInitialized(state);
|
||||
|
||||
const url = state.s3.getSignedUrl('getObject', {
|
||||
Bucket: state.bucket,
|
||||
Key: state.path + file.Key,
|
||||
});
|
||||
const downloadURL = function(data, fileName) {
|
||||
const a = document.createElement('a');
|
||||
a.href = data;
|
||||
a.download = fileName;
|
||||
a.click();
|
||||
};
|
||||
|
||||
downloadURL(url, file.Key);
|
||||
}
|
||||
|
||||
function updateSelectedFiles(files) {
|
||||
state.selectedFiles = [...files];
|
||||
}
|
||||
|
||||
function updateShiftSelectedFiles(files) {
|
||||
state.shiftSelectedFiles = files;
|
||||
}
|
||||
|
||||
function addFileToBeDeleted(file) {
|
||||
state.filesToBeDeleted = [...state.filesToBeDeleted, file];
|
||||
}
|
||||
|
||||
function removeFileFromToBeDeleted(file) {
|
||||
state.filesToBeDeleted = state.filesToBeDeleted.filter(
|
||||
(singleFile) => singleFile.Key !== file.Key,
|
||||
);
|
||||
}
|
||||
|
||||
function clearAllSelectedFiles() {
|
||||
if (state.selectedAnchorFile || state.unselectedAnchorFile) {
|
||||
state.selectedAnchorFile = null;
|
||||
state.unselectedAnchorFile = null;
|
||||
state.shiftSelectedFiles = [];
|
||||
state.selectedFiles = [];
|
||||
}
|
||||
}
|
||||
|
||||
function openDropdown(id) {
|
||||
clearAllSelectedFiles();
|
||||
state.openedDropdown = id;
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
state.openedDropdown = null;
|
||||
}
|
||||
|
||||
function openFileBrowserDropdown() {
|
||||
state.openedDropdown = 'FileBrowser';
|
||||
}
|
||||
|
||||
function cancelUpload(key) {
|
||||
const file = state.uploading.find((file) => file.Key === key);
|
||||
|
||||
if (typeof file === 'object') {
|
||||
if (file.progress !== undefined && file.upload && file.progress > 0) {
|
||||
file.upload.abort();
|
||||
}
|
||||
|
||||
state.uploading = state.uploading.filter((file) => file.Key !== key);
|
||||
} else {
|
||||
throw new Error(`File ${JSON.stringify(key)} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
function closeAllInteractions() {
|
||||
if (state.openedDropdown) {
|
||||
closeDropdown();
|
||||
}
|
||||
|
||||
if (state.selectedAnchorFile) {
|
||||
clearAllSelectedFiles();
|
||||
}
|
||||
}
|
||||
|
||||
function clear() {
|
||||
state.s3 = null;
|
||||
state.accessKey = null;
|
||||
state.path = '';
|
||||
state.bucket = '';
|
||||
state.browserRoot = '/';
|
||||
state.files = [];
|
||||
state.uploadChain = Promise.resolve();
|
||||
state.uploading = [];
|
||||
state.selectedAnchorFile = null;
|
||||
state.unselectedAnchorFile = null;
|
||||
state.selectedFiles = [];
|
||||
state.shiftSelectedFiles = [];
|
||||
state.filesToBeDeleted = [];
|
||||
state.fetchSharedLink = () => 'javascript:null';
|
||||
state.fetchPreviewAndMapUrl = () => 'javascript:null';
|
||||
state.openedDropdown = null;
|
||||
state.headingSorted = 'name';
|
||||
state.orderBy = 'asc';
|
||||
state.openModalOnFirstUpload = false;
|
||||
state.objectPathForModal = '';
|
||||
}
|
||||
|
||||
return {
|
||||
filesState: state,
|
||||
};
|
||||
});
|
Loading…
Reference in New Issue
Block a user