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'

Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.

' 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'

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

' if category.strip() else "" ) return f""" {subject}
{category_hint}
 

Interner Newsletter

Thomas-Krenn Wiki Update

{escape(ORG_NAME)}

Liebe Kolleginnen und Kollegen,

{intro}

Erstellt am {created_at}

{body} {summary}
Zum Thomas-Krenn-Wiki →
Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {escape(ORG_NAME)}
""" 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) -> str: weight = "" if bold else "" weight_end = "" if bold else "" return ( f'' f'' f'' f"{weight}{escape(title)}{weight_end}" f"" ) 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""" {_outlook_font("Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.", color=OUTLOOK_MUTED)} """ 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'

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

" ) total = len(new_articles) + len(edited_articles) summary_row = f""" {_outlook_font("Zusammenfassung: ", color=OUTLOOK_HEADING, bold=True, size="1")} {_outlook_font(f"{len(new_articles)} neu · {len(edited_articles)} bearbeitet · {total} gesamt", color=OUTLOOK_MUTED, size="1")} """ return f""" {subject}
 
{_outlook_font("Interner Newsletter", color=OUTLOOK_ORANGE, size="1")}
{_outlook_font("Thomas-Krenn Wiki Update", color=OUTLOOK_WHITE, size="5", bold=True)}
{_outlook_font(ORG_NAME, color="#CCCCCC", size="2")}

{_outlook_font("Liebe Kolleginnen und Kollegen,")}

{_outlook_font(intro)}

{_outlook_font(f"Erstellt am: {created_at}", color=OUTLOOK_MUTED, size="1")}

{category_block} {body_rows} {summary_row}
{_outlook_link("Zum Thomas-Krenn-Wiki →", WIKI_HOME_URL)}
{_outlook_font(f"Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {ORG_NAME}", color=OUTLOOK_MUTED, size="1")}
""" 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"
{_outlook_font(meta, color=OUTLOOK_MUTED, size='1')}" rows.append( f""" {_outlook_font(f"{idx}.")} {_outlook_link(title, url)}{meta_html} """ ) return "\n".join(rows) def _outlook_section( heading: str, items: list[dict[str, Any]], 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)} """ 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"""
 

{escape(heading)}

{badge} {len(items)}
""" ] if not items: blocks.append( f'

{escape(empty_text)}

' ) return "\n".join(blocks) blocks.append( f'' ) for idx, item in enumerate(items, start=1): blocks.append(_html_item_row(idx, item, display, accent)) blocks.append("
") return "\n".join(blocks) def _html_meta_tags(item: dict[str, Any], display: DisplayOptions) -> str: tags: list[str] = [] if display["show_date"]: tags.append(_html_meta_tag("Datum", _format_ts(item.get("timestamp")))) if display["show_user"]: tags.append(_html_meta_tag("Benutzer", str(item.get("user", "-")))) if display["show_category"]: cat = ", ".join(item.get("categories", [])) or "Keine Kategorie" tags.append(_html_meta_tag("Kategorie", cat)) if not tags: return "" return f'{"".join(tags)}
' def _html_meta_tag(label: str, value: str) -> str: return f"""
{escape(label)}: {escape(value)}
""" def _html_item_row(idx: int, item: dict[str, Any], display: DisplayOptions, accent: str) -> str: title = escape(item.get("title", "Ohne Titel")) url = escape(_article_url(item.get("title", ""))) meta_html = _html_meta_tags(item, display) return f"""
 
{idx}. {title} {meta_html}
""" def _html_summary(new_articles: list[dict[str, Any]], edited_articles: list[dict[str, Any]]) -> str: total = len(new_articles) + len(edited_articles) return f"""
Zusammenfassung
{len(new_articles)}
Neue Artikel
 
{len(edited_articles)}
Bearbeitet
 
{total}
Gesamt
""" 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