diff --git a/locales/de.json b/locales/de.json index 2ca2b2cf..54a5eadf 100644 --- a/locales/de.json +++ b/locales/de.json @@ -681,6 +681,22 @@ "validTemplate": "Gültige Vorlage" } }, + "filenameTemplates": { + "title": "[TODO: Translate] Filename Templates", + "help": "[TODO: Translate] Configure filenames for downloaded models per model type. Leave empty to keep the original filename. The original filename is always preserved in the model's metadata.", + "availablePlaceholders": "[TODO: Translate] Available placeholders:", + "templatePlaceholder": "[TODO: Translate] Enter filename template (e.g., {base_model}-{model_name}-{version_name})", + "applyButton": "[TODO: Translate] Apply to Library Now", + "applyHelp": "[TODO: Translate] Renames all existing files of this model type according to the template. 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": "[TODO: Translate] 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.", + "emptyTemplateInfo": "[TODO: Translate] No filename template configured for this model type — original filenames are kept, nothing to apply.", + "validation": { + "keepOriginal": "[TODO: Translate] Valid (keep original filename)", + "invalidChars": "[TODO: Translate] Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)", + "invalidPlaceholder": "[TODO: Translate] Invalid placeholder: {placeholder}", + "validTemplate": "[TODO: Translate] Valid template" + } + }, "exampleImages": { "downloadLocation": "Download-Speicherort", "downloadLocationPlaceholder": "Ordnerpfad für Beispielbilder eingeben", @@ -913,6 +929,14 @@ "complete": "Automatische Organisation abgeschlossen", "error": "Fehler: {error}" }, + "filenameTemplateProgress": { + "initializing": "[TODO: Translate] Initializing filename template apply...", + "starting": "[TODO: Translate] Applying filename template to {type}...", + "processing": "[TODO: Translate] Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed", + "completed": "[TODO: Translate] Completed: {success} renamed, {skipped} skipped, {failures} failed", + "complete": "[TODO: Translate] Filename template apply complete", + "error": "[TODO: Translate] Error: {error}" + }, "enrichHfAgent": "Metadaten mit KI anreichern" }, "contextMenu": { @@ -2311,6 +2335,9 @@ "autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen", "autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen", "autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}", + "filenameTemplateSuccess": "[TODO: Translate] Filename template applied successfully for {count} {type}", + "filenameTemplatePartialSuccess": "[TODO: Translate] Filename template applied with {success} renamed, {failures} failed out of {total} models", + "filenameTemplateFailed": "[TODO: Translate] Applying filename template failed: {error}", "noModelsSelected": "Keine Modelle ausgewählt" }, "recipes": { @@ -2477,6 +2504,8 @@ "mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}", "downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert", "downloadTemplatesFailed": "Fehler beim Speichern der Download-Pfad-Vorlagen: {message}", + "filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated", + "filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}", "recipesPathUpdated": "Rezepte-Speicherpfad aktualisiert", "recipesPathSaveFailed": "Fehler beim Aktualisieren des Rezepte-Speicherpfads: {message}", "settingsUpdated": "Einstellungen aktualisiert: {setting}", diff --git a/locales/en.json b/locales/en.json index 89f51c08..5281285c 100644 --- a/locales/en.json +++ b/locales/en.json @@ -681,6 +681,22 @@ "validTemplate": "Valid template" } }, + "filenameTemplates": { + "title": "Filename Templates", + "help": "Configure filenames for downloaded models per model type. Leave empty to keep the original filename. 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. 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.", + "emptyTemplateInfo": "No filename template configured for this model type — original filenames are kept, nothing to apply.", + "validation": { + "keepOriginal": "Valid (keep original filename)", + "invalidChars": "Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)", + "invalidPlaceholder": "Invalid placeholder: {placeholder}", + "validTemplate": "Valid template" + } + }, "exampleImages": { "downloadLocation": "Download Location", "downloadLocationPlaceholder": "Enter folder path for example images", @@ -913,6 +929,14 @@ "complete": "Auto-organize complete", "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" }, "contextMenu": { @@ -2311,6 +2335,9 @@ "autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}", "autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models", "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" }, "recipes": { @@ -2477,6 +2504,8 @@ "mappingSaveFailed": "Failed to save base model mappings: {message}", "downloadTemplatesUpdated": "Download path templates updated", "downloadTemplatesFailed": "Failed to save download path templates: {message}", + "filenameTemplatesUpdated": "Filename templates updated", + "filenameTemplatesFailed": "Failed to save filename templates: {message}", "recipesPathUpdated": "Recipes storage path updated", "recipesPathSaveFailed": "Failed to update recipes storage path: {message}", "settingsUpdated": "Settings updated: {setting}", diff --git a/locales/es.json b/locales/es.json index b10b029f..b8e14412 100644 --- a/locales/es.json +++ b/locales/es.json @@ -681,6 +681,22 @@ "validTemplate": "Plantilla válida" } }, + "filenameTemplates": { + "title": "[TODO: Translate] Filename Templates", + "help": "[TODO: Translate] Configure filenames for downloaded models per model type. Leave empty to keep the original filename. The original filename is always preserved in the model's metadata.", + "availablePlaceholders": "[TODO: Translate] Available placeholders:", + "templatePlaceholder": "[TODO: Translate] Enter filename template (e.g., {base_model}-{model_name}-{version_name})", + "applyButton": "[TODO: Translate] Apply to Library Now", + "applyHelp": "[TODO: Translate] Renames all existing files of this model type according to the template. 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": "[TODO: Translate] 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.", + "emptyTemplateInfo": "[TODO: Translate] No filename template configured for this model type — original filenames are kept, nothing to apply.", + "validation": { + "keepOriginal": "[TODO: Translate] Valid (keep original filename)", + "invalidChars": "[TODO: Translate] Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)", + "invalidPlaceholder": "[TODO: Translate] Invalid placeholder: {placeholder}", + "validTemplate": "[TODO: Translate] Valid template" + } + }, "exampleImages": { "downloadLocation": "Ubicación de descarga", "downloadLocationPlaceholder": "Introduce la ruta de la carpeta para imágenes de ejemplo", @@ -913,6 +929,14 @@ "complete": "Auto-organización completada", "error": "Error: {error}" }, + "filenameTemplateProgress": { + "initializing": "[TODO: Translate] Initializing filename template apply...", + "starting": "[TODO: Translate] Applying filename template to {type}...", + "processing": "[TODO: Translate] Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed", + "completed": "[TODO: Translate] Completed: {success} renamed, {skipped} skipped, {failures} failed", + "complete": "[TODO: Translate] Filename template apply complete", + "error": "[TODO: Translate] Error: {error}" + }, "enrichHfAgent": "Enriquecer metadatos con IA" }, "contextMenu": { @@ -2311,6 +2335,9 @@ "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", "autoOrganizeFailed": "Auto-organización fallida: {error}", + "filenameTemplateSuccess": "[TODO: Translate] Filename template applied successfully for {count} {type}", + "filenameTemplatePartialSuccess": "[TODO: Translate] Filename template applied with {success} renamed, {failures} failed out of {total} models", + "filenameTemplateFailed": "[TODO: Translate] Applying filename template failed: {error}", "noModelsSelected": "No hay modelos seleccionados" }, "recipes": { @@ -2477,6 +2504,8 @@ "mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}", "downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas", "downloadTemplatesFailed": "Error al guardar plantillas de rutas de descarga: {message}", + "filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated", + "filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}", "recipesPathUpdated": "Ruta de almacenamiento de recetas actualizada", "recipesPathSaveFailed": "Error al actualizar la ruta de almacenamiento de recetas: {message}", "settingsUpdated": "Configuración actualizada: {setting}", diff --git a/locales/fr.json b/locales/fr.json index 6ca84f89..649aa2dd 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -681,6 +681,22 @@ "validTemplate": "Modèle valide" } }, + "filenameTemplates": { + "title": "[TODO: Translate] Filename Templates", + "help": "[TODO: Translate] Configure filenames for downloaded models per model type. Leave empty to keep the original filename. The original filename is always preserved in the model's metadata.", + "availablePlaceholders": "[TODO: Translate] Available placeholders:", + "templatePlaceholder": "[TODO: Translate] Enter filename template (e.g., {base_model}-{model_name}-{version_name})", + "applyButton": "[TODO: Translate] Apply to Library Now", + "applyHelp": "[TODO: Translate] Renames all existing files of this model type according to the template. 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": "[TODO: Translate] 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.", + "emptyTemplateInfo": "[TODO: Translate] No filename template configured for this model type — original filenames are kept, nothing to apply.", + "validation": { + "keepOriginal": "[TODO: Translate] Valid (keep original filename)", + "invalidChars": "[TODO: Translate] Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)", + "invalidPlaceholder": "[TODO: Translate] Invalid placeholder: {placeholder}", + "validTemplate": "[TODO: Translate] Valid template" + } + }, "exampleImages": { "downloadLocation": "Emplacement de téléchargement", "downloadLocationPlaceholder": "Entrez le chemin du dossier pour les images d'exemple", @@ -913,6 +929,14 @@ "complete": "Auto-organisation terminée", "error": "Erreur : {error}" }, + "filenameTemplateProgress": { + "initializing": "[TODO: Translate] Initializing filename template apply...", + "starting": "[TODO: Translate] Applying filename template to {type}...", + "processing": "[TODO: Translate] Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed", + "completed": "[TODO: Translate] Completed: {success} renamed, {skipped} skipped, {failures} failed", + "complete": "[TODO: Translate] Filename template apply complete", + "error": "[TODO: Translate] Error: {error}" + }, "enrichHfAgent": "Enrichir les métadonnées avec l'IA" }, "contextMenu": { @@ -2311,6 +2335,9 @@ "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", "autoOrganizeFailed": "Échec de l'auto-organisation : {error}", + "filenameTemplateSuccess": "[TODO: Translate] Filename template applied successfully for {count} {type}", + "filenameTemplatePartialSuccess": "[TODO: Translate] Filename template applied with {success} renamed, {failures} failed out of {total} models", + "filenameTemplateFailed": "[TODO: Translate] Applying filename template failed: {error}", "noModelsSelected": "Aucun modèle sélectionné" }, "recipes": { @@ -2477,6 +2504,8 @@ "mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}", "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}", + "filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated", + "filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}", "recipesPathUpdated": "Chemin de stockage des Recipes mis à jour", "recipesPathSaveFailed": "Échec de la mise à jour du chemin de stockage des Recipes : {message}", "settingsUpdated": "Paramètres mis à jour : {setting}", diff --git a/locales/he.json b/locales/he.json index 734f964f..0ebafda1 100644 --- a/locales/he.json +++ b/locales/he.json @@ -681,6 +681,22 @@ "validTemplate": "תבנית תקינה" } }, + "filenameTemplates": { + "title": "[TODO: Translate] Filename Templates", + "help": "[TODO: Translate] Configure filenames for downloaded models per model type. Leave empty to keep the original filename. The original filename is always preserved in the model's metadata.", + "availablePlaceholders": "[TODO: Translate] Available placeholders:", + "templatePlaceholder": "[TODO: Translate] Enter filename template (e.g., {base_model}-{model_name}-{version_name})", + "applyButton": "[TODO: Translate] Apply to Library Now", + "applyHelp": "[TODO: Translate] Renames all existing files of this model type according to the template. 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": "[TODO: Translate] 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.", + "emptyTemplateInfo": "[TODO: Translate] No filename template configured for this model type — original filenames are kept, nothing to apply.", + "validation": { + "keepOriginal": "[TODO: Translate] Valid (keep original filename)", + "invalidChars": "[TODO: Translate] Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)", + "invalidPlaceholder": "[TODO: Translate] Invalid placeholder: {placeholder}", + "validTemplate": "[TODO: Translate] Valid template" + } + }, "exampleImages": { "downloadLocation": "מיקום הורדה", "downloadLocationPlaceholder": "הזן נתיב תיקייה לתמונות דוגמה", @@ -913,6 +929,14 @@ "complete": "ארגון אוטומטי הושלם", "error": "שגיאה: {error}" }, + "filenameTemplateProgress": { + "initializing": "[TODO: Translate] Initializing filename template apply...", + "starting": "[TODO: Translate] Applying filename template to {type}...", + "processing": "[TODO: Translate] Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed", + "completed": "[TODO: Translate] Completed: {success} renamed, {skipped} skipped, {failures} failed", + "complete": "[TODO: Translate] Filename template apply complete", + "error": "[TODO: Translate] Error: {error}" + }, "enrichHfAgent": "העשרת מטא-נתונים ב-AI" }, "contextMenu": { @@ -2311,6 +2335,9 @@ "autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}", "autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים", "autoOrganizeFailed": "הארגון האוטומטי נכשל: {error}", + "filenameTemplateSuccess": "[TODO: Translate] Filename template applied successfully for {count} {type}", + "filenameTemplatePartialSuccess": "[TODO: Translate] Filename template applied with {success} renamed, {failures} failed out of {total} models", + "filenameTemplateFailed": "[TODO: Translate] Applying filename template failed: {error}", "noModelsSelected": "לא נבחרו מודלים" }, "recipes": { @@ -2477,6 +2504,8 @@ "mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}", "downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו", "downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}", + "filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated", + "filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}", "recipesPathUpdated": "נתיב אחסון המתכונים עודכן", "recipesPathSaveFailed": "עדכון נתיב אחסון המתכונים נכשל: {message}", "settingsUpdated": "הגדרות עודכנו: {setting}", diff --git a/locales/ja.json b/locales/ja.json index 9b07511a..30a2b934 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -681,6 +681,22 @@ "validTemplate": "有効なテンプレート" } }, + "filenameTemplates": { + "title": "[TODO: Translate] Filename Templates", + "help": "[TODO: Translate] Configure filenames for downloaded models per model type. Leave empty to keep the original filename. The original filename is always preserved in the model's metadata.", + "availablePlaceholders": "[TODO: Translate] Available placeholders:", + "templatePlaceholder": "[TODO: Translate] Enter filename template (e.g., {base_model}-{model_name}-{version_name})", + "applyButton": "[TODO: Translate] Apply to Library Now", + "applyHelp": "[TODO: Translate] Renames all existing files of this model type according to the template. 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": "[TODO: Translate] 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.", + "emptyTemplateInfo": "[TODO: Translate] No filename template configured for this model type — original filenames are kept, nothing to apply.", + "validation": { + "keepOriginal": "[TODO: Translate] Valid (keep original filename)", + "invalidChars": "[TODO: Translate] Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)", + "invalidPlaceholder": "[TODO: Translate] Invalid placeholder: {placeholder}", + "validTemplate": "[TODO: Translate] Valid template" + } + }, "exampleImages": { "downloadLocation": "ダウンロード場所", "downloadLocationPlaceholder": "例画像のフォルダパスを入力", @@ -913,6 +929,14 @@ "complete": "自動整理が完了しました", "error": "エラー:{error}" }, + "filenameTemplateProgress": { + "initializing": "[TODO: Translate] Initializing filename template apply...", + "starting": "[TODO: Translate] Applying filename template to {type}...", + "processing": "[TODO: Translate] Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed", + "completed": "[TODO: Translate] Completed: {success} renamed, {skipped} skipped, {failures} failed", + "complete": "[TODO: Translate] Filename template apply complete", + "error": "[TODO: Translate] Error: {error}" + }, "enrichHfAgent": "メタデータをAIで補完" }, "contextMenu": { @@ -2311,6 +2335,9 @@ "autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました", "autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗", "autoOrganizeFailed": "自動整理に失敗しました:{error}", + "filenameTemplateSuccess": "[TODO: Translate] Filename template applied successfully for {count} {type}", + "filenameTemplatePartialSuccess": "[TODO: Translate] Filename template applied with {success} renamed, {failures} failed out of {total} models", + "filenameTemplateFailed": "[TODO: Translate] Applying filename template failed: {error}", "noModelsSelected": "モデルが選択されていません" }, "recipes": { @@ -2477,6 +2504,8 @@ "mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}", "downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました", "downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}", + "filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated", + "filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}", "recipesPathUpdated": "レシピ保存先を更新しました", "recipesPathSaveFailed": "レシピ保存先の更新に失敗しました: {message}", "settingsUpdated": "設定が更新されました:{setting}", diff --git a/locales/ko.json b/locales/ko.json index 64faec00..be5d0cd2 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -681,6 +681,22 @@ "validTemplate": "유효한 템플릿" } }, + "filenameTemplates": { + "title": "[TODO: Translate] Filename Templates", + "help": "[TODO: Translate] Configure filenames for downloaded models per model type. Leave empty to keep the original filename. The original filename is always preserved in the model's metadata.", + "availablePlaceholders": "[TODO: Translate] Available placeholders:", + "templatePlaceholder": "[TODO: Translate] Enter filename template (e.g., {base_model}-{model_name}-{version_name})", + "applyButton": "[TODO: Translate] Apply to Library Now", + "applyHelp": "[TODO: Translate] Renames all existing files of this model type according to the template. 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": "[TODO: Translate] 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.", + "emptyTemplateInfo": "[TODO: Translate] No filename template configured for this model type — original filenames are kept, nothing to apply.", + "validation": { + "keepOriginal": "[TODO: Translate] Valid (keep original filename)", + "invalidChars": "[TODO: Translate] Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)", + "invalidPlaceholder": "[TODO: Translate] Invalid placeholder: {placeholder}", + "validTemplate": "[TODO: Translate] Valid template" + } + }, "exampleImages": { "downloadLocation": "다운로드 위치", "downloadLocationPlaceholder": "예시 이미지 폴더 경로를 입력하세요", @@ -913,6 +929,14 @@ "complete": "자동 정리 완료", "error": "오류: {error}" }, + "filenameTemplateProgress": { + "initializing": "[TODO: Translate] Initializing filename template apply...", + "starting": "[TODO: Translate] Applying filename template to {type}...", + "processing": "[TODO: Translate] Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed", + "completed": "[TODO: Translate] Completed: {success} renamed, {skipped} skipped, {failures} failed", + "complete": "[TODO: Translate] Filename template apply complete", + "error": "[TODO: Translate] Error: {error}" + }, "enrichHfAgent": "AI로 메타데이터 보강" }, "contextMenu": { @@ -2311,6 +2335,9 @@ "autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다", "autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패", "autoOrganizeFailed": "자동 정리 실패: {error}", + "filenameTemplateSuccess": "[TODO: Translate] Filename template applied successfully for {count} {type}", + "filenameTemplatePartialSuccess": "[TODO: Translate] Filename template applied with {success} renamed, {failures} failed out of {total} models", + "filenameTemplateFailed": "[TODO: Translate] Applying filename template failed: {error}", "noModelsSelected": "선택된 모델이 없습니다" }, "recipes": { @@ -2477,6 +2504,8 @@ "mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}", "downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다", "downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}", + "filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated", + "filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}", "recipesPathUpdated": "레시피 저장 경로가 업데이트되었습니다", "recipesPathSaveFailed": "레시피 저장 경로 업데이트 실패: {message}", "settingsUpdated": "설정 업데이트됨: {setting}", diff --git a/locales/ru.json b/locales/ru.json index 8c75284c..23277557 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -681,6 +681,22 @@ "validTemplate": "Действительный шаблон" } }, + "filenameTemplates": { + "title": "[TODO: Translate] Filename Templates", + "help": "[TODO: Translate] Configure filenames for downloaded models per model type. Leave empty to keep the original filename. The original filename is always preserved in the model's metadata.", + "availablePlaceholders": "[TODO: Translate] Available placeholders:", + "templatePlaceholder": "[TODO: Translate] Enter filename template (e.g., {base_model}-{model_name}-{version_name})", + "applyButton": "[TODO: Translate] Apply to Library Now", + "applyHelp": "[TODO: Translate] Renames all existing files of this model type according to the template. 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": "[TODO: Translate] 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.", + "emptyTemplateInfo": "[TODO: Translate] No filename template configured for this model type — original filenames are kept, nothing to apply.", + "validation": { + "keepOriginal": "[TODO: Translate] Valid (keep original filename)", + "invalidChars": "[TODO: Translate] Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)", + "invalidPlaceholder": "[TODO: Translate] Invalid placeholder: {placeholder}", + "validTemplate": "[TODO: Translate] Valid template" + } + }, "exampleImages": { "downloadLocation": "Место загрузки", "downloadLocationPlaceholder": "Введите путь к папке для примеров изображений", @@ -913,6 +929,14 @@ "complete": "Автоматическая организация завершена", "error": "Ошибка: {error}" }, + "filenameTemplateProgress": { + "initializing": "[TODO: Translate] Initializing filename template apply...", + "starting": "[TODO: Translate] Applying filename template to {type}...", + "processing": "[TODO: Translate] Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed", + "completed": "[TODO: Translate] Completed: {success} renamed, {skipped} skipped, {failures} failed", + "complete": "[TODO: Translate] Filename template apply complete", + "error": "[TODO: Translate] Error: {error}" + }, "enrichHfAgent": "Обогатить метаданные с помощью ИИ" }, "contextMenu": { @@ -2311,6 +2335,9 @@ "autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}", "autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей", "autoOrganizeFailed": "Ошибка автоматической организации: {error}", + "filenameTemplateSuccess": "[TODO: Translate] Filename template applied successfully for {count} {type}", + "filenameTemplatePartialSuccess": "[TODO: Translate] Filename template applied with {success} renamed, {failures} failed out of {total} models", + "filenameTemplateFailed": "[TODO: Translate] Applying filename template failed: {error}", "noModelsSelected": "Модели не выбраны" }, "recipes": { @@ -2477,6 +2504,8 @@ "mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}", "downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены", "downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}", + "filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated", + "filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}", "recipesPathUpdated": "Путь хранения рецептов обновлён", "recipesPathSaveFailed": "Не удалось обновить путь хранения рецептов: {message}", "settingsUpdated": "Настройки обновлены: {setting}", diff --git a/locales/zh-CN.json b/locales/zh-CN.json index 36961351..e727b4e8 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -681,6 +681,22 @@ "validTemplate": "有效模板" } }, + "filenameTemplates": { + "title": "[TODO: Translate] Filename Templates", + "help": "[TODO: Translate] Configure filenames for downloaded models per model type. Leave empty to keep the original filename. The original filename is always preserved in the model's metadata.", + "availablePlaceholders": "[TODO: Translate] Available placeholders:", + "templatePlaceholder": "[TODO: Translate] Enter filename template (e.g., {base_model}-{model_name}-{version_name})", + "applyButton": "[TODO: Translate] Apply to Library Now", + "applyHelp": "[TODO: Translate] Renames all existing files of this model type according to the template. 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": "[TODO: Translate] 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.", + "emptyTemplateInfo": "[TODO: Translate] No filename template configured for this model type — original filenames are kept, nothing to apply.", + "validation": { + "keepOriginal": "[TODO: Translate] Valid (keep original filename)", + "invalidChars": "[TODO: Translate] Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)", + "invalidPlaceholder": "[TODO: Translate] Invalid placeholder: {placeholder}", + "validTemplate": "[TODO: Translate] Valid template" + } + }, "exampleImages": { "downloadLocation": "下载位置", "downloadLocationPlaceholder": "输入示例图片文件夹路径", @@ -913,6 +929,14 @@ "complete": "自动整理已完成", "error": "错误:{error}" }, + "filenameTemplateProgress": { + "initializing": "[TODO: Translate] Initializing filename template apply...", + "starting": "[TODO: Translate] Applying filename template to {type}...", + "processing": "[TODO: Translate] Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed", + "completed": "[TODO: Translate] Completed: {success} renamed, {skipped} skipped, {failures} failed", + "complete": "[TODO: Translate] Filename template apply complete", + "error": "[TODO: Translate] Error: {error}" + }, "enrichHfAgent": "AI 元数据增强" }, "contextMenu": { @@ -2311,6 +2335,9 @@ "autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}", "autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型", "autoOrganizeFailed": "自动整理失败:{error}", + "filenameTemplateSuccess": "[TODO: Translate] Filename template applied successfully for {count} {type}", + "filenameTemplatePartialSuccess": "[TODO: Translate] Filename template applied with {success} renamed, {failures} failed out of {total} models", + "filenameTemplateFailed": "[TODO: Translate] Applying filename template failed: {error}", "noModelsSelected": "未选中模型" }, "recipes": { @@ -2477,6 +2504,8 @@ "mappingSaveFailed": "保存基础模型映射失败:{message}", "downloadTemplatesUpdated": "下载路径模板已更新", "downloadTemplatesFailed": "保存下载路径模板失败:{message}", + "filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated", + "filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}", "recipesPathUpdated": "配方存储路径已更新", "recipesPathSaveFailed": "更新配方存储路径失败:{message}", "settingsUpdated": "设置已更新:{setting}", diff --git a/locales/zh-TW.json b/locales/zh-TW.json index d5114623..ded6f728 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -681,6 +681,22 @@ "validTemplate": "範本有效" } }, + "filenameTemplates": { + "title": "[TODO: Translate] Filename Templates", + "help": "[TODO: Translate] Configure filenames for downloaded models per model type. Leave empty to keep the original filename. The original filename is always preserved in the model's metadata.", + "availablePlaceholders": "[TODO: Translate] Available placeholders:", + "templatePlaceholder": "[TODO: Translate] Enter filename template (e.g., {base_model}-{model_name}-{version_name})", + "applyButton": "[TODO: Translate] Apply to Library Now", + "applyHelp": "[TODO: Translate] Renames all existing files of this model type according to the template. 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": "[TODO: Translate] 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.", + "emptyTemplateInfo": "[TODO: Translate] No filename template configured for this model type — original filenames are kept, nothing to apply.", + "validation": { + "keepOriginal": "[TODO: Translate] Valid (keep original filename)", + "invalidChars": "[TODO: Translate] Invalid characters detected (a filename cannot contain / \\ < > : \" | ? *)", + "invalidPlaceholder": "[TODO: Translate] Invalid placeholder: {placeholder}", + "validTemplate": "[TODO: Translate] Valid template" + } + }, "exampleImages": { "downloadLocation": "下載位置", "downloadLocationPlaceholder": "輸入範例圖片的資料夾路徑", @@ -913,6 +929,14 @@ "complete": "自動整理完成", "error": "錯誤:{error}" }, + "filenameTemplateProgress": { + "initializing": "[TODO: Translate] Initializing filename template apply...", + "starting": "[TODO: Translate] Applying filename template to {type}...", + "processing": "[TODO: Translate] Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed", + "completed": "[TODO: Translate] Completed: {success} renamed, {skipped} skipped, {failures} failed", + "complete": "[TODO: Translate] Filename template apply complete", + "error": "[TODO: Translate] Error: {error}" + }, "enrichHfAgent": "AI 中繼資料增強" }, "contextMenu": { @@ -2311,6 +2335,9 @@ "autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理", "autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型", "autoOrganizeFailed": "自動整理失敗:{error}", + "filenameTemplateSuccess": "[TODO: Translate] Filename template applied successfully for {count} {type}", + "filenameTemplatePartialSuccess": "[TODO: Translate] Filename template applied with {success} renamed, {failures} failed out of {total} models", + "filenameTemplateFailed": "[TODO: Translate] Applying filename template failed: {error}", "noModelsSelected": "未選擇任何模型" }, "recipes": { @@ -2477,6 +2504,8 @@ "mappingSaveFailed": "儲存基礎模型對應失敗:{message}", "downloadTemplatesUpdated": "下載路徑範本已更新", "downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}", + "filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated", + "filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}", "recipesPathUpdated": "配方儲存路徑已更新", "recipesPathSaveFailed": "更新配方儲存路徑失敗:{message}", "settingsUpdated": "設定已更新:{setting}", diff --git a/py/routes/base_model_routes.py b/py/routes/base_model_routes.py index 2bac8d7c..805f37a6 100644 --- a/py/routes/base_model_routes.py +++ b/py/routes/base_model_routes.py @@ -24,9 +24,11 @@ from ..services.use_cases import ( AutoOrganizeUseCase, BulkMetadataRefreshUseCase, DownloadModelUseCase, + FilenameTemplateUseCase, ) from ..services.websocket_progress_callback import ( WebSocketBroadcastCallback, + WebSocketFilenameTemplateProgressCallback, WebSocketProgressCallback, ) from ..utils.exif_utils import ExifUtils @@ -37,6 +39,7 @@ from .handlers.model_handlers import ( ModelAutoOrganizeHandler, ModelCivitaiHandler, ModelDownloadHandler, + ModelFilenameTemplateHandler, ModelHandlerSet, ModelListingHandler, ModelManagementHandler, @@ -83,6 +86,9 @@ class BaseModelRoutes(ABC): self.model_lifecycle_service: ModelLifecycleService | None = None self.websocket_progress_callback = WebSocketProgressCallback() self.metadata_progress_callback = WebSocketBroadcastCallback() + self.filename_template_progress_callback = ( + WebSocketFilenameTemplateProgressCallback() + ) self._handler_set: ModelHandlerSet | 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, 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( service=service, update_service=update_service, @@ -218,6 +235,7 @@ class BaseModelRoutes(ABC): civitai=civitai, move=move, auto_organize=auto_organize, + filename_template=filename_template, updates=updates, ) diff --git a/py/routes/handlers/model_handlers.py b/py/routes/handlers/model_handlers.py index 56104dce..cfab2835 100644 --- a/py/routes/handlers/model_handlers.py +++ b/py/routes/handlers/model_handlers.py @@ -37,10 +37,14 @@ from ...services.use_cases import ( DownloadModelEarlyAccessError, DownloadModelUseCase, DownloadModelValidationError, + FilenameTemplateUseCase, MetadataRefreshProgressReporter, ) 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.errors import RateLimitError, ResourceNotFoundError 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) +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: """Handle update tracking requests.""" @@ -3459,6 +3528,7 @@ class ModelHandlerSet: civitai: ModelCivitaiHandler move: ModelMoveHandler auto_organize: ModelAutoOrganizeHandler + filename_template: ModelFilenameTemplateHandler updates: ModelUpdateHandler def to_route_mapping( @@ -3523,6 +3593,7 @@ class ModelHandlerSet: "rename_folder": self.move.rename_folder, "auto_organize_models": self.auto_organize.auto_organize_models, "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_preview_url": self.query.get_model_preview_url, "get_model_civitai_url": self.query.get_model_civitai_url, diff --git a/py/routes/model_route_registrar.py b/py/routes/model_route_registrar.py index ca07137b..066b3fa3 100644 --- a/py/routes/model_route_registrar.py +++ b/py/routes/model_route_registrar.py @@ -48,6 +48,12 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = ( RouteDefinition( "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}/search-tags", "search_tags"), RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"), diff --git a/py/services/download_manager.py b/py/services/download_manager.py index 7b95fe69..d37bbf40 100644 --- a/py/services/download_manager.py +++ b/py/services/download_manager.py @@ -33,7 +33,7 @@ from ..utils.constants import ( from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url from ..utils.file_utils import calculate_sha256, calculate_autov3 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.metadata_manager import MetadataManager from .service_registry import ServiceRegistry @@ -45,6 +45,7 @@ from .errors import RateLimitError from .aria2_downloader import Aria2Error, get_aria2_downloader from .aria2_transfer_state import Aria2TransferStateStore from .download_queue_service import DownloadQueueService +from .model_lifecycle_service import ModelLifecycleService, load_local_metadata # Download to temporary file first import tempfile @@ -2746,6 +2747,7 @@ class DownloadManager: else None ) + downloaded_metadata: List[Dict[str, Any]] = [] for index, entry in enumerate(metadata_entries): file_path_for_adjust = getattr( entry, "file_path", actual_file_paths[index] @@ -2788,6 +2790,15 @@ class DownloadManager: if scanner is not None: 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: await self._aria2_state_store.remove(download_id) @@ -2827,6 +2838,83 @@ class DownloadManager: 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]: if model_type in ("checkpoint", "other"): return { diff --git a/py/services/model_file_service.py b/py/services/model_file_service.py index 23855993..659805cd 100644 --- a/py/services/model_file_service.py +++ b/py/services/model_file_service.py @@ -43,10 +43,22 @@ class AutoOrganizeResult: def to_dict(self) -> Dict[str, Any]: """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] = { 'success': self.status != 'error', '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': { 'total': self.total, 'success': self.success_count, diff --git a/py/services/model_lifecycle_service.py b/py/services/model_lifecycle_service.py index be24cc6f..f180baec 100644 --- a/py/services/model_lifecycle_service.py +++ b/py/services/model_lifecycle_service.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import logging import os 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 +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( target_dir: str, file_name: str, main_extension: str | None = None ) -> List[str]: @@ -404,6 +425,9 @@ class ModelLifecycleService: if metadata and new_metadata_path: metadata["file_name"] = new_file_name 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"): old_preview = str(metadata["preview_url"]) diff --git a/py/services/settings_manager.py b/py/services/settings_manager.py index 6314cc5c..54848adc 100644 --- a/py/services/settings_manager.py +++ b/py/services/settings_manager.py @@ -98,6 +98,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = { "recipes_path": "", "base_model_path_mappings": {}, "download_path_templates": {}, + "download_filename_templates": {}, "folder_paths": {}, "extra_folder_paths": {}, "example_images_path": "", @@ -1276,6 +1277,7 @@ class SettingsManager: defaults = copy.deepcopy(DEFAULT_SETTINGS) defaults["base_model_path_mappings"] = {} defaults["download_path_templates"] = {} + defaults["download_filename_templates"] = {} defaults["priority_tags"] = DEFAULT_PRIORITY_TAG_CONFIG.copy() defaults.setdefault("folder_paths", {}) defaults.setdefault("extra_folder_paths", {}) @@ -2424,6 +2426,49 @@ class SettingsManager: 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_LOCK = Lock() diff --git a/py/services/use_cases/__init__.py b/py/services/use_cases/__init__.py index 8a43318c..ae6a6900 100644 --- a/py/services/use_cases/__init__.py +++ b/py/services/use_cases/__init__.py @@ -20,6 +20,7 @@ from .example_images import ( ImportExampleImagesUseCase, ImportExampleImagesValidationError, ) +from .filename_template_use_case import FilenameTemplateUseCase __all__ = [ "AutoOrganizeInProgressError", @@ -34,4 +35,5 @@ __all__ = [ "DownloadExampleImagesUseCase", "ImportExampleImagesUseCase", "ImportExampleImagesValidationError", + "FilenameTemplateUseCase", ] diff --git a/py/services/use_cases/filename_template_use_case.py b/py/services/use_cases/filename_template_use_case.py new file mode 100644 index 00000000..7ac0a9e7 --- /dev/null +++ b/py/services/use_cases/filename_template_use_case.py @@ -0,0 +1,222 @@ +"""Filename template use case: bulk-rename library models per the configured template.""" + +from __future__ import annotations + +import asyncio +import logging +import os +from typing import Any, 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 +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. + + 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, + ) -> None: + self._scanner = scanner + self._lifecycle_service = lifecycle_service + self._lock_provider = lock_provider + self._model_type = model_type + + 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: + result.skipped_count += 1 + return + + 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 _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] diff --git a/py/services/websocket_manager.py b/py/services/websocket_manager.py index ca436aed..3ddb99f0 100644 --- a/py/services/websocket_manager.py +++ b/py/services/websocket_manager.py @@ -20,6 +20,8 @@ class WebSocketManager: self._last_init_progress: Dict[str, Dict[str, Any]] = {} # Add auto-organize progress tracking 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 self._recipe_rematch_progress: Optional[Dict[str, Any]] = None self._auto_organize_lock = asyncio.Lock() @@ -205,6 +207,26 @@ class WebSocketManager: def cleanup_auto_organize_progress(self): """Clear auto-organize progress data""" 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]): """Broadcast recipe rematch progress to connected clients""" diff --git a/py/services/websocket_progress_callback.py b/py/services/websocket_progress_callback.py index ba496ef6..79c7559c 100644 --- a/py/services/websocket_progress_callback.py +++ b/py/services/websocket_progress_callback.py @@ -21,6 +21,14 @@ class WebSocketProgressCallback(ProgressCallback): 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: """Generic WebSocket progress callback broadcasting to all clients.""" diff --git a/py/utils/utils.py b/py/utils/utils.py index bcfe4d7d..db60e3f8 100644 --- a/py/utils/utils.py +++ b/py/utils/utils.py @@ -1,4 +1,5 @@ from difflib import SequenceMatcher +import logging import os import re from typing import Any, Dict, List, Optional @@ -7,6 +8,8 @@ from ..config import config from ..services.settings_manager import get_settings_manager import asyncio +logger = logging.getLogger(__name__) + def get_lora_info(lora_name): """Get the lora path and trigger words from cache""" @@ -598,6 +601,107 @@ def calculate_relative_path_for_model( 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): """Recursively remove empty directories starting from the given path. diff --git a/static/js/api/apiConfig.js b/static/js/api/apiConfig.js index ae10e80f..9b277769 100644 --- a/static/js/api/apiConfig.js +++ b/static/js/api/apiConfig.js @@ -122,6 +122,9 @@ export function getApiEndpoints(modelType) { autoOrganize: `/api/lm/${modelType}/auto-organize`, autoOrganizeProgress: `/api/lm/${modelType}/auto-organize-progress`, + // Filename template operations + applyFilenameTemplate: `/api/lm/${modelType}/apply-filename-template`, + // Model-specific endpoints (will be merged with specific configs) specific: {} }; diff --git a/static/js/api/baseModelApi.js b/static/js/api/baseModelApi.js index 99f999e3..28aa6867 100644 --- a/static/js/api/baseModelApi.js +++ b/static/js/api/baseModelApi.js @@ -2175,6 +2175,135 @@ export class BaseModelApiClient { }); } + /** + * Apply the configured download filename template to models, renaming their files + * @param {Array} filePaths - Optional array of file paths to rename. If not provided, applies to all models. + * @returns {Promise} - Promise that resolves when the operation is complete + */ + async applyFilenameTemplate(filePaths = null) { + let ws = null; + + await state.loadingManager.showWithProgress(async (loading) => { + loading.showCancelButton(() => this.cancelTask()); + try { + // Connect to WebSocket for progress updates + const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://'; + ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`); + + const operationComplete = new Promise((resolve, reject) => { + ws.onmessage = (event) => { + const data = JSON.parse(event.data); + + if (data.type !== 'filename_template_progress') return; + + switch (data.status) { + case 'started': + loading.setProgress(0); + const operationType = data.operation_type === 'bulk' ? 'selected models' : 'all models'; + loading.setStatus(translate('loras.bulkOperations.filenameTemplateProgress.starting', { type: operationType }, `Applying filename template to ${operationType}...`)); + break; + + case 'processing': + const percent = data.total > 0 ? ((data.processed / data.total) * 90).toFixed(1) : 0; + loading.setProgress(percent); + loading.setStatus( + translate('loras.bulkOperations.filenameTemplateProgress.processing', { + processed: data.processed, + total: data.total, + success: data.success, + failures: data.failures, + skipped: data.skipped + }, `Processing (${data.processed}/${data.total}) - ${data.success} renamed, ${data.skipped} skipped, ${data.failures} failed`) + ); + break; + + case 'completed': + loading.setProgress(100); + loading.setStatus( + translate('loras.bulkOperations.filenameTemplateProgress.completed', { + success: data.success, + skipped: data.skipped, + failures: data.failures, + total: data.total + }, `Completed: ${data.success} renamed, ${data.skipped} skipped, ${data.failures} failed`) + ); + + setTimeout(() => { + resolve(data); + }, 1500); + break; + + case 'cancelled': + loading.setStatus(translate('toast.api.operationCancelled', {}, 'Operation cancelled by user')); + resolve(data); + break; + + case 'error': + loading.setStatus(translate('loras.bulkOperations.filenameTemplateProgress.error', { error: data.error }, `Error: ${data.error}`)); + reject(new Error(data.error)); + break; + } + }; + + ws.onerror = (error) => { + console.error('WebSocket error during filename template apply:', error); + reject(new Error('Connection error')); + }; + }); + + // Start the filename template operation + const endpoint = this.apiConfig.endpoints.applyFilenameTemplate; + + const requestBody = {}; + if (filePaths) { + requestBody.file_paths = filePaths; + } + + const response = await fetch(endpoint, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})); + throw new Error(errorData.error || 'Failed to start filename template operation'); + } + + // Wait for the operation to complete via WebSocket + const result = await operationComplete; + + // Show appropriate success message based on results + if (result.status === 'cancelled') { + showToast('toast.api.operationCancelledPartial', { success: result.success, total: result.total }, 'info'); + } else if (result.failures === 0) { + showToast('toast.loras.filenameTemplateSuccess', { + count: result.success, + type: result.operation_type === 'bulk' ? 'selected models' : 'all models' + }, 'success'); + } else { + showToast('toast.loras.filenameTemplatePartialSuccess', { + success: result.success, + failures: result.failures, + total: result.total + }, 'warning'); + } + + } catch (error) { + console.error('Error applying filename template:', error); + showToast('toast.loras.filenameTemplateFailed', { error: error.message }, 'error'); + throw error; + } finally { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.close(); + } + } + }, { + initialMessage: translate('loras.bulkOperations.filenameTemplateProgress.initializing', {}, 'Initializing filename template apply...'), + completionMessage: translate('loras.bulkOperations.filenameTemplateProgress.complete', {}, 'Filename template apply complete') + }); + } + async stopExampleImages() { try { const response = await fetch('/api/lm/stop-example-images', { diff --git a/static/js/managers/SettingsManager.js b/static/js/managers/SettingsManager.js index b5bb0cbd..a96830d8 100644 --- a/static/js/managers/SettingsManager.js +++ b/static/js/managers/SettingsManager.js @@ -1,12 +1,14 @@ import { modalManager } from './ModalManager.js'; import { showToast } from '../utils/uiHelpers.js'; import { state, createDefaultSettings } from '../state/index.js'; -import { resetAndReload } from '../api/modelApiFactory.js'; +import { resetAndReload, getModelApiClient } from '../api/modelApiFactory.js'; import { DOWNLOAD_PATH_TEMPLATES, MAPPABLE_BASE_MODELS, PATH_TEMPLATE_PLACEHOLDERS, DEFAULT_PATH_TEMPLATES, + FILENAME_TEMPLATE_PLACEHOLDERS, + DEFAULT_FILENAME_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG, getMappableBaseModelsDynamic } from '../utils/constants.js'; @@ -36,6 +38,13 @@ const OTHER_SUB_TYPE_LABEL_KEYS = { controlnet: 'settings.folderSettings.subTypeControlnet', }; +// Singular filename-template model type -> plural API model type (MODEL_TYPES) +const FILENAME_TEMPLATE_MODEL_TYPES = { + lora: 'loras', + checkpoint: 'checkpoints', + embedding: 'embeddings', +}; + export class SettingsManager { constructor() { this.initialized = false; @@ -147,6 +156,25 @@ export class SettingsManager { merged.download_path_templates = { ...DEFAULT_PATH_TEMPLATES, ...templates }; + let filenameTemplates = backendSettings?.download_filename_templates; + if (typeof filenameTemplates === 'string') { + try { + const parsed = JSON.parse(filenameTemplates); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + filenameTemplates = parsed; + } + } catch (parseError) { + console.warn('Failed to parse download_filename_templates string from backend, using defaults'); + filenameTemplates = null; + } + } + + if (!filenameTemplates || typeof filenameTemplates !== 'object' || Array.isArray(filenameTemplates)) { + filenameTemplates = {}; + } + + merged.download_filename_templates = { ...DEFAULT_FILENAME_TEMPLATES, ...filenameTemplates }; + const priorityTags = backendSettings?.priority_tags; const normalizedPriority = { ...DEFAULT_PRIORITY_TAG_CONFIG }; if (priorityTags && typeof priorityTags === 'object' && !Array.isArray(priorityTags)) { @@ -428,6 +456,31 @@ export class SettingsManager { } }); + ['lora', 'checkpoint', 'embedding'].forEach(modelType => { + const filenameInput = document.getElementById(`${modelType}FilenameTemplate`); + if (filenameInput) { + filenameInput.addEventListener('input', (e) => { + const template = e.target.value; + settingsManager.validateFilenameTemplate(modelType, template); + settingsManager.updateFilenamePreview(modelType, template); + settingsManager.updateFilenameTemplateApplyButton(modelType, template); + }); + + filenameInput.addEventListener('blur', (e) => { + const template = e.target.value; + if (settingsManager.validateFilenameTemplate(modelType, template)) { + settingsManager.updateFilenameTemplate(modelType, template); + } + }); + + filenameInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.target.blur(); + } + }); + } + }); + const autoOrganizeInput = document.getElementById('autoOrganizeExclusions'); if (autoOrganizeInput) { autoOrganizeInput.addEventListener('keydown', (event) => { @@ -1142,6 +1195,9 @@ export class SettingsManager { // Load download path templates this.loadDownloadPathTemplates(); + // Load download filename templates + this.loadFilenameTemplates(); + // Load priority tag settings this.loadPriorityTagSettings(); @@ -2797,6 +2853,146 @@ export class SettingsManager { } } + loadFilenameTemplates() { + const templates = state.global.settings.download_filename_templates || DEFAULT_FILENAME_TEMPLATES; + + ['lora', 'checkpoint', 'embedding'].forEach(modelType => { + const input = document.getElementById(`${modelType}FilenameTemplate`); + if (!input) return; + + const template = templates[modelType] || ''; + input.value = template; + this.validateFilenameTemplate(modelType, template); + this.updateFilenamePreview(modelType, template); + this.updateFilenameTemplateApplyButton(modelType, template); + }); + } + + validateFilenameTemplate(modelType, template) { + const validationElement = document.getElementById(`${modelType}FilenameValidation`); + if (!validationElement) return true; + + // Reset validation state + validationElement.innerHTML = ''; + validationElement.className = 'template-validation'; + + if (!template) { + validationElement.innerHTML = ` ${translate('settings.filenameTemplates.validation.keepOriginal', {}, 'Valid (keep original filename)')}`; + validationElement.classList.add('valid'); + return true; + } + + // A filename stem cannot contain path separators or OS-illegal characters + const invalidChars = /[/\\<>:"|?*]/; + if (invalidChars.test(template)) { + validationElement.innerHTML = ` ${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 = ` ${translate('settings.filenameTemplates.validation.invalidPlaceholder', { placeholder: invalidPlaceholders[0] }, `Invalid placeholder: ${invalidPlaceholders[0]}`)}`; + validationElement.classList.add('invalid'); + return false; + } + + // Template is valid + validationElement.innerHTML = ` ${translate('settings.filenameTemplates.validation.validTemplate', {}, 'Valid template')}`; + validationElement.classList.add('valid'); + return true; + } + + updateFilenameTemplate(modelType, template) { + if (!this.validateFilenameTemplate(modelType, template)) { + return; // Don't save invalid templates + } + + // Update state + if (!state.global.settings.download_filename_templates) { + state.global.settings.download_filename_templates = { ...DEFAULT_FILENAME_TEMPLATES }; + } + state.global.settings.download_filename_templates[modelType] = template; + + // Update preview and apply-button state + this.updateFilenamePreview(modelType, template); + this.updateFilenameTemplateApplyButton(modelType, template); + + // Save settings + this.saveFilenameTemplates(); + } + + updateFilenameTemplateApplyButton(modelType, template) { + const button = document.getElementById(`${modelType}ApplyFilenameTemplate`); + if (!button) return; + + // An empty template keeps original filenames, so there is nothing to apply + button.disabled = !template; + } + + updateFilenamePreview(modelType, template) { + const previewElement = document.getElementById(`${modelType}FilenamePreview`); + if (!previewElement) return; + + if (!template) { + // Empty template keeps the original filename untouched + previewElement.textContent = 'V1.safetensors'; + } else { + const exampleStem = template + .replaceAll('{model_name}', 'model-name') + .replaceAll('{version_name}', 'v3') + .replaceAll('{base_model}', 'Flux.1 D') + .replaceAll('{author}', 'authorname') + .replaceAll('{first_tag}', 'style') + .replaceAll('{hash_short}', 'a1b2c3d4e5') + .replaceAll('{original_name}', 'V1'); + previewElement.textContent = `${exampleStem}.safetensors`; + } + previewElement.style.display = 'block'; + } + + async saveFilenameTemplates() { + try { + // Save to backend using universal save method + await this.saveSetting('download_filename_templates', state.global.settings.download_filename_templates); + + showToast('toast.settings.filenameTemplatesUpdated', {}, 'success'); + + } catch (error) { + console.error('Error saving download filename templates:', error); + showToast('toast.settings.filenameTemplatesFailed', { message: error.message }, 'error'); + } + } + + async applyFilenameTemplate(modelType) { + const template = state.global.settings.download_filename_templates?.[modelType] || ''; + if (!template) { + showToast('settings.filenameTemplates.emptyTemplateInfo', {}, 'info'); + return; + } + + if (!confirm(translate('settings.filenameTemplates.confirmApply', {}, 'Rename all existing files of this model type according to the filename template? The original filename is preserved in each model\'s metadata.'))) { + return; + } + + try { + const apiClient = getModelApiClient(FILENAME_TEMPLATE_MODEL_TYPES[modelType]); + await apiClient.applyFilenameTemplate(); + resetAndReload(true); + } catch (error) { + // The API client already surfaced a toast with the failure reason + console.error('Error applying filename template:', error); + } + } + toggleSettings() { if (this.isOpen) { modalManager.closeModal('settingsModal'); diff --git a/static/js/state/index.js b/static/js/state/index.js index db516463..04c582d8 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -1,7 +1,7 @@ // Create the new hierarchical state structure import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js'; import { MODEL_TYPES } from '../api/apiConfig.js'; -import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js'; +import { DEFAULT_PATH_TEMPLATES, DEFAULT_FILENAME_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js'; const DEFAULT_SETTINGS_BASE = Object.freeze({ civitai_api_key: '', @@ -30,6 +30,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({ recipes_path: '', base_model_path_mappings: {}, download_path_templates: {}, + download_filename_templates: {}, example_images_path: '', example_images_open_mode: 'system', example_images_local_root: '', @@ -74,6 +75,7 @@ export function createDefaultSettings() { ...DEFAULT_SETTINGS_BASE, base_model_path_mappings: {}, download_path_templates: { ...DEFAULT_PATH_TEMPLATES }, + download_filename_templates: { ...DEFAULT_FILENAME_TEMPLATES }, priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG }, default_other_roots: {}, enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'], diff --git a/static/js/utils/constants.js b/static/js/utils/constants.js index 755d60f3..b3649858 100644 --- a/static/js/utils/constants.js +++ b/static/js/utils/constants.js @@ -360,6 +360,25 @@ export const DEFAULT_PATH_TEMPLATES = { other: '' }; +// Valid placeholders for download filename templates (opt-in rename of +// downloaded safetensors; the result is a filename stem, no path separators) +export const FILENAME_TEMPLATE_PLACEHOLDERS = [ + '{model_name}', + '{version_name}', + '{base_model}', + '{author}', + '{first_tag}', + '{hash_short}', + '{original_name}' +]; + +// Default filename templates per model type; empty string keeps the original filename +export const DEFAULT_FILENAME_TEMPLATES = { + lora: '', + checkpoint: '', + embedding: '' +}; + // Model type labels for UI export const MODEL_TYPE_LABELS = { lora: 'LoRA Models', diff --git a/templates/components/modals/settings/organization.html b/templates/components/modals/settings/organization.html index 3d1c7c99..41d74bc0 100644 --- a/templates/components/modals/settings/organization.html +++ b/templates/components/modals/settings/organization.html @@ -198,6 +198,82 @@ + +
+
+

+ {{ t('settings.filenameTemplates.title') }} + +

+
+
+
+
+ {{ t('settings.filenameTemplates.availablePlaceholders') }} + {model_name} + {version_name} + {base_model} + {author} + {first_tag} + {hash_short} + {original_name} +
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+
+
+ +
+
+ +
+
+ +
+
+
+ +
+
{{ t('settings.filenameTemplates.applyHelp') }}
+
+
+
{{ sm.subsection_header('settings.sections.autoOrganize') }} diff --git a/tests/frontend/managers/settingsManager.downloadSkipBaseModels.test.js b/tests/frontend/managers/settingsManager.downloadSkipBaseModels.test.js index 5ea9b875..558ee97c 100644 --- a/tests/frontend/managers/settingsManager.downloadSkipBaseModels.test.js +++ b/tests/frontend/managers/settingsManager.downloadSkipBaseModels.test.js @@ -35,6 +35,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({ DEFAULT_PATH_TEMPLATES: {}, MAPPABLE_BASE_MODELS: ['Flux.1 D', 'Pony', 'SDXL 1.0', 'Other'], PATH_TEMPLATE_PLACEHOLDERS: {}, + FILENAME_TEMPLATE_PLACEHOLDERS: [], + DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' }, DEFAULT_PRIORITY_TAG_CONFIG: { lora: 'character, style', checkpoint: 'base, guide', diff --git a/tests/frontend/managers/settingsManager.filenameTemplates.test.js b/tests/frontend/managers/settingsManager.filenameTemplates.test.js new file mode 100644 index 00000000..6e25421e --- /dev/null +++ b/tests/frontend/managers/settingsManager.filenameTemplates.test.js @@ -0,0 +1,276 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +vi.mock('../../../static/js/managers/ModalManager.js', () => ({ + modalManager: { + closeModal: vi.fn(), + }, +})); + +vi.mock('../../../static/js/utils/uiHelpers.js', () => ({ + showToast: vi.fn(), +})); + +vi.mock('../../../static/js/state/index.js', () => { + const settings = {}; + return { + state: { + global: { + settings, + }, + }, + createDefaultSettings: () => ({ + language: 'en', + download_filename_templates: { lora: '', checkpoint: '', embedding: '' }, + }), + }; +}); + +vi.mock('../../../static/js/api/modelApiFactory.js', () => ({ + resetAndReload: vi.fn(), + getModelApiClient: vi.fn(), +})); + +vi.mock('../../../static/js/utils/constants.js', () => ({ + DOWNLOAD_PATH_TEMPLATES: {}, + DEFAULT_PATH_TEMPLATES: {}, + MAPPABLE_BASE_MODELS: [], + PATH_TEMPLATE_PLACEHOLDERS: [], + FILENAME_TEMPLATE_PLACEHOLDERS: [ + '{model_name}', + '{version_name}', + '{base_model}', + '{author}', + '{first_tag}', + '{hash_short}', + '{original_name}', + ], + DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' }, + DEFAULT_PRIORITY_TAG_CONFIG: { + lora: 'character, style', + checkpoint: 'base, guide', + embedding: 'hint', + }, + getMappableBaseModelsDynamic: () => [], +})); + +vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({ + translate: (key, params, fallback) => { + if (params && fallback) { + return fallback.replace(/\{(\w+)\}/g, (match, name) => params[name] ?? match); + } + return fallback ?? ''; + }, +})); + +vi.mock('../../../static/js/i18n/index.js', () => ({ + i18n: { + getCurrentLocale: () => 'en', + setLanguage: vi.fn().mockResolvedValue(), + }, +})); + +vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({ + configureModelCardVideo: vi.fn(), +})); + +vi.mock('../../../static/js/managers/BannerService.js', () => ({ + bannerService: { + registerBanner: vi.fn(), + }, +})); + +import { SettingsManager } from '../../../static/js/managers/SettingsManager.js'; +import { state } from '../../../static/js/state/index.js'; +import { showToast } from '../../../static/js/utils/uiHelpers.js'; +import { resetAndReload, getModelApiClient } from '../../../static/js/api/modelApiFactory.js'; + +const createManager = () => { + const initSettingsSpy = vi + .spyOn(SettingsManager.prototype, 'initializeSettings') + .mockResolvedValue(); + const initializeSpy = vi + .spyOn(SettingsManager.prototype, 'initialize') + .mockImplementation(() => {}); + + const manager = new SettingsManager(); + + initSettingsSpy.mockRestore(); + initializeSpy.mockRestore(); + + return manager; +}; + +const appendFilenameTemplateUi = (modelType = 'lora') => { + document.body.innerHTML = ` + +
+
+ + `; +}; + +describe('SettingsManager filename templates', () => { + beforeEach(() => { + document.body.innerHTML = ''; + vi.clearAllMocks(); + state.global.settings = { + download_filename_templates: { lora: '', checkpoint: '', embedding: '' }, + }; + }); + + it('treats an empty template as valid (keep original filename)', () => { + appendFilenameTemplateUi(); + const manager = createManager(); + + expect(manager.validateFilenameTemplate('lora', '')).toBe(true); + + const validation = document.getElementById('loraFilenameValidation'); + expect(validation.classList.contains('valid')).toBe(true); + expect(validation.textContent).toContain('keep original filename'); + }); + + it('rejects templates with path separators or OS-illegal characters', () => { + appendFilenameTemplateUi(); + const manager = createManager(); + + expect(manager.validateFilenameTemplate('lora', '{base_model}/{model_name}')).toBe(false); + expect(manager.validateFilenameTemplate('lora', 'a:b')).toBe(false); + + const validation = document.getElementById('loraFilenameValidation'); + expect(validation.classList.contains('invalid')).toBe(true); + }); + + it('rejects unknown placeholders', () => { + appendFilenameTemplateUi(); + const manager = createManager(); + + expect(manager.validateFilenameTemplate('lora', '{bogus}-{model_name}')).toBe(false); + + const validation = document.getElementById('loraFilenameValidation'); + expect(validation.textContent).toContain('{bogus}'); + }); + + it('accepts a template using only known placeholders', () => { + appendFilenameTemplateUi(); + const manager = createManager(); + + const template = '{base_model}-{model_name}-{version_name}-{hash_short}'; + expect(manager.validateFilenameTemplate('lora', template)).toBe(true); + expect(document.getElementById('loraFilenameValidation').classList.contains('valid')).toBe(true); + }); + + it('saves a valid template via saveSetting with the merged dict', () => { + appendFilenameTemplateUi(); + const manager = createManager(); + manager.saveSetting = vi.fn().mockResolvedValue(); + + manager.updateFilenameTemplate('lora', '{model_name}'); + + expect(state.global.settings.download_filename_templates.lora).toBe('{model_name}'); + expect(manager.saveSetting).toHaveBeenCalledWith( + 'download_filename_templates', + { lora: '{model_name}', checkpoint: '', embedding: '' }, + ); + }); + + it('does not save an invalid template', () => { + appendFilenameTemplateUi(); + const manager = createManager(); + manager.saveSetting = vi.fn().mockResolvedValue(); + + manager.updateFilenameTemplate('lora', '{unknown_placeholder}'); + + expect(state.global.settings.download_filename_templates.lora).toBe(''); + expect(manager.saveSetting).not.toHaveBeenCalled(); + }); + + it('previews the original filename when the template is empty', () => { + appendFilenameTemplateUi(); + const manager = createManager(); + + manager.updateFilenamePreview('lora', ''); + + expect(document.getElementById('loraFilenamePreview').textContent).toBe('V1.safetensors'); + }); + + it('renders a preview with example placeholder values', () => { + appendFilenameTemplateUi(); + const manager = createManager(); + + manager.updateFilenamePreview('lora', '{base_model}-{model_name}-{version_name}-{hash_short}'); + + expect(document.getElementById('loraFilenamePreview').textContent) + .toBe('Flux.1 D-model-name-v3-a1b2c3d4e5.safetensors'); + }); + + it('disables the apply button when the template is empty', () => { + appendFilenameTemplateUi(); + const manager = createManager(); + + manager.updateFilenameTemplateApplyButton('lora', ''); + expect(document.getElementById('loraApplyFilenameTemplate').disabled).toBe(true); + + manager.updateFilenameTemplateApplyButton('lora', '{model_name}'); + expect(document.getElementById('loraApplyFilenameTemplate').disabled).toBe(false); + }); + + it('merges backend download_filename_templates over defaults', () => { + const manager = createManager(); + + const merged = manager.mergeSettingsWithDefaults({ + download_filename_templates: { lora: '{model_name}' }, + }); + expect(merged.download_filename_templates).toEqual({ + lora: '{model_name}', + checkpoint: '', + embedding: '', + }); + + const fromString = manager.mergeSettingsWithDefaults({ + download_filename_templates: '{"checkpoint":"{hash_short}"}', + }); + expect(fromString.download_filename_templates).toEqual({ + lora: '', + checkpoint: '{hash_short}', + embedding: '', + }); + }); + + it('shows an info toast and does nothing when applying an empty template', async () => { + appendFilenameTemplateUi(); + const manager = createManager(); + vi.stubGlobal('confirm', vi.fn(() => true)); + + await manager.applyFilenameTemplate('lora'); + + expect(showToast).toHaveBeenCalledWith('settings.filenameTemplates.emptyTemplateInfo', {}, 'info'); + expect(getModelApiClient).not.toHaveBeenCalled(); + }); + + it('applies the template through the model API client and reloads', async () => { + appendFilenameTemplateUi(); + state.global.settings.download_filename_templates.lora = '{model_name}'; + const manager = createManager(); + vi.stubGlobal('confirm', vi.fn(() => true)); + const apiClient = { applyFilenameTemplate: vi.fn().mockResolvedValue() }; + getModelApiClient.mockReturnValue(apiClient); + + await manager.applyFilenameTemplate('lora'); + + expect(getModelApiClient).toHaveBeenCalledWith('loras'); + expect(apiClient.applyFilenameTemplate).toHaveBeenCalledWith(); + expect(resetAndReload).toHaveBeenCalledWith(true); + }); + + it('does not apply when the confirm dialog is declined', async () => { + appendFilenameTemplateUi(); + state.global.settings.download_filename_templates.lora = '{model_name}'; + const manager = createManager(); + vi.stubGlobal('confirm', vi.fn(() => false)); + + await manager.applyFilenameTemplate('lora'); + + expect(getModelApiClient).not.toHaveBeenCalled(); + expect(resetAndReload).not.toHaveBeenCalled(); + }); +}); diff --git a/tests/frontend/managers/settingsManager.library.test.js b/tests/frontend/managers/settingsManager.library.test.js index 8020306a..3fa7d538 100644 --- a/tests/frontend/managers/settingsManager.library.test.js +++ b/tests/frontend/managers/settingsManager.library.test.js @@ -38,6 +38,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({ DEFAULT_PATH_TEMPLATES: {}, MAPPABLE_BASE_MODELS: [], PATH_TEMPLATE_PLACEHOLDERS: {}, + FILENAME_TEMPLATE_PLACEHOLDERS: [], + DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' }, DEFAULT_PRIORITY_TAG_CONFIG: { lora: 'character, style', checkpoint: 'base, guide', diff --git a/tests/frontend/managers/settingsManager.modelPaths.test.js b/tests/frontend/managers/settingsManager.modelPaths.test.js index c25cb9e3..6b94d052 100644 --- a/tests/frontend/managers/settingsManager.modelPaths.test.js +++ b/tests/frontend/managers/settingsManager.modelPaths.test.js @@ -36,6 +36,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({ DEFAULT_PATH_TEMPLATES: {}, MAPPABLE_BASE_MODELS: [], PATH_TEMPLATE_PLACEHOLDERS: {}, + FILENAME_TEMPLATE_PLACEHOLDERS: [], + DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' }, DEFAULT_PRIORITY_TAG_CONFIG: {}, getMappableBaseModelsDynamic: () => [], })); diff --git a/tests/frontend/managers/settingsManager.pathValidation.test.js b/tests/frontend/managers/settingsManager.pathValidation.test.js index 291cccbc..f05f3cc4 100644 --- a/tests/frontend/managers/settingsManager.pathValidation.test.js +++ b/tests/frontend/managers/settingsManager.pathValidation.test.js @@ -34,6 +34,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({ DEFAULT_PATH_TEMPLATES: {}, MAPPABLE_BASE_MODELS: [], PATH_TEMPLATE_PLACEHOLDERS: {}, + FILENAME_TEMPLATE_PLACEHOLDERS: [], + DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' }, DEFAULT_PRIORITY_TAG_CONFIG: {}, getMappableBaseModelsDynamic: () => [], })); diff --git a/tests/routes/test_base_model_routes_smoke.py b/tests/routes/test_base_model_routes_smoke.py index 06cc8a3c..a5605a7f 100644 --- a/tests/routes/test_base_model_routes_smoke.py +++ b/tests/routes/test_base_model_routes_smoke.py @@ -23,6 +23,7 @@ from py.services.metadata_sync_service import MetadataSyncService from py.services.model_file_service import AutoOrganizeResult from py.services.model_update_service import ModelVersionRecord from py.services.service_registry import ServiceRegistry +from py.services.use_cases import FilenameTemplateUseCase from py.services.websocket_manager import ws_manager from py.utils.exif_utils import ExifUtils from py.utils.metadata_manager import MetadataManager @@ -126,9 +127,11 @@ async def create_test_client(service) -> TestClient[Any, Any]: @pytest.fixture(autouse=True) def reset_ws_manager_state(): ws_manager.cleanup_auto_organize_progress() + ws_manager.cleanup_filename_template_progress() ws_manager._download_progress.clear() yield ws_manager.cleanup_auto_organize_progress() + ws_manager.cleanup_filename_template_progress() ws_manager._download_progress.clear() @@ -762,6 +765,105 @@ def test_auto_organize_conflict_when_running(mock_service): 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): async def scenario(): diff --git a/tests/services/test_download_filename_template.py b/tests/services/test_download_filename_template.py new file mode 100644 index 00000000..2bc346bd --- /dev/null +++ b/tests/services/test_download_filename_template.py @@ -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() diff --git a/tests/services/test_model_lifecycle_service.py b/tests/services/test_model_lifecycle_service.py index ecf13ad6..7c9a35fd 100644 --- a/tests/services/test_model_lifecycle_service.py +++ b/tests/services/test_model_lifecycle_service.py @@ -424,6 +424,52 @@ async def test_rename_model_preserves_extension(tmp_path: Path): 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 async def test_rename_model_with_dotted_basename(tmp_path: Path): old_name = "model.v1" diff --git a/tests/services/test_use_cases.py b/tests/services/test_use_cases.py index 273ba11a..5a237714 100644 --- a/tests/services/test_use_cases.py +++ b/tests/services/test_use_cases.py @@ -19,6 +19,7 @@ from py.services.use_cases import ( DownloadModelEarlyAccessError, DownloadModelUseCase, DownloadModelValidationError, + FilenameTemplateUseCase, ImportExampleImagesUseCase, ImportExampleImagesValidationError, ) @@ -33,7 +34,7 @@ from py.utils.example_images_processor import ( ExampleImagesValidationError, ) from py.utils.metadata_manager import MetadataManager -from tests.conftest import MockModelService, MockScanner +from tests.conftest import MockCache, MockModelService, MockScanner class StubLockProvider: @@ -502,4 +503,179 @@ async def test_import_example_images_use_case_propagates_generic_error() -> None request = DummyJsonRequest({"model_hash": "abc", "file_paths": ["/tmp/file"]}) with pytest.raises(ExampleImagesImportError): - await use_case.execute(request) # pyright: ignore[reportArgumentType] \ No newline at end of file + 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, +) -> FilenameTemplateUseCase: + return FilenameTemplateUseCase( + scanner=scanner, + lifecycle_service=lifecycle, # pyright: ignore[reportArgumentType] + lock_provider=lock_provider or StubLockProvider(), + model_type="lora", + ) + + +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_skips_all_when_template_empty() -> 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_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) \ No newline at end of file diff --git a/tests/utils/test_utils.py b/tests/utils/test_utils.py index 85a7907e..a371e1c3 100644 --- a/tests/utils/test_utils.py +++ b/tests/utils/test_utils.py @@ -3,6 +3,7 @@ import pytest from py.services.settings_manager import SettingsManager, get_settings_manager from py.services.service_registry import ServiceRegistry from py.utils.utils import ( + calculate_filename_for_model, calculate_recipe_fingerprint, calculate_relative_path_for_model, get_lora_info, @@ -164,6 +165,145 @@ def test_calculate_recipe_fingerprint_empty_input(): 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( "original, expected", [