From 458b1e362a80b54148d1617e13480420b804de66 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 24 Feb 2026 09:29:58 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20Tandoor=20integration=20=E2=80=94=20set?= =?UTF-8?q?tings,=20test=20connection,=20import,=20duplicate=20detection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add TandoorClient (app/tandoor.py) with full recipe creation, image upload, and duplicate detection via the Tandoor REST API. Settings page now has separate Mealie and Tandoor sections. Import page shows both send buttons based on which services are configured. Co-Authored-By: Claude Opus 4.6 --- README.md | 77 +++++++----- app/config.py | 9 +- app/main.py | 64 +++++++++- app/tandoor.py | 229 ++++++++++++++++++++++++++++++++++++ app/templates/import.html | 86 +++++++++++--- app/templates/settings.html | 79 +++++++++++-- 6 files changed, 476 insertions(+), 68 deletions(-) create mode 100644 app/tandoor.py diff --git a/README.md b/README.md index 078119a..540d71a 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,38 @@ # Recipe Importer -Docker container for importing recipes from Hungarian websites into [Mealie](https://mealie.io/) (Tandoor support planned). +Docker container for importing recipes from Hungarian websites into [Mealie](https://mealie.io/) and [Tandoor Recipes](https://tandoor.dev/). -**Problem**: Mealie's built-in URL import cannot parse ingredients and instructions from Hungarian recipe sites like mindmegette.hu — it imports the title and image but shows "Could not detect ingredients / instructions". +**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 via its REST API. +**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. ## Architecture ``` -┌─────────────────────────────────────────────────┐ -│ recipe-importer container (:8000) │ -│ │ -│ Flask + Gunicorn │ -│ ├── /settings → Configure Mealie connection │ -│ ├── /import → Paste URL, scrape, review │ -│ ├── /scrape → AJAX: parse recipe HTML │ -│ ├── /send → AJAX: push to Mealie API │ -│ └── /health → Health check │ -│ │ -│ Modules: │ -│ ├── app/config.py → JSON config persistence │ -│ ├── app/scraper.py → Site-specific parsers │ -│ └── app/mealie.py → Mealie REST API client │ -└───────────────────┬─────────────────────────────┘ - │ HTTP - ▼ - ┌──────────────────┐ - │ Mealie instance │ - │ POST /api/recipes│ - │ PATCH /api/... │ - │ PUT /api/.../img │ - └──────────────────┘ +┌──────────────────────────────────────────────────────┐ +│ recipe-importer container (:8000) │ +│ │ +│ Flask + Gunicorn │ +│ ├── /settings → Configure Mealie & Tandoor │ +│ ├── /import → Paste URL, scrape, review │ +│ ├── /scrape → AJAX: parse recipe HTML │ +│ ├── /send → AJAX: push to Mealie API │ +│ ├── /send-tandoor → AJAX: push to Tandoor API │ +│ └── /health → Health check │ +│ │ +│ Modules: │ +│ ├── app/config.py → JSON config persistence │ +│ ├── app/scraper.py → Site-specific parsers │ +│ ├── app/mealie.py → Mealie REST API client │ +│ └── app/tandoor.py → Tandoor REST API client │ +└───────────────────┬──────────────┬───────────────────┘ + │ HTTP │ HTTP + ▼ ▼ + ┌──────────────┐ ┌───────────────┐ + │ Mealie │ │ Tandoor │ + │ POST /api/.. │ │ POST /api/.. │ + │ PUT /api/.. │ │ PUT /api/.. │ + └──────────────┘ └───────────────┘ ``` ## Supported Sites @@ -76,6 +77,19 @@ The importer uses the Mealie REST API: Authentication uses a long-lived API token (Bearer header), created in Mealie at *Profile → API Tokens*. +## Tandoor API Integration + +The importer uses the Tandoor REST API: + +1. **POST** `/api/recipe/` — create the full recipe in one call (name, description, source_url, steps with nested ingredients) +2. **PUT** `/api/recipe/{id}/image/` — upload the recipe image + +**Step-based ingredients**: Tandoor nests ingredients inside steps. All ingredients are attached to the first step. Units and foods are auto-created by name (no separate resolution needed). Ingredient groups use `is_header: true` on a header entry. + +**Duplicate detection**: Before import, searches Tandoor by title and checks the `source_url` field to detect already-imported recipes. + +Authentication uses an API token (Bearer header), created in Tandoor at *Settings → API Browser → Auth Token*. + ## Configuration All settings are persisted to `/data/config.json` (mounted as a Docker volume). @@ -84,6 +98,8 @@ All settings are persisted to `/data/config.json` (mounted as a Docker volume). |---------|-------------| | `mealie_url` | Full URL to Mealie instance (e.g. `https://mealie.example.com`) | | `mealie_api_key` | Mealie API token | +| `tandoor_url` | Full URL to Tandoor instance (e.g. `https://recipes.example.com`) | +| `tandoor_api_key` | Tandoor API token | ## Deployment @@ -92,7 +108,7 @@ All settings are persisted to `/data/config.json` (mounted as a Docker volume). ```yaml services: recipe-importer: - image: gitea.dooplex.hu/admin/recipe-importer:0.1.7 + image: gitea.dooplex.hu/admin/recipe-importer:0.1.9 container_name: recipe-importer restart: unless-stopped ports: @@ -101,6 +117,8 @@ services: - recipe-data:/data environment: - SECRET_KEY=change-me-in-production + - MEALIE_INTERNAL_URL=http://mealie:9000 + - TANDOOR_INTERNAL_URL=http://tandoor:8080 volumes: recipe-data: @@ -114,6 +132,7 @@ volumes: | `DATA_DIR` | `/data` | Persistent storage path | | `VERSION` | `dev` | Shown in the UI navbar | | `MEALIE_INTERNAL_URL` | *(empty)* | Docker-internal Mealie URL (e.g. `http://mealie:9000`) to avoid Cloudflare hairpin | +| `TANDOOR_INTERNAL_URL` | *(empty)* | Docker-internal Tandoor URL (e.g. `http://tandoor:8080`) to avoid Cloudflare hairpin | ## Building @@ -128,10 +147,10 @@ cd ~/build/recipe-importer The UI is in Hungarian and uses a dark theme. The workflow is: -1. **Settings** (`/settings`) — Enter Mealie URL and API key, test connection +1. **Settings** (`/settings`) — Configure Mealie and/or Tandoor connection (URL + API key), test each connection 2. **Import** (`/import`) — Paste a recipe URL, click "Beolvasás" (Scrape) 3. **Review** — Edit structured ingredients (4-column: quantity, unit, food, note), add/remove ingredient groups, edit instructions -4. **Send** — Click "Importálás Mealie-be" to push to Mealie +4. **Send** — Click "Importálás Mealie-be" and/or "Importálás Tandoor-ba" to push to your configured services ## Tech Stack diff --git a/app/config.py b/app/config.py index 4eec05b..b12034a 100644 --- a/app/config.py +++ b/app/config.py @@ -1,4 +1,4 @@ -"""Configuration management — persists Mealie connection settings to a JSON file.""" +"""Configuration management — persists Mealie and Tandoor connection settings to a JSON file.""" import json import os @@ -7,13 +7,16 @@ from pathlib import Path DATA_DIR = Path(os.environ.get("DATA_DIR", "/data")) CONFIG_FILE = DATA_DIR / "config.json" -# Optional internal URL for Docker-to-Docker API calls (avoids Cloudflare hairpin). -# The user-facing mealie_url is still used for browser links. +# Optional internal URLs for Docker-to-Docker API calls (avoids Cloudflare hairpin). +# The user-facing URLs are still used for browser links. MEALIE_INTERNAL_URL = os.environ.get("MEALIE_INTERNAL_URL", "").strip().rstrip("/") +TANDOOR_INTERNAL_URL = os.environ.get("TANDOOR_INTERNAL_URL", "").strip().rstrip("/") _DEFAULTS = { "mealie_url": "", "mealie_api_key": "", + "tandoor_url": "", + "tandoor_api_key": "", } diff --git a/app/main.py b/app/main.py index 09f8e60..9327bc0 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,7 @@ from flask import Flask, render_template, request, redirect, url_for, flash, jso from app import config from app.scraper import scrape from app.mealie import MealieClient +from app.tandoor import TandoorClient app = Flask( __name__, @@ -28,7 +29,9 @@ VERSION = os.environ.get("VERSION", "dev") def index(): """Redirect to the import page (or settings if not configured).""" cfg = config.load() - if not cfg.get("mealie_url") or not cfg.get("mealie_api_key"): + 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: return redirect(url_for("settings")) return redirect(url_for("import_page")) @@ -41,6 +44,8 @@ def settings(): if request.method == "POST": cfg["mealie_url"] = request.form.get("mealie_url", "").strip().rstrip("/") cfg["mealie_api_key"] = request.form.get("mealie_api_key", "").strip() + cfg["tandoor_url"] = request.form.get("tandoor_url", "").strip().rstrip("/") + cfg["tandoor_api_key"] = request.form.get("tandoor_api_key", "").strip() config.save(cfg) flash("Beállítások mentve.", "success") return redirect(url_for("settings")) @@ -63,14 +68,32 @@ def settings_test(): return jsonify({"ok": False, "error": str(exc)}) +@app.route("/settings/test-tandoor", methods=["POST"]) +def settings_test_tandoor(): + """AJAX endpoint — test Tandoor connection using form values.""" + url = (request.form.get("tandoor_url") or "").strip().rstrip("/") + key = (request.form.get("tandoor_api_key") or "").strip() + if not url or not key: + return jsonify({"ok": False, "error": "Nincs megadva Tandoor URL vagy API kulcs."}) + try: + client = TandoorClient(url, key, api_url=config.TANDOOR_INTERNAL_URL) + info = client.test_connection() + return jsonify({"ok": True, "data": info}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}) + + @app.route("/import", methods=["GET"]) def import_page(): """Show the import form.""" cfg = config.load() - if not cfg.get("mealie_url") or not cfg.get("mealie_api_key"): - flash("Először állítsd be a Mealie kapcsolatot.", "warning") + 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 (Mealie vagy Tandoor).", "warning") return redirect(url_for("settings")) - return render_template("import.html", cfg=cfg, version=VERSION) + return render_template("import.html", cfg=cfg, version=VERSION, + has_mealie=has_mealie, has_tandoor=has_tandoor) @app.route("/scrape", methods=["POST"]) @@ -82,8 +105,9 @@ def scrape_url(): try: data = scrape(url) - # Check for duplicate in Mealie + # Check for duplicates in Mealie and Tandoor duplicate = None + tandoor_duplicate = None cfg = config.load() if cfg.get("mealie_url") and cfg.get("mealie_api_key"): try: @@ -92,8 +116,16 @@ def scrape_url(): duplicate = client.find_duplicate(url, data.get("title", "")) except Exception: pass # non-fatal + if cfg.get("tandoor_url") and cfg.get("tandoor_api_key"): + try: + client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"], + api_url=config.TANDOOR_INTERNAL_URL) + tandoor_duplicate = client.find_duplicate(url, data.get("title", "")) + except Exception: + pass # non-fatal - return jsonify({"ok": True, "data": data, "duplicate": duplicate}) + return jsonify({"ok": True, "data": data, "duplicate": duplicate, + "tandoor_duplicate": tandoor_duplicate}) except Exception as exc: return jsonify({"ok": False, "error": str(exc), "trace": traceback.format_exc()}) @@ -119,6 +151,26 @@ def send_to_mealie(): return jsonify({"ok": False, "error": str(exc), "trace": traceback.format_exc()}) +@app.route("/send-tandoor", methods=["POST"]) +def send_to_tandoor(): + """AJAX — send edited recipe data to Tandoor.""" + cfg = config.load() + if not cfg.get("tandoor_url") or not cfg.get("tandoor_api_key"): + return jsonify({"ok": False, "error": "Tandoor nincs beállítva."}) + + payload = request.get_json(silent=True) + if not payload: + return jsonify({"ok": False, "error": "Érvénytelen kérés."}) + + try: + client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"], + api_url=config.TANDOOR_INTERNAL_URL) + result = client.create_recipe(payload) + return jsonify({"ok": True, "id": result["id"], "url": result["url"]}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc), "trace": traceback.format_exc()}) + + # --------------------------------------------------------------------------- # Health # --------------------------------------------------------------------------- diff --git a/app/tandoor.py b/app/tandoor.py new file mode 100644 index 0000000..c2685cd --- /dev/null +++ b/app/tandoor.py @@ -0,0 +1,229 @@ +"""Tandoor API client — creates recipes and uploads images.""" + +import io +import re +import requests + + +class TandoorClient: + """Thin wrapper around the Tandoor REST API.""" + + def __init__(self, base_url: str, api_key: str, api_url: str = ""): + self.base_url = base_url.rstrip("/") + self.api_url = api_url.rstrip("/") if api_url else self.base_url + self.session = requests.Session() + self.session.headers.update({ + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + }) + + # ------------------------------------------------------------------ + # Public + # ------------------------------------------------------------------ + + def test_connection(self) -> dict: + """Return Tandoor version info or raise on failure.""" + # Try the recipe list endpoint as a connection test + r = self.session.get( + f"{self.api_url}/api/recipe/", + params={"limit": 1}, + timeout=10, + ) + r.raise_for_status() + + # Try to get version from openapi endpoint + version = "unknown" + try: + r2 = self.session.get(f"{self.api_url}/openapi/", timeout=10) + if r2.ok: + # Parse version from YAML: "version: 1.5.26" + m = re.search(r"version:\s*['\"]?([0-9][0-9a-z._-]*)", r2.text) + if m: + version = m.group(1) + except Exception: + pass + + return {"version": version} + + 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: + return None + search = title or url.split("/")[-1].replace("-", " ") + r = self.session.get( + f"{self.api_url}/api/recipe/", + params={"query": search, "limit": 20}, + timeout=10, + ) + if not r.ok: + return None + for item in r.json().get("results", []): + source = item.get("source_url") or "" + desc = item.get("description") or "" + if source == url or url in desc: + return { + "id": item["id"], + "name": item["name"], + "url": f"{self.base_url}/view/recipe/{item['id']}", + } + return None + + def create_recipe(self, recipe: dict) -> dict: + """Create a recipe in Tandoor from a scraper result dict. + + Returns {"id": int, "url": str}. + """ + payload = self._build_payload(recipe) + r = self.session.post( + f"{self.api_url}/api/recipe/", + json=payload, + timeout=15, + ) + r.raise_for_status() + data = r.json() + recipe_id = data["id"] + + # Upload image if available + image_url = recipe.get("image_url") + if image_url: + try: + self._upload_image(recipe_id, image_url) + except Exception: + pass # non-fatal + + return { + "id": recipe_id, + "url": f"{self.base_url}/view/recipe/{recipe_id}", + } + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _build_payload(self, recipe: dict) -> dict: + description = recipe.get("description", "") + original_url = recipe.get("original_url", "") + if original_url and original_url not in description: + description = f"{description}\n\n{original_url}".strip() + + # Build ingredients list + ingredients = [] + order = 0 + pending_group = "" + for item in recipe.get("ingredients", []): + if isinstance(item, dict): + if "group" in item and "food" not in item: + # Group header + ingredients.append({ + "food": None, + "unit": None, + "amount": 0, + "note": item["group"], + "is_header": True, + "no_amount": True, + "order": order, + }) + order += 1 + else: + ingredients.append(self._build_ingredient(item, order)) + order += 1 + + # Build steps — all ingredients on step 0 + instructions = recipe.get("instructions", []) + steps = [] + for i, text in enumerate(instructions): + steps.append({ + "instruction": text, + "ingredients": ingredients if i == 0 else [], + "order": i, + }) + + if not instructions and ingredients: + steps.append({ + "instruction": "", + "ingredients": ingredients, + "order": 0, + }) + + return { + "name": recipe["title"], + "description": description, + "source_url": original_url, + "steps": steps, + "servings": 1, + } + + def _build_ingredient(self, item: dict, order: int) -> dict: + """Build a Tandoor ingredient from a structured dict.""" + qty_str = str(item.get("quantity", "")).strip() + unit_str = item.get("unit", "").strip() + food_str = item.get("food", "").strip() + extra = item.get("extra", "").strip() + + has_structured = bool(qty_str or unit_str) + + if has_structured and food_str: + qty = 0 + try: + qty = float(qty_str.replace(",", ".")) + except (ValueError, TypeError): + pass + + return { + "food": {"name": food_str}, + "unit": {"name": unit_str} if unit_str else None, + "amount": qty, + "note": extra, + "is_header": False, + "no_amount": False, + "order": order, + } + else: + # No quantity/unit — food-only or note + note = food_str + if extra: + note = f"{note} ({extra})" if note else extra + if food_str: + return { + "food": {"name": food_str}, + "unit": None, + "amount": 0, + "note": extra, + "is_header": False, + "no_amount": True, + "order": order, + } + else: + return { + "food": None, + "unit": None, + "amount": 0, + "note": note, + "is_header": False, + "no_amount": True, + "order": order, + } + + def _upload_image(self, recipe_id: int, image_url: str): + """Download image from *image_url* and upload it to the recipe.""" + img_resp = requests.get(image_url, timeout=30, headers={ + "User-Agent": "RecipeImporter/1.0", + }) + img_resp.raise_for_status() + + content_type = img_resp.headers.get("Content-Type", "image/jpeg") + ext = "jpg" + if "png" in content_type: + ext = "png" + elif "webp" in content_type: + ext = "webp" + + files = { + "image": (f"recipe.{ext}", io.BytesIO(img_resp.content), content_type), + } + r = self.session.put( + f"{self.api_url}/api/recipe/{recipe_id}/image/", + files=files, + timeout=30, + ) + r.raise_for_status() diff --git a/app/templates/import.html b/app/templates/import.html index fd44881..6bed04f 100644 --- a/app/templates/import.html +++ b/app/templates/import.html @@ -180,9 +180,16 @@
- + {% endif %} + {% if has_tandoor %} + + {% endif %}
@@ -192,11 +199,7 @@

