diff --git a/app/database.py b/app/database.py index 9a7fade..48a3363 100755 --- a/app/database.py +++ b/app/database.py @@ -1,4 +1,5 @@ import os +import re import sqlite3 from datetime import datetime, date from typing import List, Dict, Any, Optional @@ -100,86 +101,185 @@ def clean_instructions_text(text: Optional[str]) -> str: return "\n".join(cleaned_lines) +def save_ingredient_category_mapping(ingredient_name: str, category_name: str): + """Speichert oder aktualisiert die bevorzugte Supermarkt-Kategorie einer Zutat.""" + from .ingredients import clean_ingredient_name, get_ingredient_key + clean = clean_ingredient_name(ingredient_name) + key = get_ingredient_key(clean) + cat = (category_name or "").strip() + if not key or not cat: + return + + try: + if DB_ENGINE == "mariadb": + execute_query( + """ + INSERT INTO ingredient_category_mappings (ingredient_name, category_name) + VALUES (?, ?) + ON DUPLICATE KEY UPDATE category_name = VALUES(category_name), updated_at = CURRENT_TIMESTAMP + """, + (key, cat), + fetch="none", + commit=True + ) + else: + execute_query( + """ + INSERT INTO ingredient_category_mappings (ingredient_name, category_name) + VALUES (?, ?) + ON CONFLICT(ingredient_name) DO UPDATE SET category_name = excluded.category_name, updated_at = CURRENT_TIMESTAMP + """, + (key, cat), + fetch="none", + commit=True + ) + except Exception as e: + print(f"Fehler beim Speichern des Kategorie-Mappings für '{key}': {e}") + + +def get_ingredient_category_mapping(ingredient_name: str) -> Optional[str]: + """Sucht nach einer gelernten Nutzer-Zuordnung für diese Zutat.""" + from .ingredients import clean_ingredient_name, get_ingredient_key + clean = clean_ingredient_name(ingredient_name) + key = get_ingredient_key(clean) + if not key: + return None + try: + row = execute_query( + "SELECT category_name FROM ingredient_category_mappings WHERE ingredient_name = ? LIMIT 1", + (key,), + fetch="one" + ) + if row and row.get("category_name"): + return str(row["category_name"]).strip() + except Exception: + pass + return None + + def guess_category_for_ingredient(name: str) -> str: - """Bestimmt automatisch die passende Supermarkt-Kategorie anhand deutscher Zutaten-Keywords.""" + """Bestimmt automatisch die passende Supermarkt-Kategorie dynamisch und lernfähig.""" if not name: return "Drogerie & Sonstiges" - n = name.lower() - # 1. Tiefkühl - if any(k in n for k in ["tk", "tiefkühl", "gefroren", "eiscreme", "speiseeis", "erbsen (tk)", "tk-"]): - return "Tiefkühl" + # 1. Gespeichertes Nutzer-Mapping prüfen + learned = get_ingredient_category_mapping(name) + try: + all_cats = [c["name"] for c in get_shopping_categories()] + except Exception: + all_cats = [] - # 2. Obst & Gemüse - if any(k in n for k in [ + if learned and (not all_cats or learned in all_cats): + return learned + + from .ingredients import clean_ingredient_name + clean = clean_ingredient_name(name).lower() + + # Hilfsfunktion zum dynamischen Abgleichen gegen die tatsächlichen Kategorien + def find_best_category_match(search_terms: List[str], fallback: str) -> str: + for cat_name in all_cats: + cat_lower = cat_name.lower() + if any(term in cat_lower for term in search_terms): + return cat_name + return fallback + + # 2. Tiefkühl + if any(k in clean for k in [ + "tk", "tiefkühl", "gefroren", "eiscreme", "speiseeis", "erbsen (tk)", "tk-", "tiefgefroren" + ]): + return find_best_category_match(["tiefkühl", "tk", "frost", "eis"], "Tiefkühl") + + # 3. Obst & Gemüse (mit Kresse, Edamame, Sprossen, Kräutern etc.) + produce_keywords = [ "zwiebel", "knoblauch", "kartoffel", "karotte", "möhre", "tomate", "paprika", "brokkoli", "blumenkohl", "spinat", "zucchini", "aubergine", "gurke", "salat", - "lauch", "porree", "frühlingszwiebel", "sellerie", "kohlrabi", "champignon", - "pilz", "steinpilz", "pfifferling", "apfel", "äpfel", "birne", "banane", - "avocado", "zitrone", "limette", "orange", "beere", "erdbeere", "himbeere", - "heidelbeere", "blaubeere", "petersilie", "schnittlauch", "basilikum", "koriander", + "lauch", "porree", "frühlingszwiebel", "lauchzwiebel", "sellerie", "kohlrabi", "champignon", + "pilz", "steinpilz", "pfifferling", "shiitake", "austernpilz", "kräuterseitling", + "apfel", "äpfel", "birne", "birnen", "banane", "bananen", "avocado", "zitrone", "zitronen", + "limette", "limetten", "orange", "orangen", "beere", "erdbeere", "himbeere", + "heidelbeere", "blaubeere", "brombeere", "kirsche", "kirschen", "pflaume", "zwetschge", + "mango", "ananas", "maracuja", "pfirsich", "nektarine", "aprikose", "melone", "wassermelone", + "petersilie", "schnittlauch", "basilikum", "koriander", "kresse", "gartenkresse", "brunnenkresse", "ingwer", "chili", "peperoni", "kürbis", "radieschen", "rucola", "rosmarin", - "minze", "dill", "salbei", "kräuter", "kohl", "wirsing" - ]): - return "Obst & Gemüse" - - # 3. Brot & Backwaren - if any(k in n for k in [ - "brot", "brötchen", "semmel", "semmeln", "toast", "baguette", "fladenbrot", - "tortilla", "wrap", "panko", "paniermehl", "hefe", "backpulver", "vanillezucker", - "blätterteig", "croissant" - ]): - return "Brot & Backwaren" + "minze", "dill", "salbei", "kräuter", "kohl", "wirsing", "rotkohl", "weißkohl", "spitzkohl", + "grünkohl", "rosenkohl", "edamame", "sprossen", "keimlinge", "bimi", "chinakohl", "pak choi", + "fenchel", "pastinake", "rote bete", "rote beete", "schalotte", "babyspinat", "feldsalat", + "kopfsalat", "eisbergsalat", "chicoree", "radicchio", "feige", "traube", "weintraube" + ] + if any(re.search(r"\b" + re.escape(k), clean) for k in produce_keywords): + return find_best_category_match(["obst", "gemüse", "frische", "früchte"], "Obst & Gemüse") # 4. Kühlregal & Käse - if any(k in n for k in [ + dairy_keywords = [ "milch", "butter", "sahne", "schmand", "quark", "joghurt", "käse", "cheddar", - "parmesan", "mozzarella", "feta", "gouda", "bergkäse", "frischkäse", - "crème fraîche", "creme fraiche", "mascarpone", "ricotta", "ei", "eier", - "tofu", "sauerrahm", "buttermilch" - ]): - return "Kühlregal & Käse" + "parmesan", "mozzarella", "feta", "gouda", "bergkäse", "frischkäse", "emmentaler", + "gruyère", "gruyere", "pecorino", "manchego", "halloumi", "burrata", "mascarpone", + "ricotta", "crème fraîche", "creme fraiche", "ei", "eier", "eigelb", "eiweiß", + "tofu", "räuchertofu", "seidentofu", "tempeh", "sauerrahm", "buttermilch", "kefir", + "skyr", "hafermilch", "sojamilch", "mandelmilch", "streukäse", "ghee" + ] + if any(re.search(r"\b" + re.escape(k), clean) for k in dairy_keywords): + return find_best_category_match(["kühl", "käse", "milch", "molkerei"], "Kühlregal & Käse") # 5. Fleisch & Fisch - if any(k in n for k in [ + meat_keywords = [ "hackfleisch", "hack", "rinderhack", "hähnchen", "huhn", "pute", "speck", "schinken", "wurst", "würstchen", "lachs", "garnele", "garnelen", "fisch", "forelle", "kabeljau", "rind", "schwein", "kalb", "lamm", "steak", "filet", - "bratwurst", "wiener" - ]): - return "Fleisch & Fisch" + "bratwurst", "wiener", "bacon", "meeresfrüchte", "thunfisch" + ] + if any(re.search(r"\b" + re.escape(k), clean) for k in meat_keywords): + return find_best_category_match(["fleisch", "fisch", "metzger", "wurst"], "Fleisch & Fisch") - # 6. Gewürze & Öle - if any(k in n for k in [ - "öl", "olivenöl", "rapsöl", "sonnenblumenöl", "sesamöl", "bratöl", "essig", - "balsamico", "sojasauce", "salz", "pfeffer", "paprikapulver", "curry", - "kreuzkümmel", "kümmel", "oregano", "thymian", "majoran", "muskat", - "cayennepfeffer", "zimt", "lorbeer", "vanille", "kurkuma", "chiliflocken" - ]): - return "Gewürze & Öle" + # 6. Brot & Backwaren + bakery_keywords = [ + "brot", "brötchen", "semmel", "semmeln", "toast", "toastbrot", "baguette", + "ciabatta", "fladenbrot", "tortilla", "wrap", "wraps", "pita", "naan", + "panko", "paniermehl", "semmelbrösel", "blätterteig", "croissant", "brioche" + ] + if any(re.search(r"\b" + re.escape(k), clean) for k in bakery_keywords): + return find_best_category_match(["brot", "backwaren", "bäckerei"], "Brot & Backwaren") - # 7. Vorrat & Nudeln - if any(k in n for k in [ + # 7. Gewürze & Öle + spice_keywords = [ + "olivenöl", "rapsöl", "sonnenblumenöl", "sesamöl", "bratöl", "kokosöl", "leinöl", "erdnussöl", + "essig", "balsamico", "apfelessig", "weißweinessig", "rotweinessig", "salz", "meersalz", + "pfeffer", "paprikapulver", "curry", "kreuzkümmel", "kumin", "kümmel", "oregano", "thymian", + "majoran", "muskat", "muskatnuss", "cayennepfeffer", "zimt", "zimtstange", "lorbeer", + "vanilleschote", "vanille", "kurkuma", "chiliflocken", "kardamom", "nelken", "piment", + "wacholder", "garam masala", "ras el hanout" + ] + if re.search(r"\böl\b", clean) or any(re.search(r"\b" + re.escape(k), clean) for k in spice_keywords): + return find_best_category_match(["gewürz", "öl", "essig"], "Gewürze & Öle") + + # 8. Vorrat & Nudeln + pantry_keywords = [ "nudel", "nudeln", "spaghetti", "penne", "mie", "pasta", "reis", "basmati", "jasmin", "linsen", "kichererbsen", "bohnen", "kidneybohnen", "mais", "tomatenmark", "passierte tomaten", "gehackte tomaten", "dosentomaten", - "mehl", "zucker", "puderzucker", "haferflocken", "kokosmilch", "brühe", - "gemüsebrühe", "rinderbrühe", "hühnerbrühe", "couscous", "bulgur", "quinoa", - "erdnussbutter", "marmelade", "honig", "ahornsirup", "senf", "ketchup", - "mayonnaise", "schokolade", "zartbitterschokolade", "kakao", "nüsse", "mandeln", - "walnüsse", "sesam", "chiasamen", "leinsamen" - ]): - return "Vorrat & Nudeln" + "mehl", "zucker", "puderzucker", "rohrzucker", "haferflocken", "kokosmilch", + "brühe", "gemüsebrühe", "rinderbrühe", "hühnerbrühe", "fond", "couscous", "bulgur", + "quinoa", "polenta", "grieß", "erdnussbutter", "marmelade", "honig", "ahornsirup", + "agavendicksaft", "senf", "ketchup", "mayonnaise", "schokolade", "kuvertüre", "kakao", + "toffifee", "nüsse", "mandeln", "walnüsse", "haselnüsse", "cashew", "cashews", + "macadamia", "sesam", "chiasamen", "leinsamen", "pinienkerne", "sojasauce", + "tamari", "teriyaki", "hoisin", "backpulver", "natron", "vanillezucker", "trockenhefe", + "sahnesteif", "speisestärke", "stärke" + ] + if any(re.search(r"\b" + re.escape(k), clean) for k in pantry_keywords): + return find_best_category_match(["vorrat", "nudel", "konserve", "trocken"], "Vorrat & Nudeln") - # 8. Getränke - if any(k in n for k in [ + # 9. Getränke + drink_keywords = [ "wasser", "mineralwasser", "saft", "apfelsaft", "orangensaft", "wein", - "weißwein", "rotwein", "bier", "kaffee", "espresso", "tee", "cola", - "limonade", "sirup" - ]): - return "Getränke" + "weißwein", "rotwein", "sekt", "prosecco", "bier", "kaffee", "espresso", + "tee", "cola", "limonade", "sirup", "tonic" + ] + if any(re.search(r"\b" + re.escape(k), clean) for k in drink_keywords): + return find_best_category_match(["getränk", "durst", "wein", "saft"], "Getränke") - return "Drogerie & Sonstiges" + # Fallback: Drogerie & Sonstiges + return find_best_category_match(["drogerie", "sonstig"], "Drogerie & Sonstiges") def get_connection(): @@ -346,6 +446,16 @@ def init_db(): ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS ingredient_category_mappings ( + id INT AUTO_INCREMENT PRIMARY KEY, + ingredient_name VARCHAR(150) NOT NULL UNIQUE, + category_name VARCHAR(100) NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_ing_cat_name (ingredient_name) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + """) + try: cursor.execute(""" SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS @@ -451,6 +561,15 @@ def init_db(): ) """) + cursor.execute(""" + CREATE TABLE IF NOT EXISTS ingredient_category_mappings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ingredient_name TEXT NOT NULL UNIQUE COLLATE NOCASE, + category_name TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + cursor.execute("PRAGMA table_info(shopping_list)") existing_shop_cols = [c[1] for c in cursor.fetchall()] if "category" not in existing_shop_cols: @@ -1092,16 +1211,21 @@ def update_shopping_item( category: Optional[str] = None, note: str = "" ) -> Optional[Dict[str, Any]]: - """Aktualisiert alle Eigenschaften eines Eintrags in der Einkaufsliste.""" + """Aktualisiert alle Eigenschaften eines Eintrags in der Einkaufsliste und lernt die Kategorie.""" + from .ingredients import clean_ingredient_name + cleaned_name = clean_ingredient_name(item_name) or item_name.strip() cat = (category or "").strip() if not cat: - cat = guess_category_for_ingredient(item_name) + cat = guess_category_for_ingredient(cleaned_name) + else: + save_ingredient_category_mapping(cleaned_name, cat) + query = """ UPDATE shopping_list SET item_name = ?, amount = ?, unit = ?, category = ?, note = ? WHERE id = ? """ - execute_query(query, (item_name.strip(), amount.strip(), unit.strip(), cat, (note or "").strip(), item_id), fetch="none", commit=True) + execute_query(query, (cleaned_name, amount.strip(), unit.strip(), cat, (note or "").strip(), item_id), fetch="none", commit=True) row = execute_query("SELECT * FROM shopping_list WHERE id = ?", (item_id,), fetch="one") if row: row["checked"] = bool(row.get("checked")) @@ -1115,8 +1239,12 @@ def update_shopping_item( def update_shopping_item_category(item_id: int, category: str) -> Optional[Dict[str, Any]]: - """Ändert die Kategorie/den Bereich eines einzelnen Listeneintrags.""" + """Ändert die Kategorie/den Bereich eines einzelnen Listeneintrags und merkt sich die Zuordnung.""" clean_cat = category.strip() + curr = execute_query("SELECT item_name FROM shopping_list WHERE id = ?", (item_id,), fetch="one") + if curr and curr.get("item_name"): + save_ingredient_category_mapping(curr["item_name"], clean_cat) + query = "UPDATE shopping_list SET category = ? WHERE id = ?" execute_query(query, (clean_cat, item_id), fetch="none", commit=True) row = execute_query("SELECT * FROM shopping_list WHERE id = ?", (item_id,), fetch="one") @@ -1160,14 +1288,60 @@ def add_shopping_item( category: Optional[str] = None, note: str = "" ) -> Dict[str, Any]: - """Fügt einen einzelnen Eintrag zur Einkaufsliste hinzu.""" - cat = (category or "").strip() - if not cat: - cat = guess_category_for_ingredient(item_name) + """Fügt einen einzelnen Eintrag zur Einkaufsliste hinzu oder aggregiert ihn mit einem offenen Eintrag.""" + from .ingredients import clean_ingredient_name, get_ingredient_key, add_ingredient_amounts + + clean_name = clean_ingredient_name(item_name) or item_name.strip() + key = get_ingredient_key(clean_name) + amt = amount.strip() + u = unit.strip() clean_src = clean_source_label(source) clean_note = (note or "").strip() + + cat = (category or "").strip() + if not cat: + cat = guess_category_for_ingredient(clean_name) + + # Prüfen, ob bereits ein offener Eintrag mit demselben Schlüssel existiert + if key: + existing = execute_query( + "SELECT * FROM shopping_list WHERE checked = 0 ORDER BY id DESC", + fetch="all" + ) + match = next((row for row in existing if get_ingredient_key(row.get("item_name", "")) == key), None) + if match: + new_amt, new_unit = add_ingredient_amounts(match.get("amount", ""), match.get("unit", ""), amt, u) + existing_sources = [s.strip() for s in (match.get("source") or "").split("&") if s.strip()] + if clean_src and clean_src not in existing_sources: + existing_sources.append(clean_src) + new_source = " & ".join(existing_sources) + + existing_notes = [n.strip() for n in (match.get("note") or "").split(" | ") if n.strip()] + if clean_note and clean_note not in existing_notes: + existing_notes.append(clean_note) + new_note = " | ".join(existing_notes) + + new_cat = category if category else (match.get("category") or cat) + execute_query( + "UPDATE shopping_list SET amount = ?, unit = ?, source = ?, category = ?, note = ? WHERE id = ?", + (new_amt, new_unit, new_source, new_cat, new_note, match["id"]), + fetch="none", + commit=True + ) + updated_row = execute_query("SELECT * FROM shopping_list WHERE id = ?", (match["id"],), fetch="one") + if updated_row: + updated_row["checked"] = bool(updated_row.get("checked")) + updated_row["display_source"] = clean_source_label(updated_row.get("source", "")) + updated_row["note"] = str(updated_row.get("note") or "").strip() + if isinstance(updated_row.get("created_at"), (datetime, date)): + updated_row["created_at"] = updated_row["created_at"].isoformat() + if isinstance(updated_row.get("updated_at"), (datetime, date)): + updated_row["updated_at"] = updated_row["updated_at"].isoformat() + return updated_row + + # Sonst neu anlegen query = "INSERT INTO shopping_list (item_name, amount, unit, source, category, note, checked) VALUES (?, ?, ?, ?, ?, ?, 0)" - res = execute_query(query, (item_name.strip(), amount.strip(), unit.strip(), clean_src, cat, clean_note), fetch="none", commit=True) + res = execute_query(query, (clean_name, amt, u, clean_src, cat, clean_note), fetch="none", commit=True) item_id = res.get("lastrowid") row = execute_query("SELECT * FROM shopping_list WHERE id = ?", (item_id,), fetch="one") if row: @@ -1182,25 +1356,92 @@ def add_shopping_item( def add_shopping_items_bulk(items: List[Dict[str, Any]]) -> int: - """Fügt mehrere Zutaten/Einträge auf einmal zur Einkaufsliste hinzu.""" + """Fügt mehrere Zutaten/Einträge auf einmal zur Einkaufsliste hinzu, fasst Duplikate zusammen und summiert Mengen.""" if not items: return 0 + + from .ingredients import clean_ingredient_name, get_ingredient_key, add_ingredient_amounts + + # 1. Vorhandene offene (ungehakte) Einträge laden + existing_rows = execute_query( + "SELECT id, item_name, amount, unit, source, category, note, checked FROM shopping_list WHERE checked = 0", + fetch="all" + ) + open_items_map: Dict[str, Dict[str, Any]] = {} + for row in existing_rows: + k = get_ingredient_key(row.get("item_name", "")) + if k and k not in open_items_map: + open_items_map[k] = row + count = 0 for it in items: - name = (it.get("item_name") or it.get("name") or it.get("raw") or "").strip() - if not name: + raw_name = (it.get("item_name") or it.get("name") or it.get("raw") or "").strip() + if not raw_name: continue + clean_name = clean_ingredient_name(raw_name) or raw_name + key = get_ingredient_key(clean_name) + amt = str(it.get("amount") or "").strip() unit = str(it.get("unit") or "").strip() raw_src = str(it.get("source") or it.get("recipe_title") or "").strip() src = clean_source_label(raw_src) cat = str(it.get("category") or "").strip() if not cat: - cat = guess_category_for_ingredient(name) + cat = guess_category_for_ingredient(clean_name) note = str(it.get("note") or "").strip() - query = "INSERT INTO shopping_list (item_name, amount, unit, source, category, note, checked) VALUES (?, ?, ?, ?, ?, ?, 0)" - execute_query(query, (name, amt, unit, src, cat, note), fetch="none", commit=True) - count += 1 + + # Prüfen, ob Zutat bereits in der offenen Liste existiert (auch innerhalb dieses Batches) + if key in open_items_map: + target = open_items_map[key] + new_amt, new_unit = add_ingredient_amounts( + target.get("amount", ""), target.get("unit", ""), + amt, unit + ) + + existing_sources = [s.strip() for s in (target.get("source") or "").split("&") if s.strip()] + if src and src not in existing_sources: + existing_sources.append(src) + new_source = " & ".join(existing_sources) + + existing_notes = [n.strip() for n in (target.get("note") or "").split(" | ") if n.strip()] + if note and note not in existing_notes: + existing_notes.append(note) + new_note = " | ".join(existing_notes) + + new_cat = target.get("category") or cat + + item_id = target["id"] + execute_query( + "UPDATE shopping_list SET amount = ?, unit = ?, source = ?, category = ?, note = ? WHERE id = ?", + (new_amt, new_unit, new_source, new_cat, new_note, item_id), + fetch="none", + commit=True + ) + + target["amount"] = new_amt + target["unit"] = new_unit + target["source"] = new_source + target["note"] = new_note + target["category"] = new_cat + count += 1 + else: + query = "INSERT INTO shopping_list (item_name, amount, unit, source, category, note, checked) VALUES (?, ?, ?, ?, ?, ?, 0)" + res = execute_query(query, (clean_name, amt, unit, src, cat, note), fetch="none", commit=True) + new_id = res.get("lastrowid") + new_row = { + "id": new_id, + "item_name": clean_name, + "amount": amt, + "unit": unit, + "source": src, + "category": cat, + "note": note, + "checked": 0 + } + if key: + open_items_map[key] = new_row + count += 1 + return count @@ -1254,7 +1495,7 @@ def get_meal_plan_ingredients(year: int, week: int) -> List[Dict[str, Any]]: if not slots: return [] - from .ingredients import parse_ingredients_block + from .ingredients import parse_ingredients_block, clean_ingredient_name all_recipes = {r["id"]: r for r in get_all_recipes()} day_names = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] @@ -1291,6 +1532,7 @@ def get_meal_plan_ingredients(year: int, week: int) -> List[Dict[str, Any]]: continue name = line.get("name") or raw_text + clean_name = clean_ingredient_name(name) or name amt = str(line.get("amount") or "") if line.get("amount") is not None else "" unit = line.get("unit") or "" @@ -1299,8 +1541,8 @@ def get_meal_plan_ingredients(year: int, week: int) -> List[Dict[str, Any]]: "recipe_title": recipe["title"], "slot_label": slot_label, "raw": raw_text, - "name": name, - "item_name": name, + "name": clean_name, + "item_name": clean_name, "amount": amt, "unit": unit }) diff --git a/app/ingredients.py b/app/ingredients.py index 4f4f7ff..9c4a08c 100644 --- a/app/ingredients.py +++ b/app/ingredients.py @@ -10,7 +10,9 @@ COMMON_UNITS = [ "pck.", "pck", "päckchen", "pkt.", "pkt", "dose", "dosen", "glas", "gläser", "prise", "prisen", "msp.", "msp", "messerspitze", "bund", "stk.", "stk", "stück", "stücke", "zehe", "zehen", "zweig", "zweige", "blatt", "blätter", - "scheibe", "scheiben", "stange", "stangen", "kopf", "nest", "nester", "tropfen", "tr." + "scheibe", "scheiben", "stange", "stangen", "kopf", "nest", "nester", "tropfen", "tr.", + "kästchen", "schale", "schalen", "topf", "töpfe", "packung", "packungen", + "becher", "beutel", "knolle", "knollen" ] def parse_fraction(val_str: str) -> Optional[float]: @@ -181,3 +183,233 @@ def calculate_portion_matrix( return matrix + +SYNONYMS = { + "möhre": "karotte", + "möhren": "karotte", + "karotte": "karotte", + "karotten": "karotte", + "zwiebel": "zwiebel", + "zwiebeln": "zwiebel", + "knoblauch": "knoblauch", + "knoblauchzehe": "knoblauch", + "knoblauchzehen": "knoblauch", + "kartoffel": "kartoffel", + "kartoffeln": "kartoffel", + "erdapfel": "kartoffel", + "erdäpfel": "kartoffel", + "ei": "ei", + "eier": "ei", + "tomate": "tomate", + "tomaten": "tomate", + "paprika": "paprika", + "paprikas": "paprika", + "paprikaschote": "paprika", + "paprikaschoten": "paprika", + "apfel": "apfel", + "äpfel": "apfel", + "birne": "birne", + "birnen": "birne", + "zitrone": "zitrone", + "zitronen": "zitrone", + "limette": "limette", + "limetten": "limette", + "gurke": "gurke", + "gurken": "gurke", + "salatgurke": "gurke", + "salatgurken": "gurke", + "avocado": "avocado", + "avocados": "avocado", + "champignon": "champignon", + "champignons": "champignon", + "pilz": "champignon", + "pilze": "champignon", + "frühlingszwiebel": "frühlingszwiebel", + "frühlingszwiebeln": "frühlingszwiebel", + "lauchzwiebel": "frühlingszwiebel", + "lauchzwiebeln": "frühlingszwiebel", + "lauch": "porree", + "porree": "porree", + "lauchstange": "porree", + "lauchstangen": "porree", + "schalotte": "schalotte", + "schalotten": "schalotte", + "banane": "banane", + "bananen": "banane", + "orange": "orange", + "orangen": "orange", + "chili": "chili", + "chilischote": "chili", + "chilischoten": "chili", + "chilis": "chili", + "semmel": "brötchen", + "semmeln": "brötchen", + "brötchen": "brötchen", +} + + +def clean_ingredient_name(name: str) -> str: + """Bereinigt Zutatennamen von vorangestellten Aufzählungszeichen, Klammerzusätzen und Zubereitungshinweisen.""" + if not name: + return "" + # Führende Bullets, Striche, Sternchen entfernen + s = re.sub(r"^[•\-\*\s]+", "", name.strip()) + # Klammerzusätze wie '(in Scheiben)', '(optional)', '(TK)', '(Größe M)' entfernen + s = re.sub(r"\s*\([^)]*\)", "", s) + # Nachgestellte Zubereitungshinweise nach Komma entfernen, falls kurz (z.B. 'Zwiebeln, gewürfelt') + if "," in s: + parts = s.split(",", 1) + if len(parts[1].strip().split()) <= 4: + s = parts[0] + # Satzzeichen am Ende entfernen + s = re.sub(r"[\s.,;:]+$", "", s).strip() + s = re.sub(r"\s+", " ", s) + if s and not s[0].isupper(): + return s[0].upper() + s[1:] + return s + + +def get_ingredient_key(name: str) -> str: + """Gibt einen normalisierten Schlüssel für den Zutatenvergleich (Plural, Synonyme, Kleinschreibung) zurück.""" + clean = re.sub(r"^[•\-\*\s]+", "", (name or "").strip().lower()) + clean = re.sub(r"\s*\([^)]*\)", "", clean) + if "," in clean: + parts = clean.split(",", 1) + if len(parts[1].strip().split()) <= 4: + clean = parts[0] + clean = re.sub(r"[\s.,;:]+$", "", clean).strip() + clean = re.sub(r"\s+", " ", clean) + if not clean: + return "" + if clean in SYNONYMS: + return SYNONYMS[clean] + + words = clean.split() + if len(words) > 1 and words[-1] in SYNONYMS: + return " ".join(words[:-1] + [SYNONYMS[words[-1]]]) + + # Allgemeine deutsche Pluralformen für Endungen (-n / -en) + if clean.endswith("n") and len(clean) > 4 and not clean.endswith("in"): + if clean.endswith("en"): + stem = clean[:-1] + if stem.endswith("e"): + return stem + return clean[:-1] + if clean.endswith("s") and len(clean) > 4: + return clean[:-1] + + return clean + + +def format_amount_display(num: float) -> str: + """Formatiert Gleitkommazahlen sauber für Mengenangaben (z.B. 1.5 -> '1,5', 2.0 -> '2').""" + if abs(num - round(num)) < 0.01: + return str(int(round(num))) + s = f"{num:.2f}".rstrip("0").rstrip(".") + return s.replace(".", ",") + + +def normalize_unit(u: str) -> str: + """Normalisiert Kücheneinheiten in einheitliche Bezeichnungen.""" + if not u: + return "" + u_clean = u.strip().lower().rstrip(".") + if u_clean in ["kg", "kilo", "kilogramm"]: + return "kg" + if u_clean in ["g", "gramm", "gr"]: + return "g" + if u_clean in ["mg", "milligramm"]: + return "mg" + if u_clean in ["l", "liter"]: + return "l" + if u_clean in ["ml", "milliliter"]: + return "ml" + if u_clean in ["cl"]: + return "cl" + if u_clean in ["el", "esslöffel", "eßlöffel"]: + return "EL" + if u_clean in ["tl", "teelöffel"]: + return "TL" + if u_clean in ["stk", "stück", "stücke"]: + return "Stück" + if u_clean in ["dose", "dosen"]: + return "Dose" + if u_clean in ["glas", "gläser"]: + return "Glas" + if u_clean in ["pck", "päckchen", "pkg", "packung", "packungen"]: + return "Pck." + if u_clean in ["prise", "prisen"]: + return "Prise" + if u_clean in ["zehe", "zehen"]: + return "Zehe" + if u_clean in ["bund"]: + return "Bund" + if u_clean in ["stange", "stangen"]: + return "Stange" + if u_clean in ["becher"]: + return "Becher" + if u_clean in ["kästchen"]: + return "Kästchen" + if u_clean in ["schale", "schalen"]: + return "Schale" + if u_clean in ["topf", "töpfe"]: + return "Topf" + return u.strip() + + +def add_ingredient_amounts(amt1_str: str, u1: str, amt2_str: str, u2: str) -> tuple: + """ + Addiert zwei Mengenangaben intelligent unter Berücksichtigung von Einheiten (Gewichte, Volumina, Stück). + Gibt (neue_menge, neue_einheit) zurück. + """ + n1 = parse_fraction(amt1_str) if amt1_str else None + n2 = parse_fraction(amt2_str) if amt2_str else None + norm_u1 = normalize_unit(u1) + norm_u2 = normalize_unit(u2) + + # Wenn keines von beiden eine Zahl hat + if n1 is None and n2 is None: + return ("", norm_u1 or norm_u2) + # Wenn nur einer eine Zahl hat + if n1 is None: + return (format_amount_display(n2) if n2 is not None else amt2_str, norm_u2) + if n2 is None: + return (format_amount_display(n1) if n1 is not None else amt1_str, norm_u1) + + # Gewichte (g, kg) + if norm_u1 in ["g", "kg"] and norm_u2 in ["g", "kg"]: + g1 = n1 * 1000.0 if norm_u1 == "kg" else n1 + g2 = n2 * 1000.0 if norm_u2 == "kg" else n2 + total_g = g1 + g2 + if total_g >= 1000.0 and (norm_u1 == "kg" or norm_u2 == "kg" or total_g % 500 == 0): + return (format_amount_display(total_g / 1000.0), "kg") + return (format_amount_display(total_g), "g") + + # Volumina (ml, l, cl) + if norm_u1 in ["ml", "l", "cl"] and norm_u2 in ["ml", "l", "cl"]: + ml1 = n1 * 1000.0 if norm_u1 == "l" else (n1 * 10.0 if norm_u1 == "cl" else n1) + ml2 = n2 * 1000.0 if norm_u2 == "l" else (n2 * 10.0 if norm_u2 == "cl" else n2) + total_ml = ml1 + ml2 + if total_ml >= 1000.0 and (norm_u1 == "l" or norm_u2 == "l" or total_ml % 500 == 0): + return (format_amount_display(total_ml / 1000.0), "l") + return (format_amount_display(total_ml), "ml") + + # Identische Einheiten oder Stückzahlen/keine Einheit + if norm_u1.lower() == norm_u2.lower() or (not norm_u1 and not norm_u2): + total = n1 + n2 + out_u = norm_u1 or norm_u2 + if out_u == "Dose" and total > 1: out_u = "Dosen" + elif out_u == "Glas" and total > 1: out_u = "Gläser" + elif out_u == "Zehe" and total > 1: out_u = "Zehen" + elif out_u == "Stange" and total > 1: out_u = "Stangen" + elif out_u == "Prise" and total > 1: out_u = "Prisen" + elif out_u == "Schale" and total > 1: out_u = "Schalen" + elif out_u == "Topf" and total > 1: out_u = "Töpfe" + return (format_amount_display(total), out_u) + + # Inkompatible Einheiten lesbar zusammenführen (z.B. '1 Bund + 500 g') + part1 = f"{format_amount_display(n1)} {u1}".strip() + part2 = f"{format_amount_display(n2)} {u2}".strip() + return (f"{part1} + {part2}", "") + + diff --git a/app/templates/shopping_list.html b/app/templates/shopping_list.html index ff3e78d..91b5ca1 100644 --- a/app/templates/shopping_list.html +++ b/app/templates/shopping_list.html @@ -48,6 +48,12 @@ width: 100%; background: var(--shop-green); } + .cb-nav-btn.is-active { + background: rgba(255, 255, 255, 0.28) !important; + border-color: rgba(255, 255, 255, 0.6) !important; + color: #FFFFFF !important; + font-weight: 700; + } /* Live Online Badge in Navbar */ .cb-online-pill { @@ -435,6 +441,9 @@