Merge branch 'verifier-la-publication-sur-l-agenda-mairie-l-e6h6'
This commit is contained in:
@@ -0,0 +1,117 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
calculerPeriode,
|
||||||
|
construireUrlPage,
|
||||||
|
recupererAgendaPublie,
|
||||||
|
} from "./extension/agenda-mairie.js";
|
||||||
|
|
||||||
|
function evenement(surcharges: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
uid: "uid-1",
|
||||||
|
titre: "Atelier",
|
||||||
|
debut: new Date("2026-07-10T16:00:00Z"),
|
||||||
|
fin: new Date("2026-07-10T18:00:00Z"),
|
||||||
|
journeeEntiere: false,
|
||||||
|
...surcharges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("calculerPeriode", () => {
|
||||||
|
test("debut = mois d'aujourd'hui ; fin = mois du dernier début", () => {
|
||||||
|
const evenements = [
|
||||||
|
evenement({ debut: new Date("2026-07-20T10:00:00Z") }),
|
||||||
|
evenement({ debut: new Date("2026-09-05T10:00:00Z") }),
|
||||||
|
evenement({ debut: new Date("2026-08-01T10:00:00Z") }),
|
||||||
|
];
|
||||||
|
const aujourdhui = new Date("2026-07-17T09:00:00Z");
|
||||||
|
|
||||||
|
expect(calculerPeriode(evenements, aujourdhui)).toEqual({
|
||||||
|
debut: "07/2026",
|
||||||
|
fin: "09/2026",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("liste vide → période bornée au mois courant", () => {
|
||||||
|
const aujourdhui = new Date("2026-07-17T09:00:00Z");
|
||||||
|
expect(calculerPeriode([], aujourdhui)).toEqual({ debut: "07/2026", fin: "07/2026" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("construireUrlPage", () => {
|
||||||
|
const periode = { debut: "07/2026", fin: "09/2026" };
|
||||||
|
|
||||||
|
test("page 1 → URL de base sans segment /page/", () => {
|
||||||
|
const url = construireUrlPage(1, periode);
|
||||||
|
expect(url).toBe(
|
||||||
|
"https://www.ville-cugnaux.fr/mes-loisirs/agenda/?f=1&theme=&date=periode&date_debut=07%2F2026&date_fin=09%2F2026&public=",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("page 3 → segment /page/3/", () => {
|
||||||
|
const url = construireUrlPage(3, periode);
|
||||||
|
expect(url).toContain("/mes-loisirs/agenda/page/3/?f=1");
|
||||||
|
expect(url).toContain("date_debut=07%2F2026");
|
||||||
|
expect(url).toContain("date_fin=09%2F2026");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("recupererAgendaPublie", () => {
|
||||||
|
const periode = { debut: "07/2026", fin: "09/2026" };
|
||||||
|
const reponseOk = (html: string) => Promise.resolve({ ok: true, text: () => Promise.resolve(html) });
|
||||||
|
|
||||||
|
test("3 pages non vides puis une vide → cartes agrégées, arrêt sur la vide", async () => {
|
||||||
|
const pages = [
|
||||||
|
[{ date: "2026-07-01", titre: "A", lien: "l1" }],
|
||||||
|
[{ date: "2026-07-02", titre: "B", lien: "l2" }],
|
||||||
|
[{ date: "2026-07-03", titre: "C", lien: "l3" }],
|
||||||
|
[],
|
||||||
|
];
|
||||||
|
const fetchImpl = (url: string) => reponseOk(url);
|
||||||
|
let appel = 0;
|
||||||
|
const extraire = () => pages[appel++];
|
||||||
|
|
||||||
|
const res = await recupererAgendaPublie(fetchImpl, extraire, periode);
|
||||||
|
|
||||||
|
expect(res).toEqual({
|
||||||
|
ok: true,
|
||||||
|
cartes: [
|
||||||
|
{ date: "2026-07-01", titre: "A", lien: "l1" },
|
||||||
|
{ date: "2026-07-02", titre: "B", lien: "l2" },
|
||||||
|
{ date: "2026-07-03", titre: "C", lien: "l3" },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(appel).toBe(4); // 3 pages pleines + la vide qui stoppe
|
||||||
|
});
|
||||||
|
|
||||||
|
test("plafond de sécurité respecté (extracteur toujours non vide)", async () => {
|
||||||
|
let appels = 0;
|
||||||
|
const fetchImpl = (url: string) => {
|
||||||
|
appels++;
|
||||||
|
return reponseOk(url);
|
||||||
|
};
|
||||||
|
const extraire = () => [{ date: "2026-07-01", titre: "A", lien: "l" }];
|
||||||
|
|
||||||
|
const res = await recupererAgendaPublie(fetchImpl, extraire, periode);
|
||||||
|
|
||||||
|
expect(res.ok).toBe(true);
|
||||||
|
expect(appels).toBe(12); // plafond
|
||||||
|
});
|
||||||
|
|
||||||
|
test("fetch qui throw → { ok:false } (jamais d'exception)", async () => {
|
||||||
|
const fetchImpl = () => Promise.reject(new Error("réseau"));
|
||||||
|
const extraire = () => [];
|
||||||
|
|
||||||
|
const res = await recupererAgendaPublie(fetchImpl, extraire, periode);
|
||||||
|
|
||||||
|
expect(res).toEqual({ ok: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("réponse HTTP non-ok (500) → { ok:false }", async () => {
|
||||||
|
const fetchImpl = () => Promise.resolve({ ok: false, text: () => Promise.resolve("") });
|
||||||
|
const extraire = () => [{ date: "2026-07-01", titre: "A", lien: "l" }];
|
||||||
|
|
||||||
|
const res = await recupererAgendaPublie(fetchImpl, extraire, periode);
|
||||||
|
|
||||||
|
expect(res).toEqual({ ok: false });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -181,3 +181,40 @@ sont le vrai risque, invisible sous `TZ=Europe/Paris`) ; `formulaire-mairie.js`
|
|||||||
est fin, impur et **non testé unitairement** — aucun harnais DOM dans le repo, et
|
est fin, impur et **non testé unitairement** — aucun harnais DOM dans le repo, et
|
||||||
happy-dom n'émulerait de toute façon pas Gravity Forms. C'est le test humain qui
|
happy-dom n'émulerait de toute façon pas Gravity Forms. C'est le test humain qui
|
||||||
tranche.
|
tranche.
|
||||||
|
|
||||||
|
## 2026-07-17 — [T2] Publication mairie : dérivée, paginée, seam pur/impur
|
||||||
|
|
||||||
|
Le statut *publié* est **dérivé** de l'agenda public de la mairie (pas de tag),
|
||||||
|
en retrouvant l'événement Nextcloud par **date locale de Cugnaux + titre
|
||||||
|
normalisé**. Il donne aussi le **lien direct** (lu dans la carte).
|
||||||
|
|
||||||
|
**Corrections factuelles vérifiées en phase plan :**
|
||||||
|
|
||||||
|
- **L'agenda est PAGINÉ** : `.../agenda/page/N/?f=1&…`. Un seul fetch « période »
|
||||||
|
ne suffit pas → on boucle jusqu'à une page vide (plafond de sécurité anti-boucle).
|
||||||
|
- **Le slug porte des suffixes WordPress** (« Atelier dessin » →
|
||||||
|
`/agenda/atelier-dessin-2/`) : on matche sur **date + titre**, jamais sur le
|
||||||
|
slug. Le lien est **lu** dans la carte, jamais reconstruit.
|
||||||
|
- `<time datetime="YYYY-MM-DD">` est une **date nue** (pas d'heure) → comparer à
|
||||||
|
la date locale de Cugnaux du `debut`, jamais à une conversion de fuseau.
|
||||||
|
Journée entière = date flottante → composantes locales (comme `presentation.js`).
|
||||||
|
|
||||||
|
**Seam extraction impure / matching pur** (même logique que
|
||||||
|
`formulaire-valeurs.js` vs `formulaire-mairie.js`) : `DOMParser` est **absent de
|
||||||
|
Bun**, donc `extraireCartes(html)` (`agenda-mairie.js`) est impur et **non testé
|
||||||
|
unitairement** (test humain sur l'agenda réel) ; le **matching** (`publication.js`)
|
||||||
|
et la boucle de pagination (via impls injectées) sont purs et **testés à fond** —
|
||||||
|
c'est le vrai risque produit.
|
||||||
|
|
||||||
|
**Biais PRÉCISION v1** : match STRICT (date identique + titre normalisé
|
||||||
|
identique). Titre proche mais non strictement identique le même jour → on
|
||||||
|
**n'affirme pas** « publié » (faux négatif bénin ; le formulaire modéré rattrape
|
||||||
|
un doublon éventuel). Dans le doute — agenda injoignable, HTTP ≠ 200, DOMParser
|
||||||
|
qui lève, structure HTML changée — la liste s'affiche **sans badge** (dégradation
|
||||||
|
gracieuse, jamais d'exception, aucun faux positif). Deux cartes de même clé
|
||||||
|
(même jour + même titre normalisé, rare) : la **dernière indexée** l'emporte.
|
||||||
|
|
||||||
|
**Périmètre v1 = badge « publié » + lien uniquement.** L'annotation est
|
||||||
|
**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`).
|
||||||
|
|||||||
+5
-2
@@ -120,9 +120,12 @@ URL filtrée par mois :
|
|||||||
|
|
||||||
### Structure HTML d'un résultat (confirmée)
|
### Structure HTML d'un résultat (confirmée)
|
||||||
|
|
||||||
Chaque événement publié = `<div class="card card-thumbnail card-event ...">` contenant :
|
Chaque événement publié = `<article class="card card-thumbnail card-event ...">`
|
||||||
|
contenant (balise `<article>`, PAS `<div>` : vérifié sur l'agenda réel le
|
||||||
|
2026-07-17 — matcher sur la classe `.card-event`, agnostique de balise) :
|
||||||
- **Date** : `<time datetime="YYYY-MM-DD">` (ancre de match la plus fiable)
|
- **Date** : `<time datetime="YYYY-MM-DD">` (ancre de match la plus fiable)
|
||||||
- **Lien direct** : `<a href="https://www.ville-cugnaux.fr/agenda/<slug>/">` — c'est le lien à afficher dans l'extension
|
- **Lien direct** : `<a href="/agenda/<slug>/">` — parfois **relatif**, à
|
||||||
|
résoudre contre l'agenda avant affichage ; c'est le lien à afficher dans l'extension
|
||||||
- **Titre** : `.card-title > a`
|
- **Titre** : `.card-title > a`
|
||||||
- **Thème** : classe `event_theme-<slug>`
|
- **Thème** : classe `event_theme-<slug>`
|
||||||
|
|
||||||
|
|||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
# Solution : [T2] Vérifier la publication sur l'agenda mairie (+ lien direct)
|
||||||
|
|
||||||
|
## Problème résolu
|
||||||
|
|
||||||
|
Un bénévole qui avait poussé un événement vers le formulaire mairie n'avait
|
||||||
|
**aucun retour** : le formulaire est modéré, la publication arrive plus tard, et
|
||||||
|
rien dans l'Écho du Huit ne disait « c'est en ligne ». On ne savait pas si un
|
||||||
|
événement était *à faire* ou *déjà publié*, et pour le retrouver sur le site il
|
||||||
|
fallait le chercher à la main — au risque de le re-soumettre.
|
||||||
|
|
||||||
|
Objectif tenu : l'extension **détecte** qu'un événement Nextcloud est réellement
|
||||||
|
publié sur l'agenda public de la mairie, affiche un badge **« Publié »** + le
|
||||||
|
**lien direct** vers la fiche, et **retire le bouton « Créer »** (anti-doublon).
|
||||||
|
Ce statut *publié* est **dérivé** de l'agenda à la volée (décision 2026-06-29),
|
||||||
|
jamais un tag écrit dans Nextcloud : c'est la mairie qui fait foi, pas notre
|
||||||
|
intention. Aucun stockage, aucun `credentials` (agenda public).
|
||||||
|
|
||||||
|
## Approche choisie
|
||||||
|
|
||||||
|
**Scraping HTML de l'agenda + match strict `date locale Cugnaux + titre
|
||||||
|
normalisé`, biais précision v1** (approche 1 du brainstorm, variante
|
||||||
|
conservatrice).
|
||||||
|
|
||||||
|
Il n'y a **pas d'API mairie** : la seule source est le HTML public. Le vrai
|
||||||
|
risque n'était donc pas « aller chercher la page » mais **la fiabilité du
|
||||||
|
match** — et il est gouverné par une **asymétrie de risque** structurante :
|
||||||
|
|
||||||
|
- *Faux positif* (marquer « publié » à tort) → le bénévole croit que c'est fait,
|
||||||
|
l'événement **ne sera jamais publié**. **Grave.**
|
||||||
|
- *Faux négatif* (rater un match réel) → au pire il revérifie, et le formulaire
|
||||||
|
**modéré** rattrape un doublon. **Bénin.**
|
||||||
|
|
||||||
|
→ On privilégie la **précision** : match strict (date identique + titre normalisé
|
||||||
|
identique), et dans le doute on **n'affirme pas** « publié ».
|
||||||
|
|
||||||
|
Alternatives écartées :
|
||||||
|
- **Match par slug dérivé** : la slugification WordPress de la mairie n'est pas
|
||||||
|
connue et ajoute des suffixes (`atelier-dessin-2`) → égalité stricte cassée,
|
||||||
|
faux négatifs. On matche sur `date + titre` ; le lien est **lu** dans la carte,
|
||||||
|
jamais reconstruit.
|
||||||
|
- **Score de confiance + confirmation humaine** des cas ambigus (approche 3) :
|
||||||
|
meilleur rappel mais 3ᵉ état d'affichage et un état à mémoriser — or *publié*
|
||||||
|
n'a pas de tag et l'état local est proscrit. Reporté hors v1 ; la porte reste
|
||||||
|
ouverte si les faux négatifs se voient à l'usage.
|
||||||
|
|
||||||
|
## Décisions clés
|
||||||
|
|
||||||
|
- **Seam extraction impure / matching pur.** `DOMParser` est **absent de Bun** :
|
||||||
|
impossible de tester l'extraction DOM en `bun test`. On isole donc
|
||||||
|
`extraireCartes(html)` (impur, DOMParser, non testé unitairement — validé par
|
||||||
|
le test humain) du **matching pur** `publication.js`, testé à fond car c'est le
|
||||||
|
vrai risque produit. Même logique que `formulaire-valeurs.js` (pur, testé) vs
|
||||||
|
`formulaire-mairie.js` (impur, DOM). La boucle de pagination reste testable en
|
||||||
|
**injectant** `fetchImpl`/`extraireCartesImpl`.
|
||||||
|
- **L'agenda est paginé** (`.../agenda/page/N/?f=1&…`). Un seul fetch « période »
|
||||||
|
ne suffit pas : on boucle jusqu'à une page vide, avec un **plafond
|
||||||
|
anti-boucle** (12) au cas où un changement HTML masquerait la page vide.
|
||||||
|
- **Fuseau : jamais de conversion.** Le `<time datetime="YYYY-MM-DD">` est une
|
||||||
|
date **nue**. On la compare à la date **locale de Cugnaux** du `debut` :
|
||||||
|
`Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Paris" })` pour l'horodaté,
|
||||||
|
**composantes locales** (date flottante, sans fuseau) pour la journée entière —
|
||||||
|
sinon la date glisse d'un jour (piège déjà documenté).
|
||||||
|
- **Enrichissement additif.** La liste est rendue **immédiatement** ; le badge est
|
||||||
|
posé *après*, en tâche de fond (`annoterPublications(...).catch(...)`,
|
||||||
|
fire-and-forget). Un agenda injoignable, un HTTP ≠ 200, un `DOMParser` qui lève
|
||||||
|
ou une structure changée → `{ ok:false }` → liste **intacte, sans badge**.
|
||||||
|
Dégradation gracieuse, jamais d'exception, **aucun faux positif**.
|
||||||
|
- **Périmètre v1 = badge « Publié » + lien seulement.** On ne pose pas encore la
|
||||||
|
colonne de statut soumis/ignoré (tâche sœur) ; l'ossature reste extensible.
|
||||||
|
- **Aucune modif de `manifest.json`** : `host_permissions` couvre déjà
|
||||||
|
`ville-cugnaux.fr`, et les nouveaux modules sont des ESM importés par la page
|
||||||
|
d'extension (`DOMParser` y est disponible), pas des content scripts.
|
||||||
|
|
||||||
|
## Patterns à réutiliser
|
||||||
|
|
||||||
|
- **Seam « impur en périphérie, pur au centre ».** Dès qu'une capacité est
|
||||||
|
indisponible sous Bun (DOMParser, DOM, Gravity Forms), extraire une fonction
|
||||||
|
fine impure non testée et pousser toute la logique décidante dans un module pur
|
||||||
|
testé. Injecter les impls (`fetchImpl`, `extraireCartesImpl`) pour tester
|
||||||
|
l'orchestration sans réseau ni DOM.
|
||||||
|
- **Normalisation de titre robuste, en une regex.**
|
||||||
|
`NFD` + suppression des diacritiques, puis `replace(/[^\p{Letter}\p{Number}\s]/gu, "")`
|
||||||
|
retire **ponctuation ET emojis d'un coup** (ni lettre ni chiffre), casse basse,
|
||||||
|
espaces compressés. La ponctuation est retirée **sans espace** pour que
|
||||||
|
« Atelier B.D. » == « Atelier BD ».
|
||||||
|
- **Enrichissement DOM additif via `dataset.uid`.** Poser `item.dataset.uid` au
|
||||||
|
rendu, puis retrouver le `<li>` en phase 2 par
|
||||||
|
`liste.querySelector([data-uid="${CSS.escape(uid)}"])`. La donnée lente ne
|
||||||
|
bloque jamais l'affichage.
|
||||||
|
- **Lien externe défensif.** Résoudre le `href` scrapé via `new URL(href, BASE)`
|
||||||
|
(gère relatif **et** absolu), **rejeter tout schéma non-http(s)**, injecter en
|
||||||
|
`textContent` + `target="_blank"` `rel="noopener"`. Href absurde → carte
|
||||||
|
ignorée.
|
||||||
|
- **`aujourdhui` / `fetchImpl` injectés obligatoires** pour des tests
|
||||||
|
déterministes (pas de `new Date()` ni de `fetch` caché dans la logique).
|
||||||
|
|
||||||
|
## Pièges à éviter
|
||||||
|
|
||||||
|
- **Ne pas présumer la balise du scraping.** L'agenda sert des
|
||||||
|
`<article class="… card-event">`, **pas** des `<div>`. Un sélecteur
|
||||||
|
`div.card-event` extrait **0 carte** alors que l'agenda répond **200** : panne
|
||||||
|
**totale et silencieuse** (pas rattrapée par la dégradation gracieuse, qui ne
|
||||||
|
se déclenche que sur erreur réseau/HTTP). Trouvée par le test humain sur
|
||||||
|
l'agenda réel (45 cartes attendues, 0 obtenue). **Leçon : sélecteur agnostique
|
||||||
|
de balise (`.card-event`), et une hypothèse de structure HTML n'est vérifiée
|
||||||
|
que confrontée au HTML réel.** C'est exactement la zone que le plan réservait
|
||||||
|
au test humain — le process a joué son rôle.
|
||||||
|
- **Ne pas reconstruire le lien depuis le slug** : suffixes WordPress imprévisibles
|
||||||
|
(`-2`). Le lien se **lit** dans la carte.
|
||||||
|
- **Ne pas convertir le fuseau** d'une date nue ou d'une journée entière → dérive
|
||||||
|
d'un jour. Invisible sous `TZ=Europe/Paris`, d'où la variable portée par le
|
||||||
|
script de test.
|
||||||
|
- **Ne pas laisser flotter la promesse d'annotation** : `.catch` explicite, sinon
|
||||||
|
une erreur d'indexation devient une unhandled rejection.
|
||||||
|
- **Le `console.error "agenda mairie injoignable"` en sortie de test est
|
||||||
|
attendu** (test de dégradation gracieuse), pas un échec.
|
||||||
|
|
||||||
|
## Tags
|
||||||
|
tags: [firefox-extension, mv3, javascript, web-scraping, dom-parsing, bun-test,
|
||||||
|
pure-impure-seam, timezone, string-normalization, graceful-degradation,
|
||||||
|
nextcloud, wordpress]
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
// Transport + pagination + extraction DOM de l'agenda public de la mairie.
|
||||||
|
// Séparation nette : les parties PURES (calculerPeriode, construireUrlPage) et
|
||||||
|
// la boucle (recupererAgendaPublie, testée via impls injectées) sont testables ;
|
||||||
|
// extraireCartes utilise DOMParser (absent de Bun) et n'est PAS testé
|
||||||
|
// unitairement — validé par le test humain, comme formulaire-mairie.js.
|
||||||
|
|
||||||
|
const BASE = "https://www.ville-cugnaux.fr/mes-loisirs/agenda/";
|
||||||
|
const FUSEAU = "Europe/Paris";
|
||||||
|
|
||||||
|
// Sécurité anti-boucle : l'agenda ne renverra jamais 12 pages pleines pour nos
|
||||||
|
// événements, mais une structure HTML changée pourrait empêcher la détection de
|
||||||
|
// page vide → on plafonne pour ne jamais boucler indéfiniment.
|
||||||
|
const PLAFOND_PAGES = 12;
|
||||||
|
|
||||||
|
const FORMAT_MOIS = new Intl.DateTimeFormat("fr-FR", {
|
||||||
|
timeZone: FUSEAU,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fenêtre à interroger : du mois courant au mois du dernier événement à venir.
|
||||||
|
// `aujourdhui` injecté (obligatoire) pour des tests déterministes.
|
||||||
|
export function calculerPeriode(evenements, aujourdhui) {
|
||||||
|
const dernier = evenements.reduce(
|
||||||
|
(max, ev) => (ev.debut.getTime() > max.getTime() ? ev.debut : max),
|
||||||
|
aujourdhui,
|
||||||
|
);
|
||||||
|
return { debut: moisAnnee(aujourdhui), fin: moisAnnee(dernier) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function moisAnnee(date) {
|
||||||
|
const parts = FORMAT_MOIS.formatToParts(date);
|
||||||
|
const mois = parts.find((p) => p.type === "month").value;
|
||||||
|
const annee = parts.find((p) => p.type === "year").value;
|
||||||
|
return `${mois}/${annee}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Page 1 = URL de base ; page N>1 = segment /page/N/. Le « MM/YYYY » est encodé
|
||||||
|
// en « MM%2FYYYY » par encodeURIComponent.
|
||||||
|
export function construireUrlPage(n, periode) {
|
||||||
|
const segment = n > 1 ? `page/${n}/` : "";
|
||||||
|
const requete =
|
||||||
|
`?f=1&theme=&date=periode` +
|
||||||
|
`&date_debut=${encodeURIComponent(periode.debut)}` +
|
||||||
|
`&date_fin=${encodeURIComponent(periode.fin)}` +
|
||||||
|
`&public=`;
|
||||||
|
return `${BASE}${segment}${requete}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parcourt les pages jusqu'à une page vide (ou le plafond). Toute erreur (throw,
|
||||||
|
// réponse non-ok) → { ok:false } : la liste s'affiche alors sans badge
|
||||||
|
// (dégradation gracieuse). `fetchImpl` et `extraireCartesImpl` injectés pour
|
||||||
|
// tester la boucle sans DOM ni réseau.
|
||||||
|
export async function recupererAgendaPublie(fetchImpl, extraireCartesImpl, periode) {
|
||||||
|
const cartes = [];
|
||||||
|
try {
|
||||||
|
for (let n = 1; n <= PLAFOND_PAGES; n++) {
|
||||||
|
const res = await fetchImpl(construireUrlPage(n, periode));
|
||||||
|
if (!res.ok) {
|
||||||
|
console.error(`Écho du Huit : agenda mairie a répondu ${res.status}.`);
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
const cartesPage = extraireCartesImpl(await res.text());
|
||||||
|
if (cartesPage.length === 0) break;
|
||||||
|
cartes.push(...cartesPage);
|
||||||
|
}
|
||||||
|
return { ok: true, cartes };
|
||||||
|
} catch (erreur) {
|
||||||
|
console.error("Écho du Huit : agenda mairie injoignable.", erreur);
|
||||||
|
return { ok: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extraction DOM (IMPUR : DOMParser). Chaque carte incomplète (date, titre ou
|
||||||
|
// lien manquant) est IGNORÉE — jamais d'exception. Le lien est LU dans la carte,
|
||||||
|
// jamais reconstruit (le slug porte des suffixes WordPress, ex. « -2 »).
|
||||||
|
export function extraireCartes(html) {
|
||||||
|
const doc = new DOMParser().parseFromString(html, "text/html");
|
||||||
|
const cartes = [];
|
||||||
|
// Sélecteur agnostique de balise : l'agenda sert des <article class="… card-event">,
|
||||||
|
// pas des <div>. `div.card-event` ne matcherait rien (panne silencieuse).
|
||||||
|
for (const carte of doc.querySelectorAll(".card-event")) {
|
||||||
|
const date = carte.querySelector("time[datetime]")?.getAttribute("datetime");
|
||||||
|
const ancre = carte.querySelector(".card-title a");
|
||||||
|
const titre = ancre?.textContent?.trim();
|
||||||
|
const lien = normaliserLien(ancre?.getAttribute("href"));
|
||||||
|
if (!date || !titre || !lien) continue;
|
||||||
|
cartes.push({ date, titre, lien });
|
||||||
|
}
|
||||||
|
return cartes;
|
||||||
|
}
|
||||||
|
|
||||||
|
// L'agenda sert le href brut, parfois RELATIF (« /agenda/slug/ ») : résolu contre
|
||||||
|
// la page d'extension (moz-extension://), ce serait un lien mort. On le résout
|
||||||
|
// contre l'agenda et on rejette tout schéma autre que http(s) — le lien vient
|
||||||
|
// d'une source externe et finit en `href`. Href absurde → carte ignorée.
|
||||||
|
function normaliserLien(href) {
|
||||||
|
if (!href) return null;
|
||||||
|
try {
|
||||||
|
const url = new URL(href, BASE);
|
||||||
|
return url.protocol === "https:" || url.protocol === "http:" ? url.href : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -59,6 +59,28 @@ h1 {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.evenement-publication {
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evenement-publie {
|
||||||
|
align-self: flex-end;
|
||||||
|
padding: 0.15rem 0.5rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
background: #1a7f37;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.evenement-lien-mairie {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
#etat {
|
#etat {
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|||||||
+58
-1
@@ -2,6 +2,8 @@ import { recupererFluxCalendrier } from "./nextcloud.js";
|
|||||||
import { parserEvenements } from "./evenements.js";
|
import { parserEvenements } from "./evenements.js";
|
||||||
import { formaterDate, formaterLieu, trierParDebut } from "./presentation.js";
|
import { formaterDate, formaterLieu, trierParDebut } from "./presentation.js";
|
||||||
import { deposerEvenement } from "./transfert.js";
|
import { deposerEvenement } from "./transfert.js";
|
||||||
|
import { calculerPeriode, extraireCartes, recupererAgendaPublie } from "./agenda-mairie.js";
|
||||||
|
import { indexerCartes, trouverPublication } from "./publication.js";
|
||||||
|
|
||||||
const URL_NEXTCLOUD = "https://atelier-huit.frama.space/";
|
const URL_NEXTCLOUD = "https://atelier-huit.frama.space/";
|
||||||
|
|
||||||
@@ -49,6 +51,8 @@ function rendreEvenements(evenements) {
|
|||||||
function rendreEvenement(evenement) {
|
function rendreEvenement(evenement) {
|
||||||
const item = document.createElement("li");
|
const item = document.createElement("li");
|
||||||
item.className = "evenement";
|
item.className = "evenement";
|
||||||
|
// Ancre pour retrouver le <li> lors de l'annotation « publié » (phase 2).
|
||||||
|
item.dataset.uid = evenement.uid;
|
||||||
|
|
||||||
const infos = document.createElement("div");
|
const infos = document.createElement("div");
|
||||||
infos.className = "evenement-infos";
|
infos.className = "evenement-infos";
|
||||||
@@ -135,10 +139,63 @@ async function charger() {
|
|||||||
// « Illegal invocation » dans le navigateur.
|
// « Illegal invocation » dans le navigateur.
|
||||||
const res = await recupererFluxCalendrier((u, o) => fetch(u, o));
|
const res = await recupererFluxCalendrier((u, o) => fetch(u, o));
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
rendreEvenements(trierParDebut(parserEvenements(res.flux, new Date())));
|
const evenements = 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.
|
||||||
|
// Fire-and-forget : le `.catch` garantit qu'aucune erreur imprévue
|
||||||
|
// (indexation, annotation) ne devienne une rejection non gérée.
|
||||||
|
annoterPublications(evenements).catch((erreur) =>
|
||||||
|
console.error("Écho du Huit : annotation des publications interrompue.", erreur),
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
rendreErreur(res.erreur);
|
rendreErreur(res.erreur);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 2 : marque « publié » les événements retrouvés sur l'agenda mairie et y
|
||||||
|
// pose leur lien direct. Dégradation gracieuse : agenda injoignable ou structure
|
||||||
|
// changée → on ne touche à rien (aucun faux positif, biais précision v1).
|
||||||
|
async function annoterPublications(evenements) {
|
||||||
|
if (evenements.length === 0) return;
|
||||||
|
|
||||||
|
const periode = calculerPeriode(evenements, new Date());
|
||||||
|
// Agenda public : fetch SANS credentials.
|
||||||
|
const res = await recupererAgendaPublie((u, o) => fetch(u, o), extraireCartes, periode);
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const index = indexerCartes(res.cartes);
|
||||||
|
for (const evenement of evenements) {
|
||||||
|
const publication = trouverPublication(evenement, index);
|
||||||
|
if (!publication.publie) continue;
|
||||||
|
|
||||||
|
const item = liste.querySelector(`[data-uid="${CSS.escape(evenement.uid)}"]`);
|
||||||
|
if (item) annoterItem(item, publication.lien);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Badge « Publié » + lien direct ; le bouton « Créer » est retiré (anti-doublon :
|
||||||
|
// 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();
|
||||||
|
|
||||||
|
const publication = document.createElement("div");
|
||||||
|
publication.className = "evenement-publication";
|
||||||
|
|
||||||
|
const badge = document.createElement("span");
|
||||||
|
badge.className = "evenement-publie";
|
||||||
|
badge.textContent = "Publié";
|
||||||
|
|
||||||
|
const voir = document.createElement("a");
|
||||||
|
voir.className = "evenement-lien-mairie";
|
||||||
|
voir.href = lien;
|
||||||
|
voir.target = "_blank";
|
||||||
|
voir.rel = "noopener";
|
||||||
|
voir.textContent = "Voir sur le site mairie";
|
||||||
|
|
||||||
|
publication.append(badge, voir);
|
||||||
|
item.append(publication);
|
||||||
|
}
|
||||||
|
|
||||||
charger();
|
charger();
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Matching événement Nextcloud ↔ carte de l'agenda mairie. Fonctions PURES,
|
||||||
|
// sans DOM (testables) : c'est le cœur du risque produit (précision du match,
|
||||||
|
// fuseau, normalisation du titre). Biais PRÉCISION v1 : dans le doute, on
|
||||||
|
// n'affirme pas « publié » (faux négatif bénin, cf. plan / DECISIONS.md).
|
||||||
|
|
||||||
|
const FUSEAU = "Europe/Paris";
|
||||||
|
|
||||||
|
// en-CA rend directement "YYYY-MM-DD" : même forme que le <time datetime> de
|
||||||
|
// l'agenda, comparable sans reformatage.
|
||||||
|
const FORMAT_JOUR_PARIS = new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: FUSEAU,
|
||||||
|
year: "numeric",
|
||||||
|
month: "2-digit",
|
||||||
|
day: "2-digit",
|
||||||
|
});
|
||||||
|
|
||||||
|
// Forme stable d'un titre : ce qui doit matcher malgré les écarts cosmétiques
|
||||||
|
// entre le calendrier et la mairie. On RETIRE la ponctuation (sans la remplacer
|
||||||
|
// par une espace) pour que « Atelier B.D. » == « Atelier BD ».
|
||||||
|
export function normaliserTitre(titre) {
|
||||||
|
return titre
|
||||||
|
.normalize("NFD")
|
||||||
|
.replace(/[̀-ͯ]/gu, "") // diacritiques isolés par NFD
|
||||||
|
.replace(/[^\p{Letter}\p{Number}\s]/gu, "") // ponctuation ET emojis
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/gu, " ")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Le <time datetime> de l'agenda est une date NUE (pas d'heure) : on la compare
|
||||||
|
// à la date locale de Cugnaux du début, jamais à une conversion de fuseau.
|
||||||
|
export function dateLocaleCugnaux(evenement) {
|
||||||
|
return evenement.journeeEntiere
|
||||||
|
? formaterDateFlottante(evenement.debut)
|
||||||
|
: FORMAT_JOUR_PARIS.format(evenement.debut);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Journée entière = date flottante (RFC 5545) : composantes LOCALES, jamais un
|
||||||
|
// fuseau, sinon la date glisse d'un jour (cf. presentation.js / DECISIONS.md).
|
||||||
|
function formaterDateFlottante(date) {
|
||||||
|
const annee = date.getFullYear();
|
||||||
|
const mois = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const jour = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${annee}-${mois}-${jour}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Index des cartes par `date|titre normalisé` → lien. Deux cartes de même clé
|
||||||
|
// (même jour + même titre normalisé, rare) : la DERNIÈRE indexée l'emporte.
|
||||||
|
export function indexerCartes(cartes) {
|
||||||
|
const index = new Map();
|
||||||
|
for (const carte of cartes) {
|
||||||
|
index.set(cle(carte.date, carte.titre), carte.lien);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match STRICT : même date locale ET titre normalisé identique. Rien d'autre ne
|
||||||
|
// vaut « publié » en v1.
|
||||||
|
export function trouverPublication(evenement, index) {
|
||||||
|
const lien = index.get(cle(dateLocaleCugnaux(evenement), evenement.titre));
|
||||||
|
return lien ? { publie: true, lien } : { publie: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function cle(date, titre) {
|
||||||
|
return `${date}|${normaliserTitre(titre)}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
import { describe, expect, test } from "bun:test";
|
||||||
|
import {
|
||||||
|
dateLocaleCugnaux,
|
||||||
|
indexerCartes,
|
||||||
|
normaliserTitre,
|
||||||
|
trouverPublication,
|
||||||
|
} from "./extension/publication.js";
|
||||||
|
|
||||||
|
// Fixtures = Event littéraux (comme presentation.test.ts) : le parsing vit
|
||||||
|
// ailleurs. Ici on éprouve le matching, vrai risque produit.
|
||||||
|
function evenement(surcharges: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
uid: "uid-1",
|
||||||
|
titre: "Atelier dessin",
|
||||||
|
description: "",
|
||||||
|
lieu: "",
|
||||||
|
debut: new Date("2026-07-10T16:00:00Z"),
|
||||||
|
fin: new Date("2026-07-10T18:00:00Z"),
|
||||||
|
categories: [],
|
||||||
|
journeeEntiere: false,
|
||||||
|
...surcharges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function carte(surcharges: Record<string, unknown> = {}) {
|
||||||
|
return {
|
||||||
|
date: "2026-07-10",
|
||||||
|
titre: "Atelier dessin",
|
||||||
|
lien: "https://www.ville-cugnaux.fr/agenda/atelier-dessin-2/",
|
||||||
|
...surcharges,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("normaliserTitre", () => {
|
||||||
|
test("accents supprimés (NFD)", () => {
|
||||||
|
expect(normaliserTitre("Soirée jazz")).toBe("soiree jazz");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("casse repliée", () => {
|
||||||
|
expect(normaliserTitre("ATELIER Dessin")).toBe("atelier dessin");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("apostrophes droites et typographiques retirées", () => {
|
||||||
|
expect(normaliserTitre("L'atelier")).toBe(normaliserTitre("L’atelier"));
|
||||||
|
expect(normaliserTitre("L'atelier")).toBe("latelier");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("tirets simples et cadratins retirés", () => {
|
||||||
|
expect(normaliserTitre("ciné-club")).toBe(normaliserTitre("ciné–club"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deux-points et ponctuation retirés sans insérer d'espace", () => {
|
||||||
|
// « Atelier B.D. » == « Atelier BD » une fois la ponctuation retirée.
|
||||||
|
expect(normaliserTitre("Atelier B.D.")).toBe(normaliserTitre("Atelier BD"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("emojis retirés", () => {
|
||||||
|
expect(normaliserTitre("Fête \u{1F389} du village")).toBe("fete du village");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("espaces multiples compressés et bords élagués", () => {
|
||||||
|
expect(normaliserTitre(" Atelier dessin ")).toBe("atelier dessin");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("dateLocaleCugnaux", () => {
|
||||||
|
test("horodaté à 22:00 Paris (été) → même jour", () => {
|
||||||
|
// 20:00 UTC = 22:00 Paris l'été.
|
||||||
|
const ev = evenement({ debut: new Date("2026-07-10T20:00:00Z") });
|
||||||
|
expect(dateLocaleCugnaux(ev)).toBe("2026-07-10");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("horodaté à 00:30 Paris = 22:30 UTC la veille → jour de Cugnaux, pas UTC", () => {
|
||||||
|
const ev = evenement({ debut: new Date("2026-07-09T22:30:00Z") });
|
||||||
|
expect(dateLocaleCugnaux(ev)).toBe("2026-07-10");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("journée entière → composantes locales, sans glissement de fuseau", () => {
|
||||||
|
// Date flottante : composantes locales (cf. presentation.js / DECISIONS.md).
|
||||||
|
const ev = evenement({ debut: new Date(2026, 6, 10), journeeEntiere: true });
|
||||||
|
expect(dateLocaleCugnaux(ev)).toBe("2026-07-10");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("trouverPublication", () => {
|
||||||
|
test("même date + titre normalisé identique → publié avec le bon lien", () => {
|
||||||
|
const index = indexerCartes([carte()]);
|
||||||
|
expect(trouverPublication(evenement(), index)).toEqual({
|
||||||
|
publie: true,
|
||||||
|
lien: "https://www.ville-cugnaux.fr/agenda/atelier-dessin-2/",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("même titre mais date différente → non publié", () => {
|
||||||
|
const index = indexerCartes([carte({ date: "2026-07-11" })]);
|
||||||
|
expect(trouverPublication(evenement(), index)).toEqual({ publie: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("même date mais titre différent → non publié", () => {
|
||||||
|
const index = indexerCartes([carte({ titre: "Concert" })]);
|
||||||
|
expect(trouverPublication(evenement(), index)).toEqual({ publie: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("titre ne différant que par accents/emoji/casse/ponctuation → match", () => {
|
||||||
|
const index = indexerCartes([
|
||||||
|
carte({ titre: "Atelier Dessin \u{1F3A8}", lien: "https://x/agenda/a/" }),
|
||||||
|
]);
|
||||||
|
expect(trouverPublication(evenement({ titre: "atelier dessin" }), index)).toEqual({
|
||||||
|
publie: true,
|
||||||
|
lien: "https://x/agenda/a/",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("titre proche mais non strictement identique le même jour → non publié (biais précision v1)", () => {
|
||||||
|
const index = indexerCartes([carte({ titre: "Atelier peinture libre" })]);
|
||||||
|
expect(trouverPublication(evenement({ titre: "Atelier peinture" }), index)).toEqual({
|
||||||
|
publie: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("index vide (agenda injoignable) → non publié", () => {
|
||||||
|
expect(trouverPublication(evenement(), new Map())).toEqual({ publie: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("deux cartes le même jour, titres distincts → la bonne est choisie", () => {
|
||||||
|
const index = indexerCartes([
|
||||||
|
carte({ titre: "Concert", lien: "https://x/agenda/concert/" }),
|
||||||
|
carte({ titre: "Atelier dessin", lien: "https://x/agenda/atelier-dessin/" }),
|
||||||
|
]);
|
||||||
|
expect(trouverPublication(evenement(), index)).toEqual({
|
||||||
|
publie: true,
|
||||||
|
lien: "https://x/agenda/atelier-dessin/",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user