Files
echo-du-huit/docs/solutions/2026-06-30-fetch-parse-le-flux-ics-oea8-solution.md
2026-06-30 01:26:32 +02:00

154 lines
8.0 KiB
Markdown

# Solution : [T1] Parser les événements iCalendar (version minimale)
## Problème résolu
Transformer le flux iCalendar **brut** (déjà récupéré par `nextcloud.js` en T1
précédente) en une liste d'objets `Event` exploitables par tout l'aval de
l'extension (liste, pré-remplissage du formulaire mairie, statut CalDAV en T2).
Enjeu central : **cette tâche FIGE la forme de l'objet `Event`**. Le coût d'un
mauvais contrat n'est pas dans T1, il est dans la refonte de la liste + du form +
du statut le jour où T3 voudra ajouter la robustesse. La **forme prime sur le
choix du parseur**.
Frontière T1 tenue : on parse, on dédoublonne, on filtre le futur, on lit
`CATEGORIES`. La robustesse (expansion `RRULE`, heure exigée pour les journées
entières, durcissement des entrées dégénérées) est **différée à T3**, « traitée
au plus simple » et **documentée**.
## Approche choisie
**Approche 3 du brainstorm** : `ical.js` (Mozilla) **vendoré en `.js`** + forme
`Event` figée qui **anticipe T3** en portant dès maintenant le drapeau
`journeeEntiere`. T1 *remplit* le contrat ; le *comportement* des cas limites
reste différé.
Pourquoi pas les alternatives :
- **Parseur maison (regex)** : iCalendar est piégeux (line-folding à 75 octets,
échappements `\,` `\n` `\;`, `TZID`, `VALUE=DATE`). Le réinventer = dette, et
T3 (RRULE, fuseaux) deviendrait un enfer maison. `RECHERCHE.md` le déconseille
explicitement.
- **`ical.js` + forme minimale stricte (approche 2)** : la forme n'exprimerait
pas la journée entière → T3 devrait **rouvrir le contrat figé**, exactement le
coût que T1 doit éviter. L'ajout d'un seul booléen rend le contrat durable pour
un coût marginal.
Contrainte structurante respectée : **pas de build, pas de runtime**. `ical.js`
v2.2.1 est vendoré tel quel (build ESM, export *default* = namespace `ICAL`),
importé par une page propre de l'extension (`type=module`) — donc aucun
changement de `manifest.json` (pas de `web_accessible_resources`).
## Décisions clés
- **Contrat `Event` figé** (ne PAS rouvrir sans accord) :
`{ uid, titre, description, lieu, debut: Date, fin: Date, categories: string[],
journeeEntiere: boolean }`. Champs texte coercés en `""` si absents (l'aval
suppose des chaînes, jamais `null`). Le défaut « café » du lieu est appliqué
**en aval**, pas dans le parseur.
- **`uid` seul, pas de `href`/`ETag`.** Le transport actuel (`?export`) renvoie
un ICS concaténé sans `href`/`ETag` par événement. L'`UID` est la clé stable ;
T2 résoudra l'adressage CalDAV par UID au moment du PUT. Le contrat n'est pas
alourdi d'une préoccupation de transport.
- **Fonction pure à dépendance injectée** : `parserEvenements(flux, aujourdhui)`.
`aujourdhui` est **obligatoire** (pas de défaut) → tests déterministes du
filtre futur. Convention du repo « params obligatoires par défaut ».
- **Enregistrement des VTIMEZONE embarqués** avant toute conversion de date —
**non négociable** : sans lui, `toJSDate()` calcule un mauvais offset pour les
`TZID` non standards (prouvé par le test Africa/Lagos vs Europe/Paris).
- **Passer par `ICAL.Event`** (pas lire `DTEND` en brut) : il dérive `endDate`
depuis `DURATION` ou `DTSTART` quand `DTEND` est absent, et expose `startDate.isDate`
pour la journée entière.
- **Cas limites « au plus simple », différés à T3** : pas d'expansion `RRULE`
(récurrents pris au `DTSTART` maître + filtre futur → un récurrent au maître
**passé** disparaît, **trou assumé et documenté**) ; exceptions d'occurrence
(`RECURRENCE-ID`) ignorées via `isRecurrenceException()` ; journées entières au
minuit local.
- **Tri & message « 0 événement futur » hors parseur** : le parseur renvoie les
événements **dans l'ordre du flux**, non triés (responsabilité de la liste).
- **Périmètre : parseur NON câblé en prod en T1.** `liste.js` reste sur
`compterEvenements` (preuve de vie). Le câblage UI appartient à la tâche
d'affichage — éviter un demi-câblage jetable et un changement d'UX silencieux.
Conséquence : changement **purement additif**, rollback trivial.
## Patterns à réutiliser
**Enregistrement des fuseaux embarqués avant conversion de dates** (le piège
n°1 du parsing iCalendar multi-fuseaux) :
```js
composant.getAllSubcomponents("vtimezone").forEach((vt) => {
const tzid = vt.getFirstPropertyValue("tzid");
if (tzid && !ICAL.TimezoneService.has(tzid)) {
ICAL.TimezoneService.register(vt); // idempotent (garde !has)
}
});
```
**Dédup par UID en un seul passage O(n), maître conservé** :
```js
const parUid = new Map();
for (const vevent of composant.getAllSubcomponents("vevent")) {
const ev = new ICAL.Event(vevent);
if (ev.isRecurrenceException()) continue; // RECURRENCE-ID non développés en T1
if (parUid.has(ev.uid)) continue; // garde le premier rencontré
parUid.set(ev.uid, toEvent(ev, vevent));
}
```
**Mapping robuste via `ICAL.Event`** (coercition `null → ""`, `endDate` dérivé) :
```js
{
uid: ev.uid,
titre: ev.summary ?? "",
description: ev.description ?? "",
lieu: ev.location ?? "",
debut: ev.startDate.toJSDate(),
fin: ev.endDate.toJSDate(), // gère DTEND absent / DURATION
journeeEntiere: ev.startDate.isDate === true,
}
```
**Lecture `CATEGORIES` tolérante aux deux formes** (valeurs séparées par virgule
ET/OU propriétés répétées) :
```js
vevent.getAllProperties("categories").flatMap((prop) => prop.getValues());
```
**Filtre futur inclusif au minuit local** (aujourd'hui inclus) :
```js
const seuil = new Date(aujourdhui); seuil.setHours(0, 0, 0, 0);
return [...parUid.values()].filter((e) => e.debut.getTime() >= seuil.getTime());
```
**Tests Bun avec fixtures ICS inline** (jamais l'export réel = données perso des
bénévoles) : helpers `vcalendar(...)` / `vevent(...)` qui joignent les lignes en
`\r\n` (CRLF de la RFC 5545), `aujourdhui` figé pour le déterminisme. Un
`VTIMEZONE` Paris et un Lagos inline prouvent l'enregistrement des fuseaux.
## Pièges à éviter
- **VTIMEZONE non enregistré → offset faux.** Le piège central : `toJSDate()`
produit silencieusement une mauvaise heure. Toujours enregistrer les VTIMEZONE
embarqués avant conversion. Couvert par le test Africa/Lagos (UTC+1) ≠
Europe/Paris (UTC+2 l'été).
- **Lire `DTEND` en brut** : il est souvent absent. Passer par
`ICAL.Event.endDate` qui le dérive (durée / start), sinon plantage.
- **`summary`/`description`/`location` peuvent renvoyer `null`** : toujours
coercer en `""` — l'aval (form, liste) suppose des chaînes.
- **Récurrents au DTSTART maître passé disparaissent** (pas d'expansion RRULE en
T1). **Trou produit assumé et documenté**, comblé en T3. Or les récurrents
(atelier hebdo, permanence) sont souvent ceux qu'on veut publier → priorité T3.
- **Pureté nuancée par un état global** (signalé P3 en review) :
`TimezoneService.register` mute un registre **global** partagé. Idempotent et
bénin, mais si deux flux portent le même `TZID` avec des définitions
différentes, **la première enregistrée gagne** pour la session. Le commentaire
le note ; ne pas qualifier la fonction de « pure » sans nuance.
- **Entrées dégénérées non durcies en T1** (constats Inzaghi, hors périmètre,
pas des anomalies) : flux chaîne vide `""` ou non-ICS → `ICAL.parse` lève ;
VEVENT sans `DTSTART` → lève ; VEVENT sans `UID` → clé `null` (collision). Le
transport renvoie toujours un VCALENDAR valide ; à durcir en T3 si besoin.
- **Ne pas committer l'export réel du calendrier** : données personnelles des
bénévoles. Fixtures inline uniquement.
- **Ne pas câbler le parseur dans `liste.js` en T1** : tentation de « finir le
travail », mais le rendu des cartes appartient à la tâche d'affichage. Un
demi-câblage serait jetable et changerait l'UX en douce.
## Tags
tags: [firefox-extension, manifest-v3, webextension, vanilla-js, ical, ical.js,
icalendar, caldav, nextcloud, vendoring, esm, timezone, vtimezone, rrule,
recurrence, pure-function, dependency-injection, bun-test, contract-design, low-tech]