Files
CookIT/tools/excel-import/cookit_import/parse.py
T
2026-10-28 22:55:09 +01:00

239 lines
7.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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]