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'
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}
| |
|
Interner Newsletter
Thomas-Krenn Wiki Update
{escape(ORG_NAME)}
|
|
Liebe Kolleginnen und Kollegen,
{intro}
|
|
{category_hint}
|
Erstellt am {created_at}
|
|
{body}
{summary}
|
|
Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {escape(ORG_NAME)}
|
|
"""
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"""
|
Im gewählten Zeitraum wurden keine passenden Wiki-Änderungen gefunden.
|
"""
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"""
Filter: Kategorie {escape(category.strip())}
"""
total = len(new_articles) + len(edited_articles)
summary_row = f"""
|
Zusammenfassung:
{len(new_articles)} neu · {len(edited_articles)} bearbeitet · {total} gesamt
|
"""
return f"""
{subject}
|
Interner Newsletter
Thomas-Krenn Wiki Update
{escape(ORG_NAME)}
|
|
Liebe Kolleginnen und Kollegen,
{intro}
Erstellt am: {created_at}
{category_block}
{body_rows}
{summary_row}
|
Zum Thomas-Krenn-Wiki →
|
|
Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {escape(ORG_NAME)}
|
|
"""
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'
'
f"{escape(meta)}"
)
rows.append(
f"""
| {idx}. |
{title}{meta_html}
|
"""
)
return "\n".join(rows)
def _outlook_section(
heading: str,
items: list[dict[str, Any]],
display: DisplayOptions,
empty_text: str,
) -> str:
header = f"""
|
{escape(heading)} ({len(items)})
|
"""
if not items:
return header + f"""
| {escape(empty_text)} |
"""
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''
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"""
|
|
"""
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