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
209 lines
7.9 KiB
Rust
209 lines
7.9 KiB
Rust
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
|
|
}
|