- PNG chunk parsing overflow protection with checked arithmetic - Font directory traversal bounded with global result limit - find_unique_path TOCTOU race fixed with create_new + marker byte - Watch mode "processed" dir exclusion narrowed to prevent false skips - Metadata copy now checks format support before little_exif calls - Clipboard temp files cleaned up on app exit - Atomic writes for file manager integration scripts - BMP format support added to encoder and convert step - Regex DoS protection with DFA size limit - Watermark NaN/negative scale guard - Selective EXIF stripping for privacy/custom metadata modes - CLI watch mode: file stability checks, per-file history saves - High contrast toggle preserves and restores original theme - Image list deduplication uses O(1) HashSet lookups - Saturation/trim/padding overflow guards in adjustments
301 lines
9.8 KiB
Rust
301 lines
9.8 KiB
Rust
use std::path::{Path, PathBuf};
|
|
|
|
pub fn apply_template(
|
|
template: &str,
|
|
name: &str,
|
|
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:") {
|
|
if let Some(end) = result[start..].find('}') {
|
|
let spec = &result[start + 9..start + end];
|
|
if let Ok(padding) = spec.parse::<usize>() {
|
|
let padding = padding.min(10);
|
|
let counter_str = format!("{:0>width$}", counter, width = padding);
|
|
result = format!("{}{}{}", &result[..start], counter_str, &result[start + end + 1..]);
|
|
}
|
|
}
|
|
} else {
|
|
result = result.replace("{counter}", &counter.to_string());
|
|
}
|
|
|
|
if let Some((w, h)) = dimensions {
|
|
result = result.replace("{width}", &w.to_string());
|
|
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 {
|
|
if year > 9999 {
|
|
break;
|
|
}
|
|
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
|
|
}
|
|
|
|
/// Apply space replacement on a filename
|
|
/// mode: 0=none, 1=underscore, 2=hyphen, 3=dot, 4=camelcase, 5=remove
|
|
pub fn apply_space_replacement(name: &str, mode: u32) -> String {
|
|
match mode {
|
|
1 => name.replace(' ', "_"),
|
|
2 => name.replace(' ', "-"),
|
|
3 => name.replace(' ', "."),
|
|
4 => {
|
|
let mut result = String::with_capacity(name.len());
|
|
let mut capitalize_next = false;
|
|
for ch in name.chars() {
|
|
if ch == ' ' {
|
|
capitalize_next = true;
|
|
} else if capitalize_next {
|
|
result.extend(ch.to_uppercase());
|
|
capitalize_next = false;
|
|
} else {
|
|
result.push(ch);
|
|
}
|
|
}
|
|
result
|
|
}
|
|
5 => name.replace(' ', ""),
|
|
_ => name.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Apply special character filtering on a filename
|
|
/// mode: 0=keep all, 1=filesystem-safe, 2=web-safe, 3=hyphens+underscores, 4=hyphens only, 5=alphanumeric only
|
|
pub fn apply_special_chars(name: &str, mode: u32) -> String {
|
|
match mode {
|
|
1 => name.chars().filter(|c| !matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')).collect(),
|
|
2 => name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.')).collect(),
|
|
3 => name.chars().filter(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')).collect(),
|
|
4 => name.chars().filter(|c| c.is_ascii_alphanumeric() || *c == '-').collect(),
|
|
5 => name.chars().filter(|c| c.is_ascii_alphanumeric()).collect(),
|
|
_ => name.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Apply case conversion to a filename (without extension)
|
|
/// case_mode: 0=none, 1=lowercase, 2=uppercase, 3=title case
|
|
pub fn apply_case_conversion(name: &str, case_mode: u32) -> String {
|
|
match case_mode {
|
|
1 => name.to_lowercase(),
|
|
2 => name.to_uppercase(),
|
|
3 => {
|
|
// Title case: capitalize first letter of each word, preserve original separators
|
|
let mut result = String::with_capacity(name.len());
|
|
let mut capitalize_next = true;
|
|
for c in name.chars() {
|
|
if c == '_' || c == '-' || c == ' ' {
|
|
result.push(c);
|
|
capitalize_next = true;
|
|
} else if capitalize_next {
|
|
for uc in c.to_uppercase() {
|
|
result.push(uc);
|
|
}
|
|
capitalize_next = false;
|
|
} else {
|
|
for lc in c.to_lowercase() {
|
|
result.push(lc);
|
|
}
|
|
}
|
|
}
|
|
result
|
|
}
|
|
_ => name.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Pre-compile a regex for batch use. Returns None (with message) if invalid.
|
|
pub fn compile_rename_regex(find: &str) -> Option<regex::Regex> {
|
|
if find.is_empty() {
|
|
return None;
|
|
}
|
|
regex::RegexBuilder::new(find)
|
|
.size_limit(1 << 16)
|
|
.dfa_size_limit(1 << 16)
|
|
.build()
|
|
.ok()
|
|
}
|
|
|
|
/// Apply regex find-and-replace on a filename
|
|
pub fn apply_regex_replace(name: &str, find: &str, replace: &str) -> String {
|
|
if find.is_empty() {
|
|
return name.to_string();
|
|
}
|
|
match regex::RegexBuilder::new(find)
|
|
.size_limit(1 << 16)
|
|
.dfa_size_limit(1 << 16)
|
|
.build()
|
|
{
|
|
Ok(re) => re.replace_all(name, replace).into_owned(),
|
|
Err(_) => name.to_string(),
|
|
}
|
|
}
|
|
|
|
/// Apply a pre-compiled regex find-and-replace on a filename
|
|
pub fn apply_regex_replace_compiled(name: &str, re: ®ex::Regex, replace: &str) -> String {
|
|
re.replace_all(name, replace).into_owned()
|
|
}
|
|
|
|
pub fn resolve_collision(path: &Path) -> PathBuf {
|
|
// Use create_new (O_CREAT|O_EXCL) for atomic reservation, preventing TOCTOU races
|
|
match std::fs::OpenOptions::new().write(true).create_new(true).open(path) {
|
|
Ok(_) => return path.to_path_buf(),
|
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
|
|
Err(_) => return path.to_path_buf(), // other errors (e.g. permission) - let caller handle
|
|
}
|
|
|
|
let parent = path.parent().unwrap_or(Path::new("."));
|
|
let stem = path
|
|
.file_stem()
|
|
.and_then(|s| s.to_str())
|
|
.unwrap_or("file");
|
|
let ext = path
|
|
.extension()
|
|
.and_then(|e| e.to_str())
|
|
.unwrap_or("");
|
|
|
|
for i in 1..1000 {
|
|
let candidate = if ext.is_empty() {
|
|
parent.join(format!("{}_{}", stem, i))
|
|
} else {
|
|
parent.join(format!("{}_{}.{}", stem, i, ext))
|
|
};
|
|
match std::fs::OpenOptions::new().write(true).create_new(true).open(&candidate) {
|
|
Ok(_) => return candidate,
|
|
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue,
|
|
Err(_) => continue,
|
|
}
|
|
}
|
|
|
|
// Fallback with timestamp for uniqueness
|
|
let ts = std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.map(|d| d.as_millis())
|
|
.unwrap_or(0);
|
|
if ext.is_empty() {
|
|
parent.join(format!("{}_{}", stem, ts))
|
|
} else {
|
|
parent.join(format!("{}_{}.{}", stem, ts, ext))
|
|
}
|
|
}
|