Author SHA1 Message Date
Pierre Martin e7338d777b chore: update default email address to match the correct one 2026-08-24 19:59:05 +02:00
Pierre Martin c6b0e7799a docs: record the measured CalDAV write path and the T2 decisions 2026-08-13 16:50:16 +02:00
Pierre Martin 69c10abbd7 feat: let the list ignore, submit and undo events from Nextcloud
Each row now shows its shared status and carries the matching gesture. Nothing
is displayed before Nextcloud confirms it: on failure the status stays put and
the reason is spelled out. Ignored events are greyed and pushed to the end,
never hidden.
2026-08-13 16:50:16 +02:00
Pierre Martin 058229dce3 feat: write the mairie status back to Nextcloud over CalDAV
REPORT by UID gives href + ETag + calendar-data, then PUT with If-Match: the
resource URL is never guessed and a concurrent edit yields 412 instead of an
overwrite. URL_COLLECTION is derived from URL_CALENDRIER.
2026-08-13 16:50:16 +02:00
Pierre Martin 3248b28e1f 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.
2026-08-13 16:50:16 +02:00
16 changed files with 1333 additions and 17 deletions
+4 -2
View File
@@ -1,8 +1,10 @@
# Dépendances de dev (Bun) # Dépendances de dev (Bun)
node_modules/ node_modules/
# Config locale (organisateur, email) # Config locale : organisateur/email (config.local.json) ET outillage de session
config.local.json # (.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 # Secrets
.env .env
+291
View File
@@ -0,0 +1,291 @@
import { describe, expect, test } from "bun:test";
import { ecrireStatut } from "./extension/caldav.js";
import { URL_COLLECTION } from "./extension/nextcloud.js";
const UID = "db2d146b-8e18-4df6-929f-a651d04245df";
const HREF =
"/remote.php/dav/calendars/Pierre/latelier-du-huit-sbastien_shared_by_admin/00D03211-DA2E-4D06-AAAE-6E8C2355BA2B.ics";
const URL_RESSOURCE = "https://atelier-huit.frama.space" + HREF;
const ETAG = '"8ee26a8f6124c1833c48f319868ce10a"';
const ICS = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//IDN nextcloud.com//Calendar app 5.2.2//EN",
"BEGIN:VEVENT",
`UID:${UID}`,
"DTSTAMP:20250828T095756Z",
"DTSTART;TZID=Europe/Paris:20260907T090000",
"SUMMARY:Forum des associations",
"CATEGORIES:Concert",
"END:VEVENT",
"END:VCALENDAR",
"",
].join("\r\n");
const ajouterTag = (categories: string[]) => [...categories, "mairie:ignoré"];
type Appel = { url: string; options: any };
// Faux transport : enregistre chaque appel, répond selon la méthode.
function transport({
jeton = { status: 200, corps: '{"token":"jeton-csrf"}' },
report = { status: 207, corps: "<multistatus/>" },
put = { status: 204, corps: "" },
levee = null as null | string,
}) {
const appels: Appel[] = [];
const fetchImpl = async (url: string, options: any = {}) => {
appels.push({ url, options });
const methode = options.method ?? "GET";
if (levee === methode) throw new TypeError("Failed to fetch");
const reponse = methode === "REPORT" ? report : methode === "PUT" ? put : jeton;
return new Response(reponse.corps, { status: reponse.status });
};
return {
appels,
fetchImpl,
parMethode: (methode: string) =>
appels.filter((a) => (a.options.method ?? "GET") === methode),
};
}
const uneReponse = () => [{ href: HREF, etag: ETAG, ics: ICS }];
function ecrire(transportUtilise: ReturnType<typeof transport>, options: any = {}) {
return ecrireStatut({
fetchImpl: transportUtilise.fetchImpl,
extraireReponsesImpl: options.extraireReponsesImpl ?? uneReponse,
uid: options.uid ?? UID,
transformer: options.transformer ?? ajouterTag,
});
}
describe("requête REPORT", () => {
test("méthode, Depth, Content-Type, credentials et UID dans le corps", async () => {
const t = transport({});
await ecrire(t);
const [report] = t.parMethode("REPORT");
expect(report.url).toBe(URL_COLLECTION);
expect(report.options.headers.Depth).toBe("1");
expect(report.options.headers["Content-Type"]).toBe("application/xml; charset=utf-8");
expect(report.options.credentials).toBe("include");
expect(report.options.body).toContain(UID);
});
test("UID échappé en XML (jamais interpolé brut)", async () => {
const t = transport({});
await ecrire(t, { uid: "a&b<c>d" });
const [report] = t.parMethode("REPORT");
expect(report.options.body).toContain("a&amp;b&lt;c&gt;d");
expect(report.options.body).not.toContain("a&b<c>d");
});
});
describe("jeton CSRF", () => {
test("jeton obtenu → en-tête requesttoken sur REPORT et PUT", async () => {
const t = transport({});
await ecrire(t);
expect(t.parMethode("REPORT")[0].options.headers.requesttoken).toBe("jeton-csrf");
expect(t.parMethode("PUT")[0].options.headers.requesttoken).toBe("jeton-csrf");
});
test("/csrftoken en 404 → on poursuit sans en-tête", async () => {
const t = transport({ jeton: { status: 404, corps: "" } });
const res = await ecrire(t);
expect(res.ok).toBe(true);
expect(t.parMethode("PUT")[0].options.headers.requesttoken).toBeUndefined();
});
test("/csrftoken qui lève → pas d'exception, écriture tentée quand même", async () => {
const t = transport({ levee: "GET" });
const res = await ecrire(t);
expect(res.ok).toBe(true);
expect(t.parMethode("PUT")).toHaveLength(1);
});
});
describe("choix de la ressource", () => {
test("multistatus vide → introuvable, aucun PUT", async () => {
const t = transport({});
const res = await ecrire(t, { extraireReponsesImpl: () => [] });
expect(res).toEqual({ ok: false, erreur: "introuvable" });
expect(t.parMethode("PUT")).toHaveLength(0);
});
test("href pointant hors de la collection → ambigu, aucun PUT", async () => {
const t = transport({});
const res = await ecrire(t, {
extraireReponsesImpl: () => [
{ href: "https://ailleurs.example/vol.ics", etag: ETAG, ics: ICS },
],
});
expect(res).toEqual({ ok: false, erreur: "ambigu" });
expect(t.parMethode("PUT")).toHaveLength(0);
});
test("deux href distincts → ambigu, aucun PUT", async () => {
const t = transport({});
const res = await ecrire(t, {
extraireReponsesImpl: () => [
{ href: HREF, etag: ETAG, ics: ICS },
{ href: HREF + "-bis", etag: ETAG, ics: ICS },
],
});
expect(res).toEqual({ ok: false, erreur: "ambigu" });
expect(t.parMethode("PUT")).toHaveLength(0);
});
});
describe("requête PUT", () => {
test("href résolu en absolu, If-Match verbatim, corps = ICS modifié", async () => {
const t = transport({});
const res = await ecrire(t);
const [put] = t.parMethode("PUT");
expect(put.url).toBe(URL_RESSOURCE);
expect(put.options.headers["If-Match"]).toBe(ETAG);
expect(put.options.headers["Content-Type"]).toBe("text/calendar; charset=utf-8");
expect(put.options.credentials).toBe("include");
expect(put.options.body).toContain("mairie:ignoré");
expect(put.options.body).toContain("Concert");
expect(res).toEqual({ ok: true, categories: ["Concert", "mairie:ignoré"] });
});
test("les catégories transformées sont celles LUES dans la ressource", async () => {
const t = transport({});
let vues: string[] = [];
await ecrire(t, {
transformer: (categories: string[]) => {
vues = categories;
return categories;
},
});
expect(vues).toEqual(["Concert"]);
});
});
describe("garde-fou de réécriture", () => {
test("ICS illisible → erreur remontée, aucun PUT", async () => {
const t = transport({});
const res = await ecrire(t, {
extraireReponsesImpl: () => [{ href: HREF, etag: ETAG, ics: "n'importe quoi" }],
});
expect(res).toEqual({ ok: false, erreur: "ics-illisible" });
expect(t.parMethode("PUT")).toHaveLength(0);
});
test("calendar-data réduit à des blancs → ics-illisible, aucun PUT", async () => {
const t = transport({});
const res = await ecrire(t, {
extraireReponsesImpl: () => [{ href: HREF, etag: ETAG, ics: " \r\n " }],
});
expect(res).toEqual({ ok: false, erreur: "ics-illisible" });
expect(t.parMethode("PUT")).toHaveLength(0);
});
test("href absolu malformé → ambigu, aucun PUT (new URL lève)", async () => {
const t = transport({});
const res = await ecrire(t, {
extraireReponsesImpl: () => [{ href: "http://[", etag: ETAG, ics: ICS }],
});
expect(res).toEqual({ ok: false, erreur: "ambigu" });
expect(t.parMethode("PUT")).toHaveLength(0);
});
test("UID absent de la ressource → vevent-introuvable, aucun PUT", async () => {
const t = transport({});
const res = await ecrire(t, { uid: "uid-absent" });
expect(res).toEqual({ ok: false, erreur: "vevent-introuvable" });
expect(t.parMethode("PUT")).toHaveLength(0);
});
});
describe("mapping des statuts", () => {
const cas: [number, string][] = [
[412, "conflit"],
[403, "lecture-seule"],
[404, "introuvable"],
[500, "serveur"],
];
for (const [status, erreur] of cas) {
test(`PUT ${status} → ${erreur}`, async () => {
const t = transport({ put: { status, corps: "" } });
expect(await ecrire(t)).toEqual({ ok: false, erreur });
});
test(`REPORT ${status} → ${erreur}, aucun PUT`, async () => {
const t = transport({ report: { status, corps: "" } });
expect(await ecrire(t)).toEqual({ ok: false, erreur });
expect(t.parMethode("PUT")).toHaveLength(0);
});
}
test("PUT 201 → succès", async () => {
const t = transport({ put: { status: 201, corps: "" } });
expect((await ecrire(t)).ok).toBe(true);
});
test("401 avec corps mentionnant CSRF → csrf", async () => {
const t = transport({
put: { status: 401, corps: '{"message":"CSRF check not passed."}' },
});
expect(await ecrire(t)).toEqual({ ok: false, erreur: "csrf" });
});
test("401 sans mention CSRF → non-connecte", async () => {
const t = transport({ put: { status: 401, corps: "Unauthorized" } });
expect(await ecrire(t)).toEqual({ ok: false, erreur: "non-connecte" });
});
test("REPORT qui lève → reseau, aucun PUT", async () => {
const t = transport({ levee: "REPORT" });
expect(await ecrire(t)).toEqual({ ok: false, erreur: "reseau" });
expect(t.parMethode("PUT")).toHaveLength(0);
});
test("PUT qui lève → reseau", async () => {
const t = transport({ levee: "PUT" });
expect(await ecrire(t)).toEqual({ ok: false, erreur: "reseau" });
});
// La connexion peut tomber APRÈS les en-têtes : c'est `text()` qui rejette,
// pas `fetch`. Sans cela, l'exception remonterait jusqu'au clic.
test("corps illisible (text() qui rejette) → reseau, jamais d'exception", async () => {
const appels: string[] = [];
const fetchImpl = async (_url: string, options: any = {}) => {
const methode = options.method ?? "GET";
appels.push(methode);
if (methode === "GET") return new Response('{"token":"t"}');
return {
ok: true,
status: 207,
text: () => Promise.reject(new TypeError("Network error while reading body")),
};
};
const res = await ecrireStatut({
fetchImpl,
extraireReponsesImpl: uneReponse,
uid: UID,
transformer: ajouterTag,
});
expect(res).toEqual({ ok: false, erreur: "reseau" });
expect(appels).not.toContain("PUT");
});
});
+59
View File
@@ -218,3 +218,62 @@ gracieuse, jamais d'exception, aucun faux positif). Deux cartes de même clé
**additive** : la liste est rendue immédiatement, l'agenda ne la bloque jamais. **additive** : la liste est rendue immédiatement, l'agenda ne la bloque jamais.
Fetch de l'agenda **sans** `credentials` (public). Aucune modification de Fetch de l'agenda **sans** `credentials` (public). Aucune modification de
`manifest.json` (`host_permissions` couvre déjà `ville-cugnaux.fr`). `manifest.json` (`host_permissions` couvre déjà `ville-cugnaux.fr`).
## 2026-08-13 — [T2] Écriture du statut : REPORT + If-Match, garde-fou, annulable
**Résolution paresseuse de la ressource, jamais d'URL devinée.** Au moment
d'écrire, un `REPORT` (`calendar-query` filtrée sur l'`UID`) rend `href` + `ETag`
+ `calendar-data`, puis on `PUT` avec `If-Match`. Mesuré : le nom du fichier
`.ics` **ne correspond pas** à l'UID (cf. RECHERCHE.md §1), l'approche
« `<uid>.ics` » aurait produit une panne partielle. `If-Match` transforme
l'écriture concurrente en **412** (« recharge la liste ») au lieu d'un écrasement
silencieux. 0 réponse → `introuvable`, plusieurs `href` → `ambigu` : on n'écrit
jamais dans une ressource choisie au hasard.
**Garde-fou de non-régression.** L'aller-retour `ical.js` pourrait abîmer une
propriété exotique. Avant tout `PUT`, on re-parse l'ICS **avant** et **après**,
on retire `CATEGORIES` des deux arbres jCal et on compare : différence ailleurs →
**aucun PUT**, message honnête. La comparaison est **structurelle**, donc le
repliement de ligne et l'espacement peuvent changer librement — une **valeur**,
non. On écrit dans l'agenda réel d'une association : un tag non posé se repose,
un événement abîmé se répare à la main.
**Ni `SEQUENCE` ni `DTSTAMP`/`LAST-MODIFIED` ne sont bumpés** : le garde-fou
l'interdit, et poser une catégorie n'est pas une modification de planification.
La synchro des autres clients passe par l'ETag/ctag.
**Le CSRF est un confort, pas une condition** : `/csrftoken` rend 200, mais un
jeton **invalide** passe quand même sur DAV (mesuré). On envoie l'en-tête
`requesttoken` quand on l'a, et un échec de récupération n'est **pas fatal** :
le serveur tranche.
**Le tag `soumis` est posé au clic « Créer » et reste annulable.** Rien ne prouve
qu'un formulaire modéré a été envoyé ; le clic est le meilleur signal disponible,
donc le statut porte toujours « Annuler l'envoi » et le bouton « Créer »
disparaît (c'est le doublon d'annonce qu'on combat). **Un échec d'écriture
n'empêche pas d'ouvrir le formulaire** : le tag est un confort d'équipe, remplir
le formulaire est la mission.
**Jamais d'affichage optimiste** : le statut affiché ne change qu'après
confirmation du serveur, et ce sont les catégories **renvoyées par
l'aller-retour** qui sont conservées. Mentir sur un état que d'autres bénévoles
lisent est pire que ne rien afficher. Seule la **ligne concernée** est re-rendue :
recharger la liste effacerait les badges « publié » déjà posés.
**Les ignorés sont grisés et repoussés en fin de liste, jamais masqués** : un
ignoré invisible est un ignoré qu'on ne peut plus dé-ignorer.
**Orthographe du tag : `mairie:ignoré`, avec l'accent** — ces tags sont lus par
des humains dans Nextcloud et l'ICS est de l'UTF-8. À re-constater au premier
aller-retour réel : si Nextcloud renormalise, basculer en ASCII et le noter.
**Limite assumée v1** : une ressource = un tag, donc un récurrent porte le même
statut pour toutes ses occurrences (cohérent avec le report des récurrences en T3).
**Limite honnête du garde-fou** : il compare `parse(ics)` à
`parse(toString(parse(ics)))`. Il attrape donc ce que la **sérialisation** perd,
pas ce que le **parsing** perdrait des deux côtés à la fois. Vérifié à
l'écriture : `ical.js` round-trippe sans dommage les formes exotiques d'un vrai
calendrier (paramètre quoté à virgule, propriété inconnue, `ATTACH` binaire,
`EXDATE` multiple, accent sur la frontière de repliement) — ces cas sont figés en
test. Le vrai arbitre reste le test humain « rien d'autre n'a bougé ».
+27
View File
@@ -31,6 +31,33 @@ Document de référence pour l'implémentation. Toutes les valeurs ci-dessous so
> être enregistrés (`ICAL.TimezoneService.register`) **avant** `toJSDate()`, > être enregistrés (`ICAL.TimezoneService.register`) **avant** `toJSDate()`,
> sinon l'offset est faux pour les TZID non standards (ex. `Africa/Lagos`). > sinon l'offset est faux pour les TZID non standards (ex. `Africa/Lagos`).
### Écriture CalDAV — **mesuré le 2026-08-13** (spike navigateur, T2)
Mesures faites depuis la console de la page d'extension, session Nextcloud
ouverte. La séquence complète a été jouée, `PUT` **no-op** compris (contenu
reposé à l'identique : rien modifié).
| Étape | Résultat mesuré |
|---|---|
| `GET /csrftoken` | **200**, corps `{"token":"…=:…="}` (le repli `data-requesttoken` n'a pas servi) |
| `REPORT` sur la collection (`calendar-query` filtrée sur `UID`) | **207**, en-têtes `Depth: 1` + `Content-Type: application/xml; charset=utf-8` |
| `PUT` avec `If-Match` | **204** → **le partage est en écriture** |
| `REPORT` avec un `requesttoken` **invalide** | **207 quand même** : l'endpoint DAV **n'exige pas** le jeton CSRF |
- **Collection** = URL du calendrier **sans** `?export` (d'où `URL_COLLECTION`
dérivée dans `nextcloud.js`).
- **Préfixes réels** du `multistatus` : `d:` → `DAV:`, `cal:` →
`urn:ietf:params:xml:ns:caldav` (plus `s:`, `cs:`, `oc:`, `nc:`). Ce sont des
**préfixes**, pas un contrat : sélectionner **par namespace**
(`getElementsByTagNameNS`), jamais par préfixe.
- ⚠️ **Le nom de la ressource n'est PAS l'UID** : l'événement d'UID
`db2d146b-8e18-…` vit dans `…/00D03211-DA2E-4D06-AAAE-6E8C2355BA2B.ics`.
Déduire l'URL en `<uid>.ics` **aurait échoué** — d'où le `REPORT` préalable.
- `href` du `multistatus` = chemin **absolu depuis la racine**
(`/remote.php/dav/…`) → à résoudre contre la collection.
- `getetag` est rendu **avec ses guillemets** (`"8ee26a8f…"`) : `If-Match` le
reprend **verbatim**.
--- ---
## 2. Cible : formulaire mairie (Gravity Forms, form id = 6) ## 2. Cible : formulaire mairie (Gravity Forms, form id = 6)
+155
View File
@@ -0,0 +1,155 @@
// Transport d'ÉCRITURE CalDAV : pose le statut mairie sur l'événement Nextcloud
// sans jamais deviner l'URL de la ressource ni écraser le travail d'un·e autre.
//
// Séquence mesurée sur Framaspace (cf. docs/RECHERCHE.md) :
// GET /csrftoken → REPORT (calendar-query par UID) → PUT If-Match.
// Le REPORT rend href + ETag + calendar-data : on écrit dans la ressource que le
// serveur a désignée, avec l'ETag qu'il vient de donner.
//
// IMPUR : `fetchImpl` et `extraireReponsesImpl` sont injectés (obligatoires),
// même couture que agenda-mairie.js — la boucle est testée, le DOMParser non.
// Le module rend un *type* d'erreur, jamais un message.
import { URL_COLLECTION } from "./nextcloud.js";
import { appliquerAuxCategories } from "./ics-categories.js";
const URL_JETON = new URL("/csrftoken", URL_COLLECTION).href;
const NS_DAV = "DAV:";
const NS_CALDAV = "urn:ietf:params:xml:ns:caldav";
export async function ecrireStatut({ fetchImpl, extraireReponsesImpl, uid, transformer }) {
const jeton = await recupererJetonCsrf(fetchImpl);
const report = await appeler(fetchImpl, URL_COLLECTION, {
method: "REPORT",
credentials: "include",
headers: enTetes(jeton, {
"Content-Type": "application/xml; charset=utf-8",
Depth: "1",
}),
body: requeteParUid(uid),
});
if (!report.ok) return report;
const reponses = extraireReponsesImpl(report.corps);
// Deviner est précisément ce que cette approche refuse : 0 ou plusieurs
// candidates → on n'écrit nulle part.
if (reponses.length === 0) return { ok: false, erreur: "introuvable" };
if (new Set(reponses.map((r) => r.href)).size > 1) return { ok: false, erreur: "ambigu" };
const { href, etag, ics } = reponses[0];
const reecriture = appliquerAuxCategories(ics, uid, transformer);
if (!reecriture.ok) return reecriture;
// L'href du multistatus est un chemin : résolu contre la collection. On refuse
// toute cible qui sortirait de CETTE collection : le serveur est de confiance,
// mais on n'écrit jamais dans une ressource qu'on n'a pas choisie.
const cible = resoudre(href);
if (!cible) return { ok: false, erreur: "ambigu" };
const put = await appeler(fetchImpl, cible, {
method: "PUT",
credentials: "include",
headers: enTetes(jeton, {
"Content-Type": "text/calendar; charset=utf-8",
"If-Match": etag,
}),
body: reecriture.ics,
});
if (!put.ok) return put;
return { ok: true, categories: reecriture.categories };
}
// Extraction DOM du multistatus (IMPUR : DOMParser, absent de Bun → non testé
// unitairement, comme extraireCartes). Sélection PAR NAMESPACE : le préfixe est
// libre côté serveur. Toute réponse incomplète est ignorée, jamais d'exception.
export function extraireReponses(xml) {
const doc = new DOMParser().parseFromString(xml, "application/xml");
// DOMParser ne lève JAMAIS : il rend un document <parsererror>. Sans cette
// trace, une réponse qui n'est pas du multistatus (page de login HTML,
// portail captif) se lirait « événement introuvable » — message faux.
if (doc.getElementsByTagName("parsererror").length > 0) {
console.error("Écho du Huit : réponse CalDAV illisible (pas du XML).", xml.slice(0, 300));
return [];
}
const reponses = [];
for (const reponse of doc.getElementsByTagNameNS(NS_DAV, "response")) {
const href = reponse.getElementsByTagNameNS(NS_DAV, "href")[0]?.textContent;
const etag = reponse.getElementsByTagNameNS(NS_DAV, "getetag")[0]?.textContent;
const ics = reponse.getElementsByTagNameNS(NS_CALDAV, "calendar-data")[0]?.textContent;
// `trim()` : un `calendar-data` réduit à des blancs est *truthy* mais ne
// contient aucun événement — le laisser passer, c'est écrire dans le vide.
if (!href?.trim() || !etag?.trim() || !ics?.trim()) continue;
reponses.push({ href, etag, ics });
}
return reponses;
}
// Échec NON FATAL : le serveur tranchera. Mesuré : DAV accepte l'écriture même
// sans jeton valide, mais on l'envoie quand il est disponible.
async function recupererJetonCsrf(fetchImpl) {
try {
const reponse = await fetchImpl(URL_JETON, { credentials: "include" });
if (!reponse.ok) return null;
return JSON.parse(await reponse.text()).token ?? null;
} catch {
return null;
}
}
// `new URL` LÈVE sur un href absolu malformé : ici comme ailleurs, une réponse
// serveur bizarre doit produire un refus, jamais une exception.
function resoudre(href) {
try {
const cible = new URL(href, URL_COLLECTION).href;
return cible.startsWith(URL_COLLECTION) ? cible : null;
} catch {
return null;
}
}
function enTetes(jeton, entetes) {
return jeton ? { ...entetes, requesttoken: jeton } : entetes;
}
async function appeler(fetchImpl, url, options) {
let reponse;
let corps;
try {
reponse = await fetchImpl(url, options);
// La lecture du corps est DANS le try : une connexion qui tombe pendant le
// téléchargement fait rejeter `text()`, pas `fetch`.
corps = await reponse.text();
} catch {
return { ok: false, erreur: "reseau" };
}
if (reponse.ok) return { ok: true, corps };
return { ok: false, erreur: typeErreur(reponse.status, corps) };
}
function typeErreur(status, corps) {
if (status === 412) return "conflit";
if (status === 403) return "lecture-seule";
if (status === 404) return "introuvable";
if (status === 401) return /csrf/i.test(corps) ? "csrf" : "non-connecte";
return "serveur";
}
// Les UID du flux sont d'origines diverses : échappement systématique avant
// interpolation dans le XML.
function requeteParUid(uid) {
return `<?xml version="1.0" encoding="utf-8"?>
<c:calendar-query xmlns:d="${NS_DAV}" xmlns:c="${NS_CALDAV}">
<d:prop><d:getetag/><c:calendar-data/></d:prop>
<c:filter><c:comp-filter name="VCALENDAR"><c:comp-filter name="VEVENT">
<c:prop-filter name="UID"><c:text-match collation="i;octet">${echapperXml(uid)}</c:text-match></c:prop-filter>
</c:comp-filter></c:comp-filter></c:filter>
</c:calendar-query>`;
}
function echapperXml(texte) {
return texte.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
}
+1 -1
View File
@@ -1,4 +1,4 @@
{ {
"organisateur": "L'Atelier du Huit", "organisateur": "L'Atelier du Huit",
"email": "contact@atelier-huit.fr" "email": "contact@latelierduhuit.com"
} }
+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());
}
+30 -1
View File
@@ -53,12 +53,41 @@ h1 {
color: #666; color: #666;
} }
.evenement-creer { .evenement-actions {
flex-shrink: 0; flex-shrink: 0;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.35rem;
}
.evenement-creer,
.evenement-secondaire {
font: inherit; font: inherit;
cursor: pointer; cursor: pointer;
} }
.evenement-secondaire {
border: none;
background: none;
padding: 0;
color: #666;
font-size: 0.85rem;
text-decoration: underline;
}
.evenement-statut {
color: #666;
font-size: 0.85rem;
font-weight: 600;
}
/* Ignoré : discret mais TOUJOURS visible et actionnable — un ignoré invisible
est un ignoré qu'on ne peut plus dé-ignorer. */
.evenement-ignore .evenement-infos {
opacity: 0.55;
}
.evenement-publication { .evenement-publication {
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
+171 -12
View File
@@ -1,9 +1,17 @@
import { recupererFluxCalendrier } from "./nextcloud.js"; import { recupererFluxCalendrier } from "./nextcloud.js";
import { parserEvenements } from "./evenements.js"; import { parserEvenements } from "./evenements.js";
import { formaterDate, formaterLieu, trierParDebut } from "./presentation.js"; import {
formaterDate,
formaterLieu,
formaterStatut,
repousserIgnores,
trierParDebut,
} from "./presentation.js";
import { deposerEvenement } from "./transfert.js"; import { deposerEvenement } from "./transfert.js";
import { calculerPeriode, extraireCartes, recupererAgendaPublie } from "./agenda-mairie.js"; import { calculerPeriode, extraireCartes, recupererAgendaPublie } from "./agenda-mairie.js";
import { indexerCartes, trouverPublication } from "./publication.js"; import { indexerCartes, trouverPublication } from "./publication.js";
import { ecrireStatut, extraireReponses } from "./caldav.js";
import { jourParis, lireStatut, poserIgnore, poserSoumis, retirerStatut } from "./statut-mairie.js";
const URL_NEXTCLOUD = "https://atelier-huit.frama.space/"; const URL_NEXTCLOUD = "https://atelier-huit.frama.space/";
@@ -18,8 +26,28 @@ const MESSAGES_ERREUR = {
"Impossible de joindre Nextcloud. Vérifie ta connexion internet, puis réessaie.", "Impossible de joindre Nextcloud. Vérifie ta connexion internet, puis réessaie.",
serveur: serveur:
"Nextcloud a renvoyé une erreur inattendue. Réessaie dans un moment.", "Nextcloud a renvoyé une erreur inattendue. Réessaie dans un moment.",
"lecture-seule":
"Le calendrier est partagé en lecture seule : demande le droit d'écriture pour enregistrer les statuts.",
conflit:
"Quelqu'un a modifié cet événement pendant ce temps. Recharge la liste (F5), puis réessaie.",
csrf:
"Ta session Nextcloud a expiré. Recharge Nextcloud dans un onglet, puis réessaie.",
introuvable:
"Événement introuvable dans le calendrier : il a peut-être été supprimé. Recharge la liste (F5).",
ambigu:
"Événement introuvable dans le calendrier : il a peut-être été supprimé. Recharge la liste (F5).",
"vevent-introuvable":
"Événement introuvable dans le calendrier : il a peut-être été supprimé. Recharge la liste (F5).",
"reecriture-risquee":
"Par prudence, rien n'a été écrit : cet événement a une forme inhabituelle. Ajoute la catégorie à la main dans Nextcloud.",
"ics-illisible":
"Par prudence, rien n'a été écrit : cet événement a une forme inhabituelle. Ajoute la catégorie à la main dans Nextcloud.",
}; };
// Type volontairement absent de la table : il déclenche le message de repli
// quand l'écriture échoue pour une raison qu'on n'a pas prévue.
const ERREUR_IMPREVUE = "imprevu";
const etat = document.querySelector("#etat"); const etat = document.querySelector("#etat");
const liste = document.querySelector("#liste"); const liste = document.querySelector("#liste");
@@ -71,29 +99,156 @@ function rendreEvenement(evenement) {
infos.append(titre, date, lieu); infos.append(titre, date, lieu);
const creer = document.createElement("button"); item.append(infos, rendreActions(evenement));
creer.type = "button"; refleterStatut(evenement, item);
creer.className = "evenement-creer"; return item;
creer.textContent = "Créer sur le site mairie"; }
creer.addEventListener("click", async () => {
// Zone d'actions : UNIQUE point de suppression quand l'événement se révèle
// publié (l'agenda mairie fait foi et écrase tout).
function rendreActions(evenement) {
const actions = document.createElement("div");
actions.className = "evenement-actions";
const statut = lireStatut(evenement.categories);
if (statut.type !== "aucun") {
const mention = document.createElement("span");
mention.className = "evenement-statut";
mention.textContent = formaterStatut(statut);
actions.append(mention);
}
// Un événement déjà soumis n'a plus de bouton « Créer » : c'est le doublon
// d'annonce qu'on combat. Il revient si l'envoi est annulé.
if (statut.type !== "soumis") {
actions.append(
bouton("evenement-creer", "Créer sur le site mairie", (ligne) =>
creerSurSiteMairie(evenement, ligne),
),
);
}
actions.append(gesteDeStatut(statut, evenement));
return actions;
}
function gesteDeStatut(statut, evenement) {
if (statut.type === "aucun") {
return bouton("evenement-secondaire", "Ignorer", (ligne) =>
appliquerStatut(evenement, ligne, poserIgnore),
);
}
const libelle = statut.type === "ignoré" ? "Ne plus ignorer" : "Annuler l'envoi";
return bouton("evenement-secondaire", libelle, (ligne) =>
appliquerStatut(evenement, ligne, retirerStatut),
);
}
// `action` reçoit la ligne (le <li>) : le geste ne capture pas le DOM, il le
// retrouve au clic — la ligne peut avoir été re-rendue entre-temps.
function bouton(classe, libelle, action) {
const element = document.createElement("button");
element.type = "button";
element.className = classe;
element.textContent = libelle;
element.addEventListener("click", async () => {
const ligne = element.closest(".evenement");
try { try {
await creerSurSiteMairie(evenement); await pendantEnregistrement(ligne, element, () => action(ligne));
} catch (erreur) { } catch (erreur) {
rendreEchecCreation(erreur); rendreEchecCreation(erreur);
} }
}); });
return element;
}
item.append(infos, creer); // Double clic impossible : tous les boutons de la ligne sont neutralisés le
return item; // temps de l'aller-retour. Si la ligne a été re-rendue entre-temps, ces boutons
// ne sont plus dans le DOM et les réactiver est sans effet.
async function pendantEnregistrement(ligne, declencheur, action) {
const boutons = [...ligne.querySelectorAll("button")];
const libelle = declencheur.textContent;
boutons.forEach((b) => (b.disabled = true));
declencheur.textContent = "Enregistrement…";
try {
await action();
} finally {
boutons.forEach((b) => (b.disabled = false));
declencheur.textContent = libelle;
}
}
// Écriture partagée : JAMAIS d'affichage optimiste. Tant que Nextcloud n'a pas
// confirmé, le statut affiché reste celui qu'on sait vrai — mentir sur un état
// que d'autres bénévoles lisent est pire que ne rien afficher.
async function appliquerStatut(evenement, ligne, transformer) {
const res = await ecrireStatut({
// Wrapper obligatoire (cf. charger) : un `fetch` nu lève « Illegal invocation ».
fetchImpl: (u, o) => fetch(u, o),
extraireReponsesImpl: extraireReponses,
uid: evenement.uid,
transformer,
});
if (res.ok) {
// Les catégories viennent de l'aller-retour serveur, pas de la liste
// (potentiellement périmée).
evenement.categories = res.categories;
rafraichirLigne(evenement, ligne);
// Un échec précédent laisserait sinon « rien n'a été enregistré » à l'écran
// alors que le tag vient d'être écrit : le mensonge en sens inverse.
etat.replaceChildren();
} else {
rendreEchecStatut(res.erreur);
}
return res;
}
// Re-rendu de la SEULE ligne concernée : recharger la liste effacerait les
// badges « publié » déjà posés par l'annotation asynchrone.
function rafraichirLigne(evenement, ligne) {
ligne.querySelector(".evenement-actions")?.replaceWith(rendreActions(evenement));
refleterStatut(evenement, ligne);
}
function refleterStatut(evenement, ligne) {
const ignore = lireStatut(evenement.categories).type === "ignoré";
ligne.classList.toggle("evenement-ignore", ignore);
}
// Le tag « soumis » est un confort d'équipe ; remplir le formulaire est la
// mission : un échec d'écriture n'empêche pas l'ouverture du formulaire.
async function creerSurSiteMairie(evenement, ligne) {
try {
await appliquerStatut(evenement, ligne, (categories) =>
poserSoumis(categories, jourParis(new Date())),
);
} catch (erreur) {
// Filet de sécurité : même un imprévu du chemin d'écriture ne doit pas
// retenir le formulaire. Le détail part en console, l'utilisateur lit une
// phrase, et la mission continue.
console.error("Écho du Huit : écriture du statut « soumis » interrompue.", erreur);
rendreEchecStatut(ERREUR_IMPREVUE);
} }
async function creerSurSiteMairie(evenement) {
// Déposer AVANT d'ouvrir l'onglet : sinon le content script peut lire avant // Déposer AVANT d'ouvrir l'onglet : sinon le content script peut lire avant
// l'écriture. // l'écriture.
await deposerEvenement(browser.storage.local, evenement); await deposerEvenement(browser.storage.local, evenement);
await browser.tabs.create({ url: URL_FORMULAIRE_MAIRIE }); await browser.tabs.create({ url: URL_FORMULAIRE_MAIRIE });
} }
// Échec d'un geste de statut : message honnête, statut inchangé.
function rendreEchecStatut(typeErreur) {
etat.replaceChildren();
const message = document.createElement("p");
message.className = "etat-erreur";
message.textContent =
MESSAGES_ERREUR[typeErreur] ??
"Le statut n'a pas pu être enregistré dans Nextcloud. Réessaie dans un moment.";
etat.append(message);
}
// Sans ce retour, la promesse du clic échouait en silence : le bouton paraissait // Sans ce retour, la promesse du clic échouait en silence : le bouton paraissait
// mort et la panne était indiagnosticable, y compris pour nous. Le détail // mort et la panne était indiagnosticable, y compris pour nous. Le détail
// technique est affiché volontairement — c'est ce qui permet de nommer la cause // technique est affiché volontairement — c'est ce qui permet de nommer la cause
@@ -139,7 +294,7 @@ async function charger() {
// « Illegal invocation » dans le navigateur. // « Illegal invocation » dans le navigateur.
const res = await recupererFluxCalendrier((u, o) => fetch(u, o)); const res = await recupererFluxCalendrier((u, o) => fetch(u, o));
if (res.ok) { if (res.ok) {
const evenements = trierParDebut(parserEvenements(res.flux, new Date())); const evenements = repousserIgnores(trierParDebut(parserEvenements(res.flux, new Date())));
rendreEvenements(evenements); rendreEvenements(evenements);
// Enrichissement additif : la liste est déjà rendue, l'agenda ne la // Enrichissement additif : la liste est déjà rendue, l'agenda ne la
// bloque jamais. Un échec de scraping laisse la liste intacte, sans badge. // bloque jamais. Un échec de scraping laisse la liste intacte, sans badge.
@@ -178,7 +333,11 @@ async function annoterPublications(evenements) {
// un événement déjà publié n'a pas à être re-soumis). textContent uniquement, // un événement déjà publié n'a pas à être re-soumis). textContent uniquement,
// le lien vient d'une source externe. // le lien vient d'une source externe.
function annoterItem(item, lien) { function annoterItem(item, lien) {
item.querySelector(".evenement-creer")?.remove(); item.querySelector(".evenement-actions")?.remove();
// La mairie fait foi : « publié » écrase TOUT l'affichage du statut, grisé
// compris. Sans cette ligne, un événement à la fois ignoré et publié resterait
// grisé sans plus aucun bouton — un ignoré qu'on ne peut plus dé-ignorer.
item.classList.remove("evenement-ignore");
const publication = document.createElement("div"); const publication = document.createElement("div");
publication.className = "evenement-publication"; publication.className = "evenement-publication";
+5
View File
@@ -6,6 +6,11 @@
export const URL_CALENDRIER = export const URL_CALENDRIER =
"https://atelier-huit.frama.space/remote.php/dav/calendars/Pierre/latelier-du-huit-sbastien_shared_by_admin/?export"; "https://atelier-huit.frama.space/remote.php/dav/calendars/Pierre/latelier-du-huit-sbastien_shared_by_admin/?export";
// La collection CalDAV (sans `?export`) est la cible des requêtes REPORT/PUT.
// DÉRIVÉE plutôt que recopiée : une seule URL de calendrier dans le repo, donc
// aucune dérive possible entre lecture et écriture.
export const URL_COLLECTION = URL_CALENDRIER.replace(/\?export$/, "");
// fetchImpl est injecté (obligatoire) pour la testabilité. // fetchImpl est injecté (obligatoire) pour la testabilité.
// Résultat discriminé : { ok:true, flux } | { ok:false, erreur }. // Résultat discriminé : { ok:true, flux } | { ok:false, erreur }.
export async function recupererFluxCalendrier(fetchImpl) { export async function recupererFluxCalendrier(fetchImpl) {
+20
View File
@@ -5,6 +5,7 @@
// config.js n'exporte rien : il pose globalThis.EchoConfig (cf. DECISIONS.md // config.js n'exporte rien : il pose globalThis.EchoConfig (cf. DECISIONS.md
// 2026-07-17). L'import ne sert qu'à son effet de bord. // 2026-07-17). L'import ne sert qu'à son effet de bord.
import "./config.js"; import "./config.js";
import { lireStatut } from "./statut-mairie.js";
const LOCALE = "fr-FR"; const LOCALE = "fr-FR";
const FUSEAU = "Europe/Paris"; const FUSEAU = "Europe/Paris";
@@ -85,3 +86,22 @@ function formaterHorodate({ debut, fin }) {
export function formaterLieu(evenement) { export function formaterLieu(evenement) {
return evenement.lieu.trim() || LIEU_PAR_DEFAUT; return evenement.lieu.trim() || LIEU_PAR_DEFAUT;
} }
// Statut mairie en clair. Le jour du tag est une date NUE (« AAAA-MM-JJ »),
// jamais un instant : on la découpe, on ne la convertit pas — un Date la ferait
// glisser d'un jour selon le fuseau de la machine.
export function formaterStatut(statut) {
if (statut.type === "ignoré") return "Ignoré";
if (statut.type !== "soumis") return "";
if (!statut.jour) return "Soumis";
const [, mois, jour] = statut.jour.split("-");
return `Soumis le ${jour}/${mois}`;
}
// Les ignorés restent VISIBLES (on doit pouvoir les dé-ignorer) mais cessent
// d'encombrer : ils passent en fin de liste, chaque groupe gardant son ordre.
export function repousserIgnores(evenements) {
const ignore = (evenement) => lireStatut(evenement.categories).type === "ignoré";
return [...evenements.filter((e) => !ignore(e)), ...evenements.filter(ignore)];
}
+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);
});
});
+9
View File
@@ -1,6 +1,7 @@
import { describe, expect, test } from "bun:test"; import { describe, expect, test } from "bun:test";
import { import {
URL_CALENDRIER, URL_CALENDRIER,
URL_COLLECTION,
recupererFluxCalendrier, recupererFluxCalendrier,
} from "./extension/nextcloud.js"; } from "./extension/nextcloud.js";
@@ -64,3 +65,11 @@ describe("recupererFluxCalendrier", () => {
expect(optionsVues).toEqual({ credentials: "include" }); expect(optionsVues).toEqual({ credentials: "include" });
}); });
}); });
describe("URL_COLLECTION", () => {
test("= URL_CALENDRIER sans ?export, et se termine par /", () => {
expect(URL_COLLECTION).toBe(URL_CALENDRIER.replace("?export", ""));
expect(URL_COLLECTION.endsWith("/")).toBe(true);
expect(URL_COLLECTION).not.toContain("?export");
});
});
+43
View File
@@ -4,6 +4,8 @@ import {
LIEU_PAR_DEFAUT, LIEU_PAR_DEFAUT,
formaterDate, formaterDate,
formaterLieu, formaterLieu,
formaterStatut,
repousserIgnores,
trierParDebut, trierParDebut,
} from "./extension/presentation.js"; } from "./extension/presentation.js";
@@ -213,3 +215,44 @@ describe("formaterLieu", () => {
expect(formaterLieu(evenement({ lieu: " " }))).toBe(LIEU_PAR_DEFAUT); expect(formaterLieu(evenement({ lieu: " " }))).toBe(LIEU_PAR_DEFAUT);
}); });
}); });
describe("formaterStatut", () => {
test("soumis avec date → « Soumis le JJ/MM »", () => {
expect(formaterStatut({ type: "soumis", jour: "2026-08-13" })).toBe("Soumis le 13/08");
});
test("soumis sans date exploitable → « Soumis »", () => {
expect(formaterStatut({ type: "soumis", jour: null })).toBe("Soumis");
});
test("ignoré → « Ignoré » ; aucun → rien à afficher", () => {
expect(formaterStatut({ type: "ignoré" })).toBe("Ignoré");
expect(formaterStatut({ type: "aucun" })).toBe("");
});
});
describe("repousserIgnores", () => {
test("les ignorés passent en fin, l'ordre relatif est conservé", () => {
const evenements = [
evenement({ uid: "a" }),
evenement({ uid: "ignore-1", categories: ["mairie:ignoré"] }),
evenement({ uid: "b", categories: ["Concert"] }),
evenement({ uid: "ignore-2", categories: ["Concert", "mairie:ignoré"] }),
evenement({ uid: "c", categories: ["mairie:soumis:2026-08-13"] }),
];
expect(repousserIgnores(evenements).map((e) => e.uid)).toEqual([
"a",
"b",
"c",
"ignore-1",
"ignore-2",
]);
});
test("liste sans ignoré → inchangée, sans mutation de l'entrée", () => {
const evenements = [evenement({ uid: "a" }), evenement({ uid: "b" })];
expect(repousserIgnores(evenements).map((e) => e.uid)).toEqual(["a", "b"]);
expect(evenements.map((e) => e.uid)).toEqual(["a", "b"]);
});
});
+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");
});
});