mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-23 23:35:57 +02:00
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:
@@ -1658,6 +1658,115 @@ fn pathdiff(target: &str, base: &str) -> Result<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 {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut chars = s.bytes();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -276,6 +276,19 @@ export async function getVaultStats(): Promise<VaultStats> {
|
||||
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> {
|
||||
return invoke("import_obsidian");
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
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 { getVersion } from '@tauri-apps/api/app';
|
||||
import type { VaultStats } from '$lib/types';
|
||||
@@ -11,6 +12,10 @@
|
||||
let stats = $state<VaultStats | null>(null);
|
||||
let activeTab = $state<'about' | 'shortcuts'>(isMobile ? 'about' : 'shortcuts');
|
||||
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');
|
||||
|
||||
@@ -35,8 +40,26 @@
|
||||
getVaultStats().then((s) => { stats = s; }).catch(console.error);
|
||||
} else {
|
||||
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>
|
||||
|
||||
{#if $showInfo}
|
||||
@@ -103,6 +126,25 @@
|
||||
</div>
|
||||
{/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">
|
||||
<p>Created by <strong>Yuri Karamian</strong></p>
|
||||
<button class="info-link" onclick={() => openLink('https://helixnotes.com')}>
|
||||
@@ -350,6 +392,71 @@
|
||||
.info-link:hover {
|
||||
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 {
|
||||
width: 100%;
|
||||
|
||||
Reference in New Issue
Block a user