From b6c883137388a833b7ced40d2f9e90d6140d9db7 Mon Sep 17 00:00:00 2001 From: samuelbiasgit Date: Sat, 29 Aug 2026 21:22:09 +0200 Subject: [PATCH] monatsplan - ckalender read fixed, developerpanel - rss feed implemented --- README.md | 86 ++- docker-compose.local.yml | 259 +++++--- .../_central/layouts/developerpanel.css | 132 ++++ .../projects/developerpanel/developer.php | 73 ++- .../developerpanel/util/developer_helpers.php | 142 ++++ worker/pdf_python/create_pdf.py | 615 ++++++++---------- 6 files changed, 804 insertions(+), 503 deletions(-) create mode 100644 web/intranet_repo/projects/_central/layouts/developerpanel.css create mode 100644 web/intranet_repo/projects/developerpanel/util/developer_helpers.php 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 @@
+ +
+
+ +

Git-Aktivität

+
+ + +

+ +
+ + $items): ?> +
= DEVELOPER_FEED_LIMIT ? ' hidden' : '' ?>> +

+ +
+ +
+ 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()