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..4bebd05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -145,9 +145,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.0" +version = "2.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c8214115b7bf84099f1309324e63141d4c5d7cc26862f97a0a857dbefe165bd" +checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" [[package]] name = "bitstream-io" @@ -200,9 +200,9 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "cc" -version = "1.2.21" +version = "1.2.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8691782945451c1c383942c4874dbe63814f61cb57ef773cda2972682b7bb3c0" +checksum = "5f4ac86a9e5bc1e2b3449ab9d7d3a6a405e3d1bb28d7b9be8614f55846ae3766" dependencies = [ "jobserver", "libc", @@ -227,9 +227,9 @@ checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" [[package]] name = "clap" -version = "4.5.37" +version = "4.5.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eccb054f56cbd38340b380d4a8e69ef1f02f1af43db2f0cc817a4774d80ae071" +checksum = "ed93b9805f8ba930df42c2590f05453d5ec36cbb85d018868a5b24d31f6ac000" dependencies = [ "clap_builder", "clap_derive", @@ -237,9 +237,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.37" +version = "4.5.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efd9466fac8543255d3b1fcad4762c5e116ffe808c8a3043d4263cd4fd4862a2" +checksum = "379026ff283facf611b0ea629334361c4211d1b12ee01024eec1591133b04120" dependencies = [ "anstream", "anstyle", @@ -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" @@ -472,9 +481,9 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.3.2" +version = "0.3.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "73fea8450eea4bac3940448fb7ae50d91f034f941199fcd9d909a5a07aa455f0" +checksum = "26145e563e54f2cadc477553f1ec5ee650b00862f0a58bcd12cbdc5f0ea2d2f4" dependencies = [ "cfg-if", "libc", @@ -511,7 +520,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "ignore", "walkdir", ] @@ -528,9 +537,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.15.2" +version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bf151400ff0baff5465007dd2f3e717f3fe502074ca563069ce3a6629d07b289" +checksum = "84b26c544d002229e640969970a2e74021aadf6e2f96372b9c58eff97de08eb3" [[package]] name = "heck" @@ -650,9 +659,9 @@ checksum = "4a5f13b858c8d314ee3e8f639011f7ccefe71f97f96e50151fb991f267928e2c" [[package]] name = "jiff" -version = "0.2.12" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d07d8d955d798e7a4d6f9c58cd1f1916e790b42b092758a9ef6e16fef9f1b3fd" +checksum = "f02000660d30638906021176af16b17498bd0d12813dbfe7b276d8bc7f3c0806" dependencies = [ "jiff-static", "log", @@ -663,9 +672,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.12" +version = "0.2.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f244cfe006d98d26f859c7abd1318d85327e1882dc9cef80f62daeeb0adcf300" +checksum = "f3c30758ddd7188629c6713fc45d1188af4f44c90582311d0c8d8c9907f60c48" dependencies = [ "proc-macro2", "quote", @@ -678,7 +687,7 @@ version = "0.1.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38f262f097c174adebe41eb73d66ae9c06b2844fb0da69969647bbddd9b0538a" dependencies = [ - "getrandom 0.3.2", + "getrandom 0.3.3", "libc", ] @@ -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" @@ -726,16 +744,6 @@ dependencies = [ "cc", ] -[[package]] -name = "libyml" -version = "0.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3302702afa434ffa30847a83305f0a69d6abd74293b6554c18ec85c7ef30c980" -dependencies = [ - "anyhow", - "version_check", -] - [[package]] name = "log" version = "0.4.27" @@ -792,6 +800,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 +838,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" @@ -929,7 +949,7 @@ dependencies = [ [[package]] name = "photojawn" -version = "0.2.0-pre.1" +version = "0.2.0" dependencies = [ "anyhow", "clap", @@ -937,14 +957,16 @@ dependencies = [ "fs_extra", "image", "indicatif", + "kamadak-exif", "log", "mktemp", "pulldown-cmark", "rayon", "serde", - "serde_yml", + "serde_yaml_ng", "tera", "thiserror 2.0.12", + "time", ] [[package]] @@ -981,6 +1003,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" @@ -1024,7 +1052,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", "getopts", "memchr", "pulldown-cmark-escape", @@ -1265,18 +1293,16 @@ dependencies = [ ] [[package]] -name = "serde_yml" -version = "0.0.12" +name = "serde_yaml_ng" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59e2dd588bf1597a252c3b920e0143eb99b0f76e4e082f4c92ce34fbc9e71ddd" +checksum = "7b4db627b98b36d4203a7b458cf3573730f2bb591b28871d916dfa9efabfd41f" dependencies = [ "indexmap", "itoa", - "libyml", - "memchr", "ryu", "serde", - "version_check", + "unsafe-libyaml", ] [[package]] @@ -1420,6 +1446,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" @@ -1540,6 +1597,12 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "utf8parse" version = "0.2.2" @@ -1761,9 +1824,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.9" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9fb597c990f03753e08d3c29efbfcf2019a003b4bf4ba19225c158e1549f0f3" +checksum = "c06928c8748d81b05c9be96aad92e1b6ff01833332f281e8cfca3be4b35fc9ec" dependencies = [ "memchr", ] @@ -1774,7 +1837,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.9.0", + "bitflags 2.9.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b2dab69..7fd88d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "photojawn" -version = "0.2.0-pre.1" +version = "0.2.0" description = "A static site generator for photo albums" authors = ["Nick Pegg "] license = "MIT" @@ -8,19 +8,21 @@ repository = "https://github.com/nickpegg/photojawn" edition = "2024" [dependencies] -anyhow = "^1.0" -clap = { version = "^4.5", features = ["derive"] } -env_logger = "^0.11.8" -fs_extra = "^1.3.0" -image = "^0.25.6" -indicatif = "^0.17.11" -log = "^0.4.27" -pulldown-cmark = "^0.13.0" -rayon = "^1.10.0" -serde = { version = "^1.0", features = ["derive"] } -serde_yml = "^0.0.12" -tera = { version = "^1.20", default-features = false } -thiserror = "^2.0" +anyhow = "1.0" +clap = { version = "4.5", features = ["derive"] } +env_logger = "0.11.8" +fs_extra = "1.3" +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" +serde = { version = "1.0", features = ["derive"] } +serde_yaml_ng = "0.10.0" +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" +mktemp = "0.5.1" diff --git a/resources/test_album/moon.jpg b/resources/test_album/moon.jpg index ba2fc11..5136493 100644 Binary files a/resources/test_album/moon.jpg and b/resources/test_album/moon.jpg differ diff --git a/resources/test_album/nested1/moon.jpg b/resources/test_album/nested1/moon.jpg new file mode 100644 index 0000000..ba2fc11 Binary files /dev/null and b/resources/test_album/nested1/moon.jpg differ 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/config.rs b/src/config.rs index 9152206..6304525 100644 --- a/src/config.rs +++ b/src/config.rs @@ -23,7 +23,7 @@ impl Config { config_path.display(), ) })?; - let cfg = serde_yml::from_slice(&content) + let cfg = serde_yaml_ng::from_slice(&content) .with_context(|| format!("Failed to parse config from {}", config_path.display()))?; Ok(cfg) } @@ -56,11 +56,11 @@ mod test { fn from_yaml() { // Empty YAML gives full default values let default_cfg = Config::default(); - let cfg: Config = serde_yml::from_str("").unwrap(); + let cfg: Config = serde_yaml_ng::from_str("").unwrap(); assert_eq!(cfg, default_cfg); // Default values for any unspecified fields - let cfg: Config = serde_yml::from_str("thumbnail_size: [1, 1]").unwrap(); + let cfg: Config = serde_yaml_ng::from_str("thumbnail_size: [1, 1]").unwrap(); assert_ne!(cfg, default_cfg); assert_eq!(cfg.thumbnail_size, (1, 1)); assert_eq!(cfg.view_size, default_cfg.view_size); diff --git a/src/generate.rs b/src/generate.rs index 767350b..4f17e55 100644 --- a/src/generate.rs +++ b/src/generate.rs @@ -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 {} -> {}", @@ -303,12 +304,12 @@ 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}; + 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() { @@ -319,27 +320,6 @@ mod tests { 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/generate/album_dir.rs b/src/generate/album_dir.rs index 461debb..a77eb25 100644 --- a/src/generate/album_dir.rs +++ b/src/generate/album_dir.rs @@ -19,7 +19,7 @@ pub struct AlbumDir { impl AlbumDir { /// Returns an iterator over all images in the album and subalbums - pub fn iter_all_images(&self) -> AlbumImageIter { + pub fn iter_all_images(&self) -> AlbumImageIter<'_> { AlbumImageIter::new(self) } @@ -79,20 +79,20 @@ impl AlbumDir { } } } - } else if entry_path.is_dir() { - if let Some(dirname) = entry_path.file_name().and_then(|n| n.to_str()) { - if dirname.starts_with("_") { - // Likely a templates or static dir - continue; - } else if dirname == "site" { - // Is a generated site dir, don't descend into it - continue; - } else if dirname == "slides" { - continue; - } - - children.push(AlbumDir::from_path(&entry_path, root)?); + } else if entry_path.is_dir() + && let Some(dirname) = entry_path.file_name().and_then(|n| n.to_str()) + { + if dirname.starts_with("_") { + // Likely a templates or static dir + continue; + } else if dirname == "site" { + // Is a generated site dir, don't descend into it + continue; + } else if dirname == "slides" { + continue; } + + children.push(AlbumDir::from_path(&entry_path, root)?); } } diff --git a/src/generate/image.rs b/src/generate/image.rs index 1053d3f..91dc1c8 100644 --- a/src/generate/image.rs +++ b/src/generate/image.rs @@ -55,17 +55,15 @@ impl Image { /// return "blah.thumb" fn slide_filename(path: &Path, ext: &str, keep_ext: bool) -> anyhow::Result { let mut new_ext: OsString = ext.into(); - if keep_ext { - if let Some(e) = path.extension() { - new_ext = OsString::from( - ext.to_string() - + "." - + e.to_str().ok_or(anyhow!( - "Image {} extension is not valid UTF-8", - path.display() - ))?, - ) - } + if keep_ext && let Some(e) = path.extension() { + new_ext = OsString::from( + ext.to_string() + + "." + + e.to_str().ok_or(anyhow!( + "Image {} extension is not valid UTF-8", + path.display() + ))?, + ) } let new_path = path.with_extension(new_ext); diff --git a/src/lib.rs b/src/lib.rs index 1ca7b53..aa53f28 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +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/main.rs b/src/main.rs index cf1a837..c6b6cfe 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,17 @@ enum Commands { #[arg(long)] full: bool, }, + /// 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 + #[arg(long)] + dry_run: bool, + }, } diff --git a/src/reorganize.rs b/src/reorganize.rs new file mode 100644 index 0000000..0b2a007 --- /dev/null +++ b/src/reorganize.rs @@ -0,0 +1,243 @@ +use anyhow::{Context, anyhow}; +use image::ImageReader; +use std::ffi::OsStr; +use std::fs::{File, rename}; +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, UtcDateTime}; + +#[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<()> { + let renames = get_renames(dir)?; + + if renames.is_empty() { + 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(()) +} + +/// 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?; + + if !entry.path().is_file() { + continue; + } + + // 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() + .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 orig_filename = entry + .path() + .file_name() + .unwrap_or(OsStr::new("")) + .to_string_lossy() + .into_owned(); + + 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]_" + ))?; + + // 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 + &orig_filename) + .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)); + } + } + } + } + + // Sort renames by the destination + renames.sort_by_key(|(_, dst)| dst.clone()); + + 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 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); + 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: UtcDateTime = match &field.value { + exif::Value::Ascii(v) => { + let s = from_utf8(&v[0])?; + 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(), + } + } + _ => return Err(OrganizeError::ExifNoDateTime(path).into()), + }; + + Ok(dt) +} + +#[cfg(test)] +mod tests { + use super::*; + 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(); + 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] + fn exif_datetime_missing() { + init(); + let result = get_exif_datetime("resources/test_album/mountains.jpg".into()); + assert!(result.is_err()); + //result.unwrap(); + } + + #[test] + fn test_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("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] + /// get_renames() should ignore other stuff in the directory + fn test_other_junk() { + init(); + let tmp_album_dir = make_test_album(); + + let renames = get_renames(&tmp_album_dir).unwrap(); + // No mountain.jpg since it doesn't have EXIF data + assert_eq!( + renames, + vec![( + tmp_album_dir.join("moon.jpg"), + tmp_album_dir.join("19700101_133700_moon.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 test_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()); + } +} diff --git a/src/test_util.rs b/src/test_util.rs new file mode 100644 index 0000000..46a3d53 --- /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").canonicalize().unwrap(); + + 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 +}