Initial commit: CookIT recipe collection and week planner
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
7d7a0bc60b
173 files changed
+22803
No files matched your search
@@ -0,0 +1,238 @@
|
||||
"""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]
|
||||
Reference in new issue
Block a user