add_lang_key.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562
  1. <?php
  2. /**
  3. * Add a missing language key for en or ru.
  4. *
  5. * The string is written into the module language XML (skipped when that
  6. * key is already there) and into the database, then the language cache
  7. * is recompiled. An existing translation is left unchanged.
  8. *
  9. * Usage:
  10. * php scripts/add_lang_key.php <en|ru> <key> <translation>
  11. * php scripts/add_lang_key.php <en|ru> <key> - <<'EOF'
  12. * line one
  13. * line two
  14. * EOF
  15. *
  16. * A translation of "-" is read from stdin. A single trailing newline on
  17. * stdin is removed; newlines inside the text are kept.
  18. *
  19. * When the key prefix doesn't name the module (bx_organizations uses _bx_orgs_),
  20. * set ADD_LANG_KEY_MODULE=<module name>.
  21. *
  22. * Run inside the php container:
  23. * docker exec -i una-php-1 php /opt/una/scripts/add_lang_key.php en _sys_example "Hello"
  24. */
  25. if (PHP_SAPI !== 'cli') {
  26. fwrite(STDERR, "CLI only\n");
  27. exit(1);
  28. }
  29. $sLang = isset($argv[1]) ? strtolower($argv[1]) : '';
  30. $sKey = isset($argv[2]) ? $argv[2] : '';
  31. $bFromStdin = !isset($argv[3]) || $argv[3] === '-';
  32. if (!in_array($sLang, array('en', 'ru'), true) || $sKey === '' || isset($argv[4])) {
  33. fwrite(STDERR, usage());
  34. exit(2);
  35. }
  36. if (strpbrk($sKey, "\r\n") !== false) {
  37. fwrite(STDERR, "Key must be a single line\n");
  38. exit(2);
  39. }
  40. if ($bFromStdin) {
  41. $sTranslation = stream_get_contents(STDIN);
  42. if ($sTranslation === false)
  43. $sTranslation = '';
  44. if (substr($sTranslation, -2) === "\r\n")
  45. $sTranslation = substr($sTranslation, 0, -2);
  46. else if (substr($sTranslation, -1) === "\n")
  47. $sTranslation = substr($sTranslation, 0, -1);
  48. }
  49. else {
  50. $sTranslation = $argv[3];
  51. }
  52. if ($sTranslation === '') {
  53. fwrite(STDERR, "Translation is empty\n");
  54. exit(2);
  55. }
  56. if (function_exists('mb_check_encoding') && !mb_check_encoding($sTranslation, 'UTF-8')) {
  57. fwrite(STDERR, "Translation is not valid UTF-8\n");
  58. exit(2);
  59. }
  60. $sRoot = dirname(__DIR__) . '/';
  61. $aModules = loadModules($sRoot);
  62. $aLangModules = loadLanguageModules($sRoot);
  63. // ADD_LANG_KEY_MODULE=<module name> picks the module when the key prefix can't tell
  64. // (e.g. _bx_orgs_ keys belong to bx_organizations).
  65. $sForced = getenv('ADD_LANG_KEY_MODULE');
  66. if ($sForced !== false && $sForced !== '') {
  67. if (!isset($aModules[$sForced])) {
  68. fwrite(STDERR, "Unknown module '$sForced'\n");
  69. exit(2);
  70. }
  71. $aModule = $aModules[$sForced];
  72. }
  73. else {
  74. $aModule = findModuleByExistingKey($sRoot, $sKey, $aModules);
  75. if (!$aModule)
  76. $aModule = inferModule($sKey, $aModules);
  77. }
  78. $sFile = resolveLangFile($sRoot, $sLang, $aModule, $aLangModules);
  79. $sRel = ltrim(str_replace('\\', '/', substr($sFile, strlen($sRoot))), '/');
  80. $sExisting = is_file($sFile) ? readLangString($sFile, $sKey) : null;
  81. if ($sExisting !== null) {
  82. echo "xml: already present in $sRel\n";
  83. if ($sExisting !== $sTranslation)
  84. echo "kept the translation already in the language file\n";
  85. $sStore = $sExisting;
  86. }
  87. else {
  88. if (!writeLangString($sFile, $sLang, $sKey, $sTranslation)) {
  89. fwrite(STDERR, "Failed to write $sRel\n");
  90. exit(1);
  91. }
  92. echo "xml: added to $sRel\n";
  93. $sStore = $sTranslation;
  94. }
  95. if (dbAddMissing($sLang, $sKey, $sStore, $aModule['language_category']) === false)
  96. exit(1);
  97. exit(0);
  98. function usage()
  99. {
  100. return <<<TXT
  101. Usage: php add_lang_key.php <en|ru> <key> <translation>
  102. php add_lang_key.php <en|ru> <key> - (translation from stdin, may span lines)
  103. Adds the key only when that language does not already have it.
  104. TXT;
  105. }
  106. function loadModules($sRoot)
  107. {
  108. $a = array(
  109. 'system' => array(
  110. 'name' => 'system',
  111. 'home_dir' => 'system/',
  112. 'language_category' => 'System',
  113. ),
  114. );
  115. foreach (glob($sRoot . 'modules/*/*/install/config.php') as $sConfig) {
  116. $s = file_get_contents($sConfig);
  117. if ($s === false)
  118. continue;
  119. $sName = configValue($s, 'name');
  120. $sHome = configValue($s, 'home_dir');
  121. if ($sName === null || $sHome === null || $sName === '')
  122. continue;
  123. $sCategory = configValue($s, 'language_category');
  124. if ($sCategory === null || $sCategory === '')
  125. $sCategory = $sName;
  126. $a[$sName] = array(
  127. 'name' => $sName,
  128. 'home_dir' => rtrim($sHome, '/') . '/',
  129. 'language_category' => $sCategory,
  130. );
  131. }
  132. return $a;
  133. }
  134. function loadLanguageModules($sRoot)
  135. {
  136. $a = array(
  137. 'en' => array('home_dir' => 'boonex/english/'),
  138. 'ru' => array('home_dir' => 'boonex/russian/'),
  139. );
  140. foreach (glob($sRoot . 'modules/*/*/install/config.php') as $sConfig) {
  141. $s = file_get_contents($sConfig);
  142. if ($s === false)
  143. continue;
  144. $sUri = configValue($s, 'home_uri');
  145. $sHome = configValue($s, 'home_dir');
  146. if (($sUri !== 'en' && $sUri !== 'ru') || $sHome === null)
  147. continue;
  148. $a[$sUri] = array('home_dir' => rtrim($sHome, '/') . '/');
  149. }
  150. return $a;
  151. }
  152. function configValue($sSource, $sKey)
  153. {
  154. $sPattern = '/[\'"]' . preg_quote($sKey, '/') . '[\'"]\s*=>\s*[\'"]([^\'"]*)[\'"]/';
  155. if (!preg_match($sPattern, $sSource, $aMatch))
  156. return null;
  157. return $aMatch[1];
  158. }
  159. function findModuleByExistingKey($sRoot, $sKey, $aModules)
  160. {
  161. $aFound = array();
  162. $aFiles = array_merge(
  163. glob($sRoot . 'modules/*/*/install/langs/*.xml') ?: array(),
  164. glob($sRoot . 'modules/*/*/data/langs/*/*.xml') ?: array()
  165. );
  166. foreach ($aFiles as $sFile) {
  167. if (!fileHasKey($sFile, $sKey))
  168. continue;
  169. $sNorm = str_replace('\\', '/', $sFile);
  170. $aModule = null;
  171. if (preg_match('#/data/langs/([^/]+)/[^/]+\.xml$#', $sNorm, $aMatch) && isset($aModules[$aMatch[1]]))
  172. $aModule = $aModules[$aMatch[1]];
  173. else if (preg_match('#/modules/(.+)/install/langs/[^/]+\.xml$#', $sNorm, $aMatch)) {
  174. $sHome = $aMatch[1] . '/';
  175. foreach ($aModules as $aCandidate) {
  176. if ($aCandidate['home_dir'] === $sHome) {
  177. $aModule = $aCandidate;
  178. break;
  179. }
  180. }
  181. }
  182. if ($aModule)
  183. $aFound[$aModule['name']] = $aModule;
  184. }
  185. if (!$aFound)
  186. return null;
  187. if (isset($aFound['system']) && count($aFound) > 1)
  188. unset($aFound['system']);
  189. return reset($aFound);
  190. }
  191. function inferModule($sKey, $aModules)
  192. {
  193. $sBare = ltrim($sKey, '_');
  194. $aNames = array_keys($aModules);
  195. usort($aNames, function ($sA, $sB) {
  196. return strlen($sB) - strlen($sA);
  197. });
  198. foreach ($aNames as $sName) {
  199. if ($sName === 'system')
  200. continue;
  201. if ($sBare === $sName || strpos($sBare, $sName . '_') === 0)
  202. return $aModules[$sName];
  203. }
  204. return $aModules['system'];
  205. }
  206. function resolveLangFile($sRoot, $sLang, $aModule, $aLangModules)
  207. {
  208. $sPack = $sRoot . 'modules/' . $aLangModules[$sLang]['home_dir'] . 'data/langs/' . $aModule['name'] . '/' . $sLang . '.xml';
  209. if (is_file($sPack))
  210. return $sPack;
  211. if ($aModule['name'] !== 'system') {
  212. $sInstall = $sRoot . 'modules/' . $aModule['home_dir'] . 'install/langs/' . $sLang . '.xml';
  213. if (is_file($sInstall) || $sLang === 'en')
  214. return $sInstall;
  215. }
  216. return $sPack;
  217. }
  218. function fileHasKey($sPath, $sKey)
  219. {
  220. return readLangString($sPath, $sKey) !== null;
  221. }
  222. function readLangString($sPath, $sKey)
  223. {
  224. $s = @file_get_contents($sPath);
  225. if ($s === false)
  226. return null;
  227. $aVariants = array_unique(array($sKey, htmlspecialchars($sKey, ENT_QUOTES | ENT_XML1, 'UTF-8')));
  228. foreach ($aVariants as $sVariant) {
  229. $sPattern = '/<string\b[^>]*\bname=(["\'])' . preg_quote($sVariant, '/') . '\\1[^>]*>(.*?)<\/string>/s';
  230. if (!preg_match($sPattern, $s, $aMatch))
  231. continue;
  232. $sInner = $aMatch[2];
  233. if (preg_match('/^\s*<!\[CDATA\[(.*)\]\]>\s*$/s', $sInner, $aCdata))
  234. return str_replace(']]]]><![CDATA[>', ']]>', $aCdata[1]);
  235. return html_entity_decode($sInner, ENT_QUOTES | ENT_XML1, 'UTF-8');
  236. }
  237. return null;
  238. }
  239. function writeLangString($sFile, $sLang, $sKey, $sTranslation)
  240. {
  241. $aMeta = array(
  242. 'en' => array('flag' => 'gb', 'title' => 'English'),
  243. 'ru' => array('flag' => 'ru', 'title' => 'Russian'),
  244. );
  245. $sNl = "\n";
  246. if (is_file($sFile)) {
  247. $sXml = file_get_contents($sFile);
  248. if ($sXml === false)
  249. return false;
  250. if (strpos($sXml, "\r\n") !== false)
  251. $sNl = "\r\n";
  252. }
  253. else {
  254. $sDir = dirname($sFile);
  255. if (!is_dir($sDir) && !mkdir($sDir, 0775, true))
  256. return false;
  257. $sXml = '<?xml version="1.0" encoding="utf-8"?>' . $sNl
  258. . '<resources name="' . $sLang . '" flag="' . $aMeta[$sLang]['flag'] . '" title="' . $aMeta[$sLang]['title'] . '">' . $sNl
  259. . '</resources>' . $sNl;
  260. }
  261. $iPos = insertionOffset($sXml, $sKey);
  262. if ($iPos === false)
  263. return false;
  264. $sIndent = indentAt($sXml, $iPos);
  265. $sLine = $sIndent . '<string name="' . htmlspecialchars($sKey, ENT_QUOTES | ENT_XML1, 'UTF-8') . '"><![CDATA[' . cdata($sTranslation) . ']]></string>' . $sNl;
  266. if ($iPos > 0 && $sXml[$iPos - 1] !== "\n")
  267. $sLine = $sNl . $sLine;
  268. $sXml = substr($sXml, 0, $iPos) . $sLine . substr($sXml, $iPos);
  269. return file_put_contents($sFile, $sXml, LOCK_EX) !== false;
  270. }
  271. /**
  272. * Byte offset where $sKey should be inserted: after the closest earlier
  273. * sibling, or before </resources> when nothing shares a meaningful prefix.
  274. * Sibling groups stay intact, so a new key lands beside a family rather
  275. * than between two keys that belong together.
  276. */
  277. function insertionOffset($sXml, $sKey)
  278. {
  279. $iFallback = strrpos($sXml, '</resources>');
  280. if ($iFallback === false)
  281. return false;
  282. $aEntries = langStringEntries($sXml);
  283. if (!$aEntries)
  284. return $iFallback;
  285. $iBest = 0;
  286. foreach ($aEntries as $aEntry) {
  287. $iScore = commonNonEmptySegments($sKey, $aEntry['name']);
  288. if ($iScore > $iBest)
  289. $iBest = $iScore;
  290. }
  291. if ($iBest < 2)
  292. return $iFallback;
  293. $aClose = array();
  294. foreach ($aEntries as $i => $aEntry) {
  295. if (commonNonEmptySegments($sKey, $aEntry['name']) === $iBest)
  296. $aClose[$i] = $aEntry;
  297. }
  298. $iPred = null;
  299. $sPredName = null;
  300. foreach ($aClose as $i => $aEntry) {
  301. if (strcmp($aEntry['name'], $sKey) < 0 && ($sPredName === null || strcmp($aEntry['name'], $sPredName) > 0)) {
  302. $iPred = $i;
  303. $sPredName = $aEntry['name'];
  304. }
  305. }
  306. if ($iPred !== null)
  307. return clusterEdge($aEntries, $iPred, $iBest, false);
  308. $iSucc = null;
  309. $sSuccName = null;
  310. foreach ($aClose as $i => $aEntry) {
  311. if (strcmp($aEntry['name'], $sKey) > 0 && ($sSuccName === null || strcmp($aEntry['name'], $sSuccName) < 0)) {
  312. $iSucc = $i;
  313. $sSuccName = $aEntry['name'];
  314. }
  315. }
  316. if ($iSucc !== null)
  317. return clusterEdge($aEntries, $iSucc, $iBest, true);
  318. return $iFallback;
  319. }
  320. function clusterEdge($aEntries, $iAnchor, $iMinScore, $bStart)
  321. {
  322. $sAnchor = $aEntries[$iAnchor]['name'];
  323. $iEdge = $iAnchor;
  324. if ($bStart) {
  325. for ($i = $iAnchor - 1; $i >= 0; $i--) {
  326. if (commonNonEmptySegments($sAnchor, $aEntries[$i]['name']) <= $iMinScore)
  327. break;
  328. $iEdge = $i;
  329. }
  330. return $aEntries[$iEdge]['start'];
  331. }
  332. $iCount = count($aEntries);
  333. for ($i = $iAnchor + 1; $i < $iCount; $i++) {
  334. if (commonNonEmptySegments($sAnchor, $aEntries[$i]['name']) <= $iMinScore)
  335. break;
  336. $iEdge = $i;
  337. }
  338. return $aEntries[$iEdge]['end'];
  339. }
  340. function langStringEntries($sXml)
  341. {
  342. $aEntries = array();
  343. $sPattern = '/^([ \t]*)<string\b[^>]*\bname=(["\'])(.*?)\2[^>]*>.*?<\/string>[ \t]*(?:\r?\n)?/ms';
  344. if (!preg_match_all($sPattern, $sXml, $aMatches, PREG_OFFSET_CAPTURE))
  345. return $aEntries;
  346. foreach ($aMatches[0] as $i => $aFull) {
  347. $aEntries[] = array(
  348. 'name' => html_entity_decode($aMatches[3][$i][0], ENT_QUOTES | ENT_XML1, 'UTF-8'),
  349. 'indent' => $aMatches[1][$i][0],
  350. 'start' => $aFull[1],
  351. 'end' => $aFull[1] + strlen($aFull[0]),
  352. );
  353. }
  354. return $aEntries;
  355. }
  356. function commonNonEmptySegments($sA, $sB)
  357. {
  358. $aA = explode('_', $sA);
  359. $aB = explode('_', $sB);
  360. $n = min(count($aA), count($aB));
  361. $iNonEmpty = 0;
  362. for ($i = 0; $i < $n; $i++) {
  363. if ($aA[$i] !== $aB[$i])
  364. break;
  365. if ($aA[$i] !== '')
  366. $iNonEmpty++;
  367. }
  368. return $iNonEmpty;
  369. }
  370. function indentAt($sXml, $iPos)
  371. {
  372. $aEntries = langStringEntries($sXml);
  373. $sIndent = ' ';
  374. foreach ($aEntries as $aEntry) {
  375. if ($aEntry['start'] <= $iPos)
  376. $sIndent = $aEntry['indent'];
  377. if ($aEntry['start'] >= $iPos)
  378. return $aEntry['indent'] !== '' ? $aEntry['indent'] : $sIndent;
  379. }
  380. return $sIndent;
  381. }
  382. function cdata($s)
  383. {
  384. return str_replace(']]>', ']]]]><![CDATA[>', $s);
  385. }
  386. function dbAddMissing($sLang, $sKey, $sTranslation, $sCategory)
  387. {
  388. bootUna();
  389. $oDb = BxDolDb::getInstance();
  390. $aLang = $oDb->getRow($oDb->prepare("SELECT `ID` AS `id` FROM `sys_localization_languages` WHERE `Name` = ? LIMIT 1", $sLang));
  391. if (!$aLang) {
  392. fwrite(STDERR, "Language '$sLang' is not installed in the database\n");
  393. return false;
  394. }
  395. $iLangId = (int)$aLang['id'];
  396. $aKey = $oDb->getRow($oDb->prepare("SELECT `ID` AS `id` FROM `sys_localization_keys` WHERE `Key` = ? LIMIT 1", $sKey));
  397. if (!$aKey) {
  398. $iCategoryId = categoryId($oDb, $sCategory);
  399. if (!$iCategoryId) {
  400. fwrite(STDERR, "Could not resolve language category '$sCategory'\n");
  401. return false;
  402. }
  403. $iInserted = (int)$oDb->query($oDb->prepare(
  404. "INSERT INTO `sys_localization_keys` (`IDCategory`, `Key`) VALUES (?, ?)",
  405. $iCategoryId,
  406. $sKey
  407. ));
  408. if ($iInserted <= 0) {
  409. fwrite(STDERR, "Could not insert language key\n");
  410. return false;
  411. }
  412. $iKeyId = (int)$oDb->lastId();
  413. echo "db: created key $iKeyId in '$sCategory'\n";
  414. }
  415. else {
  416. $iKeyId = (int)$aKey['id'];
  417. }
  418. $aString = $oDb->getRow($oDb->prepare(
  419. "SELECT `IDKey` AS `id` FROM `sys_localization_strings` WHERE `IDKey` = ? AND `IDLanguage` = ? LIMIT 1",
  420. $iKeyId,
  421. $iLangId
  422. ));
  423. if ($aString) {
  424. echo "db: already present for $sLang\n";
  425. return true;
  426. }
  427. $iInserted = (int)$oDb->query($oDb->prepare(
  428. "INSERT INTO `sys_localization_strings` (`IDKey`, `IDLanguage`, `String`) VALUES (?, ?, ?)",
  429. $iKeyId,
  430. $iLangId,
  431. $sTranslation
  432. ));
  433. if ($iInserted <= 0) {
  434. fwrite(STDERR, "Could not insert translation\n");
  435. return false;
  436. }
  437. bx_import('BxDolStudioLanguagesUtils');
  438. BxDolLanguages::getInstance();
  439. if (!BxDolStudioLanguagesUtils::getInstance()->compileLanguage($iLangId, true)) {
  440. fwrite(STDERR, "Translation stored, but the language cache was not recompiled\n");
  441. return false;
  442. }
  443. echo "db: added translation for $sLang and recompiled\n";
  444. return true;
  445. }
  446. function categoryId($oDb, $sCategory)
  447. {
  448. $aCategory = $oDb->getRow($oDb->prepare(
  449. "SELECT `ID` AS `id` FROM `sys_localization_categories` WHERE `Name` = ? LIMIT 1",
  450. $sCategory
  451. ));
  452. if ($aCategory)
  453. return (int)$aCategory['id'];
  454. $iInserted = (int)$oDb->query($oDb->prepare(
  455. "INSERT INTO `sys_localization_categories` (`Name`) VALUES (?)",
  456. $sCategory
  457. ));
  458. if ($iInserted <= 0)
  459. return 0;
  460. return (int)$oDb->lastId();
  461. }
  462. function bootUna()
  463. {
  464. $GLOBALS['bx_profiler_disable'] = true;
  465. if (!defined('BX_DOL_CRON_EXECUTE'))
  466. define('BX_DOL_CRON_EXECUTE', '1');
  467. $_ENV['UNA_SKIP_REDIRECT'] = '1';
  468. $_ENV['UNA_SKIP_MINIMAL_REQUIREMENTS'] = '1';
  469. $_ENV['UNA_SKIP_INSTALL_FOLDER_CHECK'] = '1';
  470. if (empty($_SERVER['HTTP_HOST'])) {
  471. $_SERVER['HTTP_HOST'] = 'hihi.com';
  472. $_SERVER['REQUEST_URI'] = '/una/';
  473. $_SERVER['SERVER_NAME'] = 'hihi.com';
  474. $_SERVER['REMOTE_ADDR'] = '127.0.0.1';
  475. }
  476. require_once dirname(__DIR__) . '/inc/header.inc.php';
  477. require_once BX_DIRECTORY_PATH_INC . 'design.inc.php';
  478. }