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

8.0 KiB

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) :

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é :

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é) :

{
  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) :

vevent.getAllProperties("categories").flatMap((prop) => prop.getValues());

Filtre futur inclusif au minuit local (aujourd'hui inclus) :

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]