From 83e6908f9f7ea73d4b18c5c46861fda3c7dde2fc Mon Sep 17 00:00:00 2001 From: smueller Date: Tue, 7 Jul 2026 13:26:26 +0200 Subject: [PATCH] Enhance newsletter generation with editorial features - Introduce optional fields for "Top Highlights" and "Editorial Note" in the newsletter dashboard, allowing editors to highlight key articles and provide additional context. - Update the HTML and plain text generation functions to incorporate these new fields, ensuring they are displayed correctly in the newsletter output. - Modify the dashboard template to include input areas for these new features, improving the user interface for newsletter creation. --- README.md | 6 + app/main.py | 26 +- app/services/newsletter.py | 695 ++++++++++++++++++++++------------- app/templates/dashboard.html | 10 + 4 files changed, 483 insertions(+), 254 deletions(-) diff --git a/README.md b/README.md index 0382256..2c82177 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,13 @@ Produktionsnahes internes Tool zur Erstellung von Thomas-Krenn.AG Newslettern au - Zeitraum in Tagen - Nur bearbeitete Artikel - Kategorie +- Redaktioneller Feinschliff pro Ausgabe: + - **Redaktionsnotiz** – optionaler Hinweistext, der als eigene Box im Newsletter erscheint + - **Top-Highlights** – bis zu 3 Artikel per Titel hervorheben (erscheinen oben als Highlight-Box) - Datenquelle: MediaWiki API (`recentchanges` über `wikiDE/api.php`) +- E-Mail-Layout im Thomas-Krenn Corporate Design: + - Hero-Header mit Logo, Artikel-Karten mit „Zum Beitrag“-Button, Kontakt- und Adress-Footer + - Neu-/Bearbeitet-Trennung, Zusammenfassungs-Kacheln, Outlook-taugliche Kopiervorschau - Export: - Plain Text (für Outlook) - Raw HTML diff --git a/app/main.py b/app/main.py index 9759cb1..92d0456 100644 --- a/app/main.py +++ b/app/main.py @@ -29,6 +29,7 @@ from app.services.newsletter import ( create_subject, filter_articles, parse_display_options, + parse_highlights, split_articles_by_type, ) from app.services.smtp_sender import get_smtp_settings, run_scheduled_send_if_due, send_newsletter @@ -213,6 +214,8 @@ async def dashboard(request: Request, db: Session = Depends(get_db), current_use "only_edited": False, "category": "", "display": DEFAULT_DISPLAY, + "editor_tip": "", + "highlights": "", }, "wiki_error": None, }) @@ -231,6 +234,8 @@ async def dashboard_generate( show_date: str | None = Form(None), show_user: str | None = Form(None), show_category: str | None = Form(None), + editor_tip: str = Form(""), + highlights: str = Form(""), db: Session = Depends(get_db), current_user: User = Depends(require_editor_or_admin), ): @@ -238,6 +243,7 @@ async def dashboard_generate( days = max(1, min(days, 90)) only_edited_flag = only_edited == "true" display = parse_display_options(show_date, show_user, show_category) + highlight_list = parse_highlights(highlights) wiki_error = None filtered: list = [] articles_new: list = [] @@ -250,9 +256,9 @@ async def dashboard_generate( filtered = filter_articles(articles, category_filter=category or None) articles_new, articles_edited = split_articles_by_type(filtered) title = create_subject(days, category) - plain_text = create_plain_text(filtered, days, display, category) - raw_html = create_html(filtered, days, display, category) - outlook_html = create_outlook_html(filtered, days, display, category) + plain_text = create_plain_text(filtered, days, display, category, editor_tip, highlight_list) + raw_html = create_html(filtered, days, display, category, editor_tip, highlight_list) + outlook_html = create_outlook_html(filtered, days, display, category, editor_tip, highlight_list) run_scheduled_send_if_due(db, title, raw_html, plain_text) except WikiFetchError as exc: wiki_error = exc.message @@ -270,6 +276,8 @@ async def dashboard_generate( "only_edited": only_edited_flag, "category": category, "display": display, + "editor_tip": editor_tip, + "highlights": highlights, }, "send_logs": db.query(SendLog).order_by(SendLog.created_at.desc()).limit(20).all(), "wiki_error": wiki_error, @@ -288,20 +296,24 @@ async def send_now( show_date: str | None = Form(None), show_user: str | None = Form(None), show_category: str | None = Form(None), + editor_tip: str = Form(""), + highlights: str = Form(""), db: Session = Depends(get_db), current_user: User = Depends(require_editor_or_admin), ): validate_csrf(request, csrf_token) display = parse_display_options(show_date, show_user, show_category) only_edited_flag = only_edited == "true" + highlight_list = parse_highlights(highlights) + days = max(1, min(days, 90)) try: - articles = await wiki_service.get_recent_changes(days=max(1, min(days, 90)), only_edited=only_edited_flag) + articles = await wiki_service.get_recent_changes(days=days, only_edited=only_edited_flag) except WikiFetchError as exc: raise HTTPException(status_code=502, detail=exc.message) from exc filtered = filter_articles(articles, category_filter=category or None) - title = create_subject(max(1, min(days, 90)), category) - plain_text = create_plain_text(filtered, max(1, min(days, 90)), display, category) - raw_html = create_html(filtered, max(1, min(days, 90)), display, category) + title = create_subject(days, category) + plain_text = create_plain_text(filtered, days, display, category, editor_tip, highlight_list) + raw_html = create_html(filtered, days, display, category, editor_tip, highlight_list) recipients = [r.strip() for r in get_config(db, "smtp.recipients", "").split(",") if r.strip()] send_newsletter(db, title, raw_html, plain_text, recipients, scheduled=False) return RedirectResponse(url="/dashboard", status_code=status.HTTP_302_FOUND) diff --git a/app/services/newsletter.py b/app/services/newsletter.py index dabf124..fbdc58b 100644 --- a/app/services/newsletter.py +++ b/app/services/newsletter.py @@ -6,6 +6,7 @@ from urllib.parse import quote WIKI_ARTICLE_BASE = "https://www.thomas-krenn.com/de/wiki/" WIKI_HOME_URL = "https://www.thomas-krenn.com/de/wiki/Hauptseite" ORG_NAME = "Thomas-Krenn.AG" +TK_LOGO_URL = "https://www.thomas-krenn.com/res/pics/tk_logo_340px.png" LINE = "=" * 78 SUBLINE = "-" * 78 @@ -14,6 +15,7 @@ FONT = "Arial, Helvetica, sans-serif" COLOR_TK_ORANGE = "#ED6B06" COLOR_TK_ORANGE_DARK = "#C95605" COLOR_TK_ORANGE_LIGHT = "#FFF4ED" +COLOR_TK_ORANGE_BORDER = "#F5C9A6" COLOR_TK_GRAY_DARK = "#4A4A4A" COLOR_TK_GRAY = "#6B6B6B" COLOR_TK_GRAY_LIGHT = "#F5F5F5" @@ -23,6 +25,7 @@ COLOR_HEADING = COLOR_TK_GRAY_DARK COLOR_LINK = COLOR_TK_ORANGE COLOR_BORDER = "#E0E0E0" COLOR_BG = COLOR_TK_GRAY_LIGHT +COLOR_PAGE_BG = "#ECEEF1" COLOR_WHITE = "#FFFFFF" COLOR_ACCENT_NEW = COLOR_TK_ORANGE COLOR_ACCENT_NEW_BG = COLOR_TK_ORANGE_LIGHT @@ -105,11 +108,53 @@ def create_subject(days: int, category: str = "") -> str: return base +def parse_highlights(raw: str | None) -> list[str]: + """Zerlegt das Highlight-Eingabefeld (Titel pro Zeile) in eine Liste.""" + if not raw: + return [] + return [line.strip() for line in raw.splitlines() if line.strip()] + + +def _match_highlights( + articles: list[dict[str, Any]], + highlights: list[str] | None, +) -> list[dict[str, Any]]: + """Ordnet Highlight-Titel den geladenen Artikeln zu (max. 3).""" + if not highlights: + return [] + result: list[dict[str, Any]] = [] + for wanted in highlights: + key = wanted.strip().lower() + if not key: + continue + for article in articles: + if article.get("title", "").strip().lower() == key: + result.append(article) + break + if len(result) >= 3: + break + return result + + +def _summary_sentence(total: int) -> str: + if total == 0: + return "Aktuell wurden keine neuen Wiki-Artikel gefunden." + if total == 1: + return "Es gibt einen neuen bzw. aktualisierten Wiki-Artikel." + return f"Es gibt {total} neue bzw. aktualisierte Wiki-Artikel." + + +# --------------------------------------------------------------------------- +# Plain-Text +# --------------------------------------------------------------------------- + def create_plain_text( articles: list[dict[str, Any]], days: int, display: DisplayOptions | None = None, category: str = "", + editor_tip: str = "", + highlights: list[str] | None = None, ) -> str: display = display or DEFAULT_DISPLAY new_articles, edited_articles = split_articles_by_type(articles) @@ -135,6 +180,14 @@ def create_plain_text( ] ) + highlight_articles = _match_highlights(new_articles + edited_articles, highlights) + if highlight_articles: + lines.extend([SUBLINE, "TOP HIGHLIGHTS", SUBLINE, ""]) + for idx, item in enumerate(highlight_articles, start=1): + lines.append(f" {idx}. {item.get('title', 'Ohne Titel')}") + lines.append(f" {_article_url(item.get('title', ''))}") + lines.append("") + if not new_articles and not edited_articles: lines.extend( [ @@ -143,9 +196,10 @@ def create_plain_text( SUBLINE, "Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.", "", - _plain_footer(new_articles, edited_articles), ] ) + lines.extend(_plain_editor_tip(editor_tip)) + lines.append(_plain_footer(new_articles, edited_articles)) return "\n".join(lines).strip() lines.extend(_plain_section("NEUE ARTIKEL", new_articles, display, empty_text="Keine neuen Artikel im Zeitraum.")) @@ -159,24 +213,47 @@ def create_plain_text( ) ) lines.append("") + lines.extend(_plain_editor_tip(editor_tip)) lines.append(_plain_footer(new_articles, edited_articles)) return "\n".join(lines).strip() +def _plain_editor_tip(editor_tip: str) -> list[str]: + text = (editor_tip or "").strip() + if not text: + return [] + lines = [SUBLINE, "REDAKTIONSNOTIZ", SUBLINE, ""] + lines.extend(f" {line}" for line in text.splitlines()) + lines.append("") + return lines + + +# --------------------------------------------------------------------------- +# HTML (Standard, für SMTP-Versand & Quelltext) +# --------------------------------------------------------------------------- + def create_html( articles: list[dict[str, Any]], days: int, display: DisplayOptions | None = None, category: str = "", + editor_tip: str = "", + highlights: list[str] | None = None, ) -> str: display = display or DEFAULT_DISPLAY new_articles, edited_articles = split_articles_by_type(articles) period_start, period_end = _period_range(days) - created_at = datetime.now().strftime("%d.%m.%Y %H:%M") + total = len(new_articles) + len(edited_articles) subject = escape(create_subject(days, category)) + preheader = escape(_summary_sentence(total)) + period_label = escape(f"{period_start} – {period_end}") + summary_text = escape(_summary_sentence(total)) + + highlight_articles = _match_highlights(new_articles + edited_articles, highlights) + highlights_section = _html_highlights(highlight_articles) if not new_articles and not edited_articles: - body = f'

Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.

' + body = _html_empty_card("Für diesen Zeitraum wurden keine passenden Wiki-Änderungen gefunden.") else: body = "\n".join( [ @@ -185,85 +262,280 @@ def create_html( ] ) - summary = _html_summary(new_articles, edited_articles) - intro = escape(_intro_sentence(period_start, period_end)) - category_hint = ( - f'

Filter: Kategorie {escape(category.strip())}

' - if category.strip() - else "" + summary_section = ( + f'{_html_summary(new_articles, edited_articles)}' ) + editor_tip_section = _html_editor_tip(editor_tip) + + category_hint = "" + if category.strip(): + category_hint = ( + f'' + f'

' + f'Filter: Kategorie {escape(category.strip())}

' + ) return f""" - + {subject} + - - - - - -
- - - - - - - - - - - {category_hint} - - - - - - - - - -
 
-

Interner Newsletter

-

Thomas-Krenn Wiki Update

-

{escape(ORG_NAME)}

-
- - - - -
-

Liebe Kolleginnen und Kollegen,

-

{intro}

-
-
-

Erstellt am {created_at}

-
- {body} - {summary} - - - - -
- Zum Thomas-Krenn-Wiki → -
-
- Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {escape(ORG_NAME)} -
-
+ +
{preheader} ‌ ‌ ‌
+
+ + + + +
+ + + + + + + + + + + {category_hint} + {highlights_section} + {body} + {summary_section} + {editor_tip_section} + + + + + + + + + + +
+
""" +def _cta_button(url: str, label: str) -> str: + return ( + f'' + f'
' + f'{escape(label)}' + f'
' + ) + + +def _html_empty_card(text: str) -> str: + return ( + f'' + f'' + f'
' + f'{escape(text)}
' + ) + + +def _html_highlights(items: list[dict[str, Any]]) -> str: + if not items: + return "" + rows = "" + for idx, item in enumerate(items[:3], start=1): + url = escape(_article_url(item.get("title", ""))) + title = escape(item.get("title", "Ohne Titel")) + rows += ( + f'' + f'{idx}. ' + f'{title}' + ) + return ( + f'' + f'' + f'' + f'' + f'
Top Highlights
{rows}
' + ) + + +def _html_editor_tip(editor_tip: str) -> str: + text = (editor_tip or "").strip() + if not text: + return "" + body = escape(text).replace("\n", "
") + return ( + f'' + f'' + f'' + f'' + f'
Redaktionsnotiz
{body}
' + ) + + +def _html_section( + heading: str, + items: list[dict[str, Any]], + display: DisplayOptions, + empty_text: str, + kind: str = "edited", +) -> str: + accent = COLOR_ACCENT_NEW if kind == "new" else COLOR_ACCENT_EDIT + heading_html = ( + f'' + f'
{escape(heading)} ' + f'({len(items)})
' + ) + if not items: + return heading_html + ( + f'{escape(empty_text)}' + ) + cards = "".join(_html_article_card(idx, item, display, kind) for idx, item in enumerate(items, start=1)) + return heading_html + cards + + +def _html_article_card(idx: int, item: dict[str, Any], display: DisplayOptions, kind: str) -> str: + accent = COLOR_ACCENT_NEW if kind == "new" else COLOR_ACCENT_EDIT + eyebrow = "Neuer Beitrag" if kind == "new" else "Aktualisierter Beitrag" + title = escape(item.get("title", "Ohne Titel")) + url = escape(_article_url(item.get("title", ""))) + + meta_bits: list[str] = [] + if display["show_date"]: + meta_bits.append(f"am {_format_ts(item.get('timestamp'))}") + if display["show_user"]: + meta_bits.append(f"von {escape(str(item.get('user', '-')))}") + meta_html = "" + if meta_bits: + meta_html = ( + f'Veröffentlicht {" ".join(meta_bits)}.' + ) + cat_html = "" + if display["show_category"]: + cat = ", ".join(item.get("categories", [])) or "Keine Kategorie" + cat_html = ( + f'Kategorie: {escape(cat)}' + ) + + return ( + f'' + f'' + f'' + f'' + f'{meta_html}{cat_html}' + f'' + f'
{idx}. {eyebrow}
' + f'{title}
{_cta_button(url, "Zum Beitrag →")}
' + ) + + +def _html_summary(new_articles: list[dict[str, Any]], edited_articles: list[dict[str, Any]]) -> str: + total = len(new_articles) + len(edited_articles) + return f""" + + + + + + +
Zusammenfassung
+ + + + + + + + +
+
{len(new_articles)}
+
Neue Artikel
+
  +
{len(edited_articles)}
+
Bearbeitet
+
  +
{total}
+
Gesamt
+
+
""" + + +# --------------------------------------------------------------------------- +# Outlook/Word-kompatibles HTML (UI-Vorschau & Copy-Paste in Outlook) +# --------------------------------------------------------------------------- + OUTLOOK_FONT = "Arial, sans-serif" OUTLOOK_TEXT = COLOR_TEXT OUTLOOK_MUTED = COLOR_TK_GRAY @@ -315,37 +587,52 @@ def _outlook_font( ) -def _outlook_link(title: str, url: str, *, bold: bool = True) -> str: +def _outlook_link(title: str, url: str, *, bold: bool = True, color: str = OUTLOOK_LINK, size: str = "2") -> str: weight = "" if bold else "" weight_end = "" if bold else "" return ( f'' - f'' - f'' + f'style="color:{color} !important;text-decoration:none !important;mso-style-priority:100 !important;">' + f'' + f'' f"{weight}{escape(title)}{weight_end}" f"" ) +def _outlook_cta(url: str, label: str) -> str: + return ( + f'' + f'
' + f'{_outlook_link(label, url, color="#FFFFFF")}' + f'
' + ) + + def create_outlook_html( articles: list[dict[str, Any]], days: int, display: DisplayOptions | None = None, category: str = "", + editor_tip: str = "", + highlights: list[str] | None = None, ) -> str: """Word/Outlook-kompatibles HTML mit font-Tags und bgcolor (überlebt Einfügen in Outlook).""" display = display or DEFAULT_DISPLAY new_articles, edited_articles = split_articles_by_type(articles) period_start, period_end = _period_range(days) - created_at = datetime.now().strftime("%d.%m.%Y %H:%M") - intro = _intro_sentence(period_start, period_end) + total = len(new_articles) + len(edited_articles) subject = escape(create_subject(days, category)) + period_label = f"{period_start} – {period_end}" + summary_text = _summary_sentence(total) + + highlight_articles = _match_highlights(new_articles + edited_articles, highlights) + highlights_rows = _outlook_highlights(highlight_articles) if not new_articles and not edited_articles: body_rows = f""" - - {_outlook_font("Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.", color=OUTLOOK_MUTED)} + + {_outlook_font("Für diesen Zeitraum wurden keine passenden Wiki-Änderungen gefunden.", color=OUTLOOK_MUTED)} """ else: @@ -371,14 +658,15 @@ def create_outlook_html( f"

" ) - total = len(new_articles) + len(edited_articles) summary_row = f""" - + {_outlook_font("Zusammenfassung: ", color=OUTLOOK_HEADING, bold=True, size="1")} {_outlook_font(f"{len(new_articles)} neu · {len(edited_articles)} bearbeitet · {total} gesamt", color=OUTLOOK_MUTED, size="1")} """ + editor_tip_row = _outlook_editor_tip(editor_tip) + return f""" @@ -407,33 +695,39 @@ def create_outlook_html( - +
- + + + ' + ) + + def _outlook_meta_line(item: dict[str, Any], display: DisplayOptions) -> str: parts: list[str] = [] if display["show_date"]: @@ -457,28 +784,6 @@ def _outlook_meta_line(item: dict[str, Any], display: DisplayOptions) -> str: return " | ".join(parts) -def _outlook_article_rows(items: list[dict[str, Any]], display: DisplayOptions) -> str: - rows: list[str] = [] - for idx, item in enumerate(items, 1): - title = item.get("title", "Ohne Titel") - url = _article_url(item.get("title", "")) - meta = _outlook_meta_line(item, display) - meta_html = "" - if meta: - meta_html = f"
{_outlook_font(meta, color=OUTLOOK_MUTED, size='1')}" - rows.append( - f""" - - -""" - ) - return "\n".join(rows) - - def _outlook_section( heading: str, items: list[dict[str, Any]], @@ -488,19 +793,47 @@ def _outlook_section( ) -> str: accent = OUTLOOK_ORANGE if kind == "new" else OUTLOOK_GRAY_DARK header = f""" - """ if not items: return header + f""" - """ - return header + _outlook_article_rows(items, display) + cards = "\n".join(_outlook_article_card(idx, item, display, kind) for idx, item in enumerate(items, start=1)) + return header + cards +def _outlook_article_card(idx: int, item: dict[str, Any], display: DisplayOptions, kind: str) -> str: + accent = OUTLOOK_ORANGE if kind == "new" else OUTLOOK_GRAY_DARK + eyebrow = "Neuer Beitrag" if kind == "new" else "Aktualisierter Beitrag" + title = item.get("title", "Ohne Titel") + url = _article_url(item.get("title", "")) + meta = _outlook_meta_line(item, display) + meta_html = f'
{_outlook_font(meta, color=OUTLOOK_MUTED, size="1")}' if meta else "" + return f""" + +""" + + +# --------------------------------------------------------------------------- +# Plain-Text-Helfer +# --------------------------------------------------------------------------- + def _plain_section( heading: str, items: list[dict[str, Any]], @@ -551,148 +884,16 @@ def _plain_footer(new_articles: list[dict[str, Any]], edited_articles: list[dict f"Wiki: {WIKI_HOME_URL}", "", SUBLINE, - f"Dieser Newsletter wurde automatisch erstellt und ist nur für den internen", + "Dieser Newsletter wurde automatisch erstellt und ist nur für den internen", f"Gebrauch bei {ORG_NAME} bestimmt.", LINE, ] ) -def _html_section( - heading: str, - items: list[dict[str, Any]], - display: DisplayOptions, - empty_text: str, - kind: str = "edited", -) -> str: - accent = COLOR_ACCENT_NEW if kind == "new" else COLOR_ACCENT_EDIT - accent_bg = COLOR_ACCENT_NEW_BG if kind == "new" else COLOR_ACCENT_EDIT_BG - badge = "NEU" if kind == "new" else "AKTUALISIERT" - - blocks = [ - f"""
 
- {_outlook_font("Interner Newsletter", color=OUTLOOK_ORANGE, size="1")}
- {_outlook_font("Thomas-Krenn Wiki Update", color=OUTLOOK_WHITE, size="5", bold=True)}
- {_outlook_font(ORG_NAME, color="#CCCCCC", size="2")} +
+ Thomas-Krenn Logo +
+ {_outlook_font("Thomas-Krenn Wiki Update", color=OUTLOOK_ORANGE, size="1", bold=True)}
+ {_outlook_font("Die neuesten Wiki-Artikel", color=OUTLOOK_WHITE, size="5", bold=True)}
+ {_outlook_font(f"{period_label} | {summary_text}", color="#DDDDDD", size="2")}
-

{_outlook_font("Liebe Kolleginnen und Kollegen,")}

-

{_outlook_font(intro)}

-

{_outlook_font(f"Erstellt am: {created_at}", color=OUTLOOK_MUTED, size="1")}

+

{_outlook_font("Servus,")}

+

{_outlook_font(f"hier ist deine Übersicht mit den neuesten Beiträgen aus dem Thomas-Krenn-Wiki vom {period_start} bis {period_end}.")}

{category_block} + {highlights_rows} {body_rows} {summary_row} + {editor_tip_row} - - @@ -445,6 +739,39 @@ def create_outlook_html( """ +def _outlook_highlights(items: list[dict[str, Any]]) -> str: + if not items: + return "" + rows = "" + for idx, item in enumerate(items[:3], start=1): + url = _article_url(item.get("title", "")) + rows += ( + f'' + ) + return ( + f'
- {_outlook_link("Zum Thomas-Krenn-Wiki →", WIKI_HOME_URL)} + + {_outlook_cta(WIKI_HOME_URL, "Zum Thomas-Krenn-Wiki →")}
+ {_outlook_font(f"Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {ORG_NAME}", color=OUTLOOK_MUTED, size="1")}
' + f'{_outlook_font(f"{idx}. ", color=OUTLOOK_ORANGE, bold=True)}' + f'{_outlook_link(item.get("title", "Ohne Titel"), url, color=OUTLOOK_TEXT, bold=False)}' + f'
' + f'' + f'{rows}
' + f'{_outlook_font("Top Highlights", color=OUTLOOK_ORANGE, size="2", bold=True)}
' + ) + + +def _outlook_editor_tip(editor_tip: str) -> str: + text = (editor_tip or "").strip() + if not text: + return "" + body = "
".join(_outlook_font(line) for line in text.splitlines()) + return ( + f'
' + f'{_outlook_font("Redaktionsnotiz", color=OUTLOOK_HEADING, size="1", bold=True)}
' + f'{body}
- {_outlook_font(f"{idx}.")} - - {_outlook_link(title, url)}{meta_html} -
+ {_outlook_font(f"{heading} ({len(items)})", color=accent, size="3", bold=True)}
+ {_outlook_font(empty_text, color=OUTLOOK_MUTED)}
+ + + + + +
  + {_outlook_font(f"{idx}. {eyebrow}", color=accent, size="1", bold=True)}
+ {_outlook_link(title, url, size="3")}{meta_html}
+ {_outlook_cta(url, "Zum Beitrag →")} +
+
- - - -
- - - - - -
  - - - - - -
-

{escape(heading)}

-
- {badge} - {len(items)} -
-
-
""" - ] - if not items: - blocks.append( - f'

{escape(empty_text)}

' - ) - return "\n".join(blocks) - - blocks.append( - f'' - ) - for idx, item in enumerate(items, start=1): - blocks.append(_html_item_row(idx, item, display, accent)) - blocks.append("
") - return "\n".join(blocks) - - -def _html_meta_tags(item: dict[str, Any], display: DisplayOptions) -> str: - tags: list[str] = [] - if display["show_date"]: - tags.append(_html_meta_tag("Datum", _format_ts(item.get("timestamp")))) - if display["show_user"]: - tags.append(_html_meta_tag("Benutzer", str(item.get("user", "-")))) - if display["show_category"]: - cat = ", ".join(item.get("categories", [])) or "Keine Kategorie" - tags.append(_html_meta_tag("Kategorie", cat)) - if not tags: - return "" - return f'{"".join(tags)}
' - - -def _html_meta_tag(label: str, value: str) -> str: - return f""" - - - - -
- {escape(label)}: {escape(value)} -
-""" - - -def _html_item_row(idx: int, item: dict[str, Any], display: DisplayOptions, accent: str) -> str: - title = escape(item.get("title", "Ohne Titel")) - url = escape(_article_url(item.get("title", ""))) - meta_html = _html_meta_tags(item, display) - - return f""" - - - - - - -
  - - - - - -
{idx}. - {title} - {meta_html} -
-
- -""" - - -def _html_summary(new_articles: list[dict[str, Any]], edited_articles: list[dict[str, Any]]) -> str: - total = len(new_articles) + len(edited_articles) - return f""" - - - - - - -
Zusammenfassung
- - - - - - - - -
-
{len(new_articles)}
-
Neue Artikel
-
  -
{len(edited_articles)}
-
Bearbeitet
-
  -
{total}
-
Gesamt
-
-
""" - +# --------------------------------------------------------------------------- +# Gemeinsame Helfer +# --------------------------------------------------------------------------- def _article_url(title: str) -> str: return f"{WIKI_ARTICLE_BASE}{quote(title.replace(' ', '_'))}" diff --git a/app/templates/dashboard.html b/app/templates/dashboard.html index 18f0b65..76f9929 100644 --- a/app/templates/dashboard.html +++ b/app/templates/dashboard.html @@ -61,6 +61,14 @@ Kategorie anzeigen + +

Hervorgehobene Artikel erscheinen als „Top Highlights“-Box oben im Newsletter.

+ +

Wird nur angezeigt, wenn Text hinterlegt ist – erscheint als eigene Box im Newsletter.

{% if wiki_error %}

{{ wiki_error }}

{% endif %} @@ -184,6 +192,8 @@ + +