"""Pure parsing helpers for single cell values. No workbook access here.""" from __future__ import annotations import re from dataclasses import dataclass, field # Canonical unit keys (shared with the app) and the spellings found in the workbook. UNIT_ALIASES: dict[str, list[str]] = { "gram": ["gramm", "g", "gr"], "kilogram": ["kilogramm", "kg"], "milliliter": ["milliliter", "mililiter", "ml"], "liter": ["liter", "l"], "tablespoon": ["esslöffel", "el"], "teaspoon": ["teelöffel", "tl"], "pinch": ["prise", "prisen"], "knifeTip": ["msp.", "msp", "messerspitze"], "pack": ["packung", "pkt.", "pkt", "päckchen"], "can": ["dose"], "jar": ["glas"], "tub": ["becher"], "bag": ["beutel"], "carton": ["karton"], "bunch": ["bund"], "head": ["kopf"], "slice": ["scheibe/n", "scheibe", "scheiben"], "clove": ["zehe/n", "zehe", "zehen"], "sprig": ["zweig (e)", "zweig", "zweige"], "handful": ["handvoll", "hand voll"], "cup": ["tasse", "tassen"], "grain": ["körner", "korn"], "piece": ["stück", "stk.", "stk"], "small": ["kleine", "klein", "kleiner", "kleines"], "medium": ["mittel", "mittlere", "mittelgroße"], "large": ["große", "groß", "großer", "großes"], "some": ["etwas"], } _UNIT_LOOKUP = {alias: key for key, aliases in UNIT_ALIASES.items() for alias in aliases} _FRACTIONS = {"½": 0.5, "¼": 0.25, "¾": 0.75, "⅓": 1 / 3, "⅔": 2 / 3, "⅛": 0.125} _NUMBER = r"\d+(?:[.,]\d+)?" def clean(value) -> str | None: """Cell value as stripped text, or None when empty.""" if value is None: return None text = str(value).replace("\r\n", "\n").strip() return text or None def _round(x: float) -> float | int: x = round(x, 3) return int(x) if x == int(x) else x def _single_amount(text: str) -> float | None: text = text.strip() if text in _FRACTIONS: return _FRACTIONS[text] m = re.fullmatch(rf"(\d+)\s*([½¼¾⅓⅔⅛])", text) if m: return int(m.group(1)) + _FRACTIONS[m.group(2)] m = re.fullmatch(r"(?:(\d+)\s+)?(\d+)\s*/\s*(\d+)", text) if m and int(m.group(3)) != 0: return int(m.group(1) or 0) + int(m.group(2)) / int(m.group(3)) if re.fullmatch(_NUMBER, text): return float(text.replace(",", ".")) return None def parse_amount(value) -> tuple[float | int | None, float | int | None] | None: """(amount, amountMax) from a quantity cell; None when the text is not a quantity.""" if value is None: return (None, None) if isinstance(value, (int, float)): return (_round(float(value)), None) if value != 0 else (None, None) text = clean(value) if text is None: return (None, None) parts = re.split(r"\s*(?:-|–|bis)\s*", text) if len(parts) == 2: low, high = _single_amount(parts[0]), _single_amount(parts[1]) if low is not None and high is not None: return (_round(low), _round(high)) single = _single_amount(text) if single is not None: return (_round(single), None) if single != 0 else (None, None) return None def parse_unit(value) -> str | None: """Canonical unit key for a unit cell, or None when unrecognised.""" text = clean(value) if text is None: return None return _UNIT_LOOKUP.get(text.lower()) def split_amount_unit(value) -> tuple[float | int, str | None] | None: """'100 g' / '1 TL' / '3' typed into the unit column -> (amount, unit key).""" text = clean(value) if text is None: return None m = re.fullmatch(rf"({_NUMBER})\s*(.*)", text) if not m: return None amount = _round(float(m.group(1).replace(",", "."))) rest = m.group(2).strip() if not rest: return (amount, None) unit = parse_unit(rest) return (amount, unit) if unit else None def is_number(value) -> bool: if isinstance(value, (int, float)): return True text = clean(value) return text is not None and re.fullmatch(_NUMBER, text) is not None def parse_minutes(value) -> int | None | str: """Minutes from a time cell. Returns the original text when it cannot be read.""" if value is None: return None if isinstance(value, (int, float)): return int(round(value)) or None text = clean(value) if text is None: return None total, matched = 0.0, False for num, unit in re.findall(rf"({_NUMBER})\s*(stunden|stunde|std\.?|h|minuten|min\.?|m)?\b", text.lower()): n = float(num.replace(",", ".")) total += n * 60 if unit and unit[0] in "sh" else n matched = True if not matched: return text return int(round(total)) or None @dataclass class Servings: value: float | int | None remark: str | None = None # leftover text such as "8 Stk." that does not fit the number def parse_servings(value) -> Servings: if value is None: return Servings(None) if isinstance(value, (int, float)): return Servings(_round(float(value)) or None) text = clean(value) if text is None: return Servings(None) m = re.match(rf"({_NUMBER})\s*(.*)", text, re.S) if not m: return Servings(None, text) number = _round(float(m.group(1).replace(",", "."))) rest = m.group(2).strip() if rest.lower() in ("", "portion", "portionen", "person", "personen"): return Servings(number) return Servings(number, text) WHOLE_RECIPE_THRESHOLD = 1500 def calories_basis(kcal: float) -> str: """Guessed basis for a bare kcal value (agreed rule: above 1500 means the whole recipe).""" return "wholeRecipe" if kcal > WHOLE_RECIPE_THRESHOLD else "perServing" _URL = re.compile(r"https?://\S+") def extract_urls(text: str) -> tuple[list[str], str | None]: """URLs found in text, and the text that remains without them.""" urls = _URL.findall(text) rest = _URL.sub("", text).strip() return urls, (rest or None) # --- Steps ------------------------------------------------------------------- LONG_STEP_CHARS = 600 _INLINE_HEADING = re.compile(r"^([A-ZÄÖÜ][\wäöüß\-]*(?: [\wäöüß\-]+){0,2}):\s*(\S.*)$", re.S) _ENUMERATION = re.compile(r"^(?:schritt\s*)?\d+\s*[.):]\s+", re.I) def _is_heading(line: str) -> bool: return len(line) <= 40 and not re.search(r"[.!?]$", line) and len(line.split()) <= 4 @dataclass class StepSection: title: str | None steps: list[str] = field(default_factory=list) def split_steps(text: str | None) -> list[StepSection]: """Instruction text -> Step Sections. Blank lines separate Steps; short lines become headings.""" if not text: return [] lines = [line.strip() for line in text.replace("\r\n", "\n").split("\n")] body = "\n".join(lines).strip() if not body: return [] paragraphs = [p.strip() for p in re.split(r"\n{2,}", body) if p.strip()] if len(paragraphs) == 1 and "\n" in paragraphs[0]: paragraphs = [line for line in paragraphs[0].split("\n") if line] sections = [StepSection(None)] def add_step(step: str) -> None: step = _ENUMERATION.sub("", step).strip() if step: sections[-1].steps.append(step) for i, para in enumerate(paragraphs): head, *rest = para.split("\n") is_last = i == len(paragraphs) - 1 if _is_heading(head) and (rest or not is_last): # "Teig\nMehl verkneten…" or a lone "Teig" line before the next paragraph. sections.append(StepSection(head.rstrip(":").strip())) if rest: add_step("\n".join(rest)) continue m = _INLINE_HEADING.match(para) if m: # "Soße:Die vegane Mayonnaise…" sections.append(StepSection(m.group(1))) add_step(m.group(2)) continue add_step(para) return [s for s in sections if s.steps]