diff --git a/.gitignore b/.gitignore index 52f36e3..ffa90b8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,10 @@ # Dépendances de dev (Bun) node_modules/ -# Config locale (organisateur, email) -config.local.json +# Config locale : organisateur/email (config.local.json) ET outillage de session +# (.claude/settings.local.json, régénéré par tâche). Motif générique : la +# convention *.local.json vaut pour tout ce qui ne concerne que cette machine. +*.local.json # Secrets .env diff --git a/extension/ics-categories.js b/extension/ics-categories.js new file mode 100644 index 0000000..de7d83a --- /dev/null +++ b/extension/ics-categories.js @@ -0,0 +1,81 @@ +// 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()); +} diff --git a/extension/statut-mairie.js b/extension/statut-mairie.js new file mode 100644 index 0000000..fa04620 --- /dev/null +++ b/extension/statut-mairie.js @@ -0,0 +1,67 @@ +// Statut mairie porté par les CATEGORIES de l'événement Nextcloud : `string[]` +// → `string[]`. Fonctions PURES (aucun DOM, aucun réseau, aucun ICS) : c'est le +// cœur logique de la persistance partagée, testé à fond. +// +// `mairie:` est NOTRE espace de noms : les autres catégories appartiennent aux +// bénévoles et gardent leur ordre et leur casse. + +const PREFIXE = "mairie:"; +// NFC explicite : « é » s'écrit de deux façons en Unicode (précomposée ou +// « e » + accent combinant) qui ne sont PAS `===`. Sans normalisation, un tag +// écrit par un autre client CalDAV serait relu comme « aucun » — panne +// silencieuse. On normalise ce qu'on écrit ET ce qu'on lit. +const IGNORE = `${PREFIXE}ignoré`.normalize("NFC"); +const SOUMIS = `${PREFIXE}soumis`; + +const JOUR = /^\d{4}-\d{2}-\d{2}$/; + +const FORMAT_JOUR_PARIS = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Paris", + year: "numeric", + month: "2-digit", + day: "2-digit", +}); + +// Écriture concurrente (deux bénévoles, deux clients) : plusieurs tags peuvent +// cohabiter. La DERNIÈRE valeur reconnue l'emporte, comme indexerCartes. +// Un tag `mairie:` inconnu (posé par une version future) est traité comme +// « aucun » : il ne doit jamais casser la liste d'aujourd'hui. +export function lireStatut(categories) { + let statut = { type: "aucun" }; + for (const categorie of categories) { + const reconnu = reconnaitre(categorie.normalize("NFC")); + if (reconnu) statut = reconnu; + } + return statut; +} + +function reconnaitre(categorie) { + if (categorie === IGNORE) return { type: "ignoré" }; + if (categorie === SOUMIS) return { type: "soumis", jour: null }; + if (categorie.startsWith(`${SOUMIS}:`)) { + // L'intention prime sur sa décoration : une date illisible n'annule pas + // l'envoi, on affichera « Soumis » sans date. + const jour = categorie.slice(SOUMIS.length + 1); + return { type: "soumis", jour: JOUR.test(jour) ? jour : null }; + } + return null; +} + +export function poserIgnore(categories) { + return [...retirerStatut(categories), IGNORE]; +} + +export function poserSoumis(categories, jour) { + return [...retirerStatut(categories), `${SOUMIS}:${jour}`]; +} + +export function retirerStatut(categories) { + return categories.filter((categorie) => !categorie.startsWith(PREFIXE)); +} + +// « AAAA-MM-JJ » à Cugnaux : en-CA rend directement cette forme (cf. +// publication.js). `maintenant` injecté (obligatoire) pour des tests +// déterministes. +export function jourParis(maintenant) { + return FORMAT_JOUR_PARIS.format(maintenant); +} diff --git a/ics-categories.test.ts b/ics-categories.test.ts new file mode 100644 index 0000000..96e0b9d --- /dev/null +++ b/ics-categories.test.ts @@ -0,0 +1,263 @@ +import { describe, expect, test } from "bun:test"; +import ICAL from "./extension/vendor/ical.js"; +import { + appliquerAuxCategories, + seulesLesCategoriesOntChange, +} from "./extension/ics-categories.js"; +import { poserIgnore } from "./extension/statut-mairie.js"; + +const UID = "db2d146b-8e18-4df6-929f-a651d04245df"; + +function ressource(corpsVevent: string, extra = "") { + return [ + "BEGIN:VCALENDAR", + "VERSION:2.0", + "PRODID:-//IDN nextcloud.com//Calendar app 5.2.2//EN", + extra, + "BEGIN:VEVENT", + `UID:${UID}`, + "DTSTAMP:20250828T095756Z", + "DTSTART;TZID=Europe/Paris:20260907T090000", + "DTEND;TZID=Europe/Paris:20260907T130000", + "SUMMARY:Forum des associations", + corpsVevent, + "END:VEVENT", + "END:VCALENDAR", + "", + ] + .filter((ligne) => ligne !== "") + .join("\r\n"); +} + +const VTIMEZONE = [ + "BEGIN:VTIMEZONE", + "TZID:Europe/Paris", + "BEGIN:DAYLIGHT", + "TZOFFSETFROM:+0100", + "TZOFFSETTO:+0200", + "TZNAME:CEST", + "DTSTART:19700329T020000", + "RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU", + "END:DAYLIGHT", + "BEGIN:STANDARD", + "TZOFFSETFROM:+0200", + "TZOFFSETTO:+0100", + "TZNAME:CET", + "DTSTART:19701025T030000", + "RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU", + "END:STANDARD", + "END:VTIMEZONE", +].join("\r\n"); + +// Relit la ressource produite : on vérifie des VALEURS, jamais du texte brut. +function relire(ics: string) { + const racine = new ICAL.Component(ICAL.parse(ics)); + const vevents = racine.getAllSubcomponents("vevent"); + const maitre = vevents.find((v) => !v.hasProperty("recurrence-id")); + if (!maitre) throw new Error("VEVENT maître introuvable dans la ressource produite"); + return { + racine, + maitre, + exception: vevents.find((v) => v.hasProperty("recurrence-id")), + }; +} + +function categoriesDe(vevent: any) { + return vevent.getAllProperties("categories").flatMap((p) => p.getValues()); +} + +const ajouterTag = (categories: string[]) => poserIgnore(categories); + +describe("appliquerAuxCategories", () => { + test("CATEGORIES absent → ajouté, le reste de l'événement intact", () => { + const avant = ressource("DESCRIPTION:Venez nombreux\r\nRRULE:FREQ=WEEKLY;COUNT=3"); + + const res = appliquerAuxCategories(avant, UID, ajouterTag); + + expect(res.ok).toBe(true); + const { maitre } = relire(res.ics); + expect(categoriesDe(maitre)).toEqual(["mairie:ignoré"]); + expect(maitre.getFirstPropertyValue("summary")).toBe("Forum des associations"); + expect(maitre.getFirstPropertyValue("description")).toBe("Venez nombreux"); + expect(maitre.getFirstPropertyValue("dtstart").toString()).toBe("2026-09-07T09:00:00"); + expect(maitre.getFirstProperty("dtstart").getParameter("tzid")).toBe("Europe/Paris"); + expect(maitre.getFirstPropertyValue("rrule").toString()).toBe("FREQ=WEEKLY;COUNT=3"); + }); + + test("CATEGORIES existant → catégories des bénévoles préservées, tag remplacé", () => { + const avant = ressource("CATEGORIES:Concert,mairie:soumis:2026-08-01"); + + const res = appliquerAuxCategories(avant, UID, ajouterTag); + + expect(res.categories).toEqual(["Concert", "mairie:ignoré"]); + expect(categoriesDe(relire(res.ics).maitre)).toEqual(["Concert", "mairie:ignoré"]); + }); + + test("deux propriétés CATEGORIES répétées → fusionnées, aucune valeur perdue", () => { + const avant = ressource("CATEGORIES:Concert\r\nCATEGORIES:Jazz"); + + const res = appliquerAuxCategories(avant, UID, ajouterTag); + + const { maitre } = relire(res.ics); + expect(maitre.getAllProperties("categories")).toHaveLength(1); + expect(categoriesDe(maitre)).toEqual(["Concert", "Jazz", "mairie:ignoré"]); + }); + + test("valeur contenant une virgule → échappement correct au round-trip", () => { + const avant = ressource("CATEGORIES:Concert\\, jazz"); + + const res = appliquerAuxCategories(avant, UID, ajouterTag); + + expect(res.categories).toEqual(["Concert, jazz", "mairie:ignoré"]); + expect(categoriesDe(relire(res.ics).maitre)).toEqual(["Concert, jazz", "mairie:ignoré"]); + }); + + test("accents fidèles au round-trip", () => { + const avant = ressource("CATEGORIES:Fête de quartier"); + + const res = appliquerAuxCategories(avant, UID, ajouterTag); + + expect(res.ics).toContain("mairie:ignoré"); + expect(categoriesDe(relire(res.ics).maitre)).toEqual(["Fête de quartier", "mairie:ignoré"]); + }); + + test("ligne repliée (> 75 octets) → contenu identique après round-trip", () => { + const longue = "Une description très longue ".repeat(8).trim(); + const avant = ressource(`DESCRIPTION:${longue}`); + + const res = appliquerAuxCategories(avant, UID, ajouterTag); + + expect(res.ok).toBe(true); + expect(relire(res.ics).maitre.getFirstPropertyValue("description")).toBe(longue); + }); + + test("VTIMEZONE + maître + exception → seul le maître change", () => { + const avant = ressource( + [ + "END:VEVENT", + "BEGIN:VEVENT", + `UID:${UID}`, + "RECURRENCE-ID;TZID=Europe/Paris:20260914T090000", + "DTSTAMP:20250828T095756Z", + "DTSTART;TZID=Europe/Paris:20260914T100000", + "SUMMARY:Forum (reporté)", + "CATEGORIES:Exception", + ].join("\r\n"), + VTIMEZONE, + ); + + const res = appliquerAuxCategories(avant, UID, ajouterTag); + + expect(res.ok).toBe(true); + const { racine, maitre, exception } = relire(res.ics); + expect(categoriesDe(maitre)).toEqual(["mairie:ignoré"]); + expect(categoriesDe(exception!)).toEqual(["Exception"]); + expect(exception!.getFirstPropertyValue("summary")).toBe("Forum (reporté)"); + expect(racine.getAllSubcomponents("vtimezone")).toHaveLength(1); + }); + + test("transformer rendant [] → propriété CATEGORIES supprimée", () => { + const avant = ressource("CATEGORIES:mairie:ignoré"); + + const res = appliquerAuxCategories(avant, UID, () => []); + + expect(res.categories).toEqual([]); + expect(res.ics).not.toContain("CATEGORIES"); + expect(relire(res.ics).maitre.getAllProperties("categories")).toHaveLength(0); + }); + + test("uid absent de la ressource → vevent-introuvable", () => { + const res = appliquerAuxCategories(ressource("SEQUENCE:1"), "uid-inconnu", ajouterTag); + expect(res).toEqual({ ok: false, erreur: "vevent-introuvable" }); + }); + + test("ICS illisible → ics-illisible, jamais d'exception", () => { + const res = appliquerAuxCategories("BEGIN:VCALENDAR\r\nVERSION:2.0", UID, ajouterTag); + expect(res).toEqual({ ok: false, erreur: "ics-illisible" }); + }); + + // ICAL.parse("") ne lève pas : il rend [] et le composant vide explose plus + // tard. Ces deux cas sont atteignables (calendar-data vide côté serveur). + for (const [nom, ics] of [ + ["chaîne vide", ""], + ["blancs seuls", " \r\n "], + ["texte quelconque", "pas du tout un calendrier"], + ] as [string, string][]) { + test(`${nom} → ics-illisible, jamais d'exception`, () => { + expect(appliquerAuxCategories(ics, UID, ajouterTag)).toEqual({ + ok: false, + erreur: "ics-illisible", + }); + }); + } + + // Formes exotiques rencontrées dans de vrais calendriers : elles doivent + // passer le garde-fou, sinon il bloquerait l'écriture pour rien. + const formesExotiques: [string, string][] = [ + ["paramètre quoté à virgule", 'ATTENDEE;CN="Doe, John";PARTSTAT=ACCEPTED:mailto:a@b.c'], + ["propriété inconnue", "X-BIDULE;FOO=bar:valeur;avec;points-virgules"], + ["pièce jointe binaire", "ATTACH;VALUE=BINARY;ENCODING=BASE64:aGVsbG8="], + ["EXDATE multiple", "EXDATE;TZID=Europe/Paris:20260914T090000,20260921T090000"], + ["accent sur la frontière de repliement", `DESCRIPTION:${"a".repeat(74)}é${"b".repeat(20)}`], + ]; + + for (const [nom, ligne] of formesExotiques) { + test(`${nom} → le garde-fou laisse passer`, () => { + const res = appliquerAuxCategories(ressource(ligne, VTIMEZONE), UID, ajouterTag); + expect(res.ok).toBe(true); + }); + } +}); + +describe("seulesLesCategoriesOntChange", () => { + const base = ressource("DESCRIPTION:Venez nombreux\r\nCATEGORIES:Concert", VTIMEZONE); + + test("seul CATEGORIES diffère → true", () => { + const autre = ressource("DESCRIPTION:Venez nombreux\r\nCATEGORIES:Jazz", VTIMEZONE); + expect(seulesLesCategoriesOntChange(base, autre)).toBe(true); + }); + + test("CATEGORIES supprimé → true", () => { + const autre = ressource("DESCRIPTION:Venez nombreux", VTIMEZONE); + expect(seulesLesCategoriesOntChange(base, autre)).toBe(true); + }); + + test("repliement et espacement différents → true (comparaison structurelle)", () => { + const replie = base.replace( + "DESCRIPTION:Venez nombreux", + "DESCRIPTION:Venez\r\n nombreux", + ); + expect(seulesLesCategoriesOntChange(base, replie)).toBe(true); + }); + + test("DESCRIPTION différente → false", () => { + const autre = ressource("DESCRIPTION:Annulé\r\nCATEGORIES:Concert", VTIMEZONE); + expect(seulesLesCategoriesOntChange(base, autre)).toBe(false); + }); + + test("TZID différent → false", () => { + expect( + seulesLesCategoriesOntChange(base, base.replace("TZID=Europe/Paris:20260907", "TZID=Europe/Lisbon:20260907")), + ).toBe(false); + }); + + test("RRULE ajoutée → false", () => { + const autre = ressource( + "DESCRIPTION:Venez nombreux\r\nCATEGORIES:Concert\r\nRRULE:FREQ=WEEKLY", + VTIMEZONE, + ); + expect(seulesLesCategoriesOntChange(base, autre)).toBe(false); + }); + + test("VTIMEZONE modifié → false", () => { + expect( + seulesLesCategoriesOntChange(base, base.replace("TZNAME:CEST", "TZNAME:XXXX")), + ).toBe(false); + }); + + test("ICS illisible d'un côté → false (on n'écrit pas dans le doute)", () => { + expect(seulesLesCategoriesOntChange(base, "n'importe quoi")).toBe(false); + expect(seulesLesCategoriesOntChange(base, "")).toBe(false); + expect(seulesLesCategoriesOntChange("", "")).toBe(false); + }); +}); diff --git a/statut-mairie.test.ts b/statut-mairie.test.ts new file mode 100644 index 0000000..c83239b --- /dev/null +++ b/statut-mairie.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, test } from "bun:test"; +import { + jourParis, + lireStatut, + poserIgnore, + poserSoumis, + retirerStatut, +} from "./extension/statut-mairie.js"; + +describe("lireStatut", () => { + test("aucune catégorie → aucun", () => { + expect(lireStatut([])).toEqual({ type: "aucun" }); + }); + + test("tag ignoré", () => { + expect(lireStatut(["mairie:ignoré"])).toEqual({ type: "ignoré" }); + }); + + test("tag soumis avec date", () => { + expect(lireStatut(["mairie:soumis:2026-08-13"])).toEqual({ + type: "soumis", + jour: "2026-08-13", + }); + }); + + test("les catégories des bénévoles n'interfèrent pas", () => { + expect(lireStatut(["Concert", "mairie:ignoré"])).toEqual({ type: "ignoré" }); + expect(lireStatut(["Concert", "Jazz"])).toEqual({ type: "aucun" }); + }); + + test("tag mairie: inconnu → aucun, sans exception", () => { + expect(lireStatut(["mairie:bidule"])).toEqual({ type: "aucun" }); + }); + + test("plusieurs tags reconnus → le dernier l'emporte", () => { + expect(lireStatut(["mairie:ignoré", "mairie:soumis:2026-08-13"])).toEqual({ + type: "soumis", + jour: "2026-08-13", + }); + expect(lireStatut(["mairie:soumis:2026-08-13", "mairie:ignoré"])).toEqual({ + type: "ignoré", + }); + }); + + test("tag écrit en Unicode décomposé (NFD) → reconnu quand même", () => { + const decompose = "mairie:ignoré".normalize("NFD"); + expect(decompose).not.toBe("mairie:ignoré"); + expect(lireStatut([decompose])).toEqual({ type: "ignoré" }); + }); + + test("le tag posé est en forme normalisée (NFC)", () => { + expect(poserIgnore([])[0]).toBe("mairie:ignoré".normalize("NFC")); + }); + + test("soumis sans date ou date malformée → jour null", () => { + expect(lireStatut(["mairie:soumis"])).toEqual({ type: "soumis", jour: null }); + expect(lireStatut(["mairie:soumis:hier"])).toEqual({ type: "soumis", jour: null }); + }); +}); + +describe("poser / retirer", () => { + test("poserSoumis remplace le tag existant, préserve ordre et casse des autres", () => { + expect(poserSoumis(["Concert", "mairie:ignoré"], "2026-08-13")).toEqual([ + "Concert", + "mairie:soumis:2026-08-13", + ]); + }); + + test("poserIgnore deux fois → pas de doublon", () => { + expect(poserIgnore(poserIgnore([]))).toEqual(["mairie:ignoré"]); + }); + + test("poserIgnore sur une liste vide", () => { + expect(poserIgnore([])).toEqual(["mairie:ignoré"]); + }); + + test("poser* écrase même un tag mairie: inconnu", () => { + expect(poserIgnore(["Concert", "mairie:bidule"])).toEqual(["Concert", "mairie:ignoré"]); + }); + + test("retirerStatut enlève le tag et laisse le reste", () => { + expect(retirerStatut(["Concert", "mairie:soumis:2026-08-13"])).toEqual(["Concert"]); + }); + + test("retirerStatut sur une liste sans tag → inchangée", () => { + expect(retirerStatut(["Concert", "Jazz"])).toEqual(["Concert", "Jazz"]); + }); + + test("les entrées ne sont jamais mutées", () => { + const categories = ["Concert", "mairie:ignoré"]; + poserSoumis(categories, "2026-08-13"); + poserIgnore(categories); + retirerStatut(categories); + expect(categories).toEqual(["Concert", "mairie:ignoré"]); + }); +}); + +describe("jourParis", () => { + test("23h30 UTC le 31/12 → déjà le 1er janvier à Paris (+01:00)", () => { + expect(jourParis(new Date("2025-12-31T23:30:00Z"))).toBe("2026-01-01"); + }); + + test("22h30 UTC en août → déjà le lendemain à Paris (+02:00)", () => { + expect(jourParis(new Date("2026-08-13T22:30:00Z"))).toBe("2026-08-14"); + }); +});