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

@@ -23,25 +23,43 @@ pub enum ResizeConfig {
impl ResizeConfig {
pub fn target_for(&self, original: Dimensions) -> Dimensions {
match self {
if original.width == 0 || original.height == 0 {
return original;
}
let result = match self {
Self::ByWidth(w) => {
if *w == 0 {
return original;
}
let scale = *w as f64 / original.width as f64;
Dimensions {
width: *w,
height: (original.height as f64 * scale).round() as u32,
height: (original.height as f64 * scale).round().max(1.0) as u32,
}
}
Self::ByHeight(h) => {
if *h == 0 {
return original;
}
let scale = *h as f64 / original.height as f64;
Dimensions {
width: (original.width as f64 * scale).round() as u32,
width: (original.width as f64 * scale).round().max(1.0) as u32,
height: *h,
}
}
Self::FitInBox { max, allow_upscale } => {
original.fit_within(*max, *allow_upscale)
}
Self::Exact(dims) => *dims,
Self::Exact(dims) => {
if dims.width == 0 || dims.height == 0 {
return original;
}
*dims
}
};
Dimensions {
width: result.width.max(1),
height: result.height.max(1),
}
}
}
@@ -224,6 +242,7 @@ pub enum WatermarkRotation {
Degrees45,
DegreesNeg45,
Degrees90,
Custom(f32),
}
// --- Adjustments ---
@@ -255,16 +274,16 @@ impl AdjustmentsConfig {
}
}
// --- Overwrite Behavior ---
// --- Overwrite Action (concrete action, no "Ask" variant) ---
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
pub enum OverwriteBehavior {
pub enum OverwriteAction {
AutoRename,
Overwrite,
Skip,
}
impl Default for OverwriteBehavior {
impl Default for OverwriteAction {
fn default() -> Self {
Self::AutoRename
}
@@ -278,39 +297,85 @@ pub struct RenameConfig {
pub suffix: String,
pub counter_start: u32,
pub counter_padding: u32,
#[serde(default)]
pub counter_enabled: bool,
/// 0=before prefix, 1=before name, 2=after name, 3=after suffix, 4=replace name
#[serde(default = "default_counter_position")]
pub counter_position: u32,
pub template: Option<String>,
/// 0=none, 1=lowercase, 2=uppercase, 3=title case
pub case_mode: u32,
/// 0=none, 1=underscore, 2=hyphen, 3=dot, 4=camelcase, 5=remove
#[serde(default)]
pub replace_spaces: u32,
/// 0=keep all, 1=filesystem-safe, 2=web-safe, 3=hyphens+underscores, 4=hyphens only, 5=alphanumeric only
#[serde(default)]
pub special_chars: u32,
pub regex_find: String,
pub regex_replace: String,
}
fn default_counter_position() -> u32 { 3 }
impl RenameConfig {
pub fn apply_simple(&self, original_name: &str, extension: &str, index: u32) -> String {
let counter = self.counter_start + index - 1;
let counter_str = format!(
"{:0>width$}",
counter,
width = self.counter_padding as usize
);
// Apply regex find-and-replace on the original name
// 1. Apply regex find-and-replace on the original name
let working_name = rename::apply_regex_replace(original_name, &self.regex_find, &self.regex_replace);
let mut name = String::new();
if !self.prefix.is_empty() {
name.push_str(&self.prefix);
}
name.push_str(&working_name);
if !self.suffix.is_empty() {
name.push_str(&self.suffix);
}
name.push('_');
name.push_str(&counter_str);
// 2. Apply space replacement
let working_name = rename::apply_space_replacement(&working_name, self.replace_spaces);
// Apply case conversion
let name = rename::apply_case_conversion(&name, self.case_mode);
// 3. Apply special character filtering
let working_name = rename::apply_special_chars(&working_name, self.special_chars);
format!("{}.{}", name, extension)
// 4. Build counter string
let counter_str = if self.counter_enabled {
let counter = self.counter_start.saturating_add(index.saturating_sub(1));
let padding = (self.counter_padding as usize).min(10);
format!("{:0>width$}", counter, width = padding)
} else {
String::new()
};
let has_counter = self.counter_enabled && !counter_str.is_empty();
// 5. Assemble parts based on counter position
// Positions: 0=before prefix, 1=before name, 2=after name, 3=after suffix, 4=replace name
let mut result = String::new();
if has_counter && self.counter_position == 0 {
result.push_str(&counter_str);
result.push('_');
}
result.push_str(&self.prefix);
if has_counter && self.counter_position == 1 {
result.push_str(&counter_str);
result.push('_');
}
if has_counter && self.counter_position == 4 {
result.push_str(&counter_str);
} else {
result.push_str(&working_name);
}
if has_counter && self.counter_position == 2 {
result.push('_');
result.push_str(&counter_str);
}
result.push_str(&self.suffix);
if has_counter && self.counter_position == 3 {
result.push('_');
result.push_str(&counter_str);
}
// 6. Apply case conversion
let result = rename::apply_case_conversion(&result, self.case_mode);
format!("{}.{}", result, extension)
}
}