diff --git a/Internal/de/PROJECT_CONTEXT.md b/Internal/de/PROJECT_CONTEXT.md index 08b17ec..8e0f6fe 100644 --- a/Internal/de/PROJECT_CONTEXT.md +++ b/Internal/de/PROJECT_CONTEXT.md @@ -22,6 +22,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter. Kernbereiche sind l - Die Konto-E-Mail wird app-seitig verschlüsselt gespeichert und über einen separaten HMAC-Lookup-Hash adressiert. - Profiladresse kann per Suche oder Browser-Standort übernommen werden. - Der Bereich `Events` ist wieder auf echte eigene Events und Event-Teilnahmen begrenzt. +- Der Bereich `Events` bietet zusätzlich einen manuellen ICS-Export und einen persönlichen abonnierbaren Kalender-Feed für alle eigenen Events und Event-Teilnahmen. - Orte und Veranstaltungen als Ausflugsziele sind vorerst in einen separaten Mitgliederbereichspunkt `Ausflugsziele` verschoben. - Die Eingabe im Bereich `Events` soll so einfach wie möglich bleiben und zeigt deshalb nur die für echte Termine relevanten Felder. - Kategorien werden im Mitgliederbereich per Sucheingabe mit bestehenden Vorschlägen und automatischer Neuanlage gepflegt. diff --git a/Internal/de/PROJECT_STRUCTURE.md b/Internal/de/PROJECT_STRUCTURE.md index 56cdb85..d94447c 100644 --- a/Internal/de/PROJECT_STRUCTURE.md +++ b/Internal/de/PROJECT_STRUCTURE.md @@ -18,6 +18,7 @@ Stand: 2026-08-05 - Consent- und rechtliche Texte: `public/page/datenschutz.php` - System-Einstellungen: `src/App/SystemSettings.php` - neue Listing-/Ort-Basis: `src/App/ListingCatalog.php` +- Kalender-Export und abonnierbarer Feed: `src/App/CalendarSync.php` plus `public/page/calendar/export.php` und `public/page/calendar/feed.php` - UI für getrennte Bereiche `Events` und `Ausflugsziele` im Mitgliederbereich: `partials/landing/account/dashboard.php` - Preis-, Bild- und Ortseingabe für Ausflugsziele sowie reduzierte Event-Maske: `partials/landing/account/dashboard.php` plus Speicherung in `src/App/ListingCatalog.php` - Legacy-Eigen-Events wurden erweitert in `src/App/AccountPages.php` und `schema.sql` um Kategorie- und Bildfelder diff --git a/Internal/de/README.md b/Internal/de/README.md index 4c0f5c4..32a07cf 100644 --- a/Internal/de/README.md +++ b/Internal/de/README.md @@ -27,6 +27,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale - Orte und sonstige Veranstaltungen wurden vorerst in einen separaten Bereich `Ausflugsziele` verschoben - Die Eingabemasken sind jetzt progressiv aufgebaut: erst Pflichtangaben, optionale Daten in einklappbaren Bereichen - Kategorien laufen jetzt über eine Sucheingabe mit bestehenden Vorschlägen; neue Kategorien werden automatisch angelegt und können im Systembereich von Seiten-Admins zusammengeführt werden +- Im Bereich `Events` gibt es jetzt zusätzlich einen ICS-Download und einen persönlichen abonnierbaren Kalender-Feed für alle eigenen Events und Event-Teilnahmen ## Wording-Regel - Primärbegriff im Produkt: `Events` @@ -44,6 +45,7 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf lokale ## Datenschutz und Sicherheit - Passwort-Hashes über Argon2id - Konto-E-Mails verschlüsselt plus separater HMAC-Lookup-Hash +- persönliche Kalender-Feed-Tokens verschlüsselt plus separater SHA-256-Lookup-Hash - Profilfelder wie Vorname, Nachname, Straße/Hausnummer, Telefonnummer, Beruf, Sprachen und Kurzvorstellung verschlüsselt - Standortkoordinaten (`lat`, `lng`) bleiben für Distanz- und Umkreissuchen technisch im Klartext - nicht notwendige Analyse- und Drittanbieterfunktionen erst nach Einwilligung aktiv diff --git a/Internal/en/PROJECT_CONTEXT.md b/Internal/en/PROJECT_CONTEXT.md index 3431ae6..8087f32 100644 --- a/Internal/en/PROJECT_CONTEXT.md +++ b/Internal/en/PROJECT_CONTEXT.md @@ -22,6 +22,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers. Core areas are local events - Account email is encrypted application-side and addressed through a separate HMAC lookup hash. - Profile address can be completed via address search or browser-based location import. - The `Events` area is limited again to real own events and event participations. +- The `Events` area now also provides a manual ICS export and a personal subscribable calendar feed for all own events and event participations. - Places and event-like outing targets were moved for now into a separate member-area section `Outings`. - The `Events` input flow should stay as simple as possible and therefore only shows fields relevant for real scheduled events. - Categories in the member area are handled via a search input with existing suggestions and automatic creation when needed. diff --git a/Internal/en/PROJECT_STRUCTURE.md b/Internal/en/PROJECT_STRUCTURE.md index 4fa6843..a13b37f 100644 --- a/Internal/en/PROJECT_STRUCTURE.md +++ b/Internal/en/PROJECT_STRUCTURE.md @@ -18,6 +18,7 @@ Updated: 2026-08-05 - consent and legal texts: `public/page/datenschutz.php` - system settings: `src/App/SystemSettings.php` - new listing/place base: `src/App/ListingCatalog.php` +- calendar export and subscribable feed: `src/App/CalendarSync.php` plus `public/page/calendar/export.php` and `public/page/calendar/feed.php` - separated `Events` and `Outings` UI in member area: `partials/landing/account/dashboard.php` - pricing, image upload, and place/address input for outings plus a reduced own-event form: `partials/landing/account/dashboard.php` with persistence in `src/App/ListingCatalog.php` - legacy own events were extended in `src/App/AccountPages.php` and `schema.sql` with category and image fields diff --git a/Internal/en/README.md b/Internal/en/README.md index d70b974..01155c8 100644 --- a/Internal/en/README.md +++ b/Internal/en/README.md @@ -27,6 +27,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers focused on local events, app - places and other events were moved for now into a separate member-area section `Outings` - the member-area entry forms now follow a progressive approach: required fields first, optional data inside collapsible sections - categories now use a search input with existing suggestions; new categories are created automatically and can be merged by site admins in the system area +- the `Events` area now also includes an ICS download and a personal subscribable calendar feed for all own events and event participations ## Core Wording Rule - Primary product term: `Events` @@ -43,6 +44,7 @@ Papa-Kind-Treff is a PHP-based platform for fathers focused on local events, app ## Privacy and Security - password hashes via Argon2id - account emails are encrypted and additionally addressed through a separate HMAC lookup hash +- personal calendar-feed tokens are stored encrypted and additionally resolved through a separate SHA-256 lookup hash - profile fields such as first name, last name, street/house number, phone number, profession, languages, and short bio are encrypted - location coordinates (`lat`, `lng`) remain plaintext where technically required for distance and radius search - non-essential analytics and third-party services are only active after consent diff --git a/README.md b/README.md index 061af62..1007efe 100644 --- a/README.md +++ b/README.md @@ -41,10 +41,12 @@ Papa-Kind-Treff ist eine PHP-basierte Plattform für Väter mit Fokus auf: - Eigene Events unterstützen aktuell Kategorie, Bild-Upload, klare Kinderangabe, optionale Platzzahl und direkte Karten-/Adress-Synchronisierung - Die Eingabe im Mitgliederbereich ist jetzt bewusst vereinfacht: zuerst nur Pflichtangaben, optionale Angaben in einklappbaren Bereichen - Kategorien werden jetzt über eine Sucheingabe mit bestehenden Vorschlägen gepflegt; neue Kategorien werden automatisch angelegt und sind für Seiten-Admins im Systembereich zusammenführbar +- Im Mitgliederbereich `Events` gibt es jetzt zusätzlich einen ICS-Download und einen persönlichen abonnierbaren Kalender-Feed für alle eigenen Events und Event-Teilnahmen ## Datenschutz und Sicherheit - sensible Profilfelder werden app-seitig verschlüsselt gespeichert - Konto-E-Mails werden verschlüsselt gespeichert und zusätzlich über einen separaten HMAC-Lookup-Hash adressiert +- persönliche Kalender-Feed-Tokens werden verschlüsselt gespeichert und zusätzlich über einen separaten SHA-256-Lookup-Hash adressiert - Standortkoordinaten bleiben technisch im Klartext, soweit sie für Distanz- und Umkreissuchen erforderlich sind - Änderungen an Cookies, LocalStorage, SessionStorage, Geolocation, Tracking oder Drittanbietern erfordern immer auch die Prüfung und Aktualisierung von Consent und rechtlichen Hinweisen diff --git a/partials/landing/account/dashboard.php b/partials/landing/account/dashboard.php index f89f6b9..7fa682f 100755 --- a/partials/landing/account/dashboard.php +++ b/partials/landing/account/dashboard.php @@ -284,7 +284,22 @@ if (!empty($canManageSystemSettings)) {
+ + ICS herunterladen +
+ +
+ Kalender-Synchronisierung +

