===  FourmizzTranslate ===
Contributors: sovereigntranslate
Tags: translation, multilingual, i18n, weglot alternative, self-hosted
Requires at least: 5.8
Tested up to: 6.7
Stable tag: 1.1.70
Requires PHP: 7.4
License: MIT

Plateforme de traduction auto-hébergée — sans limite de mots, sans dépendance externe obligatoire.

== Description ==

**FourmizzTranslate** est l'alternative souveraine à Weglot.

* 🔒 **100 % auto-hébergé** — vous contrôlez vos données
* ♾️  **Sans limite de mots** — le cache SQLite est illimité
* ⚡ **Cache permanent** — les traductions déjà faites ne coûtent rien
* 🌐 **Multi-providers** : LibreTranslate (self-hosted), Anthropic Claude, DeepL
* 🎨 **Widget flottant** avec drapeaux et détection automatique de la langue
* 📎 **Shortcode** `[st_switcher]` pour placer le sélecteur n'importe où
* 🧩 **Widget sidebar** compatible avec tous les thèmes

= Prérequis =

Vous devez disposer d'une instance **FourmizzTranslate** (serveur Node.js) déployée et accessible.
Voir : https://github.com/fourmizz/weglotHomebrew

= Configuration =

1. Installez et activez le plugin.
2. Allez dans **Réglages → FourmizzTranslate**.
3. Renseignez l'URL de votre serveur et votre clé de projet (créée dans le dashboard FourmizzTranslate).
4. Choisissez la langue source et les langues cibles.
5. Enregistrez — le snippet est automatiquement injecté sur le frontend.

= Shortcodes =

`[st_switcher]` — boutons avec drapeaux (défaut)
`[st_switcher style="dropdown"]` — liste déroulante HTML native

== Installation ==

1. Téléchargez le dossier `fourmizz-translate` dans `/wp-content/plugins/`.
2. Activez le plugin dans **Extensions**.
3. Configurez via **Réglages → FourmizzTranslate**.

== Changelog ==

= 1.1.70 (plugin uniquement — mise à jour du plugin sur les sites, le serveur n'a pas changé) =
* **Intégration du plugin fourmizzSEO.** `FT_Sitemap::init()` avait deux branches : Yoast, ou le sitemap natif de WordPress. Un plugin SEO qui désactive le sitemap natif et sert le sien tombait donc dans la seconde, où plus rien n'est publié — le fournisseur restait enregistré mais n'était jamais servi, et **les URL traduites disparaissaient de tout sitemap, sans la moindre erreur**. Une troisième branche, choisie sur `defined('FZSEO_VERSION')`, passe désormais la main.
* Trois filtres sont branchés (`includes/fzseo.php`) : `fzseo_translations` rend une URL traduite par langue pour un article donné, `fzseo_context` traduit le titre et la méta description de la page, `fzseo_schema_graph` met le JSON-LD dans la langue cible — adresses, `inLanguage`, et les textes lisibles.
* Le JSON-LD était jusqu'ici le seul endroit du `<head>` hors de portée : `script` figure volontairement dans les balises ignorées de la passe DOM, parce que traduire du JavaScript le casserait. Le graphe étant de la donnée et non du code, il est traité ici.
* Deux pièges évités, et les deux valent d'être connus : une URL de fichier (`/wp-content/uploads/photo.jpg`, portée par la clé `url` d'un `ImageObject`) n'est **jamais** préfixée, sous peine de 404 ; et les entités du site — `#website`, `#organization`, `#logo` — ne sont ni traduites ni réadressées, car un site nommé « Ciram » s'appelle « Ciram » dans toutes les langues.
* `fzseo_translations` peut être appelé depuis une requête `/xx/` (cron, admin, sitemap). Comme `get_permalink()` traduit dès qu'une langue est active, la langue est neutralisée le temps de l'appel : sans cela, la réponse dépendrait d'où la question a été posée et produirait des `/en/en/`.
* Vérifié : 47 assertions, dont le choix de branche, le respect d'une entrée déjà posée, l'idempotence du graphe, et le fait que rien ne bouge hors d'une requête traduite.

