docs: capitalize iCalendar parser solution (T1)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
2a25f1c34e
commit
c493349e4a
@@ -0,0 +1,153 @@
|
||||
# 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]
|
||||
Reference in New Issue
Block a user