German back office: hotel modules, reports, dashboard and menu names

The German language pack leaves the Webkul hotel modules (Hotel
Reservation System, Book Now, refunds, rooms, settings), reports,
dashboard widgets, cron manager, channel manager, reviews and 31 menu
names in English.

- docker/find-missing-admin-translations.php: scans module admin
  controllers/classes/templates and core admin pages for keys without
  a German text
- docker/translations-de-admin.php: 1361 translations (formal "Sie",
  shared glossary, placeholders/markup verified automatically)
- docker/translations-de-tabs.php: menu names (Hotelverwaltung, Zimmer
  buchen, Zimmerstatus, Erstattungsanträge, Zeitgesteuerte Aufgaben ...)
- apply-translations.php now also writes back-office module texts,
  core admin texts and menu names; module texts the core admin pack
  already translates are reused from it (modules do not fall back to
  the admin pack at runtime). Result locally: 0 untranslated strings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
DanielS
2026-11-12 00:41:51 +01:00
parent f3b1838402
commit 661cf645d4
4 changed files with 1664 additions and 29 deletions

View File

@@ -1,38 +1,41 @@
<?php
/**
* Writes missing front-office translations into modules/<module>/translations/<iso>.php.
* Usage: docker compose exec -u www-data app php docker/apply-translations.php [iso]
* Reads the dictionary docker/translations-<iso>-front.php (English => target language)
* and only fills keys that are missing; existing translations are never overwritten.
* Fills translation gaps the official language pack leaves (shop, back office, menu names).
* docker compose exec -u www-data app php docker/apply-translations.php [iso]
*
* Dictionaries (English => target language), only missing keys are written, existing
* translations are never overwritten:
* docker/translations-<iso>-front.php shop texts (scanner: find-missing-translations.php)
* docker/translations-<iso>-admin.php back-office texts (scanner: find-missing-admin-translations.php)
* docker/translations-<iso>-tabs.php menu names (tab class => name), English names only
* Module texts go to modules/<module>/translations/<iso>.php, core admin texts to
* translations/<iso>/admin.php (both git-ignored upstream: re-run after every install).
*/
if (PHP_SAPI !== 'cli') {
exit;
}
$iso = isset($argv[1]) ? $argv[1] : 'de';
$root = realpath(__DIR__.'/..');
$dict = require __DIR__.'/translations-'.$iso.'-front.php';
$json = shell_exec('php '.escapeshellarg(__DIR__.'/find-missing-translations.php').' '.escapeshellarg($iso).' 2>/dev/null');
$missing = json_decode($json, true);
if (!is_array($missing)) {
fwrite(STDERR, "Could not read missing translations\n");
exit(1);
function loadDict($file)
{
return is_file($file) ? require $file : array();
}
$added = 0;
$untranslated = array();
foreach ($missing['modules'] as $mod => $entries) {
$lines = array();
foreach ($entries as $key => $english) {
if (!isset($dict[$english])) {
$untranslated[$mod][] = $english;
continue;
}
$lines[] = '$_MODULE['.var_export($key, true).'] = '.var_export($dict[$english], true).';';
}
if (!$lines) {
continue;
function scan($script, $iso)
{
$json = shell_exec('php '.escapeshellarg(__DIR__.'/'.$script).' '.escapeshellarg($iso).' 2>/dev/null');
$data = json_decode((string)$json, true);
if (!is_array($data)) {
fwrite(STDERR, "Could not run $script\n");
exit(1);
}
return $data;
}
/** Append $_MODULE lines before a trailing "return $_MODULE;" if the pack file has one. */
function appendModuleLines($root, $mod, $iso, array $lines)
{
$file = $root.'/modules/'.$mod.'/translations/'.$iso.'.php';
if (!is_file($file)) {
@mkdir(dirname($file), 0755, true);
@@ -42,7 +45,6 @@ foreach ($missing['modules'] as $mod => $entries) {
if (substr($content, -2) === '?>') {
$content = rtrim(substr($content, 0, -2));
}
// Some language-pack files end with "return $_MODULE;" - new lines must go before it.
$hasReturn = (bool)preg_match('/\n\s*return \$_MODULE;\s*$/', $content);
if ($hasReturn) {
$content = rtrim(preg_replace('/\n\s*return \$_MODULE;\s*$/', '', $content));
@@ -52,11 +54,40 @@ foreach ($missing['modules'] as $mod => $entries) {
$content .= "\nreturn \$_MODULE;\n";
}
file_put_contents($file, $content);
}
function applyModules($root, $iso, array $missing, array $dict, &$untranslated)
{
$added = 0;
foreach ($missing as $mod => $entries) {
$lines = array();
foreach ($entries as $key => $english) {
if (!isset($dict[$english]) || !is_string($dict[$english])) {
$untranslated[] = $english;
continue;
}
$lines[] = '$_MODULE['.var_export($key, true).'] = '.var_export($dict[$english], true).';';
}
if ($lines) {
appendModuleLines($root, $mod, $iso, $lines);
$added += count($lines);
echo sprintf(" %-26s +%d\n", $mod, count($lines));
}
}
return $added;
}
$untranslated = array();
echo "Shop and module templates\n";
$front = loadDict(__DIR__.'/translations-'.$iso.'-front.php');
$admin = loadDict(__DIR__.'/translations-'.$iso.'-admin.php');
$missing = scan('find-missing-translations.php', $iso);
// the template scanner also finds dashboard/report modules: their texts live in the admin dictionary
$added = applyModules($root, $iso, $missing['modules'], $front + $admin, $untranslated);
// Theme strings the language pack "translates" with English text: replace those values.
$themeFixes = isset($dict['__theme_value_fixes']) ? $dict['__theme_value_fixes'] : array();
$themeFixes = isset($front['__theme_value_fixes']) ? $front['__theme_value_fixes'] : array();
$themeFile = $root.'/themes/hotel-reservation-theme/lang/'.$iso.'.php';
if ($themeFixes && is_file($themeFile)) {
$c = file_get_contents($themeFile);
@@ -69,6 +100,68 @@ if ($themeFixes && is_file($themeFile)) {
echo " theme lang fixes $n\n";
}
if ($admin) {
echo "Back office\n";
$missingAdmin = scan('find-missing-admin-translations.php', $iso);
// texts the core admin pack already translates (same English text): reuse its German value
$_LANGADM = array();
if (is_file($root.'/translations/'.$iso.'/admin.php')) {
include $root.'/translations/'.$iso.'/admin.php';
}
$byMd5 = array();
foreach ($_LANGADM as $k => $v) {
if ($v !== '') {
$byMd5[substr($k, -32)] = $v;
}
}
$packDict = array();
foreach ($missingAdmin['modules'] as $entries) {
foreach ($entries as $english) {
if (!isset($admin[$english]) && isset($byMd5[md5($english)])) {
$packDict[$english] = $byMd5[md5($english)];
}
}
}
$added += applyModules($root, $iso, $missingAdmin['modules'], $admin + $packDict, $untranslated);
$lines = array();
foreach ($missingAdmin['core'] as $key => $english) {
if (isset($admin[$english])) {
$lines[] = '$_LANGADM['.var_export($key, true).'] = '.var_export($admin[$english], true).';';
} else {
$untranslated[] = $english;
}
}
if ($lines) {
$file = $root.'/translations/'.$iso.'/admin.php';
$content = is_file($file) ? rtrim(file_get_contents($file)) : "<?php\n\nglobal \$_LANGADM;\n\$_LANGADM = array();";
$content = preg_replace('/\?>\s*$/', '', $content);
file_put_contents($file, rtrim($content)."\n\n// Added by docker/apply-translations.php\n".implode("\n", $lines)."\n");
$added += count($lines);
echo sprintf(" %-26s +%d\n", 'core admin', count($lines));
}
}
$tabs = loadDict(__DIR__.'/translations-'.$iso.'-tabs.php');
if ($tabs) {
echo "Menu\n";
define('_PS_ADMIN_DIR_', __DIR__);
require_once $root.'/config/config.inc.php';
$idLang = (int)Language::getIdByIso($iso);
$n = 0;
foreach ($tabs as $class => $name) {
$idTab = (int)Tab::getIdFromClassName($class);
if (!$idTab || !$idLang) {
continue;
}
$current = (string)Db::getInstance()->getValue('SELECT `name` FROM `'._DB_PREFIX_.'tab_lang` WHERE `id_tab` = '.$idTab.' AND `id_lang` = '.$idLang);
// only replace names that are still the English default (leave own renames alone)
if ($current !== $name && preg_match('/^[\x20-\x7E]*$/', $current) && !preg_match('/(ung|en|e)$/', $current)) {
Db::getInstance()->update('tab_lang', array('name' => pSQL($name)), '`id_tab` = '.$idTab.' AND `id_lang` = '.$idLang);
$n++;
}
}
echo " menu names $n\n";
}
echo "Added $added translations.\n";
$skip = array_sum(array_map('count', $untranslated));
echo "Not in dictionary: $skip (mostly back-office modules)\n";
echo 'Not in dictionary: '.count(array_unique($untranslated))."\n";

View File

@@ -0,0 +1,134 @@
<?php
/**
* Lists back-office strings without a German translation (iso arg, default de).
* docker compose ... exec -u www-data app php docker/find-missing-admin-translations.php de > missing.json
* Covers the hotel modules' admin controllers/classes/templates and the core admin pages
* the reception works with (orders, customers, carts, guest registration).
* Output: {"modules": {mod: {key: english}}, "core": {key: english}}
*/
if (PHP_SAPI !== 'cli') {
exit;
}
$iso = isset($argv[1]) ? $argv[1] : 'de';
$root = realpath(__DIR__.'/..');
$modules = array('hotelreservationsystem', 'qlohotelreview', 'qlocmconnector', 'wkroomsearchblock', 'blocknavigationmenu', 'qlocrontaskmanager');
$coreControllers = array('AdminOrders', 'AdminCustomers', 'AdminCarts', 'AdminGuestRegistration', 'AdminAddresses', 'AdminDashboard', 'AdminLogin');
$coreTplDirs = array('orders', 'customers', 'carts', 'guest_registration', 'addresses', 'dashboard', 'login');
$rePhp = '/->l\(\s*\'((?:[^\'\\\\]|\\\\.)*)\'(?:\s*,\s*\'([^\']*)\')?/';
$reTpl = '/\{l\s+s=(?:\'((?:[^\'\\\\]|\\\\.)*)\'|"((?:[^"\\\\]|\\\\.)*)")(?:[^}]*?\smod=[\'"]([\w-]+)[\'"])?[^}]*\}/';
function files($dir, $ext)
{
$out = array();
if (!is_dir($dir)) {
return $out;
}
$it = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($dir, FilesystemIterator::SKIP_DOTS));
foreach ($it as $f) {
if (substr($f, -strlen($ext)) === $ext) {
$out[] = (string)$f;
}
}
return $out;
}
$out = array('modules' => array(), 'core' => array());
// core admin pack: module strings fall back to any admin translation with the same md5
$_LANGADM = array();
if (is_file("$root/translations/$iso/admin.php")) {
include "$root/translations/$iso/admin.php";
}
$haveMd5 = array();
foreach ($_LANGADM as $k => $v) {
if ($v !== '') {
$haveMd5[substr($k, -32)] = true;
}
}
// ---- modules
foreach ($modules as $mod) {
$_MODULE = array();
foreach (array("$root/modules/$mod/translations/$iso.php", "$root/themes/hotel-reservation-theme/modules/$mod/translations/$iso.php") as $f) {
if (is_file($f)) {
include $f;
}
}
$have = $_MODULE;
$add = function ($source, $string) use (&$out, $have, $mod, $haveMd5) {
$key = strtolower('<{'.$mod.'}prestashop>'.$source).'_'.md5($string);
// module strings do NOT fall back to the admin pack at runtime: report them all
if (empty($have[$key])) {
$out['modules'][$mod][$key] = $string;
}
};
foreach (files("$root/modules/$mod", '.php') as $f) {
if (strpos($f, '/translations/') !== false || strpos($f, '/vendor/') !== false) {
continue;
}
if (!preg_match_all($rePhp, file_get_contents($f), $m, PREG_SET_ORDER)) {
continue;
}
$base = basename($f, '.php');
foreach ($m as $x) {
$string = stripslashes($x[1]);
if (!empty($x[2])) {
$source = $x[2];
} elseif (preg_match('/Controller$/', $base) && strpos($f, '/controllers/admin/') !== false) {
$source = $base; // ModuleAdminController -> "<class>controller"
} elseif (strpos($f, '/controllers/') !== false) {
$source = preg_replace('/\.php$/', '', basename($f)); // front controller file name
} else {
$source = $mod;
}
$add($source, $string);
}
}
foreach (files("$root/modules/$mod/views/templates/admin", '.tpl') as $f) {
if (!preg_match_all($reTpl, file_get_contents($f), $m, PREG_SET_ORDER)) {
continue;
}
foreach ($m as $x) {
$string = stripslashes($x[1] !== '' ? $x[1] : $x[2]);
$add(basename($f, '.tpl'), $string);
}
}
}
// ---- core admin
$addCore = function ($class, $string) use (&$out, $_LANGADM, $haveMd5) {
$md5 = md5($string);
if (empty($_LANGADM[$class.$md5]) && empty($haveMd5[$md5])) {
$out['core'][$class.$md5] = $string;
}
};
foreach ($coreControllers as $c) {
$f = "$root/controllers/admin/{$c}Controller.php";
if (is_file($f) && preg_match_all($rePhp, file_get_contents($f), $m, PREG_SET_ORDER)) {
foreach ($m as $x) {
$addCore($c, stripslashes($x[1]));
}
}
}
foreach ($coreTplDirs as $i => $dir) {
$class = isset($coreControllers[$i]) ? $coreControllers[$i] : 'AdminTab';
foreach (files("$root/admin-dev/themes/default/template/controllers/$dir", '.tpl') as $f) {
if (preg_match_all($reTpl, file_get_contents($f), $m, PREG_SET_ORDER)) {
foreach ($m as $x) {
if (!empty($x[3])) {
continue; // module string inside a core template
}
$addCore($class, stripslashes($x[1] !== '' ? $x[1] : $x[2]));
}
}
}
}
$counts = array('core' => count($out['core']));
foreach ($out['modules'] as $mod => $s) {
$counts[$mod] = count($s);
}
fwrite(STDERR, json_encode($counts).PHP_EOL);
echo json_encode($out, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,39 @@
<?php
/**
* German back-office menu names (tab class => name) for entries the language pack leaves
* in English. Applied by docker/apply-translations.php (only replaces English names).
*/
return array(
'AdminHotelReservationSystemManagement' => 'Hotelverwaltung',
'AdminHotelRoomsBooking' => 'Zimmer buchen',
'AdminAddHotel' => 'Hotel verwalten',
'AdminHotelPropertyTypes' => 'Unterkunftsarten',
'AdminHotelAmenities' => 'Ausstattung',
'AdminOrderRefundRules' => 'Stornoregeln',
'AdminOrderRefundRequests' => 'Erstattungsanträge',
'AdminHotelConfigurationSetting' => 'Einstellungen',
'AdminHotelGeneralSettings' => 'Allgemeine Hoteleinstellungen',
'AdminHotelFeaturePricesSettings' => 'Preisregeln',
'AdminHotelHeaderImage' => 'Titelbilder',
'AdminBookingDocument' => 'Buchungsdokumente',
'AdminHotelImageCategory' => 'Bildkategorien',
'AdminRoomStatuses' => 'Zimmerstatus',
'AdminHotelRoomModuleSetting' => 'Zimmeranzeige auf der Startseite',
'AdminAboutHotelBlockSetting' => 'Hotelbeschreibung',
'AdminFeaturesModuleSetting' => 'Ausstattung auf der Startseite',
'AdminTestimonialsModuleSetting' => 'Gästestimmen',
'AdminParentHotelReview' => 'Hotelbewertungen',
'AdminHotelReviewHotelReview' => 'Bewertungen',
'AdminHotelReviewCategory' => 'Einstellungen',
'AdminFooterPaymentBlockSetting' => 'Zahlungsarten im Fußbereich',
'AdminCustomNavigationLinkSetting' => 'Navigationslinks',
'AdminDashgoals' => 'Ziele',
'AdminCronTaskManager' => 'Zeitgesteuerte Aufgaben',
'AdminCronTaskLogs' => 'Protokoll zeitgesteuerter Aufgaben',
'AdminQloappsChannelManagerConnector' => 'Channel Manager',
'AdminGoogleHotelParent' => 'Google Hotel',
'AdminGoogleHotelConfig' => 'Einstellungen',
'AdminGoogleHotelLogs' => 'API-Protokoll',
'AdminModulesCatalog' => 'Modulkatalog',
'AdminNewsletter' => 'Einstellungen',
);