mirror of
https://github.com/willmiao/ComfyUI-Lora-Manager.git
synced 2026-03-21 21:22:11 -03:00
feat: add recipe root directory and move recipe endpoints
- Add GET /api/lm/recipes/roots endpoint to retrieve recipe root directories - Add POST /api/lm/recipe/move endpoint to move recipes between directories - Register new endpoints in route definitions - Implement error handling for both new endpoints with proper status codes - Enable recipe management operations for better file organization
This commit is contained in:
@@ -9,6 +9,8 @@ const removeSessionItemMock = vi.fn();
|
||||
const RecipeContextMenuMock = vi.fn();
|
||||
const refreshVirtualScrollMock = vi.fn();
|
||||
const refreshRecipesMock = vi.fn();
|
||||
const fetchUnifiedFolderTreeMock = vi.fn();
|
||||
const fetchModelFoldersMock = vi.fn();
|
||||
|
||||
let importManagerInstance;
|
||||
let recipeModalInstance;
|
||||
@@ -35,6 +37,15 @@ vi.mock('../../../static/js/components/RecipeModal.js', () => ({
|
||||
|
||||
vi.mock('../../../static/js/state/index.js', () => ({
|
||||
getCurrentPageState: getCurrentPageStateMock,
|
||||
state: {
|
||||
currentPageType: 'recipes',
|
||||
global: { settings: {} },
|
||||
virtualScroller: {
|
||||
removeItemByFilePath: vi.fn(),
|
||||
updateSingleItem: vi.fn(),
|
||||
refreshWithData: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../static/js/utils/storageHelpers.js', () => ({
|
||||
@@ -56,6 +67,14 @@ vi.mock('../../../static/js/utils/infiniteScroll.js', () => ({
|
||||
|
||||
vi.mock('../../../static/js/api/recipeApi.js', () => ({
|
||||
refreshRecipes: refreshRecipesMock,
|
||||
RecipeSidebarApiClient: vi.fn(() => ({
|
||||
apiConfig: { config: { displayName: 'Recipes', supportsMove: true } },
|
||||
fetchUnifiedFolderTree: fetchUnifiedFolderTreeMock.mockResolvedValue({ success: true, tree: {} }),
|
||||
fetchModelFolders: fetchModelFoldersMock.mockResolvedValue({ success: true, folders: [] }),
|
||||
fetchModelRoots: vi.fn().mockResolvedValue({ roots: ['/recipes'] }),
|
||||
moveBulkModels: vi.fn(),
|
||||
moveSingleModel: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('RecipeManager', () => {
|
||||
@@ -139,6 +158,7 @@ describe('RecipeManager', () => {
|
||||
tags: true,
|
||||
loraName: true,
|
||||
loraModel: true,
|
||||
recursive: true,
|
||||
});
|
||||
|
||||
expect(pageState.customFilter).toEqual({
|
||||
|
||||
@@ -69,6 +69,10 @@ class StubRecipeScanner:
|
||||
async def get_recipe_by_id(self, recipe_id: str) -> Optional[Dict[str, Any]]:
|
||||
return self.recipes.get(recipe_id)
|
||||
|
||||
async def get_recipe_json_path(self, recipe_id: str) -> Optional[str]:
|
||||
candidate = Path(self.recipes_dir) / f"{recipe_id}.recipe.json"
|
||||
return str(candidate) if candidate.exists() else None
|
||||
|
||||
async def remove_recipe(self, recipe_id: str) -> None:
|
||||
self.removed.append(recipe_id)
|
||||
self.recipes.pop(recipe_id, None)
|
||||
@@ -119,6 +123,7 @@ class StubPersistenceService:
|
||||
def __init__(self, **_: Any) -> None:
|
||||
self.save_calls: List[Dict[str, Any]] = []
|
||||
self.delete_calls: List[str] = []
|
||||
self.move_calls: List[Dict[str, str]] = []
|
||||
self.save_result = SimpleNamespace(payload={"success": True, "recipe_id": "stub-id"}, status=200)
|
||||
self.delete_result = SimpleNamespace(payload={"success": True}, status=200)
|
||||
StubPersistenceService.instances.append(self)
|
||||
@@ -142,6 +147,12 @@ class StubPersistenceService:
|
||||
await recipe_scanner.remove_recipe(recipe_id)
|
||||
return self.delete_result
|
||||
|
||||
async def move_recipe(self, *, recipe_scanner, recipe_id: str, target_path: str) -> SimpleNamespace: # noqa: D401
|
||||
self.move_calls.append({"recipe_id": recipe_id, "target_path": target_path})
|
||||
return SimpleNamespace(
|
||||
payload={"success": True, "recipe_id": recipe_id, "new_file_path": target_path}, status=200
|
||||
)
|
||||
|
||||
async def update_recipe(self, *, recipe_scanner, recipe_id: str, updates: Dict[str, Any]) -> SimpleNamespace: # pragma: no cover - unused by smoke tests
|
||||
return SimpleNamespace(payload={"success": True, "recipe_id": recipe_id, "updates": updates}, status=200)
|
||||
|
||||
@@ -310,6 +321,21 @@ async def test_save_and_delete_recipe_round_trip(monkeypatch, tmp_path: Path) ->
|
||||
assert harness.persistence.delete_calls == ["saved-id"]
|
||||
|
||||
|
||||
async def test_move_recipe_invokes_persistence(monkeypatch, tmp_path: Path) -> None:
|
||||
async with recipe_harness(monkeypatch, tmp_path) as harness:
|
||||
response = await harness.client.post(
|
||||
"/api/lm/recipe/move",
|
||||
json={"recipe_id": "move-me", "target_path": str(tmp_path / "recipes" / "subdir")},
|
||||
)
|
||||
|
||||
payload = await response.json()
|
||||
assert response.status == 200
|
||||
assert payload["recipe_id"] == "move-me"
|
||||
assert harness.persistence.move_calls == [
|
||||
{"recipe_id": "move-me", "target_path": str(tmp_path / "recipes" / "subdir")}
|
||||
]
|
||||
|
||||
|
||||
async def test_import_remote_recipe(monkeypatch, tmp_path: Path) -> None:
|
||||
provider_calls: list[int] = []
|
||||
|
||||
|
||||
@@ -403,6 +403,89 @@ async def test_save_recipe_from_widget_allows_empty_lora(tmp_path):
|
||||
assert scanner.added and scanner.added[0]["loras"] == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_move_recipe_updates_paths(tmp_path):
|
||||
exif_utils = DummyExifUtils()
|
||||
recipes_dir = tmp_path / "recipes"
|
||||
recipes_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recipe_id = "move-me"
|
||||
image_path = recipes_dir / f"{recipe_id}.webp"
|
||||
json_path = recipes_dir / f"{recipe_id}.recipe.json"
|
||||
|
||||
image_path.write_bytes(b"img")
|
||||
json_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"id": recipe_id,
|
||||
"file_path": str(image_path),
|
||||
"title": "Recipe",
|
||||
"loras": [],
|
||||
"gen_params": {},
|
||||
"created_date": 0,
|
||||
"modified": 0,
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
class MoveScanner:
|
||||
def __init__(self, root: Path):
|
||||
self.recipes_dir = str(root)
|
||||
self.recipe = {
|
||||
"id": recipe_id,
|
||||
"file_path": str(image_path),
|
||||
"title": "Recipe",
|
||||
"loras": [],
|
||||
"gen_params": {},
|
||||
"created_date": 0,
|
||||
"modified": 0,
|
||||
"folder": "",
|
||||
}
|
||||
|
||||
async def get_recipe_by_id(self, target_id: str):
|
||||
return self.recipe if target_id == recipe_id else None
|
||||
|
||||
async def get_recipe_json_path(self, target_id: str):
|
||||
matches = list(Path(self.recipes_dir).rglob(f"{target_id}.recipe.json"))
|
||||
return str(matches[0]) if matches else None
|
||||
|
||||
async def update_recipe_metadata(self, target_id: str, metadata: dict):
|
||||
if target_id != recipe_id:
|
||||
return False
|
||||
self.recipe.update(metadata)
|
||||
target_path = await self.get_recipe_json_path(target_id)
|
||||
if not target_path:
|
||||
return False
|
||||
existing = json.loads(Path(target_path).read_text())
|
||||
existing.update(metadata)
|
||||
Path(target_path).write_text(json.dumps(existing))
|
||||
return True
|
||||
|
||||
async def get_cached_data(self, force_refresh: bool = False): # noqa: ARG002 - signature parity
|
||||
return SimpleNamespace(raw_data=[self.recipe])
|
||||
|
||||
scanner = MoveScanner(recipes_dir)
|
||||
service = RecipePersistenceService(
|
||||
exif_utils=exif_utils,
|
||||
card_preview_width=512,
|
||||
logger=logging.getLogger("test"),
|
||||
)
|
||||
|
||||
target_folder = recipes_dir / "nested"
|
||||
result = await service.move_recipe(
|
||||
recipe_scanner=scanner, recipe_id=recipe_id, target_path=str(target_folder)
|
||||
)
|
||||
|
||||
assert result.payload["folder"] == "nested"
|
||||
assert Path(result.payload["json_path"]).parent == target_folder
|
||||
assert Path(result.payload["new_file_path"]).parent == target_folder
|
||||
assert not json_path.exists()
|
||||
|
||||
stored = json.loads(Path(result.payload["json_path"]).read_text())
|
||||
assert stored["folder"] == "nested"
|
||||
assert stored["file_path"] == result.payload["new_file_path"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_analyze_remote_video(tmp_path):
|
||||
exif_utils = DummyExifUtils()
|
||||
|
||||
Reference in New Issue
Block a user