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:
Will Miao
2025-11-25 17:41:24 +08:00
parent 67fb205b43
commit 3f646aa0c9
21 changed files with 501 additions and 110 deletions

View File

@@ -638,7 +638,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 +1461,8 @@
"bulkMoveFailures": "Fehlgeschlagene Verschiebungen:\n{failures}", "bulkMoveFailures": "Fehlgeschlagene Verschiebungen:\n{failures}",
"bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben", "bulkMoveSuccess": "{successCount} {type}s erfolgreich verschoben",
"exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!", "exampleImagesDownloadSuccess": "Beispielbilder erfolgreich heruntergeladen!",
"exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}" "exampleImagesDownloadFailed": "Fehler beim Herunterladen der Beispielbilder: {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -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",
@@ -638,7 +638,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 +1461,8 @@
"bulkMoveFailures": "Failed moves:\n{failures}", "bulkMoveFailures": "Failed moves:\n{failures}",
"bulkMoveSuccess": "Successfully moved {successCount} {type}s", "bulkMoveSuccess": "Successfully moved {successCount} {type}s",
"exampleImagesDownloadSuccess": "Successfully downloaded example images!", "exampleImagesDownloadSuccess": "Successfully downloaded example images!",
"exampleImagesDownloadFailed": "Failed to download example images: {message}" "exampleImagesDownloadFailed": "Failed to download example images: {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -638,7 +638,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 +1461,8 @@
"bulkMoveFailures": "Movimientos fallidos:\n{failures}", "bulkMoveFailures": "Movimientos fallidos:\n{failures}",
"bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s", "bulkMoveSuccess": "Movidos exitosamente {successCount} {type}s",
"exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!", "exampleImagesDownloadSuccess": "¡Imágenes de ejemplo descargadas exitosamente!",
"exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}" "exampleImagesDownloadFailed": "Error al descargar imágenes de ejemplo: {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -638,7 +638,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 +1461,8 @@
"bulkMoveFailures": "Échecs de déplacement :\n{failures}", "bulkMoveFailures": "Échecs de déplacement :\n{failures}",
"bulkMoveSuccess": "{successCount} {type}s déplacés avec succès", "bulkMoveSuccess": "{successCount} {type}s déplacés avec succès",
"exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !", "exampleImagesDownloadSuccess": "Images d'exemple téléchargées avec succès !",
"exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}" "exampleImagesDownloadFailed": "Échec du téléchargement des images d'exemple : {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -638,7 +638,8 @@
"recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ", "recursiveUnavailable": "חיפוש רקורסיבי זמין רק בתצוגת עץ",
"collapseAllDisabled": "לא זמין בתצוגת רשימה", "collapseAllDisabled": "לא זמין בתצוגת רשימה",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה." "unableToResolveRoot": "לא ניתן לקבוע את נתיב היעד להעברה.",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -1460,7 +1461,8 @@
"bulkMoveFailures": "העברות שנכשלו:\n{failures}", "bulkMoveFailures": "העברות שנכשלו:\n{failures}",
"bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s", "bulkMoveSuccess": "הועברו בהצלחה {successCount} {type}s",
"exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!", "exampleImagesDownloadSuccess": "תמונות הדוגמה הורדו בהצלחה!",
"exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}" "exampleImagesDownloadFailed": "הורדת תמונות הדוגמה נכשלה: {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -638,7 +638,8 @@
"recursiveUnavailable": "再帰検索はツリービューでのみ利用できます", "recursiveUnavailable": "再帰検索はツリービューでのみ利用できます",
"collapseAllDisabled": "リストビューでは利用できません", "collapseAllDisabled": "リストビューでは利用できません",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "移動先のパスを特定できません。" "unableToResolveRoot": "移動先のパスを特定できません。",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -1460,7 +1461,8 @@
"bulkMoveFailures": "失敗した移動:\n{failures}", "bulkMoveFailures": "失敗した移動:\n{failures}",
"bulkMoveSuccess": "{successCount} {type}が正常に移動されました", "bulkMoveSuccess": "{successCount} {type}が正常に移動されました",
"exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!", "exampleImagesDownloadSuccess": "例画像が正常にダウンロードされました!",
"exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}" "exampleImagesDownloadFailed": "例画像のダウンロードに失敗しました:{message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -638,7 +638,8 @@
"recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다", "recursiveUnavailable": "재귀 검색은 트리 보기에서만 사용할 수 있습니다",
"collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다", "collapseAllDisabled": "목록 보기에서는 사용할 수 없습니다",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다." "unableToResolveRoot": "이동할 대상 경로를 확인할 수 없습니다.",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -1460,7 +1461,8 @@
"bulkMoveFailures": "실패한 이동:\n{failures}", "bulkMoveFailures": "실패한 이동:\n{failures}",
"bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다", "bulkMoveSuccess": "{successCount}개 {type}이(가) 성공적으로 이동되었습니다",
"exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!", "exampleImagesDownloadSuccess": "예시 이미지가 성공적으로 다운로드되었습니다!",
"exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}" "exampleImagesDownloadFailed": "예시 이미지 다운로드 실패: {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -638,7 +638,8 @@
"recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева", "recursiveUnavailable": "Рекурсивный поиск доступен только в режиме дерева",
"collapseAllDisabled": "Недоступно в виде списка", "collapseAllDisabled": "Недоступно в виде списка",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "Не удалось определить путь назначения для перемещения." "unableToResolveRoot": "Не удалось определить путь назначения для перемещения.",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -1460,7 +1461,8 @@
"bulkMoveFailures": "Неудачные перемещения:\n{failures}", "bulkMoveFailures": "Неудачные перемещения:\n{failures}",
"bulkMoveSuccess": "Успешно перемещено {successCount} {type}s", "bulkMoveSuccess": "Успешно перемещено {successCount} {type}s",
"exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!", "exampleImagesDownloadSuccess": "Примеры изображений успешно загружены!",
"exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}" "exampleImagesDownloadFailed": "Не удалось загрузить примеры изображений: {message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -638,7 +638,8 @@
"recursiveUnavailable": "仅在树形视图中可使用递归搜索", "recursiveUnavailable": "仅在树形视图中可使用递归搜索",
"collapseAllDisabled": "列表视图下不可用", "collapseAllDisabled": "列表视图下不可用",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "无法确定移动的目标路径。" "unableToResolveRoot": "无法确定移动的目标路径。",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -1460,7 +1461,8 @@
"bulkMoveFailures": "移动失败:\n{failures}", "bulkMoveFailures": "移动失败:\n{failures}",
"bulkMoveSuccess": "成功移动 {successCount} 个 {type}", "bulkMoveSuccess": "成功移动 {successCount} 个 {type}",
"exampleImagesDownloadSuccess": "示例图片下载成功!", "exampleImagesDownloadSuccess": "示例图片下载成功!",
"exampleImagesDownloadFailed": "示例图片下载失败:{message}" "exampleImagesDownloadFailed": "示例图片下载失败:{message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -638,7 +638,8 @@
"recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用", "recursiveUnavailable": "遞迴搜尋僅能在樹狀檢視中使用",
"collapseAllDisabled": "列表檢視下不可用", "collapseAllDisabled": "列表檢視下不可用",
"dragDrop": { "dragDrop": {
"unableToResolveRoot": "無法確定移動的目標路徑。" "unableToResolveRoot": "無法確定移動的目標路徑。",
"moveUnsupported": "Move is not supported for this item."
} }
}, },
"statistics": { "statistics": {
@@ -1460,7 +1461,8 @@
"bulkMoveFailures": "移動失敗:\n{failures}", "bulkMoveFailures": "移動失敗:\n{failures}",
"bulkMoveSuccess": "已成功移動 {successCount} 個 {type}", "bulkMoveSuccess": "已成功移動 {successCount} 個 {type}",
"exampleImagesDownloadSuccess": "範例圖片下載成功!", "exampleImagesDownloadSuccess": "範例圖片下載成功!",
"exampleImagesDownloadFailed": "下載範例圖片失敗:{message}" "exampleImagesDownloadFailed": "下載範例圖片失敗:{message}",
"moveFailed": "Failed to move item: {message}"
} }
}, },
"banners": { "banners": {

View File

@@ -56,6 +56,7 @@ 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_folders": self.query.get_folders,
"get_folder_tree": self.query.get_folder_tree, "get_folder_tree": self.query.get_folder_tree,
"get_unified_folder_tree": self.query.get_unified_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, "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,
} }
@@ -306,6 +308,19 @@ 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: async def get_folders(self, request: web.Request) -> web.Response:
try: try:
await self._ensure_dependencies_ready() await self._ensure_dependencies_ready()
@@ -591,6 +606,35 @@ 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 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()

