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,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");
}
}