Fix Obsidian review issues

This commit is contained in:
yinshaohua 2026-05-01 00:19:09 +08:00
parent 62a181bb96
commit 4acff65632
8 changed files with 144 additions and 221 deletions

View file

@ -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 Obsidians standard trash behavior.
## Installation for users

View file

@ -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",
},
},
{

View file

@ -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()}`;
}

View file

@ -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.');
}
}
});

View file

@ -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();

View file

@ -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 Obsidians 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

View file

@ -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);
}

View file

@ -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)');
});
});
});