Merge pull request #735 from willmiao/recipe-folder

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

View File

@@ -188,7 +188,8 @@
"creator": "Ersteller", "creator": "Ersteller",
"title": "Rezept-Titel", "title": "Rezept-Titel",
"loraName": "LoRA-Dateiname", "loraName": "LoRA-Dateiname",
"loraModel": "LoRA-Modellname" "loraModel": "LoRA-Modellname",
"prompt": "Prompt"
} }
}, },
"filter": { "filter": {
@@ -588,10 +589,26 @@
"selectLoraRoot": "Bitte wählen Sie ein LoRA-Stammverzeichnis aus" "selectLoraRoot": "Bitte wählen Sie ein LoRA-Stammverzeichnis aus"
} }
}, },
"sort": {
"title": "Rezepte sortieren nach...",
"name": "Name",
"nameAsc": "A - Z",
"nameDesc": "Z - A",
"date": "Datum",
"dateDesc": "Neueste",
"dateAsc": "Älteste",
"lorasCount": "LoRA-Anzahl",
"lorasCountDesc": "Meiste",
"lorasCountAsc": "Wenigste"
},
"refresh": { "refresh": {
"title": "Rezeptliste aktualisieren" "title": "Rezeptliste aktualisieren"
}, },
"filteredByLora": "Gefiltert nach LoRA" "filteredByLora": "Gefiltert nach LoRA",
"favorites": {
"title": "Nur Favoriten anzeigen",
"action": "Favoriten"
}
}, },
"duplicates": { "duplicates": {
"found": "{count} Duplikat-Gruppen gefunden", "found": "{count} Duplikat-Gruppen gefunden",
@@ -638,7 +655,8 @@
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar", "recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
"collapseAllDisabled": "Im Listenmodus nicht verfügbar", "collapseAllDisabled": "Im Listenmodus nicht verfügbar",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden." "unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -1460,7 +1478,8 @@
"bulkMoveFailures": "Fehlgeschlagene Verschiebungen:\n{failures}", "bulkMoveFailures": "Fehlgeschlagene Verschiebungen:\n{failures}",
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben", "bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!", "exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}" "exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {
@@ -1478,4 +1497,4 @@
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
} }
} }
} }

View File

@@ -32,7 +32,7 @@
"korean": "한국어", "korean": "한국어",
"french": "Français", "french": "Français",
"spanish": "Español", "spanish": "Español",
"Hebrew": "עברית" "Hebrew": "עברית"
}, },
"fileSize": { "fileSize": {
"zero": "0 Bytes", "zero": "0 Bytes",
@@ -188,7 +188,8 @@
"creator": "Creator", "creator": "Creator",
"title": "Recipe Title", "title": "Recipe Title",
"loraName": "LoRA Filename", "loraName": "LoRA Filename",
"loraModel": "LoRA Model Name" "loraModel": "LoRA Model Name",
"prompt": "Prompt"
} }
}, },
"filter": { "filter": {
@@ -336,7 +337,7 @@
"templateOptions": { "templateOptions": {
"flatStructure": "Flat Structure", "flatStructure": "Flat Structure",
"byBaseModel": "By Base Model", "byBaseModel": "By Base Model",
"byAuthor": "By Author", "byAuthor": "By Author",
"byFirstTag": "By First Tag", "byFirstTag": "By First Tag",
"baseModelFirstTag": "Base Model + First Tag", "baseModelFirstTag": "Base Model + First Tag",
"baseModelAuthor": "Base Model + Author", "baseModelAuthor": "Base Model + Author",
@@ -347,7 +348,7 @@
"customTemplatePlaceholder": "Enter custom template (e.g., {base_model}/{author}/{first_tag})", "customTemplatePlaceholder": "Enter custom template (e.g., {base_model}/{author}/{first_tag})",
"modelTypes": { "modelTypes": {
"lora": "LoRA", "lora": "LoRA",
"checkpoint": "Checkpoint", "checkpoint": "Checkpoint",
"embedding": "Embedding" "embedding": "Embedding"
}, },
"baseModelPathMappings": "Base Model Path Mappings", "baseModelPathMappings": "Base Model Path Mappings",
@@ -420,11 +421,11 @@
"proxyHost": "Proxy Host", "proxyHost": "Proxy Host",
"proxyHostPlaceholder": "proxy.example.com", "proxyHostPlaceholder": "proxy.example.com",
"proxyHostHelp": "The hostname or IP address of your proxy server", "proxyHostHelp": "The hostname or IP address of your proxy server",
"proxyPort": "Proxy Port", "proxyPort": "Proxy Port",
"proxyPortPlaceholder": "8080", "proxyPortPlaceholder": "8080",
"proxyPortHelp": "The port number of your proxy server", "proxyPortHelp": "The port number of your proxy server",
"proxyUsername": "Username (Optional)", "proxyUsername": "Username (Optional)",
"proxyUsernamePlaceholder": "username", "proxyUsernamePlaceholder": "username",
"proxyUsernameHelp": "Username for proxy authentication (if required)", "proxyUsernameHelp": "Username for proxy authentication (if required)",
"proxyPassword": "Password (Optional)", "proxyPassword": "Password (Optional)",
"proxyPasswordPlaceholder": "password", "proxyPasswordPlaceholder": "password",
@@ -588,10 +589,26 @@
"selectLoraRoot": "Please select a LoRA root directory" "selectLoraRoot": "Please select a LoRA root directory"
} }
}, },
"sort": {
"title": "Sort recipes by...",
"name": "Name",
"nameAsc": "A - Z",
"nameDesc": "Z - A",
"date": "Date",
"dateDesc": "Newest",
"dateAsc": "Oldest",
"lorasCount": "LoRA Count",
"lorasCountDesc": "Most",
"lorasCountAsc": "Least"
},
"refresh": { "refresh": {
"title": "Refresh recipe list" "title": "Refresh recipe list"
}, },
"filteredByLora": "Filtered by LoRA" "filteredByLora": "Filtered by LoRA",
"favorites": {
"title": "Show Favorites Only",
"action": "Favorites"
}
}, },
"duplicates": { "duplicates": {
"found": "Found {count} duplicate groups", "found": "Found {count} duplicate groups",
@@ -638,7 +655,8 @@
"recursiveUnavailable": "Recursive search is available in tree view only", "recursiveUnavailable": "Recursive search is available in tree view only",
"collapseAllDisabled": "Not available in list view", "collapseAllDisabled": "Not available in list view",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Unable to determine destination path for move." "unableToResolveRoot": "Unable to determine destination path for move.",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -1460,7 +1478,8 @@
"bulkMoveFailures": "Failed moves:\n{failures}", "bulkMoveFailures": "Failed moves:\n{failures}",
"bulkMoveSuccess": "Successfully moved {successCount} {type}s", "bulkMoveSuccess": "Successfully moved {successCount} {type}s",
"exampleImagesDownloadSuccess": "Successfully downloaded example images!", "exampleImagesDownloadSuccess": "Successfully downloaded example images!",
"exampleImagesDownloadFailed": "Failed to download example images: {message}" "exampleImagesDownloadFailed": "Failed to download example images: {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {
@@ -1478,4 +1497,4 @@
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
} }
} }
} }

View File

@@ -188,7 +188,8 @@
"creator": "Creador", "creator": "Creador",
"title": "Título de la receta", "title": "Título de la receta",
"loraName": "Nombre de archivo LoRA", "loraName": "Nombre de archivo LoRA",
"loraModel": "Nombre del modelo LoRA" "loraModel": "Nombre del modelo LoRA",
"prompt": "Prompt"
} }
}, },
"filter": { "filter": {
@@ -588,10 +589,26 @@
"selectLoraRoot": "Por favor selecciona un directorio raíz de LoRA" "selectLoraRoot": "Por favor selecciona un directorio raíz de LoRA"
} }
}, },
"sort": {
"title": "Ordenar recetas por...",
"name": "Nombre",
"nameAsc": "A - Z",
"nameDesc": "Z - A",
"date": "Fecha",
"dateDesc": "Más reciente",
"dateAsc": "Más antiguo",
"lorasCount": "Cant. de LoRAs",
"lorasCountDesc": "Más",
"lorasCountAsc": "Menos"
},
"refresh": { "refresh": {
"title": "Actualizar lista de recetas" "title": "Actualizar lista de recetas"
}, },
"filteredByLora": "Filtrado por LoRA" "filteredByLora": "Filtrado por LoRA",
"favorites": {
"title": "Mostrar solo favoritos",
"action": "Favoritos"
}
}, },
"duplicates": { "duplicates": {
"found": "Se encontraron {count} grupos de duplicados", "found": "Se encontraron {count} grupos de duplicados",
@@ -638,7 +655,8 @@
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol", "recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
"collapseAllDisabled": "No disponible en vista de lista", "collapseAllDisabled": "No disponible en vista de lista",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento." "unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -1460,7 +1478,8 @@
"bulkMoveFailures": "Movimientos fallidos:\n{failures}", "bulkMoveFailures": "Movimientos fallidos:\n{failures}",
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s", "bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
"exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!", "exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!",
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}" "exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {
@@ -1478,4 +1497,4 @@
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
} }
} }
} }

View File

@@ -188,7 +188,8 @@
"creator": "Créateur", "creator": "Créateur",
"title": "Titre de la recipe", "title": "Titre de la recipe",
"loraName": "Nom de fichier LoRA", "loraName": "Nom de fichier LoRA",
"loraModel": "Nom du modèle LoRA" "loraModel": "Nom du modèle LoRA",
"prompt": "Prompt"
} }
}, },
"filter": { "filter": {
@@ -588,10 +589,26 @@
"selectLoraRoot": "Veuillez sélectionner un répertoire racine LoRA" "selectLoraRoot": "Veuillez sélectionner un répertoire racine LoRA"
} }
}, },
"sort": {
"title": "Trier les recettes par...",
"name": "Nom",
"nameAsc": "A - Z",
"nameDesc": "Z - A",
"date": "Date",
"dateDesc": "Plus récent",
"dateAsc": "Plus ancien",
"lorasCount": "Nombre de LoRAs",
"lorasCountDesc": "Plus",
"lorasCountAsc": "Moins"
},
"refresh": { "refresh": {
"title": "Actualiser la liste des recipes" "title": "Actualiser la liste des recipes"
}, },
"filteredByLora": "Filtré par LoRA" "filteredByLora": "Filtré par LoRA",
"favorites": {
"title": "Afficher uniquement les favoris",
"action": "Favoris"
}
}, },
"duplicates": { "duplicates": {
"found": "Trouvé {count} groupes de doublons", "found": "Trouvé {count} groupes de doublons",
@@ -638,7 +655,8 @@
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente", "recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
"collapseAllDisabled": "Non disponible en vue liste", "collapseAllDisabled": "Non disponible en vue liste",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement." "unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -1460,7 +1478,8 @@
"bulkMoveFailures": "Échecs de déplacement :\n{failures}", "bulkMoveFailures": "Échecs de déplacement :\n{failures}",
"bulkMoveSuccess": "{successCount} {type}s déplacés avec succès", "bulkMoveSuccess": "{successCount} {type}s déplacés avec succès",
"exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !", "exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !",
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}" "exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {
@@ -1478,4 +1497,4 @@
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
} }
} }
} }

View File

@@ -188,7 +188,8 @@
"creator": "יוצר", "creator": "יוצר",
"title": "כותרת מתכון", "title": "כותרת מתכון",
"loraName": "שם קובץ LoRA", "loraName": "שם קובץ LoRA",
"loraModel": "שם מודל LoRA" "loraModel": "שם מודל LoRA",
"prompt": "הנחיה"
} }
}, },
"filter": { "filter": {
@@ -228,6 +229,7 @@
"videoSettings": "הגדרות וידאו", "videoSettings": "הגדרות וידאו",
"layoutSettings": "הגדרות פריסה", "layoutSettings": "הגדרות פריסה",
"folderSettings": "הגדרות תיקייה", "folderSettings": "הגדרות תיקייה",
"priorityTags": "תגיות עדיפות",
"downloadPathTemplates": "תבניות נתיב הורדה", "downloadPathTemplates": "תבניות נתיב הורדה",
"exampleImages": "תמונות דוגמה", "exampleImages": "תמונות דוגמה",
"updateFlags": "תגי עדכון", "updateFlags": "תגי עדכון",
@@ -235,8 +237,7 @@
"misc": "שונות", "misc": "שונות",
"metadataArchive": "מסד נתונים של ארכיון מטא-דאטה", "metadataArchive": "מסד נתונים של ארכיון מטא-דאטה",
"storageLocation": "מיקום ההגדרות", "storageLocation": "מיקום ההגדרות",
"proxySettings": "הגדרות פרוקסי", "proxySettings": "הגדרות פרוקסי"
"priorityTags": "תגיות עדיפות"
}, },
"storage": { "storage": {
"locationLabel": "מצב נייד", "locationLabel": "מצב נייד",
@@ -309,6 +310,26 @@
"defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות", "defaultEmbeddingRootHelp": "הגדר את ספריית השורש המוגדרת כברירת מחדל של embedding להורדות, ייבוא והעברות",
"noDefault": "אין ברירת מחדל" "noDefault": "אין ברירת מחדל"
}, },
"priorityTags": {
"title": "תגיות עדיפות",
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
"placeholder": "character, concept, style(toon|toon_style)",
"helpLinkLabel": "פתח עזרה בנושא תגיות עדיפות",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"saveSuccess": "תגיות העדיפות עודכנו.",
"saveError": "עדכון תגיות העדיפות נכשל.",
"loadingSuggestions": "טוען הצעות...",
"validation": {
"missingClosingParen": "לרשומה {index} חסר סוגר סוגריים.",
"missingCanonical": "על הרשומה {index} לכלול שם תגית קנונית.",
"duplicateCanonical": "התגית הקנונית \"{tag}\" מופיעה יותר מפעם אחת.",
"unknown": "תצורת תגיות העדיפות שגויה."
}
},
"downloadPathTemplates": { "downloadPathTemplates": {
"title": "תבניות נתיב הורדה", "title": "תבניות נתיב הורדה",
"help": "הגדר מבני תיקיות לסוגי מודלים שונים בעת הורדה מ-Civitai.", "help": "הגדר מבני תיקיות לסוגי מודלים שונים בעת הורדה מ-Civitai.",
@@ -320,8 +341,8 @@
"byFirstTag": "לפי תגית ראשונה", "byFirstTag": "לפי תגית ראשונה",
"baseModelFirstTag": "מודל בסיס + תגית ראשונה", "baseModelFirstTag": "מודל בסיס + תגית ראשונה",
"baseModelAuthor": "מודל בסיס + יוצר", "baseModelAuthor": "מודל בסיס + יוצר",
"baseModelAuthorFirstTag": "מודל בסיס + יוצר + תגית ראשונה",
"authorFirstTag": "יוצר + תגית ראשונה", "authorFirstTag": "יוצר + תגית ראשונה",
"baseModelAuthorFirstTag": "מודל בסיס + יוצר + תגית ראשונה",
"customTemplate": "תבנית מותאמת אישית" "customTemplate": "תבנית מותאמת אישית"
}, },
"customTemplatePlaceholder": "הזן תבנית מותאמת אישית (למשל, {base_model}/{author}/{first_tag})", "customTemplatePlaceholder": "הזן תבנית מותאמת אישית (למשל, {base_model}/{author}/{first_tag})",
@@ -409,26 +430,6 @@
"proxyPassword": "סיסמה (אופציונלי)", "proxyPassword": "סיסמה (אופציונלי)",
"proxyPasswordPlaceholder": "password", "proxyPasswordPlaceholder": "password",
"proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)" "proxyPasswordHelp": "סיסמה לאימות מול הפרוקסי (אם נדרש)"
},
"priorityTags": {
"title": "תגיות עדיפות",
"description": "התאם את סדר העדיפות של התגיות עבור כל סוג מודל (לדוגמה: character, concept, style(toon|toon_style))",
"placeholder": "character, concept, style(toon|toon_style)",
"helpLinkLabel": "פתח עזרה בנושא תגיות עדיפות",
"modelTypes": {
"lora": "LoRA",
"checkpoint": "Checkpoint",
"embedding": "Embedding"
},
"saveSuccess": "תגיות העדיפות עודכנו.",
"saveError": "עדכון תגיות העדיפות נכשל.",
"loadingSuggestions": "טוען הצעות...",
"validation": {
"missingClosingParen": "לרשומה {index} חסר סוגר סוגריים.",
"missingCanonical": "על הרשומה {index} לכלול שם תגית קנונית.",
"duplicateCanonical": "התגית הקנונית \"{tag}\" מופיעה יותר מפעם אחת.",
"unknown": "תצורת תגיות העדיפות שגויה."
}
} }
}, },
"loras": { "loras": {
@@ -588,10 +589,26 @@
"selectLoraRoot": "אנא בחר ספריית שורש של LoRA" "selectLoraRoot": "אנא בחר ספריית שורש של LoRA"
} }
}, },
"sort": {
"title": "מיון מתכונים לפי...",
"name": "שם",
"nameAsc": "א - ת",
"nameDesc": "ת - א",
"date": "תאריך",
"dateDesc": "הכי חדש",
"dateAsc": "הכי ישן",
"lorasCount": "מספר LoRAs",
"lorasCountDesc": "הכי הרבה",
"lorasCountAsc": "הכי פחות"
},
"refresh": { "refresh": {
"title": "רענן רשימת מתכונים" "title": "רענן רשימת מתכונים"
}, },
"filteredByLora": "מסונן לפי LoRA" "filteredByLora": "מסונן לפי LoRA",
"favorites": {
"title": "הצג מועדפים בלבד",
"action": "מועדפים"
}
}, },
"duplicates": { "duplicates": {
"found": "נמצאו {count} קבוצות כפולות", "found": "נמצאו {count} קבוצות כפולות",
@@ -638,7 +655,8 @@
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ", "recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
"collapseAllDisabled": "לא זמין בתצוגת רשימה", "collapseAllDisabled": "לא זמין בתצוגת רשימה",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה." "unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -1460,7 +1478,8 @@
"bulkMoveFailures": "העברות שנכשלו:\n{failures}", "bulkMoveFailures": "העברות שנכשלו:\n{failures}",
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s", "bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!", "exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}" "exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {
@@ -1478,4 +1497,4 @@
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
} }
} }
} }

View File

@@ -188,7 +188,8 @@
"creator": "作成者", "creator": "作成者",
"title": "レシピタイトル", "title": "レシピタイトル",
"loraName": "LoRAファイル名", "loraName": "LoRAファイル名",
"loraModel": "LoRAモデル名" "loraModel": "LoRAモデル名",
"prompt": "プロンプト"
} }
}, },
"filter": { "filter": {
@@ -588,10 +589,26 @@
"selectLoraRoot": "LoRAルートディレクトリを選択してください" "selectLoraRoot": "LoRAルートディレクトリを選択してください"
} }
}, },
"sort": {
"title": "レシピの並び替え...",
"name": "名前",
"nameAsc": "A - Z",
"nameDesc": "Z - A",
"date": "日付",
"dateDesc": "新しい順",
"dateAsc": "古い順",
"lorasCount": "LoRA数",
"lorasCountDesc": "多い順",
"lorasCountAsc": "少ない順"
},
"refresh": { "refresh": {
"title": "レシピリストを更新" "title": "レシピリストを更新"
}, },
"filteredByLora": "LoRAでフィルタ済み" "filteredByLora": "LoRAでフィルタ済み",
"favorites": {
"title": "お気に入りのみ表示",
"action": "お気に入り"
}
}, },
"duplicates": { "duplicates": {
"found": "{count} 個の重複グループが見つかりました", "found": "{count} 個の重複グループが見つかりました",
@@ -638,7 +655,8 @@
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます", "recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
"collapseAllDisabled": "リストビューでは利用できません", "collapseAllDisabled": "リストビューでは利用できません",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "移動先のパスを特定できません。" "unableToResolveRoot": "移動先のパスを特定できません。",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -1460,7 +1478,8 @@
"bulkMoveFailures": "失敗した移動:\n{failures}", "bulkMoveFailures": "失敗した移動:\n{failures}",
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました", "bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!", "exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}" "exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {
@@ -1478,4 +1497,4 @@
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
} }
} }
} }

View File

@@ -188,7 +188,8 @@
"creator": "제작자", "creator": "제작자",
"title": "레시피 제목", "title": "레시피 제목",
"loraName": "LoRA 파일명", "loraName": "LoRA 파일명",
"loraModel": "LoRA 모델명" "loraModel": "LoRA 모델명",
"prompt": "프롬프트"
} }
}, },
"filter": { "filter": {
@@ -588,10 +589,26 @@
"selectLoraRoot": "LoRA 루트 디렉토리를 선택해주세요" "selectLoraRoot": "LoRA 루트 디렉토리를 선택해주세요"
} }
}, },
"sort": {
"title": "레시피 정렬...",
"name": "이름",
"nameAsc": "A - Z",
"nameDesc": "Z - A",
"date": "날짜",
"dateDesc": "최신순",
"dateAsc": "오래된순",
"lorasCount": "LoRA 수",
"lorasCountDesc": "많은순",
"lorasCountAsc": "적은순"
},
"refresh": { "refresh": {
"title": "레시피 목록 새로고침" "title": "레시피 목록 새로고침"
}, },
"filteredByLora": "LoRA로 필터링됨" "filteredByLora": "LoRA로 필터링됨",
"favorites": {
"title": "즐겨찾기만 표시",
"action": "즐겨찾기"
}
}, },
"duplicates": { "duplicates": {
"found": "{count}개의 중복 그룹 발견", "found": "{count}개의 중복 그룹 발견",
@@ -638,7 +655,8 @@
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다", "recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다", "collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다." "unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -1460,7 +1478,8 @@
"bulkMoveFailures": "실패한 이동:\n{failures}", "bulkMoveFailures": "실패한 이동:\n{failures}",
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다", "bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!", "exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}" "exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {
@@ -1478,4 +1497,4 @@
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
} }
} }
} }

View File

@@ -188,7 +188,8 @@
"creator": "Автор", "creator": "Автор",
"title": "Название рецепта", "title": "Название рецепта",
"loraName": "Имя файла LoRA", "loraName": "Имя файла LoRA",
"loraModel": "Название модели LoRA" "loraModel": "Название модели LoRA",
"prompt": "Запрос"
} }
}, },
"filter": { "filter": {
@@ -588,10 +589,26 @@
"selectLoraRoot": "Пожалуйста, выберите корневую папку LoRA" "selectLoraRoot": "Пожалуйста, выберите корневую папку LoRA"
} }
}, },
"sort": {
"title": "Сортировка рецептов...",
"name": "Имя",
"nameAsc": "А - Я",
"nameDesc": "Я - А",
"date": "Дата",
"dateDesc": "Сначала новые",
"dateAsc": "Сначала старые",
"lorasCount": "Кол-во LoRA",
"lorasCountDesc": "Больше всего",
"lorasCountAsc": "Меньше всего"
},
"refresh": { "refresh": {
"title": "Обновить список рецептов" "title": "Обновить список рецептов"
}, },
"filteredByLora": "Фильтр по LoRA" "filteredByLora": "Фильтр по LoRA",
"favorites": {
"title": "Только избранные",
"action": "Избранное"
}
}, },
"duplicates": { "duplicates": {
"found": "Найдено {count} групп дубликатов", "found": "Найдено {count} групп дубликатов",
@@ -638,7 +655,8 @@
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева", "recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
"collapseAllDisabled": "Недоступно в виде списка", "collapseAllDisabled": "Недоступно в виде списка",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения." "unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -1460,7 +1478,8 @@
"bulkMoveFailures": "Неудачные перемещения:\n{failures}", "bulkMoveFailures": "Неудачные перемещения:\n{failures}",
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s", "bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!", "exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}" "exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {
@@ -1478,4 +1497,4 @@
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
} }
} }
} }

View File

@@ -188,7 +188,8 @@
"creator": "创作者", "creator": "创作者",
"title": "配方标题", "title": "配方标题",
"loraName": "LoRA 文件名", "loraName": "LoRA 文件名",
"loraModel": "LoRA 模型名称" "loraModel": "LoRA 模型名称",
"prompt": "提示词"
} }
}, },
"filter": { "filter": {
@@ -588,10 +589,26 @@
"selectLoraRoot": "请选择 LoRA 根目录" "selectLoraRoot": "请选择 LoRA 根目录"
} }
}, },
"sort": {
"title": "配方排序...",
"name": "名称",
"nameAsc": "A - Z",
"nameDesc": "Z - A",
"date": "时间",
"dateDesc": "最新",
"dateAsc": "最早",
"lorasCount": "LoRA 数量",
"lorasCountDesc": "最多",
"lorasCountAsc": "最少"
},
"refresh": { "refresh": {
"title": "刷新配方列表" "title": "刷新配方列表"
}, },
"filteredByLora": "按 LoRA 筛选" "filteredByLora": "按 LoRA 筛选",
"favorites": {
"title": "仅显示收藏",
"action": "收藏"
}
}, },
"duplicates": { "duplicates": {
"found": "发现 {count} 个重复组", "found": "发现 {count} 个重复组",
@@ -638,7 +655,8 @@
"recursiveUnavailable": "仅在树形视图中可使用递归搜索", "recursiveUnavailable": "仅在树形视图中可使用递归搜索",
"collapseAllDisabled": "列表视图下不可用", "collapseAllDisabled": "列表视图下不可用",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "无法确定移动的目标路径。" "unableToResolveRoot": "无法确定移动的目标路径。",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -1460,7 +1478,8 @@
"bulkMoveFailures": "移动失败:\n{failures}", "bulkMoveFailures": "移动失败:\n{failures}",
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}", "bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
"exampleImagesDownloadSuccess": "示例图片下载成功!", "exampleImagesDownloadSuccess": "示例图片下载成功!",
"exampleImagesDownloadFailed": "示例图片下载失败:{message}" "exampleImagesDownloadFailed": "示例图片下载失败:{message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {
@@ -1478,4 +1497,4 @@
"learnMore": "浏览器插件教程" "learnMore": "浏览器插件教程"
} }
} }
} }

