diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index cf582bf7..669d5d42 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -66,6 +66,7 @@ class RecipeHandlerSet: "update_recipe": self.management.update_recipe, "reconnect_lora": self.management.reconnect_lora, "find_duplicates": self.query.find_duplicates, + "move_recipes_bulk": self.management.move_recipes_bulk, "bulk_delete": self.management.bulk_delete, "save_recipe_from_widget": self.management.save_recipe_from_widget, "get_recipes_for_lora": self.query.get_recipes_for_lora, @@ -635,6 +636,35 @@ class RecipeManagementHandler: self._logger.error("Error moving recipe: %s", exc, exc_info=True) return web.json_response({"success": False, "error": str(exc)}, status=500) + async def move_recipes_bulk(self, request: web.Request) -> web.Response: + try: + await self._ensure_dependencies_ready() + recipe_scanner = self._recipe_scanner_getter() + if recipe_scanner is None: + raise RuntimeError("Recipe scanner unavailable") + + data = await request.json() + recipe_ids = data.get("recipe_ids") or [] + target_path = data.get("target_path") + if not recipe_ids or not target_path: + return web.json_response( + {"success": False, "error": "recipe_ids and target_path are required"}, status=400 + ) + + result = await self._persistence_service.move_recipes_bulk( + recipe_scanner=recipe_scanner, + recipe_ids=recipe_ids, + target_path=str(target_path), + ) + return web.json_response(result.payload, status=result.status) + except RecipeValidationError as exc: + return web.json_response({"success": False, "error": str(exc)}, status=400) + except RecipeNotFoundError as exc: + return web.json_response({"success": False, "error": str(exc)}, status=404) + except Exception as exc: + self._logger.error("Error moving recipes in bulk: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + async def reconnect_lora(self, request: web.Request) -> web.Response: try: await self._ensure_dependencies_ready() diff --git a/py/routes/recipe_route_registrar.py b/py/routes/recipe_route_registrar.py index f397f501..b91693c4 100644 --- a/py/routes/recipe_route_registrar.py +++ b/py/routes/recipe_route_registrar.py @@ -36,6 +36,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"), RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"), RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"), + RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"), RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"), RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"), RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"), diff --git a/py/services/recipes/persistence_service.py b/py/services/recipes/persistence_service.py index 98d7e7d5..be8b3777 100644 --- a/py/services/recipes/persistence_service.py +++ b/py/services/recipes/persistence_service.py @@ -184,8 +184,8 @@ class RecipePersistenceService: return PersistenceResult({"success": True, "recipe_id": recipe_id, "updates": updates}) - async def move_recipe(self, *, recipe_scanner, recipe_id: str, target_path: str) -> PersistenceResult: - """Move a recipe's assets into a new folder under the recipes root.""" + def _normalize_target_path(self, recipe_scanner, target_path: str) -> tuple[str, str]: + """Normalize and validate the target path for recipe moves.""" if not target_path: raise RecipeValidationError("Target path is required") @@ -207,6 +207,18 @@ class RecipePersistenceService: if common_root != recipes_root: raise RecipeValidationError("Target path must be inside the recipes directory") + return normalized_target, recipes_root + + async def _move_recipe_files( + self, + *, + recipe_scanner, + recipe_id: str, + normalized_target: str, + recipes_root: str, + ) -> dict[str, Any]: + """Move the recipe's JSON and preview image into the normalized target.""" + recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id) if not recipe_json_path or not os.path.exists(recipe_json_path): raise RecipeNotFoundError("Recipe not found") @@ -221,15 +233,13 @@ class RecipePersistenceService: os.makedirs(normalized_target, exist_ok=True) if os.path.normpath(current_json_dir) == normalized_target: - return PersistenceResult( - { - "success": True, - "message": "Recipe is already in the target folder", - "recipe_id": recipe_id, - "original_file_path": recipe_data.get("file_path"), - "new_file_path": recipe_data.get("file_path"), - } - ) + return { + "success": True, + "message": "Recipe is already in the target folder", + "recipe_id": recipe_id, + "original_file_path": recipe_data.get("file_path"), + "new_file_path": recipe_data.get("file_path"), + } new_json_path = os.path.normpath(os.path.join(normalized_target, os.path.basename(recipe_json_path))) shutil.move(recipe_json_path, new_json_path) @@ -250,14 +260,84 @@ class RecipePersistenceService: if not updated: raise RecipeNotFoundError("Recipe not found after move") + return { + "success": True, + "recipe_id": recipe_id, + "original_file_path": recipe_data.get("file_path"), + "new_file_path": updates["file_path"], + "json_path": new_json_path, + "folder": updates["folder"], + } + + async def move_recipe(self, *, recipe_scanner, recipe_id: str, target_path: str) -> PersistenceResult: + """Move a recipe's assets into a new folder under the recipes root.""" + + normalized_target, recipes_root = self._normalize_target_path(recipe_scanner, target_path) + result = await self._move_recipe_files( + recipe_scanner=recipe_scanner, + recipe_id=recipe_id, + normalized_target=normalized_target, + recipes_root=recipes_root, + ) + return PersistenceResult(result) + + async def move_recipes_bulk( + self, + *, + recipe_scanner, + recipe_ids: Iterable[str], + target_path: str, + ) -> PersistenceResult: + """Move multiple recipes to a new folder.""" + + recipe_ids = list(recipe_ids) + if not recipe_ids: + raise RecipeValidationError("No recipe IDs provided") + + normalized_target, recipes_root = self._normalize_target_path(recipe_scanner, target_path) + + results: list[dict[str, Any]] = [] + success_count = 0 + failure_count = 0 + + for recipe_id in recipe_ids: + try: + move_result = await self._move_recipe_files( + recipe_scanner=recipe_scanner, + recipe_id=str(recipe_id), + normalized_target=normalized_target, + recipes_root=recipes_root, + ) + results.append( + { + "recipe_id": recipe_id, + "original_file_path": move_result.get("original_file_path"), + "new_file_path": move_result.get("new_file_path"), + "success": True, + "message": move_result.get("message", ""), + "folder": move_result.get("folder", ""), + } + ) + success_count += 1 + except Exception as exc: # pragma: no cover - per-item error handling + results.append( + { + "recipe_id": recipe_id, + "original_file_path": None, + "new_file_path": None, + "success": False, + "message": str(exc), + } + ) + failure_count += 1 + return PersistenceResult( { "success": True, - "recipe_id": recipe_id, - "original_file_path": recipe_data.get("file_path"), - "new_file_path": updates["file_path"], - "json_path": new_json_path, - "folder": updates["folder"], + "message": f"Moved {success_count} of {len(recipe_ids)} recipes", + "results": results, + "success_count": success_count, + "failure_count": failure_count, } ) diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js index 632edf49..33deea7e 100644 --- a/static/js/api/recipeApi.js +++ b/static/js/api/recipeApi.js @@ -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(); + } + } } diff --git a/static/js/components/RecipeCard.js b/static/js/components/RecipeCard.js index 2c42ddf8..13ef4b5d 100644 --- a/static/js/components/RecipeCard.js +++ b/static/js/components/RecipeCard.js @@ -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); }); diff --git a/static/js/core.js b/static/js/core.js index 11c09a1a..b78c07bf 100644 --- a/static/js/core.js +++ b/static/js/core.js @@ -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(); \ No newline at end of file +export const appCore = new AppCore(); diff --git a/static/js/managers/BulkManager.js b/static/js/managers/BulkManager.js index 10eda62a..8b32adca 100644 --- a/static/js/managers/BulkManager.js +++ b/static/js/managers/BulkManager.js @@ -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, diff --git a/static/js/recipes.js b/static/js/recipes.js index 99327c75..a535652e 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -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 }; \ No newline at end of file +export { RecipeManager }; diff --git a/templates/recipes.html b/templates/recipes.html index f4f1d5bd..5f4defd9 100644 --- a/templates/recipes.html +++ b/templates/recipes.html @@ -59,6 +59,13 @@ +
+ +