From fbd59a1f380781f0518fe6081a7b422d82b87d72 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 4 May 2026 22:31:20 +0200 Subject: [PATCH 1/3] feat(ui): permit drag&drop to the operating system a drop outside of the browser will: - upload the file if only 1 file selected - upload a .zip of the directory or multiple selection (browsers do not permit multiple upload yet) note: I had to create a new handler because a post request to /api/batch/download is possible via JS but it will create a memory blob in the browser, during the drag action. This may exhaust the browser's memory if heavy files This will initiate zip creation from the server even if drop is canceled The best approach is to add a handler supporting GET calls, this call will be triggered by the browser on drop action outside of it's window --- src/interfaces/api/handlers/batch_handler.rs | 39 +++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/src/interfaces/api/handlers/batch_handler.rs b/src/interfaces/api/handlers/batch_handler.rs index 8edf7e81..93c3c40f 100644 --- a/src/interfaces/api/handlers/batch_handler.rs +++ b/src/interfaces/api/handlers/batch_handler.rs @@ -1,5 +1,5 @@ use axum::{ - extract::{Json, State}, + extract::{Json, Query, State}, http::StatusCode, response::{IntoResponse, Response}, }; @@ -12,6 +12,7 @@ use crate::application::dtos::folder_dto::FolderDto; use crate::application::services::batch_operations::{ BatchOperationService, BatchResult, BatchStats, }; +use crate::interfaces::api::deserializer; use crate::interfaces::api::handlers::ApiResult; use crate::interfaces::middleware::auth::AuthUser; @@ -646,6 +647,24 @@ pub struct BatchDownloadRequest { pub folder_ids: Vec, } +#[derive(Debug, Deserialize)] +pub struct BatchDownloadQuery { + #[serde(default, deserialize_with = "deserializer::deserialize_csv")] + pub file_ids: Vec, // will deserialize query string "1,2,3" into Vec + #[serde(default, deserialize_with = "deserializer::deserialize_csv")] + pub folder_ids: Vec, // will deserialize query string "1,2,3" into Vec +} + +// convert BatchDownloadQuery into BatchDownloadRequest +impl From for BatchDownloadRequest { + fn from(q: BatchDownloadQuery) -> Self { + Self { + file_ids: q.file_ids, + folder_ids: q.folder_ids, + } + } +} + /// Handler for moving multiple files and folders to trash in batch #[utoipa::path( post, @@ -832,6 +851,14 @@ pub async fn move_folders_batch( Ok((status_code, Json(response)).into_response()) } +// Hander as a workarround for drag & drop (does not support POST requests) +pub async fn download_batch_querystring( + State(state): State, + auth_user: AuthUser, + Query(params): Query, +) -> Result { + process_download_batch(state, auth_user, params.into()).await +} /// Handler for downloading multiple files and folders as a single ZIP. /// /// The ZIP is written to a temporary file and streamed to the client, @@ -847,10 +874,18 @@ pub async fn move_folders_batch( ), tag = "batch" )] -pub async fn download_batch( +pub async fn download_batch_post( State(state): State, auth_user: AuthUser, Json(request): Json, +) -> Result { + process_download_batch(state, auth_user, request).await +} + +async fn process_download_batch( + state: BatchHandlerState, + auth_user: AuthUser, + request: BatchDownloadRequest, ) -> Result { if request.file_ids.is_empty() && request.folder_ids.is_empty() { return Err(( From 05037e7491760b137a7741a2868b7b6aeb91fef9 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Tue, 28 Apr 2026 13:15:08 +0200 Subject: [PATCH 2/3] feat(ui): permit drag&drop to the operating system a drop outside of the browser will: - upload the file if only 1 file selected - upload a .zip of the directory or multiple selection (browsers do not permit multiple upload yet) note: I had to create a new handler because a post request to /api/batch/download is possible via JS but it will create a memory blob in the browser, during the drag action. This may exhaust the browser's memory if heavy files This will initiate zip creation from the server even if drop is canceled The best approach is to add a handler supporting GET calls, this call will be triggered by the browser on drop action outside of it's window --- src/interfaces/api/deserializer.rs | 14 ++++++++++++ src/interfaces/api/mod.rs | 1 + src/interfaces/api/routes.rs | 4 +++- static/js/app/ui.js | 35 ++++++++++++++++++++++++++++++ static/sw.js | 2 +- 5 files changed, 54 insertions(+), 2 deletions(-) create mode 100644 src/interfaces/api/deserializer.rs diff --git a/src/interfaces/api/deserializer.rs b/src/interfaces/api/deserializer.rs new file mode 100644 index 00000000..1e5931af --- /dev/null +++ b/src/interfaces/api/deserializer.rs @@ -0,0 +1,14 @@ +use serde::{Deserialize, Deserializer}; + +// deserialize comma separated string into into Vec +pub fn deserialize_csv<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let s = String::deserialize(deserializer).unwrap_or_default(); + Ok(s.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(String::from) + .collect()) +} diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index 28831f25..a7f331b2 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -1,4 +1,5 @@ pub mod cookie_auth; +pub mod deserializer; pub mod handlers; pub mod routes; diff --git a/src/interfaces/api/routes.rs b/src/interfaces/api/routes.rs index a7e1e996..d729b114 100644 --- a/src/interfaces/api/routes.rs +++ b/src/interfaces/api/routes.rs @@ -203,7 +203,9 @@ pub fn create_api_routes(app_state: &Arc) -> Router> { // Trash operations (soft delete) .route("/trash", post(batch_handler::trash_batch)) // Download as ZIP - .route("/download", post(batch_handler::download_batch)) + .route("/download", post(batch_handler::download_batch_post)) + // work arround for drag & drop (does not support POST requests) + .route("/download", get(batch_handler::download_batch_querystring)) .with_state(batch_handler_state); // Create search routes if the service is available diff --git a/static/js/app/ui.js b/static/js/app/ui.js index 8d844a73..3f4b86b0 100644 --- a/static/js/app/ui.js +++ b/static/js/app/ui.js @@ -1081,6 +1081,41 @@ const ui = { lastItemDiv = div; } + let downloadUrl; + let nameEncoded; + + // tells Browser URL to call to drop selection on operating system (desktop, file manager etc) + // will generate a zipfile if multiple + if (selectedCardFromList.length === 1) { + // only 1 file + if (info.type === 'file') { + nameEncoded = info.name.replaceAll(/:/g, '-'); // issue is that DownloadURL is using : as separator; + downloadUrl = `${window.location.origin}/api/files/${info.id}`; + } else { + // directory into ZIP + nameEncoded = info.name.replaceAll(/:/g, '-').concat('.zip'); + downloadUrl = `${window.location.origin}/api/folders/${info.id}/download?format=zip`; + } + } else { + // must use ZIP container + // TODO better naming like ("selection in ${parent.name}") modulo i18n ? ... + const now = new Date().toISOString().replace(/T/, ' ').replace(/\.*/, '').replaceAll(/:/g, '-'); + nameEncoded = `oxicloud ${now}.zip`; + const folders = []; + const files = []; + filesList.querySelectorAll(`div.selected`).forEach((e) => { + const item = itemInfo(e); + if (item.type === 'file') { + files.push(item.id); + } else { + folders.push(item.id); + } + }); + downloadUrl = `${window.location.origin}/api/batch/download?file_ids=${files.join(',')}&folder_ids=${folders.join(',')}`; + } + + e.dataTransfer?.setData('DownloadURL', `application/octet-stream:${nameEncoded}:${downloadUrl}`); + // if more than 1 item, display the badge if (selectedCardFromList.length > 1) { const badge = document.createElement('span'); diff --git a/static/sw.js b/static/sw.js index 4d78b74f..6bb430c5 100644 --- a/static/sw.js +++ b/static/sw.js @@ -1,6 +1,6 @@ // OxiCloud Service Worker // FIXME: generate cache name according build ? -const CACHE_NAME = 'oxicloud-cache-v20'; +const CACHE_NAME = 'oxicloud-cache-v21'; // Only cache static assets — NOT HTML files. // HTML files are served network-first so browsers always get the latest From 0cb641ab20c433c6921495d692b68dfc6859d304 Mon Sep 17 00:00:00 2001 From: Edouard Vanbelle Date: Mon, 4 May 2026 22:41:29 +0200 Subject: [PATCH 3/3] feat(openapi): add new entry used by drag & drop --- src/interfaces/api/handlers/batch_handler.rs | 15 +++++++++++++++ src/interfaces/api/mod.rs | 3 ++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/src/interfaces/api/handlers/batch_handler.rs b/src/interfaces/api/handlers/batch_handler.rs index 93c3c40f..0249a3e8 100644 --- a/src/interfaces/api/handlers/batch_handler.rs +++ b/src/interfaces/api/handlers/batch_handler.rs @@ -852,6 +852,21 @@ pub async fn move_folders_batch( } // Hander as a workarround for drag & drop (does not support POST requests) +#[utoipa::path( + get, + path = "/api/batch/download", + params( + ("file_ids" = Option, Query, description = "Comma-separated file IDs"), + ("folder_ids" = Option, Query, description = "Comma-separated folder IDs"), + ), + responses( + (status = 200, description = "ZIP archive stream"), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 500, description = "ZIP creation failed") + ), + tag = "batch" +)] pub async fn download_batch_querystring( State(state): State, auth_user: AuthUser, diff --git a/src/interfaces/api/mod.rs b/src/interfaces/api/mod.rs index a7f331b2..fd4bfbbf 100644 --- a/src/interfaces/api/mod.rs +++ b/src/interfaces/api/mod.rs @@ -131,7 +131,8 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload; handlers::batch_handler::get_folders_batch, handlers::batch_handler::move_folders_batch, handlers::batch_handler::trash_batch, - handlers::batch_handler::download_batch, + handlers::batch_handler::download_batch_post, + handlers::batch_handler::download_batch_querystring, // Music/playlist handlers (free functions) handlers::music_handler::create_playlist, handlers::music_handler::list_playlists,