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}
+
-
-
-
-
-
-
-
-
-
-
- Interner Newsletter
- Thomas-Krenn Wiki Update
- {escape(ORG_NAME)}
-
-
-
-
-
-
-
- Liebe Kolleginnen und Kollegen,
- {intro}
-
-
-
-
-
- {category_hint}
-
-
- Erstellt am {created_at}
-
-
-
-
- {body}
- {summary}
-
-
-
-
-
- Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {escape(ORG_NAME)}
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Thomas-Krenn Wiki Update
+ Die neuesten Wiki-Artikel
+ {period_label} | {summary_text}
+
+
+
+
+
+
+
+
+
+
+ Servus,
+ hier ist deine Übersicht mit den neuesten Beiträgen aus dem Thomas-Krenn-Wiki vom {escape(period_start)} bis {escape(period_end)}.
+
+
+
+
+
+ {category_hint}
+ {highlights_section}
+ {body}
+ {summary_section}
+ {editor_tip_section}
+
+
+ {_cta_button(WIKI_HOME_URL, "Zum Thomas-Krenn-Wiki →")}
+
+
+
+
+
+
+
+ Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team.
+ Viele Grüße
+ Euer Thomas-Krenn-Wiki Team
+
+
+
+
+
+
+
+
+ Thomas-Krenn.AG | Speltenbach-Steinäcker 1 | D-94078 Freyung
+ Tel.: +49 8551 9150 0 | Fax: +49 8551 9150 55 |
+
thomas-krenn.com
+
Automatisch erstellter interner Newsletter · Nur für den internen Gebrauch
+
+
+
+
+
+
+
+
"""
+def _cta_button(url: str, label: str) -> str:
+ return (
+ 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'Top Highlights '
+ f' '
+ f'
'
+ )
+
+
+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'Redaktionsnotiz '
+ f'{body} '
+ f'
'
+ )
+
+
+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'{idx}. {eyebrow} '
+ f''
+ f'{title} '
+ f'{meta_html}{cat_html}'
+ f'{_cta_button(url, "Zum Beitrag →")} '
+ f'
'
+ )
+
+
+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(
-
+
-
- {_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")}
+
+
+
+
+
+
+ {_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}
-
- {_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")}
@@ -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''
+ 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' '
+ )
+ return (
+ f''
+ f''
+ f'{_outlook_font("Top Highlights", color=OUTLOOK_ORANGE, size="2", bold=True)} '
+ f'{rows}
'
+ )
+
+
+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} '
+ )
+
+
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"""
-
- {_outlook_font(f"{idx}.")}
-
-
- {_outlook_link(title, url)}{meta_html}
-
- """
- )
- 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"""
-
+
{_outlook_font(f"{heading} ({len(items)})", color=accent, size="3", bold=True)}
"""
if not items:
return header + f"""
-
+
{_outlook_font(empty_text, color=OUTLOOK_MUTED)}
"""
- 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"""
+
+
+
+
+
+ {_outlook_font(f"{idx}. {eyebrow}", color=accent, size="1", bold=True)}
+ {_outlook_link(title, url, size="3")}{meta_html}
+ {_outlook_cta(url, "Zum Beitrag →")}
+
+
+
+
+ """
+
+
+# ---------------------------------------------------------------------------
+# 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"""
-
-
-
-
-
-
-
-
-
- {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''
-
-
-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
+ Top-Highlights (optional)
+
+
+ Hervorgehobene Artikel erscheinen als „Top Highlights“-Box oben im Newsletter.
+ Redaktionsnotiz (optional)
+
+
+ Wird nur angezeigt, wenn Text hinterlegt ist – erscheint als eigene Box im Newsletter.
{% if wiki_error %}
{{ wiki_error }}
{% endif %}
@@ -184,6 +192,8 @@
+
+
Newsletter jetzt senden