From 2e60bd08d8b87b6ced176cf97d7cc7d11bab34cc Mon Sep 17 00:00:00 2001 From: Pierre Martin Date: Fri, 17 Jul 2026 13:10:00 +0200 Subject: [PATCH] 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"