diff --git a/extension/liste.css b/extension/liste.css index 8eaf117..9b44f3a 100644 --- a/extension/liste.css +++ b/extension/liste.css @@ -53,12 +53,41 @@ h1 { color: #666; } -.evenement-creer { +.evenement-actions { flex-shrink: 0; + display: flex; + flex-direction: column; + align-items: flex-end; + gap: 0.35rem; +} + +.evenement-creer, +.evenement-secondaire { font: inherit; cursor: pointer; } +.evenement-secondaire { + border: none; + background: none; + padding: 0; + color: #666; + font-size: 0.85rem; + text-decoration: underline; +} + +.evenement-statut { + color: #666; + font-size: 0.85rem; + font-weight: 600; +} + +/* Ignoré : discret mais TOUJOURS visible et actionnable — un ignoré invisible + est un ignoré qu'on ne peut plus dé-ignorer. */ +.evenement-ignore .evenement-infos { + opacity: 0.55; +} + .evenement-publication { flex-shrink: 0; display: flex; diff --git a/extension/liste.js b/extension/liste.js index ece5d44..9f284b1 100644 --- a/extension/liste.js +++ b/extension/liste.js @@ -1,9 +1,17 @@ import { recupererFluxCalendrier } from "./nextcloud.js"; import { parserEvenements } from "./evenements.js"; -import { formaterDate, formaterLieu, trierParDebut } from "./presentation.js"; +import { + formaterDate, + formaterLieu, + formaterStatut, + repousserIgnores, + trierParDebut, +} from "./presentation.js"; import { deposerEvenement } from "./transfert.js"; import { calculerPeriode, extraireCartes, recupererAgendaPublie } from "./agenda-mairie.js"; import { indexerCartes, trouverPublication } from "./publication.js"; +import { ecrireStatut, extraireReponses } from "./caldav.js"; +import { jourParis, lireStatut, poserIgnore, poserSoumis, retirerStatut } from "./statut-mairie.js"; const URL_NEXTCLOUD = "https://atelier-huit.frama.space/"; @@ -18,8 +26,28 @@ const MESSAGES_ERREUR = { "Impossible de joindre Nextcloud. Vérifie ta connexion internet, puis réessaie.", serveur: "Nextcloud a renvoyé une erreur inattendue. Réessaie dans un moment.", + "lecture-seule": + "Le calendrier est partagé en lecture seule : demande le droit d'écriture pour enregistrer les statuts.", + conflit: + "Quelqu'un a modifié cet événement pendant ce temps. Recharge la liste (F5), puis réessaie.", + csrf: + "Ta session Nextcloud a expiré. Recharge Nextcloud dans un onglet, puis réessaie.", + introuvable: + "Événement introuvable dans le calendrier : il a peut-être été supprimé. Recharge la liste (F5).", + ambigu: + "Événement introuvable dans le calendrier : il a peut-être été supprimé. Recharge la liste (F5).", + "vevent-introuvable": + "Événement introuvable dans le calendrier : il a peut-être été supprimé. Recharge la liste (F5).", + "reecriture-risquee": + "Par prudence, rien n'a été écrit : cet événement a une forme inhabituelle. Ajoute la catégorie à la main dans Nextcloud.", + "ics-illisible": + "Par prudence, rien n'a été écrit : cet événement a une forme inhabituelle. Ajoute la catégorie à la main dans Nextcloud.", }; +// Type volontairement absent de la table : il déclenche le message de repli +// quand l'écriture échoue pour une raison qu'on n'a pas prévue. +const ERREUR_IMPREVUE = "imprevu"; + const etat = document.querySelector("#etat"); const liste = document.querySelector("#liste"); @@ -71,29 +99,156 @@ function rendreEvenement(evenement) { infos.append(titre, date, lieu); - const creer = document.createElement("button"); - creer.type = "button"; - creer.className = "evenement-creer"; - creer.textContent = "Créer sur le site mairie"; - creer.addEventListener("click", async () => { + item.append(infos, rendreActions(evenement)); + refleterStatut(evenement, item); + return item; +} + +// Zone d'actions : UNIQUE point de suppression quand l'événement se révèle +// publié (l'agenda mairie fait foi et écrase tout). +function rendreActions(evenement) { + const actions = document.createElement("div"); + actions.className = "evenement-actions"; + + const statut = lireStatut(evenement.categories); + if (statut.type !== "aucun") { + const mention = document.createElement("span"); + mention.className = "evenement-statut"; + mention.textContent = formaterStatut(statut); + actions.append(mention); + } + + // Un événement déjà soumis n'a plus de bouton « Créer » : c'est le doublon + // d'annonce qu'on combat. Il revient si l'envoi est annulé. + if (statut.type !== "soumis") { + actions.append( + bouton("evenement-creer", "Créer sur le site mairie", (ligne) => + creerSurSiteMairie(evenement, ligne), + ), + ); + } + + actions.append(gesteDeStatut(statut, evenement)); + return actions; +} + +function gesteDeStatut(statut, evenement) { + if (statut.type === "aucun") { + return bouton("evenement-secondaire", "Ignorer", (ligne) => + appliquerStatut(evenement, ligne, poserIgnore), + ); + } + + const libelle = statut.type === "ignoré" ? "Ne plus ignorer" : "Annuler l'envoi"; + return bouton("evenement-secondaire", libelle, (ligne) => + appliquerStatut(evenement, ligne, retirerStatut), + ); +} + +// `action` reçoit la ligne (le
  • ) : le geste ne capture pas le DOM, il le +// retrouve au clic — la ligne peut avoir été re-rendue entre-temps. +function bouton(classe, libelle, action) { + const element = document.createElement("button"); + element.type = "button"; + element.className = classe; + element.textContent = libelle; + element.addEventListener("click", async () => { + const ligne = element.closest(".evenement"); try { - await creerSurSiteMairie(evenement); + await pendantEnregistrement(ligne, element, () => action(ligne)); } catch (erreur) { rendreEchecCreation(erreur); } }); - - item.append(infos, creer); - return item; + return element; } -async function creerSurSiteMairie(evenement) { +// Double clic impossible : tous les boutons de la ligne sont neutralisés le +// temps de l'aller-retour. Si la ligne a été re-rendue entre-temps, ces boutons +// ne sont plus dans le DOM et les réactiver est sans effet. +async function pendantEnregistrement(ligne, declencheur, action) { + const boutons = [...ligne.querySelectorAll("button")]; + const libelle = declencheur.textContent; + boutons.forEach((b) => (b.disabled = true)); + declencheur.textContent = "Enregistrement…"; + try { + await action(); + } finally { + boutons.forEach((b) => (b.disabled = false)); + declencheur.textContent = libelle; + } +} + +// Écriture partagée : JAMAIS d'affichage optimiste. Tant que Nextcloud n'a pas +// confirmé, le statut affiché reste celui qu'on sait vrai — mentir sur un état +// que d'autres bénévoles lisent est pire que ne rien afficher. +async function appliquerStatut(evenement, ligne, transformer) { + const res = await ecrireStatut({ + // Wrapper obligatoire (cf. charger) : un `fetch` nu lève « Illegal invocation ». + fetchImpl: (u, o) => fetch(u, o), + extraireReponsesImpl: extraireReponses, + uid: evenement.uid, + transformer, + }); + + if (res.ok) { + // Les catégories viennent de l'aller-retour serveur, pas de la liste + // (potentiellement périmée). + evenement.categories = res.categories; + rafraichirLigne(evenement, ligne); + // Un échec précédent laisserait sinon « rien n'a été enregistré » à l'écran + // alors que le tag vient d'être écrit : le mensonge en sens inverse. + etat.replaceChildren(); + } else { + rendreEchecStatut(res.erreur); + } + return res; +} + +// Re-rendu de la SEULE ligne concernée : recharger la liste effacerait les +// badges « publié » déjà posés par l'annotation asynchrone. +function rafraichirLigne(evenement, ligne) { + ligne.querySelector(".evenement-actions")?.replaceWith(rendreActions(evenement)); + refleterStatut(evenement, ligne); +} + +function refleterStatut(evenement, ligne) { + const ignore = lireStatut(evenement.categories).type === "ignoré"; + ligne.classList.toggle("evenement-ignore", ignore); +} + +// Le tag « soumis » est un confort d'équipe ; remplir le formulaire est la +// mission : un échec d'écriture n'empêche pas l'ouverture du formulaire. +async function creerSurSiteMairie(evenement, ligne) { + try { + await appliquerStatut(evenement, ligne, (categories) => + poserSoumis(categories, jourParis(new Date())), + ); + } catch (erreur) { + // Filet de sécurité : même un imprévu du chemin d'écriture ne doit pas + // retenir le formulaire. Le détail part en console, l'utilisateur lit une + // phrase, et la mission continue. + console.error("Écho du Huit : écriture du statut « soumis » interrompue.", erreur); + rendreEchecStatut(ERREUR_IMPREVUE); + } + // Déposer AVANT d'ouvrir l'onglet : sinon le content script peut lire avant // l'écriture. await deposerEvenement(browser.storage.local, evenement); await browser.tabs.create({ url: URL_FORMULAIRE_MAIRIE }); } +// Échec d'un geste de statut : message honnête, statut inchangé. +function rendreEchecStatut(typeErreur) { + etat.replaceChildren(); + const message = document.createElement("p"); + message.className = "etat-erreur"; + message.textContent = + MESSAGES_ERREUR[typeErreur] ?? + "Le statut n'a pas pu être enregistré dans Nextcloud. Réessaie dans un moment."; + etat.append(message); +} + // Sans ce retour, la promesse du clic échouait en silence : le bouton paraissait // mort et la panne était indiagnosticable, y compris pour nous. Le détail // technique est affiché volontairement — c'est ce qui permet de nommer la cause @@ -139,7 +294,7 @@ async function charger() { // « Illegal invocation » dans le navigateur. const res = await recupererFluxCalendrier((u, o) => fetch(u, o)); if (res.ok) { - const evenements = trierParDebut(parserEvenements(res.flux, new Date())); + const evenements = repousserIgnores(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. @@ -178,7 +333,11 @@ async function annoterPublications(evenements) { // 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(); + item.querySelector(".evenement-actions")?.remove(); + // La mairie fait foi : « publié » écrase TOUT l'affichage du statut, grisé + // compris. Sans cette ligne, un événement à la fois ignoré et publié resterait + // grisé sans plus aucun bouton — un ignoré qu'on ne peut plus dé-ignorer. + item.classList.remove("evenement-ignore"); const publication = document.createElement("div"); publication.className = "evenement-publication"; diff --git a/extension/presentation.js b/extension/presentation.js index 0f107f8..06dfa70 100644 --- a/extension/presentation.js +++ b/extension/presentation.js @@ -5,6 +5,7 @@ // 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"; +import { lireStatut } from "./statut-mairie.js"; const LOCALE = "fr-FR"; const FUSEAU = "Europe/Paris"; @@ -85,3 +86,22 @@ function formaterHorodate({ debut, fin }) { export function formaterLieu(evenement) { return evenement.lieu.trim() || LIEU_PAR_DEFAUT; } + +// Statut mairie en clair. Le jour du tag est une date NUE (« AAAA-MM-JJ »), +// jamais un instant : on la découpe, on ne la convertit pas — un Date la ferait +// glisser d'un jour selon le fuseau de la machine. +export function formaterStatut(statut) { + if (statut.type === "ignoré") return "Ignoré"; + if (statut.type !== "soumis") return ""; + + if (!statut.jour) return "Soumis"; + const [, mois, jour] = statut.jour.split("-"); + return `Soumis le ${jour}/${mois}`; +} + +// Les ignorés restent VISIBLES (on doit pouvoir les dé-ignorer) mais cessent +// d'encombrer : ils passent en fin de liste, chaque groupe gardant son ordre. +export function repousserIgnores(evenements) { + const ignore = (evenement) => lireStatut(evenement.categories).type === "ignoré"; + return [...evenements.filter((e) => !ignore(e)), ...evenements.filter(ignore)]; +} diff --git a/presentation.test.ts b/presentation.test.ts index 409ed95..920e3ae 100644 --- a/presentation.test.ts +++ b/presentation.test.ts @@ -4,6 +4,8 @@ import { LIEU_PAR_DEFAUT, formaterDate, formaterLieu, + formaterStatut, + repousserIgnores, trierParDebut, } from "./extension/presentation.js"; @@ -213,3 +215,44 @@ describe("formaterLieu", () => { expect(formaterLieu(evenement({ lieu: " " }))).toBe(LIEU_PAR_DEFAUT); }); }); + +describe("formaterStatut", () => { + test("soumis avec date → « Soumis le JJ/MM »", () => { + expect(formaterStatut({ type: "soumis", jour: "2026-08-13" })).toBe("Soumis le 13/08"); + }); + + test("soumis sans date exploitable → « Soumis »", () => { + expect(formaterStatut({ type: "soumis", jour: null })).toBe("Soumis"); + }); + + test("ignoré → « Ignoré » ; aucun → rien à afficher", () => { + expect(formaterStatut({ type: "ignoré" })).toBe("Ignoré"); + expect(formaterStatut({ type: "aucun" })).toBe(""); + }); +}); + +describe("repousserIgnores", () => { + test("les ignorés passent en fin, l'ordre relatif est conservé", () => { + const evenements = [ + evenement({ uid: "a" }), + evenement({ uid: "ignore-1", categories: ["mairie:ignoré"] }), + evenement({ uid: "b", categories: ["Concert"] }), + evenement({ uid: "ignore-2", categories: ["Concert", "mairie:ignoré"] }), + evenement({ uid: "c", categories: ["mairie:soumis:2026-08-13"] }), + ]; + + expect(repousserIgnores(evenements).map((e) => e.uid)).toEqual([ + "a", + "b", + "c", + "ignore-1", + "ignore-2", + ]); + }); + + test("liste sans ignoré → inchangée, sans mutation de l'entrée", () => { + const evenements = [evenement({ uid: "a" }), evenement({ uid: "b" })]; + expect(repousserIgnores(evenements).map((e) => e.uid)).toEqual(["a", "b"]); + expect(evenements.map((e) => e.uid)).toEqual(["a", "b"]); + }); +});