mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-23 06:02:11 -03:00
feat: add bulk move recipes endpoint
Add new move_recipes_bulk endpoint to handle moving multiple recipes simultaneously. This improves efficiency when reorganizing recipe collections by allowing batch operations instead of individual moves. - Add move_recipes_bulk handler method with proper error handling - Register new POST /api/lm/recipes/move-bulk route - Implement bulk move logic in persistence service - Validate required parameters (recipe_ids and target_path) - Handle common error cases including validation, not found, and server errors
This commit is contained in:
@@ -12,17 +12,19 @@ const RECIPE_ENDPOINTS = {
|
||||
folderTree: '/api/lm/recipes/folder-tree',
|
||||
unifiedFolderTree: '/api/lm/recipes/unified-folder-tree',
|
||||
move: '/api/lm/recipe/move',
|
||||
moveBulk: '/api/lm/recipes/move-bulk',
|
||||
bulkDelete: '/api/lm/recipes/bulk-delete',
|
||||
};
|
||||
|
||||
const RECIPE_SIDEBAR_CONFIG = {
|
||||
config: {
|
||||
displayName: 'Recipes',
|
||||
displayName: 'Recipe',
|
||||
supportsMove: true,
|
||||
},
|
||||
endpoints: RECIPE_ENDPOINTS,
|
||||
};
|
||||
|
||||
function extractRecipeId(filePath) {
|
||||
export function extractRecipeId(filePath) {
|
||||
if (!filePath) return null;
|
||||
const basename = filePath.split('/').pop().split('\\').pop();
|
||||
const dotIndex = basename.lastIndexOf('.');
|
||||
@@ -373,26 +375,71 @@ export class RecipeSidebarApiClient {
|
||||
}
|
||||
|
||||
async moveBulkModels(filePaths, targetPath) {
|
||||
const results = [];
|
||||
for (const path of filePaths) {
|
||||
try {
|
||||
const result = await this.moveSingleModel(path, targetPath);
|
||||
results.push({
|
||||
original_file_path: path,
|
||||
new_file_path: result?.new_file_path,
|
||||
success: !!result,
|
||||
message: result?.message,
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
original_file_path: path,
|
||||
new_file_path: null,
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
if (!this.apiConfig.config.supportsMove) {
|
||||
showToast('toast.api.bulkMoveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
|
||||
return [];
|
||||
}
|
||||
return results;
|
||||
|
||||
const recipeIds = filePaths
|
||||
.map((path) => extractRecipeId(path))
|
||||
.filter((id) => !!id);
|
||||
|
||||
if (recipeIds.length === 0) {
|
||||
showToast('toast.models.noModelsSelected', {}, 'warning');
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await fetch(this.apiConfig.endpoints.moveBulk, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipe_ids: recipeIds,
|
||||
target_path: targetPath,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || `Failed to move ${this.apiConfig.config.displayName}s`);
|
||||
}
|
||||
|
||||
if (result.failure_count > 0) {
|
||||
showToast(
|
||||
'toast.api.bulkMovePartial',
|
||||
{
|
||||
successCount: result.success_count,
|
||||
type: this.apiConfig.config.displayName,
|
||||
failureCount: result.failure_count,
|
||||
},
|
||||
'warning'
|
||||
);
|
||||
|
||||
const failedFiles = (result.results || [])
|
||||
.filter((item) => !item.success)
|
||||
.map((item) => item.message || 'Unknown error');
|
||||
|
||||
if (failedFiles.length > 0) {
|
||||
const failureMessage =
|
||||
failedFiles.length <= 3
|
||||
? failedFiles.join('\n')
|
||||
: `${failedFiles.slice(0, 3).join('\n')}\n(and ${failedFiles.length - 3} more)`;
|
||||
showToast('toast.api.bulkMoveFailures', { failures: failureMessage }, 'warning', 6000);
|
||||
}
|
||||
} else {
|
||||
showToast(
|
||||
'toast.api.bulkMoveSuccess',
|
||||
{
|
||||
successCount: result.success_count,
|
||||
type: this.apiConfig.config.displayName,
|
||||
},
|
||||
'success'
|
||||
);
|
||||
}
|
||||
|
||||
return result.results || [];
|
||||
}
|
||||
|
||||
async moveSingleModel(filePath, targetPath) {
|
||||
@@ -437,4 +484,47 @@ export class RecipeSidebarApiClient {
|
||||
message: result.message,
|
||||
};
|
||||
}
|
||||
|
||||
async bulkDeleteModels(filePaths) {
|
||||
if (!filePaths || filePaths.length === 0) {
|
||||
throw new Error('No file paths provided');
|
||||
}
|
||||
|
||||
const recipeIds = filePaths
|
||||
.map((path) => extractRecipeId(path))
|
||||
.filter((id) => !!id);
|
||||
|
||||
if (recipeIds.length === 0) {
|
||||
throw new Error('No recipe IDs could be derived from file paths');
|
||||
}
|
||||
|
||||
try {
|
||||
state.loadingManager?.showSimpleLoading('Deleting recipes...');
|
||||
|
||||
const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipe_ids: recipeIds,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || 'Failed to delete recipes');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deleted_count: result.total_deleted,
|
||||
failed_count: result.total_failed || 0,
|
||||
errors: result.failed || [],
|
||||
};
|
||||
} finally {
|
||||
state.loadingManager?.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { configureModelCardVideo } from './shared/ModelCard.js';
|
||||
import { modalManager } from '../managers/ModalManager.js';
|
||||
import { getCurrentPageState } from '../state/index.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { bulkManager } from '../managers/BulkManager.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js';
|
||||
|
||||
class RecipeCard {
|
||||
@@ -164,6 +165,10 @@ class RecipeCard {
|
||||
// Recipe card click event - only attach if not in duplicates mode
|
||||
if (!isDuplicatesMode) {
|
||||
card.addEventListener('click', () => {
|
||||
if (state.bulkMode) {
|
||||
bulkManager.toggleCardSelection(card);
|
||||
return;
|
||||
}
|
||||
this.clickHandler(this.recipe);
|
||||
});
|
||||
|
||||
|
||||
@@ -60,14 +60,12 @@ export class AppCore {
|
||||
initTheme();
|
||||
initBackToTop();
|
||||
|
||||
// Initialize the bulk manager and context menu only if not on recipes page
|
||||
if (state.currentPageType !== 'recipes') {
|
||||
bulkManager.initialize();
|
||||
// Initialize the bulk manager and context menu
|
||||
bulkManager.initialize();
|
||||
|
||||
// Initialize bulk context menu
|
||||
const bulkContextMenu = new BulkContextMenu();
|
||||
bulkManager.setBulkContextMenu(bulkContextMenu);
|
||||
}
|
||||
// Initialize bulk context menu
|
||||
const bulkContextMenu = new BulkContextMenu();
|
||||
bulkManager.setBulkContextMenu(bulkContextMenu);
|
||||
|
||||
// Initialize the example images manager
|
||||
exampleImagesManager.initialize();
|
||||
@@ -121,4 +119,4 @@ export class AppCore {
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
export const appCore = new AppCore();
|
||||
export const appCore = new AppCore();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax, getNSF
|
||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
|
||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
|
||||
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
||||
@@ -62,9 +63,22 @@ export class BulkManager {
|
||||
autoOrganize: true,
|
||||
deleteAll: true,
|
||||
setContentRating: true
|
||||
},
|
||||
recipes: {
|
||||
addTags: false,
|
||||
sendToWorkflow: false,
|
||||
copyAll: false,
|
||||
refreshAll: false,
|
||||
checkUpdates: false,
|
||||
moveAll: true,
|
||||
autoOrganize: false,
|
||||
deleteAll: true,
|
||||
setContentRating: false
|
||||
}
|
||||
};
|
||||
|
||||
this.recipeApiClient = null;
|
||||
|
||||
window.addEventListener('lm:priority-tags-updated', () => {
|
||||
const container = document.querySelector('#bulkAddTagsModal .metadata-suggestions-container');
|
||||
if (!container) {
|
||||
@@ -87,9 +101,6 @@ export class BulkManager {
|
||||
}
|
||||
|
||||
initialize() {
|
||||
// Do not initialize on recipes page
|
||||
if (state.currentPageType === 'recipes') return;
|
||||
|
||||
// Register with event manager for coordinated event handling
|
||||
this.registerEventHandlers();
|
||||
|
||||
@@ -97,6 +108,23 @@ export class BulkManager {
|
||||
eventManager.setState('bulkMode', state.bulkMode || false);
|
||||
}
|
||||
|
||||
getActiveApiClient() {
|
||||
if (state.currentPageType === 'recipes') {
|
||||
if (!this.recipeApiClient) {
|
||||
this.recipeApiClient = new RecipeSidebarApiClient();
|
||||
}
|
||||
return this.recipeApiClient;
|
||||
}
|
||||
return getModelApiClient();
|
||||
}
|
||||
|
||||
getCurrentDisplayConfig() {
|
||||
if (state.currentPageType === 'recipes') {
|
||||
return { displayName: 'Recipe' };
|
||||
}
|
||||
return MODEL_CONFIG[state.currentPageType] || { displayName: 'Model' };
|
||||
}
|
||||
|
||||
setBulkContextMenu(bulkContextMenu) {
|
||||
this.bulkContextMenu = bulkContextMenu;
|
||||
}
|
||||
@@ -240,7 +268,9 @@ export class BulkManager {
|
||||
// Update event manager state
|
||||
eventManager.setState('bulkMode', state.bulkMode);
|
||||
|
||||
this.bulkBtn.classList.toggle('active', state.bulkMode);
|
||||
if (this.bulkBtn) {
|
||||
this.bulkBtn.classList.toggle('active', state.bulkMode);
|
||||
}
|
||||
|
||||
updateCardsForBulkMode(state.bulkMode);
|
||||
|
||||
@@ -504,13 +534,13 @@ export class BulkManager {
|
||||
modalManager.closeModal('bulkDeleteModal');
|
||||
|
||||
try {
|
||||
const apiClient = getModelApiClient();
|
||||
const apiClient = this.getActiveApiClient();
|
||||
const filePaths = Array.from(state.selectedModels);
|
||||
|
||||
const result = await apiClient.bulkDeleteModels(filePaths);
|
||||
|
||||
if (result.success) {
|
||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
||||
const currentConfig = this.getCurrentDisplayConfig();
|
||||
showToast('toast.models.deletedSuccessfully', {
|
||||
count: result.deleted_count,
|
||||
type: currentConfig.displayName.toLowerCase()
|
||||
@@ -570,7 +600,7 @@ export class BulkManager {
|
||||
this.applySelectionState();
|
||||
|
||||
const newlySelected = state.selectedModels.size - oldCount;
|
||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
||||
const currentConfig = this.getCurrentDisplayConfig();
|
||||
showToast('toast.models.selectedAdditional', {
|
||||
count: newlySelected,
|
||||
type: currentConfig.displayName.toLowerCase()
|
||||
@@ -622,8 +652,7 @@ export class BulkManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentType = state.currentPageType;
|
||||
const currentConfig = MODEL_CONFIG[currentType] || MODEL_CONFIG[MODEL_TYPES.LORA];
|
||||
const currentConfig = this.getCurrentDisplayConfig();
|
||||
const typeLabel = (currentConfig?.displayName || 'Model').toLowerCase();
|
||||
|
||||
const { ids: modelIds, missingCount } = this.collectSelectedModelIds();
|
||||
@@ -969,7 +998,7 @@ export class BulkManager {
|
||||
modalManager.closeModal('bulkAddTagsModal');
|
||||
|
||||
if (successCount > 0) {
|
||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
||||
const currentConfig = this.getCurrentDisplayConfig();
|
||||
const toastKey = mode === 'replace' ? 'toast.models.tagsReplacedSuccessfully' : 'toast.models.tagsAddedSuccessfully';
|
||||
showToast(toastKey, {
|
||||
count: successCount,
|
||||
|
||||
@@ -220,6 +220,11 @@ class RecipeManager {
|
||||
refreshVirtualScroll();
|
||||
});
|
||||
}
|
||||
|
||||
const bulkButton = document.querySelector('[data-action="bulk"]');
|
||||
if (bulkButton) {
|
||||
bulkButton.addEventListener('click', () => window.bulkManager?.toggleBulkMode());
|
||||
}
|
||||
}
|
||||
|
||||
// This method is kept for compatibility but now uses virtual scrolling
|
||||
@@ -285,4 +290,4 @@ document.addEventListener('DOMContentLoaded', async () => {
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
export { RecipeManager };
|
||||
export { RecipeManager };
|
||||
|
||||
Reference in New Issue
Block a user