Files
TK-Wiki-Newsletter/app/services/newsletter.py
smueller 90cdb7e922 Enhance newsletter functionality and UI improvements
- Introduce `create_outlook_html` function for generating Outlook-compatible newsletter HTML.
- Update dashboard to include `outlook_html` in the response.
- Modify newsletter generation logic to support separate handling of Outlook HTML.
- Improve CSS styles for newsletter preview and layout adjustments.
- Add new button for downloading HTML directly from the dashboard.
2026-07-03 13:07:33 +02:00

648 lines
26 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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"
LINE = "=" * 78
SUBLINE = "-" * 78
FONT = "Arial, Helvetica, sans-serif"
COLOR_TEXT = "#1A1A1A"
COLOR_MUTED = "#5C6670"
COLOR_HEADING = "#003366"
COLOR_LINK = "#0055A4"
COLOR_BORDER = "#D7DEE8"
COLOR_BG = "#EEF2F7"
COLOR_WHITE = "#FFFFFF"
COLOR_ACCENT_NEW = "#1F7A4D"
COLOR_ACCENT_NEW_BG = "#E8F6EE"
COLOR_ACCENT_EDIT = "#0055A4"
COLOR_ACCENT_EDIT_BG = "#E8F1FA"
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(days: int, category: str = "") -> str:
base = f"Interner Wiki-Newsletter {ORG_NAME} ({days} Tage)"
if category.strip():
return f"{base} | Kategorie: {category.strip()}"
return base
def create_plain_text(
articles: list[dict[str, Any]],
days: int,
display: DisplayOptions | None = None,
category: str = "",
) -> 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")
lines = [
LINE,
f"{ORG_NAME.upper()} INTERNER WIKI-NEWSLETTER",
LINE,
f"Zeitraum: {period_start} bis {period_end} ({days} Tage)",
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),
"",
]
)
if not new_articles and not edited_articles:
lines.extend(
[
SUBLINE,
"HINWEIS",
SUBLINE,
"Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.",
"",
_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."))
lines.append("")
lines.extend(
_plain_section(
"BEARBEITETE ARTIKEL",
edited_articles,
display,
empty_text="Keine bearbeiteten Artikel im Zeitraum.",
)
)
lines.append("")
lines.append(_plain_footer(new_articles, edited_articles))
return "\n".join(lines).strip()
def create_html(
articles: list[dict[str, Any]],
days: int,
display: DisplayOptions | None = None,
category: str = "",
) -> 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")
subject = escape(create_subject(days, category))
if not new_articles and not edited_articles:
body = f'<p style="margin:0;font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_MUTED};">Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.</p>'
else:
body = "\n".join(
[
_html_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum.", kind="new"),
_html_section("Bearbeitete Artikel", edited_articles, display, "Keine bearbeiteten Artikel im Zeitraum.", kind="edited"),
]
)
summary = _html_summary(new_articles, edited_articles)
intro = escape(_intro_sentence(period_start, period_end))
category_hint = (
f'<tr><td style="padding:0 32px 0;font-family:{FONT};"><p style="margin:0 0 16px;font-family:{FONT};font-size:13px;line-height:20px;color:{COLOR_MUTED};"><strong style="color:{COLOR_HEADING};">Filter:</strong> Kategorie {escape(category.strip())}</p></td></tr>'
if category.strip()
else ""
)
return f"""<!DOCTYPE html>
<html lang="de" xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{subject}</title>
<!--[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_BG};font-family:{FONT};color:{COLOR_TEXT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{COLOR_BG};font-family:{FONT};">
<tr>
<td align="center" style="padding:28px 16px;font-family:{FONT};">
<table role="presentation" width="680" cellpadding="0" cellspacing="0" border="0" style="width:680px;max-width:680px;background:{COLOR_WHITE};border:1px solid {COLOR_BORDER};font-family:{FONT};">
<tr>
<td style="height:5px;background:{COLOR_LINK};font-size:0;line-height:0;">&nbsp;</td>
</tr>
<tr>
<td style="background:{COLOR_HEADING};padding:30px 36px 28px;font-family:{FONT};">
<p style="margin:0;font-family:{FONT};font-size:11px;line-height:16px;letter-spacing:1.2px;text-transform:uppercase;color:#A9C6E8;">Interner Newsletter</p>
<h1 style="margin:10px 0 0;font-family:{FONT};font-size:28px;line-height:34px;font-weight:bold;color:{COLOR_WHITE};">Thomas-Krenn Wiki Update</h1>
<p style="margin:10px 0 0;font-family:{FONT};font-size:14px;line-height:21px;color:#D9E8F8;">{escape(ORG_NAME)}</p>
</td>
</tr>
<tr>
<td style="padding:28px 36px 0;font-family:{FONT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{COLOR_BG};border-left:4px solid {COLOR_LINK};font-family:{FONT};">
<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};font-size:15px;line-height:24px;">Liebe Kolleginnen und Kollegen,</p>
<p style="margin:0;font-family:{FONT};font-size:15px;line-height:24px;">{intro}</p>
</td>
</tr>
</table>
</td>
</tr>
{category_hint}
<tr>
<td style="padding:8px 36px 0;font-family:{FONT};">
<p style="margin:0 0 18px;font-family:{FONT};font-size:12px;line-height:18px;color:{COLOR_MUTED};">Erstellt am {created_at}</p>
</td>
</tr>
<tr>
<td style="padding:0 36px 30px;font-family:{FONT};">
{body}
{summary}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" style="margin:32px auto 0;font-family:{FONT};">
<tr>
<td align="center" style="background:{COLOR_HEADING};padding:14px 28px;font-family:{FONT};">
<a href="{WIKI_HOME_URL}" style="font-family:{FONT};font-size:14px;line-height:20px;font-weight:bold;color:{COLOR_WHITE};text-decoration:none;">Zum Thomas-Krenn-Wiki &rarr;</a>
</td>
</tr>
</table>
</td>
</tr>
<tr>
<td style="padding:20px 36px;background:{COLOR_BG};border-top:1px solid {COLOR_BORDER};font-family:{FONT};font-size:11px;line-height:17px;color:{COLOR_MUTED};text-align:center;">
Automatisch erstellter interner Newsletter &middot; Nur für den Gebrauch bei {escape(ORG_NAME)}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
OUTLOOK_FONT = "Arial, sans-serif"
OUTLOOK_TEXT = "#000000"
OUTLOOK_MUTED = "#666666"
OUTLOOK_LINK = "#0563C1"
OUTLOOK_HEADING = "#003366"
OUTLOOK_BORDER = "#DDDDDD"
def create_outlook_html(
articles: list[dict[str, Any]],
days: int,
display: DisplayOptions | None = None,
category: str = "",
) -> str:
"""Vereinfachtes HTML für Outlook/Word nur Tabellen und Inline-Styles."""
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 = escape(_intro_sentence(period_start, period_end))
subject = escape(create_subject(days, category))
if not new_articles and not edited_articles:
body_rows = f"""<tr>
<td style="padding:12px 0;font-family:{OUTLOOK_FONT};font-size:11pt;color:{OUTLOOK_MUTED};">
Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.
</td>
</tr>"""
else:
body_rows = "\n".join(
[
_outlook_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum."),
_outlook_section(
"Bearbeitete Artikel",
edited_articles,
display,
"Keine bearbeiteten Artikel im Zeitraum.",
),
]
)
category_block = ""
if category.strip():
category_block = f"""<p style="margin:0 0 8px;font-family:{OUTLOOK_FONT};font-size:10pt;color:{OUTLOOK_MUTED};">
<strong style="font-family:{OUTLOOK_FONT};color:{OUTLOOK_HEADING};">Filter:</strong> Kategorie {escape(category.strip())}
</p>"""
total = len(new_articles) + len(edited_articles)
summary_row = f"""<tr>
<td style="padding:16px 0 0;font-family:{OUTLOOK_FONT};font-size:10pt;color:{OUTLOOK_MUTED};border-top:1px solid {OUTLOOK_BORDER};">
<strong style="font-family:{OUTLOOK_FONT};color:{OUTLOOK_HEADING};">Zusammenfassung:</strong>
{len(new_articles)} neu &middot; {len(edited_articles)} bearbeitet &middot; {total} gesamt
</td>
</tr>"""
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">
<title>{subject}</title>
<!--[if gte mso 9]>
<xml>
<w:WordDocument>
<w:View>Print</w:View>
<w:Zoom>100</w:Zoom>
<w:DoNotOptimizeForBrowser/>
</w:WordDocument>
</xml>
<![endif]-->
<!--[if mso]>
<style type="text/css">
body, table, td, p, a, span, strong {{ font-family: Arial, sans-serif !important; }}
</style>
<![endif]-->
</head>
<body style="margin:0;padding:16px;font-family:{OUTLOOK_FONT};font-size:11pt;color:{OUTLOOK_TEXT};background:#FFFFFF;">
<table width="600" cellpadding="0" cellspacing="0" border="0" align="center" style="width:600px;font-family:{OUTLOOK_FONT};font-size:11pt;color:{OUTLOOK_TEXT};background:#FFFFFF;">
<tr>
<td style="padding:16px 20px;background:{OUTLOOK_HEADING};font-family:{OUTLOOK_FONT};">
<p style="margin:0;font-family:{OUTLOOK_FONT};font-size:9pt;color:#A9C6E8;text-transform:uppercase;letter-spacing:1px;">Interner Newsletter</p>
<p style="margin:6px 0 0;font-family:{OUTLOOK_FONT};font-size:18pt;font-weight:bold;color:#FFFFFF;">Thomas-Krenn Wiki Update</p>
<p style="margin:4px 0 0;font-family:{OUTLOOK_FONT};font-size:11pt;color:#D9E8F8;">{escape(ORG_NAME)}</p>
</td>
</tr>
<tr>
<td style="padding:20px;font-family:{OUTLOOK_FONT};font-size:11pt;color:{OUTLOOK_TEXT};">
<p style="margin:0 0 8px;font-family:{OUTLOOK_FONT};font-size:11pt;color:{OUTLOOK_TEXT};">Liebe Kolleginnen und Kollegen,</p>
<p style="margin:0 0 12px;font-family:{OUTLOOK_FONT};font-size:11pt;color:{OUTLOOK_TEXT};">{intro}</p>
<p style="margin:0 0 16px;font-family:{OUTLOOK_FONT};font-size:9pt;color:{OUTLOOK_MUTED};">Erstellt am: {created_at}</p>
{category_block}
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:{OUTLOOK_FONT};font-size:11pt;color:{OUTLOOK_TEXT};">
{body_rows}
{summary_row}
<tr>
<td style="padding:20px 0 0;font-family:{OUTLOOK_FONT};font-size:11pt;">
<a href="{WIKI_HOME_URL}" style="font-family:{OUTLOOK_FONT};font-size:11pt;font-weight:bold;color:{OUTLOOK_LINK};">Zum Thomas-Krenn-Wiki &rarr;</a>
</td>
</tr>
<tr>
<td style="padding:16px 0 0;font-family:{OUTLOOK_FONT};font-size:9pt;color:{OUTLOOK_MUTED};border-top:1px solid {OUTLOOK_BORDER};">
Automatisch erstellter interner Newsletter &middot; Nur für den Gebrauch bei {escape(ORG_NAME)}
</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>"""
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_article_rows(items: list[dict[str, Any]], display: DisplayOptions) -> str:
rows: list[str] = []
for idx, item in enumerate(items, 1):
title = escape(item.get("title", "Ohne Titel"))
url = escape(_article_url(item.get("title", "")))
meta = _outlook_meta_line(item, display)
meta_html = ""
if meta:
meta_html = (
f'<br><span style="font-family:{OUTLOOK_FONT};font-size:9pt;color:{OUTLOOK_MUTED};">'
f"{escape(meta)}</span>"
)
rows.append(
f"""<tr>
<td width="28" valign="top" style="padding:6px 8px 10px 0;border-bottom:1px solid {OUTLOOK_BORDER};font-family:{OUTLOOK_FONT};font-size:11pt;color:{OUTLOOK_TEXT};">{idx}.</td>
<td valign="top" style="padding:6px 0 10px;border-bottom:1px solid {OUTLOOK_BORDER};font-family:{OUTLOOK_FONT};font-size:11pt;color:{OUTLOOK_TEXT};">
<a href="{url}" style="font-family:{OUTLOOK_FONT};font-size:11pt;color:{OUTLOOK_LINK};">{title}</a>{meta_html}
</td>
</tr>"""
)
return "\n".join(rows)
def _outlook_section(
heading: str,
items: list[dict[str, Any]],
display: DisplayOptions,
empty_text: str,
) -> str:
header = f"""<tr>
<td colspan="2" style="padding:18px 0 6px;font-family:{OUTLOOK_FONT};font-size:13pt;font-weight:bold;color:{OUTLOOK_HEADING};border-bottom:2px solid {OUTLOOK_HEADING};">
{escape(heading)} ({len(items)})
</td>
</tr>"""
if not items:
return header + f"""<tr>
<td colspan="2" style="padding:8px 0;font-family:{OUTLOOK_FONT};font-size:11pt;color:{OUTLOOK_MUTED};font-style:italic;">{escape(empty_text)}</td>
</tr>"""
return header + _outlook_article_rows(items, display)
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,
f"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"""<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-top:28px;font-family:{FONT};">
<tr>
<td style="padding:0;font-family:{FONT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{accent_bg};border:1px solid {COLOR_BORDER};font-family:{FONT};">
<tr>
<td style="width:6px;background:{accent};font-size:0;line-height:0;">&nbsp;</td>
<td style="padding:14px 16px;font-family:{FONT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:{FONT};">
<tr>
<td style="font-family:{FONT};">
<h2 style="margin:0;font-family:{FONT};font-size:17px;line-height:22px;font-weight:bold;color:{COLOR_HEADING};">{escape(heading)}</h2>
</td>
<td align="right" style="font-family:{FONT};white-space:nowrap;">
<span style="display:inline-block;padding:4px 10px;background:{accent};font-family:{FONT};font-size:11px;line-height:14px;font-weight:bold;color:{COLOR_WHITE};">{badge}</span>
<span style="display:inline-block;margin-left:8px;padding:4px 10px;background:{COLOR_WHITE};border:1px solid {COLOR_BORDER};font-family:{FONT};font-size:11px;line-height:14px;color:{COLOR_MUTED};">{len(items)}</span>
</td>
</tr>
</table>
</td>
</tr>
</table>
</td>
</tr>
</table>"""
]
if not items:
blocks.append(
f'<p style="margin:14px 0 0;padding:0 4px;font-family:{FONT};font-size:14px;line-height:22px;color:{COLOR_MUTED};">{escape(empty_text)}</p>'
)
return "\n".join(blocks)
blocks.append(
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-top:10px;font-family:{FONT};">'
)
for idx, item in enumerate(items, start=1):
blocks.append(_html_item_row(idx, item, display, accent))
blocks.append("</table>")
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'<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin-top:10px;font-family:{FONT};"><tr>{"".join(tags)}</tr></table>'
def _html_meta_tag(label: str, value: str) -> str:
return f"""<td style="padding:0 8px 0 0;font-family:{FONT};">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="font-family:{FONT};">
<tr>
<td style="padding:5px 10px;background:{COLOR_BG};border:1px solid {COLOR_BORDER};font-family:{FONT};font-size:11px;line-height:16px;color:{COLOR_MUTED};">
<strong style="font-family:{FONT};color:{COLOR_TEXT};">{escape(label)}:</strong> {escape(value)}
</td>
</tr>
</table>
</td>"""
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"""<tr>
<td style="padding:0 0 10px;font-family:{FONT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{COLOR_WHITE};border:1px solid {COLOR_BORDER};font-family:{FONT};">
<tr>
<td style="width:4px;background:{accent};font-size:0;line-height:0;">&nbsp;</td>
<td style="padding:14px 16px;font-family:{FONT};">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:{FONT};">
<tr>
<td style="width:26px;vertical-align:top;font-family:{FONT};font-size:13px;line-height:20px;font-weight:bold;color:{accent};">{idx}.</td>
<td style="vertical-align:top;font-family:{FONT};">
<a href="{url}" style="font-family:{FONT};font-size:15px;line-height:22px;font-weight:bold;color:{COLOR_LINK};text-decoration:underline;">{title}</a>
{meta_html}
</td>
</tr>
</table>
</td>
</tr>
</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:32px;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;">&nbsp;</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;">&nbsp;</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>"""
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