Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e7338d777b | ||
|
|
c6b0e7799a | ||
|
|
69c10abbd7 | ||
|
|
058229dce3 | ||
|
|
3248b28e1f |
+4
-2
@@ -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
|
||||
|
||||
+291
@@ -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&b<c>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");
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
Fetch de l'agenda **sans** `credentials` (public). Aucune modification de
|
||||
`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é ».
|
||||
|
||||
@@ -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()`,
|
||||
> 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)
|
||||
|
||||
@@ -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("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"organisateur": "L'Atelier du Huit",
|
||||
"email": "contact@atelier-huit.fr"
|
||||
"email": "contact@latelierduhuit.com"
|
||||
}
|
||||
|
||||
@@ -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
@@ -53,12 +53,41 @@ h1 {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.evenement-creer {
|
||||
.evenement-actions {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.evenement-creer,
|
||||
.evenement-secondaire {
|
||||
font: inherit;
|
||||
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 {
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
|
||||
+172
-13
@@ -1,9 +1,17 @@
|
||||
import { recupererFluxCalendrier } from "./nextcloud.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 { calculerPeriode, extraireCartes, recupererAgendaPublie } from "./agenda-mairie.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/";
|
||||
|
||||
@@ -18,8 +26,28 @@ const MESSAGES_ERREUR = {
|
||||
"Impossible de joindre Nextcloud. Vérifie ta connexion internet, puis réessaie.",
|
||||
serveur:
|
||||
"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 liste = document.querySelector("#liste");
|
||||
|
||||
@@ -71,29 +99,156 @@ function rendreEvenement(evenement) {
|
||||
|
||||
infos.append(titre, date, lieu);
|
||||
|
||||
const creer = document.createElement("button");
|
||||
creer.type = "button";
|
||||
creer.className = "evenement-creer";
|
||||
creer.textContent = "Créer sur le site mairie";
|
||||
creer.addEventListener("click", async () => {
|
||||
item.append(infos, rendreActions(evenement));
|
||||
refleterStatut(evenement, item);
|
||||
return item;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
await creerSurSiteMairie(evenement);
|
||||
await pendantEnregistrement(ligne, element, () => action(ligne));
|
||||
} catch (erreur) {
|
||||
rendreEchecCreation(erreur);
|
||||
}
|
||||
});
|
||||
|
||||
item.append(infos, creer);
|
||||
return item;
|
||||
return element;
|
||||
}
|
||||
|
||||
async function creerSurSiteMairie(evenement) {
|
||||
// Double clic impossible : tous les boutons de la ligne sont neutralisés le
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Déposer AVANT d'ouvrir l'onglet : sinon le content script peut lire avant
|
||||
// l'écriture.
|
||||
await deposerEvenement(browser.storage.local, evenement);
|
||||
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
|
||||
// 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
|
||||
@@ -139,7 +294,7 @@ async function charger() {
|
||||
// « Illegal invocation » dans le navigateur.
|
||||
const res = await recupererFluxCalendrier((u, o) => fetch(u, o));
|
||||
if (res.ok) {
|
||||
const evenements = trierParDebut(parserEvenements(res.flux, new Date()));
|
||||
const evenements = repousserIgnores(trierParDebut(parserEvenements(res.flux, new Date())));
|
||||
rendreEvenements(evenements);
|
||||
// Enrichissement additif : la liste est déjà rendue, l'agenda ne la
|
||||
// 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,
|
||||
// le lien vient d'une source externe.
|
||||
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");
|
||||
publication.className = "evenement-publication";
|
||||
|
||||
@@ -6,6 +6,11 @@
|
||||
export const URL_CALENDRIER =
|
||||
"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é.
|
||||
// Résultat discriminé : { ok:true, flux } | { ok:false, erreur }.
|
||||
export async function recupererFluxCalendrier(fetchImpl) {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// config.js n'exporte rien : il pose globalThis.EchoConfig (cf. DECISIONS.md
|
||||
// 2026-07-17). L'import ne sert qu'à son effet de bord.
|
||||
import "./config.js";
|
||||
import { lireStatut } from "./statut-mairie.js";
|
||||
|
||||
const LOCALE = "fr-FR";
|
||||
const FUSEAU = "Europe/Paris";
|
||||
@@ -85,3 +86,22 @@ function formaterHorodate({ debut, fin }) {
|
||||
export function formaterLieu(evenement) {
|
||||
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)];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
URL_CALENDRIER,
|
||||
URL_COLLECTION,
|
||||
recupererFluxCalendrier,
|
||||
} from "./extension/nextcloud.js";
|
||||
|
||||
@@ -64,3 +65,11 @@ describe("recupererFluxCalendrier", () => {
|
||||
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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
LIEU_PAR_DEFAUT,
|
||||
formaterDate,
|
||||
formaterLieu,
|
||||
formaterStatut,
|
||||
repousserIgnores,
|
||||
trierParDebut,
|
||||
} from "./extension/presentation.js";
|
||||
|
||||
@@ -213,3 +215,44 @@ describe("formaterLieu", () => {
|
||||
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"]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user