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)

This commit is contained in:
Yuri Karamian
2026-06-11 23:16:59 +02:00
parent b501d2bd5c
commit 15c6e6388a
4 changed files with 232 additions and 1 deletions
+109
View File
@@ -1658,6 +1658,115 @@ fn pathdiff(target: &str, base: &str) -> Result<String, ()> {
Ok(result.to_string_lossy().to_string()) 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<Vec<(String, u64)>, 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<Vec<OrphanAttachment>, 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<String>,
) -> Result<u32, String> {
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<String> = 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 { fn percent_decode(s: &str) -> String {
let mut result = String::with_capacity(s.len()); let mut result = String::with_capacity(s.len());
let mut chars = s.bytes(); let mut chars = s.bytes();
+2
View File
@@ -148,6 +148,8 @@ pub fn run() {
commands::remove_quick_access, commands::remove_quick_access,
commands::reorder_quick_access, commands::reorder_quick_access,
commands::get_vault_stats, commands::get_vault_stats,
commands::find_orphaned_attachments,
commands::trash_orphaned_attachments,
commands::import_obsidian, commands::import_obsidian,
commands::open_file, commands::open_file,
commands::open_url, commands::open_url,
+13
View File
@@ -276,6 +276,19 @@ export async function getVaultStats(): Promise<VaultStats> {
return invoke("get_vault_stats"); return invoke("get_vault_stats");
} }
export interface OrphanAttachment {
name: string;
size: number;
}
export async function findOrphanedAttachments(): Promise<OrphanAttachment[]> {
return invoke("find_orphaned_attachments");
}
export async function trashOrphanedAttachments(names: string[]): Promise<number> {
return invoke("trash_orphaned_attachments", { names });
}
export async function importObsidian(): Promise<void> { export async function importObsidian(): Promise<void> {
return invoke("import_obsidian"); return invoke("import_obsidian");
} }
+108 -1
View File
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { showInfo, appConfig } from '$lib/stores/app'; import { showInfo, appConfig } from '$lib/stores/app';
import { getVaultStats } from '$lib/api'; import { getVaultStats, findOrphanedAttachments, trashOrphanedAttachments } from '$lib/api';
import type { OrphanAttachment } from '$lib/api';
import { openUrl } from '$lib/api'; import { openUrl } from '$lib/api';
import { getVersion } from '@tauri-apps/api/app'; import { getVersion } from '@tauri-apps/api/app';
import type { VaultStats } from '$lib/types'; import type { VaultStats } from '$lib/types';
@@ -11,6 +12,10 @@
let stats = $state<VaultStats | null>(null); let stats = $state<VaultStats | null>(null);
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts'); let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
let appVersion = $state('...'); let appVersion = $state('...');
let orphans = $state<OrphanAttachment[] | null>(null);
let scanning = $state(false);
let trashing = $state(false);
const orphanTotal = $derived((orphans ?? []).reduce((a, o) => a + o.size, 0));
getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0'); getVersion().then(v => appVersion = v).catch(() => appVersion = '0.0.0');
@@ -35,8 +40,26 @@
getVaultStats().then((s) => { stats = s; }).catch(console.error); getVaultStats().then((s) => { stats = s; }).catch(console.error);
} else { } else {
stats = null; stats = null;
orphans = null;
} }
}); });
async function scanOrphans() {
scanning = true;
try { orphans = await findOrphanedAttachments(); }
catch (e) { console.error(e); }
finally { scanning = false; }
}
async function trashOrphans() {
if (!orphans || orphans.length === 0) return;
trashing = true;
try {
await trashOrphanedAttachments(orphans.map((o) => o.name));
orphans = null;
getVaultStats().then((sv) => { stats = sv; }).catch(console.error);
} catch (e) { console.error(e); }
finally { trashing = false; }
}
</script> </script>
{#if $showInfo} {#if $showInfo}
@@ -103,6 +126,25 @@
</div> </div>
{/if} {/if}
{#if stats}
<div class="info-cleanup">
{#if orphans === null}
<button class="cleanup-btn" onclick={scanOrphans} disabled={scanning}>{#if scanning}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Scanning…{:else}Find orphaned attachments{/if}</button>
{:else if orphans.length === 0}
<p class="cleanup-msg">No orphaned attachments. Nothing to clean up.</p>
{:else}
<p class="cleanup-msg">{orphans.length} {orphans.length === 1 ? 'attachment is' : 'attachments are'} not referenced by any note ({formatSize(orphanTotal)}).</p>
<div class="cleanup-list">
{#each orphans as o}
<div class="cleanup-row"><span class="cleanup-name" title={o.name}>{o.name}</span><span class="cleanup-size">{formatSize(o.size)}</span></div>
{/each}
</div>
<button class="cleanup-btn" onclick={trashOrphans} disabled={trashing}>{#if trashing}<svg class="spinner-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10" opacity="0.25" /><path d="M12 2a10 10 0 019.95 9" /></svg>Moving…{:else}Move {orphans.length} to Trash{/if}</button>
<p class="cleanup-hint">Moved to the vault's trash folder (recoverable), not permanently deleted.</p>
{/if}
</div>
{/if}
<div class="info-credits"> <div class="info-credits">
<p>Created by <strong>Yuri Karamian</strong></p> <p>Created by <strong>Yuri Karamian</strong></p>
<button class="info-link" onclick={() => openLink('https://helixnotes.com')}> <button class="info-link" onclick={() => openLink('https://helixnotes.com')}>
@@ -350,6 +392,71 @@
.info-link:hover { .info-link:hover {
background: var(--accent-light); background: var(--accent-light);
} }
.info-cleanup {
width: 100%;
margin-top: 16px;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.cleanup-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 8px;
color: var(--text-primary);
font-size: 13px;
padding: 7px 14px;
cursor: pointer;
}
.cleanup-btn:hover:not(:disabled) { background: var(--bg-hover); }
.cleanup-btn:disabled { opacity: 0.6; cursor: default; }
.cleanup-msg {
font-size: 13px;
color: var(--text-secondary);
text-align: center;
}
.cleanup-list {
width: 100%;
max-height: 140px;
overflow-y: auto;
background: var(--bg-secondary);
border: 1px solid var(--border-light);
border-radius: 8px;
}
.cleanup-row {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 5px 12px;
font-size: 12px;
}
.cleanup-name {
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cleanup-size {
color: var(--text-tertiary);
flex-shrink: 0;
}
.cleanup-hint {
font-size: 11px;
color: var(--text-tertiary);
text-align: center;
}
.spinner-icon {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
.shortcuts-section { .shortcuts-section {
width: 100%; width: 100%;