diff --git a/resources/test_album/with_description/moon.jpg b/resources/test_album/with_description/moon.jpg index ba2fc11..aab6f80 100644 Binary files a/resources/test_album/with_description/moon.jpg and b/resources/test_album/with_description/moon.jpg differ diff --git a/resources/test_album/with_description/mountains.jpg b/resources/test_album/with_description/mountains.jpg index 59a3b1f..085dfbe 100644 Binary files a/resources/test_album/with_description/mountains.jpg and b/resources/test_album/with_description/mountains.jpg differ diff --git a/src/generate.rs b/src/generate.rs index 0eb1fc7..7672a14 100644 --- a/src/generate.rs +++ b/src/generate.rs @@ -310,37 +310,17 @@ mod tests { use std::ffi::OsStr; use std::path::{Path, PathBuf}; + use crate::test_util::{init, make_test_album}; + #[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(); check_album(output_path).unwrap(); } - fn init() { - let _ = env_logger::builder().is_test(true).try_init(); - } - - /// Copies the test album to a tempdir and returns the path to it - 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 - } - /// 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 +}