Files
TK-Wiki-Newsletter/app/services/newsletter.py
smueller 9f42cc77cc Enhance UI and functionality for admin dashboard and user management
- Update `_base_context` to include `active_nav` for dynamic navigation highlighting.
- Revamp `base.html` to improve header layout and navigation links for better user experience.
- Redesign `dashboard.html` to include statistics for new and edited articles, enhancing visibility of content changes.
- Improve `users.html` layout for user management, adding detailed user roles and status indicators.
- Update CSS styles for a cohesive design across the application, introducing new color variables and layout adjustments.
2026-07-03 13:44:57 +02:00

726 lines
28 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"
# Thomas-Krenn.AG Corporate Design (Orange + Anthrazit)
COLOR_TK_ORANGE = "#ED6B06"
COLOR_TK_ORANGE_DARK = "#C95605"
COLOR_TK_ORANGE_LIGHT = "#FFF4ED"
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_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(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_TK_ORANGE};font-size:0;line-height:0;">&nbsp;</td>
</tr>
<tr>
<td style="background:{COLOR_TK_GRAY_DARK};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:{COLOR_TK_ORANGE};">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:#CCCCCC;">{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_TK_ORANGE_LIGHT};border-left:4px solid {COLOR_TK_ORANGE};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_TK_ORANGE};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 = 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) -> str:
weight = "<b>" if bold else ""
weight_end = "</b>" if bold else ""
return (
f'<a href="{escape(url)}" '
f'style="color:{OUTLOOK_LINK} !important;text-decoration:none !important;mso-style-priority:100 !important;">'
f'<span style="color:{OUTLOOK_LINK} !important;" data-ogsc="{OUTLOOK_LINK}">'
f'<font face="Arial" color="{OUTLOOK_LINK}" size="2">'
f"{weight}{escape(title)}{weight_end}"
f"</font></span></a>"
)
def create_outlook_html(
articles: list[dict[str, Any]],
days: int,
display: DisplayOptions | None = None,
category: 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)
period_start, period_end = _period_range(days)
created_at = datetime.now().strftime("%d.%m.%Y %H:%M")
intro = _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 {_outlook_td_attrs(colspan=2, extra_style="padding:12px 0;")}>
{_outlook_font("Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.", color=OUTLOOK_MUTED)}
</td>
</tr>"""
else:
body_rows = "\n".join(
[
_outlook_section("Neue Artikel", new_articles, display, "Keine neuen Artikel im Zeitraum.", kind="new"),
_outlook_section(
"Bearbeitete Artikel",
edited_articles,
display,
"Keine bearbeiteten Artikel im Zeitraum.",
kind="edited",
),
]
)
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>"
)
total = len(new_articles) + len(edited_articles)
summary_row = f"""<tr>
<td {_outlook_td_attrs(colspan=2, 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>"""
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="600" cellpadding="0" cellspacing="0" border="0" align="center" bgcolor="{OUTLOOK_WHITE}" style="width:600px;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;")}>&nbsp;</td>
</tr>
<tr>
<td {_outlook_td_attrs(bg=OUTLOOK_GRAY_DARK, extra_style="padding:16px 20px;")}>
{_outlook_font("Interner Newsletter", color=OUTLOOK_ORANGE, size="1")}<br>
{_outlook_font("Thomas-Krenn Wiki Update", color=OUTLOOK_WHITE, size="5", bold=True)}<br>
{_outlook_font(ORG_NAME, color="#CCCCCC", size="2")}
</td>
</tr>
<tr>
<td {_outlook_td_attrs(extra_style="padding:20px;")}>
<p style="margin:0 0 8px;">{_outlook_font("Liebe Kolleginnen und Kollegen,")}</p>
<p style="margin:0 0 12px;">{_outlook_font(intro)}</p>
<p style="margin:0 0 16px;">{_outlook_font(f"Erstellt am: {created_at}", color=OUTLOOK_MUTED, size="1")}</p>
{category_block}
<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}
<tr>
<td {_outlook_td_attrs(colspan=2, extra_style="padding:20px 0 0;")}>
{_outlook_link("Zum Thomas-Krenn-Wiki →", WIKI_HOME_URL)}
</td>
</tr>
<tr>
<td {_outlook_td_attrs(colspan=2, 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_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 = item.get("title", "Ohne Titel")
url = _article_url(item.get("title", ""))
meta = _outlook_meta_line(item, display)
meta_html = ""
if meta:
meta_html = f"<br>{_outlook_font(meta, color=OUTLOOK_MUTED, size='1')}"
rows.append(
f"""<tr>
<td {_outlook_td_attrs(width="28", valign="top", extra_style=f"padding:8px 8px 10px 0;border-bottom:1px solid {OUTLOOK_BORDER};")}>
{_outlook_font(f"{idx}.")}
</td>
<td {_outlook_td_attrs(valign="top", extra_style=f"padding:8px 0 10px;border-bottom:1px solid {OUTLOOK_BORDER};")}>
{_outlook_link(title, url)}{meta_html}
</td>
</tr>"""
)
return "\n".join(rows)
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(colspan=2, 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(colspan=2, extra_style="padding:8px 0;")}>
{_outlook_font(empty_text, color=OUTLOOK_MUTED)}
</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:none;">{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