mirror of
https://github.com/yinshaohua/obsidian-download-image.git
synced 2026-07-22 06:51:07 +00:00
Fix Obsidian review issues
This commit is contained in:
parent
62a181bb96
commit
4acff65632
8 changed files with 144 additions and 221 deletions
|
|
@ -36,7 +36,7 @@ To remove unused downloaded files later:
|
|||
|
||||
1. Run **Clean unused attachments** from the command palette.
|
||||
2. Review the detected orphaned files in the cleanup modal.
|
||||
3. Confirm removal using your configured cleanup method.
|
||||
3. Confirm removal using Obsidian’s standard trash behavior.
|
||||
|
||||
## Installation for users
|
||||
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ export default tseslint.config(
|
|||
},
|
||||
},
|
||||
rules: {
|
||||
"no-console": ["error", { allow: ["warn", "error", "debug", "log"] }],
|
||||
"no-console": ["error", { allow: ["warn", "error", "debug"] }],
|
||||
"obsidianmd/validate-manifest": "error",
|
||||
"obsidianmd/no-sample-code": "error",
|
||||
"obsidianmd/sample-names": "error",
|
||||
|
|
@ -52,12 +52,12 @@ export default tseslint.config(
|
|||
"obsidianmd/no-tfile-tfolder-cast": "error",
|
||||
"obsidianmd/vault/iterate": "error",
|
||||
"obsidianmd/object-assign": "off",
|
||||
"obsidianmd/prefer-file-manager-trash-file": "off",
|
||||
"obsidianmd/no-static-styles-assignment": "off",
|
||||
"obsidianmd/settings-tab/no-manual-html-headings": "off",
|
||||
"obsidianmd/ui/sentence-case": "off",
|
||||
"obsidianmd/commands/no-plugin-id-in-command-id": "off",
|
||||
"obsidianmd/commands/no-plugin-name-in-command-name": "off",
|
||||
"obsidianmd/prefer-file-manager-trash-file": "error",
|
||||
"obsidianmd/no-static-styles-assignment": "error",
|
||||
"obsidianmd/settings-tab/no-manual-html-headings": "error",
|
||||
"obsidianmd/ui/sentence-case": "error",
|
||||
"obsidianmd/commands/no-plugin-id-in-command-id": "error",
|
||||
"obsidianmd/commands/no-plugin-name-in-command-name": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ export const MIME_TO_EXT: Record<string, string> = {
|
|||
function sanitizeFilename(name: string): string {
|
||||
return name
|
||||
.replace(/[*"\\/<>:|?]/g, '')
|
||||
.replace(/[\0-\x1F]/g, '')
|
||||
.replace(/[\x00-\x1e]/g, '')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/^[.-]+|[.-]+$/g, '') || `image-${Date.now()}`;
|
||||
}
|
||||
|
|
|
|||
50
src/main.ts
50
src/main.ts
|
|
@ -8,7 +8,7 @@ import {showCleanupModal} from "./modal";
|
|||
|
||||
/**
|
||||
* Extracted cleanup pipeline for testability (D-03).
|
||||
* Runs scan -> modal -> delete with ENOENT guard and error resilience.
|
||||
* Runs scan -> modal -> trash with error resilience.
|
||||
*/
|
||||
export async function executeCleanup(
|
||||
app: App,
|
||||
|
|
@ -21,15 +21,6 @@ export async function executeCleanup(
|
|||
return;
|
||||
}
|
||||
|
||||
// ENOENT guard: pre-create .trash folder if missing (D-07)
|
||||
if (settings.cleanupMethod === 'trash') {
|
||||
const trashFolder = app.vault.getAbstractFileByPath('.trash');
|
||||
if (!trashFolder) {
|
||||
await app.vault.createFolder('.trash');
|
||||
}
|
||||
}
|
||||
|
||||
// Progress Notice (D-12, D-13)
|
||||
const progressNotice = new Notice('Cleaning up...', 0);
|
||||
|
||||
let successCount = 0;
|
||||
|
|
@ -38,11 +29,7 @@ export async function executeCleanup(
|
|||
try {
|
||||
for (const file of selected) {
|
||||
try {
|
||||
if (settings.cleanupMethod === 'trash') {
|
||||
await app.vault.trash(file, false);
|
||||
} else {
|
||||
await app.vault.delete(file);
|
||||
}
|
||||
await app.fileManager.trashFile(file);
|
||||
successCount++;
|
||||
} catch (err) {
|
||||
failCount++;
|
||||
|
|
@ -50,19 +37,18 @@ export async function executeCleanup(
|
|||
}
|
||||
}
|
||||
|
||||
// Hide progress, show result Notice (D-10)
|
||||
progressNotice.hide();
|
||||
if (failCount === 0) {
|
||||
new Notice(`Cleaned ${successCount} attachments`);
|
||||
} else if (successCount > 0) {
|
||||
new Notice(`Cleaned ${successCount} attachments, ${failCount} failed (see console)`);
|
||||
new Notice(`Cleaned ${successCount} attachments, ${failCount} failed (see debug log)`);
|
||||
} else {
|
||||
new Notice(`Cleanup failed: ${failCount} files could not be removed (see console)`);
|
||||
new Notice(`Cleanup failed: ${failCount} files could not be removed (see debug log)`);
|
||||
}
|
||||
} catch (err) {
|
||||
progressNotice.hide();
|
||||
console.error('[download-image] Unexpected cleanup error:', err);
|
||||
new Notice('Cleanup failed unexpectedly. Check console for details.');
|
||||
new Notice('Cleanup failed unexpectedly. Check the debug log for details.');
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -73,8 +59,8 @@ export default class DownloadImagePlugin extends Plugin {
|
|||
await this.loadSettings();
|
||||
|
||||
this.addCommand({
|
||||
id: 'download-images',
|
||||
name: 'Download images in current note',
|
||||
id: 'save-remote-files-in-current-note',
|
||||
name: 'Save remote files in current note',
|
||||
editorCallback: async (editor: Editor, view: MarkdownView) => {
|
||||
const content = editor.getValue();
|
||||
const refs = extractImages(content);
|
||||
|
|
@ -84,8 +70,6 @@ export default class DownloadImagePlugin extends Plugin {
|
|||
}
|
||||
|
||||
const notePath = view.file?.path ?? '';
|
||||
|
||||
// Real-time progress Notice — reuse single Notice instance
|
||||
const progressNotice = new Notice('Downloading images...', 0);
|
||||
|
||||
try {
|
||||
|
|
@ -97,10 +81,8 @@ export default class DownloadImagePlugin extends Plugin {
|
|||
},
|
||||
});
|
||||
|
||||
// Hide progress notice
|
||||
progressNotice.hide();
|
||||
|
||||
// Deduplicate by URL for accurate counts
|
||||
const seen = new Set<string>();
|
||||
const uniqueResults: typeof results = [];
|
||||
for (const r of results) {
|
||||
|
|
@ -113,23 +95,11 @@ export default class DownloadImagePlugin extends Plugin {
|
|||
const ok = uniqueResults.filter(r => r.status === 'ok').length;
|
||||
const failedResults = uniqueResults.filter(r => r.status === 'failed');
|
||||
|
||||
// Always log summary
|
||||
console.log(`[download-image] ${refs.length} refs found, ${uniqueResults.length} unique URLs, ${ok} ok, ${failedResults.length} failed`);
|
||||
for (const r of uniqueResults) {
|
||||
if (r.status === 'ok') {
|
||||
console.log(` OK ${r.ref.url} -> ${r.localPath}`);
|
||||
} else {
|
||||
console.warn(` FAIL ${r.ref.url}: ${r.error}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Replace URLs in document via single editor.transaction
|
||||
if (ok > 0) {
|
||||
const currentContent = editor.getValue();
|
||||
const replacements = buildReplacementMap(currentContent, results);
|
||||
if (replacements.length > 0) {
|
||||
const newContent = applyReplacements(currentContent, replacements);
|
||||
// Single transaction = single undo step
|
||||
editor.transaction({
|
||||
changes: [{
|
||||
from: { line: 0, ch: 0 },
|
||||
|
|
@ -140,17 +110,15 @@ export default class DownloadImagePlugin extends Plugin {
|
|||
}
|
||||
}
|
||||
|
||||
// Concise summary notice
|
||||
const dupes = refs.length - uniqueResults.length;
|
||||
new Notice(
|
||||
`Downloaded ${ok} image(s)${failedResults.length > 0 ? `, ${failedResults.length} failed (see console)` : ''}` +
|
||||
`Downloaded ${ok} image(s)${failedResults.length > 0 ? `, ${failedResults.length} failed (see debug log)` : ''}` +
|
||||
(dupes > 0 ? ` (${dupes} duplicate refs skipped)` : '')
|
||||
);
|
||||
|
||||
} catch (err) {
|
||||
progressNotice.hide();
|
||||
console.error('[download-image] Unexpected error:', err);
|
||||
new Notice('Image download failed unexpectedly. Check console for details.');
|
||||
new Notice('Image download failed unexpectedly. Check the debug log for details.');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
80
src/modal.ts
80
src/modal.ts
|
|
@ -35,18 +35,12 @@ export class CleanupModal extends Modal {
|
|||
const { contentEl, modalEl } = this;
|
||||
contentEl.empty();
|
||||
|
||||
// Flex layout: header + list (scrollable) + buttons (fixed at bottom)
|
||||
modalEl.style.maxHeight = '80vh';
|
||||
modalEl.style.display = 'flex';
|
||||
modalEl.style.flexDirection = 'column';
|
||||
contentEl.style.display = 'flex';
|
||||
contentEl.style.flexDirection = 'column';
|
||||
contentEl.style.overflow = 'hidden';
|
||||
contentEl.style.flex = '1';
|
||||
contentEl.style.minHeight = '0';
|
||||
modalEl.addClass('download-image-cleanup-modal');
|
||||
contentEl.addClass('download-image-cleanup-modal__content');
|
||||
|
||||
// Title
|
||||
contentEl.createEl('h2', { text: 'Clean Orphaned Attachments' });
|
||||
new Setting(contentEl)
|
||||
.setName('Clean orphaned attachments')
|
||||
.setHeading();
|
||||
|
||||
// Sort by vault path alphabetically (D-02)
|
||||
const sorted = [...this.orphans].sort((a, b) =>
|
||||
|
|
@ -56,55 +50,58 @@ export class CleanupModal extends Modal {
|
|||
// Checkbox state map: file path -> <input>
|
||||
const checkboxMap = new Map<string, HTMLInputElement>();
|
||||
|
||||
// Select All / Deselect All toggle (D-05)
|
||||
const toggleBtn = contentEl.createEl('button', { text: 'Deselect All' });
|
||||
toggleBtn.onclick = () => {
|
||||
const allChecked = [...checkboxMap.values()].every(cb => cb.checked);
|
||||
checkboxMap.forEach(cb => { cb.checked = !allChecked; });
|
||||
toggleBtn.textContent = allChecked ? 'Select All' : 'Deselect All';
|
||||
};
|
||||
const toggleSetting = new Setting(contentEl)
|
||||
.setName('Selection')
|
||||
.setDesc('Choose which orphaned attachments to remove.');
|
||||
|
||||
let toggleBtnText = 'Deselect all';
|
||||
let toggleBtn: HTMLButtonElement | null = null;
|
||||
toggleSetting.addButton(btn => {
|
||||
btn.setButtonText(toggleBtnText)
|
||||
.onClick(() => {
|
||||
const allChecked = [...checkboxMap.values()].every(cb => cb.checked);
|
||||
checkboxMap.forEach(cb => { cb.checked = !allChecked; });
|
||||
toggleBtnText = allChecked ? 'Select all' : 'Deselect all';
|
||||
toggleBtn?.setText(toggleBtnText);
|
||||
});
|
||||
toggleBtn = btn.buttonEl;
|
||||
});
|
||||
|
||||
// Scrollable container (D-06) — flex:1 fills remaining space
|
||||
const listEl = contentEl.createEl('div');
|
||||
listEl.style.flex = '1';
|
||||
listEl.style.minHeight = '0';
|
||||
listEl.style.overflowY = 'auto';
|
||||
listEl.style.marginTop = '8px';
|
||||
const listEl = contentEl.createDiv({ cls: 'download-image-cleanup-modal__list' });
|
||||
|
||||
// File rows (D-01, D-03, D-04)
|
||||
for (const orphan of sorted) {
|
||||
const row = listEl.createEl('div', { cls: 'cleanup-modal-row' });
|
||||
row.style.display = 'flex';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.padding = '4px 0';
|
||||
const row = listEl.createDiv({ cls: 'download-image-cleanup-modal__row' });
|
||||
|
||||
// Checkbox — checked by default (D-04)
|
||||
const cb = row.createEl('input') as HTMLInputElement;
|
||||
const cb = row.createEl('input');
|
||||
cb.type = 'checkbox';
|
||||
cb.checked = true;
|
||||
cb.id = 'cleanup-' + orphan.file.path;
|
||||
checkboxMap.set(orphan.file.path, cb);
|
||||
|
||||
// Label with three columns: name, folder, size (D-01)
|
||||
const label = row.createEl('label');
|
||||
const label = row.createEl('label', { cls: 'download-image-cleanup-modal__label' });
|
||||
label.htmlFor = cb.id;
|
||||
label.style.display = 'flex';
|
||||
label.style.flex = '1';
|
||||
label.style.gap = '8px';
|
||||
|
||||
const nameSpan = label.createEl('span', { text: orphan.file.name });
|
||||
nameSpan.style.flex = '2';
|
||||
label.createSpan({
|
||||
text: orphan.file.name,
|
||||
cls: 'download-image-cleanup-modal__name',
|
||||
});
|
||||
|
||||
const folder = orphan.file.path.includes('/')
|
||||
? orphan.file.path.substring(0, orphan.file.path.lastIndexOf('/'))
|
||||
: '/';
|
||||
const folderSpan = label.createEl('span', { text: folder });
|
||||
folderSpan.style.flex = '2';
|
||||
folderSpan.style.color = 'var(--text-muted)';
|
||||
label.createSpan({
|
||||
text: folder,
|
||||
cls: 'download-image-cleanup-modal__folder',
|
||||
});
|
||||
|
||||
const sizeSpan = label.createEl('span', { text: formatFileSize(orphan.size) });
|
||||
sizeSpan.style.flex = '1';
|
||||
sizeSpan.style.textAlign = 'right';
|
||||
label.createSpan({
|
||||
text: formatFileSize(orphan.size),
|
||||
cls: 'download-image-cleanup-modal__size',
|
||||
});
|
||||
}
|
||||
|
||||
// Action buttons using Setting API
|
||||
|
|
@ -115,7 +112,8 @@ export class CleanupModal extends Modal {
|
|||
.onClick(() => {
|
||||
const selected = [...checkboxMap.entries()]
|
||||
.filter(([, cb]) => cb.checked)
|
||||
.map(([path]) => this.orphans.find(o => o.file.path === path)!.file);
|
||||
.map(([path]) => this.orphans.find(o => o.file.path === path)?.file)
|
||||
.filter((file): file is TFile => file !== undefined);
|
||||
this.resolved = true;
|
||||
this.resolveFn(selected);
|
||||
this.close();
|
||||
|
|
|
|||
|
|
@ -3,19 +3,15 @@ import DownloadImagePlugin from "./main";
|
|||
|
||||
export type NamingStrategy = 'original' | 'timestamp' | 'hash';
|
||||
|
||||
export type CleanupMethod = 'trash' | 'delete';
|
||||
|
||||
export interface DownloadImageSettings {
|
||||
namingStrategy: NamingStrategy;
|
||||
concurrency: number;
|
||||
cleanupMethod: CleanupMethod;
|
||||
excludedFolders: string[];
|
||||
}
|
||||
|
||||
export const DEFAULT_SETTINGS: DownloadImageSettings = {
|
||||
namingStrategy: 'original',
|
||||
concurrency: 3,
|
||||
cleanupMethod: 'trash',
|
||||
excludedFolders: [],
|
||||
};
|
||||
|
||||
|
|
@ -31,8 +27,9 @@ export class DownloadImageSettingTab extends PluginSettingTab {
|
|||
const {containerEl} = this;
|
||||
containerEl.empty();
|
||||
|
||||
// ── Download section ──
|
||||
containerEl.createEl('h2', { text: 'Download' });
|
||||
new Setting(containerEl)
|
||||
.setName('Download')
|
||||
.setHeading();
|
||||
|
||||
new Setting(containerEl)
|
||||
.setName('Image naming strategy')
|
||||
|
|
@ -64,39 +61,19 @@ export class DownloadImageSettingTab extends PluginSettingTab {
|
|||
await this.plugin.saveSettings();
|
||||
}));
|
||||
|
||||
// ── Cleanup section ──
|
||||
containerEl.createEl('h2', { text: 'Cleanup' });
|
||||
|
||||
// Cleanup method dropdown
|
||||
new Setting(containerEl)
|
||||
.setName('Cleanup method')
|
||||
.setDesc('How orphaned attachments are removed.')
|
||||
.addDropdown(dropdown => dropdown
|
||||
.addOptions({
|
||||
trash: 'Move to .trash',
|
||||
delete: 'Permanent delete',
|
||||
})
|
||||
.setValue(this.plugin.settings.cleanupMethod)
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.cleanupMethod = value as CleanupMethod;
|
||||
await this.plugin.saveSettings();
|
||||
warningEl.style.display = value === 'delete' ? 'block' : 'none';
|
||||
}));
|
||||
.setName('Cleanup')
|
||||
.setHeading();
|
||||
|
||||
// Inline warning for permanent delete
|
||||
const warningEl = containerEl.createEl('p', {
|
||||
text: 'Warning: Files will be permanently deleted and cannot be recovered.',
|
||||
cls: 'mod-warning',
|
||||
});
|
||||
warningEl.style.display =
|
||||
this.plugin.settings.cleanupMethod === 'delete' ? 'block' : 'none';
|
||||
new Setting(containerEl)
|
||||
.setName('Attachment removal')
|
||||
.setDesc('Unused attachments are removed with Obsidian’s standard file trash behavior.');
|
||||
|
||||
// Folder exclusions textarea
|
||||
new Setting(containerEl)
|
||||
.setName('Excluded folders')
|
||||
.setDesc('Folder paths to exclude from orphan scan (one per line). Uses exact prefix matching.')
|
||||
.addTextArea(textarea => textarea
|
||||
.setPlaceholder('attachments/archive\ntemp')
|
||||
.setPlaceholder('Enter one folder path per line.')
|
||||
.setValue(this.plugin.settings.excludedFolders.join('\n'))
|
||||
.onChange(async (value) => {
|
||||
this.plugin.settings.excludedFolders = value
|
||||
|
|
|
|||
53
styles.css
53
styles.css
|
|
@ -1,8 +1,51 @@
|
|||
/*
|
||||
.download-image-cleanup-modal {
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
This CSS file will be included with your plugin, and
|
||||
available in the app when your plugin is enabled.
|
||||
.download-image-cleanup-modal__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
If your plugin does not need CSS, delete this file.
|
||||
.download-image-cleanup-modal__list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
*/
|
||||
.download-image-cleanup-modal__row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.download-image-cleanup-modal__label {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.download-image-cleanup-modal__name {
|
||||
flex: 2 1 0;
|
||||
}
|
||||
|
||||
.download-image-cleanup-modal__folder {
|
||||
flex: 2 1 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.download-image-cleanup-modal__size {
|
||||
flex: 1 1 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.download-image-setting-note {
|
||||
margin-top: 8px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { TFile } from 'obsidian';
|
||||
|
||||
// Track Notice constructor calls
|
||||
const noticeMessages: string[] = [];
|
||||
|
||||
// Mock obsidian module to intercept Notice constructor
|
||||
vi.mock('obsidian', async () => {
|
||||
const actual = await vi.importActual<typeof import('obsidian')>('obsidian');
|
||||
return {
|
||||
|
|
@ -18,7 +16,6 @@ vi.mock('obsidian', async () => {
|
|||
};
|
||||
});
|
||||
|
||||
// Mock scanner and modal modules
|
||||
vi.mock('../src/scanner', () => ({
|
||||
scanOrphanedAttachments: vi.fn(),
|
||||
}));
|
||||
|
|
@ -30,21 +27,19 @@ vi.mock('../src/modal', () => ({
|
|||
import { scanOrphanedAttachments } from '../src/scanner';
|
||||
import { showCleanupModal } from '../src/modal';
|
||||
import type { DownloadImageSettings } from '../src/settings';
|
||||
|
||||
// Will be implemented in main.ts
|
||||
import { executeCleanup } from '../src/main';
|
||||
|
||||
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
function makeMockApp(overrides: Record<string, unknown> = {}) {
|
||||
const fileManager = {
|
||||
trashFile: vi.fn().mockResolvedValue(undefined),
|
||||
...((overrides.fileManager as Record<string, unknown> | undefined) ?? {}),
|
||||
};
|
||||
|
||||
return {
|
||||
vault: {
|
||||
trash: vi.fn().mockResolvedValue(undefined),
|
||||
delete: vi.fn().mockResolvedValue(undefined),
|
||||
getAbstractFileByPath: vi.fn().mockReturnValue(null),
|
||||
createFolder: vi.fn().mockResolvedValue(undefined),
|
||||
...overrides,
|
||||
...((overrides.vault as Record<string, unknown> | undefined) ?? {}),
|
||||
},
|
||||
fileManager,
|
||||
} as unknown;
|
||||
}
|
||||
|
||||
|
|
@ -52,7 +47,6 @@ function makeSettings(overrides: Partial<DownloadImageSettings> = {}): DownloadI
|
|||
return {
|
||||
namingStrategy: 'original',
|
||||
concurrency: 3,
|
||||
cleanupMethod: 'trash',
|
||||
excludedFolders: [],
|
||||
...overrides,
|
||||
};
|
||||
|
|
@ -62,8 +56,6 @@ function makeFile(path: string): TFile {
|
|||
return new TFile(path, 1024);
|
||||
}
|
||||
|
||||
// ─── Tests ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('executeCleanup', () => {
|
||||
const mockScan = scanOrphanedAttachments as ReturnType<typeof vi.fn>;
|
||||
const mockModal = showCleanupModal as ReturnType<typeof vi.fn>;
|
||||
|
|
@ -105,79 +97,24 @@ describe('executeCleanup', () => {
|
|||
|
||||
await executeCleanup(app as any, settings);
|
||||
|
||||
expect((app as any).vault.trash).not.toHaveBeenCalled();
|
||||
expect((app as any).vault.delete).not.toHaveBeenCalled();
|
||||
expect((app as any).fileManager.trashFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('deletion methods', () => {
|
||||
it('calls vault.trash(file, false) for each file when cleanupMethod is trash', async () => {
|
||||
describe('deletion behavior', () => {
|
||||
it('calls fileManager.trashFile(file) for each selected file', async () => {
|
||||
const file1 = makeFile('a.png');
|
||||
const file2 = makeFile('b.jpg');
|
||||
const app = makeMockApp();
|
||||
const settings = makeSettings({ cleanupMethod: 'trash' });
|
||||
const settings = makeSettings();
|
||||
mockScan.mockResolvedValue([{ file: file1, size: 10 }, { file: file2, size: 20 }]);
|
||||
mockModal.mockResolvedValue([file1, file2]);
|
||||
|
||||
await executeCleanup(app as any, settings);
|
||||
|
||||
expect((app as any).vault.trash).toHaveBeenCalledWith(file1, false);
|
||||
expect((app as any).vault.trash).toHaveBeenCalledWith(file2, false);
|
||||
expect((app as any).vault.trash).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('calls vault.delete(file) for each file when cleanupMethod is delete', async () => {
|
||||
const file1 = makeFile('a.png');
|
||||
const file2 = makeFile('b.jpg');
|
||||
const app = makeMockApp();
|
||||
const settings = makeSettings({ cleanupMethod: 'delete' });
|
||||
mockScan.mockResolvedValue([{ file: file1, size: 10 }, { file: file2, size: 20 }]);
|
||||
mockModal.mockResolvedValue([file1, file2]);
|
||||
|
||||
await executeCleanup(app as any, settings);
|
||||
|
||||
expect((app as any).vault.delete).toHaveBeenCalledWith(file1);
|
||||
expect((app as any).vault.delete).toHaveBeenCalledWith(file2);
|
||||
expect((app as any).vault.delete).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('.trash ENOENT guard', () => {
|
||||
it('pre-creates .trash folder when missing and cleanupMethod is trash', async () => {
|
||||
const file1 = makeFile('a.png');
|
||||
const app = makeMockApp({ getAbstractFileByPath: vi.fn().mockReturnValue(null) });
|
||||
const settings = makeSettings({ cleanupMethod: 'trash' });
|
||||
mockScan.mockResolvedValue([{ file: file1, size: 10 }]);
|
||||
mockModal.mockResolvedValue([file1]);
|
||||
|
||||
await executeCleanup(app as any, settings);
|
||||
|
||||
expect((app as any).vault.getAbstractFileByPath).toHaveBeenCalledWith('.trash');
|
||||
expect((app as any).vault.createFolder).toHaveBeenCalledWith('.trash');
|
||||
});
|
||||
|
||||
it('skips .trash pre-creation when folder already exists', async () => {
|
||||
const file1 = makeFile('a.png');
|
||||
const app = makeMockApp({ getAbstractFileByPath: vi.fn().mockReturnValue({ path: '.trash' }) });
|
||||
const settings = makeSettings({ cleanupMethod: 'trash' });
|
||||
mockScan.mockResolvedValue([{ file: file1, size: 10 }]);
|
||||
mockModal.mockResolvedValue([file1]);
|
||||
|
||||
await executeCleanup(app as any, settings);
|
||||
|
||||
expect((app as any).vault.createFolder).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips .trash pre-creation when cleanupMethod is delete', async () => {
|
||||
const file1 = makeFile('a.png');
|
||||
const app = makeMockApp();
|
||||
const settings = makeSettings({ cleanupMethod: 'delete' });
|
||||
mockScan.mockResolvedValue([{ file: file1, size: 10 }]);
|
||||
mockModal.mockResolvedValue([file1]);
|
||||
|
||||
await executeCleanup(app as any, settings);
|
||||
|
||||
expect((app as any).vault.createFolder).not.toHaveBeenCalled();
|
||||
expect((app as any).fileManager.trashFile).toHaveBeenCalledWith(file1);
|
||||
expect((app as any).fileManager.trashFile).toHaveBeenCalledWith(file2);
|
||||
expect((app as any).fileManager.trashFile).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -185,24 +122,24 @@ describe('executeCleanup', () => {
|
|||
it('continues deleting after individual file failure', async () => {
|
||||
const file1 = makeFile('a.png');
|
||||
const file2 = makeFile('b.jpg');
|
||||
const trashFn = vi.fn()
|
||||
const trashFileFn = vi.fn()
|
||||
.mockRejectedValueOnce(new Error('permission denied'))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const app = makeMockApp({ trash: trashFn });
|
||||
const settings = makeSettings({ cleanupMethod: 'trash' });
|
||||
const app = makeMockApp({ fileManager: { trashFile: trashFileFn } });
|
||||
const settings = makeSettings();
|
||||
mockScan.mockResolvedValue([{ file: file1, size: 10 }, { file: file2, size: 20 }]);
|
||||
mockModal.mockResolvedValue([file1, file2]);
|
||||
|
||||
await executeCleanup(app as any, settings);
|
||||
|
||||
expect(trashFn).toHaveBeenCalledTimes(2);
|
||||
expect(trashFileFn).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('logs failed file path to console.error', async () => {
|
||||
const file1 = makeFile('attachments/broken.png');
|
||||
const trashFn = vi.fn().mockRejectedValueOnce(new Error('fail'));
|
||||
const app = makeMockApp({ trash: trashFn });
|
||||
const settings = makeSettings({ cleanupMethod: 'trash' });
|
||||
const trashFileFn = vi.fn().mockRejectedValueOnce(new Error('fail'));
|
||||
const app = makeMockApp({ fileManager: { trashFile: trashFileFn } });
|
||||
const settings = makeSettings();
|
||||
mockScan.mockResolvedValue([{ file: file1, size: 10 }]);
|
||||
mockModal.mockResolvedValue([file1]);
|
||||
|
||||
|
|
@ -223,7 +160,7 @@ describe('executeCleanup', () => {
|
|||
const file1 = makeFile('a.png');
|
||||
const file2 = makeFile('b.jpg');
|
||||
const app = makeMockApp();
|
||||
const settings = makeSettings({ cleanupMethod: 'trash' });
|
||||
const settings = makeSettings();
|
||||
mockScan.mockResolvedValue([{ file: file1, size: 10 }, { file: file2, size: 20 }]);
|
||||
mockModal.mockResolvedValue([file1, file2]);
|
||||
|
||||
|
|
@ -235,31 +172,31 @@ describe('executeCleanup', () => {
|
|||
it('shows partial failure message', async () => {
|
||||
const file1 = makeFile('a.png');
|
||||
const file2 = makeFile('b.jpg');
|
||||
const trashFn = vi.fn()
|
||||
const trashFileFn = vi.fn()
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockRejectedValueOnce(new Error('fail'));
|
||||
const app = makeMockApp({ trash: trashFn });
|
||||
const settings = makeSettings({ cleanupMethod: 'trash' });
|
||||
const app = makeMockApp({ fileManager: { trashFile: trashFileFn } });
|
||||
const settings = makeSettings();
|
||||
mockScan.mockResolvedValue([{ file: file1, size: 10 }, { file: file2, size: 20 }]);
|
||||
mockModal.mockResolvedValue([file1, file2]);
|
||||
|
||||
await executeCleanup(app as any, settings);
|
||||
|
||||
expect(noticeMessages).toContain('Cleaned 1 attachments, 1 failed (see console)');
|
||||
expect(noticeMessages).toContain('Cleaned 1 attachments, 1 failed (see debug log)');
|
||||
});
|
||||
|
||||
it('shows all-failed message', async () => {
|
||||
const file1 = makeFile('a.png');
|
||||
const file2 = makeFile('b.jpg');
|
||||
const trashFn = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
const app = makeMockApp({ trash: trashFn });
|
||||
const settings = makeSettings({ cleanupMethod: 'trash' });
|
||||
const trashFileFn = vi.fn().mockRejectedValue(new Error('fail'));
|
||||
const app = makeMockApp({ fileManager: { trashFile: trashFileFn } });
|
||||
const settings = makeSettings();
|
||||
mockScan.mockResolvedValue([{ file: file1, size: 10 }, { file: file2, size: 20 }]);
|
||||
mockModal.mockResolvedValue([file1, file2]);
|
||||
|
||||
await executeCleanup(app as any, settings);
|
||||
|
||||
expect(noticeMessages).toContain('Cleanup failed: 2 files could not be removed (see console)');
|
||||
expect(noticeMessages).toContain('Cleanup failed: 2 files could not be removed (see debug log)');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue