Merge pull request #735 from willmiao/recipe-folder

Recipe folder
This commit is contained in:
pixelpaws
2025-12-23 18:21:48 +08:00
committed by GitHub
38 changed files with 2384 additions and 566 deletions

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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": {

View File

@@ -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
View 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}

View File

@@ -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']:

View File

@@ -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:

View File

@@ -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"),

View File

@@ -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"""

View File

@@ -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:

View File

@@ -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

View File

@@ -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."""

View File

@@ -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();
}
}
}

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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();

View File

@@ -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,

View File

@@ -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) {

View File

@@ -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

View File

@@ -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: [],

View File

@@ -12,7 +12,7 @@ export class VirtualScroller {
this.scrollContainer = options.scrollContainer || this.containerElement; this.scrollContainer = options.scrollContainer || this.containerElement;
this.batchSize = options.batchSize || 50; this.batchSize = options.batchSize || 50;
this.pageSize = options.pageSize || 100; this.pageSize = options.pageSize || 100;
this.itemAspectRatio = 896/1152; // Aspect ratio of cards this.itemAspectRatio = 896 / 1152; // Aspect ratio of cards
this.rowGap = options.rowGap || 20; // Add vertical gap between rows (default 20px) this.rowGap = options.rowGap || 20; // Add vertical gap between rows (default 20px)
// Add container padding properties // Add container padding properties
@@ -716,7 +716,7 @@ export class VirtualScroller {
// Utility method for debouncing // Utility method for debouncing
debounce(func, wait) { debounce(func, wait) {
let timeout; let timeout;
return function(...args) { return function (...args) {
const context = this; const context = this;
clearTimeout(timeout); clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait); timeout = setTimeout(() => func.apply(context, args), wait);
@@ -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;

View File

@@ -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>

View File

@@ -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 %}
@@ -34,31 +43,64 @@
{% block init_check_url %}/api/recipes?page=1&page_size=1{% endblock %} {% block init_check_url %}/api/recipes?page=1&page_size=1{% endblock %}
{% block content %} {% block content %}
<!-- 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>
</div> </div>
</div> </div>
<!-- Duplicates banner (hidden by default) --> <!-- Duplicates banner (hidden by default) -->
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;"> <div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
<div class="banner-content"> <div class="banner-content">
<i class="fas fa-exclamation-triangle"></i> <i class="fas fa-exclamation-triangle"></i>
<span id="duplicatesCount">{{ t('recipes.duplicates.found', count=0) }}</span> <span id="duplicatesCount">{{ t('recipes.duplicates.found', count=0) }}</span>
@@ -74,13 +116,19 @@
</button> </button>
</div> </div>
</div> </div>
</div> </div>
<!-- Recipe grid --> {% include 'components/folder_sidebar.html' %}
<div class="card-grid" id="recipeGrid">
<!-- Recipe grid -->
<div class="card-grid" id="recipeGrid">
<!-- Remove the server-side conditional rendering and placeholder --> <!-- Remove the server-side conditional rendering and placeholder -->
<!-- Virtual scrolling will handle the display logic on the client side --> <!-- Virtual scrolling will handle the display logic on the client side -->
</div> </div>
{% endblock %}
{% block overlay %}
<div class="bulk-mode-overlay"></div>
{% endblock %} {% endblock %}
{% block main_script %} {% block main_script %}

View 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();
});
});

View File

@@ -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);
}); });
}); });

View File

@@ -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,
@@ -91,7 +110,7 @@ describe('RecipeManager', () => {
initializeAppMock.mockResolvedValue(undefined); initializeAppMock.mockResolvedValue(undefined);
initializePageFeaturesMock.mockResolvedValue(undefined); initializePageFeaturesMock.mockResolvedValue(undefined);
refreshVirtualScrollMock.mockReset(); refreshVirtualScrollMock.mockReset();
refreshVirtualScrollMock.mockImplementation(() => {}); refreshVirtualScrollMock.mockImplementation(() => { });
refreshRecipesMock.mockResolvedValue('refreshed'); refreshRecipesMock.mockResolvedValue('refreshed');
getSessionItemMock.mockImplementation((key) => { getSessionItemMock.mockImplementation((key) => {
@@ -102,7 +121,7 @@ describe('RecipeManager', () => {
}; };
return map[key] ?? null; return map[key] ?? null;
}); });
removeSessionItemMock.mockImplementation(() => {}); removeSessionItemMock.mockImplementation(() => { });
renderRecipesPage(); renderRecipesPage();
@@ -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);
}); });

View File

@@ -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

View 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"

View 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

View File

@@ -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"]

View File

@@ -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()