Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -18,14 +18,21 @@ const NameCollisionContainer: FC = () => {
const operationType = collisionDialogInfo?.operation;
const newItems = useMemo(() => collisionGroups.flatMap((g) => g.duplicatedItems), [collisionGroups]);
const existingItems = useMemo(() => collisionGroups.flatMap((g) => g.existingItems), [collisionGroups]);
const remainingItemsCount = existingItems.length;
const remainingItemsCount = newItems.length;

const selectedWorkspace = useAppSelector(workspacesSelectors.getSelectedWorkspace);
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 Expand Up @@ -60,7 +67,9 @@ const NameCollisionContainer: FC = () => {

const group = collisionGroups[groupIndex];
const itemToUpload = group.duplicatedItems[0];
const itemToReplace = findExistingItemFor(itemToUpload, group.existingItems);
const collidingExistingItem = findExistingItemFor(itemToUpload, group.existingItems);
const isReplacing = operation === 'replace';
const replacedExistingItem = isReplacing ? collidingExistingItem : undefined;

await resolveCollision(
{
Expand All @@ -73,7 +82,7 @@ const NameCollisionContainer: FC = () => {
context,
);

const remainingGroups = getRemainingGroups(collisionGroups, groupIndex, itemToReplace);
const remainingGroups = getRemainingGroups(collisionGroups, groupIndex, replacedExistingItem);
const hasRemainingGroups = remainingGroups.length > 0;
if (hasRemainingGroups) {
dispatch(
Expand Down

Large diffs are not rendered by default.

231 changes: 194 additions & 37 deletions src/app/drive/components/NameCollisionDialog/nameCollision.actions.ts
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 All @@ -20,7 +22,16 @@
import { isVersioningExtensionAllowed } from 'views/Drive/components/VersionHistory/utils';
import replaceFileService from 'views/Drive/services/replaceFile.service';
import { moveItemsToTrash } from 'views/Trash/services';
import { CollisionItem, CollisionPair, getCollisionPairs, isFolderUpload } from './nameCollision.utils';
import {
CollisionItem,
CollisionPair,
getCollisionPairs,
getUnpairedItems,
groupByNameSeries,
isFolderUpload,
isNameTakenBy,
splitReplacingPairs,
} from './nameCollision.utils';

export type CollisionOperationType = 'move' | 'upload';
export type CollisionOperation = 'keep' | 'replace' | 'skip';
Expand All @@ -30,6 +41,7 @@
selectedWorkspace: WorkspaceData | null;
maxUploadFileSize: number;
isVersioningEnabled: boolean;
versioningMaxFileSize: number;
}

export interface ResolveCollisionParams {
Expand All @@ -45,20 +57,122 @@
items: (IRoot | File)[];
};

const getUniqueNameMovePayload = async (item: DriveItemData, destinationUuid: string): Promise<MoveItemPayload> => {
interface ItemMove {
item: DriveItemData;
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);
const finalName = await getUniqueFolderName(
item.plainName ?? item.name,
duplicatedFoldersResponse as DriveItemData[],
destinationUuid,
);
return { ...item, name: finalName, plain_name: finalName, newItemName: finalName };
return duplicatedFoldersResponse as DriveItemData[];
}

const { duplicatedFilesResponse } = await checkDuplicatedFiles([item], destinationUuid);
const finalName = await getUniqueFilename(item.name, item.type, duplicatedFilesResponse, destinationUuid);
return { ...item, name: finalName, plainName: finalName, plain_name: finalName, newItemName: finalName };
return duplicatedFilesResponse as DriveItemData[];
};

const getLookupName = (item: DriveItemData): string => (item.isFolder ? (item.plainName ?? item.name) : item.name);

const getNextUniqueName = (
item: DriveItemData,
name: string,
takenItems: DriveItemData[],
destinationUuid: string,
): Promise<string> => {
if (item.isFolder) {
return getUniqueFolderName(name, takenItems, destinationUuid);
}

return getUniqueFilename(name, item.type, takenItems, destinationUuid);
};

/**
* Re-checks the names already assigned in this batch after every attempt, because the server knows
* nothing about names that are assigned but not moved yet.
*/
const getUniqueNameInBatch = async (
item: DriveItemData,
destinationUuid: string,
renamedItems: DriveItemData[],
): Promise<string> => {
const destinationDuplicates = await getDestinationDuplicates(item, destinationUuid);
const takenItems = [...destinationDuplicates, ...renamedItems];
let uniqueName = getLookupName(item);

do {
uniqueName = await getNextUniqueName(item, uniqueName, takenItems, destinationUuid);

Check warning on line 109 in src/app/drive/components/NameCollisionDialog/nameCollision.actions.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected `await` inside a loop.

See more on https://sonarcloud.io/project/issues?id=internxt_drive-web&issues=AaDwJyrAtRxBjtE1hWn2&open=AaDwJyrAtRxBjtE1hWn2&pullRequest=2159
} while (isNameTakenBy(uniqueName, item, renamedItems));

return uniqueName;
};

const getRenamedMovePayload = (item: DriveItemData, newName: string): MoveItemPayload => {
const renamedItem = { ...item, name: newName, plain_name: newName, newItemName: newName };
return item.isFolder ? renamedItem : { ...renamedItem, plainName: newName };
};

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

for (const item of seriesItems) {
const uniqueName = await getUniqueNameInBatch(item, destinationUuid, renamedItems);

Check warning on line 128 in src/app/drive/components/NameCollisionDialog/nameCollision.actions.ts

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Unexpected `await` inside a loop.

See more on https://sonarcloud.io/project/issues?id=internxt_drive-web&issues=AaDwJyrAtRxBjtE1hWn3&open=AaDwJyrAtRxBjtE1hWn3&pullRequest=2159
renamedItems.push({ ...item, name: uniqueName, plainName: uniqueName });
namedItems.push({ item, uniqueName });
}

return namedItems;
};

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

return namesBySeries.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 (
payload: MoveItemPayload,
destinationUuid: string,
{ dispatch }: NameCollisionContext,
): Promise<boolean> => {
try {
await dispatch(storageThunks.moveItemsThunk({ items: [payload], destinationFolderId: destinationUuid })).unwrap();
return true;
} catch {
return false;
}
};

/**
* Moves every item on its own, because the move thunk settles once for the whole batch and would
* hide which items made it to the destination when one of them fails.
*/
const moveItems = async (
moves: ItemMove[],
destinationUuid: string,
context: NameCollisionContext,
): Promise<DriveItemData[]> => {
const isMovedByIndex = await Promise.all(moves.map(({ payload }) => moveItem(payload, destinationUuid, context)));
const succeededMoves = moves.filter((_, index) => isMovedByIndex[index]);

return succeededMoves.map(({ item }) => item);
};

/**
Expand All @@ -67,49 +181,65 @@
const keepAndMoveItems = async (
items: DriveItemData[],
destinationUuid: string,
{ dispatch }: NameCollisionContext,
): Promise<void> => {
if (items.length === 0) return;
context: NameCollisionContext,
): Promise<DriveItemData[]> => {
if (items.length === 0) return [];

const itemsParsed = await Promise.all(items.map((item) => getUniqueNameMovePayload(item, destinationUuid)));
await dispatch(storageThunks.moveItemsThunk({ items: itemsParsed, destinationFolderId: destinationUuid }));
const moves = await getUniqueNameMoves(items, destinationUuid);
return moveItems(moves, destinationUuid, context);
};

/**
* Trashes the colliding drive items and then moves the incoming ones into their place.
*/
const trashAndMoveItems = async (
replacingPairs: CollisionPair<DriveItemData>[],
destinationUuid: string,
context: NameCollisionContext,
): Promise<DriveItemData[]> => {
if (replacingPairs.length === 0) return [];

const replacingMoves: ItemMove[] = replacingPairs.map(({ item }) => ({ item, payload: item }));

await moveItemsToTrash(replacingPairs.map((pair) => pair.existing));
return moveItems(replacingMoves, destinationUuid, context);
};

const replaceAndMoveItems = async (
pairs: CollisionPair<DriveItemData>[],
items: DriveItemData[],
existingItems: DriveItemData[],
destinationUuid: string,
{ dispatch }: NameCollisionContext,
): Promise<void> => {
if (pairs.length === 0) return;
context: NameCollisionContext,
): Promise<DriveItemData[]> => {
const pairs = getCollisionPairs(items, existingItems);
const unpairedItems = getUnpairedItems(items, existingItems);
const { replacingPairs, leftoverItems } = splitReplacingPairs(pairs);
const itemsToKeep = [...leftoverItems, ...unpairedItems];

await moveItemsToTrash(pairs.map((pair) => pair.existing));
await dispatch(
storageThunks.moveItemsThunk({
items: pairs.map((pair) => pair.item),
destinationFolderId: destinationUuid,
}),
);
const replacingItems = await trashAndMoveItems(replacingPairs, destinationUuid, context);
const keptItems = await keepAndMoveItems(itemsToKeep, destinationUuid, context);

return [...replacingItems, ...keptItems];
};

/**
* Applies the chosen resolution to items that collide while being moved, then removes them
* from the pending-deletion list. Skipped items are left untouched.
* from the pending-deletion list once moved. Skipped items are left untouched.
*/
const resolveMoveCollision = async (
{ operation, items, existingItems, destinationUuid }: ResolveMoveCollisionParams,
context: NameCollisionContext,
) => {
if (operation === 'skip') return;

let movedItems: DriveItemData[];

if (operation === 'keep') {
await keepAndMoveItems(items, destinationUuid, context);
movedItems = await keepAndMoveItems(items, destinationUuid, context);
} else {
await replaceAndMoveItems(getCollisionPairs(items, existingItems), destinationUuid, context);
movedItems = await replaceAndMoveItems(items, existingItems, destinationUuid, context);
}
context.dispatch(storageActions.popItemsToDelete(items));
context.dispatch(storageActions.popItemsToDelete(movedItems));
};

const uploadFileAndGetFileId = async (
Expand Down Expand Up @@ -166,21 +296,31 @@
});
};

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 @@ -209,7 +349,8 @@

/**
* 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 @@ -277,8 +418,22 @@
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 @@ -287,7 +442,9 @@
): 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