configRepository->findSingleton(); return null !== $config && $config->isEnabled(); } public function testConnection(): bool { $config = $this->configRepository->findSingleton(); if (null === $config || !$config->isEnabled()) { return false; } try { $response = $this->httpClient->request('PROPFIND', $this->getCalendarUrl(), [ 'timeout' => 5, 'auth_basic' => [ $config->getUsername(), $this->tokenEncryptor->decrypt((string) $config->getEncryptedPassword()), ], 'headers' => [ 'Depth' => '0', ], ]); $statusCode = $response->getStatusCode(); return $statusCode >= 200 && $statusCode < 300 || 207 === $statusCode; } catch (Throwable $e) { $this->logger->error('CalDAV connection test failed: '.$e->getMessage()); return false; } } public function createEvent(Task $task): ?string { $uid = $this->generateUid(); $calendar = $this->buildEventCalendar($task, $uid); if (!$this->makeRequest('PUT', $this->getCalendarUrl().$uid.'.ics', $calendar->serialize())) { return null; } return $uid; } public function createTodo(Task $task): ?string { $uid = $this->generateUid(); $calendar = $this->buildTodoCalendar($task, $uid); if (!$this->makeRequest('PUT', $this->getCalendarUrl().$uid.'.ics', $calendar->serialize())) { return null; } return $uid; } public function updateEvent(Task $task): bool { $uid = $task->getCalendarEventUid(); if (null === $uid) { return false; } $calendar = $this->buildEventCalendar($task, $uid); return $this->makeRequest('PUT', $this->getCalendarUrl().$uid.'.ics', $calendar->serialize()); } public function updateTodo(Task $task): bool { $uid = $task->getCalendarTodoUid(); if (null === $uid) { return false; } $calendar = $this->buildTodoCalendar($task, $uid); return $this->makeRequest('PUT', $this->getCalendarUrl().$uid.'.ics', $calendar->serialize()); } public function deleteEvent(?string $uid): bool { if (null === $uid) { return true; } return $this->makeRequest('DELETE', $this->getCalendarUrl().$uid.'.ics'); } public function deleteTodo(?string $uid): bool { if (null === $uid) { return true; } return $this->makeRequest('DELETE', $this->getCalendarUrl().$uid.'.ics'); } public function syncTask(Task $task): void { if (!$task->isSyncToCalendar()) { $this->deleteEvent($task->getCalendarEventUid()); $this->deleteTodo($task->getCalendarTodoUid()); $task->setCalendarEventUid(null); $task->setCalendarTodoUid(null); $task->setCalendarSyncError(null); return; } $hasStart = null !== $task->getScheduledStart(); $hasDeadline = null !== $task->getDeadline(); if (!$hasStart && !$hasDeadline) { return; } $syncError = null; if ($hasStart) { if (null !== $task->getCalendarEventUid()) { $success = $this->updateEvent($task); } else { $uid = $this->createEvent($task); if (null !== $uid) { $task->setCalendarEventUid($uid); $success = true; } else { $success = false; } } if (!$success) { $syncError = 'Failed to sync event to calendar.'; } } elseif (null !== $task->getCalendarEventUid()) { $this->deleteEvent($task->getCalendarEventUid()); $task->setCalendarEventUid(null); } if ($hasDeadline) { if (null !== $task->getCalendarTodoUid()) { $success = $this->updateTodo($task); } else { $uid = $this->createTodo($task); if (null !== $uid) { $task->setCalendarTodoUid($uid); $success = true; } else { $success = false; } } if (!$success) { $syncError = ($syncError ?? '').'Failed to sync todo to calendar.'; } } elseif (null !== $task->getCalendarTodoUid()) { $this->deleteTodo($task->getCalendarTodoUid()); $task->setCalendarTodoUid(null); } $task->setCalendarSyncError($syncError); } private function buildEventCalendar(Task $task, string $uid): VCalendar { $project = $task->getProject(); $projectCode = null !== $project ? $project->getCode() : ''; $summary = sprintf('[%s-%s] %s', $projectCode, $task->getNumber(), $task->getTitle()); $description = ($task->getDescription() ?? '')."\n\nLesstime task"; $vcalendar = new VCalendar(); $vcalendar->add('VEVENT', [ 'UID' => $uid, 'SUMMARY' => $summary, 'DTSTART' => $task->getScheduledStart(), 'DTEND' => $task->getScheduledEnd(), 'DESCRIPTION' => $description, ]); $recurrence = $task->getRecurrence(); if (null !== $recurrence) { $vevent = $vcalendar->VEVENT; $vevent->add('RRULE', $this->buildRRule($recurrence)); } return $vcalendar; } private function buildTodoCalendar(Task $task, string $uid): VCalendar { $project = $task->getProject(); $projectCode = null !== $project ? $project->getCode() : ''; $summary = sprintf('[%s-%s] %s (deadline)', $projectCode, $task->getNumber(), $task->getTitle()); $description = ($task->getDescription() ?? '')."\n\nLesstime task"; $vcalendar = new VCalendar(); $vcalendar->add('VTODO', [ 'UID' => $uid, 'SUMMARY' => $summary, 'DUE' => $task->getDeadline(), 'DESCRIPTION' => $description, ]); return $vcalendar; } private function buildRRule(TaskRecurrence $recurrence): string { $parts = []; $interval = $recurrence->getInterval(); match ($recurrence->getType()) { RecurrenceType::Daily => $parts[] = 'FREQ=DAILY;INTERVAL='.$interval, RecurrenceType::Weekly => (function () use (&$parts, $interval, $recurrence): void { $dayMap = $this->getDayMap(); $daysOfWeek = $recurrence->getDaysOfWeek() ?? []; $byDay = implode(',', array_map(fn (string $d) => $dayMap[$d] ?? $d, $daysOfWeek)); $rule = 'FREQ=WEEKLY;INTERVAL='.$interval; if ('' !== $byDay) { $rule .= ';BYDAY='.$byDay; } $parts[] = $rule; })(), RecurrenceType::Monthly => (function () use (&$parts, $interval, $recurrence): void { $dayOfMonth = $recurrence->getDayOfMonth(); $weekOfMonth = $recurrence->getWeekOfMonth(); $daysOfWeek = $recurrence->getDaysOfWeek() ?? []; if (null !== $dayOfMonth) { $parts[] = 'FREQ=MONTHLY;INTERVAL='.$interval.';BYMONTHDAY='.$dayOfMonth; } elseif (null !== $weekOfMonth && [] !== $daysOfWeek) { $dayMap = $this->getDayMap(); $day = $dayMap[$daysOfWeek[0]] ?? $daysOfWeek[0]; $parts[] = 'FREQ=MONTHLY;INTERVAL='.$interval.';BYDAY='.$weekOfMonth.$day; } else { $parts[] = 'FREQ=MONTHLY;INTERVAL='.$interval; } })(), RecurrenceType::Yearly => $parts[] = 'FREQ=YEARLY;INTERVAL='.$interval, default => $parts[] = 'FREQ=DAILY;INTERVAL='.$interval, }; $rule = $parts[0] ?? 'FREQ=DAILY;INTERVAL=1'; $endDate = $recurrence->getEndDate(); $maxOccurrences = $recurrence->getMaxOccurrences(); if (null !== $endDate) { $rule .= ';UNTIL='.$endDate->setTimezone(new DateTimeZone('UTC'))->format('Ymd\THis\Z'); } elseif (null !== $maxOccurrences) { $rule .= ';COUNT='.$maxOccurrences; } return $rule; } private function getCalendarUrl(): string { $config = $this->configRepository->findSingleton(); if (null === $config) { return ''; } return rtrim((string) $config->getServerUrl(), '/').'/'.ltrim((string) $config->getCalendarPath(), '/').'/'; } private function makeRequest(string $method, string $url, ?string $body = null, string $contentType = 'text/calendar'): bool { $config = $this->configRepository->findSingleton(); if (null === $config) { return false; } try { $options = [ 'timeout' => 5, 'auth_basic' => [ $config->getUsername(), $this->tokenEncryptor->decrypt((string) $config->getEncryptedPassword()), ], ]; if (null !== $body) { $options['headers'] = ['Content-Type' => $contentType]; $options['body'] = $body; } $response = $this->httpClient->request($method, $url, $options); $statusCode = $response->getStatusCode(); return $statusCode >= 200 && $statusCode < 300 || 207 === $statusCode; } catch (Throwable $e) { $this->logger->error(sprintf('CalDAV %s request to %s failed: %s', $method, $url, $e->getMessage())); return false; } } private function generateUid(): string { return sprintf('%s@lesstime', bin2hex(random_bytes(16))); } /** @return array */ private function getDayMap(): array { return [ 'monday' => 'MO', 'tuesday' => 'TU', 'wednesday' => 'WE', 'thursday' => 'TH', 'friday' => 'FR', 'saturday' => 'SA', 'sunday' => 'SU', ]; } }