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(days: int, category: str = "", period_label: str | None = None) -> str: label = period_label or f"{days} Tage" base = f"Interner Wiki-Newsletter – {ORG_NAME} ({label})" if category.strip(): return f"{base} | Kategorie: {category.strip()}" return base _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), "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", "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", ) -> 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), "", ] ) 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_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", ) -> 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(days, category, period_label)) 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'{_html_summary(new_articles, edited_articles)}' ) editor_tip_section = _html_editor_tip(editor_tip) category_hint = "" if category.strip(): category_hint = ( f'' f'

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

' ) return f""" {subject}
{preheader} ‌ ‌ ‌
{category_hint} {highlights_section} {body} {summary_section} {editor_tip_section}
""" def _cta_button(url: str, label: str) -> str: return ( f'' f'
' f'{escape(label)}' f'
' ) def _html_empty_card(text: str) -> str: return ( f'' f'' f'
' f'{escape(text)}
' ) def _html_highlights(items: list[dict[str, Any]]) -> str: if not items: return "" rows = "" for idx, item in enumerate(items[:3], start=1): url = escape(_article_url(item.get("title", ""))) title = escape(item.get("title", "Ohne Titel")) rows += ( f'' f'{idx}. ' f'{title}' ) return ( f'' f'' f'' f'' f'
Top Highlights
{rows}
' ) def _html_editor_tip(editor_tip: str) -> str: text = (editor_tip or "").strip() if not text: return "" body = escape(text).replace("\n", "
") return ( f'' f'' f'' f'' f'
Redaktionsnotiz
{body}
' ) def _html_section( heading: str, items: list[dict[str, Any]], display: DisplayOptions, empty_text: str, kind: str = "edited", ) -> str: accent = COLOR_ACCENT_NEW if kind == "new" else COLOR_ACCENT_EDIT heading_html = ( f'' f'
{escape(heading)} ' f'({len(items)})
' ) if not items: return heading_html + ( f'{escape(empty_text)}' ) cards = "".join(_html_article_card(idx, item, display, kind) for idx, item in enumerate(items, start=1)) return heading_html + cards def _html_article_card(idx: int, item: dict[str, Any], display: DisplayOptions, kind: str) -> str: accent = COLOR_ACCENT_NEW if kind == "new" else COLOR_ACCENT_EDIT eyebrow = "Neuer Beitrag" if kind == "new" else "Aktualisierter Beitrag" title = escape(item.get("title", "Ohne Titel")) url = escape(_article_url(item.get("title", ""))) meta_bits: list[str] = [] if display["show_date"]: meta_bits.append(f"am {_format_ts(item.get('timestamp'))}") if display["show_user"]: meta_bits.append(f"von {escape(str(item.get('user', '-')))}") meta_html = "" if meta_bits: meta_html = ( f'Veröffentlicht {" ".join(meta_bits)}.' ) cat_html = "" if display["show_category"]: cat = ", ".join(item.get("categories", [])) or "Keine Kategorie" cat_html = ( f'Kategorie: {escape(cat)}' ) return ( f'' f'' f'' f'' f'{meta_html}{cat_html}' f'' f'
{idx}. {eyebrow}
' f'{title}
{_cta_button(url, "Zum Beitrag →")}
' ) def _html_summary(new_articles: list[dict[str, Any]], edited_articles: list[dict[str, Any]]) -> str: total = len(new_articles) + len(edited_articles) return f"""
Zusammenfassung
{len(new_articles)}
Neue Artikel
 
{len(edited_articles)}
Bearbeitet
 