Recept sikeresen importálva!

-

- - Megnyitás Mealie-ben → - -

+
{% endblock %} @@ -216,6 +219,7 @@ async function scrapeRecipe() { document.getElementById('previewCard').classList.remove('visible'); document.getElementById('resultCard').classList.remove('visible'); + Object.keys(importedLinks).forEach(k => delete importedLinks[k]); try { const form = new FormData(); @@ -233,10 +237,17 @@ async function scrapeRecipe() { populatePreview(currentRecipe); document.getElementById('previewCard').classList.add('visible'); + const warnings = []; if (data.duplicate) { - status.innerHTML = '⚠ Ez a recept már létezik Mealie-ben: ' - + '' - + escHtml(data.duplicate.name) + ''; + warnings.push('Mealie: ' + + escHtml(data.duplicate.name) + ''); + } + if (data.tandoor_duplicate) { + warnings.push('Tandoor: ' + + escHtml(data.tandoor_duplicate.name) + ''); + } + if (warnings.length > 0) { + status.innerHTML = '⚠ Ez a recept már létezik: ' + warnings.join(' | ') + ''; } else { status.innerHTML = '✓ Beolvasva'; } @@ -351,17 +362,16 @@ function gatherRecipe() { }; } +const importedLinks = {}; + async function sendToMealie() { const recipe = gatherRecipe(); - if (!recipe.title) { - alert('A recept neve kötelező!'); - return; - } + if (!recipe.title) { alert('A recept neve kötelező!'); return; } - const btn = document.getElementById('sendBtn'); + const btn = document.getElementById('sendMealieBtn'); const status = document.getElementById('sendStatus'); btn.disabled = true; - status.innerHTML = ' Importálás...'; + status.innerHTML = ' Importálás Mealie-be...'; try { const resp = await fetch('/send', { @@ -377,15 +387,55 @@ async function sendToMealie() { return; } - status.innerHTML = ''; - document.getElementById('resultLink').href = data.url; - document.getElementById('resultCard').classList.add('visible'); + status.innerHTML = '✓ Mealie kész'; + importedLinks['Mealie'] = data.url; + showResultCard(); } catch (e) { status.innerHTML = 'Hálózati hiba: ' + e.message + ''; } btn.disabled = false; } +async function sendToTandoor() { + const recipe = gatherRecipe(); + if (!recipe.title) { alert('A recept neve kötelező!'); return; } + + const btn = document.getElementById('sendTandoorBtn'); + const status = document.getElementById('sendStatus'); + btn.disabled = true; + status.innerHTML = ' Importálás Tandoor-ba...'; + + try { + const resp = await fetch('/send-tandoor', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(recipe), + }); + const data = await resp.json(); + + if (!data.ok) { + status.innerHTML = 'Hiba: ' + data.error + ''; + btn.disabled = false; + return; + } + + status.innerHTML = '✓ Tandoor kész'; + importedLinks['Tandoor'] = data.url; + showResultCard(); + } catch (e) { + status.innerHTML = 'Hálózati hiba: ' + e.message + ''; + } + btn.disabled = false; +} + +function showResultCard() { + const links = Object.entries(importedLinks).map(([name, url]) => + 'Megnyitás ' + name + '-ben →' + ).join('
'); + document.getElementById('resultLinks').innerHTML = links; + document.getElementById('resultCard').classList.add('visible'); +} + function escHtml(s) { const d = document.createElement('div'); d.textContent = s; diff --git a/app/templates/settings.html b/app/templates/settings.html index 8611330..28dfcc6 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -2,9 +2,10 @@ {% block title %}Beállítások — Recept Importáló{% endblock %} {% block content %} -
-

Mealie kapcsolat

-
+ +
+

Mealie kapcsolat

+
- - - +
- -
+
+ +
+

Tandoor kapcsolat

+ + + + + + +

+ Az API kulcsot a Tandoor-ban itt hozhatod létre: + Settings → API Browser → Auth Token +

+ +
+ + +
+
+ +
+ +
+ {% endblock %} {% block scripts %} {% endblock %}