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::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() {
|
||||||
init();
|
|
||||||
let album_path = make_test_album();
|
let album_path = make_test_album();
|
||||||
let output_path = generate(&album_path.to_path_buf(), false).unwrap();
|
let output_path = generate(&album_path.to_path_buf(), false).unwrap();
|
||||||
|
|
||||||
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());
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
pub mod config;
|
pub(crate) mod config;
|
||||||
pub mod generate;
|
pub mod generate;
|
||||||
pub mod reorganize;
|
pub mod reorganize;
|
||||||
pub mod skel;
|
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::fs::File;
|
||||||
use std::io::BufReader;
|
use std::io::BufReader;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
use std::str::from_utf8;
|
use std::str::from_utf8;
|
||||||
use thiserror::Error;
|
use thiserror::Error;
|
||||||
use time::macros::format_description;
|
use time::macros::format_description;
|
||||||
use time::{OffsetDateTime, PrimitiveDateTime};
|
use time::{OffsetDateTime, PrimitiveDateTime, UtcDateTime};
|
||||||
|
|
||||||
#[derive(Error, Debug)]
|
#[derive(Error, Debug)]
|
||||||
pub enum OrganizeError {
|
pub enum OrganizeError {
|
||||||
|
@ -16,70 +17,130 @@ pub enum OrganizeError {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn reorganize(dir: &Path, dry_run: bool) -> anyhow::Result<()> {
|
pub fn reorganize(dir: &Path, dry_run: bool) -> anyhow::Result<()> {
|
||||||
// Run through all the images and figure out new names for them
|
let renames = get_renames(dir);
|
||||||
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
|
// Either do the renames, or if dry-run print what the names would be
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tries to figure out the datetime that t
|
/// Returns a vec of tuples of all the renames that need to happen in a directory
|
||||||
fn get_exif_datetime(path: PathBuf) -> anyhow::Result<()> {
|
fn get_renames(dir: &Path) -> anyhow::Result<Vec<(PathBuf, PathBuf)>> {
|
||||||
let DT_WITH_OFFSET = format_description!(
|
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]"
|
"[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]");
|
format_description!(version = 2, "[year]:[month]:[day] [hour]:[minute]:[second]");
|
||||||
|
|
||||||
let file = File::open(&path).with_context(|| format!("Couldn't open {}", path.display()))?;
|
let file = File::open(&path).with_context(|| format!("Couldn't open {}", path.display()))?;
|
||||||
let mut bufreader = BufReader::new(file);
|
let mut bufreader = BufReader::new(file);
|
||||||
// TODO: Return a better error if EXIF is not supported
|
// 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
|
let field = exif
|
||||||
.get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY)
|
.get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY)
|
||||||
.ok_or(OrganizeError::ExifNoDateTime(path.clone()))?;
|
.ok_or(OrganizeError::ExifNoDateTime(path.clone()))?;
|
||||||
|
|
||||||
let dt = match &field.value {
|
let dt: UtcDateTime = match &field.value {
|
||||||
exif::Value::Ascii(v) => {
|
exif::Value::Ascii(v) => {
|
||||||
let s = from_utf8(&v[0])?;
|
let s = from_utf8(&v[0])?;
|
||||||
log::debug!("Date string: {s}");
|
log::debug!("Date string from file: {s}");
|
||||||
log::debug!("{DT_WITH_OFFSET:?}");
|
|
||||||
match OffsetDateTime::parse(&s, DT_WITH_OFFSET) {
|
match OffsetDateTime::parse(&s, format_with_offset) {
|
||||||
Ok(v) => v,
|
Ok(v) => v.to_utc(),
|
||||||
Err(_) => {
|
Err(_) => PrimitiveDateTime::parse(&s, format_without_offset)?.as_utc(),
|
||||||
log::debug!("Unable to parse {s} with offset");
|
|
||||||
PrimitiveDateTime::parse(&s, DT_WITHOUT_OFFSET)?
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// TODO: return some error
|
||||||
_ => todo!(),
|
_ => todo!(),
|
||||||
};
|
};
|
||||||
println!("{dt:?}");
|
|
||||||
|
|
||||||
Ok(())
|
Ok(dt)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use crate::test_util::{init, make_test_album};
|
||||||
fn init() {
|
use time::{Date, Month, Time};
|
||||||
let _ = env_logger::builder().is_test(true).try_init();
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
/// Make sure we can get the datetime from one of our test photos
|
/// Make sure we can get the datetime from one of our test photos
|
||||||
fn basic_datetime_read() {
|
fn basic_datetime_read() {
|
||||||
init();
|
init();
|
||||||
let dt = get_exif_datetime("resources/test_album/moon.jpg".into()).unwrap();
|
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]
|
#[test]
|
||||||
|
@ -89,4 +150,23 @@ mod tests {
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
//result.unwrap();
|
//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