From 5104d66aafdc4392f979a5b763f8aee71f92b73e Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 6 Mar 2026 18:03:12 +0200 Subject: [PATCH] Add EXIF-based rename template variables Support {date}, {exif_date}, {camera}, and {original_ext} in rename templates. Reads DateTimeOriginal and Model from EXIF metadata via little_exif when these variables are used in templates. --- pixstrip-core/src/executor.rs | 7 +- pixstrip-core/src/operations/rename.rs | 116 +++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/pixstrip-core/src/executor.rs b/pixstrip-core/src/executor.rs index c476eef..e34008f 100644 --- a/pixstrip-core/src/executor.rs +++ b/pixstrip-core/src/executor.rs @@ -399,12 +399,17 @@ impl PipelineExecutor { if let Some(ref template) = rename.template { let dims = Some((img.width(), img.height())); - let new_name = crate::operations::rename::apply_template( + let original_ext = source.path.extension() + .and_then(|e| e.to_str()); + let new_name = crate::operations::rename::apply_template_full( template, stem, ext, rename.counter_start + index as u32, dims, + original_ext, + Some(&source.path), + None, ); job.output_dir.join(new_name) } else { diff --git a/pixstrip-core/src/operations/rename.rs b/pixstrip-core/src/operations/rename.rs index 1a46235..e3a3e98 100644 --- a/pixstrip-core/src/operations/rename.rs +++ b/pixstrip-core/src/operations/rename.rs @@ -6,11 +6,25 @@ pub fn apply_template( ext: &str, counter: u32, dimensions: Option<(u32, u32)>, +) -> String { + apply_template_full(template, name, ext, counter, dimensions, None, None, None) +} + +pub fn apply_template_full( + template: &str, + name: &str, + ext: &str, + counter: u32, + dimensions: Option<(u32, u32)>, + original_ext: Option<&str>, + source_path: Option<&Path>, + exif_info: Option<&ExifRenameInfo>, ) -> String { let mut result = template.to_string(); result = result.replace("{name}", name); result = result.replace("{ext}", ext); + result = result.replace("{original_ext}", original_ext.unwrap_or(ext)); // Handle {counter:N} with padding if let Some(start) = result.find("{counter:") { @@ -30,9 +44,111 @@ pub fn apply_template( result = result.replace("{height}", &h.to_string()); } + // {date} - today's date + if result.contains("{date}") { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let days = now / 86400; + // Simple date calculation (good enough for filenames) + let (y, m, d) = days_to_ymd(days); + result = result.replace("{date}", &format!("{:04}-{:02}-{:02}", y, m, d)); + } + + // EXIF-based variables + if let Some(info) = exif_info { + result = result.replace("{exif_date}", &info.date_taken); + result = result.replace("{camera}", &info.camera_model); + } else if result.contains("{exif_date}") || result.contains("{camera}") { + // Try to read EXIF from source file + let info = source_path.map(read_exif_rename_info).unwrap_or_default(); + result = result.replace("{exif_date}", &info.date_taken); + result = result.replace("{camera}", &info.camera_model); + } + result } +#[derive(Default)] +pub struct ExifRenameInfo { + pub date_taken: String, + pub camera_model: String, +} + +fn read_exif_rename_info(path: &Path) -> ExifRenameInfo { + let Ok(metadata) = little_exif::metadata::Metadata::new_from_path(path) else { + return ExifRenameInfo::default(); + }; + + let mut info = ExifRenameInfo::default(); + let endian = metadata.get_endian(); + + // Read DateTimeOriginal + if let Some(tag) = metadata.get_tag(&little_exif::exif_tag::ExifTag::DateTimeOriginal(String::new())) { + let bytes = tag.value_as_u8_vec(endian); + if let Ok(s) = std::str::from_utf8(&bytes) { + // Convert "2026:03:06 14:30:00" to "2026-03-06" + let clean = s.trim_end_matches('\0'); + if let Some(date_part) = clean.split(' ').next() { + info.date_taken = date_part.replace(':', "-"); + } + } + } + + // Read camera Model + if let Some(tag) = metadata.get_tag(&little_exif::exif_tag::ExifTag::Model(String::new())) { + let bytes = tag.value_as_u8_vec(endian); + if let Ok(s) = std::str::from_utf8(&bytes) { + info.camera_model = s.trim_end_matches('\0') + .replace(' ', "_") + .replace('/', "-") + .to_string(); + } + } + + if info.date_taken.is_empty() { + info.date_taken = "unknown-date".to_string(); + } + if info.camera_model.is_empty() { + info.camera_model = "unknown-camera".to_string(); + } + + info +} + +fn days_to_ymd(total_days: u64) -> (u64, u64, u64) { + // Simplified Gregorian calendar calculation + let mut days = total_days; + let mut year = 1970u64; + loop { + let days_in_year = if is_leap(year) { 366 } else { 365 }; + if days < days_in_year { + break; + } + days -= days_in_year; + year += 1; + } + let months_days: [u64; 12] = if is_leap(year) { + [31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + } else { + [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + }; + let mut month = 1u64; + for md in &months_days { + if days < *md { + break; + } + days -= md; + month += 1; + } + (year, month, days + 1) +} + +fn is_leap(year: u64) -> bool { + (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 +} + pub fn resolve_collision(path: &Path) -> PathBuf { if !path.exists() { return path.to_path_buf();