- Redesign tutorial tour from modal dialogs to popovers pointing at actual UI elements - Add beginner-friendly improvements: help buttons, tooltips, welcome wizard enhancements - Add AppStream metainfo with screenshots, branding, categories, keywords, provides - Update desktop file with GTK category and SingleMainWindow - Add hicolor icon theme with all sizes (16-512px) - Fix debounce SourceId panic in rename step - Various step UI improvements and bug fixes
1984 lines
69 KiB
Rust
1984 lines
69 KiB
Rust
use clap::{Parser, Subcommand};
|
|
use pixstrip_core::discovery;
|
|
use pixstrip_core::executor::PipelineExecutor;
|
|
use pixstrip_core::operations::*;
|
|
use pixstrip_core::pipeline::ProcessingJob;
|
|
use pixstrip_core::preset::Preset;
|
|
use pixstrip_core::storage::{HistoryStore, PresetStore};
|
|
use pixstrip_core::types::*;
|
|
use std::io::Write;
|
|
use std::path::PathBuf;
|
|
|
|
fn watch_config_dir() -> PathBuf {
|
|
dirs::config_dir()
|
|
.or_else(|| dirs::home_dir().map(|h| h.join(".config")))
|
|
.unwrap_or_else(|| std::env::temp_dir())
|
|
.join("pixstrip")
|
|
}
|
|
|
|
fn load_watches(watches_path: &std::path::Path) -> Vec<pixstrip_core::watcher::WatchFolder> {
|
|
std::fs::read_to_string(watches_path)
|
|
.ok()
|
|
.and_then(|s| serde_json::from_str(&s).ok())
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
#[derive(Parser)]
|
|
#[command(name = "pixstrip")]
|
|
#[command(about = "Batch image processor - resize, convert, compress, strip metadata, watermark, and rename")]
|
|
#[command(version)]
|
|
#[command(after_long_help = "\
|
|
EXAMPLES:
|
|
Resize all images in a folder to 1200px wide:
|
|
pixstrip process photos/ --resize 1200
|
|
|
|
Convert PNGs to WebP at high quality:
|
|
pixstrip process *.png --format webp --quality high
|
|
|
|
Fit images within 1920x1080 without upscaling:
|
|
pixstrip process photos/ --resize fit:1920x1080
|
|
|
|
Strip metadata for privacy and rename to lowercase:
|
|
pixstrip process photos/ --metadata privacy --rename-case lower
|
|
|
|
Add a tiled watermark with custom color and rotation:
|
|
pixstrip process photos/ --watermark \"(c) 2026\" --watermark-tiled --watermark-rotation 45 --watermark-color ff0000
|
|
|
|
Process with a saved preset:
|
|
pixstrip process photos/ --preset \"Blog Photos\"
|
|
|
|
Watch a folder and auto-process new images:
|
|
pixstrip watch add ~/incoming --preset \"Web Upload\"
|
|
pixstrip watch start
|
|
|
|
For more details on any subcommand:
|
|
pixstrip help <command>
|
|
pixstrip process --help")]
|
|
struct Cli {
|
|
#[command(subcommand)]
|
|
command: Commands,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
#[allow(clippy::large_enum_variant)]
|
|
enum Commands {
|
|
/// Process images with a preset or custom operations
|
|
#[command(after_long_help = "\
|
|
EXAMPLES:
|
|
Resize all JPEGs in a folder to 1200px wide:
|
|
pixstrip process photos/ --resize 1200 -o output/
|
|
|
|
Fit images within a 1920x1080 box (no upscaling):
|
|
pixstrip process photos/ --resize fit:1920x1080
|
|
|
|
Convert all PNGs to WebP with high quality:
|
|
pixstrip process *.png --format webp --quality high
|
|
|
|
Per-format conversion (PNGs to WebP, TIFFs to JPEG):
|
|
pixstrip process photos/ --format-map \"png=webp,tiff=jpeg\"
|
|
|
|
Fine-tune compression per format:
|
|
pixstrip process photos/ --jpeg-quality 90 --webp-quality 85 --avif-quality 70
|
|
|
|
Strip location and camera data, keep copyright:
|
|
pixstrip process photos/ --strip-gps --strip-camera
|
|
|
|
Adjust brightness/contrast and convert to grayscale:
|
|
pixstrip process photos/ --brightness 10 --contrast 20 --grayscale
|
|
|
|
Add a tiled diagonal watermark in red:
|
|
pixstrip process photos/ --watermark \"(c) 2026\" --watermark-tiled \\
|
|
--watermark-rotation 45 --watermark-color ff0000 --watermark-opacity 0.3
|
|
|
|
Add an image watermark (logo) scaled to 15%:
|
|
pixstrip process photos/ --watermark-image logo.png --watermark-scale 0.15
|
|
|
|
Rename to lowercase with hyphens, add prefix:
|
|
pixstrip process photos/ --rename-prefix \"blog-\" --rename-case lower \\
|
|
--rename-spaces hyphen
|
|
|
|
Rename with regex (remove \"IMG_\" prefix):
|
|
pixstrip process photos/ --rename-regex-find \"^IMG_\" --rename-regex-replace \"\"
|
|
|
|
Sequential numbering (replace filename with counter):
|
|
pixstrip process photos/ --rename-counter-position replace-name \\
|
|
--rename-counter-start 1 --rename-counter-padding 4
|
|
|
|
Use a saved preset:
|
|
pixstrip process photos/ --preset \"Blog Photos\"
|
|
|
|
Override preset options (preset + custom flags):
|
|
pixstrip process photos/ --preset \"Blog Photos\" --resize 800
|
|
|
|
Combine multiple operations:
|
|
pixstrip process photos/ -r --resize fit:1920x1080 --format webp \\
|
|
--quality high --strip-metadata --rename-case lower \\
|
|
--watermark \"(c) 2026\" --overwrite skip
|
|
|
|
TEMPLATE VARIABLES (for --rename-template):
|
|
{name} Original filename (without extension)
|
|
{ext} File extension
|
|
{counter} Sequential number (padding from --rename-counter-padding)
|
|
{counter:N} Sequential number with N-digit padding
|
|
{width} Image width in pixels
|
|
{height} Image height in pixels
|
|
{date} File modification date (YYYY-MM-DD)
|
|
|
|
Example: --rename-template \"{name}_{width}x{height}.{ext}\"")]
|
|
Process {
|
|
/// Input files or directories
|
|
#[arg(required = true)]
|
|
input: Vec<String>,
|
|
|
|
/// Preset name to use (see 'pixstrip preset list' for available presets)
|
|
#[arg(long)]
|
|
preset: Option<String>,
|
|
|
|
/// Output directory [default: <input>/processed]
|
|
#[arg(short, long)]
|
|
output: Option<String>,
|
|
|
|
// --- Resize ---
|
|
|
|
/// Resize images. Formats: "1200" (width), "1200x900" (exact), "fit:1200x900" (fit in box)
|
|
#[arg(long, help_heading = "Resize")]
|
|
resize: Option<String>,
|
|
|
|
/// Resize algorithm
|
|
#[arg(long, default_value = "lanczos3", help_heading = "Resize",
|
|
value_parser = ["lanczos3", "catmullrom", "bilinear", "nearest"])]
|
|
algorithm: String,
|
|
|
|
/// Allow upscaling when using fit-in-box resize mode
|
|
#[arg(long, help_heading = "Resize")]
|
|
allow_upscale: bool,
|
|
|
|
// --- Convert ---
|
|
|
|
/// Convert all images to one format
|
|
#[arg(long, help_heading = "Convert",
|
|
value_parser = ["jpeg", "jpg", "png", "webp", "avif", "gif", "tiff", "bmp"])]
|
|
format: Option<String>,
|
|
|
|
/// Per-format conversion mapping (e.g., "png=webp,tiff=jpeg"). Cannot combine with --format
|
|
#[arg(long, help_heading = "Convert")]
|
|
format_map: Option<String>,
|
|
|
|
// --- Compress ---
|
|
|
|
/// Quality preset
|
|
#[arg(long, help_heading = "Compress",
|
|
value_parser = ["maximum", "max", "high", "medium", "med", "low", "web"])]
|
|
quality: Option<String>,
|
|
|
|
/// JPEG quality (1-100). Overrides --quality for JPEG output
|
|
#[arg(long, value_parser = clap::value_parser!(u8).range(1..=100), help_heading = "Compress")]
|
|
jpeg_quality: Option<u8>,
|
|
|
|
/// PNG compression level (1-12, lower = larger but faster). Overrides --quality for PNG
|
|
#[arg(long, value_parser = clap::value_parser!(u8).range(1..=12), help_heading = "Compress")]
|
|
png_level: Option<u8>,
|
|
|
|
/// WebP quality (1-100). Overrides --quality for WebP output
|
|
#[arg(long, value_parser = clap::value_parser!(u8).range(1..=100), help_heading = "Compress")]
|
|
webp_quality: Option<u8>,
|
|
|
|
/// AVIF quality (1-100). Overrides --quality for AVIF output
|
|
#[arg(long, value_parser = clap::value_parser!(u8).range(1..=100), help_heading = "Compress")]
|
|
avif_quality: Option<u8>,
|
|
|
|
/// AVIF encoding speed (0=slowest/best, 10=fastest/worst)
|
|
#[arg(long, value_parser = clap::value_parser!(u8).range(0..=10), help_heading = "Compress")]
|
|
avif_speed: Option<u8>,
|
|
|
|
/// Enable progressive JPEG encoding (better for web, slightly larger files)
|
|
#[arg(long, help_heading = "Compress")]
|
|
progressive_jpeg: bool,
|
|
|
|
// --- Metadata ---
|
|
|
|
/// Metadata handling mode. "privacy" strips GPS, camera, and software info
|
|
#[arg(long, help_heading = "Metadata",
|
|
value_parser = ["strip-all", "keep-all", "privacy"])]
|
|
metadata: Option<String>,
|
|
|
|
/// Strip GPS/location data
|
|
#[arg(long, help_heading = "Metadata")]
|
|
strip_gps: bool,
|
|
|
|
/// Strip camera make/model/settings info
|
|
#[arg(long, help_heading = "Metadata")]
|
|
strip_camera: bool,
|
|
|
|
/// Strip software/editor tags
|
|
#[arg(long, help_heading = "Metadata")]
|
|
strip_software: bool,
|
|
|
|
/// Strip date/time timestamps
|
|
#[arg(long, help_heading = "Metadata")]
|
|
strip_timestamps: bool,
|
|
|
|
/// Strip copyright and author info
|
|
#[arg(long, help_heading = "Metadata")]
|
|
strip_copyright: bool,
|
|
|
|
/// Strip all metadata (shorthand for --metadata strip-all)
|
|
#[arg(long, help_heading = "Metadata")]
|
|
strip_metadata: bool,
|
|
|
|
// --- Rotation / Flip ---
|
|
|
|
/// Rotate images. "auto" reads EXIF orientation and corrects it
|
|
#[arg(long, help_heading = "Transform",
|
|
value_parser = ["90", "180", "270", "auto", "none"])]
|
|
rotate: Option<String>,
|
|
|
|
/// Flip/mirror images
|
|
#[arg(long, help_heading = "Transform",
|
|
value_parser = ["horizontal", "vertical", "h", "v", "none"])]
|
|
flip: Option<String>,
|
|
|
|
// --- Adjustments ---
|
|
|
|
/// Brightness adjustment (-100 to 100, 0 = no change)
|
|
#[arg(long, allow_hyphen_values = true, help_heading = "Adjustments")]
|
|
brightness: Option<i32>,
|
|
|
|
/// Contrast adjustment (-100 to 100, 0 = no change)
|
|
#[arg(long, allow_hyphen_values = true, help_heading = "Adjustments")]
|
|
contrast: Option<i32>,
|
|
|
|
/// Saturation adjustment (-100 to 100, 0 = no change)
|
|
#[arg(long, allow_hyphen_values = true, help_heading = "Adjustments")]
|
|
saturation: Option<i32>,
|
|
|
|
/// Apply sharpening filter
|
|
#[arg(long, help_heading = "Adjustments")]
|
|
sharpen: bool,
|
|
|
|
/// Convert to grayscale (black and white)
|
|
#[arg(long, help_heading = "Adjustments")]
|
|
grayscale: bool,
|
|
|
|
/// Apply sepia tone (vintage look)
|
|
#[arg(long, help_heading = "Adjustments")]
|
|
sepia: bool,
|
|
|
|
/// Crop to aspect ratio (e.g., "16:9", "4:3", "1:1", "3:2")
|
|
#[arg(long, help_heading = "Adjustments")]
|
|
crop_aspect_ratio: Option<String>,
|
|
|
|
/// Auto-trim whitespace/solid-color borders
|
|
#[arg(long, help_heading = "Adjustments")]
|
|
trim_whitespace: bool,
|
|
|
|
/// Add uniform canvas padding in pixels (applied after all other adjustments)
|
|
#[arg(long, help_heading = "Adjustments")]
|
|
canvas_padding: Option<u32>,
|
|
|
|
// --- Watermark ---
|
|
|
|
/// Add text watermark. Cannot combine with --watermark-image
|
|
#[arg(long, help_heading = "Watermark")]
|
|
watermark: Option<String>,
|
|
|
|
/// Add image watermark from file. Cannot combine with --watermark
|
|
#[arg(long, help_heading = "Watermark")]
|
|
watermark_image: Option<String>,
|
|
|
|
/// Watermark position on the image
|
|
#[arg(long, default_value = "bottom-right", help_heading = "Watermark",
|
|
value_parser = ["top-left", "top-center", "top-right",
|
|
"center", "bottom-left", "bottom-center", "bottom-right",
|
|
"tl", "tc", "tr", "c", "bl", "bc", "br"])]
|
|
watermark_position: String,
|
|
|
|
/// Watermark opacity (0.0 = invisible, 1.0 = fully opaque)
|
|
#[arg(long, default_value = "0.5", value_parser = parse_opacity, help_heading = "Watermark")]
|
|
watermark_opacity: f32,
|
|
|
|
/// Font size for text watermarks in points
|
|
#[arg(long, default_value = "24", help_heading = "Watermark")]
|
|
watermark_font_size: f32,
|
|
|
|
/// Font family name for text watermarks (searches system fonts)
|
|
#[arg(long, help_heading = "Watermark")]
|
|
watermark_font: Option<String>,
|
|
|
|
/// Text color as hex RGB or RGBA (e.g., "ffffff", "ff0000", "00000080")
|
|
#[arg(long, default_value = "ffffff", help_heading = "Watermark")]
|
|
watermark_color: String,
|
|
|
|
/// Rotate watermark in degrees (45, -45, 90, or any value)
|
|
#[arg(long, help_heading = "Watermark")]
|
|
watermark_rotation: Option<String>,
|
|
|
|
/// Tile (repeat) the watermark across the entire image
|
|
#[arg(long, help_heading = "Watermark")]
|
|
watermark_tiled: bool,
|
|
|
|
/// Watermark distance from image edges in pixels
|
|
#[arg(long, default_value = "10", help_heading = "Watermark")]
|
|
watermark_margin: u32,
|
|
|
|
/// Scale for image watermarks (0.0-1.0, fraction of image size)
|
|
#[arg(long, default_value = "0.25", value_parser = parse_opacity, help_heading = "Watermark")]
|
|
watermark_scale: f32,
|
|
|
|
// --- Rename ---
|
|
|
|
/// Add prefix before filename (e.g., "blog-" turns "photo.jpg" into "blog-photo.jpg")
|
|
#[arg(long, help_heading = "Rename")]
|
|
rename_prefix: Option<String>,
|
|
|
|
/// Add suffix after filename (e.g., "-web" turns "photo.jpg" into "photo-web.jpg")
|
|
#[arg(long, help_heading = "Rename")]
|
|
rename_suffix: Option<String>,
|
|
|
|
/// Rename using a template. Overrides --rename-prefix/--rename-suffix. See TEMPLATE VARIABLES below
|
|
#[arg(long, help_heading = "Rename")]
|
|
rename_template: Option<String>,
|
|
|
|
/// Convert filename case
|
|
#[arg(long, help_heading = "Rename",
|
|
value_parser = ["lower", "upper", "title", "none"])]
|
|
rename_case: Option<String>,
|
|
|
|
/// Replace spaces in filenames
|
|
#[arg(long, help_heading = "Rename",
|
|
value_parser = ["underscore", "hyphen", "dot", "camelcase", "remove"])]
|
|
rename_spaces: Option<String>,
|
|
|
|
/// Filter special characters in filenames
|
|
#[arg(long, help_heading = "Rename",
|
|
value_parser = ["filesystem-safe", "web-safe", "hyphens-underscores", "hyphens", "alphanumeric", "none"])]
|
|
rename_special_chars: Option<String>,
|
|
|
|
/// Regex pattern to find in filenames (paired with --rename-regex-replace)
|
|
#[arg(long, help_heading = "Rename")]
|
|
rename_regex_find: Option<String>,
|
|
|
|
/// Replacement for regex matches (supports $1, $2 capture groups)
|
|
#[arg(long, help_heading = "Rename")]
|
|
rename_regex_replace: Option<String>,
|
|
|
|
/// Where to insert the counter in the filename
|
|
#[arg(long, default_value = "after-suffix", help_heading = "Rename",
|
|
value_parser = ["before-prefix", "before-name", "after-name", "after-suffix", "replace-name"])]
|
|
rename_counter_position: String,
|
|
|
|
/// Counter starting number
|
|
#[arg(long, default_value = "1", help_heading = "Rename")]
|
|
rename_counter_start: u32,
|
|
|
|
/// Counter zero-padding width (3 = "001", 4 = "0001")
|
|
#[arg(long, default_value = "3", help_heading = "Rename")]
|
|
rename_counter_padding: u32,
|
|
|
|
// --- Output ---
|
|
|
|
/// What to do when output file already exists
|
|
#[arg(long, default_value = "auto-rename", help_heading = "Output",
|
|
value_parser = ["auto-rename", "overwrite", "skip"])]
|
|
overwrite: String,
|
|
|
|
/// Preserve subdirectory structure from input in the output folder
|
|
#[arg(long, help_heading = "Output")]
|
|
preserve_dirs: bool,
|
|
|
|
/// Set output DPI/resolution metadata (e.g., 72, 150, 300)
|
|
#[arg(long, help_heading = "Output")]
|
|
output_dpi: Option<u32>,
|
|
|
|
/// Scan input directories recursively for images
|
|
#[arg(short, long, help_heading = "Output")]
|
|
recursive: bool,
|
|
},
|
|
|
|
/// Manage presets (list, create, delete, import, export)
|
|
Preset {
|
|
#[command(subcommand)]
|
|
action: PresetAction,
|
|
},
|
|
|
|
/// Manage watch folders for automatic processing of new images
|
|
Watch {
|
|
#[command(subcommand)]
|
|
action: WatchAction,
|
|
},
|
|
|
|
/// View processing history (recent batches, file counts, sizes, and times)
|
|
History,
|
|
|
|
/// Undo recent batch operations by moving output files to system trash
|
|
Undo {
|
|
/// Number of recent batches to undo
|
|
#[arg(long, default_value = "1")]
|
|
last: usize,
|
|
},
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum WatchAction {
|
|
/// Add a folder to watch for new images, with a preset to apply automatically
|
|
Add {
|
|
/// Folder path to watch for new images
|
|
path: String,
|
|
/// Preset to apply to new images (see 'pixstrip preset list')
|
|
#[arg(long)]
|
|
preset: String,
|
|
/// Also watch subdirectories
|
|
#[arg(short, long)]
|
|
recursive: bool,
|
|
},
|
|
/// List all configured watch folders and their linked presets
|
|
List,
|
|
/// Remove a folder from the watch list
|
|
Remove {
|
|
/// Folder path to stop watching
|
|
path: String,
|
|
},
|
|
/// Start watching all active folders. Blocks until Ctrl+C. Processed files go to <folder>/processed/
|
|
Start,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum PresetAction {
|
|
/// List all available presets (built-in and user-created)
|
|
List,
|
|
/// Create a new empty user preset. Edit it by exporting, modifying the JSON, and re-importing
|
|
Create {
|
|
/// Name for the new preset
|
|
name: String,
|
|
/// Optional description
|
|
#[arg(long, default_value = "")]
|
|
description: String,
|
|
},
|
|
/// Delete a user preset. Built-in presets cannot be deleted
|
|
Delete {
|
|
/// Name of the preset to delete
|
|
name: String,
|
|
},
|
|
/// Export a preset to a JSON file for backup or sharing
|
|
Export {
|
|
/// Name of the preset to export
|
|
name: String,
|
|
/// Output file path (e.g., "my-preset.json")
|
|
#[arg(short, long)]
|
|
output: String,
|
|
},
|
|
/// Import a preset from a JSON file
|
|
Import {
|
|
/// Path to the preset JSON file
|
|
path: String,
|
|
},
|
|
}
|
|
|
|
fn main() {
|
|
let cli = Cli::parse();
|
|
|
|
match cli.command {
|
|
Commands::Process {
|
|
input,
|
|
preset,
|
|
output,
|
|
resize,
|
|
algorithm,
|
|
allow_upscale,
|
|
format,
|
|
format_map,
|
|
quality,
|
|
jpeg_quality,
|
|
png_level,
|
|
webp_quality,
|
|
avif_quality,
|
|
avif_speed,
|
|
progressive_jpeg,
|
|
metadata,
|
|
strip_gps,
|
|
strip_camera,
|
|
strip_software,
|
|
strip_timestamps,
|
|
strip_copyright,
|
|
strip_metadata,
|
|
rotate,
|
|
flip,
|
|
brightness,
|
|
contrast,
|
|
saturation,
|
|
sharpen,
|
|
grayscale,
|
|
sepia,
|
|
crop_aspect_ratio,
|
|
trim_whitespace,
|
|
canvas_padding,
|
|
watermark,
|
|
watermark_image,
|
|
watermark_position,
|
|
watermark_opacity,
|
|
watermark_font_size,
|
|
watermark_font,
|
|
watermark_color,
|
|
watermark_rotation,
|
|
watermark_tiled,
|
|
watermark_margin,
|
|
watermark_scale,
|
|
rename_prefix,
|
|
rename_suffix,
|
|
rename_template,
|
|
rename_case,
|
|
rename_spaces,
|
|
rename_special_chars,
|
|
rename_regex_find,
|
|
rename_regex_replace,
|
|
rename_counter_position,
|
|
rename_counter_start,
|
|
rename_counter_padding,
|
|
overwrite,
|
|
preserve_dirs,
|
|
output_dpi,
|
|
recursive,
|
|
} => {
|
|
cmd_process(CmdProcessArgs {
|
|
input,
|
|
preset,
|
|
output,
|
|
resize,
|
|
algorithm,
|
|
allow_upscale,
|
|
format,
|
|
format_map,
|
|
quality,
|
|
jpeg_quality,
|
|
png_level,
|
|
webp_quality,
|
|
avif_quality,
|
|
avif_speed,
|
|
progressive_jpeg,
|
|
metadata,
|
|
strip_gps,
|
|
strip_camera,
|
|
strip_software,
|
|
strip_timestamps,
|
|
strip_copyright,
|
|
strip_metadata,
|
|
rotate,
|
|
flip,
|
|
brightness,
|
|
contrast,
|
|
saturation,
|
|
sharpen,
|
|
grayscale,
|
|
sepia,
|
|
crop_aspect_ratio,
|
|
trim_whitespace,
|
|
canvas_padding,
|
|
watermark,
|
|
watermark_image,
|
|
watermark_position,
|
|
watermark_opacity,
|
|
watermark_font_size,
|
|
watermark_font,
|
|
watermark_color,
|
|
watermark_rotation,
|
|
watermark_tiled,
|
|
watermark_margin,
|
|
watermark_scale,
|
|
rename_prefix,
|
|
rename_suffix,
|
|
rename_template,
|
|
rename_case,
|
|
rename_spaces,
|
|
rename_special_chars,
|
|
rename_regex_find,
|
|
rename_regex_replace,
|
|
rename_counter_position,
|
|
rename_counter_start,
|
|
rename_counter_padding,
|
|
overwrite,
|
|
preserve_dirs,
|
|
output_dpi,
|
|
recursive,
|
|
});
|
|
}
|
|
Commands::Preset { action } => match action {
|
|
PresetAction::List => cmd_preset_list(),
|
|
PresetAction::Create { name, description } => cmd_preset_create(&name, &description),
|
|
PresetAction::Delete { name } => cmd_preset_delete(&name),
|
|
PresetAction::Export { name, output } => cmd_preset_export(&name, &output),
|
|
PresetAction::Import { path } => cmd_preset_import(&path),
|
|
},
|
|
Commands::Watch { action } => match action {
|
|
WatchAction::Add {
|
|
path,
|
|
preset,
|
|
recursive,
|
|
} => cmd_watch_add(&path, &preset, recursive),
|
|
WatchAction::List => cmd_watch_list(),
|
|
WatchAction::Remove { path } => cmd_watch_remove(&path),
|
|
WatchAction::Start => cmd_watch_start(),
|
|
},
|
|
Commands::History => cmd_history(),
|
|
Commands::Undo { last } => cmd_undo(last),
|
|
}
|
|
}
|
|
|
|
struct CmdProcessArgs {
|
|
input: Vec<String>,
|
|
preset: Option<String>,
|
|
output: Option<String>,
|
|
// Resize
|
|
resize: Option<String>,
|
|
algorithm: String,
|
|
allow_upscale: bool,
|
|
// Convert
|
|
format: Option<String>,
|
|
format_map: Option<String>,
|
|
// Compress
|
|
quality: Option<String>,
|
|
jpeg_quality: Option<u8>,
|
|
png_level: Option<u8>,
|
|
webp_quality: Option<u8>,
|
|
avif_quality: Option<u8>,
|
|
avif_speed: Option<u8>,
|
|
progressive_jpeg: bool,
|
|
// Metadata
|
|
metadata: Option<String>,
|
|
strip_gps: bool,
|
|
strip_camera: bool,
|
|
strip_software: bool,
|
|
strip_timestamps: bool,
|
|
strip_copyright: bool,
|
|
strip_metadata: bool,
|
|
// Rotation / Flip
|
|
rotate: Option<String>,
|
|
flip: Option<String>,
|
|
// Adjustments
|
|
brightness: Option<i32>,
|
|
contrast: Option<i32>,
|
|
saturation: Option<i32>,
|
|
sharpen: bool,
|
|
grayscale: bool,
|
|
sepia: bool,
|
|
crop_aspect_ratio: Option<String>,
|
|
trim_whitespace: bool,
|
|
canvas_padding: Option<u32>,
|
|
// Watermark
|
|
watermark: Option<String>,
|
|
watermark_image: Option<String>,
|
|
watermark_position: String,
|
|
watermark_opacity: f32,
|
|
watermark_font_size: f32,
|
|
watermark_font: Option<String>,
|
|
watermark_color: String,
|
|
watermark_rotation: Option<String>,
|
|
watermark_tiled: bool,
|
|
watermark_margin: u32,
|
|
watermark_scale: f32,
|
|
// Rename
|
|
rename_prefix: Option<String>,
|
|
rename_suffix: Option<String>,
|
|
rename_template: Option<String>,
|
|
rename_case: Option<String>,
|
|
rename_spaces: Option<String>,
|
|
rename_special_chars: Option<String>,
|
|
rename_regex_find: Option<String>,
|
|
rename_regex_replace: Option<String>,
|
|
rename_counter_position: String,
|
|
rename_counter_start: u32,
|
|
rename_counter_padding: u32,
|
|
// Output
|
|
overwrite: String,
|
|
preserve_dirs: bool,
|
|
output_dpi: Option<u32>,
|
|
recursive: bool,
|
|
}
|
|
|
|
fn cmd_process(args: CmdProcessArgs) {
|
|
// Collect input files
|
|
let mut source_files = Vec::new();
|
|
for path_str in &args.input {
|
|
let path = PathBuf::from(path_str);
|
|
if path.is_dir() {
|
|
let files = discovery::discover_images(&path, args.recursive);
|
|
source_files.extend(files);
|
|
} else if path.is_file() {
|
|
if path.extension()
|
|
.and_then(|e| e.to_str())
|
|
.and_then(pixstrip_core::types::ImageFormat::from_extension)
|
|
.is_some()
|
|
{
|
|
source_files.push(path);
|
|
} else {
|
|
eprintln!("Warning: '{}' is not a recognized image format, skipping", path_str);
|
|
}
|
|
} else {
|
|
eprintln!("Warning: '{}' not found, skipping", path_str);
|
|
}
|
|
}
|
|
|
|
if source_files.is_empty() {
|
|
eprintln!("No images found in the specified input(s)");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
println!("Found {} image(s)", source_files.len());
|
|
|
|
let input_dir = source_files[0]
|
|
.parent()
|
|
.unwrap_or_else(|| std::path::Path::new("."))
|
|
.to_path_buf();
|
|
|
|
let output_dir = args.output
|
|
.as_ref()
|
|
.map(PathBuf::from)
|
|
.unwrap_or_else(|| input_dir.join("processed"));
|
|
|
|
let mut job = if let Some(ref name) = args.preset {
|
|
let preset = find_preset(name).unwrap_or_else(|| {
|
|
eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", name);
|
|
std::process::exit(1);
|
|
});
|
|
println!("Using preset: {}", preset.name);
|
|
preset.to_job(&input_dir, &output_dir)
|
|
} else {
|
|
ProcessingJob::new(&input_dir, &output_dir)
|
|
};
|
|
|
|
// Override with CLI flags
|
|
|
|
// --- Resize ---
|
|
if let Some(ref resize_str) = args.resize {
|
|
job.resize = Some(parse_resize(resize_str, args.allow_upscale));
|
|
}
|
|
|
|
job.resize_algorithm = match args.algorithm.to_lowercase().as_str() {
|
|
"catmullrom" | "catmull-rom" => ResizeAlgorithm::CatmullRom,
|
|
"bilinear" => ResizeAlgorithm::Bilinear,
|
|
"nearest" => ResizeAlgorithm::Nearest,
|
|
"lanczos3" | "lanczos" => ResizeAlgorithm::Lanczos3,
|
|
other => {
|
|
eprintln!("Warning: unknown algorithm '{}', using lanczos3. Valid: lanczos3, catmullrom, bilinear, nearest", other);
|
|
ResizeAlgorithm::Lanczos3
|
|
}
|
|
};
|
|
|
|
// --- Convert ---
|
|
if args.format.is_some() && args.format_map.is_some() {
|
|
eprintln!("Error: cannot use both --format and --format-map at the same time");
|
|
std::process::exit(1);
|
|
}
|
|
if let Some(ref map_str) = args.format_map {
|
|
let mappings = parse_format_map(map_str);
|
|
if !mappings.is_empty() {
|
|
job.convert = Some(ConvertConfig::FormatMapping(mappings));
|
|
}
|
|
} else if let Some(ref fmt_str) = args.format {
|
|
let fmt = parse_format(fmt_str).unwrap_or_else(|| std::process::exit(1));
|
|
job.convert = Some(ConvertConfig::SingleFormat(fmt));
|
|
}
|
|
|
|
// --- Compress ---
|
|
if args.jpeg_quality.is_some() || args.png_level.is_some() || args.webp_quality.is_some() || args.avif_quality.is_some() {
|
|
// Per-format custom quality overrides the preset
|
|
job.compress = Some(CompressConfig::Custom {
|
|
jpeg_quality: args.jpeg_quality,
|
|
png_level: args.png_level,
|
|
webp_quality: args.webp_quality.map(|q| q as f32),
|
|
avif_quality: args.avif_quality.map(|q| q as f32),
|
|
});
|
|
} else if let Some(ref q_str) = args.quality {
|
|
let preset = parse_quality(q_str).unwrap_or_else(|| std::process::exit(1));
|
|
job.compress = Some(CompressConfig::Preset(preset));
|
|
}
|
|
|
|
if let Some(speed) = args.avif_speed {
|
|
job.avif_speed = speed;
|
|
}
|
|
if args.progressive_jpeg {
|
|
job.progressive_jpeg = true;
|
|
}
|
|
|
|
// --- Metadata ---
|
|
if args.strip_metadata {
|
|
job.metadata = Some(MetadataConfig::StripAll);
|
|
} else if args.strip_gps || args.strip_camera || args.strip_software || args.strip_timestamps || args.strip_copyright {
|
|
job.metadata = Some(MetadataConfig::Custom {
|
|
strip_gps: args.strip_gps,
|
|
strip_camera: args.strip_camera,
|
|
strip_software: args.strip_software,
|
|
strip_timestamps: args.strip_timestamps,
|
|
strip_copyright: args.strip_copyright,
|
|
});
|
|
} else if let Some(ref mode) = args.metadata {
|
|
job.metadata = Some(parse_metadata_mode(mode));
|
|
}
|
|
|
|
// --- Rotation / Flip ---
|
|
if let Some(ref rot) = args.rotate {
|
|
job.rotation = Some(parse_rotation(rot));
|
|
}
|
|
if let Some(ref fl) = args.flip {
|
|
job.flip = Some(parse_flip(fl));
|
|
}
|
|
|
|
// --- Adjustments ---
|
|
let has_adjustments = args.brightness.is_some()
|
|
|| args.contrast.is_some()
|
|
|| args.saturation.is_some()
|
|
|| args.sharpen
|
|
|| args.grayscale
|
|
|| args.sepia
|
|
|| args.crop_aspect_ratio.is_some()
|
|
|| args.trim_whitespace
|
|
|| args.canvas_padding.is_some();
|
|
if has_adjustments {
|
|
job.adjustments = Some(AdjustmentsConfig {
|
|
brightness: args.brightness.unwrap_or(0).clamp(-100, 100),
|
|
contrast: args.contrast.unwrap_or(0).clamp(-100, 100),
|
|
saturation: args.saturation.unwrap_or(0).clamp(-100, 100),
|
|
sharpen: args.sharpen,
|
|
grayscale: args.grayscale,
|
|
sepia: args.sepia,
|
|
crop_aspect_ratio: args.crop_aspect_ratio.as_deref().and_then(parse_aspect_ratio),
|
|
trim_whitespace: args.trim_whitespace,
|
|
canvas_padding: args.canvas_padding.unwrap_or(0),
|
|
});
|
|
}
|
|
|
|
// --- Watermark ---
|
|
if args.watermark.is_some() && args.watermark_image.is_some() {
|
|
eprintln!("Error: cannot use both --watermark (text) and --watermark-image at the same time");
|
|
std::process::exit(1);
|
|
}
|
|
let wm_position = parse_watermark_position(&args.watermark_position);
|
|
let wm_rotation = args.watermark_rotation.as_deref().map(parse_watermark_rotation);
|
|
if let Some(ref text) = args.watermark {
|
|
let color = parse_hex_color(&args.watermark_color);
|
|
job.watermark = Some(WatermarkConfig::Text {
|
|
text: text.clone(),
|
|
position: wm_position,
|
|
font_size: args.watermark_font_size,
|
|
opacity: args.watermark_opacity,
|
|
color,
|
|
font_family: args.watermark_font.clone(),
|
|
rotation: wm_rotation,
|
|
tiled: args.watermark_tiled,
|
|
margin: args.watermark_margin,
|
|
});
|
|
} else if let Some(ref img_path) = args.watermark_image {
|
|
let path = PathBuf::from(img_path);
|
|
if !path.exists() {
|
|
eprintln!("Watermark image not found: {}", img_path);
|
|
std::process::exit(1);
|
|
}
|
|
job.watermark = Some(WatermarkConfig::Image {
|
|
path,
|
|
position: wm_position,
|
|
opacity: args.watermark_opacity,
|
|
scale: args.watermark_scale,
|
|
rotation: wm_rotation,
|
|
tiled: args.watermark_tiled,
|
|
margin: args.watermark_margin,
|
|
});
|
|
}
|
|
|
|
// --- Rename ---
|
|
let has_rename = args.rename_prefix.is_some()
|
|
|| args.rename_suffix.is_some()
|
|
|| args.rename_template.is_some()
|
|
|| args.rename_case.is_some()
|
|
|| args.rename_spaces.is_some()
|
|
|| args.rename_special_chars.is_some()
|
|
|| args.rename_regex_find.is_some();
|
|
if has_rename {
|
|
if let Some(ref tmpl) = args.rename_template {
|
|
if args.rename_prefix.is_some() || args.rename_suffix.is_some() {
|
|
eprintln!("Warning: --rename-template overrides --rename-prefix/--rename-suffix");
|
|
}
|
|
if !tmpl.contains('{') {
|
|
eprintln!("Warning: rename template '{}' has no placeholders. Use {{name}}, {{counter}}, {{ext}}, etc.", tmpl);
|
|
}
|
|
if !tmpl.contains("{ext}") && !tmpl.contains('.') {
|
|
eprintln!("Warning: rename template has no {{ext}} or file extension - output files may lack extensions");
|
|
}
|
|
}
|
|
let case_mode = args.rename_case.as_deref().map(parse_case_mode).unwrap_or(0);
|
|
let replace_spaces = args.rename_spaces.as_deref().map(parse_space_mode).unwrap_or(0);
|
|
let special_chars = args.rename_special_chars.as_deref().map(parse_special_chars_mode).unwrap_or(0);
|
|
let counter_position = parse_counter_position(&args.rename_counter_position);
|
|
let counter_enabled = args.rename_template.is_some() || counter_position != 3;
|
|
|
|
job.rename = Some(RenameConfig {
|
|
prefix: args.rename_prefix.unwrap_or_default(),
|
|
suffix: args.rename_suffix.unwrap_or_default(),
|
|
counter_start: args.rename_counter_start,
|
|
counter_padding: args.rename_counter_padding,
|
|
counter_enabled,
|
|
counter_position,
|
|
template: args.rename_template,
|
|
case_mode,
|
|
replace_spaces,
|
|
special_chars,
|
|
regex_find: args.rename_regex_find.unwrap_or_default(),
|
|
regex_replace: args.rename_regex_replace.unwrap_or_default(),
|
|
});
|
|
}
|
|
|
|
// --- Output ---
|
|
job.overwrite_behavior = match args.overwrite.to_lowercase().as_str() {
|
|
"overwrite" | "always" => OverwriteAction::Overwrite,
|
|
"skip" => OverwriteAction::Skip,
|
|
"auto-rename" | "autorename" | "rename" => OverwriteAction::AutoRename,
|
|
other => {
|
|
eprintln!("Warning: unknown overwrite mode '{}', using auto-rename. Valid: auto-rename, overwrite, skip", other);
|
|
OverwriteAction::AutoRename
|
|
}
|
|
};
|
|
|
|
if args.preserve_dirs {
|
|
job.preserve_directory_structure = true;
|
|
}
|
|
if let Some(dpi) = args.output_dpi {
|
|
job.output_dpi = dpi;
|
|
}
|
|
|
|
for file in &source_files {
|
|
job.add_source(file);
|
|
}
|
|
|
|
// Execute
|
|
let executor = PipelineExecutor::new();
|
|
let result = executor
|
|
.execute(&job, |update| {
|
|
eprint!(
|
|
"\r[{}/{}] {}...",
|
|
update.current, update.total, update.current_file
|
|
);
|
|
let _ = std::io::stderr().flush();
|
|
})
|
|
.unwrap_or_else(|e| {
|
|
eprintln!("\nProcessing failed: {}", e);
|
|
std::process::exit(1);
|
|
});
|
|
|
|
eprintln!();
|
|
|
|
// Print results
|
|
println!();
|
|
println!("Processing complete:");
|
|
println!(" Succeeded: {}", result.succeeded);
|
|
if result.failed > 0 {
|
|
println!(" Failed: {}", result.failed);
|
|
}
|
|
if result.total_input_bytes > 0 && result.total_output_bytes > result.total_input_bytes {
|
|
let increase = (result.total_output_bytes as f64 / result.total_input_bytes as f64 - 1.0) * 100.0;
|
|
println!(
|
|
" Size: {} -> {} (+{:.0}% increase)",
|
|
format_bytes(result.total_input_bytes),
|
|
format_bytes(result.total_output_bytes),
|
|
increase,
|
|
);
|
|
} else {
|
|
println!(
|
|
" Size: {} -> {} ({:.0}% reduction)",
|
|
format_bytes(result.total_input_bytes),
|
|
format_bytes(result.total_output_bytes),
|
|
if result.total_input_bytes > 0 {
|
|
(1.0 - result.total_output_bytes as f64 / result.total_input_bytes as f64) * 100.0
|
|
} else {
|
|
0.0
|
|
}
|
|
);
|
|
}
|
|
println!(" Time: {}", format_duration(result.elapsed_ms));
|
|
println!(" Output: {}", output_dir.display());
|
|
|
|
if !result.errors.is_empty() {
|
|
println!();
|
|
println!("Errors:");
|
|
for (file, err) in &result.errors {
|
|
println!(" {} - {}", file, err);
|
|
}
|
|
}
|
|
|
|
// Save to history - use actual output paths from the executor
|
|
let history = HistoryStore::new();
|
|
if let Err(e) = history.add(pixstrip_core::storage::HistoryEntry {
|
|
timestamp: unix_timestamp(),
|
|
input_dir: input_dir.canonicalize().unwrap_or_else(|_| input_dir.to_path_buf()).to_string_lossy().into(),
|
|
output_dir: output_dir.canonicalize().unwrap_or_else(|_| output_dir.to_path_buf()).to_string_lossy().into(),
|
|
preset_name: args.preset,
|
|
total: result.total,
|
|
succeeded: result.succeeded,
|
|
failed: result.failed,
|
|
total_input_bytes: result.total_input_bytes,
|
|
total_output_bytes: result.total_output_bytes,
|
|
elapsed_ms: result.elapsed_ms,
|
|
output_files: result.output_files,
|
|
}, 50, 30) {
|
|
eprintln!("Warning: failed to save history (undo may not work): {}", e);
|
|
}
|
|
}
|
|
|
|
fn cmd_preset_list() {
|
|
println!("Built-in presets:");
|
|
for preset in Preset::all_builtins() {
|
|
println!(" {} - {}", preset.name, preset.description);
|
|
}
|
|
|
|
let store = PresetStore::new();
|
|
if let Ok(user_presets) = store.list()
|
|
&& !user_presets.is_empty()
|
|
{
|
|
println!();
|
|
println!("User presets:");
|
|
for preset in user_presets {
|
|
println!(" {} - {}", preset.name, preset.description);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_preset_create(name: &str, description: &str) {
|
|
let store = PresetStore::new();
|
|
// Check if name conflicts with a builtin
|
|
let lower = name.to_lowercase();
|
|
if Preset::all_builtins().iter().any(|p| p.name.to_lowercase() == lower) {
|
|
eprintln!("Cannot create preset '{}': conflicts with a built-in preset name", name);
|
|
std::process::exit(1);
|
|
}
|
|
let preset = Preset {
|
|
name: name.to_string(),
|
|
description: description.to_string(),
|
|
is_custom: true,
|
|
..Preset::default()
|
|
};
|
|
match store.save(&preset) {
|
|
Ok(()) => println!("Created preset '{}'", name),
|
|
Err(e) => {
|
|
eprintln!("Failed to create preset: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_preset_delete(name: &str) {
|
|
let store = PresetStore::new();
|
|
// Don't allow deleting builtins
|
|
let lower = name.to_lowercase();
|
|
if Preset::all_builtins().iter().any(|p| p.name.to_lowercase() == lower) {
|
|
eprintln!("Cannot delete built-in preset '{}'", name);
|
|
std::process::exit(1);
|
|
}
|
|
match store.delete(name) {
|
|
Ok(()) => println!("Deleted preset '{}'", name),
|
|
Err(e) => {
|
|
eprintln!("Failed to delete preset '{}': {}", name, e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_preset_export(name: &str, output: &str) {
|
|
let preset = find_preset(name).unwrap_or_else(|| {
|
|
eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", name);
|
|
std::process::exit(1);
|
|
});
|
|
let store = PresetStore::new();
|
|
match store.export_to_file(&preset, &PathBuf::from(output)) {
|
|
Ok(()) => println!("Exported '{}' to '{}'", name, output),
|
|
Err(e) => {
|
|
eprintln!("Failed to export preset: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_preset_import(path: &str) {
|
|
let store = PresetStore::new();
|
|
match store.import_from_file(&PathBuf::from(path)) {
|
|
Ok(preset) => {
|
|
let name = preset.name.clone();
|
|
match store.save(&preset) {
|
|
Ok(()) => println!("Imported preset '{}'", name),
|
|
Err(e) => {
|
|
eprintln!("Failed to save imported preset: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to import preset: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_history() {
|
|
let history = HistoryStore::new();
|
|
match history.list() {
|
|
Ok(entries) => {
|
|
if entries.is_empty() {
|
|
println!("No processing history yet.");
|
|
return;
|
|
}
|
|
for (i, entry) in entries.iter().enumerate().rev() {
|
|
println!(
|
|
"{}. [{}] {} -> {} ({}/{} succeeded, {})",
|
|
i + 1,
|
|
format_timestamp(&entry.timestamp),
|
|
entry.input_dir,
|
|
entry.output_dir,
|
|
entry.succeeded,
|
|
entry.total,
|
|
format_duration(entry.elapsed_ms),
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to read history: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_undo(count: usize) {
|
|
if count == 0 {
|
|
eprintln!("Must undo at least 1 batch");
|
|
std::process::exit(1);
|
|
}
|
|
let history = HistoryStore::new();
|
|
match history.list() {
|
|
Ok(mut entries) => {
|
|
if entries.is_empty() {
|
|
println!("No processing history to undo.");
|
|
return;
|
|
}
|
|
|
|
let undo_count = count.min(entries.len());
|
|
if count > entries.len() {
|
|
eprintln!("Warning: requested {} batches but only {} available", count, entries.len());
|
|
}
|
|
let to_undo = entries.split_off(entries.len() - undo_count);
|
|
let mut total_trashed = 0;
|
|
let mut failed_entries = Vec::new();
|
|
|
|
for entry in to_undo {
|
|
if entry.output_files.is_empty() {
|
|
println!(
|
|
"Batch from {} has no recorded output files - cannot undo",
|
|
format_timestamp(&entry.timestamp)
|
|
);
|
|
failed_entries.push(entry);
|
|
continue;
|
|
}
|
|
|
|
println!(
|
|
"Undoing batch: {} images from {}",
|
|
entry.total, entry.input_dir
|
|
);
|
|
|
|
let mut remaining_files = Vec::new();
|
|
for file_path in &entry.output_files {
|
|
let path = PathBuf::from(file_path);
|
|
if path.exists() {
|
|
match trash::delete(&path) {
|
|
Ok(()) => {
|
|
total_trashed += 1;
|
|
}
|
|
Err(e) => {
|
|
eprintln!(" Failed to trash {}: {}", path.display(), e);
|
|
remaining_files.push(file_path.clone());
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Keep entry with remaining files if some could not be trashed
|
|
if !remaining_files.is_empty() {
|
|
let mut kept = entry;
|
|
kept.output_files = remaining_files;
|
|
failed_entries.push(kept);
|
|
}
|
|
}
|
|
|
|
// Re-add entries where trash failed so they can be retried
|
|
entries.extend(failed_entries);
|
|
|
|
// Write updated history
|
|
if let Err(e) = history.write_all(&entries) {
|
|
eprintln!("Warning: failed to update history after undo: {}", e);
|
|
}
|
|
|
|
println!("{} files moved to trash", total_trashed);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to read history: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_watch_add(path: &str, preset_name: &str, recursive: bool) {
|
|
// Verify the preset exists
|
|
if find_preset(preset_name).is_none() {
|
|
eprintln!("Preset '{}' not found. Use 'pixstrip preset list' to see available presets.", preset_name);
|
|
std::process::exit(1);
|
|
}
|
|
let watch_path = PathBuf::from(path);
|
|
let watch_path = watch_path.canonicalize().unwrap_or(watch_path);
|
|
if !watch_path.exists() {
|
|
eprintln!("Watch folder does not exist: {}", path);
|
|
std::process::exit(1);
|
|
}
|
|
if !watch_path.is_dir() {
|
|
eprintln!("Watch path is not a directory: {}", path);
|
|
std::process::exit(1);
|
|
}
|
|
|
|
// Save watch folder config
|
|
let watch = pixstrip_core::watcher::WatchFolder {
|
|
path: watch_path,
|
|
preset_name: preset_name.to_string(),
|
|
recursive,
|
|
active: true,
|
|
};
|
|
|
|
// Store in config
|
|
let config_dir = watch_config_dir();
|
|
let watches_path = config_dir.join("watches.json");
|
|
let mut watches = load_watches(&watches_path);
|
|
|
|
// Don't add duplicate paths
|
|
if watches.iter().any(|w| w.path == watch.path) {
|
|
println!("Watch folder already configured: {}", path);
|
|
return;
|
|
}
|
|
|
|
watches.push(watch);
|
|
if let Err(e) = std::fs::create_dir_all(&config_dir) {
|
|
eprintln!("Failed to create config directory: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
match serde_json::to_string_pretty(&watches) {
|
|
Ok(json) => {
|
|
if let Err(e) = pixstrip_core::storage::atomic_write(&watches_path, &json) {
|
|
eprintln!("Failed to write watch config: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to serialize watch config: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
println!("Added watch: {} -> preset '{}'", path, preset_name);
|
|
}
|
|
|
|
fn cmd_watch_list() {
|
|
let config_dir = watch_config_dir();
|
|
let watches_path = config_dir.join("watches.json");
|
|
|
|
let watches = load_watches(&watches_path);
|
|
|
|
if watches.is_empty() {
|
|
println!("No watch folders configured.");
|
|
println!("Use 'pixstrip watch add <path> --preset <name>' to add one.");
|
|
return;
|
|
}
|
|
|
|
println!("Configured watch folders:");
|
|
for watch in &watches {
|
|
let recursive_str = if watch.recursive { " (recursive)" } else { "" };
|
|
let status = if watch.active { "active" } else { "inactive" };
|
|
println!(
|
|
" {} -> '{}' [{}]{}",
|
|
watch.path.display(),
|
|
watch.preset_name,
|
|
status,
|
|
recursive_str
|
|
);
|
|
}
|
|
}
|
|
|
|
fn cmd_watch_remove(path: &str) {
|
|
let config_dir = watch_config_dir();
|
|
let watches_path = config_dir.join("watches.json");
|
|
|
|
let mut watches = load_watches(&watches_path);
|
|
|
|
let original_len = watches.len();
|
|
let target = PathBuf::from(path);
|
|
let target_canonical = target.canonicalize().unwrap_or(target.clone());
|
|
watches.retain(|w| w.path != target && w.path != target_canonical);
|
|
|
|
if watches.len() == original_len {
|
|
println!("Watch folder not found: {}", path);
|
|
return;
|
|
}
|
|
|
|
match serde_json::to_string_pretty(&watches) {
|
|
Ok(json) => {
|
|
if let Err(e) = pixstrip_core::storage::atomic_write(&watches_path, &json) {
|
|
eprintln!("Failed to write watch config: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Failed to serialize watch config: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
println!("Removed watch folder: {}", path);
|
|
}
|
|
|
|
fn cmd_watch_start() {
|
|
let config_dir = watch_config_dir();
|
|
let watches_path = config_dir.join("watches.json");
|
|
|
|
let watches: Vec<pixstrip_core::watcher::WatchFolder> = if watches_path.exists() {
|
|
match std::fs::read_to_string(&watches_path) {
|
|
Ok(content) => match serde_json::from_str(&content) {
|
|
Ok(w) => w,
|
|
Err(e) => {
|
|
eprintln!("Warning: failed to parse watches.json: {}. Using empty config.", e);
|
|
Vec::new()
|
|
}
|
|
},
|
|
Err(e) => {
|
|
eprintln!("Warning: failed to read watches.json: {}", e);
|
|
Vec::new()
|
|
}
|
|
}
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
let active: Vec<_> = watches.iter().filter(|w| w.active).collect();
|
|
if active.is_empty() {
|
|
println!("No active watch folders. Use 'pixstrip watch add' first.");
|
|
return;
|
|
}
|
|
|
|
println!("Starting watch on {} folder(s)...", active.len());
|
|
for w in &active {
|
|
println!(" {} -> '{}'", w.path.display(), w.preset_name);
|
|
}
|
|
println!("Press Ctrl+C to stop.");
|
|
|
|
let (tx, rx) = std::sync::mpsc::channel();
|
|
let mut watchers = Vec::new();
|
|
// Collect output directories so we can skip files inside them (prevent infinite loop)
|
|
let mut output_dirs: Vec<PathBuf> = Vec::new();
|
|
|
|
for watch in &active {
|
|
let watcher = pixstrip_core::watcher::FolderWatcher::new();
|
|
if let Err(e) = watcher.start(watch, tx.clone()) {
|
|
eprintln!("Failed to start watching {}: {}", watch.path.display(), e);
|
|
continue;
|
|
}
|
|
output_dirs.push(watch.path.join("processed"));
|
|
watchers.push((watcher, watch.preset_name.clone()));
|
|
}
|
|
|
|
// Drop the original sender so the channel closes when all watcher threads exit
|
|
drop(tx);
|
|
|
|
if watchers.is_empty() {
|
|
eprintln!("Failed to start any watchers. Check that watch folders exist and are accessible.");
|
|
return;
|
|
}
|
|
|
|
// Process incoming files
|
|
for event in &rx {
|
|
match event {
|
|
pixstrip_core::watcher::WatchEvent::NewImage(path) => {
|
|
// Skip files inside output directories to prevent infinite processing loop.
|
|
// Check if the file is a direct child of any "processed" subdirectory
|
|
// under a watched folder.
|
|
let in_output_dir = output_dirs.iter().any(|d| path.starts_with(d))
|
|
|| active.iter().any(|w| {
|
|
// Skip if the file's immediate parent is "processed" under the watch root
|
|
path.parent()
|
|
.and_then(|p| p.file_name())
|
|
.is_some_and(|name| name == "processed")
|
|
&& path.starts_with(&w.path)
|
|
});
|
|
if in_output_dir {
|
|
continue;
|
|
}
|
|
|
|
println!("New image: {}", path.display());
|
|
|
|
// Wait for file to be fully written (check size stability)
|
|
{
|
|
let mut last_size = 0u64;
|
|
for _ in 0..10 {
|
|
std::thread::sleep(std::time::Duration::from_millis(200));
|
|
let size = std::fs::metadata(&path).map(|m| m.len()).unwrap_or(0);
|
|
if size > 0 && size == last_size {
|
|
break;
|
|
}
|
|
last_size = size;
|
|
}
|
|
}
|
|
|
|
// Find which watcher owns this path and use its preset
|
|
let matched = active.iter()
|
|
.find(|w| path.starts_with(&w.path))
|
|
.map(|w| w.preset_name.clone());
|
|
if matched.is_none() {
|
|
eprintln!(" Warning: no matching watch folder for {}, skipping", path.display());
|
|
continue;
|
|
}
|
|
if let Some(preset_name) = matched.as_deref() {
|
|
let Some(preset) = find_preset(preset_name) else {
|
|
eprintln!(" Preset '{}' not found, skipping", preset_name);
|
|
continue;
|
|
};
|
|
let input_dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")).to_path_buf();
|
|
let output_dir = input_dir.join("processed");
|
|
let mut job = preset.to_job(&input_dir, &output_dir);
|
|
job.add_source(&path);
|
|
|
|
let executor = PipelineExecutor::new();
|
|
match executor.execute(&job, |_| {}) {
|
|
Ok(r) => {
|
|
println!(" Processed: {} -> {}", format_bytes(r.total_input_bytes), format_bytes(r.total_output_bytes));
|
|
let history = HistoryStore::new();
|
|
let _ = history.add(pixstrip_core::storage::HistoryEntry {
|
|
timestamp: unix_timestamp(),
|
|
input_dir: input_dir.to_string_lossy().into(),
|
|
output_dir: output_dir.to_string_lossy().into(),
|
|
preset_name: Some(preset_name.to_string()),
|
|
total: r.total,
|
|
succeeded: r.succeeded,
|
|
failed: r.failed,
|
|
total_input_bytes: r.total_input_bytes,
|
|
total_output_bytes: r.total_output_bytes,
|
|
elapsed_ms: r.elapsed_ms,
|
|
output_files: r.output_files,
|
|
}, 50, 30);
|
|
}
|
|
Err(e) => eprintln!(" Failed: {}", e),
|
|
}
|
|
}
|
|
}
|
|
pixstrip_core::watcher::WatchEvent::Error(err) => {
|
|
eprintln!("Watch error: {}", err);
|
|
}
|
|
}
|
|
}
|
|
|
|
for (w, _) in &watchers {
|
|
w.stop();
|
|
}
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
fn find_preset(name: &str) -> Option<Preset> {
|
|
// Check builtins first (case-insensitive)
|
|
let lower = name.to_lowercase();
|
|
for preset in Preset::all_builtins() {
|
|
if preset.name.to_lowercase() == lower {
|
|
return Some(preset);
|
|
}
|
|
}
|
|
|
|
// Check user presets - try exact match first, then case-insensitive
|
|
let store = PresetStore::new();
|
|
if let Ok(preset) = store.load(name) {
|
|
return Some(preset);
|
|
}
|
|
if let Ok(presets) = store.list() {
|
|
for preset in presets {
|
|
if preset.name.to_lowercase() == lower {
|
|
return Some(preset);
|
|
}
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn parse_resize(s: &str, allow_upscale: bool) -> ResizeConfig {
|
|
// Support "fit:WxH" for fit-in-box mode
|
|
if let Some(dims) = s.strip_prefix("fit:") {
|
|
let (w, h) = dims.split_once('x').unwrap_or_else(|| {
|
|
eprintln!("Invalid fit-in-box syntax: '{}'. Use 'fit:1200x900'", s);
|
|
std::process::exit(1);
|
|
});
|
|
let width: u32 = w.parse().unwrap_or_else(|_| {
|
|
eprintln!("Invalid fit width: '{}'", w);
|
|
std::process::exit(1);
|
|
});
|
|
let height: u32 = h.parse().unwrap_or_else(|_| {
|
|
eprintln!("Invalid fit height: '{}'", h);
|
|
std::process::exit(1);
|
|
});
|
|
if width == 0 || height == 0 {
|
|
eprintln!("Fit dimensions must be greater than zero");
|
|
std::process::exit(1);
|
|
}
|
|
return ResizeConfig::FitInBox {
|
|
max: Dimensions { width, height },
|
|
allow_upscale,
|
|
};
|
|
}
|
|
|
|
if let Some((w, h)) = s.split_once('x') {
|
|
let width: u32 = w.parse().unwrap_or_else(|_| {
|
|
eprintln!("Invalid resize width: '{}'", w);
|
|
std::process::exit(1);
|
|
});
|
|
let height: u32 = h.parse().unwrap_or_else(|_| {
|
|
eprintln!("Invalid resize height: '{}'", h);
|
|
std::process::exit(1);
|
|
});
|
|
if width == 0 || height == 0 {
|
|
eprintln!("Resize dimensions must be greater than zero");
|
|
std::process::exit(1);
|
|
}
|
|
ResizeConfig::Exact(Dimensions { width, height })
|
|
} else {
|
|
let width: u32 = s.parse().unwrap_or_else(|_| {
|
|
eprintln!("Invalid resize value: '{}'. Use a width like '1200', dimensions like '1200x900', or 'fit:1200x900'", s);
|
|
std::process::exit(1);
|
|
});
|
|
if width == 0 {
|
|
eprintln!("Resize width must be greater than zero");
|
|
std::process::exit(1);
|
|
}
|
|
ResizeConfig::ByWidth(width)
|
|
}
|
|
}
|
|
|
|
fn parse_format(s: &str) -> Option<ImageFormat> {
|
|
match s.to_lowercase().as_str() {
|
|
"jpeg" | "jpg" => Some(ImageFormat::Jpeg),
|
|
"png" => Some(ImageFormat::Png),
|
|
"webp" => Some(ImageFormat::WebP),
|
|
"avif" => Some(ImageFormat::Avif),
|
|
"gif" => Some(ImageFormat::Gif),
|
|
"tiff" | "tif" => Some(ImageFormat::Tiff),
|
|
"bmp" => Some(ImageFormat::Bmp),
|
|
_ => {
|
|
eprintln!("Unknown format: '{}'. Supported: jpeg, png, webp, avif, gif, tiff, bmp", s);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_quality(s: &str) -> Option<QualityPreset> {
|
|
match s.to_lowercase().as_str() {
|
|
"maximum" | "max" => Some(QualityPreset::Maximum),
|
|
"high" => Some(QualityPreset::High),
|
|
"medium" | "med" => Some(QualityPreset::Medium),
|
|
"low" => Some(QualityPreset::Low),
|
|
"web" => Some(QualityPreset::WebOptimized),
|
|
_ => {
|
|
eprintln!("Unknown quality: '{}'. Supported: maximum, high, medium, low, web", s);
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_rotation(s: &str) -> Rotation {
|
|
match s.to_lowercase().as_str() {
|
|
"90" | "cw90" => Rotation::Cw90,
|
|
"180" => Rotation::Cw180,
|
|
"270" | "cw270" => Rotation::Cw270,
|
|
"auto" => Rotation::AutoOrient,
|
|
"none" | "0" => Rotation::None,
|
|
_ => {
|
|
eprintln!("Unknown rotation: '{}'. Supported: 90, 180, 270, auto, none", s);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_flip(s: &str) -> Flip {
|
|
match s.to_lowercase().as_str() {
|
|
"horizontal" | "h" => Flip::Horizontal,
|
|
"vertical" | "v" => Flip::Vertical,
|
|
"none" => Flip::None,
|
|
_ => {
|
|
eprintln!("Unknown flip: '{}'. Supported: horizontal, vertical, none", s);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_watermark_position(s: &str) -> WatermarkPosition {
|
|
match s.to_lowercase().replace(' ', "-").as_str() {
|
|
"top-left" | "tl" => WatermarkPosition::TopLeft,
|
|
"top-center" | "tc" | "top" => WatermarkPosition::TopCenter,
|
|
"top-right" | "tr" => WatermarkPosition::TopRight,
|
|
"middle-left" | "ml" | "left" => WatermarkPosition::MiddleLeft,
|
|
"center" | "c" | "middle" => WatermarkPosition::Center,
|
|
"middle-right" | "mr" | "right" => WatermarkPosition::MiddleRight,
|
|
"bottom-left" | "bl" => WatermarkPosition::BottomLeft,
|
|
"bottom-center" | "bc" | "bottom" => WatermarkPosition::BottomCenter,
|
|
"bottom-right" | "br" => WatermarkPosition::BottomRight,
|
|
_ => {
|
|
eprintln!("Unknown position: '{}'. Supported: top-left, top-center, top-right, center, bottom-left, bottom-center, bottom-right", s);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_opacity(s: &str) -> std::result::Result<f32, String> {
|
|
let v: f32 = s.parse().map_err(|e: std::num::ParseFloatError| e.to_string())?;
|
|
if !(0.0..=1.0).contains(&v) {
|
|
return Err("opacity must be between 0.0 and 1.0".into());
|
|
}
|
|
Ok(v)
|
|
}
|
|
|
|
fn parse_format_map(s: &str) -> Vec<(ImageFormat, ImageFormat)> {
|
|
let mut mappings = Vec::new();
|
|
for pair in s.split(',') {
|
|
let pair = pair.trim();
|
|
if pair.is_empty() {
|
|
continue;
|
|
}
|
|
let (from, to) = pair.split_once('=').unwrap_or_else(|| {
|
|
eprintln!("Invalid format mapping: '{}'. Use 'from=to' (e.g., 'png=webp')", pair);
|
|
std::process::exit(1);
|
|
});
|
|
let from_fmt = parse_format(from.trim()).unwrap_or_else(|| std::process::exit(1));
|
|
let to_fmt = parse_format(to.trim()).unwrap_or_else(|| std::process::exit(1));
|
|
if from_fmt == to_fmt {
|
|
eprintln!("Warning: format mapping '{}={}' maps to itself, skipping", from.trim(), to.trim());
|
|
continue;
|
|
}
|
|
mappings.push((from_fmt, to_fmt));
|
|
}
|
|
mappings
|
|
}
|
|
|
|
fn parse_metadata_mode(s: &str) -> MetadataConfig {
|
|
match s.to_lowercase().replace(' ', "-").as_str() {
|
|
"strip-all" | "strip" | "none" => MetadataConfig::StripAll,
|
|
"keep-all" | "keep" | "all" => MetadataConfig::KeepAll,
|
|
"privacy" => MetadataConfig::Privacy,
|
|
other => {
|
|
eprintln!("Unknown metadata mode: '{}'. Supported: strip-all, keep-all, privacy", other);
|
|
eprintln!("For selective stripping, use --strip-gps, --strip-camera, etc.");
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_hex_color(s: &str) -> [u8; 4] {
|
|
let hex = s.strip_prefix('#').unwrap_or(s);
|
|
if hex.len() == 6 || hex.len() == 8 {
|
|
if let Ok(val) = u32::from_str_radix(&hex[..6], 16) {
|
|
let r = ((val >> 16) & 0xFF) as u8;
|
|
let g = ((val >> 8) & 0xFF) as u8;
|
|
let b = (val & 0xFF) as u8;
|
|
let a = if hex.len() == 8 {
|
|
u8::from_str_radix(&hex[6..8], 16).unwrap_or(255)
|
|
} else {
|
|
255
|
|
};
|
|
return [r, g, b, a];
|
|
}
|
|
}
|
|
eprintln!("Warning: invalid hex color '{}', using white. Format: rrggbb or rrggbbaa", s);
|
|
[255, 255, 255, 255]
|
|
}
|
|
|
|
fn parse_watermark_rotation(s: &str) -> WatermarkRotation {
|
|
match s {
|
|
"45" => WatermarkRotation::Degrees45,
|
|
"-45" => WatermarkRotation::DegreesNeg45,
|
|
"90" => WatermarkRotation::Degrees90,
|
|
other => {
|
|
let deg: f32 = other.parse().unwrap_or_else(|_| {
|
|
eprintln!("Invalid watermark rotation: '{}'. Use 45, -45, 90, or a custom degree value", other);
|
|
std::process::exit(1);
|
|
});
|
|
WatermarkRotation::Custom(deg)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_aspect_ratio(s: &str) -> Option<(f64, f64)> {
|
|
if let Some((w, h)) = s.split_once(':') {
|
|
let w: f64 = w.parse().unwrap_or_else(|_| {
|
|
eprintln!("Invalid aspect ratio width: '{}'", w);
|
|
std::process::exit(1);
|
|
});
|
|
let h: f64 = h.parse().unwrap_or_else(|_| {
|
|
eprintln!("Invalid aspect ratio height: '{}'", h);
|
|
std::process::exit(1);
|
|
});
|
|
if w <= 0.0 || h <= 0.0 {
|
|
eprintln!("Aspect ratio values must be positive");
|
|
std::process::exit(1);
|
|
}
|
|
Some((w, h))
|
|
} else {
|
|
eprintln!("Invalid aspect ratio: '{}'. Use format like '16:9' or '4:3'", s);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
|
|
fn parse_case_mode(s: &str) -> u32 {
|
|
match s.to_lowercase().as_str() {
|
|
"lower" | "lowercase" => 1,
|
|
"upper" | "uppercase" => 2,
|
|
"title" | "titlecase" => 3,
|
|
"none" => 0,
|
|
other => {
|
|
eprintln!("Unknown case mode: '{}'. Supported: lower, upper, title, none", other);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_space_mode(s: &str) -> u32 {
|
|
match s.to_lowercase().as_str() {
|
|
"underscore" | "underscores" => 1,
|
|
"hyphen" | "hyphens" | "dash" => 2,
|
|
"dot" | "dots" | "period" => 3,
|
|
"camelcase" | "camel" => 4,
|
|
"remove" | "none" => 5,
|
|
other => {
|
|
eprintln!("Unknown space mode: '{}'. Supported: underscore, hyphen, dot, camelcase, remove", other);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_special_chars_mode(s: &str) -> u32 {
|
|
match s.to_lowercase().replace(' ', "-").as_str() {
|
|
"filesystem-safe" | "filesystem" | "safe" => 1,
|
|
"web-safe" | "web" => 2,
|
|
"hyphens-underscores" | "hyphens-and-underscores" => 3,
|
|
"hyphens" | "hyphens-only" => 4,
|
|
"alphanumeric" | "alnum" => 5,
|
|
"keep" | "none" => 0,
|
|
other => {
|
|
eprintln!("Unknown special chars mode: '{}'. Supported: filesystem-safe, web-safe, hyphens-underscores, hyphens, alphanumeric, none", other);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn parse_counter_position(s: &str) -> u32 {
|
|
match s.to_lowercase().replace(' ', "-").as_str() {
|
|
"before-prefix" => 0,
|
|
"before-name" => 1,
|
|
"after-name" => 2,
|
|
"after-suffix" => 3,
|
|
"replace-name" | "replace" => 4,
|
|
other => {
|
|
eprintln!("Unknown counter position: '{}'. Supported: before-prefix, before-name, after-name, after-suffix, replace-name", other);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn format_bytes(bytes: u64) -> String {
|
|
if bytes < 1024 {
|
|
format!("{} B", bytes)
|
|
} else if bytes < 1024 * 1024 {
|
|
format!("{:.1} KB", bytes as f64 / 1024.0)
|
|
} else if bytes < 1024 * 1024 * 1024 {
|
|
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
|
|
} else {
|
|
format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
|
|
}
|
|
}
|
|
|
|
fn format_duration(ms: u64) -> String {
|
|
if ms < 1000 {
|
|
format!("{}ms", ms)
|
|
} else if ms < 60_000 {
|
|
format!("{:.1}s", ms as f64 / 1000.0)
|
|
} else {
|
|
let mins = ms / 60_000;
|
|
let secs = (ms % 60_000) / 1000;
|
|
format!("{}m {}s", mins, secs)
|
|
}
|
|
}
|
|
|
|
fn unix_timestamp() -> String {
|
|
// Store as Unix seconds string (must match GTK format for pruning compatibility)
|
|
std::time::SystemTime::now()
|
|
.duration_since(std::time::UNIX_EPOCH)
|
|
.unwrap_or_default()
|
|
.as_secs()
|
|
.to_string()
|
|
}
|
|
|
|
/// Format a Unix-seconds timestamp string as human-readable "YYYY-MM-DD HH:MM:SS"
|
|
fn format_timestamp(ts: &str) -> String {
|
|
let Ok(secs) = ts.parse::<u64>() else {
|
|
return ts.to_string(); // already human-readable or unparseable - return as-is
|
|
};
|
|
|
|
let days = secs / 86400;
|
|
let time_secs = secs % 86400;
|
|
let hours = time_secs / 3600;
|
|
let minutes = (time_secs % 3600) / 60;
|
|
let seconds = time_secs % 60;
|
|
|
|
let mut d = days;
|
|
let mut year = 1970u64;
|
|
loop {
|
|
let days_in_year = if (year % 4 == 0 && year % 100 != 0) || year % 400 == 0 { 366 } else { 365 };
|
|
if d < days_in_year { break; }
|
|
d -= days_in_year;
|
|
year += 1;
|
|
}
|
|
let leap = (year % 4 == 0 && year % 100 != 0) || year % 400 == 0;
|
|
let month_days: [u64; 12] = if leap {
|
|
[31, 29, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
|
} else {
|
|
[31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
|
|
};
|
|
let mut month = 1u64;
|
|
for md in &month_days {
|
|
if d < *md { break; }
|
|
d -= md;
|
|
month += 1;
|
|
}
|
|
|
|
format!("{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", year, month, d + 1, hours, minutes, seconds)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parse_format_valid() {
|
|
assert_eq!(parse_format("jpeg"), Some(ImageFormat::Jpeg));
|
|
assert_eq!(parse_format("jpg"), Some(ImageFormat::Jpeg));
|
|
assert_eq!(parse_format("PNG"), Some(ImageFormat::Png));
|
|
assert_eq!(parse_format("webp"), Some(ImageFormat::WebP));
|
|
assert_eq!(parse_format("avif"), Some(ImageFormat::Avif));
|
|
assert_eq!(parse_format("bmp"), Some(ImageFormat::Bmp));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_format_invalid() {
|
|
assert_eq!(parse_format("xyz"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_quality_valid() {
|
|
assert_eq!(parse_quality("maximum"), Some(QualityPreset::Maximum));
|
|
assert_eq!(parse_quality("max"), Some(QualityPreset::Maximum));
|
|
assert_eq!(parse_quality("high"), Some(QualityPreset::High));
|
|
assert_eq!(parse_quality("medium"), Some(QualityPreset::Medium));
|
|
assert_eq!(parse_quality("med"), Some(QualityPreset::Medium));
|
|
assert_eq!(parse_quality("low"), Some(QualityPreset::Low));
|
|
assert_eq!(parse_quality("web"), Some(QualityPreset::WebOptimized));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_quality_invalid() {
|
|
assert_eq!(parse_quality("ultra"), None);
|
|
assert_eq!(parse_quality(""), None);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_opacity_valid() {
|
|
assert!(parse_opacity("0.5").is_ok());
|
|
assert!(parse_opacity("0.0").is_ok());
|
|
assert!(parse_opacity("1.0").is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_opacity_invalid() {
|
|
assert!(parse_opacity("1.5").is_err());
|
|
assert!(parse_opacity("-0.1").is_err());
|
|
assert!(parse_opacity("abc").is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn format_bytes_ranges() {
|
|
assert_eq!(format_bytes(500), "500 B");
|
|
assert_eq!(format_bytes(1024), "1.0 KB");
|
|
assert_eq!(format_bytes(1024 * 1024), "1.0 MB");
|
|
assert_eq!(format_bytes(1024 * 1024 * 1024), "1.0 GB");
|
|
}
|
|
|
|
#[test]
|
|
fn format_duration_ranges() {
|
|
assert_eq!(format_duration(500), "500ms");
|
|
assert_eq!(format_duration(1500), "1.5s");
|
|
assert_eq!(format_duration(90_000), "1m 30s");
|
|
}
|
|
|
|
#[test]
|
|
fn find_preset_builtins() {
|
|
assert!(find_preset("Blog Photos").is_some());
|
|
assert!(find_preset("blog photos").is_some());
|
|
assert!(find_preset("nonexistent preset xyz").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_resize_width_only() {
|
|
let config = parse_resize("1200", false);
|
|
assert!(matches!(config, ResizeConfig::ByWidth(1200)));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_resize_exact() {
|
|
let config = parse_resize("1200x900", false);
|
|
assert!(matches!(config, ResizeConfig::Exact(Dimensions { width: 1200, height: 900 })));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_resize_fit_in_box() {
|
|
let config = parse_resize("fit:1200x900", false);
|
|
assert!(matches!(config, ResizeConfig::FitInBox { max: Dimensions { width: 1200, height: 900 }, allow_upscale: false }));
|
|
let config = parse_resize("fit:800x600", true);
|
|
assert!(matches!(config, ResizeConfig::FitInBox { max: Dimensions { width: 800, height: 600 }, allow_upscale: true }));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_hex_color_valid() {
|
|
assert_eq!(parse_hex_color("ff0000"), [255, 0, 0, 255]);
|
|
assert_eq!(parse_hex_color("#00ff00"), [0, 255, 0, 255]);
|
|
assert_eq!(parse_hex_color("0000ff80"), [0, 0, 255, 128]);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_case_mode_valid() {
|
|
assert_eq!(parse_case_mode("lower"), 1);
|
|
assert_eq!(parse_case_mode("upper"), 2);
|
|
assert_eq!(parse_case_mode("title"), 3);
|
|
assert_eq!(parse_case_mode("none"), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_space_mode_valid() {
|
|
assert_eq!(parse_space_mode("underscore"), 1);
|
|
assert_eq!(parse_space_mode("hyphen"), 2);
|
|
assert_eq!(parse_space_mode("dot"), 3);
|
|
assert_eq!(parse_space_mode("camelcase"), 4);
|
|
assert_eq!(parse_space_mode("remove"), 5);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_metadata_mode_valid() {
|
|
assert!(matches!(parse_metadata_mode("strip-all"), MetadataConfig::StripAll));
|
|
assert!(matches!(parse_metadata_mode("keep-all"), MetadataConfig::KeepAll));
|
|
assert!(matches!(parse_metadata_mode("privacy"), MetadataConfig::Privacy));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_aspect_ratio_valid() {
|
|
assert_eq!(parse_aspect_ratio("16:9"), Some((16.0, 9.0)));
|
|
assert_eq!(parse_aspect_ratio("1:1"), Some((1.0, 1.0)));
|
|
}
|
|
}
|