{total}
Gesamt
""" # --------------------------------------------------------------------------- # Outlook/Word-kompatibles HTML (UI-Vorschau & Copy-Paste in Outlook) # --------------------------------------------------------------------------- OUTLOOK_FONT = "Arial, sans-serif" OUTLOOK_TEXT = COLOR_TEXT OUTLOOK_MUTED = COLOR_TK_GRAY 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"{inner}" return ( f'' f'{inner}' f"" ) def _outlook_link(title: str, url: str, *, bold: bool = True, color: str = OUTLOOK_LINK, size: str = "2") -> str: weight = "" if bold else "" weight_end = "" if bold else "" return ( f'' f'' f'' f"{weight}{escape(title)}{weight_end}" f"" ) def _outlook_cta(url: str, label: str) -> str: return ( f'' f'
' f'{_outlook_link(label, url, color="#FFFFFF")}' f'
' ) def create_outlook_html( articles: list[dict[str, Any]], days: int, display: DisplayOptions | None = None, category: str = "", editor_tip: str = "", highlights: list[str] | None = None, period_label: str | None = None, period: tuple[str, str] | None = None, article_type: str = "all", ) -> 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(days, category, period_label)) period_label = f"{period_start} – {period_end}" summary_text = _summary_sentence(total) highlight_articles = _match_highlights(new_articles + edited_articles, highlights) highlights_rows = _outlook_highlights(highlight_articles) if not new_articles and not edited_articles: body_rows = f""" {_outlook_font("Für diesen Zeitraum wurden keine passenden Wiki-Änderungen gefunden.", color=OUTLOOK_MUTED)} """ 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'

' f'{_outlook_font("Filter: ", color=OUTLOOK_HEADING, bold=True)}' f'{_outlook_font(f"Kategorie {category.strip()}", color=OUTLOOK_MUTED, size="1")}' f"

" ) summary_row = f""" {_outlook_font("Zusammenfassung: ", color=OUTLOOK_HEADING, bold=True, size="1")} {_outlook_font(f"{len(new_articles)} neu · {len(edited_articles)} bearbeitet · {total} gesamt", color=OUTLOOK_MUTED, size="1")} """ editor_tip_row = _outlook_editor_tip(editor_tip) return f""" {subject}
 
Thomas-Krenn Logo
{_outlook_font("Thomas-Krenn Wiki Update", color=OUTLOOK_ORANGE, size="1", bold=True)}
{_outlook_font("Die neuesten Wiki-Artikel", color=OUTLOOK_WHITE, size="5", bold=True)}
{_outlook_font(f"{period_label} | {summary_text}", color="#DDDDDD", size="2")}

{_outlook_font("Servus,")}

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

{category_block} {highlights_rows} {body_rows} {summary_row} {editor_tip_row}
{_outlook_cta(WIKI_HOME_URL, "Zum Thomas-Krenn-Wiki →")}
{_outlook_font(f"Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {ORG_NAME}", color=OUTLOOK_MUTED, size="1")}
""" def _outlook_highlights(items: list[dict[str, Any]]) -> str: if not items: return "" rows = "" for idx, item in enumerate(items[:3], start=1): url = _article_url(item.get("title", "")) rows += ( f'' f'{_outlook_font(f"{idx}. ", color=OUTLOOK_ORANGE, bold=True)}' f'{_outlook_link(item.get("title", "Ohne Titel"), url, color=OUTLOOK_TEXT, bold=False)}' f'' ) return ( f'' f'' f'' f'
' f'{_outlook_font("TOP HIGHLIGHTS", color=COLOR_TK_ORANGE_DARK, size="1", bold=True)}
' f'{rows}
' ) def _outlook_editor_tip(editor_tip: str) -> str: text = (editor_tip or "").strip() if not text: return "" body = "
".join(_outlook_font(line) for line in text.splitlines()) return ( f'' f'{_outlook_font("Redaktionsnotiz", color=OUTLOOK_HEADING, size="1", bold=True)}
' f'{body}' ) def _outlook_meta_line(item: dict[str, Any], display: DisplayOptions) -> str: parts: list[str] = [] if display["show_date"]: 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""" {_outlook_font(f"{heading} ({len(items)})", color=accent, size="3", bold=True)} """ if not items: return header + f""" {_outlook_font(empty_text, color=OUTLOOK_MUTED)} """ cards = "\n".join(_outlook_article_card(idx, item, display, kind) for idx, item in enumerate(items, start=1)) return header + cards def _outlook_article_card(idx: int, item: dict[str, Any], display: DisplayOptions, kind: str) -> str: accent = OUTLOOK_ORANGE if kind == "new" else OUTLOOK_GRAY_DARK eyebrow = "NEUER BEITRAG" if kind == "new" else "AKTUALISIERTER BEITRAG" title = item.get("title", "Ohne Titel") url = _article_url(item.get("title", "")) meta = _outlook_meta_line(item, display) meta_html = f'
{_outlook_font(meta, color=OUTLOOK_MUTED, size="1")}' if meta else "" return f"""
  {_outlook_font(f"{idx}. {eyebrow}", color=accent, size="1", bold=True)}
{_outlook_link(title, url, color=OUTLOOK_TEXT, size="4")}{meta_html}
{_outlook_cta(url, "Zum Beitrag →")}
""" # --------------------------------------------------------------------------- # 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