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
})
+233 -1
View File
@@ -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}", "")
+74 -8
View File
@@ -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 @@
<!-- Desktop Buttons (auf Bildschirmen > 768px) -->
<div class="cb-nav-actions desktop-nav-actions">
<button type="button" id="btn-toggle-hide-checked" onclick="toggleHideChecked()" class="cb-nav-btn" title="Erledigte Artikel ausblenden / einblenden">
<span id="hide-checked-icon">👁️</span> <span id="hide-checked-text">Erledigte ausblenden</span>
</button>
<button type="button" onclick="openCategoriesModal()" class="cb-nav-btn" title="Supermarkt-Bereiche & Laufweg anpassen">
<span>🏬</span> <span>Bereiche anpassen</span>
</button>
@@ -464,6 +473,9 @@
<span>⋮</span>
</button>
<div id="cb-mobile-menu" class="cb-mobile-dropdown">
<button type="button" onclick="handleMobileMenuAction(toggleHideChecked)" class="cb-mobile-item">
<span id="hide-checked-mobile-icon">👁️</span> <span id="hide-checked-mobile-text">Erledigte ausblenden</span>
</button>
<button type="button" onclick="handleMobileMenuAction(openCategoriesModal)" class="cb-mobile-item">
<span>🏬</span> <span>Bereiche anpassen</span>
</button>
@@ -760,6 +772,7 @@
let allItems = [];
let allCategories = [];
let currentFilterCategory = null;
let hideChecked = localStorage.getItem('wtc_hide_checked') === 'true';
let ws = null;
let reconnectTimeout = null;
let pingInterval = null;
@@ -975,6 +988,39 @@
}
}
// ==========================================
// TOGGLE: ERLEDIGTE AUSBLENDEN / EINBLENDEN
// ==========================================
function toggleHideChecked() {
hideChecked = !hideChecked;
localStorage.setItem('wtc_hide_checked', hideChecked ? 'true' : 'false');
updateHideCheckedUI();
renderCategoryFilterChips();
renderShoppingList();
}
function updateHideCheckedUI() {
const desktopBtn = document.getElementById('btn-toggle-hide-checked');
const desktopIcon = document.getElementById('hide-checked-icon');
const desktopText = document.getElementById('hide-checked-text');
const mobileIcon = document.getElementById('hide-checked-mobile-icon');
const mobileText = document.getElementById('hide-checked-mobile-text');
if (hideChecked) {
if (desktopBtn) desktopBtn.classList.add('is-active');
if (desktopIcon) desktopIcon.textContent = '👁️🗨️';
if (desktopText) desktopText.textContent = 'Erledigte anzeigen';
if (mobileIcon) mobileIcon.textContent = '👁️🗨️';
if (mobileText) mobileText.textContent = 'Erledigte anzeigen';
} else {
if (desktopBtn) desktopBtn.classList.remove('is-active');
if (desktopIcon) desktopIcon.textContent = '👁️';
if (desktopText) desktopText.textContent = 'Erledigte ausblenden';
if (mobileIcon) mobileIcon.textContent = '👁️';
if (mobileText) mobileText.textContent = 'Erledigte ausblenden';
}
}
// ==========================================
// RENDERING: KATEGORIEN-FILTERLEISTE
// ==========================================
@@ -982,7 +1028,8 @@
const container = document.getElementById('shop-category-chips');
if (!container) return;
const totalItems = allItems.length;
const relevantItems = hideChecked ? allItems.filter(it => !it.checked) : allItems;
const totalItems = relevantItems.length;
let html = `
<button type="button" class="cb-chip ${currentFilterCategory === null ? 'active' : ''}" onclick="selectFilterCategory(null)">
Alle (${totalItems})
@@ -990,7 +1037,7 @@
`;
allCategories.forEach(cat => {
const count = allItems.filter(it => it.category === cat.name).length;
const count = relevantItems.filter(it => it.category === cat.name).length;
if (count > 0 || currentFilterCategory === cat.name) {
const isAct = currentFilterCategory === cat.name;
html += `
@@ -1002,7 +1049,7 @@
});
// 'Sonstiges' anzeigen falls vorhanden
const uncategorizedCount = allItems.filter(it => !it.category || it.category === 'Sonstiges').length;
const uncategorizedCount = relevantItems.filter(it => !it.category || it.category === 'Sonstiges').length;
if (uncategorizedCount > 0 && !allCategories.some(c => c.name === 'Sonstiges')) {
const isAct = currentFilterCategory === 'Sonstiges';
html += `
@@ -1034,10 +1081,25 @@
if (currentFilterCategory !== null) {
filtered = filtered.filter(it => (it.category || 'Sonstiges') === currentFilterCategory);
}
if (hideChecked) {
filtered = filtered.filter(it => !it.checked);
}
if (filtered.length === 0) {
container.innerHTML = '';
if (emptyState) emptyState.style.display = 'block';
if (emptyState) {
emptyState.style.display = 'block';
const totalOpen = allItems.filter(it => !it.checked).length;
const titleEl = emptyState.querySelector('.shop-empty-title');
const descEl = emptyState.querySelector('.shop-empty-desc');
if (hideChecked && allItems.length > 0 && totalOpen === 0) {
if (titleEl) titleEl.textContent = 'Alle offenen Artikel erledigt!';
if (descEl) descEl.innerHTML = 'Alle Artikel auf deiner Liste sind abgehakt und ausgeblendet. Klicke auf <strong>Erledigte anzeigen</strong>, um sie wieder einzublenden.';
} else {
if (titleEl) titleEl.textContent = 'Alles eingekauft!';
if (descEl) descEl.innerHTML = 'Deine Einkaufsliste ist leer. Tippe unten rechts auf das <strong>+</strong>, um einen neuen Eintrag zu notieren, oder übernimm Zutaten direkt aus dem Wochenplan.';
}
}
return;
} else {
if (emptyState) emptyState.style.display = 'none';
@@ -1077,10 +1139,11 @@
const group = groups[catName];
group.open.sort((a, b) => (a.item_name || '').localeCompare(b.item_name || '', 'de', { sensitivity: 'base' }));
group.checked.sort((a, b) => (a.item_name || '').localeCompare(b.item_name || '', 'de', { sensitivity: 'base' }));
const allInGroup = [...group.open, ...group.checked];
if (allInGroup.length === 0) return;
const allInGroup = hideChecked ? group.open : [...group.open, ...group.checked];
if (allInGroup.length === 0) return; // Kategorie komplett ausblenden, wenn keine Einträge!
const catObj = allCategories.find(c => c.name === catName) || { name: catName, icon: '🛒' };
const countBadge = hideChecked ? `${group.open.length} offen` : `${group.open.length} offen / ${allInGroup.length}`;
html += `
<section class="shop-cat-section">
@@ -1088,7 +1151,7 @@
<h2 class="shop-cat-title">
<span>${catObj.icon || '🛒'}</span> <span>${escapeHtml(catName)}</span>
</h2>
<span class="shop-cat-count">${group.open.length} offen / ${allInGroup.length}</span>
<span class="shop-cat-count">${countBadge}</span>
</div>
<ul class="shop-items-list">
${allInGroup.map(item => renderItemRow(item)).join('')}
@@ -1147,6 +1210,7 @@
// Optimistic UI Update
item.checked = !item.checked;
saveLocalCache(allItems);
renderCategoryFilterChips();
renderShoppingList();
try {
@@ -1453,6 +1517,7 @@
data-name="${escapeAttr(ingName)}"
data-amount="${escapeAttr(ing.amount || '')}"
data-unit="${escapeAttr(ing.unit || '')}"
data-source="${escapeAttr(g.recipe_title || '')}"
onchange="updateMealPlanConfirmBtn()">
<span>${amtStr ? `<strong>${escapeHtml(amtStr)}</strong> ` : ''}${escapeHtml(ingName)}</span>
</label>
@@ -1521,7 +1586,7 @@
item_name: cb.dataset.name,
amount: cb.dataset.amount || '',
unit: cb.dataset.unit || '',
source: '',
source: cb.dataset.source || '',
category: null,
note: ''
}));
@@ -1745,6 +1810,7 @@
}
document.addEventListener('DOMContentLoaded', () => {
updateHideCheckedUI();
renderCategoryFilterChips();
renderShoppingList();
initWebSocket();
+114
View File
@@ -0,0 +1,114 @@
# -*- coding: utf-8 -*-
import sys
import os
# Project root
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
import app.ingredients as ing
import app.database as db
def test_ingredient_cleaning_and_keys():
print("--- 1. Testing clean_ingredient_name & get_ingredient_key ---")
assert ing.clean_ingredient_name("Karotten (in Scheiben)") == "Karotten"
assert ing.clean_ingredient_name("• Eier (Größe M)") == "Eier"
assert ing.clean_ingredient_name("Zwiebeln, fein gewürfelt") == "Zwiebeln"
assert ing.clean_ingredient_name("frische Kresse") == "Frische Kresse"
assert ing.get_ingredient_key("Karotten") == "karotte"
assert ing.get_ingredient_key("Möhren") == "karotte"
assert ing.get_ingredient_key("Karotten (in Scheiben geschnitten)") == "karotte"
assert ing.get_ingredient_key("Zwiebeln") == "zwiebel"
assert ing.get_ingredient_key("Zwiebel") == "zwiebel"
assert ing.get_ingredient_key("Knoblauchzehen") == "knoblauch"
assert ing.get_ingredient_key("Eier") == "ei"
assert ing.get_ingredient_key("Äpfel") == "apfel"
assert ing.get_ingredient_key("Apfel") == "apfel"
print("[OK] Cleaning & keys test passed!")
def test_amount_addition():
print("--- 2. Testing add_ingredient_amounts ---")
# Weights
amt, u = ing.add_ingredient_amounts("500", "g", "300", "g")
assert amt == "800" and u == "g", f"Got {amt} {u}"
amt, u = ing.add_ingredient_amounts("500", "g", "1", "kg")
assert amt == "1,5" and u == "kg", f"Got {amt} {u}"
# Volumes
amt, u = ing.add_ingredient_amounts("200", "ml", "800", "ml")
assert amt == "1" and u == "l", f"Got {amt} {u}"
# Spoons
amt, u = ing.add_ingredient_amounts("2", "EL", "3", "EL")
assert amt == "5" and u == "EL", f"Got {amt} {u}"
# Pieces / No unit
amt, u = ing.add_ingredient_amounts("2", "", "3", "")
assert amt == "5" and u == "", f"Got {amt} {u}"
# Plural units
amt, u = ing.add_ingredient_amounts("1", "Dose", "2", "Dosen")
assert amt == "3" and u == "Dosen", f"Got {amt} {u}"
amt, u = ing.add_ingredient_amounts("1", "Zehe", "2", "Zehen")
assert amt == "3" and u == "Zehen", f"Got {amt} {u}"
# Incompatible
amt, u = ing.add_ingredient_amounts("1", "Bund", "500", "g")
assert "1 Bund" in amt and "500 g" in amt and u == "", f"Got {amt} {u}"
print("[OK] Amount additions test passed!")
def test_category_guessing():
print("--- 3. Testing guess_category_for_ingredient ---")
assert db.guess_category_for_ingredient("Kresse") == "Obst & Gemüse"
assert db.guess_category_for_ingredient("1 Kästchen Kresse") == "Obst & Gemüse"
assert db.guess_category_for_ingredient("Edamame (TK)") == "Tiefkühl" or db.guess_category_for_ingredient("Edamame") == "Obst & Gemüse"
assert db.guess_category_for_ingredient("Edamame") == "Obst & Gemüse"
assert db.guess_category_for_ingredient("Bergkäse (gerieben)") == "Kühlregal & Käse"
assert db.guess_category_for_ingredient("Tofu") == "Kühlregal & Käse"
assert db.guess_category_for_ingredient("Ahornsirup") == "Vorrat & Nudeln"
assert db.guess_category_for_ingredient("Toffifee") == "Vorrat & Nudeln"
assert db.guess_category_for_ingredient("Olivenöl") == "Gewürze & Öle"
assert db.guess_category_for_ingredient("Möhren") == "Obst & Gemüse"
print("[OK] Category guessing test passed!")
def test_db_aggregation_and_sources():
print("--- 4. Testing DB aggregation & sources merging ---")
# Clean previous test entries
db.execute_query("DELETE FROM shopping_list WHERE item_name LIKE ?", ("Test-%",), fetch="none", commit=True)
batch = [
{"item_name": "Test-Karotten (in Scheiben)", "amount": "400", "unit": "g", "source": "Rezept A"},
{"item_name": "Test-Karotten", "amount": "600", "unit": "g", "source": "Rezept B"},
]
count = db.add_shopping_items_bulk(batch)
rows = db.execute_query("SELECT * FROM shopping_list WHERE item_name = 'Test-Karotten'", fetch="all")
assert len(rows) == 1, f"Expected 1 aggregated row, got {len(rows)}"
row = rows[0]
assert row["amount"] == "1" and row["unit"] == "kg", f"Expected 1 kg, got {row['amount']} {row['unit']}"
assert "Rezept A" in row["source"] and "Rezept B" in row["source"], f"Sources not merged: {row['source']}"
assert row["category"] == "Obst & Gemüse", f"Expected Obst & Gemüse, got {row['category']}"
# Clean up
db.execute_query("DELETE FROM shopping_list WHERE item_name LIKE ?", ("Test-%",), fetch="none", commit=True)
print("[OK] DB aggregation test passed!")
def test_learning_categories():
print("--- 5. Testing category learning & override ---")
db.save_ingredient_category_mapping("Test-Superfood", "Tiefkühl")
guessed = db.guess_category_for_ingredient("Test-Superfood")
assert guessed == "Tiefkühl", f"Expected Tiefkühl, got {guessed}"
# Clean up
db.execute_query("DELETE FROM ingredient_category_mappings WHERE ingredient_name = ?", ("test-superfood",), fetch="none", commit=True)
print("[OK] Category learning test passed!")
if __name__ == "__main__":
test_ingredient_cleaning_and_keys()
test_amount_addition()
test_category_guessing()
test_db_aggregation_and_sources()
test_learning_categories()
print("\nALL TESTS PASSED SUCCESSFULLY!")