mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -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:
@@ -0,0 +1,276 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
||||
modalManager: {
|
||||
closeModal: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||
showToast: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/state/index.js', () => {
|
||||
const settings = {};
|
||||
return {
|
||||
state: {
|
||||
global: {
|
||||
settings,
|
||||
},
|
||||
},
|
||||
createDefaultSettings: () => ({
|
||||
language: 'en',
|
||||
download_filename_templates: { lora: '', checkpoint: '', embedding: '' },
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||
resetAndReload: vi.fn(),
|
||||
getModelApiClient: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/constants.js', () => ({
|
||||
DOWNLOAD_PATH_TEMPLATES: {},
|
||||
DEFAULT_PATH_TEMPLATES: {},
|
||||
MAPPABLE_BASE_MODELS: [],
|
||||
PATH_TEMPLATE_PLACEHOLDERS: [],
|
||||
FILENAME_TEMPLATE_PLACEHOLDERS: [
|
||||
'{model_name}',
|
||||
'{version_name}',
|
||||
'{base_model}',
|
||||
'{author}',
|
||||
'{first_tag}',
|
||||
'{hash_short}',
|
||||
'{original_name}',
|
||||
],
|
||||
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
|
||||
DEFAULT_PRIORITY_TAG_CONFIG: {
|
||||
lora: 'character, style',
|
||||
checkpoint: 'base, guide',
|
||||
embedding: 'hint',
|
||||
},
|
||||
getMappableBaseModelsDynamic: () => [],
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||
translate: (key, params, fallback) => {
|
||||
if (params && fallback) {
|
||||
return fallback.replace(/\{(\w+)\}/g, (match, name) => params[name] ?? match);
|
||||
}
|
||||
return fallback ?? '';
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/i18n/index.js', () => ({
|
||||
i18n: {
|
||||
getCurrentLocale: () => 'en',
|
||||
setLanguage: vi.fn().mockResolvedValue(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
|
||||
configureModelCardVideo: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/managers/BannerService.js', () => ({
|
||||
bannerService: {
|
||||
registerBanner: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
|
||||
import { state } from '../../../static/js/state/index.js';
|
||||
import { showToast } from '../../../static/js/utils/uiHelpers.js';
|
||||
import { resetAndReload, getModelApiClient } from '../../../static/js/api/modelApiFactory.js';
|
||||
|
||||
const createManager = () => {
|
||||
const initSettingsSpy = vi
|
||||
.spyOn(SettingsManager.prototype, 'initializeSettings')
|
||||
.mockResolvedValue();
|
||||
const initializeSpy = vi
|
||||
.spyOn(SettingsManager.prototype, 'initialize')
|
||||
.mockImplementation(() => {});
|
||||
|
||||
const manager = new SettingsManager();
|
||||
|
||||
initSettingsSpy.mockRestore();
|
||||
initializeSpy.mockRestore();
|
||||
|
||||
return manager;
|
||||
};
|
||||
|
||||
const appendFilenameTemplateUi = (modelType = 'lora') => {
|
||||
document.body.innerHTML = `
|
||||
<input id="${modelType}FilenameTemplate" />
|
||||
<div id="${modelType}FilenameValidation"></div>
|
||||
<div id="${modelType}FilenamePreview"></div>
|
||||
<button id="${modelType}ApplyFilenameTemplate" type="button"></button>
|
||||
`;
|
||||
};
|
||||
|
||||
describe('SettingsManager filename templates', () => {
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
vi.clearAllMocks();
|
||||
state.global.settings = {
|
||||
download_filename_templates: { lora: '', checkpoint: '', embedding: '' },
|
||||
};
|
||||
});
|
||||
|
||||
it('treats an empty template as valid (keep original filename)', () => {
|
||||
appendFilenameTemplateUi();
|
||||
const manager = createManager();
|
||||
|
||||
expect(manager.validateFilenameTemplate('lora', '')).toBe(true);
|
||||
|
||||
const validation = document.getElementById('loraFilenameValidation');
|
||||
expect(validation.classList.contains('valid')).toBe(true);
|
||||
expect(validation.textContent).toContain('keep original filename');
|
||||
});
|
||||
|
||||
it('rejects templates with path separators or OS-illegal characters', () => {
|
||||
appendFilenameTemplateUi();
|
||||
const manager = createManager();
|
||||
|
||||
expect(manager.validateFilenameTemplate('lora', '{base_model}/{model_name}')).toBe(false);
|
||||
expect(manager.validateFilenameTemplate('lora', 'a:b')).toBe(false);
|
||||
|
||||
const validation = document.getElementById('loraFilenameValidation');
|
||||
expect(validation.classList.contains('invalid')).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects unknown placeholders', () => {
|
||||
appendFilenameTemplateUi();
|
||||
const manager = createManager();
|
||||
|
||||
expect(manager.validateFilenameTemplate('lora', '{bogus}-{model_name}')).toBe(false);
|
||||
|
||||
const validation = document.getElementById('loraFilenameValidation');
|
||||
expect(validation.textContent).toContain('{bogus}');
|
||||
});
|
||||
|
||||
it('accepts a template using only known placeholders', () => {
|
||||
appendFilenameTemplateUi();
|
||||
const manager = createManager();
|
||||
|
||||
const template = '{base_model}-{model_name}-{version_name}-{hash_short}';
|
||||
expect(manager.validateFilenameTemplate('lora', template)).toBe(true);
|
||||
expect(document.getElementById('loraFilenameValidation').classList.contains('valid')).toBe(true);
|
||||
});
|
||||
|
||||
it('saves a valid template via saveSetting with the merged dict', () => {
|
||||
appendFilenameTemplateUi();
|
||||
const manager = createManager();
|
||||
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||
|
||||
manager.updateFilenameTemplate('lora', '{model_name}');
|
||||
|
||||
expect(state.global.settings.download_filename_templates.lora).toBe('{model_name}');
|
||||
expect(manager.saveSetting).toHaveBeenCalledWith(
|
||||
'download_filename_templates',
|
||||
{ lora: '{model_name}', checkpoint: '', embedding: '' },
|
||||
);
|
||||
});
|
||||
|
||||
it('does not save an invalid template', () => {
|
||||
appendFilenameTemplateUi();
|
||||
const manager = createManager();
|
||||
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||
|
||||
manager.updateFilenameTemplate('lora', '{unknown_placeholder}');
|
||||
|
||||
expect(state.global.settings.download_filename_templates.lora).toBe('');
|
||||
expect(manager.saveSetting).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('previews the original filename when the template is empty', () => {
|
||||
appendFilenameTemplateUi();
|
||||
const manager = createManager();
|
||||
|
||||
manager.updateFilenamePreview('lora', '');
|
||||
|
||||
expect(document.getElementById('loraFilenamePreview').textContent).toBe('V1.safetensors');
|
||||
});
|
||||
|
||||
it('renders a preview with example placeholder values', () => {
|
||||
appendFilenameTemplateUi();
|
||||
const manager = createManager();
|
||||
|
||||
manager.updateFilenamePreview('lora', '{base_model}-{model_name}-{version_name}-{hash_short}');
|
||||
|
||||
expect(document.getElementById('loraFilenamePreview').textContent)
|
||||
.toBe('Flux.1 D-model-name-v3-a1b2c3d4e5.safetensors');
|
||||
});
|
||||
|
||||
it('disables the apply button when the template is empty', () => {
|
||||
appendFilenameTemplateUi();
|
||||
const manager = createManager();
|
||||
|
||||
manager.updateFilenameTemplateApplyButton('lora', '');
|
||||
expect(document.getElementById('loraApplyFilenameTemplate').disabled).toBe(true);
|
||||
|
||||
manager.updateFilenameTemplateApplyButton('lora', '{model_name}');
|
||||
expect(document.getElementById('loraApplyFilenameTemplate').disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('merges backend download_filename_templates over defaults', () => {
|
||||
const manager = createManager();
|
||||
|
||||
const merged = manager.mergeSettingsWithDefaults({
|
||||
download_filename_templates: { lora: '{model_name}' },
|
||||
});
|
||||
expect(merged.download_filename_templates).toEqual({
|
||||
lora: '{model_name}',
|
||||
checkpoint: '',
|
||||
embedding: '',
|
||||
});
|
||||
|
||||
const fromString = manager.mergeSettingsWithDefaults({
|
||||
download_filename_templates: '{"checkpoint":"{hash_short}"}',
|
||||
});
|
||||
expect(fromString.download_filename_templates).toEqual({
|
||||
lora: '',
|
||||
checkpoint: '{hash_short}',
|
||||
embedding: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows an info toast and does nothing when applying an empty template', async () => {
|
||||
appendFilenameTemplateUi();
|
||||
const manager = createManager();
|
||||
vi.stubGlobal('confirm', vi.fn(() => true));
|
||||
|
||||
await manager.applyFilenameTemplate('lora');
|
||||
|
||||
expect(showToast).toHaveBeenCalledWith('settings.filenameTemplates.emptyTemplateInfo', {}, 'info');
|
||||
expect(getModelApiClient).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies the template through the model API client and reloads', async () => {
|
||||
appendFilenameTemplateUi();
|
||||
state.global.settings.download_filename_templates.lora = '{model_name}';
|
||||
const manager = createManager();
|
||||
vi.stubGlobal('confirm', vi.fn(() => true));
|
||||
const apiClient = { applyFilenameTemplate: vi.fn().mockResolvedValue() };
|
||||
getModelApiClient.mockReturnValue(apiClient);
|
||||
|
||||
await manager.applyFilenameTemplate('lora');
|
||||
|
||||
expect(getModelApiClient).toHaveBeenCalledWith('loras');
|
||||
expect(apiClient.applyFilenameTemplate).toHaveBeenCalledWith();
|
||||
expect(resetAndReload).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('does not apply when the confirm dialog is declined', async () => {
|
||||
appendFilenameTemplateUi();
|
||||
state.global.settings.download_filename_templates.lora = '{model_name}';
|
||||
const manager = createManager();
|
||||
vi.stubGlobal('confirm', vi.fn(() => false));
|
||||
|
||||
await manager.applyFilenameTemplate('lora');
|
||||
|
||||
expect(getModelApiClient).not.toHaveBeenCalled();
|
||||
expect(resetAndReload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user