Merge branch 'trouver-l-url-ical-publique-nextcloud-o42d'
This commit is contained in:
@@ -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]
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
<body>
|
||||
<main>
|
||||
<h1>Écho du Huit</h1>
|
||||
<div id="etat" role="status" aria-live="polite"></div>
|
||||
<ul id="liste"></ul>
|
||||
</main>
|
||||
<script type="module" src="liste.js"></script>
|
||||
|
||||
+68
-7
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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("<!DOCTYPE html><html><body>Login</body></html>"),
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user