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:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user