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

383 lines
13 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.
"""Turns one recipe sheet of the workbook template into a Recipe Bundle recipe."""
from __future__ import annotations
import hashlib
import re
import uuid
from dataclasses import dataclass, field
from openpyxl.utils.cell import coordinate_from_string, column_index_from_string
from . import parse
from .workbook import Sheet
SKIPPED_SHEETS = {"Leeres Rezept", "Spinat-Feta-Pizza"} # the template and its example recipe
# Stable IDs: re-running the converter yields the same Recipe IDs, so a re-import skips them.
ID_NAMESPACE = uuid.UUID("6f1d3c3e-2b7a-4c55-9a51-0c6b8f2e9d10")
# Cells the template itself fills or that this module reads explicitly.
TEMPLATE_LABELS = {"A1", "C1", "F1", "G1", "C2", "D2", "E2", "F2", "G2", "B5", "G6", "B8", "G10", "B11"}
B12_BOILERPLATE = "Löschen Sie dieses Rezeptarbeitsblatt nicht."
PLACEHOLDER_PREFIXES = ("Geben Sie", "Kopieren Sie dieses Arbeitsblatt", "Stichwort 1")
NO_COURSE = {"Gang"}
NUTRITION_LABELS = {"fett", "fette", "kohlenhydrate", "eiweiß", "ballaststoffe", "kalorien/ 100g", "kalorien gesamt"}
FIRST_INGREDIENT_ROW = 3
class Reason:
"""Review Flag reasons, shown to the cook in the app."""
STRAY_TEXT = "Zusätzlicher Text aus der Tabelle wurde in die Notizen übernommen."
NO_SERVINGS = "Portionen fehlen."
UNKNOWN_UNIT = "Einheit nicht erkannt und in den Zutatennamen übernommen: {unit}"
UNKNOWN_AMOUNT = "Menge nicht erkannt und als Hinweis übernommen: {amount}"
MERGED_INSTRUCTIONS = "Anleitung war auf mehrere Zellen verteilt und wurde zusammengeführt."
LONG_STEP = "Sehr langer Schritt – eventuell aufteilen."
NAMELESS_LINES = "Zutatenzeilen ohne Zutat wurden verworfen: {values}"
DUPLICATE_LIST = "Doppelte Zutatenliste wurde verworfen."
ALTERNATIVE = "„ODER“-Alternative wurde als eigene Zutatengruppe übernommen."
UNREADABLE_TIME = "Zeitangabe nicht erkannt und in die Notizen übernommen."
def is_recipe_sheet(sheet: Sheet) -> bool:
return (
sheet.title not in SKIPPED_SHEETS
and parse.clean(sheet.cells.get("C2")) == "Menge"
and parse.clean(sheet.cells.get("E2")) == "Zutat"
)
TEMPLATE_SHEET = "Leeres Rezept"
def template_photo_hashes(sheets: list[Sheet]) -> set[str]:
"""The template's stock picture, which was copied onto every sheet made from it."""
return {hashlib.md5(p).hexdigest() for s in sheets if s.title == TEMPLATE_SHEET for p in s.photos}
def real_photos(sheet: Sheet, placeholders: set[str]) -> list[bytes]:
return [p for p in sheet.photos if hashlib.md5(p).hexdigest() not in placeholders]
def recipe_id(sheet_title: str) -> str:
return str(uuid.uuid5(ID_NAMESPACE, sheet_title))
def _rc(coord: str) -> tuple[int, int]:
col, row = coordinate_from_string(coord)
return row, column_index_from_string(col)
@dataclass
class _Builder:
sheet: Sheet
notes: list[str] = field(default_factory=list)
sources: list[str] = field(default_factory=list)
reasons: list[str] = field(default_factory=list)
used: set[str] = field(default_factory=set)
def get(self, coord: str):
self.used.add(coord)
return self.sheet.cells.get(coord)
def text(self, coord: str) -> str | None:
value = parse.clean(self.get(coord))
if value and value.startswith(PLACEHOLDER_PREFIXES):
return None
return value
def flag(self, reason: str) -> None:
if reason not in self.reasons:
self.reasons.append(reason)
def add_note_text(self, text: str, stray: bool) -> None:
urls, rest = parse.extract_urls(text)
for url in urls:
if url not in self.sources:
self.sources.append(url)
if rest:
self.notes.append(rest)
if stray:
self.flag(Reason.STRAY_TEXT)
def build_recipe(sheet: Sheet) -> dict:
b = _Builder(sheet)
name = _name(b.text("C1"), sheet.title)
course = b.text("F1")
if course in NO_COURSE:
course = None
comment_b12 = b.text("B12")
if comment_b12 and comment_b12 != B12_BOILERPLATE:
b.add_note_text(comment_b12, stray=False)
tags = _tags(b.text("B9"))
prep = _minutes(b, "G3", "Vorbereitungszeit")
cook = _minutes(b, "G7", "Kochzeit")
servings = _servings(b)
calories = _calories(b)
groups = _ingredient_groups(b)
sections = _steps(b)
_stray_cells(b)
_stray_comments(b)
return {
"id": recipe_id(sheet.title),
"name": name,
"course": course,
"tags": tags,
"servings": servings,
"prepMinutes": prep,
"cookMinutes": cook,
"calories": calories,
"ingredientGroups": groups,
"stepSections": sections,
"photos": [],
"sources": b.sources,
"notes": "\n\n".join(b.notes) or None,
"favourite": False,
"reviewReasons": b.reasons,
}
def _name(title_cell: str | None, sheet_title: str) -> str:
"""Sheet tabs are cut at 31 characters, title cells are sometimes abbreviated: keep the longer one."""
if not title_cell or title_cell in SKIPPED_SHEETS:
return sheet_title.strip()
return max(title_cell, sheet_title.strip(), key=len)
def _tags(text: str | None) -> list[str]:
tags, seen = [], set()
for tag in (text or "").replace(";", ",").split(","):
tag = tag.strip()
if tag and tag.casefold() not in seen:
seen.add(tag.casefold())
tags.append(tag)
return tags
def _minutes(b: _Builder, coord: str, label: str) -> int | None:
value = parse.parse_minutes(b.get(coord))
if isinstance(value, str):
if not value.startswith(PLACEHOLDER_PREFIXES):
b.notes.append(f"{label}: {value}")
b.flag(Reason.UNREADABLE_TIME)
return None
return value
def _servings(b: _Builder):
raw = b.get("G11")
if parse.clean(raw) and str(raw).startswith(PLACEHOLDER_PREFIXES):
raw = None
servings = parse.parse_servings(raw)
if servings.remark:
b.add_note_text(f"Ergebnis: {servings.remark}", stray=True)
if servings.value is None:
b.flag(Reason.NO_SERVINGS)
return servings.value
def _calories(b: _Builder) -> dict | None:
raw = b.get("B6")
comment = b.sheet.comments.get("B6")
b.used.add("comment:B6")
if not parse.is_number(raw):
text = parse.clean(raw)
if text and not text.startswith(PLACEHOLDER_PREFIXES):
b.add_note_text(f"Kalorien: {text}", stray=True)
return None
kcal = float(str(raw).replace(",", "."))
if kcal <= 0:
return None
if comment and "pro portion" in comment.lower():
basis = "perServing"
else:
# Guessed, but not flagged: the threshold matched every value in the workbook on review.
basis = parse.calories_basis(kcal)
if comment:
b.add_note_text(f"Kalorien: {comment}", stray=True)
return {"kcal": int(round(kcal)), "basis": basis}
# --- Ingredients -------------------------------------------------------------
def _row_cells(b: _Builder, row: int):
return b.sheet.cells.get(f"C{row}"), b.sheet.cells.get(f"D{row}"), b.sheet.cells.get(f"E{row}")
def _ingredient_rows(b: _Builder) -> list[int]:
rows = {
_rc(coord)[0]
for coord in b.sheet.cells
if coord[0] in "CDE" and _rc(coord)[0] >= FIRST_INGREDIENT_ROW and _rc(coord)[1] in (3, 4, 5)
}
return sorted(rows)
def _blocks(rows: list[int]) -> list[list[int]]:
"""Runs of consecutive rows; a blank row in the sheet separates Ingredient Groups."""
blocks: list[list[int]] = []
for row in rows:
if blocks and row == blocks[-1][-1] + 1:
blocks[-1].append(row)
else:
blocks.append([row])
return blocks
def _group_marker(comment: str | None) -> str | None:
"""A cell comment such as "Ab hier: Zutaten für die Sauce" names the group starting at that row."""
if not comment or "zutaten" not in comment.lower():
return None
m = re.search(r"zutaten\s+für\s+(?:die|den|das)?\s*(.+)", comment, re.I | re.S)
name = (m.group(1) if m else comment).strip().rstrip(":").strip()
return name[:1].upper() + name[1:]
def _ingredient_groups(b: _Builder) -> list[dict]:
groups: list[dict] = []
seen_names: set[str] = set()
pending_name: str | None = None
dropped: list[str] = []
for block in _blocks(_ingredient_rows(b)):
for row in block:
b.used.update({f"C{row}", f"D{row}", f"E{row}"})
block_rows = [(row, *_row_cells(b, row)) for row in block]
# Nutrition tables pasted into the ingredient columns belong in the Notes.
if any((parse.clean(d) or "").lower() in NUTRITION_LABELS for _, _, d, _ in block_rows):
lines = [" ".join(str(v).strip() for v in (c, d, e) if parse.clean(v)) for _, c, d, e in block_rows]
b.add_note_text("\n".join(lines), stray=True)
continue
named = [parse.clean(e).casefold() for _, _, _, e in block_rows if parse.clean(e)]
if len(named) >= 3 and sum(n in seen_names for n in named) / len(named) >= 0.8:
b.flag(Reason.DUPLICATE_LIST)
continue
current: dict | None = None
for row, c, d, e in block_rows:
if any((parse.clean(v) or "").upper() == "ODER" for v in (c, e)) and not parse.clean(d):
pending_name = "Alternative"
b.flag(Reason.ALTERNATIVE)
current = None
continue
if not parse.clean(e):
if all(v is None or parse.is_number(v) for v in (c, d)):
continue # leftover numbers from rescaling a recipe
if parse.clean(c) or parse.clean(d):
dropped.append(" ".join(str(v).strip() for v in (c, d) if parse.clean(v)))
continue
marker = None
for col in "CDE":
marker = marker or _group_marker(b.sheet.comments.get(f"{col}{row}"))
if marker:
pending_name = marker
current = None
if current is None:
current = {"name": pending_name, "lines": []}
pending_name = None
groups.append(current)
current["lines"].append(_line(b, row, c, d, e))
seen_names.add(parse.clean(e).casefold())
if dropped:
b.flag(Reason.NAMELESS_LINES.format(values=", ".join(dropped)))
return [g for g in groups if g["lines"]]
def _line(b: _Builder, row: int, c, d, e) -> dict:
name = parse.clean(e)
remarks: list[str] = []
amount_text = parse.clean(c)
if amount_text and amount_text.startswith("(") and name.endswith(")"):
# "(2 | Teelöffel | Oregano)": the whole line is in parentheses, i.e. optional.
c, name = amount_text[1:], name[:-1].strip()
remarks.append("optional")
amount = parse.parse_amount(c)
unit = parse.parse_unit(d)
unit_text = parse.clean(d)
if amount is None:
b.flag(Reason.UNKNOWN_AMOUNT.format(amount=parse.clean(c)))
remarks.append(parse.clean(c))
amount = (None, None)
if unit_text and unit is None:
combined = parse.split_amount_unit(unit_text)
if combined and amount == (None, None):
amount, unit = (combined[0], None), combined[1]
else:
b.flag(Reason.UNKNOWN_UNIT.format(unit=unit_text))
name = f"{unit_text} {name}"
for col in "CDE":
comment = b.sheet.comments.get(f"{col}{row}")
b.used.add(f"comment:{col}{row}")
if comment and not _group_marker(comment):
remarks.append(comment)
return {
"amount": amount[0],
"amountMax": amount[1],
"unit": unit,
"name": name,
"remark": "; ".join(remarks) or None,
}
# --- Steps -------------------------------------------------------------------
def _steps(b: _Builder) -> list[dict]:
instruction_cells = sorted((c for c in b.sheet.cells if c.startswith("F") and c not in ("F1", "F2")), key=_rc)
texts = []
for coord in instruction_cells:
text = b.text(coord)
if text:
texts.append(text)
b.used.update(instruction_cells)
if len(texts) > 1:
b.flag(Reason.MERGED_INSTRUCTIONS)
sections = parse.split_steps("\n\n".join(texts))
if any(len(step) > parse.LONG_STEP_CHARS for s in sections for step in s.steps):
b.flag(Reason.LONG_STEP)
return [{"title": s.title, "steps": s.steps} for s in sections]
# --- Everything else -----------------------------------------------------------
def _stray_cells(b: _Builder) -> None:
rows: dict[int, list[tuple[int, str]]] = {}
for coord, value in b.sheet.cells.items():
if coord in b.used or coord in TEMPLATE_LABELS:
continue
text = parse.clean(value)
if not text or text == B12_BOILERPLATE or text.startswith(PLACEHOLDER_PREFIXES):
continue
row, col = _rc(coord)
rows.setdefault(row, []).append((col, text))
for row in sorted(rows):
line = " ".join(text for _, text in sorted(rows[row]))
b.add_note_text(line, stray=True)
def _stray_comments(b: _Builder) -> None:
for coord, comment in b.sheet.comments.items():
if f"comment:{coord}" in b.used or _group_marker(comment):
continue
b.add_note_text(comment, stray=True)