Files
nomina/crates/nomina-core/src/rules/move_parts.rs
lashman 6f5b862234 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
2026-03-14 19:04:35 +02:00

319 lines
9.5 KiB
Rust

use regex::Regex;
use serde::{Deserialize, Serialize};
use crate::{RenameContext, RenameRule};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
pub enum MoveTarget {
None,
Position(usize),
Start,
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,
pub source_length: usize,
pub target: MoveTarget,
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 {
pub fn new() -> Self {
Self {
source_from: 0,
source_length: 0,
target: MoveTarget::None,
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();
}
let chars: Vec<char> = filename.chars().collect();
if self.source_from >= chars.len() {
return filename.to_string();
}
let end = (self.source_from + self.source_length).min(chars.len());
let extracted: String = chars[self.source_from..end].iter().collect();
let remaining: String = if self.copy_mode {
filename.to_string()
} else {
let mut r = String::new();
for (i, c) in chars.iter().enumerate() {
if i < self.source_from || i >= end {
r.push(*c);
}
}
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 => remaining.to_string(),
MoveTarget::Start => {
if self.separator.is_empty() {
format!("{}{}", extracted, remaining)
} else {
format!("{}{}{}", extracted, self.separator, remaining)
}
}
MoveTarget::End => {
if self.separator.is_empty() {
format!("{}{}", remaining, extracted)
} else {
format!("{}{}{}", remaining, self.separator, extracted)
}
}
MoveTarget::Position(pos) => {
let rem_chars: Vec<char> = remaining.chars().collect();
let p = (*pos).min(rem_chars.len());
let before: String = rem_chars[..p].iter().collect();
let after: String = rem_chars[p..].iter().collect();
format!("{}{}{}", before, extracted, after)
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn move_to_end() {
let rule = MovePartsRule {
source_from: 0,
source_length: 3,
target: MoveTarget::End,
separator: "_".into(),
copy_mode: false,
..MovePartsRule::new()
};
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("abcdef", &ctx), "def_abc");
}
#[test]
fn copy_to_start() {
let rule = MovePartsRule {
source_from: 3,
source_length: 3,
target: MoveTarget::Start,
separator: "-".into(),
copy_mode: true,
..MovePartsRule::new()
};
let ctx = RenameContext::dummy(0);
assert_eq!(rule.apply("abcdef", &ctx), "def-abcdef");
}
}