use crate::frontmatter::FrontmatterValue; use crate::search::SearchResponse; use crate::vault::{ DetectedRename, FolderNode, RenameResult, VaultEntry, ViewDefinition, ViewFile, }; use crate::{frontmatter, git, search, vault}; use super::expand_tilde; // ── Vault commands ────────────────────────────────────────────────────────── #[tauri::command] pub fn list_vault(path: String) -> Result, String> { let path = expand_tilde(&path); vault::scan_vault_cached(std::path::Path::new(path.as_ref())) } #[tauri::command] pub fn list_vault_folders(path: String) -> Result, String> { let path = expand_tilde(&path); vault::scan_vault_folders(std::path::Path::new(path.as_ref())) } #[tauri::command] pub fn get_note_content(path: String) -> Result { let path = expand_tilde(&path); vault::get_note_content(std::path::Path::new(path.as_ref())) } #[tauri::command] pub fn save_note_content(path: String, content: String) -> Result<(), String> { let path = expand_tilde(&path); vault::save_note_content(&path, &content) } #[tauri::command] pub fn rename_note( vault_path: String, old_path: String, new_title: String, old_title: Option, ) -> Result { let vault_path = expand_tilde(&vault_path); let old_path = expand_tilde(&old_path); vault::rename_note(&vault_path, &old_path, &new_title, old_title.as_deref()) } #[tauri::command] pub fn detect_renames(vault_path: String) -> Result, String> { let vault_path = expand_tilde(&vault_path); vault::detect_renames(&vault_path) } #[tauri::command] pub fn update_wikilinks_for_renames( vault_path: String, renames: Vec, ) -> Result { let vault_path = expand_tilde(&vault_path); vault::update_wikilinks_for_renames(&vault_path, &renames) } #[tauri::command] pub fn purge_trash(vault_path: String) -> Result, String> { let vault_path = expand_tilde(&vault_path); vault::purge_trash(&vault_path) } #[tauri::command] pub fn delete_note(path: String) -> Result { let path = expand_tilde(&path); vault::delete_note(&path) } #[tauri::command] pub fn batch_delete_notes(paths: Vec) -> Result, String> { let expanded: Vec = paths.iter().map(|p| expand_tilde(p).into_owned()).collect(); vault::batch_delete_notes(&expanded) } #[tauri::command] pub fn empty_trash(vault_path: String) -> Result, String> { let vault_path = expand_tilde(&vault_path); vault::empty_trash(&vault_path) } #[tauri::command] pub fn migrate_is_a_to_type(vault_path: String) -> Result { let vault_path = expand_tilde(&vault_path); vault::migrate_is_a_to_type(&vault_path) } #[tauri::command] pub fn create_vault_folder(vault_path: String, folder_name: String) -> Result { let vault_path = expand_tilde(&vault_path); let folder_path = std::path::Path::new(vault_path.as_ref()).join(&folder_name); if folder_path.exists() { return Err(format!("Folder '{}' already exists", folder_name)); } std::fs::create_dir_all(&folder_path).map_err(|e| format!("Failed to create folder: {}", e))?; Ok(folder_name) } #[tauri::command] pub fn create_empty_vault(target_path: String) -> Result { let path = expand_tilde(&target_path).into_owned(); let vault_dir = std::path::Path::new(&path); std::fs::create_dir_all(vault_dir) .map_err(|e| format!("Failed to create vault directory: {}", e))?; crate::git::init_repo(&path)?; Ok(vault_dir .canonicalize() .unwrap_or_else(|_| vault_dir.to_path_buf()) .to_string_lossy() .to_string()) } #[tauri::command] pub fn create_getting_started_vault(target_path: Option) -> Result { let path = match target_path { Some(p) if !p.is_empty() => expand_tilde(&p).into_owned(), _ => vault::default_vault_path()?.to_string_lossy().to_string(), }; vault::create_getting_started_vault(&path) } #[tauri::command] pub fn check_vault_exists(path: String) -> bool { let path = expand_tilde(&path); vault::vault_exists(&path) } #[tauri::command] pub fn get_default_vault_path() -> Result { vault::default_vault_path().map(|p| p.to_string_lossy().to_string()) } #[tauri::command] pub async fn reload_vault(path: String) -> Result, String> { let path = expand_tilde(&path).into_owned(); tokio::task::spawn_blocking(move || { vault::invalidate_cache(std::path::Path::new(&path)); vault::scan_vault_cached(std::path::Path::new(&path)) }) .await .map_err(|e| format!("Task panicked: {e}"))? } #[tauri::command] pub fn reload_vault_entry(path: String) -> Result { let path = expand_tilde(&path); vault::reload_entry(std::path::Path::new(path.as_ref())) } /// Sync the `title` frontmatter field with the filename on note open. /// Returns `true` if the file was modified (title was absent or desynced). #[tauri::command] pub fn sync_note_title(path: String) -> Result { use vault::SyncAction; let path = expand_tilde(&path); let action = vault::sync_title_on_open(std::path::Path::new(path.as_ref()))?; Ok(matches!(action, SyncAction::Updated { .. })) } #[tauri::command] pub fn save_image(vault_path: String, filename: String, data: String) -> Result { let vault_path = expand_tilde(&vault_path); vault::save_image(&vault_path, &filename, &data) } #[tauri::command] pub fn copy_image_to_vault(vault_path: String, source_path: String) -> Result { let vault_path = expand_tilde(&vault_path); vault::copy_image_to_vault(&vault_path, &source_path) } // ── View commands ────────────────────────────────────────────────────────── #[tauri::command] pub fn list_views(vault_path: String) -> Vec { let path = expand_tilde(&vault_path); vault::scan_views(std::path::Path::new(path.as_ref())) } #[tauri::command] pub fn save_view_cmd( vault_path: String, filename: String, definition: ViewDefinition, ) -> Result<(), String> { let path = expand_tilde(&vault_path); vault::save_view(std::path::Path::new(path.as_ref()), &filename, &definition) } #[tauri::command] pub fn delete_view_cmd(vault_path: String, filename: String) -> Result<(), String> { let path = expand_tilde(&vault_path); vault::delete_view(std::path::Path::new(path.as_ref()), &filename) } // ── Frontmatter commands ──────────────────────────────────────────────────── #[tauri::command] pub fn update_frontmatter( path: String, key: String, value: FrontmatterValue, ) -> Result { let path = expand_tilde(&path); frontmatter::update_frontmatter(&path, &key, value) } #[tauri::command] pub fn delete_frontmatter_property(path: String, key: String) -> Result { let path = expand_tilde(&path); frontmatter::delete_frontmatter_property(&path, &key) } #[tauri::command] pub fn batch_archive_notes(paths: Vec) -> Result { let mut count = 0; for path in &paths { let path = expand_tilde(path); frontmatter::update_frontmatter(&path, "_archived", FrontmatterValue::Bool(true))?; count += 1; } Ok(count) } #[tauri::command] pub fn batch_trash_notes(paths: Vec) -> Result { let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string(); let mut count = 0; for path in &paths { let path = expand_tilde(path); frontmatter::update_frontmatter(&path, "_trashed", FrontmatterValue::Bool(true))?; frontmatter::update_frontmatter( &path, "_trashed_at", FrontmatterValue::String(now.clone()), )?; count += 1; } Ok(count) } // ── Search commands ───────────────────────────────────────────────────────── #[tauri::command] pub async fn search_vault( vault_path: String, query: String, mode: String, limit: Option, ) -> Result { let vault_path = expand_tilde(&vault_path).into_owned(); let limit = limit.unwrap_or(20); tokio::task::spawn_blocking(move || search::search_vault(&vault_path, &query, &mode, limit)) .await .map_err(|e| format!("Search task failed: {}", e))? } // ── Repair command ────────────────────────────────────────────────────────── #[tauri::command] pub fn repair_vault(vault_path: String) -> Result { let vault_path = expand_tilde(&vault_path); vault::migrate_is_a_to_type(&vault_path)?; vault::repair_config_files(&vault_path)?; git::ensure_gitignore(&vault_path)?; Ok("Vault repaired".to_string()) } #[cfg(test)] mod tests { use super::*; fn temp_note(body: &str) -> (tempfile::TempDir, std::path::PathBuf) { let dir = tempfile::TempDir::new().unwrap(); let note = dir.path().join("note.md"); std::fs::write(¬e, body).unwrap(); (dir, note) } #[test] fn test_batch_archive_notes() { let (_dir, note) = temp_note("---\nStatus: Active\n---\n# Note\n"); assert_eq!( batch_archive_notes(vec![note.to_str().unwrap().to_string()]).unwrap(), 1 ); let content = std::fs::read_to_string(¬e).unwrap(); assert!(content.contains("_archived: true")); assert!(content.contains("Status: Active")); } #[test] fn test_batch_trash_notes() { let (_dir, note) = temp_note("---\nStatus: Active\n---\n# Note\n"); assert_eq!( batch_trash_notes(vec![note.to_str().unwrap().to_string()]).unwrap(), 1 ); let content = std::fs::read_to_string(¬e).unwrap(); assert!(content.contains("_trashed: true")); assert!(content.contains("_trashed_at")); } #[test] fn test_reload_vault_entry_reads_from_disk() { let dir = tempfile::TempDir::new().unwrap(); let note = dir.path().join("test.md"); std::fs::write(¬e, "---\ntitle: Test\nStatus: Active\n---\n# Test\n").unwrap(); let entry = reload_vault_entry(note.to_str().unwrap().to_string()).unwrap(); assert_eq!(entry.title, "Test"); assert_eq!(entry.status, Some("Active".to_string())); // Modify file on disk std::fs::write(¬e, "---\ntitle: Test\nStatus: Done\n---\n# Test\n").unwrap(); let fresh = reload_vault_entry(note.to_str().unwrap().to_string()).unwrap(); assert_eq!(fresh.status, Some("Done".to_string())); } #[test] fn test_reload_vault_entry_nonexistent() { let result = reload_vault_entry("/nonexistent/path.md".to_string()); assert!(result.is_err()); } #[test] fn test_reload_vault_invalidates_cache_and_rescans() { let dir = tempfile::TempDir::new().unwrap(); let vault_path = dir.path(); std::process::Command::new("git") .args(["init"]) .current_dir(vault_path) .output() .unwrap(); std::process::Command::new("git") .args(["config", "user.email", "t@t.com"]) .current_dir(vault_path) .output() .unwrap(); std::process::Command::new("git") .args(["config", "user.name", "T"]) .current_dir(vault_path) .output() .unwrap(); let cache_dir = tempfile::TempDir::new().unwrap(); std::env::set_var( "LAPUTA_CACHE_DIR", cache_dir.path().to_string_lossy().as_ref(), ); std::fs::write( vault_path.join("note.md"), "---\nTrashed: false\n---\n# Note\n", ) .unwrap(); std::process::Command::new("git") .args(["add", "."]) .current_dir(vault_path) .output() .unwrap(); std::process::Command::new("git") .args(["commit", "-m", "init"]) .current_dir(vault_path) .output() .unwrap(); let entries = list_vault(vault_path.to_str().unwrap().to_string()).unwrap(); assert!(!entries[0].trashed); std::fs::write( vault_path.join("note.md"), "---\nTrashed: true\n---\n# Note\n", ) .unwrap(); let vp_str = vault_path.to_str().unwrap(); crate::vault::invalidate_cache(std::path::Path::new(vp_str)); let fresh = crate::vault::scan_vault_cached(std::path::Path::new(vp_str)).unwrap(); assert!( fresh[0].trashed, "reload_vault must reflect disk state after trashing" ); } #[test] fn test_check_vault_exists_false() { assert!(!check_vault_exists("/nonexistent/path/abc123".to_string())); } #[test] fn test_get_default_vault_path_returns_ok() { let result = get_default_vault_path(); assert!(result.is_ok()); } #[test] fn test_repair_vault_migrates_is_a_to_type() { let dir = tempfile::TempDir::new().unwrap(); let vault_path = dir.path(); let note_dir = vault_path.join("note"); std::fs::create_dir_all(¬e_dir).unwrap(); std::fs::write(note_dir.join("hello.md"), "---\nis_a: Note\n---\n# Hello\n").unwrap(); let result = repair_vault(vault_path.to_str().unwrap().to_string()); assert!(result.is_ok()); assert!(note_dir.join("hello.md").exists()); let content = std::fs::read_to_string(note_dir.join("hello.md")).unwrap(); assert!(content.contains("type: Note")); assert!(!content.contains("is_a:")); } #[test] fn test_repair_vault_creates_config_files() { let dir = tempfile::TempDir::new().unwrap(); let vault_path = dir.path(); let result = repair_vault(vault_path.to_str().unwrap().to_string()); assert!(result.is_ok()); assert!(vault_path.join("AGENTS.md").exists()); assert!(vault_path.join("config.md").exists()); assert!(vault_path.join(".gitignore").exists()); } }