diff --git a/docs/solutions/2026-06-30-trouver-l-url-ical-publique-nextcloud-o42d-solution.md b/docs/solutions/2026-06-30-trouver-l-url-ical-publique-nextcloud-o42d-solution.md new file mode 100644 index 0000000..53816df --- /dev/null +++ b/docs/solutions/2026-06-30-trouver-l-url-ical-publique-nextcloud-o42d-solution.md @@ -0,0 +1,117 @@ +# Solution : [T1] Lire les événements via CalDAV (session navigateur) + +## Problème résolu +Donner à l'extension sa matière première : le flux iCalendar **brut** du +calendrier Nextcloud de l'Atelier du Huit, en **réutilisant la session du +navigateur** (cookies), sans jamais stocker de mot de passe. Frontière T1 +tenue : on s'arrête au **flux brut vérifié** + une preuve de vie (comptage des +`VEVENT`). Le parsing ical.js, le dédoublonnage par UID, les RRULE/fuseaux et le +filtrage « à venir » sont **hors T1** (tâche T2). + +Le vrai enjeu n'était pas « fetcher une URL » mais **savoir qu'on tient bien un +calendrier** : Nextcloud peut renvoyer une page de login en **HTTP 200 HTML**, +donc `response.ok` ne suffit pas à garantir la validité du flux. + +## Approche choisie +`GET …/?export` + `fetch` direct depuis la **page** de l'extension +(`credentials:"include"`), validation du flux par **sniffing du corps** +(`trimStart().startsWith("BEGIN:VCALENDAR")`). Transport **isolé dans son propre +module** `extension/nextcloud.js`, pur de toute présentation : il renvoie un +*type* d'erreur, pas un message. + +Pourquoi pas les alternatives : +- **CalDAV REPORT + `time-range`** (filtre serveur, expansion RRULE) : corps XML + à construire/maintenir, multi-statut 207, sémantique d'expansion à valider — + surdimensionné pour 473 événements. Le `?export` brut suffit pour T1. +- **Couche réseau centralisée dans le background** : utile plus tard (lecture + CalDAV des statuts, cache), mais sur-ingénierie tant qu'il n'y a qu'un + consommateur. On garde le déplacement **peu coûteux** grâce au module isolé. + +## Décisions clés +- **Résultat discriminé** `{ ok:true, flux } | { ok:false, erreur }` avec + `erreur ∈ {"non-connecte","reseau","serveur"}`. Le mapping type → message FR + actionnable vit dans `liste.js`, pas dans le transport. Séparation nette + données / présentation. +- **`fetchImpl` injecté et obligatoire** (convention « params obligatoires par + défaut ») : le module est testable **sans réseau réel** (Bun fabrique des + `Response`), et le wiring navigateur reste un détail de l'appelant. +- **Ordre des branches** : 401/403 traités **avant** `!reponse.ok` (sinon le + générique « serveur » les masquerait), puis sniffing du corps en dernier. +- **Détection « non connecté » par sniffing**, pas par `redirect:"manual"` : le + corps après redirection-login (200 HTML) est déjà détecté ; inutile de + complexifier. 401/403 captés en amont pour le cas réel. +- **`manifest.json` intact** : `host_permissions` couvrait déjà + `https://atelier-huit.frama.space/*`. Changement purement additif. +- **Constatation terrain (testée contre le vrai serveur)** : sans cookie, + Nextcloud répond **401** (`Sabre\DAV\Exception\NotAuthenticated`, 0 redirect), + **pas** un 200 HTML de login. Le sniffing du corps reste une ceinture-et- + bretelles utile (cas redirection possible selon config), mais la branche + réellement empruntée « pas connecté » est le **401**. + +## Patterns à réutiliser +**Transport injectable, pur, à résultat typé** (testable sans réseau) : +```js +export async function recupererFluxCalendrier(fetchImpl) { + let reponse; + try { reponse = await fetchImpl(URL_CALENDRIER, { credentials: "include" }); } + catch { return { ok: false, erreur: "reseau" }; } // fetch lève = réseau + if (reponse.status === 401 || reponse.status === 403) + return { ok: false, erreur: "non-connecte" }; // session refusée + if (!reponse.ok) return { ok: false, erreur: "serveur" }; + const flux = await reponse.text(); + if (!flux.trimStart().startsWith("BEGIN:VCALENDAR")) // login 200 HTML + return { ok: false, erreur: "non-connecte" }; + return { ok: true, flux }; +} +``` + +**Comptage ancré ligne** (ne compte pas une occurrence dans une `DESCRIPTION`, +tolère le CRLF de la RFC 5545) : +```js +export function compterEvenements(flux) { + return (flux.match(/^BEGIN:VEVENT\s*$/gm) ?? []).length; +} +``` + +**Wrapper `fetch` côté page** (voir piège ci-dessous) : +```js +const res = await recupererFluxCalendrier((u, o) => fetch(u, o)); +``` + +**Rendu d'état reconstruit à chaque tentative** : `etat.replaceChildren()` avant +de re-rendre → pas d'empilement de messages/boutons/listeners quand on clique +*Réessayer* plusieurs fois. Région `#etat` en `role="status" +aria-live="polite"` pour l'accessibilité. + +**Tests Bun important un module ES du navigateur** : `import { … } from +"./extension/nextcloud.js"`, `fetchImpl` remplacé par +`async () => new Response(corps, { status })` ou un `throw`. Aucun jsdom, aucun +réseau. + +## Pièges à éviter +- **`response.ok` ne prouve pas un calendrier** : la page de login peut arriver + en **200 HTML**. Toujours valider le *contenu* (`BEGIN:VCALENDAR`), pas que le + statut. C'est le piège central de la tâche. +- **`fetch` nu détaché de `window`** lève « Illegal invocation » dans le + navigateur. Passer un wrapper `(u, o) => fetch(u, o)` (ou `fetch.bind(window)`), + jamais `fetch` nu en argument. +- **Regex non ancrée** : `/BEGIN:VEVENT/g` compterait une occurrence apparaissant + dans une `DESCRIPTION`. Ancrer `^…$` + flag `m`. +- **Ordre des branches d'erreur** : tester 401/403 avant le `!ok` générique, + sinon « non-connecte » serait noyé dans « serveur ». +- **BOM / espaces en tête** du flux : `trimStart()` avant la comparaison + `startsWith`, sinon un VCALENDAR valide précédé d'un BOM serait rejeté. +- **0 VEVENT n'est pas une erreur** : un calendrier valide mais vide est un + succès (« 0 événement lu »), à ne pas confondre avec un échec. +- **CSS mort** : la classe `.liste-vide` (squelette) n'est plus utilisée depuis + que `liste.js` orchestre `#etat`. Signalée P3 en review, **laissée + volontairement** car T2 (rendu détaillé de la liste) la réemploiera. À nettoyer + si T2 ne la reprend pas. +- **Warning `MISSING_DATA_COLLECTION_PERMISSIONS`** (web-ext) : préexistant sur + `manifest.json` (commit squelette), **non touché** par T1, hors périmètre — pas + une régression. À traiter dans une tâche dédiée au manifest. + +## Tags +tags: [firefox-extension, manifest-v3, webextension, vanilla-js, caldav, ical, +nextcloud, fetch, session-cookies, bun-test, dependency-injection, error-handling, +low-tech] diff --git a/extension/liste.css b/extension/liste.css index 5bfee0a..a2d5c68 100644 --- a/extension/liste.css +++ b/extension/liste.css @@ -27,3 +27,25 @@ h1 { color: #666; font-style: italic; } + +#etat { + margin-bottom: 1.5rem; +} + +.etat-neutre { + color: #666; +} + +.etat-erreur { + color: #b3261e; +} + +#etat a { + display: inline-block; + margin-right: 1rem; +} + +#etat button { + font: inherit; + cursor: pointer; +} diff --git a/extension/liste.html b/extension/liste.html index a19bc08..fdb6481 100644 --- a/extension/liste.html +++ b/extension/liste.html @@ -9,6 +9,7 @@

