From 83f8908a3fc843b40face03004a57cd0c34db00d Mon Sep 17 00:00:00 2001 From: Claus Dethlefsen Date: Wed, 22 Apr 2026 07:37:37 +0200 Subject: [PATCH] Add Wednesday Bilka checklist merge flow and Madplan dashboard view --- dashboards/views/04c_madplan.yaml | 19 +++ dokumenter/google_keep_indkoeb.txt | 6 + include/automations/mealie.yaml | 16 ++ include/shell_commands/mealie.yaml | 1 + python_scripts/mealie_shopping_merge.py | 197 ++++++++++++++++++++++++ 5 files changed, 239 insertions(+) create mode 100644 dokumenter/google_keep_indkoeb.txt create mode 100644 python_scripts/mealie_shopping_merge.py diff --git a/dashboards/views/04c_madplan.yaml b/dashboards/views/04c_madplan.yaml index 27abaa4..3d646f7 100644 --- a/dashboards/views/04c_madplan.yaml +++ b/dashboards/views/04c_madplan.yaml @@ -130,3 +130,22 @@ cards: #week { width: 100%; } + + # 🛒 Bilka ToGo - opdater og vis kryds-af liste + - type: vertical-stack + cards: + - type: markdown + content: | + ## Bilka ToGo - kryds-af + Tryk på knappen for at flette Mealie-indkøb med jeres Google Keep-basisliste. + + - type: button + name: Opdater Bilka ToGo-liste nu + icon: mdi:cart-check + tap_action: + action: call-service + service: shell_command.mealie_shopping_merge + + - type: iframe + url: /local/bilka_togo_checklist_bilka.md + aspect_ratio: 100% diff --git a/dokumenter/google_keep_indkoeb.txt b/dokumenter/google_keep_indkoeb.txt new file mode 100644 index 0000000..509970c --- /dev/null +++ b/dokumenter/google_keep_indkoeb.txt @@ -0,0 +1,6 @@ +# Google Keep basis-indkøbsliste +# En vare per linje. Linjer der starter med # ignoreres. +# Eksempel: +# Mælk +# Toiletpapir +# Bananer diff --git a/include/automations/mealie.yaml b/include/automations/mealie.yaml index 29f31b7..93239d8 100644 --- a/include/automations/mealie.yaml +++ b/include/automations/mealie.yaml @@ -7,3 +7,19 @@ minutes: "/30" action: - service: shell_command.mealie_update + +- id: mealie_generate_bilka_checklist_wednesday + alias: "Mealie indkøbsliste - onsdag morgen" + trigger: + - platform: time + at: "06:30:00" + condition: + - condition: time + weekday: + - wed + action: + - service: shell_command.mealie_shopping_merge + - service: notify.mobile_app_claus_iphone_15pro + data: + title: "Bilka ToGo liste er klar" + message: "Kryds-af listen er opdateret. Åbn Madplan-dashboardet for at gennemgå hvad I mangler." diff --git a/include/shell_commands/mealie.yaml b/include/shell_commands/mealie.yaml index e56b84a..1bb1cac 100644 --- a/include/shell_commands/mealie.yaml +++ b/include/shell_commands/mealie.yaml @@ -1 +1,2 @@ mealie_update: "python3 /config/python_scripts/mealie_mealplan.py" +mealie_shopping_merge: "python3 /config/python_scripts/mealie_shopping_merge.py" diff --git a/python_scripts/mealie_shopping_merge.py b/python_scripts/mealie_shopping_merge.py new file mode 100644 index 0000000..968426b --- /dev/null +++ b/python_scripts/mealie_shopping_merge.py @@ -0,0 +1,197 @@ +#!/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()