View File

@@ -188,7 +188,8 @@
"creator": "創作者", "creator": "創作者",
"title": "配方標題", "title": "配方標題",
"loraName": "LoRA 檔案名稱", "loraName": "LoRA 檔案名稱",
"loraModel": "LoRA 模型名稱" "loraModel": "LoRA 模型名稱",
"prompt": "提示詞"
} }
}, },
"filter": { "filter": {
@@ -588,10 +589,26 @@
"selectLoraRoot": "請選擇 LoRA 根目錄" "selectLoraRoot": "請選擇 LoRA 根目錄"
} }
}, },
"sort": {
"title": "配方排序...",
"name": "名稱",
"nameAsc": "A - Z",
"nameDesc": "Z - A",
"date": "時間",
"dateDesc": "最新",
"dateAsc": "最舊",
"lorasCount": "LoRA 數量",
"lorasCountDesc": "最多",
"lorasCountAsc": "最少"
},
"refresh": { "refresh": {
"title": "重新整理配方列表" "title": "重新整理配方列表"
}, },
"filteredByLora": "已依 LoRA 篩選" "filteredByLora": "已依 LoRA 篩選",
"favorites": {
"title": "僅顯示收藏",
"action": "收藏"
}
}, },
"duplicates": { "duplicates": {
"found": "發現 {count} 組重複項", "found": "發現 {count} 組重複項",
@@ -638,7 +655,8 @@
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用", "recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
"collapseAllDisabled": "列表檢視下不可用", "collapseAllDisabled": "列表檢視下不可用",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "無法確定移動的目標路徑。" "unableToResolveRoot": "無法確定移動的目標路徑。",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -1460,7 +1478,8 @@
"bulkMoveFailures": "移動失敗:\n{failures}", "bulkMoveFailures": "移動失敗:\n{failures}",
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}", "bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
"exampleImagesDownloadSuccess": "範例圖片下載成功!", "exampleImagesDownloadSuccess": "範例圖片下載成功!",
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}" "exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {
@@ -1478,4 +1497,4 @@
"learnMore": "LM Civitai Extension Tutorial" "learnMore": "LM Civitai Extension Tutorial"
} }
} }
} }

50
py/recipes/merger.py Normal file
View File

@@ -0,0 +1,50 @@
from typing import Any, Dict, Optional
import logging
logger = logging.getLogger(__name__)
class GenParamsMerger:
"""Utility to merge generation parameters from multiple sources with priority."""
BLACKLISTED_KEYS = {"id", "url", "userId", "username", "createdAt", "updatedAt", "hash"}
@staticmethod
def merge(
request_params: Optional[Dict[str, Any]] = None,
civitai_meta: Optional[Dict[str, Any]] = None,
embedded_metadata: Optional[Dict[str, Any]] = None
) -> Dict[str, Any]:
"""
Merge generation parameters from three sources.
Priority: request_params > civitai_meta > embedded_metadata
Args:
request_params: Params provided directly in the import request
civitai_meta: Params from Civitai Image API 'meta' field
embedded_metadata: Params extracted from image EXIF/embedded metadata
Returns:
Merged parameters dictionary
"""
result = {}
# 1. Start with embedded metadata (lowest priority)
if embedded_metadata:
# If it's a full recipe metadata, we use its gen_params
if "gen_params" in embedded_metadata and isinstance(embedded_metadata["gen_params"], dict):
result.update(embedded_metadata["gen_params"])
else:
# Otherwise assume the dict itself contains gen_params
result.update(embedded_metadata)
# 2. Layer Civitai meta (medium priority)
if civitai_meta:
result.update(civitai_meta)
# 3. Layer request params (highest priority)
if request_params:
result.update(request_params)
# Filter out blacklisted keys
return {k: v for k, v in result.items() if k not in GenParamsMerger.BLACKLISTED_KEYS}

View File

@@ -36,9 +36,6 @@ class ComfyMetadataParser(RecipeMetadataParser):
# Find all LoraLoader nodes # Find all LoraLoader nodes
lora_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'LoraLoader'} lora_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'LoraLoader'}
if not lora_nodes:
return {"error": "No LoRA information found in this ComfyUI workflow", "loras": []}
# Process each LoraLoader node # Process each LoraLoader node
for node_id, node in lora_nodes.items(): for node_id, node in lora_nodes.items():
if 'inputs' not in node or 'lora_name' not in node['inputs']: if 'inputs' not in node or 'lora_name' not in node['inputs']:

View File

@@ -24,6 +24,8 @@ from ...services.recipes import (
) )
from ...services.metadata_service import get_default_metadata_provider from ...services.metadata_service import get_default_metadata_provider
from ...utils.civitai_utils import rewrite_preview_url from ...utils.civitai_utils import rewrite_preview_url
from ...utils.exif_utils import ExifUtils
from ...recipes.merger import GenParamsMerger
Logger = logging.Logger Logger = logging.Logger
EnsureDependenciesCallable = Callable[[], Awaitable[None]] EnsureDependenciesCallable = Callable[[], Awaitable[None]]
@@ -56,16 +58,22 @@ class RecipeHandlerSet:
"delete_recipe": self.management.delete_recipe, "delete_recipe": self.management.delete_recipe,
"get_top_tags": self.query.get_top_tags, "get_top_tags": self.query.get_top_tags,
"get_base_models": self.query.get_base_models, "get_base_models": self.query.get_base_models,
"get_roots": self.query.get_roots,
"get_folders": self.query.get_folders,
"get_folder_tree": self.query.get_folder_tree,
"get_unified_folder_tree": self.query.get_unified_folder_tree,
"share_recipe": self.sharing.share_recipe, "share_recipe": self.sharing.share_recipe,
"download_shared_recipe": self.sharing.download_shared_recipe, "download_shared_recipe": self.sharing.download_shared_recipe,
"get_recipe_syntax": self.query.get_recipe_syntax, "get_recipe_syntax": self.query.get_recipe_syntax,
"update_recipe": self.management.update_recipe, "update_recipe": self.management.update_recipe,
"reconnect_lora": self.management.reconnect_lora, "reconnect_lora": self.management.reconnect_lora,
"find_duplicates": self.query.find_duplicates, "find_duplicates": self.query.find_duplicates,
"move_recipes_bulk": self.management.move_recipes_bulk,
"bulk_delete": self.management.bulk_delete, "bulk_delete": self.management.bulk_delete,
"save_recipe_from_widget": self.management.save_recipe_from_widget, "save_recipe_from_widget": self.management.save_recipe_from_widget,
"get_recipes_for_lora": self.query.get_recipes_for_lora, "get_recipes_for_lora": self.query.get_recipes_for_lora,
"scan_recipes": self.query.scan_recipes, "scan_recipes": self.query.scan_recipes,
"move_recipe": self.management.move_recipe,
} }
@@ -149,12 +157,15 @@ class RecipeListingHandler:
page_size = int(request.query.get("page_size", "20")) page_size = int(request.query.get("page_size", "20"))
sort_by = request.query.get("sort_by", "date") sort_by = request.query.get("sort_by", "date")
search = request.query.get("search") search = request.query.get("search")
folder = request.query.get("folder")
recursive = request.query.get("recursive", "true").lower() == "true"
search_options = { search_options = {
"title": request.query.get("search_title", "true").lower() == "true", "title": request.query.get("search_title", "true").lower() == "true",
"tags": request.query.get("search_tags", "true").lower() == "true", "tags": request.query.get("search_tags", "true").lower() == "true",
"lora_name": request.query.get("search_lora_name", "true").lower() == "true", "lora_name": request.query.get("search_lora_name", "true").lower() == "true",
"lora_model": request.query.get("search_lora_model", "true").lower() == "true", "lora_model": request.query.get("search_lora_model", "true").lower() == "true",
"prompt": request.query.get("search_prompt", "true").lower() == "true",
} }
filters: Dict[str, Any] = {} filters: Dict[str, Any] = {}
@@ -162,6 +173,9 @@ class RecipeListingHandler:
if base_models: if base_models:
filters["base_model"] = base_models.split(",") filters["base_model"] = base_models.split(",")
if request.query.get("favorite", "false").lower() == "true":
filters["favorite"] = True
tag_filters: Dict[str, str] = {} tag_filters: Dict[str, str] = {}
legacy_tags = request.query.get("tags") legacy_tags = request.query.get("tags")
if legacy_tags: if legacy_tags:
@@ -193,6 +207,8 @@ class RecipeListingHandler:
filters=filters, filters=filters,
search_options=search_options, search_options=search_options,
lora_hash=lora_hash, lora_hash=lora_hash,
folder=folder,
recursive=recursive,
) )
for item in result.get("items", []): for item in result.get("items", []):
@@ -299,6 +315,58 @@ class RecipeQueryHandler:
self._logger.error("Error retrieving base models: %s", exc, exc_info=True) self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500) return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_roots(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
roots = [recipe_scanner.recipes_dir] if recipe_scanner.recipes_dir else []
return web.json_response({"success": True, "roots": roots})
except Exception as exc:
self._logger.error("Error retrieving recipe roots: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_folders(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
folders = await recipe_scanner.get_folders()
return web.json_response({"success": True, "folders": folders})
except Exception as exc:
self._logger.error("Error retrieving recipe folders: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_folder_tree(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
folder_tree = await recipe_scanner.get_folder_tree()
return web.json_response({"success": True, "tree": folder_tree})
except Exception as exc:
self._logger.error("Error retrieving recipe folder tree: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_unified_folder_tree(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
folder_tree = await recipe_scanner.get_folder_tree()
return web.json_response({"success": True, "tree": folder_tree})
except Exception as exc:
self._logger.error("Error retrieving unified recipe folder tree: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def get_recipes_for_lora(self, request: web.Request) -> web.Response: async def get_recipes_for_lora(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
@@ -486,7 +554,41 @@ class RecipeManagementHandler:
metadata["base_model"] = base_model_from_metadata metadata["base_model"] = base_model_from_metadata
tags = self._parse_tags(params.get("tags")) tags = self._parse_tags(params.get("tags"))
image_bytes, extension = await self._download_remote_media(image_url) image_bytes, extension, civitai_meta = await self._download_remote_media(image_url)
# Extract embedded metadata from the downloaded image
embedded_metadata = None
try:
with tempfile.NamedTemporaryFile(suffix=extension, delete=False) as temp_img:
temp_img.write(image_bytes)
temp_img_path = temp_img.name
try:
raw_embedded = ExifUtils.extract_image_metadata(temp_img_path)
if raw_embedded:
# Try to parse it using standard parsers if it looks like a recipe
parser = self._analysis_service._recipe_parser_factory.create_parser(raw_embedded)
if parser:
parsed_embedded = await parser.parse_metadata(raw_embedded, recipe_scanner=recipe_scanner)
embedded_metadata = parsed_embedded
else:
# Fallback to raw string if no parser matches (might be simple params)
embedded_metadata = {"gen_params": {"raw_metadata": raw_embedded}}
finally:
if os.path.exists(temp_img_path):
os.unlink(temp_img_path)
except Exception as exc:
self._logger.warning("Failed to extract embedded metadata during import: %s", exc)
# Merge gen_params from all sources
merged_gen_params = GenParamsMerger.merge(
request_params=gen_params,
civitai_meta=civitai_meta,
embedded_metadata=embedded_metadata
)
if merged_gen_params:
metadata["gen_params"] = merged_gen_params
result = await self._persistence_service.save_recipe( result = await self._persistence_service.save_recipe(
recipe_scanner=recipe_scanner, recipe_scanner=recipe_scanner,
@@ -545,6 +647,64 @@ class RecipeManagementHandler:
self._logger.error("Error updating recipe: %s", exc, exc_info=True) self._logger.error("Error updating recipe: %s", exc, exc_info=True)
return web.json_response({"error": str(exc)}, status=500) return web.json_response({"error": str(exc)}, status=500)
async def move_recipe(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
data = await request.json()
recipe_id = data.get("recipe_id")
target_path = data.get("target_path")
if not recipe_id or not target_path:
return web.json_response(
{"success": False, "error": "recipe_id and target_path are required"}, status=400
)
result = await self._persistence_service.move_recipe(
recipe_scanner=recipe_scanner,
recipe_id=str(recipe_id),
target_path=str(target_path),
)
return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except RecipeNotFoundError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404)
except Exception as exc:
self._logger.error("Error moving recipe: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def move_recipes_bulk(self, request: web.Request) -> web.Response:
try:
await self._ensure_dependencies_ready()
recipe_scanner = self._recipe_scanner_getter()
if recipe_scanner is None:
raise RuntimeError("Recipe scanner unavailable")
data = await request.json()
recipe_ids = data.get("recipe_ids") or []
target_path = data.get("target_path")
if not recipe_ids or not target_path:
return web.json_response(
{"success": False, "error": "recipe_ids and target_path are required"}, status=400
)
result = await self._persistence_service.move_recipes_bulk(
recipe_scanner=recipe_scanner,
recipe_ids=recipe_ids,
target_path=str(target_path),
)
return web.json_response(result.payload, status=result.status)
except RecipeValidationError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=400)
except RecipeNotFoundError as exc:
return web.json_response({"success": False, "error": str(exc)}, status=404)
except Exception as exc:
self._logger.error("Error moving recipes in bulk: %s", exc, exc_info=True)
return web.json_response({"success": False, "error": str(exc)}, status=500)
async def reconnect_lora(self, request: web.Request) -> web.Response: async def reconnect_lora(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
@@ -776,7 +936,7 @@ class RecipeManagementHandler:
extension = ".webp" # Default to webp if unknown extension = ".webp" # Default to webp if unknown
with open(temp_path, "rb") as file_obj: with open(temp_path, "rb") as file_obj:
return file_obj.read(), extension return file_obj.read(), extension, image_info.get("meta") if civitai_match and image_info else None
except RecipeDownloadError: except RecipeDownloadError:
raise raise
except RecipeValidationError: except RecipeValidationError:

View File

@@ -27,10 +27,16 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"), RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"), RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"), RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
RouteDefinition("GET", "/api/lm/recipes/folder-tree", "get_folder_tree"),
RouteDefinition("GET", "/api/lm/recipes/unified-folder-tree", "get_unified_folder_tree"),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"),
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"), RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"),
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"), RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"), RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"), RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),

View File

@@ -7,12 +7,18 @@ from natsort import natsorted
@dataclass @dataclass
class RecipeCache: class RecipeCache:
"""Cache structure for Recipe data""" """Cache structure for Recipe data"""
raw_data: List[Dict] raw_data: List[Dict]
sorted_by_name: List[Dict] sorted_by_name: List[Dict]
sorted_by_date: List[Dict] sorted_by_date: List[Dict]
folders: List[str] | None = None
folder_tree: Dict | None = None
def __post_init__(self): def __post_init__(self):
self._lock = asyncio.Lock() self._lock = asyncio.Lock()
# Normalize optional metadata containers
self.folders = self.folders or []
self.folder_tree = self.folder_tree or {}
async def resort(self, name_only: bool = False): async def resort(self, name_only: bool = False):
"""Resort all cached data views""" """Resort all cached data views"""

View File

@@ -1,7 +1,9 @@
import os from __future__ import annotations
import logging
import asyncio import asyncio
import json import json
import logging
import os
import time import time
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
from ..config import config from ..config import config
@@ -117,7 +119,9 @@ class RecipeScanner:
self._cache = RecipeCache( self._cache = RecipeCache(
raw_data=[], raw_data=[],
sorted_by_name=[], sorted_by_name=[],
sorted_by_date=[] sorted_by_date=[],
folders=[],
folder_tree={},
) )
# Mark as initializing to prevent concurrent initializations # Mark as initializing to prevent concurrent initializations
@@ -218,6 +222,7 @@ class RecipeScanner:
# Update cache with the collected data # Update cache with the collected data
self._cache.raw_data = recipes self._cache.raw_data = recipes
self._update_folder_metadata(self._cache)
# Create a simplified resort function that doesn't use await # Create a simplified resort function that doesn't use await
if hasattr(self._cache, "resort"): if hasattr(self._cache, "resort"):
@@ -336,6 +341,9 @@ class RecipeScanner:
if not self._cache: if not self._cache:
return return
# Keep folder metadata up to date alongside sort order
self._update_folder_metadata()
async def _resort_wrapper() -> None: async def _resort_wrapper() -> None:
try: try:
await self._cache.resort(name_only=name_only) await self._cache.resort(name_only=name_only)
@@ -346,6 +354,75 @@ class RecipeScanner:
self._resort_tasks.add(task) self._resort_tasks.add(task)
task.add_done_callback(lambda finished: self._resort_tasks.discard(finished)) task.add_done_callback(lambda finished: self._resort_tasks.discard(finished))
def _calculate_folder(self, recipe_path: str) -> str:
"""Calculate a normalized folder path relative to ``recipes_dir``."""
recipes_dir = self.recipes_dir
if not recipes_dir:
return ""
try:
recipe_dir = os.path.dirname(os.path.normpath(recipe_path))
relative_dir = os.path.relpath(recipe_dir, recipes_dir)
if relative_dir in (".", ""):
return ""
return relative_dir.replace(os.path.sep, "/")
except Exception:
return ""
def _build_folder_tree(self, folders: list[str]) -> dict:
"""Build a nested folder tree structure from relative folder paths."""
tree: dict[str, dict] = {}
for folder in folders:
if not folder:
continue
parts = folder.split("/")
current_level = tree
for part in parts:
if part not in current_level:
current_level[part] = {}
current_level = current_level[part]
return tree
def _update_folder_metadata(self, cache: RecipeCache | None = None) -> None:
"""Ensure folder lists and tree metadata are synchronized with cache contents."""
cache = cache or self._cache
if cache is None:
return
folders: set[str] = set()
for item in cache.raw_data:
folder_value = item.get("folder", "")
if folder_value is None:
folder_value = ""
if folder_value == ".":
folder_value = ""
normalized = str(folder_value).replace("\\", "/")
item["folder"] = normalized
folders.add(normalized)
cache.folders = sorted(folders, key=lambda entry: entry.lower())
cache.folder_tree = self._build_folder_tree(cache.folders)
async def get_folders(self) -> list[str]:
"""Return a sorted list of recipe folders relative to the recipes root."""
cache = await self.get_cached_data()
self._update_folder_metadata(cache)
return cache.folders
async def get_folder_tree(self) -> dict:
"""Return a hierarchical tree of recipe folders for sidebar navigation."""
cache = await self.get_cached_data()
self._update_folder_metadata(cache)
return cache.folder_tree
@property @property
def recipes_dir(self) -> str: def recipes_dir(self) -> str:
"""Get path to recipes directory""" """Get path to recipes directory"""
@@ -362,11 +439,14 @@ class RecipeScanner:
"""Get cached recipe data, refresh if needed""" """Get cached recipe data, refresh if needed"""
# If cache is already initialized and no refresh is needed, return it immediately # If cache is already initialized and no refresh is needed, return it immediately
if self._cache is not None and not force_refresh: if self._cache is not None and not force_refresh:
self._update_folder_metadata()
return self._cache return self._cache
# If another initialization is already in progress, wait for it to complete # If another initialization is already in progress, wait for it to complete
if self._is_initializing and not force_refresh: if self._is_initializing and not force_refresh:
return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[]) return self._cache or RecipeCache(
raw_data=[], sorted_by_name=[], sorted_by_date=[], folders=[], folder_tree={}
)
# If force refresh is requested, initialize the cache directly # If force refresh is requested, initialize the cache directly
if force_refresh: if force_refresh:
@@ -384,11 +464,14 @@ class RecipeScanner:
self._cache = RecipeCache( self._cache = RecipeCache(
raw_data=raw_data, raw_data=raw_data,
sorted_by_name=[], sorted_by_name=[],
sorted_by_date=[] sorted_by_date=[],
folders=[],
folder_tree={},
) )
# Resort cache # Resort cache
await self._cache.resort() await self._cache.resort()
self._update_folder_metadata(self._cache)
return self._cache return self._cache
@@ -398,7 +481,9 @@ class RecipeScanner:
self._cache = RecipeCache( self._cache = RecipeCache(
raw_data=[], raw_data=[],
sorted_by_name=[], sorted_by_name=[],
sorted_by_date=[] sorted_by_date=[],
folders=[],
folder_tree={},
) )
return self._cache return self._cache
finally: finally:
@@ -409,7 +494,9 @@ class RecipeScanner:
logger.error(f"Unexpected error in get_cached_data: {e}") logger.error(f"Unexpected error in get_cached_data: {e}")
# Return the cache (may be empty or partially initialized) # Return the cache (may be empty or partially initialized)
return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[]) return self._cache or RecipeCache(
raw_data=[], sorted_by_name=[], sorted_by_date=[], folders=[], folder_tree={}
)
async def refresh_cache(self, force: bool = False) -> RecipeCache: async def refresh_cache(self, force: bool = False) -> RecipeCache:
"""Public helper to refresh or return the recipe cache.""" """Public helper to refresh or return the recipe cache."""
@@ -424,6 +511,7 @@ class RecipeScanner:
cache = await self.get_cached_data() cache = await self.get_cached_data()
await cache.add_recipe(recipe_data, resort=False) await cache.add_recipe(recipe_data, resort=False)
self._update_folder_metadata(cache)
self._schedule_resort() self._schedule_resort()
async def remove_recipe(self, recipe_id: str) -> bool: async def remove_recipe(self, recipe_id: str) -> bool:
@@ -437,6 +525,7 @@ class RecipeScanner:
if removed is None: if removed is None:
return False return False
self._update_folder_metadata(cache)
self._schedule_resort() self._schedule_resort()
return True return True
@@ -521,6 +610,9 @@ class RecipeScanner:
if path_updated: if path_updated:
self._write_recipe_file(recipe_path, recipe_data) self._write_recipe_file(recipe_path, recipe_data)
# Track folder placement relative to recipes directory
recipe_data['folder'] = recipe_data.get('folder') or self._calculate_folder(recipe_path)
# Ensure loras array exists # Ensure loras array exists
if 'loras' not in recipe_data: if 'loras' not in recipe_data:
@@ -914,7 +1006,7 @@ class RecipeScanner:
return await self._lora_scanner.get_model_info_by_name(name) return await self._lora_scanner.get_model_info_by_name(name)
async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'date', search: str = None, filters: dict = None, search_options: dict = None, lora_hash: str = None, bypass_filters: bool = True): async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'date', search: str = None, filters: dict = None, search_options: dict = None, lora_hash: str = None, bypass_filters: bool = True, folder: str | None = None, recursive: bool = True):
"""Get paginated and filtered recipe data """Get paginated and filtered recipe data
Args: Args:
@@ -926,11 +1018,20 @@ class RecipeScanner:
search_options: Dictionary of search options to apply search_options: Dictionary of search options to apply
lora_hash: Optional SHA256 hash of a LoRA to filter recipes by lora_hash: Optional SHA256 hash of a LoRA to filter recipes by
bypass_filters: If True, ignore other filters when a lora_hash is provided bypass_filters: If True, ignore other filters when a lora_hash is provided
folder: Optional folder filter relative to recipes directory
recursive: Whether to include recipes in subfolders of the selected folder
""" """
cache = await self.get_cached_data() cache = await self.get_cached_data()
# Get base dataset # Get base dataset
filtered_data = cache.sorted_by_date if sort_by == 'date' else cache.sorted_by_name sort_field = sort_by.split(':')[0] if ':' in sort_by else sort_by
if sort_field == 'date':
filtered_data = list(cache.sorted_by_date)
elif sort_field == 'name':
filtered_data = list(cache.sorted_by_name)
else:
filtered_data = list(cache.raw_data)
# Apply SFW filtering if enabled # Apply SFW filtering if enabled
from .settings_manager import get_settings_manager from .settings_manager import get_settings_manager
@@ -961,6 +1062,22 @@ class RecipeScanner:
# Skip further filtering if we're only filtering by LoRA hash with bypass enabled # Skip further filtering if we're only filtering by LoRA hash with bypass enabled
if not (lora_hash and bypass_filters): if not (lora_hash and bypass_filters):
# Apply folder filter before other criteria
normalized_folder = (folder or "").strip("/")
if normalized_folder:
def matches_folder(item_folder: str) -> bool:
item_path = (item_folder or "").strip("/")
if not item_path:
return False
if recursive:
return item_path == normalized_folder or item_path.startswith(f"{normalized_folder}/")
return item_path == normalized_folder
filtered_data = [
item for item in filtered_data
if matches_folder(item.get('folder', ''))
]
# Apply search filter # Apply search filter
if search: if search:
# Default search options if none provided # Default search options if none provided
@@ -997,6 +1114,14 @@ class RecipeScanner:
if fuzzy_match(str(lora.get('modelName', '')), search): if fuzzy_match(str(lora.get('modelName', '')), search):
return True return True
# Search in prompt and negative_prompt if enabled
if search_options.get('prompt', True) and 'gen_params' in item:
gen_params = item['gen_params']
if fuzzy_match(str(gen_params.get('prompt', '')), search):
return True
if fuzzy_match(str(gen_params.get('negative_prompt', '')), search):
return True
# No match found # No match found
return False return False
@@ -1012,6 +1137,13 @@ class RecipeScanner:
if item.get('base_model', '') in filters['base_model'] if item.get('base_model', '') in filters['base_model']
] ]
# Filter by favorite
if 'favorite' in filters and filters['favorite']:
filtered_data = [
item for item in filtered_data
if item.get('favorite') is True
]
# Filter by tags # Filter by tags
if 'tags' in filters and filters['tags']: if 'tags' in filters and filters['tags']:
tag_spec = filters['tags'] tag_spec = filters['tags']
@@ -1041,6 +1173,20 @@ class RecipeScanner:
if not any(tag in exclude_tags for tag in (item.get('tags', []) or [])) if not any(tag in exclude_tags for tag in (item.get('tags', []) or []))
] ]
# Apply sorting if not already handled by pre-sorted cache
if ':' in sort_by or sort_field == 'loras_count':
field, order = (sort_by.split(':') + ['desc'])[:2]
reverse = order.lower() == 'desc'
if field == 'name':
filtered_data = natsorted(filtered_data, key=lambda x: x.get('title', '').lower(), reverse=reverse)
elif field == 'date':
# Use modified if available, falling back to created_date
filtered_data.sort(key=lambda x: (x.get('modified', x.get('created_date', 0)), x.get('file_path', '')), reverse=reverse)
elif field == 'loras_count':
filtered_data.sort(key=lambda x: len(x.get('loras', [])), reverse=reverse)
# Calculate pagination # Calculate pagination
total_items = len(filtered_data) total_items = len(filtered_data)
start_idx = (page - 1) * page_size start_idx = (page - 1) * page_size
@@ -1136,6 +1282,30 @@ class RecipeScanner:
from datetime import datetime from datetime import datetime
return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S') return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')
async def get_recipe_json_path(self, recipe_id: str) -> Optional[str]:
"""Locate the recipe JSON file, accounting for folder placement."""
recipes_dir = self.recipes_dir
if not recipes_dir:
return None
cache = await self.get_cached_data()
folder = ""
for item in cache.raw_data:
if str(item.get("id")) == str(recipe_id):
folder = item.get("folder") or ""
break
candidate = os.path.normpath(os.path.join(recipes_dir, folder, f"{recipe_id}.recipe.json"))
if os.path.exists(candidate):
return candidate
for root, _, files in os.walk(recipes_dir):
if f"{recipe_id}.recipe.json" in files:
return os.path.join(root, f"{recipe_id}.recipe.json")
return None
async def update_recipe_metadata(self, recipe_id: str, metadata: dict) -> bool: async def update_recipe_metadata(self, recipe_id: str, metadata: dict) -> bool:
"""Update recipe metadata (like title and tags) in both file system and cache """Update recipe metadata (like title and tags) in both file system and cache
@@ -1146,13 +1316,9 @@ class RecipeScanner:
Returns: Returns:
bool: True if successful, False otherwise bool: True if successful, False otherwise
""" """
import os
import json
# First, find the recipe JSON file path # First, find the recipe JSON file path
recipe_json_path = os.path.join(self.recipes_dir, f"{recipe_id}.recipe.json") recipe_json_path = await self.get_recipe_json_path(recipe_id)
if not recipe_json_path or not os.path.exists(recipe_json_path):
if not os.path.exists(recipe_json_path):
return False return False
try: try:
@@ -1201,8 +1367,8 @@ class RecipeScanner:
if target_name is None: if target_name is None:
raise ValueError("target_name must be provided") raise ValueError("target_name must be provided")
recipe_json_path = os.path.join(self.recipes_dir, f"{recipe_id}.recipe.json") recipe_json_path = await self.get_recipe_json_path(recipe_id)
if not os.path.exists(recipe_json_path): if not recipe_json_path or not os.path.exists(recipe_json_path):
raise RecipeNotFoundError("Recipe not found") raise RecipeNotFoundError("Recipe not found")
async with self._mutation_lock: async with self._mutation_lock:

