#!/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'{category.title()}\n' for item in grouped[category]: rows_html += f'{item["name"]}\n' html = f""" Bilka ToGo

🛒 Bilka ToGo

Plan {start_date.strftime('%d/%m')} – {end_date.strftime('%d/%m')}  ·  {len(items)} varer

{rows_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()