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