Écho du Huit

+
diff --git a/extension/liste.js b/extension/liste.js index 10a2141..82d662b 100644 --- a/extension/liste.js +++ b/extension/liste.js @@ -1,8 +1,69 @@ -// Squelette : la récupération des deux sources (Atelier du Huit, ville de -// Cugnaux) arrivera dans des tâches ultérieures. Pour l'instant, état vide. -const liste = document.querySelector("#liste"); +import { + compterEvenements, + recupererFluxCalendrier, +} from "./nextcloud.js"; -const vide = document.createElement("li"); -vide.className = "liste-vide"; -vide.textContent = "Aucune donnée pour l'instant."; -liste.append(vide); +const URL_NEXTCLOUD = "https://atelier-huit.frama.space/"; + +// Mapping type d'erreur → message FR non technique et actionnable. +const MESSAGES_ERREUR = { + "non-connecte": + "Tu n'es pas connecté·e à Nextcloud. Ouvre Nextcloud dans un onglet, connecte-toi, puis reviens et clique sur Réessayer.", + reseau: + "Impossible de joindre Nextcloud. Vérifie ta connexion internet, puis réessaie.", + serveur: + "Nextcloud a renvoyé une erreur inattendue. Réessaie dans un moment.", +}; + +const etat = document.querySelector("#etat"); + +function rendreNeutre(texte) { + etat.replaceChildren(); + const p = document.createElement("p"); + p.className = "etat-neutre"; + p.textContent = texte; + etat.append(p); +} + +function rendreSucces(flux) { + const nombre = compterEvenements(flux); + rendreNeutre(`${nombre} événement${nombre > 1 ? "s" : ""} lu${nombre > 1 ? "s" : ""}`); +} + +function rendreErreur(typeErreur) { + etat.replaceChildren(); + + const message = document.createElement("p"); + message.className = "etat-erreur"; + message.textContent = MESSAGES_ERREUR[typeErreur]; + etat.append(message); + + if (typeErreur === "non-connecte") { + const lien = document.createElement("a"); + lien.href = URL_NEXTCLOUD; + lien.target = "_blank"; + lien.rel = "noopener"; + lien.textContent = "Ouvrir Nextcloud"; + etat.append(lien); + } + + const reessayer = document.createElement("button"); + reessayer.type = "button"; + reessayer.textContent = "Réessayer"; + reessayer.addEventListener("click", charger); + etat.append(reessayer); +} + +async function charger() { + rendreNeutre("Lecture du calendrier…"); + // Wrapper obligatoire : un `fetch` nu détaché de `window` lève + // « Illegal invocation » dans le navigateur. + const res = await recupererFluxCalendrier((u, o) => fetch(u, o)); + if (res.ok) { + rendreSucces(res.flux); + } else { + rendreErreur(res.erreur); + } +} + +charger(); diff --git a/extension/nextcloud.js b/extension/nextcloud.js new file mode 100644 index 0000000..37c5087 --- /dev/null +++ b/extension/nextcloud.js @@ -0,0 +1,38 @@ +// Transport CalDAV isolé : récupère le flux iCalendar brut du calendrier +// Nextcloud de l'Atelier du Huit en réutilisant la session du navigateur +// (cookies). Aucun mot de passe stocké. Le module reste pur de toute +// présentation : il renvoie un *type* d'erreur, pas un message. + +export const URL_CALENDRIER = + "https://atelier-huit.frama.space/remote.php/dav/calendars/Pierre/latelier-du-huit-sbastien_shared_by_admin/?export"; + +// fetchImpl est injecté (obligatoire) pour la testabilité. +// Résultat discriminé : { ok:true, flux } | { ok:false, erreur }. +export async function recupererFluxCalendrier(fetchImpl) { + let reponse; + try { + reponse = await fetchImpl(URL_CALENDRIER, { credentials: "include" }); + } catch { + return { ok: false, erreur: "reseau" }; + } + + if (reponse.status === 401 || reponse.status === 403) { + return { ok: false, erreur: "non-connecte" }; + } + if (!reponse.ok) { + return { ok: false, erreur: "serveur" }; + } + + const flux = await reponse.text(); + // Piège central : une redirection vers la page de login renvoie un 200 HTML. + if (!flux.trimStart().startsWith("BEGIN:VCALENDAR")) { + return { ok: false, erreur: "non-connecte" }; + } + return { ok: true, flux }; +} + +// Preuve de vie de T1 : compte les vrais débuts de VEVENT (ancrage ligne, pas +// une chaîne « BEGIN:VEVENT » apparaissant dans une DESCRIPTION). +export function compterEvenements(flux) { + return (flux.match(/^BEGIN:VEVENT\s*$/gm) ?? []).length; +} diff --git a/nextcloud.test.ts b/nextcloud.test.ts new file mode 100644 index 0000000..d952c61 --- /dev/null +++ b/nextcloud.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test"; +import { + URL_CALENDRIER, + compterEvenements, + recupererFluxCalendrier, +} from "./extension/nextcloud.js"; + +const FLUX_VALIDE = + "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:Atelier\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + +function fetchQuiRenvoie(corps: string, status = 200) { + return async () => new Response(corps, { status }); +} + +describe("recupererFluxCalendrier", () => { + test("succès : 200 + VCALENDAR → { ok:true, flux }", async () => { + const res = await recupererFluxCalendrier(fetchQuiRenvoie(FLUX_VALIDE)); + expect(res).toEqual({ ok: true, flux: FLUX_VALIDE }); + }); + + test("non-connecté : 200 + page de login HTML → non-connecte", async () => { + const res = await recupererFluxCalendrier( + fetchQuiRenvoie("Login"), + ); + expect(res).toEqual({ ok: false, erreur: "non-connecte" }); + }); + + test("non-connecté : status 401 → non-connecte", async () => { + const res = await recupererFluxCalendrier(fetchQuiRenvoie("", 401)); + expect(res).toEqual({ ok: false, erreur: "non-connecte" }); + }); + + test("non-connecté : status 403 → non-connecte", async () => { + const res = await recupererFluxCalendrier(fetchQuiRenvoie("", 403)); + expect(res).toEqual({ ok: false, erreur: "non-connecte" }); + }); + + test("serveur : status 500 → serveur", async () => { + const res = await recupererFluxCalendrier(fetchQuiRenvoie("", 500)); + expect(res).toEqual({ ok: false, erreur: "serveur" }); + }); + + test("réseau : fetch lève → reseau", async () => { + const res = await recupererFluxCalendrier(async () => { + throw new TypeError("Failed to fetch"); + }); + expect(res).toEqual({ ok: false, erreur: "reseau" }); + }); + + test("BOM / espaces en tête : reste un succès", async () => { + const corps = "\r\n" + FLUX_VALIDE; + const res = await recupererFluxCalendrier(fetchQuiRenvoie(corps)); + expect(res).toEqual({ ok: true, flux: corps }); + }); + + test("appelle fetchImpl avec URL_CALENDRIER et credentials:include", async () => { + let urlVue: unknown; + let optionsVues: unknown; + await recupererFluxCalendrier(async (url, options) => { + urlVue = url; + optionsVues = options; + return new Response(FLUX_VALIDE); + }); + expect(urlVue).toBe(URL_CALENDRIER); + expect(optionsVues).toEqual({ credentials: "include" }); + }); +}); + +describe("compterEvenements", () => { + test("compte 2 VEVENT", () => { + const flux = + "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nEND:VEVENT\r\nBEGIN:VEVENT\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + expect(compterEvenements(flux)).toBe(2); + }); + + test("compte 0 quand aucun VEVENT", () => { + expect(compterEvenements("BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n")).toBe(0); + }); + + test("ignore un BEGIN:VEVENT au milieu d'une DESCRIPTION", () => { + const flux = + "BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nDESCRIPTION:texte BEGIN:VEVENT piège\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n"; + expect(compterEvenements(flux)).toBe(1); + }); +});