View File

@@ -5,6 +5,7 @@ import base64
import json import json
import os import os
import re import re
import shutil
import time import time
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
@@ -154,12 +155,8 @@ class RecipePersistenceService:
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult: async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
"""Delete an existing recipe.""" """Delete an existing recipe."""
recipes_dir = recipe_scanner.recipes_dir recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
if not recipes_dir or not os.path.exists(recipes_dir): if not recipe_json_path or not os.path.exists(recipe_json_path):
raise RecipeNotFoundError("Recipes directory not found")
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
if not os.path.exists(recipe_json_path):
raise RecipeNotFoundError("Recipe not found") raise RecipeNotFoundError("Recipe not found")
with open(recipe_json_path, "r", encoding="utf-8") as file_obj: with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
@@ -176,9 +173,9 @@ class RecipePersistenceService:
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult: async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
"""Update persisted metadata for a recipe.""" """Update persisted metadata for a recipe."""
if not any(key in updates for key in ("title", "tags", "source_path", "preview_nsfw_level")): if not any(key in updates for key in ("title", "tags", "source_path", "preview_nsfw_level", "favorite")):
raise RecipeValidationError( raise RecipeValidationError(
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level)" "At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite)"
) )
success = await recipe_scanner.update_recipe_metadata(recipe_id, updates) success = await recipe_scanner.update_recipe_metadata(recipe_id, updates)
@@ -187,6 +184,163 @@ class RecipePersistenceService:
return PersistenceResult({"success": True, "recipe_id": recipe_id, "updates": updates}) return PersistenceResult({"success": True, "recipe_id": recipe_id, "updates": updates})
def _normalize_target_path(self, recipe_scanner, target_path: str) -> tuple[str, str]:
"""Normalize and validate the target path for recipe moves."""
if not target_path:
raise RecipeValidationError("Target path is required")
recipes_root = recipe_scanner.recipes_dir
if not recipes_root:
raise RecipeNotFoundError("Recipes directory not found")
normalized_target = os.path.normpath(target_path)
recipes_root = os.path.normpath(recipes_root)
if not os.path.isabs(normalized_target):
normalized_target = os.path.normpath(os.path.join(recipes_root, normalized_target))
try:
common_root = os.path.commonpath([normalized_target, recipes_root])
except ValueError as exc:
raise RecipeValidationError("Invalid target path") from exc
if common_root != recipes_root:
raise RecipeValidationError("Target path must be inside the recipes directory")
return normalized_target, recipes_root
async def _move_recipe_files(
self,
*,
recipe_scanner,
recipe_id: str,
normalized_target: str,
recipes_root: str,
) -> dict[str, Any]:
"""Move the recipe's JSON and preview image into the normalized target."""
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
if not recipe_json_path or not os.path.exists(recipe_json_path):
raise RecipeNotFoundError("Recipe not found")
recipe_data = await recipe_scanner.get_recipe_by_id(recipe_id)
if not recipe_data:
raise RecipeNotFoundError("Recipe not found")
current_json_dir = os.path.dirname(recipe_json_path)
normalized_image_path = os.path.normpath(recipe_data.get("file_path") or "") if recipe_data.get("file_path") else None
os.makedirs(normalized_target, exist_ok=True)
if os.path.normpath(current_json_dir) == normalized_target:
return {
"success": True,
"message": "Recipe is already in the target folder",
"recipe_id": recipe_id,
"original_file_path": recipe_data.get("file_path"),
"new_file_path": recipe_data.get("file_path"),
}
new_json_path = os.path.normpath(os.path.join(normalized_target, os.path.basename(recipe_json_path)))
shutil.move(recipe_json_path, new_json_path)
new_image_path = normalized_image_path
if normalized_image_path:
target_image_path = os.path.normpath(os.path.join(normalized_target, os.path.basename(normalized_image_path)))
if os.path.exists(normalized_image_path) and normalized_image_path != target_image_path:
shutil.move(normalized_image_path, target_image_path)
new_image_path = target_image_path
relative_folder = os.path.relpath(normalized_target, recipes_root)
if relative_folder in (".", ""):
relative_folder = ""
updates = {"file_path": new_image_path or recipe_data.get("file_path"), "folder": relative_folder.replace(os.path.sep, "/")}
updated = await recipe_scanner.update_recipe_metadata(recipe_id, updates)
if not updated:
raise RecipeNotFoundError("Recipe not found after move")
return {
"success": True,
"recipe_id": recipe_id,
"original_file_path": recipe_data.get("file_path"),
"new_file_path": updates["file_path"],
"json_path": new_json_path,
"folder": updates["folder"],
}
async def move_recipe(self, *, recipe_scanner, recipe_id: str, target_path: str) -> PersistenceResult:
"""Move a recipe's assets into a new folder under the recipes root."""
normalized_target, recipes_root = self._normalize_target_path(recipe_scanner, target_path)
result = await self._move_recipe_files(
recipe_scanner=recipe_scanner,
recipe_id=recipe_id,
normalized_target=normalized_target,
recipes_root=recipes_root,
)
return PersistenceResult(result)
async def move_recipes_bulk(
self,
*,
recipe_scanner,
recipe_ids: Iterable[str],
target_path: str,
) -> PersistenceResult:
"""Move multiple recipes to a new folder."""
recipe_ids = list(recipe_ids)
if not recipe_ids:
raise RecipeValidationError("No recipe IDs provided")
normalized_target, recipes_root = self._normalize_target_path(recipe_scanner, target_path)
results: list[dict[str, Any]] = []
success_count = 0
failure_count = 0
for recipe_id in recipe_ids:
try:
move_result = await self._move_recipe_files(
recipe_scanner=recipe_scanner,
recipe_id=str(recipe_id),
normalized_target=normalized_target,
recipes_root=recipes_root,
)
results.append(
{
"recipe_id": recipe_id,
"original_file_path": move_result.get("original_file_path"),
"new_file_path": move_result.get("new_file_path"),
"success": True,
"message": move_result.get("message", ""),
"folder": move_result.get("folder", ""),
}
)
success_count += 1
except Exception as exc: # pragma: no cover - per-item error handling
results.append(
{
"recipe_id": recipe_id,
"original_file_path": None,
"new_file_path": None,
"success": False,
"message": str(exc),
}
)
failure_count += 1
return PersistenceResult(
{
"success": True,
"message": f"Moved {success_count} of {len(recipe_ids)} recipes",
"results": results,
"success_count": success_count,
"failure_count": failure_count,
}
)
async def reconnect_lora( async def reconnect_lora(
self, self,
*, *,
@@ -197,8 +351,8 @@ class RecipePersistenceService:
) -> PersistenceResult: ) -> PersistenceResult:
"""Reconnect a LoRA entry within an existing recipe.""" """Reconnect a LoRA entry within an existing recipe."""
recipe_path = os.path.join(recipe_scanner.recipes_dir, f"{recipe_id}.recipe.json") recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
if not os.path.exists(recipe_path): if not recipe_path or not os.path.exists(recipe_path):
raise RecipeNotFoundError("Recipe not found") raise RecipeNotFoundError("Recipe not found")
target_lora = await recipe_scanner.get_local_lora(target_name) target_lora = await recipe_scanner.get_local_lora(target_name)
@@ -243,16 +397,12 @@ class RecipePersistenceService:
if not recipe_ids: if not recipe_ids:
raise RecipeValidationError("No recipe IDs provided") raise RecipeValidationError("No recipe IDs provided")
recipes_dir = recipe_scanner.recipes_dir
if not recipes_dir or not os.path.exists(recipes_dir):
raise RecipeNotFoundError("Recipes directory not found")
deleted_recipes: list[str] = [] deleted_recipes: list[str] = []
failed_recipes: list[dict[str, Any]] = [] failed_recipes: list[dict[str, Any]] = []
for recipe_id in recipe_ids: for recipe_id in recipe_ids:
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json") recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
if not os.path.exists(recipe_json_path): if not recipe_json_path or not os.path.exists(recipe_json_path):
failed_recipes.append({"id": recipe_id, "reason": "Recipe not found"}) failed_recipes.append({"id": recipe_id, "reason": "Recipe not found"})
continue continue

View File

@@ -34,7 +34,7 @@ class TranslationKeySynchronizer:
self.locales_dir = locales_dir self.locales_dir = locales_dir
self.verbose = verbose self.verbose = verbose
self.reference_locale = 'en' self.reference_locale = 'en'
self.target_locales = ['zh-CN', 'zh-TW', 'ja', 'ru', 'de', 'fr', 'es', 'ko'] self.target_locales = ['zh-CN', 'zh-TW', 'ja', 'ru', 'de', 'fr', 'es', 'ko', 'he']
def log(self, message: str, level: str = 'INFO'): def log(self, message: str, level: str = 'INFO'):
"""Log a message if verbose mode is enabled.""" """Log a message if verbose mode is enabled."""

View File

