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:
+129
-7
@@ -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={
|
||||
|
||||
Reference in New Issue
Block a user