Implements json, tsv, url only, and quiet upload options in the cli app.

This commit is contained in:
2026-08-23 17:14:15 -07:00
parent c6ad520797
commit 1a6ce5f75a
2 changed files with 235 additions and 54 deletions

View File

@@ -69,7 +69,6 @@ This document defines the complete project roadmap and task tracking system for
| ID | Title | Status | Type | | ID | Title | Status | Type |
|---|---|---|---| |---|---|---|---|
| [NUT-009](#nut-009) | Implement CLI Output Formatting Options | Triage | Feature |
| [NUT-010](#nut-010) | Implement CLI Multi-file Upload Support | Triage | Feature | | [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-011](#nut-011) | Implement CLI Progress Reporting + pv Support | Triage | Feature |
| [NUT-012](#nut-012) | Implement GUI (Tauri) Frontend | Triage | Feature | | [NUT-012](#nut-012) | Implement GUI (Tauri) Frontend | Triage | Feature |
@@ -90,6 +89,7 @@ This document defines the complete project roadmap and task tracking system for
| [NUT-006](#nut-006) | Implement Credential Storage System | Fixed | Feature | | [NUT-006](#nut-006) | Implement Credential Storage System | Fixed | Feature |
| [NUT-007](#nut-007) | Implement Multi-Account Support (Backend) | Fixed | Feature | | [NUT-007](#nut-007) | Implement Multi-Account Support (Backend) | Fixed | Feature |
| [NUT-008](#nut-008) | Implement CLI Frontend | Fixed | Feature | | [NUT-008](#nut-008) | Implement CLI Frontend | Fixed | Feature |
| [NUT-009](#nut-009) | Implement CLI Output Formatting Options | Fixed | Feature |
--- ---
@@ -255,20 +255,20 @@ Implement the CLI tool using the shared backend library, including interactive b
- NUT-006 - NUT-006
<a id="nut-009" class="task" data-status="triage" data-task-type="feature"></a> <a id="nut-009" class="task" data-status="done" data-task-type="feature"></a>
### Implement CLI Output Formatting Options ### Implement CLI Output Formatting Options
**ID:** NUT-009 **ID:** NUT-009
**Status:** Triage **Status:** Fixed
**Type:** Feature **Type:** Feature
**Description:** **Description:**
Add output formatting options for scripting and automation. Add output formatting options for scripting and automation.
**Requirements:** **Requirements:**
- [ ] JSON output - [x] JSON output (`--json`)
- [ ] TSV output - [x] TSV output (`--tsv`)
- [ ] Flags for path/filename/url - [x] Flags for path/filename/url (`--url-only`, `--direct-url-only`)
- [ ] Quiet mode - [x] Quiet mode (`--quiet` / `-q`)
**Dependencies:** **Dependencies:**
- NUT-008 - NUT-008

View File

@@ -1,4 +1,5 @@
use clap::{Args, Parser, Subcommand}; use clap::{Args, Parser, Subcommand};
use serde::Serialize;
use std::io::{self, Read}; use std::io::{self, Read};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::sync::Arc; use std::sync::Arc;
@@ -35,7 +36,7 @@ enum Commands {
Account(AccountCommands), Account(AccountCommands),
/// Quick alias to list all configured accounts /// Quick alias to list all configured accounts
Accounts, Accounts(AccountListArgs),
} }
#[derive(Args, Debug)] #[derive(Args, Debug)]
@@ -52,6 +53,35 @@ struct LoginArgs {
default: bool, default: bool,
} }
#[derive(Args, Debug, Clone, Default)]
struct OutputFormatArgs {
/// Format output as JSON
#[arg(long, conflicts_with_all = ["tsv", "url_only", "direct_url_only"])]
json: bool,
/// Format output as Tab-Separated Values (TSV)
#[arg(long, conflicts_with_all = ["json", "url_only", "direct_url_only"])]
tsv: bool,
/// Output only the public share URL
#[arg(long, conflicts_with_all = ["json", "tsv", "direct_url_only"])]
url_only: bool,
/// Output only the direct download URL
#[arg(long, conflicts_with_all = ["json", "tsv", "url_only"])]
direct_url_only: bool,
/// Suppress progress and informational output
#[arg(short, long)]
quiet: bool,
}
impl OutputFormatArgs {
pub fn is_machine_readable(&self) -> bool {
self.json || self.tsv || self.url_only || self.direct_url_only || self.quiet
}
}
#[derive(Args, Debug)] #[derive(Args, Debug)]
struct UploadArgs { struct UploadArgs {
/// Path to local file(s) to upload /// Path to local file(s) to upload
@@ -81,12 +111,15 @@ struct UploadArgs {
/// Remote filename to use when uploading via stdin /// Remote filename to use when uploading via stdin
#[arg(long, default_value = "stdin_upload.txt")] #[arg(long, default_value = "stdin_upload.txt")]
filename: String, filename: String,
#[command(flatten)]
format: OutputFormatArgs,
} }
#[derive(Subcommand, Debug)] #[derive(Subcommand, Debug)]
enum AccountCommands { enum AccountCommands {
/// List all configured Nextcloud accounts /// List all configured Nextcloud accounts
List, List(AccountListArgs),
/// Set the active default account /// Set the active default account
Default { Default {
@@ -101,6 +134,28 @@ enum AccountCommands {
}, },
} }
#[derive(Args, Debug, Clone, Default)]
struct AccountListArgs {
/// Format output as JSON
#[arg(long, conflicts_with = "tsv")]
json: bool,
/// Format output as Tab-Separated Values (TSV)
#[arg(long, conflicts_with = "json")]
tsv: bool,
}
#[derive(Debug, Clone, Serialize)]
struct UploadRecord {
file: String,
remote_path: String,
bytes: u64,
#[serde(skip_serializing_if = "Option::is_none")]
share_url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
direct_download_url: Option<String>,
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
let cli = Cli::parse(); let cli = Cli::parse();
@@ -108,7 +163,9 @@ async fn main() {
let result = match cli.command { let result = match cli.command {
Commands::Login(args) => handle_login(args).await, Commands::Login(args) => handle_login(args).await,
Commands::Upload(args) => handle_upload(args).await, Commands::Upload(args) => handle_upload(args).await,
Commands::Account(AccountCommands::List) | Commands::Accounts => handle_account_list(), Commands::Account(AccountCommands::List(args)) | Commands::Accounts(args) => {
handle_account_list(args)
}
Commands::Account(AccountCommands::Default { account }) => { Commands::Account(AccountCommands::Default { account }) => {
handle_account_set_default(&account) handle_account_set_default(&account)
} }
@@ -185,35 +242,71 @@ async fn handle_login(args: LoginArgs) -> Result<()> {
/// Handle file and stdin uploads. /// Handle file and stdin uploads.
async fn handle_upload(args: UploadArgs) -> Result<()> { async fn handle_upload(args: UploadArgs) -> Result<()> {
if (args.format.url_only || args.format.direct_url_only) && !args.share {
return Err(nextcloud_client::NextcloudError::Other(
"Flag --url-only or --direct-url-only requires --share to generate a link.".into(),
));
}
let client = match args.account.as_deref() { let client = match args.account.as_deref() {
Some(acc) => CredentialStore::create_client_for_account(acc)?, Some(acc) => CredentialStore::create_client_for_account(acc)?,
None => CredentialStore::create_client_for_default()?, None => CredentialStore::create_client_for_default()?,
}; };
let mut results: Vec<UploadRecord> = Vec::new();
// Stdin Upload // Stdin Upload
if args.stdin { if args.stdin {
if !args.format.is_machine_readable() {
println!("\x1b[1;34m==>\x1b[0m Reading data from stdin..."); println!("\x1b[1;34m==>\x1b[0m Reading data from stdin...");
}
let mut buffer = Vec::new(); let mut buffer = Vec::new();
io::stdin().read_to_end(&mut buffer)?; io::stdin().read_to_end(&mut buffer)?;
let len = buffer.len() as u64; let len = buffer.len() as u64;
let remote_path = format!("{}/{}", args.remote_dir.trim_matches('/'), args.filename); let clean_dir = args.remote_dir.trim_matches('/');
println!("\x1b[1;34m==>\x1b[0m Uploading {} bytes to '{}'...", len, remote_path); let remote_path = if clean_dir.is_empty() {
args.filename.clone()
} else {
format!("{clean_dir}/{}", args.filename)
};
if !args.format.is_machine_readable() {
println!("\x1b[1;34m==>\x1b[0m Uploading {} bytes to '{}'...", len, remote_path);
}
let progress_cb = if args.format.is_machine_readable() {
None
} else {
Some(create_progress_callback(len))
};
let progress_cb = create_progress_callback(len);
let cursor = io::Cursor::new(buffer); let cursor = io::Cursor::new(buffer);
let bytes = client let bytes = client
.upload_reader(cursor, &remote_path, Some(len), Some(progress_cb)) .upload_reader(cursor, &remote_path, Some(len), progress_cb)
.await?; .await?;
println!("\n\x1b[1;32m✓\x1b[0m Upload complete! ({} bytes)", bytes); let mut share_url = None;
let mut direct_download_url = None;
if args.share { if args.share {
create_and_print_share(&client, &remote_path, args.password.as_deref()).await?; if !args.format.is_machine_readable() {
println!("\x1b[1;34m==>\x1b[0m Generating public share link...");
} }
return Ok(()); let share = client.create_public_share(&remote_path, args.password.as_deref()).await?;
let direct = client.direct_download_url(&share.token)?;
share_url = Some(share.url);
direct_download_url = Some(direct.to_string());
} }
results.push(UploadRecord {
file: "stdin".to_string(),
remote_path,
bytes,
share_url,
direct_download_url,
});
} else {
if args.files.is_empty() { if args.files.is_empty() {
return Err(nextcloud_client::NextcloudError::Other( return Err(nextcloud_client::NextcloudError::Other(
"No files specified for upload. Usage: nut upload <FILE>... or nut upload --stdin".into(), "No files specified for upload. Usage: nut upload <FILE>... or nut upload --stdin".into(),
@@ -221,9 +314,20 @@ async fn handle_upload(args: UploadArgs) -> Result<()> {
} }
for file_path in &args.files { for file_path in &args.files {
upload_single_file(&client, file_path, &args.remote_dir, args.share, args.password.as_deref()).await?; let record = upload_single_file(
&client,
file_path,
&args.remote_dir,
args.share,
args.password.as_deref(),
&args.format,
)
.await?;
results.push(record);
}
} }
render_upload_results(&results, &args.format);
Ok(()) Ok(())
} }
@@ -233,7 +337,8 @@ async fn upload_single_file(
remote_dir: &str, remote_dir: &str,
create_share: bool, create_share: bool,
password: Option<&str>, password: Option<&str>,
) -> Result<()> { format: &OutputFormatArgs,
) -> Result<UploadRecord> {
if !local_path.exists() { if !local_path.exists() {
return Err(nextcloud_client::NextcloudError::NotFound { return Err(nextcloud_client::NextcloudError::NotFound {
path: local_path.display().to_string(), path: local_path.display().to_string(),
@@ -255,9 +360,16 @@ async fn upload_single_file(
let metadata = tokio::fs::metadata(local_path).await?; let metadata = tokio::fs::metadata(local_path).await?;
let file_size = metadata.len(); let file_size = metadata.len();
if !format.is_machine_readable() {
println!("\x1b[1;34m==>\x1b[0m Uploading '{}' ({} bytes) -> '{}'...", local_path.display(), file_size, remote_path); println!("\x1b[1;34m==>\x1b[0m Uploading '{}' ({} bytes) -> '{}'...", local_path.display(), file_size, remote_path);
}
let progress_cb = if format.is_machine_readable() {
None
} else {
Some(create_progress_callback(file_size))
};
let progress_cb = create_progress_callback(file_size);
let options = UploadOptions { let options = UploadOptions {
remote_path: remote_path.clone(), remote_path: remote_path.clone(),
create_share, create_share,
@@ -266,33 +378,82 @@ async fn upload_single_file(
}; };
let result = client let result = client
.upload_and_share(local_path, &options, Some(progress_cb)) .upload_and_share(local_path, &options, progress_cb)
.await?; .await?;
println!("\n\x1b[1;32m✓\x1b[0m Uploaded '{}' ({} bytes)", file_name, result.bytes_uploaded); Ok(UploadRecord {
file: local_path.display().to_string(),
if let Some(share_url) = result.share_url { remote_path,
println!(" \x1b[1;32mShare Link:\x1b[0m {}", share_url); bytes: result.bytes_uploaded,
} share_url: result.share_url,
if let Some(direct_url) = result.direct_download_url { direct_download_url: result.direct_download_url,
println!(" \x1b[1;32mDirect Download:\x1b[0m {}", direct_url); })
}
Ok(())
} }
async fn create_and_print_share( fn render_upload_results(results: &[UploadRecord], format: &OutputFormatArgs) {
client: &NextcloudClient, if format.json {
remote_path: &str, if results.len() == 1 {
password: Option<&str>, println!("{}", serde_json::to_string_pretty(&results[0]).unwrap());
) -> Result<()> { } else {
println!("\x1b[1;34m==>\x1b[0m Generating public share link..."); println!("{}", serde_json::to_string_pretty(&results).unwrap());
let share = client.create_public_share(remote_path, password).await?; }
let direct_url = client.direct_download_url(&share.token)?; return;
}
println!(" \x1b[1;32mShare Link:\x1b[0m {}", share.url); if format.tsv {
for r in results {
println!(
"{}\t{}\t{}\t{}\t{}",
r.file,
r.remote_path,
r.bytes,
r.share_url.as_deref().unwrap_or(""),
r.direct_download_url.as_deref().unwrap_or("")
);
}
return;
}
if format.url_only {
for r in results {
if let Some(ref url) = r.share_url {
println!("{url}");
}
}
return;
}
if format.direct_url_only {
for r in results {
if let Some(ref url) = r.direct_download_url {
println!("{url}");
}
}
return;
}
if format.quiet {
// Quiet mode: if share URL exists, print that, otherwise print remote path
for r in results {
if let Some(ref url) = r.share_url {
println!("{url}");
} else {
println!("{}", r.remote_path);
}
}
return;
}
// Default human-readable terminal output
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); println!(" \x1b[1;32mDirect Download:\x1b[0m {}", direct_url);
Ok(()) }
}
} }
fn create_progress_callback(total_bytes: u64) -> ProgressCallback { fn create_progress_callback(total_bytes: u64) -> ProgressCallback {
@@ -316,8 +477,28 @@ fn create_progress_callback(total_bytes: u64) -> ProgressCallback {
} }
/// List stored accounts. /// List stored accounts.
fn handle_account_list() -> Result<()> { fn handle_account_list(args: AccountListArgs) -> Result<()> {
let accounts = CredentialStore::list_accounts()?; let accounts = CredentialStore::list_accounts()?;
if args.json {
println!("{}", serde_json::to_string_pretty(&accounts)?);
return Ok(());
}
if args.tsv {
for acc in &accounts {
println!(
"{}\t{}\t{}\t{}\t{}",
acc.id,
acc.username,
acc.server_url,
acc.is_default,
acc.label.as_deref().unwrap_or("")
);
}
return Ok(());
}
if accounts.is_empty() { if accounts.is_empty() {
println!("No Nextcloud accounts configured yet."); println!("No Nextcloud accounts configured yet.");
println!("Run \x1b[1;36mnut login <SERVER_URL>\x1b[0m to connect an account."); println!("Run \x1b[1;36mnut login <SERVER_URL>\x1b[0m to connect an account.");