diff --git a/package.json b/package.json index d3ca1ba74..7e3fcc30d 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "@iconscout/react-unicons": "^1.1.6", "@internxt/inxt-js": "=1.2.21", "@internxt/lib": "^1.2.0", - "@internxt/sdk": "^1.5.15", + "@internxt/sdk": "^1.5.16", "@phosphor-icons/react": "^2.1.7", "@popperjs/core": "^2.11.6", "@reduxjs/toolkit": "^1.6.0", diff --git a/src/app/drive/components/DriveExplorer/DriveExplorer.tsx b/src/app/drive/components/DriveExplorer/DriveExplorer.tsx index 79e82e3b7..719c19be3 100644 --- a/src/app/drive/components/DriveExplorer/DriveExplorer.tsx +++ b/src/app/drive/components/DriveExplorer/DriveExplorer.tsx @@ -370,11 +370,15 @@ const DriveExplorer = (props: DriveExplorerProps): JSX.Element => { folderInputRef.current?.click(); }, [currentFolderId]); - const onUploadFileInputChanged = (e) => { + const onUploadFileInputChanged = async (e) => { const files = e.target.files; if (files.length <= UPLOAD_ITEMS_LIMIT) { - const unrepeatedUploadedFiles = handleRepeatedUploadingFiles(Array.from(files), items, dispatch) as File[]; + const unrepeatedUploadedFiles = (await handleRepeatedUploadingFiles( + Array.from(files), + dispatch, + currentFolderId, + )) as File[]; dispatch( storageThunks.uploadItemsThunk({ files: Array.from(unrepeatedUploadedFiles), @@ -920,7 +924,7 @@ const uploadItems = async (props: DriveExplorerProps, rootList: IRoot[], files: itemsDragged: items, }, }); - const unrepeatedUploadedFiles = handleRepeatedUploadingFiles(files, items, dispatch) as File[]; + const unrepeatedUploadedFiles = (await handleRepeatedUploadingFiles(files, dispatch, currentFolderId)) as File[]; // files where dragged directly await dispatch( storageThunks.uploadItemsThunk({ @@ -945,7 +949,11 @@ const uploadItems = async (props: DriveExplorerProps, rootList: IRoot[], files: itemsDragged: items, }, }); - const unrepeatedUploadedFolders = handleRepeatedUploadingFolders(rootList, items, dispatch) as IRoot[]; + const unrepeatedUploadedFolders = (await handleRepeatedUploadingFolders( + rootList, + dispatch, + currentFolderId, + )) as IRoot[]; if (unrepeatedUploadedFolders.length > 0) { const folderDataToUpload = unrepeatedUploadedFolders.map((root) => ({ diff --git a/src/app/drive/components/DriveExplorer/DriveExplorerItem/hooks/useDriveItemDragAndDrop.tsx b/src/app/drive/components/DriveExplorer/DriveExplorerItem/hooks/useDriveItemDragAndDrop.tsx index 0fc0d0cb1..78677f861 100644 --- a/src/app/drive/components/DriveExplorer/DriveExplorerItem/hooks/useDriveItemDragAndDrop.tsx +++ b/src/app/drive/components/DriveExplorer/DriveExplorerItem/hooks/useDriveItemDragAndDrop.tsx @@ -1,6 +1,5 @@ import { ConnectDragSource, ConnectDropTarget, useDrag, useDrop } from 'react-dnd'; import { NativeTypes } from 'react-dnd-html5-backend'; -import { SdkFactory } from '../../../../../core/factory/sdk'; import { transformDraggedItems } from '../../../../../core/services/drag-and-drop.service'; import { DragAndDropType } from '../../../../../core/types'; import { useAppDispatch, useAppSelector } from '../../../../../store/hooks'; @@ -49,7 +48,6 @@ export const useDriveItemDrop = (item: DriveItemData): DriveItemDrop => { const dispatch = useAppDispatch(); const isSomeItemSelected = useAppSelector(storageSelectors.isSomeItemSelected); const { selectedItems } = useAppSelector((state) => state.storage); - const workspacesCredentials = useAppSelector((state) => state.workspaces.workspaceCredentials); const namePath = useAppSelector((state) => state.storage.namePath); const [{ isDraggingOverThisItem, canDrop }, connectDropTarget] = useDrop< DriveItemData | DriveItemData[], @@ -88,30 +86,10 @@ export const useDriveItemDrop = (item: DriveItemData): DriveItemDrop => { return i.isFolder; }); - const storageClient = SdkFactory.getNewApiInstance().createNewStorageClient(); - dispatch(storageActions.setMoveDestinationFolderId(item.uuid)); - const [folderContentPromise] = storageClient.getFolderContentByUuid( - item.uuid, - false, - workspacesCredentials?.tokenHeader, - ); - const { children: foldersInDestinationFolder, files: filesInDestinationFolder } = await folderContentPromise; - const foldersInDestinationFolderParsed = foldersInDestinationFolder.map((folder) => ({ - ...folder, - isFolder: true, - })); - const unrepeatedFiles = handleRepeatedUploadingFiles( - filesToMove, - filesInDestinationFolder as DriveItemData[], - dispatch, - ); - const unrepeatedFolders = handleRepeatedUploadingFolders( - foldersToMove, - foldersInDestinationFolderParsed as DriveItemData[], - dispatch, - ); + const unrepeatedFiles = await handleRepeatedUploadingFiles(filesToMove, dispatch, item.uuid); + const unrepeatedFolders = await handleRepeatedUploadingFolders(foldersToMove, dispatch, item.uuid); const unrepeatedItems: DriveItemData[] = [...unrepeatedFiles, ...unrepeatedFolders] as DriveItemData[]; if (unrepeatedItems.length === itemsToMove.length) diff --git a/src/app/drive/components/MoveItemsDialog/MoveItemsDialog.tsx b/src/app/drive/components/MoveItemsDialog/MoveItemsDialog.tsx index c730604cb..2085b7e36 100644 --- a/src/app/drive/components/MoveItemsDialog/MoveItemsDialog.tsx +++ b/src/app/drive/components/MoveItemsDialog/MoveItemsDialog.tsx @@ -20,7 +20,12 @@ import { uiActions } from 'app/store/slices/ui'; import folderImage from 'assets/icons/light/folder.svg'; import { useEffect, useState } from 'react'; import { useSelector } from 'react-redux'; -import { DriveItemData, FolderPathDialog } from '../../types'; +import { + handleRepeatedUploadingFiles, + handleRepeatedUploadingFolders, +} from '../../../store/slices/storage/storage.thunks/renameItemsThunk'; +import { IRoot } from '../../../store/slices/storage/types'; +import { DriveFileData, DriveFolderData, DriveItemData, FolderPathDialog } from '../../types'; import CreateFolderDialog from '../CreateFolderDialog/CreateFolderDialog'; interface MoveItemsDialogProps { @@ -152,6 +157,8 @@ const MoveItemsDialog = (props: MoveItemsDialogProps): JSX.Element => { const onAccept = async (destinationFolderId, name, namePaths): Promise => { try { + dispatch(storageActions.setMoveDestinationFolderId(destinationFolderId)); + setIsLoading(true); if (itemsToMove.length > 0) { if (destinationFolderId != currentFolderId) { @@ -162,14 +169,23 @@ const MoveItemsDialog = (props: MoveItemsDialogProps): JSX.Element => { destinationFolderId = currentFolderId; } - await dispatch( - storageThunks.moveItemsThunk({ - items: itemsToMove, - destinationFolderId: destinationFolderId, - }), - ); - } + const files = itemsToMove.filter((item) => item.type !== 'folder') as DriveFileData[]; + const folders = itemsToMove.filter((item) => item.type === 'folder') as (IRoot | DriveFolderData)[]; + + const filesWithoutDuplicates = await handleRepeatedUploadingFiles(files, dispatch, destinationFolderId); + const foldersWithoutDuplicates = await handleRepeatedUploadingFolders(folders, dispatch, destinationFolderId); + const itemsToMoveWithoutDuplicates = [...filesWithoutDuplicates, ...foldersWithoutDuplicates]; + + if (itemsToMoveWithoutDuplicates.length > 0) { + await dispatch( + storageThunks.moveItemsThunk({ + items: itemsToMoveWithoutDuplicates as DriveItemData[], + destinationFolderId: destinationFolderId, + }), + ); + } + } props.onItemsMoved?.(); setIsLoading(false); diff --git a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx index 9982ddaea..6318fe6b6 100644 --- a/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx +++ b/src/app/drive/components/NameCollisionDialog/NameCollisionContainer.tsx @@ -105,11 +105,17 @@ const NameCollisionContainer: FC = ({ }; const keepAndMoveItem = async (itemsToUpload: DriveItemData[]) => { - await dispatch(storageThunks.renameItemsThunk({ items: itemsToUpload, destinationFolderId: folderId })); - dispatch( - storageThunks.moveItemsThunk({ + await dispatch( + storageThunks.renameItemsThunk({ items: itemsToUpload, - destinationFolderId: moveDestinationFolderId as string, + destinationFolderId: folderId, + onRenameSuccess: (itemToUpload: DriveItemData) => + dispatch( + storageThunks.moveItemsThunk({ + items: [itemToUpload], + destinationFolderId: moveDestinationFolderId as string, + }), + ), }), ); }; diff --git a/src/app/drive/services/file.service/types.ts b/src/app/drive/services/file.service/types.ts new file mode 100644 index 000000000..2a567d598 --- /dev/null +++ b/src/app/drive/services/file.service/types.ts @@ -0,0 +1,7 @@ +export interface FileToUpload { + name: string; + size: number; + type: string; + content: File; + parentFolderId: string; +} diff --git a/src/app/drive/services/file.service/uploadFile.ts b/src/app/drive/services/file.service/uploadFile.ts index 903b77f15..261ce8c5b 100644 --- a/src/app/drive/services/file.service/uploadFile.ts +++ b/src/app/drive/services/file.service/uploadFile.ts @@ -13,6 +13,7 @@ import notificationsService, { ToastType } from '../../../notifications/services import { getEnvironmentConfig } from '../network.service'; import { generateThumbnailFromFile } from '../thumbnail.service'; +// TODO: REMOVE FROM HERE, DUPLICATED TO MAKE TESTS export interface FileToUpload { name: string; size: number; diff --git a/src/app/drive/services/new-storage.service.ts b/src/app/drive/services/new-storage.service.ts index 8b335d367..ac8c4e2e8 100644 --- a/src/app/drive/services/new-storage.service.ts +++ b/src/app/drive/services/new-storage.service.ts @@ -1,4 +1,12 @@ -import { DriveFileData, FolderAncestor, FolderMeta, FolderTreeResponse } from '@internxt/sdk/dist/drive/storage/types'; +import { + CheckDuplicatedFilesResponse, + CheckDuplicatedFoldersResponse, + DriveFileData, + FileStructure, + FolderAncestor, + FolderMeta, + FolderTreeResponse, +} from '@internxt/sdk/dist/drive/storage/types'; import { SdkFactory } from '../../core/factory/sdk'; export async function searchItemsByName(name: string): Promise { @@ -23,11 +31,29 @@ export async function getFolderTree(uuid: string): Promise { return storageClient.getFolderTree(uuid); } +export async function checkDuplicatedFiles( + folderUuid: string, + filesList: FileStructure[], +): Promise { + const storageClient = SdkFactory.getNewApiInstance().createNewStorageClient(); + return storageClient.checkDuplicatedFiles({ folderUuid, filesList }); +} + +export async function checkDuplicatedFolders( + folderUuid: string, + folderNamesList: string[], +): Promise { + const storageClient = SdkFactory.getNewApiInstance().createNewStorageClient(); + return storageClient.checkDuplicatedFolders({ folderUuid, folderNamesList }); +} + const newStorageService = { searchItemsByName, getFolderAncestors, getFolderMeta, getFolderTree, + checkDuplicatedFiles, + checkDuplicatedFolders, }; export default newStorageService; diff --git a/src/app/drive/types/index.ts b/src/app/drive/types/index.ts index 6e4ac4b7b..6d53299d5 100644 --- a/src/app/drive/types/index.ts +++ b/src/app/drive/types/index.ts @@ -27,6 +27,7 @@ export interface DriveFolderData { shares?: Array; sharings?: { type: string; id: string }[]; uuid: string; + type?: string; } export interface DriveFolderMetadataPayload { diff --git a/src/app/shared/components/Breadcrumbs/BreadcrumbsItem/BreadcrumbsItem.tsx b/src/app/shared/components/Breadcrumbs/BreadcrumbsItem/BreadcrumbsItem.tsx index 51126aefd..438cd8de0 100644 --- a/src/app/shared/components/Breadcrumbs/BreadcrumbsItem/BreadcrumbsItem.tsx +++ b/src/app/shared/components/Breadcrumbs/BreadcrumbsItem/BreadcrumbsItem.tsx @@ -7,7 +7,6 @@ import storageSelectors from 'app/store/slices/storage/storage.selectors'; import storageThunks from 'app/store/slices/storage/storage.thunks'; import { DropTargetMonitor, useDrop } from 'react-dnd'; import { NativeTypes } from 'react-dnd-html5-backend'; -import { SdkFactory } from '../../../../core/factory/sdk'; import { storageActions } from '../../../../store/slices/storage'; import { handleRepeatedUploadingFiles, @@ -26,7 +25,6 @@ interface BreadcrumbsItemProps { const BreadcrumbsItem = (props: BreadcrumbsItemProps): JSX.Element => { const dispatch = useAppDispatch(); const namePath = useAppSelector((state) => state.storage.namePath); - const workspacesCredentials = useAppSelector((state) => state.workspaces.workspaceCredentials); const isSomeItemSelected = useAppSelector(storageSelectors.isSomeItemSelected); const selectedItems = useAppSelector((state) => state.storage.selectedItems); @@ -52,31 +50,10 @@ const BreadcrumbsItem = (props: BreadcrumbsItemProps): JSX.Element => { }); dispatch(storageActions.setMoveDestinationFolderId(props.item.uuid)); - const storageClient = SdkFactory.getNewApiInstance().createNewStorageClient(); - const [folderContentPromise] = storageClient.getFolderContentByUuid( - props.item.uuid, - false, - workspacesCredentials?.tokenHeader, - ); - - const { children: foldersInDestinationFolder, files: filesInDestinationFolder } = await folderContentPromise; - - const foldersInDestinationFolderParsed = foldersInDestinationFolder.map((folder) => ({ - ...folder, - isFolder: true, - })); - - const unrepeatedFiles = handleRepeatedUploadingFiles( - filesToMove, - filesInDestinationFolder as DriveItemData[], - dispatch, - ); - const unrepeatedFolders = handleRepeatedUploadingFolders( - foldersToMove, - foldersInDestinationFolderParsed as DriveItemData[], - dispatch, - ); + const folderUuid = props.item.uuid; + const unrepeatedFiles = await handleRepeatedUploadingFiles(filesToMove, dispatch, folderUuid); + const unrepeatedFolders = await handleRepeatedUploadingFolders(foldersToMove, dispatch, folderUuid); const unrepeatedItems: DriveItemData[] = [...unrepeatedFiles, ...unrepeatedFolders] as DriveItemData[]; if (unrepeatedItems.length === itemsToMove.length) dispatch(storageActions.setMoveDestinationFolderId(null)); diff --git a/src/app/store/slices/storage/fileUtils/checkDuplicatedFiles.ts b/src/app/store/slices/storage/fileUtils/checkDuplicatedFiles.ts new file mode 100644 index 000000000..8a5c4c46a --- /dev/null +++ b/src/app/store/slices/storage/fileUtils/checkDuplicatedFiles.ts @@ -0,0 +1,65 @@ +import { items as itemUtils } from '@internxt/lib'; +import { DriveFileData } from '@internxt/sdk/dist/drive/storage/types'; +import newStorageService from '../../../../drive/services/new-storage.service'; + +export interface DuplicatedFilesResult { + duplicatedFilesResponse: DriveFileData[]; + filesWithDuplicates: (File | DriveFileData)[]; + filesWithoutDuplicates: (File | DriveFileData)[]; +} + +export const checkDuplicatedFiles = async ( + files: (File | DriveFileData)[], + parentFolderId: string, +): Promise => { + if (files.length === 0) { + return { + duplicatedFilesResponse: [], + filesWithDuplicates: [], + filesWithoutDuplicates: files, + } as DuplicatedFilesResult; + } + + const parsedFiles = files.map(parseFile); + const checkDuplicatedFileResponse = await newStorageService.checkDuplicatedFiles(parentFolderId, parsedFiles); + + const duplicatedFilesResponse = checkDuplicatedFileResponse.existentFiles; + + const { filesWithDuplicates, filesWithoutDuplicates } = parsedFiles.reduce( + (acc, parsedFile) => { + const isDuplicated = duplicatedFilesResponse.some( + (duplicatedFile) => + duplicatedFile.plainName === parsedFile.plainName && duplicatedFile.type === parsedFile.type, + ); + + if (isDuplicated) { + acc.filesWithDuplicates.push(parsedFile.originalFile); + } else { + acc.filesWithoutDuplicates.push(parsedFile.originalFile); + } + + return acc; + }, + { filesWithDuplicates: [], filesWithoutDuplicates: [] } as { + filesWithDuplicates: (File | DriveFileData)[]; + filesWithoutDuplicates: (File | DriveFileData)[]; + }, + ); + + return { duplicatedFilesResponse, filesWithoutDuplicates, filesWithDuplicates }; +}; + +interface ParsedFile { + plainName: string; + type: string; + originalFile: File | DriveFileData; +} + +const parseFile = (file: File | DriveFileData): ParsedFile => { + if (file instanceof File) { + const { filename, extension } = itemUtils.getFilenameAndExt(file.name); + return { plainName: filename, type: extension, originalFile: file }; + } else { + return { plainName: file.name, type: file.type, originalFile: file }; + } +}; diff --git a/src/app/store/slices/storage/fileUtils/getUniqueFileName.test.ts b/src/app/store/slices/storage/fileUtils/getUniqueFileName.test.ts new file mode 100644 index 000000000..210e72d77 --- /dev/null +++ b/src/app/store/slices/storage/fileUtils/getUniqueFileName.test.ts @@ -0,0 +1,93 @@ +import * as internxtLib from '@internxt/lib'; +import { DriveFileData } from '@internxt/sdk/dist/drive/storage/types'; +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; +import newStorageService from '../../../../drive/services/new-storage.service'; +import { getUniqueFilename } from './getUniqueFilename'; + +jest.mock('../../../../drive/services/new-storage.service', () => ({ + checkDuplicatedFiles: jest.fn(), +})); + +describe('getUniqueFilename', () => { + let renameIfNeededSpy; + + beforeEach(() => { + jest.clearAllMocks(); + renameIfNeededSpy = jest.spyOn(internxtLib.items, 'renameIfNeeded'); + }); + + it('should return the original name if no duplicates exist', async () => { + const filename = 'TestFile'; + const extension = 'txt'; + const duplicatedFiles = [] as DriveFileData[]; + const parentFolderId = 'parent123'; + + (newStorageService.checkDuplicatedFiles as jest.Mock).mockResolvedValue({ existentFiles: [] }); + + const result = await getUniqueFilename(filename, extension, duplicatedFiles, parentFolderId); + + expect(result).toBe(filename); + expect(newStorageService.checkDuplicatedFiles).toHaveBeenCalledWith(parentFolderId, [ + { plainName: filename, type: extension }, + ]); + expect(renameIfNeededSpy).toHaveBeenCalledWith([], filename, extension); + }); + + it('should rename the file if duplicates exist', async () => { + const filename = 'TestFile'; + const extension = 'txt'; + const duplicatedFiles = [{ name: 'TestFile.txt', plainName: 'TestFile', type: 'txt' }] as DriveFileData[]; + const parentFolderId = 'parent123'; + + (newStorageService.checkDuplicatedFiles as jest.Mock) + .mockResolvedValueOnce({ existentFiles: [{ plainName: 'TestFile', type: 'txt' }] }) + .mockResolvedValueOnce({ existentFiles: [] }); + + const result = await getUniqueFilename(filename, extension, duplicatedFiles, parentFolderId); + + expect(result).toBe('TestFile (1)'); + expect(newStorageService.checkDuplicatedFiles).toHaveBeenCalledTimes(2); + expect(renameIfNeededSpy).toHaveBeenCalledTimes(2); + }); + + it('should handle multiple renames if necessary', async () => { + const filename = 'TestFile'; + const extension = 'txt'; + const duplicatedFiles = [ + { name: 'TestFile.txt', plainName: 'TestFile', type: 'txt' }, + { name: 'TestFile (1).txt', plainName: 'TestFile (1)', type: 'txt' }, + ] as DriveFileData[]; + const parentFolderId = 'parent123'; + + (newStorageService.checkDuplicatedFiles as jest.Mock) + .mockResolvedValueOnce({ existentFiles: [{ plainName: 'TestFile', type: 'txt' }] }) + .mockResolvedValueOnce({ existentFiles: [{ plainName: 'TestFile (1)', type: 'txt' }] }) + .mockResolvedValueOnce({ existentFiles: [] }); + + const result = await getUniqueFilename(filename, extension, duplicatedFiles, parentFolderId); + + expect(result).toBe('TestFile (2)'); + expect(newStorageService.checkDuplicatedFiles).toHaveBeenCalledTimes(3); + expect(renameIfNeededSpy).toHaveBeenCalledTimes(3); + }); + + it('should handle files with different extensions', async () => { + const filename = 'TestFile'; + const extension = 'txt'; + const duplicatedFiles = [ + { name: 'TestFile.txt', plainName: 'TestFile', type: 'txt' }, + { name: 'TestFile.pdf', plainName: 'TestFile', type: 'pdf' }, + ] as DriveFileData[]; + const parentFolderId = 'parent123'; + + (newStorageService.checkDuplicatedFiles as jest.Mock) + .mockResolvedValueOnce({ existentFiles: [{ plainName: 'TestFile', type: 'txt' }] }) + .mockResolvedValueOnce({ existentFiles: [] }); + + const result = await getUniqueFilename(filename, extension, duplicatedFiles, parentFolderId); + + expect(result).toBe('TestFile (1)'); + expect(newStorageService.checkDuplicatedFiles).toHaveBeenCalledTimes(2); + expect(renameIfNeededSpy).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/app/store/slices/storage/fileUtils/getUniqueFilename.ts b/src/app/store/slices/storage/fileUtils/getUniqueFilename.ts new file mode 100644 index 000000000..85eddd2db --- /dev/null +++ b/src/app/store/slices/storage/fileUtils/getUniqueFilename.ts @@ -0,0 +1,38 @@ +import { items as itemsUtils } from '@internxt/lib'; + +import { DriveFileData } from '@internxt/sdk/dist/drive/storage/types'; +import newStorageService from '../../../../drive/services/new-storage.service'; + +export const getUniqueFilename = async ( + filename: string, + extension: string, + duplicatedFiles: DriveFileData[], + parentFolderId: string, +): Promise => { + let isFileNewNameDuplicated = true; + let finalFilename = filename; + let currentDuplicatedFiles = duplicatedFiles; + + do { + const currentFolderFilesToCheckDuplicates = currentDuplicatedFiles.map((file) => ({ + name: file.plainName ?? file.name, + type: file.type, + })); + + const [, , renamedFilename] = itemsUtils.renameIfNeeded( + currentFolderFilesToCheckDuplicates, + finalFilename, + extension, + ); + + finalFilename = renamedFilename; + + const duplicatedFilesResponse = await newStorageService.checkDuplicatedFiles(parentFolderId, [ + { plainName: renamedFilename, type: extension }, + ]); + currentDuplicatedFiles = duplicatedFilesResponse.existentFiles; + isFileNewNameDuplicated = currentDuplicatedFiles.length > 0; + } while (isFileNewNameDuplicated); + + return finalFilename; +}; diff --git a/src/app/store/slices/storage/fileUtils/prepareFilesToUpload.test.ts b/src/app/store/slices/storage/fileUtils/prepareFilesToUpload.test.ts new file mode 100644 index 000000000..e7c93f722 --- /dev/null +++ b/src/app/store/slices/storage/fileUtils/prepareFilesToUpload.test.ts @@ -0,0 +1,116 @@ +import { beforeEach, describe, expect, it, jest } from '@jest/globals'; + +import newStorageService from '../../../../drive/services/new-storage.service'; +import * as checkDuplicatedFilesModule from './checkDuplicatedFiles'; +import { prepareFilesToUpload } from './prepareFilesToUpload'; +import * as processDuplicateFilesModule from './processDuplicateFiles'; + +jest.mock('../../../../drive/services/new-storage.service', () => ({ + checkDuplicatedFiles: jest.fn(), +})); + +// MOCK FILE NECESSARY BECAUSE IN NODE, THE CLASS FILE NOT EXISTS +class MockFile { + name: string; + size: number; + type: string; + + constructor(parts: [], filename: string, properties?: { type?: string; size?: number }) { + this.name = filename; + this.size = properties?.size || 0; + this.type = properties?.type || ''; + } +} + +global.File = MockFile as any; + +describe('prepareFilesToUpload', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should process files in batches', async () => { + const TOTAL_FILES = 800; + const mockFiles = Array(TOTAL_FILES) + .fill(null) + .map((_, i) => new MockFile([], `file${i}.txt`, { type: 'text/plain', size: 13 })); + const parentFolderId = 'parent123'; + (newStorageService.checkDuplicatedFiles as jest.Mock).mockResolvedValue({ + existentFiles: [], + }); + + const checkDuplicatedFilesSpy = jest.spyOn(checkDuplicatedFilesModule, 'checkDuplicatedFiles'); + const processDuplicateFiles = jest.spyOn(processDuplicateFilesModule, 'processDuplicateFiles'); + const result = await prepareFilesToUpload({ files: mockFiles as File[], parentFolderId }); + + expect(checkDuplicatedFilesSpy).toHaveBeenCalledTimes(4); + expect(processDuplicateFiles).toHaveBeenCalledTimes(8); + expect(result.zeroLengthFilesNumber).toBe(0); + expect(result.filesToUpload.length).toBe(TOTAL_FILES); + }); + + it('should handle duplicates and non-duplicates', async () => { + const files = Array(10) + .fill(null) + .map((_, i) => new MockFile([], `file${i}.txt`, { type: 'text/plain', size: i === 0 ? 0 : 1 })); + const parentFolderId = 'parent123'; + + (newStorageService.checkDuplicatedFiles as jest.Mock) + .mockResolvedValueOnce({ + existentFiles: [{ plainName: 'file2', type: 'txt' }], + }) + .mockResolvedValueOnce({ existentFiles: [] }); + + const checkDuplicatedFilesSpy = jest.spyOn(checkDuplicatedFilesModule, 'checkDuplicatedFiles'); + const processDuplicateFiles = jest.spyOn(processDuplicateFilesModule, 'processDuplicateFiles'); + + const result = await prepareFilesToUpload({ files: files as File[], parentFolderId }); + + expect(checkDuplicatedFilesSpy).toHaveBeenCalledTimes(1); + expect(processDuplicateFiles).toHaveBeenCalledTimes(2); + expect(result.zeroLengthFilesNumber).toBe(1); + }); + + it('should respect the disableDuplicatedNamesCheck flag', async () => { + const files = [new File([], 'file.txt')]; + const parentFolderId = 'parent123'; + + (checkDuplicatedFilesModule.checkDuplicatedFiles as jest.Mock).mockResolvedValue({ + duplicatedFilesResponse: [{ name: 'file.txt' }], + filesWithoutDuplicates: [], + filesWithDuplicates: files, + }); + + const processDuplicateFiles = jest.spyOn(processDuplicateFilesModule, 'processDuplicateFiles'); + + await prepareFilesToUpload({ files, parentFolderId, disableDuplicatedNamesCheck: true }); + + expect(processDuplicateFiles).toHaveBeenCalledWith( + expect.objectContaining({ + disableDuplicatedNamesCheck: true, + }), + ); + }); + + it('should handle fileType parameter', async () => { + const files = [new File([], 'file.txt')]; + const parentFolderId = 'parent123'; + const fileType = 'text/plain'; + + (checkDuplicatedFilesModule.checkDuplicatedFiles as jest.Mock).mockResolvedValue({ + duplicatedFilesResponse: [], + filesWithoutDuplicates: files, + filesWithDuplicates: [], + }); + + const processDuplicateFiles = jest.spyOn(processDuplicateFilesModule, 'processDuplicateFiles'); + + await prepareFilesToUpload({ files, parentFolderId, fileType }); + + expect(processDuplicateFiles).toHaveBeenCalledWith( + expect.objectContaining({ + fileType: 'text/plain', + }), + ); + }); +}); diff --git a/src/app/store/slices/storage/fileUtils/prepareFilesToUpload.ts b/src/app/store/slices/storage/fileUtils/prepareFilesToUpload.ts new file mode 100644 index 000000000..adfd7d8d6 --- /dev/null +++ b/src/app/store/slices/storage/fileUtils/prepareFilesToUpload.ts @@ -0,0 +1,56 @@ +import { DriveFileData } from '@internxt/sdk/dist/drive/storage/types'; +import { FileToUpload } from '../../../../drive/services/file.service/uploadFile'; +import { checkDuplicatedFiles } from './checkDuplicatedFiles'; +import { processDuplicateFiles } from './processDuplicateFiles'; + +const BATCH_SIZE = 200; + +export const prepareFilesToUpload = async ({ + files, + parentFolderId, + disableDuplicatedNamesCheck = false, + fileType, +}: { + files: File[]; + parentFolderId: string; + disableDuplicatedNamesCheck?: boolean; + fileType?: string; +}): Promise<{ filesToUpload: FileToUpload[]; zeroLengthFilesNumber: number }> => { + let filesToUpload: FileToUpload[] = []; + let zeroLengthFilesNumber = 0; + + const processFiles = async ( + filesBatch: File[], + disableDuplicatedNamesCheckOverride: boolean, + duplicatedFiles?: DriveFileData[], + ) => { + const { zeroLengthFiles, newFilesToUpload } = await processDuplicateFiles({ + files: filesBatch, + existingFilesToUpload: filesToUpload, + fileType, + parentFolderId, + disableDuplicatedNamesCheck: disableDuplicatedNamesCheckOverride, + duplicatedFiles, + }); + + filesToUpload = newFilesToUpload; + zeroLengthFilesNumber += zeroLengthFiles; + }; + + const processFilesBatch = async (filesBatch: File[]) => { + const { duplicatedFilesResponse, filesWithoutDuplicates, filesWithDuplicates } = await checkDuplicatedFiles( + filesBatch, + parentFolderId, + ); + + await processFiles(filesWithoutDuplicates as File[], true); + await processFiles(filesWithDuplicates as File[], disableDuplicatedNamesCheck, duplicatedFilesResponse); + }; + + for (let i = 0; i < files.length; i += BATCH_SIZE) { + const batch = files.slice(i, i + BATCH_SIZE); + await processFilesBatch(batch); + } + + return { filesToUpload, zeroLengthFilesNumber }; +}; diff --git a/src/app/store/slices/storage/fileUtils/processDuplicateFiles.ts b/src/app/store/slices/storage/fileUtils/processDuplicateFiles.ts new file mode 100644 index 000000000..2c190f144 --- /dev/null +++ b/src/app/store/slices/storage/fileUtils/processDuplicateFiles.ts @@ -0,0 +1,51 @@ +import { items as itemUtils } from '@internxt/lib'; +import { DriveFileData } from '@internxt/sdk/dist/drive/storage/types'; +import { renameFile } from '../../../../crypto/services/utils'; +import { FileToUpload } from '../../../../drive/services/file.service/types'; +import { getUniqueFilename } from './getUniqueFilename'; + +interface ProcessDuplicateFilesParams { + files: File[]; + existingFilesToUpload: FileToUpload[]; + fileType?: string; + parentFolderId: string; + disableDuplicatedNamesCheck?: boolean; + duplicatedFiles?: DriveFileData[]; +} + +export const processDuplicateFiles = async ({ + files, + existingFilesToUpload, + fileType, + parentFolderId, + disableDuplicatedNamesCheck, + duplicatedFiles, +}: ProcessDuplicateFilesParams): Promise<{ newFilesToUpload: FileToUpload[]; zeroLengthFiles: number }> => { + const zeroLengthFiles = files.filter((file) => file.size === 0).length; + const newFilesToUpload: FileToUpload[] = [...existingFilesToUpload]; + + const processFile = async (file: File): Promise => { + if (file.size === 0) return; + + const { filename, extension } = itemUtils.getFilenameAndExt(file.name); + let finalFilename = filename; + + if (!disableDuplicatedNamesCheck && duplicatedFiles) { + finalFilename = await getUniqueFilename(filename, extension, duplicatedFiles, parentFolderId); + } + + const fileContent = renameFile(file, finalFilename); + + newFilesToUpload.push({ + name: finalFilename, + size: file.size, + type: extension ?? fileType, + content: fileContent, + parentFolderId, + }); + }; + + await Promise.all(files.filter((file) => file.size > 0).map(processFile)); + + return { newFilesToUpload, zeroLengthFiles }; +}; diff --git a/src/app/store/slices/storage/folderUtils/checkFolderDuplicated.test.ts b/src/app/store/slices/storage/folderUtils/checkFolderDuplicated.test.ts new file mode 100644 index 000000000..684344e17 --- /dev/null +++ b/src/app/store/slices/storage/folderUtils/checkFolderDuplicated.test.ts @@ -0,0 +1,83 @@ +import newStorageService from '../../../../drive/services/new-storage.service'; +import { DriveFolderData } from '../../../../drive/types'; + +import { checkFolderDuplicated } from './checkFolderDuplicated'; + +jest.mock('../../../../drive/services/new-storage.service', () => ({ + checkDuplicatedFolders: jest.fn(), +})); + +describe('checkFolderDuplicated', () => { + const parentFolderId = 'parent-folder-id'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return empty results when there are no folders', async () => { + const folders = []; + const result = await checkFolderDuplicated(folders, parentFolderId); + + expect(result).toEqual({ + duplicatedFoldersResponse: [], + foldersWithDuplicates: [], + foldersWithoutDuplicates: [], + }); + }); + + it('should return all folders as duplicated when all are duplicated', async () => { + const folders = [{ name: 'Folder1' }, { name: 'Folder2' }] as DriveFolderData[]; + + (newStorageService.checkDuplicatedFolders as jest.Mock).mockResolvedValue({ + existentFolders: [{ plainName: 'Folder1' }, { plainName: 'Folder2' }], + }); + + const result = await checkFolderDuplicated(folders, parentFolderId); + + expect(newStorageService.checkDuplicatedFolders).toHaveBeenCalledWith(parentFolderId, ['Folder1', 'Folder2']); + expect(result).toEqual({ + duplicatedFoldersResponse: [{ plainName: 'Folder1' }, { plainName: 'Folder2' }], + foldersWithDuplicates: folders, + foldersWithoutDuplicates: [], + }); + }); + + it('should return some folders as duplicated and others without duplicates', async () => { + const folders = [{ name: 'Folder1' }, { name: 'Folder2' }, { name: 'Folder3' }] as DriveFolderData[]; + const parentFolderId = 'someParentId'; + + (newStorageService.checkDuplicatedFolders as jest.Mock).mockResolvedValue({ + existentFolders: [{ plainName: 'Folder1' }, { plainName: 'Folder3' }], + }); + + const result = await checkFolderDuplicated(folders, parentFolderId); + + expect(newStorageService.checkDuplicatedFolders).toHaveBeenCalledWith(parentFolderId, [ + 'Folder1', + 'Folder2', + 'Folder3', + ]); + expect(result).toEqual({ + duplicatedFoldersResponse: [{ plainName: 'Folder1' }, { plainName: 'Folder3' }], + foldersWithDuplicates: [{ name: 'Folder1' }, { name: 'Folder3' }], + foldersWithoutDuplicates: [{ name: 'Folder2' }], + }); + }); + + it('should return all folders without duplicates when none are duplicated', async () => { + const folders = [{ name: 'Folder1' }, { name: 'Folder2' }] as DriveFolderData[]; + + (newStorageService.checkDuplicatedFolders as jest.Mock).mockResolvedValue({ + existentFolders: [], + }); + + const result = await checkFolderDuplicated(folders, parentFolderId); + + expect(newStorageService.checkDuplicatedFolders).toHaveBeenCalledWith(parentFolderId, ['Folder1', 'Folder2']); + expect(result).toEqual({ + duplicatedFoldersResponse: [], + foldersWithDuplicates: [], + foldersWithoutDuplicates: folders, + }); + }); +}); diff --git a/src/app/store/slices/storage/folderUtils/checkFolderDuplicated.ts b/src/app/store/slices/storage/folderUtils/checkFolderDuplicated.ts new file mode 100644 index 000000000..57f1aae11 --- /dev/null +++ b/src/app/store/slices/storage/folderUtils/checkFolderDuplicated.ts @@ -0,0 +1,46 @@ +import { DriveFolderData } from '@internxt/sdk/dist/drive/storage/types'; +import newStorageService from '../../../../drive/services/new-storage.service'; +import { IRoot } from '../types'; + +interface DuplicatedFoldersResult { + duplicatedFoldersResponse: DriveFolderData[]; + foldersWithDuplicates: (IRoot | DriveFolderData)[]; + foldersWithoutDuplicates: (IRoot | DriveFolderData)[]; +} + +export const checkFolderDuplicated = async ( + folders: (IRoot | DriveFolderData)[], + parentFolderId: string, +): Promise => { + if (folders.length === 0) { + return { + duplicatedFoldersResponse: [], + foldersWithDuplicates: [], + foldersWithoutDuplicates: folders, + }; + } + const foldersNamesToUpload = folders.map((folder) => folder.name); + + const checkDuplicatedFolderResponse = await newStorageService.checkDuplicatedFolders( + parentFolderId, + foldersNamesToUpload, + ); + const duplicatedFoldersResponse = checkDuplicatedFolderResponse.existentFolders; + + const foldersWithDuplicates: (IRoot | DriveFolderData)[] = []; + const foldersWithoutDuplicates: (IRoot | DriveFolderData)[] = []; + + folders.forEach((folder) => { + const isDuplicate = duplicatedFoldersResponse.some( + (duplicatedFolder) => (duplicatedFolder as DriveFolderData & { plainName: string }).plainName === folder.name, + ); + + if (isDuplicate) { + foldersWithDuplicates.push(folder); + } else { + foldersWithoutDuplicates.push(folder); + } + }); + + return { duplicatedFoldersResponse, foldersWithoutDuplicates, foldersWithDuplicates }; +}; diff --git a/src/app/store/slices/storage/folderUtils/getUniqueFolderName.test.ts b/src/app/store/slices/storage/folderUtils/getUniqueFolderName.test.ts new file mode 100644 index 000000000..c011fae2f --- /dev/null +++ b/src/app/store/slices/storage/folderUtils/getUniqueFolderName.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it, jest } from '@jest/globals'; + +import newStorageService from '../../../../drive/services/new-storage.service'; +import { DriveFolderData } from '../../../../drive/types'; + +import { getUniqueFolderName } from './getUniqueFolderName'; +import * as renameFolderModule from './renameFolderIfNeeded'; + +jest.mock('../../../../drive/services/new-storage.service', () => ({ + checkDuplicatedFolders: jest.fn(), +})); + +jest.mock('../storage.thunks/uploadFolderThunk', () => jest.fn()); + +describe('getUniqueFolderName', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return the original name if no duplicates exist', async () => { + const folderName = 'TestFolder'; + const duplicatedFolders: [] = []; + const parentFolderId = 'parent123'; + + (newStorageService.checkDuplicatedFolders as jest.Mock).mockResolvedValue({ existentFolders: [] }); + + const renameFolderIfNeeded = jest.spyOn(renameFolderModule, 'default'); + + const result = await getUniqueFolderName(folderName, duplicatedFolders, parentFolderId); + + expect(result).toBe(folderName); + expect(newStorageService.checkDuplicatedFolders).toHaveBeenCalledWith(parentFolderId, [folderName]); + expect(renameFolderIfNeeded).toHaveBeenCalledWith([], folderName); + }); + + it('should rename the folder if duplicates exist', async () => { + const folderName = 'TestFolder'; + const duplicatedFolders = [{ name: 'TestFolder', plainName: 'TestFolder' }] as DriveFolderData[]; + const parentFolderId = 'parent123'; + + (newStorageService.checkDuplicatedFolders as jest.Mock) + .mockResolvedValueOnce({ existentFolders: [{ plainName: 'TestFolder' }] }) + .mockResolvedValueOnce({ existentFolders: [] }); + const renameFolderIfNeeded = jest.spyOn(renameFolderModule, 'default'); + + const result = await getUniqueFolderName(folderName, duplicatedFolders, parentFolderId); + + expect(result).toBe('TestFolder (1)'); + expect(newStorageService.checkDuplicatedFolders).toHaveBeenCalledTimes(2); + expect(newStorageService.checkDuplicatedFolders).toHaveBeenCalledWith(parentFolderId, ['TestFolder (1)']); + expect(renameFolderIfNeeded).toHaveBeenCalledTimes(2); + }); + + it('should handle multiple renames if necessary', async () => { + const folderName = 'TestFolder'; + const duplicatedFolders = [ + { name: 'TestFolder', plainName: 'TestFolder' }, + { name: 'TestFolder (1)', plainName: 'TestFolder (1)' }, + ] as DriveFolderData[]; + const parentFolderId = 'parent123'; + + (newStorageService.checkDuplicatedFolders as jest.Mock) + .mockResolvedValueOnce({ existentFolders: [{ plainName: 'TestFolder' }] }) + .mockResolvedValueOnce({ existentFolders: [{ plainName: 'TestFolder (1)' }] }) + .mockResolvedValueOnce({ existentFolders: [] }); + + const renameFolderIfNeeded = jest.spyOn(renameFolderModule, 'default'); + + const result = await getUniqueFolderName(folderName, duplicatedFolders, parentFolderId); + + expect(result).toBe('TestFolder (2)'); + expect(newStorageService.checkDuplicatedFolders).toHaveBeenCalledTimes(3); + expect(newStorageService.checkDuplicatedFolders).toHaveBeenLastCalledWith(parentFolderId, ['TestFolder (2)']); + expect(renameFolderIfNeeded).toHaveBeenCalledTimes(3); + }); +}); diff --git a/src/app/store/slices/storage/folderUtils/getUniqueFolderName.ts b/src/app/store/slices/storage/folderUtils/getUniqueFolderName.ts new file mode 100644 index 000000000..6ec7594bd --- /dev/null +++ b/src/app/store/slices/storage/folderUtils/getUniqueFolderName.ts @@ -0,0 +1,32 @@ +import newStorageService from '../../../../drive/services/new-storage.service'; +import { DriveFolderData } from '../../../../drive/types'; +import renameFolderIfNeeded from './renameFolderIfNeeded'; + +export const getUniqueFolderName = async ( + folderName: string, + duplicatedFolders: DriveFolderData[], + parentFolderId: string, +): Promise => { + let isFolderNewNameDuplicated = true; + let finalFolderName = folderName; + let currentDuplicatedFolders = duplicatedFolders; + do { + const currentFolderFoldersToCheckDuplicates = currentDuplicatedFolders.map((folder) => ({ + ...folder, + name: folder?.plainName ?? folder.name, + })); + + const [, , renamedFoldername] = renameFolderIfNeeded(currentFolderFoldersToCheckDuplicates, finalFolderName); + + finalFolderName = renamedFoldername; + + const duplicatedFoldersResponse = await newStorageService.checkDuplicatedFolders(parentFolderId, [ + renamedFoldername, + ]); + + currentDuplicatedFolders = duplicatedFoldersResponse.existentFolders as DriveFolderData[]; + isFolderNewNameDuplicated = currentDuplicatedFolders.length > 0; + } while (isFolderNewNameDuplicated); + + return finalFolderName; +}; diff --git a/src/app/store/slices/storage/folderUtils/renameFolderIfNeeded.test.ts b/src/app/store/slices/storage/folderUtils/renameFolderIfNeeded.test.ts new file mode 100644 index 000000000..e844984ee --- /dev/null +++ b/src/app/store/slices/storage/folderUtils/renameFolderIfNeeded.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from '@jest/globals'; +import renameFolderIfNeeded from './renameFolderIfNeeded'; + +describe('renameFolderIfNeeded', () => { + it('should return the original name when there are no conflicts', () => { + const items = [{ name: 'Folder1' }, { name: 'Folder2' }]; + const folderName = 'NewFolder'; + + const [needsRename, index, newName] = renameFolderIfNeeded(items, folderName); + + expect(needsRename).toBe(false); + expect(index).toBe(0); + expect(newName).toBe('NewFolder'); + }); + + it('should rename the folder when there is a conflict', () => { + const items = [{ name: 'NewFolder' }, { name: 'Folder2' }]; + const folderName = 'NewFolder'; + + const [needsRename, index, newName] = renameFolderIfNeeded(items, folderName); + + expect(needsRename).toBe(true); + expect(index).toBe(1); + expect(newName).toBe('NewFolder (1)'); + }); + + it('should handle multiple conflicts and increment correctly', () => { + const items = [{ name: 'NewFolder' }, { name: 'NewFolder (1)' }, { name: 'NewFolder (2)' }, { name: 'Folder2' }]; + const folderName = 'NewFolder'; + + const [needsRename, index, newName] = renameFolderIfNeeded(items, folderName); + + expect(needsRename).toBe(true); + expect(index).toBe(3); + expect(newName).toBe('NewFolder (3)'); + }); + + it('should handle non-sequential numbering', () => { + const items = [{ name: 'NewFolder' }, { name: 'NewFolder (1)' }, { name: 'NewFolder (3)' }, { name: 'Folder2' }]; + const folderName = 'NewFolder'; + + const [needsRename, index, newName] = renameFolderIfNeeded(items, folderName); + + expect(needsRename).toBe(true); + expect(index).toBe(4); + expect(newName).toBe('NewFolder (4)'); + }); + + it('should handle folder names with existing parentheses', () => { + const items = [{ name: 'NewFolder (test)' }, { name: 'Folder2' }]; + const folderName = 'NewFolder (test)'; + + const [needsRename, index, newName] = renameFolderIfNeeded(items, folderName); + + expect(needsRename).toBe(true); + expect(index).toBe(1); + expect(newName).toBe('NewFolder (test) (1)'); + }); + + it('should handle folder names with existing numbers', () => { + const items = [{ name: 'NewFolder 123' }, { name: 'Folder2' }]; + const folderName = 'NewFolder 123'; + + const [needsRename, index, newName] = renameFolderIfNeeded(items, folderName); + + expect(needsRename).toBe(true); + expect(index).toBe(1); + expect(newName).toBe('NewFolder 123 (1)'); + }); +}); diff --git a/src/app/store/slices/storage/folderUtils/renameFolderIfNeeded.ts b/src/app/store/slices/storage/folderUtils/renameFolderIfNeeded.ts new file mode 100644 index 000000000..5e19994fc --- /dev/null +++ b/src/app/store/slices/storage/folderUtils/renameFolderIfNeeded.ts @@ -0,0 +1,35 @@ +function getNextNewName(filename: string, i: number): string { + return `${filename} (${i})`; +} + +export default function renameFolderIfNeeded(items: { name: string }[], folderName: string): [boolean, number, string] { + const FOLDER_INCREMENT_REGEX = /( \([0-9]+\))$/i; + const INCREMENT_INDEX_REGEX = /\(([^)]+)\)/; + + const cleanFilename = folderName.replace(FOLDER_INCREMENT_REGEX, ''); + + const infoFoldernames: { name: string; cleanName: string; incrementIndex: number }[] = items + .map((item) => { + const cleanName = item.name.replace(FOLDER_INCREMENT_REGEX, ''); + const incrementString = item.name.match(FOLDER_INCREMENT_REGEX)?.pop()?.match(INCREMENT_INDEX_REGEX)?.pop(); + const incrementIndex = parseInt(incrementString || '0'); + + return { + name: item.name, + cleanName, + incrementIndex, + }; + }) + .filter((item) => item.cleanName === cleanFilename) + .sort((a, b) => b.incrementIndex - a.incrementIndex); + + const filenameExists = !!infoFoldernames.length; + + if (filenameExists) { + const index = infoFoldernames[0].incrementIndex + 1; + + return [true, index, getNextNewName(cleanFilename, index)]; + } else { + return [false, 0, folderName]; + } +} diff --git a/src/app/store/slices/storage/storage.thunks/moveItemsThunk.ts b/src/app/store/slices/storage/storage.thunks/moveItemsThunk.ts index 270468f8e..c363b0b58 100644 --- a/src/app/store/slices/storage/storage.thunks/moveItemsThunk.ts +++ b/src/app/store/slices/storage/storage.thunks/moveItemsThunk.ts @@ -110,9 +110,9 @@ export const moveItemsThunkExtraReducers = (builder: ActionReducerMapBuilder undefined) .addCase(moveItemsThunk.fulfilled, () => undefined) - .addCase(moveItemsThunk.rejected, (state, action) => { + .addCase(moveItemsThunk.rejected, (_, action) => { notificationsService.show({ - text: 'error', + text: action.error.message ?? t('error.movingItem'), type: ToastType.Error, }); }); diff --git a/src/app/store/slices/storage/storage.thunks/renameItemsThunk.ts b/src/app/store/slices/storage/storage.thunks/renameItemsThunk.ts index 10ca14ca3..27b4bb2fa 100644 --- a/src/app/store/slices/storage/storage.thunks/renameItemsThunk.ts +++ b/src/app/store/slices/storage/storage.thunks/renameItemsThunk.ts @@ -1,7 +1,7 @@ import { ActionReducerMapBuilder, createAsyncThunk, Dispatch } from '@reduxjs/toolkit'; -import renameIfNeeded from '@internxt/lib/dist/src/items/renameIfNeeded'; -import { DriveItemData } from 'app/drive/types'; +import { DriveFileData } from '@internxt/sdk/dist/drive/storage/types'; +import { DriveFolderData, DriveItemData } from 'app/drive/types'; import notificationsService, { ToastType } from 'app/notifications/services/notifications.service'; import tasksService from 'app/tasks/services/tasks.service'; import { RenameFileTask, RenameFolderTask, TaskStatus, TaskType } from 'app/tasks/types'; @@ -9,168 +9,131 @@ import { t } from 'i18next'; import storageThunks from '.'; import { storageActions } from '..'; import { RootState } from '../../..'; -import { SdkFactory } from '../../../../core/factory/sdk'; -import errorService from '../../../../core/services/error.service'; import { uiActions } from '../../ui'; -import workspacesSelectors from '../../workspaces/workspaces.selectors'; +import { checkDuplicatedFiles } from '../fileUtils/checkDuplicatedFiles'; +import { getUniqueFilename } from '../fileUtils/getUniqueFilename'; +import { checkFolderDuplicated } from '../folderUtils/checkFolderDuplicated'; +import { getUniqueFolderName } from '../folderUtils/getUniqueFolderName'; import { StorageState } from '../storage.model'; -import storageSelectors from '../storage.selectors'; -import renameFolderIfNeeded, { IRoot } from './uploadFolderThunk'; - -const checkRepeatedNameFiles = (destinationFolderFiles: DriveItemData[], files: (DriveItemData | File)[]) => { - const repeatedFilesInDrive: DriveItemData[] = []; - const unrepeatedFiles: (DriveItemData | File)[] = []; - const filesRepeated = files.reduce((acc, file) => { - const exists = destinationFolderFiles.some((folderFile) => { - const fullFolderFileName = folderFile.name + '.' + folderFile.type; - - const fileName = (file as DriveItemData)?.fileId ? file.name + '.' + file.type : file.name; - if (fullFolderFileName === fileName) { - repeatedFilesInDrive.push(folderFile); - return true; - } - return false; - }); - - if (exists) { - return [...acc, file]; - } - unrepeatedFiles.push(file); - - return acc; - }, [] as (DriveItemData | File)[]); +import { IRoot } from './uploadFolderThunk'; - return { filesRepeated, repeatedFilesInDrive, unrepeatedFiles }; -}; - -const checkRepeatedNameFolders = (destinationFolderFolders: DriveItemData[], folders: (DriveItemData | IRoot)[]) => { - const repeatedFoldersInDrive: DriveItemData[] = []; - const unrepeatedFolders: (DriveItemData | IRoot)[] = []; - const foldersRepeated = folders.reduce((acc, file) => { - const exists = destinationFolderFolders.some((folderFile) => { - if (folderFile.name === file.name) { - repeatedFoldersInDrive.push(folderFile); - return true; - } - return false; - }); - - if (exists) { - return [...acc, file]; - } - unrepeatedFolders.push(file); - - return acc; - }, [] as (DriveItemData | IRoot)[]); - - return { foldersRepeated, repeatedFoldersInDrive, unrepeatedFolders }; -}; - -export const handleRepeatedUploadingFiles = ( - files: (DriveItemData | File)[], - items: DriveItemData[], +export const handleRepeatedUploadingFiles = async ( + files: (DriveFileData | File)[], dispatch: Dispatch, -): (DriveItemData | File)[] => { - const { filesRepeated, repeatedFilesInDrive, unrepeatedFiles } = checkRepeatedNameFiles(items, files); + destinationFolderUuid: string, +): Promise<(DriveFileData | File)[]> => { + const { + filesWithDuplicates: filesRepeated, + duplicatedFilesResponse, + filesWithoutDuplicates: unrepeatedFiles, + } = await checkDuplicatedFiles(files as File[], destinationFolderUuid); const hasRepeatedNameFiles = !!filesRepeated.length; if (hasRepeatedNameFiles) { - dispatch(storageActions.setFilesToRename(filesRepeated)); - dispatch(storageActions.setDriveFilesToRename(repeatedFilesInDrive)); + dispatch(storageActions.setFilesToRename(filesRepeated as DriveItemData[])); + dispatch(storageActions.setDriveFilesToRename(duplicatedFilesResponse as DriveItemData[])); dispatch(uiActions.setIsNameCollisionDialogOpen(true)); } - return unrepeatedFiles; + return unrepeatedFiles as DriveItemData[]; }; -export const handleRepeatedUploadingFolders = ( - folders: (DriveItemData | IRoot)[], - items: DriveItemData[], +export const handleRepeatedUploadingFolders = async ( + folders: (DriveFolderData | IRoot)[], dispatch: Dispatch, -): (DriveItemData | IRoot)[] => { - const { foldersRepeated, repeatedFoldersInDrive, unrepeatedFolders } = checkRepeatedNameFolders(items, folders); + destinationFolderUuid: string, +): Promise<(DriveFolderData | IRoot)[]> => { + const { + foldersWithDuplicates: foldersRepeated, + duplicatedFoldersResponse, + foldersWithoutDuplicates: unrepeatedFolders, + } = await checkFolderDuplicated(folders, destinationFolderUuid); + const hasRepeatedNameFiles = !!foldersRepeated.length; if (hasRepeatedNameFiles) { - dispatch(storageActions.setFoldersToRename(foldersRepeated)); - dispatch(storageActions.setDriveFoldersToRename(repeatedFoldersInDrive)); + dispatch(storageActions.setFoldersToRename(foldersRepeated as DriveItemData[])); + dispatch(storageActions.setDriveFoldersToRename(duplicatedFoldersResponse as DriveItemData[])); dispatch(uiActions.setIsNameCollisionDialogOpen(true)); } - return unrepeatedFolders; + return unrepeatedFolders as DriveItemData[]; }; export interface RenameItemsPayload { items: DriveItemData[]; destinationFolderId: string; + onRenameSuccess?: (newItemName: DriveItemData) => void; } export const renameItemsThunk = createAsyncThunk( 'storage/renameItems', - async ({ items, destinationFolderId }: RenameItemsPayload, { getState, dispatch }) => { + async ({ items, destinationFolderId, onRenameSuccess }: RenameItemsPayload, { dispatch }) => { const promises: Promise[] = []; - const state = getState(); - const workspaceCredentials = workspacesSelectors.getWorkspaceCredentials(state); if (items.some((item) => item.isFolder && item.uuid === destinationFolderId)) { return void notificationsService.show({ text: t('error.movingItemInsideItself'), type: ToastType.Error }); } - const currentFolderItems = storageSelectors.currentFolderItems(state); - for (const [index, item] of items.entries()) { - let itemParsed; - - const storageClient = SdkFactory.getNewApiInstance().createNewStorageClient(); - - const [parentFolderContentPromise] = storageClient.getFolderContentByUuid( - destinationFolderId, - false, - workspaceCredentials?.tokenHeader, - ); - const parentFolderContent = await parentFolderContentPromise; + let itemParsed: DriveItemData; if (item.isFolder) { - const currentFolderFolders = currentFolderItems.filter((item) => item?.isFolder); - const allFolderToCheckNames = [...parentFolderContent.children, ...currentFolderFolders]; - const [, , finalFilename] = renameFolderIfNeeded(allFolderToCheckNames, item.name); - itemParsed = { ...item, name: finalFilename, plain_name: finalFilename }; - } else { - const currentFolderFiles = currentFolderItems.filter((item) => !item?.isFolder); - const allFilesToCheckNames = [...parentFolderContent.files, ...currentFolderFiles]; - const [, , finalFilename] = renameIfNeeded(allFilesToCheckNames, item.name, item.type); - itemParsed = { ...item, name: finalFilename, plain_name: finalFilename }; - } + const { duplicatedFoldersResponse } = await checkFolderDuplicated([item], destinationFolderId); - let taskId: string; - if (itemParsed.isFolder) { - taskId = tasksService.create({ - action: TaskType.RenameFolder, - showNotification: true, - folder: itemParsed, + const finalFolderName = await getUniqueFolderName( + item.plainName ?? item.name, + duplicatedFoldersResponse as DriveFolderData[], destinationFolderId, - cancellable: true, - }); + ); + itemParsed = { ...item, name: finalFolderName, plain_name: finalFolderName }; } else { - taskId = tasksService.create({ - action: TaskType.RenameFile, - showNotification: true, - file: itemParsed, + const { duplicatedFilesResponse } = await checkDuplicatedFiles([item], destinationFolderId); + + const finalFilename = await getUniqueFilename( + item.name, + item.type, + duplicatedFilesResponse, destinationFolderId, - cancellable: true, - }); + ); + itemParsed = { ...item, name: finalFilename, plain_name: finalFilename }; } + const taskId: string = itemParsed.isFolder + ? tasksService.create({ + action: TaskType.RenameFolder, + showNotification: true, + folder: itemParsed, + destinationFolderId, + cancellable: true, + }) + : tasksService.create({ + action: TaskType.RenameFile, + showNotification: true, + file: itemParsed, + destinationFolderId, + cancellable: true, + }); + promises.push(dispatch(storageThunks.updateItemMetadataThunk({ item, metadata: { itemName: itemParsed.name } }))); promises[index] - .then(async () => { - tasksService.updateTask({ - taskId, - merge: { - status: TaskStatus.Success, - }, - }); + .then(async (result) => { + if (!result.error) { + tasksService.updateTask({ + taskId, + merge: { + status: TaskStatus.Success, + }, + }); + setTimeout(() => onRenameSuccess?.(itemParsed), 1000); + } else { + tasksService.updateTask({ + taskId, + merge: { + status: TaskStatus.Error, + }, + }); + } }) - .catch((e) => { - errorService.reportError(e); + .catch(() => { tasksService.updateTask({ taskId, merge: { diff --git a/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.ts b/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.ts index fed082bb7..aa40837de 100644 --- a/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.ts +++ b/src/app/store/slices/storage/storage.thunks/uploadFolderThunk.ts @@ -11,10 +11,14 @@ import tasksService from '../../../../tasks/services/tasks.service'; import { TaskStatus, TaskType, UploadFolderTask } from '../../../../tasks/types'; import { planThunks } from '../../plan'; import workspacesSelectors from '../../workspaces/workspaces.selectors'; + +import { checkFolderDuplicated } from '../folderUtils/checkFolderDuplicated'; +import { getUniqueFolderName } from '../folderUtils/getUniqueFolderName'; import { StorageState } from '../storage.model'; import { deleteItemsThunk } from './deleteItemsThunk'; import { uploadItemsParallelThunk } from './uploadItemsThunk'; +// TODO: REMOVE IROOT from this file, it is in types.ts export interface IRoot { name: string; folderId: string | null; @@ -33,14 +37,20 @@ interface UploadFolderThunkPayload { }; } -const handleFoldersRename = async (root: IRoot, currentFolderId: string, tokenHeader?: string) => { - const storageClient = SdkFactory.getNewApiInstance().createNewStorageClient(); - const [parentFolderContentPromise] = storageClient.getFolderContentByUuid(currentFolderId, false, tokenHeader); - const parentFolderContent = await parentFolderContentPromise; - const [, , finalFilename] = renameFolderIfNeeded(parentFolderContent.children, root.name); - const fileContent: IRoot = { ...root, name: finalFilename }; +const handleFoldersRename = async (root: IRoot, currentFolderId: string) => { + const { duplicatedFoldersResponse } = await checkFolderDuplicated([root], currentFolderId); + + let finalFilename = root.name; + if (duplicatedFoldersResponse.length > 0) + finalFilename = await getUniqueFolderName( + root.name, + duplicatedFoldersResponse as DriveFolderData[], + currentFolderId, + ); - return fileContent; + const folder: IRoot = { ...root, name: finalFilename }; + + return folder; }; const wait = (ms: number): Promise => { return new Promise((resolve) => setTimeout(resolve, ms)); @@ -73,8 +83,6 @@ export const uploadFolderThunk = createAsyncThunk { const state = getState(); - const workspaceCredentials = workspacesSelectors.getWorkspaceCredentials(state); - const workspaceSelected = workspacesSelectors.getSelectedWorkspace(state); const memberId = workspaceSelected?.workspaceUser?.memberId; @@ -85,7 +93,7 @@ export const uploadFolderThunk = createAsyncThunk { - const cleanName = item.name.replace(FOLDER_INCREMENT_REGEX, ''); - const incrementString = item.name.match(FOLDER_INCREMENT_REGEX)?.pop()?.match(INCREMENT_INDEX_REGEX)?.pop(); - const incrementIndex = parseInt(incrementString || '0'); - - return { - name: item.name, - cleanName, - incrementIndex, - }; - }) - .filter((item) => item.cleanName === cleanFilename) - .sort((a, b) => b.incrementIndex - a.incrementIndex); - - const filenameExists = !!infoFoldernames.length; - - if (filenameExists) { - const index = infoFoldernames[0].incrementIndex + 1; - - return [true, index, getNextNewName(cleanFilename, index)]; - } else { - return [false, 0, folderName]; - } -} - -function getNextNewName(filename: string, i: number): string { - return `${filename} (${i})`; -} - const generateTaskIdForFolders = (foldersPayload: UploadFolderThunkPayload[]) => { return foldersPayload.map(({ root, currentFolderId, options: payloadOptions }) => { const options = { withNotification: true, ...payloadOptions }; diff --git a/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.ts b/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.ts index b74729bf4..c7b9502f5 100644 --- a/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.ts +++ b/src/app/store/slices/storage/storage.thunks/uploadItemsThunk.ts @@ -14,7 +14,6 @@ import { t } from 'i18next'; import { storageActions } from '..'; import { RootState } from '../../..'; -import { SdkFactory } from '../../../../core/factory/sdk'; import errorService from '../../../../core/services/error.service'; import workspacesService from '../../../../core/services/workspace.service'; import { uploadFileWithManager } from '../../../../network/UploadManager'; @@ -23,6 +22,8 @@ import shareService from '../../../../share/services/share.service'; import { planThunks } from '../../plan'; import { uiActions } from '../../ui'; import workspacesSelectors from '../../workspaces/workspaces.selectors'; + +import { prepareFilesToUpload } from '../fileUtils/prepareFilesToUpload'; import { StorageState } from '../storage.model'; interface UploadItemsThunkOptions { @@ -98,60 +99,6 @@ const isUploadAllowed = ({ return true; }; -const prepareFilesToUpload = async ({ - files, - parentFolderId, - disableDuplicatedNamesCheck, - fileType, - workspaceToken, -}: { - files: File[]; - parentFolderId: string; - disableDuplicatedNamesCheck?: boolean; - fileType?: string; - workspaceToken?: string; -}): Promise<{ filesToUpload: FileToUpload[]; zeroLengthFilesNumber: number }> => { - const filesToUpload: FileToUpload[] = []; - const storageClient = SdkFactory.getNewApiInstance().createNewStorageClient(); - - let parentFolderContent; - - if (!disableDuplicatedNamesCheck) { - const [parentFolderContentPromise] = storageClient.getFolderContentByUuid(parentFolderId, false, workspaceToken); - parentFolderContent = await parentFolderContentPromise; - } - - let zeroLengthFilesNumber = 0; - - for (const file of files) { - if (file.size === 0) { - zeroLengthFilesNumber = zeroLengthFilesNumber + 1; - continue; - } - const { filename, extension } = itemUtils.getFilenameAndExt(file.name); - let fileContent; - let finalFilename = filename; - - if (!disableDuplicatedNamesCheck) { - const [, , renamedFilename] = itemUtils.renameIfNeeded(parentFolderContent.files, filename, extension); - finalFilename = renamedFilename; - fileContent = renameFile(file, renamedFilename); - } else { - fileContent = renameFile(file, filename); - } - - filesToUpload.push({ - name: finalFilename, - size: file.size, - type: extension ?? fileType, - content: fileContent, - parentFolderId, - }); - } - - return { filesToUpload, zeroLengthFilesNumber }; -}; - /** * @description * 1. Prepare files to upload @@ -199,7 +146,6 @@ export const uploadItemsThunk = createAsyncThunk item?.type === 'folder' || item?.isFolder; + const moveItemsToTrash = async (itemsToTrash: DriveItemData[], onSuccess?: () => void): Promise => { const items: Array<{ uuid: string; type: string }> = itemsToTrash.map((item) => { return { uuid: item.uuid, - type: item.isFolder ? 'folder' : 'file', + type: isFolder(item) ? 'folder' : 'file', }; }); let movingItemsToastId; @@ -81,10 +83,10 @@ const moveItemsToTrash = async (itemsToTrash: DriveItemData[], onSuccess?: () => item: itemsToTrash.length > 1 ? t('general.files') - : itemsToTrash[0].isFolder + : isFolder(itemsToTrash[0]) ? t('general.folder') : t('general.file'), - s: itemsToTrash.length > 1 ? 'os' : itemsToTrash[0].isFolder ? 'a' : 'o', + s: itemsToTrash.length > 1 ? 'os' : isFolder(itemsToTrash[0]) ? 'a' : 'o', }), action: { @@ -92,7 +94,7 @@ const moveItemsToTrash = async (itemsToTrash: DriveItemData[], onSuccess?: () => onClick: async () => { notificationsService.dismiss(id); if (itemsToTrash.length > 0) { - const destinationId = itemsToTrash[0].isFolder ? itemsToTrash[0].parentUuid : itemsToTrash[0].folderUuid; + const destinationId = isFolder(itemsToTrash[0]) ? itemsToTrash[0].parentUuid : itemsToTrash[0].folderUuid; store.dispatch( storageActions.pushItems({ updateRecents: true, items: itemsToTrash, folderIds: [destinationId] }), diff --git a/yarn.lock b/yarn.lock index 8eb9a8e9c..5352a6c11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1612,10 +1612,10 @@ resolved "https://npm.pkg.github.com/download/@internxt/prettier-config/1.0.2/5bd220b8de76734448db5475b3e0c01f9d22c19b#5bd220b8de76734448db5475b3e0c01f9d22c19b" integrity sha512-t4HiqvCbC7XgQepwWlIaFJe3iwW7HCf6xOSU9nKTV0tiGqOPz7xMtIgLEloQrDA34Cx4PkOYBXrvFPV6RxSFAA== -"@internxt/sdk@^1.5.15": - version "1.5.15" - resolved "https://npm.pkg.github.com/download/@internxt/sdk/1.5.15/8b8febc6f5d37f66db525f66d85fbfdba36b7357#8b8febc6f5d37f66db525f66d85fbfdba36b7357" - integrity sha512-LO2VpJsZKmPzAkz7Lu4JVubjpv27XwwunHKT3XTZ4BntGS7JxAVljOQ0LtY9GN5pdV3Id5YzQwB6SAzdm03z3A== +"@internxt/sdk@^1.5.16": + version "1.5.16" + resolved "https://npm.pkg.github.com/download/@internxt/sdk/1.5.16/6449a68049a83e9820cc59791d3fc239e32aa715#6449a68049a83e9820cc59791d3fc239e32aa715" + integrity sha512-e6anhV6PL04M+kEshBvHgjNMFUz2qylLwvhWzk4csEA/iBZHEJ5FeKN7RKcnP6XDuAjPKIYB2Vz3mnNdq3w/iA== dependencies: axios "^0.24.0" query-string "^7.1.0" @@ -1970,7 +1970,7 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" -"@phosphor-icons/react@^2.0.10": +"@phosphor-icons/react@^2.1.7": version "2.1.7" resolved "https://registry.yarnpkg.com/@phosphor-icons/react/-/react-2.1.7.tgz#b11a4b25849b7e3849970b688d9fe91e5d4fd8d7" integrity sha512-g2e2eVAn1XG2a+LI09QU3IORLhnFNAFkNbo2iwbX6NOKSLOwvEMmTa7CgOzEbgNWR47z8i8kwjdvYZ5fkGx1mQ==