= 1.1.69 (serveur uniquement — redéploiement serveur requis, le plugin n'a pas changé) =
* **Un lot de traduction mal aligné n'est plus enregistré.** Les fournisseurs qui traduisent plusieurs segments en un seul appel (Anthropic, Ollama) rendent leurs réponses séparées par un délimiteur. Quand le modèle n'en rendait pas le bon nombre — un segment fusionné, une note ajoutée en fin de réponse — le code complétait ou tronquait la liste **par la fin**, ce qui n'est juste que si la divergence est à la fin elle aussi. Sinon, chaque segment prenait la traduction de son voisin, et c'était écrit en base comme si de rien n'était.
* Le cas qui l'a révélé : une URL espagnole devenue `/tecnicas-de-caracterizacion/metalografia/tecnicas-de-caracterizacion/`, soit un chemin de trois segments dont les traductions avaient toutes glissé d'un cran — et le premier segment remplacé par le deuxième.
* Rien ne permet de savoir par quel bout la réponse a divergé, donc le lot est désormais **rejeté entier** et signalé comme échec passager. Le fournisseur de secours est sollicité, et si lui non plus ne répond pas, le texte source est rendu pour cette requête **sans être mis en cache** : la visite suivante réessaie. Une traduction fausse en cache, elle, n'aurait jamais été revue.
* Vérifié : 12 assertions sur le vrai code du fournisseur, dont le décalage exact observé en production, une note ajoutée par le modèle, et le fait qu'aucune ligne n'atteint la base dans ces cas-là.
* Ajoute `scripts/weglot-slug-redirects.js` : les migrations Weglot faites en deux passes (import du CSV puis crawl du sitemap) ont laissé d'anciennes URL traduites sans redirection, parce que les deux lots portent des chemins différents et qu'aucune ligne n'a donc été écrasée. Le script reconstruit ces anciennes adresses et écrit les redirections manquantes. En lecture seule par défaut.

= 1.1.68 (plugin + serveur — redéploiement serveur puis mise à jour du plugin partout) =
* **Préparation au passage du garde de domaine en mode bloquant.** Le contrôle de domaine tourne en mode « journal » depuis la 1.1.55 : il constate, il ne refuse pas. Tout l'intérêt de ce mode est de décider s'il est sûr de passer en mode bloquant — et deux choses rendaient cette décision impossible.
* **La ligne de journal ne permettait pas de conclure.** Elle nommait le projet et rien d'autre : impossible de distinguer son propre site auquel il manque un réglage, de quelqu'un d'autre qui utilise la clé. Elle indique maintenant le point d'entrée appelé, le `User-Agent` et la valeur d'`Origin`/`Referer` telle que reçue. `WordPress/6.7; https://exemple.fr` et `curl/8.4` ne se confondent plus.
* Ces valeurs viennent de l'appelant, donc elles sont tronquées et débarrassées des retours à la ligne : sans quoi un appelant pourrait écrire de fausses lignes dans le journal. L'adresse IP n'est **pas** journalisée — elle n'apporte rien à cette décision et c'est de la donnée personnelle qui finirait dans chaque sauvegarde.
* **Une des deux routes payantes n'était pas protégée du tout.** `/api/slugs/auto-translate` est publique, déclenche des appels payants, et le garde ne s'y appliquait pas — il ne couvrait que la traduction. Elle est désormais contrôlée comme l'autre.
* Et côté plugin, l'appel correspondant **n'envoyait jamais la clé secrète du projet**, contrairement à l'appel de traduction juste à côté. PHP n'a pas d'`Origin` à offrir — ce n'est pas un navigateur — donc sans ce secret, le serveur voit un appelant anonyme muni d'une clé publique, c'est-à-dire exactement ce qu'il est censé refuser. Les deux moitiés sont corrigées ensemble : envoyer le secret seul n'aurait rien changé, contrôler seul aurait refusé tous les sites.
* Le mode reste **« journal »** : rien n'est refusé à l'installation de cette version. Le jour du passage en mode bloquant, l'ordre compte — **plugins à jour partout d'abord, bascule ensuite** — car un site resté sur une version antérieure n'envoie pas le secret sur cette route et se ferait refuser.
* Vérifié : 26 assertions, dont l'impossibilité de forger une ligne de journal avec un retour chariot, le fait que les trois modes se comportent chacun comme annoncé sur les deux routes, et qu'un plugin non mis à jour est bien servi en mode journal et bien refusé en mode bloquant.

= 1.1.67 (serveur uniquement — redéploiement serveur requis et recommandé sans attendre) =
* **Audit de sécurité de l'ensemble du projet.** Quatre failles trouvées et corrigées, chacune reproduite avant d'être colmatée. Rien à faire côté sites.
* **XSS stocké dans le tableau de bord d'administration — la plus sérieuse.** Le champ `sourceLang` de `/api/translate` n'était validé nulle part : il était enregistré tel quel sur la ligne de traduction, puis réaffiché sans échappement dans l'administration. Or la clé de projet est publique par construction — elle figure dans le HTML de chaque page de chaque site. N'importe qui l'ayant lue pouvait donc faire exécuter du code dans le navigateur de l'administrateur, dont la session administre *tous* les projets du serveur. Deux verrous posés : les codes de langue doivent désormais avoir la forme d'un code de langue (sur les trois points d'entrée publics), et les deux tableaux de bord échappent systématiquement ces valeurs. Un bouton construisait aussi son gestionnaire de clic par concaténation — il est maintenant attaché en JavaScript, l'échappement HTML ne suffisant pas dans ce contexte imbriqué.
* **Blocage anti-force-brute contournable.** Le compteur d'échecs de connexion se basait sur la *première* entrée de l'en-tête `X-Forwarded-For`. Or nginx **ajoute** l'adresse réelle derrière ce que le client a envoyé : la première entrée était donc la valeur inventée par le client. En la changeant à chaque essai, on obtenait un compteur neuf à chaque fois — mesuré à 25 mots de passe erronés d'affilée sans jamais être bloqué, soit une attaque en ligne illimitée sur le secret d'administration. Le compteur se base maintenant sur l'adresse résolue par le serveur.
* **SSRF dans le robot de pré-chauffe.** Le module `safe-fetch` existe précisément pour empêcher qu'une URL fournie dans un formulaire serve à faire lire au serveur quelque chose sur son propre réseau. Le robot de pré-chauffe — le plus important de ses appelants, puisqu'il prend une URL de sitemap directement dans un champ — ne l'utilisait pas : il avait sa propre fonction de téléchargement, sans contrôle d'adresse, sans plafond de taille, sans limite de redirections. `http://169.254.169.254/` (métadonnées d'instance chez un hébergeur cloud), `http://127.0.0.1/`, n'importe quelle machine du réseau interne étaient atteignables. La copie locale est supprimée ; il n'y a plus qu'une seule fonction de téléchargement.
* Le contrôle anti-SSRF lui-même a été renforcé : il résolvait le nom de domaine, puis laissait le client HTTP le résoudre **une seconde fois**. Un nom dont l'enregistrement DNS répond une adresse publique au contrôle et `127.0.0.1` à la connexion contournait tout le module. La connexion est désormais épinglée sur l'adresse qui a été vérifiée. Les chaînes de redirection sont aussi plafonnées — sans quoi un site qui se redirige vers lui-même faisait tourner le serveur indéfiniment.
* **Réglage de confiance du proxy mal interprété.** Express lit un *nombre* comme un nombre d'intermédiaires et une *chaîne* comme une liste d'adresses. Une variable d'environnement est toujours une chaîne : écrire `TRUST_PROXY=1` — la chose évidente à faire — revenait à dire « faire confiance à l'adresse 1 », qui n'en est pas une. Plus rien n'était alors reconnu et toutes les requêtes semblaient venir de la même adresse, ce qui, avec le correctif précédent, aurait fait qu'une seule personne se trompant de mot de passe bloque tout le monde. Une valeur numérique est maintenant convertie ; un nom ou une liste de sous-réseaux passe tel quel.
* La clé d'API Google ressortait en clair de l'API de réglages, contrairement aux trois autres : la liste des valeurs masquées était écrite à la main et celle-ci y manquait.
* Trois avis de sécurité sur les dépendances (déni de service via `body-parser`/`qs`) corrigés.
* **Ce qui a été vérifié et s'est révélé correct**, pour mémoire : toutes les requêtes SQL sont paramétrées, y compris les tris et les recherches ; les noms de fichiers de sauvegarde sont validés par un motif strict, sans traversée possible ; le proxy AJAX du plugin WordPress vérifie capacité, jeton anti-CSRF, liste blanche de chemins et de paramètres, et ne laisse jamais sortir la clé secrète du projet ; **aucune traduction n'est jamais insérée comme du HTML** — ni dans le snippet, ni dans le rendu serveur PHP, qui n'écrit que des nœuds de texte ; les codes de secours à double facteur sont à usage unique.
* Vérifié : 45 assertions dédiées, écrites pour échouer sur le code d'avant — ce qui a été contrôlé en les exécutant contre le commit précédent.

= 1.1.66 (serveur uniquement — redéploiement serveur requis, le plugin n'a pas changé) =
* **Deuxième chantier de montée en charge : ce qui empêchait le serveur de tourner sur plusieurs processus.** Rien ne change pour un visiteur ni pour un site ; ce qui change, c'est que le serveur peut désormais être lancé en mode « cluster », avec un processus par cœur.
* Trois états vivaient en mémoire de processus, ce qui fonctionne exactement tant qu'il n'y a qu'un processus. Avec quatre, ce sont quatre mémoires différentes :
* — **les jobs** (pré-chauffe, migration Weglot, en trois exemplaires : un par routeur). Le navigateur lançait une pré-chauffe, recevait un identifiant d'un processus, interrogeait la progression et un autre répondait « job introuvable ». Le travail tournait pourtant très bien — il était simplement invisible depuis l'endroit où la question était posée.
* — **les sessions d'administration**, et c'est le plus visible : connecté sur un processus, déconnecté sur tous les autres. L'administration devenait inutilisable, pas seulement la pré-chauffe. Elles survivent maintenant aussi à un redémarrage du serveur, ce qui n'était pas le cas.
* — **le verrouillage après échecs de connexion**, et c'est le plus sérieux : un compteur par processus, c'est 5 tentatives × le nombre de processus avant blocage. Un contrôle de sécurité silencieusement divisé par le nombre de cœurs.
* Les trois sont passés en base, que tous les processus partagent. Le travail, lui, continue de s'exécuter là où il a été lancé : seul le compte rendu est partagé, et c'est tout ce dont le navigateur a besoin.
* **Rien n'est stocké en clair.** Des jetons de session en clair, c'est une sauvegarde nocturne qui donne accès à qui la lit ; des adresses IP en clair, c'est de la donnée personnelle archivée sans raison. Les uns sont hachés, les autres hachés avec un sel propre à l'installation.
* Un job interrompu par un redémarrage est marqué comme tel au bout de quinze minutes de silence, plutôt que de rester « en cours » indéfiniment. Le critère est l'inactivité, pas le démarrage — sinon, lors d'un redémarrage progressif, chaque nouveau processus tuerait les jobs que ses voisins sont en train d'exécuter.
* La livraison des événements de progression est un seul `DELETE … RETURNING` : deux interrogations simultanées sur deux processus ne peuvent pas lire les mêmes événements deux fois.
* Vérifié **en conditions réelles**, pas par raisonnement : une suite lance la vraie application sur trois processus derrière un même port, vérifie d'abord qu'elle parle bien à plusieurs d'entre eux, puis qu'un job créé par l'un est lisible par les autres, qu'une session émise par l'un est acceptée par les autres, et que cinq échecs répartis sur trois processus bloquent bien la sixième tentative. La même suite, exécutée contre la version précédente, échoue sur quatre points — dont les sessions, qui alternent `401, 200, 401, 200…`. Plus 45 assertions dédiées, dont une douzaine depuis un second processus réellement distinct.

= 1.1.65 (serveur uniquement — redéploiement serveur requis, le plugin n'a pas changé) =
* **Premier chantier de montée en charge.** Rien ne change pour un visiteur ni pour un site : c'est le coût qu'une page fait payer au serveur qui est divisé.
* Le diagnostic d'abord, parce qu'il était mal posé : la lenteur potentielle ne venait pas de requêtes lentes — elles sont toutes des accès indexés, le WAL est actif depuis toujours — mais du **nombre d'instructions SQL par requête**. better-sqlite3 est synchrone : pendant qu'elles s'exécutent, Node ne sert personne d'autre, et ce qui bloque, c'est leur somme.
* **Les compteurs de consultation sortent du chemin de la requête.** `hit_count` dit la popularité d'un segment ; personne ne dépend de son exactitude à la seconde. Il coûtait pourtant à lui seul un `UPDATE` — donc une écriture — par segment servi depuis le cache. Les incréments sont désormais cumulés en mémoire et écrits quelques secondes plus tard, en une seule transaction. Cent visiteurs sur un même segment coûtent **une** écriture au lieu de cent.
* **La recherche dans le cache est groupée** : une instruction pour toute la page au lieu d'une par segment, avec déduplication des textes répétés. La précédence entre une traduction propre au projet et le fonds partagé est rigoureusement celle d'avant — c'est ce que vérifie la moitié des nouveaux tests.
* **Les écritures d'une page tiennent dans une transaction.** Une page fraîchement découverte de 200 segments validait 200 fois pour s'enregistrer une fois.
* Les instructions du chemin chaud sont compilées une fois et réutilisées, au lieu d'être recompilées à chaque appel.
* Mesuré sur une page de 500 segments déjà traduite à 90 %, 20 requêtes : **998 → 0,3 instructions compilées** par requête, **1 009 → 39 instructions exécutées**, **32,9 ms → 4,4 ms** de temps bloqué par requête. Soit environ **sept fois moins de temps** pendant lequel le serveur ne peut servir personne d'autre.
* Tout ce qui **affiche** un compteur (tableaux de bord, listes, statistiques par langue) écrit d'abord ce qui est en attente : aucun chiffre affiché n'est en retard. Un arrêt du serveur écrit également ce qui reste.
* Vérifié : 30 assertions dédiées, dont l'égalité stricte entre l'ancienne recherche segment par segment et la nouvelle recherche groupée sur chaque cas de précédence, les limites de découpage des listes, la coalescence des incréments, la règle « première URL non vide retenue » au travers du tampon, et une ligne supprimée entre le cumul et l'écriture. Suite complète verte.

= 1.1.64 (plugin + serveur — redéploiement serveur puis mise à jour du plugin) =
* **Niveau de service par projet.** Un projet porte désormais un statut (actif, essai, impayé, suspendu), une offre, un quota mensuel et deux dates. Tous les défauts sont l'état actuel de tous les projets existants — statut « actif », aucun quota, aucune date : ajouter ces champs ne change le comportement d'aucun site tant que personne ne saisit de valeur.
* **Dégradation graduée, et c'est le cœur de la chose.** Trois niveaux, pas deux. `gelé` sert **l'intégralité du cache** et cesse seulement d'**acheter** de nouvelles traductions ; les textes nouveaux reviennent dans la langue d'origine. `suspendu` ne sert plus rien. Un impayé, un essai terminé ou un quota atteint **gèlent** — ils ne suspendent jamais. La suspension n'arrive qu'après un délai de grâce écrit d'avance, ou sur décision explicite.
* Pourquoi : un site traduit depuis trois mois a ses URL `/en/`, `/es/`, `/de/` dans l'index de Google. Couper d'un coup ne retire pas une fonctionnalité à un client, cela produit plusieurs centaines de 404 sur le site d'un tiers, du jour au lendemain, pour une facture en retard. Servir le cache ne coûte quasiment rien — un SELECT indexé — donc il n'y a aucune raison commerciale de l'arrêter le premier jour, et toutes les raisons de ne pas le faire.
* **Compteur de consommation.** Ce qui est compté, ce sont les **mots réellement envoyés à un moteur**, pas les lignes stockées : un texte servi depuis le cache, une substitution de glossaire, un texte sans lettre, un import Weglot et une correction à la main ne consomment rien et n'apparaissent pas. L'historique des mois passés est reconstitué au démarrage à partir des traductions qu'un moteur a réellement produites — une approximation qui ne peut que sous-estimer, jamais surfacturer.
* Le quota se remet à zéro le 1ᵉʳ du mois, ce qui est exactement ce qu'un dépassement devrait faire.
* **Côté site**, la page Statistiques affiche la consommation du mois et les douze derniers mois — mais **uniquement s'il y a quelque chose à dire** : un projet sans quota et sans anomalie retrouve le tableau de bord qu'il avait. Un client qui découvre une limite en la heurtant est un client mécontent ; s'il y a une limite, elle est à l'écran dès le premier jour.
* Nouvelle jauge dans les deux tableaux de bord. La sévérité n'est jamais portée par la couleur seule : chaque état écrit ce qu'il veut dire à côté du chiffre, ce qui est précisément ce qui rend légitimes deux teintes de statut volontairement peu contrastées.
* Une date malformée laisse le projet **en service**. Le mode de défaillance à éviter ici, c'est un site qui s'éteint à cause d'une faute de frappe.
* Vérifié : 68 assertions sur le résolveur et le compteur, 27 de bout en bout à travers le vrai traducteur (dont : un projet gelé sert toujours son cache, n'appelle personne, et n'écrit surtout **rien** — mettre le texte source en cache le figerait dans la page bien après le paiement), 27 à travers la vraie pile Express, et 84 dans un navigateur sur la jauge et l'écran d'abonnement, en clair et en sombre.

= 1.1.63 (plugin uniquement — mise à jour du plugin sur les sites, le serveur n'a pas changé) =
* Le propriétaire du site peut désormais ranger lui-même ses langues, depuis **Réglages → Widget de sélection de langue**, sans passer par l'administration du serveur. Une liste numérotée avec des flèches, à côté de la position du widget — l'ordre est une question de présentation, il est donc traité comme telle.
* Le partage des rôles est préservé et rendu sûr par construction : le serveur décide **quelles** langues existent, le site décide **dans quel ordre** on les montre. Le réglage ne peut que permuter la liste venue du serveur. Un code qu'il nomme mais que le serveur ne publie pas est ignoré ; une langue ajoutée côté serveur apparaît immédiatement, en queue, sans qu'il faille toucher au réglage. Il est donc impossible d'ajouter depuis WordPress une langue qui n'est configurée nulle part, ou d'en masquer une qui l'est.
* Nouvelle case **« Langue active en premier »**, cochée par défaut. Cochée, le visiteur voit d'un coup d'œil la langue qu'il lit, mais l'ordre est modifié pour elle. Décochée, l'ordre configuré est respecté à la lettre et la langue courante est simplement mise en évidence sans être déplacée. C'était jusqu'ici un comportement imposé, dans les deux sélecteurs.
* La langue d'origine du site reste en première position, devant les langues cibles.
* Choix d'implémentation : le plugin envoie au snippet la liste **déjà ordonnée**. Le snippet affiche `[langue source] + langues cibles` telles qu'il les reçoit, il n'a donc aucune logique de tri à connaître — une seule implémentation de la règle, en PHP, plutôt qu'une deuxième à faire diverger.
* Vérifié : 17 assertions PHP sur la règle d'ordonnancement, dont la garantie que l'ensemble des langues affichées est toujours exactement celui du serveur quel que soit le réglage, et 10 assertions dans un vrai navigateur sur le sélecteur réellement rendu, avec la case dans les deux positions.

= 1.1.62 (serveur uniquement — redéploiement serveur requis, le plugin n'a pas changé) =
* L'ordre d'affichage des langues dans le sélecteur du site se règle désormais explicitement : les langues choisies apparaissent dans une liste numérotée, avec des flèches pour les monter ou les descendre, et une croix pour les retirer. C'est cet ordre qui est enregistré et affiché aux visiteurs.
* **Régression introduite en 1.1.60, corrigée ici.** L'ordre stocké a toujours été l'ordre d'affichage — les deux sélecteurs, JavaScript et PHP, lisent le tableau tel quel. L'ancien champ de saisie libre conservait donc l'ordre de frappe. Le sélecteur à cases à cocher qui l'a remplacé lisait la sélection dans l'ordre du DOM, si bien que chaque enregistrement réordonnait les langues du projet selon la liste du catalogue. Un projet enregistré depuis la 1.1.60 a vu son ordre écrasé ; il suffit de le régler à nouveau, cette fois pour de bon.
* Une langue nouvellement cochée se place en dernier, ce qui est le seul choix qui ne dérange jamais ce qui est déjà réglé. Un code non répertorié se classe et se déplace comme les autres.
* À savoir, ce n'est pas un défaut : **la langue en cours de lecture est toujours ramenée en tête** du sélecteur, quel que soit l'ordre configuré. C'est un comportement d'origine, dans les deux sélecteurs. L'ordre réglé s'applique à toutes les autres.
* La langue source reste en première position, devant les langues cibles.
* Vérifié de bout en bout dans un vrai navigateur : 15 assertions sur l'interface de réglage, et 7 sur le sélecteur réellement rendu par le snippet — l'ordre configuré est bien celui que voit le visiteur, et rien n'est retrié en cours de route.

= 1.1.61 (serveur uniquement — redéploiement serveur requis, le plugin n'a pas changé) =
* Correction : sur une fenêtre de navigateur peu haute, les boutons « Save » et « Cancel » d'une fenêtre modale se retrouvaient sous le bord de l'écran, sans aucun moyen de les atteindre. Le formulaire de projet, allongé par le sélecteur de langues, rendait le problème systématique.
* La cause était plus ancienne que le sélecteur : les fenêtres modales de l'administration n'avaient **ni hauteur maximale ni défilement**. Toute modale plus haute que la fenêtre débordait déjà, en silence. Elles sont désormais plafonnées à la hauteur de l'écran et défilent à l'intérieur, avec le pied de page épinglé en bas — les boutons restent visibles pendant qu'on parcourt les champs. Toutes les modales en bénéficient, pas seulement celle des projets.
* La liste des langues s'adapte aussi aux petits écrans, où elle n'occupe plus qu'un tiers de la hauteur disponible.
* Un test de non-régression vérifie désormais, dans un vrai navigateur et à cinq hauteurs de fenêtre (de 900 à 480 pixels), que le bouton de validation est visible **et réellement cliquable**, que la modale ne déborde ni en haut ni en bas, et que chaque champ du formulaire reste atteignable sans être masqué par le pied de page.

= 1.1.60 (serveur uniquement — redéploiement serveur requis, le plugin n'a pas changé) =
* Les langues d'un projet se choisissent désormais dans une liste, drapeau et nom à l'appui, au lieu d'être saisies à la main. Le champ « Target Languages » était du texte libre : `PT`, `pt_BR` ou `xx` étaient acceptés sans un mot, et produisaient une langue à moitié cassée qu'aucun message ne signalait.
* Les 32 langues que la plateforme connaît complètement — nom, drapeau et place dans le sélecteur — sont maintenant affichées au moment où l'on en a besoin. La langue source se choisit dans la même liste.
* **Cette liste n'est pas une restriction.** Un code absent reste accepté : c'est le fournisseur qui décide de ce qui est traduisible, et un modèle de langue traduit à peu près tout. Un code inconnu est simplement conservé, marqué en rouge dans la liste et accompagné d'un avertissement expliquant ce qui sera perdu — le sélecteur affichera le code brut et un globe au lieu du nom et du drapeau. Un projet déjà configuré avec un tel code le garde, et il apparaît en tête de liste où il ne peut pas être manqué.
* Les codes sont normalisés à l'enregistrement : minuscules, `_` converti en `-`. `PT`, `pt` et `Pt` ne peuvent plus devenir trois caches distincts pour une même langue. Une variante régionale, elle, n'est **pas** ramenée à sa langue de base : `pt-br` et `pt` sont deux traductions différentes, les confondre en silence serait une surprise.
* Nouveau point d'entrée `GET /api/admin/languages`. La liste vit dans `services/languages.js` et un test vérifie qu'elle n'a pas divergé de la copie que le snippet embarque pour le navigateur — mêmes codes, mêmes noms, mêmes drapeaux — et que chaque langue annoncée a bien son fichier SVG.

= 1.1.59 (plugin + serveur, redéploiement serveur requis) =
* Les deux pages de statistiques sont refaites : celle du plugin, destinée au propriétaire du site, et le tableau de bord du serveur, destiné à l'exploitation.
* **Page « Statistiques » du plugin.** Le volume de contenu passe au premier plan : mots du site, mots traduits, mots servis aux visiteurs, mots relus à la main. Deux graphiques remplacent les tableaux : la **couverture par langue**, où chaque barre distingue la traduction automatique de ce qui a été relu, et l'**activité des 30 derniers jours**. Ni coût ni nom de fournisseur n'y figurent — ce ne sont pas les préoccupations du propriétaire d'un site.
* **Tableau de bord du serveur.** Mêmes graphiques, plus la répartition par fournisseur et les créations par jour, tous projets confondus.
* Sur le rythme quotidien, une limite à connaître : c'est la **création de traductions** qui est tracée, pas la fréquentation. Le compteur de lectures est un cumul sans horodatage, il n'existe donc aucun moyen d'en tirer une courbe. La courbe baisse naturellement quand un site est entièrement couvert, et remonte à chaque publication.
* Les graphiques sont écrits à la main, sans dépendance ni bibliothèque distante : une page d'administration WordPress n'a pas à récupérer du code sur un serveur tiers. Les barres sont en HTML plutôt qu'en SVG, ce qui rend impossible qu'une étiquette soit rognée par sa propre barre.
* Lisibilité vérifiée plutôt que supposée : la palette à deux teintes est validée par calcul (écart perceptuel OKLab, simulation des trois daltonismes, contraste) sur les fonds clair et sombre. Chaque graphique porte une légende, un survol détaillé et un tableau de valeurs dépliable — aucune information n'est accessible uniquement par la couleur. Une valeur non nulle ne peut jamais s'afficher comme une barre vide.
* Le code des graphiques est partagé entre les deux interfaces, et un test vérifie que les deux copies restent identiques à l'octet près — la leçon des trois copies divergentes du générateur de slugs.

= 1.1.58 (serveur uniquement — redéploiement serveur requis, le plugin n'a pas changé) =
* **Correctif majeur pour les langues qui ne s'écrivent pas en alphabet latin.** Le générateur de slugs ne conservait que les caractères `[a-z0-9-]`, ce qui effaçait purement et simplement l'arabe, l'hébreu, le grec, le cyrillique, le chinois. Sur un site de production, 107 des 132 slugs arabes sont sortis vides et ont été rejetés ; les 25 rescapés n'étaient que les débris — ce qui restait d'ASCII après le passage du filtre.
* Conséquence visible : `الأخبار` donnait une chaîne vide, `مقالات TASO` se réduisait à `taso`, et cinq pages de pagination devenaient `/1/` à `/5/`. Trois pages différentes se retrouvaient ainsi à partager le slug arabe `taso`. Or la carte inverse ne retient qu'une seule page source par slug traduit, la première insérée : `/ar/taso/` servait donc la liste d'articles au lieu de la page de présentation de la société.
* Nouvelle règle : un slug n'est dérivé que d'un texte **réellement en alphabet latin**. Sinon le segment source est conservé — `/ar/taso/` reste `/ar/taso/`. Une URL qu'un lecteur ne peut pas déchiffrer est un moindre mal ; une URL qui mène à la mauvaise page n'en est pas un. Rien ne change pour l'anglais, l'allemand, l'espagnol ou l'italien.
* Second garde-fou, indépendant du premier : **un slug traduit déjà pris est refusé**. Il ne peut être ni le slug traduit d'une autre page dans la même langue, ni le chemin source d'une autre page — ce qui rendrait l'URL préfixée ambiguë. Ce contrôle aurait suffi à éviter la panne même avec l'ancien générateur, et il est placé à l'écriture en base pour couvrir tous les appelants d'un coup.
* La profondeur du chemin est désormais préservée. `/nos-produits/alfa-moustique/` devenait `/alfa-moustique/` quand le segment traduit sortait vide, raccourcissant l'URL et risquant une collision avec une page de premier niveau.
* Le générateur existait en **trois copies** qui avaient déjà divergé — celle du crawler enregistrait même son résultat sans vérifier qu'il n'était pas vide. Il n'y en a plus qu'une, dans `services/slugify.js`.
* Une migration unique supprime au démarrage les lignes que l'ancien code ne pouvait pas avoir produites correctement : chemin vide, segments perdus, segment réduit à des chiffres alors que la source avait des lettres, slug partagé ou masquant une autre page. Ces lignes sont irréparables — le texte d'origine n'existe plus — et seront régénérées correctement à la visite suivante.

= 1.1.57 (plugin + serveur, redéploiement serveur requis) =
* Le glossaire agit enfin sur une balise ne contenant qu'un numéro de téléphone, un prix ou un horaire. C'est ce qui permet d'ajouter l'indicatif international dans les versions étrangères d'un site : une entrée `02 43 71 37 51` → `+33 2 43 71 37 51` s'applique désormais, y compris quand le numéro est seul dans son élément.
* Pourquoi cela ne fonctionnait pas : **le numéro ne quittait jamais la page**. Le snippet comme le rendu côté serveur écartent les textes sans aucune lettre avant de les envoyer — il n'y a rien à traduire dans un numéro, et l'envoyer serait du coût d'API pur. Le glossaire, lui, ne s'applique qu'en chemin vers le fournisseur. Une entrée visant un tel texte n'était donc jamais atteinte, et aucune correction du seul glossaire n'aurait pu y changer quoi que ce soit.
* Le serveur publie maintenant la liste des termes de glossaire sans lettre (dans `GET /api/config` et dans la configuration du snippet). Les deux collecteurs s'en servent comme d'une liste d'exceptions : ces chaînes-là sont envoyées, et elles seules. Un numéro sans entrée de glossaire continue d'être ignoré, comme avant.
* Côté serveur, la substitution est appliquée **sans aucun appel à un fournisseur** : l'entrée est déjà la réponse, une personne a écrit les deux côtés. Rien ne justifierait de faire passer des chiffres par un modèle de langue, qui peut les convertir ou les réordonner. La ligne est enregistrée comme n'importe quelle traduction, attribuée au fournisseur « glossaire », visible et modifiable dans l'éditeur visuel — et une retouche manuelle continue de primer sur le glossaire.
* Modifier l'entrée met la ligne enregistrée à jour à la visite suivante, par le mécanisme d'invalidation existant. Une entrée sans traduction forcée (terme simplement protégé) ne déclenche aucun enregistrement.
* Vérifié de bout en bout dans un vrai navigateur (Chromium, serveur et snippet réels) et sur le collecteur PHP : le numéro est envoyé et substitué dans le texte comme dans un attribut `title`, tandis qu'un numéro sans entrée de glossaire n'est toujours pas envoyé.
* À savoir pour les langues qui s'écrivent de droite à gauche : l'algorithme bidirectionnel d'Unicode inverse l'ordre des **groupes** d'un numéro séparés par des espaces ou des tirets — `02 43 71 37 51` s'affiche `51 37 71 43 02`, et l'indicatif `+33` saute en fin de ligne. Les chiffres de chaque groupe, eux, restent dans le bon ordre. Cela n'a rien à voir avec la traduction : c'est le navigateur, sur du texte intact. Le remède est dans la page — `dir="ltr"` sur l'élément qui porte le numéro — et il est efficace dans tous les cas testés.

= 1.1.56 (serveur uniquement — redéploiement serveur requis, le plugin n'a pas changé) =
* Deux défauts de l'import Weglot, trouvés en passant un export réel de 2600 lignes (fr→en) dans la chaîne complète plutôt qu'en relisant le code.
* **Les traductions validées par une personne étaient importées comme de la traduction automatique.** L'export du tableau de bord Weglot marque ces lignes « Manual » ; le code ne reconnaissait que « Human ». Conséquence : elles n'étaient pas protégées de l'invalidation par le glossaire, n'apparaissaient pas comme retouchées à la main dans l'administration, et un import ultérieur pouvait les écraser. Les deux orthographes sont désormais acceptées.
* **Des blocs dont le traducteur avait réordonné les balises importaient une traduction fausse.** Les segments des deux côtés étaient appariés par position, ce qui n'a de sens que si les balises se suivent dans le même ordre — or un traducteur les déplace librement. Weglot transformait « <strong>Action anti-calcaire physique et durable</strong><br />… » en « <br />: <strong>Physical and Long-Lasting Anti-Scale Action</strong>… » : même nombre d'emplacements des deux côtés, donc le titre était marié au « : » laissé par le <br> déplacé, et la vraie traduction, décalée d'un cran, était jetée. Trois lignes sur les 2600 de l'export testé. Quand les séquences de balises diffèrent, l'import bascule sur le bloc entier — plus grossier, mais toujours juste.
* Garde-fou complémentaire : une phrase ne se traduit jamais en simple ponctuation. Si cela se produit, le découpage est jugé non fiable dans son ensemble et le bloc entier prend le relais.
* Vérifié sur l'export réel : 2600 lignes lues, 2379 importées, 2897 lignes écrites, aucun marqueur `wg-N` ni balise ni entité HTML résiduels, aucune ligne où la source égale la traduction, et 400 relectures sur 400 retrouvées par le chemin du cache.

= 1.1.55 (plugin + serveur, redéploiement serveur requis) =
* Le champ « domaines autorisés » d'un projet est enfin appliqué. Il existait depuis la première version, était modifiable dans l'administration, et rien ne l'avait jamais lu : n'importe qui relevant une clé de projet dans le HTML d'un site — elle y figure sur chaque page, c'est sa nature — pouvait faire traduire ce qu'il voulait aux frais du propriétaire.
* Deux types d'appelants atteignent l'API de traduction, et ils se prouvent différemment. Un **navigateur** ne choisit pas son en-tête `Origin` : il est écrit par le navigateur d'après l'adresse réelle de la page, et aucun script de la page ne peut le modifier. Le **plugin WordPress**, lui, traduit côté serveur : c'est du PHP appelant une API HTTP, il n'a aucun `Origin` à présenter. Il s'identifie désormais avec la clé secrète du projet, qui n'est pas publique. Un contrôle naïf sur le seul en-tête aurait coupé la traduction sur tous les sites à la fois.
* **Ce que ce contrôle n'arrête pas, et qu'il ne faut pas lui prêter** : un script. `curl` envoie l'en-tête `Origin` qu'il veut, et aucune vérification d'en-tête ne peut distinguer un script d'un navigateur. Cette version ferme la faille bon marché — quelqu'un qui place votre clé dans sa propre page — pas la faille déterminée. C'est la limitation de débit qui répond à celle-là, et elle reste à faire.
* Le contrôle démarre en mode **journalisation seule** (Réglages → Domaines autorisés) : rien n'est bloqué, les refus qui auraient eu lieu sont écrits dans le journal. Mettre à jour le serveur ne peut donc couper aucun site. Passez en mode « Actif » une fois le journal silencieux, c'est-à-dire quand tous les sites ont reçu ce plugin et ont leur clé secrète renseignée. Un appelant bloqué étant généralement une boucle, chaque cas n'est journalisé qu'une fois par heure.
* Le champ devient obligatoire à l'enregistrement d'un projet, et son contenu est normalisé : `https://Example.com/fr/` est retenu comme `example.com`. `www.` est traité comme le même site dans les deux sens, et `*.example.com` couvre les sous-domaines ainsi que le domaine nu. Un projet existant dont le champ serait vide continue de fonctionner — il est signalé dans le journal plutôt que coupé, car une traduction ne doit pas tomber pour un réglage manquant.
* Réglages du plugin : un avertissement apparaît désormais quand la clé secrète du projet n'est pas renseignée, puisqu'elle devient nécessaire à la traduction côté serveur.

= 1.1.54 (serveur uniquement — redéploiement serveur requis, le plugin n'a pas changé) =
* Les prix, pourcentages, horaires et guillemets retrouvent leur traduction. Un texte sans aucune lettre n'a rien à traduire, et le serveur le renvoyait tel quel sans même consulter le cache — un contrôle placé AVANT la recherche. Or un nombre n'a rien à traduire mais beaucoup à localiser : l'anglais écrit « 35 000 € » sous la forme « €35,000 », « 6,5 % » sous la forme « 6.5% », remplace les guillemets français et supprime l'espace avant le point d'interrogation. Ces traductions existaient, étaient déjà payées et déjà stockées — 290 lignes sur la base de production, toutes porteuses d'une vraie conversion typographique — mais étaient devenues inatteignables.
* Le contrôle s'exécute désormais APRÈS la consultation du cache. L'intention d'origine est intégralement préservée : aucun fournisseur ne sera jamais appelé pour recopier un numéro de téléphone. Mais ce que la base sait déjà est servi. Les lignes concernées redeviennent actives immédiatement, sans un seul appel d'API.
* À noter pour la suite : un montant ajouté au site depuis ne sera toujours pas localisé, puisqu'il n'est jamais envoyé à un fournisseur. Rendre cela possible quand la langue cible a une typographie différente reste une décision à prendre — elle aurait un coût en appels.

= 1.1.53 (serveur uniquement — redéploiement serveur requis, le plugin n'a pas changé) =
* Un texte qui expire ne fait plus échouer la page entière. Les délais d'attente étaient la première cause d'échec devant tout le reste (71 sur 180 en production) et la version précédente ne les couvrait pas : elle isolait les refus, pas les pannes. Un segment lent ne coûte désormais que lui-même.
* Différence de traitement essentielle : un refus est définitif, donc enregistré pour ne plus jamais redemander ; un délai d'attente est passager, donc le texte est rendu tel quel pour cette visite et surtout **pas** mis en cache — le figer reviendrait à inscrire du texte non traduit dans la page pour toujours. La visite suivante le retentera.
* En revanche, une vague d'appels qui échoue en totalité est traitée comme une panne du fournisseur et non comme un problème de textes : le lot est abandonné pour que le repli prenne le relais, au lieu de griller un appel voué à l'échec par segment.
* Les réparations uniques (rattrapage du compte de mots, décodage des entités) sont désormais enregistrées une fois faites. Elles parcouraient toute la table à chaque redémarrage et annonçaient un travail fictif — « source_words backfilled for 39 rows », les mêmes 39 lignes indéfiniment, parce qu'un texte fait uniquement de ponctuation compte légitimement zéro mot.
* Le garde-fou de langue source ne réécrit plus la même ligne de journal à chaque visite. Un texte écarté n'étant jamais mis en cache, les mêmes chaînes de bannière cookies étaient réexaminées — et journalisées — à chaque page vue ; elles ne le sont plus qu'une fois par heure.
* Suppression d'un reste de code mort : le choix du fournisseur lisait une propriété par projet qui n'a jamais existé dans le schéma et qu'aucun écran ne renseignait. Aucun changement de comportement, mais le code ne promet plus une possibilité qui n'existe pas.

= 1.1.52 (plugin + serveur, redéploiement serveur requis) =
* Un texte refusé par le fournisseur ne fait plus échouer toute la page. Gemini décline certains textes (PROHIBITED_CONTENT) ; jusqu'ici ce refus faisait échouer la vague entière, donc le lot entier, qui basculait sur LibreTranslate, qui échouait à son tour — et comme rien n'était mis en cache, la visite suivante rejouait exactement la même séquence, indéfiniment. Chaque affichage de page rebrûlait ainsi des appels pour des textes qui, eux, passaient très bien. Seul le texte refusé est désormais isolé : les autres sont traduits et mis en cache.
* Un texte refusé est proposé au fournisseur de repli pour lui seul, et non plus en entraînant toute la page dans une seconde traduction complète.
* Quand personne ne veut le traduire, le texte est enregistré tel quel avec la mention « refusée », ce qui arrête la boucle. Il est repérable dans la liste par le filtre de fournisseur ; le supprimer revient à demander un nouvel essai.
* Le texte refusé est maintenant journalisé (tronqué) avec sa langue cible. Il ne l'était pas, ce qui rendait le diagnostic impossible. À noter : PROHIBITED_CONTENT provient du filtre non paramétrable de Google — les réglages de sécurité de l'API ne l'atteignent pas.
* Un refus n'est plus réessayé : c'est un verdict sur le texte, pas un incident passager, et les deux tentatives supplémentaires ne faisaient que tripler le coût.
* Quand LibreTranslate refuse une paire de langues qu'il annonce pourtant dans /languages (« ar is not supported » : la langue est listée, son modèle n'est pas chargé), l'échec est mémorisé une heure au lieu d'être retenté à chaque requête.
* Les corps de requête malformés — en pratique des scans automatisés — répondent désormais 400 au lieu de 500, et n'écrivent plus qu'une ligne dans les logs au lieu d'une trace d'exécution complète. Rien du contenu de la requête n'est renvoyé ni journalisé.

= 1.1.51 (serveur uniquement — redéploiement serveur requis, le plugin n'a pas changé) =
* Le cache est désormais indexé sur une empreinte du texte source plutôt que sur le texte lui-même. Jusqu'ici la comparaison se faisait octet par octet : une espace insécable là où le CMS en avait mis une normale, une apostrophe courbe au lieu d'une droite, un accent stocké sous forme décomposée — et c'était une deuxième ligne, avec sa propre traduction, pour toujours.
* L'empreinte ne rapproche que ce qui ne peut pas changer une traduction : composition Unicode, caractères de largeur nulle, nature et quantité des espaces, forme des apostrophes et des guillemets. La casse, les accents, la ponctuation, les chiffres et les balises de mise en forme sont conservés tels quels — deux textes qui se traduiraient différemment restent deux lignes. Les entités HTML ne sont volontairement pas décodées ici : « Tom &amp;amp; Jerry » et « Tom &amp; Jerry » sont deux textes différents sur une page qui parle de balisage, et une clé n'a aucun moyen de savoir lequel elle tient (ces lignes-là sont réparées au démarrage depuis la 1.1.46, où la provenance est connue).
* Le texte source lui-même n'est jamais réécrit : la ligne conserve exactement ce que la page a envoyé. Seule la clé est normalisée.
* Une contrainte d'unicité réelle protège maintenant la table, ce qui permet d'écrire une traduction en une seule instruction. Deux requêtes simultanées sur un même texte nouveau ne peuvent plus créer chacune leur ligne — la lecture puis écriture qui le permettait a disparu.
* Migration au premier démarrage : l'empreinte est calculée pour toutes les lignes existantes, les doublons que la nouvelle clé révèle sont fusionnés, puis l'index unique est créé. Le survivant d'un groupe est choisi comme le ferait un humain — une correction manuelle d'abord, puis la ligne réellement consultée, puis la plus récente — et les compteurs de consultation des autres lui sont additionnés pour que les statistiques restent justes. Les démarrages suivants ne refont pas ce travail.
* L'ancien index, qui contenait une copie intégrale de chaque texte source, paragraphes entiers compris, est supprimé. Sur un jeu de test de 150 000 paragraphes, l'espace réellement occupé passe de 82 à 65 Mo. L'espace libéré reste dans le fichier tant qu'un VACUUM n'est pas lancé — à faire à la main, hors heures de trafic, si vous voulez le récupérer.

= 1.1.50 (plugin + serveur, redéploiement serveur requis) =
* Une notation scientifique faisait perdre au paragraphe entier sa traduction d'un seul tenant. Le contenu d'une balise en ligne devait comporter au moins une lettre ; le « 13 » de δ&lt;sup&gt;13&lt;/sup&gt;C n'en a pas, et ce seul détail disqualifiait tout le paragraphe, qui repartait alors découpé en morceaux. Sur un paragraphe réel de CIRAM, cela donnait six fragments traduits séparément, dont « …l'analyse du rapport isotopique δ » et « C fournit des informations… » — deux moitiés de phrase envoyées au traducteur sans la moindre idée l'une de l'autre. La règle est levée : une notation voyage désormais dans son marqueur comme n'importe quel autre contenu, et la phrase part entière. Les mises en gras du même paragraphe en profitent au passage.
* L'espacement de ces notations est restitué tel qu'il était dans la source. Rien n'empêche un fournisseur d'ajouter des espaces autour d'un marqueur qu'on lui demande simplement de préserver, et « δ 13 C » n'est pas la même notation que « δ13C » : le collage est relevé à la lecture du paragraphe et rétabli à la reconstruction. Uniquement pour les notations — autour d'une phrase, l'espacement relève de la langue d'arrivée, et il n'est pas touché.
* L'instruction envoyée aux fournisseurs mentionne désormais ce cas explicitement, pour éviter le problème plutôt que le corriger après coup.
* Conséquence pour le glossaire : aucune entrée n'est nécessaire pour protéger ce type de notation. Une fois le nombre à l'abri dans son marqueur, les lettres isolées qui l'entourent ne sont modifiées par aucun traducteur.

= 1.1.49 (plugin + serveur, redéploiement serveur requis) =
* L'éditeur visuel proposait jusqu'à dix « correspondances » pour un seul paragraphe, dont des paragraphes entièrement différents. La cause : faute de pouvoir identifier un bloc, la recherche se rabattait sur une recherche par sous-chaîne, appliquée à chaque morceau de texte du bloc cliqué — et un fragment de liaison comme « carry out the » est contenu dans quantité de traductions sans rapport.
* La recherche procède maintenant en trois temps, du plus certain au moins certain : correspondance exacte ; puis correspondance après retrait des marqueurs de mise en forme — c'est ce qui permet enfin de reconnaître un paragraphe traduit d'un seul tenant, dont le texte stocké ne peut jamais être identique à celui affiché ; et seulement si rien n'a été identifié, la recherche approximative. Une fois le paragraphe reconnu, il n'y a plus rien à deviner.
* En conséquence, le panneau met en avant la ligne que le serveur a reconnue comme étant le texte cliqué, et non plus n'importe quelle ligne de bloc ayant quelques mots en commun. Tout le reste est replié derrière « Voir les autres correspondances ».

= 1.1.48 (plugin + serveur, redéploiement serveur requis) =
* Correction de la page enregistrée avec chaque traduction. Le snippet retirait bien le préfixe de langue (/en/…) mais pas la traduction du slug : une traduction collectée depuis la version anglaise était rattachée à /news/les-limites… au lieu de /actualites/les-limites…. Une même page apparaissait donc en double dans le filtre « toutes les pages », et l'éditeur visuel ne retrouvait pas ce qui avait été collecté depuis la version traduite. Le chemin est maintenant ramené à la page source par la table de correspondance des slugs que le snippet charge déjà.
* Un widget Google Translate installé sur le site n'est plus ramassé comme du contenu. Il injecte après le chargement son sélecteur de ~130 langues (« Xhosa », « Yiddish », « Yoruba »…) plus quelques libellés d'interface, que la surveillance du contenu dynamique envoyait à l'API comme du texte source neuf. Toute la famille de classes et d'identifiants goog-te-/goog-gt- est désormais ignorée, quel que soit le conteneur dans lequel le thème l'enveloppe.
* Distinction faite, au passage, entre « un widget de traduction est présent sur la page » et « la page a été traduite par une machine ». Seul le second cas met le snippet en retrait. Un widget est un élément permanent du site : le traiter comme une preuve aurait désactivé la traduction du contenu dynamique sur toutes les pages, définitivement.
* Les balises &lt;title&gt; et &lt;desc&gt; à l'intérieur des SVG ne sont plus collectées. Ce sont des métadonnées d'accessibilité, en pratique surtout les résidus laissés par les outils de dessin (« Created with Sketch. », le nom interne d'une icône), et les jeux d'icônes les injectent après le chargement. Contrepartie assumée : les rares infobulles portées par un &lt;title&gt; SVG ne sont plus traduites. Le texte visible des SVG (&lt;text&gt;) l'est toujours.
* Nouveau filtre « 🔁 Identiques à la source » dans la liste des traductions, et un repère du même nom sur les lignes concernées. Quand la traduction est exactement le texte source, c'est souvent légitime (nom de marque, terme identique dans les deux langues) — sinon c'est du texte qui n'était pas dans la langue source au départ, et le fournisseur n'avait rien à changer. C'est la signature commune de ce type de pollution, quelle qu'en soit l'origine.

= 1.1.47 (plugin uniquement — aucun redéploiement serveur nécessaire) =
* Suppression d'une traduction depuis l'éditeur visuel. Chaque carte porte maintenant un bouton Supprimer, pour faire le ménage sans quitter la page : les anciennes lignes de fragments, les blocs devenus inutiles. Le message de confirmation dit ce qui va réellement se passer — une ligne encore utilisée sera retraduite à la prochaine visite, une ligne déjà remplacée ne change rien pour les visiteurs — et la page n'est rechargée que dans le premier cas.
* Correction d'un message trompeur. Un paragraphe traduit d'un seul tenant portait la mention « Correspondance approximative — ce texte fait partie d'un segment plus large » alors qu'il EST le segment large. La cause : sa traduction contient les marqueurs de mise en forme, donc elle ne peut jamais être identique au texte que la page affiche, et l'absence de correspondance exacte était interprétée comme une correspondance partielle. La carte indique désormais « Paragraphe traduit d'un seul tenant, mise en forme comprise ».

= 1.1.46 (plugin + serveur, redéploiement serveur requis) =
* Les traductions stockées en HTML au lieu de texte sont réparées au démarrage du serveur. Les imports antérieurs à la 1.1.43 gardaient « art&amp;eacute;facts » tel quel ; la page n'envoyant jamais que « artéfacts », ces lignes ne pouvaient plus jamais servir — tout en continuant d'apparaître dans la liste, de surgir dans l'éditeur visuel et de gonfler le nombre de mots du site. Elles sont maintenant décodées sur place, ce qui les remet en service au lieu de les jeter. Quand la version décodée existe déjà, les deux lignes sont fusionnées, et c'est toujours la correction humaine qui est conservée ; les rares cas où deux corrections manuelles divergent sont laissés tels quels et signalés dans les logs.
* Ce décodage ne touche que ce dont l'encodage est certain : une ligne issue d'un import CSV (où tout est balisage par nature) ou une ligne contenant une entité qu'un texte de page ne peut pas produire, comme &amp;eacute; ou &amp;nbsp;. Une page qui affiche légitimement « &amp;amp; » ou « a &amp;lt; b » n'est pas modifiée.
* Nouveau filtre « 🧩 Remplacées par un bloc » dans la liste des traductions. Depuis que les paragraphes sont traduits d'un seul tenant, les anciennes lignes correspondant à leurs fragments (un lien, un passage en gras) ne servent plus jamais à afficher le site. Le filtre les retrouve — par correspondance exacte avec le contenu des balises d'un bloc, donc sans faux positif — et la suppression groupée existante permet de faire le ménage. Supprimer ne change rien à ce que voient les visiteurs.
* Éditeur visuel : quand un paragraphe est traduit d'un seul tenant, c'est sa ligne qui est présentée en premier. Les anciennes lignes de fragments, qui correspondaient au texte cliqué tout aussi exactement et se présentaient au même niveau, sont repliées derrière « Voir les autres correspondances » et signalées comme remplacées. C'est ce qui rendait le panneau déroutant sur les paragraphes riches en liens.

= 1.1.45 (plugin uniquement — aucun redéploiement serveur nécessaire) =
* Les balises de mise en forme s'affichent en pastilles. Quand un paragraphe contient un lien ou du gras, il est traduit d'un seul tenant et sa mise en forme voyage sous forme de marqueurs 【1】…【/1】, jusqu'ici affichés tels quels dans la liste. Le contenu de chaque balise apparaît désormais dans une pastille colorée, numérotée discrètement, avec la même couleur de part et d'autre : on voit d'un coup d'œil qu'un lien a simplement changé de place dans la phrase traduite — ce qui est normal — et non qu'il a disparu. Survoler une pastille met en évidence sa jumelle. Le numéro est dessiné par la feuille de style et non inséré dans le texte : copier une cellule rend une phrase propre.
* Vérification des balises à la correction. Une balise oubliée, dupliquée, renumérotée ou laissée vide empêche le serveur de reconstruire le paragraphe : celui-ci reste alors affiché en langue d'origine sur le site, sans le moindre message. La zone d'édition signale maintenant le problème pendant la frappe et demande confirmation avant d'enregistrer, en expliquant ce qui sera perdu. Les règles vérifiées sont exactement celles qu'applique le serveur au moment du rendu.
* Les traductions déjà en base dont les balises ne correspondent plus sont signalées dans la liste par un repère « ⚠ balises » — ce sont celles qui coûtent silencieusement un paragraphe sur le site.
* Les marqueurs incomplets ou inconnus ne sont plus masqués : ils s'affichent en rouge, à l'endroit exact où ils se trouvent dans le texte.

= 1.1.44 (serveur uniquement — redéploiement serveur requis, le plugin n'a pas changé) =
* Correction du comptage des mots : les sites ajoutés après les premiers restaient à 0. Le rattrapage des lignes antérieures à la nouvelle colonne s'arrêtait au bout de 100 000 lignes, et comme elles sont parcourues par ordre d'ajout, tout ce qui se trouvait au-delà — c'est-à-dire les projets migrés en dernier — n'était jamais compté. Le rattrapage parcourt désormais la totalité de la table par lots, quel qu'en soit le volume.

= 1.1.43 (plugin + serveur, redéploiement serveur requis) =
* Statistiques en nombre de mots. Les offres de traduction se facturent au mot : la page Statistiques affiche désormais en premier les « mots du site » — les textes sources distincts, comptés une seule fois quel que soit le nombre de langues, c'est-à-dire exactement le nombre à comparer avec le quota d'une offre. S'y ajoutent les mots traduits (toutes langues cumulées), les mots servis aux visiteurs (que personne ne refacture), les mots relus à la main, et une colonne « Mots » dans le tableau par langue. Les textes déjà en base sont comptés automatiquement au premier démarrage.
* Import Weglot révisé pour la gestion des traductions actuelle. Les traductions marquées « Human » dans Weglot arrivent maintenant comme modifiées manuellement : elles apparaissent comme telles dans la liste, sont protégées de l'invalidation par le glossaire et font autorité dans l'éditeur visuel.
* Import Weglot : les entités HTML des fichiers CSV (&amp;#039;, &amp;eacute;, &amp;nbsp;…) sont décodées avant enregistrement. Sans cela, une ligne contenant « L&amp;#039;équipe » ne pouvait jamais correspondre au « L'équipe » que la page envoie, et la traduction importée restait lettre morte. Les espaces insécables de la typographie française sont préservés pour la même raison.
* Import Weglot : une traduction déjà corrigée à la main dans FourmizzTranslate n'est plus écrasée par un ré-import, et le rapport indique combien ont été conservées.
* Import Weglot : les variantes régionales (fr-FR, en-US, pt-BR…) sont ramenées aux langues réellement configurées sur le projet — sinon les lignes importées n'étaient jamais retrouvées. Les couples de langues que le projet ne publie pas sont signalés dans le rapport au lieu d'être importés dans le vide.
* Import Weglot : correction de l'alignement des segments d'un bloc HTML. Les deux côtés étaient filtrés séparément avant d'être appariés, ce qui pouvait décaler les paires et importer des traductions attribuées au mauvais texte. Les blocs dont la structure diffère retombent proprement sur une ligne unique, et les mots ne sont plus collés entre deux balises.
* La page de migration accepte une colonne de page optionnelle (url, page_url, page ou link) : les traductions importées sont alors immédiatement filtrables par page et visibles dans l'éditeur visuel.

= 1.1.42 (plugin + serveur, redéploiement serveur requis) =
* Traduction par le navigateur : le snippet ne ramasse plus l'interface que Chrome injecte dans la page. Il savait déjà se mettre en retrait quand le navigateur traduit, mais la bulle de feedback de Chrome (« Valora esta traducción », « Texto original », « Rate this translation »…) survit au clic sur « Afficher l'original » : la classe posée sur la balise html disparaît, la bulle reste dans le DOM, et ses textes — écrits dans la langue du VISITEUR — ressemblaient alors à du contenu neuf du site. Les conteneurs du traducteur sont désormais reconnus et ignorés, et le retrait est verrouillé pour toute la durée de la visite une fois le navigateur pris sur le fait.
* Les éléments marqués .notranslate (convention de Google pour « ne jamais traduire ceci ») sont également ignorés par le snippet, sans pour autant faire croire que la page est traduite par le navigateur.
* Nouvelle option « Traduction du navigateur » (activée par défaut) : le site demande au navigateur de ne pas proposer sa propre traduction automatique, via la balise meta google/notranslate. Le site publie déjà ses traductions — la proposition de Chrome fait doublon, retraduit une page déjà traduite et injecte son interface dans le contenu. À décocher si votre audience inclut des langues que le site ne propose pas : ces visiteurs perdraient aussi la traduction du navigateur.

= 1.1.41 (plugin + serveur, redéploiement serveur requis) =
* Garde-fou de langue source : les textes qui ne sont pas écrits dans la langue d'origine du site ne sont plus traduits ni enregistrés. Une page contient souvent des textes étrangers qui ne lui appartiennent pas (interface d'un widget tiers comme la barre Google Traduction, bandeau de consentement livré en anglais, citation) : envoyés comme source « fr », ils créaient des lignes dont la langue source était fausse, polluaient la liste des traductions et consommaient du quota. Ils sont désormais renvoyés tels quels, sans appel au moteur de traduction ni ligne en base.
* La détection est volontairement prudente — un texte légitime laissé non traduit serait bien plus gênant qu'un texte étranger manqué : elle ne se déclenche que sur des indices sans ambiguïté (écriture non latine, lettre inexistante dans l'orthographe de la langue source, ou profil de mots outils nettement en faveur d'une autre langue sur un texte assez long). Une phrase française citant « Björk », « José » ou « München » n'est jamais signalée, et les titres courts, sigles, noms propres et prix passent sans encombre.
* Nettoyage de l'existant : la liste des traductions gagne un filtre « 🚩 Langue source suspecte » (menu des statuts) qui passe en revue les lignes déjà enregistrées et affiche celles dont le texte source ne correspond pas à la langue déclarée, avec la langue reconnue. Elles peuvent être supprimées en lot comme n'importe quelle autre sélection, et seront retraduites — ou ignorées par le garde-fou — à la prochaine visite.
* Le garde-fou peut être désactivé globalement côté serveur avec le réglage source_lang_guard = 0.

= 1.1.40 (plugin + serveur, redéploiement serveur requis) =
* Éditeur visuel : le mode édition ne dépend plus du paramètre d'URL ?ft_visual_edit=1, que certains plugins SEO/cache suppriment en redirigeant vers l'URL canonique (fonction « supprimer les paramètres inconnus » de Yoast Premium par exemple — cas d'Aboral : sans le paramètre, ni le garde anti-rechargement ni le surlignage/clic ne s'activaient). La page admin de l'éditeur pose désormais un cookie de session (ft_ve, même origine, 2 h, supprimé à la fermeture) que ni les redirections ni le strip de paramètres ne peuvent retirer ; le garde du <head> et le snippet acceptent l'un ou l'autre signal, toujours conditionnés à l'exécution dans une iframe — la navigation normale du site n'est jamais affectée.

= 1.1.39 =
* Éditeur visuel : correction de la boucle de rechargements infinis quand le thème recharge la page sur l'événement resize ou le changement d'orientation (pratique courante pour les mises en page dépendant de la largeur — cas du thème Aboral). Dans l'iframe de l'éditeur, la largeur bouge pendant la mise en page de l'admin, le thème rechargeait, la mise en page bougeait de nouveau… Un garde-fou est désormais imprimé tout en haut du <head> (avant tout script de thème), uniquement en mode édition et dans une iframe : il neutralise les enregistrements d'écouteurs resize/orientationchange pour ce cadre-là seulement. Le site public et sa réactivité ne sont pas affectés.

= 1.1.38 =
* Correction des boucles de redirection infinies (ERR_TOO_MANY_REDIRECTS) sur les URLs préfixées. Un slug traduit actuellement valide est désormais TOUJOURS servi — la carte inverse est consultée avant la carte des redirections, si bien qu'une redirection périmée (slug modifié puis remis, ou entrée « globale » héritée d'un autre site entrant en collision avec les slugs du site courant) ne peut plus renvoyer une URL valide dans un cycle /en/x/ → /en/y/ → /en/x/. Trois gardes supplémentaires : aucune des redirections 301 du routeur ne peut plus pointer vers l'URL en cours de traitement, et un cycle à deux entrées dans la carte des redirections est détecté (anticipation d'un pas) et servi au lieu d'être suivi.

= 1.1.37 (plugin + serveur, redéploiement serveur requis) =
* Éditeur visuel en contexte (FourmizzTranslate → 🎨 Éditeur visuel) : votre site s'affiche dans l'admin, les textes se surlignent au survol, un clic ouvre le segment dans un panneau latéral (source + traduction) et la sauvegarde met la page à jour en direct. Les corrections sont marquées « ✏️ manuelles » et protégées comme depuis la 1.1.35. Le cache HTML de la page éditée est invalidé automatiquement — les visiteurs voient la correction immédiatement, sans vider tout le cache.
* Le mode édition du snippet ne s'active que dans l'iframe de l'admin (même origine que le site) et ne détient aucun secret : la sauvegarde passe par le proxy AJAX WordPress (droits administrateur + nonce), la clé secrète du projet ne quitte jamais le serveur WP.
* Serveur : endpoint /api/project/translations/lookup pour retrouver la ligne correspondant à un texte cliqué (correspondance exacte, sinon approximative pour les segments traduits en bloc — dans ce cas la page est rechargée après sauvegarde).
* Nécessite le mode d'URL « prefix ». Navigation désactivée dans l'aperçu : on change de page via la barre d'outils (liste des pages connues ou chemin manuel).

= 1.1.36 =
* Refonte visuelle de la page « Traductions » : liste en cartes façon Weglot (source → traduction côte à côte), pastilles de langue avec drapeaux, chips provider et badge « ✏️ manuelle », lien de page cliquable, actions (crayon/corbeille) au survol, clic sur la traduction pour la modifier directement, barre de recherche arrondie, filtres actifs mis en évidence, barre de sélection groupée repensée, état vide illustré. Aucune modification fonctionnelle ni serveur : c'est une nouvelle peau sur l'interface 1.1.35.

= 1.1.35 =
* Interface « Traductions » repensée : texte source et traduction affichés en entier (plus de troncature), édition côte à côte avec boutons Sauvegarder/Annuler (Ctrl+Entrée pour sauvegarder, Échap pour annuler), sélection multiple avec suppression groupée, filtres par provider et par statut, tri (récentes / anciennes / ordre de la page).
* Vue par page : chaque traduction est désormais rattachée à la page où elle apparaît. Un filtre « Toutes les pages » liste les pages du site avec leur nombre de segments ; en choisir une affiche ses traductions dans l'ordre du document. Les traductions existantes récupèrent leur page au fil des visites (remplissage progressif du cache).
* Badge « ✏️ manuelle » : les traductions corrigées à la main sont identifiées et protégées — l'invalidation par glossaire ne les écrase plus jamais.
* Serveur : colonnes page_url et manual_override (migration automatique), endpoints facets et bulk-delete de l'API projet, champ pageUrl optionnel sur /api/translate et /api/translate/batch (les anciens plugins qui ne l'envoient pas restent 100 % compatibles).

= 1.1.34 (serveur uniquement) =
* Snippet : l'observateur de contenu dynamique est suspendu quand une traduction navigateur est détectée (classe translated-ltr/rtl de Chrome/Google Traduction, attributs _msttexthash d'Edge/Bing, balises <font> injectées par Google). Auparavant, un visiteur utilisant la traduction automatique de son navigateur sur une page traduite faisait envoyer le texte réécrit (turc, japonais…) à l'API étiqueté comme langue source, polluant le cache serveur.
* API : /api/translate et /api/translate/batch refusent désormais (403) une langue cible non configurée dans le projet — la clé projet étant publique (visible dans l'URL du snippet), elle ne suffit plus à faire traduire vers des langues arbitraires. Les projets sans langues cibles configurées ne sont pas restreints.

= 1.1.33 (plugin + serveur, redéploiement serveur requis) =
* Mode préfixe : traduction par bloc. Un paragraphe/titre/item de liste dont le contenu est du texte et des balises inline simples (liens, gras, italique…) est désormais traduit comme UNE seule phrase, les balises encodées en marqueurs 【N】…【/N】. Le provider voit la phrase complète : plus de fragments tronqués « réparés » par le modèle (phrase complétée avec un point, mots orphelins entre deux liens, fragments type « CIRAM » traduits hors contexte), et les mots peuvent se déplacer d'une balise à l'autre selon la syntaxe de la langue cible. Les href et tous les attributs sont préservés par construction (jamais envoyés au provider) ; la réécriture des slugs opère ensuite comme avant.
* Filet de sécurité : si le provider abîme les marqueurs (perte, doublon, imbrication), le bloc repasse automatiquement en traduction nœud par nœud (comportement historique) — le pire cas est l'ancien comportement, jamais pire. Les blocs complexes (br, images, contenu mixte…) restent d'office en nœud par nœud. Désactivable via le filtre ft_block_translation_enabled.
* Conséquences : les paragraphes à mise en forme sont retraduits une fois (nouvelles clés de cache) — prévoir un préchauffage et vider le cache de traduction après mise à jour. Les corrections manuelles portant sur ces fragments sont à refaire au niveau du bloc si besoin.
* Serveur : consigne de préservation des marqueurs 【N】…【/N】 envoyée aux providers LLM uniquement quand un texte du lot en contient — le trafic des plugins non mis à jour garde un prompt strictement identique.

= 1.1.32 =
* Mode préfixe : plus d'espace parasite avant une ponctuation ("</strong> , at the crossroads…"). L'espace de début du nœud texte original n'est plus restauré quand la traduction commence par une ponctuation qui ne doit pas être précédée d'un espace (virgule, point, parenthèse fermante… ; pour le français cible, l'espace devant ; : ! ? » est conservé). Vider le cache de traduction après mise à jour pour régénérer les pages concernées.

= 1.1.31 (serveur uniquement, redéploiement requis) =
* Traduction : le provider principal est réessayé automatiquement (2 tentatives espacées) avant tout repli — les erreurs passagères (surcharge, 429/503) ne font plus échouer le lot.
* Repli LibreTranslate : n'est tenté que si l'instance LibreTranslate prend réellement en charge la paire de langues (interrogation de GET /languages, mise en cache 1 h). Corrige les traductions arabes jamais réalisées quand le provider principal échouait : le repli vers une instance sans arabe échouait à son tour et masquait l'erreur d'origine.
* Logs : en cas d'échec du repli, c'est l'erreur du provider principal qui est remontée (la vraie cause), plus celle de LibreTranslate.

= 1.1.30 =
* Slugs : import CSV au format `slug_original;slug_traduit` (une ligne par page, un fichier par langue cible). Disponible dans WP admin (FourmizzTranslate → Slugs) et dans le dashboard serveur (section Slugs → « Importer CSV », redéploiement serveur requis). Les URLs complètes, les préfixes de langue et les slashes manquants sont nettoyés automatiquement ; les lignes vides ou identiques sont ignorées ; le cache des slugs est invalidé après import côté WP.

= 1.1.29 =
* Installation : le mode « prefix » (répertoires /en/ /es/, SEO-friendly) est désormais le mode par défaut à l'activation du plugin. Les sites existants ne sont pas affectés : leur réglage enregistré est conservé.
* Sélecteur de langues : la langue active est toujours affichée en première position — shortcode [st_switcher] (boutons et dropdown), widget sidebar et widget flottant (snippet, redéploiement serveur requis pour ce dernier). En mode prefix l'ordre est rendu côté serveur pour chaque variante de langue ; en mode storage le repositionnement se fait côté client.

= 1.1.28 =
* Mode prefix : le prewarm du contenu à la publication couvre désormais tous les types de contenu publics — pages, articles ET custom post types (même critère que la traduction des slugs). Auparavant limité à post/page, le contenu d'un CPT n'était traduit qu'à la première visite de chaque version linguistique. Les révisions et autosaves sont ignorés.

= 1.1.27 (serveur uniquement, redéploiement requis) =
* Sauvegardes automatiques de la base SQLite : snapshot cohérent quotidien (03:00 UTC par défaut) via l'API de backup en ligne de SQLite, vérification d'intégrité (PRAGMA quick_check) avant archivage, compression gzip, rotation grand-père/père/fils (14 quotidiennes, 8 hebdomadaires, 12 mensuelles). Idempotent au redémarrage : un créneau manqué (serveur éteint à 03:00) est rattrapé au prochain démarrage, sans doublon.
* Dashboard : carte « Sauvegardes » dans Réglages — statut du planificateur, sauvegarde manuelle, liste avec téléchargement et suppression.
* Configuration via variables d'environnement (BACKUP_ENABLED, BACKUP_DIR, BACKUP_HOUR_UTC, BACKUP_KEEP_*) — tout fonctionne sans configuration.

= 1.1.26 =
* Support RTL (arabe, hébreu, persan, ourdou…) : l'attribut dir="rtl" est désormais posé sur <html> quand la langue affichée est une langue droite-à-gauche — mode prefix (passe serveur PHP) comme mode storage (snippet). La mise en page se miroite correctement et les styles [dir="rtl"] des thèmes/Elementor s'activent. Le cas inverse (site source RTL traduit vers une langue LTR) repasse dir en ltr. Le widget flottant garde une direction stable (ltr interne) quelle que soit la langue de la page. Aucun changement pour les sites entièrement LTR : le HTML produit est identique. Après mise à jour en mode prefix, vider le cache de traduction si vous utilisez une langue RTL.

= 1.1.25 (serveur uniquement, redéploiement requis) =
* Glossaire : le cache ne prime plus sur le glossaire. Une traduction mise en cache AVANT l'ajout/modification d'une entrée de glossaire qui la concerne est automatiquement re-traduite (une seule fois — pas de re-traduction en boucle) au lieu d'être servie telle quelle indéfiniment.
* Glossaire : en batch, la consigne « préserver les marqueurs 【ST:N】 » n'était envoyée au provider (Gemini, Claude, Ollama) que si le PREMIER texte du lot contenait un terme du glossaire — sinon le provider traduisait/perdait les marqueurs des autres textes et le glossaire était ignoré aléatoirement. La consigne est envoyée dès qu'un texte du lot est concerné.
* Glossaire : priorité des entrées clarifiée — terme le plus long d'abord, puis entrée du projet avant entrée globale, puis entrée spécifique à la langue avant entrée « toutes langues ».
* Rappel : côté WordPress (mode prefix), le HTML traduit reste en cache transient — après une modification de glossaire, « Vider le cache » pour voir l'effet immédiatement.

= 1.1.24 =
* Correctif (serveur + snippet, redéploiement serveur requis — aucun changement de code plugin) : les animations de texte « machine à écrire » (typed.js, titres animés Elementor…) faisaient envoyer chaque état intermédiaire (« Un posic », « Un posicio »…) du texte déjà traduit à l'API, tagué langue source. Deux protections : (1) le snippet n'envoie un texte dynamique que s'il est resté stable 700 ms — les états d'animation ne se stabilisent jamais, la phrase finale est reconnue comme déjà traduite ; (2) le garde « écho » serveur détecte aussi les fragments : tout texte (8 caractères et plus) qui est un préfixe strict d'une traduction déjà produite pour la même langue cible est renvoyé tel quel, sans appel provider ni stockage.
* Serveur : la purge « écho » supprime aussi ces fragments (préfixes de 8 caractères et plus d'une traduction existante).

= 1.1.23 =
* Traçabilité : colonne « Date (UTC) » dans 📝 Traductions — permet de distinguer une ligne polluée résiduelle (antérieure aux correctifs) d'une ligne nouvellement créée.
* Serveur : date + provider affichés dans « Dernières traductions » et colonne Date dans l'onglet Traductions du dashboard.
* Serveur : le garde « écho » ne s'applique qu'aux textes de 15 caractères et plus — les mots courts identiques entre langues (« Innovation », « Design ») continuent d'être envoyés au provider normalement.

= 1.1.22 =
* Correctif majeur : retour du bug de « double traduction » (textes déjà traduits renvoyés à l'API tagués langue source). Cause : le module SEO (FT_SEO_Meta) traduit le titre et les metas via les filtres Yoast, puis la passe DOM serveur re-collectait ces mêmes balises — désormais en langue cible — et les re-soumettait comme du français. La passe DOM exclut maintenant les valeurs déjà traduites par FT_SEO_Meta.
* Serveur (redéploiement requis) : garde « écho » — tout texte soumis qui correspond à une traduction déjà produite pour la même langue cible est renvoyé tel quel, sans appel provider ni stockage. Filet de sécurité contre toute réinjection future, quelle qu'en soit l'origine.
* Serveur : bouton « Purger les traductions écho » dans Maintenance (dashboard) pour nettoyer les lignes déjà accumulées.
* Snippet : l'observateur de contenu dynamique reconnaît les textes déjà traduits même après normalisation des espaces (sliders qui re-créent les nœuds) et inclut le contenu des balises <template> dans l'instantané initial.

= 1.1.21 =
* Correctif : la recherche AJAX (Traductions / Slugs) mutilait silencieusement les termes contenant un espace, un accent ou une apostrophe — le proxy AJAX passait la query string dans `sanitize_text_field()`, qui supprime toutes les séquences percent-encodées (« piscine coque » devenait « piscinecoque » → aucun résultat). Le chemin reste validé par liste blanche et la query string est reconstruite depuis une liste blanche de paramètres.
* Amélioration (serveur, redéploiement requis) : recherche insensible aux accents et à la casse (« équipe » trouve « Équipe »), apostrophes typographiques (’) assimilées aux droites ('), recherche multi-mots (chaque mot doit matcher, « piscine coque » trouve « piscine à coque »), et les caractères % et _ sont traités littéralement.

= 1.1.20 =
* Correctif : la resynchronisation des langues et l'import des réglages renvoyaient une page blanche (redirection vers admin-post.php au lieu de la page de réglages). Les handlers utilisaient `menu_page_url()`, qui renvoie une chaîne vide sur admin-post.php. Retour désormais construit en absolu.

= 1.1.19 =
* Gouvernance : la langue source et les langues cibles sont désormais gérées exclusivement côté serveur. Elles s'affichent en lecture seule dans Réglages → FourmizzTranslate (plus aucun admin WP ne peut ajouter/retirer une langue depuis WordPress). Le plugin les synchronise depuis le serveur (endpoint `GET /api/config`) : à l'ouverture des réglages, via un bouton « Resynchroniser », et une fois par jour (cron). Ces clés ne sont plus enregistrées par le formulaire ni appliquées à l'import de réglages. La détection automatique reste, elle, modifiable côté WP.

= 1.1.18 =
* Sitemap (Yoast) : les entrées de langue ajoutées utilisaient la date de modification brute (« 2026-02-11 10:44:38 ») au lieu du format W3C Datetime exigé par le protocole sitemap. Corrige l'erreur « format du fichier sitemap » remontée par SEMrush / les validateurs. La date est désormais normalisée en ISO 8601 (« 2026-02-11T10:44:38+00:00 »).

= 1.1.17 =
* Mode prefix / SEO : la balise `og:locale` des pages traduites reflète désormais la langue cible (ex. `de_DE` sur une page allemande) au lieu de la locale source (`fr_FR`) imprimée par Yoast. Après mise à jour : vider le cache de traduction.

= 1.1.16 =
* Mode prefix / SEO : le `<link rel="canonical">` et `og:url` des pages traduites pointent désormais vers l'URL traduite elle-même (auto-référence), au lieu de l'URL source. Corrige l'alerte « Canonical to other page » (SEMrush) et évite que les moteurs traitent chaque traduction comme un doublon de la version source. Fonctionne quelle que soit la source du canonical (thème, Yoast, Rank Math…). Après mise à jour : vider le cache de traduction pour régénérer les pages déjà en cache.

= 1.1.15 =
* Mode prefix : garde anti-boucle sur les redirections. Si le re-préfixage d'une redirection tierce (plugin Redirection, SEO…) reproduit exactement l'URL demandée, on ne relance pas la boucle : redirection finie vers la langue source au lieu d'un ERR_TOO_MANY_REDIRECTS. Protège le cas d'une page renommée dont l'ancien slug source périmé partage le même slug traduit que le nouveau.

= 1.1.14 =
* Réglages : export / import des réglages du module en JSON (Réglages → FourmizzTranslate). Permet de migrer toute la configuration d'un site à l'autre (préprod → prod) en un fichier. L'import re-génère les règles de réécriture et vide le cache. Le fichier contient les clés (dont la clé secrète) — à conserver en lieu sûr.

= 1.1.13 =
* Mode prefix : la normalisation du préfixe /langue/ se fait désormais sur `setup_theme` (avant `init`). Les moteurs de redirection tiers (plugin Redirection, plugins SEO) qui matchent l'URL sur `init` voient le chemin source non préfixé et déclenchent leurs 301 normalement ; la destination est ensuite re-préfixée dans la langue. Corrige les URLs traduites d'anciennes pages supprimées qui tombaient en 404 au lieu de suivre la redirection.

= 1.1.12 =
* Mode prefix : les redirections serveur (wp_redirect / wp_safe_redirect) conservent la langue. Un thème ou plugin qui redirige (ex. contenu supprimé renvoyé vers sa page parente) ne renvoie plus le visiteur vers l'URL source sans préfixe.

= 1.0.0 =
* Version initiale
