From 16699d86a837c2fc980358953d3b325efb83b6fd Mon Sep 17 00:00:00 2001 From: Nick Pegg Date: Sun, 11 May 2025 14:44:06 -0700 Subject: [PATCH 01/12] 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 37581ee6a07c955de11181ea81e1bd6179fa1aae Mon Sep 17 00:00:00 2001 From: Nick Pegg Date: Sun, 11 May 2025 14:44:38 -0700 Subject: [PATCH 02/12] fix .gitignore --- .gitignore | 2 +- resources/test_album/nested1/moon.jpg | Bin 0 -> 51463 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 resources/test_album/nested1/moon.jpg 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/resources/test_album/nested1/moon.jpg b/resources/test_album/nested1/moon.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ba2fc1153541c5e096b1f2f4df0a46e43de84cb6 GIT binary patch literal 51463 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 Date: Sat, 17 May 2025 18:08:42 -0700 Subject: [PATCH 03/12] 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 04/12] 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 05/12] 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 06/12] 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(()); } From aba9fa40257aaebe21254283dcd58e299ae55344 Mon Sep 17 00:00:00 2001 From: Nick Pegg Date: Sun, 18 May 2025 08:46:41 -0700 Subject: [PATCH 07/12] Reorganize command (#4) Adds a command to reorganize a folder of photos, renaming them so that they contain date and time so that they're sorted by that. This also renames files associated with the photos, like the descriptions, like IMG_1234.jpg with IMG_1234.md --- Cargo.lock | 69 +++++ Cargo.toml | 2 + resources/test_album/moon.jpg | Bin 51463 -> 51649 bytes .../test_album/with_description/moon.jpg | Bin 51463 -> 51649 bytes .../test_album/with_description/mountains.jpg | Bin 71986 -> 72172 bytes src/generate.rs | 28 +- src/lib.rs | 6 +- src/main.rs | 17 ++ src/reorganize.rs | 243 ++++++++++++++++++ src/test_util.rs | 25 ++ 10 files changed, 365 insertions(+), 25 deletions(-) create mode 100644 src/reorganize.rs create mode 100644 src/test_util.rs 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/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 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/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..f96c089 --- /dev/null +++ b/src/reorganize.rs @@ -0,0 +1,243 @@ +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?; + + 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 +} From b1d66d7e9f9b2fe8f9cc8b90f88099a47f9554cb Mon Sep 17 00:00:00 2001 From: Nick Pegg Date: Sun, 18 May 2025 08:47:59 -0700 Subject: [PATCH 08/12] bump version --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e2dd689..f317289 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -959,7 +959,7 @@ dependencies = [ [[package]] name = "photojawn" -version = "0.2.0-pre.1" +version = "0.2.0-pre.2" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index f965a74..4e7ec1f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "photojawn" -version = "0.2.0-pre.1" +version = "0.2.0-pre.2" description = "A static site generator for photo albums" authors = ["Nick Pegg "] license = "MIT" From 5ff3338b30cb30e60709e1375ea22bc3cdf65d89 Mon Sep 17 00:00:00 2001 From: Nick Pegg Date: Sun, 18 May 2025 08:52:26 -0700 Subject: [PATCH 09/12] Fix dep spec, no need for caret --- Cargo.toml | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 4e7ec1f..538cfd8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,21 +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" -kamadak-exif = "^0.6.1" -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" -time = { version = "^0.3.41", features = ["formatting", "macros", "parsing"] } +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_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" +mktemp = "0.5.1" From 9ae778bb791164038da521090a7da6608f243e0f Mon Sep 17 00:00:00 2001 From: Nick Pegg Date: Mon, 19 May 2025 10:22:24 -0700 Subject: [PATCH 10/12] bump version to 0.2.0 and update deps --- Cargo.lock | 46 +++++++++++++++++++++++----------------------- Cargo.toml | 2 +- 2 files changed, 24 insertions(+), 24 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index f317289..89b63cf 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", @@ -481,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", @@ -520,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", ] @@ -537,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" @@ -659,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", @@ -672,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", @@ -687,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", ] @@ -959,7 +959,7 @@ dependencies = [ [[package]] name = "photojawn" -version = "0.2.0-pre.2" +version = "0.2.0" dependencies = [ "anyhow", "clap", @@ -1062,7 +1062,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", @@ -1830,9 +1830,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", ] @@ -1843,7 +1843,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 538cfd8..bade3fa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "photojawn" -version = "0.2.0-pre.2" +version = "0.2.0" description = "A static site generator for photo albums" authors = ["Nick Pegg "] license = "MIT" From 39d449889d8b3ca36136158f3c9bd68946eb32e1 Mon Sep 17 00:00:00 2001 From: Nick Pegg Date: Thu, 25 Sep 2025 15:04:59 -0700 Subject: [PATCH 11/12] serde_yml -> serde_yaml_ng --- Cargo.lock | 28 +++++++++++----------------- Cargo.toml | 2 +- src/config.rs | 6 +++--- 3 files changed, 15 insertions(+), 21 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 89b63cf..4bebd05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -744,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" @@ -973,7 +963,7 @@ dependencies = [ "pulldown-cmark", "rayon", "serde", - "serde_yml", + "serde_yaml_ng", "tera", "thiserror 2.0.12", "time", @@ -1303,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]] @@ -1609,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" diff --git a/Cargo.toml b/Cargo.toml index bade3fa..7fd88d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ log = "0.4.27" pulldown-cmark = "0.13.0" rayon = "1.10" serde = { version = "1.0", features = ["derive"] } -serde_yml = "0.0.12" +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"] } 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); From 35cb7949fdbdecfda56dfcd04833eb254e1fb15e Mon Sep 17 00:00:00 2001 From: Nick Pegg Date: Thu, 25 Sep 2025 15:13:16 -0700 Subject: [PATCH 12/12] fix warnings, fmt --- src/generate/album_dir.rs | 28 ++++++++++++++-------------- src/generate/image.rs | 20 +++++++++----------- src/reorganize.rs | 4 ++-- 3 files changed, 25 insertions(+), 27 deletions(-) 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/reorganize.rs b/src/reorganize.rs index f96c089..0b2a007 100644 --- a/src/reorganize.rs +++ b/src/reorganize.rs @@ -1,7 +1,7 @@ -use anyhow::{anyhow, Context}; +use anyhow::{Context, anyhow}; use image::ImageReader; use std::ffi::OsStr; -use std::fs::{rename, File}; +use std::fs::{File, rename}; use std::io::BufReader; use std::path::{Path, PathBuf}; use std::str::from_utf8;