// Réécriture de CATEGORIES dans une ressource iCalendar, avec garde-fou de // non-régression. PUR (ical.js tourne aussi dans Bun) : c'est ici que se joue // l'intégrité de l'agenda d'une association, on le teste à fond. // // Règle du rayon d'explosion : si le round-trip ical.js a touché autre chose que // CATEGORIES, on n'écrit PAS. Un tag non posé se repose ; un événement abîmé se // répare à la main. import ICAL from "./vendor/ical.js"; // `transformer` (obligatoire) : string[] -> string[]. Il est appliqué aux // catégories FRAÎCHEMENT lues dans la ressource, jamais à celles de la liste // (potentiellement périmées). export function appliquerAuxCategories(ics, uid, transformer) { const racine = parser(ics); if (!racine) return { ok: false, erreur: "ics-illisible" }; // Le VEVENT maître = celui SANS RECURRENCE-ID, cohérent avec evenements.js // qui ignore les exceptions d'occurrence. const maitre = racine .getAllSubcomponents("vevent") .find((v) => v.getFirstPropertyValue("uid") === uid && !v.hasProperty("recurrence-id")); if (!maitre) return { ok: false, erreur: "vevent-introuvable" }; const categories = transformer(lireCategories(maitre)); // Une seule propriété multi-valeurs : les formes répétées sont fusionnées. maitre.removeAllProperties("categories"); if (categories.length > 0) { const propriete = new ICAL.Property("categories"); propriete.setValues(categories); maitre.addProperty(propriete); } const apres = racine.toString(); if (!seulesLesCategoriesOntChange(ics, apres)) { return { ok: false, erreur: "reecriture-risquee" }; } return { ok: true, ics: apres, categories }; } // Garde-fou E3 : comparaison STRUCTURELLE (jCal), pas textuelle. Le repliement // de ligne et l'espacement peuvent changer librement ; une valeur, non. export function seulesLesCategoriesOntChange(icsAvant, icsApres) { const avant = parser(icsAvant); const apres = parser(icsApres); if (!avant || !apres) return false; return ( JSON.stringify(sansCategories(avant.jCal)) === JSON.stringify(sansCategories(apres.jCal)) ); } function parser(ics) { try { const racine = new ICAL.Component(ICAL.parse(ics)); // `ICAL.parse("")` ne LÈVE PAS : il rend `[]`, et le composant vide qui en // sort explose au premier accès. Le try/catch ne suffit donc pas — on exige // un VCALENDAR reconnaissable avant de rendre la main. return racine.name === "vcalendar" ? racine : null; } catch { return null; } } // jCal : [nom, propriétés, sous-composants]. On retire `categories` PARTOUT, // y compris dans les exceptions d'occurrence. function sansCategories([nom, proprietes, sousComposants]) { return [ nom, proprietes.filter(([nomPropriete]) => nomPropriete !== "categories"), sousComposants.map(sansCategories), ]; } // CATEGORIES peut prendre deux formes (virgules et/ou propriétés répétées), // comme dans evenements.js. function lireCategories(vevent) { return vevent.getAllProperties("categories").flatMap((propriete) => propriete.getValues()); }