diff --git a/README.md b/README.md
index 814a915..088bd41 100644
--- a/README.md
+++ b/README.md
@@ -263,27 +263,21 @@ Dockerfile
docker-compose.yml
docker-compose.local.yml
web/
+worker/
+docker/
```
-Die produktive `docker-compose.yml` wird lokal **nicht** verwendet. Dadurch werden insbesondere folgende Produktionsbestandteile nicht gestartet:
-
-* `scheduler`
-* `dokploy-network`
-* Strato-SSH
-* produktive Scheduler-Secrets
-
-Die lokale Compose-Datei startet ausschließlich:
+Die `docker-compose.local.yml` bildet die Anwendungsdienste der produktiven `docker-compose.yml` lokal ab und ergänzt die Datenbank. Lokal werden damit gestartet:
* `web`
+* `scheduler`
* `db` (PostgreSQL 18 mit pgvector)
-Sie verwendet denselben `Dockerfile` wie Produktion.
+Das externe `dokploy-network` wird lokal nicht benötigt. Alle drei Container verwenden stattdessen das lokale Compose-Netzwerk `app`. `web` und `scheduler` greifen über `DB_HOST=db` direkt auf den lokalen PostgreSQL-Container zu.
-Der Web-Container ist lokal unter `127.0.0.1:8080` erreichbar. PostgreSQL wird für pgAdmin unter `127.0.0.1:5432` freigegeben.
+Der Web-Container ist unter `127.0.0.1:8080` erreichbar. PostgreSQL wird für pgAdmin unter `127.0.0.1:5432` freigegeben.
-Für die Datenbank wird das Image `pgvector/pgvector:pg18` verwendet, weil der Produktiv-Dump die PostgreSQL-Erweiterung `vector` benötigt.
-
-PostgreSQL 18 verwendet für persistente Daten den Mount-Punkt:
+Für die Datenbank wird das Image `pgvector/pgvector:pg18` verwendet, weil der Produktiv-Dump die PostgreSQL-Erweiterung `vector` benötigt. PostgreSQL 18 verwendet für persistente Daten den Mount-Punkt:
```text
/var/lib/postgresql
@@ -301,17 +295,18 @@ Durch den Bind-Mount
./web → /var/www/html
```
-werden Änderungen an PHP, CSS und JavaScript ohne erneuten Image-Build sichtbar. Laufzeitdaten wie Profilbilder und Aushänge liegen weiterhin in lokalen Docker-Volumes.
+werden Änderungen an PHP, CSS und JavaScript ohne erneuten Image-Build sichtbar. Laufzeitdaten wie Profilbilder, Aushänge, Scheduler-Zustand und generierte Kalenderdateien liegen weiterhin in lokalen Docker-Volumes.
---
## 4. Environment-Variablen anlegen
-Im Projektroot eine `.env` anlegen:
+Im Projektroot eine `.env` anlegen. Werte, die lokal nicht benötigt oder noch nicht bekannt sind, bleiben leer.
```dotenv
# PostgreSQL
-POSTGRES_VERSION=18
+DB_HOST=
+DB_PORT=5432
DB_NAME=intranet
DB_USER=intranet
DB_PASSWORD=local-dev-password
@@ -319,24 +314,39 @@ DB_PASSWORD=local-dev-password
# Intranet
INTRANET_FQDN=localhost:8080
-# Lokale Dummy-Werte für externe Dienste
-SMTP_HOST=127.0.0.1
-SMTP_PORT=1025
-SMTP_USER=local
-SMTP_PASSWORD=local
-ADMIN_EMAIL=local@example.invalid
+# SMTP / Benachrichtigungen
+SMTP_HOST=
+SMTP_PORT=
+SMTP_USER=
+SMTP_PASSWORD=
+ADMIN_EMAIL=
+SMS_GATEWAY_URL=
-SMS_GATEWAY_URL=http://127.0.0.1:9
-HA_BASE_URL=http://127.0.0.1:9
-HA_TOKEN=local
-OPENWEATHER_API_KEY=local
+# Dashboard / externe APIs
+HA_BASE_URL=
+HA_TOKEN=
+OPENWEATHER_API_KEY=
+# CKalender / Scheduler
CKAL_USERNAME=
CKAL_PASSWORD=
CKAL_CALENDAR_URL=
+CALENDAR_INTERVAL_SECONDS=60
+
+# Strato-Synchronisation
+STRATO_SYNC_ENABLED=false
+STRATO_SSH_HOST=
+STRATO_SSH_PORT=
+STRATO_SSH_USER=
+STRATO_REMOTE_PATH=
+STRATO_SSH_PRIVATE_KEY=
+STRATO_KNOWN_HOSTS=
+
+# Brandschutz
+BRANDSCHUTZ_MAIL_ENABLED=false
```
-Die `.env` darf keine produktiven Secrets enthalten und sollte nicht ins Repository committed werden.
+Funktionen, die einen externen Dienst benötigen, funktionieren erst, wenn die zugehörigen Werte gesetzt sind.
> Lokal sollte möglichst dieselbe PostgreSQL-Major-Version wie in Produktion verwendet werden. Wir verwenden **PostgreSQL 18**.
@@ -379,12 +389,6 @@ docker compose -f docker-compose.local.yml ps
Der Datenbank-Container sollte nach einigen Sekunden den Status `healthy` anzeigen.
-Optional kann geprüft werden, ob pgvector verfügbar ist:
-
-```bash
-docker exec -it intranet-local-db-1 psql -U intranet -d intranet -c "CREATE EXTENSION IF NOT EXISTS vector;"
-```
-
---
## 7. Dump mit pgAdmin importieren
@@ -399,8 +403,6 @@ Falls pgAdmin 4 noch nicht installiert ist, kann es lokal als Desktop-Anwendung
Die Installationspakete und Anleitungen sind unter https://www.pgadmin.org/download/ verfügbar.
-Unter Windows mit WSL 2 kann pgAdmin direkt unter Windows installiert werden. Die PostgreSQL-Datenbank aus Docker ist anschließend über `localhost:5432` erreichbar.
-
In pgAdmin einen neuen Server registrieren:
```text
@@ -427,14 +429,8 @@ Danach:
7. **Clean before restore: No** beim ersten Import.
8. **Restore** starten.
-Soll die lokale Datenbank später vollständig durch einen neuen Dump ersetzt werden, kann das lokale Setup zurückgesetzt werden:
-
-```bash
-docker compose -f docker-compose.local.yml down -v
-docker compose -f docker-compose.local.yml up -d db
-```
-
-Danach den Dump erneut importieren.
+> **Hinweis:** Soll die lokale Datenbank später vollständig durch einen neuen Dump ersetzt werden, kann das lokale Setup zurückgesetzt werden: `docker compose -f docker-compose.local.yml down -v`,
+`docker compose -f docker-compose.local.yml up -d db`. Danach den Dump erneut importieren.
> **Achtung:** `down -v` löscht nicht nur PostgreSQL, sondern auch alle übrigen lokalen Intranet-Volumes, z. B. Profilbilder, Aushänge und andere Laufzeitdaten. Nur verwenden, wenn diese Daten bewusst verworfen werden sollen.
@@ -504,9 +500,7 @@ docker compose -f docker-compose.local.yml logs -f db
### Änderungen an PHP, CSS und JavaScript
-Änderungen unter `web/` werden durch den Bind-Mount direkt in den laufenden Container übernommen. Ein Rebuild ist normalerweise nicht notwendig.
-
-Nach dem Speichern reicht in der Regel ein Reload im Browser.
+Änderungen unter `web/` werden durch den Bind-Mount direkt in den laufenden Container übernommen. Ein Rebuild ist normalerweise nicht notwendig. Nach dem Speichern reicht in der Regel ein Reload im Browser.
### Änderungen am Dockerfile
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
index b8959a1..0185346 100644
--- a/docker-compose.local.yml
+++ b/docker-compose.local.yml
@@ -1,103 +1,156 @@
-# LOCAL DEVELOPMENT ONLY
-
-name: intranet-local
-
-services:
- web:
- build:
- context: .
- dockerfile: Dockerfile
- ports:
- - "127.0.0.1:8080:80"
- env_file:
- - .env
- environment:
- TZ: Europe/Berlin
- DB_HOST: db
- DB_PORT: "5432"
- CKAL_XLSX_DIR: /data/ckal-xlsx
- STRATO_SYNC_ENABLED: "false"
- BRANDSCHUTZ_MAIL_ENABLED: "false"
- depends_on:
- db:
- condition: service_healthy
- command: >
- sh -c "printf '%s\n' 'session.cookie_secure = 0'
- > /usr/local/etc/php/conf.d/zz-local.ini
- && exec apache2-foreground"
- volumes:
- - ./web:/var/www/html
- - profile_pictures:/var/www/html/intranet_repo/general_files/profile_pictures
- - content_news:/var/www/html/intranet_repo/projects/contentpanel/insertion/news
- - developer_work:/var/www/html/intranet_repo/projects/developerpanel/work
- - seelsorge_data:/var/www/html/intranet_repo/projects/seelsorge/data
- - dashboard_month:/var/www/html/intranet_repo/projects/dashboard/month
- - ckal_xlsx:/data/ckal-xlsx
- - calendar_pdf:/var/www/html/pdf
-
- db:
- image: pgvector/pgvector:pg18
- ports:
- - "127.0.0.1:5432:5432"
- environment:
- TZ: Europe/Berlin
- POSTGRES_DB: ${DB_NAME:?set DB_NAME in .env}
- POSTGRES_USER: ${DB_USER:?set DB_USER in .env}
- POSTGRES_PASSWORD: ${DB_PASSWORD:?set DB_PASSWORD in .env}
- volumes:
- - postgres_data:/var/lib/postgresql
- healthcheck:
- test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
- interval: 5s
- timeout: 5s
- retries: 12
-
- scheduler:
- build:
- context: .
- dockerfile: Dockerfile
- env_file:
- - .env
- environment:
- TZ: Europe/Berlin
- HOME: /tmp/intranet-worker
- XDG_CACHE_HOME: /tmp/intranet-worker-cache
- PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
-
- DB_HOST: db
- DB_PORT: "5432"
-
- CKAL_XLSX_DIR: /data/ckal-xlsx
- PDF_OUTPUT: /data/pdf/terminuebersicht.pdf
- DASHBOARD_MONTH_DIR: /var/www/html/intranet_repo/projects/dashboard/month
-
- CALENDAR_INTERVAL_SECONDS: "60"
-
- STRATO_SYNC_ENABLED: "false"
- BRANDSCHUTZ_MAIL_ENABLED: "false"
-
- command: ["/opt/intranet/docker/scheduler.py"]
- init: true
- user: "33:33"
- shm_size: "512m"
-
- depends_on:
- db:
- condition: service_healthy
-
- volumes:
- - dashboard_month:/var/www/html/intranet_repo/projects/dashboard/month
- - ckal_xlsx:/data/ckal-xlsx
- - calendar_pdf:/data/pdf
- - scheduler_state:/data/state
-
-volumes:
- postgres_data:
- profile_pictures:
- content_news:
- developer_work:
- seelsorge_data:
- dashboard_month:
- ckal_xlsx:
- calendar_pdf:
- scheduler_state:
+# LOCAL DEVELOPMENT ONLY
+
+name: intranet-local
+
+services:
+ web:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ ports:
+ - "127.0.0.1:8080:80"
+ environment:
+ TZ: Europe/Berlin
+ INTRANET_FQDN: "${INTRANET_FQDN:-localhost:8080}"
+ DB_HOST: db
+ DB_PORT: "5432"
+ DB_NAME: "${DB_NAME:-intranet}"
+ DB_USER: "${DB_USER:-intranet}"
+ DB_PASSWORD: "${DB_PASSWORD:-local-dev-password}"
+ SMTP_HOST: "${SMTP_HOST:-}"
+ SMTP_PORT: "${SMTP_PORT:-}"
+ SMTP_USER: "${SMTP_USER:-}"
+ SMTP_PASSWORD: "${SMTP_PASSWORD:-}"
+ ADMIN_EMAIL: "${ADMIN_EMAIL:-}"
+ SMS_GATEWAY_URL: "${SMS_GATEWAY_URL:-}"
+ HA_BASE_URL: "${HA_BASE_URL:-}"
+ HA_TOKEN: "${HA_TOKEN:-}"
+ OPENWEATHER_API_KEY: "${OPENWEATHER_API_KEY:-}"
+ CKAL_XLSX_DIR: /data/ckal-xlsx
+ STRATO_SYNC_ENABLED: "${STRATO_SYNC_ENABLED:-false}"
+ BRANDSCHUTZ_MAIL_ENABLED: "${BRANDSCHUTZ_MAIL_ENABLED:-false}"
+ depends_on:
+ db:
+ condition: service_healthy
+ command: >
+ sh -c "printf '%s\n' 'session.cookie_secure = 0'
+ > /usr/local/etc/php/conf.d/zz-local.ini
+ && exec apache2-foreground"
+ volumes:
+ - ./web:/var/www/html
+ - profile_pictures:/var/www/html/intranet_repo/general_files/profile_pictures
+ - content_news:/var/www/html/intranet_repo/projects/contentpanel/insertion/news
+ - developer_work:/var/www/html/intranet_repo/projects/developerpanel/work
+ - seelsorge_data:/var/www/html/intranet_repo/projects/seelsorge/data
+ - dashboard_month:/var/www/html/intranet_repo/projects/dashboard/month
+ - ckal_xlsx:/data/ckal-xlsx
+ - calendar_pdf:/var/www/html/pdf:ro
+ networks:
+ - app
+
+ scheduler:
+ build:
+ context: .
+ dockerfile: Dockerfile
+ restart: unless-stopped
+ init: true
+ user: "33:33"
+ command:
+ - /bin/sh
+ - -ec
+ - |
+ if [ -z "$${CKAL_USERNAME}" ] || [ -z "$${CKAL_PASSWORD}" ] || [ -z "$${CKAL_CALENDAR_URL}" ]; then
+ echo "Scheduler wartet: CKAL_USERNAME, CKAL_PASSWORD und CKAL_CALENDAR_URL sind nicht gesetzt."
+ exec sleep infinity
+ fi
+ exec /opt/intranet/docker/scheduler.py
+ shm_size: "512m"
+ healthcheck:
+ disable: true
+ environment:
+ TZ: Europe/Berlin
+ HOME: /tmp/intranet-worker
+ XDG_CACHE_HOME: /tmp/intranet-worker-cache
+ PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
+ INTRANET_FQDN: "${INTRANET_FQDN:-localhost:8080}"
+ DB_HOST: db
+ DB_PORT: "5432"
+ DB_NAME: "${DB_NAME:-intranet}"
+ DB_USER: "${DB_USER:-intranet}"
+ DB_PASSWORD: "${DB_PASSWORD:-local-dev-password}"
+ SMTP_HOST: "${SMTP_HOST:-}"
+ SMTP_PORT: "${SMTP_PORT:-}"
+ SMTP_USER: "${SMTP_USER:-}"
+ SMTP_PASSWORD: "${SMTP_PASSWORD:-}"
+ ADMIN_EMAIL: "${ADMIN_EMAIL:-}"
+ CKAL_USERNAME: "${CKAL_USERNAME:-}"
+ CKAL_PASSWORD: "${CKAL_PASSWORD:-}"
+ CKAL_CALENDAR_URL: "${CKAL_CALENDAR_URL:-}"
+ CKAL_XLSX_DIR: /data/ckal-xlsx
+ PDF_OUTPUT: /data/pdf/terminuebersicht.pdf
+ DASHBOARD_MONTH_DIR: /var/www/html/intranet_repo/projects/dashboard/month
+ CALENDAR_INTERVAL_SECONDS: "${CALENDAR_INTERVAL_SECONDS:-60}"
+ STRATO_SSH_HOST: "${STRATO_SSH_HOST:-}"
+ STRATO_SSH_PORT: "${STRATO_SSH_PORT:-}"
+ STRATO_SSH_USER: "${STRATO_SSH_USER:-}"
+ STRATO_REMOTE_PATH: "${STRATO_REMOTE_PATH:-}"
+ STRATO_SSH_KEY_FILE: /run/secrets/strato_ed25519
+ STRATO_KNOWN_HOSTS_FILE: /run/secrets/known_hosts
+ STRATO_SYNC_ENABLED: "${STRATO_SYNC_ENABLED:-false}"
+ BRANDSCHUTZ_MAIL_ENABLED: "${BRANDSCHUTZ_MAIL_ENABLED:-false}"
+ depends_on:
+ db:
+ condition: service_healthy
+ volumes:
+ - dashboard_month:/var/www/html/intranet_repo/projects/dashboard/month
+ - ckal_xlsx:/data/ckal-xlsx
+ - calendar_pdf:/data/pdf
+ - scheduler_state:/data/state
+ secrets:
+ - source: strato_ed25519
+ target: strato_ed25519
+ - source: strato_known_hosts
+ target: known_hosts
+ networks:
+ - app
+
+ db:
+ image: pgvector/pgvector:pg18
+ restart: unless-stopped
+ ports:
+ - "127.0.0.1:5432:5432"
+ environment:
+ TZ: Europe/Berlin
+ POSTGRES_DB: "${DB_NAME:-intranet}"
+ POSTGRES_USER: "${DB_USER:-intranet}"
+ POSTGRES_PASSWORD: "${DB_PASSWORD:-local-dev-password}"
+ volumes:
+ - postgres_data:/var/lib/postgresql
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U \"$${POSTGRES_USER}\" -d \"$${POSTGRES_DB}\""]
+ interval: 5s
+ timeout: 5s
+ retries: 12
+ networks:
+ - app
+
+volumes:
+ postgres_data:
+ profile_pictures:
+ content_news:
+ developer_work:
+ seelsorge_data:
+ dashboard_month:
+ ckal_xlsx:
+ calendar_pdf:
+ scheduler_state:
+
+networks:
+ app:
+
+secrets:
+ strato_ed25519:
+ environment: STRATO_SSH_PRIVATE_KEY
+ strato_known_hosts:
+ environment: STRATO_KNOWN_HOSTS
diff --git a/web/intranet_repo/projects/_central/layouts/developerpanel.css b/web/intranet_repo/projects/_central/layouts/developerpanel.css
new file mode 100644
index 0000000..f5ab9b4
--- /dev/null
+++ b/web/intranet_repo/projects/_central/layouts/developerpanel.css
@@ -0,0 +1,132 @@
+.developer-feed {
+ grid-column: 1 / -1;
+ padding: 0;
+ border-top: 3px solid var(--theme-color);
+}
+
+.developer-feed_header {
+ display: flex;
+ align-items: center;
+ gap: var(--space-sm);
+ padding: var(--space-md) var(--space-lg);
+ border-bottom: 1px solid var(--gray-200);
+}
+
+.developer-feed_header svg,
+.developer-feed_icon svg {
+ width: 20px;
+ height: 20px;
+ fill: currentColor;
+}
+
+.developer-feed_header svg {
+ color: var(--theme-color);
+}
+
+.developer-feed h2 {
+ margin: 0;
+ font-size: var(--font-size-md);
+ font-weight: 600;
+}
+
+.developer-feed_group + .developer-feed_group {
+ border-top: 1px solid var(--gray-200);
+}
+
+.developer-feed_group h3 {
+ margin: 0;
+ padding: var(--space-sm) var(--space-lg);
+ color: var(--gray-700);
+ background: var(--gray-50);
+ font-size: var(--font-size-sm);
+ font-weight: 600;
+}
+
+.developer-feed_group ul {
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.developer-feed_group li + li {
+ border-top: 1px solid var(--gray-200);
+}
+
+.developer-feed a {
+ display: grid;
+ grid-template-columns: 42px minmax(0, 1fr);
+ gap: var(--space-md);
+ align-items: center;
+ padding: var(--space-md) var(--space-lg);
+ color: var(--gray-900);
+ text-decoration: none;
+ border-left: 3px solid transparent;
+ transition: background var(--transition-fast), border-color var(--transition-fast);
+}
+
+.developer-feed a:hover,
+.developer-feed a:focus-visible {
+ background: var(--theme-light);
+ border-left-color: var(--theme-color);
+}
+
+.developer-feed_icon {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 42px;
+ height: 42px;
+ color: var(--theme-color);
+ background: var(--theme-light);
+}
+
+.developer-feed_message {
+ min-width: 0;
+}
+
+.developer-feed_message strong,
+.developer-feed_description {
+ display: block;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.developer-feed_message strong {
+ font-weight: 500;
+}
+
+.developer-feed_description,
+.developer-feed_meta {
+ margin-top: 2px;
+ color: var(--gray-600);
+ font-size: var(--font-size-sm);
+}
+
+.developer-feed_meta {
+ display: flex;
+ gap: var(--space-sm);
+}
+
+.developer-feed_empty {
+ margin: 0;
+ padding: var(--space-lg);
+ color: var(--gray-600);
+}
+
+.developer-feed_more {
+ width: 100%;
+ padding: var(--space-md);
+ border: 0;
+ border-top: 1px solid var(--gray-200);
+ background: #fff;
+ color: var(--theme-color);
+ font: inherit;
+ font-weight: 500;
+ cursor: pointer;
+}
+
+.developer-feed_more:hover,
+.developer-feed_more:focus-visible {
+ background: var(--theme-light);
+}
diff --git a/web/intranet_repo/projects/developerpanel/developer.php b/web/intranet_repo/projects/developerpanel/developer.php
index 41f04cb..646b61c 100644
--- a/web/intranet_repo/projects/developerpanel/developer.php
+++ b/web/intranet_repo/projects/developerpanel/developer.php
@@ -4,16 +4,28 @@
// Initialisierung
require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/general_files/initialise.php';
+ require_once __DIR__.'/util/developer_helpers.php';
+
// Zugriff nur für Entwickler
if (!isset($groups["Entwickler"]) || $groups["Entwickler"] != 1) {
header("Location: {$g_httpHomePath}");
exit();
}
+
+ $feedError = '';
+ try {
+ $feedItems = developer_feed_items();
+ } catch (RuntimeException) {
+ $feedItems = [];
+ $feedError = 'Der Git-RSS-Feed ist momentan nicht erreichbar.';
+ }
+ $feedGroups = developer_feed_groups($feedItems);
?>
+
@@ -26,26 +38,81 @@
+
+
+
+
+
+ = $feedError ?: 'Noch keine Git-Aktivität vorhanden.' ?>
+
+
+
+ $items): ?>
+
= DEVELOPER_FEED_LIMIT ? ' hidden' : '' ?>>
+ = htmlspecialchars($group, ENT_QUOTES, 'UTF-8') ?>
+
+
+
+
+ DEVELOPER_FEED_LIMIT): ?>
+
+
+
+
+
+ DEVELOPER_FEED_LIMIT): ?>
+
+
diff --git a/web/intranet_repo/projects/developerpanel/util/developer_helpers.php b/web/intranet_repo/projects/developerpanel/util/developer_helpers.php
new file mode 100644
index 0000000..dd930e4
--- /dev/null
+++ b/web/intranet_repo/projects/developerpanel/util/developer_helpers.php
@@ -0,0 +1,142 @@
+\s*', '> <', html_entity_decode($value, ENT_QUOTES | ENT_HTML5, 'UTF-8')) ?? '')
+ ) ?? '');
+}
+
+function developer_feed_items(string $url = DEVELOPER_FEED_URL): array
+{
+ $handle = curl_init($url);
+ if ($handle === false) {
+ throw new RuntimeException('RSS-Feed konnte nicht initialisiert werden.');
+ }
+
+ curl_setopt_array($handle, [
+ CURLOPT_RETURNTRANSFER => true,
+ CURLOPT_HTTPHEADER => ['Accept: application/rss+xml, application/xml;q=0.9'],
+ CURLOPT_USERAGENT => 'NAK-Reinickendorf-Intranet/1.0',
+ CURLOPT_CONNECTTIMEOUT => 2,
+ CURLOPT_TIMEOUT => 4,
+ CURLOPT_FOLLOWLOCATION => true,
+ CURLOPT_MAXREDIRS => 3,
+ CURLOPT_PROTOCOLS => CURLPROTO_HTTPS,
+ CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTPS,
+ ]);
+
+ try {
+ $response = curl_exec($handle);
+ $status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
+ $error = curl_error($handle);
+ } finally {
+ curl_close($handle);
+ }
+
+ if ($response === false || $status < 200 || $status >= 300) {
+ throw new RuntimeException($error !== '' ? $error : 'HTTP ' . $status);
+ }
+
+ return developer_parse_feed($response);
+}
+
+function developer_parse_feed(string $xml): array
+{
+ $previous = libxml_use_internal_errors(true);
+ try {
+ $feed = simplexml_load_string($xml, SimpleXMLElement::class, LIBXML_NONET | LIBXML_NOCDATA);
+ } finally {
+ libxml_clear_errors();
+ libxml_use_internal_errors($previous);
+ }
+
+ if ($feed === false || !isset($feed->channel->item)) {
+ throw new RuntimeException('Ungültiger RSS-Feed.');
+ }
+
+ $items = [];
+ foreach ($feed->channel->item as $item) {
+ $title = developer_feed_plain_text((string) $item->title);
+ if ($title === '') {
+ continue;
+ }
+
+ $description = developer_feed_plain_text((string) $item->description);
+ $author = trim((string) $item->children('http://purl.org/dc/elements/1.1/')->creator ?: (string) $item->author);
+ if (preg_match('/\(([^)]+)\)\s*$/u', $author, $match) === 1) {
+ $author = trim($match[1]);
+ } else {
+ $author = trim(preg_replace('/<[^>]+>|\S+@\S+/u', '', $author) ?? '');
+ }
+ $link = trim((string) $item->link);
+
+ $items[] = [
+ 'title' => $title,
+ 'description' => $description !== $title ? $description : '',
+ 'url' => filter_var($link, FILTER_VALIDATE_URL) && str_starts_with($link, DEVELOPER_REPO_URL) ? $link : DEVELOPER_REPO_URL,
+ 'author' => $author !== '' ? $author : 'Git',
+ 'timestamp' => strtotime((string) $item->pubDate) ?: 0,
+ ];
+ }
+
+ usort($items, static fn(array $a, array $b): int => $b['timestamp'] <=> $a['timestamp']);
+ return $items;
+}
+
+function developer_feed_groups(array $items, ?DateTimeImmutable $now = null): array
+{
+ $now ??= new DateTimeImmutable();
+ $today = $now->setTime(0, 0);
+ $week = $today->modify('monday this week');
+ $month = $today->modify('first day of this month');
+ $year = $today->setDate((int) $today->format('Y'), 1, 1);
+ $starts = [
+ 'Heute' => $today,
+ 'Gestern' => $today->modify('-1 day'),
+ 'Diese Woche' => $week,
+ 'Letzte Woche' => $week->modify('-1 week'),
+ 'Diesen Monat' => $month,
+ 'Letzten Monat' => $month->modify('-1 month'),
+ 'Dieses Jahr' => $year,
+ 'Letztes Jahr' => $year->modify('-1 year'),
+ ];
+
+ $groups = [];
+ foreach ($items as $item) {
+ $date = (new DateTimeImmutable('@' . (int) $item['timestamp']))->setTimezone($now->getTimezone());
+ $group = 'Älter';
+ foreach ($starts as $label => $start) {
+ if ($date >= $start) {
+ $group = $label;
+ break;
+ }
+ }
+ $groups[$group][] = $item;
+ }
+
+ return $groups;
+}
+
+function developer_feed_icon(string $title): string
+{
+ $title = strtolower($title);
+
+ return match (true) {
+ str_contains($title, 'merge pull'), str_starts_with($title, 'merge ') => 'M7 3a3 3 0 1 0-2 2.83v12.34a3 3 0 1 0 2 0V9h4a4 4 0 0 1 4 4v1.17a3 3 0 1 0 2 0V13a6 6 0 0 0-6-6H7V5.83A3 3 0 0 0 7 3Z',
+ preg_match('/\b(fix|fixed|bugfix|hotfix|repair)\b/u', $title) === 1 => 'M14.7 6.3a5 5 0 0 0-6.47 6.47l-5.94 5.94a1 1 0 0 0 0 1.42l1.58 1.58a1 1 0 0 0 1.42 0l5.94-5.94A5 5 0 0 0 17.7 9.3l-3 3-3-3 3-3Z',
+ preg_match('/\b(remove|removed|delete|deleted|revert)\b/u', $title) === 1 => 'M9 3h6l1 2h4v2H4V5h4l1-2Zm-3 6h12l-1 12H7L6 9Zm3 2v8h2v-8H9Zm4 0v8h2v-8h-2Z',
+ preg_match('/\b(refactor|cleanup|clean up|simplif|optimi[sz])\w*/u', $title) === 1 => 'M7 3a3 3 0 1 0-2 2.83v12.34a3 3 0 1 0 2 0V14h3a5 5 0 0 0 5-5V7.83a3 3 0 1 0-2 0V9a3 3 0 0 1-3 3H7V5.83A3 3 0 0 0 7 3Z',
+ preg_match('/\b(doc|docs|readme|wiki|tutorial)\w*/u', $title) === 1 => 'M4 3h7a2 2 0 0 1 2 2v15a3 3 0 0 0-3-3H4V3Zm16 0h-5v14h5V3Zm0 16h-5a1 1 0 0 0-1 1h6v-1Z',
+ preg_match('/\b(add|added|new|create|created|introduce|introduced|implement|implemented)\b/u', $title) === 1 => 'M11 5h2v6h6v2h-6v6h-2v-6H5v-2h6V5Z',
+ default => 'M12 3a4 4 0 0 0-3.87 3H3v2h5.13a4 4 0 0 0 7.74 0H21V6h-5.13A4 4 0 0 0 12 3Zm0 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4Zm0 10a4 4 0 0 0-3.87 3H3v2h5.13a4 4 0 0 0 7.74 0H21v-2h-5.13A4 4 0 0 0 12 15Zm0 2a2 2 0 1 1 0 4 2 2 0 0 1 0-4Z',
+ };
+}
diff --git a/worker/pdf_python/create_pdf.py b/worker/pdf_python/create_pdf.py
index feac006..ab227ae 100644
--- a/worker/pdf_python/create_pdf.py
+++ b/worker/pdf_python/create_pdf.py
@@ -1,351 +1,264 @@
-#! /usr/bin/env python3
-from calendar import monthrange
-from fpdf import FPDF
-import datetime
-from datetime import datetime as datelul
-from bs4 import BeautifulSoup
-import requests
-import os
-
-
-def is_invalid_date(date_str):
- try:
- datelul.strptime(date_str, "%Y-%m-%d")
- return False
- except ValueError:
- return True
-
-
-def pdf_safe(text):
- if text is None:
- return ""
-
- text = str(text)
-
- replacements = {
- "„": '"',
- "“": '"',
- "‚": "'",
- "‘": "'",
- "’": "'",
- "–": "-",
- "—": "-",
- "…": "...",
- "\xa0": " ",
- }
-
- for old, new in replacements.items():
- text = text.replace(old, new)
-
- # Alles, was nicht latin-1-kompatibel ist, verwerfen
- return text.encode("latin-1", "replace").decode("latin-1")
-
-
-#### GENERAL DATE CONFIGURATION ####
-months_numeric = ["JANUAR", "FEBRUAR", "MÄRZ", "APRIL", "MAI", "JUNI", "JULI", "AUGUST", "SEPTEMBER", "OKTOBER", "NOVEMBER", "DEZEMBER"]
-months_numeric_lower = ["Januar", "Februar", "März", "April", "Mai", "Juni", "Juli", "August", "September", "Oktober", "November", "Dezember"]
-date = datetime.datetime.now()
-month_range = monthrange(date.year, date.month)
-month = months_numeric[date.month - 1]
-weekdays_list = ["Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"]
-counter = 2
-actual_day = datetime.date.today()
-first_day = actual_day.replace(day=1)
-weekday = first_day.weekday()
-
-#### URL HTML PARSING ####
-url = os.environ.get("CKAL_CALENDAR_URL")
-if not url:
- raise RuntimeError("CKAL_CALENDAR_URL ist nicht gesetzt.")
-result = requests.get(url, timeout=30)
-result.raise_for_status()
-
-doc = BeautifulSoup(result.text, "html.parser")
-table = doc.find_all("tbody")[0]
-tablerows = table.find_all("tr")
-
-#### PDF GENERATING PROGRESS ####
-pdf = FPDF("P", "mm", "A4")
-pdf.set_auto_page_break(auto=True, margin=15)
-pdf.add_page()
-
-# Header
-pdf.set_font("helvetica", "B", 16)
-pdf.cell(160, 7, pdf_safe("Terminübersicht: " + str(month) + " " + str(date.year)), 0, 1)
-
-path = os.path.join(os.path.dirname(__file__), "nak_logo.png")
-assert os.path.isfile(path)
-pdf.image(path, 188, 9.5, 11)
-
-pdf.set_font("helvetica", "", 10)
-pdf.cell(160, 5, pdf_safe("der Gemeinde Berlin-Reinickendorf"), 0, 1)
-pdf.cell(0, 2, " ", 0, 1)
-
-# Tablehead
-pdf.set_font("helvetica", "B", 10)
-pdf.cell(20, 5, "Datum", 1, 0, "C")
-pdf.cell(15, 5, "Tag", 1, 0, "C")
-pdf.cell(20, 5, "Uhrzeit", 1, 0, "C")
-pdf.cell(75, 5, "Veranstaltung", 1, 0)
-pdf.cell(60, 5, "Ort", 1, 1, "C")
-
-pdf.set_font("helvetica", "", 8)
-
-# Startposition (heutiges Datum) suchen
-iStart = None
-jStart = None
-
-for i in range(len(tablerows)):
- table_cells = tablerows[i].find_all("td")
- for j in range(len(table_cells)):
- table_dates = table_cells[j].get("data-date", "")
- date_for_table = str(table_dates).split("-")
- if len(date_for_table) == 3:
- if (
- date_for_table[0] == str(date.strftime("%Y"))
- and date_for_table[1] == str(date.strftime("%m"))
- and date_for_table[2] == str(date.strftime("%d"))
- ):
- iStart = i
- jStart = j
- break
- if jStart is not None:
- break
-
-if jStart is None:
- raise RuntimeError("Heutiges Datum wurde im Kalender nicht gefunden.")
-
-j = jStart
-
-for i in range(len(tablerows)):
- table_cells = tablerows[i].find_all("td")
- table_dates = table_cells[j].get("data-date", "")
- date_for_table = str(table_dates).split("-")
-
- if len(date_for_table) != 3:
- break
-
- if is_invalid_date(date_for_table[0] + "-" + date_for_table[1] + "-" + date_for_table[2]):
- break
-
- # Trennlinie
- pdf.set_fill_color(130)
- pdf.cell(0, 0.3, " ", 0, 1, fill=True)
-
- # Wochentag korrigieren
- if weekday == 7:
- weekday = 0
-
- # Zeilenfarbe
- if counter % 2 == 0:
- pdf.set_fill_color(255, 255, 255)
- else:
- pdf.set_fill_color(220)
-
- # Samstag/Sonntag hervorheben
- pdf.set_font("helvetica", "", 8)
- if weekday == 2 or weekday == 6:
- pdf.set_font("helvetica", "B", 8)
- if weekday == 6:
- pdf.set_fill_color(170, 210, 255)
-
- # Heutigen Tag gelb markieren
- if (
- date_for_table[0] == str(date.strftime("%Y"))
- and date_for_table[1] == str(date.strftime("%m"))
- and date_for_table[2] == str(date.strftime("%d"))
- ):
- pdf.set_fill_color(255, 255, 0)
-
- pdf.cell(20, 4, pdf_safe(date_for_table[2] + "." + date_for_table[1] + "." + date_for_table[0]), 0, 0, "C", fill=True)
-
- event_full_block = table_cells[j].find_all("div", class_="calEventBox")
- events = table_cells[j].find_all(
- style=lambda style: style
- and "margin-right:.2em" in style.replace(" ", "")
- )
-
- pdf.cell(15, 4, pdf_safe(weekdays_list[weekday]), 0, 0, "C", fill=True)
-
- if len(events) == 0:
- pdf.cell(0, 4, " ", 0, 1, fill=True)
-
- for k in range(len(events)):
- if k > 0:
- pdf.cell(35, 4, " ", 0, 0, fill=True)
-
- event_place = "--"
- pdf.cell(20, 4, " ", 0, 0, "C", fill=True)
-
- if k < len(event_full_block):
- event_time = event_full_block[k].find_all(style="font-weight:normal")
- else:
- event_time = []
-
- if len(event_time) > 0:
- try:
- elements = str(event_time[0]).split("<")
- except Exception:
- print("error1")
- else:
- if len(elements) > 1 and ">" in elements[1]:
- time_text = elements[1].split(">")[1]
- if ":" in str(time_text):
- pdf.set_x(pdf.get_x() - 20)
- pdf.cell(20, 4, pdf_safe(time_text), 0, 0, "C", fill=True)
-
- if len(elements) > 2 and ">" in elements[2]:
- place_text = elements[2].split(">")[1]
- if place_text == "":
- event_place = "--"
- else:
- if str(place_text).startswith(","):
- event_place = str(place_text)[2:]
- else:
- event_place = str(place_text)
-
- event_name = events[k].get_text(strip=True) if len(events) > 0 else ""
- pdf.cell(75, 4, pdf_safe(event_name), 0, 0, fill=True)
- pdf.cell(60, 4, pdf_safe(event_place), 0, 1, "C", fill=True)
-
- counter += 1
- weekday += 1
-
-# Fußzeile
-pdf.set_font("helvetica", "", 8)
-pdf.cell(0, 1, " ", 0, 1)
-pdf.cell(0, 0.1, " ", 1, 1)
-pdf.cell(0, 1, " ", 0, 1)
-pdf.cell(153, 3, pdf_safe("gemeindeleitung@nak-reinickendorf.de"), 0, 0)
-pdf.cell(30, 3, pdf_safe("Klixstraße 17, 13403 Berlin"), 0, 1)
-pdf.set_font("helvetica", "I", 8)
-pdf.cell(153, 3, pdf_safe("Berlin, " + str(date.strftime("%d.%m.%Y %H:%M"))), 0, 0)
-pdf.set_font("helvetica", "", 8)
-pdf.cell(30, 3, pdf_safe("nak-reinickendorf.de"), 0, 1)
-
-# Zweite Monats-Spalte / Folgemonat
-if jStart + 1 < 12:
- pdf.cell(0, 4, " ", 0, 1)
- pdf.cell(0, 2, " ", 0, 1)
-
- pdf.set_font("helvetica", "B", 16)
- pdf.cell(160, 7, pdf_safe("Terminübersicht: " + str(months_numeric[date.month] + " " + str(date.year))), 0, 1)
-
- pdf.set_font("helvetica", "", 10)
- pdf.cell(
- 160,
- 5,
- pdf_safe("Die Termine für " + str(months_numeric_lower[date.month]) + " befinden sich noch in Planung und können sich ändern."),
- 0,
- 1,
- )
- pdf.cell(0, 2, " ", 0, 1)
-
- # Tablehead
- pdf.set_font("helvetica", "B", 10)
- pdf.cell(20, 5, "Datum", 1, 0, "C")
- pdf.cell(15, 5, "Tag", 1, 0, "C")
- pdf.cell(20, 5, "Uhrzeit", 1, 0, "C")
- pdf.cell(75, 5, "Veranstaltung", 1, 0)
- pdf.cell(60, 5, "Ort", 1, 1, "C")
-
- j = jStart + 1
-
- for i in range(len(tablerows)):
- table_cells = tablerows[i].find_all("td")
- table_dates = table_cells[j].get("data-date", "")
- date_for_table = str(table_dates).split("-")
-
- if len(date_for_table) != 3:
- break
-
- if is_invalid_date(date_for_table[0] + "-" + date_for_table[1] + "-" + date_for_table[2]):
- break
-
- pdf.set_fill_color(130)
- pdf.cell(0, 0.3, " ", 0, 1, fill=True)
-
- if weekday == 7:
- weekday = 0
-
- if counter % 2 == 0:
- pdf.set_fill_color(255, 255, 255)
- else:
- pdf.set_fill_color(220)
-
- pdf.set_font("helvetica", "", 8)
- if weekday == 2 or weekday == 6:
- pdf.set_font("helvetica", "B", 8)
- if weekday == 6:
- pdf.set_fill_color(170, 210, 255)
-
- pdf.cell(20, 4, pdf_safe(date_for_table[2] + "." + date_for_table[1] + "." + date_for_table[0]), 0, 0, "C", fill=True)
-
- event_full_block = table_cells[j].find_all("div", class_="calEventBox")
- events = table_cells[j].find_all(
- style=lambda style: style
- and "margin-right:.2em" in style.replace(" ", "")
- )
-
- pdf.cell(15, 4, pdf_safe(weekdays_list[weekday]), 0, 0, "C", fill=True)
-
- if len(events) == 0:
- pdf.cell(0, 4, " ", 0, 1, fill=True)
-
- for k in range(len(events)):
- if k > 0:
- pdf.cell(35, 4, " ", 0, 0, fill=True)
-
- event_place = "--"
- pdf.cell(20, 4, " ", 0, 0, "C", fill=True)
-
- if k < len(event_full_block):
- event_time = event_full_block[k].find_all(style="font-weight:normal")
- else:
- event_time = []
-
- if len(event_time) > 0:
- try:
- elements = str(event_time[0]).split("<")
- except Exception:
- print("error3")
- else:
- if len(elements) > 1 and ">" in elements[1]:
- time_text = elements[1].split(">")[1]
- if ":" in str(time_text):
- pdf.set_x(pdf.get_x() - 20)
- pdf.cell(20, 4, pdf_safe(time_text), 0, 0, "C", fill=True)
-
- if len(elements) > 2 and ">" in elements[2]:
- place_text = elements[2].split(">")[1]
- if place_text == "":
- event_place = "--"
- else:
- if str(place_text).startswith(","):
- event_place = str(place_text)[2:]
- else:
- event_place = str(place_text)
-
- event_name = events[k].get_text(strip=True) if len(events) > 0 else ""
- pdf.cell(75, 4, pdf_safe(event_name), 0, 0, fill=True)
- pdf.cell(60, 4, pdf_safe(event_place), 0, 1, "C", fill=True)
-
- counter += 1
- weekday += 1
-
- # Fußzeile
- pdf.set_font("helvetica", "", 8)
- pdf.cell(0, 1, " ", 0, 1)
- pdf.cell(0, 0.1, " ", 1, 1)
- pdf.cell(0, 1, " ", 0, 1)
- pdf.cell(153, 3, pdf_safe("gemeindeleitung@nak-reinickendorf.de"), 0, 0)
- pdf.cell(30, 3, pdf_safe("Klixstraße 17, 13403 Berlin"), 0, 1)
- pdf.set_font("helvetica", "I", 8)
- pdf.cell(153, 3, pdf_safe("Berlin, " + str(date.strftime("%d.%m.%Y %H:%M"))), 0, 0)
- pdf.set_font("helvetica", "", 8)
- pdf.cell(30, 3, pdf_safe("nak-reinickendorf.de"), 0, 1)
-
-#### PDF ERZEUGEN ####
-output_path = os.environ.get("PDF_OUTPUT", "/data/pdf/terminuebersicht.pdf")
-os.makedirs(os.path.dirname(output_path), exist_ok=True)
-pdf.output(output_path)
+#!/usr/bin/env python3
+import datetime as dt
+import os
+import re
+from urllib.parse import parse_qsl, urlencode, urlsplit
+
+import requests
+from bs4 import BeautifulSoup
+from fpdf import FPDF
+
+MONTHS = (
+ "Januar", "Februar", "März", "April", "Mai", "Juni",
+ "Juli", "August", "September", "Oktober", "November", "Dezember",
+)
+WEEKDAYS = ("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So")
+DATE_PATTERNS = (
+ (re.compile(r"(? 3 else ""
+ raise RuntimeError(
+ f"CKalender-Daten für {label} sind unvollständig "
+ f"({len(missing)} Tage fehlen: {preview}{suffix})."
+ )
+
+
+def calendar_url_for_period(url, start, end, kind):
+ parts = urlsplit(url)
+ overridden = {"calendar_kind", "from_date", "until_date"}
+ query = [
+ item for item in parse_qsl(parts.query, keep_blank_values=True)
+ if item[0] not in overridden
+ ]
+ query += [
+ ("calendar_kind", kind),
+ ("from_date", start.isoformat()),
+ ("until_date", end.isoformat()),
+ ]
+ return parts._replace(query=urlencode(query)).geturl()
+
+
+def fetch_calendar_cells(url, start, end, kind):
+ response = requests.get(calendar_url_for_period(url, start, end, kind), timeout=30)
+ response.raise_for_status()
+ return calendar_cells(response.text)
+
+
+def load_calendar_cells(url, today):
+ months = target_months(today)
+ cells = fetch_calendar_cells(url, months[0], month_end(months[1]), "year")
+
+ for month in months:
+ if not missing_days(cells, month):
+ continue
+ end = month_end(month)
+ fetched = fetch_calendar_cells(url, month, end, "month")
+ cells.update({
+ day: cell for day, cell in fetched.items()
+ if month <= day <= end
+ })
+ return cells
+
+
+def add_table_header(pdf):
+ pdf.set_font("helvetica", "B", 10)
+ for index, (width, label, align) in enumerate(TABLE_COLUMNS):
+ pdf.cell(width, 5, label, 1, int(index == len(TABLE_COLUMNS) - 1), align)
+
+
+def add_footer(pdf, now):
+ pdf.set_font("helvetica", "", 8)
+ pdf.cell(0, 1, " ", 0, 1)
+ pdf.cell(0, 0.1, " ", 1, 1)
+ pdf.cell(0, 1, " ", 0, 1)
+ pdf.cell(153, 3, "gemeindeleitung@nak-reinickendorf.de", 0, 0)
+ pdf.cell(30, 3, pdf_safe("Klixstraße 17, 13403 Berlin"), 0, 1)
+ pdf.set_font("helvetica", "I", 8)
+ pdf.cell(153, 3, f"Berlin, {now:%d.%m.%Y %H:%M}", 0, 0)
+ pdf.set_font("helvetica", "", 8)
+ pdf.cell(30, 3, "nak-reinickendorf.de", 0, 1)
+
+
+def add_month(pdf, cells, month, today=None):
+ for day in month_days(month):
+ pdf.set_fill_color(130)
+ pdf.cell(0, 0.3, " ", 0, 1, fill=True)
+ pdf.set_fill_color(255 if day.day % 2 else 220)
+ pdf.set_font("helvetica", "B" if day.weekday() >= 5 else "", 8)
+ if day.weekday() == 6:
+ pdf.set_fill_color(170, 210, 255)
+ if day == today:
+ pdf.set_fill_color(255, 255, 0)
+
+ pdf.cell(20, 4, day.strftime("%d.%m.%Y"), 0, 0, "C", fill=True)
+ pdf.cell(15, 4, WEEKDAYS[day.weekday()], 0, 0, "C", fill=True)
+
+ events = list(event_rows(cells[day])) if day in cells else []
+ if not events:
+ pdf.cell(0, 4, " ", 0, 1, fill=True)
+ continue
+
+ for index, (time_text, title, place) in enumerate(events):
+ if index:
+ pdf.cell(35, 4, " ", 0, 0, fill=True)
+ pdf.cell(20, 4, pdf_safe(time_text), 0, 0, "C", fill=True)
+ pdf.cell(75, 4, pdf_safe(title), 0, 0, fill=True)
+ pdf.cell(60, 4, pdf_safe(place), 0, 1, "C", fill=True)
+
+
+def add_month_section(pdf, cells, month, now, today=None, planning_note=False):
+ name = MONTHS[month.month - 1]
+ pdf.set_font("helvetica", "B", 16)
+ pdf.cell(160, 7, pdf_safe(f"Terminübersicht: {name.upper()} {month.year}"), 0, 1)
+ pdf.set_font("helvetica", "", 10)
+ subtitle = (
+ f"Die Termine für {name} befinden sich noch in Planung und können sich ändern."
+ if planning_note else "der Gemeinde Berlin-Reinickendorf"
+ )
+ pdf.cell(160, 5, pdf_safe(subtitle), 0, 1)
+ pdf.cell(0, 2, " ", 0, 1)
+ add_table_header(pdf)
+ add_month(pdf, cells, month, today)
+ add_footer(pdf, now)
+
+
+def create_pdf(cells, output_path, now=None):
+ now = now or dt.datetime.now()
+ today = now.date()
+ months = target_months(today)
+ for month in months:
+ require_month(cells, month)
+
+ pdf = FPDF("P", "mm", "A4")
+ pdf.set_auto_page_break(auto=True, margin=15)
+ pdf.add_page()
+
+ logo = os.path.join(os.path.dirname(__file__), "nak_logo.png")
+ if not os.path.isfile(logo):
+ raise RuntimeError(f"Logo fehlt: {logo}")
+ pdf.image(logo, 188, 9.5, 11)
+
+ for index, month in enumerate(months):
+ if index:
+ pdf.cell(0, 6, " ", 0, 1)
+ add_month_section(
+ pdf, cells, month, now,
+ today=today if index == 0 else None,
+ planning_note=index == 1,
+ )
+
+ output_dir = os.path.dirname(output_path)
+ if output_dir:
+ os.makedirs(output_dir, exist_ok=True)
+ pdf.output(output_path)
+
+
+def main():
+ url = os.environ.get("CKAL_CALENDAR_URL")
+ if not url:
+ raise RuntimeError("CKAL_CALENDAR_URL ist nicht gesetzt.")
+
+ now = dt.datetime.now()
+ cells = load_calendar_cells(url, now.date())
+ create_pdf(cells, os.environ.get("PDF_OUTPUT", "/data/pdf/terminuebersicht.pdf"), now)
+
+
+if __name__ == "__main__":
+ main()