Files

238 lines
7.7 KiB
Python
Raw Permalink Normal View History

"""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]