@@ -2,6 +2,35 @@ import { RecipeCard } from '../components/RecipeCard.js';
import { state, getCurrentPageState } from '../state/index.js'; import { state, getCurrentPageState } from '../state/index.js';
import { showToast } from '../utils/uiHelpers.js'; import { showToast } from '../utils/uiHelpers.js';
const RECIPE_ENDPOINTS = {
list: '/api/lm/recipes',
detail: '/api/lm/recipe',
scan: '/api/lm/recipes/scan',
update: '/api/lm/recipe',
roots: '/api/lm/recipes/roots',
folders: '/api/lm/recipes/folders',
folderTree: '/api/lm/recipes/folder-tree',
unifiedFolderTree: '/api/lm/recipes/unified-folder-tree',
move: '/api/lm/recipe/move',
moveBulk: '/api/lm/recipes/move-bulk',
bulkDelete: '/api/lm/recipes/bulk-delete',
};
const RECIPE_SIDEBAR_CONFIG = {
config: {
displayName: 'Recipe',
supportsMove: true,
},
endpoints: RECIPE_ENDPOINTS,
};
export function extractRecipeId(filePath) {
if (!filePath) return null;
const basename = filePath.split('/').pop().split('\\').pop();
const dotIndex = basename.lastIndexOf('.');
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
}
/** /**
* Fetch recipes with pagination for virtual scrolling * Fetch recipes with pagination for virtual scrolling
* @param {number} page - Page number to fetch * @param {number} page - Page number to fetch
@@ -10,25 +39,36 @@ import { showToast } from '../utils/uiHelpers.js';
*/ */
export async function fetchRecipesPage(page = 1, pageSize = 100) { export async function fetchRecipesPage(page = 1, pageSize = 100) {
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
try { try {
const params = new URLSearchParams({ const params = new URLSearchParams({
page: page, page: page,
page_size: pageSize || pageState.pageSize || 20, page_size: pageSize || pageState.pageSize || 20,
sort_by: pageState.sortBy sort_by: pageState.sortBy
}); });
if (pageState.showFavoritesOnly) {
params.append('favorite', 'true');
}
if (pageState.activeFolder) {
params.append('folder', pageState.activeFolder);
params.append('recursive', pageState.searchOptions?.recursive !== false);
} else if (pageState.searchOptions?.recursive !== undefined) {
params.append('recursive', pageState.searchOptions.recursive);
}
// If we have a specific recipe ID to load // If we have a specific recipe ID to load
if (pageState.customFilter?.active && pageState.customFilter?.recipeId) { if (pageState.customFilter?.active && pageState.customFilter?.recipeId) {
// Special case: load specific recipe // Special case: load specific recipe
const response = await fetch(`/api/lm/recipe/${pageState.customFilter.recipeId}`); const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${pageState.customFilter.recipeId}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load recipe: ${response.statusText}`); throw new Error(`Failed to load recipe: ${response.statusText}`);
} }
const recipe = await response.json(); const recipe = await response.json();
// Return in expected format // Return in expected format
return { return {
items: [recipe], items: [recipe],
@@ -38,33 +78,34 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
hasMore: false hasMore: false
}; };
} }
// Add custom filter for Lora if present // Add custom filter for Lora if present
if (pageState.customFilter?.active && pageState.customFilter?.loraHash) { if (pageState.customFilter?.active && pageState.customFilter?.loraHash) {
params.append('lora_hash', pageState.customFilter.loraHash); params.append('lora_hash', pageState.customFilter.loraHash);
params.append('bypass_filters', 'true'); params.append('bypass_filters', 'true');
} else { } else {
// Normal filtering logic // Normal filtering logic
// Add search filter if present // Add search filter if present
if (pageState.filters?.search) { if (pageState.filters?.search) {
params.append('search', pageState.filters.search); params.append('search', pageState.filters.search);
// Add search option parameters // Add search option parameters
if (pageState.searchOptions) { if (pageState.searchOptions) {
params.append('search_title', pageState.searchOptions.title.toString()); params.append('search_title', pageState.searchOptions.title.toString());
params.append('search_tags', pageState.searchOptions.tags.toString()); params.append('search_tags', pageState.searchOptions.tags.toString());
params.append('search_lora_name', pageState.searchOptions.loraName.toString()); params.append('search_lora_name', pageState.searchOptions.loraName.toString());
params.append('search_lora_model', pageState.searchOptions.loraModel.toString()); params.append('search_lora_model', pageState.searchOptions.loraModel.toString());
params.append('search_prompt', (pageState.searchOptions.prompt || false).toString());
params.append('fuzzy', 'true'); params.append('fuzzy', 'true');
} }
} }
// Add base model filters // Add base model filters
if (pageState.filters?.baseModel && pageState.filters.baseModel.length) { if (pageState.filters?.baseModel && pageState.filters.baseModel.length) {
params.append('base_models', pageState.filters.baseModel.join(',')); params.append('base_models', pageState.filters.baseModel.join(','));
} }
// Add tag filters // Add tag filters
if (pageState.filters?.tags && Object.keys(pageState.filters.tags).length) { if (pageState.filters?.tags && Object.keys(pageState.filters.tags).length) {
Object.entries(pageState.filters.tags).forEach(([tag, state]) => { Object.entries(pageState.filters.tags).forEach(([tag, state]) => {
@@ -78,14 +119,14 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) {
} }
// Fetch recipes // Fetch recipes
const response = await fetch(`/api/lm/recipes?${params.toString()}`); const response = await fetch(`${RECIPE_ENDPOINTS.list}?${params.toString()}`);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load recipes: ${response.statusText}`); throw new Error(`Failed to load recipes: ${response.statusText}`);
} }
const data = await response.json(); const data = await response.json();
return { return {
items: data.items, items: data.items,
totalItems: data.total, totalItems: data.total,
@@ -111,29 +152,29 @@ export async function resetAndReloadWithVirtualScroll(options = {}) {
updateFolders = false, updateFolders = false,
fetchPageFunction fetchPageFunction
} = options; } = options;
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
try { try {
pageState.isLoading = true; pageState.isLoading = true;
// Reset page counter // Reset page counter
pageState.currentPage = 1; pageState.currentPage = 1;
// Fetch the first page // Fetch the first page
const result = await fetchPageFunction(1, pageState.pageSize || 50); const result = await fetchPageFunction(1, pageState.pageSize || 50);
// Update the virtual scroller // Update the virtual scroller
state.virtualScroller.refreshWithData( state.virtualScroller.refreshWithData(
result.items, result.items,
result.totalItems, result.totalItems,
result.hasMore result.hasMore
); );
// Update state // Update state
pageState.hasMore = result.hasMore; pageState.hasMore = result.hasMore;
pageState.currentPage = 2; // Next page will be 2 pageState.currentPage = 2; // Next page will be 2
return result; return result;
} catch (error) { } catch (error) {
console.error(`Error reloading ${modelType}s:`, error); console.error(`Error reloading ${modelType}s:`, error);
@@ -156,32 +197,32 @@ export async function loadMoreWithVirtualScroll(options = {}) {
updateFolders = false, updateFolders = false,
fetchPageFunction fetchPageFunction
} = options; } = options;
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
try { try {
// Start loading state // Start loading state
pageState.isLoading = true; pageState.isLoading = true;
// Reset to first page if requested // Reset to first page if requested
if (resetPage) { if (resetPage) {
pageState.currentPage = 1; pageState.currentPage = 1;
} }
// Fetch the first page of data // Fetch the first page of data
const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50); const result = await fetchPageFunction(pageState.currentPage, pageState.pageSize || 50);
// Update virtual scroller with the new data // Update virtual scroller with the new data
state.virtualScroller.refreshWithData( state.virtualScroller.refreshWithData(
result.items, result.items,
result.totalItems, result.totalItems,
result.hasMore result.hasMore
); );
// Update state // Update state
pageState.hasMore = result.hasMore; pageState.hasMore = result.hasMore;
pageState.currentPage = 2; // Next page to load would be 2 pageState.currentPage = 2; // Next page to load would be 2
return result; return result;
} catch (error) { } catch (error) {
console.error(`Error loading ${modelType}s:`, error); console.error(`Error loading ${modelType}s:`, error);
@@ -211,18 +252,18 @@ export async function resetAndReload(updateFolders = false) {
export async function refreshRecipes() { export async function refreshRecipes() {
try { try {
state.loadingManager.showSimpleLoading('Refreshing recipes...'); state.loadingManager.showSimpleLoading('Refreshing recipes...');
// Call the API endpoint to rebuild the recipe cache // Call the API endpoint to rebuild the recipe cache
const response = await fetch('/api/lm/recipes/scan'); const response = await fetch(RECIPE_ENDPOINTS.scan);
if (!response.ok) { if (!response.ok) {
const data = await response.json(); const data = await response.json();
throw new Error(data.error || 'Failed to refresh recipe cache'); throw new Error(data.error || 'Failed to refresh recipe cache');
} }
// After successful cache rebuild, reload the recipes // After successful cache rebuild, reload the recipes
await resetAndReload(); await resetAndReload();
showToast('toast.recipes.refreshComplete', {}, 'success'); showToast('toast.recipes.refreshComplete', {}, 'success');
} catch (error) { } catch (error) {
console.error('Error refreshing recipes:', error); console.error('Error refreshing recipes:', error);
@@ -240,7 +281,7 @@ export async function refreshRecipes() {
*/ */
export async function loadMoreRecipes(resetPage = false) { export async function loadMoreRecipes(resetPage = false) {
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
// Use virtual scroller if available // Use virtual scroller if available
if (state.virtualScroller) { if (state.virtualScroller) {
return loadMoreWithVirtualScroll({ return loadMoreWithVirtualScroll({
@@ -277,10 +318,12 @@ export async function updateRecipeMetadata(filePath, updates) {
state.loadingManager.showSimpleLoading('Saving metadata...'); state.loadingManager.showSimpleLoading('Saving metadata...');
// Extract recipeId from filePath (basename without extension) // Extract recipeId from filePath (basename without extension)
const basename = filePath.split('/').pop().split('\\').pop(); const recipeId = extractRecipeId(filePath);
const recipeId = basename.substring(0, basename.lastIndexOf('.')); if (!recipeId) {
throw new Error('Unable to determine recipe ID');
const response = await fetch(`/api/lm/recipe/${recipeId}/update`, { }
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, {
method: 'PUT', method: 'PUT',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@@ -296,7 +339,7 @@ export async function updateRecipeMetadata(filePath, updates) {
} }
state.virtualScroller.updateSingleItem(filePath, updates); state.virtualScroller.updateSingleItem(filePath, updates);
return data; return data;
} catch (error) { } catch (error) {
console.error('Error updating recipe:', error); console.error('Error updating recipe:', error);
@@ -306,3 +349,187 @@ export async function updateRecipeMetadata(filePath, updates) {
state.loadingManager.hide(); state.loadingManager.hide();
} }
} }
export class RecipeSidebarApiClient {
constructor() {
this.apiConfig = RECIPE_SIDEBAR_CONFIG;
}
async fetchUnifiedFolderTree() {
const response = await fetch(this.apiConfig.endpoints.unifiedFolderTree);
if (!response.ok) {
throw new Error('Failed to fetch recipe folder tree');
}
return response.json();
}
async fetchModelRoots() {
const response = await fetch(this.apiConfig.endpoints.roots);
if (!response.ok) {
throw new Error('Failed to fetch recipe roots');
}
return response.json();
}
async fetchModelFolders() {
const response = await fetch(this.apiConfig.endpoints.folders);
if (!response.ok) {
throw new Error('Failed to fetch recipe folders');
}
return response.json();
}
async moveBulkModels(filePaths, targetPath) {
if (!this.apiConfig.config.supportsMove) {
showToast('toast.api.bulkMoveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
return [];
}
const recipeIds = filePaths
.map((path) => extractRecipeId(path))
.filter((id) => !!id);
if (recipeIds.length === 0) {
showToast('toast.models.noModelsSelected', {}, 'warning');
return [];
}
const response = await fetch(this.apiConfig.endpoints.moveBulk, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_ids: recipeIds,
target_path: targetPath,
}),
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || `Failed to move ${this.apiConfig.config.displayName}s`);
}
if (result.failure_count > 0) {
showToast(
'toast.api.bulkMovePartial',
{
successCount: result.success_count,
type: this.apiConfig.config.displayName,
failureCount: result.failure_count,
},
'warning'
);
const failedFiles = (result.results || [])
.filter((item) => !item.success)
.map((item) => item.message || 'Unknown error');
if (failedFiles.length > 0) {
const failureMessage =
failedFiles.length <= 3
? failedFiles.join('\n')
: `${failedFiles.slice(0, 3).join('\n')}\n(and ${failedFiles.length - 3} more)`;
showToast('toast.api.bulkMoveFailures', { failures: failureMessage }, 'warning', 6000);
}
} else {
showToast(
'toast.api.bulkMoveSuccess',
{
successCount: result.success_count,
type: this.apiConfig.config.displayName,
},
'success'
);
}
return result.results || [];
}
async moveSingleModel(filePath, targetPath) {
if (!this.apiConfig.config.supportsMove) {
showToast('toast.api.moveNotSupported', { type: this.apiConfig.config.displayName }, 'warning');
return null;
}
const recipeId = extractRecipeId(filePath);
if (!recipeId) {
showToast('toast.api.moveFailed', { message: 'Recipe ID missing' }, 'error');
return null;
}
const response = await fetch(this.apiConfig.endpoints.move, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_id: recipeId,
target_path: targetPath,
}),
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || `Failed to move ${this.apiConfig.config.displayName}`);
}
if (result.message) {
showToast('toast.api.moveInfo', { message: result.message }, 'info');
} else {
showToast('toast.api.moveSuccess', { type: this.apiConfig.config.displayName }, 'success');
}
return {
original_file_path: result.original_file_path || filePath,
new_file_path: result.new_file_path || filePath,
folder: result.folder || '',
message: result.message,
};
}
async bulkDeleteModels(filePaths) {
if (!filePaths || filePaths.length === 0) {
throw new Error('No file paths provided');
}
const recipeIds = filePaths
.map((path) => extractRecipeId(path))
.filter((id) => !!id);
if (recipeIds.length === 0) {
throw new Error('No recipe IDs could be derived from file paths');
}
try {
state.loadingManager?.showSimpleLoading('Deleting recipes...');
const response = await fetch(this.apiConfig.endpoints.bulkDelete, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
recipe_ids: recipeIds,
}),
});
const result = await response.json();
if (!response.ok || !result.success) {
throw new Error(result.error || 'Failed to delete recipes');
}
return {
success: true,
deleted_count: result.total_deleted,
failed_count: result.total_failed || 0,
errors: result.failed || [],
};
} finally {
state.loadingManager?.hide();
}
}
}

View File

@@ -4,6 +4,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHe
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js'; import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
import { updateRecipeMetadata } from '../../api/recipeApi.js'; import { updateRecipeMetadata } from '../../api/recipeApi.js';
import { state } from '../../state/index.js'; import { state } from '../../state/index.js';
import { moveManager } from '../../managers/MoveManager.js';
export class RecipeContextMenu extends BaseContextMenu { export class RecipeContextMenu extends BaseContextMenu {
constructor() { constructor() {
@@ -77,6 +78,9 @@ export class RecipeContextMenu extends BaseContextMenu {
// Share recipe // Share recipe
this.currentCard.querySelector('.fa-share-alt')?.click(); this.currentCard.querySelector('.fa-share-alt')?.click();
break; break;
case 'move':
moveManager.showMoveModal(this.currentCard.dataset.filepath);
break;
case 'delete': case 'delete':
// Delete recipe // Delete recipe
this.currentCard.querySelector('.fa-trash')?.click(); this.currentCard.querySelector('.fa-trash')?.click();

View File

@@ -1,9 +1,11 @@
// Recipe Card Component // Recipe Card Component
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js'; import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
import { updateRecipeMetadata } from '../api/recipeApi.js';
import { configureModelCardVideo } from './shared/ModelCard.js'; import { configureModelCardVideo } from './shared/ModelCard.js';
import { modalManager } from '../managers/ModalManager.js'; import { modalManager } from '../managers/ModalManager.js';
import { getCurrentPageState } from '../state/index.js'; import { getCurrentPageState } from '../state/index.js';
import { state } from '../state/index.js'; import { state } from '../state/index.js';
import { bulkManager } from '../managers/BulkManager.js';
import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js'; import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js';
class RecipeCard { class RecipeCard {
@@ -43,8 +45,11 @@ class RecipeCard {
(this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` : (this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` :
'/loras_static/images/no-preview.png'); '/loras_static/images/no-preview.png');
const isDuplicatesMode = getCurrentPageState().duplicatesMode;
const autoplayOnHover = state?.global?.settings?.autoplay_on_hover === true;
const isFavorite = this.recipe.favorite === true;
// Video preview logic // Video preview logic
const autoplayOnHover = state.settings.autoplay_on_hover || false;
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm'); const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
const videoAttrs = [ const videoAttrs = [
'controls', 'controls',
@@ -59,10 +64,6 @@ class RecipeCard {
videoAttrs.push('data-autoplay="true"'); videoAttrs.push('data-autoplay="true"');
} }
// Check if in duplicates mode
const pageState = getCurrentPageState();
const isDuplicatesMode = pageState.duplicatesMode;
// NSFW blur logic - similar to LoraCard // NSFW blur logic - similar to LoraCard
const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0; const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0;
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13; const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
@@ -95,6 +96,7 @@ class RecipeCard {
</button>` : ''} </button>` : ''}
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${baseModelLabel}">${baseModelDisplay}</span> <span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${baseModelLabel}">${baseModelDisplay}</span>
<div class="card-actions"> <div class="card-actions">
<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}"></i>
<i class="fas fa-share-alt" title="Share Recipe"></i> <i class="fas fa-share-alt" title="Share Recipe"></i>
<i class="fas fa-paper-plane" title="Send Recipe to Workflow (Click: Append, Shift+Click: Replace)"></i> <i class="fas fa-paper-plane" title="Send Recipe to Workflow (Click: Append, Shift+Click: Replace)"></i>
<i class="fas fa-trash" title="Delete Recipe"></i> <i class="fas fa-trash" title="Delete Recipe"></i>
@@ -140,6 +142,67 @@ class RecipeCard {
return `${missingCount} of ${totalCount} LoRAs missing`; return `${missingCount} of ${totalCount} LoRAs missing`;
} }
async toggleFavorite(card) {
// Find the latest star icon in case the card was re-rendered
const getStarIcon = (c) => c.querySelector('.fa-star');
let starIcon = getStarIcon(card);
const isFavorite = this.recipe.favorite || false;
const newFavoriteState = !isFavorite;
// Update early to provide instant feedback and avoid race conditions with re-renders
this.recipe.favorite = newFavoriteState;
// Function to update icon state
const updateIconUI = (icon, state) => {
if (!icon) return;
if (state) {
icon.classList.remove('far');
icon.classList.add('fas', 'favorite-active');
icon.title = 'Remove from Favorites';
} else {
icon.classList.remove('fas', 'favorite-active');
icon.classList.add('far');
icon.title = 'Add to Favorites';
}
};
// Update current icon immediately
updateIconUI(starIcon, newFavoriteState);
try {
await updateRecipeMetadata(this.recipe.file_path, {
favorite: newFavoriteState
});
// Status already updated, just show toast
if (newFavoriteState) {
showToast('modelCard.favorites.added', {}, 'success');
} else {
showToast('modelCard.favorites.removed', {}, 'success');
}
// Re-find star icon after API call as VirtualScroller might have replaced the element
// During updateRecipeMetadata, VirtualScroller.updateSingleItem might have re-rendered the card
// We need to find the NEW element in the DOM to ensure we don't have a stale reference
// Though typically VirtualScroller handles the re-render with the NEW this.recipe.favorite
// we will check the DOM just to be sure if this instance's internal card is still what's in DOM
} catch (error) {
console.error('Failed to update favorite status:', error);
// Revert local state on error
this.recipe.favorite = isFavorite;
// Re-find star icon in case of re-render during fault
const currentCard = card.ownerDocument.evaluate(
`.//*[@data-filepath="${this.recipe.file_path}"]`,
card.ownerDocument, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
).singleNodeValue || card;
updateIconUI(getStarIcon(currentCard), isFavorite);
showToast('modelCard.favorites.updateFailed', {}, 'error');
}
}
attachEventListeners(card, isDuplicatesMode, shouldBlur) { attachEventListeners(card, isDuplicatesMode, shouldBlur) {
// Add blur toggle functionality if content should be blurred // Add blur toggle functionality if content should be blurred
if (shouldBlur) { if (shouldBlur) {
@@ -164,9 +227,19 @@ class RecipeCard {
// Recipe card click event - only attach if not in duplicates mode // Recipe card click event - only attach if not in duplicates mode
if (!isDuplicatesMode) { if (!isDuplicatesMode) {
card.addEventListener('click', () => { card.addEventListener('click', () => {
if (state.bulkMode) {
bulkManager.toggleCardSelection(card);
return;
}
this.clickHandler(this.recipe); this.clickHandler(this.recipe);
}); });
// Favorite button click event - prevent propagation to card
card.querySelector('.fa-star')?.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleFavorite(card);
});
// Share button click event - prevent propagation to card // Share button click event - prevent propagation to card
card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => { card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => {
e.stopPropagation(); e.stopPropagation();

View File

@@ -77,7 +77,9 @@ export class SidebarManager {
this.pageControls = pageControls; this.pageControls = pageControls;
this.pageType = pageControls.pageType; this.pageType = pageControls.pageType;
this.lastPageControls = pageControls; this.lastPageControls = pageControls;
this.apiClient = getModelApiClient(); this.apiClient = pageControls?.getSidebarApiClient?.()
|| pageControls?.sidebarApiClient
|| getModelApiClient();
// Set initial sidebar state immediately (hidden by default) // Set initial sidebar state immediately (hidden by default)
this.setInitialSidebarState(); this.setInitialSidebarState();
@@ -205,6 +207,10 @@ export class SidebarManager {
} }
initializeDragAndDrop() { initializeDragAndDrop() {
if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
return;
}
if (!this.dragHandlersInitialized) { if (!this.dragHandlersInitialized) {
document.addEventListener('dragstart', this.handleCardDragStart); document.addEventListener('dragstart', this.handleCardDragStart);
document.addEventListener('dragend', this.handleCardDragEnd); document.addEventListener('dragend', this.handleCardDragEnd);
@@ -416,7 +422,14 @@ export class SidebarManager {
} }
if (!this.apiClient) { if (!this.apiClient) {
this.apiClient = getModelApiClient(); this.apiClient = this.pageControls?.getSidebarApiClient?.()
|| this.pageControls?.sidebarApiClient
|| getModelApiClient();
}
if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
showToast('toast.models.moveFailed', { message: translate('sidebar.dragDrop.moveUnsupported', {}, 'Move not supported for this page') }, 'error');
return false;
} }
const rootPath = this.draggedRootPath ? this.draggedRootPath.replace(/\\/g, '/') : ''; const rootPath = this.draggedRootPath ? this.draggedRootPath.replace(/\\/g, '/') : '';
@@ -470,7 +483,9 @@ export class SidebarManager {
} }
async init() { async init() {
this.apiClient = getModelApiClient(); this.apiClient = this.pageControls?.getSidebarApiClient?.()
|| this.pageControls?.sidebarApiClient
|| getModelApiClient();
// Set initial sidebar state immediately (hidden by default) // Set initial sidebar state immediately (hidden by default)
this.setInitialSidebarState(); this.setInitialSidebarState();

View File

@@ -60,14 +60,12 @@ export class AppCore {
initTheme(); initTheme();
initBackToTop(); initBackToTop();
// Initialize the bulk manager and context menu only if not on recipes page // Initialize the bulk manager and context menu
if (state.currentPageType !== 'recipes') { bulkManager.initialize();
bulkManager.initialize();
// Initialize bulk context menu // Initialize bulk context menu
const bulkContextMenu = new BulkContextMenu(); const bulkContextMenu = new BulkContextMenu();
bulkManager.setBulkContextMenu(bulkContextMenu); bulkManager.setBulkContextMenu(bulkContextMenu);
}
// Initialize the example images manager // Initialize the example images manager
exampleImagesManager.initialize(); exampleImagesManager.initialize();
@@ -121,4 +119,4 @@ export class AppCore {
} }
// Create and export a singleton instance // Create and export a singleton instance
export const appCore = new AppCore(); export const appCore = new AppCore();

View File

@@ -3,6 +3,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow, buildLoraSyntax, getNSF
import { updateCardsForBulkMode } from '../components/shared/ModelCard.js'; import { updateCardsForBulkMode } from '../components/shared/ModelCard.js';
import { modalManager } from './ModalManager.js'; import { modalManager } from './ModalManager.js';
import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js'; import { getModelApiClient, resetAndReload } from '../api/modelApiFactory.js';
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js'; import { MODEL_TYPES, MODEL_CONFIG } from '../api/apiConfig.js';
import { BASE_MODEL_CATEGORIES } from '../utils/constants.js'; import { BASE_MODEL_CATEGORIES } from '../utils/constants.js';
import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js'; import { getPriorityTagSuggestions } from '../utils/priorityTagHelpers.js';
@@ -62,9 +63,22 @@ export class BulkManager {
autoOrganize: true, autoOrganize: true,
deleteAll: true, deleteAll: true,
setContentRating: true setContentRating: true
},
recipes: {
addTags: false,
sendToWorkflow: false,
copyAll: false,
refreshAll: false,
checkUpdates: false,
moveAll: true,
autoOrganize: false,
deleteAll: true,
setContentRating: false
} }
}; };
this.recipeApiClient = null;
window.addEventListener('lm:priority-tags-updated', () => { window.addEventListener('lm:priority-tags-updated', () => {
const container = document.querySelector('#bulkAddTagsModal .metadata-suggestions-container'); const container = document.querySelector('#bulkAddTagsModal .metadata-suggestions-container');
if (!container) { if (!container) {
@@ -87,9 +101,6 @@ export class BulkManager {
} }
initialize() { initialize() {
// Do not initialize on recipes page
if (state.currentPageType === 'recipes') return;
// Register with event manager for coordinated event handling // Register with event manager for coordinated event handling
this.registerEventHandlers(); this.registerEventHandlers();
@@ -97,6 +108,23 @@ export class BulkManager {
eventManager.setState('bulkMode', state.bulkMode || false); eventManager.setState('bulkMode', state.bulkMode || false);
} }
getActiveApiClient() {
if (state.currentPageType === 'recipes') {
if (!this.recipeApiClient) {
this.recipeApiClient = new RecipeSidebarApiClient();
}
return this.recipeApiClient;
}
return getModelApiClient();
}
getCurrentDisplayConfig() {
if (state.currentPageType === 'recipes') {
return { displayName: 'Recipe' };
}
return MODEL_CONFIG[state.currentPageType] || { displayName: 'Model' };
}
setBulkContextMenu(bulkContextMenu) { setBulkContextMenu(bulkContextMenu) {
this.bulkContextMenu = bulkContextMenu; this.bulkContextMenu = bulkContextMenu;
} }
@@ -240,7 +268,9 @@ export class BulkManager {
// Update event manager state // Update event manager state
eventManager.setState('bulkMode', state.bulkMode); eventManager.setState('bulkMode', state.bulkMode);
this.bulkBtn.classList.toggle('active', state.bulkMode); if (this.bulkBtn) {
this.bulkBtn.classList.toggle('active', state.bulkMode);
}
updateCardsForBulkMode(state.bulkMode); updateCardsForBulkMode(state.bulkMode);
@@ -504,13 +534,13 @@ export class BulkManager {
modalManager.closeModal('bulkDeleteModal'); modalManager.closeModal('bulkDeleteModal');
try { try {
const apiClient = getModelApiClient(); const apiClient = this.getActiveApiClient();
const filePaths = Array.from(state.selectedModels); const filePaths = Array.from(state.selectedModels);
const result = await apiClient.bulkDeleteModels(filePaths); const result = await apiClient.bulkDeleteModels(filePaths);
if (result.success) { if (result.success) {
const currentConfig = MODEL_CONFIG[state.currentPageType]; const currentConfig = this.getCurrentDisplayConfig();
showToast('toast.models.deletedSuccessfully', { showToast('toast.models.deletedSuccessfully', {
count: result.deleted_count, count: result.deleted_count,
type: currentConfig.displayName.toLowerCase() type: currentConfig.displayName.toLowerCase()
@@ -570,7 +600,7 @@ export class BulkManager {
this.applySelectionState(); this.applySelectionState();
const newlySelected = state.selectedModels.size - oldCount; const newlySelected = state.selectedModels.size - oldCount;
const currentConfig = MODEL_CONFIG[state.currentPageType]; const currentConfig = this.getCurrentDisplayConfig();
showToast('toast.models.selectedAdditional', { showToast('toast.models.selectedAdditional', {
count: newlySelected, count: newlySelected,
type: currentConfig.displayName.toLowerCase() type: currentConfig.displayName.toLowerCase()
@@ -622,8 +652,7 @@ export class BulkManager {
return; return;
} }
const currentType = state.currentPageType; const currentConfig = this.getCurrentDisplayConfig();
const currentConfig = MODEL_CONFIG[currentType] || MODEL_CONFIG[MODEL_TYPES.LORA];
const typeLabel = (currentConfig?.displayName || 'Model').toLowerCase(); const typeLabel = (currentConfig?.displayName || 'Model').toLowerCase();
const { ids: modelIds, missingCount } = this.collectSelectedModelIds(); const { ids: modelIds, missingCount } = this.collectSelectedModelIds();
@@ -969,7 +998,7 @@ export class BulkManager {
modalManager.closeModal('bulkAddTagsModal'); modalManager.closeModal('bulkAddTagsModal');
if (successCount > 0) { if (successCount > 0) {
const currentConfig = MODEL_CONFIG[state.currentPageType]; const currentConfig = this.getCurrentDisplayConfig();
const toastKey = mode === 'replace' ? 'toast.models.tagsReplacedSuccessfully' : 'toast.models.tagsAddedSuccessfully'; const toastKey = mode === 'replace' ? 'toast.models.tagsReplacedSuccessfully' : 'toast.models.tagsAddedSuccessfully';
showToast(toastKey, { showToast(toastKey, {
count: successCount, count: successCount,

View File

@@ -3,6 +3,7 @@ import { state, getCurrentPageState } from '../state/index.js';
import { modalManager } from './ModalManager.js'; import { modalManager } from './ModalManager.js';
import { bulkManager } from './BulkManager.js'; import { bulkManager } from './BulkManager.js';
import { getModelApiClient } from '../api/modelApiFactory.js'; import { getModelApiClient } from '../api/modelApiFactory.js';
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
import { FolderTreeManager } from '../components/FolderTreeManager.js'; import { FolderTreeManager } from '../components/FolderTreeManager.js';
import { sidebarManager } from '../components/SidebarManager.js'; import { sidebarManager } from '../components/SidebarManager.js';
@@ -12,11 +13,22 @@ class MoveManager {
this.bulkFilePaths = null; this.bulkFilePaths = null;
this.folderTreeManager = new FolderTreeManager(); this.folderTreeManager = new FolderTreeManager();
this.initialized = false; this.initialized = false;
this.recipeApiClient = null;
// Bind methods // Bind methods
this.updateTargetPath = this.updateTargetPath.bind(this); this.updateTargetPath = this.updateTargetPath.bind(this);
} }
_getApiClient(modelType = null) {
if (state.currentPageType === 'recipes') {
if (!this.recipeApiClient) {
this.recipeApiClient = new RecipeSidebarApiClient();
}
return this.recipeApiClient;
}
return getModelApiClient(modelType);
}
initializeEventListeners() { initializeEventListeners() {
if (this.initialized) return; if (this.initialized) return;
@@ -36,7 +48,7 @@ class MoveManager {
this.currentFilePath = null; this.currentFilePath = null;
this.bulkFilePaths = null; this.bulkFilePaths = null;
const apiClient = getModelApiClient(); const apiClient = this._getApiClient(modelType);
const currentPageType = state.currentPageType; const currentPageType = state.currentPageType;
const modelConfig = apiClient.apiConfig.config; const modelConfig = apiClient.apiConfig.config;
@@ -121,7 +133,7 @@ class MoveManager {
async initializeFolderTree() { async initializeFolderTree() {
try { try {
const apiClient = getModelApiClient(); const apiClient = this._getApiClient();
// Fetch unified folder tree // Fetch unified folder tree
const treeData = await apiClient.fetchUnifiedFolderTree(); const treeData = await apiClient.fetchUnifiedFolderTree();
@@ -141,7 +153,7 @@ class MoveManager {
updateTargetPath() { updateTargetPath() {
const pathDisplay = document.getElementById('moveTargetPathDisplay'); const pathDisplay = document.getElementById('moveTargetPathDisplay');
const modelRoot = document.getElementById('moveModelRoot').value; const modelRoot = document.getElementById('moveModelRoot').value;
const apiClient = getModelApiClient(); const apiClient = this._getApiClient();
const config = apiClient.apiConfig.config; const config = apiClient.apiConfig.config;
let fullPath = modelRoot || `Select a ${config.displayName.toLowerCase()} root directory`; let fullPath = modelRoot || `Select a ${config.displayName.toLowerCase()} root directory`;
@@ -158,7 +170,7 @@ class MoveManager {
async moveModel() { async moveModel() {
const selectedRoot = document.getElementById('moveModelRoot').value; const selectedRoot = document.getElementById('moveModelRoot').value;
const apiClient = getModelApiClient(); const apiClient = this._getApiClient();
const config = apiClient.apiConfig.config; const config = apiClient.apiConfig.config;
if (!selectedRoot) { if (!selectedRoot) {

View File

@@ -2,31 +2,60 @@
import { appCore } from './core.js'; import { appCore } from './core.js';
import { ImportManager } from './managers/ImportManager.js'; import { ImportManager } from './managers/ImportManager.js';
import { RecipeModal } from './components/RecipeModal.js'; import { RecipeModal } from './components/RecipeModal.js';
import { getCurrentPageState } from './state/index.js'; import { state, getCurrentPageState } from './state/index.js';
import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js'; import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js';
import { RecipeContextMenu } from './components/ContextMenu/index.js'; import { RecipeContextMenu } from './components/ContextMenu/index.js';
import { DuplicatesManager } from './components/DuplicatesManager.js'; import { DuplicatesManager } from './components/DuplicatesManager.js';
import { refreshVirtualScroll } from './utils/infiniteScroll.js'; import { refreshVirtualScroll } from './utils/infiniteScroll.js';
import { refreshRecipes } from './api/recipeApi.js'; import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js';
import { sidebarManager } from './components/SidebarManager.js';
class RecipePageControls {
constructor() {
this.pageType = 'recipes';
this.pageState = getCurrentPageState();
this.sidebarApiClient = new RecipeSidebarApiClient();
}
async resetAndReload() {
refreshVirtualScroll();
}
async refreshModels(fullRebuild = false) {
if (fullRebuild) {
await refreshRecipes();
return;
}
refreshVirtualScroll();
}
getSidebarApiClient() {
return this.sidebarApiClient;
}
}
class RecipeManager { class RecipeManager {
constructor() { constructor() {
// Get page state // Get page state
this.pageState = getCurrentPageState(); this.pageState = getCurrentPageState();
// Page controls for shared sidebar behaviors
this.pageControls = new RecipePageControls();
// Initialize ImportManager // Initialize ImportManager
this.importManager = new ImportManager(); this.importManager = new ImportManager();
// Initialize RecipeModal // Initialize RecipeModal
this.recipeModal = new RecipeModal(); this.recipeModal = new RecipeModal();
// Initialize DuplicatesManager // Initialize DuplicatesManager
this.duplicatesManager = new DuplicatesManager(this); this.duplicatesManager = new DuplicatesManager(this);
// Add state tracking for infinite scroll // Add state tracking for infinite scroll
this.pageState.isLoading = false; this.pageState.isLoading = false;
this.pageState.hasMore = true; this.pageState.hasMore = true;
// Custom filter state - move to pageState for compatibility with virtual scrolling // Custom filter state - move to pageState for compatibility with virtual scrolling
this.pageState.customFilter = { this.pageState.customFilter = {
active: false, active: false,
@@ -35,27 +64,40 @@ class RecipeManager {
recipeId: null recipeId: null
}; };
} }
async initialize() { async initialize() {
// Initialize event listeners // Initialize event listeners
this.initEventListeners(); this.initEventListeners();
// Set default search options if not already defined // Set default search options if not already defined
this._initSearchOptions(); this._initSearchOptions();
// Initialize context menu // Initialize context menu
new RecipeContextMenu(); new RecipeContextMenu();
// Check for custom filter parameters in session storage // Check for custom filter parameters in session storage
this._checkCustomFilter(); this._checkCustomFilter();
// Expose necessary functions to the page // Expose necessary functions to the page
this._exposeGlobalFunctions(); this._exposeGlobalFunctions();
// Initialize sidebar navigation
await this._initSidebar();
// Initialize common page features // Initialize common page features
appCore.initializePageFeatures(); appCore.initializePageFeatures();
} }
async _initSidebar() {
try {
sidebarManager.setHostPageControls(this.pageControls);
const shouldShowSidebar = state?.global?.settings?.show_folder_sidebar !== false;
await sidebarManager.setSidebarEnabled(shouldShowSidebar);
} catch (error) {
console.error('Failed to initialize recipe sidebar:', error);
}
}
_initSearchOptions() { _initSearchOptions() {
// Ensure recipes search options are properly initialized // Ensure recipes search options are properly initialized
if (!this.pageState.searchOptions) { if (!this.pageState.searchOptions) {
@@ -63,25 +105,27 @@ class RecipeManager {
title: true, // Recipe title title: true, // Recipe title
tags: true, // Recipe tags tags: true, // Recipe tags
loraName: true, // LoRA file name loraName: true, // LoRA file name
loraModel: true // LoRA model name loraModel: true, // LoRA model name
prompt: true, // Prompt search
recursive: true
}; };
} }
} }
_exposeGlobalFunctions() { _exposeGlobalFunctions() {
// Only expose what's needed for the page // Only expose what's needed for the page
window.recipeManager = this; window.recipeManager = this;
window.importManager = this.importManager; window.importManager = this.importManager;
} }
_checkCustomFilter() { _checkCustomFilter() {
// Check for Lora filter // Check for Lora filter
const filterLoraName = getSessionItem('lora_to_recipe_filterLoraName'); const filterLoraName = getSessionItem('lora_to_recipe_filterLoraName');
const filterLoraHash = getSessionItem('lora_to_recipe_filterLoraHash'); const filterLoraHash = getSessionItem('lora_to_recipe_filterLoraHash');
// Check for specific recipe ID // Check for specific recipe ID
const viewRecipeId = getSessionItem('viewRecipeId'); const viewRecipeId = getSessionItem('viewRecipeId');
// Set custom filter if any parameter is present // Set custom filter if any parameter is present
if (filterLoraName || filterLoraHash || viewRecipeId) { if (filterLoraName || filterLoraHash || viewRecipeId) {
this.pageState.customFilter = { this.pageState.customFilter = {
@@ -90,35 +134,35 @@ class RecipeManager {
loraHash: filterLoraHash, loraHash: filterLoraHash,
recipeId: viewRecipeId recipeId: viewRecipeId
}; };
// Show custom filter indicator // Show custom filter indicator
this._showCustomFilterIndicator(); this._showCustomFilterIndicator();
} }
} }
_showCustomFilterIndicator() { _showCustomFilterIndicator() {
const indicator = document.getElementById('customFilterIndicator'); const indicator = document.getElementById('customFilterIndicator');
const textElement = document.getElementById('customFilterText'); const textElement = document.getElementById('customFilterText');
if (!indicator || !textElement) return; if (!indicator || !textElement) return;
// Update text based on filter type // Update text based on filter type
let filterText = ''; let filterText = '';
if (this.pageState.customFilter.recipeId) { if (this.pageState.customFilter.recipeId) {
filterText = 'Viewing specific recipe'; filterText = 'Viewing specific recipe';
} else if (this.pageState.customFilter.loraName) { } else if (this.pageState.customFilter.loraName) {
// Format with Lora name // Format with Lora name
const loraName = this.pageState.customFilter.loraName; const loraName = this.pageState.customFilter.loraName;
const displayName = loraName.length > 25 ? const displayName = loraName.length > 25 ?
loraName.substring(0, 22) + '...' : loraName.substring(0, 22) + '...' :
loraName; loraName;
filterText = `<span>Recipes using: <span class="lora-name">${displayName}</span></span>`; filterText = `<span>Recipes using: <span class="lora-name">${displayName}</span></span>`;
} else { } else {
filterText = 'Filtered recipes'; filterText = 'Filtered recipes';
} }
// Update indicator text and show it // Update indicator text and show it
textElement.innerHTML = filterText; textElement.innerHTML = filterText;
// Add title attribute to show the lora name as a tooltip // Add title attribute to show the lora name as a tooltip
@@ -126,14 +170,14 @@ class RecipeManager {
textElement.setAttribute('title', this.pageState.customFilter.loraName); textElement.setAttribute('title', this.pageState.customFilter.loraName);
} }
indicator.classList.remove('hidden'); indicator.classList.remove('hidden');
// Add pulse animation // Add pulse animation
const filterElement = indicator.querySelector('.filter-active'); const filterElement = indicator.querySelector('.filter-active');
if (filterElement) { if (filterElement) {
filterElement.classList.add('animate'); filterElement.classList.add('animate');
setTimeout(() => filterElement.classList.remove('animate'), 600); setTimeout(() => filterElement.classList.remove('animate'), 600);
} }
// Add click handler for clear filter button // Add click handler for clear filter button
const clearFilterBtn = indicator.querySelector('.clear-filter'); const clearFilterBtn = indicator.querySelector('.clear-filter');
if (clearFilterBtn) { if (clearFilterBtn) {
@@ -143,7 +187,7 @@ class RecipeManager {
}); });
} }
} }
_clearCustomFilter() { _clearCustomFilter() {
// Reset custom filter // Reset custom filter
this.pageState.customFilter = { this.pageState.customFilter = {
@@ -152,33 +196,48 @@ class RecipeManager {
loraHash: null, loraHash: null,
recipeId: null recipeId: null
}; };
// Hide indicator // Hide indicator
const indicator = document.getElementById('customFilterIndicator'); const indicator = document.getElementById('customFilterIndicator');
if (indicator) { if (indicator) {
indicator.classList.add('hidden'); indicator.classList.add('hidden');
} }
// Clear any session storage items // Clear any session storage items
removeSessionItem('lora_to_recipe_filterLoraName'); removeSessionItem('lora_to_recipe_filterLoraName');
removeSessionItem('lora_to_recipe_filterLoraHash'); removeSessionItem('lora_to_recipe_filterLoraHash');
removeSessionItem('viewRecipeId'); removeSessionItem('viewRecipeId');
// Reset and refresh the virtual scroller // Reset and refresh the virtual scroller
refreshVirtualScroll(); refreshVirtualScroll();
} }
initEventListeners() { initEventListeners() {
// Sort select // Sort select
const sortSelect = document.getElementById('sortSelect'); const sortSelect = document.getElementById('sortSelect');
if (sortSelect) { if (sortSelect) {
sortSelect.value = this.pageState.sortBy || 'date:desc';
sortSelect.addEventListener('change', () => { sortSelect.addEventListener('change', () => {
this.pageState.sortBy = sortSelect.value; this.pageState.sortBy = sortSelect.value;
refreshVirtualScroll(); refreshVirtualScroll();
}); });
} }
const bulkButton = document.querySelector('[data-action="bulk"]');
if (bulkButton) {
bulkButton.addEventListener('click', () => window.bulkManager?.toggleBulkMode());
}
const favoriteFilterBtn = document.getElementById('favoriteFilterBtn');
if (favoriteFilterBtn) {
favoriteFilterBtn.addEventListener('click', () => {
this.pageState.showFavoritesOnly = !this.pageState.showFavoritesOnly;
favoriteFilterBtn.classList.toggle('active', this.pageState.showFavoritesOnly);
refreshVirtualScroll();
});
}
} }
// This method is kept for compatibility but now uses virtual scrolling // This method is kept for compatibility but now uses virtual scrolling
async loadRecipes(resetPage = true) { async loadRecipes(resetPage = true) {
// Skip loading if in duplicates mode // Skip loading if in duplicates mode
@@ -186,32 +245,32 @@ class RecipeManager {
if (pageState.duplicatesMode) { if (pageState.duplicatesMode) {
return; return;
} }
if (resetPage) { if (resetPage) {
refreshVirtualScroll(); refreshVirtualScroll();
} }
} }
/** /**
* Refreshes the recipe list by first rebuilding the cache and then loading recipes * Refreshes the recipe list by first rebuilding the cache and then loading recipes
*/ */
async refreshRecipes() { async refreshRecipes() {
return refreshRecipes(); return refreshRecipes();
} }
showRecipeDetails(recipe) { showRecipeDetails(recipe) {
this.recipeModal.showRecipeDetails(recipe); this.recipeModal.showRecipeDetails(recipe);
} }
// Duplicate detection and management methods // Duplicate detection and management methods
async findDuplicateRecipes() { async findDuplicateRecipes() {
return await this.duplicatesManager.findDuplicates(); return await this.duplicatesManager.findDuplicates();
} }
selectLatestDuplicates() { selectLatestDuplicates() {
this.duplicatesManager.selectLatestDuplicates(); this.duplicatesManager.selectLatestDuplicates();
} }
deleteSelectedDuplicates() { deleteSelectedDuplicates() {
this.duplicatesManager.deleteSelectedDuplicates(); this.duplicatesManager.deleteSelectedDuplicates();
} }
@@ -219,14 +278,14 @@ class RecipeManager {
confirmDeleteDuplicates() { confirmDeleteDuplicates() {
this.duplicatesManager.confirmDeleteDuplicates(); this.duplicatesManager.confirmDeleteDuplicates();
} }
exitDuplicateMode() { exitDuplicateMode() {
// Clear the grid first to prevent showing old content temporarily // Clear the grid first to prevent showing old content temporarily
const recipeGrid = document.getElementById('recipeGrid'); const recipeGrid = document.getElementById('recipeGrid');
if (recipeGrid) { if (recipeGrid) {
recipeGrid.innerHTML = ''; recipeGrid.innerHTML = '';
} }
this.duplicatesManager.exitDuplicateMode(); this.duplicatesManager.exitDuplicateMode();
} }
} }
@@ -235,11 +294,11 @@ class RecipeManager {
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
// Initialize core application // Initialize core application
await appCore.initialize(); await appCore.initialize();
// Initialize recipe manager // Initialize recipe manager
const recipeManager = new RecipeManager(); const recipeManager = new RecipeManager();
await recipeManager.initialize(); await recipeManager.initialize();
}); });
// Export for use in other modules // Export for use in other modules
export { RecipeManager }; export { RecipeManager };

View File

@@ -58,7 +58,7 @@ export const state = {
loadingManager: null, loadingManager: null,
observer: null, observer: null,
}, },
// Page-specific states // Page-specific states
pages: { pages: {
[MODEL_TYPES.LORA]: { [MODEL_TYPES.LORA]: {
@@ -69,20 +69,20 @@ export const state = {
activeFolder: getStorageItem(`${MODEL_TYPES.LORA}_activeFolder`), activeFolder: getStorageItem(`${MODEL_TYPES.LORA}_activeFolder`),
activeLetterFilter: null, activeLetterFilter: null,
previewVersions: loraPreviewVersions, previewVersions: loraPreviewVersions,
searchManager: null, searchManager: null,
searchOptions: { searchOptions: {
filename: true, filename: true,
modelname: true, modelname: true,
tags: false, tags: false,
creator: false, creator: false,
recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true), recursive: getStorageItem(`${MODEL_TYPES.LORA}_recursiveSearch`, true),
}, },
filters: { filters: {
baseModel: [], baseModel: [],
tags: {}, tags: {},
license: {}, license: {},
modelTypes: [] modelTypes: []
}, },
bulkMode: false, bulkMode: false,
selectedLoras: new Set(), selectedLoras: new Set(),
loraMetadataCache: new Map(), loraMetadataCache: new Map(),
@@ -90,33 +90,35 @@ export const state = {
showUpdateAvailableOnly: false, showUpdateAvailableOnly: false,
duplicatesMode: false, duplicatesMode: false,
}, },
recipes: { recipes: {
currentPage: 1, currentPage: 1,
isLoading: false, isLoading: false,
hasMore: true, hasMore: true,
sortBy: 'date', sortBy: 'date:desc',
searchManager: null, activeFolder: getStorageItem('recipes_activeFolder'),
searchOptions: { searchManager: null,
title: true, searchOptions: {
tags: true, title: true,
loraName: true, tags: true,
loraModel: true loraName: true,
}, loraModel: true,
filters: { recursive: getStorageItem('recipes_recursiveSearch', true),
baseModel: [], },
tags: {}, filters: {
license: {}, baseModel: [],
modelTypes: [], tags: {},
search: '' license: {},
}, modelTypes: [],
search: ''
},
pageSize: 20, pageSize: 20,
showFavoritesOnly: false, showFavoritesOnly: false,
duplicatesMode: false, duplicatesMode: false,
bulkMode: false, bulkMode: false,
selectedModels: new Set(), selectedModels: new Set(),
}, },
[MODEL_TYPES.CHECKPOINT]: { [MODEL_TYPES.CHECKPOINT]: {
currentPage: 1, currentPage: 1,
isLoading: false, isLoading: false,
@@ -124,19 +126,19 @@ export const state = {
sortBy: 'name', sortBy: 'name',
activeFolder: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_activeFolder`), activeFolder: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_activeFolder`),
previewVersions: checkpointPreviewVersions, previewVersions: checkpointPreviewVersions,
searchManager: null, searchManager: null,
searchOptions: { searchOptions: {
filename: true, filename: true,
modelname: true, modelname: true,
creator: false, creator: false,
recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true), recursive: getStorageItem(`${MODEL_TYPES.CHECKPOINT}_recursiveSearch`, true),
}, },
filters: { filters: {
baseModel: [], baseModel: [],
tags: {}, tags: {},
license: {}, license: {},
modelTypes: [] modelTypes: []
}, },
modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model' modelType: 'checkpoint', // 'checkpoint' or 'diffusion_model'
bulkMode: false, bulkMode: false,
selectedModels: new Set(), selectedModels: new Set(),
@@ -145,7 +147,7 @@ export const state = {
showUpdateAvailableOnly: false, showUpdateAvailableOnly: false,
duplicatesMode: false, duplicatesMode: false,
}, },
[MODEL_TYPES.EMBEDDING]: { [MODEL_TYPES.EMBEDDING]: {
currentPage: 1, currentPage: 1,
isLoading: false, isLoading: false,
@@ -154,20 +156,20 @@ export const state = {
activeFolder: getStorageItem(`${MODEL_TYPES.EMBEDDING}_activeFolder`), activeFolder: getStorageItem(`${MODEL_TYPES.EMBEDDING}_activeFolder`),
activeLetterFilter: null, activeLetterFilter: null,
previewVersions: embeddingPreviewVersions, previewVersions: embeddingPreviewVersions,
searchManager: null, searchManager: null,
searchOptions: { searchOptions: {
filename: true, filename: true,
modelname: true, modelname: true,
tags: false, tags: false,
creator: false, creator: false,
recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true), recursive: getStorageItem(`${MODEL_TYPES.EMBEDDING}_recursiveSearch`, true),
}, },
filters: { filters: {
baseModel: [], baseModel: [],
tags: {}, tags: {},
license: {}, license: {},
modelTypes: [] modelTypes: []
}, },
bulkMode: false, bulkMode: false,
selectedModels: new Set(), selectedModels: new Set(),
metadataCache: new Map(), metadataCache: new Map(),
@@ -176,45 +178,45 @@ export const state = {
duplicatesMode: false, duplicatesMode: false,
} }
}, },
// Current active page - use MODEL_TYPES constants // Current active page - use MODEL_TYPES constants
currentPageType: MODEL_TYPES.LORA, currentPageType: MODEL_TYPES.LORA,
// Backward compatibility - proxy properties // Backward compatibility - proxy properties
get currentPage() { return this.pages[this.currentPageType].currentPage; }, get currentPage() { return this.pages[this.currentPageType].currentPage; },
set currentPage(value) { this.pages[this.currentPageType].currentPage = value; }, set currentPage(value) { this.pages[this.currentPageType].currentPage = value; },
get isLoading() { return this.pages[this.currentPageType].isLoading; }, get isLoading() { return this.pages[this.currentPageType].isLoading; },
set isLoading(value) { this.pages[this.currentPageType].isLoading = value; }, set isLoading(value) { this.pages[this.currentPageType].isLoading = value; },
get hasMore() { return this.pages[this.currentPageType].hasMore; }, get hasMore() { return this.pages[this.currentPageType].hasMore; },
set hasMore(value) { this.pages[this.currentPageType].hasMore = value; }, set hasMore(value) { this.pages[this.currentPageType].hasMore = value; },
get sortBy() { return this.pages[this.currentPageType].sortBy; }, get sortBy() { return this.pages[this.currentPageType].sortBy; },
set sortBy(value) { this.pages[this.currentPageType].sortBy = value; }, set sortBy(value) { this.pages[this.currentPageType].sortBy = value; },
get activeFolder() { return this.pages[this.currentPageType].activeFolder; }, get activeFolder() { return this.pages[this.currentPageType].activeFolder; },
set activeFolder(value) { this.pages[this.currentPageType].activeFolder = value; }, set activeFolder(value) { this.pages[this.currentPageType].activeFolder = value; },
get loadingManager() { return this.global.loadingManager; }, get loadingManager() { return this.global.loadingManager; },
set loadingManager(value) { this.global.loadingManager = value; }, set loadingManager(value) { this.global.loadingManager = value; },
get observer() { return this.global.observer; }, get observer() { return this.global.observer; },
set observer(value) { this.global.observer = value; }, set observer(value) { this.global.observer = value; },
get previewVersions() { return this.pages.loras.previewVersions; }, get previewVersions() { return this.pages.loras.previewVersions; },
set previewVersions(value) { this.pages.loras.previewVersions = value; }, set previewVersions(value) { this.pages.loras.previewVersions = value; },
get searchManager() { return this.pages[this.currentPageType].searchManager; }, get searchManager() { return this.pages[this.currentPageType].searchManager; },
set searchManager(value) { this.pages[this.currentPageType].searchManager = value; }, set searchManager(value) { this.pages[this.currentPageType].searchManager = value; },
get searchOptions() { return this.pages[this.currentPageType].searchOptions; }, get searchOptions() { return this.pages[this.currentPageType].searchOptions; },
set searchOptions(value) { this.pages[this.currentPageType].searchOptions = value; }, set searchOptions(value) { this.pages[this.currentPageType].searchOptions = value; },
get filters() { return this.pages[this.currentPageType].filters; }, get filters() { return this.pages[this.currentPageType].filters; },
set filters(value) { this.pages[this.currentPageType].filters = value; }, set filters(value) { this.pages[this.currentPageType].filters = value; },
get bulkMode() { get bulkMode() {
const currentType = this.currentPageType; const currentType = this.currentPageType;
if (currentType === MODEL_TYPES.LORA) { if (currentType === MODEL_TYPES.LORA) {
return this.pages.loras.bulkMode; return this.pages.loras.bulkMode;
@@ -222,7 +224,7 @@ export const state = {
return this.pages[currentType].bulkMode; return this.pages[currentType].bulkMode;
} }
}, },
set bulkMode(value) { set bulkMode(value) {
const currentType = this.currentPageType; const currentType = this.currentPageType;
if (currentType === MODEL_TYPES.LORA) { if (currentType === MODEL_TYPES.LORA) {
this.pages.loras.bulkMode = value; this.pages.loras.bulkMode = value;
@@ -230,11 +232,11 @@ export const state = {
this.pages[currentType].bulkMode = value; this.pages[currentType].bulkMode = value;
} }
}, },
get selectedLoras() { return this.pages.loras.selectedLoras; }, get selectedLoras() { return this.pages.loras.selectedLoras; },
set selectedLoras(value) { this.pages.loras.selectedLoras = value; }, set selectedLoras(value) { this.pages.loras.selectedLoras = value; },
get selectedModels() { get selectedModels() {
const currentType = this.currentPageType; const currentType = this.currentPageType;
if (currentType === MODEL_TYPES.LORA) { if (currentType === MODEL_TYPES.LORA) {
return this.pages.loras.selectedLoras; return this.pages.loras.selectedLoras;
@@ -242,7 +244,7 @@ export const state = {
return this.pages[currentType].selectedModels; return this.pages[currentType].selectedModels;
} }
}, },
set selectedModels(value) { set selectedModels(value) {
const currentType = this.currentPageType; const currentType = this.currentPageType;
if (currentType === MODEL_TYPES.LORA) { if (currentType === MODEL_TYPES.LORA) {
this.pages.loras.selectedLoras = value; this.pages.loras.selectedLoras = value;
@@ -250,10 +252,10 @@ export const state = {
this.pages[currentType].selectedModels = value; this.pages[currentType].selectedModels = value;
} }
}, },
get loraMetadataCache() { return this.pages.loras.loraMetadataCache; }, get loraMetadataCache() { return this.pages.loras.loraMetadataCache; },
set loraMetadataCache(value) { this.pages.loras.loraMetadataCache = value; }, set loraMetadataCache(value) { this.pages.loras.loraMetadataCache = value; },
get settings() { return this.global.settings; }, get settings() { return this.global.settings; },
set settings(value) { this.global.settings = value; } set settings(value) { this.global.settings = value; }
}; };

View File

@@ -12,13 +12,13 @@ export class VirtualScroller {
this.scrollContainer = options.scrollContainer || this.containerElement; this.scrollContainer = options.scrollContainer || this.containerElement;
this.batchSize = options.batchSize || 50; this.batchSize = options.batchSize || 50;
this.pageSize = options.pageSize || 100; this.pageSize = options.pageSize || 100;
this.itemAspectRatio = 896/1152; // Aspect ratio of cards this.itemAspectRatio = 896 / 1152; // Aspect ratio of cards
this.rowGap = options.rowGap || 20; // Add vertical gap between rows (default 20px) this.rowGap = options.rowGap || 20; // Add vertical gap between rows (default 20px)
// Add container padding properties // Add container padding properties
this.containerPaddingTop = options.containerPaddingTop || 4; // Default top padding from CSS this.containerPaddingTop = options.containerPaddingTop || 4; // Default top padding from CSS
this.containerPaddingBottom = options.containerPaddingBottom || 4; // Default bottom padding from CSS this.containerPaddingBottom = options.containerPaddingBottom || 4; // Default bottom padding from CSS
// Add data windowing enable/disable flag // Add data windowing enable/disable flag
this.enableDataWindowing = options.enableDataWindowing !== undefined ? options.enableDataWindowing : false; this.enableDataWindowing = options.enableDataWindowing !== undefined ? options.enableDataWindowing : false;
@@ -73,15 +73,15 @@ export class VirtualScroller {
this.spacerElement.style.width = '100%'; this.spacerElement.style.width = '100%';
this.spacerElement.style.height = '0px'; // Will be updated as items are loaded this.spacerElement.style.height = '0px'; // Will be updated as items are loaded
this.spacerElement.style.pointerEvents = 'none'; this.spacerElement.style.pointerEvents = 'none';
// The grid will be used for the actual visible items // The grid will be used for the actual visible items
this.gridElement.style.position = 'relative'; this.gridElement.style.position = 'relative';
this.gridElement.style.minHeight = '0'; this.gridElement.style.minHeight = '0';
// Apply padding directly to ensure consistency // Apply padding directly to ensure consistency
this.gridElement.style.paddingTop = `${this.containerPaddingTop}px`; this.gridElement.style.paddingTop = `${this.containerPaddingTop}px`;
this.gridElement.style.paddingBottom = `${this.containerPaddingBottom}px`; this.gridElement.style.paddingBottom = `${this.containerPaddingBottom}px`;
// Place the spacer inside the grid container // Place the spacer inside the grid container
this.gridElement.appendChild(this.spacerElement); this.gridElement.appendChild(this.spacerElement);
} }
@@ -97,16 +97,16 @@ export class VirtualScroller {
const containerStyle = getComputedStyle(this.containerElement); const containerStyle = getComputedStyle(this.containerElement);
const paddingLeft = parseInt(containerStyle.paddingLeft, 10) || 0; const paddingLeft = parseInt(containerStyle.paddingLeft, 10) || 0;
const paddingRight = parseInt(containerStyle.paddingRight, 10) || 0; const paddingRight = parseInt(containerStyle.paddingRight, 10) || 0;
// Calculate available content width (excluding padding) // Calculate available content width (excluding padding)
const availableContentWidth = containerWidth - paddingLeft - paddingRight; const availableContentWidth = containerWidth - paddingLeft - paddingRight;
// Get display density setting // Get display density setting
const displayDensity = state.global.settings?.display_density || 'default'; const displayDensity = state.global.settings?.display_density || 'default';
// Set exact column counts and grid widths to match CSS container widths // Set exact column counts and grid widths to match CSS container widths
let maxColumns, maxGridWidth; let maxColumns, maxGridWidth;
// Match exact column counts and CSS container width values based on density // Match exact column counts and CSS container width values based on density
if (window.innerWidth >= 3000) { // 4K if (window.innerWidth >= 3000) { // 4K
if (displayDensity === 'default') { if (displayDensity === 'default') {
@@ -137,17 +137,17 @@ export class VirtualScroller {
} }
maxGridWidth = 1400; // Match exact CSS container width for 1080p maxGridWidth = 1400; // Match exact CSS container width for 1080p
} }
// Calculate baseCardWidth based on desired column count and available space // Calculate baseCardWidth based on desired column count and available space
// Formula: (maxGridWidth - (columns-1)*gap) / columns // Formula: (maxGridWidth - (columns-1)*gap) / columns
const baseCardWidth = (maxGridWidth - ((maxColumns - 1) * this.columnGap)) / maxColumns; const baseCardWidth = (maxGridWidth - ((maxColumns - 1) * this.columnGap)) / maxColumns;
// Use the smaller of available content width or max grid width // Use the smaller of available content width or max grid width
const actualGridWidth = Math.min(availableContentWidth, maxGridWidth); const actualGridWidth = Math.min(availableContentWidth, maxGridWidth);
// Set exact column count based on screen size and mode // Set exact column count based on screen size and mode
this.columnsCount = maxColumns; this.columnsCount = maxColumns;
// When available width is smaller than maxGridWidth, recalculate columns // When available width is smaller than maxGridWidth, recalculate columns
if (availableContentWidth < maxGridWidth) { if (availableContentWidth < maxGridWidth) {
// Calculate how many columns can fit in the available space // Calculate how many columns can fit in the available space
@@ -155,30 +155,30 @@ export class VirtualScroller {
(availableContentWidth + this.columnGap) / (baseCardWidth + this.columnGap) (availableContentWidth + this.columnGap) / (baseCardWidth + this.columnGap)
)); ));
} }
// Calculate actual item width // Calculate actual item width
this.itemWidth = (actualGridWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount; this.itemWidth = (actualGridWidth - (this.columnsCount - 1) * this.columnGap) / this.columnsCount;
// Calculate height based on aspect ratio // Calculate height based on aspect ratio
this.itemHeight = this.itemWidth / this.itemAspectRatio; this.itemHeight = this.itemWidth / this.itemAspectRatio;
// Calculate the left offset to center the grid within the content area // Calculate the left offset to center the grid within the content area
this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2); this.leftOffset = Math.max(0, (availableContentWidth - actualGridWidth) / 2);
// Update grid element max-width to match available width // Update grid element max-width to match available width
this.gridElement.style.maxWidth = `${actualGridWidth}px`; this.gridElement.style.maxWidth = `${actualGridWidth}px`;
// Add or remove density classes for style adjustments // Add or remove density classes for style adjustments
this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density'); this.gridElement.classList.remove('default-density', 'medium-density', 'compact-density');
this.gridElement.classList.add(`${displayDensity}-density`); this.gridElement.classList.add(`${displayDensity}-density`);
// Update spacer height // Update spacer height
this.updateSpacerHeight(); this.updateSpacerHeight();
// Re-render with new layout // Re-render with new layout
this.clearRenderedItems(); this.clearRenderedItems();
this.scheduleRender(); this.scheduleRender();
return true; return true;
} }
@@ -186,20 +186,20 @@ export class VirtualScroller {
// Debounced scroll handler // Debounced scroll handler
this.scrollHandler = this.debounce(() => this.handleScroll(), 10); this.scrollHandler = this.debounce(() => this.handleScroll(), 10);
this.scrollContainer.addEventListener('scroll', this.scrollHandler); this.scrollContainer.addEventListener('scroll', this.scrollHandler);
// Window resize handler for layout recalculation // Window resize handler for layout recalculation
this.resizeHandler = this.debounce(() => { this.resizeHandler = this.debounce(() => {
this.calculateLayout(); this.calculateLayout();
}, 150); }, 150);
window.addEventListener('resize', this.resizeHandler); window.addEventListener('resize', this.resizeHandler);
// Use ResizeObserver for more accurate container size detection // Use ResizeObserver for more accurate container size detection
if (typeof ResizeObserver !== 'undefined') { if (typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(this.debounce(() => { this.resizeObserver = new ResizeObserver(this.debounce(() => {
this.calculateLayout(); this.calculateLayout();
}, 150)); }, 150));
this.resizeObserver.observe(this.containerElement); this.resizeObserver.observe(this.containerElement);
} }
} }
@@ -217,35 +217,35 @@ export class VirtualScroller {
async loadInitialBatch() { async loadInitialBatch() {
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
if (this.isLoading) return; if (this.isLoading) return;
this.isLoading = true; this.isLoading = true;
this.setLoadingTimeout(); // Add loading timeout safety this.setLoadingTimeout(); // Add loading timeout safety
try { try {
const { items, totalItems, hasMore } = await this.fetchItemsFn(1, this.pageSize); const { items, totalItems, hasMore } = await this.fetchItemsFn(1, this.pageSize);
// Initialize the data window with the first batch of items // Initialize the data window with the first batch of items
this.items = items || []; this.items = items || [];
this.totalItems = totalItems || 0; this.totalItems = totalItems || 0;
this.hasMore = hasMore; this.hasMore = hasMore;
this.dataWindow = { start: 0, end: this.items.length }; this.dataWindow = { start: 0, end: this.items.length };
this.absoluteWindowStart = 0; this.absoluteWindowStart = 0;
// Update the spacer height based on the total number of items // Update the spacer height based on the total number of items
this.updateSpacerHeight(); this.updateSpacerHeight();
// Check if there are no items and show placeholder if needed // Check if there are no items and show placeholder if needed
if (this.items.length === 0) { if (this.items.length === 0) {
this.showNoItemsPlaceholder(); this.showNoItemsPlaceholder();
} else { } else {
this.removeNoItemsPlaceholder(); this.removeNoItemsPlaceholder();
} }
// Reset page state to sync with our virtual scroller // Reset page state to sync with our virtual scroller
pageState.currentPage = 2; // Next page to load would be 2 pageState.currentPage = 2; // Next page to load would be 2
pageState.hasMore = this.hasMore; pageState.hasMore = this.hasMore;
pageState.isLoading = false; pageState.isLoading = false;
return { items, totalItems, hasMore }; return { items, totalItems, hasMore };
} catch (err) { } catch (err) {
console.error('Failed to load initial batch:', err); console.error('Failed to load initial batch:', err);
@@ -260,36 +260,36 @@ export class VirtualScroller {
async loadMoreItems() { async loadMoreItems() {
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
if (this.isLoading || !this.hasMore) return; if (this.isLoading || !this.hasMore) return;
this.isLoading = true; this.isLoading = true;
pageState.isLoading = true; pageState.isLoading = true;
this.setLoadingTimeout(); // Add loading timeout safety this.setLoadingTimeout(); // Add loading timeout safety
try { try {
console.log('Loading more items, page:', pageState.currentPage); console.log('Loading more items, page:', pageState.currentPage);
const { items, hasMore } = await this.fetchItemsFn(pageState.currentPage, this.pageSize); const { items, hasMore } = await this.fetchItemsFn(pageState.currentPage, this.pageSize);
if (items && items.length > 0) { if (items && items.length > 0) {
this.items = [...this.items, ...items]; this.items = [...this.items, ...items];
this.hasMore = hasMore; this.hasMore = hasMore;
pageState.hasMore = hasMore; pageState.hasMore = hasMore;
// Update page for next request // Update page for next request
pageState.currentPage++; pageState.currentPage++;
// Update the spacer height // Update the spacer height
this.updateSpacerHeight(); this.updateSpacerHeight();
// Render the newly loaded items if they're in view // Render the newly loaded items if they're in view
this.scheduleRender(); this.scheduleRender();
console.log(`Loaded ${items.length} more items, total now: ${this.items.length}`); console.log(`Loaded ${items.length} more items, total now: ${this.items.length}`);
} else { } else {
this.hasMore = false; this.hasMore = false;
pageState.hasMore = false; pageState.hasMore = false;
console.log('No more items to load'); console.log('No more items to load');
} }
return items; return items;
} catch (err) { } catch (err) {
console.error('Failed to load more items:', err); console.error('Failed to load more items:', err);
@@ -305,7 +305,7 @@ export class VirtualScroller {
setLoadingTimeout() { setLoadingTimeout() {
// Clear any existing timeout first // Clear any existing timeout first
this.clearLoadingTimeout(); this.clearLoadingTimeout();
// Set a new timeout to prevent loading state from getting stuck // Set a new timeout to prevent loading state from getting stuck
this.loadingTimeout = setTimeout(() => { this.loadingTimeout = setTimeout(() => {
if (this.isLoading) { if (this.isLoading) {
@@ -326,15 +326,15 @@ export class VirtualScroller {
updateSpacerHeight() { updateSpacerHeight() {
if (this.columnsCount === 0) return; if (this.columnsCount === 0) return;
// Calculate total rows needed based on total items and columns // Calculate total rows needed based on total items and columns
const totalRows = Math.ceil(this.totalItems / this.columnsCount); const totalRows = Math.ceil(this.totalItems / this.columnsCount);
// Add row gaps to the total height calculation // Add row gaps to the total height calculation
const totalHeight = totalRows * this.itemHeight + (totalRows - 1) * this.rowGap; const totalHeight = totalRows * this.itemHeight + (totalRows - 1) * this.rowGap;
// Include container padding in the total height // Include container padding in the total height
const spacerHeight = totalHeight + this.containerPaddingTop + this.containerPaddingBottom; const spacerHeight = totalHeight + this.containerPaddingTop + this.containerPaddingBottom;
// Update spacer height to represent all items // Update spacer height to represent all items
this.spacerElement.style.height = `${spacerHeight}px`; this.spacerElement.style.height = `${spacerHeight}px`;
} }
@@ -342,28 +342,28 @@ export class VirtualScroller {
getVisibleRange() { getVisibleRange() {
const scrollTop = this.scrollContainer.scrollTop; const scrollTop = this.scrollContainer.scrollTop;
const viewportHeight = this.scrollContainer.clientHeight; const viewportHeight = this.scrollContainer.clientHeight;
// Calculate the visible row range, accounting for row gaps // Calculate the visible row range, accounting for row gaps
const rowHeight = this.itemHeight + this.rowGap; const rowHeight = this.itemHeight + this.rowGap;
const startRow = Math.floor(scrollTop / rowHeight); const startRow = Math.floor(scrollTop / rowHeight);
const endRow = Math.ceil((scrollTop + viewportHeight) / rowHeight); const endRow = Math.ceil((scrollTop + viewportHeight) / rowHeight);
// Add overscan for smoother scrolling // Add overscan for smoother scrolling
const overscanRows = this.overscan; const overscanRows = this.overscan;
const firstRow = Math.max(0, startRow - overscanRows); const firstRow = Math.max(0, startRow - overscanRows);
const lastRow = Math.min(Math.ceil(this.totalItems / this.columnsCount), endRow + overscanRows); const lastRow = Math.min(Math.ceil(this.totalItems / this.columnsCount), endRow + overscanRows);
// Calculate item indices // Calculate item indices
const firstIndex = firstRow * this.columnsCount; const firstIndex = firstRow * this.columnsCount;
const lastIndex = Math.min(this.totalItems, lastRow * this.columnsCount); const lastIndex = Math.min(this.totalItems, lastRow * this.columnsCount);
return { start: firstIndex, end: lastIndex }; return { start: firstIndex, end: lastIndex };
} }
// Update the scheduleRender method to check for disabled state // Update the scheduleRender method to check for disabled state
scheduleRender() { scheduleRender() {
if (this.disabled || this.renderScheduled) return; if (this.disabled || this.renderScheduled) return;
this.renderScheduled = true; this.renderScheduled = true;
requestAnimationFrame(() => { requestAnimationFrame(() => {
this.renderItems(); this.renderItems();
@@ -374,25 +374,25 @@ export class VirtualScroller {
// Update the renderItems method to check for disabled state // Update the renderItems method to check for disabled state
renderItems() { renderItems() {
if (this.disabled || this.items.length === 0 || this.columnsCount === 0) return; if (this.disabled || this.items.length === 0 || this.columnsCount === 0) return;
const { start, end } = this.getVisibleRange(); const { start, end } = this.getVisibleRange();
// Check if render range has significantly changed // Check if render range has significantly changed
const isSameRange = const isSameRange =
start >= this.lastRenderRange.start && start >= this.lastRenderRange.start &&
end <= this.lastRenderRange.end && end <= this.lastRenderRange.end &&
Math.abs(start - this.lastRenderRange.start) < 10; Math.abs(start - this.lastRenderRange.start) < 10;
if (isSameRange) return; if (isSameRange) return;
this.lastRenderRange = { start, end }; this.lastRenderRange = { start, end };
// Determine which items need to be added and removed // Determine which items need to be added and removed
const currentIndices = new Set(); const currentIndices = new Set();
for (let i = start; i < end && i < this.items.length; i++) { for (let i = start; i < end && i < this.items.length; i++) {
currentIndices.add(i); currentIndices.add(i);
} }
// Remove items that are no longer visible // Remove items that are no longer visible
for (const [index, element] of this.renderedItems.entries()) { for (const [index, element] of this.renderedItems.entries()) {
if (!currentIndices.has(index)) { if (!currentIndices.has(index)) {
@@ -400,10 +400,10 @@ export class VirtualScroller {
this.renderedItems.delete(index); this.renderedItems.delete(index);
} }
} }
// Use DocumentFragment for batch DOM operations // Use DocumentFragment for batch DOM operations
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
// Add new visible items to the fragment // Add new visible items to the fragment
for (let i = start; i < end && i < this.items.length; i++) { for (let i = start; i < end && i < this.items.length; i++) {
if (!this.renderedItems.has(i)) { if (!this.renderedItems.has(i)) {
@@ -413,17 +413,17 @@ export class VirtualScroller {
this.renderedItems.set(i, element); this.renderedItems.set(i, element);
} }
} }
// Add the fragment to the grid (single DOM operation) // Add the fragment to the grid (single DOM operation)
if (fragment.childNodes.length > 0) { if (fragment.childNodes.length > 0) {
this.gridElement.appendChild(fragment); this.gridElement.appendChild(fragment);
} }
// If we're close to the end and have more items to load, fetch them // 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) { if (end > this.items.length - (this.columnsCount * 2) && this.hasMore && !this.isLoading) {
this.loadMoreItems(); this.loadMoreItems();
} }
// Check if we need to slide the data window // Check if we need to slide the data window
this.slideDataWindow(); this.slideDataWindow();
} }
@@ -439,14 +439,14 @@ export class VirtualScroller {
this.totalItems = totalItems || 0; this.totalItems = totalItems || 0;
this.hasMore = hasMore; this.hasMore = hasMore;
this.updateSpacerHeight(); this.updateSpacerHeight();
// Check if there are no items and show placeholder if needed // Check if there are no items and show placeholder if needed
if (this.items.length === 0) { if (this.items.length === 0) {
this.showNoItemsPlaceholder(); this.showNoItemsPlaceholder();
} else { } else {
this.removeNoItemsPlaceholder(); this.removeNoItemsPlaceholder();
} }
// Clear all rendered items and redraw // Clear all rendered items and redraw
this.clearRenderedItems(); this.clearRenderedItems();
this.scheduleRender(); this.scheduleRender();
@@ -455,29 +455,29 @@ export class VirtualScroller {
createItemElement(item, index) { createItemElement(item, index) {
// Create the DOM element // Create the DOM element
const element = this.createItemFn(item); const element = this.createItemFn(item);
// Add virtual scroll item class // Add virtual scroll item class
element.classList.add('virtual-scroll-item'); element.classList.add('virtual-scroll-item');
// Calculate the position // Calculate the position
const row = Math.floor(index / this.columnsCount); const row = Math.floor(index / this.columnsCount);
const col = index % this.columnsCount; const col = index % this.columnsCount;
// Calculate precise positions with row gap included // Calculate precise positions with row gap included
// Add the top padding to account for container padding // Add the top padding to account for container padding
const topPos = this.containerPaddingTop + (row * (this.itemHeight + this.rowGap)); const topPos = this.containerPaddingTop + (row * (this.itemHeight + this.rowGap));
// Position correctly with leftOffset (no need to add padding as absolute // Position correctly with leftOffset (no need to add padding as absolute
// positioning is already relative to the padding edge of the container) // positioning is already relative to the padding edge of the container)
const leftPos = this.leftOffset + (col * (this.itemWidth + this.columnGap)); const leftPos = this.leftOffset + (col * (this.itemWidth + this.columnGap));
// Position the element with absolute positioning // Position the element with absolute positioning
element.style.position = 'absolute'; element.style.position = 'absolute';
element.style.left = `${leftPos}px`; element.style.left = `${leftPos}px`;
element.style.top = `${topPos}px`; element.style.top = `${topPos}px`;
element.style.width = `${this.itemWidth}px`; element.style.width = `${this.itemWidth}px`;
element.style.height = `${this.itemHeight}px`; element.style.height = `${this.itemHeight}px`;
return element; return element;
} }
@@ -486,17 +486,17 @@ export class VirtualScroller {
const scrollTop = this.scrollContainer.scrollTop; const scrollTop = this.scrollContainer.scrollTop;
this.scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up'; this.scrollDirection = scrollTop > this.lastScrollTop ? 'down' : 'up';
this.lastScrollTop = scrollTop; this.lastScrollTop = scrollTop;
// Handle large jumps in scroll position - check if we need to fetch a new window // Handle large jumps in scroll position - check if we need to fetch a new window
const { scrollHeight } = this.scrollContainer; const { scrollHeight } = this.scrollContainer;
const scrollRatio = scrollTop / scrollHeight; const scrollRatio = scrollTop / scrollHeight;
// Only perform data windowing if the feature is enabled // Only perform data windowing if the feature is enabled
if (this.enableDataWindowing && this.totalItems > this.windowSize) { if (this.enableDataWindowing && this.totalItems > this.windowSize) {
const estimatedIndex = Math.floor(scrollRatio * this.totalItems); const estimatedIndex = Math.floor(scrollRatio * this.totalItems);
const currentWindowStart = this.absoluteWindowStart; const currentWindowStart = this.absoluteWindowStart;
const currentWindowEnd = currentWindowStart + this.items.length; const currentWindowEnd = currentWindowStart + this.items.length;
// If the estimated position is outside our current window by a significant amount // If the estimated position is outside our current window by a significant amount
if (estimatedIndex < currentWindowStart || estimatedIndex > currentWindowEnd) { if (estimatedIndex < currentWindowStart || estimatedIndex > currentWindowEnd) {
// Fetch a new data window centered on the estimated position // 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 return; // Skip normal rendering until new data is loaded
} }
} }
// Render visible items // Render visible items
this.scheduleRender(); this.scheduleRender();
// If we're near the bottom and have more items, load them // If we're near the bottom and have more items, load them
const { clientHeight } = this.scrollContainer; const { clientHeight } = this.scrollContainer;
const scrollBottom = scrollTop + clientHeight; const scrollBottom = scrollTop + clientHeight;
// Fix the threshold calculation - use percentage of remaining height instead // Fix the threshold calculation - use percentage of remaining height instead
// We'll trigger loading when within 20% of the bottom of rendered content // We'll trigger loading when within 20% of the bottom of rendered content
const remainingScroll = scrollHeight - scrollBottom; const remainingScroll = scrollHeight - scrollBottom;
@@ -521,9 +521,9 @@ export class VirtualScroller {
// Or when within 2 rows of content from the bottom, whichever is larger // Or when within 2 rows of content from the bottom, whichever is larger
(this.itemHeight + this.rowGap) * 2 (this.itemHeight + this.rowGap) * 2
); );
const shouldLoadMore = remainingScroll <= scrollThreshold; const shouldLoadMore = remainingScroll <= scrollThreshold;
if (shouldLoadMore && this.hasMore && !this.isLoading) { if (shouldLoadMore && this.hasMore && !this.isLoading) {
this.loadMoreItems(); this.loadMoreItems();
} }
@@ -533,40 +533,40 @@ export class VirtualScroller {
async fetchDataWindow(targetIndex) { async fetchDataWindow(targetIndex) {
// Skip if data windowing is disabled or already fetching // Skip if data windowing is disabled or already fetching
if (!this.enableDataWindowing || this.fetchingWindow) return; if (!this.enableDataWindowing || this.fetchingWindow) return;
this.fetchingWindow = true; this.fetchingWindow = true;
try { try {
// Calculate which page we need to fetch based on target index // Calculate which page we need to fetch based on target index
const targetPage = Math.floor(targetIndex / this.pageSize) + 1; const targetPage = Math.floor(targetIndex / this.pageSize) + 1;
console.log(`Fetching data window for index ${targetIndex}, page ${targetPage}`); console.log(`Fetching data window for index ${targetIndex}, page ${targetPage}`);
const { items, totalItems, hasMore } = await this.fetchItemsFn(targetPage, this.pageSize); const { items, totalItems, hasMore } = await this.fetchItemsFn(targetPage, this.pageSize);
if (items && items.length > 0) { if (items && items.length > 0) {
// Calculate new absolute window start // Calculate new absolute window start
this.absoluteWindowStart = (targetPage - 1) * this.pageSize; this.absoluteWindowStart = (targetPage - 1) * this.pageSize;
// Replace the entire data window with new items // Replace the entire data window with new items
this.items = items; this.items = items;
this.dataWindow = { this.dataWindow = {
start: 0, start: 0,
end: items.length end: items.length
}; };
this.totalItems = totalItems || 0; this.totalItems = totalItems || 0;
this.hasMore = hasMore; this.hasMore = hasMore;
// Update the current page for future fetches // Update the current page for future fetches
const pageState = getCurrentPageState(); const pageState = getCurrentPageState();
pageState.currentPage = targetPage + 1; pageState.currentPage = targetPage + 1;
pageState.hasMore = hasMore; pageState.hasMore = hasMore;
// Update the spacer height and clear current rendered items // Update the spacer height and clear current rendered items
this.updateSpacerHeight(); this.updateSpacerHeight();
this.clearRenderedItems(); this.clearRenderedItems();
this.scheduleRender(); this.scheduleRender();
console.log(`Loaded ${items.length} items for window at absolute index ${this.absoluteWindowStart}`); console.log(`Loaded ${items.length} items for window at absolute index ${this.absoluteWindowStart}`);
} }
} catch (err) { } catch (err) {
@@ -581,37 +581,37 @@ export class VirtualScroller {
async slideDataWindow() { async slideDataWindow() {
// Skip if data windowing is disabled // Skip if data windowing is disabled
if (!this.enableDataWindowing) return; if (!this.enableDataWindowing) return;
const { start, end } = this.getVisibleRange(); const { start, end } = this.getVisibleRange();
const windowStart = this.dataWindow.start; const windowStart = this.dataWindow.start;
const windowEnd = this.dataWindow.end; const windowEnd = this.dataWindow.end;
const absoluteIndex = this.absoluteWindowStart + windowStart; const absoluteIndex = this.absoluteWindowStart + windowStart;
// Calculate the midpoint of the visible range // Calculate the midpoint of the visible range
const visibleMidpoint = Math.floor((start + end) / 2); const visibleMidpoint = Math.floor((start + end) / 2);
const absoluteMidpoint = this.absoluteWindowStart + visibleMidpoint; const absoluteMidpoint = this.absoluteWindowStart + visibleMidpoint;
// Check if we're too close to the window edges // Check if we're too close to the window edges
const closeToStart = start - windowStart < this.windowPadding; const closeToStart = start - windowStart < this.windowPadding;
const closeToEnd = windowEnd - end < this.windowPadding; const closeToEnd = windowEnd - end < this.windowPadding;
// If we're close to either edge and have total items > window size // If we're close to either edge and have total items > window size
if ((closeToStart || closeToEnd) && this.totalItems > this.windowSize) { if ((closeToStart || closeToEnd) && this.totalItems > this.windowSize) {
// Calculate a new target index centered around the current viewport // Calculate a new target index centered around the current viewport
const halfWindow = Math.floor(this.windowSize / 2); const halfWindow = Math.floor(this.windowSize / 2);
const targetIndex = Math.max(0, absoluteMidpoint - halfWindow); const targetIndex = Math.max(0, absoluteMidpoint - halfWindow);
// Don't fetch a new window if we're already showing items near the beginning // Don't fetch a new window if we're already showing items near the beginning
if (targetIndex === 0 && this.absoluteWindowStart === 0) { if (targetIndex === 0 && this.absoluteWindowStart === 0) {
return; return;
} }
// Don't fetch if we're showing the end of the list and are near the end // 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) { this.totalItems - end < halfWindow) {
return; return;
} }
// Fetch the new data window // Fetch the new data window
await this.fetchDataWindow(targetIndex); await this.fetchDataWindow(targetIndex);
} }
@@ -620,18 +620,18 @@ export class VirtualScroller {
reset() { reset() {
// Remove all rendered items // Remove all rendered items
this.clearRenderedItems(); this.clearRenderedItems();
// Reset state // Reset state
this.items = []; this.items = [];
this.totalItems = 0; this.totalItems = 0;
this.hasMore = true; this.hasMore = true;
// Reset spacer height // Reset spacer height
this.spacerElement.style.height = '0px'; this.spacerElement.style.height = '0px';
// Remove any placeholder // Remove any placeholder
this.removeNoItemsPlaceholder(); this.removeNoItemsPlaceholder();
// Schedule a re-render // Schedule a re-render
this.scheduleRender(); this.scheduleRender();
} }
@@ -640,21 +640,21 @@ export class VirtualScroller {
// Remove event listeners // Remove event listeners
this.scrollContainer.removeEventListener('scroll', this.scrollHandler); this.scrollContainer.removeEventListener('scroll', this.scrollHandler);
window.removeEventListener('resize', this.resizeHandler); window.removeEventListener('resize', this.resizeHandler);
// Clean up the resize observer if present // Clean up the resize observer if present
if (this.resizeObserver) { if (this.resizeObserver) {
this.resizeObserver.disconnect(); this.resizeObserver.disconnect();
} }
// Remove rendered elements // Remove rendered elements
this.clearRenderedItems(); this.clearRenderedItems();
// Remove spacer // Remove spacer
this.spacerElement.remove(); this.spacerElement.remove();
// Remove virtual scroll class // Remove virtual scroll class
this.gridElement.classList.remove('virtual-scroll'); this.gridElement.classList.remove('virtual-scroll');
// Clear any pending timeout // Clear any pending timeout
this.clearLoadingTimeout(); this.clearLoadingTimeout();
} }
@@ -663,19 +663,19 @@ export class VirtualScroller {
showNoItemsPlaceholder(message) { showNoItemsPlaceholder(message) {
// Remove any existing placeholder first // Remove any existing placeholder first
this.removeNoItemsPlaceholder(); this.removeNoItemsPlaceholder();
// Create placeholder message // Create placeholder message
const placeholder = document.createElement('div'); const placeholder = document.createElement('div');
placeholder.className = 'placeholder-message'; placeholder.className = 'placeholder-message';
// Determine appropriate message based on page type // Determine appropriate message based on page type
let placeholderText = ''; let placeholderText = '';
if (message) { if (message) {
placeholderText = message; placeholderText = message;
} else { } else {
const pageType = state.currentPageType; const pageType = state.currentPageType;
if (pageType === 'recipes') { if (pageType === 'recipes') {
placeholderText = ` placeholderText = `
<p>No recipes found</p> <p>No recipes found</p>
@@ -698,10 +698,10 @@ export class VirtualScroller {
`; `;
} }
} }
placeholder.innerHTML = placeholderText; placeholder.innerHTML = placeholderText;
placeholder.id = 'virtualScrollPlaceholder'; placeholder.id = 'virtualScrollPlaceholder';
// Append placeholder to the grid // Append placeholder to the grid
this.gridElement.appendChild(placeholder); this.gridElement.appendChild(placeholder);
} }
@@ -716,7 +716,7 @@ export class VirtualScroller {
// Utility method for debouncing // Utility method for debouncing
debounce(func, wait) { debounce(func, wait) {
let timeout; let timeout;
return function(...args) { return function (...args) {
const context = this; const context = this;
clearTimeout(timeout); clearTimeout(timeout);
timeout = setTimeout(() => func.apply(context, args), wait); timeout = setTimeout(() => func.apply(context, args), wait);
@@ -727,55 +727,55 @@ export class VirtualScroller {
disable() { disable() {
// Detach scroll event listener // Detach scroll event listener
this.scrollContainer.removeEventListener('scroll', this.scrollHandler); this.scrollContainer.removeEventListener('scroll', this.scrollHandler);
// Clear all rendered items from the DOM // Clear all rendered items from the DOM
this.clearRenderedItems(); this.clearRenderedItems();
// Hide the spacer element // Hide the spacer element
if (this.spacerElement) { if (this.spacerElement) {
this.spacerElement.style.display = 'none'; this.spacerElement.style.display = 'none';
} }
// Flag as disabled // Flag as disabled
this.disabled = true; this.disabled = true;
console.log('Virtual scroller disabled'); console.log('Virtual scroller disabled');
} }
// Add enable method to resume rendering and events // Add enable method to resume rendering and events
enable() { enable() {
if (!this.disabled) return; if (!this.disabled) return;
// Reattach scroll event listener // Reattach scroll event listener
this.scrollContainer.addEventListener('scroll', this.scrollHandler); this.scrollContainer.addEventListener('scroll', this.scrollHandler);
// Check if spacer element exists in the DOM, if not, recreate it // Check if spacer element exists in the DOM, if not, recreate it
if (!this.spacerElement || !this.gridElement.contains(this.spacerElement)) { if (!this.spacerElement || !this.gridElement.contains(this.spacerElement)) {
console.log('Spacer element not found in DOM, recreating it'); console.log('Spacer element not found in DOM, recreating it');
// Create a new spacer element // Create a new spacer element
this.spacerElement = document.createElement('div'); this.spacerElement = document.createElement('div');
this.spacerElement.className = 'virtual-scroll-spacer'; this.spacerElement.className = 'virtual-scroll-spacer';
this.spacerElement.style.width = '100%'; this.spacerElement.style.width = '100%';
this.spacerElement.style.height = '0px'; this.spacerElement.style.height = '0px';
this.spacerElement.style.pointerEvents = 'none'; this.spacerElement.style.pointerEvents = 'none';
// Append it to the grid // Append it to the grid
this.gridElement.appendChild(this.spacerElement); this.gridElement.appendChild(this.spacerElement);
// Update the spacer height // Update the spacer height
this.updateSpacerHeight(); this.updateSpacerHeight();
} else { } else {
// Show the spacer element if it exists // Show the spacer element if it exists
this.spacerElement.style.display = 'block'; this.spacerElement.style.display = 'block';
} }
// Flag as enabled // Flag as enabled
this.disabled = false; this.disabled = false;
// Re-render items // Re-render items
this.scheduleRender(); this.scheduleRender();
console.log('Virtual scroller enabled'); console.log('Virtual scroller enabled');
} }
@@ -783,31 +783,30 @@ export class VirtualScroller {
deepMerge(target, source) { deepMerge(target, source) {
if (!source || !target) return target; if (!source || !target) return target;
// Initialize result with a copy of target
const result = { ...target }; const result = { ...target };
// Only iterate over keys that exist in target if (!source) return result;
Object.keys(target).forEach(key => {
// Check if source has this key
if (source.hasOwnProperty(key)) {
const targetValue = target[key];
const sourceValue = source[key];
// If both values are non-null objects and not arrays, merge recursively // Iterate over all keys in the source object
if ( Object.keys(source).forEach(key => {
targetValue !== null && const targetValue = target[key];
typeof targetValue === 'object' && const sourceValue = source[key];
!Array.isArray(targetValue) &&
sourceValue !== null && // If both values are non-null objects and not arrays, merge recursively
typeof sourceValue === 'object' && if (
!Array.isArray(sourceValue) targetValue !== null &&
) { typeof targetValue === 'object' &&
result[key] = this.deepMerge(targetValue, sourceValue); !Array.isArray(targetValue) &&
} else { sourceValue !== null &&
// For primitive types, arrays, or null, use the value from source typeof sourceValue === 'object' &&
result[key] = sourceValue; !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; return result;
@@ -828,43 +827,43 @@ export class VirtualScroller {
// Update the item data using deep merge // Update the item data using deep merge
this.items[index] = this.deepMerge(this.items[index], updatedItem); this.items[index] = this.deepMerge(this.items[index], updatedItem);
// If the item is currently rendered, update its DOM representation // If the item is currently rendered, update its DOM representation
if (this.renderedItems.has(index)) { if (this.renderedItems.has(index)) {
const element = this.renderedItems.get(index); const element = this.renderedItems.get(index);
// Remove the old element // Remove the old element
element.remove(); element.remove();
this.renderedItems.delete(index); this.renderedItems.delete(index);
// Create and render the updated element // Create and render the updated element
const updatedElement = this.createItemElement(this.items[index], index); const updatedElement = this.createItemElement(this.items[index], index);
// Add update indicator visual effects // Add update indicator visual effects
updatedElement.classList.add('updated'); updatedElement.classList.add('updated');
// Add temporary update tag // Add temporary update tag
const updateIndicator = document.createElement('div'); const updateIndicator = document.createElement('div');
updateIndicator.className = 'update-indicator'; updateIndicator.className = 'update-indicator';
updateIndicator.textContent = 'Updated'; updateIndicator.textContent = 'Updated';
updatedElement.querySelector('.card-preview').appendChild(updateIndicator); updatedElement.querySelector('.card-preview').appendChild(updateIndicator);
// Automatically remove the updated class after animation completes // Automatically remove the updated class after animation completes
setTimeout(() => { setTimeout(() => {
updatedElement.classList.remove('updated'); updatedElement.classList.remove('updated');
}, 1500); }, 1500);
// Automatically remove the indicator after animation completes // Automatically remove the indicator after animation completes
setTimeout(() => { setTimeout(() => {
if (updateIndicator && updateIndicator.parentNode) { if (updateIndicator && updateIndicator.parentNode) {
updateIndicator.remove(); updateIndicator.remove();
} }
}, 2000); }, 2000);
this.renderedItems.set(index, updatedElement); this.renderedItems.set(index, updatedElement);
this.gridElement.appendChild(updatedElement); this.gridElement.appendChild(updatedElement);
} }
return true; return true;
} }
@@ -882,26 +881,26 @@ export class VirtualScroller {
// Remove the item from the data array // Remove the item from the data array
this.items.splice(index, 1); this.items.splice(index, 1);
// Decrement total count // Decrement total count
this.totalItems = Math.max(0, this.totalItems - 1); this.totalItems = Math.max(0, this.totalItems - 1);
// Remove the item from rendered items if it exists // Remove the item from rendered items if it exists
if (this.renderedItems.has(index)) { if (this.renderedItems.has(index)) {
this.renderedItems.get(index).remove(); this.renderedItems.get(index).remove();
this.renderedItems.delete(index); this.renderedItems.delete(index);
} }
// Shift all rendered items with higher indices down by 1 // Shift all rendered items with higher indices down by 1
const indicesToUpdate = []; const indicesToUpdate = [];
// Collect all indices that need to be updated // Collect all indices that need to be updated
for (const [idx, element] of this.renderedItems.entries()) { for (const [idx, element] of this.renderedItems.entries()) {
if (idx > index) { if (idx > index) {
indicesToUpdate.push(idx); indicesToUpdate.push(idx);
} }
} }
// Update the elements and map entries // Update the elements and map entries
for (const idx of indicesToUpdate) { for (const idx of indicesToUpdate) {
const element = this.renderedItems.get(idx); const element = this.renderedItems.get(idx);
@@ -909,14 +908,14 @@ export class VirtualScroller {
// The item is now at the previous index // The item is now at the previous index
this.renderedItems.set(idx - 1, element); this.renderedItems.set(idx - 1, element);
} }
// Update the spacer height to reflect the new total // Update the spacer height to reflect the new total
this.updateSpacerHeight(); this.updateSpacerHeight();
// Re-render to ensure proper layout // Re-render to ensure proper layout
this.clearRenderedItems(); this.clearRenderedItems();
this.scheduleRender(); this.scheduleRender();
console.log(`Removed item with file path ${filePath} from virtual scroller data`); console.log(`Removed item with file path ${filePath} from virtual scroller data`);
return true; return true;
} }
@@ -929,28 +928,28 @@ export class VirtualScroller {
return; // Ignore rapid repeated triggers return; // Ignore rapid repeated triggers
} }
this.lastPageNavTime = now; this.lastPageNavTime = now;
const scrollContainer = this.scrollContainer; const scrollContainer = this.scrollContainer;
const viewportHeight = scrollContainer.clientHeight; const viewportHeight = scrollContainer.clientHeight;
// Calculate scroll distance (one viewport minus 10% overlap for context) // Calculate scroll distance (one viewport minus 10% overlap for context)
const scrollDistance = viewportHeight * 0.9; const scrollDistance = viewportHeight * 0.9;
// Determine the new scroll position // Determine the new scroll position
const newScrollTop = scrollContainer.scrollTop + (direction === 'down' ? scrollDistance : -scrollDistance); const newScrollTop = scrollContainer.scrollTop + (direction === 'down' ? scrollDistance : -scrollDistance);
// Remove any existing transition indicators // Remove any existing transition indicators
this.removeExistingTransitionIndicator(); this.removeExistingTransitionIndicator();
// Scroll to the new position with smooth animation // Scroll to the new position with smooth animation
scrollContainer.scrollTo({ scrollContainer.scrollTo({
top: newScrollTop, top: newScrollTop,
behavior: 'smooth' behavior: 'smooth'
}); });
// Page transition indicator removed // Page transition indicator removed
// this.showTransitionIndicator(); // this.showTransitionIndicator();
// Force render after scrolling // Force render after scrolling
setTimeout(() => this.renderItems(), 100); setTimeout(() => this.renderItems(), 100);
setTimeout(() => this.renderItems(), 300); setTimeout(() => this.renderItems(), 300);
@@ -966,25 +965,25 @@ export class VirtualScroller {
scrollToTop() { scrollToTop() {
this.removeExistingTransitionIndicator(); this.removeExistingTransitionIndicator();
// Page transition indicator removed // Page transition indicator removed
// this.showTransitionIndicator(); // this.showTransitionIndicator();
this.scrollContainer.scrollTo({ this.scrollContainer.scrollTo({
top: 0, top: 0,
behavior: 'smooth' behavior: 'smooth'
}); });
// Force render after scrolling // Force render after scrolling
setTimeout(() => this.renderItems(), 100); setTimeout(() => this.renderItems(), 100);
} }
scrollToBottom() { scrollToBottom() {
this.removeExistingTransitionIndicator(); this.removeExistingTransitionIndicator();
// Page transition indicator removed // Page transition indicator removed
// this.showTransitionIndicator(); // this.showTransitionIndicator();
// Start loading all remaining pages to ensure content is available // Start loading all remaining pages to ensure content is available
this.loadRemainingPages().then(() => { this.loadRemainingPages().then(() => {
// After loading all content, scroll to the very bottom // After loading all content, scroll to the very bottom
@@ -995,27 +994,27 @@ export class VirtualScroller {
}); });
}); });
} }
// New method to load all remaining pages // New method to load all remaining pages
async loadRemainingPages() { async loadRemainingPages() {
// If we're already at the end or loading, don't proceed // If we're already at the end or loading, don't proceed
if (!this.hasMore || this.isLoading) return; if (!this.hasMore || this.isLoading) return;
console.log('Loading all remaining pages for End key navigation...'); console.log('Loading all remaining pages for End key navigation...');
// Keep loading pages until we reach the end // Keep loading pages until we reach the end
while (this.hasMore && !this.isLoading) { while (this.hasMore && !this.isLoading) {
await this.loadMoreItems(); await this.loadMoreItems();
// Force render after each page load // Force render after each page load
this.renderItems(); this.renderItems();
// Small delay to prevent overwhelming the browser // Small delay to prevent overwhelming the browser
await new Promise(resolve => setTimeout(resolve, 50)); await new Promise(resolve => setTimeout(resolve, 50));
} }
console.log('Finished loading all pages'); console.log('Finished loading all pages');
// Final render to ensure all content is displayed // Final render to ensure all content is displayed
this.renderItems(); this.renderItems();
} }

View File

@@ -8,52 +8,60 @@
</div> </div>
{% set current_path = request.path %} {% set current_path = request.path %}
{% if current_path.startswith('/loras/recipes') %} {% if current_path.startswith('/loras/recipes') %}
{% set current_page = 'recipes' %} {% set current_page = 'recipes' %}
{% elif current_path.startswith('/checkpoints') %} {% elif current_path.startswith('/checkpoints') %}
{% set current_page = 'checkpoints' %} {% set current_page = 'checkpoints' %}
{% elif current_path.startswith('/embeddings') %} {% elif current_path.startswith('/embeddings') %}
{% set current_page = 'embeddings' %} {% set current_page = 'embeddings' %}
{% elif current_path.startswith('/statistics') %} {% elif current_path.startswith('/statistics') %}
{% set current_page = 'statistics' %} {% set current_page = 'statistics' %}
{% else %} {% else %}
{% set current_page = 'loras' %} {% set current_page = 'loras' %}
{% endif %} {% endif %}
{% set search_disabled = current_page == 'statistics' %} {% set search_disabled = current_page == 'statistics' %}
{% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~ current_page %} {% set search_placeholder_key = 'header.search.notAvailable' if search_disabled else 'header.search.placeholders.' ~
current_page %}
{% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %} {% set header_search_class = 'header-search disabled' if search_disabled else 'header-search' %}
<nav class="main-nav"> <nav class="main-nav">
<a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem"> <a href="/loras" class="nav-item{% if current_path == '/loras' %} active{% endif %}" id="lorasNavItem">
<i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span> <i class="fas fa-layer-group"></i> <span>{{ t('header.navigation.loras') }}</span>
</a> </a>
<a href="/loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}" id="recipesNavItem"> <a href="/loras/recipes" class="nav-item{% if current_path.startswith('/loras/recipes') %} active{% endif %}"
id="recipesNavItem">
<i class="fas fa-book-open"></i> <span>{{ t('header.navigation.recipes') }}</span> <i class="fas fa-book-open"></i> <span>{{ t('header.navigation.recipes') }}</span>
</a> </a>
<a href="/checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}" id="checkpointsNavItem"> <a href="/checkpoints" class="nav-item{% if current_path.startswith('/checkpoints') %} active{% endif %}"
id="checkpointsNavItem">
<i class="fas fa-check-circle"></i> <span>{{ t('header.navigation.checkpoints') }}</span> <i class="fas fa-check-circle"></i> <span>{{ t('header.navigation.checkpoints') }}</span>
</a> </a>
<a href="/embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}" id="embeddingsNavItem"> <a href="/embeddings" class="nav-item{% if current_path.startswith('/embeddings') %} active{% endif %}"
id="embeddingsNavItem">
<i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span> <i class="fas fa-code"></i> <span>{{ t('header.navigation.embeddings') }}</span>
</a> </a>
<a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}" id="statisticsNavItem"> <a href="/statistics" class="nav-item{% if current_path.startswith('/statistics') %} active{% endif %}"
id="statisticsNavItem">
<i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span> <i class="fas fa-chart-bar"></i> <span>{{ t('header.navigation.statistics') }}</span>
</a> </a>
</nav> </nav>
<!-- Context-aware search container --> <!-- Context-aware search container -->
<div class="{{ header_search_class }}" id="headerSearch"> <div class="{{ header_search_class }}" id="headerSearch">
<div class="search-container"> <div class="search-container">
<input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}"{% if search_disabled %} disabled{% endif %} /> <input type="text" id="searchInput" placeholder="{{ t(search_placeholder_key) }}" {% if search_disabled %}
disabled{% endif %} />
<i class="fas fa-search search-icon"></i> <i class="fas fa-search search-icon"></i>
<button class="search-options-toggle" id="searchOptionsToggle" title="{{ t('header.search.options') }}"{% if search_disabled %} disabled aria-disabled="true"{% endif %}> <button class="search-options-toggle" id="searchOptionsToggle" title="{{ t('header.search.options') }}" {% if
search_disabled %} disabled aria-disabled="true" {% endif %}>
<i class="fas fa-sliders-h"></i> <i class="fas fa-sliders-h"></i>
</button> </button>
<button class="search-filter-toggle" id="filterButton" title="{{ t('header.filter.title') }}"{% if search_disabled %} disabled aria-disabled="true"{% endif %}> <button class="search-filter-toggle" id="filterButton" title="{{ t('header.filter.title') }}" {% if
search_disabled %} disabled aria-disabled="true" {% endif %}>
<i class="fas fa-filter"></i> <i class="fas fa-filter"></i>
<span class="filter-badge" id="activeFiltersCount" style="display: none">0</span> <span class="filter-badge" id="activeFiltersCount" style="display: none">0</span>
</button> </button>
</div> </div>
</div> </div>
<div class="header-actions"> <div class="header-actions">
<!-- Integrated corner controls --> <!-- Integrated corner controls -->
<div class="header-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="tags">{{ t('header.search.filters.tags') }}</div>
<div class="search-option-tag active" data-option="loraName">{{ t('header.search.filters.loraName') }}</div> <div class="search-option-tag active" data-option="loraName">{{ t('header.search.filters.loraName') }}</div>
<div class="search-option-tag active" data-option="loraModel">{{ t('header.search.filters.loraModel') }}</div> <div class="search-option-tag active" data-option="loraModel">{{ t('header.search.filters.loraModel') }}</div>
<div class="search-option-tag active" data-option="prompt">{{ t('header.search.filters.prompt') }}</div>
{% elif request.path == '/checkpoints' %} {% elif request.path == '/checkpoints' %}
<div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div> <div class="search-option-tag active" data-option="filename">{{ t('header.search.filters.filename') }}</div>
<div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div> <div class="search-option-tag active" data-option="modelname">{{ t('header.search.filters.modelname') }}</div>
@@ -165,4 +174,4 @@
{{ t('header.filter.clearAll') }} {{ t('header.filter.clearAll') }}
</button> </button>
</div> </div>
</div> </div>

View File

@@ -15,17 +15,26 @@
<div id="recipeContextMenu" class="context-menu" style="display: none;"> <div id="recipeContextMenu" class="context-menu" style="display: none;">
<!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> --> <!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> -->
<div class="context-menu-item" data-action="share"><i class="fas fa-share-alt"></i> {{ t('loras.contextMenu.shareRecipe') }}</div> <div class="context-menu-item" data-action="share"><i class="fas fa-share-alt"></i> {{
<div class="context-menu-item" data-action="copy"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyRecipeSyntax') }}</div> t('loras.contextMenu.shareRecipe') }}</div>
<div class="context-menu-item" data-action="sendappend"><i class="fas fa-paper-plane"></i> {{ t('loras.contextMenu.sendToWorkflowAppend') }}</div> <div class="context-menu-item" data-action="copy"><i class="fas fa-copy"></i> {{
<div class="context-menu-item" data-action="sendreplace"><i class="fas fa-exchange-alt"></i> {{ t('loras.contextMenu.sendToWorkflowReplace') }}</div> t('loras.contextMenu.copyRecipeSyntax') }}</div>
<div class="context-menu-item" data-action="viewloras"><i class="fas fa-layer-group"></i> {{ t('loras.contextMenu.viewAllLoras') }}</div> <div class="context-menu-item" data-action="sendappend"><i class="fas fa-paper-plane"></i> {{
<div class="context-menu-item download-missing-item" data-action="download-missing"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadMissingLoras') }}</div> t('loras.contextMenu.sendToWorkflowAppend') }}</div>
<div class="context-menu-item" data-action="sendreplace"><i class="fas fa-exchange-alt"></i> {{
t('loras.contextMenu.sendToWorkflowReplace') }}</div>
<div class="context-menu-item" data-action="viewloras"><i class="fas fa-layer-group"></i> {{
t('loras.contextMenu.viewAllLoras') }}</div>
<div class="context-menu-item download-missing-item" data-action="download-missing"><i class="fas fa-download"></i>
{{ t('loras.contextMenu.downloadMissingLoras') }}</div>
<div class="context-menu-item" data-action="set-nsfw"> <div class="context-menu-item" data-action="set-nsfw">
<i class="fas fa-exclamation-triangle"></i> {{ t('loras.contextMenu.setContentRating') }} <i class="fas fa-exclamation-triangle"></i> {{ t('loras.contextMenu.setContentRating') }}
</div> </div>
<div class="context-menu-item" data-action="move"><i class="fas fa-folder-open"></i> {{
t('loras.contextMenu.moveToFolder') }}</div>
<div class="context-menu-separator"></div> <div class="context-menu-separator"></div>
<div class="context-menu-item delete-item" data-action="delete"><i class="fas fa-trash"></i> {{ t('loras.contextMenu.deleteRecipe') }}</div> <div class="context-menu-item delete-item" data-action="delete"><i class="fas fa-trash"></i> {{
t('loras.contextMenu.deleteRecipe') }}</div>
</div> </div>
{% endblock %} {% endblock %}
@@ -34,53 +43,92 @@
{% block init_check_url %}/api/recipes?page=1&page_size=1{% endblock %} {% block init_check_url %}/api/recipes?page=1&page_size=1{% endblock %}
{% block content %} {% block content %}
<!-- Recipe controls --> <!-- Recipe controls -->
<div class="controls"> <div class="controls">
<div class="action-buttons"> <div class="action-buttons">
<div title="{{ t('recipes.controls.refresh.title') }}" class="control-group"> <div class="control-group">
<button onclick="recipeManager.refreshRecipes()"><i class="fas fa-sync"></i> {{ t('common.actions.refresh') }}</button> <select id="sortSelect" title="{{ t('recipes.controls.sort.title') }}">
</div> <optgroup label="{{ t('recipes.controls.sort.name') }}">
<div title="{{ t('recipes.controls.import.title') }}" class="control-group"> <option value="name:asc">{{ t('recipes.controls.sort.nameAsc') }}</option>
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{ t('recipes.controls.import.action') }}</button> <option value="name:desc">{{ t('recipes.controls.sort.nameDesc') }}</option>
</div> </optgroup>
<!-- Add duplicate detection button --> <optgroup label="{{ t('recipes.controls.sort.date') }}">
<div title="{{ t('loras.controls.duplicates.title') }}" class="control-group"> <option value="date:desc">{{ t('recipes.controls.sort.dateDesc') }}</option>
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> {{ t('loras.controls.duplicates.action') }}</button> <option value="date:asc">{{ t('recipes.controls.sort.dateAsc') }}</option>
</div> </optgroup>
<!-- Custom filter indicator button (hidden by default) --> <optgroup label="{{ t('recipes.controls.sort.lorasCount') }}">
<div id="customFilterIndicator" class="control-group hidden"> <option value="loras_count:desc">{{ t('recipes.controls.sort.lorasCountDesc') }}</option>
<div class="filter-active"> <option value="loras_count:asc">{{ t('recipes.controls.sort.lorasCountAsc') }}</option>
<i class="fas fa-filter"></i> <span id="customFilterText">{{ t('recipes.controls.filteredByLora') }}</span> </optgroup>
<i class="fas fa-times-circle clear-filter"></i> </select>
</div> </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>
</div> </div>
</div>
<!-- Duplicates banner (hidden by default) --> <!-- Duplicates banner (hidden by default) -->
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;"> <div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
<div class="banner-content"> <div class="banner-content">
<i class="fas fa-exclamation-triangle"></i> <i class="fas fa-exclamation-triangle"></i>
<span id="duplicatesCount">{{ t('recipes.duplicates.found', count=0) }}</span> <span id="duplicatesCount">{{ t('recipes.duplicates.found', count=0) }}</span>
<div class="banner-actions"> <div class="banner-actions">
<button class="btn-select-latest" onclick="recipeManager.selectLatestDuplicates()"> <button class="btn-select-latest" onclick="recipeManager.selectLatestDuplicates()">
{{ t('recipes.duplicates.keepLatest') }} {{ t('recipes.duplicates.keepLatest') }}
</button> </button>
<button class="btn-delete-selected disabled" onclick="recipeManager.deleteSelectedDuplicates()"> <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>
<button class="btn-exit" onclick="recipeManager.exitDuplicateMode()"> <button class="btn-exit" onclick="recipeManager.exitDuplicateMode()">
<i class="fas fa-times"></i> <i class="fas fa-times"></i>
</button> </button>
</div>
</div> </div>
</div> </div>
</div>
<!-- Recipe grid -->
<div class="card-grid" id="recipeGrid"> {% include 'components/folder_sidebar.html' %}
<!-- Remove the server-side conditional rendering and placeholder -->
<!-- Virtual scrolling will handle the display logic on the client side --> <!-- Recipe grid -->
</div> <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 %} {% endblock %}
{% block main_script %} {% block main_script %}

View File

@@ -0,0 +1,114 @@
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
const showToastMock = vi.hoisted(() => vi.fn());
const loadingManagerMock = vi.hoisted(() => ({
showSimpleLoading: vi.fn(),
hide: vi.fn(),
}));
vi.mock('../../../static/js/utils/uiHelpers.js', () => {
return {
showToast: showToastMock,
};
});
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
RecipeCard: vi.fn(() => ({ element: document.createElement('div') })),
}));
vi.mock('../../../static/js/state/index.js', () => {
return {
state: {
loadingManager: loadingManagerMock,
},
getCurrentPageState: vi.fn(),
};
});
import { RecipeSidebarApiClient } from '../../../static/js/api/recipeApi.js';
describe('RecipeSidebarApiClient bulk operations', () => {
beforeEach(() => {
vi.clearAllMocks();
global.fetch = vi.fn();
});
afterEach(() => {
delete global.fetch;
});
it('sends recipe IDs when moving in bulk', async () => {
const api = new RecipeSidebarApiClient();
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({
success: true,
results: [
{
recipe_id: 'abc',
original_file_path: '/recipes/abc.webp',
new_file_path: '/recipes/target/abc.webp',
success: true,
},
],
success_count: 1,
failure_count: 0,
}),
});
const results = await api.moveBulkModels(['/recipes/abc.webp'], '/target/folder');
expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/recipes/move-bulk',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
);
const { body } = global.fetch.mock.calls[0][1];
expect(JSON.parse(body)).toEqual({
recipe_ids: ['abc'],
target_path: '/target/folder',
});
expect(showToastMock).toHaveBeenCalledWith(
'toast.api.bulkMoveSuccess',
{ successCount: 1, type: 'Recipe' },
'success'
);
expect(results[0].recipe_id).toBe('abc');
});
it('posts recipe IDs for bulk delete', async () => {
const api = new RecipeSidebarApiClient();
global.fetch.mockResolvedValue({
ok: true,
json: async () => ({
success: true,
total_deleted: 2,
total_failed: 0,
failed: [],
}),
});
const result = await api.bulkDeleteModels(['/recipes/a.webp', '/recipes/b.webp']);
expect(global.fetch).toHaveBeenCalledWith(
'/api/lm/recipes/bulk-delete',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
})
);
const parsedBody = JSON.parse(global.fetch.mock.calls[0][1].body);
expect(parsedBody.recipe_ids).toEqual(['a', 'b']);
expect(result).toMatchObject({
success: true,
deleted_count: 2,
failed_count: 0,
});
expect(loadingManagerMock.hide).toHaveBeenCalled();
});
});

View File

@@ -252,13 +252,13 @@ describe('AppCore initialization flow', () => {
expect(onboardingManager.start).not.toHaveBeenCalled(); expect(onboardingManager.start).not.toHaveBeenCalled();
}); });
it('skips bulk setup when viewing recipes', async () => { it('initializes bulk setup when viewing recipes', async () => {
state.currentPageType = 'recipes'; state.currentPageType = 'recipes';
await appCore.initialize(); await appCore.initialize();
expect(bulkManager.initialize).not.toHaveBeenCalled(); expect(bulkManager.initialize).toHaveBeenCalledTimes(1);
expect(BulkContextMenu).not.toHaveBeenCalled(); expect(BulkContextMenu).toHaveBeenCalledTimes(1);
expect(bulkManager.setBulkContextMenu).not.toHaveBeenCalled(); expect(bulkManager.setBulkContextMenu).toHaveBeenCalledTimes(1);
}); });
}); });

View File

@@ -9,6 +9,8 @@ const removeSessionItemMock = vi.fn();
const RecipeContextMenuMock = vi.fn(); const RecipeContextMenuMock = vi.fn();
const refreshVirtualScrollMock = vi.fn(); const refreshVirtualScrollMock = vi.fn();
const refreshRecipesMock = vi.fn(); const refreshRecipesMock = vi.fn();
const fetchUnifiedFolderTreeMock = vi.fn();
const fetchModelFoldersMock = vi.fn();
let importManagerInstance; let importManagerInstance;
let recipeModalInstance; let recipeModalInstance;
@@ -35,6 +37,15 @@ vi.mock('../../../static/js/components/RecipeModal.js', () => ({
vi.mock('../../../static/js/state/index.js', () => ({ vi.mock('../../../static/js/state/index.js', () => ({
getCurrentPageState: getCurrentPageStateMock, getCurrentPageState: getCurrentPageStateMock,
state: {
currentPageType: 'recipes',
global: { settings: {} },
virtualScroller: {
removeItemByFilePath: vi.fn(),
updateSingleItem: vi.fn(),
refreshWithData: vi.fn(),
},
},
})); }));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({ vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
@@ -56,6 +67,14 @@ vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
vi.mock('../../../static/js/api/recipeApi.js', () => ({ vi.mock('../../../static/js/api/recipeApi.js', () => ({
refreshRecipes: refreshRecipesMock, refreshRecipes: refreshRecipesMock,
RecipeSidebarApiClient: vi.fn(() => ({
apiConfig: { config: { displayName: 'Recipes', supportsMove: true } },
fetchUnifiedFolderTree: fetchUnifiedFolderTreeMock.mockResolvedValue({ success: true, tree: {} }),
fetchModelFolders: fetchModelFoldersMock.mockResolvedValue({ success: true, folders: [] }),
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/recipes'] }),
moveBulkModels: vi.fn(),
moveSingleModel: vi.fn(),
})),
})); }));
describe('RecipeManager', () => { describe('RecipeManager', () => {
@@ -81,7 +100,7 @@ describe('RecipeManager', () => {
}; };
pageState = { pageState = {
sortBy: 'date', sortBy: 'date:desc',
searchOptions: undefined, searchOptions: undefined,
customFilter: undefined, customFilter: undefined,
duplicatesMode: false, duplicatesMode: false,
@@ -91,7 +110,7 @@ describe('RecipeManager', () => {
initializeAppMock.mockResolvedValue(undefined); initializeAppMock.mockResolvedValue(undefined);
initializePageFeaturesMock.mockResolvedValue(undefined); initializePageFeaturesMock.mockResolvedValue(undefined);
refreshVirtualScrollMock.mockReset(); refreshVirtualScrollMock.mockReset();
refreshVirtualScrollMock.mockImplementation(() => {}); refreshVirtualScrollMock.mockImplementation(() => { });
refreshRecipesMock.mockResolvedValue('refreshed'); refreshRecipesMock.mockResolvedValue('refreshed');
getSessionItemMock.mockImplementation((key) => { getSessionItemMock.mockImplementation((key) => {
@@ -102,7 +121,7 @@ describe('RecipeManager', () => {
}; };
return map[key] ?? null; return map[key] ?? null;
}); });
removeSessionItemMock.mockImplementation(() => {}); removeSessionItemMock.mockImplementation(() => { });
renderRecipesPage(); renderRecipesPage();
@@ -118,8 +137,8 @@ describe('RecipeManager', () => {
const sortSelectElement = document.createElement('select'); const sortSelectElement = document.createElement('select');
sortSelectElement.id = 'sortSelect'; sortSelectElement.id = 'sortSelect';
sortSelectElement.innerHTML = ` sortSelectElement.innerHTML = `
<option value="date">Date</option> <option value="date:desc">Newest</option>
<option value="name">Name</option> <option value="name:asc">Name A-Z</option>
`; `;
document.body.appendChild(sortSelectElement); document.body.appendChild(sortSelectElement);
@@ -139,6 +158,8 @@ describe('RecipeManager', () => {
tags: true, tags: true,
loraName: true, loraName: true,
loraModel: true, loraModel: true,
prompt: true,
recursive: true,
}); });
expect(pageState.customFilter).toEqual({ expect(pageState.customFilter).toEqual({
@@ -162,10 +183,10 @@ describe('RecipeManager', () => {
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1); expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(1);
const sortSelect = document.getElementById('sortSelect'); const sortSelect = document.getElementById('sortSelect');
sortSelect.value = 'name'; sortSelect.value = 'name:asc';
sortSelect.dispatchEvent(new Event('change', { bubbles: true })); sortSelect.dispatchEvent(new Event('change', { bubbles: true }));
expect(pageState.sortBy).toBe('name'); expect(pageState.sortBy).toBe('name:asc');
expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(2); expect(refreshVirtualScrollMock).toHaveBeenCalledTimes(2);
expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1); expect(initializePageFeaturesMock).toHaveBeenCalledTimes(1);
}); });

View File

@@ -69,6 +69,10 @@ class StubRecipeScanner:
async def get_recipe_by_id(self, recipe_id: str) -> Optional[Dict[str, Any]]: async def get_recipe_by_id(self, recipe_id: str) -> Optional[Dict[str, Any]]:
return self.recipes.get(recipe_id) return self.recipes.get(recipe_id)
async def get_recipe_json_path(self, recipe_id: str) -> Optional[str]:
candidate = Path(self.recipes_dir) / f"{recipe_id}.recipe.json"
return str(candidate) if candidate.exists() else None
async def remove_recipe(self, recipe_id: str) -> None: async def remove_recipe(self, recipe_id: str) -> None:
self.removed.append(recipe_id) self.removed.append(recipe_id)
self.recipes.pop(recipe_id, None) self.recipes.pop(recipe_id, None)
@@ -87,6 +91,7 @@ class StubAnalysisService:
self.remote_calls: List[Optional[str]] = [] self.remote_calls: List[Optional[str]] = []
self.local_calls: List[Optional[str]] = [] self.local_calls: List[Optional[str]] = []
self.result = SimpleNamespace(payload={"loras": []}, status=200) self.result = SimpleNamespace(payload={"loras": []}, status=200)
self._recipe_parser_factory = None
StubAnalysisService.instances.append(self) StubAnalysisService.instances.append(self)
async def analyze_uploaded_image(self, *, image_bytes: bytes | None, recipe_scanner) -> SimpleNamespace: # noqa: D401 - mirrors real signature async def analyze_uploaded_image(self, *, image_bytes: bytes | None, recipe_scanner) -> SimpleNamespace: # noqa: D401 - mirrors real signature
@@ -119,6 +124,7 @@ class StubPersistenceService:
def __init__(self, **_: Any) -> None: def __init__(self, **_: Any) -> None:
self.save_calls: List[Dict[str, Any]] = [] self.save_calls: List[Dict[str, Any]] = []
self.delete_calls: List[str] = [] self.delete_calls: List[str] = []
self.move_calls: List[Dict[str, str]] = []
self.save_result = SimpleNamespace(payload={"success": True, "recipe_id": "stub-id"}, status=200) self.save_result = SimpleNamespace(payload={"success": True, "recipe_id": "stub-id"}, status=200)
self.delete_result = SimpleNamespace(payload={"success": True}, status=200) self.delete_result = SimpleNamespace(payload={"success": True}, status=200)
StubPersistenceService.instances.append(self) StubPersistenceService.instances.append(self)
@@ -142,6 +148,12 @@ class StubPersistenceService:
await recipe_scanner.remove_recipe(recipe_id) await recipe_scanner.remove_recipe(recipe_id)
return self.delete_result return self.delete_result
async def move_recipe(self, *, recipe_scanner, recipe_id: str, target_path: str) -> SimpleNamespace: # noqa: D401
self.move_calls.append({"recipe_id": recipe_id, "target_path": target_path})
return SimpleNamespace(
payload={"success": True, "recipe_id": recipe_id, "new_file_path": target_path}, status=200
)
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: Dict[str, Any]) -> SimpleNamespace: # pragma: no cover - unused by smoke tests async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: Dict[str, Any]) -> SimpleNamespace: # pragma: no cover - unused by smoke tests
return SimpleNamespace(payload={"success": True, "recipe_id": recipe_id, "updates": updates}, status=200) return SimpleNamespace(payload={"success": True, "recipe_id": recipe_id, "updates": updates}, status=200)
@@ -310,6 +322,21 @@ async def test_save_and_delete_recipe_round_trip(monkeypatch, tmp_path: Path) ->
assert harness.persistence.delete_calls == ["saved-id"] assert harness.persistence.delete_calls == ["saved-id"]
async def test_move_recipe_invokes_persistence(monkeypatch, tmp_path: Path) -> None:
async with recipe_harness(monkeypatch, tmp_path) as harness:
response = await harness.client.post(
"/api/lm/recipe/move",
json={"recipe_id": "move-me", "target_path": str(tmp_path / "recipes" / "subdir")},
)
payload = await response.json()
assert response.status == 200
assert payload["recipe_id"] == "move-me"
assert harness.persistence.move_calls == [
{"recipe_id": "move-me", "target_path": str(tmp_path / "recipes" / "subdir")}
]
async def test_import_remote_recipe(monkeypatch, tmp_path: Path) -> None: async def test_import_remote_recipe(monkeypatch, tmp_path: Path) -> None:
provider_calls: list[int] = [] provider_calls: list[int] = []
@@ -501,3 +528,69 @@ async def test_share_and_download_recipe(monkeypatch, tmp_path: Path) -> None:
assert body == b"stub" assert body == b"stub"
download_path.unlink(missing_ok=True) download_path.unlink(missing_ok=True)
async def test_import_remote_recipe_merges_metadata(monkeypatch, tmp_path: Path) -> None:
# 1. Mock Metadata Provider
class Provider:
async def get_model_version_info(self, model_version_id):
return {"baseModel": "Flux Provider"}, None
async def fake_get_default_metadata_provider():
return Provider()
monkeypatch.setattr(recipe_handlers, "get_default_metadata_provider", fake_get_default_metadata_provider)
# 2. Mock ExifUtils to return some embedded metadata
class MockExifUtils:
@staticmethod
def extract_image_metadata(path):
return "Recipe metadata: " + json.dumps({
"gen_params": {"prompt": "from embedded", "seed": 123}
})
monkeypatch.setattr(recipe_handlers, "ExifUtils", MockExifUtils)
# 3. Mock Parser Factory for StubAnalysisService
class MockParser:
async def parse_metadata(self, raw, recipe_scanner=None):
return json.loads(raw[len("Recipe metadata: "):])
class MockFactory:
def create_parser(self, raw):
if raw.startswith("Recipe metadata: "):
return MockParser()
return None
# 4. Setup Harness and run test
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.analysis._recipe_parser_factory = MockFactory()
# Civitai meta via image_info
harness.civitai.image_info["1"] = {
"id": 1,
"url": "https://example.com/images/1.jpg",
"meta": {"prompt": "from civitai", "cfg": 7.0}
}
resources = []
response = await harness.client.get(
"/api/lm/recipes/import-remote",
params={
"image_url": "https://civitai.com/images/1",
"name": "Merged Recipe",
"resources": json.dumps(resources),
"gen_params": json.dumps({"prompt": "from request", "steps": 25}),
},
)
payload = await response.json()
assert response.status == 200
call = harness.persistence.save_calls[-1]
metadata = call["metadata"]
gen_params = metadata["gen_params"]
# Priority: request (prompt=request, steps=25) > civitai (prompt=civitai, cfg=7.0) > embedded (prompt=embedded, seed=123)
assert gen_params["prompt"] == "from request"
assert gen_params["steps"] == 25
assert gen_params["cfg"] == 7.0
assert gen_params["seed"] == 123

View File

@@ -0,0 +1,113 @@
import pytest
import json
from py.recipes.parsers.comfy import ComfyMetadataParser
@pytest.mark.asyncio
async def test_parse_metadata_without_loras(monkeypatch):
checkpoint_info = {
"id": 2224012,
"modelId": 1908679,
"model": {"name": "SDXL Checkpoint", "type": "checkpoint"},
"name": "v1.0",
"images": [{"url": "https://image.civitai.com/checkpoints/original=true"}],
"baseModel": "sdxl",
"downloadUrl": "https://civitai.com/api/download/checkpoint",
}
async def fake_metadata_provider():
class Provider:
async def get_model_version_info(self, version_id):
assert version_id == "2224012"
return checkpoint_info, None
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.comfy.get_default_metadata_provider",
fake_metadata_provider,
)
parser = ComfyMetadataParser()
# User provided metadata
metadata_json = {
"resource-stack": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "urn:air:sdxl:checkpoint:civitai:1908679@2224012"}
},
"6": {
"class_type": "smZ CLIPTextEncode",
"inputs": {"text": "Positive prompt content"},
"_meta": {"title": "Positive"}
},
"7": {
"class_type": "smZ CLIPTextEncode",
"inputs": {"text": "Negative prompt content"},
"_meta": {"title": "Negative"}
},
"11": {
"class_type": "KSampler",
"inputs": {
"sampler_name": "euler_ancestral",
"scheduler": "normal",
"seed": 904124997,
"steps": 35,
"cfg": 6,
"denoise": 0.1,
"model": ["resource-stack", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["21", 0]
},
"_meta": {"title": "KSampler"}
},
"extraMetadata": json.dumps({
"prompt": "One woman, (solo:1.3), ...",
"negativePrompt": "lowres, worst quality, ...",
"steps": 35,
"cfgScale": 6,
"sampler": "euler_ancestral",
"seed": 904124997,
"width": 1024,
"height": 1024
})
}
result = await parser.parse_metadata(json.dumps(metadata_json))
assert "error" not in result
assert result["loras"] == []
assert result["checkpoint"] is not None
assert int(result["checkpoint"]["modelId"]) == 1908679
assert int(result["checkpoint"]["id"]) == 2224012
assert result["gen_params"]["prompt"] == "One woman, (solo:1.3), ..."
assert result["gen_params"]["steps"] == 35
assert result["gen_params"]["size"] == "1024x1024"
assert result["from_comfy_metadata"] is True
@pytest.mark.asyncio
async def test_parse_metadata_without_extra_metadata(monkeypatch):
async def fake_metadata_provider():
class Provider:
async def get_model_version_info(self, version_id):
return {"model": {"name": "Test"}, "id": version_id}, None
return Provider()
monkeypatch.setattr(
"py.recipes.parsers.comfy.get_default_metadata_provider",
fake_metadata_provider,
)
parser = ComfyMetadataParser()
metadata_json = {
"node_1": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "urn:air:sdxl:checkpoint:civitai:123@456"}
}
}
result = await parser.parse_metadata(json.dumps(metadata_json))
assert "error" not in result
assert result["loras"] == []
assert result["checkpoint"]["id"] == "456"

View File

@@ -0,0 +1,59 @@
import pytest
from py.recipes.merger import GenParamsMerger
def test_merge_priority():
request_params = {"prompt": "from request", "steps": 20}
civitai_meta = {"prompt": "from civitai", "cfg": 7.0}
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
assert merged["prompt"] == "from request"
assert merged["steps"] == 20
assert merged["cfg"] == 7.0
assert merged["seed"] == 123
def test_merge_no_request_params():
civitai_meta = {"prompt": "from civitai", "cfg": 7.0}
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
merged = GenParamsMerger.merge(None, civitai_meta, embedded_metadata)
assert merged["prompt"] == "from civitai"
assert merged["cfg"] == 7.0
assert merged["seed"] == 123
def test_merge_only_embedded():
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
merged = GenParamsMerger.merge(None, None, embedded_metadata)
assert merged["prompt"] == "from embedded"
assert merged["seed"] == 123
def test_merge_raw_embedded():
# Test when embedded metadata is just the gen_params themselves
embedded_metadata = {"prompt": "from raw embedded", "seed": 456}
merged = GenParamsMerger.merge(None, None, embedded_metadata)
assert merged["prompt"] == "from raw embedded"
assert merged["seed"] == 456
def test_merge_none_values():
merged = GenParamsMerger.merge(None, None, None)
assert merged == {}
def test_merge_filters_blacklisted_keys():
request_params = {"prompt": "test", "id": "should-be-removed"}
civitai_meta = {"cfg": 7, "url": "remove-me"}
embedded_metadata = {"seed": 123, "hash": "remove-also"}
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
assert "prompt" in merged
assert "cfg" in merged
assert "seed" in merged
assert "id" not in merged
assert "url" not in merged
assert "hash" not in merged

View File

@@ -502,7 +502,142 @@ async def test_update_lora_filename_by_hash_updates_affected_recipes(tmp_path: P
persisted2 = json.loads(recipe2_path.read_text()) persisted2 = json.loads(recipe2_path.read_text())
assert persisted2["loras"][0]["file_name"] == "other_lora" assert persisted2["loras"][0]["file_name"] == "other_lora"
# Check cache
cache = await scanner.get_cached_data() cache = await scanner.get_cached_data()
cached1 = next(r for r in cache.raw_data if r["id"] == recipe1_id) cached1 = next(r for r in cache.raw_data if r["id"] == recipe1_id)
assert cached1["loras"][0]["file_name"] == new_name assert cached1["loras"][0]["file_name"] == new_name
@pytest.mark.asyncio
async def test_get_paginated_data_filters_by_favorite(recipe_scanner):
scanner, _ = recipe_scanner
# Add a normal recipe
await scanner.add_recipe({
"id": "regular",
"file_path": "path/regular.png",
"title": "Regular Recipe",
"modified": 1.0,
"created_date": 1.0,
"loras": [],
})
# Add a favorite recipe
await scanner.add_recipe({
"id": "favorite",
"file_path": "path/favorite.png",
"title": "Favorite Recipe",
"modified": 2.0,
"created_date": 2.0,
"loras": [],
"favorite": True
})
# Wait for cache update (it's async in some places, add_recipe is usually enough but let's be safe)
await asyncio.sleep(0)
# Test without filter (should return both)
result_all = await scanner.get_paginated_data(page=1, page_size=10)
assert len(result_all["items"]) == 2
# Test with favorite filter
result_fav = await scanner.get_paginated_data(page=1, page_size=10, filters={"favorite": True})
assert len(result_fav["items"]) == 1
assert result_fav["items"][0]["id"] == "favorite"
# Test with favorite filter set to False (should return both or at least not filter if it's the default)
# Actually our implementation checks if 'favorite' in filters and filters['favorite']
result_fav_false = await scanner.get_paginated_data(page=1, page_size=10, filters={"favorite": False})
assert len(result_fav_false["items"]) == 2
@pytest.mark.asyncio
async def test_get_paginated_data_filters_by_prompt(recipe_scanner):
scanner, _ = recipe_scanner
# Add a recipe with a specific prompt
await scanner.add_recipe({
"id": "prompt-recipe",
"file_path": "path/prompt.png",
"title": "Prompt Recipe",
"modified": 1.0,
"created_date": 1.0,
"loras": [],
"gen_params": {
"prompt": "a beautiful forest landscape"
}
})
# Add a recipe with a specific negative prompt
await scanner.add_recipe({
"id": "neg-prompt-recipe",
"file_path": "path/neg.png",
"title": "Negative Prompt Recipe",
"modified": 2.0,
"created_date": 2.0,
"loras": [],
"gen_params": {
"negative_prompt": "ugly, blurry mountains"
}
})
await asyncio.sleep(0)
# Test search in prompt
result_prompt = await scanner.get_paginated_data(
page=1, page_size=10, search="forest", search_options={"prompt": True}
)
assert len(result_prompt["items"]) == 1
assert result_prompt["items"][0]["id"] == "prompt-recipe"
# Test search in negative prompt
result_neg = await scanner.get_paginated_data(
page=1, page_size=10, search="mountains", search_options={"prompt": True}
)
assert len(result_neg["items"]) == 1
assert result_neg["items"][0]["id"] == "neg-prompt-recipe"
# Test search disabled (should not find by prompt)
result_disabled = await scanner.get_paginated_data(
page=1, page_size=10, search="forest", search_options={"prompt": False}
)
assert len(result_disabled["items"]) == 0
@pytest.mark.asyncio
async def test_get_paginated_data_sorting(recipe_scanner):
scanner, _ = recipe_scanner
# Add test recipes
# Recipe A: Name "Alpha", Date 10, LoRAs 2
await scanner.add_recipe({
"id": "A", "title": "Alpha", "created_date": 10.0,
"loras": [{}, {}], "file_path": "a.png"
})
# Recipe B: Name "Beta", Date 20, LoRAs 1
await scanner.add_recipe({
"id": "B", "title": "Beta", "created_date": 20.0,
"loras": [{}], "file_path": "b.png"
})
# Recipe C: Name "Gamma", Date 5, LoRAs 3
await scanner.add_recipe({
"id": "C", "title": "Gamma", "created_date": 5.0,
"loras": [{}, {}, {}], "file_path": "c.png"
})
await asyncio.sleep(0)
# Test Name DESC: Gamma, Beta, Alpha
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="name:desc")
assert [i["id"] for i in res["items"]] == ["C", "B", "A"]
# Test LoRA Count DESC: Gamma (3), Alpha (2), Beta (1)
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="loras_count:desc")
assert [i["id"] for i in res["items"]] == ["C", "A", "B"]
# Test LoRA Count ASC: Beta (1), Alpha (2), Gamma (3)
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="loras_count:asc")
assert [i["id"] for i in res["items"]] == ["B", "A", "C"]
# Test Date ASC: Gamma (5), Alpha (10), Beta (20)
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="date:asc")
assert [i["id"] for i in res["items"]] == ["C", "A", "B"]

View File

@@ -403,6 +403,89 @@ async def test_save_recipe_from_widget_allows_empty_lora(tmp_path):
assert scanner.added and scanner.added[0]["loras"] == [] assert scanner.added and scanner.added[0]["loras"] == []
@pytest.mark.asyncio
async def test_move_recipe_updates_paths(tmp_path):
exif_utils = DummyExifUtils()
recipes_dir = tmp_path / "recipes"
recipes_dir.mkdir(parents=True, exist_ok=True)
recipe_id = "move-me"
image_path = recipes_dir / f"{recipe_id}.webp"
json_path = recipes_dir / f"{recipe_id}.recipe.json"
image_path.write_bytes(b"img")
json_path.write_text(
json.dumps(
{
"id": recipe_id,
"file_path": str(image_path),
"title": "Recipe",
"loras": [],
"gen_params": {},
"created_date": 0,
"modified": 0,
}
)
)
class MoveScanner:
def __init__(self, root: Path):
self.recipes_dir = str(root)
self.recipe = {
"id": recipe_id,
"file_path": str(image_path),
"title": "Recipe",
"loras": [],
"gen_params": {},
"created_date": 0,
"modified": 0,
"folder": "",
}
async def get_recipe_by_id(self, target_id: str):
return self.recipe if target_id == recipe_id else None
async def get_recipe_json_path(self, target_id: str):
matches = list(Path(self.recipes_dir).rglob(f"{target_id}.recipe.json"))
return str(matches[0]) if matches else None
async def update_recipe_metadata(self, target_id: str, metadata: dict):
if target_id != recipe_id:
return False
self.recipe.update(metadata)
target_path = await self.get_recipe_json_path(target_id)
if not target_path:
return False
existing = json.loads(Path(target_path).read_text())
existing.update(metadata)
Path(target_path).write_text(json.dumps(existing))
return True
async def get_cached_data(self, force_refresh: bool = False): # noqa: ARG002 - signature parity
return SimpleNamespace(raw_data=[self.recipe])
scanner = MoveScanner(recipes_dir)
service = RecipePersistenceService(
exif_utils=exif_utils,
card_preview_width=512,
logger=logging.getLogger("test"),
)
target_folder = recipes_dir / "nested"
result = await service.move_recipe(
recipe_scanner=scanner, recipe_id=recipe_id, target_path=str(target_folder)
)
assert result.payload["folder"] == "nested"
assert Path(result.payload["json_path"]).parent == target_folder
assert Path(result.payload["new_file_path"]).parent == target_folder
assert not json_path.exists()
stored = json.loads(Path(result.payload["json_path"]).read_text())
assert stored["folder"] == "nested"
assert stored["file_path"] == result.payload["new_file_path"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_analyze_remote_video(tmp_path): async def test_analyze_remote_video(tmp_path):
exif_utils = DummyExifUtils() exif_utils = DummyExifUtils()