Files
ComfyUI-Lora-Manager/static/js/managers/BulkManager.js
T
Will Miao a17399d667 feat(recipes): delegate CivitAI-image re-import to companion browser extension
Recipes imported from CivitAI image URLs can contain 0 LoRAs: the backend
only sees the REST image API + EXIF, while the complete generation data
lives in the image page's internal trpc payload (see
docs/recipe-civitai-image-no-metadata.md). When the companion
lm-civitai-extension is installed with a valid license, re-import (single
and bulk) of CivitAI-image-sourced recipes is now delegated to the
extension via DOM CustomEvents; the extension scrapes the image page with
the user's session and calls back into the reimport endpoint with the
full metadata payload. Without the extension (or with an invalid license)
the native path runs unchanged.

- POST /api/lm/recipe/{id}/reimport accepts optional payload params
  (image_url/name/resources/gen_params/base_model/tags); the payload path
  reuses the import-remote engine with reimport semantics (user-edit
  carryover, delete-after-save), and malformed/failed payloads fall back
  to the legacy URL import. Response gains loras_count.
- The endpoint also accepts GET: the extension is GET-only by convention
  (documented in AGENTS.md).
- New static/js/utils/extensionReimportBridge.js (probeExtension /
  delegateReimport / getCivitaiImageInfo) wired into RecipeContextMenu
  and BulkManager with silent native fallback.
- i18n: toast.recipes.reimportingViaExtension added and translated in
  all 9 locales.
2026-09-06 20:26:14 +08:00

2282 lines
84 KiB
JavaScript

