mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 23:25:43 -03:00
@@ -188,7 +188,8 @@
|
|||||||
"creator": "Ersteller",
|
"creator": "Ersteller",
|
||||||
"title": "Rezept-Titel",
|
"title": "Rezept-Titel",
|
||||||
"loraName": "LoRA-Dateiname",
|
"loraName": "LoRA-Dateiname",
|
||||||
"loraModel": "LoRA-Modellname"
|
"loraModel": "LoRA-Modellname",
|
||||||
|
"prompt": "Prompt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -588,10 +589,26 @@
|
|||||||
"selectLoraRoot": "Bitte wählen Sie ein LoRA-Stammverzeichnis aus"
|
"selectLoraRoot": "Bitte wählen Sie ein LoRA-Stammverzeichnis aus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "Rezepte sortieren nach...",
|
||||||
|
"name": "Name",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "Datum",
|
||||||
|
"dateDesc": "Neueste",
|
||||||
|
"dateAsc": "Älteste",
|
||||||
|
"lorasCount": "LoRA-Anzahl",
|
||||||
|
"lorasCountDesc": "Meiste",
|
||||||
|
"lorasCountAsc": "Wenigste"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Rezeptliste aktualisieren"
|
"title": "Rezeptliste aktualisieren"
|
||||||
},
|
},
|
||||||
"filteredByLora": "Gefiltert nach LoRA"
|
"filteredByLora": "Gefiltert nach LoRA",
|
||||||
|
"favorites": {
|
||||||
|
"title": "Nur Favoriten anzeigen",
|
||||||
|
"action": "Favoriten"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "{count} Duplikat-Gruppen gefunden",
|
"found": "{count} Duplikat-Gruppen gefunden",
|
||||||
@@ -638,7 +655,8 @@
|
|||||||
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
|
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
|
||||||
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
|
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden."
|
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1460,7 +1478,8 @@
|
|||||||
"bulkMoveFailures": "Fehlgeschlagene Verschiebungen:\n{failures}",
|
"bulkMoveFailures": "Fehlgeschlagene Verschiebungen:\n{failures}",
|
||||||
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
|
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
|
||||||
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
|
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
|
||||||
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}"
|
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -188,7 +188,8 @@
|
|||||||
"creator": "Creator",
|
"creator": "Creator",
|
||||||
"title": "Recipe Title",
|
"title": "Recipe Title",
|
||||||
"loraName": "LoRA Filename",
|
"loraName": "LoRA Filename",
|
||||||
"loraModel": "LoRA Model Name"
|
"loraModel": "LoRA Model Name",
|
||||||
|
"prompt": "Prompt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -588,10 +589,26 @@
|
|||||||
"selectLoraRoot": "Please select a LoRA root directory"
|
"selectLoraRoot": "Please select a LoRA root directory"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "Sort recipes by...",
|
||||||
|
"name": "Name",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "Date",
|
||||||
|
"dateDesc": "Newest",
|
||||||
|
"dateAsc": "Oldest",
|
||||||
|
"lorasCount": "LoRA Count",
|
||||||
|
"lorasCountDesc": "Most",
|
||||||
|
"lorasCountAsc": "Least"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Refresh recipe list"
|
"title": "Refresh recipe list"
|
||||||
},
|
},
|
||||||
"filteredByLora": "Filtered by LoRA"
|
"filteredByLora": "Filtered by LoRA",
|
||||||
|
"favorites": {
|
||||||
|
"title": "Show Favorites Only",
|
||||||
|
"action": "Favorites"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "Found {count} duplicate groups",
|
"found": "Found {count} duplicate groups",
|
||||||
@@ -638,7 +655,8 @@
|
|||||||
"recursiveUnavailable": "Recursive search is available in tree view only",
|
"recursiveUnavailable": "Recursive search is available in tree view only",
|
||||||
"collapseAllDisabled": "Not available in list view",
|
"collapseAllDisabled": "Not available in list view",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "Unable to determine destination path for move."
|
"unableToResolveRoot": "Unable to determine destination path for move.",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1460,7 +1478,8 @@
|
|||||||
"bulkMoveFailures": "Failed moves:\n{failures}",
|
"bulkMoveFailures": "Failed moves:\n{failures}",
|
||||||
"bulkMoveSuccess": "Successfully moved {successCount} {type}s",
|
"bulkMoveSuccess": "Successfully moved {successCount} {type}s",
|
||||||
"exampleImagesDownloadSuccess": "Successfully downloaded example images!",
|
"exampleImagesDownloadSuccess": "Successfully downloaded example images!",
|
||||||
"exampleImagesDownloadFailed": "Failed to download example images: {message}"
|
"exampleImagesDownloadFailed": "Failed to download example images: {message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -188,7 +188,8 @@
|
|||||||
"creator": "Creador",
|
"creator": "Creador",
|
||||||
"title": "Título de la receta",
|
"title": "Título de la receta",
|
||||||
"loraName": "Nombre de archivo LoRA",
|
"loraName": "Nombre de archivo LoRA",
|
||||||
"loraModel": "Nombre del modelo LoRA"
|
"loraModel": "Nombre del modelo LoRA",
|
||||||
|
"prompt": "Prompt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -588,10 +589,26 @@
|
|||||||
"selectLoraRoot": "Por favor selecciona un directorio raíz de LoRA"
|
"selectLoraRoot": "Por favor selecciona un directorio raíz de LoRA"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "Ordenar recetas por...",
|
||||||
|
"name": "Nombre",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "Fecha",
|
||||||
|
"dateDesc": "Más reciente",
|
||||||
|
"dateAsc": "Más antiguo",
|
||||||
|
"lorasCount": "Cant. de LoRAs",
|
||||||
|
"lorasCountDesc": "Más",
|
||||||
|
"lorasCountAsc": "Menos"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualizar lista de recetas"
|
"title": "Actualizar lista de recetas"
|
||||||
},
|
},
|
||||||
"filteredByLora": "Filtrado por LoRA"
|
"filteredByLora": "Filtrado por LoRA",
|
||||||
|
"favorites": {
|
||||||
|
"title": "Mostrar solo favoritos",
|
||||||
|
"action": "Favoritos"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "Se encontraron {count} grupos de duplicados",
|
"found": "Se encontraron {count} grupos de duplicados",
|
||||||
@@ -638,7 +655,8 @@
|
|||||||
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
|
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
|
||||||
"collapseAllDisabled": "No disponible en vista de lista",
|
"collapseAllDisabled": "No disponible en vista de lista",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento."
|
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1460,7 +1478,8 @@
|
|||||||
"bulkMoveFailures": "Movimientos fallidos:\n{failures}",
|
"bulkMoveFailures": "Movimientos fallidos:\n{failures}",
|
||||||
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
|
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
|
||||||
"exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!",
|
"exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!",
|
||||||
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}"
|
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -188,7 +188,8 @@
|
|||||||
"creator": "Créateur",
|
"creator": "Créateur",
|
||||||
"title": "Titre de la recipe",
|
"title": "Titre de la recipe",
|
||||||
"loraName": "Nom de fichier LoRA",
|
"loraName": "Nom de fichier LoRA",
|
||||||
"loraModel": "Nom du modèle LoRA"
|
"loraModel": "Nom du modèle LoRA",
|
||||||
|
"prompt": "Prompt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -588,10 +589,26 @@
|
|||||||
"selectLoraRoot": "Veuillez sélectionner un répertoire racine LoRA"
|
"selectLoraRoot": "Veuillez sélectionner un répertoire racine LoRA"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "Trier les recettes par...",
|
||||||
|
"name": "Nom",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "Date",
|
||||||
|
"dateDesc": "Plus récent",
|
||||||
|
"dateAsc": "Plus ancien",
|
||||||
|
"lorasCount": "Nombre de LoRAs",
|
||||||
|
"lorasCountDesc": "Plus",
|
||||||
|
"lorasCountAsc": "Moins"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Actualiser la liste des recipes"
|
"title": "Actualiser la liste des recipes"
|
||||||
},
|
},
|
||||||
"filteredByLora": "Filtré par LoRA"
|
"filteredByLora": "Filtré par LoRA",
|
||||||
|
"favorites": {
|
||||||
|
"title": "Afficher uniquement les favoris",
|
||||||
|
"action": "Favoris"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "Trouvé {count} groupes de doublons",
|
"found": "Trouvé {count} groupes de doublons",
|
||||||
@@ -638,7 +655,8 @@
|
|||||||
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
|
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
|
||||||
"collapseAllDisabled": "Non disponible en vue liste",
|
"collapseAllDisabled": "Non disponible en vue liste",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement."
|
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1460,7 +1478,8 @@
|
|||||||
"bulkMoveFailures": "Échecs de déplacement :\n{failures}",
|
"bulkMoveFailures": "Échecs de déplacement :\n{failures}",
|
||||||
"bulkMoveSuccess": "{successCount} {type}s déplacés avec succès",
|
"bulkMoveSuccess": "{successCount} {type}s déplacés avec succès",
|
||||||
"exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !",
|
"exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !",
|
||||||
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}"
|
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -188,7 +188,8 @@
|
|||||||
"creator": "יוצר",
|
"creator": "יוצר",
|
||||||
"title": "כותרת מתכון",
|
"title": "כותרת מתכון",
|
||||||
"loraName": "שם קובץ LoRA",
|
"loraName": "שם קובץ LoRA",
|
||||||
"loraModel": "שם מודל LoRA"
|
"loraModel": "שם מודל LoRA",
|
||||||
|
"prompt": "הנחיה"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -228,6 +229,7 @@
|
|||||||
"videoSettings": "הגדרות וידאו",
|
"videoSettings": "הגדרות וידאו",
|
||||||
"layoutSettings": "הגדרות פריסה",
|
"layoutSettings": "הגדרות פריסה",
|
||||||
"folderSettings": "הגדרות תיקייה",
|
"folderSettings": "הגדרות תיקייה",
|
||||||
|
"priorityTags": "תגיות עדיפות",
|
||||||
"downloadPathTemplates": "תבניות נתיב הורדה",
|
"downloadPathTemplates": "תבניות נתיב הורדה",
|
||||||
"exampleImages": "תמונות דוגמה",
|
"exampleImages": "תמונות דוגמה",
|
||||||
"updateFlags": "תגי עדכון",
|
"updateFlags": "תגי עדכון",
|
||||||
@@ -235,8 +237,7 @@
|
|||||||
"misc": "שונות",
|
"misc": "שונות",
|
||||||
"metadataArchive": "מסד נתונים של ארכיון מטא-דאטה",
|
"metadataArchive": "מסד נתונים של ארכיון מטא-דאטה",
|
||||||
"storageLocation": "מיקום ההגדרות",
|
"storageLocation": "מיקום ההגדרות",
|
||||||
"proxySettings": "הגדרות פרוקסי",
|
"proxySettings": "הגדרות פרוקסי"
|
||||||
"priorityTags": "תגיות עדיפות"
|
|
||||||
},
|
},
|
||||||
"storage": {
|
"storage": {
|
||||||
"locationLabel": "מצב נייד",
|
"locationLabel": "מצב נייד",
|
||||||
@@ -309,6 +310,26 @@
|
|||||||
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
|
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
|
||||||
"noDefault": "אין ברירת מחדל"
|
"noDefault": "אין ברירת מחדל"
|
||||||
},
|
},
|
||||||
|
"priorityTags": {
|
||||||
|
"title": "תגיות עדיפות",
|
||||||
|
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
|
||||||
|
"placeholder": "character, concept, style(toon|toon_style)",
|
||||||
|
"helpLinkLabel": "פתח עזרה בנושא תגיות עדיפות",
|
||||||
|
"modelTypes": {
|
||||||
|
"lora": "LoRA",
|
||||||
|
"checkpoint": "Checkpoint",
|
||||||
|
"embedding": "Embedding"
|
||||||
|
},
|
||||||
|
"saveSuccess": "תגיות העדיפות עודכנו.",
|
||||||
|
"saveError": "עדכון תגיות העדיפות נכשל.",
|
||||||
|
"loadingSuggestions": "טוען הצעות...",
|
||||||
|
"validation": {
|
||||||
|
"missingClosingParen": "לרשומה {index} חסר סוגר סוגריים.",
|
||||||
|
"missingCanonical": "על הרשומה {index} לכלול שם תגית קנונית.",
|
||||||
|
"duplicateCanonical": "התגית הקנונית \"{tag}\" מופיעה יותר מפעם אחת.",
|
||||||
|
"unknown": "תצורת תגיות העדיפות שגויה."
|
||||||
|
}
|
||||||
|
},
|
||||||
"downloadPathTemplates": {
|
"downloadPathTemplates": {
|
||||||
"title": "תבניות נתיב הורדה",
|
"title": "תבניות נתיב הורדה",
|
||||||
"help": "הגדר מבני תיקיות לסוגי מודלים שונים בעת הורדה מ-Civitai.",
|
"help": "הגדר מבני תיקיות לסוגי מודלים שונים בעת הורדה מ-Civitai.",
|
||||||
@@ -320,8 +341,8 @@
|
|||||||
"byFirstTag": "לפי תגית ראשונה",
|
"byFirstTag": "לפי תגית ראשונה",
|
||||||
"baseModelFirstTag": "מודל בסיס + תגית ראשונה",
|
"baseModelFirstTag": "מודל בסיס + תגית ראשונה",
|
||||||
"baseModelAuthor": "מודל בסיס + יוצר",
|
"baseModelAuthor": "מודל בסיס + יוצר",
|
||||||
"baseModelAuthorFirstTag": "מודל בסיס + יוצר + תגית ראשונה",
|
|
||||||
"authorFirstTag": "יוצר + תגית ראשונה",
|
"authorFirstTag": "יוצר + תגית ראשונה",
|
||||||
|
"baseModelAuthorFirstTag": "מודל בסיס + יוצר + תגית ראשונה",
|
||||||
"customTemplate": "תבנית מותאמת אישית"
|
"customTemplate": "תבנית מותאמת אישית"
|
||||||
},
|
},
|
||||||
"customTemplatePlaceholder": "הזן תבנית מותאמת אישית (למשל, {base_model}/{author}/{first_tag})",
|
"customTemplatePlaceholder": "הזן תבנית מותאמת אישית (למשל, {base_model}/{author}/{first_tag})",
|
||||||
@@ -409,26 +430,6 @@
|
|||||||
"proxyPassword": "סיסמה (אופציונלי)",
|
"proxyPassword": "סיסמה (אופציונלי)",
|
||||||
"proxyPasswordPlaceholder": "password",
|
"proxyPasswordPlaceholder": "password",
|
||||||
"proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)"
|
"proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)"
|
||||||
},
|
|
||||||
"priorityTags": {
|
|
||||||
"title": "תגיות עדיפות",
|
|
||||||
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
|
|
||||||
"placeholder": "character, concept, style(toon|toon_style)",
|
|
||||||
"helpLinkLabel": "פתח עזרה בנושא תגיות עדיפות",
|
|
||||||
"modelTypes": {
|
|
||||||
"lora": "LoRA",
|
|
||||||
"checkpoint": "Checkpoint",
|
|
||||||
"embedding": "Embedding"
|
|
||||||
},
|
|
||||||
"saveSuccess": "תגיות העדיפות עודכנו.",
|
|
||||||
"saveError": "עדכון תגיות העדיפות נכשל.",
|
|
||||||
"loadingSuggestions": "טוען הצעות...",
|
|
||||||
"validation": {
|
|
||||||
"missingClosingParen": "לרשומה {index} חסר סוגר סוגריים.",
|
|
||||||
"missingCanonical": "על הרשומה {index} לכלול שם תגית קנונית.",
|
|
||||||
"duplicateCanonical": "התגית הקנונית \"{tag}\" מופיעה יותר מפעם אחת.",
|
|
||||||
"unknown": "תצורת תגיות העדיפות שגויה."
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"loras": {
|
"loras": {
|
||||||
@@ -588,10 +589,26 @@
|
|||||||
"selectLoraRoot": "אנא בחר ספריית שורש של LoRA"
|
"selectLoraRoot": "אנא בחר ספריית שורש של LoRA"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "מיון מתכונים לפי...",
|
||||||
|
"name": "שם",
|
||||||
|
"nameAsc": "א - ת",
|
||||||
|
"nameDesc": "ת - א",
|
||||||
|
"date": "תאריך",
|
||||||
|
"dateDesc": "הכי חדש",
|
||||||
|
"dateAsc": "הכי ישן",
|
||||||
|
"lorasCount": "מספר LoRAs",
|
||||||
|
"lorasCountDesc": "הכי הרבה",
|
||||||
|
"lorasCountAsc": "הכי פחות"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "רענן רשימת מתכונים"
|
"title": "רענן רשימת מתכונים"
|
||||||
},
|
},
|
||||||
"filteredByLora": "מסונן לפי LoRA"
|
"filteredByLora": "מסונן לפי LoRA",
|
||||||
|
"favorites": {
|
||||||
|
"title": "הצג מועדפים בלבד",
|
||||||
|
"action": "מועדפים"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "נמצאו {count} קבוצות כפולות",
|
"found": "נמצאו {count} קבוצות כפולות",
|
||||||
@@ -638,7 +655,8 @@
|
|||||||
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
|
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
|
||||||
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
|
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה."
|
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1460,7 +1478,8 @@
|
|||||||
"bulkMoveFailures": "העברות שנכשלו:\n{failures}",
|
"bulkMoveFailures": "העברות שנכשלו:\n{failures}",
|
||||||
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
|
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
|
||||||
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
|
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
|
||||||
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}"
|
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -188,7 +188,8 @@
|
|||||||
"creator": "作成者",
|
"creator": "作成者",
|
||||||
"title": "レシピタイトル",
|
"title": "レシピタイトル",
|
||||||
"loraName": "LoRAファイル名",
|
"loraName": "LoRAファイル名",
|
||||||
"loraModel": "LoRAモデル名"
|
"loraModel": "LoRAモデル名",
|
||||||
|
"prompt": "プロンプト"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -588,10 +589,26 @@
|
|||||||
"selectLoraRoot": "LoRAルートディレクトリを選択してください"
|
"selectLoraRoot": "LoRAルートディレクトリを選択してください"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "レシピの並び替え...",
|
||||||
|
"name": "名前",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "日付",
|
||||||
|
"dateDesc": "新しい順",
|
||||||
|
"dateAsc": "古い順",
|
||||||
|
"lorasCount": "LoRA数",
|
||||||
|
"lorasCountDesc": "多い順",
|
||||||
|
"lorasCountAsc": "少ない順"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "レシピリストを更新"
|
"title": "レシピリストを更新"
|
||||||
},
|
},
|
||||||
"filteredByLora": "LoRAでフィルタ済み"
|
"filteredByLora": "LoRAでフィルタ済み",
|
||||||
|
"favorites": {
|
||||||
|
"title": "お気に入りのみ表示",
|
||||||
|
"action": "お気に入り"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "{count} 個の重複グループが見つかりました",
|
"found": "{count} 個の重複グループが見つかりました",
|
||||||
@@ -638,7 +655,8 @@
|
|||||||
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
|
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
|
||||||
"collapseAllDisabled": "リストビューでは利用できません",
|
"collapseAllDisabled": "リストビューでは利用できません",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "移動先のパスを特定できません。"
|
"unableToResolveRoot": "移動先のパスを特定できません。",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1460,7 +1478,8 @@
|
|||||||
"bulkMoveFailures": "失敗した移動:\n{failures}",
|
"bulkMoveFailures": "失敗した移動:\n{failures}",
|
||||||
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
|
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
|
||||||
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
|
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
|
||||||
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}"
|
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -188,7 +188,8 @@
|
|||||||
"creator": "제작자",
|
"creator": "제작자",
|
||||||
"title": "레시피 제목",
|
"title": "레시피 제목",
|
||||||
"loraName": "LoRA 파일명",
|
"loraName": "LoRA 파일명",
|
||||||
"loraModel": "LoRA 모델명"
|
"loraModel": "LoRA 모델명",
|
||||||
|
"prompt": "프롬프트"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -588,10 +589,26 @@
|
|||||||
"selectLoraRoot": "LoRA 루트 디렉토리를 선택해주세요"
|
"selectLoraRoot": "LoRA 루트 디렉토리를 선택해주세요"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "레시피 정렬...",
|
||||||
|
"name": "이름",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "날짜",
|
||||||
|
"dateDesc": "최신순",
|
||||||
|
"dateAsc": "오래된순",
|
||||||
|
"lorasCount": "LoRA 수",
|
||||||
|
"lorasCountDesc": "많은순",
|
||||||
|
"lorasCountAsc": "적은순"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "레시피 목록 새로고침"
|
"title": "레시피 목록 새로고침"
|
||||||
},
|
},
|
||||||
"filteredByLora": "LoRA로 필터링됨"
|
"filteredByLora": "LoRA로 필터링됨",
|
||||||
|
"favorites": {
|
||||||
|
"title": "즐겨찾기만 표시",
|
||||||
|
"action": "즐겨찾기"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "{count}개의 중복 그룹 발견",
|
"found": "{count}개의 중복 그룹 발견",
|
||||||
@@ -638,7 +655,8 @@
|
|||||||
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
|
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
|
||||||
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
|
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다."
|
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1460,7 +1478,8 @@
|
|||||||
"bulkMoveFailures": "실패한 이동:\n{failures}",
|
"bulkMoveFailures": "실패한 이동:\n{failures}",
|
||||||
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
|
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
|
||||||
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
|
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
|
||||||
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}"
|
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -188,7 +188,8 @@
|
|||||||
"creator": "Автор",
|
"creator": "Автор",
|
||||||
"title": "Название рецепта",
|
"title": "Название рецепта",
|
||||||
"loraName": "Имя файла LoRA",
|
"loraName": "Имя файла LoRA",
|
||||||
"loraModel": "Название модели LoRA"
|
"loraModel": "Название модели LoRA",
|
||||||
|
"prompt": "Запрос"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -588,10 +589,26 @@
|
|||||||
"selectLoraRoot": "Пожалуйста, выберите корневую папку LoRA"
|
"selectLoraRoot": "Пожалуйста, выберите корневую папку LoRA"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "Сортировка рецептов...",
|
||||||
|
"name": "Имя",
|
||||||
|
"nameAsc": "А - Я",
|
||||||
|
"nameDesc": "Я - А",
|
||||||
|
"date": "Дата",
|
||||||
|
"dateDesc": "Сначала новые",
|
||||||
|
"dateAsc": "Сначала старые",
|
||||||
|
"lorasCount": "Кол-во LoRA",
|
||||||
|
"lorasCountDesc": "Больше всего",
|
||||||
|
"lorasCountAsc": "Меньше всего"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Обновить список рецептов"
|
"title": "Обновить список рецептов"
|
||||||
},
|
},
|
||||||
"filteredByLora": "Фильтр по LoRA"
|
"filteredByLora": "Фильтр по LoRA",
|
||||||
|
"favorites": {
|
||||||
|
"title": "Только избранные",
|
||||||
|
"action": "Избранное"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "Найдено {count} групп дубликатов",
|
"found": "Найдено {count} групп дубликатов",
|
||||||
@@ -638,7 +655,8 @@
|
|||||||
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
|
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
|
||||||
"collapseAllDisabled": "Недоступно в виде списка",
|
"collapseAllDisabled": "Недоступно в виде списка",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения."
|
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1460,7 +1478,8 @@
|
|||||||
"bulkMoveFailures": "Неудачные перемещения:\n{failures}",
|
"bulkMoveFailures": "Неудачные перемещения:\n{failures}",
|
||||||
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
|
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
|
||||||
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
|
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
|
||||||
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}"
|
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -188,7 +188,8 @@
|
|||||||
"creator": "创作者",
|
"creator": "创作者",
|
||||||
"title": "配方标题",
|
"title": "配方标题",
|
||||||
"loraName": "LoRA 文件名",
|
"loraName": "LoRA 文件名",
|
||||||
"loraModel": "LoRA 模型名称"
|
"loraModel": "LoRA 模型名称",
|
||||||
|
"prompt": "提示词"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -588,10 +589,26 @@
|
|||||||
"selectLoraRoot": "请选择 LoRA 根目录"
|
"selectLoraRoot": "请选择 LoRA 根目录"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "配方排序...",
|
||||||
|
"name": "名称",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "时间",
|
||||||
|
"dateDesc": "最新",
|
||||||
|
"dateAsc": "最早",
|
||||||
|
"lorasCount": "LoRA 数量",
|
||||||
|
"lorasCountDesc": "最多",
|
||||||
|
"lorasCountAsc": "最少"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "刷新配方列表"
|
"title": "刷新配方列表"
|
||||||
},
|
},
|
||||||
"filteredByLora": "按 LoRA 筛选"
|
"filteredByLora": "按 LoRA 筛选",
|
||||||
|
"favorites": {
|
||||||
|
"title": "仅显示收藏",
|
||||||
|
"action": "收藏"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "发现 {count} 个重复组",
|
"found": "发现 {count} 个重复组",
|
||||||
@@ -638,7 +655,8 @@
|
|||||||
"recursiveUnavailable": "仅在树形视图中可使用递归搜索",
|
"recursiveUnavailable": "仅在树形视图中可使用递归搜索",
|
||||||
"collapseAllDisabled": "列表视图下不可用",
|
"collapseAllDisabled": "列表视图下不可用",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "无法确定移动的目标路径。"
|
"unableToResolveRoot": "无法确定移动的目标路径。",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1460,7 +1478,8 @@
|
|||||||
"bulkMoveFailures": "移动失败:\n{failures}",
|
"bulkMoveFailures": "移动失败:\n{failures}",
|
||||||
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
|
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
|
||||||
"exampleImagesDownloadSuccess": "示例图片下载成功!",
|
"exampleImagesDownloadSuccess": "示例图片下载成功!",
|
||||||
"exampleImagesDownloadFailed": "示例图片下载失败:{message}"
|
"exampleImagesDownloadFailed": "示例图片下载失败:{message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
@@ -188,7 +188,8 @@
|
|||||||
"creator": "創作者",
|
"creator": "創作者",
|
||||||
"title": "配方標題",
|
"title": "配方標題",
|
||||||
"loraName": "LoRA 檔案名稱",
|
"loraName": "LoRA 檔案名稱",
|
||||||
"loraModel": "LoRA 模型名稱"
|
"loraModel": "LoRA 模型名稱",
|
||||||
|
"prompt": "提示詞"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -588,10 +589,26 @@
|
|||||||
"selectLoraRoot": "請選擇 LoRA 根目錄"
|
"selectLoraRoot": "請選擇 LoRA 根目錄"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "配方排序...",
|
||||||
|
"name": "名稱",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "時間",
|
||||||
|
"dateDesc": "最新",
|
||||||
|
"dateAsc": "最舊",
|
||||||
|
"lorasCount": "LoRA 數量",
|
||||||
|
"lorasCountDesc": "最多",
|
||||||
|
"lorasCountAsc": "最少"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "重新整理配方列表"
|
"title": "重新整理配方列表"
|
||||||
},
|
},
|
||||||
"filteredByLora": "已依 LoRA 篩選"
|
"filteredByLora": "已依 LoRA 篩選",
|
||||||
|
"favorites": {
|
||||||
|
"title": "僅顯示收藏",
|
||||||
|
"action": "收藏"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "發現 {count} 組重複項",
|
"found": "發現 {count} 組重複項",
|
||||||
@@ -638,7 +655,8 @@
|
|||||||
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
|
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
|
||||||
"collapseAllDisabled": "列表檢視下不可用",
|
"collapseAllDisabled": "列表檢視下不可用",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "無法確定移動的目標路徑。"
|
"unableToResolveRoot": "無法確定移動的目標路徑。",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1460,7 +1478,8 @@
|
|||||||
"bulkMoveFailures": "移動失敗:\n{failures}",
|
"bulkMoveFailures": "移動失敗:\n{failures}",
|
||||||
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
|
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
|
||||||
"exampleImagesDownloadSuccess": "範例圖片下載成功!",
|
"exampleImagesDownloadSuccess": "範例圖片下載成功!",
|
||||||
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}"
|
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
|
|||||||
50
py/recipes/merger.py
Normal file
50
py/recipes/merger.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from typing import Any, Dict, Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class GenParamsMerger:
|
||||||
|
"""Utility to merge generation parameters from multiple sources with priority."""
|
||||||
|
|
||||||
|
BLACKLISTED_KEYS = {"id", "url", "userId", "username", "createdAt", "updatedAt", "hash"}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def merge(
|
||||||
|
request_params: Optional[Dict[str, Any]] = None,
|
||||||
|
civitai_meta: Optional[Dict[str, Any]] = None,
|
||||||
|
embedded_metadata: Optional[Dict[str, Any]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Merge generation parameters from three sources.
|
||||||
|
|
||||||
|
Priority: request_params > civitai_meta > embedded_metadata
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_params: Params provided directly in the import request
|
||||||
|
civitai_meta: Params from Civitai Image API 'meta' field
|
||||||
|
embedded_metadata: Params extracted from image EXIF/embedded metadata
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Merged parameters dictionary
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# 1. Start with embedded metadata (lowest priority)
|
||||||
|
if embedded_metadata:
|
||||||
|
# If it's a full recipe metadata, we use its gen_params
|
||||||
|
if "gen_params" in embedded_metadata and isinstance(embedded_metadata["gen_params"], dict):
|
||||||
|
result.update(embedded_metadata["gen_params"])
|
||||||
|
else:
|
||||||
|
# Otherwise assume the dict itself contains gen_params
|
||||||
|
result.update(embedded_metadata)
|
||||||
|
|
||||||
|
# 2. Layer Civitai meta (medium priority)
|
||||||
|
if civitai_meta:
|
||||||
|
result.update(civitai_meta)
|
||||||
|
|
||||||
|
# 3. Layer request params (highest priority)
|
||||||
|
if request_params:
|
||||||
|
result.update(request_params)
|
||||||
|
|
||||||
|
# Filter out blacklisted keys
|
||||||
|
return {k: v for k, v in result.items() if k not in GenParamsMerger.BLACKLISTED_KEYS}
|
||||||
@@ -36,9 +36,6 @@ class ComfyMetadataParser(RecipeMetadataParser):
|
|||||||
# Find all LoraLoader nodes
|
# Find all LoraLoader nodes
|
||||||
lora_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'LoraLoader'}
|
lora_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'LoraLoader'}
|
||||||
|
|
||||||
if not lora_nodes:
|
|
||||||
return {"error": "No LoRA information found in this ComfyUI workflow", "loras": []}
|
|
||||||
|
|
||||||
# Process each LoraLoader node
|
# Process each LoraLoader node
|
||||||
for node_id, node in lora_nodes.items():
|
for node_id, node in lora_nodes.items():
|
||||||
if 'inputs' not in node or 'lora_name' not in node['inputs']:
|
if 'inputs' not in node or 'lora_name' not in node['inputs']:
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ from ...services.recipes import (
|
|||||||
)
|
)
|
||||||
from ...services.metadata_service import get_default_metadata_provider
|
from ...services.metadata_service import get_default_metadata_provider
|
||||||
from ...utils.civitai_utils import rewrite_preview_url
|
from ...utils.civitai_utils import rewrite_preview_url
|
||||||
|
from ...utils.exif_utils import ExifUtils
|
||||||
|
from ...recipes.merger import GenParamsMerger
|
||||||
|
|
||||||
Logger = logging.Logger
|
Logger = logging.Logger
|
||||||
EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
||||||
@@ -56,16 +58,22 @@ class RecipeHandlerSet:
|
|||||||
"delete_recipe": self.management.delete_recipe,
|
"delete_recipe": self.management.delete_recipe,
|
||||||
"get_top_tags": self.query.get_top_tags,
|
"get_top_tags": self.query.get_top_tags,
|
||||||
"get_base_models": self.query.get_base_models,
|
"get_base_models": self.query.get_base_models,
|
||||||
|
"get_roots": self.query.get_roots,
|
||||||
|
"get_folders": self.query.get_folders,
|
||||||
|
"get_folder_tree": self.query.get_folder_tree,
|
||||||
|
"get_unified_folder_tree": self.query.get_unified_folder_tree,
|
||||||
"share_recipe": self.sharing.share_recipe,
|
"share_recipe": self.sharing.share_recipe,
|
||||||
"download_shared_recipe": self.sharing.download_shared_recipe,
|
"download_shared_recipe": self.sharing.download_shared_recipe,
|
||||||
"get_recipe_syntax": self.query.get_recipe_syntax,
|
"get_recipe_syntax": self.query.get_recipe_syntax,
|
||||||
"update_recipe": self.management.update_recipe,
|
"update_recipe": self.management.update_recipe,
|
||||||
"reconnect_lora": self.management.reconnect_lora,
|
"reconnect_lora": self.management.reconnect_lora,
|
||||||
"find_duplicates": self.query.find_duplicates,
|
"find_duplicates": self.query.find_duplicates,
|
||||||
|
"move_recipes_bulk": self.management.move_recipes_bulk,
|
||||||
"bulk_delete": self.management.bulk_delete,
|
"bulk_delete": self.management.bulk_delete,
|
||||||
"save_recipe_from_widget": self.management.save_recipe_from_widget,
|
"save_recipe_from_widget": self.management.save_recipe_from_widget,
|
||||||
"get_recipes_for_lora": self.query.get_recipes_for_lora,
|
"get_recipes_for_lora": self.query.get_recipes_for_lora,
|
||||||
"scan_recipes": self.query.scan_recipes,
|
"scan_recipes": self.query.scan_recipes,
|
||||||
|
"move_recipe": self.management.move_recipe,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -149,12 +157,15 @@ class RecipeListingHandler:
|
|||||||
page_size = int(request.query.get("page_size", "20"))
|
page_size = int(request.query.get("page_size", "20"))
|
||||||
sort_by = request.query.get("sort_by", "date")
|
sort_by = request.query.get("sort_by", "date")
|
||||||
search = request.query.get("search")
|
search = request.query.get("search")
|
||||||
|
folder = request.query.get("folder")
|
||||||
|
recursive = request.query.get("recursive", "true").lower() == "true"
|
||||||
|
|
||||||
search_options = {
|
search_options = {
|
||||||
"title": request.query.get("search_title", "true").lower() == "true",
|
"title": request.query.get("search_title", "true").lower() == "true",
|
||||||
"tags": request.query.get("search_tags", "true").lower() == "true",
|
"tags": request.query.get("search_tags", "true").lower() == "true",
|
||||||
"lora_name": request.query.get("search_lora_name", "true").lower() == "true",
|
"lora_name": request.query.get("search_lora_name", "true").lower() == "true",
|
||||||
"lora_model": request.query.get("search_lora_model", "true").lower() == "true",
|
"lora_model": request.query.get("search_lora_model", "true").lower() == "true",
|
||||||
|
"prompt": request.query.get("search_prompt", "true").lower() == "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
filters: Dict[str, Any] = {}
|
filters: Dict[str, Any] = {}
|
||||||
@@ -162,6 +173,9 @@ class RecipeListingHandler:
|
|||||||
if base_models:
|
if base_models:
|
||||||
filters["base_model"] = base_models.split(",")
|
filters["base_model"] = base_models.split(",")
|
||||||
|
|
||||||
|
if request.query.get("favorite", "false").lower() == "true":
|
||||||
|
filters["favorite"] = True
|
||||||
|
|
||||||
tag_filters: Dict[str, str] = {}
|
tag_filters: Dict[str, str] = {}
|
||||||
legacy_tags = request.query.get("tags")
|
legacy_tags = request.query.get("tags")
|
||||||
if legacy_tags:
|
if legacy_tags:
|
||||||
@@ -193,6 +207,8 @@ class RecipeListingHandler:
|
|||||||
filters=filters,
|
filters=filters,
|
||||||
search_options=search_options,
|
search_options=search_options,
|
||||||
lora_hash=lora_hash,
|
lora_hash=lora_hash,
|
||||||
|
folder=folder,
|
||||||
|
recursive=recursive,
|
||||||
)
|
)
|
||||||
|
|
||||||
for item in result.get("items", []):
|
for item in result.get("items", []):
|
||||||
@@ -299,6 +315,58 @@ class RecipeQueryHandler:
|
|||||||
self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
|
self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_roots(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
roots = [recipe_scanner.recipes_dir] if recipe_scanner.recipes_dir else []
|
||||||
|
return web.json_response({"success": True, "roots": roots})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error retrieving recipe roots: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_folders(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
folders = await recipe_scanner.get_folders()
|
||||||
|
return web.json_response({"success": True, "folders": folders})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error retrieving recipe folders: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_folder_tree(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
folder_tree = await recipe_scanner.get_folder_tree()
|
||||||
|
return web.json_response({"success": True, "tree": folder_tree})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error retrieving recipe folder tree: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_unified_folder_tree(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
folder_tree = await recipe_scanner.get_folder_tree()
|
||||||
|
return web.json_response({"success": True, "tree": folder_tree})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error retrieving unified recipe folder tree: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def get_recipes_for_lora(self, request: web.Request) -> web.Response:
|
async def get_recipes_for_lora(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
@@ -486,7 +554,41 @@ class RecipeManagementHandler:
|
|||||||
metadata["base_model"] = base_model_from_metadata
|
metadata["base_model"] = base_model_from_metadata
|
||||||
|
|
||||||
tags = self._parse_tags(params.get("tags"))
|
tags = self._parse_tags(params.get("tags"))
|
||||||
image_bytes, extension = await self._download_remote_media(image_url)
|
image_bytes, extension, civitai_meta = await self._download_remote_media(image_url)
|
||||||
|
|
||||||
|
# Extract embedded metadata from the downloaded image
|
||||||
|
embedded_metadata = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=extension, delete=False) as temp_img:
|
||||||
|
temp_img.write(image_bytes)
|
||||||
|
temp_img_path = temp_img.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_embedded = ExifUtils.extract_image_metadata(temp_img_path)
|
||||||
|
if raw_embedded:
|
||||||
|
# Try to parse it using standard parsers if it looks like a recipe
|
||||||
|
parser = self._analysis_service._recipe_parser_factory.create_parser(raw_embedded)
|
||||||
|
if parser:
|
||||||
|
parsed_embedded = await parser.parse_metadata(raw_embedded, recipe_scanner=recipe_scanner)
|
||||||
|
embedded_metadata = parsed_embedded
|
||||||
|
else:
|
||||||
|
# Fallback to raw string if no parser matches (might be simple params)
|
||||||
|
embedded_metadata = {"gen_params": {"raw_metadata": raw_embedded}}
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temp_img_path):
|
||||||
|
os.unlink(temp_img_path)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.warning("Failed to extract embedded metadata during import: %s", exc)
|
||||||
|
|
||||||
|
# Merge gen_params from all sources
|
||||||
|
merged_gen_params = GenParamsMerger.merge(
|
||||||
|
request_params=gen_params,
|
||||||
|
civitai_meta=civitai_meta,
|
||||||
|
embedded_metadata=embedded_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
if merged_gen_params:
|
||||||
|
metadata["gen_params"] = merged_gen_params
|
||||||
|
|
||||||
result = await self._persistence_service.save_recipe(
|
result = await self._persistence_service.save_recipe(
|
||||||
recipe_scanner=recipe_scanner,
|
recipe_scanner=recipe_scanner,
|
||||||
@@ -545,6 +647,64 @@ class RecipeManagementHandler:
|
|||||||
self._logger.error("Error updating recipe: %s", exc, exc_info=True)
|
self._logger.error("Error updating recipe: %s", exc, exc_info=True)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def move_recipe(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
data = await request.json()
|
||||||
|
recipe_id = data.get("recipe_id")
|
||||||
|
target_path = data.get("target_path")
|
||||||
|
if not recipe_id or not target_path:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "recipe_id and target_path are required"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self._persistence_service.move_recipe(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
recipe_id=str(recipe_id),
|
||||||
|
target_path=str(target_path),
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
except RecipeValidationError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||||
|
except RecipeNotFoundError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error moving recipe: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def move_recipes_bulk(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
data = await request.json()
|
||||||
|
recipe_ids = data.get("recipe_ids") or []
|
||||||
|
target_path = data.get("target_path")
|
||||||
|
if not recipe_ids or not target_path:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "recipe_ids and target_path are required"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self._persistence_service.move_recipes_bulk(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
recipe_ids=recipe_ids,
|
||||||
|
target_path=str(target_path),
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
except RecipeValidationError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||||
|
except RecipeNotFoundError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error moving recipes in bulk: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def reconnect_lora(self, request: web.Request) -> web.Response:
|
async def reconnect_lora(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
@@ -776,7 +936,7 @@ class RecipeManagementHandler:
|
|||||||
extension = ".webp" # Default to webp if unknown
|
extension = ".webp" # Default to webp if unknown
|
||||||
|
|
||||||
with open(temp_path, "rb") as file_obj:
|
with open(temp_path, "rb") as file_obj:
|
||||||
return file_obj.read(), extension
|
return file_obj.read(), extension, image_info.get("meta") if civitai_match and image_info else None
|
||||||
except RecipeDownloadError:
|
except RecipeDownloadError:
|
||||||
raise
|
raise
|
||||||
except RecipeValidationError:
|
except RecipeValidationError:
|
||||||
|
|||||||
@@ -27,10 +27,16 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
|
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
|
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/folder-tree", "get_folder_tree"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/unified-folder-tree", "get_unified_folder_tree"),
|
||||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"),
|
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"),
|
||||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"),
|
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"),
|
||||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
|
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
|
||||||
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
|
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"),
|
||||||
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
|
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ from natsort import natsorted
|
|||||||
@dataclass
|
@dataclass
|
||||||
class RecipeCache:
|
class RecipeCache:
|
||||||
"""Cache structure for Recipe data"""
|
"""Cache structure for Recipe data"""
|
||||||
|
|
||||||
raw_data: List[Dict]
|
raw_data: List[Dict]
|
||||||
sorted_by_name: List[Dict]
|
sorted_by_name: List[Dict]
|
||||||
sorted_by_date: List[Dict]
|
sorted_by_date: List[Dict]
|
||||||
|
folders: List[str] | None = None
|
||||||
|
folder_tree: Dict | None = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
# Normalize optional metadata containers
|
||||||
|
self.folders = self.folders or []
|
||||||
|
self.folder_tree = self.folder_tree or {}
|
||||||
|
|
||||||
async def resort(self, name_only: bool = False):
|
async def resort(self, name_only: bool = False):
|
||||||
"""Resort all cached data views"""
|
"""Resort all cached data views"""
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import os
|
from __future__ import annotations
|
||||||
import logging
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
||||||
from ..config import config
|
from ..config import config
|
||||||
@@ -117,7 +119,9 @@ class RecipeScanner:
|
|||||||
self._cache = RecipeCache(
|
self._cache = RecipeCache(
|
||||||
raw_data=[],
|
raw_data=[],
|
||||||
sorted_by_name=[],
|
sorted_by_name=[],
|
||||||
sorted_by_date=[]
|
sorted_by_date=[],
|
||||||
|
folders=[],
|
||||||
|
folder_tree={},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mark as initializing to prevent concurrent initializations
|
# Mark as initializing to prevent concurrent initializations
|
||||||
@@ -218,6 +222,7 @@ class RecipeScanner:
|
|||||||
|
|
||||||
# Update cache with the collected data
|
# Update cache with the collected data
|
||||||
self._cache.raw_data = recipes
|
self._cache.raw_data = recipes
|
||||||
|
self._update_folder_metadata(self._cache)
|
||||||
|
|
||||||
# Create a simplified resort function that doesn't use await
|
# Create a simplified resort function that doesn't use await
|
||||||
if hasattr(self._cache, "resort"):
|
if hasattr(self._cache, "resort"):
|
||||||
@@ -336,6 +341,9 @@ class RecipeScanner:
|
|||||||
if not self._cache:
|
if not self._cache:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Keep folder metadata up to date alongside sort order
|
||||||
|
self._update_folder_metadata()
|
||||||
|
|
||||||
async def _resort_wrapper() -> None:
|
async def _resort_wrapper() -> None:
|
||||||
try:
|
try:
|
||||||
await self._cache.resort(name_only=name_only)
|
await self._cache.resort(name_only=name_only)
|
||||||
@@ -346,6 +354,75 @@ class RecipeScanner:
|
|||||||
self._resort_tasks.add(task)
|
self._resort_tasks.add(task)
|
||||||
task.add_done_callback(lambda finished: self._resort_tasks.discard(finished))
|
task.add_done_callback(lambda finished: self._resort_tasks.discard(finished))
|
||||||
|
|
||||||
|
def _calculate_folder(self, recipe_path: str) -> str:
|
||||||
|
"""Calculate a normalized folder path relative to ``recipes_dir``."""
|
||||||
|
|
||||||
|
recipes_dir = self.recipes_dir
|
||||||
|
if not recipes_dir:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
recipe_dir = os.path.dirname(os.path.normpath(recipe_path))
|
||||||
|
relative_dir = os.path.relpath(recipe_dir, recipes_dir)
|
||||||
|
if relative_dir in (".", ""):
|
||||||
|
return ""
|
||||||
|
return relative_dir.replace(os.path.sep, "/")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _build_folder_tree(self, folders: list[str]) -> dict:
|
||||||
|
"""Build a nested folder tree structure from relative folder paths."""
|
||||||
|
|
||||||
|
tree: dict[str, dict] = {}
|
||||||
|
for folder in folders:
|
||||||
|
if not folder:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = folder.split("/")
|
||||||
|
current_level = tree
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
if part not in current_level:
|
||||||
|
current_level[part] = {}
|
||||||
|
current_level = current_level[part]
|
||||||
|
|
||||||
|
return tree
|
||||||
|
|
||||||
|
def _update_folder_metadata(self, cache: RecipeCache | None = None) -> None:
|
||||||
|
"""Ensure folder lists and tree metadata are synchronized with cache contents."""
|
||||||
|
|
||||||
|
cache = cache or self._cache
|
||||||
|
if cache is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
folders: set[str] = set()
|
||||||
|
for item in cache.raw_data:
|
||||||
|
folder_value = item.get("folder", "")
|
||||||
|
if folder_value is None:
|
||||||
|
folder_value = ""
|
||||||
|
if folder_value == ".":
|
||||||
|
folder_value = ""
|
||||||
|
normalized = str(folder_value).replace("\\", "/")
|
||||||
|
item["folder"] = normalized
|
||||||
|
folders.add(normalized)
|
||||||
|
|
||||||
|
cache.folders = sorted(folders, key=lambda entry: entry.lower())
|
||||||
|
cache.folder_tree = self._build_folder_tree(cache.folders)
|
||||||
|
|
||||||
|
async def get_folders(self) -> list[str]:
|
||||||
|
"""Return a sorted list of recipe folders relative to the recipes root."""
|
||||||
|
|
||||||
|
cache = await self.get_cached_data()
|
||||||
|
self._update_folder_metadata(cache)
|
||||||
|
return cache.folders
|
||||||
|
|
||||||
|
async def get_folder_tree(self) -> dict:
|
||||||
|
"""Return a hierarchical tree of recipe folders for sidebar navigation."""
|
||||||
|
|
||||||
|
cache = await self.get_cached_data()
|
||||||
|
self._update_folder_metadata(cache)
|
||||||
|
return cache.folder_tree
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def recipes_dir(self) -> str:
|
def recipes_dir(self) -> str:
|
||||||
"""Get path to recipes directory"""
|
"""Get path to recipes directory"""
|
||||||
@@ -362,11 +439,14 @@ class RecipeScanner:
|
|||||||
"""Get cached recipe data, refresh if needed"""
|
"""Get cached recipe data, refresh if needed"""
|
||||||
# If cache is already initialized and no refresh is needed, return it immediately
|
# If cache is already initialized and no refresh is needed, return it immediately
|
||||||
if self._cache is not None and not force_refresh:
|
if self._cache is not None and not force_refresh:
|
||||||
|
self._update_folder_metadata()
|
||||||
return self._cache
|
return self._cache
|
||||||
|
|
||||||
# If another initialization is already in progress, wait for it to complete
|
# If another initialization is already in progress, wait for it to complete
|
||||||
if self._is_initializing and not force_refresh:
|
if self._is_initializing and not force_refresh:
|
||||||
return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[])
|
return self._cache or RecipeCache(
|
||||||
|
raw_data=[], sorted_by_name=[], sorted_by_date=[], folders=[], folder_tree={}
|
||||||
|
)
|
||||||
|
|
||||||
# If force refresh is requested, initialize the cache directly
|
# If force refresh is requested, initialize the cache directly
|
||||||
if force_refresh:
|
if force_refresh:
|
||||||
@@ -384,11 +464,14 @@ class RecipeScanner:
|
|||||||
self._cache = RecipeCache(
|
self._cache = RecipeCache(
|
||||||
raw_data=raw_data,
|
raw_data=raw_data,
|
||||||
sorted_by_name=[],
|
sorted_by_name=[],
|
||||||
sorted_by_date=[]
|
sorted_by_date=[],
|
||||||
|
folders=[],
|
||||||
|
folder_tree={},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Resort cache
|
# Resort cache
|
||||||
await self._cache.resort()
|
await self._cache.resort()
|
||||||
|
self._update_folder_metadata(self._cache)
|
||||||
|
|
||||||
return self._cache
|
return self._cache
|
||||||
|
|
||||||
@@ -398,7 +481,9 @@ class RecipeScanner:
|
|||||||
self._cache = RecipeCache(
|
self._cache = RecipeCache(
|
||||||
raw_data=[],
|
raw_data=[],
|
||||||
sorted_by_name=[],
|
sorted_by_name=[],
|
||||||
sorted_by_date=[]
|
sorted_by_date=[],
|
||||||
|
folders=[],
|
||||||
|
folder_tree={},
|
||||||
)
|
)
|
||||||
return self._cache
|
return self._cache
|
||||||
finally:
|
finally:
|
||||||
@@ -409,7 +494,9 @@ class RecipeScanner:
|
|||||||
logger.error(f"Unexpected error in get_cached_data: {e}")
|
logger.error(f"Unexpected error in get_cached_data: {e}")
|
||||||
|
|
||||||
# Return the cache (may be empty or partially initialized)
|
# Return the cache (may be empty or partially initialized)
|
||||||
return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[])
|
return self._cache or RecipeCache(
|
||||||
|
raw_data=[], sorted_by_name=[], sorted_by_date=[], folders=[], folder_tree={}
|
||||||
|
)
|
||||||
|
|
||||||
async def refresh_cache(self, force: bool = False) -> RecipeCache:
|
async def refresh_cache(self, force: bool = False) -> RecipeCache:
|
||||||
"""Public helper to refresh or return the recipe cache."""
|
"""Public helper to refresh or return the recipe cache."""
|
||||||
@@ -424,6 +511,7 @@ class RecipeScanner:
|
|||||||
|
|
||||||
cache = await self.get_cached_data()
|
cache = await self.get_cached_data()
|
||||||
await cache.add_recipe(recipe_data, resort=False)
|
await cache.add_recipe(recipe_data, resort=False)
|
||||||
|
self._update_folder_metadata(cache)
|
||||||
self._schedule_resort()
|
self._schedule_resort()
|
||||||
|
|
||||||
async def remove_recipe(self, recipe_id: str) -> bool:
|
async def remove_recipe(self, recipe_id: str) -> bool:
|
||||||
@@ -437,6 +525,7 @@ class RecipeScanner:
|
|||||||
if removed is None:
|
if removed is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
self._update_folder_metadata(cache)
|
||||||
self._schedule_resort()
|
self._schedule_resort()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -522,6 +611,9 @@ class RecipeScanner:
|
|||||||
if path_updated:
|
if path_updated:
|
||||||
self._write_recipe_file(recipe_path, recipe_data)
|
self._write_recipe_file(recipe_path, recipe_data)
|
||||||
|
|
||||||
|
# Track folder placement relative to recipes directory
|
||||||
|
recipe_data['folder'] = recipe_data.get('folder') or self._calculate_folder(recipe_path)
|
||||||
|
|
||||||
# Ensure loras array exists
|
# Ensure loras array exists
|
||||||
if 'loras' not in recipe_data:
|
if 'loras' not in recipe_data:
|
||||||
recipe_data['loras'] = []
|
recipe_data['loras'] = []
|
||||||
@@ -914,7 +1006,7 @@ class RecipeScanner:
|
|||||||
|
|
||||||
return await self._lora_scanner.get_model_info_by_name(name)
|
return await self._lora_scanner.get_model_info_by_name(name)
|
||||||
|
|
||||||
async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'date', search: str = None, filters: dict = None, search_options: dict = None, lora_hash: str = None, bypass_filters: bool = True):
|
async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'date', search: str = None, filters: dict = None, search_options: dict = None, lora_hash: str = None, bypass_filters: bool = True, folder: str | None = None, recursive: bool = True):
|
||||||
"""Get paginated and filtered recipe data
|
"""Get paginated and filtered recipe data
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
@@ -926,11 +1018,20 @@ class RecipeScanner:
|
|||||||
search_options: Dictionary of search options to apply
|
search_options: Dictionary of search options to apply
|
||||||
lora_hash: Optional SHA256 hash of a LoRA to filter recipes by
|
lora_hash: Optional SHA256 hash of a LoRA to filter recipes by
|
||||||
bypass_filters: If True, ignore other filters when a lora_hash is provided
|
bypass_filters: If True, ignore other filters when a lora_hash is provided
|
||||||
|
folder: Optional folder filter relative to recipes directory
|
||||||
|
recursive: Whether to include recipes in subfolders of the selected folder
|
||||||
"""
|
"""
|
||||||
cache = await self.get_cached_data()
|
cache = await self.get_cached_data()
|
||||||
|
|
||||||
# Get base dataset
|
# Get base dataset
|
||||||
filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name
|
sort_field = sort_by.split(':')[0] if ':' in sort_by else sort_by
|
||||||
|
|
||||||
|
if sort_field == 'date':
|
||||||
|
filtered_data = list(cache.sorted_by_date)
|
||||||
|
elif sort_field == 'name':
|
||||||
|
filtered_data = list(cache.sorted_by_name)
|
||||||
|
else:
|
||||||
|
filtered_data = list(cache.raw_data)
|
||||||
|
|
||||||
# Apply SFW filtering if enabled
|
# Apply SFW filtering if enabled
|
||||||
from .settings_manager import get_settings_manager
|
from .settings_manager import get_settings_manager
|
||||||
@@ -961,6 +1062,22 @@ class RecipeScanner:
|
|||||||
|
|
||||||
# Skip further filtering if we're only filtering by LoRA hash with bypass enabled
|
# Skip further filtering if we're only filtering by LoRA hash with bypass enabled
|
||||||
if not (lora_hash and bypass_filters):
|
if not (lora_hash and bypass_filters):
|
||||||
|
# Apply folder filter before other criteria
|
||||||
|
normalized_folder = (folder or "").strip("/")
|
||||||
|
if normalized_folder:
|
||||||
|
def matches_folder(item_folder: str) -> bool:
|
||||||
|
item_path = (item_folder or "").strip("/")
|
||||||
|
if not item_path:
|
||||||
|
return False
|
||||||
|
if recursive:
|
||||||
|
return item_path == normalized_folder or item_path.startswith(f"{normalized_folder}/")
|
||||||
|
return item_path == normalized_folder
|
||||||
|
|
||||||
|
filtered_data = [
|
||||||
|
item for item in filtered_data
|
||||||
|
if matches_folder(item.get('folder', ''))
|
||||||
|
]
|
||||||
|
|
||||||
# Apply search filter
|
# Apply search filter
|
||||||
if search:
|
if search:
|
||||||
# Default search options if none provided
|
# Default search options if none provided
|
||||||
@@ -997,6 +1114,14 @@ class RecipeScanner:
|
|||||||
if fuzzy_match(str(lora.get('modelName', '')), search):
|
if fuzzy_match(str(lora.get('modelName', '')), search):
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
# Search in prompt and negative_prompt if enabled
|
||||||
|
if search_options.get('prompt', True) and 'gen_params' in item:
|
||||||
|
gen_params = item['gen_params']
|
||||||
|
if fuzzy_match(str(gen_params.get('prompt', '')), search):
|
||||||
|
return True
|
||||||
|
if fuzzy_match(str(gen_params.get('negative_prompt', '')), search):
|
||||||
|
return True
|
||||||
|
|
||||||
# No match found
|
# No match found
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -1012,6 +1137,13 @@ class RecipeScanner:
|
|||||||
if item.get('base_model', '') in filters['base_model']
|
if item.get('base_model', '') in filters['base_model']
|
||||||
]
|
]
|
||||||
|
|
||||||
|
# Filter by favorite
|
||||||
|
if 'favorite' in filters and filters['favorite']:
|
||||||
|
filtered_data = [
|
||||||
|
item for item in filtered_data
|
||||||
|
if item.get('favorite') is True
|
||||||
|
]
|
||||||
|
|
||||||
# Filter by tags
|
# Filter by tags
|
||||||
if 'tags' in filters and filters['tags']:
|
if 'tags' in filters and filters['tags']:
|
||||||
tag_spec = filters['tags']
|
tag_spec = filters['tags']
|
||||||
@@ -1041,6 +1173,20 @@ class RecipeScanner:
|
|||||||
if not any(tag in exclude_tags for tag in (item.get('tags', []) or []))
|
if not any(tag in exclude_tags for tag in (item.get('tags', []) or []))
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# Apply sorting if not already handled by pre-sorted cache
|
||||||
|
if ':' in sort_by or sort_field == 'loras_count':
|
||||||
|
field, order = (sort_by.split(':') + ['desc'])[:2]
|
||||||
|
reverse = order.lower() == 'desc'
|
||||||
|
|
||||||
|
if field == 'name':
|
||||||
|
filtered_data = natsorted(filtered_data, key=lambda x: x.get('title', '').lower(), reverse=reverse)
|
||||||
|
elif field == 'date':
|
||||||
|
# Use modified if available, falling back to created_date
|
||||||
|
filtered_data.sort(key=lambda x: (x.get('modified', x.get('created_date', 0)), x.get('file_path', '')), reverse=reverse)
|
||||||
|
elif field == 'loras_count':
|
||||||
|
filtered_data.sort(key=lambda x: len(x.get('loras', [])), reverse=reverse)
|
||||||
|
|
||||||
# Calculate pagination
|
# Calculate pagination
|
||||||
total_items = len(filtered_data)
|
total_items = len(filtered_data)
|
||||||
start_idx = (page - 1) * page_size
|
start_idx = (page - 1) * page_size
|
||||||
@@ -1136,6 +1282,30 @@ class RecipeScanner:
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')
|
return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')
|
||||||
|
|
||||||
|
async def get_recipe_json_path(self, recipe_id: str) -> Optional[str]:
|
||||||
|
"""Locate the recipe JSON file, accounting for folder placement."""
|
||||||
|
|
||||||
|
recipes_dir = self.recipes_dir
|
||||||
|
if not recipes_dir:
|
||||||
|
return None
|
||||||
|
|
||||||
|
cache = await self.get_cached_data()
|
||||||
|
folder = ""
|
||||||
|
for item in cache.raw_data:
|
||||||
|
if str(item.get("id")) == str(recipe_id):
|
||||||
|
folder = item.get("folder") or ""
|
||||||
|
break
|
||||||
|
|
||||||
|
candidate = os.path.normpath(os.path.join(recipes_dir, folder, f"{recipe_id}.recipe.json"))
|
||||||
|
if os.path.exists(candidate):
|
||||||
|
return candidate
|
||||||
|
|
||||||
|
for root, _, files in os.walk(recipes_dir):
|
||||||
|
if f"{recipe_id}.recipe.json" in files:
|
||||||
|
return os.path.join(root, f"{recipe_id}.recipe.json")
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
async def update_recipe_metadata(self, recipe_id: str, metadata: dict) -> bool:
|
async def update_recipe_metadata(self, recipe_id: str, metadata: dict) -> bool:
|
||||||
"""Update recipe metadata (like title and tags) in both file system and cache
|
"""Update recipe metadata (like title and tags) in both file system and cache
|
||||||
|
|
||||||
@@ -1146,13 +1316,9 @@ class RecipeScanner:
|
|||||||
Returns:
|
Returns:
|
||||||
bool: True if successful, False otherwise
|
bool: True if successful, False otherwise
|
||||||
"""
|
"""
|
||||||
import os
|
|
||||||
import json
|
|
||||||
|
|
||||||
# First, find the recipe JSON file path
|
# First, find the recipe JSON file path
|
||||||
recipe_json_path = os.path.join(self.recipes_dir, f"{recipe_id}.recipe.json")
|
recipe_json_path = await self.get_recipe_json_path(recipe_id)
|
||||||
|
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||||
if not os.path.exists(recipe_json_path):
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -1201,8 +1367,8 @@ class RecipeScanner:
|
|||||||
if target_name is None:
|
if target_name is None:
|
||||||
raise ValueError("target_name must be provided")
|
raise ValueError("target_name must be provided")
|
||||||
|
|
||||||
recipe_json_path = os.path.join(self.recipes_dir, f"{recipe_id}.recipe.json")
|
recipe_json_path = await self.get_recipe_json_path(recipe_id)
|
||||||
if not os.path.exists(recipe_json_path):
|
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||||
raise RecipeNotFoundError("Recipe not found")
|
raise RecipeNotFoundError("Recipe not found")
|
||||||
|
|
||||||
async with self._mutation_lock:
|
async with self._mutation_lock:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -154,12 +155,8 @@ class RecipePersistenceService:
|
|||||||
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
|
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
|
||||||
"""Delete an existing recipe."""
|
"""Delete an existing recipe."""
|
||||||
|
|
||||||
recipes_dir = recipe_scanner.recipes_dir
|
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||||
if not recipes_dir or not os.path.exists(recipes_dir):
|
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||||
raise RecipeNotFoundError("Recipes directory not found")
|
|
||||||
|
|
||||||
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
|
|
||||||
if not os.path.exists(recipe_json_path):
|
|
||||||
raise RecipeNotFoundError("Recipe not found")
|
raise RecipeNotFoundError("Recipe not found")
|
||||||
|
|
||||||
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
|
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
|
||||||
@@ -176,9 +173,9 @@ class RecipePersistenceService:
|
|||||||
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
|
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
|
||||||
"""Update persisted metadata for a recipe."""
|
"""Update persisted metadata for a recipe."""
|
||||||
|
|
||||||
if not any(key in updates for key in ("title", "tags", "source_path", "preview_nsfw_level")):
|
if not any(key in updates for key in ("title", "tags", "source_path", "preview_nsfw_level", "favorite")):
|
||||||
raise RecipeValidationError(
|
raise RecipeValidationError(
|
||||||
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level)"
|
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite)"
|
||||||
)
|
)
|
||||||
|
|
||||||
success = await recipe_scanner.update_recipe_metadata(recipe_id, updates)
|
success = await recipe_scanner.update_recipe_metadata(recipe_id, updates)
|
||||||
@@ -187,6 +184,163 @@ class RecipePersistenceService:
|
|||||||
|
|
||||||
return PersistenceResult({"success": True, "recipe_id": recipe_id, "updates": updates})
|
return PersistenceResult({"success": True, "recipe_id": recipe_id, "updates": updates})
|
||||||
|
|
||||||
|
def _normalize_target_path(self, recipe_scanner, target_path: str) -> tuple[str, str]:
|
||||||
|
"""Normalize and validate the target path for recipe moves."""
|
||||||
|
|
||||||
|
if not target_path:
|
||||||
|
raise RecipeValidationError("Target path is required")
|
||||||
|
|
||||||
|
recipes_root = recipe_scanner.recipes_dir
|
||||||
|
if not recipes_root:
|
||||||
|
raise RecipeNotFoundError("Recipes directory not found")
|
||||||
|
|
||||||
|
normalized_target = os.path.normpath(target_path)
|
||||||
|
recipes_root = os.path.normpath(recipes_root)
|
||||||
|
if not os.path.isabs(normalized_target):
|
||||||
|
normalized_target = os.path.normpath(os.path.join(recipes_root, normalized_target))
|
||||||
|
|
||||||
|
try:
|
||||||
|
common_root = os.path.commonpath([normalized_target, recipes_root])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RecipeValidationError("Invalid target path") from exc
|
||||||
|
|
||||||
|
if common_root != recipes_root:
|
||||||
|
raise RecipeValidationError("Target path must be inside the recipes directory")
|
||||||
|
|
||||||
|
return normalized_target, recipes_root
|
||||||
|
|
||||||
|
async def _move_recipe_files(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
recipe_scanner,
|
||||||
|
recipe_id: str,
|
||||||
|
normalized_target: str,
|
||||||
|
recipes_root: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Move the recipe's JSON and preview image into the normalized target."""
|
||||||
|
|
||||||
|
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||||
|
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||||
|
raise RecipeNotFoundError("Recipe not found")
|
||||||
|
|
||||||
|
recipe_data = await recipe_scanner.get_recipe_by_id(recipe_id)
|
||||||
|
if not recipe_data:
|
||||||
|
raise RecipeNotFoundError("Recipe not found")
|
||||||
|
|
||||||
|
current_json_dir = os.path.dirname(recipe_json_path)
|
||||||
|
normalized_image_path = os.path.normpath(recipe_data.get("file_path") or "") if recipe_data.get("file_path") else None
|
||||||
|
|
||||||
|
os.makedirs(normalized_target, exist_ok=True)
|
||||||
|
|
||||||
|
if os.path.normpath(current_json_dir) == normalized_target:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Recipe is already in the target folder",
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"original_file_path": recipe_data.get("file_path"),
|
||||||
|
"new_file_path": recipe_data.get("file_path"),
|
||||||
|
}
|
||||||
|
|
||||||
|
new_json_path = os.path.normpath(os.path.join(normalized_target, os.path.basename(recipe_json_path)))
|
||||||
|
shutil.move(recipe_json_path, new_json_path)
|
||||||
|
|
||||||
|
new_image_path = normalized_image_path
|
||||||
|
if normalized_image_path:
|
||||||
|
target_image_path = os.path.normpath(os.path.join(normalized_target, os.path.basename(normalized_image_path)))
|
||||||
|
if os.path.exists(normalized_image_path) and normalized_image_path != target_image_path:
|
||||||
|
shutil.move(normalized_image_path, target_image_path)
|
||||||
|
new_image_path = target_image_path
|
||||||
|
|
||||||
|
relative_folder = os.path.relpath(normalized_target, recipes_root)
|
||||||
|
if relative_folder in (".", ""):
|
||||||
|
relative_folder = ""
|
||||||
|
updates = {"file_path": new_image_path or recipe_data.get("file_path"), "folder": relative_folder.replace(os.path.sep, "/")}
|
||||||
|
|
||||||
|
updated = await recipe_scanner.update_recipe_metadata(recipe_id, updates)
|
||||||
|
if not updated:
|
||||||
|
raise RecipeNotFoundError("Recipe not found after move")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"original_file_path": recipe_data.get("file_path"),
|
||||||
|
"new_file_path": updates["file_path"],
|
||||||
|
"json_path": new_json_path,
|
||||||
|
"folder": updates["folder"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def move_recipe(self, *, recipe_scanner, recipe_id: str, target_path: str) -> PersistenceResult:
|
||||||
|
"""Move a recipe's assets into a new folder under the recipes root."""
|
||||||
|
|
||||||
|
normalized_target, recipes_root = self._normalize_target_path(recipe_scanner, target_path)
|
||||||
|
result = await self._move_recipe_files(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
recipe_id=recipe_id,
|
||||||
|
normalized_target=normalized_target,
|
||||||
|
recipes_root=recipes_root,
|
||||||
|
)
|
||||||
|
return PersistenceResult(result)
|
||||||
|
|
||||||
|
async def move_recipes_bulk(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
recipe_scanner,
|
||||||
|
recipe_ids: Iterable[str],
|
||||||
|
target_path: str,
|
||||||
|
) -> PersistenceResult:
|
||||||
|
"""Move multiple recipes to a new folder."""
|
||||||
|
|
||||||
|
recipe_ids = list(recipe_ids)
|
||||||
|
if not recipe_ids:
|
||||||
|
raise RecipeValidationError("No recipe IDs provided")
|
||||||
|
|
||||||
|
normalized_target, recipes_root = self._normalize_target_path(recipe_scanner, target_path)
|
||||||
|
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
success_count = 0
|
||||||
|
failure_count = 0
|
||||||
|
|
||||||
|
for recipe_id in recipe_ids:
|
||||||
|
try:
|
||||||
|
move_result = await self._move_recipe_files(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
recipe_id=str(recipe_id),
|
||||||
|
normalized_target=normalized_target,
|
||||||
|
recipes_root=recipes_root,
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"original_file_path": move_result.get("original_file_path"),
|
||||||
|
"new_file_path": move_result.get("new_file_path"),
|
||||||
|
"success": True,
|
||||||
|
"message": move_result.get("message", ""),
|
||||||
|
"folder": move_result.get("folder", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
success_count += 1
|
||||||
|
except Exception as exc: # pragma: no cover - per-item error handling
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"original_file_path": None,
|
||||||
|
"new_file_path": None,
|
||||||
|
"success": False,
|
||||||
|
"message": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
failure_count += 1
|
||||||
|
|
||||||
|
return PersistenceResult(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": f"Moved {success_count} of {len(recipe_ids)} recipes",
|
||||||
|
"results": results,
|
||||||
|
"success_count": success_count,
|
||||||
|
"failure_count": failure_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def reconnect_lora(
|
async def reconnect_lora(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -197,8 +351,8 @@ class RecipePersistenceService:
|
|||||||
) -> PersistenceResult:
|
) -> PersistenceResult:
|
||||||
"""Reconnect a LoRA entry within an existing recipe."""
|
"""Reconnect a LoRA entry within an existing recipe."""
|
||||||
|
|
||||||
recipe_path = os.path.join(recipe_scanner.recipes_dir, f"{recipe_id}.recipe.json")
|
recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||||
if not os.path.exists(recipe_path):
|
if not recipe_path or not os.path.exists(recipe_path):
|
||||||
raise RecipeNotFoundError("Recipe not found")
|
raise RecipeNotFoundError("Recipe not found")
|
||||||
|
|
||||||
target_lora = await recipe_scanner.get_local_lora(target_name)
|
target_lora = await recipe_scanner.get_local_lora(target_name)
|
||||||
@@ -243,16 +397,12 @@ class RecipePersistenceService:
|
|||||||
if not recipe_ids:
|
if not recipe_ids:
|
||||||
raise RecipeValidationError("No recipe IDs provided")
|
raise RecipeValidationError("No recipe IDs provided")
|
||||||
|
|
||||||
recipes_dir = recipe_scanner.recipes_dir
|
|
||||||
if not recipes_dir or not os.path.exists(recipes_dir):
|
|
||||||
raise RecipeNotFoundError("Recipes directory not found")
|
|
||||||
|
|
||||||
deleted_recipes: list[str] = []
|
deleted_recipes: list[str] = []
|
||||||
failed_recipes: list[dict[str, Any]] = []
|
failed_recipes: list[dict[str, Any]] = []
|
||||||
|
|
||||||
for recipe_id in recipe_ids:
|
for recipe_id in recipe_ids:
|
||||||
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
|
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||||
if not os.path.exists(recipe_json_path):
|
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||||
failed_recipes.append({"id": recipe_id, "reason": "Recipe not found"})
|
failed_recipes.append({"id": recipe_id, "reason": "Recipe not found"})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class TranslationKeySynchronizer:
|
|||||||
self.locales_dir = locales_dir
|
self.locales_dir = locales_dir
|
||||||
self.verbose = verbose
|
self.verbose = verbose
|
||||||
self.reference_locale = 'en'
|
self.reference_locale = 'en'
|
||||||
self.target_locales = ['zh-CN', 'zh-TW', 'ja', 'ru', 'de', 'fr', 'es', 'ko']
|
self.target_locales = ['zh-CN', 'zh-TW', 'ja', 'ru', 'de', 'fr', 'es', 'ko', 'he']
|
||||||
|
|
||||||
def log(self, message: str, level: str = 'INFO'):
|
def log(self, message: str, level: str = 'INFO'):
|
||||||
"""Log a message if verbose mode is enabled."""
|
"""Log a message if verbose mode is enabled."""
|
||||||
|
|||||||
@@ -2,6 +2,35 @@ import { RecipeCard } from '../components/RecipeCard.js';
|
|||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
|
||||||
|
const RECIPE_ENDPOINTS = {
|
||||||
|
list: '/api/lm/recipes',
|
||||||
|
detail: '/api/lm/recipe',
|
||||||
|
scan: '/api/lm/recipes/scan',
|
||||||
|
update: '/api/lm/recipe',
|
||||||
|
roots: '/api/lm/recipes/roots',
|
||||||
|
folders: '/api/lm/recipes/folders',
|
||||||
|
folderTree: '/api/lm/recipes/folder-tree',
|
||||||
|
unifiedFolderTree: '/api/lm/recipes/unified-folder-tree',
|
||||||
|
move: '/api/lm/recipe/move',
|
||||||
|
moveBulk: '/api/lm/recipes/move-bulk',
|
||||||
|
bulkDelete: '/api/lm/recipes/bulk-delete',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RECIPE_SIDEBAR_CONFIG = {
|
||||||
|
config: {
|
||||||
|
displayName: 'Recipe',
|
||||||
|
supportsMove: true,
|
||||||
|
},
|
||||||
|
endpoints: RECIPE_ENDPOINTS,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function extractRecipeId(filePath) {
|
||||||
|
if (!filePath) return null;
|
||||||
|
const basename = filePath.split('/').pop().split('\\').pop();
|
||||||
|
const dotIndex = basename.lastIndexOf('.');
|
||||||
|
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch recipes with pagination for virtual scrolling
|
* Fetch recipes with pagination for virtual scrolling
|
||||||
* @param {number} page - Page number to fetch
|
* @param {number} page - Page number to fetch
|
||||||
@@ -18,10 +47,21 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
|||||||
sort_by: pageState.sortBy
|
sort_by: pageState.sortBy
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (pageState.showFavoritesOnly) {
|
||||||
|
params.append('favorite', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pageState.activeFolder) {
|
||||||
|
params.append('folder', pageState.activeFolder);
|
||||||
|
params.append('recursive', pageState.searchOptions?.recursive !== false);
|
||||||
|
} else if (pageState.searchOptions?.recursive !== undefined) {
|
||||||
|
params.append('recursive', pageState.searchOptions.recursive);
|
||||||
|
}
|
||||||
|
|
||||||
// If we have a specific recipe ID to load
|
// If we have a specific recipe ID to load
|
||||||
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
|
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
|
||||||
// Special case: load specific recipe
|
// Special case: load specific recipe
|
||||||
const response = await fetch(`/api/lm/recipe/${pageState.customFilter.recipeId}`);
|
const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${pageState.customFilter.recipeId}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
||||||
@@ -56,6 +96,7 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
|||||||
params.append('search_tags', pageState.searchOptions.tags.toString());
|
params.append('search_tags', pageState.searchOptions.tags.toString());
|
||||||
params.append('search_lora_name', pageState.searchOptions.loraName.toString());
|
params.append('search_lora_name', pageState.searchOptions.loraName.toString());
|
||||||
params.append('search_lora_model', pageState.searchOptions.loraModel.toString());
|
params.append('search_lora_model', pageState.searchOptions.loraModel.toString());
|
||||||
|
params.append('search_prompt', (pageState.searchOptions.prompt || false).toString());
|
||||||
params.append('fuzzy', 'true');
|
params.append('fuzzy', 'true');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -78,7 +119,7 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Fetch recipes
|
// Fetch recipes
|
||||||
const response = await fetch(`/api/lm/recipes?${params.toString()}`);
|
const response = await fetch(`${RECIPE_ENDPOINTS.list}?${params.toString()}`);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to load recipes: ${response.statusText}`);
|
throw new Error(`Failed to load recipes: ${response.statusText}`);
|
||||||
@@ -213,7 +254,7 @@ export async function refreshRecipes() {
|
|||||||
state.loadingManager.showSimpleLoading('Refreshing recipes...');
|
state.loadingManager.showSimpleLoading('Refreshing recipes...');
|
||||||
|
|
||||||
// Call the API endpoint to rebuild the recipe cache
|
// Call the API endpoint to rebuild the recipe cache
|
||||||
const response = await fetch('/api/lm/recipes/scan');
|
const response = await fetch(RECIPE_ENDPOINTS.scan);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
@@ -277,10 +318,12 @@ export async function updateRecipeMetadata(filePath, updates) {
|
|||||||
state.loadingManager.showSimpleLoading('Saving metadata...');
|
state.loadingManager.showSimpleLoading('Saving metadata...');
|
||||||
|
|
||||||
// Extract recipeId from filePath (basename without extension)
|
// Extract recipeId from filePath (basename without extension)
|
||||||
const basename = filePath.split('/').pop().split('\\').pop();
|
const recipeId = extractRecipeId(filePath);
|
||||||
const recipeId = basename.substring(0, basename.lastIndexOf('.'));
|
if (!recipeId) {
|
||||||
|
throw new Error('Unable to determine recipe ID');
|
||||||
|
}
|
||||||
|
|
||||||
const response = await fetch(`/api/lm/recipe/${recipeId}/update`, {
|
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
@@ -306,3 +349,187 @@ export async function updateRecipeMetadata(filePath, updates) {
|
|||||||
state.loadingManager.hide();
|
state.loadingManager.hide();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class RecipeSidebarApiClient {
|
||||||
|
constructor() {
|
||||||
|
this.apiConfig = RECIPE_SIDEBAR_CONFIG;
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchUnifiedFolderTree() {
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.unifiedFolderTree);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch recipe folder tree');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchModelRoots() {
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.roots);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch recipe roots');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async fetchModelFolders() {
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.folders);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to fetch recipe folders');
|
||||||
|
}
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveBulkModels(filePaths, targetPath) {
|
||||||
|
if (!this.apiConfig.config.supportsMove) {
|
||||||
|
showToast('toast.api.bulkMoveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipeIds = filePaths
|
||||||
|
.map((path) => extractRecipeId(path))
|
||||||
|
.filter((id) => !!id);
|
||||||
|
|
||||||
|
if (recipeIds.length === 0) {
|
||||||
|
showToast('toast.models.noModelsSelected', {}, 'warning');
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.moveBulk, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
recipe_ids: recipeIds,
|
||||||
|
target_path: targetPath,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new Error(result.error || `Failed to move ${this.apiConfig.config.displayName}s`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.failure_count > 0) {
|
||||||
|
showToast(
|
||||||
|
'toast.api.bulkMovePartial',
|
||||||
|
{
|
||||||
|
successCount: result.success_count,
|
||||||
|
type: this.apiConfig.config.displayName,
|
||||||
|
failureCount: result.failure_count,
|
||||||
|
},
|
||||||
|
'warning'
|
||||||
|
);
|
||||||
|
|
||||||
|
const failedFiles = (result.results || [])
|
||||||
|
.filter((item) => !item.success)
|
||||||
|
.map((item) => item.message || 'Unknown error');
|
||||||
|
|
||||||
|
if (failedFiles.length > 0) {
|
||||||
|
const failureMessage =
|
||||||
|
failedFiles.length <= 3
|
||||||
|
? failedFiles.join('\n')
|
||||||
|
: `${failedFiles.slice(0, 3).join('\n')}\n(and ${failedFiles.length - 3} more)`;
|
||||||
|
showToast('toast.api.bulkMoveFailures', { failures: failureMessage }, 'warning', 6000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast(
|
||||||
|
'toast.api.bulkMoveSuccess',
|
||||||
|
{
|
||||||
|
successCount: result.success_count,
|
||||||
|
type: this.apiConfig.config.displayName,
|
||||||
|
},
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.results || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async moveSingleModel(filePath, targetPath) {
|
||||||
|
if (!this.apiConfig.config.supportsMove) {
|
||||||
|
showToast('toast.api.moveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipeId = extractRecipeId(filePath);
|
||||||
|
if (!recipeId) {
|
||||||
|
showToast('toast.api.moveFailed', { message: 'Recipe ID missing' }, 'error');
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.move, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
recipe_id: recipeId,
|
||||||
|
target_path: targetPath,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new Error(result.error || `Failed to move ${this.apiConfig.config.displayName}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.message) {
|
||||||
|
showToast('toast.api.moveInfo', { message: result.message }, 'info');
|
||||||
|
} else {
|
||||||
|
showToast('toast.api.moveSuccess', { type: this.apiConfig.config.displayName }, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
original_file_path: result.original_file_path || filePath,
|
||||||
|
new_file_path: result.new_file_path || filePath,
|
||||||
|
folder: result.folder || '',
|
||||||
|
message: result.message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async bulkDeleteModels(filePaths) {
|
||||||
|
if (!filePaths || filePaths.length === 0) {
|
||||||
|
throw new Error('No file paths provided');
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipeIds = filePaths
|
||||||
|
.map((path) => extractRecipeId(path))
|
||||||
|
.filter((id) => !!id);
|
||||||
|
|
||||||
|
if (recipeIds.length === 0) {
|
||||||
|
throw new Error('No recipe IDs could be derived from file paths');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
state.loadingManager?.showSimpleLoading('Deleting recipes...');
|
||||||
|
|
||||||
|
const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
recipe_ids: recipeIds,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
|
||||||
|
if (!response.ok || !result.success) {
|
||||||
|
throw new Error(result.error || 'Failed to delete recipes');
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
deleted_count: result.total_deleted,
|
||||||
|
failed_count: result.total_failed || 0,
|
||||||
|
errors: result.failed || [],
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
state.loadingManager?.hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHe
|
|||||||
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||||
import { updateRecipeMetadata } from '../../api/recipeApi.js';
|
import { updateRecipeMetadata } from '../../api/recipeApi.js';
|
||||||
import { state } from '../../state/index.js';
|
import { state } from '../../state/index.js';
|
||||||
|
import { moveManager } from '../../managers/MoveManager.js';
|
||||||
|
|
||||||
export class RecipeContextMenu extends BaseContextMenu {
|
export class RecipeContextMenu extends BaseContextMenu {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -77,6 +78,9 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
// Share recipe
|
// Share recipe
|
||||||
this.currentCard.querySelector('.fa-share-alt')?.click();
|
this.currentCard.querySelector('.fa-share-alt')?.click();
|
||||||
break;
|
break;
|
||||||
|
case 'move':
|
||||||
|
moveManager.showMoveModal(this.currentCard.dataset.filepath);
|
||||||
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
// Delete recipe
|
// Delete recipe
|
||||||
this.currentCard.querySelector('.fa-trash')?.click();
|
this.currentCard.querySelector('.fa-trash')?.click();
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
// Recipe Card Component
|
// Recipe Card Component
|
||||||
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
|
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
|
||||||
|
import { updateRecipeMetadata } from '../api/recipeApi.js';
|
||||||
import { configureModelCardVideo } from './shared/ModelCard.js';
|
import { configureModelCardVideo } from './shared/ModelCard.js';
|
||||||
import { modalManager } from '../managers/ModalManager.js';
|
import { modalManager } from '../managers/ModalManager.js';
|
||||||
import { getCurrentPageState } from '../state/index.js';
|
import { getCurrentPageState } from '../state/index.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
|
import { bulkManager } from '../managers/BulkManager.js';
|
||||||
import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js';
|
import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js';
|
||||||
|
|
||||||
class RecipeCard {
|
class RecipeCard {
|
||||||
@@ -43,8 +45,11 @@ class RecipeCard {
|
|||||||
(this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` :
|
(this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` :
|
||||||
'/loras_static/images/no-preview.png');
|
'/loras_static/images/no-preview.png');
|
||||||
|
|
||||||
|
const isDuplicatesMode = getCurrentPageState().duplicatesMode;
|
||||||
|
const autoplayOnHover = state?.global?.settings?.autoplay_on_hover === true;
|
||||||
|
const isFavorite = this.recipe.favorite === true;
|
||||||
|
|
||||||
// Video preview logic
|
// Video preview logic
|
||||||
const autoplayOnHover = state.settings.autoplay_on_hover || false;
|
|
||||||
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
|
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
|
||||||
const videoAttrs = [
|
const videoAttrs = [
|
||||||
'controls',
|
'controls',
|
||||||
@@ -59,10 +64,6 @@ class RecipeCard {
|
|||||||
videoAttrs.push('data-autoplay="true"');
|
videoAttrs.push('data-autoplay="true"');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if in duplicates mode
|
|
||||||
const pageState = getCurrentPageState();
|
|
||||||
const isDuplicatesMode = pageState.duplicatesMode;
|
|
||||||
|
|
||||||
// NSFW blur logic - similar to LoraCard
|
// NSFW blur logic - similar to LoraCard
|
||||||
const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0;
|
const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0;
|
||||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
||||||
@@ -95,6 +96,7 @@ class RecipeCard {
|
|||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${baseModelLabel}">${baseModelDisplay}</span>
|
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${baseModelLabel}">${baseModelDisplay}</span>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
|
<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}"></i>
|
||||||
<i class="fas fa-share-alt" title="Share Recipe"></i>
|
<i class="fas fa-share-alt" title="Share Recipe"></i>
|
||||||
<i class="fas fa-paper-plane" title="Send Recipe to Workflow (Click: Append, Shift+Click: Replace)"></i>
|
<i class="fas fa-paper-plane" title="Send Recipe to Workflow (Click: Append, Shift+Click: Replace)"></i>
|
||||||
<i class="fas fa-trash" title="Delete Recipe"></i>
|
<i class="fas fa-trash" title="Delete Recipe"></i>
|
||||||
@@ -140,6 +142,67 @@ class RecipeCard {
|
|||||||
return `${missingCount} of ${totalCount} LoRAs missing`;
|
return `${missingCount} of ${totalCount} LoRAs missing`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async toggleFavorite(card) {
|
||||||
|
// Find the latest star icon in case the card was re-rendered
|
||||||
|
const getStarIcon = (c) => c.querySelector('.fa-star');
|
||||||
|
let starIcon = getStarIcon(card);
|
||||||
|
|
||||||
|
const isFavorite = this.recipe.favorite || false;
|
||||||
|
const newFavoriteState = !isFavorite;
|
||||||
|
|
||||||
|
// Update early to provide instant feedback and avoid race conditions with re-renders
|
||||||
|
this.recipe.favorite = newFavoriteState;
|
||||||
|
|
||||||
|
// Function to update icon state
|
||||||
|
const updateIconUI = (icon, state) => {
|
||||||
|
if (!icon) return;
|
||||||
|
if (state) {
|
||||||
|
icon.classList.remove('far');
|
||||||
|
icon.classList.add('fas', 'favorite-active');
|
||||||
|
icon.title = 'Remove from Favorites';
|
||||||
|
} else {
|
||||||
|
icon.classList.remove('fas', 'favorite-active');
|
||||||
|
icon.classList.add('far');
|
||||||
|
icon.title = 'Add to Favorites';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update current icon immediately
|
||||||
|
updateIconUI(starIcon, newFavoriteState);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateRecipeMetadata(this.recipe.file_path, {
|
||||||
|
favorite: newFavoriteState
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status already updated, just show toast
|
||||||
|
if (newFavoriteState) {
|
||||||
|
showToast('modelCard.favorites.added', {}, 'success');
|
||||||
|
} else {
|
||||||
|
showToast('modelCard.favorites.removed', {}, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-find star icon after API call as VirtualScroller might have replaced the element
|
||||||
|
// During updateRecipeMetadata, VirtualScroller.updateSingleItem might have re-rendered the card
|
||||||
|
// We need to find the NEW element in the DOM to ensure we don't have a stale reference
|
||||||
|
// Though typically VirtualScroller handles the re-render with the NEW this.recipe.favorite
|
||||||
|
// we will check the DOM just to be sure if this instance's internal card is still what's in DOM
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update favorite status:', error);
|
||||||
|
// Revert local state on error
|
||||||
|
this.recipe.favorite = isFavorite;
|
||||||
|
|
||||||
|
// Re-find star icon in case of re-render during fault
|
||||||
|
const currentCard = card.ownerDocument.evaluate(
|
||||||
|
`.//*[@data-filepath="${this.recipe.file_path}"]`,
|
||||||
|
card.ownerDocument, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
|
||||||
|
).singleNodeValue || card;
|
||||||
|
|
||||||
|
updateIconUI(getStarIcon(currentCard), isFavorite);
|
||||||
|
showToast('modelCard.favorites.updateFailed', {}, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
attachEventListeners(card, isDuplicatesMode, shouldBlur) {
|
attachEventListeners(card, isDuplicatesMode, shouldBlur) {
|
||||||
// Add blur toggle functionality if content should be blurred
|
// Add blur toggle functionality if content should be blurred
|
||||||
if (shouldBlur) {
|
if (shouldBlur) {
|
||||||
@@ -164,9 +227,19 @@ class RecipeCard {
|
|||||||
// Recipe card click event - only attach if not in duplicates mode
|
// Recipe card click event - only attach if not in duplicates mode
|
||||||
if (!isDuplicatesMode) {
|
if (!isDuplicatesMode) {
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
|
if (state.bulkMode) {
|
||||||
|
bulkManager.toggleCardSelection(card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.clickHandler(this.recipe);
|
this.clickHandler(this.recipe);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Favorite button click event - prevent propagation to card
|
||||||
|
card.querySelector('.fa-star')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.toggleFavorite(card);
|
||||||
|
});
|
||||||
|
|
||||||
// Share button click event - prevent propagation to card
|
// Share button click event - prevent propagation to card
|
||||||
card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => {
|
card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ export class SidebarManager {
|
|||||||
this.pageControls = pageControls;
|
this.pageControls = pageControls;
|
||||||
this.pageType = pageControls.pageType;
|
this.pageType = pageControls.pageType;
|
||||||
this.lastPageControls = pageControls;
|
this.lastPageControls = pageControls;
|
||||||
this.apiClient = getModelApiClient();
|
this.apiClient = pageControls?.getSidebarApiClient?.()
|
||||||
|
|| pageControls?.sidebarApiClient
|
||||||
|
|| getModelApiClient();
|
||||||
|
|
||||||
// Set initial sidebar state immediately (hidden by default)
|
// Set initial sidebar state immediately (hidden by default)
|
||||||
this.setInitialSidebarState();
|
this.setInitialSidebarState();
|
||||||
@@ -205,6 +207,10 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initializeDragAndDrop() {
|
initializeDragAndDrop() {
|
||||||
|
if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.dragHandlersInitialized) {
|
if (!this.dragHandlersInitialized) {
|
||||||
document.addEventListener('dragstart', this.handleCardDragStart);
|
document.addEventListener('dragstart', this.handleCardDragStart);
|
||||||
document.addEventListener('dragend', this.handleCardDragEnd);
|
document.addEventListener('dragend', this.handleCardDragEnd);
|
||||||
@@ -416,7 +422,14 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.apiClient) {
|
if (!this.apiClient) {
|
||||||
this.apiClient = getModelApiClient();
|
this.apiClient = this.pageControls?.getSidebarApiClient?.()
|
||||||
|
|| this.pageControls?.sidebarApiClient
|
||||||
|
|| getModelApiClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
|
||||||
|
showToast('toast.models.moveFailed', { message: translate('sidebar.dragDrop.moveUnsupported', {}, 'Move not supported for this page') }, 'error');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootPath = this.draggedRootPath ? this.draggedRootPath.replace(/\\/g, '/') : '';
|
const rootPath = this.draggedRootPath ? this.draggedRootPath.replace(/\\/g, '/') : '';
|
||||||
@@ -470,7 +483,9 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
this.apiClient = getModelApiClient();
|
this.apiClient = this.pageControls?.getSidebarApiClient?.()
|
||||||
|
|| this.pageControls?.sidebarApiClient
|
||||||
|
|| getModelApiClient();
|
||||||
|
|
||||||
// Set initial sidebar state immediately (hidden by default)
|
// Set initial sidebar state immediately (hidden by default)
|
||||||
this.setInitialSidebarState();
|
this.setInitialSidebarState();
|
||||||
|
|||||||
@@ -60,14 +60,12 @@ export class AppCore {
|
|||||||
initTheme();
|
initTheme();
|
||||||
initBackToTop();
|
initBackToTop();
|
||||||
|
|
||||||
// Initialize the bulk manager and context menu only if not on recipes page
|
// Initialize the bulk manager and context menu
|
||||||
if (state.currentPageType !== 'recipes') {
|
|
||||||
bulkManager.initialize();
|
bulkManager.initialize();
|
||||||
|
|
||||||
// Initialize bulk context menu
|
// Initialize bulk context menu
|
||||||
const bulkContextMenu = new BulkContextMenu();
|
const bulkContextMenu = new BulkContextMenu();
|
||||||
bulkManager.setBulkContextMenu(bulkContextMenu);
|
bulkManager.setBulkContextMenu(bulkContextMenu);
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the example images manager
|
// Initialize the example images manager
|
||||||
exampleImagesManager.initialize();
|
exampleImagesManager.initialize();
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax, getNSF
|
|||||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||||
|
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
|
||||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||||
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
|
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
|
||||||
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
||||||
@@ -62,9 +63,22 @@ export class BulkManager {
|
|||||||
autoOrganize: true,
|
autoOrganize: true,
|
||||||
deleteAll: true,
|
deleteAll: true,
|
||||||
setContentRating: true
|
setContentRating: true
|
||||||
|
},
|
||||||
|
recipes: {
|
||||||
|
addTags: false,
|
||||||
|
sendToWorkflow: false,
|
||||||
|
copyAll: false,
|
||||||
|
refreshAll: false,
|
||||||
|
checkUpdates: false,
|
||||||
|
moveAll: true,
|
||||||
|
autoOrganize: false,
|
||||||
|
deleteAll: true,
|
||||||
|
setContentRating: false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.recipeApiClient = null;
|
||||||
|
|
||||||
window.addEventListener('lm:priority-tags-updated', () => {
|
window.addEventListener('lm:priority-tags-updated', () => {
|
||||||
const container = document.querySelector('#bulkAddTagsModal .metadata-suggestions-container');
|
const container = document.querySelector('#bulkAddTagsModal .metadata-suggestions-container');
|
||||||
if (!container) {
|
if (!container) {
|
||||||
@@ -87,9 +101,6 @@ export class BulkManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initialize() {
|
initialize() {
|
||||||
// Do not initialize on recipes page
|
|
||||||
if (state.currentPageType === 'recipes') return;
|
|
||||||
|
|
||||||
// Register with event manager for coordinated event handling
|
// Register with event manager for coordinated event handling
|
||||||
this.registerEventHandlers();
|
this.registerEventHandlers();
|
||||||
|
|
||||||
@@ -97,6 +108,23 @@ export class BulkManager {
|
|||||||
eventManager.setState('bulkMode', state.bulkMode || false);
|
eventManager.setState('bulkMode', state.bulkMode || false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getActiveApiClient() {
|
||||||
|
if (state.currentPageType === 'recipes') {
|
||||||
|
if (!this.recipeApiClient) {
|
||||||
|
this.recipeApiClient = new RecipeSidebarApiClient();
|
||||||
|
}
|
||||||
|
return this.recipeApiClient;
|
||||||
|
}
|
||||||
|
return getModelApiClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
getCurrentDisplayConfig() {
|
||||||
|
if (state.currentPageType === 'recipes') {
|
||||||
|
return { displayName: 'Recipe' };
|
||||||
|
}
|
||||||
|
return MODEL_CONFIG[state.currentPageType] || { displayName: 'Model' };
|
||||||
|
}
|
||||||
|
|
||||||
setBulkContextMenu(bulkContextMenu) {
|
setBulkContextMenu(bulkContextMenu) {
|
||||||
this.bulkContextMenu = bulkContextMenu;
|
this.bulkContextMenu = bulkContextMenu;
|
||||||
}
|
}
|
||||||
@@ -240,7 +268,9 @@ export class BulkManager {
|
|||||||
// Update event manager state
|
// Update event manager state
|
||||||
eventManager.setState('bulkMode', state.bulkMode);
|
eventManager.setState('bulkMode', state.bulkMode);
|
||||||
|
|
||||||
|
if (this.bulkBtn) {
|
||||||
this.bulkBtn.classList.toggle('active', state.bulkMode);
|
this.bulkBtn.classList.toggle('active', state.bulkMode);
|
||||||
|
}
|
||||||
|
|
||||||
updateCardsForBulkMode(state.bulkMode);
|
updateCardsForBulkMode(state.bulkMode);
|
||||||
|
|
||||||
@@ -504,13 +534,13 @@ export class BulkManager {
|
|||||||
modalManager.closeModal('bulkDeleteModal');
|
modalManager.closeModal('bulkDeleteModal');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const apiClient = getModelApiClient();
|
const apiClient = this.getActiveApiClient();
|
||||||
const filePaths = Array.from(state.selectedModels);
|
const filePaths = Array.from(state.selectedModels);
|
||||||
|
|
||||||
const result = await apiClient.bulkDeleteModels(filePaths);
|
const result = await apiClient.bulkDeleteModels(filePaths);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
const currentConfig = this.getCurrentDisplayConfig();
|
||||||
showToast('toast.models.deletedSuccessfully', {
|
showToast('toast.models.deletedSuccessfully', {
|
||||||
count: result.deleted_count,
|
count: result.deleted_count,
|
||||||
type: currentConfig.displayName.toLowerCase()
|
type: currentConfig.displayName.toLowerCase()
|
||||||
@@ -570,7 +600,7 @@ export class BulkManager {
|
|||||||
this.applySelectionState();
|
this.applySelectionState();
|
||||||
|
|
||||||
const newlySelected = state.selectedModels.size - oldCount;
|
const newlySelected = state.selectedModels.size - oldCount;
|
||||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
const currentConfig = this.getCurrentDisplayConfig();
|
||||||
showToast('toast.models.selectedAdditional', {
|
showToast('toast.models.selectedAdditional', {
|
||||||
count: newlySelected,
|
count: newlySelected,
|
||||||
type: currentConfig.displayName.toLowerCase()
|
type: currentConfig.displayName.toLowerCase()
|
||||||
@@ -622,8 +652,7 @@ export class BulkManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const currentType = state.currentPageType;
|
const currentConfig = this.getCurrentDisplayConfig();
|
||||||
const currentConfig = MODEL_CONFIG[currentType] || MODEL_CONFIG[MODEL_TYPES.LORA];
|
|
||||||
const typeLabel = (currentConfig?.displayName || 'Model').toLowerCase();
|
const typeLabel = (currentConfig?.displayName || 'Model').toLowerCase();
|
||||||
|
|
||||||
const { ids: modelIds, missingCount } = this.collectSelectedModelIds();
|
const { ids: modelIds, missingCount } = this.collectSelectedModelIds();
|
||||||
@@ -969,7 +998,7 @@ export class BulkManager {
|
|||||||
modalManager.closeModal('bulkAddTagsModal');
|
modalManager.closeModal('bulkAddTagsModal');
|
||||||
|
|
||||||
if (successCount > 0) {
|
if (successCount > 0) {
|
||||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
const currentConfig = this.getCurrentDisplayConfig();
|
||||||
const toastKey = mode === 'replace' ? 'toast.models.tagsReplacedSuccessfully' : 'toast.models.tagsAddedSuccessfully';
|
const toastKey = mode === 'replace' ? 'toast.models.tagsReplacedSuccessfully' : 'toast.models.tagsAddedSuccessfully';
|
||||||
showToast(toastKey, {
|
showToast(toastKey, {
|
||||||
count: successCount,
|
count: successCount,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { state, getCurrentPageState } from '../state/index.js';
|
|||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { bulkManager } from './BulkManager.js';
|
import { bulkManager } from './BulkManager.js';
|
||||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
|
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
|
||||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||||
import { sidebarManager } from '../components/SidebarManager.js';
|
import { sidebarManager } from '../components/SidebarManager.js';
|
||||||
|
|
||||||
@@ -12,11 +13,22 @@ class MoveManager {
|
|||||||
this.bulkFilePaths = null;
|
this.bulkFilePaths = null;
|
||||||
this.folderTreeManager = new FolderTreeManager();
|
this.folderTreeManager = new FolderTreeManager();
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
|
this.recipeApiClient = null;
|
||||||
|
|
||||||
// Bind methods
|
// Bind methods
|
||||||
this.updateTargetPath = this.updateTargetPath.bind(this);
|
this.updateTargetPath = this.updateTargetPath.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_getApiClient(modelType = null) {
|
||||||
|
if (state.currentPageType === 'recipes') {
|
||||||
|
if (!this.recipeApiClient) {
|
||||||
|
this.recipeApiClient = new RecipeSidebarApiClient();
|
||||||
|
}
|
||||||
|
return this.recipeApiClient;
|
||||||
|
}
|
||||||
|
return getModelApiClient(modelType);
|
||||||
|
}
|
||||||
|
|
||||||
initializeEventListeners() {
|
initializeEventListeners() {
|
||||||
if (this.initialized) return;
|
if (this.initialized) return;
|
||||||
|
|
||||||
@@ -36,7 +48,7 @@ class MoveManager {
|
|||||||
this.currentFilePath = null;
|
this.currentFilePath = null;
|
||||||
this.bulkFilePaths = null;
|
this.bulkFilePaths = null;
|
||||||
|
|
||||||
const apiClient = getModelApiClient();
|
const apiClient = this._getApiClient(modelType);
|
||||||
const currentPageType = state.currentPageType;
|
const currentPageType = state.currentPageType;
|
||||||
const modelConfig = apiClient.apiConfig.config;
|
const modelConfig = apiClient.apiConfig.config;
|
||||||
|
|
||||||
@@ -121,7 +133,7 @@ class MoveManager {
|
|||||||
|
|
||||||
async initializeFolderTree() {
|
async initializeFolderTree() {
|
||||||
try {
|
try {
|
||||||
const apiClient = getModelApiClient();
|
const apiClient = this._getApiClient();
|
||||||
// Fetch unified folder tree
|
// Fetch unified folder tree
|
||||||
const treeData = await apiClient.fetchUnifiedFolderTree();
|
const treeData = await apiClient.fetchUnifiedFolderTree();
|
||||||
|
|
||||||
@@ -141,7 +153,7 @@ class MoveManager {
|
|||||||
updateTargetPath() {
|
updateTargetPath() {
|
||||||
const pathDisplay = document.getElementById('moveTargetPathDisplay');
|
const pathDisplay = document.getElementById('moveTargetPathDisplay');
|
||||||
const modelRoot = document.getElementById('moveModelRoot').value;
|
const modelRoot = document.getElementById('moveModelRoot').value;
|
||||||
const apiClient = getModelApiClient();
|
const apiClient = this._getApiClient();
|
||||||
const config = apiClient.apiConfig.config;
|
const config = apiClient.apiConfig.config;
|
||||||
|
|
||||||
let fullPath = modelRoot || `Select a ${config.displayName.toLowerCase()} root directory`;
|
let fullPath = modelRoot || `Select a ${config.displayName.toLowerCase()} root directory`;
|
||||||
@@ -158,7 +170,7 @@ class MoveManager {
|
|||||||
|
|
||||||
async moveModel() {
|
async moveModel() {
|
||||||
const selectedRoot = document.getElementById('moveModelRoot').value;
|
const selectedRoot = document.getElementById('moveModelRoot').value;
|
||||||
const apiClient = getModelApiClient();
|
const apiClient = this._getApiClient();
|
||||||
const config = apiClient.apiConfig.config;
|
const config = apiClient.apiConfig.config;
|
||||||
|
|
||||||
if (!selectedRoot) {
|
if (!selectedRoot) {
|
||||||
|
|||||||
@@ -2,18 +2,47 @@
|
|||||||
import { appCore } from './core.js';
|
import { appCore } from './core.js';
|
||||||
import { ImportManager } from './managers/ImportManager.js';
|
import { ImportManager } from './managers/ImportManager.js';
|
||||||
import { RecipeModal } from './components/RecipeModal.js';
|
import { RecipeModal } from './components/RecipeModal.js';
|
||||||
import { getCurrentPageState } from './state/index.js';
|
import { state, getCurrentPageState } from './state/index.js';
|
||||||
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
|
||||||
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
import { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||||
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
import { DuplicatesManager } from './components/DuplicatesManager.js';
|
||||||
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
import { refreshVirtualScroll } from './utils/infiniteScroll.js';
|
||||||
import { refreshRecipes } from './api/recipeApi.js';
|
import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
|
||||||
|
import { sidebarManager } from './components/SidebarManager.js';
|
||||||
|
|
||||||
|
class RecipePageControls {
|
||||||
|
constructor() {
|
||||||
|
this.pageType = 'recipes';
|
||||||
|
this.pageState = getCurrentPageState();
|
||||||
|
this.sidebarApiClient = new RecipeSidebarApiClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
async resetAndReload() {
|
||||||
|
refreshVirtualScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
async refreshModels(fullRebuild = false) {
|
||||||
|
if (fullRebuild) {
|
||||||
|
await refreshRecipes();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshVirtualScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
getSidebarApiClient() {
|
||||||
|
return this.sidebarApiClient;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class RecipeManager {
|
class RecipeManager {
|
||||||
constructor() {
|
constructor() {
|
||||||
// Get page state
|
// Get page state
|
||||||
this.pageState = getCurrentPageState();
|
this.pageState = getCurrentPageState();
|
||||||
|
|
||||||
|
// Page controls for shared sidebar behaviors
|
||||||
|
this.pageControls = new RecipePageControls();
|
||||||
|
|
||||||
// Initialize ImportManager
|
// Initialize ImportManager
|
||||||
this.importManager = new ImportManager();
|
this.importManager = new ImportManager();
|
||||||
|
|
||||||
@@ -52,10 +81,23 @@ class RecipeManager {
|
|||||||
// Expose necessary functions to the page
|
// Expose necessary functions to the page
|
||||||
this._exposeGlobalFunctions();
|
this._exposeGlobalFunctions();
|
||||||
|
|
||||||
|
// Initialize sidebar navigation
|
||||||
|
await this._initSidebar();
|
||||||
|
|
||||||
// Initialize common page features
|
// Initialize common page features
|
||||||
appCore.initializePageFeatures();
|
appCore.initializePageFeatures();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _initSidebar() {
|
||||||
|
try {
|
||||||
|
sidebarManager.setHostPageControls(this.pageControls);
|
||||||
|
const shouldShowSidebar = state?.global?.settings?.show_folder_sidebar !== false;
|
||||||
|
await sidebarManager.setSidebarEnabled(shouldShowSidebar);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to initialize recipe sidebar:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_initSearchOptions() {
|
_initSearchOptions() {
|
||||||
// Ensure recipes search options are properly initialized
|
// Ensure recipes search options are properly initialized
|
||||||
if (!this.pageState.searchOptions) {
|
if (!this.pageState.searchOptions) {
|
||||||
@@ -63,7 +105,9 @@ class RecipeManager {
|
|||||||
title: true, // Recipe title
|
title: true, // Recipe title
|
||||||
tags: true, // Recipe tags
|
tags: true, // Recipe tags
|
||||||
loraName: true, // LoRA file name
|
loraName: true, // LoRA file name
|
||||||
loraModel: true // LoRA model name
|
loraModel: true, // LoRA model name
|
||||||
|
prompt: true, // Prompt search
|
||||||
|
recursive: true
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -172,11 +216,26 @@ class RecipeManager {
|
|||||||
// Sort select
|
// Sort select
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
if (sortSelect) {
|
if (sortSelect) {
|
||||||
|
sortSelect.value = this.pageState.sortBy || 'date:desc';
|
||||||
sortSelect.addEventListener('change', () => {
|
sortSelect.addEventListener('change', () => {
|
||||||
this.pageState.sortBy = sortSelect.value;
|
this.pageState.sortBy = sortSelect.value;
|
||||||
refreshVirtualScroll();
|
refreshVirtualScroll();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const bulkButton = document.querySelector('[data-action="bulk"]');
|
||||||
|
if (bulkButton) {
|
||||||
|
bulkButton.addEventListener('click', () => window.bulkManager?.toggleBulkMode());
|
||||||
|
}
|
||||||
|
|
||||||
|
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
|
||||||
|
if (favoriteFilterBtn) {
|
||||||
|
favoriteFilterBtn.addEventListener('click', () => {
|
||||||
|
this.pageState.showFavoritesOnly = !this.pageState.showFavoritesOnly;
|
||||||
|
favoriteFilterBtn.classList.toggle('active', this.pageState.showFavoritesOnly);
|
||||||
|
refreshVirtualScroll();
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This method is kept for compatibility but now uses virtual scrolling
|
// This method is kept for compatibility but now uses virtual scrolling
|
||||||
|
|||||||
@@ -95,13 +95,15 @@ export const state = {
|
|||||||
currentPage: 1,
|
currentPage: 1,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
hasMore: true,
|
hasMore: true,
|
||||||
sortBy: 'date',
|
sortBy: 'date:desc',
|
||||||
|
activeFolder: getStorageItem('recipes_activeFolder'),
|
||||||
searchManager: null,
|
searchManager: null,
|
||||||
searchOptions: {
|
searchOptions: {
|
||||||
title: true,
|
title: true,
|
||||||
tags: true,
|
tags: true,
|
||||||
loraName: true,
|
loraName: true,
|
||||||
loraModel: true
|
loraModel: true,
|
||||||
|
recursive: getStorageItem('recipes_recursiveSearch', true),
|
||||||
},
|
},
|
||||||
filters: {
|
filters: {
|
||||||
baseModel: [],
|
baseModel: [],
|
||||||
|
|||||||
@@ -783,12 +783,13 @@ export class VirtualScroller {
|
|||||||
deepMerge(target, source) {
|
deepMerge(target, source) {
|
||||||
if (!source || !target) return target;
|
if (!source || !target) return target;
|
||||||
|
|
||||||
|
// Initialize result with a copy of target
|
||||||
const result = { ...target };
|
const result = { ...target };
|
||||||
|
|
||||||
// Only iterate over keys that exist in target
|
if (!source) return result;
|
||||||
Object.keys(target).forEach(key => {
|
|
||||||
// Check if source has this key
|
// Iterate over all keys in the source object
|
||||||
if (source.hasOwnProperty(key)) {
|
Object.keys(source).forEach(key => {
|
||||||
const targetValue = target[key];
|
const targetValue = target[key];
|
||||||
const sourceValue = source[key];
|
const sourceValue = source[key];
|
||||||
|
|
||||||
@@ -801,13 +802,11 @@ export class VirtualScroller {
|
|||||||
typeof sourceValue === 'object' &&
|
typeof sourceValue === 'object' &&
|
||||||
!Array.isArray(sourceValue)
|
!Array.isArray(sourceValue)
|
||||||
) {
|
) {
|
||||||
result[key] = this.deepMerge(targetValue, sourceValue);
|
result[key] = this.deepMerge(targetValue || {}, sourceValue);
|
||||||
} else {
|
} else {
|
||||||
// For primitive types, arrays, or null, use the value from source
|
// Otherwise update with source value (includes primitives, arrays, and new keys)
|
||||||
result[key] = sourceValue;
|
result[key] = sourceValue;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
// If source does not have this key, keep the original value from target
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
|
|||||||
@@ -19,22 +19,27 @@
|
|||||||
{% set current_page = 'loras' %}
|
{% set current_page = 'loras' %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% set search_disabled = current_page == 'statistics' %}
|
{% set search_disabled = current_page == 'statistics' %}
|
||||||
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~ current_page %}
|
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~
|
||||||
|
current_page %}
|
||||||
{% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %}
|
{% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %}
|
||||||
<nav class="main-nav">
|
<nav class="main-nav">
|
||||||
<a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
|
<a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
|
||||||
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
|
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}" id="recipesNavItem">
|
<a href="/loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}"
|
||||||
|
id="recipesNavItem">
|
||||||
<i class="fas fa-book-open"></i> <span>{{ t('header.navigation.recipes') }}</span>
|
<i class="fas fa-book-open"></i> <span>{{ t('header.navigation.recipes') }}</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}" id="checkpointsNavItem">
|
<a href="/checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}"
|
||||||
|
id="checkpointsNavItem">
|
||||||
<i class="fas fa-check-circle"></i> <span>{{ t('header.navigation.checkpoints') }}</span>
|
<i class="fas fa-check-circle"></i> <span>{{ t('header.navigation.checkpoints') }}</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}" id="embeddingsNavItem">
|
<a href="/embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}"
|
||||||
|
id="embeddingsNavItem">
|
||||||
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
|
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
|
||||||
</a>
|
</a>
|
||||||
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}" id="statisticsNavItem">
|
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
|
||||||
|
id="statisticsNavItem">
|
||||||
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
|
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
|
||||||
</a>
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -42,12 +47,15 @@
|
|||||||
<!-- Context-aware search container -->
|
<!-- Context-aware search container -->
|
||||||
<div class="{{ header_search_class }}" id="headerSearch">
|
<div class="{{ header_search_class }}" id="headerSearch">
|
||||||
<div class="search-container">
|
<div class="search-container">
|
||||||
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}"{% if search_disabled %} disabled{% endif %} />
|
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %}
|
||||||
|
disabled{% endif %} />
|
||||||
<i class="fas fa-search search-icon"></i>
|
<i class="fas fa-search search-icon"></i>
|
||||||
<button class="search-options-toggle" id="searchOptionsToggle" title="{{ t('header.search.options') }}"{% if search_disabled %} disabled aria-disabled="true"{% endif %}>
|
<button class="search-options-toggle" id="searchOptionsToggle" title="{{ t('header.search.options') }}" {% if
|
||||||
|
search_disabled %} disabled aria-disabled="true" {% endif %}>
|
||||||
<i class="fas fa-sliders-h"></i>
|
<i class="fas fa-sliders-h"></i>
|
||||||
</button>
|
</button>
|
||||||
<button class="search-filter-toggle" id="filterButton" title="{{ t('header.filter.title') }}"{% if search_disabled %} disabled aria-disabled="true"{% endif %}>
|
<button class="search-filter-toggle" id="filterButton" title="{{ t('header.filter.title') }}" {% if
|
||||||
|
search_disabled %} disabled aria-disabled="true" {% endif %}>
|
||||||
<i class="fas fa-filter"></i>
|
<i class="fas fa-filter"></i>
|
||||||
<span class="filter-badge" id="activeFiltersCount" style="display: none">0</span>
|
<span class="filter-badge" id="activeFiltersCount" style="display: none">0</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -97,6 +105,7 @@
|
|||||||
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
|
<div class="search-option-tag active" data-option="tags">{{ t('header.search.filters.tags') }}</div>
|
||||||
<div class="search-option-tag active" data-option="loraName">{{ t('header.search.filters.loraName') }}</div>
|
<div class="search-option-tag active" data-option="loraName">{{ t('header.search.filters.loraName') }}</div>
|
||||||
<div class="search-option-tag active" data-option="loraModel">{{ t('header.search.filters.loraModel') }}</div>
|
<div class="search-option-tag active" data-option="loraModel">{{ t('header.search.filters.loraModel') }}</div>
|
||||||
|
<div class="search-option-tag active" data-option="prompt">{{ t('header.search.filters.prompt') }}</div>
|
||||||
{% elif request.path == '/checkpoints' %}
|
{% elif request.path == '/checkpoints' %}
|
||||||
<div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div>
|
<div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div>
|
||||||
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
|
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
|
||||||
|
|||||||
@@ -15,17 +15,26 @@
|
|||||||
|
|
||||||
<div id="recipeContextMenu" class="context-menu" style="display: none;">
|
<div id="recipeContextMenu" class="context-menu" style="display: none;">
|
||||||
<!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> -->
|
<!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> -->
|
||||||
<div class="context-menu-item" data-action="share"><i class="fas fa-share-alt"></i> {{ t('loras.contextMenu.shareRecipe') }}</div>
|
<div class="context-menu-item" data-action="share"><i class="fas fa-share-alt"></i> {{
|
||||||
<div class="context-menu-item" data-action="copy"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyRecipeSyntax') }}</div>
|
t('loras.contextMenu.shareRecipe') }}</div>
|
||||||
<div class="context-menu-item" data-action="sendappend"><i class="fas fa-paper-plane"></i> {{ t('loras.contextMenu.sendToWorkflowAppend') }}</div>
|
<div class="context-menu-item" data-action="copy"><i class="fas fa-copy"></i> {{
|
||||||
<div class="context-menu-item" data-action="sendreplace"><i class="fas fa-exchange-alt"></i> {{ t('loras.contextMenu.sendToWorkflowReplace') }}</div>
|
t('loras.contextMenu.copyRecipeSyntax') }}</div>
|
||||||
<div class="context-menu-item" data-action="viewloras"><i class="fas fa-layer-group"></i> {{ t('loras.contextMenu.viewAllLoras') }}</div>
|
<div class="context-menu-item" data-action="sendappend"><i class="fas fa-paper-plane"></i> {{
|
||||||
<div class="context-menu-item download-missing-item" data-action="download-missing"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadMissingLoras') }}</div>
|
t('loras.contextMenu.sendToWorkflowAppend') }}</div>
|
||||||
|
<div class="context-menu-item" data-action="sendreplace"><i class="fas fa-exchange-alt"></i> {{
|
||||||
|
t('loras.contextMenu.sendToWorkflowReplace') }}</div>
|
||||||
|
<div class="context-menu-item" data-action="viewloras"><i class="fas fa-layer-group"></i> {{
|
||||||
|
t('loras.contextMenu.viewAllLoras') }}</div>
|
||||||
|
<div class="context-menu-item download-missing-item" data-action="download-missing"><i class="fas fa-download"></i>
|
||||||
|
{{ t('loras.contextMenu.downloadMissingLoras') }}</div>
|
||||||
<div class="context-menu-item" data-action="set-nsfw">
|
<div class="context-menu-item" data-action="set-nsfw">
|
||||||
<i class="fas fa-exclamation-triangle"></i> {{ t('loras.contextMenu.setContentRating') }}
|
<i class="fas fa-exclamation-triangle"></i> {{ t('loras.contextMenu.setContentRating') }}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="context-menu-item" data-action="move"><i class="fas fa-folder-open"></i> {{
|
||||||
|
t('loras.contextMenu.moveToFolder') }}</div>
|
||||||
<div class="context-menu-separator"></div>
|
<div class="context-menu-separator"></div>
|
||||||
<div class="context-menu-item delete-item" data-action="delete"><i class="fas fa-trash"></i> {{ t('loras.contextMenu.deleteRecipe') }}</div>
|
<div class="context-menu-item delete-item" data-action="delete"><i class="fas fa-trash"></i> {{
|
||||||
|
t('loras.contextMenu.deleteRecipe') }}</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
@@ -37,20 +46,53 @@
|
|||||||
<!-- Recipe controls -->
|
<!-- Recipe controls -->
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
|
<div class="control-group">
|
||||||
|
<select id="sortSelect" title="{{ t('recipes.controls.sort.title') }}">
|
||||||
|
<optgroup label="{{ t('recipes.controls.sort.name') }}">
|
||||||
|
<option value="name:asc">{{ t('recipes.controls.sort.nameAsc') }}</option>
|
||||||
|
<option value="name:desc">{{ t('recipes.controls.sort.nameDesc') }}</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="{{ t('recipes.controls.sort.date') }}">
|
||||||
|
<option value="date:desc">{{ t('recipes.controls.sort.dateDesc') }}</option>
|
||||||
|
<option value="date:asc">{{ t('recipes.controls.sort.dateAsc') }}</option>
|
||||||
|
</optgroup>
|
||||||
|
<optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
|
||||||
|
<option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
|
||||||
|
<option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
|
||||||
|
</optgroup>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
<div title="{{ t('recipes.controls.refresh.title') }}" class="control-group">
|
<div title="{{ t('recipes.controls.refresh.title') }}" class="control-group">
|
||||||
<button onclick="recipeManager.refreshRecipes()"><i class="fas fa-sync"></i> {{ t('common.actions.refresh') }}</button>
|
<button onclick="recipeManager.refreshRecipes()"><i class="fas fa-sync"></i> {{ t('common.actions.refresh')
|
||||||
|
}}</button>
|
||||||
</div>
|
</div>
|
||||||
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
|
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
|
||||||
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{ t('recipes.controls.import.action') }}</button>
|
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{
|
||||||
|
t('recipes.controls.import.action') }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="control-group" title="{{ t('loras.controls.bulk.title') }}">
|
||||||
|
<button id="bulkOperationsBtn" data-action="bulk" title="{{ t('loras.controls.bulk.title') }}">
|
||||||
|
<i class="fas fa-th-large"></i> <span><span>{{ t('loras.controls.bulk.action') }}</span>
|
||||||
|
<div class="shortcut-key">B</div>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Add duplicate detection button -->
|
<!-- Add duplicate detection button -->
|
||||||
<div title="{{ t('loras.controls.duplicates.title') }}" class="control-group">
|
<div title="{{ t('loras.controls.duplicates.title') }}" class="control-group">
|
||||||
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> {{ t('loras.controls.duplicates.action') }}</button>
|
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> {{
|
||||||
|
t('loras.controls.duplicates.action') }}</button>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<button id="favoriteFilterBtn" data-action="toggle-favorites" class="favorite-filter"
|
||||||
|
title="{{ t('recipes.controls.favorites.title') }}">
|
||||||
|
<i class="fas fa-star"></i> <span>{{ t('recipes.controls.favorites.action') }}</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<!-- Custom filter indicator button (hidden by default) -->
|
<!-- Custom filter indicator button (hidden by default) -->
|
||||||
<div id="customFilterIndicator" class="control-group hidden">
|
<div id="customFilterIndicator" class="control-group hidden">
|
||||||
<div class="filter-active">
|
<div class="filter-active">
|
||||||
<i class="fas fa-filter"></i> <span id="customFilterText">{{ t('recipes.controls.filteredByLora') }}</span>
|
<i class="fas fa-filter"></i> <span id="customFilterText">{{ t('recipes.controls.filteredByLora')
|
||||||
|
}}</span>
|
||||||
<i class="fas fa-times-circle clear-filter"></i>
|
<i class="fas fa-times-circle clear-filter"></i>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,6 +118,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% include 'components/folder_sidebar.html' %}
|
||||||
|
|
||||||
<!-- Recipe grid -->
|
<!-- Recipe grid -->
|
||||||
<div class="card-grid" id="recipeGrid">
|
<div class="card-grid" id="recipeGrid">
|
||||||
<!-- Remove the server-side conditional rendering and placeholder -->
|
<!-- Remove the server-side conditional rendering and placeholder -->
|
||||||
@@ -83,6 +127,10 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block overlay %}
|
||||||
|
<div class="bulk-mode-overlay"></div>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block main_script %}
|
{% block main_script %}
|
||||||
<script type="module" src="/loras_static/js/recipes.js?v={{ version }}"></script>
|
<script type="module" src="/loras_static/js/recipes.js?v={{ version }}"></script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
114
tests/frontend/api/recipeApi.bulk.test.js
Normal file
114
tests/frontend/api/recipeApi.bulk.test.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const showToastMock = vi.hoisted(() => vi.fn());
|
||||||
|
const loadingManagerMock = vi.hoisted(() => ({
|
||||||
|
showSimpleLoading: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => {
|
||||||
|
return {
|
||||||
|
showToast: showToastMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
|
||||||
|
RecipeCard: vi.fn(() => ({ element: document.createElement('div') })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/state/index.js', () => {
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
loadingManager: loadingManagerMock,
|
||||||
|
},
|
||||||
|
getCurrentPageState: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { RecipeSidebarApiClient } from '../../../static/js/api/recipeApi.js';
|
||||||
|
|
||||||
|
describe('RecipeSidebarApiClient bulk operations', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends recipe IDs when moving in bulk', async () => {
|
||||||
|
const api = new RecipeSidebarApiClient();
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
recipe_id: 'abc',
|
||||||
|
original_file_path: '/recipes/abc.webp',
|
||||||
|
new_file_path: '/recipes/target/abc.webp',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
success_count: 1,
|
||||||
|
failure_count: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await api.moveBulkModels(['/recipes/abc.webp'], '/target/folder');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/lm/recipes/move-bulk',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { body } = global.fetch.mock.calls[0][1];
|
||||||
|
expect(JSON.parse(body)).toEqual({
|
||||||
|
recipe_ids: ['abc'],
|
||||||
|
target_path: '/target/folder',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.api.bulkMoveSuccess',
|
||||||
|
{ successCount: 1, type: 'Recipe' },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
expect(results[0].recipe_id).toBe('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts recipe IDs for bulk delete', async () => {
|
||||||
|
const api = new RecipeSidebarApiClient();
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
total_deleted: 2,
|
||||||
|
total_failed: 0,
|
||||||
|
failed: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await api.bulkDeleteModels(['/recipes/a.webp', '/recipes/b.webp']);
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/lm/recipes/bulk-delete',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsedBody = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||||
|
expect(parsedBody.recipe_ids).toEqual(['a', 'b']);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
deleted_count: 2,
|
||||||
|
failed_count: 0,
|
||||||
|
});
|
||||||
|
expect(loadingManagerMock.hide).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -252,13 +252,13 @@ describe('AppCore initialization flow', () => {
|
|||||||
expect(onboardingManager.start).not.toHaveBeenCalled();
|
expect(onboardingManager.start).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips bulk setup when viewing recipes', async () => {
|
it('initializes bulk setup when viewing recipes', async () => {
|
||||||
state.currentPageType = 'recipes';
|
state.currentPageType = 'recipes';
|
||||||
|
|
||||||
await appCore.initialize();
|
await appCore.initialize();
|
||||||
|
|
||||||
expect(bulkManager.initialize).not.toHaveBeenCalled();
|
expect(bulkManager.initialize).toHaveBeenCalledTimes(1);
|
||||||
expect(BulkContextMenu).not.toHaveBeenCalled();
|
expect(BulkContextMenu).toHaveBeenCalledTimes(1);
|
||||||
expect(bulkManager.setBulkContextMenu).not.toHaveBeenCalled();
|
expect(bulkManager.setBulkContextMenu).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const removeSessionItemMock = vi.fn();
|
|||||||
const RecipeContextMenuMock = vi.fn();
|
const RecipeContextMenuMock = vi.fn();
|
||||||
const refreshVirtualScrollMock = vi.fn();
|
const refreshVirtualScrollMock = vi.fn();
|
||||||
const refreshRecipesMock = vi.fn();
|
const refreshRecipesMock = vi.fn();
|
||||||
|
const fetchUnifiedFolderTreeMock = vi.fn();
|
||||||
|
const fetchModelFoldersMock = vi.fn();
|
||||||
|
|
||||||
let importManagerInstance;
|
let importManagerInstance;
|
||||||
let recipeModalInstance;
|
let recipeModalInstance;
|
||||||
@@ -35,6 +37,15 @@ vi.mock('../../../static/js/components/RecipeModal.js', () => ({
|
|||||||
|
|
||||||
vi.mock('../../../static/js/state/index.js', () => ({
|
vi.mock('../../../static/js/state/index.js', () => ({
|
||||||
getCurrentPageState: getCurrentPageStateMock,
|
getCurrentPageState: getCurrentPageStateMock,
|
||||||
|
state: {
|
||||||
|
currentPageType: 'recipes',
|
||||||
|
global: { settings: {} },
|
||||||
|
virtualScroller: {
|
||||||
|
removeItemByFilePath: vi.fn(),
|
||||||
|
updateSingleItem: vi.fn(),
|
||||||
|
refreshWithData: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||||
@@ -56,6 +67,14 @@ vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
|||||||
|
|
||||||
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||||
refreshRecipes: refreshRecipesMock,
|
refreshRecipes: refreshRecipesMock,
|
||||||
|
RecipeSidebarApiClient: vi.fn(() => ({
|
||||||
|
apiConfig: { config: { displayName: 'Recipes', supportsMove: true } },
|
||||||
|
fetchUnifiedFolderTree: fetchUnifiedFolderTreeMock.mockResolvedValue({ success: true, tree: {} }),
|
||||||
|
fetchModelFolders: fetchModelFoldersMock.mockResolvedValue({ success: true, folders: [] }),
|
||||||
|
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/recipes'] }),
|
||||||
|
moveBulkModels: vi.fn(),
|
||||||
|
moveSingleModel: vi.fn(),
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('RecipeManager', () => {
|
describe('RecipeManager', () => {
|
||||||
@@ -81,7 +100,7 @@ describe('RecipeManager', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
pageState = {
|
pageState = {
|
||||||
sortBy: 'date',
|
sortBy: 'date:desc',
|
||||||
searchOptions: undefined,
|
searchOptions: undefined,
|
||||||
customFilter: undefined,
|
customFilter: undefined,
|
||||||
duplicatesMode: false,
|
duplicatesMode: false,
|
||||||
@@ -118,8 +137,8 @@ describe('RecipeManager', () => {
|
|||||||
const sortSelectElement = document.createElement('select');
|
const sortSelectElement = document.createElement('select');
|
||||||
sortSelectElement.id = 'sortSelect';
|
sortSelectElement.id = 'sortSelect';
|
||||||
sortSelectElement.innerHTML = `
|
sortSelectElement.innerHTML = `
|
||||||
<option value="date">Date</option>
|
<option value="date:desc">Newest</option>
|
||||||
<option value="name">Name</option>
|
<option value="name:asc">Name A-Z</option>
|
||||||
`;
|
`;
|
||||||
document.body.appendChild(sortSelectElement);
|
document.body.appendChild(sortSelectElement);
|
||||||
|
|
||||||
@@ -139,6 +158,8 @@ describe('RecipeManager', () => {
|
|||||||
tags: true,
|
tags: true,
|
||||||
loraName: true,
|
loraName: true,
|
||||||
loraModel: true,
|
loraModel: true,
|
||||||
|
prompt: true,
|
||||||
|
recursive: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(pageState.customFilter).toEqual({
|
expect(pageState.customFilter).toEqual({
|
||||||
@@ -162,10 +183,10 @@ describe('RecipeManager', () => {
|
|||||||
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1);
|
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
const sortSelect = document.getElementById('sortSelect');
|
const sortSelect = document.getElementById('sortSelect');
|
||||||
sortSelect.value = 'name';
|
sortSelect.value = 'name:asc';
|
||||||
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
|
||||||
expect(pageState.sortBy).toBe('name');
|
expect(pageState.sortBy).toBe('name:asc');
|
||||||
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(2);
|
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(2);
|
||||||
expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1);
|
expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ class StubRecipeScanner:
|
|||||||
async def get_recipe_by_id(self, recipe_id: str) -> Optional[Dict[str, Any]]:
|
async def get_recipe_by_id(self, recipe_id: str) -> Optional[Dict[str, Any]]:
|
||||||
return self.recipes.get(recipe_id)
|
return self.recipes.get(recipe_id)
|
||||||
|
|
||||||
|
async def get_recipe_json_path(self, recipe_id: str) -> Optional[str]:
|
||||||
|
candidate = Path(self.recipes_dir) / f"{recipe_id}.recipe.json"
|
||||||
|
return str(candidate) if candidate.exists() else None
|
||||||
|
|
||||||
async def remove_recipe(self, recipe_id: str) -> None:
|
async def remove_recipe(self, recipe_id: str) -> None:
|
||||||
self.removed.append(recipe_id)
|
self.removed.append(recipe_id)
|
||||||
self.recipes.pop(recipe_id, None)
|
self.recipes.pop(recipe_id, None)
|
||||||
@@ -87,6 +91,7 @@ class StubAnalysisService:
|
|||||||
self.remote_calls: List[Optional[str]] = []
|
self.remote_calls: List[Optional[str]] = []
|
||||||
self.local_calls: List[Optional[str]] = []
|
self.local_calls: List[Optional[str]] = []
|
||||||
self.result = SimpleNamespace(payload={"loras": []}, status=200)
|
self.result = SimpleNamespace(payload={"loras": []}, status=200)
|
||||||
|
self._recipe_parser_factory = None
|
||||||
StubAnalysisService.instances.append(self)
|
StubAnalysisService.instances.append(self)
|
||||||
|
|
||||||
async def analyze_uploaded_image(self, *, image_bytes: bytes | None, recipe_scanner) -> SimpleNamespace: # noqa: D401 - mirrors real signature
|
async def analyze_uploaded_image(self, *, image_bytes: bytes | None, recipe_scanner) -> SimpleNamespace: # noqa: D401 - mirrors real signature
|
||||||
@@ -119,6 +124,7 @@ class StubPersistenceService:
|
|||||||
def __init__(self, **_: Any) -> None:
|
def __init__(self, **_: Any) -> None:
|
||||||
self.save_calls: List[Dict[str, Any]] = []
|
self.save_calls: List[Dict[str, Any]] = []
|
||||||
self.delete_calls: List[str] = []
|
self.delete_calls: List[str] = []
|
||||||
|
self.move_calls: List[Dict[str, str]] = []
|
||||||
self.save_result = SimpleNamespace(payload={"success": True, "recipe_id": "stub-id"}, status=200)
|
self.save_result = SimpleNamespace(payload={"success": True, "recipe_id": "stub-id"}, status=200)
|
||||||
self.delete_result = SimpleNamespace(payload={"success": True}, status=200)
|
self.delete_result = SimpleNamespace(payload={"success": True}, status=200)
|
||||||
StubPersistenceService.instances.append(self)
|
StubPersistenceService.instances.append(self)
|
||||||
@@ -142,6 +148,12 @@ class StubPersistenceService:
|
|||||||
await recipe_scanner.remove_recipe(recipe_id)
|
await recipe_scanner.remove_recipe(recipe_id)
|
||||||
return self.delete_result
|
return self.delete_result
|
||||||
|
|
||||||
|
async def move_recipe(self, *, recipe_scanner, recipe_id: str, target_path: str) -> SimpleNamespace: # noqa: D401
|
||||||
|
self.move_calls.append({"recipe_id": recipe_id, "target_path": target_path})
|
||||||
|
return SimpleNamespace(
|
||||||
|
payload={"success": True, "recipe_id": recipe_id, "new_file_path": target_path}, status=200
|
||||||
|
)
|
||||||
|
|
||||||
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: Dict[str, Any]) -> SimpleNamespace: # pragma: no cover - unused by smoke tests
|
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: Dict[str, Any]) -> SimpleNamespace: # pragma: no cover - unused by smoke tests
|
||||||
return SimpleNamespace(payload={"success": True, "recipe_id": recipe_id, "updates": updates}, status=200)
|
return SimpleNamespace(payload={"success": True, "recipe_id": recipe_id, "updates": updates}, status=200)
|
||||||
|
|
||||||
@@ -310,6 +322,21 @@ async def test_save_and_delete_recipe_round_trip(monkeypatch, tmp_path: Path) ->
|
|||||||
assert harness.persistence.delete_calls == ["saved-id"]
|
assert harness.persistence.delete_calls == ["saved-id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_move_recipe_invokes_persistence(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
response = await harness.client.post(
|
||||||
|
"/api/lm/recipe/move",
|
||||||
|
json={"recipe_id": "move-me", "target_path": str(tmp_path / "recipes" / "subdir")},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await response.json()
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["recipe_id"] == "move-me"
|
||||||
|
assert harness.persistence.move_calls == [
|
||||||
|
{"recipe_id": "move-me", "target_path": str(tmp_path / "recipes" / "subdir")}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_import_remote_recipe(monkeypatch, tmp_path: Path) -> None:
|
async def test_import_remote_recipe(monkeypatch, tmp_path: Path) -> None:
|
||||||
provider_calls: list[int] = []
|
provider_calls: list[int] = []
|
||||||
|
|
||||||
@@ -501,3 +528,69 @@ async def test_share_and_download_recipe(monkeypatch, tmp_path: Path) -> None:
|
|||||||
assert body == b"stub"
|
assert body == b"stub"
|
||||||
|
|
||||||
download_path.unlink(missing_ok=True)
|
download_path.unlink(missing_ok=True)
|
||||||
|
async def test_import_remote_recipe_merges_metadata(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
# 1. Mock Metadata Provider
|
||||||
|
class Provider:
|
||||||
|
async def get_model_version_info(self, model_version_id):
|
||||||
|
return {"baseModel": "Flux Provider"}, None
|
||||||
|
|
||||||
|
async def fake_get_default_metadata_provider():
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(recipe_handlers, "get_default_metadata_provider", fake_get_default_metadata_provider)
|
||||||
|
|
||||||
|
# 2. Mock ExifUtils to return some embedded metadata
|
||||||
|
class MockExifUtils:
|
||||||
|
@staticmethod
|
||||||
|
def extract_image_metadata(path):
|
||||||
|
return "Recipe metadata: " + json.dumps({
|
||||||
|
"gen_params": {"prompt": "from embedded", "seed": 123}
|
||||||
|
})
|
||||||
|
|
||||||
|
monkeypatch.setattr(recipe_handlers, "ExifUtils", MockExifUtils)
|
||||||
|
|
||||||
|
# 3. Mock Parser Factory for StubAnalysisService
|
||||||
|
class MockParser:
|
||||||
|
async def parse_metadata(self, raw, recipe_scanner=None):
|
||||||
|
return json.loads(raw[len("Recipe metadata: "):])
|
||||||
|
|
||||||
|
class MockFactory:
|
||||||
|
def create_parser(self, raw):
|
||||||
|
if raw.startswith("Recipe metadata: "):
|
||||||
|
return MockParser()
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 4. Setup Harness and run test
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
harness.analysis._recipe_parser_factory = MockFactory()
|
||||||
|
|
||||||
|
# Civitai meta via image_info
|
||||||
|
harness.civitai.image_info["1"] = {
|
||||||
|
"id": 1,
|
||||||
|
"url": "https://example.com/images/1.jpg",
|
||||||
|
"meta": {"prompt": "from civitai", "cfg": 7.0}
|
||||||
|
}
|
||||||
|
|
||||||
|
resources = []
|
||||||
|
response = await harness.client.get(
|
||||||
|
"/api/lm/recipes/import-remote",
|
||||||
|
params={
|
||||||
|
"image_url": "https://civitai.com/images/1",
|
||||||
|
"name": "Merged Recipe",
|
||||||
|
"resources": json.dumps(resources),
|
||||||
|
"gen_params": json.dumps({"prompt": "from request", "steps": 25}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await response.json()
|
||||||
|
assert response.status == 200
|
||||||
|
|
||||||
|
call = harness.persistence.save_calls[-1]
|
||||||
|
metadata = call["metadata"]
|
||||||
|
gen_params = metadata["gen_params"]
|
||||||
|
|
||||||
|
# Priority: request (prompt=request, steps=25) > civitai (prompt=civitai, cfg=7.0) > embedded (prompt=embedded, seed=123)
|
||||||
|
assert gen_params["prompt"] == "from request"
|
||||||
|
assert gen_params["steps"] == 25
|
||||||
|
assert gen_params["cfg"] == 7.0
|
||||||
|
assert gen_params["seed"] == 123
|
||||||
|
|||||||
113
tests/services/test_comfy_metadata_parser.py
Normal file
113
tests/services/test_comfy_metadata_parser.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
from py.recipes.parsers.comfy import ComfyMetadataParser
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_without_loras(monkeypatch):
|
||||||
|
checkpoint_info = {
|
||||||
|
"id": 2224012,
|
||||||
|
"modelId": 1908679,
|
||||||
|
"model": {"name": "SDXL Checkpoint", "type": "checkpoint"},
|
||||||
|
"name": "v1.0",
|
||||||
|
"images": [{"url": "https://image.civitai.com/checkpoints/original=true"}],
|
||||||
|
"baseModel": "sdxl",
|
||||||
|
"downloadUrl": "https://civitai.com/api/download/checkpoint",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_version_info(self, version_id):
|
||||||
|
assert version_id == "2224012"
|
||||||
|
return checkpoint_info, None
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.comfy.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = ComfyMetadataParser()
|
||||||
|
|
||||||
|
# User provided metadata
|
||||||
|
metadata_json = {
|
||||||
|
"resource-stack": {
|
||||||
|
"class_type": "CheckpointLoaderSimple",
|
||||||
|
"inputs": {"ckpt_name": "urn:air:sdxl:checkpoint:civitai:1908679@2224012"}
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"class_type": "smZ CLIPTextEncode",
|
||||||
|
"inputs": {"text": "Positive prompt content"},
|
||||||
|
"_meta": {"title": "Positive"}
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"class_type": "smZ CLIPTextEncode",
|
||||||
|
"inputs": {"text": "Negative prompt content"},
|
||||||
|
"_meta": {"title": "Negative"}
|
||||||
|
},
|
||||||
|
"11": {
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"inputs": {
|
||||||
|
"sampler_name": "euler_ancestral",
|
||||||
|
"scheduler": "normal",
|
||||||
|
"seed": 904124997,
|
||||||
|
"steps": 35,
|
||||||
|
"cfg": 6,
|
||||||
|
"denoise": 0.1,
|
||||||
|
"model": ["resource-stack", 0],
|
||||||
|
"positive": ["6", 0],
|
||||||
|
"negative": ["7", 0],
|
||||||
|
"latent_image": ["21", 0]
|
||||||
|
},
|
||||||
|
"_meta": {"title": "KSampler"}
|
||||||
|
},
|
||||||
|
"extraMetadata": json.dumps({
|
||||||
|
"prompt": "One woman, (solo:1.3), ...",
|
||||||
|
"negativePrompt": "lowres, worst quality, ...",
|
||||||
|
"steps": 35,
|
||||||
|
"cfgScale": 6,
|
||||||
|
"sampler": "euler_ancestral",
|
||||||
|
"seed": 904124997,
|
||||||
|
"width": 1024,
|
||||||
|
"height": 1024
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await parser.parse_metadata(json.dumps(metadata_json))
|
||||||
|
|
||||||
|
assert "error" not in result
|
||||||
|
assert result["loras"] == []
|
||||||
|
assert result["checkpoint"] is not None
|
||||||
|
assert int(result["checkpoint"]["modelId"]) == 1908679
|
||||||
|
assert int(result["checkpoint"]["id"]) == 2224012
|
||||||
|
assert result["gen_params"]["prompt"] == "One woman, (solo:1.3), ..."
|
||||||
|
assert result["gen_params"]["steps"] == 35
|
||||||
|
assert result["gen_params"]["size"] == "1024x1024"
|
||||||
|
assert result["from_comfy_metadata"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_without_extra_metadata(monkeypatch):
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_version_info(self, version_id):
|
||||||
|
return {"model": {"name": "Test"}, "id": version_id}, None
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.comfy.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = ComfyMetadataParser()
|
||||||
|
|
||||||
|
metadata_json = {
|
||||||
|
"node_1": {
|
||||||
|
"class_type": "CheckpointLoaderSimple",
|
||||||
|
"inputs": {"ckpt_name": "urn:air:sdxl:checkpoint:civitai:123@456"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await parser.parse_metadata(json.dumps(metadata_json))
|
||||||
|
|
||||||
|
assert "error" not in result
|
||||||
|
assert result["loras"] == []
|
||||||
|
assert result["checkpoint"]["id"] == "456"
|
||||||
59
tests/services/test_gen_params_merger.py
Normal file
59
tests/services/test_gen_params_merger.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import pytest
|
||||||
|
from py.recipes.merger import GenParamsMerger
|
||||||
|
|
||||||
|
def test_merge_priority():
|
||||||
|
request_params = {"prompt": "from request", "steps": 20}
|
||||||
|
civitai_meta = {"prompt": "from civitai", "cfg": 7.0}
|
||||||
|
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
||||||
|
|
||||||
|
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
|
||||||
|
|
||||||
|
assert merged["prompt"] == "from request"
|
||||||
|
assert merged["steps"] == 20
|
||||||
|
assert merged["cfg"] == 7.0
|
||||||
|
assert merged["seed"] == 123
|
||||||
|
|
||||||
|
def test_merge_no_request_params():
|
||||||
|
civitai_meta = {"prompt": "from civitai", "cfg": 7.0}
|
||||||
|
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
||||||
|
|
||||||
|
merged = GenParamsMerger.merge(None, civitai_meta, embedded_metadata)
|
||||||
|
|
||||||
|
assert merged["prompt"] == "from civitai"
|
||||||
|
assert merged["cfg"] == 7.0
|
||||||
|
assert merged["seed"] == 123
|
||||||
|
|
||||||
|
def test_merge_only_embedded():
|
||||||
|
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
||||||
|
|
||||||
|
merged = GenParamsMerger.merge(None, None, embedded_metadata)
|
||||||
|
|
||||||
|
assert merged["prompt"] == "from embedded"
|
||||||
|
assert merged["seed"] == 123
|
||||||
|
|
||||||
|
def test_merge_raw_embedded():
|
||||||
|
# Test when embedded metadata is just the gen_params themselves
|
||||||
|
embedded_metadata = {"prompt": "from raw embedded", "seed": 456}
|
||||||
|
|
||||||
|
merged = GenParamsMerger.merge(None, None, embedded_metadata)
|
||||||
|
|
||||||
|
assert merged["prompt"] == "from raw embedded"
|
||||||
|
assert merged["seed"] == 456
|
||||||
|
|
||||||
|
def test_merge_none_values():
|
||||||
|
merged = GenParamsMerger.merge(None, None, None)
|
||||||
|
assert merged == {}
|
||||||
|
|
||||||
|
def test_merge_filters_blacklisted_keys():
|
||||||
|
request_params = {"prompt": "test", "id": "should-be-removed"}
|
||||||
|
civitai_meta = {"cfg": 7, "url": "remove-me"}
|
||||||
|
embedded_metadata = {"seed": 123, "hash": "remove-also"}
|
||||||
|
|
||||||
|
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
|
||||||
|
|
||||||
|
assert "prompt" in merged
|
||||||
|
assert "cfg" in merged
|
||||||
|
assert "seed" in merged
|
||||||
|
assert "id" not in merged
|
||||||
|
assert "url" not in merged
|
||||||
|
assert "hash" not in merged
|
||||||
@@ -502,7 +502,142 @@ async def test_update_lora_filename_by_hash_updates_affected_recipes(tmp_path: P
|
|||||||
persisted2 = json.loads(recipe2_path.read_text())
|
persisted2 = json.loads(recipe2_path.read_text())
|
||||||
assert persisted2["loras"][0]["file_name"] == "other_lora"
|
assert persisted2["loras"][0]["file_name"] == "other_lora"
|
||||||
|
|
||||||
# Check cache
|
|
||||||
cache = await scanner.get_cached_data()
|
cache = await scanner.get_cached_data()
|
||||||
cached1 = next(r for r in cache.raw_data if r["id"] == recipe1_id)
|
cached1 = next(r for r in cache.raw_data if r["id"] == recipe1_id)
|
||||||
assert cached1["loras"][0]["file_name"] == new_name
|
assert cached1["loras"][0]["file_name"] == new_name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_paginated_data_filters_by_favorite(recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
|
||||||
|
# Add a normal recipe
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "regular",
|
||||||
|
"file_path": "path/regular.png",
|
||||||
|
"title": "Regular Recipe",
|
||||||
|
"modified": 1.0,
|
||||||
|
"created_date": 1.0,
|
||||||
|
"loras": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add a favorite recipe
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "favorite",
|
||||||
|
"file_path": "path/favorite.png",
|
||||||
|
"title": "Favorite Recipe",
|
||||||
|
"modified": 2.0,
|
||||||
|
"created_date": 2.0,
|
||||||
|
"loras": [],
|
||||||
|
"favorite": True
|
||||||
|
})
|
||||||
|
|
||||||
|
# Wait for cache update (it's async in some places, add_recipe is usually enough but let's be safe)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
# Test without filter (should return both)
|
||||||
|
result_all = await scanner.get_paginated_data(page=1, page_size=10)
|
||||||
|
assert len(result_all["items"]) == 2
|
||||||
|
|
||||||
|
# Test with favorite filter
|
||||||
|
result_fav = await scanner.get_paginated_data(page=1, page_size=10, filters={"favorite": True})
|
||||||
|
assert len(result_fav["items"]) == 1
|
||||||
|
assert result_fav["items"][0]["id"] == "favorite"
|
||||||
|
|
||||||
|
# Test with favorite filter set to False (should return both or at least not filter if it's the default)
|
||||||
|
# Actually our implementation checks if 'favorite' in filters and filters['favorite']
|
||||||
|
result_fav_false = await scanner.get_paginated_data(page=1, page_size=10, filters={"favorite": False})
|
||||||
|
assert len(result_fav_false["items"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_paginated_data_filters_by_prompt(recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
|
||||||
|
# Add a recipe with a specific prompt
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "prompt-recipe",
|
||||||
|
"file_path": "path/prompt.png",
|
||||||
|
"title": "Prompt Recipe",
|
||||||
|
"modified": 1.0,
|
||||||
|
"created_date": 1.0,
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {
|
||||||
|
"prompt": "a beautiful forest landscape"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add a recipe with a specific negative prompt
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "neg-prompt-recipe",
|
||||||
|
"file_path": "path/neg.png",
|
||||||
|
"title": "Negative Prompt Recipe",
|
||||||
|
"modified": 2.0,
|
||||||
|
"created_date": 2.0,
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {
|
||||||
|
"negative_prompt": "ugly, blurry mountains"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
# Test search in prompt
|
||||||
|
result_prompt = await scanner.get_paginated_data(
|
||||||
|
page=1, page_size=10, search="forest", search_options={"prompt": True}
|
||||||
|
)
|
||||||
|
assert len(result_prompt["items"]) == 1
|
||||||
|
assert result_prompt["items"][0]["id"] == "prompt-recipe"
|
||||||
|
|
||||||
|
# Test search in negative prompt
|
||||||
|
result_neg = await scanner.get_paginated_data(
|
||||||
|
page=1, page_size=10, search="mountains", search_options={"prompt": True}
|
||||||
|
)
|
||||||
|
assert len(result_neg["items"]) == 1
|
||||||
|
assert result_neg["items"][0]["id"] == "neg-prompt-recipe"
|
||||||
|
|
||||||
|
# Test search disabled (should not find by prompt)
|
||||||
|
result_disabled = await scanner.get_paginated_data(
|
||||||
|
page=1, page_size=10, search="forest", search_options={"prompt": False}
|
||||||
|
)
|
||||||
|
assert len(result_disabled["items"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_paginated_data_sorting(recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
|
||||||
|
# Add test recipes
|
||||||
|
# Recipe A: Name "Alpha", Date 10, LoRAs 2
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "A", "title": "Alpha", "created_date": 10.0,
|
||||||
|
"loras": [{}, {}], "file_path": "a.png"
|
||||||
|
})
|
||||||
|
# Recipe B: Name "Beta", Date 20, LoRAs 1
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "B", "title": "Beta", "created_date": 20.0,
|
||||||
|
"loras": [{}], "file_path": "b.png"
|
||||||
|
})
|
||||||
|
# Recipe C: Name "Gamma", Date 5, LoRAs 3
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "C", "title": "Gamma", "created_date": 5.0,
|
||||||
|
"loras": [{}, {}, {}], "file_path": "c.png"
|
||||||
|
})
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
# Test Name DESC: Gamma, Beta, Alpha
|
||||||
|
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="name:desc")
|
||||||
|
assert [i["id"] for i in res["items"]] == ["C", "B", "A"]
|
||||||
|
|
||||||
|
# Test LoRA Count DESC: Gamma (3), Alpha (2), Beta (1)
|
||||||
|
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="loras_count:desc")
|
||||||
|
assert [i["id"] for i in res["items"]] == ["C", "A", "B"]
|
||||||
|
|
||||||
|
# Test LoRA Count ASC: Beta (1), Alpha (2), Gamma (3)
|
||||||
|
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="loras_count:asc")
|
||||||
|
assert [i["id"] for i in res["items"]] == ["B", "A", "C"]
|
||||||
|
|
||||||
|
# Test Date ASC: Gamma (5), Alpha (10), Beta (20)
|
||||||
|
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="date:asc")
|
||||||
|
assert [i["id"] for i in res["items"]] == ["C", "A", "B"]
|
||||||
|
|||||||
@@ -403,6 +403,89 @@ async def test_save_recipe_from_widget_allows_empty_lora(tmp_path):
|
|||||||
assert scanner.added and scanner.added[0]["loras"] == []
|
assert scanner.added and scanner.added[0]["loras"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_move_recipe_updates_paths(tmp_path):
|
||||||
|
exif_utils = DummyExifUtils()
|
||||||
|
recipes_dir = tmp_path / "recipes"
|
||||||
|
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
recipe_id = "move-me"
|
||||||
|
image_path = recipes_dir / f"{recipe_id}.webp"
|
||||||
|
json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
|
||||||
|
image_path.write_bytes(b"img")
|
||||||
|
json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": str(image_path),
|
||||||
|
"title": "Recipe",
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {},
|
||||||
|
"created_date": 0,
|
||||||
|
"modified": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
class MoveScanner:
|
||||||
|
def __init__(self, root: Path):
|
||||||
|
self.recipes_dir = str(root)
|
||||||
|
self.recipe = {
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": str(image_path),
|
||||||
|
"title": "Recipe",
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {},
|
||||||
|
"created_date": 0,
|
||||||
|
"modified": 0,
|
||||||
|
"folder": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_recipe_by_id(self, target_id: str):
|
||||||
|
return self.recipe if target_id == recipe_id else None
|
||||||
|
|
||||||
|
async def get_recipe_json_path(self, target_id: str):
|
||||||
|
matches = list(Path(self.recipes_dir).rglob(f"{target_id}.recipe.json"))
|
||||||
|
return str(matches[0]) if matches else None
|
||||||
|
|
||||||
|
async def update_recipe_metadata(self, target_id: str, metadata: dict):
|
||||||
|
if target_id != recipe_id:
|
||||||
|
return False
|
||||||
|
self.recipe.update(metadata)
|
||||||
|
target_path = await self.get_recipe_json_path(target_id)
|
||||||
|
if not target_path:
|
||||||
|
return False
|
||||||
|
existing = json.loads(Path(target_path).read_text())
|
||||||
|
existing.update(metadata)
|
||||||
|
Path(target_path).write_text(json.dumps(existing))
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_cached_data(self, force_refresh: bool = False): # noqa: ARG002 - signature parity
|
||||||
|
return SimpleNamespace(raw_data=[self.recipe])
|
||||||
|
|
||||||
|
scanner = MoveScanner(recipes_dir)
|
||||||
|
service = RecipePersistenceService(
|
||||||
|
exif_utils=exif_utils,
|
||||||
|
card_preview_width=512,
|
||||||
|
logger=logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
target_folder = recipes_dir / "nested"
|
||||||
|
result = await service.move_recipe(
|
||||||
|
recipe_scanner=scanner, recipe_id=recipe_id, target_path=str(target_folder)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.payload["folder"] == "nested"
|
||||||
|
assert Path(result.payload["json_path"]).parent == target_folder
|
||||||
|
assert Path(result.payload["new_file_path"]).parent == target_folder
|
||||||
|
assert not json_path.exists()
|
||||||
|
|
||||||
|
stored = json.loads(Path(result.payload["json_path"]).read_text())
|
||||||
|
assert stored["folder"] == "nested"
|
||||||
|
assert stored["file_path"] == result.payload["new_file_path"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_analyze_remote_video(tmp_path):
|
async def test_analyze_remote_video(tmp_path):
|
||||||
exif_utils = DummyExifUtils()
|
exif_utils = DummyExifUtils()
|
||||||
|
|||||||
Reference in New Issue
Block a user