initial project scaffold
Rust workspace with nomina-core (rename engine) and nomina-app (Tauri v2 shell). React/TypeScript frontend with tabbed rule panels, virtual-scrolled file list, and Zustand state management. All 9 rule types implemented with 25 passing tests.
This commit is contained in:
23
crates/nomina-core/Cargo.toml
Normal file
23
crates/nomina-core/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "nomina-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "CC0-1.0"
|
||||
|
||||
[dependencies]
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
regex = "1"
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
walkdir = "2"
|
||||
rayon = "1"
|
||||
uuid = { version = "1", features = ["v4", "serde"] }
|
||||
filetime = "0.2"
|
||||
thiserror = "2"
|
||||
glob = "0.3"
|
||||
natord = "1"
|
||||
log = "0.4"
|
||||
kamadak-exif = "0.5"
|
||||
|
||||
[dev-dependencies]
|
||||
tempfile = "3"
|
||||
83
crates/nomina-core/src/filter.rs
Normal file
83
crates/nomina-core/src/filter.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::FileEntry;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FilterConfig {
|
||||
pub mask: String,
|
||||
pub regex_filter: Option<String>,
|
||||
pub min_size: Option<u64>,
|
||||
pub max_size: Option<u64>,
|
||||
pub include_files: bool,
|
||||
pub include_folders: bool,
|
||||
pub include_hidden: bool,
|
||||
pub subfolder_depth: Option<usize>,
|
||||
}
|
||||
|
||||
impl Default for FilterConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
mask: "*".into(),
|
||||
regex_filter: None,
|
||||
min_size: None,
|
||||
max_size: None,
|
||||
include_files: true,
|
||||
include_folders: false,
|
||||
include_hidden: false,
|
||||
subfolder_depth: Some(0),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FilterConfig {
|
||||
pub fn matches(&self, entry: &FileEntry) -> bool {
|
||||
if entry.is_dir && !self.include_folders {
|
||||
return false;
|
||||
}
|
||||
if !entry.is_dir && !self.include_files {
|
||||
return false;
|
||||
}
|
||||
if entry.is_hidden && !self.include_hidden {
|
||||
return false;
|
||||
}
|
||||
|
||||
if let Some(min) = self.min_size {
|
||||
if entry.size < min {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if let Some(max) = self.max_size {
|
||||
if entry.size > max {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if self.mask != "*" && !self.mask.is_empty() {
|
||||
let patterns: Vec<&str> = self.mask.split(';').collect();
|
||||
let name_lower = entry.name.to_lowercase();
|
||||
let matched = patterns.iter().any(|p| {
|
||||
let pattern = p.trim().to_lowercase();
|
||||
if let Ok(glob) = glob::Pattern::new(&pattern) {
|
||||
glob.matches(&name_lower)
|
||||
} else {
|
||||
false
|
||||
}
|
||||
});
|
||||
if !matched {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref regex_str) = self.regex_filter {
|
||||
if !regex_str.is_empty() {
|
||||
if let Ok(re) = regex::Regex::new(regex_str) {
|
||||
if !re.is_match(&entry.name) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
}
|
||||
107
crates/nomina-core/src/lib.rs
Normal file
107
crates/nomina-core/src/lib.rs
Normal file
@@ -0,0 +1,107 @@
|
||||
pub mod rules;
|
||||
pub mod pipeline;
|
||||
pub mod filter;
|
||||
pub mod metadata;
|
||||
pub mod preset;
|
||||
pub mod undo;
|
||||
pub mod scanner;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Error, Debug)]
|
||||
pub enum NominaError {
|
||||
#[error("Invalid regex pattern: {pattern} - {reason}")]
|
||||
InvalidRegex { pattern: String, reason: String },
|
||||
|
||||
#[error("File not found: {path}")]
|
||||
FileNotFound { path: PathBuf },
|
||||
|
||||
#[error("Rename conflict: {count} files would produce the name '{name}'")]
|
||||
NamingConflict { name: String, count: usize },
|
||||
|
||||
#[error("Invalid filename '{name}': {reason}")]
|
||||
InvalidFilename { name: String, reason: String },
|
||||
|
||||
#[error("Filesystem error on '{path}': {source}")]
|
||||
Filesystem {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
|
||||
#[error("Preset parse error: {reason}")]
|
||||
PresetError { reason: String },
|
||||
|
||||
#[error("BRU import error at line {line}: {reason}")]
|
||||
BruImportError { line: usize, reason: String },
|
||||
|
||||
#[error("EXIF read error for '{path}': {reason}")]
|
||||
ExifError { path: PathBuf, reason: String },
|
||||
}
|
||||
|
||||
pub type Result<T> = std::result::Result<T, NominaError>;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RenameContext {
|
||||
pub index: usize,
|
||||
pub total: usize,
|
||||
pub original_name: String,
|
||||
pub extension: String,
|
||||
pub path: PathBuf,
|
||||
pub size: u64,
|
||||
pub created: Option<DateTime<Utc>>,
|
||||
pub modified: Option<DateTime<Utc>>,
|
||||
pub date_taken: Option<DateTime<Utc>>,
|
||||
pub parent_folder: String,
|
||||
}
|
||||
|
||||
impl RenameContext {
|
||||
pub fn dummy(index: usize) -> Self {
|
||||
Self {
|
||||
index,
|
||||
total: 1,
|
||||
original_name: String::new(),
|
||||
extension: String::new(),
|
||||
path: PathBuf::new(),
|
||||
size: 0,
|
||||
created: None,
|
||||
modified: None,
|
||||
date_taken: None,
|
||||
parent_folder: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub trait RenameRule: Send + Sync {
|
||||
fn apply(&self, filename: &str, context: &RenameContext) -> String;
|
||||
fn display_name(&self) -> &str;
|
||||
fn rule_type(&self) -> &str;
|
||||
fn is_enabled(&self) -> bool;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct FileEntry {
|
||||
pub path: PathBuf,
|
||||
pub name: String,
|
||||
pub stem: String,
|
||||
pub extension: String,
|
||||
pub size: u64,
|
||||
pub is_dir: bool,
|
||||
pub is_hidden: bool,
|
||||
pub created: Option<DateTime<Utc>>,
|
||||
pub modified: Option<DateTime<Utc>>,
|
||||
pub accessed: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PreviewResult {
|
||||
pub original_path: PathBuf,
|
||||
pub original_name: String,
|
||||
pub new_name: String,
|
||||
pub has_conflict: bool,
|
||||
pub has_error: bool,
|
||||
pub error_message: Option<String>,
|
||||
}
|
||||
32
crates/nomina-core/src/metadata.rs
Normal file
32
crates/nomina-core/src/metadata.rs
Normal file
@@ -0,0 +1,32 @@
|
||||
use std::path::Path;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
|
||||
pub struct ExifData {
|
||||
pub date_taken: Option<DateTime<Utc>>,
|
||||
pub camera_model: Option<String>,
|
||||
}
|
||||
|
||||
pub fn read_exif(path: &Path) -> Option<ExifData> {
|
||||
let file = std::fs::File::open(path).ok()?;
|
||||
let mut reader = std::io::BufReader::new(file);
|
||||
let exif = exif::Reader::new().read_from_container(&mut reader).ok()?;
|
||||
|
||||
let date_taken = exif
|
||||
.get_field(exif::Tag::DateTimeOriginal, exif::In::PRIMARY)
|
||||
.and_then(|f| {
|
||||
let val = f.display_value().to_string();
|
||||
chrono::NaiveDateTime::parse_from_str(&val, "%Y-%m-%d %H:%M:%S")
|
||||
.ok()
|
||||
.map(|dt| dt.and_utc())
|
||||
});
|
||||
|
||||
let camera_model = exif
|
||||
.get_field(exif::Tag::Model, exif::In::PRIMARY)
|
||||
.map(|f| f.display_value().to_string());
|
||||
|
||||
Some(ExifData {
|
||||
date_taken,
|
||||
camera_model,
|
||||
})
|
||||
}
|
||||
236
crates/nomina-core/src/pipeline.rs
Normal file
236
crates/nomina-core/src/pipeline.rs
Normal file
@@ -0,0 +1,236 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{PreviewResult, RenameContext, RenameRule};
|
||||
use crate::rules::extension::ExtensionRule;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum StepMode {
|
||||
Simultaneous,
|
||||
Sequential,
|
||||
}
|
||||
|
||||
pub struct PipelineStep {
|
||||
pub rule: Box<dyn RenameRule>,
|
||||
pub mode: StepMode,
|
||||
}
|
||||
|
||||
pub struct Pipeline {
|
||||
pub steps: Vec<PipelineStep>,
|
||||
pub extension_rule: Option<ExtensionRule>,
|
||||
}
|
||||
|
||||
impl Pipeline {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
steps: Vec::new(),
|
||||
extension_rule: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn add_step(&mut self, rule: Box<dyn RenameRule>, mode: StepMode) {
|
||||
self.steps.push(PipelineStep { rule, mode });
|
||||
}
|
||||
|
||||
pub fn preview(&self, files: &[crate::FileEntry]) -> Vec<PreviewResult> {
|
||||
let results: Vec<PreviewResult> = files
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, file)| {
|
||||
let ctx = RenameContext {
|
||||
index: i,
|
||||
total: files.len(),
|
||||
original_name: file.name.clone(),
|
||||
extension: file.extension.clone(),
|
||||
path: file.path.clone(),
|
||||
size: file.size,
|
||||
created: file.created,
|
||||
modified: file.modified,
|
||||
date_taken: None,
|
||||
parent_folder: file
|
||||
.path
|
||||
.parent()
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|n| n.to_string_lossy().to_string())
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
|
||||
let new_stem = self.apply_rules(&file.stem, &ctx);
|
||||
|
||||
let new_ext = if let Some(ref ext_rule) = self.extension_rule {
|
||||
if ext_rule.is_enabled() {
|
||||
ext_rule.transform_extension(&file.extension)
|
||||
} else {
|
||||
file.extension.clone()
|
||||
}
|
||||
} else {
|
||||
file.extension.clone()
|
||||
};
|
||||
|
||||
let new_name = if new_ext.is_empty() {
|
||||
new_stem
|
||||
} else {
|
||||
format!("{}.{}", new_stem, new_ext)
|
||||
};
|
||||
|
||||
PreviewResult {
|
||||
original_path: file.path.clone(),
|
||||
original_name: file.name.clone(),
|
||||
new_name,
|
||||
has_conflict: false,
|
||||
has_error: false,
|
||||
error_message: None,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
self.detect_conflicts(results)
|
||||
}
|
||||
|
||||
fn apply_rules(&self, stem: &str, ctx: &RenameContext) -> String {
|
||||
let mut working = stem.to_string();
|
||||
|
||||
// collect simultaneous rules
|
||||
let simultaneous: Vec<&PipelineStep> = self
|
||||
.steps
|
||||
.iter()
|
||||
.filter(|s| s.mode == StepMode::Simultaneous && s.rule.is_enabled())
|
||||
.collect();
|
||||
|
||||
// apply simultaneous rules (last one wins for the full output)
|
||||
if !simultaneous.is_empty() {
|
||||
let mut sim_result = stem.to_string();
|
||||
for step in &simultaneous {
|
||||
sim_result = step.rule.apply(stem, ctx);
|
||||
}
|
||||
working = sim_result;
|
||||
}
|
||||
|
||||
// apply sequential rules in order
|
||||
for step in &self.steps {
|
||||
if step.mode == StepMode::Sequential && step.rule.is_enabled() {
|
||||
working = step.rule.apply(&working, ctx);
|
||||
}
|
||||
}
|
||||
|
||||
working
|
||||
}
|
||||
|
||||
fn detect_conflicts(&self, mut results: Vec<PreviewResult>) -> Vec<PreviewResult> {
|
||||
let mut name_counts: HashMap<String, usize> = HashMap::new();
|
||||
for r in &results {
|
||||
*name_counts
|
||||
.entry(r.new_name.to_lowercase())
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
|
||||
for r in &mut results {
|
||||
if let Some(&count) = name_counts.get(&r.new_name.to_lowercase()) {
|
||||
if count > 1 {
|
||||
r.has_conflict = true;
|
||||
r.has_error = true;
|
||||
r.error_message = Some(format!(
|
||||
"{} files would be renamed to '{}'",
|
||||
count, r.new_name
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::rules::replace::ReplaceRule;
|
||||
use crate::rules::case::{CaseRule, CaseMode};
|
||||
use crate::FileEntry;
|
||||
use std::path::PathBuf;
|
||||
|
||||
fn make_file(name: &str) -> FileEntry {
|
||||
let stem = name.rsplit_once('.').map(|(s, _)| s).unwrap_or(name);
|
||||
let ext = name.rsplit_once('.').map(|(_, e)| e).unwrap_or("");
|
||||
FileEntry {
|
||||
path: PathBuf::from(format!("/test/{}", name)),
|
||||
name: name.to_string(),
|
||||
stem: stem.to_string(),
|
||||
extension: ext.to_string(),
|
||||
size: 100,
|
||||
is_dir: false,
|
||||
is_hidden: false,
|
||||
created: None,
|
||||
modified: None,
|
||||
accessed: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn basic_pipeline() {
|
||||
let mut pipeline = Pipeline::new();
|
||||
pipeline.add_step(
|
||||
Box::new(ReplaceRule {
|
||||
search: "IMG_".into(),
|
||||
replace_with: "photo-".into(),
|
||||
match_case: true,
|
||||
first_only: false,
|
||||
enabled: true,
|
||||
}),
|
||||
StepMode::Simultaneous,
|
||||
);
|
||||
|
||||
let files = vec![make_file("IMG_001.jpg"), make_file("IMG_002.jpg")];
|
||||
let results = pipeline.preview(&files);
|
||||
assert_eq!(results[0].new_name, "photo-001.jpg");
|
||||
assert_eq!(results[1].new_name, "photo-002.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sequential_chain() {
|
||||
let mut pipeline = Pipeline::new();
|
||||
pipeline.add_step(
|
||||
Box::new(ReplaceRule {
|
||||
search: "IMG_".into(),
|
||||
replace_with: "photo-".into(),
|
||||
match_case: true,
|
||||
first_only: false,
|
||||
enabled: true,
|
||||
}),
|
||||
StepMode::Sequential,
|
||||
);
|
||||
pipeline.add_step(
|
||||
Box::new(CaseRule {
|
||||
mode: CaseMode::Upper,
|
||||
exceptions: String::new(),
|
||||
enabled: true,
|
||||
}),
|
||||
StepMode::Sequential,
|
||||
);
|
||||
|
||||
let files = vec![make_file("IMG_001.jpg")];
|
||||
let results = pipeline.preview(&files);
|
||||
assert_eq!(results[0].new_name, "PHOTO-001.jpg");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflict_detection() {
|
||||
let mut pipeline = Pipeline::new();
|
||||
pipeline.add_step(
|
||||
Box::new(ReplaceRule {
|
||||
search: "".into(),
|
||||
replace_with: "".into(),
|
||||
match_case: true,
|
||||
first_only: false,
|
||||
enabled: false,
|
||||
}),
|
||||
StepMode::Simultaneous,
|
||||
);
|
||||
|
||||
let files = vec![make_file("same.txt"), make_file("same.txt")];
|
||||
let results = pipeline.preview(&files);
|
||||
assert!(results[0].has_conflict);
|
||||
assert!(results[1].has_conflict);
|
||||
}
|
||||
}
|
||||
47
crates/nomina-core/src/preset.rs
Normal file
47
crates/nomina-core/src/preset.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::filter::FilterConfig;
|
||||
use crate::pipeline::StepMode;
|
||||
use crate::NominaError;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NominaPreset {
|
||||
pub version: u32,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub created: String,
|
||||
pub rules: Vec<PresetRule>,
|
||||
pub filters: Option<FilterConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PresetRule {
|
||||
#[serde(rename = "type")]
|
||||
pub rule_type: String,
|
||||
pub step_mode: StepMode,
|
||||
pub enabled: bool,
|
||||
#[serde(flatten)]
|
||||
pub config: serde_json::Value,
|
||||
}
|
||||
|
||||
impl NominaPreset {
|
||||
pub fn save(&self, path: &std::path::Path) -> crate::Result<()> {
|
||||
let json = serde_json::to_string_pretty(self).map_err(|e| NominaError::PresetError {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
std::fs::write(path, json).map_err(|e| NominaError::Filesystem {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn load(path: &std::path::Path) -> crate::Result<Self> {
|
||||
let data = std::fs::read_to_string(path).map_err(|e| NominaError::Filesystem {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
serde_json::from_str(&data).map_err(|e| NominaError::PresetError {
|
||||
reason: e.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
110
crates/nomina-core/src/rules/add.rs
Normal file
110
crates/nomina-core/src/rules/add.rs
Normal file
@@ -0,0 +1,110 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{RenameContext, RenameRule};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AddRule {
|
||||
pub prefix: String,
|
||||
pub suffix: String,
|
||||
pub insert: String,
|
||||
pub insert_at: usize,
|
||||
pub word_space: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl AddRule {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
prefix: String::new(),
|
||||
suffix: String::new(),
|
||||
insert: String::new(),
|
||||
insert_at: 0,
|
||||
word_space: false,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for AddRule {
|
||||
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
|
||||
let mut result = filename.to_string();
|
||||
|
||||
if !self.insert.is_empty() {
|
||||
let chars: Vec<char> = result.chars().collect();
|
||||
let pos = self.insert_at.min(chars.len());
|
||||
let before: String = chars[..pos].iter().collect();
|
||||
let after: String = chars[pos..].iter().collect();
|
||||
if self.word_space {
|
||||
result = format!("{} {} {}", before.trim_end(), self.insert, after.trim_start());
|
||||
} else {
|
||||
result = format!("{}{}{}", before, self.insert, after);
|
||||
}
|
||||
}
|
||||
|
||||
if !self.prefix.is_empty() {
|
||||
if self.word_space {
|
||||
result = format!("{} {}", self.prefix, result);
|
||||
} else {
|
||||
result = format!("{}{}", self.prefix, result);
|
||||
}
|
||||
}
|
||||
|
||||
if !self.suffix.is_empty() {
|
||||
if self.word_space {
|
||||
result = format!("{} {}", result, self.suffix);
|
||||
} else {
|
||||
result = format!("{}{}", result, self.suffix);
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Add"
|
||||
}
|
||||
|
||||
fn rule_type(&self) -> &str {
|
||||
"add"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prefix() {
|
||||
let rule = AddRule {
|
||||
prefix: "new_".into(),
|
||||
..AddRule::new()
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("file", &ctx), "new_file");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suffix() {
|
||||
let rule = AddRule {
|
||||
suffix: "_bak".into(),
|
||||
..AddRule::new()
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("file", &ctx), "file_bak");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_at_position() {
|
||||
let rule = AddRule {
|
||||
insert: "-x-".into(),
|
||||
insert_at: 2,
|
||||
..AddRule::new()
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("abcd", &ctx), "ab-x-cd");
|
||||
}
|
||||
}
|
||||
149
crates/nomina-core/src/rules/case.rs
Normal file
149
crates/nomina-core/src/rules/case.rs
Normal file
@@ -0,0 +1,149 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{RenameContext, RenameRule};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum CaseMode {
|
||||
Same,
|
||||
Upper,
|
||||
Lower,
|
||||
Title,
|
||||
Sentence,
|
||||
Invert,
|
||||
Random,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CaseRule {
|
||||
pub mode: CaseMode,
|
||||
pub exceptions: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl CaseRule {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
mode: CaseMode::Same,
|
||||
exceptions: String::new(),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn exception_words(&self) -> Vec<String> {
|
||||
self.exceptions
|
||||
.split(',')
|
||||
.map(|s| s.trim().to_lowercase())
|
||||
.filter(|s| !s.is_empty())
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for CaseRule {
|
||||
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
|
||||
match self.mode {
|
||||
CaseMode::Same => filename.to_string(),
|
||||
CaseMode::Upper => filename.to_uppercase(),
|
||||
CaseMode::Lower => filename.to_lowercase(),
|
||||
CaseMode::Title => {
|
||||
let exceptions = self.exception_words();
|
||||
filename
|
||||
.split_inclusive(|c: char| !c.is_alphanumeric())
|
||||
.enumerate()
|
||||
.map(|(i, word)| {
|
||||
let trimmed = word.trim();
|
||||
if i > 0 && exceptions.contains(&trimmed.to_lowercase()) {
|
||||
word.to_lowercase()
|
||||
} else {
|
||||
let mut chars = word.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(c) => {
|
||||
c.to_uppercase().to_string() + &chars.as_str().to_lowercase()
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
CaseMode::Sentence => {
|
||||
let mut chars = filename.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(c) => c.to_uppercase().to_string() + &chars.as_str().to_lowercase(),
|
||||
}
|
||||
}
|
||||
CaseMode::Invert => filename
|
||||
.chars()
|
||||
.map(|c| {
|
||||
if c.is_uppercase() {
|
||||
c.to_lowercase().to_string()
|
||||
} else {
|
||||
c.to_uppercase().to_string()
|
||||
}
|
||||
})
|
||||
.collect(),
|
||||
CaseMode::Random => {
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
filename
|
||||
.chars()
|
||||
.enumerate()
|
||||
.map(|(i, c)| {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
(filename, i).hash(&mut hasher);
|
||||
if hasher.finish() % 2 == 0 {
|
||||
c.to_uppercase().to_string()
|
||||
} else {
|
||||
c.to_lowercase().to_string()
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Case"
|
||||
}
|
||||
|
||||
fn rule_type(&self) -> &str {
|
||||
"case"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn upper() {
|
||||
let rule = CaseRule { mode: CaseMode::Upper, ..CaseRule::new() };
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("hello world", &ctx), "HELLO WORLD");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lower() {
|
||||
let rule = CaseRule { mode: CaseMode::Lower, ..CaseRule::new() };
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("HELLO WORLD", &ctx), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sentence() {
|
||||
let rule = CaseRule { mode: CaseMode::Sentence, ..CaseRule::new() };
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("hELLO WORLD", &ctx), "Hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invert() {
|
||||
let rule = CaseRule { mode: CaseMode::Invert, ..CaseRule::new() };
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("Hello", &ctx), "hELLO");
|
||||
}
|
||||
}
|
||||
116
crates/nomina-core/src/rules/date.rs
Normal file
116
crates/nomina-core/src/rules/date.rs
Normal file
@@ -0,0 +1,116 @@
|
||||
use chrono::DateTime;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{RenameContext, RenameRule};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum DateMode {
|
||||
None,
|
||||
Prefix,
|
||||
Suffix,
|
||||
Insert,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum DateSource {
|
||||
Created,
|
||||
Modified,
|
||||
Accessed,
|
||||
ExifTaken,
|
||||
ExifDigitized,
|
||||
Current,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DateRule {
|
||||
pub mode: DateMode,
|
||||
pub source: DateSource,
|
||||
pub format: String,
|
||||
pub separator: String,
|
||||
pub segment_separator: String,
|
||||
pub include_time: bool,
|
||||
pub custom_format: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl DateRule {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
mode: DateMode::None,
|
||||
source: DateSource::Modified,
|
||||
format: "YMD".into(),
|
||||
separator: "-".into(),
|
||||
segment_separator: "_".into(),
|
||||
include_time: false,
|
||||
custom_format: None,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_date(&self, dt: &DateTime<chrono::Utc>) -> String {
|
||||
if let Some(ref custom) = self.custom_format {
|
||||
return dt.format(custom).to_string();
|
||||
}
|
||||
|
||||
let sep = &self.separator;
|
||||
let date_str = match self.format.as_str() {
|
||||
"YMD" => dt.format(&format!("%Y{sep}%m{sep}%d")).to_string(),
|
||||
"DMY" => dt.format(&format!("%d{sep}%m{sep}%Y")).to_string(),
|
||||
"MDY" => dt.format(&format!("%m{sep}%d{sep}%Y")).to_string(),
|
||||
"YM" => dt.format(&format!("%Y{sep}%m")).to_string(),
|
||||
"MY" => dt.format(&format!("%m{sep}%Y")).to_string(),
|
||||
"Y" => dt.format("%Y").to_string(),
|
||||
_ => dt.format(&format!("%Y{sep}%m{sep}%d")).to_string(),
|
||||
};
|
||||
|
||||
if self.include_time {
|
||||
let time_str = dt.format("%H%M%S").to_string();
|
||||
format!("{}{}{}", date_str, sep, time_str)
|
||||
} else {
|
||||
date_str
|
||||
}
|
||||
}
|
||||
|
||||
fn get_date(&self, context: &RenameContext) -> Option<DateTime<chrono::Utc>> {
|
||||
match self.source {
|
||||
DateSource::Created => context.created,
|
||||
DateSource::Modified => context.modified,
|
||||
DateSource::Accessed => None,
|
||||
DateSource::ExifTaken | DateSource::ExifDigitized => context.date_taken,
|
||||
DateSource::Current => Some(chrono::Utc::now()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for DateRule {
|
||||
fn apply(&self, filename: &str, context: &RenameContext) -> String {
|
||||
if self.mode == DateMode::None {
|
||||
return filename.to_string();
|
||||
}
|
||||
|
||||
let date = match self.get_date(context) {
|
||||
Some(d) => self.format_date(&d),
|
||||
None => return filename.to_string(),
|
||||
};
|
||||
|
||||
let seg = &self.segment_separator;
|
||||
match self.mode {
|
||||
DateMode::None => filename.to_string(),
|
||||
DateMode::Prefix => format!("{}{}{}", date, seg, filename),
|
||||
DateMode::Suffix => format!("{}{}{}", filename, seg, date),
|
||||
DateMode::Insert => format!("{}{}", date, filename),
|
||||
}
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Date"
|
||||
}
|
||||
|
||||
fn rule_type(&self) -> &str {
|
||||
"date"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
75
crates/nomina-core/src/rules/extension.rs
Normal file
75
crates/nomina-core/src/rules/extension.rs
Normal file
@@ -0,0 +1,75 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{RenameContext, RenameRule};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum ExtensionMode {
|
||||
Same,
|
||||
Lower,
|
||||
Upper,
|
||||
Title,
|
||||
Extra,
|
||||
Remove,
|
||||
Fixed,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ExtensionRule {
|
||||
pub mode: ExtensionMode,
|
||||
pub fixed_value: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl ExtensionRule {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
mode: ExtensionMode::Same,
|
||||
fixed_value: String::new(),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn transform_extension(&self, ext: &str) -> String {
|
||||
match self.mode {
|
||||
ExtensionMode::Same => ext.to_string(),
|
||||
ExtensionMode::Lower => ext.to_lowercase(),
|
||||
ExtensionMode::Upper => ext.to_uppercase(),
|
||||
ExtensionMode::Title => {
|
||||
let mut chars = ext.chars();
|
||||
match chars.next() {
|
||||
None => String::new(),
|
||||
Some(c) => c.to_uppercase().to_string() + &chars.as_str().to_lowercase(),
|
||||
}
|
||||
}
|
||||
ExtensionMode::Extra => {
|
||||
if self.fixed_value.is_empty() {
|
||||
ext.to_string()
|
||||
} else {
|
||||
format!("{}.{}", ext, self.fixed_value)
|
||||
}
|
||||
}
|
||||
ExtensionMode::Remove => String::new(),
|
||||
ExtensionMode::Fixed => self.fixed_value.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for ExtensionRule {
|
||||
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
|
||||
// extension rule is special - it operates on the stem, but the pipeline
|
||||
// handles extension separately. This apply just passes through.
|
||||
filename.to_string()
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Extension"
|
||||
}
|
||||
|
||||
fn rule_type(&self) -> &str {
|
||||
"extension"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
19
crates/nomina-core/src/rules/mod.rs
Normal file
19
crates/nomina-core/src/rules/mod.rs
Normal file
@@ -0,0 +1,19 @@
|
||||
pub mod replace;
|
||||
pub mod regex;
|
||||
pub mod remove;
|
||||
pub mod add;
|
||||
pub mod case;
|
||||
pub mod numbering;
|
||||
pub mod date;
|
||||
pub mod move_parts;
|
||||
pub mod extension;
|
||||
|
||||
pub use replace::ReplaceRule;
|
||||
pub use self::regex::RegexRule;
|
||||
pub use remove::RemoveRule;
|
||||
pub use add::AddRule;
|
||||
pub use case::{CaseMode, CaseRule};
|
||||
pub use numbering::{NumberBase, NumberMode, NumberingRule};
|
||||
pub use date::{DateMode, DateRule, DateSource};
|
||||
pub use move_parts::{MovePartsRule, MoveTarget};
|
||||
pub use extension::{ExtensionMode, ExtensionRule};
|
||||
132
crates/nomina-core/src/rules/move_parts.rs
Normal file
132
crates/nomina-core/src/rules/move_parts.rs
Normal file
@@ -0,0 +1,132 @@
|
||||
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)]
|
||||
pub struct MovePartsRule {
|
||||
pub source_from: usize,
|
||||
pub source_length: usize,
|
||||
pub target: MoveTarget,
|
||||
pub separator: String,
|
||||
pub copy_mode: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl MovePartsRule {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
source_from: 0,
|
||||
source_length: 0,
|
||||
target: MoveTarget::None,
|
||||
separator: String::new(),
|
||||
copy_mode: false,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for MovePartsRule {
|
||||
fn apply(&self, filename: &str, _context: &RenameContext) -> 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
|
||||
};
|
||||
|
||||
match &self.target {
|
||||
MoveTarget::None => filename.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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Move/Copy"
|
||||
}
|
||||
|
||||
fn rule_type(&self) -> &str {
|
||||
"move_parts"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[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");
|
||||
}
|
||||
}
|
||||
176
crates/nomina-core/src/rules/numbering.rs
Normal file
176
crates/nomina-core/src/rules/numbering.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{RenameContext, RenameRule};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum NumberMode {
|
||||
None,
|
||||
Prefix,
|
||||
Suffix,
|
||||
Both,
|
||||
Insert,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub enum NumberBase {
|
||||
Decimal,
|
||||
Hex,
|
||||
Octal,
|
||||
Binary,
|
||||
Alpha,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NumberingRule {
|
||||
pub mode: NumberMode,
|
||||
pub start: i64,
|
||||
pub increment: i64,
|
||||
pub padding: usize,
|
||||
pub separator: String,
|
||||
pub break_at: usize,
|
||||
pub base: NumberBase,
|
||||
pub per_folder: bool,
|
||||
pub insert_at: usize,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl NumberingRule {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
mode: NumberMode::None,
|
||||
start: 1,
|
||||
increment: 1,
|
||||
padding: 1,
|
||||
separator: "_".into(),
|
||||
break_at: 0,
|
||||
base: NumberBase::Decimal,
|
||||
per_folder: false,
|
||||
insert_at: 0,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn format_number(&self, n: i64) -> String {
|
||||
let s = match self.base {
|
||||
NumberBase::Decimal => format!("{}", n),
|
||||
NumberBase::Hex => format!("{:x}", n),
|
||||
NumberBase::Octal => format!("{:o}", n),
|
||||
NumberBase::Binary => format!("{:b}", n),
|
||||
NumberBase::Alpha => {
|
||||
if n <= 0 {
|
||||
return "a".to_string();
|
||||
}
|
||||
let mut result = String::new();
|
||||
let mut remaining = n - 1;
|
||||
loop {
|
||||
result.insert(0, (b'a' + (remaining % 26) as u8) as char);
|
||||
remaining = remaining / 26 - 1;
|
||||
if remaining < 0 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
};
|
||||
|
||||
if self.base != NumberBase::Alpha && s.len() < self.padding {
|
||||
format!("{:0>width$}", s, width = self.padding)
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for NumberingRule {
|
||||
fn apply(&self, filename: &str, context: &RenameContext) -> String {
|
||||
if self.mode == NumberMode::None {
|
||||
return filename.to_string();
|
||||
}
|
||||
|
||||
let idx = context.index as i64;
|
||||
let n = if self.break_at > 0 {
|
||||
self.start + (idx % self.break_at as i64) * self.increment
|
||||
} else {
|
||||
self.start + idx * self.increment
|
||||
};
|
||||
let num = self.format_number(n);
|
||||
|
||||
match self.mode {
|
||||
NumberMode::None => filename.to_string(),
|
||||
NumberMode::Prefix => format!("{}{}{}", num, self.separator, filename),
|
||||
NumberMode::Suffix => format!("{}{}{}", filename, self.separator, num),
|
||||
NumberMode::Both => format!(
|
||||
"{}{}{}{}{}",
|
||||
num, self.separator, filename, self.separator, num
|
||||
),
|
||||
NumberMode::Insert => {
|
||||
let chars: Vec<char> = filename.chars().collect();
|
||||
let pos = self.insert_at.min(chars.len());
|
||||
let before: String = chars[..pos].iter().collect();
|
||||
let after: String = chars[pos..].iter().collect();
|
||||
format!("{}{}{}", before, num, after)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Numbering"
|
||||
}
|
||||
|
||||
fn rule_type(&self) -> &str {
|
||||
"numbering"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn prefix_numbering() {
|
||||
let rule = NumberingRule {
|
||||
mode: NumberMode::Prefix,
|
||||
start: 1,
|
||||
increment: 1,
|
||||
padding: 3,
|
||||
separator: "_".into(),
|
||||
..NumberingRule::new()
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("photo", &ctx), "001_photo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn suffix_numbering() {
|
||||
let rule = NumberingRule {
|
||||
mode: NumberMode::Suffix,
|
||||
start: 1,
|
||||
increment: 1,
|
||||
padding: 2,
|
||||
separator: "-".into(),
|
||||
..NumberingRule::new()
|
||||
};
|
||||
let mut ctx = RenameContext::dummy(2);
|
||||
ctx.index = 2;
|
||||
assert_eq!(rule.apply("photo", &ctx), "photo-03");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hex_numbering() {
|
||||
let rule = NumberingRule {
|
||||
mode: NumberMode::Suffix,
|
||||
start: 10,
|
||||
increment: 1,
|
||||
padding: 1,
|
||||
separator: "_".into(),
|
||||
base: NumberBase::Hex,
|
||||
..NumberingRule::new()
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("file", &ctx), "file_a");
|
||||
}
|
||||
}
|
||||
100
crates/nomina-core/src/rules/regex.rs
Normal file
100
crates/nomina-core/src/rules/regex.rs
Normal file
@@ -0,0 +1,100 @@
|
||||
use regex::Regex;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{NominaError, RenameContext, RenameRule};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RegexRule {
|
||||
pub pattern: String,
|
||||
pub replace_with: String,
|
||||
pub case_insensitive: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl RegexRule {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
pattern: String::new(),
|
||||
replace_with: String::new(),
|
||||
case_insensitive: false,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_regex(&self) -> std::result::Result<Regex, NominaError> {
|
||||
let pat = if self.case_insensitive {
|
||||
format!("(?i){}", self.pattern)
|
||||
} else {
|
||||
self.pattern.clone()
|
||||
};
|
||||
Regex::new(&pat).map_err(|e| NominaError::InvalidRegex {
|
||||
pattern: self.pattern.clone(),
|
||||
reason: e.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for RegexRule {
|
||||
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
|
||||
if self.pattern.is_empty() {
|
||||
return filename.to_string();
|
||||
}
|
||||
match self.build_regex() {
|
||||
Ok(re) => re.replace_all(filename, self.replace_with.as_str()).into_owned(),
|
||||
Err(_) => filename.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Regex"
|
||||
}
|
||||
|
||||
fn rule_type(&self) -> &str {
|
||||
"regex"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn basic_regex() {
|
||||
let rule = RegexRule {
|
||||
pattern: r"(\d+)".into(),
|
||||
replace_with: "NUM".into(),
|
||||
case_insensitive: false,
|
||||
enabled: true,
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("file123", &ctx), "fileNUM");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn capture_groups() {
|
||||
let rule = RegexRule {
|
||||
pattern: r"([a-z]+)-([a-z]+)".into(),
|
||||
replace_with: "${2}_${1}".into(),
|
||||
case_insensitive: false,
|
||||
enabled: true,
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("hello-world", &ctx), "world_hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_pattern() {
|
||||
let rule = RegexRule {
|
||||
pattern: String::new(),
|
||||
replace_with: "x".into(),
|
||||
case_insensitive: false,
|
||||
enabled: true,
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("test", &ctx), "test");
|
||||
}
|
||||
}
|
||||
173
crates/nomina-core/src/rules/remove.rs
Normal file
173
crates/nomina-core/src/rules/remove.rs
Normal file
@@ -0,0 +1,173 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{RenameContext, RenameRule};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum RemoveMode {
|
||||
Chars,
|
||||
Words,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct TrimOptions {
|
||||
pub digits: bool,
|
||||
pub spaces: bool,
|
||||
pub symbols: bool,
|
||||
pub accents: bool,
|
||||
pub lead_dots: bool,
|
||||
}
|
||||
|
||||
impl Default for TrimOptions {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
digits: false,
|
||||
spaces: false,
|
||||
symbols: false,
|
||||
accents: false,
|
||||
lead_dots: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemoveRule {
|
||||
pub first_n: usize,
|
||||
pub last_n: usize,
|
||||
pub from: usize,
|
||||
pub to: usize,
|
||||
pub mode: RemoveMode,
|
||||
pub crop_before: Option<String>,
|
||||
pub crop_after: Option<String>,
|
||||
pub trim: TrimOptions,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl RemoveRule {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
first_n: 0,
|
||||
last_n: 0,
|
||||
from: 0,
|
||||
to: 0,
|
||||
mode: RemoveMode::Chars,
|
||||
crop_before: None,
|
||||
crop_after: None,
|
||||
trim: TrimOptions::default(),
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for RemoveRule {
|
||||
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
|
||||
let mut result: Vec<char> = filename.chars().collect();
|
||||
|
||||
// crop before/after first
|
||||
if let Some(ref marker) = self.crop_before {
|
||||
if let Some(pos) = filename.find(marker.as_str()) {
|
||||
result = result[pos..].to_vec();
|
||||
}
|
||||
}
|
||||
if let Some(ref marker) = self.crop_after {
|
||||
let s: String = result.iter().collect();
|
||||
if let Some(pos) = s.find(marker.as_str()) {
|
||||
result = result[..pos + marker.len()].to_vec();
|
||||
}
|
||||
}
|
||||
|
||||
let s: String = result.iter().collect();
|
||||
let mut result = s;
|
||||
|
||||
// remove first N
|
||||
if self.first_n > 0 && self.first_n < result.chars().count() {
|
||||
result = result.chars().skip(self.first_n).collect();
|
||||
}
|
||||
|
||||
// remove last N
|
||||
if self.last_n > 0 {
|
||||
let count = result.chars().count();
|
||||
if self.last_n < count {
|
||||
result = result.chars().take(count - self.last_n).collect();
|
||||
}
|
||||
}
|
||||
|
||||
// remove from..to range
|
||||
if self.to > self.from && self.from < result.chars().count() {
|
||||
let chars: Vec<char> = result.chars().collect();
|
||||
let to = self.to.min(chars.len());
|
||||
let mut s = String::new();
|
||||
for (i, c) in chars.iter().enumerate() {
|
||||
if i < self.from || i >= to {
|
||||
s.push(*c);
|
||||
}
|
||||
}
|
||||
result = s;
|
||||
}
|
||||
|
||||
// trim options
|
||||
if self.trim.lead_dots {
|
||||
result = result.trim_start_matches('.').to_string();
|
||||
}
|
||||
if self.trim.spaces {
|
||||
result = result.trim().to_string();
|
||||
}
|
||||
if self.trim.digits {
|
||||
result = result.chars().filter(|c| !c.is_ascii_digit()).collect();
|
||||
}
|
||||
if self.trim.symbols {
|
||||
result = result
|
||||
.chars()
|
||||
.filter(|c| c.is_alphanumeric() || c.is_whitespace() || *c == '-' || *c == '_' || *c == '.')
|
||||
.collect();
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Remove"
|
||||
}
|
||||
|
||||
fn rule_type(&self) -> &str {
|
||||
"remove"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn remove_first_n() {
|
||||
let rule = RemoveRule {
|
||||
first_n: 4,
|
||||
..RemoveRule::new()
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("IMG_001", &ctx), "001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_last_n() {
|
||||
let rule = RemoveRule {
|
||||
last_n: 3,
|
||||
..RemoveRule::new()
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("photo_raw", &ctx), "photo_");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn crop_before() {
|
||||
let rule = RemoveRule {
|
||||
crop_before: Some("-".into()),
|
||||
..RemoveRule::new()
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("prefix-content", &ctx), "-content");
|
||||
}
|
||||
}
|
||||
133
crates/nomina-core/src/rules/replace.rs
Normal file
133
crates/nomina-core/src/rules/replace.rs
Normal file
@@ -0,0 +1,133 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{RenameContext, RenameRule};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ReplaceRule {
|
||||
pub search: String,
|
||||
pub replace_with: String,
|
||||
pub match_case: bool,
|
||||
pub first_only: bool,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl ReplaceRule {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
search: String::new(),
|
||||
replace_with: String::new(),
|
||||
match_case: true,
|
||||
first_only: false,
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenameRule for ReplaceRule {
|
||||
fn apply(&self, filename: &str, _context: &RenameContext) -> String {
|
||||
if self.search.is_empty() {
|
||||
return filename.to_string();
|
||||
}
|
||||
|
||||
if self.match_case {
|
||||
if self.first_only {
|
||||
filename.replacen(&self.search, &self.replace_with, 1)
|
||||
} else {
|
||||
filename.replace(&self.search, &self.replace_with)
|
||||
}
|
||||
} else {
|
||||
let lower_search = self.search.to_lowercase();
|
||||
let mut result = String::new();
|
||||
let mut remaining = filename;
|
||||
|
||||
loop {
|
||||
let lower_remaining = remaining.to_lowercase();
|
||||
match lower_remaining.find(&lower_search) {
|
||||
Some(pos) => {
|
||||
result.push_str(&remaining[..pos]);
|
||||
result.push_str(&self.replace_with);
|
||||
remaining = &remaining[pos + self.search.len()..];
|
||||
if self.first_only {
|
||||
result.push_str(remaining);
|
||||
break;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
result.push_str(remaining);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
fn display_name(&self) -> &str {
|
||||
"Replace"
|
||||
}
|
||||
|
||||
fn rule_type(&self) -> &str {
|
||||
"replace"
|
||||
}
|
||||
|
||||
fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn basic_replace() {
|
||||
let rule = ReplaceRule {
|
||||
search: "IMG_".into(),
|
||||
replace_with: "photo-".into(),
|
||||
match_case: true,
|
||||
first_only: false,
|
||||
enabled: true,
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("IMG_001", &ctx), "photo-001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn case_insensitive() {
|
||||
let rule = ReplaceRule {
|
||||
search: "img_".into(),
|
||||
replace_with: "photo-".into(),
|
||||
match_case: false,
|
||||
first_only: false,
|
||||
enabled: true,
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("IMG_001", &ctx), "photo-001");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_only() {
|
||||
let rule = ReplaceRule {
|
||||
search: "a".into(),
|
||||
replace_with: "b".into(),
|
||||
match_case: true,
|
||||
first_only: true,
|
||||
enabled: true,
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("aaa", &ctx), "baa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn empty_search() {
|
||||
let rule = ReplaceRule {
|
||||
search: String::new(),
|
||||
replace_with: "x".into(),
|
||||
match_case: true,
|
||||
first_only: false,
|
||||
enabled: true,
|
||||
};
|
||||
let ctx = RenameContext::dummy(0);
|
||||
assert_eq!(rule.apply("test", &ctx), "test");
|
||||
}
|
||||
}
|
||||
115
crates/nomina-core/src/scanner.rs
Normal file
115
crates/nomina-core/src/scanner.rs
Normal file
@@ -0,0 +1,115 @@
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::filter::FilterConfig;
|
||||
use crate::FileEntry;
|
||||
|
||||
pub struct FileScanner {
|
||||
pub root: PathBuf,
|
||||
pub filters: FilterConfig,
|
||||
}
|
||||
|
||||
impl FileScanner {
|
||||
pub fn new(root: PathBuf, filters: FilterConfig) -> Self {
|
||||
Self { root, filters }
|
||||
}
|
||||
|
||||
pub fn scan(&self) -> Vec<FileEntry> {
|
||||
let max_depth = self.filters.subfolder_depth.map(|d| d + 1).unwrap_or(usize::MAX);
|
||||
|
||||
let walker = WalkDir::new(&self.root)
|
||||
.max_depth(max_depth)
|
||||
.follow_links(false);
|
||||
|
||||
let mut entries: Vec<FileEntry> = Vec::new();
|
||||
|
||||
for result in walker {
|
||||
let dir_entry = match result {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
// skip the root directory itself
|
||||
if dir_entry.path() == self.root {
|
||||
continue;
|
||||
}
|
||||
|
||||
let metadata = match dir_entry.metadata() {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let path = dir_entry.path().to_path_buf();
|
||||
let name = dir_entry.file_name().to_string_lossy().to_string();
|
||||
let is_dir = metadata.is_dir();
|
||||
let is_hidden = is_hidden_file(&path);
|
||||
|
||||
let (stem, extension) = if is_dir {
|
||||
(name.clone(), String::new())
|
||||
} else {
|
||||
split_filename(&name)
|
||||
};
|
||||
|
||||
let created = file_created(&metadata);
|
||||
let modified = file_modified(&metadata);
|
||||
let accessed = file_accessed(&metadata);
|
||||
|
||||
let entry = FileEntry {
|
||||
path,
|
||||
name,
|
||||
stem,
|
||||
extension,
|
||||
size: metadata.len(),
|
||||
is_dir,
|
||||
is_hidden,
|
||||
created,
|
||||
modified,
|
||||
accessed,
|
||||
};
|
||||
|
||||
if self.filters.matches(&entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
// natural sort
|
||||
entries.sort_by(|a, b| natord::compare(&a.name, &b.name));
|
||||
entries
|
||||
}
|
||||
}
|
||||
|
||||
fn split_filename(name: &str) -> (String, String) {
|
||||
match name.rsplit_once('.') {
|
||||
Some((stem, ext)) if !stem.is_empty() => (stem.to_string(), ext.to_string()),
|
||||
_ => (name.to_string(), String::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hidden_file(path: &Path) -> bool {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
use std::os::windows::fs::MetadataExt;
|
||||
if let Ok(meta) = std::fs::metadata(path) {
|
||||
const FILE_ATTRIBUTE_HIDDEN: u32 = 0x2;
|
||||
return meta.file_attributes() & FILE_ATTRIBUTE_HIDDEN != 0;
|
||||
}
|
||||
}
|
||||
|
||||
path.file_name()
|
||||
.map(|n| n.to_string_lossy().starts_with('.'))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
fn file_created(meta: &std::fs::Metadata) -> Option<DateTime<Utc>> {
|
||||
meta.created().ok().map(|t| DateTime::<Utc>::from(t))
|
||||
}
|
||||
|
||||
fn file_modified(meta: &std::fs::Metadata) -> Option<DateTime<Utc>> {
|
||||
meta.modified().ok().map(|t| DateTime::<Utc>::from(t))
|
||||
}
|
||||
|
||||
fn file_accessed(meta: &std::fs::Metadata) -> Option<DateTime<Utc>> {
|
||||
meta.accessed().ok().map(|t| DateTime::<Utc>::from(t))
|
||||
}
|
||||
88
crates/nomina-core/src/undo.rs
Normal file
88
crates/nomina-core/src/undo.rs
Normal file
@@ -0,0 +1,88 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::NominaError;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UndoLog {
|
||||
pub entries: Vec<UndoBatch>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UndoBatch {
|
||||
pub id: Uuid,
|
||||
pub timestamp: DateTime<Utc>,
|
||||
pub description: String,
|
||||
pub operations: Vec<UndoEntry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UndoEntry {
|
||||
pub original_path: PathBuf,
|
||||
pub renamed_path: PathBuf,
|
||||
}
|
||||
|
||||
const MAX_UNDO_BATCHES: usize = 50;
|
||||
|
||||
impl UndoLog {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load(path: &std::path::Path) -> crate::Result<Self> {
|
||||
if !path.exists() {
|
||||
return Ok(Self::new());
|
||||
}
|
||||
let data = std::fs::read_to_string(path).map_err(|e| NominaError::Filesystem {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
serde_json::from_str(&data).map_err(|e| NominaError::PresetError {
|
||||
reason: e.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save(&self, path: &std::path::Path) -> crate::Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).map_err(|e| NominaError::Filesystem {
|
||||
path: parent.to_path_buf(),
|
||||
source: e,
|
||||
})?;
|
||||
}
|
||||
let json = serde_json::to_string_pretty(self).map_err(|e| NominaError::PresetError {
|
||||
reason: e.to_string(),
|
||||
})?;
|
||||
std::fs::write(path, json).map_err(|e| NominaError::Filesystem {
|
||||
path: path.to_path_buf(),
|
||||
source: e,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn add_batch(&mut self, batch: UndoBatch) {
|
||||
self.entries.push(batch);
|
||||
while self.entries.len() > MAX_UNDO_BATCHES {
|
||||
self.entries.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn undo_last(&mut self) -> Option<UndoBatch> {
|
||||
self.entries.pop()
|
||||
}
|
||||
|
||||
pub fn undo_by_id(&mut self, id: Uuid) -> Option<UndoBatch> {
|
||||
if let Some(pos) = self.entries.iter().position(|b| b.id == id) {
|
||||
Some(self.entries.remove(pos))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.entries.clear();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user