mirror of
https://github.com/lossless-group/image-gin.git
synced 2026-07-22 06:49:33 +00:00
ObsidianReviewBot's `prefer-file-manager-trash-file` is the optional finding it surfaces against image-gin: `Vault.delete()` ignores the user's "Files & links → Move to system trash / Obsidian trash / delete permanently" preference, while `FileManager.trashFile()` respects it. Three sites, all behind explicit user actions (settings toggle or the Clear Cache button), so the deletion-preference is exactly the contract we want to honor. - BatchDirectoryConvertLocalToRemote.ts:449 — local file removal after a successful ImageKit upload, gated by `imageKit.removeLocalFiles` setting. - imageCacheService.ts:156, 164 — `clearCache()` removing the configured cache folder (either as a single TFile or as the contents of a folder). After this commit `pnpm exec eslint .` reports 0 errors and 0 warnings against the obsidianmd recommended config. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
493 lines
No EOL
17 KiB
TypeScript
493 lines
No EOL
17 KiB
TypeScript
import { logger } from '../utils/logger';
|
|
import type { App } from 'obsidian';
|
|
import { Modal, Setting, Notice, TFile } from 'obsidian';
|
|
import type ImageGinPlugin from '../../main';
|
|
import { ImageKitService } from '../services/imagekitService';
|
|
import { dirname, basename } from 'path';
|
|
|
|
interface LocalImageReference {
|
|
filePath: string;
|
|
fileName: string;
|
|
imagePath: string;
|
|
imageName: string;
|
|
fullMatch: string;
|
|
lineNumber: number;
|
|
selected: boolean;
|
|
}
|
|
|
|
export class BatchDirectoryConvertLocalToRemote extends Modal {
|
|
private plugin: ImageGinPlugin;
|
|
private currentDirectory: string = '';
|
|
private localImages: LocalImageReference[] = [];
|
|
private isScanning: boolean = false;
|
|
private isConverting: boolean = false;
|
|
private progressEl: HTMLElement | null = null;
|
|
private resultsEl: HTMLElement | null = null;
|
|
|
|
private selectAllCheckbox: HTMLInputElement | null = null;
|
|
|
|
constructor(app: App, plugin: ImageGinPlugin) {
|
|
super(app);
|
|
this.plugin = plugin;
|
|
this.initializeCurrentDirectory();
|
|
}
|
|
|
|
private initializeCurrentDirectory(): void {
|
|
const activeFile = this.app.workspace.getActiveFile();
|
|
if (activeFile) {
|
|
// Get the directory of the current file
|
|
const filePath = activeFile.path;
|
|
const fileDir = dirname(filePath);
|
|
this.currentDirectory = fileDir === '.' ? '' : fileDir;
|
|
} else {
|
|
this.currentDirectory = '';
|
|
}
|
|
}
|
|
|
|
onOpen(): void {
|
|
const { contentEl, modalEl } = this;
|
|
contentEl.empty();
|
|
modalEl.addClass('batch-directory-modal');
|
|
|
|
// Check if ImageKit is enabled
|
|
if (!this.plugin.settings.imageKit.enabled) {
|
|
this.renderImageKitDisabledMessage(contentEl);
|
|
return;
|
|
}
|
|
|
|
this.renderModalContent(contentEl);
|
|
}
|
|
|
|
private renderImageKitDisabledMessage(containerEl: HTMLElement): void {
|
|
const section = containerEl.createDiv('modal-section');
|
|
section.createEl('h3', { text: 'ImageKit not enabled' });
|
|
|
|
const content = section.createDiv();
|
|
content.createEl('p', {
|
|
text: 'Please enable ImageKit CDN in the plugin settings before using this feature.'
|
|
});
|
|
}
|
|
|
|
private renderModalContent(containerEl: HTMLElement): void {
|
|
// Header
|
|
const headerEl = containerEl.createDiv('image-gin-header');
|
|
headerEl.createEl('h2', {
|
|
text: 'Batch convert local images to remote',
|
|
cls: 'image-gin-title'
|
|
});
|
|
|
|
// Directory Selection Section
|
|
this.renderDirectorySection(containerEl);
|
|
|
|
// Search Section
|
|
this.renderSearchSection(containerEl);
|
|
|
|
// Results Section
|
|
this.renderResultsSection(containerEl);
|
|
|
|
// Progress Section
|
|
this.renderProgressSection(containerEl);
|
|
|
|
// Convert Button Section
|
|
this.renderConvertSection(containerEl);
|
|
}
|
|
|
|
private renderDirectorySection(containerEl: HTMLElement): void {
|
|
const section = containerEl.createDiv('modal-section');
|
|
section.createEl('h3', { text: 'Directory selection' });
|
|
|
|
const content = section.createDiv();
|
|
|
|
new Setting(content)
|
|
.setName('Directory path')
|
|
.setDesc('Current directory to scan for markdown files with local images')
|
|
.addText(text => {
|
|
text.setValue(this.currentDirectory)
|
|
.setPlaceholder('Enter directory path or leave empty for vault root')
|
|
.onChange((value) => {
|
|
this.currentDirectory = value;
|
|
});
|
|
});
|
|
}
|
|
|
|
private renderSearchSection(containerEl: HTMLElement): void {
|
|
const section = containerEl.createDiv('modal-section');
|
|
section.createEl('h3', { text: 'Search for local images' });
|
|
|
|
const content = section.createDiv();
|
|
|
|
const searchBtn = content.createEl('button', {
|
|
text: 'Search for local images',
|
|
cls: 'image-gin-button'
|
|
});
|
|
|
|
searchBtn.addEventListener('click', () => {
|
|
void this.handleSearch();
|
|
});
|
|
}
|
|
|
|
private renderResultsSection(containerEl: HTMLElement): void {
|
|
this.resultsEl = containerEl.createDiv('modal-section');
|
|
this.resultsEl.addClass('image-gin-hidden');
|
|
|
|
// Create header with justified layout
|
|
const headerContainer = this.resultsEl.createDiv('flex-row');
|
|
headerContainer.addClass('image-gin-row-with-margin');
|
|
|
|
headerContainer.createEl('h3', { text: 'Found local images' });
|
|
|
|
// Select All checkbox in header
|
|
const selectAllLabel = headerContainer.createEl('label', {
|
|
cls: 'flex-row',
|
|
attr: { style: 'display: flex; align-items: center; gap: 0.5rem; font-weight: 600;' }
|
|
});
|
|
|
|
this.selectAllCheckbox = selectAllLabel.createEl('input', {
|
|
type: 'checkbox',
|
|
attr: { checked: 'true' }
|
|
});
|
|
|
|
selectAllLabel.createSpan({ text: 'Select All' });
|
|
|
|
this.selectAllCheckbox.addEventListener('change', () => {
|
|
const isChecked = this.selectAllCheckbox?.checked || false;
|
|
this.localImages.forEach(img => img.selected = isChecked);
|
|
this.updateImageCheckboxes(isChecked);
|
|
});
|
|
}
|
|
|
|
private renderProgressSection(containerEl: HTMLElement): void {
|
|
this.progressEl = containerEl.createDiv('image-gin-progress');
|
|
this.progressEl.addClass('image-gin-hidden');
|
|
|
|
this.progressEl.createEl('p', {
|
|
text: 'Processing...',
|
|
cls: 'image-gin-progress-text'
|
|
});
|
|
}
|
|
|
|
private renderConvertSection(containerEl: HTMLElement): void {
|
|
const section = containerEl.createDiv('modal-section');
|
|
section.createEl('h3', { text: 'Convert images' });
|
|
|
|
const content = section.createDiv();
|
|
|
|
const convertBtn = content.createEl('button', {
|
|
text: 'Convert images for directory',
|
|
cls: 'image-gin-button'
|
|
});
|
|
|
|
convertBtn.addEventListener('click', () => {
|
|
void this.handleConvert();
|
|
});
|
|
}
|
|
|
|
private async handleSearch(): Promise<void> {
|
|
if (this.isScanning) return;
|
|
|
|
this.isScanning = true;
|
|
this.showProgress('Scanning directory for local images...');
|
|
this.localImages = [];
|
|
|
|
try {
|
|
await this.scanDirectoryForLocalImages();
|
|
this.renderImageResults();
|
|
|
|
if (this.localImages.length === 0) {
|
|
new Notice('No local images found in the specified directory');
|
|
} else {
|
|
new Notice(`Found ${this.localImages.length} local image references`);
|
|
}
|
|
} catch (error) {
|
|
logger.error('Error scanning directory:', error);
|
|
new Notice(`Error scanning directory: ${this.getErrorMessage(error)}`);
|
|
} finally {
|
|
this.isScanning = false;
|
|
this.hideProgress();
|
|
}
|
|
}
|
|
|
|
private async scanDirectoryForLocalImages(): Promise<void> {
|
|
const vault = this.app.vault;
|
|
const files = vault.getMarkdownFiles();
|
|
|
|
// Filter files by directory if specified
|
|
const targetFiles = this.currentDirectory
|
|
? files.filter(file => file.path.startsWith(this.currentDirectory))
|
|
: files;
|
|
|
|
for (const file of targetFiles) {
|
|
try {
|
|
const content = await vault.read(file);
|
|
const localImageRefs = this.extractLocalImageReferences(content, file.path);
|
|
this.localImages.push(...localImageRefs);
|
|
} catch (error) {
|
|
logger.error(`Error reading file ${file.path}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
private extractLocalImageReferences(content: string, filePath: string): LocalImageReference[] {
|
|
const references: LocalImageReference[] = [];
|
|
const lines = content.split('\n');
|
|
|
|
// Regex to match ![[path/to/image]] syntax
|
|
const localImageRegex = /!\[\[([^\]]+)\]\]/g;
|
|
|
|
lines.forEach((line, index) => {
|
|
let match;
|
|
while ((match = localImageRegex.exec(line)) !== null) {
|
|
const fullMatch = match[0];
|
|
const imagePath = match[1];
|
|
|
|
// Check if this is a local image (not a URL)
|
|
if (!this.isRemoteUrl(imagePath ?? '')) {
|
|
references.push({
|
|
filePath: filePath,
|
|
fileName: basename(filePath),
|
|
imagePath: imagePath ?? '',
|
|
imageName: basename(imagePath ?? ''),
|
|
fullMatch: fullMatch,
|
|
lineNumber: index + 1,
|
|
selected: true // Default to selected
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
return references;
|
|
}
|
|
|
|
private isRemoteUrl(path: string): boolean {
|
|
return path.startsWith('http://') || path.startsWith('https://') || path.startsWith('//');
|
|
}
|
|
|
|
private renderImageResults(): void {
|
|
if (!this.resultsEl) return;
|
|
|
|
// Clear previous results (but keep the header)
|
|
const existingContent = this.resultsEl.querySelector('.setting-item');
|
|
if (existingContent) {
|
|
existingContent.remove();
|
|
}
|
|
|
|
if (this.localImages.length === 0) {
|
|
this.resultsEl.addClass('image-gin-hidden');
|
|
return;
|
|
}
|
|
|
|
this.resultsEl.removeClass('image-gin-hidden');
|
|
const content = this.resultsEl.createDiv('setting-item');
|
|
|
|
// Images table (Select All checkbox is now in the header)
|
|
const table = content.createEl('table', {
|
|
cls: 'image-results-table',
|
|
attr: { style: 'width: 100%; border-collapse: collapse;' }
|
|
});
|
|
|
|
// Table header
|
|
const thead = table.createEl('thead');
|
|
const headerRow = thead.createEl('tr');
|
|
headerRow.createEl('th', { text: 'Select', attr: { style: 'width: 60px; padding: 0.5rem; border: 1px solid var(--background-modifier-border); background: var(--background-secondary);' } });
|
|
headerRow.createEl('th', { text: 'File', attr: { style: 'padding: 0.5rem; border: 1px solid var(--background-modifier-border); background: var(--background-secondary);' } });
|
|
headerRow.createEl('th', { text: 'Image', attr: { style: 'padding: 0.5rem; border: 1px solid var(--background-modifier-border); background: var(--background-secondary);' } });
|
|
|
|
// Table body
|
|
const tbody = table.createEl('tbody');
|
|
|
|
this.localImages.forEach((imageRef, index) => {
|
|
const row = tbody.createEl('tr');
|
|
|
|
// Checkbox cell
|
|
const checkboxCell = row.createEl('td', {
|
|
attr: { style: 'padding: 0.5rem; border: 1px solid var(--background-modifier-border); text-align: center;' }
|
|
});
|
|
|
|
const checkbox = checkboxCell.createEl('input', {
|
|
type: 'checkbox',
|
|
attr: {
|
|
checked: imageRef.selected ? 'true' : '',
|
|
'data-index': index.toString()
|
|
}
|
|
});
|
|
|
|
checkbox.addEventListener('change', () => {
|
|
const currentImageRef = this.localImages[index];
|
|
if (currentImageRef) {
|
|
currentImageRef.selected = checkbox.checked;
|
|
this.updateSelectAllCheckbox();
|
|
}
|
|
});
|
|
|
|
// File cell
|
|
row.createEl('td', {
|
|
text: imageRef.fileName,
|
|
attr: { style: 'padding: 0.5rem; border: 1px solid var(--background-modifier-border);' }
|
|
});
|
|
|
|
// Image cell
|
|
row.createEl('td', {
|
|
text: imageRef.imageName,
|
|
attr: { style: 'padding: 0.5rem; border: 1px solid var(--background-modifier-border);' }
|
|
});
|
|
});
|
|
}
|
|
|
|
private updateImageCheckboxes(checked: boolean): void {
|
|
if (!this.resultsEl) return;
|
|
const checkboxes = this.resultsEl.querySelectorAll('input[type="checkbox"][data-index]');
|
|
checkboxes.forEach((checkbox) => {
|
|
(checkbox as HTMLInputElement).checked = checked;
|
|
});
|
|
}
|
|
|
|
private updateSelectAllCheckbox(): void {
|
|
if (!this.selectAllCheckbox) return;
|
|
|
|
const selectedCount = this.localImages.filter(img => img.selected).length;
|
|
const totalCount = this.localImages.length;
|
|
|
|
if (selectedCount === 0) {
|
|
this.selectAllCheckbox.checked = false;
|
|
this.selectAllCheckbox.indeterminate = false;
|
|
} else if (selectedCount === totalCount) {
|
|
this.selectAllCheckbox.checked = true;
|
|
this.selectAllCheckbox.indeterminate = false;
|
|
} else {
|
|
this.selectAllCheckbox.checked = false;
|
|
this.selectAllCheckbox.indeterminate = true;
|
|
}
|
|
}
|
|
|
|
private async handleConvert(): Promise<void> {
|
|
if (this.isConverting) return;
|
|
|
|
const selectedImages = this.localImages.filter(img => img.selected);
|
|
if (selectedImages.length === 0) {
|
|
new Notice('Please select at least one image to convert');
|
|
return;
|
|
}
|
|
|
|
this.isConverting = true;
|
|
this.showProgress(`Converting ${selectedImages.length} images...`);
|
|
|
|
try {
|
|
const imageKitService = new ImageKitService(this.plugin.settings);
|
|
let successCount = 0;
|
|
let errorCount = 0;
|
|
|
|
for (let i = 0; i < selectedImages.length; i++) {
|
|
const imageRef = selectedImages[i];
|
|
|
|
if (!imageRef) continue;
|
|
|
|
try {
|
|
this.updateProgress(`Converting ${i + 1}/${selectedImages.length}: ${imageRef.imageName}`);
|
|
|
|
await this.convertSingleImage(imageRef, imageKitService);
|
|
successCount++;
|
|
} catch (error) {
|
|
logger.error(`Error converting ${imageRef.imagePath}:`, error);
|
|
errorCount++;
|
|
}
|
|
}
|
|
|
|
const message = `Conversion completed: ${successCount} successful, ${errorCount} failed`;
|
|
new Notice(message);
|
|
|
|
if (successCount > 0) {
|
|
// Refresh the search results to show updated state
|
|
await this.handleSearch();
|
|
}
|
|
|
|
} catch (error) {
|
|
logger.error('Error in batch conversion:', error);
|
|
new Notice(`Error during conversion: ${this.getErrorMessage(error)}`);
|
|
} finally {
|
|
this.isConverting = false;
|
|
this.hideProgress();
|
|
}
|
|
}
|
|
|
|
private async convertSingleImage(
|
|
imageRef: LocalImageReference,
|
|
imageKitService: ImageKitService
|
|
): Promise<void> {
|
|
// Get the file from vault
|
|
const file = this.app.vault.getAbstractFileByPath(imageRef.filePath);
|
|
if (!(file instanceof TFile)) {
|
|
throw new Error(`File not found: ${imageRef.filePath}`);
|
|
}
|
|
|
|
// Read the image file
|
|
const imageFile = this.app.vault.getAbstractFileByPath(imageRef.imagePath);
|
|
if (!(imageFile instanceof TFile)) {
|
|
throw new Error(`Image file not found: ${imageRef.imagePath}`);
|
|
}
|
|
|
|
// Upload to ImageKit
|
|
const imageBuffer = await this.app.vault.readBinary(imageFile);
|
|
const uploadFolder = this.plugin.settings.imageKit.uploadFolder || '';
|
|
const uploadResult = await imageKitService.uploadFile(
|
|
imageBuffer,
|
|
imageRef.imageName,
|
|
uploadFolder
|
|
);
|
|
|
|
// Replace the local reference with remote URL in the markdown file
|
|
const content = await this.app.vault.read(file);
|
|
const newContent = content.replace(
|
|
imageRef.fullMatch,
|
|
``
|
|
);
|
|
|
|
await this.app.vault.modify(file, newContent);
|
|
|
|
// Optionally remove local file if setting is enabled
|
|
if (this.plugin.settings.imageKit.removeLocalFiles) {
|
|
try {
|
|
await this.app.fileManager.trashFile(imageFile);
|
|
} catch (error) {
|
|
logger.warn(`Could not delete local file ${imageRef.imagePath}:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
private showProgress(message: string): void {
|
|
if (this.progressEl) {
|
|
this.progressEl.removeClass('image-gin-hidden');
|
|
const textEl = this.progressEl.querySelector('.image-gin-progress-text');
|
|
if (textEl) {
|
|
textEl.textContent = message;
|
|
}
|
|
}
|
|
}
|
|
|
|
private updateProgress(message: string): void {
|
|
if (this.progressEl) {
|
|
const textEl = this.progressEl.querySelector('.image-gin-progress-text');
|
|
if (textEl) {
|
|
textEl.textContent = message;
|
|
}
|
|
}
|
|
}
|
|
|
|
private hideProgress(): void {
|
|
if (this.progressEl) {
|
|
this.progressEl.addClass('image-gin-hidden');
|
|
}
|
|
}
|
|
|
|
private getErrorMessage(error: unknown): string {
|
|
if (error instanceof Error) {
|
|
return error.message;
|
|
}
|
|
return String(error);
|
|
}
|
|
|
|
onClose(): void {
|
|
// Clean up any ongoing operations
|
|
this.isScanning = false;
|
|
this.isConverting = false;
|
|
}
|
|
} |