Avec MalioInputRichText qui émet désormais du HTML par défaut, plusieurs points d'affichage rendaient les balises brutes au lieu du texte. Ajoute un helper stripRichText() (frontend) et descriptionToPlainText() (backend) pour neutraliser ces cas. - TimeEntryList : strip avant truncate dans la liste des time entries. - ProjectGroupTab : strip dans la cellule description du tableau des groupes. - CalDavService : strip_tags + html_entity_decode avant injection dans le DESCRIPTION VEVENT/VTODO iCal (sinon Outlook/Apple Calendar affichaient les <p>...</p> à l'utilisateur). Co-Authored-By: RuFlo <ruv@ruv.net>
369 lines
12 KiB
PHP
369 lines
12 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;
|
|
|
|
use const ENT_HTML5;
|
|
use const ENT_QUOTES;
|
|
|
|
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 = $this->descriptionToPlainText($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 = $this->descriptionToPlainText($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)));
|
|
}
|
|
|
|
private function descriptionToPlainText(?string $value): string
|
|
{
|
|
if (null === $value || '' === $value) {
|
|
return '';
|
|
}
|
|
|
|
$stripped = strip_tags($value);
|
|
$decoded = html_entity_decode($stripped, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
|
|
return trim((string) preg_replace('/[ \t]+/', ' ', $decoded));
|
|
}
|
|
|
|
/** @return array<string, string> */
|
|
private function getDayMap(): array
|
|
{
|
|
return [
|
|
'monday' => 'MO',
|
|
'tuesday' => 'TU',
|
|
'wednesday' => 'WE',
|
|
'thursday' => 'TH',
|
|
'friday' => 'FR',
|
|
'saturday' => 'SA',
|
|
'sunday' => 'SU',
|
|
];
|
|
}
|
|
}
|