initial project scaffold
Rust workspace with nomina-core (rename engine) and nomina-app (Tauri v2 shell). React/TypeScript frontend with tabbed rule panels, virtual-scrolled file list, and Zustand state management. All 9 rule types implemented with 25 passing tests.
This commit is contained in:
23
crates/nomina-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"
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user