diff --git a/src/modules/upload/Dropzone.jsx b/src/modules/upload/Dropzone.jsx index 9f8e8cb081..8893fb1174 100644 --- a/src/modules/upload/Dropzone.jsx +++ b/src/modules/upload/Dropzone.jsx @@ -18,13 +18,6 @@ import { uploadFiles } from '@/modules/navigation/duck' import DropzoneTeaser from '@/modules/upload/DropzoneTeaser' import { useNewItemHighlightContext } from '@/modules/upload/NewItemHighlightProvider' -// DnD helpers for folder upload -const canHandleFolders = evt => { - if (!evt.dataTransfer) return false - const dt = evt.dataTransfer - return dt.items && dt.items.length && dt.items[0].webkitGetAsEntry != null -} - const canDrop = evt => { const items = evt.dataTransfer.items for (let i = 0; i < items.length; i += 1) { @@ -55,7 +48,9 @@ export const Dropzone = ({ const onDrop = async (files, _, evt) => { if (!canDrop(evt)) return - const filesToUpload = canHandleFolders(evt) ? evt.dataTransfer.items : files + // react-dropzone sets file.path on each File with the relative path. + // addToUploadQueue uses file.path to detect and handle folder uploads. + const filesToUpload = files dispatch( uploadFiles( filesToUpload, diff --git a/src/modules/upload/DropzoneDnD.jsx b/src/modules/upload/DropzoneDnD.jsx index b565da6660..12c166098c 100644 --- a/src/modules/upload/DropzoneDnD.jsx +++ b/src/modules/upload/DropzoneDnD.jsx @@ -59,6 +59,9 @@ export const Dropzone = ({ canDrop: item => !disabled && canDropHelper(item), drop(item) { if (disabled) return + // react-dnd calls drop() synchronously during the native drop event, + // so DataTransferItemList and webkitGetAsEntry() are still valid here + // (unlike react-dropzone which processes files asynchronously). const filesToUpload = canHandleFolders(item) ? item.dataTransfer.items : item.files diff --git a/src/modules/upload/UploadQueue.jsx b/src/modules/upload/UploadQueue.jsx index 6c13cb2d8b..312ddf183e 100644 --- a/src/modules/upload/UploadQueue.jsx +++ b/src/modules/upload/UploadQueue.jsx @@ -40,11 +40,28 @@ export const DumbUploadQueue = translate()(props => { ) }) -const mapStateToProps = state => ({ - queue: getUploadQueue(state), - doneCount: getProcessed(state).length, - successCount: getSuccessful(state).length -}) +const mapStateToProps = state => { + const rawQueue = getUploadQueue(state) + + // Replace file.name with relativePath for display when available + const queue = rawQueue.map(item => { + if (!item.relativePath) return item + return { + ...item, + file: { + name: item.relativePath, + type: item.file?.type, + size: item.file?.size + } + } + }) + + return { + queue, + doneCount: getProcessed(state).length, + successCount: getSuccessful(state).length + } +} const mapDispatchToProps = dispatch => ({ purgeQueue: () => dispatch(purgeUploadQueue()) }) diff --git a/src/modules/upload/index.js b/src/modules/upload/index.js index 4b92a507ae..9586f6501d 100644 --- a/src/modules/upload/index.js +++ b/src/modules/upload/index.js @@ -15,6 +15,7 @@ import { CozyFile } from '@/models' const SLUG = 'upload' export const ADD_TO_UPLOAD_QUEUE = 'ADD_TO_UPLOAD_QUEUE' +const RESOLVE_FOLDER_ITEMS = 'RESOLVE_FOLDER_ITEMS' const UPLOAD_FILE = 'UPLOAD_FILE' const UPLOAD_PROGRESS = 'UPLOAD_PROGRESS' export const RECEIVE_UPLOAD_SUCCESS = 'RECEIVE_UPLOAD_SUCCESS' @@ -144,6 +145,29 @@ const item = (state, action = { isUpdate: false }) => { } } +/** + * Merge resolved folder items into the queue: update existing items + * by fileId, and append new ones discovered by flattenEntries + * (DropzoneDnD drops where directory entries have file=null). + */ +const mergeResolvedItems = (state, resolvedItems) => { + const resolvedMap = new Map(resolvedItems.map(r => [r.fileId, r])) + const existingIds = new Set(state.map(i => i.fileId)) + const updated = state.map(i => { + const update = resolvedMap.get(i.fileId) + return update ? { ...i, ...update } : i + }) + const newItems = resolvedItems + .filter(r => !existingIds.has(r.fileId)) + .map(r => itemInitialState(r)) + return [...updated, ...newItems] +} + +const updateQueueItem = (state, action) => { + const matchId = action.fileId ?? action.file?.name + return state.map(i => (i.fileId !== matchId ? i : item(i, action))) +} + export const queue = (state = [], action) => { switch (action.type) { case ADD_TO_UPLOAD_QUEUE: @@ -153,13 +177,13 @@ export const queue = (state = [], action) => { ] case PURGE_UPLOAD_QUEUE: return [] + case RESOLVE_FOLDER_ITEMS: + return mergeResolvedItems(state, action.resolvedItems) case UPLOAD_FILE: case RECEIVE_UPLOAD_SUCCESS: case RECEIVE_UPLOAD_ERROR: - case UPLOAD_PROGRESS: { - const matchId = action.fileId ?? action.file?.name - return state.map(i => (i.fileId !== matchId ? i : item(i, action))) - } + case UPLOAD_PROGRESS: + return updateQueueItem(state, action) default: return state } @@ -227,42 +251,6 @@ const handleConflictOverwrite = async ( return uploadedFile } -const performUpload = async ( - client, - item, - dirID, - { vaultClient, encryptionKey }, - driveId, - dispatch -) => { - const { file, fileId, entry, isDirectory } = item - dispatch({ type: UPLOAD_FILE, fileId, file }) - - if (entry && isDirectory) { - return uploadDirectory( - client, - entry, - dirID, - { vaultClient, encryptionKey }, - driveId - ) - } - - return uploadFile( - client, - file, - dirID, - { - vaultClient, - encryptionKey, - onUploadProgress: event => { - dispatch(uploadProgress(fileId, file, event)) - } - }, - driveId - ) -} - const handleUploadError = async ( uploadError, { client, file, fileId, dirID, driveId, dispatch, safeCallback } @@ -295,6 +283,52 @@ const handleUploadError = async ( }) } +const ensureCallback = fn => (typeof fn === 'function' ? fn : () => {}) + +const uploadSingleFile = async ( + { file, fileId, folderId }, + { client, vaultClient, dirID, driveId, dispatch, safeCallback } +) => { + const targetDirId = folderId ?? dirID + const encryptionKey = flag('drive.enable-encryption') + ? await getEncryptionKeyFromDirId(client, targetDirId) + : null + + try { + dispatch({ type: UPLOAD_FILE, fileId, file }) + const uploadedFile = await uploadFile( + client, + file, + targetDirId, + { + vaultClient, + encryptionKey, + onUploadProgress: event => { + dispatch(uploadProgress(fileId, file, event)) + } + }, + driveId + ) + safeCallback(uploadedFile) + dispatch({ + type: RECEIVE_UPLOAD_SUCCESS, + fileId, + file, + uploadedItem: uploadedFile + }) + } catch (uploadError) { + await handleUploadError(uploadError, { + client, + file, + fileId, + dirID: targetDirId, + driveId, + dispatch, + safeCallback + }) + } +} + export const processNextFile = ( fileUploadedCallback, @@ -306,10 +340,7 @@ export const processNextFile = addItems ) => async (dispatch, getState) => { - const safeCallback = - typeof fileUploadedCallback === 'function' - ? fileUploadedCallback - : () => {} + const safeCallback = ensureCallback(fileUploadedCallback) if (!client) { throw new Error( 'Upload module needs a cozy-client instance to work. This instance should be made available by using the extraArgument function of redux-thunk' @@ -320,37 +351,14 @@ export const processNextFile = return dispatch(onQueueEmpty(queueCompletedCallback)) } - const { file, fileId } = item - const encryptionKey = flag('drive.enable-encryption') - ? await getEncryptionKeyFromDirId(client, dirID) - : null - try { - const uploaded = await performUpload( - client, - item, - dirID, - { vaultClient, encryptionKey }, - driveId, - dispatch - ) - safeCallback(uploaded) - dispatch({ - type: RECEIVE_UPLOAD_SUCCESS, - fileId, - file, - uploadedItem: uploaded - }) - } catch (uploadError) { - await handleUploadError(uploadError, { - client, - file, - fileId, - dirID, - driveId, - dispatch, - safeCallback - }) - } + await uploadSingleFile(item, { + client, + vaultClient, + dirID, + driveId, + dispatch, + safeCallback + }) dispatch( processNextFile( fileUploadedCallback, @@ -385,40 +393,172 @@ const readAllEntries = async dirReader => { return entries } -const uploadDirectory = async ( +/** + * Recursively flatten FileSystemEntry-based directory entries into + * individual file items. Used by DropzoneDnD where webkitGetAsEntry() + * is still valid (react-dnd calls drop() synchronously). + */ +export const flattenEntries = async ( + entries, + rootDirId, + client, + driveId, + pathPrefix = '', + folderName = null +) => { + const result = [] + + for (const item of entries) { + if (item.isDirectory && item.entry) { + const dirEntry = item.entry + const dirName = dirEntry.name + const newDir = await createFolder(client, dirName, rootDirId, driveId) + const topFolderName = folderName ?? dirName + const newPrefix = pathPrefix ? `${pathPrefix}/${dirName}` : dirName + + const dirReader = dirEntry.createReader() + const childEntries = await readAllEntries(dirReader) + const fileEntries = childEntries.filter(e => e.isFile) + const dirEntries = childEntries.filter(e => e.isDirectory) + + const files = await Promise.all(fileEntries.map(e => getFileFromEntry(e))) + + for (const file of files) { + const relativePath = `${newPrefix}/${file.name}` + result.push({ + fileId: relativePath, + file, + relativePath, + folderId: newDir.id, + folderName: topFolderName, + isDirectory: false, + entry: null + }) + } + + const subEntries = dirEntries.map(e => ({ + file: null, + isDirectory: true, + entry: e + })) + if (subEntries.length > 0) { + const subItems = await flattenEntries( + subEntries, + newDir.id, + client, + driveId, + newPrefix, + topFolderName + ) + result.push(...subItems) + } + } else if (!item.isDirectory) { + result.push({ + fileId: item.file.name, + file: item.file, + relativePath: null, + folderId: rootDirId, + folderName: null, + isDirectory: false, + entry: null + }) + } + } + + return result +} + +const cleanFilePath = filePath => { + if (!filePath) return null + const cleaned = filePath.startsWith('/') ? filePath.slice(1) : filePath + return cleaned && cleaned.includes('/') ? cleaned : null +} + +const makeFlatItem = (file, folderId) => ({ + fileId: file.name, + file, + relativePath: null, + folderId, + folderName: null, + isDirectory: false, + entry: null +}) + +const makeFolderItem = (file, cleanPath, folderId) => ({ + fileId: cleanPath, + file, + relativePath: cleanPath, + folderId, + folderName: cleanPath.split('/')[0], + isDirectory: false, + entry: null +}) + +/** + * Flatten file entries using file.path (set by react-dropzone/file-selector). + * Creates server-side folders based on the path structure. + * file.path looks like "/fichiers/dossier 1/103.txt". + */ +export const flattenEntriesFromPaths = async ( + entries, + rootDirId, client, - directory, - dirID, - { vaultClient, encryptionKey }, driveId ) => { - const newDir = await createFolder(client, directory.name, dirID, driveId) - const dirReader = directory.createReader() - const options = { vaultClient, encryptionKey } + const folderCache = { '': rootDirId } - const entries = await readAllEntries(dirReader) - - const files = await Promise.all( - entries.filter(e => e.isFile).map(e => getFileFromEntry(e)) - ) + const ensureFolder = async folderPath => { + if (folderCache[folderPath] != null) { + return folderCache[folderPath] + } + const lastSlash = folderPath.lastIndexOf('/') + const parentPath = lastSlash > 0 ? folderPath.slice(0, lastSlash) : '' + const name = lastSlash > 0 ? folderPath.slice(lastSlash + 1) : folderPath + const parentId = await ensureFolder(parentPath) + + const folder = await createFolder(client, name, parentId, driveId) + folderCache[folderPath] = folder.id + return folder.id + } - let fileIndex = 0 + const result = [] for (const entry of entries) { - if (entry.isFile) { - await uploadFile(client, files[fileIndex++], newDir.id, options, driveId) - } else if (entry.isDirectory) { - await uploadDirectory(client, entry, newDir.id, options, driveId) + if (!entry.file) continue + + const cleanPath = cleanFilePath(entry.file.path) + if (!cleanPath) { + result.push(makeFlatItem(entry.file, rootDirId)) + } else { + const folderPath = cleanPath.slice(0, cleanPath.lastIndexOf('/')) + const folderId = await ensureFolder(folderPath) + result.push(makeFolderItem(entry.file, cleanPath, folderId)) } } - return newDir + return result } const createFolder = async (client, name, dirID, driveId) => { - const resp = await client - .collection(DOCTYPE_FILES, { driveId }) - .createDirectory({ name, dirId: dirID }) - return resp.data + try { + const resp = await client + .collection(DOCTYPE_FILES, { driveId }) + .createDirectory({ name, dirId: dirID }) + return resp.data + } catch (error) { + if (error.status === 409) { + const parentResp = await client + .collection(DOCTYPE_FILES, { driveId }) + .statById(dirID) + const parentPath = + parentResp.data.path || parentResp.data.attributes?.path + const folderPath = `${parentPath}/${name}` + const existingResp = await client + .collection(DOCTYPE_FILES, { driveId }) + .statByPath(folderPath) + return existingResp.data + } + throw error + } } const uploadFile = async (client, file, dirID, options = {}, driveId) => { @@ -525,13 +665,76 @@ export const overwriteFile = async ( return resp.data } -export const removeFileToUploadQueue = (file, fileId) => async dispatch => { - dispatch({ - type: RECEIVE_UPLOAD_SUCCESS, - fileId: fileId ?? file.name, - file, - isUpdate: true - }) +/** + * Build preliminary queue items from raw entries for immediate display. + * Extracts relativePath from file.path when available and creates + * placeholder items for dropped directories while their contents are resolved. + */ +const buildPreliminaryItems = (entries, dirID) => + entries + .map(e => { + if (e.file) { + const filePath = e.file.path || '' + const cleanPath = filePath.startsWith('/') + ? filePath.slice(1) + : filePath + const hasPath = cleanPath && cleanPath.includes('/') + return { + fileId: hasPath ? cleanPath : e.file.name, + file: e.file, + relativePath: hasPath ? cleanPath : null, + folderId: dirID, + folderName: hasPath ? cleanPath.split('/')[0] : null, + isDirectory: false, + entry: null + } + } + + if (e.entry?.isDirectory) { + const entryPath = e.entry.fullPath || e.entry.name || '' + const cleanPath = entryPath.startsWith('/') + ? entryPath.slice(1) + : entryPath + + return { + fileId: cleanPath || e.entry.name, + file: null, + relativePath: cleanPath || null, + folderId: dirID, + folderName: cleanPath + ? cleanPath.split('/')[0] + : e.entry.name || null, + isDirectory: true, + entry: e.entry + } + } + + return null + }) + .filter(Boolean) + +/** + * Resolve folder structure server-side and return updated queue items. + * Uses file.path (react-dropzone) or FileSystemEntry (react-dnd). + */ +const resolveServerFolders = async (entries, dirID, client, driveId) => { + const hasFilePaths = entries.some( + e => e.file?.path && e.file.path.includes('/') + ) + if (hasFilePaths) { + return flattenEntriesFromPaths(entries, dirID, client, driveId) + } + return flattenEntries(entries, dirID, client, driveId) +} + +const hasFolderEntries = entries => + entries.some(e => e.file?.path && e.file.path.includes('/')) || + entries.some(e => e.isDirectory && e.entry) + +const notifyFolderError = () => { + if (typeof window !== 'undefined' && typeof window.alert === 'function') { + window.alert('The folder upload could not be prepared. Please try again.') + } } export const addToUploadQueue = @@ -548,8 +751,26 @@ export const addToUploadQueue = async dispatch => { dispatch({ type: ADD_TO_UPLOAD_QUEUE, - files: entries + files: buildPreliminaryItems(entries, dirID) }) + + if (hasFolderEntries(entries)) { + try { + const resolvedItems = await resolveServerFolders( + entries, + dirID, + client, + driveId + ) + dispatch({ type: RESOLVE_FOLDER_ITEMS, resolvedItems }) + } catch (error) { + logger.error(`Upload module: folder resolution failed: ${error}`) + notifyFolderError() + dispatch({ type: PURGE_UPLOAD_QUEUE }) + return + } + } + dispatch( processNextFile( fileUploadedCallback, @@ -566,7 +787,7 @@ export const addToUploadQueue = export const purgeUploadQueue = () => ({ type: PURGE_UPLOAD_QUEUE }) export const onQueueEmpty = callback => (dispatch, getState) => { - const safeCallback = typeof callback === 'function' ? callback : () => {} + const safeCallback = ensureCallback(callback) const queue = getUploadQueue(getState()) const quotas = getQuotaErrors(queue) const conflicts = getConflicts(queue) @@ -631,8 +852,8 @@ export const extractFilesEntries = items => { let results = [] for (let i = 0; i < items.length; i += 1) { const item = items[i] - if (item.webkitGetAsEntry != null && item.webkitGetAsEntry()) { - const entry = item.webkitGetAsEntry() + const entry = item.webkitGetAsEntry?.() + if (entry) { results.push({ file: item.getAsFile(), fileId: entry.fullPath, diff --git a/src/modules/upload/index.spec.js b/src/modules/upload/index.spec.js index b4046536c9..87a9ae04ca 100644 --- a/src/modules/upload/index.spec.js +++ b/src/modules/upload/index.spec.js @@ -5,7 +5,9 @@ import { overwriteFile, uploadProgress, extractFilesEntries, - exceedsFileLimit + exceedsFileLimit, + flattenEntries, + flattenEntriesFromPaths } from './index' import { getEncryptionKeyFromDirId } from '@/lib/encryption' @@ -366,6 +368,39 @@ describe('queue reducer', () => { expect(result).toEqual([]) }) + it('should update existing items and append new ones on RESOLVE_FOLDER_ITEMS', () => { + const initialState = [ + { + fileId: 'report.pdf', + status: 'pending', + file: { name: 'report.pdf' }, + progress: null + } + ] + const action = { + type: 'RESOLVE_FOLDER_ITEMS', + resolvedItems: [ + { + fileId: 'report.pdf', + file: { name: 'report.pdf' }, + folderId: 'resolved-dir', + relativePath: null + }, + { + fileId: 'photos/img.jpg', + file: { name: 'img.jpg' }, + folderId: 'photos-dir', + relativePath: 'photos/img.jpg' + } + ] + } + const result = queue(initialState, action) + expect(result).toHaveLength(2) + expect(result[0].folderId).toBe('resolved-dir') + expect(result[1].fileId).toBe('photos/img.jpg') + expect(result[1].status).toBe('pending') + }) + it('should handle PURGE_UPLOAD_QUEUE action type', () => { const action = { type: 'PURGE_UPLOAD_QUEUE' @@ -704,10 +739,11 @@ describe('queue reducer', () => { }) // Helpers to mock browser FileSystem API objects -const createMockFileEntry = name => ({ +const createMockFileEntry = (name, content = '') => ({ isFile: true, isDirectory: false, - name + name, + file: resolve => resolve(new File([content], name)) }) const createMockDirEntry = (name, children) => ({ @@ -873,6 +909,265 @@ describe('exceedsFileLimit', () => { }) }) +describe('flattenEntries (FileSystemEntry-based)', () => { + const createDirSpy = jest.fn().mockName('createDirectory') + const flattenClient = { + collection: () => ({ + createDirectory: createDirSpy + }) + } + + beforeEach(() => { + createDirSpy.mockReset() + createDirSpy.mockImplementation(({ name }) => ({ + data: { id: `dir-id-${name}`, name } + })) + }) + + it('should flatten a single-level directory', async () => { + const dirEntry = createMockDirEntry('photos', [ + createMockFileEntry('img1.jpg'), + createMockFileEntry('img2.jpg') + ]) + const entries = [{ file: null, isDirectory: true, entry: dirEntry }] + + const result = await flattenEntries( + entries, + 'root-dir', + flattenClient, + null + ) + + expect(createDirSpy).toHaveBeenCalledWith({ + name: 'photos', + dirId: 'root-dir' + }) + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject({ + fileId: 'photos/img1.jpg', + relativePath: 'photos/img1.jpg', + folderId: 'dir-id-photos', + folderName: 'photos' + }) + }) + + it('should flatten nested directories', async () => { + const subDir = createMockDirEntry('2024', [createMockFileEntry('ski.jpg')]) + const topDir = createMockDirEntry('photos', [ + createMockFileEntry('beach.jpg'), + subDir + ]) + const entries = [{ file: null, isDirectory: true, entry: topDir }] + + const result = await flattenEntries( + entries, + 'root-dir', + flattenClient, + null + ) + + expect(result).toHaveLength(2) + expect(result[0]).toMatchObject({ + fileId: 'photos/beach.jpg', + relativePath: 'photos/beach.jpg', + folderId: 'dir-id-photos', + folderName: 'photos' + }) + expect(result[1]).toMatchObject({ + fileId: 'photos/2024/ski.jpg', + relativePath: 'photos/2024/ski.jpg', + folderId: 'dir-id-2024', + folderName: 'photos' + }) + }) + + it('should handle empty directories', async () => { + const emptyDir = createMockDirEntry('empty', []) + const entries = [{ file: null, isDirectory: true, entry: emptyDir }] + + const result = await flattenEntries( + entries, + 'root-dir', + flattenClient, + null + ) + + expect(createDirSpy).toHaveBeenCalledWith({ + name: 'empty', + dirId: 'root-dir' + }) + expect(result).toHaveLength(0) + }) +}) + +describe('flattenEntriesFromPaths (file.path-based)', () => { + const createDirSpy = jest.fn().mockName('createDirectory') + const pathClient = { + collection: () => ({ + createDirectory: createDirSpy, + statById: jest.fn().mockResolvedValue({ data: { path: '/root' } }), + statByPath: jest.fn().mockImplementation(path => { + const name = path.split('/').pop() + return Promise.resolve({ data: { id: `existing-${name}` } }) + }) + }) + } + + beforeEach(() => { + createDirSpy.mockReset() + createDirSpy.mockImplementation(({ name }) => ({ + data: { id: `dir-id-${name}`, name } + })) + }) + + it('should pass through flat files without creating folders', async () => { + const file = new File(['a'], 'report.pdf') + const entries = [{ file, isDirectory: false, entry: null }] + + const result = await flattenEntriesFromPaths( + entries, + 'root-dir', + pathClient, + null + ) + + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ + fileId: 'report.pdf', + relativePath: null, + folderId: 'root-dir', + folderName: null + }) + expect(createDirSpy).not.toHaveBeenCalled() + }) + + it('should create folders from file.path and set relativePath', async () => { + const file1 = new File(['a'], '10.txt') + Object.defineProperty(file1, 'path', { value: '/fichiers/10.txt' }) + const file2 = new File(['b'], '103.txt') + Object.defineProperty(file2, 'path', { + value: '/fichiers/dossier 1/103.txt' + }) + const file3 = new File(['c'], '88.txt') + Object.defineProperty(file3, 'path', { + value: '/fichiers/dossier 1/sous dossier 1/88.txt' + }) + + const entries = [ + { file: file1, isDirectory: false, entry: null }, + { file: file2, isDirectory: false, entry: null }, + { file: file3, isDirectory: false, entry: null } + ] + + const result = await flattenEntriesFromPaths( + entries, + 'root-dir', + pathClient, + null + ) + + expect(createDirSpy).toHaveBeenCalledTimes(3) + expect(createDirSpy).toHaveBeenCalledWith({ + name: 'fichiers', + dirId: 'root-dir' + }) + expect(createDirSpy).toHaveBeenCalledWith({ + name: 'dossier 1', + dirId: 'dir-id-fichiers' + }) + expect(createDirSpy).toHaveBeenCalledWith({ + name: 'sous dossier 1', + dirId: 'dir-id-dossier 1' + }) + + expect(result).toHaveLength(3) + expect(result[0]).toMatchObject({ + fileId: 'fichiers/10.txt', + relativePath: 'fichiers/10.txt', + folderId: 'dir-id-fichiers', + folderName: 'fichiers' + }) + expect(result[1]).toMatchObject({ + fileId: 'fichiers/dossier 1/103.txt', + relativePath: 'fichiers/dossier 1/103.txt', + folderId: 'dir-id-dossier 1', + folderName: 'fichiers' + }) + expect(result[2]).toMatchObject({ + fileId: 'fichiers/dossier 1/sous dossier 1/88.txt', + relativePath: 'fichiers/dossier 1/sous dossier 1/88.txt', + folderId: 'dir-id-sous dossier 1', + folderName: 'fichiers' + }) + }) + + it('should not create the same folder twice', async () => { + const file1 = new File(['a'], 'a.txt') + Object.defineProperty(file1, 'path', { value: '/photos/a.txt' }) + const file2 = new File(['b'], 'b.txt') + Object.defineProperty(file2, 'path', { value: '/photos/b.txt' }) + + const entries = [ + { file: file1, isDirectory: false, entry: null }, + { file: file2, isDirectory: false, entry: null } + ] + + const result = await flattenEntriesFromPaths( + entries, + 'root-dir', + pathClient, + null + ) + + expect(createDirSpy).toHaveBeenCalledTimes(1) + expect(result).toHaveLength(2) + expect(result[0].folderId).toBe('dir-id-photos') + expect(result[1].folderId).toBe('dir-id-photos') + }) + + it('should handle 409 conflict when folder already exists', async () => { + const conflictError = new Error('Conflict') + conflictError.status = 409 + createDirSpy.mockRejectedValueOnce(conflictError) + + const file = new File(['a'], 'doc.txt') + Object.defineProperty(file, 'path', { + value: '/existing-folder/doc.txt' + }) + + const entries = [{ file, isDirectory: false, entry: null }] + const result = await flattenEntriesFromPaths( + entries, + 'root-dir', + pathClient, + null + ) + + expect(result).toHaveLength(1) + expect(result[0].folderId).toBe('existing-existing-folder') + }) + + it('should handle file.path without leading slash', async () => { + const file = new File(['a'], 'img.jpg') + Object.defineProperty(file, 'path', { value: 'photos/img.jpg' }) + + const entries = [{ file, isDirectory: false, entry: null }] + const result = await flattenEntriesFromPaths( + entries, + 'root-dir', + pathClient, + null + ) + + expect(result).toHaveLength(1) + expect(result[0]).toMatchObject({ + fileId: 'photos/img.jpg', + relativePath: 'photos/img.jpg', + folderId: 'dir-id-photos' + }) + }) +}) + describe('overwriteFile function', () => { it('should update the io.cozy.files', async () => { updateFileSpy.mockResolvedValue({