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:
+117
@@ -97,6 +97,123 @@ class MealieClient:
|
||||
return [{"name": t["name"], "slug": t["slug"], "id": t["id"]}
|
||||
for t in r.json().get("items", [])]
|
||||
|
||||
def list_recipes(self, search: str = "", tag_ids: list[str] | None = None,
|
||||
page: int = 1, per_page: int = 50) -> dict:
|
||||
"""List recipes with optional search and tag filtering.
|
||||
|
||||
Returns {"items": [...], "page": int, "per_page": int, "total": int}.
|
||||
"""
|
||||
params: dict = {
|
||||
"page": page, "perPage": per_page,
|
||||
"orderBy": "name", "orderDirection": "asc",
|
||||
}
|
||||
if search:
|
||||
params["search"] = search
|
||||
if tag_ids:
|
||||
# Mealie accepts repeated ?tags=<id>&tags=<id> params.
|
||||
# requests encodes list values as repeated keys automatically.
|
||||
params["tags"] = tag_ids
|
||||
r = self.session.get(f"{self.api_url}/api/recipes", params=params,
|
||||
timeout=15)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
items = []
|
||||
for item in data.get("items", []):
|
||||
image_url = ""
|
||||
if item.get("image"):
|
||||
image_url = f"{self.base_url}/api/media/recipes/{item['id']}/images/min-original.webp"
|
||||
items.append({
|
||||
"id": item["slug"],
|
||||
"name": item.get("name", ""),
|
||||
"image": image_url,
|
||||
"tags": [t["name"] for t in item.get("tags", [])],
|
||||
"url": f"{self.base_url}/g/home/r/{item['slug']}",
|
||||
})
|
||||
return {
|
||||
"items": items,
|
||||
"page": data.get("page", page),
|
||||
"per_page": data.get("perPage", per_page),
|
||||
"total": data.get("total", 0),
|
||||
}
|
||||
|
||||
def get_recipe(self, slug: str) -> dict:
|
||||
"""Fetch a single recipe and return it in the common format."""
|
||||
r = self.session.get(f"{self.api_url}/api/recipes/{slug}", timeout=15)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
|
||||
# Parse ingredients
|
||||
ingredients = []
|
||||
current_group = None
|
||||
for ing in data.get("recipeIngredient", []):
|
||||
group_title = ing.get("title", "")
|
||||
if group_title and group_title != current_group:
|
||||
ingredients.append({"group": group_title})
|
||||
current_group = group_title
|
||||
food_name = ""
|
||||
if ing.get("food"):
|
||||
food_name = ing["food"].get("name", "")
|
||||
unit_name = ""
|
||||
if ing.get("unit"):
|
||||
unit_name = ing["unit"].get("name", "")
|
||||
qty = ing.get("quantity", 0)
|
||||
qty_str = ""
|
||||
if qty:
|
||||
qty_str = str(int(qty)) if qty == int(qty) else str(qty)
|
||||
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("recipeInstructions", []):
|
||||
text = step.get("text", "").strip()
|
||||
if text:
|
||||
instructions.append(text)
|
||||
|
||||
# Parse tags
|
||||
tags = [t["name"] for t in data.get("tags", [])]
|
||||
|
||||
# Image URL
|
||||
image_url = ""
|
||||
if data.get("image"):
|
||||
image_url = f"{self.base_url}/api/media/recipes/{data['id']}/images/original.webp"
|
||||
|
||||
return {
|
||||
"title": data.get("name", ""),
|
||||
"description": data.get("description", ""),
|
||||
"image_url": image_url,
|
||||
"ingredients": ingredients,
|
||||
"instructions": instructions,
|
||||
"tags": tags,
|
||||
"original_url": data.get("orgURL", ""),
|
||||
}
|
||||
|
||||
def update_recipe(self, slug: str, recipe: dict) -> None:
|
||||
"""Update an existing recipe from a common-format dict."""
|
||||
payload = self._build_payload(recipe)
|
||||
tag_names = recipe.get("tags", [])
|
||||
if tag_names:
|
||||
payload["tags"] = self._ensure_tags(tag_names)
|
||||
else:
|
||||
payload["tags"] = []
|
||||
r = self.session.patch(
|
||||
f"{self.api_url}/api/recipes/{slug}",
|
||||
json=payload,
|
||||
timeout=15,
|
||||
)
|
||||
r.raise_for_status()
|
||||
|
||||
def delete_recipe(self, slug: str) -> None:
|
||||
"""Delete a recipe by slug."""
|
||||
r = self.session.delete(f"{self.api_url}/api/recipes/{slug}", timeout=10)
|
||||
r.raise_for_status()
|
||||
|
||||
def create_recipe(self, recipe: dict) -> str:
|
||||
"""Create a recipe in Mealie from a scraper result dict.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user