mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-21 03:01:27 -03:00
feat(settings): filename templates for download and bulk rename (#1071)
Add per-model-type filename templates ({model_name}, {version_name},
{base_model}, {author}, {first_tag}, {hash_short}, {original_name}) so
downloaded files get informative names instead of e.g. V1.safetensors.
Empty template keeps the current filename (opt-in, off by default).
- apply template automatically after downloads; rename conflicts keep
the original name and never fail the download
- record original_file_name in metadata on rename for traceability
- bulk apply via GET|POST /api/lm/{prefix}/apply-filename-template with
WebSocket progress, sharing the auto-organize lock
- settings UI lives in the new Organization tab with validation, live
preview, and per-type 'apply to library' actions
This commit is contained in:
@@ -122,6 +122,9 @@ export function getApiEndpoints(modelType) {
|
||||
autoOrganize: `/api/lm/${modelType}/auto-organize`,
|
||||
autoOrganizeProgress: `/api/lm/${modelType}/auto-organize-progress`,
|
||||
|
||||
// Filename template operations
|
||||
applyFilenameTemplate: `/api/lm/${modelType}/apply-filename-template`,
|
||||
|
||||
// Model-specific endpoints (will be merged with specific configs)
|
||||
specific: {}
|
||||
};
|
||||
|
||||
@@ -2175,6 +2175,135 @@ export class BaseModelApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the configured download filename template to models, renaming their files
|
||||
* @param {Array} filePaths - Optional array of file paths to rename. If not provided, applies to all models.
|
||||
* @returns {Promise} - Promise that resolves when the operation is complete
|
||||
*/
|
||||
async applyFilenameTemplate(filePaths = null) {
|
||||
let ws = null;
|
||||
|
||||
await state.loadingManager.showWithProgress(async (loading) => {
|
||||
loading.showCancelButton(() => this.cancelTask());
|
||||
try {
|
||||
// Connect to WebSocket for progress updates
|
||||
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
|
||||
|
||||
const operationComplete = new Promise((resolve, reject) => {
|
||||
ws.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
|
||||
if (data.type !== 'filename_template_progress') return;
|
||||
|
||||
switch (data.status) {
|
||||
case 'started':
|
||||
loading.setProgress(0);
|
||||
const operationType = data.operation_type === 'bulk' ? 'selected models' : 'all models';
|
||||
loading.setStatus(translate('loras.bulkOperations.filenameTemplateProgress.starting', { type: operationType }, `Applying filename template to ${operationType}...`));
|
||||
break;
|
||||
|
||||
case 'processing':
|
||||
const percent = data.total > 0 ? ((data.processed / data.total) * 90).toFixed(1) : 0;
|
||||
loading.setProgress(percent);
|
||||
loading.setStatus(
|
||||
translate('loras.bulkOperations.filenameTemplateProgress.processing', {
|
||||
processed: data.processed,
|
||||
total: data.total,
|
||||
success: data.success,
|
||||
failures: data.failures,
|
||||
skipped: data.skipped
|
||||
}, `Processing (${data.processed}/${data.total}) - ${data.success} renamed, ${data.skipped} skipped, ${data.failures} failed`)
|
||||
);
|
||||
break;
|
||||
|
||||
case 'completed':
|
||||
loading.setProgress(100);
|
||||
loading.setStatus(
|
||||
translate('loras.bulkOperations.filenameTemplateProgress.completed', {
|
||||
success: data.success,
|
||||
skipped: data.skipped,
|
||||
failures: data.failures,
|
||||
total: data.total
|
||||
}, `Completed: ${data.success} renamed, ${data.skipped} skipped, ${data.failures} failed`)
|
||||
);
|
||||
|
||||
setTimeout(() => {
|
||||
resolve(data);
|
||||
}, 1500);
|
||||
break;
|
||||
|
||||
case 'cancelled':
|
||||
loading.setStatus(translate('toast.api.operationCancelled', {}, 'Operation cancelled by user'));
|
||||
resolve(data);
|
||||
break;
|
||||
|
||||
case 'error':
|
||||
loading.setStatus(translate('loras.bulkOperations.filenameTemplateProgress.error', { error: data.error }, `Error: ${data.error}`));
|
||||
reject(new Error(data.error));
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
ws.onerror = (error) => {
|
||||
console.error('WebSocket error during filename template apply:', error);
|
||||
reject(new Error('Connection error'));
|
||||
};
|
||||
});
|
||||
|
||||
// Start the filename template operation
|
||||
const endpoint = this.apiConfig.endpoints.applyFilenameTemplate;
|
||||
|
||||
const requestBody = {};
|
||||
if (filePaths) {
|
||||
requestBody.file_paths = filePaths;
|
||||
}
|
||||
|
||||
const response = await fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || 'Failed to start filename template operation');
|
||||
}
|
||||
|
||||
// Wait for the operation to complete via WebSocket
|
||||
const result = await operationComplete;
|
||||
|
||||
// Show appropriate success message based on results
|
||||
if (result.status === 'cancelled') {
|
||||
showToast('toast.api.operationCancelledPartial', { success: result.success, total: result.total }, 'info');
|
||||
} else if (result.failures === 0) {
|
||||
showToast('toast.loras.filenameTemplateSuccess', {
|
||||
count: result.success,
|
||||
type: result.operation_type === 'bulk' ? 'selected models' : 'all models'
|
||||
}, 'success');
|
||||
} else {
|
||||
showToast('toast.loras.filenameTemplatePartialSuccess', {
|
||||
success: result.success,
|
||||
failures: result.failures,
|
||||
total: result.total
|
||||
}, 'warning');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error applying filename template:', error);
|
||||
showToast('toast.loras.filenameTemplateFailed', { error: error.message }, 'error');
|
||||
throw error;
|
||||
} finally {
|
||||
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||
ws.close();
|
||||
}
|
||||
}
|
||||
}, {
|
||||
initialMessage: translate('loras.bulkOperations.filenameTemplateProgress.initializing', {}, 'Initializing filename template apply...'),
|
||||
completionMessage: translate('loras.bulkOperations.filenameTemplateProgress.complete', {}, 'Filename template apply complete')
|
||||
});
|
||||
}
|
||||
|
||||
async stopExampleImages() {
|
||||
try {
|
||||
const response = await fetch('/api/lm/stop-example-images', {
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { showToast } from '../utils/uiHelpers.js';
|
||||
import { state, createDefaultSettings } from '../state/index.js';
|
||||
import { resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { resetAndReload, getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import {
|
||||
DOWNLOAD_PATH_TEMPLATES,
|
||||
MAPPABLE_BASE_MODELS,
|
||||
PATH_TEMPLATE_PLACEHOLDERS,
|
||||
DEFAULT_PATH_TEMPLATES,
|
||||
FILENAME_TEMPLATE_PLACEHOLDERS,
|
||||
DEFAULT_FILENAME_TEMPLATES,
|
||||
DEFAULT_PRIORITY_TAG_CONFIG,
|
||||
getMappableBaseModelsDynamic
|
||||
} from '../utils/constants.js';
|
||||
@@ -36,6 +38,13 @@ const OTHER_SUB_TYPE_LABEL_KEYS = {
|
||||
controlnet: 'settings.folderSettings.subTypeControlnet',
|
||||
};
|
||||
|
||||
// Singular filename-template model type -> plural API model type (MODEL_TYPES)
|
||||
const FILENAME_TEMPLATE_MODEL_TYPES = {
|
||||
lora: 'loras',
|
||||
checkpoint: 'checkpoints',
|
||||
embedding: 'embeddings',
|
||||
};
|
||||
|
||||
export class SettingsManager {
|
||||
constructor() {
|
||||
this.initialized = false;
|
||||
@@ -147,6 +156,25 @@ export class SettingsManager {
|
||||
|
||||
merged.download_path_templates = { ...DEFAULT_PATH_TEMPLATES, ...templates };
|
||||
|
||||
let filenameTemplates = backendSettings?.download_filename_templates;
|
||||
if (typeof filenameTemplates === 'string') {
|
||||
try {
|
||||
const parsed = JSON.parse(filenameTemplates);
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
filenameTemplates = parsed;
|
||||
}
|
||||
} catch (parseError) {
|
||||
console.warn('Failed to parse download_filename_templates string from backend, using defaults');
|
||||
filenameTemplates = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!filenameTemplates || typeof filenameTemplates !== 'object' || Array.isArray(filenameTemplates)) {
|
||||
filenameTemplates = {};
|
||||
}
|
||||
|
||||
merged.download_filename_templates = { ...DEFAULT_FILENAME_TEMPLATES, ...filenameTemplates };
|
||||
|
||||
const priorityTags = backendSettings?.priority_tags;
|
||||
const normalizedPriority = { ...DEFAULT_PRIORITY_TAG_CONFIG };
|
||||
if (priorityTags && typeof priorityTags === 'object' && !Array.isArray(priorityTags)) {
|
||||
@@ -428,6 +456,31 @@ export class SettingsManager {
|
||||
}
|
||||
});
|
||||
|
||||
['lora', 'checkpoint', 'embedding'].forEach(modelType => {
|
||||
const filenameInput = document.getElementById(`${modelType}FilenameTemplate`);
|
||||
if (filenameInput) {
|
||||
filenameInput.addEventListener('input', (e) => {
|
||||
const template = e.target.value;
|
||||
settingsManager.validateFilenameTemplate(modelType, template);
|
||||
settingsManager.updateFilenamePreview(modelType, template);
|
||||
settingsManager.updateFilenameTemplateApplyButton(modelType, template);
|
||||
});
|
||||
|
||||
filenameInput.addEventListener('blur', (e) => {
|
||||
const template = e.target.value;
|
||||
if (settingsManager.validateFilenameTemplate(modelType, template)) {
|
||||
settingsManager.updateFilenameTemplate(modelType, template);
|
||||
}
|
||||
});
|
||||
|
||||
filenameInput.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.target.blur();
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const autoOrganizeInput = document.getElementById('autoOrganizeExclusions');
|
||||
if (autoOrganizeInput) {
|
||||
autoOrganizeInput.addEventListener('keydown', (event) => {
|
||||
@@ -1142,6 +1195,9 @@ export class SettingsManager {
|
||||
// Load download path templates
|
||||
this.loadDownloadPathTemplates();
|
||||
|
||||
// Load download filename templates
|
||||
this.loadFilenameTemplates();
|
||||
|
||||
// Load priority tag settings
|
||||
this.loadPriorityTagSettings();
|
||||
|
||||
@@ -2797,6 +2853,146 @@ export class SettingsManager {
|
||||
}
|
||||
}
|
||||
|
||||
loadFilenameTemplates() {
|
||||
const templates = state.global.settings.download_filename_templates || DEFAULT_FILENAME_TEMPLATES;
|
||||
|
||||
['lora', 'checkpoint', 'embedding'].forEach(modelType => {
|
||||
const input = document.getElementById(`${modelType}FilenameTemplate`);
|
||||
if (!input) return;
|
||||
|
||||
const template = templates[modelType] || '';
|
||||
input.value = template;
|
||||
this.validateFilenameTemplate(modelType, template);
|
||||
this.updateFilenamePreview(modelType, template);
|
||||
this.updateFilenameTemplateApplyButton(modelType, template);
|
||||
});
|
||||
}
|
||||
|
||||
validateFilenameTemplate(modelType, template) {
|
||||
const validationElement = document.getElementById(`${modelType}FilenameValidation`);
|
||||
if (!validationElement) return true;
|
||||
|
||||
// Reset validation state
|
||||
validationElement.innerHTML = '';
|
||||
validationElement.className = 'template-validation';
|
||||
|
||||
if (!template) {
|
||||
validationElement.innerHTML = `<i class="fas fa-check"></i> ${translate('settings.filenameTemplates.validation.keepOriginal', {}, 'Valid (keep original filename)')}`;
|
||||
validationElement.classList.add('valid');
|
||||
return true;
|
||||
}
|
||||
|
||||
// A filename stem cannot contain path separators or OS-illegal characters
|
||||
const invalidChars = /[/\\<>:"|?*]/;
|
||||
if (invalidChars.test(template)) {
|
||||
validationElement.innerHTML = `<i class="fas fa-times"></i> ${translate('settings.filenameTemplates.validation.invalidChars', {}, 'Invalid characters detected (a filename cannot contain / \\ < > : " | ? *)')}`;
|
||||
validationElement.classList.add('invalid');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Extract placeholders
|
||||
const placeholderRegex = /\{([^}]+)\}/g;
|
||||
const matches = template.match(placeholderRegex) || [];
|
||||
|
||||
// Check for invalid placeholders
|
||||
const invalidPlaceholders = matches.filter(match =>
|
||||
!FILENAME_TEMPLATE_PLACEHOLDERS.includes(match)
|
||||
);
|
||||
|
||||
if (invalidPlaceholders.length > 0) {
|
||||
validationElement.innerHTML = `<i class="fas fa-times"></i> ${translate('settings.filenameTemplates.validation.invalidPlaceholder', { placeholder: invalidPlaceholders[0] }, `Invalid placeholder: ${invalidPlaceholders[0]}`)}`;
|
||||
validationElement.classList.add('invalid');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Template is valid
|
||||
validationElement.innerHTML = `<i class="fas fa-check"></i> ${translate('settings.filenameTemplates.validation.validTemplate', {}, 'Valid template')}`;
|
||||
validationElement.classList.add('valid');
|
||||
return true;
|
||||
}
|
||||
|
||||
updateFilenameTemplate(modelType, template) {
|
||||
if (!this.validateFilenameTemplate(modelType, template)) {
|
||||
return; // Don't save invalid templates
|
||||
}
|
||||
|
||||
// Update state
|
||||
if (!state.global.settings.download_filename_templates) {
|
||||
state.global.settings.download_filename_templates = { ...DEFAULT_FILENAME_TEMPLATES };
|
||||
}
|
||||
state.global.settings.download_filename_templates[modelType] = template;
|
||||
|
||||
// Update preview and apply-button state
|
||||
this.updateFilenamePreview(modelType, template);
|
||||
this.updateFilenameTemplateApplyButton(modelType, template);
|
||||
|
||||
// Save settings
|
||||
this.saveFilenameTemplates();
|
||||
}
|
||||
|
||||
updateFilenameTemplateApplyButton(modelType, template) {
|
||||
const button = document.getElementById(`${modelType}ApplyFilenameTemplate`);
|
||||
if (!button) return;
|
||||
|
||||
// An empty template keeps original filenames, so there is nothing to apply
|
||||
button.disabled = !template;
|
||||
}
|
||||
|
||||
updateFilenamePreview(modelType, template) {
|
||||
const previewElement = document.getElementById(`${modelType}FilenamePreview`);
|
||||
if (!previewElement) return;
|
||||
|
||||
if (!template) {
|
||||
// Empty template keeps the original filename untouched
|
||||
previewElement.textContent = 'V1.safetensors';
|
||||
} else {
|
||||
const exampleStem = template
|
||||
.replaceAll('{model_name}', 'model-name')
|
||||
.replaceAll('{version_name}', 'v3')
|
||||
.replaceAll('{base_model}', 'Flux.1 D')
|
||||
.replaceAll('{author}', 'authorname')
|
||||
.replaceAll('{first_tag}', 'style')
|
||||
.replaceAll('{hash_short}', 'a1b2c3d4e5')
|
||||
.replaceAll('{original_name}', 'V1');
|
||||
previewElement.textContent = `${exampleStem}.safetensors`;
|
||||
}
|
||||
previewElement.style.display = 'block';
|
||||
}
|
||||
|
||||
async saveFilenameTemplates() {
|
||||
try {
|
||||
// Save to backend using universal save method
|
||||
await this.saveSetting('download_filename_templates', state.global.settings.download_filename_templates);
|
||||
|
||||
showToast('toast.settings.filenameTemplatesUpdated', {}, 'success');
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error saving download filename templates:', error);
|
||||
showToast('toast.settings.filenameTemplatesFailed', { message: error.message }, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async applyFilenameTemplate(modelType) {
|
||||
const template = state.global.settings.download_filename_templates?.[modelType] || '';
|
||||
if (!template) {
|
||||
showToast('settings.filenameTemplates.emptyTemplateInfo', {}, 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!confirm(translate('settings.filenameTemplates.confirmApply', {}, 'Rename all existing files of this model type according to the filename template? The original filename is preserved in each model\'s metadata.'))) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const apiClient = getModelApiClient(FILENAME_TEMPLATE_MODEL_TYPES[modelType]);
|
||||
await apiClient.applyFilenameTemplate();
|
||||
resetAndReload(true);
|
||||
} catch (error) {
|
||||
// The API client already surfaced a toast with the failure reason
|
||||
console.error('Error applying filename template:', error);
|
||||
}
|
||||
}
|
||||
|
||||
toggleSettings() {
|
||||
if (this.isOpen) {
|
||||
modalManager.closeModal('settingsModal');
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Create the new hierarchical state structure
|
||||
import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js';
|
||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||
import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js';
|
||||
import { DEFAULT_PATH_TEMPLATES, DEFAULT_FILENAME_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js';
|
||||
|
||||
const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
civitai_api_key: '',
|
||||
@@ -30,6 +30,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||
recipes_path: '',
|
||||
base_model_path_mappings: {},
|
||||
download_path_templates: {},
|
||||
download_filename_templates: {},
|
||||
example_images_path: '',
|
||||
example_images_open_mode: 'system',
|
||||
example_images_local_root: '',
|
||||
@@ -74,6 +75,7 @@ export function createDefaultSettings() {
|
||||
...DEFAULT_SETTINGS_BASE,
|
||||
base_model_path_mappings: {},
|
||||
download_path_templates: { ...DEFAULT_PATH_TEMPLATES },
|
||||
download_filename_templates: { ...DEFAULT_FILENAME_TEMPLATES },
|
||||
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
||||
default_other_roots: {},
|
||||
enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'],
|
||||
|
||||
@@ -360,6 +360,25 @@ export const DEFAULT_PATH_TEMPLATES = {
|
||||
other: ''
|
||||
};
|
||||
|
||||
// Valid placeholders for download filename templates (opt-in rename of
|
||||
// downloaded safetensors; the result is a filename stem, no path separators)
|
||||
export const FILENAME_TEMPLATE_PLACEHOLDERS = [
|
||||
'{model_name}',
|
||||
'{version_name}',
|
||||
'{base_model}',
|
||||
'{author}',
|
||||
'{first_tag}',
|
||||
'{hash_short}',
|
||||
'{original_name}'
|
||||
];
|
||||
|
||||
// Default filename templates per model type; empty string keeps the original filename
|
||||
export const DEFAULT_FILENAME_TEMPLATES = {
|
||||
lora: '',
|
||||
checkpoint: '',
|
||||
embedding: ''
|
||||
};
|
||||
|
||||
// Model type labels for UI
|
||||
export const MODEL_TYPE_LABELS = {
|
||||
lora: 'LoRA Models',
|
||||
|
||||
Reference in New Issue
Block a user