diff --git a/app/database.py b/app/database.py index a1daa06..9a7fade 100755 --- a/app/database.py +++ b/app/database.py @@ -291,6 +291,13 @@ def init_db(): 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 = 'subtitle' + """, (MARIADB_NAME,)) + if not cursor.fetchone(): + cursor.execute("ALTER TABLE recipes ADD COLUMN subtitle VARCHAR(255) DEFAULT '' AFTER title") + cursor.execute(""" SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'recipes' AND COLUMN_NAME = 'has_portion_table' @@ -375,6 +382,7 @@ def init_db(): CREATE TABLE IF NOT EXISTS recipes ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, + subtitle TEXT DEFAULT '', category TEXT NOT NULL, chapter_number INTEGER NOT NULL, effort TEXT NOT NULL DEFAULT 'mittel', @@ -395,6 +403,8 @@ def init_db(): # Migration check for SQLite cursor.execute("PRAGMA table_info(recipes)") existing_cols = [c[1] for c in cursor.fetchall()] + if "subtitle" not in existing_cols: + cursor.execute("ALTER TABLE recipes ADD COLUMN subtitle TEXT DEFAULT ''") if "source" not in existing_cols: cursor.execute("ALTER TABLE recipes ADD COLUMN source TEXT DEFAULT ''") if "printed_at" not in existing_cols: @@ -713,9 +723,11 @@ def add_recipe( source: str = "", image_url: str = "", printed_at: Optional[str] = None, - has_portion_table: int = 0 + has_portion_table: int = 0, + subtitle: str = "" ) -> Dict[str, Any]: title = title.strip() + subtitle = (subtitle or "").strip() category = category.strip() effort = effort.strip().lower() source = source.strip() @@ -732,10 +744,10 @@ def add_recipe( 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + INSERT INTO recipes (title, subtitle, 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), + (title, subtitle, 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 ) @@ -758,7 +770,8 @@ def update_recipe( source: str = "", image_url: Optional[str] = None, printed_at: Optional[str] = None, - has_portion_table: Optional[int] = None + has_portion_table: Optional[int] = None, + subtitle: Optional[str] = None ) -> Dict[str, Any]: title = title.strip() category = category.strip() @@ -772,6 +785,8 @@ def update_recipe( if not existing: raise KeyError(f"Rezept #{recipe_id} nicht gefunden.") + use_subtitle = (subtitle if subtitle is not None else existing.get("subtitle", "")).strip() + instructions = clean_instructions_text(instructions) use_portion_table = int(bool(has_portion_table)) if has_portion_table is not None else existing.get("has_portion_table", 0) @@ -779,17 +794,17 @@ def update_recipe( 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 = ? + SET title = ?, subtitle = ?, 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) + params = (title, use_subtitle, 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 = ? + SET title = ?, subtitle = ?, 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) + params = (title, use_subtitle, 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) @@ -826,9 +841,9 @@ def get_all_recipes( params.append(category.strip()) if search and search.strip(): - query += " AND (title LIKE ? OR ingredients LIKE ? OR instructions LIKE ?)" + query += " AND (title LIKE ? OR subtitle LIKE ? OR ingredients LIKE ? OR instructions LIKE ?)" s = f"%{search.strip()}%" - params.extend([s, s, s]) + params.extend([s, s, s, s]) if printed_status == "unprinted": query += " AND printed_at IS NULL" diff --git a/app/main.py b/app/main.py index 2bfea00..5fa7f25 100755 --- a/app/main.py +++ b/app/main.py @@ -580,6 +580,7 @@ def api_get_recipe(recipe_id: int): @app.post("/api/recipes") async def api_create_recipe( title: str = Form(...), + subtitle: str = Form(""), chapter_number: int = Form(...), effort: str = Form("mittel"), prep_time: str = Form(""), @@ -604,6 +605,7 @@ async def api_create_recipe( try: recipe = add_recipe( title=title, + subtitle=subtitle, category=category_title, chapter_number=chapter_number, effort=effort, @@ -636,6 +638,7 @@ async def api_create_recipe( async def api_update_recipe( recipe_id: int, title: str = Form(...), + subtitle: Optional[str] = Form(None), chapter_number: int = Form(...), effort: str = Form("mittel"), prep_time: str = Form(""), @@ -661,6 +664,7 @@ async def api_update_recipe( recipe = update_recipe( recipe_id=recipe_id, title=title, + subtitle=subtitle, category=category_title, chapter_number=chapter_number, effort=effort, diff --git a/app/static/cookbook.css b/app/static/cookbook.css index cedc107..8271512 100755 --- a/app/static/cookbook.css +++ b/app/static/cookbook.css @@ -542,6 +542,14 @@ line-height: 1.15; } +.recipe-subtitle { + font-family: var(--font-hand); + font-size: 1.55rem; + color: #4a453f; + margin: -4px 0 12px 0; + line-height: 1.25; +} + .recipe-meta-row { display: flex; flex-wrap: wrap; @@ -945,6 +953,13 @@ word-break: break-word; } + .recipe-subtitle { + font-size: 1.25rem; + margin-top: -4px; + margin-bottom: 8px; + word-break: break-word; + } + .recipe-meta-row { font-size: 0.95rem; gap: 10px; diff --git a/app/templates/cookbook.html b/app/templates/cookbook.html index b2304f7..11bf563 100755 --- a/app/templates/cookbook.html +++ b/app/templates/cookbook.html @@ -290,6 +290,14 @@ color: var(--tannengruen) !important; } + .recipe-subtitle { + font-family: var(--font-hand) !important; + font-size: 1.05rem !important; + line-height: 1.15 !important; + margin: -0.5mm 0 1.5mm 0 !important; + color: #4a453f !important; + } + .recipe-meta-row { display: flex !important; flex-wrap: wrap !important; @@ -685,6 +693,9 @@

