ui: replace emoji with coloured unicode symbols/glyphs

This commit enhances the user interface by adding coloured glyphs/symbols instead of emoji.

- Added the coloured crate as a dependency.
- Modified downloader.rs to incorporate coloured output for download status, MD5 hashing, and error messages.
- Updated main.rs to use colours in spinner messages and validation feedback.
- Modified utils.rs to format progress bar messages with colours.
- Reduced the LARGE_FILE_THRESHOLD in downloader.rs from 16MB to 2MB, to trigger hashing progress bar more frequently.
This commit is contained in:
Martin Wimpress
2025-05-25 17:22:39 +01:00
committed by Martin Wimpress
parent 404032b8c0
commit 7ea122d5bb
5 changed files with 141 additions and 132 deletions
Generated
+11
View File
@@ -194,6 +194,16 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b63caa9aa9397e2d9480a9b13673856c78d8ac123288526c37d7839f2a86990"
[[package]]
name = "colored"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
dependencies = [
"lazy_static",
"windows-sys 0.59.0",
]
[[package]]
name = "console"
version = "0.15.7"
@@ -478,6 +488,7 @@ name = "ia-get"
version = "0.1.2"
dependencies = [
"clap",
"colored",
"ctrlc",
"futures",
"indicatif",
+1
View File
@@ -24,6 +24,7 @@ thiserror = "1.0"
url = "2.4.1"
clap = { version = "4.0", features = ["derive"] }
ctrlc = "3.4"
colored = "2.1.0"
[profile.release]
strip = true # Automatically strip symbols from the binary.
+83 -113
View File
@@ -8,15 +8,17 @@ use std::sync::Arc;
use reqwest::header::{HeaderMap, HeaderValue};
use reqwest::Client;
use colored::*; // Add this line
use crate::Result;
use crate::utils::{create_progress_bar, format_duration, format_size, format_transfer_rate};
use crate::error::IaGetError; // Import IaGetError for explicit error conversion
use crate::utils::{create_progress_bar, format_duration, format_size, format_transfer_rate}; // Import utility functions
/// Buffer size for file operations (8KB)
const BUFFER_SIZE: usize = 8192;
/// File size threshold for showing hash progress bar (16MB)
const LARGE_FILE_THRESHOLD: u64 = 2 * 1024 * 1024;
/// File size threshold for showing hash progress bar (2MB)
const LARGE_FILE_THRESHOLD: u64 = 2 * 1024 * 1024;
/// Sets up signal handling for graceful shutdown on Ctrl+C
///
@@ -28,7 +30,7 @@ fn setup_signal_handler() -> Arc<AtomicBool> {
ctrlc::set_handler(move || {
r.store(false, Ordering::SeqCst);
println!("\nReceived Ctrl+C, finishing current operation...");
println!("\n{} Received Ctrl+C, finishing current operation...", "".red().bold());
}).expect("Error setting Ctrl+C handler");
running
@@ -45,11 +47,8 @@ fn calculate_md5(file_path: &str, running: &Arc<AtomicBool>) -> Result<String> {
let mut buffer = [0; BUFFER_SIZE];
let pb = if is_large_file {
let progress_bar = create_progress_bar(file_size, "╰╼ Hashing ", Some("cyan/blue"), false);
Some(progress_bar)
} else {
None
};
Some(create_progress_bar(file_size, &format!("{} {} ", "╰╼".cyan().dimmed(), "Verifying".white()), Some("blue/blue"), false))
} else { None };
let mut bytes_processed: u64 = 0;
@@ -77,9 +76,7 @@ fn calculate_md5(file_path: &str, running: &Arc<AtomicBool>) -> Result<String> {
}
}
if let Some(progress_bar) = pb {
progress_bar.finish_and_clear();
}
if let Some(progress_bar) = pb.as_ref() { progress_bar.finish_and_clear(); }
let hash = context.compute();
Ok(format!("{:x}", hash))
@@ -101,7 +98,7 @@ fn check_existing_file(file_path: &str, expected_md5: Option<&str>, running: &Ar
if e.to_string().contains("interrupted by signal") {
return Err(e);
}
println!("╰╼ Failed to calculate MD5 hash: {}", e);
println!("{} {} to calculate MD5 hash: {}", "╰╼".cyan().dimmed(), "Failed".red().bold(), e);
return Ok(Some(false));
}
};
@@ -142,35 +139,31 @@ async fn download_file_content(
running: &Arc<AtomicBool>,
is_resuming: bool
) -> Result<u64> {
let download_action = if is_resuming { "╰╼ Resuming " } else { "╰╼ Downloading " };
let download_action = if is_resuming {
format!("{} {} ", "╰╼".cyan().dimmed(), "Resuming".white())
} else {
format!("{} {} ", "╰╼".cyan().dimmed(), "Downloading".white())
};
let mut response = if file_size > 0 {
// Resume download with range request
let range_header = format!("bytes={}-", file_size);
let mut headers = HeaderMap::new();
headers.insert(
reqwest::header::RANGE,
HeaderValue::from_str(&range_header).map_err(|e| {
std::io::Error::new(
std::io::ErrorKind::InvalidInput,
format!("Invalid header value: {}", e)
)
})?
);
let mut headers = HeaderMap::new();
if file_size > 0 {
// Use IaGetError::Network for header parsing errors
headers.insert(reqwest::header::RANGE, HeaderValue::from_str(&format!("bytes={}-", file_size)).map_err(|e| IaGetError::Network(format!("Invalid range header value: {}", e)))?);
}
let mut response = if file_size > 0 && is_resuming { // Ensure headers are only used for resume
client.get(url).headers(headers).send().await?
} else {
// Fresh download
client.get(url).send().await?
};
let content_length = response.content_length().unwrap_or(0);
let total_expected_size = content_length + file_size;
let total_expected_size = if is_resuming { content_length + file_size } else { content_length };
let pb = create_progress_bar(
total_expected_size,
download_action,
None,
total_expected_size,
&download_action,
Some("green/green"), // Color for download bar
true
);
@@ -201,90 +194,55 @@ async fn download_file_content(
let elapsed = start_time.elapsed();
let elapsed_secs = elapsed.as_secs_f64();
let transfer_rate = if elapsed_secs > 0.0 {
let transfer_rate_val = if elapsed_secs > 0.0 {
downloaded_bytes as f64 / elapsed_secs
} else {
0.0
};
let (rate, unit) = format_transfer_rate(transfer_rate);
} else { 0.0 };
let (rate, unit) = format_transfer_rate(transfer_rate_val);
pb.finish_and_clear();
if downloaded_bytes > 0 {
println!("├╼ Downloaded ⤵️ {} in {} ({:.2} {}/s)",
format_size(downloaded_bytes),
format_duration(elapsed),
rate,
unit);
}
println!(
"{} {} {} {} in {} ({:.2} {}/s)",
"├╼".cyan().dimmed(),
"Downloaded".white(),
"".green().bold(),
format_size(downloaded_bytes).bold(),
format_duration(elapsed).bold(),
rate,
unit
);
Ok(total_bytes)
}
/// Verify a downloaded file's hash against an expected value
fn verify_downloaded_file(file_path: &str, expected_md5: Option<&str>, running: &Arc<AtomicBool>) -> Result<bool> {
let local_md5 = match calculate_md5(file_path, running) {
Ok(hash) => hash,
Err(e) => {
println!("╰╼ Failed to calculate MD5 hash: {}", e);
return Ok(false);
}
};
match expected_md5 {
Some(expected) => {
let matches = local_md5 == expected;
if !matches {
println!("╰╼ Hash ❌");
} else {
println!("╰╼ Hash ✅");
}
Ok(matches)
},
None => {
println!("╰╼ No MD5: ⚠️");
Ok(true)
},
fn verify_downloaded_file(file_path: &str, expected_md5: Option<&str>, running: &Arc<AtomicBool>) -> Result<bool> {
if expected_md5.is_none() {
println!("{} {}", "-".dimmed(), "No MD5 hash provided for verification.".dimmed());
return Ok(true); // No hash to check against, consider it verified
}
}
/// Download a file from archive.org with resume capability
///
/// This function handles signal setup internally and manages graceful shutdown
/// during download operations.
pub async fn download_file(
client: &Client,
url: &str,
file_path: &str,
expected_md5: Option<&str>,
) -> Result<()> {
// Set up signal handling for this download session
let running = setup_signal_handler();
println!(" ");
println!("📦️ Filename {}", file_path);
if let Some(is_valid) = check_existing_file(file_path, expected_md5, &running)? {
if is_valid {
println!("╰╼ Downloaded ✅");
return Ok(());
} else {
println!("├╼ Partial 🔄");
}
let expected_md5_str = expected_md5.unwrap();
let local_md5 = calculate_md5(file_path, running)?;
if local_md5 == expected_md5_str {
println!(
"{} {} {} {}",
"╰╼".cyan().dimmed(),
"Hash".white(),
"".green().bold(),
format!("({})", local_md5).dimmed()
);
Ok(true)
} else {
println!(
"{} {} {} ({}) Expected ({})",
"╰╼".cyan().dimmed(),
"Hash".white(),
"".red().bold(),
local_md5.red(),
expected_md5_str.dimmed()
);
Ok(false)
}
ensure_parent_directories(file_path)?;
let mut file = prepare_file_for_download(file_path)?;
let file_size = file.metadata()?.len();
let is_resuming = file_size > 0;
download_file_content(client, url, file_size, &mut file, &running, is_resuming).await?;
verify_downloaded_file(file_path, expected_md5, &running)?;
Ok(())
}
/// Download multiple files with shared signal handling
@@ -305,20 +263,32 @@ where
for (index, (url, file_path, expected_md5)) in files.into_iter().enumerate() {
// Check if we should stop due to signal
if !running.load(Ordering::SeqCst) {
println!("\nDownload interrupted. Run the command again to resume remaining files.");
println!("\n{} Download interrupted. Run the command again to resume remaining files.", "".red().bold());
break;
}
println!(" ");
println!("📦️ Filename {}", file_path);
println!("├╼ Count {} of {}", index + 1, total_files);
println!(
"{} {} {}",
"".bright_cyan().bold(),
"Filename".white(),
file_path.bold()
);
println!(
"{} {} {} {} of {}",
"├╼".cyan().dimmed(),
"Count".white(),
"#".blue().bold(),
(index + 1).to_string().bold(),
total_files.to_string().bold()
);
if let Some(is_valid) = check_existing_file(&file_path, expected_md5.as_deref(), &running)? {
if is_valid {
println!("╰╼ Downloaded ✅");
println!("{} {} {}", "╰╼".cyan().dimmed(), "Downloaded".white(), "".green().bold());
continue;
} else {
println!("├╼ Partial 🔄");
println!("{} {} {}", "├╼".cyan().dimmed(), "Partial".white(), "".yellow().bold());
}
}
+35 -16
View File
@@ -13,6 +13,7 @@ use ia_get::archive_metadata::{XmlFiles, parse_xml_files};
use indicatif::ProgressStyle;
use reqwest::Client;
use clap::Parser;
use colored::*; // Add this line
/// Checks if a URL is accessible by sending a HEAD request
async fn is_url_accessible(url: &str, client: &Client) -> Result<()> {
@@ -41,7 +42,7 @@ fn get_xml_url(original_url: &str) -> String {
// The identifier is the last segment of the trimmed URL
// This expect is considered safe because get_xml_url is only called after
// validate_archive_url has confirmed the URL structure.
let identifier = trimmed_url.split('/').last()
let identifier = trimmed_url.rsplit('/').next() // Changed from split().last() to address clippy warning
.expect("Validated URL should have a valid identifier segment after validation");
// The base URL for download is "https://archive.org/download/{identifier}"
@@ -64,22 +65,30 @@ fn get_xml_url(original_url: &str) -> String {
/// # Returns
/// Tuple of (XmlFiles, base_url) for download processing
async fn fetch_xml_metadata(
details_url: &str,
client: &Client,
spinner: &indicatif::ProgressBar
details_url: &str,
client: &Client,
spinner: &indicatif::ProgressBar,
) -> Result<(XmlFiles, reqwest::Url)> {
// Generate XML URL
let xml_url = get_xml_url(details_url);
spinner.set_message(format!("Accessing XML metadata: {}", xml_url));
spinner.set_message(format!(
"{} Accessing XML metadata: {}",
"".blue(),
xml_url.bold()
));
// Check XML URL accessibility
if let Err(e) = is_url_accessible(&xml_url, client).await {
spinner.finish_with_message(format!("🔴 XML metadata not accessible: {}", xml_url));
spinner.finish_with_message(format!(
"{} XML metadata not accessible: {}",
"".red().bold(),
xml_url.bold()
));
return Err(e); // Propagate the error
}
spinner.set_message("Parsing archive metadata... 👀");
spinner.set_message(format!("{} {}", "".blue(), "Parsing archive metadata...".bold()));
// Parse base URL and fetch XML content
let base_url = reqwest::Url::parse(&xml_url)?;
let response = client.get(&xml_url).send().await?;
@@ -109,7 +118,7 @@ struct Cli {
#[tokio::main]
async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
let cli = Cli::parse();
// Create a single client instance for all requests
let client = Client::builder()
.user_agent(USER_AGENT)
@@ -117,28 +126,38 @@ async fn main() -> std::result::Result<(), Box<dyn std::error::Error>> {
.build()?;
// Start a single spinner for the entire initialization process
let spinner = create_spinner(&format!("Processing archive.org URL: {}", cli.url));
let spinner = create_spinner(&format!("Processing archive.org URL: {}", cli.url.bold()));
// Validate URL format using consolidated function
if let Err(e) = validate_archive_url(&cli.url) {
spinner.finish_with_message(format!(" {}", e));
spinner.finish_with_message(format!("{} {}", "".red().bold(), e));
return Err(e.into());
}
// Check URL accessibility
if let Err(e) = is_url_accessible(&cli.url, &client).await {
spinner.finish_with_message(format!("🔴 Archive.org URL not accessible: {}", cli.url));
spinner.finish_with_message(format!(
"{} Archive.org URL not accessible: {}",
"".red().bold(),
cli.url.bold()
));
return Err(e.into()); // Propagate error
}
// Fetch and parse XML metadata in one operation
let (files, base_url) = fetch_xml_metadata(&cli.url, &client, &spinner).await?;
// Successfully finished initialization - replace with green tick
// Successfully finished initialization
spinner.set_style(
ProgressStyle::default_spinner()
.template(&format!("✅ Ready to download {} files from archive.org ✨", files.files.len()))
.expect("Failed to set completion style")
.template(&format!(
"{} {} to download {} files from archive.org {}",
"".green().bold(),
"Ready".bold(),
files.files.len().to_string().bold(),
"".yellow()
))
.expect("Failed to set completion style"),
);
spinner.finish();
+11 -3
View File
@@ -5,6 +5,7 @@ use regex::Regex;
use std::sync::LazyLock;
use crate::constants::URL_PATTERN;
use crate::{Result, IaGetError};
use colored::*; // Add this line
/// Spinner tick interval in milliseconds
pub const SPINNER_TICK_INTERVAL: u64 = 100;
@@ -57,11 +58,18 @@ pub fn validate_archive_url(url: &str) -> Result<()> {
pub fn create_progress_bar(total: u64, action: &str, color: Option<&str>, with_eta: bool) -> ProgressBar {
let pb = ProgressBar::new(total);
let color_str = color.unwrap_or("green/green");
let styled_action = if action.contains("├╼") || action.contains("╰╼") {
action.replace("├╼", &"├╼".cyan().dimmed().to_string())
.replace("╰╼", &"╰╼".cyan().dimmed().to_string())
} else {
action.to_string()
};
let template = if with_eta {
format!("{action}{{elapsed_precise}} {{bar:40.{color_str}}} {{bytes}}/{{total_bytes}} (ETA: {{eta}})")
format!("{}{{elapsed_precise}} {{bar:40.{}}} {{bytes}}/{{total_bytes}} (ETA: {{eta}})", styled_action, color_str)
} else {
format!("{action}{{elapsed_precise}} {{bar:40.{color_str}}} {{bytes}}/{{total_bytes}}")
format!("{}{{elapsed_precise}} {{bar:40.{}}} {{bytes}}/{{total_bytes}}", styled_action, color_str)
};
pb.set_style(
@@ -86,7 +94,7 @@ pub fn create_spinner(message: &str) -> ProgressBar {
spinner.set_style(
ProgressStyle::default_spinner()
.tick_chars("⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏")
.template(&format!("{{spinner}} {message}"))
.template(&format!("{} {}", "{spinner}".yellow().bold(), message))
.expect("Failed to set spinner style")
);
spinner.enable_steady_tick(std::time::Duration::from_millis(SPINNER_TICK_INTERVAL));