diff --git a/assets/lang/strings.ts b/assets/lang/strings.ts index 1826adbb1..a16b74056 100644 --- a/assets/lang/strings.ts +++ b/assets/lang/strings.ts @@ -343,6 +343,7 @@ const translations = { descrypting: 'Decrypting...', cancel: 'Cancel', cancelling: 'Cancelling', + upload: 'Upload', confirm: 'Confirm', move: 'Move', moveHere: 'Move here', @@ -477,6 +478,20 @@ const translations = { duplicateFilesMessage: 'The following files already exist: %s\n\nDo you want to upload them with a new name?', duplicateFilesAction: 'Upload with new name', }, + nameCollision: { + title: 'Item already exists', + titleMultiple: 'Some items already exist', + messageSingleFolder: + '"{0}" already exists in this location. Do you want to replace the folder or keep both?', + messageSingleFile: + '"{0}" already exists in this location. Do you want to replace the file or keep both?', + messageMultiple: + 'More than one element already exists in this location. Do you want to replace them or keep all?', + replaceCurrentItem: 'Replace current item', + keepBoth: 'Keep both', + replaceAll: 'Replace all', + keepAll: 'Keep all', + }, rename: { title: 'Rename', label: 'Name', @@ -1134,6 +1149,7 @@ const translations = { descrypting: 'Desencriptando...', cancel: 'Cancelar', cancelling: 'Cancelando', + upload: 'Subir', confirm: 'Confirmar', move: 'Mover', save: 'Guardar', @@ -1268,6 +1284,20 @@ const translations = { duplicateFilesMessage: 'Los siguientes archivos ya existen: %s\n\n¿Quieres subirlos con un nuevo nombre?', duplicateFilesAction: 'Subir con nuevo nombre', }, + nameCollision: { + title: 'El elemento ya existe', + titleMultiple: 'Algunos elementos ya existen', + messageSingleFolder: + '"{0}" ya existe en esta ubicación. ¿Quieres reemplazar la carpeta o conservar ambas?', + messageSingleFile: + '"{0}" ya existe en esta ubicación. ¿Quieres reemplazar el archivo o conservar ambos?', + messageMultiple: + 'Más de un elemento ya existe en esta ubicación. ¿Quieres reemplazarlos o conservar todos?', + replaceCurrentItem: 'Reemplazar el elemento actual', + keepBoth: 'Conservar ambos', + replaceAll: 'Reemplazar todos', + keepAll: 'Conservar todos', + }, rename: { title: 'Renombrar', label: 'Nombre', diff --git a/src/components/modals/AddModal/hooks/useFolderUpload.ts b/src/components/modals/AddModal/hooks/useFolderUpload.ts index 3271af587..448ac968a 100644 --- a/src/components/modals/AddModal/hooks/useFolderUpload.ts +++ b/src/components/modals/AddModal/hooks/useFolderUpload.ts @@ -1,4 +1,5 @@ import { StorageAccessFramework } from 'expo-file-system/legacy'; +import { useRef, useState } from 'react'; import { Platform } from 'react-native'; import uuid from 'react-native-uuid'; @@ -8,6 +9,7 @@ import errorService from '@internxt-mobile/services/ErrorService'; import { DriveFileData } from '@internxt-mobile/types/drive/file'; import strings from '../../../../../assets/lang/strings'; import analytics, { DriveAnalyticsEvent } from '../../../../services/AnalyticsService'; +import { driveFolderService } from '../../../../services/drive/folder/driveFolder.service'; import { createFolderWithMerge, getMaxDepth, @@ -16,6 +18,8 @@ import { import { FolderTooLargeError, folderTraversalService } from '../../../../services/drive/folder/folderTraversal.service'; import { folderUploadService } from '../../../../services/drive/folder/folderUpload.service'; import { folderUploadCancellationService } from '../../../../services/drive/folder/folderUploadCancellation.service'; +import { getUniqueFolderName } from '../../../../services/drive/folder/utils/getUniqueFolderName'; +import { driveTrashService } from '../../../../services/drive/trash/driveTrash.service'; import fileSystemService from '../../../../services/FileSystemService'; import notificationsService from '../../../../services/NotificationsService'; import { useAppDispatch, useAppSelector } from '../../../../store/hooks'; @@ -23,10 +27,35 @@ import { driveActions, driveThunks } from '../../../../store/slices/drive'; import { uiActions } from '../../../../store/slices/ui'; import { NotificationType, ProgressCallback } from '../../../../types'; import { FolderTreeNode } from '../../../../types/drive/folderUpload'; +import { NameCollisionAction } from '../../NameCollisionModal'; // eslint-disable-next-line @typescript-eslint/no-empty-function const noopProgress: ProgressCallback = () => {}; +const showFolderUploadResult = ( + result: { cancelled: boolean; failedFiles: number; uploadedFiles: number; totalFiles: number }, + folderName: string, +) => { + if (result.cancelled) { + notificationsService.show({ type: NotificationType.Info, text1: strings.messages.folderUploadCancelled }); + } else if (result.failedFiles === 0) { + notificationsService.show({ + type: NotificationType.Success, + text1: strings.formatString(strings.messages.folderUploadCompleted, result.uploadedFiles, folderName) as string, + }); + } else { + notificationsService.show({ + type: NotificationType.Warning, + text1: strings.formatString( + strings.messages.folderUploadPartial, + result.uploadedFiles, + result.totalFiles, + result.failedFiles, + ) as string, + }); + } +}; + const getFileExtensionAndPlainName = (name: string): { extension: string; plainName: string } => { const lastDot = name.lastIndexOf('.'); if (lastDot <= 0) return { extension: '', plainName: name }; @@ -43,11 +72,49 @@ type UploadFileEntryFn = ( creationTime?: string, ) => Promise; +export interface FolderUploadCollisionModalState { + isOpen: boolean; + folderName: string; + onConfirm: (action: NameCollisionAction) => void; + onClose: () => void; +} + export const useFolderUpload = ({ uploadAndCreateFileEntry }: { uploadAndCreateFileEntry: UploadFileEntryFn }) => { const dispatch = useAppDispatch(); const { focusedFolder, loadFolderContent } = useDrive(); const folderUploads = useAppSelector((state) => state.drive.folderUploads); + const collisionResolverRef = useRef<((action: NameCollisionAction | null) => void) | null>(null); + const [collisionState, setCollisionState] = useState<{ + isOpen: boolean; + folderName: string; + existingFolderUuid: string; + existingFolderId: number; + }>({ isOpen: false, folderName: '', existingFolderUuid: '', existingFolderId: 0 }); + + const waitForCollisionResolution = ( + folderName: string, + existingFolderUuid: string, + existingFolderId: number, + ): Promise => { + return new Promise((resolve) => { + collisionResolverRef.current = resolve; + setCollisionState({ isOpen: true, folderName, existingFolderUuid, existingFolderId }); + }); + }; + + const closedCollisionState = { isOpen: false, folderName: '', existingFolderUuid: '', existingFolderId: 0 }; + + const handleCollisionConfirm = (action: NameCollisionAction) => { + setCollisionState(closedCollisionState); + collisionResolverRef.current?.(action); + }; + + const handleCollisionClose = () => { + setCollisionState(closedCollisionState); + collisionResolverRef.current?.(null); + }; + const handleUploadFolder = async () => { dispatch(uiActions.setShowUploadFileModal(false)); @@ -102,6 +169,19 @@ export const useFolderUpload = ({ uploadAndCreateFileEntry }: { uploadAndCreateF throw new Error('No focused folder UUID'); } + // Name collision check + const { existentFolders } = await driveFolderService.checkDuplicatedFolders(focusedFolder.uuid, [picked.name]); + if (existentFolders.length > 0) { + const existing = existentFolders[0]; + const action = await waitForCollisionResolution(picked.name, existing.uuid, existing.id); + if (action === null) return; + if (action === 'replace') { + await driveTrashService.moveToTrash([{ uuid: existing.uuid, id: existing.id, type: 'folder' }]); + } else { + picked.name = await getUniqueFolderName(picked.name, focusedFolder.uuid); + } + } + // 4. Create the root folder (merge if already exists) const rootFolderUuid = await createFolderWithMerge(focusedFolder.uuid, picked.name); logger.info(`[useFolderUpload][${uploadId}] Root folder "${picked.name}" - ${rootFolderUuid}`); @@ -171,31 +251,7 @@ export const useFolderUpload = ({ uploadAndCreateFileEntry }: { uploadAndCreateF }); // 9. Display result - if (result.cancelled) { - notificationsService.show({ - type: NotificationType.Info, - text1: strings.messages.folderUploadCancelled, - }); - } else if (result.failedFiles === 0) { - notificationsService.show({ - type: NotificationType.Success, - text1: strings.formatString( - strings.messages.folderUploadCompleted, - result.uploadedFiles, - picked.name, - ) as string, - }); - } else { - notificationsService.show({ - type: NotificationType.Warning, - text1: strings.formatString( - strings.messages.folderUploadPartial, - result.uploadedFiles, - result.totalFiles, - result.failedFiles, - ) as string, - }); - } + showFolderUploadResult(result, picked.name); } catch (err) { const error = err as Error; if (!(err instanceof FolderTooLargeError)) { @@ -217,5 +273,12 @@ export const useFolderUpload = ({ uploadAndCreateFileEntry }: { uploadAndCreateF } }; - return { handleUploadFolder }; + const nameCollisionModal: FolderUploadCollisionModalState = { + isOpen: collisionState.isOpen, + folderName: collisionState.folderName, + onConfirm: handleCollisionConfirm, + onClose: handleCollisionClose, + }; + + return { handleUploadFolder, nameCollisionModal }; }; diff --git a/src/components/modals/AddModal/index.tsx b/src/components/modals/AddModal/index.tsx index 45bd15da9..4d63e89ad 100644 --- a/src/components/modals/AddModal/index.tsx +++ b/src/components/modals/AddModal/index.tsx @@ -70,6 +70,7 @@ import { DocumentPickerFile, UPLOAD_FILE_SIZE_LIMIT, UploadingFile } from '../.. import AppText from '../../AppText'; import BottomModal from '../BottomModal'; import CreateFolderModal from '../CreateFolderModal'; +import NameCollisionModal from '../NameCollisionModal'; const MAX_FILES_BULK_UPLOAD = 50; @@ -85,7 +86,7 @@ function AddModal(): JSX.Element { const { limit } = useAppSelector((state) => state.storage); const usage = useAppSelector(storageSelectors.usage); const user = useAppSelector((state) => state.auth.user); - const { handleUploadFolder } = useFolderUpload({ uploadAndCreateFileEntry }); + const { handleUploadFolder, nameCollisionModal } = useFolderUpload({ uploadAndCreateFileEntry }); async function uploadIOS(file: UploadingFile, fileType: 'document' | 'image', progressCallback: ProgressCallback) { const name = file.name ?? decodeURI(file.uri).split('/').pop(); @@ -938,6 +939,15 @@ function AddModal(): JSX.Element { onFolderCreated={onFolderCreated} /> ) : null} + ); } diff --git a/src/components/modals/MoveItemsModal/index.tsx b/src/components/modals/MoveItemsModal/index.tsx index 25bd40b40..1e7cbd875 100644 --- a/src/components/modals/MoveItemsModal/index.tsx +++ b/src/components/modals/MoveItemsModal/index.tsx @@ -22,6 +22,9 @@ import { useTailwind } from 'tailwind-rn'; import { checkIsFile, checkIsFolder } from '../../../helpers'; import useGetColor from '../../../hooks/useColor'; import { logger } from '../../../services/common'; +import { driveFolderService } from '../../../services/drive/folder/driveFolder.service'; +import { getUniqueFolderName } from '../../../services/drive/folder/utils/getUniqueFolderName'; +import { driveTrashService } from '../../../services/drive/trash/driveTrash.service'; import notificationsService from '../../../services/NotificationsService'; import { NotificationType } from '../../../types'; import { RootScreenNavigationProp } from '../../../types/navigation'; @@ -30,6 +33,7 @@ import DriveItemSkinSkeleton from '../../DriveItemSkinSkeleton'; import BottomModal from '../BottomModal'; import ConfirmMoveItemModal from '../ConfirmMoveItemModal'; import CreateFolderModal from '../CreateFolderModal'; +import NameCollisionModal, { NameCollisionAction } from '../NameCollisionModal'; import SortModal, { SortMode } from '../SortModal'; const INITIAL_SORT_MODE: SortMode = { @@ -45,6 +49,9 @@ function MoveItemsModal(): JSX.Element { const navigation = useNavigation>(); const dispatch = useAppDispatch(); const [confirmModalOpen, setConfirmModalOpen] = useState(false); + const [nameCollisionOpen, setNameCollisionOpen] = useState(false); + const [collidingFolderName, setCollidingFolderName] = useState(''); + const [existingCollisionFolder, setExistingCollisionFolder] = useState<{ uuid: string; id: number } | null>(null); const [sortMode, setSortMode] = useState(INITIAL_SORT_MODE); const [sortModalOpen, setSortModalOpen] = useState(false); const [createFolderModalOpen, setCreateFolderModalOpen] = useState(false); @@ -112,7 +119,7 @@ function MoveItemsModal(): JSX.Element { return false; }; - const confirmMoveItem = async () => { + const executeMoveItem = async () => { if (!originFolderContentResponse || !itemToMove || !originFolderId || !destinationFolderContentResponse?.uuid) { notificationsService.show({ text1: strings.errors.moveError, @@ -194,6 +201,53 @@ function MoveItemsModal(): JSX.Element { } }; + const confirmMoveItem = async () => { + if (isFolder) { + const folderName = itemToMove?.name ?? ''; + try { + const { existentFolders } = await driveFolderService.checkDuplicatedFolders( + destinationFolderContentResponse?.uuid ?? '', + [folderName], + ); + if (existentFolders.length > 0) { + setConfirmModalOpen(false); + setCollidingFolderName(folderName); + setExistingCollisionFolder({ uuid: existentFolders[0].uuid, id: existentFolders[0].id }); + setNameCollisionOpen(true); + return; + } + } catch { + notificationsService.show({ text1: strings.errors.moveError, type: NotificationType.Error }); + return; + } + } + + await executeMoveItem(); + }; + + const handleNameCollisionConfirm = async (action: NameCollisionAction) => { + setNameCollisionOpen(false); + try { + if (action === 'replace' && existingCollisionFolder) { + await driveTrashService.moveToTrash([ + { uuid: existingCollisionFolder.uuid, id: existingCollisionFolder.id, type: 'folder' }, + ]); + } else if (action === 'keep-both' && itemToMove?.uuid && destinationFolderContentResponse?.uuid) { + const uniqueName = await getUniqueFolderName(collidingFolderName, destinationFolderContentResponse.uuid); + await driveFolderService.updateMetaData(itemToMove.uuid as string, uniqueName); + } + } catch { + notificationsService.show({ text1: strings.errors.moveError, type: NotificationType.Error }); + return; + } + await executeMoveItem(); + }; + + const handleNameCollisionClose = () => { + setNameCollisionOpen(false); + setConfirmModalOpen(true); + }; + const onFolderCreated = async () => { if (destinationFolderContentResponse?.uuid) { await loadDestinationFolderContent(destinationFolderContentResponse.uuid); @@ -453,6 +507,15 @@ function MoveItemsModal(): JSX.Element { onFolderCreated={onFolderCreated} /> ) : null} + ); } diff --git a/src/components/modals/NameCollisionModal/index.tsx b/src/components/modals/NameCollisionModal/index.tsx new file mode 100644 index 000000000..e0160c314 --- /dev/null +++ b/src/components/modals/NameCollisionModal/index.tsx @@ -0,0 +1,128 @@ +import Portal from '@burstware/react-native-portal'; +import strings from 'assets/lang/strings'; +import React, { useState } from 'react'; +import { Pressable, View } from 'react-native'; +import AppButton from 'src/components/AppButton'; +import AppText from 'src/components/AppText'; +import useGetColor from 'src/hooks/useColor'; +import { useTailwind } from 'tailwind-rn'; +import CenterModal from '../CenterModal'; + +export type NameCollisionAction = 'replace' | 'keep-both'; + +export interface NameCollisionModalProps { + isOpen: boolean; + itemName: string; + collisionCount: number; + itemType: 'file' | 'folder'; + confirmLabel: string; + onClose: () => void; + onConfirm: (action: NameCollisionAction) => void; +} + +const NameCollisionModal: React.FC = ({ + isOpen, + itemName, + collisionCount, + itemType, + confirmLabel, + onClose, + onConfirm, +}) => { + const tailwind = useTailwind(); + const getColor = useGetColor(); + const [selectedAction, setSelectedAction] = useState('replace'); + + const isMultiple = collisionCount > 1; + + const title = isMultiple ? strings.modals.nameCollision.titleMultiple : strings.modals.nameCollision.title; + + const singleMessageKey = itemType === 'folder' ? 'messageSingleFolder' : 'messageSingleFile'; + const message = isMultiple + ? strings.modals.nameCollision.messageMultiple + : (strings.formatString(strings.modals.nameCollision[singleMessageKey], itemName) as string); + + const replaceLabel = isMultiple + ? strings.modals.nameCollision.replaceAll + : strings.modals.nameCollision.replaceCurrentItem; + + const keepLabel = isMultiple ? strings.modals.nameCollision.keepAll : strings.modals.nameCollision.keepBoth; + + const handleConfirm = () => { + onConfirm(selectedAction); + }; + + return ( + + + + + {title} + + + + {message} + + + setSelectedAction('replace')} + /> + + + setSelectedAction('keep-both')} + /> + + + + + + + + + + + ); +}; + +interface RadioOptionProps { + label: string; + selected: boolean; + onPress: () => void; +} + +const RadioOption: React.FC = ({ label, selected, onPress }) => { + const tailwind = useTailwind(); + const getColor = useGetColor(); + + return ( + + + {label} + + ); +}; + +export default NameCollisionModal; diff --git a/src/services/drive/folder/utils/getUniqueFolderName.spec.ts b/src/services/drive/folder/utils/getUniqueFolderName.spec.ts new file mode 100644 index 000000000..cb244ff45 --- /dev/null +++ b/src/services/drive/folder/utils/getUniqueFolderName.spec.ts @@ -0,0 +1,121 @@ +import { buildCandidates, extractBaseName, FolderNameChecker, getUniqueFolderName } from './getUniqueFolderName'; + +const PARENT_UUID = 'parent-uuid-123'; + +const makeChecker = (taken: string[] = []): FolderNameChecker => jest.fn().mockResolvedValue(new Set(taken)); + +describe('extractBaseName', () => { + describe('when name has a trailing " (n)" suffix', () => { + it('when suffix is a single digit, then strips it', () => { + expect(extractBaseName('Photos (1)')).toBe('Photos'); + }); + + it('when suffix has multiple digits, then strips it', () => { + expect(extractBaseName('Documents (100)')).toBe('Documents'); + }); + }); + + describe('when name has no trailing suffix', () => { + it('when name is plain, then returns it unchanged', () => { + expect(extractBaseName('Photos')).toBe('Photos'); + }); + + it('when name is an empty string, then returns an empty string', () => { + expect(extractBaseName('')).toBe(''); + }); + }); + + describe('when parentheses are not a trailing numeric suffix', () => { + it('when parentheses are in the middle of the name, then does not strip them', () => { + expect(extractBaseName('My (photos) backup')).toBe('My (photos) backup'); + }); + + it('when parentheses contain non-digit text, then does not strip them', () => { + expect(extractBaseName('My Folder (copy)')).toBe('My Folder (copy)'); + }); + }); +}); + +describe('buildCandidates', () => { + it('when count is 3 and fromCounter is 1, then returns three sequential candidates', () => { + expect(buildCandidates('Photos', 1, 3)).toEqual(['Photos (1)', 'Photos (2)', 'Photos (3)']); + }); + + it('when fromCounter is 5, then starts from (5)', () => { + expect(buildCandidates('Photos', 5, 2)).toEqual(['Photos (5)', 'Photos (6)']); + }); + + it('when count is 1, then returns a single candidate', () => { + expect(buildCandidates('Photos', 1, 1)).toEqual(['Photos (1)']); + }); + + it('when count is 0, then returns an empty array', () => { + expect(buildCandidates('Photos', 1, 0)).toEqual([]); + }); +}); + +describe('getUniqueFolderName', () => { + describe('when the first candidate is free', () => { + it('then returns " (1)"', async () => { + const result = await getUniqueFolderName('Photos', PARENT_UUID, makeChecker()); + expect(result).toBe('Photos (1)'); + }); + }); + + describe('when some candidates are already taken', () => { + it('when the first candidate is taken, then returns the next free one', async () => { + const result = await getUniqueFolderName('Photos', PARENT_UUID, makeChecker(['Photos (1)'])); + expect(result).toBe('Photos (2)'); + }); + }); + + describe('when folderName already has a trailing suffix', () => { + it('then strips it and searches from (1)', async () => { + const result = await getUniqueFolderName('Photos (3)', PARENT_UUID, makeChecker()); + expect(result).toBe('Photos (1)'); + }); + }); + + describe('when verifying API call behaviour', () => { + it('then passes the correct parentFolderUuid to the checker', async () => { + const checker = makeChecker(); + await getUniqueFolderName('Photos', PARENT_UUID, checker); + expect(checker).toHaveBeenCalledWith(PARENT_UUID, expect.any(Array)); + }); + + it('then sends candidates in a batch instead of one by one', async () => { + const checker = makeChecker(); + await getUniqueFolderName('Photos', PARENT_UUID, checker); + expect(checker).toHaveBeenCalledTimes(1); + const [, names] = (checker as jest.Mock).mock.calls[0]; + expect(names.length).toBeGreaterThan(1); + }); + }); + + describe('when the entire first batch is taken', () => { + it('then queries the next batch and returns the first free name', async () => { + const BATCH_SIZE = 100; + const firstBatch = Array.from({ length: BATCH_SIZE }, (_, i) => `Photos (${i + 1})`); + + const checker: FolderNameChecker = jest + .fn() + .mockResolvedValueOnce(new Set(firstBatch)) + .mockResolvedValue(new Set()); + + const result = await getUniqueFolderName('Photos', PARENT_UUID, checker); + + expect(checker).toHaveBeenCalledTimes(2); + expect(result).toBe(`Photos (${BATCH_SIZE + 1})`); + }); + }); + + describe('when all possible names are taken', () => { + it('then throws a descriptive error', async () => { + const checker: FolderNameChecker = jest.fn().mockImplementation(async (_, names) => new Set(names)); + + await expect(getUniqueFolderName('Photos', PARENT_UUID, checker)).rejects.toThrow( + 'No unique name found for "Photos"', + ); + }); + }); +}); diff --git a/src/services/drive/folder/utils/getUniqueFolderName.ts b/src/services/drive/folder/utils/getUniqueFolderName.ts new file mode 100644 index 000000000..fa5a5917d --- /dev/null +++ b/src/services/drive/folder/utils/getUniqueFolderName.ts @@ -0,0 +1,58 @@ +import { driveFolderService } from '../driveFolder.service'; + +const MAX_ATTEMPTS = 10000; +const BATCH_SIZE = 100; +const TRAILING_NUMERIC_SUFFIX = /\s\(\d+\)$/; + +export type FolderNameChecker = (parentFolderUuid: string, names: string[]) => Promise>; + +/** + * Strips any trailing " (n)" suffix from `name`, returning the base name. + * + * @param name - Folder name to normalize. + * @returns The base name without a numeric suffix. + */ +export const extractBaseName = (name: string): string => name.replace(TRAILING_NUMERIC_SUFFIX, ''); + +/** + * Generates `count` sequential candidates starting at `fromCounter`. + * + * @param base - Base folder name. + * @param fromCounter - Starting counter value (inclusive). + * @param count - Number of candidates to generate. + * @returns Array of candidate names in the form `" (n)"`. + */ +export const buildCandidates = (base: string, fromCounter: number, count: number): string[] => + Array.from({ length: count }, (_, i) => `${base} (${fromCounter + i})`); + +const defaultChecker: FolderNameChecker = async (parentFolderUuid, names) => { + const { existentFolders } = await driveFolderService.checkDuplicatedFolders(parentFolderUuid, names); + return new Set(existentFolders.map((file) => file.plainName ?? file.plain_name)); +}; + +/** + * Returns the first available folder name following the pattern `" (n)"`. + * + * + * @param folderName - Desired folder name, with or without a trailing `" (n)"` suffix. + * @param parentFolderUuid - UUID of the parent folder where uniqueness is checked. + * @param checkExists - Injectable checker (defaults to `driveFolderService`). Useful for testing. + * @returns The first available candidate name. + * @throws {Error} If no unique name is found within {@link MAX_ATTEMPTS} attempts. + */ +export const getUniqueFolderName = async ( + folderName: string, + parentFolderUuid: string, + checkExists: FolderNameChecker = defaultChecker, +): Promise => { + const base = extractBaseName(folderName); + + for (let startCounter = 1; startCounter <= MAX_ATTEMPTS; startCounter += BATCH_SIZE) { + const candidates = buildCandidates(base, startCounter, BATCH_SIZE); + const taken = await checkExists(parentFolderUuid, candidates); + const free = candidates.find((candidate) => !taken.has(candidate)); + if (free) return free; + } + + throw new Error(`No unique name found for "${base}" after ${MAX_ATTEMPTS} attempts`); +};