From 15c6e6388a63491e04731b609fb70d2010451df9 Mon Sep 17 00:00:00 2001 From: Yuri Karamian Date: Thu, 11 Jun 2026 23:16:59 +0200 Subject: [PATCH] Attachments: manual 'Find orphaned attachments' tool in the Info panel - scans all notes incl. trash, previews unreferenced files with sizes, moves to vault trash (recoverable); conservative raw+decoded matching, re-verified before moving, with a loading spinner (#52) --- src-tauri/src/commands.rs | 109 ++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 2 + src/lib/api.ts | 13 ++++ src/lib/components/InfoPanel.svelte | 109 +++++++++++++++++++++++++++- 4 files changed, 232 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 3e6d005..5121be6 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1658,6 +1658,115 @@ fn pathdiff(target: &str, base: &str) -> Result { Ok(result.to_string_lossy().to_string()) } +// ── Orphaned attachment cleanup ── + +#[derive(serde::Serialize)] +pub struct OrphanAttachment { + pub name: String, + pub size: u64, +} + +// Conservatively find files in .helixnotes/attachments not referenced by ANY note. Scans every +// .md in the vault (including .helixnotes/trash, so a restorable trashed note keeps its files) +// and matches each filename against both the raw note text and a percent-decoded copy (so a +// URL-encoded path like `my%20file.png` still counts as a reference). When in doubt a file is +// KEPT: a leftover orphan is harmless, a wrong deletion is not. +fn scan_orphaned_attachments(vault: &str) -> Result, String> { + let attachments_dir = operations::helixnotes_dir(vault).join("attachments"); + if !attachments_dir.is_dir() { + return Ok(Vec::new()); + } + let mut files: Vec<(String, u64)> = Vec::new(); + for entry in std::fs::read_dir(&attachments_dir).map_err(|e| e.to_string())? { + let entry = entry.map_err(|e| e.to_string())?; + let p = entry.path(); + if p.is_file() { + let name = p.file_name().unwrap_or_default().to_string_lossy().to_string(); + let size = entry.metadata().map(|m| m.len()).unwrap_or(0); + files.push((name, size)); + } + } + if files.is_empty() { + return Ok(Vec::new()); + } + let mut haystack = String::new(); + for entry in walkdir::WalkDir::new(vault).into_iter().filter_map(|e| e.ok()) { + let p = entry.path(); + if p.is_file() && p.extension().and_then(|x| x.to_str()) == Some("md") { + if let Ok(content) = std::fs::read_to_string(p) { + haystack.push_str(&content); + haystack.push('\n'); + } + } + } + let decoded = percent_decode(&haystack); + let orphans = files + .into_iter() + .filter(|(name, _)| !haystack.contains(name.as_str()) && !decoded.contains(name.as_str())) + .collect(); + Ok(orphans) +} + +#[tauri::command] +pub fn find_orphaned_attachments( + state: State<'_, AppState>, +) -> Result, String> { + let vault = { + let config = state.config.lock().map_err(|e| e.to_string())?; + config.active_vault.clone().ok_or("No active vault")? + }; + let orphans = scan_orphaned_attachments(&vault)?; + Ok(orphans + .into_iter() + .map(|(name, size)| OrphanAttachment { name, size }) + .collect()) +} + +#[tauri::command] +pub fn trash_orphaned_attachments( + state: State<'_, AppState>, + names: Vec, +) -> Result { + let vault = { + let config = state.config.lock().map_err(|e| e.to_string())?; + config.active_vault.clone().ok_or("No active vault")? + }; + // Re-scan now and only move files that are STILL orphaned (guards against a reference added + // between scan and confirm), intersected with the caller's selection. + let current: std::collections::HashSet = scan_orphaned_attachments(&vault)? + .into_iter() + .map(|(n, _)| n) + .collect(); + let attachments_dir = operations::helixnotes_dir(&vault).join("attachments"); + let trash_dir = operations::helixnotes_dir(&vault).join("trash"); + std::fs::create_dir_all(&trash_dir).map_err(|e| e.to_string())?; + let stamp = chrono::Utc::now().format("%Y%m%d%H%M%S%3f").to_string(); + let mut moved = 0u32; + for (i, name) in names.iter().enumerate() { + if !current.contains(name) { + continue; + } + // Path-traversal guard: only act on a bare filename inside the attachments dir. + if std::path::Path::new(name) + .file_name() + .map(|f| f.to_string_lossy().to_string()) + .as_deref() + != Some(name.as_str()) + { + continue; + } + let src = attachments_dir.join(name); + if !src.is_file() { + continue; + } + let dest = trash_dir.join(format!("{}_{}_attachment_{}", stamp, i, name)); + if std::fs::rename(&src, &dest).is_ok() { + moved += 1; + } + } + Ok(moved) +} + fn percent_decode(s: &str) -> String { let mut result = String::with_capacity(s.len()); let mut chars = s.bytes(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 7730bfb..72afb66 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -148,6 +148,8 @@ pub fn run() { commands::remove_quick_access, commands::reorder_quick_access, commands::get_vault_stats, + commands::find_orphaned_attachments, + commands::trash_orphaned_attachments, commands::import_obsidian, commands::open_file, commands::open_url, diff --git a/src/lib/api.ts b/src/lib/api.ts index d6fed5e..6ec5d59 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -276,6 +276,19 @@ export async function getVaultStats(): Promise { return invoke("get_vault_stats"); } +export interface OrphanAttachment { + name: string; + size: number; +} + +export async function findOrphanedAttachments(): Promise { + return invoke("find_orphaned_attachments"); +} + +export async function trashOrphanedAttachments(names: string[]): Promise { + return invoke("trash_orphaned_attachments", { names }); +} + export async function importObsidian(): Promise { return invoke("import_obsidian"); } diff --git a/src/lib/components/InfoPanel.svelte b/src/lib/components/InfoPanel.svelte index 0462d72..6c3e16b 100644 --- a/src/lib/components/InfoPanel.svelte +++ b/src/lib/components/InfoPanel.svelte @@ -1,6 +1,7 @@ {#if $showInfo} @@ -103,6 +126,25 @@ {/if} + {#if stats} +
+ {#if orphans === null} + + {:else if orphans.length === 0} +

No orphaned attachments. Nothing to clean up.

+ {:else} +

{orphans.length} {orphans.length === 1 ? 'attachment is' : 'attachments are'} not referenced by any note ({formatSize(orphanTotal)}).

+
+ {#each orphans as o} +
{o.name}{formatSize(o.size)}
+ {/each} +
+ +

Moved to the vault's trash folder (recoverable), not permanently deleted.

+ {/if} +
+ {/if} +

Created by Yuri Karamian