' et la // traduction sur la ligne suivante, précédé d'une tabulation. // // Limite connue et acceptée (même limite que qsTr()/gettext) : ne détecte // que les chaînes littérales simples (pas de concaténation dynamique). // Pour du texte avec contenu variable, utiliser un espace réservé : // sprintf(t("Bonjour %s"), $nom) // plutôt que t("Bonjour " . $nom) - la seconde forme ne sera pas détectée. // // Usage : php extraire_traductions.php [racine] (racine par défaut : /var/www) $racine = $argv[1] ?? "/var/www"; $fichier_en = "$racine/config/lang/en.php"; // ---------------------------------------------------------------------------- // Clé de tri : minuscules, sans accents, sans balises HTML, espaces réduits, // sans ponctuation en tête. Sert UNIQUEMENT à ordonner : le texte réellement // stocké dans en.php reste celui du code, inchangé. // ---------------------------------------------------------------------------- function cle_de_tri($texte) { static $table = [ 'à'=>'a','á'=>'a','â'=>'a','ä'=>'a','ã'=>'a','å'=>'a', 'À'=>'a','Á'=>'a','Â'=>'a','Ä'=>'a','Ã'=>'a','Å'=>'a', 'ç'=>'c','Ç'=>'c', 'è'=>'e','é'=>'e','ê'=>'e','ë'=>'e', 'È'=>'e','É'=>'e','Ê'=>'e','Ë'=>'e', 'ì'=>'i','í'=>'i','î'=>'i','ï'=>'i', 'Ì'=>'i','Í'=>'i','Î'=>'i','Ï'=>'i', 'ñ'=>'n','Ñ'=>'n', 'ò'=>'o','ó'=>'o','ô'=>'o','ö'=>'o','õ'=>'o', 'Ò'=>'o','Ó'=>'o','Ô'=>'o','Ö'=>'o','Õ'=>'o', 'ù'=>'u','ú'=>'u','û'=>'u','ü'=>'u', 'Ù'=>'u','Ú'=>'u','Û'=>'u','Ü'=>'u', 'ý'=>'y','ÿ'=>'y','Ý'=>'y', 'œ'=>'oe','Œ'=>'oe','æ'=>'ae','Æ'=>'ae', '’'=>"'", '‘'=>"'", "\u{00A0}"=>' ', ]; $cle = strtr($texte, $table); $cle = strtolower($cle); // ASCII seulement : les accents sont déjà traités $cle = strip_tags($cle); $cle = preg_replace('/\s+/', ' ', $cle) ?? $cle; $cle = trim($cle); $cle = preg_replace('/^[^\p{L}\p{N}]+/u', '', $cle) ?? $cle; return $cle; } // Tri par texte français ; à clé égale, ordre des octets pour rester déterministe. function trier_par_texte(array $tableau) { $cles = []; foreach ($tableau as $texte => $_) $cles[$texte] = cle_de_tri((string)$texte); uksort($tableau, function ($a, $b) use ($cles) { $c = strcmp($cles[$a], $cles[$b]); return $c !== 0 ? $c : strcmp((string)$a, (string)$b); }); return $tableau; } // Une entrée sur deux lignes : le texte, puis TAB + '=> traduction,' function formater_entree($cle, $valeur) { return " " . var_export($cle, true) . "\n\t=> " . var_export($valeur, true) . ",\n"; } // ---------------------------------------------------------------------------- // Contrôle des espaces réservés (jetons [[...]] et formats de sprintf) // ---------------------------------------------------------------------------- // Jetons [[Nom]] et formats %s, %d, %1$s, %.2f... trouvés dans un texte. function extraire_jetons($texte) { preg_match_all('/\[\[[^\]\r\n]+\]\]/u', $texte, $m1); $sans_pourcent_litteral = str_replace('%%', '', $texte); // '%%' = un vrai signe % preg_match_all('/%(?:\d+\$)?[-+0]*\d*(?:\.\d+)?[bcdeEfFgGosuxX]/', $sans_pourcent_litteral, $m2); return ['reserves' => $m1[0], 'formats' => $m2[0]]; } // Compare deux listes en ignorant l'ordre. Renvoie [manquants, en trop]. function ecart_jetons(array $attendus, array $obtenus) { $manquants = []; $en_trop = []; $ca = array_count_values($attendus); $co = array_count_values($obtenus); foreach ($ca as $j => $n) if (($co[$j] ?? 0) < $n) $manquants[] = $j; foreach ($co as $j => $n) if (($ca[$j] ?? 0) < $n) $en_trop[] = $j; return [$manquants, $en_trop]; } // Renvoie la liste des problèmes (chaînes lisibles) entre un texte français et sa traduction. function controler_jetons($francais, $traduction) { $problemes = []; $fr = extraire_jetons($francais); $en = extraire_jetons($traduction); // 1) Jetons [[...]] : présence seulement, l'ordre est libre. [$manquants, $en_trop] = ecart_jetons($fr['reserves'], $en['reserves']); if ($manquants) $problemes[] = "jeton(s) absent(s), ou en nombre insuffisant, dans la traduction : " . implode(' ', $manquants); if ($en_trop) $problemes[] = "jeton(s) inconnu(s) dans la traduction : " . implode(' ', $en_trop); // 2) Formats sprintf : on compare le type de conversion (s, d, f...) sans // la position ni la largeur, pour ne pas signaler un %1$s légitime. $types = function ($liste) { return array_map(function ($f) { return substr($f, -1); }, $liste); }; $tf = $types($fr['formats']); $te = $types($en['formats']); [$manquants, $en_trop] = ecart_jetons($tf, $te); if ($manquants) $problemes[] = "format(s) sprintf absent(s), ou en nombre insuffisant, dans la traduction : %" . implode(' %', $manquants); if ($en_trop) $problemes[] = "format(s) sprintf en trop dans la traduction : %" . implode(' %', $en_trop); // 3) Même formats mais dans un autre ordre : dangereux seulement s'ils ne // sont pas numérotés (%s puis %d), les valeurs seraient alors échangées. if (!$manquants && !$en_trop && $tf !== $te) { $numerotes = false; foreach ($en['formats'] as $f) if (strpos($f, '$') !== false) $numerotes = true; if (!$numerotes) { $problemes[] = "formats sprintf dans un autre ordre (" . implode(' ', $en['formats']) . " au lieu de " . implode(' ', $fr['formats']) . ") : numéroter avec %1\$s, %2\$s, ou remettre l'ordre du français"; } } return $problemes; } // Texte français raccourci pour l'affichage (coupé sur un caractère entier). function abreger($texte, $max = 70) { $texte = trim(preg_replace('/\s+/', ' ', $texte)); if (preg_match('/^.{1,' . $max . '}/us', $texte, $m) && $m[0] !== $texte) return $m[0] . '…'; return $texte; } // ---------------------------------------------------------------------------- // Relit le bloc des obsolètes (commentaire en fin de fichier) et le renvoie // sous forme de tableau texte => traduction. Renvoie [] s'il n'y en a pas. // Lève une exception si le bloc a été modifié à la main de façon à ne plus // être du PHP valide (par exemple une entrée dont il ne reste que la moitié). // ---------------------------------------------------------------------------- function lire_obsoletes($fichier) { if (!file_exists($fichier)) return []; $brut = file_get_contents($fichier); if (!preg_match('/\/\*\n\/\/ OBSOLÈTES[^\n]*\n(.*?)\*\/\s*\z/su', $brut, $m)) return []; // Les '*/' des textes ont été écrits '*\/' pour ne pas fermer le commentaire : $entrees = str_replace('*\/', '*/', $m[1]); $tmp = tempnam(sys_get_temp_dir(), 'obs'); file_put_contents($tmp, "getFilename(), ".php")) continue; if (str_contains($fichier->getPathname(), "/lang/")) continue; // ne pas s'auto-analyser if ($fichier->getFilename() === basename(__FILE__)) continue; // idem, pour le script lui-même $contenu = file_get_contents($fichier->getPathname()); if (preg_match_all('/\bt\(\s*"((?:[^"\\\\]|\\\\.)*)"\s*\)/', $contenu, $matches)) { foreach ($matches[1] as $chaine) { $chaine = stripcslashes($chaine); $chaines_trouvees[$chaine][] = $fichier->getPathname(); } } } $traductions_en = file_exists($fichier_en) ? include $fichier_en : []; // Lecture du bloc des obsolètes AVANT toute écriture : si elle échoue, on // s'arrête sans avoir rien modifié. try { $obsoletes_anciens = lire_obsoletes($fichier_en); } catch (Throwable $e) { fwrite(STDERR, "ERREUR : le bloc des entrées obsolètes de $fichier_en n'est plus du PHP valide.\n"); fwrite(STDERR, " (" . $e->getMessage() . ")\n"); fwrite(STDERR, "Chaque entrée doit rester sur deux lignes : 'texte' puis, à la ligne, '=> traduction,'.\n"); fwrite(STDERR, "Corrigez le bloc (ou supprimez-le) puis relancez. RIEN n'a été modifié.\n"); exit(1); } // --- Bilan --- $nouvellement_obsoletes = []; foreach (array_diff(array_keys($traductions_en), array_keys($chaines_trouvees)) as $chaine) { $nouvellement_obsoletes[$chaine] = $traductions_en[$chaine]; } // Un texte de nouveau utilisé dans le code, mais présent dans le bloc des // obsolètes, retrouve sa traduction et sort du bloc : $reactivees = []; foreach ($chaines_trouvees as $chaine => $fichiers) { if (!array_key_exists($chaine, $traductions_en) && array_key_exists($chaine, $obsoletes_anciens)) { $reactivees[$chaine] = $obsoletes_anciens[$chaine]; unset($obsoletes_anciens[$chaine]); } } $manquantes = []; foreach ($chaines_trouvees as $chaine => $fichiers) { if (!array_key_exists($chaine, $traductions_en) && !array_key_exists($chaine, $reactivees)) { $manquantes[$chaine] = $fichiers[0]; } } $obsoletes_total = $obsoletes_anciens + $nouvellement_obsoletes; echo "=== Chaînes utilisées dans le code : " . count($chaines_trouvees) . " ===\n\n"; echo count($manquantes) . " nouvelle(s) chaîne(s) à traduire.\n"; echo count($nouvellement_obsoletes) . " entrée(s) devenue(s) obsolète(s), ajoutée(s) au bloc en commentaire.\n"; echo count($obsoletes_anciens) . " entrée(s) obsolète(s) déjà en commentaire, conservée(s) (à supprimer à la main).\n"; echo count($reactivees) . " entrée(s) obsolète(s) de nouveau utilisée(s), traduction récupérée.\n\n"; // ---------------------------------------------------------------------------- // Régénération de lang/en.php // ---------------------------------------------------------------------------- // Sauvegarde de précaution avant d'écraser : if (file_exists($fichier_en)) { copy($fichier_en, "$fichier_en.bak"); } // Fusion : traductions existantes conservées pour les chaînes encore utilisées, // traductions récupérées du bloc des obsolètes, nouvelles chaînes vides : $nouveau_tableau = []; foreach ($chaines_trouvees as $chaine => $fichiers) { $nouveau_tableau[$chaine] = $traductions_en[$chaine] ?? $reactivees[$chaine] ?? ""; } $nouveau_tableau = trier_par_texte($nouveau_tableau); $contenu = " $valeur) { $contenu .= formater_entree($cle, $valeur); } $contenu .= "];\n"; if (!empty($obsoletes_total)) { $bloc = ""; foreach (trier_par_texte($obsoletes_total) as $cle => $valeur) { $bloc .= formater_entree($cle, $valeur); } // Une séquence '*/' dans un texte fermerait le commentaire trop tôt : $bloc = str_replace('*/', '*\/', $bloc); $contenu .= "\n/*\n"; $contenu .= "// OBSOLÈTES (bloc mis à jour le " . date("d/m/Y à H:i:s") . ") - plus utilisées dans le code.\n"; $contenu .= "// Conservées d'un passage à l'autre : à supprimer à la main quand elles ne servent vraiment plus.\n"; $contenu .= $bloc; $contenu .= "*/\n"; } file_put_contents($fichier_en, $contenu); echo "Fichier régénéré : $fichier_en\n"; echo "(sauvegarde de l'ancienne version : $fichier_en.bak)\n"; // ---------------------------------------------------------------------------- // Contrôle des espaces réservés (affiché à CHAQUE passage tant que ce n'est // pas corrigé ; ne bloque rien) // ---------------------------------------------------------------------------- $avertissements = []; foreach ($nouveau_tableau as $francais => $traduction) { if ($traduction === "") continue; // pas encore traduit $problemes = controler_jetons((string)$francais, (string)$traduction); if ($problemes) $avertissements[$francais] = $problemes; } echo "\n"; if (empty($avertissements)) { echo "Espaces réservés ([[...]], %s...) : cohérents dans toutes les traductions.\n"; } else { echo "ATTENTION : " . count($avertissements) . " traduction(s) dont les espaces réservés diffèrent du français.\n"; echo "Ils ne se traduisent pas : le code cherche les jetons exacts.\n"; foreach ($avertissements as $francais => $problemes) { echo "\n « " . abreger((string)$francais) . " »\n"; foreach ($problemes as $probleme) echo " - $probleme\n"; } }