{{ recipe.title }}

+ {% if recipe.subtitle and recipe.subtitle.strip() %} +
{{ recipe.subtitle.strip() }}
+ {% endif %}
{% if recipe.effort %} @@ -1174,6 +1185,12 @@
+ +
+ + +
+
diff --git a/app/templates/recipe_detail.html b/app/templates/recipe_detail.html index d37f0ab..58ead8e 100755 --- a/app/templates/recipe_detail.html +++ b/app/templates/recipe_detail.html @@ -63,6 +63,9 @@

{{ recipe.title }}

+ {% if recipe.subtitle and recipe.subtitle.strip() %} +
{{ recipe.subtitle.strip() }}
+ {% endif %}
{% if recipe.effort %} diff --git a/app/templates/recipe_form.html b/app/templates/recipe_form.html index cc35160..366d977 100755 --- a/app/templates/recipe_form.html +++ b/app/templates/recipe_form.html @@ -65,6 +65,14 @@ placeholder="z. B. Klassische Spaghetti Carbonara" required autocomplete="off">
+ +
+ + +
+
diff --git a/scripts/migrate_subtitle.py b/scripts/migrate_subtitle.py new file mode 100644 index 0000000..10f40bf --- /dev/null +++ b/scripts/migrate_subtitle.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from app.database import get_connection, MARIADB_NAME + +sys.stdout.reconfigure(encoding='utf-8') + +conn = get_connection() +cur = conn.cursor() + +cur.execute(""" + SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'recipes' AND COLUMN_NAME = 'subtitle' +""", (MARIADB_NAME,)) + +if not cur.fetchone(): + cur.execute("ALTER TABLE recipes ADD COLUMN subtitle VARCHAR(255) DEFAULT '' AFTER title") + print("Column 'subtitle' added to MariaDB 'recipes' table.") +else: + print("Column 'subtitle' already exists in MariaDB 'recipes' table.") + +cur.execute("DESCRIBE recipes") +for col in cur.fetchall(): + print(f" {col['Field']}: {col['Type']}") + +conn.close() diff --git a/scripts/test_endpoints.py b/scripts/test_endpoints.py index d97be65..f5b1c44 100644 --- a/scripts/test_endpoints.py +++ b/scripts/test_endpoints.py @@ -41,6 +41,24 @@ def test_routes(): assert len(matches) == 0, f"Found {len(matches)} double numbers in /cookbook: {matches[:3]}" print("✓ Zero double numbers in rendered /cookbook steps confirmed!") + # Verify subtitle behavior: + # 1. Kressesuppe is in TOC, without subtitle + toc_titles = re.findall(r'([^<]+)', res_cb.text) + assert "Kressesuppe" in toc_titles, "Kressesuppe not found in TOC titles!" + assert "Kressesuppe mit Croutons" not in toc_titles, "Old combined title still in TOC!" + print("✓ TOC Title check: Only 'Kressesuppe' appears in TOC, not the subtitle.") + + # 2. Recipe page renders title and subtitle + assert '

