feat: parse iCalendar feed into Event[] (T1)

Add a pure parser parserEvenements(flux, aujourdhui) backed by vendored
ical.js v2.2.1 (ESM, no build). Returns events at the frozen Event contract
{ uid, titre, description, lieu, debut, fin, categories, journeeEntiere }:

- register embedded VTIMEZONE before toJSDate (correct offset, e.g. Africa/Lagos)
- dedupe by UID, keep the master (RECURRENCE-ID exceptions ignored)
- filter to today and future (local start of day)
- coerce missing text fields to ""

Edge cases deferred to T3 (RRULE expansion, all-day hour) are documented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Pierre Martin
2026-06-30 01:18:29 +02:00
co-authored by Claude Opus 4.8
parent fd28d3b2d3
commit 1878ba08ec
4 changed files with 10410 additions and 0 deletions
+62
View File
@@ -0,0 +1,62 @@
// Parseur iCalendar : flux brut → Event[] au contrat figé (T1).
// Fonction pure. `aujourdhui` est injecté (obligatoire) pour des tests
// déterministes. Voir docs/DECISIONS.md pour le contrat et les cas différés à T3.
//
// Event = {
// uid: string, titre: string, description: string, lieu: string,
// debut: Date, fin: Date, categories: string[], journeeEntiere: boolean
// }
import ICAL from "./vendor/ical.js";
export function parserEvenements(flux, aujourdhui) {
const composant = new ICAL.Component(ICAL.parse(flux));
// Enregistrer les fuseaux embarqués : sans cela toJSDate() calcule un
// mauvais offset pour les TZID non standards (ex. Africa/Lagos).
composant.getAllSubcomponents("vtimezone").forEach((vt) => {
const tzid = vt.getFirstPropertyValue("tzid");
if (tzid && !ICAL.TimezoneService.has(tzid)) {
ICAL.TimezoneService.register(vt);
}
});
// Dédoublonnage par UID. Les exceptions d'occurrence (RECURRENCE-ID) ne sont
// pas développées en T1 : on les ignore et on garde le maître.
const parUid = new Map();
for (const vevent of composant.getAllSubcomponents("vevent")) {
const ev = new ICAL.Event(vevent);
if (ev.isRecurrenceException()) continue;
if (parUid.has(ev.uid)) continue;
parUid.set(ev.uid, toEvent(ev, vevent));
}
const seuil = new Date(aujourdhui);
seuil.setHours(0, 0, 0, 0);
// Ordre du flux conservé (tri différé à la liste).
return [...parUid.values()].filter(
(event) => event.debut.getTime() >= seuil.getTime(),
);
}
function toEvent(ev, vevent) {
return {
uid: ev.uid,
titre: ev.summary ?? "",
description: ev.description ?? "",
lieu: ev.location ?? "",
debut: ev.startDate.toJSDate(),
fin: ev.endDate.toJSDate(),
categories: lireCategories(vevent),
journeeEntiere: ev.startDate.isDate === true,
};
}
// CATEGORIES peut prendre deux formes (valeurs séparées par virgule et/ou
// propriétés répétées) : getAllProperties + getValues couvre les deux.
function lireCategories(vevent) {
return vevent
.getAllProperties("categories")
.flatMap((prop) => prop.getValues());
}