- Introduce optional fields for "Top Highlights" and "Editorial Note" in the newsletter dashboard, allowing editors to highlight key articles and provide additional context. - Update the HTML and plain text generation functions to incorporate these new fields, ensuring they are displayed correctly in the newsletter output. - Modify the dashboard template to include input areas for these new features, improving the user interface for newsletter creation.
927 lines
38 KiB
Python
927 lines
38 KiB
Python
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 = "") -> str:
|
||
base = f"Interner Wiki-Newsletter – {ORG_NAME} ({days} Tage)"
|
||
if category.strip():
|
||
return f"{base} | Kategorie: {category.strip()}"
|
||
return base
|
||
|
||
|
||
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,
|
||
) -> 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),
|
||
"",
|
||
]
|
||
)
|
||
|
||
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()
|
||
|
||
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.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,
|
||
) -> str:
|
||
display = display or DEFAULT_DISPLAY
|
||
new_articles, edited_articles = split_articles_by_type(articles)
|
||
period_start, period_end = _period_range(days)
|
||
total = len(new_articles) + len(edited_articles)
|
||
subject = escape(create_subject(days, category))
|
||
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:
|
||
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_section = (
|
||
f'<tr><td style="padding:24px 32px 0 32px;" class="mobile-padding">{_html_summary(new_articles, edited_articles)}</td></tr>'
|
||
)
|
||
editor_tip_section = _html_editor_tip(editor_tip)
|
||
|
||
category_hint = ""
|
||
if category.strip():
|
||
category_hint = (
|
||
f'<tr><td style="padding:0 32px 4px 32px;font-family:{FONT};" class="mobile-padding">'
|
||
f'<p style="margin:0;font-family:{FONT};font-size:13px;line-height:20px;color:{COLOR_MUTED};">'
|
||
f'<strong style="color:{COLOR_HEADING};">Filter:</strong> Kategorie {escape(category.strip())}</p></td></tr>'
|
||
)
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>{subject}</title>
|
||
<style type="text/css">
|
||
html, body {{ margin:0 !important; padding:0 !important; width:100% !important; background:{COLOR_PAGE_BG}; }}
|
||
table, td {{ border-collapse:collapse; mso-table-lspace:0pt; mso-table-rspace:0pt; }}
|
||
img {{ border:0; outline:none; text-decoration:none; -ms-interpolation-mode:bicubic; }}
|
||
a {{ text-decoration:none; }}
|
||
.preheader {{ display:none !important; visibility:hidden; opacity:0; color:transparent; height:0; width:0; max-height:0; max-width:0; overflow:hidden; mso-hide:all; }}
|
||
@media only screen and (max-width: 700px) {{
|
||
.email-container {{ width:100% !important; }}
|
||
.mobile-padding {{ padding-left:16px !important; padding-right:16px !important; }}
|
||
.mobile-headline {{ font-size:28px !important; line-height:34px !important; }}
|
||
.mobile-card-title {{ font-size:18px !important; line-height:25px !important; }}
|
||
}}
|
||
</style>
|
||
<!--[if mso]>
|
||
<style type="text/css">
|
||
body, table, td, p, a, li, h1, h2, h3, span, strong {{ font-family: Arial, sans-serif !important; }}
|
||
</style>
|
||
<![endif]-->
|
||
</head>
|
||
<body style="margin:0;padding:0;background:{COLOR_PAGE_BG};font-family:{FONT};color:{COLOR_TEXT};">
|
||
<div class="preheader">{preheader} ‌ ‌ ‌</div>
|
||
<center role="article" aria-roledescription="email" lang="de" style="width:100%;background:{COLOR_PAGE_BG};">
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{COLOR_PAGE_BG}" style="width:100%;background:{COLOR_PAGE_BG};">
|
||
<tr>
|
||
<td align="center" style="padding:22px 10px;">
|
||
<table role="presentation" width="700" cellpadding="0" cellspacing="0" border="0" class="email-container" style="width:100%;max-width:700px;background:{COLOR_WHITE};border-top:4px solid {COLOR_TK_ORANGE};">
|
||
<tr>
|
||
<td align="center" style="padding:24px 32px 18px 32px;background:{COLOR_TK_GRAY_DARK};" class="mobile-padding">
|
||
<img src="{TK_LOGO_URL}" width="190" alt="Thomas-Krenn Logo" style="display:block;margin:0 auto;border:0;outline:none;text-decoration:none;height:auto;">
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:0 32px;" class="mobile-padding">
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;background:{COLOR_TK_GRAY_DARK};">
|
||
<tr>
|
||
<td style="padding:28px 26px;font-family:{FONT};">
|
||
<div style="font-size:12px;line-height:16px;font-weight:bold;color:{COLOR_TK_ORANGE};letter-spacing:1px;text-transform:uppercase;">Thomas-Krenn Wiki Update</div>
|
||
<div class="mobile-headline" style="font-size:32px;line-height:38px;font-weight:bold;color:{COLOR_WHITE};margin-top:10px;">Die neuesten Wiki-Artikel</div>
|
||
<div style="font-size:16px;line-height:25px;color:#DDDDDD;margin-top:10px;">{period_label} | {summary_text}</div>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:22px 32px 6px 32px;" class="mobile-padding">
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background:{COLOR_TK_ORANGE_LIGHT};border-left:4px solid {COLOR_TK_ORANGE};">
|
||
<tr>
|
||
<td style="padding:18px 20px;font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_TEXT};">
|
||
<p style="margin:0 0 10px;font-family:{FONT};">Servus,</p>
|
||
<p style="margin:0;font-family:{FONT};">hier ist deine Übersicht mit den neuesten Beiträgen aus dem Thomas-Krenn-Wiki vom {escape(period_start)} bis {escape(period_end)}.</p>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
{category_hint}
|
||
{highlights_section}
|
||
{body}
|
||
{summary_section}
|
||
{editor_tip_section}
|
||
<tr>
|
||
<td align="center" style="padding:28px 32px 4px 32px;" class="mobile-padding">
|
||
{_cta_button(WIKI_HOME_URL, "Zum Thomas-Krenn-Wiki →")}
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="padding:24px 32px 28px 32px;" class="mobile-padding">
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||
<tr>
|
||
<td style="font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_TEXT};">
|
||
Fragen oder ein Themenwunsch? Meldet euch gern beim Wiki-Team.<br><br>
|
||
Viele Grüße<br>
|
||
Euer Thomas-Krenn-Wiki Team
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="background:{COLOR_TK_GRAY_DARK};padding:22px 20px;text-align:center;font-family:{FONT};">
|
||
<div style="font-size:12px;line-height:19px;color:#EEF0F2;">
|
||
Thomas-Krenn.AG | Speltenbach-Steinäcker 1 | D-94078 Freyung<br>
|
||
Tel.: +49 8551 9150 0 | Fax: +49 8551 9150 55 |
|
||
<a href="{WIKI_HOME_URL}" target="_blank" style="color:{COLOR_TK_ORANGE};text-decoration:underline;">thomas-krenn.com</a><br>
|
||
<span style="color:#AEB2B7;">Automatisch erstellter interner Newsletter · Nur für den internen Gebrauch</span>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</center>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
def _cta_button(url: str, label: str) -> str:
|
||
return (
|
||
f'<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="margin:0 auto;">'
|
||
f'<tr><td bgcolor="{COLOR_TK_ORANGE}" style="background:{COLOR_TK_ORANGE};border-radius:6px;">'
|
||
f'<a href="{escape(url)}" target="_blank" style="display:inline-block;padding:12px 22px;font-family:{FONT};'
|
||
f'font-size:14px;line-height:18px;font-weight:bold;color:{COLOR_WHITE};text-decoration:none;">{escape(label)}</a>'
|
||
f'</td></tr></table>'
|
||
)
|
||
|
||
|
||
def _html_empty_card(text: str) -> str:
|
||
return (
|
||
f'<tr><td style="padding:8px 32px 8px 32px;" class="mobile-padding">'
|
||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
|
||
f'style="width:100%;background:{COLOR_WHITE};border:1px solid {COLOR_BORDER};border-radius:12px;">'
|
||
f'<tr><td style="padding:20px 22px;font-family:{FONT};font-size:15px;line-height:23px;color:{COLOR_TEXT};">'
|
||
f'{escape(text)}</td></tr></table></td></tr>'
|
||
)
|
||
|
||
|
||
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'<tr><td style="padding:0 0 8px 0;font-family:{FONT};font-size:15px;line-height:22px;color:{COLOR_TEXT};">'
|
||
f'<span style="display:inline-block;min-width:22px;font-weight:bold;color:{COLOR_TK_ORANGE};">{idx}.</span> '
|
||
f'<a href="{url}" target="_blank" style="color:{COLOR_TEXT};text-decoration:none;">{title}</a></td></tr>'
|
||
)
|
||
return (
|
||
f'<tr><td style="padding:8px 32px 4px 32px;" class="mobile-padding">'
|
||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
|
||
f'style="width:100%;background:{COLOR_TK_ORANGE_LIGHT};border:1px solid {COLOR_TK_ORANGE_BORDER};border-radius:12px;">'
|
||
f'<tr><td style="padding:16px 18px 8px 18px;font-family:{FONT};font-size:11px;line-height:15px;'
|
||
f'color:{COLOR_TK_ORANGE_DARK};font-weight:bold;letter-spacing:.8px;text-transform:uppercase;">Top Highlights</td></tr>'
|
||
f'<tr><td style="padding:0 18px 12px 18px;"><table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">{rows}</table></td></tr>'
|
||
f'</table></td></tr>'
|
||
)
|
||
|
||
|
||
def _html_editor_tip(editor_tip: str) -> str:
|
||
text = (editor_tip or "").strip()
|
||
if not text:
|
||
return ""
|
||
body = escape(text).replace("\n", "<br>")
|
||
return (
|
||
f'<tr><td style="padding:16px 32px 4px 32px;" class="mobile-padding">'
|
||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
|
||
f'style="width:100%;background:{COLOR_TK_GRAY_LIGHT};border:1px solid {COLOR_BORDER};border-radius:12px;">'
|
||
f'<tr><td style="padding:16px 18px 8px 18px;font-family:{FONT};font-size:11px;line-height:15px;'
|
||
f'color:{COLOR_HEADING};font-weight:bold;letter-spacing:.8px;text-transform:uppercase;">Redaktionsnotiz</td></tr>'
|
||
f'<tr><td style="padding:0 18px 16px 18px;font-family:{FONT};font-size:15px;line-height:24px;color:{COLOR_TEXT};">{body}</td></tr>'
|
||
f'</table></td></tr>'
|
||
)
|
||
|
||
|
||
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'<tr><td style="padding:26px 32px 8px 32px;font-family:{FONT};" class="mobile-padding">'
|
||
f'<div style="font-family:{FONT};font-size:18px;line-height:24px;font-weight:bold;color:{COLOR_HEADING};'
|
||
f'border-bottom:2px solid {accent};padding-bottom:8px;">{escape(heading)} '
|
||
f'<span style="color:{accent};">({len(items)})</span></div></td></tr>'
|
||
)
|
||
if not items:
|
||
return heading_html + (
|
||
f'<tr><td style="padding:8px 32px 4px 32px;font-family:{FONT};font-size:14px;line-height:22px;'
|
||
f'color:{COLOR_MUTED};" class="mobile-padding">{escape(empty_text)}</td></tr>'
|
||
)
|
||
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'<tr><td style="padding:0 22px 12px 22px;font-family:{FONT};font-size:13px;line-height:20px;'
|
||
f'color:{COLOR_MUTED};">Veröffentlicht {" ".join(meta_bits)}.</td></tr>'
|
||
)
|
||
cat_html = ""
|
||
if display["show_category"]:
|
||
cat = ", ".join(item.get("categories", [])) or "Keine Kategorie"
|
||
cat_html = (
|
||
f'<tr><td style="padding:0 22px 12px 22px;font-family:{FONT};font-size:12px;line-height:18px;'
|
||
f'color:{COLOR_MUTED};">Kategorie: {escape(cat)}</td></tr>'
|
||
)
|
||
|
||
return (
|
||
f'<tr><td style="padding:0 32px 16px 32px;" class="mobile-padding">'
|
||
f'<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" '
|
||
f'style="width:100%;background:{COLOR_WHITE};border:1px solid {COLOR_BORDER};border-left:4px solid {accent};border-radius:12px;">'
|
||
f'<tr><td style="padding:16px 22px 4px 22px;font-family:{FONT};font-size:11px;line-height:16px;font-weight:bold;'
|
||
f'letter-spacing:.7px;text-transform:uppercase;color:{accent};">{idx}. {eyebrow}</td></tr>'
|
||
f'<tr><td class="mobile-card-title" style="padding:0 22px 8px 22px;font-family:{FONT};font-size:20px;'
|
||
f'line-height:27px;font-weight:bold;color:{COLOR_HEADING};">'
|
||
f'<a href="{url}" target="_blank" style="color:{COLOR_HEADING};text-decoration:none;">{title}</a></td></tr>'
|
||
f'{meta_html}{cat_html}'
|
||
f'<tr><td style="padding:2px 22px 18px 22px;">{_cta_button(url, "Zum Beitrag →")}</td></tr>'
|
||
f'</table></td></tr>'
|
||
)
|
||
|
||
|
||
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"""<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-top:4px;font-family:{FONT};">
|
||
<tr>
|
||
<td style="padding:0 0 10px;font-family:{FONT};font-size:14px;line-height:20px;font-weight:bold;color:{COLOR_HEADING};">Zusammenfassung</td>
|
||
</tr>
|
||
<tr>
|
||
<td style="font-family:{FONT};">
|
||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="font-family:{FONT};">
|
||
<tr>
|
||
<td width="33%" style="padding:18px 12px;background:{COLOR_ACCENT_NEW_BG};border:1px solid {COLOR_BORDER};text-align:center;font-family:{FONT};">
|
||
<div style="font-family:{FONT};font-size:28px;line-height:32px;font-weight:bold;color:{COLOR_ACCENT_NEW};">{len(new_articles)}</div>
|
||
<div style="margin-top:4px;font-family:{FONT};font-size:12px;line-height:16px;color:{COLOR_MUTED};">Neue Artikel</div>
|
||
</td>
|
||
<td width="4" style="font-size:0;line-height:0;"> </td>
|
||
<td width="33%" style="padding:18px 12px;background:{COLOR_ACCENT_EDIT_BG};border:1px solid {COLOR_BORDER};text-align:center;font-family:{FONT};">
|
||
<div style="font-family:{FONT};font-size:28px;line-height:32px;font-weight:bold;color:{COLOR_ACCENT_EDIT};">{len(edited_articles)}</div>
|
||
<div style="margin-top:4px;font-family:{FONT};font-size:12px;line-height:16px;color:{COLOR_MUTED};">Bearbeitet</div>
|
||
</td>
|
||
<td width="4" style="font-size:0;line-height:0;"> </td>
|
||
<td width="33%" style="padding:18px 12px;background:{COLOR_BG};border:1px solid {COLOR_BORDER};text-align:center;font-family:{FONT};">
|
||
<div style="font-family:{FONT};font-size:28px;line-height:32px;font-weight:bold;color:{COLOR_HEADING};">{total}</div>
|
||
<div style="margin-top:4px;font-family:{FONT};font-size:12px;line-height:16px;color:{COLOR_MUTED};">Gesamt</div>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>"""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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"<b>{inner}</b>"
|
||
return (
|
||
f'<span style="color:{color} !important;" data-ogsc="{color}">'
|
||
f'<font face="Arial" color="{color}" size="{size}">{inner}</font>'
|
||
f"</span>"
|
||
)
|
||
|
||
|
||
def _outlook_link(title: str, url: str, *, bold: bool = True, color: str = OUTLOOK_LINK, size: str = "2") -> str:
|
||
weight = "<b>" if bold else ""
|
||
weight_end = "</b>" if bold else ""
|
||
return (
|
||
f'<a href="{escape(url)}" '
|
||
f'style="color:{color} !important;text-decoration:none !important;mso-style-priority:100 !important;">'
|
||
f'<span style="color:{color} !important;" data-ogsc="{color}">'
|
||
f'<font face="Arial" color="{color}" size="{size}">'
|
||
f"{weight}{escape(title)}{weight_end}"
|
||
f"</font></span></a>"
|
||
)
|
||
|
||
|
||
def _outlook_cta(url: str, label: str) -> str:
|
||
return (
|
||
f'<table cellpadding="0" cellspacing="0" border="0"><tr>'
|
||
f'<td bgcolor="{OUTLOOK_ORANGE}" style="background-color:{OUTLOOK_ORANGE} !important;padding:9px 16px;" data-ogsb="{OUTLOOK_ORANGE}">'
|
||
f'{_outlook_link(label, url, color="#FFFFFF")}'
|
||
f'</td></tr></table>'
|
||
)
|
||
|
||
|
||
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,
|
||
) -> 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)
|
||
total = len(new_articles) + len(edited_articles)
|
||
subject = escape(create_subject(days, category))
|
||
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"""<tr>
|
||
<td {_outlook_td_attrs(extra_style="padding:12px 0;")}>
|
||
{_outlook_font("Für diesen Zeitraum wurden keine passenden Wiki-Änderungen gefunden.", color=OUTLOOK_MUTED)}
|
||
</td>
|
||
</tr>"""
|
||
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'<p style="margin:0 0 10px;">'
|
||
f'{_outlook_font("Filter: ", color=OUTLOOK_HEADING, bold=True)}'
|
||
f'{_outlook_font(f"Kategorie {category.strip()}", color=OUTLOOK_MUTED, size="1")}'
|
||
f"</p>"
|
||
)
|
||
|
||
summary_row = f"""<tr>
|
||
<td {_outlook_td_attrs(extra_style=f"padding:16px 0 0;border-top:1px solid {OUTLOOK_BORDER};")}>
|
||
{_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")}
|
||
</td>
|
||
</tr>"""
|
||
|
||
editor_tip_row = _outlook_editor_tip(editor_tip)
|
||
|
||
return f"""<!DOCTYPE html>
|
||
<html lang="de" xmlns="http://www.w3.org/1999/xhtml" xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:w="urn:schemas-microsoft-com:office:word">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="ProgId" content="Word.Document">
|
||
<meta name="Generator" content="Microsoft Word 15">
|
||
<meta name="color-scheme" content="light dark">
|
||
<meta name="supported-color-schemes" content="light dark">
|
||
<title>{subject}</title>
|
||
<!--[if mso]>
|
||
<style type="text/css">
|
||
body, table, td, p, font, span, strong {{ font-family: Arial, sans-serif !important; }}
|
||
a, span.MsoHyperlink, span.MsoHyperlinkFollowed {{
|
||
color: {OUTLOOK_LINK} !important;
|
||
text-decoration: none !important;
|
||
}}
|
||
</style>
|
||
<![endif]-->
|
||
<style type="text/css">
|
||
:root {{ color-scheme: light dark; supported-color-schemes: light dark; }}
|
||
body {{ background-color: {OUTLOOK_WHITE} !important; color: {OUTLOOK_TEXT} !important; }}
|
||
a, a:link, a:visited, a:hover, a:active {{
|
||
color: {OUTLOOK_LINK} !important;
|
||
text-decoration: none !important;
|
||
}}
|
||
</style>
|
||
</head>
|
||
<body bgcolor="{OUTLOOK_WHITE}" style="margin:0;padding:16px;background-color:{OUTLOOK_WHITE} !important;color:{OUTLOOK_TEXT} !important;" data-ogsb="{OUTLOOK_WHITE}">
|
||
<table width="640" cellpadding="0" cellspacing="0" border="0" align="center" bgcolor="{OUTLOOK_WHITE}" style="width:640px;background-color:{OUTLOOK_WHITE} !important;" data-ogsb="{OUTLOOK_WHITE}">
|
||
<tr>
|
||
<td {_outlook_td_attrs(bg=OUTLOOK_ORANGE, extra_style="height:5px;font-size:0;line-height:0;")}> </td>
|
||
</tr>
|
||
<tr>
|
||
<td {_outlook_td_attrs(bg=OUTLOOK_GRAY_DARK, extra_style="padding:16px 20px;text-align:center;")} align="center">
|
||
<img src="{TK_LOGO_URL}" width="180" alt="Thomas-Krenn Logo" style="display:block;margin:0 auto;border:0;height:auto;">
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td {_outlook_td_attrs(bg=OUTLOOK_GRAY_DARK, extra_style="padding:18px 20px;")}>
|
||
{_outlook_font("Thomas-Krenn Wiki Update", color=OUTLOOK_ORANGE, size="1", bold=True)}<br>
|
||
{_outlook_font("Die neuesten Wiki-Artikel", color=OUTLOOK_WHITE, size="5", bold=True)}<br>
|
||
{_outlook_font(f"{period_label} | {summary_text}", color="#DDDDDD", size="2")}
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td {_outlook_td_attrs(extra_style="padding:20px;")}>
|
||
<p style="margin:0 0 8px;">{_outlook_font("Servus,")}</p>
|
||
<p style="margin:0 0 16px;">{_outlook_font(f"hier ist deine Übersicht mit den neuesten Beiträgen aus dem Thomas-Krenn-Wiki vom {period_start} bis {period_end}.")}</p>
|
||
{category_block}
|
||
{highlights_rows}
|
||
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{OUTLOOK_WHITE}" style="background-color:{OUTLOOK_WHITE} !important;" data-ogsb="{OUTLOOK_WHITE}">
|
||
{body_rows}
|
||
{summary_row}
|
||
{editor_tip_row}
|
||
<tr>
|
||
<td {_outlook_td_attrs(extra_style="padding:20px 0 0;")}>
|
||
{_outlook_cta(WIKI_HOME_URL, "Zum Thomas-Krenn-Wiki →")}
|
||
</td>
|
||
</tr>
|
||
<tr>
|
||
<td {_outlook_td_attrs(extra_style=f"padding:16px 0 0;border-top:1px solid {OUTLOOK_BORDER};")}>
|
||
{_outlook_font(f"Automatisch erstellter interner Newsletter · Nur für den Gebrauch bei {ORG_NAME}", color=OUTLOOK_MUTED, size="1")}
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</body>
|
||
</html>"""
|
||
|
||
|
||
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'<tr><td {_outlook_td_attrs(bg=OUTLOOK_WHITE, extra_style="padding:2px 0;")}>'
|
||
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'</td></tr>'
|
||
)
|
||
return (
|
||
f'<table width="100%" cellpadding="0" cellspacing="0" border="0" '
|
||
f'bgcolor="{OUTLOOK_WHITE}" style="background-color:{OUTLOOK_WHITE} !important;margin:0 0 8px;" data-ogsb="{OUTLOOK_WHITE}">'
|
||
f'<tr><td {_outlook_td_attrs(extra_style="padding:0 0 6px;")}>'
|
||
f'{_outlook_font("Top Highlights", color=OUTLOOK_ORANGE, size="2", bold=True)}</td></tr>'
|
||
f'{rows}</table>'
|
||
)
|
||
|
||
|
||
def _outlook_editor_tip(editor_tip: str) -> str:
|
||
text = (editor_tip or "").strip()
|
||
if not text:
|
||
return ""
|
||
body = "<br>".join(_outlook_font(line) for line in text.splitlines())
|
||
return (
|
||
f'<tr><td {_outlook_td_attrs(bg=COLOR_TK_GRAY_LIGHT, extra_style=f"padding:14px 16px;margin-top:16px;border:1px solid {OUTLOOK_BORDER};")}>'
|
||
f'{_outlook_font("Redaktionsnotiz", color=OUTLOOK_HEADING, size="1", bold=True)}<br>'
|
||
f'{body}</td></tr>'
|
||
)
|
||
|
||
|
||
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"""<tr>
|
||
<td {_outlook_td_attrs(extra_style=f"padding:18px 0 8px;border-bottom:2px solid {accent};")}>
|
||
{_outlook_font(f"{heading} ({len(items)})", color=accent, size="3", bold=True)}
|
||
</td>
|
||
</tr>"""
|
||
if not items:
|
||
return header + f"""<tr>
|
||
<td {_outlook_td_attrs(extra_style="padding:8px 0;")}>
|
||
{_outlook_font(empty_text, color=OUTLOOK_MUTED)}
|
||
</td>
|
||
</tr>"""
|
||
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'<br>{_outlook_font(meta, color=OUTLOOK_MUTED, size="1")}' if meta else ""
|
||
return f"""<tr>
|
||
<td {_outlook_td_attrs(extra_style="padding:8px 0;")}>
|
||
<table width="100%" cellpadding="0" cellspacing="0" border="0" bgcolor="{OUTLOOK_WHITE}" style="background-color:{OUTLOOK_WHITE} !important;border:1px solid {OUTLOOK_BORDER};" data-ogsb="{OUTLOOK_WHITE}">
|
||
<tr>
|
||
<td {_outlook_td_attrs(bg=accent, width="6", extra_style="font-size:0;line-height:0;width:6px;")}> </td>
|
||
<td {_outlook_td_attrs(extra_style="padding:12px 16px;")}>
|
||
{_outlook_font(f"{idx}. {eyebrow}", color=accent, size="1", bold=True)}<br>
|
||
{_outlook_link(title, url, size="3")}{meta_html}<br>
|
||
<span style="display:inline-block;margin-top:8px;">{_outlook_cta(url, "Zum Beitrag →")}</span>
|
||
</td>
|
||
</tr>
|
||
</table>
|
||
</td>
|
||
</tr>"""
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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
|