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,38 @@
|
||||
# Excel import
|
||||
|
||||
A one-time converter from `Wochenspeiseplan.xlsm` to a Recipe Bundle ([format](../../docs/recipe-bundle-format.md)) that you then import into CookIt on the phone. The workbook and the bundle are private data and stay out of git.
|
||||
|
||||
```sh
|
||||
cd tools/excel-import
|
||||
python -m venv .venv
|
||||
.venv/Scripts/pip install -r requirements.txt # Windows; use .venv/bin/pip elsewhere
|
||||
.venv/Scripts/python convert.py ../../Wochenspeiseplan.xlsm
|
||||
# -> out/cookit-rezepte.zip; copy it to the phone and use Einstellungen → Rezepte importieren
|
||||
.venv/Scripts/python -m pytest
|
||||
```
|
||||
|
||||
The tests in `tests/test_workbook.py` run only when the workbook is at the repo root, or wherever `COOKIT_WORKBOOK` points.
|
||||
|
||||
## What it does
|
||||
|
||||
- Reads every sheet built from the recipe template (`C2 = Menge`, `E2 = Zutat`) and skips `Leeres Rezept` and `Spinat-Feta-Pizza`.
|
||||
- **Ingredients:** C/D/E from row 3 down.
|
||||
- A blank row starts a new (unnamed) Ingredient Group.
|
||||
- An `ODER` row starts a group named "Alternative".
|
||||
- Cell comments such as "Zutaten für die Sauce" name the group starting at that row.
|
||||
- Units are mapped to canonical keys, including typos.
|
||||
- "100 g" typed into the unit column is split into amount and unit.
|
||||
- Rows without an ingredient name that hold only numbers (left over from rescaling) are dropped.
|
||||
- A repeated copy of the ingredient list is dropped.
|
||||
- **Steps:** text from the F column.
|
||||
- Blank lines separate Steps.
|
||||
- If there are no blank lines, each line is a Step.
|
||||
- Short lines ("Teig", "Soße:") and inline headings ("Salat:Die Zwiebel…") become Step Sections.
|
||||
- **Calories:** more than 1500 kcal counts as the whole recipe, otherwise per serving. A "Pro Portion" comment on the cell settles it. The guess isn't flagged: on review it matched every value in the workbook.
|
||||
- **Notes & Sources:**
|
||||
- The comment field (B12) and any cell outside the template go into Notes, one line per sheet row.
|
||||
- URLs anywhere become Sources.
|
||||
- The template text "Löschen Sie dieses Rezeptarbeitsblatt nicht." is dropped.
|
||||
- **Photos:** pictures on the sheet, top-left first, re-encoded as JPEG, at most 1600 px. The template's stock picture (copied onto every sheet made from `Leeres Rezept`) is dropped, so those Recipes simply have no Photo.
|
||||
- **Review Flags:** anything the converter guessed or moved adds a German reason to `reviewReasons`, shown in the app under "zu prüfen".
|
||||
- **IDs:** Recipe IDs come from the sheet name (UUIDv5), so converting again gives the same IDs and a second import skips Recipes already there.
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Convert the Wochenspeiseplan workbook into a CookIt Recipe Bundle.
|
||||
|
||||
Usage: python convert.py <Wochenspeiseplan.xlsm> [-o out/cookit-rezepte.zip]
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import sys
|
||||
from collections import Counter
|
||||
|
||||
from cookit_import.bundle import write_bundle
|
||||
from cookit_import.recipe import build_recipe, is_recipe_sheet, real_photos, template_photo_hashes
|
||||
from cookit_import.workbook import read_sheets
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
|
||||
parser.add_argument("workbook")
|
||||
parser.add_argument("-o", "--output", default=os.path.join("out", "cookit-rezepte.zip"))
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
sys.stdout.reconfigure(encoding="utf-8")
|
||||
print(f"Lese {args.workbook} …")
|
||||
all_sheets = read_sheets(args.workbook)
|
||||
placeholders = template_photo_hashes(all_sheets)
|
||||
sheets = [s for s in all_sheets if is_recipe_sheet(s)]
|
||||
recipes = [(build_recipe(s), real_photos(s, placeholders)) for s in sheets]
|
||||
|
||||
os.makedirs(os.path.dirname(args.output) or ".", exist_ok=True)
|
||||
write_bundle(args.output, recipes)
|
||||
|
||||
flagged = [r for r, _ in recipes if r["reviewReasons"]]
|
||||
reasons = Counter(reason.split(":")[0] for r in flagged for reason in r["reviewReasons"])
|
||||
print(f"{len(recipes)} Rezepte, {sum(len(r['photos']) for r, _ in recipes)} Fotos")
|
||||
print(f"{len(flagged)} Rezepte mit Prüfhinweis:")
|
||||
for reason, count in reasons.most_common():
|
||||
print(f" {count:4d} {reason}")
|
||||
print(f"Geschrieben: {args.output} ({os.path.getsize(args.output) / 1_000_000:.1f} MB)")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1 @@
|
||||
"""One-time converter from the Wochenspeiseplan workbook to a CookIt Recipe Bundle."""
|
||||
@@ -0,0 +1,58 @@
|
||||
"""Writes a Recipe Bundle: a zip with bundle.json and downscaled photos (see docs/recipe-bundle-format.md)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import zipfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from PIL import Image, ImageOps, UnidentifiedImageError
|
||||
|
||||
FORMAT = "cookit.recipe-bundle"
|
||||
VERSION = 1
|
||||
MAX_PHOTO_EDGE = 1600
|
||||
JPEG_QUALITY = 82
|
||||
|
||||
|
||||
def downscale(data: bytes) -> bytes | None:
|
||||
"""JPEG no larger than MAX_PHOTO_EDGE on its long side, or None if the picture can't be read."""
|
||||
try:
|
||||
img = Image.open(io.BytesIO(data))
|
||||
img = ImageOps.exif_transpose(img)
|
||||
except (UnidentifiedImageError, OSError, ValueError):
|
||||
return None
|
||||
if img.mode in ("RGBA", "LA", "P"):
|
||||
img = img.convert("RGBA")
|
||||
background = Image.new("RGB", img.size, "white")
|
||||
background.paste(img, mask=img.getchannel("A"))
|
||||
img = background
|
||||
elif img.mode != "RGB":
|
||||
img = img.convert("RGB")
|
||||
img.thumbnail((MAX_PHOTO_EDGE, MAX_PHOTO_EDGE), Image.Resampling.LANCZOS)
|
||||
out = io.BytesIO()
|
||||
img.save(out, "JPEG", quality=JPEG_QUALITY, optimize=True)
|
||||
return out.getvalue()
|
||||
|
||||
|
||||
def write_bundle(path: str, recipes: list[tuple[dict, list[bytes]]]) -> None:
|
||||
"""recipes: (recipe dict, raw photo bytes in display order)."""
|
||||
with zipfile.ZipFile(path, "w", zipfile.ZIP_DEFLATED) as z:
|
||||
entries = []
|
||||
for recipe, photos in recipes:
|
||||
recipe["photos"] = []
|
||||
for raw in photos:
|
||||
jpeg = downscale(raw)
|
||||
if jpeg is None:
|
||||
continue
|
||||
name = f"photos/{recipe['id']}-{len(recipe['photos']) + 1}.jpg"
|
||||
z.writestr(zipfile.ZipInfo(name, date_time=(2026, 1, 1, 0, 0, 0)), jpeg, zipfile.ZIP_STORED)
|
||||
recipe["photos"].append(name)
|
||||
entries.append(recipe)
|
||||
manifest = {
|
||||
"format": FORMAT,
|
||||
"version": VERSION,
|
||||
"createdAt": datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z"),
|
||||
"recipes": entries,
|
||||
}
|
||||
z.writestr("bundle.json", json.dumps(manifest, ensure_ascii=False, indent=2))
|
||||
@@ -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]
|
||||
@@ -0,0 +1,382 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,112 @@
|
||||
"""Reads what the converter needs from the .xlsm: cell values, cell comments and embedded photos."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import posixpath
|
||||
import re
|
||||
import zipfile
|
||||
import xml.etree.ElementTree as ET
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
import openpyxl
|
||||
|
||||
NS = {
|
||||
"main": "http://schemas.openxmlformats.org/spreadsheetml/2006/main",
|
||||
"rel": "http://schemas.openxmlformats.org/package/2006/relationships",
|
||||
"r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships",
|
||||
"xdr": "http://schemas.openxmlformats.org/drawingml/2006/spreadsheetDrawing",
|
||||
"a": "http://schemas.openxmlformats.org/drawingml/2006/main",
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class Sheet:
|
||||
title: str
|
||||
cells: dict[str, object] # "C3" -> raw value (cached formula results)
|
||||
comments: dict[str, str] = field(default_factory=dict) # "C3" -> comment text without author
|
||||
photos: list[bytes] = field(default_factory=list) # embedded pictures, top-left first
|
||||
|
||||
|
||||
def read_sheets(path: str) -> list[Sheet]:
|
||||
wb = openpyxl.load_workbook(path, read_only=True, data_only=True)
|
||||
sheets = []
|
||||
for ws in wb.worksheets:
|
||||
cells = {}
|
||||
for row in ws.iter_rows():
|
||||
for cell in row:
|
||||
if cell.value is not None and str(cell.value).strip() != "":
|
||||
cells[cell.coordinate] = cell.value
|
||||
sheets.append(Sheet(ws.title, cells))
|
||||
wb.close()
|
||||
|
||||
with zipfile.ZipFile(path) as z:
|
||||
parts = _sheet_parts(z)
|
||||
for sheet in sheets:
|
||||
part = parts.get(sheet.title)
|
||||
if part:
|
||||
sheet.comments = _comments(z, part)
|
||||
sheet.photos = _photos(z, part)
|
||||
return sheets
|
||||
|
||||
|
||||
def _rels(z: zipfile.ZipFile, part: str) -> dict[str, str]:
|
||||
"""Relationship id -> absolute part path for a part's .rels file."""
|
||||
rels_path = posixpath.join(posixpath.dirname(part), "_rels", posixpath.basename(part) + ".rels")
|
||||
if rels_path not in z.namelist():
|
||||
return {}
|
||||
root = ET.fromstring(z.read(rels_path))
|
||||
out = {}
|
||||
for rel in root.findall("rel:Relationship", NS):
|
||||
target = rel.get("Target")
|
||||
if rel.get("TargetMode") == "External":
|
||||
continue
|
||||
out[rel.get("Id")] = (
|
||||
target.lstrip("/") if target.startswith("/") else posixpath.normpath(posixpath.join(posixpath.dirname(part), target))
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _sheet_parts(z: zipfile.ZipFile) -> dict[str, str]:
|
||||
root = ET.fromstring(z.read("xl/workbook.xml"))
|
||||
rels = _rels(z, "xl/workbook.xml")
|
||||
return {
|
||||
s.get("name"): rels[s.get(f"{{{NS['r']}}}id")]
|
||||
for s in root.find("main:sheets", NS)
|
||||
if s.get(f"{{{NS['r']}}}id") in rels
|
||||
}
|
||||
|
||||
|
||||
def _comments(z: zipfile.ZipFile, sheet_part: str) -> dict[str, str]:
|
||||
out = {}
|
||||
for target in _rels(z, sheet_part).values():
|
||||
if not re.search(r"comments\d*\.xml$", target):
|
||||
continue
|
||||
root = ET.fromstring(z.read(target))
|
||||
authors = [a.text or "" for a in root.find("main:authors", NS)]
|
||||
for c in root.find("main:commentList", NS):
|
||||
text = "".join(t.text or "" for t in c.iter(f"{{{NS['main']}}}t")).strip()
|
||||
for author in authors:
|
||||
if author and text.startswith(author + ":"):
|
||||
text = text[len(author) + 1 :].strip()
|
||||
if text:
|
||||
out[c.get("ref")] = text
|
||||
return out
|
||||
|
||||
|
||||
def _photos(z: zipfile.ZipFile, sheet_part: str) -> list[bytes]:
|
||||
found = []
|
||||
for target in _rels(z, sheet_part).values():
|
||||
if not re.search(r"drawings/drawing\d+\.xml$", target):
|
||||
continue
|
||||
media = _rels(z, target)
|
||||
root = ET.fromstring(z.read(target))
|
||||
for anchor in root:
|
||||
frm = anchor.find("xdr:from", NS)
|
||||
row = int(frm.find("xdr:row", NS).text) if frm is not None else 0
|
||||
col = int(frm.find("xdr:col", NS).text) if frm is not None else 0
|
||||
for blip in anchor.iter(f"{{{NS['a']}}}blip"):
|
||||
embed = blip.get(f"{{{NS['r']}}}embed")
|
||||
if embed in media:
|
||||
found.append((row, col, z.read(media[embed])))
|
||||
found.sort(key=lambda f: (f[0], f[1]))
|
||||
return [data for _, _, data in found]
|
||||
@@ -0,0 +1,3 @@
|
||||
[pytest]
|
||||
pythonpath = .
|
||||
testpaths = tests
|
||||
@@ -0,0 +1,3 @@
|
||||
openpyxl==3.1.5
|
||||
pillow==13.0.0
|
||||
pytest==9.1.1
|
||||
@@ -0,0 +1,135 @@
|
||||
import pytest
|
||||
|
||||
from cookit_import import parse
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, expected",
|
||||
[
|
||||
(275, (275, None)),
|
||||
(1.5, (1.5, None)),
|
||||
(0.08333333333333333, (0.083, None)),
|
||||
("0,5", (0.5, None)),
|
||||
("½", (0.5, None)),
|
||||
("1/2", (0.5, None)),
|
||||
("1 1/2", (1.5, None)),
|
||||
("3/4", (0.75, None)),
|
||||
("2-3", (2, 3)),
|
||||
("10 - 12", (10, 12)),
|
||||
("200-250", (200, 250)),
|
||||
(None, (None, None)),
|
||||
(0, (None, None)),
|
||||
],
|
||||
)
|
||||
def test_parse_amount(value, expected):
|
||||
assert parse.parse_amount(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["opt. als Topping", "(2", "ODER"])
|
||||
def test_parse_amount_rejects_text(value):
|
||||
assert parse.parse_amount(value) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, key",
|
||||
[
|
||||
("Gramm", "gram"),
|
||||
("Mililiter", "milliliter"),
|
||||
("milliliter", "milliliter"),
|
||||
("Zehe/n", "clove"),
|
||||
("Zehen", "clove"),
|
||||
("Kleine", "small"),
|
||||
("kleine", "small"),
|
||||
("Große", "large"),
|
||||
("Zweig (e)", "sprig"),
|
||||
("Msp.", "knifeTip"),
|
||||
("Etwas", "some"),
|
||||
("Tassen", "cup"),
|
||||
],
|
||||
)
|
||||
def test_parse_unit(value, key):
|
||||
assert parse.parse_unit(value) == key
|
||||
|
||||
|
||||
def test_parse_unit_unknown():
|
||||
assert parse.parse_unit("Fett") is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, expected",
|
||||
[("100 g", (100, "gram")), ("125 ml", (125, "milliliter")), ("1 TL", (1, "teaspoon")), ("1 Pkt.", (1, "pack")), ("3", (3, None))],
|
||||
)
|
||||
def test_split_amount_unit(value, expected):
|
||||
assert parse.split_amount_unit(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, expected",
|
||||
[(30, 30), ("45 Minuten", 45), ("2 Stunden", 120), ("1h 30min", 90), ("0 Minuten", None), (None, None), ("über Nacht", "über Nacht")],
|
||||
)
|
||||
def test_parse_minutes(value, expected):
|
||||
assert parse.parse_minutes(value) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value, number, remark",
|
||||
[
|
||||
(4, 4, None),
|
||||
(3.5, 3.5, None),
|
||||
("6 Portionen", 6, None),
|
||||
("4 Personen", 4, None),
|
||||
("8 Stk.", 8, "8 Stk."),
|
||||
("2 Rollen;\neine Rolle entspricht 8 Scheiben", 2, "2 Rollen;\neine Rolle entspricht 8 Scheiben"),
|
||||
(None, None, None),
|
||||
],
|
||||
)
|
||||
def test_parse_servings(value, number, remark):
|
||||
s = parse.parse_servings(value)
|
||||
assert (s.value, s.remark) == (number, remark)
|
||||
|
||||
|
||||
def test_calories_basis_threshold():
|
||||
assert parse.calories_basis(1500) == "perServing"
|
||||
assert parse.calories_basis(1501) == "wholeRecipe"
|
||||
|
||||
|
||||
def test_extract_urls():
|
||||
urls, rest = parse.extract_urls("Quelle: https://sallys-blog.de/rezepte/focaccia")
|
||||
assert urls == ["https://sallys-blog.de/rezepte/focaccia"]
|
||||
assert rest == "Quelle:"
|
||||
|
||||
|
||||
class TestSplitSteps:
|
||||
def test_blank_lines_separate_steps(self):
|
||||
sections = parse.split_steps("\nZutaten mischen.\n\nTeig gehen lassen.\n\n\nBacken.")
|
||||
assert [(s.title, s.steps) for s in sections] == [(None, ["Zutaten mischen.", "Teig gehen lassen.", "Backen."])]
|
||||
|
||||
def test_heading_line_starts_a_section(self):
|
||||
sections = parse.split_steps("Vorteig\nVerrühre die Zutaten.\n\nTeig\nFüge das Mehl hinzu.")
|
||||
assert [(s.title, s.steps) for s in sections] == [
|
||||
("Vorteig", ["Verrühre die Zutaten."]),
|
||||
("Teig", ["Füge das Mehl hinzu."]),
|
||||
]
|
||||
|
||||
def test_lone_heading_paragraph(self):
|
||||
sections = parse.split_steps("Soße:\n\nAlles verrühren.\n\nAbschmecken.")
|
||||
assert [(s.title, s.steps) for s in sections] == [("Soße", ["Alles verrühren.", "Abschmecken."])]
|
||||
|
||||
def test_inline_heading(self):
|
||||
sections = parse.split_steps("Salat:Die Zwiebel schneiden.\n\nSoße:Mayo verrühren.")
|
||||
assert [(s.title, s.steps) for s in sections] == [("Salat", ["Die Zwiebel schneiden."]), ("Soße", ["Mayo verrühren."])]
|
||||
|
||||
def test_single_paragraph_splits_on_lines(self):
|
||||
sections = parse.split_steps(" Mehl und Salz mischen.\n Wasser zugeben und kneten.")
|
||||
assert sections[0].steps == ["Mehl und Salz mischen.", "Wasser zugeben und kneten."]
|
||||
|
||||
def test_short_last_line_stays_a_step(self):
|
||||
sections = parse.split_steps("Alles anbraten.\n\nServieren")
|
||||
assert sections[0].steps == ["Alles anbraten.", "Servieren"]
|
||||
|
||||
def test_strips_enumeration(self):
|
||||
sections = parse.split_steps("1. Wasser kochen.\n\n2) Nudeln garen.")
|
||||
assert sections[0].steps == ["Wasser kochen.", "Nudeln garen."]
|
||||
|
||||
def test_empty(self):
|
||||
assert parse.split_steps("\n\n ") == []
|
||||
@@ -0,0 +1,178 @@
|
||||
from cookit_import.recipe import Reason, build_recipe, is_recipe_sheet, recipe_id
|
||||
from cookit_import.workbook import Sheet
|
||||
|
||||
TEMPLATE = {
|
||||
"C1": "Testrezept",
|
||||
"F1": "Hauptgericht",
|
||||
"G1": "Rezeptindex",
|
||||
"C2": "Menge",
|
||||
"D2": "Maßeinheit",
|
||||
"E2": "Zutat",
|
||||
"F2": "Anleitungen",
|
||||
"G2": "Vorbereitungszeit",
|
||||
"B5": "Kalorien",
|
||||
"G6": "Kochzeit",
|
||||
"B8": "Rezept-Stichwörter",
|
||||
"G10": "Ergebnis (Portionen)",
|
||||
"B11": "Kommentare",
|
||||
"B12": "Löschen Sie dieses Rezeptarbeitsblatt nicht.",
|
||||
}
|
||||
|
||||
|
||||
def sheet(title="Testrezept", comments=None, **cells) -> Sheet:
|
||||
return Sheet(title, {**TEMPLATE, **cells}, comments or {})
|
||||
|
||||
|
||||
def lines(recipe):
|
||||
return [[(l["amount"], l["unit"], l["name"]) for l in g["lines"]] for g in recipe["ingredientGroups"]]
|
||||
|
||||
|
||||
def test_recognises_recipe_sheets_and_skips_template_ones():
|
||||
assert is_recipe_sheet(sheet())
|
||||
assert not is_recipe_sheet(sheet(title="Leeres Rezept"))
|
||||
assert not is_recipe_sheet(sheet(title="Spinat-Feta-Pizza"))
|
||||
assert not is_recipe_sheet(Sheet("Speiseplan", {"C2": "Montag"}))
|
||||
|
||||
|
||||
def test_basic_fields():
|
||||
r = build_recipe(sheet(G3=20, G7="15 Minuten", G11=4, B6=775, B9="Tomaten, Nudeln, tomaten, Käse", F3="Kochen.\n\nEssen."))
|
||||
assert r["id"] == recipe_id("Testrezept")
|
||||
assert r["name"] == "Testrezept"
|
||||
assert r["course"] == "Hauptgericht"
|
||||
assert r["tags"] == ["Tomaten", "Nudeln", "Käse"]
|
||||
assert (r["prepMinutes"], r["cookMinutes"], r["servings"]) == (20, 15, 4)
|
||||
assert r["calories"] == {"kcal": 775, "basis": "perServing"}
|
||||
assert r["stepSections"] == [{"title": None, "steps": ["Kochen.", "Essen."]}]
|
||||
assert r["notes"] is None
|
||||
assert r["reviewReasons"] == []
|
||||
|
||||
|
||||
def test_ids_are_stable_per_sheet():
|
||||
assert recipe_id("Focaccia") == recipe_id("Focaccia") != recipe_id("Brot")
|
||||
|
||||
|
||||
def test_placeholder_course_and_missing_servings():
|
||||
r = build_recipe(sheet(F1="Gang"))
|
||||
assert r["course"] is None
|
||||
assert Reason.NO_SERVINGS in r["reviewReasons"]
|
||||
|
||||
|
||||
def test_longer_name_wins_and_template_title_is_ignored():
|
||||
assert build_recipe(sheet(title="Flammkuchen, low carb", C1="Flammkuchen"))["name"] == "Flammkuchen, low carb"
|
||||
assert build_recipe(sheet(title="Kohlrabi-Kartoffel-Auflauf", C1="Leeres Rezept"))["name"] == "Kohlrabi-Kartoffel-Auflauf"
|
||||
|
||||
|
||||
def test_blank_rows_separate_ingredient_groups_and_units_are_canonical():
|
||||
r = build_recipe(sheet(G11=1, C3=300, D3="Gramm", E3="Wasser", C4=1, D4="Teelöffel", E4="Zucker", E6="Rosmarin"))
|
||||
assert lines(r) == [[(300, "gram", "Wasser"), (1, "teaspoon", "Zucker")], [(None, None, "Rosmarin")]]
|
||||
assert [g["name"] for g in r["ingredientGroups"]] == [None, None]
|
||||
|
||||
|
||||
def test_rescaling_helper_numbers_are_dropped_silently():
|
||||
r = build_recipe(sheet(G11=4, C3=350, D3="Gramm", E3="Rigatoni", C33=350, D33=87.5, C34=1, D34=0.25, C10=125))
|
||||
assert lines(r) == [[(350, "gram", "Rigatoni")]]
|
||||
assert r["reviewReasons"] == []
|
||||
|
||||
|
||||
def test_oder_row_starts_alternative_group():
|
||||
r = build_recipe(sheet(G11=8, C3=375, D3="Gramm", E3="Mehl", C5="ODER", C7=295, D7="Gramm", E7="Mehl", C8=80, D8="Gramm", E8="Proteinpulver"))
|
||||
assert [g["name"] for g in r["ingredientGroups"]] == [None, "Alternative"]
|
||||
assert Reason.ALTERNATIVE in r["reviewReasons"]
|
||||
|
||||
|
||||
def test_duplicate_ingredient_list_is_dropped():
|
||||
first = dict(C3=100, D3="Gramm", E3="Mehl", C4=3, E4="Eier", C5=125, D5="Milliliter", E5="Milch")
|
||||
copy = dict(D20="100 g", E20="Mehl", D21="3", E21="Eier", D22="125 ml", E22="Milch")
|
||||
r = build_recipe(sheet(G11=2, **first, **copy))
|
||||
assert len(r["ingredientGroups"]) == 1
|
||||
assert Reason.DUPLICATE_LIST in r["reviewReasons"]
|
||||
|
||||
|
||||
def test_amount_typed_into_unit_column():
|
||||
r = build_recipe(sheet(G11=2, D3="100 g", E3="Mehl", D4="1 TL", E4="Zucker"))
|
||||
assert lines(r) == [[(100, "gram", "Mehl"), (1, "teaspoon", "Zucker")]]
|
||||
|
||||
|
||||
def test_ranges_fractions_and_unknown_amounts():
|
||||
r = build_recipe(sheet(G11=2, C3="2-3", D3="Esslöffel", E3="Kakao", C4="½", D4="Bund", E4="Schnittlauch", C5="opt. als Topping", E5="Sesam"))
|
||||
first, second, third = r["ingredientGroups"][0]["lines"]
|
||||
assert (first["amount"], first["amountMax"]) == (2, 3)
|
||||
assert second["amount"] == 0.5
|
||||
assert (third["amount"], third["remark"]) == (None, "opt. als Topping")
|
||||
assert Reason.UNKNOWN_AMOUNT.format(amount="opt. als Topping") in r["reviewReasons"]
|
||||
|
||||
|
||||
def test_parenthesised_line_is_optional():
|
||||
r = build_recipe(sheet(G11=4, C3="(2", D3="Teelöffel", E3="Oregano)"))
|
||||
line = r["ingredientGroups"][0]["lines"][0]
|
||||
assert (line["amount"], line["unit"], line["name"], line["remark"]) == (2, "teaspoon", "Oregano", "optional")
|
||||
|
||||
|
||||
def test_unknown_unit_moves_into_name():
|
||||
r = build_recipe(sheet(G11=2, C3=2, D3="Würfel", E3="Brühe"))
|
||||
assert lines(r) == [[(2, None, "Würfel Brühe")]]
|
||||
assert Reason.UNKNOWN_UNIT.format(unit="Würfel") in r["reviewReasons"]
|
||||
|
||||
|
||||
def test_comment_group_markers_name_groups_and_other_comments_become_remarks():
|
||||
comments = {"C3": "Zutaten für die Rouladen", "C5": "Ab hier: \nZutaten für die Sauce", "E4": "kleiner Espresso-Löffel"}
|
||||
r = build_recipe(sheet(G11=6, comments=comments, C3=6, E3="Rinderrouladen", C4=1, D4="Teelöffel", E4="Senf", C5=50, D5="Gramm", E5="Butter"))
|
||||
assert [g["name"] for g in r["ingredientGroups"]] == ["Rouladen", "Sauce"]
|
||||
assert r["ingredientGroups"][0]["lines"][1]["remark"] == "kleiner Espresso-Löffel"
|
||||
|
||||
|
||||
def test_pro_portion_comment_settles_calories_basis():
|
||||
r = build_recipe(sheet(G11=4, B6=662, comments={"B6": "Pro Portion"}))
|
||||
assert r["calories"] == {"kcal": 662, "basis": "perServing"}
|
||||
assert r["reviewReasons"] == []
|
||||
|
||||
|
||||
def test_high_calories_mean_whole_recipe():
|
||||
r = build_recipe(sheet(G11=1, B6=3831))
|
||||
assert r["calories"]["basis"] == "wholeRecipe"
|
||||
|
||||
|
||||
def test_comment_b12_and_stray_cells_go_to_notes_and_urls_to_sources():
|
||||
r = build_recipe(
|
||||
sheet(
|
||||
G11=1,
|
||||
B12="Springform 26 cm",
|
||||
I12="ca.",
|
||||
J12="15",
|
||||
K12="Dampfnudeln",
|
||||
B17="https://sallys-blog.de/rezepte/focaccia",
|
||||
)
|
||||
)
|
||||
assert r["notes"] == "Springform 26 cm\n\nca. 15 Dampfnudeln"
|
||||
assert r["sources"] == ["https://sallys-blog.de/rezepte/focaccia"]
|
||||
assert Reason.STRAY_TEXT in r["reviewReasons"]
|
||||
|
||||
|
||||
def test_template_comment_is_not_a_note():
|
||||
r = build_recipe(sheet(G11=1))
|
||||
assert r["notes"] is None
|
||||
assert r["reviewReasons"] == []
|
||||
|
||||
|
||||
def test_nutrition_rows_in_ingredient_columns_become_notes():
|
||||
r = build_recipe(sheet(G11=1, C3=3, E3="Eier", D18="Kalorien/ 100g", E18="263.47", D19="Fett", E19="17.92"))
|
||||
assert lines(r) == [[(3, None, "Eier")]]
|
||||
assert r["notes"] == "Kalorien/ 100g 263.47\nFett 17.92"
|
||||
|
||||
|
||||
def test_instructions_split_over_cells_are_merged_and_flagged():
|
||||
r = build_recipe(sheet(G11=6, F3="Teig rühren.", F19="Spätzle abschrecken."))
|
||||
assert r["stepSections"][0]["steps"] == ["Teig rühren.", "Spätzle abschrecken."]
|
||||
assert Reason.MERGED_INSTRUCTIONS in r["reviewReasons"]
|
||||
|
||||
|
||||
def test_long_step_is_flagged():
|
||||
r = build_recipe(sheet(G11=1, F3="Rühren. " * 100))
|
||||
assert Reason.LONG_STEP in r["reviewReasons"]
|
||||
|
||||
|
||||
def test_unreadable_time_goes_to_notes():
|
||||
r = build_recipe(sheet(G11=1, G3="über Nacht"))
|
||||
assert r["prepMinutes"] is None
|
||||
assert r["notes"] == "Vorbereitungszeit: über Nacht"
|
||||
assert Reason.UNREADABLE_TIME in r["reviewReasons"]
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Checks against the real (private, untracked) workbook. Skipped when it isn't there."""
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import zipfile
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from cookit_import.bundle import MAX_PHOTO_EDGE, write_bundle
|
||||
from cookit_import.recipe import build_recipe, is_recipe_sheet, real_photos, template_photo_hashes
|
||||
from cookit_import.workbook import read_sheets
|
||||
|
||||
WORKBOOK = os.environ.get("COOKIT_WORKBOOK", os.path.join(os.path.dirname(__file__), "..", "..", "..", "Wochenspeiseplan.xlsm"))
|
||||
|
||||
pytestmark = pytest.mark.skipif(not os.path.exists(WORKBOOK), reason="Wochenspeiseplan.xlsm not available")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def recipes():
|
||||
all_sheets = read_sheets(WORKBOOK)
|
||||
placeholders = template_photo_hashes(all_sheets)
|
||||
return {s.title: (build_recipe(s), real_photos(s, placeholders)) for s in all_sheets if is_recipe_sheet(s)}
|
||||
|
||||
|
||||
def test_all_recipe_sheets_found(recipes):
|
||||
assert len(recipes) == 214
|
||||
assert "Leeres Rezept" not in recipes and "Spinat-Feta-Pizza" not in recipes
|
||||
|
||||
|
||||
def test_focaccia(recipes):
|
||||
r, photos = recipes["Focaccia"]
|
||||
assert [s["title"] for s in r["stepSections"]][:2] == ["Vorteig", "Teig"]
|
||||
assert len(r["ingredientGroups"]) == 3
|
||||
assert len(r["sources"]) == 2
|
||||
assert r["notes"] == "22x22 cm eckige Form"
|
||||
assert photos == [] # only the template's stock picture was on the sheet
|
||||
|
||||
|
||||
def test_template_stock_photo_is_dropped_but_real_photos_kept(recipes):
|
||||
without = [title for title, (_, photos) in recipes.items() if not photos]
|
||||
assert 25 <= len(without) <= 35
|
||||
assert len(recipes["Melonen-Minze-Shake"][1]) == 3
|
||||
|
||||
|
||||
def test_rinderrouladen_groups_from_comments(recipes):
|
||||
r, _ = recipes["Klassische Rinderrouladen"]
|
||||
assert [g["name"] for g in r["ingredientGroups"]] == ["Rouladen", "Sauce", "Zusätzliche Zutaten"]
|
||||
|
||||
|
||||
def test_bundle_round_trip(recipes, tmp_path):
|
||||
subset = [recipes[t] for t in ("Focaccia", "Melonen-Minze-Shake", "Hähnchen")]
|
||||
out = tmp_path / "bundle.zip"
|
||||
write_bundle(str(out), [(dict(r), photos) for r, photos in subset])
|
||||
with zipfile.ZipFile(out) as z:
|
||||
manifest = json.loads(z.read("bundle.json"))
|
||||
assert manifest["format"] == "cookit.recipe-bundle" and manifest["version"] == 1
|
||||
shake = next(r for r in manifest["recipes"] if r["name"] == "Melonen-Minze-Shake")
|
||||
assert len(shake["photos"]) == 3
|
||||
for name in shake["photos"]:
|
||||
img = Image.open(io.BytesIO(z.read(name)))
|
||||
assert img.format == "JPEG" and max(img.size) <= MAX_PHOTO_EDGE
|
||||
Reference in new issue
Block a user