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:
2026-03-13 23:49:29 +02:00
commit 9dca2bedfa
69 changed files with 17462 additions and 0 deletions

View 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 = [] }

View File

@@ -0,0 +1,3 @@
fn main() {
tauri_build::build()
}

View File

@@ -0,0 +1,10 @@
{
"identifier": "default",
"description": "Default permissions for Nomina",
"windows": ["main"],
"permissions": [
"core:default",
"dialog:default",
"shell:default"
]
}

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1 @@
{"default":{"identifier":"default","description":"Default permissions for Nomina","local":true,"windows":["main"],"permissions":["core:default","dialog:default","shell:default"]}}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 395 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 762 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

View 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)),
})
}

View File

@@ -0,0 +1,4 @@
pub mod files;
pub mod rename;
pub mod presets;
pub mod undo;

View 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()
}

View 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")
}

View 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")
}

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

View 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"
]
}
}