mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-08-28 08:21:27 -03:00
fix(recipes): restore batch import modal on reopen and log recipe ingest progress (#1084)
This commit is contained in:
@@ -184,6 +184,7 @@ class BatchImportService:
|
|||||||
def cancel_import(self, operation_id: str) -> bool:
|
def cancel_import(self, operation_id: str) -> bool:
|
||||||
if operation_id in self._active_operations:
|
if operation_id in self._active_operations:
|
||||||
self._cancellation_flags[operation_id] = True
|
self._cancellation_flags[operation_id] = True
|
||||||
|
self._logger.info("Cancel requested for batch import operation %s", operation_id)
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -273,6 +274,14 @@ class BatchImportService:
|
|||||||
self._active_operations[operation_id] = progress
|
self._active_operations[operation_id] = progress
|
||||||
self._cancellation_flags[operation_id] = False
|
self._cancellation_flags[operation_id] = False
|
||||||
|
|
||||||
|
self._logger.info(
|
||||||
|
"Starting batch import operation %s: %d item(s) (%d URL(s), %d local path(s))",
|
||||||
|
operation_id,
|
||||||
|
len(import_items),
|
||||||
|
sum(1 for it in import_items if it.item_type == ImportItemType.URL),
|
||||||
|
sum(1 for it in import_items if it.item_type == ImportItemType.LOCAL_PATH),
|
||||||
|
)
|
||||||
|
|
||||||
asyncio.create_task(
|
asyncio.create_task(
|
||||||
self._run_batch_import(
|
self._run_batch_import(
|
||||||
operation_id=operation_id,
|
operation_id=operation_id,
|
||||||
@@ -295,6 +304,12 @@ class BatchImportService:
|
|||||||
skip_duplicates: bool = False,
|
skip_duplicates: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
image_paths = await self._discover_images(directory, recursive)
|
image_paths = await self._discover_images(directory, recursive)
|
||||||
|
self._logger.info(
|
||||||
|
"Batch import directory scan: %d image(s) discovered in %s (recursive=%s)",
|
||||||
|
len(image_paths),
|
||||||
|
directory,
|
||||||
|
recursive,
|
||||||
|
)
|
||||||
|
|
||||||
items = [{"source": path, "type": "local_path"} for path in image_paths]
|
items = [{"source": path, "type": "local_path"} for path in image_paths]
|
||||||
|
|
||||||
@@ -403,6 +418,19 @@ class BatchImportService:
|
|||||||
self._concurrency_controller.record_result(item.duration, False)
|
self._concurrency_controller.record_result(item.duration, False)
|
||||||
|
|
||||||
progress.completed += 1
|
progress.completed += 1
|
||||||
|
self._logger.info(
|
||||||
|
"Batch import %s: item %d/%d status=%s source=%s%s",
|
||||||
|
operation_id,
|
||||||
|
progress.completed,
|
||||||
|
progress.total,
|
||||||
|
item.status.value,
|
||||||
|
(
|
||||||
|
os.path.basename(item.source)
|
||||||
|
if item.item_type == ImportItemType.LOCAL_PATH
|
||||||
|
else item.source[:50]
|
||||||
|
),
|
||||||
|
(f" error={item.error_message}" if item.error_message else ""),
|
||||||
|
)
|
||||||
await self._broadcast_progress(progress)
|
await self._broadcast_progress(progress)
|
||||||
|
|
||||||
tasks = [process_item(item) for item in progress.items]
|
tasks = [process_item(item) for item in progress.items]
|
||||||
@@ -415,6 +443,15 @@ class BatchImportService:
|
|||||||
|
|
||||||
progress.finished_at = time.time()
|
progress.finished_at = time.time()
|
||||||
progress.current_item = ""
|
progress.current_item = ""
|
||||||
|
self._logger.info(
|
||||||
|
"Batch import %s finished: status=%s total=%d success=%d failed=%d skipped=%d",
|
||||||
|
operation_id,
|
||||||
|
progress.status,
|
||||||
|
progress.total,
|
||||||
|
progress.success,
|
||||||
|
progress.failed,
|
||||||
|
progress.skipped,
|
||||||
|
)
|
||||||
await self._broadcast_progress(progress)
|
await self._broadcast_progress(progress)
|
||||||
|
|
||||||
await asyncio.sleep(5)
|
await asyncio.sleep(5)
|
||||||
@@ -595,3 +632,6 @@ class BatchImportService:
|
|||||||
def _cleanup_operation(self, operation_id: str) -> None:
|
def _cleanup_operation(self, operation_id: str) -> None:
|
||||||
if operation_id in self._cancellation_flags:
|
if operation_id in self._cancellation_flags:
|
||||||
del self._cancellation_flags[operation_id]
|
del self._cancellation_flags[operation_id]
|
||||||
|
if operation_id in self._active_operations:
|
||||||
|
del self._active_operations[operation_id]
|
||||||
|
self._logger.info("Batch import operation %s cleaned up", operation_id)
|
||||||
|
|||||||
@@ -17,17 +17,77 @@ export class BatchImportManager {
|
|||||||
this.progress = null;
|
this.progress = null;
|
||||||
this.results = null;
|
this.results = null;
|
||||||
this.isCancelled = false;
|
this.isCancelled = false;
|
||||||
|
this.isImporting = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Show the batch import modal
|
* Show the batch import modal.
|
||||||
|
*
|
||||||
|
* If an import is still running in the background (e.g. the modal was
|
||||||
|
* closed mid-run with the X button or a backdrop click), reopen it in the
|
||||||
|
* progress/results view instead of resetting to a fresh form, so the modal
|
||||||
|
* never becomes unusable while an operation is in flight.
|
||||||
*/
|
*/
|
||||||
showModal() {
|
showModal() {
|
||||||
if (!this.initialized) {
|
if (!this.initialized) {
|
||||||
this.initialize();
|
this.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (this.isImporting && this.operationId) {
|
||||||
|
console.log(
|
||||||
|
`[BatchImport] Reopening modal while operation ${this.operationId} is still active; restoring its view.`
|
||||||
|
);
|
||||||
|
this.resumeRunningImportView();
|
||||||
|
} else if (this.results && this.operationId) {
|
||||||
|
// A previous operation finished while the modal was closed —
|
||||||
|
// restore its results view instead of discarding them.
|
||||||
|
console.log(
|
||||||
|
`[BatchImport] Reopening modal after operation ${this.operationId} finished; showing results.`
|
||||||
|
);
|
||||||
|
this.showStep('batchResultsStep');
|
||||||
|
this.updateResultsUI(this.results);
|
||||||
|
} else {
|
||||||
this.resetState();
|
this.resetState();
|
||||||
modalManager.showModal('batchImportModal');
|
console.log('[BatchImport] Opening batch import modal.');
|
||||||
|
}
|
||||||
|
|
||||||
|
modalManager.showModal('batchImportModal', null, () => this.handleModalClosed());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore the progress (or results) view for an operation that is still
|
||||||
|
* running in the background after the modal was closed.
|
||||||
|
*/
|
||||||
|
resumeRunningImportView() {
|
||||||
|
// Operation completed while the modal was closed — show results
|
||||||
|
if (this.results) {
|
||||||
|
this.showStep('batchResultsStep');
|
||||||
|
this.updateResultsUI(this.results);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Still running — restore the progress step and re-attach live updates
|
||||||
|
this.showStep('batchProgressStep');
|
||||||
|
this.updateProgressUI(this.progress || {});
|
||||||
|
if (!this.wsConnection && !this.pollingInterval) {
|
||||||
|
this.connectWebSocket();
|
||||||
|
this.startPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Called whenever the modal is closed (X button, backdrop click, cancel,
|
||||||
|
* closeAndReset). Logs whether an operation is still running so users can
|
||||||
|
* tell from the console that work continues in the background.
|
||||||
|
*/
|
||||||
|
handleModalClosed() {
|
||||||
|
if (this.isImporting && this.operationId) {
|
||||||
|
console.log(
|
||||||
|
`[BatchImport] Modal closed while import ${this.operationId} is still running; it keeps running in the background. Reopen the modal to watch its progress.`
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
console.log('[BatchImport] Modal closed (no active import).');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,6 +117,7 @@ export class BatchImportManager {
|
|||||||
this.progress = null;
|
this.progress = null;
|
||||||
this.results = null;
|
this.results = null;
|
||||||
this.isCancelled = false;
|
this.isCancelled = false;
|
||||||
|
this.isImporting = false;
|
||||||
|
|
||||||
// Reset UI
|
// Reset UI
|
||||||
this.showStep('batchInputStep');
|
this.showStep('batchInputStep');
|
||||||
@@ -172,6 +233,10 @@ export class BatchImportManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[BatchImport] Starting import: mode=${data.mode}, items=${data.items ? data.items.length : 'directory'}, tags=${data.tags.length}`
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Show progress step
|
// Show progress step
|
||||||
this.showStep('batchProgressStep');
|
this.showStep('batchProgressStep');
|
||||||
@@ -182,6 +247,8 @@ export class BatchImportManager {
|
|||||||
if (response.success) {
|
if (response.success) {
|
||||||
this.operationId = response.operation_id;
|
this.operationId = response.operation_id;
|
||||||
this.isCancelled = false;
|
this.isCancelled = false;
|
||||||
|
this.isImporting = true;
|
||||||
|
console.log(`[BatchImport] Import started, operation_id=${this.operationId}`);
|
||||||
|
|
||||||
// Connect to WebSocket for real-time updates
|
// Connect to WebSocket for real-time updates
|
||||||
this.connectWebSocket();
|
this.connectWebSocket();
|
||||||
@@ -189,6 +256,7 @@ export class BatchImportManager {
|
|||||||
// Start polling as fallback
|
// Start polling as fallback
|
||||||
this.startPolling();
|
this.startPolling();
|
||||||
} else {
|
} else {
|
||||||
|
console.warn(`[BatchImport] Failed to start import: ${response.error}`);
|
||||||
showToast('toast.recipes.batchImportFailed', { message: response.error }, 'error');
|
showToast('toast.recipes.batchImportFailed', { message: response.error }, 'error');
|
||||||
this.showStep('batchInputStep');
|
this.showStep('batchInputStep');
|
||||||
}
|
}
|
||||||
@@ -358,6 +426,16 @@ export class BatchImportManager {
|
|||||||
this.progress = progress;
|
this.progress = progress;
|
||||||
this.updateProgressUI(progress);
|
this.updateProgressUI(progress);
|
||||||
|
|
||||||
|
// Console visibility for background progress: while the modal is
|
||||||
|
// closed (or open), the console shows what the import is doing.
|
||||||
|
console.log(
|
||||||
|
`[BatchImport] Progress ${Math.round(progress.progress_percent || 0)}% ` +
|
||||||
|
`(${progress.completed}/${progress.total}) ` +
|
||||||
|
`status=${progress.status} ` +
|
||||||
|
`success=${progress.success} failed=${progress.failed} skipped=${progress.skipped} ` +
|
||||||
|
`item=${progress.current_item || '-'}`
|
||||||
|
);
|
||||||
|
|
||||||
// Check if import is complete
|
// Check if import is complete
|
||||||
if (progress.status === 'completed' || progress.status === 'cancelled' ||
|
if (progress.status === 'completed' || progress.status === 'cancelled' ||
|
||||||
(progress.total > 0 && progress.completed >= progress.total)) {
|
(progress.total > 0 && progress.completed >= progress.total)) {
|
||||||
@@ -431,7 +509,12 @@ export class BatchImportManager {
|
|||||||
*/
|
*/
|
||||||
importComplete(progress) {
|
importComplete(progress) {
|
||||||
this.cleanupConnections();
|
this.cleanupConnections();
|
||||||
|
this.isImporting = false;
|
||||||
this.results = progress;
|
this.results = progress;
|
||||||
|
console.log(
|
||||||
|
`[BatchImport] Import finished: status=${progress.status} ` +
|
||||||
|
`total=${progress.total} success=${progress.success} failed=${progress.failed} skipped=${progress.skipped}`
|
||||||
|
);
|
||||||
|
|
||||||
// Refresh recipes list to show newly imported recipes
|
// Refresh recipes list to show newly imported recipes
|
||||||
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
if (window.recipeManager && typeof window.recipeManager.loadRecipes === 'function') {
|
||||||
@@ -559,6 +642,7 @@ export class BatchImportManager {
|
|||||||
if (!this.operationId) return;
|
if (!this.operationId) return;
|
||||||
|
|
||||||
this.isCancelled = true;
|
this.isCancelled = true;
|
||||||
|
console.log(`[BatchImport] Cancelling import ${this.operationId}...`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/lm/recipes/batch-import/cancel', {
|
const response = await fetch('/api/lm/recipes/batch-import/cancel', {
|
||||||
@@ -572,8 +656,10 @@ export class BatchImportManager {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.success) {
|
if (data.success) {
|
||||||
|
console.log(`[BatchImport] Cancel request accepted for ${this.operationId}`);
|
||||||
showToast('toast.recipes.batchImportCancelling', {}, 'info');
|
showToast('toast.recipes.batchImportCancelling', {}, 'info');
|
||||||
} else {
|
} else {
|
||||||
|
console.warn(`[BatchImport] Cancel request failed: ${data.error}`);
|
||||||
showToast('toast.recipes.batchImportCancelFailed', { message: data.error }, 'error');
|
showToast('toast.recipes.batchImportCancelFailed', { message: data.error }, 'error');
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -586,6 +672,7 @@ export class BatchImportManager {
|
|||||||
* Close modal and reset state
|
* Close modal and reset state
|
||||||
*/
|
*/
|
||||||
closeAndReset() {
|
closeAndReset() {
|
||||||
|
console.log('[BatchImport] Closing modal and resetting state.');
|
||||||
this.cleanupConnections();
|
this.cleanupConnections();
|
||||||
this.resetState();
|
this.resetState();
|
||||||
modalManager.closeModal('batchImportModal');
|
modalManager.closeModal('batchImportModal');
|
||||||
@@ -595,6 +682,7 @@ export class BatchImportManager {
|
|||||||
* Start a new import (from results step)
|
* Start a new import (from results step)
|
||||||
*/
|
*/
|
||||||
startNewImport() {
|
startNewImport() {
|
||||||
|
console.log('[BatchImport] Starting a new import from the results view.');
|
||||||
this.resetState();
|
this.resetState();
|
||||||
this.showStep('batchInputStep');
|
this.showStep('batchInputStep');
|
||||||
}
|
}
|
||||||
@@ -789,6 +877,14 @@ export class BatchImportManager {
|
|||||||
* Clean up WebSocket and polling connections
|
* Clean up WebSocket and polling connections
|
||||||
*/
|
*/
|
||||||
cleanupConnections() {
|
cleanupConnections() {
|
||||||
|
const hasWs = this.wsConnection && (
|
||||||
|
this.wsConnection.readyState === WebSocket.OPEN ||
|
||||||
|
this.wsConnection.readyState === WebSocket.CONNECTING
|
||||||
|
);
|
||||||
|
if (hasWs || this.pollingInterval) {
|
||||||
|
console.log('[BatchImport] Cleaning up live connections (WebSocket/polling).');
|
||||||
|
}
|
||||||
|
|
||||||
if (this.wsConnection) {
|
if (this.wsConnection) {
|
||||||
if (this.wsConnection.readyState === WebSocket.OPEN ||
|
if (this.wsConnection.readyState === WebSocket.OPEN ||
|
||||||
this.wsConnection.readyState === WebSocket.CONNECTING) {
|
this.wsConnection.readyState === WebSocket.CONNECTING) {
|
||||||
|
|||||||
@@ -66,10 +66,15 @@ export class ImportManager {
|
|||||||
|
|
||||||
// Show modal
|
// Show modal
|
||||||
modalManager.showModal('importModal', null, () => {
|
modalManager.showModal('importModal', null, () => {
|
||||||
|
console.log('[RecipeImport] Import modal closed.');
|
||||||
this.cleanupFolderBrowser();
|
this.cleanupFolderBrowser();
|
||||||
this.stepManager.removeInjectedStyles();
|
this.stepManager.removeInjectedStyles();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[RecipeImport] Import modal opened (${recipeData ? 'download-missing-loras mode' : 'new import'}).`
|
||||||
|
);
|
||||||
|
|
||||||
// Verify visibility and focus on the URL input (primary mode)
|
// Verify visibility and focus on the URL input (primary mode)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const urlInput = document.getElementById('imageUrlInput');
|
const urlInput = document.getElementById('imageUrlInput');
|
||||||
|
|||||||
@@ -146,7 +146,13 @@ export class ModalManager {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add batchImportModal registration
|
// Add batchImportModal registration.
|
||||||
|
// Deliberately no closeOnOutsideClick: batch import is a stateful,
|
||||||
|
// multi-step workflow (input -> progress -> results) that runs a
|
||||||
|
// long-lived background operation. A stray backdrop click would
|
||||||
|
// dismiss the modal while the import keeps running, leaving users
|
||||||
|
// unable to tell what is still happening (issue #1084). Close is
|
||||||
|
// available via the explicit X button / Cancel instead.
|
||||||
const batchImportModal = document.getElementById('batchImportModal');
|
const batchImportModal = document.getElementById('batchImportModal');
|
||||||
if (batchImportModal) {
|
if (batchImportModal) {
|
||||||
this.registerModal('batchImportModal', {
|
this.registerModal('batchImportModal', {
|
||||||
@@ -154,8 +160,7 @@ export class ModalManager {
|
|||||||
onClose: () => {
|
onClose: () => {
|
||||||
this.getModal('batchImportModal').element.style.display = 'none';
|
this.getModal('batchImportModal').element.style.display = 'none';
|
||||||
document.body.classList.remove('modal-open');
|
document.body.classList.remove('modal-open');
|
||||||
},
|
}
|
||||||
closeOnOutsideClick: true
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ export class DownloadManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[RecipeImport] Saving recipe "${this.importManager.recipeName}" (download-only=${isDownloadOnly}, skipDownload=${skipDownload})`
|
||||||
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Show progress indicator
|
// Show progress indicator
|
||||||
const loadingMessage = skipDownload
|
const loadingMessage = skipDownload
|
||||||
@@ -102,6 +106,7 @@ export class DownloadManager {
|
|||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
// Handle save error
|
// Handle save error
|
||||||
console.error("Failed to save recipe:", result.error);
|
console.error("Failed to save recipe:", result.error);
|
||||||
|
console.log('[RecipeImport] Save failed; closing import modal.');
|
||||||
showToast('toast.recipes.recipeSaveFailed', { error: result.error }, 'error');
|
showToast('toast.recipes.recipeSaveFailed', { error: result.error }, 'error');
|
||||||
// Close modal
|
// Close modal
|
||||||
modalManager.closeModal('importModal');
|
modalManager.closeModal('importModal');
|
||||||
@@ -112,6 +117,7 @@ export class DownloadManager {
|
|||||||
// Check if we need to download LoRAs (skip if skipDownload is true)
|
// Check if we need to download LoRAs (skip if skipDownload is true)
|
||||||
let failedDownloads = 0;
|
let failedDownloads = 0;
|
||||||
if (!skipDownload && this.importManager.downloadableLoRAs && this.importManager.downloadableLoRAs.length > 0) {
|
if (!skipDownload && this.importManager.downloadableLoRAs && this.importManager.downloadableLoRAs.length > 0) {
|
||||||
|
console.log(`[RecipeImport] Downloading ${this.importManager.downloadableLoRAs.length} missing LoRA(s)...`);
|
||||||
await this.downloadMissingLoras();
|
await this.downloadMissingLoras();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +133,7 @@ export class DownloadManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
modalManager.closeModal('importModal');
|
modalManager.closeModal('importModal');
|
||||||
|
console.log(`[RecipeImport] Recipe "${this.importManager.recipeName}" saved successfully.`);
|
||||||
|
|
||||||
if (isDownloadOnly && state.virtualScroller) {
|
if (isDownloadOnly && state.virtualScroller) {
|
||||||
const recipeId = this.importManager.recipeId;
|
const recipeId = this.importManager.recipeId;
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export class ImageProcessor {
|
|||||||
errorElement.textContent = '';
|
errorElement.textContent = '';
|
||||||
this.importManager.recipeImage = file;
|
this.importManager.recipeImage = file;
|
||||||
this.importManager.importMode = 'upload';
|
this.importManager.importMode = 'upload';
|
||||||
|
console.log(`[RecipeImport] Recipe image selected: ${file.name}`);
|
||||||
|
|
||||||
// Show the selected file name in the drop zone
|
// Show the selected file name in the drop zone
|
||||||
this.importManager.updateSelectedFileName(file.name);
|
this.importManager.updateSelectedFileName(file.name);
|
||||||
@@ -66,6 +67,10 @@ export class ImageProcessor {
|
|||||||
errorElement.textContent = '';
|
errorElement.textContent = '';
|
||||||
this.importManager.importMode = 'url';
|
this.importManager.importMode = 'url';
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
`[RecipeImport] Analyzing recipe input (${input.startsWith('http://') || input.startsWith('https://') ? 'remote URL' : 'local path'}): ${input.slice(0, 80)}`
|
||||||
|
);
|
||||||
|
|
||||||
// Put the fetch button into a loading state to prevent duplicate submits
|
// Put the fetch button into a loading state to prevent duplicate submits
|
||||||
const fetchBtn = document.getElementById('fetchImageBtn');
|
const fetchBtn = document.getElementById('fetchImageBtn');
|
||||||
this._setFetchButtonLoading(fetchBtn, true);
|
this._setFetchButtonLoading(fetchBtn, true);
|
||||||
@@ -144,6 +149,9 @@ export class ImageProcessor {
|
|||||||
this.importManager.importAsNew = false;
|
this.importManager.importAsNew = false;
|
||||||
|
|
||||||
// Proceed to recipe details step
|
// Proceed to recipe details step
|
||||||
|
console.log(
|
||||||
|
`[RecipeImport] Analysis complete: ${this.importManager.recipeData.loras.length} LoRA(s) found, ${this.importManager.missingLoras.length} missing locally.`
|
||||||
|
);
|
||||||
this.importManager.showRecipeDetailsStep();
|
this.importManager.showRecipeDetailsStep();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -196,6 +204,9 @@ export class ImageProcessor {
|
|||||||
this.importManager.importAsNew = false;
|
this.importManager.importAsNew = false;
|
||||||
|
|
||||||
// Proceed to recipe details step
|
// Proceed to recipe details step
|
||||||
|
console.log(
|
||||||
|
`[RecipeImport] Analysis complete: ${this.importManager.recipeData.loras.length} LoRA(s) found, ${this.importManager.missingLoras.length} missing locally.`
|
||||||
|
);
|
||||||
this.importManager.showRecipeDetailsStep();
|
this.importManager.showRecipeDetailsStep();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -251,6 +262,9 @@ export class ImageProcessor {
|
|||||||
this.importManager.importAsNew = false;
|
this.importManager.importAsNew = false;
|
||||||
|
|
||||||
// Proceed to recipe details step
|
// Proceed to recipe details step
|
||||||
|
console.log(
|
||||||
|
`[RecipeImport] Analysis complete: ${this.importManager.recipeData.loras.length} LoRA(s) found, ${this.importManager.missingLoras.length} missing locally.`
|
||||||
|
);
|
||||||
this.importManager.showRecipeDetailsStep();
|
this.importManager.showRecipeDetailsStep();
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { renderTemplate } from '../utils/domFixtures.js';
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: vi.fn(),
|
||||||
|
setupAutoNewlineOnPaste: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
|
translate: (key, params = {}, fallback = null) => fallback ?? key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/apiConfig.js', () => ({
|
||||||
|
WS_ENDPOINTS: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||||
|
getStorageItem: vi.fn(() => true),
|
||||||
|
setStorageItem: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
// jsdom has no WebSocket; the manager only needs open/connecting/close states.
|
||||||
|
class FakeWebSocket {
|
||||||
|
constructor(url) {
|
||||||
|
this.url = url;
|
||||||
|
this.readyState = 0;
|
||||||
|
}
|
||||||
|
close() {
|
||||||
|
this.readyState = 3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
FakeWebSocket.OPEN = 1;
|
||||||
|
FakeWebSocket.CONNECTING = 0;
|
||||||
|
|
||||||
|
const RUNNING_PROGRESS = {
|
||||||
|
status: 'running',
|
||||||
|
total: 2,
|
||||||
|
completed: 1,
|
||||||
|
success: 1,
|
||||||
|
failed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
progress_percent: 50,
|
||||||
|
current_item: 'image-1.png',
|
||||||
|
};
|
||||||
|
|
||||||
|
const COMPLETED_PROGRESS = {
|
||||||
|
status: 'completed',
|
||||||
|
total: 2,
|
||||||
|
completed: 2,
|
||||||
|
success: 2,
|
||||||
|
failed: 0,
|
||||||
|
skipped: 0,
|
||||||
|
progress_percent: 100,
|
||||||
|
current_item: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('BatchImportManager reopen behavior (#1084)', () => {
|
||||||
|
let modalManager;
|
||||||
|
let batchImportManager;
|
||||||
|
let fetchMock;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
vi.resetModules();
|
||||||
|
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
renderTemplate('components/batch_import_modal.html');
|
||||||
|
|
||||||
|
// jsdom does not implement window.scrollTo; ModalManager calls it on close.
|
||||||
|
window.scrollTo = vi.fn();
|
||||||
|
|
||||||
|
vi.stubGlobal('WebSocket', FakeWebSocket);
|
||||||
|
fetchMock = vi.fn(async () => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({}),
|
||||||
|
}));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|
||||||
|
const modalModule = await import('../../../static/js/managers/ModalManager.js');
|
||||||
|
modalManager = modalModule.modalManager;
|
||||||
|
modalManager.initialize();
|
||||||
|
|
||||||
|
const batchModule = await import('../../../static/js/managers/BatchImportManager.js');
|
||||||
|
batchImportManager = new batchModule.BatchImportManager();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
if (batchImportManager) {
|
||||||
|
batchImportManager.cleanupConnections();
|
||||||
|
}
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function startImportViaUrls(urls) {
|
||||||
|
batchImportManager.showModal();
|
||||||
|
document.getElementById('batchUrlInput').value = urls.join('\n');
|
||||||
|
fetchMock.mockImplementation(async (url) => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () =>
|
||||||
|
url.includes('/batch-import/start')
|
||||||
|
? { success: true, operation_id: 'op-123' }
|
||||||
|
: { success: true, progress: RUNNING_PROGRESS },
|
||||||
|
}));
|
||||||
|
await batchImportManager.startImport();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('opens a fresh input form when no operation exists', () => {
|
||||||
|
batchImportManager.showModal();
|
||||||
|
expect(document.getElementById('batchImportModal').style.display).toBe('block');
|
||||||
|
expect(document.getElementById('batchInputStep').style.display).toBe('block');
|
||||||
|
expect(document.getElementById('batchProgressStep').style.display).toBe('none');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reopens into the progress view while an import keeps running in the background', async () => {
|
||||||
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
await startImportViaUrls([
|
||||||
|
'https://civitai.com/images/1',
|
||||||
|
'https://civitai.com/images/2',
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(batchImportManager.isImporting).toBe(true);
|
||||||
|
expect(batchImportManager.operationId).toBe('op-123');
|
||||||
|
expect(document.getElementById('batchProgressStep').style.display).toBe('block');
|
||||||
|
|
||||||
|
// Close the modal the same way the X button does.
|
||||||
|
modalManager.closeModal('batchImportModal');
|
||||||
|
expect(document.getElementById('batchImportModal').style.display).toBe('none');
|
||||||
|
|
||||||
|
// Closing while running must be visible in the console (#1084).
|
||||||
|
const closedWhileRunning = logSpy.mock.calls.some((call) =>
|
||||||
|
String(call[0]).includes('Modal closed while import op-123 is still running')
|
||||||
|
);
|
||||||
|
expect(closedWhileRunning).toBe(true);
|
||||||
|
|
||||||
|
// Reopen: the in-flight operation must be restored, not discarded.
|
||||||
|
batchImportManager.showModal();
|
||||||
|
expect(document.getElementById('batchImportModal').style.display).toBe('block');
|
||||||
|
expect(batchImportManager.operationId).toBe('op-123');
|
||||||
|
expect(batchImportManager.isImporting).toBe(true);
|
||||||
|
expect(document.getElementById('batchProgressStep').style.display).toBe('block');
|
||||||
|
expect(document.getElementById('batchInputStep').style.display).toBe('none');
|
||||||
|
|
||||||
|
logSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reopens into the results view after a background import completes', async () => {
|
||||||
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
await startImportViaUrls([
|
||||||
|
'https://civitai.com/images/1',
|
||||||
|
'https://civitai.com/images/2',
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Operation finishes while the modal stays closed.
|
||||||
|
modalManager.closeModal('batchImportModal');
|
||||||
|
batchImportManager.handleProgressUpdate(RUNNING_PROGRESS);
|
||||||
|
batchImportManager.handleProgressUpdate(COMPLETED_PROGRESS);
|
||||||
|
|
||||||
|
expect(batchImportManager.isImporting).toBe(false);
|
||||||
|
expect(batchImportManager.results.status).toBe('completed');
|
||||||
|
|
||||||
|
// Reopening shows the finished results instead of a blank form.
|
||||||
|
batchImportManager.showModal();
|
||||||
|
expect(document.getElementById('batchImportModal').style.display).toBe('block');
|
||||||
|
expect(document.getElementById('batchResultsStep').style.display).toBe('block');
|
||||||
|
expect(document.getElementById('batchInputStep').style.display).toBe('none');
|
||||||
|
|
||||||
|
logSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not close when clicking the backdrop (stateful workflow, #1084)', async () => {
|
||||||
|
await startImportViaUrls(['https://civitai.com/images/1']);
|
||||||
|
|
||||||
|
const modalEl = document.getElementById('batchImportModal');
|
||||||
|
expect(modalEl.style.display).toBe('block');
|
||||||
|
|
||||||
|
// Simulate a backdrop click: mousedown + mouseup on the modal shell.
|
||||||
|
// Because batch import is a stateful, multi-step workflow, the modal must
|
||||||
|
// not dismiss on stray outside clicks.
|
||||||
|
modalEl.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||||
|
modalEl.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
||||||
|
|
||||||
|
expect(modalEl.style.display).toBe('block');
|
||||||
|
expect(modalManager.isAnyModalOpen()).toBe('batchImportModal');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('logs start, progress and completion to the console', async () => {
|
||||||
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
await startImportViaUrls(['https://civitai.com/images/1']);
|
||||||
|
|
||||||
|
// force a polled progress update
|
||||||
|
batchImportManager.handleProgressUpdate(RUNNING_PROGRESS);
|
||||||
|
batchImportManager.handleProgressUpdate(COMPLETED_PROGRESS);
|
||||||
|
|
||||||
|
const messages = logSpy.mock.calls.map((call) => String(call[0]));
|
||||||
|
expect(messages.some((m) => m.includes('[BatchImport] Import started, operation_id=op-123'))).toBe(true);
|
||||||
|
expect(messages.some((m) => m.includes('[BatchImport] Progress 50%'))).toBe(true);
|
||||||
|
expect(messages.some((m) => m.includes('[BatchImport] Import finished: status=completed'))).toBe(true);
|
||||||
|
|
||||||
|
logSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user