diff --git a/src/Service/CalDavService.php b/src/Service/CalDavService.php new file mode 100644 index 0000000..bcaf3a0 --- /dev/null +++ b/src/Service/CalDavService.php @@ -0,0 +1,353 @@ +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', + ]; + } +}