mirror of
https://github.com/hanzoskill/grocery-list.git
synced 2026-10-30 21:01:39 +00:00
887 lines
31 KiB
Python
887 lines
31 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.10"
|
|
# ///
|
|
"""Grocery List & Meal Planner CLI.
|
|
|
|
Self-contained grocery lists, recipes, and meal planning with local JSON storage.
|
|
No external services required.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
import uuid
|
|
from dataclasses import asdict, dataclass, field
|
|
from datetime import datetime, timedelta
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# Default data directory
|
|
DATA_DIR = Path(os.environ.get("GROCERY_DATA_DIR", Path.home() / ".bot" / "grocery-list"))
|
|
DATA_FILE = DATA_DIR / "data.json"
|
|
|
|
# Category auto-detection keywords
|
|
CATEGORY_KEYWORDS: dict[str, list[str]] = {
|
|
"produce": ["apple", "banana", "orange", "lettuce", "tomato", "onion", "potato", "carrot", "celery", "broccoli", "spinach", "avocado", "pepper", "cucumber", "garlic", "lemon", "lime", "strawberry", "blueberry", "grape", "mango", "pineapple", "watermelon", "mushroom", "corn", "cabbage", "kale", "asparagus", "zucchini", "squash"],
|
|
"dairy": ["milk", "cheese", "yogurt", "butter", "cream", "egg", "eggs", "sour cream", "cottage cheese", "half and half", "creamer", "whipping cream"],
|
|
"meat": ["chicken", "beef", "pork", "turkey", "bacon", "sausage", "ham", "steak", "ground beef", "ground turkey", "lamb", "veal", "brisket", "ribs"],
|
|
"seafood": ["fish", "salmon", "tuna", "shrimp", "crab", "lobster", "tilapia", "cod", "halibut", "scallops", "mussels", "clams"],
|
|
"bakery": ["bread", "rolls", "bagel", "muffin", "croissant", "baguette", "tortilla", "pita", "english muffin", "bun", "buns"],
|
|
"frozen": ["ice cream", "frozen pizza", "frozen vegetables", "frozen fruit", "frozen dinner", "popsicle", "frozen waffles"],
|
|
"pantry": ["pasta", "rice", "beans", "soup", "canned", "cereal", "oatmeal", "flour", "sugar", "olive oil", "vegetable oil", "vinegar", "soy sauce", "peanut butter", "jelly", "honey", "syrup", "salt", "pepper", "spice"],
|
|
"beverages": ["water", "soda", "juice", "coffee", "tea", "wine", "beer", "lemonade", "gatorade", "energy drink", "sparkling water", "kombucha"],
|
|
"snacks": ["chips", "crackers", "cookies", "popcorn", "nuts", "pretzels", "granola bar", "candy", "chocolate"],
|
|
"household": ["paper towel", "toilet paper", "dish soap", "laundry detergent", "trash bags", "aluminum foil", "plastic wrap", "napkins", "sponge", "cleaner"],
|
|
"personal": ["shampoo", "conditioner", "soap", "toothpaste", "deodorant", "lotion", "razor", "medicine", "vitamins", "bandaid"],
|
|
}
|
|
|
|
ALL_CATEGORIES = list(CATEGORY_KEYWORDS.keys()) + ["other"]
|
|
|
|
|
|
@dataclass
|
|
class GroceryItem:
|
|
id: str
|
|
name: str
|
|
list_name: str
|
|
category: str = "other"
|
|
quantity: float = 1.0
|
|
unit: str = ""
|
|
assignee: str = ""
|
|
checked: bool = False
|
|
added: str = ""
|
|
|
|
def to_dict(self) -> dict:
|
|
return asdict(self)
|
|
|
|
@classmethod
|
|
def from_dict(cls, d: dict) -> "GroceryItem":
|
|
return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
|
|
|
|
|
|
@dataclass
|
|
class Recipe:
|
|
id: str
|
|
name: str
|
|
ingredients: list[str] = field(default_factory=list)
|
|
instructions: str = ""
|
|
category: str = ""
|
|
servings: int = 4
|
|
prep_time: str = ""
|
|
cook_time: str = ""
|
|
notes: str = ""
|
|
created: str = ""
|
|
|
|
def to_dict(self) -> dict:
|
|
return asdict(self)
|
|
|
|
@classmethod
|
|
def from_dict(cls, d: dict) -> "Recipe":
|
|
return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
|
|
|
|
|
|
@dataclass
|
|
class Meal:
|
|
id: str
|
|
date: str # YYYY-MM-DD
|
|
type: str # breakfast, lunch, dinner, snack
|
|
recipe_name: str = ""
|
|
notes: str = ""
|
|
|
|
def to_dict(self) -> dict:
|
|
return asdict(self)
|
|
|
|
@classmethod
|
|
def from_dict(cls, d: dict) -> "Meal":
|
|
return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
|
|
|
|
|
|
@dataclass
|
|
class DataStore:
|
|
lists: list[str] = field(default_factory=lambda: ["Grocery"])
|
|
items: list[GroceryItem] = field(default_factory=list)
|
|
recipes: list[Recipe] = field(default_factory=list)
|
|
meals: list[Meal] = field(default_factory=list)
|
|
family: list[str] = field(default_factory=list)
|
|
|
|
def to_dict(self) -> dict:
|
|
return {
|
|
"lists": self.lists,
|
|
"items": [i.to_dict() for i in self.items],
|
|
"recipes": [r.to_dict() for r in self.recipes],
|
|
"meals": [m.to_dict() for m in self.meals],
|
|
"family": self.family,
|
|
}
|
|
|
|
@classmethod
|
|
def from_dict(cls, d: dict) -> "DataStore":
|
|
return cls(
|
|
lists=d.get("lists", ["Grocery"]),
|
|
items=[GroceryItem.from_dict(i) for i in d.get("items", [])],
|
|
recipes=[Recipe.from_dict(r) for r in d.get("recipes", [])],
|
|
meals=[Meal.from_dict(m) for m in d.get("meals", [])],
|
|
family=d.get("family", []),
|
|
)
|
|
|
|
|
|
def load_data() -> DataStore:
|
|
"""Load data from JSON file."""
|
|
if not DATA_FILE.exists():
|
|
return DataStore()
|
|
try:
|
|
with open(DATA_FILE) as f:
|
|
return DataStore.from_dict(json.load(f))
|
|
except (json.JSONDecodeError, KeyError):
|
|
return DataStore()
|
|
|
|
|
|
def save_data(data: DataStore) -> None:
|
|
"""Save data to JSON file."""
|
|
DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|
with open(DATA_FILE, "w") as f:
|
|
json.dump(data.to_dict(), f, indent=2, sort_keys=True)
|
|
|
|
|
|
def detect_category(name: str) -> str:
|
|
"""Auto-detect category based on item name."""
|
|
name_lower = name.lower()
|
|
for category, keywords in CATEGORY_KEYWORDS.items():
|
|
for kw in keywords:
|
|
if kw in name_lower:
|
|
return category
|
|
return "other"
|
|
|
|
|
|
def parse_quantity(qty_str: str) -> tuple[float, str]:
|
|
"""Parse '2 gallons' into (2.0, 'gallon')."""
|
|
if not qty_str:
|
|
return (1.0, "")
|
|
parts = qty_str.strip().split(None, 1)
|
|
if len(parts) == 1:
|
|
try:
|
|
return (float(parts[0]), "")
|
|
except ValueError:
|
|
return (1.0, parts[0])
|
|
try:
|
|
num = float(parts[0])
|
|
unit = parts[1].rstrip("s") # Normalize plural
|
|
return (num, unit)
|
|
except ValueError:
|
|
return (1.0, qty_str)
|
|
|
|
|
|
def now_iso() -> str:
|
|
"""Get current ISO timestamp."""
|
|
return datetime.now().strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
|
|
|
|
def today_str() -> str:
|
|
"""Get today's date as YYYY-MM-DD."""
|
|
return datetime.now().strftime("%Y-%m-%d")
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# Commands
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
def cmd_lists(args: argparse.Namespace) -> int:
|
|
"""List all shopping lists."""
|
|
data = load_data()
|
|
if args.json:
|
|
print(json.dumps({"lists": data.lists}, indent=2))
|
|
else:
|
|
print("📋 Shopping Lists:")
|
|
for lst in data.lists:
|
|
count = sum(1 for i in data.items if i.list_name == lst and not i.checked)
|
|
print(f" • {lst} ({count} items)")
|
|
return 0
|
|
|
|
|
|
def cmd_list_view(args: argparse.Namespace) -> int:
|
|
"""View items in a specific list."""
|
|
data = load_data()
|
|
list_name = args.list_name
|
|
|
|
if list_name not in data.lists:
|
|
print(f"❌ List '{list_name}' not found. Available: {', '.join(data.lists)}", file=sys.stderr)
|
|
return 1
|
|
|
|
items = [i for i in data.items if i.list_name == list_name]
|
|
if args.unchecked:
|
|
items = [i for i in items if not i.checked]
|
|
|
|
if args.json:
|
|
print(json.dumps({"list": list_name, "items": [i.to_dict() for i in items]}, indent=2))
|
|
return 0
|
|
|
|
# Group by category
|
|
by_category: dict[str, list[GroceryItem]] = {}
|
|
for item in items:
|
|
by_category.setdefault(item.category, []).append(item)
|
|
|
|
print(f"🛒 {list_name}:")
|
|
if not items:
|
|
print(" (empty)")
|
|
return 0
|
|
|
|
for cat in ALL_CATEGORIES:
|
|
if cat not in by_category:
|
|
continue
|
|
print(f"\n [{cat.upper()}]")
|
|
for item in by_category[cat]:
|
|
check = "✓" if item.checked else "○"
|
|
qty = f"{item.quantity:g}" if item.quantity != 1 else ""
|
|
unit = f" {item.unit}" if item.unit else ""
|
|
assignee = f" (@{item.assignee})" if item.assignee else ""
|
|
print(f" {check} {qty}{unit} {item.name}{assignee}")
|
|
return 0
|
|
|
|
|
|
def cmd_list_create(args: argparse.Namespace) -> int:
|
|
"""Create a new list."""
|
|
data = load_data()
|
|
name = args.name
|
|
if name in data.lists:
|
|
print(f"⚠️ List '{name}' already exists.", file=sys.stderr)
|
|
return 1
|
|
data.lists.append(name)
|
|
save_data(data)
|
|
print(f"✅ Created list '{name}'")
|
|
return 0
|
|
|
|
|
|
def cmd_list_delete(args: argparse.Namespace) -> int:
|
|
"""Delete a list and its items."""
|
|
data = load_data()
|
|
name = args.name
|
|
if name not in data.lists:
|
|
print(f"❌ List '{name}' not found.", file=sys.stderr)
|
|
return 1
|
|
data.lists.remove(name)
|
|
data.items = [i for i in data.items if i.list_name != name]
|
|
save_data(data)
|
|
print(f"🗑️ Deleted list '{name}'")
|
|
return 0
|
|
|
|
|
|
def cmd_add(args: argparse.Namespace) -> int:
|
|
"""Add item(s) to a list."""
|
|
data = load_data()
|
|
list_name = args.list_name
|
|
|
|
if list_name not in data.lists:
|
|
data.lists.append(list_name)
|
|
|
|
for item_name in args.items:
|
|
qty, unit = parse_quantity(args.qty) if args.qty else (1.0, "")
|
|
category = args.category or detect_category(item_name)
|
|
|
|
item = GroceryItem(
|
|
id=str(uuid.uuid4())[:8],
|
|
name=item_name,
|
|
list_name=list_name,
|
|
category=category,
|
|
quantity=qty,
|
|
unit=unit,
|
|
assignee=args.assignee or "",
|
|
checked=False,
|
|
added=now_iso(),
|
|
)
|
|
data.items.append(item)
|
|
print(f"✅ Added '{item_name}' to {list_name}")
|
|
|
|
save_data(data)
|
|
return 0
|
|
|
|
|
|
def cmd_check(args: argparse.Namespace) -> int:
|
|
"""Mark item as checked."""
|
|
data = load_data()
|
|
found = False
|
|
for item in data.items:
|
|
if item.list_name == args.list_name and item.name.lower() == args.item.lower():
|
|
item.checked = True
|
|
found = True
|
|
print(f"✓ Checked off '{item.name}'")
|
|
break
|
|
if not found:
|
|
print(f"❌ Item '{args.item}' not found in '{args.list_name}'", file=sys.stderr)
|
|
return 1
|
|
save_data(data)
|
|
return 0
|
|
|
|
|
|
def cmd_uncheck(args: argparse.Namespace) -> int:
|
|
"""Mark item as unchecked."""
|
|
data = load_data()
|
|
found = False
|
|
for item in data.items:
|
|
if item.list_name == args.list_name and item.name.lower() == args.item.lower():
|
|
item.checked = False
|
|
found = True
|
|
print(f"○ Unchecked '{item.name}'")
|
|
break
|
|
if not found:
|
|
print(f"❌ Item '{args.item}' not found in '{args.list_name}'", file=sys.stderr)
|
|
return 1
|
|
save_data(data)
|
|
return 0
|
|
|
|
|
|
def cmd_remove(args: argparse.Namespace) -> int:
|
|
"""Remove item from list."""
|
|
data = load_data()
|
|
before = len(data.items)
|
|
data.items = [i for i in data.items if not (i.list_name == args.list_name and i.name.lower() == args.item.lower())]
|
|
if len(data.items) == before:
|
|
print(f"❌ Item '{args.item}' not found in '{args.list_name}'", file=sys.stderr)
|
|
return 1
|
|
save_data(data)
|
|
print(f"🗑️ Removed '{args.item}' from {args.list_name}")
|
|
return 0
|
|
|
|
|
|
def cmd_clear(args: argparse.Namespace) -> int:
|
|
"""Clear all checked items from a list."""
|
|
data = load_data()
|
|
before = len(data.items)
|
|
data.items = [i for i in data.items if not (i.list_name == args.list_name and i.checked)]
|
|
cleared = before - len(data.items)
|
|
save_data(data)
|
|
print(f"🗑️ Cleared {cleared} checked items from {args.list_name}")
|
|
return 0
|
|
|
|
|
|
# ─── Recipes ─────────────────────────────────────────────────────────────────
|
|
|
|
def cmd_recipes(args: argparse.Namespace) -> int:
|
|
"""List all recipes."""
|
|
data = load_data()
|
|
recipes = data.recipes
|
|
|
|
if args.category:
|
|
recipes = [r for r in recipes if r.category.lower() == args.category.lower()]
|
|
|
|
if args.json:
|
|
print(json.dumps({"recipes": [r.to_dict() for r in recipes]}, indent=2))
|
|
return 0
|
|
|
|
print("📖 Recipes:")
|
|
if not recipes:
|
|
print(" (none saved)")
|
|
return 0
|
|
|
|
by_cat: dict[str, list[Recipe]] = {}
|
|
for r in recipes:
|
|
by_cat.setdefault(r.category or "Uncategorized", []).append(r)
|
|
|
|
for cat, rlist in sorted(by_cat.items()):
|
|
print(f"\n [{cat}]")
|
|
for r in rlist:
|
|
print(f" • {r.name} ({r.servings} servings)")
|
|
return 0
|
|
|
|
|
|
def cmd_recipe_view(args: argparse.Namespace) -> int:
|
|
"""View a specific recipe."""
|
|
data = load_data()
|
|
name = args.name.lower()
|
|
recipe = next((r for r in data.recipes if r.name.lower() == name), None)
|
|
|
|
if not recipe:
|
|
print(f"❌ Recipe '{args.name}' not found.", file=sys.stderr)
|
|
return 1
|
|
|
|
if args.json:
|
|
print(json.dumps(recipe.to_dict(), indent=2))
|
|
return 0
|
|
|
|
if args.ingredients_only:
|
|
print(f"Ingredients for {recipe.name}:")
|
|
for ing in recipe.ingredients:
|
|
print(f" • {ing}")
|
|
return 0
|
|
|
|
print(f"📖 {recipe.name}")
|
|
if recipe.category:
|
|
print(f" Category: {recipe.category}")
|
|
print(f" Servings: {recipe.servings}")
|
|
if recipe.prep_time or recipe.cook_time:
|
|
print(f" Time: {recipe.prep_time} prep / {recipe.cook_time} cook")
|
|
print("\n Ingredients:")
|
|
for ing in recipe.ingredients:
|
|
print(f" • {ing}")
|
|
if recipe.instructions:
|
|
print(f"\n Instructions:\n {recipe.instructions}")
|
|
if recipe.notes:
|
|
print(f"\n Notes: {recipe.notes}")
|
|
return 0
|
|
|
|
|
|
def cmd_recipe_add(args: argparse.Namespace) -> int:
|
|
"""Add a new recipe."""
|
|
data = load_data()
|
|
|
|
# Check if exists
|
|
existing = next((r for r in data.recipes if r.name.lower() == args.name.lower()), None)
|
|
if existing:
|
|
print(f"⚠️ Recipe '{args.name}' already exists. Use a different name or delete first.", file=sys.stderr)
|
|
return 1
|
|
|
|
ingredients = []
|
|
if args.ingredients:
|
|
ingredients = [i.strip() for i in args.ingredients.split(",")]
|
|
|
|
recipe = Recipe(
|
|
id=str(uuid.uuid4())[:8],
|
|
name=args.name,
|
|
ingredients=ingredients,
|
|
instructions=args.instructions or "",
|
|
category=args.category or "",
|
|
servings=args.servings or 4,
|
|
prep_time=args.prep_time or "",
|
|
cook_time=args.cook_time or "",
|
|
notes=args.notes or "",
|
|
created=now_iso(),
|
|
)
|
|
data.recipes.append(recipe)
|
|
save_data(data)
|
|
print(f"✅ Added recipe '{args.name}' with {len(ingredients)} ingredients")
|
|
return 0
|
|
|
|
|
|
def cmd_recipe_delete(args: argparse.Namespace) -> int:
|
|
"""Delete a recipe."""
|
|
data = load_data()
|
|
before = len(data.recipes)
|
|
data.recipes = [r for r in data.recipes if r.name.lower() != args.name.lower()]
|
|
if len(data.recipes) == before:
|
|
print(f"❌ Recipe '{args.name}' not found.", file=sys.stderr)
|
|
return 1
|
|
save_data(data)
|
|
print(f"🗑️ Deleted recipe '{args.name}'")
|
|
return 0
|
|
|
|
|
|
def cmd_recipe_search(args: argparse.Namespace) -> int:
|
|
"""Search recipes."""
|
|
data = load_data()
|
|
query = args.query.lower()
|
|
matches = [r for r in data.recipes if query in r.name.lower() or query in " ".join(r.ingredients).lower()]
|
|
|
|
if args.json:
|
|
print(json.dumps({"results": [r.to_dict() for r in matches]}, indent=2))
|
|
return 0
|
|
|
|
print(f"🔍 Recipes matching '{args.query}':")
|
|
if not matches:
|
|
print(" (no matches)")
|
|
return 0
|
|
for r in matches:
|
|
print(f" • {r.name} ({len(r.ingredients)} ingredients)")
|
|
return 0
|
|
|
|
|
|
# ─── Meals ───────────────────────────────────────────────────────────────────
|
|
|
|
def cmd_meals(args: argparse.Namespace) -> int:
|
|
"""Show meal plan."""
|
|
data = load_data()
|
|
|
|
# Default to this week
|
|
today = datetime.now()
|
|
start = today - timedelta(days=today.weekday()) # Monday
|
|
end = start + timedelta(days=6) # Sunday
|
|
|
|
if args.date:
|
|
target = datetime.strptime(args.date, "%Y-%m-%d")
|
|
start = target - timedelta(days=target.weekday())
|
|
end = start + timedelta(days=6)
|
|
|
|
start_str = start.strftime("%Y-%m-%d")
|
|
end_str = end.strftime("%Y-%m-%d")
|
|
|
|
meals = [m for m in data.meals if start_str <= m.date <= end_str]
|
|
meals.sort(key=lambda m: (m.date, ["breakfast", "lunch", "dinner", "snack"].index(m.type) if m.type in ["breakfast", "lunch", "dinner", "snack"] else 4))
|
|
|
|
if args.json:
|
|
print(json.dumps({"week_start": start_str, "week_end": end_str, "meals": [m.to_dict() for m in meals]}, indent=2))
|
|
return 0
|
|
|
|
print(f"📅 Meal Plan ({start_str} to {end_str}):")
|
|
if not meals:
|
|
print(" (no meals planned)")
|
|
return 0
|
|
|
|
current_date = ""
|
|
for m in meals:
|
|
if m.date != current_date:
|
|
current_date = m.date
|
|
day_name = datetime.strptime(m.date, "%Y-%m-%d").strftime("%A %b %d")
|
|
print(f"\n {day_name}:")
|
|
print(f" {m.type.capitalize()}: {m.recipe_name or '(unplanned)'}{' - ' + m.notes if m.notes else ''}")
|
|
return 0
|
|
|
|
|
|
def cmd_meal_add(args: argparse.Namespace) -> int:
|
|
"""Add a meal to the plan."""
|
|
data = load_data()
|
|
|
|
date = args.date or today_str()
|
|
meal_type = args.type.lower()
|
|
|
|
if meal_type not in ["breakfast", "lunch", "dinner", "snack"]:
|
|
print(f"❌ Invalid meal type '{meal_type}'. Use: breakfast, lunch, dinner, snack", file=sys.stderr)
|
|
return 1
|
|
|
|
# Check for existing
|
|
existing = next((m for m in data.meals if m.date == date and m.type == meal_type), None)
|
|
if existing:
|
|
existing.recipe_name = args.recipe or ""
|
|
existing.notes = args.notes or ""
|
|
print(f"✅ Updated {meal_type} for {date}: {args.recipe or '(cleared)'}")
|
|
else:
|
|
meal = Meal(
|
|
id=str(uuid.uuid4())[:8],
|
|
date=date,
|
|
type=meal_type,
|
|
recipe_name=args.recipe or "",
|
|
notes=args.notes or "",
|
|
)
|
|
data.meals.append(meal)
|
|
print(f"✅ Added {meal_type} for {date}: {args.recipe or '(open)'}")
|
|
|
|
save_data(data)
|
|
return 0
|
|
|
|
|
|
def cmd_meal_remove(args: argparse.Namespace) -> int:
|
|
"""Remove a meal from the plan."""
|
|
data = load_data()
|
|
date = args.date or today_str()
|
|
meal_type = args.type.lower()
|
|
|
|
before = len(data.meals)
|
|
data.meals = [m for m in data.meals if not (m.date == date and m.type == meal_type)]
|
|
if len(data.meals) == before:
|
|
print(f"❌ No {meal_type} found for {date}", file=sys.stderr)
|
|
return 1
|
|
save_data(data)
|
|
print(f"🗑️ Removed {meal_type} for {date}")
|
|
return 0
|
|
|
|
|
|
def cmd_meal_add_to_list(args: argparse.Namespace) -> int:
|
|
"""Add meal ingredients to a grocery list."""
|
|
data = load_data()
|
|
date = args.date or today_str()
|
|
list_name = args.list or "Grocery"
|
|
|
|
# Find meals for this date
|
|
meals_today = [m for m in data.meals if m.date == date and m.recipe_name]
|
|
if not meals_today:
|
|
print(f"❌ No meals with recipes found for {date}", file=sys.stderr)
|
|
return 1
|
|
|
|
if list_name not in data.lists:
|
|
data.lists.append(list_name)
|
|
|
|
added = 0
|
|
for meal in meals_today:
|
|
recipe = next((r for r in data.recipes if r.name.lower() == meal.recipe_name.lower()), None)
|
|
if not recipe:
|
|
print(f"⚠️ Recipe '{meal.recipe_name}' not found, skipping")
|
|
continue
|
|
|
|
for ing in recipe.ingredients:
|
|
# Check if already in list
|
|
exists = any(i.list_name == list_name and i.name.lower() == ing.lower() and not i.checked for i in data.items)
|
|
if exists:
|
|
continue
|
|
|
|
item = GroceryItem(
|
|
id=str(uuid.uuid4())[:8],
|
|
name=ing,
|
|
list_name=list_name,
|
|
category=detect_category(ing),
|
|
quantity=1.0,
|
|
unit="",
|
|
assignee="",
|
|
checked=False,
|
|
added=now_iso(),
|
|
)
|
|
data.items.append(item)
|
|
added += 1
|
|
|
|
save_data(data)
|
|
print(f"✅ Added {added} ingredients to {list_name}")
|
|
return 0
|
|
|
|
|
|
# ─── Utility ─────────────────────────────────────────────────────────────────
|
|
|
|
def cmd_stats(args: argparse.Namespace) -> int:
|
|
"""Show quick statistics."""
|
|
data = load_data()
|
|
|
|
total_items = len([i for i in data.items if not i.checked])
|
|
total_recipes = len(data.recipes)
|
|
|
|
# This week's meals
|
|
today = datetime.now()
|
|
start = today - timedelta(days=today.weekday())
|
|
start_str = start.strftime("%Y-%m-%d")
|
|
end_str = (start + timedelta(days=6)).strftime("%Y-%m-%d")
|
|
week_meals = len([m for m in data.meals if start_str <= m.date <= end_str])
|
|
|
|
if args.json:
|
|
print(json.dumps({
|
|
"lists": len(data.lists),
|
|
"unchecked_items": total_items,
|
|
"recipes": total_recipes,
|
|
"meals_this_week": week_meals,
|
|
}, indent=2))
|
|
return 0
|
|
|
|
print("📊 Grocery Stats:")
|
|
print(f" Lists: {len(data.lists)}")
|
|
print(f" Items to buy: {total_items}")
|
|
print(f" Saved recipes: {total_recipes}")
|
|
print(f" Meals planned this week: {week_meals}")
|
|
return 0
|
|
|
|
|
|
def cmd_notify(args: argparse.Namespace) -> int:
|
|
"""Output notification for heartbeat/cron."""
|
|
data = load_data()
|
|
|
|
alerts = []
|
|
|
|
# Items to buy
|
|
unchecked = [i for i in data.items if not i.checked]
|
|
if unchecked:
|
|
by_list: dict[str, int] = {}
|
|
for i in unchecked:
|
|
by_list[i.list_name] = by_list.get(i.list_name, 0) + 1
|
|
for lst, count in by_list.items():
|
|
if count >= 5:
|
|
alerts.append(f"🛒 {lst} has {count} items")
|
|
|
|
# Unplanned meals this week
|
|
today = datetime.now()
|
|
for i in range(7):
|
|
check_date = (today + timedelta(days=i)).strftime("%Y-%m-%d")
|
|
dinner = next((m for m in data.meals if m.date == check_date and m.type == "dinner"), None)
|
|
if not dinner or not dinner.recipe_name:
|
|
day_name = (today + timedelta(days=i)).strftime("%A")
|
|
if i == 0:
|
|
alerts.append(f"🍽️ No dinner planned for tonight!")
|
|
elif i <= 2:
|
|
alerts.append(f"🍽️ No dinner planned for {day_name}")
|
|
break # Only alert for first unplanned
|
|
|
|
if args.json:
|
|
print(json.dumps({"alerts": alerts}, indent=2))
|
|
return 0
|
|
|
|
if not alerts:
|
|
print("HEARTBEAT_OK")
|
|
else:
|
|
for a in alerts:
|
|
print(a)
|
|
return 0
|
|
|
|
|
|
def cmd_family(args: argparse.Namespace) -> int:
|
|
"""Manage family members."""
|
|
data = load_data()
|
|
|
|
if args.add:
|
|
if args.add not in data.family:
|
|
data.family.append(args.add)
|
|
save_data(data)
|
|
print(f"✅ Added {args.add} to family")
|
|
else:
|
|
print(f"⚠️ {args.add} already in family")
|
|
return 0
|
|
|
|
if args.remove:
|
|
if args.remove in data.family:
|
|
data.family.remove(args.remove)
|
|
save_data(data)
|
|
print(f"🗑️ Removed {args.remove} from family")
|
|
else:
|
|
print(f"❌ {args.remove} not in family")
|
|
return 1
|
|
return 0
|
|
|
|
if args.json:
|
|
print(json.dumps({"family": data.family}, indent=2))
|
|
else:
|
|
print("👨👩👧👦 Family Members:")
|
|
if not data.family:
|
|
print(" (none added)")
|
|
for name in data.family:
|
|
print(f" • {name}")
|
|
return 0
|
|
|
|
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
# CLI Setup
|
|
# ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(
|
|
description="Grocery List & Meal Planner",
|
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
)
|
|
subparsers = parser.add_subparsers(dest="command", help="Commands")
|
|
|
|
# lists
|
|
p_lists = subparsers.add_parser("lists", help="Show all shopping lists")
|
|
p_lists.add_argument("--json", action="store_true", help="JSON output")
|
|
p_lists.set_defaults(func=cmd_lists)
|
|
|
|
# list <name>
|
|
p_list = subparsers.add_parser("list", help="View or manage a list")
|
|
p_list.add_argument("list_name", help="List name")
|
|
p_list.add_argument("--unchecked", "-u", action="store_true", help="Only unchecked")
|
|
p_list.add_argument("--json", action="store_true")
|
|
p_list.set_defaults(func=cmd_list_view)
|
|
|
|
# list-create
|
|
p_list_create = subparsers.add_parser("list-create", help="Create a new list")
|
|
p_list_create.add_argument("name", help="List name")
|
|
p_list_create.set_defaults(func=cmd_list_create)
|
|
|
|
# list-delete
|
|
p_list_delete = subparsers.add_parser("list-delete", help="Delete a list")
|
|
p_list_delete.add_argument("name", help="List name")
|
|
p_list_delete.set_defaults(func=cmd_list_delete)
|
|
|
|
# add
|
|
p_add = subparsers.add_parser("add", help="Add item(s) to a list")
|
|
p_add.add_argument("list_name", help="List name")
|
|
p_add.add_argument("items", nargs="+", help="Item name(s)")
|
|
p_add.add_argument("--category", "-c", help="Category")
|
|
p_add.add_argument("--qty", "-q", help="Quantity (e.g. '2 gallons')")
|
|
p_add.add_argument("--assignee", "-a", help="Assign to family member")
|
|
p_add.set_defaults(func=cmd_add)
|
|
|
|
# check
|
|
p_check = subparsers.add_parser("check", help="Mark item as checked")
|
|
p_check.add_argument("list_name")
|
|
p_check.add_argument("item")
|
|
p_check.set_defaults(func=cmd_check)
|
|
|
|
# uncheck
|
|
p_uncheck = subparsers.add_parser("uncheck", help="Uncheck item")
|
|
p_uncheck.add_argument("list_name")
|
|
p_uncheck.add_argument("item")
|
|
p_uncheck.set_defaults(func=cmd_uncheck)
|
|
|
|
# remove
|
|
p_remove = subparsers.add_parser("remove", help="Remove item from list")
|
|
p_remove.add_argument("list_name")
|
|
p_remove.add_argument("item")
|
|
p_remove.set_defaults(func=cmd_remove)
|
|
|
|
# clear
|
|
p_clear = subparsers.add_parser("clear", help="Clear checked items")
|
|
p_clear.add_argument("list_name")
|
|
p_clear.set_defaults(func=cmd_clear)
|
|
|
|
# recipes
|
|
p_recipes = subparsers.add_parser("recipes", help="List all recipes")
|
|
p_recipes.add_argument("--category", "-c", help="Filter by category")
|
|
p_recipes.add_argument("--json", action="store_true")
|
|
p_recipes.set_defaults(func=cmd_recipes)
|
|
|
|
# recipe <name>
|
|
p_recipe = subparsers.add_parser("recipe", help="View a recipe")
|
|
p_recipe.add_argument("name", help="Recipe name")
|
|
p_recipe.add_argument("--ingredients-only", "-i", action="store_true")
|
|
p_recipe.add_argument("--json", action="store_true")
|
|
p_recipe.set_defaults(func=cmd_recipe_view)
|
|
|
|
# recipe-add
|
|
p_recipe_add = subparsers.add_parser("recipe-add", help="Add a recipe")
|
|
p_recipe_add.add_argument("name", help="Recipe name")
|
|
p_recipe_add.add_argument("--ingredients", help="Comma-separated ingredients")
|
|
p_recipe_add.add_argument("--instructions", help="Cooking instructions")
|
|
p_recipe_add.add_argument("--category", help="Category (e.g. Mexican, Italian)")
|
|
p_recipe_add.add_argument("--servings", type=int, default=4)
|
|
p_recipe_add.add_argument("--prep-time", help="Prep time")
|
|
p_recipe_add.add_argument("--cook-time", help="Cook time")
|
|
p_recipe_add.add_argument("--notes", help="Notes")
|
|
p_recipe_add.set_defaults(func=cmd_recipe_add)
|
|
|
|
# recipe-delete
|
|
p_recipe_del = subparsers.add_parser("recipe-delete", help="Delete a recipe")
|
|
p_recipe_del.add_argument("name")
|
|
p_recipe_del.set_defaults(func=cmd_recipe_delete)
|
|
|
|
# recipe-search
|
|
p_recipe_search = subparsers.add_parser("recipe-search", help="Search recipes")
|
|
p_recipe_search.add_argument("query")
|
|
p_recipe_search.add_argument("--json", action="store_true")
|
|
p_recipe_search.set_defaults(func=cmd_recipe_search)
|
|
|
|
# meals
|
|
p_meals = subparsers.add_parser("meals", help="Show meal plan")
|
|
p_meals.add_argument("--date", "-d", help="Week containing date (YYYY-MM-DD)")
|
|
p_meals.add_argument("--json", action="store_true")
|
|
p_meals.set_defaults(func=cmd_meals)
|
|
|
|
# meal-add
|
|
p_meal_add = subparsers.add_parser("meal-add", help="Add meal to plan")
|
|
p_meal_add.add_argument("--date", "-d", help="Date (YYYY-MM-DD, default: today)")
|
|
p_meal_add.add_argument("--type", "-t", required=True, help="breakfast/lunch/dinner/snack")
|
|
p_meal_add.add_argument("--recipe", "-r", help="Recipe name")
|
|
p_meal_add.add_argument("--notes", "-n", help="Notes")
|
|
p_meal_add.set_defaults(func=cmd_meal_add)
|
|
|
|
# meal-remove
|
|
p_meal_rm = subparsers.add_parser("meal-remove", help="Remove meal from plan")
|
|
p_meal_rm.add_argument("--date", "-d", help="Date")
|
|
p_meal_rm.add_argument("--type", "-t", required=True)
|
|
p_meal_rm.set_defaults(func=cmd_meal_remove)
|
|
|
|
# meal-add-to-list
|
|
p_meal_list = subparsers.add_parser("meal-add-to-list", help="Add meal ingredients to grocery list")
|
|
p_meal_list.add_argument("--date", "-d", help="Date")
|
|
p_meal_list.add_argument("--list", "-l", default="Grocery", help="Target list")
|
|
p_meal_list.set_defaults(func=cmd_meal_add_to_list)
|
|
|
|
# stats
|
|
p_stats = subparsers.add_parser("stats", help="Show statistics")
|
|
p_stats.add_argument("--json", action="store_true")
|
|
p_stats.set_defaults(func=cmd_stats)
|
|
|
|
# notify
|
|
p_notify = subparsers.add_parser("notify", help="Output alerts for heartbeat/cron")
|
|
p_notify.add_argument("--json", action="store_true")
|
|
p_notify.set_defaults(func=cmd_notify)
|
|
|
|
# family
|
|
p_family = subparsers.add_parser("family", help="Manage family members")
|
|
p_family.add_argument("--add", help="Add member")
|
|
p_family.add_argument("--remove", help="Remove member")
|
|
p_family.add_argument("--json", action="store_true")
|
|
p_family.set_defaults(func=cmd_family)
|
|
|
|
args = parser.parse_args()
|
|
|
|
if not args.command:
|
|
parser.print_help()
|
|
return 0
|
|
|
|
return args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|