Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,15 @@ const NameCollisionContainer: FC = () => {
const limits = useAppSelector(fileVersionsSelectors.getLimits);
const maxUploadFileSize = useAppSelector(fileVersionsSelectors.getMaxFileSizeLimit);
const isVersioningEnabled = limits?.versioning?.enabled ?? false;
const versioningMaxFileSize = limits?.versioning?.maxFileSize ?? 0;

const context: NameCollisionContext = { dispatch, selectedWorkspace, maxUploadFileSize, isVersioningEnabled };
const context: NameCollisionContext = {
dispatch,
selectedWorkspace,
maxUploadFileSize,
isVersioningEnabled,
versioningMaxFileSize,
};

const closeDialog = () => {
dispatch(uiActions.setIsNameCollisionDialogOpen({ open: false, info: undefined }));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ vi.mock('views/Drive/components/VersionHistory/utils', () => ({
}));

const DESTINATION = 'destination-uuid';
const VERSIONING_MAX_FILE_SIZE = 10;

const asMoveAction = (payload: unknown) => ({ type: 'move', payload });
const asPopAction = (payload: unknown) => ({ type: 'pop', payload });
Expand All @@ -83,6 +84,7 @@ const getContext = (overrides: Partial<NameCollisionContext> = {}): NameCollisio
selectedWorkspace: null,
maxUploadFileSize: 5000,
isVersioningEnabled: false,
versioningMaxFileSize: VERSIONING_MAX_FILE_SIZE,
...overrides,
});

Expand Down Expand Up @@ -143,9 +145,25 @@ const failMovesOf = (failingItems: DriveItemData[]) =>

const succeedAllMoves = () => failMovesOf([]);

const getPdfCollision = (name: string, size: number) => ({
file: new File(['x'.repeat(size)], `${name}.pdf`),
existing: getDriveItemData({ uuid: `existing-${name}`, plainName: name, type: 'pdf' }),
});

const resolve = (params: Omit<ResolveCollisionParams, 'destinationUuid'>, context = getContext()) =>
resolveCollision({ ...params, destinationUuid: DESTINATION }, context);

const replaceWithVersioning = (collisions: ReturnType<typeof getPdfCollision>[]) =>
resolve(
{
operationType: 'upload',
operation: 'replace',
items: collisions.map(({ file }) => file),
existingItems: collisions.map(({ existing }) => existing),
},
getContext({ isVersioningEnabled: true }),
);

/**
* Mocks are reset by hand because the browser test project does not do it between tests.
*/
Expand Down Expand Up @@ -336,7 +354,7 @@ describe('resolveCollision', () => {
expectMovesThenPop([asRenamed(file, 'report (1)')], [file]);
});