import { state, getCurrentPageState } from '../state/index.js';
import { showToast, showActionToast, copyToClipboard, sendLoraToWorkflow, sendEmbeddingToWorkflow, buildLoraSyntax, getNSFWLevelName } from '../utils/uiHelpers.js';
import { handleUndoDelete } from '../utils/undoHelpers.js';
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { RecipeSidebarApiClient, updateRecipeMetadata, extractRecipeId } from '../api/recipeApi.js';
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
import { createBaseModelPicker, inferBaseModelsFromFilepaths } from '../components/shared/BaseModelPicker.js';
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
import { eventManager } from '../utils/EventManager.js';
import { translate } from '../utils/i18nHelpers.js';
import { probeExtension, delegateReimport, getCivitaiImageInfo } from '../utils/extensionReimportBridge.js';
import { getNsfwLevelSelector } from '../components/shared/NsfwLevelSelector.js';
export class BulkManager {
constructor() {
this.bulkBtn = document.getElementById('bulkOperationsBtn');
// Remove bulk panel references since we're using context menu now
this.bulkContextMenu = null; // Will be set by core initialization
// Marquee selection properties
this.isMarqueeActive = false;
this.isDragging = false;
this.marqueeStart = { x: 0, y: 0 };
this.marqueeStartDoc = { x: 0, y: 0 }; // Marquee start in document coordinates
this.marqueeElement = null;
this.initialSelectedModels = new Set();
// Shift+click range anchor: last plain-clicked filepath. Set in
// toggleCardSelection, cleared in clearSelection.
this.bulkAnchorFilepath = null;
// Bulk base model picker state
this.bulkBaseModelPicker = null;
this.bulkBaseModelValue = '';
// Drag detection properties
this.dragThreshold = 5; // Pixels to move before considering it a drag
this.dragDelayMs = 100; // Minimum hold time before a drag is treated as a marquee
this.minMarqueeSize = 10; // Minimum drag box (px) before a marquee counts as a selection
this.mouseDownTime = 0;
this.mouseDownPosition = { x: 0, y: 0 };
// Auto-scroll properties for marquee
this.lastClientX = 0;
this.lastClientY = 0;
this.autoScrollRaf = null;
// Model type specific action configurations
this.actionConfig = {
[MODEL_TYPES.LORA]: {
addTags: true,
sendToWorkflow: true,
copyAll: true,
refreshAll: true,
checkUpdates: true,
moveAll: true,
autoOrganize: true,
deleteAll: true,
setContentRating: true,
skipMetadataRefresh: true,
setFavorite: true,
unfavorite: true
},
[MODEL_TYPES.EMBEDDING]: {
addTags: true,
sendToWorkflow: true,
copyAll: false,
refreshAll: true,
checkUpdates: true,
moveAll: true,
autoOrganize: true,
deleteAll: true,
setContentRating: false,
skipMetadataRefresh: true,
setFavorite: true,
unfavorite: true
},
[MODEL_TYPES.CHECKPOINT]: {
addTags: true,
sendToWorkflow: false,
copyAll: false,
refreshAll: true,
checkUpdates: true,
moveAll: false,
autoOrganize: true,
deleteAll: true,
setContentRating: true,
skipMetadataRefresh: true,
setFavorite: true,
unfavorite: true
},
recipes: {
addTags: true,
sendToWorkflow: false,
copyAll: false,
refreshAll: false,
checkUpdates: false,
moveAll: true,
autoOrganize: false,
deleteAll: true,
setContentRating: true,
skipMetadataRefresh: false,
setFavorite: true,
unfavorite: true,
reimportMetadata: true,
rematchMetadata: true
}
};
this.recipeApiClient = null;
window.addEventListener('lm:priority-tags-updated', () => {
const container = document.querySelector('#bulkAddTagsModal .metadata-suggestions-container');
if (!container) {
return;
}
const currentType = state.currentPageType;
if (!currentType || currentType === 'recipes') {
return;
}
getPriorityTagSuggestions(currentType).then((tags) => {
if (!container.isConnected) {
return;
}
this.renderBulkSuggestionItems(container, tags);
this.updateBulkSuggestionsDropdown();
}).catch(() => {
// Ignore refresh failures; UI will retry on next open
});
});
}
initialize() {
// Register with event manager for coordinated event handling
this.registerEventHandlers();
// Initialize bulk mode state in event manager
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;
}
/**
* Register all event handlers with the centralized event manager
*/
registerEventHandlers() {
// Register keyboard shortcuts with high priority
eventManager.addHandler('keydown', 'bulkManager-keyboard', (e) => {
return this.handleGlobalKeyboard(e);
}, {
priority: 100,
skipWhenModalOpen: true
});
// Register marquee selection events
eventManager.addHandler('mousedown', 'bulkManager-marquee-start', (e) => {
return this.handleMarqueeStart(e);
}, {
priority: 80,
skipWhenModalOpen: true,
targetSelector: '.page-content',
excludeSelector: '.model-card, button, input, folder-sidebar, .breadcrumb-item, #path-part, .context-menu',
button: 0 // Left mouse button only
});
eventManager.addHandler('mousemove', 'bulkManager-marquee-move', (e) => {
// Only track marquee/drag while the left button is physically held.
// mouseup can be missed (release outside the window, focus loss, driver quirks),
// so mousemove must verify the button state itself instead of relying on it.
if (!(e.buttons & 1)) {
if (this.isMarqueeActive) {
this.endMarqueeSelection(e);
} else {
this.mouseDownTime = 0;
this.isDragging = false;
}
return false;
}
if (this.isMarqueeActive) {
this.lastClientX = e.clientX;
this.lastClientY = e.clientY;
this.updateMarqueeSelection(e);
this.startAutoScroll();
} else if (this.mouseDownTime && !this.isDragging) {
// Check if we've moved enough to consider it a drag
const dx = e.clientX - this.mouseDownPosition.x;
const dy = e.clientY - this.mouseDownPosition.y;
const distance = Math.sqrt(dx * dx + dy * dy);
// Require both enough movement AND enough hold time so quick
// click jitter from micro-movement input devices is not a marquee.
const heldTime = Date.now() - this.mouseDownTime;
if (heldTime >= this.dragDelayMs && distance >= this.dragThreshold) {
this.isDragging = true;
this.startMarqueeSelection(e, true);
}
}
}, {
priority: 90,
skipWhenModalOpen: true
});
eventManager.addHandler('mouseup', 'bulkManager-marquee-end', (e) => {
if (this.isMarqueeActive) {
this.endMarqueeSelection(e);
return true; // Stop propagation
}
// Reset drag detection if we had a mousedown but didn't drag
if (this.mouseDownTime) {
this.mouseDownTime = 0;
return false; // Allow other handlers to process the click
}
}, {
priority: 90
});
eventManager.addHandler('contextmenu', 'bulkManager-marquee-prevent', (e) => {
if (this.isMarqueeActive) {
e.preventDefault();
return true; // Stop propagation
}
}, {
priority: 100
});
// Modified: Clear selection and exit bulk mode on left-click page-content blank area
// Lower priority to avoid interfering with context menu interactions
eventManager.addHandler('mousedown', 'bulkManager-clear-on-blank', (e) => {
// Only handle left mouse button
if (e.button !== 0) return false;
// Only if in bulk mode and there are selected models
if (state.bulkMode && state.selectedModels && state.selectedModels.size > 0) {
// Check if click is on blank area (not on a model card or excluded elements)
this.clearSelection();
this.toggleBulkMode();
// Prevent further handling
return true;
}
return false;
}, {
priority: 70, // Lower priority to let context menu events process first
onlyInBulkMode: true,
skipWhenModalOpen: true,
targetSelector: '.page-content',
excludeSelector: '.model-card, button, input, folder-sidebar, .breadcrumb-item, #path-part, .context-menu, .context-menu *',
button: 0 // Left mouse button only
});
}
/**
* Clean up event handlers
*/
cleanup() {
this.stopAutoScroll();
eventManager.removeAllHandlersForSource('bulkManager-keyboard');
eventManager.removeAllHandlersForSource('bulkManager-marquee-start');
eventManager.removeAllHandlersForSource('bulkManager-marquee-move');
eventManager.removeAllHandlersForSource('bulkManager-marquee-end');
eventManager.removeAllHandlersForSource('bulkManager-marquee-prevent');
eventManager.removeAllHandlersForSource('bulkManager-clear-on-blank');
}
/**
* Handle global keyboard events through the event manager
*/
handleGlobalKeyboard(e) {
// Skip if modal is open (handled by event manager conditions)
if (this.isEditingTextInputContext(e.target)) {
return false; // Don't handle, allow default behavior
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'a') {
e.preventDefault();
if (!state.bulkMode) {
this.toggleBulkMode();
setTimeout(() => this.selectAllVisibleModels(), 50);
} else {
this.selectAllVisibleModels();
}
return true; // Stop propagation
} else if (e.key === 'Escape' && state.bulkMode) {
this.toggleBulkMode();
return true; // Stop propagation
} else if (e.key.toLowerCase() === 'b') {
this.toggleBulkMode();
return true; // Stop propagation
}
return false; // Continue with other handlers
}
isEditingTextInputContext(target) {
const activeElement = document.activeElement;
const candidate = target instanceof Element ? target : activeElement;
if (!candidate) {
return false;
}
const tagName = candidate.tagName?.toLowerCase();
if (
candidate.isContentEditable
|| tagName === 'input'
|| tagName === 'textarea'
|| tagName === 'select'
) {
return true;
}
return Boolean(candidate.closest?.('#filterPanel'));
}
toggleBulkMode() {
state.bulkMode = !state.bulkMode;
// Update event manager state
eventManager.setState('bulkMode', state.bulkMode);
if (this.bulkBtn) {
this.bulkBtn.classList.toggle('active', state.bulkMode);
}
updateCardsForBulkMode(state.bulkMode);
if (!state.bulkMode) {
this.clearSelection();
// Hide context menu when exiting bulk mode
if (this.bulkContextMenu) {
this.bulkContextMenu.hideMenu();
}
}
}
clearSelection() {
document.querySelectorAll('.model-card.selected').forEach(card => {
card.classList.remove('selected');
});
state.selectedModels.clear();
this.bulkAnchorFilepath = null;
// Update context menu header if visible
if (this.bulkContextMenu) {
this.bulkContextMenu.updateSelectedCountHeader();
}
}
toggleCardSelection(card, extendSelection = false) {
const filepath = card.dataset.filepath;
if (extendSelection && this.selectRangeFromAnchor(filepath)) {
return;
}
if (card.classList.contains('selected')) {
card.classList.remove('selected');
state.selectedModels.delete(filepath);
} else {
card.classList.add('selected');
state.selectedModels.add(filepath);
// Cache the metadata for this model
this.updateMetadataCacheFromCard(filepath, card);
}
this.bulkAnchorFilepath = filepath;
// Update context menu header if visible
if (this.bulkContextMenu) {
this.bulkContextMenu.updateSelectedCountHeader();
}
}
/**
* Select exactly the items between the shift anchor and the target
* (inclusive), following list order. Explorer-style range semantics:
* selections outside the new range are dropped, and consecutive shifts
* re-derive the range from the same anchor. Returns false when there is
* no usable anchor so the caller can fall back to a single-card toggle.
*/
selectRangeFromAnchor(targetFilepath) {
const scroller = state.virtualScroller;
if (!scroller || !scroller.items || !this.bulkAnchorFilepath) {
return false;
}
const anchorIndex = scroller.findIndexByFilePath(this.bulkAnchorFilepath);
const targetIndex = scroller.findIndexByFilePath(targetFilepath);
if (anchorIndex === -1 || targetIndex === -1) {
return false;
}
const startIndex = Math.min(anchorIndex, targetIndex);
const endIndex = Math.max(anchorIndex, targetIndex);
const metadataCache = this.getMetadataCache();
const rangePaths = new Set();
for (let i = startIndex; i <= endIndex; i++) {
const item = scroller.items[i];
if (!item || !item.file_path) {
continue;
}
rangePaths.add(item.file_path);
if (!metadataCache.has(item.file_path)) {
const modelId = this.parseModelId(item?.civitai?.modelId);
metadataCache.set(item.file_path, {
fileName: item.file_name,
folder: item.folder || '',
usageTips: item.usage_tips || '{}',
modelName: item.name || item.file_name,
...(modelId !== null ? { modelId } : {})
});
}
state.selectedModels.add(item.file_path);
}
for (const filepath of [...state.selectedModels]) {
if (!rangePaths.has(filepath)) {
state.selectedModels.delete(filepath);
}
}
this.applySelectionState();
if (this.bulkContextMenu) {
this.bulkContextMenu.updateSelectedCountHeader();
}
if (this.isStripVisible) {
this.updateThumbnailStrip();
}
return true;
}
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;
}
}
parseModelId(value) {
if (value === undefined || value === null || value === '') {
return null;
}
const parsed = Number.parseInt(value, 10);
return Number.isNaN(parsed) ? null : parsed;
}
updateMetadataCacheFromCard(filepath, card) {
if (!card) {
return;
}
const metadataCache = this.getMetadataCache();
const existing = metadataCache.get(filepath) || {};
const modelId = this.parseModelId(card.dataset.modelId);
const updated = {
...existing,
fileName: card.dataset.file_name ?? existing.fileName,
folder: card.dataset.folder ?? existing.folder,
usageTips: card.dataset.usage_tips ?? existing.usageTips,
modelName: card.dataset.name ?? existing.modelName,
};
if (modelId !== null) {
updated.modelId = modelId;
}
metadataCache.set(filepath, updated);
}
escapeAttributeValue(value) {
if (value === undefined || value === null) {
return '';
}
return String(value)
.replace(/\\/g, '\\\\')
.replace(/"/g, '\\"');
}
getModelIdForFilePath(filePath) {
const metadataCache = this.getMetadataCache();
const cached = metadataCache.get(filePath);
if (cached && typeof cached.modelId === 'number') {
return cached.modelId;
}
const escapedPath = this.escapeAttributeValue(filePath);
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
if (!card) {
return null;
}
this.updateMetadataCacheFromCard(filePath, card);
const updated = metadataCache.get(filePath);
return updated && typeof updated.modelId === 'number' ? updated.modelId : null;
}
collectSelectedModelIds() {
const metadataCache = this.getMetadataCache();
const ids = [];
let missingCount = 0;
for (const filepath of state.selectedModels) {
const cached = metadataCache.get(filepath);
let modelId = cached && typeof cached.modelId === 'number' ? cached.modelId : null;
if (modelId === null) {
modelId = this.getModelIdForFilePath(filepath);
}
if (typeof modelId === 'number') {
ids.push(modelId);
} else {
missingCount++;
}
}
const uniqueIds = Array.from(new Set(ids));
return { ids: uniqueIds, missingCount };
}
applySelectionState() {
if (!state.bulkMode) return;
document.querySelectorAll('.model-card').forEach(card => {
const filepath = card.dataset.filepath;
if (state.selectedModels.has(filepath)) {
card.classList.add('selected');
this.updateMetadataCacheFromCard(filepath, card);
} else {
card.classList.remove('selected');
}
});
}
async copyAllModelsSyntax() {
if (state.currentPageType !== MODEL_TYPES.LORA) {
showToast('toast.loras.copyOnlyForLoras', {}, 'warning');
return;
}
if (state.selectedModels.size === 0) {
showToast('toast.loras.noLorasSelected', {}, 'warning');
return;
}
const loraSyntaxes = [];
const missingLoras = [];
const metadataCache = this.getMetadataCache();
for (const filepath of state.selectedModels) {
const metadata = metadataCache.get(filepath);
if (metadata) {
const usageTips = JSON.parse(metadata.usageTips || '{}');
const loraName = metadata.folder ? `${metadata.folder}/${metadata.fileName}` : metadata.fileName;
loraSyntaxes.push(buildLoraSyntax(loraName, usageTips));
} else {
missingLoras.push(filepath);
}
}
if (missingLoras.length > 0) {
console.warn('Missing metadata for some selected loras:', missingLoras);
showToast('toast.loras.missingDataForLoras', { count: missingLoras.length }, 'warning');
}
if (loraSyntaxes.length === 0) {
showToast('toast.loras.noValidLorasToCopy', {}, 'error');
return;
}
await copyToClipboard(loraSyntaxes.join(', '), `Copied ${loraSyntaxes.length} LoRA syntaxes to clipboard`);
}
async sendAllModelsToWorkflow(replaceMode = false) {
if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return;
}
if (state.currentPageType === MODEL_TYPES.EMBEDDING) {
return this._sendAllEmbeddingsToWorkflow();
}
if (state.currentPageType !== MODEL_TYPES.LORA) {
showToast('toast.loras.sendOnlyForLoras', {}, 'warning');
return;
}
const loraSyntaxes = [];
const missingLoras = [];
const metadataCache = this.getMetadataCache();
for (const filepath of state.selectedModels) {
const metadata = metadataCache.get(filepath);
if (metadata) {
const usageTips = JSON.parse(metadata.usageTips || '{}');
const loraName = metadata.folder ? `${metadata.folder}/${metadata.fileName}` : metadata.fileName;
loraSyntaxes.push(buildLoraSyntax(loraName, usageTips));
} else {
missingLoras.push(filepath);
}
}
if (missingLoras.length > 0) {
console.warn('Missing metadata for some selected loras:', missingLoras);
showToast('toast.loras.missingDataForLoras', { count: missingLoras.length }, 'warning');
}
if (loraSyntaxes.length === 0) {
showToast('toast.loras.noValidLorasToSend', {}, 'error');
return;
}
const exitBulkMode = () => { if (state.bulkMode) this.toggleBulkMode(); };
await sendLoraToWorkflow(loraSyntaxes.join(', '), replaceMode, 'lora', exitBulkMode);
}
async _sendAllEmbeddingsToWorkflow() {
const embeddingCodes = [];
for (const filepath of state.selectedModels) {
const escapedPath = CSS.escape(filepath);
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
if (card) {
const folder = card.dataset.folder || '';
const name = card.dataset.file_name || '';
const code = folder ? `embedding:${folder}/${name}` : `embedding:${name}`;
embeddingCodes.push(code);
}
}
if (embeddingCodes.length === 0) {
showToast('No valid embedding data found', {}, 'warning');
return;
}
const joinedCode = embeddingCodes.join(', ');
const exitBulkMode = () => { if (state.bulkMode) this.toggleBulkMode(); };
await sendEmbeddingToWorkflow(joinedCode, exitBulkMode);
}
showBulkDeleteModal() {
if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return;
}
const count = state.selectedModels.size;
const isRecipes = state.currentPageType === 'recipes';
const keyPrefix = isRecipes ? 'modals.bulkDeleteRecipes' : 'modals.bulkDelete';
const titleEl = document.querySelector('#bulkDeleteModal h2');
if (titleEl) {
titleEl.textContent = translate(`${keyPrefix}.title`);
}
const messageEl = document.querySelector('#bulkDeleteModal .delete-message');
if (messageEl) {
messageEl.textContent = translate(`${keyPrefix}.message`);
}
const countInfoEl = document.querySelector('#bulkDeleteModal .delete-model-info p');
if (countInfoEl) {
countInfoEl.innerHTML = `<span id="bulkDeleteCount">${count}</span> ${translate(`${keyPrefix}.countMessage`)}`;
}
modalManager.showModal('bulkDeleteModal');
}
async confirmBulkDelete() {
if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
modalManager.closeModal('bulkDeleteModal');
return;
}
modalManager.closeModal('bulkDeleteModal');
try {
const apiClient = this.getActiveApiClient();
const filePaths = Array.from(state.selectedModels);
const result = await apiClient.bulkDeleteModels(filePaths);
if (result?.cancelled) {
showToast('toast.api.operationCancelled', {}, 'info');
} else if (result.success) {
const currentConfig = this.getCurrentDisplayConfig();
const isRecipes = state.currentPageType === 'recipes';
const refreshFn = isRecipes
? () => window.recipeManager.loadRecipes(true)
: () => resetAndReload(true);
if (result.batch_id || (result.batch_ids && result.batch_ids.length)) {
// One undo action for the whole bulk action — the backend
// merges staged per-file batches into a single batch, with
// a batch_ids fallback array when the merge failed
const onAction = result.batch_id
? () => handleUndoDelete(result.batch_id, refreshFn)
: async () => {
for (const id of result.batch_ids) {
const succeeded = await handleUndoDelete(id, null, { showToast: false, refresh: false });
if (!succeeded) {
showToast('toast.undo.failed', { error: '' }, 'error');
return;
}
}
refreshFn();
showToast('toast.undo.restored', {}, 'success');
};
showActionToast('toast.undo.deletedBulk', { count: result.deleted_count }, 'success', {
actionText: translate('toast.undo.action'),
onAction,
});
} else {
showToast('toast.models.deletedSuccessfully', {
count: result.deleted_count,
type: currentConfig.displayName.toLowerCase()
}, 'success');
}
filePaths.forEach(path => {
state.virtualScroller.removeItemByFilePath(path);
});
if (state.bulkMode) this.toggleBulkMode();
if (window.modelDuplicatesManager) {
window.modelDuplicatesManager.updateDuplicatesBadgeAfterRefresh();
}
} else {
showToast('toast.models.deleteFailed', { error: result.error || 'Failed to delete models' }, 'error');
}
} catch (error) {
console.error('Error during bulk delete:', error);
showToast('toast.models.deleteFailedGeneral', {}, 'error');
}
}
deselectItem(filepath) {
const escapedPath = this.escapeAttributeValue(filepath);
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
if (card) {
card.classList.remove('selected');
}
state.selectedModels.delete(filepath);
}
selectAllVisibleModels() {
if (!state.virtualScroller || !state.virtualScroller.items) {
showToast('toast.bulk.unableToSelectAll', {}, 'error');
return;
}
const oldCount = state.selectedModels.size;
const metadataCache = this.getMetadataCache();
state.virtualScroller.items.forEach(item => {
if (item && item.file_path) {
state.selectedModels.add(item.file_path);
if (!metadataCache.has(item.file_path)) {
const modelId = this.parseModelId(item?.civitai?.modelId);
metadataCache.set(item.file_path, {
fileName: item.file_name,
folder: item.folder || '',
usageTips: item.usage_tips || '{}',
modelName: item.name || item.file_name,
...(modelId !== null ? { modelId } : {})
});
}
}
});
this.applySelectionState();
const newlySelected = state.selectedModels.size - oldCount;
const currentConfig = this.getCurrentDisplayConfig();
showToast('toast.models.selectedAdditional', {
count: newlySelected,
type: currentConfig.displayName.toLowerCase()
}, 'success');
if (this.isStripVisible) {
this.updateThumbnailStrip();
}
}
async reimportSelectedRecipes() {
if (state.selectedModels.size === 0) {
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
return;
}
if (state.currentPageType !== 'recipes') {
showToast('This operation is only available for recipes', {}, 'warning');
return;
}
const filePaths = Array.from(state.selectedModels);
const total = filePaths.length;
let completed = 0;
let failed = 0;
const recipeMap = new Map();
if (state.virtualScroller?.items) {
for (const item of state.virtualScroller.items) {
if (item.file_path && item.id) {
recipeMap.set(item.file_path, item);
}
}
}
const progressUI = state.loadingManager.showEnhancedProgress(
`Re-importing recipe 1/${total}...`
);
// Partition the selection: recipes sourced from a CivitAI image page
// can be delegated to the companion browser extension (which scrapes
// the full page metadata); everything else uses the native endpoint.
const delegatable = [];
const nativeFilePaths = [];
for (const filePath of filePaths) {
const recipeItem = recipeMap.get(filePath);
const civitaiImage = getCivitaiImageInfo(recipeItem?.source_path);
if (civitaiImage && recipeItem?.id) {
delegatable.push({
filePath,
recipeId: recipeItem.id,
imageId: civitaiImage.imageId,
imageUrl: civitaiImage.imageUrl,
title: recipeItem.title || '',
});
} else {
nativeFilePaths.push(filePath);
}
}
// Probe once; on any probe/delegate failure the delegatable recipes
// fall back to the native sequential loop below.
if (delegatable.length > 0) {
try {
const probe = await probeExtension();
if (probe?.supported && probe?.licenseValid) {
const batchResult = await delegateReimport(
delegatable.map(({ recipeId, imageId, imageUrl, title }) => ({
recipeId, imageId, imageUrl, title,
})),
{
onProgress: (progress) => {
progressUI.updateProgress(
Math.floor(((progress.current || 0) / total) * 100),
progress.title || '',
translate('toast.recipes.reimportingViaExtension', {
current: progress.current || 0,
total,
})
);
},
}
);
completed += batchResult.completed;
failed += batchResult.failed;
} else {
nativeFilePaths.push(...delegatable.map(entry => entry.filePath));
}
} catch (error) {
console.warn('[reimportSelectedRecipes] extension delegation failed, using native path:', error);
nativeFilePaths.push(...delegatable.map(entry => entry.filePath));
}
}
try {
const processedBeforeNative = completed + failed;
for (let i = 0; i < nativeFilePaths.length; i++) {
const filePath = nativeFilePaths[i];
const recipeItem = recipeMap.get(filePath);
const recipeId = recipeItem?.id;
const recipeName = recipeItem?.title || recipeId || 'Unknown';
const processed = processedBeforeNative + i;
progressUI.updateProgress(
Math.floor((processed / total) * 100),
recipeName,
`Re-importing recipe ${Math.min(processed + 1, total)}/${total}...`
);
if (!recipeId) {
failed++;
continue;
}
try {
const response = await fetch(
`/api/lm/recipe/${recipeId}/reimport`,
{ method: 'POST' }
);
const result = await response.json();
if (result.success) {
completed++;
} else {
failed++;
}
} catch {
failed++;
}
}
if (completed > 0) {
await progressUI.complete(
`Re-import complete: ${completed} re-imported, ${failed} failed`
);
const { resetAndReload: recipeResetAndReload } = await import('../api/recipeApi.js');
this.clearSelection();
if (state.bulkMode) this.toggleBulkMode();
recipeResetAndReload(false, { preserveScroll: false });
} else {
state.loadingManager.hide();
showToast('toast.recipes.reimportBulkFailed', {}, 'error');
}
} catch (error) {
console.error('[reimportSelectedRecipes] outer catch:', error);
state.loadingManager.hide();
showToast('toast.recipes.reimportBulkFailed', {}, 'error');
}
}
async rematchSelectedRecipes() {
if (state.selectedModels.size === 0) {
showToast('toast.recipes.noRecipesSelected', {}, 'warning');
return;
}
if (state.currentPageType !== 'recipes') {
showToast('This operation is only available for recipes', {}, 'warning');
return;
}
try {
const apiClient = this.getActiveApiClient();
const filePaths = Array.from(state.selectedModels);
if (typeof apiClient.rematchBulkModels !== 'function') {
showToast('Bulk rematch is not supported for this model type', {}, 'error');
return;
}
state.loadingManager.showSimpleLoading('Rematching recipes to local models...');
const result = await apiClient.rematchBulkModels(filePaths);
if (result.success) {
const total = result.total || filePaths.length;
// Unified counters from the backend; legacy fields fall back
// for older backends: `rematched` (entry count) for
// matched_entries, `total` (selection size) for
// matched_recipes.
const rematched = result.rematched || 0;
const skipped = result.skipped || 0;
const matchedRecipes = result.matched_recipes || result.total || 0;
const matchedEntries = result.matched_entries || rematched;
const failures = result.errors || 0;
const unresolvedEntries = result.unresolved_entries || 0;
const unresolvedRecipes = result.unresolved_recipes || 0;
const recipes = result.recipes || [];
for (const recipe of recipes) {
if (recipe.file_path) {
state.virtualScroller.updateSingleItem(
recipe.file_path,
recipe
);
}
}
if (matchedEntries > 0) {
const hasFailures = failures > 0;
const toastKey = hasFailures
? 'toast.recipes.rematchCompleteErrors'
: 'toast.recipes.rematchComplete';
showToast(
toastKey,
{ rematched, skipped, total, entries: matchedEntries, recipes: matchedRecipes, failures },
hasFailures ? 'warning' : 'success'
);
} else if (failures > 0) {
// Nothing matched and at least one recipe errored —
// "no rematch needed" would be actively misleading here.
showToast(
'toast.recipes.rematchAllFailed',
{ total, failures },
'error'
);
} else if (unresolvedEntries > 0) {
// Entries existed but have no local model — expected for
// models deleted from Civitai; informational, not an error.
showToast(
'toast.recipes.rematchUnmatched',
{ entries: unresolvedEntries, recipes: unresolvedRecipes, total },
'info'
);
} else {
showToast(
'toast.recipes.rematchSkipped',
{ total },
'info'
);
}
if (state.bulkMode) this.toggleBulkMode();
} else {
throw new Error(result.error || 'Bulk rematch failed');
}
} catch (error) {
console.error('Error during bulk recipe rematch:', error);
showToast('toast.recipes.rematchFailed', { message: error.message }, 'error');
} finally {
if (state.loadingManager?.hide) {
state.loadingManager.hide();
}
if (typeof state.loadingManager?.restoreProgressBar === 'function') {
state.loadingManager.restoreProgressBar();
}
}
}
async refreshAllMetadata() {
if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return;
}
try {
const apiClient = getModelApiClient();
const filePaths = Array.from(state.selectedModels);
const result = await apiClient.refreshBulkModelMetadata(filePaths);
if (result.success) {
const metadataCache = this.getMetadataCache();
for (const filepath of state.selectedModels) {
const metadata = metadataCache.get(filepath);
if (metadata) {
const escapedPath = this.escapeAttributeValue(filepath);
const card = document.querySelector(`.model-card[data-filepath="${escapedPath}"]`);
if (card) {
this.updateMetadataCacheFromCard(filepath, card);
}
}
}
if (this.isStripVisible) {
this.updateThumbnailStrip();
}
if (state.bulkMode) this.toggleBulkMode();
}
} catch (error) {
console.error('Error during bulk metadata refresh:', error);
showToast('toast.models.refreshMetadataFailed', {}, 'error');
}
}
async checkUpdatesForSelectedModels() {
if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return;
}
const currentConfig = this.getCurrentDisplayConfig();
const typeLabel = (currentConfig?.displayName || 'Model').toLowerCase();
const { ids: modelIds, missingCount } = this.collectSelectedModelIds();
if (modelIds.length === 0) {
showToast('toast.models.bulkUpdatesMissing', { type: typeLabel }, 'warning');
return;
}
if (missingCount > 0) {
showToast('toast.models.bulkUpdatesPartialMissing', { missing: missingCount, type: typeLabel }, 'info');
}
const apiClient = getModelApiClient();
if (!apiClient || typeof apiClient.refreshUpdatesForModels !== 'function') {
console.warn('Model API client does not support refreshUpdatesForModels');
showToast('toast.models.bulkUpdatesFailed', { type: typeLabel, message: 'Operation not supported' }, 'error');
return;
}
const loadingMessage = translate(
'toast.models.bulkUpdatesChecking',
{ count: state.selectedModels.size, type: typeLabel },
`Checking selected ${typeLabel}(s) for updates...`
);
state.loadingManager?.showSimpleLoading?.(loadingMessage);
try {
const response = await apiClient.refreshUpdatesForModels(modelIds);
const records = Array.isArray(response?.records) ? response.records : [];
const updatesCount = records.length;
if (updatesCount > 0) {
showToast('toast.models.bulkUpdatesSuccess', { count: updatesCount, type: typeLabel }, 'success');
} else {
showToast('toast.models.bulkUpdatesNone', { type: typeLabel }, 'info');
}
if (state.bulkMode) this.toggleBulkMode();
await resetAndReload(false);
} catch (error) {
console.error('Error checking updates for selected models:', error);
showToast(
'toast.models.bulkUpdatesFailed',
{ type: typeLabel, message: error?.message ?? 'Unknown error' },
'error'
);
} finally {
if (state.loadingManager?.hide) {
state.loadingManager.hide();
}
if (typeof state.loadingManager?.restoreProgressBar === 'function') {
state.loadingManager.restoreProgressBar();
}
}
}
showBulkAddTagsModal() {
if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return;
}
const countElement = document.getElementById('bulkAddTagsCount');
if (countElement) {
countElement.textContent = state.selectedModels.size;
}
// Clear any existing tags in the modal
const tagsContainer = document.getElementById('bulkTagsItems');
if (tagsContainer) {
tagsContainer.innerHTML = '';
}
modalManager.showModal('bulkAddTagsModal', null, null, () => {
// Cleanup when modal is closed
this.cleanupBulkAddTagsModal();
});
// Initialize the bulk tags editing interface
this.initializeBulkTagsInterface();
}
initializeBulkTagsInterface() {
// Setup tag input behavior
const tagInput = document.querySelector('.bulk-metadata-input');
if (tagInput) {
tagInput.focus();
tagInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
this.addBulkTag(e.target.value.trim());
e.target.value = '';
// Update dropdown to show added indicator
this.updateBulkSuggestionsDropdown();
}
});
}
// Create suggestions dropdown
const tagForm = document.querySelector('#bulkAddTagsModal .metadata-add-form');
if (tagForm) {
const suggestionsDropdown = this.createBulkSuggestionsDropdown();
tagForm.appendChild(suggestionsDropdown);
}
// Setup save button
const appendBtn = document.querySelector('.bulk-append-tags-btn');
const replaceBtn = document.querySelector('.bulk-replace-tags-btn');
if (appendBtn) {
appendBtn.addEventListener('click', () => {
this.saveBulkTags('append');
});
}
if (replaceBtn) {
replaceBtn.addEventListener('click', () => {
this.saveBulkTags('replace');
});
}
}
createBulkSuggestionsDropdown() {
const dropdown = document.createElement('div');
dropdown.className = 'metadata-suggestions-dropdown';
const header = document.createElement('div');
header.className = 'metadata-suggestions-header';
header.innerHTML = `
<span>Suggested Tags</span>
<small>Click to add</small>
`;
dropdown.appendChild(header);
const container = document.createElement('div');
container.className = 'metadata-suggestions-container';
container.innerHTML = `<div class="metadata-suggestions-loading">${translate('settings.priorityTags.loadingSuggestions', 'Loading suggestions…')}</div>`;
const currentType = state.currentPageType;
if (!currentType || currentType === 'recipes') {
container.innerHTML = '';
} else {
getPriorityTagSuggestions(currentType).then((tags) => {
if (!container.isConnected) {
return;
}
this.renderBulkSuggestionItems(container, tags);
this.updateBulkSuggestionsDropdown();
}).catch(() => {
if (container.isConnected) {
container.innerHTML = '';
}
});
}
dropdown.appendChild(container);
return dropdown;
}
renderBulkSuggestionItems(container, tags) {
container.innerHTML = '';
tags.forEach(tag => {
const existingTags = this.getBulkExistingTags();
const isAdded = existingTags.includes(tag);
const item = document.createElement('div');
item.className = `metadata-suggestion-item ${isAdded ? 'already-added' : ''}`;
item.title = tag;
item.innerHTML = `
<span class="metadata-suggestion-text">${tag}</span>
${isAdded ? '<span class="added-indicator"><i class="fas fa-check"></i></span>' : ''}
`;
if (!isAdded) {
item.addEventListener('click', () => {
this.addBulkTag(tag);
const input = document.querySelector('.bulk-metadata-input');
if (input) {
input.value = tag;
input.focus();
}
this.updateBulkSuggestionsDropdown();
});
}
container.appendChild(item);
});
}
addBulkTag(tag) {
tag = tag.trim().toLowerCase();
if (!tag) return;
const tagsContainer = document.getElementById('bulkTagsItems');
if (!tagsContainer) return;
// Validation: Check length
if (tag.length > 30) {
showToast('modelTags.validation.maxLength', {}, 'error');
return;
}
// Validation: Check total number
const currentTags = tagsContainer.querySelectorAll('.metadata-item');
if (currentTags.length >= 30) {
showToast('modelTags.validation.maxCount', {}, 'error');
return;
}
// Validation: Check for duplicates
const existingTags = Array.from(currentTags).map(tagEl => tagEl.dataset.tag);
if (existingTags.includes(tag)) {
showToast('modelTags.validation.duplicate', {}, 'error');
return;
}
// Create new tag
const newTag = document.createElement('div');
newTag.className = 'metadata-item';
newTag.dataset.tag = tag;
newTag.innerHTML = `
<span class="metadata-item-content">${tag}</span>
<button class="metadata-delete-btn">
<i class="fas fa-times"></i>
</button>
`;
// Add delete button event listener
const deleteBtn = newTag.querySelector('.metadata-delete-btn');
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
newTag.remove();
// Update dropdown to show/hide added indicator
this.updateBulkSuggestionsDropdown();
});
tagsContainer.appendChild(newTag);
}
/**
* Get existing tags in the bulk tags container
* @returns {Array} Array of existing tag strings
*/
getBulkExistingTags() {
const tagsContainer = document.getElementById('bulkTagsItems');
if (!tagsContainer) return [];
const currentTags = tagsContainer.querySelectorAll('.metadata-item');
return Array.from(currentTags).map(tag => tag.dataset.tag);
}
/**
* Update status of items in the bulk suggestions dropdown
*/
updateBulkSuggestionsDropdown() {
const dropdown = document.querySelector('.metadata-suggestions-dropdown');
if (!dropdown) return;
// Get all current tags
const existingTags = this.getBulkExistingTags();
// Update status of each item in dropdown
dropdown.querySelectorAll('.metadata-suggestion-item').forEach(item => {
const tagText = item.querySelector('.metadata-suggestion-text').textContent;
const isAdded = existingTags.includes(tagText);
if (isAdded) {
item.classList.add('already-added');
// Add indicator if it doesn't exist
let indicator = item.querySelector('.added-indicator');
if (!indicator) {
indicator = document.createElement('span');
indicator.className = 'added-indicator';
indicator.innerHTML = '<i class="fas fa-check"></i>';
item.appendChild(indicator);
}
// Remove click event
item.onclick = null;
item.removeEventListener('click', item._clickHandler);
} else {
// Re-enable items that are no longer in the list
item.classList.remove('already-added');
// Remove indicator if it exists
const indicator = item.querySelector('.added-indicator');
if (indicator) indicator.remove();
// Restore click event if not already set
if (!item._clickHandler) {
item._clickHandler = () => {
this.addBulkTag(tagText);
const input = document.querySelector('.bulk-metadata-input');
if (input) {
input.value = tagText;
input.focus();
}
// Update dropdown to show added indicator
this.updateBulkSuggestionsDropdown();
};
item.addEventListener('click', item._clickHandler);
}
}
});
}
async saveBulkTags(mode = 'append') {
const tagElements = document.querySelectorAll('#bulkTagsItems .metadata-item');
let tags = Array.from(tagElements).map(tag => tag.dataset.tag);
// Flush uncommitted input as a tag so it's not silently lost on save
const tagInput = document.querySelector('.bulk-metadata-input');
if (tagInput) {
const pendingTag = tagInput.value.trim().toLowerCase();
if (pendingTag && !tags.includes(pendingTag)) {
tags.push(pendingTag);
}
tagInput.value = '';
}
if (tags.length === 0) {
showToast('toast.models.noTagsToAdd', {}, 'warning');
return;
}
if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return;
}
try {
const apiClient = getModelApiClient();
const filePaths = Array.from(state.selectedModels);
let successCount = 0;
let failCount = 0;
let cancelled = false;
state.loadingManager.showSimpleLoading(translate('toast.models.bulkTagsUpdating', { count: filePaths.length }));
state.loadingManager.showCancelButton(() => {
cancelled = true;
});
const isRecipes = state.currentPageType === 'recipes';
// Add or replace tags for each selected model based on mode
for (const filePath of filePaths) {
if (cancelled) {
showToast('toast.api.operationCancelled', {}, 'info');
break;
}
try {
if (isRecipes) {
await this._saveRecipeTags(filePath, tags, mode);
} else if (mode === 'replace') {
await apiClient.saveModelMetadata(filePath, { tags: tags });
} else {
await apiClient.addTags(filePath, { tags: tags });
}
successCount++;
} catch (error) {
console.error(`Failed to ${mode} tags for ${filePath}:`, error);
failCount++;
}
}
modalManager.closeModal('bulkAddTagsModal');
if (successCount > 0) {
const currentConfig = this.getCurrentDisplayConfig();
const toastKey = mode === 'replace' ? 'toast.models.tagsReplacedSuccessfully' : 'toast.models.tagsAddedSuccessfully';
showToast(toastKey, {
count: successCount,
tagCount: tags.length,
type: currentConfig.displayName.toLowerCase()
}, 'success');
}
if (failCount > 0) {
const toastKey = mode === 'replace' ? 'toast.models.tagsReplaceFailed' : 'toast.models.tagsAddFailed';
showToast(toastKey, { count: failCount }, 'warning');
}
if (state.bulkMode) this.toggleBulkMode();
} catch (error) {
console.error('Error during bulk tag operation:', error);
const toastKey = mode === 'replace' ? 'toast.models.bulkTagsReplaceFailed' : 'toast.models.bulkTagsAddFailed';
showToast(toastKey, {}, 'error');
} finally {
state.loadingManager.hide();
state.loadingManager.restoreProgressBar();
}
}
async _saveRecipeTags(filePath, newTags, mode) {
const recipeId = extractRecipeId(filePath);
if (!recipeId) throw new Error('Unable to determine recipe ID');
let finalTags = newTags;
if (mode === 'append') {
const recipeItem = state.virtualScroller?.items?.find(
item => item.file_path === filePath
);
const existingTags = recipeItem?.tags || [];
finalTags = [...new Set([...existingTags, ...newTags])];
}
const response = await fetch(
`/api/lm/recipe/${encodeURIComponent(recipeId)}/update`,
{
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ tags: finalTags }),
}
);
const data = await response.json();
if (!data.success) {
throw new Error(data.error || 'Failed to update recipe tags');
}
state.virtualScroller.updateSingleItem(filePath, { tags: finalTags });
}
cleanupBulkAddTagsModal() {
// Clear tags container
const tagsContainer = document.getElementById('bulkTagsItems');
if (tagsContainer) {
tagsContainer.innerHTML = '';
}
// Clear input
const input = document.querySelector('.bulk-metadata-input');
if (input) {
input.value = '';
}
// Remove event listeners (they will be re-added when modal opens again)
const appendBtn = document.querySelector('.bulk-append-tags-btn');
if (appendBtn) {
appendBtn.replaceWith(appendBtn.cloneNode(true));
}
const replaceBtn = document.querySelector('.bulk-replace-tags-btn');
if (replaceBtn) {
replaceBtn.replaceWith(replaceBtn.cloneNode(true));
}
// Remove the suggestions dropdown
const tagForm = document.querySelector('#bulkAddTagsModal .metadata-add-form');
if (tagForm) {
const dropdown = tagForm.querySelector('.metadata-suggestions-dropdown');
if (dropdown) {
dropdown.remove();
}
}
}
async setBulkFavorites(value) {
if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return;
}
const totalCount = state.selectedModels.size;
const isRecipesPage = state.currentPageType === 'recipes';
state.loadingManager.showSimpleLoading(
translate(value ? 'toast.models.bulkFavoriteUpdating' : 'toast.models.bulkUnfavoriteUpdating', { count: totalCount })
);
let cancelled = false;
state.loadingManager.showCancelButton(() => {
cancelled = true;
});
let successCount = 0;
let failureCount = 0;
try {
for (const filePath of state.selectedModels) {
if (cancelled) {
showToast('toast.api.operationCancelled', {}, 'info');
break;
}
try {
if (isRecipesPage) {
await updateRecipeMetadata(filePath, { favorite: value });
} else {
const apiClient = getModelApiClient();
await apiClient.saveModelMetadata(filePath, { favorite: value });
}
successCount++;
} catch (error) {
failureCount++;
console.error(`Failed to set favorite=${value} for ${filePath}:`, error);
}
}
} finally {
state.loadingManager?.hide?.();
}
if (successCount === totalCount) {
const toastKey = value ? 'modelCard.favorites.added' : 'modelCard.favorites.removed';
showToast(toastKey, {}, 'success');
} else if (successCount > 0) {
const toastKey = value ? 'toast.models.bulkFavoritePartialAdded' : 'toast.models.bulkFavoritePartialRemoved';
showToast(toastKey, { success: successCount, failed: failureCount }, 'warning');
} else {
showToast('toast.models.bulkFavoriteFailed', {}, 'error');
}
if (state.bulkMode) this.toggleBulkMode();
}
/**
* Show bulk base model modal
*/
showBulkBaseModelModal() {
if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return;
}
const countElement = document.getElementById('bulkBaseModelCount');
if (countElement) {
countElement.textContent = state.selectedModels.size;
}
modalManager.showModal('bulkBaseModelModal', null, null, () => {
this.cleanupBulkBaseModelModal();
});
// Initialize the bulk base model interface
this.initializeBulkBaseModelInterface();
}
showBulkContentRatingSelector() {
if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return;
}
const selector = getNsfwLevelSelector();
if (!selector) {
console.warn('NSFW level selector not found');
return;
}
const filePaths = Array.from(state.selectedModels);
const selectedCards = Array.from(document.querySelectorAll('.model-card.selected'));
const levels = new Set();
selectedCards.forEach((card) => {
let level = 0;
try {
const metaData = JSON.parse(card.dataset.meta || '{}');
if (typeof metaData.preview_nsfw_level === 'number') {
level = metaData.preview_nsfw_level;
}
} catch (error) {
console.warn('Failed to parse metadata for card', error);
}
if (!level && card.dataset.nsfwLevel) {
const parsed = parseInt(card.dataset.nsfwLevel, 10);
if (!Number.isNaN(parsed)) {
level = parsed;
}
}
levels.add(level);
});
let highlightLevel = null;
if (levels.size === 1) {
highlightLevel = levels.values().next().value;
}
selector.show({
currentLevel: highlightLevel || 0,
multipleLabel: levels.size > 1 ? translate('modals.contentRating.multiple', {}, 'Multiple values') : '',
onSelect: async (level) => {
await this.setBulkContentRating(level, filePaths);
// Always allow selector to close after attempting the update
return true;
}
});
}
async setBulkContentRating(level, filePaths = null) {
const targets = Array.isArray(filePaths) ? filePaths : Array.from(state.selectedModels);
if (!targets || targets.length === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return false;
}
const totalCount = targets.length;
const levelName = getNSFWLevelName(level);
state.loadingManager.showSimpleLoading(translate('toast.models.bulkContentRatingUpdating', { count: totalCount }));
let cancelled = false;
state.loadingManager.showCancelButton(() => {
cancelled = true;
});
let successCount = 0;
let failureCount = 0;
try {
const isRecipesPage = state.currentPageType === 'recipes';
for (const filePath of targets) {
if (cancelled) {
showToast('toast.api.operationCancelled', {}, 'info');
break;
}
try {
if (isRecipesPage) {
await updateRecipeMetadata(filePath, { preview_nsfw_level: level });
} else {
await getModelApiClient().saveModelMetadata(filePath, { preview_nsfw_level: level });
}
successCount++;
} catch (error) {
failureCount++;
console.error(`Failed to set content rating for ${filePath}:`, error);
}
}
} finally {
state.loadingManager?.hide?.();
}
if (successCount === totalCount) {
showToast('toast.models.bulkContentRatingSet', { count: successCount, level: levelName }, 'success');
} else if (successCount > 0) {
showToast('toast.models.bulkContentRatingPartial', {
success: successCount,
failed: failureCount,
level: levelName
}, 'warning');
} else {
showToast('toast.models.bulkContentRatingFailed', {}, 'error');
}
if (state.bulkMode) this.toggleBulkMode();
return successCount > 0;
}
async setSkipMetadataRefresh(value) {
if (state.selectedModels.size === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return;
}
const totalCount = state.selectedModels.size;
state.loadingManager.showSimpleLoading(
translate('toast.models.skipMetadataRefreshUpdating', { count: totalCount })
);
let cancelled = false;
state.loadingManager.showCancelButton(() => {
cancelled = true;
});
let successCount = 0;
let failureCount = 0;
try {
const apiClient = getModelApiClient();
for (const filePath of state.selectedModels) {
if (cancelled) {
showToast('toast.api.operationCancelled', {}, 'info');
break;
}
try {
await apiClient.saveModelMetadata(filePath, { skip_metadata_refresh: value });
successCount++;
} catch (error) {
failureCount++;
console.error(`Failed to set skip_metadata_refresh for ${filePath}:`, error);
}
}
} finally {
state.loadingManager?.hide?.();
}
if (successCount === totalCount) {
const toastKey = value
? 'toast.models.skipMetadataRefreshSet'
: 'toast.models.skipMetadataRefreshCleared';
showToast(toastKey, { count: successCount }, 'success');
} else if (successCount > 0) {
showToast('toast.models.skipMetadataRefreshPartial', {
success: successCount,
failed: failureCount
}, 'warning');
} else {
showToast('toast.models.skipMetadataRefreshFailed', {}, 'error');
}
if (state.bulkMode) this.toggleBulkMode();
}
/**
* Initialize bulk base model interface
*/
initializeBulkBaseModelInterface() {
const container = document.getElementById('bulkBaseModelPicker');
if (!container) return;
// Reset any previous picker instance
this.cleanupBulkBaseModelModal();
container.innerHTML = '';
const suggestions = inferBaseModelsFromFilepaths(Array.from(state.selectedModels));
this.bulkBaseModelValue = '';
this.bulkBaseModelPicker = createBaseModelPicker({
suggestions,
mode: 'change',
onChange: (value) => {
this.bulkBaseModelValue = value;
},
});
container.appendChild(this.bulkBaseModelPicker.element);
this.bulkBaseModelPicker.element.querySelector('.base-model-search-input')?.focus();
}
/**
* Save bulk base model changes
*/
async saveBulkBaseModel() {
const newBaseModel = (this.bulkBaseModelValue || this.bulkBaseModelPicker?.getValue() || '').trim();
if (!newBaseModel) {
showToast('toast.models.baseModelNotSelected', {}, 'warning');
return;
}
const selectedCount = state.selectedModels.size;
if (selectedCount === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return;
}
modalManager.closeModal('bulkBaseModelModal');
try {
let successCount = 0;
let errorCount = 0;
const errors = [];
let cancelled = false;
state.loadingManager.showSimpleLoading(translate('toast.models.bulkBaseModelUpdating'));
state.loadingManager.showCancelButton(() => {
cancelled = true;
});
const isRecipesPage = state.currentPageType === 'recipes';
for (const filepath of state.selectedModels) {
if (cancelled) {
showToast('toast.api.operationCancelled', {}, 'info');
break;
}
try {
if (isRecipesPage) {
await updateRecipeMetadata(filepath, { base_model: newBaseModel });
} else {
await getModelApiClient().saveModelMetadata(filepath, { base_model: newBaseModel });
}
successCount++;
} catch (error) {
errorCount++;
errors.push({ filepath, error: error.message });
console.error(`Failed to update base model for ${filepath}:`, error);
}
}
// Show results
if (errorCount === 0) {
showToast('toast.models.bulkBaseModelUpdateSuccess', { count: successCount }, 'success');
} else if (successCount > 0) {
showToast('toast.models.bulkBaseModelUpdatePartial', {
success: successCount,
failed: errorCount
}, 'warning');
} else {
showToast('toast.models.bulkBaseModelUpdateFailed', {}, 'error');
}
if (state.bulkMode) this.toggleBulkMode();
} catch (error) {
console.error('Error during bulk base model operation:', error);
showToast('toast.models.bulkBaseModelUpdateFailed', {}, 'error');
} finally {
state.loadingManager?.hide?.();
}
}
/**
* Cleanup bulk base model modal
*/
cleanupBulkBaseModelModal() {
if (this.bulkBaseModelPicker) {
this.bulkBaseModelPicker.destroy();
this.bulkBaseModelPicker = null;
}
this.bulkBaseModelValue = '';
const container = document.getElementById('bulkBaseModelPicker');
if (container) {
container.innerHTML = '';
}
}
/**
* Auto-organize selected models based on current path template settings
*/
async autoOrganizeSelectedModels() {
if (state.selectedModels.size === 0) {
showToast('toast.loras.noModelsSelected', {}, 'error');
return;
}
try {
// Get selected file paths
const filePaths = Array.from(state.selectedModels);
// Get the API client for the current model type
const apiClient = getModelApiClient();
// Call the auto-organize method with selected file paths
await apiClient.autoOrganizeModels(filePaths);
if (state.bulkMode) this.toggleBulkMode();
resetAndReload(true);
} catch (error) {
console.error('Error during bulk auto-organize:', error);
showToast('toast.loras.autoOrganizeFailed', { error: error.message }, 'error');
}
}
/**
* Handle marquee start through event manager
*/
handleMarqueeStart(e) {
// Store mousedown info for potential drag detection
this.mouseDownTime = Date.now();
this.mouseDownPosition = { x: e.clientX, y: e.clientY };
this.isDragging = false;
// Don't start marquee yet - wait to see if user is dragging
return false;
}
/**
* Start marquee selection
* @param {MouseEvent} e - Mouse event
* @param {boolean} isDragging - Whether this is triggered from a drag operation
*/
startMarqueeSelection(e, isDragging = false) {
// Store initial mouse position (viewport coordinates for visual element)
this.marqueeStart.x = this.mouseDownPosition.x;
this.marqueeStart.y = this.mouseDownPosition.y;
// Store initial mouse position in document coordinates (for logical selection)
const container = document.querySelector('.page-content');
this.marqueeStartDoc.x = this.mouseDownPosition.x + (container?.scrollLeft || 0);
this.marqueeStartDoc.y = this.mouseDownPosition.y + (container?.scrollTop || 0);
// Store initial selection state
this.initialSelectedModels = new Set(state.selectedModels);
// Enter bulk mode if not already active and we're actually dragging
if (isDragging && !state.bulkMode) {
this.toggleBulkMode();
}
// Create marquee element
this.createMarqueeElement();
this.isMarqueeActive = true;
// Update event manager state
eventManager.setState('marqueeActive', true);
// Add visual feedback class to body
document.body.classList.add('marquee-selecting');
}
/**
* Create the visual marquee selection rectangle
*/
createMarqueeElement() {
this.marqueeElement = document.createElement('div');
this.marqueeElement.className = 'marquee-selection';
this.marqueeElement.style.cssText = `
position: fixed;
border: 2px dashed var(--lora-accent, #007bff);
background: rgba(0, 123, 255, 0.1);
pointer-events: none;
z-index: 9999;
left: ${this.marqueeStart.x}px;
top: ${this.marqueeStart.y}px;
width: 0;
height: 0;
`;
document.body.appendChild(this.marqueeElement);
}
/**
* Update marquee selection rectangle and selected items
*/
updateMarqueeSelection(e) {
if (!this.marqueeElement) return;
this.updateMarqueeSelectionFromPosition(e.clientX, e.clientY);
}
/**
* Update marquee from raw client coordinates (used by both mousemove and auto-scroll loop)
*/
updateMarqueeSelectionFromPosition(clientX, clientY) {
if (!this.marqueeElement) return;
const container = document.querySelector('.page-content');
const scrollX = container?.scrollLeft || 0;
const scrollY = container?.scrollTop || 0;
// Current position in document coordinates
const currentDocX = clientX + scrollX;
const currentDocY = clientY + scrollY;
// Calculate marquee rectangle in document coordinates
const docLeft = Math.min(this.marqueeStartDoc.x, currentDocX);
const docTop = Math.min(this.marqueeStartDoc.y, currentDocY);
const docRight = Math.max(this.marqueeStartDoc.x, currentDocX);
const docBottom = Math.max(this.marqueeStartDoc.y, currentDocY);
// Update visual marquee element (position: fixed, so subtract scroll offset)
this.marqueeElement.style.left = (docLeft - scrollX) + 'px';
this.marqueeElement.style.top = (docTop - scrollY) + 'px';
this.marqueeElement.style.width = (docRight - docLeft) + 'px';
this.marqueeElement.style.height = (docBottom - docTop) + 'px';
// Check which cards intersect with marquee
this.updateCardSelection(docLeft, docTop, docRight, docBottom);
}
/**
* Update card selection based on marquee bounds (document coordinates).
* Uses dual detection: DOM cards for visible ones + VirtualScroller layout for off-screen cards.
*/
updateCardSelection(docLeft, docTop, docRight, docBottom) {
const vs = state.virtualScroller;
const container = document.querySelector('.page-content');
const scrollX = container?.scrollLeft || 0;
const scrollY = container?.scrollTop || 0;
const newSelection = new Set(this.initialSelectedModels);
const visibleFilepaths = new Set();
// Step 1: Process visible DOM cards using getBoundingClientRect + scroll offset
document.querySelectorAll('.model-card').forEach(card => {
const filepath = card.dataset.filepath;
if (!filepath) return;
visibleFilepaths.add(filepath);
const rect = card.getBoundingClientRect();
const cardLeft = rect.left + scrollX;
const cardTop = rect.top + scrollY;
const cardRight = rect.right + scrollX;
const cardBottom = rect.bottom + scrollY;
const intersects = !(cardRight < docLeft || cardLeft > docRight ||
cardBottom < docTop || cardTop > docBottom);
if (intersects) {
newSelection.add(filepath);
card.classList.add('selected');
// Cache metadata if not already cached
const metadataCache = this.getMetadataCache();
if (!metadataCache.has(filepath)) {
this.updateMetadataCacheFromCard(filepath, card);
}
} else if (!this.initialSelectedModels.has(filepath)) {
newSelection.delete(filepath);
card.classList.remove('selected');
}
});
// Step 2: Process off-screen cards via VirtualScroller layout calculation.
// Since VirtualScroller removes off-screen DOM elements, we compute
// each card's position from its index and the VS layout parameters.
if (vs?.gridElement && vs.items && vs.columnsCount > 0) {
const gridRect = vs.gridElement.getBoundingClientRect();
// Grid origin in scroll-container content coordinates
const originX = gridRect.left + scrollX;
const originY = gridRect.top + scrollY;
for (let i = 0; i < vs.items.length; i++) {
const filepath = vs.items[i]?.file_path;
if (!filepath || visibleFilepaths.has(filepath)) continue;
const row = Math.floor(i / vs.columnsCount);
const col = i % vs.columnsCount;
const cLeft = originX + col * (vs.itemWidth + vs.columnGap);
const cTop = originY + (vs.containerPaddingTop || 0) + row * (vs.itemHeight + (vs.rowGap || 0));
const cRight = cLeft + vs.itemWidth;
const cBottom = cTop + vs.itemHeight;
const intersects = !(cRight < docLeft || cLeft > docRight ||
cBottom < docTop || cTop > docBottom);
if (intersects) {
newSelection.add(filepath);
} else if (!this.initialSelectedModels.has(filepath)) {
newSelection.delete(filepath);
}
}
}
// Update global selection state
state.selectedModels = newSelection;
// Update context menu header if visible
if (this.bulkContextMenu) {
this.bulkContextMenu.updateSelectedCountHeader();
}
}
/**
* End marquee selection
*/
endMarqueeSelection(e) {
// First, mark as inactive to prevent double processing
this.isMarqueeActive = false;
this.isDragging = false;
this.mouseDownTime = 0;
// Stop any active auto-scroll
this.stopAutoScroll();
// Update event manager state
eventManager.setState('marqueeActive', false);
// Remove marquee element
if (this.marqueeElement) {
this.marqueeElement.remove();
this.marqueeElement = null;
}
// Remove visual feedback class
document.body.classList.remove('marquee-selecting');
// Compute the actual drag box size in document coordinates, matching how
// updateMarqueeSelectionFromPosition tracks the rectangle. Client-space
// size would wrongly flag auto-scroll marquees (tiny pointer movement,
// large document-space box) as accidental clicks.
const container = document.querySelector('.page-content');
const scrollX = container?.scrollLeft || 0;
const scrollY = container?.scrollTop || 0;
const dragWidth = Math.abs((e.clientX + scrollX) - this.marqueeStartDoc.x);
const dragHeight = Math.abs((e.clientY + scrollY) - this.marqueeStartDoc.y);
const isTinyMarquee = dragWidth < this.minMarqueeSize && dragHeight < this.minMarqueeSize;
// Get selection count
const selectionCount = state.selectedModels.size;
// A tiny box (e.g. click jitter that happened to graze a card) is treated
// as an accidental click: undo any selection and leave bulk mode.
if (isTinyMarquee) {
this.clearSelection();
if (state.bulkMode) {
this.toggleBulkMode();
}
this.initialSelectedModels.clear();
return;
}
// If no models were selected, exit bulk mode
if (selectionCount === 0) {
if (state.bulkMode) {
this.toggleBulkMode();
}
}
// Clear initial selection state
this.initialSelectedModels.clear();
}
/**
* Start auto-scroll loop when mouse approaches viewport edge during marquee
*/
startAutoScroll() {
if (this.autoScrollRaf) return;
this.autoScrollLoop();
}
/**
* Stop auto-scroll loop
*/
stopAutoScroll() {
if (this.autoScrollRaf) {
cancelAnimationFrame(this.autoScrollRaf);
this.autoScrollRaf = null;
}
}
/**
* Auto-scroll loop: scrolls the page when mouse is near viewport edges
* and re-evaluates marquee selection after each scroll.
*/
autoScrollLoop() {
if (!this.isMarqueeActive) {
this.autoScrollRaf = null;
return;
}
const container = document.querySelector('.page-content');
if (!container) {
this.autoScrollRaf = null;
return;
}
const MARGIN = 30; // Px from edge to trigger scroll
const BASE_SPEED = 12; // Pixels per frame at edge boundary
const MAX_SPEED = 40; // Maximum scroll speed
const rect = container.getBoundingClientRect();
let dx = 0;
let dy = 0;
// Vertical auto-scroll - speed increases the further the cursor is past the edge
if (this.lastClientY !== undefined) {
if (this.lastClientY < rect.top + MARGIN) {
const dist = Math.max(0, (rect.top + MARGIN) - this.lastClientY);
dy = -Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
} else if (this.lastClientY > rect.bottom - MARGIN) {
const dist = Math.max(0, this.lastClientY - (rect.bottom - MARGIN));
dy = Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
}
}
// Horizontal auto-scroll
if (this.lastClientX !== undefined) {
if (this.lastClientX < rect.left + MARGIN) {
const dist = Math.max(0, (rect.left + MARGIN) - this.lastClientX);
dx = -Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
} else if (this.lastClientX > rect.right - MARGIN) {
const dist = Math.max(0, this.lastClientX - (rect.right - MARGIN));
dx = Math.min(BASE_SPEED + dist * 0.5, MAX_SPEED);
}
}
if (dx !== 0 || dy !== 0) {
container.scrollBy(dx, dy);
// Re-evaluate marquee selection with the new scroll position
this.updateMarqueeSelectionFromPosition(this.lastClientX, this.lastClientY);
this.autoScrollRaf = requestAnimationFrame(() => this.autoScrollLoop());
} else {
this.autoScrollRaf = null;
}
}
}
export const bulkManager = new BulkManager();