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