From 67fb205b43f0acb60883d6a6b8f7d5a983c6cd8e Mon Sep 17 00:00:00 2001 From: Will Miao Date: Tue, 25 Nov 2025 11:10:58 +0800 Subject: [PATCH 1/9] feat: add folder-based recipe organization and navigation - Add new API endpoints for folder operations: get_folders, get_folder_tree, and get_unified_folder_tree - Extend recipe listing handler to support folder and recursive filtering parameters - Register new folder-related routes in route definitions - Enable users to organize and browse recipes using folder structures --- py/routes/handlers/recipe_handlers.py | 46 +++++++++ py/routes/recipe_route_registrar.py | 3 + py/services/recipe_cache.py | 6 ++ py/services/recipe_scanner.py | 128 +++++++++++++++++++++++-- static/js/api/recipeApi.js | 63 +++++++++++- static/js/components/SidebarManager.js | 21 +++- static/js/recipes.js | 49 +++++++++- static/js/state/index.js | 4 +- templates/recipes.html | 4 +- 9 files changed, 303 insertions(+), 21 deletions(-) diff --git a/py/routes/handlers/recipe_handlers.py b/py/routes/handlers/recipe_handlers.py index 35469eaf..f6751c4a 100644 --- a/py/routes/handlers/recipe_handlers.py +++ b/py/routes/handlers/recipe_handlers.py @@ -56,6 +56,9 @@ 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_folders": self.query.get_folders, + "get_folder_tree": self.query.get_folder_tree, + "get_unified_folder_tree": self.query.get_unified_folder_tree, "share_recipe": self.sharing.share_recipe, "download_shared_recipe": self.sharing.download_shared_recipe, "get_recipe_syntax": self.query.get_recipe_syntax, @@ -149,6 +152,8 @@ class RecipeListingHandler: page_size = int(request.query.get("page_size", "20")) sort_by = request.query.get("sort_by", "date") search = request.query.get("search") + folder = request.query.get("folder") + recursive = request.query.get("recursive", "true").lower() == "true" search_options = { "title": request.query.get("search_title", "true").lower() == "true", @@ -193,6 +198,8 @@ class RecipeListingHandler: filters=filters, search_options=search_options, lora_hash=lora_hash, + folder=folder, + recursive=recursive, ) for item in result.get("items", []): @@ -299,6 +306,45 @@ 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_folders(self, request: web.Request) -> web.Response: + try: + await self._ensure_dependencies_ready() + recipe_scanner = self._recipe_scanner_getter() + if recipe_scanner is None: + raise RuntimeError("Recipe scanner unavailable") + + folders = await recipe_scanner.get_folders() + return web.json_response({"success": True, "folders": folders}) + except Exception as exc: + self._logger.error("Error retrieving recipe folders: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + + async def get_folder_tree(self, request: web.Request) -> web.Response: + try: + await self._ensure_dependencies_ready() + recipe_scanner = self._recipe_scanner_getter() + if recipe_scanner is None: + raise RuntimeError("Recipe scanner unavailable") + + folder_tree = await recipe_scanner.get_folder_tree() + return web.json_response({"success": True, "tree": folder_tree}) + except Exception as exc: + self._logger.error("Error retrieving recipe folder tree: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + + async def get_unified_folder_tree(self, request: web.Request) -> web.Response: + try: + await self._ensure_dependencies_ready() + recipe_scanner = self._recipe_scanner_getter() + if recipe_scanner is None: + raise RuntimeError("Recipe scanner unavailable") + + folder_tree = await recipe_scanner.get_folder_tree() + return web.json_response({"success": True, "tree": folder_tree}) + except Exception as exc: + self._logger.error("Error retrieving unified recipe folder tree: %s", exc, exc_info=True) + return web.json_response({"success": False, "error": str(exc)}, status=500) + async def get_recipes_for_lora(self, request: web.Request) -> web.Response: try: await self._ensure_dependencies_ready() diff --git a/py/routes/recipe_route_registrar.py b/py/routes/recipe_route_registrar.py index 18bf4cba..22f18d88 100644 --- a/py/routes/recipe_route_registrar.py +++ b/py/routes/recipe_route_registrar.py @@ -27,6 +27,9 @@ 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/folders", "get_folders"), + RouteDefinition("GET", "/api/lm/recipes/folder-tree", "get_folder_tree"), + RouteDefinition("GET", "/api/lm/recipes/unified-folder-tree", "get_unified_folder_tree"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share", "share_recipe"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/share/download", "download_shared_recipe"), RouteDefinition("GET", "/api/lm/recipe/{recipe_id}/syntax", "get_recipe_syntax"), diff --git a/py/services/recipe_cache.py b/py/services/recipe_cache.py index ac28b3aa..279c9e37 100644 --- a/py/services/recipe_cache.py +++ b/py/services/recipe_cache.py @@ -7,12 +7,18 @@ from natsort import natsorted @dataclass class RecipeCache: """Cache structure for Recipe data""" + raw_data: List[Dict] sorted_by_name: List[Dict] sorted_by_date: List[Dict] + folders: List[str] | None = None + folder_tree: Dict | None = None def __post_init__(self): self._lock = asyncio.Lock() + # Normalize optional metadata containers + self.folders = self.folders or [] + self.folder_tree = self.folder_tree or {} async def resort(self, name_only: bool = False): """Resort all cached data views""" diff --git a/py/services/recipe_scanner.py b/py/services/recipe_scanner.py index c7ac2842..efa77119 100644 --- a/py/services/recipe_scanner.py +++ b/py/services/recipe_scanner.py @@ -1,7 +1,9 @@ -import os -import logging +from __future__ import annotations + import asyncio import json +import logging +import os import time from typing import Any, Dict, Iterable, List, Optional, Set, Tuple from ..config import config @@ -117,7 +119,9 @@ class RecipeScanner: self._cache = RecipeCache( raw_data=[], sorted_by_name=[], - sorted_by_date=[] + sorted_by_date=[], + folders=[], + folder_tree={}, ) # Mark as initializing to prevent concurrent initializations @@ -218,6 +222,7 @@ class RecipeScanner: # Update cache with the collected data self._cache.raw_data = recipes + self._update_folder_metadata(self._cache) # Create a simplified resort function that doesn't use await if hasattr(self._cache, "resort"): @@ -336,6 +341,9 @@ class RecipeScanner: if not self._cache: return + # Keep folder metadata up to date alongside sort order + self._update_folder_metadata() + async def _resort_wrapper() -> None: try: await self._cache.resort(name_only=name_only) @@ -346,6 +354,75 @@ class RecipeScanner: self._resort_tasks.add(task) task.add_done_callback(lambda finished: self._resort_tasks.discard(finished)) + def _calculate_folder(self, recipe_path: str) -> str: + """Calculate a normalized folder path relative to ``recipes_dir``.""" + + recipes_dir = self.recipes_dir + if not recipes_dir: + return "" + + try: + recipe_dir = os.path.dirname(os.path.normpath(recipe_path)) + relative_dir = os.path.relpath(recipe_dir, recipes_dir) + if relative_dir in (".", ""): + return "" + return relative_dir.replace(os.path.sep, "/") + except Exception: + return "" + + def _build_folder_tree(self, folders: list[str]) -> dict: + """Build a nested folder tree structure from relative folder paths.""" + + tree: dict[str, dict] = {} + for folder in folders: + if not folder: + continue + + parts = folder.split("/") + current_level = tree + + for part in parts: + if part not in current_level: + current_level[part] = {} + current_level = current_level[part] + + return tree + + def _update_folder_metadata(self, cache: RecipeCache | None = None) -> None: + """Ensure folder lists and tree metadata are synchronized with cache contents.""" + + cache = cache or self._cache + if cache is None: + return + + folders: set[str] = set() + for item in cache.raw_data: + folder_value = item.get("folder", "") + if folder_value is None: + folder_value = "" + if folder_value == ".": + folder_value = "" + normalized = str(folder_value).replace("\\", "/") + item["folder"] = normalized + folders.add(normalized) + + cache.folders = sorted(folders, key=lambda entry: entry.lower()) + cache.folder_tree = self._build_folder_tree(cache.folders) + + async def get_folders(self) -> list[str]: + """Return a sorted list of recipe folders relative to the recipes root.""" + + cache = await self.get_cached_data() + self._update_folder_metadata(cache) + return cache.folders + + async def get_folder_tree(self) -> dict: + """Return a hierarchical tree of recipe folders for sidebar navigation.""" + + cache = await self.get_cached_data() + self._update_folder_metadata(cache) + return cache.folder_tree + @property def recipes_dir(self) -> str: """Get path to recipes directory""" @@ -362,11 +439,14 @@ class RecipeScanner: """Get cached recipe data, refresh if needed""" # If cache is already initialized and no refresh is needed, return it immediately if self._cache is not None and not force_refresh: + self._update_folder_metadata() return self._cache # If another initialization is already in progress, wait for it to complete if self._is_initializing and not force_refresh: - return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[]) + return self._cache or RecipeCache( + raw_data=[], sorted_by_name=[], sorted_by_date=[], folders=[], folder_tree={} + ) # If force refresh is requested, initialize the cache directly if force_refresh: @@ -384,11 +464,14 @@ class RecipeScanner: self._cache = RecipeCache( raw_data=raw_data, sorted_by_name=[], - sorted_by_date=[] + sorted_by_date=[], + folders=[], + folder_tree={}, ) - + # Resort cache await self._cache.resort() + self._update_folder_metadata(self._cache) return self._cache @@ -398,7 +481,9 @@ class RecipeScanner: self._cache = RecipeCache( raw_data=[], sorted_by_name=[], - sorted_by_date=[] + sorted_by_date=[], + folders=[], + folder_tree={}, ) return self._cache finally: @@ -409,7 +494,9 @@ class RecipeScanner: logger.error(f"Unexpected error in get_cached_data: {e}") # Return the cache (may be empty or partially initialized) - return self._cache or RecipeCache(raw_data=[], sorted_by_name=[], sorted_by_date=[]) + return self._cache or RecipeCache( + raw_data=[], sorted_by_name=[], sorted_by_date=[], folders=[], folder_tree={} + ) async def refresh_cache(self, force: bool = False) -> RecipeCache: """Public helper to refresh or return the recipe cache.""" @@ -424,6 +511,7 @@ class RecipeScanner: cache = await self.get_cached_data() await cache.add_recipe(recipe_data, resort=False) + self._update_folder_metadata(cache) self._schedule_resort() async def remove_recipe(self, recipe_id: str) -> bool: @@ -437,6 +525,7 @@ class RecipeScanner: if removed is None: return False + self._update_folder_metadata(cache) self._schedule_resort() return True @@ -521,6 +610,9 @@ class RecipeScanner: if path_updated: self._write_recipe_file(recipe_path, recipe_data) + + # Track folder placement relative to recipes directory + recipe_data['folder'] = recipe_data.get('folder') or self._calculate_folder(recipe_path) # Ensure loras array exists if 'loras' not in recipe_data: @@ -914,7 +1006,7 @@ class RecipeScanner: return await self._lora_scanner.get_model_info_by_name(name) - async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'date', search: str = None, filters: dict = None, search_options: dict = None, lora_hash: str = None, bypass_filters: bool = True): + async def get_paginated_data(self, page: int, page_size: int, sort_by: str = 'date', search: str = None, filters: dict = None, search_options: dict = None, lora_hash: str = None, bypass_filters: bool = True, folder: str | None = None, recursive: bool = True): """Get paginated and filtered recipe data Args: @@ -926,6 +1018,8 @@ class RecipeScanner: search_options: Dictionary of search options to apply lora_hash: Optional SHA256 hash of a LoRA to filter recipes by bypass_filters: If True, ignore other filters when a lora_hash is provided + folder: Optional folder filter relative to recipes directory + recursive: Whether to include recipes in subfolders of the selected folder """ cache = await self.get_cached_data() @@ -961,6 +1055,22 @@ class RecipeScanner: # Skip further filtering if we're only filtering by LoRA hash with bypass enabled if not (lora_hash and bypass_filters): + # Apply folder filter before other criteria + normalized_folder = (folder or "").strip("/") + if normalized_folder: + def matches_folder(item_folder: str) -> bool: + item_path = (item_folder or "").strip("/") + if not item_path: + return False + if recursive: + return item_path == normalized_folder or item_path.startswith(f"{normalized_folder}/") + return item_path == normalized_folder + + filtered_data = [ + item for item in filtered_data + if matches_folder(item.get('folder', '')) + ] + # Apply search filter if search: # Default search options if none provided diff --git a/static/js/api/recipeApi.js b/static/js/api/recipeApi.js index ece9938f..1421e569 100644 --- a/static/js/api/recipeApi.js +++ b/static/js/api/recipeApi.js @@ -2,6 +2,24 @@ import { RecipeCard } from '../components/RecipeCard.js'; import { state, getCurrentPageState } from '../state/index.js'; import { showToast } from '../utils/uiHelpers.js'; +const RECIPE_ENDPOINTS = { + list: '/api/lm/recipes', + detail: '/api/lm/recipe', + scan: '/api/lm/recipes/scan', + update: '/api/lm/recipe', + folders: '/api/lm/recipes/folders', + folderTree: '/api/lm/recipes/folder-tree', + unifiedFolderTree: '/api/lm/recipes/unified-folder-tree', +}; + +const RECIPE_SIDEBAR_CONFIG = { + config: { + displayName: 'Recipes', + supportsMove: false, + }, + endpoints: RECIPE_ENDPOINTS, +}; + /** * Fetch recipes with pagination for virtual scrolling * @param {number} page - Page number to fetch @@ -17,11 +35,18 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) { page_size: pageSize || pageState.pageSize || 20, sort_by: pageState.sortBy }); + + if (pageState.activeFolder) { + params.append('folder', pageState.activeFolder); + params.append('recursive', pageState.searchOptions?.recursive !== false); + } else if (pageState.searchOptions?.recursive !== undefined) { + params.append('recursive', pageState.searchOptions.recursive); + } // If we have a specific recipe ID to load if (pageState.customFilter?.active && pageState.customFilter?.recipeId) { // Special case: load specific recipe - const response = await fetch(`/api/lm/recipe/${pageState.customFilter.recipeId}`); + const response = await fetch(`${RECIPE_ENDPOINTS.detail}/${pageState.customFilter.recipeId}`); if (!response.ok) { throw new Error(`Failed to load recipe: ${response.statusText}`); @@ -78,7 +103,7 @@ export async function fetchRecipesPage(page = 1, pageSize = 100) { } // Fetch recipes - const response = await fetch(`/api/lm/recipes?${params.toString()}`); + const response = await fetch(`${RECIPE_ENDPOINTS.list}?${params.toString()}`); if (!response.ok) { throw new Error(`Failed to load recipes: ${response.statusText}`); @@ -213,7 +238,7 @@ export async function refreshRecipes() { state.loadingManager.showSimpleLoading('Refreshing recipes...'); // Call the API endpoint to rebuild the recipe cache - const response = await fetch('/api/lm/recipes/scan'); + const response = await fetch(RECIPE_ENDPOINTS.scan); if (!response.ok) { const data = await response.json(); @@ -280,7 +305,7 @@ export async function updateRecipeMetadata(filePath, updates) { const basename = filePath.split('/').pop().split('\\').pop(); const recipeId = basename.substring(0, basename.lastIndexOf('.')); - const response = await fetch(`/api/lm/recipe/${recipeId}/update`, { + const response = await fetch(`${RECIPE_ENDPOINTS.update}/${recipeId}/update`, { method: 'PUT', headers: { 'Content-Type': 'application/json', @@ -306,3 +331,33 @@ export async function updateRecipeMetadata(filePath, updates) { state.loadingManager.hide(); } } + +export class RecipeSidebarApiClient { + constructor() { + this.apiConfig = RECIPE_SIDEBAR_CONFIG; + } + + async fetchUnifiedFolderTree() { + const response = await fetch(this.apiConfig.endpoints.unifiedFolderTree); + if (!response.ok) { + throw new Error('Failed to fetch recipe folder tree'); + } + return response.json(); + } + + async fetchModelFolders() { + const response = await fetch(this.apiConfig.endpoints.folders); + if (!response.ok) { + throw new Error('Failed to fetch recipe folders'); + } + return response.json(); + } + + async moveBulkModels() { + throw new Error('Recipe move operations are not supported.'); + } + + async moveSingleModel() { + throw new Error('Recipe move operations are not supported.'); + } +} diff --git a/static/js/components/SidebarManager.js b/static/js/components/SidebarManager.js index f4ed13bb..8c21643a 100644 --- a/static/js/components/SidebarManager.js +++ b/static/js/components/SidebarManager.js @@ -77,7 +77,9 @@ export class SidebarManager { this.pageControls = pageControls; this.pageType = pageControls.pageType; this.lastPageControls = pageControls; - this.apiClient = getModelApiClient(); + this.apiClient = pageControls?.getSidebarApiClient?.() + || pageControls?.sidebarApiClient + || getModelApiClient(); // Set initial sidebar state immediately (hidden by default) this.setInitialSidebarState(); @@ -205,6 +207,10 @@ export class SidebarManager { } initializeDragAndDrop() { + if (this.apiClient?.apiConfig?.config?.supportsMove === false) { + return; + } + if (!this.dragHandlersInitialized) { document.addEventListener('dragstart', this.handleCardDragStart); document.addEventListener('dragend', this.handleCardDragEnd); @@ -416,7 +422,14 @@ export class SidebarManager { } if (!this.apiClient) { - this.apiClient = getModelApiClient(); + this.apiClient = this.pageControls?.getSidebarApiClient?.() + || this.pageControls?.sidebarApiClient + || getModelApiClient(); + } + + if (this.apiClient?.apiConfig?.config?.supportsMove === false) { + showToast('toast.models.moveFailed', { message: translate('sidebar.dragDrop.moveUnsupported', {}, 'Move not supported for this page') }, 'error'); + return false; } const rootPath = this.draggedRootPath ? this.draggedRootPath.replace(/\\/g, '/') : ''; @@ -470,7 +483,9 @@ export class SidebarManager { } async init() { - this.apiClient = getModelApiClient(); + this.apiClient = this.pageControls?.getSidebarApiClient?.() + || this.pageControls?.sidebarApiClient + || getModelApiClient(); // Set initial sidebar state immediately (hidden by default) this.setInitialSidebarState(); diff --git a/static/js/recipes.js b/static/js/recipes.js index 293bb8d9..99327c75 100644 --- a/static/js/recipes.js +++ b/static/js/recipes.js @@ -2,17 +2,46 @@ import { appCore } from './core.js'; import { ImportManager } from './managers/ImportManager.js'; import { RecipeModal } from './components/RecipeModal.js'; -import { getCurrentPageState } from './state/index.js'; +import { state, getCurrentPageState } from './state/index.js'; import { getSessionItem, removeSessionItem } from './utils/storageHelpers.js'; import { RecipeContextMenu } from './components/ContextMenu/index.js'; import { DuplicatesManager } from './components/DuplicatesManager.js'; import { refreshVirtualScroll } from './utils/infiniteScroll.js'; -import { refreshRecipes } from './api/recipeApi.js'; +import { refreshRecipes, RecipeSidebarApiClient } from './api/recipeApi.js'; +import { sidebarManager } from './components/SidebarManager.js'; + +class RecipePageControls { + constructor() { + this.pageType = 'recipes'; + this.pageState = getCurrentPageState(); + this.sidebarApiClient = new RecipeSidebarApiClient(); + } + + async resetAndReload() { + refreshVirtualScroll(); + } + + async refreshModels(fullRebuild = false) { + if (fullRebuild) { + await refreshRecipes(); + return; + } + + refreshVirtualScroll(); + } + + getSidebarApiClient() { + return this.sidebarApiClient; + } +} class RecipeManager { constructor() { // Get page state this.pageState = getCurrentPageState(); + + // Page controls for shared sidebar behaviors + this.pageControls = new RecipePageControls(); // Initialize ImportManager this.importManager = new ImportManager(); @@ -51,10 +80,23 @@ class RecipeManager { // Expose necessary functions to the page this._exposeGlobalFunctions(); + + // Initialize sidebar navigation + await this._initSidebar(); // Initialize common page features appCore.initializePageFeatures(); } + + async _initSidebar() { + try { + sidebarManager.setHostPageControls(this.pageControls); + const shouldShowSidebar = state?.global?.settings?.show_folder_sidebar !== false; + await sidebarManager.setSidebarEnabled(shouldShowSidebar); + } catch (error) { + console.error('Failed to initialize recipe sidebar:', error); + } + } _initSearchOptions() { // Ensure recipes search options are properly initialized @@ -63,7 +105,8 @@ class RecipeManager { title: true, // Recipe title tags: true, // Recipe tags loraName: true, // LoRA file name - loraModel: true // LoRA model name + loraModel: true, // LoRA model name + recursive: true }; } } diff --git a/static/js/state/index.js b/static/js/state/index.js index 4ee59b55..69d05d5b 100644 --- a/static/js/state/index.js +++ b/static/js/state/index.js @@ -96,12 +96,14 @@ export const state = { isLoading: false, hasMore: true, sortBy: 'date', + activeFolder: getStorageItem('recipes_activeFolder'), searchManager: null, searchOptions: { title: true, tags: true, loraName: true, - loraModel: true + loraModel: true, + recursive: getStorageItem('recipes_recursiveSearch', true), }, filters: { baseModel: [], diff --git a/templates/recipes.html b/templates/recipes.html index 202791a2..aba5ce63 100644 --- a/templates/recipes.html +++ b/templates/recipes.html @@ -75,7 +75,9 @@ - + + {% include 'components/folder_sidebar.html' %} +
From 3f646aa0c91253e3f7db5a9c5adcc05821d741b4 Mon Sep 17 00:00:00 2001 From: Will Miao Date: Tue, 25 Nov 2025 17:41:24 +0800 Subject: [PATCH 2/9] 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 --- locales/de.json | 8 +- locales/en.json | 18 ++-- locales/es.json | 8 +- locales/fr.json | 8 +- locales/he.json | 8 +- locales/ja.json | 8 +- locales/ko.json | 8 +- locales/ru.json | 8 +- locales/zh-CN.json | 8 +- locales/zh-TW.json | 8 +- py/routes/handlers/recipe_handlers.py | 44 +++++++++ py/routes/recipe_route_registrar.py | 2 + py/services/recipe_scanner.py | 36 +++++-- py/services/recipes/persistence_service.py | 98 ++++++++++++++++--- static/js/api/recipeApi.js | 91 +++++++++++++++-- .../ContextMenu/RecipeContextMenu.js | 4 + static/js/managers/MoveManager.js | 20 +++- templates/recipes.html | 97 ++++++++++-------- tests/frontend/pages/recipesPage.test.js | 20 ++++ tests/routes/test_recipe_routes.py | 26 +++++ tests/services/test_recipe_services.py | 83 ++++++++++++++++ 21 files changed, 501 insertions(+), 110 deletions(-) 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 %} - -
-
-
- -
-
- -
- -
- -
- -