Files
intranet/web/intranet_repo/projects/kalli/util/kalli_helpers.php
T

406 lines
13 KiB
PHP

<?php
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheet\Writer\Xlsx;
const KALLI_CKAL_HEADERS = [
'ID',
'Lang-ID',
'Termin-Typ',
'Von-Datum',
'Von-Zeit',
'Bis-Datum',
'Bis-Zeit',
'Zeitzusatz',
'Titel',
'Text',
'Ort',
'Planungsstatus',
'Anzeigeziel',
'Gruppenzuordnung',
'Zyklusart',
'Zyklusintervall',
'Zyklusdetail',
'Notiz',
'Verantwortlich',
'Hintergrundfarbe',
'BucherID',
'Benutzerdefinierte Felder',
'Dokument-Titel',
'Anzeigeziel Ort',
'Anzeigeziel Notiz',
'Anzeigeziel Verantwortlicher',
'Erzeugt',
'Aktualisiert',
];
const KALLI_CUSTOM_PUBLIC = 'oeffentlich';
const KALLI_CUSTOM_INTERNAL = 'intern';
function kalliHtml($value): string
{
return htmlspecialchars((string)$value, ENT_QUOTES, 'UTF-8');
}
function kalliPermissionEnabled($value): bool
{
return $value === true || $value === 1 || $value === '1' || $value === 't' || $value === 'true';
}
function kalliCsrfToken(): string
{
if (empty($_SESSION['kalli_csrf'])) {
$_SESSION['kalli_csrf'] = bin2hex(random_bytes(32));
}
return $_SESSION['kalli_csrf'];
}
function kalliCsrfIsValid($postedToken): bool
{
$sessionToken = $_SESSION['kalli_csrf'] ?? '';
return is_string($postedToken)
&& is_string($sessionToken)
&& $sessionToken !== ''
&& hash_equals($sessionToken, $postedToken);
}
/**
* Lädt Kategorien und Terminarten in einer Abfrage. Die Berechtigungsfilterung
* erfolgt anschließend zentral in kalliBuildCatalog().
*/
function kalliFetchCatalogRows(PDO $pdo): array
{
$sql = <<<'SQL'
SELECT
c.id AS category_id,
c.name AS category_name,
c.permission_role,
t.id AS type_id,
t.name AS type_name,
t.display_label,
t.planungsstatus,
t.anzeigeziel,
t.gruppenzuordnung,
t.anzeigeziel_ort,
t.anzeigeziel_notiz,
t.anzeigeziel_verantwortlicher,
t.default_text
FROM kalli_categories c
LEFT JOIN kalli_appointment_types t ON t.category_id = c.id
ORDER BY c.sort_order, c.name, t.sort_order, t.name
SQL;
return $pdo->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;
}