mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 15:15:44 -03:00
@@ -188,7 +188,8 @@
|
|||||||
"creator": "Ersteller",
|
"creator": "Ersteller",
|
||||||
"title": "Rezept-Titel",
|
"title": "Rezept-Titel",
|
||||||
"loraName": "LoRA-Dateiname",
|
"loraName": "LoRA-Dateiname",
|
||||||
"loraModel": "LoRA-Modellname"
|
"loraModel": "LoRA-Modellname",
|
||||||
|
"prompt": "Prompt"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"filter": {
|
"filter": {
|
||||||
@@ -588,10 +589,26 @@
|
|||||||
"selectLoraRoot": "Bitte wählen Sie ein LoRA-Stammverzeichnis aus"
|
"selectLoraRoot": "Bitte wählen Sie ein LoRA-Stammverzeichnis aus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"sort": {
|
||||||
|
"title": "Rezepte sortieren nach...",
|
||||||
|
"name": "Name",
|
||||||
|
"nameAsc": "A - Z",
|
||||||
|
"nameDesc": "Z - A",
|
||||||
|
"date": "Datum",
|
||||||
|
"dateDesc": "Neueste",
|
||||||
|
"dateAsc": "Älteste",
|
||||||
|
"lorasCount": "LoRA-Anzahl",
|
||||||
|
"lorasCountDesc": "Meiste",
|
||||||
|
"lorasCountAsc": "Wenigste"
|
||||||
|
},
|
||||||
"refresh": {
|
"refresh": {
|
||||||
"title": "Rezeptliste aktualisieren"
|
"title": "Rezeptliste aktualisieren"
|
||||||
},
|
},
|
||||||
"filteredByLora": "Gefiltert nach LoRA"
|
"filteredByLora": "Gefiltert nach LoRA",
|
||||||
|
"favorites": {
|
||||||
|
"title": "Nur Favoriten anzeigen",
|
||||||
|
"action": "Favoriten"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"duplicates": {
|
"duplicates": {
|
||||||
"found": "{count} Duplikat-Gruppen gefunden",
|
"found": "{count} Duplikat-Gruppen gefunden",
|
||||||
@@ -638,7 +655,8 @@
|
|||||||
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
|
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
|
||||||
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
|
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
|
||||||
"dragDrop": {
|
"dragDrop": {
|
||||||
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden."
|
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
|
||||||
|
"moveUnsupported": "Move is not supported for this item."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"statistics": {
|
"statistics": {
|
||||||
@@ -1460,7 +1478,8 @@
|
|||||||
"bulkMoveFailures": "Fehlgeschlagene Verschiebungen:\n{failures}",
|
"bulkMoveFailures": "Fehlgeschlagene Verschiebungen:\n{failures}",
|
||||||
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
|
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
|
||||||
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
|
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
|
||||||
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}"
|
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}",
|
||||||
|
"moveFailed": "Failed to move item: {message}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"banners": {
|
"banners": {
|
||||||
@@ -1478,4 +1497,4 @@
|
|||||||
"learnMore": "LM Civitai Extension Tutorial"
|
"learnMore": "LM Civitai Extension Tutorial"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "浏览器插件教程"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
50
py/recipes/merger.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from typing import Any, Dict, Optional
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class GenParamsMerger:
|
||||||
|
"""Utility to merge generation parameters from multiple sources with priority."""
|
||||||
|
|
||||||
|
BLACKLISTED_KEYS = {"id", "url", "userId", "username", "createdAt", "updatedAt", "hash"}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def merge(
|
||||||
|
request_params: Optional[Dict[str, Any]] = None,
|
||||||
|
civitai_meta: Optional[Dict[str, Any]] = None,
|
||||||
|
embedded_metadata: Optional[Dict[str, Any]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Merge generation parameters from three sources.
|
||||||
|
|
||||||
|
Priority: request_params > civitai_meta > embedded_metadata
|
||||||
|
|
||||||
|
Args:
|
||||||
|
request_params: Params provided directly in the import request
|
||||||
|
civitai_meta: Params from Civitai Image API 'meta' field
|
||||||
|
embedded_metadata: Params extracted from image EXIF/embedded metadata
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Merged parameters dictionary
|
||||||
|
"""
|
||||||
|
result = {}
|
||||||
|
|
||||||
|
# 1. Start with embedded metadata (lowest priority)
|
||||||
|
if embedded_metadata:
|
||||||
|
# If it's a full recipe metadata, we use its gen_params
|
||||||
|
if "gen_params" in embedded_metadata and isinstance(embedded_metadata["gen_params"], dict):
|
||||||
|
result.update(embedded_metadata["gen_params"])
|
||||||
|
else:
|
||||||
|
# Otherwise assume the dict itself contains gen_params
|
||||||
|
result.update(embedded_metadata)
|
||||||
|
|
||||||
|
# 2. Layer Civitai meta (medium priority)
|
||||||
|
if civitai_meta:
|
||||||
|
result.update(civitai_meta)
|
||||||
|
|
||||||
|
# 3. Layer request params (highest priority)
|
||||||
|
if request_params:
|
||||||
|
result.update(request_params)
|
||||||
|
|
||||||
|
# Filter out blacklisted keys
|
||||||
|
return {k: v for k, v in result.items() if k not in GenParamsMerger.BLACKLISTED_KEYS}
|
||||||
@@ -36,9 +36,6 @@ class ComfyMetadataParser(RecipeMetadataParser):
|
|||||||
# Find all LoraLoader nodes
|
# Find all LoraLoader nodes
|
||||||
lora_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'LoraLoader'}
|
lora_nodes = {k: v for k, v in data.items() if isinstance(v, dict) and v.get('class_type') == 'LoraLoader'}
|
||||||
|
|
||||||
if not lora_nodes:
|
|
||||||
return {"error": "No LoRA information found in this ComfyUI workflow", "loras": []}
|
|
||||||
|
|
||||||
# Process each LoraLoader node
|
# Process each LoraLoader node
|
||||||
for node_id, node in lora_nodes.items():
|
for node_id, node in lora_nodes.items():
|
||||||
if 'inputs' not in node or 'lora_name' not in node['inputs']:
|
if 'inputs' not in node or 'lora_name' not in node['inputs']:
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ from ...services.recipes import (
|
|||||||
)
|
)
|
||||||
from ...services.metadata_service import get_default_metadata_provider
|
from ...services.metadata_service import get_default_metadata_provider
|
||||||
from ...utils.civitai_utils import rewrite_preview_url
|
from ...utils.civitai_utils import rewrite_preview_url
|
||||||
|
from ...utils.exif_utils import ExifUtils
|
||||||
|
from ...recipes.merger import GenParamsMerger
|
||||||
|
|
||||||
Logger = logging.Logger
|
Logger = logging.Logger
|
||||||
EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
EnsureDependenciesCallable = Callable[[], Awaitable[None]]
|
||||||
@@ -56,16 +58,22 @@ class RecipeHandlerSet:
|
|||||||
"delete_recipe": self.management.delete_recipe,
|
"delete_recipe": self.management.delete_recipe,
|
||||||
"get_top_tags": self.query.get_top_tags,
|
"get_top_tags": self.query.get_top_tags,
|
||||||
"get_base_models": self.query.get_base_models,
|
"get_base_models": self.query.get_base_models,
|
||||||
|
"get_roots": self.query.get_roots,
|
||||||
|
"get_folders": self.query.get_folders,
|
||||||
|
"get_folder_tree": self.query.get_folder_tree,
|
||||||
|
"get_unified_folder_tree": self.query.get_unified_folder_tree,
|
||||||
"share_recipe": self.sharing.share_recipe,
|
"share_recipe": self.sharing.share_recipe,
|
||||||
"download_shared_recipe": self.sharing.download_shared_recipe,
|
"download_shared_recipe": self.sharing.download_shared_recipe,
|
||||||
"get_recipe_syntax": self.query.get_recipe_syntax,
|
"get_recipe_syntax": self.query.get_recipe_syntax,
|
||||||
"update_recipe": self.management.update_recipe,
|
"update_recipe": self.management.update_recipe,
|
||||||
"reconnect_lora": self.management.reconnect_lora,
|
"reconnect_lora": self.management.reconnect_lora,
|
||||||
"find_duplicates": self.query.find_duplicates,
|
"find_duplicates": self.query.find_duplicates,
|
||||||
|
"move_recipes_bulk": self.management.move_recipes_bulk,
|
||||||
"bulk_delete": self.management.bulk_delete,
|
"bulk_delete": self.management.bulk_delete,
|
||||||
"save_recipe_from_widget": self.management.save_recipe_from_widget,
|
"save_recipe_from_widget": self.management.save_recipe_from_widget,
|
||||||
"get_recipes_for_lora": self.query.get_recipes_for_lora,
|
"get_recipes_for_lora": self.query.get_recipes_for_lora,
|
||||||
"scan_recipes": self.query.scan_recipes,
|
"scan_recipes": self.query.scan_recipes,
|
||||||
|
"move_recipe": self.management.move_recipe,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -149,12 +157,15 @@ class RecipeListingHandler:
|
|||||||
page_size = int(request.query.get("page_size", "20"))
|
page_size = int(request.query.get("page_size", "20"))
|
||||||
sort_by = request.query.get("sort_by", "date")
|
sort_by = request.query.get("sort_by", "date")
|
||||||
search = request.query.get("search")
|
search = request.query.get("search")
|
||||||
|
folder = request.query.get("folder")
|
||||||
|
recursive = request.query.get("recursive", "true").lower() == "true"
|
||||||
|
|
||||||
search_options = {
|
search_options = {
|
||||||
"title": request.query.get("search_title", "true").lower() == "true",
|
"title": request.query.get("search_title", "true").lower() == "true",
|
||||||
"tags": request.query.get("search_tags", "true").lower() == "true",
|
"tags": request.query.get("search_tags", "true").lower() == "true",
|
||||||
"lora_name": request.query.get("search_lora_name", "true").lower() == "true",
|
"lora_name": request.query.get("search_lora_name", "true").lower() == "true",
|
||||||
"lora_model": request.query.get("search_lora_model", "true").lower() == "true",
|
"lora_model": request.query.get("search_lora_model", "true").lower() == "true",
|
||||||
|
"prompt": request.query.get("search_prompt", "true").lower() == "true",
|
||||||
}
|
}
|
||||||
|
|
||||||
filters: Dict[str, Any] = {}
|
filters: Dict[str, Any] = {}
|
||||||
@@ -162,6 +173,9 @@ class RecipeListingHandler:
|
|||||||
if base_models:
|
if base_models:
|
||||||
filters["base_model"] = base_models.split(",")
|
filters["base_model"] = base_models.split(",")
|
||||||
|
|
||||||
|
if request.query.get("favorite", "false").lower() == "true":
|
||||||
|
filters["favorite"] = True
|
||||||
|
|
||||||
tag_filters: Dict[str, str] = {}
|
tag_filters: Dict[str, str] = {}
|
||||||
legacy_tags = request.query.get("tags")
|
legacy_tags = request.query.get("tags")
|
||||||
if legacy_tags:
|
if legacy_tags:
|
||||||
@@ -193,6 +207,8 @@ class RecipeListingHandler:
|
|||||||
filters=filters,
|
filters=filters,
|
||||||
search_options=search_options,
|
search_options=search_options,
|
||||||
lora_hash=lora_hash,
|
lora_hash=lora_hash,
|
||||||
|
folder=folder,
|
||||||
|
recursive=recursive,
|
||||||
)
|
)
|
||||||
|
|
||||||
for item in result.get("items", []):
|
for item in result.get("items", []):
|
||||||
@@ -299,6 +315,58 @@ class RecipeQueryHandler:
|
|||||||
self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
|
self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
|
||||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_roots(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
roots = [recipe_scanner.recipes_dir] if recipe_scanner.recipes_dir else []
|
||||||
|
return web.json_response({"success": True, "roots": roots})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error retrieving recipe roots: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_folders(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
folders = await recipe_scanner.get_folders()
|
||||||
|
return web.json_response({"success": True, "folders": folders})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error retrieving recipe folders: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_folder_tree(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
folder_tree = await recipe_scanner.get_folder_tree()
|
||||||
|
return web.json_response({"success": True, "tree": folder_tree})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error retrieving recipe folder tree: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def get_unified_folder_tree(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
folder_tree = await recipe_scanner.get_folder_tree()
|
||||||
|
return web.json_response({"success": True, "tree": folder_tree})
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error retrieving unified recipe folder tree: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def get_recipes_for_lora(self, request: web.Request) -> web.Response:
|
async def get_recipes_for_lora(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
@@ -486,7 +554,41 @@ class RecipeManagementHandler:
|
|||||||
metadata["base_model"] = base_model_from_metadata
|
metadata["base_model"] = base_model_from_metadata
|
||||||
|
|
||||||
tags = self._parse_tags(params.get("tags"))
|
tags = self._parse_tags(params.get("tags"))
|
||||||
image_bytes, extension = await self._download_remote_media(image_url)
|
image_bytes, extension, civitai_meta = await self._download_remote_media(image_url)
|
||||||
|
|
||||||
|
# Extract embedded metadata from the downloaded image
|
||||||
|
embedded_metadata = None
|
||||||
|
try:
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=extension, delete=False) as temp_img:
|
||||||
|
temp_img.write(image_bytes)
|
||||||
|
temp_img_path = temp_img.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_embedded = ExifUtils.extract_image_metadata(temp_img_path)
|
||||||
|
if raw_embedded:
|
||||||
|
# Try to parse it using standard parsers if it looks like a recipe
|
||||||
|
parser = self._analysis_service._recipe_parser_factory.create_parser(raw_embedded)
|
||||||
|
if parser:
|
||||||
|
parsed_embedded = await parser.parse_metadata(raw_embedded, recipe_scanner=recipe_scanner)
|
||||||
|
embedded_metadata = parsed_embedded
|
||||||
|
else:
|
||||||
|
# Fallback to raw string if no parser matches (might be simple params)
|
||||||
|
embedded_metadata = {"gen_params": {"raw_metadata": raw_embedded}}
|
||||||
|
finally:
|
||||||
|
if os.path.exists(temp_img_path):
|
||||||
|
os.unlink(temp_img_path)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.warning("Failed to extract embedded metadata during import: %s", exc)
|
||||||
|
|
||||||
|
# Merge gen_params from all sources
|
||||||
|
merged_gen_params = GenParamsMerger.merge(
|
||||||
|
request_params=gen_params,
|
||||||
|
civitai_meta=civitai_meta,
|
||||||
|
embedded_metadata=embedded_metadata
|
||||||
|
)
|
||||||
|
|
||||||
|
if merged_gen_params:
|
||||||
|
metadata["gen_params"] = merged_gen_params
|
||||||
|
|
||||||
result = await self._persistence_service.save_recipe(
|
result = await self._persistence_service.save_recipe(
|
||||||
recipe_scanner=recipe_scanner,
|
recipe_scanner=recipe_scanner,
|
||||||
@@ -545,6 +647,64 @@ class RecipeManagementHandler:
|
|||||||
self._logger.error("Error updating recipe: %s", exc, exc_info=True)
|
self._logger.error("Error updating recipe: %s", exc, exc_info=True)
|
||||||
return web.json_response({"error": str(exc)}, status=500)
|
return web.json_response({"error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def move_recipe(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
data = await request.json()
|
||||||
|
recipe_id = data.get("recipe_id")
|
||||||
|
target_path = data.get("target_path")
|
||||||
|
if not recipe_id or not target_path:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "recipe_id and target_path are required"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self._persistence_service.move_recipe(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
recipe_id=str(recipe_id),
|
||||||
|
target_path=str(target_path),
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
except RecipeValidationError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||||
|
except RecipeNotFoundError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error moving recipe: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
|
async def move_recipes_bulk(self, request: web.Request) -> web.Response:
|
||||||
|
try:
|
||||||
|
await self._ensure_dependencies_ready()
|
||||||
|
recipe_scanner = self._recipe_scanner_getter()
|
||||||
|
if recipe_scanner is None:
|
||||||
|
raise RuntimeError("Recipe scanner unavailable")
|
||||||
|
|
||||||
|
data = await request.json()
|
||||||
|
recipe_ids = data.get("recipe_ids") or []
|
||||||
|
target_path = data.get("target_path")
|
||||||
|
if not recipe_ids or not target_path:
|
||||||
|
return web.json_response(
|
||||||
|
{"success": False, "error": "recipe_ids and target_path are required"}, status=400
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await self._persistence_service.move_recipes_bulk(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
recipe_ids=recipe_ids,
|
||||||
|
target_path=str(target_path),
|
||||||
|
)
|
||||||
|
return web.json_response(result.payload, status=result.status)
|
||||||
|
except RecipeValidationError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||||
|
except RecipeNotFoundError as exc:
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||||
|
except Exception as exc:
|
||||||
|
self._logger.error("Error moving recipes in bulk: %s", exc, exc_info=True)
|
||||||
|
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||||
|
|
||||||
async def reconnect_lora(self, request: web.Request) -> web.Response:
|
async def reconnect_lora(self, request: web.Request) -> web.Response:
|
||||||
try:
|
try:
|
||||||
await self._ensure_dependencies_ready()
|
await self._ensure_dependencies_ready()
|
||||||
@@ -776,7 +936,7 @@ class RecipeManagementHandler:
|
|||||||
extension = ".webp" # Default to webp if unknown
|
extension = ".webp" # Default to webp if unknown
|
||||||
|
|
||||||
with open(temp_path, "rb") as file_obj:
|
with open(temp_path, "rb") as file_obj:
|
||||||
return file_obj.read(), extension
|
return file_obj.read(), extension, image_info.get("meta") if civitai_match and image_info else None
|
||||||
except RecipeDownloadError:
|
except RecipeDownloadError:
|
||||||
raise
|
raise
|
||||||
except RecipeValidationError:
|
except RecipeValidationError:
|
||||||
|
|||||||
@@ -27,10 +27,16 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
|||||||
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
|
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
|
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/folder-tree", "get_folder_tree"),
|
||||||
|
RouteDefinition("GET", "/api/lm/recipes/unified-folder-tree", "get_unified_folder_tree"),
|
||||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"),
|
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"),
|
||||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"),
|
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"),
|
||||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
|
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
|
||||||
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
|
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
|
||||||
|
RouteDefinition("POST", "/api/lm/recipes/move-bulk", "move_recipes_bulk"),
|
||||||
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
|
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
|
||||||
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
||||||
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
||||||
|
|||||||
@@ -7,12 +7,18 @@ from natsort import natsorted
|
|||||||
@dataclass
|
@dataclass
|
||||||
class RecipeCache:
|
class RecipeCache:
|
||||||
"""Cache structure for Recipe data"""
|
"""Cache structure for Recipe data"""
|
||||||
|
|
||||||
raw_data: List[Dict]
|
raw_data: List[Dict]
|
||||||
sorted_by_name: List[Dict]
|
sorted_by_name: List[Dict]
|
||||||
sorted_by_date: List[Dict]
|
sorted_by_date: List[Dict]
|
||||||
|
folders: List[str] | None = None
|
||||||
|
folder_tree: Dict | None = None
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
self._lock = asyncio.Lock()
|
self._lock = asyncio.Lock()
|
||||||
|
# Normalize optional metadata containers
|
||||||
|
self.folders = self.folders or []
|
||||||
|
self.folder_tree = self.folder_tree or {}
|
||||||
|
|
||||||
async def resort(self, name_only: bool = False):
|
async def resort(self, name_only: bool = False):
|
||||||
"""Resort all cached data views"""
|
"""Resort all cached data views"""
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import os
|
from __future__ import annotations
|
||||||
import logging
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
import time
|
import time
|
||||||
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
|
||||||
from ..config import config
|
from ..config import config
|
||||||
@@ -117,7 +119,9 @@ class RecipeScanner:
|
|||||||
self._cache = RecipeCache(
|
self._cache = RecipeCache(
|
||||||
raw_data=[],
|
raw_data=[],
|
||||||
sorted_by_name=[],
|
sorted_by_name=[],
|
||||||
sorted_by_date=[]
|
sorted_by_date=[],
|
||||||
|
folders=[],
|
||||||
|
folder_tree={},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Mark as initializing to prevent concurrent initializations
|
# Mark as initializing to prevent concurrent initializations
|
||||||
@@ -218,6 +222,7 @@ class RecipeScanner:
|
|||||||
|
|
||||||
# Update cache with the collected data
|
# Update cache with the collected data
|
||||||
self._cache.raw_data = recipes
|
self._cache.raw_data = recipes
|
||||||
|
self._update_folder_metadata(self._cache)
|
||||||
|
|
||||||
# Create a simplified resort function that doesn't use await
|
# Create a simplified resort function that doesn't use await
|
||||||
if hasattr(self._cache, "resort"):
|
if hasattr(self._cache, "resort"):
|
||||||
@@ -336,6 +341,9 @@ class RecipeScanner:
|
|||||||
if not self._cache:
|
if not self._cache:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Keep folder metadata up to date alongside sort order
|
||||||
|
self._update_folder_metadata()
|
||||||
|
|
||||||
async def _resort_wrapper() -> None:
|
async def _resort_wrapper() -> None:
|
||||||
try:
|
try:
|
||||||
await self._cache.resort(name_only=name_only)
|
await self._cache.resort(name_only=name_only)
|
||||||
@@ -346,6 +354,75 @@ class RecipeScanner:
|
|||||||
self._resort_tasks.add(task)
|
self._resort_tasks.add(task)
|
||||||
task.add_done_callback(lambda finished: self._resort_tasks.discard(finished))
|
task.add_done_callback(lambda finished: self._resort_tasks.discard(finished))
|
||||||
|
|
||||||
|
def _calculate_folder(self, recipe_path: str) -> str:
|
||||||
|
"""Calculate a normalized folder path relative to ``recipes_dir``."""
|
||||||
|
|
||||||
|
recipes_dir = self.recipes_dir
|
||||||
|
if not recipes_dir:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
recipe_dir = os.path.dirname(os.path.normpath(recipe_path))
|
||||||
|
relative_dir = os.path.relpath(recipe_dir, recipes_dir)
|
||||||
|
if relative_dir in (".", ""):
|
||||||
|
return ""
|
||||||
|
return relative_dir.replace(os.path.sep, "/")
|
||||||
|
except Exception:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _build_folder_tree(self, folders: list[str]) -> dict:
|
||||||
|
"""Build a nested folder tree structure from relative folder paths."""
|
||||||
|
|
||||||
|
tree: dict[str, dict] = {}
|
||||||
|
for folder in folders:
|
||||||
|
if not folder:
|
||||||
|
continue
|
||||||
|
|
||||||
|
parts = folder.split("/")
|
||||||
|
current_level = tree
|
||||||
|
|
||||||
|
for part in parts:
|
||||||
|
if part not in current_level:
|
||||||
|
current_level[part] = {}
|
||||||
|
current_level = current_level[part]
|
||||||
|
|
||||||
|
return tree
|
||||||
|
|
||||||
|
def _update_folder_metadata(self, cache: RecipeCache | None = None) -> None:
|
||||||
|
"""Ensure folder lists and tree metadata are synchronized with cache contents."""
|
||||||
|
|
||||||
|
cache = cache or self._cache
|
||||||
|
if cache is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
folders: set[str] = set()
|
||||||
|
for item in cache.raw_data:
|
||||||
|
folder_value = item.get("folder", "")
|
||||||
|
if folder_value is None:
|
||||||
|
folder_value = ""
|
||||||
|
if folder_value == ".":
|
||||||
|
folder_value = ""
|
||||||
|
normalized = str(folder_value).replace("\\", "/")
|
||||||
|
item["folder"] = normalized
|
||||||
|
folders.add(normalized)
|
||||||
|
|
||||||
|
cache.folders = sorted(folders, key=lambda entry: entry.lower())
|
||||||
|
cache.folder_tree = self._build_folder_tree(cache.folders)
|
||||||
|
|
||||||
|
async def get_folders(self) -> list[str]:
|
||||||
|
"""Return a sorted list of recipe folders relative to the recipes root."""
|
||||||
|
|
||||||
|
cache = await self.get_cached_data()
|
||||||
|
self._update_folder_metadata(cache)
|
||||||
|
return cache.folders
|
||||||
|
|
||||||
|
async def get_folder_tree(self) -> dict:
|
||||||
|
"""Return a hierarchical tree of recipe folders for sidebar navigation."""
|
||||||
|
|
||||||
|
cache = await self.get_cached_data()
|
||||||
|
self._update_folder_metadata(cache)
|
||||||
|
return cache.folder_tree
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def recipes_dir(self) -> str:
|
def recipes_dir(self) -> str:
|
||||||
"""Get path to recipes directory"""
|
"""Get path to recipes directory"""
|
||||||
@@ -362,11 +439,14 @@ class RecipeScanner:
|
|||||||
"""Get cached recipe data, refresh if needed"""
|
"""Get cached recipe data, refresh if needed"""
|
||||||
# If cache is already initialized and no refresh is needed, return it immediately
|
# If cache is already initialized and no refresh is needed, return it immediately
|
||||||
if self._cache is not None and not force_refresh:
|
if self._cache is not None and not force_refresh:
|
||||||
|
self._update_folder_metadata()
|
||||||
return self._cache
|
return self._cache
|
||||||
|
|
||||||
# If another initialization is already in progress, wait for it to complete
|
# If another initialization is already in progress, wait for it to complete
|
||||||
if self._is_initializing and not force_refresh:
|
if self._is_initializing and not force_refresh:
|
||||||
return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[])
|
return self._cache or RecipeCache(
|
||||||
|
raw_data=[], sorted_by_name=[], sorted_by_date=[], folders=[], folder_tree={}
|
||||||
|
)
|
||||||
|
|
||||||
# If force refresh is requested, initialize the cache directly
|
# If force refresh is requested, initialize the cache directly
|
||||||
if force_refresh:
|
if force_refresh:
|
||||||
@@ -384,11 +464,14 @@ class RecipeScanner:
|
|||||||
self._cache = RecipeCache(
|
self._cache = RecipeCache(
|
||||||
raw_data=raw_data,
|
raw_data=raw_data,
|
||||||
sorted_by_name=[],
|
sorted_by_name=[],
|
||||||
sorted_by_date=[]
|
sorted_by_date=[],
|
||||||
|
folders=[],
|
||||||
|
folder_tree={},
|
||||||
)
|
)
|
||||||
|
|
||||||
# Resort cache
|
# Resort cache
|
||||||
await self._cache.resort()
|
await self._cache.resort()
|
||||||
|
self._update_folder_metadata(self._cache)
|
||||||
|
|
||||||
return self._cache
|
return self._cache
|
||||||
|
|
||||||
@@ -398,7 +481,9 @@ class RecipeScanner:
|
|||||||
self._cache = RecipeCache(
|
self._cache = RecipeCache(
|
||||||
raw_data=[],
|
raw_data=[],
|
||||||
sorted_by_name=[],
|
sorted_by_name=[],
|
||||||
sorted_by_date=[]
|
sorted_by_date=[],
|
||||||
|
folders=[],
|
||||||
|
folder_tree={},
|
||||||
)
|
)
|
||||||
return self._cache
|
return self._cache
|
||||||
finally:
|
finally:
|
||||||
@@ -409,7 +494,9 @@ class RecipeScanner:
|
|||||||
logger.error(f"Unexpected error in get_cached_data: {e}")
|
logger.error(f"Unexpected error in get_cached_data: {e}")
|
||||||
|
|
||||||
# Return the cache (may be empty or partially initialized)
|
# Return the cache (may be empty or partially initialized)
|
||||||
return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[])
|
return self._cache or RecipeCache(
|
||||||
|
raw_data=[], sorted_by_name=[], sorted_by_date=[], folders=[], folder_tree={}
|
||||||
|
)
|
||||||
|
|
||||||
async def refresh_cache(self, force: bool = False) -> RecipeCache:
|
async def refresh_cache(self, force: bool = False) -> RecipeCache:
|
||||||
"""Public helper to refresh or return the recipe cache."""
|
"""Public helper to refresh or return the recipe cache."""
|
||||||
@@ -424,6 +511,7 @@ class RecipeScanner:
|
|||||||
|
|
||||||
cache = await self.get_cached_data()
|
cache = await self.get_cached_data()
|
||||||
await cache.add_recipe(recipe_data, resort=False)
|
await cache.add_recipe(recipe_data, resort=False)
|
||||||
|
self._update_folder_metadata(cache)
|
||||||
self._schedule_resort()
|
self._schedule_resort()
|
||||||
|
|
||||||
async def remove_recipe(self, recipe_id: str) -> bool:
|
async def remove_recipe(self, recipe_id: str) -> bool:
|
||||||
@@ -437,6 +525,7 @@ class RecipeScanner:
|
|||||||
if removed is None:
|
if removed is None:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
self._update_folder_metadata(cache)
|
||||||
self._schedule_resort()
|
self._schedule_resort()
|
||||||
return True
|
return True
|
||||||
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import base64
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
|
import shutil
|
||||||
import time
|
import time
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
@@ -154,12 +155,8 @@ class RecipePersistenceService:
|
|||||||
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
|
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
|
||||||
"""Delete an existing recipe."""
|
"""Delete an existing recipe."""
|
||||||
|
|
||||||
recipes_dir = recipe_scanner.recipes_dir
|
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||||
if not recipes_dir or not os.path.exists(recipes_dir):
|
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||||
raise RecipeNotFoundError("Recipes directory not found")
|
|
||||||
|
|
||||||
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
|
|
||||||
if not os.path.exists(recipe_json_path):
|
|
||||||
raise RecipeNotFoundError("Recipe not found")
|
raise RecipeNotFoundError("Recipe not found")
|
||||||
|
|
||||||
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
|
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
|
||||||
@@ -176,9 +173,9 @@ class RecipePersistenceService:
|
|||||||
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
|
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: dict[str, Any]) -> PersistenceResult:
|
||||||
"""Update persisted metadata for a recipe."""
|
"""Update persisted metadata for a recipe."""
|
||||||
|
|
||||||
if not any(key in updates for key in ("title", "tags", "source_path", "preview_nsfw_level")):
|
if not any(key in updates for key in ("title", "tags", "source_path", "preview_nsfw_level", "favorite")):
|
||||||
raise RecipeValidationError(
|
raise RecipeValidationError(
|
||||||
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level)"
|
"At least one field to update must be provided (title or tags or source_path or preview_nsfw_level or favorite)"
|
||||||
)
|
)
|
||||||
|
|
||||||
success = await recipe_scanner.update_recipe_metadata(recipe_id, updates)
|
success = await recipe_scanner.update_recipe_metadata(recipe_id, updates)
|
||||||
@@ -187,6 +184,163 @@ class RecipePersistenceService:
|
|||||||
|
|
||||||
return PersistenceResult({"success": True, "recipe_id": recipe_id, "updates": updates})
|
return PersistenceResult({"success": True, "recipe_id": recipe_id, "updates": updates})
|
||||||
|
|
||||||
|
def _normalize_target_path(self, recipe_scanner, target_path: str) -> tuple[str, str]:
|
||||||
|
"""Normalize and validate the target path for recipe moves."""
|
||||||
|
|
||||||
|
if not target_path:
|
||||||
|
raise RecipeValidationError("Target path is required")
|
||||||
|
|
||||||
|
recipes_root = recipe_scanner.recipes_dir
|
||||||
|
if not recipes_root:
|
||||||
|
raise RecipeNotFoundError("Recipes directory not found")
|
||||||
|
|
||||||
|
normalized_target = os.path.normpath(target_path)
|
||||||
|
recipes_root = os.path.normpath(recipes_root)
|
||||||
|
if not os.path.isabs(normalized_target):
|
||||||
|
normalized_target = os.path.normpath(os.path.join(recipes_root, normalized_target))
|
||||||
|
|
||||||
|
try:
|
||||||
|
common_root = os.path.commonpath([normalized_target, recipes_root])
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RecipeValidationError("Invalid target path") from exc
|
||||||
|
|
||||||
|
if common_root != recipes_root:
|
||||||
|
raise RecipeValidationError("Target path must be inside the recipes directory")
|
||||||
|
|
||||||
|
return normalized_target, recipes_root
|
||||||
|
|
||||||
|
async def _move_recipe_files(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
recipe_scanner,
|
||||||
|
recipe_id: str,
|
||||||
|
normalized_target: str,
|
||||||
|
recipes_root: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Move the recipe's JSON and preview image into the normalized target."""
|
||||||
|
|
||||||
|
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||||
|
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||||
|
raise RecipeNotFoundError("Recipe not found")
|
||||||
|
|
||||||
|
recipe_data = await recipe_scanner.get_recipe_by_id(recipe_id)
|
||||||
|
if not recipe_data:
|
||||||
|
raise RecipeNotFoundError("Recipe not found")
|
||||||
|
|
||||||
|
current_json_dir = os.path.dirname(recipe_json_path)
|
||||||
|
normalized_image_path = os.path.normpath(recipe_data.get("file_path") or "") if recipe_data.get("file_path") else None
|
||||||
|
|
||||||
|
os.makedirs(normalized_target, exist_ok=True)
|
||||||
|
|
||||||
|
if os.path.normpath(current_json_dir) == normalized_target:
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"message": "Recipe is already in the target folder",
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"original_file_path": recipe_data.get("file_path"),
|
||||||
|
"new_file_path": recipe_data.get("file_path"),
|
||||||
|
}
|
||||||
|
|
||||||
|
new_json_path = os.path.normpath(os.path.join(normalized_target, os.path.basename(recipe_json_path)))
|
||||||
|
shutil.move(recipe_json_path, new_json_path)
|
||||||
|
|
||||||
|
new_image_path = normalized_image_path
|
||||||
|
if normalized_image_path:
|
||||||
|
target_image_path = os.path.normpath(os.path.join(normalized_target, os.path.basename(normalized_image_path)))
|
||||||
|
if os.path.exists(normalized_image_path) and normalized_image_path != target_image_path:
|
||||||
|
shutil.move(normalized_image_path, target_image_path)
|
||||||
|
new_image_path = target_image_path
|
||||||
|
|
||||||
|
relative_folder = os.path.relpath(normalized_target, recipes_root)
|
||||||
|
if relative_folder in (".", ""):
|
||||||
|
relative_folder = ""
|
||||||
|
updates = {"file_path": new_image_path or recipe_data.get("file_path"), "folder": relative_folder.replace(os.path.sep, "/")}
|
||||||
|
|
||||||
|
updated = await recipe_scanner.update_recipe_metadata(recipe_id, updates)
|
||||||
|
if not updated:
|
||||||
|
raise RecipeNotFoundError("Recipe not found after move")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"success": True,
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"original_file_path": recipe_data.get("file_path"),
|
||||||
|
"new_file_path": updates["file_path"],
|
||||||
|
"json_path": new_json_path,
|
||||||
|
"folder": updates["folder"],
|
||||||
|
}
|
||||||
|
|
||||||
|
async def move_recipe(self, *, recipe_scanner, recipe_id: str, target_path: str) -> PersistenceResult:
|
||||||
|
"""Move a recipe's assets into a new folder under the recipes root."""
|
||||||
|
|
||||||
|
normalized_target, recipes_root = self._normalize_target_path(recipe_scanner, target_path)
|
||||||
|
result = await self._move_recipe_files(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
recipe_id=recipe_id,
|
||||||
|
normalized_target=normalized_target,
|
||||||
|
recipes_root=recipes_root,
|
||||||
|
)
|
||||||
|
return PersistenceResult(result)
|
||||||
|
|
||||||
|
async def move_recipes_bulk(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
recipe_scanner,
|
||||||
|
recipe_ids: Iterable[str],
|
||||||
|
target_path: str,
|
||||||
|
) -> PersistenceResult:
|
||||||
|
"""Move multiple recipes to a new folder."""
|
||||||
|
|
||||||
|
recipe_ids = list(recipe_ids)
|
||||||
|
if not recipe_ids:
|
||||||
|
raise RecipeValidationError("No recipe IDs provided")
|
||||||
|
|
||||||
|
normalized_target, recipes_root = self._normalize_target_path(recipe_scanner, target_path)
|
||||||
|
|
||||||
|
results: list[dict[str, Any]] = []
|
||||||
|
success_count = 0
|
||||||
|
failure_count = 0
|
||||||
|
|
||||||
|
for recipe_id in recipe_ids:
|
||||||
|
try:
|
||||||
|
move_result = await self._move_recipe_files(
|
||||||
|
recipe_scanner=recipe_scanner,
|
||||||
|
recipe_id=str(recipe_id),
|
||||||
|
normalized_target=normalized_target,
|
||||||
|
recipes_root=recipes_root,
|
||||||
|
)
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"original_file_path": move_result.get("original_file_path"),
|
||||||
|
"new_file_path": move_result.get("new_file_path"),
|
||||||
|
"success": True,
|
||||||
|
"message": move_result.get("message", ""),
|
||||||
|
"folder": move_result.get("folder", ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
success_count += 1
|
||||||
|
except Exception as exc: # pragma: no cover - per-item error handling
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"recipe_id": recipe_id,
|
||||||
|
"original_file_path": None,
|
||||||
|
"new_file_path": None,
|
||||||
|
"success": False,
|
||||||
|
"message": str(exc),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
failure_count += 1
|
||||||
|
|
||||||
|
return PersistenceResult(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"message": f"Moved {success_count} of {len(recipe_ids)} recipes",
|
||||||
|
"results": results,
|
||||||
|
"success_count": success_count,
|
||||||
|
"failure_count": failure_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
async def reconnect_lora(
|
async def reconnect_lora(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -197,8 +351,8 @@ class RecipePersistenceService:
|
|||||||
) -> PersistenceResult:
|
) -> PersistenceResult:
|
||||||
"""Reconnect a LoRA entry within an existing recipe."""
|
"""Reconnect a LoRA entry within an existing recipe."""
|
||||||
|
|
||||||
recipe_path = os.path.join(recipe_scanner.recipes_dir, f"{recipe_id}.recipe.json")
|
recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||||
if not os.path.exists(recipe_path):
|
if not recipe_path or not os.path.exists(recipe_path):
|
||||||
raise RecipeNotFoundError("Recipe not found")
|
raise RecipeNotFoundError("Recipe not found")
|
||||||
|
|
||||||
target_lora = await recipe_scanner.get_local_lora(target_name)
|
target_lora = await recipe_scanner.get_local_lora(target_name)
|
||||||
@@ -243,16 +397,12 @@ class RecipePersistenceService:
|
|||||||
if not recipe_ids:
|
if not recipe_ids:
|
||||||
raise RecipeValidationError("No recipe IDs provided")
|
raise RecipeValidationError("No recipe IDs provided")
|
||||||
|
|
||||||
recipes_dir = recipe_scanner.recipes_dir
|
|
||||||
if not recipes_dir or not os.path.exists(recipes_dir):
|
|
||||||
raise RecipeNotFoundError("Recipes directory not found")
|
|
||||||
|
|
||||||
deleted_recipes: list[str] = []
|
deleted_recipes: list[str] = []
|
||||||
failed_recipes: list[dict[str, Any]] = []
|
failed_recipes: list[dict[str, Any]] = []
|
||||||
|
|
||||||
for recipe_id in recipe_ids:
|
for recipe_id in recipe_ids:
|
||||||
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
|
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||||
if not os.path.exists(recipe_json_path):
|
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||||
failed_recipes.append({"id": recipe_id, "reason": "Recipe not found"})
|
failed_recipes.append({"id": recipe_id, "reason": "Recipe not found"})
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class TranslationKeySynchronizer:
|
|||||||
self.locales_dir = locales_dir
|
self.locales_dir = locales_dir
|
||||||
self.verbose = verbose
|
self.verbose = verbose
|
||||||
self.reference_locale = 'en'
|
self.reference_locale = 'en'
|
||||||
self.target_locales = ['zh-CN', 'zh-TW', 'ja', 'ru', 'de', 'fr', 'es', 'ko']
|
self.target_locales = ['zh-CN', 'zh-TW', 'ja', 'ru', 'de', 'fr', 'es', 'ko', 'he']
|
||||||
|
|
||||||
def log(self, message: str, level: str = 'INFO'):
|
def log(self, message: str, level: str = 'INFO'):
|
||||||
"""Log a message if verbose mode is enabled."""
|
"""Log a message if verbose mode is enabled."""
|
||||||
|
|||||||
@@ -2,6 +2,35 @@ import { RecipeCard } from '../components/RecipeCard.js';
|
|||||||
import { state, getCurrentPageState } from '../state/index.js';
|
import { state, getCurrentPageState } from '../state/index.js';
|
||||||
import { showToast } from '../utils/uiHelpers.js';
|
import { showToast } from '../utils/uiHelpers.js';
|
||||||
|
|
||||||
|
const RECIPE_ENDPOINTS = {
|
||||||
|
list: '/api/lm/recipes',
|
||||||
|
detail: '/api/lm/recipe',
|
||||||
|
scan: '/api/lm/recipes/scan',
|
||||||
|
update: '/api/lm/recipe',
|
||||||
|
roots: '/api/lm/recipes/roots',
|
||||||
|
folders: '/api/lm/recipes/folders',
|
||||||
|
folderTree: '/api/lm/recipes/folder-tree',
|
||||||
|
unifiedFolderTree: '/api/lm/recipes/unified-folder-tree',
|
||||||
|
move: '/api/lm/recipe/move',
|
||||||
|
moveBulk: '/api/lm/recipes/move-bulk',
|
||||||
|
bulkDelete: '/api/lm/recipes/bulk-delete',
|
||||||
|
};
|
||||||
|
|
||||||
|
const RECIPE_SIDEBAR_CONFIG = {
|
||||||
|
config: {
|
||||||
|
displayName: 'Recipe',
|
||||||
|
supportsMove: true,
|
||||||
|
},
|
||||||
|
endpoints: RECIPE_ENDPOINTS,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function extractRecipeId(filePath) {
|
||||||
|
if (!filePath) return null;
|
||||||
|
const basename = filePath.split('/').pop().split('\\').pop();
|
||||||
|
const dotIndex = basename.lastIndexOf('.');
|
||||||
|
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch recipes with pagination for virtual scrolling
|
* Fetch recipes with pagination for virtual scrolling
|
||||||
* @param {number} page - Page number to fetch
|
* @param {number} page - Page number to fetch
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHe
|
|||||||
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||||
import { updateRecipeMetadata } from '../../api/recipeApi.js';
|
import { updateRecipeMetadata } from '../../api/recipeApi.js';
|
||||||
import { state } from '../../state/index.js';
|
import { state } from '../../state/index.js';
|
||||||
|
import { moveManager } from '../../managers/MoveManager.js';
|
||||||
|
|
||||||
export class RecipeContextMenu extends BaseContextMenu {
|
export class RecipeContextMenu extends BaseContextMenu {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -77,6 +78,9 @@ export class RecipeContextMenu extends BaseContextMenu {
|
|||||||
// Share recipe
|
// Share recipe
|
||||||
this.currentCard.querySelector('.fa-share-alt')?.click();
|
this.currentCard.querySelector('.fa-share-alt')?.click();
|
||||||
break;
|
break;
|
||||||
|
case 'move':
|
||||||
|
moveManager.showMoveModal(this.currentCard.dataset.filepath);
|
||||||
|
break;
|
||||||
case 'delete':
|
case 'delete':
|
||||||
// Delete recipe
|
// Delete recipe
|
||||||
this.currentCard.querySelector('.fa-trash')?.click();
|
this.currentCard.querySelector('.fa-trash')?.click();
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
// Recipe Card Component
|
// Recipe Card Component
|
||||||
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
|
import { showToast, copyToClipboard, sendLoraToWorkflow } from '../utils/uiHelpers.js';
|
||||||
|
import { updateRecipeMetadata } from '../api/recipeApi.js';
|
||||||
import { configureModelCardVideo } from './shared/ModelCard.js';
|
import { configureModelCardVideo } from './shared/ModelCard.js';
|
||||||
import { modalManager } from '../managers/ModalManager.js';
|
import { modalManager } from '../managers/ModalManager.js';
|
||||||
import { getCurrentPageState } from '../state/index.js';
|
import { getCurrentPageState } from '../state/index.js';
|
||||||
import { state } from '../state/index.js';
|
import { state } from '../state/index.js';
|
||||||
|
import { bulkManager } from '../managers/BulkManager.js';
|
||||||
import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js';
|
import { NSFW_LEVELS, getBaseModelAbbreviation } from '../utils/constants.js';
|
||||||
|
|
||||||
class RecipeCard {
|
class RecipeCard {
|
||||||
@@ -43,8 +45,11 @@ class RecipeCard {
|
|||||||
(this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` :
|
(this.recipe.file_path ? `/loras_static/root1/preview/${this.recipe.file_path.split('/').pop()}` :
|
||||||
'/loras_static/images/no-preview.png');
|
'/loras_static/images/no-preview.png');
|
||||||
|
|
||||||
|
const isDuplicatesMode = getCurrentPageState().duplicatesMode;
|
||||||
|
const autoplayOnHover = state?.global?.settings?.autoplay_on_hover === true;
|
||||||
|
const isFavorite = this.recipe.favorite === true;
|
||||||
|
|
||||||
// Video preview logic
|
// Video preview logic
|
||||||
const autoplayOnHover = state.settings.autoplay_on_hover || false;
|
|
||||||
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
|
const isVideo = previewUrl.endsWith('.mp4') || previewUrl.endsWith('.webm');
|
||||||
const videoAttrs = [
|
const videoAttrs = [
|
||||||
'controls',
|
'controls',
|
||||||
@@ -59,10 +64,6 @@ class RecipeCard {
|
|||||||
videoAttrs.push('data-autoplay="true"');
|
videoAttrs.push('data-autoplay="true"');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if in duplicates mode
|
|
||||||
const pageState = getCurrentPageState();
|
|
||||||
const isDuplicatesMode = pageState.duplicatesMode;
|
|
||||||
|
|
||||||
// NSFW blur logic - similar to LoraCard
|
// NSFW blur logic - similar to LoraCard
|
||||||
const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0;
|
const nsfwLevel = this.recipe.preview_nsfw_level !== undefined ? this.recipe.preview_nsfw_level : 0;
|
||||||
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
const shouldBlur = state.settings.blur_mature_content && nsfwLevel > NSFW_LEVELS.PG13;
|
||||||
@@ -95,6 +96,7 @@ class RecipeCard {
|
|||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${baseModelLabel}">${baseModelDisplay}</span>
|
<span class="base-model-label ${shouldBlur ? 'with-toggle' : ''}" title="${baseModelLabel}">${baseModelDisplay}</span>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
|
<i class="${isFavorite ? 'fas fa-star favorite-active' : 'far fa-star'}" title="${isFavorite ? 'Remove from Favorites' : 'Add to Favorites'}"></i>
|
||||||
<i class="fas fa-share-alt" title="Share Recipe"></i>
|
<i class="fas fa-share-alt" title="Share Recipe"></i>
|
||||||
<i class="fas fa-paper-plane" title="Send Recipe to Workflow (Click: Append, Shift+Click: Replace)"></i>
|
<i class="fas fa-paper-plane" title="Send Recipe to Workflow (Click: Append, Shift+Click: Replace)"></i>
|
||||||
<i class="fas fa-trash" title="Delete Recipe"></i>
|
<i class="fas fa-trash" title="Delete Recipe"></i>
|
||||||
@@ -140,6 +142,67 @@ class RecipeCard {
|
|||||||
return `${missingCount} of ${totalCount} LoRAs missing`;
|
return `${missingCount} of ${totalCount} LoRAs missing`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async toggleFavorite(card) {
|
||||||
|
// Find the latest star icon in case the card was re-rendered
|
||||||
|
const getStarIcon = (c) => c.querySelector('.fa-star');
|
||||||
|
let starIcon = getStarIcon(card);
|
||||||
|
|
||||||
|
const isFavorite = this.recipe.favorite || false;
|
||||||
|
const newFavoriteState = !isFavorite;
|
||||||
|
|
||||||
|
// Update early to provide instant feedback and avoid race conditions with re-renders
|
||||||
|
this.recipe.favorite = newFavoriteState;
|
||||||
|
|
||||||
|
// Function to update icon state
|
||||||
|
const updateIconUI = (icon, state) => {
|
||||||
|
if (!icon) return;
|
||||||
|
if (state) {
|
||||||
|
icon.classList.remove('far');
|
||||||
|
icon.classList.add('fas', 'favorite-active');
|
||||||
|
icon.title = 'Remove from Favorites';
|
||||||
|
} else {
|
||||||
|
icon.classList.remove('fas', 'favorite-active');
|
||||||
|
icon.classList.add('far');
|
||||||
|
icon.title = 'Add to Favorites';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update current icon immediately
|
||||||
|
updateIconUI(starIcon, newFavoriteState);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await updateRecipeMetadata(this.recipe.file_path, {
|
||||||
|
favorite: newFavoriteState
|
||||||
|
});
|
||||||
|
|
||||||
|
// Status already updated, just show toast
|
||||||
|
if (newFavoriteState) {
|
||||||
|
showToast('modelCard.favorites.added', {}, 'success');
|
||||||
|
} else {
|
||||||
|
showToast('modelCard.favorites.removed', {}, 'success');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-find star icon after API call as VirtualScroller might have replaced the element
|
||||||
|
// During updateRecipeMetadata, VirtualScroller.updateSingleItem might have re-rendered the card
|
||||||
|
// We need to find the NEW element in the DOM to ensure we don't have a stale reference
|
||||||
|
// Though typically VirtualScroller handles the re-render with the NEW this.recipe.favorite
|
||||||
|
// we will check the DOM just to be sure if this instance's internal card is still what's in DOM
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update favorite status:', error);
|
||||||
|
// Revert local state on error
|
||||||
|
this.recipe.favorite = isFavorite;
|
||||||
|
|
||||||
|
// Re-find star icon in case of re-render during fault
|
||||||
|
const currentCard = card.ownerDocument.evaluate(
|
||||||
|
`.//*[@data-filepath="${this.recipe.file_path}"]`,
|
||||||
|
card.ownerDocument, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null
|
||||||
|
).singleNodeValue || card;
|
||||||
|
|
||||||
|
updateIconUI(getStarIcon(currentCard), isFavorite);
|
||||||
|
showToast('modelCard.favorites.updateFailed', {}, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
attachEventListeners(card, isDuplicatesMode, shouldBlur) {
|
attachEventListeners(card, isDuplicatesMode, shouldBlur) {
|
||||||
// Add blur toggle functionality if content should be blurred
|
// Add blur toggle functionality if content should be blurred
|
||||||
if (shouldBlur) {
|
if (shouldBlur) {
|
||||||
@@ -164,9 +227,19 @@ class RecipeCard {
|
|||||||
// Recipe card click event - only attach if not in duplicates mode
|
// Recipe card click event - only attach if not in duplicates mode
|
||||||
if (!isDuplicatesMode) {
|
if (!isDuplicatesMode) {
|
||||||
card.addEventListener('click', () => {
|
card.addEventListener('click', () => {
|
||||||
|
if (state.bulkMode) {
|
||||||
|
bulkManager.toggleCardSelection(card);
|
||||||
|
return;
|
||||||
|
}
|
||||||
this.clickHandler(this.recipe);
|
this.clickHandler(this.recipe);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Favorite button click event - prevent propagation to card
|
||||||
|
card.querySelector('.fa-star')?.addEventListener('click', (e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
this.toggleFavorite(card);
|
||||||
|
});
|
||||||
|
|
||||||
// Share button click event - prevent propagation to card
|
// Share button click event - prevent propagation to card
|
||||||
card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => {
|
card.querySelector('.fa-share-alt')?.addEventListener('click', (e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|||||||
@@ -77,7 +77,9 @@ export class SidebarManager {
|
|||||||
this.pageControls = pageControls;
|
this.pageControls = pageControls;
|
||||||
this.pageType = pageControls.pageType;
|
this.pageType = pageControls.pageType;
|
||||||
this.lastPageControls = pageControls;
|
this.lastPageControls = pageControls;
|
||||||
this.apiClient = getModelApiClient();
|
this.apiClient = pageControls?.getSidebarApiClient?.()
|
||||||
|
|| pageControls?.sidebarApiClient
|
||||||
|
|| getModelApiClient();
|
||||||
|
|
||||||
// Set initial sidebar state immediately (hidden by default)
|
// Set initial sidebar state immediately (hidden by default)
|
||||||
this.setInitialSidebarState();
|
this.setInitialSidebarState();
|
||||||
@@ -205,6 +207,10 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
initializeDragAndDrop() {
|
initializeDragAndDrop() {
|
||||||
|
if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!this.dragHandlersInitialized) {
|
if (!this.dragHandlersInitialized) {
|
||||||
document.addEventListener('dragstart', this.handleCardDragStart);
|
document.addEventListener('dragstart', this.handleCardDragStart);
|
||||||
document.addEventListener('dragend', this.handleCardDragEnd);
|
document.addEventListener('dragend', this.handleCardDragEnd);
|
||||||
@@ -416,7 +422,14 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!this.apiClient) {
|
if (!this.apiClient) {
|
||||||
this.apiClient = getModelApiClient();
|
this.apiClient = this.pageControls?.getSidebarApiClient?.()
|
||||||
|
|| this.pageControls?.sidebarApiClient
|
||||||
|
|| getModelApiClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.apiClient?.apiConfig?.config?.supportsMove === false) {
|
||||||
|
showToast('toast.models.moveFailed', { message: translate('sidebar.dragDrop.moveUnsupported', {}, 'Move not supported for this page') }, 'error');
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootPath = this.draggedRootPath ? this.draggedRootPath.replace(/\\/g, '/') : '';
|
const rootPath = this.draggedRootPath ? this.draggedRootPath.replace(/\\/g, '/') : '';
|
||||||
@@ -470,7 +483,9 @@ export class SidebarManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
this.apiClient = getModelApiClient();
|
this.apiClient = this.pageControls?.getSidebarApiClient?.()
|
||||||
|
|| this.pageControls?.sidebarApiClient
|
||||||
|
|| getModelApiClient();
|
||||||
|
|
||||||
// Set initial sidebar state immediately (hidden by default)
|
// Set initial sidebar state immediately (hidden by default)
|
||||||
this.setInitialSidebarState();
|
this.setInitialSidebarState();
|
||||||
|
|||||||
@@ -60,14 +60,12 @@ export class AppCore {
|
|||||||
initTheme();
|
initTheme();
|
||||||
initBackToTop();
|
initBackToTop();
|
||||||
|
|
||||||
// Initialize the bulk manager and context menu only if not on recipes page
|
// Initialize the bulk manager and context menu
|
||||||
if (state.currentPageType !== 'recipes') {
|
bulkManager.initialize();
|
||||||
bulkManager.initialize();
|
|
||||||
|
|
||||||
// Initialize bulk context menu
|
// Initialize bulk context menu
|
||||||
const bulkContextMenu = new BulkContextMenu();
|
const bulkContextMenu = new BulkContextMenu();
|
||||||
bulkManager.setBulkContextMenu(bulkContextMenu);
|
bulkManager.setBulkContextMenu(bulkContextMenu);
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize the example images manager
|
// Initialize the example images manager
|
||||||
exampleImagesManager.initialize();
|
exampleImagesManager.initialize();
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { state, getCurrentPageState } from '../state/index.js';
|
|||||||
import { modalManager } from './ModalManager.js';
|
import { modalManager } from './ModalManager.js';
|
||||||
import { bulkManager } from './BulkManager.js';
|
import { bulkManager } from './BulkManager.js';
|
||||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||||
|
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
|
||||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||||
import { sidebarManager } from '../components/SidebarManager.js';
|
import { sidebarManager } from '../components/SidebarManager.js';
|
||||||
|
|
||||||
@@ -12,11 +13,22 @@ class MoveManager {
|
|||||||
this.bulkFilePaths = null;
|
this.bulkFilePaths = null;
|
||||||
this.folderTreeManager = new FolderTreeManager();
|
this.folderTreeManager = new FolderTreeManager();
|
||||||
this.initialized = false;
|
this.initialized = false;
|
||||||
|
this.recipeApiClient = null;
|
||||||
|
|
||||||
// Bind methods
|
// Bind methods
|
||||||
this.updateTargetPath = this.updateTargetPath.bind(this);
|
this.updateTargetPath = this.updateTargetPath.bind(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_getApiClient(modelType = null) {
|
||||||
|
if (state.currentPageType === 'recipes') {
|
||||||
|
if (!this.recipeApiClient) {
|
||||||
|
this.recipeApiClient = new RecipeSidebarApiClient();
|
||||||
|
}
|
||||||
|
return this.recipeApiClient;
|
||||||
|
}
|
||||||
|
return getModelApiClient(modelType);
|
||||||
|
}
|
||||||
|
|
||||||
initializeEventListeners() {
|
initializeEventListeners() {
|
||||||
if (this.initialized) return;
|
if (this.initialized) return;
|
||||||
|
|
||||||
@@ -36,7 +48,7 @@ class MoveManager {
|
|||||||
this.currentFilePath = null;
|
this.currentFilePath = null;
|
||||||
this.bulkFilePaths = null;
|
this.bulkFilePaths = null;
|
||||||
|
|
||||||
const apiClient = getModelApiClient();
|
const apiClient = this._getApiClient(modelType);
|
||||||
const currentPageType = state.currentPageType;
|
const currentPageType = state.currentPageType;
|
||||||
const modelConfig = apiClient.apiConfig.config;
|
const modelConfig = apiClient.apiConfig.config;
|
||||||
|
|
||||||
@@ -121,7 +133,7 @@ class MoveManager {
|
|||||||
|
|
||||||
async initializeFolderTree() {
|
async initializeFolderTree() {
|
||||||
try {
|
try {
|
||||||
const apiClient = getModelApiClient();
|
const apiClient = this._getApiClient();
|
||||||
// Fetch unified folder tree
|
// Fetch unified folder tree
|
||||||
const treeData = await apiClient.fetchUnifiedFolderTree();
|
const treeData = await apiClient.fetchUnifiedFolderTree();
|
||||||
|
|
||||||
@@ -141,7 +153,7 @@ class MoveManager {
|
|||||||
updateTargetPath() {
|
updateTargetPath() {
|
||||||
const pathDisplay = document.getElementById('moveTargetPathDisplay');
|
const pathDisplay = document.getElementById('moveTargetPathDisplay');
|
||||||
const modelRoot = document.getElementById('moveModelRoot').value;
|
const modelRoot = document.getElementById('moveModelRoot').value;
|
||||||
const apiClient = getModelApiClient();
|
const apiClient = this._getApiClient();
|
||||||
const config = apiClient.apiConfig.config;
|
const config = apiClient.apiConfig.config;
|
||||||
|
|
||||||
let fullPath = modelRoot || `Select a ${config.displayName.toLowerCase()} root directory`;
|
let fullPath = modelRoot || `Select a ${config.displayName.toLowerCase()} root directory`;
|
||||||
@@ -158,7 +170,7 @@ class MoveManager {
|
|||||||
|
|
||||||
async moveModel() {
|
async moveModel() {
|
||||||
const selectedRoot = document.getElementById('moveModelRoot').value;
|
const selectedRoot = document.getElementById('moveModelRoot').value;
|
||||||
const apiClient = getModelApiClient();
|
const apiClient = this._getApiClient();
|
||||||
const config = apiClient.apiConfig.config;
|
const config = apiClient.apiConfig.config;
|
||||||
|
|
||||||
if (!selectedRoot) {
|
if (!selectedRoot) {
|
||||||
|
|||||||
@@ -2,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 };
|
||||||
|
|||||||
@@ -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; }
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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 %}
|
||||||
|
|||||||
114
tests/frontend/api/recipeApi.bulk.test.js
Normal file
114
tests/frontend/api/recipeApi.bulk.test.js
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
const showToastMock = vi.hoisted(() => vi.fn());
|
||||||
|
const loadingManagerMock = vi.hoisted(() => ({
|
||||||
|
showSimpleLoading: vi.fn(),
|
||||||
|
hide: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/utils/uiHelpers.js', () => {
|
||||||
|
return {
|
||||||
|
showToast: showToastMock,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/components/RecipeCard.js', () => ({
|
||||||
|
RecipeCard: vi.fn(() => ({ element: document.createElement('div') })),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../../../static/js/state/index.js', () => {
|
||||||
|
return {
|
||||||
|
state: {
|
||||||
|
loadingManager: loadingManagerMock,
|
||||||
|
},
|
||||||
|
getCurrentPageState: vi.fn(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
import { RecipeSidebarApiClient } from '../../../static/js/api/recipeApi.js';
|
||||||
|
|
||||||
|
describe('RecipeSidebarApiClient bulk operations', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
global.fetch = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete global.fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sends recipe IDs when moving in bulk', async () => {
|
||||||
|
const api = new RecipeSidebarApiClient();
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
recipe_id: 'abc',
|
||||||
|
original_file_path: '/recipes/abc.webp',
|
||||||
|
new_file_path: '/recipes/target/abc.webp',
|
||||||
|
success: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
success_count: 1,
|
||||||
|
failure_count: 0,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const results = await api.moveBulkModels(['/recipes/abc.webp'], '/target/folder');
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/lm/recipes/move-bulk',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const { body } = global.fetch.mock.calls[0][1];
|
||||||
|
expect(JSON.parse(body)).toEqual({
|
||||||
|
recipe_ids: ['abc'],
|
||||||
|
target_path: '/target/folder',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(showToastMock).toHaveBeenCalledWith(
|
||||||
|
'toast.api.bulkMoveSuccess',
|
||||||
|
{ successCount: 1, type: 'Recipe' },
|
||||||
|
'success'
|
||||||
|
);
|
||||||
|
expect(results[0].recipe_id).toBe('abc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('posts recipe IDs for bulk delete', async () => {
|
||||||
|
const api = new RecipeSidebarApiClient();
|
||||||
|
global.fetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({
|
||||||
|
success: true,
|
||||||
|
total_deleted: 2,
|
||||||
|
total_failed: 0,
|
||||||
|
failed: [],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await api.bulkDeleteModels(['/recipes/a.webp', '/recipes/b.webp']);
|
||||||
|
|
||||||
|
expect(global.fetch).toHaveBeenCalledWith(
|
||||||
|
'/api/lm/recipes/bulk-delete',
|
||||||
|
expect.objectContaining({
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsedBody = JSON.parse(global.fetch.mock.calls[0][1].body);
|
||||||
|
expect(parsedBody.recipe_ids).toEqual(['a', 'b']);
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
success: true,
|
||||||
|
deleted_count: 2,
|
||||||
|
failed_count: 0,
|
||||||
|
});
|
||||||
|
expect(loadingManagerMock.hide).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -252,13 +252,13 @@ describe('AppCore initialization flow', () => {
|
|||||||
expect(onboardingManager.start).not.toHaveBeenCalled();
|
expect(onboardingManager.start).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('skips bulk setup when viewing recipes', async () => {
|
it('initializes bulk setup when viewing recipes', async () => {
|
||||||
state.currentPageType = 'recipes';
|
state.currentPageType = 'recipes';
|
||||||
|
|
||||||
await appCore.initialize();
|
await appCore.initialize();
|
||||||
|
|
||||||
expect(bulkManager.initialize).not.toHaveBeenCalled();
|
expect(bulkManager.initialize).toHaveBeenCalledTimes(1);
|
||||||
expect(BulkContextMenu).not.toHaveBeenCalled();
|
expect(BulkContextMenu).toHaveBeenCalledTimes(1);
|
||||||
expect(bulkManager.setBulkContextMenu).not.toHaveBeenCalled();
|
expect(bulkManager.setBulkContextMenu).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ const removeSessionItemMock = vi.fn();
|
|||||||
const RecipeContextMenuMock = vi.fn();
|
const RecipeContextMenuMock = vi.fn();
|
||||||
const refreshVirtualScrollMock = vi.fn();
|
const refreshVirtualScrollMock = vi.fn();
|
||||||
const refreshRecipesMock = vi.fn();
|
const refreshRecipesMock = vi.fn();
|
||||||
|
const fetchUnifiedFolderTreeMock = vi.fn();
|
||||||
|
const fetchModelFoldersMock = vi.fn();
|
||||||
|
|
||||||
let importManagerInstance;
|
let importManagerInstance;
|
||||||
let recipeModalInstance;
|
let recipeModalInstance;
|
||||||
@@ -35,6 +37,15 @@ vi.mock('../../../static/js/components/RecipeModal.js', () => ({
|
|||||||
|
|
||||||
vi.mock('../../../static/js/state/index.js', () => ({
|
vi.mock('../../../static/js/state/index.js', () => ({
|
||||||
getCurrentPageState: getCurrentPageStateMock,
|
getCurrentPageState: getCurrentPageStateMock,
|
||||||
|
state: {
|
||||||
|
currentPageType: 'recipes',
|
||||||
|
global: { settings: {} },
|
||||||
|
virtualScroller: {
|
||||||
|
removeItemByFilePath: vi.fn(),
|
||||||
|
updateSingleItem: vi.fn(),
|
||||||
|
refreshWithData: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||||
@@ -56,6 +67,14 @@ vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
|||||||
|
|
||||||
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||||
refreshRecipes: refreshRecipesMock,
|
refreshRecipes: refreshRecipesMock,
|
||||||
|
RecipeSidebarApiClient: vi.fn(() => ({
|
||||||
|
apiConfig: { config: { displayName: 'Recipes', supportsMove: true } },
|
||||||
|
fetchUnifiedFolderTree: fetchUnifiedFolderTreeMock.mockResolvedValue({ success: true, tree: {} }),
|
||||||
|
fetchModelFolders: fetchModelFoldersMock.mockResolvedValue({ success: true, folders: [] }),
|
||||||
|
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/recipes'] }),
|
||||||
|
moveBulkModels: vi.fn(),
|
||||||
|
moveSingleModel: vi.fn(),
|
||||||
|
})),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('RecipeManager', () => {
|
describe('RecipeManager', () => {
|
||||||
@@ -81,7 +100,7 @@ describe('RecipeManager', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
pageState = {
|
pageState = {
|
||||||
sortBy: 'date',
|
sortBy: 'date:desc',
|
||||||
searchOptions: undefined,
|
searchOptions: undefined,
|
||||||
customFilter: undefined,
|
customFilter: undefined,
|
||||||
duplicatesMode: false,
|
duplicatesMode: false,
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -69,6 +69,10 @@ class StubRecipeScanner:
|
|||||||
async def get_recipe_by_id(self, recipe_id: str) -> Optional[Dict[str, Any]]:
|
async def get_recipe_by_id(self, recipe_id: str) -> Optional[Dict[str, Any]]:
|
||||||
return self.recipes.get(recipe_id)
|
return self.recipes.get(recipe_id)
|
||||||
|
|
||||||
|
async def get_recipe_json_path(self, recipe_id: str) -> Optional[str]:
|
||||||
|
candidate = Path(self.recipes_dir) / f"{recipe_id}.recipe.json"
|
||||||
|
return str(candidate) if candidate.exists() else None
|
||||||
|
|
||||||
async def remove_recipe(self, recipe_id: str) -> None:
|
async def remove_recipe(self, recipe_id: str) -> None:
|
||||||
self.removed.append(recipe_id)
|
self.removed.append(recipe_id)
|
||||||
self.recipes.pop(recipe_id, None)
|
self.recipes.pop(recipe_id, None)
|
||||||
@@ -87,6 +91,7 @@ class StubAnalysisService:
|
|||||||
self.remote_calls: List[Optional[str]] = []
|
self.remote_calls: List[Optional[str]] = []
|
||||||
self.local_calls: List[Optional[str]] = []
|
self.local_calls: List[Optional[str]] = []
|
||||||
self.result = SimpleNamespace(payload={"loras": []}, status=200)
|
self.result = SimpleNamespace(payload={"loras": []}, status=200)
|
||||||
|
self._recipe_parser_factory = None
|
||||||
StubAnalysisService.instances.append(self)
|
StubAnalysisService.instances.append(self)
|
||||||
|
|
||||||
async def analyze_uploaded_image(self, *, image_bytes: bytes | None, recipe_scanner) -> SimpleNamespace: # noqa: D401 - mirrors real signature
|
async def analyze_uploaded_image(self, *, image_bytes: bytes | None, recipe_scanner) -> SimpleNamespace: # noqa: D401 - mirrors real signature
|
||||||
@@ -119,6 +124,7 @@ class StubPersistenceService:
|
|||||||
def __init__(self, **_: Any) -> None:
|
def __init__(self, **_: Any) -> None:
|
||||||
self.save_calls: List[Dict[str, Any]] = []
|
self.save_calls: List[Dict[str, Any]] = []
|
||||||
self.delete_calls: List[str] = []
|
self.delete_calls: List[str] = []
|
||||||
|
self.move_calls: List[Dict[str, str]] = []
|
||||||
self.save_result = SimpleNamespace(payload={"success": True, "recipe_id": "stub-id"}, status=200)
|
self.save_result = SimpleNamespace(payload={"success": True, "recipe_id": "stub-id"}, status=200)
|
||||||
self.delete_result = SimpleNamespace(payload={"success": True}, status=200)
|
self.delete_result = SimpleNamespace(payload={"success": True}, status=200)
|
||||||
StubPersistenceService.instances.append(self)
|
StubPersistenceService.instances.append(self)
|
||||||
@@ -142,6 +148,12 @@ class StubPersistenceService:
|
|||||||
await recipe_scanner.remove_recipe(recipe_id)
|
await recipe_scanner.remove_recipe(recipe_id)
|
||||||
return self.delete_result
|
return self.delete_result
|
||||||
|
|
||||||
|
async def move_recipe(self, *, recipe_scanner, recipe_id: str, target_path: str) -> SimpleNamespace: # noqa: D401
|
||||||
|
self.move_calls.append({"recipe_id": recipe_id, "target_path": target_path})
|
||||||
|
return SimpleNamespace(
|
||||||
|
payload={"success": True, "recipe_id": recipe_id, "new_file_path": target_path}, status=200
|
||||||
|
)
|
||||||
|
|
||||||
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: Dict[str, Any]) -> SimpleNamespace: # pragma: no cover - unused by smoke tests
|
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: Dict[str, Any]) -> SimpleNamespace: # pragma: no cover - unused by smoke tests
|
||||||
return SimpleNamespace(payload={"success": True, "recipe_id": recipe_id, "updates": updates}, status=200)
|
return SimpleNamespace(payload={"success": True, "recipe_id": recipe_id, "updates": updates}, status=200)
|
||||||
|
|
||||||
@@ -310,6 +322,21 @@ async def test_save_and_delete_recipe_round_trip(monkeypatch, tmp_path: Path) ->
|
|||||||
assert harness.persistence.delete_calls == ["saved-id"]
|
assert harness.persistence.delete_calls == ["saved-id"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_move_recipe_invokes_persistence(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
response = await harness.client.post(
|
||||||
|
"/api/lm/recipe/move",
|
||||||
|
json={"recipe_id": "move-me", "target_path": str(tmp_path / "recipes" / "subdir")},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await response.json()
|
||||||
|
assert response.status == 200
|
||||||
|
assert payload["recipe_id"] == "move-me"
|
||||||
|
assert harness.persistence.move_calls == [
|
||||||
|
{"recipe_id": "move-me", "target_path": str(tmp_path / "recipes" / "subdir")}
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
async def test_import_remote_recipe(monkeypatch, tmp_path: Path) -> None:
|
async def test_import_remote_recipe(monkeypatch, tmp_path: Path) -> None:
|
||||||
provider_calls: list[int] = []
|
provider_calls: list[int] = []
|
||||||
|
|
||||||
@@ -501,3 +528,69 @@ async def test_share_and_download_recipe(monkeypatch, tmp_path: Path) -> None:
|
|||||||
assert body == b"stub"
|
assert body == b"stub"
|
||||||
|
|
||||||
download_path.unlink(missing_ok=True)
|
download_path.unlink(missing_ok=True)
|
||||||
|
async def test_import_remote_recipe_merges_metadata(monkeypatch, tmp_path: Path) -> None:
|
||||||
|
# 1. Mock Metadata Provider
|
||||||
|
class Provider:
|
||||||
|
async def get_model_version_info(self, model_version_id):
|
||||||
|
return {"baseModel": "Flux Provider"}, None
|
||||||
|
|
||||||
|
async def fake_get_default_metadata_provider():
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(recipe_handlers, "get_default_metadata_provider", fake_get_default_metadata_provider)
|
||||||
|
|
||||||
|
# 2. Mock ExifUtils to return some embedded metadata
|
||||||
|
class MockExifUtils:
|
||||||
|
@staticmethod
|
||||||
|
def extract_image_metadata(path):
|
||||||
|
return "Recipe metadata: " + json.dumps({
|
||||||
|
"gen_params": {"prompt": "from embedded", "seed": 123}
|
||||||
|
})
|
||||||
|
|
||||||
|
monkeypatch.setattr(recipe_handlers, "ExifUtils", MockExifUtils)
|
||||||
|
|
||||||
|
# 3. Mock Parser Factory for StubAnalysisService
|
||||||
|
class MockParser:
|
||||||
|
async def parse_metadata(self, raw, recipe_scanner=None):
|
||||||
|
return json.loads(raw[len("Recipe metadata: "):])
|
||||||
|
|
||||||
|
class MockFactory:
|
||||||
|
def create_parser(self, raw):
|
||||||
|
if raw.startswith("Recipe metadata: "):
|
||||||
|
return MockParser()
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 4. Setup Harness and run test
|
||||||
|
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||||
|
harness.analysis._recipe_parser_factory = MockFactory()
|
||||||
|
|
||||||
|
# Civitai meta via image_info
|
||||||
|
harness.civitai.image_info["1"] = {
|
||||||
|
"id": 1,
|
||||||
|
"url": "https://example.com/images/1.jpg",
|
||||||
|
"meta": {"prompt": "from civitai", "cfg": 7.0}
|
||||||
|
}
|
||||||
|
|
||||||
|
resources = []
|
||||||
|
response = await harness.client.get(
|
||||||
|
"/api/lm/recipes/import-remote",
|
||||||
|
params={
|
||||||
|
"image_url": "https://civitai.com/images/1",
|
||||||
|
"name": "Merged Recipe",
|
||||||
|
"resources": json.dumps(resources),
|
||||||
|
"gen_params": json.dumps({"prompt": "from request", "steps": 25}),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await response.json()
|
||||||
|
assert response.status == 200
|
||||||
|
|
||||||
|
call = harness.persistence.save_calls[-1]
|
||||||
|
metadata = call["metadata"]
|
||||||
|
gen_params = metadata["gen_params"]
|
||||||
|
|
||||||
|
# Priority: request (prompt=request, steps=25) > civitai (prompt=civitai, cfg=7.0) > embedded (prompt=embedded, seed=123)
|
||||||
|
assert gen_params["prompt"] == "from request"
|
||||||
|
assert gen_params["steps"] == 25
|
||||||
|
assert gen_params["cfg"] == 7.0
|
||||||
|
assert gen_params["seed"] == 123
|
||||||
|
|||||||
113
tests/services/test_comfy_metadata_parser.py
Normal file
113
tests/services/test_comfy_metadata_parser.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import pytest
|
||||||
|
import json
|
||||||
|
from py.recipes.parsers.comfy import ComfyMetadataParser
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_without_loras(monkeypatch):
|
||||||
|
checkpoint_info = {
|
||||||
|
"id": 2224012,
|
||||||
|
"modelId": 1908679,
|
||||||
|
"model": {"name": "SDXL Checkpoint", "type": "checkpoint"},
|
||||||
|
"name": "v1.0",
|
||||||
|
"images": [{"url": "https://image.civitai.com/checkpoints/original=true"}],
|
||||||
|
"baseModel": "sdxl",
|
||||||
|
"downloadUrl": "https://civitai.com/api/download/checkpoint",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_version_info(self, version_id):
|
||||||
|
assert version_id == "2224012"
|
||||||
|
return checkpoint_info, None
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.comfy.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = ComfyMetadataParser()
|
||||||
|
|
||||||
|
# User provided metadata
|
||||||
|
metadata_json = {
|
||||||
|
"resource-stack": {
|
||||||
|
"class_type": "CheckpointLoaderSimple",
|
||||||
|
"inputs": {"ckpt_name": "urn:air:sdxl:checkpoint:civitai:1908679@2224012"}
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"class_type": "smZ CLIPTextEncode",
|
||||||
|
"inputs": {"text": "Positive prompt content"},
|
||||||
|
"_meta": {"title": "Positive"}
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"class_type": "smZ CLIPTextEncode",
|
||||||
|
"inputs": {"text": "Negative prompt content"},
|
||||||
|
"_meta": {"title": "Negative"}
|
||||||
|
},
|
||||||
|
"11": {
|
||||||
|
"class_type": "KSampler",
|
||||||
|
"inputs": {
|
||||||
|
"sampler_name": "euler_ancestral",
|
||||||
|
"scheduler": "normal",
|
||||||
|
"seed": 904124997,
|
||||||
|
"steps": 35,
|
||||||
|
"cfg": 6,
|
||||||
|
"denoise": 0.1,
|
||||||
|
"model": ["resource-stack", 0],
|
||||||
|
"positive": ["6", 0],
|
||||||
|
"negative": ["7", 0],
|
||||||
|
"latent_image": ["21", 0]
|
||||||
|
},
|
||||||
|
"_meta": {"title": "KSampler"}
|
||||||
|
},
|
||||||
|
"extraMetadata": json.dumps({
|
||||||
|
"prompt": "One woman, (solo:1.3), ...",
|
||||||
|
"negativePrompt": "lowres, worst quality, ...",
|
||||||
|
"steps": 35,
|
||||||
|
"cfgScale": 6,
|
||||||
|
"sampler": "euler_ancestral",
|
||||||
|
"seed": 904124997,
|
||||||
|
"width": 1024,
|
||||||
|
"height": 1024
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await parser.parse_metadata(json.dumps(metadata_json))
|
||||||
|
|
||||||
|
assert "error" not in result
|
||||||
|
assert result["loras"] == []
|
||||||
|
assert result["checkpoint"] is not None
|
||||||
|
assert int(result["checkpoint"]["modelId"]) == 1908679
|
||||||
|
assert int(result["checkpoint"]["id"]) == 2224012
|
||||||
|
assert result["gen_params"]["prompt"] == "One woman, (solo:1.3), ..."
|
||||||
|
assert result["gen_params"]["steps"] == 35
|
||||||
|
assert result["gen_params"]["size"] == "1024x1024"
|
||||||
|
assert result["from_comfy_metadata"] is True
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_parse_metadata_without_extra_metadata(monkeypatch):
|
||||||
|
async def fake_metadata_provider():
|
||||||
|
class Provider:
|
||||||
|
async def get_model_version_info(self, version_id):
|
||||||
|
return {"model": {"name": "Test"}, "id": version_id}, None
|
||||||
|
return Provider()
|
||||||
|
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"py.recipes.parsers.comfy.get_default_metadata_provider",
|
||||||
|
fake_metadata_provider,
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = ComfyMetadataParser()
|
||||||
|
|
||||||
|
metadata_json = {
|
||||||
|
"node_1": {
|
||||||
|
"class_type": "CheckpointLoaderSimple",
|
||||||
|
"inputs": {"ckpt_name": "urn:air:sdxl:checkpoint:civitai:123@456"}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result = await parser.parse_metadata(json.dumps(metadata_json))
|
||||||
|
|
||||||
|
assert "error" not in result
|
||||||
|
assert result["loras"] == []
|
||||||
|
assert result["checkpoint"]["id"] == "456"
|
||||||
59
tests/services/test_gen_params_merger.py
Normal file
59
tests/services/test_gen_params_merger.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import pytest
|
||||||
|
from py.recipes.merger import GenParamsMerger
|
||||||
|
|
||||||
|
def test_merge_priority():
|
||||||
|
request_params = {"prompt": "from request", "steps": 20}
|
||||||
|
civitai_meta = {"prompt": "from civitai", "cfg": 7.0}
|
||||||
|
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
||||||
|
|
||||||
|
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
|
||||||
|
|
||||||
|
assert merged["prompt"] == "from request"
|
||||||
|
assert merged["steps"] == 20
|
||||||
|
assert merged["cfg"] == 7.0
|
||||||
|
assert merged["seed"] == 123
|
||||||
|
|
||||||
|
def test_merge_no_request_params():
|
||||||
|
civitai_meta = {"prompt": "from civitai", "cfg": 7.0}
|
||||||
|
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
||||||
|
|
||||||
|
merged = GenParamsMerger.merge(None, civitai_meta, embedded_metadata)
|
||||||
|
|
||||||
|
assert merged["prompt"] == "from civitai"
|
||||||
|
assert merged["cfg"] == 7.0
|
||||||
|
assert merged["seed"] == 123
|
||||||
|
|
||||||
|
def test_merge_only_embedded():
|
||||||
|
embedded_metadata = {"gen_params": {"prompt": "from embedded", "seed": 123}}
|
||||||
|
|
||||||
|
merged = GenParamsMerger.merge(None, None, embedded_metadata)
|
||||||
|
|
||||||
|
assert merged["prompt"] == "from embedded"
|
||||||
|
assert merged["seed"] == 123
|
||||||
|
|
||||||
|
def test_merge_raw_embedded():
|
||||||
|
# Test when embedded metadata is just the gen_params themselves
|
||||||
|
embedded_metadata = {"prompt": "from raw embedded", "seed": 456}
|
||||||
|
|
||||||
|
merged = GenParamsMerger.merge(None, None, embedded_metadata)
|
||||||
|
|
||||||
|
assert merged["prompt"] == "from raw embedded"
|
||||||
|
assert merged["seed"] == 456
|
||||||
|
|
||||||
|
def test_merge_none_values():
|
||||||
|
merged = GenParamsMerger.merge(None, None, None)
|
||||||
|
assert merged == {}
|
||||||
|
|
||||||
|
def test_merge_filters_blacklisted_keys():
|
||||||
|
request_params = {"prompt": "test", "id": "should-be-removed"}
|
||||||
|
civitai_meta = {"cfg": 7, "url": "remove-me"}
|
||||||
|
embedded_metadata = {"seed": 123, "hash": "remove-also"}
|
||||||
|
|
||||||
|
merged = GenParamsMerger.merge(request_params, civitai_meta, embedded_metadata)
|
||||||
|
|
||||||
|
assert "prompt" in merged
|
||||||
|
assert "cfg" in merged
|
||||||
|
assert "seed" in merged
|
||||||
|
assert "id" not in merged
|
||||||
|
assert "url" not in merged
|
||||||
|
assert "hash" not in merged
|
||||||
@@ -502,7 +502,142 @@ async def test_update_lora_filename_by_hash_updates_affected_recipes(tmp_path: P
|
|||||||
persisted2 = json.loads(recipe2_path.read_text())
|
persisted2 = json.loads(recipe2_path.read_text())
|
||||||
assert persisted2["loras"][0]["file_name"] == "other_lora"
|
assert persisted2["loras"][0]["file_name"] == "other_lora"
|
||||||
|
|
||||||
# Check cache
|
|
||||||
cache = await scanner.get_cached_data()
|
cache = await scanner.get_cached_data()
|
||||||
cached1 = next(r for r in cache.raw_data if r["id"] == recipe1_id)
|
cached1 = next(r for r in cache.raw_data if r["id"] == recipe1_id)
|
||||||
assert cached1["loras"][0]["file_name"] == new_name
|
assert cached1["loras"][0]["file_name"] == new_name
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_paginated_data_filters_by_favorite(recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
|
||||||
|
# Add a normal recipe
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "regular",
|
||||||
|
"file_path": "path/regular.png",
|
||||||
|
"title": "Regular Recipe",
|
||||||
|
"modified": 1.0,
|
||||||
|
"created_date": 1.0,
|
||||||
|
"loras": [],
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add a favorite recipe
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "favorite",
|
||||||
|
"file_path": "path/favorite.png",
|
||||||
|
"title": "Favorite Recipe",
|
||||||
|
"modified": 2.0,
|
||||||
|
"created_date": 2.0,
|
||||||
|
"loras": [],
|
||||||
|
"favorite": True
|
||||||
|
})
|
||||||
|
|
||||||
|
# Wait for cache update (it's async in some places, add_recipe is usually enough but let's be safe)
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
# Test without filter (should return both)
|
||||||
|
result_all = await scanner.get_paginated_data(page=1, page_size=10)
|
||||||
|
assert len(result_all["items"]) == 2
|
||||||
|
|
||||||
|
# Test with favorite filter
|
||||||
|
result_fav = await scanner.get_paginated_data(page=1, page_size=10, filters={"favorite": True})
|
||||||
|
assert len(result_fav["items"]) == 1
|
||||||
|
assert result_fav["items"][0]["id"] == "favorite"
|
||||||
|
|
||||||
|
# Test with favorite filter set to False (should return both or at least not filter if it's the default)
|
||||||
|
# Actually our implementation checks if 'favorite' in filters and filters['favorite']
|
||||||
|
result_fav_false = await scanner.get_paginated_data(page=1, page_size=10, filters={"favorite": False})
|
||||||
|
assert len(result_fav_false["items"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_paginated_data_filters_by_prompt(recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
|
||||||
|
# Add a recipe with a specific prompt
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "prompt-recipe",
|
||||||
|
"file_path": "path/prompt.png",
|
||||||
|
"title": "Prompt Recipe",
|
||||||
|
"modified": 1.0,
|
||||||
|
"created_date": 1.0,
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {
|
||||||
|
"prompt": "a beautiful forest landscape"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Add a recipe with a specific negative prompt
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "neg-prompt-recipe",
|
||||||
|
"file_path": "path/neg.png",
|
||||||
|
"title": "Negative Prompt Recipe",
|
||||||
|
"modified": 2.0,
|
||||||
|
"created_date": 2.0,
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {
|
||||||
|
"negative_prompt": "ugly, blurry mountains"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
# Test search in prompt
|
||||||
|
result_prompt = await scanner.get_paginated_data(
|
||||||
|
page=1, page_size=10, search="forest", search_options={"prompt": True}
|
||||||
|
)
|
||||||
|
assert len(result_prompt["items"]) == 1
|
||||||
|
assert result_prompt["items"][0]["id"] == "prompt-recipe"
|
||||||
|
|
||||||
|
# Test search in negative prompt
|
||||||
|
result_neg = await scanner.get_paginated_data(
|
||||||
|
page=1, page_size=10, search="mountains", search_options={"prompt": True}
|
||||||
|
)
|
||||||
|
assert len(result_neg["items"]) == 1
|
||||||
|
assert result_neg["items"][0]["id"] == "neg-prompt-recipe"
|
||||||
|
|
||||||
|
# Test search disabled (should not find by prompt)
|
||||||
|
result_disabled = await scanner.get_paginated_data(
|
||||||
|
page=1, page_size=10, search="forest", search_options={"prompt": False}
|
||||||
|
)
|
||||||
|
assert len(result_disabled["items"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_paginated_data_sorting(recipe_scanner):
|
||||||
|
scanner, _ = recipe_scanner
|
||||||
|
|
||||||
|
# Add test recipes
|
||||||
|
# Recipe A: Name "Alpha", Date 10, LoRAs 2
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "A", "title": "Alpha", "created_date": 10.0,
|
||||||
|
"loras": [{}, {}], "file_path": "a.png"
|
||||||
|
})
|
||||||
|
# Recipe B: Name "Beta", Date 20, LoRAs 1
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "B", "title": "Beta", "created_date": 20.0,
|
||||||
|
"loras": [{}], "file_path": "b.png"
|
||||||
|
})
|
||||||
|
# Recipe C: Name "Gamma", Date 5, LoRAs 3
|
||||||
|
await scanner.add_recipe({
|
||||||
|
"id": "C", "title": "Gamma", "created_date": 5.0,
|
||||||
|
"loras": [{}, {}, {}], "file_path": "c.png"
|
||||||
|
})
|
||||||
|
|
||||||
|
await asyncio.sleep(0)
|
||||||
|
|
||||||
|
# Test Name DESC: Gamma, Beta, Alpha
|
||||||
|
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="name:desc")
|
||||||
|
assert [i["id"] for i in res["items"]] == ["C", "B", "A"]
|
||||||
|
|
||||||
|
# Test LoRA Count DESC: Gamma (3), Alpha (2), Beta (1)
|
||||||
|
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="loras_count:desc")
|
||||||
|
assert [i["id"] for i in res["items"]] == ["C", "A", "B"]
|
||||||
|
|
||||||
|
# Test LoRA Count ASC: Beta (1), Alpha (2), Gamma (3)
|
||||||
|
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="loras_count:asc")
|
||||||
|
assert [i["id"] for i in res["items"]] == ["B", "A", "C"]
|
||||||
|
|
||||||
|
# Test Date ASC: Gamma (5), Alpha (10), Beta (20)
|
||||||
|
res = await scanner.get_paginated_data(page=1, page_size=10, sort_by="date:asc")
|
||||||
|
assert [i["id"] for i in res["items"]] == ["C", "A", "B"]
|
||||||
|
|||||||
@@ -403,6 +403,89 @@ async def test_save_recipe_from_widget_allows_empty_lora(tmp_path):
|
|||||||
assert scanner.added and scanner.added[0]["loras"] == []
|
assert scanner.added and scanner.added[0]["loras"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_move_recipe_updates_paths(tmp_path):
|
||||||
|
exif_utils = DummyExifUtils()
|
||||||
|
recipes_dir = tmp_path / "recipes"
|
||||||
|
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
recipe_id = "move-me"
|
||||||
|
image_path = recipes_dir / f"{recipe_id}.webp"
|
||||||
|
json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
|
||||||
|
image_path.write_bytes(b"img")
|
||||||
|
json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": str(image_path),
|
||||||
|
"title": "Recipe",
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {},
|
||||||
|
"created_date": 0,
|
||||||
|
"modified": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
class MoveScanner:
|
||||||
|
def __init__(self, root: Path):
|
||||||
|
self.recipes_dir = str(root)
|
||||||
|
self.recipe = {
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": str(image_path),
|
||||||
|
"title": "Recipe",
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {},
|
||||||
|
"created_date": 0,
|
||||||
|
"modified": 0,
|
||||||
|
"folder": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_recipe_by_id(self, target_id: str):
|
||||||
|
return self.recipe if target_id == recipe_id else None
|
||||||
|
|
||||||
|
async def get_recipe_json_path(self, target_id: str):
|
||||||
|
matches = list(Path(self.recipes_dir).rglob(f"{target_id}.recipe.json"))
|
||||||
|
return str(matches[0]) if matches else None
|
||||||
|
|
||||||
|
async def update_recipe_metadata(self, target_id: str, metadata: dict):
|
||||||
|
if target_id != recipe_id:
|
||||||
|
return False
|
||||||
|
self.recipe.update(metadata)
|
||||||
|
target_path = await self.get_recipe_json_path(target_id)
|
||||||
|
if not target_path:
|
||||||
|
return False
|
||||||
|
existing = json.loads(Path(target_path).read_text())
|
||||||
|
existing.update(metadata)
|
||||||
|
Path(target_path).write_text(json.dumps(existing))
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_cached_data(self, force_refresh: bool = False): # noqa: ARG002 - signature parity
|
||||||
|
return SimpleNamespace(raw_data=[self.recipe])
|
||||||
|
|
||||||
|
scanner = MoveScanner(recipes_dir)
|
||||||
|
service = RecipePersistenceService(
|
||||||
|
exif_utils=exif_utils,
|
||||||
|
card_preview_width=512,
|
||||||
|
logger=logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
target_folder = recipes_dir / "nested"
|
||||||
|
result = await service.move_recipe(
|
||||||
|
recipe_scanner=scanner, recipe_id=recipe_id, target_path=str(target_folder)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.payload["folder"] == "nested"
|
||||||
|
assert Path(result.payload["json_path"]).parent == target_folder
|
||||||
|
assert Path(result.payload["new_file_path"]).parent == target_folder
|
||||||
|
assert not json_path.exists()
|
||||||
|
|
||||||
|
stored = json.loads(Path(result.payload["json_path"]).read_text())
|
||||||
|
assert stored["folder"] == "nested"
|
||||||
|
assert stored["file_path"] == result.payload["new_file_path"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_analyze_remote_video(tmp_path):
|
async def test_analyze_remote_video(tmp_path):
|
||||||
exif_utils = DummyExifUtils()
|
exif_utils = DummyExifUtils()
|
||||||
|
|||||||
Reference in New Issue
Block a user