- Added a new function to generate a plain text contact line based on the provided email. - Updated the plain text and HTML newsletter templates to include the contact information dynamically. - Removed direct contact email handling from the footer function to streamline the code and enhance readability.
1035 lines
43 KiB
Python
1035 lines
43 KiB
Python
from datetime import datetime, timedelta
|
||
from html import escape
|
||
from typing import Any, TypedDict
|
||
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
|
||
|
||
FONT = "Arial, Helvetica, sans-serif"
|
||
# Thomas-Krenn.AG Corporate Design (Orange + Anthrazit)
|
||
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"
|
||
COLOR_TEXT = "#1A1A1A"
|
||
COLOR_MUTED = COLOR_TK_GRAY
|
||
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
|
||
COLOR_ACCENT_EDIT = COLOR_TK_GRAY_DARK
|
||
COLOR_ACCENT_EDIT_BG = "#EEEEEE"
|
||
|
||
|
||
class DisplayOptions(TypedDict):
|
||
show_date: bool
|
||
show_user: bool
|
||
show_category: bool
|
||
|
||
|
||
DEFAULT_DISPLAY: DisplayOptions = {
|
||
"show_date": True,
|
||
"show_user": True,
|
||
"show_category": True,
|
||
}
|
||
|
||
|
||
def parse_display_options(
|
||
show_date: str | None = None,
|
||
show_user: str | None = None,
|
||
show_category: str | None = None,
|
||
) -> DisplayOptions:
|
||
return {
|
||
"show_date": show_date == "true",
|
||
"show_user": show_user == "true",
|
||
"show_category": show_category == "true",
|
||
}
|
||
|
||
|
||
IGNORED_ARTICLE_TITLES = frozenset({"Hauptseite"})
|
||
|
||
|
||
def filter_articles(
|
||
articles: list[dict[str, Any]],
|
||
category_filter: str | None = None,
|
||
) -> list[dict[str, Any]]:
|
||
articles = [a for a in articles if a.get("title") not in IGNORED_ARTICLE_TITLES]
|
||
if not category_filter:
|
||
return articles
|
||
wanted = category_filter.strip().lower()
|
||
return [
|
||
a
|
||
for a in articles
|
||
if any(c.lower() == wanted for c in a.get("categories", []))
|
||
]
|
||
|
||
|
||
def split_articles_by_type(articles: list[dict[str, Any]]) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
|
||
sorted_articles = sorted(articles, key=lambda a: a.get("timestamp", ""), reverse=True)
|
||
new_articles: list[dict[str, Any]] = []
|
||
edited_articles: list[dict[str, Any]] = []
|
||
new_titles: set[str] = set()
|
||
|
||
for item in sorted_articles:
|
||
title = item.get("title", "")
|
||
if not title or item.get("type") != "new":
|
||
continue
|
||
if title not in new_titles:
|
||
new_articles.append(item)
|
||
new_titles.add(title)
|
||
|
||
seen_edited: set[str] = set()
|
||
for item in sorted_articles:
|
||
title = item.get("title", "")
|
||
if not title or title in new_titles or title in seen_edited:
|
||
continue
|
||
edited_articles.append(item)
|
||
seen_edited.add(title)
|
||
|
||
return new_articles, edited_articles
|
||
|
||
|
||
def create_subject(month_label: str | None = None, days: int = 30) -> str:
|
||
label = month_label or f"{days} Tage"
|
||
return f"Neue Wiki-Artikel Veröffentlichungen vom {label} | Wiki-Redaktion"
|
||
|
||
|
||
_MONTHS_DE = [
|
||
"Januar", "Februar", "März", "April", "Mai", "Juni",
|
||
"Juli", "August", "September", "Oktober", "November", "Dezember",
|
||
]
|
||
|
||
|
||
def _month_label(dt: datetime) -> str:
|
||
return f"{_MONTHS_DE[dt.month - 1]} {dt.year}"
|
||
|
||
|
||
def resolve_period(mode: str, days: int) -> dict[str, Any]:
|
||
"""Berechnet Start/Ende und Beschriftung für einen Zeitraum-Modus.
|
||
|
||
- "last_month": voriger Kalendermonat (z. B. 01.06.–01.07.)
|
||
- sonst: die letzten N Tage
|
||
"""
|
||
now = datetime.now()
|
||
if mode == "last_month":
|
||
first_this_month = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||
last_day_prev = first_this_month - timedelta(days=1)
|
||
start = last_day_prev.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
|
||
end = first_this_month
|
||
return {
|
||
"mode": "last_month",
|
||
"start": start,
|
||
"end": end,
|
||
"start_str": start.strftime("%d.%m.%Y"),
|
||
"end_str": end.strftime("%d.%m.%Y"),
|
||
"label": _month_label(start),
|
||
"month_label": _month_label(start),
|
||
"days": max(1, (end - start).days),
|
||
}
|
||
days = max(1, min(days, 90))
|
||
end = now
|
||
start = now - timedelta(days=days)
|
||
return {
|
||
"mode": "days",
|
||
"start": start,
|
||
"end": end,
|
||
"start_str": start.strftime("%d.%m.%Y"),
|
||
"end_str": end.strftime("%d.%m.%Y"),
|
||
"label": f"{days} Tage",
|
||
"month_label": _month_label(end),
|
||
"days": days,
|
||
}
|
||
|
||
|
||
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,
|
||
period_label: str | None = None,
|
||
period: tuple[str, str] | None = None,
|
||
article_type: str = "all",
|
||
contact_email: str = "",
|
||
) -> str:
|
||
display = display or DEFAULT_DISPLAY
|
||
new_articles, edited_articles = split_articles_by_type(articles)
|
||
show_new = article_type in ("all", "new")
|
||
show_edited = article_type in ("all", "edited")
|
||
period_start, period_end = period if period else _period_range(days)
|
||
period_desc = period_label or f"{days} Tage"
|
||
created_at = datetime.now().strftime("%d.%m.%Y %H:%M")
|
||
|
||
lines = [
|
||
LINE,
|
||
f"{ORG_NAME.upper()} – INTERNER WIKI-NEWSLETTER",
|
||
LINE,
|
||
f"Zeitraum: {period_start} bis {period_end} ({period_desc})",
|
||
f"Erstellt am: {created_at}",
|
||
]
|
||
if category.strip():
|
||
lines.append(f"Kategorie: {category.strip()}")
|
||
lines.extend(
|
||
[
|
||
"",
|
||
"Liebe Kolleginnen und Kollegen,",
|
||
"",
|
||
_intro_sentence(period_start, period_end),
|
||
"",
|
||
]
|
||
)
|
||
lines.extend(_plain_contact_line(contact_email))
|
||
|
||
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(
|
||
[
|
||
SUBLINE,
|
||
"HINWEIS",
|
||
SUBLINE,
|
||
"Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.",
|
||
"",
|
||
]
|
||
)
|
||
lines.extend(_plain_editor_tip(editor_tip))
|
||
lines.append(_plain_footer(new_articles, edited_articles))
|
||
return "\n".join(lines).strip()
|
||
|
||
if show_new:
|
||
lines.extend(_plain_section("NEUE ARTIKEL", new_articles, display, empty_text="Keine neuen Artikel im Zeitraum."))
|
||
lines.append("")
|
||
if show_edited:
|
||
lines.extend(
|
||
_plain_section(
|
||
"BEARBEITETE ARTIKEL",
|
||
edited_articles,
|
||
display,
|
||
empty_text="Keine bearbeiteten Artikel im Zeitraum.",
|
||
)
|
||
)
|
||
lines.append("")
|
||
lines.extend(_plain_editor_tip(editor_tip))
|
||
lines.append(_plain_footer(new_articles, edited_articles))
|
||
return "\n".join(lines).strip()
|
||
|
||
|
||
def _plain_contact_line(contact_email: str) -> list[str]:
|
||
if contact_email.strip():
|
||
return [
|
||
f"Fragen oder ein Themenwunsch? E-Mail an das Wiki-Team: {contact_email.strip()}",
|
||
"",
|
||
]
|
||
return ["Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team.", ""]
|
||
|
||
|
||
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,
|
||
period_label: str | None = None,
|
||
period: tuple[str, str] | None = None,
|
||
article_type: str = "all",
|
||
month_label: str | None = None,
|
||
contact_email: str = "",
|
||
) -> str:
|
||
display = display or DEFAULT_DISPLAY
|
||
new_articles, edited_articles = split_articles_by_type(articles)
|
||
show_new = article_type in ("all", "new")
|
||
show_edited = article_type in ("all", "edited")
|
||
period_start, period_end = period if period else _period_range(days)
|
||
total = len(new_articles) + len(edited_articles)
|
||
subject = escape(create_subject(month_label or period_label, days))
|
||
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 = _html_empty_card("Für diesen Zeitraum wurden keine passenden Wiki-Änderungen gefunden.")
|
||
else:
|
||
sections = []
|
||
if show_new:
|
||
sections.append(_html_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum.", kind="new"))
|
||
if show_edited:
|
||
sections.append(_html_section("Bearbeitete Artikel", edited_articles, display, "Keine bearbeiteten Artikel im Zeitraum.", kind="edited"))
|
||
body = "\n".join(sections)
|
||
|
||
summary_section = (
|
||
f'<tr><td style="padding:24px 32px 0 32px;" class="mobile-padding">{_html_summary(new_articles, edited_articles)}</td></tr>'
|
||
)
|
||
editor_tip_section = _html_editor_tip(editor_tip)
|
||
|
||
category_hint = ""
|
||
if category.strip():
|
||
category_hint = (
|
||
f'<tr><td style="padding:0 32px 4px 32px;font-family:{FONT};" class="mobile-padding">'
|
||
f'<p style="margin:0;font-family:{FONT};font-size:13px;line-height:20px;color:{COLOR_MUTED};">'
|
||
f'<strong style="color:{COLOR_HEADING};">Filter:</strong> Kategorie {escape(category.strip())}</p></td></tr>'
|
||
)
|
||
|
||
if contact_email.strip():
|
||
ce = escape(contact_email.strip())
|
||
contact_html = (
|
||
f'Fragen oder ein Themenwunsch? Meldet euch gern beim '
|
||
f'<a href="mailto:{ce}" style="color:{COLOR_TK_ORANGE};text-decoration:underline;">Wiki-Team</a>.'
|
||
)
|
||
else:
|
||
contact_html = "Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team."
|
||
|
||
contact_section = (
|
||
f'<tr><td style="padding:8px 32px 18px 32px;" class="mobile-padding">'
|
||
f'<p style="margin:0;font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_TEXT};">{contact_html}</p>'
|
||
f"</td></tr>"
|
||
)
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{subject}</title>
|
||
<style type="text/css">
|
||
html, body {{ margin:0 !important; padding:0 !important; width:100% !important; background:{COLOR_PAGE_BG}; }}
|
||
table, td {{ border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; }}
|
||
img {{ border:0; outline:none; text-decoration:none; -ms-interpolation-mode:bicubic; }}
|
||
a {{ text-decoration:none; }}
|
||
.preheader {{ display:none !important; visibility:hidden; opacity:0; color:transparent; height:0; width:0; max-height:0; max-width:0; overflow:hidden; mso-hide:all; }}
|
||
@media only screen and (max-width: 700px) {{
|
||
.email-container {{ width:100% !important; }}
|
||
.mobile-padding {{ padding-left:16px !important; padding-right:16px !important; }}
|
||
.mobile-headline {{ font-size:28px !important; line-height:34px !important; }}
|
||
.mobile-card-title {{ font-size:18px !important; line-height:25px !important; }}
|
||
}}
|
||
</style>
|
||
<!--[if mso]>
|
||
<style type="text/css">
|
||
body, table, td, p, a, li, h1, h2, h3, span, strong {{ font-family: Arial, sans-serif !important; }}
|
||
</style>
|
||
<![endif]-->
|
||
</head>
|
||
<body style="margin:0;padding:0;background:{COLOR_PAGE_BG};font-family:{FONT};color:{COLOR_TEXT};">
|
||
<div class="preheader">{preheader} ‌ ‌ ‌</div>
|
||
<center role="article" aria-roledescription="email" lang="de" style="width:100%;background:{COLOR_PAGE_BG};">
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{COLOR_PAGE_BG}" style="width:100%;background:{COLOR_PAGE_BG};">
|
||
<tr>
|
||
<td align="center" style="padding:22px 10px;">
|
||
<table role="presentation" width="700" cellpadding="0" cellspacing="0" border="0" class="email-container" style="width:100%;max-width:700px;background:{COLOR_WHITE};border-top:4px solid {COLOR_TK_ORANGE};">
|
||
<tr>
|
||
<td align="center" style="padding:24px 32px 18px 32px;background:{COLOR_TK_GRAY_DARK};" class="mobile-padding">
|
||
<img src="{TK_LOGO_URL}" width="190" alt="Thomas-Krenn Logo" style="display:block;margin:0 auto;border:0;outline:none;text-decoration:none;height:auto;">
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:0 32px;" class="mobile-padding">
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;background:{COLOR_TK_GRAY_DARK};">
|
||
<tr>
|
||
<td style="padding:28px 26px;font-family:{FONT};">
|
||
<div style="font-size:12px;line-height:16px;font-weight:bold;color:{COLOR_TK_ORANGE};letter-spacing:1px;text-transform:uppercase;">Thomas-Krenn Wiki Update</div>
|
||
<div class="mobile-headline" style="font-size:32px;line-height:38px;font-weight:bold;color:{COLOR_WHITE};margin-top:10px;">Die neuesten Wiki-Artikel</div>
|
||
<div style="font-size:16px;line-height:25px;color:#DDDDDD;margin-top:10px;">{period_label} | {summary_text}</div>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:22px 32px 6px 32px;" class="mobile-padding">
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{COLOR_TK_ORANGE_LIGHT};border-left:4px solid {COLOR_TK_ORANGE};">
|
||
<tr>
|
||
<td style="padding:18px 20px;font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_TEXT};">
|
||
<p style="margin:0 0 10px;font-family:{FONT};">Servus,</p>
|
||
<p style="margin:0;font-family:{FONT};">hier ist deine Übersicht mit den neuesten Beiträgen aus dem Thomas-Krenn-Wiki vom {escape(period_start)} bis {escape(period_end)}.</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
{contact_section}
|
||
{category_hint}
|
||
{highlights_section}
|
||
{body}
|
||
{summary_section}
|
||
{editor_tip_section}
|
||
<tr>
|
||
<td align="center" style="padding:28px 32px 4px 32px;" class="mobile-padding">
|
||
{_cta_button(WIKI_HOME_URL, "Zum Thomas-Krenn-Wiki →")}
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:24px 32px 28px 32px;" class="mobile-padding">
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||
<tr>
|
||
<td style="font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_TEXT};">
|
||
Viele Grüße<br>
|
||
Euer Thomas-Krenn-Wiki Team
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="background:{COLOR_TK_GRAY_DARK};padding:22px 20px;text-align:center;font-family:{FONT};">
|
||
<div style="font-size:12px;line-height:19px;color:#EEF0F2;">
|
||
Thomas-Krenn.AG | Speltenbach-Steinäcker 1 | D-94078 Freyung<br>
|
||
Tel.: +49 8551 9150 0 | Fax: +49 8551 9150 55 |
|
||
<a href="{WIKI_HOME_URL}" target="_blank" style="color:{COLOR_TK_ORANGE};text-decoration:underline;">thomas-krenn.com</a><br>
|
||
<span style="color:#AEB2B7;">Automatisch erstellter interner Newsletter · Nur für den internen Gebrauch</span>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</center>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
def _cta_button(url: str, label: str) -> str:
|
||
return (
|
||
f'<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 auto;">'
|
||
f'<tr><td bgcolor="{COLOR_TK_ORANGE}" style="background:{COLOR_TK_ORANGE};border-radius:6px;">'
|
||
f'<a href="{escape(url)}" target="_blank" style="display:inline-block;padding:12px 22px;font-family:{FONT};'
|
||
f'font-size:14px;line-height:18px;font-weight:bold;color:{COLOR_WHITE};text-decoration:none;">{escape(label)}</a>'
|
||
f'</td></tr></table>'
|
||
)
|
||
|
||
|
||
def _html_empty_card(text: str) -> str:
|
||
return (
|
||
f'<tr><td style="padding:8px 32px 8px 32px;" class="mobile-padding">'
|
||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
|
||
f'style="width:100%;background:{COLOR_WHITE};border:1px solid {COLOR_BORDER};border-radius:12px;">'
|
||
f'<tr><td style="padding:20px 22px;font-family:{FONT};font-size:15px;line-height:23px;color:{COLOR_TEXT};">'
|
||
f'{escape(text)}</td></tr></table></td></tr>'
|
||
)
|
||
|
||
|
||
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'<tr><td style="padding:0 0 8px 0;font-family:{FONT};font-size:15px;line-height:22px;color:{COLOR_TEXT};">'
|
||
f'<span style="display:inline-block;min-width:22px;font-weight:bold;color:{COLOR_TK_ORANGE};">{idx}.</span> '
|
||
f'<a href="{url}" target="_blank" style="color:{COLOR_TEXT};text-decoration:none;">{title}</a></td></tr>'
|
||
)
|
||
return (
|
||
f'<tr><td style="padding:8px 32px 4px 32px;" class="mobile-padding">'
|
||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
|
||
f'style="width:100%;background:{COLOR_TK_ORANGE_LIGHT};border:1px solid {COLOR_TK_ORANGE_BORDER};border-radius:12px;">'
|
||
f'<tr><td style="padding:16px 18px 8px 18px;font-family:{FONT};font-size:11px;line-height:15px;'
|
||
f'color:{COLOR_TK_ORANGE_DARK};font-weight:bold;letter-spacing:.8px;text-transform:uppercase;">Top Highlights</td></tr>'
|
||
f'<tr><td style="padding:0 18px 12px 18px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">{rows}</table></td></tr>'
|
||
f'</table></td></tr>'
|
||
)
|
||
|
||
|
||
def _html_editor_tip(editor_tip: str) -> str:
|
||
text = (editor_tip or "").strip()
|
||
if not text:
|
||
return ""
|
||
body = escape(text).replace("\n", "<br>")
|
||
return (
|
||
f'<tr><td style="padding:16px 32px 4px 32px;" class="mobile-padding">'
|
||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
|
||
f'style="width:100%;background:{COLOR_TK_GRAY_LIGHT};border:1px solid {COLOR_BORDER};border-radius:12px;">'
|
||
f'<tr><td style="padding:16px 18px 8px 18px;font-family:{FONT};font-size:11px;line-height:15px;'
|
||
f'color:{COLOR_HEADING};font-weight:bold;letter-spacing:.8px;text-transform:uppercase;">Redaktionsnotiz</td></tr>'
|
||
f'<tr><td style="padding:0 18px 16px 18px;font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_TEXT};">{body}</td></tr>'
|
||
f'</table></td></tr>'
|
||
)
|
||
|
||
|
||
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'<tr><td style="padding:26px 32px 8px 32px;font-family:{FONT};" class="mobile-padding">'
|
||
f'<div style="font-family:{FONT};font-size:18px;line-height:24px;font-weight:bold;color:{COLOR_HEADING};'
|
||
f'border-bottom:2px solid {accent};padding-bottom:8px;">{escape(heading)} '
|
||
f'<span style="color:{accent};">({len(items)})</span></div></td></tr>'
|
||
)
|
||
if not items:
|
||
return heading_html + (
|
||
f'<tr><td style="padding:8px 32px 4px 32px;font-family:{FONT};font-size:14px;line-height:22px;'
|
||
f'color:{COLOR_MUTED};" class="mobile-padding">{escape(empty_text)}</td></tr>'
|
||
)
|
||
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'<tr><td style="padding:0 22px 12px 22px;font-family:{FONT};font-size:13px;line-height:20px;'
|
||
f'color:{COLOR_MUTED};">Veröffentlicht {" ".join(meta_bits)}.</td></tr>'
|
||
)
|
||
cat_html = ""
|
||
if display["show_category"]:
|
||
cat = ", ".join(item.get("categories", [])) or "Keine Kategorie"
|
||
cat_html = (
|
||
f'<tr><td style="padding:0 22px 12px 22px;font-family:{FONT};font-size:12px;line-height:18px;'
|
||
f'color:{COLOR_MUTED};">Kategorie: {escape(cat)}</td></tr>'
|
||
)
|
||
|
||
return (
|
||
f'<tr><td style="padding:0 32px 16px 32px;" class="mobile-padding">'
|
||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
|
||
f'style="width:100%;background:{COLOR_WHITE};border:1px solid {COLOR_BORDER};border-left:4px solid {accent};border-radius:12px;">'
|
||
f'<tr><td style="padding:16px 22px 4px 22px;font-family:{FONT};font-size:11px;line-height:16px;font-weight:bold;'
|
||
f'letter-spacing:.7px;text-transform:uppercase;color:{accent};">{idx}. {eyebrow}</td></tr>'
|
||
f'<tr><td class="mobile-card-title" style="padding:0 22px 8px 22px;font-family:{FONT};font-size:20px;'
|
||
f'line-height:27px;font-weight:bold;color:{COLOR_HEADING};">'
|
||
f'<a href="{url}" target="_blank" style="color:{COLOR_HEADING};text-decoration:none;">{title}</a></td></tr>'
|
||
f'{meta_html}{cat_html}'
|
||
f'<tr><td style="padding:2px 22px 18px 22px;">{_cta_button(url, "Zum Beitrag →")}</td></tr>'
|
||
f'</table></td></tr>'
|
||
)
|
||
|
||
|
||
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"""<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-top:4px;font-family:{FONT};">
|
||
<tr>
|
||
<td style="padding:0 0 10px;font-family:{FONT};font-size:14px;line-height:20px;font-weight:bold;color:{COLOR_HEADING};">Zusammenfassung</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="font-family:{FONT};">
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:{FONT};">
|
||
<tr>
|
||
<td width="33%" style="padding:18px 12px;background:{COLOR_ACCENT_NEW_BG};border:1px solid {COLOR_BORDER};text-align:center;font-family:{FONT};">
|
||
<div style="font-family:{FONT};font-size:28px;line-height:32px;font-weight:bold;color:{COLOR_ACCENT_NEW};">{len(new_articles)}</div>
|
||
<div style="margin-top:4px;font-family:{FONT};font-size:12px;line-height:16px;color:{COLOR_MUTED};">Neue Artikel</div>
|
||
</td>
|
||
<td width="4" style="font-size:0;line-height:0;"> </td>
|
||
<td width="33%" style="padding:18px 12px;background:{COLOR_ACCENT_EDIT_BG};border:1px solid {COLOR_BORDER};text-align:center;font-family:{FONT};">
|
||
<div style="font-family:{FONT};font-size:28px;line-height:32px;font-weight:bold;color:{COLOR_ACCENT_EDIT};">{len(edited_articles)}</div>
|
||
<div style="margin-top:4px;font-family:{FONT};font-size:12px;line-height:16px;color:{COLOR_MUTED};">Bearbeitet</div>
|
||
</td>
|
||
<td width="4" style="font-size:0;line-height:0;"> </td>
|
||
<td width="33%" style="padding:18px 12px;background:{COLOR_BG};border:1px solid {COLOR_BORDER};text-align:center;font-family:{FONT};">
|
||
<div style="font-family:{FONT};font-size:28px;line-height:32px;font-weight:bold;color:{COLOR_HEADING};">{total}</div>
|
||
<div style="margin-top:4px;font-family:{FONT};font-size:12px;line-height:16px;color:{COLOR_MUTED};">Gesamt</div>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>"""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Outlook/Word-kompatibles HTML (UI-Vorschau & Copy-Paste in Outlook)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
OUTLOOK_FONT = "Arial, sans-serif"
|
||
OUTLOOK_TEXT = COLOR_TEXT
|
||
OUTLOOK_MUTED = COLOR_TK_GRAY
|
||
OUTLOOK_LINK = COLOR_TK_ORANGE
|
||
OUTLOOK_HEADING = COLOR_TK_GRAY_DARK
|
||
OUTLOOK_BORDER = COLOR_BORDER
|
||
OUTLOOK_WHITE = COLOR_WHITE
|
||
OUTLOOK_ORANGE = COLOR_TK_ORANGE
|
||
OUTLOOK_GRAY_DARK = COLOR_TK_GRAY_DARK
|
||
|
||
|
||
def _outlook_td_attrs(
|
||
bg: str = OUTLOOK_WHITE,
|
||
*,
|
||
colspan: int | None = None,
|
||
width: str | None = None,
|
||
valign: str | None = None,
|
||
extra_style: str = "",
|
||
) -> str:
|
||
"""Tabellen-Zelle mit Outlook-Dunkelmodus-Schutz (data-ogsb/data-ogsc)."""
|
||
parts = [
|
||
f'bgcolor="{bg}"',
|
||
f'style="background-color:{bg} !important;color:{OUTLOOK_TEXT} !important;{extra_style}"',
|
||
f'data-ogsb="{bg}"',
|
||
]
|
||
if colspan:
|
||
parts.insert(0, f'colspan="{colspan}"')
|
||
if width:
|
||
parts.append(f'width="{width}"')
|
||
if valign:
|
||
parts.append(f'valign="{valign}"')
|
||
return " ".join(parts)
|
||
|
||
|
||
def _outlook_font(
|
||
text: str,
|
||
*,
|
||
color: str = OUTLOOK_TEXT,
|
||
size: str = "2",
|
||
bold: bool = False,
|
||
) -> str:
|
||
inner = escape(text)
|
||
if bold:
|
||
inner = f"<b>{inner}</b>"
|
||
return (
|
||
f'<span style="color:{color} !important;" data-ogsc="{color}">'
|
||
f'<font face="Arial" color="{color}" size="{size}">{inner}</font>'
|
||
f"</span>"
|
||
)
|
||
|
||
|
||
def _outlook_link(title: str, url: str, *, bold: bool = True, color: str = OUTLOOK_LINK, size: str = "2") -> str:
|
||
weight = "<b>" if bold else ""
|
||
weight_end = "</b>" if bold else ""
|
||
return (
|
||
f'<a href="{escape(url)}" '
|
||
f'style="color:{color} !important;text-decoration:none !important;mso-style-priority:100 !important;">'
|
||
f'<span style="color:{color} !important;" data-ogsc="{color}">'
|
||
f'<font face="Arial" color="{color}" size="{size}">'
|
||
f"{weight}{escape(title)}{weight_end}"
|
||
f"</font></span></a>"
|
||
)
|
||
|
||
|
||
def _outlook_cta(url: str, label: str) -> str:
|
||
return (
|
||
f'<table cellpadding="0" cellspacing="0" border="0"><tr>'
|
||
f'<td bgcolor="{OUTLOOK_ORANGE}" style="background-color:{OUTLOOK_ORANGE} !important;padding:9px 16px;" data-ogsb="{OUTLOOK_ORANGE}">'
|
||
f'{_outlook_link(label, url, color="#FFFFFF")}'
|
||
f'</td></tr></table>'
|
||
)
|
||
|
||
|
||
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,
|
||
period_label: str | None = None,
|
||
period: tuple[str, str] | None = None,
|
||
article_type: str = "all",
|
||
month_label: str | None = None,
|
||
contact_email: str = "",
|
||
) -> 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)
|
||
show_new = article_type in ("all", "new")
|
||
show_edited = article_type in ("all", "edited")
|
||
period_start, period_end = period if period else _period_range(days)
|
||
total = len(new_articles) + len(edited_articles)
|
||
subject = escape(create_subject(month_label or period_label, days))
|
||
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"""<tr>
|
||
<td {_outlook_td_attrs(extra_style="padding:12px 0;")}>
|
||
{_outlook_font("Für diesen Zeitraum wurden keine passenden Wiki-Änderungen gefunden.", color=OUTLOOK_MUTED)}
|
||
</td>
|
||
</tr>"""
|
||
else:
|
||
sections = []
|
||
if show_new:
|
||
sections.append(_outlook_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum.", kind="new"))
|
||
if show_edited:
|
||
sections.append(_outlook_section("Bearbeitete Artikel", edited_articles, display, "Keine bearbeiteten Artikel im Zeitraum.", kind="edited"))
|
||
body_rows = "\n".join(sections)
|
||
|
||
category_block = ""
|
||
if category.strip():
|
||
category_block = (
|
||
f'<p style="margin:0 0 10px;">'
|
||
f'{_outlook_font("Filter: ", color=OUTLOOK_HEADING, bold=True)}'
|
||
f'{_outlook_font(f"Kategorie {category.strip()}", color=OUTLOOK_MUTED, size="1")}'
|
||
f"</p>"
|
||
)
|
||
|
||
summary_row = f"""<tr>
|
||
<td {_outlook_td_attrs(extra_style=f"padding:16px 0 0;border-top:1px solid {OUTLOOK_BORDER};")}>
|
||
{_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")}
|
||
</td>
|
||
</tr>"""
|
||
|
||
editor_tip_row = _outlook_editor_tip(editor_tip)
|
||
|
||
if contact_email.strip():
|
||
contact_block = (
|
||
f'<p style="margin:0 0 16px;">'
|
||
f'{_outlook_font("Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team: ", size="1")}'
|
||
f'{_outlook_link(contact_email.strip(), f"mailto:{contact_email.strip()}", color=OUTLOOK_ORANGE, bold=False)}'
|
||
f"</p>"
|
||
)
|
||
else:
|
||
contact_block = (
|
||
f'<p style="margin:0 0 16px;">'
|
||
f'{_outlook_font("Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team.", size="1")}'
|
||
f"</p>"
|
||
)
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="ProgId" content="Word.Document">
|
||
<meta name="Generator" content="Microsoft Word 15">
|
||
<meta name="color-scheme" content="light dark">
|
||
<meta name="supported-color-schemes" content="light dark">
|
||
<title>{subject}</title>
|
||
<!--[if mso]>
|
||
<style type="text/css">
|
||
body, table, td, p, font, span, strong {{ font-family: Arial, sans-serif !important; }}
|
||
a, span.MsoHyperlink, span.MsoHyperlinkFollowed {{
|
||
color: {OUTLOOK_LINK} !important;
|
||
text-decoration: none !important;
|
||
}}
|
||
</style>
|
||
<![endif]-->
|
||
<style type="text/css">
|
||
:root {{ color-scheme: light dark; supported-color-schemes: light dark; }}
|
||
body {{ background-color: {OUTLOOK_WHITE} !important; color: {OUTLOOK_TEXT} !important; }}
|
||
a, a:link, a:visited, a:hover, a:active {{
|
||
color: {OUTLOOK_LINK} !important;
|
||
text-decoration: none !important;
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body bgcolor="{OUTLOOK_WHITE}" style="margin:0;padding:16px;background-color:{OUTLOOK_WHITE} !important;color:{OUTLOOK_TEXT} !important;" data-ogsb="{OUTLOOK_WHITE}">
|
||
<table width="640" cellpadding="0" cellspacing="0" border="0" align="center" bgcolor="{OUTLOOK_WHITE}" style="width:640px;background-color:{OUTLOOK_WHITE} !important;" data-ogsb="{OUTLOOK_WHITE}">
|
||
<tr>
|
||
<td {_outlook_td_attrs(bg=OUTLOOK_ORANGE, extra_style="height:5px;font-size:0;line-height:0;")}> </td>
|
||
</tr>
|
||
<tr>
|
||
<td {_outlook_td_attrs(bg=OUTLOOK_GRAY_DARK, extra_style="padding:16px 20px;text-align:center;")} align="center">
|
||
<img src="{TK_LOGO_URL}" width="180" alt="Thomas-Krenn Logo" style="display:block;margin:0 auto;border:0;height:auto;">
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td {_outlook_td_attrs(bg=OUTLOOK_GRAY_DARK, extra_style="padding:18px 20px;")}>
|
||
{_outlook_font("Thomas-Krenn Wiki Update", color=OUTLOOK_ORANGE, size="1", bold=True)}<br>
|
||
{_outlook_font("Die neuesten Wiki-Artikel", color=OUTLOOK_WHITE, size="5", bold=True)}<br>
|
||
{_outlook_font(f"{period_label} | {summary_text}", color="#DDDDDD", size="2")}
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td {_outlook_td_attrs(extra_style="padding:20px;")}>
|
||
<p style="margin:0 0 8px;">{_outlook_font("Servus,")}</p>
|
||
<p style="margin:0 0 16px;">{_outlook_font(f"hier ist deine Übersicht mit den neuesten Beiträgen aus dem Thomas-Krenn-Wiki vom {period_start} bis {period_end}.")}</p>
|
||
{contact_block}
|
||
{category_block}
|
||
{highlights_rows}
|
||
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{OUTLOOK_WHITE}" style="background-color:{OUTLOOK_WHITE} !important;" data-ogsb="{OUTLOOK_WHITE}">
|
||
{body_rows}
|
||
{summary_row}
|
||
{editor_tip_row}
|
||
<tr>
|
||
<td {_outlook_td_attrs(extra_style="padding:20px 0 0;")}>
|
||
{_outlook_cta(WIKI_HOME_URL, "Zum Thomas-Krenn-Wiki →")}
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td {_outlook_td_attrs(extra_style=f"padding:16px 0 0;border-top:1px solid {OUTLOOK_BORDER};")}>
|
||
{_outlook_font(f"Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {ORG_NAME}", color=OUTLOOK_MUTED, size="1")}
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</body>
|
||
</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'<tr><td {_outlook_td_attrs(bg=COLOR_TK_ORANGE_LIGHT, extra_style="padding:3px 0;")}>'
|
||
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'</td></tr>'
|
||
)
|
||
return (
|
||
f'<table width="100%" cellpadding="0" cellspacing="0" border="0" '
|
||
f'bgcolor="{COLOR_TK_ORANGE_LIGHT}" style="background-color:{COLOR_TK_ORANGE_LIGHT} !important;'
|
||
f'border:1px solid {COLOR_TK_ORANGE_BORDER};margin:0 0 12px;" data-ogsb="{COLOR_TK_ORANGE_LIGHT}">'
|
||
f'<tr><td {_outlook_td_attrs(bg=COLOR_TK_ORANGE_LIGHT, extra_style="padding:12px 14px 6px;")}>'
|
||
f'{_outlook_font("TOP HIGHLIGHTS", color=COLOR_TK_ORANGE_DARK, size="1", bold=True)}</td></tr>'
|
||
f'<tr><td {_outlook_td_attrs(bg=COLOR_TK_ORANGE_LIGHT, extra_style="padding:0 14px 12px;")}>'
|
||
f'<table width="100%" cellpadding="0" cellspacing="0" border="0">{rows}</table></td></tr>'
|
||
f'</table>'
|
||
)
|
||
|
||
|
||
def _outlook_editor_tip(editor_tip: str) -> str:
|
||
text = (editor_tip or "").strip()
|
||
if not text:
|
||
return ""
|
||
body = "<br>".join(_outlook_font(line) for line in text.splitlines())
|
||
return (
|
||
f'<tr><td {_outlook_td_attrs(bg=COLOR_TK_GRAY_LIGHT, extra_style=f"padding:14px 16px;margin-top:16px;border:1px solid {OUTLOOK_BORDER};")}>'
|
||
f'{_outlook_font("Redaktionsnotiz", color=OUTLOOK_HEADING, size="1", bold=True)}<br>'
|
||
f'{body}</td></tr>'
|
||
)
|
||
|
||
|
||
def _outlook_meta_line(item: dict[str, Any], display: DisplayOptions) -> str:
|
||
parts: list[str] = []
|
||
if display["show_date"]:
|
||
parts.append(f"Datum: {_format_ts(item.get('timestamp'))}")
|
||
if display["show_user"]:
|
||
parts.append(f"Benutzer: {item.get('user', '-')}")
|
||
if display["show_category"]:
|
||
cat = ", ".join(item.get("categories", [])) or "Keine Kategorie"
|
||
parts.append(f"Kategorie: {cat}")
|
||
return " | ".join(parts)
|
||
|
||
|
||
def _outlook_section(
|
||
heading: str,
|
||
items: list[dict[str, Any]],
|
||
display: DisplayOptions,
|
||
empty_text: str,
|
||
kind: str = "edited",
|
||
) -> str:
|
||
accent = OUTLOOK_ORANGE if kind == "new" else OUTLOOK_GRAY_DARK
|
||
header = f"""<tr>
|
||
<td {_outlook_td_attrs(extra_style=f"padding:18px 0 8px;border-bottom:2px solid {accent};")}>
|
||
{_outlook_font(f"{heading} ({len(items)})", color=accent, size="3", bold=True)}
|
||
</td>
|
||
</tr>"""
|
||
if not items:
|
||
return header + f"""<tr>
|
||
<td {_outlook_td_attrs(extra_style="padding:8px 0;")}>
|
||
{_outlook_font(empty_text, color=OUTLOOK_MUTED)}
|
||
</td>
|
||
</tr>"""
|
||
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'<br>{_outlook_font(meta, color=OUTLOOK_MUTED, size="1")}' if meta else ""
|
||
return f"""<tr>
|
||
<td {_outlook_td_attrs(extra_style="padding:8px 0;")}>
|
||
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{OUTLOOK_WHITE}" style="background-color:{OUTLOOK_WHITE} !important;border:1px solid {OUTLOOK_BORDER};" data-ogsb="{OUTLOOK_WHITE}">
|
||
<tr>
|
||
<td {_outlook_td_attrs(bg=accent, width="6", extra_style="font-size:0;line-height:0;width:6px;")}> </td>
|
||
<td {_outlook_td_attrs(extra_style="padding:16px 20px;")}>
|
||
{_outlook_font(f"{idx}. {eyebrow}", color=accent, size="1", bold=True)}<br>
|
||
<span style="display:inline-block;margin:6px 0 2px;">{_outlook_link(title, url, color=OUTLOOK_TEXT, size="4")}</span>{meta_html}<br>
|
||
<span style="display:inline-block;margin-top:12px;">{_outlook_cta(url, "Zum Beitrag →")}</span>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>"""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Plain-Text-Helfer
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _plain_section(
|
||
heading: str,
|
||
items: list[dict[str, Any]],
|
||
display: DisplayOptions,
|
||
empty_text: str,
|
||
) -> list[str]:
|
||
lines = [SUBLINE, f"{heading} ({len(items)})", SUBLINE, ""]
|
||
if not items:
|
||
lines.append(f" {empty_text}")
|
||
lines.append("")
|
||
return lines
|
||
|
||
for idx, item in enumerate(items, start=1):
|
||
lines.extend(_plain_item(idx, item, display))
|
||
return lines
|
||
|
||
|
||
def _plain_item(idx: int, item: dict[str, Any], display: DisplayOptions) -> list[str]:
|
||
title = item.get("title", "Ohne Titel")
|
||
url = _article_url(item.get("title", ""))
|
||
meta_parts: list[str] = []
|
||
if display["show_date"]:
|
||
meta_parts.append(_format_ts(item.get("timestamp")))
|
||
if display["show_user"]:
|
||
meta_parts.append(str(item.get("user", "-")))
|
||
if display["show_category"]:
|
||
cat = ", ".join(item.get("categories", [])) or "Keine Kategorie"
|
||
meta_parts.append(cat)
|
||
|
||
lines = [f" {idx}. {title}", f" {url}"]
|
||
if meta_parts:
|
||
lines.append(f" ({' | '.join(meta_parts)})")
|
||
lines.append("")
|
||
return lines
|
||
|
||
|
||
def _plain_footer(
|
||
new_articles: list[dict[str, Any]],
|
||
edited_articles: list[dict[str, Any]],
|
||
) -> str:
|
||
total = len(new_articles) + len(edited_articles)
|
||
return "\n".join(
|
||
[
|
||
SUBLINE,
|
||
"ZUSAMMENFASSUNG",
|
||
SUBLINE,
|
||
f" Neue Artikel: {len(new_articles)}",
|
||
f" Bearbeitete Artikel: {len(edited_articles)}",
|
||
f" Gesamt: {total}",
|
||
"",
|
||
f"Wiki: {WIKI_HOME_URL}",
|
||
"",
|
||
SUBLINE,
|
||
"Dieser Newsletter wurde automatisch erstellt und ist nur für den internen",
|
||
f"Gebrauch bei {ORG_NAME} bestimmt.",
|
||
LINE,
|
||
]
|
||
)
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Gemeinsame Helfer
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def _article_url(title: str) -> str:
|
||
return f"{WIKI_ARTICLE_BASE}{quote(title.replace(' ', '_'))}"
|
||
|
||
|
||
def article_url(title: str) -> str:
|
||
return _article_url(title)
|
||
|
||
|
||
def _intro_sentence(period_start: str, period_end: str) -> str:
|
||
return (
|
||
f"Im Thomas-Krenn-Wiki wurden im Zeitraum vom {period_start} bis {period_end} "
|
||
"folgende Artikel neu veröffentlicht bzw. aktualisiert:"
|
||
)
|
||
|
||
|
||
def _period_range(days: int) -> tuple[str, str]:
|
||
end = datetime.now()
|
||
start = end - timedelta(days=days)
|
||
return start.strftime("%d.%m.%Y"), end.strftime("%d.%m.%Y")
|
||
|
||
|
||
def _format_ts(value: str | None) -> str:
|
||
if not value:
|
||
return "-"
|
||
try:
|
||
dt = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ")
|
||
return dt.strftime("%d.%m.%Y %H:%M")
|
||
except ValueError:
|
||
return value
|