View File

@@ -27,6 +27,7 @@ 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/folders", "get_folders"),
RouteDefinition("GET", "/api/lm/recipes/folder-tree", "get_folder_tree"), 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/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}/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/recipe/lora/reconnect", "reconnect_lora"), RouteDefinition("POST", "/api/lm/recipe/lora/reconnect", "reconnect_lora"),
RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"), RouteDefinition("GET", "/api/lm/recipes/find-duplicates", "find_duplicates"),
RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"), RouteDefinition("POST", "/api/lm/recipes/bulk-delete", "bulk_delete"),

View File

@@ -1246,6 +1246,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
@@ -1256,13 +1280,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:
@@ -1311,8 +1331,8 @@ class RecipeScanner:
if target_name is None: if target_name is None:
raise ValueError("target_name must be provided") raise ValueError("target_name must be provided")
recipe_json_path = os.path.join(self.recipes_dir, f"{recipe_id}.recipe.json") recipe_json_path = await self.get_recipe_json_path(recipe_id)
if not os.path.exists(recipe_json_path): if not recipe_json_path or not os.path.exists(recipe_json_path):
raise RecipeNotFoundError("Recipe not found") raise RecipeNotFoundError("Recipe not found")
async with self._mutation_lock: async with self._mutation_lock:

View File

