Fix 26 bugs, edge cases, and consistency issues from fifth audit pass

Critical: undo toast now trashes only batch output files (not entire dir),
JPEG scanline write errors propagated, selective metadata write result returned.

High: zero-dimension guards in ResizeConfig/fit_within, negative aspect ratio
rejection, FM integration toggle infinite recursion guard, saturating counter
arithmetic in executor.

Medium: PNG compression level passed to oxipng, pct mode updates job_config,
external file loading updates step indicator, CLI undo removes history entries,
watch config write failures reported, fast-copy path reads image dimensions for
rename templates, discovery excludes unprocessable formats (heic/svg/ico/jxl),
CLI warns on invalid algorithm/overwrite values, resolve_collision trailing dot
fix, generation guards on all preview threads to cancel stale results, default
DPI aligned to 0, watermark text width uses char count not byte length.

Low: binary path escaped in Nautilus extension, file dialog filter aligned with
discovery, reset_wizard clears preset_mode and output_dir.
This commit is contained in:
2026-03-07 19:47:23 +02:00
parent 270a7db60d
commit b432cc7431
44 changed files with 5748 additions and 2221 deletions

View File

@@ -122,6 +122,9 @@ fn days_to_ymd(total_days: u64) -> (u64, u64, u64) {
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;
@@ -149,6 +152,46 @@ 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 {
@@ -156,20 +199,25 @@ pub fn apply_case_conversion(name: &str, case_mode: u32) -> String {
1 => name.to_lowercase(),
2 => name.to_uppercase(),
3 => {
// Title case: capitalize first letter of each word (split on _ - space)
name.split(|c: char| c == '_' || c == '-' || c == ' ')
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(first) => {
let upper: String = first.to_uppercase().collect();
upper + &chars.as_str().to_lowercase()
}
None => String::new(),
// 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);
}
})
.collect::<Vec<_>>()
.join("_")
capitalize_next = false;
} else {
for lc in c.to_lowercase() {
result.push(lc);
}
}
}
result
}
_ => name.to_string(),
}
@@ -180,7 +228,10 @@ pub fn apply_regex_replace(name: &str, find: &str, replace: &str) -> String {
if find.is_empty() {
return name.to_string();
}
match regex::Regex::new(find) {
match regex::RegexBuilder::new(find)
.size_limit(1 << 16)
.build()
{
Ok(re) => re.replace_all(name, replace).into_owned(),
Err(_) => name.to_string(),
}
@@ -213,5 +264,9 @@ pub fn resolve_collision(path: &Path) -> PathBuf {
}
// Fallback - should never happen with 1000 attempts
parent.join(format!("{}_{}.{}", stem, "overflow", ext))
if ext.is_empty() {
parent.join(format!("{}_overflow", stem))
} else {
parent.join(format!("{}_overflow.{}", stem, ext))
}
}