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:
@@ -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());
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user