feat: recipe management page — browse, search, edit, delete recipes
New /recipes page with backend switching (Mealie/Tandoor), full-text search, tag filtering, multi-select bulk delete, per-recipe edit/delete buttons, and a full recipe editor reusing the import preview form. New API client methods: list_recipes, get_recipe, update_recipe, delete_recipe for both MealieClient and TandoorClient. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+104
@@ -80,6 +80,110 @@ class TandoorClient:
|
||||
params = {} # next URL already has params
|
||||
return results
|
||||
|
||||
def list_recipes(self, search: str = "", keyword_ids: list[int] | None = None,
|
||||
page: int = 1, per_page: int = 50) -> dict:
|
||||
"""List recipes with optional search and keyword filtering.
|
||||
|
||||
Returns {"items": [...], "page": int, "per_page": int, "total": int}.
|
||||
"""
|
||||
params: dict = {
|
||||
"limit": per_page,
|
||||
"offset": (page - 1) * per_page,
|
||||
"format": "json",
|
||||
}
|
||||
if search:
|
||||
params["query"] = search
|
||||
if keyword_ids:
|
||||
params["keywords"] = keyword_ids
|
||||
r = self.session.get(f"{self.api_url}/api/recipe/", params=params,
|
||||
timeout=15)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
items = []
|
||||
for item in data.get("results", []):
|
||||
items.append({
|
||||
"id": item["id"],
|
||||
"name": item.get("name", ""),
|
||||
"image": item.get("image") or "",
|
||||
"tags": [k["name"] for k in item.get("keywords", [])],
|
||||
"url": f"{self.base_url}/view/recipe/{item['id']}",
|
||||
})
|
||||
return {
|
||||
"items": items,
|
||||
"page": page,
|
||||
"per_page": per_page,
|
||||
"total": data.get("count", 0),
|
||||
}
|
||||
|
||||
def get_recipe(self, recipe_id: int) -> dict:
|
||||
"""Fetch a single recipe and return it in the common format."""
|
||||
r = self.session.get(f"{self.api_url}/api/recipe/{recipe_id}/",
|
||||
timeout=15)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
# Parse ingredients from all steps
|
||||
ingredients = []
|
||||
for step in data.get("steps", []):
|
||||
for ing in step.get("ingredients", []):
|
||||
if ing.get("is_header"):
|
||||
ingredients.append({"group": ing.get("note", "")})
|
||||
continue
|
||||
food_name = ""
|
||||
if ing.get("food"):
|
||||
food_name = ing["food"].get("name", "")
|
||||
unit_name = ""
|
||||
if ing.get("unit"):
|
||||
unit_name = ing["unit"].get("name", "")
|
||||
amount = ing.get("amount", 0)
|
||||
qty_str = ""
|
||||
if amount:
|
||||
qty_str = str(int(amount)) if amount == int(amount) else str(amount)
|
||||
note = ing.get("note", "")
|
||||
if food_name or qty_str or unit_name:
|
||||
ingredients.append({
|
||||
"quantity": qty_str,
|
||||
"unit": unit_name,
|
||||
"food": food_name,
|
||||
"extra": note,
|
||||
})
|
||||
|
||||
# Parse instructions
|
||||
instructions = []
|
||||
for step in data.get("steps", []):
|
||||
text = step.get("instruction", "").strip()
|
||||
if text:
|
||||
instructions.append(text)
|
||||
|
||||
# Parse tags
|
||||
tags = [k["name"] for k in data.get("keywords", [])]
|
||||
|
||||
return {
|
||||
"title": data.get("name", ""),
|
||||
"description": data.get("description", ""),
|
||||
"image_url": data.get("image") or "",
|
||||
"ingredients": ingredients,
|
||||
"instructions": instructions,
|
||||
"tags": tags,
|
||||
"original_url": data.get("source_url", ""),
|
||||
}
|
||||
|
||||
def update_recipe(self, recipe_id: int, recipe: dict) -> None:
|
||||
"""Update an existing recipe from a common-format dict."""
|
||||
payload = self._build_payload(recipe)
|
||||
r = self.session.put(
|
||||
f"{self.api_url}/api/recipe/{recipe_id}/",
|
||||
json=payload,
|
||||
timeout=15,
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
def delete_recipe(self, recipe_id: int) -> None:
|
||||
"""Delete a recipe by ID."""
|
||||
r = self.session.delete(f"{self.api_url}/api/recipe/{recipe_id}/",
|
||||
timeout=10)
|
||||
r.raise_for_status()
|
||||
|
||||
def find_duplicate(self, url: str, title: str = "") -> dict | None:
|
||||
"""Check if a recipe with this source URL already exists."""
|
||||
if not url and not title:
|
||||
|
||||
Reference in New Issue
Block a user