From cb669f18615c62af200a3600116547788b4b959a Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 24 Feb 2026 08:33:24 +0100 Subject: [PATCH] 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 --- app/mealie.py | 136 ++++++++++++++++++++++++++++++++++++-- app/scraper.py | 47 ++++++------- app/templates/import.html | 51 ++++++++++++-- 3 files changed, 194 insertions(+), 40 deletions(-) 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 extra_el = row.find("small") or row.find("span", class_="extra") - if qty_el: - parts.append(_text(qty_el)) - if unit_el: - parts.append(_text(unit_el)) - if name_el: - parts.append(_text(name_el)) - if extra_el: - extra = _text(extra_el) - if extra: - # Wrap in parens if not already - if not extra.startswith("("): - extra = f"({extra})" - parts.append(extra) + qty = _text(qty_el) + unit = _text(unit_el) + food = _text(name_el) + extra = _text(extra_el).strip("() ") - line = " ".join(p for p in parts if p) - if not line: - # Fallback: grab whole row text with spaces between elements - line = row.get_text(separator=" ", strip=True) - if line: - ingredients.append(line) + if not food: + # Fallback: grab whole row text + food = row.get_text(separator=" ", strip=True) + + if food: + ingredients.append({ + "quantity": qty, + "unit": unit, + "food": food, + "extra": extra, + }) # --- Instructions --- instructions = [] @@ -150,7 +142,10 @@ def _parse_generic(soup: BeautifulSoup, url: str) -> dict: if isinstance(data, list): data = data[0] if data.get("@type") == "Recipe": - ingredients = data.get("recipeIngredient", []) + for line in data.get("recipeIngredient", []): + ingredients.append({ + "quantity": "", "unit": "", "food": line, "extra": "", + }) raw_instructions = data.get("recipeInstructions", []) for item in raw_instructions: if isinstance(item, str): diff --git a/app/templates/import.html b/app/templates/import.html index 30beb52..34d6c9c 100644 --- a/app/templates/import.html +++ b/app/templates/import.html @@ -11,13 +11,35 @@ border-radius: var(--radius); object-fit: cover; } + .ingredient-header { + display: flex; + gap: 0.5rem; + margin-bottom: 0.3rem; + padding-right: 34px; /* space for remove btn */ + } + .ingredient-header span { + font-size: 0.8rem; + color: var(--text-dim); + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.03em; + } + .ingredient-header .col-qty { width: 60px; flex-shrink: 0; } + .ingredient-header .col-unit { width: 80px; flex-shrink: 0; } + .ingredient-header .col-food { flex: 1; } + .ingredient-header .col-extra { width: 120px; flex-shrink: 0; } + .ingredient-row { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 0.4rem; } - .ingredient-row input { margin-bottom: 0; flex: 1; } + .ingredient-row input { margin-bottom: 0; } + .ingredient-row .ing-qty { width: 60px; flex-shrink: 0; } + .ingredient-row .ing-unit { width: 80px; flex-shrink: 0; } + .ingredient-row .ing-food { flex: 1; } + .ingredient-row .ing-extra { width: 120px; flex-shrink: 0; } .ingredient-row button { background: var(--danger); border: none; @@ -115,8 +137,14 @@ +
+ Menny. + Egység + Hozzávaló + Megjegyzés +
- + @@ -206,11 +234,15 @@ function populatePreview(r) { (r.instructions || []).forEach(t => addInstruction(t)); } -function addIngredient(value) { +function addIngredient(item) { + if (typeof item === 'string') item = { food: item }; const list = document.getElementById('ingredientsList'); const row = document.createElement('div'); row.className = 'ingredient-row'; - row.innerHTML = '' + row.innerHTML = '' + + '' + + '' + + '' + ''; list.appendChild(row); } @@ -239,9 +271,14 @@ function renumberInstructions() { function gatherRecipe() { const ingredients = []; - document.querySelectorAll('#ingredientsList .ingredient-row input').forEach(inp => { - const v = inp.value.trim(); - if (v) ingredients.push(v); + document.querySelectorAll('#ingredientsList .ingredient-row').forEach(row => { + const qty = row.querySelector('.ing-qty').value.trim(); + const unit = row.querySelector('.ing-unit').value.trim(); + const food = row.querySelector('.ing-food').value.trim(); + const extra = row.querySelector('.ing-extra').value.trim(); + if (food || qty) { + ingredients.push({ quantity: qty, unit: unit, food: food, extra: extra }); + } }); const instructions = [];