Files
ComfyUI-Lora-Manager/static/js/managers/BatchImportManager.js
Will Miao ee466113d5 feat: implement batch import recipe functionality (frontend + backend fixes)
Backend fixes:
- Add missing API route for /api/lm/recipes/batch-import/progress (GET)
- Add missing API route for /api/lm/recipes/batch-import/directory (POST)
- Add missing API route for /api/lm/recipes/browse-directory (POST)
- Register WebSocket endpoint for batch import progress
- Fix skip_no_metadata default value (True -> False) to allow no-LoRA imports
- Add items array to BatchImportProgress.to_dict() for detailed results

Frontend implementation:
- Create BatchImportManager.js with complete batch import workflow
- Add directory browser UI for selecting folders
- Add batch import modal with URL list and directory input modes
- Implement real-time progress tracking (WebSocket + HTTP polling)
- Add results summary with success/failed/skipped statistics
- Add expandable details view showing individual item status
- Auto-refresh recipe list after import completion

UI improvements:
- Add spinner animation for importing status
- Simplify results summary UI to match progress stats styling
- Fix current item text alignment
- Fix dark theme styling for directory browser button
- Fix batch import button styling consistency

Translations:
- Add batch import related i18n keys to all locale files
- Run sync_translation_keys.py to sync all translations

Fixes:
- Batch import now allows images without LoRAs (matches single import behavior)
- Progress endpoint now returns complete items array with status details
- Results view correctly displays skipped items with error messages
2026-03-16 09:41:58 +08:00

796 lines
26 KiB
JavaScript

