Find Unused Attachments
find_unused_attachmentsFind attachments in an Obsidian vault that are not referenced by any note, aiding vault cleanup before archiving or sync.
Instructions
Locate attachments that no note references — neither via ![[file]] embeds nor [text](file) markdown links. Useful for vault hygiene before archiving or before running a sync. Pair the output with delete operations from your shell, since this tool deliberately doesn't unlink files.
Input Schema
| Name | Required | Description | Default |
|---|---|---|---|
| limit | No | Maximum number of unused-attachment paths to return (1-10000, default: 200). Total counts are still reported. | |
| includeBytes | No | If true, also stat each unused attachment and report total reclaimable bytes. |
Implementation Reference
- src/tools/attachments.ts:194-271 (handler)The main handler function for the 'find_unused_attachments' tool. It lists all attachments and notes, scans each note for references (wikilink embeds and markdown links), then returns the set of attachments that are not referenced by any note. Supports optional includeBytes to report reclaimable storage.
async ({ limit, includeBytes }, extra) => { try { const reportProgress = makeProgressReporter(extra); const attachments = await listAttachments(vaultPath); if (attachments.length === 0) { return textResult("No attachments in this vault — nothing to check."); } const attachmentSet = new Set(attachments); const basenameIndex = new Map<string, string[]>(); for (const p of attachments) { const base = path.basename(p).toLowerCase(); const list = basenameIndex.get(base); if (list) list.push(p); else basenameIndex.set(base, [p]); } const notes = await listNotes(vaultPath); await reportProgress(0, notes.length, "Reading notes…"); const { contents } = await readAllCached(vaultPath, notes, (note, err) => { log.warn("find_unused_attachments: note read failed", { note, err }); }); const referenced = new Set<string>(); let scanned = 0; for (const notePath of notes) { const content = contents.get(notePath); if (content !== undefined) { const { resolved } = collectReferencedAttachments(content, attachmentSet, basenameIndex); for (const r of resolved) referenced.add(r); } scanned++; await reportProgress(scanned, notes.length, `Scanned ${scanned}/${notes.length} notes`); } const unused = attachments.filter((p) => !referenced.has(p)); if (unused.length === 0) { return textResult( `All ${attachments.length} attachment(s) are referenced — nothing to clean up.`, ); } const truncated = unused.slice(0, limit); const lines: string[] = [ `Found ${unused.length} unused attachment(s) of ${attachments.length} total${unused.length > limit ? ` (showing first ${limit})` : ""}:`, "", ]; if (includeBytes) { let totalBytes = 0; const sizes = new Map<string, number>(); for (const p of truncated) { try { const stat = await getAttachmentStats(vaultPath, p); sizes.set(p, stat.size); totalBytes += stat.size; } catch { // skip — file may have been removed mid-scan } } lines.push(`Total reclaimable: ${totalBytes.toLocaleString()} bytes`); lines.push(""); for (const p of truncated) { const sz = sizes.get(p); lines.push(sz !== undefined ? `- ${p} (${sz.toLocaleString()} bytes)` : `- ${p}`); } } else { for (const p of truncated) lines.push(`- ${p}`); } return textResult(lines.join("\n")); } catch (err) { log.error("find_unused_attachments failed", { tool: "find_unused_attachments", err: err as Error, }); return errorResult(`Error finding unused attachments: ${sanitizeError(err)}`); } }, - src/tools/attachments.ts:178-192 (schema)Input schema for 'find_unused_attachments': limit (1-10000, default 200) for max paths returned, and includeBytes (boolean, default false) to also stat files and report total reclaimable bytes.
inputSchema: { limit: z .number() .int() .min(1) .max(10000) .optional() .default(200) .describe("Maximum number of unused-attachment paths to return (1-10000, default: 200). Total counts are still reported."), includeBytes: z .boolean() .optional() .default(false) .describe("If true, also stat each unused attachment and report total reclaimable bytes."), }, - src/tools/attachments.ts:167-272 (registration)Registration of the 'find_unused_attachments' tool via server.registerTool inside the registerAttachmentTools function, which is exported and called from src/index.ts.
server.registerTool( "find_unused_attachments", { title: "Find Unused Attachments", description: "Locate attachments that no note references — neither via `![[file]]` embeds nor `[text](file)` markdown links. Useful for vault hygiene before archiving or before running a sync. Pair the output with `delete` operations from your shell, since this tool deliberately doesn't unlink files.", annotations: { readOnlyHint: true, idempotentHint: true, openWorldHint: false, }, inputSchema: { limit: z .number() .int() .min(1) .max(10000) .optional() .default(200) .describe("Maximum number of unused-attachment paths to return (1-10000, default: 200). Total counts are still reported."), includeBytes: z .boolean() .optional() .default(false) .describe("If true, also stat each unused attachment and report total reclaimable bytes."), }, }, async ({ limit, includeBytes }, extra) => { try { const reportProgress = makeProgressReporter(extra); const attachments = await listAttachments(vaultPath); if (attachments.length === 0) { return textResult("No attachments in this vault — nothing to check."); } const attachmentSet = new Set(attachments); const basenameIndex = new Map<string, string[]>(); for (const p of attachments) { const base = path.basename(p).toLowerCase(); const list = basenameIndex.get(base); if (list) list.push(p); else basenameIndex.set(base, [p]); } const notes = await listNotes(vaultPath); await reportProgress(0, notes.length, "Reading notes…"); const { contents } = await readAllCached(vaultPath, notes, (note, err) => { log.warn("find_unused_attachments: note read failed", { note, err }); }); const referenced = new Set<string>(); let scanned = 0; for (const notePath of notes) { const content = contents.get(notePath); if (content !== undefined) { const { resolved } = collectReferencedAttachments(content, attachmentSet, basenameIndex); for (const r of resolved) referenced.add(r); } scanned++; await reportProgress(scanned, notes.length, `Scanned ${scanned}/${notes.length} notes`); } const unused = attachments.filter((p) => !referenced.has(p)); if (unused.length === 0) { return textResult( `All ${attachments.length} attachment(s) are referenced — nothing to clean up.`, ); } const truncated = unused.slice(0, limit); const lines: string[] = [ `Found ${unused.length} unused attachment(s) of ${attachments.length} total${unused.length > limit ? ` (showing first ${limit})` : ""}:`, "", ]; if (includeBytes) { let totalBytes = 0; const sizes = new Map<string, number>(); for (const p of truncated) { try { const stat = await getAttachmentStats(vaultPath, p); sizes.set(p, stat.size); totalBytes += stat.size; } catch { // skip — file may have been removed mid-scan } } lines.push(`Total reclaimable: ${totalBytes.toLocaleString()} bytes`); lines.push(""); for (const p of truncated) { const sz = sizes.get(p); lines.push(sz !== undefined ? `- ${p} (${sz.toLocaleString()} bytes)` : `- ${p}`); } } else { for (const p of truncated) lines.push(`- ${p}`); } return textResult(lines.join("\n")); } catch (err) { log.error("find_unused_attachments failed", { tool: "find_unused_attachments", err: err as Error, }); return errorResult(`Error finding unused attachments: ${sanitizeError(err)}`); } }, ); - src/index.ts:26-26 (registration)Import of registerAttachmentTools from src/tools/attachments.js, used to register all attachment tools including find_unused_attachments.
import { registerAttachmentTools } from "./tools/attachments.js"; - src/tools/attachments.ts:56-101 (helper)Helper function collectReferencedAttachments which resolves the set of attachment paths referenced by a single note's content, considering ![[wikilink]] embeds and [markdown links](files). Uses exact relative-path match then basename match.
function collectReferencedAttachments( noteContent: string, attachmentSet: ReadonlySet<string>, basenameIndex: ReadonlyMap<string, string[]>, ): { resolved: Set<string>; unresolved: string[] } { const resolved = new Set<string>(); const unresolved: string[] = []; const consider = (rawTarget: string): void => { const t = rawTarget.split("#")[0].split("^")[0].trim(); if (!t) return; // 1) Exact relative-path match (case-insensitive on case-insensitive FS, // but we lowercase consistently to keep cross-platform behavior stable). const lower = t.toLowerCase(); for (const att of attachmentSet) { if (att.toLowerCase() === lower) { resolved.add(att); return; } } // 2) Basename match. Obsidian also allows missing extensions on // attachment links, but only for image/PDF formats — we stay strict // and require the extension to keep this code small. const base = path.basename(t).toLowerCase(); const candidates = basenameIndex.get(base); if (candidates && candidates.length > 0) { for (const c of candidates) resolved.add(c); return; } unresolved.push(t); }; for (const span of extractWikilinkSpans(noteContent)) { if (!span.isEmbed) continue; consider(span.target); } for (const span of extractMarkdownLinkSpans(noteContent)) { // Markdown embed: ``. The `isEmbed` flag captures `!`. // Plain `[text](url)` to a file is also a reference, even without `!`. consider(span.urlPath); } return { resolved, unresolved }; }