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
+48 -29
View File
@@ -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
+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 %}