From 16699d86a837c2fc980358953d3b325efb83b6fd Mon Sep 17 00:00:00 2001 From: Nick Pegg Date: Sun, 11 May 2025 14:44:06 -0700 Subject: [PATCH 1/5] wip --- .gitignore | 2 +- Cargo.lock | 69 +++++++++++++++++++++++++ Cargo.toml | 2 + resources/test_album/moon.jpg | Bin 51463 -> 51649 bytes src/generate.rs | 5 +- src/lib.rs | 1 + src/main.rs | 12 +++++ src/reorganize.rs | 92 ++++++++++++++++++++++++++++++++++ 8 files changed, 180 insertions(+), 3 deletions(-) create mode 100644 src/reorganize.rs diff --git a/.gitignore b/.gitignore index dc17db2..50bfee3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,6 @@ dist /target # Project specific files -test_album* +/test_album* DESIGN.md TODO.md diff --git a/Cargo.lock b/Cargo.lock index 6b81d0f..e2dd689 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -349,6 +349,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", +] + [[package]] name = "digest" version = "0.10.7" @@ -698,6 +707,15 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "kamadak-exif" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1130d80c7374efad55a117d715a3af9368f0fa7a2c54573afc15a188cd984837" +dependencies = [ + "mutate_once", +] + [[package]] name = "lazy_static" version = "1.5.0" @@ -792,6 +810,12 @@ dependencies = [ "uuid", ] +[[package]] +name = "mutate_once" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16cf681a23b4d0a43fc35024c176437f9dcd818db34e0f42ab456a0ee5ad497b" + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -824,6 +848,12 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + [[package]] name = "num-derive" version = "0.4.2" @@ -937,6 +967,7 @@ dependencies = [ "fs_extra", "image", "indicatif", + "kamadak-exif", "log", "mktemp", "pulldown-cmark", @@ -945,6 +976,7 @@ dependencies = [ "serde_yml", "tera", "thiserror 2.0.12", + "time", ] [[package]] @@ -981,6 +1013,12 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + [[package]] name = "ppv-lite86" version = "0.2.21" @@ -1420,6 +1458,37 @@ dependencies = [ "weezl", ] +[[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + [[package]] name = "toml" version = "0.8.22" diff --git a/Cargo.toml b/Cargo.toml index b2dab69..f965a74 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,7 @@ env_logger = "^0.11.8" fs_extra = "^1.3.0" image = "^0.25.6" indicatif = "^0.17.11" +kamadak-exif = "^0.6.1" log = "^0.4.27" pulldown-cmark = "^0.13.0" rayon = "^1.10.0" @@ -21,6 +22,7 @@ serde = { version = "^1.0", features = ["derive"] } serde_yml = "^0.0.12" tera = { version = "^1.20", default-features = false } thiserror = "^2.0" +time = { version = "^0.3.41", features = ["formatting", "macros", "parsing"] } [dev-dependencies] mktemp = "^0.5.1" diff --git a/resources/test_album/moon.jpg b/resources/test_album/moon.jpg index ba2fc1153541c5e096b1f2f4df0a46e43de84cb6..51364934d39abfde309bb32cef619a1986103483 100644 GIT binary patch delta 198 zcmZpl#C&iv^90fQhYUMhD>Bm<7<_#hv=|r|I2c$Nr5IR&EJh&qVw8rngBUd!n8D&q z3=B-dP&QCidnN-5RDBeX1_2Ks2I+^;tP>a**nvD210!Rj3Cs*Y{R|>NJZB=KB}9Ue tiJ4&mOp&31!2(8z@&Eq=l>vdFrMZEXfgunn7#dp{n_C$eY!v=-0s!us7&nYn!u^90e2=RchQ03?wHQvd(} diff --git a/src/generate.rs b/src/generate.rs index 767350b..0eb1fc7 100644 --- a/src/generate.rs +++ b/src/generate.rs @@ -4,7 +4,7 @@ mod image; use crate::config::Config; use crate::generate::image::Image; use album_dir::AlbumDir; -use anyhow::{Context, anyhow}; +use anyhow::{anyhow, Context}; use indicatif::ProgressBar; use rayon::prelude::*; use serde::Serialize; @@ -98,7 +98,8 @@ fn generate_images(config: &Config, album: &AlbumDir, full: bool) -> anyhow::Res fs::hard_link(&img.path, &full_size_path) .with_context(|| format!("Error creating hard link at {}", full_size_path.display()))?; - let orig_image = ::image::open(&img.path)?; + let orig_image = ::image::open(&img.path) + .with_context(|| format!("Failed to read image {}", &img.path.display()))?; let thumb_path = output_path.join(&img.thumb_path); log::info!( "Resizing {} -> {}", diff --git a/src/lib.rs b/src/lib.rs index 1ca7b53..cbd7159 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ pub mod config; pub mod generate; +pub mod reorganize; pub mod skel; diff --git a/src/main.rs b/src/main.rs index cf1a837..06650a7 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,6 @@ use clap::{Parser, Subcommand}; use photojawn::generate::generate; +use photojawn::reorganize::reorganize; use photojawn::skel::make_skeleton; use std::path::Path; @@ -18,6 +19,9 @@ fn main() -> anyhow::Result<()> { let path = generate(&album_path.to_path_buf(), full)?; println!("Album site generated in {}", path.display()); } + Commands::Reorganize { path, dry_run } => { + reorganize(Path::new(&path), dry_run)?; + } } Ok(()) @@ -44,4 +48,12 @@ enum Commands { #[arg(long)] full: bool, }, + /// Reorganize photos in an album by date + Reorganize { + #[arg()] + path: String, + /// Don't actually reorganize, just say what renames would happen + #[arg(long)] + dry_run: bool, + }, } diff --git a/src/reorganize.rs b/src/reorganize.rs new file mode 100644 index 0000000..c08a96a --- /dev/null +++ b/src/reorganize.rs @@ -0,0 +1,92 @@ +use anyhow::Context; +use std::fs::File; +use std::io::BufReader; +use std::path::{Path, PathBuf}; +use std::str::from_utf8; +use thiserror::Error; +use time::macros::format_description; +use time::{OffsetDateTime, PrimitiveDateTime}; + +#[derive(Error, Debug)] +pub enum OrganizeError { + #[error("These files are not supported, unable to parse EXIF data: {0:?}")] + ExifNotSupported(Vec), + #[error("File {0} is missing an EXIF DateTimeOriginal field")] + ExifNoDateTime(PathBuf), +} + +pub fn reorganize(dir: &Path, dry_run: bool) -> anyhow::Result<()> { + // Run through all the images and figure out new names for them + for entry in dir.read_dir()? { + let entry = entry?; + if entry.path().is_file() { + let dt = get_exif_datetime(entry.path())?; + todo!(); + } + } + + // Either do the renames, or if dry-run print what the names would be + + Ok(()) +} + +/// Tries to figure out the datetime that t +fn get_exif_datetime(path: PathBuf) -> anyhow::Result<()> { + let DT_WITH_OFFSET = format_description!( + "[year]:[month]:[day] [hour]:[minute]:[second][offset_hour]:[offset_minute]" + ); + let DT_WITHOUT_OFFSET = + format_description!(version = 2, "[year]:[month]:[day] [hour]:[minute]:[second]"); + + let file = File::open(&path).with_context(|| format!("Couldn't open {}", path.display()))?; + let mut bufreader = BufReader::new(file); + // TODO: Return a better error if EXIF is not supported + let exif = exif::Reader::new().read_from_container(&mut bufreader)?; + let field = exif + .get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY) + .ok_or(OrganizeError::ExifNoDateTime(path.clone()))?; + + let dt = match &field.value { + exif::Value::Ascii(v) => { + let s = from_utf8(&v[0])?; + log::debug!("Date string: {s}"); + log::debug!("{DT_WITH_OFFSET:?}"); + match OffsetDateTime::parse(&s, DT_WITH_OFFSET) { + Ok(v) => v, + Err(_) => { + log::debug!("Unable to parse {s} with offset"); + PrimitiveDateTime::parse(&s, DT_WITHOUT_OFFSET)? + } + } + } + _ => todo!(), + }; + println!("{dt:?}"); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn init() { + let _ = env_logger::builder().is_test(true).try_init(); + } + + #[test] + /// Make sure we can get the datetime from one of our test photos + fn basic_datetime_read() { + init(); + let dt = get_exif_datetime("resources/test_album/moon.jpg".into()).unwrap(); + todo!(); + } + + #[test] + fn exif_datetime_missing() { + init(); + let result = get_exif_datetime("resources/test_album/mountains.jpg".into()); + assert!(result.is_err()); + //result.unwrap(); + } +} From 89f6ea533518d01b18dafd26980b36f246328613 Mon Sep 17 00:00:00 2001 From: Nick Pegg Date: Sat, 17 May 2025 18:08:42 -0700 Subject: [PATCH 2/5] Find image files and figure out renames based on EXIF metadata --- .../test_album/with_description/moon.jpg | Bin 51463 -> 51649 bytes .../test_album/with_description/mountains.jpg | Bin 71986 -> 72172 bytes src/generate.rs | 24 +-- src/lib.rs | 5 +- src/reorganize.rs | 142 ++++++++++++++---- src/test_util.rs | 25 +++ 6 files changed, 142 insertions(+), 54 deletions(-) create mode 100644 src/test_util.rs diff --git a/resources/test_album/with_description/moon.jpg b/resources/test_album/with_description/moon.jpg index ba2fc1153541c5e096b1f2f4df0a46e43de84cb6..aab6f80d1d5369c851b7bcde499762af133948da 100644 GIT binary patch delta 198 zcmZpl#C&iv^90fQhYUMhD>Bm<7<_#hv=|r|I2c$Nr5IR&EJh&qVw8rngBUd!n8D&q z3=B-dP&QCidnN-5RDBeX1_2Ks2I+^;tP>a**nvD210!Rj3Cs*Y{R|>NJZB=KB}9Ue uiJ4&mOp&31!2(8z@&Eq=l>vdFrMZEXfuWUwk%FPIm9e>%fx$-MFDC%*s2F$v delta 12 UcmX>&nYn!u^90e2=RchQ03?wHQvd(} diff --git a/resources/test_album/with_description/mountains.jpg b/resources/test_album/with_description/mountains.jpg index 59a3b1f8bf14d0103c790b45fa426b7a397cc3d6..085dfbe908815eb3fbe5b5dcaa9dfdae8389859f 100644 GIT binary patch delta 201 zcmdnAiRH~^mI Temp { - let tmpdir = Temp::new_dir().unwrap(); - let source_path = Path::new("resources/test_album"); - - log::info!("Creating test album in {}", tmpdir.display()); - make_skeleton(&tmpdir.to_path_buf()).unwrap(); - fs_extra::dir::copy( - &source_path, - &tmpdir, - &fs_extra::dir::CopyOptions::new().content_only(true), - ) - .unwrap(); - - tmpdir - } - /// Does basic sanity checks on an output album fn check_album(root_path: PathBuf) -> anyhow::Result<()> { log::debug!("Checking album dir {}", root_path.display()); diff --git a/src/lib.rs b/src/lib.rs index cbd7159..aa53f28 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,7 @@ -pub mod config; +pub(crate) mod config; pub mod generate; pub mod reorganize; pub mod skel; + +#[cfg(test)] +pub(crate) mod test_util; diff --git a/src/reorganize.rs b/src/reorganize.rs index c08a96a..a50d913 100644 --- a/src/reorganize.rs +++ b/src/reorganize.rs @@ -1,11 +1,12 @@ -use anyhow::Context; +use anyhow::{anyhow, Context}; +use image::ImageReader; use std::fs::File; use std::io::BufReader; use std::path::{Path, PathBuf}; use std::str::from_utf8; use thiserror::Error; use time::macros::format_description; -use time::{OffsetDateTime, PrimitiveDateTime}; +use time::{OffsetDateTime, PrimitiveDateTime, UtcDateTime}; #[derive(Error, Debug)] pub enum OrganizeError { @@ -16,70 +17,130 @@ pub enum OrganizeError { } pub fn reorganize(dir: &Path, dry_run: bool) -> anyhow::Result<()> { - // Run through all the images and figure out new names for them - for entry in dir.read_dir()? { - let entry = entry?; - if entry.path().is_file() { - let dt = get_exif_datetime(entry.path())?; - todo!(); - } - } + let renames = get_renames(dir); // Either do the renames, or if dry-run print what the names would be Ok(()) } -/// Tries to figure out the datetime that t -fn get_exif_datetime(path: PathBuf) -> anyhow::Result<()> { - let DT_WITH_OFFSET = format_description!( +/// Returns a vec of tuples of all the renames that need to happen in a directory +fn get_renames(dir: &Path) -> anyhow::Result> { + let mut renames: Vec<(PathBuf, PathBuf)> = Vec::new(); + + // Run through all the images and figure out new names for them + for entry in dir.read_dir()? { + let entry = entry?; + + // Only bother with image files, because we return an error if we fail to read EXIF + let is_image: bool = ImageReader::open(entry.path())? + .with_guessed_format()? + .format() + .is_some(); + + let is_cover: bool = entry + .path() + .file_name() + .is_some_and(|n| n.to_string_lossy().starts_with("cover")); + + if is_image && !is_cover { + // TODO: Should we just skip over images with no EXIF data? Find datetime some other + // way? + let Ok(dt) = get_exif_datetime(entry.path()) else { + log::warn!( + "Unable to read datetime from EXIF for {}", + entry.path().display() + ); + continue; + }; + let ext = entry + .path() + .extension() + .ok_or(anyhow!( + "{} is missing an extension", + entry.path().display() + ))? + .to_string_lossy() + .to_string(); + + let new_filename_base = dt.format(format_description!( + "[year][month][day]_[hour][minute][second]" + ))?; + let new_path = entry + .path() + .with_file_name(new_filename_base) + .with_extension(ext); + + renames.push((entry.path(), new_path.clone())); + + // Check for files associated with this image and set them up to be renamed too, like + // description files that end with .txt or .md + for ext in ["txt", "md"] { + let side_file_path = entry.path().with_extension(ext); + if side_file_path.exists() { + let new_side_file_path = new_path.with_extension(ext); + renames.push((side_file_path, new_side_file_path)); + } + } + } + } + Ok(renames) +} + +/// Tries to figure out the datetime that the image was created from EXIF metadata +fn get_exif_datetime(path: PathBuf) -> anyhow::Result { + let format_with_offset = format_description!( "[year]:[month]:[day] [hour]:[minute]:[second][offset_hour]:[offset_minute]" ); - let DT_WITHOUT_OFFSET = + let format_without_offset = format_description!(version = 2, "[year]:[month]:[day] [hour]:[minute]:[second]"); let file = File::open(&path).with_context(|| format!("Couldn't open {}", path.display()))?; let mut bufreader = BufReader::new(file); // TODO: Return a better error if EXIF is not supported - let exif = exif::Reader::new().read_from_container(&mut bufreader)?; + let exif = exif::Reader::new() + .read_from_container(&mut bufreader) + .with_context(|| format!("Couldn't read EXIF data from {}", path.display()))?; let field = exif .get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY) .ok_or(OrganizeError::ExifNoDateTime(path.clone()))?; - let dt = match &field.value { + let dt: UtcDateTime = match &field.value { exif::Value::Ascii(v) => { let s = from_utf8(&v[0])?; - log::debug!("Date string: {s}"); - log::debug!("{DT_WITH_OFFSET:?}"); - match OffsetDateTime::parse(&s, DT_WITH_OFFSET) { - Ok(v) => v, - Err(_) => { - log::debug!("Unable to parse {s} with offset"); - PrimitiveDateTime::parse(&s, DT_WITHOUT_OFFSET)? - } + log::debug!("Date string from file: {s}"); + + match OffsetDateTime::parse(&s, format_with_offset) { + Ok(v) => v.to_utc(), + Err(_) => PrimitiveDateTime::parse(&s, format_without_offset)?.as_utc(), } } + // TODO: return some error _ => todo!(), }; - println!("{dt:?}"); - Ok(()) + Ok(dt) } #[cfg(test)] mod tests { use super::*; - - fn init() { - let _ = env_logger::builder().is_test(true).try_init(); - } + use crate::test_util::{init, make_test_album}; + use time::{Date, Month, Time}; #[test] /// Make sure we can get the datetime from one of our test photos fn basic_datetime_read() { init(); let dt = get_exif_datetime("resources/test_album/moon.jpg".into()).unwrap(); - todo!(); + log::info!("Got dt: {dt}"); + assert_eq!( + dt, + UtcDateTime::new( + Date::from_calendar_date(1970, Month::January, 1).unwrap(), + Time::from_hms(13, 37, 0).unwrap(), + ) + ) } #[test] @@ -89,4 +150,23 @@ mod tests { assert!(result.is_err()); //result.unwrap(); } + + #[test] + fn basic_renames() { + init(); + let tmp_album_dir = make_test_album(); + let dir = tmp_album_dir.join("with_description"); + + log::debug!("Getting renames for {}", dir.display()); + let renames = get_renames(&dir).unwrap(); + + assert_eq!( + renames, + vec![ + (dir.join("mountains.jpg"), dir.join("19700103_133700.jpg")), + (dir.join("moon.jpg"), dir.join("19700102_133700.jpg")), + (dir.join("moon.txt"), dir.join("19700102_133700.txt")), + ] + ); + } } diff --git a/src/test_util.rs b/src/test_util.rs new file mode 100644 index 0000000..977ddf1 --- /dev/null +++ b/src/test_util.rs @@ -0,0 +1,25 @@ +use crate::skel::make_skeleton; +use mktemp::Temp; +use std::path::Path; + +pub fn init() { + let _ = env_logger::builder().is_test(true).try_init(); +} + +/// Copies the test album to a tempdir and returns the path to it. Returns a Temp object which +/// cleans up the directory on drop, so make sure to persist the variable until you're done with it +pub fn make_test_album() -> Temp { + let tmpdir = Temp::new_dir().unwrap(); + let source_path = Path::new("resources/test_album"); + + log::info!("Creating test album in {}", tmpdir.display()); + make_skeleton(&tmpdir.to_path_buf()).unwrap(); + fs_extra::dir::copy( + &source_path, + &tmpdir, + &fs_extra::dir::CopyOptions::new().content_only(true), + ) + .unwrap(); + + tmpdir +} From 04932b2c77fa9e766af715f9fe044a9ac0702bbd Mon Sep 17 00:00:00 2001 From: Nick Pegg Date: Sat, 17 May 2025 18:41:21 -0700 Subject: [PATCH 3/5] canonicalize() to fix race condition with cwd --- src/test_util.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/test_util.rs b/src/test_util.rs index 977ddf1..46a3d53 100644 --- a/src/test_util.rs +++ b/src/test_util.rs @@ -10,7 +10,7 @@ pub fn init() { /// cleans up the directory on drop, so make sure to persist the variable until you're done with it pub fn make_test_album() -> Temp { let tmpdir = Temp::new_dir().unwrap(); - let source_path = Path::new("resources/test_album"); + let source_path = Path::new("resources/test_album").canonicalize().unwrap(); log::info!("Creating test album in {}", tmpdir.display()); make_skeleton(&tmpdir.to_path_buf()).unwrap(); From ace35c7c86815bfe211095fec3494427af69d4fd Mon Sep 17 00:00:00 2001 From: Nick Pegg Date: Sat, 17 May 2025 19:11:44 -0700 Subject: [PATCH 4/5] actually do renames, preserve original filename in new, don't rerename files --- src/generate.rs | 5 ++-- src/main.rs | 5 ++++ src/reorganize.rs | 72 ++++++++++++++++++++++++++++++++++++++++------- 3 files changed, 69 insertions(+), 13 deletions(-) diff --git a/src/generate.rs b/src/generate.rs index 7672a14..4f17e55 100644 --- a/src/generate.rs +++ b/src/generate.rs @@ -4,7 +4,7 @@ mod image; use crate::config::Config; use crate::generate::image::Image; use album_dir::AlbumDir; -use anyhow::{anyhow, Context}; +use anyhow::{Context, anyhow}; use indicatif::ProgressBar; use rayon::prelude::*; use serde::Serialize; @@ -304,8 +304,6 @@ struct SlideContext { #[cfg(test)] mod tests { use super::generate; - use crate::skel::make_skeleton; - use mktemp::Temp; use std::collections::{HashSet, VecDeque}; use std::ffi::OsStr; use std::path::{Path, PathBuf}; @@ -315,6 +313,7 @@ mod tests { #[test] /// Test that the generate function creates a rendered site as we expect it fn test_generate() { + init(); let album_path = make_test_album(); let output_path = generate(&album_path.to_path_buf(), false).unwrap(); diff --git a/src/main.rs b/src/main.rs index 06650a7..c6b6cfe 100644 --- a/src/main.rs +++ b/src/main.rs @@ -50,6 +50,11 @@ enum Commands { }, /// Reorganize photos in an album by date Reorganize { + /// Directory of images you want to reorganize. Only image files will be moved. + /// + /// The new image filenames will be the date and time taken, followed by the original + /// filename. For example: + /// original_filename.jpg -> YYYYMMDD_HHSS_original_filename.jpg #[arg()] path: String, /// Don't actually reorganize, just say what renames would happen diff --git a/src/reorganize.rs b/src/reorganize.rs index a50d913..3a73113 100644 --- a/src/reorganize.rs +++ b/src/reorganize.rs @@ -1,6 +1,7 @@ use anyhow::{anyhow, Context}; use image::ImageReader; -use std::fs::File; +use std::ffi::OsStr; +use std::fs::{rename, File}; use std::io::BufReader; use std::path::{Path, PathBuf}; use std::str::from_utf8; @@ -17,9 +18,27 @@ pub enum OrganizeError { } pub fn reorganize(dir: &Path, dry_run: bool) -> anyhow::Result<()> { - let renames = get_renames(dir); + let renames = get_renames(dir)?; + + if renames.len() == 0 { + println!("Nothing to rename"); + return Ok(()); + } // Either do the renames, or if dry-run print what the names would be + if dry_run { + for (src, dst) in renames { + println!("{} -> {}", src.display(), dst.display()); + } + println!("Would have renamed the above files"); + } else { + for (src, dst) in renames { + println!("{} -> {}", src.display(), dst.display()); + rename(&src, &dst).with_context(|| { + format!("Failed to rename {} to {}", src.display(), dst.display()) + })?; + } + } Ok(()) } @@ -32,7 +51,7 @@ fn get_renames(dir: &Path) -> anyhow::Result> { for entry in dir.read_dir()? { let entry = entry?; - // Only bother with image files, because we return an error if we fail to read EXIF + // Only bother with image files, because those are the only hope for EXIF let is_image: bool = ImageReader::open(entry.path())? .with_guessed_format()? .format() @@ -53,6 +72,13 @@ fn get_renames(dir: &Path) -> anyhow::Result> { ); continue; }; + let orig_filename = entry + .path() + .file_name() + .unwrap_or(OsStr::new("")) + .to_string_lossy() + .into_owned(); + let ext = entry .path() .extension() @@ -64,11 +90,18 @@ fn get_renames(dir: &Path) -> anyhow::Result> { .to_string(); let new_filename_base = dt.format(format_description!( - "[year][month][day]_[hour][minute][second]" + "[year][month][day]_[hour][minute][second]_" ))?; + + // Renaming an already-renamed file should be a no-op + if orig_filename.starts_with(&new_filename_base) { + log::info!("{orig_filename} looks like it was already renamed, skiping"); + continue; + } + let new_path = entry .path() - .with_file_name(new_filename_base) + .with_file_name(new_filename_base + &orig_filename) .with_extension(ext); renames.push((entry.path(), new_path.clone())); @@ -84,6 +117,10 @@ fn get_renames(dir: &Path) -> anyhow::Result> { } } } + + // Sort renames by the destination + renames.sort_by_key(|(_, dst)| dst.clone()); + Ok(renames) } @@ -110,9 +147,9 @@ fn get_exif_datetime(path: PathBuf) -> anyhow::Result { let s = from_utf8(&v[0])?; log::debug!("Date string from file: {s}"); - match OffsetDateTime::parse(&s, format_with_offset) { + match OffsetDateTime::parse(s, format_with_offset) { Ok(v) => v.to_utc(), - Err(_) => PrimitiveDateTime::parse(&s, format_without_offset)?.as_utc(), + Err(_) => PrimitiveDateTime::parse(s, format_without_offset)?.as_utc(), } } // TODO: return some error @@ -163,10 +200,25 @@ mod tests { assert_eq!( renames, vec![ - (dir.join("mountains.jpg"), dir.join("19700103_133700.jpg")), - (dir.join("moon.jpg"), dir.join("19700102_133700.jpg")), - (dir.join("moon.txt"), dir.join("19700102_133700.txt")), + (dir.join("moon.jpg"), dir.join("19700102_133700_moon.jpg")), + (dir.join("moon.txt"), dir.join("19700102_133700_moon.txt")), + ( + dir.join("mountains.jpg"), + dir.join("19700103_133700_mountains.jpg") + ), ] ); } + + #[test] + /// The rename function will prepend date and time to the original filenames. If we do it a + /// second time, it should be a no-op instead of continuing to prepend date and time. + fn rerename() { + let tmp_album_dir = make_test_album(); + let dir = tmp_album_dir.join("with_description"); + reorganize(&dir, false).unwrap(); + + let renames = get_renames(&dir).unwrap(); + assert_eq!(renames, Vec::new()); + } } From c151ac409bdb51dabad94a5a2d5f5ef39a5be9d6 Mon Sep 17 00:00:00 2001 From: Nick Pegg Date: Sat, 17 May 2025 19:15:17 -0700 Subject: [PATCH 5/5] clippy fix --- src/reorganize.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/reorganize.rs b/src/reorganize.rs index 3a73113..dcf3f00 100644 --- a/src/reorganize.rs +++ b/src/reorganize.rs @@ -20,7 +20,7 @@ pub enum OrganizeError { pub fn reorganize(dir: &Path, dry_run: bool) -> anyhow::Result<()> { let renames = get_renames(dir)?; - if renames.len() == 0 { + if renames.is_empty() { println!("Nothing to rename"); return Ok(()); }