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
+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}", "")