From c1c5b739c62a4dffd1ce4277c73518ad866ae04c Mon Sep 17 00:00:00 2001 From: Crash-- Date: Mon, 6 Apr 2026 16:04:46 +0700 Subject: [PATCH 01/10] fix: reducer fallback on file.name, fix extractFilesEntries, cleanup dead code - Reducer falls back to matching on file.name when fileId is absent, ensuring Flagship upload path (which dispatches without fileId) works. - extractFilesEntries now calls webkitGetAsEntry() only once per item. - Remove dead code: removeFileToUploadQueue (unused export). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/modules/upload/index.js | 13 ++----------- 1 file changed, 2 insertions(+), 11 deletions(-) diff --git a/src/modules/upload/index.js b/src/modules/upload/index.js index 4b92a507ae..b3f6cfd727 100644 --- a/src/modules/upload/index.js +++ b/src/modules/upload/index.js @@ -525,15 +525,6 @@ 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 - }) -} - export const addToUploadQueue = ( entries, @@ -631,8 +622,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, From 6e66ad7d676f5e28aa098a1905f0d8e4c6b2bbf6 Mon Sep 17 00:00:00 2001 From: Crash-- Date: Mon, 6 Apr 2026 16:09:13 +0700 Subject: [PATCH 02/10] feat: flatten folder uploads into individual file items in upload queue When a folder is dropped, each file now appears as its own item in the upload queue with its relative path (e.g. "photos/2024/img.jpg"), individual progress tracking, and per-file error handling. - Add flattenEntriesFromPaths: uses file.path from react-dropzone to detect folder structure, create server-side folders, and produce flat queue items with relativePath and folderId - Add flattenEntries: FileSystemEntry-based variant for DropzoneDnD where webkitGetAsEntry() is still valid (synchronous drop handler) - Refactor processNextFile: remove isDirectory branch, use item.folderId - Fix createFolder: handle 409 conflict (folder already exists) - Simplify Dropzone.jsx: always pass files (react-dropzone sets file.path) - UploadQueue.jsx: display relativePath instead of file.name in the queue - Remove dead code: uploadDirectory, canHandleFolders in Dropzone.jsx Co-Authored-By: Claude Opus 4.6 (1M context) --- src/modules/upload/Dropzone.jsx | 11 +- src/modules/upload/DropzoneDnD.jsx | 3 + src/modules/upload/UploadQueue.jsx | 27 ++- src/modules/upload/index.js | 264 ++++++++++++++++++++++++---- src/modules/upload/index.spec.js | 268 ++++++++++++++++++++++++++++- 5 files changed, 523 insertions(+), 50 deletions(-) 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 b3f6cfd727..020e6b64b0 100644 --- a/src/modules/upload/index.js +++ b/src/modules/upload/index.js @@ -320,32 +320,40 @@ export const processNextFile = return dispatch(onQueueEmpty(queueCompletedCallback)) } - const { file, fileId } = item + const { file, fileId, folderId } = item + const targetDirId = folderId ?? dirID const encryptionKey = flag('drive.enable-encryption') - ? await getEncryptionKeyFromDirId(client, dirID) + ? await getEncryptionKeyFromDirId(client, targetDirId) : null try { - const uploaded = await performUpload( + dispatch({ type: UPLOAD_FILE, fileId, file }) + + const uploadedFile = await uploadFile( client, - item, - dirID, - { vaultClient, encryptionKey }, - driveId, - dispatch + file, + targetDirId, + { + vaultClient, + encryptionKey, + onUploadProgress: event => { + dispatch(uploadProgress(fileId, file, event)) + } + }, + driveId ) - safeCallback(uploaded) + safeCallback(uploadedFile) dispatch({ type: RECEIVE_UPLOAD_SUCCESS, fileId, file, - uploadedItem: uploaded + uploadedItem: uploadedFile }) } catch (uploadError) { await handleUploadError(uploadError, { client, file, fileId, - dirID, + dirID: targetDirId, driveId, dispatch, safeCallback @@ -385,40 +393,169 @@ 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, - directory, - dirID, - { vaultClient, encryptionKey }, - driveId + driveId, + pathPrefix = '', + folderName = null ) => { - const newDir = await createFolder(client, directory.name, dirID, driveId) - const dirReader = directory.createReader() - const options = { vaultClient, encryptionKey } + 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 entries = await readAllEntries(dirReader) + 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 + }) + } + } - const files = await Promise.all( - entries.filter(e => e.isFile).map(e => getFileFromEntry(e)) - ) + return result +} + +/** + * 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, + driveId +) => { + const folderCache = { '': rootDirId } + + 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) + const file = entry.file + if (!file) continue + + const filePath = file.path || '' + const cleanPath = filePath.startsWith('/') ? filePath.slice(1) : filePath + + if (!cleanPath || !cleanPath.includes('/')) { + result.push({ + fileId: file.name, + file, + relativePath: null, + folderId: rootDirId, + folderName: null, + isDirectory: false, + entry: null + }) + } else { + const lastSlash = cleanPath.lastIndexOf('/') + const folderPath = cleanPath.slice(0, lastSlash) + const topFolderName = cleanPath.split('/')[0] + + const folderId = await ensureFolder(folderPath) + + result.push({ + fileId: cleanPath, + file, + relativePath: cleanPath, + folderId, + folderName: topFolderName, + isDirectory: false, + entry: null + }) } } - 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) => { @@ -537,9 +674,68 @@ export const addToUploadQueue = addItems ) => async dispatch => { + const hasFilePaths = entries.some( + e => e.file?.path && e.file.path.includes('/') + ) + const hasDirectories = entries.some(e => e.isDirectory && e.entry) + + let flatItems + if (hasFilePaths) { + try { + flatItems = await flattenEntriesFromPaths( + entries, + dirID, + client, + driveId + ) + } catch (error) { + logger.error(`Upload module: flattenEntriesFromPaths failed: ${error}`) + flatItems = entries + .filter(e => e.file) + .map(e => ({ + fileId: e.file.name, + file: e.file, + relativePath: null, + folderId: dirID, + folderName: null, + isDirectory: false, + entry: null + })) + } + } else if (hasDirectories) { + try { + flatItems = await flattenEntries(entries, dirID, client, driveId) + } catch (error) { + logger.error(`Upload module: flattenEntries failed: ${error}`) + flatItems = entries + .filter(e => !e.isDirectory && e.file) + .map(e => ({ + fileId: e.file.name, + file: e.file, + relativePath: null, + folderId: dirID, + folderName: null, + isDirectory: false, + entry: null + })) + } + } else { + flatItems = entries + .filter(e => e.file) + .map(e => ({ + fileId: e.file.name, + file: e.file, + relativePath: null, + folderId: dirID, + folderName: null, + isDirectory: false, + entry: null + })) + } + dispatch({ type: ADD_TO_UPLOAD_QUEUE, - files: entries + files: flatItems }) dispatch( processNextFile( diff --git a/src/modules/upload/index.spec.js b/src/modules/upload/index.spec.js index b4046536c9..fce2a4d77f 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' @@ -704,10 +706,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 +876,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({ From ac3f655dd9e5e92f82918ceb56b31f5781c580e7 Mon Sep 17 00:00:00 2001 From: Crash-- Date: Mon, 6 Apr 2026 16:24:29 +0700 Subject: [PATCH 03/10] feat: show upload queue immediately before folder resolution The upload queue now appears instantly when files are dropped. Server- side folder creation happens in the background, then queue items are updated with resolved folderId/relativePath via RESOLVE_FOLDER_ITEMS. Previously the queue only appeared after all folders were created, causing a noticeable delay on slow connections. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/modules/upload/index.js | 103 ++++++++++++++++++------------------ 1 file changed, 52 insertions(+), 51 deletions(-) diff --git a/src/modules/upload/index.js b/src/modules/upload/index.js index 020e6b64b0..3bafca2e0f 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' @@ -153,6 +154,13 @@ export const queue = (state = [], action) => { ] case PURGE_UPLOAD_QUEUE: return [] + case RESOLVE_FOLDER_ITEMS: { + const resolved = action.resolvedItems + return state.map(i => { + const update = resolved.find(r => r.fileId === i.fileId) + return update ? { ...i, ...update } : i + }) + } case UPLOAD_FILE: case RECEIVE_UPLOAD_SUCCESS: case RECEIVE_UPLOAD_ERROR: @@ -678,65 +686,58 @@ export const addToUploadQueue = e => e.file?.path && e.file.path.includes('/') ) const hasDirectories = entries.some(e => e.isDirectory && e.entry) - - let flatItems - if (hasFilePaths) { - try { - flatItems = await flattenEntriesFromPaths( - entries, - dirID, - client, - driveId - ) - } catch (error) { - logger.error(`Upload module: flattenEntriesFromPaths failed: ${error}`) - flatItems = entries - .filter(e => e.file) - .map(e => ({ - fileId: e.file.name, - file: e.file, - relativePath: null, - folderId: dirID, - folderName: null, - isDirectory: false, - entry: null - })) - } - } else if (hasDirectories) { - try { - flatItems = await flattenEntries(entries, dirID, client, driveId) - } catch (error) { - logger.error(`Upload module: flattenEntries failed: ${error}`) - flatItems = entries - .filter(e => !e.isDirectory && e.file) - .map(e => ({ - fileId: e.file.name, - file: e.file, - relativePath: null, - folderId: dirID, - folderName: null, - isDirectory: false, - entry: null - })) - } - } else { - flatItems = entries - .filter(e => e.file) - .map(e => ({ - fileId: e.file.name, + const needsFolderResolution = hasFilePaths || hasDirectories + + // Step 1: dispatch queue immediately so the UI shows up right away + const preliminaryItems = entries + .filter(e => e.file) + .map(e => { + 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: null, + relativePath: hasPath ? cleanPath : null, folderId: dirID, - folderName: null, + folderName: hasPath ? cleanPath.split('/')[0] : null, isDirectory: false, entry: null - })) - } + } + }) dispatch({ type: ADD_TO_UPLOAD_QUEUE, - files: flatItems + files: preliminaryItems }) + + // Step 2: resolve folders server-side, then update items with folderId + if (needsFolderResolution) { + try { + let resolvedItems + if (hasFilePaths) { + resolvedItems = await flattenEntriesFromPaths( + entries, + dirID, + client, + driveId + ) + } else { + resolvedItems = await flattenEntries(entries, dirID, client, driveId) + } + dispatch({ + type: RESOLVE_FOLDER_ITEMS, + resolvedItems + }) + } catch (error) { + logger.error(`Upload module: folder resolution failed: ${error}`) + // Items stay with folderId=dirID, files will upload to root + } + } + + // Step 3: start processing dispatch( processNextFile( fileUploadedCallback, From 59d2f768b15e718a8040c8afec868cafb84db582 Mon Sep 17 00:00:00 2001 From: Crash-- Date: Mon, 6 Apr 2026 16:36:12 +0700 Subject: [PATCH 04/10] refactor: reduce cyclomatic complexity in upload module Extract helpers to reduce cyclomatic complexity flagged by CodeScene: - buildPreliminaryItems: builds initial queue items for immediate display - resolveServerFolders: dispatches to the right flatten strategy - cleanFilePath / makeFlatItem / makeFolderItem: reduce branching in flattenEntriesFromPaths Co-Authored-By: Claude Opus 4.6 (1M context) --- src/modules/upload/index.js | 152 +++++++++++++++++++----------------- 1 file changed, 79 insertions(+), 73 deletions(-) diff --git a/src/modules/upload/index.js b/src/modules/upload/index.js index 3bafca2e0f..eb107f1250 100644 --- a/src/modules/upload/index.js +++ b/src/modules/upload/index.js @@ -476,6 +476,32 @@ export const flattenEntries = async ( 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. @@ -505,38 +531,15 @@ export const flattenEntriesFromPaths = async ( const result = [] for (const entry of entries) { - const file = entry.file - if (!file) continue - - const filePath = file.path || '' - const cleanPath = filePath.startsWith('/') ? filePath.slice(1) : filePath + if (!entry.file) continue - if (!cleanPath || !cleanPath.includes('/')) { - result.push({ - fileId: file.name, - file, - relativePath: null, - folderId: rootDirId, - folderName: null, - isDirectory: false, - entry: null - }) + const cleanPath = cleanFilePath(entry.file.path) + if (!cleanPath) { + result.push(makeFlatItem(entry.file, rootDirId)) } else { - const lastSlash = cleanPath.lastIndexOf('/') - const folderPath = cleanPath.slice(0, lastSlash) - const topFolderName = cleanPath.split('/')[0] - + const folderPath = cleanPath.slice(0, cleanPath.lastIndexOf('/')) const folderId = await ensureFolder(folderPath) - - result.push({ - fileId: cleanPath, - file, - relativePath: cleanPath, - folderId, - folderName: topFolderName, - isDirectory: false, - entry: null - }) + result.push(makeFolderItem(entry.file, cleanPath, folderId)) } } @@ -670,6 +673,42 @@ export const overwriteFile = async ( return resp.data } +/** + * Build preliminary queue items from raw entries for immediate display. + * Extracts relativePath from file.path when available. + */ +const buildPreliminaryItems = (entries, dirID) => + entries + .filter(e => e.file) + .map(e => { + 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 + } + }) + +/** + * 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) +} + export const addToUploadQueue = ( entries, @@ -682,62 +721,29 @@ export const addToUploadQueue = addItems ) => async dispatch => { - const hasFilePaths = entries.some( - e => e.file?.path && e.file.path.includes('/') - ) - const hasDirectories = entries.some(e => e.isDirectory && e.entry) - const needsFolderResolution = hasFilePaths || hasDirectories - - // Step 1: dispatch queue immediately so the UI shows up right away - const preliminaryItems = entries - .filter(e => e.file) - .map(e => { - 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 - } - }) + const needsFolderResolution = + entries.some(e => e.file?.path && e.file.path.includes('/')) || + entries.some(e => e.isDirectory && e.entry) dispatch({ type: ADD_TO_UPLOAD_QUEUE, - files: preliminaryItems + files: buildPreliminaryItems(entries, dirID) }) - // Step 2: resolve folders server-side, then update items with folderId if (needsFolderResolution) { try { - let resolvedItems - if (hasFilePaths) { - resolvedItems = await flattenEntriesFromPaths( - entries, - dirID, - client, - driveId - ) - } else { - resolvedItems = await flattenEntries(entries, dirID, client, driveId) - } - dispatch({ - type: RESOLVE_FOLDER_ITEMS, - resolvedItems - }) + const resolvedItems = await resolveServerFolders( + entries, + dirID, + client, + driveId + ) + dispatch({ type: RESOLVE_FOLDER_ITEMS, resolvedItems }) } catch (error) { logger.error(`Upload module: folder resolution failed: ${error}`) - // Items stay with folderId=dirID, files will upload to root } } - // Step 3: start processing dispatch( processNextFile( fileUploadedCallback, From ab6ea254f48d26f65f0a80893ea46c1c96a7c122 Mon Sep 17 00:00:00 2001 From: Crash-- Date: Mon, 6 Apr 2026 16:49:21 +0700 Subject: [PATCH 05/10] fix: RESOLVE_FOLDER_ITEMS appends new items for DropzoneDnD folder drops When dropping a folder via DropzoneDnD, directory entries have file=null so buildPreliminaryItems filters them out. flattenEntries then discovers the actual files inside. The RESOLVE_FOLDER_ITEMS reducer now appends items whose fileId doesn't exist in the queue yet, instead of only updating existing ones. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/modules/upload/index.js | 26 ++++++++++++++++++------- src/modules/upload/index.spec.js | 33 ++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/modules/upload/index.js b/src/modules/upload/index.js index eb107f1250..40b9cd93aa 100644 --- a/src/modules/upload/index.js +++ b/src/modules/upload/index.js @@ -145,6 +145,23 @@ 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 existingIds = new Set(state.map(i => i.fileId)) + const updated = state.map(i => { + const update = resolvedItems.find(r => r.fileId === i.fileId) + return update ? { ...i, ...update } : i + }) + const newItems = resolvedItems + .filter(r => !existingIds.has(r.fileId)) + .map(r => itemInitialState(r)) + return [...updated, ...newItems] +} + export const queue = (state = [], action) => { switch (action.type) { case ADD_TO_UPLOAD_QUEUE: @@ -154,13 +171,8 @@ export const queue = (state = [], action) => { ] case PURGE_UPLOAD_QUEUE: return [] - case RESOLVE_FOLDER_ITEMS: { - const resolved = action.resolvedItems - return state.map(i => { - const update = resolved.find(r => r.fileId === i.fileId) - return update ? { ...i, ...update } : i - }) - } + case RESOLVE_FOLDER_ITEMS: + return mergeResolvedItems(state, action.resolvedItems) case UPLOAD_FILE: case RECEIVE_UPLOAD_SUCCESS: case RECEIVE_UPLOAD_ERROR: diff --git a/src/modules/upload/index.spec.js b/src/modules/upload/index.spec.js index fce2a4d77f..87a9ae04ca 100644 --- a/src/modules/upload/index.spec.js +++ b/src/modules/upload/index.spec.js @@ -368,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' From 70fef8eec26924e7afab95edde4cf244c15ccf53 Mon Sep 17 00:00:00 2001 From: Quentin Valmori Date: Mon, 6 Apr 2026 21:46:57 +0700 Subject: [PATCH 06/10] Update src/modules/upload/index.js Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/modules/upload/index.js | 48 +++++++++++++++++++++++++++---------- 1 file changed, 35 insertions(+), 13 deletions(-) diff --git a/src/modules/upload/index.js b/src/modules/upload/index.js index 40b9cd93aa..249b0ab3c3 100644 --- a/src/modules/upload/index.js +++ b/src/modules/upload/index.js @@ -687,25 +687,47 @@ export const overwriteFile = async ( /** * Build preliminary queue items from raw entries for immediate display. - * Extracts relativePath from file.path when available. + * Extracts relativePath from file.path when available and creates + * placeholder items for dropped directories while their contents are resolved. */ const buildPreliminaryItems = (entries, dirID) => entries - .filter(e => e.file) .map(e => { - 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.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. From b725ca5c03d84ceacf937de642f5f6fb99650a76 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 6 Apr 2026 14:49:04 +0000 Subject: [PATCH 07/10] perf: pre-index resolvedItems by fileId in mergeResolvedItems for O(n) merge Agent-Logs-Url: https://github.com/linagora/twake-drive/sessions/0b905f1c-c853-4a77-9954-fb0f711b7c9d Co-authored-by: Crash-- <1107936+Crash--@users.noreply.github.com> --- src/modules/upload/index.js | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/modules/upload/index.js b/src/modules/upload/index.js index 249b0ab3c3..6df1a35fa1 100644 --- a/src/modules/upload/index.js +++ b/src/modules/upload/index.js @@ -151,9 +151,10 @@ const item = (state, action = { isUpdate: false }) => { * (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 = resolvedItems.find(r => r.fileId === i.fileId) + const update = resolvedMap.get(i.fileId) return update ? { ...i, ...update } : i }) const newItems = resolvedItems From 31b166963b1457498417ac8270ea208fba96b04f Mon Sep 17 00:00:00 2001 From: Quentin Valmori Date: Tue, 7 Apr 2026 08:22:38 +0700 Subject: [PATCH 08/10] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/modules/upload/index.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/modules/upload/index.js b/src/modules/upload/index.js index 6df1a35fa1..155cf1dbdd 100644 --- a/src/modules/upload/index.js +++ b/src/modules/upload/index.js @@ -776,6 +776,13 @@ export const addToUploadQueue = dispatch({ type: RESOLVE_FOLDER_ITEMS, resolvedItems }) } catch (error) { logger.error(`Upload module: folder resolution failed: ${error}`) + if (typeof window !== 'undefined' && typeof window.alert === 'function') { + window.alert( + 'The folder upload could not be prepared. Please try again.' + ) + } + dispatch({ type: PURGE_UPLOAD_QUEUE }) + return } } From f2132dd1b8fe4f63873a5168f06eb0bf3608dadc Mon Sep 17 00:00:00 2001 From: Crash-- Date: Tue, 7 Apr 2026 12:35:43 +0700 Subject: [PATCH 09/10] fix: resolve rebase conflicts, remove dead code, fix lint Remove unused performUpload helper after rebase. Fix Prettier formatting on ternary expressions. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/modules/upload/index.js | 49 ++++++++----------------------------- 1 file changed, 10 insertions(+), 39 deletions(-) diff --git a/src/modules/upload/index.js b/src/modules/upload/index.js index 155cf1dbdd..2e8e396812 100644 --- a/src/modules/upload/index.js +++ b/src/modules/upload/index.js @@ -248,42 +248,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 } @@ -696,7 +660,9 @@ const buildPreliminaryItems = (entries, dirID) => .map(e => { if (e.file) { const filePath = e.file.path || '' - const cleanPath = filePath.startsWith('/') ? filePath.slice(1) : filePath + const cleanPath = filePath.startsWith('/') + ? filePath.slice(1) + : filePath const hasPath = cleanPath && cleanPath.includes('/') return { fileId: hasPath ? cleanPath : e.file.name, @@ -720,7 +686,9 @@ const buildPreliminaryItems = (entries, dirID) => file: null, relativePath: cleanPath || null, folderId: dirID, - folderName: cleanPath ? cleanPath.split('/')[0] : e.entry.name || null, + folderName: cleanPath + ? cleanPath.split('/')[0] + : e.entry.name || null, isDirectory: true, entry: e.entry } @@ -776,7 +744,10 @@ export const addToUploadQueue = dispatch({ type: RESOLVE_FOLDER_ITEMS, resolvedItems }) } catch (error) { logger.error(`Upload module: folder resolution failed: ${error}`) - if (typeof window !== 'undefined' && typeof window.alert === 'function') { + if ( + typeof window !== 'undefined' && + typeof window.alert === 'function' + ) { window.alert( 'The folder upload could not be prepared. Please try again.' ) From d21f35a81e66f45f93c7a5484f4be4fd3113e26f Mon Sep 17 00:00:00 2001 From: Crash-- Date: Tue, 7 Apr 2026 12:46:35 +0700 Subject: [PATCH 10/10] refactor: extract helpers to fix CodeScene complexity in upload module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract updateQueueItem, uploadSingleFile, hasFolderEntries, notifyFolderError, and ensureCallback to reduce cyclomatic complexity and method size. Code Health: 7.7 → 9.01. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/modules/upload/index.js | 136 ++++++++++++++++++++---------------- 1 file changed, 75 insertions(+), 61 deletions(-) diff --git a/src/modules/upload/index.js b/src/modules/upload/index.js index 2e8e396812..9586f6501d 100644 --- a/src/modules/upload/index.js +++ b/src/modules/upload/index.js @@ -163,6 +163,11 @@ const mergeResolvedItems = (state, resolvedItems) => { 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: @@ -177,10 +182,8 @@ export const queue = (state = [], action) => { 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 } @@ -280,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, @@ -291,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' @@ -305,45 +351,14 @@ export const processNextFile = return dispatch(onQueueEmpty(queueCompletedCallback)) } - const { file, fileId, folderId } = item - 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 - }) - } + await uploadSingleFile(item, { + client, + vaultClient, + dirID, + driveId, + dispatch, + safeCallback + }) dispatch( processNextFile( fileUploadedCallback, @@ -712,6 +727,16 @@ const resolveServerFolders = async (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 = ( entries, @@ -724,16 +749,12 @@ export const addToUploadQueue = addItems ) => async dispatch => { - const needsFolderResolution = - entries.some(e => e.file?.path && e.file.path.includes('/')) || - entries.some(e => e.isDirectory && e.entry) - dispatch({ type: ADD_TO_UPLOAD_QUEUE, files: buildPreliminaryItems(entries, dirID) }) - if (needsFolderResolution) { + if (hasFolderEntries(entries)) { try { const resolvedItems = await resolveServerFolders( entries, @@ -744,14 +765,7 @@ export const addToUploadQueue = dispatch({ type: RESOLVE_FOLDER_ITEMS, resolvedItems }) } catch (error) { logger.error(`Upload module: folder resolution failed: ${error}`) - if ( - typeof window !== 'undefined' && - typeof window.alert === 'function' - ) { - window.alert( - 'The folder upload could not be prepared. Please try again.' - ) - } + notifyFolderError() dispatch({ type: PURGE_UPLOAD_QUEUE }) return } @@ -773,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)