Harden cleanup safety and review all deletions

This commit is contained in:
Aleksei Blinov 2026-04-28 11:26:38 +03:00
parent ace0fcb177
commit 623ff6b4f6
14 changed files with 340 additions and 93 deletions

View file

@ -1,10 +0,0 @@
{
"trailingComma": "es5",
"tabWidth": 4,
"semi": true,
"singleQuote": true,
"printWidth": 155,
"jsxBracketSameLine": true,
"bracketSpacing": true,
"arrowParens": "always"
}

File diff suppressed because one or more lines are too long

View file

@ -1,7 +1,7 @@
{
"id": "clear-unused-images",
"name": "Clear Unused Images",
"version": "1.4.1",
"version": "1.4.2",
"minAppVersion": "0.11.13",
"description": "Fork of oz-clear-unused-images for clearing unused images from Obsidian vaults.",
"author": "Aleksei Blinov",

View file

@ -1,6 +1,6 @@
{
"name": "clear-unused-images",
"version": "1.4.1",
"version": "1.4.2",
"description": "Fork of oz-clear-unused-images for clearing unused images from Obsidian vaults.",
"main": "main.js",
"scripts": {

22
src/frontmatterWalker.ts Normal file
View file

@ -0,0 +1,22 @@
export const walkFrontmatterValues = (
frontmatterValue: unknown,
visitString: (value: string) => void
): void => {
if (typeof frontmatterValue === 'string') {
visitString(frontmatterValue);
return;
}
if (Array.isArray(frontmatterValue)) {
for (const value of frontmatterValue) {
walkFrontmatterValues(value, visitString);
}
return;
}
if (frontmatterValue && typeof frontmatterValue === 'object') {
for (const value of Object.values(frontmatterValue as Record<string, unknown>)) {
walkFrontmatterValues(value, visitString);
}
}
};

View file

@ -1,5 +1,5 @@
import { TFile, App } from 'obsidian';
import { extractMarkdownLinkMatches } from './referenceUtils';
import { extractMarkdownLinkMatches, parseMarkdownLinkDestination } from './referenceUtils';
/* -------------------- LINK DETECTOR -------------------- */
@ -67,11 +67,15 @@ export const getAllLinkMatchesInFile = async (mdFile: TFile, app: App, fileText?
// --> Get All Markdown Links
let markdownMatches = extractMarkdownLinkMatches(fileText.toString());
if (markdownMatches) {
let fileRegex = /(?<=\().*(?=\))/;
for (let markdownMatch of markdownMatches) {
const destination = parseMarkdownLinkDestination(markdownMatch);
if (!destination) {
continue;
}
// --> Check if it is Transclusion
if (matchIsMdTransclusion(markdownMatch)) {
let fileName = getTransclusionFileName(markdownMatch);
if (destination.includes('#')) {
let fileName = destination.split('#', 1)[0];
let file = app.metadataCache.getFirstLinkpathDest(fileName, mdFile.path);
if (fileName !== '') {
let linkMatch: LinkMatch = {
@ -85,19 +89,16 @@ export const getAllLinkMatchesInFile = async (mdFile: TFile, app: App, fileText?
}
}
// --> Normal Internal Link
let fileMatch = markdownMatch.match(fileRegex);
if (fileMatch) {
// Web links are to be skipped
if (fileMatch[0].startsWith('http')) continue;
let file = app.metadataCache.getFirstLinkpathDest(fileMatch[0], mdFile.path);
let linkMatch: LinkMatch = {
type: 'markdown',
match: markdownMatch,
linkText: file ? file.path : fileMatch[0],
sourceFilePath: mdFile.path,
};
linkMatches.push(linkMatch);
}
// Web links are to be skipped
if (destination.startsWith('http')) continue;
let file = app.metadataCache.getFirstLinkpathDest(destination, mdFile.path);
let linkMatch: LinkMatch = {
type: 'markdown',
match: markdownMatch,
linkText: file ? file.path : destination,
sourceFilePath: mdFile.path,
};
linkMatches.push(linkMatch);
}
}
return linkMatches;
@ -108,27 +109,19 @@ export const getAllLinkMatchesInFile = async (mdFile: TFile, app: App, fileText?
const wikiTransclusionRegex = /\[\[(.*?)#.*?\]\]/;
const wikiTransclusionFileNameRegex = /(?<=\[\[)(.*)(?=#)/;
const mdTransclusionRegex = /\[.*?]\((.*?)#.*?\)/;
const mdTransclusionFileNameRegex = /(?<=\]\()(.*)(?=#)/;
const matchIsWikiTransclusion = (match: string): boolean => {
return wikiTransclusionRegex.test(match);
};
const matchIsMdTransclusion = (match: string): boolean => {
return mdTransclusionRegex.test(match);
};
/**
* @param match
* @returns file name if there is a match or empty string if no match
*/
const getTransclusionFileName = (match: string): string => {
let isWiki = wikiTransclusionRegex.test(match);
let isMd = mdTransclusionRegex.test(match);
if (isWiki || isMd) {
let fileNameMatch = match.match(isWiki ? wikiTransclusionFileNameRegex : mdTransclusionFileNameRegex);
if (matchIsWikiTransclusion(match)) {
let fileNameMatch = match.match(wikiTransclusionFileNameRegex);
if (fileNameMatch) return fileNameMatch[0];
}
return '';
};

View file

@ -2,6 +2,7 @@ import { Plugin, TFile, Notice } from 'obsidian';
import { OzanClearImagesSettingsTab } from './settings';
import { OzanClearImagesSettings, DEFAULT_SETTINGS } from './settings';
import { LogsModal } from './modals';
import { CleanupReviewModal } from './reviewModal';
import * as Util from './util';
import { createPeriodicCleanupScheduler, createVaultLoadCleanupScheduler } from './startupCleanup';
@ -12,6 +13,10 @@ export default class OzanClearImages extends Plugin {
periodicCleanupTimerId: number | undefined = undefined;
periodicCleanupScheduler: ReturnType<typeof createPeriodicCleanupScheduler<number>> | undefined = undefined;
cleanupInProgress = false;
private vaultLayoutReady = false;
private vaultMetadataResolved = false;
private vaultReadyCallbacks: Array<() => void | Promise<void>> = [];
private vaultReadyListenersRegistered = false;
async onload() {
console.log('Clear Unused Images plugin loaded...');
@ -61,11 +66,7 @@ export default class OzanClearImages extends Plugin {
this.startupCleanupScheduled = true;
const scheduleCleanup = createVaultLoadCleanupScheduler(
(callback) => {
this.app.workspace.onLayoutReady(() => {
void callback();
});
},
(callback) => this.onVaultReady(callback),
async (type) => {
await this.clearUnusedAttachments(type);
}
@ -77,9 +78,7 @@ export default class OzanClearImages extends Plugin {
refreshPeriodicCleanup(): void {
if (!this.periodicCleanupScheduler) {
this.periodicCleanupScheduler = createPeriodicCleanupScheduler<number>(
(callback) => {
this.app.workspace.onLayoutReady(callback);
},
(callback) => this.onVaultReady(callback),
(callback, intervalMs) => {
this.clearPeriodicCleanupTimer();
const timerId = window.setInterval(callback, intervalMs);
@ -93,7 +92,7 @@ export default class OzanClearImages extends Plugin {
}
},
async (type) => {
await this.clearUnusedAttachments(type, { silentIfBusy: true });
await this.clearUnusedAttachments(type);
}
);
}
@ -115,6 +114,52 @@ export default class OzanClearImages extends Plugin {
}
}
onVaultReady(callback: () => void | Promise<void>): void {
this.ensureVaultReadyListeners();
if (this.vaultLayoutReady && this.vaultMetadataResolved) {
void callback();
return;
}
this.vaultReadyCallbacks.push(callback);
}
private ensureVaultReadyListeners(): void {
if (this.vaultReadyListenersRegistered) {
return;
}
this.vaultReadyListenersRegistered = true;
this.vaultLayoutReady = this.app.workspace.layoutReady;
this.vaultMetadataResolved = Object.keys(this.app.metadataCache.resolvedLinks).length > 0;
this.app.workspace.onLayoutReady(() => {
this.vaultLayoutReady = true;
this.flushVaultReadyCallbacks();
});
this.registerEvent(
this.app.metadataCache.on('resolved', () => {
this.vaultMetadataResolved = true;
this.flushVaultReadyCallbacks();
})
);
this.flushVaultReadyCallbacks();
}
private flushVaultReadyCallbacks(): void {
if (!this.vaultLayoutReady || !this.vaultMetadataResolved || this.vaultReadyCallbacks.length === 0) {
return;
}
const callbacks = this.vaultReadyCallbacks.splice(0, this.vaultReadyCallbacks.length);
for (const callback of callbacks) {
void callback();
}
}
clearPeriodicCleanupTimer(): void {
if (this.periodicCleanupTimerId !== undefined) {
window.clearInterval(this.periodicCleanupTimerId);
@ -139,6 +184,17 @@ export default class OzanClearImages extends Plugin {
var unusedAttachments: TFile[] = await Util.getUnusedAttachments(this.app, type);
var len = unusedAttachments.length;
if (len > 0) {
if (type === 'all') {
const reviewAccepted = await new CleanupReviewModal(
this.app,
unusedAttachments.map((file) => file.path)
).prompt();
if (!reviewAccepted) {
new Notice('Cleanup cancelled.');
return;
}
}
if (this.settings.deleteOption === 'permanent' && !this.confirmPermanentDelete(len, type)) {
new Notice('Cleanup cancelled.');
return;

View file

@ -21,10 +21,15 @@ export const resolveVaultAttachmentReference = (
reference: string,
sourcePath: string,
resolveLinkpathDest: (referencePath: string, sourceFilePath: string) => string | null,
hasExactPath: (path: string) => boolean
hasExactPath: (path: string) => boolean,
scope: 'image' | 'all' = 'image'
): string | null => {
const trimmedReference = reference.trim();
if (!trimmedReference || EXTERNAL_REFERENCE_REGEX.test(trimmedReference) || !hasImageExtension(trimmedReference)) {
if (!trimmedReference || EXTERNAL_REFERENCE_REGEX.test(trimmedReference)) {
return null;
}
if (scope === 'image' && !hasImageExtension(trimmedReference)) {
return null;
}
@ -36,6 +41,30 @@ export const resolveVaultAttachmentReference = (
return hasExactPath(trimmedReference) ? trimmedReference : null;
};
export const parseMarkdownLinkDestination = (markdownMatch: string): string => {
const openParenIndex = markdownMatch.indexOf('](');
if (openParenIndex === -1) {
return '';
}
const rawDestination = markdownMatch.slice(openParenIndex + 2, -1).trim();
if (!rawDestination) {
return '';
}
if (rawDestination.startsWith('<')) {
const closingAngleIndex = rawDestination.indexOf('>');
if (closingAngleIndex === -1) {
return '';
}
return unescapeMarkdownDestination(rawDestination.slice(1, closingAngleIndex).trim());
}
const destinationEnd = findMarkdownDestinationEnd(rawDestination);
return unescapeMarkdownDestination(rawDestination.slice(0, destinationEnd).trim());
};
export const splitExcludedFolders = (input: string): string[] => {
return input
.split(',')
@ -123,3 +152,30 @@ const findClosingDelimiter = (text: string, openingIndex: number, openingChar: s
return -1;
};
const findMarkdownDestinationEnd = (destination: string): number => {
let escaped = false;
for (let index = 0; index < destination.length; index++) {
const character = destination[index];
if (escaped) {
escaped = false;
continue;
}
if (character === '\\') {
escaped = true;
continue;
}
if (/\s/.test(character)) {
return index;
}
}
return destination.length;
};
const unescapeMarkdownDestination = (destination: string): string => {
return destination.replace(/\\([\\[\]()<>\s!#%&'*,.:;=?@^`~])/g, '$1');
};

71
src/reviewModal.ts Normal file
View file

@ -0,0 +1,71 @@
import { App, Modal } from 'obsidian';
export class CleanupReviewModal extends Modal {
private readonly filePaths: string[];
private resolveDecision: ((decision: boolean) => void) | undefined;
private decisionResolved = false;
constructor(app: App, filePaths: string[]) {
super(app);
this.filePaths = filePaths;
}
prompt(): Promise<boolean> {
return new Promise<boolean>((resolve) => {
this.resolveDecision = resolve;
this.open();
});
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
const headerWrapper = contentEl.createEl('div');
headerWrapper.addClass('unused-images-center-wrapper');
headerWrapper.createEl('h1', { text: 'Review Unused Files' }).addClass('modal-title');
contentEl.createEl('p', {
text: 'These files are about to be processed by Clear Unused Attachments. Review the exact paths before continuing.',
});
const listWrapper = contentEl.createEl('div');
listWrapper.addClass('unused-images-logs');
for (const filePath of this.filePaths) {
listWrapper.createDiv({ text: filePath });
}
const buttonWrapper = contentEl.createEl('div');
buttonWrapper.addClass('unused-images-center-wrapper');
const cancelButton = buttonWrapper.createEl('button', { text: 'Cancel' });
cancelButton.addClass('unused-images-button');
cancelButton.addEventListener('click', () => {
this.closeWithDecision(false);
});
const continueButton = buttonWrapper.createEl('button', { text: 'Continue' });
continueButton.addClass('unused-images-button');
continueButton.addEventListener('click', () => {
this.closeWithDecision(true);
});
}
onClose() {
this.contentEl.empty();
if (!this.decisionResolved) {
this.decisionResolved = true;
this.resolveDecision?.(false);
}
}
private closeWithDecision(decision: boolean): void {
if (this.decisionResolved) {
return;
}
this.decisionResolved = true;
this.resolveDecision?.(decision);
this.close();
}
}

View file

@ -8,6 +8,7 @@ import {
resolveVaultAttachmentReference,
splitExcludedFolders,
} from './referenceUtils';
import { walkFrontmatterValues } from './frontmatterWalker';
/* ------------------ Image Handlers ------------------ */
@ -20,7 +21,7 @@ export const getUnusedAttachments = async (app: App, type: 'image' | 'all') => {
var usedAttachmentsSet: Set<string>;
// Get Used Attachments in All Markdown Files
usedAttachmentsSet = await getAttachmentPathSetForVault(app);
usedAttachmentsSet = await getAttachmentPathSetForVault(app, type);
// Compare All Attachments vs Used Attachments
allAttachmentsInVault.forEach((attachment) => {
@ -50,7 +51,7 @@ const getAttachmentsInVault = (app: App, type: 'image' | 'all'): TFile[] => {
};
// New Method for Getting All Used Attachments
const getAttachmentPathSetForVault = async (app: App): Promise<Set<string>> => {
const getAttachmentPathSetForVault = async (app: App, type: 'image' | 'all'): Promise<Set<string>> => {
var attachmentsSet: Set<string> = new Set();
var resolvedLinks = app.metadataCache.resolvedLinks;
if (resolvedLinks) {
@ -71,23 +72,7 @@ const getAttachmentPathSetForVault = async (app: App): Promise<Set<string>> => {
// Frontmatter
let fileCache = app.metadataCache.getFileCache(obsFile);
if (fileCache.frontmatter) {
let frontmatter = fileCache.frontmatter;
for (let k of Object.keys(frontmatter)) {
if (typeof frontmatter[k] === 'string') {
if (frontmatter[k].match(bannerRegex)) {
let fileName = frontmatter[k].match(bannerRegex)[1];
let file = app.metadataCache.getFirstLinkpathDest(fileName, obsFile.path);
if (file) {
addToSet(attachmentsSet, file.path);
}
} else {
const resolvedPath = resolveAttachmentReference(app, frontmatter[k], obsFile.path);
if (resolvedPath) {
addToSet(attachmentsSet, resolvedPath);
}
}
}
}
collectFrontmatterAttachmentReferences(fileCache.frontmatter, app, obsFile.path, attachmentsSet, type);
}
// Any Additional Link
let linkMatches: LinkMatch[] = await getAllLinkMatchesInFile(obsFile, app);
@ -220,7 +205,12 @@ const addToSet = (setObj: Set<string>, value: string) => {
}
};
const resolveAttachmentReference = (app: App, reference: string, sourcePath: string): string | null => {
const resolveAttachmentReference = (
app: App,
reference: string,
sourcePath: string,
type: 'image' | 'all'
): string | null => {
return resolveVaultAttachmentReference(
reference,
sourcePath,
@ -230,11 +220,45 @@ const resolveAttachmentReference = (app: App, reference: string, sourcePath: str
},
(referencePath) => {
const file = app.vault.getAbstractFileByPath(referencePath);
return file instanceof TFile && (hasImageExtension(file.path) || file.extension !== 'md');
}
if (!(file instanceof TFile)) {
return false;
}
if (type === 'image') {
return hasImageExtension(file.path);
}
return file.extension !== 'md' && file.extension !== 'canvas';
},
type
);
};
const collectFrontmatterAttachmentReferences = (
frontmatterValue: unknown,
app: App,
sourcePath: string,
attachmentsSet: Set<string>,
type: 'image' | 'all'
) => {
walkFrontmatterValues(frontmatterValue, (stringValue) => {
const bannerMatch = stringValue.match(bannerRegex);
if (bannerMatch) {
const fileName = bannerMatch[1];
const file = app.metadataCache.getFirstLinkpathDest(fileName, sourcePath);
if (file && (type === 'all' || hasImageExtension(file.path))) {
addToSet(attachmentsSet, file.path);
}
return;
}
const resolvedPath = resolveAttachmentReference(app, stringValue, sourcePath, type);
if (resolvedPath) {
addToSet(attachmentsSet, resolvedPath);
}
});
};
const getErrorMessage = (error: unknown): string => {
return error instanceof Error ? error.message : String(error);
};

View file

@ -1,6 +1,9 @@
.unused-images-logs {
margin-bottom: 13px;
margin-top: 5px;
max-height: 60vh;
overflow-y: auto;
white-space: pre-wrap;
}
.unused-images-center-wrapper {

View file

@ -0,0 +1,19 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { walkFrontmatterValues } from '../src/frontmatterWalker.ts';
test('walkFrontmatterValues visits strings inside arrays and nested objects', () => {
const visited: string[] = [];
walkFrontmatterValues(
{
cover: 'assets/photo.png',
attachments: ['docs/report.pdf', { nested: 'audio/song.mp3' }],
ignored: 42,
},
(value) => visited.push(value)
);
assert.deepEqual(visited, ['assets/photo.png', 'docs/report.pdf', 'audio/song.mp3']);
});

View file

@ -5,6 +5,7 @@ import {
extractMarkdownLinkMatches,
hasImageExtension,
isPathCoveredByExcludedFolder,
parseMarkdownLinkDestination,
resolveVaultAttachmentReference,
} from '../src/referenceUtils.ts';
@ -29,6 +30,30 @@ test('resolveVaultAttachmentReference prefers resolved vault path and ignores ex
);
});
test('resolveVaultAttachmentReference respects cleanup scope', () => {
assert.equal(
resolveVaultAttachmentReference(
'report.pdf',
'notes/daily.md',
(referencePath) => (referencePath === 'report.pdf' ? 'docs/report.pdf' : null),
() => false,
'all'
),
'docs/report.pdf'
);
assert.equal(
resolveVaultAttachmentReference(
'report.pdf',
'notes/daily.md',
(referencePath) => (referencePath === 'report.pdf' ? 'docs/report.pdf' : null),
() => false,
'image'
),
null
);
});
test('resolveVaultAttachmentReference falls back to exact vault path lookup', () => {
const resolvedPath = resolveVaultAttachmentReference(
'assets/cover.webp',
@ -54,3 +79,9 @@ test('extractMarkdownLinkMatches keeps paths with parentheses intact', () => {
assert.deepEqual(matches, ['[photo](assets/photo (1).png)', '[doc](files/report.pdf)']);
});
test('parseMarkdownLinkDestination strips titles and angle brackets', () => {
assert.equal(parseMarkdownLinkDestination('[img](assets/photo.png "caption")'), 'assets/photo.png');
assert.equal(parseMarkdownLinkDestination('[img](<assets/photo (1).png>)'), 'assets/photo (1).png');
assert.equal(parseMarkdownLinkDestination('[doc](<files/report.pdf> "caption")'), 'files/report.pdf');
});

View file

@ -1,18 +0,0 @@
{
"1.1.1": "0.11.13",
"1.1.0": "0.11.13",
"1.0.9": "0.11.13",
"1.0.8": "0.11.13",
"1.0.7": "0.11.13",
"1.0.6": "0.11.13",
"1.0.5": "0.11.13",
"1.0.4": "0.11.13",
"1.0.3": "0.11.13",
"1.0.2": "0.11.13",
"1.0.1": "0.11.13",
"1.0.0": "0.11.13",
"0.0.3": "0.11.13",
"0.0.2": "0.11.13",
"0.0.1": "0.11.13",
"0.0.0": "0.11.13"
}