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
This commit is contained in:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -1921,6 +1921,7 @@ dependencies = [
|
|||||||
"notify",
|
"notify",
|
||||||
"oxipng",
|
"oxipng",
|
||||||
"rayon",
|
"rayon",
|
||||||
|
"regex",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
|
|||||||
@@ -326,6 +326,9 @@ fn cmd_process(args: CmdProcessArgs) {
|
|||||||
counter_start: 1,
|
counter_start: 1,
|
||||||
counter_padding: 3,
|
counter_padding: 3,
|
||||||
template: args.rename_template,
|
template: args.rename_template,
|
||||||
|
case_mode: 0,
|
||||||
|
regex_find: String::new(),
|
||||||
|
regex_replace: String::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ imageproc = "0.25"
|
|||||||
ab_glyph = "0.2"
|
ab_glyph = "0.2"
|
||||||
dirs = "6"
|
dirs = "6"
|
||||||
notify = "7"
|
notify = "7"
|
||||||
|
regex = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|||||||
@@ -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
|
// Determine output format
|
||||||
let output_format = if let Some(ref convert) = job.convert {
|
let output_format = if let Some(ref convert) = job.convert {
|
||||||
let input_fmt = source.original_format.unwrap_or(ImageFormat::Jpeg);
|
let input_fmt = source.original_format.unwrap_or(ImageFormat::Jpeg);
|
||||||
@@ -401,9 +396,13 @@ impl PipelineExecutor {
|
|||||||
let dims = Some((img.width(), img.height()));
|
let dims = Some((img.width(), img.height()));
|
||||||
let original_ext = source.path.extension()
|
let original_ext = source.path.extension()
|
||||||
.and_then(|e| e.to_str());
|
.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(
|
let new_name = crate::operations::rename::apply_template_full(
|
||||||
template,
|
template,
|
||||||
stem,
|
&working_stem,
|
||||||
ext,
|
ext,
|
||||||
rename.counter_start + index as u32,
|
rename.counter_start + index as u32,
|
||||||
dims,
|
dims,
|
||||||
@@ -411,6 +410,17 @@ impl PipelineExecutor {
|
|||||||
Some(&source.path),
|
Some(&source.path),
|
||||||
None,
|
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)
|
job.output_dir.join(new_name)
|
||||||
} else {
|
} else {
|
||||||
let new_name = rename.apply_simple(stem, ext, index as u32 + 1);
|
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)?;
|
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
|
// Encode and save
|
||||||
encoder.encode_to_file(&img, &output_path, output_format, quality)?;
|
encoder.encode_to_file(&img, &output_path, output_format, quality)?;
|
||||||
|
|
||||||
// Metadata stripping: re-encoding through the image crate naturally
|
// Metadata handling: re-encoding strips all EXIF by default.
|
||||||
// strips all EXIF/metadata. No additional action is needed for
|
// KeepAll: copy everything back from source.
|
||||||
// StripAll, Privacy, or Custom modes. KeepAll mode would require
|
// Privacy/Custom: copy metadata back, then selectively strip certain tags.
|
||||||
// copying EXIF tags back from the source file using little_exif.
|
// StripAll: do nothing (already stripped by re-encoding).
|
||||||
if let Some(ref meta_config) = job.metadata {
|
if let Some(ref meta_config) = job.metadata {
|
||||||
if matches!(meta_config, crate::operations::MetadataConfig::KeepAll) {
|
match meta_config {
|
||||||
copy_metadata_from_source(&source.path, &output_path);
|
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);
|
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<u16> = 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -279,6 +279,10 @@ pub struct RenameConfig {
|
|||||||
pub counter_start: u32,
|
pub counter_start: u32,
|
||||||
pub counter_padding: u32,
|
pub counter_padding: u32,
|
||||||
pub template: Option<String>,
|
pub template: Option<String>,
|
||||||
|
/// 0=none, 1=lowercase, 2=uppercase, 3=title case
|
||||||
|
pub case_mode: u32,
|
||||||
|
pub regex_find: String,
|
||||||
|
pub regex_replace: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RenameConfig {
|
impl RenameConfig {
|
||||||
@@ -290,18 +294,23 @@ impl RenameConfig {
|
|||||||
width = self.counter_padding as usize
|
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();
|
let mut name = String::new();
|
||||||
if !self.prefix.is_empty() {
|
if !self.prefix.is_empty() {
|
||||||
name.push_str(&self.prefix);
|
name.push_str(&self.prefix);
|
||||||
}
|
}
|
||||||
name.push_str(original_name);
|
name.push_str(&working_name);
|
||||||
if !self.suffix.is_empty() {
|
if !self.suffix.is_empty() {
|
||||||
name.push_str(&self.suffix);
|
name.push_str(&self.suffix);
|
||||||
}
|
}
|
||||||
name.push('_');
|
name.push('_');
|
||||||
name.push_str(&counter_str);
|
name.push_str(&counter_str);
|
||||||
name.push('.');
|
|
||||||
name.push_str(extension);
|
// Apply case conversion
|
||||||
name
|
let name = rename::apply_case_conversion(&name, self.case_mode);
|
||||||
|
|
||||||
|
format!("{}.{}", name, extension)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -149,6 +149,43 @@ fn is_leap(year: u64) -> bool {
|
|||||||
(year % 4 == 0 && year % 100 != 0) || year % 400 == 0
|
(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::<Vec<_>>()
|
||||||
|
.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 {
|
pub fn resolve_collision(path: &Path) -> PathBuf {
|
||||||
if !path.exists() {
|
if !path.exists() {
|
||||||
return path.to_path_buf();
|
return path.to_path_buf();
|
||||||
|
|||||||
@@ -120,6 +120,9 @@ impl Preset {
|
|||||||
counter_start: 1,
|
counter_start: 1,
|
||||||
counter_padding: 3,
|
counter_padding: 3,
|
||||||
template: None,
|
template: None,
|
||||||
|
case_mode: 0,
|
||||||
|
regex_find: String::new(),
|
||||||
|
regex_replace: String::new(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -177,6 +180,9 @@ impl Preset {
|
|||||||
counter_start: 1,
|
counter_start: 1,
|
||||||
counter_padding: 4,
|
counter_padding: 4,
|
||||||
template: Some("{exif_date}_{name}_{counter:4}".into()),
|
template: Some("{exif_date}_{name}_{counter:4}".into()),
|
||||||
|
case_mode: 0,
|
||||||
|
regex_find: String::new(),
|
||||||
|
regex_replace: String::new(),
|
||||||
}),
|
}),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,6 +92,9 @@ fn rename_config_simple_template() {
|
|||||||
counter_start: 1,
|
counter_start: 1,
|
||||||
counter_padding: 3,
|
counter_padding: 3,
|
||||||
template: None,
|
template: None,
|
||||||
|
case_mode: 0,
|
||||||
|
regex_find: String::new(),
|
||||||
|
regex_replace: String::new(),
|
||||||
};
|
};
|
||||||
let result = config.apply_simple("sunset", "jpg", 1);
|
let result = config.apply_simple("sunset", "jpg", 1);
|
||||||
assert_eq!(result, "blog_sunset_001.jpg");
|
assert_eq!(result, "blog_sunset_001.jpg");
|
||||||
@@ -105,6 +108,9 @@ fn rename_config_with_suffix() {
|
|||||||
counter_start: 1,
|
counter_start: 1,
|
||||||
counter_padding: 2,
|
counter_padding: 2,
|
||||||
template: None,
|
template: None,
|
||||||
|
case_mode: 0,
|
||||||
|
regex_find: String::new(),
|
||||||
|
regex_replace: String::new(),
|
||||||
};
|
};
|
||||||
let result = config.apply_simple("photo", "webp", 5);
|
let result = config.apply_simple("photo", "webp", 5);
|
||||||
assert_eq!(result, "photo_web_05.webp");
|
assert_eq!(result, "photo_web_05.webp");
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ pub struct JobConfig {
|
|||||||
pub rename_counter_start: u32,
|
pub rename_counter_start: u32,
|
||||||
pub rename_counter_padding: u32,
|
pub rename_counter_padding: u32,
|
||||||
pub rename_template: String,
|
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
|
// Output
|
||||||
pub preserve_dir_structure: bool,
|
pub preserve_dir_structure: bool,
|
||||||
pub overwrite_behavior: u8,
|
pub overwrite_behavior: u8,
|
||||||
@@ -389,6 +392,9 @@ fn build_ui(app: &adw::Application) {
|
|||||||
rename_counter_start: 1,
|
rename_counter_start: 1,
|
||||||
rename_counter_padding: 3,
|
rename_counter_padding: 3,
|
||||||
rename_template: String::new(),
|
rename_template: String::new(),
|
||||||
|
rename_case: 0,
|
||||||
|
rename_find: String::new(),
|
||||||
|
rename_replace: String::new(),
|
||||||
preserve_dir_structure: false,
|
preserve_dir_structure: false,
|
||||||
overwrite_behavior: match app_cfg.overwrite_behavior {
|
overwrite_behavior: match app_cfg.overwrite_behavior {
|
||||||
pixstrip_core::config::OverwriteBehavior::Ask => 0,
|
pixstrip_core::config::OverwriteBehavior::Ask => 0,
|
||||||
@@ -1847,6 +1853,9 @@ fn run_processing(_window: &adw::ApplicationWindow, ui: &WizardUi) {
|
|||||||
} else {
|
} else {
|
||||||
Some(cfg.rename_template.clone())
|
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 {
|
} else {
|
||||||
Some(cfg.rename_template.clone())
|
Some(cfg.rename_template.clone())
|
||||||
},
|
},
|
||||||
|
case_mode: cfg.rename_case,
|
||||||
|
regex_find: cfg.rename_find.clone(),
|
||||||
|
regex_replace: cfg.rename_replace.clone(),
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
None
|
None
|
||||||
|
|||||||
@@ -264,6 +264,9 @@ pub fn build_rename_page(state: &AppState) -> adw::NavigationPage {
|
|||||||
counter_start: cfg.rename_counter_start,
|
counter_start: cfg.rename_counter_start,
|
||||||
counter_padding: cfg.rename_counter_padding,
|
counter_padding: cfg.rename_counter_padding,
|
||||||
template: None,
|
template: None,
|
||||||
|
case_mode: cfg.rename_case,
|
||||||
|
regex_find: cfg.rename_find.clone(),
|
||||||
|
regex_replace: cfg.rename_replace.clone(),
|
||||||
};
|
};
|
||||||
let result =
|
let result =
|
||||||
rename_cfg.apply_simple(name, ext, (i + 1) as u32);
|
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 jc = state.job_config.clone();
|
||||||
let up = update_preview;
|
let up = update_preview.clone();
|
||||||
template_row.connect_changed(move |row| {
|
template_row.connect_changed(move |row| {
|
||||||
jc.borrow_mut().rename_template = row.text().to_string();
|
jc.borrow_mut().rename_template = row.text().to_string();
|
||||||
up();
|
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));
|
scrolled.set_child(Some(&content));
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user