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:
5444
Cargo.lock
generated
Normal file
5444
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
3
Cargo.toml
Normal file
3
Cargo.toml
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
[workspace]
|
||||||
|
members = ["crates/nomina-core", "crates/nomina-app"]
|
||||||
|
resolver = "2"
|
||||||
51
README.md
Normal file
51
README.md
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
# Nomina
|
||||||
|
|
||||||
|
Cross-platform bulk file renaming tool. Built with Rust, Tauri v2, and React.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Replace, regex, remove, add, case, numbering, date, move/copy, and extension rules
|
||||||
|
- Pipeline execution - simultaneous or sequential rule chaining
|
||||||
|
- Live preview with conflict detection
|
||||||
|
- Undo/revert system with persistent history
|
||||||
|
- Preset save/load/export
|
||||||
|
- Dark mode and light mode
|
||||||
|
- Virtual-scrolled file table for large directories
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- Rust (stable)
|
||||||
|
- Node.js (LTS)
|
||||||
|
- Visual Studio Build Tools (Windows)
|
||||||
|
|
||||||
|
### Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# install frontend deps
|
||||||
|
cd ui && npm install
|
||||||
|
|
||||||
|
# start dev server
|
||||||
|
cargo tauri dev
|
||||||
|
|
||||||
|
# run core library tests
|
||||||
|
cargo test -p nomina-core
|
||||||
|
|
||||||
|
# build release
|
||||||
|
cargo tauri build
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project structure
|
||||||
|
|
||||||
|
```
|
||||||
|
nomina/
|
||||||
|
crates/
|
||||||
|
nomina-core/ # pure Rust rename engine library
|
||||||
|
nomina-app/ # Tauri v2 desktop app
|
||||||
|
ui/ # React/TypeScript frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
CC0 (Public Domain Dedication)
|
||||||
23
crates/nomina-app/Cargo.toml
Normal file
23
crates/nomina-app/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "nomina-app"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
license = "CC0-1.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
nomina-core = { path = "../nomina-core" }
|
||||||
|
tauri = { version = "2", features = [] }
|
||||||
|
tauri-plugin-dialog = "2"
|
||||||
|
tauri-plugin-shell = "2"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
uuid = { version = "1", features = ["v4", "serde"] }
|
||||||
|
log = "0.4"
|
||||||
|
env_logger = "0.11"
|
||||||
|
directories = "6"
|
||||||
|
anyhow = "1"
|
||||||
|
tokio = { version = "1", features = ["full"] }
|
||||||
|
|
||||||
|
[build-dependencies]
|
||||||
|
tauri-build = { version = "2", features = [] }
|
||||||
3
crates/nomina-app/build.rs
Normal file
3
crates/nomina-app/build.rs
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fn main() {
|
||||||
|
tauri_build::build()
|
||||||
|
}
|
||||||
10
crates/nomina-app/capabilities/default.json
Normal file
10
crates/nomina-app/capabilities/default.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"identifier": "default",
|
||||||
|
"description": "Default permissions for Nomina",
|
||||||
|
"windows": ["main"],
|
||||||
|
"permissions": [
|
||||||
|
"core:default",
|
||||||
|
"dialog:default",
|
||||||
|
"shell:default"
|
||||||
|
]
|
||||||
|
}
|
||||||
1
crates/nomina-app/gen/schemas/acl-manifests.json
Normal file
1
crates/nomina-app/gen/schemas/acl-manifests.json
Normal file
File diff suppressed because one or more lines are too long
1
crates/nomina-app/gen/schemas/capabilities.json
Normal file
1
crates/nomina-app/gen/schemas/capabilities.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"default":{"identifier":"default","description":"Default permissions for Nomina","local":true,"windows":["main"],"permissions":["core:default","dialog:default","shell:default"]}}
|
||||||
2630
crates/nomina-app/gen/schemas/desktop-schema.json
Normal file
2630
crates/nomina-app/gen/schemas/desktop-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
2630
crates/nomina-app/gen/schemas/windows-schema.json
Normal file
2630
crates/nomina-app/gen/schemas/windows-schema.json
Normal file
File diff suppressed because it is too large
Load Diff
BIN
crates/nomina-app/icons/128x128.png
Normal file
BIN
crates/nomina-app/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 395 B |
BIN
crates/nomina-app/icons/128x128@2x.png
Normal file
BIN
crates/nomina-app/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 859 B |
BIN
crates/nomina-app/icons/32x32.png
Normal file
BIN
crates/nomina-app/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 113 B |
BIN
crates/nomina-app/icons/icon.icns
Normal file
BIN
crates/nomina-app/icons/icon.icns
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 762 B |
BIN
crates/nomina-app/icons/icon.ico
Normal file
BIN
crates/nomina-app/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.8 KiB |
50
crates/nomina-app/src/commands/files.rs
Normal file
50
crates/nomina-app/src/commands/files.rs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
use nomina_core::filter::FilterConfig;
|
||||||
|
use nomina_core::scanner::FileScanner;
|
||||||
|
use nomina_core::FileEntry;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn scan_directory(
|
||||||
|
path: String,
|
||||||
|
filters: Option<FilterConfig>,
|
||||||
|
) -> Result<Vec<FileEntry>, String> {
|
||||||
|
let root = PathBuf::from(&path);
|
||||||
|
if !root.exists() {
|
||||||
|
return Err(format!("Directory not found: {}", path));
|
||||||
|
}
|
||||||
|
if !root.is_dir() {
|
||||||
|
return Err(format!("Not a directory: {}", path));
|
||||||
|
}
|
||||||
|
|
||||||
|
let filter_config = filters.unwrap_or_default();
|
||||||
|
let scanner = FileScanner::new(root, filter_config);
|
||||||
|
Ok(scanner.scan())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_file_metadata(path: String) -> Result<FileEntry, String> {
|
||||||
|
let p = PathBuf::from(&path);
|
||||||
|
let meta = std::fs::metadata(&p).map_err(|e| e.to_string())?;
|
||||||
|
let name = p
|
||||||
|
.file_name()
|
||||||
|
.map(|n| n.to_string_lossy().to_string())
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let (stem, extension) = match name.rsplit_once('.') {
|
||||||
|
Some((s, e)) if !s.is_empty() => (s.to_string(), e.to_string()),
|
||||||
|
_ => (name.clone(), String::new()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(FileEntry {
|
||||||
|
path: p,
|
||||||
|
name,
|
||||||
|
stem,
|
||||||
|
extension,
|
||||||
|
size: meta.len(),
|
||||||
|
is_dir: meta.is_dir(),
|
||||||
|
is_hidden: false,
|
||||||
|
created: meta.created().ok().map(|t| chrono::DateTime::<chrono::Utc>::from(t)),
|
||||||
|
modified: meta.modified().ok().map(|t| chrono::DateTime::<chrono::Utc>::from(t)),
|
||||||
|
accessed: meta.accessed().ok().map(|t| chrono::DateTime::<chrono::Utc>::from(t)),
|
||||||
|
})
|
||||||
|
}
|
||||||
4
crates/nomina-app/src/commands/mod.rs
Normal file
4
crates/nomina-app/src/commands/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
pub mod files;
|
||||||
|
pub mod rename;
|
||||||
|
pub mod presets;
|
||||||
|
pub mod undo;
|
||||||
72
crates/nomina-app/src/commands/presets.rs
Normal file
72
crates/nomina-app/src/commands/presets.rs
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use nomina_core::preset::NominaPreset;
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn save_preset(preset: NominaPreset) -> Result<String, String> {
|
||||||
|
let dir = get_presets_dir();
|
||||||
|
std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let filename = format!("{}.nomina", sanitize_filename(&preset.name));
|
||||||
|
let path = dir.join(&filename);
|
||||||
|
preset.save(&path).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
Ok(path.to_string_lossy().to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn load_preset(path: String) -> Result<NominaPreset, String> {
|
||||||
|
NominaPreset::load(&PathBuf::from(path)).map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_presets() -> Result<Vec<PresetInfo>, String> {
|
||||||
|
let dir = get_presets_dir();
|
||||||
|
if !dir.exists() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut presets = Vec::new();
|
||||||
|
let entries = std::fs::read_dir(&dir).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
for entry in entries {
|
||||||
|
let entry = match entry {
|
||||||
|
Ok(e) => e,
|
||||||
|
Err(_) => continue,
|
||||||
|
};
|
||||||
|
let path = entry.path();
|
||||||
|
if path.extension().map(|e| e == "nomina").unwrap_or(false) {
|
||||||
|
if let Ok(preset) = NominaPreset::load(&path) {
|
||||||
|
presets.push(PresetInfo {
|
||||||
|
name: preset.name,
|
||||||
|
description: preset.description,
|
||||||
|
path: path.to_string_lossy().to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(presets)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Serialize)]
|
||||||
|
pub struct PresetInfo {
|
||||||
|
pub name: String,
|
||||||
|
pub description: String,
|
||||||
|
pub path: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_presets_dir() -> PathBuf {
|
||||||
|
let dirs = directories::ProjectDirs::from("com", "nomina", "Nomina")
|
||||||
|
.expect("failed to get app data directory");
|
||||||
|
dirs.data_dir().join("presets")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitize_filename(name: &str) -> String {
|
||||||
|
name.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
|
||||||
|
_ => c,
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
162
crates/nomina-app/src/commands/rename.rs
Normal file
162
crates/nomina-app/src/commands/rename.rs
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use nomina_core::pipeline::{Pipeline, StepMode};
|
||||||
|
use nomina_core::rules;
|
||||||
|
use nomina_core::scanner::FileScanner;
|
||||||
|
use nomina_core::filter::FilterConfig;
|
||||||
|
use nomina_core::undo::{UndoBatch, UndoEntry, UndoLog};
|
||||||
|
use nomina_core::{PreviewResult, RenameRule};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct RuleConfig {
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub rule_type: String,
|
||||||
|
pub step_mode: StepMode,
|
||||||
|
pub enabled: bool,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub config: serde_json::Value,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct RenameReport {
|
||||||
|
pub succeeded: usize,
|
||||||
|
pub failed: Vec<String>,
|
||||||
|
pub undo_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_rule(cfg: &RuleConfig) -> Option<Box<dyn RenameRule>> {
|
||||||
|
if !cfg.enabled {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let val = &cfg.config;
|
||||||
|
match cfg.rule_type.as_str() {
|
||||||
|
"replace" => serde_json::from_value::<rules::ReplaceRule>(val.clone())
|
||||||
|
.ok()
|
||||||
|
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||||
|
"regex" => serde_json::from_value::<rules::RegexRule>(val.clone())
|
||||||
|
.ok()
|
||||||
|
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||||
|
"remove" => serde_json::from_value::<rules::RemoveRule>(val.clone())
|
||||||
|
.ok()
|
||||||
|
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||||
|
"add" => serde_json::from_value::<rules::AddRule>(val.clone())
|
||||||
|
.ok()
|
||||||
|
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||||
|
"case" => serde_json::from_value::<rules::CaseRule>(val.clone())
|
||||||
|
.ok()
|
||||||
|
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||||
|
"numbering" => serde_json::from_value::<rules::NumberingRule>(val.clone())
|
||||||
|
.ok()
|
||||||
|
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||||
|
"date" => serde_json::from_value::<rules::DateRule>(val.clone())
|
||||||
|
.ok()
|
||||||
|
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||||
|
"move_parts" => serde_json::from_value::<rules::MovePartsRule>(val.clone())
|
||||||
|
.ok()
|
||||||
|
.map(|r| Box::new(r) as Box<dyn RenameRule>),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn preview_rename(
|
||||||
|
rules: Vec<RuleConfig>,
|
||||||
|
directory: String,
|
||||||
|
filters: Option<FilterConfig>,
|
||||||
|
) -> Result<Vec<PreviewResult>, String> {
|
||||||
|
let scanner = FileScanner::new(
|
||||||
|
PathBuf::from(&directory),
|
||||||
|
filters.unwrap_or_default(),
|
||||||
|
);
|
||||||
|
let files = scanner.scan();
|
||||||
|
|
||||||
|
let mut pipeline = Pipeline::new();
|
||||||
|
for cfg in &rules {
|
||||||
|
if let Some(rule) = build_rule(cfg) {
|
||||||
|
pipeline.add_step(rule, cfg.step_mode.clone());
|
||||||
|
}
|
||||||
|
if cfg.rule_type == "extension" {
|
||||||
|
if let Ok(ext_rule) = serde_json::from_value(cfg.config.clone()) {
|
||||||
|
pipeline.extension_rule = Some(ext_rule);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(pipeline.preview(&files))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn execute_rename(operations: Vec<PreviewResult>) -> Result<RenameReport, String> {
|
||||||
|
let valid: Vec<&PreviewResult> = operations
|
||||||
|
.iter()
|
||||||
|
.filter(|op| !op.has_conflict && !op.has_error && op.original_name != op.new_name)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// validation pass
|
||||||
|
for op in &valid {
|
||||||
|
if !op.original_path.exists() {
|
||||||
|
return Err(format!("File no longer exists: {}", op.original_path.display()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut succeeded = 0;
|
||||||
|
let mut failed = Vec::new();
|
||||||
|
let mut undo_entries = Vec::new();
|
||||||
|
|
||||||
|
for op in &valid {
|
||||||
|
let parent = op.original_path.parent().unwrap();
|
||||||
|
let new_path = parent.join(&op.new_name);
|
||||||
|
|
||||||
|
// if target exists and isn't another file we're renaming, use temp
|
||||||
|
let needs_temp = new_path.exists()
|
||||||
|
&& !valid.iter().any(|other| other.original_path == new_path);
|
||||||
|
|
||||||
|
let result = if needs_temp {
|
||||||
|
let tmp_name = format!("__nomina_tmp_{}", uuid::Uuid::new_v4());
|
||||||
|
let tmp_path = parent.join(&tmp_name);
|
||||||
|
std::fs::rename(&op.original_path, &tmp_path)
|
||||||
|
.and_then(|_| std::fs::rename(&tmp_path, &new_path))
|
||||||
|
} else {
|
||||||
|
std::fs::rename(&op.original_path, &new_path)
|
||||||
|
};
|
||||||
|
|
||||||
|
match result {
|
||||||
|
Ok(_) => {
|
||||||
|
succeeded += 1;
|
||||||
|
undo_entries.push(UndoEntry {
|
||||||
|
original_path: op.original_path.clone(),
|
||||||
|
renamed_path: new_path,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
failed.push(format!("{}: {}", op.original_name, e));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// save undo log
|
||||||
|
let batch = UndoBatch {
|
||||||
|
id: uuid::Uuid::new_v4(),
|
||||||
|
timestamp: chrono::Utc::now(),
|
||||||
|
description: format!("Renamed {} files", succeeded),
|
||||||
|
operations: undo_entries,
|
||||||
|
};
|
||||||
|
|
||||||
|
let undo_path = get_undo_log_path();
|
||||||
|
let mut log = UndoLog::load(&undo_path).unwrap_or_else(|_| UndoLog::new());
|
||||||
|
log.add_batch(batch.clone());
|
||||||
|
let _ = log.save(&undo_path);
|
||||||
|
|
||||||
|
Ok(RenameReport {
|
||||||
|
succeeded,
|
||||||
|
failed,
|
||||||
|
undo_id: batch.id.to_string(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_undo_log_path() -> PathBuf {
|
||||||
|
let dirs = directories::ProjectDirs::from("com", "nomina", "Nomina")
|
||||||
|
.expect("failed to get app data directory");
|
||||||
|
dirs.data_dir().join("undo.json")
|
||||||
|
}
|
||||||
84
crates/nomina-app/src/commands/undo.rs
Normal file
84
crates/nomina-app/src/commands/undo.rs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use nomina_core::undo::{UndoBatch, UndoLog};
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
pub struct UndoReport {
|
||||||
|
pub succeeded: usize,
|
||||||
|
pub failed: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_undo_history() -> Result<Vec<UndoBatch>, String> {
|
||||||
|
let path = get_undo_log_path();
|
||||||
|
let log = UndoLog::load(&path).map_err(|e| e.to_string())?;
|
||||||
|
Ok(log.entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn undo_last() -> Result<UndoReport, String> {
|
||||||
|
let path = get_undo_log_path();
|
||||||
|
let mut log = UndoLog::load(&path).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let batch = log.undo_last().ok_or("Nothing to undo")?;
|
||||||
|
let report = execute_undo(&batch)?;
|
||||||
|
|
||||||
|
log.save(&path).map_err(|e| e.to_string())?;
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn undo_batch(batch_id: String) -> Result<UndoReport, String> {
|
||||||
|
let path = get_undo_log_path();
|
||||||
|
let mut log = UndoLog::load(&path).map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let id: uuid::Uuid = batch_id.parse().map_err(|e: uuid::Error| e.to_string())?;
|
||||||
|
let batch = log.undo_by_id(id).ok_or("Undo batch not found")?;
|
||||||
|
let report = execute_undo(&batch)?;
|
||||||
|
|
||||||
|
log.save(&path).map_err(|e| e.to_string())?;
|
||||||
|
Ok(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn clear_undo_history() -> Result<(), String> {
|
||||||
|
let path = get_undo_log_path();
|
||||||
|
let mut log = UndoLog::load(&path).map_err(|e| e.to_string())?;
|
||||||
|
log.clear();
|
||||||
|
log.save(&path).map_err(|e| e.to_string())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_undo(batch: &UndoBatch) -> Result<UndoReport, String> {
|
||||||
|
let mut succeeded = 0;
|
||||||
|
let mut failed = Vec::new();
|
||||||
|
|
||||||
|
// reverse order
|
||||||
|
for entry in batch.operations.iter().rev() {
|
||||||
|
if !entry.renamed_path.exists() {
|
||||||
|
failed.push(format!(
|
||||||
|
"File no longer exists: {}",
|
||||||
|
entry.renamed_path.display()
|
||||||
|
));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
match std::fs::rename(&entry.renamed_path, &entry.original_path) {
|
||||||
|
Ok(_) => succeeded += 1,
|
||||||
|
Err(e) => failed.push(format!(
|
||||||
|
"{} -> {}: {}",
|
||||||
|
entry.renamed_path.display(),
|
||||||
|
entry.original_path.display(),
|
||||||
|
e
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(UndoReport { succeeded, failed })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_undo_log_path() -> PathBuf {
|
||||||
|
let dirs = directories::ProjectDirs::from("com", "nomina", "Nomina")
|
||||||
|
.expect("failed to get app data directory");
|
||||||
|
dirs.data_dir().join("undo.json")
|
||||||
|
}
|
||||||
24
crates/nomina-app/src/main.rs
Normal file
24
crates/nomina-app/src/main.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||||
|
|
||||||
|
mod commands;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
tauri::Builder::default()
|
||||||
|
.plugin(tauri_plugin_dialog::init())
|
||||||
|
.plugin(tauri_plugin_shell::init())
|
||||||
|
.invoke_handler(tauri::generate_handler![
|
||||||
|
commands::files::scan_directory,
|
||||||
|
commands::files::get_file_metadata,
|
||||||
|
commands::rename::preview_rename,
|
||||||
|
commands::rename::execute_rename,
|
||||||
|
commands::presets::save_preset,
|
||||||
|
commands::presets::load_preset,
|
||||||
|
commands::presets::list_presets,
|
||||||
|
commands::undo::undo_last,
|
||||||
|
commands::undo::undo_batch,
|
||||||
|
commands::undo::get_undo_history,
|
||||||
|
commands::undo::clear_undo_history,
|
||||||
|
])
|
||||||
|
.run(tauri::generate_context!())
|
||||||
|
.expect("error running nomina");
|
||||||
|
}
|
||||||
39
crates/nomina-app/tauri.conf.json
Normal file
39
crates/nomina-app/tauri.conf.json
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://raw.githubusercontent.com/nicegram/nicetauri/v2/tooling/cli/schema.json",
|
||||||
|
"productName": "Nomina",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"identifier": "com.nomina.app",
|
||||||
|
"build": {
|
||||||
|
"frontendDist": "../../ui/dist",
|
||||||
|
"devUrl": "http://localhost:5173",
|
||||||
|
"beforeDevCommand": "cd ../../ui && npm run dev",
|
||||||
|
"beforeBuildCommand": "cd ../../ui && npm run build"
|
||||||
|
},
|
||||||
|
"app": {
|
||||||
|
"windows": [
|
||||||
|
{
|
||||||
|
"title": "Nomina",
|
||||||
|
"width": 1200,
|
||||||
|
"height": 800,
|
||||||
|
"minWidth": 900,
|
||||||
|
"minHeight": 600,
|
||||||
|
"resizable": true,
|
||||||
|
"fullscreen": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"security": {
|
||||||
|
"csp": null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"bundle": {
|
||||||
|
"active": true,
|
||||||
|
"targets": "all",
|
||||||
|
"icon": [
|
||||||
|
"icons/32x32.png",
|
||||||
|
"icons/128x128.png",
|
||||||
|
"icons/128x128@2x.png",
|
||||||
|
"icons/icon.icns",
|
||||||
|
"icons/icon.ico"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
12
ui/index.html
Normal file
12
ui/index.html
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Nomina</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
2498
ui/package-lock.json
generated
Normal file
2498
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
ui/package.json
Normal file
27
ui/package.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "nomina-ui",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.1.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc && vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/react-virtual": "^3.13.22",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"zustand": "^5.0.11"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.1",
|
||||||
|
"@tauri-apps/api": "^2.10.1",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
|
"tailwindcss": "^4.2.1",
|
||||||
|
"typescript": "^5.9.3",
|
||||||
|
"vite": "^6.4.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
ui/src/App.tsx
Normal file
7
ui/src/App.tsx
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
import { AppShell } from "./components/layout/AppShell";
|
||||||
|
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
useKeyboardShortcuts();
|
||||||
|
return <AppShell />;
|
||||||
|
}
|
||||||
127
ui/src/components/browser/FileList.tsx
Normal file
127
ui/src/components/browser/FileList.tsx
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
import { useRef } from "react";
|
||||||
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
import { useFileStore } from "../../stores/fileStore";
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FileList() {
|
||||||
|
const files = useFileStore((s) => s.files);
|
||||||
|
const previewResults = useFileStore((s) => s.previewResults);
|
||||||
|
const selectedFiles = useFileStore((s) => s.selectedFiles);
|
||||||
|
const toggleFileSelection = useFileStore((s) => s.toggleFileSelection);
|
||||||
|
const parentRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const previewMap = new Map(previewResults.map((r) => [r.original_path, r]));
|
||||||
|
|
||||||
|
const rowVirtualizer = useVirtualizer({
|
||||||
|
count: files.length,
|
||||||
|
getScrollElement: () => parentRef.current,
|
||||||
|
estimateSize: () => 28,
|
||||||
|
overscan: 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (files.length === 0) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center justify-center h-full text-sm"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Navigate to a folder to see files
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* header */}
|
||||||
|
<div
|
||||||
|
className="flex text-xs font-medium border-b px-2 py-1 shrink-0"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-tertiary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-8" />
|
||||||
|
<div className="flex-1 min-w-0 px-2">Original Name</div>
|
||||||
|
<div className="flex-1 min-w-0 px-2">New Name</div>
|
||||||
|
<div className="w-20 px-2 text-right">Size</div>
|
||||||
|
<div className="w-16 px-2 text-center">Status</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* virtual rows */}
|
||||||
|
<div ref={parentRef} className="flex-1 overflow-auto">
|
||||||
|
<div style={{ height: `${rowVirtualizer.getTotalSize()}px`, position: "relative" }}>
|
||||||
|
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
|
||||||
|
const file = files[virtualRow.index];
|
||||||
|
const preview = previewMap.get(file.path);
|
||||||
|
const isSelected = selectedFiles.has(file.path);
|
||||||
|
const changed = preview && preview.new_name !== preview.original_name;
|
||||||
|
const hasError = preview?.has_error;
|
||||||
|
const hasConflict = preview?.has_conflict;
|
||||||
|
|
||||||
|
let rowBg = virtualRow.index % 2 === 0 ? "var(--row-even)" : "var(--row-odd)";
|
||||||
|
if (hasConflict) rowBg = "rgba(220, 38, 38, 0.1)";
|
||||||
|
else if (hasError) rowBg = "rgba(217, 119, 6, 0.1)";
|
||||||
|
else if (changed) rowBg = "rgba(22, 163, 74, 0.06)";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={file.path}
|
||||||
|
className="flex items-center text-xs px-2 absolute w-full"
|
||||||
|
style={{
|
||||||
|
height: `${virtualRow.size}px`,
|
||||||
|
transform: `translateY(${virtualRow.start}px)`,
|
||||||
|
background: rowBg,
|
||||||
|
borderBottom: "1px solid var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="w-8 flex items-center justify-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={isSelected}
|
||||||
|
onChange={() => toggleFileSelection(file.path)}
|
||||||
|
className="accent-[var(--accent)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex-1 min-w-0 px-2 truncate"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="flex-1 min-w-0 px-2 truncate"
|
||||||
|
style={{
|
||||||
|
color: changed ? "var(--success)" : "var(--text-secondary)",
|
||||||
|
fontWeight: changed ? 500 : 400,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{preview?.new_name || file.name}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="w-20 px-2 text-right"
|
||||||
|
style={{ color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
{file.is_dir ? "-" : formatSize(file.size)}
|
||||||
|
</div>
|
||||||
|
<div className="w-16 px-2 text-center">
|
||||||
|
{hasConflict && <span style={{ color: "var(--error)" }}>Conflict</span>}
|
||||||
|
{hasError && !hasConflict && (
|
||||||
|
<span style={{ color: "var(--warning)" }}>Error</span>
|
||||||
|
)}
|
||||||
|
{changed && !hasError && <span style={{ color: "var(--success)" }}>OK</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
26
ui/src/components/layout/AppShell.tsx
Normal file
26
ui/src/components/layout/AppShell.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
import { useState } from "react";
|
||||||
|
import { Sidebar } from "./Sidebar";
|
||||||
|
import { StatusBar } from "./StatusBar";
|
||||||
|
import { Toolbar } from "./Toolbar";
|
||||||
|
import { FileList } from "../browser/FileList";
|
||||||
|
import { RulePanel } from "../rules/RulePanel";
|
||||||
|
|
||||||
|
export function AppShell() {
|
||||||
|
const [sidebarWidth, setSidebarWidth] = useState(240);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-screen">
|
||||||
|
<Toolbar />
|
||||||
|
<div className="flex flex-1 min-h-0">
|
||||||
|
<Sidebar width={sidebarWidth} onResize={setSidebarWidth} />
|
||||||
|
<div className="flex flex-col flex-1 min-w-0">
|
||||||
|
<div className="flex-1 min-h-0 overflow-auto">
|
||||||
|
<FileList />
|
||||||
|
</div>
|
||||||
|
<RulePanel />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<StatusBar />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
151
ui/src/components/layout/Sidebar.tsx
Normal file
151
ui/src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
import { useState, useEffect } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { useFileStore } from "../../stores/fileStore";
|
||||||
|
|
||||||
|
interface SidebarProps {
|
||||||
|
width: number;
|
||||||
|
onResize: (width: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Sidebar({ width }: SidebarProps) {
|
||||||
|
const [pathInput, setPathInput] = useState("");
|
||||||
|
const [drives, setDrives] = useState<string[]>([]);
|
||||||
|
const [folders, setFolders] = useState<string[]>([]);
|
||||||
|
const scanDirectory = useFileStore((s) => s.scanDirectory);
|
||||||
|
const currentPath = useFileStore((s) => s.currentPath);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// detect windows drives
|
||||||
|
const detected: string[] = [];
|
||||||
|
for (const letter of "CDEFGHIJKLMNOPQRSTUVWXYZ") {
|
||||||
|
detected.push(`${letter}:\\`);
|
||||||
|
}
|
||||||
|
setDrives(detected);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPath) {
|
||||||
|
setPathInput(currentPath);
|
||||||
|
loadFolders(currentPath);
|
||||||
|
}
|
||||||
|
}, [currentPath]);
|
||||||
|
|
||||||
|
async function loadFolders(path: string) {
|
||||||
|
try {
|
||||||
|
const entries = await invoke<Array<{ path: string; name: string; is_dir: boolean }>>(
|
||||||
|
"scan_directory",
|
||||||
|
{
|
||||||
|
path,
|
||||||
|
filters: {
|
||||||
|
mask: "*",
|
||||||
|
regex_filter: null,
|
||||||
|
min_size: null,
|
||||||
|
max_size: null,
|
||||||
|
include_files: false,
|
||||||
|
include_folders: true,
|
||||||
|
include_hidden: false,
|
||||||
|
subfolder_depth: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
|
setFolders(entries.filter((e) => e.is_dir).map((e) => e.path));
|
||||||
|
} catch {
|
||||||
|
setFolders([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePathSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (pathInput.trim()) {
|
||||||
|
scanDirectory(pathInput.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleDriveClick(drive: string) {
|
||||||
|
scanDirectory(drive);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleFolderClick(path: string) {
|
||||||
|
scanDirectory(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
function folderName(path: string): string {
|
||||||
|
const parts = path.replace(/\\/g, "/").split("/").filter(Boolean);
|
||||||
|
return parts[parts.length - 1] || path;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex flex-col border-r overflow-hidden select-none"
|
||||||
|
style={{
|
||||||
|
width: `${width}px`,
|
||||||
|
minWidth: "180px",
|
||||||
|
maxWidth: "400px",
|
||||||
|
background: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<form onSubmit={handlePathSubmit} className="p-2 border-b" style={{ borderColor: "var(--border)" }}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={pathInput}
|
||||||
|
onChange={(e) => setPathInput(e.target.value)}
|
||||||
|
placeholder="Enter path..."
|
||||||
|
className="w-full px-2 py-1 text-xs rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-y-auto text-xs">
|
||||||
|
{!currentPath && (
|
||||||
|
<div className="p-2">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide mb-1" style={{ color: "var(--text-secondary)" }}>
|
||||||
|
Drives
|
||||||
|
</div>
|
||||||
|
{drives.map((d) => (
|
||||||
|
<button
|
||||||
|
key={d}
|
||||||
|
onClick={() => handleDriveClick(d)}
|
||||||
|
className="block w-full text-left px-2 py-1 rounded hover:opacity-80"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{d}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{currentPath && (
|
||||||
|
<div className="p-2">
|
||||||
|
{currentPath.includes("\\") || currentPath.includes("/") ? (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
const parent = currentPath.replace(/\\/g, "/").replace(/\/[^/]+\/?$/, "");
|
||||||
|
if (parent) scanDirectory(parent.replace(/\//g, "\\") || currentPath.slice(0, 3));
|
||||||
|
}}
|
||||||
|
className="block w-full text-left px-2 py-1 rounded mb-1 hover:opacity-80"
|
||||||
|
style={{ color: "var(--accent)" }}
|
||||||
|
>
|
||||||
|
..
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{folders.map((f) => (
|
||||||
|
<button
|
||||||
|
key={f}
|
||||||
|
onClick={() => handleFolderClick(f)}
|
||||||
|
className="block w-full text-left px-2 py-1 rounded truncate hover:opacity-80"
|
||||||
|
style={{ color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
{folderName(f)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
35
ui/src/components/layout/StatusBar.tsx
Normal file
35
ui/src/components/layout/StatusBar.tsx
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
import { useFileStore } from "../../stores/fileStore";
|
||||||
|
|
||||||
|
export function StatusBar() {
|
||||||
|
const files = useFileStore((s) => s.files);
|
||||||
|
const selectedFiles = useFileStore((s) => s.selectedFiles);
|
||||||
|
const previewResults = useFileStore((s) => s.previewResults);
|
||||||
|
const loading = useFileStore((s) => s.loading);
|
||||||
|
|
||||||
|
const conflicts = previewResults.filter((r) => r.has_conflict).length;
|
||||||
|
const changes = previewResults.filter(
|
||||||
|
(r) => r.original_name !== r.new_name && !r.has_error,
|
||||||
|
).length;
|
||||||
|
|
||||||
|
const status = loading ? "Scanning..." : changes > 0 ? "Preview ready" : "Ready";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-4 px-4 py-1 text-xs border-t select-none"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-secondary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span>{files.length} files</span>
|
||||||
|
<span>{selectedFiles.size} selected</span>
|
||||||
|
{changes > 0 && <span>{changes} to rename</span>}
|
||||||
|
{conflicts > 0 && (
|
||||||
|
<span style={{ color: "var(--error)" }}>{conflicts} conflicts</span>
|
||||||
|
)}
|
||||||
|
<div className="flex-1" />
|
||||||
|
<span>{status}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
98
ui/src/components/layout/Toolbar.tsx
Normal file
98
ui/src/components/layout/Toolbar.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import { useFileStore } from "../../stores/fileStore";
|
||||||
|
import { useRuleStore } from "../../stores/ruleStore";
|
||||||
|
|
||||||
|
export function Toolbar() {
|
||||||
|
const previewResults = useFileStore((s) => s.previewResults);
|
||||||
|
const currentPath = useFileStore((s) => s.currentPath);
|
||||||
|
const scanDirectory = useFileStore((s) => s.scanDirectory);
|
||||||
|
const requestPreview = useRuleStore((s) => s.requestPreview);
|
||||||
|
const resetAllRules = useRuleStore((s) => s.resetAllRules);
|
||||||
|
|
||||||
|
async function handleRename() {
|
||||||
|
const ops = previewResults.filter(
|
||||||
|
(r) => !r.has_conflict && !r.has_error && r.original_name !== r.new_name,
|
||||||
|
);
|
||||||
|
if (ops.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const report = await invoke<{ succeeded: number; failed: string[] }>(
|
||||||
|
"execute_rename",
|
||||||
|
{ operations: ops },
|
||||||
|
);
|
||||||
|
if (report.failed.length > 0) {
|
||||||
|
console.error("Some renames failed:", report.failed);
|
||||||
|
}
|
||||||
|
// refresh
|
||||||
|
if (currentPath) {
|
||||||
|
await scanDirectory(currentPath);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Rename failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUndo() {
|
||||||
|
try {
|
||||||
|
await invoke("undo_last");
|
||||||
|
if (currentPath) {
|
||||||
|
await scanDirectory(currentPath);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Undo failed:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasChanges = previewResults.some(
|
||||||
|
(r) => r.original_name !== r.new_name && !r.has_conflict && !r.has_error,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 px-4 py-2 border-b select-none"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-secondary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-semibold text-sm mr-4" style={{ color: "var(--accent)" }}>
|
||||||
|
Nomina
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleRename}
|
||||||
|
disabled={!hasChanges}
|
||||||
|
className="px-4 py-1.5 rounded text-sm font-medium text-white disabled:opacity-40"
|
||||||
|
style={{ background: hasChanges ? "var(--accent)" : "var(--border)" }}
|
||||||
|
>
|
||||||
|
Rename
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => requestPreview(currentPath)}
|
||||||
|
className="px-3 py-1.5 rounded text-sm border"
|
||||||
|
style={{ borderColor: "var(--border)", color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Preview
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleUndo}
|
||||||
|
className="px-3 py-1.5 rounded text-sm border"
|
||||||
|
style={{ borderColor: "var(--border)", color: "var(--text-primary)" }}
|
||||||
|
>
|
||||||
|
Undo
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={resetAllRules}
|
||||||
|
className="px-3 py-1.5 rounded text-sm border"
|
||||||
|
style={{ borderColor: "var(--border)", color: "var(--text-secondary)" }}
|
||||||
|
>
|
||||||
|
Clear Rules
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
ui/src/components/rules/AddTab.tsx
Normal file
102
ui/src/components/rules/AddTab.tsx
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import { useRuleStore } from "../../stores/ruleStore";
|
||||||
|
import type { AddConfig, StepMode } from "../../types/rules";
|
||||||
|
|
||||||
|
export function AddTab() {
|
||||||
|
const rule = useRuleStore((s) => s.rules.add) as AddConfig;
|
||||||
|
const updateRule = useRuleStore((s) => s.updateRule);
|
||||||
|
|
||||||
|
const update = (changes: Partial<AddConfig>) => updateRule("add", changes);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 text-xs">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<label className="flex flex-col flex-1 gap-1">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Prefix</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={rule.prefix}
|
||||||
|
onChange={(e) => update({ prefix: e.target.value })}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col flex-1 gap-1">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Suffix</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={rule.suffix}
|
||||||
|
onChange={(e) => update({ suffix: e.target.value })}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<label className="flex flex-col flex-1 gap-1">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Insert</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={rule.insert}
|
||||||
|
onChange={(e) => update({ insert: e.target.value })}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 w-24">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>At position</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={rule.insert_at}
|
||||||
|
onChange={(e) => update({ insert_at: parseInt(e.target.value) || 0 })}
|
||||||
|
min={0}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rule.word_space}
|
||||||
|
onChange={(e) => update({ word_space: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>Word space</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
|
||||||
|
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
|
||||||
|
<label key={mode} className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="add-mode"
|
||||||
|
checked={rule.step_mode === mode}
|
||||||
|
onChange={() => update({ step_mode: mode })}
|
||||||
|
/>
|
||||||
|
<span>{mode}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
69
ui/src/components/rules/CaseTab.tsx
Normal file
69
ui/src/components/rules/CaseTab.tsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useRuleStore } from "../../stores/ruleStore";
|
||||||
|
import type { CaseConfig, StepMode } from "../../types/rules";
|
||||||
|
|
||||||
|
const caseModes = ["Same", "Upper", "Lower", "Title", "Sentence", "Invert", "Random"] as const;
|
||||||
|
|
||||||
|
export function CaseTab() {
|
||||||
|
const rule = useRuleStore((s) => s.rules.case) as CaseConfig;
|
||||||
|
const updateRule = useRuleStore((s) => s.updateRule);
|
||||||
|
|
||||||
|
const update = (changes: Partial<CaseConfig>) => updateRule("case", changes);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 text-xs">
|
||||||
|
<div className="flex gap-3 items-end">
|
||||||
|
<label className="flex flex-col gap-1 w-40">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Case mode</span>
|
||||||
|
<select
|
||||||
|
value={rule.mode}
|
||||||
|
onChange={(e) => update({ mode: e.target.value as CaseConfig["mode"] })}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{caseModes.map((m) => (
|
||||||
|
<option key={m} value={m}>{m}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{rule.mode === "Title" && (
|
||||||
|
<label className="flex flex-col flex-1 gap-1">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Exceptions (comma-separated)</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={rule.exceptions}
|
||||||
|
onChange={(e) => update({ exceptions: e.target.value })}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
placeholder="the, a, an, of..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1" />
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
|
||||||
|
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
|
||||||
|
<label key={mode} className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="case-mode"
|
||||||
|
checked={rule.step_mode === mode}
|
||||||
|
onChange={() => update({ step_mode: mode })}
|
||||||
|
/>
|
||||||
|
<span>{mode}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
71
ui/src/components/rules/ExtensionTab.tsx
Normal file
71
ui/src/components/rules/ExtensionTab.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { useRuleStore } from "../../stores/ruleStore";
|
||||||
|
import type { ExtensionConfig, StepMode } from "../../types/rules";
|
||||||
|
|
||||||
|
const extModes = ["Same", "Lower", "Upper", "Title", "Extra", "Remove", "Fixed"] as const;
|
||||||
|
|
||||||
|
export function ExtensionTab() {
|
||||||
|
const rule = useRuleStore((s) => s.rules.extension) as ExtensionConfig;
|
||||||
|
const updateRule = useRuleStore((s) => s.updateRule);
|
||||||
|
|
||||||
|
const update = (changes: Partial<ExtensionConfig>) => updateRule("extension", changes);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 text-xs">
|
||||||
|
<div className="flex gap-3 items-end">
|
||||||
|
<label className="flex flex-col gap-1 w-40">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Extension mode</span>
|
||||||
|
<select
|
||||||
|
value={rule.mode}
|
||||||
|
onChange={(e) => update({ mode: e.target.value as ExtensionConfig["mode"] })}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{extModes.map((m) => (
|
||||||
|
<option key={m} value={m}>{m}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{(rule.mode === "Fixed" || rule.mode === "Extra") && (
|
||||||
|
<label className="flex flex-col flex-1 gap-1">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>
|
||||||
|
{rule.mode === "Extra" ? "Extra extension" : "New extension"}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={rule.fixed_value}
|
||||||
|
onChange={(e) => update({ fixed_value: e.target.value })}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
placeholder="e.g. bak, txt..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1" />
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
|
||||||
|
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
|
||||||
|
<label key={mode} className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="ext-mode"
|
||||||
|
checked={rule.step_mode === mode}
|
||||||
|
onChange={() => update({ step_mode: mode })}
|
||||||
|
/>
|
||||||
|
<span>{mode}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
136
ui/src/components/rules/NumberingTab.tsx
Normal file
136
ui/src/components/rules/NumberingTab.tsx
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
import { useRuleStore } from "../../stores/ruleStore";
|
||||||
|
import type { NumberingConfig, StepMode } from "../../types/rules";
|
||||||
|
|
||||||
|
const numberModes = ["None", "Prefix", "Suffix", "Both", "Insert"] as const;
|
||||||
|
const bases = ["Decimal", "Hex", "Octal", "Binary", "Alpha"] as const;
|
||||||
|
|
||||||
|
export function NumberingTab() {
|
||||||
|
const rule = useRuleStore((s) => s.rules.numbering) as NumberingConfig;
|
||||||
|
const updateRule = useRuleStore((s) => s.updateRule);
|
||||||
|
|
||||||
|
const update = (changes: Partial<NumberingConfig>) => updateRule("numbering", changes);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 text-xs">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<label className="flex flex-col gap-1 w-28">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Position</span>
|
||||||
|
<select
|
||||||
|
value={rule.mode}
|
||||||
|
onChange={(e) => update({ mode: e.target.value as NumberingConfig["mode"] })}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{numberModes.map((m) => (
|
||||||
|
<option key={m} value={m}>{m}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 w-20">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Start</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={rule.start}
|
||||||
|
onChange={(e) => update({ start: parseInt(e.target.value) || 0 })}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 w-20">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Step</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={rule.increment}
|
||||||
|
onChange={(e) => update({ increment: parseInt(e.target.value) || 1 })}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 w-20">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Padding</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={rule.padding}
|
||||||
|
onChange={(e) => update({ padding: parseInt(e.target.value) || 1 })}
|
||||||
|
min={1}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 w-20">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Separator</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={rule.separator}
|
||||||
|
onChange={(e) => update({ separator: e.target.value })}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 w-24">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Base</span>
|
||||||
|
<select
|
||||||
|
value={rule.base}
|
||||||
|
onChange={(e) => update({ base: e.target.value as NumberingConfig["base"] })}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{bases.map((b) => (
|
||||||
|
<option key={b} value={b}>{b}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rule.per_folder}
|
||||||
|
onChange={(e) => update({ per_folder: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>Reset per folder</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
|
||||||
|
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
|
||||||
|
<label key={mode} className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="numbering-mode"
|
||||||
|
checked={rule.step_mode === mode}
|
||||||
|
onChange={() => update({ step_mode: mode })}
|
||||||
|
/>
|
||||||
|
<span>{mode}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
74
ui/src/components/rules/RegexTab.tsx
Normal file
74
ui/src/components/rules/RegexTab.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useRuleStore } from "../../stores/ruleStore";
|
||||||
|
import type { RegexConfig, StepMode } from "../../types/rules";
|
||||||
|
|
||||||
|
export function RegexTab() {
|
||||||
|
const rule = useRuleStore((s) => s.rules.regex) as RegexConfig;
|
||||||
|
const updateRule = useRuleStore((s) => s.updateRule);
|
||||||
|
|
||||||
|
const update = (changes: Partial<RegexConfig>) => updateRule("regex", changes);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 text-xs">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<label className="flex flex-col flex-1 gap-1">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Pattern</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={rule.pattern}
|
||||||
|
onChange={(e) => update({ pattern: e.target.value })}
|
||||||
|
className="px-2 py-1.5 rounded border font-mono"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
placeholder="Regex pattern..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col flex-1 gap-1">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Replace with</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={rule.replace_with}
|
||||||
|
onChange={(e) => update({ replace_with: e.target.value })}
|
||||||
|
className="px-2 py-1.5 rounded border font-mono"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
placeholder="$1, $2 for capture groups..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rule.case_insensitive}
|
||||||
|
onChange={(e) => update({ case_insensitive: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>Case insensitive</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
|
||||||
|
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
|
||||||
|
<label key={mode} className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="regex-mode"
|
||||||
|
checked={rule.step_mode === mode}
|
||||||
|
onChange={() => update({ step_mode: mode })}
|
||||||
|
/>
|
||||||
|
<span>{mode}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
123
ui/src/components/rules/RemoveTab.tsx
Normal file
123
ui/src/components/rules/RemoveTab.tsx
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import { useRuleStore } from "../../stores/ruleStore";
|
||||||
|
import type { RemoveConfig, StepMode } from "../../types/rules";
|
||||||
|
|
||||||
|
export function RemoveTab() {
|
||||||
|
const rule = useRuleStore((s) => s.rules.remove) as RemoveConfig;
|
||||||
|
const updateRule = useRuleStore((s) => s.updateRule);
|
||||||
|
|
||||||
|
const update = (changes: Partial<RemoveConfig>) => updateRule("remove", changes);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 text-xs">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<label className="flex flex-col gap-1 w-24">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>First N</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={rule.first_n}
|
||||||
|
onChange={(e) => update({ first_n: parseInt(e.target.value) || 0 })}
|
||||||
|
min={0}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 w-24">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Last N</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={rule.last_n}
|
||||||
|
onChange={(e) => update({ last_n: parseInt(e.target.value) || 0 })}
|
||||||
|
min={0}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 w-24">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>From</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={rule.from}
|
||||||
|
onChange={(e) => update({ from: parseInt(e.target.value) || 0 })}
|
||||||
|
min={0}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col gap-1 w-24">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>To</span>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={rule.to}
|
||||||
|
onChange={(e) => update({ to: parseInt(e.target.value) || 0 })}
|
||||||
|
min={0}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<label className="flex flex-col flex-1 gap-1">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Crop before</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={rule.crop_before || ""}
|
||||||
|
onChange={(e) => update({ crop_before: e.target.value || null })}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col flex-1 gap-1">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Crop after</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={rule.crop_after || ""}
|
||||||
|
onChange={(e) => update({ crop_after: e.target.value || null })}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1" />
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
|
||||||
|
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
|
||||||
|
<label key={mode} className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="remove-mode"
|
||||||
|
checked={rule.step_mode === mode}
|
||||||
|
onChange={() => update({ step_mode: mode })}
|
||||||
|
/>
|
||||||
|
<span>{mode}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
ui/src/components/rules/ReplaceTab.tsx
Normal file
82
ui/src/components/rules/ReplaceTab.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { useRuleStore } from "../../stores/ruleStore";
|
||||||
|
import type { ReplaceConfig, StepMode } from "../../types/rules";
|
||||||
|
|
||||||
|
export function ReplaceTab() {
|
||||||
|
const rule = useRuleStore((s) => s.rules.replace) as ReplaceConfig;
|
||||||
|
const updateRule = useRuleStore((s) => s.updateRule);
|
||||||
|
|
||||||
|
const update = (changes: Partial<ReplaceConfig>) => updateRule("replace", changes);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-3 text-xs">
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<label className="flex flex-col flex-1 gap-1">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Find</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={rule.search}
|
||||||
|
onChange={(e) => update({ search: e.target.value })}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
placeholder="Text to find..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="flex flex-col flex-1 gap-1">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Replace with</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={rule.replace_with}
|
||||||
|
onChange={(e) => update({ replace_with: e.target.value })}
|
||||||
|
className="px-2 py-1.5 rounded border"
|
||||||
|
style={{
|
||||||
|
background: "var(--bg-primary)",
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
}}
|
||||||
|
placeholder="Replacement text..."
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rule.match_case}
|
||||||
|
onChange={(e) => update({ match_case: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>Match case</span>
|
||||||
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={rule.first_only}
|
||||||
|
onChange={(e) => update({ first_only: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span>First only</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span style={{ color: "var(--text-secondary)" }}>Mode:</span>
|
||||||
|
{(["Simultaneous", "Sequential"] as StepMode[]).map((mode) => (
|
||||||
|
<label key={mode} className="flex items-center gap-1 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="replace-mode"
|
||||||
|
checked={rule.step_mode === mode}
|
||||||
|
onChange={() => update({ step_mode: mode })}
|
||||||
|
/>
|
||||||
|
<span>{mode}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
113
ui/src/components/rules/RulePanel.tsx
Normal file
113
ui/src/components/rules/RulePanel.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useRuleStore } from "../../stores/ruleStore";
|
||||||
|
import { useFileStore } from "../../stores/fileStore";
|
||||||
|
import { ReplaceTab } from "./ReplaceTab";
|
||||||
|
import { RegexTab } from "./RegexTab";
|
||||||
|
import { RemoveTab } from "./RemoveTab";
|
||||||
|
import { AddTab } from "./AddTab";
|
||||||
|
import { CaseTab } from "./CaseTab";
|
||||||
|
import { NumberingTab } from "./NumberingTab";
|
||||||
|
import { ExtensionTab } from "./ExtensionTab";
|
||||||
|
|
||||||
|
const tabs = [
|
||||||
|
{ id: "replace", label: "Replace" },
|
||||||
|
{ id: "regex", label: "Regex" },
|
||||||
|
{ id: "remove", label: "Remove" },
|
||||||
|
{ id: "add", label: "Add" },
|
||||||
|
{ id: "case", label: "Case" },
|
||||||
|
{ id: "numbering", label: "Number" },
|
||||||
|
{ id: "extension", label: "Extension" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export function RulePanel() {
|
||||||
|
const activeTab = useRuleStore((s) => s.activeTab);
|
||||||
|
const setActiveTab = useRuleStore((s) => s.setActiveTab);
|
||||||
|
const rules = useRuleStore((s) => s.rules);
|
||||||
|
const requestPreview = useRuleStore((s) => s.requestPreview);
|
||||||
|
const currentPath = useFileStore((s) => s.currentPath);
|
||||||
|
|
||||||
|
// auto-preview when rules change
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentPath) {
|
||||||
|
requestPreview(currentPath);
|
||||||
|
}
|
||||||
|
}, [rules, currentPath, requestPreview]);
|
||||||
|
|
||||||
|
function isTabActive(id: string): boolean {
|
||||||
|
const rule = rules[id];
|
||||||
|
if (!rule || !rule.enabled) return false;
|
||||||
|
// check if rule has any non-default values set
|
||||||
|
switch (id) {
|
||||||
|
case "replace":
|
||||||
|
return !!(rule as any).search;
|
||||||
|
case "regex":
|
||||||
|
return !!(rule as any).pattern;
|
||||||
|
case "remove":
|
||||||
|
return (rule as any).first_n > 0 || (rule as any).last_n > 0 || (rule as any).from !== (rule as any).to;
|
||||||
|
case "add":
|
||||||
|
return !!(rule as any).prefix || !!(rule as any).suffix || !!(rule as any).insert;
|
||||||
|
case "case":
|
||||||
|
return (rule as any).mode !== "Same";
|
||||||
|
case "numbering":
|
||||||
|
return (rule as any).mode !== "None";
|
||||||
|
case "extension":
|
||||||
|
return (rule as any).mode !== "Same";
|
||||||
|
default:
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="border-t flex flex-col"
|
||||||
|
style={{
|
||||||
|
borderColor: "var(--border)",
|
||||||
|
background: "var(--bg-secondary)",
|
||||||
|
height: "240px",
|
||||||
|
minHeight: "160px",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* tab bar */}
|
||||||
|
<div
|
||||||
|
className="flex border-b shrink-0"
|
||||||
|
style={{ borderColor: "var(--border)" }}
|
||||||
|
>
|
||||||
|
{tabs.map((tab) => {
|
||||||
|
const active = activeTab === tab.id;
|
||||||
|
const hasContent = isTabActive(tab.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id)}
|
||||||
|
className="px-4 py-2 text-xs font-medium relative"
|
||||||
|
style={{
|
||||||
|
color: active ? "var(--accent)" : "var(--text-secondary)",
|
||||||
|
background: active ? "var(--bg-primary)" : "transparent",
|
||||||
|
borderBottom: active ? "2px solid var(--accent)" : "2px solid transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tab.label}
|
||||||
|
{hasContent && (
|
||||||
|
<span
|
||||||
|
className="absolute top-1 right-1 w-1.5 h-1.5 rounded-full"
|
||||||
|
style={{ background: "var(--accent)" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* tab content */}
|
||||||
|
<div className="flex-1 overflow-auto p-3">
|
||||||
|
{activeTab === "replace" && <ReplaceTab />}
|
||||||
|
{activeTab === "regex" && <RegexTab />}
|
||||||
|
{activeTab === "remove" && <RemoveTab />}
|
||||||
|
{activeTab === "add" && <AddTab />}
|
||||||
|
{activeTab === "case" && <CaseTab />}
|
||||||
|
{activeTab === "numbering" && <NumberingTab />}
|
||||||
|
{activeTab === "extension" && <ExtensionTab />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
ui/src/hooks/useDebounce.ts
Normal file
12
ui/src/hooks/useDebounce.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export function useDebounce<T>(value: T, delay: number): T {
|
||||||
|
const [debounced, setDebounced] = useState(value);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = setTimeout(() => setDebounced(value), delay);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}, [value, delay]);
|
||||||
|
|
||||||
|
return debounced;
|
||||||
|
}
|
||||||
36
ui/src/hooks/useKeyboardShortcuts.ts
Normal file
36
ui/src/hooks/useKeyboardShortcuts.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { useFileStore } from "../stores/fileStore";
|
||||||
|
import { useRuleStore } from "../stores/ruleStore";
|
||||||
|
|
||||||
|
export function useKeyboardShortcuts() {
|
||||||
|
const selectAll = useFileStore((s) => s.selectAll);
|
||||||
|
const deselectAll = useFileStore((s) => s.deselectAll);
|
||||||
|
const resetAllRules = useRuleStore((s) => s.resetAllRules);
|
||||||
|
const setActiveTab = useRuleStore((s) => s.setActiveTab);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const tabs = ["replace", "regex", "remove", "add", "case", "numbering", "extension"];
|
||||||
|
|
||||||
|
function handler(e: KeyboardEvent) {
|
||||||
|
if (e.ctrlKey && e.key === "a" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
selectAll();
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.shiftKey && e.key === "A") {
|
||||||
|
e.preventDefault();
|
||||||
|
deselectAll();
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
resetAllRules();
|
||||||
|
}
|
||||||
|
if (e.ctrlKey && e.key >= "1" && e.key <= "7") {
|
||||||
|
e.preventDefault();
|
||||||
|
const idx = parseInt(e.key) - 1;
|
||||||
|
if (idx < tabs.length) setActiveTab(tabs[idx]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
return () => window.removeEventListener("keydown", handler);
|
||||||
|
}, [selectAll, deselectAll, resetAllRules, setActiveTab]);
|
||||||
|
}
|
||||||
78
ui/src/index.css
Normal file
78
ui/src/index.css
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--bg-primary: #ffffff;
|
||||||
|
--bg-secondary: #f8f9fa;
|
||||||
|
--bg-tertiary: #e9ecef;
|
||||||
|
--text-primary: #212529;
|
||||||
|
--text-secondary: #6c757d;
|
||||||
|
--border: #dee2e6;
|
||||||
|
--accent: #4f46e5;
|
||||||
|
--accent-hover: #4338ca;
|
||||||
|
--success: #16a34a;
|
||||||
|
--warning: #d97706;
|
||||||
|
--error: #dc2626;
|
||||||
|
--row-even: #ffffff;
|
||||||
|
--row-odd: #f8f9fb;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg-primary: #1a1a2e;
|
||||||
|
--bg-secondary: #16213e;
|
||||||
|
--bg-tertiary: #0f3460;
|
||||||
|
--text-primary: #e2e8f0;
|
||||||
|
--text-secondary: #94a3b8;
|
||||||
|
--border: #334155;
|
||||||
|
--accent: #6366f1;
|
||||||
|
--accent-hover: #818cf8;
|
||||||
|
--success: #22c55e;
|
||||||
|
--warning: #f59e0b;
|
||||||
|
--error: #ef4444;
|
||||||
|
--row-even: #1a1a2e;
|
||||||
|
--row-odd: #1e2240;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
overflow: hidden;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#root {
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
input, select, button {
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-secondary);
|
||||||
|
}
|
||||||
10
ui/src/main.tsx
Normal file
10
ui/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from "react";
|
||||||
|
import ReactDOM from "react-dom/client";
|
||||||
|
import App from "./App";
|
||||||
|
import "./index.css";
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
);
|
||||||
85
ui/src/stores/fileStore.ts
Normal file
85
ui/src/stores/fileStore.ts
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import type { FileEntry, FilterConfig, PreviewResult } from "../types/files";
|
||||||
|
|
||||||
|
interface FileState {
|
||||||
|
currentPath: string;
|
||||||
|
files: FileEntry[];
|
||||||
|
previewResults: PreviewResult[];
|
||||||
|
selectedFiles: Set<string>;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
filters: FilterConfig;
|
||||||
|
|
||||||
|
setCurrentPath: (path: string) => void;
|
||||||
|
scanDirectory: (path: string) => Promise<void>;
|
||||||
|
setPreviewResults: (results: PreviewResult[]) => void;
|
||||||
|
toggleFileSelection: (path: string) => void;
|
||||||
|
selectAll: () => void;
|
||||||
|
deselectAll: () => void;
|
||||||
|
setFilters: (filters: Partial<FilterConfig>) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useFileStore = create<FileState>((set, get) => ({
|
||||||
|
currentPath: "",
|
||||||
|
files: [],
|
||||||
|
previewResults: [],
|
||||||
|
selectedFiles: new Set(),
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
filters: {
|
||||||
|
mask: "*",
|
||||||
|
regex_filter: null,
|
||||||
|
min_size: null,
|
||||||
|
max_size: null,
|
||||||
|
include_files: true,
|
||||||
|
include_folders: false,
|
||||||
|
include_hidden: false,
|
||||||
|
subfolder_depth: 0,
|
||||||
|
},
|
||||||
|
|
||||||
|
setCurrentPath: (path) => set({ currentPath: path }),
|
||||||
|
|
||||||
|
scanDirectory: async (path) => {
|
||||||
|
set({ loading: true, error: null });
|
||||||
|
try {
|
||||||
|
const files = await invoke<FileEntry[]>("scan_directory", {
|
||||||
|
path,
|
||||||
|
filters: get().filters,
|
||||||
|
});
|
||||||
|
set({
|
||||||
|
files,
|
||||||
|
currentPath: path,
|
||||||
|
loading: false,
|
||||||
|
selectedFiles: new Set(files.map((f) => f.path)),
|
||||||
|
previewResults: [],
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
set({ error: String(e), loading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setPreviewResults: (results) => set({ previewResults: results }),
|
||||||
|
|
||||||
|
toggleFileSelection: (path) => {
|
||||||
|
const selected = new Set(get().selectedFiles);
|
||||||
|
if (selected.has(path)) {
|
||||||
|
selected.delete(path);
|
||||||
|
} else {
|
||||||
|
selected.add(path);
|
||||||
|
}
|
||||||
|
set({ selectedFiles: selected });
|
||||||
|
},
|
||||||
|
|
||||||
|
selectAll: () => {
|
||||||
|
set({ selectedFiles: new Set(get().files.map((f) => f.path)) });
|
||||||
|
},
|
||||||
|
|
||||||
|
deselectAll: () => {
|
||||||
|
set({ selectedFiles: new Set() });
|
||||||
|
},
|
||||||
|
|
||||||
|
setFilters: (filters) => {
|
||||||
|
set({ filters: { ...get().filters, ...filters } });
|
||||||
|
},
|
||||||
|
}));
|
||||||
88
ui/src/stores/ruleStore.ts
Normal file
88
ui/src/stores/ruleStore.ts
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
|
import type { PreviewResult } from "../types/files";
|
||||||
|
import type { RuleConfig } from "../types/rules";
|
||||||
|
import { useFileStore } from "./fileStore";
|
||||||
|
import {
|
||||||
|
defaultReplace,
|
||||||
|
defaultRegex,
|
||||||
|
defaultRemove,
|
||||||
|
defaultAdd,
|
||||||
|
defaultCase,
|
||||||
|
defaultNumbering,
|
||||||
|
defaultExtension,
|
||||||
|
} from "../types/rules";
|
||||||
|
|
||||||
|
interface RuleState {
|
||||||
|
rules: Record<string, RuleConfig>;
|
||||||
|
activeTab: string;
|
||||||
|
previewDebounceTimer: ReturnType<typeof setTimeout> | null;
|
||||||
|
|
||||||
|
setActiveTab: (tab: string) => void;
|
||||||
|
updateRule: (type: string, updates: Partial<RuleConfig>) => void;
|
||||||
|
resetRule: (type: string) => void;
|
||||||
|
resetAllRules: () => void;
|
||||||
|
requestPreview: (directory: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaults(): Record<string, RuleConfig> {
|
||||||
|
return {
|
||||||
|
replace: defaultReplace(),
|
||||||
|
regex: defaultRegex(),
|
||||||
|
remove: defaultRemove(),
|
||||||
|
add: defaultAdd(),
|
||||||
|
case: defaultCase(),
|
||||||
|
numbering: defaultNumbering(),
|
||||||
|
extension: defaultExtension(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRuleStore = create<RuleState>((set, get) => ({
|
||||||
|
rules: getDefaults(),
|
||||||
|
activeTab: "replace",
|
||||||
|
previewDebounceTimer: null,
|
||||||
|
|
||||||
|
setActiveTab: (tab) => set({ activeTab: tab }),
|
||||||
|
|
||||||
|
updateRule: (type, updates) => {
|
||||||
|
const rules = { ...get().rules };
|
||||||
|
rules[type] = { ...rules[type], ...updates };
|
||||||
|
set({ rules });
|
||||||
|
},
|
||||||
|
|
||||||
|
resetRule: (type) => {
|
||||||
|
const defaults = getDefaults();
|
||||||
|
const rules = { ...get().rules };
|
||||||
|
rules[type] = defaults[type];
|
||||||
|
set({ rules });
|
||||||
|
},
|
||||||
|
|
||||||
|
resetAllRules: () => {
|
||||||
|
set({ rules: getDefaults() });
|
||||||
|
},
|
||||||
|
|
||||||
|
requestPreview: (directory) => {
|
||||||
|
const timer = get().previewDebounceTimer;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
|
||||||
|
const newTimer = setTimeout(async () => {
|
||||||
|
const { rules } = get();
|
||||||
|
const activeRules = Object.values(rules).filter((r) => r.enabled);
|
||||||
|
|
||||||
|
if (activeRules.length === 0 || !directory) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const results = await invoke<PreviewResult[]>("preview_rename", {
|
||||||
|
rules: activeRules,
|
||||||
|
directory,
|
||||||
|
});
|
||||||
|
|
||||||
|
useFileStore.getState().setPreviewResults(results);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Preview failed:", e);
|
||||||
|
}
|
||||||
|
}, 150);
|
||||||
|
|
||||||
|
set({ previewDebounceTimer: newTimer });
|
||||||
|
},
|
||||||
|
}));
|
||||||
13
ui/src/stores/settingsStore.ts
Normal file
13
ui/src/stores/settingsStore.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
type Theme = "light" | "dark" | "system";
|
||||||
|
|
||||||
|
interface SettingsState {
|
||||||
|
theme: Theme;
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSettingsStore = create<SettingsState>((set) => ({
|
||||||
|
theme: "system",
|
||||||
|
setTheme: (theme) => set({ theme }),
|
||||||
|
}));
|
||||||
32
ui/src/types/files.ts
Normal file
32
ui/src/types/files.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
export interface FileEntry {
|
||||||
|
path: string;
|
||||||
|
name: string;
|
||||||
|
stem: string;
|
||||||
|
extension: string;
|
||||||
|
size: number;
|
||||||
|
is_dir: boolean;
|
||||||
|
is_hidden: boolean;
|
||||||
|
created: string | null;
|
||||||
|
modified: string | null;
|
||||||
|
accessed: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PreviewResult {
|
||||||
|
original_path: string;
|
||||||
|
original_name: string;
|
||||||
|
new_name: string;
|
||||||
|
has_conflict: boolean;
|
||||||
|
has_error: boolean;
|
||||||
|
error_message: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FilterConfig {
|
||||||
|
mask: string;
|
||||||
|
regex_filter: string | null;
|
||||||
|
min_size: number | null;
|
||||||
|
max_size: number | null;
|
||||||
|
include_files: boolean;
|
||||||
|
include_folders: boolean;
|
||||||
|
include_hidden: boolean;
|
||||||
|
subfolder_depth: number | null;
|
||||||
|
}
|
||||||
17
ui/src/types/presets.ts
Normal file
17
ui/src/types/presets.ts
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import type { FilterConfig } from "./files";
|
||||||
|
import type { RuleConfig } from "./rules";
|
||||||
|
|
||||||
|
export interface NominaPreset {
|
||||||
|
version: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
created: string;
|
||||||
|
rules: RuleConfig[];
|
||||||
|
filters: FilterConfig | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PresetInfo {
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
154
ui/src/types/rules.ts
Normal file
154
ui/src/types/rules.ts
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
export type StepMode = "Simultaneous" | "Sequential";
|
||||||
|
|
||||||
|
export interface RuleConfig {
|
||||||
|
type: string;
|
||||||
|
step_mode: StepMode;
|
||||||
|
enabled: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReplaceConfig extends RuleConfig {
|
||||||
|
type: "replace";
|
||||||
|
search: string;
|
||||||
|
replace_with: string;
|
||||||
|
match_case: boolean;
|
||||||
|
first_only: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegexConfig extends RuleConfig {
|
||||||
|
type: "regex";
|
||||||
|
pattern: string;
|
||||||
|
replace_with: string;
|
||||||
|
case_insensitive: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RemoveConfig extends RuleConfig {
|
||||||
|
type: "remove";
|
||||||
|
first_n: number;
|
||||||
|
last_n: number;
|
||||||
|
from: number;
|
||||||
|
to: number;
|
||||||
|
crop_before: string | null;
|
||||||
|
crop_after: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddConfig extends RuleConfig {
|
||||||
|
type: "add";
|
||||||
|
prefix: string;
|
||||||
|
suffix: string;
|
||||||
|
insert: string;
|
||||||
|
insert_at: number;
|
||||||
|
word_space: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CaseConfig extends RuleConfig {
|
||||||
|
type: "case";
|
||||||
|
mode: "Same" | "Upper" | "Lower" | "Title" | "Sentence" | "Invert" | "Random";
|
||||||
|
exceptions: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface NumberingConfig extends RuleConfig {
|
||||||
|
type: "numbering";
|
||||||
|
mode: "None" | "Prefix" | "Suffix" | "Both" | "Insert";
|
||||||
|
start: number;
|
||||||
|
increment: number;
|
||||||
|
padding: number;
|
||||||
|
separator: string;
|
||||||
|
break_at: number;
|
||||||
|
base: "Decimal" | "Hex" | "Octal" | "Binary" | "Alpha";
|
||||||
|
per_folder: boolean;
|
||||||
|
insert_at: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExtensionConfig extends RuleConfig {
|
||||||
|
type: "extension";
|
||||||
|
mode: "Same" | "Lower" | "Upper" | "Title" | "Extra" | "Remove" | "Fixed";
|
||||||
|
fixed_value: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultReplace(): ReplaceConfig {
|
||||||
|
return {
|
||||||
|
type: "replace",
|
||||||
|
step_mode: "Simultaneous",
|
||||||
|
enabled: true,
|
||||||
|
search: "",
|
||||||
|
replace_with: "",
|
||||||
|
match_case: true,
|
||||||
|
first_only: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultRegex(): RegexConfig {
|
||||||
|
return {
|
||||||
|
type: "regex",
|
||||||
|
step_mode: "Simultaneous",
|
||||||
|
enabled: true,
|
||||||
|
pattern: "",
|
||||||
|
replace_with: "",
|
||||||
|
case_insensitive: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultRemove(): RemoveConfig {
|
||||||
|
return {
|
||||||
|
type: "remove",
|
||||||
|
step_mode: "Simultaneous",
|
||||||
|
enabled: true,
|
||||||
|
first_n: 0,
|
||||||
|
last_n: 0,
|
||||||
|
from: 0,
|
||||||
|
to: 0,
|
||||||
|
crop_before: null,
|
||||||
|
crop_after: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultAdd(): AddConfig {
|
||||||
|
return {
|
||||||
|
type: "add",
|
||||||
|
step_mode: "Simultaneous",
|
||||||
|
enabled: true,
|
||||||
|
prefix: "",
|
||||||
|
suffix: "",
|
||||||
|
insert: "",
|
||||||
|
insert_at: 0,
|
||||||
|
word_space: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultCase(): CaseConfig {
|
||||||
|
return {
|
||||||
|
type: "case",
|
||||||
|
step_mode: "Simultaneous",
|
||||||
|
enabled: true,
|
||||||
|
mode: "Same",
|
||||||
|
exceptions: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultNumbering(): NumberingConfig {
|
||||||
|
return {
|
||||||
|
type: "numbering",
|
||||||
|
step_mode: "Sequential",
|
||||||
|
enabled: true,
|
||||||
|
mode: "None",
|
||||||
|
start: 1,
|
||||||
|
increment: 1,
|
||||||
|
padding: 1,
|
||||||
|
separator: "_",
|
||||||
|
break_at: 0,
|
||||||
|
base: "Decimal",
|
||||||
|
per_folder: false,
|
||||||
|
insert_at: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultExtension(): ExtensionConfig {
|
||||||
|
return {
|
||||||
|
type: "extension",
|
||||||
|
step_mode: "Simultaneous",
|
||||||
|
enabled: true,
|
||||||
|
mode: "Same",
|
||||||
|
fixed_value: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
21
ui/tsconfig.json
Normal file
21
ui/tsconfig.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2020",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"forceConsistentCasingInFileNames": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
20
ui/vite.config.ts
Normal file
20
ui/vite.config.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import { defineConfig } from "vite";
|
||||||
|
import react from "@vitejs/plugin-react";
|
||||||
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
|
|
||||||
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
|
||||||
|
clearScreen: false,
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
|
host: host || false,
|
||||||
|
hmr: host ? { protocol: "ws", host, port: 5174 } : undefined,
|
||||||
|
watch: {
|
||||||
|
ignored: ["**/crates/**"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user