feat: read and write the mairie status carried by CATEGORIES

Two pure modules: statut-mairie.js (string[] -> string[] status logic) and
ics-categories.js, which rewrites CATEGORIES in an iCalendar resource behind a
structural guard: nothing is written if anything but CATEGORIES changed.
This commit is contained in:
Pierre Martin
2026-08-13 16:50:16 +02:00
parent d741d248d9
commit 3248b28e1f
5 changed files with 521 additions and 2 deletions
+4 -2
View File
@@ -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
+81
View File
@@ -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());
}
+67
View File
@@ -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);
}
+263
View File
@@ -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);
});
});
+106
View File
@@ -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");
});
});