feat: Tandoor integration — settings, test connection, import, duplicate detection

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 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 09:29:58 +01:00
parent f7810ba33d
commit 458b1e362a
6 changed files with 476 additions and 68 deletions
+6 -3
View File
@@ -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": "",
}
+58 -6
View File
@@ -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
# ---------------------------------------------------------------------------
+229
View File
@@ -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()
+68 -18
View File
@@ -180,9 +180,16 @@
<button class="add-btn mt-1 mb-2" onclick="addInstruction('')">+ Lépés hozzáadása</button>
<div class="flex mt-2">
<button class="btn btn-success" id="sendBtn" onclick="sendToMealie()">
{% if has_mealie %}
<button class="btn btn-success" id="sendMealieBtn" onclick="sendToMealie()">
Importálás Mealie-be
</button>
{% endif %}
{% if has_tandoor %}
<button class="btn btn-success" id="sendTandoorBtn" onclick="sendToTandoor()">
Importálás Tandoor-ba
</button>
{% endif %}
<span id="sendStatus"></span>
</div>
</div>
@@ -192,11 +199,7 @@
<div class="result-card" id="resultCard">
<div class="card">
<h2 class="text-success">Recept sikeresen importálva!</h2>
<p class="mt-1">
<a id="resultLink" href="#" target="_blank" style="color:var(--accent);">
Megnyitás Mealie-ben →
</a>
</p>
<p class="mt-1" id="resultLinks"></p>
</div>
</div>
{% 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 = '<span class="text-warning">⚠ Ez a recept már létezik Mealie-ben: '
+ '<a href="' + escHtml(data.duplicate.url) + '" target="_blank" style="color:var(--accent)">'
+ escHtml(data.duplicate.name) + '</a></span>';
warnings.push('Mealie: <a href="' + escHtml(data.duplicate.url) + '" target="_blank" style="color:var(--accent)">'
+ escHtml(data.duplicate.name) + '</a>');
}
if (data.tandoor_duplicate) {
warnings.push('Tandoor: <a href="' + escHtml(data.tandoor_duplicate.url) + '" target="_blank" style="color:var(--accent)">'
+ escHtml(data.tandoor_duplicate.name) + '</a>');
}
if (warnings.length > 0) {
status.innerHTML = '<span class="text-warning">⚠ Ez a recept már létezik: ' + warnings.join(' | ') + '</span>';
} else {
status.innerHTML = '<span class="text-success">✓ Beolvasva</span>';
}
@@ -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 = '<span class="spinner"></span> Importálás...';
status.innerHTML = '<span class="spinner"></span> 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 = '<span class="text-success">✓ Mealie kész</span>';
importedLinks['Mealie'] = data.url;
showResultCard();
} catch (e) {
status.innerHTML = '<span class="text-danger">Hálózati hiba: ' + e.message + '</span>';
}
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 = '<span class="spinner"></span> 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 = '<span class="text-danger">Hiba: ' + data.error + '</span>';
btn.disabled = false;
return;
}
status.innerHTML = '<span class="text-success">✓ Tandoor kész</span>';
importedLinks['Tandoor'] = data.url;
showResultCard();
} catch (e) {
status.innerHTML = '<span class="text-danger">Hálózati hiba: ' + e.message + '</span>';
}
btn.disabled = false;
}
function showResultCard() {
const links = Object.entries(importedLinks).map(([name, url]) =>
'<a href="' + escHtml(url) + '" target="_blank" style="color:var(--accent);">Megnyitás ' + name + '-ben →</a>'
).join('<br>');
document.getElementById('resultLinks').innerHTML = links;
document.getElementById('resultCard').classList.add('visible');
}
function escHtml(s) {
const d = document.createElement('div');
d.textContent = s;
+67 -12
View File
@@ -2,9 +2,10 @@
{% block title %}Beállítások — Recept Importáló{% endblock %}
{% block content %}
<div class="card">
<h2>Mealie kapcsolat</h2>
<form method="POST" action="/settings">
<form method="POST" action="/settings">
<div class="card">
<h2>Mealie kapcsolat</h2>
<label for="mealie_url">Mealie URL</label>
<input type="url" id="mealie_url" name="mealie_url"
value="{{ cfg.mealie_url }}"
@@ -20,26 +21,56 @@
</p>
<div class="flex">
<button type="submit" class="btn btn-primary">Mentés</button>
<button type="button" class="btn btn-secondary" id="testBtn" onclick="testConnection()">
<button type="button" class="btn btn-secondary" id="testMealieBtn" onclick="testMealie()">
Kapcsolat tesztelése
</button>
<span id="testResult"></span>
<span id="testMealieResult"></span>
</div>
</form>
</div>
</div>
<div class="card">
<h2>Tandoor kapcsolat</h2>
<label for="tandoor_url">Tandoor URL</label>
<input type="url" id="tandoor_url" name="tandoor_url"
value="{{ cfg.tandoor_url }}"
placeholder="https://recipes.example.com">
<label for="tandoor_api_key">API kulcs</label>
<input type="password" id="tandoor_api_key" name="tandoor_api_key"
value="{{ cfg.tandoor_api_key }}"
placeholder="Tandoor API token">
<p class="text-dim mb-2" style="font-size:0.85rem;">
Az API kulcsot a Tandoor-ban itt hozhatod létre:
<em>Settings → API Browser → Auth Token</em>
</p>
<div class="flex">
<button type="button" class="btn btn-secondary" id="testTandoorBtn" onclick="testTandoor()">
Kapcsolat tesztelése
</button>
<span id="testTandoorResult"></span>
</div>
</div>
<div class="card">
<button type="submit" class="btn btn-primary">Mentés</button>
</div>
</form>
{% endblock %}
{% block scripts %}
<script>
async function testConnection() {
const btn = document.getElementById('testBtn');
const result = document.getElementById('testResult');
async function testMealie() {
const btn = document.getElementById('testMealieBtn');
const result = document.getElementById('testMealieResult');
btn.disabled = true;
result.innerHTML = '<span class="spinner"></span>';
try {
const form = new FormData(document.querySelector('form'));
const form = new FormData();
form.append('mealie_url', document.getElementById('mealie_url').value);
form.append('mealie_api_key', document.getElementById('mealie_api_key').value);
const resp = await fetch('/settings/test', { method: 'POST', body: form });
const data = await resp.json();
if (data.ok) {
@@ -53,5 +84,29 @@ async function testConnection() {
}
btn.disabled = false;
}
async function testTandoor() {
const btn = document.getElementById('testTandoorBtn');
const result = document.getElementById('testTandoorResult');
btn.disabled = true;
result.innerHTML = '<span class="spinner"></span>';
try {
const form = new FormData();
form.append('tandoor_url', document.getElementById('tandoor_url').value);
form.append('tandoor_api_key', document.getElementById('tandoor_api_key').value);
const resp = await fetch('/settings/test-tandoor', { method: 'POST', body: form });
const data = await resp.json();
if (data.ok) {
const v = data.data.version || '?';
result.innerHTML = '<span class="text-success">✓ Kapcsolódva (Tandoor v' + v + ')</span>';
} else {
result.innerHTML = '<span class="text-danger">✗ ' + data.error + '</span>';
}
} catch (e) {
result.innerHTML = '<span class="text-danger">✗ Hálózati hiba</span>';
}
btn.disabled = false;
}
</script>
{% endblock %}