import os import sqlite3 from datetime import datetime, date from typing import List, Dict, Any, Optional try: from dotenv import load_dotenv load_dotenv() except ImportError: pass try: import pymysql import pymysql.cursors except ImportError: pymysql = None DB_PATH = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "data", "wtc.db") DB_ENGINE = os.getenv("DB_ENGINE", "mariadb").lower() MARIADB_HOST = os.getenv("MARIADB_HOST", "db.waiswuaschd.de") MARIADB_PORT = int(os.getenv("MARIADB_PORT", "3306")) MARIADB_USER = os.getenv("MARIADB_USER", "v2_usr_wtc_cookbook") MARIADB_PASS = os.getenv("MARIADB_PASS", "XKikHf&j6Wj!rs5%A8#ZzyvE#*A1hT") MARIADB_NAME = os.getenv("MARIADB_NAME", "v2_db_wtc_cookbook") LEGACY_CATEGORIES = [ "Reis", "Nudeln", "Kartoffeln", "Teigig", "asiatisch", "Hausmannskost", "Suppen", "Eintöpfe", "Süßspeisen", "Fisch" ] # 11 Kochbuch-Kapitel aus dem Benutzerentwurf mit handgezeichneten Doodles & Icons CHAPTERS = [ {"number": 1, "title": "Suppen & Eintöpfe", "icon": "🍲", "doodle_hint": "Dampfende Suppenschale", "illustration_url": "/static/img/chapters/chapter_1.png"}, {"number": 2, "title": "Traditionell & Basics", "icon": "🥟", "doodle_hint": "Klassiker & Brotzeit", "illustration_url": "/static/img/chapters/chapter_2.png"}, {"number": 3, "title": "Pasta & Nudelgerichte", "icon": "🍝", "doodle_hint": "Spaghetti & Pasta", "illustration_url": "/static/img/chapters/chapter_3.png"}, {"number": 4, "title": "Kartoffeln", "icon": "🥔", "doodle_hint": "Kartoffelauflauf & Klöße", "illustration_url": "/static/img/chapters/chapter_4.png"}, {"number": 5, "title": "Reis & Getreide", "icon": "🍚", "doodle_hint": "Reisschale & Körner", "illustration_url": "/static/img/chapters/chapter_5.png"}, {"number": 6, "title": "Teig & Gebackenes", "icon": "🍕", "doodle_hint": "Pizza, Flammkuchen & Brot", "illustration_url": "/static/img/chapters/chapter_6.png"}, {"number": 7, "title": "Asiatische Küche", "icon": "🥢", "doodle_hint": "Wok, Ramen & Thai-Curry", "illustration_url": "/static/img/chapters/chapter_7.png"}, {"number": 8, "title": "Süßspeisen", "icon": "🥞", "doodle_hint": "Kaiserschmarrn & Knödel", "illustration_url": "/static/img/chapters/chapter_8.png"}, {"number": 9, "title": "Desserts", "icon": "🍰", "doodle_hint": "Tiramisu & Mousse", "illustration_url": "/static/img/chapters/chapter_9.png"}, {"number": 10, "title": "Kuchen & Gebäck", "icon": "🎂", "doodle_hint": "Torten & Feingebäck", "illustration_url": "/static/img/chapters/chapter_10.png"}, {"number": 11, "title": "Plätzchen", "icon": "🍪", "doodle_hint": "Weihnachtsplätzchen & Kekse", "illustration_url": "/static/img/chapters/chapter_11.png"} ] CATEGORIES = [c["title"] for c in CHAPTERS] + [c for c in LEGACY_CATEGORIES if c not in [ch["title"] for ch in CHAPTERS]] EFFORTS = ["gering", "mittel", "hoch"] CHAPTER_MAP = {c["title"]: c["number"] for c in CHAPTERS} for c in CHAPTERS: CHAPTER_MAP[str(c["number"])] = c["number"] # Standard-Kategorien für die Einkaufsliste (Supermarkt-Laufweg) DEFAULT_SHOPPING_CATEGORIES = [ {"name": "Obst & Gemüse", "icon": "🥦", "sort_order": 1}, {"name": "Brot & Backwaren", "icon": "🍞", "sort_order": 2}, {"name": "Kühlregal & Käse", "icon": "🧀", "sort_order": 3}, {"name": "Fleisch & Fisch", "icon": "🥩", "sort_order": 4}, {"name": "Vorrat & Nudeln", "icon": "🥫", "sort_order": 5}, {"name": "Gewürze & Öle", "icon": "🧂", "sort_order": 6}, {"name": "Tiefkühl", "icon": "❄️", "sort_order": 7}, {"name": "Getränke", "icon": "🧃", "sort_order": 8}, {"name": "Drogerie & Sonstiges", "icon": "🧺", "sort_order": 9}, ] def clean_source_label(source: Optional[str]) -> str: """Extrahiert einen sauberen, kurzen Rezepttitel aus der Quellangabe (ohne Wochenplan-Präfixe).""" if not source: return "" src = str(source).strip() import re m = re.search(r'\((.+)\)$', src) if m: return m.group(1).strip() return src def guess_category_for_ingredient(name: str) -> str: """Bestimmt automatisch die passende Supermarkt-Kategorie anhand deutscher Zutaten-Keywords.""" if not name: return "Drogerie & Sonstiges" n = name.lower() # 1. Tiefkühl if any(k in n for k in ["tk", "tiefkühl", "gefroren", "eiscreme", "speiseeis", "erbsen (tk)", "tk-"]): return "Tiefkühl" # 2. Obst & Gemüse if any(k in n for k in [ "zwiebel", "knoblauch", "kartoffel", "karotte", "möhre", "tomate", "paprika", "brokkoli", "blumenkohl", "spinat", "zucchini", "aubergine", "gurke", "salat", "lauch", "porree", "frühlingszwiebel", "sellerie", "kohlrabi", "champignon", "pilz", "steinpilz", "pfifferling", "apfel", "äpfel", "birne", "banane", "avocado", "zitrone", "limette", "orange", "beere", "erdbeere", "himbeere", "heidelbeere", "blaubeere", "petersilie", "schnittlauch", "basilikum", "koriander", "ingwer", "chili", "peperoni", "kürbis", "radieschen", "rucola", "rosmarin", "minze", "dill", "salbei", "kräuter", "kohl", "wirsing" ]): return "Obst & Gemüse" # 3. Brot & Backwaren if any(k in n for k in [ "brot", "brötchen", "semmel", "semmeln", "toast", "baguette", "fladenbrot", "tortilla", "wrap", "panko", "paniermehl", "hefe", "backpulver", "vanillezucker", "blätterteig", "croissant" ]): return "Brot & Backwaren" # 4. Kühlregal & Käse if any(k in n for k in [ "milch", "butter", "sahne", "schmand", "quark", "joghurt", "käse", "cheddar", "parmesan", "mozzarella", "feta", "gouda", "bergkäse", "frischkäse", "crème fraîche", "creme fraiche", "mascarpone", "ricotta", "ei", "eier", "tofu", "sauerrahm", "buttermilch" ]): return "Kühlregal & Käse" # 5. Fleisch & Fisch if any(k in n for k in [ "hackfleisch", "hack", "rinderhack", "hähnchen", "huhn", "pute", "speck", "schinken", "wurst", "würstchen", "lachs", "garnele", "garnelen", "fisch", "forelle", "kabeljau", "rind", "schwein", "kalb", "lamm", "steak", "filet", "bratwurst", "wiener" ]): return "Fleisch & Fisch" # 6. Gewürze & Öle if any(k in n for k in [ "öl", "olivenöl", "rapsöl", "sonnenblumenöl", "sesamöl", "bratöl", "essig", "balsamico", "sojasauce", "salz", "pfeffer", "paprikapulver", "curry", "kreuzkümmel", "kümmel", "oregano", "thymian", "majoran", "muskat", "cayennepfeffer", "zimt", "lorbeer", "vanille", "kurkuma", "chiliflocken" ]): return "Gewürze & Öle" # 7. Vorrat & Nudeln if any(k in n for k in [ "nudel", "nudeln", "spaghetti", "penne", "mie", "pasta", "reis", "basmati", "jasmin", "linsen", "kichererbsen", "bohnen", "kidneybohnen", "mais", "tomatenmark", "passierte tomaten", "gehackte tomaten", "dosentomaten", "mehl", "zucker", "puderzucker", "haferflocken", "kokosmilch", "brühe", "gemüsebrühe", "rinderbrühe", "hühnerbrühe", "couscous", "bulgur", "quinoa", "erdnussbutter", "marmelade", "honig", "ahornsirup", "senf", "ketchup", "mayonnaise", "schokolade", "zartbitterschokolade", "kakao", "nüsse", "mandeln", "walnüsse", "sesam", "chiasamen", "leinsamen" ]): return "Vorrat & Nudeln" # 8. Getränke if any(k in n for k in [ "wasser", "mineralwasser", "saft", "apfelsaft", "orangensaft", "wein", "weißwein", "rotwein", "bier", "kaffee", "espresso", "tee", "cola", "limonade", "sirup" ]): return "Getränke" return "Drogerie & Sonstiges" def get_connection(): if DB_ENGINE == "mariadb": if pymysql is None: raise RuntimeError("PyMySQL ist nicht installiert, wird aber für MariaDB benötigt.") return pymysql.connect( host=MARIADB_HOST, port=MARIADB_PORT, user=MARIADB_USER, password=MARIADB_PASS, database=MARIADB_NAME, charset="utf8mb4", cursorclass=pymysql.cursors.DictCursor, autocommit=True ) else: os.makedirs(os.path.dirname(DB_PATH), exist_ok=True) conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row return conn def execute_query(query: str, params: tuple = (), fetch: str = "all", commit: bool = False): conn = get_connection() try: if DB_ENGINE == "mariadb": # MariaDB uses %s instead of ? maria_query = query.replace("?", "%s") with conn.cursor() as cursor: cursor.execute(maria_query, params) last_id = cursor.lastrowid rowcount = cursor.rowcount if fetch == "all": rows = cursor.fetchall() return rows elif fetch == "one": return cursor.fetchone() else: return {"lastrowid": last_id, "rowcount": rowcount} else: cursor = conn.cursor() cursor.execute(query, params) if commit: conn.commit() last_id = cursor.lastrowid rowcount = cursor.rowcount if fetch == "all": rows = [dict(r) for r in cursor.fetchall()] return rows elif fetch == "one": row = cursor.fetchone() return dict(row) if row else None else: return {"lastrowid": last_id, "rowcount": rowcount} finally: conn.close() def init_db(): conn = get_connection() try: if DB_ENGINE == "mariadb": with conn.cursor() as cursor: cursor.execute(""" CREATE TABLE IF NOT EXISTS dishes ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(255) NOT NULL UNIQUE, category VARCHAR(100) NOT NULL, effort VARCHAR(20) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, INDEX idx_dish_cat (category), INDEX idx_dish_eff (effort) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; """) cursor.execute(""" CREATE TABLE IF NOT EXISTS recipes ( id INT AUTO_INCREMENT PRIMARY KEY, title VARCHAR(255) NOT NULL, category VARCHAR(100) NOT NULL, chapter_number INT NOT NULL, effort VARCHAR(20) NOT NULL DEFAULT 'mittel', prep_time VARCHAR(50) DEFAULT '', cook_time VARCHAR(50) DEFAULT '', portions INT DEFAULT 4, ingredients LONGTEXT NOT NULL, instructions LONGTEXT NOT NULL, notes TEXT, source VARCHAR(255) DEFAULT '', image_url VARCHAR(500) DEFAULT '', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_recipe_chapter (chapter_number), INDEX idx_recipe_cat (category) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; """) # Migration check for MariaDB cursor.execute(""" SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'recipes' AND COLUMN_NAME = 'source' """, (MARIADB_NAME,)) if not cursor.fetchone(): cursor.execute("ALTER TABLE recipes ADD COLUMN source VARCHAR(255) DEFAULT ''") cursor.execute(""" SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'recipes' AND COLUMN_NAME = 'printed_at' """, (MARIADB_NAME,)) if not cursor.fetchone(): cursor.execute("ALTER TABLE recipes ADD COLUMN printed_at DATETIME NULL DEFAULT NULL AFTER image_url") cursor.execute(""" SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'recipes' AND COLUMN_NAME = 'has_portion_table' """, (MARIADB_NAME,)) if not cursor.fetchone(): cursor.execute("ALTER TABLE recipes ADD COLUMN has_portion_table TINYINT(1) NOT NULL DEFAULT 0 AFTER portions") cursor.execute(""" CREATE TABLE IF NOT EXISTS meal_plans ( id INT AUTO_INCREMENT PRIMARY KEY, year INT NOT NULL, week INT NOT NULL, day_of_week INT NOT NULL, meal_type VARCHAR(20) NOT NULL, recipe_id INT NULL, custom_text VARCHAR(255) NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uq_meal_slot (year, week, day_of_week, meal_type), INDEX idx_plan_week (year, week) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; """) cursor.execute(""" CREATE TABLE IF NOT EXISTS shopping_list ( id INT AUTO_INCREMENT PRIMARY KEY, item_name VARCHAR(255) NOT NULL, amount VARCHAR(50) DEFAULT '', unit VARCHAR(50) DEFAULT '', checked TINYINT(1) NOT NULL DEFAULT 0, source VARCHAR(255) DEFAULT '', category VARCHAR(100) DEFAULT 'Drogerie & Sonstiges', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_shop_checked (checked) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; """) cursor.execute(""" CREATE TABLE IF NOT EXISTS shopping_categories ( id INT AUTO_INCREMENT PRIMARY KEY, name VARCHAR(100) NOT NULL UNIQUE, icon VARCHAR(50) DEFAULT '🛒', sort_order INT NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; """) try: cursor.execute(""" SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'shopping_list' AND COLUMN_NAME = 'category' """, (MARIADB_NAME,)) if not cursor.fetchone(): cursor.execute("ALTER TABLE shopping_list ADD COLUMN category VARCHAR(100) DEFAULT 'Drogerie & Sonstiges' AFTER source") except Exception: pass try: cursor.execute(""" SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'shopping_list' AND COLUMN_NAME = 'note' """, (MARIADB_NAME,)) if not cursor.fetchone(): cursor.execute("ALTER TABLE shopping_list ADD COLUMN note VARCHAR(255) DEFAULT '' AFTER category") except Exception: pass cursor.execute("DELETE FROM dishes") else: cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS dishes ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE COLLATE NOCASE, category TEXT NOT NULL, effort TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) cursor.execute(""" CREATE TABLE IF NOT EXISTS recipes ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, category TEXT NOT NULL, chapter_number INTEGER NOT NULL, effort TEXT NOT NULL DEFAULT 'mittel', prep_time TEXT DEFAULT '', cook_time TEXT DEFAULT '', portions INTEGER DEFAULT 4, has_portion_table INTEGER DEFAULT 0, ingredients TEXT NOT NULL, instructions TEXT NOT NULL, notes TEXT DEFAULT '', source TEXT DEFAULT '', image_url TEXT DEFAULT '', printed_at TIMESTAMP NULL DEFAULT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) # Migration check for SQLite cursor.execute("PRAGMA table_info(recipes)") existing_cols = [c[1] for c in cursor.fetchall()] if "source" not in existing_cols: cursor.execute("ALTER TABLE recipes ADD COLUMN source TEXT DEFAULT ''") if "printed_at" not in existing_cols: cursor.execute("ALTER TABLE recipes ADD COLUMN printed_at TIMESTAMP NULL DEFAULT NULL") if "has_portion_table" not in existing_cols: cursor.execute("ALTER TABLE recipes ADD COLUMN has_portion_table INTEGER DEFAULT 0") cursor.execute(""" CREATE TABLE IF NOT EXISTS meal_plans ( id INTEGER PRIMARY KEY AUTOINCREMENT, year INTEGER NOT NULL, week INTEGER NOT NULL, day_of_week INTEGER NOT NULL, meal_type TEXT NOT NULL, recipe_id INTEGER NULL, custom_text TEXT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, UNIQUE (year, week, day_of_week, meal_type) ) """) cursor.execute(""" CREATE TABLE IF NOT EXISTS shopping_list ( id INTEGER PRIMARY KEY AUTOINCREMENT, item_name TEXT NOT NULL, amount TEXT DEFAULT '', unit TEXT DEFAULT '', checked INTEGER NOT NULL DEFAULT 0, source TEXT DEFAULT '', category TEXT DEFAULT 'Drogerie & Sonstiges', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) cursor.execute(""" CREATE TABLE IF NOT EXISTS shopping_categories ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE COLLATE NOCASE, icon TEXT DEFAULT '🛒', sort_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) cursor.execute("PRAGMA table_info(shopping_list)") existing_shop_cols = [c[1] for c in cursor.fetchall()] if "category" not in existing_shop_cols: cursor.execute("ALTER TABLE shopping_list ADD COLUMN category TEXT DEFAULT 'Drogerie & Sonstiges'") if "note" not in existing_shop_cols: cursor.execute("ALTER TABLE shopping_list ADD COLUMN note TEXT DEFAULT ''") cursor.execute("DELETE FROM dishes") conn.commit() finally: conn.close() # Standard-Kategorien initialisieren & Altdaten abgleichen seed_default_shopping_categories_if_empty() # --- Dishes (Zufallsgenerator) --- def get_all_dishes(category: Optional[str] = None, effort: Optional[str] = None, search: Optional[str] = None) -> List[Dict[str, Any]]: query = "SELECT id, name, category, effort, created_at FROM dishes WHERE 1=1" params = [] if category and category.strip() and category != "alle": query += " AND category = ?" params.append(category.strip()) if effort and effort.strip() and effort != "alle": query += " AND effort = ?" params.append(effort.strip()) if search and search.strip(): query += " AND name LIKE ?" params.append(f"%{search.strip()}%") query += " ORDER BY category ASC, name ASC" return execute_query(query, tuple(params), fetch="all") def add_dish(name: str, category: str, effort: str) -> Dict[str, Any]: name = name.strip() category = category.strip() effort = effort.strip() if not name: raise ValueError("Name darf nicht leer sein.") if category not in CATEGORIES: raise ValueError(f"Ungültige Kategorie '{category}'. Gültig sind: {', '.join(CATEGORIES)}") if effort not in EFFORTS: raise ValueError(f"Ungültiger Aufwand '{effort}'. Gültig sind: {', '.join(EFFORTS)}") try: res = execute_query( "INSERT INTO dishes (name, category, effort) VALUES (?, ?, ?)", (name, category, effort), fetch="none", commit=True ) dish_id = res["lastrowid"] return execute_query("SELECT id, name, category, effort, created_at FROM dishes WHERE id = ?", (dish_id,), fetch="one") except Exception as e: if "UNIQUE" in str(e).upper() or "DUPLICATE" in str(e).upper(): raise ValueError(f"Ein Gericht mit dem Namen '{name}' existiert bereits.") raise def update_dish(dish_id: int, name: str, category: str, effort: str) -> Dict[str, Any]: name = name.strip() category = category.strip() effort = effort.strip() if not name: raise ValueError("Name darf nicht leer sein.") if category not in CATEGORIES: raise ValueError(f"Ungültige Kategorie '{category}'.") if effort not in EFFORTS: raise ValueError(f"Ungültiger Aufwand '{effort}'.") try: res = execute_query( "UPDATE dishes SET name = ?, category = ?, effort = ? WHERE id = ?", (name, category, effort, dish_id), fetch="none", commit=True ) if res["rowcount"] == 0: raise KeyError(f"Gericht #{dish_id} nicht gefunden.") return execute_query("SELECT id, name, category, effort, created_at FROM dishes WHERE id = ?", (dish_id,), fetch="one") except Exception as e: if "UNIQUE" in str(e).upper() or "DUPLICATE" in str(e).upper(): raise ValueError(f"Ein Gericht mit dem Namen '{name}' existiert bereits.") raise def delete_dish(dish_id: int) -> bool: res = execute_query("DELETE FROM dishes WHERE id = ?", (dish_id,), fetch="none", commit=True) return res["rowcount"] > 0 def clear_all_dishes(): execute_query("DELETE FROM dishes", fetch="none", commit=True) if DB_ENGINE == "sqlite": try: execute_query("DELETE FROM sqlite_sequence WHERE name = 'dishes'", fetch="none", commit=True) except Exception: pass def get_stats() -> Dict[str, Any]: total_res = execute_query("SELECT COUNT(*) as total FROM dishes", fetch="one") total = total_res["total"] if total_res else 0 cat_rows = execute_query("SELECT category, COUNT(*) as cnt FROM dishes GROUP BY category", fetch="all") by_category = {row["category"]: row["cnt"] for row in cat_rows} eff_rows = execute_query("SELECT effort, COUNT(*) as cnt FROM dishes GROUP BY effort", fetch="all") by_effort = {row["effort"]: row["cnt"] for row in eff_rows} recipe_count_res = execute_query("SELECT COUNT(*) as total FROM recipes", fetch="one") total_recipes = recipe_count_res["total"] if recipe_count_res else 0 return { "total": total, "by_category": by_category, "by_effort": by_effort, "total_recipes": total_recipes } def resolve_category_terms(cat_input: Optional[str]) -> List[str]: if not cat_input or str(cat_input).strip().lower() in ["", "alle", "beliebig", "none"]: return [] s = str(cat_input).strip() chap_num = None if s.isdigit(): chap_num = int(s) else: for c in CHAPTERS: if s.lower() == c["title"].lower() or s.lower() in c["title"].lower(): chap_num = c["number"] break chapter_terms = { 1: ["Suppen", "Eintöpfe", "Suppe", "Eintopf", "Suppen & Eintöpfe", "Chili", "Gulasch"], 2: ["Traditionell & Basics", "Hausmannskost", "Traditionell", "Basics", "Fisch", "Brotzeit", "Klassiker", "Schnitzel", "Rouladen", "Klopse", "Forelle", "Lachs"], 3: ["Pasta & Nudelgerichte", "Nudeln", "Pasta", "Spaghetti", "Penne", "Tagliatelle", "Carbonara", "Lasagne", "Spätzle"], 4: ["Kartoffeln", "Kartoffel", "Gratin", "Pommes", "Püree"], 5: ["Reis & Getreide", "Reis", "Getreide", "Risotto", "Paella"], 6: ["Teig & Gebackenes", "Teigig", "Teig", "Pizza", "Flammkuchen", "Quiche", "Calzone"], 7: ["Asiatische Küche", "asiatisch", "Asiatisch", "Curry", "Pad Thai", "Bami", "Wok", "Ramen", "Sushi"], 8: ["Süßspeisen", "Süßspeise", "Kaiserschmarrn", "Germknödel", "Dampfnudel", "Apfelküchle", "Milchreis"], 9: ["Desserts", "Dessert", "Tiramisu", "Mousse", "Pudding"], 10: ["Kuchen & Gebäck", "Kuchen", "Gebäck", "Torte"], 11: ["Plätzchen", "Kekse", "Keks"] } if chap_num and chap_num in chapter_terms: return chapter_terms[chap_num] # Legacy category names mapping legacy_map = { "nudeln": chapter_terms[3], "pasta": chapter_terms[3], "reis": chapter_terms[5], "kartoffeln": chapter_terms[4], "teigig": chapter_terms[6], "suppen": chapter_terms[1], "eintöpfe": chapter_terms[1], "eintoepfe": chapter_terms[1], "hausmannskost": chapter_terms[2], "asiatisch": chapter_terms[7], "süßspeisen": chapter_terms[8], "suessspeisen": chapter_terms[8], "fisch": ["Fisch", "Forelle", "Lachs", "Kabeljau", "Hausmannskost"] } if s.lower() in legacy_map: return legacy_map[s.lower()] return [s] def get_chapter_info_for_dish(dish_category: str = "", dish_name: str = "", recipe_chapter: Optional[int] = None) -> Dict[str, Any]: if recipe_chapter and 1 <= recipe_chapter <= len(CHAPTERS): return CHAPTERS[recipe_chapter - 1] combo = f"{dish_category or ''} {dish_name or ''}".lower() if any(k in combo for k in ["suppe", "eintopf", "chili", "brühe"]): return CHAPTERS[0] # 1: Suppen & Eintöpfe if any(k in combo for k in ["nudel", "pasta", "spaghetti", "penne", "tagliatelle", "carbonara", "bolognese", "spätzle", "lasagne"]): return CHAPTERS[2] # 3: Pasta & Nudelgerichte if any(k in combo for k in ["kartoffel", "pommes", "gratin", "püree"]): return CHAPTERS[3] # 4: Kartoffeln if any(k in combo for k in ["curry", "pad thai", "bami", "wok", "ramen", "sushi", "asiatisch"]): return CHAPTERS[6] # 7: Asiatische Küche if any(k in combo for k in ["reis", "risotto", "paella", "djuvec"]): return CHAPTERS[4] # 5: Reis & Getreide if any(k in combo for k in ["pizza", "flammkuchen", "quiche", "calzone", "teig", "brot"]): return CHAPTERS[5] # 6: Teig & Gebackenes if any(k in combo for k in ["kuchen", "torte", "muffin", "gebäck"]): return CHAPTERS[9] # 10: Kuchen & Gebäck if any(k in combo for k in ["keks", "plätzchen"]): return CHAPTERS[10] # 11: Plätzchen if any(k in combo for k in ["tiramisu", "mousse", "pudding", "dessert"]): return CHAPTERS[8] # 9: Desserts if any(k in combo for k in ["kaiserschmarrn", "knödel", "apfelküchle", "dampfnudel", "milchreis", "süßspeis"]): return CHAPTERS[7] # 8: Süßspeisen return CHAPTERS[1] # 2: Traditionell & Basics def query_recipe_candidates(chapter_number: Optional[int] = None, efforts: Optional[List[str]] = None) -> List[Dict[str, Any]]: """Gibt alle Rezepte aus der recipes-Tabelle zurück, die zu den Shuffle-Kriterien passen.""" all_recipes = get_all_recipes() if not all_recipes: return [] # 1. Nach Kapitel filtern (wenn spezifiziert) if chapter_number is not None and chapter_number > 0: pool = [r for r in all_recipes if r.get("chapter_number") == chapter_number] else: # Standard beim Zufallsgenerator: Kategorien Desserts (9), Kuchen & Gebäck (10) und Plätzchen (11) ausschließen EXCLUDED_SHUFFLE_CHAPTERS = {9, 10, 11} pool = [r for r in all_recipes if r.get("chapter_number") not in EXCLUDED_SHUFFLE_CHAPTERS] if not pool: return [] # 2. Nach Aufwand filtern (nur, wenn eine Teilmenge gewählt wurde) if efforts and len(efforts) > 0 and len(efforts) < len(EFFORTS): clean_efforts = [e.strip().lower() for e in efforts if e.strip() in EFFORTS] effort_pool = [r for r in pool if (r.get("effort") or "").lower() in clean_efforts] if effort_pool: return effort_pool return pool def query_candidates(category: Optional[str], efforts: Optional[List[str]]) -> List[Dict[str, Any]]: """Fallback-Funktion für alte Dish-Abfragen""" return query_recipe_candidates() # --- Recipes (Kochbuch) --- def format_printed_date(p_at: Any) -> str: if not p_at: return "" if hasattr(p_at, "strftime"): return p_at.strftime("%d.%m.%Y") try: parts = str(p_at).split(" ")[0].split("-") if len(parts) == 3: return f"{parts[2]}.{parts[1]}.{parts[0]}" except Exception: pass return str(p_at) def add_recipe( title: str, category: str, chapter_number: int, effort: str = "mittel", prep_time: str = "", cook_time: str = "", portions: int = 4, ingredients: str = "", instructions: str = "", notes: str = "", source: str = "", image_url: str = "", printed_at: Optional[str] = None, has_portion_table: int = 0 ) -> Dict[str, Any]: title = title.strip() category = category.strip() effort = effort.strip().lower() source = source.strip() if not title: raise ValueError("Rezepttitel darf nicht leer sein.") if not category: raise ValueError("Kategorie darf nicht leer sein.") if chapter_number not in [c["number"] for c in CHAPTERS]: # Fallback to mapped chapter number if possible chapter_number = CHAPTER_MAP.get(category, 1) res = execute_query( """ INSERT INTO recipes (title, category, chapter_number, effort, prep_time, cook_time, portions, has_portion_table, ingredients, instructions, notes, source, image_url, printed_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """, (title, category, chapter_number, effort, prep_time, cook_time, portions, int(bool(has_portion_table)), ingredients, instructions, notes, source, image_url, printed_at), fetch="none", commit=True ) recipe_id = res["lastrowid"] return get_recipe(recipe_id) def update_recipe( recipe_id: int, title: str, category: str, chapter_number: int, effort: str = "mittel", prep_time: str = "", cook_time: str = "", portions: int = 4, ingredients: str = "", instructions: str = "", notes: str = "", source: str = "", image_url: Optional[str] = None, printed_at: Optional[str] = None, has_portion_table: Optional[int] = None ) -> Dict[str, Any]: title = title.strip() category = category.strip() effort = effort.strip().lower() source = source.strip() if not title: raise ValueError("Rezepttitel darf nicht leer sein.") existing = get_recipe(recipe_id) if not existing: raise KeyError(f"Rezept #{recipe_id} nicht gefunden.") use_portion_table = int(bool(has_portion_table)) if has_portion_table is not None else existing.get("has_portion_table", 0) if image_url is not None: query = """ UPDATE recipes SET title = ?, category = ?, chapter_number = ?, effort = ?, prep_time = ?, cook_time = ?, portions = ?, has_portion_table = ?, ingredients = ?, instructions = ?, notes = ?, source = ?, image_url = ?, printed_at = ? WHERE id = ? """ params = (title, category, chapter_number, effort, prep_time, cook_time, portions, use_portion_table, ingredients, instructions, notes, source, image_url, printed_at, recipe_id) else: query = """ UPDATE recipes SET title = ?, category = ?, chapter_number = ?, effort = ?, prep_time = ?, cook_time = ?, portions = ?, has_portion_table = ?, ingredients = ?, instructions = ?, notes = ?, source = ?, printed_at = ? WHERE id = ? """ params = (title, category, chapter_number, effort, prep_time, cook_time, portions, use_portion_table, ingredients, instructions, notes, source, printed_at, recipe_id) res = execute_query(query, params, fetch="none", commit=True) return get_recipe(recipe_id) def delete_recipe(recipe_id: int) -> bool: res = execute_query("DELETE FROM recipes WHERE id = ?", (recipe_id,), fetch="none", commit=True) return res["rowcount"] > 0 def get_recipe(recipe_id: int) -> Optional[Dict[str, Any]]: row = execute_query("SELECT * FROM recipes WHERE id = ?", (recipe_id,), fetch="one") if row: row["printed_at_formatted"] = format_printed_date(row.get("printed_at")) row["has_portion_table"] = bool(row.get("has_portion_table", 0)) return row def get_all_recipes( chapter_number: Optional[int] = None, category: Optional[str] = None, search: Optional[str] = None, printed_status: Optional[str] = None ) -> List[Dict[str, Any]]: query = "SELECT * FROM recipes WHERE 1=1" params = [] if chapter_number is not None and chapter_number > 0: query += " AND chapter_number = ?" params.append(chapter_number) if category and category.strip() and category != "alle": query += " AND category = ?" params.append(category.strip()) if search and search.strip(): query += " AND (title LIKE ? OR ingredients LIKE ? OR instructions LIKE ?)" s = f"%{search.strip()}%" params.extend([s, s, s]) if printed_status == "unprinted": query += " AND printed_at IS NULL" elif printed_status == "printed": query += " AND printed_at IS NOT NULL" query += " ORDER BY chapter_number ASC, title ASC" rows = execute_query(query, tuple(params), fetch="all") for r in rows: r["printed_at_formatted"] = format_printed_date(r.get("printed_at")) r["has_portion_table"] = bool(r.get("has_portion_table", 0)) return rows def mark_recipes_printed(recipe_ids: List[int], print_date: Optional[str] = None) -> int: """Markiert die angegebenen Rezepte als gedruckt.""" if not recipe_ids: return 0 date_val = print_date or datetime.now().strftime("%Y-%m-%d %H:%M:%S") placeholders = ", ".join(["?"] * len(recipe_ids)) query = f"UPDATE recipes SET printed_at = ? WHERE id IN ({placeholders})" params = [date_val] + list(recipe_ids) res = execute_query(query, tuple(params), fetch="none", commit=True) return res.get("rowcount", 0) def unmark_recipes_printed(recipe_ids: List[int]) -> int: """Entfernt den Gedruckt-Status von den angegebenen Rezepten.""" if not recipe_ids: return 0 placeholders = ", ".join(["?"] * len(recipe_ids)) query = f"UPDATE recipes SET printed_at = NULL WHERE id IN ({placeholders})" res = execute_query(query, tuple(recipe_ids), fetch="none", commit=True) return res.get("rowcount", 0) def toggle_recipe_printed(recipe_id: int) -> Dict[str, Any]: """Schaltet den Druckstatus eines Rezepts um.""" rec = get_recipe(recipe_id) if not rec: raise KeyError(f"Rezept #{recipe_id} nicht gefunden.") if rec.get("printed_at"): unmark_recipes_printed([recipe_id]) else: mark_recipes_printed([recipe_id]) return get_recipe(recipe_id) # ========================================== # WOCHENPLAN (MEAL PLANNER) # ========================================== def get_meal_plan(year: int, week: int) -> List[Dict[str, Any]]: """Gibt alle Einträge des Wochenplans für die angegebene KW zurück.""" query = """ SELECT m.id, m.year, m.week, m.day_of_week, m.meal_type, m.recipe_id, m.custom_text, r.title AS recipe_title, r.image_url AS recipe_image_url, r.chapter_number AS recipe_chapter_number, r.category AS recipe_category FROM meal_plans m LEFT JOIN recipes r ON m.recipe_id = r.id WHERE m.year = ? AND m.week = ? ORDER BY m.day_of_week ASC, CASE m.meal_type WHEN 'frueh' THEN 1 WHEN 'mittag' THEN 2 WHEN 'kaffee' THEN 3 WHEN 'abend' THEN 4 ELSE 5 END ASC """ return execute_query(query, (year, week), fetch="all") def set_meal_plan_slot( year: int, week: int, day_of_week: int, meal_type: str, recipe_id: Optional[int] = None, custom_text: Optional[str] = None ) -> Dict[str, Any]: """Setzt oder aktualisiert einen Slot im Wochenplan.""" if DB_ENGINE == "mariadb": query = """ INSERT INTO meal_plans (year, week, day_of_week, meal_type, recipe_id, custom_text) VALUES (?, ?, ?, ?, ?, ?) ON DUPLICATE KEY UPDATE recipe_id = VALUES(recipe_id), custom_text = VALUES(custom_text), updated_at = CURRENT_TIMESTAMP """ execute_query(query, (year, week, day_of_week, meal_type, recipe_id, custom_text), fetch="none", commit=True) else: query = """ INSERT INTO meal_plans (year, week, day_of_week, meal_type, recipe_id, custom_text, updated_at) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) ON CONFLICT(year, week, day_of_week, meal_type) DO UPDATE SET recipe_id = excluded.recipe_id, custom_text = excluded.custom_text, updated_at = CURRENT_TIMESTAMP """ execute_query(query, (year, week, day_of_week, meal_type, recipe_id, custom_text), fetch="none", commit=True) slot_query = """ SELECT m.id, m.year, m.week, m.day_of_week, m.meal_type, m.recipe_id, m.custom_text, r.title AS recipe_title, r.image_url AS recipe_image_url FROM meal_plans m LEFT JOIN recipes r ON m.recipe_id = r.id WHERE m.year = ? AND m.week = ? AND m.day_of_week = ? AND m.meal_type = ? """ return execute_query(slot_query, (year, week, day_of_week, meal_type), fetch="one") def delete_meal_plan_slot(year: int, week: int, day_of_week: int, meal_type: str) -> bool: """Löscht einen einzelnen Slot aus dem Wochenplan.""" query = "DELETE FROM meal_plans WHERE year = ? AND week = ? AND day_of_week = ? AND meal_type = ?" res = execute_query(query, (year, week, day_of_week, meal_type), fetch="none", commit=True) return res.get("rowcount", 0) > 0 def clear_meal_plan_week(year: int, week: int) -> int: """Leert den gesamten Wochenplan für eine Kalenderwoche.""" query = "DELETE FROM meal_plans WHERE year = ? AND week = ?" res = execute_query(query, (year, week), fetch="none", commit=True) return res.get("rowcount", 0) # --- Shopping Categories & Shopping List (Einkaufsliste) --- def seed_default_shopping_categories_if_empty(): """Initialisiert die Standard-Kategorien, falls noch keine vorhanden sind, und kategorisiert alte Einträge.""" try: cats = execute_query("SELECT COUNT(*) as cnt FROM shopping_categories", fetch="one") cnt = cats["cnt"] if cats else 0 if cnt == 0: for c in DEFAULT_SHOPPING_CATEGORIES: execute_query( "INSERT INTO shopping_categories (name, icon, sort_order) VALUES (?, ?, ?)", (c["name"], c["icon"], c["sort_order"]), fetch="none", commit=True ) # Bestehende Einträge ohne Kategorie automatisch zuweisen items = execute_query("SELECT id, item_name, category FROM shopping_list", fetch="all") for it in items: cur_cat = it.get("category") if not cur_cat or cur_cat in ("", "None", "null", "Sonstiges") or cur_cat == "Drogerie & Sonstiges": guessed = guess_category_for_ingredient(it.get("item_name", "")) if guessed != cur_cat: execute_query("UPDATE shopping_list SET category = ? WHERE id = ?", (guessed, it["id"]), fetch="none", commit=True) except Exception as e: print(f"Hinweis beim Initialisieren der Shopping-Kategorien: {e}") def get_shopping_categories() -> List[Dict[str, Any]]: """Gibt alle Supermarkt-Bereiche/Kategorien sortiert nach Reihenfolge zurück.""" query = "SELECT id, name, icon, sort_order, created_at FROM shopping_categories ORDER BY sort_order ASC, id ASC" rows = execute_query(query, fetch="all") if not rows: seed_default_shopping_categories_if_empty() rows = execute_query(query, fetch="all") for r in rows: if isinstance(r.get("created_at"), (datetime, date)): r["created_at"] = r["created_at"].isoformat() return rows def add_shopping_category(name: str, icon: str = "🛒", sort_order: Optional[int] = None) -> Dict[str, Any]: """Fügt einen neuen Supermarkt-Bereich hinzu.""" clean_name = name.strip() if not clean_name: raise ValueError("Name des Bereichs darf nicht leer sein.") clean_icon = (icon or "🛒").strip() if sort_order is None: max_row = execute_query("SELECT MAX(sort_order) as m FROM shopping_categories", fetch="one") sort_order = ((max_row["m"] or 0) + 1) if max_row else 1 query = "INSERT INTO shopping_categories (name, icon, sort_order) VALUES (?, ?, ?)" res = execute_query(query, (clean_name, clean_icon, sort_order), fetch="none", commit=True) cat_id = res.get("lastrowid") row = execute_query("SELECT * FROM shopping_categories WHERE id = ?", (cat_id,), fetch="one") if row and isinstance(row.get("created_at"), (datetime, date)): row["created_at"] = row["created_at"].isoformat() return row def update_shopping_category(cat_id: int, name: str, icon: str, sort_order: Optional[int] = None) -> Optional[Dict[str, Any]]: """Aktualisiert einen Supermarkt-Bereich und benennt vorhandene Zuweisungen um.""" old = execute_query("SELECT * FROM shopping_categories WHERE id = ?", (cat_id,), fetch="one") if not old: return None clean_name = name.strip() clean_icon = (icon or "🛒").strip() order_val = sort_order if sort_order is not None else old.get("sort_order", 0) query = "UPDATE shopping_categories SET name = ?, icon = ?, sort_order = ? WHERE id = ?" execute_query(query, (clean_name, clean_icon, order_val, cat_id), fetch="none", commit=True) # Falls der Name geändert wurde, aktualisiere die Items in shopping_list if old["name"] != clean_name: execute_query("UPDATE shopping_list SET category = ? WHERE category = ?", (clean_name, old["name"]), fetch="none", commit=True) row = execute_query("SELECT * FROM shopping_categories WHERE id = ?", (cat_id,), fetch="one") if row and isinstance(row.get("created_at"), (datetime, date)): row["created_at"] = row["created_at"].isoformat() return row def delete_shopping_category(cat_id: int) -> bool: """Löscht einen Bereich. Zugeordnete Items werden auf einen Ausweichbereich gelegt.""" old = execute_query("SELECT * FROM shopping_categories WHERE id = ?", (cat_id,), fetch="one") if not old: return False fallback = execute_query("SELECT name FROM shopping_categories WHERE id != ? ORDER BY sort_order DESC LIMIT 1", (cat_id,), fetch="one") fallback_name = fallback["name"] if fallback else "Drogerie & Sonstiges" execute_query("UPDATE shopping_list SET category = ? WHERE category = ?", (fallback_name, old["name"]), fetch="none", commit=True) res = execute_query("DELETE FROM shopping_categories WHERE id = ?", (cat_id,), fetch="none", commit=True) return res.get("rowcount", 0) > 0 def reorder_shopping_categories(category_ids: List[int]) -> bool: """Aktualisiert die Reihenfolge der Supermarkt-Bereiche.""" for idx, cid in enumerate(category_ids): execute_query("UPDATE shopping_categories SET sort_order = ? WHERE id = ?", (idx + 1, cid), fetch="none", commit=True) return True def reset_default_shopping_categories() -> List[Dict[str, Any]]: """Setzt alle Bereiche auf die Standard-Reihenfolge zurück.""" execute_query("DELETE FROM shopping_categories", fetch="none", commit=True) seed_default_shopping_categories_if_empty() return get_shopping_categories() def update_shopping_item( item_id: int, item_name: str, amount: str = "", unit: str = "", category: Optional[str] = None, note: str = "" ) -> Optional[Dict[str, Any]]: """Aktualisiert alle Eigenschaften eines Eintrags in der Einkaufsliste.""" cat = (category or "").strip() if not cat: cat = guess_category_for_ingredient(item_name) query = """ UPDATE shopping_list SET item_name = ?, amount = ?, unit = ?, category = ?, note = ? WHERE id = ? """ execute_query(query, (item_name.strip(), amount.strip(), unit.strip(), cat, (note or "").strip(), item_id), fetch="none", commit=True) row = execute_query("SELECT * FROM shopping_list WHERE id = ?", (item_id,), fetch="one") if row: row["checked"] = bool(row.get("checked")) row["display_source"] = clean_source_label(row.get("source", "")) row["note"] = str(row.get("note") or "").strip() if isinstance(row.get("created_at"), (datetime, date)): row["created_at"] = row["created_at"].isoformat() if isinstance(row.get("updated_at"), (datetime, date)): row["updated_at"] = row["updated_at"].isoformat() return row def update_shopping_item_category(item_id: int, category: str) -> Optional[Dict[str, Any]]: """Ändert die Kategorie/den Bereich eines einzelnen Listeneintrags.""" clean_cat = category.strip() query = "UPDATE shopping_list SET category = ? WHERE id = ?" execute_query(query, (clean_cat, item_id), fetch="none", commit=True) row = execute_query("SELECT * FROM shopping_list WHERE id = ?", (item_id,), fetch="one") if row: row["checked"] = bool(row.get("checked")) row["display_source"] = clean_source_label(row.get("source", "")) row["note"] = str(row.get("note") or "").strip() if isinstance(row.get("created_at"), (datetime, date)): row["created_at"] = row["created_at"].isoformat() if isinstance(row.get("updated_at"), (datetime, date)): row["updated_at"] = row["updated_at"].isoformat() return row def get_shopping_list() -> List[Dict[str, Any]]: """Gibt alle Einträge der Einkaufsliste zurück, sortiert nach unvollständig/erledigt.""" query = """ SELECT id, item_name, amount, unit, checked, source, category, note, created_at, updated_at FROM shopping_list ORDER BY checked ASC, id DESC """ rows = execute_query(query, fetch="all") for r in rows: r["checked"] = bool(r.get("checked")) if not r.get("category"): r["category"] = guess_category_for_ingredient(r.get("item_name", "")) r["display_source"] = clean_source_label(r.get("source", "")) r["note"] = str(r.get("note") or "").strip() if isinstance(r.get("created_at"), (datetime, date)): r["created_at"] = r["created_at"].isoformat() if isinstance(r.get("updated_at"), (datetime, date)): r["updated_at"] = r["updated_at"].isoformat() return rows def add_shopping_item( item_name: str, amount: str = "", unit: str = "", source: str = "", category: Optional[str] = None, note: str = "" ) -> Dict[str, Any]: """Fügt einen einzelnen Eintrag zur Einkaufsliste hinzu.""" cat = (category or "").strip() if not cat: cat = guess_category_for_ingredient(item_name) clean_src = clean_source_label(source) clean_note = (note or "").strip() query = "INSERT INTO shopping_list (item_name, amount, unit, source, category, note, checked) VALUES (?, ?, ?, ?, ?, ?, 0)" res = execute_query(query, (item_name.strip(), amount.strip(), unit.strip(), clean_src, cat, clean_note), fetch="none", commit=True) item_id = res.get("lastrowid") row = execute_query("SELECT * FROM shopping_list WHERE id = ?", (item_id,), fetch="one") if row: row["checked"] = bool(row.get("checked")) row["display_source"] = clean_source_label(row.get("source", "")) row["note"] = str(row.get("note") or "").strip() if isinstance(row.get("created_at"), (datetime, date)): row["created_at"] = row["created_at"].isoformat() if isinstance(row.get("updated_at"), (datetime, date)): row["updated_at"] = row["updated_at"].isoformat() return row def add_shopping_items_bulk(items: List[Dict[str, Any]]) -> int: """Fügt mehrere Zutaten/Einträge auf einmal zur Einkaufsliste hinzu.""" if not items: return 0 count = 0 for it in items: name = (it.get("item_name") or it.get("name") or it.get("raw") or "").strip() if not name: continue amt = str(it.get("amount") or "").strip() unit = str(it.get("unit") or "").strip() raw_src = str(it.get("source") or it.get("recipe_title") or "").strip() src = clean_source_label(raw_src) cat = str(it.get("category") or "").strip() if not cat: cat = guess_category_for_ingredient(name) note = str(it.get("note") or "").strip() query = "INSERT INTO shopping_list (item_name, amount, unit, source, category, note, checked) VALUES (?, ?, ?, ?, ?, ?, 0)" execute_query(query, (name, amt, unit, src, cat, note), fetch="none", commit=True) count += 1 return count def toggle_shopping_item(item_id: int) -> Optional[Dict[str, Any]]: """Schaltet den Erledigt-Status eines Eintrags um.""" query = "UPDATE shopping_list SET checked = CASE WHEN checked = 1 THEN 0 ELSE 1 END WHERE id = ?" execute_query(query, (item_id,), fetch="none", commit=True) row = execute_query("SELECT * FROM shopping_list WHERE id = ?", (item_id,), fetch="one") if row: row["checked"] = bool(row.get("checked")) row["display_source"] = clean_source_label(row.get("source", "")) return row def set_shopping_item_checked(item_id: int, checked: bool) -> Optional[Dict[str, Any]]: """Setzt den Erledigt-Status eines Eintrags explizit auf True oder False.""" val = 1 if checked else 0 query = "UPDATE shopping_list SET checked = ? WHERE id = ?" execute_query(query, (val, item_id), fetch="none", commit=True) row = execute_query("SELECT * FROM shopping_list WHERE id = ?", (item_id,), fetch="one") if row: row["checked"] = bool(row.get("checked")) row["display_source"] = clean_source_label(row.get("source", "")) return row def delete_shopping_item(item_id: int) -> bool: """Löscht einen einzelnen Eintrag aus der Einkaufsliste.""" query = "DELETE FROM shopping_list WHERE id = ?" res = execute_query(query, (item_id,), fetch="none", commit=True) return res.get("rowcount", 0) > 0 def delete_checked_shopping_items() -> int: """Löscht alle bereits abgehakten Einträge aus der Einkaufsliste.""" query = "DELETE FROM shopping_list WHERE checked = 1" res = execute_query(query, fetch="none", commit=True) return res.get("rowcount", 0) def clear_shopping_list() -> int: """Leert die gesamte Einkaufsliste.""" query = "DELETE FROM shopping_list" res = execute_query(query, fetch="none", commit=True) return res.get("rowcount", 0) def get_meal_plan_ingredients(year: int, week: int) -> List[Dict[str, Any]]: """Gibt alle Zutaten aller Rezepte des Wochenplans strukturiert für die Einkaufsliste zurück.""" slots = get_meal_plan(year, week) if not slots: return [] from .ingredients import parse_ingredients_block all_recipes = {r["id"]: r for r in get_all_recipes()} day_names = ["Montag", "Dienstag", "Mittwoch", "Donnerstag", "Freitag", "Samstag", "Sonntag"] meal_labels = {"frueh": "Früh", "mittag": "Mittag", "kaffee": "Kaffee & Kuchen", "abend": "Abend"} recipe_groups = [] seen_slots = set() for s in slots: r_id = s.get("recipe_id") if not r_id or r_id not in all_recipes: continue recipe = all_recipes[r_id] d_idx = s.get("day_of_week", 0) day_str = day_names[d_idx] if 0 <= d_idx < 7 else f"Tag {d_idx}" m_type = s.get("meal_type", "") m_label = meal_labels.get(m_type, m_type.capitalize()) slot_key = f"{d_idx}_{m_type}_{r_id}" if slot_key in seen_slots: continue seen_slots.add(slot_key) slot_label = f"{day_str} {m_label}" parsed = parse_ingredients_block(recipe.get("ingredients", "")) items = [] for line in parsed: if line.get("is_header"): continue raw_text = line.get("raw", "").strip() if not raw_text: continue name = line.get("name") or raw_text amt = str(line.get("amount") or "") if line.get("amount") is not None else "" unit = line.get("unit") or "" items.append({ "recipe_id": recipe["id"], "recipe_title": recipe["title"], "slot_label": slot_label, "raw": raw_text, "name": name, "item_name": name, "amount": amt, "unit": unit }) if items: recipe_groups.append({ "recipe_id": recipe["id"], "recipe_title": recipe["title"], "slot_label": slot_label, "items": items, "ingredients": items }) return recipe_groups