354 lines
11 KiB
PHP
354 lines
11 KiB
PHP
<?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',
|
|
];
|
|
}
|
|
}
|