refactor: remove Trash system — delete is now permanent with confirm modal
Remove all vestiges of the abandoned Trash system: trashed/trashedAt fields from types, frontmatter parsing, sidebar filtering, editor banners, inspector components, mock data, and all related tests. Delete is already permanent via useDeleteActions with a confirmation dialog. Notes with trashed:true in existing vault frontmatter are now treated as normal notes (the flag is ignored by the parser). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -60,12 +60,6 @@ pub fn update_wikilinks_for_renames(
|
||||
vault::update_wikilinks_for_renames(&vault_path, &renames)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn purge_trash(vault_path: String) -> Result<Vec<String>, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
vault::purge_trash(&vault_path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn delete_note(path: String) -> Result<String, String> {
|
||||
let path = expand_tilde(&path);
|
||||
@@ -78,12 +72,6 @@ pub fn batch_delete_notes(paths: Vec<String>) -> Result<Vec<String>, String> {
|
||||
vault::batch_delete_notes(&expanded)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn empty_trash(vault_path: String) -> Result<Vec<String>, 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<usize, String> {
|
||||
let vault_path = expand_tilde(&vault_path);
|
||||
@@ -230,23 +218,6 @@ pub fn batch_archive_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
Ok(count)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn batch_trash_notes(paths: Vec<String>) -> Result<usize, String> {
|
||||
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]
|
||||
@@ -297,18 +268,6 @@ mod tests {
|
||||
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();
|
||||
@@ -359,7 +318,7 @@ mod tests {
|
||||
|
||||
std::fs::write(
|
||||
vault_path.join("note.md"),
|
||||
"---\nTrashed: false\n---\n# Note\n",
|
||||
"---\n_archived: false\n---\n# Note\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
@@ -374,11 +333,11 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let entries = list_vault(vault_path.to_str().unwrap().to_string()).unwrap();
|
||||
assert!(!entries[0].trashed);
|
||||
assert!(!entries[0].archived);
|
||||
|
||||
std::fs::write(
|
||||
vault_path.join("note.md"),
|
||||
"---\nTrashed: true\n---\n# Note\n",
|
||||
"---\n_archived: true\n---\n# Note\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -386,8 +345,8 @@ mod tests {
|
||||
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"
|
||||
fresh[0].archived,
|
||||
"reload_vault must reflect disk state after archiving"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -16,15 +16,10 @@ pub mod vault_list;
|
||||
use std::process::Child;
|
||||
#[cfg(desktop)]
|
||||
use std::sync::Mutex;
|
||||
#[cfg(desktop)]
|
||||
use std::time::Instant;
|
||||
|
||||
#[cfg(desktop)]
|
||||
struct WsBridgeChild(Mutex<Option<Child>>);
|
||||
|
||||
#[cfg(desktop)]
|
||||
struct LastPurgeTime(Mutex<Instant>);
|
||||
|
||||
#[cfg(desktop)]
|
||||
fn log_startup_result(label: &str, result: Result<usize, String>) {
|
||||
match result {
|
||||
@@ -34,7 +29,7 @@ fn log_startup_result(label: &str, result: Result<usize, String>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Run startup housekeeping on the default vault (purge old trash, migrate legacy frontmatter).
|
||||
/// Run startup housekeeping on the default vault (migrate legacy frontmatter, seed configs).
|
||||
#[cfg(desktop)]
|
||||
fn run_startup_tasks() {
|
||||
let vault_path = dirs::home_dir()
|
||||
@@ -44,10 +39,6 @@ fn run_startup_tasks() {
|
||||
return;
|
||||
}
|
||||
let vp_str = vault_path.to_str().unwrap_or_default();
|
||||
log_startup_result(
|
||||
"Purged trashed files on startup",
|
||||
vault::purge_trash(vp_str).map(|d| d.len()),
|
||||
);
|
||||
log_startup_result(
|
||||
"Migrated is_a to type on startup",
|
||||
vault::migrate_is_a_to_type(vp_str),
|
||||
@@ -86,8 +77,6 @@ pub fn run() {
|
||||
|
||||
#[cfg(desktop)]
|
||||
let builder = builder.manage(WsBridgeChild(Mutex::new(None)));
|
||||
#[cfg(desktop)]
|
||||
let builder = builder.manage(LastPurgeTime(Mutex::new(Instant::now())));
|
||||
|
||||
builder
|
||||
.setup(|app| {
|
||||
@@ -158,14 +147,11 @@ pub fn run() {
|
||||
commands::sync_note_title,
|
||||
commands::save_image,
|
||||
commands::copy_image_to_vault,
|
||||
commands::purge_trash,
|
||||
commands::delete_note,
|
||||
commands::batch_delete_notes,
|
||||
commands::empty_trash,
|
||||
commands::migrate_is_a_to_type,
|
||||
commands::create_vault_folder,
|
||||
commands::batch_archive_notes,
|
||||
commands::batch_trash_notes,
|
||||
commands::get_settings,
|
||||
commands::update_menu_state,
|
||||
commands::save_settings,
|
||||
@@ -196,39 +182,13 @@ pub fn run() {
|
||||
#[cfg(desktop)]
|
||||
{
|
||||
use tauri::Manager;
|
||||
match _event {
|
||||
tauri::RunEvent::Exit => {
|
||||
let state: tauri::State<'_, WsBridgeChild> = _app_handle.state();
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
if let Some(ref mut child) = *guard {
|
||||
let _ = child.kill();
|
||||
log::info!("ws-bridge child process killed on exit");
|
||||
}
|
||||
if let tauri::RunEvent::Exit = _event {
|
||||
let state: tauri::State<'_, WsBridgeChild> = _app_handle.state();
|
||||
let mut guard = state.0.lock().unwrap();
|
||||
if let Some(ref mut child) = *guard {
|
||||
let _ = child.kill();
|
||||
log::info!("ws-bridge child process killed on exit");
|
||||
}
|
||||
tauri::RunEvent::WindowEvent {
|
||||
event: tauri::WindowEvent::Focused(true),
|
||||
..
|
||||
} => {
|
||||
let state: tauri::State<'_, LastPurgeTime> = _app_handle.state();
|
||||
let mut last = state.0.lock().unwrap();
|
||||
if last.elapsed() >= std::time::Duration::from_secs(3600) {
|
||||
*last = Instant::now();
|
||||
drop(last);
|
||||
std::thread::spawn(|| {
|
||||
let vault_path = dirs::home_dir()
|
||||
.map(|h| h.join("Laputa"))
|
||||
.unwrap_or_default();
|
||||
if vault_path.is_dir() {
|
||||
log_startup_result(
|
||||
"Purged trashed files on focus",
|
||||
vault::purge_trash(vault_path.to_str().unwrap_or_default())
|
||||
.map(|d| d.len()),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -32,13 +32,11 @@ const VIEW_GO_FORWARD: &str = "view-go-forward";
|
||||
|
||||
const GO_ALL_NOTES: &str = "go-all-notes";
|
||||
const GO_ARCHIVED: &str = "go-archived";
|
||||
const GO_TRASH: &str = "go-trash";
|
||||
const GO_CHANGES: &str = "go-changes";
|
||||
const GO_INBOX: &str = "go-inbox";
|
||||
|
||||
const NOTE_ARCHIVE: &str = "note-archive";
|
||||
const NOTE_TRASH: &str = "note-trash";
|
||||
const NOTE_EMPTY_TRASH: &str = "note-empty-trash";
|
||||
const NOTE_DELETE: &str = "note-delete";
|
||||
const NOTE_OPEN_IN_NEW_WINDOW: &str = "note-open-in-new-window";
|
||||
|
||||
const VAULT_OPEN: &str = "vault-open";
|
||||
@@ -77,11 +75,9 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
VIEW_GO_FORWARD,
|
||||
GO_ALL_NOTES,
|
||||
GO_ARCHIVED,
|
||||
GO_TRASH,
|
||||
GO_CHANGES,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
NOTE_EMPTY_TRASH,
|
||||
NOTE_DELETE,
|
||||
NOTE_OPEN_IN_NEW_WINDOW,
|
||||
VAULT_OPEN,
|
||||
VAULT_REMOVE,
|
||||
@@ -99,7 +95,7 @@ const CUSTOM_IDS: &[&str] = &[
|
||||
const NOTE_DEPENDENT_IDS: &[&str] = &[
|
||||
FILE_SAVE,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
NOTE_DELETE,
|
||||
EDIT_TOGGLE_RAW_EDITOR,
|
||||
EDIT_TOGGLE_DIFF,
|
||||
VIEW_TOGGLE_BACKLINKS,
|
||||
@@ -249,7 +245,6 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
let archived = MenuItemBuilder::new("Archived")
|
||||
.id(GO_ARCHIVED)
|
||||
.build(app)?;
|
||||
let trash = MenuItemBuilder::new("Trash").id(GO_TRASH).build(app)?;
|
||||
let changes = MenuItemBuilder::new("Changes").id(GO_CHANGES).build(app)?;
|
||||
let inbox = MenuItemBuilder::new("Inbox").id(GO_INBOX).build(app)?;
|
||||
let go_back = MenuItemBuilder::new("Go Back")
|
||||
@@ -264,7 +259,6 @@ fn build_go_menu(app: &App) -> MenuResult {
|
||||
Ok(SubmenuBuilder::new(app, "Go")
|
||||
.item(&all_notes)
|
||||
.item(&archived)
|
||||
.item(&trash)
|
||||
.item(&changes)
|
||||
.item(&inbox)
|
||||
.separator()
|
||||
@@ -278,13 +272,10 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
.id(NOTE_ARCHIVE)
|
||||
.accelerator("CmdOrCtrl+E")
|
||||
.build(app)?;
|
||||
let trash_note = MenuItemBuilder::new("Trash Note")
|
||||
.id(NOTE_TRASH)
|
||||
let delete_note = MenuItemBuilder::new("Delete Note")
|
||||
.id(NOTE_DELETE)
|
||||
.accelerator("CmdOrCtrl+Backspace")
|
||||
.build(app)?;
|
||||
let empty_trash = MenuItemBuilder::new("Empty Trash…")
|
||||
.id(NOTE_EMPTY_TRASH)
|
||||
.build(app)?;
|
||||
let open_new_window = MenuItemBuilder::new("Open in New Window")
|
||||
.id(NOTE_OPEN_IN_NEW_WINDOW)
|
||||
.accelerator("CmdOrCtrl+Shift+O")
|
||||
@@ -303,8 +294,7 @@ fn build_note_menu(app: &App) -> MenuResult {
|
||||
|
||||
Ok(SubmenuBuilder::new(app, "Note")
|
||||
.item(&archive_note)
|
||||
.item(&trash_note)
|
||||
.item(&empty_trash)
|
||||
.item(&delete_note)
|
||||
.separator()
|
||||
.item(&open_new_window)
|
||||
.separator()
|
||||
@@ -462,11 +452,9 @@ mod tests {
|
||||
VIEW_GO_FORWARD,
|
||||
GO_ALL_NOTES,
|
||||
GO_ARCHIVED,
|
||||
GO_TRASH,
|
||||
GO_CHANGES,
|
||||
NOTE_ARCHIVE,
|
||||
NOTE_TRASH,
|
||||
NOTE_EMPTY_TRASH,
|
||||
NOTE_DELETE,
|
||||
NOTE_OPEN_IN_NEW_WINDOW,
|
||||
VAULT_OPEN,
|
||||
VAULT_REMOVE,
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
use crate::vault;
|
||||
use serde::Serialize;
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
@@ -75,9 +74,6 @@ pub fn search_vault(
|
||||
if !path.extension().is_some_and(|ext| ext == "md") {
|
||||
continue;
|
||||
}
|
||||
if vault::is_file_trashed(path) {
|
||||
continue;
|
||||
}
|
||||
// Skip hidden dirs and .laputa config
|
||||
if path
|
||||
.components()
|
||||
|
||||
@@ -888,18 +888,18 @@ mod tests {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "note.md", "---\nTrashed: false\n---\n# Note\n");
|
||||
create_test_file(vault, "note.md", "---\n_archived: false\n---\n# Note\n");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
// Build cache — note is not trashed
|
||||
// Build cache — note is not archived
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(!entries[0].trashed, "note must not be trashed initially");
|
||||
assert!(!entries[0].archived, "note must not be archived initially");
|
||||
|
||||
// Simulate trashing the note on disk (update frontmatter directly)
|
||||
create_test_file(vault, "note.md", "---\nTrashed: true\n---\n# Note\n");
|
||||
// Simulate archiving the note on disk (update frontmatter directly)
|
||||
create_test_file(vault, "note.md", "---\n_archived: true\n---\n# Note\n");
|
||||
// Stage the change so git sees it
|
||||
git_add_commit(vault, "trash");
|
||||
git_add_commit(vault, "archive");
|
||||
|
||||
// Without invalidation, scan_vault_cached uses incremental update.
|
||||
// With invalidation, it must do a full rescan from disk.
|
||||
@@ -907,8 +907,8 @@ mod tests {
|
||||
let entries2 = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries2.len(), 1);
|
||||
assert!(
|
||||
entries2[0].trashed,
|
||||
"note must be trashed after invalidate + rescan"
|
||||
entries2[0].archived,
|
||||
"note must be archived after invalidate + rescan"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -936,23 +936,6 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Integration test: `Trashed: Yes` (string) through full cached path.
|
||||
#[test]
|
||||
fn test_cached_vault_trashed_yes_string() {
|
||||
let (_lock, _cache_tmp, dir) = setup_git_vault();
|
||||
let vault = dir.path();
|
||||
|
||||
create_test_file(vault, "trashed-note.md", "---\nTrashed: Yes\n---\n# Gone\n");
|
||||
git_add_commit(vault, "init");
|
||||
|
||||
let entries = scan_vault_cached(vault).unwrap();
|
||||
assert_eq!(entries.len(), 1);
|
||||
assert!(
|
||||
entries[0].trashed,
|
||||
"'Trashed: Yes' must be parsed as true through the cached vault path"
|
||||
);
|
||||
}
|
||||
|
||||
/// Integration test: stale cache with old version is invalidated and
|
||||
/// re-parses `Archived: Yes` correctly after cache version bump.
|
||||
#[test]
|
||||
|
||||
@@ -26,9 +26,6 @@ pub struct VaultEntry {
|
||||
pub related_to: Vec<String>,
|
||||
pub status: Option<String>,
|
||||
pub archived: bool,
|
||||
pub trashed: bool,
|
||||
#[serde(rename = "trashedAt")]
|
||||
pub trashed_at: Option<u64>,
|
||||
#[serde(rename = "modifiedAt")]
|
||||
pub modified_at: Option<u64>,
|
||||
#[serde(rename = "createdAt")]
|
||||
|
||||
@@ -19,18 +19,8 @@ pub(crate) struct Frontmatter {
|
||||
deserialize_with = "deserialize_bool_or_string"
|
||||
)]
|
||||
pub archived: Option<bool>,
|
||||
#[serde(
|
||||
rename = "_trashed",
|
||||
alias = "Trashed",
|
||||
alias = "trashed",
|
||||
default,
|
||||
deserialize_with = "deserialize_bool_or_string"
|
||||
)]
|
||||
pub trashed: Option<bool>,
|
||||
#[serde(rename = "Status", alias = "status", default)]
|
||||
pub status: Option<StringOrList>,
|
||||
#[serde(rename = "_trashed_at", alias = "Trashed at", alias = "trashed_at")]
|
||||
pub trashed_at: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
pub icon: Option<StringOrList>,
|
||||
#[serde(default)]
|
||||
@@ -158,12 +148,6 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
"_archived",
|
||||
"Archived",
|
||||
"archived",
|
||||
"_trashed",
|
||||
"Trashed",
|
||||
"trashed",
|
||||
"_trashed_at",
|
||||
"Trashed at",
|
||||
"trashed_at",
|
||||
"icon",
|
||||
"color",
|
||||
"order",
|
||||
@@ -199,11 +183,6 @@ const SKIP_KEYS: &[&str] = &[
|
||||
"aliases",
|
||||
"_archived",
|
||||
"archived",
|
||||
"_trashed",
|
||||
"trashed",
|
||||
"_trashed_at",
|
||||
"trashed at",
|
||||
"trashed_at",
|
||||
"icon",
|
||||
"color",
|
||||
"order",
|
||||
|
||||
@@ -23,7 +23,7 @@ pub use rename::{
|
||||
detect_renames, rename_note, update_wikilinks_for_renames, DetectedRename, RenameResult,
|
||||
};
|
||||
pub use title_sync::{sync_title_on_open, SyncAction};
|
||||
pub use trash::{batch_delete_notes, delete_note, empty_trash, is_file_trashed, purge_trash};
|
||||
pub use trash::{batch_delete_notes, delete_note};
|
||||
pub use views::{
|
||||
delete_view, evaluate_view, save_view, scan_views, FilterCondition, FilterGroup, FilterNode,
|
||||
FilterOp, ViewDefinition, ViewFile,
|
||||
@@ -98,12 +98,6 @@ pub fn parse_md_file(path: &Path, git_dates: Option<(u64, u64)>) -> Result<Vault
|
||||
related_to,
|
||||
status: frontmatter.status.and_then(|v| v.into_scalar()),
|
||||
archived: frontmatter.archived.unwrap_or(false),
|
||||
trashed: frontmatter.trashed.unwrap_or(false),
|
||||
trashed_at: frontmatter
|
||||
.trashed_at
|
||||
.and_then(|v| v.into_scalar())
|
||||
.as_deref()
|
||||
.and_then(parsing::parse_iso_date),
|
||||
modified_at,
|
||||
created_at,
|
||||
file_size,
|
||||
|
||||
@@ -955,30 +955,6 @@ Company: Acme Corp
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_title_case() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nTrashed: true\nTrashed at: \"2025-02-01\"\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone.md", content);
|
||||
assert!(entry.trashed);
|
||||
assert!(entry.trashed_at.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_lowercase_alias() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntrashed: true\ntrashed_at: \"2025-02-01\"\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone.md", content);
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"lowercase 'trashed' must be parsed via alias"
|
||||
);
|
||||
assert!(
|
||||
entry.trashed_at.is_some(),
|
||||
"lowercase 'trashed_at' must be parsed via alias"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_lowercase_alias() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -998,16 +974,7 @@ fn test_parse_archived_titlecase() {
|
||||
assert!(entry.archived, "titlecase 'Archived' must also be parsed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trashed_false_when_absent() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nIs A: Note\n---\n# Active\n";
|
||||
let entry = parse_test_entry(&dir, "active.md", content);
|
||||
assert!(!entry.trashed);
|
||||
assert!(entry.trashed_at.is_none());
|
||||
}
|
||||
|
||||
// --- archived/trashed string-value tests ---
|
||||
// --- archived string-value tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_archived_yes_titlecase() {
|
||||
@@ -1068,44 +1035,8 @@ fn test_parse_archived_absent() {
|
||||
assert!(!entry.archived, "absent archived must default to false");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_yes_titlecase() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nTrashed: Yes\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone2.md", content);
|
||||
assert!(entry.trashed, "'Trashed: Yes' must be parsed as true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_yes_lowercase() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntrashed: yes\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone3.md", content);
|
||||
assert!(entry.trashed, "'trashed: yes' must be parsed as true");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_trashed_no() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\nTrashed: No\n---\n# Active\n";
|
||||
let entry = parse_test_entry(&dir, "active6.md", content);
|
||||
assert!(!entry.trashed, "'Trashed: No' must be parsed as false");
|
||||
}
|
||||
|
||||
// --- new canonical underscore-prefixed keys ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_underscore_trashed_canonical() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\n_trashed: true\n_trashed_at: \"2026-03-15\"\n---\n# Gone\n";
|
||||
let entry = parse_test_entry(&dir, "gone-new.md", content);
|
||||
assert!(entry.trashed, "'_trashed: true' must be parsed as trashed");
|
||||
assert!(
|
||||
entry.trashed_at.is_some(),
|
||||
"'_trashed_at' must be parsed as trashed_at"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_underscore_archived_canonical() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1166,22 +1097,6 @@ fn test_parse_visible_missing_defaults_to_none() {
|
||||
assert_eq!(entry.visible, None);
|
||||
}
|
||||
|
||||
// --- Regression: trashed/archived must survive unquoted date in "Trashed at" ---
|
||||
|
||||
#[test]
|
||||
fn test_trashed_true_with_unquoted_date_in_trashed_at() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Reproduces the engineering-management.md scenario: Trashed at has an
|
||||
// unquoted YAML date (2026-03-11) which gray_matter may parse as a non-string.
|
||||
// The entire Frontmatter deserialization must NOT fail because of this.
|
||||
let content = "---\ntype: Topic\nTrashed: true\n\"Trashed at\": 2026-03-11\n---\n# Engineering Management\n";
|
||||
let entry = parse_test_entry(&dir, "engineering-management.md", content);
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even when 'Trashed at' contains an unquoted date"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_archived_true_with_extra_non_string_fields() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -1194,91 +1109,6 @@ fn test_archived_true_with_extra_non_string_fields() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_trashed_with_reviewed_false_field() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let content = "---\ntype: Topic\nReviewed: False\nTrashed: true\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "reviewed-test.md", content);
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even when frontmatter contains Reviewed: False"
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Topic".to_string()));
|
||||
}
|
||||
|
||||
/// Regression: wikilinks containing curly braces + nested quotes in YAML arrays
|
||||
/// cause gray_matter to produce Hash values instead of strings for array elements.
|
||||
/// This must NOT make parse_frontmatter fall back to default (losing trashed/archived).
|
||||
#[test]
|
||||
fn test_trashed_survives_malformed_wikilinks_in_yaml() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// This YAML has curly braces inside a double-quoted string, producing nested
|
||||
// Hash values in some YAML parsers. The Frontmatter serde must not fail.
|
||||
let content = "---\ntype: Topic\nNotes:\n - \"[[foo|bar]]\"\n - \"[[slug|{'Title': 'Subtitle'}]]\"\nTrashed: true\n---\n# Test\n";
|
||||
let entry = parse_test_entry(&dir, "malformed-links.md", content);
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even with curly-brace wikilinks in frontmatter arrays"
|
||||
);
|
||||
}
|
||||
|
||||
/// Regression: files with malformed YAML (e.g. Notion exports with unescaped quotes
|
||||
/// in wikilinks) cause gray_matter to return Null instead of a Hash. The fallback
|
||||
/// parser must still extract Trashed, type, and other simple key:value fields.
|
||||
#[test]
|
||||
fn test_parse_real_engineering_management_file() {
|
||||
let path = std::path::Path::new("/Users/luca/Laputa/engineering-management.md");
|
||||
if !path.exists() {
|
||||
return; // Skip when the Laputa vault is not available
|
||||
}
|
||||
let entry = parse_md_file(path, None).unwrap();
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"engineering-management.md must be trashed (has Trashed: true in frontmatter)"
|
||||
);
|
||||
assert_eq!(entry.is_a, Some("Topic".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_parser_extracts_trashed_from_malformed_yaml() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Simulate malformed YAML that gray_matter can't parse: unescaped double
|
||||
// quotes inside a double-quoted YAML string cause the YAML parser to fail.
|
||||
// The fallback line-by-line parser must still extract simple key:value pairs.
|
||||
//
|
||||
// Write the file manually with literal unescaped quotes (can't use Rust string
|
||||
// escaping for this since the YAML itself is the malformed part).
|
||||
let fm = [
|
||||
"---",
|
||||
"type: Topic",
|
||||
"Status: Draft",
|
||||
"Belongs to:",
|
||||
" - \"[[engineering|Engineering]]\"",
|
||||
"aliases:",
|
||||
" - Engineering Management",
|
||||
"Notes:",
|
||||
// This line has unescaped " inside a "-quoted YAML string — malformed YAML
|
||||
" - \"[[slug|{\"Title\": 'Subtitle'}]]\"",
|
||||
"Trashed: true",
|
||||
"\"Trashed at\": 2026-03-11",
|
||||
"---",
|
||||
"",
|
||||
"# Engineering Management",
|
||||
];
|
||||
let content = fm.join("\n");
|
||||
create_test_file(dir.path(), "eng-mgmt.md", &content);
|
||||
let entry = parse_md_file(&dir.path().join("eng-mgmt.md"), None).unwrap();
|
||||
assert!(
|
||||
entry.trashed,
|
||||
"Trashed must be true even when YAML is malformed (fallback parser)"
|
||||
);
|
||||
assert_eq!(
|
||||
entry.is_a,
|
||||
Some("Topic".to_string()),
|
||||
"isA must be extracted by fallback parser"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fallback_parser_extracts_archived_from_malformed_yaml() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -285,32 +285,6 @@ pub(super) fn extract_outgoing_links(content: &str) -> Vec<String> {
|
||||
links
|
||||
}
|
||||
|
||||
/// Parse an ISO 8601 date string to Unix timestamp (seconds since epoch).
|
||||
/// Handles "2025-05-23T14:35:00.000Z" and "2025-05-23" formats.
|
||||
pub(super) fn parse_iso_date(date_str: &str) -> Option<u64> {
|
||||
use chrono::{NaiveDate, NaiveDateTime};
|
||||
|
||||
let trimmed = date_str.trim().trim_matches('"');
|
||||
|
||||
// Try full datetime with optional fractional seconds and Z suffix
|
||||
if let Ok(dt) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S%.fZ") {
|
||||
return Some(dt.and_utc().timestamp() as u64);
|
||||
}
|
||||
if let Ok(dt) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%SZ") {
|
||||
return Some(dt.and_utc().timestamp() as u64);
|
||||
}
|
||||
if let Ok(dt) = NaiveDateTime::parse_from_str(trimmed, "%Y-%m-%dT%H:%M:%S") {
|
||||
return Some(dt.and_utc().timestamp() as u64);
|
||||
}
|
||||
|
||||
// Try date-only
|
||||
if let Ok(d) = NaiveDate::parse_from_str(trimmed, "%Y-%m-%d") {
|
||||
return Some(d.and_hms_opt(0, 0, 0)?.and_utc().timestamp() as u64);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -747,50 +721,6 @@ mod tests {
|
||||
assert!(!contains_wikilink("only ]] closing"));
|
||||
}
|
||||
|
||||
// --- parse_iso_date tests ---
|
||||
|
||||
#[test]
|
||||
fn test_parse_iso_date_full_datetime_with_z() {
|
||||
let ts = parse_iso_date("2025-05-23T14:35:00.000Z");
|
||||
assert!(ts.is_some());
|
||||
assert_eq!(ts.unwrap(), 1748010900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_iso_date_datetime_no_fractional() {
|
||||
let ts = parse_iso_date("2025-05-23T14:35:00Z");
|
||||
assert!(ts.is_some());
|
||||
assert_eq!(ts.unwrap(), 1748010900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_iso_date_datetime_no_z() {
|
||||
let ts = parse_iso_date("2025-05-23T14:35:00");
|
||||
assert!(ts.is_some());
|
||||
assert_eq!(ts.unwrap(), 1748010900);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_iso_date_date_only() {
|
||||
let ts = parse_iso_date("2025-05-23");
|
||||
assert!(ts.is_some());
|
||||
assert_eq!(ts.unwrap(), 1747958400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_iso_date_with_quotes_and_whitespace() {
|
||||
let ts = parse_iso_date(" \"2025-05-23\" ");
|
||||
assert!(ts.is_some());
|
||||
assert_eq!(ts.unwrap(), 1747958400);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_iso_date_invalid() {
|
||||
assert!(parse_iso_date("not-a-date").is_none());
|
||||
assert!(parse_iso_date("").is_none());
|
||||
assert!(parse_iso_date("2025-13-45").is_none());
|
||||
}
|
||||
|
||||
// --- extract_outgoing_links tests ---
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,128 +1,5 @@
|
||||
use gray_matter::engine::YAML;
|
||||
use gray_matter::Matter;
|
||||
use std::fs;
|
||||
use std::io::Write as IoWrite;
|
||||
use std::path::{Path, PathBuf};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
/// Check if a file path points to a markdown file.
|
||||
fn is_markdown_file(path: &Path) -> bool {
|
||||
path.is_file() && path.extension().is_some_and(|ext| ext == "md")
|
||||
}
|
||||
|
||||
/// Extract the "Trashed at" date string from parsed gray_matter data.
|
||||
fn extract_trashed_at_string(data: &Option<gray_matter::Pod>) -> Option<String> {
|
||||
let gray_matter::Pod::Hash(ref map) = data.as_ref()? else {
|
||||
return None;
|
||||
};
|
||||
let pod = map
|
||||
.get("_trashed_at")
|
||||
.or_else(|| map.get("Trashed at"))
|
||||
.or_else(|| map.get("trashed_at"))?;
|
||||
match pod {
|
||||
gray_matter::Pod::String(s) => Some(s.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a "Trashed at" date string into a NaiveDate. Supports "2026-01-01" and "2026-01-01T..." formats.
|
||||
fn parse_trashed_date(date_str: &str) -> Option<chrono::NaiveDate> {
|
||||
let trimmed = date_str.trim().trim_matches('"');
|
||||
let date_part = trimmed.split('T').next().unwrap_or(trimmed);
|
||||
chrono::NaiveDate::parse_from_str(date_part, "%Y-%m-%d").ok()
|
||||
}
|
||||
|
||||
/// Check if `_trashed` (or aliases) is set to a truthy value in gray_matter data.
|
||||
fn is_trashed_flag_set(data: &Option<gray_matter::Pod>) -> bool {
|
||||
let gray_matter::Pod::Hash(ref map) = data.as_ref().unwrap_or(&gray_matter::Pod::Null) else {
|
||||
return false;
|
||||
};
|
||||
let Some(pod) = map
|
||||
.get("_trashed")
|
||||
.or_else(|| map.get("Trashed"))
|
||||
.or_else(|| map.get("trashed"))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
match pod {
|
||||
gray_matter::Pod::Boolean(b) => *b,
|
||||
gray_matter::Pod::String(s) => {
|
||||
matches!(s.to_ascii_lowercase().as_str(), "yes" | "true" | "1")
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check whether a file path is strictly inside the given vault root.
|
||||
fn is_inside_vault(file_path: &Path, vault_root: &Path) -> bool {
|
||||
match (file_path.canonicalize(), vault_root.canonicalize()) {
|
||||
(Ok(fp), Ok(vr)) => fp.starts_with(&vr),
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Move a file to OS trash. Falls back to fs::remove_file if OS trash fails.
|
||||
fn trash_or_remove(path: &Path) -> Result<(), String> {
|
||||
match trash::delete(path) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(trash_err) => {
|
||||
log::warn!(
|
||||
"OS trash failed for {}: {} — falling back to fs::remove_file",
|
||||
path.display(),
|
||||
trash_err
|
||||
);
|
||||
fs::remove_file(path).map_err(|e| format!("Failed to delete {}: {}", path.display(), e))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete a file (move to OS trash) and log the result. Returns the path string if successful.
|
||||
fn try_purge_file(path: &Path) -> Option<String> {
|
||||
match trash_or_remove(path) {
|
||||
Ok(()) => {
|
||||
log::info!("Purged trashed file: {}", path.display());
|
||||
Some(path.to_string_lossy().to_string())
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to purge {}: {}", path.display(), e);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Append a purge run summary to `.laputa/purge.log`.
|
||||
fn write_purge_log(vault_path: &Path, checked: usize, purged: &[String], dry_run: bool) {
|
||||
let log_dir = vault_path.join(".laputa");
|
||||
if fs::create_dir_all(&log_dir).is_err() {
|
||||
log::warn!("Could not create .laputa/ directory for purge log");
|
||||
return;
|
||||
}
|
||||
let log_path = log_dir.join("purge.log");
|
||||
let mut file = match fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
log::warn!("Could not open purge.log: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let now = chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ");
|
||||
let mode = if dry_run { " [DRY-RUN]" } else { "" };
|
||||
let _ = writeln!(
|
||||
file,
|
||||
"[{}]{} checked={}, purged={}",
|
||||
now,
|
||||
mode,
|
||||
checked,
|
||||
purged.len()
|
||||
);
|
||||
for path in purged {
|
||||
let _ = writeln!(file, " - {}", path);
|
||||
}
|
||||
}
|
||||
use std::path::Path;
|
||||
|
||||
/// Permanently delete a single note file.
|
||||
/// Returns the deleted path on success, or an error if the file doesn't exist.
|
||||
@@ -139,41 +16,6 @@ pub fn delete_note(path: &str) -> Result<String, String> {
|
||||
Ok(path.to_string())
|
||||
}
|
||||
|
||||
/// Check whether a file's frontmatter marks it as trashed.
|
||||
/// Returns `true` if `Trashed: true` or `Trashed at` is present.
|
||||
pub fn is_file_trashed(path: &Path) -> bool {
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => return false,
|
||||
};
|
||||
let matter = Matter::<YAML>::new();
|
||||
let parsed = matter.parse(&content);
|
||||
|
||||
// Check for "Trashed at" field — its presence implies trashed
|
||||
if extract_trashed_at_string(&parsed.data).is_some() {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for "Trashed: true"
|
||||
if let Some(gray_matter::Pod::Hash(ref map)) = parsed.data {
|
||||
if let Some(pod) = map
|
||||
.get("_trashed")
|
||||
.or_else(|| map.get("Trashed"))
|
||||
.or_else(|| map.get("trashed"))
|
||||
{
|
||||
return match pod {
|
||||
gray_matter::Pod::Boolean(b) => *b,
|
||||
gray_matter::Pod::String(s) => {
|
||||
matches!(s.to_ascii_lowercase().as_str(), "yes" | "true")
|
||||
}
|
||||
_ => false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
/// Delete multiple note files from disk.
|
||||
/// Returns the list of successfully deleted paths.
|
||||
/// Skips files that don't exist or fail to delete (logs warnings).
|
||||
@@ -181,165 +23,23 @@ pub fn batch_delete_notes(paths: &[String]) -> Result<Vec<String>, String> {
|
||||
let mut deleted = Vec::new();
|
||||
for path in paths {
|
||||
let file = Path::new(path.as_str());
|
||||
match try_purge_file(file) {
|
||||
Some(p) => deleted.push(p),
|
||||
None if !file.exists() => {
|
||||
log::warn!("File does not exist, skipping: {}", path);
|
||||
if !file.exists() {
|
||||
log::warn!("File does not exist, skipping: {}", path);
|
||||
continue;
|
||||
}
|
||||
match fs::remove_file(file) {
|
||||
Ok(()) => {
|
||||
log::info!("Permanently deleted note: {}", path);
|
||||
deleted.push(path.clone());
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("Failed to delete {}: {}", path, e);
|
||||
}
|
||||
None => {} // try_purge_file already logged the warning
|
||||
}
|
||||
}
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// Scan all markdown files in the vault and delete ALL trashed notes
|
||||
/// (regardless of age). Returns the list of deleted file paths.
|
||||
pub fn empty_trash(vault_path: &str) -> Result<Vec<String>, String> {
|
||||
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 mut deleted = Vec::new();
|
||||
for entry in WalkDir::new(vault)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| is_markdown_file(e.path()))
|
||||
.filter(|e| is_file_trashed(e.path()))
|
||||
{
|
||||
if let Some(p) = try_purge_file(entry.path()) {
|
||||
deleted.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
/// Scan all markdown files in the vault and delete those where
|
||||
/// `_trashed_at` frontmatter is more than 30 days ago.
|
||||
///
|
||||
/// Safety checks enforced per file (all must pass):
|
||||
/// 1. `_trashed: true` present in frontmatter
|
||||
/// 2. `_trashed_at` present and parseable as a date
|
||||
/// 3. Date is strictly more than 30 days ago
|
||||
/// 4. File exists on disk
|
||||
/// 5. File path is inside the vault root
|
||||
///
|
||||
/// When `dry_run` is true, no files are deleted — only the list of candidates is returned.
|
||||
/// Returns the list of purged (or would-be-purged) file paths.
|
||||
pub fn purge_old_trash(vault_path: &Path, dry_run: bool) -> Result<Vec<PathBuf>, String> {
|
||||
if !vault_path.exists() || !vault_path.is_dir() {
|
||||
return Err(format!(
|
||||
"Vault path does not exist or is not a directory: {}",
|
||||
vault_path.display()
|
||||
));
|
||||
}
|
||||
|
||||
let today = chrono::Utc::now().date_naive();
|
||||
let matter = Matter::<YAML>::new();
|
||||
let max_age_days = 30;
|
||||
let mut checked = 0usize;
|
||||
let mut purged: Vec<String> = Vec::new();
|
||||
let mut purged_paths: Vec<PathBuf> = Vec::new();
|
||||
|
||||
for entry in WalkDir::new(vault_path)
|
||||
.follow_links(true)
|
||||
.into_iter()
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| is_markdown_file(e.path()))
|
||||
{
|
||||
let path = entry.path();
|
||||
|
||||
// Safety check 4: file exists
|
||||
if !path.exists() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Safety check 5: file is inside vault root
|
||||
if !is_inside_vault(path, vault_path) {
|
||||
log::warn!("Skipping file outside vault: {}", path.display());
|
||||
continue;
|
||||
}
|
||||
|
||||
let content = match fs::read_to_string(path) {
|
||||
Ok(c) => c,
|
||||
Err(_) => continue,
|
||||
};
|
||||
let parsed = matter.parse(&content);
|
||||
|
||||
// Safety check 1: _trashed: true
|
||||
if !is_trashed_flag_set(&parsed.data) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Safety check 2: _trashed_at present and parseable
|
||||
let date_str = match extract_trashed_at_string(&parsed.data) {
|
||||
Some(s) => s,
|
||||
None => {
|
||||
log::warn!(
|
||||
"Trashed file missing _trashed_at, skipping: {}",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let trashed_date = match parse_trashed_date(&date_str) {
|
||||
Some(d) => d,
|
||||
None => {
|
||||
log::warn!(
|
||||
"Unparseable _trashed_at '{}', skipping: {}",
|
||||
date_str,
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Safety check 3: strictly more than 30 days old
|
||||
let age = today.signed_duration_since(trashed_date);
|
||||
if age.num_days() <= max_age_days {
|
||||
continue;
|
||||
}
|
||||
|
||||
checked += 1;
|
||||
|
||||
if dry_run {
|
||||
log::info!(
|
||||
"[DRY-RUN] Would purge: {} (trashed {} days ago)",
|
||||
path.display(),
|
||||
age.num_days()
|
||||
);
|
||||
purged.push(path.to_string_lossy().to_string());
|
||||
purged_paths.push(path.to_path_buf());
|
||||
} else if let Some(p) = try_purge_file(path) {
|
||||
purged.push(p);
|
||||
purged_paths.push(path.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
write_purge_log(vault_path, checked, &purged, dry_run);
|
||||
|
||||
Ok(purged_paths)
|
||||
}
|
||||
|
||||
/// Legacy wrapper that calls purge_old_trash with dry_run=false and returns string paths.
|
||||
/// Used by the Tauri command and startup tasks.
|
||||
pub fn purge_trash(vault_path: &str) -> Result<Vec<String>, String> {
|
||||
let vault = Path::new(vault_path);
|
||||
purge_old_trash(vault, false)
|
||||
.map(|paths| {
|
||||
paths
|
||||
.into_iter()
|
||||
.map(|p| p.to_string_lossy().to_string())
|
||||
.collect()
|
||||
})
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -377,285 +77,6 @@ mod tests {
|
||||
assert!(result.unwrap_err().contains("does not exist"));
|
||||
}
|
||||
|
||||
fn old_date(days_ago: i64) -> String {
|
||||
(chrono::Utc::now().date_naive() - chrono::Duration::days(days_ago))
|
||||
.format("%Y-%m-%d")
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn trashed_content(date: &str) -> String {
|
||||
format!(
|
||||
"---\n_trashed: true\n_trashed_at: \"{}\"\n---\n# Trashed\n",
|
||||
date
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_deletes_old_trashed_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "old.md", &trashed_content("2025-01-01"));
|
||||
create_test_file(dir.path(), "recent.md", &trashed_content(&old_date(5)));
|
||||
create_test_file(dir.path(), "normal.md", "---\ntype: Note\n---\n# Normal\n");
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert_eq!(purged.len(), 1);
|
||||
assert!(!dir.path().join("old.md").exists());
|
||||
assert!(dir.path().join("recent.md").exists());
|
||||
assert!(dir.path().join("normal.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_supports_datetime_format() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"dt.md",
|
||||
"---\n_trashed: true\n_trashed_at: \"2025-01-01T10:30:00Z\"\n---\n# DT\n",
|
||||
);
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert_eq!(purged.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_empty_vault() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert!(purged.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_nonexistent_path() {
|
||||
let result = purge_old_trash(Path::new("/nonexistent/path"), false);
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_exactly_30_days_not_deleted() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "borderline.md", &trashed_content(&old_date(30)));
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert!(purged.is_empty());
|
||||
assert!(dir.path().join("borderline.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_31_days_deleted() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "expired.md", &trashed_content(&old_date(31)));
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert_eq!(purged.len(), 1);
|
||||
assert!(!dir.path().join("expired.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_nested_directories() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"sub/deep/old.md",
|
||||
&trashed_content("2025-01-01"),
|
||||
);
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert_eq!(purged.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_dry_run_does_not_delete() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "old.md", &trashed_content("2025-01-01"));
|
||||
|
||||
let purged = purge_old_trash(dir.path(), true).unwrap();
|
||||
assert_eq!(purged.len(), 1);
|
||||
assert!(
|
||||
dir.path().join("old.md").exists(),
|
||||
"dry-run must not delete files"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_missing_trashed_flag_skips() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Has _trashed_at but no _trashed: true
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"no-flag.md",
|
||||
"---\n_trashed_at: \"2025-01-01\"\n---\n# No flag\n",
|
||||
);
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert!(purged.is_empty());
|
||||
assert!(dir.path().join("no-flag.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_missing_trashed_at_skips() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Has _trashed: true but no _trashed_at
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"no-date.md",
|
||||
"---\n_trashed: true\n---\n# No date\n",
|
||||
);
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert!(purged.is_empty());
|
||||
assert!(dir.path().join("no-date.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_unparseable_date_skips() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"bad-date.md",
|
||||
"---\n_trashed: true\n_trashed_at: \"not-a-date\"\n---\n# Bad date\n",
|
||||
);
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert!(purged.is_empty());
|
||||
assert!(dir.path().join("bad-date.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_trashed_false_skips() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"not-trashed.md",
|
||||
"---\n_trashed: false\n_trashed_at: \"2025-01-01\"\n---\n# Not trashed\n",
|
||||
);
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert!(purged.is_empty());
|
||||
assert!(dir.path().join("not-trashed.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_legacy_field_names() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"legacy.md",
|
||||
"---\nTrashed: true\nTrashed at: \"2025-01-01\"\n---\n# Legacy\n",
|
||||
);
|
||||
|
||||
let purged = purge_old_trash(dir.path(), false).unwrap();
|
||||
assert_eq!(purged.len(), 1);
|
||||
assert!(!dir.path().join("legacy.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_writes_purge_log() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "old.md", &trashed_content("2025-01-01"));
|
||||
|
||||
let _ = purge_old_trash(dir.path(), false).unwrap();
|
||||
let log_path = dir.path().join(".laputa/purge.log");
|
||||
assert!(log_path.exists(), "purge.log must be created");
|
||||
let log_content = fs::read_to_string(&log_path).unwrap();
|
||||
assert!(log_content.contains("purged=1"));
|
||||
assert!(log_content.contains("old.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_purge_old_trash_wrapper_returns_strings() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "old.md", &trashed_content("2025-01-01"));
|
||||
|
||||
let deleted = purge_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert!(deleted[0].contains("old.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_true() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"trashed.md",
|
||||
"---\nTrashed: true\n---\n# Gone\n",
|
||||
);
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_at() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"trashed.md",
|
||||
"---\nTrashed at: \"2026-01-01\"\n---\n# Gone\n",
|
||||
);
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_yes() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "trashed.md", "---\nTrashed: Yes\n---\n# Gone\n");
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_normal_note() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(dir.path(), "normal.md", "---\ntype: Note\n---\n# Normal\n");
|
||||
assert!(!is_file_trashed(&dir.path().join("normal.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_archived_not_trashed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"archived.md",
|
||||
"---\nArchived: true\n---\n# Archived\n",
|
||||
);
|
||||
assert!(!is_file_trashed(&dir.path().join("archived.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_nonexistent_file() {
|
||||
assert!(!is_file_trashed(Path::new("/nonexistent/path.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_underscore_trashed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"trashed.md",
|
||||
"---\n_trashed: true\n---\n# Gone\n",
|
||||
);
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_underscore_trashed_at() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"trashed.md",
|
||||
"---\n_trashed_at: \"2026-03-15\"\n---\n# Gone\n",
|
||||
);
|
||||
assert!(is_file_trashed(&dir.path().join("trashed.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_file_trashed_with_trashed_false() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"active.md",
|
||||
"---\nTrashed: false\n---\n# Active\n",
|
||||
);
|
||||
assert!(!is_file_trashed(&dir.path().join("active.md")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_batch_delete_notes_removes_files() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
@@ -687,49 +108,4 @@ mod tests {
|
||||
assert_eq!(deleted.len(), 1);
|
||||
assert!(!dir.path().join("exists.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_trash_deletes_all_trashed() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
// Recently trashed — should be deleted
|
||||
let recent = chrono::Utc::now()
|
||||
.date_naive()
|
||||
.format("%Y-%m-%d")
|
||||
.to_string();
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"recent.md",
|
||||
&format!(
|
||||
"---\nTrashed: true\nTrashed at: \"{}\"\n---\n# Recent\n",
|
||||
recent
|
||||
),
|
||||
);
|
||||
// Old trashed — should be deleted
|
||||
create_test_file(
|
||||
dir.path(),
|
||||
"old.md",
|
||||
"---\nTrashed: true\nTrashed at: \"2025-01-01\"\n---\n# Old\n",
|
||||
);
|
||||
// Not trashed — should be kept
|
||||
create_test_file(dir.path(), "normal.md", "---\ntype: Note\n---\n# Normal\n");
|
||||
|
||||
let deleted = empty_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert_eq!(deleted.len(), 2);
|
||||
assert!(!dir.path().join("recent.md").exists());
|
||||
assert!(!dir.path().join("old.md").exists());
|
||||
assert!(dir.path().join("normal.md").exists());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_trash_empty_vault() {
|
||||
let dir = TempDir::new().unwrap();
|
||||
let deleted = empty_trash(dir.path().to_str().unwrap()).unwrap();
|
||||
assert!(deleted.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_empty_trash_nonexistent_path() {
|
||||
let result = empty_trash("/nonexistent/path/that/does/not/exist");
|
||||
assert!(result.is_err());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,7 +296,6 @@ fn evaluate_condition(cond: &FilterCondition, entry: &VaultEntry) -> bool {
|
||||
// Boolean fields
|
||||
match field {
|
||||
"archived" => return evaluate_bool_field(entry.archived, &cond.op, &cond.value),
|
||||
"trashed" => return evaluate_bool_field(entry.trashed, &cond.op, &cond.value),
|
||||
"favorite" => return evaluate_bool_field(entry.favorite, &cond.op, &cond.value),
|
||||
_ => {}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user