last change
All checks were successful
Deploy / deploy (push) Successful in 56s

This commit is contained in:
2026-08-07 23:43:36 +02:00
parent c9b1839460
commit a102b2aa0c
15 changed files with 486 additions and 1 deletions

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -284,7 +284,22 @@ if (!empty($canManageSystemSettings)) {
<div class="account-panel__body">
<div class="flex gap-12" style="margin:0 0 16px 0; flex-wrap: wrap;">
<button class="btn" type="button" data-modal-open="modalEvent">Eigenes Event anlegen</button>
<?php if (!empty($calendarExportUrl)): ?>
<a class="btn ghost" href="<?= htmlspecialchars((string)$calendarExportUrl, ENT_QUOTES) ?>">ICS herunterladen</a>
<?php endif; ?>
</div>
<?php if (!empty($calendarExportUrl) && !empty($calendarFeedUrl)): ?>
<div class="card" style="margin-bottom:18px;">
<strong>Kalender-Synchronisierung</strong>
<p class="muted small" style="margin:8px 0 0;">Exportiert werden immer alle eigenen Events und alle Events, an denen du teilnimmst.</p>
<p class="muted small" style="margin:8px 0 0;">„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.</p>
<p class="muted small" style="margin:8px 0 0;">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.</p>
<div class="stack gap-6" style="margin-top:12px;">
<label class="label" for="calendarFeedUrl">Persönlicher Kalender-Feed</label>
<input id="calendarFeedUrl" class="input" type="text" readonly value="<?= htmlspecialchars((string)$calendarFeedUrl, ENT_QUOTES) ?>">
</div>
</div>
<?php endif; ?>
<div class="card" style="margin-bottom:18px;">
<strong>Eigene Events</strong>
<p class="muted small" style="margin:8px 0 0;">Von dir organisierte Treffen oder Events mit eigener Teilnahme-Logik.</p>

View File

@@ -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

View File

@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
use App\App;
use App\CalendarSync;
$app = App::get();
if (!isset($_SESSION['user_id'])) {
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
echo 'Login erforderlich.';
return;
}
$calendarSync = new CalendarSync($app);
$calendarSync->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;

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
use App\App;
use App\CalendarSync;
$app = App::get();
$token = trim((string)($_GET['token'] ?? ''));
if ($token === '') {
http_response_code(400);
header('Content-Type: text/plain; charset=utf-8');
echo 'Token fehlt.';
return;
}
$calendarSync = new CalendarSync($app);
$calendarSync->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;

View File

@@ -9,7 +9,7 @@ $clientCookie = $config->cookiePrefix() . 'client';
<main class="container section legal-page">
<div class="content-card content-card--narrow page-copy">
<h1>Datenschutz &amp; Cookies</h1>
<p class="muted small">Stand: 4. August 2026</p>
<p class="muted small">Stand: 5. August 2026</p>
<p>
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.
</p>
<p>
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.
</p>
<p>
Rechtsgrundlage ist Art. 6 Abs. 1 lit. b DSGVO, soweit die Verarbeitung für die Durchführung des
Nutzungsverhältnisses erforderlich ist.

View File

@@ -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,

View File

@@ -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'
);

366
src/App/CalendarSync.php Normal file
View File

@@ -0,0 +1,366 @@
<?php
declare(strict_types=1);
namespace App;
final class CalendarSync
{
public function __construct(private App $app)
{
}
public function ensureSchema(): void
{
$this->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());
}
}