Vous changez une chaîne depuis International puis Traductions, la validation passe sans un mot, et côté boutique l’ancien libellé s’affiche toujours : vient alors l’envie d’aller fouiller les permissions du dossier translations/. Raté. Plus aucun fichier n’est écrit par une traduction saisie au back-office depuis PrestaShop 1.7. Du coup, à rester accroché aux droits d’écriture, votre journée y passera sans rien produire (sur une 1.7.8, moi, c’est une bonne heure que j’ai laissée là-dedans la première fois).
Où atterrit réellement une traduction enregistrée
TranslationService::saveTranslationMessage() habite src/PrestaShopBundle/Service/ et réclame quatre entrées : la langue par son identifiant, le domaine, la clé, la valeur. De tout ça sort une entité Doctrine Translation, rendue persistante. Dit autrement, ps_translation hérite d’une ligne de plus, répartie sur id_lang, key, translation, domain et theme. Côté XLF, aucun fichier ne bouge.
Allez chercher cette table dans install-dev/data/db_structure.sql en 1.7 ou en 8, vous ne l’y verrez pas (Doctrine s’en occupe, par la commande prestashop:schema:update-without-foreign que déclenche l’installateur), tandis que le fichier SQL livré avec la 9.0 la décrit en toutes lettres. Elle est là quand même. Pour ma part, je démarre toujours de ce côté : questionnez-la avant quoi que ce soit d’autre, histoire de voir si votre saisie a bien atterri.
-- Adapter le prefixe ps_ a celui de la boutique.
-- 1. Toute traduction saisie dans International > Traductions atterrit ici,
-- jamais dans un fichier. La colonne domain ne contient pas de point :
-- DomainNormalizer retire les deux premiers, donc 'Shop.Theme.Actions'
-- est stocke 'ShopThemeActions' et 'Modules.Monmodule.Shop' devient
-- 'ModulesMonmoduleShop'.
SELECT t.id_translation, l.iso_code, t.domain, t.theme,
LEFT(t.`key`, 60) AS source,
LEFT(t.translation, 60) AS traduction
FROM ps_translation t
JOIN ps_lang l ON l.id_lang = t.id_lang
WHERE t.domain LIKE 'ModulesMonmodule%'
ORDER BY t.domain, t.`key`;
-- 2. Retrouver une chaine precise, toutes langues et tous domaines confondus.
-- Si elle sort ici, l'enregistrement a fonctionne et le probleme est ailleurs.
SELECT l.iso_code, t.domain, t.theme, t.translation
FROM ps_translation t
JOIN ps_lang l ON l.id_lang = t.id_lang
WHERE t.`key` = 'Ajouter au panier';
-- 3. Une meme chaine enregistree a la fois avec et sans theme :
-- le catalogue du theme actif gagne, l'autre ligne reste invisible.
SELECT t.`key`, t.domain, COUNT(DISTINCT IFNULL(t.theme, '')) AS variantes
FROM ps_translation t
GROUP BY t.`key`, t.domain
HAVING variantes > 1;
Ligne trouvée, l’écriture a donc bien eu lieu, et c’est côté lecture que je poursuis : le cache d’abord, le domaine ensuite, ou alors le système de traduction choisi par le module. Rien dans la table, et votre formulaire s’est arrêté en route.
Droits de fichiers : ce qui bloque, et ce qui ne bloque rien
Oui, les droits jouent encore, mais ailleurs que sur le dossier qu’on soupçonne d’abord : getListModules() ouvre la page de traduction des modules, et cette méthode envoie une PrestaShopException au message « The module directory must be writable. » dès que modules/ refuse l’écriture. Et vous n’y accédez pas du tout. Rien à voir avec une sauvegarde muette qui n’aboutit pas (ici, pas l’ombre d’un formulaire, juste un avertissement limpide). Et devant une page d’erreur serveur, là où vous attendiez le formulaire, partez plutôt sur une erreur 500 après une mise à jour, vos traductions n’ont aucune part là-dedans.
Ce dossier translations/ abrite autre chose : les catalogues livrés d’origine et les paquets de langue que vous exportez, jamais ce que vous tapez au quotidien. Y installer une langue a des suites visibles sur votre boutique, puisque le préfixe d’URL reprend le code iso de cette langue : supprimer le /fr/ des URL dépend de ce réglage-là, et le catalogue n’y entre pas. D’ailleurs, en 1.7.8 vous trouverez ce catalogue d’origine sous app/Resources/translations/default, adresse qui devient translations/default à partir de la 8, alors que var/ réclame toujours le droit d’écriture pour son cache compilé (celui-là, je le contrôle systématiquement).
# A executer depuis la racine de la boutique.
# modules/ doit etre inscriptible : AdminTranslationsController leve une
# PrestaShopException "The module directory must be writable." des l'ouverture
# de l'ecran de traduction des modules, avant meme tout enregistrement.
find modules -type d -exec chmod 755 {} +
find modules -type f -exec chmod 644 {} +
# translations/ sert aux catalogues par defaut et aux exports de paquets
# de langue. Sur 1.7, le catalogue par defaut vit dans app/Resources/translations.
find translations -type d -exec chmod 755 {} +
find translations -type f -exec chmod 644 {} +
# Le proprietaire pese autant que le mode : un fichier appartenant a root
# reste inaccessible en ecriture pour PHP, meme en 755. Remplacer www-data
# par l'utilisateur reel du pool PHP-FPM.
chown -R www-data:www-data modules translations var
# Controle rapide, sans modifier quoi que ce soit.
sudo -u www-data test -w modules && echo 'modules inscriptible'
sudo -u www-data test -w var/cache && echo 'var/cache inscriptible'
Une archive décompressée en SSH qui laisse root propriétaire donne la même panne qu’un mode trop verrouillé : les droits que votre client FTP affiche à l’écran ne valent rien, ne vous y fiez pas. Écrivez un fichier sous l’identité du serveur web, c’est le seul essai qui départage.
Le catalogue compilé garde l’ancienne version
Écriture confirmée en base, j’arrive alors sur la cause la plus fréquente de toutes. Le traducteur Symfony compile ces catalogues puis les relit dans var/cache, dont _PS_CACHE_DIR_ fixe l’emplacement depuis config/defines.inc.php : ce sera var/cache/dev, ou bien var/cache/prod, selon l’état de _PS_MODE_DEV_ (deux dossiers séparés, et donc deux catalogues séparés).
Avec la 9, tout bouge. L’identifiant de l’application vient maintenant s’ajouter au chemin via AppKernel::getCacheDir(), et trois noyaux vivent côte à côte, front, admin et admin-api, de sorte qu’un vidage lancé depuis votre back-office épargne celui du front. Le bin/console de la 9 lit pour sa part une option globale --app-id et tape sur admin faute d’indication (d’où ces purges qui ne changent rien de visible en boutique).
# PrestaShop 1.7 et 8 : un repertoire de cache par environnement. # _PS_CACHE_DIR_ vaut var/cache/dev ou var/cache/prod selon _PS_MODE_DEV_. rm -rf var/cache/dev var/cache/prod # PrestaShop 9 : AppKernel::getCacheDir() ajoute l'identifiant du noyau, # il y a donc trois sous-repertoires par environnement. ls -1 var/cache/prod rm -rf var/cache/prod/front var/cache/prod/admin var/cache/prod/admin-api # Equivalent en ligne de commande. Sur la 9, bin/console vise le noyau admin # par defaut ; --app-id permet de cibler les deux autres. php bin/console cache:clear php bin/console --app-id=front cache:clear php bin/console --app-id=admin-api cache:clear # Le repertoire est recree par le premier appel : il doit rester inscriptible. chown -R www-data:www-data var/cache
Le cache Smarty, lui, marche à part : il prend en charge les gabarits de votre thème et se nettoie par le menu Performances. L’un vidé, l’autre reste plein. Une chaîne traduite comme il faut au catalogue et pourtant invisible derrière un gabarit compilé la veille, j’en croise régulièrement. Dans le doute, faites les deux.
Domaines et syntaxe : les points disparaissent avant la recherche
Ce détail-là fait capoter quantité de requêtes lancées en base. PrestaShopTranslatorTrait::trans() remet le domaine à DomainNormalizer, et sa méthode normalize() lui applique un preg_replace('/\./', '', $domain, 2) : deux points s’en vont, jamais davantage, parce qu’un troisième peut parfaitement figurer dans un domaine de module. Du coup Shop.Theme.Actions s’écrit ShopThemeActions, écriture qui tient lieu de clé de catalogue et qui atterrit telle quelle dans la colonne domain : pensez-y quand vous rédigez vos requêtes, les points n’y sont plus.
Dans les gabarits, la fonction Smarty l s’enregistre dans config/smarty.config.inc.php et renvoie sur smartyTranslate(), qui file vers le système moderne si on lui fournit le paramètre d, vers le vieux si on lui passe mod. À vous de voir lequel tourne chez vous.
{* Systeme moderne : le domaine complet, points compris.
PrestaShop retire lui-meme les deux premiers points avant de chercher
dans le catalogue. *}
{l s='Ajouter au panier' d='Shop.Theme.Actions'}
{* Substitutions anonymes. sprintf attend un tableau, jamais une chaine :
smartyTranslate journalise une erreur et leve une exception en mode debug. *}
{l s='Liste des produits du fournisseur %s' sprintf=[$supplier.name] d='Shop.Theme.Catalog'}
{* Substitutions nommees. *}
{l s='Il y a %products_count% articles dans votre panier.' sprintf=['%products_count%' => $cart.products_count] d='Shop.Theme.Checkout'}
{* Ancien systeme, encore majoritaire dans les modules tiers : mod= au lieu de d=.
smartyTranslate bascule alors sur Translate::getModuleTranslation() et sur
la cle md5, pas sur le catalogue Symfony ni sur la table ps_translation. *}
{l s='Ajouter au panier' mod='monmodule'}
{* Le parametre tags n'existe plus : il declenche une exception en mode debug
et une entree dans les logs en production. Utiliser sprintf. *}
En PHP, même histoire. J’attaque directement le catalogue chargé pour la langue en cours, faites-en autant : rien ne va plus vite pour établir qu’un domaine existe (dix secondes, montre en main), avant de courir après telle chaîne qui ne sort pas.
<?php
/**
* diag-traduction.php
* A deposer a la racine de la boutique, a executer une fois, puis a supprimer.
* Montre quel domaine est reellement interroge et si le catalogue le connait.
*/
require_once __DIR__ . '/config/config.inc.php';
$domaineEcrit = 'Modules.Monmodule.Shop';
// PrestaShopTranslatorTrait::trans() passe le domaine dans DomainNormalizer,
// qui supprime les deux premiers points. C'est cette forme, sans point,
// qui sert de cle de catalogue et qui est stockee dans ps_translation.domain.
$domaineReel = preg_replace('/\./', '', $domaineEcrit, 2);
echo 'Domaine interroge : ' . $domaineReel . PHP_EOL;
$translator = Context::getContext()->getTranslator();
// getCatalogue()->all() renvoie un tableau indexe par domaine.
// C'est exactement le test utilise par Module::isUsingNewTranslationSystem().
$domaines = array_keys($translator->getCatalogue()->all());
echo in_array($domaineReel, $domaines, true)
? 'Domaine present dans le catalogue.' . PHP_EOL
: 'Domaine absent : aucune traduction ne sera trouvee.' . PHP_EOL;
// Signature identique sur 1.7.8, 8.2 et 9.0 :
// trans($id, array $parameters = [], $domain = null, $locale = null)
var_dump($translator->trans('Ajouter au panier', [], $domaineEcrit));
Les modules restés sur l’ancien système
Les modules tiers, eux, sont restés dessus. Leurs gabarits annoncent les chaînes par mod='monmodule', et c’est le cœur qui s’en débrouille ensuite, par Translate::getModuleTranslation(), laquelle inclut le fichier modules/<module>/translations/<iso>.php, remplit au passage un tableau global $_MODULE, puis part réclamer une clé bâtie à la main.
classes/Translate.php donne la recette en clair : entre accolades vient le module, puis le thème, ou à défaut le littéral prestashop, puis le basename du fichier source, un tiret bas, et tout au bout le md5 de la chaîne échappée, avec l’ensemble passé en bas de casse. Modifiez d’un signe la chaîne dans le code, et la correspondance tombe sans rien afficher à l’écran (un espace de trop suffit), et en général je retrouve ce blocage juste derrière une reprise de gabarit.
<?php
/**
* cle-legacy.php
* Calcule la cle de l'ancien systeme de traduction des modules, celle qui
* remplit le tableau $_MODULE dans modules/<module>/translations/<iso>.php.
* Formule reprise de Translate::getModuleTranslation(), classes/Translate.php.
*/
require_once __DIR__ . '/config/config.inc.php';
$module = 'monmodule'; // nom du module, donc du repertoire
$source = 'monmodule'; // basename du fichier, sans extension
$chaine = 'Ajouter au panier'; // chaine exactement telle qu'ecrite dans le code
// Le coeur echappe les apostrophes avant de hacher.
$normalisee = preg_replace("/\\\*'/", "\\'", $chaine);
$hash = md5($normalisee);
// Deux cles sont testees, dans cet ordre : celle du theme actif, puis celle
// du catalogue par defaut. Les deux sont passees en minuscules.
$cleTheme = strtolower('<{' . $module . '}' . _THEME_NAME_ . '>' . $source) . '_' . $hash;
$cleDefaut = strtolower('<{' . $module . '}prestashop>' . $source) . '_' . $hash;
echo '$_MODULE[\'' . $cleTheme . '\'] = \'Add to cart\';' . PHP_EOL;
echo '$_MODULE[\'' . $cleDefaut . '\'] = \'Add to cart\';' . PHP_EOL;
// Rappel : pour un fichier de controleur, le coeur teste en plus une variante
// ou le suffixe "controller" est retire du basename.
Reste à placer une chaîne dans l’un ou l’autre système, et pour ça je fouille le disque. Faites tourner la même recherche. Quelques secondes suffisent. Trouver la chaîne dans un fichier de module désigne l’ancien système, ne rien trouver du tout vous renvoie côté base, autrement dit vers le système moderne.
CHAINE='Ajouter au panier' # 1. Ancien systeme : tableau $_MODULE, un fichier par code de langue. grep -Rn --include='*.php' "$CHAINE" modules/*/translations/ 2>/dev/null # 2. Fichiers XLF fournis par un module pour le systeme moderne. grep -Rn --include='*.xlf' "$CHAINE" modules/*/translations/ 2>/dev/null # 3. Catalogue par defaut livre avec le coeur. # 1.7 : app/Resources/translations/default. 8 et 9 : translations/default. grep -Rln "$CHAINE" app/Resources/translations/default translations/default 2>/dev/null # 4. Traductions d'un theme. grep -Rln "$CHAINE" themes/*/translations/ 2>/dev/null # Aucun resultat alors que la chaine s'affiche traduite en front ? # C'est le cas normal du systeme moderne : la saisie back-office ne produit # aucun fichier, elle ecrit une ligne dans ps_translation.
trans() de 1.7 à 9 : la signature ne bouge pas, l’échappement si
Sur les trois branches que j’ai ouvertes, aucune retouche de signature pour Module::trans(), toujours trans($id, array $parameters = [], $domain = null, $locale = null), et la différence porte sur ce qui sort. Version 1.7.8, un paramètre legacy arrive, réglé sur htmlspecialchars. Version 8.2, la sortie se retrouve emballée dans htmlspecialchars() avec ENT_NOQUOTES, et le paramètre _raw part en dépréciation. Version 9.0, la chaîne repart brute, aucun échappement au programme.
Voilà ce que ça donne au moment de monter de version : un module jusque-là tranquille avec un chevron ou une esperluette dans sa chaîne se met à sortir du HTML interprété, et je le vois arriver à chaque fois. L’échappement retombe sur votre gabarit. Mes modules maison, je les reprends un par un sur ce point précis (une demi-journée pour une dizaine de modules), et quand le parc pèse lourd, le travail bascule en développement sur mesure. Le blog en garde d’autres récits de migration.
Questions fréquentes
Pourquoi mes traductions ne s’enregistrent-elles pas dans le back-office ?
Je commence par la base, bien avant vos permissions. TranslationService::saveTranslationMessage() met en base une entité Doctrine Translation, ce qui revient à une ligne de ps_translation. Cette ligne existe ? Alors la sauvegarde a tenu, et le blocage se joue plus loin, à la lecture, dans ce catalogue compilé rangé sous var/cache où je finis presque toujours par le dénicher.
Les droits comptent vraiment, mais ailleurs. Tant que modules/ n’est pas inscriptible, le contrôleur des traductions de modules reste porte close, et il le signale noir sur blanc. Laissez root propriétaire de votre arborescence et vous obtenez le résultat d’un mode trop serré (PHP s’exécute sous le compte du serveur web).
Comment forcer la prise en compte quand le cache bloque l’affichage ?
Jetez le dossier d’environnement sous var/cache, ou tapez php bin/console cache:clear. Un seul dossier par environnement en 1.7 comme en 8 (var/cache/dev ou var/cache/prod).
La 9 vous oblige à vous y prendre autrement. Son identifiant, le noyau l’accole au chemin, et voilà trois sous-dossiers (front, admin et admin-api). bin/console, lui, tape dans admin sans qu’on lui demande. Une purge lancée du back-office abandonne donc votre front à son vieux catalogue aussi longtemps que --app-id=front manque à l’appel.
Comment gérer correctement les domaines de traduction ?
Dans votre code, le domaine s’écrit en entier, points inclus : trans('Ajouter au panier', [], 'Modules.Monmodule.Shop') pour PHP, {l s='Ajouter au panier' d='Modules.Monmodule.Shop'} pour Smarty. La normalisation, c’est l’affaire du cœur.
Deux points partent, pas le troisième, qu’un domaine de module a parfaitement le droit de porter. Modules.Monmodule.Shop prend donc l’allure de ModulesMonmoduleShop, aussi bien en base que dans le catalogue. Voilà pourquoi interroger le SQL avec le domaine tel que vous l’avez tapé ne vous rendra jamais la moindre ligne. Cinq familles existent (pour rappel) : Install, Shop, Admin, Modules et Emails.
À quoi sert le hachage md5 dans les fichiers de langue des modules ?
À repérer une chaîne dans le système d’avant, celui que les modules signalent par mod= et non par d=. Translate::getModuleTranslation() inclut alors modules/<module>/translations/<iso>.php, le tableau global $_MODULE se remplit, et la clé attendue se monte dans la foulée.
On y trouve, dans l’ordre, le module placé entre accolades, le thème ou sinon le littéral prestashop, le basename du fichier source, et pour finir le md5 d’une chaîne aux apostrophes déjà échappées, l’ensemble basculé en minuscules. Changez un espace dans votre texte source et la correspondance ne vaut plus rien, sans un bruit. À l’écran revient la chaîne d’origine, pas d’erreur, pas de message.
Que change PrestaShop 9 pour la fonction trans() ?
La signature, elle, ne bouge pas. trans($id, array $parameters = [], $domain = null, $locale = null) reste identique en 1.7.8, en 8.2 et en 9.0. Ce qui se déplace, c’est le sort de la valeur que Module::trans() rend.
Un paramètre legacy calé sur htmlspecialchars venait se greffer en 1.7.8. Avec la 8.2, le résultat traverse htmlspecialchars() en ENT_NOQUOTES, pendant que _raw tombe en dépréciation. La 9.0 ressort la chaîne intacte. Votre module s’appuyait sur l’échappement implicite ? Il devra s’en charger seul une fois la migration passée, faute de quoi du HTML se glissera dans une chaîne traduite. Repasser vos appels trans() un par un règle l’affaire en général, et je réponds toujours depuis la page contact.