From 45b3d62365fb76ded68fd20164bd3042ed8c6b5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:44:43 +0000 Subject: [PATCH 1/2] Add recipe standardization agent with sample recipes and HTML output Agent-Logs-Url: https://github.com/GeraldRamich/AgentBuilding/sessions/97f0fc4b-1688-46c9-85ec-ce679d3c8877 Co-authored-by: GeraldRamich <53840101+GeraldRamich@users.noreply.github.com> --- agent/README.md | 150 ++++ .../__pycache__/recipe_agent.cpython-312.pyc | Bin 0 -> 32008 bytes agent/recipe_agent.py | 748 ++++++++++++++++++ recipes/input/banana_bread.txt | 28 + recipes/input/caesar_salad.csv | 26 + recipes/input/chicken_tikka_masala.md | 43 + recipes/input/chocolate_chip_cookies.json | 29 + recipes/input/spaghetti_carbonara.txt | 25 + recipes/output/recipes.html | 408 ++++++++++ 9 files changed, 1457 insertions(+) create mode 100644 agent/README.md create mode 100644 agent/__pycache__/recipe_agent.cpython-312.pyc create mode 100644 agent/recipe_agent.py create mode 100644 recipes/input/banana_bread.txt create mode 100644 recipes/input/caesar_salad.csv create mode 100644 recipes/input/chicken_tikka_masala.md create mode 100644 recipes/input/chocolate_chip_cookies.json create mode 100644 recipes/input/spaghetti_carbonara.txt create mode 100644 recipes/output/recipes.html diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 0000000..fd9f893 --- /dev/null +++ b/agent/README.md @@ -0,0 +1,150 @@ +# Recipe Standardization Agent + +An agent that reads recipe files in **multiple formats**, normalises them into a standard schema, and produces a single **iPad-friendly HTML document** you can open directly in Safari. + +--- + +## Features + +| Capability | Detail | +|---|---| +| **Supported input formats** | `.txt` (plain text), `.json`, `.md` / `.markdown`, `.csv` | +| **Parsed fields** | Title, servings, prep time, cook time, ingredients (amount / unit / item), numbered instructions, notes | +| **Output** | Single self-contained HTML file – no internet connection required on device | +| **iPad optimised** | Responsive layout, system fonts, print-safe, table of contents | + +--- + +## Quick Start + +```bash +# Parse all recipes in recipes/input/ and write recipes/output/recipes.html +python3 agent/recipe_agent.py + +# Custom paths +python3 agent/recipe_agent.py --input /path/to/my/recipes --output /path/to/output.html +``` + +Open `recipes/output/recipes.html` in **Safari on your iPad** (or any browser). + +--- + +## Project Layout + +``` +AgentBuilding/ +├── agent/ +│ └── recipe_agent.py # The agent (pure Python, no dependencies) +├── recipes/ +│ ├── input/ # Drop your recipe files here +│ │ ├── banana_bread.txt +│ │ ├── caesar_salad.csv +│ │ ├── chicken_tikka_masala.md +│ │ ├── chocolate_chip_cookies.json +│ │ └── spaghetti_carbonara.txt +│ └── output/ +│ └── recipes.html # Generated output (open on iPad) +└── README.md +``` + +--- + +## Input Format Examples + +### Plain Text (`.txt`) + +``` +My Recipe Title + +Servings: 4 +Prep Time: 15 minutes +Cook Time: 30 minutes + +Ingredients: +- 2 cups flour +- 1 tsp salt + +Instructions: +1. Mix the ingredients. +2. Bake at 350°F for 30 minutes. + +Notes: +Don't over-mix the batter. +``` + +Section headings are flexible — `INGREDIENTS:`, `What you need:`, `You will need:` are all recognised. Instruction headings like `INSTRUCTIONS:`, `Directions:`, `How to make it:` also work. + +### JSON (`.json`) + +```json +{ + "name": "My Recipe", + "servings": "4", + "prepTime": "10 min", + "cookTime": "25 min", + "ingredients": [ + { "qty": "2", "unit": "cups", "item": "flour" } + ], + "steps": [ + "Mix the ingredients.", + "Bake for 25 minutes." + ], + "notes": "Optional tip here." +} +``` + +### Markdown (`.md`) + +```markdown +# My Recipe + +**Servings:** 4 +**Prep Time:** 10 minutes +**Cook Time:** 25 minutes + +## Ingredients +- 2 cups flour +- 1 tsp salt + +## Instructions +1. Mix the ingredients. +2. Bake for 25 minutes. + +## Notes +> Optional tip here. +``` + +### CSV (`.csv`) + +```csv +Recipe Name,My Recipe +Servings,4 +Prep Time,10 minutes +Cook Time,25 minutes +Notes,Optional tip here. + +Section,Amount,Unit,Ingredient +Main,2,cups,flour +Main,1,tsp,salt + +Step,Instruction +1,Mix the ingredients. +2,Bake for 25 minutes. +``` + +--- + +## Requirements + +- Python 3.7 or later +- No third-party libraries required + +--- + +## Adding Your Own Recipes + +1. Place your recipe file(s) in `recipes/input/` +2. Run `python3 agent/recipe_agent.py` +3. Open `recipes/output/recipes.html` on your iPad + +The agent processes all supported files in the input directory alphabetically and combines them into one document. diff --git a/agent/__pycache__/recipe_agent.cpython-312.pyc b/agent/__pycache__/recipe_agent.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c283dd521a44d825faa75552592130f1fb1513bc GIT binary patch literal 32008 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 Uw^c=VXx%G^*Hjc;OuFL#0GqOZkN^Mx literal 0 HcmV?d00001 diff --git a/agent/recipe_agent.py b/agent/recipe_agent.py new file mode 100644 index 0000000..f68a3d2 --- /dev/null +++ b/agent/recipe_agent.py @@ -0,0 +1,748 @@ +#!/usr/bin/env python3 +""" +Recipe Standardization Agent +============================= +An agent that reads recipe files in various formats (TXT, JSON, Markdown, CSV), +parses and standardizes them into a common schema, and produces a single +iPad-friendly HTML document. + +Supported input formats: + - .txt plain-text recipes (flexible heading detection) + - .json JSON-structured recipes + - .md Markdown recipes + - .csv CSV-structured recipes (custom two-section format) + +Usage: + python recipe_agent.py [--input ] [--output ] + +Defaults: + --input recipes/input/ + --output recipes/output/recipes.html +""" + +import argparse +import csv +import json +import os +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional + + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + +@dataclass +class Ingredient: + amount: str = "" + unit: str = "" + item: str = "" + + def display(self) -> str: + parts = [p for p in (self.amount, self.unit, self.item) if p] + return " ".join(parts) + + +@dataclass +class Recipe: + title: str = "Untitled Recipe" + servings: str = "" + prep_time: str = "" + cook_time: str = "" + ingredients: List[Ingredient] = field(default_factory=list) + instructions: List[str] = field(default_factory=list) + notes: str = "" + source_file: str = "" + + +# --------------------------------------------------------------------------- +# Parsers +# --------------------------------------------------------------------------- + +class RecipeParser: + """Base class – subclasses implement `can_parse` and `parse`.""" + + def can_parse(self, path: Path) -> bool: + raise NotImplementedError + + def parse(self, path: Path) -> Recipe: + raise NotImplementedError + + +class JsonParser(RecipeParser): + """Parses JSON-structured recipe files.""" + + def can_parse(self, path: Path) -> bool: + return path.suffix.lower() == ".json" + + def parse(self, path: Path) -> Recipe: + with open(path, encoding="utf-8") as f: + data = json.load(f) + + recipe = Recipe(source_file=path.name) + recipe.title = data.get("name") or data.get("title") or path.stem.replace("_", " ").title() + recipe.servings = str(data.get("servings") or data.get("yield") or "") + recipe.prep_time = str(data.get("prepTime") or data.get("prep_time") or "") + recipe.cook_time = str(data.get("cookTime") or data.get("cook_time") or "") + recipe.notes = data.get("notes") or "" + + raw_ingredients = data.get("ingredients") or [] + for item in raw_ingredients: + if isinstance(item, dict): + recipe.ingredients.append(Ingredient( + amount=str(item.get("qty") or item.get("amount") or ""), + unit=str(item.get("unit") or ""), + item=str(item.get("item") or item.get("name") or ""), + )) + elif isinstance(item, str): + recipe.ingredients.append(Ingredient(item=item)) + + instructions = data.get("steps") or data.get("instructions") or [] + recipe.instructions = [str(s) for s in instructions] + + return recipe + + +class MarkdownParser(RecipeParser): + """Parses Markdown-structured recipe files.""" + + _TIME_KEYS = re.compile(r"(prep|cook|total)\s*time", re.IGNORECASE) + _SERVING_KEYS = re.compile(r"(servings?|yield|makes)", re.IGNORECASE) + _HEADING = re.compile(r"^#{1,4}\s+(.*)", re.IGNORECASE) + _LIST_ITEM = re.compile(r"^\s*[-*+]\s+(.*)") + _NUMBERED = re.compile(r"^\s*\d+\.\s+(.*)") + _BOLD_META = re.compile(r"\*\*([^*]+)\*\*\s*:?\s*(.*)") + + def can_parse(self, path: Path) -> bool: + return path.suffix.lower() in (".md", ".markdown") + + def parse(self, path: Path) -> Recipe: + with open(path, encoding="utf-8") as f: + lines = f.readlines() + + recipe = Recipe(source_file=path.name) + section = "meta" + + for line in lines: + stripped = line.strip() + if not stripped: + continue + + # Top-level heading → recipe title + h_match = self._HEADING.match(stripped) + if h_match: + heading = h_match.group(1).strip() + lower = heading.lower() + if "ingredient" in lower: + section = "ingredients" + elif "instruction" in lower or "direction" in lower or "method" in lower or "step" in lower: + section = "instructions" + elif "note" in lower or "tip" in lower: + section = "notes" + elif section == "meta": + recipe.title = heading + continue + + # Bold metadata lines (e.g. **Servings:** 4) + bold_match = self._BOLD_META.match(stripped) + if bold_match: + key, value = bold_match.group(1).strip(), bold_match.group(2).strip() + value = value.lstrip(":").strip() + if self._SERVING_KEYS.search(key): + recipe.servings = value + elif "prep" in key.lower(): + recipe.prep_time = value + elif "cook" in key.lower() or "bake" in key.lower(): + recipe.cook_time = value + continue + + # List items + list_match = self._LIST_ITEM.match(stripped) + if list_match: + content = list_match.group(1).strip() + if section == "ingredients": + recipe.ingredients.append(_parse_ingredient_string(content)) + elif section == "notes": + recipe.notes += content + " " + continue + + # Numbered steps + num_match = self._NUMBERED.match(stripped) + if num_match: + content = num_match.group(1).strip() + # Strip bold sub-headings like **Marinate:** + content = re.sub(r"\*\*[^*]+\*\*:?\s*", "", content).strip() + if section == "instructions" and content: + recipe.instructions.append(content) + continue + + # Blockquote notes + if stripped.startswith(">"): + note_text = stripped.lstrip("> ").strip() + recipe.notes += note_text + " " + + recipe.notes = recipe.notes.strip() + return recipe + + +class CsvParser(RecipeParser): + """ + Parses a two-section CSV format: + - Header rows: Recipe Name, Servings, Prep Time, Cook Time, Notes + - Ingredient rows: Section, Amount, Unit, Ingredient + - Step rows: Step, Instruction + """ + + def can_parse(self, path: Path) -> bool: + return path.suffix.lower() == ".csv" + + def parse(self, path: Path) -> Recipe: + with open(path, encoding="utf-8", newline="") as f: + rows = list(csv.reader(f)) + + recipe = Recipe(source_file=path.name) + mode = "header" + + for row in rows: + if not any(cell.strip() for cell in row): + continue + + first = row[0].strip() + second = row[1].strip() if len(row) > 1 else "" + + # Detect section transitions + if first.lower() == "section" and second.lower() in ("amount", "qty"): + mode = "ingredients" + continue + if first.lower() == "step" and second.lower() == "instruction": + mode = "instructions" + continue + + if mode == "header": + key = first.lower() + value = second + if "recipe name" in key or "name" == key: + recipe.title = value + elif "servings" in key or "yield" in key: + recipe.servings = value + elif "prep" in key: + recipe.prep_time = value + elif "cook" in key or "bake" in key: + recipe.cook_time = value + elif "note" in key: + recipe.notes = value.strip('"') + + elif mode == "ingredients": + # Row format: Section, Amount, Unit, Ingredient + amount = row[1].strip() if len(row) > 1 else "" + unit = row[2].strip() if len(row) > 2 else "" + item = row[3].strip() if len(row) > 3 else "" + if item: + recipe.ingredients.append(Ingredient(amount=amount, unit=unit, item=item)) + + elif mode == "instructions": + instruction = row[1].strip() if len(row) > 1 else "" + if instruction: + recipe.instructions.append(instruction) + + return recipe + + +class PlainTextParser(RecipeParser): + """ + Parses plain-text recipe files with flexible heading detection. + Handles headings like INGREDIENTS:, INSTRUCTIONS:, What you need:, How to make it: etc. + """ + + _INGREDIENT_HEADINGS = re.compile( + r"^(ingredients?|what\s+you\s+need|you\s+will\s+need|shopping\s+list)\s*:?\s*$", + re.IGNORECASE, + ) + _INSTRUCTION_HEADINGS = re.compile( + r"^(instructions?|directions?|method|steps?|how\s+to\s+(make\s+)?it|preparation)\s*:?\s*$", + re.IGNORECASE, + ) + _NOTES_HEADINGS = re.compile( + r"^(notes?|tips?|chef['\u2019s]*\s+note)\s*:?\s*$", + re.IGNORECASE, + ) + _META_LINE = re.compile( + r"^(servings?|yield|makes|prep\s*(time)?|cook\s*(time)?|bak(e|ing)\s*(time)?|total\s*(time)?)\s*:\s*(.+)$", + re.IGNORECASE, + ) + _NUMBERED = re.compile(r"^\d+[.)]\s+(.*)") + _BULLET = re.compile(r"^[-*•]\s+(.*)") + _INLINE_NOTE = re.compile(r"^(tip|note)\s*:\s*(.*)", re.IGNORECASE) + + def can_parse(self, path: Path) -> bool: + return path.suffix.lower() in (".txt", ".text", "") + + def parse(self, path: Path) -> Recipe: + with open(path, encoding="utf-8") as f: + lines = [l.rstrip("\n") for l in f.readlines()] + + recipe = Recipe(source_file=path.name) + section = "title" + + for line in lines: + stripped = line.strip() + if not stripped: + continue + + # Section heading detection + if self._INGREDIENT_HEADINGS.match(stripped): + section = "ingredients" + continue + if self._INSTRUCTION_HEADINGS.match(stripped): + section = "instructions" + continue + if self._NOTES_HEADINGS.match(stripped): + section = "notes" + continue + + # Metadata lines (Servings: 4, Prep Time: 15 min, ...) + meta = self._META_LINE.match(stripped) + if meta: + key = meta.group(1).lower() + value = meta.group(meta.lastindex).strip() if meta.lastindex else "" + if not value: + # fallback: everything after the first colon + value = stripped.split(":", 1)[-1].strip() + if "serving" in key or "yield" in key or "makes" in key: + recipe.servings = value + elif "prep" in key: + recipe.prep_time = value + elif "cook" in key or "bak" in key: + recipe.cook_time = value + continue + + # Inline tip/note + note_match = self._INLINE_NOTE.match(stripped) + if note_match: + recipe.notes += note_match.group(2).strip() + " " + continue + + if section == "title": + recipe.title = stripped + section = "meta" # only the first non-empty line is the title + + elif section == "ingredients": + # Remove leading bullets + bullet = self._BULLET.match(stripped) + content = bullet.group(1).strip() if bullet else stripped + recipe.ingredients.append(_parse_ingredient_string(content)) + + elif section == "instructions": + num = self._NUMBERED.match(stripped) + if num: + recipe.instructions.append(num.group(1).strip()) + else: + # Treat non-empty lines as instruction sentences + recipe.instructions.append(stripped) + + elif section == "notes": + recipe.notes += stripped + " " + + recipe.notes = recipe.notes.strip() + return recipe + + +# --------------------------------------------------------------------------- +# Ingredient string parser +# --------------------------------------------------------------------------- + +_AMOUNT_PATTERN = re.compile( + r"^([\d½¼¾⅓⅔⅛⅜⅝⅞]+(?:[/ \-][\d½¼¾⅓⅔⅛⅜⅝⅞]+)?)\s*" + r"(cups?|tbsps?|tablespoons?|tsps?|teaspoons?|oz|ounces?|lbs?|pounds?|" + r"g|grams?|kg|kilograms?|ml|liters?|l|cloves?|heads?|bunches?|stalks?|" + r"cans?|packages?|slices?|pieces?|pinch(?:es)?|dash(?:es)?|" + r"small|medium|large|whole)?\s*" + r"(.*)", + re.IGNORECASE, +) + + +def _parse_ingredient_string(text: str) -> Ingredient: + """Best-effort parse of a free-form ingredient string into (amount, unit, item).""" + m = _AMOUNT_PATTERN.match(text.strip()) + if m: + amount = m.group(1).strip() + unit = (m.group(2) or "").strip() + item = (m.group(3) or "").strip().lstrip(",- ").strip() + return Ingredient(amount=amount, unit=unit, item=item or text) + return Ingredient(item=text) + + +# --------------------------------------------------------------------------- +# Agent orchestrator +# --------------------------------------------------------------------------- + +PARSERS: List[RecipeParser] = [ + JsonParser(), + MarkdownParser(), + CsvParser(), + PlainTextParser(), +] + + +def process_directory(input_dir: Path) -> List[Recipe]: + """Walk input_dir, select a parser for each file, and return parsed recipes.""" + recipes: List[Recipe] = [] + supported = {".txt", ".text", ".json", ".md", ".markdown", ".csv"} + + for path in sorted(input_dir.iterdir()): + if path.is_dir() or path.suffix.lower() not in supported: + continue + parser = next((p for p in PARSERS if p.can_parse(path)), None) + if parser is None: + print(f" [skip] no parser for {path.name}", file=sys.stderr) + continue + print(f" [parse] {path.name} ({parser.__class__.__name__})") + try: + recipe = parser.parse(path) + recipes.append(recipe) + except Exception as exc: # noqa: BLE001 + print(f" [error] {path.name}: {exc}", file=sys.stderr) + + return recipes + + +# --------------------------------------------------------------------------- +# HTML renderer (iPad-optimised) +# --------------------------------------------------------------------------- + +_CSS = """ +:root { + --bg: #fdf6ec; + --card: #ffffff; + --accent: #c0392b; + --accent-light: #f9e5e3; + --text: #2c2c2c; + --muted: #6b6b6b; + --border: #e2d9cf; + --shadow: 0 2px 12px rgba(0,0,0,.08); + --radius: 14px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, "Helvetica Neue", Arial, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.65; + padding: 1.5rem 1rem 3rem; +} + +header { + text-align: center; + padding: 2rem 1rem 1.5rem; +} +header h1 { + font-size: 2rem; + color: var(--accent); + letter-spacing: -.5px; +} +header p { + color: var(--muted); + font-size: .9rem; + margin-top: .3rem; +} + +/* Table of contents */ +.toc { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.2rem 1.5rem; + max-width: 700px; + margin: 0 auto 2rem; + box-shadow: var(--shadow); +} +.toc h2 { + font-size: 1rem; + text-transform: uppercase; + letter-spacing: .08em; + color: var(--muted); + margin-bottom: .7rem; +} +.toc ol { padding-left: 1.4rem; } +.toc li { margin: .3rem 0; } +.toc a { color: var(--accent); text-decoration: none; font-weight: 500; } +.toc a:hover { text-decoration: underline; } + +/* Recipe cards */ +.recipes { max-width: 700px; margin: 0 auto; display: flex; flex-direction: column; gap: 2rem; } + +.card { + background: var(--card); + border-radius: var(--radius); + border: 1px solid var(--border); + box-shadow: var(--shadow); + overflow: hidden; +} + +.card-header { + background: var(--accent); + color: #fff; + padding: 1.2rem 1.5rem; +} +.card-header h2 { + font-size: 1.4rem; + font-weight: 700; +} +.card-header .source { + font-size: .75rem; + opacity: .75; + margin-top: .2rem; +} + +.meta-bar { + display: flex; + flex-wrap: wrap; + gap: .5rem 1.5rem; + padding: .9rem 1.5rem; + background: var(--accent-light); + border-bottom: 1px solid var(--border); +} +.meta-item { font-size: .85rem; color: var(--muted); } +.meta-item strong { color: var(--text); } + +.card-body { padding: 1.2rem 1.5rem; } + +.section-title { + font-size: .8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .1em; + color: var(--accent); + margin: 1.2rem 0 .5rem; + padding-bottom: .3rem; + border-bottom: 2px solid var(--accent-light); +} +.section-title:first-child { margin-top: 0; } + +.ingredients { list-style: none; } +.ingredients li { + padding: .4rem 0; + border-bottom: 1px solid var(--border); + font-size: .95rem; + display: flex; + align-items: baseline; + gap: .4rem; +} +.ingredients li:last-child { border-bottom: none; } +.ingredients .amount { font-weight: 600; min-width: 3rem; } +.ingredients .unit { color: var(--muted); min-width: 4rem; } + +.instructions { list-style: none; counter-reset: step; } +.instructions li { + counter-increment: step; + display: flex; + gap: .9rem; + padding: .5rem 0; + font-size: .95rem; + border-bottom: 1px solid var(--border); + align-items: flex-start; +} +.instructions li:last-child { border-bottom: none; } +.instructions li::before { + content: counter(step); + background: var(--accent); + color: #fff; + border-radius: 50%; + min-width: 1.6rem; + height: 1.6rem; + display: flex; + align-items: center; + justify-content: center; + font-size: .8rem; + font-weight: 700; + flex-shrink: 0; + margin-top: .1rem; +} + +.notes-box { + background: #fffbf0; + border-left: 4px solid #f0c040; + border-radius: 0 8px 8px 0; + padding: .7rem 1rem; + font-size: .9rem; + color: #5a4a00; + margin-top: .5rem; +} + +footer { + text-align: center; + color: var(--muted); + font-size: .8rem; + margin-top: 3rem; +} + +/* iPad / print optimisations */ +@media (min-width: 768px) { + body { padding: 2rem 2rem 4rem; } + .card-header h2 { font-size: 1.6rem; } +} +@media print { + body { background: #fff; } + .card { page-break-inside: avoid; box-shadow: none; border: 1px solid #ccc; } +} +""" + + +def _escape(text: str) -> str: + """Minimal HTML escaping.""" + return ( + text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +def render_html(recipes: List[Recipe], generated_at: str) -> str: + """Return the full HTML document as a string.""" + parts: List[str] = [] + + parts.append(f""" + + + + + My Recipe Book + + + + +
+

