diff --git a/src/application/ports/file_ports.rs b/src/application/ports/file_ports.rs index 08c97189..317b84fc 100644 --- a/src/application/ports/file_ports.rs +++ b/src/application/ports/file_ports.rs @@ -334,6 +334,16 @@ pub trait FileManagementUseCase: Send + Sync + 'static { "copy_folder_tree not implemented", )) } + + /// Copies a folder tree, enforcing that `caller_id` owns both the source folder + /// and the target parent folder. + async fn copy_folder_tree_owned( + &self, + source_folder_id: &str, + caller_id: Uuid, + target_parent_id: Option, + dest_name: Option, + ) -> Result; } /// Factory for creating file use case implementations diff --git a/src/application/services/batch_operations.rs b/src/application/services/batch_operations.rs index 901abb66..1fb58a3b 100644 --- a/src/application/services/batch_operations.rs +++ b/src/application/services/batch_operations.rs @@ -12,6 +12,7 @@ use tracing::info; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::{FolderDto, MoveFolderDto}; use crate::application::ports::file_ports::{FileManagementUseCase, FileRetrievalUseCase}; +use crate::application::ports::storage_ports::CopyFolderTreeResult; use crate::application::ports::inbound::FolderUseCase; use crate::application::ports::trash_ports::TrashUseCase; use crate::application::services::file_management_service::FileManagementService; @@ -616,6 +617,73 @@ impl BatchOperationService { Ok(result) } + /// Copies multiple folder trees to a target parent in parallel + pub async fn copy_folders( + &self, + folder_ids: Vec, + target_folder_id: Option, + user_id: Uuid, + ) -> Result, BatchOperationError> { + info!("Starting batch copy of {} folders", folder_ids.len()); + let start_time = std::time::Instant::now(); + + let mut result = BatchResult { + successful: Vec::new(), + failed: Vec::new(), + stats: BatchStats { + total: folder_ids.len(), + ..Default::default() + }, + }; + + let target: Option> = target_folder_id.map(|s| Arc::from(s.as_str())); + + let mut operation_stream = stream::iter(folder_ids.into_iter().map(|folder_id| { + let file_management = self.file_management.clone(); + let target = target.clone(); + + async move { + let copy_result = file_management + .copy_folder_tree_owned( + &folder_id, + user_id, + target.map(|s| s.to_string()), + None, + ) + .await; + (folder_id, copy_result) + } + })) + .buffer_unordered(self.config.concurrency.max_concurrent_files); + + while let Some((folder_id, operation_result)) = operation_stream.next().await { + match operation_result { + Ok(copy_result) => { + result.successful.push(copy_result); + result.stats.successful += 1; + } + Err(e) => { + result.failed.push((folder_id, e.to_string())); + result.stats.failed += 1; + } + } + } + + result.stats.execution_time_ms = start_time.elapsed().as_millis(); + result.stats.max_concurrency = self + .config + .concurrency + .max_concurrent_files + .min(result.stats.total); + + info!( + "Batch folder copy completed: {}/{} successful in {}ms", + result.stats.successful, result.stats.total, result.stats.execution_time_ms + ); + + Ok(result) + } + /// Downloads multiple files/folders as a single ZIP archive. /// /// Writes the archive to a temporary file so RAM usage is O(buffer_size) diff --git a/src/application/services/file_management_service.rs b/src/application/services/file_management_service.rs index 1c4bf638..c5ee2ba8 100644 --- a/src/application/services/file_management_service.rs +++ b/src/application/services/file_management_service.rs @@ -326,4 +326,31 @@ impl FileManagementUseCase for FileManagementService { Ok(result) } + + async fn copy_folder_tree_owned( + &self, + source_folder_id: &str, + caller_id: Uuid, + target_parent_id: Option, + dest_name: Option, + ) -> Result { + if let Some(folder_repo) = &self.folder_repo { + let owner = folder_repo.get_folder_user_id(source_folder_id).await?; + if owner != caller_id { + return Err(DomainError::not_found( + "Folder", + "Source folder not found or access denied", + )); + } + } else { + return Err(DomainError::internal_error( + "FileManagement", + "Folder ownership verification unavailable", + )); + } + self.verify_target_folder_owner(&target_parent_id, caller_id) + .await?; + self.copy_folder_tree(source_folder_id, target_parent_id, dest_name) + .await + } } diff --git a/src/common/stubs.rs b/src/common/stubs.rs index 156563a5..01328051 100644 --- a/src/common/stubs.rs +++ b/src/common/stubs.rs @@ -671,6 +671,19 @@ impl FileManagementUseCase for StubFileManagementUseCase { ) -> Result { Ok(FileDto::default()) } + + async fn copy_folder_tree_owned( + &self, + _source_folder_id: &str, + _caller_id: Uuid, + _target_parent_id: Option, + _dest_name: Option, + ) -> Result { + Err(DomainError::internal_error( + "StubFileManagement", + "copy_folder_tree_owned not implemented", + )) + } } // --------------------------------------------------------------------------- diff --git a/src/interfaces/api/handlers/batch_handler.rs b/src/interfaces/api/handlers/batch_handler.rs index 0249a3e8..8b650f0e 100644 --- a/src/interfaces/api/handlers/batch_handler.rs +++ b/src/interfaces/api/handlers/batch_handler.rs @@ -9,6 +9,7 @@ use utoipa::ToSchema; use crate::application::dtos::file_dto::FileDto; use crate::application::dtos::folder_dto::FolderDto; +use crate::application::ports::storage_ports::CopyFolderTreeResult; use crate::application::services::batch_operations::{ BatchOperationService, BatchResult, BatchStats, }; @@ -851,6 +852,90 @@ pub async fn move_folders_batch( Ok((status_code, Json(response)).into_response()) } +/// DTO returned for each successfully copied folder tree +#[derive(Debug, Serialize, ToSchema)] +pub struct CopiedFolderDto { + /// UUID of the newly created root folder + pub new_root_folder_id: String, + /// Total folders created (including root) + pub folders_copied: i64, + /// Total files copied (zero-copy via dedup) + pub files_copied: i64, +} + +impl From for CopiedFolderDto { + fn from(r: CopyFolderTreeResult) -> Self { + Self { + new_root_folder_id: r.new_root_folder_id, + folders_copied: r.folders_copied, + files_copied: r.files_copied, + } + } +} + +/// Handler for copying multiple folder trees in batch +#[utoipa::path( + post, + path = "/api/batch/folders/copy", + responses( + (status = 200, description = "All folders copied"), + (status = 206, description = "Partial success"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized") + ), + tag = "batch" +)] +pub async fn copy_folders_batch( + State(state): State, + auth_user: AuthUser, + Json(request): Json, +) -> ApiResult { + if request.folder_ids.is_empty() { + return Ok(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": "No folder IDs provided" + })), + ) + .into_response()); + } + if request.folder_ids.len() > MAX_BATCH_SIZE { + return Ok(( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({ + "error": format!("Batch size {} exceeds maximum of {}", request.folder_ids.len(), MAX_BATCH_SIZE) + })), + ) + .into_response()); + } + + let result = state + .batch_service + .copy_folders(request.folder_ids, request.target_folder_id, auth_user.id) + .await + .map_err(|e| { + tracing::error!("Batch copy_folders failed: {}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "Batch operation failed".to_string(), + ) + })?; + + let response: BatchOperationResponse = result.into(); + + let status_code = if response.stats.failed > 0 { + if response.stats.successful > 0 { + StatusCode::PARTIAL_CONTENT + } else { + StatusCode::BAD_REQUEST + } + } else { + StatusCode::OK + }; + + Ok((status_code, Json(response)).into_response()) +} + // Hander as a workarround for drag & drop (does not support POST requests) #[utoipa::path( get, diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index 17de42e3..bdd28464 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -237,6 +237,7 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { .route("/folders/delete", post(batch_handler::delete_folders_batch)) .route("/folders/create", post(batch_handler::create_folders_batch)) .route("/folders/get", post(batch_handler::get_folders_batch)) + .route("/folders/copy", post(batch_handler::copy_folders_batch)) .route("/folders/move", post(batch_handler::move_folders_batch)) // Trash operations (soft delete) .route("/trash", post(batch_handler::trash_batch)) diff --git a/static/js/features/files/fileOperations.js b/static/js/features/files/fileOperations.js index 78af5c10..348936b4 100644 --- a/static/js/features/files/fileOperations.js +++ b/static/js/features/files/fileOperations.js @@ -928,23 +928,31 @@ const fileOps = { /** * Copy a folder to another folder - * Note: Backend folder copy is not yet implemented, this shows a notification - * @param {string} _folderId - Folder ID - * @param {string} _targetFolderId - Target folder ID + * @param {string} folderId - Folder ID + * @param {string} targetFolderId - Target folder ID * @returns {Promise} - Success status */ - async copyFolder(_folderId, _targetFolderId) { - // Folder copy is not yet implemented in the backend - ui.showNotification('Not implemented', 'Folder copy is not yet supported'); - return false; + async copyFolder(folderId, targetFolderId) { + const res = await fetch('/api/batch/folders/copy', { + method: 'POST', + headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder_ids: [folderId], target_folder_id: targetFolderId }) + }); + return res.ok; }, + /** + * @typedef {Object} BatchCopyReturn + * @property {number} success + * @property {number} errors + */ + /** * Copy files & folders * @param {string[]} fileIds - File IDs * @param {string[]} folderIds - Folder IDs * @param {string} targetFolderId - Target folder ID - * @returns {Promise} - Success status + * @returns {Promise} - Success status */ async batchCopy(fileIds, folderIds, targetFolderId) { // FIXME ensure not moving a folder into itself @@ -964,13 +972,22 @@ const fileOps = { }); const data = await res.json(); success += data.stats?.successful || 0; - errors += data.stats?.failed || 0; + errors += data.stats?.failed || (!res.ok && !data.stats ? fileIds.length : 0); } - // Note: Folder copy is not yet implemented in batch API + // Batch copy folders if (folderIds.length > 0) { - ui.showNotification('Info', 'Folder copy is not yet supported in batch mode'); - errors += folderIds.lenngth; + const res = await fetch('/api/batch/folders/copy', { + method: 'POST', + headers: { ...getAuthHeaders(), 'Content-Type': 'application/json' }, + body: JSON.stringify({ + folder_ids: folderIds, + target_folder_id: targetFolderId + }) + }); + const data = await res.json(); + success += data.stats?.successful || 0; + errors += data.stats?.failed || (!res.ok && !data.stats ? folderIds.length : 0); } } catch (err) { console.error('Batch copy error:', err);