diff --git a/app/mealie.py b/app/mealie.py
index ca14758..86a026e 100644
--- a/app/mealie.py
+++ b/app/mealie.py
@@ -16,6 +16,8 @@ class MealieClient:
"Authorization": f"Bearer {api_key}",
"Accept": "application/json",
})
+ self._units_cache = None # {name_or_abbr: {id, name}}
+ self._foods_cache = {} # {lowercase_name: {id, name}}
# ------------------------------------------------------------------
# Public
@@ -61,19 +63,99 @@ class MealieClient:
return slug
+ # ------------------------------------------------------------------
+ # Unit / food resolution
+ # ------------------------------------------------------------------
+
+ def _load_units(self):
+ """Fetch all units from Mealie, build lookup by name and abbreviation."""
+ if self._units_cache is not None:
+ return
+ self._units_cache = {}
+ r = self.session.get(f"{self.api_url}/api/units", params={"perPage": -1}, timeout=10)
+ r.raise_for_status()
+ for u in r.json().get("items", []):
+ entry = {"id": u["id"], "name": u["name"]}
+ self._units_cache[u["name"].lower()] = entry
+ abbr = u.get("abbreviation", "")
+ if abbr:
+ self._units_cache[abbr.lower()] = entry
+
+ def _ensure_unit(self, name: str):
+ """Look up a unit by name/abbreviation. Create if missing.
+ Returns {"id": ..., "name": ...} or None for empty name."""
+ if not name:
+ return None
+ self._load_units()
+ key = name.lower().strip()
+ if key in self._units_cache:
+ return self._units_cache[key]
+
+ # Create new unit
+ r = self.session.post(
+ f"{self.api_url}/api/units",
+ json={"name": name, "abbreviation": name, "pluralName": name},
+ timeout=10,
+ )
+ if r.ok:
+ u = r.json()
+ entry = {"id": u["id"], "name": u["name"]}
+ self._units_cache[key] = entry
+ return entry
+ return None
+
+ def _ensure_food(self, name: str):
+ """Look up a food by name. Create if missing.
+ Returns {"id": ..., "name": ...} or None for empty name."""
+ if not name:
+ return None
+ key = name.lower().strip()
+ if key in self._foods_cache:
+ return self._foods_cache[key]
+
+ # Search existing foods
+ r = self.session.get(
+ f"{self.api_url}/api/foods",
+ params={"search": name, "perPage": 50},
+ timeout=10,
+ )
+ if r.ok:
+ for f in r.json().get("items", []):
+ if f["name"].lower() == key:
+ entry = {"id": f["id"], "name": f["name"]}
+ self._foods_cache[key] = entry
+ return entry
+
+ # Create new food
+ r = self.session.post(
+ f"{self.api_url}/api/foods",
+ json={"name": name},
+ timeout=10,
+ )
+ if r.ok:
+ f = r.json()
+ entry = {"id": f["id"], "name": f["name"]}
+ self._foods_cache[key] = entry
+ return entry
+ return None
+
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _build_payload(self, recipe: dict) -> dict:
ingredients = []
- for line in recipe.get("ingredients", []):
- ingredients.append({
- "referenceId": str(uuid.uuid4()),
- "note": line,
- "isFood": False,
- "disableAmount": True,
- })
+ for item in recipe.get("ingredients", []):
+ if isinstance(item, dict):
+ ingredients.append(self._build_ingredient(item))
+ else:
+ # Legacy: plain string
+ ingredients.append({
+ "referenceId": str(uuid.uuid4()),
+ "note": str(item),
+ "isFood": False,
+ "disableAmount": True,
+ })
instructions = []
for text in recipe.get("instructions", []):
@@ -93,6 +175,46 @@ class MealieClient:
"recipeYield": "",
}
+ def _build_ingredient(self, item: dict) -> dict:
+ """Build a Mealie 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:
+ unit_ref = self._ensure_unit(unit_str) if unit_str else None
+ food_ref = self._ensure_food(food_str)
+
+ qty = 0
+ try:
+ qty = float(qty_str.replace(",", "."))
+ except (ValueError, TypeError):
+ pass
+
+ return {
+ "referenceId": str(uuid.uuid4()),
+ "quantity": qty,
+ "unit": unit_ref,
+ "food": food_ref,
+ "note": extra,
+ "isFood": True,
+ "disableAmount": False,
+ }
+ else:
+ # No quantity/unit — put food+extra in note
+ note = food_str
+ if extra:
+ note = f"{note} ({extra})" if note else extra
+ return {
+ "referenceId": str(uuid.uuid4()),
+ "note": note,
+ "isFood": False,
+ "disableAmount": True,
+ }
+
def _upload_image(self, slug: str, image_url: str):
"""Download image from *image_url* and upload it to the recipe."""
img_resp = requests.get(image_url, timeout=30, headers={
diff --git a/app/scraper.py b/app/scraper.py
index 1693f4e..bf388a6 100644
--- a/app/scraper.py
+++ b/app/scraper.py
@@ -26,7 +26,7 @@ def scrape(url: str) -> dict:
"title": str,
"description": str,
"image_url": str | None,
- "ingredients": [str, ...],
+ "ingredients": [{"quantity": str, "unit": str, "food": str, "extra": str}, ...],
"instructions": [str, ...],
"original_url": str,
}
@@ -65,41 +65,33 @@ def _parse_mindmegette(soup: BeautifulSoup, url: str) -> dict:
ing_container = soup.find("div", class_="ingredients")
if ing_container:
for row in ing_container.find_all("div", class_="ingredients-meta"):
- parts = []
# Actual HTML: qty unit
- # name
+ # name (extra)
qty_el = row.find("strong")
- # Unit: first plain (not one with a specific class like
- # "ingredients-checkbox" etc.)
unit_el = None
for sp in row.find_all("span"):
if not sp.get("class"):
unit_el = sp
break
name_el = row.find("a", class_="ingredients-link")
- # Extra info: (darált) or