Kressesuppe

' in res_cb.text + assert '
mit Croutons
' in res_cb.text + print("✓ Cookbook recipe page check: Title is Kressesuppe, Subtitle is mit Croutons.") + + # 3. Recipe detail page renders title and subtitle + res_detail = client.get("/cookbook/recipe/51") + assert '

Kressesuppe

' in res_detail.text + assert '
mit Croutons
' in res_detail.text + print("✓ Recipe detail page check: Title and subtitle present.") + print("\nAll route tests passed successfully!") if __name__ == "__main__": diff --git a/scripts/update_recipe_subtitles.py b/scripts/update_recipe_subtitles.py new file mode 100644 index 0000000..e344f22 --- /dev/null +++ b/scripts/update_recipe_subtitles.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Apply titles and optional subtitles to recipes in MariaDB. +""" +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +from app.database import get_connection + +sys.stdout.reconfigure(encoding='utf-8') + +# Mapping of ID -> (new_title, new_subtitle) +SUBTITLE_UPDATES = { + # Specifically requested by user: + 51: ("Kressesuppe", "mit Croutons"), + + # Parenthetical subtitles: + 9: ("Gebratene Kasknödel", "Käseknödel aus der Pfanne"), + 24: ("Galaktoboureko", "Griechischer Grießauflauf"), + 30: ("Heidelbeer-Tiramisu", "fruchtig-frisch ohne Ei"), + 29: ("Zitronen-Tiramisu", "mit Lemon Curd (ohne rohes Ei)"), + 37: ("Donauwelle", "Super Easy vom Blech"), + 39: ("Fudgy Kidneybohnen-Brownies", "ohne Mehl"), + 82: ("Blumenkohl-Curry mit Reis", "Cauliflower Curry"), + 87: ("Pla Pad Krapao", "Thai-Basilikum-Fischfilet mit Reis"), + 92: ("Schmackefatz", "Süßer Nudel-Quark-Auflauf"), + 95: ("Saftiger Karottenkuchen", "Rüblikuchen"), + 107: ("Jian Baos", "Gebratene Bao-Taschen"), + 108: ("Okonomiyaki", "Japanischer Kohlpfannkuchen"), + 109: ("Klassische Dim Sum", "Gyoza / Dumplings"), + 112: ("Soft Cinnamon Rolls", "Amerikanische Zimtschnecken"), + 113: ("Zimtschneckenkuchen", "ohne Hefe"), + + # Long composite titles with "mit ..." / "auf ..." / "aus ...": + 3: ("Champignons wie vom Weihnachtsmarkt", "mit Knoblauchsauce"), + 7: ("Spaghetti in Zitronenbutter", "und Lachs mit Macadamiakruste"), + 8: ("Orzo-Halloumi-Salat", "mit Granatapfel & Honig-Senf-Dressing"), + 11: ("Cremige Zucchini-Bohnenpfanne", "mit Erdnussbutter"), + 12: ("Blätterteigpäckchen", "mit grünem Spargel & Pecorino"), + 14: ("Traditionelle italienische Focaccia", "mit Rosmarin"), + 15: ("Fluffige Bao Buns", "mit marinierten Champignons & Edamame"), + 16: ("Schnelle Ramen", "mit Miso-Dashibrühe & Räuchertofu"), + 17: ("Traditionelle Ramen", "mit Shoyu-Tare & knusprigem Hack"), + 18: ("One-Pot Mie-Nudeln", "mit Kokos, Curry & Erdnussbutter"), + 19: ("Gemüse nach Gonbao-Art", "mit gerösteten Erdnüssen"), + 20: ("Knuspriger Orangentofu", "mit Duftreis"), + 21: ("Lachs in Thai-Curry-Soße", "mit Zucchini & goldenem Reis"), + 22: ("American Pancakes", "mit Beeren & Ahornsirup"), + 25: ("Crema Catalana", "mit feiner Zuckerkruste"), + 26: ("Cremiges veganes Schokomousse", "aus Seidentofu"), + 27: ("Mango-Limetten-Triffle", "mit weißer Schokolade"), + 28: ("Lebkuchen-Mousse", "mit Glühweinkirschen"), + 32: ("Zimt-Biskuitrolle", "mit karamellisierten Pflaumen & Krokant"), + 34: ("Apfel-Salzkaramell-Tarte", "mit Erdnüssen"), + 36: ("Bounty®-Kuchen", "vom Blech mit Kokoscreme"), + 38: ("Gedeckter Apfelkuchen", "mit Streuseln"), + 53: ("Blätterteig Lauchstrudel", "mit Speck"), + 56: ("Klassische Senfeier", "mit Schnittlauch & Kresse"), + 57: ("Würzige Fischboulette", "in Kräuterpanade"), + 59: ("Maultaschen", "auf warmem Wurzelgemüse-Salat"), + 60: ("Allgäuer Käsenudeln", "mit Tomatensauce"), + 63: ("Spaghetti Napoletana", "mit knusprigen Butterbröseln"), + 65: ("Thunfischsauce", "mit Orecchiette"), + 68: ("Glasierte Garnelen", "auf Umami-Kartoffelstampf"), + 71: ("Korean Glazed Potatoes", "mit Chili-Öl & Gurkensalat"), + 72: ("Teriyaki-Fischfilet", "auf Ingwer-Süßkartoffelstampf"), + 74: ("Schummel-Paella", "mit Meeresfrüchten & Hähnchen"), + 75: ("Vegane Bowl", "mit Sesam-Süßkartoffel & Avocado"), + 77: ("Hausgemachtes Naan-Brot", "mit Knoblauchbutter"), + 79: ("Rahm-Fladen", "mit Schmand, Speck & Frühlingszwiebeln"), + 80: ("Schwäbische Dinnete", "mit Kartoffeln und Käse"), + 81: ("Veggie Po-Boy Sandwich", "mit crunchy Champignons"), + 83: ("Curry-Pfannkuchen", "mit würziger Bohnenfüllung"), + 85: ("Indisches rotes Linsen-Dal", "mit Kokos"), + 86: ("Pad Thai", "mit veganem Hähnchen"), + 88: ("Shredded Tofu-Linsen-Curry", "mit Kokosmilch"), + 89: ("Umami-Kräuterseitlinge", "mit Sesam & Frühlingszwiebeln"), + 90: ("Apple Crumble", "mit Haferflocken-Streuseln"), + 91: ("Palatschinken", "mit feiner Walnussfüllung"), + 97: ("Rhabarberkuchen", "mit Quarkcreme und Streuseln"), + 98: ("Zwetschgenkuchen", "mit feinen Zimtstreuseln"), + 103: ("Soba „Sommerrollen“-Salat", "mit Tofu und Erdnussdressing"), + 111: ("Zimt-Apfel-Blondies", "mit Mandelkruste") +} + +def main(): + conn = get_connection() + cur = conn.cursor() + + count = 0 + for rid, (title, subtitle) in SUBTITLE_UPDATES.items(): + cur.execute("UPDATE recipes SET title = %s, subtitle = %s WHERE id = %s", (title, subtitle, rid)) + count += 1 + print(f"Updated #{rid}: '{title}' | Untertitel: '{subtitle}'") + + conn.commit() + conn.close() + print(f"\nSuccessfully updated {count} recipes with titles and subtitles.") + +if __name__ == "__main__": + main()