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
143 lines
3.5 KiB
Rust
143 lines
3.5 KiB
Rust
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");
|
|
}
|
|
}
|