Exportiert werden immer alle eigenen Events und alle Events, an denen du teilnimmst.

+

„ICS herunterladen“ ist nur eine Momentaufnahme für den manuellen Import. Änderungen, Absagen oder Löschungen in Papa-Kind-Treff werden dadurch später nicht automatisch aus deinem privaten Kalender entfernt.

+

Für automatische Aktualisierungen nutze den persönlichen Kalender-Feed. Dann können unterstützte Kalender gelöschte oder abgesagte Einträge bei der nächsten Synchronisierung übernehmen.

+
+ + +
+
+
Eigene Events

Von dir organisierte Treffen oder Events mit eigener Teilnahme-Logik.

diff --git a/public/index.php b/public/index.php index 9294f79..771235d 100755 --- a/public/index.php +++ b/public/index.php @@ -71,6 +71,9 @@ if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/retool if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/api'))) { $skipLayout = true; } +if ($targetReal && str_starts_with($targetReal, realpath(__DIR__ . '/page/calendar'))) { + $skipLayout = true; +} // ------------------------------------ // Ausgabe diff --git a/public/page/calendar/export.php b/public/page/calendar/export.php new file mode 100644 index 0000000..29a0cdd --- /dev/null +++ b/public/page/calendar/export.php @@ -0,0 +1,26 @@ +ensureSchema(); + +$userId = (int)$_SESSION['user_id']; +$ics = $calendarSync->renderUserCalendarIcs($userId, 'Papa-Kind-Treff Events'); + +header('Content-Type: text/calendar; charset=utf-8'); +header('Content-Disposition: attachment; filename="papa-kind-treff-events.ics"'); +header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + +echo $ics; diff --git a/public/page/calendar/feed.php b/public/page/calendar/feed.php new file mode 100644 index 0000000..0815b7f --- /dev/null +++ b/public/page/calendar/feed.php @@ -0,0 +1,35 @@ +ensureSchema(); + +$userId = $calendarSync->findUserIdByFeedToken($token); +if (!$userId) { + http_response_code(404); + header('Content-Type: text/plain; charset=utf-8'); + echo 'Kalender-Feed nicht gefunden.'; + return; +} + +$calendarSync->touchFeedAccess($userId); +$ics = $calendarSync->renderUserCalendarIcs($userId, 'Papa-Kind-Treff Events'); + +header('Content-Type: text/calendar; charset=utf-8'); +header('Content-Disposition: inline; filename="papa-kind-treff-events-feed.ics"'); +header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0'); + +echo $ics; diff --git a/public/page/datenschutz.php b/public/page/datenschutz.php index 0d99fa8..bb0e819 100644 --- a/public/page/datenschutz.php +++ b/public/page/datenschutz.php @@ -9,7 +9,7 @@ $clientCookie = $config->cookiePrefix() . 'client';

