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
+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');