diff --git a/Cargo.toml b/Cargo.toml index 5278117..848b151 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,7 +6,7 @@ edition = "2021" readme = "README.md" repository = "https://github.com/ouch-org/ouch" license = "MIT" -keywords = ["decompression", "compression", "zip", "tar", "gzip"] +keywords = ["decompression", "compression", "zip", "tar", "gzip", "accessibility", "a11y"] categories = ["command-line-utilities", "compression", "encoding"] description = "A command-line utility for easily compressing and decompressing files and directories." diff --git a/README.md b/README.md index 0d13606..d127062 100644 --- a/README.md +++ b/README.md @@ -24,12 +24,13 @@ # Features 1. Easy to use. -2. Automatic formats detection. -3. Same usage syntax for all formats. -4. Uses encoding and decoding streams to improve performance. -5. No runtime dependencies (for _Linux x86_64_). -6. Can list archive contents with pretty tree formatting. -7. Shell completions (soon!). +2. Accessibility mode (A11Y) via `--accessibility` or `ACCESSIBILITY` env var (see [wiki page](https://github.com/ouch-org/ouch/wiki/Accessibility)). +3. Automatic formats detection. +4. Same usage syntax for all formats. +5. Uses encoding and decoding streams to improve performance. +6. No runtime dependencies (for _Linux x86_64_). +7. Can list archive contents with pretty tree formatting. +8. Shell completions (soon!). # Usage diff --git a/install.sh b/install.sh index f6f4f30..d12b572 100644 --- a/install.sh +++ b/install.sh @@ -7,6 +7,10 @@ DOWNLOAD_LOCATION="/tmp/ouch-binary" INSTALLATION_LOCATION="/usr/local/bin/ouch" REPO_URL="https://github.com/ouch-org/ouch" +# If env var ACCESSIBLE is set (to a nonempty value), suppress output of +# `curl` or `wget` + + # Panics script if anything fails set -e @@ -55,12 +59,15 @@ install() { echo "" # Set $downloader + downloader_quiet_flag="" if [ $(which curl) ]; then downloader="curl" - downloader_command="curl -fSL $binary_url -o $DOWNLOAD_LOCATION" + if [ "$ACCESSIBLE" ]; then downloader_quiet_flag="--silent"; fi + downloader_command="curl $downloader_quiet_flag -fSL $binary_url -o $DOWNLOAD_LOCATION" elif [ $(which wget) ]; then downloader="wget" - downloader_command="wget $binary_url -O $DOWNLOAD_LOCATION" + if [ "$ACCESSIBLE" ]; then downloader_quiet_flag="--quiet"; fi + downloader_command="wget $downloader_quiet_flag $binary_url -O $DOWNLOAD_LOCATION" else echo "ERROR: have not found 'curl' nor 'wget' to donwload ouch binary." exit 1 diff --git a/src/archive/tar.rs b/src/archive/tar.rs index a42108c..c3ae74c 100644 --- a/src/archive/tar.rs +++ b/src/archive/tar.rs @@ -38,7 +38,11 @@ pub fn unpack_archive( file.unpack_in(output_folder)?; - info!("{:?} extracted. ({})", output_folder.join(file.path()?), Bytes::new(file.size())); + // This is printed for every file in the archive and has little + // importance for most users, but would generate lots of + // spoken text for users using screen readers, braille displays + // and so on + info!(inaccessible, "{:?} extracted. ({})", output_folder.join(file.path()?), Bytes::new(file.size())); files_unpacked.push(file_path); } @@ -80,7 +84,11 @@ where let entry = entry?; let path = entry.path(); - info!("Compressing '{}'.", utils::to_utf(path)); + // This is printed for every file in `input_filenames` and has + // little importance for most users, but would generate lots of + // spoken text for users using screen readers, braille displays + // and so on + info!(inaccessible, "Compressing '{}'.", utils::to_utf(path)); if path.is_dir() { builder.append_dir(path, path)?; diff --git a/src/archive/zip.rs b/src/archive/zip.rs index c71be89..4962933 100644 --- a/src/archive/zip.rs +++ b/src/archive/zip.rs @@ -48,7 +48,11 @@ where match (&*file.name()).ends_with('/') { _is_dir @ true => { - println!("File {} extracted to \"{}\"", idx, file_path.display()); + // This is printed for every file in the archive and has little + // importance for most users, but would generate lots of + // spoken text for users using screen readers, braille displays + // and so on + info!(inaccessible, "File {} extracted to \"{}\"", idx, file_path.display()); fs::create_dir_all(&file_path)?; } _is_file @ false => { @@ -59,7 +63,8 @@ where } let file_path = strip_cur_dir(file_path.as_path()); - info!("{:?} extracted. ({})", file_path.display(), Bytes::new(file.size())); + // same reason is in _is_dir: long, often not needed text + info!(inaccessible, "{:?} extracted. ({})", file_path.display(), Bytes::new(file.size())); let mut output_file = fs::File::create(&file_path)?; io::copy(&mut file, &mut output_file)?; @@ -125,7 +130,11 @@ where let entry = entry?; let path = entry.path(); - info!("Compressing '{}'.", to_utf(path)); + // This is printed for every file in `input_filenames` and has + // little importance for most users, but would generate lots of + // spoken text for users using screen readers, braille displays + // and so on + info!(inaccessible, "Compressing '{}'.", to_utf(path)); if path.is_dir() { if dir_is_empty(path) { @@ -149,7 +158,17 @@ where fn check_for_comments(file: &ZipFile) { let comment = file.comment(); if !comment.is_empty() { - info!("Found comment in {}: {}", file.name(), comment); + // Zip file comments seem to be pretty rare, but if they are used, + // they may contain important information, so better show them + // + // "The .ZIP file format allows for a comment containing up to 65,535 (216−1) bytes + // of data to occur at the end of the file after the central directory." + // + // If there happen to be cases of very long and unnecessary comments in + // the future, maybe asking the user if he wants to display the comment + // (informing him of its size) would be sensible for both normal and + // accessibility mode.. + info!(accessible, "Found comment in {}: {}", file.name(), comment); } } diff --git a/src/cli.rs b/src/cli.rs index 677471f..f80135c 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -8,9 +8,14 @@ use std::{ use clap::Parser; use fs_err as fs; +use once_cell::sync::OnceCell; use crate::{Opts, QuestionPolicy, Subcommand}; +/// Whether to enable accessible output (removes info output and reduces other +/// output, removes visual markers like '[' and ']') +pub static ACCESSIBLE: OnceCell = OnceCell::new(); + impl Opts { /// A helper method that calls `clap::Parser::parse`. /// @@ -20,6 +25,8 @@ impl Opts { pub fn parse_args() -> crate::Result<(Self, QuestionPolicy)> { let mut opts = Self::parse(); + ACCESSIBLE.set(opts.accessible).unwrap(); + let (Subcommand::Compress { files, .. } | Subcommand::Decompress { files, .. } | Subcommand::List { archives: files, .. }) = &mut opts.cmd; diff --git a/src/commands.rs b/src/commands.rs index 35b6dbc..aea5484 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -148,6 +148,7 @@ pub fn run(args: Opts, question_policy: QuestionPolicy) -> crate::Result<()> { // Path::extension says: "if there is no file_name, then there is no extension". // Contrapositive statement: "if there is extension, then there is file_name". info!( + accessible, // important information "Partial compression detected. Compressing {} into {}", to_utf(files[0].as_path().file_name().unwrap()), to_utf(&output_path) @@ -168,7 +169,11 @@ pub fn run(args: Opts, question_policy: QuestionPolicy) -> crate::Result<()> { eprintln!(" Error:{reset} {}{red}.{reset}\n", err, reset = *colors::RESET, red = *colors::RED); } } else { - info!("Successfully compressed '{}'.", to_utf(output_path)); + // this is only printed once, so it doesn't result in much text. On the other hand, + // having a final status message is important especially in an accessibility context + // as screen readers may not read a commands exit code, making it hard to reason + // about whether the command succeeded without such a message + info!(accessible, "Successfully compressed '{}'.", to_utf(output_path)); } compress_result?; @@ -366,8 +371,18 @@ fn decompress_file( } else { return Ok(()); }; - info!("Successfully decompressed archive in {}.", nice_directory_display(output_dir)); - info!("Files unpacked: {}", files.len()); + + // this is only printed once, so it doesn't result in much text. On the other hand, + // having a final status message is important especially in an accessibility context + // as screen readers may not read a commands exit code, making it hard to reason + // about whether the command succeeded without such a message + info!( + accessible, + "Successfully decompressed archive in {} ({} files).", + nice_directory_display(output_dir), + files.len() + ); + return Ok(()); } @@ -446,8 +461,12 @@ fn decompress_file( } } - info!("Successfully decompressed archive in {}.", nice_directory_display(output_dir)); - info!("Files unpacked: {}", files_unpacked.len()); + // this is only printed once, so it doesn't result in much text. On the other hand, + // having a final status message is important especially in an accessibility context + // as screen readers may not read a commands exit code, making it hard to reason + // about whether the command succeeded without such a message + info!(accessible, "Successfully decompressed archive in {}.", nice_directory_display(output_dir)); + info!(accessible, "Files unpacked: {}", files_unpacked.len()); Ok(()) } @@ -531,7 +550,11 @@ fn smart_unpack( assert!(output_dir.exists()); let temp_dir = tempfile::tempdir_in(output_dir)?; let temp_dir_path = temp_dir.path(); - info!("Created temporary directory {} to hold decompressed elements.", nice_directory_display(temp_dir_path)); + info!( + accessible, + "Created temporary directory {} to hold decompressed elements.", + nice_directory_display(temp_dir_path) + ); // unpack the files let files = unpack_fn(temp_dir_path)?; @@ -550,6 +573,7 @@ fn smart_unpack( } fs::rename(&file_path, &correct_path)?; info!( + accessible, "Successfully moved {} to {}.", nice_directory_display(&file_path), nice_directory_display(&correct_path) @@ -563,6 +587,7 @@ fn smart_unpack( } fs::rename(&temp_dir_path, &output_file_path)?; info!( + accessible, "Successfully moved {} to {}.", nice_directory_display(&temp_dir_path), nice_directory_display(&output_file_path) @@ -581,7 +606,9 @@ fn check_mime_type( // File with no extension // Try to detect it automatically and prompt the user about it if let Some(detected_format) = try_infer_extension(path) { - info!("Detected file: `{}` extension as `{}`", path.display(), detected_format); + // Infering the file extension can have unpredicted consequences (e.g. the user just + // mistyped, ...) which we should always inform the user about. + info!(accessible, "Detected file: `{}` extension as `{}`", path.display(), detected_format); if user_wants_to_continue_decompressing(path, question_policy)? { format.push(detected_format); } else { @@ -605,7 +632,7 @@ fn check_mime_type( } else { // NOTE: If this actually produces no false positives, we can upgrade it in the future // to a warning and ask the user if he wants to continue decompressing. - info!("Could not detect the extension of `{}`", path.display()); + info!(accessible, "Could not detect the extension of `{}`", path.display()); } } Ok(ControlFlow::Continue(())) diff --git a/src/error.rs b/src/error.rs index 289efbc..550def2 100644 --- a/src/error.rs +++ b/src/error.rs @@ -49,7 +49,13 @@ pub struct FinalError { impl Display for FinalError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { // Title - write!(f, "{}[ERROR]{} {}", *RED, *RESET, self.title)?; + // + // When in ACCESSIBLE mode, the square brackets are suppressed + if *crate::cli::ACCESSIBLE.get().unwrap_or(&false) { + write!(f, "{}ERROR{}: {}", *RED, *RESET, self.title)?; + } else { + write!(f, "{}[ERROR]{} {}", *RED, *RESET, self.title)?; + } // Details for detail in &self.details { @@ -60,9 +66,18 @@ impl Display for FinalError { if !self.hints.is_empty() { // Separate by one blank line. writeln!(f)?; - for hint in &self.hints { - write!(f, "\n{}hint:{} {}", *GREEN, *RESET, hint)?; - } + // to reduce redundant output for text-to-speach systems, braille + // displays and so on, only print "hints" once in ACCESSIBLE mode + if *crate::cli::ACCESSIBLE.get().unwrap_or(&false) { + write!(f, "\n{}hints:{}", *GREEN, *RESET)?; + for hint in &self.hints { + write!(f, "\n{}", hint)?; + } + } else { + for hint in &self.hints { + write!(f, "\n{}hint:{} {}", *GREEN, *RESET, hint)?; + } + } } Ok(()) diff --git a/src/list.rs b/src/list.rs index fceb05e..1549b30 100644 --- a/src/list.rs +++ b/src/list.rs @@ -23,7 +23,7 @@ pub struct FileInArchive { /// Actually print the files pub fn list_files(archive: &Path, files: Vec, list_options: ListOptions) { - println!("{}:", archive.display()); + println!("Archive: {}", archive.display()); if list_options.tree { let tree: Tree = files.into_iter().collect(); tree.print(); @@ -43,6 +43,11 @@ fn print_entry(name: impl std::fmt::Display, is_dir: bool) { // if colors are deactivated, print final / to mark directories if BLUE.is_empty() { println!("{}/", name); + // if in ACCESSIBLE mode, use colors but print final / in case colors + // aren't read out aloud with a screen reader or aren't printed on a + // braille reader + } else if *crate::cli::ACCESSIBLE.get().unwrap() { + println!("{}{}{}/{}", *BLUE, *STYLE_BOLD, name, *ALL_RESET); } else { println!("{}{}{}{}", *BLUE, *STYLE_BOLD, name, *ALL_RESET); } diff --git a/src/macros.rs b/src/macros.rs index 49fd87c..0826a64 100644 --- a/src/macros.rs +++ b/src/macros.rs @@ -1,12 +1,38 @@ //! Macros used on ouch. /// Macro that prints \[INFO\] messages, wraps [`println`]. +/// +/// There are essentially two different versions of the `info!()` macro: +/// - `info!(accessible, ...)` should only be used for short, important +/// information which is expected to be useful for e.g. blind users whose +/// text-to-speach systems read out every output line, which is why we +/// should reduce nonessential output to a minimum when running in +/// ACCESSIBLE mode +/// - `info!(inaccessible, ...)` can be used more carelessly / for less +/// important information. A seeing user can easily skim through more lines +/// of output, so e.g. reporting every single processed file can be helpful, +/// while it would generate long and hard to navigate text for blind people +/// who have to have each line of output read to them aloud, whithout to +/// ability to skip some lines deemed not important like a seeing person would. #[macro_export] macro_rules! info { - ($($arg:tt)*) => { - $crate::macros::_info_helper(); + // Accessible (short/important) info message. + // Show info message even in ACCESSIBLE mode + (accessible, $($arg:tt)*) => { + // if in ACCESSIBLE mode, suppress the "[INFO]" and just print the message + if (!$crate::cli::ACCESSIBLE.get().unwrap()) { + $crate::macros::_info_helper(); + } println!($($arg)*); }; + // Inccessible (long/no important) info message. + // Print info message if ACCESSIBLE is not turned on + (inaccessible, $($arg:tt)*) => { + if (!$crate::cli::ACCESSIBLE.get().unwrap()) { + $crate::macros::_info_helper(); + println!($($arg)*); + } + }; } /// Helper to display "\[INFO\]", colored yellow @@ -29,5 +55,9 @@ macro_rules! warning { pub fn _warning_helper() { use crate::utils::colors::{ORANGE, RESET}; - print!("{}[WARNING]{} ", *ORANGE, *RESET); + if !crate::cli::ACCESSIBLE.get().unwrap() { + print!("{}Warning:{} ", *ORANGE, *RESET); + } else { + print!("{}[WARNING]{} ", *ORANGE, *RESET); + } } diff --git a/src/opts.rs b/src/opts.rs index def47d9..ad08251 100644 --- a/src/opts.rs +++ b/src/opts.rs @@ -19,6 +19,10 @@ pub struct Opts { #[clap(short, long)] pub no: bool, + /// Activate accessibility mode, reducing visual noise + #[clap(short = 'A', long, env = "ACCESSIBLE")] + pub accessible: bool, + /// Ouch and claps subcommands #[clap(subcommand)] pub cmd: Subcommand, diff --git a/src/utils/fs.rs b/src/utils/fs.rs index 31575ea..76dc0bd 100644 --- a/src/utils/fs.rs +++ b/src/utils/fs.rs @@ -42,7 +42,9 @@ pub fn clear_path(path: &Path, question_policy: QuestionPolicy) -> crate::Result pub fn create_dir_if_non_existent(path: &Path) -> crate::Result<()> { if !path.exists() { fs::create_dir_all(path)?; - info!("directory {} created.", to_utf(path)); + // creating a directory is an important change to the file system we + // should always inform the user about + info!(accessible, "directory {} created.", to_utf(path)); } Ok(()) } diff --git a/src/utils/question.rs b/src/utils/question.rs index 01bff85..67094d5 100644 --- a/src/utils/question.rs +++ b/src/utils/question.rs @@ -106,7 +106,11 @@ impl<'a> Confirmation<'a> { // Ask the same question to end while no valid answers are given loop { - print!("{} [{}Y{}/{}n{}] ", message, *colors::GREEN, *colors::RESET, *colors::RED, *colors::RESET); + if *crate::cli::ACCESSIBLE.get().unwrap() { + print!("{} {}yes{}/{}no{}: ", message, *colors::GREEN, *colors::RESET, *colors::RED, *colors::RESET); + } else { + print!("{} [{}Y{}/{}n{}] ", message, *colors::GREEN, *colors::RESET, *colors::RED, *colors::RESET); + } io::stdout().flush()?; let mut answer = String::new();