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
This commit is contained in:
Will Miao
2026-03-14 21:17:36 +08:00
parent f86651652c
commit ee466113d5
24 changed files with 2791 additions and 145 deletions

View File

@@ -0,0 +1,677 @@
/* Batch Import Modal Styles */
/* Step Containers */
.batch-import-step {
margin: var(--space-2) 0;
}
/* Section Description */
.section-description {
color: var(--text-color);
opacity: 0.8;
margin-bottom: var(--space-2);
font-size: 0.95em;
}
/* Hint Text */
.input-hint {
display: flex;
align-items: center;
gap: 6px;
color: var(--text-color);
opacity: 0.7;
font-size: 0.85em;
margin-top: 6px;
}
.input-hint i {
color: var(--lora-accent);
}
/* Textarea Styling */
#batchUrlInput {
width: 100%;
min-height: 120px;
padding: 12px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--bg-color);
color: var(--text-color);
font-family: inherit;
font-size: 0.9em;
resize: vertical;
transition: border-color 0.2s, box-shadow 0.2s;
}
#batchUrlInput:focus {
outline: none;
border-color: var(--lora-accent);
box-shadow: 0 0 0 2px oklch(from var(--lora-accent) l c h / 0.2);
}
/* Checkbox Group */
.checkbox-group {
margin-top: var(--space-2);
}
.checkbox-label {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
color: var(--text-color);
font-size: 0.95em;
user-select: none;
}
.checkbox-label input[type="checkbox"] {
display: none;
}
.checkmark {
width: 18px;
height: 18px;
border: 2px solid var(--border-color);
border-radius: 4px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
background: var(--bg-color);
}
.checkbox-label input[type="checkbox"]:checked + .checkmark {
background: var(--lora-accent);
border-color: var(--lora-accent);
}
.checkbox-label input[type="checkbox"]:checked + .checkmark::after {
content: '\f00c';
font-family: 'Font Awesome 6 Free';
font-weight: 900;
color: var(--lora-text);
font-size: 12px;
}
/* Batch Options */
.batch-options {
margin-top: var(--space-3);
padding-top: var(--space-3);
border-top: 1px solid var(--border-color);
}
/* Input with Button */
.input-with-button {
display: flex;
gap: 8px;
}
.input-with-button input {
flex: 1;
min-width: 0;
}
.input-with-button button {
flex-shrink: 0;
white-space: nowrap;
padding: 8px 16px;
background: var(--lora-accent);
color: var(--lora-text);
border: none;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: background-color 0.2s;
}
.input-with-button button:hover {
background: oklch(from var(--lora-accent) l c h / 0.9);
}
/* Dark theme adjustments for input-with-button */
[data-theme="dark"] .input-with-button button {
background: var(--lora-accent);
color: var(--lora-text);
}
[data-theme="dark"] .input-with-button button:hover {
background: oklch(from var(--lora-accent) calc(l - 0.1) c h);
}
/* Directory Browser */
.directory-browser {
margin-top: var(--space-3);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--lora-surface);
overflow: hidden;
}
.browser-header {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
background: var(--bg-color);
border-bottom: 1px solid var(--border-color);
}
.back-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
background: var(--card-bg);
color: var(--text-color);
cursor: pointer;
transition: all 0.2s;
}
.back-btn:hover {
border-color: var(--lora-accent);
background: var(--bg-color);
}
.back-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.current-path {
flex: 1;
padding: 6px 10px;
background: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: var(--border-radius-xs);
font-size: 0.9em;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.browser-content {
max-height: 300px;
overflow-y: auto;
padding: 12px;
}
.browser-section {
margin-bottom: 16px;
}
.browser-section:last-child {
margin-bottom: 0;
}
.section-label {
display: flex;
align-items: center;
gap: 8px;
font-weight: 600;
font-size: 0.85em;
color: var(--text-color);
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border-color);
}
.section-label i {
color: var(--lora-accent);
}
.folder-list,
.file-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.folder-item,
.file-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px;
border-radius: var(--border-radius-xs);
cursor: pointer;
transition: all 0.2s;
border: 1px solid transparent;
}
.folder-item:hover,
.file-item:hover {
background: var(--lora-surface-hover, oklch(from var(--lora-accent) l c h / 0.1));
border-color: var(--lora-accent);
}
.folder-item.selected,
.file-item.selected {
background: oklch(from var(--lora-accent) l c h / 0.15);
border-color: var(--lora-accent);
}
.folder-item i {
color: #fbbf24;
font-size: 1.1em;
}
.file-item i {
color: var(--text-color);
opacity: 0.6;
font-size: 1em;
}
.item-name {
flex: 1;
font-size: 0.9em;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.item-size {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.6;
}
.browser-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px 12px;
background: var(--bg-color);
border-top: 1px solid var(--border-color);
}
.stats {
font-size: 0.85em;
color: var(--text-color);
opacity: 0.8;
}
.stats span {
font-weight: 600;
color: var(--lora-accent);
}
/* Dark theme adjustments */
[data-theme="dark"] .directory-browser {
background: var(--card-bg);
}
[data-theme="dark"] .browser-header,
[data-theme="dark"] .browser-footer {
background: var(--lora-surface);
}
[data-theme="dark"] .folder-item i {
color: #fcd34d;
}
/* Progress Container */
.batch-progress-container {
padding: var(--space-3);
background: var(--lora-surface);
border-radius: var(--border-radius-sm);
margin-bottom: var(--space-3);
}
.progress-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: var(--space-2);
}
.progress-status {
display: flex;
align-items: center;
gap: 10px;
}
.status-icon {
color: var(--lora-accent);
font-size: 1.1em;
}
.status-icon i {
animation: fa-spin 2s infinite linear;
}
.status-text {
font-weight: 500;
color: var(--text-color);
}
.progress-percentage {
font-size: 1.2em;
font-weight: 600;
color: var(--lora-accent);
}
/* Progress Bar */
.progress-bar-container {
height: 8px;
background: var(--bg-color);
border-radius: 4px;
overflow: hidden;
margin-bottom: var(--space-3);
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--lora-accent), oklch(from var(--lora-accent) calc(l + 0.1) c h));
border-radius: 4px;
transition: width 0.3s ease;
}
/* Progress Stats */
.progress-stats {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-2);
margin-bottom: var(--space-2);
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
}
.stat-item.success {
border-left: 3px solid #00B87A;
}
.stat-item.failed {
border-left: 3px solid var(--lora-error);
}
.stat-item.skipped {
border-left: 3px solid var(--lora-warning);
}
.stat-label {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
margin-bottom: 4px;
}
.stat-value {
font-size: 1.4em;
font-weight: 600;
color: var(--text-color);
}
/* Current Item */
.current-item {
display: flex;
align-items: baseline;
gap: 10px;
padding: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-xs);
font-size: 0.9em;
}
.current-item-label {
color: var(--text-color);
opacity: 0.7;
flex-shrink: 0;
}
.current-item-name {
color: var(--text-color);
font-weight: 500;
flex: 1;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.2;
}
/* Results Container */
.batch-results-container {
padding: var(--space-3);
background: var(--lora-surface);
border-radius: var(--border-radius-sm);
margin-bottom: var(--space-3);
}
.results-header {
text-align: center;
margin-bottom: var(--space-3);
}
.results-icon {
font-size: 3em;
color: #00B87A;
margin-bottom: var(--space-1);
}
.results-icon.warning {
color: var(--lora-warning);
}
.results-icon.error {
color: var(--lora-error);
}
.results-title {
font-size: 1.3em;
font-weight: 600;
color: var(--text-color);
}
/* Results Summary - Matches progress-stats styling */
.results-summary {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.result-card {
display: flex;
flex-direction: column;
align-items: center;
padding: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
text-align: center;
}
.result-card.success {
border-left: 3px solid #00B87A;
}
.result-card.failed {
border-left: 3px solid var(--lora-error);
}
.result-card.skipped {
border-left: 3px solid var(--lora-warning);
}
.result-label {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
margin-bottom: 4px;
}
.result-value {
font-size: 1.4em;
font-weight: 600;
color: var(--text-color);
}
/* Results Details */
.results-details {
border-top: 1px solid var(--border-color);
padding-top: var(--space-2);
}
.details-toggle {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px;
cursor: pointer;
color: var(--lora-accent);
font-weight: 500;
border-radius: var(--border-radius-xs);
transition: background 0.2s;
}
.details-toggle:hover {
background: oklch(from var(--lora-accent) l c h / 0.1);
}
.details-toggle i {
transition: transform 0.2s;
}
.details-toggle.expanded i {
transform: rotate(180deg);
}
.details-list {
max-height: 250px;
overflow-y: auto;
margin-top: var(--space-2);
background: var(--bg-color);
border-radius: var(--border-radius-xs);
border: 1px solid var(--border-color);
}
/* Result Item in Details */
.result-item {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
font-size: 0.9em;
}
.result-item:last-child {
border-bottom: none;
}
.result-item-status {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8em;
}
.result-item-status.success {
background: oklch(from #00B87A l c h / 0.2);
color: #00B87A;
}
.result-item-status.failed {
background: oklch(from var(--lora-error) l c h / 0.2);
color: var(--lora-error);
}
.result-item-status.skipped {
background: oklch(from var(--lora-warning) l c h / 0.2);
color: var(--lora-warning);
}
.result-item-info {
flex: 1;
min-width: 0;
}
.result-item-name {
font-weight: 500;
color: var(--text-color);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-item-error {
font-size: 0.8em;
color: var(--lora-error);
margin-top: 2px;
}
/* Responsive Adjustments */
@media (max-width: 768px) {
.progress-stats,
.results-summary {
grid-template-columns: repeat(2, 1fr);
}
.batch-progress-container,
.batch-results-container {
padding: var(--space-2);
}
}
/* Dark Theme Adjustments */
[data-theme="dark"] .batch-progress-container,
[data-theme="dark"] .batch-results-container {
background: var(--card-bg);
}
[data-theme="dark"] .stat-item,
[data-theme="dark"] .result-card,
[data-theme="dark"] .current-item,
[data-theme="dark"] .details-list {
background: var(--lora-surface);
}
/* Cancelled State */
.batch-progress-container.cancelled .progress-bar {
background: var(--lora-warning);
}
.batch-progress-container.cancelled .status-icon {
color: var(--lora-warning);
}
/* Error State */
.batch-progress-container.error .progress-bar {
background: var(--lora-error);
}
.batch-progress-container.error .status-icon {
color: var(--lora-error);
}
/* Completed State */
.batch-progress-container.completed .progress-bar {
background: #00B87A;
}
.batch-progress-container.completed .status-icon {
color: #00B87A;
}
.batch-progress-container.completed .status-icon i {
animation: none;
}
.batch-progress-container.completed .status-icon i::before {
content: '\f00c';
}

View File

@@ -0,0 +1,795 @@
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();

View File

@@ -134,6 +134,19 @@ export class ModalManager {
});
}
// Add batchImportModal registration
const batchImportModal = document.getElementById('batchImportModal');
if (batchImportModal) {
this.registerModal('batchImportModal', {
element: batchImportModal,
onClose: () => {
this.getModal('batchImportModal').element.style.display = 'none';
document.body.classList.remove('modal-open');
},
closeOnOutsideClick: true
});
}
// Add recipeModal registration
const recipeModal = document.getElementById('recipeModal');
if (recipeModal) {

View File

@@ -1,6 +1,7 @@
// Recipe manager module
import { appCore } from './core.js';
import { ImportManager } from './managers/ImportManager.js';
import { BatchImportManager } from './managers/BatchImportManager.js';
import { RecipeModal } from './components/RecipeModal.js';
import { state, getCurrentPageState } from './state/index.js';
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
@@ -46,6 +47,10 @@ class RecipeManager {
// Initialize ImportManager
this.importManager = new ImportManager();
// Initialize BatchImportManager and make it globally accessible
this.batchImportManager = new BatchImportManager();
window.batchImportManager = this.batchImportManager;
// Initialize RecipeModal
this.recipeModal = new RecipeModal();