diff --git a/locales/de.json b/locales/de.json index 873d2284..94826973 100644 --- a/locales/de.json +++ b/locales/de.json @@ -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" } } -} +} \ No newline at end of file diff --git a/locales/en.json b/locales/en.json index df25d8e9..3345567f 100644 --- a/locales/en.json +++ b/locales/en.json @@ -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" } } -} +} \ No newline at end of file diff --git a/locales/es.json b/locales/es.json index ff0e9e16..d05018a5 100644 --- a/locales/es.json +++ b/locales/es.json @@ -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" } } -} +} \ No newline at end of file diff --git a/locales/fr.json b/locales/fr.json index d7c82004..1d36be36 100644 --- a/locales/fr.json +++ b/locales/fr.json @@ -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" } } -} +} \ No newline at end of file diff --git a/locales/he.json b/locales/he.json index 4afa4aa4..f7dace98 100644 --- a/locales/he.json +++ b/locales/he.json @@ -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" } } -} +} \ No newline at end of file diff --git a/locales/ja.json b/locales/ja.json index 7b83ec8f..336cc856 100644 --- a/locales/ja.json +++ b/locales/ja.json @@ -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" } } -} +} \ No newline at end of file diff --git a/locales/ko.json b/locales/ko.json index 9750f070..262b5c30 100644 --- a/locales/ko.json +++ b/locales/ko.json @@ -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" } } -} +} \ No newline at end of file diff --git a/locales/ru.json b/locales/ru.json index 9c22651a..851bccfa 100644 --- a/locales/ru.json +++ b/locales/ru.json @@ -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" } } -} +} \ No newline at end of file diff --git a/locales/zh-CN.json b/locales/zh-CN.json index df02db1d..2d9c5295 100644 --- a/locales/zh-CN.json +++ b/locales/zh-CN.json @@ -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": "浏览器插件教程" } } -} +} \ No newline at end of file diff --git a/locales/zh-TW.json b/locales/zh-TW.json index 0d5a8dae..e6a1967b 100644 --- a/locales/zh-TW.json +++ b/locales/zh-TW.json @@ -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" } } -} +} \ No newline at end of file diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index f6751c4a..cf582bf7 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -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() diff --git a/py/routes/recipe_route_registrar.py b/py/routes/recipe_route_registrar.py index 22f18d88..f397f501 100644 --- a/py/routes/recipe_route_registrar.py +++ b/py/routes/recipe_route_registrar.py @@ -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"), diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index efa77119..1ffb30b3 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -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: diff --git a/py/services/recipes/persistence_service.py b/py/services/recipes/persistence_service.py index 535f0853..98d7e7d5 100644 --- a/py/services/recipes/persistence_service.py +++ b/py/services/recipes/persistence_service.py @@ -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 diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js index 1421e569..632edf49 100644 --- a/static/js/api/recipeApi.js +++ b/static/js/api/recipeApi.js @@ -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, + }; } } diff --git a/static/js/components/ContextMenu/RecipeContextMenu.js b/static/js/components/ContextMenu/RecipeContextMenu.js index f9cb9719..6dcb709b 100644 --- a/static/js/components/ContextMenu/RecipeContextMenu.js +++ b/static/js/components/ContextMenu/RecipeContextMenu.js @@ -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(); diff --git a/static/js/managers/MoveManager.js b/static/js/managers/MoveManager.js index 1b23a827..88f62839 100644 --- a/static/js/managers/MoveManager.js +++ b/static/js/managers/MoveManager.js @@ -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) { diff --git a/templates/recipes.html b/templates/recipes.html index aba5ce63..f4f1d5bd 100644 --- a/templates/recipes.html +++ b/templates/recipes.html @@ -15,17 +15,26 @@ {% endblock %} @@ -34,55 +43,59 @@ {% block init_check_url %}/api/recipes?page=1&page_size=1{% endblock %} {% block content %} - -
-
-
- -
-
- -
- -
- -
- -