add_lang_key.php 16 KB

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