query($sql)->fetchAll(PDO::FETCH_ASSOC); } /** * Erzeugt die einzige Berechtigungs- und Preset-Sicht für UI und POST-Verarbeitung. */ function kalliBuildCatalog(array $rows, array $groups): array { $isAdmin = kalliPermissionEnabled($groups['Admin'] ?? null); $categories = []; $typesByName = []; $allowCustom = $isAdmin; foreach ($rows as $row) { $role = (string)($row['permission_role'] ?? ''); $allowed = $isAdmin || kalliPermissionEnabled($groups[$role] ?? null); if (!$allowed) { continue; } // Entspricht dem bisherigen Verhalten: Wer mindestens eine Kalli-Kategorie // sehen darf, darf auch einen benutzerdefinierten Termin anlegen. $allowCustom = true; if ($row['type_id'] === null) { continue; } $categoryId = (string)$row['category_id']; if (!isset($categories[$categoryId])) { $categories[$categoryId] = [ 'id' => $row['category_id'], 'name' => (string)$row['category_name'], 'types' => [], ]; } $type = [ 'id' => $row['type_id'], 'name' => (string)$row['type_name'], 'display_label' => (string)($row['display_label'] ?? ''), 'planungsstatus' => (string)($row['planungsstatus'] ?? ''), 'anzeigeziel' => (string)($row['anzeigeziel'] ?? ''), 'gruppenzuordnung' => (string)($row['gruppenzuordnung'] ?? ''), 'anzeigeziel_ort' => (string)($row['anzeigeziel_ort'] ?? ''), 'anzeigeziel_notiz' => (string)($row['anzeigeziel_notiz'] ?? ''), 'anzeigeziel_verantwortlicher' => (string)($row['anzeigeziel_verantwortlicher'] ?? ''), 'default_text' => (string)($row['default_text'] ?? ''), ]; $categories[$categoryId]['types'][] = $type; // Das Formular übermittelt aus Kompatibilitätsgründen weiterhin den Namen. // Bei versehentlichen Dubletten entspricht der erste sortierte Treffer dem // deterministischen Ersatz für das frühere "LIMIT 1" ohne ORDER BY. if (!isset($typesByName[$type['name']])) { $typesByName[$type['name']] = $type; } } return [ 'categories' => array_values($categories), 'types_by_name' => $typesByName, 'allow_custom' => $allowCustom, ]; } function kalliFormValues(array $source = []): array { $keys = [ 'group', 'userinput', 'date_s', 'date_e', 'time_s', 'time_e', 'further_text', 'location', 'responsibility', ]; $values = []; foreach ($keys as $key) { $value = $source[$key] ?? ''; $values[$key] = is_scalar($value) ? trim((string)$value) : ''; } return $values; } function kalliParseDate(string $value, string $label, bool $required = false): ?DateTimeImmutable { if ($value === '') { if ($required) { throw new InvalidArgumentException($label . ' fehlt.'); } return null; } $date = DateTimeImmutable::createFromFormat('!Y-m-d', $value); $errors = DateTimeImmutable::getLastErrors(); $hasErrors = $errors !== false && ($errors['warning_count'] > 0 || $errors['error_count'] > 0); if (!$date || $hasErrors || $date->format('Y-m-d') !== $value) { throw new InvalidArgumentException($label . ' ist ungültig.'); } return $date; } function kalliParseTime(string $value, string $label): ?string { if ($value === '') { return null; } if (!preg_match('/^(?:[01]\d|2[0-3]):[0-5]\d$/D', $value)) { throw new InvalidArgumentException($label . ' ist ungültig. Bitte hh:mm verwenden.'); } return $value; } /** * Validiert einen vollständigen Formular-POST und löst die CKalender-Presets auf. */ function kalliValidateEntry(array $source, array $catalog): array { $values = kalliFormValues($source); $group = $values['group']; if ($group === '') { throw new InvalidArgumentException('Bitte wählen Sie eine Terminart aus.'); } $preset = $catalog['types_by_name'][$group] ?? null; if ($preset !== null) { $title = $preset['name']; } elseif (in_array($group, [KALLI_CUSTOM_PUBLIC, KALLI_CUSTOM_INTERNAL], true)) { if (!$catalog['allow_custom']) { throw new InvalidArgumentException('Für benutzerdefinierte Termine fehlt die Berechtigung.'); } $title = $values['userinput']; if ($title === '') { throw new InvalidArgumentException('Bitte geben Sie einen Titel für den benutzerdefinierten Termin ein.'); } $isPublic = $group === KALLI_CUSTOM_PUBLIC; $preset = [ 'planungsstatus' => 'fix', 'anzeigeziel' => $isPublic ? 'öffentlich' : 'intern', 'gruppenzuordnung' => 'AN ALLE', 'anzeigeziel_ort' => $isPublic ? 'öffentlich' : 'intern', 'anzeigeziel_notiz' => 'intern', 'anzeigeziel_verantwortlicher' => 'intern', 'default_text' => '', ]; } else { throw new InvalidArgumentException('Die gewählte Terminart ist ungültig oder nicht freigegeben.'); } $dateStart = kalliParseDate($values['date_s'], 'Das Startdatum', true); $dateEnd = kalliParseDate($values['date_e'], 'Das Enddatum'); if ($dateEnd !== null && $dateEnd < $dateStart) { throw new InvalidArgumentException('Das Enddatum darf nicht vor dem Startdatum liegen.'); } $timeStart = kalliParseTime($values['time_s'], 'Die Startzeit'); $timeEnd = kalliParseTime($values['time_e'], 'Die Endzeit'); if ($timeStart !== null && $timeEnd !== null && ($dateEnd === null || $dateEnd == $dateStart) && $timeEnd < $timeStart) { throw new InvalidArgumentException('Die Endzeit darf am selben Tag nicht vor der Startzeit liegen.'); } $text = $values['further_text']; if ($text === '' && $preset['default_text'] !== '') { $text = $preset['default_text']; } return [ 'title' => $title, 'date_start' => $dateStart->format('d.m.Y'), 'date_end' => $dateEnd?->format('d.m.Y') ?? '', 'time_start' => $timeStart ?? '', 'time_end' => $timeEnd ?? '', 'text' => $text, 'location' => $values['location'], 'responsibility' => $values['responsibility'], 'planungsstatus' => $preset['planungsstatus'], 'anzeigeziel' => $preset['anzeigeziel'], 'gruppenzuordnung' => $preset['gruppenzuordnung'], 'anzeigeziel_ort' => $preset['anzeigeziel_ort'], 'anzeigeziel_notiz' => $preset['anzeigeziel_notiz'], 'anzeigeziel_verantwortlicher' => $preset['anzeigeziel_verantwortlicher'], ]; } function kalliCalendarRow(array $entry, array $user): array { $userName = trim((string)($user['first_name'] ?? '') . ' ' . (string)($user['last_name'] ?? '')); $info = 'Von ' . $userName . ' eingetragen (Eintragungsassistent)'; return [ '', // ID '', // Lang-ID 'Einzel', // Termin-Typ $entry['date_start'], // Von-Datum $entry['time_start'], // Von-Zeit $entry['date_end'], // Bis-Datum $entry['time_end'], // Bis-Zeit '', // Zeitzusatz $entry['title'], // Titel $entry['text'], // Text $entry['location'], // Ort $entry['planungsstatus'], // Planungsstatus $entry['anzeigeziel'], // Anzeigeziel $entry['gruppenzuordnung'], // Gruppenzuordnung 'einmalig', // Zyklusart '', // Zyklusintervall '', // Zyklusdetail $info, // Notiz $entry['responsibility'], // Verantwortlich '', // Hintergrundfarbe '', // BucherID '', // Benutzerdefinierte Felder '', // Dokument-Titel $entry['anzeigeziel_ort'], $entry['anzeigeziel_notiz'], $entry['anzeigeziel_verantwortlicher'], '', // Erzeugt '', // Aktualisiert ]; } function kalliSafeFilenamePart(string $value, string $fallback): string { $safe = preg_replace('/[^\p{L}\p{N}._ -]+/u', '_', $value); $safe = trim((string)$safe, " ._-\t\n\r\0\x0B"); return $safe !== '' ? $safe : $fallback; } function kalliReserveExportPath(string $directory, array $user): array { $displayName = trim((string)($user['first_name'] ?? '') . ' ' . (string)($user['last_name'] ?? '')); $fallback = 'user-' . (string)($user['user_id'] ?? $_SESSION['user_id'] ?? 'unknown'); $safeName = kalliSafeFilenamePart($displayName, $fallback); $base = $directory . '/' . $safeName . '-' . date('Ymd-His'); for ($suffix = 1; $suffix <= 1000; $suffix++) { $target = $base . ($suffix === 1 ? '' : '-' . $suffix) . '.xlsx'; $lockPath = $target . '.lock'; $lockHandle = @fopen($lockPath, 'x'); if ($lockHandle === false) { continue; } if (file_exists($target)) { fclose($lockHandle); @unlink($lockPath); continue; } return [$target, $lockPath, $lockHandle]; } throw new RuntimeException('Kein freier Dateiname für den CKalender-Export verfügbar.'); } /** * Schreibt die XLSX zunächst unter einer Nicht-XLSX-Endung und veröffentlicht sie * anschließend per rename(). So kann ein Ordner-Watcher keine halbfertige Datei lesen. */ function kalliWriteCalendarFile(array $entry, array $user, string $directory): string { $directory = rtrim($directory, '/'); if ($directory === '') { throw new RuntimeException('CKalender-XLSX-Verzeichnis ist nicht konfiguriert.'); } if (!is_dir($directory) && !mkdir($directory, 0770, true) && !is_dir($directory)) { throw new RuntimeException('CKalender-XLSX-Verzeichnis kann nicht angelegt werden: ' . $directory); } if (!is_writable($directory)) { throw new RuntimeException('CKalender-XLSX-Verzeichnis ist nicht beschreibbar: ' . $directory); } [$target, $lockPath, $lockHandle] = kalliReserveExportPath($directory, $user); $temporary = $target . '.tmp'; $spreadsheet = new Spreadsheet(); try { $sheet = $spreadsheet->getActiveSheet(); $sheet->fromArray(KALLI_CKAL_HEADERS, null, 'A1'); $sheet->fromArray(kalliCalendarRow($entry, $user), null, 'A2'); (new Xlsx($spreadsheet))->save($temporary); if (!rename($temporary, $target)) { throw new RuntimeException('CKalender-XLSX-Datei konnte nicht veröffentlicht werden.'); } } finally { $spreadsheet->disconnectWorksheets(); if (is_file($temporary)) { @unlink($temporary); } fclose($lockHandle); @unlink($lockPath); } return $target; }