Files
Lesstime/src/Service/CalDavService.php
matthieu b2cc6e96e1 fix(rich-text) : strip HTML pour les contextes plain-text
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>
2026-05-04 19:55:23 +02:00

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',
];
}
}