mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 13:12:12 -03:00
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
374 lines
12 KiB
JavaScript
374 lines
12 KiB
JavaScript
// 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';
|
|
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
|
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
|
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
|
import { refreshRecipes, syncChanges, RecipeSidebarApiClient } from './api/recipeApi.js';
|
|
import { sidebarManager } from './components/SidebarManager.js';
|
|
|
|
class RecipePageControls {
|
|
constructor() {
|
|
this.pageType = 'recipes';
|
|
this.pageState = getCurrentPageState();
|
|
this.sidebarApiClient = new RecipeSidebarApiClient();
|
|
}
|
|
|
|
async resetAndReload() {
|
|
refreshVirtualScroll();
|
|
}
|
|
|
|
async refreshModels(fullRebuild = false) {
|
|
if (fullRebuild) {
|
|
await refreshRecipes();
|
|
return;
|
|
}
|
|
|
|
await syncChanges();
|
|
}
|
|
|
|
getSidebarApiClient() {
|
|
return this.sidebarApiClient;
|
|
}
|
|
}
|
|
|
|
class RecipeManager {
|
|
constructor() {
|
|
// Get page state
|
|
this.pageState = getCurrentPageState();
|
|
|
|
// Page controls for shared sidebar behaviors
|
|
this.pageControls = new RecipePageControls();
|
|
|
|
// 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();
|
|
|
|
// Initialize DuplicatesManager
|
|
this.duplicatesManager = new DuplicatesManager(this);
|
|
|
|
// Add state tracking for infinite scroll
|
|
this.pageState.isLoading = false;
|
|
this.pageState.hasMore = true;
|
|
|
|
// Custom filter state - move to pageState for compatibility with virtual scrolling
|
|
this.pageState.customFilter = {
|
|
active: false,
|
|
loraName: null,
|
|
loraHash: null,
|
|
recipeId: null
|
|
};
|
|
}
|
|
|
|
async initialize() {
|
|
// Initialize event listeners
|
|
this.initEventListeners();
|
|
|
|
// Set default search options if not already defined
|
|
this._initSearchOptions();
|
|
|
|
// Initialize context menu
|
|
new RecipeContextMenu();
|
|
|
|
// Check for custom filter parameters in session storage
|
|
this._checkCustomFilter();
|
|
|
|
// Expose necessary functions to the page
|
|
this._exposeGlobalFunctions();
|
|
|
|
// Initialize sidebar navigation
|
|
await this._initSidebar();
|
|
|
|
// Initialize common page features
|
|
appCore.initializePageFeatures();
|
|
}
|
|
|
|
async _initSidebar() {
|
|
try {
|
|
sidebarManager.setHostPageControls(this.pageControls);
|
|
const shouldShowSidebar = state?.global?.settings?.show_folder_sidebar !== false;
|
|
await sidebarManager.setSidebarEnabled(shouldShowSidebar);
|
|
} catch (error) {
|
|
console.error('Failed to initialize recipe sidebar:', error);
|
|
}
|
|
}
|
|
|
|
_initSearchOptions() {
|
|
// Ensure recipes search options are properly initialized
|
|
if (!this.pageState.searchOptions) {
|
|
this.pageState.searchOptions = {
|
|
title: true, // Recipe title
|
|
tags: true, // Recipe tags
|
|
loraName: true, // LoRA file name
|
|
loraModel: true, // LoRA model name
|
|
prompt: true, // Prompt search
|
|
recursive: true
|
|
};
|
|
}
|
|
}
|
|
|
|
_exposeGlobalFunctions() {
|
|
// Only expose what's needed for the page
|
|
window.recipeManager = this;
|
|
window.importManager = this.importManager;
|
|
}
|
|
|
|
_checkCustomFilter() {
|
|
// Check for Lora filter
|
|
const filterLoraName = getSessionItem('lora_to_recipe_filterLoraName');
|
|
const filterLoraHash = getSessionItem('lora_to_recipe_filterLoraHash');
|
|
|
|
// Check for specific recipe ID
|
|
const viewRecipeId = getSessionItem('viewRecipeId');
|
|
|
|
// Set custom filter if any parameter is present
|
|
if (filterLoraName || filterLoraHash || viewRecipeId) {
|
|
this.pageState.customFilter = {
|
|
active: true,
|
|
loraName: filterLoraName,
|
|
loraHash: filterLoraHash,
|
|
recipeId: viewRecipeId
|
|
};
|
|
|
|
// Show custom filter indicator
|
|
this._showCustomFilterIndicator();
|
|
}
|
|
}
|
|
|
|
_showCustomFilterIndicator() {
|
|
const indicator = document.getElementById('customFilterIndicator');
|
|
const textElement = document.getElementById('customFilterText');
|
|
|
|
if (!indicator || !textElement) return;
|
|
|
|
// Update text based on filter type
|
|
let filterText = '';
|
|
|
|
if (this.pageState.customFilter.recipeId) {
|
|
filterText = 'Viewing specific recipe';
|
|
} else if (this.pageState.customFilter.loraName) {
|
|
// Format with Lora name
|
|
const loraName = this.pageState.customFilter.loraName;
|
|
const displayName = loraName.length > 25 ?
|
|
loraName.substring(0, 22) + '...' :
|
|
loraName;
|
|
|
|
filterText = `<span>Recipes using: <span class="lora-name">${displayName}</span></span>`;
|
|
} else {
|
|
filterText = 'Filtered recipes';
|
|
}
|
|
|
|
// Update indicator text and show it
|
|
textElement.innerHTML = filterText;
|
|
// Add title attribute to show the lora name as a tooltip
|
|
if (this.pageState.customFilter.loraName) {
|
|
textElement.setAttribute('title', this.pageState.customFilter.loraName);
|
|
}
|
|
indicator.classList.remove('hidden');
|
|
|
|
// Add pulse animation
|
|
const filterElement = indicator.querySelector('.filter-active');
|
|
if (filterElement) {
|
|
filterElement.classList.add('animate');
|
|
setTimeout(() => filterElement.classList.remove('animate'), 600);
|
|
}
|
|
|
|
// Add click handler for clear filter button
|
|
const clearFilterBtn = indicator.querySelector('.clear-filter');
|
|
if (clearFilterBtn) {
|
|
clearFilterBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation(); // Prevent button click from triggering
|
|
this._clearCustomFilter();
|
|
});
|
|
}
|
|
}
|
|
|
|
_clearCustomFilter() {
|
|
// Reset custom filter
|
|
this.pageState.customFilter = {
|
|
active: false,
|
|
loraName: null,
|
|
loraHash: null,
|
|
recipeId: null
|
|
};
|
|
|
|
// Hide indicator
|
|
const indicator = document.getElementById('customFilterIndicator');
|
|
if (indicator) {
|
|
indicator.classList.add('hidden');
|
|
}
|
|
|
|
// Clear any session storage items
|
|
removeSessionItem('lora_to_recipe_filterLoraName');
|
|
removeSessionItem('lora_to_recipe_filterLoraHash');
|
|
removeSessionItem('viewRecipeId');
|
|
|
|
// Reset and refresh the virtual scroller
|
|
refreshVirtualScroll();
|
|
}
|
|
|
|
initEventListeners() {
|
|
// Sort select
|
|
const sortSelect = document.getElementById('sortSelect');
|
|
if (sortSelect) {
|
|
sortSelect.value = this.pageState.sortBy || 'date:desc';
|
|
sortSelect.addEventListener('change', () => {
|
|
this.pageState.sortBy = sortSelect.value;
|
|
refreshVirtualScroll();
|
|
});
|
|
}
|
|
|
|
const bulkButton = document.querySelector('[data-action="bulk"]');
|
|
if (bulkButton) {
|
|
bulkButton.addEventListener('click', () => window.bulkManager?.toggleBulkMode());
|
|
}
|
|
|
|
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
|
|
if (favoriteFilterBtn) {
|
|
favoriteFilterBtn.addEventListener('click', () => {
|
|
this.pageState.showFavoritesOnly = !this.pageState.showFavoritesOnly;
|
|
favoriteFilterBtn.classList.toggle('active', this.pageState.showFavoritesOnly);
|
|
refreshVirtualScroll();
|
|
});
|
|
}
|
|
|
|
// Initialize dropdown functionality for refresh button
|
|
this.initDropdowns();
|
|
}
|
|
|
|
initDropdowns() {
|
|
// Handle dropdown toggles
|
|
const dropdownToggles = document.querySelectorAll('.dropdown-toggle');
|
|
dropdownToggles.forEach(toggle => {
|
|
toggle.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const dropdownGroup = toggle.closest('.dropdown-group');
|
|
|
|
// Close all other open dropdowns first
|
|
document.querySelectorAll('.dropdown-group.active').forEach(group => {
|
|
if (group !== dropdownGroup) {
|
|
group.classList.remove('active');
|
|
}
|
|
});
|
|
|
|
dropdownGroup.classList.toggle('active');
|
|
});
|
|
});
|
|
|
|
// Handle quick refresh option (Sync Changes)
|
|
const quickRefreshOption = document.querySelector('[data-action="quick-refresh"]');
|
|
if (quickRefreshOption) {
|
|
quickRefreshOption.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
this.pageControls.refreshModels(false);
|
|
this.closeDropdowns();
|
|
});
|
|
}
|
|
|
|
// Handle full rebuild option (Rebuild Cache)
|
|
const fullRebuildOption = document.querySelector('[data-action="full-rebuild"]');
|
|
if (fullRebuildOption) {
|
|
fullRebuildOption.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
this.pageControls.refreshModels(true);
|
|
this.closeDropdowns();
|
|
});
|
|
}
|
|
|
|
// Handle main refresh button (default: sync changes)
|
|
const refreshBtn = document.querySelector('[data-action="refresh"]');
|
|
if (refreshBtn) {
|
|
refreshBtn.addEventListener('click', () => {
|
|
this.pageControls.refreshModels(false);
|
|
});
|
|
}
|
|
|
|
// Close dropdowns when clicking outside
|
|
document.addEventListener('click', (e) => {
|
|
if (!e.target.closest('.dropdown-group')) {
|
|
this.closeDropdowns();
|
|
}
|
|
});
|
|
}
|
|
|
|
closeDropdowns() {
|
|
document.querySelectorAll('.dropdown-group.active').forEach(group => {
|
|
group.classList.remove('active');
|
|
});
|
|
}
|
|
|
|
// This method is kept for compatibility but now uses virtual scrolling
|
|
async loadRecipes(resetPage = true) {
|
|
// Skip loading if in duplicates mode
|
|
const pageState = getCurrentPageState();
|
|
if (pageState.duplicatesMode) {
|
|
return;
|
|
}
|
|
|
|
if (resetPage) {
|
|
refreshVirtualScroll();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
|
|
*/
|
|
async refreshRecipes() {
|
|
return refreshRecipes();
|
|
}
|
|
|
|
showRecipeDetails(recipe) {
|
|
this.recipeModal.showRecipeDetails(recipe);
|
|
}
|
|
|
|
// Duplicate detection and management methods
|
|
async findDuplicateRecipes() {
|
|
return await this.duplicatesManager.findDuplicates();
|
|
}
|
|
|
|
selectLatestDuplicates() {
|
|
this.duplicatesManager.selectLatestDuplicates();
|
|
}
|
|
|
|
deleteSelectedDuplicates() {
|
|
this.duplicatesManager.deleteSelectedDuplicates();
|
|
}
|
|
|
|
confirmDeleteDuplicates() {
|
|
this.duplicatesManager.confirmDeleteDuplicates();
|
|
}
|
|
|
|
exitDuplicateMode() {
|
|
// Clear the grid first to prevent showing old content temporarily
|
|
const recipeGrid = document.getElementById('recipeGrid');
|
|
if (recipeGrid) {
|
|
recipeGrid.innerHTML = '';
|
|
}
|
|
|
|
this.duplicatesManager.exitDuplicateMode();
|
|
}
|
|
}
|
|
|
|
// Initialize components
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
// Initialize core application
|
|
await appCore.initialize();
|
|
|
|
// Initialize recipe manager
|
|
const recipeManager = new RecipeManager();
|
|
await recipeManager.initialize();
|
|
});
|
|
|
|
// Export for use in other modules
|
|
export { RecipeManager };
|