added --fix - fif can now rename files itself!
this alone warrants a bump to 0.4.0 imo, and now that i think about it, there's not really much keeping me from calling it 1.0... i think i'd want to get more tests, and maybe upgrade to clap 3 stable when that happens, before calling it 1.0, though. maybe even get some sort of configuration file...
This commit is contained in:
parent
c4fabbc0f4
commit
556ea82a06
7 changed files with 123 additions and 13 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -1,5 +1,6 @@
|
||||||
/target
|
/target
|
||||||
/imgs
|
/imgs
|
||||||
|
/imgs.tar.zst
|
||||||
/fif_*
|
/fif_*
|
||||||
/old
|
/old
|
||||||
/.mypy_cache
|
/.mypy_cache
|
||||||
|
|
11
CHANGELOG.md
11
CHANGELOG.md
|
@ -5,6 +5,17 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
||||||
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
### Added
|
||||||
|
- `--fix` mode - instead of outputting a shell script or text file, fif will rename the misnamed files for you!
|
||||||
|
- By default, the user will be prompted for each rename. This behaviour can be changed with the new `p`/`--prompt`
|
||||||
|
flag: `-p always` to be prompted each time (the default), `-p error` to be prompted on errors and when a file
|
||||||
|
would be overwritten by renaming, and `-p never` to disable prompting altogether - this behaves the same as
|
||||||
|
answering "yes" to every prompt.
|
||||||
|
- The `--overwrite` flag must be specified along with `--fix` in order for fif to process renames that would cause an
|
||||||
|
existing file to be overwritten. Without it, fif will never overwrite existing files, even with `-p always`.
|
||||||
|
**Caution**: If this flag is set in combination with `--prompt never`, fif will overwrite files **without asking**!
|
||||||
|
- For a more thorough breakdown of how these flags work, see [the corresponding wiki
|
||||||
|
page](https://gitlab.com/Lynnesbian/fif/-/wikis/Fix).
|
||||||
### Changed
|
### Changed
|
||||||
- Capped help output (`-h`/`--help`) width at 120 characters max
|
- Capped help output (`-h`/`--help`) width at 120 characters max
|
||||||
- Output is now sorted by filename - specifically, errors will appear first, followed by files that fif is unable to
|
- Output is now sorted by filename - specifically, errors will appear first, followed by files that fif is unable to
|
||||||
|
|
|
@ -23,6 +23,20 @@ pub struct Findings {
|
||||||
pub mime: Mime,
|
pub mime: Mime,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl Findings {
|
||||||
|
pub fn recommended_extension(&self) -> Option<String> {
|
||||||
|
mime_extension_lookup(self.mime.essence_str().into()).map(|extensions| extensions[0].clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn recommended_path(&self) -> Option<PathBuf> {
|
||||||
|
if let Some(ext) = self.recommended_extension() {
|
||||||
|
Some(self.file.with_extension(ext.as_str()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
impl PartialOrd<Self> for Findings {
|
impl PartialOrd<Self> for Findings {
|
||||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> { Some(self.cmp(other)) }
|
fn partial_cmp(&self, other: &Self) -> Option<Ordering> { Some(self.cmp(other)) }
|
||||||
}
|
}
|
||||||
|
@ -61,12 +75,6 @@ impl serde::Serialize for Findings {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Findings {
|
|
||||||
pub fn recommended_extension(&self) -> Option<String> {
|
|
||||||
mime_extension_lookup(self.mime.essence_str().into()).map(|extensions| extensions[0].clone())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, PartialEq, PartialOrd, Ord, Eq)]
|
#[derive(Debug, PartialEq, PartialOrd, Ord, Eq)]
|
||||||
#[cfg_attr(feature = "json", derive(serde::Serialize))]
|
#[cfg_attr(feature = "json", derive(serde::Serialize))]
|
||||||
#[cfg_attr(feature = "json", serde(tag = "type", content = "path"))]
|
#[cfg_attr(feature = "json", serde(tag = "type", content = "path"))]
|
||||||
|
|
|
@ -142,8 +142,8 @@ pub trait FormatSteps {
|
||||||
}
|
}
|
||||||
|
|
||||||
for finding in findings {
|
for finding in findings {
|
||||||
if let Some(ext) = finding.recommended_extension() {
|
if let Some(name) = finding.recommended_path() {
|
||||||
self.rename(f, finding.file.as_path(), &finding.file.with_extension(ext.as_str()))?;
|
self.rename(f, finding.file.as_path(), &name)?;
|
||||||
} else {
|
} else {
|
||||||
self.no_known_extension(f, finding.file.as_path())?;
|
self.no_known_extension(f, finding.file.as_path())?;
|
||||||
}
|
}
|
||||||
|
|
67
src/main.rs
67
src/main.rs
|
@ -17,14 +17,14 @@
|
||||||
#![forbid(unsafe_code)]
|
#![forbid(unsafe_code)]
|
||||||
#![warn(trivial_casts, unused_lifetimes, unused_qualifications)]
|
#![warn(trivial_casts, unused_lifetimes, unused_qualifications)]
|
||||||
|
|
||||||
use std::io::{stdout, BufWriter, Write};
|
use std::io::{stdin, stdout, BufWriter, Write};
|
||||||
use std::process::exit;
|
use std::process::exit;
|
||||||
|
|
||||||
use cfg_if::cfg_if;
|
use cfg_if::cfg_if;
|
||||||
use clap::Clap;
|
use clap::Clap;
|
||||||
use fif::files::{scan_directory, scan_from_walkdir};
|
use fif::files::{scan_directory, scan_from_walkdir};
|
||||||
use fif::formats::Format;
|
use fif::formats::Format;
|
||||||
use fif::parameters::OutputFormat;
|
use fif::parameters::{OutputFormat, Prompt};
|
||||||
use fif::utils::{os_name, CLAP_LONG_VERSION};
|
use fif::utils::{os_name, CLAP_LONG_VERSION};
|
||||||
use fif::{formats, parameters};
|
use fif::{formats, parameters};
|
||||||
use itertools::Itertools;
|
use itertools::Itertools;
|
||||||
|
@ -136,6 +136,69 @@ fn main() {
|
||||||
.collect_vec();
|
.collect_vec();
|
||||||
|
|
||||||
if args.fix {
|
if args.fix {
|
||||||
|
fn ask(message: String) -> bool {
|
||||||
|
let mut buf = String::with_capacity(1);
|
||||||
|
print!("{} [y/N] ", message);
|
||||||
|
|
||||||
|
// flush stdout to ensure message is displayed
|
||||||
|
stdout().flush().expect("Failed to flush stdout");
|
||||||
|
|
||||||
|
if let Err(e) = stdin().read_line(&mut buf) {
|
||||||
|
// something went wrong while reading input - just exit
|
||||||
|
error!("{}", e);
|
||||||
|
exit(exitcode::IOERR)
|
||||||
|
}
|
||||||
|
buf.starts_with("y") || buf.starts_with("Y")
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt = args.prompt.unwrap_or(Prompt::Always);
|
||||||
|
|
||||||
|
for f in findings {
|
||||||
|
if let Some(rename_to) = f.recommended_path() {
|
||||||
|
let will_rename = {
|
||||||
|
if !args.overwrite && rename_to.exists() {
|
||||||
|
// handles: --prompt never, --prompt error, --prompt always
|
||||||
|
// user didn't specify --overwrite, and the destination exists
|
||||||
|
info!("Not renaming {:#?}: Target {:#?} exists", f.file, rename_to);
|
||||||
|
false
|
||||||
|
} else if prompt == Prompt::Never {
|
||||||
|
// handles: --prompt never --overwrite
|
||||||
|
// user specified --prompt never in conjunction with --overwrite, so always rename
|
||||||
|
true
|
||||||
|
} else if prompt == Prompt::Error || ask(format!("Rename {:#?} to {:#?}?", &f.file, &rename_to)) {
|
||||||
|
// handles: --prompt error --overwrite, --prompt always --overwrite [y]
|
||||||
|
// if the target exists, prompt before renaming; otherwise, just rename
|
||||||
|
!rename_to.exists() || ask(format!("Destination {:#?} already exists, overwrite?", rename_to))
|
||||||
|
} else {
|
||||||
|
// handles: --prompt always --overwrite [n]
|
||||||
|
// user was prompted and replied "no"
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if !will_rename { continue }
|
||||||
|
|
||||||
|
loop {
|
||||||
|
match std::fs::rename(&f.file, &rename_to) {
|
||||||
|
Ok(_) => {
|
||||||
|
info!("Renamed {:#?} -> {:#?}", f.file, rename_to);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
warn!("Couldn't rename {:#?} to {:#?}: {:#?}", f.file, rename_to, e);
|
||||||
|
// if the user passed --prompt never, continue to the next file
|
||||||
|
// otherwise, prompt user to retry move, retrying until the rename succeeds or they respond "N"
|
||||||
|
if prompt == Prompt::Never || !ask(format!("Error while renaming file: {:#?}. Try again?", e)) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// no recommended name :c
|
||||||
|
info!("No known extension for file {:#?} of type {}", f.file, f.mime)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let mut buffered_stdout = BufWriter::new(stdout());
|
let mut buffered_stdout = BufWriter::new(stdout());
|
||||||
|
|
||||||
|
|
|
@ -32,6 +32,13 @@ pub enum OutputFormat {
|
||||||
Json,
|
Json,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clap, PartialEq, Debug)]
|
||||||
|
pub enum Prompt {
|
||||||
|
Never,
|
||||||
|
Error,
|
||||||
|
Always
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clap, Debug)]
|
#[derive(Clap, Debug)]
|
||||||
#[allow(clippy::struct_excessive_bools)]
|
#[allow(clippy::struct_excessive_bools)]
|
||||||
#[clap(
|
#[clap(
|
||||||
|
@ -48,12 +55,18 @@ pub enum OutputFormat {
|
||||||
max_term_width = 120
|
max_term_width = 120
|
||||||
)]
|
)]
|
||||||
pub struct Parameters {
|
pub struct Parameters {
|
||||||
/// Automatically rename files to use the correct extension.
|
/// Automatically rename files to use the correct extension, prompting the user for every rename.
|
||||||
#[clap(long)]
|
#[clap(long)]
|
||||||
pub fix: bool,
|
pub fix: bool,
|
||||||
|
|
||||||
#[clap(long)]
|
/// Requires --fix. Should fif prompt you `Never`, only on `Error`s and overwrites, or `Always`?
|
||||||
pub noconfirm: bool,
|
#[clap(short = 'p', long, arg_enum, requires = "fix")]
|
||||||
|
pub prompt: Option<Prompt>,
|
||||||
|
|
||||||
|
/// Requires --fix. Allow overwriting files. Warning: When used in combination with `--prompt never`, fif will
|
||||||
|
/// overwrite files without prompting!
|
||||||
|
#[clap(short = 'n', long, requires = "fix")]
|
||||||
|
pub overwrite: bool,
|
||||||
|
|
||||||
// NOTE: clap's comma-separated argument parser makes it impossible to specify extensions with commas in their name -
|
// NOTE: clap's comma-separated argument parser makes it impossible to specify extensions with commas in their name -
|
||||||
// `-e sil\,ly` is treated as ["sil", "ly"] rather than as ["silly"], no matter how i escape the comma (in bash,
|
// `-e sil\,ly` is treated as ["sil", "ly"] rather than as ["silly"], no matter how i escape the comma (in bash,
|
||||||
|
|
|
@ -136,6 +136,11 @@ fn simple_directory() {
|
||||||
assert_eq!(result.mime, IMAGE_PNG);
|
assert_eq!(result.mime, IMAGE_PNG);
|
||||||
// 3. ensure the recommended extension for "wrong.jpg" is "png"
|
// 3. ensure the recommended extension for "wrong.jpg" is "png"
|
||||||
assert_eq!(&result.recommended_extension().unwrap(), &String::from("png"));
|
assert_eq!(&result.recommended_extension().unwrap(), &String::from("png"));
|
||||||
|
// 4. ensure the recommended filename for "wrong.jpg" is "wrong.png"
|
||||||
|
assert_eq!(
|
||||||
|
result.recommended_path().unwrap().file_name(),
|
||||||
|
Some(OsStr::new("wrong.png"))
|
||||||
|
);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -146,6 +151,15 @@ fn simple_directory() {
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.contains(&result.recommended_extension().unwrap()));
|
.contains(&result.recommended_extension().unwrap()));
|
||||||
|
|
||||||
|
// ensure that the recommended_name function outputs something beginning with "test"
|
||||||
|
assert!(result
|
||||||
|
.recommended_path()
|
||||||
|
.unwrap()
|
||||||
|
.file_name()
|
||||||
|
.unwrap()
|
||||||
|
.to_string_lossy()
|
||||||
|
.starts_with("test"));
|
||||||
|
|
||||||
// make sure the guessed mimetype is correct based on the extension of the scanned file
|
// make sure the guessed mimetype is correct based on the extension of the scanned file
|
||||||
// because we already know that the extensions match the mimetype (as we created these files ourselves earlier in
|
// because we already know that the extensions match the mimetype (as we created these files ourselves earlier in
|
||||||
// the test), all files with the "jpg" extension should be IMAGE_JPEGs, etc.
|
// the test), all files with the "jpg" extension should be IMAGE_JPEGs, etc.
|
||||||
|
|
Loading…
Reference in a new issue