238 lines
7.7 KiB
Python
238 lines
7.7 KiB
Python
"""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]
|