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

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

View File

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

View File

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

View File

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

View File

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