test('when uploading with keep, then folders upload with tracking, files upload with the duplicates check, and the folder is refreshed', async () => {
test('when uploading with keep, then folders upload with tracking, files upload under a unique name without the duplicates check, and the folder is refreshed', async () => {
const context = getContext({ maxUploadFileSize: 123, selectedWorkspace: { id: 'ws' } as never });
const file = new File(['content'], 'report.pdf');
const root = getRoot();
Expand All @@ -350,12 +368,44 @@ describe('resolveCollision', () => {
maxUploadFileSize: 123,
});
expect(mocks.dispatch.mock.calls).toEqual([
[asUploadAction({ files: [file], parentFolderId: DESTINATION, options: { disableDuplicatedNamesCheck: false } })],
[
asUploadAction({
files: [expect.objectContaining({ name: 'report (1).pdf' })],
parentFolderId: DESTINATION,
options: { disableDuplicatedNamesCheck: true },
}),
],
[asRefreshAction(DESTINATION)],
]);
expect(mocks.popItemsToDelete).not.toHaveBeenCalled();
});

test('when three colliding files of the same series are uploaded with keep, then they upload together under the next free names', async () => {
const fileNames = ['file.txt', 'file (1).txt', 'file (2).txt'];
mocks.getUniqueFilename
.mockResolvedValueOnce('file (3)')
.mockResolvedValueOnce('file (4)')
.mockResolvedValueOnce('file (5)');

await resolve({
operationType: 'upload',
operation: 'keep',
items: fileNames.map((name) => new File(['content'], name)),
existingItems: [],
});

expect(mocks.dispatch.mock.calls).toEqual([
[
asUploadAction({
files: ['file (3).txt', 'file (4).txt', 'file (5).txt'].map((name) => expect.objectContaining({ name })),
parentFolderId: DESTINATION,
options: { disableDuplicatedNamesCheck: true },
}),
],
[asRefreshAction(DESTINATION)],
]);
});

test('when uploading with replace and versioning is off, then only matched existing items are trashed and re-uploaded without the duplicates check', async () => {
const matched = new File(['content'], 'report.pdf');
const unmatched = new File(['content'], 'other.txt');
Expand Down Expand Up @@ -511,4 +561,50 @@ describe('resolveCollision', () => {
[asRefreshAction(DESTINATION)],
]);
});

test.each([
['under', VERSIONING_MAX_FILE_SIZE - 1],
['exactly at', VERSIONING_MAX_FILE_SIZE],
])(
'when replacing a versionable file %s the versioning size limit, then it becomes a new version and nothing is trashed',
async (_, size) => {
const { file, existing } = getPdfCollision('report', size);

await replaceWithVersioning([{ file, existing }]);

expect(mocks.replaceFile).toHaveBeenCalledWith(existing.uuid, { fileId: 'new-file-id', size });
expect(mocks.moveItemsToTrash).not.toHaveBeenCalled();
expect(mocks.uploadItemsThunk).not.toHaveBeenCalled();
},
);

test('when replacing a versionable file over the versioning size limit, then the existing file is trashed and the new one is uploaded', async () => {
const { file, existing } = getPdfCollision('report', VERSIONING_MAX_FILE_SIZE + 1);

await replaceWithVersioning([{ file, existing }]);

expect(mocks.replaceFile).not.toHaveBeenCalled();
expect(mocks.networkUploadFile).not.toHaveBeenCalled();
expect(mocks.moveItemsToTrash).toHaveBeenCalledWith([existing]);
expect(mocks.uploadItemsThunk).toHaveBeenCalledWith({
files: [file],
parentFolderId: DESTINATION,
options: { disableDuplicatedNamesCheck: true },
});
});

test('when replacing versionable files of mixed sizes at once, then only the ones within the limit become new versions', async () => {
const small = getPdfCollision('small', VERSIONING_MAX_FILE_SIZE);
const big = getPdfCollision('big', VERSIONING_MAX_FILE_SIZE + 1);

await replaceWithVersioning([small, big]);

expect(mocks.replaceFile).toHaveBeenCalledTimes(1);
expect(mocks.replaceFile).toHaveBeenCalledWith(small.existing.uuid, {
fileId: 'new-file-id',
size: small.file.size,
});
expect(mocks.moveItemsToTrash).toHaveBeenCalledWith([big.existing]);
expect(mocks.uploadItemsThunk).toHaveBeenCalledWith(expect.objectContaining({ files: [big.file] }));
});
});
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { items as itemUtils } from '@internxt/lib';
import { WorkspaceData } from '@internxt/sdk/dist/workspaces';
import { renameFile } from 'app/crypto/services/utils';
import { uploadFoldersWithTracking } from 'app/drive/services/folder.service/uploadFoldersWithTracking';
import { Network, getEnvironmentConfig } from 'app/drive/services/network.service';
import { DriveItemData } from 'app/drive/types';
Expand Down Expand Up @@ -39,6 +41,7 @@ export interface NameCollisionContext {
selectedWorkspace: WorkspaceData | null;
maxUploadFileSize: number;
isVersioningEnabled: boolean;
versioningMaxFileSize: number;
}

