"""Parsing and formatting of ingredient lines, steps and tags. Recipes are edited as plain text (one ingredient per line, group headers end with ":", steps separated by blank lines). This module converts between that text and the structured data stored in the database, and scales amounts for the serving calculator. """ import re from dataclasses import dataclass from decimal import ROUND_HALF_UP, Decimal, InvalidOperation MAX_TAGS = 15 MAX_TAG_LENGTH = 40 # Abbreviations that get a canonical spelling. CANONICAL_UNITS = { "g": "g", "gr": "g", "gr.": "g", "gramm": "g", "kg": "kg", "kilo": "kg", "kilogramm": "kg", "mg": "mg", "ml": "ml", "milliliter": "ml", "cl": "cl", "dl": "dl", "l": "l", "liter": "l", "ltr": "l", "ltr.": "l", "el": "EL", "el.": "EL", "essl.": "EL", "esslöffel": "EL", "tbsp": "EL", "tbsp.": "EL", "tl": "TL", "tl.": "TL", "teel.": "TL", "teelöffel": "TL", "tsp": "TL", "tsp.": "TL", "msp": "Msp.", "msp.": "Msp.", "messerspitze": "Msp.", "messerspitzen": "Msp.", "pck": "Pck.", "pck.": "Pck.", "pkg": "Pck.", "pkg.": "Pck.", "päckchen": "Pck.", "packung": "Pck.", "stk": "Stück", "stk.": "Stück", "st.": "Stück", "stück": "Stück", "prise": "Prise", "prisen": "Prise", "cup": "Cup", "cups": "Cup", "oz": "oz", "lb": "lb", "lbs": "lb", } # Unit words kept as written (plural forms matter in German). UNIT_WORDS = { "bund", "dose", "dosen", "zehe", "zehen", "tasse", "tassen", "becher", "glas", "gläser", "scheibe", "scheiben", "handvoll", "zweig", "zweige", "blatt", "blätter", "schuss", "tropfen", "würfel", "stange", "stangen", "knolle", "knollen", "kopf", "köpfe", "flasche", "flaschen", "beutel", "tafel", "tafeln", "spritzer", "stiel", "stiele", } FRACTION_CHARS = {"½": Decimal("0.5"), "¼": Decimal("0.25"), "¾": Decimal("0.75"), "⅓": Decimal(1) / 3, "⅔": Decimal(2) / 3, "⅛": Decimal("0.125")} NICE_FRACTIONS = [(Decimal("0.125"), "⅛"), (Decimal("0.25"), "¼"), (Decimal(1) / 3, "⅓"), (Decimal("0.5"), "½"), (Decimal(2) / 3, "⅔"), (Decimal("0.75"), "¾")] _FRAC = "".join(FRACTION_CHARS) _NUMBER = rf"(?:\d+\s+\d+\s*/\s*\d+|\d+\s*/\s*\d+|\d+\s*[{_FRAC}]|[{_FRAC}]|\d+(?:[.,]\d+)?)" _AMOUNT_RE = re.compile(rf"^(?P{_NUMBER})(?:\s*(?:-|–|bis)\s*(?P{_NUMBER}))?(?=[\s\D]|$)", re.IGNORECASE) _BULLET_RE = re.compile(r"^\s*(?:[-*•·▢□◦]|\d+[.)](?=\s))\s*") _STEP_NUMBER_RE = re.compile(r"^\s*(?:schritt\s*)?\d+\s*[.):]\s*", re.IGNORECASE) @dataclass class ParsedIngredient: name: str amount: Decimal | None = None amount_max: Decimal | None = None unit: str = "" note: str = "" group: str = "" def parse_number(text): text = text.strip().replace(" ", " ") if not text: return None try: if "/" in text: parts = text.split() whole = Decimal(parts[0]) if len(parts) == 2 else Decimal(0) num, den = (parts[-1] if len(parts) == 2 else text).replace(" ", "").split("/") if Decimal(den) == 0: return None return whole + Decimal(num) / Decimal(den) if text[-1] in FRACTION_CHARS: whole = text[:-1].strip() return (Decimal(whole) if whole else Decimal(0)) + FRACTION_CHARS[text[-1]] return Decimal(text.replace(",", ".")) except (InvalidOperation, ValueError): return None def _split_unit(rest): """Split a leading unit off the remaining text. Returns (unit, remainder).""" match = re.match(r"^(\S+)(?:\s+(.*))?$", rest) if not match: return "", rest word, remainder = match.group(1), (match.group(2) or "").strip() if not remainder: return "", rest # "2 Eier": the only word is the ingredient, not a unit key = word.lower() if key in CANONICAL_UNITS: return CANONICAL_UNITS[key], remainder if key in UNIT_WORDS: return word, remainder return "", rest def parse_ingredient_line(line, group=""): line = _BULLET_RE.sub("", line.strip()).strip() if not line: return None amount = amount_max = None rest = line match = _AMOUNT_RE.match(line) if match: amount = parse_number(match.group("a")) amount_max = parse_number(match.group("b")) if match.group("b") else None if amount is not None: rest = line[match.end():].strip() # "200g Mehl": unit glued to the number glued = re.match(r"^([a-zA-ZäöüÄÖÜ.]+)\s+(.*)$", rest) if glued and glued.group(1).lower() in CANONICAL_UNITS and not line[match.end():match.end() + 1].isspace(): unit, rest = CANONICAL_UNITS[glued.group(1).lower()], glued.group(2) else: unit, rest = _split_unit(rest) else: unit = "" else: unit = "" name, note = rest, "" if "," in rest: name, note = (part.strip() for part in rest.split(",", 1)) if not name: name, note = note, "" return ParsedIngredient(name=name, amount=amount, amount_max=amount_max, unit=unit, note=note, group=group) def is_group_header(line): stripped = line.strip() return stripped.endswith(":") and len(stripped) > 1 and not _AMOUNT_RE.match(stripped) def parse_ingredients(text): """Parse the ingredient textarea into a list of ParsedIngredient.""" items, group = [], "" for raw in (text or "").splitlines(): if not raw.strip(): continue if is_group_header(raw): group = _BULLET_RE.sub("", raw.strip())[:-1].strip()[:100] continue item = parse_ingredient_line(raw, group) if item: items.append(item) return items def parse_steps(text): """Blank lines separate steps; without blank lines every line is a step.""" text = (text or "").replace("\r\n", "\n").strip() if not text: return [] chunks = re.split(r"\n\s*\n", text) if re.search(r"\n\s*\n", text) else text.split("\n") steps = [] for chunk in chunks: chunk = " ".join(part.strip() for part in chunk.split("\n") if part.strip()) chunk = _STEP_NUMBER_RE.sub("", chunk).strip() if chunk: steps.append(chunk) return steps def normalize_tag(tag): tag = re.sub(r"\s+", " ", (tag or "").strip().lstrip("#")).lower() return tag[:MAX_TAG_LENGTH] def parse_tags(text): seen, tags = set(), [] for raw in re.split(r"[,;\n]", text or ""): tag = normalize_tag(raw) if tag and tag not in seen: seen.add(tag) tags.append(tag) return tags[:MAX_TAGS] # --- Formatting & scaling ------------------------------------------------------------ def format_amount(value): if value is None: return "" value = Decimal(value) whole = int(value) frac = value - whole for target, glyph in NICE_FRACTIONS: if abs(frac - target) < Decimal("0.02"): return f"{whole} {glyph}" if whole else glyph if frac < Decimal("0.02"): return str(whole) if frac > Decimal("0.98"): return str(whole + 1) rounded = value.quantize(Decimal("0.01"), rounding=ROUND_HALF_UP).normalize() return f"{rounded:f}".replace(".", ",") def _round_metric(value): """Gram/millilitre amounts: no fractions; coarser rounding for big numbers.""" if value >= 100: step = Decimal(5) elif value >= 10: step = Decimal(1) else: return value return (value / step).quantize(Decimal(1), rounding=ROUND_HALF_UP) * step def scale_amount(amount, amount_max, unit, factor): """Scale an amount (and optional upper bound of a range) and normalise metric units (1250 g -> 1,25 kg). Returns (amount, amount_max, unit).""" if amount is None: return None, None, unit values = [Decimal(v) * Decimal(factor) if v is not None else None for v in (amount, amount_max)] if unit in ("g", "ml"): values = [_round_metric(v) if v is not None else None for v in values] if values[0] >= 1000: values = [v / 1000 if v is not None else None for v in values] unit = "kg" if unit == "g" else "l" return values[0], values[1], unit def format_ingredient(amount, amount_max, unit, name, note=""): parts = [] if amount is not None: parts.append(format_amount(amount) + (f"-{format_amount(amount_max)}" if amount_max is not None else "")) if unit: parts.append(unit) parts.append(name) text = " ".join(parts) return f"{text}, {note}" if note else text def ingredients_to_text(ingredients): """Inverse of parse_ingredients for the edit form. Accepts objects with the same attributes.""" lines, group = [], "" for ing in ingredients: if ing.group != group: if lines: lines.append("") if ing.group: lines.append(f"{ing.group}:") group = ing.group lines.append(format_ingredient(ing.amount, ing.amount_max, ing.unit, ing.name, ing.note)) return "\n".join(lines) def steps_to_text(steps): return "\n\n".join(steps)