- 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>
279 lines
12 KiB
Markdown
279 lines
12 KiB
Markdown
# 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
|
|
|
|
- `scheduledEnd` requiert `scheduledStart` (et vice versa) — les deux ou aucun
|
|
- `scheduledEnd` doit être après `scheduledStart`
|
|
- `syncToCalendar = true` sans aucune date → ignoré silencieusement (pas de sync)
|
|
- `deadline` est 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` → `ManyToOne` vers `TaskRecurrence` (nullable)
|
|
- `TaskRecurrence.tasks` → `OneToMany` vers `Task`
|
|
|
|
### 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'UID
|
|
- `createTodo(Task): string` — crée un VTODO (deadline), retourne l'UID
|
|
- `updateEvent(Task): void` — met à jour le VEVENT existant
|
|
- `updateTodo(Task): void` — met à jour le VTODO existant
|
|
- `deleteEvent(string $uid): void` — supprime le VEVENT par UID
|
|
- `deleteTodo(string $uid): void` — supprime le VTODO par UID
|
|
- `testConnection(): 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 `null` si la création a échoué
|
|
- En cas de succès après un échec précédent, `calendarSyncError` est remis à `null`
|
|
|
|
## Tâches récurrentes
|
|
|
|
### Comportement
|
|
|
|
1. L'utilisateur crée une tâche avec récurrence dans Lesstime
|
|
2. **Zimbra** : un seul VEVENT avec `RRULE` est créé — Zimbra génère toutes les occurrences dans le calendrier automatiquement
|
|
3. **Lesstime** : une seule tâche existe à la fois
|
|
4. 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 `calendarEventUid` et `calendarTodoUid` de 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 `number` généré via `findMaxNumberByProjectForUpdate` (même pattern transactionnel que `TaskNumberProcessor`)
|
|
- 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)
|
|
- `calendarEventUid` pointant vers le même VEVENT récurrent
|
|
- Nouveau `calendarTodoUid` (nouvelle deadline)
|
|
- `occurrenceCount` incrémenté sur `TaskRecurrence` (avec lock optimiste `@ORM\Version` pour éviter les doublons en cas de concurrence)
|
|
5. Si `maxOccurrences` ou `endDate` atteint, 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 de `scheduledStart + interval semaines`
|
|
- **Monthly** : même `dayOfMonth` ou même `weekOfMonth`+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-local` picker)
|
|
- Date planifiée fin (`datetime-local` picker)
|
|
- Deadline (`date` picker)
|
|
|
|
#### 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 `syncToCalendar` activé
|
|
- 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 optionnels
|
|
- `delete-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 :
|
|
- `DateFilter` sur `scheduledStart`, `scheduledEnd`, `deadline` (pour le tri et filtrage par plage de dates)
|
|
- `BooleanFilter` sur `syncToCalendar`
|
|
- `OrderFilter` sur `scheduledStart`, `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.
|