diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9bb00c99..4fb24cc8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -656,13 +656,34 @@ dependencies = [ "crypto-common", ] +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + [[package]] name = "dirs" version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" dependencies = [ - "dirs-sys", + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", ] [[package]] @@ -673,7 +694,7 @@ checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" dependencies = [ "libc", "option-ext", - "redox_users", + "redox_users 0.5.2", "windows-sys 0.61.2", ] @@ -1879,6 +1900,7 @@ version = "0.1.0" dependencies = [ "base64 0.22.1", "chrono", + "dirs 5.0.1", "futures-util", "gray_matter", "log", @@ -2944,6 +2966,17 @@ dependencies = [ "bitflags 2.11.0", ] +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + [[package]] name = "redox_users" version = "0.5.2" @@ -3831,7 +3864,7 @@ dependencies = [ "anyhow", "bytes", "cookie", - "dirs", + "dirs 6.0.0", "dunce", "embed_plist", "getrandom 0.3.4", @@ -3882,7 +3915,7 @@ checksum = "ca7bd893329425df750813e95bd2b643d5369d929438da96d5bbb7cc2c918f74" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 6.0.0", "glob", "heck 0.5.0", "json-patch", @@ -4434,7 +4467,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" dependencies = [ "crossbeam-channel", - "dirs", + "dirs 6.0.0", "libappindicator", "muda", "objc2", @@ -5094,6 +5127,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + [[package]] name = "windows-sys" version = "0.52.0" @@ -5145,6 +5187,21 @@ dependencies = [ "windows_x86_64_msvc 0.42.2", ] +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + [[package]] name = "windows-targets" version = "0.52.6" @@ -5202,6 +5259,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + [[package]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -5220,6 +5283,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -5238,6 +5307,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -5268,6 +5343,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -5286,6 +5367,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + [[package]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -5304,6 +5391,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + [[package]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -5322,6 +5415,12 @@ version = "0.42.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + [[package]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -5466,7 +5565,7 @@ dependencies = [ "block2", "cookie", "crossbeam-channel", - "dirs", + "dirs 6.0.0", "dpi", "dunce", "gdkx11", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index b16138c7..efcb16e1 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ reqwest = { version = "0.12", features = ["json", "stream"] } tokio = { version = "1", features = ["rt-multi-thread", "macros"] } futures-util = "0.3" base64 = "0.22" +dirs = "5" [dev-dependencies] tempfile = "3" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9e2e4f3d..54164800 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -63,6 +63,11 @@ fn save_image(vault_path: String, filename: String, data: String) -> Result Result, String> { + vault::purge_trash(&vault_path) +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() @@ -74,6 +79,23 @@ pub fn run() { .build(), )?; } + + // Purge trashed files older than 30 days on startup + let vault_path = dirs::home_dir() + .map(|h| h.join("Laputa")) + .unwrap_or_default(); + if vault_path.is_dir() { + match vault::purge_trash(vault_path.to_str().unwrap_or_default()) { + Ok(deleted) if !deleted.is_empty() => { + log::info!("Purged {} trashed files on startup", deleted.len()); + } + Err(e) => { + log::warn!("Failed to purge trash on startup: {}", e); + } + _ => {} + } + } + Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -87,7 +109,8 @@ pub fn run() { git_commit, git_push, ai_chat, - save_image + save_image, + purge_trash ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/vault.rs b/src-tauri/src/vault.rs index 4d571bbd..5005e98f 100644 --- a/src-tauri/src/vault.rs +++ b/src-tauri/src/vault.rs @@ -425,6 +425,80 @@ fn pod_to_json(pod: gray_matter::Pod) -> serde_json::Value { } } +/// Scan all markdown files in the vault and delete those where +/// `Trashed at` frontmatter is more than 30 days ago. +/// Returns the list of deleted file paths. +pub fn purge_trash(vault_path: &str) -> Result, String> { + use chrono::{NaiveDate, Utc}; + + let vault = Path::new(vault_path); + if !vault.exists() || !vault.is_dir() { + return Err(format!( + "Vault path does not exist or is not a directory: {}", + vault_path + )); + } + + let today = Utc::now().date_naive(); + let matter = Matter::::new(); + let mut deleted = Vec::new(); + + for entry in WalkDir::new(vault) + .follow_links(true) + .into_iter() + .filter_map(|e| e.ok()) + { + let path = entry.path(); + if !path.is_file() + || path + .extension() + .map(|ext| ext != "md") + .unwrap_or(true) + { + continue; + } + + let content = match fs::read_to_string(path) { + Ok(c) => c, + Err(_) => continue, + }; + + let parsed = matter.parse(&content); + let trashed_at_str = match parsed.data { + Some(gray_matter::Pod::Hash(ref map)) => map + .get("Trashed at") + .or_else(|| map.get("trashed_at")) + .and_then(|v| match v { + gray_matter::Pod::String(s) => Some(s.clone()), + _ => None, + }), + _ => None, + }; + + if let Some(date_str) = trashed_at_str { + let trimmed = date_str.trim().trim_matches('"'); + // Support both "2026-01-01" and "2026-01-01T..." formats + let date_part = trimmed.split('T').next().unwrap_or(trimmed); + if let Ok(trashed_date) = NaiveDate::parse_from_str(date_part, "%Y-%m-%d") { + let age = today.signed_duration_since(trashed_date); + if age.num_days() > 30 { + match fs::remove_file(path) { + Ok(()) => { + log::info!("Purged trashed file: {}", path.display()); + deleted.push(path.to_string_lossy().to_string()); + } + Err(e) => { + log::warn!("Failed to delete {}: {}", path.display(), e); + } + } + } + } + } + } + + Ok(deleted) +} + /// Read the content of a single note file. pub fn get_note_content(path: &str) -> Result { let file_path = Path::new(path); @@ -1585,4 +1659,116 @@ References: assert!(result.is_err()); assert!(result.unwrap_err().contains("Invalid base64")); } + + // --- purge_trash tests --- + + #[test] + fn test_purge_trash_deletes_old_trashed_files() { + let dir = TempDir::new().unwrap(); + // File trashed 60 days ago — should be deleted + create_test_file( + dir.path(), + "old-trash.md", + "---\nTrashed at: \"2025-01-01\"\n---\n# Old Trash\n", + ); + // File trashed recently — should be kept + let recent = chrono::Utc::now().date_naive().format("%Y-%m-%d").to_string(); + create_test_file( + dir.path(), + "recent-trash.md", + &format!("---\nTrashed at: \"{}\"\n---\n# Recent Trash\n", recent), + ); + // File without trashed_at — should be kept + create_test_file( + dir.path(), + "normal.md", + "---\nIs A: Note\n---\n# Normal Note\n", + ); + + let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(deleted.len(), 1); + assert!(deleted[0].contains("old-trash.md")); + // Verify old file is actually gone + assert!(!dir.path().join("old-trash.md").exists()); + // Verify other files still exist + assert!(dir.path().join("recent-trash.md").exists()); + assert!(dir.path().join("normal.md").exists()); + } + + #[test] + fn test_purge_trash_supports_datetime_format() { + let dir = TempDir::new().unwrap(); + create_test_file( + dir.path(), + "datetime-trash.md", + "---\nTrashed at: \"2025-01-01T10:30:00Z\"\n---\n# Datetime Trash\n", + ); + + let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(deleted.len(), 1); + assert!(deleted[0].contains("datetime-trash.md")); + } + + #[test] + fn test_purge_trash_empty_vault() { + let dir = TempDir::new().unwrap(); + let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap(); + assert!(deleted.is_empty()); + } + + #[test] + fn test_purge_trash_nonexistent_path() { + let result = purge_trash("/nonexistent/path/that/does/not/exist"); + assert!(result.is_err()); + } + + #[test] + fn test_purge_trash_exactly_30_days_not_deleted() { + let dir = TempDir::new().unwrap(); + let thirty_days_ago = (chrono::Utc::now().date_naive() + - chrono::Duration::days(30)) + .format("%Y-%m-%d") + .to_string(); + create_test_file( + dir.path(), + "borderline.md", + &format!("---\nTrashed at: \"{}\"\n---\n# Borderline\n", thirty_days_ago), + ); + + let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap(); + assert!(deleted.is_empty()); + assert!(dir.path().join("borderline.md").exists()); + } + + #[test] + fn test_purge_trash_31_days_deleted() { + let dir = TempDir::new().unwrap(); + let thirty_one_days_ago = (chrono::Utc::now().date_naive() + - chrono::Duration::days(31)) + .format("%Y-%m-%d") + .to_string(); + create_test_file( + dir.path(), + "expired.md", + &format!("---\nTrashed at: \"{}\"\n---\n# Expired\n", thirty_one_days_ago), + ); + + let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(deleted.len(), 1); + assert!(!dir.path().join("expired.md").exists()); + } + + #[test] + fn test_purge_trash_nested_directories() { + let dir = TempDir::new().unwrap(); + create_test_file( + dir.path(), + "sub/deep/old.md", + "---\nTrashed at: \"2025-01-01\"\n---\n# Deep Old\n", + ); + + let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap(); + assert_eq!(deleted.len(), 1); + assert!(deleted[0].contains("old.md")); + } }