mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-25 23:25:43 -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",
|
"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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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] = []
|
||||||
|
|
||||||
|
|||||||
@@ -403,6 +403,89 @@ async def test_save_recipe_from_widget_allows_empty_lora(tmp_path):
|
|||||||
assert scanner.added and scanner.added[0]["loras"] == []
|
assert scanner.added and scanner.added[0]["loras"] == []
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_move_recipe_updates_paths(tmp_path):
|
||||||
|
exif_utils = DummyExifUtils()
|
||||||
|
recipes_dir = tmp_path / "recipes"
|
||||||
|
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
recipe_id = "move-me"
|
||||||
|
image_path = recipes_dir / f"{recipe_id}.webp"
|
||||||
|
json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||||
|
|
||||||
|
image_path.write_bytes(b"img")
|
||||||
|
json_path.write_text(
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": str(image_path),
|
||||||
|
"title": "Recipe",
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {},
|
||||||
|
"created_date": 0,
|
||||||
|
"modified": 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
class MoveScanner:
|
||||||
|
def __init__(self, root: Path):
|
||||||
|
self.recipes_dir = str(root)
|
||||||
|
self.recipe = {
|
||||||
|
"id": recipe_id,
|
||||||
|
"file_path": str(image_path),
|
||||||
|
"title": "Recipe",
|
||||||
|
"loras": [],
|
||||||
|
"gen_params": {},
|
||||||
|
"created_date": 0,
|
||||||
|
"modified": 0,
|
||||||
|
"folder": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
async def get_recipe_by_id(self, target_id: str):
|
||||||
|
return self.recipe if target_id == recipe_id else None
|
||||||
|
|
||||||
|
async def get_recipe_json_path(self, target_id: str):
|
||||||
|
matches = list(Path(self.recipes_dir).rglob(f"{target_id}.recipe.json"))
|
||||||
|
return str(matches[0]) if matches else None
|
||||||
|
|
||||||
|
async def update_recipe_metadata(self, target_id: str, metadata: dict):
|
||||||
|
if target_id != recipe_id:
|
||||||
|
return False
|
||||||
|
self.recipe.update(metadata)
|
||||||
|
target_path = await self.get_recipe_json_path(target_id)
|
||||||
|
if not target_path:
|
||||||
|
return False
|
||||||
|
existing = json.loads(Path(target_path).read_text())
|
||||||
|
existing.update(metadata)
|
||||||
|
Path(target_path).write_text(json.dumps(existing))
|
||||||
|
return True
|
||||||
|
|
||||||
|
async def get_cached_data(self, force_refresh: bool = False): # noqa: ARG002 - signature parity
|
||||||
|
return SimpleNamespace(raw_data=[self.recipe])
|
||||||
|
|
||||||
|
scanner = MoveScanner(recipes_dir)
|
||||||
|
service = RecipePersistenceService(
|
||||||
|
exif_utils=exif_utils,
|
||||||
|
card_preview_width=512,
|
||||||
|
logger=logging.getLogger("test"),
|
||||||
|
)
|
||||||
|
|
||||||
|
target_folder = recipes_dir / "nested"
|
||||||
|
result = await service.move_recipe(
|
||||||
|
recipe_scanner=scanner, recipe_id=recipe_id, target_path=str(target_folder)
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.payload["folder"] == "nested"
|
||||||
|
assert Path(result.payload["json_path"]).parent == target_folder
|
||||||
|
assert Path(result.payload["new_file_path"]).parent == target_folder
|
||||||
|
assert not json_path.exists()
|
||||||
|
|
||||||
|
stored = json.loads(Path(result.payload["json_path"]).read_text())
|
||||||
|
assert stored["folder"] == "nested"
|
||||||
|
assert stored["file_path"] == result.payload["new_file_path"]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_analyze_remote_video(tmp_path):
|
async def test_analyze_remote_video(tmp_path):
|
||||||
exif_utils = DummyExifUtils()
|
exif_utils = DummyExifUtils()
|
||||||
|
|||||||
Reference in New Issue
Block a user