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:
Will Miao
2026-09-19 09:04:24 +08:00
parent 327da0465b
commit 2bc9860b24
38 changed files with 2239 additions and 7 deletions
+3
View File
@@ -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: {}
};
+129
View File
@@ -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', {
+197 -1
View File
@@ -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');
+3 -1
View File
@@ -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'],
+19
View File
@@ -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',