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 <noreply@anthropic.com>
This commit is contained in:
Pierre Martin
2026-07-17 14:30:47 +02:00
co-authored by Claude Opus 4.8
parent 78153557dd
commit 2e60bd08d8
5 changed files with 363 additions and 1 deletions
+42
View File
@@ -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);
});
+24
View File
@@ -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);
+49
View File
@@ -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;
}
+237
View File
@@ -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 <select> par un <input> → `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 <input type="number"> (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;
}
+11 -1
View File
@@ -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"