mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-09-20 18:51:26 -03:00
feat(settings): filename templates for download and bulk rename (#1071)
Add per-model-type filename templates ({model_name}, {version_name},
{base_model}, {author}, {first_tag}, {hash_short}, {original_name}) so
downloaded files get informative names instead of e.g. V1.safetensors.
Empty template keeps the current filename (opt-in, off by default).
- apply template automatically after downloads; rename conflicts keep
the original name and never fail the download
- record original_file_name in metadata on rename for traceability
- bulk apply via GET|POST /api/lm/{prefix}/apply-filename-template with
WebSocket progress, sharing the auto-organize lock
- settings UI lives in the new Organization tab with validation, live
preview, and per-type 'apply to library' actions
This commit is contained in:
@@ -681,6 +681,22 @@
|
|||||||
"validTemplate": "Gültige Vorlage"
|
"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": {
|
"exampleImages": {
|
||||||
"downloadLocation": "Download-Speicherort",
|
"downloadLocation": "Download-Speicherort",
|
||||||
"downloadLocationPlaceholder": "Ordnerpfad für Beispielbilder eingeben",
|
"downloadLocationPlaceholder": "Ordnerpfad für Beispielbilder eingeben",
|
||||||
@@ -913,6 +929,14 @@
|
|||||||
"complete": "Automatische Organisation abgeschlossen",
|
"complete": "Automatische Organisation abgeschlossen",
|
||||||
"error": "Fehler: {error}"
|
"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"
|
"enrichHfAgent": "Metadaten mit KI anreichern"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
@@ -2311,6 +2335,9 @@
|
|||||||
"autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen",
|
"autoOrganizeSuccess": "Automatische Organisation für {count} {type} erfolgreich abgeschlossen",
|
||||||
"autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen",
|
"autoOrganizePartialSuccess": "Automatische Organisation abgeschlossen: {success} verschoben, {failures} fehlgeschlagen von insgesamt {total} Modellen",
|
||||||
"autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}",
|
"autoOrganizeFailed": "Automatische Organisation fehlgeschlagen: {error}",
|
||||||
|
"filenameTemplateSuccess": "[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"
|
"noModelsSelected": "Keine Modelle ausgewählt"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -2477,6 +2504,8 @@
|
|||||||
"mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}",
|
"mappingSaveFailed": "Fehler beim Speichern der Basismodell-Zuordnungen: {message}",
|
||||||
"downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert",
|
"downloadTemplatesUpdated": "Download-Pfad-Vorlagen aktualisiert",
|
||||||
"downloadTemplatesFailed": "Fehler beim Speichern der Download-Pfad-Vorlagen: {message}",
|
"downloadTemplatesFailed": "Fehler beim Speichern der Download-Pfad-Vorlagen: {message}",
|
||||||
|
"filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated",
|
||||||
|
"filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}",
|
||||||
"recipesPathUpdated": "Rezepte-Speicherpfad aktualisiert",
|
"recipesPathUpdated": "Rezepte-Speicherpfad aktualisiert",
|
||||||
"recipesPathSaveFailed": "Fehler beim Aktualisieren des Rezepte-Speicherpfads: {message}",
|
"recipesPathSaveFailed": "Fehler beim Aktualisieren des Rezepte-Speicherpfads: {message}",
|
||||||
"settingsUpdated": "Einstellungen aktualisiert: {setting}",
|
"settingsUpdated": "Einstellungen aktualisiert: {setting}",
|
||||||
|
|||||||
@@ -681,6 +681,22 @@
|
|||||||
"validTemplate": "Valid template"
|
"validTemplate": "Valid template"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"filenameTemplates": {
|
||||||
|
"title": "Filename Templates",
|
||||||
|
"help": "Configure filenames for downloaded models per model type. Leave empty to keep 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": {
|
"exampleImages": {
|
||||||
"downloadLocation": "Download Location",
|
"downloadLocation": "Download Location",
|
||||||
"downloadLocationPlaceholder": "Enter folder path for example images",
|
"downloadLocationPlaceholder": "Enter folder path for example images",
|
||||||
@@ -913,6 +929,14 @@
|
|||||||
"complete": "Auto-organize complete",
|
"complete": "Auto-organize complete",
|
||||||
"error": "Error: {error}"
|
"error": "Error: {error}"
|
||||||
},
|
},
|
||||||
|
"filenameTemplateProgress": {
|
||||||
|
"initializing": "Initializing filename template apply...",
|
||||||
|
"starting": "Applying filename template to {type}...",
|
||||||
|
"processing": "Processing ({processed}/{total}) - {success} renamed, {skipped} skipped, {failures} failed",
|
||||||
|
"completed": "Completed: {success} renamed, {skipped} skipped, {failures} failed",
|
||||||
|
"complete": "Filename template apply complete",
|
||||||
|
"error": "Error: {error}"
|
||||||
|
},
|
||||||
"enrichHfAgent": "Enrich Metadata with AI"
|
"enrichHfAgent": "Enrich Metadata with AI"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
@@ -2311,6 +2335,9 @@
|
|||||||
"autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}",
|
"autoOrganizeSuccess": "Auto-organize completed successfully for {count} {type}",
|
||||||
"autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models",
|
"autoOrganizePartialSuccess": "Auto-organize completed with {success} moved, {failures} failed out of {total} models",
|
||||||
"autoOrganizeFailed": "Auto-organize failed: {error}",
|
"autoOrganizeFailed": "Auto-organize failed: {error}",
|
||||||
|
"filenameTemplateSuccess": "Filename template applied successfully for {count} {type}",
|
||||||
|
"filenameTemplatePartialSuccess": "Filename template applied with {success} renamed, {failures} failed out of {total} models",
|
||||||
|
"filenameTemplateFailed": "Applying filename template failed: {error}",
|
||||||
"noModelsSelected": "No models selected"
|
"noModelsSelected": "No models selected"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -2477,6 +2504,8 @@
|
|||||||
"mappingSaveFailed": "Failed to save base model mappings: {message}",
|
"mappingSaveFailed": "Failed to save base model mappings: {message}",
|
||||||
"downloadTemplatesUpdated": "Download path templates updated",
|
"downloadTemplatesUpdated": "Download path templates updated",
|
||||||
"downloadTemplatesFailed": "Failed to save download path templates: {message}",
|
"downloadTemplatesFailed": "Failed to save download path templates: {message}",
|
||||||
|
"filenameTemplatesUpdated": "Filename templates updated",
|
||||||
|
"filenameTemplatesFailed": "Failed to save filename templates: {message}",
|
||||||
"recipesPathUpdated": "Recipes storage path updated",
|
"recipesPathUpdated": "Recipes storage path updated",
|
||||||
"recipesPathSaveFailed": "Failed to update recipes storage path: {message}",
|
"recipesPathSaveFailed": "Failed to update recipes storage path: {message}",
|
||||||
"settingsUpdated": "Settings updated: {setting}",
|
"settingsUpdated": "Settings updated: {setting}",
|
||||||
|
|||||||
@@ -681,6 +681,22 @@
|
|||||||
"validTemplate": "Plantilla válida"
|
"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": {
|
"exampleImages": {
|
||||||
"downloadLocation": "Ubicación de descarga",
|
"downloadLocation": "Ubicación de descarga",
|
||||||
"downloadLocationPlaceholder": "Introduce la ruta de la carpeta para imágenes de ejemplo",
|
"downloadLocationPlaceholder": "Introduce la ruta de la carpeta para imágenes de ejemplo",
|
||||||
@@ -913,6 +929,14 @@
|
|||||||
"complete": "Auto-organización completada",
|
"complete": "Auto-organización completada",
|
||||||
"error": "Error: {error}"
|
"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"
|
"enrichHfAgent": "Enriquecer metadatos con IA"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
@@ -2311,6 +2335,9 @@
|
|||||||
"autoOrganizeSuccess": "Auto-organización completada exitosamente para {count} {type}",
|
"autoOrganizeSuccess": "Auto-organización completada exitosamente para {count} {type}",
|
||||||
"autoOrganizePartialSuccess": "Auto-organización completada con {success} movidos, {failures} fallidos de un total de {total} modelos",
|
"autoOrganizePartialSuccess": "Auto-organización completada con {success} movidos, {failures} fallidos de un total de {total} modelos",
|
||||||
"autoOrganizeFailed": "Auto-organización fallida: {error}",
|
"autoOrganizeFailed": "Auto-organización fallida: {error}",
|
||||||
|
"filenameTemplateSuccess": "[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"
|
"noModelsSelected": "No hay modelos seleccionados"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -2477,6 +2504,8 @@
|
|||||||
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
|
"mappingSaveFailed": "Error al guardar mapeos de modelo base: {message}",
|
||||||
"downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas",
|
"downloadTemplatesUpdated": "Plantillas de rutas de descarga actualizadas",
|
||||||
"downloadTemplatesFailed": "Error al guardar plantillas de rutas de descarga: {message}",
|
"downloadTemplatesFailed": "Error al guardar plantillas de rutas de descarga: {message}",
|
||||||
|
"filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated",
|
||||||
|
"filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}",
|
||||||
"recipesPathUpdated": "Ruta de almacenamiento de recetas actualizada",
|
"recipesPathUpdated": "Ruta de almacenamiento de recetas actualizada",
|
||||||
"recipesPathSaveFailed": "Error al actualizar la ruta de almacenamiento de recetas: {message}",
|
"recipesPathSaveFailed": "Error al actualizar la ruta de almacenamiento de recetas: {message}",
|
||||||
"settingsUpdated": "Configuración actualizada: {setting}",
|
"settingsUpdated": "Configuración actualizada: {setting}",
|
||||||
|
|||||||
@@ -681,6 +681,22 @@
|
|||||||
"validTemplate": "Modèle valide"
|
"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": {
|
"exampleImages": {
|
||||||
"downloadLocation": "Emplacement de téléchargement",
|
"downloadLocation": "Emplacement de téléchargement",
|
||||||
"downloadLocationPlaceholder": "Entrez le chemin du dossier pour les images d'exemple",
|
"downloadLocationPlaceholder": "Entrez le chemin du dossier pour les images d'exemple",
|
||||||
@@ -913,6 +929,14 @@
|
|||||||
"complete": "Auto-organisation terminée",
|
"complete": "Auto-organisation terminée",
|
||||||
"error": "Erreur : {error}"
|
"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"
|
"enrichHfAgent": "Enrichir les métadonnées avec l'IA"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
@@ -2311,6 +2335,9 @@
|
|||||||
"autoOrganizeSuccess": "Auto-organisation terminée avec succès pour {count} {type}",
|
"autoOrganizeSuccess": "Auto-organisation terminée avec succès pour {count} {type}",
|
||||||
"autoOrganizePartialSuccess": "Auto-organisation terminée avec {success} déplacés, {failures} échecs sur {total} modèles",
|
"autoOrganizePartialSuccess": "Auto-organisation terminée avec {success} déplacés, {failures} échecs sur {total} modèles",
|
||||||
"autoOrganizeFailed": "Échec de l'auto-organisation : {error}",
|
"autoOrganizeFailed": "Échec de l'auto-organisation : {error}",
|
||||||
|
"filenameTemplateSuccess": "[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é"
|
"noModelsSelected": "Aucun modèle sélectionné"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -2477,6 +2504,8 @@
|
|||||||
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
|
"mappingSaveFailed": "Échec de la sauvegarde des mappages de modèle de base : {message}",
|
||||||
"downloadTemplatesUpdated": "Modèles de chemin de téléchargement mis à jour",
|
"downloadTemplatesUpdated": "Modèles de chemin de téléchargement mis à jour",
|
||||||
"downloadTemplatesFailed": "Échec de la sauvegarde des modèles de chemin de téléchargement : {message}",
|
"downloadTemplatesFailed": "Échec de la sauvegarde des modèles de chemin de téléchargement : {message}",
|
||||||
|
"filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated",
|
||||||
|
"filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}",
|
||||||
"recipesPathUpdated": "Chemin de stockage des Recipes mis à jour",
|
"recipesPathUpdated": "Chemin de stockage des Recipes mis à jour",
|
||||||
"recipesPathSaveFailed": "Échec de la mise à jour du chemin de stockage des Recipes : {message}",
|
"recipesPathSaveFailed": "Échec de la mise à jour du chemin de stockage des Recipes : {message}",
|
||||||
"settingsUpdated": "Paramètres mis à jour : {setting}",
|
"settingsUpdated": "Paramètres mis à jour : {setting}",
|
||||||
|
|||||||
@@ -681,6 +681,22 @@
|
|||||||
"validTemplate": "תבנית תקינה"
|
"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": {
|
"exampleImages": {
|
||||||
"downloadLocation": "מיקום הורדה",
|
"downloadLocation": "מיקום הורדה",
|
||||||
"downloadLocationPlaceholder": "הזן נתיב תיקייה לתמונות דוגמה",
|
"downloadLocationPlaceholder": "הזן נתיב תיקייה לתמונות דוגמה",
|
||||||
@@ -913,6 +929,14 @@
|
|||||||
"complete": "ארגון אוטומטי הושלם",
|
"complete": "ארגון אוטומטי הושלם",
|
||||||
"error": "שגיאה: {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": "העשרת מטא-נתונים ב-AI"
|
"enrichHfAgent": "העשרת מטא-נתונים ב-AI"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
@@ -2311,6 +2335,9 @@
|
|||||||
"autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}",
|
"autoOrganizeSuccess": "הארגון האוטומטי הושלם בהצלחה עבור {count} {type}",
|
||||||
"autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים",
|
"autoOrganizePartialSuccess": "הארגון האוטומטי הושלם עם {success} שהועברו, {failures} שנכשלו מתוך {total} מודלים",
|
||||||
"autoOrganizeFailed": "הארגון האוטומטי נכשל: {error}",
|
"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": "לא נבחרו מודלים"
|
"noModelsSelected": "לא נבחרו מודלים"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -2477,6 +2504,8 @@
|
|||||||
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
|
"mappingSaveFailed": "שמירת מיפויי מודל בסיס נכשלה: {message}",
|
||||||
"downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו",
|
"downloadTemplatesUpdated": "תבניות נתיב הורדה עודכנו",
|
||||||
"downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}",
|
"downloadTemplatesFailed": "שמירת תבניות נתיב הורדה נכשלה: {message}",
|
||||||
|
"filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated",
|
||||||
|
"filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}",
|
||||||
"recipesPathUpdated": "נתיב אחסון המתכונים עודכן",
|
"recipesPathUpdated": "נתיב אחסון המתכונים עודכן",
|
||||||
"recipesPathSaveFailed": "עדכון נתיב אחסון המתכונים נכשל: {message}",
|
"recipesPathSaveFailed": "עדכון נתיב אחסון המתכונים נכשל: {message}",
|
||||||
"settingsUpdated": "הגדרות עודכנו: {setting}",
|
"settingsUpdated": "הגדרות עודכנו: {setting}",
|
||||||
|
|||||||
@@ -681,6 +681,22 @@
|
|||||||
"validTemplate": "有効なテンプレート"
|
"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": {
|
"exampleImages": {
|
||||||
"downloadLocation": "ダウンロード場所",
|
"downloadLocation": "ダウンロード場所",
|
||||||
"downloadLocationPlaceholder": "例画像のフォルダパスを入力",
|
"downloadLocationPlaceholder": "例画像のフォルダパスを入力",
|
||||||
@@ -913,6 +929,14 @@
|
|||||||
"complete": "自動整理が完了しました",
|
"complete": "自動整理が完了しました",
|
||||||
"error": "エラー:{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": "メタデータをAIで補完"
|
"enrichHfAgent": "メタデータをAIで補完"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
@@ -2311,6 +2335,9 @@
|
|||||||
"autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました",
|
"autoOrganizeSuccess": "{count} {type} の自動整理が正常に完了しました",
|
||||||
"autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗",
|
"autoOrganizePartialSuccess": "自動整理が完了しました:{total} モデル中 {success} 移動、{failures} 失敗",
|
||||||
"autoOrganizeFailed": "自動整理に失敗しました:{error}",
|
"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": "モデルが選択されていません"
|
"noModelsSelected": "モデルが選択されていません"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -2477,6 +2504,8 @@
|
|||||||
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
|
"mappingSaveFailed": "ベースモデルマッピングの保存に失敗しました:{message}",
|
||||||
"downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました",
|
"downloadTemplatesUpdated": "ダウンロードパステンプレートが更新されました",
|
||||||
"downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}",
|
"downloadTemplatesFailed": "ダウンロードパステンプレートの保存に失敗しました:{message}",
|
||||||
|
"filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated",
|
||||||
|
"filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}",
|
||||||
"recipesPathUpdated": "レシピ保存先を更新しました",
|
"recipesPathUpdated": "レシピ保存先を更新しました",
|
||||||
"recipesPathSaveFailed": "レシピ保存先の更新に失敗しました: {message}",
|
"recipesPathSaveFailed": "レシピ保存先の更新に失敗しました: {message}",
|
||||||
"settingsUpdated": "設定が更新されました:{setting}",
|
"settingsUpdated": "設定が更新されました:{setting}",
|
||||||
|
|||||||
@@ -681,6 +681,22 @@
|
|||||||
"validTemplate": "유효한 템플릿"
|
"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": {
|
"exampleImages": {
|
||||||
"downloadLocation": "다운로드 위치",
|
"downloadLocation": "다운로드 위치",
|
||||||
"downloadLocationPlaceholder": "예시 이미지 폴더 경로를 입력하세요",
|
"downloadLocationPlaceholder": "예시 이미지 폴더 경로를 입력하세요",
|
||||||
@@ -913,6 +929,14 @@
|
|||||||
"complete": "자동 정리 완료",
|
"complete": "자동 정리 완료",
|
||||||
"error": "오류: {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": "AI로 메타데이터 보강"
|
"enrichHfAgent": "AI로 메타데이터 보강"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
@@ -2311,6 +2335,9 @@
|
|||||||
"autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다",
|
"autoOrganizeSuccess": "{count}개의 {type}에 대해 자동 정리가 성공적으로 완료되었습니다",
|
||||||
"autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패",
|
"autoOrganizePartialSuccess": "자동 정리 완료: 전체 {total}개 중 {success}개 이동, {failures}개 실패",
|
||||||
"autoOrganizeFailed": "자동 정리 실패: {error}",
|
"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": "선택된 모델이 없습니다"
|
"noModelsSelected": "선택된 모델이 없습니다"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -2477,6 +2504,8 @@
|
|||||||
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
|
"mappingSaveFailed": "베이스 모델 매핑 저장 실패: {message}",
|
||||||
"downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다",
|
"downloadTemplatesUpdated": "다운로드 경로 템플릿이 업데이트되었습니다",
|
||||||
"downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}",
|
"downloadTemplatesFailed": "다운로드 경로 템플릿 저장 실패: {message}",
|
||||||
|
"filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated",
|
||||||
|
"filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}",
|
||||||
"recipesPathUpdated": "레시피 저장 경로가 업데이트되었습니다",
|
"recipesPathUpdated": "레시피 저장 경로가 업데이트되었습니다",
|
||||||
"recipesPathSaveFailed": "레시피 저장 경로 업데이트 실패: {message}",
|
"recipesPathSaveFailed": "레시피 저장 경로 업데이트 실패: {message}",
|
||||||
"settingsUpdated": "설정 업데이트됨: {setting}",
|
"settingsUpdated": "설정 업데이트됨: {setting}",
|
||||||
|
|||||||
@@ -681,6 +681,22 @@
|
|||||||
"validTemplate": "Действительный шаблон"
|
"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": {
|
"exampleImages": {
|
||||||
"downloadLocation": "Место загрузки",
|
"downloadLocation": "Место загрузки",
|
||||||
"downloadLocationPlaceholder": "Введите путь к папке для примеров изображений",
|
"downloadLocationPlaceholder": "Введите путь к папке для примеров изображений",
|
||||||
@@ -913,6 +929,14 @@
|
|||||||
"complete": "Автоматическая организация завершена",
|
"complete": "Автоматическая организация завершена",
|
||||||
"error": "Ошибка: {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": "Обогатить метаданные с помощью ИИ"
|
"enrichHfAgent": "Обогатить метаданные с помощью ИИ"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
@@ -2311,6 +2335,9 @@
|
|||||||
"autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}",
|
"autoOrganizeSuccess": "Автоматическая организация успешно завершена для {count} {type}",
|
||||||
"autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей",
|
"autoOrganizePartialSuccess": "Автоматическая организация завершена: перемещено {success}, не удалось {failures} из {total} моделей",
|
||||||
"autoOrganizeFailed": "Ошибка автоматической организации: {error}",
|
"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": "Модели не выбраны"
|
"noModelsSelected": "Модели не выбраны"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -2477,6 +2504,8 @@
|
|||||||
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
|
"mappingSaveFailed": "Не удалось сохранить сопоставления базовых моделей: {message}",
|
||||||
"downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены",
|
"downloadTemplatesUpdated": "Шаблоны путей загрузки обновлены",
|
||||||
"downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}",
|
"downloadTemplatesFailed": "Не удалось сохранить шаблоны путей загрузки: {message}",
|
||||||
|
"filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated",
|
||||||
|
"filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}",
|
||||||
"recipesPathUpdated": "Путь хранения рецептов обновлён",
|
"recipesPathUpdated": "Путь хранения рецептов обновлён",
|
||||||
"recipesPathSaveFailed": "Не удалось обновить путь хранения рецептов: {message}",
|
"recipesPathSaveFailed": "Не удалось обновить путь хранения рецептов: {message}",
|
||||||
"settingsUpdated": "Настройки обновлены: {setting}",
|
"settingsUpdated": "Настройки обновлены: {setting}",
|
||||||
|
|||||||
@@ -681,6 +681,22 @@
|
|||||||
"validTemplate": "有效模板"
|
"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": {
|
"exampleImages": {
|
||||||
"downloadLocation": "下载位置",
|
"downloadLocation": "下载位置",
|
||||||
"downloadLocationPlaceholder": "输入示例图片文件夹路径",
|
"downloadLocationPlaceholder": "输入示例图片文件夹路径",
|
||||||
@@ -913,6 +929,14 @@
|
|||||||
"complete": "自动整理已完成",
|
"complete": "自动整理已完成",
|
||||||
"error": "错误:{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": "AI 元数据增强"
|
"enrichHfAgent": "AI 元数据增强"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
@@ -2311,6 +2335,9 @@
|
|||||||
"autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}",
|
"autoOrganizeSuccess": "自动整理已成功完成,共 {count} 个 {type}",
|
||||||
"autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型",
|
"autoOrganizePartialSuccess": "自动整理完成:已移动 {success} 个,{failures} 个失败,共 {total} 个模型",
|
||||||
"autoOrganizeFailed": "自动整理失败:{error}",
|
"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": "未选中模型"
|
"noModelsSelected": "未选中模型"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -2477,6 +2504,8 @@
|
|||||||
"mappingSaveFailed": "保存基础模型映射失败:{message}",
|
"mappingSaveFailed": "保存基础模型映射失败:{message}",
|
||||||
"downloadTemplatesUpdated": "下载路径模板已更新",
|
"downloadTemplatesUpdated": "下载路径模板已更新",
|
||||||
"downloadTemplatesFailed": "保存下载路径模板失败:{message}",
|
"downloadTemplatesFailed": "保存下载路径模板失败:{message}",
|
||||||
|
"filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated",
|
||||||
|
"filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}",
|
||||||
"recipesPathUpdated": "配方存储路径已更新",
|
"recipesPathUpdated": "配方存储路径已更新",
|
||||||
"recipesPathSaveFailed": "更新配方存储路径失败:{message}",
|
"recipesPathSaveFailed": "更新配方存储路径失败:{message}",
|
||||||
"settingsUpdated": "设置已更新:{setting}",
|
"settingsUpdated": "设置已更新:{setting}",
|
||||||
|
|||||||
@@ -681,6 +681,22 @@
|
|||||||
"validTemplate": "範本有效"
|
"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": {
|
"exampleImages": {
|
||||||
"downloadLocation": "下載位置",
|
"downloadLocation": "下載位置",
|
||||||
"downloadLocationPlaceholder": "輸入範例圖片的資料夾路徑",
|
"downloadLocationPlaceholder": "輸入範例圖片的資料夾路徑",
|
||||||
@@ -913,6 +929,14 @@
|
|||||||
"complete": "自動整理完成",
|
"complete": "自動整理完成",
|
||||||
"error": "錯誤:{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": "AI 中繼資料增強"
|
"enrichHfAgent": "AI 中繼資料增強"
|
||||||
},
|
},
|
||||||
"contextMenu": {
|
"contextMenu": {
|
||||||
@@ -2311,6 +2335,9 @@
|
|||||||
"autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理",
|
"autoOrganizeSuccess": "自動整理已成功完成,共 {count} 個 {type} 已整理",
|
||||||
"autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型",
|
"autoOrganizePartialSuccess": "自動整理完成:已移動 {success} 個,{failures} 個失敗,共 {total} 個模型",
|
||||||
"autoOrganizeFailed": "自動整理失敗:{error}",
|
"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": "未選擇任何模型"
|
"noModelsSelected": "未選擇任何模型"
|
||||||
},
|
},
|
||||||
"recipes": {
|
"recipes": {
|
||||||
@@ -2477,6 +2504,8 @@
|
|||||||
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
|
"mappingSaveFailed": "儲存基礎模型對應失敗:{message}",
|
||||||
"downloadTemplatesUpdated": "下載路徑範本已更新",
|
"downloadTemplatesUpdated": "下載路徑範本已更新",
|
||||||
"downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}",
|
"downloadTemplatesFailed": "儲存下載路徑範本失敗:{message}",
|
||||||
|
"filenameTemplatesUpdated": "[TODO: Translate] Filename templates updated",
|
||||||
|
"filenameTemplatesFailed": "[TODO: Translate] Failed to save filename templates: {message}",
|
||||||
"recipesPathUpdated": "配方儲存路徑已更新",
|
"recipesPathUpdated": "配方儲存路徑已更新",
|
||||||
"recipesPathSaveFailed": "更新配方儲存路徑失敗:{message}",
|
"recipesPathSaveFailed": "更新配方儲存路徑失敗:{message}",
|
||||||
"settingsUpdated": "設定已更新:{setting}",
|
"settingsUpdated": "設定已更新:{setting}",
|
||||||
|
|||||||
@@ -24,9 +24,11 @@ from ..services.use_cases import (
|
|||||||
AutoOrganizeUseCase,
|
AutoOrganizeUseCase,
|
||||||
BulkMetadataRefreshUseCase,
|
BulkMetadataRefreshUseCase,
|
||||||
DownloadModelUseCase,
|
DownloadModelUseCase,
|
||||||
|
FilenameTemplateUseCase,
|
||||||
)
|
)
|
||||||
from ..services.websocket_progress_callback import (
|
from ..services.websocket_progress_callback import (
|
||||||
WebSocketBroadcastCallback,
|
WebSocketBroadcastCallback,
|
||||||
|
WebSocketFilenameTemplateProgressCallback,
|
||||||
WebSocketProgressCallback,
|
WebSocketProgressCallback,
|
||||||
)
|
)
|
||||||
from ..utils.exif_utils import ExifUtils
|
from ..utils.exif_utils import ExifUtils
|
||||||
@@ -37,6 +39,7 @@ from .handlers.model_handlers import (
|
|||||||
ModelAutoOrganizeHandler,
|
ModelAutoOrganizeHandler,
|
||||||
ModelCivitaiHandler,
|
ModelCivitaiHandler,
|
||||||
ModelDownloadHandler,
|
ModelDownloadHandler,
|
||||||
|
ModelFilenameTemplateHandler,
|
||||||
ModelHandlerSet,
|
ModelHandlerSet,
|
||||||
ModelListingHandler,
|
ModelListingHandler,
|
||||||
ModelManagementHandler,
|
ModelManagementHandler,
|
||||||
@@ -83,6 +86,9 @@ class BaseModelRoutes(ABC):
|
|||||||
self.model_lifecycle_service: ModelLifecycleService | None = None
|
self.model_lifecycle_service: ModelLifecycleService | None = None
|
||||||
self.websocket_progress_callback = WebSocketProgressCallback()
|
self.websocket_progress_callback = WebSocketProgressCallback()
|
||||||
self.metadata_progress_callback = WebSocketBroadcastCallback()
|
self.metadata_progress_callback = WebSocketBroadcastCallback()
|
||||||
|
self.filename_template_progress_callback = (
|
||||||
|
WebSocketFilenameTemplateProgressCallback()
|
||||||
|
)
|
||||||
|
|
||||||
self._handler_set: ModelHandlerSet | None = None
|
self._handler_set: ModelHandlerSet | None = None
|
||||||
self._handler_mapping: Dict[str, Callable[[web.Request], Awaitable[web.Response]]] | None = None
|
self._handler_mapping: Dict[str, Callable[[web.Request], Awaitable[web.Response]]] | None = None
|
||||||
@@ -202,6 +208,17 @@ class BaseModelRoutes(ABC):
|
|||||||
ws_manager=self._ws_manager,
|
ws_manager=self._ws_manager,
|
||||||
logger=logger,
|
logger=logger,
|
||||||
)
|
)
|
||||||
|
filename_template_use_case = FilenameTemplateUseCase(
|
||||||
|
scanner=service.scanner,
|
||||||
|
lifecycle_service=self._ensure_lifecycle_service(),
|
||||||
|
lock_provider=self._ws_manager,
|
||||||
|
model_type=service.model_type,
|
||||||
|
)
|
||||||
|
filename_template = ModelFilenameTemplateHandler(
|
||||||
|
use_case=filename_template_use_case,
|
||||||
|
progress_callback=self.filename_template_progress_callback,
|
||||||
|
logger=logger,
|
||||||
|
)
|
||||||
updates = ModelUpdateHandler(
|
updates = ModelUpdateHandler(
|
||||||
service=service,
|
service=service,
|
||||||
update_service=update_service,
|
update_service=update_service,
|
||||||
@@ -218,6 +235,7 @@ class BaseModelRoutes(ABC):
|
|||||||
civitai=civitai,
|
civitai=civitai,
|
||||||
move=move,
|
move=move,
|
||||||
auto_organize=auto_organize,
|
auto_organize=auto_organize,
|
||||||
|
filename_template=filename_template,
|
||||||
updates=updates,
|
updates=updates,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -37,10 +37,14 @@ from ...services.use_cases import (
|
|||||||
DownloadModelEarlyAccessError,
|
DownloadModelEarlyAccessError,
|
||||||
DownloadModelUseCase,
|
DownloadModelUseCase,
|
||||||
DownloadModelValidationError,
|
DownloadModelValidationError,
|
||||||
|
FilenameTemplateUseCase,
|
||||||
MetadataRefreshProgressReporter,
|
MetadataRefreshProgressReporter,
|
||||||
)
|
)
|
||||||
from ...services.websocket_manager import WebSocketManager
|
from ...services.websocket_manager import WebSocketManager
|
||||||
from ...services.websocket_progress_callback import WebSocketProgressCallback
|
from ...services.websocket_progress_callback import (
|
||||||
|
WebSocketFilenameTemplateProgressCallback,
|
||||||
|
WebSocketProgressCallback,
|
||||||
|
)
|
||||||
from ...services.download_queue_service import DownloadQueueService
|
from ...services.download_queue_service import DownloadQueueService
|
||||||
from ...services.errors import RateLimitError, ResourceNotFoundError
|
from ...services.errors import RateLimitError, ResourceNotFoundError
|
||||||
from ...utils.civitai_utils import resolve_license_payload
|
from ...utils.civitai_utils import resolve_license_payload
|
||||||
@@ -2692,6 +2696,71 @@ class ModelAutoOrganizeHandler:
|
|||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
|
class ModelFilenameTemplateHandler:
|
||||||
|
"""Apply the configured filename template to existing library models."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
use_case: FilenameTemplateUseCase,
|
||||||
|
progress_callback: WebSocketFilenameTemplateProgressCallback,
|
||||||
|
logger: logging.Logger,
|
||||||
|
) -> None:
|
||||||
|
self._use_case = use_case
|
||||||
|
self._progress_callback = progress_callback
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
async def apply_filename_template(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
file_paths = None
|
||||||
|
if request.method == "POST":
|
||||||
|
try:
|
||||||
|
data = await request.json()
|
||||||
|
file_paths = data.get("file_paths")
|
||||||
|
except Exception: # pragma: no cover - permissive path
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
# GET variant (browser extension is GET-only): comma-separated
|
||||||
|
# file_paths query parameter.
|
||||||
|
raw_file_paths = request.query.get("file_paths")
|
||||||
|
if raw_file_paths:
|
||||||
|
file_paths = [
|
||||||
|
path.strip()
|
||||||
|
for path in raw_file_paths.split(",")
|
||||||
|
if path.strip()
|
||||||
|
]
|
||||||
|
|
||||||
|
result = await self._use_case.execute(
|
||||||
|
file_paths=file_paths,
|
||||||
|
progress_callback=self._progress_callback,
|
||||||
|
)
|
||||||
|
_broadcast_models_changed()
|
||||||
|
return web.json_response(result.to_dict())
|
||||||
|
except AutoOrganizeInProgressError:
|
||||||
|
return web.json_response(
|
||||||
|
{
|
||||||
|
"success": False,
|
||||||
|
"error": "Another library operation is already running. Please wait for it to complete.",
|
||||||
|
},
|
||||||
|
status=409,
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error(
|
||||||
|
"Error in apply_filename_template: %s", exc, exc_info=True
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
await self._progress_callback.on_progress(
|
||||||
|
{
|
||||||
|
"type": "filename_template_progress",
|
||||||
|
"status": "error",
|
||||||
|
"error": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
except Exception: # pragma: no cover - defensive reporting
|
||||||
|
pass
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
|
||||||
class ModelUpdateHandler:
|
class ModelUpdateHandler:
|
||||||
"""Handle update tracking requests."""
|
"""Handle update tracking requests."""
|
||||||
|
|
||||||
@@ -3459,6 +3528,7 @@ class ModelHandlerSet:
|
|||||||
civitai: ModelCivitaiHandler
|
civitai: ModelCivitaiHandler
|
||||||
move: ModelMoveHandler
|
move: ModelMoveHandler
|
||||||
auto_organize: ModelAutoOrganizeHandler
|
auto_organize: ModelAutoOrganizeHandler
|
||||||
|
filename_template: ModelFilenameTemplateHandler
|
||||||
updates: ModelUpdateHandler
|
updates: ModelUpdateHandler
|
||||||
|
|
||||||
def to_route_mapping(
|
def to_route_mapping(
|
||||||
@@ -3523,6 +3593,7 @@ class ModelHandlerSet:
|
|||||||
"rename_folder": self.move.rename_folder,
|
"rename_folder": self.move.rename_folder,
|
||||||
"auto_organize_models": self.auto_organize.auto_organize_models,
|
"auto_organize_models": self.auto_organize.auto_organize_models,
|
||||||
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
|
"get_auto_organize_progress": self.auto_organize.get_auto_organize_progress,
|
||||||
|
"apply_filename_template": self.filename_template.apply_filename_template,
|
||||||
"get_model_notes": self.query.get_model_notes,
|
"get_model_notes": self.query.get_model_notes,
|
||||||
"get_model_preview_url": self.query.get_model_preview_url,
|
"get_model_preview_url": self.query.get_model_preview_url,
|
||||||
"get_model_civitai_url": self.query.get_model_civitai_url,
|
"get_model_civitai_url": self.query.get_model_civitai_url,
|
||||||
|
|||||||
@@ -48,6 +48,12 @@ COMMON_ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition(
|
RouteDefinition(
|
||||||
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
|
"GET", "/api/lm/{prefix}/auto-organize-progress", "get_auto_organize_progress"
|
||||||
),
|
),
|
||||||
|
RouteDefinition(
|
||||||
|
"GET", "/api/lm/{prefix}/apply-filename-template", "apply_filename_template"
|
||||||
|
),
|
||||||
|
RouteDefinition(
|
||||||
|
"POST", "/api/lm/{prefix}/apply-filename-template", "apply_filename_template"
|
||||||
|
),
|
||||||
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
|
RouteDefinition("GET", "/api/lm/{prefix}/top-tags", "get_top_tags"),
|
||||||
RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"),
|
RouteDefinition("GET", "/api/lm/{prefix}/search-tags", "search_tags"),
|
||||||
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
|
RouteDefinition("GET", "/api/lm/{prefix}/base-models", "get_base_models"),
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ from ..utils.constants import (
|
|||||||
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
from ..utils.civitai_utils import normalize_civitai_download_url, rewrite_preview_url
|
||||||
from ..utils.file_utils import calculate_sha256, calculate_autov3
|
from ..utils.file_utils import calculate_sha256, calculate_autov3
|
||||||
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
from ..utils.preview_selection import resolve_mature_threshold, select_preview_media
|
||||||
from ..utils.utils import sanitize_folder_name
|
from ..utils.utils import calculate_filename_for_model, sanitize_folder_name
|
||||||
from ..utils.exif_utils import ExifUtils
|
from ..utils.exif_utils import ExifUtils
|
||||||
from ..utils.metadata_manager import MetadataManager
|
from ..utils.metadata_manager import MetadataManager
|
||||||
from .service_registry import ServiceRegistry
|
from .service_registry import ServiceRegistry
|
||||||
@@ -45,6 +45,7 @@ from .errors import RateLimitError
|
|||||||
from .aria2_downloader import Aria2Error, get_aria2_downloader
|
from .aria2_downloader import Aria2Error, get_aria2_downloader
|
||||||
from .aria2_transfer_state import Aria2TransferStateStore
|
from .aria2_transfer_state import Aria2TransferStateStore
|
||||||
from .download_queue_service import DownloadQueueService
|
from .download_queue_service import DownloadQueueService
|
||||||
|
from .model_lifecycle_service import ModelLifecycleService, load_local_metadata
|
||||||
|
|
||||||
# Download to temporary file first
|
# Download to temporary file first
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -2746,6 +2747,7 @@ class DownloadManager:
|
|||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
downloaded_metadata: List[Dict[str, Any]] = []
|
||||||
for index, entry in enumerate(metadata_entries):
|
for index, entry in enumerate(metadata_entries):
|
||||||
file_path_for_adjust = getattr(
|
file_path_for_adjust = getattr(
|
||||||
entry, "file_path", actual_file_paths[index]
|
entry, "file_path", actual_file_paths[index]
|
||||||
@@ -2788,6 +2790,15 @@ class DownloadManager:
|
|||||||
if scanner is not None:
|
if scanner is not None:
|
||||||
await scanner.add_model_to_cache(metadata_dict, relative_path)
|
await scanner.add_model_to_cache(metadata_dict, relative_path)
|
||||||
|
|
||||||
|
downloaded_metadata.append(metadata_dict)
|
||||||
|
|
||||||
|
await self._apply_download_filename_template(
|
||||||
|
scanner=scanner,
|
||||||
|
model_type=model_type,
|
||||||
|
downloaded_metadata=downloaded_metadata,
|
||||||
|
download_id=download_id,
|
||||||
|
)
|
||||||
|
|
||||||
if transfer_backend == "aria2" and download_id:
|
if transfer_backend == "aria2" and download_id:
|
||||||
await self._aria2_state_store.remove(download_id)
|
await self._aria2_state_store.remove(download_id)
|
||||||
|
|
||||||
@@ -2827,6 +2838,83 @@ class DownloadManager:
|
|||||||
|
|
||||||
return {"success": False, "error": str(e)}
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
async def _apply_download_filename_template(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
scanner,
|
||||||
|
model_type: str,
|
||||||
|
downloaded_metadata: List[Dict[str, Any]],
|
||||||
|
download_id: Optional[str],
|
||||||
|
) -> None:
|
||||||
|
"""Rename freshly downloaded models according to the filename template.
|
||||||
|
|
||||||
|
Best-effort post-download step: any failure (including name conflicts)
|
||||||
|
is logged and skipped so a successful download is never turned into a
|
||||||
|
failure by a rename problem.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if scanner is None or not downloaded_metadata:
|
||||||
|
return
|
||||||
|
|
||||||
|
template = get_settings_manager().get_download_filename_template(
|
||||||
|
model_type
|
||||||
|
)
|
||||||
|
if not template:
|
||||||
|
return
|
||||||
|
|
||||||
|
lifecycle_service = ModelLifecycleService(
|
||||||
|
scanner=scanner,
|
||||||
|
metadata_manager=MetadataManager,
|
||||||
|
metadata_loader=load_local_metadata,
|
||||||
|
recipe_scanner_factory=ServiceRegistry.get_recipe_scanner,
|
||||||
|
)
|
||||||
|
|
||||||
|
for metadata_dict in downloaded_metadata:
|
||||||
|
file_path = metadata_dict.get("file_path")
|
||||||
|
if not isinstance(file_path, str) or not file_path:
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_stem = calculate_filename_for_model(metadata_dict, model_type)
|
||||||
|
if not new_stem:
|
||||||
|
continue
|
||||||
|
|
||||||
|
current_stem = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
|
if new_stem == current_stem or os.path.normcase(
|
||||||
|
new_stem
|
||||||
|
) == os.path.normcase(current_stem):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = await lifecycle_service.rename_model(
|
||||||
|
file_path=file_path, new_file_name=new_stem
|
||||||
|
)
|
||||||
|
except ValueError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"Keeping original filename for %s: %s", file_path, exc
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
new_file_path = result.get("new_file_path")
|
||||||
|
if download_id and isinstance(new_file_path, str):
|
||||||
|
info = self._active_downloads.get(download_id)
|
||||||
|
if info is None:
|
||||||
|
continue
|
||||||
|
if info.get("file_path") == file_path:
|
||||||
|
info["file_path"] = new_file_path
|
||||||
|
extracted = info.get("extracted_paths")
|
||||||
|
if isinstance(extracted, list):
|
||||||
|
info["extracted_paths"] = [
|
||||||
|
new_file_path if path == file_path else path
|
||||||
|
for path in extracted
|
||||||
|
]
|
||||||
|
except Exception as exc: # Rename phase must never fail the download
|
||||||
|
logger.warning(
|
||||||
|
"Filename template rename failed for %s download: %s",
|
||||||
|
model_type,
|
||||||
|
exc,
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]:
|
def _get_supported_extensions_for_type(self, model_type: str) -> Set[str]:
|
||||||
if model_type in ("checkpoint", "other"):
|
if model_type in ("checkpoint", "other"):
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -43,10 +43,22 @@ class AutoOrganizeResult:
|
|||||||
|
|
||||||
def to_dict(self) -> Dict[str, Any]:
|
def to_dict(self) -> Dict[str, Any]:
|
||||||
"""Convert result to dictionary"""
|
"""Convert result to dictionary"""
|
||||||
|
if self.operation_type == 'filename_template':
|
||||||
|
message = (
|
||||||
|
f'Filename template applied: {self.success_count} renamed, '
|
||||||
|
f'{self.skipped_count} skipped, {self.failure_count} failed '
|
||||||
|
f'out of {self.total} total'
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
f'Auto-organize {self.operation_type} completed: '
|
||||||
|
f'{self.success_count} moved, {self.skipped_count} skipped, '
|
||||||
|
f'{self.failure_count} failed out of {self.total} total'
|
||||||
|
)
|
||||||
result: Dict[str, Any] = {
|
result: Dict[str, Any] = {
|
||||||
'success': self.status != 'error',
|
'success': self.status != 'error',
|
||||||
'status': self.status,
|
'status': self.status,
|
||||||
'message': f'Auto-organize {self.operation_type} completed: {self.success_count} moved, {self.skipped_count} skipped, {self.failure_count} failed out of {self.total} total',
|
'message': message,
|
||||||
'summary': {
|
'summary': {
|
||||||
'total': self.total,
|
'total': self.total,
|
||||||
'success': self.success_count,
|
'success': self.success_count,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
|
from typing import Any, Awaitable, Callable, Dict, Iterable, List, Mapping, Optional, TYPE_CHECKING, cast
|
||||||
@@ -17,6 +18,26 @@ if TYPE_CHECKING:
|
|||||||
from ..services.model_update_service import ModelUpdateService
|
from ..services.model_update_service import ModelUpdateService
|
||||||
|
|
||||||
|
|
||||||
|
async def load_local_metadata(metadata_path: str) -> Dict[str, Any]:
|
||||||
|
"""Load a metadata sidecar JSON, returning an empty dict when missing.
|
||||||
|
|
||||||
|
Thin equivalent of ``MetadataSyncService.load_local_metadata`` for callers
|
||||||
|
(download manager, use cases) that do not hold a sync-service instance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not os.path.exists(metadata_path):
|
||||||
|
return {}
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(metadata_path, "r", encoding="utf-8") as handle:
|
||||||
|
payload = json.load(handle)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("Failed to load metadata from %s: %s", metadata_path, exc)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
return payload if isinstance(payload, dict) else {}
|
||||||
|
|
||||||
|
|
||||||
async def delete_model_artifacts(
|
async def delete_model_artifacts(
|
||||||
target_dir: str, file_name: str, main_extension: str | None = None
|
target_dir: str, file_name: str, main_extension: str | None = None
|
||||||
) -> List[str]:
|
) -> List[str]:
|
||||||
@@ -404,6 +425,9 @@ class ModelLifecycleService:
|
|||||||
if metadata and new_metadata_path:
|
if metadata and new_metadata_path:
|
||||||
metadata["file_name"] = new_file_name
|
metadata["file_name"] = new_file_name
|
||||||
metadata["file_path"] = new_file_path
|
metadata["file_path"] = new_file_path
|
||||||
|
# Preserve the pre-rename stem so the original download filename
|
||||||
|
# stays recoverable after template-driven renames.
|
||||||
|
metadata.setdefault("original_file_name", old_file_name)
|
||||||
|
|
||||||
if metadata.get("preview_url"):
|
if metadata.get("preview_url"):
|
||||||
old_preview = str(metadata["preview_url"])
|
old_preview = str(metadata["preview_url"])
|
||||||
|
|||||||
@@ -98,6 +98,7 @@ DEFAULT_SETTINGS: Dict[str, Any] = {
|
|||||||
"recipes_path": "",
|
"recipes_path": "",
|
||||||
"base_model_path_mappings": {},
|
"base_model_path_mappings": {},
|
||||||
"download_path_templates": {},
|
"download_path_templates": {},
|
||||||
|
"download_filename_templates": {},
|
||||||
"folder_paths": {},
|
"folder_paths": {},
|
||||||
"extra_folder_paths": {},
|
"extra_folder_paths": {},
|
||||||
"example_images_path": "",
|
"example_images_path": "",
|
||||||
@@ -1276,6 +1277,7 @@ class SettingsManager:
|
|||||||
defaults = copy.deepcopy(DEFAULT_SETTINGS)
|
defaults = copy.deepcopy(DEFAULT_SETTINGS)
|
||||||
defaults["base_model_path_mappings"] = {}
|
defaults["base_model_path_mappings"] = {}
|
||||||
defaults["download_path_templates"] = {}
|
defaults["download_path_templates"] = {}
|
||||||
|
defaults["download_filename_templates"] = {}
|
||||||
defaults["priority_tags"] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
|
defaults["priority_tags"] = DEFAULT_PRIORITY_TAG_CONFIG.copy()
|
||||||
defaults.setdefault("folder_paths", {})
|
defaults.setdefault("folder_paths", {})
|
||||||
defaults.setdefault("extra_folder_paths", {})
|
defaults.setdefault("extra_folder_paths", {})
|
||||||
@@ -2424,6 +2426,49 @@ class SettingsManager:
|
|||||||
model_type, DEFAULT_DOWNLOAD_PATH_TEMPLATES.get(model_type, "")
|
model_type, DEFAULT_DOWNLOAD_PATH_TEMPLATES.get(model_type, "")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def get_download_filename_template(self, model_type: str) -> str:
|
||||||
|
"""Get the download filename template for a specific model type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_type: The type of model ('lora', 'checkpoint', 'embedding',
|
||||||
|
'other')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Template string for the model type. Empty string (the default for
|
||||||
|
every model type) means downloaded files keep their original
|
||||||
|
filename.
|
||||||
|
"""
|
||||||
|
templates = self.settings.get("download_filename_templates", {})
|
||||||
|
|
||||||
|
# Handle edge case where templates might be stored as JSON string
|
||||||
|
if isinstance(templates, str):
|
||||||
|
try:
|
||||||
|
parsed_templates = json.loads(templates)
|
||||||
|
if isinstance(parsed_templates, dict):
|
||||||
|
self.settings["download_filename_templates"] = parsed_templates
|
||||||
|
self._save_settings()
|
||||||
|
templates = parsed_templates
|
||||||
|
logger.info(
|
||||||
|
"Successfully parsed download_filename_templates from JSON string"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise ValueError("Parsed JSON is not a dictionary")
|
||||||
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
|
logger.warning(
|
||||||
|
f"Failed to parse download_filename_templates JSON string: {e}. Resetting to empty templates."
|
||||||
|
)
|
||||||
|
templates = {}
|
||||||
|
self.settings["download_filename_templates"] = templates
|
||||||
|
self._save_settings()
|
||||||
|
|
||||||
|
if not isinstance(templates, dict):
|
||||||
|
templates = {}
|
||||||
|
self.settings["download_filename_templates"] = templates
|
||||||
|
self._save_settings()
|
||||||
|
|
||||||
|
template = templates.get(model_type, "")
|
||||||
|
return template if isinstance(template, str) else ""
|
||||||
|
|
||||||
|
|
||||||
_SETTINGS_MANAGER: Optional["SettingsManager"] = None
|
_SETTINGS_MANAGER: Optional["SettingsManager"] = None
|
||||||
_SETTINGS_MANAGER_LOCK = Lock()
|
_SETTINGS_MANAGER_LOCK = Lock()
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from .example_images import (
|
|||||||
ImportExampleImagesUseCase,
|
ImportExampleImagesUseCase,
|
||||||
ImportExampleImagesValidationError,
|
ImportExampleImagesValidationError,
|
||||||
)
|
)
|
||||||
|
from .filename_template_use_case import FilenameTemplateUseCase
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"AutoOrganizeInProgressError",
|
"AutoOrganizeInProgressError",
|
||||||
@@ -34,4 +35,5 @@ __all__ = [
|
|||||||
"DownloadExampleImagesUseCase",
|
"DownloadExampleImagesUseCase",
|
||||||
"ImportExampleImagesUseCase",
|
"ImportExampleImagesUseCase",
|
||||||
"ImportExampleImagesValidationError",
|
"ImportExampleImagesValidationError",
|
||||||
|
"FilenameTemplateUseCase",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,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]
|
||||||
@@ -20,6 +20,8 @@ class WebSocketManager:
|
|||||||
self._last_init_progress: Dict[str, Dict[str, Any]] = {}
|
self._last_init_progress: Dict[str, Dict[str, Any]] = {}
|
||||||
# Add auto-organize progress tracking
|
# Add auto-organize progress tracking
|
||||||
self._auto_organize_progress: Optional[Dict[str, Any]] = None
|
self._auto_organize_progress: Optional[Dict[str, Any]] = None
|
||||||
|
# Add filename template progress tracking
|
||||||
|
self._filename_template_progress: Optional[Dict[str, Any]] = None
|
||||||
# Add recipe rematch progress tracking
|
# Add recipe rematch progress tracking
|
||||||
self._recipe_rematch_progress: Optional[Dict[str, Any]] = None
|
self._recipe_rematch_progress: Optional[Dict[str, Any]] = None
|
||||||
self._auto_organize_lock = asyncio.Lock()
|
self._auto_organize_lock = asyncio.Lock()
|
||||||
@@ -205,6 +207,26 @@ class WebSocketManager:
|
|||||||
def cleanup_auto_organize_progress(self):
|
def cleanup_auto_organize_progress(self):
|
||||||
"""Clear auto-organize progress data"""
|
"""Clear auto-organize progress data"""
|
||||||
self._auto_organize_progress = None
|
self._auto_organize_progress = None
|
||||||
|
|
||||||
|
async def broadcast_filename_template_progress(self, data: Dict[str, Any]):
|
||||||
|
"""Broadcast filename template progress to connected clients"""
|
||||||
|
self._filename_template_progress = data
|
||||||
|
await self.broadcast(data)
|
||||||
|
|
||||||
|
def get_filename_template_progress(self) -> Optional[Dict[str, Any]]:
|
||||||
|
"""Get current filename template progress"""
|
||||||
|
return self._filename_template_progress
|
||||||
|
|
||||||
|
def cleanup_filename_template_progress(self):
|
||||||
|
"""Clear filename template progress data"""
|
||||||
|
self._filename_template_progress = None
|
||||||
|
|
||||||
|
def is_filename_template_running(self) -> bool:
|
||||||
|
"""Check if a filename template operation is currently running"""
|
||||||
|
if not self._filename_template_progress:
|
||||||
|
return False
|
||||||
|
status = self._filename_template_progress.get('status')
|
||||||
|
return status in ['started', 'processing']
|
||||||
|
|
||||||
async def broadcast_recipe_rematch_progress(self, data: Dict[str, Any]):
|
async def broadcast_recipe_rematch_progress(self, data: Dict[str, Any]):
|
||||||
"""Broadcast recipe rematch progress to connected clients"""
|
"""Broadcast recipe rematch progress to connected clients"""
|
||||||
|
|||||||
@@ -21,6 +21,14 @@ class WebSocketProgressCallback(ProgressCallback):
|
|||||||
await ws_manager.broadcast_auto_organize_progress(progress_data)
|
await ws_manager.broadcast_auto_organize_progress(progress_data)
|
||||||
|
|
||||||
|
|
||||||
|
class WebSocketFilenameTemplateProgressCallback(ProgressCallback):
|
||||||
|
"""WebSocket progress callback for filename template operations."""
|
||||||
|
|
||||||
|
async def on_progress(self, progress_data: Dict[str, Any]) -> None:
|
||||||
|
"""Send filename template progress via WebSocket."""
|
||||||
|
await ws_manager.broadcast_filename_template_progress(progress_data)
|
||||||
|
|
||||||
|
|
||||||
class WebSocketBroadcastCallback:
|
class WebSocketBroadcastCallback:
|
||||||
"""Generic WebSocket progress callback broadcasting to all clients."""
|
"""Generic WebSocket progress callback broadcasting to all clients."""
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
from difflib import SequenceMatcher
|
from difflib import SequenceMatcher
|
||||||
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
@@ -7,6 +8,8 @@ from ..config import config
|
|||||||
from ..services.settings_manager import get_settings_manager
|
from ..services.settings_manager import get_settings_manager
|
||||||
import asyncio
|
import asyncio
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
def get_lora_info(lora_name):
|
def get_lora_info(lora_name):
|
||||||
"""Get the lora path and trigger words from cache"""
|
"""Get the lora path and trigger words from cache"""
|
||||||
@@ -598,6 +601,107 @@ def calculate_relative_path_for_model(
|
|||||||
return formatted_path
|
return formatted_path
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_filename_for_model(
|
||||||
|
model_data: Dict[str, Any], model_type: str = "lora"
|
||||||
|
) -> str:
|
||||||
|
"""Calculate the filename stem for a model using the filename template.
|
||||||
|
|
||||||
|
Mirrors the data extraction of :func:`calculate_relative_path_for_model`
|
||||||
|
but renders a single filename (no path segments). Missing values resolve
|
||||||
|
to empty segments instead of the path-oriented defaults ("Anonymous" /
|
||||||
|
"no tags") so templates degrade gracefully.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
model_data: Model data from scanner cache
|
||||||
|
model_type: Type of model ('lora', 'checkpoint', 'embedding')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Sanitized filename stem without extension, or an empty string when no
|
||||||
|
template is configured, the template is invalid, or the rendered name
|
||||||
|
is empty.
|
||||||
|
"""
|
||||||
|
settings_manager = get_settings_manager()
|
||||||
|
template = settings_manager.get_download_filename_template(model_type)
|
||||||
|
|
||||||
|
if not template:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
# A filename template must render a single name, never folder segments.
|
||||||
|
if "/" in template or "\\" in template:
|
||||||
|
logger.warning(
|
||||||
|
"Filename template for %s contains a path separator and is ignored: %r",
|
||||||
|
model_type,
|
||||||
|
template,
|
||||||
|
)
|
||||||
|
return ""
|
||||||
|
|
||||||
|
civitai_data = model_data.get("civitai", {})
|
||||||
|
|
||||||
|
author = ""
|
||||||
|
if isinstance(civitai_data, dict) and civitai_data.get("id") is not None:
|
||||||
|
creator_info = civitai_data.get("creator") or {}
|
||||||
|
author = creator_info.get("username") or ""
|
||||||
|
|
||||||
|
base_model = model_data.get("base_model", "")
|
||||||
|
base_model_mappings = settings_manager.get("base_model_path_mappings", {})
|
||||||
|
mapped_base_model = base_model_mappings.get(base_model, base_model)
|
||||||
|
|
||||||
|
lowercase_tags = [
|
||||||
|
tag.lower() for tag in model_data.get("tags", []) if isinstance(tag, str)
|
||||||
|
]
|
||||||
|
first_tag = settings_manager.resolve_priority_tag_for_model(
|
||||||
|
lowercase_tags, model_type
|
||||||
|
)
|
||||||
|
|
||||||
|
model_name = model_data.get("model_name", "")
|
||||||
|
version_name = ""
|
||||||
|
if isinstance(civitai_data, dict):
|
||||||
|
version_name = civitai_data.get("name") or ""
|
||||||
|
|
||||||
|
sha256 = model_data.get("sha256") or ""
|
||||||
|
hash_short = sha256[:10].lower() if isinstance(sha256, str) else ""
|
||||||
|
|
||||||
|
file_path = model_data.get("file_path") or ""
|
||||||
|
if isinstance(file_path, str) and file_path:
|
||||||
|
original_name = os.path.splitext(os.path.basename(file_path))[0]
|
||||||
|
else:
|
||||||
|
original_name = os.path.splitext(str(model_data.get("file_name", "")))[0]
|
||||||
|
|
||||||
|
def _sanitize_value(value: Any) -> str:
|
||||||
|
# sanitize_folder_name falls back to "unnamed" for empty input; for
|
||||||
|
# templates an empty value must stay empty so segments collapse.
|
||||||
|
text = str(value) if value else ""
|
||||||
|
return sanitize_folder_name(text) if text else ""
|
||||||
|
|
||||||
|
replacements = {
|
||||||
|
"{model_name}": _sanitize_value(model_name),
|
||||||
|
"{version_name}": _sanitize_value(version_name),
|
||||||
|
"{base_model}": _sanitize_value(mapped_base_model),
|
||||||
|
"{author}": _sanitize_value(author),
|
||||||
|
"{first_tag}": _sanitize_value(first_tag),
|
||||||
|
"{hash_short}": hash_short,
|
||||||
|
"{original_name}": _sanitize_value(original_name),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = template
|
||||||
|
for placeholder, value in replacements.items():
|
||||||
|
result = result.replace(placeholder, value)
|
||||||
|
|
||||||
|
if model_type == "embedding":
|
||||||
|
result = result.replace(" ", "_")
|
||||||
|
|
||||||
|
# Strip characters that are illegal in filenames on common filesystems.
|
||||||
|
result = re.sub(r'[:*?"<>|]', "", result)
|
||||||
|
# Collapse runs of identical separators introduced by empty substitutions.
|
||||||
|
result = re.sub(r"([-_. ])\1+", r"\1", result)
|
||||||
|
# Drop separators left dangling next to each other ("- -" -> "-").
|
||||||
|
result = re.sub(r" ?([-_.]) (?=[-_.])", r"\1", result)
|
||||||
|
# A stem must not start or end with separators, spaces or dots.
|
||||||
|
result = result.strip("-_. ")
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def remove_empty_dirs(path):
|
def remove_empty_dirs(path):
|
||||||
"""Recursively remove empty directories starting from the given path.
|
"""Recursively remove empty directories starting from the given path.
|
||||||
|
|
||||||
|
|||||||
@@ -122,6 +122,9 @@ export function getApiEndpoints(modelType) {
|
|||||||
autoOrganize: `/api/lm/${modelType}/auto-organize`,
|
autoOrganize: `/api/lm/${modelType}/auto-organize`,
|
||||||
autoOrganizeProgress: `/api/lm/${modelType}/auto-organize-progress`,
|
autoOrganizeProgress: `/api/lm/${modelType}/auto-organize-progress`,
|
||||||
|
|
||||||
|
// Filename template operations
|
||||||
|
applyFilenameTemplate: `/api/lm/${modelType}/apply-filename-template`,
|
||||||
|
|
||||||
// Model-specific endpoints (will be merged with specific configs)
|
// Model-specific endpoints (will be merged with specific configs)
|
||||||
specific: {}
|
specific: {}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2175,6 +2175,135 @@ export class BaseModelApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply the configured download filename template to models, renaming their files
|
||||||
|
* @param {Array} filePaths - Optional array of file paths to rename. If not provided, applies to all models.
|
||||||
|
* @returns {Promise} - Promise that resolves when the operation is complete
|
||||||
|
*/
|
||||||
|
async applyFilenameTemplate(filePaths = null) {
|
||||||
|
let ws = null;
|
||||||
|
|
||||||
|
await state.loadingManager.showWithProgress(async (loading) => {
|
||||||
|
loading.showCancelButton(() => this.cancelTask());
|
||||||
|
try {
|
||||||
|
// Connect to WebSocket for progress updates
|
||||||
|
const wsProtocol = window.location.protocol === 'https:' ? 'wss://' : 'ws://';
|
||||||
|
ws = new WebSocket(`${wsProtocol}${window.location.host}${WS_ENDPOINTS.fetchProgress}`);
|
||||||
|
|
||||||
|
const operationComplete = new Promise((resolve, reject) => {
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
const data = JSON.parse(event.data);
|
||||||
|
|
||||||
|
if (data.type !== 'filename_template_progress') return;
|
||||||
|
|
||||||
|
switch (data.status) {
|
||||||
|
case 'started':
|
||||||
|
loading.setProgress(0);
|
||||||
|
const operationType = data.operation_type === 'bulk' ? 'selected models' : 'all models';
|
||||||
|
loading.setStatus(translate('loras.bulkOperations.filenameTemplateProgress.starting', { type: operationType }, `Applying filename template to ${operationType}...`));
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'processing':
|
||||||
|
const percent = data.total > 0 ? ((data.processed / data.total) * 90).toFixed(1) : 0;
|
||||||
|
loading.setProgress(percent);
|
||||||
|
loading.setStatus(
|
||||||
|
translate('loras.bulkOperations.filenameTemplateProgress.processing', {
|
||||||
|
processed: data.processed,
|
||||||
|
total: data.total,
|
||||||
|
success: data.success,
|
||||||
|
failures: data.failures,
|
||||||
|
skipped: data.skipped
|
||||||
|
}, `Processing (${data.processed}/${data.total}) - ${data.success} renamed, ${data.skipped} skipped, ${data.failures} failed`)
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'completed':
|
||||||
|
loading.setProgress(100);
|
||||||
|
loading.setStatus(
|
||||||
|
translate('loras.bulkOperations.filenameTemplateProgress.completed', {
|
||||||
|
success: data.success,
|
||||||
|
skipped: data.skipped,
|
||||||
|
failures: data.failures,
|
||||||
|
total: data.total
|
||||||
|
}, `Completed: ${data.success} renamed, ${data.skipped} skipped, ${data.failures} failed`)
|
||||||
|
);
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
resolve(data);
|
||||||
|
}, 1500);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'cancelled':
|
||||||
|
loading.setStatus(translate('toast.api.operationCancelled', {}, 'Operation cancelled by user'));
|
||||||
|
resolve(data);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'error':
|
||||||
|
loading.setStatus(translate('loras.bulkOperations.filenameTemplateProgress.error', { error: data.error }, `Error: ${data.error}`));
|
||||||
|
reject(new Error(data.error));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (error) => {
|
||||||
|
console.error('WebSocket error during filename template apply:', error);
|
||||||
|
reject(new Error('Connection error'));
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Start the filename template operation
|
||||||
|
const endpoint = this.apiConfig.endpoints.applyFilenameTemplate;
|
||||||
|
|
||||||
|
const requestBody = {};
|
||||||
|
if (filePaths) {
|
||||||
|
requestBody.file_paths = filePaths;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(requestBody),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json().catch(() => ({}));
|
||||||
|
throw new Error(errorData.error || 'Failed to start filename template operation');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the operation to complete via WebSocket
|
||||||
|
const result = await operationComplete;
|
||||||
|
|
||||||
|
// Show appropriate success message based on results
|
||||||
|
if (result.status === 'cancelled') {
|
||||||
|
showToast('toast.api.operationCancelledPartial', { success: result.success, total: result.total }, 'info');
|
||||||
|
} else if (result.failures === 0) {
|
||||||
|
showToast('toast.loras.filenameTemplateSuccess', {
|
||||||
|
count: result.success,
|
||||||
|
type: result.operation_type === 'bulk' ? 'selected models' : 'all models'
|
||||||
|
}, 'success');
|
||||||
|
} else {
|
||||||
|
showToast('toast.loras.filenameTemplatePartialSuccess', {
|
||||||
|
success: result.success,
|
||||||
|
failures: result.failures,
|
||||||
|
total: result.total
|
||||||
|
}, 'warning');
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error applying filename template:', error);
|
||||||
|
showToast('toast.loras.filenameTemplateFailed', { error: error.message }, 'error');
|
||||||
|
throw error;
|
||||||
|
} finally {
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
initialMessage: translate('loras.bulkOperations.filenameTemplateProgress.initializing', {}, 'Initializing filename template apply...'),
|
||||||
|
completionMessage: translate('loras.bulkOperations.filenameTemplateProgress.complete', {}, 'Filename template apply complete')
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async stopExampleImages() {
|
async stopExampleImages() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/api/lm/stop-example-images', {
|
const response = await fetch('/api/lm/stop-example-images', {
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
import { state, createDefaultSettings } from '../state/index.js';
|
import { state, createDefaultSettings } from '../state/index.js';
|
||||||
import { resetAndReload } from '../api/modelApiFactory.js';
|
import { resetAndReload, getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
import {
|
import {
|
||||||
DOWNLOAD_PATH_TEMPLATES,
|
DOWNLOAD_PATH_TEMPLATES,
|
||||||
MAPPABLE_BASE_MODELS,
|
MAPPABLE_BASE_MODELS,
|
||||||
PATH_TEMPLATE_PLACEHOLDERS,
|
PATH_TEMPLATE_PLACEHOLDERS,
|
||||||
DEFAULT_PATH_TEMPLATES,
|
DEFAULT_PATH_TEMPLATES,
|
||||||
|
FILENAME_TEMPLATE_PLACEHOLDERS,
|
||||||
|
DEFAULT_FILENAME_TEMPLATES,
|
||||||
DEFAULT_PRIORITY_TAG_CONFIG,
|
DEFAULT_PRIORITY_TAG_CONFIG,
|
||||||
getMappableBaseModelsDynamic
|
getMappableBaseModelsDynamic
|
||||||
} from '../utils/constants.js';
|
} from '../utils/constants.js';
|
||||||
@@ -36,6 +38,13 @@ const OTHER_SUB_TYPE_LABEL_KEYS = {
|
|||||||
controlnet: 'settings.folderSettings.subTypeControlnet',
|
controlnet: 'settings.folderSettings.subTypeControlnet',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Singular filename-template model type -> plural API model type (MODEL_TYPES)
|
||||||
|
const FILENAME_TEMPLATE_MODEL_TYPES = {
|
||||||
|
lora: 'loras',
|
||||||
|
checkpoint: 'checkpoints',
|
||||||
|
embedding: 'embeddings',
|
||||||
|
};
|
||||||
|
|
||||||
export class SettingsManager {
|
export class SettingsManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
@@ -147,6 +156,25 @@ export class SettingsManager {
|
|||||||
|
|
||||||
merged.download_path_templates = { ...DEFAULT_PATH_TEMPLATES, ...templates };
|
merged.download_path_templates = { ...DEFAULT_PATH_TEMPLATES, ...templates };
|
||||||
|
|
||||||
|
let filenameTemplates = backendSettings?.download_filename_templates;
|
||||||
|
if (typeof filenameTemplates === 'string') {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(filenameTemplates);
|
||||||
|
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||||
|
filenameTemplates = parsed;
|
||||||
|
}
|
||||||
|
} catch (parseError) {
|
||||||
|
console.warn('Failed to parse download_filename_templates string from backend, using defaults');
|
||||||
|
filenameTemplates = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!filenameTemplates || typeof filenameTemplates !== 'object' || Array.isArray(filenameTemplates)) {
|
||||||
|
filenameTemplates = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
merged.download_filename_templates = { ...DEFAULT_FILENAME_TEMPLATES, ...filenameTemplates };
|
||||||
|
|
||||||
const priorityTags = backendSettings?.priority_tags;
|
const priorityTags = backendSettings?.priority_tags;
|
||||||
const normalizedPriority = { ...DEFAULT_PRIORITY_TAG_CONFIG };
|
const normalizedPriority = { ...DEFAULT_PRIORITY_TAG_CONFIG };
|
||||||
if (priorityTags && typeof priorityTags === 'object' && !Array.isArray(priorityTags)) {
|
if (priorityTags && typeof priorityTags === 'object' && !Array.isArray(priorityTags)) {
|
||||||
@@ -428,6 +456,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');
|
const autoOrganizeInput = document.getElementById('autoOrganizeExclusions');
|
||||||
if (autoOrganizeInput) {
|
if (autoOrganizeInput) {
|
||||||
autoOrganizeInput.addEventListener('keydown', (event) => {
|
autoOrganizeInput.addEventListener('keydown', (event) => {
|
||||||
@@ -1142,6 +1195,9 @@ export class SettingsManager {
|
|||||||
// Load download path templates
|
// Load download path templates
|
||||||
this.loadDownloadPathTemplates();
|
this.loadDownloadPathTemplates();
|
||||||
|
|
||||||
|
// Load download filename templates
|
||||||
|
this.loadFilenameTemplates();
|
||||||
|
|
||||||
// Load priority tag settings
|
// Load priority tag settings
|
||||||
this.loadPriorityTagSettings();
|
this.loadPriorityTagSettings();
|
||||||
|
|
||||||
@@ -2797,6 +2853,146 @@ export class SettingsManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
loadFilenameTemplates() {
|
||||||
|
const templates = state.global.settings.download_filename_templates || DEFAULT_FILENAME_TEMPLATES;
|
||||||
|
|
||||||
|
['lora', 'checkpoint', 'embedding'].forEach(modelType => {
|
||||||
|
const input = document.getElementById(`${modelType}FilenameTemplate`);
|
||||||
|
if (!input) return;
|
||||||
|
|
||||||
|
const template = templates[modelType] || '';
|
||||||
|
input.value = template;
|
||||||
|
this.validateFilenameTemplate(modelType, template);
|
||||||
|
this.updateFilenamePreview(modelType, template);
|
||||||
|
this.updateFilenameTemplateApplyButton(modelType, template);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
validateFilenameTemplate(modelType, template) {
|
||||||
|
const validationElement = document.getElementById(`${modelType}FilenameValidation`);
|
||||||
|
if (!validationElement) return true;
|
||||||
|
|
||||||
|
// Reset validation state
|
||||||
|
validationElement.innerHTML = '';
|
||||||
|
validationElement.className = 'template-validation';
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
validationElement.innerHTML = `<i class="fas fa-check"></i> ${translate('settings.filenameTemplates.validation.keepOriginal', {}, 'Valid (keep original filename)')}`;
|
||||||
|
validationElement.classList.add('valid');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A filename stem cannot contain path separators or OS-illegal characters
|
||||||
|
const invalidChars = /[/\\<>:"|?*]/;
|
||||||
|
if (invalidChars.test(template)) {
|
||||||
|
validationElement.innerHTML = `<i class="fas fa-times"></i> ${translate('settings.filenameTemplates.validation.invalidChars', {}, 'Invalid characters detected (a filename cannot contain / \\ < > : " | ? *)')}`;
|
||||||
|
validationElement.classList.add('invalid');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract placeholders
|
||||||
|
const placeholderRegex = /\{([^}]+)\}/g;
|
||||||
|
const matches = template.match(placeholderRegex) || [];
|
||||||
|
|
||||||
|
// Check for invalid placeholders
|
||||||
|
const invalidPlaceholders = matches.filter(match =>
|
||||||
|
!FILENAME_TEMPLATE_PLACEHOLDERS.includes(match)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (invalidPlaceholders.length > 0) {
|
||||||
|
validationElement.innerHTML = `<i class="fas fa-times"></i> ${translate('settings.filenameTemplates.validation.invalidPlaceholder', { placeholder: invalidPlaceholders[0] }, `Invalid placeholder: ${invalidPlaceholders[0]}`)}`;
|
||||||
|
validationElement.classList.add('invalid');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Template is valid
|
||||||
|
validationElement.innerHTML = `<i class="fas fa-check"></i> ${translate('settings.filenameTemplates.validation.validTemplate', {}, 'Valid template')}`;
|
||||||
|
validationElement.classList.add('valid');
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFilenameTemplate(modelType, template) {
|
||||||
|
if (!this.validateFilenameTemplate(modelType, template)) {
|
||||||
|
return; // Don't save invalid templates
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update state
|
||||||
|
if (!state.global.settings.download_filename_templates) {
|
||||||
|
state.global.settings.download_filename_templates = { ...DEFAULT_FILENAME_TEMPLATES };
|
||||||
|
}
|
||||||
|
state.global.settings.download_filename_templates[modelType] = template;
|
||||||
|
|
||||||
|
// Update preview and apply-button state
|
||||||
|
this.updateFilenamePreview(modelType, template);
|
||||||
|
this.updateFilenameTemplateApplyButton(modelType, template);
|
||||||
|
|
||||||
|
// Save settings
|
||||||
|
this.saveFilenameTemplates();
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFilenameTemplateApplyButton(modelType, template) {
|
||||||
|
const button = document.getElementById(`${modelType}ApplyFilenameTemplate`);
|
||||||
|
if (!button) return;
|
||||||
|
|
||||||
|
// An empty template keeps original filenames, so there is nothing to apply
|
||||||
|
button.disabled = !template;
|
||||||
|
}
|
||||||
|
|
||||||
|
updateFilenamePreview(modelType, template) {
|
||||||
|
const previewElement = document.getElementById(`${modelType}FilenamePreview`);
|
||||||
|
if (!previewElement) return;
|
||||||
|
|
||||||
|
if (!template) {
|
||||||
|
// Empty template keeps the original filename untouched
|
||||||
|
previewElement.textContent = 'V1.safetensors';
|
||||||
|
} else {
|
||||||
|
const exampleStem = template
|
||||||
|
.replaceAll('{model_name}', 'model-name')
|
||||||
|
.replaceAll('{version_name}', 'v3')
|
||||||
|
.replaceAll('{base_model}', 'Flux.1 D')
|
||||||
|
.replaceAll('{author}', 'authorname')
|
||||||
|
.replaceAll('{first_tag}', 'style')
|
||||||
|
.replaceAll('{hash_short}', 'a1b2c3d4e5')
|
||||||
|
.replaceAll('{original_name}', 'V1');
|
||||||
|
previewElement.textContent = `${exampleStem}.safetensors`;
|
||||||
|
}
|
||||||
|
previewElement.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
async saveFilenameTemplates() {
|
||||||
|
try {
|
||||||
|
// Save to backend using universal save method
|
||||||
|
await this.saveSetting('download_filename_templates', state.global.settings.download_filename_templates);
|
||||||
|
|
||||||
|
showToast('toast.settings.filenameTemplatesUpdated', {}, 'success');
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving download filename templates:', error);
|
||||||
|
showToast('toast.settings.filenameTemplatesFailed', { message: error.message }, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async applyFilenameTemplate(modelType) {
|
||||||
|
const template = state.global.settings.download_filename_templates?.[modelType] || '';
|
||||||
|
if (!template) {
|
||||||
|
showToast('settings.filenameTemplates.emptyTemplateInfo', {}, 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!confirm(translate('settings.filenameTemplates.confirmApply', {}, 'Rename all existing files of this model type according to the filename template? The original filename is preserved in each model\'s metadata.'))) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const apiClient = getModelApiClient(FILENAME_TEMPLATE_MODEL_TYPES[modelType]);
|
||||||
|
await apiClient.applyFilenameTemplate();
|
||||||
|
resetAndReload(true);
|
||||||
|
} catch (error) {
|
||||||
|
// The API client already surfaced a toast with the failure reason
|
||||||
|
console.error('Error applying filename template:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
toggleSettings() {
|
toggleSettings() {
|
||||||
if (this.isOpen) {
|
if (this.isOpen) {
|
||||||
modalManager.closeModal('settingsModal');
|
modalManager.closeModal('settingsModal');
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// Create the new hierarchical state structure
|
// Create the new hierarchical state structure
|
||||||
import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js';
|
import { getStorageItem, getMapFromStorage } from '../utils/storageHelpers.js';
|
||||||
import { MODEL_TYPES } from '../api/apiConfig.js';
|
import { MODEL_TYPES } from '../api/apiConfig.js';
|
||||||
import { DEFAULT_PATH_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js';
|
import { DEFAULT_PATH_TEMPLATES, DEFAULT_FILENAME_TEMPLATES, DEFAULT_PRIORITY_TAG_CONFIG } from '../utils/constants.js';
|
||||||
|
|
||||||
const DEFAULT_SETTINGS_BASE = Object.freeze({
|
const DEFAULT_SETTINGS_BASE = Object.freeze({
|
||||||
civitai_api_key: '',
|
civitai_api_key: '',
|
||||||
@@ -30,6 +30,7 @@ const DEFAULT_SETTINGS_BASE = Object.freeze({
|
|||||||
recipes_path: '',
|
recipes_path: '',
|
||||||
base_model_path_mappings: {},
|
base_model_path_mappings: {},
|
||||||
download_path_templates: {},
|
download_path_templates: {},
|
||||||
|
download_filename_templates: {},
|
||||||
example_images_path: '',
|
example_images_path: '',
|
||||||
example_images_open_mode: 'system',
|
example_images_open_mode: 'system',
|
||||||
example_images_local_root: '',
|
example_images_local_root: '',
|
||||||
@@ -74,6 +75,7 @@ export function createDefaultSettings() {
|
|||||||
...DEFAULT_SETTINGS_BASE,
|
...DEFAULT_SETTINGS_BASE,
|
||||||
base_model_path_mappings: {},
|
base_model_path_mappings: {},
|
||||||
download_path_templates: { ...DEFAULT_PATH_TEMPLATES },
|
download_path_templates: { ...DEFAULT_PATH_TEMPLATES },
|
||||||
|
download_filename_templates: { ...DEFAULT_FILENAME_TEMPLATES },
|
||||||
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
priority_tags: { ...DEFAULT_PRIORITY_TAG_CONFIG },
|
||||||
default_other_roots: {},
|
default_other_roots: {},
|
||||||
enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'],
|
enabled_other_sub_types: ['vae', 'upscaler', 'text_encoder'],
|
||||||
|
|||||||
@@ -360,6 +360,25 @@ export const DEFAULT_PATH_TEMPLATES = {
|
|||||||
other: ''
|
other: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Valid placeholders for download filename templates (opt-in rename of
|
||||||
|
// downloaded safetensors; the result is a filename stem, no path separators)
|
||||||
|
export const FILENAME_TEMPLATE_PLACEHOLDERS = [
|
||||||
|
'{model_name}',
|
||||||
|
'{version_name}',
|
||||||
|
'{base_model}',
|
||||||
|
'{author}',
|
||||||
|
'{first_tag}',
|
||||||
|
'{hash_short}',
|
||||||
|
'{original_name}'
|
||||||
|
];
|
||||||
|
|
||||||
|
// Default filename templates per model type; empty string keeps the original filename
|
||||||
|
export const DEFAULT_FILENAME_TEMPLATES = {
|
||||||
|
lora: '',
|
||||||
|
checkpoint: '',
|
||||||
|
embedding: ''
|
||||||
|
};
|
||||||
|
|
||||||
// Model type labels for UI
|
// Model type labels for UI
|
||||||
export const MODEL_TYPE_LABELS = {
|
export const MODEL_TYPE_LABELS = {
|
||||||
lora: 'LoRA Models',
|
lora: 'LoRA Models',
|
||||||
|
|||||||
@@ -198,6 +198,82 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Filename Templates -->
|
||||||
|
<div class="settings-subsection">
|
||||||
|
<div class="settings-subsection-header">
|
||||||
|
<h4>
|
||||||
|
{{ t('settings.filenameTemplates.title') }}
|
||||||
|
<i class="fas fa-info-circle info-icon" data-tooltip="{{ t('settings.filenameTemplates.help') }}"></i>
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="input-help">
|
||||||
|
<div class="placeholder-info">
|
||||||
|
<strong>{{ t('settings.filenameTemplates.availablePlaceholders') }}</strong>
|
||||||
|
<span class="placeholder-tag">{model_name}</span>
|
||||||
|
<span class="placeholder-tag">{version_name}</span>
|
||||||
|
<span class="placeholder-tag">{base_model}</span>
|
||||||
|
<span class="placeholder-tag">{author}</span>
|
||||||
|
<span class="placeholder-tag">{first_tag}</span>
|
||||||
|
<span class="placeholder-tag">{hash_short}</span>
|
||||||
|
<span class="placeholder-tag">{original_name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="loraFilenameTemplate">{{ t('settings.downloadPathTemplates.modelTypes.lora') }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<button type="button" id="loraApplyFilenameTemplate" class="primary-btn" onclick="settingsManager.applyFilenameTemplate('lora')">
|
||||||
|
{{ t('settings.filenameTemplates.applyButton') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="loraFilenameTemplate" class="template-custom-input" placeholder="{{ t('settings.filenameTemplates.templatePlaceholder') }}" />
|
||||||
|
<div class="template-validation" id="loraFilenameValidation"></div>
|
||||||
|
<div class="template-preview" id="loraFilenamePreview"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="checkpointFilenameTemplate">{{ t('settings.downloadPathTemplates.modelTypes.checkpoint') }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<button type="button" id="checkpointApplyFilenameTemplate" class="primary-btn" onclick="settingsManager.applyFilenameTemplate('checkpoint')">
|
||||||
|
{{ t('settings.filenameTemplates.applyButton') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="checkpointFilenameTemplate" class="template-custom-input" placeholder="{{ t('settings.filenameTemplates.templatePlaceholder') }}" />
|
||||||
|
<div class="template-validation" id="checkpointFilenameValidation"></div>
|
||||||
|
<div class="template-preview" id="checkpointFilenamePreview"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="setting-row">
|
||||||
|
<div class="setting-info">
|
||||||
|
<label for="embeddingFilenameTemplate">{{ t('settings.downloadPathTemplates.modelTypes.embedding') }}</label>
|
||||||
|
</div>
|
||||||
|
<div class="setting-control">
|
||||||
|
<button type="button" id="embeddingApplyFilenameTemplate" class="primary-btn" onclick="settingsManager.applyFilenameTemplate('embedding')">
|
||||||
|
{{ t('settings.filenameTemplates.applyButton') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input type="text" id="embeddingFilenameTemplate" class="template-custom-input" placeholder="{{ t('settings.filenameTemplates.templatePlaceholder') }}" />
|
||||||
|
<div class="template-validation" id="embeddingFilenameValidation"></div>
|
||||||
|
<div class="template-preview" id="embeddingFilenamePreview"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="setting-item">
|
||||||
|
<div class="input-help">{{ t('settings.filenameTemplates.applyHelp') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Auto-organize -->
|
<!-- Auto-organize -->
|
||||||
<div class="settings-subsection">
|
<div class="settings-subsection">
|
||||||
{{ sm.subsection_header('settings.sections.autoOrganize') }}
|
{{ sm.subsection_header('settings.sections.autoOrganize') }}
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
|
|||||||
DEFAULT_PATH_TEMPLATES: {},
|
DEFAULT_PATH_TEMPLATES: {},
|
||||||
MAPPABLE_BASE_MODELS: ['Flux.1 D', 'Pony', 'SDXL 1.0', 'Other'],
|
MAPPABLE_BASE_MODELS: ['Flux.1 D', 'Pony', 'SDXL 1.0', 'Other'],
|
||||||
PATH_TEMPLATE_PLACEHOLDERS: {},
|
PATH_TEMPLATE_PLACEHOLDERS: {},
|
||||||
|
FILENAME_TEMPLATE_PLACEHOLDERS: [],
|
||||||
|
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
|
||||||
DEFAULT_PRIORITY_TAG_CONFIG: {
|
DEFAULT_PRIORITY_TAG_CONFIG: {
|
||||||
lora: 'character, style',
|
lora: 'character, style',
|
||||||
checkpoint: 'base, guide',
|
checkpoint: 'base, guide',
|
||||||
|
|||||||
@@ -0,0 +1,276 @@
|
|||||||
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/managers/ModalManager.js', () => ({
|
||||||
|
modalManager: {
|
||||||
|
closeModal: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => ({
|
||||||
|
showToast: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/state/index.js', () => {
|
||||||
|
const settings = {};
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
global: {
|
||||||
|
settings,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
createDefaultSettings: () => ({
|
||||||
|
language: 'en',
|
||||||
|
download_filename_templates: { lora: '', checkpoint: '', embedding: '' },
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/api/modelApiFactory.js', () => ({
|
||||||
|
resetAndReload: vi.fn(),
|
||||||
|
getModelApiClient: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/constants.js', () => ({
|
||||||
|
DOWNLOAD_PATH_TEMPLATES: {},
|
||||||
|
DEFAULT_PATH_TEMPLATES: {},
|
||||||
|
MAPPABLE_BASE_MODELS: [],
|
||||||
|
PATH_TEMPLATE_PLACEHOLDERS: [],
|
||||||
|
FILENAME_TEMPLATE_PLACEHOLDERS: [
|
||||||
|
'{model_name}',
|
||||||
|
'{version_name}',
|
||||||
|
'{base_model}',
|
||||||
|
'{author}',
|
||||||
|
'{first_tag}',
|
||||||
|
'{hash_short}',
|
||||||
|
'{original_name}',
|
||||||
|
],
|
||||||
|
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
|
||||||
|
DEFAULT_PRIORITY_TAG_CONFIG: {
|
||||||
|
lora: 'character, style',
|
||||||
|
checkpoint: 'base, guide',
|
||||||
|
embedding: 'hint',
|
||||||
|
},
|
||||||
|
getMappableBaseModelsDynamic: () => [],
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/i18nHelpers.js', () => ({
|
||||||
|
translate: (key, params, fallback) => {
|
||||||
|
if (params && fallback) {
|
||||||
|
return fallback.replace(/\{(\w+)\}/g, (match, name) => params[name] ?? match);
|
||||||
|
}
|
||||||
|
return fallback ?? '';
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/i18n/index.js', () => ({
|
||||||
|
i18n: {
|
||||||
|
getCurrentLocale: () => 'en',
|
||||||
|
setLanguage: vi.fn().mockResolvedValue(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/shared/ModelCard.js', () => ({
|
||||||
|
configureModelCardVideo: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/managers/BannerService.js', () => ({
|
||||||
|
bannerService: {
|
||||||
|
registerBanner: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { SettingsManager } from '../../../static/js/managers/SettingsManager.js';
|
||||||
|
import { state } from '../../../static/js/state/index.js';
|
||||||
|
import { showToast } from '../../../static/js/utils/uiHelpers.js';
|
||||||
|
import { resetAndReload, getModelApiClient } from '../../../static/js/api/modelApiFactory.js';
|
||||||
|
|
||||||
|
const createManager = () => {
|
||||||
|
const initSettingsSpy = vi
|
||||||
|
.spyOn(SettingsManager.prototype, 'initializeSettings')
|
||||||
|
.mockResolvedValue();
|
||||||
|
const initializeSpy = vi
|
||||||
|
.spyOn(SettingsManager.prototype, 'initialize')
|
||||||
|
.mockImplementation(() => {});
|
||||||
|
|
||||||
|
const manager = new SettingsManager();
|
||||||
|
|
||||||
|
initSettingsSpy.mockRestore();
|
||||||
|
initializeSpy.mockRestore();
|
||||||
|
|
||||||
|
return manager;
|
||||||
|
};
|
||||||
|
|
||||||
|
const appendFilenameTemplateUi = (modelType = 'lora') => {
|
||||||
|
document.body.innerHTML = `
|
||||||
|
<input id="${modelType}FilenameTemplate" />
|
||||||
|
<div id="${modelType}FilenameValidation"></div>
|
||||||
|
<div id="${modelType}FilenamePreview"></div>
|
||||||
|
<button id="${modelType}ApplyFilenameTemplate" type="button"></button>
|
||||||
|
`;
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('SettingsManager filename templates', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '';
|
||||||
|
vi.clearAllMocks();
|
||||||
|
state.global.settings = {
|
||||||
|
download_filename_templates: { lora: '', checkpoint: '', embedding: '' },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats an empty template as valid (keep original filename)', () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
const manager = createManager();
|
||||||
|
|
||||||
|
expect(manager.validateFilenameTemplate('lora', '')).toBe(true);
|
||||||
|
|
||||||
|
const validation = document.getElementById('loraFilenameValidation');
|
||||||
|
expect(validation.classList.contains('valid')).toBe(true);
|
||||||
|
expect(validation.textContent).toContain('keep original filename');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects templates with path separators or OS-illegal characters', () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
const manager = createManager();
|
||||||
|
|
||||||
|
expect(manager.validateFilenameTemplate('lora', '{base_model}/{model_name}')).toBe(false);
|
||||||
|
expect(manager.validateFilenameTemplate('lora', 'a:b')).toBe(false);
|
||||||
|
|
||||||
|
const validation = document.getElementById('loraFilenameValidation');
|
||||||
|
expect(validation.classList.contains('invalid')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown placeholders', () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
const manager = createManager();
|
||||||
|
|
||||||
|
expect(manager.validateFilenameTemplate('lora', '{bogus}-{model_name}')).toBe(false);
|
||||||
|
|
||||||
|
const validation = document.getElementById('loraFilenameValidation');
|
||||||
|
expect(validation.textContent).toContain('{bogus}');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('accepts a template using only known placeholders', () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
const manager = createManager();
|
||||||
|
|
||||||
|
const template = '{base_model}-{model_name}-{version_name}-{hash_short}';
|
||||||
|
expect(manager.validateFilenameTemplate('lora', template)).toBe(true);
|
||||||
|
expect(document.getElementById('loraFilenameValidation').classList.contains('valid')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves a valid template via saveSetting with the merged dict', () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
const manager = createManager();
|
||||||
|
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||||
|
|
||||||
|
manager.updateFilenameTemplate('lora', '{model_name}');
|
||||||
|
|
||||||
|
expect(state.global.settings.download_filename_templates.lora).toBe('{model_name}');
|
||||||
|
expect(manager.saveSetting).toHaveBeenCalledWith(
|
||||||
|
'download_filename_templates',
|
||||||
|
{ lora: '{model_name}', checkpoint: '', embedding: '' },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not save an invalid template', () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
const manager = createManager();
|
||||||
|
manager.saveSetting = vi.fn().mockResolvedValue();
|
||||||
|
|
||||||
|
manager.updateFilenameTemplate('lora', '{unknown_placeholder}');
|
||||||
|
|
||||||
|
expect(state.global.settings.download_filename_templates.lora).toBe('');
|
||||||
|
expect(manager.saveSetting).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('previews the original filename when the template is empty', () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
const manager = createManager();
|
||||||
|
|
||||||
|
manager.updateFilenamePreview('lora', '');
|
||||||
|
|
||||||
|
expect(document.getElementById('loraFilenamePreview').textContent).toBe('V1.safetensors');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a preview with example placeholder values', () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
const manager = createManager();
|
||||||
|
|
||||||
|
manager.updateFilenamePreview('lora', '{base_model}-{model_name}-{version_name}-{hash_short}');
|
||||||
|
|
||||||
|
expect(document.getElementById('loraFilenamePreview').textContent)
|
||||||
|
.toBe('Flux.1 D-model-name-v3-a1b2c3d4e5.safetensors');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables the apply button when the template is empty', () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
const manager = createManager();
|
||||||
|
|
||||||
|
manager.updateFilenameTemplateApplyButton('lora', '');
|
||||||
|
expect(document.getElementById('loraApplyFilenameTemplate').disabled).toBe(true);
|
||||||
|
|
||||||
|
manager.updateFilenameTemplateApplyButton('lora', '{model_name}');
|
||||||
|
expect(document.getElementById('loraApplyFilenameTemplate').disabled).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('merges backend download_filename_templates over defaults', () => {
|
||||||
|
const manager = createManager();
|
||||||
|
|
||||||
|
const merged = manager.mergeSettingsWithDefaults({
|
||||||
|
download_filename_templates: { lora: '{model_name}' },
|
||||||
|
});
|
||||||
|
expect(merged.download_filename_templates).toEqual({
|
||||||
|
lora: '{model_name}',
|
||||||
|
checkpoint: '',
|
||||||
|
embedding: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fromString = manager.mergeSettingsWithDefaults({
|
||||||
|
download_filename_templates: '{"checkpoint":"{hash_short}"}',
|
||||||
|
});
|
||||||
|
expect(fromString.download_filename_templates).toEqual({
|
||||||
|
lora: '',
|
||||||
|
checkpoint: '{hash_short}',
|
||||||
|
embedding: '',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows an info toast and does nothing when applying an empty template', async () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
const manager = createManager();
|
||||||
|
vi.stubGlobal('confirm', vi.fn(() => true));
|
||||||
|
|
||||||
|
await manager.applyFilenameTemplate('lora');
|
||||||
|
|
||||||
|
expect(showToast).toHaveBeenCalledWith('settings.filenameTemplates.emptyTemplateInfo', {}, 'info');
|
||||||
|
expect(getModelApiClient).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies the template through the model API client and reloads', async () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
state.global.settings.download_filename_templates.lora = '{model_name}';
|
||||||
|
const manager = createManager();
|
||||||
|
vi.stubGlobal('confirm', vi.fn(() => true));
|
||||||
|
const apiClient = { applyFilenameTemplate: vi.fn().mockResolvedValue() };
|
||||||
|
getModelApiClient.mockReturnValue(apiClient);
|
||||||
|
|
||||||
|
await manager.applyFilenameTemplate('lora');
|
||||||
|
|
||||||
|
expect(getModelApiClient).toHaveBeenCalledWith('loras');
|
||||||
|
expect(apiClient.applyFilenameTemplate).toHaveBeenCalledWith();
|
||||||
|
expect(resetAndReload).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not apply when the confirm dialog is declined', async () => {
|
||||||
|
appendFilenameTemplateUi();
|
||||||
|
state.global.settings.download_filename_templates.lora = '{model_name}';
|
||||||
|
const manager = createManager();
|
||||||
|
vi.stubGlobal('confirm', vi.fn(() => false));
|
||||||
|
|
||||||
|
await manager.applyFilenameTemplate('lora');
|
||||||
|
|
||||||
|
expect(getModelApiClient).not.toHaveBeenCalled();
|
||||||
|
expect(resetAndReload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -38,6 +38,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
|
|||||||
DEFAULT_PATH_TEMPLATES: {},
|
DEFAULT_PATH_TEMPLATES: {},
|
||||||
MAPPABLE_BASE_MODELS: [],
|
MAPPABLE_BASE_MODELS: [],
|
||||||
PATH_TEMPLATE_PLACEHOLDERS: {},
|
PATH_TEMPLATE_PLACEHOLDERS: {},
|
||||||
|
FILENAME_TEMPLATE_PLACEHOLDERS: [],
|
||||||
|
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
|
||||||
DEFAULT_PRIORITY_TAG_CONFIG: {
|
DEFAULT_PRIORITY_TAG_CONFIG: {
|
||||||
lora: 'character, style',
|
lora: 'character, style',
|
||||||
checkpoint: 'base, guide',
|
checkpoint: 'base, guide',
|
||||||
|
|||||||
@@ -36,6 +36,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
|
|||||||
DEFAULT_PATH_TEMPLATES: {},
|
DEFAULT_PATH_TEMPLATES: {},
|
||||||
MAPPABLE_BASE_MODELS: [],
|
MAPPABLE_BASE_MODELS: [],
|
||||||
PATH_TEMPLATE_PLACEHOLDERS: {},
|
PATH_TEMPLATE_PLACEHOLDERS: {},
|
||||||
|
FILENAME_TEMPLATE_PLACEHOLDERS: [],
|
||||||
|
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
|
||||||
DEFAULT_PRIORITY_TAG_CONFIG: {},
|
DEFAULT_PRIORITY_TAG_CONFIG: {},
|
||||||
getMappableBaseModelsDynamic: () => [],
|
getMappableBaseModelsDynamic: () => [],
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ vi.mock('../../../static/js/utils/constants.js', () => ({
|
|||||||
DEFAULT_PATH_TEMPLATES: {},
|
DEFAULT_PATH_TEMPLATES: {},
|
||||||
MAPPABLE_BASE_MODELS: [],
|
MAPPABLE_BASE_MODELS: [],
|
||||||
PATH_TEMPLATE_PLACEHOLDERS: {},
|
PATH_TEMPLATE_PLACEHOLDERS: {},
|
||||||
|
FILENAME_TEMPLATE_PLACEHOLDERS: [],
|
||||||
|
DEFAULT_FILENAME_TEMPLATES: { lora: '', checkpoint: '', embedding: '' },
|
||||||
DEFAULT_PRIORITY_TAG_CONFIG: {},
|
DEFAULT_PRIORITY_TAG_CONFIG: {},
|
||||||
getMappableBaseModelsDynamic: () => [],
|
getMappableBaseModelsDynamic: () => [],
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from py.services.metadata_sync_service import MetadataSyncService
|
|||||||
from py.services.model_file_service import AutoOrganizeResult
|
from py.services.model_file_service import AutoOrganizeResult
|
||||||
from py.services.model_update_service import ModelVersionRecord
|
from py.services.model_update_service import ModelVersionRecord
|
||||||
from py.services.service_registry import ServiceRegistry
|
from py.services.service_registry import ServiceRegistry
|
||||||
|
from py.services.use_cases import FilenameTemplateUseCase
|
||||||
from py.services.websocket_manager import ws_manager
|
from py.services.websocket_manager import ws_manager
|
||||||
from py.utils.exif_utils import ExifUtils
|
from py.utils.exif_utils import ExifUtils
|
||||||
from py.utils.metadata_manager import MetadataManager
|
from py.utils.metadata_manager import MetadataManager
|
||||||
@@ -126,9 +127,11 @@ async def create_test_client(service) -> TestClient[Any, Any]:
|
|||||||
@pytest.fixture(autouse=True)
|
@pytest.fixture(autouse=True)
|
||||||
def reset_ws_manager_state():
|
def reset_ws_manager_state():
|
||||||
ws_manager.cleanup_auto_organize_progress()
|
ws_manager.cleanup_auto_organize_progress()
|
||||||
|
ws_manager.cleanup_filename_template_progress()
|
||||||
ws_manager._download_progress.clear()
|
ws_manager._download_progress.clear()
|
||||||
yield
|
yield
|
||||||
ws_manager.cleanup_auto_organize_progress()
|
ws_manager.cleanup_auto_organize_progress()
|
||||||
|
ws_manager.cleanup_filename_template_progress()
|
||||||
ws_manager._download_progress.clear()
|
ws_manager._download_progress.clear()
|
||||||
|
|
||||||
|
|
||||||
@@ -762,6 +765,105 @@ def test_auto_organize_conflict_when_running(mock_service):
|
|||||||
asyncio.run(scenario())
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_filename_template_route_emits_progress(
|
||||||
|
mock_service, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
async def fake_execute(self, file_paths=None, progress_callback=None):
|
||||||
|
result = AutoOrganizeResult()
|
||||||
|
result.total = 1
|
||||||
|
result.processed = 1
|
||||||
|
result.success_count = 1
|
||||||
|
result.operation_type = "filename_template"
|
||||||
|
if progress_callback is not None:
|
||||||
|
await progress_callback.on_progress(
|
||||||
|
{"type": "filename_template_progress", "status": "started"}
|
||||||
|
)
|
||||||
|
await progress_callback.on_progress(
|
||||||
|
{"type": "filename_template_progress", "status": "completed"}
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
monkeypatch.setattr(FilenameTemplateUseCase, "execute", fake_execute)
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
client = await create_test_client(mock_service)
|
||||||
|
try:
|
||||||
|
response = await client.post(
|
||||||
|
"/api/lm/test-models/apply-filename-template",
|
||||||
|
json={"file_paths": ["/tmp/a.safetensors"]},
|
||||||
|
)
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert payload["summary"]["operation_type"] == "filename_template"
|
||||||
|
|
||||||
|
progress = ws_manager.get_filename_template_progress()
|
||||||
|
assert progress is not None
|
||||||
|
assert progress["status"] == "completed"
|
||||||
|
# Auto-organize progress state must stay untouched.
|
||||||
|
assert ws_manager.get_auto_organize_progress() is None
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_filename_template_get_parses_query_file_paths(
|
||||||
|
mock_service, monkeypatch: pytest.MonkeyPatch
|
||||||
|
):
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_execute(self, file_paths=None, progress_callback=None):
|
||||||
|
captured["file_paths"] = file_paths
|
||||||
|
result = AutoOrganizeResult()
|
||||||
|
result.operation_type = "filename_template"
|
||||||
|
return result
|
||||||
|
|
||||||
|
monkeypatch.setattr(FilenameTemplateUseCase, "execute", fake_execute)
|
||||||
|
|
||||||
|
async def scenario():
|
||||||
|
client = await create_test_client(mock_service)
|
||||||
|
try:
|
||||||
|
response = await client.get(
|
||||||
|
"/api/lm/test-models/apply-filename-template",
|
||||||
|
params={"file_paths": "/tmp/a.safetensors, /tmp/b.safetensors"},
|
||||||
|
)
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["success"] is True
|
||||||
|
assert captured["file_paths"] == [
|
||||||
|
"/tmp/a.safetensors",
|
||||||
|
"/tmp/b.safetensors",
|
||||||
|
]
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
|
def test_apply_filename_template_conflict_when_running(mock_service):
|
||||||
|
async def scenario():
|
||||||
|
client = await create_test_client(mock_service)
|
||||||
|
try:
|
||||||
|
await ws_manager.broadcast_filename_template_progress(
|
||||||
|
{"type": "filename_template_progress", "status": "started"}
|
||||||
|
)
|
||||||
|
|
||||||
|
response = await client.post("/api/lm/test-models/apply-filename-template")
|
||||||
|
payload = await response.json()
|
||||||
|
|
||||||
|
assert response.status == 409
|
||||||
|
assert payload == {
|
||||||
|
"success": False,
|
||||||
|
"error": "Another library operation is already running. Please wait for it to complete.",
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
await client.close()
|
||||||
|
|
||||||
|
asyncio.run(scenario())
|
||||||
|
|
||||||
|
|
||||||
def test_download_model_returns_skipped_success(mock_service, download_manager_stub):
|
def test_download_model_returns_skipped_success(mock_service, download_manager_stub):
|
||||||
async def scenario():
|
async def scenario():
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
"""Tests for the post-download filename template rename phase."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from py.services.download_manager import DownloadManager
|
||||||
|
from py.services.service_registry import ServiceRegistry
|
||||||
|
from py.services.settings_manager import get_settings_manager
|
||||||
|
|
||||||
|
|
||||||
|
class DummyScanner:
|
||||||
|
def __init__(self, root: Path):
|
||||||
|
self._root = root
|
||||||
|
self.model_type = "lora"
|
||||||
|
self.updates = []
|
||||||
|
|
||||||
|
def get_model_roots(self):
|
||||||
|
return [str(self._root)]
|
||||||
|
|
||||||
|
async def update_single_model_cache(self, original_path, new_path, metadata):
|
||||||
|
self.updates.append((original_path, new_path, metadata))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def download_manager() -> DownloadManager:
|
||||||
|
return DownloadManager()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def no_recipe_scanner(monkeypatch: pytest.MonkeyPatch):
|
||||||
|
async def _no_scanner():
|
||||||
|
return None
|
||||||
|
|
||||||
|
monkeypatch.setattr(ServiceRegistry, "get_recipe_scanner", _no_scanner)
|
||||||
|
|
||||||
|
|
||||||
|
def _set_template(template: str, model_type: str = "lora") -> None:
|
||||||
|
manager = get_settings_manager()
|
||||||
|
templates = dict(manager.settings.get("download_filename_templates") or {})
|
||||||
|
templates[model_type] = template
|
||||||
|
manager.settings["download_filename_templates"] = templates
|
||||||
|
|
||||||
|
|
||||||
|
def _write_model(root: Path, stem: str, model_name: str, sha256: str) -> Path:
|
||||||
|
model_path = root / f"{stem}.safetensors"
|
||||||
|
model_path.write_bytes(b"model")
|
||||||
|
metadata_path = root / f"{stem}.metadata.json"
|
||||||
|
metadata_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"file_name": stem,
|
||||||
|
"file_path": model_path.as_posix(),
|
||||||
|
"model_name": model_name,
|
||||||
|
"sha256": sha256,
|
||||||
|
"civitai": {"id": 1},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return model_path
|
||||||
|
|
||||||
|
|
||||||
|
async def test_download_rename_applies_filename_template(
|
||||||
|
tmp_path: Path, download_manager: DownloadManager
|
||||||
|
):
|
||||||
|
_set_template("{model_name}-{hash_short}")
|
||||||
|
model_path = _write_model(tmp_path, "V1", "My Model", "abcdef0123456789")
|
||||||
|
download_manager._active_downloads["dl1"] = {"file_path": model_path.as_posix()}
|
||||||
|
|
||||||
|
downloaded_metadata = [
|
||||||
|
{
|
||||||
|
"file_path": model_path.as_posix(),
|
||||||
|
"file_name": "V1",
|
||||||
|
"model_name": "My Model",
|
||||||
|
"sha256": "abcdef0123456789",
|
||||||
|
"civitai": {"id": 1},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
await download_manager._apply_download_filename_template(
|
||||||
|
scanner=DummyScanner(tmp_path),
|
||||||
|
model_type="lora",
|
||||||
|
downloaded_metadata=downloaded_metadata,
|
||||||
|
download_id="dl1",
|
||||||
|
)
|
||||||
|
|
||||||
|
new_path = tmp_path / "My Model-abcdef0123.safetensors"
|
||||||
|
assert new_path.exists()
|
||||||
|
assert not model_path.exists()
|
||||||
|
|
||||||
|
new_metadata = json.loads(
|
||||||
|
(tmp_path / "My Model-abcdef0123.metadata.json").read_text()
|
||||||
|
)
|
||||||
|
assert new_metadata["original_file_name"] == "V1"
|
||||||
|
|
||||||
|
assert (
|
||||||
|
download_manager._active_downloads["dl1"]["file_path"]
|
||||||
|
== new_path.as_posix()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_download_rename_keeps_original_on_conflict(
|
||||||
|
tmp_path: Path, download_manager: DownloadManager
|
||||||
|
):
|
||||||
|
_set_template("{model_name}-{hash_short}")
|
||||||
|
model_path = _write_model(tmp_path, "V1", "My Model", "abcdef0123456789")
|
||||||
|
# Conflicting target already exists.
|
||||||
|
(tmp_path / "My Model-abcdef0123.safetensors").write_bytes(b"other")
|
||||||
|
|
||||||
|
downloaded_metadata = [
|
||||||
|
{
|
||||||
|
"file_path": model_path.as_posix(),
|
||||||
|
"file_name": "V1",
|
||||||
|
"model_name": "My Model",
|
||||||
|
"sha256": "abcdef0123456789",
|
||||||
|
"civitai": {"id": 1},
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
# Must not raise: a rename conflict never fails the download.
|
||||||
|
await download_manager._apply_download_filename_template(
|
||||||
|
scanner=DummyScanner(tmp_path),
|
||||||
|
model_type="lora",
|
||||||
|
downloaded_metadata=downloaded_metadata,
|
||||||
|
download_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert model_path.exists()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_download_rename_noop_without_template(
|
||||||
|
tmp_path: Path, download_manager: DownloadManager
|
||||||
|
):
|
||||||
|
_set_template("")
|
||||||
|
model_path = _write_model(tmp_path, "V1", "My Model", "abcdef0123456789")
|
||||||
|
|
||||||
|
await download_manager._apply_download_filename_template(
|
||||||
|
scanner=DummyScanner(tmp_path),
|
||||||
|
model_type="lora",
|
||||||
|
downloaded_metadata=[{"file_path": model_path.as_posix()}],
|
||||||
|
download_id=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert model_path.exists()
|
||||||
|
assert (tmp_path / "V1.metadata.json").exists()
|
||||||
@@ -424,6 +424,52 @@ async def test_rename_model_preserves_extension(tmp_path: Path):
|
|||||||
assert payload["file_name"] == new_name
|
assert payload["file_name"] == new_name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rename_model_records_original_file_name(tmp_path: Path):
|
||||||
|
old_name = "V1"
|
||||||
|
new_name = "flux-my-model-v3"
|
||||||
|
|
||||||
|
model_path = tmp_path / f"{old_name}.safetensors"
|
||||||
|
model_path.write_bytes(b"model")
|
||||||
|
|
||||||
|
metadata_path = tmp_path / f"{old_name}.metadata.json"
|
||||||
|
metadata_payload = {
|
||||||
|
"file_name": old_name,
|
||||||
|
"file_path": model_path.as_posix(),
|
||||||
|
}
|
||||||
|
metadata_path.write_text(json.dumps(metadata_payload))
|
||||||
|
|
||||||
|
async def metadata_loader(path: str):
|
||||||
|
with open(path, "r", encoding="utf-8") as handle:
|
||||||
|
return json.load(handle)
|
||||||
|
|
||||||
|
service = ModelLifecycleService(
|
||||||
|
scanner=DummyScanner(),
|
||||||
|
metadata_manager=PassthroughMetadataManager(),
|
||||||
|
metadata_loader=metadata_loader,
|
||||||
|
)
|
||||||
|
|
||||||
|
await service.rename_model(
|
||||||
|
file_path=model_path.as_posix(),
|
||||||
|
new_file_name=new_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
saved_metadata = json.loads((tmp_path / f"{new_name}.metadata.json").read_text())
|
||||||
|
assert saved_metadata["original_file_name"] == old_name
|
||||||
|
|
||||||
|
# A second rename keeps the very first recorded name.
|
||||||
|
second_name = "flux-my-model-v4"
|
||||||
|
await service.rename_model(
|
||||||
|
file_path=(tmp_path / f"{new_name}.safetensors").as_posix(),
|
||||||
|
new_file_name=second_name,
|
||||||
|
)
|
||||||
|
|
||||||
|
saved_metadata = json.loads(
|
||||||
|
(tmp_path / f"{second_name}.metadata.json").read_text()
|
||||||
|
)
|
||||||
|
assert saved_metadata["original_file_name"] == old_name
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_rename_model_with_dotted_basename(tmp_path: Path):
|
async def test_rename_model_with_dotted_basename(tmp_path: Path):
|
||||||
old_name = "model.v1"
|
old_name = "model.v1"
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from py.services.use_cases import (
|
|||||||
DownloadModelEarlyAccessError,
|
DownloadModelEarlyAccessError,
|
||||||
DownloadModelUseCase,
|
DownloadModelUseCase,
|
||||||
DownloadModelValidationError,
|
DownloadModelValidationError,
|
||||||
|
FilenameTemplateUseCase,
|
||||||
ImportExampleImagesUseCase,
|
ImportExampleImagesUseCase,
|
||||||
ImportExampleImagesValidationError,
|
ImportExampleImagesValidationError,
|
||||||
)
|
)
|
||||||
@@ -33,7 +34,7 @@ from py.utils.example_images_processor import (
|
|||||||
ExampleImagesValidationError,
|
ExampleImagesValidationError,
|
||||||
)
|
)
|
||||||
from py.utils.metadata_manager import MetadataManager
|
from py.utils.metadata_manager import MetadataManager
|
||||||
from tests.conftest import MockModelService, MockScanner
|
from tests.conftest import MockCache, MockModelService, MockScanner
|
||||||
|
|
||||||
|
|
||||||
class StubLockProvider:
|
class StubLockProvider:
|
||||||
@@ -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"]})
|
request = DummyJsonRequest({"model_hash": "abc", "file_paths": ["/tmp/file"]})
|
||||||
|
|
||||||
with pytest.raises(ExampleImagesImportError):
|
with pytest.raises(ExampleImagesImportError):
|
||||||
await use_case.execute(request) # pyright: ignore[reportArgumentType]
|
await use_case.execute(request) # pyright: ignore[reportArgumentType]
|
||||||
|
|
||||||
|
|
||||||
|
class StubLifecycleService:
|
||||||
|
def __init__(self, scanner: Optional[MockScanner] = None) -> None:
|
||||||
|
self.renames: List[Dict[str, str]] = []
|
||||||
|
self.error: Optional[Exception] = None
|
||||||
|
self.cancel_on_rename = False
|
||||||
|
self._scanner = scanner
|
||||||
|
|
||||||
|
async def rename_model(self, *, file_path: str, new_file_name: str) -> Dict[str, Any]:
|
||||||
|
if self.error is not None:
|
||||||
|
raise self.error
|
||||||
|
self.renames.append({"file_path": file_path, "new_file_name": new_file_name})
|
||||||
|
if self.cancel_on_rename and self._scanner is not None:
|
||||||
|
self._scanner.cancel_task()
|
||||||
|
return {"success": True, "new_file_path": file_path}
|
||||||
|
|
||||||
|
|
||||||
|
def _filename_template_model(
|
||||||
|
file_path: str,
|
||||||
|
model_name: str,
|
||||||
|
sha256: str = "abcdef0123456789",
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"file_path": file_path,
|
||||||
|
"file_name": file_path.rsplit("/", 1)[-1].rsplit(".", 1)[0],
|
||||||
|
"model_name": model_name,
|
||||||
|
"sha256": sha256,
|
||||||
|
"civitai": {"id": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _set_filename_template(template: str, model_type: str = "lora") -> None:
|
||||||
|
from py.services.settings_manager import get_settings_manager
|
||||||
|
|
||||||
|
manager = get_settings_manager()
|
||||||
|
templates = dict(manager.settings.get("download_filename_templates") or {})
|
||||||
|
templates[model_type] = template
|
||||||
|
manager.settings["download_filename_templates"] = templates
|
||||||
|
|
||||||
|
|
||||||
|
def _make_filename_template_use_case(
|
||||||
|
scanner: MockScanner,
|
||||||
|
lifecycle: StubLifecycleService,
|
||||||
|
lock_provider: Optional[StubLockProvider] = None,
|
||||||
|
) -> 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)
|
||||||
@@ -3,6 +3,7 @@ import pytest
|
|||||||
from py.services.settings_manager import SettingsManager, get_settings_manager
|
from py.services.settings_manager import SettingsManager, get_settings_manager
|
||||||
from py.services.service_registry import ServiceRegistry
|
from py.services.service_registry import ServiceRegistry
|
||||||
from py.utils.utils import (
|
from py.utils.utils import (
|
||||||
|
calculate_filename_for_model,
|
||||||
calculate_recipe_fingerprint,
|
calculate_recipe_fingerprint,
|
||||||
calculate_relative_path_for_model,
|
calculate_relative_path_for_model,
|
||||||
get_lora_info,
|
get_lora_info,
|
||||||
@@ -164,6 +165,145 @@ def test_calculate_recipe_fingerprint_empty_input():
|
|||||||
assert calculate_recipe_fingerprint([]) == ""
|
assert calculate_recipe_fingerprint([]) == ""
|
||||||
|
|
||||||
|
|
||||||
|
def _set_filename_templates(isolated_settings, template, model_types=("lora", "checkpoint", "embedding")):
|
||||||
|
isolated_settings["download_filename_templates"] = {
|
||||||
|
model_type: template for model_type in model_types
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_filename_returns_empty_without_template(isolated_settings):
|
||||||
|
model_data = {"model_name": "Model", "file_path": "/models/V1.safetensors"}
|
||||||
|
|
||||||
|
assert calculate_filename_for_model(model_data, "lora") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_filename_substitutes_all_placeholders(isolated_settings):
|
||||||
|
_set_filename_templates(
|
||||||
|
isolated_settings,
|
||||||
|
"{base_model}-{model_name}-{version_name}-{author}-{first_tag}-{hash_short}-{original_name}",
|
||||||
|
)
|
||||||
|
|
||||||
|
model_data = {
|
||||||
|
"model_name": "My Model",
|
||||||
|
"base_model": "SDXL",
|
||||||
|
"tags": ["Style"],
|
||||||
|
"sha256": "ABCDEF0123456789",
|
||||||
|
"file_path": "/models/V1.safetensors",
|
||||||
|
"civitai": {"id": 1, "name": "v3", "creator": {"username": "Author"}},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = calculate_filename_for_model(model_data, "lora")
|
||||||
|
|
||||||
|
assert result == "SDXL-My Model-v3-Author-style-abcdef0123-V1"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_filename_hash_short_empty_when_unknown(isolated_settings):
|
||||||
|
_set_filename_templates(isolated_settings, "{model_name}-{hash_short}")
|
||||||
|
|
||||||
|
model_data = {
|
||||||
|
"model_name": "My Model",
|
||||||
|
"file_path": "/models/V1.safetensors",
|
||||||
|
"civitai": {"id": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Missing hash leaves an empty segment; the dangling separator collapses.
|
||||||
|
assert calculate_filename_for_model(model_data, "lora") == "My Model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_filename_missing_metadata_produces_empty_segments(isolated_settings):
|
||||||
|
_set_filename_templates(isolated_settings, "{base_model}-{model_name}")
|
||||||
|
|
||||||
|
model_data = {
|
||||||
|
"model_name": "My Model",
|
||||||
|
"base_model": "",
|
||||||
|
"tags": [],
|
||||||
|
"file_path": "/models/V1.safetensors",
|
||||||
|
"civitai": {"id": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert calculate_filename_for_model(model_data, "lora") == "My Model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_filename_rejects_path_separators(isolated_settings):
|
||||||
|
_set_filename_templates(isolated_settings, "{base_model}/{model_name}")
|
||||||
|
|
||||||
|
model_data = {
|
||||||
|
"model_name": "My Model",
|
||||||
|
"base_model": "SDXL",
|
||||||
|
"file_path": "/models/V1.safetensors",
|
||||||
|
"civitai": {"id": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert calculate_filename_for_model(model_data, "lora") == ""
|
||||||
|
|
||||||
|
_set_filename_templates(isolated_settings, "{base_model}\\{model_name}")
|
||||||
|
assert calculate_filename_for_model(model_data, "lora") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_filename_strips_illegal_characters(isolated_settings):
|
||||||
|
_set_filename_templates(isolated_settings, '{model_name}:"custom"')
|
||||||
|
|
||||||
|
model_data = {
|
||||||
|
"model_name": "My:Model*",
|
||||||
|
"file_path": "/models/V1.safetensors",
|
||||||
|
"civitai": {"id": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
result = calculate_filename_for_model(model_data, "lora")
|
||||||
|
|
||||||
|
assert result == "My_Modelcustom"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_filename_empty_result_returns_empty(isolated_settings):
|
||||||
|
_set_filename_templates(isolated_settings, "{base_model}-{first_tag}")
|
||||||
|
|
||||||
|
model_data = {
|
||||||
|
"base_model": "",
|
||||||
|
"tags": [],
|
||||||
|
"file_path": "/models/V1.safetensors",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert calculate_filename_for_model(model_data, "lora") == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_filename_uses_base_model_mapping(isolated_settings):
|
||||||
|
_set_filename_templates(isolated_settings, "{base_model}-{model_name}")
|
||||||
|
isolated_settings["base_model_path_mappings"] = {"SDXL": "sdxl-mapped"}
|
||||||
|
|
||||||
|
model_data = {
|
||||||
|
"model_name": "Model",
|
||||||
|
"base_model": "SDXL",
|
||||||
|
"file_path": "/models/V1.safetensors",
|
||||||
|
"civitai": {"id": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert calculate_filename_for_model(model_data, "lora") == "sdxl-mapped-Model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_filename_embedding_replaces_spaces(isolated_settings):
|
||||||
|
_set_filename_templates(isolated_settings, "{base_model} {model_name}")
|
||||||
|
|
||||||
|
model_data = {
|
||||||
|
"model_name": "My Model",
|
||||||
|
"base_model": "Base Model",
|
||||||
|
"file_path": "/models/V1.safetensors",
|
||||||
|
"civitai": {"id": 1},
|
||||||
|
}
|
||||||
|
|
||||||
|
assert calculate_filename_for_model(model_data, "embedding") == "Base_Model_My_Model"
|
||||||
|
|
||||||
|
|
||||||
|
def test_calculate_filename_original_name_falls_back_to_file_name(isolated_settings):
|
||||||
|
_set_filename_templates(isolated_settings, "{original_name}-{hash_short}")
|
||||||
|
|
||||||
|
model_data = {
|
||||||
|
"file_name": "legacy-name",
|
||||||
|
"sha256": "0123456789abcdef",
|
||||||
|
}
|
||||||
|
|
||||||
|
assert calculate_filename_for_model(model_data, "lora") == "legacy-name-0123456789"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"original, expected",
|
"original, expected",
|
||||||
[
|
[
|
||||||
|
|||||||
Reference in New Issue
Block a user