🍽 My Recipe Book

+

{len(recipes)} recipe{"s" if len(recipes) != 1 else ""}  ·  Generated {_escape(generated_at)}

+
+""") + + # Table of contents + parts.append('\n\n") + + # Recipe cards + parts.append('
\n') + for i, r in enumerate(recipes, 1): + anchor = f"recipe-{i}" + parts.append(f'
\n') + + # Card header + parts.append(f'
\n') + parts.append(f'

{_escape(r.title)}

\n') + if r.source_file: + parts.append(f'
Source: {_escape(r.source_file)}
\n') + parts.append(f'
\n') + + # Meta bar + meta_items = [] + if r.servings: + meta_items.append(f'Servings: {_escape(r.servings)}') + if r.prep_time: + meta_items.append(f'Prep: {_escape(r.prep_time)}') + if r.cook_time: + meta_items.append(f'Cook: {_escape(r.cook_time)}') + if meta_items: + parts.append('
\n ') + parts.append("\n ".join(meta_items)) + parts.append('\n
\n') + + parts.append('
\n') + + # Ingredients + if r.ingredients: + parts.append('
Ingredients
\n') + parts.append('
    \n') + for ing in r.ingredients: + amount_html = f'{_escape(ing.amount)}' if ing.amount else "" + unit_html = f'{_escape(ing.unit)}' if ing.unit else "" + item_html = _escape(ing.item) + parts.append(f'
  • {amount_html}{unit_html}{item_html}
  • \n') + parts.append('
\n') + + # Instructions + if r.instructions: + parts.append('
Instructions
\n') + parts.append('
    \n') + for step in r.instructions: + parts.append(f'
  1. {_escape(step)}
  2. \n') + parts.append('
\n') + + # Notes + if r.notes: + parts.append(f'
Notes
\n') + parts.append(f'
{_escape(r.notes)}
\n') + + parts.append('
\n') # card-body + parts.append('
\n\n') + + parts.append('
\n\n') # .recipes + + parts.append(f'
Recipe Book © {generated_at[:4]}  ·  Open in Safari on iPad for best experience
\n') + parts.append('\n\n') + + return "".join(parts) + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Recipe Standardization Agent – converts mixed-format recipes to a single iPad-friendly HTML file." + ) + parser.add_argument( + "--input", + default="recipes/input", + help="Directory containing recipe files (default: recipes/input)", + ) + parser.add_argument( + "--output", + default="recipes/output/recipes.html", + help="Output HTML file path (default: recipes/output/recipes.html)", + ) + args = parser.parse_args() + + input_dir = Path(args.input) + output_path = Path(args.output) + + if not input_dir.is_dir(): + print(f"Error: input directory '{input_dir}' does not exist.", file=sys.stderr) + sys.exit(1) + + output_path.parent.mkdir(parents=True, exist_ok=True) + + print(f"Recipe Standardization Agent") + print(f" Input : {input_dir.resolve()}") + print(f" Output : {output_path.resolve()}") + print() + + recipes = process_directory(input_dir) + + if not recipes: + print("No recipes parsed – nothing to write.", file=sys.stderr) + sys.exit(1) + + print(f"\n {len(recipes)} recipe(s) standardised successfully.") + + from datetime import datetime, timezone + generated_at = datetime.now(timezone.utc).strftime("%B %d, %Y") + + html = render_html(recipes, generated_at) + output_path.write_text(html, encoding="utf-8") + print(f" Output written to: {output_path}") + + +if __name__ == "__main__": + main() diff --git a/recipes/input/banana_bread.txt b/recipes/input/banana_bread.txt new file mode 100644 index 0000000..ff2efcc --- /dev/null +++ b/recipes/input/banana_bread.txt @@ -0,0 +1,28 @@ +Banana Bread + +Yield: 1 loaf (8 slices) +Prep: 15 minutes +Bake: 60-65 minutes + +What you need: +3 very ripe bananas +1/3 cup melted butter +3/4 cup sugar +1 egg, beaten +1 tsp vanilla +1 tsp baking soda +Pinch of salt +1 1/2 cups all-purpose flour + +How to make it: +Preheat your oven to 350°F (175°C). Grease a 4x8-inch loaf pan. +In a mixing bowl, mash the ripe bananas with a fork until smooth. +Stir the melted butter into the mashed bananas. +Mix in the sugar, beaten egg, and vanilla extract. +Sprinkle the baking soda and salt over the mixture and stir. +Add the flour and mix until just combined — do not overmix. +Pour the batter into the prepared loaf pan. +Bake for 60–65 minutes, or until a toothpick inserted in the center comes out clean. +Let cool in the pan for a few minutes before turning out onto a wire rack. + +Tip: The riper and blacker the bananas, the sweeter and more flavorful the bread will be! diff --git a/recipes/input/caesar_salad.csv b/recipes/input/caesar_salad.csv new file mode 100644 index 0000000..ade60a5 --- /dev/null +++ b/recipes/input/caesar_salad.csv @@ -0,0 +1,26 @@ +Recipe Name,Caesar Salad +Servings,2 +Prep Time,20 minutes +Cook Time,0 minutes +Notes,"A classic Caesar salad. Use fresh Parmesan for best results." + +Section,Amount,Unit,Ingredient +Salad,1,head,romaine lettuce (washed and torn) +Salad,0.5,cup,Parmesan cheese (freshly grated) +Salad,1,cup,croutons +Dressing,2,,anchovy fillets (minced) +Dressing,1,,garlic clove (minced) +Dressing,1,tsp,Dijon mustard +Dressing,1,tsp,Worcestershire sauce +Dressing,2,tbsp,lemon juice +Dressing,0.5,cup,mayonnaise +Dressing,0.25,cup,Parmesan cheese (grated) +Dressing,,,Salt and black pepper to taste + +Step,Instruction +1,Whisk together anchovies and garlic in a large bowl until a paste forms. +2,Add mustard and Worcestershire sauce; whisk to combine. +3,Whisk in lemon juice then mayonnaise until smooth and creamy. +4,Stir in 1/4 cup Parmesan and season generously with salt and pepper. +5,Add romaine lettuce and toss well to coat every leaf with dressing. +6,Top with croutons and remaining Parmesan. Serve immediately. diff --git a/recipes/input/chicken_tikka_masala.md b/recipes/input/chicken_tikka_masala.md new file mode 100644 index 0000000..56b64b1 --- /dev/null +++ b/recipes/input/chicken_tikka_masala.md @@ -0,0 +1,43 @@ +# Chicken Tikka Masala + +**Servings:** 4 +**Prep Time:** 30 minutes (plus 2 hours marinating) +**Cook Time:** 40 minutes + +## Ingredients + +### Marinade +- 1 cup plain yogurt +- 2 tbsp lemon juice +- 2 tsp cumin +- 1 tsp cinnamon +- 2 tsp cayenne pepper +- 2 tsp black pepper +- 1 tbsp minced fresh ginger +- 1 tsp salt +- 3 boneless chicken breasts, cut into bite-sized pieces + +### Sauce +- 3 tbsp butter +- 1 clove garlic, minced +- 1 jalapeno pepper, finely chopped +- 2 tsp cumin +- 2 tsp paprika +- 1 tsp salt +- 1 can (8 oz) tomato sauce +- 1 cup heavy cream +- 1/4 cup fresh cilantro, chopped + +## Instructions + +1. **Marinate the chicken:** Combine yogurt, lemon juice, and marinade spices in a bowl. Add the chicken, cover, and refrigerate for at least 2 hours (overnight preferred). +2. **Grill or broil the chicken:** Thread marinated chicken onto skewers and grill or broil until slightly charred, about 5 minutes per side. Set aside. +3. **Make the sauce:** Melt butter in a large heavy skillet over medium heat. Sauté garlic and jalapeno for 1 minute. Add cumin, paprika, and salt; stir for 1 minute. +4. **Add tomatoes:** Pour in the tomato sauce and simmer for 15 minutes, stirring occasionally. +5. **Add cream:** Stir in heavy cream and simmer until the sauce thickens, about 10 minutes. +6. **Combine:** Add the grilled chicken to the sauce and simmer for 10 minutes. +7. **Serve:** Garnish with fresh cilantro and serve over basmati rice or with naan bread. + +## Notes + +> The longer you marinate the chicken, the more tender and flavorful it will be. Overnight marinating gives the best results. diff --git a/recipes/input/chocolate_chip_cookies.json b/recipes/input/chocolate_chip_cookies.json new file mode 100644 index 0000000..8fdb9f5 --- /dev/null +++ b/recipes/input/chocolate_chip_cookies.json @@ -0,0 +1,29 @@ +{ + "name": "Classic Chocolate Chip Cookies", + "servings": "36 cookies", + "prepTime": "15 min", + "cookTime": "11 min", + "ingredients": [ + { "qty": "2 1/4", "unit": "cups", "item": "all-purpose flour" }, + { "qty": "1", "unit": "tsp", "item": "baking soda" }, + { "qty": "1", "unit": "tsp", "item": "salt" }, + { "qty": "1", "unit": "cup", "item": "unsalted butter, softened" }, + { "qty": "3/4", "unit": "cup", "item": "granulated sugar" }, + { "qty": "3/4", "unit": "cup", "item": "packed brown sugar" }, + { "qty": "2", "unit": "", "item": "large eggs" }, + { "qty": "2", "unit": "tsp", "item": "vanilla extract" }, + { "qty": "2", "unit": "cups", "item": "chocolate chips" }, + { "qty": "1", "unit": "cup", "item": "chopped walnuts (optional)" } + ], + "steps": [ + "Preheat oven to 375°F (190°C). Line baking sheets with parchment paper.", + "Whisk together flour, baking soda, and salt in a bowl; set aside.", + "Beat butter and both sugars together until light and fluffy, about 3 minutes.", + "Add eggs one at a time, then mix in vanilla extract.", + "Gradually blend in the flour mixture until just combined.", + "Stir in chocolate chips and walnuts if using.", + "Drop rounded tablespoons of dough onto prepared baking sheets, spacing 2 inches apart.", + "Bake for 9–11 minutes or until golden brown. Cool on baking sheet for 2 minutes before transferring to wire rack." + ], + "notes": "For chewier cookies, refrigerate the dough for at least 1 hour before baking." +} diff --git a/recipes/input/spaghetti_carbonara.txt b/recipes/input/spaghetti_carbonara.txt new file mode 100644 index 0000000..7c97829 --- /dev/null +++ b/recipes/input/spaghetti_carbonara.txt @@ -0,0 +1,25 @@ +Spaghetti Carbonara + +Servings: 4 +Prep Time: 10 minutes +Cook Time: 20 minutes + +INGREDIENTS: +- 400g spaghetti +- 200g pancetta or guanciale, diced +- 4 large eggs +- 100g Pecorino Romano, finely grated +- 100g Parmigiano-Reggiano, finely grated +- 2 cloves garlic +- Salt and black pepper to taste + +INSTRUCTIONS: +1. Bring a large pot of salted water to a boil and cook spaghetti until al dente. +2. Meanwhile, cook the pancetta in a large skillet over medium heat until crispy, about 8 minutes. +3. In a bowl, whisk together the eggs and half the cheese. Season generously with black pepper. +4. Remove the skillet from heat. Add the drained pasta, tossing to coat in the pancetta fat. +5. Pour the egg mixture over the pasta, tossing quickly so the eggs don't scramble. Add pasta water a splash at a time to create a creamy sauce. +6. Serve immediately topped with remaining cheese and extra black pepper. + +NOTES: +Do not add cream — traditional carbonara gets its creaminess from the eggs and cheese. diff --git a/recipes/output/recipes.html b/recipes/output/recipes.html new file mode 100644 index 0000000..1223afb --- /dev/null +++ b/recipes/output/recipes.html @@ -0,0 +1,408 @@ + + + + + + My Recipe Book + + + + +
+

🍽 My Recipe Book

+

5 recipes  ·  Generated April 30, 2026

+
+ + +
+
+
+

Banana Bread

+
Source: banana_bread.txt
+
+
+ Servings: 1 loaf (8 slices) + Prep: 15 minutes + Cook: 60-65 minutes +
+
+
Ingredients
+
    +
  • 3very ripe bananas
  • +
  • 1/3cupmelted butter
  • +
  • 3/4cupsugar
  • +
  • 1egg, beaten
  • +
  • 1tspvanilla
  • +
  • 1tspbaking soda
  • +
  • Pinch of salt
  • +
  • 1 1/2 cups all-purpose flour
  • +
+
Instructions
+
    +
  1. Preheat your oven to 350°F (175°C). Grease a 4x8-inch loaf pan.
  2. +
  3. In a mixing bowl, mash the ripe bananas with a fork until smooth.
  4. +
  5. Stir the melted butter into the mashed bananas.
  6. +
  7. Mix in the sugar, beaten egg, and vanilla extract.
  8. +
  9. Sprinkle the baking soda and salt over the mixture and stir.
  10. +
  11. Add the flour and mix until just combined — do not overmix.
  12. +
  13. Pour the batter into the prepared loaf pan.
  14. +
  15. Bake for 60–65 minutes, or until a toothpick inserted in the center comes out clean.
  16. +
  17. Let cool in the pan for a few minutes before turning out onto a wire rack.
  18. +
+
Notes
+
The riper and blacker the bananas, the sweeter and more flavorful the bread will be!
+
+
+ +
+
+

Caesar Salad

+
Source: caesar_salad.csv
+
+
+ Servings: 2 + Prep: 20 minutes + Cook: 0 minutes +
+
+
Ingredients
+
    +
  • 1headromaine lettuce (washed and torn)
  • +
  • 0.5cupParmesan cheese (freshly grated)
  • +
  • 1cupcroutons
  • +
  • 2anchovy fillets (minced)
  • +
  • 1garlic clove (minced)
  • +
  • 1tspDijon mustard
  • +
  • 1tspWorcestershire sauce
  • +
  • 2tbsplemon juice
  • +
  • 0.5cupmayonnaise
  • +
  • 0.25cupParmesan cheese (grated)
  • +
  • Salt and black pepper to taste
  • +
+
Instructions
+
    +
  1. Whisk together anchovies and garlic in a large bowl until a paste forms.
  2. +
  3. Add mustard and Worcestershire sauce; whisk to combine.
  4. +
  5. Whisk in lemon juice then mayonnaise until smooth and creamy.
  6. +
  7. Stir in 1/4 cup Parmesan and season generously with salt and pepper.
  8. +
  9. Add romaine lettuce and toss well to coat every leaf with dressing.
  10. +
  11. Top with croutons and remaining Parmesan. Serve immediately.
  12. +
+
Notes
+
A classic Caesar salad. Use fresh Parmesan for best results.
+
+
+ +
+
+

Chicken Tikka Masala

+
Source: chicken_tikka_masala.md
+
+
+ Servings: 4 + Prep: 30 minutes (plus 2 hours marinating) + Cook: 40 minutes +
+
+
Ingredients
+
    +
  • 1cupplain yogurt
  • +
  • 2tbsplemon juice
  • +
  • 2tspcumin
  • +
  • 1tspcinnamon
  • +
  • 2tspcayenne pepper
  • +
  • 2tspblack pepper
  • +
  • 1tbspminced fresh ginger
  • +
  • 1tspsalt
  • +
  • 3boneless chicken breasts, cut into bite-sized pieces
  • +
  • 3tbspbutter
  • +
  • 1clovegarlic, minced
  • +
  • 1jalapeno pepper, finely chopped
  • +
  • 2tspcumin
  • +
  • 2tsppaprika
  • +
  • 1tspsalt
  • +
  • 1can(8 oz) tomato sauce
  • +
  • 1cupheavy cream
  • +
  • 1/4cupfresh cilantro, chopped
  • +
+
Instructions
+
    +
  1. Combine yogurt, lemon juice, and marinade spices in a bowl. Add the chicken, cover, and refrigerate for at least 2 hours (overnight preferred).
  2. +
  3. Thread marinated chicken onto skewers and grill or broil until slightly charred, about 5 minutes per side. Set aside.
  4. +
  5. Melt butter in a large heavy skillet over medium heat. Sauté garlic and jalapeno for 1 minute. Add cumin, paprika, and salt; stir for 1 minute.
  6. +
  7. Pour in the tomato sauce and simmer for 15 minutes, stirring occasionally.
  8. +
  9. Stir in heavy cream and simmer until the sauce thickens, about 10 minutes.
  10. +
  11. Add the grilled chicken to the sauce and simmer for 10 minutes.
  12. +
  13. Garnish with fresh cilantro and serve over basmati rice or with naan bread.
  14. +
+
Notes
+
The longer you marinate the chicken, the more tender and flavorful it will be. Overnight marinating gives the best results.
+
+
+ +
+
+

Classic Chocolate Chip Cookies

+
Source: chocolate_chip_cookies.json
+
+
+ Servings: 36 cookies + Prep: 15 min + Cook: 11 min +
+
+
Ingredients
+
    +
  • 2 1/4cupsall-purpose flour
  • +
  • 1tspbaking soda
  • +
  • 1tspsalt
  • +
  • 1cupunsalted butter, softened
  • +
  • 3/4cupgranulated sugar
  • +
  • 3/4cuppacked brown sugar
  • +
  • 2large eggs
  • +
  • 2tspvanilla extract
  • +
  • 2cupschocolate chips
  • +
  • 1cupchopped walnuts (optional)
  • +
+
Instructions
+
    +
  1. Preheat oven to 375°F (190°C). Line baking sheets with parchment paper.
  2. +
  3. Whisk together flour, baking soda, and salt in a bowl; set aside.
  4. +
  5. Beat butter and both sugars together until light and fluffy, about 3 minutes.
  6. +
  7. Add eggs one at a time, then mix in vanilla extract.
  8. +
  9. Gradually blend in the flour mixture until just combined.
  10. +
  11. Stir in chocolate chips and walnuts if using.
  12. +
  13. Drop rounded tablespoons of dough onto prepared baking sheets, spacing 2 inches apart.
  14. +
  15. Bake for 9–11 minutes or until golden brown. Cool on baking sheet for 2 minutes before transferring to wire rack.
  16. +
+
Notes
+
For chewier cookies, refrigerate the dough for at least 1 hour before baking.
+
+
+ +
+
+

Spaghetti Carbonara

+
Source: spaghetti_carbonara.txt
+
+
+ Servings: 4 + Prep: 10 minutes + Cook: 20 minutes +
+
+
Ingredients
+
    +
  • 400gspaghetti
  • +
  • 200gpancetta or guanciale, diced
  • +
  • 4large eggs
  • +
  • 100gPecorino Romano, finely grated
  • +
  • 100gParmigiano-Reggiano, finely grated
  • +
  • 2clovesgarlic
  • +
  • Salt and black pepper to taste
  • +
+
Instructions
+
    +
  1. Bring a large pot of salted water to a boil and cook spaghetti until al dente.
  2. +
  3. Meanwhile, cook the pancetta in a large skillet over medium heat until crispy, about 8 minutes.
  4. +
  5. In a bowl, whisk together the eggs and half the cheese. Season generously with black pepper.
  6. +
  7. Remove the skillet from heat. Add the drained pasta, tossing to coat in the pancetta fat.
  8. +
  9. Pour the egg mixture over the pasta, tossing quickly so the eggs don't scramble. Add pasta water a splash at a time to create a creamy sauce.
  10. +
  11. Serve immediately topped with remaining cheese and extra black pepper.
  12. +
+
Notes
+
Do not add cream — traditional carbonara gets its creaminess from the eggs and cheese.
+
+
+ +
+ +
Recipe Book © Apri  ·  Open in Safari on iPad for best experience
+ + From 5b19cd0761e35cdd54969564afadd1c8aa9d4e88 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Apr 2026 16:47:01 +0000 Subject: [PATCH 2/2] Fix ingredient parser (mixed fractions, unit word-boundaries) and footer year Agent-Logs-Url: https://github.com/GeraldRamich/AgentBuilding/sessions/97f0fc4b-1688-46c9-85ec-ce679d3c8877 Co-authored-by: GeraldRamich <53840101+GeraldRamich@users.noreply.github.com> --- .../__pycache__/recipe_agent.cpython-312.pyc | Bin 32008 -> 32163 bytes agent/recipe_agent.py | 20 +++++++++++------- recipes/output/recipes.html | 14 ++++++------ 3 files changed, 19 insertions(+), 15 deletions(-) diff --git a/agent/__pycache__/recipe_agent.cpython-312.pyc b/agent/__pycache__/recipe_agent.cpython-312.pyc index c283dd521a44d825faa75552592130f1fb1513bc..ef1f71da3c927cfe49c92c666e60a681b95d049b 100644 GIT binary patch delta 1616 zcmZuxZA@EL7(VBI^yA)^0;L@-Fj~r7Yx&yHFtD+@m6*)2fW*L91q$~T%P7cgCk1Xz zO_ofI8IThNMHY-PF=HCEf0p?*f0&T?XIcL&b;%_9XPTH|{iRFr+}?Yk7*BG}`=0l_ z?{nVgJs)rV4L|+|9{u*{e5nmvxud*| ztHs^Q+rm1|afKdmaP^$?3N_&5>$tj0ZU6)w0eI)8f&sxSF-v%krJIN^pZA`E01Uc= zoHrQr;;|Wbu}Hf89l<6%_xQz&$P}<4Z=i(+i{rij0LAa1^=<=4qKGZ*W(VeAc`H(P5stmC-;4aPc zTRH$%z!*r9B8hfJEaz*gAm#{^nYqz|a{$6F9knVpDkJR zE%;Wc3|o^5{K{IFW;P6;av8Q2CFY(!0tM;Y_7Fk|7Uw-Kp$$vnPxQ^)_0orunw18L zgg~N8^L30KZ!i5Sv1zn2f5S>?XhW_9(AukAZ+7L)jG0K8a(2`wPMzGX=!4L{TCo~_ fzd8pbeXCcNcSu=o_vQ=syht>{6S-lO6D$7(%fhs~ delta 1544 zcmZuxTWlLu5WV;A+Fsjh$FaRGb{>vxAaRUI3Zf5GaGE-O04WJo0Fg9_lU;|Hq)FC} zn#Wp7B_LJQiZ&zBq9~{p2nyjN$UgxI2|g+zm5377M5|8(`H1x%f>K-N*O+a(9)ju((OiUjema++DY={n%bS6?g0!lIr z?;;F;8oVCDG zx>|4Hbj)hQYIkOZ%{ZYoC17*8%Uda)Q9ZcMqh`zvRbv(g4|!F*a)cvLL0VpdF3|}K zLw1_yu0Z-^NJVm2Aeqc^yIz!InAa<58TJWgqfJ8_9m0eW=LlA;u`rdT6@9LQT`_{- z7iyIk!6{l8i(-NA4|(Asu?lNem*`P?Fg#*zsbwY@0v&Wf%qnObdyV{95l=zfJ; zL5h`rHoCnJteUo}IkmuQTPZ+P;{9xbW%iKjN1kbtXp6}LZAd%L8uRXri7j(OP%Y$|}opx@Wg1d(aHq&n1 zeaFbIX;rYeU3?^fDDk6}#5&K&NEnmC(um(=K75O%9m5A>pK`o>jhgaJm>ln7)e8le z$Ak8ts4+mVTJ|rdaC`hRQ-4X>Xy53*+LE~Xa9a_fy?8&N%6)+RhLGtXd^@!hZxp<8 zf+3k0oUQV}LeN^Dx8&j!s4NPrSl0B*o97fJ(*oVQysYT|C;(q%1))Z}o5s(hj}3~} z7Ji@ZAkaQ@$$XoF$VDj5+~-znyJ0&nkLIL-oHUb*S|KnS*`-COVK>zP_T}98Sk_9- z218__yfB^`Jth-+vp-WCTrQlLeF{J5sX4dj?4AqU`}{?I(-Z&3v*T;ej`jIX&%T9c zezg0}9KXPC+T*~^KjtE`lj0nXM&zAT%fCX`{M)YYRP%;9sO8B7{+n!)${vw0efLup8{snfsc?JLg diff --git a/agent/recipe_agent.py b/agent/recipe_agent.py index f68a3d2..c7c43fe 100644 --- a/agent/recipe_agent.py +++ b/agent/recipe_agent.py @@ -355,11 +355,13 @@ def parse(self, path: Path) -> Recipe: # --------------------------------------------------------------------------- _AMOUNT_PATTERN = re.compile( - r"^([\d½¼¾⅓⅔⅛⅜⅝⅞]+(?:[/ \-][\d½¼¾⅓⅔⅛⅜⅝⅞]+)?)\s*" - r"(cups?|tbsps?|tablespoons?|tsps?|teaspoons?|oz|ounces?|lbs?|pounds?|" - r"g|grams?|kg|kilograms?|ml|liters?|l|cloves?|heads?|bunches?|stalks?|" + # Amount: mixed numbers (e.g. "1 1/2"), plain fractions ("3/4"), integers, or vulgar fractions + r"^(\d+\s+\d+/\d+|\d+/\d+|\d+(?:\.\d+)?|[½¼¾⅓⅔⅛⅜⅝⅞])\s*" + # Unit: must match as a complete word (word boundary) to avoid "l" matching "large" + r"(cups?|tbsps?|tablespoons?|tsps?|teaspoons?|fl\.?\s*oz|oz|ounces?|lbs?|pounds?|" + r"grams?|kg|kilograms?|ml|liters?|litres?|cloves?|heads?|bunches?|stalks?|" r"cans?|packages?|slices?|pieces?|pinch(?:es)?|dash(?:es)?|" - r"small|medium|large|whole)?\s*" + r"small|medium|large|whole|g)\b\s*" r"(.*)", re.IGNORECASE, ) @@ -604,7 +606,7 @@ def _escape(text: str) -> str: ) -def render_html(recipes: List[Recipe], generated_at: str) -> str: +def render_html(recipes: List[Recipe], generated_at: str, year: str = "") -> str: """Return the full HTML document as a string.""" parts: List[str] = [] @@ -688,7 +690,7 @@ def render_html(recipes: List[Recipe], generated_at: str) -> str: parts.append('\n\n') # .recipes - parts.append(f'
Recipe Book © {generated_at[:4]}  ·  Open in Safari on iPad for best experience
\n') + parts.append(f'
Recipe Book © {_escape(year or generated_at[-4:])}  ·  Open in Safari on iPad for best experience
\n') parts.append('\n\n') return "".join(parts) @@ -737,9 +739,11 @@ def main() -> None: print(f"\n {len(recipes)} recipe(s) standardised successfully.") from datetime import datetime, timezone - generated_at = datetime.now(timezone.utc).strftime("%B %d, %Y") + now = datetime.now(timezone.utc) + generated_at = now.strftime("%B %d, %Y") + year = now.strftime("%Y") - html = render_html(recipes, generated_at) + html = render_html(recipes, generated_at, year) output_path.write_text(html, encoding="utf-8") print(f" Output written to: {output_path}") diff --git a/recipes/output/recipes.html b/recipes/output/recipes.html index 1223afb..95c5024 100644 --- a/recipes/output/recipes.html +++ b/recipes/output/recipes.html @@ -214,14 +214,14 @@

Banana Bread

Ingredients
    -
  • 3very ripe bananas
  • +
  • 3 very ripe bananas
  • 1/3cupmelted butter
  • 3/4cupsugar
  • -
  • 1egg, beaten
  • +
  • 1 egg, beaten
  • 1tspvanilla
  • 1tspbaking soda
  • Pinch of salt
  • -
  • 1 1/2 cups all-purpose flour
  • +
  • 1 1/2cupsall-purpose flour
Instructions
    @@ -300,10 +300,10 @@

    Chicken Tikka Masala

  1. 2tspblack pepper
  2. 1tbspminced fresh ginger
  3. 1tspsalt
  4. -
  5. 3boneless chicken breasts, cut into bite-sized pieces
  6. +
  7. 3 boneless chicken breasts, cut into bite-sized pieces
  8. 3tbspbutter
  9. 1clovegarlic, minced
  10. -
  11. 1jalapeno pepper, finely chopped
  12. +
  13. 1 jalapeno pepper, finely chopped
  14. 2tspcumin
  15. 2tsppaprika
  16. 1tspsalt
  17. @@ -381,7 +381,7 @@

    Spaghetti Carbonara

    • 400gspaghetti
    • 200gpancetta or guanciale, diced
    • -
    • 4large eggs
    • +
    • 4largeeggs
    • 100gPecorino Romano, finely grated
    • 100gParmigiano-Reggiano, finely grated
    • 2clovesgarlic
    • @@ -403,6 +403,6 @@

      Spaghetti Carbonara

-
Recipe Book © Apri  ·  Open in Safari on iPad for best experience
+