Merge pull request #329 from EdouardVanbelle/feat/drop-to-sytem

This commit is contained in:
Dionisio Pozo
2026-05-04 22:45:19 +02:00
committed by GitHub
6 changed files with 108 additions and 5 deletions
+14
View File
@@ -0,0 +1,14 @@
use serde::{Deserialize, Deserializer};
// deserialize comma separated string into into Vec<String>
pub fn deserialize_csv<'de, D>(deserializer: D) -> Result<Vec<String>, 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())
}
+52 -2
View File
@@ -1,5 +1,5 @@
use axum::{ use axum::{
extract::{Json, State}, extract::{Json, Query, State},
http::StatusCode, http::StatusCode,
response::{IntoResponse, Response}, response::{IntoResponse, Response},
}; };
@@ -12,6 +12,7 @@ use crate::application::dtos::folder_dto::FolderDto;
use crate::application::services::batch_operations::{ use crate::application::services::batch_operations::{
BatchOperationService, BatchResult, BatchStats, BatchOperationService, BatchResult, BatchStats,
}; };
use crate::interfaces::api::deserializer;
use crate::interfaces::api::handlers::ApiResult; use crate::interfaces::api::handlers::ApiResult;
use crate::interfaces::middleware::auth::AuthUser; use crate::interfaces::middleware::auth::AuthUser;
@@ -646,6 +647,24 @@ pub struct BatchDownloadRequest {
pub folder_ids: Vec<String>, pub folder_ids: Vec<String>,
} }
#[derive(Debug, Deserialize)]
pub struct BatchDownloadQuery {
#[serde(default, deserialize_with = "deserializer::deserialize_csv")]
pub file_ids: Vec<String>, // will deserialize query string "1,2,3" into Vec<String>
#[serde(default, deserialize_with = "deserializer::deserialize_csv")]
pub folder_ids: Vec<String>, // will deserialize query string "1,2,3" into Vec<String>
}
// convert BatchDownloadQuery into BatchDownloadRequest
impl From<BatchDownloadQuery> 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 /// Handler for moving multiple files and folders to trash in batch
#[utoipa::path( #[utoipa::path(
post, post,
@@ -832,6 +851,29 @@ pub async fn move_folders_batch(
Ok((status_code, Json(response)).into_response()) Ok((status_code, Json(response)).into_response())
} }
// Hander as a workarround for drag & drop (does not support POST requests)
#[utoipa::path(
get,
path = "/api/batch/download",
params(
("file_ids" = Option<String>, Query, description = "Comma-separated file IDs"),
("folder_ids" = Option<String>, 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<BatchHandlerState>,
auth_user: AuthUser,
Query(params): Query<BatchDownloadQuery>,
) -> Result<Response, (StatusCode, String)> {
process_download_batch(state, auth_user, params.into()).await
}
/// Handler for downloading multiple files and folders as a single ZIP. /// Handler for downloading multiple files and folders as a single ZIP.
/// ///
/// The ZIP is written to a temporary file and streamed to the client, /// The ZIP is written to a temporary file and streamed to the client,
@@ -847,10 +889,18 @@ pub async fn move_folders_batch(
), ),
tag = "batch" tag = "batch"
)] )]
pub async fn download_batch( pub async fn download_batch_post(
State(state): State<BatchHandlerState>, State(state): State<BatchHandlerState>,
auth_user: AuthUser, auth_user: AuthUser,
Json(request): Json<BatchDownloadRequest>, Json(request): Json<BatchDownloadRequest>,
) -> Result<Response, (StatusCode, String)> {
process_download_batch(state, auth_user, request).await
}
async fn process_download_batch(
state: BatchHandlerState,
auth_user: AuthUser,
request: BatchDownloadRequest,
) -> Result<Response, (StatusCode, String)> { ) -> Result<Response, (StatusCode, String)> {
if request.file_ids.is_empty() && request.folder_ids.is_empty() { if request.file_ids.is_empty() && request.folder_ids.is_empty() {
return Err(( return Err((
+3 -1
View File
@@ -1,4 +1,5 @@
pub mod cookie_auth; pub mod cookie_auth;
pub mod deserializer;
pub mod handlers; pub mod handlers;
pub mod routes; pub mod routes;
@@ -130,7 +131,8 @@ use crate::interfaces::api::handlers::file_handler::MoveFilePayload;
handlers::batch_handler::get_folders_batch, handlers::batch_handler::get_folders_batch,
handlers::batch_handler::move_folders_batch, handlers::batch_handler::move_folders_batch,
handlers::batch_handler::trash_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) // Music/playlist handlers (free functions)
handlers::music_handler::create_playlist, handlers::music_handler::create_playlist,
handlers::music_handler::list_playlists, handlers::music_handler::list_playlists,
+3 -1
View File
@@ -203,7 +203,9 @@ pub fn create_api_routes(app_state: &Arc<AppState>) -> Router<Arc<AppState>> {
// Trash operations (soft delete) // Trash operations (soft delete)
.route("/trash", post(batch_handler::trash_batch)) .route("/trash", post(batch_handler::trash_batch))
// Download as ZIP // 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); .with_state(batch_handler_state);
// Create search routes if the service is available // Create search routes if the service is available
+35
View File
@@ -1081,6 +1081,41 @@ const ui = {
lastItemDiv = div; 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 more than 1 item, display the badge
if (selectedCardFromList.length > 1) { if (selectedCardFromList.length > 1) {
const badge = document.createElement('span'); const badge = document.createElement('span');
+1 -1
View File
@@ -1,6 +1,6 @@
// OxiCloud Service Worker // OxiCloud Service Worker
// FIXME: generate cache name according build ? // 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. // Only cache static assets — NOT HTML files.
// HTML files are served network-first so browsers always get the latest // HTML files are served network-first so browsers always get the latest