Find image files and figure out renames based on EXIF metadata
This commit is contained in:
parent
16699d86a8
commit
89f6ea5335
6 changed files with 142 additions and 54 deletions
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 |
|
@ -310,37 +310,17 @@ mod tests {
|
|||
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() {
|
||||
init();
|
||||
let album_path = make_test_album();
|
||||
let output_path = generate(&album_path.to_path_buf(), false).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
|
||||
fn check_album(root_path: PathBuf) -> anyhow::Result<()> {
|
||||
log::debug!("Checking album dir {}", root_path.display());
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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<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?;
|
||||
|
||||
// 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<UtcDateTime> {
|
||||
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")),
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
25
src/test_util.rs
Normal file
25
src/test_util.rs
Normal 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");
|
||||
|
||||
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
|
||||
}
|
Loading…
Add table
Reference in a new issue