Files
N22/python_scripts/mealie_shopping_merge.py
T

291 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Build Mealie shopping list for the Friday-Thursday meal plan window.
Flow:
1) Compute next Friday-Thursday window.
2) Ensure/clear the 'Bilka ToGo' Mealie shopping list.
3) Add all recipes from the meal plan window to the list.
4) Fetch the resulting shopping items and write a styled HTML file for display in HA.
Output:
- /www/bilka_togo_checklist.html (iframe-renderable in HA)
- /www/bilka_togo_checklist.json (machine-readable backup)
"""
from __future__ import annotations
import json
import re
import urllib.request
from collections import defaultdict
from datetime import date, timedelta
from pathlib import Path
ROOT_CANDIDATES = [Path('/Volumes/homeassistant'), Path('/config')]
MEALIE_BASE_URL = 'http://10.0.0.142:9925'
TARGET_SHOPPING_LIST_NAME = 'Bilka ToGo'
CATEGORY_RULES = {
'frugt & grønt': ['banan', 'aeble', 'æble', 'citron', 'lime', 'tomat', 'salat', 'agurk', 'gulerod', 'kartoffel', 'log', 'løg', 'hvidlog', 'hvidløg', 'broccoli', 'spidskal', 'spidskål', 'avocado', 'peberfrugt'],
'kød & fisk': ['kylling', 'oksekød', 'hakket', 'ribeye', 'bacon', 'laks', 'fisk', 'skinke', 'pølse'],
'mejeri & æg': ['mælk', 'yoghurt', 'smør', 'ost', 'feta', 'mozzarella', 'æg', 'fløde', 'creme fraiche'],
'kolonial': ['ris', 'pasta', 'mel', 'havregryn', 'olie', 'eddike', 'krydder', 'bønne', 'linse', 'dåse', 'sukker', 'salt', 'tomatpuré', 'tomatpure', 'nori', 'soja'],
'frost': ['frost', 'frossen', 'is', 'edamame'],
'husholdning': ['toiletpapir', 'køkkenrulle', 'kokkenrulle', 'sæbe', 'sæb', 'opvasketabs', 'vaskemiddel', 'affaldspose'],
}
def detect_root() -> Path:
# Prefer repository-relative root so the script works both in HA (/config)
# and when run locally from the mounted workspace (/Volumes/homeassistant).
local_root = Path(__file__).resolve().parent.parent
if (local_root / 'secrets.yaml').exists():
return local_root
for root in ROOT_CANDIDATES:
if (root / 'secrets.yaml').exists():
return root
raise RuntimeError('Could not locate Home Assistant config root')
def read_bearer_token(secrets_path: Path) -> str:
for line in secrets_path.read_text().splitlines():
if line.strip().startswith('mealie_bearer_token:'):
return line.split(':', 1)[1].strip().strip('"')
raise RuntimeError('mealie_bearer_token not found in secrets.yaml')
def api_get(url: str, token: str) -> dict:
req = urllib.request.Request(url, headers={'Authorization': token})
with urllib.request.urlopen(req, timeout=20) as resp:
raw = resp.read()
return json.loads(raw) if raw else {}
def api_request(
base_url: str,
path: str,
token: str,
method: str = 'GET',
payload: dict | list | None = None,
timeout: int = 90,
):
data = None
headers = {'Authorization': token}
if payload is not None:
data = json.dumps(payload).encode('utf-8')
headers['Content-Type'] = 'application/json'
req = urllib.request.Request(f'{base_url}{path}', headers=headers, data=data, method=method)
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
if not raw:
return {}
try:
return json.loads(raw)
except json.JSONDecodeError:
return {}
def normalize_name(value: str) -> str:
value = value.lower().strip()
value = value.replace('å', 'aa').replace('æ', 'ae').replace('ø', 'oe')
value = re.sub(r'\s+', ' ', value)
value = re.sub(r'[^a-z0-9 ]', '', value)
return value
def classify_category(name: str) -> str:
key = normalize_name(name)
for category, needles in CATEGORY_RULES.items():
for needle in needles:
if needle in key:
return category
return 'andet'
def read_keep_items(_keep_path: Path) -> list[str]: # noqa: ARG001 (kept for backward compat)
return []
def friday_to_thursday_window(today: date) -> tuple[date, date]:
days_until_friday = (4 - today.weekday()) % 7
start = today + timedelta(days=days_until_friday)
end = start + timedelta(days=6)
return start, end
def ensure_shopping_list(base_url: str, token: str, name: str) -> str:
lists = api_request(base_url, '/api/households/shopping/lists?perPage=200', token).get('items', []) or []
for shopping_list in lists:
if (shopping_list.get('name') or '').strip().lower() == name.lower():
return shopping_list['id']
created = api_request(
base_url,
'/api/households/shopping/lists',
token,
method='POST',
payload={'name': name},
)
return created['id']
def reset_shopping_list(base_url: str, token: str, name: str, shopping_list_id: str) -> str:
"""Delete the list and recreate it. Returns new list id."""
api_request(base_url, f'/api/households/shopping/lists/{shopping_list_id}', token, method='DELETE')
created = api_request(
base_url,
'/api/households/shopping/lists',
token,
method='POST',
payload={'name': name},
)
return created['id']
def get_mealplan_recipe_ids(base_url: str, token: str, start_date: date, end_date: date) -> list[str]:
entries: list[dict] = []
current = start_date
while current <= end_date:
path = (
f"/api/households/mealplans?start_date={current.isoformat()}"
f"&end_date={current.isoformat()}&perPage=100"
)
day_payload = api_request(base_url, path, token)
day_items = day_payload.get('items', []) if isinstance(day_payload, dict) else []
entries.extend(day_items or [])
current = current + timedelta(days=1)
result: list[str] = []
seen: set[str] = set()
for entry in entries:
recipe_id = entry.get('recipeId')
if not recipe_id:
continue
if recipe_id in seen:
continue
seen.add(recipe_id)
result.append(recipe_id)
return result
def add_recipes_to_shopping_list(base_url: str, token: str, shopping_list_id: str, recipe_ids: list[str]) -> int:
if not recipe_ids:
return 0
payload = [{'recipeId': rid, 'recipeIncrementQuantity': 1} for rid in recipe_ids]
api_request(
base_url,
f'/api/households/shopping/lists/{shopping_list_id}/recipe',
token,
method='POST',
payload=payload,
)
return len(recipe_ids)
def fetch_mealie_items(base_url: str, token: str, shopping_list_id: str | None = None) -> list[dict]:
data = api_request(base_url, '/api/households/shopping/items?perPage=1000', token)
items = data.get('items', []) or []
if not shopping_list_id:
return items
return [item for item in items if item.get('shoppingListId') == shopping_list_id]
def extract_item_name(item: dict) -> str:
display = (item.get('display') or '').strip()
if display:
return display
food = item.get('food') or {}
return (food.get('name') or '').strip()
def build_items(mealie_items: list[dict]) -> list[dict]:
merged: dict[str, dict] = {}
for item in mealie_items:
name = extract_item_name(item)
if not name:
continue
norm = normalize_name(name)
if norm not in merged:
merged[norm] = {
'name': name,
'category': classify_category(name),
}
result = [v for v in merged.values()]
result.sort(key=lambda x: (x['category'], normalize_name(x['name'])))
return result
def write_outputs(root: Path, items: list[dict], start_date: date, end_date: date) -> None:
www = root / 'www'
www.mkdir(parents=True, exist_ok=True)
payload = {'count': len(items), 'items': items}
(www / 'bilka_togo_checklist.json').write_text(json.dumps(payload, ensure_ascii=False, indent=2))
grouped: dict[str, list[dict]] = defaultdict(list)
for item in items:
grouped[item['category']].append(item)
rows_html = ''
for category in sorted(grouped.keys()):
rows_html += f'<tr><th colspan="2" class="cat">{category.title()}</th></tr>\n'
for item in grouped[category]:
rows_html += f'<tr><td class="cb"><input type="checkbox"></td><td>{item["name"]}</td></tr>\n'
html = f"""<!DOCTYPE html>
<html lang="da">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Bilka ToGo</title>
<style>
body {{ font-family: sans-serif; padding: 8px; background: #1c1c1e; color: #e5e5ea; margin: 0; }}
h1 {{ font-size: 1.1em; margin-bottom: 2px; color: #fff; }}
p.sub {{ font-size: 0.8em; color: #8e8e93; margin: 0 0 10px; }}
table {{ width: 100%; border-collapse: collapse; }}
td, th {{ padding: 5px 6px; text-align: left; border-bottom: 1px solid #2c2c2e; }}
th.cat {{ background: #2c2c2e; color: #ffe066; font-size: 0.85em; text-transform: uppercase; letter-spacing: 0.05em; }}
td.cb {{ width: 28px; }}
input[type=checkbox] {{ width: 18px; height: 18px; accent-color: #30d158; cursor: pointer; }}
tr:hover td {{ background: #2c2c2e; }}
</style>
</head>
<body>
<h1>🛒 Bilka ToGo</h1>
<p class="sub">Plan {start_date.strftime('%d/%m')} {end_date.strftime('%d/%m')} &nbsp;·&nbsp; {len(items)} varer</p>
<table>
{rows_html}</table>
</body>
</html>
"""
(www / 'bilka_togo_checklist.html').write_text(html)
def main() -> None:
root = detect_root()
token = read_bearer_token(root / 'secrets.yaml')
start_date, end_date = friday_to_thursday_window(date.today())
shopping_list_id = ensure_shopping_list(MEALIE_BASE_URL, token, TARGET_SHOPPING_LIST_NAME)
shopping_list_id = reset_shopping_list(MEALIE_BASE_URL, token, TARGET_SHOPPING_LIST_NAME, shopping_list_id)
recipe_ids = get_mealplan_recipe_ids(MEALIE_BASE_URL, token, start_date, end_date)
added_recipes = add_recipes_to_shopping_list(MEALIE_BASE_URL, token, shopping_list_id, recipe_ids)
mealie_items = fetch_mealie_items(MEALIE_BASE_URL, token, shopping_list_id=shopping_list_id)
items = build_items(mealie_items)
write_outputs(root, items, start_date, end_date)
print(
'OK: '
f'window={start_date.isoformat()}..{end_date.isoformat()} '
f'recipes_added={added_recipes} items={len(items)}'
)
if __name__ == '__main__':
main()