mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
feat: add recipe root directory and move recipe endpoints
- Add GET /api/lm/recipes/roots endpoint to retrieve recipe root directories - Add POST /api/lm/recipe/move endpoint to move recipes between directories - Register new endpoints in route definitions - Implement error handling for both new endpoints with proper status codes - Enable recipe management operations for better file organization
This commit is contained in:
@@ -638,7 +638,8 @@
|
||||
"recursiveUnavailable": "Rekursive Suche ist nur in der Baumansicht verfügbar",
|
||||
"collapseAllDisabled": "Im Listenmodus nicht verfügbar",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden."
|
||||
"unableToResolveRoot": "Zielpfad für das Verschieben konnte nicht ermittelt werden.",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1460,7 +1461,8 @@
|
||||
"bulkMoveFailures": "Fehlgeschlagene Verschiebungen:\n{failures}",
|
||||
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
|
||||
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
|
||||
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}"
|
||||
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
@@ -1478,4 +1480,4 @@
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@
|
||||
"korean": "한국어",
|
||||
"french": "Français",
|
||||
"spanish": "Español",
|
||||
"Hebrew": "עברית"
|
||||
"Hebrew": "עברית"
|
||||
},
|
||||
"fileSize": {
|
||||
"zero": "0 Bytes",
|
||||
@@ -336,7 +336,7 @@
|
||||
"templateOptions": {
|
||||
"flatStructure": "Flat Structure",
|
||||
"byBaseModel": "By Base Model",
|
||||
"byAuthor": "By Author",
|
||||
"byAuthor": "By Author",
|
||||
"byFirstTag": "By First Tag",
|
||||
"baseModelFirstTag": "Base Model + First Tag",
|
||||
"baseModelAuthor": "Base Model + Author",
|
||||
@@ -347,7 +347,7 @@
|
||||
"customTemplatePlaceholder": "Enter custom template (e.g., {base_model}/{author}/{first_tag})",
|
||||
"modelTypes": {
|
||||
"lora": "LoRA",
|
||||
"checkpoint": "Checkpoint",
|
||||
"checkpoint": "Checkpoint",
|
||||
"embedding": "Embedding"
|
||||
},
|
||||
"baseModelPathMappings": "Base Model Path Mappings",
|
||||
@@ -420,11 +420,11 @@
|
||||
"proxyHost": "Proxy Host",
|
||||
"proxyHostPlaceholder": "proxy.example.com",
|
||||
"proxyHostHelp": "The hostname or IP address of your proxy server",
|
||||
"proxyPort": "Proxy Port",
|
||||
"proxyPort": "Proxy Port",
|
||||
"proxyPortPlaceholder": "8080",
|
||||
"proxyPortHelp": "The port number of your proxy server",
|
||||
"proxyUsername": "Username (Optional)",
|
||||
"proxyUsernamePlaceholder": "username",
|
||||
"proxyUsernamePlaceholder": "username",
|
||||
"proxyUsernameHelp": "Username for proxy authentication (if required)",
|
||||
"proxyPassword": "Password (Optional)",
|
||||
"proxyPasswordPlaceholder": "password",
|
||||
@@ -638,7 +638,8 @@
|
||||
"recursiveUnavailable": "Recursive search is available in tree view only",
|
||||
"collapseAllDisabled": "Not available in list view",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "Unable to determine destination path for move."
|
||||
"unableToResolveRoot": "Unable to determine destination path for move.",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1460,7 +1461,8 @@
|
||||
"bulkMoveFailures": "Failed moves:\n{failures}",
|
||||
"bulkMoveSuccess": "Successfully moved {successCount} {type}s",
|
||||
"exampleImagesDownloadSuccess": "Successfully downloaded example images!",
|
||||
"exampleImagesDownloadFailed": "Failed to download example images: {message}"
|
||||
"exampleImagesDownloadFailed": "Failed to download example images: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
@@ -1478,4 +1480,4 @@
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -638,7 +638,8 @@
|
||||
"recursiveUnavailable": "La búsqueda recursiva solo está disponible en la vista en árbol",
|
||||
"collapseAllDisabled": "No disponible en vista de lista",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento."
|
||||
"unableToResolveRoot": "No se puede determinar la ruta de destino para el movimiento.",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1460,7 +1461,8 @@
|
||||
"bulkMoveFailures": "Movimientos fallidos:\n{failures}",
|
||||
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
|
||||
"exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!",
|
||||
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}"
|
||||
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
@@ -1478,4 +1480,4 @@
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -638,7 +638,8 @@
|
||||
"recursiveUnavailable": "La recherche récursive n'est disponible qu'en vue arborescente",
|
||||
"collapseAllDisabled": "Non disponible en vue liste",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement."
|
||||
"unableToResolveRoot": "Impossible de déterminer le chemin de destination pour le déplacement.",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1460,7 +1461,8 @@
|
||||
"bulkMoveFailures": "Échecs de déplacement :\n{failures}",
|
||||
"bulkMoveSuccess": "{successCount} {type}s déplacés avec succès",
|
||||
"exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !",
|
||||
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}"
|
||||
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
@@ -1478,4 +1480,4 @@
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -638,7 +638,8 @@
|
||||
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
|
||||
"collapseAllDisabled": "לא זמין בתצוגת רשימה",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה."
|
||||
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1460,7 +1461,8 @@
|
||||
"bulkMoveFailures": "העברות שנכשלו:\n{failures}",
|
||||
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
|
||||
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
|
||||
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}"
|
||||
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
@@ -1478,4 +1480,4 @@
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -638,7 +638,8 @@
|
||||
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
|
||||
"collapseAllDisabled": "リストビューでは利用できません",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "移動先のパスを特定できません。"
|
||||
"unableToResolveRoot": "移動先のパスを特定できません。",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1460,7 +1461,8 @@
|
||||
"bulkMoveFailures": "失敗した移動:\n{failures}",
|
||||
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
|
||||
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
|
||||
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}"
|
||||
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
@@ -1478,4 +1480,4 @@
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -638,7 +638,8 @@
|
||||
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
|
||||
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다."
|
||||
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1460,7 +1461,8 @@
|
||||
"bulkMoveFailures": "실패한 이동:\n{failures}",
|
||||
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
|
||||
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
|
||||
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}"
|
||||
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
@@ -1478,4 +1480,4 @@
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -638,7 +638,8 @@
|
||||
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
|
||||
"collapseAllDisabled": "Недоступно в виде списка",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения."
|
||||
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1460,7 +1461,8 @@
|
||||
"bulkMoveFailures": "Неудачные перемещения:\n{failures}",
|
||||
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
|
||||
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
|
||||
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}"
|
||||
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
@@ -1478,4 +1480,4 @@
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -638,7 +638,8 @@
|
||||
"recursiveUnavailable": "仅在树形视图中可使用递归搜索",
|
||||
"collapseAllDisabled": "列表视图下不可用",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "无法确定移动的目标路径。"
|
||||
"unableToResolveRoot": "无法确定移动的目标路径。",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1460,7 +1461,8 @@
|
||||
"bulkMoveFailures": "移动失败:\n{failures}",
|
||||
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
|
||||
"exampleImagesDownloadSuccess": "示例图片下载成功!",
|
||||
"exampleImagesDownloadFailed": "示例图片下载失败:{message}"
|
||||
"exampleImagesDownloadFailed": "示例图片下载失败:{message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
@@ -1478,4 +1480,4 @@
|
||||
"learnMore": "浏览器插件教程"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -638,7 +638,8 @@
|
||||
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
|
||||
"collapseAllDisabled": "列表檢視下不可用",
|
||||
"dragDrop": {
|
||||
"unableToResolveRoot": "無法確定移動的目標路徑。"
|
||||
"unableToResolveRoot": "無法確定移動的目標路徑。",
|
||||
"moveUnsupported": "Move is not supported for this item."
|
||||
}
|
||||
},
|
||||
"statistics": {
|
||||
@@ -1460,7 +1461,8 @@
|
||||
"bulkMoveFailures": "移動失敗:\n{failures}",
|
||||
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
|
||||
"exampleImagesDownloadSuccess": "範例圖片下載成功!",
|
||||
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}"
|
||||
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
|
||||
"moveFailed": "Failed to move item: {message}"
|
||||
}
|
||||
},
|
||||
"banners": {
|
||||
@@ -1478,4 +1480,4 @@
|
||||
"learnMore": "LM Civitai Extension Tutorial"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,7 @@ class RecipeHandlerSet:
|
||||
"delete_recipe": self.management.delete_recipe,
|
||||
"get_top_tags": self.query.get_top_tags,
|
||||
"get_base_models": self.query.get_base_models,
|
||||
"get_roots": self.query.get_roots,
|
||||
"get_folders": self.query.get_folders,
|
||||
"get_folder_tree": self.query.get_folder_tree,
|
||||
"get_unified_folder_tree": self.query.get_unified_folder_tree,
|
||||
@@ -69,6 +70,7 @@ class RecipeHandlerSet:
|
||||
"save_recipe_from_widget": self.management.save_recipe_from_widget,
|
||||
"get_recipes_for_lora": self.query.get_recipes_for_lora,
|
||||
"scan_recipes": self.query.scan_recipes,
|
||||
"move_recipe": self.management.move_recipe,
|
||||
}
|
||||
|
||||
|
||||
@@ -306,6 +308,19 @@ class RecipeQueryHandler:
|
||||
self._logger.error("Error retrieving base models: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_roots(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
roots = [recipe_scanner.recipes_dir] if recipe_scanner.recipes_dir else []
|
||||
return web.json_response({"success": True, "roots": roots})
|
||||
except Exception as exc:
|
||||
self._logger.error("Error retrieving recipe roots: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def get_folders(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
@@ -591,6 +606,35 @@ class RecipeManagementHandler:
|
||||
self._logger.error("Error updating recipe: %s", exc, exc_info=True)
|
||||
return web.json_response({"error": str(exc)}, status=500)
|
||||
|
||||
async def move_recipe(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
recipe_scanner = self._recipe_scanner_getter()
|
||||
if recipe_scanner is None:
|
||||
raise RuntimeError("Recipe scanner unavailable")
|
||||
|
||||
data = await request.json()
|
||||
recipe_id = data.get("recipe_id")
|
||||
target_path = data.get("target_path")
|
||||
if not recipe_id or not target_path:
|
||||
return web.json_response(
|
||||
{"success": False, "error": "recipe_id and target_path are required"}, status=400
|
||||
)
|
||||
|
||||
result = await self._persistence_service.move_recipe(
|
||||
recipe_scanner=recipe_scanner,
|
||||
recipe_id=str(recipe_id),
|
||||
target_path=str(target_path),
|
||||
)
|
||||
return web.json_response(result.payload, status=result.status)
|
||||
except RecipeValidationError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=400)
|
||||
except RecipeNotFoundError as exc:
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=404)
|
||||
except Exception as exc:
|
||||
self._logger.error("Error moving recipe: %s", exc, exc_info=True)
|
||||
return web.json_response({"success": False, "error": str(exc)}, status=500)
|
||||
|
||||
async def reconnect_lora(self, request: web.Request) -> web.Response:
|
||||
try:
|
||||
await self._ensure_dependencies_ready()
|
||||
|
||||
@@ -27,6 +27,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("DELETE", "/api/lm/recipe/{recipe_id}", "delete_recipe"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/top-tags", "get_top_tags"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/base-models", "get_base_models"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/roots", "get_roots"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/folders", "get_folders"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/folder-tree", "get_folder_tree"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/unified-folder-tree", "get_unified_folder_tree"),
|
||||
@@ -34,6 +35,7 @@ ROUTE_DEFINITIONS: tuple[RouteDefinition, ...] = (
|
||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"),
|
||||
RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"),
|
||||
RouteDefinition("PUT", "/api/lm/recipe/{recipe_id}/update", "update_recipe"),
|
||||
RouteDefinition("POST", "/api/lm/recipe/move", "move_recipe"),
|
||||
RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
|
||||
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
|
||||
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),
|
||||
|
||||
@@ -1246,6 +1246,30 @@ class RecipeScanner:
|
||||
from datetime import datetime
|
||||
return datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')
|
||||
|
||||
async def get_recipe_json_path(self, recipe_id: str) -> Optional[str]:
|
||||
"""Locate the recipe JSON file, accounting for folder placement."""
|
||||
|
||||
recipes_dir = self.recipes_dir
|
||||
if not recipes_dir:
|
||||
return None
|
||||
|
||||
cache = await self.get_cached_data()
|
||||
folder = ""
|
||||
for item in cache.raw_data:
|
||||
if str(item.get("id")) == str(recipe_id):
|
||||
folder = item.get("folder") or ""
|
||||
break
|
||||
|
||||
candidate = os.path.normpath(os.path.join(recipes_dir, folder, f"{recipe_id}.recipe.json"))
|
||||
if os.path.exists(candidate):
|
||||
return candidate
|
||||
|
||||
for root, _, files in os.walk(recipes_dir):
|
||||
if f"{recipe_id}.recipe.json" in files:
|
||||
return os.path.join(root, f"{recipe_id}.recipe.json")
|
||||
|
||||
return None
|
||||
|
||||
async def update_recipe_metadata(self, recipe_id: str, metadata: dict) -> bool:
|
||||
"""Update recipe metadata (like title and tags) in both file system and cache
|
||||
|
||||
@@ -1256,13 +1280,9 @@ class RecipeScanner:
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
import os
|
||||
import json
|
||||
|
||||
# First, find the recipe JSON file path
|
||||
recipe_json_path = os.path.join(self.recipes_dir, f"{recipe_id}.recipe.json")
|
||||
|
||||
if not os.path.exists(recipe_json_path):
|
||||
recipe_json_path = await self.get_recipe_json_path(recipe_id)
|
||||
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
@@ -1311,8 +1331,8 @@ class RecipeScanner:
|
||||
if target_name is None:
|
||||
raise ValueError("target_name must be provided")
|
||||
|
||||
recipe_json_path = os.path.join(self.recipes_dir, f"{recipe_id}.recipe.json")
|
||||
if not os.path.exists(recipe_json_path):
|
||||
recipe_json_path = await self.get_recipe_json_path(recipe_id)
|
||||
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||
raise RecipeNotFoundError("Recipe not found")
|
||||
|
||||
async with self._mutation_lock:
|
||||
|
||||
@@ -5,6 +5,7 @@ import base64
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
@@ -154,12 +155,8 @@ class RecipePersistenceService:
|
||||
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
|
||||
"""Delete an existing recipe."""
|
||||
|
||||
recipes_dir = recipe_scanner.recipes_dir
|
||||
if not recipes_dir or not os.path.exists(recipes_dir):
|
||||
raise RecipeNotFoundError("Recipes directory not found")
|
||||
|
||||
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
|
||||
if not os.path.exists(recipe_json_path):
|
||||
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||
raise RecipeNotFoundError("Recipe not found")
|
||||
|
||||
with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
|
||||
@@ -187,6 +184,83 @@ class RecipePersistenceService:
|
||||
|
||||
return PersistenceResult({"success": True, "recipe_id": recipe_id, "updates": updates})
|
||||
|
||||
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."""
|
||||
|
||||
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")
|
||||
|
||||
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 PersistenceResult(
|
||||
{
|
||||
"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 PersistenceResult(
|
||||
{
|
||||
"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 reconnect_lora(
|
||||
self,
|
||||
*,
|
||||
@@ -197,8 +271,8 @@ class RecipePersistenceService:
|
||||
) -> PersistenceResult:
|
||||
"""Reconnect a LoRA entry within an existing recipe."""
|
||||
|
||||
recipe_path = os.path.join(recipe_scanner.recipes_dir, f"{recipe_id}.recipe.json")
|
||||
if not os.path.exists(recipe_path):
|
||||
recipe_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||
if not recipe_path or not os.path.exists(recipe_path):
|
||||
raise RecipeNotFoundError("Recipe not found")
|
||||
|
||||
target_lora = await recipe_scanner.get_local_lora(target_name)
|
||||
@@ -243,16 +317,12 @@ class RecipePersistenceService:
|
||||
if not recipe_ids:
|
||||
raise RecipeValidationError("No recipe IDs provided")
|
||||
|
||||
recipes_dir = recipe_scanner.recipes_dir
|
||||
if not recipes_dir or not os.path.exists(recipes_dir):
|
||||
raise RecipeNotFoundError("Recipes directory not found")
|
||||
|
||||
deleted_recipes: list[str] = []
|
||||
failed_recipes: list[dict[str, Any]] = []
|
||||
|
||||
for recipe_id in recipe_ids:
|
||||
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
|
||||
if not os.path.exists(recipe_json_path):
|
||||
recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
|
||||
if not recipe_json_path or not os.path.exists(recipe_json_path):
|
||||
failed_recipes.append({"id": recipe_id, "reason": "Recipe not found"})
|
||||
continue
|
||||
|
||||
|
||||
@@ -7,19 +7,28 @@ const RECIPE_ENDPOINTS = {
|
||||
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',
|
||||
};
|
||||
|
||||
const RECIPE_SIDEBAR_CONFIG = {
|
||||
config: {
|
||||
displayName: 'Recipes',
|
||||
supportsMove: false,
|
||||
supportsMove: true,
|
||||
},
|
||||
endpoints: RECIPE_ENDPOINTS,
|
||||
};
|
||||
|
||||
function extractRecipeId(filePath) {
|
||||
if (!filePath) return null;
|
||||
const basename = filePath.split('/').pop().split('\\').pop();
|
||||
const dotIndex = basename.lastIndexOf('.');
|
||||
return dotIndex > 0 ? basename.substring(0, dotIndex) : basename;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch recipes with pagination for virtual scrolling
|
||||
* @param {number} page - Page number to fetch
|
||||
@@ -302,8 +311,10 @@ export async function updateRecipeMetadata(filePath, updates) {
|
||||
state.loadingManager.showSimpleLoading('Saving metadata...');
|
||||
|
||||
// Extract recipeId from filePath (basename without extension)
|
||||
const basename = filePath.split('/').pop().split('\\').pop();
|
||||
const recipeId = basename.substring(0, basename.lastIndexOf('.'));
|
||||
const recipeId = extractRecipeId(filePath);
|
||||
if (!recipeId) {
|
||||
throw new Error('Unable to determine recipe ID');
|
||||
}
|
||||
|
||||
const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, {
|
||||
method: 'PUT',
|
||||
@@ -345,6 +356,14 @@ export class RecipeSidebarApiClient {
|
||||
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) {
|
||||
@@ -353,11 +372,69 @@ export class RecipeSidebarApiClient {
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async moveBulkModels() {
|
||||
throw new Error('Recipe move operations are not supported.');
|
||||
async moveBulkModels(filePaths, targetPath) {
|
||||
const results = [];
|
||||
for (const path of filePaths) {
|
||||
try {
|
||||
const result = await this.moveSingleModel(path, targetPath);
|
||||
results.push({
|
||||
original_file_path: path,
|
||||
new_file_path: result?.new_file_path,
|
||||
success: !!result,
|
||||
message: result?.message,
|
||||
});
|
||||
} catch (error) {
|
||||
results.push({
|
||||
original_file_path: path,
|
||||
new_file_path: null,
|
||||
success: false,
|
||||
message: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
async moveSingleModel() {
|
||||
throw new Error('Recipe move operations are not supported.');
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { showToast, copyToClipboard, sendLoraToWorkflow } from '../../utils/uiHe
|
||||
import { setSessionItem, removeSessionItem } from '../../utils/storageHelpers.js';
|
||||
import { updateRecipeMetadata } from '../../api/recipeApi.js';
|
||||
import { state } from '../../state/index.js';
|
||||
import { moveManager } from '../../managers/MoveManager.js';
|
||||
|
||||
export class RecipeContextMenu extends BaseContextMenu {
|
||||
constructor() {
|
||||
@@ -77,6 +78,9 @@ export class RecipeContextMenu extends BaseContextMenu {
|
||||
// Share recipe
|
||||
this.currentCard.querySelector('.fa-share-alt')?.click();
|
||||
break;
|
||||
case 'move':
|
||||
moveManager.showMoveModal(this.currentCard.dataset.filepath);
|
||||
break;
|
||||
case 'delete':
|
||||
// Delete recipe
|
||||
this.currentCard.querySelector('.fa-trash')?.click();
|
||||
|
||||
@@ -3,6 +3,7 @@ import { state, getCurrentPageState } from '../state/index.js';
|
||||
import { modalManager } from './ModalManager.js';
|
||||
import { bulkManager } from './BulkManager.js';
|
||||
import { getModelApiClient } from '../api/modelApiFactory.js';
|
||||
import { RecipeSidebarApiClient } from '../api/recipeApi.js';
|
||||
import { FolderTreeManager } from '../components/FolderTreeManager.js';
|
||||
import { sidebarManager } from '../components/SidebarManager.js';
|
||||
|
||||
@@ -12,11 +13,22 @@ class MoveManager {
|
||||
this.bulkFilePaths = null;
|
||||
this.folderTreeManager = new FolderTreeManager();
|
||||
this.initialized = false;
|
||||
this.recipeApiClient = null;
|
||||
|
||||
// Bind methods
|
||||
this.updateTargetPath = this.updateTargetPath.bind(this);
|
||||
}
|
||||
|
||||
_getApiClient(modelType = null) {
|
||||
if (state.currentPageType === 'recipes') {
|
||||
if (!this.recipeApiClient) {
|
||||
this.recipeApiClient = new RecipeSidebarApiClient();
|
||||
}
|
||||
return this.recipeApiClient;
|
||||
}
|
||||
return getModelApiClient(modelType);
|
||||
}
|
||||
|
||||
initializeEventListeners() {
|
||||
if (this.initialized) return;
|
||||
|
||||
@@ -36,7 +48,7 @@ class MoveManager {
|
||||
this.currentFilePath = null;
|
||||
this.bulkFilePaths = null;
|
||||
|
||||
const apiClient = getModelApiClient();
|
||||
const apiClient = this._getApiClient(modelType);
|
||||
const currentPageType = state.currentPageType;
|
||||
const modelConfig = apiClient.apiConfig.config;
|
||||
|
||||
@@ -121,7 +133,7 @@ class MoveManager {
|
||||
|
||||
async initializeFolderTree() {
|
||||
try {
|
||||
const apiClient = getModelApiClient();
|
||||
const apiClient = this._getApiClient();
|
||||
// Fetch unified folder tree
|
||||
const treeData = await apiClient.fetchUnifiedFolderTree();
|
||||
|
||||
@@ -141,7 +153,7 @@ class MoveManager {
|
||||
updateTargetPath() {
|
||||
const pathDisplay = document.getElementById('moveTargetPathDisplay');
|
||||
const modelRoot = document.getElementById('moveModelRoot').value;
|
||||
const apiClient = getModelApiClient();
|
||||
const apiClient = this._getApiClient();
|
||||
const config = apiClient.apiConfig.config;
|
||||
|
||||
let fullPath = modelRoot || `Select a ${config.displayName.toLowerCase()} root directory`;
|
||||
@@ -158,7 +170,7 @@ class MoveManager {
|
||||
|
||||
async moveModel() {
|
||||
const selectedRoot = document.getElementById('moveModelRoot').value;
|
||||
const apiClient = getModelApiClient();
|
||||
const apiClient = this._getApiClient();
|
||||
const config = apiClient.apiConfig.config;
|
||||
|
||||
if (!selectedRoot) {
|
||||
|
||||
@@ -15,17 +15,26 @@
|
||||
|
||||
<div id="recipeContextMenu" class="context-menu" style="display: none;">
|
||||
<!-- <div class="context-menu-item" data-action="details"><i class="fas fa-info-circle"></i> View Details</div> -->
|
||||
<div class="context-menu-item" data-action="share"><i class="fas fa-share-alt"></i> {{ t('loras.contextMenu.shareRecipe') }}</div>
|
||||
<div class="context-menu-item" data-action="copy"><i class="fas fa-copy"></i> {{ t('loras.contextMenu.copyRecipeSyntax') }}</div>
|
||||
<div class="context-menu-item" data-action="sendappend"><i class="fas fa-paper-plane"></i> {{ t('loras.contextMenu.sendToWorkflowAppend') }}</div>
|
||||
<div class="context-menu-item" data-action="sendreplace"><i class="fas fa-exchange-alt"></i> {{ t('loras.contextMenu.sendToWorkflowReplace') }}</div>
|
||||
<div class="context-menu-item" data-action="viewloras"><i class="fas fa-layer-group"></i> {{ t('loras.contextMenu.viewAllLoras') }}</div>
|
||||
<div class="context-menu-item download-missing-item" data-action="download-missing"><i class="fas fa-download"></i> {{ t('loras.contextMenu.downloadMissingLoras') }}</div>
|
||||
<div class="context-menu-item" data-action="share"><i class="fas fa-share-alt"></i> {{
|
||||
t('loras.contextMenu.shareRecipe') }}</div>
|
||||
<div class="context-menu-item" data-action="copy"><i class="fas fa-copy"></i> {{
|
||||
t('loras.contextMenu.copyRecipeSyntax') }}</div>
|
||||
<div class="context-menu-item" data-action="sendappend"><i class="fas fa-paper-plane"></i> {{
|
||||
t('loras.contextMenu.sendToWorkflowAppend') }}</div>
|
||||
<div class="context-menu-item" data-action="sendreplace"><i class="fas fa-exchange-alt"></i> {{
|
||||
t('loras.contextMenu.sendToWorkflowReplace') }}</div>
|
||||
<div class="context-menu-item" data-action="viewloras"><i class="fas fa-layer-group"></i> {{
|
||||
t('loras.contextMenu.viewAllLoras') }}</div>
|
||||
<div class="context-menu-item download-missing-item" data-action="download-missing"><i class="fas fa-download"></i>
|
||||
{{ t('loras.contextMenu.downloadMissingLoras') }}</div>
|
||||
<div class="context-menu-item" data-action="set-nsfw">
|
||||
<i class="fas fa-exclamation-triangle"></i> {{ t('loras.contextMenu.setContentRating') }}
|
||||
</div>
|
||||
<div class="context-menu-item" data-action="move"><i class="fas fa-folder-open"></i> {{
|
||||
t('loras.contextMenu.moveToFolder') }}</div>
|
||||
<div class="context-menu-separator"></div>
|
||||
<div class="context-menu-item delete-item" data-action="delete"><i class="fas fa-trash"></i> {{ t('loras.contextMenu.deleteRecipe') }}</div>
|
||||
<div class="context-menu-item delete-item" data-action="delete"><i class="fas fa-trash"></i> {{
|
||||
t('loras.contextMenu.deleteRecipe') }}</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
@@ -34,55 +43,59 @@
|
||||
{% block init_check_url %}/api/recipes?page=1&page_size=1{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<!-- Recipe controls -->
|
||||
<div class="controls">
|
||||
<div class="action-buttons">
|
||||
<div title="{{ t('recipes.controls.refresh.title') }}" class="control-group">
|
||||
<button onclick="recipeManager.refreshRecipes()"><i class="fas fa-sync"></i> {{ t('common.actions.refresh') }}</button>
|
||||
</div>
|
||||
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
|
||||
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{ t('recipes.controls.import.action') }}</button>
|
||||
</div>
|
||||
<!-- Add duplicate detection button -->
|
||||
<div title="{{ t('loras.controls.duplicates.title') }}" class="control-group">
|
||||
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> {{ t('loras.controls.duplicates.action') }}</button>
|
||||
</div>
|
||||
<!-- Custom filter indicator button (hidden by default) -->
|
||||
<div id="customFilterIndicator" class="control-group hidden">
|
||||
<div class="filter-active">
|
||||
<i class="fas fa-filter"></i> <span id="customFilterText">{{ t('recipes.controls.filteredByLora') }}</span>
|
||||
<i class="fas fa-times-circle clear-filter"></i>
|
||||
</div>
|
||||
<!-- Recipe controls -->
|
||||
<div class="controls">
|
||||
<div class="action-buttons">
|
||||
<div title="{{ t('recipes.controls.refresh.title') }}" class="control-group">
|
||||
<button onclick="recipeManager.refreshRecipes()"><i class="fas fa-sync"></i> {{ t('common.actions.refresh')
|
||||
}}</button>
|
||||
</div>
|
||||
<div title="{{ t('recipes.controls.import.title') }}" class="control-group">
|
||||
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{
|
||||
t('recipes.controls.import.action') }}</button>
|
||||
</div>
|
||||
<!-- Add duplicate detection button -->
|
||||
<div title="{{ t('loras.controls.duplicates.title') }}" class="control-group">
|
||||
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> {{
|
||||
t('loras.controls.duplicates.action') }}</button>
|
||||
</div>
|
||||
<!-- Custom filter indicator button (hidden by default) -->
|
||||
<div id="customFilterIndicator" class="control-group hidden">
|
||||
<div class="filter-active">
|
||||
<i class="fas fa-filter"></i> <span id="customFilterText">{{ t('recipes.controls.filteredByLora')
|
||||
}}</span>
|
||||
<i class="fas fa-times-circle clear-filter"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Duplicates banner (hidden by default) -->
|
||||
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
|
||||
<div class="banner-content">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<span id="duplicatesCount">{{ t('recipes.duplicates.found', count=0) }}</span>
|
||||
<div class="banner-actions">
|
||||
<!-- Duplicates banner (hidden by default) -->
|
||||
<div id="duplicatesBanner" class="duplicates-banner" style="display: none;">
|
||||
<div class="banner-content">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<span id="duplicatesCount">{{ t('recipes.duplicates.found', count=0) }}</span>
|
||||
<div class="banner-actions">
|
||||
<button class="btn-select-latest" onclick="recipeManager.selectLatestDuplicates()">
|
||||
{{ t('recipes.duplicates.keepLatest') }}
|
||||
{{ t('recipes.duplicates.keepLatest') }}
|
||||
</button>
|
||||
<button class="btn-delete-selected disabled" onclick="recipeManager.deleteSelectedDuplicates()">
|
||||
{{ t('recipes.duplicates.deleteSelected') }} (<span id="duplicatesSelectedCount">0</span>)
|
||||
{{ t('recipes.duplicates.deleteSelected') }} (<span id="duplicatesSelectedCount">0</span>)
|
||||
</button>
|
||||
<button class="btn-exit" onclick="recipeManager.exitDuplicateMode()">
|
||||
<i class="fas fa-times"></i>
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% include 'components/folder_sidebar.html' %}
|
||||
{% include 'components/folder_sidebar.html' %}
|
||||
|
||||
<!-- Recipe grid -->
|
||||
<div class="card-grid" id="recipeGrid">
|
||||
<!-- Remove the server-side conditional rendering and placeholder -->
|
||||
<!-- Virtual scrolling will handle the display logic on the client side -->
|
||||
</div>
|
||||
<!-- Recipe grid -->
|
||||
<div class="card-grid" id="recipeGrid">
|
||||
<!-- Remove the server-side conditional rendering and placeholder -->
|
||||
<!-- Virtual scrolling will handle the display logic on the client side -->
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block main_script %}
|
||||
|
||||
@@ -9,6 +9,8 @@ const removeSessionItemMock = vi.fn();
|
||||
const RecipeContextMenuMock = vi.fn();
|
||||
const refreshVirtualScrollMock = vi.fn();
|
||||
const refreshRecipesMock = vi.fn();
|
||||
const fetchUnifiedFolderTreeMock = vi.fn();
|
||||
const fetchModelFoldersMock = vi.fn();
|
||||
|
||||
let importManagerInstance;
|
||||
let recipeModalInstance;
|
||||
@@ -35,6 +37,15 @@ vi.mock('../../../static/js/components/RecipeModal.js', () => ({
|
||||
|
||||
vi.mock('../../../static/js/state/index.js', () => ({
|
||||
getCurrentPageState: getCurrentPageStateMock,
|
||||
state: {
|
||||
currentPageType: 'recipes',
|
||||
global: { settings: {} },
|
||||
virtualScroller: {
|
||||
removeItemByFilePath: vi.fn(),
|
||||
updateSingleItem: vi.fn(),
|
||||
refreshWithData: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||
@@ -56,6 +67,14 @@ vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||
|
||||
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||
refreshRecipes: refreshRecipesMock,
|
||||
RecipeSidebarApiClient: vi.fn(() => ({
|
||||
apiConfig: { config: { displayName: 'Recipes', supportsMove: true } },
|
||||
fetchUnifiedFolderTree: fetchUnifiedFolderTreeMock.mockResolvedValue({ success: true, tree: {} }),
|
||||
fetchModelFolders: fetchModelFoldersMock.mockResolvedValue({ success: true, folders: [] }),
|
||||
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/recipes'] }),
|
||||
moveBulkModels: vi.fn(),
|
||||
moveSingleModel: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('RecipeManager', () => {
|
||||
@@ -139,6 +158,7 @@ describe('RecipeManager', () => {
|
||||
tags: true,
|
||||
loraName: true,
|
||||
loraModel: true,
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
expect(pageState.customFilter).toEqual({
|
||||
|
||||
@@ -69,6 +69,10 @@ class StubRecipeScanner:
|
||||
async def get_recipe_by_id(self, recipe_id: str) -> Optional[Dict[str, Any]]:
|
||||
return self.recipes.get(recipe_id)
|
||||
|
||||
async def get_recipe_json_path(self, recipe_id: str) -> Optional[str]:
|
||||
candidate = Path(self.recipes_dir) / f"{recipe_id}.recipe.json"
|
||||
return str(candidate) if candidate.exists() else None
|
||||
|
||||
async def remove_recipe(self, recipe_id: str) -> None:
|
||||
self.removed.append(recipe_id)
|
||||
self.recipes.pop(recipe_id, None)
|
||||
@@ -119,6 +123,7 @@ class StubPersistenceService:
|
||||
def __init__(self, **_: Any) -> None:
|
||||
self.save_calls: List[Dict[str, Any]] = []
|
||||
self.delete_calls: List[str] = []
|
||||
self.move_calls: List[Dict[str, str]] = []
|
||||
self.save_result = SimpleNamespace(payload={"success": True, "recipe_id": "stub-id"}, status=200)
|
||||
self.delete_result = SimpleNamespace(payload={"success": True}, status=200)
|
||||
StubPersistenceService.instances.append(self)
|
||||
@@ -142,6 +147,12 @@ class StubPersistenceService:
|
||||
await recipe_scanner.remove_recipe(recipe_id)
|
||||
return self.delete_result
|
||||
|
||||
async def move_recipe(self, *, recipe_scanner, recipe_id: str, target_path: str) -> SimpleNamespace: # noqa: D401
|
||||
self.move_calls.append({"recipe_id": recipe_id, "target_path": target_path})
|
||||
return SimpleNamespace(
|
||||
payload={"success": True, "recipe_id": recipe_id, "new_file_path": target_path}, status=200
|
||||
)
|
||||
|
||||
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: Dict[str, Any]) -> SimpleNamespace: # pragma: no cover - unused by smoke tests
|
||||
return SimpleNamespace(payload={"success": True, "recipe_id": recipe_id, "updates": updates}, status=200)
|
||||
|
||||
@@ -310,6 +321,21 @@ async def test_save_and_delete_recipe_round_trip(monkeypatch, tmp_path: Path) ->
|
||||
assert harness.persistence.delete_calls == ["saved-id"]
|
||||
|
||||
|
||||
async def test_move_recipe_invokes_persistence(monkeypatch, tmp_path: Path) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipe/move",
|
||||
json={"recipe_id": "move-me", "target_path": str(tmp_path / "recipes" / "subdir")},
|
||||
)
|
||||
|
||||
payload = await response.json()
|
||||
assert response.status == 200
|
||||
assert payload["recipe_id"] == "move-me"
|
||||
assert harness.persistence.move_calls == [
|
||||
{"recipe_id": "move-me", "target_path": str(tmp_path / "recipes" / "subdir")}
|
||||
]
|
||||
|
||||
|
||||
async def test_import_remote_recipe(monkeypatch, tmp_path: Path) -> None:
|
||||
provider_calls: list[int] = []
|
||||
|
||||
|
||||
@@ -403,6 +403,89 @@ async def test_save_recipe_from_widget_allows_empty_lora(tmp_path):
|
||||
assert scanner.added and scanner.added[0]["loras"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_recipe_updates_paths(tmp_path):
|
||||
exif_utils = DummyExifUtils()
|
||||
recipes_dir = tmp_path / "recipes"
|
||||
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recipe_id = "move-me"
|
||||
image_path = recipes_dir / f"{recipe_id}.webp"
|
||||
json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||
|
||||
image_path.write_bytes(b"img")
|
||||
json_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"id": recipe_id,
|
||||
"file_path": str(image_path),
|
||||
"title": "Recipe",
|
||||
"loras": [],
|
||||
"gen_params": {},
|
||||
"created_date": 0,
|
||||
"modified": 0,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
class MoveScanner:
|
||||
def __init__(self, root: Path):
|
||||
self.recipes_dir = str(root)
|
||||
self.recipe = {
|
||||
"id": recipe_id,
|
||||
"file_path": str(image_path),
|
||||
"title": "Recipe",
|
||||
"loras": [],
|
||||
"gen_params": {},
|
||||
"created_date": 0,
|
||||
"modified": 0,
|
||||
"folder": "",
|
||||
}
|
||||
|
||||
async def get_recipe_by_id(self, target_id: str):
|
||||
return self.recipe if target_id == recipe_id else None
|
||||
|
||||
async def get_recipe_json_path(self, target_id: str):
|
||||
matches = list(Path(self.recipes_dir).rglob(f"{target_id}.recipe.json"))
|
||||
return str(matches[0]) if matches else None
|
||||
|
||||
async def update_recipe_metadata(self, target_id: str, metadata: dict):
|
||||
if target_id != recipe_id:
|
||||
return False
|
||||
self.recipe.update(metadata)
|
||||
target_path = await self.get_recipe_json_path(target_id)
|
||||
if not target_path:
|
||||
return False
|
||||
existing = json.loads(Path(target_path).read_text())
|
||||
existing.update(metadata)
|
||||
Path(target_path).write_text(json.dumps(existing))
|
||||
return True
|
||||
|
||||
async def get_cached_data(self, force_refresh: bool = False): # noqa: ARG002 - signature parity
|
||||
return SimpleNamespace(raw_data=[self.recipe])
|
||||
|
||||
scanner = MoveScanner(recipes_dir)
|
||||
service = RecipePersistenceService(
|
||||
exif_utils=exif_utils,
|
||||
card_preview_width=512,
|
||||
logger=logging.getLogger("test"),
|
||||
)
|
||||
|
||||
target_folder = recipes_dir / "nested"
|
||||
result = await service.move_recipe(
|
||||
recipe_scanner=scanner, recipe_id=recipe_id, target_path=str(target_folder)
|
||||
)
|
||||
|
||||
assert result.payload["folder"] == "nested"
|
||||
assert Path(result.payload["json_path"]).parent == target_folder
|
||||
assert Path(result.payload["new_file_path"]).parent == target_folder
|
||||
assert not json_path.exists()
|
||||
|
||||
stored = json.loads(Path(result.payload["json_path"]).read_text())
|
||||
assert stored["folder"] == "nested"
|
||||
assert stored["file_path"] == result.payload["new_file_path"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_remote_video(tmp_path):
|
||||
exif_utils = DummyExifUtils()
|
||||
|
||||
Reference in New Issue
Block a user