From a666fbad058cee40061c29c094a90a7e6160a930 Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 6 Mar 2026 18:12:18 +0200 Subject: [PATCH] Fix pipeline order, add selective metadata stripping, rename case/regex - Move watermark step after compress in processing pipeline to match design doc order (resize, adjustments, convert, compress, metadata, watermark, rename) - Implement selective EXIF metadata stripping for Privacy and Custom modes using little_exif tag filtering (GPS, camera, software, timestamps, copyright categories) - Add case conversion support to rename (none/lower/upper/title) - Add regex find-and-replace on original filenames - Wire case and regex controls in rename step UI to JobConfig - Add regex crate dependency to pixstrip-core --- Cargo.lock | 1 + pixstrip-cli/src/main.rs | 3 + pixstrip-core/Cargo.toml | 1 + pixstrip-core/src/executor.rs | 113 +++++++++++++++++++++--- pixstrip-core/src/operations/mod.rs | 17 +++- pixstrip-core/src/operations/rename.rs | 37 ++++++++ pixstrip-core/src/preset.rs | 6 ++ pixstrip-core/tests/operations_tests.rs | 6 ++ pixstrip-gtk/src/app.rs | 12 +++ pixstrip-gtk/src/steps/step_rename.rs | 29 +++++- 10 files changed, 208 insertions(+), 17 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index bef2388..84dc2bd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1921,6 +1921,7 @@ dependencies = [ "notify", "oxipng", "rayon", + "regex", "serde", "serde_json", "tempfile", diff --git a/pixstrip-cli/src/main.rs b/pixstrip-cli/src/main.rs index efc1fd7..2b5759c 100644 --- a/pixstrip-cli/src/main.rs +++ b/pixstrip-cli/src/main.rs @@ -326,6 +326,9 @@ fn cmd_process(args: CmdProcessArgs) { counter_start: 1, counter_padding: 3, template: args.rename_template, + case_mode: 0, + regex_find: String::new(), + regex_replace: String::new(), }); } diff --git a/pixstrip-core/Cargo.toml b/pixstrip-core/Cargo.toml index cfb656b..4bcd817 100644 --- a/pixstrip-core/Cargo.toml +++ b/pixstrip-core/Cargo.toml @@ -21,6 +21,7 @@ imageproc = "0.25" ab_glyph = "0.2" dirs = "6" notify = "7" +regex = "1" [dev-dependencies] tempfile = "3" diff --git a/pixstrip-core/src/executor.rs b/pixstrip-core/src/executor.rs index e34008f..2460dba 100644 --- a/pixstrip-core/src/executor.rs +++ b/pixstrip-core/src/executor.rs @@ -356,11 +356,6 @@ impl PipelineExecutor { } } - // Watermark (after resize so watermark is at correct scale) - if let Some(ref config) = job.watermark { - img = apply_watermark(img, config)?; - } - // Determine output format let output_format = if let Some(ref convert) = job.convert { let input_fmt = source.original_format.unwrap_or(ImageFormat::Jpeg); @@ -401,9 +396,13 @@ impl PipelineExecutor { let dims = Some((img.width(), img.height())); let original_ext = source.path.extension() .and_then(|e| e.to_str()); + // Apply regex on the stem before template expansion + let working_stem = crate::operations::rename::apply_regex_replace( + stem, &rename.regex_find, &rename.regex_replace, + ); let new_name = crate::operations::rename::apply_template_full( template, - stem, + &working_stem, ext, rename.counter_start + index as u32, dims, @@ -411,6 +410,17 @@ impl PipelineExecutor { Some(&source.path), None, ); + // Apply case conversion to the final name (without extension) + let new_name = if rename.case_mode > 0 { + if let Some(dot_pos) = new_name.rfind('.') { + let (name_part, ext_part) = new_name.split_at(dot_pos); + format!("{}{}", crate::operations::rename::apply_case_conversion(name_part, rename.case_mode), ext_part) + } else { + crate::operations::rename::apply_case_conversion(&new_name, rename.case_mode) + } + } else { + new_name + }; job.output_dir.join(new_name) } else { let new_name = rename.apply_simple(stem, ext, index as u32 + 1); @@ -444,16 +454,31 @@ impl PipelineExecutor { std::fs::create_dir_all(parent).map_err(PixstripError::Io)?; } + // Watermark (after compress settings determined, before encode) + if let Some(ref config) = job.watermark { + img = apply_watermark(img, config)?; + } + // Encode and save encoder.encode_to_file(&img, &output_path, output_format, quality)?; - // Metadata stripping: re-encoding through the image crate naturally - // strips all EXIF/metadata. No additional action is needed for - // StripAll, Privacy, or Custom modes. KeepAll mode would require - // copying EXIF tags back from the source file using little_exif. + // Metadata handling: re-encoding strips all EXIF by default. + // KeepAll: copy everything back from source. + // Privacy/Custom: copy metadata back, then selectively strip certain tags. + // StripAll: do nothing (already stripped by re-encoding). if let Some(ref meta_config) = job.metadata { - if matches!(meta_config, crate::operations::MetadataConfig::KeepAll) { - copy_metadata_from_source(&source.path, &output_path); + match meta_config { + crate::operations::MetadataConfig::KeepAll => { + copy_metadata_from_source(&source.path, &output_path); + } + crate::operations::MetadataConfig::StripAll => { + // Already stripped by re-encoding - nothing to do + } + _ => { + // Privacy or Custom: copy all metadata back, then strip unwanted tags + copy_metadata_from_source(&source.path, &output_path); + strip_selective_metadata(&output_path, meta_config); + } } } @@ -509,3 +534,67 @@ fn copy_metadata_from_source(source: &std::path::Path, output: &std::path::Path) }; let _: std::result::Result<(), std::io::Error> = metadata.write_to_file(output); } + +fn strip_selective_metadata( + path: &std::path::Path, + config: &crate::operations::MetadataConfig, +) { + use little_exif::exif_tag::ExifTag; + use little_exif::metadata::Metadata; + + // Read the metadata we just wrote back + let Ok(source_meta) = Metadata::new_from_path(path) else { + return; + }; + + // Build a set of tag IDs to strip + let mut strip_ids: Vec = Vec::new(); + + if config.should_strip_gps() { + // GPSInfo pointer (0x8825) - removing it strips the GPS sub-IFD reference + strip_ids.push(ExifTag::GPSInfo(Vec::new()).as_u16()); + } + + if config.should_strip_camera() { + strip_ids.push(ExifTag::Make(String::new()).as_u16()); + strip_ids.push(ExifTag::Model(String::new()).as_u16()); + strip_ids.push(ExifTag::LensModel(String::new()).as_u16()); + strip_ids.push(ExifTag::LensMake(String::new()).as_u16()); + strip_ids.push(ExifTag::SerialNumber(String::new()).as_u16()); + strip_ids.push(ExifTag::LensSerialNumber(String::new()).as_u16()); + strip_ids.push(ExifTag::LensInfo(Vec::new()).as_u16()); + } + + if config.should_strip_software() { + strip_ids.push(ExifTag::Software(String::new()).as_u16()); + strip_ids.push(ExifTag::MakerNote(Vec::new()).as_u16()); + } + + if config.should_strip_timestamps() { + strip_ids.push(ExifTag::ModifyDate(String::new()).as_u16()); + strip_ids.push(ExifTag::DateTimeOriginal(String::new()).as_u16()); + strip_ids.push(ExifTag::CreateDate(String::new()).as_u16()); + strip_ids.push(ExifTag::SubSecTime(String::new()).as_u16()); + strip_ids.push(ExifTag::SubSecTimeOriginal(String::new()).as_u16()); + strip_ids.push(ExifTag::SubSecTimeDigitized(String::new()).as_u16()); + strip_ids.push(ExifTag::OffsetTime(String::new()).as_u16()); + strip_ids.push(ExifTag::OffsetTimeOriginal(String::new()).as_u16()); + strip_ids.push(ExifTag::OffsetTimeDigitized(String::new()).as_u16()); + } + + if config.should_strip_copyright() { + strip_ids.push(ExifTag::Copyright(String::new()).as_u16()); + strip_ids.push(ExifTag::Artist(String::new()).as_u16()); + strip_ids.push(ExifTag::OwnerName(String::new()).as_u16()); + } + + // Build new metadata with only the tags we want to keep + let mut new_meta = Metadata::new(); + for tag in source_meta.data() { + if !strip_ids.contains(&tag.as_u16()) { + new_meta.set_tag(tag.clone()); + } + } + + let _: std::result::Result<(), std::io::Error> = new_meta.write_to_file(path); +} diff --git a/pixstrip-core/src/operations/mod.rs b/pixstrip-core/src/operations/mod.rs index b1990bd..e2e722c 100644 --- a/pixstrip-core/src/operations/mod.rs +++ b/pixstrip-core/src/operations/mod.rs @@ -279,6 +279,10 @@ pub struct RenameConfig { pub counter_start: u32, pub counter_padding: u32, pub template: Option, + /// 0=none, 1=lowercase, 2=uppercase, 3=title case + pub case_mode: u32, + pub regex_find: String, + pub regex_replace: String, } impl RenameConfig { @@ -290,18 +294,23 @@ impl RenameConfig { width = self.counter_padding as usize ); + // 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(original_name); + name.push_str(&working_name); if !self.suffix.is_empty() { name.push_str(&self.suffix); } name.push('_'); name.push_str(&counter_str); - name.push('.'); - name.push_str(extension); - name + + // Apply case conversion + let name = rename::apply_case_conversion(&name, self.case_mode); + + format!("{}.{}", name, extension) } } diff --git a/pixstrip-core/src/operations/rename.rs b/pixstrip-core/src/operations/rename.rs index e3a3e98..fe0e686 100644 --- a/pixstrip-core/src/operations/rename.rs +++ b/pixstrip-core/src/operations/rename.rs @@ -149,6 +149,43 @@ fn is_leap(year: u64) -> bool { (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 } +/// 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 (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(), + } + }) + .collect::>() + .join("_") + } + _ => name.to_string(), + } +} + +/// 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::Regex::new(find) { + Ok(re) => re.replace_all(name, replace).into_owned(), + Err(_) => name.to_string(), + } +} + pub fn resolve_collision(path: &Path) -> PathBuf { if !path.exists() { return path.to_path_buf(); diff --git a/pixstrip-core/src/preset.rs b/pixstrip-core/src/preset.rs index fc55d63..63917b5 100644 --- a/pixstrip-core/src/preset.rs +++ b/pixstrip-core/src/preset.rs @@ -120,6 +120,9 @@ impl Preset { counter_start: 1, counter_padding: 3, template: None, + case_mode: 0, + regex_find: String::new(), + regex_replace: String::new(), }), } } @@ -177,6 +180,9 @@ impl Preset { counter_start: 1, counter_padding: 4, template: Some("{exif_date}_{name}_{counter:4}".into()), + case_mode: 0, + regex_find: String::new(), + regex_replace: String::new(), }), } } diff --git a/pixstrip-core/tests/operations_tests.rs b/pixstrip-core/tests/operations_tests.rs index 03baa7e..5c471a6 100644 --- a/pixstrip-core/tests/operations_tests.rs +++ b/pixstrip-core/tests/operations_tests.rs @@ -92,6 +92,9 @@ fn rename_config_simple_template() { counter_start: 1, counter_padding: 3, template: None, + case_mode: 0, + regex_find: String::new(), + regex_replace: String::new(), }; let result = config.apply_simple("sunset", "jpg", 1); assert_eq!(result, "blog_sunset_001.jpg"); @@ -105,6 +108,9 @@ fn rename_config_with_suffix() { counter_start: 1, counter_padding: 2, template: None, + case_mode: 0, + regex_find: String::new(), + regex_replace: String::new(), }; let result = config.apply_simple("photo", "webp", 5); assert_eq!(result, "photo_web_05.webp"); diff --git a/pixstrip-gtk/src/app.rs b/pixstrip-gtk/src/app.rs index 99663f8..53addaf 100644 --- a/pixstrip-gtk/src/app.rs +++ b/pixstrip-gtk/src/app.rs @@ -78,6 +78,9 @@ pub struct JobConfig { pub rename_counter_start: u32, pub rename_counter_padding: u32, pub rename_template: String, + pub rename_case: u32, // 0=none, 1=lower, 2=upper, 3=title + pub rename_find: String, + pub rename_replace: String, // Output pub preserve_dir_structure: bool, pub overwrite_behavior: u8, @@ -389,6 +392,9 @@ fn build_ui(app: &adw::Application) { rename_counter_start: 1, rename_counter_padding: 3, rename_template: String::new(), + rename_case: 0, + rename_find: String::new(), + rename_replace: String::new(), preserve_dir_structure: false, overwrite_behavior: match app_cfg.overwrite_behavior { pixstrip_core::config::OverwriteBehavior::Ask => 0, @@ -1847,6 +1853,9 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) { } else { Some(cfg.rename_template.clone()) }, + case_mode: cfg.rename_case, + regex_find: cfg.rename_find.clone(), + regex_replace: cfg.rename_replace.clone(), }); } @@ -2786,6 +2795,9 @@ fn build_preset_from_config(cfg: &JobConfig, name: &str) -> pixstrip_core::prese } else { Some(cfg.rename_template.clone()) }, + case_mode: cfg.rename_case, + regex_find: cfg.rename_find.clone(), + regex_replace: cfg.rename_replace.clone(), }) } else { None diff --git a/pixstrip-gtk/src/steps/step_rename.rs b/pixstrip-gtk/src/steps/step_rename.rs index 1614bab..edc74b3 100644 --- a/pixstrip-gtk/src/steps/step_rename.rs +++ b/pixstrip-gtk/src/steps/step_rename.rs @@ -264,6 +264,9 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage { counter_start: cfg.rename_counter_start, counter_padding: cfg.rename_counter_padding, template: None, + case_mode: cfg.rename_case, + regex_find: cfg.rename_find.clone(), + regex_replace: cfg.rename_replace.clone(), }; let result = rename_cfg.apply_simple(name, ext, (i + 1) as u32); @@ -319,12 +322,36 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage { } { let jc = state.job_config.clone(); - let up = update_preview; + let up = update_preview.clone(); template_row.connect_changed(move |row| { jc.borrow_mut().rename_template = row.text().to_string(); up(); }); } + { + let jc = state.job_config.clone(); + let up = update_preview.clone(); + case_row.connect_selected_notify(move |row| { + jc.borrow_mut().rename_case = row.selected(); + up(); + }); + } + { + let jc = state.job_config.clone(); + let up = update_preview.clone(); + find_row.connect_changed(move |row| { + jc.borrow_mut().rename_find = row.text().to_string(); + up(); + }); + } + { + let jc = state.job_config.clone(); + let up = update_preview; + replace_row.connect_changed(move |row| { + jc.borrow_mut().rename_replace = row.text().to_string(); + up(); + }); + } scrolled.set_child(Some(&content));