Merge branch 'accessibility'

This commit is contained in:
João M. Bezerra 2021-11-24 23:39:00 -03:00
commit 4e0dbeb85b
13 changed files with 162 additions and 33 deletions

View File

@ -6,7 +6,7 @@ edition = "2021"
readme = "README.md" readme = "README.md"
repository = "https://github.com/ouch-org/ouch" repository = "https://github.com/ouch-org/ouch"
license = "MIT" license = "MIT"
keywords = ["decompression", "compression", "zip", "tar", "gzip"] keywords = ["decompression", "compression", "zip", "tar", "gzip", "accessibility", "a11y"]
categories = ["command-line-utilities", "compression", "encoding"] categories = ["command-line-utilities", "compression", "encoding"]
description = "A command-line utility for easily compressing and decompressing files and directories." description = "A command-line utility for easily compressing and decompressing files and directories."

View File

@ -24,12 +24,13 @@
# Features # Features
1. Easy to use. 1. Easy to use.
2. Automatic formats detection. 2. Accessibility mode (A11Y) via `--accessibility` or `ACCESSIBILITY` env var (see [wiki page](https://github.com/ouch-org/ouch/wiki/Accessibility)).
3. Same usage syntax for all formats. 3. Automatic formats detection.
4. Uses encoding and decoding streams to improve performance. 4. Same usage syntax for all formats.
5. No runtime dependencies (for _Linux x86_64_). 5. Uses encoding and decoding streams to improve performance.
6. Can list archive contents with pretty tree formatting. 6. No runtime dependencies (for _Linux x86_64_).
7. Shell completions (soon!). 7. Can list archive contents with pretty tree formatting.
8. Shell completions (soon!).
# Usage # Usage

View File

@ -7,6 +7,10 @@ DOWNLOAD_LOCATION="/tmp/ouch-binary"
INSTALLATION_LOCATION="/usr/local/bin/ouch" INSTALLATION_LOCATION="/usr/local/bin/ouch"
REPO_URL="https://github.com/ouch-org/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 # Panics script if anything fails
set -e set -e
@ -55,12 +59,15 @@ install() {
echo "" echo ""
# Set $downloader # Set $downloader
downloader_quiet_flag=""
if [ $(which curl) ]; then if [ $(which curl) ]; then
downloader="curl" 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 elif [ $(which wget) ]; then
downloader="wget" 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 else
echo "ERROR: have not found 'curl' nor 'wget' to donwload ouch binary." echo "ERROR: have not found 'curl' nor 'wget' to donwload ouch binary."
exit 1 exit 1

View File

@ -38,7 +38,11 @@ pub fn unpack_archive(
file.unpack_in(output_folder)?; 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); files_unpacked.push(file_path);
} }
@ -80,7 +84,11 @@ where
let entry = entry?; let entry = entry?;
let path = entry.path(); 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() { if path.is_dir() {
builder.append_dir(path, path)?; builder.append_dir(path, path)?;

View File

@ -48,7 +48,11 @@ where
match (&*file.name()).ends_with('/') { match (&*file.name()).ends_with('/') {
_is_dir @ true => { _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)?; fs::create_dir_all(&file_path)?;
} }
_is_file @ false => { _is_file @ false => {
@ -59,7 +63,8 @@ where
} }
let file_path = strip_cur_dir(file_path.as_path()); 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)?; let mut output_file = fs::File::create(&file_path)?;
io::copy(&mut file, &mut output_file)?; io::copy(&mut file, &mut output_file)?;
@ -125,7 +130,11 @@ where
let entry = entry?; let entry = entry?;
let path = entry.path(); 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 path.is_dir() {
if dir_is_empty(path) { if dir_is_empty(path) {
@ -149,7 +158,17 @@ where
fn check_for_comments(file: &ZipFile) { fn check_for_comments(file: &ZipFile) {
let comment = file.comment(); let comment = file.comment();
if !comment.is_empty() { 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 (2161) 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);
} }
} }

View File

@ -8,9 +8,14 @@ use std::{
use clap::Parser; use clap::Parser;
use fs_err as fs; use fs_err as fs;
use once_cell::sync::OnceCell;
use crate::{Opts, QuestionPolicy, Subcommand}; 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<bool> = OnceCell::new();
impl Opts { impl Opts {
/// A helper method that calls `clap::Parser::parse`. /// A helper method that calls `clap::Parser::parse`.
/// ///
@ -20,6 +25,8 @@ impl Opts {
pub fn parse_args() -> crate::Result<(Self, QuestionPolicy)> { pub fn parse_args() -> crate::Result<(Self, QuestionPolicy)> {
let mut opts = Self::parse(); let mut opts = Self::parse();
ACCESSIBLE.set(opts.accessible).unwrap();
let (Subcommand::Compress { files, .. } let (Subcommand::Compress { files, .. }
| Subcommand::Decompress { files, .. } | Subcommand::Decompress { files, .. }
| Subcommand::List { archives: files, .. }) = &mut opts.cmd; | Subcommand::List { archives: files, .. }) = &mut opts.cmd;

View File

@ -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". // 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". // Contrapositive statement: "if there is extension, then there is file_name".
info!( info!(
accessible, // important information
"Partial compression detected. Compressing {} into {}", "Partial compression detected. Compressing {} into {}",
to_utf(files[0].as_path().file_name().unwrap()), to_utf(files[0].as_path().file_name().unwrap()),
to_utf(&output_path) 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); eprintln!(" Error:{reset} {}{red}.{reset}\n", err, reset = *colors::RESET, red = *colors::RED);
} }
} else { } 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?; compress_result?;
@ -366,8 +371,18 @@ fn decompress_file(
} else { } else {
return Ok(()); 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(()); return Ok(());
} }
@ -446,8 +461,12 @@ fn decompress_file(
} }
} }
info!("Successfully decompressed archive in {}.", nice_directory_display(output_dir)); // this is only printed once, so it doesn't result in much text. On the other hand,
info!("Files unpacked: {}", files_unpacked.len()); // 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(()) Ok(())
} }
@ -531,7 +550,11 @@ fn smart_unpack(
assert!(output_dir.exists()); assert!(output_dir.exists());
let temp_dir = tempfile::tempdir_in(output_dir)?; let temp_dir = tempfile::tempdir_in(output_dir)?;
let temp_dir_path = temp_dir.path(); 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 // unpack the files
let files = unpack_fn(temp_dir_path)?; let files = unpack_fn(temp_dir_path)?;
@ -550,6 +573,7 @@ fn smart_unpack(
} }
fs::rename(&file_path, &correct_path)?; fs::rename(&file_path, &correct_path)?;
info!( info!(
accessible,
"Successfully moved {} to {}.", "Successfully moved {} to {}.",
nice_directory_display(&file_path), nice_directory_display(&file_path),
nice_directory_display(&correct_path) nice_directory_display(&correct_path)
@ -563,6 +587,7 @@ fn smart_unpack(
} }
fs::rename(&temp_dir_path, &output_file_path)?; fs::rename(&temp_dir_path, &output_file_path)?;
info!( info!(
accessible,
"Successfully moved {} to {}.", "Successfully moved {} to {}.",
nice_directory_display(&temp_dir_path), nice_directory_display(&temp_dir_path),
nice_directory_display(&output_file_path) nice_directory_display(&output_file_path)
@ -581,7 +606,9 @@ fn check_mime_type(
// File with no extension // File with no extension
// Try to detect it automatically and prompt the user about it // Try to detect it automatically and prompt the user about it
if let Some(detected_format) = try_infer_extension(path) { 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)? { if user_wants_to_continue_decompressing(path, question_policy)? {
format.push(detected_format); format.push(detected_format);
} else { } else {
@ -605,7 +632,7 @@ fn check_mime_type(
} else { } else {
// NOTE: If this actually produces no false positives, we can upgrade it in the future // 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. // 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(())) Ok(ControlFlow::Continue(()))

View File

@ -49,7 +49,13 @@ pub struct FinalError {
impl Display for FinalError { impl Display for FinalError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
// Title // 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 // Details
for detail in &self.details { for detail in &self.details {
@ -60,9 +66,18 @@ impl Display for FinalError {
if !self.hints.is_empty() { if !self.hints.is_empty() {
// Separate by one blank line. // Separate by one blank line.
writeln!(f)?; writeln!(f)?;
for hint in &self.hints { // to reduce redundant output for text-to-speach systems, braille
write!(f, "\n{}hint:{} {}", *GREEN, *RESET, hint)?; // 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(()) Ok(())

View File

@ -23,7 +23,7 @@ pub struct FileInArchive {
/// Actually print the files /// Actually print the files
pub fn list_files(archive: &Path, files: Vec<FileInArchive>, list_options: ListOptions) { pub fn list_files(archive: &Path, files: Vec<FileInArchive>, list_options: ListOptions) {
println!("{}:", archive.display()); println!("Archive: {}", archive.display());
if list_options.tree { if list_options.tree {
let tree: Tree = files.into_iter().collect(); let tree: Tree = files.into_iter().collect();
tree.print(); 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 colors are deactivated, print final / to mark directories
if BLUE.is_empty() { if BLUE.is_empty() {
println!("{}/", name); 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 { } else {
println!("{}{}{}{}", *BLUE, *STYLE_BOLD, name, *ALL_RESET); println!("{}{}{}{}", *BLUE, *STYLE_BOLD, name, *ALL_RESET);
} }

View File

@ -1,12 +1,38 @@
//! Macros used on ouch. //! Macros used on ouch.
/// Macro that prints \[INFO\] messages, wraps [`println`]. /// 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_export]
macro_rules! info { macro_rules! info {
($($arg:tt)*) => { // Accessible (short/important) info message.
$crate::macros::_info_helper(); // 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)*); 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 /// Helper to display "\[INFO\]", colored yellow
@ -29,5 +55,9 @@ macro_rules! warning {
pub fn _warning_helper() { pub fn _warning_helper() {
use crate::utils::colors::{ORANGE, RESET}; 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);
}
} }

View File

@ -19,6 +19,10 @@ pub struct Opts {
#[clap(short, long)] #[clap(short, long)]
pub no: bool, pub no: bool,
/// Activate accessibility mode, reducing visual noise
#[clap(short = 'A', long, env = "ACCESSIBLE")]
pub accessible: bool,
/// Ouch and claps subcommands /// Ouch and claps subcommands
#[clap(subcommand)] #[clap(subcommand)]
pub cmd: Subcommand, pub cmd: Subcommand,

View File

@ -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<()> { pub fn create_dir_if_non_existent(path: &Path) -> crate::Result<()> {
if !path.exists() { if !path.exists() {
fs::create_dir_all(path)?; 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(()) Ok(())
} }

View File

@ -106,7 +106,11 @@ impl<'a> Confirmation<'a> {
// Ask the same question to end while no valid answers are given // Ask the same question to end while no valid answers are given
loop { 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()?; io::stdout().flush()?;
let mut answer = String::new(); let mut answer = String::new();