- Use TokenEncryptor for password (align with GiteaConfiguration) - Replace Entity Listener with API Platform Processor for CalDAV sync - Add calendarSyncError field for persistent error tracking - Add validation rules for date fields - Fix ICS format (VCALENDAR wrapper, UTC timezone) - Add task number generation for recurring task auto-creation - Add optimistic locking on TaskRecurrence - Clear calendar UIDs on archived tasks - Add API filters for date fields - Document i18n for daysOfWeek - Clarify MCP tool behavior and known limitations Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
12 KiB
Intégration Calendrier Zimbra CalDAV
Date : 2026-03-19 Statut : Validé
Objectif
Permettre de synchroniser les tâches Lesstime vers un calendrier Zimbra OVH via CalDAV. Sync one-way (push uniquement), avec support des tâches récurrentes.
Principes
- Push uniquement : Lesstime pousse vers Zimbra, ne récupère jamais les événements existants
- Opt-in : les tâches ne sont pas envoyées au calendrier par défaut (checkbox décochée)
- Sync synchrone : les appels CalDAV se font au moment de l'action, timeout 5s
- Configuration globale : un seul compte Zimbra admin pour toute l'instance
- Calendrier d'équipe : toutes les tâches sync vont dans le même calendrier
Modèle de données
Nouveaux champs sur Task
| Champ | Type | Nullable | Default | Description |
|---|---|---|---|---|
scheduledStart |
DateTimeImmutable |
oui | null |
Début du créneau planifié |
scheduledEnd |
DateTimeImmutable |
oui | null |
Fin du créneau planifié |
deadline |
DateTimeImmutable |
oui | null |
Date d'échéance |
syncToCalendar |
bool |
non | false |
Opt-in pour la sync Zimbra |
calendarEventUid |
string |
oui | null |
UID du VEVENT dans Zimbra |
calendarTodoUid |
string |
oui | null |
UID du VTODO dans Zimbra |
calendarSyncError |
string |
oui | null |
Dernière erreur de sync CalDAV (null = OK) |
Règles de validation
scheduledEndrequiertscheduledStart(et vice versa) — les deux ou aucunscheduledEnddoit être aprèsscheduledStartsyncToCalendar = truesans aucune date → ignoré silencieusement (pas de sync)deadlineest indépendant des dates planifiées (peut exister seul)
Nouvelle entité TaskRecurrence
| Champ | Type | Nullable | Description |
|---|---|---|---|
id |
int |
non | PK auto-increment |
type |
RecurrenceType (PHP enum) |
non | Enum backed string : daily, weekly, monthly, yearly |
interval |
int |
non | Tous les X (jours/semaines/mois/ans) |
daysOfWeek |
json |
oui | Jours de la semaine pour hebdo, ex: ["monday","wednesday"] |
dayOfMonth |
int |
oui | Jour du mois pour mensuel, ex: 15 |
weekOfMonth |
int |
oui | Semaine du mois, ex: 1 pour "le 1er X du mois" |
endDate |
Date |
oui | Fin de la récurrence (null = infini) |
maxOccurrences |
int |
oui | Nombre max d'occurrences (alternatif à endDate) |
occurrenceCount |
int |
non | Compteur d'occurrences créées (default 0) |
Relations
Task.recurrence→ManyToOneversTaskRecurrence(nullable)TaskRecurrence.tasks→OneToManyversTask
Nouvelle entité ZimbraConfiguration
| Champ | Type | Nullable | Description |
|---|---|---|---|
id |
int |
non | PK auto-increment |
serverUrl |
string |
non | URL CalDAV Zimbra |
username |
string |
non | Compte Zimbra |
encryptedPassword |
string |
non | Mot de passe chiffré via TokenEncryptor (même pattern que GiteaConfiguration) |
calendarPath |
string |
non | Chemin complet du calendrier, ex: /dav/user@domain.com/Calendar/ |
enabled |
bool |
non | Activer/désactiver la sync (default false) |
Service CalDAV
CalDavService
Dépendances : sabre/vobject pour la génération ICS, requêtes HTTP via Symfony\Contracts\HttpClient.
Le service utilise la ZimbraConfiguration pour construire l'URL CalDAV complète : {serverUrl}{calendarPath}{uid}.ics. Le mot de passe est déchiffré via TokenEncryptor avant chaque requête. L'authentification CalDAV se fait via HTTP Basic Auth.
Méthodes
createEvent(Task): string— crée un VEVENT (créneau planifié), retourne l'UIDcreateTodo(Task): string— crée un VTODO (deadline), retourne l'UIDupdateEvent(Task): void— met à jour le VEVENT existantupdateTodo(Task): void— met à jour le VTODO existantdeleteEvent(string $uid): void— supprime le VEVENT par UIDdeleteTodo(string $uid): void— supprime le VTODO par UIDtestConnection(): bool— teste la connexion CalDAV
Format ICS
Toutes les dates sont envoyées en UTC (suffixe Z). Les composants sont wrappés dans un document iCalendar complet :
VEVENT (créneau planifié) :
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Lesstime//CalDAV//EN
BEGIN:VEVENT
UID:{calendarEventUid}
SUMMARY:[PROJET-NUM] Titre de la tâche
DTSTART:{scheduledStart en UTC, format 20260319T140000Z}
DTEND:{scheduledEnd en UTC}
DESCRIPTION:{description}\n\nLesstime: {url}
RRULE:{rrule si récurrence}
END:VEVENT
END:VCALENDAR
VTODO (deadline) :
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//Lesstime//CalDAV//EN
BEGIN:VTODO
UID:{calendarTodoUid}
SUMMARY:[PROJET-NUM] Titre de la tâche (deadline)
DUE:{deadline en UTC}
DESCRIPTION:{description}\n\nLesstime: {url}
END:VTODO
END:VCALENDAR
Pas de RRULE sur le VTODO — il suit la tâche courante uniquement.
Logique de sync
Déclenchement
Un API Platform State Processor (TaskCalendarProcessor) qui décore le persist/remove processor. La sync CalDAV est appelée après le flush en BDD, jamais pendant la transaction. Cela garantit :
- La tâche est sauvegardée même si Zimbra est down
- Pas de blocage de transaction DB par les appels HTTP
Pour les MCP tools, le CalDavService doit être appelé explicitement après le flush() dans chaque tool qui modifie les champs liés au calendrier (create-task, update-task, delete-task).
Matrice d'actions
| Action Lesstime | Effet CalDAV |
|---|---|
Tâche créée/modifiée avec syncToCalendar=true et dates renseignées |
Crée ou met à jour VEVENT + VTODO |
syncToCalendar décoché |
Supprime VEVENT + VTODO si existants |
| Tâche supprimée | Supprime VEVENT + VTODO si existants |
Tâche récurrente passe en isFinal |
Tâche archivée (archived=true), événements conservés dans Zimbra. Nouvelle tâche créée pointant vers le même VEVENT récurrent |
| Dates retirées | Supprime les events correspondants |
Gestion des erreurs
- Timeout CalDAV : 5 secondes
- En cas d'échec : la tâche est quand même sauvegardée en BDD, un toast d'erreur est affiché côté frontend
- L'erreur est persistée dans
calendarSyncError(visible dans l'UI comme indicateur rouge) - Les UIDs CalDAV restent
nullsi la création a échoué - En cas de succès après un échec précédent,
calendarSyncErrorest remis ànull
Tâches récurrentes
Comportement
- L'utilisateur crée une tâche avec récurrence dans Lesstime
- Zimbra : un seul VEVENT avec
RRULEest créé — Zimbra génère toutes les occurrences dans le calendrier automatiquement - Lesstime : une seule tâche existe à la fois
- Quand la tâche passe en statut
isFinal:- La tâche est archivée automatiquement (
archived = true) - Les événements Zimbra sont conservés (historique)
- Les
calendarEventUidetcalendarTodoUidde la tâche archivée sont vidés (null) pour éviter toute modification accidentelle de l'événement Zimbra depuis une tâche archivée - Une nouvelle tâche est créée avec :
- Même titre, description, assigné, tags, projet, groupe, effort, priorité
- Nouveau
numbergénéré viafindMaxNumberByProjectForUpdate(même pattern transactionnel queTaskNumberProcessor) - Statut réinitialisé au premier statut (position la plus basse)
- Dates recalculées selon le pattern de récurrence (prochaine date selon le pattern, indépendamment de quand la tâche a été terminée)
calendarEventUidpointant vers le même VEVENT récurrent- Nouveau
calendarTodoUid(nouvelle deadline) occurrenceCountincrémenté surTaskRecurrence(avec lock optimiste@ORM\Versionpour éviter les doublons en cas de concurrence)
- La tâche est archivée automatiquement (
- Si
maxOccurrencesouendDateatteint, la récurrence s'arrête (pas de nouvelle tâche créée)
Calcul de la prochaine date
La prochaine date est calculée à partir de la date planifiée de la tâche courante (pas de la date de complétion) :
- Daily :
scheduledStart + interval jours - Weekly : prochain jour de
daysOfWeekà partir descheduledStart + interval semaines - Monthly : même
dayOfMonthou mêmeweekOfMonth+jour, mois+ interval - Yearly : même date, année
+ interval
La durée du créneau (scheduledEnd - scheduledStart) est conservée.
Frontend
Onglet "Planification" dans TaskModal
La modale tâche existante aura 2 onglets :
Onglet "Détails" (existant) : titre, description, statut, priorité, effort, assigné, tags, groupe
Onglet "Planification" (nouveau) :
Bloc Dates
- Date planifiée début (
datetime-localpicker) - Date planifiée fin (
datetime-localpicker) - Deadline (
datepicker)
Bloc Calendrier
- Checkbox "Envoyer au calendrier" (décoché par défaut)
- Indicateur de statut sync (icône verte si sync OK, rouge si erreur, gris si non configuré)
Bloc Récurrence
- Toggle "Tâche récurrente"
- Si activé :
- Type : Quotidien / Hebdomadaire / Mensuel / Annuel (select)
- Intervalle : "Tous les X ..." (input number)
- Conditionnel selon le type :
- Hebdomadaire → checkboxes jours de la semaine (Lu, Ma, Me, Je, Ve, Sa, Di)
- Mensuel → radio "Le X du mois" (input) ou "Le Xème [jour] du mois" (2 selects)
- Fin de récurrence : radio Jamais / Après X occurrences (input) / À une date (date picker)
Affichage des dates
Cartes Kanban (TaskCard) :
- Badge deadline coloré : rouge si dépassée, orange si < 2 jours, gris sinon
- Icône calendrier si
syncToCalendaractivé - Icône récurrence si tâche récurrente
Vue liste (TaskListItem) :
- Colonne "Planifié" (date début)
- Colonne "Deadline"
- Icône récurrence si tâche récurrente
Page "Mes tâches" :
- Même affichage que la vue liste
- Tri possible par deadline ou date planifiée
Page Admin — Configuration Zimbra
Nouveau bloc dans la page admin existante :
- URL du serveur CalDAV (input text)
- Nom d'utilisateur (input text)
- Mot de passe (input password)
- Chemin du calendrier (input text)
- Toggle activer/désactiver
- Bouton "Tester la connexion" (toast succès/erreur)
Accessible uniquement ROLE_ADMIN.
MCP Tools
Mise à jour des tools existants
create-task et update-task : nouveaux paramètres optionnels :
scheduledStart(string datetime ISO)scheduledEnd(string datetime ISO)deadline(string datetime ISO)syncToCalendar(bool)
Nouveaux tools
create-task-recurrence— paramètres : taskId, type, interval, daysOfWeek?, dayOfMonth?, weekOfMonth?, endDate?, maxOccurrences?update-task-recurrence— paramètres : recurrenceId, + mêmes champs optionnelsdelete-task-recurrence— paramètres : recurrenceId — supprime la récurrence, nullifie la relation sur la tâche active, et supprime l'événement récurrent Zimbra si existant
API Filters
Ajouter sur Task les filtres API Platform suivants :
DateFiltersurscheduledStart,scheduledEnd,deadline(pour le tri et filtrage par plage de dates)BooleanFiltersursyncToCalendarOrderFiltersurscheduledStart,deadline
Valeurs stockées en JSON (i18n)
Les daysOfWeek dans TaskRecurrence sont stockés en anglais (monday, tuesday...) — les labels traduits sont gérés uniquement côté frontend via i18n.
Dépendances PHP
sabre/vobject— génération/parsing ICS (VEVENT, VTODO, RRULE)symfony/http-client— requêtes HTTP CalDAV (PUT, DELETE, PROPFIND)
Limitations connues
- Sync synchrone : si Zimbra est lent, chaque sauvegarde de tâche peut prendre jusqu'à 5s. Migration vers Symfony Messenger possible à l'avenir si nécessaire.
- Pas de sync bidirectionnelle : les modifications faites directement dans Zimbra ne sont pas reflétées dans Lesstime.