@@ -5,6 +5,7 @@ import base64
import json import json
import os import os
import re import re
import shutil
import time import time
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
@@ -154,12 +155,8 @@ class RecipePersistenceService:
async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult: async def delete_recipe(self, *, recipe_scanner, recipe_id: str) -> PersistenceResult:
"""Delete an existing recipe.""" """Delete an existing recipe."""
recipes_dir = recipe_scanner.recipes_dir recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
if not recipes_dir or not os.path.exists(recipes_dir): if not recipe_json_path or not os.path.exists(recipe_json_path):
raise RecipeNotFoundError("Recipes directory not found")
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json")
if not os.path.exists(recipe_json_path):
raise RecipeNotFoundError("Recipe not found") raise RecipeNotFoundError("Recipe not found")
with open(recipe_json_path, "r", encoding="utf-8") as file_obj: with open(recipe_json_path, "r", encoding="utf-8") as file_obj:
@@ -187,6 +184,83 @@ class RecipePersistenceService:
return PersistenceResult({"success": True, "recipe_id": recipe_id, "updates": updates}) 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( async def reconnect_lora(
self, self,
*, *,
@@ -197,8 +271,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 +317,12 @@ class RecipePersistenceService:
if not recipe_ids: if not recipe_ids:
raise RecipeValidationError("No recipe IDs provided") raise RecipeValidationError("No recipe IDs provided")
recipes_dir = recipe_scanner.recipes_dir
if not recipes_dir or not os.path.exists(recipes_dir):
raise RecipeNotFoundError("Recipes directory not found")
deleted_recipes: list[str] = [] deleted_recipes: list[str] = []
failed_recipes: list[dict[str, Any]] = [] failed_recipes: list[dict[str, Any]] = []
for recipe_id in recipe_ids: for recipe_id in recipe_ids:
recipe_json_path = os.path.join(recipes_dir, f"{recipe_id}.recipe.json") recipe_json_path = await recipe_scanner.get_recipe_json_path(recipe_id)
if not os.path.exists(recipe_json_path): if not recipe_json_path or not os.path.exists(recipe_json_path):
failed_recipes.append({"id": recipe_id, "reason": "Recipe not found"}) failed_recipes.append({"id": recipe_id, "reason": "Recipe not found"})
continue continue

View File

@@ -7,19 +7,28 @@ const RECIPE_ENDPOINTS = {
detail: '/api/lm/recipe', detail: '/api/lm/recipe',
scan: '/api/lm/recipes/scan', scan: '/api/lm/recipes/scan',
update: '/api/lm/recipe', update: '/api/lm/recipe',
roots: '/api/lm/recipes/roots',
folders: '/api/lm/recipes/folders', folders: '/api/lm/recipes/folders',
folderTree: '/api/lm/recipes/folder-tree', folderTree: '/api/lm/recipes/folder-tree',
unifiedFolderTree: '/api/lm/recipes/unified-folder-tree', unifiedFolderTree: '/api/lm/recipes/unified-folder-tree',
move: '/api/lm/recipe/move',
}; };
const RECIPE_SIDEBAR_CONFIG = { const RECIPE_SIDEBAR_CONFIG = {
config: { config: {
displayName: 'Recipes', displayName: 'Recipes',
supportsMove: false, supportsMove: true,
}, },
endpoints: RECIPE_ENDPOINTS, 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 * Fetch recipes with pagination for virtual scrolling
* @param {number} page - Page number to fetch * @param {number} page - Page number to fetch
@@ -302,8 +311,10 @@ 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(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, { const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, {
method: 'PUT', method: 'PUT',
@@ -345,6 +356,14 @@ export class RecipeSidebarApiClient {
return response.json(); 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() { async fetchModelFolders() {
const response = await fetch(this.apiConfig.endpoints.folders); const response = await fetch(this.apiConfig.endpoints.folders);
if (!response.ok) { if (!response.ok) {
@@ -353,11 +372,69 @@ export class RecipeSidebarApiClient {
return response.json(); return response.json();
} }
async moveBulkModels() { async moveBulkModels(filePaths, targetPath) {
throw new Error('Recipe move operations are not supported.'); 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() { async moveSingleModel(filePath, targetPath) {
throw new Error('Recipe move operations are not supported.'); 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,
};
} }
} }

View File

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

View File

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

View File

@@ -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,55 +43,59 @@
{% 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 title="{{ t('recipes.controls.refresh.title') }}" class="control-group">
<button onclick="recipeManager.refreshRecipes()"><i class="fas fa-sync"></i> {{ t('common.actions.refresh') }}</button> <button onclick="recipeManager.refreshRecipes()"><i class="fas fa-sync"></i> {{ t('common.actions.refresh')
</div> }}</button>
<div title="{{ t('recipes.controls.import.title') }}" class="control-group"> </div>
<button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{ t('recipes.controls.import.action') }}</button> <div title="{{ t('recipes.controls.import.title') }}" class="control-group">
</div> <button onclick="importManager.showImportModal()"><i class="fas fa-file-import"></i> {{
<!-- Add duplicate detection button --> t('recipes.controls.import.action') }}</button>
<div title="{{ t('loras.controls.duplicates.title') }}" class="control-group"> </div>
<button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> {{ t('loras.controls.duplicates.action') }}</button> <!-- Add duplicate detection button -->
</div> <div title="{{ t('loras.controls.duplicates.title') }}" class="control-group">
<!-- Custom filter indicator button (hidden by default) --> <button onclick="recipeManager.findDuplicateRecipes()"><i class="fas fa-clone"></i> {{
<div id="customFilterIndicator" class="control-group hidden"> t('loras.controls.duplicates.action') }}</button>
<div class="filter-active"> </div>
<i class="fas fa-filter"></i> <span id="customFilterText">{{ t('recipes.controls.filteredByLora') }}</span> <!-- Custom filter indicator button (hidden by default) -->
<i class="fas fa-times-circle clear-filter"></i> <div id="customFilterIndicator" class="control-group hidden">
</div> <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>
{% include 'components/folder_sidebar.html' %} {% include 'components/folder_sidebar.html' %}
<!-- Recipe grid --> <!-- Recipe grid -->
<div class="card-grid" id="recipeGrid"> <div class="card-grid" id="recipeGrid">
<!-- Remove the server-side conditional rendering and placeholder --> <!-- Remove the server-side conditional rendering and placeholder -->
<!-- Virtual scrolling will handle the display logic on the client side --> <!-- Virtual scrolling will handle the display logic on the client side -->
</div> </div>
{% endblock %} {% endblock %}
{% block main_script %} {% block main_script %}

View File

@@ -9,6 +9,8 @@ const removeSessionItemMock = vi.fn();
const RecipeContextMenuMock = vi.fn(); const RecipeContextMenuMock = vi.fn();
const refreshVirtualScrollMock = vi.fn(); const refreshVirtualScrollMock = vi.fn();
const refreshRecipesMock = vi.fn(); const refreshRecipesMock = vi.fn();
const fetchUnifiedFolderTreeMock = vi.fn();
const fetchModelFoldersMock = vi.fn();
let importManagerInstance; let importManagerInstance;
let recipeModalInstance; let recipeModalInstance;
@@ -35,6 +37,15 @@ vi.mock('../../../static/js/components/RecipeModal.js', () => ({
vi.mock('../../../static/js/state/index.js', () => ({ vi.mock('../../../static/js/state/index.js', () => ({
getCurrentPageState: getCurrentPageStateMock, getCurrentPageState: getCurrentPageStateMock,
state: {
currentPageType: 'recipes',
global: { settings: {} },
virtualScroller: {
removeItemByFilePath: vi.fn(),
updateSingleItem: vi.fn(),
refreshWithData: vi.fn(),
},
},
})); }));
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({ vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
@@ -56,6 +67,14 @@ vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
vi.mock('../../../static/js/api/recipeApi.js', () => ({ vi.mock('../../../static/js/api/recipeApi.js', () => ({
refreshRecipes: refreshRecipesMock, refreshRecipes: refreshRecipesMock,
RecipeSidebarApiClient: vi.fn(() => ({
apiConfig: { config: { displayName: 'Recipes', supportsMove: true } },
fetchUnifiedFolderTree: fetchUnifiedFolderTreeMock.mockResolvedValue({ success: true, tree: {} }),
fetchModelFolders: fetchModelFoldersMock.mockResolvedValue({ success: true, folders: [] }),
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/recipes'] }),
moveBulkModels: vi.fn(),
moveSingleModel: vi.fn(),
})),
})); }));
describe('RecipeManager', () => { describe('RecipeManager', () => {
@@ -139,6 +158,7 @@ describe('RecipeManager', () => {
tags: true, tags: true,
loraName: true, loraName: true,
loraModel: true, loraModel: true,
recursive: true,
}); });
expect(pageState.customFilter).toEqual({ expect(pageState.customFilter).toEqual({

View File

@@ -69,6 +69,10 @@ class StubRecipeScanner:
async def get_recipe_by_id(self, recipe_id: str) -> Optional[Dict[str, Any]]: async def get_recipe_by_id(self, recipe_id: str) -> Optional[Dict[str, Any]]:
return self.recipes.get(recipe_id) return self.recipes.get(recipe_id)
async def get_recipe_json_path(self, recipe_id: str) -> Optional[str]:
candidate = Path(self.recipes_dir) / f"{recipe_id}.recipe.json"
return str(candidate) if candidate.exists() else None
async def remove_recipe(self, recipe_id: str) -> None: async def remove_recipe(self, recipe_id: str) -> None:
self.removed.append(recipe_id) self.removed.append(recipe_id)
self.recipes.pop(recipe_id, None) self.recipes.pop(recipe_id, None)
@@ -119,6 +123,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 +147,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 +321,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] = []

View File

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