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(month_label: str | None = None, days: int = 30) -> str:
label = month_label or f"{days} Tage"
return f"Neue Wiki-Artikel Veröffentlichungen vom {label} | Wiki-Redaktion"
_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),
"month_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",
"month_label": _month_label(end),
"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",
contact_email: str = "",
) -> 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, contact_email))
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, contact_email))
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",
month_label: str | None = None,
contact_email: str = "",
) -> 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(month_label or period_label, days))
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())} |
'
)
if contact_email.strip():
ce = escape(contact_email.strip())
contact_html = (
f'Fragen oder ein Themenwunsch? Meldet euch gern beim '
f'Wiki-Team.'
)
else:
contact_html = "Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team."
return f"""
{subject}
|
|
Thomas-Krenn Wiki Update
Die neuesten Wiki-Artikel
{period_label} | {summary_text}
|
|
|
Servus,
hier ist deine Übersicht mit den neuesten Beiträgen aus dem Thomas-Krenn-Wiki vom {escape(period_start)} bis {escape(period_end)}.
|
|
{category_hint}
{highlights_section}
{body}
{summary_section}
{editor_tip_section}
|
{_cta_button(WIKI_HOME_URL, "Zum Thomas-Krenn-Wiki →")}
|
{contact_html}
Viele Grüße
Euer Thomas-Krenn-Wiki Team
|
|
Thomas-Krenn.AG | Speltenbach-Steinäcker 1 | D-94078 Freyung
Tel.: +49 8551 9150 0 | Fax: +49 8551 9150 55 |
thomas-krenn.com
Automatisch erstellter interner Newsletter · Nur für den internen Gebrauch
|
|
"""
def _cta_button(url: str, label: str) -> str:
return (
f''
)
def _html_empty_card(text: str) -> str:
return (
f'| '
f' |
'
)
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'| Top Highlights | '
f' | '
f' |
'
)
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'| Redaktionsnotiz | '
f'| {body} | '
f' |
'
)
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'| {idx}. {eyebrow} | '
f'| '
f'{title} | '
f'{meta_html}{cat_html}'
f'| {_cta_button(url, "Zum Beitrag →")} | '
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
|
|
"""
# ---------------------------------------------------------------------------
# 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",
month_label: str | None = None,
contact_email: 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)
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(month_label or period_label, days))
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)
if contact_email.strip():
contact_inner = (
f'{_outlook_font("Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team: ", size="1")}'
f'{_outlook_link(contact_email.strip(), f"mailto:{contact_email.strip()}", color=OUTLOOK_ORANGE, bold=False)}'
)
contact_row = f"""
|
{contact_inner}
|
"""
else:
contact_row = ""
return f"""
{subject}
| |
|
{_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 →")}
|
{contact_row}
|
{_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'{_outlook_font("TOP HIGHLIGHTS", color=COLOR_TK_ORANGE_DARK, size="1", bold=True)} |
'
f'| '
f' |
'
f'
'
)
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]],
contact_email: str = "",
) -> str:
total = len(new_articles) + len(edited_articles)
contact_lines = []
if contact_email.strip():
contact_lines = [f"Fragen oder ein Themenwunsch? E-Mail an das Wiki-Team: {contact_email.strip()}", ""]
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}",
"",
*contact_lines,
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