import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js';
import { translate } from '../utils/i18nHelpers.js';
import { WS_ENDPOINTS } from '../api/apiConfig.js';
/**
* Manager for batch importing recipes from multiple images
*/
export class BatchImportManager {
constructor() {
this.initialized = false;
this.inputMode = 'urls'; // 'urls' or 'directory'
this.operationId = null;
this.wsConnection = null;
this.pollingInterval = null;
this.progress = null;
this.results = null;
this.isCancelled = false;
}
/**
* Show the batch import modal
*/
showModal() {
if (!this.initialized) {
this.initialize();
}
this.resetState();
modalManager.showModal('batchImportModal');
}
/**
* Initialize the manager
*/
initialize() {
this.initialized = true;
}
/**
* Reset all state to initial values
*/
resetState() {
this.inputMode = 'urls';
this.operationId = null;
this.progress = null;
this.results = null;
this.isCancelled = false;
// Reset UI
this.showStep('batchInputStep');
this.toggleInputMode('urls');
// Clear inputs
const urlInput = document.getElementById('batchUrlInput');
if (urlInput) urlInput.value = '';
const directoryInput = document.getElementById('batchDirectoryInput');
if (directoryInput) directoryInput.value = '';
const tagsInput = document.getElementById('batchTagsInput');
if (tagsInput) tagsInput.value = '';
const skipNoMetadata = document.getElementById('batchSkipNoMetadata');
if (skipNoMetadata) skipNoMetadata.checked = true;
const recursiveCheck = document.getElementById('batchRecursiveCheck');
if (recursiveCheck) recursiveCheck.checked = true;
// Reset progress UI
this.updateProgressUI({
total: 0,
completed: 0,
success: 0,
failed: 0,
skipped: 0,
progress_percent: 0,
current_item: '',
status: 'pending'
});
// Reset results
const detailsList = document.getElementById('batchDetailsList');
if (detailsList) {
detailsList.innerHTML = '';
detailsList.style.display = 'none';
}
const toggleIcon = document.getElementById('resultsToggleIcon');
if (toggleIcon) {
toggleIcon.classList.remove('expanded');
}
// Clean up any existing connections
this.cleanupConnections();
}
/**
* Show a specific step in the modal
*/
showStep(stepId) {
document.querySelectorAll('.batch-import-step').forEach(step => {
step.style.display = 'none';
});
const step = document.getElementById(stepId);
if (step) {
step.style.display = 'block';
}
}
/**
* Toggle between URL list and directory input modes
*/
toggleInputMode(mode) {
this.inputMode = mode;
// Update toggle buttons
document.querySelectorAll('.toggle-btn[data-mode]').forEach(btn => {
btn.classList.remove('active');
});
const activeBtn = document.querySelector(`.toggle-btn[data-mode="${mode}"]`);
if (activeBtn) {
activeBtn.classList.add('active');
}
// Show/hide appropriate sections
const urlSection = document.getElementById('urlListSection');
const directorySection = document.getElementById('directorySection');
if (urlSection && directorySection) {
if (mode === 'urls') {
urlSection.style.display = 'block';
directorySection.style.display = 'none';
} else {
urlSection.style.display = 'none';
directorySection.style.display = 'block';
}
}
}
/**
* Start the batch import process
*/
async startImport() {
const data = this.collectInputData();
if (!this.validateInput(data)) {
return;
}
try {
// Show progress step
this.showStep('batchProgressStep');
// Start the import
const response = await this.sendStartRequest(data);
if (response.success) {
this.operationId = response.operation_id;
this.isCancelled = false;
// Connect to WebSocket for real-time updates
this.connectWebSocket();
// Start polling as fallback
this.startPolling();
} else {
showToast('toast.recipes.batchImportFailed', { message: response.error }, 'error');
this.showStep('batchInputStep');
}
} catch (error) {
console.error('Error starting batch import:', error);
showToast('toast.recipes.batchImportFailed', { message: error.message }, 'error');
this.showStep('batchInputStep');
}
}
/**
* Collect input data from the form
*/
collectInputData() {
const data = {
mode: this.inputMode,
tags: [],
skip_no_metadata: false
};
// Collect tags
const tagsInput = document.getElementById('batchTagsInput');
if (tagsInput && tagsInput.value.trim()) {
data.tags = tagsInput.value.split(',').map(t => t.trim()).filter(t => t);
}
// Collect skip_no_metadata
const skipNoMetadata = document.getElementById('batchSkipNoMetadata');
if (skipNoMetadata) {
data.skip_no_metadata = skipNoMetadata.checked;
}
if (this.inputMode === 'urls') {
const urlInput = document.getElementById('batchUrlInput');
if (urlInput) {
const urls = urlInput.value.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
// Convert to items format
data.items = urls.map(url => ({
source: url,
type: this.detectUrlType(url)
}));
}
} else {
const directoryInput = document.getElementById('batchDirectoryInput');
if (directoryInput) {
data.directory = directoryInput.value.trim();
}
const recursiveCheck = document.getElementById('batchRecursiveCheck');
if (recursiveCheck) {
data.recursive = recursiveCheck.checked;
}
}
return data;
}
/**
* Detect if a URL is http or local path
*/
detectUrlType(url) {
if (url.startsWith('http://') || url.startsWith('https://')) {
return 'url';
}
return 'local_path';
}
/**
* Validate the input data
*/
validateInput(data) {
if (data.mode === 'urls') {
if (!data.items || data.items.length === 0) {
showToast('toast.recipes.batchImportNoUrls', {}, 'error');
return false;
}
} else {
if (!data.directory) {
showToast('toast.recipes.batchImportNoDirectory', {}, 'error');
return false;
}
}
return true;
}
/**
* Send the start batch import request
*/
async sendStartRequest(data) {
const endpoint = data.mode === 'urls'
? '/api/lm/recipes/batch-import/start'
: '/api/lm/recipes/batch-import/directory';
const response = await fetch(endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(data)
});
return await response.json();
}
/**
* Connect to WebSocket for real-time progress updates
*/
connectWebSocket() {
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${wsProtocol}//${window.location.host}/ws/batch-import-progress?id=${this.operationId}`;
this.wsConnection = new WebSocket(wsUrl);
this.wsConnection.onopen = () => {
console.log('Connected to batch import progress WebSocket');
};
this.wsConnection.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'batch_import_progress') {
this.handleProgressUpdate(data);
}
} catch (error) {
console.error('Error parsing WebSocket message:', error);
}
};
this.wsConnection.onerror = (error) => {
console.error('WebSocket error:', error);
};
this.wsConnection.onclose = () => {
console.log('WebSocket connection closed');
};
}
/**
* Start polling for progress updates (fallback)
*/
startPolling() {
this.pollingInterval = setInterval(async () => {
if (!this.operationId || this.isCancelled) {
return;
}
try {
const response = await fetch(`/api/lm/recipes/batch-import/progress?operation_id=${this.operationId}`);
const data = await response.json();
if (data.success && data.progress) {
this.handleProgressUpdate(data.progress);
}
} catch (error) {
console.error('Error polling progress:', error);
}
}, 1000);
}
/**
* Handle progress update from WebSocket or polling
*/
handleProgressUpdate(progress) {
this.progress = progress;
this.updateProgressUI(progress);
// Check if import is complete
if (progress.status === 'completed' || progress.status === 'cancelled' ||
(progress.total > 0 && progress.completed >= progress.total)) {
this.importComplete(progress);
}
}
/**
* Update the progress UI
*/
updateProgressUI(progress) {
// Update progress bar
const progressBar = document.getElementById('batchProgressBar');
if (progressBar) {
progressBar.style.width = `${progress.progress_percent || 0}%`;
}
// Update percentage
const progressPercent = document.getElementById('batchProgressPercent');
if (progressPercent) {
progressPercent.textContent = `${Math.round(progress.progress_percent || 0)}%`;
}
// Update stats
const totalCount = document.getElementById('batchTotalCount');
if (totalCount) totalCount.textContent = progress.total || 0;
const successCount = document.getElementById('batchSuccessCount');
if (successCount) successCount.textContent = progress.success || 0;
const failedCount = document.getElementById('batchFailedCount');
if (failedCount) failedCount.textContent = progress.failed || 0;
const skippedCount = document.getElementById('batchSkippedCount');
if (skippedCount) skippedCount.textContent = progress.skipped || 0;
// Update current item
const currentItem = document.getElementById('batchCurrentItem');
if (currentItem) {
currentItem.textContent = progress.current_item || '-';
}
// Update status text
const statusText = document.getElementById('batchStatusText');
if (statusText) {
if (progress.status === 'running') {
statusText.textContent = translate('recipes.batchImport.importing', {}, 'Importing...');
} else if (progress.status === 'completed') {
statusText.textContent = translate('recipes.batchImport.completed', {}, 'Import completed');
} else if (progress.status === 'cancelled') {
statusText.textContent = translate('recipes.batchImport.cancelled', {}, 'Import cancelled');
}
}
// Update container classes
const progressContainer = document.querySelector('.batch-progress-container');
if (progressContainer) {
progressContainer.classList.remove('completed', 'cancelled', 'error');
if (progress.status === 'completed') {
progressContainer.classList.add('completed');
} else if (progress.status === 'cancelled') {
progressContainer.classList.add('cancelled');
} else if (progress.failed > 0 && progress.failed === progress.total) {
progressContainer.classList.add('error');
}
}
}
/**
* Handle import completion
*/
importComplete(progress) {
this.cleanupConnections();
this.results = progress;
// Refresh recipes list to show newly imported recipes
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
window.recipeManager.loadRecipes();
}
// Show results step
this.showStep('batchResultsStep');
this.updateResultsUI(progress);
}
/**
* Update the results UI
*/
updateResultsUI(progress) {
// Update summary cards
const resultsTotal = document.getElementById('resultsTotal');
if (resultsTotal) resultsTotal.textContent = progress.total || 0;
const resultsSuccess = document.getElementById('resultsSuccess');
if (resultsSuccess) resultsSuccess.textContent = progress.success || 0;
const resultsFailed = document.getElementById('resultsFailed');
if (resultsFailed) resultsFailed.textContent = progress.failed || 0;
const resultsSkipped = document.getElementById('resultsSkipped');
if (resultsSkipped) resultsSkipped.textContent = progress.skipped || 0;
// Update header based on results
const resultsHeader = document.getElementById('batchResultsHeader');
if (resultsHeader) {
const icon = resultsHeader.querySelector('.results-icon i');
const title = resultsHeader.querySelector('.results-title');
if (this.isCancelled) {
if (icon) {
icon.className = 'fas fa-stop-circle';
icon.parentElement.classList.add('warning');
}
if (title) title.textContent = translate('recipes.batchImport.cancelled', {}, 'Import cancelled');
} else if (progress.failed === 0 && progress.success > 0) {
if (icon) {
icon.className = 'fas fa-check-circle';
icon.parentElement.classList.remove('warning', 'error');
}
if (title) title.textContent = translate('recipes.batchImport.completed', {}, 'Import completed');
} else if (progress.failed > 0 && progress.success === 0) {
if (icon) {
icon.className = 'fas fa-times-circle';
icon.parentElement.classList.add('error');
}
if (title) title.textContent = translate('recipes.batchImport.failed', {}, 'Import failed');
} else {
if (icon) {
icon.className = 'fas fa-exclamation-circle';
icon.parentElement.classList.add('warning');
}
if (title) title.textContent = translate('recipes.batchImport.completedWithErrors', {}, 'Completed with errors');
}
}
}
/**
* Toggle the results details visibility
*/
toggleResultsDetails() {
const detailsList = document.getElementById('batchDetailsList');
const toggleIcon = document.getElementById('resultsToggleIcon');
const toggle = document.querySelector('.details-toggle');
if (detailsList && toggleIcon) {
if (detailsList.style.display === 'none') {
detailsList.style.display = 'block';
toggleIcon.classList.add('expanded');
if (toggle) toggle.classList.add('expanded');
// Load details if not loaded
if (detailsList.children.length === 0 && this.results && this.results.items) {
this.loadResultsDetails(this.results.items);
}
} else {
detailsList.style.display = 'none';
toggleIcon.classList.remove('expanded');
if (toggle) toggle.classList.remove('expanded');
}
}
}
/**
* Load results details into the list
*/
loadResultsDetails(items) {
const detailsList = document.getElementById('batchDetailsList');
if (!detailsList) return;
detailsList.innerHTML = '';
items.forEach(item => {
const resultItem = document.createElement('div');
resultItem.className = 'result-item';
const statusClass = item.status === 'success' ? 'success' :
item.status === 'failed' ? 'failed' : 'skipped';
const statusIcon = item.status === 'success' ? 'check' :
item.status === 'failed' ? 'times' : 'forward';
resultItem.innerHTML = `
<div class="result-item-status ${statusClass}">
<i class="fas fa-${statusIcon}"></i>
</div>
<div class="result-item-info">
<div class="result-item-name">${this.escapeHtml(item.source || item.current_item || 'Unknown')}</div>
${item.error_message ? `<div class="result-item-error">${this.escapeHtml(item.error_message)}</div>` : ''}
</div>
`;
detailsList.appendChild(resultItem);
});
}
/**
* Cancel the current import
*/
async cancelImport() {
if (!this.operationId) return;
this.isCancelled = true;
try {
const response = await fetch('/api/lm/recipes/batch-import/cancel', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ operation_id: this.operationId })
});
const data = await response.json();
if (data.success) {
showToast('toast.recipes.batchImportCancelling', {}, 'info');
} else {
showToast('toast.recipes.batchImportCancelFailed', { message: data.error }, 'error');
}
} catch (error) {
console.error('Error cancelling import:', error);
showToast('toast.recipes.batchImportCancelFailed', { message: error.message }, 'error');
}
}
/**
* Close modal and reset state
*/
closeAndReset() {
this.cleanupConnections();
this.resetState();
modalManager.closeModal('batchImportModal');
}
/**
* Start a new import (from results step)
*/
startNewImport() {
this.resetState();
this.showStep('batchInputStep');
}
/**
* Toggle directory browser visibility
*/
toggleDirectoryBrowser() {
const browser = document.getElementById('batchDirectoryBrowser');
if (browser) {
const isVisible = browser.style.display !== 'none';
browser.style.display = isVisible ? 'none' : 'block';
if (!isVisible) {
// Load initial directory when opening
const currentPath = document.getElementById('batchDirectoryInput').value;
this.loadDirectory(currentPath || '/');
}
}
}
/**
* Load directory contents
*/
async loadDirectory(path) {
try {
const response = await fetch('/api/lm/recipes/browse-directory', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ path })
});
const data = await response.json();
if (data.success) {
this.renderDirectoryBrowser(data);
} else {
showToast('toast.recipes.batchImportBrowseFailed', { message: data.error }, 'error');
}
} catch (error) {
console.error('Error loading directory:', error);
showToast('toast.recipes.batchImportBrowseFailed', { message: error.message }, 'error');
}
}
/**
* Render directory browser UI
*/
renderDirectoryBrowser(data) {
const currentPathEl = document.getElementById('batchCurrentPath');
const folderList = document.getElementById('batchFolderList');
const fileList = document.getElementById('batchFileList');
const directoryCount = document.getElementById('batchDirectoryCount');
const imageCount = document.getElementById('batchImageCount');
if (currentPathEl) {
currentPathEl.textContent = data.current_path;
}
// Render folders
if (folderList) {
folderList.innerHTML = '';
// Add parent directory if available
if (data.parent_path) {
const parentItem = this.createFolderItem('..', data.parent_path, true);
folderList.appendChild(parentItem);
}
data.directories.forEach(dir => {
folderList.appendChild(this.createFolderItem(dir.name, dir.path));
});
}
// Render files
if (fileList) {
fileList.innerHTML = '';
data.image_files.forEach(file => {
fileList.appendChild(this.createFileItem(file.name, file.path, file.size));
});
}
// Update stats
if (directoryCount) {
directoryCount.textContent = data.directory_count;
}
if (imageCount) {
imageCount.textContent = data.image_count;
}
}
/**
* Create folder item element
*/
createFolderItem(name, path, isParent = false) {
const item = document.createElement('div');
item.className = 'folder-item';
item.dataset.path = path;
item.innerHTML = `
<i class="fas fa-folder${isParent ? '' : ''}"></i>
<span class="item-name">${this.escapeHtml(name)}</span>
`;
item.addEventListener('click', () => {
if (isParent) {
this.navigateToParentDirectory();
} else {
this.loadDirectory(path);
}
});
return item;
}
/**
* Create file item element
*/
createFileItem(name, path, size) {
const item = document.createElement('div');
item.className = 'file-item';
item.dataset.path = path;
item.innerHTML = `
<i class="fas fa-image"></i>
<span class="item-name">${this.escapeHtml(name)}</span>
<span class="item-size">${this.formatFileSize(size)}</span>
`;
return item;
}
/**
* Navigate to parent directory
*/
navigateToParentDirectory() {
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
if (currentPath) {
// Get parent path using path manipulation
const lastSeparator = currentPath.lastIndexOf('/');
const parentPath = lastSeparator > 0 ? currentPath.substring(0, lastSeparator) : currentPath;
this.loadDirectory(parentPath);
}
}
/**
* Select current directory
*/
selectCurrentDirectory() {
const currentPath = document.getElementById('batchCurrentPath')?.textContent;
const directoryInput = document.getElementById('batchDirectoryInput');
if (currentPath && directoryInput) {
directoryInput.value = currentPath;
this.toggleDirectoryBrowser(); // Close browser
showToast('toast.recipes.batchImportDirectorySelected', { path: currentPath }, 'success');
}
}
/**
* Format file size for display
*/
formatFileSize(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 10) / 10 + ' ' + sizes[i];
}
/**
* Escape HTML to prevent XSS
*/
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
/**
* Browse for directory using File System Access API (deprecated - kept for compatibility)
*/
async browseDirectory() {
// Now redirects to the new directory browser
this.toggleDirectoryBrowser();
}
/**
* Clean up WebSocket and polling connections
*/
cleanupConnections() {
if (this.wsConnection) {
if (this.wsConnection.readyState === WebSocket.OPEN ||
this.wsConnection.readyState === WebSocket.CONNECTING) {
this.wsConnection.close();
}
this.wsConnection = null;
}
if (this.pollingInterval) {
clearInterval(this.pollingInterval);
this.pollingInterval = null;
}
}
/**
* Escape HTML to prevent XSS
*/
escapeHtml(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
}
// Create singleton instance
export const batchImportManager = new BatchImportManager();