Reorganize command (#4)
Some checks are pending
Rust / build (push) Waiting to run

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
This commit is contained in:
Nick Pegg 2025-05-18 08:46:41 -07:00 committed by GitHub
parent 37581ee6a0
commit aba9fa4025
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 365 additions and 25 deletions

69
Cargo.lock generated
View file

@ -349,6 +349,15 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "deranged"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e"
dependencies = [
"powerfmt",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@ -698,6 +707,15 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "lazy_static" name = "lazy_static"
version = "1.5.0" version = "1.5.0"
@ -792,6 +810,12 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "mutate_once"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "16cf681a23b4d0a43fc35024c176437f9dcd818db34e0f42ab456a0ee5ad497b"
[[package]] [[package]]
name = "new_debug_unreachable" name = "new_debug_unreachable"
version = "1.0.6" version = "1.0.6"
@ -824,6 +848,12 @@ dependencies = [
"num-traits", "num-traits",
] ]
[[package]]
name = "num-conv"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9"
[[package]] [[package]]
name = "num-derive" name = "num-derive"
version = "0.4.2" version = "0.4.2"
@ -937,6 +967,7 @@ dependencies = [
"fs_extra", "fs_extra",
"image", "image",
"indicatif", "indicatif",
"kamadak-exif",
"log", "log",
"mktemp", "mktemp",
"pulldown-cmark", "pulldown-cmark",
@ -945,6 +976,7 @@ dependencies = [
"serde_yml", "serde_yml",
"tera", "tera",
"thiserror 2.0.12", "thiserror 2.0.12",
"time",
] ]
[[package]] [[package]]
@ -981,6 +1013,12 @@ dependencies = [
"portable-atomic", "portable-atomic",
] ]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]] [[package]]
name = "ppv-lite86" name = "ppv-lite86"
version = "0.2.21" version = "0.2.21"
@ -1420,6 +1458,37 @@ dependencies = [
"weezl", "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]] [[package]]
name = "toml" name = "toml"
version = "0.8.22" version = "0.8.22"

View file

@ -14,6 +14,7 @@ env_logger = "^0.11.8"
fs_extra = "^1.3.0" fs_extra = "^1.3.0"
image = "^0.25.6" image = "^0.25.6"
indicatif = "^0.17.11" indicatif = "^0.17.11"
kamadak-exif = "^0.6.1"
log = "^0.4.27" log = "^0.4.27"
pulldown-cmark = "^0.13.0" pulldown-cmark = "^0.13.0"
rayon = "^1.10.0" rayon = "^1.10.0"
@ -21,6 +22,7 @@ serde = { version = "^1.0", features = ["derive"] }
serde_yml = "^0.0.12" serde_yml = "^0.0.12"
tera = { version = "^1.20", default-features = false } tera = { version = "^1.20", default-features = false }
thiserror = "^2.0" thiserror = "^2.0"
time = { version = "^0.3.41", features = ["formatting", "macros", "parsing"] }
[dev-dependencies] [dev-dependencies]
mktemp = "^0.5.1" mktemp = "^0.5.1"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 70 KiB

View file

@ -98,7 +98,8 @@ fn generate_images(config: &Config, album: &AlbumDir, full: bool) -> anyhow::Res
fs::hard_link(&img.path, &full_size_path) fs::hard_link(&img.path, &full_size_path)
.with_context(|| format!("Error creating hard link at {}", full_size_path.display()))?; .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); let thumb_path = output_path.join(&img.thumb_path);
log::info!( log::info!(
"Resizing {} -> {}", "Resizing {} -> {}",
@ -303,12 +304,12 @@ struct SlideContext {
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::generate; use super::generate;
use crate::skel::make_skeleton;
use mktemp::Temp;
use std::collections::{HashSet, VecDeque}; use std::collections::{HashSet, VecDeque};
use std::ffi::OsStr; use std::ffi::OsStr;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use crate::test_util::{init, make_test_album};
#[test] #[test]
/// Test that the generate function creates a rendered site as we expect it /// Test that the generate function creates a rendered site as we expect it
fn test_generate() { fn test_generate() {
@ -319,27 +320,6 @@ mod tests {
check_album(output_path).unwrap(); check_album(output_path).unwrap();
} }
fn init() {
let _ = env_logger::builder().is_test(true).try_init();
}
/// Copies the test album to a tempdir and returns the path to it
fn make_test_album() -> Temp {
let tmpdir = Temp::new_dir().unwrap();
let source_path = Path::new("resources/test_album");
log::info!("Creating test album in {}", tmpdir.display());
make_skeleton(&tmpdir.to_path_buf()).unwrap();
fs_extra::dir::copy(
&source_path,
&tmpdir,
&fs_extra::dir::CopyOptions::new().content_only(true),
)
.unwrap();
tmpdir
}
/// Does basic sanity checks on an output album /// Does basic sanity checks on an output album
fn check_album(root_path: PathBuf) -> anyhow::Result<()> { fn check_album(root_path: PathBuf) -> anyhow::Result<()> {
log::debug!("Checking album dir {}", root_path.display()); log::debug!("Checking album dir {}", root_path.display());

View file

@ -1,3 +1,7 @@
pub mod config; pub(crate) mod config;
pub mod generate; pub mod generate;
pub mod reorganize;
pub mod skel; pub mod skel;
#[cfg(test)]
pub(crate) mod test_util;

View file

@ -1,5 +1,6 @@
use clap::{Parser, Subcommand}; use clap::{Parser, Subcommand};
use photojawn::generate::generate; use photojawn::generate::generate;
use photojawn::reorganize::reorganize;
use photojawn::skel::make_skeleton; use photojawn::skel::make_skeleton;
use std::path::Path; use std::path::Path;
@ -18,6 +19,9 @@ fn main() -> anyhow::Result<()> {
let path = generate(&album_path.to_path_buf(), full)?; let path = generate(&album_path.to_path_buf(), full)?;
println!("Album site generated in {}", path.display()); println!("Album site generated in {}", path.display());
} }
Commands::Reorganize { path, dry_run } => {
reorganize(Path::new(&path), dry_run)?;
}
} }
Ok(()) Ok(())
@ -44,4 +48,17 @@ enum Commands {
#[arg(long)] #[arg(long)]
full: bool, 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,
},
} }

243
src/reorganize.rs Normal file
View file

@ -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<PathBuf>),
#[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<Vec<(PathBuf, PathBuf)>> {
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<UtcDateTime> {
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());
}
}

25
src/test_util.rs Normal file
View file

@ -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
}