Files
ship2net-www/extraire_traductions.php

314 lines
14 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
// Parcourt tous les fichiers .php sous /var/www/html et /var/www/config,
// repère chaque appel t("...") et régénère lang/en.php en conséquence -
// équivalent de 'lupdate' pour ce mécanisme de traduction "à la Qt".
//
// - Les traductions déjà faites, pour des chaînes encore utilisées dans le
// code, sont conservées telles quelles.
// - Les nouvelles chaînes (trouvées dans le code, absentes de en.php)
// sont ajoutées avec une valeur vide, à traduire manuellement.
// - Les entrées obsolètes (présentes dans en.php, plus utilisées dans le
// code) sont déplacées en fin de fichier, dans un bloc de commentaire.
// Ce bloc est RELU à chaque passage : le script n'y fait qu'AJOUTER les
// entrées nouvellement obsolètes. Une entrée n'en sort que si vous la
// supprimez à la main, ou si son texte est de nouveau utilisé dans le code
// (elle retrouve alors sa traduction).
// - Les entrées sont triées par ordre alphabétique du texte français
// (insensible à la casse et aux accents), pour que deux textes presque
// identiques (typiquement une coquille) se retrouvent l'un à côté de l'autre.
// - Contrôle des espaces réservés : pour chaque traduction non vide, les jetons
// [[Nom]], [[Email]]... et les formats %s, %d... doivent être ceux du texte
// français (ils ne se traduisent pas). Simple avertissement à chaque passage,
// qui ne bloque rien. Les crochets simples ([Owner]...) ne sont PAS contrôlés :
// ce sont des libellés affichés, que l'on traduit.
// - Chaque entrée occupe deux lignes : le texte français, puis '=>' 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, "<?php\nreturn [\n" . $entrees . "];\n");
try {
$tableau = include $tmp;
} finally {
unlink($tmp);
}
return is_array($tableau) ? $tableau : [];
}
// ----------------------------------------------------------------------------
// Recherche des appels t("...") dans le code
// ----------------------------------------------------------------------------
$chaines_trouvees = [];
$iterateur = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($racine));
foreach ($iterateur as $fichier) {
if (!str_ends_with($fichier->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 = "<?php\n";
$contenu .= "// Fichier régénéré automatiquement par extraire_traductions.php\n";
$contenu .= "// le " . date("d/m/Y à H:i:s") . " - les valeurs vides sont à traduire manuellement.\n";
$contenu .= "// Entrées triées par ordre alphabétique du texte français.\n";
$contenu .= "return [\n";
foreach ($nouveau_tableau as $cle => $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";
}
}