feat: purge_trash() — auto-delete notes trashed for 30+ days on startup

This commit is contained in:
lucaronin
2026-02-21 17:15:01 +01:00
4 changed files with 316 additions and 7 deletions

111
src-tauri/Cargo.lock generated
View File

@@ -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",

View File

@@ -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"

View File

@@ -63,6 +63,11 @@ fn save_image(vault_path: String, filename: String, data: String) -> Result<Stri
vault::save_image(&vault_path, &filename, &data)
}
#[tauri::command]
fn purge_trash(vault_path: String) -> Result<Vec<String>, 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");

View File

@@ -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<Vec<String>, 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::<YAML>::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<String, String> {
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"));
}
}