export interface ResolveCollisionParams {
Expand All @@ -59,6 +62,11 @@ interface ItemMove {
payload: MoveItemPayload;
}

interface UniquelyNamedItem {
item: DriveItemData;
uniqueName: string;
}

const getDestinationDuplicates = async (item: DriveItemData, destinationUuid: string): Promise<DriveItemData[]> => {
if (item.isFolder) {
const { duplicatedFoldersResponse } = await checkFolderDuplicated([item], destinationUuid);
Expand Down Expand Up @@ -109,24 +117,34 @@ const getRenamedMovePayload = (item: DriveItemData, newName: string): MoveItemPa
return item.isFolder ? renamedItem : { ...renamedItem, plainName: newName };
};

const getSeriesMoves = async (seriesItems: DriveItemData[], destinationUuid: string): Promise<ItemMove[]> => {
const getSeriesUniqueNames = async (
seriesItems: DriveItemData[],
destinationUuid: string,
): Promise<UniquelyNamedItem[]> => {
const renamedItems: DriveItemData[] = [];
const moves: ItemMove[] = [];
const namedItems: UniquelyNamedItem[] = [];

for (const item of seriesItems) {
const uniqueName = await getUniqueNameInBatch(item, destinationUuid, renamedItems);
renamedItems.push({ ...item, name: uniqueName, plainName: uniqueName });
moves.push({ item, payload: getRenamedMovePayload(item, uniqueName) });
namedItems.push({ item, uniqueName });
}

return moves;
return namedItems;
};

const getUniqueNameMoves = async (items: DriveItemData[], destinationUuid: string): Promise<ItemMove[]> => {
const getUniqueNames = async (items: DriveItemData[], destinationUuid: string): Promise<UniquelyNamedItem[]> => {
const series = groupByNameSeries(items, getLookupName);
const movesBySeries = await Promise.all(series.map((seriesItems) => getSeriesMoves(seriesItems, destinationUuid)));
const namesBySeries = await Promise.all(
series.map((seriesItems) => getSeriesUniqueNames(seriesItems, destinationUuid)),
);

return namesBySeries.flat();
};

return movesBySeries.flat();
const getUniqueNameMoves = async (items: DriveItemData[], destinationUuid: string): Promise<ItemMove[]> => {
const namedItems = await getUniqueNames(items, destinationUuid);
return namedItems.map(({ item, uniqueName }) => ({ item, payload: getRenamedMovePayload(item, uniqueName) }));
};

const moveItem = async (
Expand Down Expand Up @@ -278,21 +296,31 @@ const uploadFolders = async (
});
};

const splitFoldersAndFiles = (items: (IRoot | File)[]) => ({
folders: items.filter(isFolderUpload),
files: items.filter((item): item is File => !isFolderUpload(item)),
});

const uploadItems = async (
items: (IRoot | File)[],
destinationUuid: string,
context: NameCollisionContext,
shouldSkipDuplicatesCheck = false,
) => {
const folders = items.filter(isFolderUpload);
const files = items.filter((item): item is File => !isFolderUpload(item));
const { folders, files } = splitFoldersAndFiles(items);

await uploadFolders(folders, destinationUuid, context);
await uploadFiles(files, destinationUuid, context, shouldSkipDuplicatesCheck);
};

const isVersionedFilePair = (pair: CollisionPair<IRoot | File>, { isVersioningEnabled }: NameCollisionContext) =>
!isFolderUpload(pair.item) && isVersioningEnabled && isVersioningExtensionAllowed(pair.existing);
const isVersionedFilePair = (
pair: CollisionPair<IRoot | File>,
{ isVersioningEnabled, versioningMaxFileSize }: NameCollisionContext,
) =>
!isFolderUpload(pair.item) &&
isVersioningEnabled &&
isVersioningExtensionAllowed(pair.existing) &&
pair.item.size <= versioningMaxFileSize;

/**
* Versioned files are replaced one at a time because that upload bypasses the upload queue.
Expand Down Expand Up @@ -321,7 +349,8 @@ const trashAndUploadItems = async (

/**
* Replaces the colliding drive items with the uploaded ones. Files whose extension supports
* versioning become a new version of the existing file; everything else is trashed and re-uploaded.
* versioning and whose size is within the versioning limit become a new version of the existing
* file; everything else is trashed and re-uploaded.
*/
const replaceAndUploadItems = async (
pairs: CollisionPair<IRoot | File>[],
Expand Down Expand Up @@ -389,8 +418,22 @@ const skipAndUploadItems = async (
context.dispatch(fetchSortedFolderContentThunk(destinationUuid));
};

const getFileLookupItem = (file: File): DriveItemData => {
const { filename, extension } = itemUtils.getFilenameAndExt(file.name);
return { name: filename, plainName: filename, type: extension, isFolder: false } as DriveItemData;
};

const getUniquelyNamedFiles = async (files: File[], destinationUuid: string): Promise<File[]> => {
const lookupItems = files.map(getFileLookupItem);
const namedItems = await getUniqueNames(lookupItems, destinationUuid);

return namedItems.map(({ item, uniqueName }) =>
renameFile(files[lookupItems.indexOf(item)], itemUtils.getItemDisplayName({ name: uniqueName, type: item.type })),
);
};

/**
* Uploads the items next to the existing ones, letting the upload flow pick a unique name.
* Uploads the items next to the existing ones under a name that does not collide with anything.
*/
const keepAndUploadItems = async (
items: (IRoot | File)[],
Expand All @@ -399,7 +442,9 @@ const keepAndUploadItems = async (
): Promise<void> => {
if (items.length === 0) return;

await uploadItems(items, destinationUuid, context);
const { folders, files } = splitFoldersAndFiles(items);
await uploadFolders(folders, destinationUuid, context);
await uploadFiles(await getUniquelyNamedFiles(files, destinationUuid), destinationUuid, context, true);
context.dispatch(fetchSortedFolderContentThunk(destinationUuid));
};

Expand Down
Loading
Loading