Add ingredient aggregation, smart category mapping, and toggle to hide checked items

This commit is contained in:
2026-10-19 21:44:00 +02:00
parent f6c1f4e232
commit 3709840d7a
4 changed files with 738 additions and 84 deletions
+317 -75
View File
@@ -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
})