From 19cbd505d47c6e233c1919f9869311856d09e992 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Thu, 26 Feb 2026 08:30:26 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20recipe=20management=20page=20=E2=80=94?= =?UTF-8?q?=20browse,=20search,=20edit,=20delete=20recipes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 14 + README.md | 4 +- app/main.py | 160 ++++++++ app/mealie.py | 117 ++++++ app/tandoor.py | 104 ++++++ app/templates/base.html | 1 + app/templates/recipe_edit.html | 575 +++++++++++++++++++++++++++++ app/templates/recipes.html | 644 +++++++++++++++++++++++++++++++++ 8 files changed, 1618 insertions(+), 1 deletion(-) create mode 100644 app/templates/recipe_edit.html create mode 100644 app/templates/recipes.html diff --git a/CHANGELOG.md b/CHANGELOG.md index a82152d..3c4335a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## v0.9.0 (2026-02-26) + +### Added +- **Recipe management page** (`/recipes`): browse, search, filter, edit, and delete recipes from Mealie and Tandoor + - Backend switching tabs (Mealie / Tandoor) with only configured backends shown + - Full-text search with 300ms debounce + - Tag/keyword filtering with searchable dropdown + - Multi-select with bulk delete (confirmation dialog) + - Per-recipe edit and delete buttons + - Recipe edit page with the same form as the import preview (ingredients, instructions, tags) + - Pagination for large recipe collections +- New API client methods: `list_recipes()`, `get_recipe()`, `update_recipe()`, `delete_recipe()` for both Mealie and Tandoor +- New API endpoints: `GET /api/recipes/`, `GET/PUT /api/recipes//`, `POST /api/recipes//delete`, `GET /api/tags/` + ## v0.8.4 (2026-02-26) ### Added diff --git a/README.md b/README.md index 0a81387..0f8b487 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Docker container for importing recipes from Hungarian websites into [Mealie](htt **Problem**: Mealie's and Tandoor's built-in URL import cannot parse ingredients and instructions from Hungarian recipe sites like mindmegette.hu. -**Solution**: This container provides a web UI that scrapes Hungarian recipe pages with site-specific parsers, lets you review and edit the extracted data, then pushes it to Mealie and/or Tandoor via their REST APIs. Supports both single recipe import and bulk import of multiple URLs. +**Solution**: This container provides a web UI that scrapes Hungarian recipe pages with site-specific parsers, lets you review and edit the extracted data, then pushes it to Mealie and/or Tandoor via their REST APIs. Supports single recipe import, bulk import, and recipe management (browse, search, edit, delete). ## Architecture @@ -15,10 +15,12 @@ Docker container for importing recipes from Hungarian websites into [Mealie](htt │ Flask + Gunicorn │ │ ├── /settings → Configure Mealie & Tandoor │ │ ├── /import → Single or bulk import │ +│ ├── /recipes → Browse, search, edit, delete │ │ ├── /scrape → AJAX: parse recipe HTML │ │ ├── /send → AJAX: push to Mealie API │ │ ├── /send-tandoor → AJAX: push to Tandoor API │ │ ├── /tags → AJAX: list tags from both │ +│ ├── /api/recipes/* → AJAX: recipe CRUD endpoints │ │ └── /health → Health check │ │ │ │ Modules: │ diff --git a/app/main.py b/app/main.py index 9c72136..682cb5a 100644 --- a/app/main.py +++ b/app/main.py @@ -249,6 +249,166 @@ def send_to_tandoor(): return jsonify({"ok": False, "error": str(exc), "trace": traceback.format_exc()}) +@app.route("/recipes") +def recipes_page(): + """Show the recipes management page.""" + cfg = config.load() + has_mealie = bool(cfg.get("mealie_url") and cfg.get("mealie_api_key")) + has_tandoor = bool(cfg.get("tandoor_url") and cfg.get("tandoor_api_key")) + if not has_mealie and not has_tandoor: + flash("Először állíts be legalább egy szolgáltatást.", "warning") + return redirect(url_for("settings")) + return render_template("recipes.html", cfg=cfg, version=VERSION, + has_mealie=has_mealie, has_tandoor=has_tandoor) + + +@app.route("/recipes///edit") +def recipe_edit_page(backend, recipe_id): + """Show the recipe edit page.""" + cfg = config.load() + has_mealie = bool(cfg.get("mealie_url") and cfg.get("mealie_api_key")) + has_tandoor = bool(cfg.get("tandoor_url") and cfg.get("tandoor_api_key")) + return render_template("recipe_edit.html", cfg=cfg, version=VERSION, + backend=backend, recipe_id=recipe_id, + has_mealie=has_mealie, has_tandoor=has_tandoor) + + +@app.route("/api/recipes/") +def api_list_recipes(backend): + """AJAX — list recipes from the given backend.""" + cfg = config.load() + search = request.args.get("search", "").strip() + page = int(request.args.get("page", 1)) + per_page = int(request.args.get("per_page", 50)) + tag_ids_raw = request.args.get("tag_ids", "").strip() + + try: + if backend == "mealie": + if not cfg.get("mealie_url") or not cfg.get("mealie_api_key"): + return jsonify({"ok": False, "error": "Mealie nincs beállítva."}) + client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"], + api_url=config.MEALIE_INTERNAL_URL) + tag_ids = [t for t in tag_ids_raw.split(",") if t] if tag_ids_raw else None + result = client.list_recipes(search=search, tag_ids=tag_ids, + page=page, per_page=per_page) + elif backend == "tandoor": + if not cfg.get("tandoor_url") or not cfg.get("tandoor_api_key"): + return jsonify({"ok": False, "error": "Tandoor nincs beállítva."}) + client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"], + api_url=config.TANDOOR_INTERNAL_URL) + tag_ids = [int(t) for t in tag_ids_raw.split(",") if t] if tag_ids_raw else None + result = client.list_recipes(search=search, keyword_ids=tag_ids, + page=page, per_page=per_page) + else: + return jsonify({"ok": False, "error": "Ismeretlen backend."}) + return jsonify({"ok": True, **result}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}) + + +@app.route("/api/recipes//") +def api_get_recipe(backend, recipe_id): + """AJAX — get a single recipe in common format.""" + cfg = config.load() + try: + if backend == "mealie": + client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"], + api_url=config.MEALIE_INTERNAL_URL) + recipe = client.get_recipe(recipe_id) + elif backend == "tandoor": + client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"], + api_url=config.TANDOOR_INTERNAL_URL) + recipe = client.get_recipe(int(recipe_id)) + else: + return jsonify({"ok": False, "error": "Ismeretlen backend."}) + return jsonify({"ok": True, "recipe": recipe}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}) + + +@app.route("/api/recipes//", methods=["PUT"]) +def api_update_recipe(backend, recipe_id): + """AJAX — update a single recipe.""" + cfg = config.load() + payload = request.get_json(silent=True) + if not payload: + return jsonify({"ok": False, "error": "Érvénytelen kérés."}) + try: + if backend == "mealie": + client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"], + api_url=config.MEALIE_INTERNAL_URL) + client.update_recipe(recipe_id, payload) + elif backend == "tandoor": + client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"], + api_url=config.TANDOOR_INTERNAL_URL) + client.update_recipe(int(recipe_id), payload) + else: + return jsonify({"ok": False, "error": "Ismeretlen backend."}) + return jsonify({"ok": True, "message": "Recept sikeresen mentve."}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}) + + +@app.route("/api/recipes//delete", methods=["POST"]) +def api_delete_recipes(backend): + """AJAX — delete one or more recipes.""" + cfg = config.load() + payload = request.get_json(silent=True) + if not payload or not payload.get("ids"): + return jsonify({"ok": False, "error": "Nincs kiválasztott recept."}) + + ids = payload["ids"] + errors = [] + try: + if backend == "mealie": + client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"], + api_url=config.MEALIE_INTERNAL_URL) + for slug in ids: + try: + client.delete_recipe(slug) + except Exception as e: + errors.append(f"{slug}: {e}") + elif backend == "tandoor": + client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"], + api_url=config.TANDOOR_INTERNAL_URL) + for rid in ids: + try: + client.delete_recipe(int(rid)) + except Exception as e: + errors.append(f"{rid}: {e}") + else: + return jsonify({"ok": False, "error": "Ismeretlen backend."}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}) + + deleted = len(ids) - len(errors) + if errors: + return jsonify({"ok": True, "deleted": deleted, "errors": errors, + "message": f"{deleted} recept törölve, {len(errors)} hiba."}) + return jsonify({"ok": True, "deleted": deleted, + "message": f"{deleted} recept sikeresen törölve."}) + + +@app.route("/api/tags/") +def api_list_tags(backend): + """Return tags/keywords with IDs for a specific backend.""" + cfg = config.load() + try: + if backend == "mealie": + client = MealieClient(cfg["mealie_url"], cfg["mealie_api_key"], + api_url=config.MEALIE_INTERNAL_URL) + tags = client.list_tags() + elif backend == "tandoor": + client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"], + api_url=config.TANDOOR_INTERNAL_URL) + tags = client.list_keywords() + else: + return jsonify({"ok": False, "error": "Ismeretlen backend."}) + return jsonify({"ok": True, "tags": tags}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}) + + @app.route("/tags", methods=["GET"]) def list_all_tags(): """Return existing tags from Mealie and Tandoor for autocomplete.""" diff --git a/app/mealie.py b/app/mealie.py index 6d97a19..669c2d0 100644 --- a/app/mealie.py +++ b/app/mealie.py @@ -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=&tags= 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. diff --git a/app/tandoor.py b/app/tandoor.py index 2cb11e8..0f9257f 100644 --- a/app/tandoor.py +++ b/app/tandoor.py @@ -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: diff --git a/app/templates/base.html b/app/templates/base.html index fc036b9..1843194 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -217,6 +217,7 @@ Recept Importáló Importálás + Receptek Beállítások {{ version }} diff --git a/app/templates/recipe_edit.html b/app/templates/recipe_edit.html new file mode 100644 index 0000000..ca42347 --- /dev/null +++ b/app/templates/recipe_edit.html @@ -0,0 +1,575 @@ +{% extends "base.html" %} +{% block title %}Recept szerkesztése — Recept Importáló{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
+ Recept betöltése... +
+
+ + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/app/templates/recipes.html b/app/templates/recipes.html new file mode 100644 index 0000000..0b1ccf9 --- /dev/null +++ b/app/templates/recipes.html @@ -0,0 +1,644 @@ +{% extends "base.html" %} +{% block title %}Receptek — Recept Importáló{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+ +
+ {% if has_mealie %} + + {% endif %} + {% if has_tandoor %} + + {% endif %} +
+ + + + + + + + + + + +
+ + + + + + +
+ + + +{% endblock %} + +{% block scripts %} + +{% endblock %}