Datenschutz & Cookies

-

Stand: 4. August 2026

+

Stand: 5. August 2026

Diese Hinweise erklären, welche personenbezogenen Daten beim Besuch und bei der Nutzung von Papa-Kind-Treff @@ -62,6 +62,12 @@ $clientCookie = $config->cookiePrefix() . 'client'; Soweit es sich um sensible oder besonders persönliche Profildaten handelt, werden diese innerhalb der Anwendung verschlüsselt gespeichert und verarbeitet.

+

+ Wenn du deine Events in einen privaten Kalender exportierst oder einen persönlichen Kalender-Feed abonnierst, + werden dabei ausschließlich deine eigenen Events und deine Event-Teilnahmen als Kalenderdaten bereitgestellt. + Der abonnierbare Feed ist über eine persönliche, nicht öffentliche URL abgesichert. Diese URL sollte vertraulich + behandelt und nicht an Dritte weitergegeben werden. +

Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, soweit die Verarbeitung für die Durchführung des Nutzungsverhältnisses erforderlich ist. diff --git a/schema.sql b/schema.sql index 3c5a938..624ce8a 100755 --- a/schema.sql +++ b/schema.sql @@ -424,6 +424,17 @@ CREATE TABLE user_tokens ( INDEX idx_ut_type (type) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +CREATE TABLE user_calendar_feeds ( + user_id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + token_encrypted TEXT NOT NULL, + token_lookup_hash CHAR(64) NOT NULL UNIQUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + rotated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_accessed_at DATETIME NULL, + CONSTRAINT fk_user_calendar_feed_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user_calendar_feeds_lookup (token_lookup_hash) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + -- Audit-Log für wichtige Aktionen CREATE TABLE audit_log ( id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, diff --git a/src/App/AccountPages.php b/src/App/AccountPages.php index 89baf44..a34a3ec 100755 --- a/src/App/AccountPages.php +++ b/src/App/AccountPages.php @@ -176,6 +176,7 @@ final class AccountPages $profileSettings = $pdo ? new ProfileSettings($pdo) : null; $systemSettings = $pdo ? new SystemSettings($pdo) : null; $listingCatalog = $pdo ? new ListingCatalog($pdo) : null; + $calendarSync = $pdo ? new CalendarSync($app) : null; $section = (string)($_GET['section'] ?? 'profile'); $canManageSystemSettings = $communityAccess ? $communityAccess->canManageApplications($userId) : false; $allowedSections = ['profile', 'children', 'events', 'places', 'community', 'settings']; @@ -189,6 +190,9 @@ final class AccountPages if ($listingCatalog) { $listingCatalog->ensureSchema(); } + if ($calendarSync) { + $calendarSync->ensureSchema(); + } if ($pdo) { self::ensureLegacyEventSchema($pdo); } @@ -785,6 +789,13 @@ final class AccountPages $section = 'profile'; } $avatarBuilder = AvatarManager::builderStyles($profile); + $calendarExportUrl = null; + $calendarFeedUrl = null; + if ($calendarSync) { + $calendarToken = $calendarSync->getOrCreateFeedToken($userId); + $calendarExportUrl = CalendarSync::buildAbsoluteUrl('/calendar/export'); + $calendarFeedUrl = CalendarSync::buildAbsoluteUrl('/calendar/feed?token=' . rawurlencode($calendarToken)); + } return compact( 'flash', @@ -813,6 +824,8 @@ final class AccountPages 'systemSettingsValues', 'listingCatalogStatus', 'avatarBuilder', + 'calendarExportUrl', + 'calendarFeedUrl', 'section', 'allowedSections' ); diff --git a/src/App/CalendarSync.php b/src/App/CalendarSync.php new file mode 100644 index 0000000..b21fbad --- /dev/null +++ b/src/App/CalendarSync.php @@ -0,0 +1,366 @@ +pdo()->exec( + 'CREATE TABLE IF NOT EXISTS user_calendar_feeds ( + user_id BIGINT UNSIGNED NOT NULL PRIMARY KEY, + token_encrypted TEXT NOT NULL, + token_lookup_hash CHAR(64) NOT NULL UNIQUE, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + rotated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + last_accessed_at DATETIME NULL, + CONSTRAINT fk_user_calendar_feed_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user_calendar_feeds_lookup (token_lookup_hash) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci' + ); + } + + public function getOrCreateFeedToken(int $userId): string + { + $existing = $this->getFeedToken($userId); + if ($existing !== null) { + return $existing; + } + + $token = rtrim(strtr(base64_encode(random_bytes(32)), '+/', '-_'), '='); + $crypto = $this->crypto(); + $stmt = $this->pdo()->prepare( + 'INSERT INTO user_calendar_feeds (user_id, token_encrypted, token_lookup_hash, created_at, rotated_at) + VALUES (:userId, :tokenEncrypted, :tokenLookupHash, NOW(), NOW()) + ON DUPLICATE KEY UPDATE + token_encrypted = VALUES(token_encrypted), + token_lookup_hash = VALUES(token_lookup_hash), + rotated_at = NOW()' + ); + $stmt->execute([ + 'userId' => $userId, + 'tokenEncrypted' => $crypto->encrypt($token), + 'tokenLookupHash' => hash('sha256', $token), + ]); + + return $token; + } + + public function getFeedToken(int $userId): ?string + { + $stmt = $this->pdo()->prepare('SELECT token_encrypted FROM user_calendar_feeds WHERE user_id = :userId LIMIT 1'); + $stmt->execute(['userId' => $userId]); + $encrypted = $stmt->fetchColumn(); + if (!is_string($encrypted) || trim($encrypted) === '') { + return null; + } + + $token = $this->crypto()->decrypt($encrypted); + return is_string($token) && trim($token) !== '' ? $token : null; + } + + public function findUserIdByFeedToken(string $token): ?int + { + $token = trim($token); + if ($token === '') { + return null; + } + + $stmt = $this->pdo()->prepare( + 'SELECT user_id + FROM user_calendar_feeds + WHERE token_lookup_hash = :tokenLookupHash + LIMIT 1' + ); + $stmt->execute(['tokenLookupHash' => hash('sha256', $token)]); + $userId = $stmt->fetchColumn(); + + return $userId !== false ? (int)$userId : null; + } + + public function touchFeedAccess(int $userId): void + { + $stmt = $this->pdo()->prepare( + 'UPDATE user_calendar_feeds + SET last_accessed_at = NOW() + WHERE user_id = :userId' + ); + $stmt->execute(['userId' => $userId]); + } + + public function listCalendarEventsForUser(int $userId): array + { + $stmt = $this->pdo()->prepare( + 'SELECT e.id, e.created_by, e.title, e.description, e.category_slug, e.street, e.zip, e.city, e.region, + e.starts_at, e.ends_at, e.visibility, e.status, e.allow_kids, + "owner" AS relation_type, + NULL AS participation_status, + NULL AS host_name + FROM events e + WHERE e.created_by = :userId + + UNION ALL + + SELECT e.id, e.created_by, e.title, e.description, e.category_slug, e.street, e.zip, e.city, e.region, + e.starts_at, e.ends_at, e.visibility, e.status, e.allow_kids, + "participant" AS relation_type, + ep.status AS participation_status, + COALESCE(up.display_name, "Mitglied") AS host_name + FROM event_participants ep + INNER JOIN events e ON e.id = ep.event_id + INNER JOIN users u ON u.id = e.created_by + LEFT JOIN user_profiles up ON up.user_id = u.id + WHERE ep.user_id = :participantId + AND e.created_by <> :ownerId + AND ep.status <> "cancelled" + + ORDER BY starts_at ASC, id ASC' + ); + $stmt->execute([ + 'userId' => $userId, + 'participantId' => $userId, + 'ownerId' => $userId, + ]); + + return $stmt->fetchAll(\PDO::FETCH_ASSOC) ?: []; + } + + public function renderUserCalendarIcs(int $userId, string $calendarName = 'Papa-Kind-Treff Events'): string + { + return $this->renderCalendarIcs($calendarName, $this->listCalendarEventsForUser($userId)); + } + + public function renderCalendarIcs(string $calendarName, array $events): string + { + $lines = [ + 'BEGIN:VCALENDAR', + 'VERSION:2.0', + 'PRODID:-//Papa-Kind-Treff//Kalender//DE', + 'CALSCALE:GREGORIAN', + 'METHOD:PUBLISH', + 'X-WR-CALNAME:' . $this->escapeText($calendarName), + 'X-WR-TIMEZONE:Europe/Berlin', + ]; + + foreach ($events as $event) { + $lines = array_merge($lines, $this->buildEventLines($event)); + } + + $lines[] = 'END:VCALENDAR'; + + return $this->foldLines($lines); + } + + public static function buildAbsoluteUrl(string $path): string + { + $scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http'; + $host = (string)($_SERVER['HTTP_HOST'] ?? 'localhost'); + + return $scheme . '://' . $host . $path; + } + + private function buildEventLines(array $event): array + { + $lines = [ + 'BEGIN:VEVENT', + 'UID:' . $this->buildUid((int)($event['id'] ?? 0), (string)($event['relation_type'] ?? 'event')), + 'DTSTAMP:' . gmdate('Ymd\THis\Z'), + 'SUMMARY:' . $this->escapeText((string)($event['title'] ?? 'Papa-Kind-Treff Event')), + ]; + + $startAt = trim((string)($event['starts_at'] ?? '')); + $endAt = trim((string)($event['ends_at'] ?? '')); + $isAllDay = $this->isAllDayEvent($startAt, $endAt); + if ($isAllDay) { + $startDate = $this->toDateString($startAt); + if ($startDate !== null) { + $lines[] = 'DTSTART;VALUE=DATE:' . $startDate; + $lines[] = 'DTEND;VALUE=DATE:' . $this->incrementDateString($startDate); + } + } else { + $startDateTime = $this->toDateTimeString($startAt); + if ($startDateTime !== null) { + $lines[] = 'DTSTART;TZID=Europe/Berlin:' . $startDateTime; + } + + $endDateTime = $this->toDateTimeString($endAt); + if ($endDateTime !== null) { + $lines[] = 'DTEND;TZID=Europe/Berlin:' . $endDateTime; + } + } + + $description = $this->buildDescription($event); + if ($description !== '') { + $lines[] = 'DESCRIPTION:' . $this->escapeText($description); + } + + $location = $this->buildLocation($event); + if ($location !== '') { + $lines[] = 'LOCATION:' . $this->escapeText($location); + } + + if (($event['status'] ?? '') === 'cancelled') { + $lines[] = 'STATUS:CANCELLED'; + } else { + $lines[] = 'STATUS:CONFIRMED'; + } + + $lines[] = 'END:VEVENT'; + + return $lines; + } + + private function buildDescription(array $event): string + { + $parts = []; + + $description = trim((string)($event['description'] ?? '')); + if ($description !== '') { + $parts[] = $description; + } + + $meta = []; + if (($event['relation_type'] ?? '') === 'owner') { + $meta[] = 'Typ: Eigenes Event'; + } elseif (($event['relation_type'] ?? '') === 'participant') { + $meta[] = 'Typ: Teilnahme an fremdem Event'; + } + + if (!empty($event['host_name'])) { + $meta[] = 'Veranstalter: ' . trim((string)$event['host_name']); + } + if (!empty($event['participation_status'])) { + $meta[] = 'Teilnahmestatus: ' . trim((string)$event['participation_status']); + } + if (!empty($event['category_slug'])) { + $meta[] = 'Kategorie: ' . trim((string)$event['category_slug']); + } + $meta[] = !empty($event['allow_kids']) ? 'Mit Kindern: Ja' : 'Mit Kindern: Nein'; + $meta[] = 'Sichtbarkeit: ' . (($event['visibility'] ?? 'public') === 'members' ? 'Nur Mitglieder' : 'Öffentlich'); + if (($event['status'] ?? '') === 'cancelled') { + $meta[] = 'Status: Abgesagt'; + } + + if ($meta !== []) { + $parts[] = implode("\n", $meta); + } + + return trim(implode("\n\n", $parts)); + } + + private function buildLocation(array $event): string + { + $parts = array_filter([ + trim((string)($event['street'] ?? '')), + trim((string)implode(' ', array_filter([ + (string)($event['zip'] ?? ''), + (string)($event['city'] ?? ''), + ]))), + trim((string)($event['region'] ?? '')), + ]); + + return trim(implode(', ', $parts)); + } + + private function buildUid(int $eventId, string $relationType): string + { + $host = preg_replace('/[^a-z0-9.-]+/i', '-', (string)($_SERVER['HTTP_HOST'] ?? 'papa-kind-treff.local')) ?: 'papa-kind-treff.local'; + + return sprintf('event-%d-%s@%s', $eventId, $relationType, $host); + } + + private function isAllDayEvent(string $startAt, string $endAt): bool + { + if ($startAt === '') { + return false; + } + + $startTime = substr($startAt, 11, 8); + $endTime = $endAt !== '' ? substr($endAt, 11, 8) : ''; + + return $startTime === '' || $startTime === '00:00:00' || $startTime === '23:59:59' + ? ($endAt === '' || $endTime === '00:00:00' || $endTime === '23:59:59') + : false; + } + + private function toDateString(string $value): ?string + { + if ($value === '') { + return null; + } + + try { + return (new \DateTimeImmutable($value))->format('Ymd'); + } catch (\Throwable) { + return null; + } + } + + private function incrementDateString(string $dateString): string + { + $date = \DateTimeImmutable::createFromFormat('Ymd', $dateString); + if (!$date instanceof \DateTimeImmutable) { + return $dateString; + } + + return $date->modify('+1 day')->format('Ymd'); + } + + private function toDateTimeString(string $value): ?string + { + if ($value === '') { + return null; + } + + try { + return (new \DateTimeImmutable($value, new \DateTimeZone('Europe/Berlin')))->format('Ymd\THis'); + } catch (\Throwable) { + return null; + } + } + + private function escapeText(string $value): string + { + $value = str_replace(["\r\n", "\r"], "\n", trim($value)); + $value = str_replace('\\', '\\\\', $value); + $value = str_replace(';', '\;', $value); + $value = str_replace(',', '\,', $value); + + return str_replace("\n", '\n', $value); + } + + private function foldLines(array $lines): string + { + $output = []; + foreach ($lines as $line) { + $line = (string)$line; + while (strlen($line) > 75) { + $output[] = substr($line, 0, 75); + $line = ' ' . substr($line, 75); + } + $output[] = $line; + } + + return implode("\r\n", $output) . "\r\n"; + } + + private function pdo(): \PDO + { + $pdo = $this->app->pdo(); + if (!$pdo instanceof \PDO) { + throw new \RuntimeException('Datenbankverbindung nicht verfügbar.'); + } + + return $pdo; + } + + private function crypto(): Crypto + { + return new Crypto($this->app->config()); + } +}