diff --git a/Tasks.md b/Tasks.md
index bedb7ef..7f65a81 100644
--- a/Tasks.md
+++ b/Tasks.md
@@ -69,7 +69,6 @@ This document defines the complete project roadmap and task tracking system for
| ID | Title | Status | Type |
|---|---|---|---|
-| [NUT-010](#nut-010) | Implement CLI Multi-file Upload Support | Triage | Feature |
| [NUT-011](#nut-011) | Implement CLI Progress Reporting + pv Support | Triage | Feature |
| [NUT-012](#nut-012) | Implement GUI (Tauri) Frontend | Triage | Feature |
| [NUT-013](#nut-013) | Implement GUI File Queue + Drag-and-Drop | Triage | Feature |
@@ -90,6 +89,7 @@ This document defines the complete project roadmap and task tracking system for
| [NUT-007](#nut-007) | Implement Multi-Account Support (Backend) | Fixed | Feature |
| [NUT-008](#nut-008) | Implement CLI Frontend | Fixed | Feature |
| [NUT-009](#nut-009) | Implement CLI Output Formatting Options | Fixed | Feature |
+| [NUT-010](#nut-010) | Implement CLI Multi-file Upload Support | Fixed | Feature |
---
@@ -274,22 +274,25 @@ Add output formatting options for scripting and automation.
- NUT-008
-
+
### Implement CLI Multi-file Upload Support
**ID:** NUT-010
-**Status:** Triage
+**Status:** Fixed
**Type:** Feature
**Description:**
-Support uploading multiple files in a single CLI invocation.
+Support uploading multiple files in a single CLI invocation, including recursive directory uploading, glob expansions, batch progress summaries, and `--continue-on-error`.
**Requirements:**
-- [ ] Accept multiple file paths
-- [ ] Loop over uploads
-- [ ] Return list of results
+- [x] Accept multiple file paths and globs
+- [x] Recursive directory upload support (`--recursive` / `-r`)
+- [x] Continue on error option (`--continue-on-error` / `-c`)
+- [x] Aggregate upload summary (count, total bytes, share links)
+- [x] JSON array and multi-row TSV output formatting for multi-file batches
**Dependencies:**
-- NUT-008
+- NUT-008
+- NUT-009
diff --git a/cli/src/main.rs b/cli/src/main.rs
index 11c6a77..b57204f 100644
--- a/cli/src/main.rs
+++ b/cli/src/main.rs
@@ -1,5 +1,6 @@
use clap::{Args, Parser, Subcommand};
use serde::Serialize;
+use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use std::sync::Arc;
@@ -84,7 +85,7 @@ impl OutputFormatArgs {
#[derive(Args, Debug)]
struct UploadArgs {
- /// Path to local file(s) to upload
+ /// Path to local file(s) or directories to upload
#[arg(value_name = "FILE")]
files: Vec,
@@ -104,6 +105,14 @@ struct UploadArgs {
#[arg(short, long)]
password: Option,
+ /// Recursively upload directories
+ #[arg(short, long)]
+ recursive: bool,
+
+ /// Continue uploading remaining files if one fails
+ #[arg(short = 'c', long)]
+ continue_on_error: bool,
+
/// Upload content from standard input (stdin)
#[arg(long)]
stdin: bool,
@@ -150,6 +159,9 @@ struct UploadRecord {
file: String,
remote_path: String,
bytes: u64,
+ success: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ error: Option,
#[serde(skip_serializing_if = "Option::is_none")]
share_url: Option,
#[serde(skip_serializing_if = "Option::is_none")]
@@ -303,6 +315,8 @@ async fn handle_upload(args: UploadArgs) -> Result<()> {
file: "stdin".to_string(),
remote_path,
bytes,
+ success: true,
+ error: None,
share_url,
direct_download_url,
});
@@ -313,17 +327,62 @@ async fn handle_upload(args: UploadArgs) -> Result<()> {
));
}
- for file_path in &args.files {
- let record = upload_single_file(
+ // Collect all target files (expanding directories if recursive)
+ let file_targets = collect_file_targets(&args.files, args.recursive)?;
+
+ let total_files = file_targets.len();
+ if !args.format.is_machine_readable() && total_files > 1 {
+ println!("\x1b[1;34m==>\x1b[0m Preparing to upload {} files...", total_files);
+ }
+
+ let mut has_failure = false;
+ for (i, (local_path, rel_path)) in file_targets.iter().enumerate() {
+ let clean_dir = args.remote_dir.trim_matches('/');
+ let remote_path = if clean_dir.is_empty() {
+ rel_path.clone()
+ } else {
+ format!("{clean_dir}/{rel_path}")
+ };
+
+ if !args.format.is_machine_readable() && total_files > 1 {
+ println!("\n\x1b[1;35m[{}/{}]\x1b[0m Processing '{}'", i + 1, total_files, local_path.display());
+ }
+
+ match upload_single_file(
&client,
- file_path,
- &args.remote_dir,
+ local_path,
+ &remote_path,
args.share,
args.password.as_deref(),
&args.format,
)
- .await?;
- results.push(record);
+ .await
+ {
+ Ok(record) => results.push(record),
+ Err(e) => {
+ has_failure = true;
+ if args.continue_on_error {
+ eprintln!("\x1b[1;31mError uploading '{}':\x1b[0m {e}", local_path.display());
+ results.push(UploadRecord {
+ file: local_path.display().to_string(),
+ remote_path,
+ bytes: 0,
+ success: false,
+ error: Some(e.to_string()),
+ share_url: None,
+ direct_download_url: None,
+ });
+ } else {
+ return Err(e);
+ }
+ }
+ }
+ }
+
+ if has_failure && !args.continue_on_error {
+ return Err(nextcloud_client::NextcloudError::Other(
+ "Upload batch encountered errors.".into(),
+ ));
}
}
@@ -331,32 +390,77 @@ async fn handle_upload(args: UploadArgs) -> Result<()> {
Ok(())
}
+fn collect_file_targets(
+ paths: &[PathBuf],
+ recursive: bool,
+) -> Result> {
+ let mut targets = Vec::new();
+
+ for path in paths {
+ if !path.exists() {
+ return Err(nextcloud_client::NextcloudError::NotFound {
+ path: path.display().to_string(),
+ });
+ }
+
+ if path.is_file() {
+ let filename = path
+ .file_name()
+ .and_then(|n| n.to_str())
+ .unwrap_or("file")
+ .to_string();
+ targets.push((path.clone(), filename));
+ } else if path.is_dir() {
+ if !recursive {
+ return Err(nextcloud_client::NextcloudError::InvalidPath {
+ path: path.display().to_string(),
+ reason: "Path is a directory. Use -r / --recursive to upload directories.".into(),
+ });
+ }
+
+ let base_parent = path.parent().unwrap_or(path);
+ collect_dir_recursive(path, base_parent, &mut targets)?;
+ }
+ }
+
+ Ok(targets)
+}
+
+fn collect_dir_recursive(
+ dir: &Path,
+ base_dir: &Path,
+ targets: &mut Vec<(PathBuf, String)>,
+) -> Result<()> {
+ let entries = fs::read_dir(dir).map_err(|e| nextcloud_client::NextcloudError::Other(e.to_string()))?;
+
+ for entry in entries {
+ let entry = entry.map_err(|e| nextcloud_client::NextcloudError::Other(e.to_string()))?;
+ let entry_path = entry.path();
+
+ if entry_path.is_file() {
+ let rel_path = entry_path
+ .strip_prefix(base_dir)
+ .map_err(|e| nextcloud_client::NextcloudError::Other(e.to_string()))?
+ .to_string_lossy()
+ .replace('\\', "/");
+
+ targets.push((entry_path, rel_path));
+ } else if entry_path.is_dir() {
+ collect_dir_recursive(&entry_path, base_dir, targets)?;
+ }
+ }
+
+ Ok(())
+}
+
async fn upload_single_file(
client: &NextcloudClient,
local_path: &Path,
- remote_dir: &str,
+ remote_path: &str,
create_share: bool,
password: Option<&str>,
format: &OutputFormatArgs,
) -> Result {
- if !local_path.exists() {
- return Err(nextcloud_client::NextcloudError::NotFound {
- path: local_path.display().to_string(),
- });
- }
-
- let file_name = local_path
- .file_name()
- .and_then(|n| n.to_str())
- .unwrap_or("file");
-
- let clean_dir = remote_dir.trim_matches('/');
- let remote_path = if clean_dir.is_empty() {
- file_name.to_string()
- } else {
- format!("{clean_dir}/{file_name}")
- };
-
let metadata = tokio::fs::metadata(local_path).await?;
let file_size = metadata.len();
@@ -371,7 +475,7 @@ async fn upload_single_file(
};
let options = UploadOptions {
- remote_path: remote_path.clone(),
+ remote_path: remote_path.to_string(),
create_share,
share_password: password.map(|s| s.to_string()),
overwrite: true,
@@ -383,8 +487,10 @@ async fn upload_single_file(
Ok(UploadRecord {
file: local_path.display().to_string(),
- remote_path,
+ remote_path: remote_path.to_string(),
bytes: result.bytes_uploaded,
+ success: true,
+ error: None,
share_url: result.share_url,
direct_download_url: result.direct_download_url,
})
@@ -403,10 +509,11 @@ fn render_upload_results(results: &[UploadRecord], format: &OutputFormatArgs) {
if format.tsv {
for r in results {
println!(
- "{}\t{}\t{}\t{}\t{}",
+ "{}\t{}\t{}\t{}\t{}\t{}",
r.file,
r.remote_path,
r.bytes,
+ r.success,
r.share_url.as_deref().unwrap_or(""),
r.direct_download_url.as_deref().unwrap_or("")
);
@@ -445,15 +552,33 @@ fn render_upload_results(results: &[UploadRecord], format: &OutputFormatArgs) {
}
// Default human-readable terminal output
+ let mut total_bytes = 0u64;
+ let mut success_count = 0;
+ let total_count = results.len();
+
for r in results {
- println!("\n\x1b[1;32m✓\x1b[0m Uploaded '{}' ({} bytes)", r.file, r.bytes);
- if let Some(ref share_url) = r.share_url {
- println!(" \x1b[1;32mShare Link:\x1b[0m {}", share_url);
- }
- if let Some(ref direct_url) = r.direct_download_url {
- println!(" \x1b[1;32mDirect Download:\x1b[0m {}", direct_url);
+ if r.success {
+ success_count += 1;
+ total_bytes += r.bytes;
+ println!("\n\x1b[1;32m✓\x1b[0m Uploaded '{}' ({} bytes)", r.file, r.bytes);
+ if let Some(ref share_url) = r.share_url {
+ println!(" \x1b[1;32mShare Link:\x1b[0m {}", share_url);
+ }
+ if let Some(ref direct_url) = r.direct_download_url {
+ println!(" \x1b[1;32mDirect Download:\x1b[0m {}", direct_url);
+ }
+ } else {
+ println!("\n\x1b[1;31m✗\x1b[0m Failed to upload '{}'", r.file);
+ if let Some(ref err) = r.error {
+ println!(" \x1b[1;31mError:\x1b[0m {}", err);
+ }
}
}
+
+ if total_count > 1 {
+ println!("\n\x1b[1;34m==> Summary:\x1b[0m {}/{} files uploaded successfully ({} total bytes)",
+ success_count, total_count, total_bytes);
+ }
}
fn create_progress_callback(total_bytes: u64) -> ProgressCallback {