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:
@@ -21,6 +21,7 @@ imageproc = "0.25"
|
||||
ab_glyph = "0.2"
|
||||
dirs = "6"
|
||||
notify = "7"
|
||||
regex = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
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
|
||||
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<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_padding: u32,
|
||||
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 {
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<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 {
|
||||
if !path.exists() {
|
||||
return path.to_path_buf();
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user