From b103ab78ecfd0c507255e0478b3b4179e12ad0db Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Fri, 17 Jul 2026 13:07:17 +0200 Subject: [PATCH 1/5] feat: introduce a shared config with an optional local override MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The mairie form needs an organiser, an email and the café defaults. Ship the defaults in the repo and let each volunteer override the personal bits in config.local.json (gitignored, optional: a fresh clone still works). config.js carries neither import nor export on purpose, so that it loads both as a classic script (content script, background) and as an ESM module. That is what lets presentation.js drop its duplicated café address. Co-Authored-By: Claude Opus 4.8 --- config.test.ts | 63 +++++++++++++++++++++++++++++ extension/config.js | 39 ++++++++++++++++++ extension/config.local.json.example | 4 ++ extension/presentation.js | 9 ++++- 4 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 config.test.ts create mode 100644 extension/config.js create mode 100644 extension/config.local.json.example diff --git a/config.test.ts b/config.test.ts new file mode 100644 index 0000000..e08fd84 --- /dev/null +++ b/config.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test"; +// config.js n'exporte RIEN (chargeable en script classique ET en module) : +// on l'importe pour son effet de bord et on lit le namespace global. +import "./extension/config.js"; + +const { DEFAUTS, fusionner, CLE_CONFIG_LOCALE } = (globalThis as any).EchoConfig; + +describe("DEFAUTS", () => { + test("themeDefaut est une valeur exacte du dropdown mairie (RECHERCHE §3)", () => { + expect(DEFAUTS.themeDefaut).toBe("Culture"); + }); + + test("organisateur et email sont vides : jamais inventés, signalés à l'utilisateur", () => { + expect(DEFAUTS.organisateur).toBe(""); + expect(DEFAUTS.email).toBe(""); + }); + + test("le café porte l'adresse et les coordonnées de RECHERCHE §2", () => { + expect(DEFAUTS.cafe.adresse).toBe("8 Rue du Pré Vicinal 31270 Cugnaux"); + expect(DEFAUTS.cafe.coords).toBe("43.53753132806989|1.342981890597508"); + }); + + test("tarifs par défaut : Gratuit", () => { + expect(DEFAUTS.tarifs).toBe("Gratuit"); + }); +}); + +describe("CLE_CONFIG_LOCALE", () => { + test("est la clé de storage partagée background → content script", () => { + expect(CLE_CONFIG_LOCALE).toBe("config-locale"); + }); +}); + +describe("fusionner", () => { + // Un clone frais n'a pas de config.local.json : le repli doit être total, + // jamais une exception. + test.each([ + ["absente", undefined], + ["null", null], + ["une chaîne", "n'importe quoi"], + ["un tableau vide", []], + ])("locale %s → défauts nus", (_libelle, locale) => { + expect(fusionner(DEFAUTS, locale)).toEqual(DEFAUTS); + }); + + test("organisateur et email surchargent, le reste survit", () => { + const fusion = fusionner(DEFAUTS, { + organisateur: "L'Atelier du Huit", + email: "contact@atelier-huit.fr", + }); + + expect(fusion.organisateur).toBe("L'Atelier du Huit"); + expect(fusion.email).toBe("contact@atelier-huit.fr"); + expect(fusion.themeDefaut).toBe("Culture"); + expect(fusion.cafe).toEqual(DEFAUTS.cafe); + }); + + test("ne mute pas DEFAUTS (le module est partagé par tout le monde)", () => { + fusionner(DEFAUTS, { organisateur: "Autre" }); + + expect(DEFAUTS.organisateur).toBe(""); + }); +}); diff --git a/extension/config.js b/extension/config.js new file mode 100644 index 0000000..bb03a75 --- /dev/null +++ b/extension/config.js @@ -0,0 +1,39 @@ +// Défauts committés de l'extension + fusion avec la surcharge locale. +// +// ⚠️ Ce fichier n'a NI `import` NI `export`, DÉLIBÉRÉMENT (ne pas « corriger ») : +// c'est ce qui le rend chargeable à la fois comme content script / script de +// background (scripts classiques : un `export` y lève une SyntaxError) et comme +// module ESM (`import "./config.js"` depuis presentation.js et bun test). +// Il communique donc par `globalThis`. Cf. DECISIONS.md 2026-07-17. + +globalThis.EchoConfig = { + // Clé de storage : le background écrit, le content script lit. + CLE_CONFIG_LOCALE: "config-locale", + + DEFAUTS: { + // Vides par défaut : sans config.local.json, on préfère un champ vide + // signalé dans le bandeau à un organisateur inventé. + organisateur: "", + email: "", + + // RECHERCHE.md §3 : valeur exacte du dropdown #input_6_20. + themeDefaut: "Culture", + tarifs: "Gratuit", + + // RECHERCHE.md §2. `input_18` se compose `${coords}|${adresse}`. + cafe: { + adresse: "8 Rue du Pré Vicinal 31270 Cugnaux", + coords: "43.53753132806989|1.342981890597508", + }, + }, + + // Fusion de surface : la locale ne porte que des champs plats + // (organisateur, email…). Tolérante par nécessité — `config.local.json` est + // optionnel et éditable à la main : absent, `null` ou invalide → défauts nus, + // jamais d'exception. + fusionner(defauts, locale) { + const surcharge = + locale && typeof locale === "object" && !Array.isArray(locale) ? locale : {}; + return { ...defauts, ...surcharge }; + }, +}; diff --git a/extension/config.local.json.example b/extension/config.local.json.example new file mode 100644 index 0000000..dc767b5 --- /dev/null +++ b/extension/config.local.json.example @@ -0,0 +1,4 @@ +{ + "organisateur": "L'Atelier du Huit", + "email": "contact@atelier-huit.fr" +} diff --git a/extension/presentation.js b/extension/presentation.js index b357d53..0f107f8 100644 --- a/extension/presentation.js +++ b/extension/presentation.js @@ -2,11 +2,16 @@ // Fonctions pures, sans DOM (testables). Locale et fuseau sont des constantes : // l'appli est mono-locale (bénévoles de Cugnaux). +// 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"; + const LOCALE = "fr-FR"; const FUSEAU = "Europe/Paris"; -// RECHERCHE.md §2 : adresse du café de l'Atelier du Huit. -export const LIEU_PAR_DEFAUT = "8 Rue du Pré Vicinal 31270 Cugnaux"; +// Source unique : le formulaire mairie a besoin de la même adresse (et de ses +// coordonnées) que la liste. +export const LIEU_PAR_DEFAUT = globalThis.EchoConfig.DEFAUTS.cafe.adresse; // Un événement horodaté porte un vrai instant : on l'affiche à l'heure de // Cugnaux, quel que soit le fuseau de la machine. From 78153557ddc3340c90639a986abfd0645f92d9f7 Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Fri, 17 Jul 2026 13:07:17 +0200 Subject: [PATCH 2/5] feat: compute the mairie form values from a pending event Pure, testable core of the prefill: payload + config -> flat values ready to lay down. What the machine does not know stays empty and is listed in aCompleter, never invented (no fake 00:00, no made-up coordinates). DTEND is exclusive (RFC 5545): a festival on the 18th-20th carries DTEND=21, so the end date must step back one day, in local components for all-day events. Without it, a wrong date reaches a moderated form. Co-Authored-By: Claude Opus 4.8 --- extension/formulaire-valeurs.js | 133 +++++++++++++++ formulaire-valeurs.test.ts | 283 ++++++++++++++++++++++++++++++++ 2 files changed, 416 insertions(+) create mode 100644 extension/formulaire-valeurs.js create mode 100644 formulaire-valeurs.test.ts diff --git a/extension/formulaire-valeurs.js b/extension/formulaire-valeurs.js new file mode 100644 index 0000000..e7ed1fe --- /dev/null +++ b/extension/formulaire-valeurs.js @@ -0,0 +1,133 @@ +// Cœur métier du pré-remplissage : traduit le payload déposé par la liste en +// valeurs prêtes à poser dans le formulaire mairie. Fonctions PURES, aucun DOM +// (c'est cette partie-là qui est testée ; le poseur, lui, est fin et impur). +// +// ⚠️ Ni `import` ni `export`, comme config.js (cf. DECISIONS.md 2026-07-17). + +const LOCALE = "fr-FR"; +const FUSEAU = "Europe/Paris"; + +// Dupliquée depuis transfert.js (ESM, inatteignable depuis un content script +// classique). Un test de dérive garantit l'égalité — cf. DECISIONS.md 2026-07-17. +const CLE_EVENEMENT_EN_ATTENTE = "evenement-en-attente"; + +// Un événement horodaté porte un vrai instant : la mairie attend l'heure de +// Cugnaux, quel que soit le fuseau de la machine du bénévole. +const FORMAT_DATE_PARIS = new Intl.DateTimeFormat(LOCALE, { + timeZone: FUSEAU, + day: "2-digit", + month: "2-digit", + year: "numeric", +}); +const FORMAT_HEURE_PARIS = new Intl.DateTimeFormat(LOCALE, { + timeZone: FUSEAU, + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", +}); + +globalThis.EchoFormulaire = { + CLE_EVENEMENT_EN_ATTENTE, + + // payload (transfert.js) + config (config.js) → objet plat, prêt à poser. + // Ce qu'on ignore reste vide et part dans `aCompleter` : jamais d'invention. + calculerValeurs(payload, config) { + const debut = new Date(payload.debut); + const fin = new Date(payload.fin); + + const valeurs = { + organisateur: config.organisateur, + email: config.email, + titre: payload.titre, + description: payload.description, + theme: config.themeDefaut, + tarifs: config.tarifs, + ...(payload.journeeEntiere ? datesFlottantes(debut, fin) : datesHorodatees(debut, fin)), + ...lieuEtCoordonnees(payload.lieu, config.cafe), + }; + + return { ...valeurs, aCompleter: listerACompleter(valeurs) }; + }, +}; + +// Journée entière = date FLOTTANTE (RFC 5545) : composantes locales +// uniquement, jamais l'heure, jamais un fuseau — sinon la date glisse d'un jour +// hors de +02:00 (cf. DECISIONS.md 2026-07-14). +function datesFlottantes(debut, fin) { + const dateDebut = formaterDateLocale(debut); + const dernierJour = veilleDe(fin); + + return { + dateDebut, + // Un seul jour, ou DTEND absent (le parseur pose alors fin === debut). + dateFin: + dernierJour.getTime() <= debut.getTime() ? dateDebut : formaterDateLocale(dernierJour), + // Poser « 00:00 » serait exactement le mensonge que la liste refuse déjà. + heureDebut: null, + heureFin: null, + }; +} + +function datesHorodatees(debut, fin) { + const dateDebut = FORMAT_DATE_PARIS.format(debut); + const heureDebut = heureDe(debut); + + // DTEND absent : le parseur pose fin === debut. Pas d'heure de fin à + // soumettre — mais l'événement a bien lieu ce jour-là. + if (fin.getTime() <= debut.getTime()) { + return { dateDebut, dateFin: dateDebut, heureDebut, heureFin: null }; + } + + // Jour de fin calculé à Paris : un 22:00 → 02:00 change de jour. + return { + dateDebut, + dateFin: FORMAT_DATE_PARIS.format(fin), + heureDebut, + heureFin: heureDe(fin), + }; +} + +// Le formulaire attend deux selects [HH, MM], zéro-paddés. +function heureDe(date) { + return FORMAT_HEURE_PARIS.format(date).split(":"); +} + +function formaterDateLocale(date) { + const jour = String(date.getDate()).padStart(2, "0"); + const mois = String(date.getMonth() + 1).padStart(2, "0"); + return `${jour}/${mois}/${date.getFullYear()}`; +} + +// DTEND est EXCLUSIF (RFC 5545) : un événement du 18 porte DTEND=19, un festival +// du 18 au 20 porte DTEND=21. Sans ce recul d'un jour, on soumet à une mairie +// qui modère une date de fin FAUSSE. Composantes locales, jamais « -24h ». +function veilleDe(date) { + return new Date(date.getFullYear(), date.getMonth(), date.getDate() - 1); +} + +// La majorité des événements n'a pas de LOCATION : ils se tiennent au café. +function lieuEtCoordonnees(lieu, cafe) { + const saisi = lieu.trim(); + if (!saisi) { + return { adresse: cafe.adresse, coordonnees: `${cafe.coords}|${cafe.adresse}` }; + } + + // Lieu ailleurs : on ne sait pas géocoder. Inventer un lat|lng serait pire + // qu'un champ vide → l'humain place le point sur la carte. + return { adresse: saisi, coordonnees: "" }; +} + +// Toujours honnête : ce que la machine ne sait pas est dit, pas masqué. +function listerACompleter({ heureDebut, heureFin, coordonnees, organisateur, email, titre }) { + const aCompleter = [ + "l'image (obligatoire)", // un script ne peut pas remplir un + "le thème", // le calendrier n'en a pas : le défaut est à confirmer + ]; + + if (heureDebut === null || heureFin === null) aCompleter.push("l'heure"); + if (coordonnees === "") aCompleter.push("le lieu exact sur la carte"); + if (!organisateur || !email) aCompleter.push("l'organisateur et l'email (config.local.json)"); + if (titre === "") aCompleter.push("le titre"); + + return aCompleter; +} diff --git a/formulaire-valeurs.test.ts b/formulaire-valeurs.test.ts new file mode 100644 index 0000000..db61620 --- /dev/null +++ b/formulaire-valeurs.test.ts @@ -0,0 +1,283 @@ +import { describe, expect, test } from "bun:test"; +import "./extension/config.js"; +import "./extension/formulaire-valeurs.js"; +import { CLE_EVENEMENT_EN_ATTENTE, serialiserPourFormulaire } from "./extension/transfert.js"; + +const { DEFAUTS } = (globalThis as any).EchoConfig; +const { calculerValeurs } = (globalThis as any).EchoFormulaire; + +// Payload = ce que dépose transfert.js (debut/fin en ISO), pas un Event. +function payload(surcharges: Record = {}) { + return { + uid: "uid-1", + titre: "Atelier", + description: "Venez nombreux", + lieu: "", + debut: new Date("2026-07-18T08:30:00Z").toISOString(), + fin: new Date("2026-07-18T10:30:00Z").toISOString(), + categories: [], + journeeEntiere: false, + ...surcharges, + }; +} + +// Une journée entière est une date FLOTTANTE : fixture en composantes locales, +// jamais un instant absolu (cf. DECISIONS.md 2026-07-14). +function payloadJourneeEntiere(debut: Date, fin: Date, surcharges = {}) { + return payload({ + debut: debut.toISOString(), + fin: fin.toISOString(), + journeeEntiere: true, + ...surcharges, + }); +} + +describe("journée entière", () => { + test("un seul jour (DTEND = J+1) → dateFin === dateDebut", () => { + const valeurs = calculerValeurs( + payloadJourneeEntiere(new Date(2026, 6, 10), new Date(2026, 6, 11)), + DEFAUTS, + ); + + expect(valeurs.dateDebut).toBe("10/07/2026"); + expect(valeurs.dateFin).toBe("10/07/2026"); + }); + + test("lit les composantes locales, pas un instant absolu", () => { + const valeurs = calculerValeurs( + payloadJourneeEntiere(new Date(2026, 6, 10), new Date(2026, 6, 11)), + DEFAUTS, + ); + + expect(valeurs.dateDebut).toBe("10/07/2026"); + }); + + // Festival 18→21 exclusif : il se termine réellement le 20. Sans ce recul, + // une date FAUSSE part à une mairie qui modère. + test("plusieurs jours (18 → DTEND 21) → dateFin = 20/07/2026", () => { + const valeurs = calculerValeurs( + payloadJourneeEntiere(new Date(2026, 6, 18), new Date(2026, 6, 21)), + DEFAUTS, + ); + + expect(valeurs.dateDebut).toBe("18/07/2026"); + expect(valeurs.dateFin).toBe("20/07/2026"); + }); + + test("DTEND absent (fin === debut) → dateFin === dateDebut", () => { + const valeurs = calculerValeurs( + payloadJourneeEntiere(new Date(2026, 6, 10), new Date(2026, 6, 10)), + DEFAUTS, + ); + + expect(valeurs.dateFin).toBe("10/07/2026"); + }); + + // Poser « 00:00 » serait le mensonge que presentation.js refuse déjà. + test("aucune heure : poser 00:00 serait un mensonge", () => { + const valeurs = calculerValeurs( + payloadJourneeEntiere(new Date(2026, 6, 10), new Date(2026, 6, 11)), + DEFAUTS, + ); + + expect(valeurs.heureDebut).toBeNull(); + expect(valeurs.heureFin).toBeNull(); + }); + + test("l'heure manquante est signalée", () => { + const valeurs = calculerValeurs( + payloadJourneeEntiere(new Date(2026, 6, 10), new Date(2026, 6, 11)), + DEFAUTS, + ); + + expect(valeurs.aCompleter).toContain("l'heure"); + }); +}); + +describe("événement horodaté", () => { + test("heures à Paris, zéro-paddées", () => { + const valeurs = calculerValeurs( + payload({ + debut: new Date("2026-07-18T07:05:00Z").toISOString(), // 09:05 à Paris + fin: new Date("2026-07-18T19:00:00Z").toISOString(), // 21:00 à Paris + }), + DEFAUTS, + ); + + expect(valeurs.heureDebut).toEqual(["09", "05"]); + expect(valeurs.heureFin).toEqual(["21", "00"]); + expect(valeurs.dateDebut).toBe("18/07/2026"); + expect(valeurs.dateFin).toBe("18/07/2026"); + }); + + test("DTEND absent (fin === debut) → pas d'heure de fin, mais le jour reste vrai", () => { + const debut = new Date("2026-07-18T08:30:00Z").toISOString(); + const valeurs = calculerValeurs(payload({ debut, fin: debut }), DEFAUTS); + + expect(valeurs.heureDebut).toEqual(["10", "30"]); + expect(valeurs.heureFin).toBeNull(); + expect(valeurs.dateFin).toBe("18/07/2026"); + expect(valeurs.aCompleter).toContain("l'heure"); + }); + + // 22:00 → 02:00 : annoncer la fin le même jour serait un mensonge. + test("franchit minuit → dateFin est le lendemain", () => { + const valeurs = calculerValeurs( + payload({ + debut: new Date("2026-07-18T20:00:00Z").toISOString(), // 22:00 à Paris + fin: new Date("2026-07-19T00:00:00Z").toISOString(), // 02:00 le 19 à Paris + }), + DEFAUTS, + ); + + expect(valeurs.dateDebut).toBe("18/07/2026"); + expect(valeurs.heureDebut).toEqual(["22", "00"]); + expect(valeurs.dateFin).toBe("19/07/2026"); + expect(valeurs.heureFin).toEqual(["02", "00"]); + }); + + test("heures complètes → l'heure n'est pas dans aCompleter", () => { + const valeurs = calculerValeurs(payload(), DEFAUTS); + + expect(valeurs.aCompleter).not.toContain("l'heure"); + }); +}); + +describe("lieu", () => { + test("lieu vide (cas majoritaire) → adresse et coordonnées du café", () => { + const valeurs = calculerValeurs(payload({ lieu: "" }), DEFAUTS); + + expect(valeurs.adresse).toBe("8 Rue du Pré Vicinal 31270 Cugnaux"); + expect(valeurs.coordonnees).toBe( + "43.53753132806989|1.342981890597508|8 Rue du Pré Vicinal 31270 Cugnaux", + ); + expect(valeurs.aCompleter).not.toContain("le lieu exact sur la carte"); + }); + + test("lieu en blancs → café", () => { + const valeurs = calculerValeurs(payload({ lieu: " " }), DEFAUTS); + + expect(valeurs.adresse).toBe("8 Rue du Pré Vicinal 31270 Cugnaux"); + }); + + // On ne sait pas géocoder : inventer un lat|lng serait pire que le laisser vide. + test("lieu renseigné → pas de coordonnées inventées, manque signalé", () => { + const valeurs = calculerValeurs(payload({ lieu: "Salle des fêtes" }), DEFAUTS); + + expect(valeurs.adresse).toBe("Salle des fêtes"); + expect(valeurs.coordonnees).toBe(""); + expect(valeurs.aCompleter).toContain("le lieu exact sur la carte"); + }); +}); + +describe("champs de config", () => { + test("thème et tarifs viennent de la config", () => { + const valeurs = calculerValeurs(payload(), DEFAUTS); + + expect(valeurs.theme).toBe("Culture"); + expect(valeurs.tarifs).toBe("Gratuit"); + }); + + test("organisateur et email surchargés sont posés et non signalés", () => { + const config = { ...DEFAUTS, organisateur: "L'Atelier du Huit", email: "a@b.fr" }; + + const valeurs = calculerValeurs(payload(), config); + + expect(valeurs.organisateur).toBe("L'Atelier du Huit"); + expect(valeurs.email).toBe("a@b.fr"); + expect(valeurs.aCompleter).not.toContain("l'organisateur et l'email (config.local.json)"); + }); + + test("sans config.local.json → organisateur/email vides et signalés", () => { + const valeurs = calculerValeurs(payload(), DEFAUTS); + + expect(valeurs.organisateur).toBe(""); + expect(valeurs.email).toBe(""); + expect(valeurs.aCompleter).toContain("l'organisateur et l'email (config.local.json)"); + }); +}); + +describe("aCompleter", () => { + test("signale toujours l'image et le thème", () => { + const config = { ...DEFAUTS, organisateur: "X", email: "a@b.fr" }; + + const valeurs = calculerValeurs(payload({ lieu: "" }), config); + + expect(valeurs.aCompleter).toContain("l'image (obligatoire)"); + expect(valeurs.aCompleter).toContain("le thème"); + }); + + test("titre vide → signalé", () => { + expect(calculerValeurs(payload({ titre: "" }), DEFAUTS).aCompleter).toContain("le titre"); + }); + + test("titre renseigné → non signalé", () => { + expect(calculerValeurs(payload(), DEFAUTS).aCompleter).not.toContain("le titre"); + }); +}); + +describe("texte", () => { + test("titre et description sont repris tels quels", () => { + const valeurs = calculerValeurs(payload(), DEFAUTS); + + expect(valeurs.titre).toBe("Atelier"); + expect(valeurs.description).toBe("Venez nombreux"); + }); + + // Cas majoritaire (142/473 ont une DESCRIPTION) : le poseur n'écrira pas "". + test("description vide reste vide", () => { + expect(calculerValeurs(payload({ description: "" }), DEFAUTS).description).toBe(""); + }); +}); + +// La constante est dupliquée dans formulaire-valeurs.js (un content script +// classique ne peut pas importer transfert.js, qui est en ESM). Ce test est le +// garde-fou contre la dérive. +describe("couture avec transfert.js", () => { + test("la clé dupliquée est identique à l'originale", () => { + expect((globalThis as any).EchoFormulaire.CLE_EVENEMENT_EN_ATTENTE).toBe( + CLE_EVENEMENT_EN_ATTENTE, + ); + }); + + // Le contrat de couture vérifié bout en bout, pas supposé. + test("aller-retour réel : serialiserPourFormulaire → calculerValeurs", () => { + const evenement = { + uid: "uid-1", + titre: "Concert", + description: "Au café", + lieu: "", + debut: new Date("2026-07-18T18:00:00Z"), // 20:00 à Paris + fin: new Date("2026-07-18T20:00:00Z"), // 22:00 à Paris + categories: [], + journeeEntiere: false, + }; + + const valeurs = calculerValeurs(serialiserPourFormulaire(evenement), DEFAUTS); + + expect(valeurs.titre).toBe("Concert"); + expect(valeurs.dateDebut).toBe("18/07/2026"); + expect(valeurs.heureDebut).toEqual(["20", "00"]); + expect(valeurs.heureFin).toEqual(["22", "00"]); + expect(valeurs.adresse).toBe("8 Rue du Pré Vicinal 31270 Cugnaux"); + }); + + test("aller-retour réel d'une journée entière (date flottante)", () => { + const evenement = { + uid: "uid-2", + titre: "Festival", + description: "", + lieu: "", + debut: new Date(2026, 6, 18), + fin: new Date(2026, 6, 21), + categories: [], + journeeEntiere: true, + }; + + const valeurs = calculerValeurs(serialiserPourFormulaire(evenement), DEFAUTS); + + expect(valeurs.dateDebut).toBe("18/07/2026"); + expect(valeurs.dateFin).toBe("20/07/2026"); + expect(valeurs.heureDebut).toBeNull(); + }); +}); From 2e60bd08d8b87b6ced176cf97d7cc7d11bab34cc Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Fri, 17 Jul 2026 13:10:00 +0200 Subject: [PATCH 3/5] feat: prefill the mairie form from the picked event Wire the seam end to end: the background mirrors config.local.json into storage (a content script would need web_accessible_resources, which would expose the email to the mairie page), and the content script lays the computed values into the form. The layer is deliberately thin and untested: all the logic lives in formulaire-valeurs.js. It never writes an empty value, never touches the image, the honeypots or the CSRF tokens, and never submits: a human reads and sends. A banner states what is left to do, and warns when a field could not be filled, so that a silent failure stays diagnosable. Co-Authored-By: Claude Opus 4.8 --- extension.test.ts | 42 ++++++ extension/background.js | 24 ++++ extension/formulaire-mairie.css | 49 +++++++ extension/formulaire-mairie.js | 237 ++++++++++++++++++++++++++++++++ extension/manifest.json | 12 +- 5 files changed, 363 insertions(+), 1 deletion(-) create mode 100644 extension/formulaire-mairie.css create mode 100644 extension/formulaire-mairie.js diff --git a/extension.test.ts b/extension.test.ts index 3b38d74..a9174f0 100644 --- a/extension.test.ts +++ b/extension.test.ts @@ -14,6 +14,12 @@ describe("manifest.json", () => { expect(manifest.background.scripts).toContain("background.js"); }); + // Scripts classiques : pas d'import, l'ordre du tableau EST la dépendance. + // config.js pose globalThis.EchoConfig, que background.js lit. + test("charge config.js avant background.js", () => { + expect(manifest.background.scripts).toEqual(["config.js", "background.js"]); + }); + test("demande les host_permissions des deux sources", () => { expect(manifest.host_permissions).toEqual([ "https://atelier-huit.frama.space/*", @@ -34,11 +40,47 @@ describe("manifest.json", () => { }); }); +describe("content_scripts (pré-remplissage du formulaire mairie)", () => { + const contentScript = manifest.content_scripts?.[0]; + + // URL exacte du formulaire, pas tout ville-cugnaux.fr : on n'injecte pas de + // code sur les pages d'un tiers sans raison. + test("ne s'injecte que sur le formulaire de la mairie", () => { + expect(contentScript.matches).toEqual([ + "https://www.ville-cugnaux.fr/mes-loisirs/associations/proposer-un-evenement-dans-lagenda/*", + ]); + }); + + test("charge config, valeurs puis poseur (l'ordre EST la dépendance)", () => { + expect(contentScript.js).toEqual([ + "config.js", + "formulaire-valeurs.js", + "formulaire-mairie.js", + ]); + }); + + test("porte le style du bandeau", () => { + expect(contentScript.css).toEqual(["formulaire-mairie.css"]); + }); + + test("chaque fichier déclaré existe sur le disque", () => { + for (const fichier of [...contentScript.js, ...contentScript.css]) { + expect(existsSync(join(extensionDir, fichier))).toBe(true); + } + }); +}); + describe("ressources référencées", () => { test("background.js existe", () => { expect(existsSync(join(extensionDir, "background.js"))).toBe(true); }); + test("chaque script d'arrière-plan existe", () => { + for (const fichier of manifest.background.scripts) { + expect(existsSync(join(extensionDir, fichier))).toBe(true); + } + }); + test("liste.html existe", () => { expect(existsSync(join(extensionDir, "liste.html"))).toBe(true); }); diff --git a/extension/background.js b/extension/background.js index 2431475..0bc415c 100644 --- a/extension/background.js +++ b/extension/background.js @@ -3,3 +3,27 @@ browser.action.onClicked.addListener(() => { browser.tabs.create({ url: browser.runtime.getURL("liste.html") }); }); + +// C'est le background qui lit config.local.json, pas le content script : ce +// dernier ne pourrait le faire que via web_accessible_resources, ce qui +// exposerait l'email à la page de la mairie. Il la reçoit par le storage. +// +// Le fichier est OPTIONNEL (absent d'un clone frais), donc impossible à +// déclarer dans un tableau `js` du manifest — Firefox refuserait d'installer +// l'extension. D'où fetch + repli : absent ou JSON invalide → {} → défauts nus, +// jamais d'exception. +async function chargerConfigLocale() { + let locale = {}; + try { + const reponse = await fetch(browser.runtime.getURL("config.local.json")); + if (reponse.ok) locale = await reponse.json(); + } catch { + console.warn("Écho du Huit : config.local.json illisible, défauts utilisés."); + } + await browser.storage.local.set({ [globalThis.EchoConfig.CLE_CONFIG_LOCALE]: locale }); +} + +// À chaque réveil de l'event page, et pas seulement à l'installation : le +// fichier peut avoir changé depuis, le storage doit refléter le disque. +chargerConfigLocale(); +browser.runtime.onInstalled.addListener(chargerConfigLocale); diff --git a/extension/formulaire-mairie.css b/extension/formulaire-mairie.css new file mode 100644 index 0000000..f832969 --- /dev/null +++ b/extension/formulaire-mairie.css @@ -0,0 +1,49 @@ +/* Bandeau injecté dans la page de la mairie. Propriétés posées explicitement : + le CSS du site est hors de notre contrôle et pourrait tout hériter. */ + +.echo-bandeau { + display: block; + box-sizing: border-box; + margin: 1.5rem 0; + padding: 1rem 1.25rem; + border: 1px solid #1a73e8; + border-left-width: 4px; + border-radius: 4px; + background: #f2f7fe; + color: #1a1a1a; + font-family: system-ui, sans-serif; + font-size: 1rem; + line-height: 1.5; + text-align: left; +} + +.echo-bandeau p { + margin: 0 0 0.5rem; + color: inherit; +} + +.echo-bandeau-titre { + font-weight: 700; +} + +.echo-bandeau-liste { + margin: 0 0 0.5rem; + padding-left: 1.5rem; + list-style: disc; +} + +.echo-bandeau-liste li { + margin: 0; + color: inherit; + list-style: disc; +} + +.echo-bandeau-consigne { + margin-bottom: 0; + font-weight: 700; +} + +.echo-bandeau-alerte { + margin: 0.75rem 0 0; + color: #b3261e; +} diff --git a/extension/formulaire-mairie.js b/extension/formulaire-mairie.js new file mode 100644 index 0000000..056360e --- /dev/null +++ b/extension/formulaire-mairie.js @@ -0,0 +1,237 @@ +// Poseur : lit le storage, écrit le DOM du formulaire mairie, injecte le +// bandeau. Volontairement FIN et sans logique métier — tout le calcul est dans +// formulaire-valeurs.js, sous tests. Ici, c'est le test humain qui tranche. +// +// Content script classique : pas d'import, les dépendances arrivent par +// globalThis (ordre du tableau `js` du manifest — cf. DECISIONS.md 2026-07-17). +// +// PAS d'auto-submit : on pré-remplit, l'humain relit et envoie. + +// RECHERCHE.md §2 (form id = 6). Non touchés délibérément : input_6_34 (image, +// impossible par script), input_6_23 / input_6_43 (honeypot), les tokens CSRF, +// et input_6_35 (accessibilité : la machine ne sait pas si le lieu est +// accessible — cocher au hasard serait un champ rempli faux). +const CHAMP_ORGANISATEUR = "input_6_42"; +const CHAMP_EMAIL = "input_6_41"; +const CHAMP_TITRE = "input_6_1"; +const CHAMP_DESCRIPTION = "input_6_3"; +const CHAMP_THEME = "input_6_20"; +const CHAMP_DATE_DEBUT = "input_6_31"; +const CHAMP_DATE_FIN = "input_6_32"; +const CHAMP_HEURE_DEBUT = "input_6_38"; +const CHAMP_HEURE_FIN = "input_6_39"; +const CHAMP_TARIFS = "input_6_40"; +const CHAMP_ADRESSE = "nova_address"; +const CHAMP_COORDONNEES = "input_6_18"; + +(async () => { + const { EchoConfig, EchoFormulaire } = globalThis; + + // Storage illisible ou visite directe du formulaire (sans passer par la + // liste) : on ne sait pas s'il y a un travail à faire, donc on ne touche à + // rien et on n'affiche aucun bandeau. + const stock = await lireStock(); + if (!stock) return; + + const payload = stock[EchoFormulaire.CLE_EVENEMENT_EN_ATTENTE]; + if (!payload) return; + + try { + // On n'efface PAS le storage à la lecture : le pré-remplissage survit à un + // F5 (couture actée — DECISIONS.md 2026-07-14). + const config = EchoConfig.fusionner( + EchoConfig.DEFAUTS, + stock[EchoConfig.CLE_CONFIG_LOCALE], + ); + const valeurs = EchoFormulaire.calculerValeurs(payload, config); + + injecterBandeau(valeurs.aCompleter, remplir(valeurs)); + } catch (erreur) { + // Filet : cette fonction est `async`, une exception y deviendrait une + // rejection non gérée — ni remplissage, ni bandeau, ni message. Le bénévole + // ne verrait rien du tout. Ce poseur n'étant pas testé unitairement (choix + // acté), le filet est sa seule ligne de défense. + // Cas réel : la mairie remplace un → `champ.options` + // est undefined → TypeError au milieu du remplissage. + console.error("Écho du Huit : pré-remplissage interrompu.", erreur); + injecterBandeauDegrade(); + } +})(); + +async function lireStock() { + const { EchoConfig, EchoFormulaire } = globalThis; + try { + return await browser.storage.local.get([ + EchoFormulaire.CLE_EVENEMENT_EN_ATTENTE, + EchoConfig.CLE_CONFIG_LOCALE, + ]); + } catch (erreur) { + console.error("Écho du Huit : storage illisible.", erreur); + return null; + } +} + +// Renvoie les ids qu'on n'a PAS pu remplir (formulaire de la mairie changé ?). +function remplir(valeurs) { + const manquants = []; + + // Un champ inattendu (type changé côté mairie) ne doit pas emporter tous les + // champs suivants : on le compte comme manquant et on continue. Sans ça, une + // seule exception vide silencieusement toute la fin du formulaire. + const poser = (poseur, id, valeur) => { + try { + if (!poseur(id, valeur)) manquants.push(id); + } catch (erreur) { + console.warn(`Écho du Huit : échec sur #${id}.`, erreur); + manquants.push(id); + } + }; + const texte = (id, valeur) => poser(poserTexte, id, valeur); + const select = (id, valeur) => poser(poserSelect, id, valeur); + + // heure === null (journée entière, DTEND absent) : ne rien poser est le + // comportement voulu, pas un échec. + // Les 4 champs d'heures sont des (vérifié sur le HTML + // réel), PAS des selects — contrairement à ce qu'annonçait l'énoncé du + // ticket. Ils acceptent « 09 » et « 00 » (chiffres = valid floating-point + // number au sens HTML), donc le zéro-padding du calcul convient tel quel. + const heure = (prefixe, valeur) => { + if (valeur === null) return; + texte(`${prefixe}_1`, valeur[0]); + texte(`${prefixe}_2`, valeur[1]); + }; + + texte(CHAMP_ORGANISATEUR, valeurs.organisateur); + texte(CHAMP_EMAIL, valeurs.email); + texte(CHAMP_TITRE, valeurs.titre); + texte(CHAMP_DESCRIPTION, valeurs.description); + select(CHAMP_THEME, valeurs.theme); + texte(CHAMP_DATE_DEBUT, valeurs.dateDebut); + texte(CHAMP_DATE_FIN, valeurs.dateFin); + heure(CHAMP_HEURE_DEBUT, valeurs.heureDebut); + heure(CHAMP_HEURE_FIN, valeurs.heureFin); + texte(CHAMP_TARIFS, valeurs.tarifs); + texte(CHAMP_ADRESSE, valeurs.adresse); + texte(CHAMP_COORDONNEES, valeurs.coordonnees); + + return manquants; +} + +function poserTexte(id, valeur) { + // Une valeur vide se laisse telle quelle : un champ intact est honnête, et + // le manque est déjà annoncé dans le bandeau. + if (!valeur) return true; + + const champ = document.getElementById(id); + if (!champ) return absent(id); + + champ.value = valeur; + notifier(champ); + return true; +} + +function poserSelect(id, valeur) { + if (!valeur) return true; + + const champ = document.getElementById(id); + if (!champ) return absent(id); + + // Match exact : les valeurs du thème sont reprises telles quelles de + // RECHERCHE.md §3. Poser une valeur sans option correspondante VIDERAIT le + // champ en silence. + const option = [...champ.options].find((option) => option.value === valeur); + if (!option) { + console.warn(`Écho du Huit : aucune option « ${valeur} » dans #${id}.`); + return false; + } + + champ.value = option.value; + notifier(champ); + return true; +} + +// Gravity Forms écoute `change` : poser `.value` seul peut rester invisible du +// formulaire. Le dispatch est gratuit et sans risque, on ne le suppose pas. +function notifier(champ) { + champ.dispatchEvent(new Event("input", { bubbles: true })); + champ.dispatchEvent(new Event("change", { bubbles: true })); +} + +function absent(id) { + console.warn(`Écho du Huit : champ #${id} introuvable.`); + return false; +} + +// Un geste sans retour visible est indiagnosticable : le bandeau dit ce qui a +// été fait ET ce qui reste à faire. +function injecterBandeau(aCompleter, manquants) { + const bandeau = creerBandeau(); + bandeau.append( + paragraphe("echo-bandeau-titre", "Écho du Huit a prérempli ce formulaire."), + paragraphe("echo-bandeau-intro", "À compléter :"), + listeDe(aCompleter), + paragraphe("echo-bandeau-consigne", "Relis puis envoie toi-même."), + ); + + if (manquants.length > 0) { + bandeau.append( + paragraphe( + "echo-bandeau-alerte", + `⚠️ Certains champs n'ont pas pu être remplis (le formulaire de la mairie a peut-être changé) : ${manquants.join(", ")}.`, + ), + ); + } + + injecter(bandeau); +} + +// Le pré-remplissage a échoué en cours de route : le dire, plutôt que de +// laisser le bénévole devant un formulaire à moitié rempli sans explication. +function injecterBandeauDegrade() { + const bandeau = creerBandeau(); + bandeau.append( + paragraphe("echo-bandeau-titre", "Écho du Huit n'a pas pu préremplir ce formulaire."), + paragraphe( + "echo-bandeau-alerte", + "⚠️ Le formulaire de la mairie a peut-être changé. Vérifie chaque champ et remplis-le à la main ; le détail de l'erreur est dans la console (F12).", + ), + ); + + injecter(bandeau); +} + +function creerBandeau() { + const bandeau = document.createElement("aside"); + bandeau.className = "echo-bandeau"; + return bandeau; +} + +function injecter(bandeau) { + const ancre = + document.getElementById("gform_wrapper_6") ?? document.getElementById("gform_6"); + if (!ancre) { + console.warn("Écho du Huit : formulaire introuvable, bandeau non injecté."); + return; + } + + ancre.before(bandeau); +} + +// textContent uniquement : jamais d'innerHTML sur une page tierce. +function paragraphe(classe, texte) { + const p = document.createElement("p"); + p.className = classe; + p.textContent = texte; + return p; +} + +function listeDe(elements) { + const liste = document.createElement("ul"); + liste.className = "echo-bandeau-liste"; + for (const element of elements) { + const item = document.createElement("li"); + item.textContent = element; + liste.append(item); + } + return liste; +} diff --git a/extension/manifest.json b/extension/manifest.json index b5c32e7..fecb728 100644 --- a/extension/manifest.json +++ b/extension/manifest.json @@ -10,8 +10,18 @@ } }, "background": { - "scripts": ["background.js"] + "scripts": ["config.js", "background.js"] }, + "content_scripts": [ + { + "matches": [ + "https://www.ville-cugnaux.fr/mes-loisirs/associations/proposer-un-evenement-dans-lagenda/*" + ], + "js": ["config.js", "formulaire-valeurs.js", "formulaire-mairie.js"], + "css": ["formulaire-mairie.css"], + "run_at": "document_idle" + } + ], "action": { "default_title": "Ouvrir la liste de l'Écho du Huit", "default_icon": "icons/icon.svg" From 23e58b17ee021708ffb11ec431290d93f102c4fa Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Fri, 17 Jul 2026 13:10:59 +0200 Subject: [PATCH 4/5] docs: record the prefill seam and the optional local config Co-Authored-By: Claude Opus 4.8 --- README.md | 31 ++++++++++++++++++++++++++ docs/DECISIONS.md | 55 +++++++++++++++++++++++++++++++++++++++++++++++ docs/RECHERCHE.md | 46 +++++++++++++++++++++++---------------- 3 files changed, 114 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index a7caec3..24ebda5 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,37 @@ runtime à installer) : > Pense aussi à **fermer les anciens onglets de la liste** avant de recharger : > une page orpheline rend les clics inertes. +### Configuration (optionnelle) + +Le formulaire de la mairie demande un **organisateur** et un **email** : ils sont +personnels, donc absents du dépôt. Pour qu'ils soient pré-remplis : + +```sh +cp extension/config.local.json.example extension/config.local.json +``` + +puis édite le fichier (il est gitignoré, il ne partira jamais sur le dépôt) : + +```json +{ + "organisateur": "L'Atelier du Huit", + "email": "contact@atelier-huit.fr" +} +``` + +Ce fichier est **facultatif**. Sans lui, tout le reste est pré-rempli (titre, +dates, heures, lieu, thème, tarifs) : seuls l'organisateur et l'email restent +vides, et le bandeau du formulaire te le dit. + +> ⚠️ Ce fichier ne surcharge que `organisateur` et `email`. Les autres valeurs +> par défaut (thème « Culture », tarifs « Gratuit », adresse **et coordonnées** +> du café) vont ensemble et vivent dans `extension/config.js` : c'est là qu'on +> les change. Y mettre un `cafe` partiel produirait des coordonnées invalides. + +> **Après avoir créé ou modifié `config.local.json`** : recharge le module dans +> `about:debugging`. L'extension lit le fichier au réveil de son arrière-plan, +> pas à chaque ouverture du formulaire. + ### Vérifier l'extension ```sh diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index a68cf6e..1d0c67c 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -126,3 +126,58 @@ deux avals du parseur ont chacun le leur : la liste affiche l'adresse pour ne pa montrer une ligne vide (cas majoritaire), le content script décide s'il pose **aussi** les coordonnées (`input_18`). Écrire l'adresse dans le payload détruirait le signal « pas de lieu » dont il a besoin. + +## 2026-07-17 — [T1] Pré-remplissage : content scripts classiques + `globalThis` + +Le formulaire mairie est pré-rempli par deux content scripts (`config.js`, +`formulaire-valeurs.js`, `formulaire-mairie.js`), **classiques — pas des modules +ESM** — qui communiquent par des namespaces `globalThis` (`EchoConfig`, +`EchoFormulaire`). + +**Pourquoi pas ESM :** un content script n'est pas un module (`import` y lève une +`SyntaxError`), et le `import()` dynamique y est cassé côté Firefox (bugs ouverts +1803950 / 1536094). Restait à convertir la couture `transfert.js`, figée le +2026-07-14 : non. + +⚠️ **`config.js` et `formulaire-valeurs.js` n'ont NI `import` NI `export`, et +c'est délibéré — ne pas « corriger ».** Un fichier sans les deux est valide **à la +fois** comme script classique (content script, background) et comme module ESM +(`import "./config.js"` depuis `presentation.js` et depuis `bun test`). C'est ce +qui permet à `presentation.js` de partager l'adresse du café **sans build**. Le +jour où quelqu'un y ajoute un `export`, les content scripts cassent +silencieusement. + +**Corollaire : l'ordre des tableaux `js` du manifest EST la dépendance** +(`config` → `valeurs` → `mairie`), testé dans `extension.test.ts`. + +**`CLE_EVENEMENT_EN_ATTENTE` est dupliquée** dans `formulaire-valeurs.js` +(l'original vit dans `transfert.js`, en ESM, inatteignable depuis un content +script classique). Un **test de dérive** garantit l'égalité des deux. + +**Config (a) : défauts committés + `config.local.json` optionnel.** Les défauts +(thème, tarifs, café) sont dans `config.js` ; seuls organisateur et email sont +personnels et vivent dans `config.local.json` (gitignoré). Le fichier n'est +**pas requis** : un clone frais doit marcher, il laisserait sinon l'extension +cassée par défaut. Absent ou invalide → défauts nus, manque **signalé** dans le +bandeau, jamais d'exception. + +**C'est le background qui lit `config.local.json`**, puis le passe par le +storage. Un content script ne pourrait le `fetch(runtime.getURL(...))` que si le +fichier était en `web_accessible_resources` — ce qui **exposerait l'email à la +page de la mairie**. Le fichier étant optionnel, il ne peut pas non plus être +listé dans un tableau `js` (Firefox refuse d'installer si un fichier déclaré +manque) : `fetch` + repli est la seule voie. + +**On ne remplit que ce qu'on sait ; le reste est dit, jamais inventé.** Pas +d'heure « 00:00 » pour une journée entière, pas de `lat|lng` deviné pour un lieu +hors café, pas d'organisateur par défaut. Ce qui manque part dans le bandeau +(`aCompleter`). En particulier, **`input_6_35` (accessibilité) est laissé +intact** : `RECHERCHE.md` le dit « fixe », mais la machine ne sait pas si le lieu +est accessible — cocher au hasard sur un formulaire **modéré** serait exactement +le « champ rempli faux » qu'on veut éviter. + +**Séparation calcul / DOM** : `formulaire-valeurs.js` est pur et testé (les dates +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 +happy-dom n'émulerait de toute façon pas Gravity Forms. C'est le test humain qui +tranche. diff --git a/docs/RECHERCHE.md b/docs/RECHERCHE.md index b41652d..3b6fd39 100644 --- a/docs/RECHERCHE.md +++ b/docs/RECHERCHE.md @@ -45,24 +45,34 @@ Content script qui **remplit le DOM par id**, puis **l'utilisateur soumet à la ### Mapping des champs (ids confirmés présents dans le HTML) -| id DOM | name | Contenu | Source | -|---|---|---|---| -| `#input_6_42` | `input_42` | Organisateur | config (« L'Atelier du Huit ») | -| `#input_6_41` | `input_41` | Email | config | -| `#input_6_1` | `input_1` | Titre | SUMMARY | -| `#input_6_3` | `input_3` | Description (textarea) | DESCRIPTION | -| `#input_6_20` | `input_20` | Thème (select) | mapping (voir §3) | -| `#input_6_31` | `input_31` | Date début `jj/mm/aaaa` | DTSTART | -| `#input_6_32` | `input_32` | Date fin `jj/mm/aaaa` | DTEND | -| `#input_6_38_1` / `#input_6_38_2` | `input_38[]` | Heure début [HH, MM] | DTSTART | -| `#input_6_39_1` / `#input_6_39_2` | `input_39[]` | Heure fin [HH, MM] | DTEND | -| `#input_6_35` (`_1`) | `input_35.1` | Accessibilité (case) | fixe | -| `#input_6_36` | `input_36` | Public ciblé | optionnel | -| `#input_6_40` | `input_40` | Tarifs | défaut « Gratuit » | -| `#nova_address` | `nova_address` | Adresse texte | défaut café | -| `#input_6_18` | `input_18` | `lat\|lng\|adresse` (hidden) | défaut café | -| `#input_6_34` | `input_34` | **Image (file)** | ⚠️ non remplissable par script (sécurité) → manuel | -| `#input_6_23`, `#input_6_43` | | Honeypot | laisser vide | +> ⚠️ **La colonne « balise réelle » a été payée par un bug** (2026-07-17). Le +> corps de la tâche T1 affirmait « Heures = 2 **selects** » : c'est **faux**, ce +> sont des ``. Le code l'a suivi et les 4 champs d'heures +> ont échoué (`champ.options` undefined → TypeError). **Ne pas déduire le type +> d'un champ de son intitulé : le HTML est la seule source de vérité.** Types +> ci-dessous vérifiés sur la page réelle le 2026-07-17. + +| id DOM | name | Balise réelle | Contenu | Source | +|---|---|---|---|---| +| `#input_6_42` | `input_42` | `input[type=text]` | Organisateur | config (« L'Atelier du Huit ») | +| `#input_6_41` | `input_41` | `input[type=email]` | Email | config | +| `#input_6_1` | `input_1` | `input[type=text]` | Titre | SUMMARY | +| `#input_6_3` | `input_3` | `textarea` | Description | DESCRIPTION | +| `#input_6_20` | `input_20` | **`select`** | Thème | mapping (voir §3) | +| `#input_6_31` | `input_31` | `input[type=text]` (datepicker) | Date début `jj/mm/aaaa` | DTSTART | +| `#input_6_32` | `input_32` | `input[type=text]` (datepicker) | Date fin `jj/mm/aaaa` | DTEND | +| `#input_6_38_1` / `#input_6_38_2` | `input_38[]` | **`input[type=number]`** (`min/max`, placeholder `HH`/`MM`) | Heure début [HH, MM] | DTSTART | +| `#input_6_39_1` / `#input_6_39_2` | `input_39[]` | **`input[type=number]`** | Heure fin [HH, MM] | DTEND | +| `#input_6_35` (`_1`) | `input_35.1` | `input[type=checkbox]` | Accessibilité | ⚠️ laissé **intact** : la machine ne sait pas si le lieu est accessible | +| `#input_6_36` | `input_36` | | Public ciblé | optionnel | +| `#input_6_40` | `input_40` | **`textarea`** | Tarifs | défaut « Gratuit » | +| `#nova_address` | `nova_address` | `input[type=text]` | Adresse texte | défaut café | +| `#input_6_18` | `input_18` | `input[type=hidden]` | `lat\|lng\|adresse` | défaut café | +| `#input_6_34` | `input_34` | `input[type=file]` | **Image** | ⚠️ non remplissable par script (sécurité) → manuel | +| `#input_6_23`, `#input_6_43` | | | Honeypot | laisser vide | + +Les 4 champs d'heures acceptent le **zéro-padding** (`"09"`, `"00"`) : une suite +de chiffres est un *valid floating-point number* au sens HTML. **Défaut café** : `8 Rue du Pré Vicinal 31270 Cugnaux` `input_18` = `43.53753132806989|1.342981890597508|8 Rue du Pré Vicinal 31270 Cugnaux` From ee79ca07572198720e13783278bc105f2b7f9cdb Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Fri, 17 Jul 2026 17:36:56 +0200 Subject: [PATCH 5/5] docs: capitalize the mairie form prefill and config solution (T1) Co-Authored-By: Claude Opus 4.8 --- ...-pour-le-formulaire-mairi-oebs-solution.md | 161 ++++++++++++++++++ 1 file changed, 161 insertions(+) create mode 100644 docs/solutions/2026-07-17-formater-les-donnees-pour-le-formulaire-mairi-oebs-solution.md diff --git a/docs/solutions/2026-07-17-formater-les-donnees-pour-le-formulaire-mairi-oebs-solution.md b/docs/solutions/2026-07-17-formater-les-donnees-pour-le-formulaire-mairi-oebs-solution.md new file mode 100644 index 0000000..67a23a4 --- /dev/null +++ b/docs/solutions/2026-07-17-formater-les-donnees-pour-le-formulaire-mairi-oebs-solution.md @@ -0,0 +1,161 @@ +# Solution : [T1] Content script : pré-remplir le formulaire mairie (+ config) + +## Problème résolu + +Au clic sur « Créer sur le site mairie », l'extension déposait l'événement dans +`storage.local` puis ouvrait un **formulaire vide** : le bénévole devait recopier +à la main titre, dates, heures, adresse, organisateur, email — exactement le +geste que l'extension existe pour supprimer. La boucle n'était pas fermée. + +Objectif tenu : le formulaire s'ouvre **déjà rempli** de ce que la machine sait, +**laisse vide** ce qu'elle ignore (jamais de valeur inventée), et **dit** dans un +bandeau ce qui reste à compléter. Le bénévole relit, complète (image, thème, +enrichissement) et envoie lui-même — **pas d'auto-submit**. + +Ce ticket a aussi **introduit la config** de l'extension. + +## Approche choisie + +**Deux content scripts classiques partageant un scope + namespace `globalThis`** +(approche 3 du brainstorm), **+ config à défauts committés et surcharge locale +optionnelle** (option (a)). + +Le vrai risque du ticket n'était pas « poser des valeurs dans un DOM » : c'était +le **calcul des dates** (DTEND exclusif, dates flottantes de journée entière, +franchissement de minuit) — invisible sous `TZ=Europe/Paris`, la famille de bugs +qui avait déjà coûté deux passes au ticket précédent. Il fallait donc que ce +calcul reste **sous `bun test`**. + +Trois alternatives écartées, et pourquoi : +- **Content script monolithique** : logique de dates intestable (rien à importer + en `bun test`) → on coderait à l'aveugle les deux bugs les plus chers. +- **Module ESM en import dynamique** (`import(runtime.getURL(...))` + + `web_accessible_resources`) : s'appuie sur un **bug ouvert MV3 de Firefox** + ([bugzilla 1803950](https://bugzilla.mozilla.org/show_bug.cgi?id=1803950)) — + parier le risque sur le point le plus fragile de la plateforme. +- **Formater en amont, content script bête** : réviserait une **couture figée + trois jours plus tôt** et coupleraient la page liste au formulaire mairie. + +L'approche retenue garde le calcul testable **sans** parier sur un bug plateforme +**ni** rouvrir une couture fraîche. + +## Décisions clés + +- **`config.js` sans `import` ni `export`, communique par `globalThis`.** Un + fichier sans `import`/`export` est valide **à la fois** comme script classique + (content script, background) **et** comme module ESM (`import "./config.js"` + depuis `presentation.js` et `bun test`). C'est ce qui permet d'absorber + `LIEU_PAR_DEFAUT` (fin d'une duplication) sans build. ⚠️ Le jour où quelqu'un + ajoute un `export`, tous les content scripts cassent → acté dans `DECISIONS.md`, + avec un commentaire en tête de fichier. + +- **Le background lit `config.local.json`, pas le content script.** Un content + script ne peut `fetch(runtime.getURL(...))` que si la ressource est en + `web_accessible_resources` — ce qui **exposerait l'email à la page de la + mairie**. Le background lit sans WAR et transmet via `storage.local`. + +- **Config optionnelle, dégradation propre.** Fichier absent (clone frais), HTTP + non-ok ou JSON invalide → `{}` → organisateur/email vides **signalés dans le + bandeau**, tout le reste rempli. Un `config.local.json` **requis** (énoncé + littéral) aurait cassé tout clone frais : rejeté. Le fichier n'étant pas + garanti présent, il **ne peut pas** être listé dans le manifest (Firefox refuse + d'installer si un fichier `js` manque) : `fetch` + repli est la seule voie. + +- **`CLE_EVENEMENT_EN_ATTENTE` dupliquée + test de dérive.** La constante vit dans + `transfert.js` (ESM), inatteignable depuis un content script classique. + Dupliquée volontairement, avec un test qui garantit l'égalité avec l'originale + (plutôt que rouvrir la couture pour convertir `transfert.js`). + +- **Honnêteté machine/humain.** Jamais de « 00:00 » sur une journée entière + (heures laissées vides), jamais de `lat|lng` inventé sur un lieu hors café, + `input_6_35` (accessibilité) laissé intact, honeypots intacts. Tout manque + remonte dans `aCompleter` (bandeau). + +- **`config.local.json.example` dans `extension/`, pas à la racine.** Le + background résout `runtime.getURL("config.local.json")` **dans `extension/`** : + l'exemple doit être à côté de sa cible de copie. + +## Patterns à réutiliser + +- **Fichier « bilingue » ESM/classique** : ne poser `import`/`export` nulle part, + publier l'API sur `globalThis.MonNamespace`. Chargeable partout dans une + extension MV3 sans build. Le prix : une convention à documenter pour qu'un futur + agent ne la « corrige » pas en `export`. + +- **Séparer calcul pur (testé) / effet de bord (fin, non testé).** + `calculerValeurs(payload, config)` est pur et renvoie un objet **plat, prêt à + poser** (`dateDebut: "18/07/2026"`, `heureDebut: ["19","30"] | null`, …) ; + `formulaire-mairie.js` ne fait que lire le storage et écrire le DOM. Le bug des + heures (voir Pièges) a **validé** ce découpage : confiné au poseur, diagnostiqué + en une pile d'appels, corrigé en quelques lignes, cœur testé jamais touché. + +- **Merge tolérant pour config éditée à la main** : `fusionner(defauts, locale)` + traite `null` / non-objet / tableau comme « pas de surcharge » et ne mute pas + les défauts. Une config optionnelle ne doit **jamais** lever. + +- **Dates de calendrier sans fuseau.** Journée entière = date flottante + (RFC 5545) → lire **uniquement** les composantes locales + (`getFullYear/getMonth/getDate`), jamais l'heure, jamais un fuseau. DTEND est + **exclusif** → reculer la date de fin d'un jour (en composantes locales, jamais + « −24h »). Événement horodaté → tout à `Europe/Paris` via `Intl.DateTimeFormat`, + heures zéro-paddées. **Épingler `TZ=Europe/Paris` dans les tests ET rejouer sous + plusieurs fuseaux** (le bug de fuseau est invisible sous le fuseau de dev). + +- **Poser une valeur dans un champ piloté par un framework** : après `.value`, + dispatcher `new Event("input", {bubbles:true})` **puis** + `new Event("change", {bubbles:true})` (Gravity Forms écoute `change`). Le + dispatch est gratuit — ne pas supposer que `.value` suffit. + +- **Isoler chaque effet de bord dans son propre `try/catch`.** Un `try` global + autour du remplissage laissait la première exception emporter **tous** les + champs suivants. Un `try` **par champ** → le champ fautif part dans `manquants`, + les autres sont posés. Ferme la classe entière de panne, pas le cas vu. + +- **Rendre tout geste automatique visible.** Un bandeau injecté dit ce qui a été + fait et ce qui reste ; storage vide (visite directe) → **rien**. Un + préremplissage silencieusement partiel soumis à une mairie qui modère est le + pire échec possible. + +## Pièges à éviter + +- 🔴 **Ne jamais déduire le type d'un champ de son intitulé — le HTML est la seule + source de vérité.** L'énoncé du ticket affirmait « Heures = 2 **selects** + input_6_38_1/_2 ». Le HTML réel dit ``. `poserSelect` + faisait `[...champ.options]` sur un `` → `champ.options` undefined → + `TypeError`, qui (avant l'isolation par champ) vidait aussi Tarifs, adresse et + coordonnées. Corrigé : heures via `poserTexte` (le zéro-padding `"09"`/`"00"` + reste un *valid floating-point number* au sens HTML). `RECHERCHE.md` §2 a été + corrigée à la racine (colonne « balise réelle » + avertissement) pour qu'un + futur agent ne repaie pas ce bug. C'est la cause racine du seul bug du ticket, + et il venait d'un **doc/énoncé faux cru sur parole**. + +- **Toucher au `manifest.json` impose Décharger + Charger**, pas « Recharger » ni + F5. Piège déjà payé plusieurs fois. Fermer aussi les anciens onglets de la liste + (page orpheline = clics inertes). + +- **DTEND exclusif** : sans recul d'un jour, la date de fin d'un événement + multi-jours est **fausse** — et soumise à une mairie qui modère (personne ne + relit une date qui *a l'air* juste). Le bug le plus cher du ticket. + +- **Course background ↔ content script** (P3 ouvert, non corrigé) : + `chargerConfigLocale()` est async ; au tout premier chargement post-install, si + le formulaire s'ouvrait avant la fin du `fetch`, organisateur/email seraient + vides à tort. Improbable (la valeur persiste ensuite), noté pour mémoire. + +- **`chargerConfigLocale()` s'exécute à chaque réveil de l'event page** et écrit + `{}` si le fichier est absent. Toute future page d'options qui écrirait dans + `storage.local` verrait ses valeurs **écrasées par `{}`** au prochain réveil. + Trancher « qui fait autorité, fichier ou UI » fait partie du futur ticket — ce + n'est pas « juste ajouter un écran ». + +- **Le poseur DOM n'est pas testé unitairement** (aucun harnais DOM dans le repo ; + happy-dom n'émule pas le JS de Gravity Forms / Leaflet). C'est le **test humain** + qui tranche le rendu réel (datepicker, marqueur carte, bandeau non écrasé par le + CSS de la mairie). Le bug des heures l'a prouvé : seul le DOM réel tranche. + +- **Config `cafe` partielle interdite** : `fusionner` est shallow → une locale + avec un `cafe` incomplet produirait `"undefined|adresse"`. Le README précise que + seuls `organisateur` et `email` sont surchargeables. + +## Tags +tags: [firefox-extension, manifest-v3, content-script, globalthis, esm, gravity-forms, dates, timezone, rfc5545, config, bun-test, tdd, vanilla-js]