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:
Will Miao
2025-11-26 00:57:35 +08:00
parent 3f646aa0c9
commit 6e64f97e2b
11 changed files with 423 additions and 60 deletions

View File

@@ -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()

View File

@@ -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"),

View File

@@ -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"],
} }
) )

View File

@@ -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();
}
}
} }

View File

@@ -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);
}); });

View File

@@ -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();

View File

@@ -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,

View File

@@ -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 };

View File

@@ -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 %}

View 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();
});
});

View File

@@ -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);
}); });
}); });