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