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:
parent
37581ee6a0
commit
aba9fa4025
10 changed files with 365 additions and 25 deletions
243
src/reorganize.rs
Normal file
243
src/reorganize.rs
Normal 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());
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue