Add ingredient aggregation, smart category mapping, and toggle to hide checked items
This commit is contained in:
+317
-75
@@ -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
@@ -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}", "")
|
||||
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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!")
|
||||
Reference in New Issue
Block a user