feat: structured ingredients with unit/food resolution

Scraper returns {quantity, unit, food, extra} dicts instead of flat
strings. UI shows 4-column ingredient editor. Mealie client resolves
unit/food IDs via API (creates missing ones automatically).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 08:33:24 +01:00
parent 274aaeb0b6
commit cb669f1861
3 changed files with 194 additions and 40 deletions
+129 -7
View File
@@ -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={