pipeline cards, context menus, presets, settings overhaul
rewrote pipeline as draggable card strip with per-rule config popovers, added right-click menus to pipeline cards, sidebar tree, and file list, preset import/export with BRU format support, new rules (hash, swap, truncate, sanitize, padding, randomize, text editor, folder name, transliterate), settings dialog with all sections, overlay collision containment, tooltips on icon buttons, empty pipeline default
This commit is contained in:
@@ -17,7 +17,9 @@ log = "0.4"
|
||||
env_logger = "0.11"
|
||||
directories = "6"
|
||||
anyhow = "1"
|
||||
winreg = "0.55"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
@@ -4,7 +4,20 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-minimize",
|
||||
"core:window:allow-toggle-maximize",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-start-dragging",
|
||||
"core:window:allow-set-size",
|
||||
"core:window:allow-set-position",
|
||||
"core:window:allow-outer-position",
|
||||
"core:window:allow-outer-size",
|
||||
"core:window:allow-is-maximized",
|
||||
"core:window:allow-maximize",
|
||||
"core:window:allow-set-always-on-top",
|
||||
"core:window:allow-request-user-attention",
|
||||
"dialog:default",
|
||||
"shell:default"
|
||||
"shell:default",
|
||||
"shell:allow-open"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default permissions for Nomina","local":true,"windows":["main"],"permissions":["core:default","dialog:default","shell:default"]}}
|
||||
{"default":{"identifier":"default","description":"Default permissions for Nomina","local":true,"windows":["main"],"permissions":["core:default","core:window:allow-minimize","core:window:allow-toggle-maximize","core:window:allow-close","core:window:allow-start-dragging","core:window:allow-set-size","core:window:allow-set-position","core:window:allow-outer-position","core:window:allow-outer-size","core:window:allow-is-maximized","core:window:allow-maximize","core:window:allow-set-always-on-top","core:window:allow-request-user-attention","dialog:default","shell:default","shell:allow-open"]}}
|
||||
128
crates/nomina-app/src/commands/context_menu.rs
Normal file
128
crates/nomina-app/src/commands/context_menu.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
use winreg::enums::*;
|
||||
#[cfg(target_os = "windows")]
|
||||
use winreg::RegKey;
|
||||
|
||||
#[tauri::command]
|
||||
pub fn get_launch_args() -> Vec<String> {
|
||||
std::env::args().skip(1).collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn register_context_menu() -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let exe = std::env::current_exe()
|
||||
.map_err(|e| format!("Failed to get exe path: {}", e))?;
|
||||
let exe_str = exe.to_string_lossy().to_string();
|
||||
let icon = format!("{},0", exe_str);
|
||||
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
let base = r"Software\Classes";
|
||||
|
||||
// Context menu for files: *\shell\Nomina
|
||||
let (key, _) = hkcu
|
||||
.create_subkey(format!(r"{}\*\shell\Nomina", base))
|
||||
.map_err(|e| format!("Registry error: {}", e))?;
|
||||
key.set_value("", &"Edit in Nomina")
|
||||
.map_err(|e| format!("Registry error: {}", e))?;
|
||||
key.set_value("Icon", &icon)
|
||||
.map_err(|e| format!("Registry error: {}", e))?;
|
||||
|
||||
let (cmd, _) = hkcu
|
||||
.create_subkey(format!(r"{}\*\shell\Nomina\command", base))
|
||||
.map_err(|e| format!("Registry error: {}", e))?;
|
||||
cmd.set_value("", &format!("\"{}\" \"%1\"", exe_str))
|
||||
.map_err(|e| format!("Registry error: {}", e))?;
|
||||
|
||||
// Context menu for folders: Directory\shell\Nomina
|
||||
let (key, _) = hkcu
|
||||
.create_subkey(format!(r"{}\Directory\shell\Nomina", base))
|
||||
.map_err(|e| format!("Registry error: {}", e))?;
|
||||
key.set_value("", &"Edit in Nomina")
|
||||
.map_err(|e| format!("Registry error: {}", e))?;
|
||||
key.set_value("Icon", &icon)
|
||||
.map_err(|e| format!("Registry error: {}", e))?;
|
||||
|
||||
let (cmd, _) = hkcu
|
||||
.create_subkey(format!(r"{}\Directory\shell\Nomina\command", base))
|
||||
.map_err(|e| format!("Registry error: {}", e))?;
|
||||
cmd.set_value("", &format!("\"{}\" \"%1\"", exe_str))
|
||||
.map_err(|e| format!("Registry error: {}", e))?;
|
||||
|
||||
// Context menu for folder background: Directory\Background\shell\Nomina
|
||||
let (key, _) = hkcu
|
||||
.create_subkey(format!(r"{}\Directory\Background\shell\Nomina", base))
|
||||
.map_err(|e| format!("Registry error: {}", e))?;
|
||||
key.set_value("", &"Edit in Nomina")
|
||||
.map_err(|e| format!("Registry error: {}", e))?;
|
||||
key.set_value("Icon", &icon)
|
||||
.map_err(|e| format!("Registry error: {}", e))?;
|
||||
|
||||
let (cmd, _) = hkcu
|
||||
.create_subkey(format!(r"{}\Directory\Background\shell\Nomina\command", base))
|
||||
.map_err(|e| format!("Registry error: {}", e))?;
|
||||
// %V gives the current folder path when right-clicking background
|
||||
cmd.set_value("", &format!("\"{}\" \"%V\"", exe_str))
|
||||
.map_err(|e| format!("Registry error: {}", e))?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
Err("Context menu registration is only supported on Windows".to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn unregister_context_menu() -> Result<(), String> {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
|
||||
let base = r"Software\Classes";
|
||||
|
||||
let _ = hkcu.delete_subkey_all(format!(r"{}\*\shell\Nomina", base));
|
||||
let _ = hkcu.delete_subkey_all(format!(r"{}\Directory\shell\Nomina", base));
|
||||
let _ = hkcu.delete_subkey_all(format!(r"{}\Directory\Background\shell\Nomina", base));
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
Err("Context menu registration is only supported on Windows".to_string())
|
||||
}
|
||||
|
||||
/// Given CLI args (paths), figure out what folder to open and which files to select.
|
||||
/// Returns (folder_path, selected_file_paths).
|
||||
#[tauri::command]
|
||||
pub fn resolve_launch_paths(args: Vec<String>) -> Option<(String, Vec<String>)> {
|
||||
if args.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let paths: Vec<PathBuf> = args.iter().map(PathBuf::from).filter(|p| p.exists()).collect();
|
||||
if paths.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// If single directory passed (from background click or folder right-click), open it
|
||||
if paths.len() == 1 && paths[0].is_dir() {
|
||||
let folder = paths[0].to_string_lossy().to_string();
|
||||
return Some((folder, vec![]));
|
||||
}
|
||||
|
||||
// For files (or mix), use the parent of the first path as the folder
|
||||
// and collect all paths as the selection
|
||||
let folder = paths[0]
|
||||
.parent()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
if folder.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let selected: Vec<String> = paths.iter().map(|p| p.to_string_lossy().to_string()).collect();
|
||||
Some((folder, selected))
|
||||
}
|
||||
@@ -48,3 +48,55 @@ pub async fn get_file_metadata(path: String) -> Result<FileEntry, String> {
|
||||
accessed: meta.accessed().ok().map(|t| chrono::DateTime::<chrono::Utc>::from(t)),
|
||||
})
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn reveal_in_explorer(path: String) -> Result<(), String> {
|
||||
let p = PathBuf::from(&path);
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
if p.is_dir() {
|
||||
std::process::Command::new("explorer")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
std::process::Command::new("explorer")
|
||||
.args(["/select,", &path])
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if p.is_dir() {
|
||||
std::process::Command::new("open")
|
||||
.arg(&path)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
} else {
|
||||
std::process::Command::new("open")
|
||||
.args(["-R", &path])
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let target = if p.is_dir() {
|
||||
path.clone()
|
||||
} else {
|
||||
p.parent()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.unwrap_or(path.clone())
|
||||
};
|
||||
std::process::Command::new("xdg-open")
|
||||
.arg(&target)
|
||||
.spawn()
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -2,3 +2,5 @@ pub mod files;
|
||||
pub mod rename;
|
||||
pub mod presets;
|
||||
pub mod undo;
|
||||
pub mod context_menu;
|
||||
pub mod updates;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use nomina_core::bru;
|
||||
use nomina_core::preset::NominaPreset;
|
||||
|
||||
#[tauri::command]
|
||||
@@ -19,6 +20,11 @@ pub async fn load_preset(path: String) -> Result<NominaPreset, String> {
|
||||
NominaPreset::load(&PathBuf::from(path)).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn delete_preset(path: String) -> Result<(), String> {
|
||||
std::fs::remove_file(&path).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_presets() -> Result<Vec<PresetInfo>, String> {
|
||||
let dir = get_presets_dir();
|
||||
@@ -70,3 +76,21 @@ fn sanitize_filename(name: &str) -> String {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn export_preset(source_path: String, dest_path: String) -> Result<(), String> {
|
||||
std::fs::copy(&source_path, &dest_path).map_err(|e| e.to_string())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn import_preset(path: String) -> Result<NominaPreset, String> {
|
||||
let p = PathBuf::from(&path);
|
||||
let ext = p.extension().and_then(|e| e.to_str()).unwrap_or("").to_lowercase();
|
||||
|
||||
match ext.as_str() {
|
||||
"nomina" => NominaPreset::load(&p).map_err(|e| e.to_string()),
|
||||
"bru" => bru::parse_bru_file(&p).map_err(|e| e.to_string()),
|
||||
_ => Err(format!("Unsupported preset format: .{}", ext)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,30 +29,62 @@ fn build_rule(cfg: &RuleConfig) -> Option<Box<dyn RenameRule>> {
|
||||
if !cfg.enabled {
|
||||
return None;
|
||||
}
|
||||
let val = &cfg.config;
|
||||
// Re-inject `enabled` - #[serde(flatten)] on RuleConfig consumes it
|
||||
// before individual rule structs can see it
|
||||
let mut val = cfg.config.clone();
|
||||
if let serde_json::Value::Object(ref mut map) = val {
|
||||
map.insert("enabled".to_string(), serde_json::Value::Bool(cfg.enabled));
|
||||
}
|
||||
match cfg.rule_type.as_str() {
|
||||
"replace" => serde_json::from_value::<rules::ReplaceRule>(val.clone())
|
||||
"replace" => serde_json::from_value::<rules::ReplaceRule>(val)
|
||||
.ok()
|
||||
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||
"regex" => serde_json::from_value::<rules::RegexRule>(val.clone())
|
||||
"regex" => serde_json::from_value::<rules::RegexRule>(val)
|
||||
.ok()
|
||||
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||
"remove" => serde_json::from_value::<rules::RemoveRule>(val.clone())
|
||||
"remove" => serde_json::from_value::<rules::RemoveRule>(val)
|
||||
.ok()
|
||||
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||
"add" => serde_json::from_value::<rules::AddRule>(val.clone())
|
||||
"add" => serde_json::from_value::<rules::AddRule>(val)
|
||||
.ok()
|
||||
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||
"case" => serde_json::from_value::<rules::CaseRule>(val.clone())
|
||||
"case" => serde_json::from_value::<rules::CaseRule>(val)
|
||||
.ok()
|
||||
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||
"numbering" => serde_json::from_value::<rules::NumberingRule>(val.clone())
|
||||
"numbering" => serde_json::from_value::<rules::NumberingRule>(val)
|
||||
.ok()
|
||||
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||
"date" => serde_json::from_value::<rules::DateRule>(val.clone())
|
||||
"date" => serde_json::from_value::<rules::DateRule>(val)
|
||||
.ok()
|
||||
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||
"move_parts" => serde_json::from_value::<rules::MovePartsRule>(val.clone())
|
||||
"move_parts" => serde_json::from_value::<rules::MovePartsRule>(val)
|
||||
.ok()
|
||||
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||
"text_editor" => serde_json::from_value::<rules::TextEditorRule>(val)
|
||||
.ok()
|
||||
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||
"hash" => serde_json::from_value::<rules::HashRule>(val)
|
||||
.ok()
|
||||
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||
"folder_name" => serde_json::from_value::<rules::FolderNameRule>(val)
|
||||
.ok()
|
||||
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||
"transliterate" => serde_json::from_value::<rules::TransliterateRule>(val)
|
||||
.ok()
|
||||
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||
"padding" => serde_json::from_value::<rules::PaddingRule>(val)
|
||||
.ok()
|
||||
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||
"truncate" => serde_json::from_value::<rules::TruncateRule>(val)
|
||||
.ok()
|
||||
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||
"randomize" => serde_json::from_value::<rules::RandomizeRule>(val)
|
||||
.ok()
|
||||
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||
"swap" => serde_json::from_value::<rules::SwapRule>(val)
|
||||
.ok()
|
||||
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||
"sanitize" => serde_json::from_value::<rules::SanitizeRule>(val)
|
||||
.ok()
|
||||
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||
_ => None,
|
||||
@@ -64,12 +96,20 @@ pub async fn preview_rename(
|
||||
rules: Vec<RuleConfig>,
|
||||
directory: String,
|
||||
filters: Option<FilterConfig>,
|
||||
selected_paths: Option<Vec<String>>,
|
||||
) -> Result<Vec<PreviewResult>, String> {
|
||||
let scanner = FileScanner::new(
|
||||
PathBuf::from(&directory),
|
||||
filters.unwrap_or_default(),
|
||||
);
|
||||
let files = scanner.scan();
|
||||
let all_files = scanner.scan();
|
||||
|
||||
let files = if let Some(ref paths) = selected_paths {
|
||||
let file_map: std::collections::HashMap<PathBuf, _> = all_files.into_iter().map(|f| (f.path.clone(), f)).collect();
|
||||
paths.iter().filter_map(|p| file_map.get(&PathBuf::from(p)).cloned()).collect()
|
||||
} else {
|
||||
all_files
|
||||
};
|
||||
|
||||
let mut pipeline = Pipeline::new();
|
||||
for cfg in &rules {
|
||||
@@ -77,7 +117,11 @@ pub async fn preview_rename(
|
||||
pipeline.add_step(rule, cfg.step_mode.clone());
|
||||
}
|
||||
if cfg.rule_type == "extension" {
|
||||
if let Ok(ext_rule) = serde_json::from_value(cfg.config.clone()) {
|
||||
let mut ext_val = cfg.config.clone();
|
||||
if let serde_json::Value::Object(ref mut map) = ext_val {
|
||||
map.insert("enabled".to_string(), serde_json::Value::Bool(cfg.enabled));
|
||||
}
|
||||
if let Ok(ext_rule) = serde_json::from_value(ext_val) {
|
||||
pipeline.extension_rule = Some(ext_rule);
|
||||
}
|
||||
}
|
||||
@@ -87,12 +131,40 @@ pub async fn preview_rename(
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn execute_rename(operations: Vec<PreviewResult>) -> Result<RenameReport, String> {
|
||||
let valid: Vec<&PreviewResult> = operations
|
||||
pub async fn execute_rename(
|
||||
operations: Vec<PreviewResult>,
|
||||
create_backup: Option<bool>,
|
||||
undo_limit: Option<usize>,
|
||||
skip_read_only: Option<bool>,
|
||||
conflict_strategy: Option<String>,
|
||||
backup_path: Option<String>,
|
||||
) -> Result<RenameReport, String> {
|
||||
let skip_ro = skip_read_only.unwrap_or(true);
|
||||
let strategy = conflict_strategy.unwrap_or_else(|| "suffix".to_string());
|
||||
|
||||
let mut valid: Vec<&PreviewResult> = operations
|
||||
.iter()
|
||||
.filter(|op| !op.has_conflict && !op.has_error && op.original_name != op.new_name)
|
||||
.collect();
|
||||
|
||||
// skip read-only files if enabled
|
||||
if skip_ro {
|
||||
valid.retain(|op| {
|
||||
if let Ok(meta) = std::fs::metadata(&op.original_path) {
|
||||
!meta.permissions().readonly()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// sort deepest paths first so children are renamed before parents
|
||||
valid.sort_by(|a, b| {
|
||||
let depth_a = a.original_path.components().count();
|
||||
let depth_b = b.original_path.components().count();
|
||||
depth_b.cmp(&depth_a)
|
||||
});
|
||||
|
||||
// validation pass
|
||||
for op in &valid {
|
||||
if !op.original_path.exists() {
|
||||
@@ -100,6 +172,32 @@ pub async fn execute_rename(operations: Vec<PreviewResult>) -> Result<RenameRepo
|
||||
}
|
||||
}
|
||||
|
||||
// create backups if requested
|
||||
if create_backup.unwrap_or(false) && !valid.is_empty() {
|
||||
let backup_dir = if let Some(ref bp) = backup_path {
|
||||
if !bp.is_empty() {
|
||||
PathBuf::from(bp)
|
||||
} else {
|
||||
let first_parent = valid[0].original_path.parent().unwrap();
|
||||
first_parent.join("_nomina_backup")
|
||||
}
|
||||
} else {
|
||||
let first_parent = valid[0].original_path.parent().unwrap();
|
||||
first_parent.join("_nomina_backup")
|
||||
};
|
||||
std::fs::create_dir_all(&backup_dir)
|
||||
.map_err(|e| format!("Failed to create backup directory: {}", e))?;
|
||||
|
||||
for op in &valid {
|
||||
if op.original_path.is_file() {
|
||||
let dest = backup_dir.join(&op.original_name);
|
||||
if let Err(e) = std::fs::copy(&op.original_path, &dest) {
|
||||
return Err(format!("Failed to backup {}: {}", op.original_name, e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut succeeded = 0;
|
||||
let mut failed = Vec::new();
|
||||
let mut undo_entries = Vec::new();
|
||||
@@ -108,11 +206,17 @@ pub async fn execute_rename(operations: Vec<PreviewResult>) -> Result<RenameRepo
|
||||
let parent = op.original_path.parent().unwrap();
|
||||
let new_path = parent.join(&op.new_name);
|
||||
|
||||
// if target exists and isn't another file we're renaming, use temp
|
||||
let needs_temp = new_path.exists()
|
||||
// if target exists and isn't another file we're renaming
|
||||
let target_conflict = new_path.exists()
|
||||
&& !valid.iter().any(|other| other.original_path == new_path);
|
||||
|
||||
let result = if needs_temp {
|
||||
if target_conflict && strategy == "skip" {
|
||||
failed.push(format!("{}: target already exists (skipped)", op.original_name));
|
||||
continue;
|
||||
}
|
||||
|
||||
let result = if target_conflict {
|
||||
// suffix strategy - use temp rename
|
||||
let tmp_name = format!("__nomina_tmp_{}", uuid::Uuid::new_v4());
|
||||
let tmp_path = parent.join(&tmp_name);
|
||||
std::fs::rename(&op.original_path, &tmp_path)
|
||||
@@ -145,7 +249,8 @@ pub async fn execute_rename(operations: Vec<PreviewResult>) -> Result<RenameRepo
|
||||
|
||||
let undo_path = get_undo_log_path();
|
||||
let mut log = UndoLog::load(&undo_path).unwrap_or_else(|_| UndoLog::new());
|
||||
log.add_batch(batch.clone());
|
||||
let max = undo_limit.unwrap_or(nomina_core::undo::DEFAULT_MAX_UNDO_BATCHES);
|
||||
log.add_batch_with_limit(batch.clone(), max);
|
||||
let _ = log.save(&undo_path);
|
||||
|
||||
Ok(RenameReport {
|
||||
|
||||
75
crates/nomina-app/src/commands/updates.rs
Normal file
75
crates/nomina-app/src/commands/updates.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct UpdateInfo {
|
||||
pub available: bool,
|
||||
pub current_version: String,
|
||||
pub latest_version: String,
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn check_for_updates() -> Result<UpdateInfo, String> {
|
||||
let current = env!("CARGO_PKG_VERSION").to_string();
|
||||
let api_url = "https://git.lashman.live/api/v1/repos/lashman/nomina/releases?limit=1";
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(10))
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let resp = client
|
||||
.get(api_url)
|
||||
.header("Accept", "application/json")
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("Network error: {}", e))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
return Err(format!("Server returned {}", resp.status()));
|
||||
}
|
||||
|
||||
let releases: Vec<serde_json::Value> = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("Parse error: {}", e))?;
|
||||
|
||||
let latest = releases.first().ok_or("No releases found")?;
|
||||
let tag = latest["tag_name"]
|
||||
.as_str()
|
||||
.unwrap_or("0.0.0")
|
||||
.trim_start_matches('v')
|
||||
.to_string();
|
||||
let url = latest["html_url"]
|
||||
.as_str()
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let available = version_newer(&tag, ¤t);
|
||||
|
||||
Ok(UpdateInfo {
|
||||
available,
|
||||
current_version: current,
|
||||
latest_version: tag,
|
||||
url,
|
||||
})
|
||||
}
|
||||
|
||||
fn version_newer(latest: &str, current: &str) -> bool {
|
||||
let parse = |s: &str| -> Vec<u32> {
|
||||
s.split('.').filter_map(|p| p.parse().ok()).collect()
|
||||
};
|
||||
let l = parse(latest);
|
||||
let c = parse(current);
|
||||
for i in 0..l.len().max(c.len()) {
|
||||
let lv = l.get(i).copied().unwrap_or(0);
|
||||
let cv = c.get(i).copied().unwrap_or(0);
|
||||
if lv > cv {
|
||||
return true;
|
||||
}
|
||||
if lv < cv {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
mod commands;
|
||||
|
||||
@@ -9,15 +9,24 @@ fn main() {
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::files::scan_directory,
|
||||
commands::files::get_file_metadata,
|
||||
commands::files::reveal_in_explorer,
|
||||
commands::rename::preview_rename,
|
||||
commands::rename::execute_rename,
|
||||
commands::presets::save_preset,
|
||||
commands::presets::load_preset,
|
||||
commands::presets::list_presets,
|
||||
commands::presets::delete_preset,
|
||||
commands::presets::export_preset,
|
||||
commands::presets::import_preset,
|
||||
commands::undo::undo_last,
|
||||
commands::undo::undo_batch,
|
||||
commands::undo::get_undo_history,
|
||||
commands::undo::clear_undo_history,
|
||||
commands::context_menu::get_launch_args,
|
||||
commands::context_menu::register_context_menu,
|
||||
commands::context_menu::unregister_context_menu,
|
||||
commands::context_menu::resolve_launch_paths,
|
||||
commands::updates::check_for_updates,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error running nomina");
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
"build": {
|
||||
"frontendDist": "../../ui/dist",
|
||||
"devUrl": "http://localhost:5173",
|
||||
"beforeDevCommand": "cd ../../ui && npm run dev",
|
||||
"beforeBuildCommand": "cd ../../ui && npm run build"
|
||||
"beforeDevCommand": { "script": "npm run dev", "cwd": "../../ui" },
|
||||
"beforeBuildCommand": { "script": "npm run build", "cwd": "../../ui" }
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
@@ -18,7 +18,10 @@
|
||||
"minWidth": 900,
|
||||
"minHeight": 600,
|
||||
"resizable": true,
|
||||
"fullscreen": false
|
||||
"fullscreen": false,
|
||||
"decorations": false,
|
||||
"shadow": false,
|
||||
"transparent": true
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
|
||||
@@ -17,7 +17,13 @@ thiserror = "2"
|
||||
glob = "0.3"
|
||||
natord = "1"
|
||||
log = "0.4"
|
||||
kamadak-exif = "0.5"
|
||||
kamadak-exif = "0.6"
|
||||
unicode-normalization = "0.1"
|
||||
sha2 = "0.10"
|
||||
md-5 = "0.10"
|
||||
sha1 = "0.10"
|
||||
deunicode = "1"
|
||||
rand = "0.8"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
|
||||
208
crates/nomina-core/src/bru.rs
Normal file
208
crates/nomina-core/src/bru.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::NominaError;
|
||||
use crate::preset::{NominaPreset, PresetRule};
|
||||
|
||||
/// Parse a Bulk Rename Utility (.bru) preset file into a NominaPreset.
|
||||
/// BRU files use an INI-like format with sections for each operation.
|
||||
pub fn parse_bru_file(path: &Path) -> crate::Result<NominaPreset> {
|
||||
let data = std::fs::read_to_string(path).map_err(|e| NominaError::Filesystem {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
|
||||
let sections = parse_ini(&data);
|
||||
let mut rules = Vec::new();
|
||||
|
||||
// RegEx(1) section
|
||||
if let Some(sec) = sections.get("regexmatch(1)") {
|
||||
let pattern = sec.get("match").cloned().unwrap_or_default();
|
||||
let replace = sec.get("replace").cloned().unwrap_or_default();
|
||||
let case = sec.get("casesensitive").map(|v| v == "1").unwrap_or(false);
|
||||
if !pattern.is_empty() {
|
||||
rules.push(make_rule("regex", serde_json::json!({
|
||||
"pattern": pattern,
|
||||
"replacement": replace,
|
||||
"case_sensitive": case,
|
||||
"global": true,
|
||||
"target": "Name",
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
// Replace sections - BRU supports multiple replace slots
|
||||
for i in 1..=14 {
|
||||
let key = if i == 1 { "replace(1)".to_string() } else { format!("replace({})", i) };
|
||||
if let Some(sec) = sections.get(&key) {
|
||||
let search = sec.get("find").or(sec.get("search")).cloned().unwrap_or_default();
|
||||
let replace = sec.get("replace").cloned().unwrap_or_default();
|
||||
let case = sec.get("casesensitive").or(sec.get("matchcase")).map(|v| v == "1").unwrap_or(false);
|
||||
if !search.is_empty() {
|
||||
rules.push(make_rule("replace", serde_json::json!({
|
||||
"search": search,
|
||||
"replace_with": replace,
|
||||
"match_case": case,
|
||||
"use_regex": false,
|
||||
"target": "Name",
|
||||
})));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Remove section
|
||||
if let Some(sec) = sections.get("remove(1)") {
|
||||
let first_n: usize = sec.get("first").and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
let last_n: usize = sec.get("last").and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
let from: usize = sec.get("from").and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
let to: usize = sec.get("to").and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
if first_n > 0 || last_n > 0 || from != to {
|
||||
rules.push(make_rule("remove", serde_json::json!({
|
||||
"first_n": first_n,
|
||||
"last_n": last_n,
|
||||
"from": from,
|
||||
"to": to,
|
||||
"crop_before": "",
|
||||
"crop_after": "",
|
||||
"remove_pattern": "",
|
||||
"collapse_chars": false,
|
||||
"trim": { "spaces": false, "dots": false, "dashes": false, "underscores": false },
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
// Add / Prefix / Suffix section
|
||||
if let Some(sec) = sections.get("add(1)") {
|
||||
let prefix = sec.get("prefix").cloned().unwrap_or_default();
|
||||
let suffix = sec.get("suffix").cloned().unwrap_or_default();
|
||||
let insert = sec.get("insert").cloned().unwrap_or_default();
|
||||
let at_pos: usize = sec.get("atpos").or(sec.get("at")).and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
if !prefix.is_empty() || !suffix.is_empty() || !insert.is_empty() {
|
||||
rules.push(make_rule("add", serde_json::json!({
|
||||
"prefix": prefix,
|
||||
"suffix": suffix,
|
||||
"insert": insert,
|
||||
"position": at_pos,
|
||||
"inserts": [],
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
// Case section
|
||||
if let Some(sec) = sections.get("case(1)") {
|
||||
let mode_num: u32 = sec.get("type").or(sec.get("mode")).and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
let mode = match mode_num {
|
||||
1 => "Lower",
|
||||
2 => "Upper",
|
||||
3 => "Title",
|
||||
4 => "Sentence",
|
||||
5 => "Invert",
|
||||
_ => "Same",
|
||||
};
|
||||
if mode != "Same" {
|
||||
rules.push(make_rule("case", serde_json::json!({
|
||||
"mode": mode,
|
||||
"target": "Name",
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
// Numbering section
|
||||
if let Some(sec) = sections.get("number(1)") {
|
||||
let mode_num: u32 = sec.get("mode").and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
if mode_num > 0 {
|
||||
let start: i64 = sec.get("start").and_then(|v| v.parse().ok()).unwrap_or(1);
|
||||
let step: i64 = sec.get("incr").or(sec.get("step")).and_then(|v| v.parse().ok()).unwrap_or(1);
|
||||
let pad: usize = sec.get("pad").and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
let mode = match mode_num {
|
||||
1 => "Prefix",
|
||||
2 => "Suffix",
|
||||
3 => "Both",
|
||||
_ => "Prefix",
|
||||
};
|
||||
rules.push(make_rule("numbering", serde_json::json!({
|
||||
"mode": mode,
|
||||
"start": start,
|
||||
"step": step,
|
||||
"padding": pad,
|
||||
"separator": "",
|
||||
"format": "Decimal",
|
||||
})));
|
||||
}
|
||||
}
|
||||
|
||||
// Extension section
|
||||
if let Some(sec) = sections.get("extension(1)") {
|
||||
let mode_num: u32 = sec.get("type").or(sec.get("mode")).and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
let new_ext = sec.get("new").or(sec.get("extension")).cloned().unwrap_or_default();
|
||||
let mode = match mode_num {
|
||||
1 => "Lower",
|
||||
2 => "Upper",
|
||||
3 => "Title",
|
||||
4 if !new_ext.is_empty() => "Fixed",
|
||||
5 => "Remove",
|
||||
_ => "Same",
|
||||
};
|
||||
if mode != "Same" {
|
||||
let mut json = serde_json::json!({
|
||||
"mode": mode,
|
||||
"new_extension": new_ext,
|
||||
"mapping": [],
|
||||
});
|
||||
if mode == "Fixed" {
|
||||
json["new_extension"] = serde_json::Value::String(new_ext);
|
||||
}
|
||||
rules.push(make_rule("extension", json));
|
||||
}
|
||||
}
|
||||
|
||||
let name = path.file_stem()
|
||||
.map(|s| s.to_string_lossy().to_string())
|
||||
.unwrap_or_else(|| "Imported BRU preset".to_string());
|
||||
|
||||
Ok(NominaPreset {
|
||||
version: 1,
|
||||
name,
|
||||
description: "Imported from Bulk Rename Utility".to_string(),
|
||||
created: chrono::Utc::now().to_rfc3339(),
|
||||
rules,
|
||||
filters: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn make_rule(rule_type: &str, config: serde_json::Value) -> PresetRule {
|
||||
let mut map = config.as_object().cloned().unwrap_or_default();
|
||||
map.insert("type".to_string(), serde_json::Value::String(rule_type.to_string()));
|
||||
map.insert("step_mode".to_string(), serde_json::Value::String("Simultaneous".to_string()));
|
||||
map.insert("enabled".to_string(), serde_json::Value::Bool(true));
|
||||
|
||||
serde_json::from_value(serde_json::Value::Object(map)).unwrap()
|
||||
}
|
||||
|
||||
/// Parse INI-like BRU format into section -> key/value maps.
|
||||
/// BRU uses `[SectionName]` headers and `Key=Value` pairs.
|
||||
/// Section names are normalized to lowercase for easier lookup.
|
||||
fn parse_ini(text: &str) -> HashMap<String, HashMap<String, String>> {
|
||||
let mut sections: HashMap<String, HashMap<String, String>> = HashMap::new();
|
||||
let mut current_section = String::new();
|
||||
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() || trimmed.starts_with(';') || trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
if trimmed.starts_with('[') && trimmed.ends_with(']') {
|
||||
current_section = trimmed[1..trimmed.len() - 1].trim().to_lowercase();
|
||||
sections.entry(current_section.clone()).or_default();
|
||||
} else if let Some((key, value)) = trimmed.split_once('=') {
|
||||
if !current_section.is_empty() {
|
||||
sections
|
||||
.entry(current_section.clone())
|
||||
.or_default()
|
||||
.insert(key.trim().to_lowercase(), value.trim().to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
sections
|
||||
}
|
||||
@@ -3,6 +3,7 @@ pub mod pipeline;
|
||||
pub mod filter;
|
||||
pub mod metadata;
|
||||
pub mod preset;
|
||||
pub mod bru;
|
||||
pub mod undo;
|
||||
pub mod scanner;
|
||||
|
||||
@@ -54,6 +55,7 @@ pub struct RenameContext {
|
||||
pub size: u64,
|
||||
pub created: Option<DateTime<Utc>>,
|
||||
pub modified: Option<DateTime<Utc>>,
|
||||
pub accessed: Option<DateTime<Utc>>,
|
||||
pub date_taken: Option<DateTime<Utc>>,
|
||||
pub parent_folder: String,
|
||||
}
|
||||
@@ -69,6 +71,7 @@ impl RenameContext {
|
||||
size: 0,
|
||||
created: None,
|
||||
modified: None,
|
||||
accessed: None,
|
||||
date_taken: None,
|
||||
parent_folder: String::new(),
|
||||
}
|
||||
|
||||
@@ -47,6 +47,7 @@ impl Pipeline {
|
||||
size: file.size,
|
||||
created: file.created,
|
||||
modified: file.modified,
|
||||
accessed: file.accessed,
|
||||
date_taken: None,
|
||||
parent_folder: file
|
||||
.path
|
||||
@@ -91,27 +92,11 @@ impl Pipeline {
|
||||
fn apply_rules(&self, stem: &str, ctx: &RenameContext) -> String {
|
||||
let mut working = stem.to_string();
|
||||
|
||||
// collect simultaneous rules
|
||||
let simultaneous: Vec<&PipelineStep> = self
|
||||
.steps
|
||||
.iter()
|
||||
.filter(|s| s.mode == StepMode::Simultaneous && s.rule.is_enabled())
|
||||
.collect();
|
||||
|
||||
// apply simultaneous rules (last one wins for the full output)
|
||||
if !simultaneous.is_empty() {
|
||||
let mut sim_result = stem.to_string();
|
||||
for step in &simultaneous {
|
||||
sim_result = step.rule.apply(stem, ctx);
|
||||
}
|
||||
working = sim_result;
|
||||
}
|
||||
|
||||
// apply sequential rules in order
|
||||
for step in &self.steps {
|
||||
if step.mode == StepMode::Sequential && step.rule.is_enabled() {
|
||||
working = step.rule.apply(&working, ctx);
|
||||
if !step.rule.is_enabled() {
|
||||
continue;
|
||||
}
|
||||
working = step.rule.apply(&working, ctx);
|
||||
}
|
||||
|
||||
working
|
||||
@@ -126,6 +111,18 @@ impl Pipeline {
|
||||
}
|
||||
|
||||
for r in &mut results {
|
||||
// check for empty filename (stem is everything before the last dot)
|
||||
let stem = if let Some(dot_pos) = r.new_name.rfind('.') {
|
||||
&r.new_name[..dot_pos]
|
||||
} else {
|
||||
&r.new_name[..]
|
||||
};
|
||||
if stem.is_empty() {
|
||||
r.has_error = true;
|
||||
r.error_message = Some("Empty filename - add a rule to set a new name".into());
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(&count) = name_counts.get(&r.new_name.to_lowercase()) {
|
||||
if count > 1 {
|
||||
r.has_conflict = true;
|
||||
@@ -177,6 +174,10 @@ mod tests {
|
||||
match_case: true,
|
||||
first_only: false,
|
||||
enabled: true,
|
||||
use_regex: false,
|
||||
scope_start: None,
|
||||
scope_end: None,
|
||||
occurrence: None,
|
||||
}),
|
||||
StepMode::Simultaneous,
|
||||
);
|
||||
@@ -197,6 +198,10 @@ mod tests {
|
||||
match_case: true,
|
||||
first_only: false,
|
||||
enabled: true,
|
||||
use_regex: false,
|
||||
scope_start: None,
|
||||
scope_end: None,
|
||||
occurrence: None,
|
||||
}),
|
||||
StepMode::Sequential,
|
||||
);
|
||||
@@ -224,6 +229,10 @@ mod tests {
|
||||
match_case: true,
|
||||
first_only: false,
|
||||
enabled: false,
|
||||
use_regex: false,
|
||||
scope_start: None,
|
||||
scope_end: None,
|
||||
occurrence: None,
|
||||
}),
|
||||
StepMode::Simultaneous,
|
||||
);
|
||||
|
||||
@@ -2,6 +2,12 @@ use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{RenameContext, RenameRule};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct InsertOp {
|
||||
pub position: usize,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AddRule {
|
||||
pub prefix: String,
|
||||
@@ -10,6 +16,23 @@ pub struct AddRule {
|
||||
pub insert_at: usize,
|
||||
pub word_space: bool,
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub inserts: Vec<InsertOp>,
|
||||
}
|
||||
|
||||
fn expand_vars(template: &str, ctx: &RenameContext) -> String {
|
||||
let date_str = ctx
|
||||
.modified
|
||||
.map(|d| d.format("%Y-%m-%d").to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
template
|
||||
.replace("{name}", &ctx.original_name)
|
||||
.replace("{ext}", &ctx.extension)
|
||||
.replace("{folder}", &ctx.parent_folder)
|
||||
.replace("{date}", &date_str)
|
||||
.replace("{index}", &ctx.index.to_string())
|
||||
.replace("{total}", &ctx.total.to_string())
|
||||
}
|
||||
|
||||
impl AddRule {
|
||||
@@ -21,39 +44,60 @@ impl AddRule {
|
||||
insert_at: 0,
|
||||
word_space: false,
|
||||
enabled: true,
|
||||
inserts: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for AddRule {
|
||||
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
|
||||
fn apply(&self, filename: &str, context: &RenameContext) -> String {
|
||||
let prefix = expand_vars(&self.prefix, context);
|
||||
let suffix = expand_vars(&self.suffix, context);
|
||||
let insert = expand_vars(&self.insert, context);
|
||||
|
||||
let mut result = filename.to_string();
|
||||
|
||||
if !self.insert.is_empty() {
|
||||
if !insert.is_empty() {
|
||||
let chars: Vec<char> = result.chars().collect();
|
||||
let pos = self.insert_at.min(chars.len());
|
||||
let before: String = chars[..pos].iter().collect();
|
||||
let after: String = chars[pos..].iter().collect();
|
||||
if self.word_space {
|
||||
result = format!("{} {} {}", before.trim_end(), self.insert, after.trim_start());
|
||||
result = format!("{} {} {}", before.trim_end(), insert, after.trim_start());
|
||||
} else {
|
||||
result = format!("{}{}{}", before, self.insert, after);
|
||||
result = format!("{}{}{}", before, insert, after);
|
||||
}
|
||||
}
|
||||
|
||||
if !self.prefix.is_empty() {
|
||||
if self.word_space {
|
||||
result = format!("{} {}", self.prefix, result);
|
||||
} else {
|
||||
result = format!("{}{}", self.prefix, result);
|
||||
if !self.inserts.is_empty() {
|
||||
let mut ops: Vec<&InsertOp> = self.inserts.iter().collect();
|
||||
ops.sort_by(|a, b| b.position.cmp(&a.position));
|
||||
for op in ops {
|
||||
let text = expand_vars(&op.text, context);
|
||||
if text.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let chars: Vec<char> = result.chars().collect();
|
||||
let pos = op.position.min(chars.len());
|
||||
let before: String = chars[..pos].iter().collect();
|
||||
let after: String = chars[pos..].iter().collect();
|
||||
result = format!("{}{}{}", before, text, after);
|
||||
}
|
||||
}
|
||||
|
||||
if !self.suffix.is_empty() {
|
||||
if !prefix.is_empty() {
|
||||
if self.word_space {
|
||||
result = format!("{} {}", result, self.suffix);
|
||||
result = format!("{} {}", prefix, result);
|
||||
} else {
|
||||
result = format!("{}{}", result, self.suffix);
|
||||
result = format!("{}{}", prefix, result);
|
||||
}
|
||||
}
|
||||
|
||||
if !suffix.is_empty() {
|
||||
if self.word_space {
|
||||
result = format!("{} {}", result, suffix);
|
||||
} else {
|
||||
result = format!("{}{}", result, suffix);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,4 +151,39 @@ mod tests {
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("abcd", &ctx), "ab-x-cd");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_name_var() {
|
||||
let rule = AddRule {
|
||||
prefix: "{name}_copy".into(),
|
||||
..AddRule::new()
|
||||
};
|
||||
let mut ctx = RenameContext::dummy(0);
|
||||
ctx.original_name = "photo".into();
|
||||
assert_eq!(rule.apply("file", &ctx), "photo_copyfile");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_index_total() {
|
||||
let rule = AddRule {
|
||||
suffix: "_{index}_of_{total}".into(),
|
||||
..AddRule::new()
|
||||
};
|
||||
let mut ctx = RenameContext::dummy(3);
|
||||
ctx.total = 10;
|
||||
assert_eq!(rule.apply("file", &ctx), "file_3_of_10");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multi_inserts_reverse_order() {
|
||||
let rule = AddRule {
|
||||
inserts: vec![
|
||||
InsertOp { position: 1, text: "X".into() },
|
||||
InsertOp { position: 3, text: "Y".into() },
|
||||
],
|
||||
..AddRule::new()
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("abcd", &ctx), "aXbcYd");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,12 @@ pub enum CaseMode {
|
||||
Sentence,
|
||||
Invert,
|
||||
Random,
|
||||
CamelCase,
|
||||
PascalCase,
|
||||
SnakeCase,
|
||||
KebabCase,
|
||||
DotCase,
|
||||
SmartTitle,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -38,6 +44,62 @@ impl CaseRule {
|
||||
}
|
||||
}
|
||||
|
||||
fn split_words(s: &str) -> Vec<String> {
|
||||
let mut words = Vec::new();
|
||||
let mut current = String::new();
|
||||
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
for i in 0..chars.len() {
|
||||
let c = chars[i];
|
||||
if !c.is_alphanumeric() {
|
||||
if !current.is_empty() {
|
||||
words.push(std::mem::take(&mut current));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let should_split = if i > 0 && chars[i - 1].is_alphanumeric() {
|
||||
if c.is_uppercase() {
|
||||
// don't split between consecutive uppercase unless next is lowercase
|
||||
// e.g. "XMLParser" -> split before 'P' not before 'M' or 'L'
|
||||
let prev_upper = chars[i - 1].is_uppercase();
|
||||
if prev_upper {
|
||||
i + 1 < chars.len() && chars[i + 1].is_lowercase()
|
||||
} else {
|
||||
true
|
||||
}
|
||||
} else {
|
||||
false
|
||||
}
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
if should_split && !current.is_empty() {
|
||||
words.push(std::mem::take(&mut current));
|
||||
}
|
||||
current.push(c);
|
||||
}
|
||||
if !current.is_empty() {
|
||||
words.push(current);
|
||||
}
|
||||
words
|
||||
}
|
||||
|
||||
const SMALL_WORDS: &[&str] = &[
|
||||
"a", "an", "the", "and", "but", "or", "for", "nor",
|
||||
"in", "on", "at", "to", "by", "of", "up", "as",
|
||||
"is", "it", "if", "so", "no", "not", "yet",
|
||||
];
|
||||
|
||||
fn capitalize(s: &str) -> String {
|
||||
let mut chars = s.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(c) => c.to_uppercase().to_string() + chars.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for CaseRule {
|
||||
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
|
||||
match self.mode {
|
||||
@@ -99,6 +161,73 @@ impl RenameRule for CaseRule {
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
CaseMode::CamelCase => {
|
||||
let words = split_words(filename);
|
||||
words
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, w)| {
|
||||
if i == 0 {
|
||||
w.to_lowercase()
|
||||
} else {
|
||||
capitalize(&w.to_lowercase())
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
CaseMode::PascalCase => {
|
||||
split_words(filename)
|
||||
.iter()
|
||||
.map(|w| capitalize(&w.to_lowercase()))
|
||||
.collect()
|
||||
}
|
||||
CaseMode::SnakeCase => {
|
||||
split_words(filename)
|
||||
.iter()
|
||||
.map(|w| w.to_lowercase())
|
||||
.collect::<Vec<_>>()
|
||||
.join("_")
|
||||
}
|
||||
CaseMode::KebabCase => {
|
||||
split_words(filename)
|
||||
.iter()
|
||||
.map(|w| w.to_lowercase())
|
||||
.collect::<Vec<_>>()
|
||||
.join("-")
|
||||
}
|
||||
CaseMode::DotCase => {
|
||||
split_words(filename)
|
||||
.iter()
|
||||
.map(|w| w.to_lowercase())
|
||||
.collect::<Vec<_>>()
|
||||
.join(".")
|
||||
}
|
||||
CaseMode::SmartTitle => {
|
||||
let exceptions = self.exception_words();
|
||||
filename
|
||||
.split_inclusive(|c: char| !c.is_alphanumeric())
|
||||
.enumerate()
|
||||
.map(|(i, word)| {
|
||||
let trimmed = word.trim();
|
||||
let lower = trimmed.to_lowercase();
|
||||
if i > 0
|
||||
&& (exceptions.contains(&lower)
|
||||
|| SMALL_WORDS.contains(&lower.as_str()))
|
||||
{
|
||||
word.to_lowercase()
|
||||
} else {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(c) => {
|
||||
c.to_uppercase().to_string()
|
||||
+ &chars.as_str().to_lowercase()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,4 +275,53 @@ mod tests {
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("Hello", &ctx), "hELLO");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn camel_case() {
|
||||
let rule = CaseRule { mode: CaseMode::CamelCase, ..CaseRule::new() };
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("some_file-name", &ctx), "someFileName");
|
||||
assert_eq!(rule.apply("hello world", &ctx), "helloWorld");
|
||||
assert_eq!(rule.apply("XMLParser", &ctx), "xmlParser");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pascal_case() {
|
||||
let rule = CaseRule { mode: CaseMode::PascalCase, ..CaseRule::new() };
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("some_file-name", &ctx), "SomeFileName");
|
||||
assert_eq!(rule.apply("hello world", &ctx), "HelloWorld");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn snake_case() {
|
||||
let rule = CaseRule { mode: CaseMode::SnakeCase, ..CaseRule::new() };
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("camelCase", &ctx), "camel_case");
|
||||
assert_eq!(rule.apply("XMLParser", &ctx), "xml_parser");
|
||||
assert_eq!(rule.apply("some-file name", &ctx), "some_file_name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kebab_case() {
|
||||
let rule = CaseRule { mode: CaseMode::KebabCase, ..CaseRule::new() };
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("camelCase", &ctx), "camel-case");
|
||||
assert_eq!(rule.apply("some_file name", &ctx), "some-file-name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dot_case() {
|
||||
let rule = CaseRule { mode: CaseMode::DotCase, ..CaseRule::new() };
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("camelCase", &ctx), "camel.case");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn smart_title() {
|
||||
let rule = CaseRule { mode: CaseMode::SmartTitle, ..CaseRule::new() };
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("the lord of the rings", &ctx), "The Lord of the Rings");
|
||||
assert_eq!(rule.apply("a tale of two cities", &ctx), "A Tale of Two Cities");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ impl DateRule {
|
||||
match self.source {
|
||||
DateSource::Created => context.created,
|
||||
DateSource::Modified => context.modified,
|
||||
DateSource::Accessed => None,
|
||||
DateSource::Accessed => context.accessed,
|
||||
DateSource::ExifTaken | DateSource::ExifDigitized => context.date_taken,
|
||||
DateSource::Current => Some(chrono::Utc::now()),
|
||||
}
|
||||
|
||||
@@ -18,6 +18,10 @@ pub struct ExtensionRule {
|
||||
pub mode: ExtensionMode,
|
||||
pub fixed_value: String,
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub mapping: Option<Vec<(String, String)>>,
|
||||
#[serde(default)]
|
||||
pub multi_extension: bool,
|
||||
}
|
||||
|
||||
impl ExtensionRule {
|
||||
@@ -26,10 +30,21 @@ impl ExtensionRule {
|
||||
mode: ExtensionMode::Same,
|
||||
fixed_value: String::new(),
|
||||
enabled: true,
|
||||
mapping: None,
|
||||
multi_extension: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transform_extension(&self, ext: &str) -> String {
|
||||
if let Some(ref mappings) = self.mapping {
|
||||
let ext_lower = ext.to_lowercase();
|
||||
for (from, to) in mappings {
|
||||
if ext_lower == from.to_lowercase() {
|
||||
return to.clone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match self.mode {
|
||||
ExtensionMode::Same => ext.to_string(),
|
||||
ExtensionMode::Lower => ext.to_lowercase(),
|
||||
|
||||
127
crates/nomina-core/src/rules/folder_name.rs
Normal file
127
crates/nomina-core/src/rules/folder_name.rs
Normal file
@@ -0,0 +1,127 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{RenameContext, RenameRule};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub enum FolderMode {
|
||||
#[default]
|
||||
None,
|
||||
Prefix,
|
||||
Suffix,
|
||||
Replace,
|
||||
}
|
||||
|
||||
fn default_level() -> usize {
|
||||
1
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FolderNameRule {
|
||||
pub mode: FolderMode,
|
||||
#[serde(default = "default_level")]
|
||||
pub level: usize,
|
||||
pub separator: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl FolderNameRule {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
mode: FolderMode::None,
|
||||
level: 1,
|
||||
separator: String::new(),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_folder_name(&self, context: &RenameContext) -> Option<String> {
|
||||
if self.level == 1 {
|
||||
if context.parent_folder.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(context.parent_folder.clone());
|
||||
}
|
||||
|
||||
context
|
||||
.path
|
||||
.ancestors()
|
||||
.nth(self.level)
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for FolderNameRule {
|
||||
fn apply(&self, filename: &str, context: &RenameContext) -> String {
|
||||
if self.mode == FolderMode::None {
|
||||
return filename.to_string();
|
||||
}
|
||||
|
||||
let folder = match self.get_folder_name(context) {
|
||||
Some(f) => f,
|
||||
None => return filename.to_string(),
|
||||
};
|
||||
|
||||
match self.mode {
|
||||
FolderMode::None => unreachable!(),
|
||||
FolderMode::Prefix => format!("{}{}{}", folder, self.separator, filename),
|
||||
FolderMode::Suffix => format!("{}{}{}", filename, self.separator, folder),
|
||||
FolderMode::Replace => folder,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Folder Name"
|
||||
}
|
||||
|
||||
fn rule_type(&self) -> &str {
|
||||
"folder_name"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn prefix_with_parent() {
|
||||
let rule = FolderNameRule {
|
||||
mode: FolderMode::Prefix,
|
||||
separator: " - ".into(),
|
||||
..FolderNameRule::new()
|
||||
};
|
||||
let mut ctx = RenameContext::dummy(0);
|
||||
ctx.parent_folder = "Photos".into();
|
||||
assert_eq!(rule.apply("sunset", &ctx), "Photos - sunset");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suffix_mode() {
|
||||
let rule = FolderNameRule {
|
||||
mode: FolderMode::Suffix,
|
||||
separator: "_".into(),
|
||||
..FolderNameRule::new()
|
||||
};
|
||||
let mut ctx = RenameContext::dummy(0);
|
||||
ctx.parent_folder = "2024".into();
|
||||
assert_eq!(rule.apply("photo", &ctx), "photo_2024");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grandparent_level() {
|
||||
let rule = FolderNameRule {
|
||||
mode: FolderMode::Prefix,
|
||||
level: 2,
|
||||
separator: "_".into(),
|
||||
..FolderNameRule::new()
|
||||
};
|
||||
let mut ctx = RenameContext::dummy(0);
|
||||
ctx.path = PathBuf::from("/home/user/docs/sub/file.txt");
|
||||
assert_eq!(rule.apply("file", &ctx), "docs_file");
|
||||
}
|
||||
}
|
||||
142
crates/nomina-core/src/rules/hash.rs
Normal file
142
crates/nomina-core/src/rules/hash.rs
Normal file
@@ -0,0 +1,142 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::Digest;
|
||||
|
||||
use crate::{RenameContext, RenameRule};
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub enum HashMode {
|
||||
#[default]
|
||||
None,
|
||||
Prefix,
|
||||
Suffix,
|
||||
Replace,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
|
||||
pub enum HashAlgorithm {
|
||||
MD5,
|
||||
SHA1,
|
||||
#[default]
|
||||
SHA256,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct HashRule {
|
||||
pub mode: HashMode,
|
||||
pub algorithm: HashAlgorithm,
|
||||
pub length: usize,
|
||||
pub separator: String,
|
||||
pub uppercase: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl HashRule {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
mode: HashMode::None,
|
||||
algorithm: HashAlgorithm::SHA256,
|
||||
length: 0,
|
||||
separator: "_".into(),
|
||||
uppercase: false,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn compute_hash(&self, data: &[u8]) -> String {
|
||||
let raw = match self.algorithm {
|
||||
HashAlgorithm::MD5 => {
|
||||
let mut h = md5::Md5::new();
|
||||
h.update(data);
|
||||
format!("{:x}", h.finalize())
|
||||
}
|
||||
HashAlgorithm::SHA1 => {
|
||||
let mut h = sha1::Sha1::new();
|
||||
h.update(data);
|
||||
format!("{:x}", h.finalize())
|
||||
}
|
||||
HashAlgorithm::SHA256 => {
|
||||
let mut h = sha2::Sha256::new();
|
||||
h.update(data);
|
||||
format!("{:x}", h.finalize())
|
||||
}
|
||||
};
|
||||
|
||||
let truncated = if self.length > 0 && self.length < raw.len() {
|
||||
&raw[..self.length]
|
||||
} else {
|
||||
&raw
|
||||
};
|
||||
|
||||
if self.uppercase {
|
||||
truncated.to_uppercase()
|
||||
} else {
|
||||
truncated.to_string()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for HashRule {
|
||||
fn apply(&self, filename: &str, context: &RenameContext) -> String {
|
||||
if self.mode == HashMode::None {
|
||||
return filename.to_string();
|
||||
}
|
||||
|
||||
let data = match std::fs::read(&context.path) {
|
||||
Ok(d) => d,
|
||||
Err(_) => return filename.to_string(),
|
||||
};
|
||||
|
||||
let hash = self.compute_hash(&data);
|
||||
|
||||
match self.mode {
|
||||
HashMode::None => filename.to_string(),
|
||||
HashMode::Prefix => format!("{}{}{}", hash, self.separator, filename),
|
||||
HashMode::Suffix => format!("{}{}{}", filename, self.separator, hash),
|
||||
HashMode::Replace => hash,
|
||||
}
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Hash"
|
||||
}
|
||||
|
||||
fn rule_type(&self) -> &str {
|
||||
"hash"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn mode_none_returns_unchanged() {
|
||||
let rule = HashRule::new();
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("photo.jpg", &ctx), "photo.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn file_read_fails_returns_unchanged() {
|
||||
let mut rule = HashRule::new();
|
||||
rule.mode = HashMode::Prefix;
|
||||
let ctx = RenameContext::dummy(0);
|
||||
// dummy context points to a nonexistent file, so read fails
|
||||
assert_eq!(rule.apply("photo.jpg", &ctx), "photo.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suffix_mode_read_fail_unchanged() {
|
||||
let mut rule = HashRule::new();
|
||||
rule.mode = HashMode::Suffix;
|
||||
rule.algorithm = HashAlgorithm::MD5;
|
||||
rule.length = 8;
|
||||
rule.uppercase = true;
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("doc.pdf", &ctx), "doc.pdf");
|
||||
}
|
||||
}
|
||||
@@ -7,13 +7,31 @@ pub mod numbering;
|
||||
pub mod date;
|
||||
pub mod move_parts;
|
||||
pub mod extension;
|
||||
pub mod text_editor;
|
||||
pub mod hash;
|
||||
pub mod folder_name;
|
||||
pub mod transliterate;
|
||||
pub mod padding;
|
||||
pub mod truncate;
|
||||
pub mod randomize;
|
||||
pub mod swap;
|
||||
pub mod sanitize;
|
||||
|
||||
pub use replace::ReplaceRule;
|
||||
pub use self::regex::RegexRule;
|
||||
pub use remove::RemoveRule;
|
||||
pub use add::AddRule;
|
||||
pub use add::{AddRule, InsertOp};
|
||||
pub use case::{CaseMode, CaseRule};
|
||||
pub use numbering::{NumberBase, NumberMode, NumberingRule};
|
||||
pub use date::{DateMode, DateRule, DateSource};
|
||||
pub use move_parts::{MovePartsRule, MoveTarget};
|
||||
pub use extension::{ExtensionMode, ExtensionRule};
|
||||
pub use text_editor::TextEditorRule;
|
||||
pub use hash::HashRule;
|
||||
pub use folder_name::FolderNameRule;
|
||||
pub use transliterate::TransliterateRule;
|
||||
pub use padding::PaddingRule;
|
||||
pub use truncate::TruncateRule;
|
||||
pub use randomize::RandomizeRule;
|
||||
pub use swap::SwapRule;
|
||||
pub use sanitize::SanitizeRule;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{RenameContext, RenameRule};
|
||||
@@ -10,6 +11,19 @@ pub enum MoveTarget {
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum SelectionMode {
|
||||
Chars,
|
||||
Words,
|
||||
Regex,
|
||||
}
|
||||
|
||||
impl Default for SelectionMode {
|
||||
fn default() -> Self {
|
||||
SelectionMode::Chars
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MovePartsRule {
|
||||
pub source_from: usize,
|
||||
@@ -18,6 +32,16 @@ pub struct MovePartsRule {
|
||||
pub separator: String,
|
||||
pub copy_mode: bool,
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub selection_mode: SelectionMode,
|
||||
#[serde(default)]
|
||||
pub regex_pattern: Option<String>,
|
||||
#[serde(default)]
|
||||
pub regex_group: usize,
|
||||
#[serde(default)]
|
||||
pub swap_with_from: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub swap_with_length: Option<usize>,
|
||||
}
|
||||
|
||||
impl MovePartsRule {
|
||||
@@ -29,12 +53,62 @@ impl MovePartsRule {
|
||||
separator: String::new(),
|
||||
copy_mode: false,
|
||||
enabled: true,
|
||||
selection_mode: SelectionMode::Chars,
|
||||
regex_pattern: None,
|
||||
regex_group: 0,
|
||||
swap_with_from: None,
|
||||
swap_with_length: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn split_words(s: &str) -> Vec<(usize, usize)> {
|
||||
let mut spans = Vec::new();
|
||||
let chars: Vec<char> = s.chars().collect();
|
||||
let mut i = 0;
|
||||
while i < chars.len() {
|
||||
if chars[i].is_whitespace() || matches!(chars[i], '_' | '-' | '.') {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let start = i;
|
||||
while i < chars.len() && !chars[i].is_whitespace() && !matches!(chars[i], '_' | '-' | '.') {
|
||||
i += 1;
|
||||
}
|
||||
spans.push((start, i));
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
impl RenameRule for MovePartsRule {
|
||||
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
|
||||
// swap mode
|
||||
if let (Some(sw_from), Some(sw_len)) = (self.swap_with_from, self.swap_with_length) {
|
||||
return self.apply_swap(filename, sw_from, sw_len);
|
||||
}
|
||||
|
||||
match self.selection_mode {
|
||||
SelectionMode::Chars => self.apply_chars(filename),
|
||||
SelectionMode::Words => self.apply_words(filename),
|
||||
SelectionMode::Regex => self.apply_regex(filename),
|
||||
}
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Move/Copy"
|
||||
}
|
||||
|
||||
fn rule_type(&self) -> &str {
|
||||
"move_parts"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
impl MovePartsRule {
|
||||
fn apply_chars(&self, filename: &str) -> String {
|
||||
if self.target == MoveTarget::None || self.source_length == 0 {
|
||||
return filename.to_string();
|
||||
}
|
||||
@@ -59,8 +133,132 @@ impl RenameRule for MovePartsRule {
|
||||
r
|
||||
};
|
||||
|
||||
self.place_at_target(&extracted, &remaining)
|
||||
}
|
||||
|
||||
fn apply_words(&self, filename: &str) -> String {
|
||||
if self.target == MoveTarget::None || self.source_length == 0 {
|
||||
return filename.to_string();
|
||||
}
|
||||
|
||||
let words = split_words(filename);
|
||||
if self.source_from >= words.len() {
|
||||
return filename.to_string();
|
||||
}
|
||||
|
||||
let word_end = (self.source_from + self.source_length).min(words.len());
|
||||
let byte_start = words[self.source_from].0;
|
||||
let byte_end = words[word_end - 1].1;
|
||||
|
||||
let chars: Vec<char> = filename.chars().collect();
|
||||
let extracted: String = chars[byte_start..byte_end].iter().collect();
|
||||
|
||||
let remaining: String = if self.copy_mode {
|
||||
filename.to_string()
|
||||
} else {
|
||||
// remove extracted span, trimming adjacent separators
|
||||
let mut r = String::new();
|
||||
let mut skip_start = byte_start;
|
||||
let mut skip_end = byte_end;
|
||||
// extend skip to eat one adjacent separator
|
||||
if skip_end < chars.len() && (chars[skip_end].is_whitespace() || matches!(chars[skip_end], '_' | '-' | '.')) {
|
||||
skip_end += 1;
|
||||
} else if skip_start > 0 && (chars[skip_start - 1].is_whitespace() || matches!(chars[skip_start - 1], '_' | '-' | '.')) {
|
||||
skip_start -= 1;
|
||||
}
|
||||
for (i, c) in chars.iter().enumerate() {
|
||||
if i < skip_start || i >= skip_end {
|
||||
r.push(*c);
|
||||
}
|
||||
}
|
||||
r
|
||||
};
|
||||
|
||||
self.place_at_target(&extracted, &remaining)
|
||||
}
|
||||
|
||||
fn apply_regex(&self, filename: &str) -> String {
|
||||
let pat = match &self.regex_pattern {
|
||||
Some(p) if !p.is_empty() => p,
|
||||
_ => return filename.to_string(),
|
||||
};
|
||||
let re = match Regex::new(pat) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return filename.to_string(),
|
||||
};
|
||||
let caps = match re.captures(filename) {
|
||||
Some(c) => c,
|
||||
None => return filename.to_string(),
|
||||
};
|
||||
let full_match = match caps.get(0) {
|
||||
Some(m) => m,
|
||||
None => return filename.to_string(),
|
||||
};
|
||||
let extracted = match caps.get(self.regex_group) {
|
||||
Some(m) => m.as_str().to_string(),
|
||||
None => return filename.to_string(),
|
||||
};
|
||||
|
||||
if self.target == MoveTarget::None {
|
||||
return filename.to_string();
|
||||
}
|
||||
|
||||
// remove the full match from filename
|
||||
let remaining = if self.copy_mode {
|
||||
filename.to_string()
|
||||
} else {
|
||||
let mut r = String::new();
|
||||
r.push_str(&filename[..full_match.start()]);
|
||||
r.push_str(&filename[full_match.end()..]);
|
||||
r
|
||||
};
|
||||
|
||||
self.place_at_target(&extracted, &remaining)
|
||||
}
|
||||
|
||||
fn apply_swap(&self, filename: &str, sw_from: usize, sw_len: usize) -> String {
|
||||
let chars: Vec<char> = filename.chars().collect();
|
||||
let len = chars.len();
|
||||
|
||||
let a_start = self.source_from;
|
||||
let a_end = (a_start + self.source_length).min(len);
|
||||
let b_start = sw_from;
|
||||
let b_end = (b_start + sw_len).min(len);
|
||||
|
||||
if a_start >= len || b_start >= len || self.source_length == 0 || sw_len == 0 {
|
||||
return filename.to_string();
|
||||
}
|
||||
|
||||
// ranges must not overlap
|
||||
if a_start < b_end && b_start < a_end {
|
||||
return filename.to_string();
|
||||
}
|
||||
|
||||
// ensure first < second
|
||||
let (r1_start, r1_end, r2_start, r2_end) = if a_start < b_start {
|
||||
(a_start, a_end, b_start, b_end)
|
||||
} else {
|
||||
(b_start, b_end, a_start, a_end)
|
||||
};
|
||||
|
||||
let part1: String = chars[r1_start..r1_end].iter().collect();
|
||||
let part2: String = chars[r2_start..r2_end].iter().collect();
|
||||
|
||||
let mut result = String::new();
|
||||
let before: String = chars[..r1_start].iter().collect();
|
||||
let mid: String = chars[r1_end..r2_start].iter().collect();
|
||||
let after: String = chars[r2_end..].iter().collect();
|
||||
result.push_str(&before);
|
||||
result.push_str(&part2);
|
||||
result.push_str(&mid);
|
||||
result.push_str(&part1);
|
||||
result.push_str(&after);
|
||||
result
|
||||
}
|
||||
|
||||
fn place_at_target(&self, extracted: &str, remaining: &str) -> String {
|
||||
match &self.target {
|
||||
MoveTarget::None => filename.to_string(),
|
||||
MoveTarget::None => remaining.to_string(),
|
||||
MoveTarget::Start => {
|
||||
if self.separator.is_empty() {
|
||||
format!("{}{}", extracted, remaining)
|
||||
@@ -84,18 +282,6 @@ impl RenameRule for MovePartsRule {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Move/Copy"
|
||||
}
|
||||
|
||||
fn rule_type(&self) -> &str {
|
||||
"move_parts"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -18,6 +18,7 @@ pub enum NumberBase {
|
||||
Octal,
|
||||
Binary,
|
||||
Alpha,
|
||||
Roman,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -32,6 +33,29 @@ pub struct NumberingRule {
|
||||
pub per_folder: bool,
|
||||
pub insert_at: usize,
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub custom_format: Option<String>,
|
||||
#[serde(default)]
|
||||
pub reverse: bool,
|
||||
}
|
||||
|
||||
fn to_roman(mut n: i64) -> Option<String> {
|
||||
if n <= 0 {
|
||||
return None;
|
||||
}
|
||||
let table = [
|
||||
(1000, "M"), (900, "CM"), (500, "D"), (400, "CD"),
|
||||
(100, "C"), (90, "XC"), (50, "L"), (40, "XL"),
|
||||
(10, "X"), (9, "IX"), (5, "V"), (4, "IV"), (1, "I"),
|
||||
];
|
||||
let mut result = String::new();
|
||||
for &(val, sym) in &table {
|
||||
while n >= val {
|
||||
result.push_str(sym);
|
||||
n -= val;
|
||||
}
|
||||
}
|
||||
Some(result)
|
||||
}
|
||||
|
||||
impl NumberingRule {
|
||||
@@ -47,6 +71,8 @@ impl NumberingRule {
|
||||
per_folder: false,
|
||||
insert_at: 0,
|
||||
enabled: true,
|
||||
custom_format: None,
|
||||
reverse: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +97,9 @@ impl NumberingRule {
|
||||
}
|
||||
result
|
||||
}
|
||||
NumberBase::Roman => {
|
||||
return to_roman(n).unwrap_or_else(|| format!("{}", n));
|
||||
}
|
||||
};
|
||||
|
||||
if self.base != NumberBase::Alpha && s.len() < self.padding {
|
||||
@@ -87,7 +116,11 @@ impl RenameRule for NumberingRule {
|
||||
return filename.to_string();
|
||||
}
|
||||
|
||||
let idx = context.index as i64;
|
||||
let idx = if self.reverse {
|
||||
context.total as i64 - 1 - context.index as i64
|
||||
} else {
|
||||
context.index as i64
|
||||
};
|
||||
let n = if self.break_at > 0 {
|
||||
self.start + (idx % self.break_at as i64) * self.increment
|
||||
} else {
|
||||
@@ -95,6 +128,10 @@ impl RenameRule for NumberingRule {
|
||||
};
|
||||
let num = self.format_number(n);
|
||||
|
||||
if let Some(ref fmt) = self.custom_format {
|
||||
return fmt.replace("{n}", &num);
|
||||
}
|
||||
|
||||
match self.mode {
|
||||
NumberMode::None => filename.to_string(),
|
||||
NumberMode::Prefix => format!("{}{}{}", num, self.separator, filename),
|
||||
@@ -173,4 +210,63 @@ mod tests {
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("file", &ctx), "file_a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roman_numbering() {
|
||||
let rule = NumberingRule {
|
||||
mode: NumberMode::Prefix,
|
||||
start: 1,
|
||||
increment: 1,
|
||||
separator: "_".into(),
|
||||
base: NumberBase::Roman,
|
||||
..NumberingRule::new()
|
||||
};
|
||||
assert_eq!(rule.apply("track", &RenameContext::dummy(0)), "I_track");
|
||||
assert_eq!(rule.apply("track", &RenameContext::dummy(3)), "IV_track");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roman_zero_falls_back() {
|
||||
let rule = NumberingRule {
|
||||
mode: NumberMode::Prefix,
|
||||
start: 0,
|
||||
increment: 1,
|
||||
separator: "_".into(),
|
||||
base: NumberBase::Roman,
|
||||
..NumberingRule::new()
|
||||
};
|
||||
assert_eq!(rule.apply("x", &RenameContext::dummy(0)), "0_x");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn custom_format() {
|
||||
let rule = NumberingRule {
|
||||
mode: NumberMode::Prefix,
|
||||
start: 1,
|
||||
increment: 1,
|
||||
padding: 3,
|
||||
custom_format: Some("File_{n}".into()),
|
||||
..NumberingRule::new()
|
||||
};
|
||||
let ctx = RenameContext::dummy(2);
|
||||
assert_eq!(rule.apply("ignored", &ctx), "File_003");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_numbering() {
|
||||
let rule = NumberingRule {
|
||||
mode: NumberMode::Prefix,
|
||||
start: 1,
|
||||
increment: 1,
|
||||
padding: 1,
|
||||
separator: "_".into(),
|
||||
reverse: true,
|
||||
..NumberingRule::new()
|
||||
};
|
||||
let mut ctx = RenameContext::dummy(0);
|
||||
ctx.total = 5;
|
||||
assert_eq!(rule.apply("a", &ctx), "5_a");
|
||||
ctx.index = 4;
|
||||
assert_eq!(rule.apply("a", &ctx), "1_a");
|
||||
}
|
||||
}
|
||||
|
||||
124
crates/nomina-core/src/rules/padding.rs
Normal file
124
crates/nomina-core/src/rules/padding.rs
Normal file
@@ -0,0 +1,124 @@
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{RenameContext, RenameRule};
|
||||
|
||||
fn default_width() -> usize {
|
||||
3
|
||||
}
|
||||
|
||||
fn default_pad_char() -> char {
|
||||
'0'
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PaddingRule {
|
||||
#[serde(default = "default_width")]
|
||||
pub width: usize,
|
||||
#[serde(default = "default_pad_char")]
|
||||
pub pad_char: char,
|
||||
#[serde(default)]
|
||||
pub pad_all: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl PaddingRule {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
width: default_width(),
|
||||
pad_char: default_pad_char(),
|
||||
pad_all: true,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for PaddingRule {
|
||||
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
|
||||
let re = match Regex::new(r"\d+") {
|
||||
Ok(r) => r,
|
||||
Err(_) => return filename.to_string(),
|
||||
};
|
||||
|
||||
let mut result = String::new();
|
||||
let mut last = 0;
|
||||
let mut count = 0;
|
||||
|
||||
for m in re.find_iter(filename) {
|
||||
count += 1;
|
||||
result.push_str(&filename[last..m.start()]);
|
||||
|
||||
if self.pad_all || count == 1 {
|
||||
let num_str = m.as_str();
|
||||
if num_str.len() < self.width {
|
||||
let pad_count = self.width - num_str.len();
|
||||
for _ in 0..pad_count {
|
||||
result.push(self.pad_char);
|
||||
}
|
||||
}
|
||||
result.push_str(num_str);
|
||||
} else {
|
||||
result.push_str(m.as_str());
|
||||
}
|
||||
|
||||
last = m.end();
|
||||
}
|
||||
|
||||
result.push_str(&filename[last..]);
|
||||
result
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Padding"
|
||||
}
|
||||
|
||||
fn rule_type(&self) -> &str {
|
||||
"padding"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rule(width: usize, pad_all: bool) -> PaddingRule {
|
||||
PaddingRule {
|
||||
width,
|
||||
pad_char: '0',
|
||||
pad_all,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_single_number() {
|
||||
let r = rule(3, true);
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(r.apply("file1", &ctx), "file001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_multiple_numbers() {
|
||||
let r = rule(3, true);
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(r.apply("img2_v10", &ctx), "img002_v010");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pad_first_only() {
|
||||
let r = rule(3, false);
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(r.apply("file1", &ctx), "file001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn already_wide_enough() {
|
||||
let r = rule(3, true);
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(r.apply("file100", &ctx), "file100");
|
||||
}
|
||||
}
|
||||
123
crates/nomina-core/src/rules/randomize.rs
Normal file
123
crates/nomina-core/src/rules/randomize.rs
Normal file
@@ -0,0 +1,123 @@
|
||||
use rand::Rng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{RenameContext, RenameRule};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub enum RandomMode {
|
||||
#[default]
|
||||
Replace,
|
||||
Prefix,
|
||||
Suffix,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub enum RandomFormat {
|
||||
#[default]
|
||||
Hex,
|
||||
Alpha,
|
||||
AlphaNum,
|
||||
UUID,
|
||||
}
|
||||
|
||||
fn default_length() -> usize {
|
||||
8
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RandomizeRule {
|
||||
pub mode: RandomMode,
|
||||
pub format: RandomFormat,
|
||||
#[serde(default = "default_length")]
|
||||
pub length: usize,
|
||||
pub separator: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl RandomizeRule {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
mode: RandomMode::Replace,
|
||||
format: RandomFormat::Hex,
|
||||
length: 8,
|
||||
separator: String::from("_"),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_random(&self) -> String {
|
||||
if self.format == RandomFormat::UUID {
|
||||
return Uuid::new_v4().to_string();
|
||||
}
|
||||
|
||||
let mut rng = rand::thread_rng();
|
||||
let chars: &[u8] = match self.format {
|
||||
RandomFormat::Hex => b"0123456789abcdef",
|
||||
RandomFormat::Alpha => b"abcdefghijklmnopqrstuvwxyz",
|
||||
RandomFormat::AlphaNum => b"abcdefghijklmnopqrstuvwxyz0123456789",
|
||||
RandomFormat::UUID => unreachable!(),
|
||||
};
|
||||
|
||||
(0..self.length)
|
||||
.map(|_| chars[rng.gen_range(0..chars.len())] as char)
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for RandomizeRule {
|
||||
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
|
||||
let random = self.generate_random();
|
||||
match self.mode {
|
||||
RandomMode::Replace => random,
|
||||
RandomMode::Prefix => format!("{}{}{}", random, self.separator, filename),
|
||||
RandomMode::Suffix => format!("{}{}{}", filename, self.separator, random),
|
||||
}
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Randomize"
|
||||
}
|
||||
|
||||
fn rule_type(&self) -> &str {
|
||||
"randomize"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn replace_mode_length() {
|
||||
let rule = RandomizeRule {
|
||||
mode: RandomMode::Replace,
|
||||
format: RandomFormat::Hex,
|
||||
length: 12,
|
||||
separator: String::new(),
|
||||
enabled: true,
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
let result = rule.apply("anything", &ctx);
|
||||
assert_eq!(result.len(), 12);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prefix_mode_contains_filename() {
|
||||
let rule = RandomizeRule {
|
||||
mode: RandomMode::Prefix,
|
||||
format: RandomFormat::Alpha,
|
||||
length: 6,
|
||||
separator: "-".into(),
|
||||
enabled: true,
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
let result = rule.apply("photo", &ctx);
|
||||
assert!(result.contains("photo"));
|
||||
assert!(result.ends_with("photo"));
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ pub struct RegexRule {
|
||||
pub replace_with: String,
|
||||
pub case_insensitive: bool,
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub match_limit: Option<usize>,
|
||||
}
|
||||
|
||||
impl RegexRule {
|
||||
@@ -18,9 +20,19 @@ impl RegexRule {
|
||||
replace_with: String::new(),
|
||||
case_insensitive: false,
|
||||
enabled: true,
|
||||
match_limit: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
let pat = if self.case_insensitive {
|
||||
format!("(?i){}", self.pattern)
|
||||
} else {
|
||||
self.pattern.clone()
|
||||
};
|
||||
Regex::new(&pat).map(|_| ()).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
fn build_regex(&self) -> std::result::Result<Regex, NominaError> {
|
||||
let pat = if self.case_insensitive {
|
||||
format!("(?i){}", self.pattern)
|
||||
@@ -40,7 +52,10 @@ impl RenameRule for RegexRule {
|
||||
return filename.to_string();
|
||||
}
|
||||
match self.build_regex() {
|
||||
Ok(re) => re.replace_all(filename, self.replace_with.as_str()).into_owned(),
|
||||
Ok(re) => match self.match_limit {
|
||||
Some(n) => re.replacen(filename, n, self.replace_with.as_str()).into_owned(),
|
||||
None => re.replace_all(filename, self.replace_with.as_str()).into_owned(),
|
||||
},
|
||||
Err(_) => filename.to_string(),
|
||||
}
|
||||
}
|
||||
@@ -69,6 +84,7 @@ mod tests {
|
||||
replace_with: "NUM".into(),
|
||||
case_insensitive: false,
|
||||
enabled: true,
|
||||
match_limit: None,
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("file123", &ctx), "fileNUM");
|
||||
@@ -81,6 +97,7 @@ mod tests {
|
||||
replace_with: "${2}_${1}".into(),
|
||||
case_insensitive: false,
|
||||
enabled: true,
|
||||
match_limit: None,
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("hello-world", &ctx), "world_hello");
|
||||
@@ -93,6 +110,7 @@ mod tests {
|
||||
replace_with: "x".into(),
|
||||
case_insensitive: false,
|
||||
enabled: true,
|
||||
match_limit: None,
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("test", &ctx), "test");
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
use crate::{RenameContext, RenameRule};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||
pub enum RemoveMode {
|
||||
#[default]
|
||||
Chars,
|
||||
Words,
|
||||
}
|
||||
@@ -29,16 +32,78 @@ impl Default for TrimOptions {
|
||||
}
|
||||
}
|
||||
|
||||
const WORD_SEPARATORS: &[char] = &[' ', '_', '-', '.'];
|
||||
|
||||
fn split_words(s: &str) -> Vec<(String, String)> {
|
||||
// Returns pairs of (word, trailing_separator).
|
||||
// The last word may have an empty separator.
|
||||
let mut words: Vec<(String, String)> = Vec::new();
|
||||
let mut word = String::new();
|
||||
let mut sep = String::new();
|
||||
let mut in_sep = false;
|
||||
|
||||
for c in s.chars() {
|
||||
if WORD_SEPARATORS.contains(&c) {
|
||||
if !in_sep && !word.is_empty() {
|
||||
in_sep = true;
|
||||
sep.push(c);
|
||||
} else if in_sep {
|
||||
sep.push(c);
|
||||
} else {
|
||||
// leading separator before any word
|
||||
sep.push(c);
|
||||
}
|
||||
} else {
|
||||
if in_sep {
|
||||
words.push((std::mem::take(&mut word), std::mem::take(&mut sep)));
|
||||
in_sep = false;
|
||||
} else if !sep.is_empty() {
|
||||
// leading separators - attach to first word
|
||||
words.push((String::new(), std::mem::take(&mut sep)));
|
||||
}
|
||||
word.push(c);
|
||||
}
|
||||
}
|
||||
// push whatever is left
|
||||
if !word.is_empty() || !sep.is_empty() {
|
||||
words.push((word, sep));
|
||||
}
|
||||
words
|
||||
}
|
||||
|
||||
fn join_words(parts: &[(String, String)]) -> String {
|
||||
let mut out = String::new();
|
||||
for (w, s) in parts {
|
||||
out.push_str(w);
|
||||
out.push_str(s);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn strip_accents(s: &str) -> String {
|
||||
s.nfkd()
|
||||
.filter(|c| !unicode_normalization::char::is_combining_mark(*c))
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemoveRule {
|
||||
pub first_n: usize,
|
||||
pub last_n: usize,
|
||||
pub from: usize,
|
||||
pub to: usize,
|
||||
#[serde(default)]
|
||||
pub mode: RemoveMode,
|
||||
pub crop_before: Option<String>,
|
||||
pub crop_after: Option<String>,
|
||||
#[serde(default)]
|
||||
pub trim: TrimOptions,
|
||||
#[serde(default)]
|
||||
pub collapse_chars: Option<String>,
|
||||
#[serde(default)]
|
||||
pub remove_pattern: Option<String>,
|
||||
#[serde(default)]
|
||||
pub allow_empty: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
@@ -53,6 +118,9 @@ impl RemoveRule {
|
||||
crop_before: None,
|
||||
crop_after: None,
|
||||
trim: TrimOptions::default(),
|
||||
collapse_chars: None,
|
||||
remove_pattern: None,
|
||||
allow_empty: false,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
@@ -78,30 +146,92 @@ impl RenameRule for RemoveRule {
|
||||
let s: String = result.iter().collect();
|
||||
let mut result = s;
|
||||
|
||||
// remove first N
|
||||
if self.first_n > 0 && self.first_n < result.chars().count() {
|
||||
result = result.chars().skip(self.first_n).collect();
|
||||
}
|
||||
match self.mode {
|
||||
RemoveMode::Chars => {
|
||||
if self.first_n > 0 {
|
||||
let count = result.chars().count();
|
||||
if self.first_n < count || self.allow_empty {
|
||||
result = result.chars().skip(self.first_n).collect();
|
||||
}
|
||||
}
|
||||
|
||||
// remove last N
|
||||
if self.last_n > 0 {
|
||||
let count = result.chars().count();
|
||||
if self.last_n < count {
|
||||
result = result.chars().take(count - self.last_n).collect();
|
||||
}
|
||||
}
|
||||
if self.last_n > 0 {
|
||||
let count = result.chars().count();
|
||||
if self.last_n < count || self.allow_empty {
|
||||
let take = count.saturating_sub(self.last_n);
|
||||
result = result.chars().take(take).collect();
|
||||
}
|
||||
}
|
||||
|
||||
// remove from..to range
|
||||
if self.to > self.from && self.from < result.chars().count() {
|
||||
let chars: Vec<char> = result.chars().collect();
|
||||
let to = self.to.min(chars.len());
|
||||
let mut s = String::new();
|
||||
for (i, c) in chars.iter().enumerate() {
|
||||
if i < self.from || i >= to {
|
||||
s.push(*c);
|
||||
if self.to > self.from && self.from < result.chars().count() {
|
||||
let chars: Vec<char> = result.chars().collect();
|
||||
let to = self.to.min(chars.len());
|
||||
let mut s = String::new();
|
||||
for (i, c) in chars.iter().enumerate() {
|
||||
if i < self.from || i >= to {
|
||||
s.push(*c);
|
||||
}
|
||||
}
|
||||
result = s;
|
||||
}
|
||||
}
|
||||
result = s;
|
||||
RemoveMode::Words => {
|
||||
let mut parts = split_words(&result);
|
||||
|
||||
// count only non-empty words
|
||||
let word_indices: Vec<usize> = parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (w, _))| !w.is_empty())
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
if self.first_n > 0 && (self.first_n < word_indices.len() || self.allow_empty) {
|
||||
// remove first N words and their trailing separators
|
||||
for &idx in word_indices.iter().take(self.first_n) {
|
||||
parts[idx].0.clear();
|
||||
parts[idx].1.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// recompute after removal
|
||||
let word_indices: Vec<usize> = parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (w, _))| !w.is_empty())
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
if self.last_n > 0 && (self.last_n < word_indices.len() || self.allow_empty) {
|
||||
let start = word_indices.len() - self.last_n;
|
||||
for &idx in &word_indices[start..] {
|
||||
// remove separator before this word (from previous part)
|
||||
if idx > 0 {
|
||||
parts[idx - 1].1.clear();
|
||||
}
|
||||
parts[idx].0.clear();
|
||||
parts[idx].1.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// recompute for from/to
|
||||
let word_indices: Vec<usize> = parts
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, (w, _))| !w.is_empty())
|
||||
.map(|(i, _)| i)
|
||||
.collect();
|
||||
|
||||
if self.to > self.from && self.from < word_indices.len() {
|
||||
let to = self.to.min(word_indices.len());
|
||||
for &idx in &word_indices[self.from..to] {
|
||||
parts[idx].0.clear();
|
||||
parts[idx].1.clear();
|
||||
}
|
||||
}
|
||||
|
||||
result = join_words(&parts);
|
||||
}
|
||||
}
|
||||
|
||||
// trim options
|
||||
@@ -120,6 +250,43 @@ impl RenameRule for RemoveRule {
|
||||
.filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '-' || *c == '_' || *c == '.')
|
||||
.collect();
|
||||
}
|
||||
if self.trim.accents {
|
||||
result = strip_accents(&result);
|
||||
}
|
||||
|
||||
// collapse runs of specified chars
|
||||
if let Some(ref chars) = self.collapse_chars {
|
||||
let set: Vec<char> = chars.chars().collect();
|
||||
let mut collapsed = String::new();
|
||||
let mut prev_was_target = false;
|
||||
let mut prev_char: Option<char> = None;
|
||||
for c in result.chars() {
|
||||
if set.contains(&c) {
|
||||
if prev_was_target && prev_char == Some(c) {
|
||||
continue;
|
||||
}
|
||||
if prev_was_target && set.len() > 1 {
|
||||
// different target char in a run - still collapse
|
||||
continue;
|
||||
}
|
||||
prev_was_target = true;
|
||||
prev_char = Some(c);
|
||||
collapsed.push(c);
|
||||
} else {
|
||||
prev_was_target = false;
|
||||
prev_char = Some(c);
|
||||
collapsed.push(c);
|
||||
}
|
||||
}
|
||||
result = collapsed;
|
||||
}
|
||||
|
||||
// remove regex pattern matches
|
||||
if let Some(ref pat) = self.remove_pattern {
|
||||
if let Ok(re) = Regex::new(pat) {
|
||||
result = re.replace_all(&result, "").into_owned();
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
@@ -170,4 +337,92 @@ mod tests {
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("prefix-content", &ctx), "-content");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_first_n() {
|
||||
let rule = RemoveRule {
|
||||
first_n: 1,
|
||||
mode: RemoveMode::Words,
|
||||
..RemoveRule::new()
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("hello_world-foo bar", &ctx), "world-foo bar");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_last_n() {
|
||||
let rule = RemoveRule {
|
||||
last_n: 1,
|
||||
mode: RemoveMode::Words,
|
||||
..RemoveRule::new()
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("hello_world_end", &ctx), "hello_world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn words_from_to() {
|
||||
let rule = RemoveRule {
|
||||
from: 1,
|
||||
to: 2,
|
||||
mode: RemoveMode::Words,
|
||||
..RemoveRule::new()
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("aaa-bbb-ccc", &ctx), "aaa-ccc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_accents() {
|
||||
let rule = RemoveRule {
|
||||
trim: TrimOptions {
|
||||
accents: true,
|
||||
..TrimOptions::default()
|
||||
},
|
||||
..RemoveRule::new()
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("cafe\u{0301}", &ctx), "cafe");
|
||||
assert_eq!(rule.apply("\u{00e9}t\u{00e9}", &ctx), "ete");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapse_chars_single() {
|
||||
let rule = RemoveRule {
|
||||
collapse_chars: Some("-".into()),
|
||||
..RemoveRule::new()
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("file---name", &ctx), "file-name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapse_chars_multi() {
|
||||
let rule = RemoveRule {
|
||||
collapse_chars: Some("-_".into()),
|
||||
..RemoveRule::new()
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("file-_-name", &ctx), "file-name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_pattern_basic() {
|
||||
let rule = RemoveRule {
|
||||
remove_pattern: Some(r"\d+".into()),
|
||||
..RemoveRule::new()
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("file123name456", &ctx), "filename");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_pattern_complex() {
|
||||
let rule = RemoveRule {
|
||||
remove_pattern: Some(r"\s*\(copy\)".into()),
|
||||
..RemoveRule::new()
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("document (copy)", &ctx), "document");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{RenameContext, RenameRule};
|
||||
@@ -9,6 +10,14 @@ pub struct ReplaceRule {
|
||||
pub match_case: bool,
|
||||
pub first_only: bool,
|
||||
pub enabled: bool,
|
||||
#[serde(default)]
|
||||
pub use_regex: bool,
|
||||
#[serde(default)]
|
||||
pub scope_start: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub scope_end: Option<usize>,
|
||||
#[serde(default)]
|
||||
pub occurrence: Option<usize>,
|
||||
}
|
||||
|
||||
impl ReplaceRule {
|
||||
@@ -19,37 +28,116 @@ impl ReplaceRule {
|
||||
match_case: true,
|
||||
first_only: false,
|
||||
enabled: true,
|
||||
use_regex: false,
|
||||
scope_start: None,
|
||||
scope_end: None,
|
||||
occurrence: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for ReplaceRule {
|
||||
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
|
||||
if self.search.is_empty() {
|
||||
return filename.to_string();
|
||||
fn effective_limit(&self) -> Option<usize> {
|
||||
if let Some(n) = self.occurrence {
|
||||
Some(n)
|
||||
} else if self.first_only {
|
||||
Some(1)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_inner(&self, text: &str) -> String {
|
||||
if self.use_regex {
|
||||
self.replace_regex(text)
|
||||
} else {
|
||||
self.replace_literal(text)
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_regex(&self, text: &str) -> String {
|
||||
let pat = if self.match_case {
|
||||
self.search.clone()
|
||||
} else {
|
||||
format!("(?i){}", self.search)
|
||||
};
|
||||
let re = match Regex::new(&pat) {
|
||||
Ok(r) => r,
|
||||
Err(_) => return text.to_string(),
|
||||
};
|
||||
|
||||
let limit = self.effective_limit();
|
||||
let mut result = String::new();
|
||||
let mut last = 0;
|
||||
let mut count = 0;
|
||||
|
||||
for m in re.find_iter(text) {
|
||||
count += 1;
|
||||
match limit {
|
||||
Some(n) if count < n => {
|
||||
result.push_str(&text[last..m.end()]);
|
||||
last = m.end();
|
||||
}
|
||||
Some(n) if count == n => {
|
||||
result.push_str(&text[last..m.start()]);
|
||||
let replaced = re.replace(m.as_str(), self.replace_with.as_str());
|
||||
result.push_str(&replaced);
|
||||
result.push_str(&text[m.end()..]);
|
||||
return result;
|
||||
}
|
||||
Some(_) => {
|
||||
// past target, shouldn't happen since we return above
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
result.push_str(&text[last..m.start()]);
|
||||
let replaced = re.replace(m.as_str(), self.replace_with.as_str());
|
||||
result.push_str(&replaced);
|
||||
last = m.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push_str(&text[last..]);
|
||||
result
|
||||
}
|
||||
|
||||
fn replace_literal(&self, text: &str) -> String {
|
||||
let limit = self.effective_limit();
|
||||
|
||||
if self.match_case {
|
||||
if self.first_only {
|
||||
filename.replacen(&self.search, &self.replace_with, 1)
|
||||
} else {
|
||||
filename.replace(&self.search, &self.replace_with)
|
||||
match limit {
|
||||
Some(n) => self.replace_nth_literal(text, &self.search, n),
|
||||
None => text.replace(&self.search, &self.replace_with),
|
||||
}
|
||||
} else {
|
||||
let lower_search = self.search.to_lowercase();
|
||||
let mut result = String::new();
|
||||
let mut remaining = filename;
|
||||
let mut remaining = text;
|
||||
let mut count = 0;
|
||||
|
||||
loop {
|
||||
let lower_remaining = remaining.to_lowercase();
|
||||
match lower_remaining.find(&lower_search) {
|
||||
Some(pos) => {
|
||||
result.push_str(&remaining[..pos]);
|
||||
result.push_str(&self.replace_with);
|
||||
remaining = &remaining[pos + self.search.len()..];
|
||||
if self.first_only {
|
||||
result.push_str(remaining);
|
||||
break;
|
||||
count += 1;
|
||||
match limit {
|
||||
Some(n) if count < n => {
|
||||
result.push_str(&remaining[..pos + self.search.len()]);
|
||||
remaining = &remaining[pos + self.search.len()..];
|
||||
}
|
||||
Some(n) if count == n => {
|
||||
result.push_str(&remaining[..pos]);
|
||||
result.push_str(&self.replace_with);
|
||||
result.push_str(&remaining[pos + self.search.len()..]);
|
||||
return result;
|
||||
}
|
||||
Some(_) => {
|
||||
result.push_str(remaining);
|
||||
return result;
|
||||
}
|
||||
None => {
|
||||
result.push_str(&remaining[..pos]);
|
||||
result.push_str(&self.replace_with);
|
||||
remaining = &remaining[pos + self.search.len()..];
|
||||
}
|
||||
}
|
||||
}
|
||||
None => {
|
||||
@@ -62,6 +150,58 @@ impl RenameRule for ReplaceRule {
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_nth_literal(&self, text: &str, search: &str, n: usize) -> String {
|
||||
let mut result = String::new();
|
||||
let mut remaining = text;
|
||||
let mut count = 0;
|
||||
|
||||
loop {
|
||||
match remaining.find(search) {
|
||||
Some(pos) => {
|
||||
count += 1;
|
||||
if count == n {
|
||||
result.push_str(&remaining[..pos]);
|
||||
result.push_str(&self.replace_with);
|
||||
result.push_str(&remaining[pos + search.len()..]);
|
||||
return result;
|
||||
}
|
||||
result.push_str(&remaining[..pos + search.len()]);
|
||||
remaining = &remaining[pos + search.len()..];
|
||||
}
|
||||
None => {
|
||||
result.push_str(remaining);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for ReplaceRule {
|
||||
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
|
||||
if self.search.is_empty() {
|
||||
return filename.to_string();
|
||||
}
|
||||
|
||||
let has_scope = self.scope_start.is_some() || self.scope_end.is_some();
|
||||
if has_scope {
|
||||
let chars: Vec<char> = filename.chars().collect();
|
||||
let start = self.scope_start.unwrap_or(0).min(chars.len());
|
||||
let end = self.scope_end.unwrap_or(chars.len()).min(chars.len());
|
||||
if start >= end {
|
||||
return filename.to_string();
|
||||
}
|
||||
let prefix: String = chars[..start].iter().collect();
|
||||
let scoped: String = chars[start..end].iter().collect();
|
||||
let suffix: String = chars[end..].iter().collect();
|
||||
let replaced = self.replace_inner(&scoped);
|
||||
format!("{}{}{}", prefix, replaced, suffix)
|
||||
} else {
|
||||
self.replace_inner(filename)
|
||||
}
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Replace"
|
||||
}
|
||||
@@ -79,6 +219,20 @@ impl RenameRule for ReplaceRule {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn base_rule(search: &str, replace_with: &str) -> ReplaceRule {
|
||||
ReplaceRule {
|
||||
search: search.into(),
|
||||
replace_with: replace_with.into(),
|
||||
match_case: true,
|
||||
first_only: false,
|
||||
enabled: true,
|
||||
use_regex: false,
|
||||
scope_start: None,
|
||||
scope_end: None,
|
||||
occurrence: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_replace() {
|
||||
let rule = ReplaceRule {
|
||||
@@ -87,6 +241,10 @@ mod tests {
|
||||
match_case: true,
|
||||
first_only: false,
|
||||
enabled: true,
|
||||
use_regex: false,
|
||||
scope_start: None,
|
||||
scope_end: None,
|
||||
occurrence: None,
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("IMG_001", &ctx), "photo-001");
|
||||
@@ -100,6 +258,10 @@ mod tests {
|
||||
match_case: false,
|
||||
first_only: false,
|
||||
enabled: true,
|
||||
use_regex: false,
|
||||
scope_start: None,
|
||||
scope_end: None,
|
||||
occurrence: None,
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("IMG_001", &ctx), "photo-001");
|
||||
@@ -113,6 +275,10 @@ mod tests {
|
||||
match_case: true,
|
||||
first_only: true,
|
||||
enabled: true,
|
||||
use_regex: false,
|
||||
scope_start: None,
|
||||
scope_end: None,
|
||||
occurrence: None,
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("aaa", &ctx), "baa");
|
||||
@@ -126,8 +292,120 @@ mod tests {
|
||||
match_case: true,
|
||||
first_only: false,
|
||||
enabled: true,
|
||||
use_regex: false,
|
||||
scope_start: None,
|
||||
scope_end: None,
|
||||
occurrence: None,
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("test", &ctx), "test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_replace() {
|
||||
let mut rule = base_rule(r"\d+", "NUM");
|
||||
rule.use_regex = true;
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("file123-456", &ctx), "fileNUM-NUM");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_first_only() {
|
||||
let mut rule = base_rule(r"\d+", "NUM");
|
||||
rule.use_regex = true;
|
||||
rule.first_only = true;
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("file123-456", &ctx), "fileNUM-456");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_invalid_pattern() {
|
||||
let mut rule = base_rule(r"[invalid", "x");
|
||||
rule.use_regex = true;
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("test", &ctx), "test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn regex_case_insensitive() {
|
||||
let mut rule = base_rule("hello", "HI");
|
||||
rule.use_regex = true;
|
||||
rule.match_case = false;
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("HELLO world", &ctx), "HI world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_basic() {
|
||||
let mut rule = base_rule("a", "X");
|
||||
rule.scope_start = Some(2);
|
||||
rule.scope_end = Some(5);
|
||||
let ctx = RenameContext::dummy(0);
|
||||
// "abcabc" - scope chars 2..5 = "cab" - replace 'a' -> 'X' = "cXb"
|
||||
assert_eq!(rule.apply("abcabc", &ctx), "abcXbc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_no_start() {
|
||||
let mut rule = base_rule("a", "X");
|
||||
rule.scope_end = Some(3);
|
||||
let ctx = RenameContext::dummy(0);
|
||||
// scope 0..3 = "abc" -> "Xbc", rest = "abc"
|
||||
assert_eq!(rule.apply("abcabc", &ctx), "Xbcabc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_no_end() {
|
||||
let mut rule = base_rule("a", "X");
|
||||
rule.scope_start = Some(3);
|
||||
let ctx = RenameContext::dummy(0);
|
||||
// prefix = "abc", scope 3.. = "abc" -> "Xbc"
|
||||
assert_eq!(rule.apply("abcabc", &ctx), "abcXbc");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_nth() {
|
||||
let mut rule = base_rule("a", "X");
|
||||
rule.occurrence = Some(2);
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("ababa", &ctx), "abXba");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_overrides_first_only() {
|
||||
let mut rule = base_rule("a", "X");
|
||||
rule.first_only = true;
|
||||
rule.occurrence = Some(3);
|
||||
let ctx = RenameContext::dummy(0);
|
||||
// occurrence takes priority - replace 3rd 'a'
|
||||
assert_eq!(rule.apply("ababa", &ctx), "ababX");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_beyond_matches() {
|
||||
let mut rule = base_rule("a", "X");
|
||||
rule.occurrence = Some(10);
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("aaa", &ctx), "aaa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scope_with_regex() {
|
||||
let mut rule = base_rule(r"\d+", "N");
|
||||
rule.use_regex = true;
|
||||
rule.scope_start = Some(0);
|
||||
rule.scope_end = Some(5);
|
||||
let ctx = RenameContext::dummy(0);
|
||||
// "abc12345" scope 0..5 = "abc12" -> "abcN", suffix = "345"
|
||||
assert_eq!(rule.apply("abc12345", &ctx), "abcN345");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn occurrence_with_case_insensitive() {
|
||||
let mut rule = base_rule("a", "X");
|
||||
rule.match_case = false;
|
||||
rule.occurrence = Some(2);
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("aAbAa", &ctx), "aXbAa");
|
||||
}
|
||||
}
|
||||
|
||||
324
crates/nomina-core/src/rules/sanitize.rs
Normal file
324
crates/nomina-core/src/rules/sanitize.rs
Normal file
@@ -0,0 +1,324 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use unicode_normalization::UnicodeNormalization;
|
||||
|
||||
use crate::{RenameContext, RenameRule};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub enum SpaceMode {
|
||||
#[default]
|
||||
None,
|
||||
Underscores,
|
||||
Dashes,
|
||||
Dots,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SanitizeRule {
|
||||
#[serde(default = "default_true")]
|
||||
pub illegal_chars: bool,
|
||||
#[serde(default)]
|
||||
pub spaces_to: SpaceMode,
|
||||
#[serde(default)]
|
||||
pub normalize_unicode: bool,
|
||||
#[serde(default)]
|
||||
pub strip_zero_width: bool,
|
||||
#[serde(default)]
|
||||
pub strip_diacritics: bool,
|
||||
#[serde(default)]
|
||||
pub collapse_whitespace: bool,
|
||||
#[serde(default = "default_true")]
|
||||
pub trim_dots_spaces: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
fn default_true() -> bool {
|
||||
true
|
||||
}
|
||||
|
||||
const ZERO_WIDTH: &[char] = &[
|
||||
'\u{200B}', // zero-width space
|
||||
'\u{200C}', // zero-width non-joiner
|
||||
'\u{200D}', // zero-width joiner
|
||||
'\u{FEFF}', // BOM / zero-width no-break space
|
||||
'\u{00AD}', // soft hyphen
|
||||
];
|
||||
|
||||
impl SanitizeRule {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
illegal_chars: true,
|
||||
spaces_to: SpaceMode::None,
|
||||
normalize_unicode: false,
|
||||
strip_zero_width: false,
|
||||
strip_diacritics: false,
|
||||
collapse_whitespace: false,
|
||||
trim_dots_spaces: true,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn is_illegal(c: char) -> bool {
|
||||
matches!(c, '<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*')
|
||||
|| (c as u32) <= 0x1F
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for SanitizeRule {
|
||||
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
|
||||
let mut result = filename.to_string();
|
||||
|
||||
if self.illegal_chars {
|
||||
result = result.chars().filter(|c| !Self::is_illegal(*c)).collect();
|
||||
}
|
||||
|
||||
if self.spaces_to != SpaceMode::None {
|
||||
let replacement = match self.spaces_to {
|
||||
SpaceMode::Underscores => '_',
|
||||
SpaceMode::Dashes => '-',
|
||||
SpaceMode::Dots => '.',
|
||||
SpaceMode::None => unreachable!(),
|
||||
};
|
||||
result = result.chars().map(|c| if c == ' ' { replacement } else { c }).collect();
|
||||
}
|
||||
|
||||
if self.normalize_unicode {
|
||||
result = result.nfc().collect::<String>();
|
||||
}
|
||||
|
||||
if self.strip_zero_width {
|
||||
result = result.chars().filter(|c| !ZERO_WIDTH.contains(c)).collect();
|
||||
}
|
||||
|
||||
if self.strip_diacritics {
|
||||
result = result
|
||||
.nfkd()
|
||||
.filter(|c| !unicode_normalization::char::is_combining_mark(*c))
|
||||
.collect();
|
||||
}
|
||||
|
||||
if self.collapse_whitespace {
|
||||
let mut collapsed = String::with_capacity(result.len());
|
||||
let mut prev: Option<char> = Option::None;
|
||||
for c in result.chars() {
|
||||
let dominated = matches!(c, ' ' | '_' | '-');
|
||||
if dominated {
|
||||
if let Some(p) = prev {
|
||||
if matches!(p, ' ' | '_' | '-') {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
collapsed.push(c);
|
||||
prev = Some(c);
|
||||
}
|
||||
result = collapsed;
|
||||
}
|
||||
|
||||
if self.trim_dots_spaces {
|
||||
let trimmed = result.trim_matches(|c: char| c == '.' || c == ' ');
|
||||
result = trimmed.to_string();
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Sanitize"
|
||||
}
|
||||
|
||||
fn rule_type(&self) -> &str {
|
||||
"sanitize"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ctx() -> RenameContext {
|
||||
RenameContext::dummy(0)
|
||||
}
|
||||
|
||||
fn base() -> SanitizeRule {
|
||||
SanitizeRule {
|
||||
illegal_chars: false,
|
||||
spaces_to: SpaceMode::None,
|
||||
normalize_unicode: false,
|
||||
strip_zero_width: false,
|
||||
strip_diacritics: false,
|
||||
collapse_whitespace: false,
|
||||
trim_dots_spaces: false,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn illegal_chars_removed() {
|
||||
let rule = SanitizeRule {
|
||||
illegal_chars: true,
|
||||
..base()
|
||||
};
|
||||
assert_eq!(rule.apply("file<name>:test", &ctx()), "filenametest");
|
||||
assert_eq!(rule.apply("a\"b/c\\d|e?f*g", &ctx()), "abcdefg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_chars_removed() {
|
||||
let rule = SanitizeRule {
|
||||
illegal_chars: true,
|
||||
..base()
|
||||
};
|
||||
let input = format!("file{}name", '\x01');
|
||||
assert_eq!(rule.apply(&input, &ctx()), "filename");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_to_underscores() {
|
||||
let rule = SanitizeRule {
|
||||
spaces_to: SpaceMode::Underscores,
|
||||
..base()
|
||||
};
|
||||
assert_eq!(rule.apply("my file name", &ctx()), "my_file_name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_to_dashes() {
|
||||
let rule = SanitizeRule {
|
||||
spaces_to: SpaceMode::Dashes,
|
||||
..base()
|
||||
};
|
||||
assert_eq!(rule.apply("my file name", &ctx()), "my-file-name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spaces_to_dots() {
|
||||
let rule = SanitizeRule {
|
||||
spaces_to: SpaceMode::Dots,
|
||||
..base()
|
||||
};
|
||||
assert_eq!(rule.apply("my file name", &ctx()), "my.file.name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_unicode_nfc() {
|
||||
let rule = SanitizeRule {
|
||||
normalize_unicode: true,
|
||||
..base()
|
||||
};
|
||||
// e + combining acute -> precomposed
|
||||
let input = "caf\u{0065}\u{0301}";
|
||||
let output = rule.apply(input, &ctx());
|
||||
assert_eq!(output, "caf\u{00E9}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_zero_width_chars() {
|
||||
let rule = SanitizeRule {
|
||||
strip_zero_width: true,
|
||||
..base()
|
||||
};
|
||||
let input = "fi\u{200B}le\u{FEFF}name\u{00AD}";
|
||||
assert_eq!(rule.apply(&input, &ctx()), "filename");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_diacritics_basic() {
|
||||
let rule = SanitizeRule {
|
||||
strip_diacritics: true,
|
||||
..base()
|
||||
};
|
||||
assert_eq!(rule.apply("caf\u{00E9}", &ctx()), "cafe");
|
||||
assert_eq!(rule.apply("\u{00FC}ber", &ctx()), "uber");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strip_diacritics_combining() {
|
||||
let rule = SanitizeRule {
|
||||
strip_diacritics: true,
|
||||
..base()
|
||||
};
|
||||
assert_eq!(rule.apply("cafe\u{0301}", &ctx()), "cafe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapse_whitespace_spaces() {
|
||||
let rule = SanitizeRule {
|
||||
collapse_whitespace: true,
|
||||
..base()
|
||||
};
|
||||
assert_eq!(rule.apply("file name", &ctx()), "file name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapse_whitespace_underscores() {
|
||||
let rule = SanitizeRule {
|
||||
collapse_whitespace: true,
|
||||
..base()
|
||||
};
|
||||
assert_eq!(rule.apply("file___name", &ctx()), "file_name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapse_whitespace_dashes() {
|
||||
let rule = SanitizeRule {
|
||||
collapse_whitespace: true,
|
||||
..base()
|
||||
};
|
||||
assert_eq!(rule.apply("file---name", &ctx()), "file-name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapse_whitespace_mixed() {
|
||||
let rule = SanitizeRule {
|
||||
collapse_whitespace: true,
|
||||
..base()
|
||||
};
|
||||
assert_eq!(rule.apply("file -_name", &ctx()), "file name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn trim_dots_and_spaces() {
|
||||
let rule = SanitizeRule {
|
||||
trim_dots_spaces: true,
|
||||
..base()
|
||||
};
|
||||
assert_eq!(rule.apply(" file.txt ", &ctx()), "file.txt");
|
||||
assert_eq!(rule.apply("..file.txt..", &ctx()), "file.txt");
|
||||
assert_eq!(rule.apply(". .file. .", &ctx()), "file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_together() {
|
||||
let rule = SanitizeRule {
|
||||
illegal_chars: true,
|
||||
spaces_to: SpaceMode::Underscores,
|
||||
normalize_unicode: false,
|
||||
strip_zero_width: true,
|
||||
strip_diacritics: false,
|
||||
collapse_whitespace: true,
|
||||
trim_dots_spaces: true,
|
||||
enabled: true,
|
||||
};
|
||||
let input = " <my file\u{200B}name>.txt..";
|
||||
let output = rule.apply(input, &ctx());
|
||||
assert_eq!(output, "_my_filename.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn passthrough_when_nothing_enabled() {
|
||||
let rule = base();
|
||||
assert_eq!(rule.apply("anything goes <here>", &ctx()), "anything goes <here>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_and_type() {
|
||||
let rule = SanitizeRule::new();
|
||||
assert_eq!(rule.display_name(), "Sanitize");
|
||||
assert_eq!(rule.rule_type(), "sanitize");
|
||||
assert!(rule.is_enabled());
|
||||
}
|
||||
}
|
||||
116
crates/nomina-core/src/rules/swap.rs
Normal file
116
crates/nomina-core/src/rules/swap.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{RenameContext, RenameRule};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub enum SwapOccurrence {
|
||||
#[default]
|
||||
First,
|
||||
Last,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SwapRule {
|
||||
pub delimiter: String,
|
||||
pub occurrence: SwapOccurrence,
|
||||
#[serde(default)]
|
||||
pub new_delimiter: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl SwapRule {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
delimiter: ", ".into(),
|
||||
occurrence: SwapOccurrence::First,
|
||||
new_delimiter: None,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for SwapRule {
|
||||
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
|
||||
if self.delimiter.is_empty() {
|
||||
return filename.to_string();
|
||||
}
|
||||
|
||||
let pos = match self.occurrence {
|
||||
SwapOccurrence::First => filename.find(&self.delimiter),
|
||||
SwapOccurrence::Last => filename.rfind(&self.delimiter),
|
||||
};
|
||||
|
||||
let pos = match pos {
|
||||
Some(p) => p,
|
||||
None => return filename.to_string(),
|
||||
};
|
||||
|
||||
let left = &filename[..pos];
|
||||
let right = &filename[pos + self.delimiter.len()..];
|
||||
let joiner = self.new_delimiter.as_deref().unwrap_or(&self.delimiter);
|
||||
|
||||
format!("{}{}{}", right, joiner, left)
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Swap"
|
||||
}
|
||||
|
||||
fn rule_type(&self) -> &str {
|
||||
"swap"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn ctx() -> RenameContext {
|
||||
RenameContext::dummy(0)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_comma_space() {
|
||||
let rule = SwapRule::new();
|
||||
assert_eq!(rule.apply("LastName, FirstName", &ctx()), "FirstName, LastName");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_with_new_delimiter() {
|
||||
let rule = SwapRule {
|
||||
new_delimiter: Some(" ".into()),
|
||||
..SwapRule::new()
|
||||
};
|
||||
assert_eq!(rule.apply("LastName, FirstName", &ctx()), "FirstName LastName");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_first_occurrence() {
|
||||
let rule = SwapRule {
|
||||
delimiter: "-".into(),
|
||||
occurrence: SwapOccurrence::First,
|
||||
..SwapRule::new()
|
||||
};
|
||||
assert_eq!(rule.apply("a-b-c", &ctx()), "b-c-a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swap_last_occurrence() {
|
||||
let rule = SwapRule {
|
||||
delimiter: "-".into(),
|
||||
occurrence: SwapOccurrence::Last,
|
||||
..SwapRule::new()
|
||||
};
|
||||
assert_eq!(rule.apply("a-b-c", &ctx()), "c-a-b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_delimiter_found() {
|
||||
let rule = SwapRule::new();
|
||||
assert_eq!(rule.apply("nodelimiter", &ctx()), "nodelimiter");
|
||||
}
|
||||
}
|
||||
82
crates/nomina-core/src/rules/text_editor.rs
Normal file
82
crates/nomina-core/src/rules/text_editor.rs
Normal file
@@ -0,0 +1,82 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{RenameContext, RenameRule};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TextEditorRule {
|
||||
#[serde(default)]
|
||||
pub names: Vec<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl TextEditorRule {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
names: Vec::new(),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for TextEditorRule {
|
||||
fn apply(&self, filename: &str, context: &RenameContext) -> String {
|
||||
if let Some(name) = self.names.get(context.index) {
|
||||
if !name.is_empty() {
|
||||
return name.clone();
|
||||
}
|
||||
}
|
||||
filename.to_string()
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Text Editor"
|
||||
}
|
||||
|
||||
fn rule_type(&self) -> &str {
|
||||
"text_editor"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn maps_by_index() {
|
||||
let rule = TextEditorRule {
|
||||
names: vec!["alpha".into(), "beta".into(), "gamma".into()],
|
||||
enabled: true,
|
||||
};
|
||||
let mut ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("file1", &ctx), "alpha");
|
||||
ctx.index = 1;
|
||||
assert_eq!(rule.apply("file2", &ctx), "beta");
|
||||
ctx.index = 2;
|
||||
assert_eq!(rule.apply("file3", &ctx), "gamma");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn out_of_bounds_keeps_original() {
|
||||
let rule = TextEditorRule {
|
||||
names: vec!["only".into()],
|
||||
enabled: true,
|
||||
};
|
||||
let mut ctx = RenameContext::dummy(0);
|
||||
ctx.index = 5;
|
||||
assert_eq!(rule.apply("original", &ctx), "original");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_line_keeps_original() {
|
||||
let rule = TextEditorRule {
|
||||
names: vec!["".into(), "renamed".into()],
|
||||
enabled: true,
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("original", &ctx), "original");
|
||||
}
|
||||
}
|
||||
99
crates/nomina-core/src/rules/transliterate.rs
Normal file
99
crates/nomina-core/src/rules/transliterate.rs
Normal file
@@ -0,0 +1,99 @@
|
||||
use deunicode::deunicode;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{RenameContext, RenameRule};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TransliterateRule {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl TransliterateRule {
|
||||
pub fn new() -> Self {
|
||||
Self { enabled: true }
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for TransliterateRule {
|
||||
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
|
||||
deunicode(filename)
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Transliterate"
|
||||
}
|
||||
|
||||
fn rule_type(&self) -> &str {
|
||||
"transliterate"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn rule() -> TransliterateRule {
|
||||
TransliterateRule::new()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ascii_unchanged() {
|
||||
let r = rule();
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(r.apply("hello.txt", &ctx), "hello.txt");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accented_chars() {
|
||||
let r = rule();
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(r.apply("caf\u{e9}", &ctx), "cafe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn combining_accent() {
|
||||
let r = rule();
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(r.apply("cafe\u{0301}", &ctx), "cafe");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cyrillic() {
|
||||
let r = rule();
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(r.apply("\u{41c}\u{43e}\u{441}\u{43a}\u{432}\u{430}", &ctx), "Moskva");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cjk() {
|
||||
let r = rule();
|
||||
let ctx = RenameContext::dummy(0);
|
||||
let result = r.apply("\u{4e16}\u{754c}", &ctx);
|
||||
assert!(!result.is_empty());
|
||||
assert!(result.is_ascii());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn german_umlauts() {
|
||||
let r = rule();
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(r.apply("\u{fc}ber", &ctx), "uber");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled() {
|
||||
let r = TransliterateRule { enabled: false };
|
||||
assert!(!r.is_enabled());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn display_and_type() {
|
||||
let r = rule();
|
||||
assert_eq!(r.display_name(), "Transliterate");
|
||||
assert_eq!(r.rule_type(), "transliterate");
|
||||
}
|
||||
}
|
||||
110
crates/nomina-core/src/rules/truncate.rs
Normal file
110
crates/nomina-core/src/rules/truncate.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{RenameContext, RenameRule};
|
||||
|
||||
fn default_max() -> usize {
|
||||
50
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
|
||||
pub enum TruncateFrom {
|
||||
Start,
|
||||
#[default]
|
||||
End,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TruncateRule {
|
||||
#[serde(default = "default_max")]
|
||||
pub max_length: usize,
|
||||
#[serde(default)]
|
||||
pub from: TruncateFrom,
|
||||
#[serde(default)]
|
||||
pub suffix: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl TruncateRule {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
max_length: 50,
|
||||
from: TruncateFrom::End,
|
||||
suffix: String::new(),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for TruncateRule {
|
||||
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
|
||||
let char_count = filename.chars().count();
|
||||
if char_count <= self.max_length {
|
||||
return filename.to_string();
|
||||
}
|
||||
|
||||
let suffix_len = self.suffix.chars().count();
|
||||
let keep = self.max_length.saturating_sub(suffix_len);
|
||||
|
||||
match self.from {
|
||||
TruncateFrom::End => {
|
||||
let truncated: String = filename.chars().take(keep).collect();
|
||||
format!("{}{}", truncated, self.suffix)
|
||||
}
|
||||
TruncateFrom::Start => {
|
||||
let truncated: String = filename.chars().skip(char_count - keep).collect();
|
||||
format!("{}{}", self.suffix, truncated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Truncate"
|
||||
}
|
||||
|
||||
fn rule_type(&self) -> &str {
|
||||
"truncate"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn no_truncation_when_short() {
|
||||
let rule = TruncateRule {
|
||||
max_length: 10,
|
||||
..TruncateRule::new()
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("short", &ctx), "short");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_from_end_with_suffix() {
|
||||
let rule = TruncateRule {
|
||||
max_length: 8,
|
||||
from: TruncateFrom::End,
|
||||
suffix: "...".into(),
|
||||
enabled: true,
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("a]very_long_filename", &ctx), "a]ver...");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_from_start() {
|
||||
let rule = TruncateRule {
|
||||
max_length: 7,
|
||||
from: TruncateFrom::Start,
|
||||
suffix: "~".into(),
|
||||
enabled: true,
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("some_long_name", &ctx), "~g_name");
|
||||
}
|
||||
}
|
||||
@@ -88,18 +88,25 @@ fn split_filename(name: &str) -> (String, String) {
|
||||
}
|
||||
|
||||
fn is_hidden_file(path: &Path) -> bool {
|
||||
if path.file_name()
|
||||
.map(|n| n.to_string_lossy().starts_with('.'))
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
if let Ok(meta) = std::fs::metadata(path) {
|
||||
const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
|
||||
return meta.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0;
|
||||
if meta.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
path.file_name()
|
||||
.map(|n| n.to_string_lossy().starts_with('.'))
|
||||
.unwrap_or(false)
|
||||
false
|
||||
}
|
||||
|
||||
fn file_created(meta: &std::fs::Metadata) -> Option<DateTime<Utc>> {
|
||||
|
||||
@@ -25,7 +25,7 @@ pub struct UndoEntry {
|
||||
pub renamed_path: PathBuf,
|
||||
}
|
||||
|
||||
const MAX_UNDO_BATCHES: usize = 50;
|
||||
pub const DEFAULT_MAX_UNDO_BATCHES: usize = 50;
|
||||
|
||||
impl UndoLog {
|
||||
pub fn new() -> Self {
|
||||
@@ -64,8 +64,12 @@ impl UndoLog {
|
||||
}
|
||||
|
||||
pub fn add_batch(&mut self, batch: UndoBatch) {
|
||||
self.add_batch_with_limit(batch, DEFAULT_MAX_UNDO_BATCHES);
|
||||
}
|
||||
|
||||
pub fn add_batch_with_limit(&mut self, batch: UndoBatch, max: usize) {
|
||||
self.entries.push(batch);
|
||||
while self.entries.len() > MAX_UNDO_BATCHES {
|
||||
while self.entries.len() > max {
|
||||
self.entries.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user