Add optional subtitle to recipes, display under title, omit from TOC

This commit is contained in:
2026-10-17 17:20:02 +02:00
parent 814fd54d75
commit f8e5525847
9 changed files with 223 additions and 11 deletions
+26 -11
View File
@@ -291,6 +291,13 @@ def init_db():
if not cursor.fetchone(): if not cursor.fetchone():
cursor.execute("ALTER TABLE recipes ADD COLUMN printed_at DATETIME NULL DEFAULT NULL AFTER image_url") 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(""" cursor.execute("""
SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS SELECT COLUMN_NAME FROM INFORMATION_SCHEMA.COLUMNS
WHERE TABLE_SCHEMA = %s AND TABLE_NAME = 'recipes' AND COLUMN_NAME = 'has_portion_table' 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 ( CREATE TABLE IF NOT EXISTS recipes (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL, title TEXT NOT NULL,
subtitle TEXT DEFAULT '',
category TEXT NOT NULL, category TEXT NOT NULL,
chapter_number INTEGER NOT NULL, chapter_number INTEGER NOT NULL,
effort TEXT NOT NULL DEFAULT 'mittel', effort TEXT NOT NULL DEFAULT 'mittel',
@@ -395,6 +403,8 @@ def init_db():
# Migration check for SQLite # Migration check for SQLite
cursor.execute("PRAGMA table_info(recipes)") cursor.execute("PRAGMA table_info(recipes)")
existing_cols = [c[1] for c in cursor.fetchall()] 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: if "source" not in existing_cols:
cursor.execute("ALTER TABLE recipes ADD COLUMN source TEXT DEFAULT ''") cursor.execute("ALTER TABLE recipes ADD COLUMN source TEXT DEFAULT ''")
if "printed_at" not in existing_cols: if "printed_at" not in existing_cols:
@@ -713,9 +723,11 @@ def add_recipe(
source: str = "", source: str = "",
image_url: str = "", image_url: str = "",
printed_at: Optional[str] = None, printed_at: Optional[str] = None,
has_portion_table: int = 0 has_portion_table: int = 0,
subtitle: str = ""
) -> Dict[str, Any]: ) -> Dict[str, Any]:
title = title.strip() title = title.strip()
subtitle = (subtitle or "").strip()
category = category.strip() category = category.strip()
effort = effort.strip().lower() effort = effort.strip().lower()
source = source.strip() source = source.strip()
@@ -732,10 +744,10 @@ def add_recipe(
res = execute_query( 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) 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 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", fetch="none",
commit=True commit=True
) )
@@ -758,7 +770,8 @@ def update_recipe(
source: str = "", source: str = "",
image_url: Optional[str] = None, image_url: Optional[str] = None,
printed_at: 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]: ) -> Dict[str, Any]:
title = title.strip() title = title.strip()
category = category.strip() category = category.strip()
@@ -772,6 +785,8 @@ def update_recipe(
if not existing: if not existing:
raise KeyError(f"Rezept #{recipe_id} nicht gefunden.") 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) 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) 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: if image_url is not None:
query = """ query = """
UPDATE recipes 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 = ? 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: else:
query = """ query = """
UPDATE recipes 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 = ? 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) res = execute_query(query, params, fetch="none", commit=True)
return get_recipe(recipe_id) return get_recipe(recipe_id)
@@ -826,9 +841,9 @@ def get_all_recipes(
params.append(category.strip()) params.append(category.strip())
if search and search.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()}%" s = f"%{search.strip()}%"
params.extend([s, s, s]) params.extend([s, s, s, s])
if printed_status == "unprinted": if printed_status == "unprinted":
query += " AND printed_at IS NULL" query += " AND printed_at IS NULL"
+4
View File
@@ -580,6 +580,7 @@ def api_get_recipe(recipe_id: int):
@app.post("/api/recipes") @app.post("/api/recipes")
async def api_create_recipe( async def api_create_recipe(
title: str = Form(...), title: str = Form(...),
subtitle: str = Form(""),
chapter_number: int = Form(...), chapter_number: int = Form(...),
effort: str = Form("mittel"), effort: str = Form("mittel"),
prep_time: str = Form(""), prep_time: str = Form(""),
@@ -604,6 +605,7 @@ async def api_create_recipe(
try: try:
recipe = add_recipe( recipe = add_recipe(
title=title, title=title,
subtitle=subtitle,
category=category_title, category=category_title,
chapter_number=chapter_number, chapter_number=chapter_number,
effort=effort, effort=effort,
@@ -636,6 +638,7 @@ async def api_create_recipe(
async def api_update_recipe( async def api_update_recipe(
recipe_id: int, recipe_id: int,
title: str = Form(...), title: str = Form(...),
subtitle: Optional[str] = Form(None),
chapter_number: int = Form(...), chapter_number: int = Form(...),
effort: str = Form("mittel"), effort: str = Form("mittel"),
prep_time: str = Form(""), prep_time: str = Form(""),
@@ -661,6 +664,7 @@ async def api_update_recipe(
recipe = update_recipe( recipe = update_recipe(
recipe_id=recipe_id, recipe_id=recipe_id,
title=title, title=title,
subtitle=subtitle,
category=category_title, category=category_title,
chapter_number=chapter_number, chapter_number=chapter_number,
effort=effort, effort=effort,
+15
View File
@@ -542,6 +542,14 @@
line-height: 1.15; 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 { .recipe-meta-row {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@@ -945,6 +953,13 @@
word-break: break-word; word-break: break-word;
} }
.recipe-subtitle {
font-size: 1.25rem;
margin-top: -4px;
margin-bottom: 8px;
word-break: break-word;
}
.recipe-meta-row { .recipe-meta-row {
font-size: 0.95rem; font-size: 0.95rem;
gap: 10px; gap: 10px;
+17
View File
@@ -290,6 +290,14 @@
color: var(--tannengruen) !important; 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 { .recipe-meta-row {
display: flex !important; display: flex !important;
flex-wrap: wrap !important; flex-wrap: wrap !important;
@@ -685,6 +693,9 @@
<!-- Header (Ohne Kapitelangabe & ohne Quelle im Titel) --> <!-- Header (Ohne Kapitelangabe & ohne Quelle im Titel) -->
<header class="recipe-header"> <header class="recipe-header">
<h3 class="recipe-title">{{ recipe.title }}</h3> <h3 class="recipe-title">{{ recipe.title }}</h3>
{% if recipe.subtitle and recipe.subtitle.strip() %}
<div class="recipe-subtitle">{{ recipe.subtitle.strip() }}</div>
{% endif %}
<div class="recipe-meta-row"> <div class="recipe-meta-row">
{% if recipe.effort %} {% if recipe.effort %}
@@ -1174,6 +1185,12 @@
<input type="text" id="m-title" name="title" class="cb-input" placeholder="z. B. Klassische Waffeln" required autocomplete="off"> <input type="text" id="m-title" name="title" class="cb-input" placeholder="z. B. Klassische Waffeln" required autocomplete="off">
</div> </div>
<!-- Untertitel -->
<div class="cb-form-group">
<label for="m-subtitle" class="cb-label">Untertitel (optional)</label>
<input type="text" id="m-subtitle" name="subtitle" class="cb-input" placeholder="z. B. mit knusprigen Croutons" autocomplete="off">
</div>
<!-- Kapitel --> <!-- Kapitel -->
<div class="cb-form-group"> <div class="cb-form-group">
<label for="m-chapter_number" class="cb-label">Kategorie / Kapitel *</label> <label for="m-chapter_number" class="cb-label">Kategorie / Kapitel *</label>
+3
View File
@@ -63,6 +63,9 @@
<!-- Header (Ohne Kapitelangabe & ohne Quelle im Titel) --> <!-- Header (Ohne Kapitelangabe & ohne Quelle im Titel) -->
<header class="recipe-header"> <header class="recipe-header">
<h2 class="recipe-title">{{ recipe.title }}</h2> <h2 class="recipe-title">{{ recipe.title }}</h2>
{% if recipe.subtitle and recipe.subtitle.strip() %}
<div class="recipe-subtitle">{{ recipe.subtitle.strip() }}</div>
{% endif %}
<div class="recipe-meta-row"> <div class="recipe-meta-row">
{% if recipe.effort %} {% if recipe.effort %}
+8
View File
@@ -65,6 +65,14 @@
placeholder="z. B. Klassische Spaghetti Carbonara" required autocomplete="off"> placeholder="z. B. Klassische Spaghetti Carbonara" required autocomplete="off">
</div> </div>
<!-- Untertitel -->
<div class="cb-form-group">
<label for="subtitle" class="cb-label">Untertitel (optional)</label>
<input type="text" id="subtitle" name="subtitle" class="cb-input"
value="{{ recipe.subtitle if recipe and recipe.subtitle else '' }}"
placeholder="z. B. mit knusprigen Croutons" autocomplete="off">
</div>
<!-- Kategorie / Kapitel --> <!-- Kategorie / Kapitel -->
<div class="cb-form-group"> <div class="cb-form-group">
<label for="chapter_number" class="cb-label">Kategorie / Kapitel *</label> <label for="chapter_number" class="cb-label">Kategorie / Kapitel *</label>
+29
View File
@@ -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()
+18
View File
@@ -41,6 +41,24 @@ def test_routes():
assert len(matches) == 0, f"Found {len(matches)} double numbers in /cookbook: {matches[:3]}" assert len(matches) == 0, f"Found {len(matches)} double numbers in /cookbook: {matches[:3]}"
print("✓ Zero double numbers in rendered /cookbook steps confirmed!") print("✓ Zero double numbers in rendered /cookbook steps confirmed!")
# Verify subtitle behavior:
# 1. Kressesuppe is in TOC, without subtitle
toc_titles = re.findall(r'<span class="toc-rec-title">([^<]+)</span>', 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 '<h3 class="recipe-title">Kressesuppe</h3>' in res_cb.text
assert '<div class="recipe-subtitle">mit Croutons</div>' 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 '<h2 class="recipe-title">Kressesuppe</h2>' in res_detail.text
assert '<div class="recipe-subtitle">mit Croutons</div>' in res_detail.text
print("✓ Recipe detail page check: Title and subtitle present.")
print("\nAll route tests passed successfully!") print("\nAll route tests passed successfully!")
if __name__ == "__main__": if __name__ == "__main__":
+103
View File
@@ -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()