diff --git a/desktop/crates/cloudreve-api/src/api/mod.rs b/desktop/crates/cloudreve-api/src/api/mod.rs index 18f4167c..1c1c51be 100644 --- a/desktop/crates/cloudreve-api/src/api/mod.rs +++ b/desktop/crates/cloudreve-api/src/api/mod.rs @@ -1,10 +1,12 @@ pub mod explorer; +pub mod share; pub mod site; pub mod user; pub mod workflow; // Re-export for convenience pub use explorer::ExplorerApi; +pub use share::ShareApi; pub use site::SiteApi; pub use user::UserApi; pub use workflow::WorkflowApi; diff --git a/desktop/crates/cloudreve-api/src/api/share.rs b/desktop/crates/cloudreve-api/src/api/share.rs new file mode 100644 index 00000000..860d4224 --- /dev/null +++ b/desktop/crates/cloudreve-api/src/api/share.rs @@ -0,0 +1,18 @@ +use crate::client::{Client, RequestOptions}; +use crate::error::ApiResult; +use crate::models::share::*; +use async_trait::async_trait; + +/// Share API methods +#[async_trait] +pub trait ShareApi { + /// Create a share link for a file or folder, returns the share URL. + async fn create_share(&self, request: &ShareCreateService) -> ApiResult; +} + +#[async_trait] +impl ShareApi for Client { + async fn create_share(&self, request: &ShareCreateService) -> ApiResult { + self.put("/share", request, RequestOptions::new()).await + } +} diff --git a/desktop/crates/cloudreve-api/src/error.rs b/desktop/crates/cloudreve-api/src/error.rs index 63c4b81e..bcd9d3fd 100644 --- a/desktop/crates/cloudreve-api/src/error.rs +++ b/desktop/crates/cloudreve-api/src/error.rs @@ -46,6 +46,7 @@ pub enum ErrorCode { IncorrectPassword = 40069, LockConflict = 40073, StaleVersion = 40076, + EntityNotExist = 40077, BatchOperationNotFullyCompleted = 40081, DomainNotLicensed = 40087, AnonymousAccessDenied = 40088, @@ -65,6 +66,7 @@ impl ErrorCode { 40069 => Some(Self::IncorrectPassword), 40073 => Some(Self::LockConflict), 40076 => Some(Self::StaleVersion), + 40077 => Some(Self::EntityNotExist), 40081 => Some(Self::BatchOperationNotFullyCompleted), 40087 => Some(Self::DomainNotLicensed), 40088 => Some(Self::AnonymousAccessDenied), @@ -198,6 +200,11 @@ impl ApiError { matches!(self, ApiError::AccessTokenExpired) } + /// Check if this error reports a missing entity (code 40077) + pub fn is_entity_not_exist(&self) -> bool { + matches!(self, ApiError::ApiError { code, .. } if *code == 40077) + } + /// Check if this error requires login pub fn requires_login(&self) -> bool { matches!( diff --git a/desktop/crates/cloudreve-api/src/models/share.rs b/desktop/crates/cloudreve-api/src/models/share.rs index c241959a..f2520d58 100644 --- a/desktop/crates/cloudreve-api/src/models/share.rs +++ b/desktop/crates/cloudreve-api/src/models/share.rs @@ -20,3 +20,10 @@ pub struct ListShareResponse { pub shares: Vec, pub pagination: PaginationResults, } + +/// Share create service. Only the target URI is required; the server applies +/// public-link defaults for all other fields. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ShareCreateService { + pub uri: String, +} diff --git a/desktop/crates/cloudreve-api/src/models/uri.rs b/desktop/crates/cloudreve-api/src/models/uri.rs index db2bca66..fdbec085 100644 --- a/desktop/crates/cloudreve-api/src/models/uri.rs +++ b/desktop/crates/cloudreve-api/src/models/uri.rs @@ -499,3 +499,29 @@ pub fn new_my_uri(uid: Option<&str>) -> Result { None => CrUri::new("cloudreve://my"), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn join_encodes_spaces_and_special_chars() { + let mut uri = CrUri::new("cloudreve://my").unwrap(); + uri.join(&["dir with spaces", "file name.txt"]); + + let s = uri.to_string(); + assert!( + s.contains("dir%20with%20spaces/file%20name.txt"), + "uri: {s}" + ); + assert!(!s.contains(' ')); + assert_eq!(uri.elements(), vec!["dir with spaces", "file name.txt"]); + } + + #[test] + fn encoded_uri_roundtrips_spaces() { + let uri = CrUri::new("cloudreve://my/a%20b/c%20d.txt").unwrap(); + assert_eq!(uri.path(), "/a b/c d.txt"); + assert!(uri.to_string().contains("a%20b/c%20d.txt")); + } +} diff --git a/desktop/crates/cloudreve-sync/Cargo.toml b/desktop/crates/cloudreve-sync/Cargo.toml index 72839b79..2442809e 100644 --- a/desktop/crates/cloudreve-sync/Cargo.toml +++ b/desktop/crates/cloudreve-sync/Cargo.toml @@ -85,6 +85,7 @@ features = [ "Win32_Graphics_Gdi", "Graphics_Imaging", "Win32_System_Com_StructuredStorage", + "Win32_System_DataExchange", "Win32_System_Variant", "Win32_Storage_EnhancedStorage", "Win32_UI_Notifications", diff --git a/desktop/crates/cloudreve-sync/src/drive/commands.rs b/desktop/crates/cloudreve-sync/src/drive/commands.rs index 9e89d804..cf9601ac 100644 --- a/desktop/crates/cloudreve-sync/src/drive/commands.rs +++ b/desktop/crates/cloudreve-sync/src/drive/commands.rs @@ -20,12 +20,13 @@ use anyhow::{Context, Result}; use bytes::Bytes; use cloudreve_api::{ ApiError, - api::{ExplorerApi, explorer::ExplorerApiExt}, + api::{ExplorerApi, ShareApi, explorer::ExplorerApiExt}, models::{ explorer::{ DeleteFileService, FileResponse, FileURLService, MoveFileService, RenameFileService, metadata, }, + share::ShareCreateService, uri::CrUri, user::Token, }, @@ -151,6 +152,10 @@ pub enum ManagerCommand { ViewOnline { path: PathBuf, }, + /// Create a public share link for a file or folder and copy it to the clipboard + CopyShareLink { + path: PathBuf, + }, PersistConfig, GenerateThumbnail { path: PathBuf, @@ -234,11 +239,21 @@ impl Mount { request.entity = Some(meta.etag.clone()); } } - let entity_url_res = self - .cr_client - .get_file_url(&request) - .await - .context("failed to get file url")?; + let entity_url_res = match self.cr_client.get_file_url(&request).await { + Err(e) if e.is_entity_not_exist() && request.entity.is_some() => { + // The entity id cached in local inventory can go stale when the + // remote file is re-uploaded or migrated; retry letting the + // server pick the primary entity. + tracing::info!(target: "drive::commands", path = %path.display(), "Preferred entity no longer exists, retrying without it"); + let mut retry = request.clone(); + retry.entity = None; + self.cr_client + .get_file_url(&retry) + .await + .context("failed to get file url")? + } + res => res.context("failed to get file url")?, + }; // Get the download URL from the response let download_url = entity_url_res @@ -432,6 +447,22 @@ impl Mount { Ok(thumb_response.bytes().await?) } + /// Create a public share link for the file or folder at `path`, + /// returning the share URL. + pub async fn create_share_link(&self, path: PathBuf) -> Result { + let (sync_path, remote_base) = { + let config = self.config.read().await; + (config.sync_path.clone(), config.remote_path.to_string()) + }; + let uri = local_path_to_cr_uri(path.clone(), sync_path, remote_base) + .context("failed to convert local path to cloudreve uri")? + .to_string(); + Ok(self + .cr_client + .create_share(&ShareCreateService { uri }) + .await?) + } + pub async fn rename_completed(&self, source: PathBuf, destination: PathBuf) -> Result<()> { // If source or destination is ignored, do nothing let matcher = self.ignore_matcher.read().await; diff --git a/desktop/crates/cloudreve-sync/src/drive/manager/command_handlers.rs b/desktop/crates/cloudreve-sync/src/drive/manager/command_handlers.rs index ab8de826..179155cd 100644 --- a/desktop/crates/cloudreve-sync/src/drive/manager/command_handlers.rs +++ b/desktop/crates/cloudreve-sync/src/drive/manager/command_handlers.rs @@ -41,6 +41,15 @@ impl DriveManager { tracing::debug!(target: "drive::manager", path = %path.display(), result = ?result, "ViewOnline command result"); }); } + ManagerCommand::CopyShareLink { path } => { + let path = path.clone(); + spawn(async move { + let result = manager.handle_copy_share_link(path.clone()).await; + if let Err(e) = result { + tracing::error!(target: "drive::manager", path = %path.display(), error = %e, "CopyShareLink command failed"); + } + }); + } ManagerCommand::PersistConfig => { let result = manager.persist().await; if let Err(e) = result { @@ -194,6 +203,36 @@ impl DriveManager { Ok(()) } + /// Handle CopyShareLink command - creates a share link for the file or + /// folder, copies it to the clipboard and notifies the user. + pub(super) async fn handle_copy_share_link(&self, path: PathBuf) -> Result<()> { + tracing::debug!(target: "drive::manager", path = %path.display(), "CopyShareLink command"); + + let mount = self + .search_drive_by_child_path(path.to_str().unwrap_or("")) + .await + .ok_or_else(|| anyhow::anyhow!("No drive found for path: {:?}", path))?; + + match mount.create_share_link(path.clone()).await { + Ok(url) => { + crate::utils::clipboard::set_text(&url)?; + crate::utils::toast::send_general_text_toast( + &rust_i18n::t!("shareLinkCopied"), + &url, + ); + } + Err(e) => { + crate::utils::toast::send_general_text_toast( + &rust_i18n::t!("shareLinkFailed"), + &e.to_string(), + ); + return Err(e); + } + } + + Ok(()) + } + /// Handle ShowConflictToast command pub(super) async fn handle_show_conflict_toast(&self, path: PathBuf) -> Result<()> { tracing::debug!(target: "drive::manager", path = %path.display(), "ShowConflictToast command"); diff --git a/desktop/crates/cloudreve-sync/src/drive/utils.rs b/desktop/crates/cloudreve-sync/src/drive/utils.rs index b0cf6fa7..787a680b 100644 --- a/desktop/crates/cloudreve-sync/src/drive/utils.rs +++ b/desktop/crates/cloudreve-sync/src/drive/utils.rs @@ -124,3 +124,29 @@ pub fn notify_shell_change(path: &PathBuf, event: SHCNE_ID) -> Result<()> { pub fn notify_shell_change(_path: &PathBuf, _event: u32) -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn local_path_to_cr_uri_encodes_spaces() { + let uri = local_path_to_cr_uri( + PathBuf::from("/sync/dir with spaces/file name.txt"), + PathBuf::from("/sync"), + "cloudreve://my".to_string(), + ) + .unwrap(); + + // The wire URI must carry percent-encoded segments, never raw spaces. + assert!( + uri.to_string() + .contains("dir%20with%20spaces/file%20name.txt"), + "uri: {}", + uri.to_string() + ); + assert!(!uri.to_string().contains(' ')); + // The decoded path round-trips to the original names. + assert_eq!(uri.path(), "/dir with spaces/file name.txt"); + } +} diff --git a/desktop/crates/cloudreve-sync/src/shellext/context_menu/mod.rs b/desktop/crates/cloudreve-sync/src/shellext/context_menu/mod.rs index e38b5231..2e263e3e 100644 --- a/desktop/crates/cloudreve-sync/src/shellext/context_menu/mod.rs +++ b/desktop/crates/cloudreve-sync/src/shellext/context_menu/mod.rs @@ -4,6 +4,7 @@ mod explorer_command; mod factory; mod resolve_conflict; +mod share_link; mod sub_commands; mod sync_now; mod view_online; @@ -11,6 +12,7 @@ mod view_online; pub use explorer_command::CrExplorerCommandHandler; pub use factory::CrExplorerCommandFactory; pub use resolve_conflict::ResolveConflictCommandHandler; +pub use share_link::ShareLinkCommandHandler; pub use sub_commands::SubCommands; pub use sync_now::SyncNowCommandHandler; pub use view_online::ViewOnlineCommandHandler; diff --git a/desktop/crates/cloudreve-sync/src/shellext/context_menu/share_link.rs b/desktop/crates/cloudreve-sync/src/shellext/context_menu/share_link.rs new file mode 100644 index 00000000..13a280b8 --- /dev/null +++ b/desktop/crates/cloudreve-sync/src/shellext/context_menu/share_link.rs @@ -0,0 +1,142 @@ +use crate::drive::manager::DriveManager; +use crate::{drive::commands::ManagerCommand, utils::app::AppRoot}; +use rust_i18n::t; +use std::path::PathBuf; +use std::sync::{Arc, Mutex}; +use windows::{ + Win32::{Foundation::*, System::Com::*, System::Ole::*, UI::Shell::*}, + core::*, +}; + +/// shlguid.h: SID_SFolderView — resolves IFolderView from the command site. +const SID_S_FOLDER_VIEW: GUID = GUID::from_u128(0xcde725b0_ccc9_4519_917e_325d72fab4ce); + +#[implement(IExplorerCommand, IObjectWithSite)] +pub struct ShareLinkCommandHandler { + drive_manager: Arc, + app_root: AppRoot, + site: Mutex>, +} + +impl ShareLinkCommandHandler { + pub fn new(drive_manager: Arc, app_root: AppRoot) -> Self { + Self { + drive_manager, + app_root, + site: Mutex::new(None), + } + } + + fn send_copy_share_link(&self, path: PathBuf) { + tracing::debug!(target: "shellext::context_menu", path = %path.display(), "Copy share link requested"); + let command_tx = self.drive_manager.get_command_sender(); + if let Err(e) = command_tx.send(ManagerCommand::CopyShareLink { path }) { + tracing::error!(target: "shellext::context_menu", error = %e, "Failed to send CopyShareLink command"); + } + } +} + +impl IObjectWithSite_Impl for ShareLinkCommandHandler_Impl { + fn SetSite(&self, punksite: Option<&IUnknown>) -> Result<()> { + *self.site.lock().unwrap() = punksite.cloned(); + Ok(()) + } + + fn GetSite(&self, riid: *const GUID, ppvsite: *mut *mut core::ffi::c_void) -> Result<()> { + let site = self.site.lock().unwrap(); + if let Some(site) = site.as_ref() { + unsafe { site.query(riid, ppvsite) }.ok() + } else { + Err(Error::from(E_FAIL)) + } + } +} + +impl IExplorerCommand_Impl for ShareLinkCommandHandler_Impl { + fn GetTitle(&self, _items: Option<&IShellItemArray>) -> Result { + let title = t!("copyShareLink"); + let hstring = HSTRING::from(title.as_ref()); + unsafe { SHStrDupW(&hstring) } + } + + fn GetIcon(&self, _items: Option<&IShellItemArray>) -> Result { + let icon_path = format!("{}\\people.ico", self.app_root.image_path()); + let hstring = HSTRING::from(icon_path); + unsafe { SHStrDupW(&hstring) } + } + + fn GetToolTip(&self, _items: Option<&IShellItemArray>) -> Result { + Err(Error::from(E_NOTIMPL)) + } + + fn GetCanonicalName(&self) -> Result { + Ok(GUID::from_u128(0x7d2b8f1c_3a9e_4c5d_b6f2_9e8a1d4c6f0b)) + } + + fn GetState(&self, items: Option<&IShellItemArray>, _oktobeslow: BOOL) -> Result { + let Some(items) = items else { + // Not select anthing, but still triggerd from a folder + return Ok(ECS_ENABLED.0 as u32); + }; + + unsafe { + let count = items.GetCount()?; + if count <= 1 { + Ok(ECS_ENABLED.0 as u32) + } else { + Ok(ECS_HIDDEN.0 as u32) + } + } + } + + fn Invoke( + &self, + selection: Option<&IShellItemArray>, + _bindctx: Option<&IBindCtx>, + ) -> Result<()> { + tracing::debug!(target: "shellext::context_menu", "Copy share link context menu command invoked"); + + if let Some(items) = selection { + unsafe { + let count = items.GetCount()?; + if count != 1 { + return Ok(()); + } + + // Get the first item + let item = items.GetItemAt(0)?; + let display_name = item.GetDisplayName(SIGDN_FILESYSPATH)?; + let path = PathBuf::from(display_name.to_string()?); + + self.send_copy_share_link(path); + } + } else { + // Folder-background invoke carries no selection; resolve the + // current folder through the site Explorer gave us in SetSite. + let site = self.site.lock().unwrap().clone(); + let Some(site) = site else { + return Ok(()); + }; + unsafe { + let service_provider: IServiceProvider = site.cast()?; + let folder_view: IFolderView = + service_provider.QueryService(&SID_S_FOLDER_VIEW)?; + let item: IShellItem = folder_view.GetFolder()?; + let display_name = item.GetDisplayName(SIGDN_FILESYSPATH)?; + let path = PathBuf::from(display_name.to_string()?); + + self.send_copy_share_link(path); + } + } + + Ok(()) + } + + fn GetFlags(&self) -> Result { + Ok(ECF_DEFAULT.0 as u32) + } + + fn EnumSubCommands(&self) -> Result { + Err(Error::from(E_NOTIMPL)) + } +} diff --git a/desktop/crates/cloudreve-sync/src/shellext/context_menu/sub_commands.rs b/desktop/crates/cloudreve-sync/src/shellext/context_menu/sub_commands.rs index 8319ce4c..74ffde54 100644 --- a/desktop/crates/cloudreve-sync/src/shellext/context_menu/sub_commands.rs +++ b/desktop/crates/cloudreve-sync/src/shellext/context_menu/sub_commands.rs @@ -1,4 +1,7 @@ -use super::{ResolveConflictCommandHandler, SyncNowCommandHandler, ViewOnlineCommandHandler}; +use super::{ + ResolveConflictCommandHandler, ShareLinkCommandHandler, SyncNowCommandHandler, + ViewOnlineCommandHandler, +}; use crate::{drive::manager::DriveManager, utils::app::AppRoot}; use std::sync::{Arc, Mutex}; use windows::{ @@ -109,14 +112,16 @@ macro_rules! sub_command_factory { } sub_command_factory!(create_view_online_command, ViewOnlineCommandHandler); +sub_command_factory!(create_share_link_command, ShareLinkCommandHandler); sub_command_factory!(create_sync_now_command, SyncNowCommandHandler); sub_command_factory!( create_resolve_conflict_command, ResolveConflictCommandHandler ); -const SUB_COMMAND_FACTORIES: [SubCommandFactory; 3] = [ +const SUB_COMMAND_FACTORIES: [SubCommandFactory; 4] = [ create_view_online_command, + create_share_link_command, create_sync_now_command, create_resolve_conflict_command, ]; diff --git a/desktop/crates/cloudreve-sync/src/tasks/download.rs b/desktop/crates/cloudreve-sync/src/tasks/download.rs index 6d1e6a24..3160cbfa 100644 --- a/desktop/crates/cloudreve-sync/src/tasks/download.rs +++ b/desktop/crates/cloudreve-sync/src/tasks/download.rs @@ -338,11 +338,19 @@ impl<'a> DownloadTask<'a> { .clone(); } - let entity_url_res = self - .cr_client - .get_file_url(&request) - .await - .context("failed to get file url")?; + let entity_url_res = match self.cr_client.get_file_url(&request).await { + Err(e) if e.is_entity_not_exist() && request.entity.is_some() => { + // Entity id cached from a previous file info response may be + // stale; retry letting the server pick the primary entity. + let mut retry = request.clone(); + retry.entity = None; + self.cr_client + .get_file_url(&retry) + .await + .context("failed to get file url")? + } + res => res.context("failed to get file url")?, + }; let download_url = entity_url_res .urls diff --git a/desktop/crates/cloudreve-sync/src/utils/clipboard.rs b/desktop/crates/cloudreve-sync/src/utils/clipboard.rs new file mode 100644 index 00000000..65006426 --- /dev/null +++ b/desktop/crates/cloudreve-sync/src/utils/clipboard.rs @@ -0,0 +1,39 @@ +//! Minimal clipboard helpers. Only Windows sets real clipboard text; other +//! platforms no-op so callers can stay unconditional. + +#[cfg(windows)] +pub fn set_text(text: &str) -> anyhow::Result<()> { + use windows::Win32::{ + Foundation::HANDLE, + System::{ + DataExchange::{CloseClipboard, EmptyClipboard, OpenClipboard, SetClipboardData}, + Memory::{GMEM_MOVEABLE, GlobalAlloc, GlobalLock, GlobalUnlock}, + Ole::CF_UNICODETEXT, + }, + }; + + unsafe { + OpenClipboard(None)?; + let result = (|| -> anyhow::Result<()> { + EmptyClipboard()?; + let wide: Vec = text.encode_utf16().chain(std::iter::once(0)).collect(); + let hglobal = GlobalAlloc(GMEM_MOVEABLE, wide.len() * 2)?; + let dst = GlobalLock(hglobal); + if dst.is_null() { + return Err(anyhow::anyhow!("GlobalLock failed")); + } + std::ptr::copy_nonoverlapping(wide.as_ptr(), dst as *mut u16, wide.len()); + let _ = GlobalUnlock(hglobal); + SetClipboardData(CF_UNICODETEXT.0 as u32, HANDLE(hglobal.0))?; + Ok(()) + })(); + let _ = CloseClipboard(); + result + } +} + +#[cfg(not(windows))] +pub fn set_text(_text: &str) -> anyhow::Result<()> { + tracing::warn!(target: "utils::clipboard", "Clipboard set_text is not supported on this platform"); + Ok(()) +} diff --git a/desktop/crates/cloudreve-sync/src/utils/mod.rs b/desktop/crates/cloudreve-sync/src/utils/mod.rs index 68495be4..13874d13 100644 --- a/desktop/crates/cloudreve-sync/src/utils/mod.rs +++ b/desktop/crates/cloudreve-sync/src/utils/mod.rs @@ -1,2 +1,3 @@ pub mod app; +pub mod clipboard; pub mod toast; diff --git a/desktop/locales/app.yaml b/desktop/locales/app.yaml index 5a375db1..003ffcc4 100644 --- a/desktop/locales/app.yaml +++ b/desktop/locales/app.yaml @@ -11,6 +11,42 @@ viewOnline: ru: "Просмотреть онлайн" pl: "Zobacz online" it: "Visualizza online" +copyShareLink: + en-US: "Copy share link" + zh-CN: "复制分享链接" + zh-TW: "複製分享連結" + ja: "共有リンクをコピー" + de: "Freigabelink kopieren" + fr: "Copier le lien de partage" + es: "Copiar enlace para compartir" + ko: "공유 링크 복사" + ru: "Копировать ссылку" + pl: "Kopiuj link udostępniania" + it: "Copia link di condivisione" +shareLinkCopied: + en-US: "Share link copied to clipboard" + zh-CN: "分享链接已复制到剪贴板" + zh-TW: "分享連結已複製到剪貼簿" + ja: "共有リンクをクリップボードにコピーしました" + de: "Freigabelink in die Zwischenablage kopiert" + fr: "Lien de partage copié dans le presse-papiers" + es: "Enlace para compartir copiado al portapapeles" + ko: "공유 링크가 클립보드에 복사되었습니다" + ru: "Ссылка скопирована в буфер обмена" + pl: "Link udostępniania skopiowany do schowka" + it: "Link di condivisione copiato negli appunti" +shareLinkFailed: + en-US: "Failed to create share link" + zh-CN: "创建分享链接失败" + zh-TW: "建立分享連結失敗" + ja: "共有リンクの作成に失敗しました" + de: "Freigabelink konnte nicht erstellt werden" + fr: "Échec de la création du lien de partage" + es: "Error al crear el enlace para compartir" + ko: "공유 링크 생성 실패" + ru: "Не удалось создать ссылку" + pl: "Nie udało się utworzyć linku udostępniania" + it: "Impossibile creare il link di condivisione" shared: en-US: "Shared" zh-CN: "已共享" diff --git a/frontend/src/component/FileManager/ContextMenu/ContextMenu.tsx b/frontend/src/component/FileManager/ContextMenu/ContextMenu.tsx index 263ecb67..1738e966 100644 --- a/frontend/src/component/FileManager/ContextMenu/ContextMenu.tsx +++ b/frontend/src/component/FileManager/ContextMenu/ContextMenu.tsx @@ -228,7 +228,7 @@ const ContextMenu = ({ fmIndex = 0 }: ContextMenuProps) => { )} {displayOpt.showExtractArchive && ( - dispatch(extractArchive(fmIndex, targets[0]))}> + dispatch(extractArchive(fmIndex, targets))}> diff --git a/frontend/src/component/FileManager/ContextMenu/useActionDisplayOpt.ts b/frontend/src/component/FileManager/ContextMenu/useActionDisplayOpt.ts index 3a37a4ab..4de3e843 100644 --- a/frontend/src/component/FileManager/ContextMenu/useActionDisplayOpt.ts +++ b/frontend/src/component/FileManager/ContextMenu/useActionDisplayOpt.ts @@ -13,6 +13,28 @@ import { FileManagerIndex } from "../FileManager.tsx"; const supportedArchiveTypes = ["zip", "gz", "xz", "tar", "rar", "7z", "bz2"]; +// archiveVolumeSetKey returns the volume-set key of a file name following the +// same conventions as the backend (partN.rar, .rNN), or null when the name is +// not a recognizable multi-volume member. +const archiveVolumeSetKey = (name: string): string | null => { + const l = name.toLowerCase(); + let m = /^(.+)\.part\d+\.rar$/.exec(l); + if (m) return `rar:${m[1]}`; + m = /^(.+)\.r\d+$/.exec(l); + if (m) return `rarold:${m[1]}`; + if (l.endsWith(".rar")) return `rarold:${l.slice(0, -4)}`; + return null; +}; + +// isSingleVolumeSet is true when all files are volumes of the same archive set. +export const isSingleVolumeSet = (files: FileResponse[]): boolean => { + if (files.length < 2 || files.some((f) => f.type != FileType.file)) { + return false; + } + const key = archiveVolumeSetKey(files[0].name); + return key != null && files.every((f) => archiveVolumeSetKey(f.name) === key); +}; + export const canManageVersion = (file: FileResponse, bs: Boolset) => { return ( file.type == FileType.file && @@ -267,12 +289,11 @@ export const getActionOpt = ( display.orCapability?.enabled(NavigatorCapability.enter_folder) && display.allReadable; display.showExtractArchive = - targets.length == 1 && display.hasFile && display.showDownload && !!currentUser && groupBs.enabled(GroupPermission.archive_task) && - supportedArchiveTypes.includes(firstFileSuffix ?? ""); + ((targets.length == 1 && supportedArchiveTypes.includes(firstFileSuffix ?? "")) || isSingleVolumeSet(targets)); display.showTorrentRemoteDownload = targets.length == 1 && display.hasFile && diff --git a/frontend/src/component/FileManager/Dialogs/ExtractArchive.tsx b/frontend/src/component/FileManager/Dialogs/ExtractArchive.tsx index 286c1ba0..3ddc5891 100644 --- a/frontend/src/component/FileManager/Dialogs/ExtractArchive.tsx +++ b/frontend/src/component/FileManager/Dialogs/ExtractArchive.tsx @@ -29,6 +29,7 @@ const ExtractArchive = () => { const open = useAppSelector((state) => state.globalState.extractArchiveDialogOpen); const target = useAppSelector((state) => state.globalState.extractArchiveDialogFile); + const targets = useAppSelector((state) => state.globalState.extractArchiveDialogFiles); const current = useAppSelector((state) => state.fileManager[FileManagerIndex.main].pure_path); const mask = useAppSelector((state) => state.globalState.extractArchiveDialogMask); const predefinedEncoding = useAppSelector((state) => state.globalState.extractArchiveDialogEncoding); @@ -44,7 +45,7 @@ const ExtractArchive = () => { const showPasswordOption = useMemo(() => { const ext = fileExtension(target?.name ?? ""); - return ext === "zip" || ext === "7z"; + return ext === "zip" || ext === "7z" || ext === "rar"; }, [target?.name]); useEffect(() => { @@ -65,7 +66,7 @@ const ExtractArchive = () => { setLoading(true); dispatch( sendExtractArchive({ - src: [getFileLinkedUri(target)], + src: (targets ?? [target]).map((f) => getFileLinkedUri(f)), dst: path, encoding: showEncodingOption && encoding != defaultEncodingValue ? encoding : undefined, password: showPasswordOption && password ? password : undefined, @@ -83,7 +84,7 @@ const ExtractArchive = () => { .finally(() => { setLoading(false); }); - }, [target, encoding, path, showPasswordOption, showEncodingOption, password, mask]); + }, [target, targets, encoding, path, showPasswordOption, showEncodingOption, password, mask]); return ( , + action: PayloadAction<{ open: boolean; file?: FileResponse; files?: FileResponse[]; mask?: string[]; encoding?: string }>, ) => { state.extractArchiveDialogOpen = action.payload.open; state.extractArchiveDialogFile = action.payload.file; + state.extractArchiveDialogFiles = action.payload.files; state.extractArchiveDialogMask = action.payload.mask; state.extractArchiveDialogEncoding = action.payload.encoding; }, closeExtractArchiveDialog: (state) => { state.extractArchiveDialogOpen = false; + state.extractArchiveDialogFiles = undefined; state.extractArchiveDialogMask = undefined; state.extractArchiveDialogEncoding = undefined; }, diff --git a/frontend/src/redux/thunks/file.ts b/frontend/src/redux/thunks/file.ts index ea1811b5..7a543d3a 100644 --- a/frontend/src/redux/thunks/file.ts +++ b/frontend/src/redux/thunks/file.ts @@ -1246,10 +1246,10 @@ export function walkAll(files: FileResponse[]): AppThunk { dispatch(closeContextMenu({ index, value: undefined })); - dispatch(setExtractArchiveDialog({ open: true, file })); + dispatch(setExtractArchiveDialog({ open: true, file: files[0], files })); }; } diff --git a/pkg/filemanager/workflows/extract.go b/pkg/filemanager/workflows/extract.go index b145a0aa..8a72ddb2 100644 --- a/pkg/filemanager/workflows/extract.go +++ b/pkg/filemanager/workflows/extract.go @@ -21,6 +21,7 @@ import ( "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs/dbfs" "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager" + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource" "github.com/cloudreve/Cloudreve/v4/pkg/hashid" "github.com/cloudreve/Cloudreve/v4/pkg/logging" "github.com/cloudreve/Cloudreve/v4/pkg/queue" @@ -42,6 +43,7 @@ type ( ExtractArchiveTaskState struct { Uri string `json:"uri,omitempty"` Encoding string `json:"encoding,omitempty"` + VolumeUris []string `json:"volume_uris,omitempty"` Dst string `json:"dst,omitempty"` TempPath string `json:"temp_path,omitempty"` TempZipFilePath string `json:"temp_zip_file_path,omitempty"` @@ -72,15 +74,17 @@ func init() { queue.RegisterResumableTaskFactory(queue.ExtractArchiveTaskType, NewExtractArchiveTaskFromModel) } -// NewExtractArchiveTask creates a new ExtractArchiveTask -func NewExtractArchiveTask(ctx context.Context, src, dst, encoding, password string, mask []string) (queue.Task, error) { +// NewExtractArchiveTask creates a new ExtractArchiveTask. volumes optionally +// lists the URIs of all volumes of a multi-volume archive selected together. +func NewExtractArchiveTask(ctx context.Context, src, dst, encoding, password string, mask []string, volumes []string) (queue.Task, error) { state := &ExtractArchiveTaskState{ - Uri: src, - Dst: dst, - Encoding: encoding, - NodeState: NodeState{}, - Password: password, - FileMask: mask, + Uri: src, + Dst: dst, + Encoding: encoding, + NodeState: NodeState{}, + Password: password, + FileMask: mask, + VolumeUris: volumes, } stateBytes, err := json.Marshal(state) if err != nil { @@ -178,6 +182,10 @@ func (m *ExtractArchiveTask) createSlaveExtractTask(ctx context.Context, dep dep user := inventory.UserFromContext(ctx) fm := manager.NewFileManager(dep, user) + // A later volume may have been selected directly; extract from the first + // volume of the set when it exists. + uri = m.resolveFirstVolumeURI(ctx, fm, uri) + // Get entity source to extract archiveFile, err := fm.Get(ctx, uri, dbfs.WithFileEntities(), dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile), dbfs.WithNotRoot()) if err != nil { @@ -212,6 +220,7 @@ func (m *ExtractArchiveTask) createSlaveExtractTask(ctx context.Context, dep dep UserID: user.ID, Password: m.state.Password, FileMask: m.state.FileMask, + Volumes: m.resolveVolumeEntities(ctx, fm, uri.DirUri(), archiveFile.DisplayName(), entityModel), } payloadStr, err := json.Marshal(payload) @@ -271,6 +280,10 @@ func (m *ExtractArchiveTask) masterExtractArchive(ctx context.Context, dep depen user := inventory.UserFromContext(ctx) fm := manager.NewFileManager(dep, user) + // A later volume may have been selected directly; extract from the first + // volume of the set when it exists. + uri = m.resolveFirstVolumeURI(ctx, fm, uri) + // Get entity source to extract archiveFile, err := fm.Get(ctx, uri, dbfs.WithFileEntities(), dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile), dbfs.WithNotRoot()) if err != nil { @@ -344,8 +357,22 @@ func (m *ExtractArchiveTask) masterExtractArchive(ctx context.Context, dep depen extractor = zipExtractor } } - } else if rarExtractor, ok := extractor.(archives.Rar); ok && m.state.Password != "" { + } else if rarExtractor, ok := extractor.(archives.Rar); ok { rarExtractor.Password = m.state.Password + // Route volume reads through a resolver on the archive's folder so + // multi-volume archives (.partN.rar / .rNN) are followed across files. + rarExtractor.Name = archiveFile.DisplayName() + dir := uri.DirUri() + rarExtractor.FS = &archiveVolumeFS{open: func(name string) (entitysource.EntitySource, error) { + sibling, err := fm.Get(ctx, dir.Join(name), dbfs.WithFileEntities(), dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile), dbfs.WithNotRoot()) + if err != nil { + return nil, err + } + if sibling.PrimaryEntity() == nil { + return nil, fmt.Errorf("volume %q has no entity", name) + } + return fm.GetEntitySource(ctx, 0, fs.WithEntity(sibling.PrimaryEntity())) + }} extractor = rarExtractor } else if sevenZipExtractor, ok := extractor.(archives.SevenZip); ok && m.state.Password != "" { sevenZipExtractor.Password = m.state.Password @@ -555,6 +582,67 @@ func (m *ExtractArchiveTask) Cleanup(ctx context.Context) error { return nil } +// resolveFirstVolumeURI returns the URI of the first volume of the archive +// set the given URI belongs to. When the selected file already is the first +// volume, or the first volume is not found in the same folder, uri is +// returned unchanged. +func (m *ExtractArchiveTask) resolveFirstVolumeURI(ctx context.Context, fm manager.FileManager, uri *fs.URI) *fs.URI { + first := firstVolumeName(uri.Name()) + if first == "" { + return uri + } + candidate := uri.DirUri().Join(first) + if _, err := fm.Get(ctx, candidate, dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile), dbfs.WithNotRoot()); err != nil { + m.l.Info("Archive %q is a later volume but first volume %q is unavailable: %s", uri.Name(), first, err) + return uri + } + m.l.Info("Selected file %q is a later volume, extracting from first volume %q", uri.Name(), first) + return candidate +} + +// resolveVolumeEntities collects the entities of all sibling files in the +// same archive volume set, so a slave node can resolve later volumes without +// accessing the filesystem. The first volume entity is always included. +func (m *ExtractArchiveTask) resolveVolumeEntities(ctx context.Context, fm manager.FileManager, dir *fs.URI, archiveName string, archiveEntity *ent.Entity) map[string]*ent.Entity { + volumes := map[string]*ent.Entity{archiveName: archiveEntity} + key, _, isVolume := volumeInfo(archiveName) + + // Volumes explicitly selected with the archive. + for _, v := range m.state.VolumeUris { + vuri, err := fs.NewUriFromString(v) + if err != nil || volumes[vuri.Name()] != nil { + continue + } + volumeFile, err := fm.Get(ctx, vuri, dbfs.WithFileEntities(), dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile), dbfs.WithNotRoot()) + if err != nil || volumeFile.PrimaryEntity() == nil { + continue + } + volumes[volumeFile.DisplayName()] = volumeFile.PrimaryEntity().Model() + } + + // Sibling volumes present in the folder but not selected. + if isVolume { + if _, listed, err := fm.List(ctx, dir, &manager.ListArgs{PageSize: 1000}); err == nil { + for _, child := range listed.Files { + if volumes[child.DisplayName()] != nil { + continue + } + childKey, _, ok := volumeInfo(child.DisplayName()) + if !ok || childKey != key { + continue + } + volumeFile, err := fm.Get(ctx, dir.Join(child.DisplayName()), dbfs.WithFileEntities(), dbfs.WithRequiredCapabilities(dbfs.NavigatorCapabilityDownloadFile), dbfs.WithNotRoot()) + if err != nil || volumeFile.PrimaryEntity() == nil { + continue + } + volumes[child.DisplayName()] = volumeFile.PrimaryEntity().Model() + } + } + } + + return volumes +} + type ( SlaveExtractArchiveTask struct { *queue.InMemoryTask @@ -566,17 +654,18 @@ type ( } SlaveExtractArchiveTaskState struct { - FileName string `json:"file_name"` - Entity *ent.Entity `json:"entity"` - Policy *ent.StoragePolicy `json:"policy"` - Encoding string `json:"encoding,omitempty"` - Dst string `json:"dst,omitempty"` - UserID int `json:"user_id"` - TempPath string `json:"temp_path,omitempty"` - TempZipFilePath string `json:"temp_zip_file_path,omitempty"` - ProcessedCursor string `json:"processed_cursor,omitempty"` - Password string `json:"password,omitempty"` - FileMask []string `json:"file_mask,omitempty"` + FileName string `json:"file_name"` + Entity *ent.Entity `json:"entity"` + Policy *ent.StoragePolicy `json:"policy"` + Encoding string `json:"encoding,omitempty"` + Dst string `json:"dst,omitempty"` + UserID int `json:"user_id"` + TempPath string `json:"temp_path,omitempty"` + TempZipFilePath string `json:"temp_zip_file_path,omitempty"` + ProcessedCursor string `json:"processed_cursor,omitempty"` + Password string `json:"password,omitempty"` + FileMask []string `json:"file_mask,omitempty"` + Volumes map[string]*ent.Entity `json:"volumes,omitempty"` } ) @@ -724,8 +813,18 @@ func (m *SlaveExtractArchiveTask) Do(ctx context.Context) (task.Status, error) { extractor = zipExtractor } } - } else if rarExtractor, ok := extractor.(archives.Rar); ok && m.state.Password != "" { + } else if rarExtractor, ok := extractor.(archives.Rar); ok { rarExtractor.Password = m.state.Password + // Resolve sibling volumes from the entities shipped with the task + // payload so multi-volume archives are followed across files. + rarExtractor.Name = m.state.FileName + rarExtractor.FS = &archiveVolumeFS{open: func(name string) (entitysource.EntitySource, error) { + model, ok := m.state.Volumes[name] + if !ok { + return nil, fmt.Errorf("volume %q not provided", name) + } + return fm.GetEntitySource(ctx, 0, fs.WithEntity(fs.NewEntity(model)), fs.WithPolicy(fm.CastStoragePolicyOnSlave(ctx, m.state.Policy))) + }} extractor = rarExtractor } else if sevenZipExtractor, ok := extractor.(archives.SevenZip); ok && m.state.Password != "" { sevenZipExtractor.Password = m.state.Password diff --git a/pkg/filemanager/workflows/volume.go b/pkg/filemanager/workflows/volume.go new file mode 100644 index 00000000..9d95004c --- /dev/null +++ b/pkg/filemanager/workflows/volume.go @@ -0,0 +1,141 @@ +package workflows + +import ( + "fmt" + "io" + iofs "io/fs" + "regexp" + "strconv" + "strings" + "time" + + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs" + "github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource" +) + +// Multi-volume archive naming conventions. rardecode derives the next volume +// name from the current one and opens it via a custom fs.FS, so siblings only +// need to be resolvable by bare file name within the archive's folder. +// Only RAR conventions are recognized: it is the format rardecode can follow +// across volumes via fs.FS. Split zip/7z sets are intentionally excluded as +// their extractors cannot span files. +var ( + newRarVolumeRe = regexp.MustCompile(`^(.+)\.part(\d+)\.rar$`) + oldRarVolumeRe = regexp.MustCompile(`^(.+)\.r(\d+)$`) +) + +// volumeInfo returns the volume-set key and the ordering number of a file +// name. ok is false when the name is not a recognizable multi-volume member. +// The key is only comparable within one folder. +func volumeInfo(name string) (key string, order int64, ok bool) { + lower := strings.ToLower(name) + if m := newRarVolumeRe.FindStringSubmatch(lower); m != nil { + n, _ := strconv.ParseInt(m[2], 10, 64) + return "rar:" + m[1], n, true + } + if m := oldRarVolumeRe.FindStringSubmatch(lower); m != nil { + n, _ := strconv.ParseInt(m[2], 10, 64) + return "rarold:" + m[1], n + 1, true + } + if stem, found := strings.CutSuffix(lower, ".rar"); found { + return "rarold:" + stem, 0, true + } + return "", 0, false +} + +// firstVolumeName returns the expected file name of the first volume of the +// set the given member belongs to, or "" when the name already is the first +// volume or is not a recognizable later volume. +func firstVolumeName(name string) string { + lower := strings.ToLower(name) + if m := newRarVolumeRe.FindStringSubmatchIndex(lower); m != nil { + if n, _ := strconv.Atoi(lower[m[4]:m[5]]); n > 1 { + return fmt.Sprintf("%s.part%0*d.rar", name[m[2]:m[3]], len(lower[m[4]:m[5]]), 1) + } + return "" + } + if m := oldRarVolumeRe.FindStringSubmatchIndex(lower); m != nil { + return name[m[2]:m[3]] + ".rar" + } + return "" +} + +// FirstVolumeURI inspects a multi-file selection of cloudreve URIs and, when +// all files are volumes of the same archive set in the same folder, returns +// the URI of the first volume to extract from. ok is false when the +// selection is not a single volume set. +func FirstVolumeURI(srcs []string) (src string, ok bool) { + if len(srcs) < 2 { + return "", false + } + var key, dir string + best, bestOrder := -1, int64(0) + for i, s := range srcs { + u, err := fs.NewUriFromString(s) + if err != nil { + return "", false + } + k, order, isVolume := volumeInfo(u.Name()) + if !isVolume { + return "", false + } + if i == 0 { + key, dir = k, u.Dir() + } else if k != key || u.Dir() != dir { + return "", false + } + if best < 0 || order < bestOrder { + best, bestOrder = i, order + } + } + return srcs[best], true +} + +// archiveVolumeFS is an fs.FS resolving bare archive volume file names to +// entity sources. rardecode calls Open with volume names derived from the +// first volume's name; a missing sibling must surface fs.ErrNotExist so the +// decoder treats it as the end of the archive. +type archiveVolumeFS struct { + open func(name string) (entitysource.EntitySource, error) +} + +func (f *archiveVolumeFS) Open(name string) (iofs.File, error) { + if name == "" || strings.ContainsRune(name, '/') || strings.ContainsRune(name, '\\') { + return nil, &iofs.PathError{Op: "open", Path: name, Err: iofs.ErrNotExist} + } + + es, err := f.open(name) + if err != nil || es == nil { + return nil, &iofs.PathError{Op: "open", Path: name, Err: iofs.ErrNotExist} + } + + return &archiveVolumeFile{es: es, name: name}, nil +} + +// archiveVolumeFile adapts an entity source to fs.File while keeping the +// io.Seeker passthrough rardecode uses for in-volume seeks. +type archiveVolumeFile struct { + es entitysource.EntitySource + name string +} + +func (f *archiveVolumeFile) Read(p []byte) (int, error) { return f.es.Read(p) } +func (f *archiveVolumeFile) Seek(o int64, w int) (int64, error) { return f.es.Seek(o, w) } +func (f *archiveVolumeFile) Close() error { return f.es.Close() } +func (f *archiveVolumeFile) Stat() (iofs.FileInfo, error) { + return archiveVolumeFileInfo{name: f.name, size: f.es.Entity().Size()}, nil +} + +var _ io.Seeker = (*archiveVolumeFile)(nil) + +type archiveVolumeFileInfo struct { + name string + size int64 +} + +func (i archiveVolumeFileInfo) Name() string { return i.name } +func (i archiveVolumeFileInfo) Size() int64 { return i.size } +func (i archiveVolumeFileInfo) Mode() iofs.FileMode { return 0 } +func (i archiveVolumeFileInfo) ModTime() time.Time { return time.Time{} } +func (i archiveVolumeFileInfo) IsDir() bool { return false } +func (i archiveVolumeFileInfo) Sys() any { return nil } diff --git a/pkg/filemanager/workflows/volume_test.go b/pkg/filemanager/workflows/volume_test.go new file mode 100644 index 00000000..24b885c7 --- /dev/null +++ b/pkg/filemanager/workflows/volume_test.go @@ -0,0 +1,83 @@ +package workflows + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestVolumeInfo(t *testing.T) { + cases := []struct { + name string + key string + order int64 + ok bool + }{ + {"movie.part1.rar", "rar:movie", 1, true}, + {"movie.part02.rar", "rar:movie", 2, true}, + {"MOVIE.PART10.RAR", "rar:movie", 10, true}, + {"movie.rar", "rarold:movie", 0, true}, + {"movie.r00", "rarold:movie", 1, true}, + {"movie.r15", "rarold:movie", 16, true}, + {"movie.zip", "", 0, false}, + {"movie.z01", "", 0, false}, + {"movie.001", "", 0, false}, + {"movie.7z", "", 0, false}, + {"movie.tar.gz", "", 0, false}, + {"readme.txt", "", 0, false}, + } + for _, c := range cases { + key, order, ok := volumeInfo(c.name) + require.Equal(t, c.ok, ok, c.name) + if c.ok { + require.Equal(t, c.key, key, c.name) + require.Equal(t, c.order, order, c.name) + } + } +} + +func TestFirstVolumeName(t *testing.T) { + require.Equal(t, "movie.part01.rar", firstVolumeName("movie.part07.rar")) + require.Equal(t, "movie.part1.rar", firstVolumeName("movie.part3.rar")) + require.Equal(t, "movie.rar", firstVolumeName("movie.r02")) + require.Equal(t, "", firstVolumeName("movie.part1.rar")) + require.Equal(t, "", firstVolumeName("movie.rar")) + require.Equal(t, "", firstVolumeName("movie.zip")) + require.Equal(t, "", firstVolumeName("movie.001")) + require.Equal(t, "", firstVolumeName("plain.txt")) +} + +func TestFirstVolumeURI(t *testing.T) { + base := "cloudreve://my/archives" + src, ok := FirstVolumeURI([]string{ + base + "/movie.part2.rar", + base + "/movie.part1.rar", + base + "/movie.part3.rar", + }) + require.True(t, ok) + require.Equal(t, base+"/movie.part1.rar", src) + + // Old-style set: .rar is first. + src, ok = FirstVolumeURI([]string{ + base + "/movie.r00", + base + "/movie.rar", + }) + require.True(t, ok) + require.Equal(t, base+"/movie.rar", src) + + // Single source never matches. + _, ok = FirstVolumeURI([]string{base + "/movie.part1.rar"}) + require.False(t, ok) + + // Mixed sets do not match. + _, ok = FirstVolumeURI([]string{base + "/a.part1.rar", base + "/b.part2.rar"}) + require.False(t, ok) + + // Different folders do not match. + _, ok = FirstVolumeURI([]string{base + "/movie.part1.rar", "cloudreve://my/other/movie.part2.rar"}) + require.False(t, ok) + + // Non-volume names do not match. + _, ok = FirstVolumeURI([]string{base + "/a.txt", base + "/b.txt"}) + require.False(t, ok) +} diff --git a/service/explorer/workflows.go b/service/explorer/workflows.go index 2e11c4eb..a80cee89 100644 --- a/service/explorer/workflows.go +++ b/service/explorer/workflows.go @@ -260,8 +260,17 @@ func (service *ArchiveWorkflowService) CreateExtractTask(c *gin.Context) (*TaskR return nil, serializer.NewError(serializer.CodeParamErr, "Invalid destination", err) } + // When multiple sources form a single multi-volume archive set, extract + // from the first volume and pass all volumes to the task. + src := service.Src[0] + var volumes []string + if first, ok := workflows.FirstVolumeURI(service.Src); ok { + src = first + volumes = service.Src + } + // Create task - t, err := workflows.NewExtractArchiveTask(c, service.Src[0], service.Dst, service.Encoding, service.Password, service.FileMask) + t, err := workflows.NewExtractArchiveTask(c, src, service.Dst, service.Encoding, service.Password, service.FileMask, volumes) if err != nil { return nil, serializer.NewError(serializer.CodeCreateTaskError, "Failed to create task", err) }