diff --git a/.gitignore b/.gitignore index 50bfee3..dc17db2 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 e2dd689..6b81d0f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -349,15 +349,6 @@ 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" @@ -707,15 +698,6 @@ 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" @@ -810,12 +792,6 @@ 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" @@ -848,12 +824,6 @@ 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" @@ -967,7 +937,6 @@ dependencies = [ "fs_extra", "image", "indicatif", - "kamadak-exif", "log", "mktemp", "pulldown-cmark", @@ -976,7 +945,6 @@ dependencies = [ "serde_yml", "tera", "thiserror 2.0.12", - "time", ] [[package]] @@ -1013,12 +981,6 @@ 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" @@ -1458,37 +1420,6 @@ 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 f965a74..b2dab69 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,6 @@ 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" @@ -22,7 +21,6 @@ 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 5136493..ba2fc11 100644 Binary files a/resources/test_album/moon.jpg and b/resources/test_album/moon.jpg differ diff --git a/resources/test_album/with_description/moon.jpg b/resources/test_album/with_description/moon.jpg index aab6f80..ba2fc11 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 085dfbe..59a3b1f 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 4f17e55..767350b 100644 --- a/src/generate.rs +++ b/src/generate.rs @@ -98,8 +98,7 @@ 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) - .with_context(|| format!("Failed to read image {}", &img.path.display()))?; + let orig_image = ::image::open(&img.path)?; let thumb_path = output_path.join(&img.thumb_path); log::info!( "Resizing {} -> {}", @@ -304,12 +303,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() { @@ -320,6 +319,27 @@ 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/lib.rs b/src/lib.rs index aa53f28..1ca7b53 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,7 +1,3 @@ -pub(crate) mod config; +pub 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 c6b6cfe..cf1a837 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,5 @@ use clap::{Parser, Subcommand}; use photojawn::generate::generate; -use photojawn::reorganize::reorganize; use photojawn::skel::make_skeleton; use std::path::Path; @@ -19,9 +18,6 @@ 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(()) @@ -48,17 +44,4 @@ 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 deleted file mode 100644 index dcf3f00..0000000 --- a/src/reorganize.rs +++ /dev/null @@ -1,224 +0,0 @@ -use anyhow::{anyhow, Context}; -use image::ImageReader; -use std::ffi::OsStr; -use std::fs::{rename, 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, 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?; - - // 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); - // TODO: Return a better error if EXIF is not supported - 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(), - } - } - // TODO: return some error - _ => todo!(), - }; - - 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 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] - /// 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()); - } -} diff --git a/src/test_util.rs b/src/test_util.rs deleted file mode 100644 index 46a3d53..0000000 --- a/src/test_util.rs +++ /dev/null @@ -1,25 +0,0 @@ -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 -}