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 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", } def filter_articles( articles: list[dict[str, Any]], category_filter: str | None = None, ) -> list[dict[str, Any]]: 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,", "", "im Thomas-Krenn-Wiki wurden im ausgewählten Zeitraum folgende Artikel", "neu veröffentlicht bzw. aktualisiert:", "", ] ) 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)) category_row = "" if category.strip(): category_row = f'

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

' if not new_articles and not edited_articles: body = '

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."), _html_section("Bearbeitete Artikel", edited_articles, display, "Keine bearbeiteten Artikel im Zeitraum."), ] ) summary = _html_summary(new_articles, edited_articles) return f""" {subject}

Interner Wiki-Newsletter

{escape(ORG_NAME)}

Zeitraum: {period_start} bis {period_end} ({days} Tage)

Erstellt am: {created_at}

{category_row}

Liebe Kolleginnen und Kollegen,

im Thomas-Krenn-Wiki wurden im ausgewählten Zeitraum folgende Artikel neu veröffentlicht bzw. aktualisiert:

{body} {summary}

Zum Thomas-Krenn-Wiki

Dieser Newsletter wurde automatisch erstellt und ist nur für den internen Gebrauch bei {escape(ORG_NAME)} bestimmt.
""" 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) -> str: blocks = [ f'

{escape(heading)} ({len(items)})

' ] if not items: blocks.append(f'

{escape(empty_text)}

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