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:
2026-03-14 19:04:35 +02:00
parent 9dca2bedfa
commit 6f5b862234
105 changed files with 17257 additions and 1369 deletions

View 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))
}

View File

@@ -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(())
}

View File

@@ -2,3 +2,5 @@ pub mod files;
pub mod rename;
pub mod presets;
pub mod undo;
pub mod context_menu;
pub mod updates;

View File

@@ -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)),
}
}

View File

@@ -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 {

View 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, &current);
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
}

View File

@@ -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");