#!/usr/bin/env python3 """Merge Mealie shopping items with a local Google Keep base list. Output: - /www/bilka_togo_checklist.md - /www/bilka_togo_checklist_bilka.md - /www/bilka_togo_checklist.json """ from __future__ import annotations import json import re import urllib.request from collections import defaultdict from pathlib import Path ROOT_CANDIDATES = [Path('/Volumes/homeassistant'), Path('/config')] 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 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]: if not keep_path.exists(): return [] items: list[str] = [] for line in keep_path.read_text().splitlines(): stripped = line.strip() if not stripped or stripped.startswith('#'): continue items.append(stripped) return items def fetch_mealie_items(base_url: str, token: str) -> list[dict]: data = api_get(f'{base_url}/api/households/shopping/items?perPage=500', token) return data.get('items', []) or [] 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 merge_items(mealie_items: list[dict], keep_items: list[str]) -> 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), 'sources': set(), } merged[norm]['sources'].add('Mealie') for name in keep_items: norm = normalize_name(name) if norm not in merged: merged[norm] = { 'name': name, 'category': classify_category(name), 'sources': set(), } merged[norm]['sources'].add('Google Keep') result = [] for _, row in merged.items(): result.append( { 'name': row['name'], 'category': row['category'], 'sources': sorted(row['sources']), } ) result.sort(key=lambda x: (x['category'], normalize_name(x['name']))) return result def write_outputs(root: Path, items: list[dict]) -> 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) lines = [ '# Bilka ToGo - Kryds-af-liste', '', 'Gå listen igennem derhjemme først, og bestil kun de varer du mangler.', '', ] for category in sorted(grouped.keys()): lines.append(f'## {category.title()}') for item in grouped[category]: sources = '/'.join(item['sources']) lines.append(f"- [ ] {item['name']} ({sources})") lines.append('') (www / 'bilka_togo_checklist.md').write_text('\n'.join(lines)) # Bilka-ready checklist: same grouped list, but without source metadata. bilka_lines = [ '# Bilka ToGo - Klar til bestilling', '', 'Kryds af hvad I allerede har i huset, og bestil resten.', '', ] for category in sorted(grouped.keys()): bilka_lines.append(f'## {category.title()}') for item in grouped[category]: bilka_lines.append(f"- [ ] {item['name']}") bilka_lines.append('') (www / 'bilka_togo_checklist_bilka.md').write_text('\n'.join(bilka_lines)) def main() -> None: root = detect_root() token = read_bearer_token(root / 'secrets.yaml') keep_path = root / 'dokumenter' / 'google_keep_indkoeb.txt' mealie_items = fetch_mealie_items('http://10.0.0.142:9925', token) keep_items = read_keep_items(keep_path) merged = merge_items(mealie_items, keep_items) write_outputs(root, merged) print(f'OK: merged {len(merged)} items') if __name__ == '__main__': main()