feat: implement bulk operations for model management including delete, move, and refresh functionalities

This commit is contained in:
Will Miao
2025-08-05 11:23:20 +08:00
parent 65d5f50088
commit 7abfc49e08
7 changed files with 255 additions and 215 deletions

View File

@@ -779,4 +779,46 @@ export class BaseModelApiClient {
}
return successFilePaths;
}
async bulkDeleteModels(filePaths) {
if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided');
}
try {
state.loadingManager.showSimpleLoading(`Deleting ${this.apiConfig.config.displayName.toLowerCase()}s...`);
const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
file_paths: filePaths
})
});
if (!response.ok) {
throw new Error(`Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s: ${response.statusText}`);
}
const result = await response.json();
if (result.success) {
return {
success: true,
deleted_count: result.deleted_count,
failed_count: result.failed_count || 0,
errors: result.errors || []
};
} else {
throw new Error(result.error || `Failed to delete ${this.apiConfig.config.displayName.toLowerCase()}s`);
}
} catch (error) {
console.error(`Error during bulk delete of ${this.apiConfig.config.displayName.toLowerCase()}s:`, error);
throw error;
} finally {
state.loadingManager.hide();
}
}
}

View File

@@ -5,6 +5,8 @@ import { modalManager } from './managers/ModalManager.js';
import { updateService } from './managers/UpdateService.js';
import { HeaderManager } from './components/Header.js';
import { settingsManager } from './managers/SettingsManager.js';
import { moveManager } from './managers/MoveManager.js';
import { bulkManager } from './managers/BulkManager.js';
import { exampleImagesManager } from './managers/ExampleImagesManager.js';
import { helpManager } from './managers/HelpManager.js';
import { bannerService } from './managers/BannerService.js';
@@ -33,11 +35,16 @@ export class AppCore {
window.settingsManager = settingsManager;
window.exampleImagesManager = exampleImagesManager;
window.helpManager = helpManager;
window.moveManager = moveManager;
window.bulkManager = bulkManager;
// Initialize UI components
window.headerManager = new HeaderManager();
initTheme();
initBackToTop();
// Initialize the bulk manager
bulkManager.initialize();
// Initialize the example images manager
exampleImagesManager.initialize();

View File

@@ -1,7 +1,6 @@
import { appCore } from './core.js';
import { state } from './state/index.js';
import { updateCardsForBulkMode } from './components/shared/ModelCard.js';
import { bulkManager } from './managers/BulkManager.js';
import { LoraContextMenu } from './components/ContextMenu/index.js';
import { createPageControls } from './components/controls/index.js';
import { confirmDelete, closeDeleteModal, confirmExclude, closeExcludeModal } from './utils/modalUtils.js';
@@ -33,14 +32,6 @@ class LoraPageManager {
window.confirmExclude = confirmExclude;
window.closeExcludeModal = closeExcludeModal;
// Bulk operations
window.toggleBulkMode = () => bulkManager.toggleBulkMode();
window.clearSelection = () => bulkManager.clearSelection();
window.toggleCardSelection = (card) => bulkManager.toggleCardSelection(card);
window.copyAllLorasSyntax = () => bulkManager.copyAllLorasSyntax();
window.updateSelectedCount = () => bulkManager.updateSelectedCount();
window.bulkManager = bulkManager;
// Expose duplicates manager
window.modelDuplicatesManager = this.duplicatesManager;
}
@@ -54,9 +45,6 @@ class LoraPageManager {
// Initialize cards for current bulk mode state (should be false initially)
updateCardsForBulkMode(state.bulkMode);
// Initialize the bulk manager
bulkManager.initialize();
// Initialize common page features (virtual scroll)
appCore.initializePageFeatures();
}

View File

@@ -1,147 +1,201 @@
import { state } from '../state/index.js';
import { state, getCurrentPageState } from '../state/index.js';
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { moveManager } from './MoveManager.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
export class BulkManager {
constructor() {
this.bulkBtn = document.getElementById('bulkOperationsBtn');
this.bulkPanel = document.getElementById('bulkOperationsPanel');
this.isStripVisible = false; // Track strip visibility state
this.isStripVisible = false;
// Initialize selected loras set in state if not already there
if (!state.selectedLoras) {
state.selectedLoras = new Set();
}
this.stripMaxThumbnails = 50;
// Cache for lora metadata to handle non-visible selected loras
if (!state.loraMetadataCache) {
state.loraMetadataCache = new Map();
}
this.stripMaxThumbnails = 50; // Maximum thumbnails to show in strip
// Model type specific action configurations
this.actionConfig = {
[MODEL_TYPES.LORA]: {
sendToWorkflow: true,
copyAll: true,
refreshAll: true,
moveAll: true,
deleteAll: true
},
[MODEL_TYPES.EMBEDDING]: {
sendToWorkflow: false,
copyAll: false,
refreshAll: true,
moveAll: true,
deleteAll: true
},
[MODEL_TYPES.CHECKPOINT]: {
sendToWorkflow: false,
copyAll: false,
refreshAll: true,
moveAll: false,
deleteAll: true
}
};
}
initialize() {
// Add event listeners if needed
// (Already handled via onclick attributes in HTML, but could be moved here)
// Add event listeners for the selected count to toggle thumbnail strip
this.setupEventListeners();
this.setupGlobalKeyboardListeners();
}
setupEventListeners() {
// Bulk operations button listeners
const sendToWorkflowBtn = this.bulkPanel?.querySelector('[data-action="send-to-workflow"]');
const copyAllBtn = this.bulkPanel?.querySelector('[data-action="copy-all"]');
const refreshAllBtn = this.bulkPanel?.querySelector('[data-action="refresh-all"]');
const moveAllBtn = this.bulkPanel?.querySelector('[data-action="move-all"]');
const deleteAllBtn = this.bulkPanel?.querySelector('[data-action="delete-all"]');
const clearBtn = this.bulkPanel?.querySelector('[data-action="clear"]');
if (sendToWorkflowBtn) {
sendToWorkflowBtn.addEventListener('click', () => this.sendAllModelsToWorkflow());
}
if (copyAllBtn) {
copyAllBtn.addEventListener('click', () => this.copyAllModelsSyntax());
}
if (refreshAllBtn) {
refreshAllBtn.addEventListener('click', () => this.refreshAllMetadata());
}
if (moveAllBtn) {
moveAllBtn.addEventListener('click', () => {
moveManager.showMoveModal('bulk');
});
}
if (deleteAllBtn) {
deleteAllBtn.addEventListener('click', () => this.showBulkDeleteModal());
}
if (clearBtn) {
clearBtn.addEventListener('click', () => this.clearSelection());
}
// Selected count click listener
const selectedCount = document.getElementById('selectedCount');
if (selectedCount) {
selectedCount.addEventListener('click', () => this.toggleThumbnailStrip());
}
}
// Add global keyboard event listener for Ctrl+A
setupGlobalKeyboardListeners() {
document.addEventListener('keydown', (e) => {
// First check if any modal is currently open - if so, don't handle Ctrl+A
if (modalManager.isAnyModalOpen()) {
return; // Exit early - let the browser handle Ctrl+A within the modal
return;
}
// Check if search input is currently focused - if so, don't handle Ctrl+A
const searchInput = document.getElementById('searchInput');
if (searchInput && document.activeElement === searchInput) {
return; // Exit early - let the browser handle Ctrl+A within the search input
return;
}
// Check if it's Ctrl+A (or Cmd+A on Mac)
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
// Prevent default browser "Select All" behavior
e.preventDefault();
// If not in bulk mode, enable it first
if (!state.bulkMode) {
this.toggleBulkMode();
// Small delay to ensure DOM is updated
setTimeout(() => this.selectAllVisibleLoras(), 50);
setTimeout(() => this.selectAllVisibleModels(), 50);
} else {
this.selectAllVisibleLoras();
this.selectAllVisibleModels();
}
} else if (e.key === 'Escape' && state.bulkMode) {
// If in bulk mode, exit it on Escape
this.toggleBulkMode();
} else if (e.key.toLowerCase() === 'b') {
// If 'b' is pressed, toggle bulk mode
this.toggleBulkMode();
}
});
}
toggleBulkMode() {
// Toggle the state
state.bulkMode = !state.bulkMode;
// Update UI
this.bulkBtn.classList.toggle('active', state.bulkMode);
// Important: Remove the hidden class when entering bulk mode
if (state.bulkMode) {
this.bulkPanel.classList.remove('hidden');
// Use setTimeout to ensure the DOM updates before adding visible class
// This helps with the transition animation
this.updateActionButtonsVisibility();
setTimeout(() => {
this.bulkPanel.classList.add('visible');
}, 10);
} else {
this.bulkPanel.classList.remove('visible');
// Add hidden class back after transition completes
setTimeout(() => {
this.bulkPanel.classList.add('hidden');
}, 400); // Match this with the transition duration in CSS
// Hide thumbnail strip if it's visible
}, 400);
this.hideThumbnailStrip();
}
// First update all cards' visual state before clearing selection
updateCardsForBulkMode(state.bulkMode);
// Clear selection if exiting bulk mode - do this after updating cards
if (!state.bulkMode) {
this.clearSelection();
// TODO: fix this, no DOM manipulation should be done here
// Force a lightweight refresh of the cards to ensure proper display
// This is less disruptive than a full resetAndReload()
// TODO:
document.querySelectorAll('.model-card').forEach(card => {
// Re-apply normal display mode to all card actions
const actions = card.querySelectorAll('.card-actions, .card-button');
actions.forEach(action => action.style.display = 'flex');
});
}
}
updateActionButtonsVisibility() {
const currentModelType = state.currentPageType;
const config = this.actionConfig[currentModelType];
if (!config) return;
// Update button visibility based on model type
const sendToWorkflowBtn = this.bulkPanel?.querySelector('[data-action="send-to-workflow"]');
const copyAllBtn = this.bulkPanel?.querySelector('[data-action="copy-all"]');
const refreshAllBtn = this.bulkPanel?.querySelector('[data-action="refresh-all"]');
const moveAllBtn = this.bulkPanel?.querySelector('[data-action="move-all"]');
const deleteAllBtn = this.bulkPanel?.querySelector('[data-action="delete-all"]');
if (sendToWorkflowBtn) {
sendToWorkflowBtn.style.display = config.sendToWorkflow ? 'block' : 'none';
}
if (copyAllBtn) {
copyAllBtn.style.display = config.copyAll ? 'block' : 'none';
}
if (refreshAllBtn) {
refreshAllBtn.style.display = config.refreshAll ? 'block' : 'none';
}
if (moveAllBtn) {
moveAllBtn.style.display = config.moveAll ? 'block' : 'none';
}
if (deleteAllBtn) {
deleteAllBtn.style.display = config.deleteAll ? 'block' : 'none';
}
}
clearSelection() {
document.querySelectorAll('.model-card.selected').forEach(card => {
card.classList.remove('selected');
});
state.selectedLoras.clear();
state.selectedModels.clear();
this.updateSelectedCount();
// Hide thumbnail strip if it's visible
this.hideThumbnailStrip();
}
updateSelectedCount() {
const countElement = document.getElementById('selectedCount');
const currentConfig = MODEL_CONFIG[state.currentPageType];
const displayName = currentConfig?.displayName || 'Models';
if (countElement) {
// Set text content without the icon
countElement.textContent = `${state.selectedLoras.size} selected `;
countElement.textContent = `${state.selectedModels.size} ${displayName.toLowerCase()}(s) selected `;
// Update caret icon if it exists
const existingCaret = countElement.querySelector('.dropdown-caret');
if (existingCaret) {
existingCaret.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
existingCaret.style.visibility = state.selectedLoras.size > 0 ? 'visible' : 'hidden';
existingCaret.style.visibility = state.selectedModels.size > 0 ? 'visible' : 'hidden';
} else {
// Create new caret icon if it doesn't exist
const caretIcon = document.createElement('i');
caretIcon.className = `fas fa-caret-${this.isStripVisible ? 'down' : 'up'} dropdown-caret`;
caretIcon.style.visibility = state.selectedLoras.size > 0 ? 'visible' : 'hidden';
caretIcon.style.visibility = state.selectedModels.size > 0 ? 'visible' : 'hidden';
countElement.appendChild(caretIcon);
}
}
@@ -149,16 +203,18 @@ export class BulkManager {
toggleCardSelection(card) {
const filepath = card.dataset.filepath;
const pageState = getCurrentPageState();
if (card.classList.contains('selected')) {
card.classList.remove('selected');
state.selectedLoras.delete(filepath);
state.selectedModels.delete(filepath);
} else {
card.classList.add('selected');
state.selectedLoras.add(filepath);
state.selectedModels.add(filepath);
// Cache the metadata for this lora
state.loraMetadataCache.set(filepath, {
// Cache the metadata for this model
const metadataCache = this.getMetadataCache();
metadataCache.set(filepath, {
fileName: card.dataset.file_name,
usageTips: card.dataset.usage_tips,
previewUrl: this.getCardPreviewUrl(card),
@@ -169,35 +225,49 @@ export class BulkManager {
this.updateSelectedCount();
// Update thumbnail strip if it's visible
if (this.isStripVisible) {
this.updateThumbnailStrip();
}
}
getMetadataCache() {
const currentType = state.currentPageType;
const pageState = getCurrentPageState();
// Initialize metadata cache if it doesn't exist
if (currentType === MODEL_TYPES.LORA) {
if (!state.loraMetadataCache) {
state.loraMetadataCache = new Map();
}
return state.loraMetadataCache;
} else {
if (!pageState.metadataCache) {
pageState.metadataCache = new Map();
}
return pageState.metadataCache;
}
}
// Helper method to get preview URL from a card
getCardPreviewUrl(card) {
const img = card.querySelector('img');
const video = card.querySelector('video source');
return img ? img.src : (video ? video.src : '/loras_static/images/no-preview.png');
}
// Helper method to check if preview is a video
isCardPreviewVideo(card) {
return card.querySelector('video') !== null;
}
// Apply selection state to cards after they are refreshed
applySelectionState() {
if (!state.bulkMode) return;
document.querySelectorAll('.model-card').forEach(card => {
const filepath = card.dataset.filepath;
if (state.selectedLoras.has(filepath)) {
if (state.selectedModels.has(filepath)) {
card.classList.add('selected');
// Update the cache with latest data
state.loraMetadataCache.set(filepath, {
const metadataCache = this.getMetadataCache();
metadataCache.set(filepath, {
fileName: card.dataset.file_name,
usageTips: card.dataset.usage_tips,
previewUrl: this.getCardPreviewUrl(card),
@@ -212,30 +282,33 @@ export class BulkManager {
this.updateSelectedCount();
}
async copyAllLorasSyntax() {
if (state.selectedLoras.size === 0) {
async copyAllModelsSyntax() {
if (state.currentPageType !== MODEL_TYPES.LORA) {
showToast('Copy syntax is only available for LoRAs', 'warning');
return;
}
if (state.selectedModels.size === 0) {
showToast('No LoRAs selected', 'warning');
return;
}
const loraSyntaxes = [];
const missingLoras = [];
const metadataCache = this.getMetadataCache();
// Process all selected loras using our metadata cache
for (const filepath of state.selectedLoras) {
const metadata = state.loraMetadataCache.get(filepath);
for (const filepath of state.selectedModels) {
const metadata = metadataCache.get(filepath);
if (metadata) {
const usageTips = JSON.parse(metadata.usageTips || '{}');
const strength = usageTips.strength || 1;
loraSyntaxes.push(`<lora:${metadata.fileName}:${strength}>`);
} else {
// If we don't have metadata, this is an error case
missingLoras.push(filepath);
}
}
// Handle any loras with missing metadata
if (missingLoras.length > 0) {
console.warn('Missing metadata for some selected loras:', missingLoras);
showToast(`Missing data for ${missingLoras.length} LoRAs`, 'warning');
@@ -249,31 +322,33 @@ export class BulkManager {
await copyToClipboard(loraSyntaxes.join(', '), `Copied ${loraSyntaxes.length} LoRA syntaxes to clipboard`);
}
// Add method to send all selected loras to workflow
async sendAllLorasToWorkflow() {
if (state.selectedLoras.size === 0) {
async sendAllModelsToWorkflow() {
if (state.currentPageType !== MODEL_TYPES.LORA) {
showToast('Send to workflow is only available for LoRAs', 'warning');
return;
}
if (state.selectedModels.size === 0) {
showToast('No LoRAs selected', 'warning');
return;
}
const loraSyntaxes = [];
const missingLoras = [];
const metadataCache = this.getMetadataCache();
// Process all selected loras using our metadata cache
for (const filepath of state.selectedLoras) {
const metadata = state.loraMetadataCache.get(filepath);
for (const filepath of state.selectedModels) {
const metadata = metadataCache.get(filepath);
if (metadata) {
const usageTips = JSON.parse(metadata.usageTips || '{}');
const strength = usageTips.strength || 1;
loraSyntaxes.push(`<lora:${metadata.fileName}:${strength}>`);
} else {
// If we don't have metadata, this is an error case
missingLoras.push(filepath);
}
}
// Handle any loras with missing metadata
if (missingLoras.length > 0) {
console.warn('Missing metadata for some selected loras:', missingLoras);
showToast(`Missing data for ${missingLoras.length} LoRAs`, 'warning');
@@ -284,82 +359,48 @@ export class BulkManager {
return;
}
// Send the loras to the workflow
await sendLoraToWorkflow(loraSyntaxes.join(', '), false, 'lora');
}
// Show the bulk delete confirmation modal
showBulkDeleteModal() {
if (state.selectedLoras.size === 0) {
showToast('No LoRAs selected', 'warning');
if (state.selectedModels.size === 0) {
showToast('No models selected', 'warning');
return;
}
// Update the count in the modal
const countElement = document.getElementById('bulkDeleteCount');
if (countElement) {
countElement.textContent = state.selectedLoras.size;
countElement.textContent = state.selectedModels.size;
}
// Show the modal
modalManager.showModal('bulkDeleteModal');
}
// Confirm bulk delete action
async confirmBulkDelete() {
if (state.selectedLoras.size === 0) {
showToast('No LoRAs selected', 'warning');
if (state.selectedModels.size === 0) {
showToast('No models selected', 'warning');
modalManager.closeModal('bulkDeleteModal');
return;
}
// Close the modal first before showing loading indicator
modalManager.closeModal('bulkDeleteModal');
try {
// Show loading indicator
state.loadingManager.showSimpleLoading('Deleting models...');
const apiClient = getModelApiClient();
const filePaths = Array.from(state.selectedModels);
// Gather all file paths for deletion
const filePaths = Array.from(state.selectedLoras);
// Call the backend API
const response = await fetch('/api/loras/bulk-delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
file_paths: filePaths
})
});
const result = await response.json();
const result = await apiClient.bulkDeleteModels(filePaths);
if (result.success) {
showToast(`Successfully deleted ${result.deleted_count} models`, 'success');
const currentConfig = MODEL_CONFIG[state.currentPageType];
showToast(`Successfully deleted ${result.deleted_count} ${currentConfig.displayName.toLowerCase()}(s)`, 'success');
// If virtual scroller exists, update the UI without page reload
if (state.virtualScroller) {
// Remove each deleted item from the virtual scroller
filePaths.forEach(path => {
state.virtualScroller.removeItemByFilePath(path);
});
// Clear the selection
this.clearSelection();
} else {
// Clear the selection
this.clearSelection();
// Fall back to page reload for non-virtual scroll mode
setTimeout(() => {
window.location.reload();
}, 1500);
}
filePaths.forEach(path => {
state.virtualScroller.removeItemByFilePath(path);
});
this.clearSelection();
if (window.modelDuplicatesManager) {
// Update duplicates badge after refresh
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
}
} else {
@@ -368,16 +409,11 @@ export class BulkManager {
} catch (error) {
console.error('Error during bulk delete:', error);
showToast('Failed to delete models', 'error');
} finally {
// Hide loading indicator
state.loadingManager.hide();
}
}
// Create and show the thumbnail strip of selected LoRAs
toggleThumbnailStrip() {
// If no items are selected, do nothing
if (state.selectedLoras.size === 0) return;
if (state.selectedModels.size === 0) return;
const existing = document.querySelector('.selected-thumbnails-strip');
if (existing) {
@@ -388,38 +424,30 @@ export class BulkManager {
}
showThumbnailStrip() {
// Create the thumbnail strip container
const strip = document.createElement('div');
strip.className = 'selected-thumbnails-strip';
// Create a container for the thumbnails (for scrolling)
const thumbnailContainer = document.createElement('div');
thumbnailContainer.className = 'thumbnails-container';
strip.appendChild(thumbnailContainer);
// Position the strip above the bulk operations panel
this.bulkPanel.parentNode.insertBefore(strip, this.bulkPanel);
// Populate the thumbnails
this.updateThumbnailStrip();
// Update strip visibility state and caret direction
this.isStripVisible = true;
this.updateSelectedCount(); // Update caret
this.updateSelectedCount();
// Add animation class after a short delay to trigger transition
setTimeout(() => strip.classList.add('visible'), 10);
}
hideThumbnailStrip() {
const strip = document.querySelector('.selected-thumbnails-strip');
if (strip && this.isStripVisible) { // Only hide if actually visible
if (strip && this.isStripVisible) {
strip.classList.remove('visible');
// Update strip visibility state
this.isStripVisible = false;
// Update caret without triggering another hide
const countElement = document.getElementById('selectedCount');
if (countElement) {
const caret = countElement.querySelector('.dropdown-caret');
@@ -428,7 +456,6 @@ export class BulkManager {
}
}
// Wait for animation to complete before removing
setTimeout(() => {
if (strip.parentNode) {
strip.parentNode.removeChild(strip);
@@ -441,33 +468,28 @@ export class BulkManager {
const container = document.querySelector('.thumbnails-container');
if (!container) return;
// Clear existing thumbnails
container.innerHTML = '';
// Get all selected loras
const selectedLoras = Array.from(state.selectedLoras);
const selectedModels = Array.from(state.selectedModels);
// Create counter if we have more thumbnails than we'll show
if (selectedLoras.length > this.stripMaxThumbnails) {
if (selectedModels.length > this.stripMaxThumbnails) {
const counter = document.createElement('div');
counter.className = 'strip-counter';
counter.textContent = `Showing ${this.stripMaxThumbnails} of ${selectedLoras.length} selected`;
counter.textContent = `Showing ${this.stripMaxThumbnails} of ${selectedModels.length} selected`;
container.appendChild(counter);
}
// Limit the number of thumbnails to display
const thumbnailsToShow = selectedLoras.slice(0, this.stripMaxThumbnails);
const thumbnailsToShow = selectedModels.slice(0, this.stripMaxThumbnails);
const metadataCache = this.getMetadataCache();
// Add a thumbnail for each selected LoRA (limited to max)
thumbnailsToShow.forEach(filepath => {
const metadata = state.loraMetadataCache.get(filepath);
const metadata = metadataCache.get(filepath);
if (!metadata) return;
const thumbnail = document.createElement('div');
thumbnail.className = 'selected-thumbnail';
thumbnail.dataset.filepath = filepath;
// Create the visual element (image or video)
if (metadata.isVideo) {
thumbnail.innerHTML = `
<video autoplay loop muted playsinline>
@@ -484,14 +506,12 @@ export class BulkManager {
`;
}
// Add click handler for deselection
thumbnail.addEventListener('click', (e) => {
if (!e.target.closest('.thumbnail-remove')) {
this.deselectItem(filepath);
}
});
// Add click handler for the remove button
thumbnail.querySelector('.thumbnail-remove').addEventListener('click', (e) => {
e.stopPropagation();
this.deselectItem(filepath);
@@ -502,43 +522,36 @@ export class BulkManager {
}
deselectItem(filepath) {
// Find and deselect the corresponding card if it's in the DOM
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
if (card) {
card.classList.remove('selected');
}
// Remove from the selection set
state.selectedLoras.delete(filepath);
state.selectedModels.delete(filepath);
// Update UI
this.updateSelectedCount();
this.updateThumbnailStrip();
// Hide the strip if no more selections
if (state.selectedLoras.size === 0) {
if (state.selectedModels.size === 0) {
this.hideThumbnailStrip();
}
}
// Add method to select all visible loras
selectAllVisibleLoras() {
// Only select loras already in the VirtualScroller's data model
selectAllVisibleModels() {
if (!state.virtualScroller || !state.virtualScroller.items) {
showToast('Unable to select all items', 'error');
return;
}
const oldCount = state.selectedLoras.size;
const oldCount = state.selectedModels.size;
const metadataCache = this.getMetadataCache();
// Add all loaded loras to the selection set
state.virtualScroller.items.forEach(item => {
if (item && item.file_path) {
state.selectedLoras.add(item.file_path);
state.selectedModels.add(item.file_path);
// Add to metadata cache if not already there
if (!state.loraMetadataCache.has(item.file_path)) {
state.loraMetadataCache.set(item.file_path, {
if (!metadataCache.has(item.file_path)) {
metadataCache.set(item.file_path, {
fileName: item.file_name,
usageTips: item.usage_tips || '{}',
previewUrl: item.preview_url || '/loras_static/images/no-preview.png',
@@ -549,45 +562,37 @@ export class BulkManager {
}
});
// Update visual state
this.applySelectionState();
// Show success message
const newlySelected = state.selectedLoras.size - oldCount;
showToast(`Selected ${newlySelected} additional LoRAs`, 'success');
const newlySelected = state.selectedModels.size - oldCount;
const currentConfig = MODEL_CONFIG[state.currentPageType];
showToast(`Selected ${newlySelected} additional ${currentConfig.displayName.toLowerCase()}(s)`, 'success');
// Update thumbnail strip if visible
if (this.isStripVisible) {
this.updateThumbnailStrip();
}
}
// Add method to refresh metadata for all selected models
async refreshAllMetadata() {
if (state.selectedLoras.size === 0) {
if (state.selectedModels.size === 0) {
showToast('No models selected', 'warning');
return;
}
try {
// Get the API client for the current model type
const apiClient = getModelApiClient();
const filePaths = Array.from(state.selectedModels);
// Convert Set to Array for processing
const filePaths = Array.from(state.selectedLoras);
// Call the bulk refresh method
const result = await apiClient.refreshBulkModelMetadata(filePaths);
if (result.success) {
// Update the metadata cache for successfully refreshed items
for (const filepath of state.selectedLoras) {
const metadata = state.loraMetadataCache.get(filepath);
const metadataCache = this.getMetadataCache();
for (const filepath of state.selectedModels) {
const metadata = metadataCache.get(filepath);
if (metadata) {
// Find the corresponding card to get updated data
const card = document.querySelector(`.model-card[data-filepath="${filepath}"]`);
if (card) {
state.loraMetadataCache.set(filepath, {
metadataCache.set(filepath, {
...metadata,
fileName: card.dataset.file_name,
usageTips: card.dataset.usage_tips,
@@ -599,7 +604,6 @@ export class BulkManager {
}
}
// Update thumbnail strip if visible
if (this.isStripVisible) {
this.updateThumbnailStrip();
}
@@ -612,5 +616,4 @@ export class BulkManager {
}
}
// Create a singleton instance
export const bulkManager = new BulkManager();

View File

@@ -1,6 +1,7 @@
import { showToast, updateFolderTags } from '../utils/uiHelpers.js';
import { state, getCurrentPageState } from '../state/index.js';
import { modalManager } from './ModalManager.js';
import { bulkManager } from './BulkManager.js';
import { getStorageItem } from '../utils/storageHelpers.js';
import { getModelApiClient } from '../api/modelApiFactory.js';
@@ -209,7 +210,7 @@ class MoveManager {
// If we were in bulk mode, exit it after successful move
if (this.bulkFilePaths && state.bulkMode) {
toggleBulkMode();
bulkManager.toggleBulkMode();
}
} catch (error) {

View File

@@ -92,6 +92,7 @@ export const state = {
modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model'
bulkMode: false,
selectedModels: new Set(),
metadataCache: new Map(),
showFavoritesOnly: false,
duplicatesMode: false,
},
@@ -117,6 +118,7 @@ export const state = {
},
bulkMode: false,
selectedModels: new Set(),
metadataCache: new Map(),
showFavoritesOnly: false,
duplicatesMode: false,
}

View File

@@ -50,14 +50,11 @@
<i class="fas fa-cloud-download-alt"></i> Download
</button>
</div>
<!-- Conditional buttons based on page -->
{% if request.path == '/loras' %}
<div class="control-group">
<button id="bulkOperationsBtn" data-action="bulk" title="Bulk Operations (Press B)">
<i class="fas fa-th-large"></i> <span>Bulk <div class="shortcut-key">B</div></span>
</button>
</div>
{% endif %}
<div class="control-group">
<button id="findDuplicatesBtn" data-action="find-duplicates" title="Find duplicate models">
<i class="fas fa-clone"></i> Duplicates
@@ -119,22 +116,22 @@
0 selected <i class="fas fa-caret-down dropdown-caret"></i>
</span>
<div class="bulk-operations-actions">
<button onclick="bulkManager.sendAllLorasToWorkflow()" title="Send all selected LoRAs to workflow">
<button data-action="send-to-workflow" title="Send all selected LoRAs to workflow">
<i class="fas fa-arrow-right"></i> Send to Workflow
</button>
<button onclick="bulkManager.copyAllLorasSyntax()" title="Copy all selected LoRAs syntax">
<button data-action="copy-all" title="Copy all selected LoRAs syntax">
<i class="fas fa-copy"></i> Copy All
</button>
<button onclick="bulkManager.refreshAllMetadata()" title="Refresh CivitAI metadata for selected models">
<button data-action="refresh-all" title="Refresh CivitAI metadata for selected models">
<i class="fas fa-sync-alt"></i> Refresh All
</button>
<button onclick="moveManager.showMoveModal('bulk')" title="Move selected LoRAs to folder">
<button data-action="move-all" title="Move selected models to folder">
<i class="fas fa-folder-open"></i> Move All
</button>
<button onclick="bulkManager.showBulkDeleteModal()" title="Delete selected LoRAs" class="danger-btn">
<button data-action="delete-all" title="Delete selected models" class="danger-btn">
<i class="fas fa-trash"></i> Delete All
</button>
<button onclick="bulkManager.clearSelection()" title="Clear selection">
<button data-action="clear" title="Clear selection">
<i class="fas fa-times"></i> Clear
</button>
</div>