mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22: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:
@@ -66,6 +66,7 @@ class RecipeHandlerSet:
|
|||||||
"update_recipe": self.management.update_recipe,
|
"update_recipe": self.management.update_recipe,
|
||||||
"reconnect_lora": self.management.reconnect_lora,
|
"reconnect_lora": self.management.reconnect_lora,
|
||||||
"find_duplicates": self.query.find_duplicates,
|
"find_duplicates": self.query.find_duplicates,
|
||||||
|
"move_recipes_bulk": self.management.move_recipes_bulk,
|
||||||
"bulk_delete": self.management.bulk_delete,
|
"bulk_delete": self.management.bulk_delete,
|
||||||
"save_recipe_from_widget": self.management.save_recipe_from_widget,
|
"save_recipe_from_widget": self.management.save_recipe_from_widget,
|
||||||
"get_recipes_for_lora": self.query.get_recipes_for_lora,
|
"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)
|
self._logger.error("Error moving recipe: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
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:
|
async def reconnect_lora(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
|
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
|
||||||
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
|
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
|
||||||
RouteDefinition("POST", "/api/lm/recipe/move", "move_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("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
||||||
|
|||||||
@@ -184,8 +184,8 @@ class RecipePersistenceService:
|
|||||||
|
|
||||||
return PersistenceResult({"success": True, "recipe_id": recipe_id, "updates": updates})
|
return PersistenceResult({"success": True, "recipe_id": recipe_id, "updates": updates})
|
||||||
|
|
||||||
async def move_recipe(self, *, recipe_scanner, recipe_id: str, target_path: str) -> PersistenceResult:
|
def _normalize_target_path(self, recipe_scanner, target_path: str) -> tuple[str, str]:
|
||||||
"""Move a recipe's assets into a new folder under the recipes root."""
|
"""Normalize and validate the target path for recipe moves."""
|
||||||
|
|
||||||
if not target_path:
|
if not target_path:
|
||||||
raise RecipeValidationError("Target path is required")
|
raise RecipeValidationError("Target path is required")
|
||||||
@@ -207,6 +207,18 @@ class RecipePersistenceService:
|
|||||||
if common_root != recipes_root:
|
if common_root != recipes_root:
|
||||||
raise RecipeValidationError("Target path must be inside the recipes directory")
|
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)
|
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):
|
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||||
raise RecipeNotFoundError("Recipe not found")
|
raise RecipeNotFoundError("Recipe not found")
|
||||||
@@ -221,15 +233,13 @@ class RecipePersistenceService:
|
|||||||
os.makedirs(normalized_target, exist_ok=True)
|
os.makedirs(normalized_target, exist_ok=True)
|
||||||
|
|
||||||
if os.path.normpath(current_json_dir) == normalized_target:
|
if os.path.normpath(current_json_dir) == normalized_target:
|
||||||
return PersistenceResult(
|
return {
|
||||||
{
|
"success": True,
|
||||||
"success": True,
|
"message": "Recipe is already in the target folder",
|
||||||
"message": "Recipe is already in the target folder",
|
"recipe_id": recipe_id,
|
||||||
"recipe_id": recipe_id,
|
"original_file_path": recipe_data.get("file_path"),
|
||||||
"original_file_path": recipe_data.get("file_path"),
|
"new_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)))
|
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)
|
shutil.move(recipe_json_path, new_json_path)
|
||||||
@@ -250,14 +260,84 @@ class RecipePersistenceService:
|
|||||||
if not updated:
|
if not updated:
|
||||||
raise RecipeNotFoundError("Recipe not found after move")
|
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(
|
return PersistenceResult(
|
||||||
{
|
{
|
||||||
"success": True,
|
"success": True,
|
||||||
"recipe_id": recipe_id,
|
"message": f"Moved {success_count} of {len(recipe_ids)} recipes",
|
||||||
"original_file_path": recipe_data.get("file_path"),
|
"results": results,
|
||||||
"new_file_path": updates["file_path"],
|
"success_count": success_count,
|
||||||
"json_path": new_json_path,
|
"failure_count": failure_count,
|
||||||
"folder": updates["folder"],
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -12,17 +12,19 @@ const RECIPE_ENDPOINTS = {
|
|||||||
folderTree: '/api/lm/recipes/folder-tree',
|
folderTree: '/api/lm/recipes/folder-tree',
|
||||||
unifiedFolderTree: '/api/lm/recipes/unified-folder-tree',
|
unifiedFolderTree: '/api/lm/recipes/unified-folder-tree',
|
||||||
move: '/api/lm/recipe/move',
|
move: '/api/lm/recipe/move',
|
||||||
|
moveBulk: '/api/lm/recipes/move-bulk',
|
||||||
|
bulkDelete: '/api/lm/recipes/bulk-delete',
|
||||||
};
|
};
|
||||||
|
|
||||||
const RECIPE_SIDEBAR_CONFIG = {
|
const RECIPE_SIDEBAR_CONFIG = {
|
||||||
config: {
|
config: {
|
||||||
displayName: 'Recipes',
|
displayName: 'Recipe',
|
||||||
supportsMove: true,
|
supportsMove: true,
|
||||||
},
|
},
|
||||||
endpoints: RECIPE_ENDPOINTS,
|
endpoints: RECIPE_ENDPOINTS,
|
||||||
};
|
};
|
||||||
|
|
||||||
function extractRecipeId(filePath) {
|
export function extractRecipeId(filePath) {
|
||||||
if (!filePath) return null;
|
if (!filePath) return null;
|
||||||
const basename = filePath.split('/').pop().split('\\').pop();
|
const basename = filePath.split('/').pop().split('\\').pop();
|
||||||
const dotIndex = basename.lastIndexOf('.');
|
const dotIndex = basename.lastIndexOf('.');
|
||||||
@@ -373,26 +375,71 @@ export class RecipeSidebarApiClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async moveBulkModels(filePaths, targetPath) {
|
async moveBulkModels(filePaths, targetPath) {
|
||||||
const results = [];
|
if (!this.apiConfig.config.supportsMove) {
|
||||||
for (const path of filePaths) {
|
showToast('toast.api.bulkMoveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
|
||||||
try {
|
return [];
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
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) {
|
async moveSingleModel(filePath, targetPath) {
|
||||||
@@ -437,4 +484,47 @@ export class RecipeSidebarApiClient {
|
|||||||
message: result.message,
|
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 { modalManager } from '../managers/ModalManager.js';
|
||||||
import { getCurrentPageState } from '../state/index.js';
|
import { getCurrentPageState } from '../state/index.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
|
import { bulkManager } from '../managers/BulkManager.js';
|
||||||
import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js';
|
import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js';
|
||||||
|
|
||||||
class RecipeCard {
|
class RecipeCard {
|
||||||
@@ -164,6 +165,10 @@ class RecipeCard {
|
|||||||
// Recipe card click event - only attach if not in duplicates mode
|
// Recipe card click event - only attach if not in duplicates mode
|
||||||
if (!isDuplicatesMode) {
|
if (!isDuplicatesMode) {
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
|
if (state.bulkMode) {
|
||||||
|
bulkManager.toggleCardSelection(card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.clickHandler(this.recipe);
|
this.clickHandler(this.recipe);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -60,14 +60,12 @@ export class AppCore {
|
|||||||
initTheme();
|
initTheme();
|
||||||
initBackToTop();
|
initBackToTop();
|
||||||
|
|
||||||
// Initialize the bulk manager and context menu only if not on recipes page
|
// Initialize the bulk manager and context menu
|
||||||
if (state.currentPageType !== 'recipes') {
|
bulkManager.initialize();
|
||||||
bulkManager.initialize();
|
|
||||||
|
|
||||||
// Initialize bulk context menu
|
// Initialize bulk context menu
|
||||||
const bulkContextMenu = new BulkContextMenu();
|
const bulkContextMenu = new BulkContextMenu();
|
||||||
bulkManager.setBulkContextMenu(bulkContextMenu);
|
bulkManager.setBulkContextMenu(bulkContextMenu);
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the example images manager
|
// Initialize the example images manager
|
||||||
exampleImagesManager.initialize();
|
exampleImagesManager.initialize();
|
||||||
@@ -121,4 +119,4 @@ export class AppCore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create and export a singleton instance
|
// 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 { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||||
|
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
|
||||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||||
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
|
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
|
||||||
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
||||||
@@ -62,9 +63,22 @@ export class BulkManager {
|
|||||||
autoOrganize: true,
|
autoOrganize: true,
|
||||||
deleteAll: true,
|
deleteAll: true,
|
||||||
setContentRating: 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', () => {
|
window.addEventListener('lm:priority-tags-updated', () => {
|
||||||
const container = document.querySelector('#bulkAddTagsModal .metadata-suggestions-container');
|
const container = document.querySelector('#bulkAddTagsModal .metadata-suggestions-container');
|
||||||
if (!container) {
|
if (!container) {
|
||||||
@@ -87,9 +101,6 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initialize() {
|
initialize() {
|
||||||
// Do not initialize on recipes page
|
|
||||||
if (state.currentPageType === 'recipes') return;
|
|
||||||
|
|
||||||
// Register with event manager for coordinated event handling
|
// Register with event manager for coordinated event handling
|
||||||
this.registerEventHandlers();
|
this.registerEventHandlers();
|
||||||
|
|
||||||
@@ -97,6 +108,23 @@ export class BulkManager {
|
|||||||
eventManager.setState('bulkMode', state.bulkMode || false);
|
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) {
|
setBulkContextMenu(bulkContextMenu) {
|
||||||
this.bulkContextMenu = bulkContextMenu;
|
this.bulkContextMenu = bulkContextMenu;
|
||||||
}
|
}
|
||||||
@@ -240,7 +268,9 @@ export class BulkManager {
|
|||||||
// Update event manager state
|
// Update event manager state
|
||||||
eventManager.setState('bulkMode', state.bulkMode);
|
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);
|
updateCardsForBulkMode(state.bulkMode);
|
||||||
|
|
||||||
@@ -504,13 +534,13 @@ export class BulkManager {
|
|||||||
modalManager.closeModal('bulkDeleteModal');
|
modalManager.closeModal('bulkDeleteModal');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiClient = getModelApiClient();
|
const apiClient = this.getActiveApiClient();
|
||||||
const filePaths = Array.from(state.selectedModels);
|
const filePaths = Array.from(state.selectedModels);
|
||||||
|
|
||||||
const result = await apiClient.bulkDeleteModels(filePaths);
|
const result = await apiClient.bulkDeleteModels(filePaths);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
const currentConfig = this.getCurrentDisplayConfig();
|
||||||
showToast('toast.models.deletedSuccessfully', {
|
showToast('toast.models.deletedSuccessfully', {
|
||||||
count: result.deleted_count,
|
count: result.deleted_count,
|
||||||
type: currentConfig.displayName.toLowerCase()
|
type: currentConfig.displayName.toLowerCase()
|
||||||
@@ -570,7 +600,7 @@ export class BulkManager {
|
|||||||
this.applySelectionState();
|
this.applySelectionState();
|
||||||
|
|
||||||
const newlySelected = state.selectedModels.size - oldCount;
|
const newlySelected = state.selectedModels.size - oldCount;
|
||||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
const currentConfig = this.getCurrentDisplayConfig();
|
||||||
showToast('toast.models.selectedAdditional', {
|
showToast('toast.models.selectedAdditional', {
|
||||||
count: newlySelected,
|
count: newlySelected,
|
||||||
type: currentConfig.displayName.toLowerCase()
|
type: currentConfig.displayName.toLowerCase()
|
||||||
@@ -622,8 +652,7 @@ export class BulkManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentType = state.currentPageType;
|
const currentConfig = this.getCurrentDisplayConfig();
|
||||||
const currentConfig = MODEL_CONFIG[currentType] || MODEL_CONFIG[MODEL_TYPES.LORA];
|
|
||||||
const typeLabel = (currentConfig?.displayName || 'Model').toLowerCase();
|
const typeLabel = (currentConfig?.displayName || 'Model').toLowerCase();
|
||||||
|
|
||||||
const { ids: modelIds, missingCount } = this.collectSelectedModelIds();
|
const { ids: modelIds, missingCount } = this.collectSelectedModelIds();
|
||||||
@@ -969,7 +998,7 @@ export class BulkManager {
|
|||||||
modalManager.closeModal('bulkAddTagsModal');
|
modalManager.closeModal('bulkAddTagsModal');
|
||||||
|
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
const currentConfig = this.getCurrentDisplayConfig();
|
||||||
const toastKey = mode === 'replace' ? 'toast.models.tagsReplacedSuccessfully' : 'toast.models.tagsAddedSuccessfully';
|
const toastKey = mode === 'replace' ? 'toast.models.tagsReplacedSuccessfully' : 'toast.models.tagsAddedSuccessfully';
|
||||||
showToast(toastKey, {
|
showToast(toastKey, {
|
||||||
count: successCount,
|
count: successCount,
|
||||||
|
|||||||
@@ -220,6 +220,11 @@ class RecipeManager {
|
|||||||
refreshVirtualScroll();
|
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
|
// 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 for use in other modules
|
||||||
export { RecipeManager };
|
export { RecipeManager };
|
||||||
|
|||||||
@@ -59,6 +59,13 @@
|
|||||||
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> {{
|
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> {{
|
||||||
t('loras.controls.duplicates.action') }}</button>
|
t('loras.controls.duplicates.action') }}</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="control-group" title="{{ t('loras.controls.bulk.title') }}">
|
||||||
|
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
|
||||||
|
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span>
|
||||||
|
<div class="shortcut-key">B</div>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
<!-- Custom filter indicator button (hidden by default) -->
|
<!-- Custom filter indicator button (hidden by default) -->
|
||||||
<div id="customFilterIndicator" class="control-group hidden">
|
<div id="customFilterIndicator" class="control-group hidden">
|
||||||
<div class="filter-active">
|
<div class="filter-active">
|
||||||
@@ -98,6 +105,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block overlay %}
|
||||||
|
<div class="bulk-mode-overlay"></div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block main_script %}
|
{% block main_script %}
|
||||||
<script type="module" src="/loras_static/js/recipes.js?v={{ version }}"></script>
|
<script type="module" src="/loras_static/js/recipes.js?v={{ version }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
114
tests/frontend/api/recipeApi.bulk.test.js
Normal file
114
tests/frontend/api/recipeApi.bulk.test.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const showToastMock = vi.hoisted(() => vi.fn());
|
||||||
|
const loadingManagerMock = vi.hoisted(() => ({
|
||||||
|
showSimpleLoading: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => {
|
||||||
|
return {
|
||||||
|
showToast: showToastMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
|
||||||
|
RecipeCard: vi.fn(() => ({ element: document.createElement('div') })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/state/index.js', () => {
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
loadingManager: loadingManagerMock,
|
||||||
|
},
|
||||||
|
getCurrentPageState: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { RecipeSidebarApiClient } from '../../../static/js/api/recipeApi.js';
|
||||||
|
|
||||||
|
describe('RecipeSidebarApiClient bulk operations', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends recipe IDs when moving in bulk', async () => {
|
||||||
|
const api = new RecipeSidebarApiClient();
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
recipe_id: 'abc',
|
||||||
|
original_file_path: '/recipes/abc.webp',
|
||||||
|
new_file_path: '/recipes/target/abc.webp',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
success_count: 1,
|
||||||
|
failure_count: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await api.moveBulkModels(['/recipes/abc.webp'], '/target/folder');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/lm/recipes/move-bulk',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { body } = global.fetch.mock.calls[0][1];
|
||||||
|
expect(JSON.parse(body)).toEqual({
|
||||||
|
recipe_ids: ['abc'],
|
||||||
|
target_path: '/target/folder',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.api.bulkMoveSuccess',
|
||||||
|
{ successCount: 1, type: 'Recipe' },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
expect(results[0].recipe_id).toBe('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts recipe IDs for bulk delete', async () => {
|
||||||
|
const api = new RecipeSidebarApiClient();
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
total_deleted: 2,
|
||||||
|
total_failed: 0,
|
||||||
|
failed: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await api.bulkDeleteModels(['/recipes/a.webp', '/recipes/b.webp']);
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/lm/recipes/bulk-delete',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsedBody = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||||
|
expect(parsedBody.recipe_ids).toEqual(['a', 'b']);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
deleted_count: 2,
|
||||||
|
failed_count: 0,
|
||||||
|
});
|
||||||
|
expect(loadingManagerMock.hide).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -252,13 +252,13 @@ describe('AppCore initialization flow', () => {
|
|||||||
expect(onboardingManager.start).not.toHaveBeenCalled();
|
expect(onboardingManager.start).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips bulk setup when viewing recipes', async () => {
|
it('initializes bulk setup when viewing recipes', async () => {
|
||||||
state.currentPageType = 'recipes';
|
state.currentPageType = 'recipes';
|
||||||
|
|
||||||
await appCore.initialize();
|
await appCore.initialize();
|
||||||
|
|
||||||
expect(bulkManager.initialize).not.toHaveBeenCalled();
|
expect(bulkManager.initialize).toHaveBeenCalledTimes(1);
|
||||||
expect(BulkContextMenu).not.toHaveBeenCalled();
|
expect(BulkContextMenu).toHaveBeenCalledTimes(1);
|
||||||
expect(bulkManager.setBulkContextMenu).not.toHaveBeenCalled();
|
expect(bulkManager.setBulkContextMenu).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user