feat : add CalDavService for Zimbra CalDAV sync
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
353
src/Service/CalDavService.php
Normal file
353
src/Service/CalDavService.php
Normal file
@@ -0,0 +1,353 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Service;
|
||||
|
||||
use App\Entity\Task;
|
||||
use App\Entity\TaskRecurrence;
|
||||
use App\Enum\RecurrenceType;
|
||||
use App\Repository\ZimbraConfigurationRepository;
|
||||
use DateTimeZone;
|
||||
use Psr\Log\LoggerInterface;
|
||||
use Sabre\VObject\Component\VCalendar;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
use Throwable;
|
||||
|
||||
final class CalDavService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ZimbraConfigurationRepository $configRepository,
|
||||
private readonly TokenEncryptor $tokenEncryptor,
|
||||
private readonly HttpClientInterface $httpClient,
|
||||
private readonly LoggerInterface $logger,
|
||||
) {}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
$config = $this->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<string, string> */
|
||||
private function getDayMap(): array
|
||||
{
|
||||
return [
|
||||
'monday' => 'MO',
|
||||
'tuesday' => 'TU',
|
||||
'wednesday' => 'WE',
|
||||
'thursday' => 'TH',
|
||||
'friday' => 'FR',
|
||||
'saturday' => 'SA',
|
||||
'sunday' => 'SU',
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user