"""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)