mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
@@ -188,7 +188,8 @@
|
||||
"creator": "Ersteller",
|
||||
"title": "Rezept-Titel",
|
||||
"loraName": "LoRA-Dateiname",
|
||||
"loraModel": "LoRA-Modellname"
|
||||
"loraModel": "LoRA-Modellname",
|
||||
"prompt": "Prompt"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
@@ -588,10 +589,26 @@
|
||||
"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": {
|
||||
"title": "Rezeptliste aktualisieren"
|
||||
},
|
||||
"filteredByLora": "Gefiltert nach LoRA"
|
||||
"filteredByLora": "Gefiltert nach LoRA",
|
||||
"favorites": {
|
||||
"title": "Nur Favoriten anzeigen",
|
||||
"action": "Favoriten"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"found": "{count} Duplikat-Gruppen gefunden",
|
||||
@@ -638,7 +655,8 @@
|
||||
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
|
||||
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
|
||||
"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": {
|
||||
@@ -1460,7 +1478,8 @@
|
||||
"bulkMoveFailures": "Fehlgeschlagene Verschiebungen:\n{failures}",
|
||||
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
|
||||
"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": {
|
||||
@@ -1478,4 +1497,4 @@
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0 Bytes",
|
||||
@@ -188,7 +188,8 @@
|
||||
"creator": "Creator",
|
||||
"title": "Recipe Title",
|
||||
"loraName": "LoRA Filename",
|
||||
"loraModel": "LoRA Model Name"
|
||||
"loraModel": "LoRA Model Name",
|
||||
"prompt": "Prompt"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
@@ -336,7 +337,7 @@
|
||||
"templateOptions": {
|
||||
"flatStructure": "Flat Structure",
|
||||
"byBaseModel": "By Base Model",
|
||||
"byAuthor": "By Author",
|
||||
"byAuthor": "By Author",
|
||||
"byFirstTag": "By First Tag",
|
||||
"baseModelFirstTag": "Base Model + First Tag",
|
||||
"baseModelAuthor": "Base Model + Author",
|
||||
@@ -347,7 +348,7 @@
|
||||
"customTemplatePlaceholder": "Enter custom template (e.g., {base_model}/{author}/{first_tag})",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"checkpoint": "Checkpoint",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"baseModelPathMappings": "Base Model Path Mappings",
|
||||
@@ -420,11 +421,11 @@
|
||||
"proxyHost": "Proxy Host",
|
||||
"proxyHostPlaceholder": "proxy.example.com",
|
||||
"proxyHostHelp": "The hostname or IP address of your proxy server",
|
||||
"proxyPort": "Proxy Port",
|
||||
"proxyPort": "Proxy Port",
|
||||
"proxyPortPlaceholder": "8080",
|
||||
"proxyPortHelp": "The port number of your proxy server",
|
||||
"proxyUsername": "Username (Optional)",
|
||||
"proxyUsernamePlaceholder": "username",
|
||||
"proxyUsernamePlaceholder": "username",
|
||||
"proxyUsernameHelp": "Username for proxy authentication (if required)",
|
||||
"proxyPassword": "Password (Optional)",
|
||||
"proxyPasswordPlaceholder": "password",
|
||||
@@ -588,10 +589,26 @@
|
||||
"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": {
|
||||
"title": "Refresh recipe list"
|
||||
},
|
||||
"filteredByLora": "Filtered by LoRA"
|
||||
"filteredByLora": "Filtered by LoRA",
|
||||
"favorites": {
|
||||
"title": "Show Favorites Only",
|
||||
"action": "Favorites"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"found": "Found {count} duplicate groups",
|
||||
@@ -638,7 +655,8 @@
|
||||
"recursiveUnavailable": "Recursive search is available in tree view only",
|
||||
"collapseAllDisabled": "Not available in list view",
|
||||
"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": {
|
||||
@@ -1460,7 +1478,8 @@
|
||||
"bulkMoveFailures": "Failed moves:\n{failures}",
|
||||
"bulkMoveSuccess": "Successfully moved {successCount} {type}s",
|
||||
"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": {
|
||||
@@ -1478,4 +1497,4 @@
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,8 @@
|
||||
"creator": "Creador",
|
||||
"title": "Título de la receta",
|
||||
"loraName": "Nombre de archivo LoRA",
|
||||
"loraModel": "Nombre del modelo LoRA"
|
||||
"loraModel": "Nombre del modelo LoRA",
|
||||
"prompt": "Prompt"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
@@ -588,10 +589,26 @@
|
||||
"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": {
|
||||
"title": "Actualizar lista de recetas"
|
||||
},
|
||||
"filteredByLora": "Filtrado por LoRA"
|
||||
"filteredByLora": "Filtrado por LoRA",
|
||||
"favorites": {
|
||||
"title": "Mostrar solo favoritos",
|
||||
"action": "Favoritos"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"found": "Se encontraron {count} grupos de duplicados",
|
||||
@@ -638,7 +655,8 @@
|
||||
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
|
||||
"collapseAllDisabled": "No disponible en vista de lista",
|
||||
"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": {
|
||||
@@ -1460,7 +1478,8 @@
|
||||
"bulkMoveFailures": "Movimientos fallidos:\n{failures}",
|
||||
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
|
||||
"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": {
|
||||
@@ -1478,4 +1497,4 @@
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,8 @@
|
||||
"creator": "Créateur",
|
||||
"title": "Titre de la recipe",
|
||||
"loraName": "Nom de fichier LoRA",
|
||||
"loraModel": "Nom du modèle LoRA"
|
||||
"loraModel": "Nom du modèle LoRA",
|
||||
"prompt": "Prompt"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
@@ -588,10 +589,26 @@
|
||||
"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": {
|
||||
"title": "Actualiser la liste des recipes"
|
||||
},
|
||||
"filteredByLora": "Filtré par LoRA"
|
||||
"filteredByLora": "Filtré par LoRA",
|
||||
"favorites": {
|
||||
"title": "Afficher uniquement les favoris",
|
||||
"action": "Favoris"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"found": "Trouvé {count} groupes de doublons",
|
||||
@@ -638,7 +655,8 @@
|
||||
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
|
||||
"collapseAllDisabled": "Non disponible en vue liste",
|
||||
"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": {
|
||||
@@ -1460,7 +1478,8 @@
|
||||
"bulkMoveFailures": "Échecs de déplacement :\n{failures}",
|
||||
"bulkMoveSuccess": "{successCount} {type}s déplacés 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": {
|
||||
@@ -1478,4 +1497,4 @@
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,8 @@
|
||||
"creator": "יוצר",
|
||||
"title": "כותרת מתכון",
|
||||
"loraName": "שם קובץ LoRA",
|
||||
"loraModel": "שם מודל LoRA"
|
||||
"loraModel": "שם מודל LoRA",
|
||||
"prompt": "הנחיה"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
@@ -228,6 +229,7 @@
|
||||
"videoSettings": "הגדרות וידאו",
|
||||
"layoutSettings": "הגדרות פריסה",
|
||||
"folderSettings": "הגדרות תיקייה",
|
||||
"priorityTags": "תגיות עדיפות",
|
||||
"downloadPathTemplates": "תבניות נתיב הורדה",
|
||||
"exampleImages": "תמונות דוגמה",
|
||||
"updateFlags": "תגי עדכון",
|
||||
@@ -235,8 +237,7 @@
|
||||
"misc": "שונות",
|
||||
"metadataArchive": "מסד נתונים של ארכיון מטא-דאטה",
|
||||
"storageLocation": "מיקום ההגדרות",
|
||||
"proxySettings": "הגדרות פרוקסי",
|
||||
"priorityTags": "תגיות עדיפות"
|
||||
"proxySettings": "הגדרות פרוקסי"
|
||||
},
|
||||
"storage": {
|
||||
"locationLabel": "מצב נייד",
|
||||
@@ -309,6 +310,26 @@
|
||||
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
|
||||
"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": {
|
||||
"title": "תבניות נתיב הורדה",
|
||||
"help": "הגדר מבני תיקיות לסוגי מודלים שונים בעת הורדה מ-Civitai.",
|
||||
@@ -320,8 +341,8 @@
|
||||
"byFirstTag": "לפי תגית ראשונה",
|
||||
"baseModelFirstTag": "מודל בסיס + תגית ראשונה",
|
||||
"baseModelAuthor": "מודל בסיס + יוצר",
|
||||
"baseModelAuthorFirstTag": "מודל בסיס + יוצר + תגית ראשונה",
|
||||
"authorFirstTag": "יוצר + תגית ראשונה",
|
||||
"baseModelAuthorFirstTag": "מודל בסיס + יוצר + תגית ראשונה",
|
||||
"customTemplate": "תבנית מותאמת אישית"
|
||||
},
|
||||
"customTemplatePlaceholder": "הזן תבנית מותאמת אישית (למשל, {base_model}/{author}/{first_tag})",
|
||||
@@ -409,26 +430,6 @@
|
||||
"proxyPassword": "סיסמה (אופציונלי)",
|
||||
"proxyPasswordPlaceholder": "password",
|
||||
"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": {
|
||||
@@ -588,10 +589,26 @@
|
||||
"selectLoraRoot": "אנא בחר ספריית שורש של LoRA"
|
||||
}
|
||||
},
|
||||
"sort": {
|
||||
"title": "מיון מתכונים לפי...",
|
||||
"name": "שם",
|
||||
"nameAsc": "א - ת",
|
||||
"nameDesc": "ת - א",
|
||||
"date": "תאריך",
|
||||
"dateDesc": "הכי חדש",
|
||||
"dateAsc": "הכי ישן",
|
||||
"lorasCount": "מספר LoRAs",
|
||||
"lorasCountDesc": "הכי הרבה",
|
||||
"lorasCountAsc": "הכי פחות"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "רענן רשימת מתכונים"
|
||||
},
|
||||
"filteredByLora": "מסונן לפי LoRA"
|
||||
"filteredByLora": "מסונן לפי LoRA",
|
||||
"favorites": {
|
||||
"title": "הצג מועדפים בלבד",
|
||||
"action": "מועדפים"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"found": "נמצאו {count} קבוצות כפולות",
|
||||
@@ -638,7 +655,8 @@
|
||||
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
|
||||
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה."
|
||||
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1460,7 +1478,8 @@
|
||||
"bulkMoveFailures": "העברות שנכשלו:\n{failures}",
|
||||
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
|
||||
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
|
||||
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}"
|
||||
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
@@ -1478,4 +1497,4 @@
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,8 @@
|
||||
"creator": "作成者",
|
||||
"title": "レシピタイトル",
|
||||
"loraName": "LoRAファイル名",
|
||||
"loraModel": "LoRAモデル名"
|
||||
"loraModel": "LoRAモデル名",
|
||||
"prompt": "プロンプト"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
@@ -588,10 +589,26 @@
|
||||
"selectLoraRoot": "LoRAルートディレクトリを選択してください"
|
||||
}
|
||||
},
|
||||
"sort": {
|
||||
"title": "レシピの並び替え...",
|
||||
"name": "名前",
|
||||
"nameAsc": "A - Z",
|
||||
"nameDesc": "Z - A",
|
||||
"date": "日付",
|
||||
"dateDesc": "新しい順",
|
||||
"dateAsc": "古い順",
|
||||
"lorasCount": "LoRA数",
|
||||
"lorasCountDesc": "多い順",
|
||||
"lorasCountAsc": "少ない順"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "レシピリストを更新"
|
||||
},
|
||||
"filteredByLora": "LoRAでフィルタ済み"
|
||||
"filteredByLora": "LoRAでフィルタ済み",
|
||||
"favorites": {
|
||||
"title": "お気に入りのみ表示",
|
||||
"action": "お気に入り"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"found": "{count} 個の重複グループが見つかりました",
|
||||
@@ -638,7 +655,8 @@
|
||||
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
|
||||
"collapseAllDisabled": "リストビューでは利用できません",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "移動先のパスを特定できません。"
|
||||
"unableToResolveRoot": "移動先のパスを特定できません。",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1460,7 +1478,8 @@
|
||||
"bulkMoveFailures": "失敗した移動:\n{failures}",
|
||||
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
|
||||
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
|
||||
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}"
|
||||
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
@@ -1478,4 +1497,4 @@
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,8 @@
|
||||
"creator": "제작자",
|
||||
"title": "레시피 제목",
|
||||
"loraName": "LoRA 파일명",
|
||||
"loraModel": "LoRA 모델명"
|
||||
"loraModel": "LoRA 모델명",
|
||||
"prompt": "프롬프트"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
@@ -588,10 +589,26 @@
|
||||
"selectLoraRoot": "LoRA 루트 디렉토리를 선택해주세요"
|
||||
}
|
||||
},
|
||||
"sort": {
|
||||
"title": "레시피 정렬...",
|
||||
"name": "이름",
|
||||
"nameAsc": "A - Z",
|
||||
"nameDesc": "Z - A",
|
||||
"date": "날짜",
|
||||
"dateDesc": "최신순",
|
||||
"dateAsc": "오래된순",
|
||||
"lorasCount": "LoRA 수",
|
||||
"lorasCountDesc": "많은순",
|
||||
"lorasCountAsc": "적은순"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "레시피 목록 새로고침"
|
||||
},
|
||||
"filteredByLora": "LoRA로 필터링됨"
|
||||
"filteredByLora": "LoRA로 필터링됨",
|
||||
"favorites": {
|
||||
"title": "즐겨찾기만 표시",
|
||||
"action": "즐겨찾기"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"found": "{count}개의 중복 그룹 발견",
|
||||
@@ -638,7 +655,8 @@
|
||||
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
|
||||
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다."
|
||||
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1460,7 +1478,8 @@
|
||||
"bulkMoveFailures": "실패한 이동:\n{failures}",
|
||||
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
|
||||
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
|
||||
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}"
|
||||
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
@@ -1478,4 +1497,4 @@
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,8 @@
|
||||
"creator": "Автор",
|
||||
"title": "Название рецепта",
|
||||
"loraName": "Имя файла LoRA",
|
||||
"loraModel": "Название модели LoRA"
|
||||
"loraModel": "Название модели LoRA",
|
||||
"prompt": "Запрос"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
@@ -588,10 +589,26 @@
|
||||
"selectLoraRoot": "Пожалуйста, выберите корневую папку LoRA"
|
||||
}
|
||||
},
|
||||
"sort": {
|
||||
"title": "Сортировка рецептов...",
|
||||
"name": "Имя",
|
||||
"nameAsc": "А - Я",
|
||||
"nameDesc": "Я - А",
|
||||
"date": "Дата",
|
||||
"dateDesc": "Сначала новые",
|
||||
"dateAsc": "Сначала старые",
|
||||
"lorasCount": "Кол-во LoRA",
|
||||
"lorasCountDesc": "Больше всего",
|
||||
"lorasCountAsc": "Меньше всего"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "Обновить список рецептов"
|
||||
},
|
||||
"filteredByLora": "Фильтр по LoRA"
|
||||
"filteredByLora": "Фильтр по LoRA",
|
||||
"favorites": {
|
||||
"title": "Только избранные",
|
||||
"action": "Избранное"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"found": "Найдено {count} групп дубликатов",
|
||||
@@ -638,7 +655,8 @@
|
||||
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
|
||||
"collapseAllDisabled": "Недоступно в виде списка",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения."
|
||||
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1460,7 +1478,8 @@
|
||||
"bulkMoveFailures": "Неудачные перемещения:\n{failures}",
|
||||
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
|
||||
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
|
||||
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}"
|
||||
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
@@ -1478,4 +1497,4 @@
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,8 @@
|
||||
"creator": "创作者",
|
||||
"title": "配方标题",
|
||||
"loraName": "LoRA 文件名",
|
||||
"loraModel": "LoRA 模型名称"
|
||||
"loraModel": "LoRA 模型名称",
|
||||
"prompt": "提示词"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
@@ -588,10 +589,26 @@
|
||||
"selectLoraRoot": "请选择 LoRA 根目录"
|
||||
}
|
||||
},
|
||||
"sort": {
|
||||
"title": "配方排序...",
|
||||
"name": "名称",
|
||||
"nameAsc": "A - Z",
|
||||
"nameDesc": "Z - A",
|
||||
"date": "时间",
|
||||
"dateDesc": "最新",
|
||||
"dateAsc": "最早",
|
||||
"lorasCount": "LoRA 数量",
|
||||
"lorasCountDesc": "最多",
|
||||
"lorasCountAsc": "最少"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "刷新配方列表"
|
||||
},
|
||||
"filteredByLora": "按 LoRA 筛选"
|
||||
"filteredByLora": "按 LoRA 筛选",
|
||||
"favorites": {
|
||||
"title": "仅显示收藏",
|
||||
"action": "收藏"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"found": "发现 {count} 个重复组",
|
||||
@@ -638,7 +655,8 @@
|
||||
"recursiveUnavailable": "仅在树形视图中可使用递归搜索",
|
||||
"collapseAllDisabled": "列表视图下不可用",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "无法确定移动的目标路径。"
|
||||
"unableToResolveRoot": "无法确定移动的目标路径。",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1460,7 +1478,8 @@
|
||||
"bulkMoveFailures": "移动失败:\n{failures}",
|
||||
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
|
||||
"exampleImagesDownloadSuccess": "示例图片下载成功!",
|
||||
"exampleImagesDownloadFailed": "示例图片下载失败:{message}"
|
||||
"exampleImagesDownloadFailed": "示例图片下载失败:{message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
@@ -1478,4 +1497,4 @@
|
||||
"learnMore": "浏览器插件教程"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -188,7 +188,8 @@
|
||||
"creator": "創作者",
|
||||
"title": "配方標題",
|
||||
"loraName": "LoRA 檔案名稱",
|
||||
"loraModel": "LoRA 模型名稱"
|
||||
"loraModel": "LoRA 模型名稱",
|
||||
"prompt": "提示詞"
|
||||
}
|
||||
},
|
||||
"filter": {
|
||||
@@ -588,10 +589,26 @@
|
||||
"selectLoraRoot": "請選擇 LoRA 根目錄"
|
||||
}
|
||||
},
|
||||
"sort": {
|
||||
"title": "配方排序...",
|
||||
"name": "名稱",
|
||||
"nameAsc": "A - Z",
|
||||
"nameDesc": "Z - A",
|
||||
"date": "時間",
|
||||
"dateDesc": "最新",
|
||||
"dateAsc": "最舊",
|
||||
"lorasCount": "LoRA 數量",
|
||||
"lorasCountDesc": "最多",
|
||||
"lorasCountAsc": "最少"
|
||||
},
|
||||
"refresh": {
|
||||
"title": "重新整理配方列表"
|
||||
},
|
||||
"filteredByLora": "已依 LoRA 篩選"
|
||||
"filteredByLora": "已依 LoRA 篩選",
|
||||
"favorites": {
|
||||
"title": "僅顯示收藏",
|
||||
"action": "收藏"
|
||||
}
|
||||
},
|
||||
"duplicates": {
|
||||
"found": "發現 {count} 組重複項",
|
||||
@@ -638,7 +655,8 @@
|
||||
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
|
||||
"collapseAllDisabled": "列表檢視下不可用",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "無法確定移動的目標路徑。"
|
||||
"unableToResolveRoot": "無法確定移動的目標路徑。",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1460,7 +1478,8 @@
|
||||
"bulkMoveFailures": "移動失敗:\n{failures}",
|
||||
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
|
||||
"exampleImagesDownloadSuccess": "範例圖片下載成功!",
|
||||
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}"
|
||||
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
@@ -1478,4 +1497,4 @@
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
py/recipes/merger.py
Normal file
50
py/recipes/merger.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from typing import Any, Dict, Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class GenParamsMerger:
|
||||
"""Utility to merge generation parameters from multiple sources with priority."""
|
||||
|
||||
BLACKLISTED_KEYS = {"id", "url", "userId", "username", "createdAt", "updatedAt", "hash"}
|
||||
|
||||
@staticmethod
|
||||
def merge(
|
||||
request_params: Optional[Dict[str, Any]] = None,
|
||||
civitai_meta: Optional[Dict[str, Any]] = None,
|
||||
embedded_metadata: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Merge generation parameters from three sources.
|
||||
|
||||
Priority: request_params > civitai_meta > embedded_metadata
|
||||
|
||||
Args:
|
||||
request_params: Params provided directly in the import request
|
||||
civitai_meta: Params from Civitai Image API 'meta' field
|
||||
embedded_metadata: Params extracted from image EXIF/embedded metadata
|
||||
|
||||
Returns:
|
||||
Merged parameters dictionary
|
||||
"""
|
||||
result = {}
|
||||
|
||||
# 1. Start with embedded metadata (lowest priority)
|
||||
if embedded_metadata:
|
||||
# If it's a full recipe metadata, we use its gen_params
|
||||
if "gen_params" in embedded_metadata and isinstance(embedded_metadata["gen_params"], dict):
|
||||
result.update(embedded_metadata["gen_params"])
|
||||
else:
|
||||
# Otherwise assume the dict itself contains gen_params
|
||||
result.update(embedded_metadata)
|
||||
|
||||
# 2. Layer Civitai meta (medium priority)
|
||||
if civitai_meta:
|
||||
result.update(civitai_meta)
|
||||
|
||||
# 3. Layer request params (highest priority)
|
||||
if request_params:
|
||||
result.update(request_params)
|
||||
|
||||
# Filter out blacklisted keys
|
||||
return {k: v for k, v in result.items() if k not in GenParamsMerger.BLACKLISTED_KEYS}
|
||||
@@ -36,9 +36,6 @@ class ComfyMetadataParser(RecipeMetadataParser):
|
||||
# Find all LoraLoader nodes
|
||||
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
|
||||
for node_id, node in lora_nodes.items():
|
||||
if 'inputs' not in node or 'lora_name' not in node['inputs']:
|
||||
|
||||
@@ -24,6 +24,8 @@ from ...services.recipes import (
|
||||
)
|
||||
from ...services.metadata_service import get_default_metadata_provider
|
||||
from ...utils.civitai_utils import rewrite_preview_url
|
||||
from ...utils.exif_utils import ExifUtils
|
||||
from ...recipes.merger import GenParamsMerger
|
||||
|
||||
Logger = logging.Logger
|
||||
EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
||||
@@ -56,16 +58,22 @@ class RecipeHandlerSet:
|
||||
"delete_recipe": self.management.delete_recipe,
|
||||
"get_top_tags": self.query.get_top_tags,
|
||||
"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,
|
||||
"download_shared_recipe": self.sharing.download_shared_recipe,
|
||||
"get_recipe_syntax": self.query.get_recipe_syntax,
|
||||
"update_recipe": self.management.update_recipe,
|
||||
"reconnect_lora": self.management.reconnect_lora,
|
||||
"find_duplicates": self.query.find_duplicates,
|
||||
"move_recipes_bulk": self.management.move_recipes_bulk,
|
||||
"bulk_delete": self.management.bulk_delete,
|
||||
"save_recipe_from_widget": self.management.save_recipe_from_widget,
|
||||
"get_recipes_for_lora": self.query.get_recipes_for_lora,
|
||||
"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"))
|
||||
sort_by = request.query.get("sort_by", "date")
|
||||
search = request.query.get("search")
|
||||
folder = request.query.get("folder")
|
||||
recursive = request.query.get("recursive", "true").lower() == "true"
|
||||
|
||||
search_options = {
|
||||
"title": request.query.get("search_title", "true").lower() == "true",
|
||||
"tags": request.query.get("search_tags", "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",
|
||||
"prompt": request.query.get("search_prompt", "true").lower() == "true",
|
||||
}
|
||||
|
||||
filters: Dict[str, Any] = {}
|
||||
@@ -162,6 +173,9 @@ class RecipeListingHandler:
|
||||
if base_models:
|
||||
filters["base_model"] = base_models.split(",")
|
||||
|
||||
if request.query.get("favorite", "false").lower() == "true":
|
||||
filters["favorite"] = True
|
||||
|
||||
tag_filters: Dict[str, str] = {}
|
||||
legacy_tags = request.query.get("tags")
|
||||
if legacy_tags:
|
||||
@@ -193,6 +207,8 @@ class RecipeListingHandler:
|
||||
filters=filters,
|
||||
search_options=search_options,
|
||||
lora_hash=lora_hash,
|
||||
folder=folder,
|
||||
recursive=recursive,
|
||||
)
|
||||
|
||||
for item in result.get("items", []):
|
||||
@@ -299,6 +315,58 @@ class RecipeQueryHandler:
|
||||
self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
|
||||
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:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
@@ -486,7 +554,41 @@ class RecipeManagementHandler:
|
||||
metadata["base_model"] = base_model_from_metadata
|
||||
|
||||
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(
|
||||
recipe_scanner=recipe_scanner,
|
||||
@@ -545,6 +647,64 @@ class RecipeManagementHandler:
|
||||
self._logger.error("Error updating recipe: %s", exc, exc_info=True)
|
||||
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:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
@@ -776,7 +936,7 @@ class RecipeManagementHandler:
|
||||
extension = ".webp" # Default to webp if unknown
|
||||
|
||||
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:
|
||||
raise
|
||||
except RecipeValidationError:
|
||||
|
||||
@@ -27,10 +27,16 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
||||
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/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/download", "download_shared_recipe"),
|
||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
|
||||
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("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
||||
|
||||
@@ -7,12 +7,18 @@ from natsort import natsorted
|
||||
@dataclass
|
||||
class RecipeCache:
|
||||
"""Cache structure for Recipe data"""
|
||||
|
||||
raw_data: List[Dict]
|
||||
sorted_by_name: List[Dict]
|
||||
sorted_by_date: List[Dict]
|
||||
folders: List[str] | None = None
|
||||
folder_tree: Dict | None = None
|
||||
|
||||
def __post_init__(self):
|
||||
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):
|
||||
"""Resort all cached data views"""
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import os
|
||||
import logging
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
||||
from ..config import config
|
||||
@@ -117,7 +119,9 @@ class RecipeScanner:
|
||||
self._cache = RecipeCache(
|
||||
raw_data=[],
|
||||
sorted_by_name=[],
|
||||
sorted_by_date=[]
|
||||
sorted_by_date=[],
|
||||
folders=[],
|
||||
folder_tree={},
|
||||
)
|
||||
|
||||
# Mark as initializing to prevent concurrent initializations
|
||||
@@ -218,6 +222,7 @@ class RecipeScanner:
|
||||
|
||||
# Update cache with the collected data
|
||||
self._cache.raw_data = recipes
|
||||
self._update_folder_metadata(self._cache)
|
||||
|
||||
# Create a simplified resort function that doesn't use await
|
||||
if hasattr(self._cache, "resort"):
|
||||
@@ -336,6 +341,9 @@ class RecipeScanner:
|
||||
if not self._cache:
|
||||
return
|
||||
|
||||
# Keep folder metadata up to date alongside sort order
|
||||
self._update_folder_metadata()
|
||||
|
||||
async def _resort_wrapper() -> None:
|
||||
try:
|
||||
await self._cache.resort(name_only=name_only)
|
||||
@@ -346,6 +354,75 @@ class RecipeScanner:
|
||||
self._resort_tasks.add(task)
|
||||
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
|
||||
def recipes_dir(self) -> str:
|
||||
"""Get path to recipes directory"""
|
||||
@@ -362,11 +439,14 @@ class RecipeScanner:
|
||||
"""Get cached recipe data, refresh if needed"""
|
||||
# If cache is already initialized and no refresh is needed, return it immediately
|
||||
if self._cache is not None and not force_refresh:
|
||||
self._update_folder_metadata()
|
||||
return self._cache
|
||||
|
||||
# If another initialization is already in progress, wait for it to complete
|
||||
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:
|
||||
@@ -384,11 +464,14 @@ class RecipeScanner:
|
||||
self._cache = RecipeCache(
|
||||
raw_data=raw_data,
|
||||
sorted_by_name=[],
|
||||
sorted_by_date=[]
|
||||
sorted_by_date=[],
|
||||
folders=[],
|
||||
folder_tree={},
|
||||
)
|
||||
|
||||
|
||||
# Resort cache
|
||||
await self._cache.resort()
|
||||
self._update_folder_metadata(self._cache)
|
||||
|
||||
return self._cache
|
||||
|
||||
@@ -398,7 +481,9 @@ class RecipeScanner:
|
||||
self._cache = RecipeCache(
|
||||
raw_data=[],
|
||||
sorted_by_name=[],
|
||||
sorted_by_date=[]
|
||||
sorted_by_date=[],
|
||||
folders=[],
|
||||
folder_tree={},
|
||||
)
|
||||
return self._cache
|
||||
finally:
|
||||
@@ -409,7 +494,9 @@ class RecipeScanner:
|
||||
logger.error(f"Unexpected error in get_cached_data: {e}")
|
||||
|
||||
# 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:
|
||||
"""Public helper to refresh or return the recipe cache."""
|
||||
@@ -424,6 +511,7 @@ class RecipeScanner:
|
||||
|
||||
cache = await self.get_cached_data()
|
||||
await cache.add_recipe(recipe_data, resort=False)
|
||||
self._update_folder_metadata(cache)
|
||||
self._schedule_resort()
|
||||
|
||||
async def remove_recipe(self, recipe_id: str) -> bool:
|
||||
@@ -437,6 +525,7 @@ class RecipeScanner:
|
||||
if removed is None:
|
||||
return False
|
||||
|
||||
self._update_folder_metadata(cache)
|
||||
self._schedule_resort()
|
||||
return True
|
||||
|
||||
@@ -521,6 +610,9 @@ class RecipeScanner:
|
||||
|
||||
if path_updated:
|
||||
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
|
||||
if 'loras' not in recipe_data:
|
||||
@@ -914,7 +1006,7 @@ class RecipeScanner:
|
||||
|
||||
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
|
||||
|
||||
Args:
|
||||
@@ -926,11 +1018,20 @@ class RecipeScanner:
|
||||
search_options: Dictionary of search options to apply
|
||||
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
|
||||
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()
|
||||
|
||||
# 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
|
||||
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
|
||||
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
|
||||
if search:
|
||||
# Default search options if none provided
|
||||
@@ -997,6 +1114,14 @@ class RecipeScanner:
|
||||
if fuzzy_match(str(lora.get('modelName', '')), search):
|
||||
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
|
||||
return False
|
||||
|
||||
@@ -1012,6 +1137,13 @@ class RecipeScanner:
|
||||
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
|
||||
if 'tags' in filters and 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 []))
|
||||
]
|
||||
|
||||
|
||||
# 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
|
||||
total_items = len(filtered_data)
|
||||
start_idx = (page - 1) * page_size
|
||||
@@ -1136,6 +1282,30 @@ class RecipeScanner:
|
||||
from datetime import datetime
|
||||
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:
|
||||
"""Update recipe metadata (like title and tags) in both file system and cache
|
||||
|
||||
@@ -1146,13 +1316,9 @@ class RecipeScanner:
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
|
||||
# First, find the recipe JSON file path
|
||||
recipe_json_path = os.path.join(self.recipes_dir, f"{recipe_id}.recipe.json")
|
||||
|
||||
if not os.path.exists(recipe_json_path):
|
||||
recipe_json_path = await self.get_recipe_json_path(recipe_id)
|
||||
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -1201,8 +1367,8 @@ class RecipeScanner:
|
||||
if target_name is None:
|
||||
raise ValueError("target_name must be provided")
|
||||
|
||||
recipe_json_path = os.path.join(self.recipes_dir, f"{recipe_id}.recipe.json")
|
||||
if not os.path.exists(recipe_json_path):
|
||||
recipe_json_path = await self.get_recipe_json_path(recipe_id)
|
||||
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||
raise RecipeNotFoundError("Recipe not found")
|
||||
|
||||
async with self._mutation_lock:
|
||||
|
||||
@@ -5,6 +5,7 @@ import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
@@ -154,12 +155,8 @@ class RecipePersistenceService:
|
||||
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
|
||||
"""Delete an existing recipe."""
|
||||
|
||||
recipes_dir = recipe_scanner.recipes_dir
|
||||
if not recipes_dir or not os.path.exists(recipes_dir):
|
||||
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):
|
||||
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")
|
||||
|
||||
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:
|
||||
"""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(
|
||||
"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)
|
||||
@@ -187,6 +184,163 @@ class RecipePersistenceService:
|
||||
|
||||
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(
|
||||
self,
|
||||
*,
|
||||
@@ -197,8 +351,8 @@ class RecipePersistenceService:
|
||||
) -> PersistenceResult:
|
||||
"""Reconnect a LoRA entry within an existing recipe."""
|
||||
|
||||
recipe_path = os.path.join(recipe_scanner.recipes_dir, f"{recipe_id}.recipe.json")
|
||||
if not os.path.exists(recipe_path):
|
||||
recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||
if not recipe_path or not os.path.exists(recipe_path):
|
||||
raise RecipeNotFoundError("Recipe not found")
|
||||
|
||||
target_lora = await recipe_scanner.get_local_lora(target_name)
|
||||
@@ -243,16 +397,12 @@ class RecipePersistenceService:
|
||||
if not recipe_ids:
|
||||
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] = []
|
||||
failed_recipes: list[dict[str, Any]] = []
|
||||
|
||||
for recipe_id in recipe_ids:
|
||||
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
|
||||
if not os.path.exists(recipe_json_path):
|
||||
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):
|
||||
failed_recipes.append({"id": recipe_id, "reason": "Recipe not found"})
|
||||
continue
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ class TranslationKeySynchronizer:
|
||||
self.locales_dir = locales_dir
|
||||
self.verbose = verbose
|
||||
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'):
|
||||
"""Log a message if verbose mode is enabled."""
|
||||
|
||||
@@ -2,6 +2,35 @@ import { RecipeCard } from '../components/RecipeCard.js';
|
||||
import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { 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
|
||||
* @param {number} page - Page number to fetch
|
||||
@@ -10,25 +39,36 @@ import { showToast } from '../utils/uiHelpers.js';
|
||||
*/
|
||||
export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: page,
|
||||
page_size: pageSize || pageState.pageSize || 20,
|
||||
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 (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
|
||||
// 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) {
|
||||
throw new Error(`Failed to load recipe: ${response.statusText}`);
|
||||
}
|
||||
|
||||
|
||||
const recipe = await response.json();
|
||||
|
||||
|
||||
// Return in expected format
|
||||
return {
|
||||
items: [recipe],
|
||||
@@ -38,33 +78,34 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
||||
hasMore: false
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
// Add custom filter for Lora if present
|
||||
if (pageState.customFilter?.active && pageState.customFilter?.loraHash) {
|
||||
params.append('lora_hash', pageState.customFilter.loraHash);
|
||||
params.append('bypass_filters', 'true');
|
||||
} else {
|
||||
// Normal filtering logic
|
||||
|
||||
|
||||
// Add search filter if present
|
||||
if (pageState.filters?.search) {
|
||||
params.append('search', pageState.filters.search);
|
||||
|
||||
|
||||
// Add search option parameters
|
||||
if (pageState.searchOptions) {
|
||||
params.append('search_title', pageState.searchOptions.title.toString());
|
||||
params.append('search_tags', pageState.searchOptions.tags.toString());
|
||||
params.append('search_lora_name', pageState.searchOptions.loraName.toString());
|
||||
params.append('search_lora_model', pageState.searchOptions.loraModel.toString());
|
||||
params.append('search_prompt', (pageState.searchOptions.prompt || false).toString());
|
||||
params.append('fuzzy', 'true');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add base model filters
|
||||
if (pageState.filters?.baseModel && pageState.filters.baseModel.length) {
|
||||
params.append('base_models', pageState.filters.baseModel.join(','));
|
||||
}
|
||||
|
||||
|
||||
// Add tag filters
|
||||
if (pageState.filters?.tags && Object.keys(pageState.filters.tags).length) {
|
||||
Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
|
||||
@@ -78,14 +119,14 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
|
||||
}
|
||||
|
||||
// Fetch recipes
|
||||
const response = await fetch(`/api/lm/recipes?${params.toString()}`);
|
||||
|
||||
const response = await fetch(`${RECIPE_ENDPOINTS.list}?${params.toString()}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load recipes: ${response.statusText}`);
|
||||
}
|
||||
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
|
||||
return {
|
||||
items: data.items,
|
||||
totalItems: data.total,
|
||||
@@ -111,29 +152,29 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
|
||||
updateFolders = false,
|
||||
fetchPageFunction
|
||||
} = options;
|
||||
|
||||
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
|
||||
try {
|
||||
pageState.isLoading = true;
|
||||
|
||||
|
||||
// Reset page counter
|
||||
pageState.currentPage = 1;
|
||||
|
||||
|
||||
// Fetch the first page
|
||||
const result = await fetchPageFunction(1, pageState.pageSize || 50);
|
||||
|
||||
|
||||
// Update the virtual scroller
|
||||
state.virtualScroller.refreshWithData(
|
||||
result.items,
|
||||
result.totalItems,
|
||||
result.hasMore
|
||||
);
|
||||
|
||||
|
||||
// Update state
|
||||
pageState.hasMore = result.hasMore;
|
||||
pageState.currentPage = 2; // Next page will be 2
|
||||
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error reloading ${modelType}s:`, error);
|
||||
@@ -156,32 +197,32 @@ export async function loadMoreWithVirtualScroll(options = {}) {
|
||||
updateFolders = false,
|
||||
fetchPageFunction
|
||||
} = options;
|
||||
|
||||
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
|
||||
try {
|
||||
// Start loading state
|
||||
pageState.isLoading = true;
|
||||
|
||||
|
||||
// Reset to first page if requested
|
||||
if (resetPage) {
|
||||
pageState.currentPage = 1;
|
||||
}
|
||||
|
||||
|
||||
// Fetch the first page of data
|
||||
const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50);
|
||||
|
||||
|
||||
// Update virtual scroller with the new data
|
||||
state.virtualScroller.refreshWithData(
|
||||
result.items,
|
||||
result.totalItems,
|
||||
result.hasMore
|
||||
);
|
||||
|
||||
|
||||
// Update state
|
||||
pageState.hasMore = result.hasMore;
|
||||
pageState.currentPage = 2; // Next page to load would be 2
|
||||
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error(`Error loading ${modelType}s:`, error);
|
||||
@@ -211,18 +252,18 @@ export async function resetAndReload(updateFolders = false) {
|
||||
export async function refreshRecipes() {
|
||||
try {
|
||||
state.loadingManager.showSimpleLoading('Refreshing recipes...');
|
||||
|
||||
|
||||
// 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) {
|
||||
const data = await response.json();
|
||||
throw new Error(data.error || 'Failed to refresh recipe cache');
|
||||
}
|
||||
|
||||
|
||||
// After successful cache rebuild, reload the recipes
|
||||
await resetAndReload();
|
||||
|
||||
|
||||
showToast('toast.recipes.refreshComplete', {}, 'success');
|
||||
} catch (error) {
|
||||
console.error('Error refreshing recipes:', error);
|
||||
@@ -240,7 +281,7 @@ export async function refreshRecipes() {
|
||||
*/
|
||||
export async function loadMoreRecipes(resetPage = false) {
|
||||
const pageState = getCurrentPageState();
|
||||
|
||||
|
||||
// Use virtual scroller if available
|
||||
if (state.virtualScroller) {
|
||||
return loadMoreWithVirtualScroll({
|
||||
@@ -277,10 +318,12 @@ export async function updateRecipeMetadata(filePath, updates) {
|
||||
state.loadingManager.showSimpleLoading('Saving metadata...');
|
||||
|
||||
// Extract recipeId from filePath (basename without extension)
|
||||
const basename = filePath.split('/').pop().split('\\').pop();
|
||||
const recipeId = basename.substring(0, basename.lastIndexOf('.'));
|
||||
|
||||
const response = await fetch(`/api/lm/recipe/${recipeId}/update`, {
|
||||
const recipeId = extractRecipeId(filePath);
|
||||
if (!recipeId) {
|
||||
throw new Error('Unable to determine recipe ID');
|
||||
}
|
||||
|
||||
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -296,7 +339,7 @@ export async function updateRecipeMetadata(filePath, updates) {
|
||||
}
|
||||
|
||||
state.virtualScroller.updateSingleItem(filePath, updates);
|
||||
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
console.error('Error updating recipe:', error);
|
||||
@@ -306,3 +349,187 @@ export async function updateRecipeMetadata(filePath, updates) {
|
||||
state.loadingManager.hide();
|
||||
}
|
||||
}
|
||||
|
||||
export class RecipeSidebarApiClient {
|
||||
constructor() {
|
||||
this.apiConfig = RECIPE_SIDEBAR_CONFIG;
|
||||
}
|
||||
|
||||
async fetchUnifiedFolderTree() {
|
||||
const response = await fetch(this.apiConfig.endpoints.unifiedFolderTree);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch recipe folder tree');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async fetchModelRoots() {
|
||||
const response = await fetch(this.apiConfig.endpoints.roots);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch recipe roots');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async fetchModelFolders() {
|
||||
const response = await fetch(this.apiConfig.endpoints.folders);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch recipe folders');
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async moveBulkModels(filePaths, targetPath) {
|
||||
if (!this.apiConfig.config.supportsMove) {
|
||||
showToast('toast.api.bulkMoveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
|
||||
return [];
|
||||
}
|
||||
|
||||
const recipeIds = filePaths
|
||||
.map((path) => extractRecipeId(path))
|
||||
.filter((id) => !!id);
|
||||
|
||||
if (recipeIds.length === 0) {
|
||||
showToast('toast.models.noModelsSelected', {}, 'warning');
|
||||
return [];
|
||||
}
|
||||
|
||||
const response = await fetch(this.apiConfig.endpoints.moveBulk, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipe_ids: recipeIds,
|
||||
target_path: targetPath,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || `Failed to move ${this.apiConfig.config.displayName}s`);
|
||||
}
|
||||
|
||||
if (result.failure_count > 0) {
|
||||
showToast(
|
||||
'toast.api.bulkMovePartial',
|
||||
{
|
||||
successCount: result.success_count,
|
||||
type: this.apiConfig.config.displayName,
|
||||
failureCount: result.failure_count,
|
||||
},
|
||||
'warning'
|
||||
);
|
||||
|
||||
const failedFiles = (result.results || [])
|
||||
.filter((item) => !item.success)
|
||||
.map((item) => item.message || 'Unknown error');
|
||||
|
||||
if (failedFiles.length > 0) {
|
||||
const failureMessage =
|
||||
failedFiles.length <= 3
|
||||
? failedFiles.join('\n')
|
||||
: `${failedFiles.slice(0, 3).join('\n')}\n(and ${failedFiles.length - 3} more)`;
|
||||
showToast('toast.api.bulkMoveFailures', { failures: failureMessage }, 'warning', 6000);
|
||||
}
|
||||
} else {
|
||||
showToast(
|
||||
'toast.api.bulkMoveSuccess',
|
||||
{
|
||||
successCount: result.success_count,
|
||||
type: this.apiConfig.config.displayName,
|
||||
},
|
||||
'success'
|
||||
);
|
||||
}
|
||||
|
||||
return result.results || [];
|
||||
}
|
||||
|
||||
async moveSingleModel(filePath, targetPath) {
|
||||
if (!this.apiConfig.config.supportsMove) {
|
||||
showToast('toast.api.moveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
|
||||
return null;
|
||||
}
|
||||
|
||||
const recipeId = extractRecipeId(filePath);
|
||||
if (!recipeId) {
|
||||
showToast('toast.api.moveFailed', { message: 'Recipe ID missing' }, 'error');
|
||||
return null;
|
||||
}
|
||||
|
||||
const response = await fetch(this.apiConfig.endpoints.move, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipe_id: recipeId,
|
||||
target_path: targetPath,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || `Failed to move ${this.apiConfig.config.displayName}`);
|
||||
}
|
||||
|
||||
if (result.message) {
|
||||
showToast('toast.api.moveInfo', { message: result.message }, 'info');
|
||||
} else {
|
||||
showToast('toast.api.moveSuccess', { type: this.apiConfig.config.displayName }, 'success');
|
||||
}
|
||||
|
||||
return {
|
||||
original_file_path: result.original_file_path || filePath,
|
||||
new_file_path: result.new_file_path || filePath,
|
||||
folder: result.folder || '',
|
||||
message: result.message,
|
||||
};
|
||||
}
|
||||
|
||||
async bulkDeleteModels(filePaths) {
|
||||
if (!filePaths || filePaths.length === 0) {
|
||||
throw new Error('No file paths provided');
|
||||
}
|
||||
|
||||
const recipeIds = filePaths
|
||||
.map((path) => extractRecipeId(path))
|
||||
.filter((id) => !!id);
|
||||
|
||||
if (recipeIds.length === 0) {
|
||||
throw new Error('No recipe IDs could be derived from file paths');
|
||||
}
|
||||
|
||||
try {
|
||||
state.loadingManager?.showSimpleLoading('Deleting recipes...');
|
||||
|
||||
const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
recipe_ids: recipeIds,
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (!response.ok || !result.success) {
|
||||
throw new Error(result.error || 'Failed to delete recipes');
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deleted_count: result.total_deleted,
|
||||
failed_count: result.total_failed || 0,
|
||||
errors: result.failed || [],
|
||||
};
|
||||
} finally {
|
||||
state.loadingManager?.hide();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHe
|
||||
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||
import { updateRecipeMetadata } from '../../api/recipeApi.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { moveManager } from '../../managers/MoveManager.js';
|
||||
|
||||
export class RecipeContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
@@ -77,6 +78,9 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
// Share recipe
|
||||
this.currentCard.querySelector('.fa-share-alt')?.click();
|
||||
break;
|
||||
case 'move':
|
||||
moveManager.showMoveModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'delete':
|
||||
// Delete recipe
|
||||
this.currentCard.querySelector('.fa-trash')?.click();
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// Recipe Card Component
|
||||
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
|
||||
import { updateRecipeMetadata } from '../api/recipeApi.js';
|
||||
import { configureModelCardVideo } from './shared/ModelCard.js';
|
||||
import { modalManager } from '../managers/ModalManager.js';
|
||||
import { getCurrentPageState } from '../state/index.js';
|
||||
import { state } from '../state/index.js';
|
||||
import { bulkManager } from '../managers/BulkManager.js';
|
||||
import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js';
|
||||
|
||||
class RecipeCard {
|
||||
@@ -43,8 +45,11 @@ class RecipeCard {
|
||||
(this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` :
|
||||
'/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
|
||||
const autoplayOnHover = state.settings.autoplay_on_hover || false;
|
||||
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
|
||||
const videoAttrs = [
|
||||
'controls',
|
||||
@@ -59,10 +64,6 @@ class RecipeCard {
|
||||
videoAttrs.push('data-autoplay="true"');
|
||||
}
|
||||
|
||||
// Check if in duplicates mode
|
||||
const pageState = getCurrentPageState();
|
||||
const isDuplicatesMode = pageState.duplicatesMode;
|
||||
|
||||
// NSFW blur logic - similar to LoraCard
|
||||
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;
|
||||
@@ -95,6 +96,7 @@ class RecipeCard {
|
||||
</button>` : ''}
|
||||
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${baseModelLabel}">${baseModelDisplay}</span>
|
||||
<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-paper-plane" title="Send Recipe to Workflow (Click: Append, Shift+Click: Replace)"></i>
|
||||
<i class="fas fa-trash" title="Delete Recipe"></i>
|
||||
@@ -140,6 +142,67 @@ class RecipeCard {
|
||||
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) {
|
||||
// Add blur toggle functionality if content should be blurred
|
||||
if (shouldBlur) {
|
||||
@@ -164,9 +227,19 @@ class RecipeCard {
|
||||
// Recipe card click event - only attach if not in duplicates mode
|
||||
if (!isDuplicatesMode) {
|
||||
card.addEventListener('click', () => {
|
||||
if (state.bulkMode) {
|
||||
bulkManager.toggleCardSelection(card);
|
||||
return;
|
||||
}
|
||||
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
|
||||
card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
|
||||
@@ -77,7 +77,9 @@ export class SidebarManager {
|
||||
this.pageControls = pageControls;
|
||||
this.pageType = pageControls.pageType;
|
||||
this.lastPageControls = pageControls;
|
||||
this.apiClient = getModelApiClient();
|
||||
this.apiClient = pageControls?.getSidebarApiClient?.()
|
||||
|| pageControls?.sidebarApiClient
|
||||
|| getModelApiClient();
|
||||
|
||||
// Set initial sidebar state immediately (hidden by default)
|
||||
this.setInitialSidebarState();
|
||||
@@ -205,6 +207,10 @@ export class SidebarManager {
|
||||
}
|
||||
|
||||
initializeDragAndDrop() {
|
||||
if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.dragHandlersInitialized) {
|
||||
document.addEventListener('dragstart', this.handleCardDragStart);
|
||||
document.addEventListener('dragend', this.handleCardDragEnd);
|
||||
@@ -416,7 +422,14 @@ export class SidebarManager {
|
||||
}
|
||||
|
||||
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, '/') : '';
|
||||
@@ -470,7 +483,9 @@ export class SidebarManager {
|
||||
}
|
||||
|
||||
async init() {
|
||||
this.apiClient = getModelApiClient();
|
||||
this.apiClient = this.pageControls?.getSidebarApiClient?.()
|
||||
|| this.pageControls?.sidebarApiClient
|
||||
|| getModelApiClient();
|
||||
|
||||
// Set initial sidebar state immediately (hidden by default)
|
||||
this.setInitialSidebarState();
|
||||
|
||||
@@ -60,14 +60,12 @@ export class AppCore {
|
||||
initTheme();
|
||||
initBackToTop();
|
||||
|
||||
// Initialize the bulk manager and context menu only if not on recipes page
|
||||
if (state.currentPageType !== 'recipes') {
|
||||
bulkManager.initialize();
|
||||
// Initialize the bulk manager and context menu
|
||||
bulkManager.initialize();
|
||||
|
||||
// Initialize bulk context menu
|
||||
const bulkContextMenu = new BulkContextMenu();
|
||||
bulkManager.setBulkContextMenu(bulkContextMenu);
|
||||
}
|
||||
// Initialize bulk context menu
|
||||
const bulkContextMenu = new BulkContextMenu();
|
||||
bulkManager.setBulkContextMenu(bulkContextMenu);
|
||||
|
||||
// Initialize the example images manager
|
||||
exampleImagesManager.initialize();
|
||||
@@ -121,4 +119,4 @@ export class AppCore {
|
||||
}
|
||||
|
||||
// Create and export a singleton instance
|
||||
export const appCore = new AppCore();
|
||||
export const appCore = new AppCore();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax, getNSF
|
||||
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
|
||||
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
|
||||
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
|
||||
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
|
||||
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
|
||||
@@ -62,9 +63,22 @@ export class BulkManager {
|
||||
autoOrganize: true,
|
||||
deleteAll: 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', () => {
|
||||
const container = document.querySelector('#bulkAddTagsModal .metadata-suggestions-container');
|
||||
if (!container) {
|
||||
@@ -87,9 +101,6 @@ export class BulkManager {
|
||||
}
|
||||
|
||||
initialize() {
|
||||
// Do not initialize on recipes page
|
||||
if (state.currentPageType === 'recipes') return;
|
||||
|
||||
// Register with event manager for coordinated event handling
|
||||
this.registerEventHandlers();
|
||||
|
||||
@@ -97,6 +108,23 @@ export class BulkManager {
|
||||
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) {
|
||||
this.bulkContextMenu = bulkContextMenu;
|
||||
}
|
||||
@@ -240,7 +268,9 @@ export class BulkManager {
|
||||
// Update event manager state
|
||||
eventManager.setState('bulkMode', state.bulkMode);
|
||||
|
||||
this.bulkBtn.classList.toggle('active', state.bulkMode);
|
||||
if (this.bulkBtn) {
|
||||
this.bulkBtn.classList.toggle('active', state.bulkMode);
|
||||
}
|
||||
|
||||
updateCardsForBulkMode(state.bulkMode);
|
||||
|
||||
@@ -504,13 +534,13 @@ export class BulkManager {
|
||||
modalManager.closeModal('bulkDeleteModal');
|
||||
|
||||
try {
|
||||
const apiClient = getModelApiClient();
|
||||
const apiClient = this.getActiveApiClient();
|
||||
const filePaths = Array.from(state.selectedModels);
|
||||
|
||||
const result = await apiClient.bulkDeleteModels(filePaths);
|
||||
|
||||
if (result.success) {
|
||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
||||
const currentConfig = this.getCurrentDisplayConfig();
|
||||
showToast('toast.models.deletedSuccessfully', {
|
||||
count: result.deleted_count,
|
||||
type: currentConfig.displayName.toLowerCase()
|
||||
@@ -570,7 +600,7 @@ export class BulkManager {
|
||||
this.applySelectionState();
|
||||
|
||||
const newlySelected = state.selectedModels.size - oldCount;
|
||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
||||
const currentConfig = this.getCurrentDisplayConfig();
|
||||
showToast('toast.models.selectedAdditional', {
|
||||
count: newlySelected,
|
||||
type: currentConfig.displayName.toLowerCase()
|
||||
@@ -622,8 +652,7 @@ export class BulkManager {
|
||||
return;
|
||||
}
|
||||
|
||||
const currentType = state.currentPageType;
|
||||
const currentConfig = MODEL_CONFIG[currentType] || MODEL_CONFIG[MODEL_TYPES.LORA];
|
||||
const currentConfig = this.getCurrentDisplayConfig();
|
||||
const typeLabel = (currentConfig?.displayName || 'Model').toLowerCase();
|
||||
|
||||
const { ids: modelIds, missingCount } = this.collectSelectedModelIds();
|
||||
@@ -969,7 +998,7 @@ export class BulkManager {
|
||||
modalManager.closeModal('bulkAddTagsModal');
|
||||
|
||||
if (successCount > 0) {
|
||||
const currentConfig = MODEL_CONFIG[state.currentPageType];
|
||||
const currentConfig = this.getCurrentDisplayConfig();
|
||||
const toastKey = mode === 'replace' ? 'toast.models.tagsReplacedSuccessfully' : 'toast.models.tagsAddedSuccessfully';
|
||||
showToast(toastKey, {
|
||||
count: successCount,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { bulkManager } from './BulkManager.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
|
||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||
import { sidebarManager } from '../components/SidebarManager.js';
|
||||
|
||||
@@ -12,11 +13,22 @@ class MoveManager {
|
||||
this.bulkFilePaths = null;
|
||||
this.folderTreeManager = new FolderTreeManager();
|
||||
this.initialized = false;
|
||||
this.recipeApiClient = null;
|
||||
|
||||
// Bind methods
|
||||
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() {
|
||||
if (this.initialized) return;
|
||||
|
||||
@@ -36,7 +48,7 @@ class MoveManager {
|
||||
this.currentFilePath = null;
|
||||
this.bulkFilePaths = null;
|
||||
|
||||
const apiClient = getModelApiClient();
|
||||
const apiClient = this._getApiClient(modelType);
|
||||
const currentPageType = state.currentPageType;
|
||||
const modelConfig = apiClient.apiConfig.config;
|
||||
|
||||
@@ -121,7 +133,7 @@ class MoveManager {
|
||||
|
||||
async initializeFolderTree() {
|
||||
try {
|
||||
const apiClient = getModelApiClient();
|
||||
const apiClient = this._getApiClient();
|
||||
// Fetch unified folder tree
|
||||
const treeData = await apiClient.fetchUnifiedFolderTree();
|
||||
|
||||
@@ -141,7 +153,7 @@ class MoveManager {
|
||||
updateTargetPath() {
|
||||
const pathDisplay = document.getElementById('moveTargetPathDisplay');
|
||||
const modelRoot = document.getElementById('moveModelRoot').value;
|
||||
const apiClient = getModelApiClient();
|
||||
const apiClient = this._getApiClient();
|
||||
const config = apiClient.apiConfig.config;
|
||||
|
||||
let fullPath = modelRoot || `Select a ${config.displayName.toLowerCase()} root directory`;
|
||||
@@ -158,7 +170,7 @@ class MoveManager {
|
||||
|
||||
async moveModel() {
|
||||
const selectedRoot = document.getElementById('moveModelRoot').value;
|
||||
const apiClient = getModelApiClient();
|
||||
const apiClient = this._getApiClient();
|
||||
const config = apiClient.apiConfig.config;
|
||||
|
||||
if (!selectedRoot) {
|
||||
|
||||
@@ -2,31 +2,60 @@
|
||||
import { appCore } from './core.js';
|
||||
import { ImportManager } from './managers/ImportManager.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 { RecipeContextMenu } from './components/ContextMenu/index.js';
|
||||
import { DuplicatesManager } from './components/DuplicatesManager.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 {
|
||||
constructor() {
|
||||
// Get page state
|
||||
this.pageState = getCurrentPageState();
|
||||
|
||||
|
||||
// Page controls for shared sidebar behaviors
|
||||
this.pageControls = new RecipePageControls();
|
||||
|
||||
// Initialize ImportManager
|
||||
this.importManager = new ImportManager();
|
||||
|
||||
|
||||
// Initialize RecipeModal
|
||||
this.recipeModal = new RecipeModal();
|
||||
|
||||
|
||||
// Initialize DuplicatesManager
|
||||
this.duplicatesManager = new DuplicatesManager(this);
|
||||
|
||||
|
||||
// Add state tracking for infinite scroll
|
||||
this.pageState.isLoading = false;
|
||||
this.pageState.hasMore = true;
|
||||
|
||||
|
||||
// Custom filter state - move to pageState for compatibility with virtual scrolling
|
||||
this.pageState.customFilter = {
|
||||
active: false,
|
||||
@@ -35,27 +64,40 @@ class RecipeManager {
|
||||
recipeId: null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
async initialize() {
|
||||
// Initialize event listeners
|
||||
this.initEventListeners();
|
||||
|
||||
|
||||
// Set default search options if not already defined
|
||||
this._initSearchOptions();
|
||||
|
||||
|
||||
// Initialize context menu
|
||||
new RecipeContextMenu();
|
||||
|
||||
|
||||
// Check for custom filter parameters in session storage
|
||||
this._checkCustomFilter();
|
||||
|
||||
|
||||
// Expose necessary functions to the page
|
||||
this._exposeGlobalFunctions();
|
||||
|
||||
|
||||
// Initialize sidebar navigation
|
||||
await this._initSidebar();
|
||||
|
||||
// Initialize common page features
|
||||
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() {
|
||||
// Ensure recipes search options are properly initialized
|
||||
if (!this.pageState.searchOptions) {
|
||||
@@ -63,25 +105,27 @@ class RecipeManager {
|
||||
title: true, // Recipe title
|
||||
tags: true, // Recipe tags
|
||||
loraName: true, // LoRA file name
|
||||
loraModel: true // LoRA model name
|
||||
loraModel: true, // LoRA model name
|
||||
prompt: true, // Prompt search
|
||||
recursive: true
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_exposeGlobalFunctions() {
|
||||
// Only expose what's needed for the page
|
||||
window.recipeManager = this;
|
||||
window.importManager = this.importManager;
|
||||
}
|
||||
|
||||
|
||||
_checkCustomFilter() {
|
||||
// Check for Lora filter
|
||||
const filterLoraName = getSessionItem('lora_to_recipe_filterLoraName');
|
||||
const filterLoraHash = getSessionItem('lora_to_recipe_filterLoraHash');
|
||||
|
||||
|
||||
// Check for specific recipe ID
|
||||
const viewRecipeId = getSessionItem('viewRecipeId');
|
||||
|
||||
|
||||
// Set custom filter if any parameter is present
|
||||
if (filterLoraName || filterLoraHash || viewRecipeId) {
|
||||
this.pageState.customFilter = {
|
||||
@@ -90,35 +134,35 @@ class RecipeManager {
|
||||
loraHash: filterLoraHash,
|
||||
recipeId: viewRecipeId
|
||||
};
|
||||
|
||||
|
||||
// Show custom filter indicator
|
||||
this._showCustomFilterIndicator();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_showCustomFilterIndicator() {
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
const textElement = document.getElementById('customFilterText');
|
||||
|
||||
|
||||
if (!indicator || !textElement) return;
|
||||
|
||||
|
||||
// Update text based on filter type
|
||||
let filterText = '';
|
||||
|
||||
|
||||
if (this.pageState.customFilter.recipeId) {
|
||||
filterText = 'Viewing specific recipe';
|
||||
} else if (this.pageState.customFilter.loraName) {
|
||||
// Format with Lora name
|
||||
const loraName = this.pageState.customFilter.loraName;
|
||||
const displayName = loraName.length > 25 ?
|
||||
loraName.substring(0, 22) + '...' :
|
||||
const displayName = loraName.length > 25 ?
|
||||
loraName.substring(0, 22) + '...' :
|
||||
loraName;
|
||||
|
||||
|
||||
filterText = `<span>Recipes using: <span class="lora-name">${displayName}</span></span>`;
|
||||
} else {
|
||||
filterText = 'Filtered recipes';
|
||||
}
|
||||
|
||||
|
||||
// Update indicator text and show it
|
||||
textElement.innerHTML = filterText;
|
||||
// Add title attribute to show the lora name as a tooltip
|
||||
@@ -126,14 +170,14 @@ class RecipeManager {
|
||||
textElement.setAttribute('title', this.pageState.customFilter.loraName);
|
||||
}
|
||||
indicator.classList.remove('hidden');
|
||||
|
||||
|
||||
// Add pulse animation
|
||||
const filterElement = indicator.querySelector('.filter-active');
|
||||
if (filterElement) {
|
||||
filterElement.classList.add('animate');
|
||||
setTimeout(() => filterElement.classList.remove('animate'), 600);
|
||||
}
|
||||
|
||||
|
||||
// Add click handler for clear filter button
|
||||
const clearFilterBtn = indicator.querySelector('.clear-filter');
|
||||
if (clearFilterBtn) {
|
||||
@@ -143,7 +187,7 @@ class RecipeManager {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
_clearCustomFilter() {
|
||||
// Reset custom filter
|
||||
this.pageState.customFilter = {
|
||||
@@ -152,33 +196,48 @@ class RecipeManager {
|
||||
loraHash: null,
|
||||
recipeId: null
|
||||
};
|
||||
|
||||
|
||||
// Hide indicator
|
||||
const indicator = document.getElementById('customFilterIndicator');
|
||||
if (indicator) {
|
||||
indicator.classList.add('hidden');
|
||||
}
|
||||
|
||||
|
||||
// Clear any session storage items
|
||||
removeSessionItem('lora_to_recipe_filterLoraName');
|
||||
removeSessionItem('lora_to_recipe_filterLoraHash');
|
||||
removeSessionItem('viewRecipeId');
|
||||
|
||||
|
||||
// Reset and refresh the virtual scroller
|
||||
refreshVirtualScroll();
|
||||
}
|
||||
|
||||
|
||||
initEventListeners() {
|
||||
// Sort select
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
if (sortSelect) {
|
||||
sortSelect.value = this.pageState.sortBy || 'date:desc';
|
||||
sortSelect.addEventListener('change', () => {
|
||||
this.pageState.sortBy = sortSelect.value;
|
||||
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
|
||||
async loadRecipes(resetPage = true) {
|
||||
// Skip loading if in duplicates mode
|
||||
@@ -186,32 +245,32 @@ class RecipeManager {
|
||||
if (pageState.duplicatesMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
if (resetPage) {
|
||||
refreshVirtualScroll();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Refreshes the recipe list by first rebuilding the cache and then loading recipes
|
||||
*/
|
||||
async refreshRecipes() {
|
||||
return refreshRecipes();
|
||||
}
|
||||
|
||||
|
||||
showRecipeDetails(recipe) {
|
||||
this.recipeModal.showRecipeDetails(recipe);
|
||||
}
|
||||
|
||||
|
||||
// Duplicate detection and management methods
|
||||
async findDuplicateRecipes() {
|
||||
return await this.duplicatesManager.findDuplicates();
|
||||
}
|
||||
|
||||
|
||||
selectLatestDuplicates() {
|
||||
this.duplicatesManager.selectLatestDuplicates();
|
||||
}
|
||||
|
||||
|
||||
deleteSelectedDuplicates() {
|
||||
this.duplicatesManager.deleteSelectedDuplicates();
|
||||
}
|
||||
@@ -219,14 +278,14 @@ class RecipeManager {
|
||||
confirmDeleteDuplicates() {
|
||||
this.duplicatesManager.confirmDeleteDuplicates();
|
||||
}
|
||||
|
||||
|
||||
exitDuplicateMode() {
|
||||
// Clear the grid first to prevent showing old content temporarily
|
||||
const recipeGrid = document.getElementById('recipeGrid');
|
||||
if (recipeGrid) {
|
||||
recipeGrid.innerHTML = '';
|
||||
}
|
||||
|
||||
|
||||
this.duplicatesManager.exitDuplicateMode();
|
||||
}
|
||||
}
|
||||
@@ -235,11 +294,11 @@ class RecipeManager {
|
||||
document.addEventListener('DOMContentLoaded', async () => {
|
||||
// Initialize core application
|
||||
await appCore.initialize();
|
||||
|
||||
|
||||
// Initialize recipe manager
|
||||
const recipeManager = new RecipeManager();
|
||||
await recipeManager.initialize();
|
||||
});
|
||||
|
||||
// Export for use in other modules
|
||||
export { RecipeManager };
|
||||
export { RecipeManager };
|
||||
|
||||
@@ -58,7 +58,7 @@ export const state = {
|
||||
loadingManager: null,
|
||||
observer: null,
|
||||
},
|
||||
|
||||
|
||||
// Page-specific states
|
||||
pages: {
|
||||
[MODEL_TYPES.LORA]: {
|
||||
@@ -69,20 +69,20 @@ export const state = {
|
||||
activeFolder: getStorageItem(`${MODEL_TYPES.LORA}_activeFolder`),
|
||||
activeLetterFilter: null,
|
||||
previewVersions: loraPreviewVersions,
|
||||
searchManager: null,
|
||||
searchOptions: {
|
||||
filename: true,
|
||||
modelname: true,
|
||||
tags: false,
|
||||
creator: false,
|
||||
recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true),
|
||||
},
|
||||
filters: {
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: []
|
||||
},
|
||||
searchManager: null,
|
||||
searchOptions: {
|
||||
filename: true,
|
||||
modelname: true,
|
||||
tags: false,
|
||||
creator: false,
|
||||
recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true),
|
||||
},
|
||||
filters: {
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: []
|
||||
},
|
||||
bulkMode: false,
|
||||
selectedLoras: new Set(),
|
||||
loraMetadataCache: new Map(),
|
||||
@@ -90,33 +90,35 @@ export const state = {
|
||||
showUpdateAvailableOnly: false,
|
||||
duplicatesMode: false,
|
||||
},
|
||||
|
||||
|
||||
recipes: {
|
||||
currentPage: 1,
|
||||
isLoading: false,
|
||||
hasMore: true,
|
||||
sortBy: 'date',
|
||||
searchManager: null,
|
||||
searchOptions: {
|
||||
title: true,
|
||||
tags: true,
|
||||
loraName: true,
|
||||
loraModel: true
|
||||
},
|
||||
filters: {
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: [],
|
||||
search: ''
|
||||
},
|
||||
sortBy: 'date:desc',
|
||||
activeFolder: getStorageItem('recipes_activeFolder'),
|
||||
searchManager: null,
|
||||
searchOptions: {
|
||||
title: true,
|
||||
tags: true,
|
||||
loraName: true,
|
||||
loraModel: true,
|
||||
recursive: getStorageItem('recipes_recursiveSearch', true),
|
||||
},
|
||||
filters: {
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: [],
|
||||
search: ''
|
||||
},
|
||||
pageSize: 20,
|
||||
showFavoritesOnly: false,
|
||||
duplicatesMode: false,
|
||||
bulkMode: false,
|
||||
selectedModels: new Set(),
|
||||
},
|
||||
|
||||
|
||||
[MODEL_TYPES.CHECKPOINT]: {
|
||||
currentPage: 1,
|
||||
isLoading: false,
|
||||
@@ -124,19 +126,19 @@ export const state = {
|
||||
sortBy: 'name',
|
||||
activeFolder: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_activeFolder`),
|
||||
previewVersions: checkpointPreviewVersions,
|
||||
searchManager: null,
|
||||
searchOptions: {
|
||||
filename: true,
|
||||
modelname: true,
|
||||
creator: false,
|
||||
recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true),
|
||||
},
|
||||
filters: {
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: []
|
||||
},
|
||||
searchManager: null,
|
||||
searchOptions: {
|
||||
filename: true,
|
||||
modelname: true,
|
||||
creator: false,
|
||||
recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true),
|
||||
},
|
||||
filters: {
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: []
|
||||
},
|
||||
modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model'
|
||||
bulkMode: false,
|
||||
selectedModels: new Set(),
|
||||
@@ -145,7 +147,7 @@ export const state = {
|
||||
showUpdateAvailableOnly: false,
|
||||
duplicatesMode: false,
|
||||
},
|
||||
|
||||
|
||||
[MODEL_TYPES.EMBEDDING]: {
|
||||
currentPage: 1,
|
||||
isLoading: false,
|
||||
@@ -154,20 +156,20 @@ export const state = {
|
||||
activeFolder: getStorageItem(`${MODEL_TYPES.EMBEDDING}_activeFolder`),
|
||||
activeLetterFilter: null,
|
||||
previewVersions: embeddingPreviewVersions,
|
||||
searchManager: null,
|
||||
searchOptions: {
|
||||
filename: true,
|
||||
modelname: true,
|
||||
tags: false,
|
||||
creator: false,
|
||||
recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true),
|
||||
},
|
||||
filters: {
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: []
|
||||
},
|
||||
searchManager: null,
|
||||
searchOptions: {
|
||||
filename: true,
|
||||
modelname: true,
|
||||
tags: false,
|
||||
creator: false,
|
||||
recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true),
|
||||
},
|
||||
filters: {
|
||||
baseModel: [],
|
||||
tags: {},
|
||||
license: {},
|
||||
modelTypes: []
|
||||
},
|
||||
bulkMode: false,
|
||||
selectedModels: new Set(),
|
||||
metadataCache: new Map(),
|
||||
@@ -176,45 +178,45 @@ export const state = {
|
||||
duplicatesMode: false,
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
// Current active page - use MODEL_TYPES constants
|
||||
currentPageType: MODEL_TYPES.LORA,
|
||||
|
||||
|
||||
// Backward compatibility - proxy properties
|
||||
get currentPage() { return this.pages[this.currentPageType].currentPage; },
|
||||
set currentPage(value) { this.pages[this.currentPageType].currentPage = value; },
|
||||
|
||||
|
||||
get isLoading() { return this.pages[this.currentPageType].isLoading; },
|
||||
set isLoading(value) { this.pages[this.currentPageType].isLoading = value; },
|
||||
|
||||
|
||||
get hasMore() { return this.pages[this.currentPageType].hasMore; },
|
||||
set hasMore(value) { this.pages[this.currentPageType].hasMore = value; },
|
||||
|
||||
|
||||
get sortBy() { return this.pages[this.currentPageType].sortBy; },
|
||||
set sortBy(value) { this.pages[this.currentPageType].sortBy = value; },
|
||||
|
||||
|
||||
get activeFolder() { return this.pages[this.currentPageType].activeFolder; },
|
||||
set activeFolder(value) { this.pages[this.currentPageType].activeFolder = value; },
|
||||
|
||||
|
||||
get loadingManager() { return this.global.loadingManager; },
|
||||
set loadingManager(value) { this.global.loadingManager = value; },
|
||||
|
||||
|
||||
get observer() { return this.global.observer; },
|
||||
set observer(value) { this.global.observer = value; },
|
||||
|
||||
|
||||
get previewVersions() { return this.pages.loras.previewVersions; },
|
||||
set previewVersions(value) { this.pages.loras.previewVersions = value; },
|
||||
|
||||
|
||||
get searchManager() { return this.pages[this.currentPageType].searchManager; },
|
||||
set searchManager(value) { this.pages[this.currentPageType].searchManager = value; },
|
||||
|
||||
|
||||
get searchOptions() { return this.pages[this.currentPageType].searchOptions; },
|
||||
set searchOptions(value) { this.pages[this.currentPageType].searchOptions = value; },
|
||||
|
||||
|
||||
get filters() { return this.pages[this.currentPageType].filters; },
|
||||
set filters(value) { this.pages[this.currentPageType].filters = value; },
|
||||
|
||||
get bulkMode() {
|
||||
|
||||
get bulkMode() {
|
||||
const currentType = this.currentPageType;
|
||||
if (currentType === MODEL_TYPES.LORA) {
|
||||
return this.pages.loras.bulkMode;
|
||||
@@ -222,7 +224,7 @@ export const state = {
|
||||
return this.pages[currentType].bulkMode;
|
||||
}
|
||||
},
|
||||
set bulkMode(value) {
|
||||
set bulkMode(value) {
|
||||
const currentType = this.currentPageType;
|
||||
if (currentType === MODEL_TYPES.LORA) {
|
||||
this.pages.loras.bulkMode = value;
|
||||
@@ -230,11 +232,11 @@ export const state = {
|
||||
this.pages[currentType].bulkMode = value;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
get selectedLoras() { return this.pages.loras.selectedLoras; },
|
||||
set selectedLoras(value) { this.pages.loras.selectedLoras = value; },
|
||||
|
||||
get selectedModels() {
|
||||
|
||||
get selectedModels() {
|
||||
const currentType = this.currentPageType;
|
||||
if (currentType === MODEL_TYPES.LORA) {
|
||||
return this.pages.loras.selectedLoras;
|
||||
@@ -242,7 +244,7 @@ export const state = {
|
||||
return this.pages[currentType].selectedModels;
|
||||
}
|
||||
},
|
||||
set selectedModels(value) {
|
||||
set selectedModels(value) {
|
||||
const currentType = this.currentPageType;
|
||||
if (currentType === MODEL_TYPES.LORA) {
|
||||
this.pages.loras.selectedLoras = value;
|
||||
@@ -250,10 +252,10 @@ export const state = {
|
||||
this.pages[currentType].selectedModels = value;
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
get loraMetadataCache() { return this.pages.loras.loraMetadataCache; },
|
||||
set loraMetadataCache(value) { this.pages.loras.loraMetadataCache = value; },
|
||||
|
||||
|
||||
get settings() { return this.global.settings; },
|
||||
set settings(value) { this.global.settings = value; }
|
||||
};
|
||||
|
||||
@@ -12,13 +12,13 @@ export class VirtualScroller {
|
||||
this.scrollContainer = options.scrollContainer || this.containerElement;
|
||||
this.batchSize = options.batchSize || 50;
|
||||
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)
|
||||
|
||||
|
||||
// Add container padding properties
|
||||
this.containerPaddingTop = options.containerPaddingTop || 4; // Default top padding from CSS
|
||||
this.containerPaddingBottom = options.containerPaddingBottom || 4; // Default bottom padding from CSS
|
||||
|
||||
|
||||
// Add data windowing enable/disable flag
|
||||
this.enableDataWindowing = options.enableDataWindowing !== undefined ? options.enableDataWindowing : false;
|
||||
|
||||
@@ -73,15 +73,15 @@ export class VirtualScroller {
|
||||
this.spacerElement.style.width = '100%';
|
||||
this.spacerElement.style.height = '0px'; // Will be updated as items are loaded
|
||||
this.spacerElement.style.pointerEvents = 'none';
|
||||
|
||||
|
||||
// The grid will be used for the actual visible items
|
||||
this.gridElement.style.position = 'relative';
|
||||
this.gridElement.style.minHeight = '0';
|
||||
|
||||
|
||||
// Apply padding directly to ensure consistency
|
||||
this.gridElement.style.paddingTop = `${this.containerPaddingTop}px`;
|
||||
this.gridElement.style.paddingBottom = `${this.containerPaddingBottom}px`;
|
||||
|
||||
|
||||
// Place the spacer inside the grid container
|
||||
this.gridElement.appendChild(this.spacerElement);
|
||||
}
|
||||
@@ -97,16 +97,16 @@ export class VirtualScroller {
|
||||
const containerStyle = getComputedStyle(this.containerElement);
|
||||
const paddingLeft = parseInt(containerStyle.paddingLeft, 10) || 0;
|
||||
const paddingRight = parseInt(containerStyle.paddingRight, 10) || 0;
|
||||
|
||||
|
||||
// Calculate available content width (excluding padding)
|
||||
const availableContentWidth = containerWidth - paddingLeft - paddingRight;
|
||||
|
||||
|
||||
// Get display density setting
|
||||
const displayDensity = state.global.settings?.display_density || 'default';
|
||||
|
||||
|
||||
// Set exact column counts and grid widths to match CSS container widths
|
||||
let maxColumns, maxGridWidth;
|
||||
|
||||
|
||||
// Match exact column counts and CSS container width values based on density
|
||||
if (window.innerWidth >= 3000) { // 4K
|
||||
if (displayDensity === 'default') {
|
||||
@@ -137,17 +137,17 @@ export class VirtualScroller {
|
||||
}
|
||||
maxGridWidth = 1400; // Match exact CSS container width for 1080p
|
||||
}
|
||||
|
||||
|
||||
// Calculate baseCardWidth based on desired column count and available space
|
||||
// Formula: (maxGridWidth - (columns-1)*gap) / columns
|
||||
const baseCardWidth = (maxGridWidth - ((maxColumns - 1) * this.columnGap)) / maxColumns;
|
||||
|
||||
|
||||
// Use the smaller of available content width or max grid width
|
||||
const actualGridWidth = Math.min(availableContentWidth, maxGridWidth);
|
||||
|
||||
|
||||
// Set exact column count based on screen size and mode
|
||||
this.columnsCount = maxColumns;
|
||||
|
||||
|
||||
// When available width is smaller than maxGridWidth, recalculate columns
|
||||
if (availableContentWidth < maxGridWidth) {
|
||||
// Calculate how many columns can fit in the available space
|
||||
@@ -155,30 +155,30 @@ export class VirtualScroller {
|
||||
(availableContentWidth + this.columnGap) / (baseCardWidth + this.columnGap)
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
// Calculate actual item width
|
||||
this.itemWidth = (actualGridWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount;
|
||||
|
||||
|
||||
// Calculate height based on aspect ratio
|
||||
this.itemHeight = this.itemWidth / this.itemAspectRatio;
|
||||
|
||||
|
||||
// Calculate the left offset to center the grid within the content area
|
||||
this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2);
|
||||
|
||||
// Update grid element max-width to match available width
|
||||
this.gridElement.style.maxWidth = `${actualGridWidth}px`;
|
||||
|
||||
|
||||
// Add or remove density classes for style adjustments
|
||||
this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density');
|
||||
this.gridElement.classList.add(`${displayDensity}-density`);
|
||||
|
||||
|
||||
// Update spacer height
|
||||
this.updateSpacerHeight();
|
||||
|
||||
|
||||
// Re-render with new layout
|
||||
this.clearRenderedItems();
|
||||
this.scheduleRender();
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -186,20 +186,20 @@ export class VirtualScroller {
|
||||
// Debounced scroll handler
|
||||
this.scrollHandler = this.debounce(() => this.handleScroll(), 10);
|
||||
this.scrollContainer.addEventListener('scroll', this.scrollHandler);
|
||||
|
||||
|
||||
// Window resize handler for layout recalculation
|
||||
this.resizeHandler = this.debounce(() => {
|
||||
this.calculateLayout();
|
||||
}, 150);
|
||||
|
||||
|
||||
window.addEventListener('resize', this.resizeHandler);
|
||||
|
||||
|
||||
// Use ResizeObserver for more accurate container size detection
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
this.resizeObserver = new ResizeObserver(this.debounce(() => {
|
||||
this.calculateLayout();
|
||||
}, 150));
|
||||
|
||||
|
||||
this.resizeObserver.observe(this.containerElement);
|
||||
}
|
||||
}
|
||||
@@ -217,35 +217,35 @@ export class VirtualScroller {
|
||||
async loadInitialBatch() {
|
||||
const pageState = getCurrentPageState();
|
||||
if (this.isLoading) return;
|
||||
|
||||
|
||||
this.isLoading = true;
|
||||
this.setLoadingTimeout(); // Add loading timeout safety
|
||||
|
||||
|
||||
try {
|
||||
const { items, totalItems, hasMore } = await this.fetchItemsFn(1, this.pageSize);
|
||||
|
||||
|
||||
// Initialize the data window with the first batch of items
|
||||
this.items = items || [];
|
||||
this.totalItems = totalItems || 0;
|
||||
this.hasMore = hasMore;
|
||||
this.dataWindow = { start: 0, end: this.items.length };
|
||||
this.absoluteWindowStart = 0;
|
||||
|
||||
|
||||
// Update the spacer height based on the total number of items
|
||||
this.updateSpacerHeight();
|
||||
|
||||
|
||||
// Check if there are no items and show placeholder if needed
|
||||
if (this.items.length === 0) {
|
||||
this.showNoItemsPlaceholder();
|
||||
} else {
|
||||
this.removeNoItemsPlaceholder();
|
||||
}
|
||||
|
||||
|
||||
// Reset page state to sync with our virtual scroller
|
||||
pageState.currentPage = 2; // Next page to load would be 2
|
||||
pageState.hasMore = this.hasMore;
|
||||
pageState.isLoading = false;
|
||||
|
||||
|
||||
return { items, totalItems, hasMore };
|
||||
} catch (err) {
|
||||
console.error('Failed to load initial batch:', err);
|
||||
@@ -260,36 +260,36 @@ export class VirtualScroller {
|
||||
async loadMoreItems() {
|
||||
const pageState = getCurrentPageState();
|
||||
if (this.isLoading || !this.hasMore) return;
|
||||
|
||||
|
||||
this.isLoading = true;
|
||||
pageState.isLoading = true;
|
||||
this.setLoadingTimeout(); // Add loading timeout safety
|
||||
|
||||
|
||||
try {
|
||||
console.log('Loading more items, page:', pageState.currentPage);
|
||||
const { items, hasMore } = await this.fetchItemsFn(pageState.currentPage, this.pageSize);
|
||||
|
||||
|
||||
if (items && items.length > 0) {
|
||||
this.items = [...this.items, ...items];
|
||||
this.hasMore = hasMore;
|
||||
pageState.hasMore = hasMore;
|
||||
|
||||
|
||||
// Update page for next request
|
||||
pageState.currentPage++;
|
||||
|
||||
|
||||
// Update the spacer height
|
||||
this.updateSpacerHeight();
|
||||
|
||||
|
||||
// Render the newly loaded items if they're in view
|
||||
this.scheduleRender();
|
||||
|
||||
|
||||
console.log(`Loaded ${items.length} more items, total now: ${this.items.length}`);
|
||||
} else {
|
||||
this.hasMore = false;
|
||||
pageState.hasMore = false;
|
||||
console.log('No more items to load');
|
||||
}
|
||||
|
||||
|
||||
return items;
|
||||
} catch (err) {
|
||||
console.error('Failed to load more items:', err);
|
||||
@@ -305,7 +305,7 @@ export class VirtualScroller {
|
||||
setLoadingTimeout() {
|
||||
// Clear any existing timeout first
|
||||
this.clearLoadingTimeout();
|
||||
|
||||
|
||||
// Set a new timeout to prevent loading state from getting stuck
|
||||
this.loadingTimeout = setTimeout(() => {
|
||||
if (this.isLoading) {
|
||||
@@ -326,15 +326,15 @@ export class VirtualScroller {
|
||||
|
||||
updateSpacerHeight() {
|
||||
if (this.columnsCount === 0) return;
|
||||
|
||||
|
||||
// Calculate total rows needed based on total items and columns
|
||||
const totalRows = Math.ceil(this.totalItems / this.columnsCount);
|
||||
// Add row gaps to the total height calculation
|
||||
const totalHeight = totalRows * this.itemHeight + (totalRows - 1) * this.rowGap;
|
||||
|
||||
|
||||
// Include container padding in the total height
|
||||
const spacerHeight = totalHeight + this.containerPaddingTop + this.containerPaddingBottom;
|
||||
|
||||
|
||||
// Update spacer height to represent all items
|
||||
this.spacerElement.style.height = `${spacerHeight}px`;
|
||||
}
|
||||
@@ -342,28 +342,28 @@ export class VirtualScroller {
|
||||
getVisibleRange() {
|
||||
const scrollTop = this.scrollContainer.scrollTop;
|
||||
const viewportHeight = this.scrollContainer.clientHeight;
|
||||
|
||||
|
||||
// Calculate the visible row range, accounting for row gaps
|
||||
const rowHeight = this.itemHeight + this.rowGap;
|
||||
const startRow = Math.floor(scrollTop / rowHeight);
|
||||
const endRow = Math.ceil((scrollTop + viewportHeight) / rowHeight);
|
||||
|
||||
|
||||
// Add overscan for smoother scrolling
|
||||
const overscanRows = this.overscan;
|
||||
const firstRow = Math.max(0, startRow - overscanRows);
|
||||
const lastRow = Math.min(Math.ceil(this.totalItems / this.columnsCount), endRow + overscanRows);
|
||||
|
||||
|
||||
// Calculate item indices
|
||||
const firstIndex = firstRow * this.columnsCount;
|
||||
const lastIndex = Math.min(this.totalItems, lastRow * this.columnsCount);
|
||||
|
||||
|
||||
return { start: firstIndex, end: lastIndex };
|
||||
}
|
||||
|
||||
// Update the scheduleRender method to check for disabled state
|
||||
scheduleRender() {
|
||||
if (this.disabled || this.renderScheduled) return;
|
||||
|
||||
|
||||
this.renderScheduled = true;
|
||||
requestAnimationFrame(() => {
|
||||
this.renderItems();
|
||||
@@ -374,25 +374,25 @@ export class VirtualScroller {
|
||||
// Update the renderItems method to check for disabled state
|
||||
renderItems() {
|
||||
if (this.disabled || this.items.length === 0 || this.columnsCount === 0) return;
|
||||
|
||||
|
||||
const { start, end } = this.getVisibleRange();
|
||||
|
||||
|
||||
// Check if render range has significantly changed
|
||||
const isSameRange =
|
||||
start >= this.lastRenderRange.start &&
|
||||
const isSameRange =
|
||||
start >= this.lastRenderRange.start &&
|
||||
end <= this.lastRenderRange.end &&
|
||||
Math.abs(start - this.lastRenderRange.start) < 10;
|
||||
|
||||
|
||||
if (isSameRange) return;
|
||||
|
||||
|
||||
this.lastRenderRange = { start, end };
|
||||
|
||||
|
||||
// Determine which items need to be added and removed
|
||||
const currentIndices = new Set();
|
||||
for (let i = start; i < end && i < this.items.length; i++) {
|
||||
currentIndices.add(i);
|
||||
}
|
||||
|
||||
|
||||
// Remove items that are no longer visible
|
||||
for (const [index, element] of this.renderedItems.entries()) {
|
||||
if (!currentIndices.has(index)) {
|
||||
@@ -400,10 +400,10 @@ export class VirtualScroller {
|
||||
this.renderedItems.delete(index);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Use DocumentFragment for batch DOM operations
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
|
||||
// Add new visible items to the fragment
|
||||
for (let i = start; i < end && i < this.items.length; i++) {
|
||||
if (!this.renderedItems.has(i)) {
|
||||
@@ -413,17 +413,17 @@ export class VirtualScroller {
|
||||
this.renderedItems.set(i, element);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Add the fragment to the grid (single DOM operation)
|
||||
if (fragment.childNodes.length > 0) {
|
||||
this.gridElement.appendChild(fragment);
|
||||
}
|
||||
|
||||
|
||||
// If we're close to the end and have more items to load, fetch them
|
||||
if (end > this.items.length - (this.columnsCount * 2) && this.hasMore && !this.isLoading) {
|
||||
this.loadMoreItems();
|
||||
}
|
||||
|
||||
|
||||
// Check if we need to slide the data window
|
||||
this.slideDataWindow();
|
||||
}
|
||||
@@ -439,14 +439,14 @@ export class VirtualScroller {
|
||||
this.totalItems = totalItems || 0;
|
||||
this.hasMore = hasMore;
|
||||
this.updateSpacerHeight();
|
||||
|
||||
|
||||
// Check if there are no items and show placeholder if needed
|
||||
if (this.items.length === 0) {
|
||||
this.showNoItemsPlaceholder();
|
||||
} else {
|
||||
this.removeNoItemsPlaceholder();
|
||||
}
|
||||
|
||||
|
||||
// Clear all rendered items and redraw
|
||||
this.clearRenderedItems();
|
||||
this.scheduleRender();
|
||||
@@ -455,29 +455,29 @@ export class VirtualScroller {
|
||||
createItemElement(item, index) {
|
||||
// Create the DOM element
|
||||
const element = this.createItemFn(item);
|
||||
|
||||
|
||||
// Add virtual scroll item class
|
||||
element.classList.add('virtual-scroll-item');
|
||||
|
||||
|
||||
// Calculate the position
|
||||
const row = Math.floor(index / this.columnsCount);
|
||||
const col = index % this.columnsCount;
|
||||
|
||||
|
||||
// Calculate precise positions with row gap included
|
||||
// Add the top padding to account for container padding
|
||||
const topPos = this.containerPaddingTop + (row * (this.itemHeight + this.rowGap));
|
||||
|
||||
|
||||
// Position correctly with leftOffset (no need to add padding as absolute
|
||||
// positioning is already relative to the padding edge of the container)
|
||||
const leftPos = this.leftOffset + (col * (this.itemWidth + this.columnGap));
|
||||
|
||||
|
||||
// Position the element with absolute positioning
|
||||
element.style.position = 'absolute';
|
||||
element.style.left = `${leftPos}px`;
|
||||
element.style.top = `${topPos}px`;
|
||||
element.style.width = `${this.itemWidth}px`;
|
||||
element.style.height = `${this.itemHeight}px`;
|
||||
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
@@ -486,17 +486,17 @@ export class VirtualScroller {
|
||||
const scrollTop = this.scrollContainer.scrollTop;
|
||||
this.scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up';
|
||||
this.lastScrollTop = scrollTop;
|
||||
|
||||
|
||||
// Handle large jumps in scroll position - check if we need to fetch a new window
|
||||
const { scrollHeight } = this.scrollContainer;
|
||||
const scrollRatio = scrollTop / scrollHeight;
|
||||
|
||||
|
||||
// Only perform data windowing if the feature is enabled
|
||||
if (this.enableDataWindowing && this.totalItems > this.windowSize) {
|
||||
const estimatedIndex = Math.floor(scrollRatio * this.totalItems);
|
||||
const currentWindowStart = this.absoluteWindowStart;
|
||||
const currentWindowEnd = currentWindowStart + this.items.length;
|
||||
|
||||
|
||||
// If the estimated position is outside our current window by a significant amount
|
||||
if (estimatedIndex < currentWindowStart || estimatedIndex > currentWindowEnd) {
|
||||
// Fetch a new data window centered on the estimated position
|
||||
@@ -504,14 +504,14 @@ export class VirtualScroller {
|
||||
return; // Skip normal rendering until new data is loaded
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Render visible items
|
||||
this.scheduleRender();
|
||||
|
||||
|
||||
// If we're near the bottom and have more items, load them
|
||||
const { clientHeight } = this.scrollContainer;
|
||||
const scrollBottom = scrollTop + clientHeight;
|
||||
|
||||
|
||||
// Fix the threshold calculation - use percentage of remaining height instead
|
||||
// We'll trigger loading when within 20% of the bottom of rendered content
|
||||
const remainingScroll = scrollHeight - scrollBottom;
|
||||
@@ -521,9 +521,9 @@ export class VirtualScroller {
|
||||
// Or when within 2 rows of content from the bottom, whichever is larger
|
||||
(this.itemHeight + this.rowGap) * 2
|
||||
);
|
||||
|
||||
|
||||
const shouldLoadMore = remainingScroll <= scrollThreshold;
|
||||
|
||||
|
||||
if (shouldLoadMore && this.hasMore && !this.isLoading) {
|
||||
this.loadMoreItems();
|
||||
}
|
||||
@@ -533,40 +533,40 @@ export class VirtualScroller {
|
||||
async fetchDataWindow(targetIndex) {
|
||||
// Skip if data windowing is disabled or already fetching
|
||||
if (!this.enableDataWindowing || this.fetchingWindow) return;
|
||||
|
||||
|
||||
this.fetchingWindow = true;
|
||||
|
||||
|
||||
try {
|
||||
// Calculate which page we need to fetch based on target index
|
||||
const targetPage = Math.floor(targetIndex / this.pageSize) + 1;
|
||||
console.log(`Fetching data window for index ${targetIndex}, page ${targetPage}`);
|
||||
|
||||
|
||||
const { items, totalItems, hasMore } = await this.fetchItemsFn(targetPage, this.pageSize);
|
||||
|
||||
|
||||
if (items && items.length > 0) {
|
||||
// Calculate new absolute window start
|
||||
this.absoluteWindowStart = (targetPage - 1) * this.pageSize;
|
||||
|
||||
|
||||
// Replace the entire data window with new items
|
||||
this.items = items;
|
||||
this.dataWindow = {
|
||||
this.dataWindow = {
|
||||
start: 0,
|
||||
end: items.length
|
||||
};
|
||||
|
||||
|
||||
this.totalItems = totalItems || 0;
|
||||
this.hasMore = hasMore;
|
||||
|
||||
|
||||
// Update the current page for future fetches
|
||||
const pageState = getCurrentPageState();
|
||||
pageState.currentPage = targetPage + 1;
|
||||
pageState.hasMore = hasMore;
|
||||
|
||||
|
||||
// Update the spacer height and clear current rendered items
|
||||
this.updateSpacerHeight();
|
||||
this.clearRenderedItems();
|
||||
this.scheduleRender();
|
||||
|
||||
|
||||
console.log(`Loaded ${items.length} items for window at absolute index ${this.absoluteWindowStart}`);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -581,37 +581,37 @@ export class VirtualScroller {
|
||||
async slideDataWindow() {
|
||||
// Skip if data windowing is disabled
|
||||
if (!this.enableDataWindowing) return;
|
||||
|
||||
|
||||
const { start, end } = this.getVisibleRange();
|
||||
const windowStart = this.dataWindow.start;
|
||||
const windowEnd = this.dataWindow.end;
|
||||
const absoluteIndex = this.absoluteWindowStart + windowStart;
|
||||
|
||||
|
||||
// Calculate the midpoint of the visible range
|
||||
const visibleMidpoint = Math.floor((start + end) / 2);
|
||||
const absoluteMidpoint = this.absoluteWindowStart + visibleMidpoint;
|
||||
|
||||
|
||||
// Check if we're too close to the window edges
|
||||
const closeToStart = start - windowStart < this.windowPadding;
|
||||
const closeToEnd = windowEnd - end < this.windowPadding;
|
||||
|
||||
|
||||
// If we're close to either edge and have total items > window size
|
||||
if ((closeToStart || closeToEnd) && this.totalItems > this.windowSize) {
|
||||
// Calculate a new target index centered around the current viewport
|
||||
const halfWindow = Math.floor(this.windowSize / 2);
|
||||
const targetIndex = Math.max(0, absoluteMidpoint - halfWindow);
|
||||
|
||||
|
||||
// Don't fetch a new window if we're already showing items near the beginning
|
||||
if (targetIndex === 0 && this.absoluteWindowStart === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Don't fetch if we're showing the end of the list and are near the end
|
||||
if (this.absoluteWindowStart + this.items.length >= this.totalItems &&
|
||||
if (this.absoluteWindowStart + this.items.length >= this.totalItems &&
|
||||
this.totalItems - end < halfWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Fetch the new data window
|
||||
await this.fetchDataWindow(targetIndex);
|
||||
}
|
||||
@@ -620,18 +620,18 @@ export class VirtualScroller {
|
||||
reset() {
|
||||
// Remove all rendered items
|
||||
this.clearRenderedItems();
|
||||
|
||||
|
||||
// Reset state
|
||||
this.items = [];
|
||||
this.totalItems = 0;
|
||||
this.hasMore = true;
|
||||
|
||||
|
||||
// Reset spacer height
|
||||
this.spacerElement.style.height = '0px';
|
||||
|
||||
|
||||
// Remove any placeholder
|
||||
this.removeNoItemsPlaceholder();
|
||||
|
||||
|
||||
// Schedule a re-render
|
||||
this.scheduleRender();
|
||||
}
|
||||
@@ -640,21 +640,21 @@ export class VirtualScroller {
|
||||
// Remove event listeners
|
||||
this.scrollContainer.removeEventListener('scroll', this.scrollHandler);
|
||||
window.removeEventListener('resize', this.resizeHandler);
|
||||
|
||||
|
||||
// Clean up the resize observer if present
|
||||
if (this.resizeObserver) {
|
||||
this.resizeObserver.disconnect();
|
||||
}
|
||||
|
||||
|
||||
// Remove rendered elements
|
||||
this.clearRenderedItems();
|
||||
|
||||
|
||||
// Remove spacer
|
||||
this.spacerElement.remove();
|
||||
|
||||
|
||||
// Remove virtual scroll class
|
||||
this.gridElement.classList.remove('virtual-scroll');
|
||||
|
||||
|
||||
// Clear any pending timeout
|
||||
this.clearLoadingTimeout();
|
||||
}
|
||||
@@ -663,19 +663,19 @@ export class VirtualScroller {
|
||||
showNoItemsPlaceholder(message) {
|
||||
// Remove any existing placeholder first
|
||||
this.removeNoItemsPlaceholder();
|
||||
|
||||
|
||||
// Create placeholder message
|
||||
const placeholder = document.createElement('div');
|
||||
placeholder.className = 'placeholder-message';
|
||||
|
||||
|
||||
// Determine appropriate message based on page type
|
||||
let placeholderText = '';
|
||||
|
||||
|
||||
if (message) {
|
||||
placeholderText = message;
|
||||
} else {
|
||||
const pageType = state.currentPageType;
|
||||
|
||||
|
||||
if (pageType === 'recipes') {
|
||||
placeholderText = `
|
||||
<p>No recipes found</p>
|
||||
@@ -698,10 +698,10 @@ export class VirtualScroller {
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
placeholder.innerHTML = placeholderText;
|
||||
placeholder.id = 'virtualScrollPlaceholder';
|
||||
|
||||
|
||||
// Append placeholder to the grid
|
||||
this.gridElement.appendChild(placeholder);
|
||||
}
|
||||
@@ -716,7 +716,7 @@ export class VirtualScroller {
|
||||
// Utility method for debouncing
|
||||
debounce(func, wait) {
|
||||
let timeout;
|
||||
return function(...args) {
|
||||
return function (...args) {
|
||||
const context = this;
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(() => func.apply(context, args), wait);
|
||||
@@ -727,55 +727,55 @@ export class VirtualScroller {
|
||||
disable() {
|
||||
// Detach scroll event listener
|
||||
this.scrollContainer.removeEventListener('scroll', this.scrollHandler);
|
||||
|
||||
|
||||
// Clear all rendered items from the DOM
|
||||
this.clearRenderedItems();
|
||||
|
||||
|
||||
// Hide the spacer element
|
||||
if (this.spacerElement) {
|
||||
this.spacerElement.style.display = 'none';
|
||||
}
|
||||
|
||||
|
||||
// Flag as disabled
|
||||
this.disabled = true;
|
||||
|
||||
|
||||
console.log('Virtual scroller disabled');
|
||||
}
|
||||
|
||||
// Add enable method to resume rendering and events
|
||||
enable() {
|
||||
if (!this.disabled) return;
|
||||
|
||||
|
||||
// Reattach scroll event listener
|
||||
this.scrollContainer.addEventListener('scroll', this.scrollHandler);
|
||||
|
||||
|
||||
// Check if spacer element exists in the DOM, if not, recreate it
|
||||
if (!this.spacerElement || !this.gridElement.contains(this.spacerElement)) {
|
||||
console.log('Spacer element not found in DOM, recreating it');
|
||||
|
||||
|
||||
// Create a new spacer element
|
||||
this.spacerElement = document.createElement('div');
|
||||
this.spacerElement.className = 'virtual-scroll-spacer';
|
||||
this.spacerElement.style.width = '100%';
|
||||
this.spacerElement.style.height = '0px';
|
||||
this.spacerElement.style.pointerEvents = 'none';
|
||||
|
||||
|
||||
// Append it to the grid
|
||||
this.gridElement.appendChild(this.spacerElement);
|
||||
|
||||
|
||||
// Update the spacer height
|
||||
this.updateSpacerHeight();
|
||||
} else {
|
||||
// Show the spacer element if it exists
|
||||
this.spacerElement.style.display = 'block';
|
||||
}
|
||||
|
||||
|
||||
// Flag as enabled
|
||||
this.disabled = false;
|
||||
|
||||
|
||||
// Re-render items
|
||||
this.scheduleRender();
|
||||
|
||||
|
||||
console.log('Virtual scroller enabled');
|
||||
}
|
||||
|
||||
@@ -783,31 +783,30 @@ export class VirtualScroller {
|
||||
deepMerge(target, source) {
|
||||
if (!source || !target) return target;
|
||||
|
||||
// Initialize result with a copy of target
|
||||
const result = { ...target };
|
||||
|
||||
// Only iterate over keys that exist in target
|
||||
Object.keys(target).forEach(key => {
|
||||
// Check if source has this key
|
||||
if (source.hasOwnProperty(key)) {
|
||||
const targetValue = target[key];
|
||||
const sourceValue = source[key];
|
||||
if (!source) return result;
|
||||
|
||||
// If both values are non-null objects and not arrays, merge recursively
|
||||
if (
|
||||
targetValue !== null &&
|
||||
typeof targetValue === 'object' &&
|
||||
!Array.isArray(targetValue) &&
|
||||
sourceValue !== null &&
|
||||
typeof sourceValue === 'object' &&
|
||||
!Array.isArray(sourceValue)
|
||||
) {
|
||||
result[key] = this.deepMerge(targetValue, sourceValue);
|
||||
} else {
|
||||
// For primitive types, arrays, or null, use the value from source
|
||||
result[key] = sourceValue;
|
||||
}
|
||||
// Iterate over all keys in the source object
|
||||
Object.keys(source).forEach(key => {
|
||||
const targetValue = target[key];
|
||||
const sourceValue = source[key];
|
||||
|
||||
// If both values are non-null objects and not arrays, merge recursively
|
||||
if (
|
||||
targetValue !== null &&
|
||||
typeof targetValue === 'object' &&
|
||||
!Array.isArray(targetValue) &&
|
||||
sourceValue !== null &&
|
||||
typeof sourceValue === 'object' &&
|
||||
!Array.isArray(sourceValue)
|
||||
) {
|
||||
result[key] = this.deepMerge(targetValue || {}, sourceValue);
|
||||
} else {
|
||||
// Otherwise update with source value (includes primitives, arrays, and new keys)
|
||||
result[key] = sourceValue;
|
||||
}
|
||||
// If source does not have this key, keep the original value from target
|
||||
});
|
||||
|
||||
return result;
|
||||
@@ -828,43 +827,43 @@ export class VirtualScroller {
|
||||
|
||||
// Update the item data using deep merge
|
||||
this.items[index] = this.deepMerge(this.items[index], updatedItem);
|
||||
|
||||
|
||||
// If the item is currently rendered, update its DOM representation
|
||||
if (this.renderedItems.has(index)) {
|
||||
const element = this.renderedItems.get(index);
|
||||
|
||||
|
||||
// Remove the old element
|
||||
element.remove();
|
||||
this.renderedItems.delete(index);
|
||||
|
||||
|
||||
// Create and render the updated element
|
||||
const updatedElement = this.createItemElement(this.items[index], index);
|
||||
|
||||
|
||||
// Add update indicator visual effects
|
||||
updatedElement.classList.add('updated');
|
||||
|
||||
|
||||
// Add temporary update tag
|
||||
const updateIndicator = document.createElement('div');
|
||||
updateIndicator.className = 'update-indicator';
|
||||
updateIndicator.textContent = 'Updated';
|
||||
updatedElement.querySelector('.card-preview').appendChild(updateIndicator);
|
||||
|
||||
|
||||
// Automatically remove the updated class after animation completes
|
||||
setTimeout(() => {
|
||||
updatedElement.classList.remove('updated');
|
||||
}, 1500);
|
||||
|
||||
|
||||
// Automatically remove the indicator after animation completes
|
||||
setTimeout(() => {
|
||||
if (updateIndicator && updateIndicator.parentNode) {
|
||||
updateIndicator.remove();
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
|
||||
this.renderedItems.set(index, updatedElement);
|
||||
this.gridElement.appendChild(updatedElement);
|
||||
}
|
||||
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -882,26 +881,26 @@ export class VirtualScroller {
|
||||
|
||||
// Remove the item from the data array
|
||||
this.items.splice(index, 1);
|
||||
|
||||
|
||||
// Decrement total count
|
||||
this.totalItems = Math.max(0, this.totalItems - 1);
|
||||
|
||||
|
||||
// Remove the item from rendered items if it exists
|
||||
if (this.renderedItems.has(index)) {
|
||||
this.renderedItems.get(index).remove();
|
||||
this.renderedItems.delete(index);
|
||||
}
|
||||
|
||||
|
||||
// Shift all rendered items with higher indices down by 1
|
||||
const indicesToUpdate = [];
|
||||
|
||||
|
||||
// Collect all indices that need to be updated
|
||||
for (const [idx, element] of this.renderedItems.entries()) {
|
||||
if (idx > index) {
|
||||
indicesToUpdate.push(idx);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Update the elements and map entries
|
||||
for (const idx of indicesToUpdate) {
|
||||
const element = this.renderedItems.get(idx);
|
||||
@@ -909,14 +908,14 @@ export class VirtualScroller {
|
||||
// The item is now at the previous index
|
||||
this.renderedItems.set(idx - 1, element);
|
||||
}
|
||||
|
||||
|
||||
// Update the spacer height to reflect the new total
|
||||
this.updateSpacerHeight();
|
||||
|
||||
|
||||
// Re-render to ensure proper layout
|
||||
this.clearRenderedItems();
|
||||
this.scheduleRender();
|
||||
|
||||
|
||||
console.log(`Removed item with file path ${filePath} from virtual scroller data`);
|
||||
return true;
|
||||
}
|
||||
@@ -929,28 +928,28 @@ export class VirtualScroller {
|
||||
return; // Ignore rapid repeated triggers
|
||||
}
|
||||
this.lastPageNavTime = now;
|
||||
|
||||
|
||||
const scrollContainer = this.scrollContainer;
|
||||
const viewportHeight = scrollContainer.clientHeight;
|
||||
|
||||
|
||||
// Calculate scroll distance (one viewport minus 10% overlap for context)
|
||||
const scrollDistance = viewportHeight * 0.9;
|
||||
|
||||
|
||||
// Determine the new scroll position
|
||||
const newScrollTop = scrollContainer.scrollTop + (direction === 'down' ? scrollDistance : -scrollDistance);
|
||||
|
||||
|
||||
// Remove any existing transition indicators
|
||||
this.removeExistingTransitionIndicator();
|
||||
|
||||
|
||||
// Scroll to the new position with smooth animation
|
||||
scrollContainer.scrollTo({
|
||||
top: newScrollTop,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
|
||||
// Page transition indicator removed
|
||||
// this.showTransitionIndicator();
|
||||
|
||||
|
||||
// Force render after scrolling
|
||||
setTimeout(() => this.renderItems(), 100);
|
||||
setTimeout(() => this.renderItems(), 300);
|
||||
@@ -966,25 +965,25 @@ export class VirtualScroller {
|
||||
|
||||
scrollToTop() {
|
||||
this.removeExistingTransitionIndicator();
|
||||
|
||||
|
||||
// Page transition indicator removed
|
||||
// this.showTransitionIndicator();
|
||||
|
||||
|
||||
this.scrollContainer.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
});
|
||||
|
||||
|
||||
// Force render after scrolling
|
||||
setTimeout(() => this.renderItems(), 100);
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
this.removeExistingTransitionIndicator();
|
||||
|
||||
|
||||
// Page transition indicator removed
|
||||
// this.showTransitionIndicator();
|
||||
|
||||
|
||||
// Start loading all remaining pages to ensure content is available
|
||||
this.loadRemainingPages().then(() => {
|
||||
// After loading all content, scroll to the very bottom
|
||||
@@ -995,27 +994,27 @@ export class VirtualScroller {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
// New method to load all remaining pages
|
||||
async loadRemainingPages() {
|
||||
// If we're already at the end or loading, don't proceed
|
||||
if (!this.hasMore || this.isLoading) return;
|
||||
|
||||
|
||||
console.log('Loading all remaining pages for End key navigation...');
|
||||
|
||||
|
||||
// Keep loading pages until we reach the end
|
||||
while (this.hasMore && !this.isLoading) {
|
||||
await this.loadMoreItems();
|
||||
|
||||
|
||||
// Force render after each page load
|
||||
this.renderItems();
|
||||
|
||||
|
||||
// Small delay to prevent overwhelming the browser
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
}
|
||||
|
||||
|
||||
console.log('Finished loading all pages');
|
||||
|
||||
|
||||
// Final render to ensure all content is displayed
|
||||
this.renderItems();
|
||||
}
|
||||
|
||||
@@ -8,52 +8,60 @@
|
||||
</div>
|
||||
{% set current_path = request.path %}
|
||||
{% if current_path.startswith('/loras/recipes') %}
|
||||
{% set current_page = 'recipes' %}
|
||||
{% set current_page = 'recipes' %}
|
||||
{% elif current_path.startswith('/checkpoints') %}
|
||||
{% set current_page = 'checkpoints' %}
|
||||
{% set current_page = 'checkpoints' %}
|
||||
{% elif current_path.startswith('/embeddings') %}
|
||||
{% set current_page = 'embeddings' %}
|
||||
{% set current_page = 'embeddings' %}
|
||||
{% elif current_path.startswith('/statistics') %}
|
||||
{% set current_page = 'statistics' %}
|
||||
{% set current_page = 'statistics' %}
|
||||
{% else %}
|
||||
{% set current_page = 'loras' %}
|
||||
{% set current_page = 'loras' %}
|
||||
{% endif %}
|
||||
{% 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' %}
|
||||
<nav class="main-nav">
|
||||
<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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</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>
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
|
||||
<!-- Context-aware search container -->
|
||||
<div class="{{ header_search_class }}" id="headerSearch">
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
<span class="filter-badge" id="activeFiltersCount" style="display: none">0</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="header-actions">
|
||||
<!-- Integrated corner controls -->
|
||||
<div class="header-controls">
|
||||
@@ -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="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="prompt">{{ t('header.search.filters.prompt') }}</div>
|
||||
{% 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="modelname">{{ t('header.search.filters.modelname') }}</div>
|
||||
@@ -165,4 +174,4 @@
|
||||
{{ t('header.filter.clearAll') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -15,17 +15,26 @@
|
||||
|
||||
<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="share"><i class="fas fa-share-alt"></i> {{ t('loras.contextMenu.shareRecipe') }}</div>
|
||||
<div class="context-menu-item" data-action="copy"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyRecipeSyntax') }}</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="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="share"><i class="fas fa-share-alt"></i> {{
|
||||
t('loras.contextMenu.shareRecipe') }}</div>
|
||||
<div class="context-menu-item" data-action="copy"><i class="fas fa-copy"></i> {{
|
||||
t('loras.contextMenu.copyRecipeSyntax') }}</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="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">
|
||||
<i class="fas fa-exclamation-triangle"></i> {{ t('loras.contextMenu.setContentRating') }}
|
||||
</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-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>
|
||||
{% endblock %}
|
||||
|
||||
@@ -34,53 +43,92 @@
|
||||
{% block init_check_url %}/api/recipes?page=1&page_size=1{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Recipe controls -->
|
||||
<div class="controls">
|
||||
<div class="action-buttons">
|
||||
<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>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
<!-- Add duplicate detection button -->
|
||||
<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>
|
||||
</div>
|
||||
<!-- Custom filter indicator button (hidden by default) -->
|
||||
<div id="customFilterIndicator" class="control-group hidden">
|
||||
<div class="filter-active">
|
||||
<i class="fas fa-filter"></i> <span id="customFilterText">{{ t('recipes.controls.filteredByLora') }}</span>
|
||||
<i class="fas fa-times-circle clear-filter"></i>
|
||||
</div>
|
||||
<!-- Recipe controls -->
|
||||
<div class="controls">
|
||||
<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">
|
||||
<button onclick="recipeManager.refreshRecipes()"><i class="fas fa-sync"></i> {{ t('common.actions.refresh')
|
||||
}}</button>
|
||||
</div>
|
||||
<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>
|
||||
</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>
|
||||
<!-- Add duplicate detection button -->
|
||||
<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>
|
||||
</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>
|
||||
<!-- Custom filter indicator button (hidden by default) -->
|
||||
<div id="customFilterIndicator" class="control-group hidden">
|
||||
<div class="filter-active">
|
||||
<i class="fas fa-filter"></i> <span id="customFilterText">{{ t('recipes.controls.filteredByLora')
|
||||
}}</span>
|
||||
<i class="fas fa-times-circle clear-filter"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Duplicates banner (hidden by default) -->
|
||||
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
|
||||
<div class="banner-content">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<span id="duplicatesCount">{{ t('recipes.duplicates.found', count=0) }}</span>
|
||||
<div class="banner-actions">
|
||||
<!-- Duplicates banner (hidden by default) -->
|
||||
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
|
||||
<div class="banner-content">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<span id="duplicatesCount">{{ t('recipes.duplicates.found', count=0) }}</span>
|
||||
<div class="banner-actions">
|
||||
<button class="btn-select-latest" onclick="recipeManager.selectLatestDuplicates()">
|
||||
{{ t('recipes.duplicates.keepLatest') }}
|
||||
{{ t('recipes.duplicates.keepLatest') }}
|
||||
</button>
|
||||
<button class="btn-delete-selected disabled" onclick="recipeManager.deleteSelectedDuplicates()">
|
||||
{{ t('recipes.duplicates.deleteSelected') }} (<span id="duplicatesSelectedCount">0</span>)
|
||||
{{ t('recipes.duplicates.deleteSelected') }} (<span id="duplicatesSelectedCount">0</span>)
|
||||
</button>
|
||||
<button class="btn-exit" onclick="recipeManager.exitDuplicateMode()">
|
||||
<i class="fas fa-times"></i>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recipe grid -->
|
||||
<div class="card-grid" id="recipeGrid">
|
||||
<!-- Remove the server-side conditional rendering and placeholder -->
|
||||
<!-- Virtual scrolling will handle the display logic on the client side -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'components/folder_sidebar.html' %}
|
||||
|
||||
<!-- Recipe grid -->
|
||||
<div class="card-grid" id="recipeGrid">
|
||||
<!-- Remove the server-side conditional rendering and placeholder -->
|
||||
<!-- Virtual scrolling will handle the display logic on the client side -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block overlay %}
|
||||
<div class="bulk-mode-overlay"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block main_script %}
|
||||
|
||||
114
tests/frontend/api/recipeApi.bulk.test.js
Normal file
114
tests/frontend/api/recipeApi.bulk.test.js
Normal file
@@ -0,0 +1,114 @@
|
||||
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||
|
||||
const showToastMock = vi.hoisted(() => vi.fn());
|
||||
const loadingManagerMock = vi.hoisted(() => ({
|
||||
showSimpleLoading: vi.fn(),
|
||||
hide: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/uiHelpers.js', () => {
|
||||
return {
|
||||
showToast: showToastMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
|
||||
RecipeCard: vi.fn(() => ({ element: document.createElement('div') })),
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/state/index.js', () => {
|
||||
return {
|
||||
state: {
|
||||
loadingManager: loadingManagerMock,
|
||||
},
|
||||
getCurrentPageState: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
import { RecipeSidebarApiClient } from '../../../static/js/api/recipeApi.js';
|
||||
|
||||
describe('RecipeSidebarApiClient bulk operations', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
global.fetch = vi.fn();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete global.fetch;
|
||||
});
|
||||
|
||||
it('sends recipe IDs when moving in bulk', async () => {
|
||||
const api = new RecipeSidebarApiClient();
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
results: [
|
||||
{
|
||||
recipe_id: 'abc',
|
||||
original_file_path: '/recipes/abc.webp',
|
||||
new_file_path: '/recipes/target/abc.webp',
|
||||
success: true,
|
||||
},
|
||||
],
|
||||
success_count: 1,
|
||||
failure_count: 0,
|
||||
}),
|
||||
});
|
||||
|
||||
const results = await api.moveBulkModels(['/recipes/abc.webp'], '/target/folder');
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/lm/recipes/move-bulk',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
);
|
||||
|
||||
const { body } = global.fetch.mock.calls[0][1];
|
||||
expect(JSON.parse(body)).toEqual({
|
||||
recipe_ids: ['abc'],
|
||||
target_path: '/target/folder',
|
||||
});
|
||||
|
||||
expect(showToastMock).toHaveBeenCalledWith(
|
||||
'toast.api.bulkMoveSuccess',
|
||||
{ successCount: 1, type: 'Recipe' },
|
||||
'success'
|
||||
);
|
||||
expect(results[0].recipe_id).toBe('abc');
|
||||
});
|
||||
|
||||
it('posts recipe IDs for bulk delete', async () => {
|
||||
const api = new RecipeSidebarApiClient();
|
||||
global.fetch.mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
success: true,
|
||||
total_deleted: 2,
|
||||
total_failed: 0,
|
||||
failed: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await api.bulkDeleteModels(['/recipes/a.webp', '/recipes/b.webp']);
|
||||
|
||||
expect(global.fetch).toHaveBeenCalledWith(
|
||||
'/api/lm/recipes/bulk-delete',
|
||||
expect.objectContaining({
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
);
|
||||
|
||||
const parsedBody = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||
expect(parsedBody.recipe_ids).toEqual(['a', 'b']);
|
||||
expect(result).toMatchObject({
|
||||
success: true,
|
||||
deleted_count: 2,
|
||||
failed_count: 0,
|
||||
});
|
||||
expect(loadingManagerMock.hide).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -252,13 +252,13 @@ describe('AppCore initialization flow', () => {
|
||||
expect(onboardingManager.start).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('skips bulk setup when viewing recipes', async () => {
|
||||
it('initializes bulk setup when viewing recipes', async () => {
|
||||
state.currentPageType = 'recipes';
|
||||
|
||||
await appCore.initialize();
|
||||
|
||||
expect(bulkManager.initialize).not.toHaveBeenCalled();
|
||||
expect(BulkContextMenu).not.toHaveBeenCalled();
|
||||
expect(bulkManager.setBulkContextMenu).not.toHaveBeenCalled();
|
||||
expect(bulkManager.initialize).toHaveBeenCalledTimes(1);
|
||||
expect(BulkContextMenu).toHaveBeenCalledTimes(1);
|
||||
expect(bulkManager.setBulkContextMenu).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,6 +9,8 @@ const removeSessionItemMock = vi.fn();
|
||||
const RecipeContextMenuMock = vi.fn();
|
||||
const refreshVirtualScrollMock = vi.fn();
|
||||
const refreshRecipesMock = vi.fn();
|
||||
const fetchUnifiedFolderTreeMock = vi.fn();
|
||||
const fetchModelFoldersMock = vi.fn();
|
||||
|
||||
let importManagerInstance;
|
||||
let recipeModalInstance;
|
||||
@@ -35,6 +37,15 @@ vi.mock('../../../static/js/components/RecipeModal.js', () => ({
|
||||
|
||||
vi.mock('../../../static/js/state/index.js', () => ({
|
||||
getCurrentPageState: getCurrentPageStateMock,
|
||||
state: {
|
||||
currentPageType: 'recipes',
|
||||
global: { settings: {} },
|
||||
virtualScroller: {
|
||||
removeItemByFilePath: vi.fn(),
|
||||
updateSingleItem: vi.fn(),
|
||||
refreshWithData: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
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', () => ({
|
||||
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', () => {
|
||||
@@ -81,7 +100,7 @@ describe('RecipeManager', () => {
|
||||
};
|
||||
|
||||
pageState = {
|
||||
sortBy: 'date',
|
||||
sortBy: 'date:desc',
|
||||
searchOptions: undefined,
|
||||
customFilter: undefined,
|
||||
duplicatesMode: false,
|
||||
@@ -91,7 +110,7 @@ describe('RecipeManager', () => {
|
||||
initializeAppMock.mockResolvedValue(undefined);
|
||||
initializePageFeaturesMock.mockResolvedValue(undefined);
|
||||
refreshVirtualScrollMock.mockReset();
|
||||
refreshVirtualScrollMock.mockImplementation(() => {});
|
||||
refreshVirtualScrollMock.mockImplementation(() => { });
|
||||
refreshRecipesMock.mockResolvedValue('refreshed');
|
||||
|
||||
getSessionItemMock.mockImplementation((key) => {
|
||||
@@ -102,7 +121,7 @@ describe('RecipeManager', () => {
|
||||
};
|
||||
return map[key] ?? null;
|
||||
});
|
||||
removeSessionItemMock.mockImplementation(() => {});
|
||||
removeSessionItemMock.mockImplementation(() => { });
|
||||
|
||||
renderRecipesPage();
|
||||
|
||||
@@ -118,8 +137,8 @@ describe('RecipeManager', () => {
|
||||
const sortSelectElement = document.createElement('select');
|
||||
sortSelectElement.id = 'sortSelect';
|
||||
sortSelectElement.innerHTML = `
|
||||
<option value="date">Date</option>
|
||||
<option value="name">Name</option>
|
||||
<option value="date:desc">Newest</option>
|
||||
<option value="name:asc">Name A-Z</option>
|
||||
`;
|
||||
document.body.appendChild(sortSelectElement);
|
||||
|
||||
@@ -139,6 +158,8 @@ describe('RecipeManager', () => {
|
||||
tags: true,
|
||||
loraName: true,
|
||||
loraModel: true,
|
||||
prompt: true,
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
expect(pageState.customFilter).toEqual({
|
||||
@@ -162,10 +183,10 @@ describe('RecipeManager', () => {
|
||||
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1);
|
||||
|
||||
const sortSelect = document.getElementById('sortSelect');
|
||||
sortSelect.value = 'name';
|
||||
sortSelect.value = 'name:asc';
|
||||
sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
expect(pageState.sortBy).toBe('name');
|
||||
expect(pageState.sortBy).toBe('name:asc');
|
||||
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(2);
|
||||
expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
@@ -69,6 +69,10 @@ class StubRecipeScanner:
|
||||
async def get_recipe_by_id(self, recipe_id: str) -> Optional[Dict[str, Any]]:
|
||||
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:
|
||||
self.removed.append(recipe_id)
|
||||
self.recipes.pop(recipe_id, None)
|
||||
@@ -87,6 +91,7 @@ class StubAnalysisService:
|
||||
self.remote_calls: List[Optional[str]] = []
|
||||
self.local_calls: List[Optional[str]] = []
|
||||
self.result = SimpleNamespace(payload={"loras": []}, status=200)
|
||||
self._recipe_parser_factory = None
|
||||
StubAnalysisService.instances.append(self)
|
||||
|
||||
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:
|
||||
self.save_calls: List[Dict[str, Any]] = []
|
||||
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.delete_result = SimpleNamespace(payload={"success": True}, status=200)
|
||||
StubPersistenceService.instances.append(self)
|
||||
@@ -142,6 +148,12 @@ class StubPersistenceService:
|
||||
await recipe_scanner.remove_recipe(recipe_id)
|
||||
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
|
||||
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"]
|
||||
|
||||
|
||||
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:
|
||||
provider_calls: list[int] = []
|
||||
|
||||
@@ -501,3 +528,69 @@ async def test_share_and_download_recipe(monkeypatch, tmp_path: Path) -> None:
|
||||
assert body == b"stub"
|
||||
|
||||
download_path.unlink(missing_ok=True)
|
||||
async def test_import_remote_recipe_merges_metadata(monkeypatch, tmp_path: Path) -> None:
|
||||
# 1. Mock Metadata Provider
|
||||
class Provider:
|
||||
async def get_model_version_info(self, model_version_id):
|
||||
return {"baseModel": "Flux Provider"}, None
|
||||
|
||||
async def fake_get_default_metadata_provider():
|
||||
return Provider()
|
||||
|
||||
monkeypatch.setattr(recipe_handlers, "get_default_metadata_provider", fake_get_default_metadata_provider)
|
||||
|
||||
# 2. Mock ExifUtils to return some embedded metadata
|
||||
class MockExifUtils:
|
||||
@staticmethod
|
||||
def extract_image_metadata(path):
|
||||
return "Recipe metadata: " + json.dumps({
|
||||
"gen_params": {"prompt": "from embedded", "seed": 123}
|
||||
})
|
||||
|
||||
monkeypatch.setattr(recipe_handlers, "ExifUtils", MockExifUtils)
|
||||
|
||||
# 3. Mock Parser Factory for StubAnalysisService
|
||||
class MockParser:
|
||||
async def parse_metadata(self, raw, recipe_scanner=None):
|
||||
return json.loads(raw[len("Recipe metadata: "):])
|
||||
|
||||
class MockFactory:
|
||||
def create_parser(self, raw):
|
||||
if raw.startswith("Recipe metadata: "):
|
||||
return MockParser()
|
||||
return None
|
||||
|
||||
# 4. Setup Harness and run test
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
harness.analysis._recipe_parser_factory = MockFactory()
|
||||
|
||||
# Civitai meta via image_info
|
||||
harness.civitai.image_info["1"] = {
|
||||
"id": 1,
|
||||
"url": "https://example.com/images/1.jpg",
|
||||
"meta": {"prompt": "from civitai", "cfg": 7.0}
|
||||
}
|
||||
|
||||
resources = []
|
||||
response = await harness.client.get(
|
||||
"/api/lm/recipes/import-remote",
|
||||
params={
|
||||
"image_url": "https://civitai.com/images/1",
|
||||
"name": "Merged Recipe",
|
||||
"resources": json.dumps(resources),
|
||||
"gen_params": json.dumps({"prompt": "from request", "steps": 25}),
|
||||
},
|
||||
)
|
||||
|
||||
payload = await response.json()
|
||||
assert response.status == 200
|
||||
|
||||
call = harness.persistence.save_calls[-1]
|
||||
metadata = call["metadata"]
|
||||
gen_params = metadata["gen_params"]
|
||||
|
||||
# Priority: request (prompt=request, steps=25) > civitai (prompt=civitai, cfg=7.0) > embedded (prompt=embedded, seed=123)
|
||||
assert gen_params["prompt"] == "from request"
|
||||
assert gen_params["steps"] == 25
|
||||
assert gen_params["cfg"] == 7.0
|
||||
assert gen_params["seed"] == 123
|
||||
|
||||
113
tests/services/test_comfy_metadata_parser.py
Normal file
113
tests/services/test_comfy_metadata_parser.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import pytest
|
||||
import json
|
||||
from py.recipes.parsers.comfy import ComfyMetadataParser
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_metadata_without_loras(monkeypatch):
|
||||
checkpoint_info = {
|
||||
"id": 2224012,
|
||||
"modelId": 1908679,
|
||||
"model": {"name": "SDXL Checkpoint", "type": "checkpoint"},
|
||||
"name": "v1.0",
|
||||
"images": [{"url": "https://image.civitai.com/checkpoints/original=true"}],
|
||||
"baseModel": "sdxl",
|
||||
"downloadUrl": "https://civitai.com/api/download/checkpoint",
|
||||
}
|
||||
|
||||
async def fake_metadata_provider():
|
||||
class Provider:
|
||||
async def get_model_version_info(self, version_id):
|
||||
assert version_id == "2224012"
|
||||
return checkpoint_info, None
|
||||
return Provider()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.recipes.parsers.comfy.get_default_metadata_provider",
|
||||
fake_metadata_provider,
|
||||
)
|
||||
|
||||
parser = ComfyMetadataParser()
|
||||
|
||||
# User provided metadata
|
||||
metadata_json = {
|
||||
"resource-stack": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": "urn:air:sdxl:checkpoint:civitai:1908679@2224012"}
|
||||
},
|
||||
"6": {
|
||||
"class_type": "smZ CLIPTextEncode",
|
||||
"inputs": {"text": "Positive prompt content"},
|
||||
"_meta": {"title": "Positive"}
|
||||
},
|
||||
"7": {
|
||||
"class_type": "smZ CLIPTextEncode",
|
||||
"inputs": {"text": "Negative prompt content"},
|
||||
"_meta": {"title": "Negative"}
|
||||
},
|
||||
"11": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {
|
||||
"sampler_name": "euler_ancestral",
|
||||
"scheduler": "normal",
|
||||
"seed": 904124997,
|
||||
"steps": 35,
|
||||
"cfg": 6,
|
||||
"denoise": 0.1,
|
||||
"model": ["resource-stack", 0],
|
||||
"positive": ["6", 0],
|
||||
"negative": ["7", 0],
|
||||
"latent_image": ["21", 0]
|
||||
},
|
||||
"_meta": {"title": "KSampler"}
|
||||
},
|
||||
"extraMetadata": json.dumps({
|
||||
"prompt": "One woman, (solo:1.3), ...",
|
||||
"negativePrompt": "lowres, worst quality, ...",
|
||||
"steps": 35,
|
||||
"cfgScale": 6,
|
||||
"sampler": "euler_ancestral",
|
||||
"seed": 904124997,
|
||||
"width": 1024,
|
||||
"height": 1024
|
||||
})
|
||||
}
|
||||
|
||||
result = await parser.parse_metadata(json.dumps(metadata_json))
|
||||
|
||||
assert "error" not in result
|
||||
assert result["loras"] == []
|
||||
assert result["checkpoint"] is not None
|
||||
assert int(result["checkpoint"]["modelId"]) == 1908679
|
||||
assert int(result["checkpoint"]["id"]) == 2224012
|
||||
assert result["gen_params"]["prompt"] == "One woman, (solo:1.3), ..."
|
||||
assert result["gen_params"]["steps"] == 35
|
||||
assert result["gen_params"]["size"] == "1024x1024"
|
||||
assert result["from_comfy_metadata"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_metadata_without_extra_metadata(monkeypatch):
|
||||
async def fake_metadata_provider():
|
||||
class Provider:
|
||||
async def get_model_version_info(self, version_id):
|
||||
return {"model": {"name": "Test"}, "id": version_id}, None
|
||||
return Provider()
|
||||
|
||||
monkeypatch.setattr(
|
||||
"py.recipes.parsers.comfy.get_default_metadata_provider",
|
||||
fake_metadata_provider,
|
||||
)
|
||||
|
||||
parser = ComfyMetadataParser()
|
||||
|
||||
metadata_json = {
|
||||
"node_1": {
|
||||
"class_type": "CheckpointLoaderSimple",
|
||||
"inputs": {"ckpt_name": "urn:air:sdxl:checkpoint:civitai:123@456"}
|
||||
}
|
||||
}
|
||||
|
||||
result = await parser.parse_metadata(json.dumps(metadata_json))
|
||||
|
||||
assert "error" not in result
|
||||
assert result["loras"] == []
|
||||
assert result["checkpoint"]["id"] == "456"
|
||||
59
tests/services/test_gen_params_merger.py
Normal file
59
tests/services/test_gen_params_merger.py
Normal file
@@ -0,0 +1,59 @@
|
||||
import pytest
|
||||
from py.recipes.merger import GenParamsMerger
|
||||
|
||||
def test_merge_priority():
|
||||
request_params = {"prompt": "from request", "steps": 20}
|
||||
civitai_meta = {"prompt": "from civitai", "cfg": 7.0}
|
||||
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
||||
|
||||
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
|
||||
|
||||
assert merged["prompt"] == "from request"
|
||||
assert merged["steps"] == 20
|
||||
assert merged["cfg"] == 7.0
|
||||
assert merged["seed"] == 123
|
||||
|
||||
def test_merge_no_request_params():
|
||||
civitai_meta = {"prompt": "from civitai", "cfg": 7.0}
|
||||
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
||||
|
||||
merged = GenParamsMerger.merge(None, civitai_meta, embedded_metadata)
|
||||
|
||||
assert merged["prompt"] == "from civitai"
|
||||
assert merged["cfg"] == 7.0
|
||||
assert merged["seed"] == 123
|
||||
|
||||
def test_merge_only_embedded():
|
||||
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
||||
|
||||
merged = GenParamsMerger.merge(None, None, embedded_metadata)
|
||||
|
||||
assert merged["prompt"] == "from embedded"
|
||||
assert merged["seed"] == 123
|
||||
|
||||
def test_merge_raw_embedded():
|
||||
# Test when embedded metadata is just the gen_params themselves
|
||||
embedded_metadata = {"prompt": "from raw embedded", "seed": 456}
|
||||
|
||||
merged = GenParamsMerger.merge(None, None, embedded_metadata)
|
||||
|
||||
assert merged["prompt"] == "from raw embedded"
|
||||
assert merged["seed"] == 456
|
||||
|
||||
def test_merge_none_values():
|
||||
merged = GenParamsMerger.merge(None, None, None)
|
||||
assert merged == {}
|
||||
|
||||
def test_merge_filters_blacklisted_keys():
|
||||
request_params = {"prompt": "test", "id": "should-be-removed"}
|
||||
civitai_meta = {"cfg": 7, "url": "remove-me"}
|
||||
embedded_metadata = {"seed": 123, "hash": "remove-also"}
|
||||
|
||||
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
|
||||
|
||||
assert "prompt" in merged
|
||||
assert "cfg" in merged
|
||||
assert "seed" in merged
|
||||
assert "id" not in merged
|
||||
assert "url" not in merged
|
||||
assert "hash" not in merged
|
||||
@@ -502,7 +502,142 @@ async def test_update_lora_filename_by_hash_updates_affected_recipes(tmp_path: P
|
||||
persisted2 = json.loads(recipe2_path.read_text())
|
||||
assert persisted2["loras"][0]["file_name"] == "other_lora"
|
||||
|
||||
# Check cache
|
||||
cache = await scanner.get_cached_data()
|
||||
cached1 = next(r for r in cache.raw_data if r["id"] == recipe1_id)
|
||||
assert cached1["loras"][0]["file_name"] == new_name
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_paginated_data_filters_by_favorite(recipe_scanner):
|
||||
scanner, _ = recipe_scanner
|
||||
|
||||
# Add a normal recipe
|
||||
await scanner.add_recipe({
|
||||
"id": "regular",
|
||||
"file_path": "path/regular.png",
|
||||
"title": "Regular Recipe",
|
||||
"modified": 1.0,
|
||||
"created_date": 1.0,
|
||||
"loras": [],
|
||||
})
|
||||
|
||||
# Add a favorite recipe
|
||||
await scanner.add_recipe({
|
||||
"id": "favorite",
|
||||
"file_path": "path/favorite.png",
|
||||
"title": "Favorite Recipe",
|
||||
"modified": 2.0,
|
||||
"created_date": 2.0,
|
||||
"loras": [],
|
||||
"favorite": True
|
||||
})
|
||||
|
||||
# Wait for cache update (it's async in some places, add_recipe is usually enough but let's be safe)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# Test without filter (should return both)
|
||||
result_all = await scanner.get_paginated_data(page=1, page_size=10)
|
||||
assert len(result_all["items"]) == 2
|
||||
|
||||
# Test with favorite filter
|
||||
result_fav = await scanner.get_paginated_data(page=1, page_size=10, filters={"favorite": True})
|
||||
assert len(result_fav["items"]) == 1
|
||||
assert result_fav["items"][0]["id"] == "favorite"
|
||||
|
||||
# Test with favorite filter set to False (should return both or at least not filter if it's the default)
|
||||
# Actually our implementation checks if 'favorite' in filters and filters['favorite']
|
||||
result_fav_false = await scanner.get_paginated_data(page=1, page_size=10, filters={"favorite": False})
|
||||
assert len(result_fav_false["items"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_paginated_data_filters_by_prompt(recipe_scanner):
|
||||
scanner, _ = recipe_scanner
|
||||
|
||||
# Add a recipe with a specific prompt
|
||||
await scanner.add_recipe({
|
||||
"id": "prompt-recipe",
|
||||
"file_path": "path/prompt.png",
|
||||
"title": "Prompt Recipe",
|
||||
"modified": 1.0,
|
||||
"created_date": 1.0,
|
||||
"loras": [],
|
||||
"gen_params": {
|
||||
"prompt": "a beautiful forest landscape"
|
||||
}
|
||||
})
|
||||
|
||||
# Add a recipe with a specific negative prompt
|
||||
await scanner.add_recipe({
|
||||
"id": "neg-prompt-recipe",
|
||||
"file_path": "path/neg.png",
|
||||
"title": "Negative Prompt Recipe",
|
||||
"modified": 2.0,
|
||||
"created_date": 2.0,
|
||||
"loras": [],
|
||||
"gen_params": {
|
||||
"negative_prompt": "ugly, blurry mountains"
|
||||
}
|
||||
})
|
||||
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# Test search in prompt
|
||||
result_prompt = await scanner.get_paginated_data(
|
||||
page=1, page_size=10, search="forest", search_options={"prompt": True}
|
||||
)
|
||||
assert len(result_prompt["items"]) == 1
|
||||
assert result_prompt["items"][0]["id"] == "prompt-recipe"
|
||||
|
||||
# Test search in negative prompt
|
||||
result_neg = await scanner.get_paginated_data(
|
||||
page=1, page_size=10, search="mountains", search_options={"prompt": True}
|
||||
)
|
||||
assert len(result_neg["items"]) == 1
|
||||
assert result_neg["items"][0]["id"] == "neg-prompt-recipe"
|
||||
|
||||
# Test search disabled (should not find by prompt)
|
||||
result_disabled = await scanner.get_paginated_data(
|
||||
page=1, page_size=10, search="forest", search_options={"prompt": False}
|
||||
)
|
||||
assert len(result_disabled["items"]) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_paginated_data_sorting(recipe_scanner):
|
||||
scanner, _ = recipe_scanner
|
||||
|
||||
# Add test recipes
|
||||
# Recipe A: Name "Alpha", Date 10, LoRAs 2
|
||||
await scanner.add_recipe({
|
||||
"id": "A", "title": "Alpha", "created_date": 10.0,
|
||||
"loras": [{}, {}], "file_path": "a.png"
|
||||
})
|
||||
# Recipe B: Name "Beta", Date 20, LoRAs 1
|
||||
await scanner.add_recipe({
|
||||
"id": "B", "title": "Beta", "created_date": 20.0,
|
||||
"loras": [{}], "file_path": "b.png"
|
||||
})
|
||||
# Recipe C: Name "Gamma", Date 5, LoRAs 3
|
||||
await scanner.add_recipe({
|
||||
"id": "C", "title": "Gamma", "created_date": 5.0,
|
||||
"loras": [{}, {}, {}], "file_path": "c.png"
|
||||
})
|
||||
|
||||
await asyncio.sleep(0)
|
||||
|
||||
# Test Name DESC: Gamma, Beta, Alpha
|
||||
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="name:desc")
|
||||
assert [i["id"] for i in res["items"]] == ["C", "B", "A"]
|
||||
|
||||
# Test LoRA Count DESC: Gamma (3), Alpha (2), Beta (1)
|
||||
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="loras_count:desc")
|
||||
assert [i["id"] for i in res["items"]] == ["C", "A", "B"]
|
||||
|
||||
# Test LoRA Count ASC: Beta (1), Alpha (2), Gamma (3)
|
||||
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="loras_count:asc")
|
||||
assert [i["id"] for i in res["items"]] == ["B", "A", "C"]
|
||||
|
||||
# Test Date ASC: Gamma (5), Alpha (10), Beta (20)
|
||||
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="date:asc")
|
||||
assert [i["id"] for i in res["items"]] == ["C", "A", "B"]
|
||||
|
||||
@@ -403,6 +403,89 @@ async def test_save_recipe_from_widget_allows_empty_lora(tmp_path):
|
||||
assert scanner.added and scanner.added[0]["loras"] == []
|
||||
|
||||
|
||||
@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
|
||||
async def test_analyze_remote_video(tmp_path):
|
||||
exif_utils = DummyExifUtils()
|
||||
|
||||
Reference in New Issue
Block a user