Compare commits

..

6 Commits

Author SHA1 Message Date
Will Miao 521531111a i18n: translate the Filename Templates feature into all locales
26 keys (settings.filenameTemplates.*, filenameTemplateProgress,
modals.filenameTemplateConfirm, related toasts) translated into the 9
non-English locales, reusing each locale's autoOrganizeProgress /
downloadPathTemplates renderings. Terminology recorded in
docs/i18n-translation-guidelines.md.
2026-09-19 11:00:39 +08:00
Will Miao 474da1b264 feat(settings): empty filename template reverts to recorded original filename (#1071)
Redefine the empty download filename template from a no-op to a bulk
revert: FilenameTemplateUseCase resolves the target from each model's
recorded original_file_name sidecar entry (skipping models without one),
which resolves follow-ups 1 and 2 with a single coherent semantic shared
by the download and bulk-apply paths.

Also replace the browser-native confirm() with a self-managed
confirmation modal (filenameTemplateConfirmModal) that stacks above the
settings modal, since ModalManager would close the settings modal when
opening a registered one.
2026-09-19 10:44:43 +08:00
Will Miao 78d38b449e docs: record filename template follow-ups for #1071 2026-09-19 09:05:39 +08:00
Will Miao 2bc9860b24 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
2026-09-19 09:04:24 +08:00
Will Miao 327da0465b feat(settings): split overloaded Library tab into a new Organization tab
Move download path templates, priority tags, and auto-organize
exclusions out of the Library settings section into a dedicated
Organization section, so Library keeps location-focused settings
(roots, extra paths, example images, metadata) and Organization holds
file-arrangement rules. Translated settings.nav.organization for all
locales.
2026-09-19 07:38:07 +08:00
Will Miao c8c84bfc54 feat(loras): warn when widget strength leaves the usage-tips range
The cycler-list payload now carries usage_tips, and the LORAS widget
parses strength_min/strength_max/strength_range into a cached lookup.
Strength inputs (model and clip) turn amber with an explanatory tooltip
when dragged, typed, or stepped outside the recommended range.

Related: https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1090
2026-09-19 05:49:01 +08:00
51 changed files with 3468 additions and 231 deletions
+50
View File
@@ -66,6 +66,18 @@ Locales: `en`, `zh-CN`, `zh-TW`, `ja`, `ko`, `fr`, `de`, `es`, `ru`, `he` (RTL).
> section" — and re-translated in all 9 locales. The `on disk` phrase now survives only in > section" — and re-translated in all 9 locales. The `on disk` phrase now survives only in
> the ComfyUI variant (`descriptionComfyUI`). > the ComfyUI variant (`descriptionComfyUI`).
> **Status (2026-09, settings Organization tab):** the settings modal split its overloaded
> Library tab, adding the single `settings.nav.organization` key (renderings in §2,
> "Settings Organization tab"). All 9 locales are translated, so the "no remaining
> placeholders" claim holds again.
> **Status (2026-09, filename templates):** the Filename Templates feature (per-model-type
> download filename templates + bulk "Apply to Library Now" rename, with an empty template
> restoring recorded original filenames) added 26 keys across `settings.filenameTemplates.*`,
> `loras.bulkOperations.filenameTemplateProgress.*`, `modals.filenameTemplateConfirm.*` and
> the `toast.loras.filenameTemplate*` / `toast.settings.filenameTemplates*` toasts. All 9
> locales are translated (terminology in §2, "Filename Templates feature").
--- ---
## 1. Hard rules (do not violate) ## 1. Hard rules (do not violate)
@@ -371,6 +383,44 @@ clause (and its `—`) when the copy is edited. The `{name}` / `{count}` / `{mes
`sidebar.createFolderResult.*`, `sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*` `sidebar.createFolderResult.*`, `sidebar.deleteFolderResult.*` and `sidebar.renameFolderResult.*`
are verbatim §1-R2 placeholders; `successWithFiles` is the only key carrying `{count}`. are verbatim §1-R2 placeholders; `successWithFiles` is the only key carrying `{count}`.
### Settings Organization tab
The settings modal's fourth nav tab groups everything about how files are arranged on
disk: download path templates, priority tags, and auto-organize exclusions. The label is
the **noun for arranging files**, matching each locale's existing
`settings.sections.autoOrganize` rendering minus the "auto":
| Locale | `settings.nav.organization` |
|---|---|
| fr | Organisation |
| zh-CN | 整理 |
| zh-TW | 整理 |
| ja | 整理 |
| ko | 정리 |
| de | Organisation |
| es | Organización |
| ru | Организация |
| he | ארגון |
zh-CN/zh-TW use 整理 ("tidying/arranging"), not 组织/組織 (an organization as a group).
### Filename Templates feature
Per-model-type templates that name downloaded model files; "Apply to Library Now"
bulk-renames existing files, and an **empty template restores the recorded original
filenames** (recorded in each model's metadata at its first rename). "Template" follows
each locale's existing download-path-template noun (zh-CN 模板 vs zh-TW 範本 — note the
split); progress strings mirror `loras.bulkOperations.autoOrganizeProgress` verbatim with
the locale's "moved" verb swapped for its "renamed" verb, and the toasts mirror the
`autoOrganize*` / `downloadTemplates*` toast shapes.
| Term | Rendering |
|---|---|
| filename template(s) | zh-CN 文件名模板 · zh-TW 檔案名稱範本 · ja ファイル名テンプレート · ko 파일명 템플릿 · fr modèle(s) de nom de fichier · de Dateinamen-Vorlage(n) · es plantilla(s) de nombres de archivo · ru шаблон(ы) имён файлов · he תבנית שם קובץ / תבניות שמות קבצים |
| Apply to Library Now (button) | zh-CN 立即应用到库 · zh-TW 立即套用至模型庫 · ja ライブラリに今すぐ適用 · ko 지금 라이브러리에 적용 · fr Appliquer à la bibliothèque maintenant · de Jetzt auf Bibliothek anwenden · es Aplicar a la biblioteca ahora · ru Применить к библиотеке сейчас · he החל על הספרייה כעת |
| Restore original filenames (modal title / button) | zh-CN 恢复原始文件名?/ 恢复原始文件名 · zh-TW 要還原原始檔案名稱嗎?/ 還原原始檔案名稱 · ja 元のファイル名を復元しますか?/ 元のファイル名を復元 · ko 원본 파일명을 복원하시겠습니까? / 원본 파일명 복원 · fr Restaurer les noms de fichier d'origine ? / Restaurer les noms de fichier d'origine · de Ursprüngliche Dateinamen wiederherstellen? / Ursprüngliche Dateinamen wiederherstellen · es ¿Restaurar los nombres de archivo originales? / Restaurar nombres de archivo originales · ru Восстановить исходные имена файлов? / Восстановить исходные имена файлов · he לשחזר שמות קבצים מקוריים? / שחזר שמות קבצים מקוריים |
| "renamed" (progress/toast counter) | zh-CN 已重命名 · zh-TW 已重新命名 · ja リネーム · ko 이름 변경 · fr renommés · de umbenannt · es renombrados · ru переименовано · he שונו שמותם |
### Chip reordering (model tags / trigger words) ### Chip reordering (model tags / trigger words)
Model tags and trigger-word chips share a single reorder affordance (drag the chip, or its Model tags and trigger-word chips share a single reorder affordance (drag the chip, or its
@@ -0,0 +1,107 @@
# Plan: Filename Template Follow-ups
**Issue:** [#1071 — Lora Renaming](https://github.com/willmiao/ComfyUI-Lora-Manager/issues/1071)
**Status:** Core feature **implemented** (2026-09-19, commit `2bc9860b`,
preceded by the settings-tab split in `327da046`). Follow-ups 1 and 2 were
resolved together on 2026-09-19 by redefining the empty template as
"revert to recorded original filename" (see below). Follow-up 3 remains open.
## What shipped in `2bc9860b`
- Per-model-type `download_filename_templates` setting (empty = keep current
filename; opt-in). Placeholders: `{model_name}`, `{version_name}`,
`{base_model}`, `{author}`, `{first_tag}`, `{hash_short}`,
`{original_name}`.
- `calculate_filename_for_model()` in `py/utils/utils.py` renders the
template; templates containing path separators are rejected.
- Downloads apply the template post-download
(`DownloadManager._apply_download_filename_template`); rename conflicts
keep the original name and never fail the download.
- `ModelLifecycleService.rename_model` records `original_file_name` in the
`.metadata.json` sidecar (first rename wins via `setdefault`).
- Bulk apply: `GET|POST /api/lm/{prefix}/apply-filename-template`
(`FilenameTemplateUseCase`, shares the auto-organize lock, WS progress type
`filename_template_progress`).
- Settings UI: "Filename Templates" subsection in the new **Organization**
settings tab (`templates/components/modals/settings/organization.html`),
with validation, live preview, and per-type "Apply to Library Now".
Sandbox E2E verified: rename incl. companion files (previews, sidecars),
metadata pointer updates, `original_file_name` recording, idempotency,
conflict handling (failure counted, batch continues), empty-template no-op,
GET variant.
## Follow-ups 1 & 2 — RESOLVED: empty template = revert to recorded original
Follow-up 1 asked to reword the ambiguous "Valid (keep original filename)"
empty-template message; Follow-up 2 asked for a bulk revert to the recorded
`original_file_name`. Both were resolved by a single semantic change: **an
empty template now means "restore the recorded original filename"** instead of
"leave the current filename untouched".
Rationale: for never-renamed models a revert is a no-op (no recorded
original), for renamed models it restores the pre-rename name, and new
downloads with an empty template keep the download name as before — so the
two contexts (download path and bulk apply) share one coherent meaning, and
no separate revert feature or `{recorded_original}` placeholder is needed.
Implemented changes:
- `FilenameTemplateUseCase._process_model`: an empty template now resolves
the target name from the sidecar's `original_file_name` via the injected
`metadata_loader` (default `load_local_metadata`); models without a
recorded original or whose original matches the current name are skipped.
Cache entries do not project `original_file_name`, so the sidecar is read
per model.
- `SettingsManager.js`: removed the empty-template early return and the
apply-button disable (`updateFilenameTemplateApplyButton` deleted — the
button is now always enabled). The browser-native `confirm()` was replaced
with `filenameTemplateConfirmModal`
(`templates/components/modals/confirm_modals.html`), a **self-managed**
modal (like `DirectoryPickerModal`, NOT registered with ModalManager):
ModalManager's "close current modal on open" behavior would kill the
settings modal underneath. It stacks via `z-index: 10010`
(`delete-modal.css`), handles ESC in capture phase with
`stopPropagation`, and shows apply vs revert wording
(`modals.filenameTemplateConfirm.titleApply` / `titleRevert` /
`revertButton`; messages reuse `settings.filenameTemplates.confirmApply` /
`confirmRevert`).
- `locales/en.json`: reworded `help` / `applyHelp`, replaced
`validation.keepOriginal` with `validation.restoreOriginal`
("Valid (empty template restores original filenames)"), added
`confirmRevert`, removed the now-unused `emptyTemplateInfo`. Other locales
re-synced with `[TODO: Translate]` placeholders — retranslation waits for
the feature owner's request per `docs/i18n-translation-guidelines.md` §7.
- Tests: revert / no-record-skip / same-name-skip cases in
`tests/services/test_use_cases.py`; modal confirm-and-revert and
cancel paths in
`tests/frontend/managers/settingsManager.filenameTemplates.test.js`.
Sandbox E2E verified (standalone server, sandboxed settings + library under
`/tmp`, 2026-09-19): template apply renames and records
`original_file_name`; empty-template apply reverts to the recorded name;
revert target occupied by a newer file counts as failure and keeps the
current name; models without a recorded original are skipped;
apply → revert → re-apply cycles repeat cleanly.
Standing caveats (unchanged):
- The revert target may collide with an existing file — the existing conflict
handling (count as failure, keep current name) covers this.
- `original_file_name` only exists for models renamed after `2bc9860b`;
older renames have no recorded original and are skipped.
- `original_file_name` is kept (not cleared) after a revert, so
apply → revert → re-apply stays repeatable.
## Follow-up 3 — Cross-page refresh after bulk apply
**Problem:** the settings-modal "Apply to Library Now" button calls
`resetAndReload(true)`, which refreshes only the page type currently open.
Applying the checkpoint template while on the loras page leaves the loras
view refreshed but does not touch the checkpoints page state (same
limitation as the existing bulk auto-organize flow in
`static/js/managers/SettingsManager.js#applyFilenameTemplate`).
**Fix options:** broadcast a generic "library changed" event that every
page's state listens to, or accept the limitation (the other page reloads
its cache on next visit). Low priority.
+35
View File
@@ -383,6 +383,7 @@
"general": "Allgemein", "general": "Allgemein",
"interface": "Oberfläche", "interface": "Oberfläche",
"library": "Bibliothek", "library": "Bibliothek",
"organization": "Organisation",
"modelPaths": "Modellpfade" "modelPaths": "Modellpfade"
}, },
"search": { "search": {
@@ -680,6 +681,22 @@
"validTemplate": "Gültige Vorlage" "validTemplate": "Gültige Vorlage"
} }
}, },
"filenameTemplates": {
"title": "Dateinamen-Vorlagen",
"help": "Konfigurieren Sie Dateinamen für heruntergeladene Modelle pro Modelltyp. Leer lassen, um den ursprünglichen Dateinamen zu behalten. Der ursprüngliche Dateiname bleibt immer in den Metadaten des Modells erhalten.",
"availablePlaceholders": "Verfügbare Platzhalter:",
"templatePlaceholder": "Dateinamen-Vorlage eingeben (z.B. {base_model}-{model_name}-{version_name})",
"applyButton": "Jetzt auf Bibliothek anwenden",
"applyHelp": "Benennt alle vorhandenen Dateien dieses Modelltyps gemäß der Vorlage um. Warnung: Das Umbenennen ändert den relativen Pfad, den ComfyUI-Loader sehen; vorhandene Workflows, die den alten Dateinamen referenzieren, müssen möglicherweise aktualisiert werden. Der ursprüngliche Dateiname bleibt in den Metadaten jedes Modells erhalten.",
"confirmApply": "Alle vorhandenen Dateien dieses Modelltyps gemäß der Dateinamen-Vorlage umbenennen? Dies ändert den relativen Pfad, den ComfyUI-Loader sehen. Der ursprüngliche Dateiname bleibt in den Metadaten jedes Modells erhalten.",
"confirmRevert": "Die gespeicherten ursprünglichen Dateinamen aller zuvor umbenannten Dateien dieses Modelltyps wiederherstellen? Dies ändert den relativen Pfad, den ComfyUI-Loader sehen. Dateien ohne gespeicherten ursprünglichen Dateinamen werden übersprungen.",
"validation": {
"restoreOriginal": "Gültig (leere Vorlage stellt ursprüngliche Dateinamen wieder her)",
"invalidChars": "Ungültige Zeichen erkannt (ein Dateiname darf / \\ < > : \" | ? * nicht enthalten)",
"invalidPlaceholder": "Ungültiger Platzhalter: {placeholder}",
"validTemplate": "Gültige Vorlage"
}
},
"exampleImages": { "exampleImages": {
"downloadLocation": "Download-Speicherort", "downloadLocation": "Download-Speicherort",
"downloadLocationPlaceholder": "Ordnerpfad für Beispielbilder eingeben", "downloadLocationPlaceholder": "Ordnerpfad für Beispielbilder eingeben",
@@ -912,6 +929,14 @@
"complete": "Automatische Organisation abgeschlossen", "complete": "Automatische Organisation abgeschlossen",
"error": "Fehler: {error}" "error": "Fehler: {error}"
}, },
"filenameTemplateProgress": {
"initializing": "Anwendung der Dateinamen-Vorlage wird initialisiert...",
"starting": "Dateinamen-Vorlage wird auf {type} angewendet...",
"processing": "Verarbeitung ({processed}/{total}) {success} umbenannt, {skipped} übersprungen, {failures} fehlgeschlagen",
"completed": "Abgeschlossen: {success} umbenannt, {skipped} übersprungen, {failures} fehlgeschlagen",
"complete": "Anwendung der Dateinamen-Vorlage abgeschlossen",
"error": "Fehler: {error}"
},
"enrichHfAgent": "Metadaten mit KI anreichern" "enrichHfAgent": "Metadaten mit KI anreichern"
}, },
"contextMenu": { "contextMenu": {
@@ -1601,6 +1626,11 @@
"tip": "Möchten Sie in Etappen prüfen? Wechseln Sie in den Massenmodus, wählen Sie die benötigten Modelle aus und nutzen Sie anschließend \"Auswahl auf Updates prüfen\".", "tip": "Möchten Sie in Etappen prüfen? Wechseln Sie in den Massenmodus, wählen Sie die benötigten Modelle aus und nutzen Sie anschließend \"Auswahl auf Updates prüfen\".",
"action": "Alles prüfen" "action": "Alles prüfen"
}, },
"filenameTemplateConfirm": {
"titleApply": "Dateinamen-Vorlage auf Bibliothek anwenden?",
"titleRevert": "Ursprüngliche Dateinamen wiederherstellen?",
"revertButton": "Ursprüngliche Dateinamen wiederherstellen"
},
"bulkAddTags": { "bulkAddTags": {
"title": "Tags zu mehreren Modellen hinzufügen", "title": "Tags zu mehreren Modellen hinzufügen",
"description": "Tags hinzufügen zu", "description": "Tags hinzufügen zu",
@@ -2310,6 +2340,9 @@
"autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen", "autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen",
"autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen", "autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen",
"autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}", "autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}",
"filenameTemplateSuccess": "Dateinamen-Vorlage erfolgreich für {count} {type} angewendet",
"filenameTemplatePartialSuccess": "Dateinamen-Vorlage angewendet: {success} umbenannt, {failures} von {total} Modellen fehlgeschlagen",
"filenameTemplateFailed": "Anwendung der Dateinamen-Vorlage fehlgeschlagen: {error}",
"noModelsSelected": "Keine Modelle ausgewählt" "noModelsSelected": "Keine Modelle ausgewählt"
}, },
"recipes": { "recipes": {
@@ -2476,6 +2509,8 @@
"mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}", "mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}",
"downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert", "downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert",
"downloadTemplatesFailed": "Fehler beim Speichern der Download-Pfad-Vorlagen: {message}", "downloadTemplatesFailed": "Fehler beim Speichern der Download-Pfad-Vorlagen: {message}",
"filenameTemplatesUpdated": "Dateinamen-Vorlagen aktualisiert",
"filenameTemplatesFailed": "Dateinamen-Vorlagen konnten nicht gespeichert werden: {message}",
"recipesPathUpdated": "Rezepte-Speicherpfad aktualisiert", "recipesPathUpdated": "Rezepte-Speicherpfad aktualisiert",
"recipesPathSaveFailed": "Fehler beim Aktualisieren des Rezepte-Speicherpfads: {message}", "recipesPathSaveFailed": "Fehler beim Aktualisieren des Rezepte-Speicherpfads: {message}",
"settingsUpdated": "Einstellungen aktualisiert: {setting}", "settingsUpdated": "Einstellungen aktualisiert: {setting}",
+35
View File
@@ -383,6 +383,7 @@
"general": "General", "general": "General",
"interface": "Interface", "interface": "Interface",
"library": "Library", "library": "Library",
"organization": "Organization",
"modelPaths": "Model Paths" "modelPaths": "Model Paths"
}, },
"search": { "search": {
@@ -680,6 +681,22 @@
"validTemplate": "Valid template" "validTemplate": "Valid template"
} }
}, },
"filenameTemplates": {
"title": "Filename Templates",
"help": "Configure filenames for downloaded models per model type. Leave empty to keep original filenames on download; applying an empty template restores the recorded original filenames of previously renamed models. The original filename is always preserved in the model's metadata.",
"availablePlaceholders": "Available placeholders:",
"templatePlaceholder": "Enter filename template (e.g., {base_model}-{model_name}-{version_name})",
"applyButton": "Apply to Library Now",
"applyHelp": "Renames all existing files of this model type according to the template; with an empty template, restores the recorded original filenames instead. Warning: renaming changes the relative path seen by ComfyUI loaders, so existing workflows referencing the old filename may need to be updated. The original filename is preserved in each model's metadata.",
"confirmApply": "Rename all existing files of this model type according to the filename template? This changes the relative path seen by ComfyUI loaders. The original filename is preserved in each model's metadata.",
"confirmRevert": "Restore the recorded original filenames of all previously renamed files of this model type? This changes the relative path seen by ComfyUI loaders. Files without a recorded original filename are skipped.",
"validation": {
"restoreOriginal": "Valid (empty template restores original filenames)",
"invalidChars": "Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)",
"invalidPlaceholder": "Invalid placeholder: {placeholder}",
"validTemplate": "Valid template"
}
},
"exampleImages": { "exampleImages": {
"downloadLocation": "Download Location", "downloadLocation": "Download Location",
"downloadLocationPlaceholder": "Enter folder path for example images", "downloadLocationPlaceholder": "Enter folder path for example images",
@@ -912,6 +929,14 @@
"complete": "Auto-organize complete", "complete": "Auto-organize complete",
"error": "Error: {error}" "error": "Error: {error}"
}, },
"filenameTemplateProgress": {
"initializing": "Initializing filename template apply...",
"starting": "Applying filename template to {type}...",
"processing": "Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed",
"completed": "Completed: {success} renamed, {skipped} skipped, {failures} failed",
"complete": "Filename template apply complete",
"error": "Error: {error}"
},
"enrichHfAgent": "Enrich Metadata with AI" "enrichHfAgent": "Enrich Metadata with AI"
}, },
"contextMenu": { "contextMenu": {
@@ -1601,6 +1626,11 @@
"tip": "To work in smaller batches, switch to bulk mode, choose the ones you need, then use \"Check Updates for Selected\".", "tip": "To work in smaller batches, switch to bulk mode, choose the ones you need, then use \"Check Updates for Selected\".",
"action": "Check All" "action": "Check All"
}, },
"filenameTemplateConfirm": {
"titleApply": "Apply filename template to library?",
"titleRevert": "Restore original filenames?",
"revertButton": "Restore Original Filenames"
},
"bulkAddTags": { "bulkAddTags": {
"title": "Add Tags to Multiple Models", "title": "Add Tags to Multiple Models",
"description": "Add tags to", "description": "Add tags to",
@@ -2310,6 +2340,9 @@
"autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}", "autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}",
"autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models", "autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models",
"autoOrganizeFailed": "Auto-organize failed: {error}", "autoOrganizeFailed": "Auto-organize failed: {error}",
"filenameTemplateSuccess": "Filename template applied successfully for {count} {type}",
"filenameTemplatePartialSuccess": "Filename template applied with {success} renamed, {failures} failed out of {total} models",
"filenameTemplateFailed": "Applying filename template failed: {error}",
"noModelsSelected": "No models selected" "noModelsSelected": "No models selected"
}, },
"recipes": { "recipes": {
@@ -2476,6 +2509,8 @@
"mappingSaveFailed": "Failed to save base model mappings: {message}", "mappingSaveFailed": "Failed to save base model mappings: {message}",
"downloadTemplatesUpdated": "Download path templates updated", "downloadTemplatesUpdated": "Download path templates updated",
"downloadTemplatesFailed": "Failed to save download path templates: {message}", "downloadTemplatesFailed": "Failed to save download path templates: {message}",
"filenameTemplatesUpdated": "Filename templates updated",
"filenameTemplatesFailed": "Failed to save filename templates: {message}",
"recipesPathUpdated": "Recipes storage path updated", "recipesPathUpdated": "Recipes storage path updated",
"recipesPathSaveFailed": "Failed to update recipes storage path: {message}", "recipesPathSaveFailed": "Failed to update recipes storage path: {message}",
"settingsUpdated": "Settings updated: {setting}", "settingsUpdated": "Settings updated: {setting}",
+35
View File
@@ -383,6 +383,7 @@
"general": "General", "general": "General",
"interface": "Interfaz", "interface": "Interfaz",
"library": "Biblioteca", "library": "Biblioteca",
"organization": "Organización",
"modelPaths": "Rutas de modelos" "modelPaths": "Rutas de modelos"
}, },
"search": { "search": {
@@ -680,6 +681,22 @@
"validTemplate": "Plantilla válida" "validTemplate": "Plantilla válida"
} }
}, },
"filenameTemplates": {
"title": "Plantillas de nombres de archivo",
"help": "Configurar nombres de archivo de los modelos descargados por tipo de modelo. Dejar vacío para conservar los nombres de archivo originales al descargar; aplicar una plantilla vacía restaura los nombres de archivo originales registrados de los modelos renombrados previamente. El nombre de archivo original siempre se conserva en los metadatos del modelo.",
"availablePlaceholders": "Marcadores de posición disponibles:",
"templatePlaceholder": "Introduce plantilla de nombre de archivo (ej., {base_model}-{model_name}-{version_name})",
"applyButton": "Aplicar a la biblioteca ahora",
"applyHelp": "Renombra todos los archivos existentes de este tipo de modelo según la plantilla; con una plantilla vacía, restaura los nombres de archivo originales registrados. Advertencia: renombrar cambia la ruta relativa que ven los cargadores de ComfyUI, por lo que los workflows existentes que hagan referencia al nombre de archivo anterior pueden necesitar actualizarse. El nombre de archivo original se conserva en los metadatos de cada modelo.",
"confirmApply": "¿Renombrar todos los archivos existentes de este tipo de modelo según la plantilla de nombres de archivo? Esto cambia la ruta relativa que ven los cargadores de ComfyUI. El nombre de archivo original se conserva en los metadatos de cada modelo.",
"confirmRevert": "¿Restaurar los nombres de archivo originales registrados de todos los archivos renombrados previamente de este tipo de modelo? Esto cambia la ruta relativa que ven los cargadores de ComfyUI. Los archivos sin un nombre de archivo original registrado se omiten.",
"validation": {
"restoreOriginal": "Válido (la plantilla vacía restaura los nombres de archivo originales)",
"invalidChars": "Caracteres inválidos detectados (un nombre de archivo no puede contener / \\ < > : \" | ? *)",
"invalidPlaceholder": "Marcador de posición inválido: {placeholder}",
"validTemplate": "Plantilla válida"
}
},
"exampleImages": { "exampleImages": {
"downloadLocation": "Ubicación de descarga", "downloadLocation": "Ubicación de descarga",
"downloadLocationPlaceholder": "Introduce la ruta de la carpeta para imágenes de ejemplo", "downloadLocationPlaceholder": "Introduce la ruta de la carpeta para imágenes de ejemplo",
@@ -912,6 +929,14 @@
"complete": "Auto-organización completada", "complete": "Auto-organización completada",
"error": "Error: {error}" "error": "Error: {error}"
}, },
"filenameTemplateProgress": {
"initializing": "Inicializando aplicación de plantilla de nombres de archivo...",
"starting": "Aplicando plantilla de nombres de archivo a {type}...",
"processing": "Procesando ({processed}/{total}) - {success} renombrados, {skipped} omitidos, {failures} fallidos",
"completed": "Completado: {success} renombrados, {skipped} omitidos, {failures} fallidos",
"complete": "Aplicación de plantilla de nombres de archivo completada",
"error": "Error: {error}"
},
"enrichHfAgent": "Enriquecer metadatos con IA" "enrichHfAgent": "Enriquecer metadatos con IA"
}, },
"contextMenu": { "contextMenu": {
@@ -1601,6 +1626,11 @@
"tip": "¿Quieres hacerlo por partes? Activa el modo por lotes, selecciona los modelos que necesites y usa \"Comprobar actualizaciones para la selección\".", "tip": "¿Quieres hacerlo por partes? Activa el modo por lotes, selecciona los modelos que necesites y usa \"Comprobar actualizaciones para la selección\".",
"action": "Comprobar todo" "action": "Comprobar todo"
}, },
"filenameTemplateConfirm": {
"titleApply": "¿Aplicar la plantilla de nombres de archivo a la biblioteca?",
"titleRevert": "¿Restaurar los nombres de archivo originales?",
"revertButton": "Restaurar nombres de archivo originales"
},
"bulkAddTags": { "bulkAddTags": {
"title": "Añadir etiquetas a múltiples modelos", "title": "Añadir etiquetas a múltiples modelos",
"description": "Añadir etiquetas a", "description": "Añadir etiquetas a",
@@ -2310,6 +2340,9 @@
"autoOrganizeSuccess": "Auto-organización completada exitosamente para {count} {type}", "autoOrganizeSuccess": "Auto-organización completada exitosamente para {count} {type}",
"autoOrganizePartialSuccess": "Auto-organización completada con {success} movidos, {failures} fallidos de un total de {total} modelos", "autoOrganizePartialSuccess": "Auto-organización completada con {success} movidos, {failures} fallidos de un total de {total} modelos",
"autoOrganizeFailed": "Auto-organización fallida: {error}", "autoOrganizeFailed": "Auto-organización fallida: {error}",
"filenameTemplateSuccess": "Plantilla de nombres de archivo aplicada exitosamente para {count} {type}",
"filenameTemplatePartialSuccess": "Plantilla de nombres de archivo aplicada con {success} renombrados, {failures} fallidos de un total de {total} modelos",
"filenameTemplateFailed": "Aplicación de la plantilla de nombres de archivo fallida: {error}",
"noModelsSelected": "No hay modelos seleccionados" "noModelsSelected": "No hay modelos seleccionados"
}, },
"recipes": { "recipes": {
@@ -2476,6 +2509,8 @@
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}", "mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
"downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas", "downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas",
"downloadTemplatesFailed": "Error al guardar plantillas de rutas de descarga: {message}", "downloadTemplatesFailed": "Error al guardar plantillas de rutas de descarga: {message}",
"filenameTemplatesUpdated": "Plantillas de nombres de archivo actualizadas",
"filenameTemplatesFailed": "Error al guardar plantillas de nombres de archivo: {message}",
"recipesPathUpdated": "Ruta de almacenamiento de recetas actualizada", "recipesPathUpdated": "Ruta de almacenamiento de recetas actualizada",
"recipesPathSaveFailed": "Error al actualizar la ruta de almacenamiento de recetas: {message}", "recipesPathSaveFailed": "Error al actualizar la ruta de almacenamiento de recetas: {message}",
"settingsUpdated": "Configuración actualizada: {setting}", "settingsUpdated": "Configuración actualizada: {setting}",
+35
View File
@@ -383,6 +383,7 @@
"general": "Général", "general": "Général",
"interface": "Interface", "interface": "Interface",
"library": "Bibliothèque", "library": "Bibliothèque",
"organization": "Organisation",
"modelPaths": "Chemins de modèles" "modelPaths": "Chemins de modèles"
}, },
"search": { "search": {
@@ -680,6 +681,22 @@
"validTemplate": "Modèle valide" "validTemplate": "Modèle valide"
} }
}, },
"filenameTemplates": {
"title": "Modèles de nom de fichier",
"help": "Configurer les noms de fichier des modèles téléchargés par type de modèle. Laisser vide pour conserver le nom de fichier d'origine. Le nom de fichier d'origine est toujours conservé dans les métadonnées du modèle.",
"availablePlaceholders": "Espaces réservés disponibles :",
"templatePlaceholder": "Entrez un modèle de nom de fichier (ex: {base_model}-{model_name}-{version_name})",
"applyButton": "Appliquer à la bibliothèque maintenant",
"applyHelp": "Renomme tous les fichiers existants de ce type de modèle selon le modèle. Attention : le renommage change le chemin relatif vu par les loaders ComfyUI, les workflows existants référençant l'ancien nom de fichier peuvent donc nécessiter une mise à jour. Le nom de fichier d'origine est conservé dans les métadonnées de chaque modèle.",
"confirmApply": "Renommer tous les fichiers existants de ce type de modèle selon le modèle de nom de fichier ? Cela change le chemin relatif vu par les loaders ComfyUI. Le nom de fichier d'origine est conservé dans les métadonnées de chaque modèle.",
"confirmRevert": "Restaurer les noms de fichier d'origine enregistrés de tous les fichiers précédemment renommés de ce type de modèle ? Cela change le chemin relatif vu par les loaders ComfyUI. Les fichiers sans nom de fichier d'origine enregistré sont ignorés.",
"validation": {
"restoreOriginal": "Valide (un modèle vide restaure les noms de fichier d'origine)",
"invalidChars": "Caractères invalides détectés (un nom de fichier ne peut pas contenir / \\ < > : \" | ? *)",
"invalidPlaceholder": "Espace réservé invalide : {placeholder}",
"validTemplate": "Modèle valide"
}
},
"exampleImages": { "exampleImages": {
"downloadLocation": "Emplacement de téléchargement", "downloadLocation": "Emplacement de téléchargement",
"downloadLocationPlaceholder": "Entrez le chemin du dossier pour les images d'exemple", "downloadLocationPlaceholder": "Entrez le chemin du dossier pour les images d'exemple",
@@ -912,6 +929,14 @@
"complete": "Auto-organisation terminée", "complete": "Auto-organisation terminée",
"error": "Erreur : {error}" "error": "Erreur : {error}"
}, },
"filenameTemplateProgress": {
"initializing": "Initialisation de l'application du modèle de nom de fichier...",
"starting": "Application du modèle de nom de fichier pour {type}...",
"processing": "Traitement ({processed}/{total}) - {success} renommés, {skipped} ignorés, {failures} échecs",
"completed": "Terminé : {success} renommés, {skipped} ignorés, {failures} échecs",
"complete": "Application du modèle de nom de fichier terminée",
"error": "Erreur : {error}"
},
"enrichHfAgent": "Enrichir les métadonnées avec l'IA" "enrichHfAgent": "Enrichir les métadonnées avec l'IA"
}, },
"contextMenu": { "contextMenu": {
@@ -1601,6 +1626,11 @@
"tip": "Besoin de procéder par étapes ? Passez en mode groupé, sélectionnez les modèles souhaités puis utilisez \"Vérifier les mises à jour pour la sélection\".", "tip": "Besoin de procéder par étapes ? Passez en mode groupé, sélectionnez les modèles souhaités puis utilisez \"Vérifier les mises à jour pour la sélection\".",
"action": "Tout vérifier" "action": "Tout vérifier"
}, },
"filenameTemplateConfirm": {
"titleApply": "Appliquer le modèle de nom de fichier à la bibliothèque ?",
"titleRevert": "Restaurer les noms de fichier d'origine ?",
"revertButton": "Restaurer les noms de fichier d'origine"
},
"bulkAddTags": { "bulkAddTags": {
"title": "Ajouter des tags à plusieurs modèles", "title": "Ajouter des tags à plusieurs modèles",
"description": "Ajouter des tags à", "description": "Ajouter des tags à",
@@ -2310,6 +2340,9 @@
"autoOrganizeSuccess": "Auto-organisation terminée avec succès pour {count} {type}", "autoOrganizeSuccess": "Auto-organisation terminée avec succès pour {count} {type}",
"autoOrganizePartialSuccess": "Auto-organisation terminée avec {success} déplacés, {failures} échecs sur {total} modèles", "autoOrganizePartialSuccess": "Auto-organisation terminée avec {success} déplacés, {failures} échecs sur {total} modèles",
"autoOrganizeFailed": "Échec de l'auto-organisation : {error}", "autoOrganizeFailed": "Échec de l'auto-organisation : {error}",
"filenameTemplateSuccess": "Modèle de nom de fichier appliqué avec succès pour {count} {type}",
"filenameTemplatePartialSuccess": "Modèle de nom de fichier appliqué avec {success} renommés, {failures} échecs sur {total} modèles",
"filenameTemplateFailed": "Échec de l'application du modèle de nom de fichier : {error}",
"noModelsSelected": "Aucun modèle sélectionné" "noModelsSelected": "Aucun modèle sélectionné"
}, },
"recipes": { "recipes": {
@@ -2476,6 +2509,8 @@
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}", "mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
"downloadTemplatesUpdated": "Modèles de chemin de téléchargement mis à jour", "downloadTemplatesUpdated": "Modèles de chemin de téléchargement mis à jour",
"downloadTemplatesFailed": "Échec de la sauvegarde des modèles de chemin de téléchargement : {message}", "downloadTemplatesFailed": "Échec de la sauvegarde des modèles de chemin de téléchargement : {message}",
"filenameTemplatesUpdated": "Modèles de nom de fichier mis à jour",
"filenameTemplatesFailed": "Échec de la sauvegarde des modèles de nom de fichier : {message}",
"recipesPathUpdated": "Chemin de stockage des Recipes mis à jour", "recipesPathUpdated": "Chemin de stockage des Recipes mis à jour",
"recipesPathSaveFailed": "Échec de la mise à jour du chemin de stockage des Recipes : {message}", "recipesPathSaveFailed": "Échec de la mise à jour du chemin de stockage des Recipes : {message}",
"settingsUpdated": "Paramètres mis à jour : {setting}", "settingsUpdated": "Paramètres mis à jour : {setting}",
+35
View File
@@ -383,6 +383,7 @@
"general": "כללי", "general": "כללי",
"interface": "ממשק", "interface": "ממשק",
"library": "ספרייה", "library": "ספרייה",
"organization": "ארגון",
"modelPaths": "נתיבי מודלים" "modelPaths": "נתיבי מודלים"
}, },
"search": { "search": {
@@ -680,6 +681,22 @@
"validTemplate": "תבנית תקינה" "validTemplate": "תבנית תקינה"
} }
}, },
"filenameTemplates": {
"title": "תבניות שמות קבצים",
"help": "הגדר שמות קבצים למודלים שהורדו לפי סוג מודל. השאר ריק כדי לשמור על שמות הקבצים המקוריים בעת ההורדה; החלת תבנית ריקה משחזרת את שמות הקבצים המקוריים המתועדים של מודלים ששונה שמם בעבר. שם הקובץ המקורי תמיד נשמר במטא-נתונים של המודל.",
"availablePlaceholders": "מצייני מקום זמינים:",
"templatePlaceholder": "הזן תבנית שם קובץ (למשל, {base_model}-{model_name}-{version_name})",
"applyButton": "החל על הספרייה כעת",
"applyHelp": "משנה את שמות כל הקבצים הקיימים מסוג מודל זה בהתאם לתבנית; עם תבנית ריקה, משחזר במקום זאת את שמות הקבצים המקוריים המתועדים. אזהרה: שינוי שם משנה את הנתיב היחסי שרואים הטוענים של ComfyUI, ולכן workflows קיימים המפנים לשם הקובץ הישן עשויים לדרוש עדכון. שם הקובץ המקורי נשמר במטא-נתונים של כל מודל.",
"confirmApply": "לשנות את שמות כל הקבצים הקיימים מסוג מודל זה בהתאם לתבנית שם הקובץ? פעולה זו משנה את הנתיב היחסי שרואים הטוענים של ComfyUI. שם הקובץ המקורי נשמר במטא-נתונים של כל מודל.",
"confirmRevert": "לשחזר את שמות הקבצים המקוריים המתועדים של כל הקבצים ששונה שמם בעבר מסוג מודל זה? פעולה זו משנה את הנתיב היחסי שרואים הטוענים של ComfyUI. קבצים ללא שם קובץ מקורי מתועד ידולגו.",
"validation": {
"restoreOriginal": "תקין (תבנית ריקה משחזרת שמות קבצים מקוריים)",
"invalidChars": "זוהו תווים לא חוקיים (שם קובץ אינו יכול להכיל / \\ < > : \" | ? *)",
"invalidPlaceholder": "מציין מקום לא חוקי: {placeholder}",
"validTemplate": "תבנית תקינה"
}
},
"exampleImages": { "exampleImages": {
"downloadLocation": "מיקום הורדה", "downloadLocation": "מיקום הורדה",
"downloadLocationPlaceholder": "הזן נתיב תיקייה לתמונות דוגמה", "downloadLocationPlaceholder": "הזן נתיב תיקייה לתמונות דוגמה",
@@ -912,6 +929,14 @@
"complete": "ארגון אוטומטי הושלם", "complete": "ארגון אוטומטי הושלם",
"error": "שגיאה: {error}" "error": "שגיאה: {error}"
}, },
"filenameTemplateProgress": {
"initializing": "מאתחל החלת תבנית שם קובץ...",
"starting": "מחיל תבנית שם קובץ על {type}...",
"processing": "מעבד ({processed}/{total}) - {success} שונו שמותם, {skipped} דולגו, {failures} נכשלו",
"completed": "הושלם: {success} שונו שמותם, {skipped} דולגו, {failures} נכשלו",
"complete": "החלת תבנית שם הקובץ הושלמה",
"error": "שגיאה: {error}"
},
"enrichHfAgent": "העשרת מטא-נתונים ב-AI" "enrichHfAgent": "העשרת מטא-נתונים ב-AI"
}, },
"contextMenu": { "contextMenu": {
@@ -1601,6 +1626,11 @@
"tip": "רוצים לחלק למנות קטנות? עברו למצב בכמות גדולה, בחרו את המודלים הדרושים ואז השתמשו ב\"בדוק עדכונים לנבחרים\".", "tip": "רוצים לחלק למנות קטנות? עברו למצב בכמות גדולה, בחרו את המודלים הדרושים ואז השתמשו ב\"בדוק עדכונים לנבחרים\".",
"action": "בדוק הכל" "action": "בדוק הכל"
}, },
"filenameTemplateConfirm": {
"titleApply": "להחיל תבנית שם קובץ על הספרייה?",
"titleRevert": "לשחזר שמות קבצים מקוריים?",
"revertButton": "שחזר שמות קבצים מקוריים"
},
"bulkAddTags": { "bulkAddTags": {
"title": "הוסף תגיות למספר מודלים", "title": "הוסף תגיות למספר מודלים",
"description": "הוסף תגיות ל-", "description": "הוסף תגיות ל-",
@@ -2310,6 +2340,9 @@
"autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}", "autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}",
"autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים", "autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים",
"autoOrganizeFailed": "הארגון האוטומטי נכשל: {error}", "autoOrganizeFailed": "הארגון האוטומטי נכשל: {error}",
"filenameTemplateSuccess": "תבנית שם הקובץ הוחלה בהצלחה עבור {count} {type}",
"filenameTemplatePartialSuccess": "החלת תבנית שם הקובץ הושלמה עם {success} ששונה שמם, {failures} שנכשלו מתוך {total} מודלים",
"filenameTemplateFailed": "החלת תבנית שם הקובץ נכשלה: {error}",
"noModelsSelected": "לא נבחרו מודלים" "noModelsSelected": "לא נבחרו מודלים"
}, },
"recipes": { "recipes": {
@@ -2476,6 +2509,8 @@
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}", "mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
"downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו", "downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו",
"downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}", "downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}",
"filenameTemplatesUpdated": "תבניות שמות הקבצים עודכנו",
"filenameTemplatesFailed": "שמירת תבניות שמות הקבצים נכשלה: {message}",
"recipesPathUpdated": "נתיב אחסון המתכונים עודכן", "recipesPathUpdated": "נתיב אחסון המתכונים עודכן",
"recipesPathSaveFailed": "עדכון נתיב אחסון המתכונים נכשל: {message}", "recipesPathSaveFailed": "עדכון נתיב אחסון המתכונים נכשל: {message}",
"settingsUpdated": "הגדרות עודכנו: {setting}", "settingsUpdated": "הגדרות עודכנו: {setting}",
+35
View File
@@ -383,6 +383,7 @@
"general": "一般", "general": "一般",
"interface": "インターフェース", "interface": "インターフェース",
"library": "ライブラリ", "library": "ライブラリ",
"organization": "整理",
"modelPaths": "モデルパス" "modelPaths": "モデルパス"
}, },
"search": { "search": {
@@ -680,6 +681,22 @@
"validTemplate": "有効なテンプレート" "validTemplate": "有効なテンプレート"
} }
}, },
"filenameTemplates": {
"title": "ファイル名テンプレート",
"help": "ダウンロードしたモデルのファイル名をモデルタイプごとに設定します。空欄にするとダウンロード時は元のファイル名が保持され、空のテンプレートを適用すると以前にリネームされたモデルの記録済みの元のファイル名が復元されます。元のファイル名は常にモデルのメタデータに保持されます。",
"availablePlaceholders": "利用可能なプレースホルダー:",
"templatePlaceholder": "ファイル名テンプレートを入力(例:{base_model}-{model_name}-{version_name}",
"applyButton": "ライブラリに今すぐ適用",
"applyHelp": "このモデルタイプの既存のすべてのファイルをテンプレートに従ってリネームします。空のテンプレートの場合は、代わりに記録済みの元のファイル名を復元します。警告:リネームするとComfyUIローダーから見える相対パスが変わるため、古いファイル名を参照する既存のワークフローは更新が必要になる場合があります。元のファイル名は各モデルのメタデータに保持されます。",
"confirmApply": "このモデルタイプの既存のすべてのファイルをファイル名テンプレートに従ってリネームしますか?ComfyUIローダーから見える相対パスが変わります。元のファイル名は各モデルのメタデータに保持されます。",
"confirmRevert": "このモデルタイプの以前にリネームされたすべてのファイルについて、記録済みの元のファイル名を復元しますか?ComfyUIローダーから見える相対パスが変わります。記録済みの元のファイル名がないファイルはスキップされます。",
"validation": {
"restoreOriginal": "有効(空のテンプレートは元のファイル名を復元)",
"invalidChars": "無効な文字が検出されました(ファイル名に / \\ < > : \" | ? * は使用できません)",
"invalidPlaceholder": "無効なプレースホルダー:{placeholder}",
"validTemplate": "有効なテンプレート"
}
},
"exampleImages": { "exampleImages": {
"downloadLocation": "ダウンロード場所", "downloadLocation": "ダウンロード場所",
"downloadLocationPlaceholder": "例画像のフォルダパスを入力", "downloadLocationPlaceholder": "例画像のフォルダパスを入力",
@@ -912,6 +929,14 @@
"complete": "自動整理が完了しました", "complete": "自動整理が完了しました",
"error": "エラー:{error}" "error": "エラー:{error}"
}, },
"filenameTemplateProgress": {
"initializing": "ファイル名テンプレートの適用を初期化中...",
"starting": "{type}にファイル名テンプレートを適用中...",
"processing": "処理中({processed}/{total}- {success} リネーム、{skipped} スキップ、{failures} 失敗",
"completed": "完了:{success} リネーム、{skipped} スキップ、{failures} 失敗",
"complete": "ファイル名テンプレートの適用が完了しました",
"error": "エラー:{error}"
},
"enrichHfAgent": "メタデータをAIで補完" "enrichHfAgent": "メタデータをAIで補完"
}, },
"contextMenu": { "contextMenu": {
@@ -1601,6 +1626,11 @@
"tip": "少しずつ確認したい場合は一括モードに切り替え、必要なモデルを選んで「選択項目の更新を確認」を使ってください。", "tip": "少しずつ確認したい場合は一括モードに切り替え、必要なモデルを選んで「選択項目の更新を確認」を使ってください。",
"action": "すべて確認" "action": "すべて確認"
}, },
"filenameTemplateConfirm": {
"titleApply": "ファイル名テンプレートをライブラリに適用しますか?",
"titleRevert": "元のファイル名を復元しますか?",
"revertButton": "元のファイル名を復元"
},
"bulkAddTags": { "bulkAddTags": {
"title": "複数モデルにタグを追加", "title": "複数モデルにタグを追加",
"description": "タグを追加するモデル:", "description": "タグを追加するモデル:",
@@ -2310,6 +2340,9 @@
"autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました", "autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました",
"autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗", "autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗",
"autoOrganizeFailed": "自動整理に失敗しました:{error}", "autoOrganizeFailed": "自動整理に失敗しました:{error}",
"filenameTemplateSuccess": "{count} 件の{type}にファイル名テンプレートを正常に適用しました",
"filenameTemplatePartialSuccess": "ファイル名テンプレートを適用しました:{total} 件中 {success} 件をリネーム、{failures} 件失敗",
"filenameTemplateFailed": "ファイル名テンプレートの適用に失敗しました:{error}",
"noModelsSelected": "モデルが選択されていません" "noModelsSelected": "モデルが選択されていません"
}, },
"recipes": { "recipes": {
@@ -2476,6 +2509,8 @@
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}", "mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
"downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました", "downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました",
"downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}", "downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}",
"filenameTemplatesUpdated": "ファイル名テンプレートを更新しました",
"filenameTemplatesFailed": "ファイル名テンプレートの保存に失敗しました:{message}",
"recipesPathUpdated": "レシピ保存先を更新しました", "recipesPathUpdated": "レシピ保存先を更新しました",
"recipesPathSaveFailed": "レシピ保存先の更新に失敗しました: {message}", "recipesPathSaveFailed": "レシピ保存先の更新に失敗しました: {message}",
"settingsUpdated": "設定が更新されました:{setting}", "settingsUpdated": "設定が更新されました:{setting}",
+35
View File
@@ -383,6 +383,7 @@
"general": "일반", "general": "일반",
"interface": "인터페이스", "interface": "인터페이스",
"library": "라이브러리", "library": "라이브러리",
"organization": "정리",
"modelPaths": "모델 경로" "modelPaths": "모델 경로"
}, },
"search": { "search": {
@@ -680,6 +681,22 @@
"validTemplate": "유효한 템플릿" "validTemplate": "유효한 템플릿"
} }
}, },
"filenameTemplates": {
"title": "파일명 템플릿",
"help": "모델 유형별로 다운로드되는 모델의 파일명을 구성합니다. 비워 두면 다운로드 시 원본 파일명을 유지하고, 빈 템플릿을 적용하면 이전에 이름이 변경된 모델의 기록된 원본 파일명이 복원됩니다. 원본 파일명은 항상 모델의 메타데이터에 보존됩니다.",
"availablePlaceholders": "사용 가능한 플레이스홀더:",
"templatePlaceholder": "파일명 템플릿 입력 (예: {base_model}-{model_name}-{version_name})",
"applyButton": "지금 라이브러리에 적용",
"applyHelp": "이 모델 유형의 기존 파일을 모두 템플릿에 따라 이름 변경합니다. 빈 템플릿이면 기록된 원본 파일명을 대신 복원합니다. 경고: 이름을 변경하면 ComfyUI 로더에서 보이는 상대 경로가 바뀌므로 이전 파일명을 참조하는 기존 워크플로를 업데이트해야 할 수 있습니다. 원본 파일명은 각 모델의 메타데이터에 보존됩니다.",
"confirmApply": "이 모델 유형의 기존 파일을 모두 파일명 템플릿에 따라 이름 변경하시겠습니까? ComfyUI 로더에서 보이는 상대 경로가 변경됩니다. 원본 파일명은 각 모델의 메타데이터에 보존됩니다.",
"confirmRevert": "이 모델 유형에서 이전에 이름이 변경된 모든 파일의 기록된 원본 파일명을 복원하시겠습니까? ComfyUI 로더에서 보이는 상대 경로가 변경됩니다. 기록된 원본 파일명이 없는 파일은 건너뜁니다.",
"validation": {
"restoreOriginal": "유효함 (빈 템플릿은 원본 파일명을 복원합니다)",
"invalidChars": "잘못된 문자가 감지됨 (파일명에는 / \\ < > : \" | ? * 문자를 사용할 수 없습니다)",
"invalidPlaceholder": "잘못된 플레이스홀더: {placeholder}",
"validTemplate": "유효한 템플릿"
}
},
"exampleImages": { "exampleImages": {
"downloadLocation": "다운로드 위치", "downloadLocation": "다운로드 위치",
"downloadLocationPlaceholder": "예시 이미지 폴더 경로를 입력하세요", "downloadLocationPlaceholder": "예시 이미지 폴더 경로를 입력하세요",
@@ -912,6 +929,14 @@
"complete": "자동 정리 완료", "complete": "자동 정리 완료",
"error": "오류: {error}" "error": "오류: {error}"
}, },
"filenameTemplateProgress": {
"initializing": "파일명 템플릿 적용 초기화 중...",
"starting": "{type}에 파일명 템플릿 적용 중...",
"processing": "처리 중 ({processed}/{total}) - {success}개 이름 변경, {skipped}개 건너뜀, {failures}개 실패",
"completed": "완료: {success}개 이름 변경, {skipped}개 건너뜀, {failures}개 실패",
"complete": "파일명 템플릿 적용 완료",
"error": "오류: {error}"
},
"enrichHfAgent": "AI로 메타데이터 보강" "enrichHfAgent": "AI로 메타데이터 보강"
}, },
"contextMenu": { "contextMenu": {
@@ -1601,6 +1626,11 @@
"tip": "나눠서 진행하고 싶다면 일괄 모드로 전환해 필요한 모델만 선택한 뒤 \"선택 항목 업데이트 확인\"을 사용하세요.", "tip": "나눠서 진행하고 싶다면 일괄 모드로 전환해 필요한 모델만 선택한 뒤 \"선택 항목 업데이트 확인\"을 사용하세요.",
"action": "전체 확인" "action": "전체 확인"
}, },
"filenameTemplateConfirm": {
"titleApply": "라이브러리에 파일명 템플릿을 적용하시겠습니까?",
"titleRevert": "원본 파일명을 복원하시겠습니까?",
"revertButton": "원본 파일명 복원"
},
"bulkAddTags": { "bulkAddTags": {
"title": "여러 모델에 태그 추가", "title": "여러 모델에 태그 추가",
"description": "다음에 태그를 추가합니다:", "description": "다음에 태그를 추가합니다:",
@@ -2310,6 +2340,9 @@
"autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다", "autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다",
"autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패", "autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패",
"autoOrganizeFailed": "자동 정리 실패: {error}", "autoOrganizeFailed": "자동 정리 실패: {error}",
"filenameTemplateSuccess": "{count}개의 {type}에 파일명 템플릿이 성공적으로 적용되었습니다",
"filenameTemplatePartialSuccess": "파일명 템플릿 적용 완료: 전체 {total}개 중 {success}개 이름 변경, {failures}개 실패",
"filenameTemplateFailed": "파일명 템플릿 적용 실패: {error}",
"noModelsSelected": "선택된 모델이 없습니다" "noModelsSelected": "선택된 모델이 없습니다"
}, },
"recipes": { "recipes": {
@@ -2476,6 +2509,8 @@
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}", "mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
"downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다", "downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다",
"downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}", "downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}",
"filenameTemplatesUpdated": "파일명 템플릿이 업데이트되었습니다",
"filenameTemplatesFailed": "파일명 템플릿 저장 실패: {message}",
"recipesPathUpdated": "레시피 저장 경로가 업데이트되었습니다", "recipesPathUpdated": "레시피 저장 경로가 업데이트되었습니다",
"recipesPathSaveFailed": "레시피 저장 경로 업데이트 실패: {message}", "recipesPathSaveFailed": "레시피 저장 경로 업데이트 실패: {message}",
"settingsUpdated": "설정 업데이트됨: {setting}", "settingsUpdated": "설정 업데이트됨: {setting}",
+35
View File
@@ -383,6 +383,7 @@
"general": "Общее", "general": "Общее",
"interface": "Интерфейс", "interface": "Интерфейс",
"library": "Библиотека", "library": "Библиотека",
"organization": "Организация",
"modelPaths": "Пути к моделям" "modelPaths": "Пути к моделям"
}, },
"search": { "search": {
@@ -680,6 +681,22 @@
"validTemplate": "Действительный шаблон" "validTemplate": "Действительный шаблон"
} }
}, },
"filenameTemplates": {
"title": "Шаблоны имён файлов",
"help": "Настройте имена файлов загружаемых моделей для каждого типа моделей. Оставьте пустым, чтобы сохранять исходные имена файлов при загрузке; применение пустого шаблона восстанавливает записанные исходные имена файлов ранее переименованных моделей. Исходное имя файла всегда сохраняется в метаданных модели.",
"availablePlaceholders": "Доступные заполнители:",
"templatePlaceholder": "Введите шаблон имени файла (например, {base_model}-{model_name}-{version_name})",
"applyButton": "Применить к библиотеке сейчас",
"applyHelp": "Переименовывает все существующие файлы этого типа моделей согласно шаблону; при пустом шаблоне вместо этого восстанавливает записанные исходные имена файлов. Предупреждение: переименование меняет относительный путь, который видят загрузчики ComfyUI, поэтому существующие workflow, ссылающиеся на старое имя файла, может потребоваться обновить. Исходное имя файла сохраняется в метаданных каждой модели.",
"confirmApply": "Переименовать все существующие файлы этого типа моделей согласно шаблону имён файлов? Это меняет относительный путь, который видят загрузчики ComfyUI. Исходное имя файла сохраняется в метаданных каждой модели.",
"confirmRevert": "Восстановить записанные исходные имена файлов всех ранее переименованных файлов этого типа моделей? Это меняет относительный путь, который видят загрузчики ComfyUI. Файлы без записанного исходного имени файла пропускаются.",
"validation": {
"restoreOriginal": "Действительный (пустой шаблон восстанавливает исходные имена файлов)",
"invalidChars": "Обнаружены недопустимые символы (имя файла не может содержать / \\ < > : \" | ? *)",
"invalidPlaceholder": "Недопустимый заполнитель: {placeholder}",
"validTemplate": "Действительный шаблон"
}
},
"exampleImages": { "exampleImages": {
"downloadLocation": "Место загрузки", "downloadLocation": "Место загрузки",
"downloadLocationPlaceholder": "Введите путь к папке для примеров изображений", "downloadLocationPlaceholder": "Введите путь к папке для примеров изображений",
@@ -912,6 +929,14 @@
"complete": "Автоматическая организация завершена", "complete": "Автоматическая организация завершена",
"error": "Ошибка: {error}" "error": "Ошибка: {error}"
}, },
"filenameTemplateProgress": {
"initializing": "Инициализация применения шаблона имён файлов...",
"starting": "Применение шаблона имён файлов к {type}...",
"processing": "Обработка ({processed}/{total}) — {success} переименовано, {skipped} пропущено, {failures} не удалось",
"completed": "Завершено: {success} переименовано, {skipped} пропущено, {failures} не удалось",
"complete": "Применение шаблона имён файлов завершено",
"error": "Ошибка: {error}"
},
"enrichHfAgent": "Обогатить метаданные с помощью ИИ" "enrichHfAgent": "Обогатить метаданные с помощью ИИ"
}, },
"contextMenu": { "contextMenu": {
@@ -1601,6 +1626,11 @@
"tip": "Хотите проверять по частям? Переключитесь в массовый режим, выберите нужные модели и используйте \"Проверить обновления для выбранных\".", "tip": "Хотите проверять по частям? Переключитесь в массовый режим, выберите нужные модели и используйте \"Проверить обновления для выбранных\".",
"action": "Проверить всё" "action": "Проверить всё"
}, },
"filenameTemplateConfirm": {
"titleApply": "Применить шаблон имён файлов к библиотеке?",
"titleRevert": "Восстановить исходные имена файлов?",
"revertButton": "Восстановить исходные имена файлов"
},
"bulkAddTags": { "bulkAddTags": {
"title": "Добавить теги к нескольким моделям", "title": "Добавить теги к нескольким моделям",
"description": "Добавить теги к", "description": "Добавить теги к",
@@ -2310,6 +2340,9 @@
"autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}", "autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}",
"autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей", "autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей",
"autoOrganizeFailed": "Ошибка автоматической организации: {error}", "autoOrganizeFailed": "Ошибка автоматической организации: {error}",
"filenameTemplateSuccess": "Шаблон имён файлов успешно применён для {count} {type}",
"filenameTemplatePartialSuccess": "Шаблон имён файлов применён: переименовано {success}, не удалось {failures} из {total} моделей",
"filenameTemplateFailed": "Не удалось применить шаблон имён файлов: {error}",
"noModelsSelected": "Модели не выбраны" "noModelsSelected": "Модели не выбраны"
}, },
"recipes": { "recipes": {
@@ -2476,6 +2509,8 @@
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}", "mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
"downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены", "downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены",
"downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}", "downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}",
"filenameTemplatesUpdated": "Шаблоны имён файлов обновлены",
"filenameTemplatesFailed": "Не удалось сохранить шаблоны имён файлов: {message}",
"recipesPathUpdated": "Путь хранения рецептов обновлён", "recipesPathUpdated": "Путь хранения рецептов обновлён",
"recipesPathSaveFailed": "Не удалось обновить путь хранения рецептов: {message}", "recipesPathSaveFailed": "Не удалось обновить путь хранения рецептов: {message}",
"settingsUpdated": "Настройки обновлены: {setting}", "settingsUpdated": "Настройки обновлены: {setting}",
+35
View File
@@ -383,6 +383,7 @@
"general": "通用", "general": "通用",
"interface": "界面", "interface": "界面",
"library": "库", "library": "库",
"organization": "整理",
"modelPaths": "模型路径" "modelPaths": "模型路径"
}, },
"search": { "search": {
@@ -680,6 +681,22 @@
"validTemplate": "有效模板" "validTemplate": "有效模板"
} }
}, },
"filenameTemplates": {
"title": "文件名模板",
"help": "按模型类型配置下载模型的文件名。留空则下载时保留原始文件名;应用空模板会恢复此前被重命名模型所记录的原始文件名。原始文件名始终保留在模型的元数据中。",
"availablePlaceholders": "可用占位符:",
"templatePlaceholder": "输入文件名模板(如:{base_model}-{model_name}-{version_name}",
"applyButton": "立即应用到库",
"applyHelp": "根据模板重命名此模型类型的所有现有文件;模板为空时则恢复已记录的原始文件名。警告:重命名会改变 ComfyUI 加载器所见的相对路径,因此引用旧文件名的现有工作流可能需要更新。原始文件名保留在每个模型的元数据中。",
"confirmApply": "要根据文件名模板重命名此模型类型的所有现有文件吗?这会改变 ComfyUI 加载器所见的相对路径。原始文件名保留在每个模型的元数据中。",
"confirmRevert": "要恢复此模型类型中所有此前被重命名文件所记录的原始文件名吗?这会改变 ComfyUI 加载器所见的相对路径。未记录原始文件名的文件将被跳过。",
"validation": {
"restoreOriginal": "有效(空模板将恢复原始文件名)",
"invalidChars": "检测到无效字符(文件名不能包含 / \\ < > : \" | ? *",
"invalidPlaceholder": "无效占位符:{placeholder}",
"validTemplate": "有效模板"
}
},
"exampleImages": { "exampleImages": {
"downloadLocation": "下载位置", "downloadLocation": "下载位置",
"downloadLocationPlaceholder": "输入示例图片文件夹路径", "downloadLocationPlaceholder": "输入示例图片文件夹路径",
@@ -912,6 +929,14 @@
"complete": "自动整理已完成", "complete": "自动整理已完成",
"error": "错误:{error}" "error": "错误:{error}"
}, },
"filenameTemplateProgress": {
"initializing": "正在初始化应用文件名模板...",
"starting": "正在为 {type} 应用文件名模板...",
"processing": "处理中({processed}/{total}- 已重命名 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
"completed": "完成:已重命名 {success} 个,跳过 {skipped} 个,失败 {failures} 个",
"complete": "文件名模板应用完成",
"error": "错误:{error}"
},
"enrichHfAgent": "AI 元数据增强" "enrichHfAgent": "AI 元数据增强"
}, },
"contextMenu": { "contextMenu": {
@@ -1601,6 +1626,11 @@
"tip": "想分批进行?切换到批量模式,选中需要的模型,然后使用“检查所选更新”。", "tip": "想分批进行?切换到批量模式,选中需要的模型,然后使用“检查所选更新”。",
"action": "检查全部" "action": "检查全部"
}, },
"filenameTemplateConfirm": {
"titleApply": "将文件名模板应用到库?",
"titleRevert": "恢复原始文件名?",
"revertButton": "恢复原始文件名"
},
"bulkAddTags": { "bulkAddTags": {
"title": "批量添加标签", "title": "批量添加标签",
"description": "为多个模型添加标签", "description": "为多个模型添加标签",
@@ -2310,6 +2340,9 @@
"autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}", "autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}",
"autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型", "autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型",
"autoOrganizeFailed": "自动整理失败:{error}", "autoOrganizeFailed": "自动整理失败:{error}",
"filenameTemplateSuccess": "文件名模板已成功应用,共 {count} 个 {type}",
"filenameTemplatePartialSuccess": "文件名模板应用完成:已重命名 {success} 个,{failures} 个失败,共 {total} 个模型",
"filenameTemplateFailed": "应用文件名模板失败:{error}",
"noModelsSelected": "未选中模型" "noModelsSelected": "未选中模型"
}, },
"recipes": { "recipes": {
@@ -2476,6 +2509,8 @@
"mappingSaveFailed": "保存基础模型映射失败:{message}", "mappingSaveFailed": "保存基础模型映射失败:{message}",
"downloadTemplatesUpdated": "下载路径模板已更新", "downloadTemplatesUpdated": "下载路径模板已更新",
"downloadTemplatesFailed": "保存下载路径模板失败:{message}", "downloadTemplatesFailed": "保存下载路径模板失败:{message}",
"filenameTemplatesUpdated": "文件名模板已更新",
"filenameTemplatesFailed": "保存文件名模板失败:{message}",
"recipesPathUpdated": "配方存储路径已更新", "recipesPathUpdated": "配方存储路径已更新",
"recipesPathSaveFailed": "更新配方存储路径失败:{message}", "recipesPathSaveFailed": "更新配方存储路径失败:{message}",
"settingsUpdated": "设置已更新:{setting}", "settingsUpdated": "设置已更新:{setting}",
+35
View File
@@ -383,6 +383,7 @@
"general": "通用", "general": "通用",
"interface": "介面", "interface": "介面",
"library": "模型庫", "library": "模型庫",
"organization": "整理",
"modelPaths": "模型路徑" "modelPaths": "模型路徑"
}, },
"search": { "search": {
@@ -680,6 +681,22 @@
"validTemplate": "範本有效" "validTemplate": "範本有效"
} }
}, },
"filenameTemplates": {
"title": "檔案名稱範本",
"help": "依模型類型設定已下載模型的檔案名稱。留空則下載時保留原始檔案名稱;套用空範本會還原先前已重新命名模型所記錄的原始檔案名稱。原始檔案名稱一律會保存在模型的中繼資料中。",
"availablePlaceholders": "可用佔位符:",
"templatePlaceholder": "輸入檔案名稱範本(例如:{base_model}-{model_name}-{version_name}",
"applyButton": "立即套用至模型庫",
"applyHelp": "依範本重新命名此模型類型的所有現有檔案;若範本為空,則改為還原已記錄的原始檔案名稱。警告:重新命名會變更 ComfyUI 載入器所見的相對路徑,因此參照舊檔案名稱的現有工作流可能需要更新。原始檔案名稱會保存在每個模型的中繼資料中。",
"confirmApply": "要依檔案名稱範本重新命名此模型類型的所有現有檔案嗎?這會變更 ComfyUI 載入器所見的相對路徑。原始檔案名稱會保存在每個模型的中繼資料中。",
"confirmRevert": "要將此模型類型所有先前已重新命名的檔案還原為已記錄的原始檔案名稱嗎?這會變更 ComfyUI 載入器所見的相對路徑。沒有記錄原始檔案名稱的檔案將被略過。",
"validation": {
"restoreOriginal": "有效(空範本會還原原始檔案名稱)",
"invalidChars": "偵測到無效字元(檔案名稱不能包含 / \\ < > : \" | ? *",
"invalidPlaceholder": "無效佔位符:{placeholder}",
"validTemplate": "範本有效"
}
},
"exampleImages": { "exampleImages": {
"downloadLocation": "下載位置", "downloadLocation": "下載位置",
"downloadLocationPlaceholder": "輸入範例圖片的資料夾路徑", "downloadLocationPlaceholder": "輸入範例圖片的資料夾路徑",
@@ -912,6 +929,14 @@
"complete": "自動整理完成", "complete": "自動整理完成",
"error": "錯誤:{error}" "error": "錯誤:{error}"
}, },
"filenameTemplateProgress": {
"initializing": "正在初始化檔案名稱範本套用...",
"starting": "正在將檔案名稱範本套用至 {type}...",
"processing": "處理中({processed}/{total}- 已重新命名 {success},已略過 {skipped},失敗 {failures}",
"completed": "完成:已重新命名 {success},已略過 {skipped},失敗 {failures}",
"complete": "檔案名稱範本套用完成",
"error": "錯誤:{error}"
},
"enrichHfAgent": "AI 中繼資料增強" "enrichHfAgent": "AI 中繼資料增強"
}, },
"contextMenu": { "contextMenu": {
@@ -1601,6 +1626,11 @@
"tip": "想分批處理?切換到批次模式,選擇需要的模型,然後使用「檢查所選更新」。", "tip": "想分批處理?切換到批次模式,選擇需要的模型,然後使用「檢查所選更新」。",
"action": "全部檢查" "action": "全部檢查"
}, },
"filenameTemplateConfirm": {
"titleApply": "要將檔案名稱範本套用至模型庫嗎?",
"titleRevert": "要還原原始檔案名稱嗎?",
"revertButton": "還原原始檔案名稱"
},
"bulkAddTags": { "bulkAddTags": {
"title": "新增標籤到多個模型", "title": "新增標籤到多個模型",
"description": "新增標籤到", "description": "新增標籤到",
@@ -2310,6 +2340,9 @@
"autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理", "autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理",
"autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型", "autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型",
"autoOrganizeFailed": "自動整理失敗:{error}", "autoOrganizeFailed": "自動整理失敗:{error}",
"filenameTemplateSuccess": "已成功為 {count} 個 {type} 套用檔案名稱範本",
"filenameTemplatePartialSuccess": "檔案名稱範本套用完成:已重新命名 {success} 個,{failures} 個失敗,共 {total} 個模型",
"filenameTemplateFailed": "套用檔案名稱範本失敗:{error}",
"noModelsSelected": "未選擇任何模型" "noModelsSelected": "未選擇任何模型"
}, },
"recipes": { "recipes": {
@@ -2476,6 +2509,8 @@
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}", "mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
"downloadTemplatesUpdated": "下載路徑範本已更新", "downloadTemplatesUpdated": "下載路徑範本已更新",
"downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}", "downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}",
"filenameTemplatesUpdated": "檔案名稱範本已更新",
"filenameTemplatesFailed": "儲存檔案名稱範本失敗:{message}",
"recipesPathUpdated": "配方儲存路徑已更新", "recipesPathUpdated": "配方儲存路徑已更新",
"recipesPathSaveFailed": "更新配方儲存路徑失敗:{message}", "recipesPathSaveFailed": "更新配方儲存路徑失敗:{message}",
"settingsUpdated": "設定已更新:{setting}", "settingsUpdated": "設定已更新:{setting}",
+18
View File
@@ -24,9 +24,11 @@ from ..services.use_cases import (
AutoOrganizeUseCase, AutoOrganizeUseCase,
BulkMetadataRefreshUseCase, BulkMetadataRefreshUseCase,
DownloadModelUseCase, DownloadModelUseCase,
FilenameTemplateUseCase,
) )
from ..services.websocket_progress_callback import ( from ..services.websocket_progress_callback import (
WebSocketBroadcastCallback, WebSocketBroadcastCallback,
WebSocketFilenameTemplateProgressCallback,
WebSocketProgressCallback, WebSocketProgressCallback,
) )
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
@@ -37,6 +39,7 @@ from .handlers.model_handlers import (
ModelAutoOrganizeHandler, ModelAutoOrganizeHandler,
ModelCivitaiHandler, ModelCivitaiHandler,
ModelDownloadHandler, ModelDownloadHandler,
ModelFilenameTemplateHandler,
ModelHandlerSet, ModelHandlerSet,
ModelListingHandler, ModelListingHandler,
ModelManagementHandler, ModelManagementHandler,
@@ -83,6 +86,9 @@ class BaseModelRoutes(ABC):
self.model_lifecycle_service: ModelLifecycleService | None = None self.model_lifecycle_service: ModelLifecycleService | None = None
self.websocket_progress_callback = WebSocketProgressCallback() self.websocket_progress_callback = WebSocketProgressCallback()
self.metadata_progress_callback = WebSocketBroadcastCallback() self.metadata_progress_callback = WebSocketBroadcastCallback()
self.filename_template_progress_callback = (
WebSocketFilenameTemplateProgressCallback()
)
self._handler_set: ModelHandlerSet | None = None self._handler_set: ModelHandlerSet | None = None
self._handler_mapping: Dict[str, Callable[[web.Request], Awaitable[web.Response]]] | None = None self._handler_mapping: Dict[str, Callable[[web.Request], Awaitable[web.Response]]] | None = None
@@ -202,6 +208,17 @@ class BaseModelRoutes(ABC):
ws_manager=self._ws_manager, ws_manager=self._ws_manager,
logger=logger, logger=logger,
) )
filename_template_use_case = FilenameTemplateUseCase(
scanner=service.scanner,
lifecycle_service=self._ensure_lifecycle_service(),
lock_provider=self._ws_manager,
model_type=service.model_type,
)
filename_template = ModelFilenameTemplateHandler(
use_case=filename_template_use_case,
progress_callback=self.filename_template_progress_callback,
logger=logger,
)
updates = ModelUpdateHandler( updates = ModelUpdateHandler(
service=service, service=service,
update_service=update_service, update_service=update_service,
@@ -218,6 +235,7 @@ class BaseModelRoutes(ABC):
civitai=civitai, civitai=civitai,
move=move, move=move,
auto_organize=auto_organize, auto_organize=auto_organize,
filename_template=filename_template,
updates=updates, updates=updates,
) )
+72 -1
View File
@@ -37,10 +37,14 @@ from ...services.use_cases import (
DownloadModelEarlyAccessError, DownloadModelEarlyAccessError,
DownloadModelUseCase, DownloadModelUseCase,
DownloadModelValidationError, DownloadModelValidationError,
FilenameTemplateUseCase,
MetadataRefreshProgressReporter, MetadataRefreshProgressReporter,
) )
from ...services.websocket_manager import WebSocketManager from ...services.websocket_manager import WebSocketManager
from ...services.websocket_progress_callback import WebSocketProgressCallback from ...services.websocket_progress_callback import (
WebSocketFilenameTemplateProgressCallback,
WebSocketProgressCallback,
)
from ...services.download_queue_service import DownloadQueueService from ...services.download_queue_service import DownloadQueueService
from ...services.errors import RateLimitError, ResourceNotFoundError from ...services.errors import RateLimitError, ResourceNotFoundError
from ...utils.civitai_utils import resolve_license_payload from ...utils.civitai_utils import resolve_license_payload
@@ -2692,6 +2696,71 @@ class ModelAutoOrganizeHandler:
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
class ModelFilenameTemplateHandler:
"""Apply the configured filename template to existing library models."""
def __init__(
self,
*,
use_case: FilenameTemplateUseCase,
progress_callback: WebSocketFilenameTemplateProgressCallback,
logger: logging.Logger,
) -> None:
self._use_case = use_case
self._progress_callback = progress_callback
self._logger = logger
async def apply_filename_template(self, request: web.Request) -> web.Response:
try:
file_paths = None
if request.method == "POST":
try:
data = await request.json()
file_paths = data.get("file_paths")
except Exception: # pragma: no cover - permissive path
pass
else:
# GET variant (browser extension is GET-only): comma-separated
# file_paths query parameter.
raw_file_paths = request.query.get("file_paths")
if raw_file_paths:
file_paths = [
path.strip()
for path in raw_file_paths.split(",")
if path.strip()
]
result = await self._use_case.execute(
file_paths=file_paths,
progress_callback=self._progress_callback,
)
_broadcast_models_changed()
return web.json_response(result.to_dict())
except AutoOrganizeInProgressError:
return web.json_response(
{
"success": False,
"error": "Another library operation is already running. Please wait for it to complete.",
},
status=409,
)
except Exception as exc:
self._logger.error(
"Error in apply_filename_template: %s", exc, exc_info=True
)
try:
await self._progress_callback.on_progress(
{
"type": "filename_template_progress",
"status": "error",
"error": str(exc),
}
)
except Exception: # pragma: no cover - defensive reporting
pass
return web.json_response({"success": False, "error": str(exc)}, status=500)
class ModelUpdateHandler: class ModelUpdateHandler:
"""Handle update tracking requests.""" """Handle update tracking requests."""
@@ -3459,6 +3528,7 @@ class ModelHandlerSet:
civitai: ModelCivitaiHandler civitai: ModelCivitaiHandler
move: ModelMoveHandler move: ModelMoveHandler
auto_organize: ModelAutoOrganizeHandler auto_organize: ModelAutoOrganizeHandler
filename_template: ModelFilenameTemplateHandler
updates: ModelUpdateHandler updates: ModelUpdateHandler
def to_route_mapping( def to_route_mapping(
@@ -3523,6 +3593,7 @@ class ModelHandlerSet:
"rename_folder": self.move.rename_folder, "rename_folder": self.move.rename_folder,
"auto_organize_models": self.auto_organize.auto_organize_models, "auto_organize_models": self.auto_organize.auto_organize_models,
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress, "get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
"apply_filename_template": self.filename_template.apply_filename_template,
"get_model_notes": self.query.get_model_notes, "get_model_notes": self.query.get_model_notes,
"get_model_preview_url": self.query.get_model_preview_url, "get_model_preview_url": self.query.get_model_preview_url,
"get_model_civitai_url": self.query.get_model_civitai_url, "get_model_civitai_url": self.query.get_model_civitai_url,
+6
View File
@@ -48,6 +48,12 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition( RouteDefinition(
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress" "GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
), ),
RouteDefinition(
"GET", "/api/lm/{prefix}/apply-filename-template", "apply_filename_template"
),
RouteDefinition(
"POST", "/api/lm/{prefix}/apply-filename-template", "apply_filename_template"
),
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"), RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"), RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"),
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"), RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
+89 -1
View File
@@ -33,7 +33,7 @@ from ..utils.constants import (
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
from ..utils.file_utils import calculate_sha256, calculate_autov3 from ..utils.file_utils import calculate_sha256, calculate_autov3
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
from ..utils.utils import sanitize_folder_name from ..utils.utils import calculate_filename_for_model, sanitize_folder_name
from ..utils.exif_utils import ExifUtils from ..utils.exif_utils import ExifUtils
from ..utils.metadata_manager import MetadataManager from ..utils.metadata_manager import MetadataManager
from .service_registry import ServiceRegistry from .service_registry import ServiceRegistry
@@ -45,6 +45,7 @@ from .errors import RateLimitError
from .aria2_downloader import Aria2Error, get_aria2_downloader from .aria2_downloader import Aria2Error, get_aria2_downloader
from .aria2_transfer_state import Aria2TransferStateStore from .aria2_transfer_state import Aria2TransferStateStore
from .download_queue_service import DownloadQueueService from .download_queue_service import DownloadQueueService
from .model_lifecycle_service import ModelLifecycleService, load_local_metadata
# Download to temporary file first # Download to temporary file first
import tempfile import tempfile
@@ -2746,6 +2747,7 @@ class DownloadManager:
else None else None
) )
downloaded_metadata: List[Dict[str, Any]] = []
for index, entry in enumerate(metadata_entries): for index, entry in enumerate(metadata_entries):
file_path_for_adjust = getattr( file_path_for_adjust = getattr(
entry, "file_path", actual_file_paths[index] entry, "file_path", actual_file_paths[index]
@@ -2788,6 +2790,15 @@ class DownloadManager:
if scanner is not None: if scanner is not None:
await scanner.add_model_to_cache(metadata_dict, relative_path) await scanner.add_model_to_cache(metadata_dict, relative_path)
downloaded_metadata.append(metadata_dict)
await self._apply_download_filename_template(
scanner=scanner,
model_type=model_type,
downloaded_metadata=downloaded_metadata,
download_id=download_id,
)
if transfer_backend == "aria2" and download_id: if transfer_backend == "aria2" and download_id:
await self._aria2_state_store.remove(download_id) await self._aria2_state_store.remove(download_id)
@@ -2827,6 +2838,83 @@ class DownloadManager:
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
async def _apply_download_filename_template(
self,
*,
scanner,
model_type: str,
downloaded_metadata: List[Dict[str, Any]],
download_id: Optional[str],
) -> None:
"""Rename freshly downloaded models according to the filename template.
Best-effort post-download step: any failure (including name conflicts)
is logged and skipped so a successful download is never turned into a
failure by a rename problem.
"""
try:
if scanner is None or not downloaded_metadata:
return
template = get_settings_manager().get_download_filename_template(
model_type
)
if not template:
return
lifecycle_service = ModelLifecycleService(
scanner=scanner,
metadata_manager=MetadataManager,
metadata_loader=load_local_metadata,
recipe_scanner_factory=ServiceRegistry.get_recipe_scanner,
)
for metadata_dict in downloaded_metadata:
file_path = metadata_dict.get("file_path")
if not isinstance(file_path, str) or not file_path:
continue
new_stem = calculate_filename_for_model(metadata_dict, model_type)
if not new_stem:
continue
current_stem = os.path.splitext(os.path.basename(file_path))[0]
if new_stem == current_stem or os.path.normcase(
new_stem
) == os.path.normcase(current_stem):
continue
try:
result = await lifecycle_service.rename_model(
file_path=file_path, new_file_name=new_stem
)
except ValueError as exc:
logger.warning(
"Keeping original filename for %s: %s", file_path, exc
)
continue
new_file_path = result.get("new_file_path")
if download_id and isinstance(new_file_path, str):
info = self._active_downloads.get(download_id)
if info is None:
continue
if info.get("file_path") == file_path:
info["file_path"] = new_file_path
extracted = info.get("extracted_paths")
if isinstance(extracted, list):
info["extracted_paths"] = [
new_file_path if path == file_path else path
for path in extracted
]
except Exception as exc: # Rename phase must never fail the download
logger.warning(
"Filename template rename failed for %s download: %s",
model_type,
exc,
exc_info=True,
)
def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]: def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]:
if model_type in ("checkpoint", "other"): if model_type in ("checkpoint", "other"):
return { return {
+11 -5
View File
@@ -714,12 +714,18 @@ class LoraService(BaseModelService):
), ),
) )
# Return minimal data needed for cycling # Return minimal data needed for cycling. usage_tips is only included
return [ # when non-empty so widget consumers (recommended strength range cues)
{ # can build their lookup without inflating the payload.
result = []
for lora in available_loras:
entry = {
"file_name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"], "file_name": f"{lora['folder']}/{lora['file_name']}" if lora.get("folder") else lora["file_name"],
"model_name": lora.get("model_name", lora["file_name"]), "model_name": lora.get("model_name", lora["file_name"]),
"folder": lora.get("folder", ""), "folder": lora.get("folder", ""),
} }
for lora in available_loras usage_tips = lora.get("usage_tips")
] if usage_tips:
entry["usage_tips"] = usage_tips
result.append(entry)
return result
+13 -1
View File
@@ -43,10 +43,22 @@ class AutoOrganizeResult:
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""Convert result to dictionary""" """Convert result to dictionary"""
if self.operation_type == 'filename_template':
message = (
f'Filename template applied: {self.success_count} renamed, '
f'{self.skipped_count} skipped, {self.failure_count} failed '
f'out of {self.total} total'
)
else:
message = (
f'Auto-organize {self.operation_type} completed: '
f'{self.success_count} moved, {self.skipped_count} skipped, '
f'{self.failure_count} failed out of {self.total} total'
)
result: Dict[str, Any] = { result: Dict[str, Any] = {
'success': self.status != 'error', 'success': self.status != 'error',
'status': self.status, 'status': self.status,
'message': f'Auto-organize {self.operation_type} completed: {self.success_count} moved, {self.skipped_count} skipped, {self.failure_count} failed out of {self.total} total', 'message': message,
'summary': { 'summary': {
'total': self.total, 'total': self.total,
'success': self.success_count, 'success': self.success_count,
+24
View File
@@ -2,6 +2,7 @@
from __future__ import annotations from __future__ import annotations
import json
import logging import logging
import os import os
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
@@ -17,6 +18,26 @@ if TYPE_CHECKING:
from ..services.model_update_service import ModelUpdateService from ..services.model_update_service import ModelUpdateService
async def load_local_metadata(metadata_path: str) -> Dict[str, Any]:
"""Load a metadata sidecar JSON, returning an empty dict when missing.
Thin equivalent of ``MetadataSyncService.load_local_metadata`` for callers
(download manager, use cases) that do not hold a sync-service instance.
"""
if not os.path.exists(metadata_path):
return {}
try:
with open(metadata_path, "r", encoding="utf-8") as handle:
payload = json.load(handle)
except Exception as exc:
logger.warning("Failed to load metadata from %s: %s", metadata_path, exc)
return {}
return payload if isinstance(payload, dict) else {}
async def delete_model_artifacts( async def delete_model_artifacts(
target_dir: str, file_name: str, main_extension: str | None = None target_dir: str, file_name: str, main_extension: str | None = None
) -> List[str]: ) -> List[str]:
@@ -404,6 +425,9 @@ class ModelLifecycleService:
if metadata and new_metadata_path: if metadata and new_metadata_path:
metadata["file_name"] = new_file_name metadata["file_name"] = new_file_name
metadata["file_path"] = new_file_path metadata["file_path"] = new_file_path
# Preserve the pre-rename stem so the original download filename
# stays recoverable after template-driven renames.
metadata.setdefault("original_file_name", old_file_name)
if metadata.get("preview_url"): if metadata.get("preview_url"):
old_preview = str(metadata["preview_url"]) old_preview = str(metadata["preview_url"])
+45
View File
@@ -98,6 +98,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
"recipes_path": "", "recipes_path": "",
"base_model_path_mappings": {}, "base_model_path_mappings": {},
"download_path_templates": {}, "download_path_templates": {},
"download_filename_templates": {},
"folder_paths": {}, "folder_paths": {},
"extra_folder_paths": {}, "extra_folder_paths": {},
"example_images_path": "", "example_images_path": "",
@@ -1276,6 +1277,7 @@ class SettingsManager:
defaults = copy.deepcopy(DEFAULT_SETTINGS) defaults = copy.deepcopy(DEFAULT_SETTINGS)
defaults["base_model_path_mappings"] = {} defaults["base_model_path_mappings"] = {}
defaults["download_path_templates"] = {} defaults["download_path_templates"] = {}
defaults["download_filename_templates"] = {}
defaults["priority_tags"] = DEFAULT_PRIORITY_TAG_CONFIG.copy() defaults["priority_tags"] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
defaults.setdefault("folder_paths", {}) defaults.setdefault("folder_paths", {})
defaults.setdefault("extra_folder_paths", {}) defaults.setdefault("extra_folder_paths", {})
@@ -2424,6 +2426,49 @@ class SettingsManager:
model_type, DEFAULT_DOWNLOAD_PATH_TEMPLATES.get(model_type, "") model_type, DEFAULT_DOWNLOAD_PATH_TEMPLATES.get(model_type, "")
) )
def get_download_filename_template(self, model_type: str) -> str:
"""Get the download filename template for a specific model type.
Args:
model_type: The type of model ('lora', 'checkpoint', 'embedding',
'other')
Returns:
Template string for the model type. Empty string (the default for
every model type) means downloaded files keep their original
filename.
"""
templates = self.settings.get("download_filename_templates", {})
# Handle edge case where templates might be stored as JSON string
if isinstance(templates, str):
try:
parsed_templates = json.loads(templates)
if isinstance(parsed_templates, dict):
self.settings["download_filename_templates"] = parsed_templates
self._save_settings()
templates = parsed_templates
logger.info(
"Successfully parsed download_filename_templates from JSON string"
)
else:
raise ValueError("Parsed JSON is not a dictionary")
except (json.JSONDecodeError, ValueError) as e:
logger.warning(
f"Failed to parse download_filename_templates JSON string: {e}. Resetting to empty templates."
)
templates = {}
self.settings["download_filename_templates"] = templates
self._save_settings()
if not isinstance(templates, dict):
templates = {}
self.settings["download_filename_templates"] = templates
self._save_settings()
template = templates.get(model_type, "")
return template if isinstance(template, str) else ""
_SETTINGS_MANAGER: Optional["SettingsManager"] = None _SETTINGS_MANAGER: Optional["SettingsManager"] = None
_SETTINGS_MANAGER_LOCK = Lock() _SETTINGS_MANAGER_LOCK = Lock()
+2
View File
@@ -20,6 +20,7 @@ from .example_images import (
ImportExampleImagesUseCase, ImportExampleImagesUseCase,
ImportExampleImagesValidationError, ImportExampleImagesValidationError,
) )
from .filename_template_use_case import FilenameTemplateUseCase
__all__ = [ __all__ = [
"AutoOrganizeInProgressError", "AutoOrganizeInProgressError",
@@ -34,4 +35,5 @@ __all__ = [
"DownloadExampleImagesUseCase", "DownloadExampleImagesUseCase",
"ImportExampleImagesUseCase", "ImportExampleImagesUseCase",
"ImportExampleImagesValidationError", "ImportExampleImagesValidationError",
"FilenameTemplateUseCase",
] ]
@@ -0,0 +1,245 @@
"""Filename template use case: bulk-rename library models per the configured template.
An empty template reverts previously renamed models to the original filename
recorded in their ``.metadata.json`` sidecar (``original_file_name``).
"""
from __future__ import annotations
import asyncio
import logging
import os
from typing import Any, Awaitable, Callable, Dict, List, Optional, Sequence
from ...utils.constants import AUTO_ORGANIZE_BATCH_SIZE
from ...utils.utils import calculate_filename_for_model
from ..model_file_service import AutoOrganizeResult, ProgressCallback
from ..model_lifecycle_service import ModelLifecycleService, load_local_metadata
from ..settings_manager import get_settings_manager
from .auto_organize_use_case import (
AutoOrganizeInProgressError,
AutoOrganizeLockProvider,
)
logger = logging.getLogger(__name__)
_PROGRESS_TYPE = "filename_template_progress"
class FilenameTemplateUseCase:
"""Apply the download filename template to existing library models.
An empty template restores the recorded original filename instead of
rendering a template. Shares the auto-organize lock (and its in-progress
error) so a bulk rename never runs concurrently with an auto-organize
operation.
"""
def __init__(
self,
*,
scanner,
lifecycle_service: ModelLifecycleService,
lock_provider: AutoOrganizeLockProvider,
model_type: str,
metadata_loader: Callable[[str], Awaitable[Dict[str, Any]]] = load_local_metadata,
) -> None:
self._scanner = scanner
self._lifecycle_service = lifecycle_service
self._lock_provider = lock_provider
self._model_type = model_type
self._metadata_loader = metadata_loader
async def execute(
self,
*,
file_paths: Optional[Sequence[str]] = None,
progress_callback: Optional[ProgressCallback] = None,
) -> AutoOrganizeResult:
"""Run the bulk rename guarded by the shared library-operation lock."""
is_running = getattr(self._lock_provider, "is_filename_template_running", None)
if callable(is_running) and is_running():
raise AutoOrganizeInProgressError(
"A filename template operation is already running"
)
if self._lock_provider.is_auto_organize_running():
raise AutoOrganizeInProgressError("Auto-organize is already running")
lock = await self._lock_provider.get_auto_organize_lock()
if lock.locked():
raise AutoOrganizeInProgressError(
"Another library operation is already running"
)
async with lock:
return await self._run(
file_paths=file_paths, progress_callback=progress_callback
)
async def _run(
self,
*,
file_paths: Optional[Sequence[str]],
progress_callback: Optional[ProgressCallback],
) -> AutoOrganizeResult:
result = AutoOrganizeResult()
result.operation_type = "filename_template"
self._scanner.reset_cancellation()
try:
template = get_settings_manager().get_download_filename_template(
self._model_type
)
cache = await self._scanner.get_cached_data()
models = list(cache.raw_data)
if file_paths:
wanted = set(file_paths)
models = [
model for model in models if model.get("file_path") in wanted
]
result.total = len(models)
await self._emit_progress(progress_callback, result, "started")
for index in range(0, result.total, AUTO_ORGANIZE_BATCH_SIZE):
if self._scanner.is_cancelled():
logger.info(
"Filename template apply cancelled for %s", self._model_type
)
break
batch = models[index : index + AUTO_ORGANIZE_BATCH_SIZE]
for model in batch:
if self._scanner.is_cancelled():
break
await self._process_model(model, template, result)
result.processed += 1
await self._emit_progress(progress_callback, result, "processing")
# Yield between batches so the server stays responsive.
await asyncio.sleep(0.1)
if self._scanner.is_cancelled():
result.status = "cancelled"
await self._emit_progress(progress_callback, result, "cancelled")
return result
await self._emit_progress(progress_callback, result, "completed")
return result
except Exception as exc:
logger.error("Error in filename template apply: %s", exc, exc_info=True)
if progress_callback:
await progress_callback.on_progress(
{
"type": _PROGRESS_TYPE,
"status": "error",
"error": str(exc),
"operation_type": result.operation_type,
}
)
raise
async def _process_model(
self,
model: Dict[str, Any],
template: str,
result: AutoOrganizeResult,
) -> None:
model_name = model.get("model_name", "Unknown")
try:
file_path = model.get("file_path")
if not file_path:
self._add_result(result, model_name, False, "No file path found")
result.failure_count += 1
return
if not template:
# Empty template = revert to the original filename recorded
# by the first rename; models without a record are skipped.
new_stem = await self._resolve_recorded_original(file_path)
else:
new_stem = calculate_filename_for_model(model, self._model_type)
if not new_stem:
result.skipped_count += 1
return
current_stem = os.path.splitext(os.path.basename(file_path))[0]
if new_stem == current_stem or os.path.normcase(
new_stem
) == os.path.normcase(current_stem):
result.skipped_count += 1
return
await self._lifecycle_service.rename_model(
file_path=file_path, new_file_name=new_stem
)
result.success_count += 1
except ValueError as exc:
# Conflicts (e.g. target name already exists) count as failures
# without aborting the batch.
self._add_result(result, model_name, False, str(exc))
result.failure_count += 1
except Exception as exc:
logger.error(
"Error applying filename template to %s: %s", model_name, exc,
exc_info=True,
)
self._add_result(result, model_name, False, f"Error: {exc}")
result.failure_count += 1
async def _resolve_recorded_original(self, file_path: str) -> str:
"""Return the original filename stem recorded at the first rename.
Reads the ``.metadata.json`` sidecar; returns an empty string when no
sidecar or no ``original_file_name`` entry exists (models never
renamed, or renamed before the recording shipped).
"""
metadata_path = f"{os.path.splitext(file_path)[0]}.metadata.json"
metadata = await self._metadata_loader(metadata_path)
original = metadata.get("original_file_name")
if not isinstance(original, str):
return ""
return original.strip()
async def _emit_progress(
self,
progress_callback: Optional[ProgressCallback],
result: AutoOrganizeResult,
status: str,
) -> None:
if not progress_callback:
return
await progress_callback.on_progress(
{
"type": _PROGRESS_TYPE,
"status": status,
"total": result.total,
"processed": result.processed,
"success": result.success_count,
"failures": result.failure_count,
"skipped": result.skipped_count,
"operation_type": result.operation_type,
}
)
@staticmethod
def _add_result(
result: AutoOrganizeResult,
model_name: str,
success: bool,
message: str,
) -> None:
"""Add a result entry if under the limit (mirrors ModelFileService)."""
if len(result.results) < 100:
result.results.append(
{"model": model_name, "success": success, "message": message}
)
elif len(result.results) == 100:
result.results_truncated = True
result.sample_results = result.results[:50]
+22
View File
@@ -20,6 +20,8 @@ class WebSocketManager:
self._last_init_progress: Dict[str, Dict[str, Any]] = {} self._last_init_progress: Dict[str, Dict[str, Any]] = {}
# Add auto-organize progress tracking # Add auto-organize progress tracking
self._auto_organize_progress: Optional[Dict[str, Any]] = None self._auto_organize_progress: Optional[Dict[str, Any]] = None
# Add filename template progress tracking
self._filename_template_progress: Optional[Dict[str, Any]] = None
# Add recipe rematch progress tracking # Add recipe rematch progress tracking
self._recipe_rematch_progress: Optional[Dict[str, Any]] = None self._recipe_rematch_progress: Optional[Dict[str, Any]] = None
self._auto_organize_lock = asyncio.Lock() self._auto_organize_lock = asyncio.Lock()
@@ -206,6 +208,26 @@ class WebSocketManager:
"""Clear auto-organize progress data""" """Clear auto-organize progress data"""
self._auto_organize_progress = None self._auto_organize_progress = None
async def broadcast_filename_template_progress(self, data: Dict[str, Any]):
"""Broadcast filename template progress to connected clients"""
self._filename_template_progress = data
await self.broadcast(data)
def get_filename_template_progress(self) -> Optional[Dict[str, Any]]:
"""Get current filename template progress"""
return self._filename_template_progress
def cleanup_filename_template_progress(self):
"""Clear filename template progress data"""
self._filename_template_progress = None
def is_filename_template_running(self) -> bool:
"""Check if a filename template operation is currently running"""
if not self._filename_template_progress:
return False
status = self._filename_template_progress.get('status')
return status in ['started', 'processing']
async def broadcast_recipe_rematch_progress(self, data: Dict[str, Any]): async def broadcast_recipe_rematch_progress(self, data: Dict[str, Any]):
"""Broadcast recipe rematch progress to connected clients""" """Broadcast recipe rematch progress to connected clients"""
# Store progress data in memory # Store progress data in memory
@@ -21,6 +21,14 @@ class WebSocketProgressCallback(ProgressCallback):
await ws_manager.broadcast_auto_organize_progress(progress_data) await ws_manager.broadcast_auto_organize_progress(progress_data)
class WebSocketFilenameTemplateProgressCallback(ProgressCallback):
"""WebSocket progress callback for filename template operations."""
async def on_progress(self, progress_data: Dict[str, Any]) -> None:
"""Send filename template progress via WebSocket."""
await ws_manager.broadcast_filename_template_progress(progress_data)
class WebSocketBroadcastCallback: class WebSocketBroadcastCallback:
"""Generic WebSocket progress callback broadcasting to all clients.""" """Generic WebSocket progress callback broadcasting to all clients."""
+104
View File
@@ -1,4 +1,5 @@
from difflib import SequenceMatcher from difflib import SequenceMatcher
import logging
import os import os
import re import re
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
@@ -7,6 +8,8 @@ from ..config import config
from ..services.settings_manager import get_settings_manager from ..services.settings_manager import get_settings_manager
import asyncio import asyncio
logger = logging.getLogger(__name__)
def get_lora_info(lora_name): def get_lora_info(lora_name):
"""Get the lora path and trigger words from cache""" """Get the lora path and trigger words from cache"""
@@ -598,6 +601,107 @@ def calculate_relative_path_for_model(
return formatted_path return formatted_path
def calculate_filename_for_model(
model_data: Dict[str, Any], model_type: str = "lora"
) -> str:
"""Calculate the filename stem for a model using the filename template.
Mirrors the data extraction of :func:`calculate_relative_path_for_model`
but renders a single filename (no path segments). Missing values resolve
to empty segments instead of the path-oriented defaults ("Anonymous" /
"no tags") so templates degrade gracefully.
Args:
model_data: Model data from scanner cache
model_type: Type of model ('lora', 'checkpoint', 'embedding')
Returns:
Sanitized filename stem without extension, or an empty string when no
template is configured, the template is invalid, or the rendered name
is empty.
"""
settings_manager = get_settings_manager()
template = settings_manager.get_download_filename_template(model_type)
if not template:
return ""
# A filename template must render a single name, never folder segments.
if "/" in template or "\\" in template:
logger.warning(
"Filename template for %s contains a path separator and is ignored: %r",
model_type,
template,
)
return ""
civitai_data = model_data.get("civitai", {})
author = ""
if isinstance(civitai_data, dict) and civitai_data.get("id") is not None:
creator_info = civitai_data.get("creator") or {}
author = creator_info.get("username") or ""
base_model = model_data.get("base_model", "")
base_model_mappings = settings_manager.get("base_model_path_mappings", {})
mapped_base_model = base_model_mappings.get(base_model, base_model)
lowercase_tags = [
tag.lower() for tag in model_data.get("tags", []) if isinstance(tag, str)
]
first_tag = settings_manager.resolve_priority_tag_for_model(
lowercase_tags, model_type
)
model_name = model_data.get("model_name", "")
version_name = ""
if isinstance(civitai_data, dict):
version_name = civitai_data.get("name") or ""
sha256 = model_data.get("sha256") or ""
hash_short = sha256[:10].lower() if isinstance(sha256, str) else ""
file_path = model_data.get("file_path") or ""
if isinstance(file_path, str) and file_path:
original_name = os.path.splitext(os.path.basename(file_path))[0]
else:
original_name = os.path.splitext(str(model_data.get("file_name", "")))[0]
def _sanitize_value(value: Any) -> str:
# sanitize_folder_name falls back to "unnamed" for empty input; for
# templates an empty value must stay empty so segments collapse.
text = str(value) if value else ""
return sanitize_folder_name(text) if text else ""
replacements = {
"{model_name}": _sanitize_value(model_name),
"{version_name}": _sanitize_value(version_name),
"{base_model}": _sanitize_value(mapped_base_model),
"{author}": _sanitize_value(author),
"{first_tag}": _sanitize_value(first_tag),
"{hash_short}": hash_short,
"{original_name}": _sanitize_value(original_name),
}
result = template
for placeholder, value in replacements.items():
result = result.replace(placeholder, value)
if model_type == "embedding":
result = result.replace(" ", "_")
# Strip characters that are illegal in filenames on common filesystems.
result = re.sub(r'[:*?"<>|]', "", result)
# Collapse runs of identical separators introduced by empty substitutions.
result = re.sub(r"([-_. ])\1+", r"\1", result)
# Drop separators left dangling next to each other ("- -" -> "-").
result = re.sub(r" ?([-_.]) (?=[-_.])", r"\1", result)
# A stem must not start or end with separators, spaces or dots.
result = result.strip("-_. ")
return result
def remove_empty_dirs(path): def remove_empty_dirs(path):
"""Recursively remove empty directories starting from the given path. """Recursively remove empty directories starting from the given path.
@@ -27,6 +27,12 @@
justify-content: center; justify-content: center;
} }
/* Self-managed by SettingsManager: stacks above the settings modal like the
directory picker (settings panels sit at 10000/10002). */
#filenameTemplateConfirmModal {
z-index: 10010;
}
.delete-modal-content { .delete-modal-content {
max-width: 500px; max-width: 500px;
width: 90%; width: 90%;
+3
View File
@@ -122,6 +122,9 @@ export function getApiEndpoints(modelType) {
autoOrganize: `/api/lm/${modelType}/auto-organize`, autoOrganize: `/api/lm/${modelType}/auto-organize`,
autoOrganizeProgress: `/api/lm/${modelType}/auto-organize-progress`, 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) // Model-specific endpoints (will be merged with specific configs)
specific: {} 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() { async stopExampleImages() {
try { try {
const response = await fetch('/api/lm/stop-example-images', { const response = await fetch('/api/lm/stop-example-images', {
+268 -1
View File
@@ -1,12 +1,14 @@
import { modalManager } from './ModalManager.js'; import { modalManager } from './ModalManager.js';
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
import { state, createDefaultSettings } from '../state/index.js'; import { state, createDefaultSettings } from '../state/index.js';
import { resetAndReload } from '../api/modelApiFactory.js'; import { resetAndReload, getModelApiClient } from '../api/modelApiFactory.js';
import { import {
DOWNLOAD_PATH_TEMPLATES, DOWNLOAD_PATH_TEMPLATES,
MAPPABLE_BASE_MODELS, MAPPABLE_BASE_MODELS,
PATH_TEMPLATE_PLACEHOLDERS, PATH_TEMPLATE_PLACEHOLDERS,
DEFAULT_PATH_TEMPLATES, DEFAULT_PATH_TEMPLATES,
FILENAME_TEMPLATE_PLACEHOLDERS,
DEFAULT_FILENAME_TEMPLATES,
DEFAULT_PRIORITY_TAG_CONFIG, DEFAULT_PRIORITY_TAG_CONFIG,
getMappableBaseModelsDynamic getMappableBaseModelsDynamic
} from '../utils/constants.js'; } from '../utils/constants.js';
@@ -36,6 +38,13 @@ const OTHER_SUB_TYPE_LABEL_KEYS = {
controlnet: 'settings.folderSettings.subTypeControlnet', 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 { export class SettingsManager {
constructor() { constructor() {
this.initialized = false; this.initialized = false;
@@ -147,6 +156,25 @@ export class SettingsManager {
merged.download_path_templates = { ...DEFAULT_PATH_TEMPLATES, ...templates }; 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 priorityTags = backendSettings?.priority_tags;
const normalizedPriority = { ...DEFAULT_PRIORITY_TAG_CONFIG }; const normalizedPriority = { ...DEFAULT_PRIORITY_TAG_CONFIG };
if (priorityTags && typeof priorityTags === 'object' && !Array.isArray(priorityTags)) { if (priorityTags && typeof priorityTags === 'object' && !Array.isArray(priorityTags)) {
@@ -428,6 +456,30 @@ 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);
});
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'); const autoOrganizeInput = document.getElementById('autoOrganizeExclusions');
if (autoOrganizeInput) { if (autoOrganizeInput) {
autoOrganizeInput.addEventListener('keydown', (event) => { autoOrganizeInput.addEventListener('keydown', (event) => {
@@ -1142,6 +1194,9 @@ export class SettingsManager {
// Load download path templates // Load download path templates
this.loadDownloadPathTemplates(); this.loadDownloadPathTemplates();
// Load download filename templates
this.loadFilenameTemplates();
// Load priority tag settings // Load priority tag settings
this.loadPriorityTagSettings(); this.loadPriorityTagSettings();
@@ -2797,6 +2852,218 @@ 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);
});
}
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.restoreOriginal', {}, 'Valid (empty template restores original filenames)')}`;
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
this.updateFilenamePreview(modelType, template);
// Save settings
this.saveFilenameTemplates();
}
updateFilenamePreview(modelType, template) {
const previewElement = document.getElementById(`${modelType}FilenamePreview`);
if (!previewElement) return;
if (!template) {
// Empty template restores the recorded original filename
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');
}
}
/**
* Confirm the bulk apply/revert via a dedicated modal. Self-managed (NOT
* through ModalManager): it stacks above the settings modal, and
* ModalManager's "close current modal on open" behavior would kill the
* settings modal underneath. Resolves true when the user confirms.
*/
confirmFilenameTemplateApply(isRevert) {
const modalElement = document.getElementById('filenameTemplateConfirmModal');
if (!modalElement) {
return Promise.resolve(true);
}
const titleElement = modalElement.querySelector('[data-role="title"]');
if (titleElement) {
titleElement.textContent = isRevert
? translate('modals.filenameTemplateConfirm.titleRevert', {}, 'Restore original filenames?')
: translate('modals.filenameTemplateConfirm.titleApply', {}, 'Apply filename template to library?');
}
const messageElement = modalElement.querySelector('[data-role="message"]');
if (messageElement) {
messageElement.textContent = isRevert
? translate('settings.filenameTemplates.confirmRevert', {}, 'Restore the recorded original filename for all previously renamed files of this model type? This changes the relative path seen by ComfyUI loaders. Files without a recorded original filename are skipped.')
: translate('settings.filenameTemplates.confirmApply', {}, 'Rename all existing files of this model type according to the filename template? This changes the relative path seen by ComfyUI loaders. The original filename is preserved in each model\'s metadata.');
}
const confirmButton = modalElement.querySelector('[data-action="confirm-filename-template"]');
const cancelButton = modalElement.querySelector('[data-action="cancel-filename-template"]');
if (!confirmButton || !cancelButton) {
return Promise.resolve(true);
}
confirmButton.textContent = isRevert
? translate('modals.filenameTemplateConfirm.revertButton', {}, 'Restore Original Filenames')
: translate('settings.filenameTemplates.applyButton', {}, 'Apply to Library Now');
return new Promise((resolve) => {
let resolved = false;
const cleanup = () => {
confirmButton.removeEventListener('click', handleConfirm);
cancelButton.removeEventListener('click', handleCancel);
document.removeEventListener('keydown', handleEscape, true);
};
const finalize = (proceed) => {
if (resolved) {
return;
}
resolved = true;
cleanup();
modalElement.classList.remove('show');
// Keep body.modal-open: the settings modal underneath is still open.
resolve(proceed);
};
const handleConfirm = (event) => {
event.preventDefault();
finalize(true);
};
const handleCancel = (event) => {
event.preventDefault();
finalize(false);
};
// Capture phase + stopPropagation so ESC never reaches the
// settings modal's own ESC handler underneath.
const handleEscape = (event) => {
if (event.key === 'Escape') {
event.stopPropagation();
finalize(false);
}
};
confirmButton.addEventListener('click', handleConfirm);
cancelButton.addEventListener('click', handleCancel);
document.addEventListener('keydown', handleEscape, true);
modalElement.classList.add('show');
cancelButton.focus();
});
}
async applyFilenameTemplate(modelType) {
const template = state.global.settings.download_filename_templates?.[modelType] || '';
// An empty template reverts renamed models to their recorded original
// filename instead of rendering a template.
const confirmed = await this.confirmFilenameTemplateApply(!template);
if (!confirmed) {
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() { toggleSettings() {
if (this.isOpen) { if (this.isOpen) {
modalManager.closeModal('settingsModal'); modalManager.closeModal('settingsModal');
+3 -1
View File
@@ -1,7 +1,7 @@
// Create the new hierarchical state structure // Create the new hierarchical state structure
import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js'; import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js';
import { MODEL_TYPES } from '../api/apiConfig.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({ const DEFAULT_SETTINGS_BASE = Object.freeze({
civitai_api_key: '', civitai_api_key: '',
@@ -30,6 +30,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
recipes_path: '', recipes_path: '',
base_model_path_mappings: {}, base_model_path_mappings: {},
download_path_templates: {}, download_path_templates: {},
download_filename_templates: {},
example_images_path: '', example_images_path: '',
example_images_open_mode: 'system', example_images_open_mode: 'system',
example_images_local_root: '', example_images_local_root: '',
@@ -74,6 +75,7 @@ export function createDefaultSettings() {
...DEFAULT_SETTINGS_BASE, ...DEFAULT_SETTINGS_BASE,
base_model_path_mappings: {}, base_model_path_mappings: {},
download_path_templates: { ...DEFAULT_PATH_TEMPLATES }, download_path_templates: { ...DEFAULT_PATH_TEMPLATES },
download_filename_templates: { ...DEFAULT_FILENAME_TEMPLATES },
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG }, priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
default_other_roots: {}, default_other_roots: {},
enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'], enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'],
+19
View File
@@ -360,6 +360,25 @@ export const DEFAULT_PATH_TEMPLATES = {
other: '' 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 // Model type labels for UI
export const MODEL_TYPE_LABELS = { export const MODEL_TYPE_LABELS = {
lora: 'LoRA Models', lora: 'LoRA Models',
@@ -82,6 +82,20 @@
</div> </div>
</div> </div>
<!-- Filename Template Apply/Revert Confirmation Modal
Self-managed by SettingsManager (NOT registered with ModalManager): it
stacks above the settings modal, like the directory picker. -->
<div id="filenameTemplateConfirmModal" class="modal delete-modal">
<div class="modal-content delete-modal-content">
<h2 data-role="title"></h2>
<p class="delete-message" data-role="message"></p>
<div class="modal-actions">
<button class="cancel-btn" data-action="cancel-filename-template">{{ t('common.actions.cancel') }}</button>
<button class="primary-btn" data-action="confirm-filename-template"></button>
</div>
</div>
</div>
<!-- Sidebar Folder Delete Confirmation Modal --> <!-- Sidebar Folder Delete Confirmation Modal -->
<!-- Shared by two states: 'confirm' (model-free folder) and 'blocked' (the <!-- Shared by two states: 'confirm' (model-free folder) and 'blocked' (the
subtree still holds models, so a cascade delete is refused). --> subtree still holds models, so a cascade delete is refused). -->
@@ -1,15 +1,4 @@
{% import 'components/modals/settings/_macros.html' as sm with context %} {% import 'components/modals/settings/_macros.html' as sm with context %}
{% set template_preset_options = [
('', 'settings.downloadPathTemplates.templateOptions.flatStructure'),
('{base_model}', 'settings.downloadPathTemplates.templateOptions.byBaseModel'),
('{author}', 'settings.downloadPathTemplates.templateOptions.byAuthor'),
('{first_tag}', 'settings.downloadPathTemplates.templateOptions.byFirstTag'),
('{base_model}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.baseModelFirstTag'),
('{base_model}/{author}', 'settings.downloadPathTemplates.templateOptions.baseModelAuthor'),
('{author}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.authorFirstTag'),
('{base_model}/{author}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.baseModelAuthorFirstTag'),
('custom', 'settings.downloadPathTemplates.templateOptions.customTemplate'),
] %}
<!-- Section 3: Library --> <!-- Section 3: Library -->
<div id="section-library" class="settings-section" data-section="library"> <div id="section-library" class="settings-section" data-section="library">
<!-- Folder Settings --> <!-- Folder Settings -->
@@ -187,192 +176,6 @@
</div> </div>
</div> </div>
<!-- Download Path Templates -->
<div class="settings-subsection">
<div class="settings-subsection-header">
<h4>
{{ t('settings.downloadPathTemplates.title') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadPathTemplates.help') }}"></i>
</h4>
</div>
<div class="setting-item">
<div class="input-help">
<div class="placeholder-info">
<strong>{{ t('settings.downloadPathTemplates.availablePlaceholders') }}</strong>
<span class="placeholder-tag">{base_model}</span>
<span class="placeholder-tag">{author}</span>
<span class="placeholder-tag">{first_tag}</span>
<span class="placeholder-tag">{model_name}</span>
<span class="placeholder-tag">{version_name}</span>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="loraTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.lora') }}</label>
</div>
<div class="setting-control select-control">
<select id="loraTemplatePreset" onchange="settingsManager.updateTemplatePreset('lora', this.value)">
{% for value, option_label in template_preset_options %}
<option value="{{ value }}">{{ t(option_label) }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="template-custom-row" id="loraCustomRow" style="display: none;">
<input type="text" id="loraCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
<div class="template-validation" id="loraValidation"></div>
</div>
<div class="template-preview" id="loraPreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="checkpointTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.checkpoint') }}</label>
</div>
<div class="setting-control select-control">
<select id="checkpointTemplatePreset" onchange="settingsManager.updateTemplatePreset('checkpoint', this.value)">
{% for value, option_label in template_preset_options %}
<option value="{{ value }}">{{ t(option_label) }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="template-custom-row" id="checkpointCustomRow" style="display: none;">
<input type="text" id="checkpointCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
<div class="template-validation" id="checkpointValidation"></div>
</div>
<div class="template-preview" id="checkpointPreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="embeddingTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.embedding') }}</label>
</div>
<div class="setting-control select-control">
<select id="embeddingTemplatePreset" onchange="settingsManager.updateTemplatePreset('embedding', this.value)">
{% for value, option_label in template_preset_options %}
<option value="{{ value }}">{{ t(option_label) }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="template-custom-row" id="embeddingCustomRow" style="display: none;">
<input type="text" id="embeddingCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
<div class="template-validation" id="embeddingValidation"></div>
</div>
<div class="template-preview" id="embeddingPreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label>
{{ t('settings.downloadPathTemplates.baseModelPathMappings') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadPathTemplates.baseModelPathMappingsHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<button type="button" class="add-mapping-btn" onclick="settingsManager.addMappingRow()">
<i class="fas fa-plus"></i>
<span>{{ t('settings.downloadPathTemplates.addMapping') }}</span>
</button>
</div>
</div>
<div class="mappings-container">
<div id="baseModelMappingsContainer">
</div>
</div>
</div>
{{ sm.setting_toggle('skipPreviouslyDownloadedModelVersions', 'skip_previously_downloaded_model_versions', 'settings.skipPreviouslyDownloadedModelVersions.label', 'settings.skipPreviouslyDownloadedModelVersions.help') }}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="downloadSkipBaseModelsToggle">
{{ t('settings.downloadSkipBaseModels.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadSkipBaseModels.help') }}"></i>
</label>
</div>
<div class="setting-control">
<button
type="button"
id="downloadSkipBaseModelsToggle"
class="secondary-btn base-model-skip-toggle"
aria-expanded="false"
>
<span id="downloadSkipBaseModelsSummary">{{ t('settings.downloadSkipBaseModels.summary.none') }}</span>
<span class="base-model-skip-toggle-label">{{ t('settings.downloadSkipBaseModels.actions.edit') }}</span>
</button>
</div>
</div>
<div id="downloadSkipBaseModelsPanel" class="base-model-skip-panel" hidden>
<div class="base-model-skip-toolbar">
<input
type="text"
id="downloadSkipBaseModelsSearch"
class="base-model-skip-search"
placeholder="{{ t('settings.downloadSkipBaseModels.searchPlaceholder') }}"
/>
<button type="button" class="text-btn base-model-skip-clear" id="downloadSkipBaseModelsClear">
{{ t('settings.downloadSkipBaseModels.actions.clear') }}
</button>
</div>
<div id="downloadSkipBaseModelsContainer" class="base-model-skip-list"></div>
<div id="downloadSkipBaseModelsEmpty" class="base-model-skip-empty" hidden>
{{ t('settings.downloadSkipBaseModels.empty') }}
</div>
</div>
<div class="settings-input-error-message" id="downloadSkipBaseModelsError"></div>
</div>
<!-- Priority Tags -->
<div class="setting-item priority-tags-item">
<div class="setting-row priority-tags-header-row">
<div class="setting-info priority-tags-header">
<label>
{{ t('settings.priorityTags.title') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.priorityTags.description') }}"></i>
</label>
<a class="settings-action-link priority-tags-help-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Priority-Tags-Configuration-Guide" target="_blank" rel="noopener" aria-label="{{ t('settings.priorityTags.helpLinkLabel') }}" title="{{ t('settings.priorityTags.helpLinkLabel') }}">
<i class="fas fa-question-circle" aria-hidden="true"></i>
</a>
</div>
</div>
<div class="priority-tags-tabs">
<input type="radio" id="priority-tags-tab-lora" name="priority-tags-tab" class="priority-tags-tab-input" checked>
<input type="radio" id="priority-tags-tab-checkpoint" name="priority-tags-tab" class="priority-tags-tab-input">
<input type="radio" id="priority-tags-tab-embedding" name="priority-tags-tab" class="priority-tags-tab-input">
<div class="priority-tags-tablist">
<label class="priority-tags-tab-label" for="priority-tags-tab-lora" id="priority-tags-tab-lora-label">{{ t('settings.priorityTags.modelTypes.lora') }}</label>
<label class="priority-tags-tab-label" for="priority-tags-tab-checkpoint" id="priority-tags-tab-checkpoint-label">{{ t('settings.priorityTags.modelTypes.checkpoint') }}</label>
<label class="priority-tags-tab-label" for="priority-tags-tab-embedding" id="priority-tags-tab-embedding-label">{{ t('settings.priorityTags.modelTypes.embedding') }}</label>
</div>
<div class="priority-tags-panels">
<div class="priority-tags-panel" id="priority-tags-panel-lora">
<textarea id="loraPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="loraPriorityTagsError"></div>
</div>
<div class="priority-tags-panel" id="priority-tags-panel-checkpoint">
<textarea id="checkpointPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="checkpointPriorityTagsError"></div>
</div>
<div class="priority-tags-panel" id="priority-tags-panel-embedding">
<textarea id="embeddingPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="embeddingPriorityTagsError"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Version Scope --> <!-- Version Scope -->
<div class="settings-subsection"> <div class="settings-subsection">
{{ sm.subsection_header('settings.sections.versionScope') }} {{ sm.subsection_header('settings.sections.versionScope') }}
@@ -463,25 +266,6 @@
</div> </div>
</div> </div>
<!-- Auto-organize -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.autoOrganize') }}
<!-- Auto-organize Exclusions -->
<div class="setting-item auto-organize-exclusions-item">
<div class="setting-row">
<div class="setting-info">
<label for="autoOrganizeExclusions">
{{ t('settings.autoOrganizeExclusions.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.autoOrganizeExclusions.help') }}"></i>
</label>
</div>
</div>
<textarea id="autoOrganizeExclusions" class="priority-tags-input auto-organize-exclusions-input" placeholder="{{ t('settings.autoOrganizeExclusions.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="autoOrganizeExclusionsError"></div>
</div>
</div>
<!-- Metadata --> <!-- Metadata -->
<div class="settings-subsection"> <div class="settings-subsection">
{{ sm.subsection_header('settings.sections.metadata') }} {{ sm.subsection_header('settings.sections.metadata') }}
@@ -0,0 +1,295 @@
{% import 'components/modals/settings/_macros.html' as sm with context %}
{% set template_preset_options = [
('', 'settings.downloadPathTemplates.templateOptions.flatStructure'),
('{base_model}', 'settings.downloadPathTemplates.templateOptions.byBaseModel'),
('{author}', 'settings.downloadPathTemplates.templateOptions.byAuthor'),
('{first_tag}', 'settings.downloadPathTemplates.templateOptions.byFirstTag'),
('{base_model}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.baseModelFirstTag'),
('{base_model}/{author}', 'settings.downloadPathTemplates.templateOptions.baseModelAuthor'),
('{author}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.authorFirstTag'),
('{base_model}/{author}/{first_tag}', 'settings.downloadPathTemplates.templateOptions.baseModelAuthorFirstTag'),
('custom', 'settings.downloadPathTemplates.templateOptions.customTemplate'),
] %}
<!-- Section 4: Organization -->
<div id="section-organization" class="settings-section" data-section="organization">
<!-- Download Path Templates -->
<div class="settings-subsection">
<div class="settings-subsection-header">
<h4>
{{ t('settings.downloadPathTemplates.title') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadPathTemplates.help') }}"></i>
</h4>
</div>
<div class="setting-item">
<div class="input-help">
<div class="placeholder-info">
<strong>{{ t('settings.downloadPathTemplates.availablePlaceholders') }}</strong>
<span class="placeholder-tag">{base_model}</span>
<span class="placeholder-tag">{author}</span>
<span class="placeholder-tag">{first_tag}</span>
<span class="placeholder-tag">{model_name}</span>
<span class="placeholder-tag">{version_name}</span>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="loraTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.lora') }}</label>
</div>
<div class="setting-control select-control">
<select id="loraTemplatePreset" onchange="settingsManager.updateTemplatePreset('lora', this.value)">
{% for value, option_label in template_preset_options %}
<option value="{{ value }}">{{ t(option_label) }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="template-custom-row" id="loraCustomRow" style="display: none;">
<input type="text" id="loraCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
<div class="template-validation" id="loraValidation"></div>
</div>
<div class="template-preview" id="loraPreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="checkpointTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.checkpoint') }}</label>
</div>
<div class="setting-control select-control">
<select id="checkpointTemplatePreset" onchange="settingsManager.updateTemplatePreset('checkpoint', this.value)">
{% for value, option_label in template_preset_options %}
<option value="{{ value }}">{{ t(option_label) }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="template-custom-row" id="checkpointCustomRow" style="display: none;">
<input type="text" id="checkpointCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
<div class="template-validation" id="checkpointValidation"></div>
</div>
<div class="template-preview" id="checkpointPreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="embeddingTemplatePreset">{{ t('settings.downloadPathTemplates.modelTypes.embedding') }}</label>
</div>
<div class="setting-control select-control">
<select id="embeddingTemplatePreset" onchange="settingsManager.updateTemplatePreset('embedding', this.value)">
{% for value, option_label in template_preset_options %}
<option value="{{ value }}">{{ t(option_label) }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="template-custom-row" id="embeddingCustomRow" style="display: none;">
<input type="text" id="embeddingCustomTemplate" class="template-custom-input" placeholder="{{ t('settings.downloadPathTemplates.customTemplatePlaceholder') }}" />
<div class="template-validation" id="embeddingValidation"></div>
</div>
<div class="template-preview" id="embeddingPreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label>
{{ t('settings.downloadPathTemplates.baseModelPathMappings') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadPathTemplates.baseModelPathMappingsHelp') }}"></i>
</label>
</div>
<div class="setting-control">
<button type="button" class="add-mapping-btn" onclick="settingsManager.addMappingRow()">
<i class="fas fa-plus"></i>
<span>{{ t('settings.downloadPathTemplates.addMapping') }}</span>
</button>
</div>
</div>
<div class="mappings-container">
<div id="baseModelMappingsContainer">
</div>
</div>
</div>
{{ sm.setting_toggle('skipPreviouslyDownloadedModelVersions', 'skip_previously_downloaded_model_versions', 'settings.skipPreviouslyDownloadedModelVersions.label', 'settings.skipPreviouslyDownloadedModelVersions.help') }}
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="downloadSkipBaseModelsToggle">
{{ t('settings.downloadSkipBaseModels.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.downloadSkipBaseModels.help') }}"></i>
</label>
</div>
<div class="setting-control">
<button
type="button"
id="downloadSkipBaseModelsToggle"
class="secondary-btn base-model-skip-toggle"
aria-expanded="false"
>
<span id="downloadSkipBaseModelsSummary">{{ t('settings.downloadSkipBaseModels.summary.none') }}</span>
<span class="base-model-skip-toggle-label">{{ t('settings.downloadSkipBaseModels.actions.edit') }}</span>
</button>
</div>
</div>
<div id="downloadSkipBaseModelsPanel" class="base-model-skip-panel" hidden>
<div class="base-model-skip-toolbar">
<input
type="text"
id="downloadSkipBaseModelsSearch"
class="base-model-skip-search"
placeholder="{{ t('settings.downloadSkipBaseModels.searchPlaceholder') }}"
/>
<button type="button" class="text-btn base-model-skip-clear" id="downloadSkipBaseModelsClear">
{{ t('settings.downloadSkipBaseModels.actions.clear') }}
</button>
</div>
<div id="downloadSkipBaseModelsContainer" class="base-model-skip-list"></div>
<div id="downloadSkipBaseModelsEmpty" class="base-model-skip-empty" hidden>
{{ t('settings.downloadSkipBaseModels.empty') }}
</div>
</div>
<div class="settings-input-error-message" id="downloadSkipBaseModelsError"></div>
</div>
<!-- Priority Tags -->
<div class="setting-item priority-tags-item">
<div class="setting-row priority-tags-header-row">
<div class="setting-info priority-tags-header">
<label>
{{ t('settings.priorityTags.title') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.priorityTags.description') }}"></i>
</label>
<a class="settings-action-link priority-tags-help-link" href="https://github.com/willmiao/ComfyUI-Lora-Manager/wiki/Priority-Tags-Configuration-Guide" target="_blank" rel="noopener" aria-label="{{ t('settings.priorityTags.helpLinkLabel') }}" title="{{ t('settings.priorityTags.helpLinkLabel') }}">
<i class="fas fa-question-circle" aria-hidden="true"></i>
</a>
</div>
</div>
<div class="priority-tags-tabs">
<input type="radio" id="priority-tags-tab-lora" name="priority-tags-tab" class="priority-tags-tab-input" checked>
<input type="radio" id="priority-tags-tab-checkpoint" name="priority-tags-tab" class="priority-tags-tab-input">
<input type="radio" id="priority-tags-tab-embedding" name="priority-tags-tab" class="priority-tags-tab-input">
<div class="priority-tags-tablist">
<label class="priority-tags-tab-label" for="priority-tags-tab-lora" id="priority-tags-tab-lora-label">{{ t('settings.priorityTags.modelTypes.lora') }}</label>
<label class="priority-tags-tab-label" for="priority-tags-tab-checkpoint" id="priority-tags-tab-checkpoint-label">{{ t('settings.priorityTags.modelTypes.checkpoint') }}</label>
<label class="priority-tags-tab-label" for="priority-tags-tab-embedding" id="priority-tags-tab-embedding-label">{{ t('settings.priorityTags.modelTypes.embedding') }}</label>
</div>
<div class="priority-tags-panels">
<div class="priority-tags-panel" id="priority-tags-panel-lora">
<textarea id="loraPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="loraPriorityTagsError"></div>
</div>
<div class="priority-tags-panel" id="priority-tags-panel-checkpoint">
<textarea id="checkpointPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="checkpointPriorityTagsError"></div>
</div>
<div class="priority-tags-panel" id="priority-tags-panel-embedding">
<textarea id="embeddingPriorityTagsInput" class="priority-tags-input" placeholder="{{ t('settings.priorityTags.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="embeddingPriorityTagsError"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Filename Templates -->
<div class="settings-subsection">
<div class="settings-subsection-header">
<h4>
{{ t('settings.filenameTemplates.title') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.filenameTemplates.help') }}"></i>
</h4>
</div>
<div class="setting-item">
<div class="input-help">
<div class="placeholder-info">
<strong>{{ t('settings.filenameTemplates.availablePlaceholders') }}</strong>
<span class="placeholder-tag">{model_name}</span>
<span class="placeholder-tag">{version_name}</span>
<span class="placeholder-tag">{base_model}</span>
<span class="placeholder-tag">{author}</span>
<span class="placeholder-tag">{first_tag}</span>
<span class="placeholder-tag">{hash_short}</span>
<span class="placeholder-tag">{original_name}</span>
</div>
</div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="loraFilenameTemplate">{{ t('settings.downloadPathTemplates.modelTypes.lora') }}</label>
</div>
<div class="setting-control">
<button type="button" id="loraApplyFilenameTemplate" class="primary-btn" onclick="settingsManager.applyFilenameTemplate('lora')">
{{ t('settings.filenameTemplates.applyButton') }}
</button>
</div>
</div>
<input type="text" id="loraFilenameTemplate" class="template-custom-input" placeholder="{{ t('settings.filenameTemplates.templatePlaceholder') }}" />
<div class="template-validation" id="loraFilenameValidation"></div>
<div class="template-preview" id="loraFilenamePreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="checkpointFilenameTemplate">{{ t('settings.downloadPathTemplates.modelTypes.checkpoint') }}</label>
</div>
<div class="setting-control">
<button type="button" id="checkpointApplyFilenameTemplate" class="primary-btn" onclick="settingsManager.applyFilenameTemplate('checkpoint')">
{{ t('settings.filenameTemplates.applyButton') }}
</button>
</div>
</div>
<input type="text" id="checkpointFilenameTemplate" class="template-custom-input" placeholder="{{ t('settings.filenameTemplates.templatePlaceholder') }}" />
<div class="template-validation" id="checkpointFilenameValidation"></div>
<div class="template-preview" id="checkpointFilenamePreview"></div>
</div>
<div class="setting-item">
<div class="setting-row">
<div class="setting-info">
<label for="embeddingFilenameTemplate">{{ t('settings.downloadPathTemplates.modelTypes.embedding') }}</label>
</div>
<div class="setting-control">
<button type="button" id="embeddingApplyFilenameTemplate" class="primary-btn" onclick="settingsManager.applyFilenameTemplate('embedding')">
{{ t('settings.filenameTemplates.applyButton') }}
</button>
</div>
</div>
<input type="text" id="embeddingFilenameTemplate" class="template-custom-input" placeholder="{{ t('settings.filenameTemplates.templatePlaceholder') }}" />
<div class="template-validation" id="embeddingFilenameValidation"></div>
<div class="template-preview" id="embeddingFilenamePreview"></div>
</div>
<div class="setting-item">
<div class="input-help">{{ t('settings.filenameTemplates.applyHelp') }}</div>
</div>
</div>
<!-- Auto-organize -->
<div class="settings-subsection">
{{ sm.subsection_header('settings.sections.autoOrganize') }}
<!-- Auto-organize Exclusions -->
<div class="setting-item auto-organize-exclusions-item">
<div class="setting-row">
<div class="setting-info">
<label for="autoOrganizeExclusions">
{{ t('settings.autoOrganizeExclusions.label') }}
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.autoOrganizeExclusions.help') }}"></i>
</label>
</div>
</div>
<textarea id="autoOrganizeExclusions" class="priority-tags-input auto-organize-exclusions-input" placeholder="{{ t('settings.autoOrganizeExclusions.placeholder') }}"></textarea>
<div class="settings-input-error-message" id="autoOrganizeExclusionsError"></div>
</div>
</div>
</div>
@@ -36,6 +36,7 @@
<button type="button" class="settings-nav-item active" data-section="general">{{ t('settings.nav.general') }}</button> <button type="button" class="settings-nav-item active" data-section="general">{{ t('settings.nav.general') }}</button>
<button type="button" class="settings-nav-item" data-section="interface">{{ t('settings.nav.interface') }}</button> <button type="button" class="settings-nav-item" data-section="interface">{{ t('settings.nav.interface') }}</button>
<button type="button" class="settings-nav-item" data-section="library">{{ t('settings.nav.library') }}</button> <button type="button" class="settings-nav-item" data-section="library">{{ t('settings.nav.library') }}</button>
<button type="button" class="settings-nav-item" data-section="organization">{{ t('settings.nav.organization') }}</button>
</li> </li>
</ul> </ul>
</nav> </nav>
@@ -46,6 +47,7 @@
{% include 'components/modals/settings/general.html' %} {% include 'components/modals/settings/general.html' %}
{% include 'components/modals/settings/interface.html' %} {% include 'components/modals/settings/interface.html' %}
{% include 'components/modals/settings/library.html' %} {% include 'components/modals/settings/library.html' %}
{% include 'components/modals/settings/organization.html' %}
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,299 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
const {
APP_MODULE,
API_MODULE,
} = vi.hoisted(() => ({
APP_MODULE: new URL('../../../scripts/app.js', import.meta.url).pathname,
API_MODULE: new URL('../../../scripts/api.js', import.meta.url).pathname,
}));
vi.mock(APP_MODULE, () => ({
app: { graph: {} },
}));
const { fetchApiMock } = vi.hoisted(() => ({ fetchApiMock: vi.fn() }));
vi.mock(API_MODULE, () => ({
api: { fetchApi: fetchApiMock },
}));
import {
parseStrengthRange,
describeStrengthRangeViolation,
applyStrengthRangeCue,
buildStrengthRangeMap,
getLoraStrengthRange,
getAvailableLoras,
resetAvailableLorasCache,
} from '../../../web/comfyui/loras_widget_utils.js';
describe('parseStrengthRange', () => {
it('parses explicit strength_min/strength_max keys', () => {
expect(parseStrengthRange('{"strength_min": 0.4, "strength_max": 0.8}')).toEqual({
min: 0.4,
max: 0.8,
recommended: null,
});
});
it('parses the strength_range shorthand', () => {
expect(parseStrengthRange('{"strength_range": "0.4-0.8"}')).toEqual({
min: 0.4,
max: 0.8,
recommended: null,
});
});
it('accepts camelCase variants', () => {
expect(parseStrengthRange('{"strengthMin": 0.2, "strengthMax": 1.5}')).toEqual({
min: 0.2,
max: 1.5,
recommended: null,
});
});
it('prefers explicit min/max over the range string per side', () => {
expect(
parseStrengthRange('{"strength_min": 0.1, "strength_range": "0.4-0.8"}')
).toEqual({ min: 0.1, max: 0.8, recommended: null });
});
it('supports open-ended ranges', () => {
expect(parseStrengthRange('{"strength_max": 1.0}')).toEqual({
min: null,
max: 1.0,
recommended: null,
});
});
it('captures the recommended strength when present', () => {
expect(
parseStrengthRange('{"strength": 0.6, "strength_min": 0.4, "strength_max": 0.8}')
).toEqual({ min: 0.4, max: 0.8, recommended: 0.6 });
});
it('accepts numeric strings for bounds', () => {
expect(parseStrengthRange('{"strength_min": "0.4", "strength_max": "0.8"}')).toEqual({
min: 0.4,
max: 0.8,
recommended: null,
});
});
it('parses negative bounds in range strings', () => {
expect(parseStrengthRange('{"strength_range": "-0.5-0.8"}')).toEqual({
min: -0.5,
max: 0.8,
recommended: null,
});
});
it('returns null when no range is configured', () => {
expect(parseStrengthRange('{"strength": 0.6}')).toBeNull();
expect(parseStrengthRange('{}')).toBeNull();
expect(parseStrengthRange('')).toBeNull();
expect(parseStrengthRange(null)).toBeNull();
expect(parseStrengthRange(undefined)).toBeNull();
});
it('returns null for malformed JSON', () => {
expect(parseStrengthRange('{invalid')).toBeNull();
});
it('returns null for inverted ranges', () => {
expect(parseStrengthRange('{"strength_min": 0.9, "strength_max": 0.2}')).toBeNull();
});
it('accepts already-parsed objects', () => {
expect(parseStrengthRange({ strength_min: 0.3 })).toEqual({
min: 0.3,
max: null,
recommended: null,
});
});
});
describe('describeStrengthRangeViolation', () => {
const range = { min: 0.4, max: 0.8, recommended: 0.6 };
it('returns null when the value is inside the range', () => {
expect(describeStrengthRangeViolation(0.6, range)).toBeNull();
expect(describeStrengthRangeViolation(0.4, range)).toBeNull();
expect(describeStrengthRangeViolation(0.8, range)).toBeNull();
});
it('describes values below the range', () => {
expect(describeStrengthRangeViolation(0.2, range)).toBe(
'Below recommended strength range (0.40\u20130.80); recommended: 0.60'
);
});
it('describes values above the range', () => {
expect(describeStrengthRangeViolation('1.0', range)).toBe(
'Above recommended strength range (0.40\u20130.80); recommended: 0.60'
);
});
it('omits the recommended part when not configured', () => {
expect(describeStrengthRangeViolation(0.1, { min: 0.4, max: null, recommended: null })).toBe(
'Below recommended strength range (\u2265 0.40)'
);
expect(describeStrengthRangeViolation(1.5, { min: null, max: 1.0, recommended: null })).toBe(
'Above recommended strength range (\u2264 1.00)'
);
});
it('returns null without a range or with a non-numeric value', () => {
expect(describeStrengthRangeViolation(0.1, null)).toBeNull();
expect(describeStrengthRangeViolation('abc', range)).toBeNull();
});
});
describe('applyStrengthRangeCue', () => {
const range = { min: 0.4, max: 0.8, recommended: 0.6 };
it('adds the cue class and tooltip for out-of-range values', () => {
const input = document.createElement('input');
applyStrengthRangeCue(input, 1.5, range);
expect(input.classList.contains('lm-strength-out-of-range')).toBe(true);
expect(input.title).toContain('Above recommended strength range');
});
it('clears the cue for in-range values', () => {
const input = document.createElement('input');
applyStrengthRangeCue(input, 1.5, range);
applyStrengthRangeCue(input, 0.6, range);
expect(input.classList.contains('lm-strength-out-of-range')).toBe(false);
expect(input.hasAttribute('title')).toBe(false);
});
it('clears the cue when no range is configured', () => {
const input = document.createElement('input');
input.classList.add('lm-strength-out-of-range');
input.title = 'stale';
applyStrengthRangeCue(input, 99, null);
expect(input.classList.contains('lm-strength-out-of-range')).toBe(false);
expect(input.hasAttribute('title')).toBe(false);
});
});
describe('buildStrengthRangeMap', () => {
it('keys ranges by normalized path and basename', () => {
const map = buildStrengthRangeMap([
{ file_name: 'sub/a', usage_tips: '{"strength_min": 0.4, "strength_max": 0.8}' },
]);
expect(map.get('sub/a')).toEqual({ min: 0.4, max: 0.8, recommended: null });
expect(map.get('a')).toEqual({ min: 0.4, max: 0.8, recommended: null });
});
it('strips extensions from keys', () => {
const map = buildStrengthRangeMap([
{ file_name: 'sub/a.safetensors', usage_tips: '{"strength_max": 1.0}' },
]);
expect(map.get('sub/a')).toBeTruthy();
});
it('skips entries without a valid range', () => {
const map = buildStrengthRangeMap([
{ file_name: 'a', usage_tips: '' },
{ file_name: 'b' },
{ file_name: 'c', usage_tips: '{"strength": 0.6}' },
null,
]);
expect(map.size).toBe(0);
});
});
describe('getLoraStrengthRange', () => {
beforeEach(() => {
fetchApiMock.mockReset();
resetAvailableLorasCache();
});
it('returns null while the cache is not loaded', () => {
expect(getLoraStrengthRange('a')).toBeNull();
});
it('resolves ranges from the cached cycler list', async () => {
fetchApiMock.mockResolvedValue({
ok: true,
json: async () => ({
success: true,
loras: [
{
file_name: 'sub/a.safetensors',
usage_tips: '{"strength": 0.6, "strength_min": 0.4, "strength_max": 0.8}',
},
{ file_name: 'b.safetensors' },
],
}),
});
await getAvailableLoras();
expect(getLoraStrengthRange('sub/a.safetensors')).toEqual({
min: 0.4,
max: 0.8,
recommended: 0.6,
});
// Extension-free and basename forms resolve to the same entry.
expect(getLoraStrengthRange('sub/a')).toEqual({
min: 0.4,
max: 0.8,
recommended: 0.6,
});
expect(getLoraStrengthRange('a')).toEqual({
min: 0.4,
max: 0.8,
recommended: 0.6,
});
});
it('falls back to the basename for folder-qualified names', async () => {
fetchApiMock.mockResolvedValue({
ok: true,
json: async () => ({
success: true,
loras: [
{ file_name: 'sub/a.safetensors', usage_tips: '{"strength_max": 1.0}' },
],
}),
});
await getAvailableLoras();
expect(getLoraStrengthRange('any/folder/a.safetensors')).toEqual({
min: null,
max: 1.0,
recommended: null,
});
});
it('returns null for loras without a configured range', async () => {
fetchApiMock.mockResolvedValue({
ok: true,
json: async () => ({
success: true,
loras: [{ file_name: 'b.safetensors' }],
}),
});
await getAvailableLoras();
expect(getLoraStrengthRange('b')).toBeNull();
expect(getLoraStrengthRange('missing')).toBeNull();
});
it('returns null for absolute paths', async () => {
fetchApiMock.mockResolvedValue({
ok: true,
json: async () => ({
success: true,
loras: [
{ file_name: 'a.safetensors', usage_tips: '{"strength_max": 1.0}' },
],
}),
});
await getAvailableLoras();
expect(getLoraStrengthRange('/abs/path/a.safetensors')).toBeNull();
expect(getLoraStrengthRange('C:/abs/path/a.safetensors')).toBeNull();
});
});
@@ -35,6 +35,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
DEFAULT_PATH_TEMPLATES: {}, DEFAULT_PATH_TEMPLATES: {},
MAPPABLE_BASE_MODELS: ['Flux.1 D', 'Pony', 'SDXL 1.0', 'Other'], MAPPABLE_BASE_MODELS: ['Flux.1 D', 'Pony', 'SDXL 1.0', 'Other'],
PATH_TEMPLATE_PLACEHOLDERS: {}, PATH_TEMPLATE_PLACEHOLDERS: {},
FILENAME_TEMPLATE_PLACEHOLDERS: [],
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
DEFAULT_PRIORITY_TAG_CONFIG: { DEFAULT_PRIORITY_TAG_CONFIG: {
lora: 'character, style', lora: 'character, style',
checkpoint: 'base, guide', checkpoint: 'base, guide',
@@ -0,0 +1,314 @@
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 { 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>
`;
};
const appendConfirmModal = () => {
document.body.insertAdjacentHTML('beforeend', `
<div id="filenameTemplateConfirmModal" class="modal delete-modal">
<h2 data-role="title"></h2>
<p data-role="message"></p>
<button data-action="cancel-filename-template"></button>
<button data-action="confirm-filename-template"></button>
</div>
`);
};
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 (restores original filenames)', () => {
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('restores original filenames');
});
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 recorded 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('applies an empty template as a revert after modal confirmation', async () => {
appendFilenameTemplateUi();
appendConfirmModal();
const manager = createManager();
const apiClient = { applyFilenameTemplate: vi.fn().mockResolvedValue() };
getModelApiClient.mockReturnValue(apiClient);
const applyPromise = manager.applyFilenameTemplate('lora');
const modal = document.getElementById('filenameTemplateConfirmModal');
expect(modal.classList.contains('show')).toBe(true);
expect(modal.querySelector('[data-role="title"]').textContent)
.toBe('Restore original filenames?');
expect(modal.querySelector('[data-role="message"]').textContent)
.toContain('Restore the recorded original filename');
expect(modal.querySelector('[data-action="confirm-filename-template"]').textContent)
.toBe('Restore Original Filenames');
modal.querySelector('[data-action="confirm-filename-template"]').click();
await applyPromise;
expect(getModelApiClient).toHaveBeenCalledWith('loras');
expect(apiClient.applyFilenameTemplate).toHaveBeenCalledWith();
expect(resetAndReload).toHaveBeenCalledWith(true);
expect(modal.classList.contains('show')).toBe(false);
});
it('does not revert when the modal is cancelled', async () => {
appendFilenameTemplateUi();
appendConfirmModal();
const manager = createManager();
const applyPromise = manager.applyFilenameTemplate('lora');
document.querySelector('[data-action="cancel-filename-template"]').click();
await applyPromise;
expect(getModelApiClient).not.toHaveBeenCalled();
expect(resetAndReload).not.toHaveBeenCalled();
});
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('applies the template through the model API client and reloads', async () => {
appendFilenameTemplateUi();
state.global.settings.download_filename_templates.lora = '{model_name}';
const manager = createManager();
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('shows the apply wording for a non-empty template and honours cancellation', async () => {
appendFilenameTemplateUi();
appendConfirmModal();
state.global.settings.download_filename_templates.lora = '{model_name}';
const manager = createManager();
const applyPromise = manager.applyFilenameTemplate('lora');
const modal = document.getElementById('filenameTemplateConfirmModal');
expect(modal.classList.contains('show')).toBe(true);
expect(modal.querySelector('[data-role="title"]').textContent)
.toBe('Apply filename template to library?');
expect(modal.querySelector('[data-role="message"]').textContent)
.toContain('Rename all existing files');
modal.querySelector('[data-action="cancel-filename-template"]').click();
await applyPromise;
expect(getModelApiClient).not.toHaveBeenCalled();
expect(resetAndReload).not.toHaveBeenCalled();
expect(modal.classList.contains('show')).toBe(false);
});
});
@@ -38,6 +38,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
DEFAULT_PATH_TEMPLATES: {}, DEFAULT_PATH_TEMPLATES: {},
MAPPABLE_BASE_MODELS: [], MAPPABLE_BASE_MODELS: [],
PATH_TEMPLATE_PLACEHOLDERS: {}, PATH_TEMPLATE_PLACEHOLDERS: {},
FILENAME_TEMPLATE_PLACEHOLDERS: [],
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
DEFAULT_PRIORITY_TAG_CONFIG: { DEFAULT_PRIORITY_TAG_CONFIG: {
lora: 'character, style', lora: 'character, style',
checkpoint: 'base, guide', checkpoint: 'base, guide',
@@ -36,6 +36,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
DEFAULT_PATH_TEMPLATES: {}, DEFAULT_PATH_TEMPLATES: {},
MAPPABLE_BASE_MODELS: [], MAPPABLE_BASE_MODELS: [],
PATH_TEMPLATE_PLACEHOLDERS: {}, PATH_TEMPLATE_PLACEHOLDERS: {},
FILENAME_TEMPLATE_PLACEHOLDERS: [],
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
DEFAULT_PRIORITY_TAG_CONFIG: {}, DEFAULT_PRIORITY_TAG_CONFIG: {},
getMappableBaseModelsDynamic: () => [], getMappableBaseModelsDynamic: () => [],
})); }));
@@ -34,6 +34,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
DEFAULT_PATH_TEMPLATES: {}, DEFAULT_PATH_TEMPLATES: {},
MAPPABLE_BASE_MODELS: [], MAPPABLE_BASE_MODELS: [],
PATH_TEMPLATE_PLACEHOLDERS: {}, PATH_TEMPLATE_PLACEHOLDERS: {},
FILENAME_TEMPLATE_PLACEHOLDERS: [],
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
DEFAULT_PRIORITY_TAG_CONFIG: {}, DEFAULT_PRIORITY_TAG_CONFIG: {},
getMappableBaseModelsDynamic: () => [], getMappableBaseModelsDynamic: () => [],
})); }));
@@ -23,6 +23,7 @@ from py.services.metadata_sync_service import MetadataSyncService
from py.services.model_file_service import AutoOrganizeResult from py.services.model_file_service import AutoOrganizeResult
from py.services.model_update_service import ModelVersionRecord from py.services.model_update_service import ModelVersionRecord
from py.services.service_registry import ServiceRegistry from py.services.service_registry import ServiceRegistry
from py.services.use_cases import FilenameTemplateUseCase
from py.services.websocket_manager import ws_manager from py.services.websocket_manager import ws_manager
from py.utils.exif_utils import ExifUtils from py.utils.exif_utils import ExifUtils
from py.utils.metadata_manager import MetadataManager from py.utils.metadata_manager import MetadataManager
@@ -126,9 +127,11 @@ async def create_test_client(service) -> TestClient[Any, Any]:
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def reset_ws_manager_state(): def reset_ws_manager_state():
ws_manager.cleanup_auto_organize_progress() ws_manager.cleanup_auto_organize_progress()
ws_manager.cleanup_filename_template_progress()
ws_manager._download_progress.clear() ws_manager._download_progress.clear()
yield yield
ws_manager.cleanup_auto_organize_progress() ws_manager.cleanup_auto_organize_progress()
ws_manager.cleanup_filename_template_progress()
ws_manager._download_progress.clear() ws_manager._download_progress.clear()
@@ -762,6 +765,105 @@ def test_auto_organize_conflict_when_running(mock_service):
asyncio.run(scenario()) asyncio.run(scenario())
def test_apply_filename_template_route_emits_progress(
mock_service, monkeypatch: pytest.MonkeyPatch
):
async def fake_execute(self, file_paths=None, progress_callback=None):
result = AutoOrganizeResult()
result.total = 1
result.processed = 1
result.success_count = 1
result.operation_type = "filename_template"
if progress_callback is not None:
await progress_callback.on_progress(
{"type": "filename_template_progress", "status": "started"}
)
await progress_callback.on_progress(
{"type": "filename_template_progress", "status": "completed"}
)
return result
monkeypatch.setattr(FilenameTemplateUseCase, "execute", fake_execute)
async def scenario():
client = await create_test_client(mock_service)
try:
response = await client.post(
"/api/lm/test-models/apply-filename-template",
json={"file_paths": ["/tmp/a.safetensors"]},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert payload["summary"]["operation_type"] == "filename_template"
progress = ws_manager.get_filename_template_progress()
assert progress is not None
assert progress["status"] == "completed"
# Auto-organize progress state must stay untouched.
assert ws_manager.get_auto_organize_progress() is None
finally:
await client.close()
asyncio.run(scenario())
def test_apply_filename_template_get_parses_query_file_paths(
mock_service, monkeypatch: pytest.MonkeyPatch
):
captured = {}
async def fake_execute(self, file_paths=None, progress_callback=None):
captured["file_paths"] = file_paths
result = AutoOrganizeResult()
result.operation_type = "filename_template"
return result
monkeypatch.setattr(FilenameTemplateUseCase, "execute", fake_execute)
async def scenario():
client = await create_test_client(mock_service)
try:
response = await client.get(
"/api/lm/test-models/apply-filename-template",
params={"file_paths": "/tmp/a.safetensors, /tmp/b.safetensors"},
)
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert captured["file_paths"] == [
"/tmp/a.safetensors",
"/tmp/b.safetensors",
]
finally:
await client.close()
asyncio.run(scenario())
def test_apply_filename_template_conflict_when_running(mock_service):
async def scenario():
client = await create_test_client(mock_service)
try:
await ws_manager.broadcast_filename_template_progress(
{"type": "filename_template_progress", "status": "started"}
)
response = await client.post("/api/lm/test-models/apply-filename-template")
payload = await response.json()
assert response.status == 409
assert payload == {
"success": False,
"error": "Another library operation is already running. Please wait for it to complete.",
}
finally:
await client.close()
asyncio.run(scenario())
def test_download_model_returns_skipped_success(mock_service, download_manager_stub): def test_download_model_returns_skipped_success(mock_service, download_manager_stub):
async def scenario(): async def scenario():
@@ -0,0 +1,147 @@
"""Tests for the post-download filename template rename phase."""
import json
from pathlib import Path
import pytest
from py.services.download_manager import DownloadManager
from py.services.service_registry import ServiceRegistry
from py.services.settings_manager import get_settings_manager
class DummyScanner:
def __init__(self, root: Path):
self._root = root
self.model_type = "lora"
self.updates = []
def get_model_roots(self):
return [str(self._root)]
async def update_single_model_cache(self, original_path, new_path, metadata):
self.updates.append((original_path, new_path, metadata))
return True
@pytest.fixture
def download_manager() -> DownloadManager:
return DownloadManager()
@pytest.fixture(autouse=True)
def no_recipe_scanner(monkeypatch: pytest.MonkeyPatch):
async def _no_scanner():
return None
monkeypatch.setattr(ServiceRegistry, "get_recipe_scanner", _no_scanner)
def _set_template(template: str, model_type: str = "lora") -> None:
manager = get_settings_manager()
templates = dict(manager.settings.get("download_filename_templates") or {})
templates[model_type] = template
manager.settings["download_filename_templates"] = templates
def _write_model(root: Path, stem: str, model_name: str, sha256: str) -> Path:
model_path = root / f"{stem}.safetensors"
model_path.write_bytes(b"model")
metadata_path = root / f"{stem}.metadata.json"
metadata_path.write_text(
json.dumps(
{
"file_name": stem,
"file_path": model_path.as_posix(),
"model_name": model_name,
"sha256": sha256,
"civitai": {"id": 1},
}
)
)
return model_path
async def test_download_rename_applies_filename_template(
tmp_path: Path, download_manager: DownloadManager
):
_set_template("{model_name}-{hash_short}")
model_path = _write_model(tmp_path, "V1", "My Model", "abcdef0123456789")
download_manager._active_downloads["dl1"] = {"file_path": model_path.as_posix()}
downloaded_metadata = [
{
"file_path": model_path.as_posix(),
"file_name": "V1",
"model_name": "My Model",
"sha256": "abcdef0123456789",
"civitai": {"id": 1},
}
]
await download_manager._apply_download_filename_template(
scanner=DummyScanner(tmp_path),
model_type="lora",
downloaded_metadata=downloaded_metadata,
download_id="dl1",
)
new_path = tmp_path / "My Model-abcdef0123.safetensors"
assert new_path.exists()
assert not model_path.exists()
new_metadata = json.loads(
(tmp_path / "My Model-abcdef0123.metadata.json").read_text()
)
assert new_metadata["original_file_name"] == "V1"
assert (
download_manager._active_downloads["dl1"]["file_path"]
== new_path.as_posix()
)
async def test_download_rename_keeps_original_on_conflict(
tmp_path: Path, download_manager: DownloadManager
):
_set_template("{model_name}-{hash_short}")
model_path = _write_model(tmp_path, "V1", "My Model", "abcdef0123456789")
# Conflicting target already exists.
(tmp_path / "My Model-abcdef0123.safetensors").write_bytes(b"other")
downloaded_metadata = [
{
"file_path": model_path.as_posix(),
"file_name": "V1",
"model_name": "My Model",
"sha256": "abcdef0123456789",
"civitai": {"id": 1},
}
]
# Must not raise: a rename conflict never fails the download.
await download_manager._apply_download_filename_template(
scanner=DummyScanner(tmp_path),
model_type="lora",
downloaded_metadata=downloaded_metadata,
download_id=None,
)
assert model_path.exists()
async def test_download_rename_noop_without_template(
tmp_path: Path, download_manager: DownloadManager
):
_set_template("")
model_path = _write_model(tmp_path, "V1", "My Model", "abcdef0123456789")
await download_manager._apply_download_filename_template(
scanner=DummyScanner(tmp_path),
model_type="lora",
downloaded_metadata=[{"file_path": model_path.as_posix()}],
download_id=None,
)
assert model_path.exists()
assert (tmp_path / "V1.metadata.json").exists()
+64
View File
@@ -0,0 +1,64 @@
"""Tests for LoraService.get_cycler_list usage_tips exposure."""
import pytest
from unittest.mock import Mock, AsyncMock
from py.services.lora_service import LoraService
@pytest.fixture
def lora_service():
"""Create a LoraService instance with a mocked scanner cache."""
scanner = Mock()
cache_mock = Mock()
cache_mock.raw_data = [
{
"file_name": "with_tips.safetensors",
"folder": "sub",
"model_name": "With Tips",
"usage_tips": '{"strength": 0.6, "strength_min": 0.4, "strength_max": 0.8}',
},
{
"file_name": "empty_tips.safetensors",
"folder": "",
"model_name": "Empty Tips",
"usage_tips": "",
},
{
"file_name": "no_tips.safetensors",
"folder": "",
"model_name": "No Tips",
},
]
scanner.get_cached_data = AsyncMock(return_value=cache_mock)
return LoraService(scanner)
@pytest.mark.asyncio
async def test_cycler_list_includes_usage_tips_when_present(lora_service):
loras = await lora_service.get_cycler_list()
with_tips = next(l for l in loras if l["file_name"] == "sub/with_tips.safetensors")
assert with_tips["usage_tips"] == (
'{"strength": 0.6, "strength_min": 0.4, "strength_max": 0.8}'
)
@pytest.mark.asyncio
async def test_cycler_list_omits_usage_tips_when_empty_or_missing(lora_service):
loras = await lora_service.get_cycler_list()
empty_tips = next(l for l in loras if l["file_name"] == "empty_tips.safetensors")
no_tips = next(l for l in loras if l["file_name"] == "no_tips.safetensors")
assert "usage_tips" not in empty_tips
assert "usage_tips" not in no_tips
@pytest.mark.asyncio
async def test_cycler_list_keeps_existing_fields(lora_service):
loras = await lora_service.get_cycler_list()
with_tips = next(l for l in loras if l["model_name"] == "With Tips")
assert with_tips["file_name"] == "sub/with_tips.safetensors"
assert with_tips["folder"] == "sub"
assert with_tips["model_name"] == "With Tips"
@@ -424,6 +424,52 @@ async def test_rename_model_preserves_extension(tmp_path: Path):
assert payload["file_name"] == new_name assert payload["file_name"] == new_name
@pytest.mark.asyncio
async def test_rename_model_records_original_file_name(tmp_path: Path):
old_name = "V1"
new_name = "flux-my-model-v3"
model_path = tmp_path / f"{old_name}.safetensors"
model_path.write_bytes(b"model")
metadata_path = tmp_path / f"{old_name}.metadata.json"
metadata_payload = {
"file_name": old_name,
"file_path": model_path.as_posix(),
}
metadata_path.write_text(json.dumps(metadata_payload))
async def metadata_loader(path: str):
with open(path, "r", encoding="utf-8") as handle:
return json.load(handle)
service = ModelLifecycleService(
scanner=DummyScanner(),
metadata_manager=PassthroughMetadataManager(),
metadata_loader=metadata_loader,
)
await service.rename_model(
file_path=model_path.as_posix(),
new_file_name=new_name,
)
saved_metadata = json.loads((tmp_path / f"{new_name}.metadata.json").read_text())
assert saved_metadata["original_file_name"] == old_name
# A second rename keeps the very first recorded name.
second_name = "flux-my-model-v4"
await service.rename_model(
file_path=(tmp_path / f"{new_name}.safetensors").as_posix(),
new_file_name=second_name,
)
saved_metadata = json.loads(
(tmp_path / f"{second_name}.metadata.json").read_text()
)
assert saved_metadata["original_file_name"] == old_name
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_rename_model_with_dotted_basename(tmp_path: Path): async def test_rename_model_with_dotted_basename(tmp_path: Path):
old_name = "model.v1" old_name = "model.v1"
+232 -1
View File
@@ -19,6 +19,7 @@ from py.services.use_cases import (
DownloadModelEarlyAccessError, DownloadModelEarlyAccessError,
DownloadModelUseCase, DownloadModelUseCase,
DownloadModelValidationError, DownloadModelValidationError,
FilenameTemplateUseCase,
ImportExampleImagesUseCase, ImportExampleImagesUseCase,
ImportExampleImagesValidationError, ImportExampleImagesValidationError,
) )
@@ -33,7 +34,7 @@ from py.utils.example_images_processor import (
ExampleImagesValidationError, ExampleImagesValidationError,
) )
from py.utils.metadata_manager import MetadataManager from py.utils.metadata_manager import MetadataManager
from tests.conftest import MockModelService, MockScanner from tests.conftest import MockCache, MockModelService, MockScanner
class StubLockProvider: class StubLockProvider:
@@ -503,3 +504,233 @@ async def test_import_example_images_use_case_propagates_generic_error() -> None
with pytest.raises(ExampleImagesImportError): with pytest.raises(ExampleImagesImportError):
await use_case.execute(request) # pyright: ignore[reportArgumentType] await use_case.execute(request) # pyright: ignore[reportArgumentType]
class StubLifecycleService:
def __init__(self, scanner: Optional[MockScanner] = None) -> None:
self.renames: List[Dict[str, str]] = []
self.error: Optional[Exception] = None
self.cancel_on_rename = False
self._scanner = scanner
async def rename_model(self, *, file_path: str, new_file_name: str) -> Dict[str, Any]:
if self.error is not None:
raise self.error
self.renames.append({"file_path": file_path, "new_file_name": new_file_name})
if self.cancel_on_rename and self._scanner is not None:
self._scanner.cancel_task()
return {"success": True, "new_file_path": file_path}
def _filename_template_model(
file_path: str,
model_name: str,
sha256: str = "abcdef0123456789",
) -> Dict[str, Any]:
return {
"file_path": file_path,
"file_name": file_path.rsplit("/", 1)[-1].rsplit(".", 1)[0],
"model_name": model_name,
"sha256": sha256,
"civitai": {"id": 1},
}
def _set_filename_template(template: str, model_type: str = "lora") -> None:
from py.services.settings_manager import get_settings_manager
manager = get_settings_manager()
templates = dict(manager.settings.get("download_filename_templates") or {})
templates[model_type] = template
manager.settings["download_filename_templates"] = templates
def _make_filename_template_use_case(
scanner: MockScanner,
lifecycle: StubLifecycleService,
lock_provider: Optional[StubLockProvider] = None,
metadata_loader: Optional[Any] = None,
) -> FilenameTemplateUseCase:
kwargs: Dict[str, Any] = {}
if metadata_loader is not None:
kwargs["metadata_loader"] = metadata_loader
return FilenameTemplateUseCase(
scanner=scanner,
lifecycle_service=lifecycle, # pyright: ignore[reportArgumentType]
lock_provider=lock_provider or StubLockProvider(),
model_type="lora",
**kwargs,
)
async def test_filename_template_use_case_renames_models() -> None:
_set_filename_template("{model_name}-{hash_short}")
scanner = MockScanner(cache=MockCache([
_filename_template_model("/library/alpha.safetensors", "Alpha"),
_filename_template_model("/library/beta.safetensors", "Beta"),
]))
lifecycle = StubLifecycleService()
progress = ProgressCollector()
use_case = _make_filename_template_use_case(scanner, lifecycle)
result = await use_case.execute(progress_callback=progress)
assert result.status == "success"
assert result.operation_type == "filename_template"
assert result.total == 2
assert result.success_count == 2
assert result.failure_count == 0
assert lifecycle.renames == [
{"file_path": "/library/alpha.safetensors", "new_file_name": "Alpha-abcdef0123"},
{"file_path": "/library/beta.safetensors", "new_file_name": "Beta-abcdef0123"},
]
statuses = [event["status"] for event in progress.events]
assert statuses[0] == "started"
assert statuses[-1] == "completed"
assert all(event["type"] == "filename_template_progress" for event in progress.events)
async def test_filename_template_use_case_skips_unchanged_names() -> None:
_set_filename_template("{model_name}-{hash_short}")
scanner = MockScanner(cache=MockCache([
_filename_template_model("/library/Alpha-abcdef0123.safetensors", "Alpha"),
]))
lifecycle = StubLifecycleService()
use_case = _make_filename_template_use_case(scanner, lifecycle)
result = await use_case.execute(progress_callback=None)
assert result.success_count == 0
assert result.skipped_count == 1
assert lifecycle.renames == []
async def test_filename_template_use_case_reverts_to_recorded_original_when_template_empty() -> None:
_set_filename_template("")
scanner = MockScanner(cache=MockCache([
_filename_template_model("/library/alpha-renamed.safetensors", "Alpha"),
_filename_template_model("/library/beta.safetensors", "Beta"),
]))
lifecycle = StubLifecycleService()
async def metadata_loader(metadata_path: str) -> Dict[str, Any]:
if metadata_path == "/library/alpha-renamed.metadata.json":
return {"original_file_name": "alpha-original"}
return {}
use_case = _make_filename_template_use_case(
scanner, lifecycle, metadata_loader=metadata_loader
)
result = await use_case.execute(progress_callback=None)
assert result.success_count == 1
assert result.skipped_count == 1
assert lifecycle.renames == [
{
"file_path": "/library/alpha-renamed.safetensors",
"new_file_name": "alpha-original",
}
]
async def test_filename_template_use_case_skips_revert_without_recorded_original() -> None:
_set_filename_template("")
scanner = MockScanner(cache=MockCache([
_filename_template_model("/library/alpha.safetensors", "Alpha"),
]))
lifecycle = StubLifecycleService()
use_case = _make_filename_template_use_case(scanner, lifecycle)
result = await use_case.execute(progress_callback=None)
assert result.skipped_count == 1
assert lifecycle.renames == []
async def test_filename_template_use_case_skips_revert_matching_current_name() -> None:
_set_filename_template("")
scanner = MockScanner(cache=MockCache([
_filename_template_model("/library/alpha.safetensors", "Alpha"),
]))
lifecycle = StubLifecycleService()
async def metadata_loader(metadata_path: str) -> Dict[str, Any]:
return {"original_file_name": "alpha"}
use_case = _make_filename_template_use_case(
scanner, lifecycle, metadata_loader=metadata_loader
)
result = await use_case.execute(progress_callback=None)
assert result.success_count == 0
assert result.skipped_count == 1
assert lifecycle.renames == []
async def test_filename_template_use_case_counts_conflicts_as_failures() -> None:
_set_filename_template("{model_name}")
scanner = MockScanner(cache=MockCache([
_filename_template_model("/library/alpha.safetensors", "Alpha"),
_filename_template_model("/library/beta.safetensors", "Beta"),
]))
lifecycle = StubLifecycleService()
lifecycle.error = ValueError("A file with this name already exists")
use_case = _make_filename_template_use_case(scanner, lifecycle)
result = await use_case.execute(progress_callback=None)
assert result.status == "success"
assert result.failure_count == 2
assert result.success_count == 0
assert len(result.results) == 2
async def test_filename_template_use_case_honours_cancellation() -> None:
_set_filename_template("{model_name}-{hash_short}")
scanner = MockScanner(cache=MockCache([
_filename_template_model("/library/alpha.safetensors", "Alpha"),
_filename_template_model("/library/beta.safetensors", "Beta"),
]))
lifecycle = StubLifecycleService(scanner=scanner)
lifecycle.cancel_on_rename = True
progress = ProgressCollector()
use_case = _make_filename_template_use_case(scanner, lifecycle)
result = await use_case.execute(progress_callback=progress)
assert result.status == "cancelled"
assert len(lifecycle.renames) == 1
assert progress.events[-1]["status"] == "cancelled"
async def test_filename_template_use_case_filters_file_paths() -> None:
_set_filename_template("{model_name}-{hash_short}")
scanner = MockScanner(cache=MockCache([
_filename_template_model("/library/alpha.safetensors", "Alpha"),
_filename_template_model("/library/beta.safetensors", "Beta"),
]))
lifecycle = StubLifecycleService()
use_case = _make_filename_template_use_case(scanner, lifecycle)
result = await use_case.execute(
file_paths=["/library/beta.safetensors"], progress_callback=None
)
assert result.total == 1
assert lifecycle.renames == [
{"file_path": "/library/beta.safetensors", "new_file_name": "Beta-abcdef0123"}
]
async def test_filename_template_use_case_rejects_when_lock_held() -> None:
_set_filename_template("{model_name}")
scanner = MockScanner(cache=MockCache())
lifecycle = StubLifecycleService()
lock_provider = StubLockProvider()
lock_provider.running = True
use_case = _make_filename_template_use_case(scanner, lifecycle, lock_provider)
with pytest.raises(AutoOrganizeInProgressError):
await use_case.execute(progress_callback=None)
+140
View File
@@ -3,6 +3,7 @@ import pytest
from py.services.settings_manager import SettingsManager, get_settings_manager from py.services.settings_manager import SettingsManager, get_settings_manager
from py.services.service_registry import ServiceRegistry from py.services.service_registry import ServiceRegistry
from py.utils.utils import ( from py.utils.utils import (
calculate_filename_for_model,
calculate_recipe_fingerprint, calculate_recipe_fingerprint,
calculate_relative_path_for_model, calculate_relative_path_for_model,
get_lora_info, get_lora_info,
@@ -164,6 +165,145 @@ def test_calculate_recipe_fingerprint_empty_input():
assert calculate_recipe_fingerprint([]) == "" assert calculate_recipe_fingerprint([]) == ""
def _set_filename_templates(isolated_settings, template, model_types=("lora", "checkpoint", "embedding")):
isolated_settings["download_filename_templates"] = {
model_type: template for model_type in model_types
}
def test_calculate_filename_returns_empty_without_template(isolated_settings):
model_data = {"model_name": "Model", "file_path": "/models/V1.safetensors"}
assert calculate_filename_for_model(model_data, "lora") == ""
def test_calculate_filename_substitutes_all_placeholders(isolated_settings):
_set_filename_templates(
isolated_settings,
"{base_model}-{model_name}-{version_name}-{author}-{first_tag}-{hash_short}-{original_name}",
)
model_data = {
"model_name": "My Model",
"base_model": "SDXL",
"tags": ["Style"],
"sha256": "ABCDEF0123456789",
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1, "name": "v3", "creator": {"username": "Author"}},
}
result = calculate_filename_for_model(model_data, "lora")
assert result == "SDXL-My Model-v3-Author-style-abcdef0123-V1"
def test_calculate_filename_hash_short_empty_when_unknown(isolated_settings):
_set_filename_templates(isolated_settings, "{model_name}-{hash_short}")
model_data = {
"model_name": "My Model",
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1},
}
# Missing hash leaves an empty segment; the dangling separator collapses.
assert calculate_filename_for_model(model_data, "lora") == "My Model"
def test_calculate_filename_missing_metadata_produces_empty_segments(isolated_settings):
_set_filename_templates(isolated_settings, "{base_model}-{model_name}")
model_data = {
"model_name": "My Model",
"base_model": "",
"tags": [],
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1},
}
assert calculate_filename_for_model(model_data, "lora") == "My Model"
def test_calculate_filename_rejects_path_separators(isolated_settings):
_set_filename_templates(isolated_settings, "{base_model}/{model_name}")
model_data = {
"model_name": "My Model",
"base_model": "SDXL",
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1},
}
assert calculate_filename_for_model(model_data, "lora") == ""
_set_filename_templates(isolated_settings, "{base_model}\\{model_name}")
assert calculate_filename_for_model(model_data, "lora") == ""
def test_calculate_filename_strips_illegal_characters(isolated_settings):
_set_filename_templates(isolated_settings, '{model_name}:"custom"')
model_data = {
"model_name": "My:Model*",
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1},
}
result = calculate_filename_for_model(model_data, "lora")
assert result == "My_Modelcustom"
def test_calculate_filename_empty_result_returns_empty(isolated_settings):
_set_filename_templates(isolated_settings, "{base_model}-{first_tag}")
model_data = {
"base_model": "",
"tags": [],
"file_path": "/models/V1.safetensors",
}
assert calculate_filename_for_model(model_data, "lora") == ""
def test_calculate_filename_uses_base_model_mapping(isolated_settings):
_set_filename_templates(isolated_settings, "{base_model}-{model_name}")
isolated_settings["base_model_path_mappings"] = {"SDXL": "sdxl-mapped"}
model_data = {
"model_name": "Model",
"base_model": "SDXL",
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1},
}
assert calculate_filename_for_model(model_data, "lora") == "sdxl-mapped-Model"
def test_calculate_filename_embedding_replaces_spaces(isolated_settings):
_set_filename_templates(isolated_settings, "{base_model} {model_name}")
model_data = {
"model_name": "My Model",
"base_model": "Base Model",
"file_path": "/models/V1.safetensors",
"civitai": {"id": 1},
}
assert calculate_filename_for_model(model_data, "embedding") == "Base_Model_My_Model"
def test_calculate_filename_original_name_falls_back_to_file_name(isolated_settings):
_set_filename_templates(isolated_settings, "{original_name}-{hash_short}")
model_data = {
"file_name": "legacy-name",
"sha256": "0123456789abcdef",
}
assert calculate_filename_for_model(model_data, "lora") == "legacy-name-0123456789"
@pytest.mark.parametrize( @pytest.mark.parametrize(
"original, expected", "original, expected",
[ [
+12
View File
@@ -309,6 +309,18 @@
box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.3); box-shadow: 0 0 0 2px rgba(66, 153, 225, 0.3);
} }
/* Advisory cue: strength is outside the usage-tips recommended range.
Amber to distinguish it from the red "missing LoRA" error state. */
.lm-lora-strength-input.lm-strength-out-of-range {
border-color: rgba(245, 158, 11, 0.75);
color: rgba(252, 211, 77, 0.95);
}
.lm-lora-strength-input.lm-strength-out-of-range:focus {
border-color: rgba(245, 158, 11, 0.9);
box-shadow: 0 0 0 2px rgba(245, 158, 11, 0.3);
}
.lm-lora-strength-buttons { .lm-lora-strength-buttons {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
+4
View File
@@ -7,6 +7,8 @@ import {
syncClipStrengthIfCollapsed, syncClipStrengthIfCollapsed,
getAvailableLoras, getAvailableLoras,
getAvailableLorasSync, getAvailableLorasSync,
getLoraStrengthRange,
applyStrengthRangeCue,
isLoraNameAvailable, isLoraNameAvailable,
onLibraryChanged onLibraryChanged
} from "./loras_widget_utils.js"; } from "./loras_widget_utils.js";
@@ -521,6 +523,7 @@ export function addLorasWidget(node, name, opts, callback) {
strengthEl.classList.add("lm-lora-strength-input"); strengthEl.classList.add("lm-lora-strength-input");
strengthEl.type = "text"; strengthEl.type = "text";
strengthEl.value = typeof strength === 'number' ? strength.toFixed(2) : Number(strength).toFixed(2); strengthEl.value = typeof strength === 'number' ? strength.toFixed(2) : Number(strength).toFixed(2);
applyStrengthRangeCue(strengthEl, strength, getLoraStrengthRange(name));
strengthEl.addEventListener('pointerdown', () => { strengthEl.addEventListener('pointerdown', () => {
pendingFocusTarget = { name, type: "strength" }; pendingFocusTarget = { name, type: "strength" };
}); });
@@ -650,6 +653,7 @@ export function addLorasWidget(node, name, opts, callback) {
clipStrengthEl.classList.add("lm-lora-strength-input", "lm-lora-clip-strength-input"); clipStrengthEl.classList.add("lm-lora-strength-input", "lm-lora-clip-strength-input");
clipStrengthEl.type = "text"; clipStrengthEl.type = "text";
clipStrengthEl.value = typeof clipStrength === 'number' ? clipStrength.toFixed(2) : Number(clipStrength).toFixed(2); clipStrengthEl.value = typeof clipStrength === 'number' ? clipStrength.toFixed(2) : Number(clipStrength).toFixed(2);
applyStrengthRangeCue(clipStrengthEl, clipStrength, getLoraStrengthRange(name));
clipStrengthEl.addEventListener('pointerdown', () => { clipStrengthEl.addEventListener('pointerdown', () => {
pendingFocusTarget = { name, type: "clip" }; pendingFocusTarget = { name, type: "clip" };
}); });
+6 -1
View File
@@ -1,7 +1,7 @@
import { api } from "../../scripts/api.js"; import { api } from "../../scripts/api.js";
import { app } from "../../scripts/app.js"; import { app } from "../../scripts/app.js";
import { createMenuItem, createDropIndicator } from "./loras_widget_components.js"; import { createMenuItem, createDropIndicator } from "./loras_widget_components.js";
import { parseLoraValue, formatLoraValue, syncClipStrengthIfCollapsed, saveRecipeDirectly, copyToClipboard, showToast, moveLoraByDirection, getDropTargetIndex } from "./loras_widget_utils.js"; import { parseLoraValue, formatLoraValue, syncClipStrengthIfCollapsed, saveRecipeDirectly, copyToClipboard, showToast, moveLoraByDirection, getDropTargetIndex, getLoraStrengthRange, applyStrengthRangeCue } from "./loras_widget_utils.js";
// Function to handle strength adjustment via dragging // Function to handle strength adjustment via dragging
export function handleStrengthDrag(name, initialStrength, initialX, event, widget, isClipStrength = false, updateWidget = true) { export function handleStrengthDrag(name, initialStrength, initialX, event, widget, isClipStrength = false, updateWidget = true) {
@@ -194,6 +194,7 @@ export function initDrag(
const strengthInput = currentDragElement.querySelector('.lm-lora-strength-input'); const strengthInput = currentDragElement.querySelector('.lm-lora-strength-input');
if (strengthInput && typeof newStrength === 'number') { if (strengthInput && typeof newStrength === 'number') {
strengthInput.value = newStrength.toFixed(2); strengthInput.value = newStrength.toFixed(2);
applyStrengthRangeCue(strengthInput, newStrength, getLoraStrengthRange(name));
} }
// Prevent showing the preview tooltip during drag // Prevent showing the preview tooltip during drag
@@ -335,6 +336,10 @@ export function initHeaderDrag(headerEl, widget, renderFunction) {
if (lorasData[index]) { if (lorasData[index]) {
input.value = lorasData[index].strength.toFixed(2); input.value = lorasData[index].strength.toFixed(2);
} }
const entryEl = input.closest('[data-lora-name]');
if (entryEl) {
applyStrengthRangeCue(input, input.value, getLoraStrengthRange(entryEl.dataset.loraName));
}
}); });
}); });
+188 -2
View File
@@ -88,6 +88,158 @@ let availableLorasCache = null;
let availableLorasPromise = null; let availableLorasPromise = null;
let availabilityGeneration = 0; let availabilityGeneration = 0;
/**
* Parse a numeric usage-tips value (accepts numbers and numeric strings).
*/
function parseUsageTipNumber(value) {
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
const parsed = parseFloat(value);
if (Number.isFinite(parsed)) {
return parsed;
}
}
return null;
}
/**
* Parse a "x.x-y.y" range string, tolerating whitespace and negatives.
*/
function parseRangeString(value) {
if (typeof value !== "string") {
return null;
}
const match = value.trim().match(/^(-?\d+(?:\.\d+)?)\s*-\s*(-?\d+(?:\.\d+)?)$/);
if (!match) {
return null;
}
return { min: parseFloat(match[1]), max: parseFloat(match[2]) };
}
/**
* Extract the recommended strength range from a usage_tips payload (JSON
* string or already-parsed object). Explicit strength_min/strength_max keys
* take precedence over the strength_range "x.x-y.y" shorthand. Returns
* { min, max, recommended } with null bounds for open-ended ranges, or null
* when no valid range is configured.
*/
export function parseStrengthRange(usageTips) {
let tips = usageTips;
if (typeof tips === "string") {
if (!tips.trim()) {
return null;
}
try {
tips = JSON.parse(tips);
} catch {
return null;
}
}
if (!tips || typeof tips !== "object") {
return null;
}
let min = parseUsageTipNumber(tips.strength_min ?? tips.strengthMin);
let max = parseUsageTipNumber(tips.strength_max ?? tips.strengthMax);
if (min === null || max === null) {
const parsed = parseRangeString(tips.strength_range ?? tips.strengthRange);
if (parsed) {
if (min === null) min = parsed.min;
if (max === null) max = parsed.max;
}
}
if (min === null && max === null) {
return null;
}
if (min !== null && max !== null && min > max) {
return null;
}
return { min, max, recommended: parseUsageTipNumber(tips.strength) };
}
/**
* Describe how a strength value violates the recommended range, or return
* null when it is inside (or no range is configured). The returned string is
* used as the input's tooltip.
*/
export function describeStrengthRangeViolation(value, range) {
if (!range) {
return null;
}
const numeric = Number(value);
if (!Number.isFinite(numeric)) {
return null;
}
const { min, max, recommended } = range;
let direction = null;
if (min !== null && numeric < min) {
direction = "Below";
} else if (max !== null && numeric > max) {
direction = "Above";
}
if (!direction) {
return null;
}
let rangeText;
if (min !== null && max !== null) {
rangeText = `${min.toFixed(2)}\u2013${max.toFixed(2)}`;
} else if (min !== null) {
rangeText = `\u2265 ${min.toFixed(2)}`;
} else {
rangeText = `\u2264 ${max.toFixed(2)}`;
}
let text = `${direction} recommended strength range (${rangeText})`;
if (recommended !== null) {
text += `; recommended: ${recommended.toFixed(2)}`;
}
return text;
}
/**
* Toggle the out-of-range visual cue on a strength input.
*/
export function applyStrengthRangeCue(inputEl, value, range) {
const message = describeStrengthRangeViolation(value, range);
inputEl.classList.toggle("lm-strength-out-of-range", message !== null);
if (message) {
inputEl.title = message;
} else {
inputEl.removeAttribute("title");
}
}
/**
* Build the lookup map of recommended strength ranges from cycler-list
* entries. Keyed like the availability set (normalized path and basename).
*/
export function buildStrengthRangeMap(loras) {
const map = new Map();
for (const lora of loras || []) {
const range = parseStrengthRange(lora?.usage_tips);
if (!range) {
continue;
}
const normalized = normalizeLoraNameKey(lora?.file_name);
if (!normalized) {
continue;
}
map.set(normalized, range);
const slash = normalized.lastIndexOf("/");
if (slash >= 0) {
map.set(normalized.slice(slash + 1), range);
}
}
return map;
}
async function refreshAvailableLoras() { async function refreshAvailableLoras() {
const generation = availabilityGeneration; const generation = availabilityGeneration;
try { try {
@@ -100,16 +252,18 @@ async function refreshAvailableLoras() {
return null; return null;
} }
const data = await response.json(); const data = await response.json();
const paths = (data?.loras || []) const loras = data?.loras || [];
const paths = loras
.map((lora) => lora?.file_name) .map((lora) => lora?.file_name)
.filter(Boolean); .filter(Boolean);
const set = buildAvailableLoraSet(paths); const set = buildAvailableLoraSet(paths);
const ranges = buildStrengthRangeMap(loras);
if (generation !== availabilityGeneration) { if (generation !== availabilityGeneration) {
// Stale response: the cache was invalidated while this fetch was in // Stale response: the cache was invalidated while this fetch was in
// flight, do not repopulate it with pre-change data. // flight, do not repopulate it with pre-change data.
return null; return null;
} }
availableLorasCache = { set, at: Date.now() }; availableLorasCache = { set, ranges, at: Date.now() };
return set; return set;
} catch (error) { } catch (error) {
console.warn("Failed to fetch available LoRAs:", error); console.warn("Failed to fetch available LoRAs:", error);
@@ -151,6 +305,38 @@ export function getAvailableLorasSync() {
return null; return null;
} }
/**
* Synchronous lookup of a LoRA's recommended strength range from the cached
* cycler-list data. Mirrors isLoraNameAvailable's matching: basename fallback
* for folder-qualified names, and null for absolute paths or while the cache
* is not loaded (no cue is shown in those cases).
*/
export function getLoraStrengthRange(name) {
if (
!availableLorasCache ||
Date.now() - availableLorasCache.at >= AVAILABLE_LORAS_TTL_MS
) {
return null;
}
const ranges = availableLorasCache.ranges;
if (!ranges || ranges.size === 0) {
return null;
}
const normalized = String(name || "").replace(/\\/g, "/");
if (normalized.startsWith("/") || /^[A-Za-z]:\//.test(normalized)) {
return null;
}
const key = normalizeLoraNameKey(name);
if (ranges.has(key)) {
return ranges.get(key);
}
const slash = key.lastIndexOf("/");
if (slash >= 0) {
return ranges.get(key.slice(slash + 1)) || null;
}
return null;
}
/** /**
* Drop the cached availability data (used by tests and by callers that need * Drop the cached availability data (used by tests and by callers that need
* a forced refresh of the local library state). In-flight fetches started * a forced refresh of the local library state). In-flight fetches started