Inital commit for agent
This commit is contained in:
commit
82fdc33771
12 files changed
+497
No files matched your search
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2024 Nutritional Recipe Parser
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
# Nutritional Recipe Parser
|
||||||
|
|
||||||
|
A Python application that parses recipes and looks up nutritional information for ingredients using the OpenFoodFacts API.
|
||||||
|
|
||||||
|
## Python Version
|
||||||
|
|
||||||
|
- **Required:** Python 3.8 or higher
|
||||||
|
- **Tested on:** Python 3.12.13
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
### Python Libraries
|
||||||
|
|
||||||
|
The following Python packages are required:
|
||||||
|
|
||||||
|
| Package | Purpose | Install Method |
|
||||||
|
|---------|---------|----------------|
|
||||||
|
| `requests` | HTTP client for OpenFoodFacts API | `pip install requests` |
|
||||||
|
| `json` | Built-in | Python 3.x (no install needed) |
|
||||||
|
| `tkinter` | GUI toolkit (optional) | Usually bundled with Python |
|
||||||
|
|
||||||
|
### System Packages (apt)
|
||||||
|
|
||||||
|
For a fresh TrueNAS/Ubuntu/Debian environment, install the following:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt update
|
||||||
|
sudo apt install -y python3 python3-pip python3-tk
|
||||||
|
```
|
||||||
|
|
||||||
|
**Explanation:**
|
||||||
|
- `python3` - The Python interpreter (version 3.12.13 is available on this system)
|
||||||
|
- `python3-pip` - Pip package manager to install `requests` and other PyPI packages
|
||||||
|
- `python3-tk` - Tkinter support for the optional GUI mode
|
||||||
|
|
||||||
|
### Verifying Installation
|
||||||
|
|
||||||
|
After installing, verify the core dependency:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip3 install --upgrade requests
|
||||||
|
```
|
||||||
|
|
||||||
|
Test the installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 --version
|
||||||
|
python3 -c "import requests; print('requests version:', requests.__version__)"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Command-Line Interface
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Parse a recipe file and show summary
|
||||||
|
python3 main.py recipe.md
|
||||||
|
|
||||||
|
# Parse and output JSON to a file
|
||||||
|
python3 main.py recipe.md -o output.json
|
||||||
|
|
||||||
|
# Parse with verbose nutrition output
|
||||||
|
python3 main.py recipe.md -v
|
||||||
|
|
||||||
|
# List cached ingredients
|
||||||
|
python3 main.py --list-foods
|
||||||
|
```
|
||||||
|
|
||||||
|
### GUI Mode
|
||||||
|
|
||||||
|
Launch without arguments for a graphical interface:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python3 main.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Project Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
nutri_parser/
|
||||||
|
├── nutri_parser.py # Core parsing and API logic
|
||||||
|
├── recipe_parser.py # Recipe parsing utilities
|
||||||
|
├── main.py # Entry point (CLI + GUI)
|
||||||
|
├── README.md # This file
|
||||||
|
└── LICENSE # Project license
|
||||||
|
```
|
||||||
|
|
||||||
|
## API Integration
|
||||||
|
|
||||||
|
This project uses the [OpenFoodFacts](https://world.openfoodfacts.org/) open database API to look up nutritional values for ingredients. The API:
|
||||||
|
|
||||||
|
- **Does not require** an API key for basic use
|
||||||
|
- **Requires** a descriptive `User-Agent` header (set to `"RecipeParser/1.0"` in this project)
|
||||||
|
- **Recommends** rate limiting (1 request/second minimum) to avoid being blocked
|
||||||
|
- **Includes** automatic retry with exponential backoff (5 attempts with delays [2, 5, 10, 20, 30] seconds)
|
||||||
|
|
||||||
|
## Rate Limiting & Caching
|
||||||
|
|
||||||
|
- **Rate limiter:** Enforces 1 request per second minimum between API calls
|
||||||
|
- **SQLite cache:** Ingredient nutrition data is cached locally to avoid redundant API calls
|
||||||
|
- **Cache management:** Use `--list-foods` to view cached ingredients, or clear the cache file to refresh
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""
|
||||||
|
Nutritional Recipe Parser Package
|
||||||
|
"""
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,117 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Main entry point for the Nutritional Recipe Parser application.
|
||||||
|
|
||||||
|
Supports both CLI and GUI modes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from nutri_parser import Recipe, RecipeParser, NutritionApi
|
||||||
|
import argparse
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def main_gui():
|
||||||
|
"""Launch the tkinter GUI application."""
|
||||||
|
try:
|
||||||
|
import tkinter as tk
|
||||||
|
from nutri_parser import NutritionApi
|
||||||
|
root = tk.Tk()
|
||||||
|
app = RecipeAppGUI(root)
|
||||||
|
root.mainloop()
|
||||||
|
except ImportError:
|
||||||
|
print("Tkinter not available. Using CLI mode instead.")
|
||||||
|
main_cli()
|
||||||
|
|
||||||
|
|
||||||
|
def main_cli() -> None:
|
||||||
|
"""Command-line interface entry point."""
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Parse nutritional values from recipe files.",
|
||||||
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||||
|
epilog="""
|
||||||
|
Examples:
|
||||||
|
%(prog)s recipe.md
|
||||||
|
%(prog)s --output-json recipe.md output.json
|
||||||
|
%(prog)s --list-foods
|
||||||
|
%(prog)s --verbose recipe.md
|
||||||
|
""",
|
||||||
|
)
|
||||||
|
parser.add_argument("input_file", nargs="?", help="Input recipe file")
|
||||||
|
parser.add_argument("--output-json", "-o", help="Output JSON file path")
|
||||||
|
parser.add_argument("--list-foods", "-l", action="store_true",
|
||||||
|
help="List cached ingredients")
|
||||||
|
parser.add_argument("--verbose", "-v", action="store_true",
|
||||||
|
help="Verbose output")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
api = NutritionApi()
|
||||||
|
|
||||||
|
if args.list_foods:
|
||||||
|
# Show what we have in cache
|
||||||
|
cursor = api.conn.execute("SELECT ingredient_name FROM ingredient_cache")
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
if rows:
|
||||||
|
print("Cached ingredients ({}):".format(len(rows)))
|
||||||
|
for row in rows:
|
||||||
|
print(f" {row[0]}")
|
||||||
|
else:
|
||||||
|
print("No cached ingredients yet. Parse a recipe to populate the cache.")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not args.input_file:
|
||||||
|
parser.print_usage()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not os.path.exists(args.input_file):
|
||||||
|
print(f"Error: File not found: {args.input_file}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with open(args.input_file, "r", encoding="utf-8") as f:
|
||||||
|
text = f.read()
|
||||||
|
|
||||||
|
cli_parser = RecipeParser()
|
||||||
|
try:
|
||||||
|
parsed = cli_parser.parse(text)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error parsing recipe: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
recipe = Recipe(parsed, api=api, servings=parsed.servings)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error looking up nutrition: {e}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.verbose:
|
||||||
|
print(recipe.summary())
|
||||||
|
|
||||||
|
# Print JSON to stderr (so stdout can be used separately) or stdout
|
||||||
|
json_output = recipe.to_json()
|
||||||
|
if args.output_json:
|
||||||
|
with open(args.output_json, "w", encoding="utf-8") as f:
|
||||||
|
f.write(json_output)
|
||||||
|
print(f"Exported to: {args.output_json}", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
# Print a human-readable summary to stdout
|
||||||
|
print(recipe.summary(), file=sys.stdout)
|
||||||
|
# Also print JSON to stderr for pip/redirect use
|
||||||
|
print("\n--- JSON ---", file=sys.stderr)
|
||||||
|
print(json_output, file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
"""Determine mode: GUI if no args, otherwise CLI."""
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# If no arguments at all, launch GUI
|
||||||
|
if len(sys.argv) == 1:
|
||||||
|
main_gui()
|
||||||
|
else:
|
||||||
|
main_cli()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+211
@@ -0,0 +1,211 @@
|
|||||||
|
import json
|
||||||
|
import time
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
from typing import Dict, Optional
|
||||||
|
|
||||||
|
# Ensure recipe_parser is importable
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
from recipe_parser import ParsedRecipe
|
||||||
|
|
||||||
|
_BASE_URL = "https://world.openfoodfacts.org"
|
||||||
|
_SEARCH_BASE = _BASE_URL + "/cgi/search.pl"
|
||||||
|
_PRODUCT_BASE = _BASE_URL + "/api/v2/product"
|
||||||
|
|
||||||
|
|
||||||
|
class NutritionApi:
|
||||||
|
"""Wrapper around OpenFoodFacts API for looking up ingredient nutrition."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self.conn = None # SQLite connection set externally if using cache
|
||||||
|
self._last_request = 0.0
|
||||||
|
|
||||||
|
def set_conn(self, conn):
|
||||||
|
"""Set the SQLite connection for caching."""
|
||||||
|
self.conn = conn
|
||||||
|
|
||||||
|
def lookup(self, ingredient: str) -> Optional[Dict]:
|
||||||
|
"""Look up nutrition data for *ingredient*.
|
||||||
|
|
||||||
|
Returns a dict with keys ``calories``, ``protein``, ``fat``,
|
||||||
|
``carbs``, ``sugar``, ``sodium`` – or ``None`` if not found.
|
||||||
|
"""
|
||||||
|
# Check cache first
|
||||||
|
if self.conn:
|
||||||
|
cursor = self.conn.execute(
|
||||||
|
"SELECT data FROM ingredient_cache WHERE ingredient = ?",
|
||||||
|
(ingredient,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if row:
|
||||||
|
try:
|
||||||
|
return json.loads(row[0])
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pass # Fall through to API lookup
|
||||||
|
|
||||||
|
self._rate_limit()
|
||||||
|
result = self._search_openfood(ingredient)
|
||||||
|
if result is not None:
|
||||||
|
if self.conn:
|
||||||
|
self.conn.execute(
|
||||||
|
"INSERT OR REPLACE INTO ingredient_cache (ingredient, data) VALUES (?, ?)",
|
||||||
|
(ingredient, json.dumps(result)),
|
||||||
|
)
|
||||||
|
self.conn.commit()
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _rate_limit(self) -> None:
|
||||||
|
"""Simple rate limiter: minimum delay between requests."""
|
||||||
|
elapsed = time.time() - self._last_request
|
||||||
|
if elapsed < 1.0: # 1 request per second minimum
|
||||||
|
time.sleep(1.0 - elapsed)
|
||||||
|
self._last_request = time.time()
|
||||||
|
|
||||||
|
def _search_openfood(self, query: str) -> Optional[Dict]:
|
||||||
|
"""Search OpenFoodFacts and return normalised nutrition dict.
|
||||||
|
|
||||||
|
Retries with increasing delays to handle rate limiting / captcha challenges.
|
||||||
|
"""
|
||||||
|
# Retry with exponential backoff for transient errors (e.g. 503, HTML captcha pages)
|
||||||
|
max_retries = 5
|
||||||
|
# Increasing delays: 2, 5, 10, 20, 30 seconds to handle rate limiting / captcha challenges
|
||||||
|
backoff_delays = [2, 5, 10, 20, 30]
|
||||||
|
for attempt in range(max_retries):
|
||||||
|
try:
|
||||||
|
if attempt > 0:
|
||||||
|
delay = backoff_delays[min(attempt - 1, len(backoff_delays) - 1)]
|
||||||
|
time.sleep(delay)
|
||||||
|
params = {"search": query.strip(), "json": "1", "page_size": "5"}
|
||||||
|
r = requests.get(_SEARCH_BASE, params=params, timeout=10,
|
||||||
|
headers={"User-Agent": "RecipeParser/1.0"})
|
||||||
|
# Handle non-JSON responses (e.g. Cloudflare captcha HTML)
|
||||||
|
if r.status_code != 200:
|
||||||
|
if attempt == max_retries - 1:
|
||||||
|
return None
|
||||||
|
continue
|
||||||
|
# Check if response is valid JSON (not HTML error page)
|
||||||
|
try:
|
||||||
|
data = r.json()
|
||||||
|
except Exception:
|
||||||
|
# Not JSON – likely a captcha/challenge page; retry with longer delay
|
||||||
|
if attempt == max_retries - 1:
|
||||||
|
print(f"OpenFoodFacts returned non-JSON for '{query}'; giving up")
|
||||||
|
return None
|
||||||
|
continue
|
||||||
|
products = data.get("products", [])
|
||||||
|
if not products:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Take the first reasonably complete product
|
||||||
|
for prod in products:
|
||||||
|
nutr = prod.get("nutriments", {})
|
||||||
|
# We need at least calories/energy; skip if totally empty
|
||||||
|
if not nutr:
|
||||||
|
continue
|
||||||
|
return {
|
||||||
|
"calories": nutr.get("energy-kcal", 0) or 0,
|
||||||
|
"protein": nutr.get("protein", 0) or 0,
|
||||||
|
"fat": nutr.get("fat", 0) or 0,
|
||||||
|
"carbs": nutr.get("carbohydrates", 0) or 0,
|
||||||
|
"sugar": nutr.get("sugars", 0) or 0,
|
||||||
|
"sodium": nutr.get("sodium", 0) or 0,
|
||||||
|
}
|
||||||
|
return None
|
||||||
|
except Exception as exc: # broad – network / JSON issues
|
||||||
|
if attempt == max_retries - 1:
|
||||||
|
print(f"OpenFoodFacts error for '{query}': {exc}")
|
||||||
|
return None
|
||||||
|
continue
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class RecipeParser:
|
||||||
|
"""Parses a markdown recipe string into a structured format."""
|
||||||
|
|
||||||
|
def parse(self, text: str) -> ParsedRecipe:
|
||||||
|
"""Parse a markdown recipe string.
|
||||||
|
|
||||||
|
Returns a ParsedRecipe object with 'servings' and 'ingredients' attributes.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
lines = text.strip().split('\n')
|
||||||
|
|
||||||
|
# Default values
|
||||||
|
servings = 4
|
||||||
|
ingredients = []
|
||||||
|
|
||||||
|
# Simple parsing: look for "Makes X servings" line
|
||||||
|
for line in lines:
|
||||||
|
line_lower = line.strip().lower()
|
||||||
|
if 'makes' in line_lower and 'serving' in line_lower:
|
||||||
|
# Extract number before 'servings'
|
||||||
|
match = re.search(r'(\d+)', line)
|
||||||
|
if match:
|
||||||
|
servings = int(match.group(1))
|
||||||
|
break
|
||||||
|
|
||||||
|
# Also look for ingredient lines (lines starting with '- ')
|
||||||
|
for line in lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith('- '):
|
||||||
|
ingredients.append(stripped[2:])
|
||||||
|
|
||||||
|
return ParsedRecipe(servings=servings, ingredients=ingredients)
|
||||||
|
|
||||||
|
|
||||||
|
class Recipe:
|
||||||
|
"""Represents a parsed recipe with nutrition lookups."""
|
||||||
|
|
||||||
|
def __init__(self, parsed: ParsedRecipe, api: NutritionApi, servings: int = 4):
|
||||||
|
self.parsed = parsed
|
||||||
|
self.api = api
|
||||||
|
self.servings = parsed.servings if parsed else servings
|
||||||
|
self._nutrition = {}
|
||||||
|
|
||||||
|
def _get_ingredient_nutrition(self, ingredient: str) -> Optional[Dict]:
|
||||||
|
"""Look up nutrition for a single ingredient."""
|
||||||
|
# Try exact match first, then fall back
|
||||||
|
result = self.api.lookup(ingredient)
|
||||||
|
if result is not None:
|
||||||
|
self._nutrition[ingredient] = result
|
||||||
|
return result
|
||||||
|
return None
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
"""Return a text summary of the recipe with nutrition info."""
|
||||||
|
lines = []
|
||||||
|
lines.append(f"Recipe Summary")
|
||||||
|
lines.append(f"Servings: {self.servings}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
ingredients = self.parsed.ingredients
|
||||||
|
if ingredients:
|
||||||
|
lines.append("Ingredients:")
|
||||||
|
for ing in ingredients:
|
||||||
|
# Get just the ingredient name (before any measurements/units)
|
||||||
|
ing_name = ing.split(',')[0] if ',' in ing else ing.split()[0] if ing.split() else ing
|
||||||
|
nutrition = self._nutrition.get(ing_name)
|
||||||
|
if nutrition:
|
||||||
|
lines.append(f" - {ing_name}: "
|
||||||
|
f"Cal={nutrition.get('calories', '?')}, "
|
||||||
|
f"Pro={nutrition.get('protein', '?')}g, "
|
||||||
|
f"Fat={nutrition.get('fat', '?')}g, "
|
||||||
|
f"Carbs={nutrition.get('carbs', '?')}g")
|
||||||
|
else:
|
||||||
|
lines.append(f" - {ing_name}: nutrition pending")
|
||||||
|
else:
|
||||||
|
lines.append("No ingredients found")
|
||||||
|
|
||||||
|
return '\n'.join(lines)
|
||||||
|
|
||||||
|
def to_json(self) -> str:
|
||||||
|
"""Return a JSON representation of the recipe."""
|
||||||
|
import json as _json
|
||||||
|
data = {
|
||||||
|
'servings': self.servings,
|
||||||
|
'ingredients': self.parsed.ingredients,
|
||||||
|
'nutrition': self._nutrition,
|
||||||
|
}
|
||||||
|
return _json.dumps(data, indent=2)
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
"""Simple recipe data container returned by RecipeParser."""
|
||||||
|
|
||||||
|
|
||||||
|
class ParsedRecipe:
|
||||||
|
"""Container for parsed recipe data."""
|
||||||
|
def __init__(self, servings: int = 4, ingredients: list = None):
|
||||||
|
self.servings = servings
|
||||||
|
self.ingredients = ingredients if ingredients is not None else []
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Nutritional Recipe Parser - Requirements
|
||||||
|
# External dependencies:
|
||||||
|
requests
|
||||||
|
# Python standard library modules (included):
|
||||||
|
# - json, time, sys, os, re, typing
|
||||||
|
# Python Version: 3.12 or higher
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Vegetarian Frittata
|
||||||
|
|
||||||
|
A delicious egg dish with vegetables, perfect for breakfast or brunch.
|
||||||
|
|
||||||
|
## Ingredients
|
||||||
|
- 4 eggs
|
||||||
|
- 100g spinach
|
||||||
|
- 50g feta cheese
|
||||||
|
- 1 small onion, chopped
|
||||||
|
- 2 cloves garlic, minced
|
||||||
|
- 1 tbsp olive oil
|
||||||
|
- Salt and pepper to taste
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
1. Preheat oven to 180°C (350°F).
|
||||||
|
2. Heat olive oil in a skillet over medium heat.
|
||||||
|
3. Sauté onion and garlic until softened, about 5 minutes.
|
||||||
|
4. Add spinach and cook until wilted, about 2 minutes.
|
||||||
|
5. Whisk eggs in a bowl, season with salt and pepper.
|
||||||
|
6. Pour eggs over the spinach mixture in the skillet.
|
||||||
|
7. Sprinkle feta cheese on top.
|
||||||
|
8. Cook for 2-3 minutes until edges are set.
|
||||||
|
9. Transfer skillet to oven and bake for 10-12 minutes until fully set.
|
||||||
|
10. Slice and serve warm.
|
||||||
|
|
||||||
|
Makes 4 servings.
|
||||||
Reference in new issue
Block a user