add_lang_key.php 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919
  1. <?php
  2. /**
  3. * UNA language-key MCP server (stdio, protocol 2026-07-28).
  4. *
  5. * Adds a missing English or Russian string to the language XML and, when
  6. * that language is installed, to the database. An existing translation is
  7. * left unchanged. The language cache is recompiled after a new string.
  8. *
  9. * Messages are one JSON-RPC object per line on stdin. Responses are one
  10. * JSON-RPC object per line on stdout. Logs go to stderr.
  11. *
  12. * docker compose exec -T -w /opt/una php php tools/add_lang_key.php <<'EOF'
  13. * {"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"add","arguments":{"language":"en","key":"_sys_example","translation":"Hello"},"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}
  14. * EOF
  15. */
  16. if (PHP_SAPI !== 'cli')
  17. exit(1);
  18. const MCP_PROTOCOL_VERSION = '2026-07-28';
  19. const MCP_SERVER_NAME = 'una-lang';
  20. const MCP_SERVER_VERSION = '1.0.0';
  21. const JSONRPC_PARSE_ERROR = -32700;
  22. const JSONRPC_INVALID_REQUEST = -32600;
  23. const JSONRPC_METHOD_NOT_FOUND = -32601;
  24. const JSONRPC_INVALID_PARAMS = -32602;
  25. const MCP_UNSUPPORTED_PROTOCOL_VERSION = -32022;
  26. ini_set('display_errors', 'stderr');
  27. ini_set('log_errors', '1');
  28. if (isset($argv[1])) {
  29. fwrite(STDERR, usage());
  30. exit($argv[1] === '--help' || $argv[1] === '-h' ? 0 : 2);
  31. }
  32. if (stream_isatty(STDIN))
  33. fwrite(STDERR, "una-lang MCP server (" . MCP_PROTOCOL_VERSION . "), reading JSON-RPC from stdin\n");
  34. while (($sLine = fgets(STDIN)) !== false) {
  35. $sLine = rtrim($sLine, "\r\n");
  36. if ($sLine === '')
  37. continue;
  38. handleLine($sLine);
  39. }
  40. exit(0);
  41. function handleLine(string $sLine): void
  42. {
  43. try {
  44. $mixed = json_decode($sLine, false, 512, JSON_THROW_ON_ERROR);
  45. }
  46. catch (JsonException $oException) {
  47. writeError(null, JSONRPC_PARSE_ERROR, 'Parse error');
  48. return;
  49. }
  50. if (!is_object($mixed)) {
  51. writeError(null, JSONRPC_INVALID_REQUEST, 'Invalid Request');
  52. return;
  53. }
  54. $bHasId = property_exists($mixed, 'id');
  55. $mId = $bHasId ? $mixed->id : null;
  56. $bValidId = is_string($mId) || is_int($mId);
  57. if (!$bHasId)
  58. return;
  59. $sMethod = $mixed->method ?? null;
  60. if (!$bValidId || ($mixed->jsonrpc ?? null) !== '2.0' || !is_string($sMethod) || $sMethod === '') {
  61. writeError($bValidId ? $mId : null, JSONRPC_INVALID_REQUEST, 'Invalid Request');
  62. return;
  63. }
  64. if (isset($mixed->params) && !is_object($mixed->params)) {
  65. writeError($mId, JSONRPC_INVALID_PARAMS, 'Invalid params');
  66. return;
  67. }
  68. $oParams = $mixed->params ?? new stdClass();
  69. $aProtocolError = protocolError($oParams);
  70. if ($aProtocolError !== null) {
  71. writeError($mId, $aProtocolError[0], $aProtocolError[1], $aProtocolError[2]);
  72. return;
  73. }
  74. switch ($sMethod) {
  75. case 'server/discover':
  76. writeResult($mId, [
  77. 'supportedVersions' => [MCP_PROTOCOL_VERSION],
  78. 'capabilities' => [
  79. 'tools' => ['listChanged' => false],
  80. ],
  81. 'instructions' => 'Add a missing English or Russian language key with the add tool. The string is written into the language XML and, when that language is installed, into the database. An existing translation is left unchanged.',
  82. 'ttlMs' => 86400000,
  83. 'cacheScope' => 'public',
  84. ]);
  85. return;
  86. case 'tools/list':
  87. if (isset($oParams->cursor) && $oParams->cursor !== '') {
  88. if (!is_string($oParams->cursor)) {
  89. writeError($mId, JSONRPC_INVALID_PARAMS, 'Invalid params: cursor must be a string');
  90. return;
  91. }
  92. writeResult($mId, [
  93. 'tools' => [],
  94. 'ttlMs' => 86400000,
  95. 'cacheScope' => 'public',
  96. ]);
  97. return;
  98. }
  99. writeResult($mId, [
  100. 'tools' => [addTool()],
  101. 'ttlMs' => 86400000,
  102. 'cacheScope' => 'public',
  103. ]);
  104. return;
  105. case 'tools/call':
  106. $aCall = callTool($oParams);
  107. if (isset($aCall['error'])) {
  108. writeError($mId, $aCall['error'][0], $aCall['error'][1]);
  109. return;
  110. }
  111. writeResult($mId, $aCall['result']);
  112. return;
  113. default:
  114. writeError($mId, JSONRPC_METHOD_NOT_FOUND, 'Method not found');
  115. }
  116. }
  117. /**
  118. * @return array{0: int, 1: string, 2: ?array}|null
  119. */
  120. function protocolError(object $oParams): ?array
  121. {
  122. if (!isset($oParams->_meta) || !is_object($oParams->_meta))
  123. return [JSONRPC_INVALID_PARAMS, 'Invalid params: _meta is required', null];
  124. $oMeta = $oParams->_meta;
  125. $sVersion = $oMeta->{'io.modelcontextprotocol/protocolVersion'} ?? null;
  126. if (!is_string($sVersion) || $sVersion === '')
  127. return [JSONRPC_INVALID_PARAMS, 'Invalid params: protocol version is required', null];
  128. if ($sVersion !== MCP_PROTOCOL_VERSION) {
  129. return [MCP_UNSUPPORTED_PROTOCOL_VERSION, 'Unsupported protocol version', [
  130. 'supported' => [MCP_PROTOCOL_VERSION],
  131. 'requested' => $sVersion,
  132. ]];
  133. }
  134. $mCapabilities = $oMeta->{'io.modelcontextprotocol/clientCapabilities'} ?? null;
  135. if (!is_object($mCapabilities))
  136. return [JSONRPC_INVALID_PARAMS, 'Invalid params: client capabilities are required', null];
  137. return null;
  138. }
  139. function addTool(): array
  140. {
  141. return [
  142. 'name' => 'add',
  143. 'title' => 'Add language key',
  144. 'description' => 'Add a missing English or Russian string. The key is inserted into the language XML beside the closest existing keys, and into the database when that language is installed. An existing translation is left unchanged. Pass module when the key prefix does not name the module (for example _bx_orgs_ belongs to bx_organizations).',
  145. 'inputSchema' => [
  146. 'type' => 'object',
  147. 'properties' => [
  148. 'language' => [
  149. 'type' => 'string',
  150. 'enum' => ['en', 'ru'],
  151. 'description' => 'Language name: en or ru.',
  152. ],
  153. 'key' => [
  154. 'type' => 'string',
  155. 'description' => 'Language key, a single line such as _sys_example or _bx_posts_example.',
  156. ],
  157. 'translation' => [
  158. 'type' => 'string',
  159. 'description' => 'Translation text. Line breaks are kept.',
  160. ],
  161. 'module' => [
  162. 'type' => 'string',
  163. 'description' => 'Module name to write into when the key prefix cannot identify it.',
  164. ],
  165. ],
  166. 'required' => ['language', 'key', 'translation'],
  167. 'additionalProperties' => false,
  168. ],
  169. 'outputSchema' => [
  170. 'type' => 'object',
  171. 'properties' => [
  172. 'language' => ['type' => 'string'],
  173. 'key' => ['type' => 'string'],
  174. 'file' => ['type' => 'string'],
  175. 'xml' => [
  176. 'type' => 'string',
  177. 'enum' => ['added', 'already_present'],
  178. ],
  179. 'keptExisting' => ['type' => 'boolean'],
  180. 'languageInstalled' => ['type' => 'boolean'],
  181. 'keyCreated' => ['type' => 'boolean'],
  182. 'translationStatus' => [
  183. 'type' => 'string',
  184. 'enum' => ['added', 'already_present', 'skipped'],
  185. ],
  186. 'keyId' => ['type' => ['integer', 'null']],
  187. 'category' => ['type' => 'string'],
  188. 'recompiled' => ['type' => 'boolean'],
  189. ],
  190. 'required' => [
  191. 'language',
  192. 'key',
  193. 'file',
  194. 'xml',
  195. 'keptExisting',
  196. 'languageInstalled',
  197. 'keyCreated',
  198. 'translationStatus',
  199. 'keyId',
  200. 'category',
  201. 'recompiled',
  202. ],
  203. ],
  204. 'annotations' => [
  205. 'title' => 'Add language key',
  206. 'readOnlyHint' => false,
  207. 'destructiveHint' => false,
  208. 'idempotentHint' => true,
  209. 'openWorldHint' => false,
  210. ],
  211. ];
  212. }
  213. /**
  214. * @return array{error: array{0: int, 1: string}}|array{result: array}
  215. */
  216. function callTool(object $oParams): array
  217. {
  218. if (!isset($oParams->name) || !is_string($oParams->name) || $oParams->name === '')
  219. return ['error' => [JSONRPC_INVALID_PARAMS, 'Invalid params: tool name is required']];
  220. if ($oParams->name !== 'add')
  221. return ['error' => [JSONRPC_INVALID_PARAMS, 'Unknown tool: ' . $oParams->name]];
  222. if (isset($oParams->arguments) && !is_object($oParams->arguments))
  223. return ['error' => [JSONRPC_INVALID_PARAMS, 'Invalid params: arguments must be an object']];
  224. $oArgs = $oParams->arguments ?? new stdClass();
  225. $sInvalid = invalidAddArguments($oArgs);
  226. if ($sInvalid !== null)
  227. return ['result' => toolError($sInvalid)];
  228. $sModule = (isset($oArgs->module) && is_string($oArgs->module) && $oArgs->module !== '') ? $oArgs->module : null;
  229. try {
  230. $aAdded = addLanguageKey(strtolower($oArgs->language), $oArgs->key, $oArgs->translation, $sModule);
  231. }
  232. catch (Throwable $oException) {
  233. return ['result' => toolError($oException->getMessage())];
  234. }
  235. $bFailed = isset($aAdded['error']);
  236. $sText = statusText($aAdded['status'], $bFailed);
  237. if ($bFailed)
  238. $sText .= "\n" . $aAdded['error'];
  239. return ['result' => [
  240. 'content' => [[
  241. 'type' => 'text',
  242. 'text' => $sText,
  243. ]],
  244. 'structuredContent' => $aAdded['status'],
  245. 'isError' => isset($aAdded['error']),
  246. ]];
  247. }
  248. function invalidAddArguments(object $oArgs): ?string
  249. {
  250. $mLang = $oArgs->language ?? null;
  251. if (!is_string($mLang) || !in_array(strtolower($mLang), ['en', 'ru'], true))
  252. return 'language must be en or ru';
  253. $mKey = $oArgs->key ?? null;
  254. if (!is_string($mKey) || $mKey === '')
  255. return 'key must be a non-empty string';
  256. if (strpbrk($mKey, "\r\n") !== false)
  257. return 'key must be a single line';
  258. $mTranslation = $oArgs->translation ?? null;
  259. if (!is_string($mTranslation) || $mTranslation === '')
  260. return 'translation must be a non-empty string';
  261. if (function_exists('mb_check_encoding') && !mb_check_encoding($mTranslation, 'UTF-8'))
  262. return 'translation is not valid UTF-8';
  263. if (isset($oArgs->module) && !is_string($oArgs->module))
  264. return 'module must be a string';
  265. return null;
  266. }
  267. function toolError(string $sMessage): array
  268. {
  269. return [
  270. 'content' => [[
  271. 'type' => 'text',
  272. 'text' => $sMessage,
  273. ]],
  274. 'isError' => true,
  275. ];
  276. }
  277. /**
  278. * @param array{language: string, key: string, file: string, xml: string, keptExisting: bool, languageInstalled: bool, keyCreated: bool, translationStatus: string, keyId: int|null, category: string, recompiled: bool} $aStatus
  279. */
  280. function statusText(array $aStatus, bool $bFailed): string
  281. {
  282. $aLines = [];
  283. if ($aStatus['xml'] === 'added')
  284. $aLines[] = 'xml: added to ' . $aStatus['file'];
  285. else {
  286. $aLines[] = 'xml: already present in ' . $aStatus['file'];
  287. if ($aStatus['keptExisting'])
  288. $aLines[] = 'kept the translation already in the language file';
  289. }
  290. if (!$aStatus['languageInstalled']) {
  291. if (!$bFailed)
  292. $aLines[] = "Language '" . $aStatus['language'] . "' is not installed in the database";
  293. return implode("\n", $aLines);
  294. }
  295. if ($aStatus['keyCreated'])
  296. $aLines[] = 'db: created key ' . $aStatus['keyId'] . " in '" . $aStatus['category'] . "'";
  297. if ($aStatus['translationStatus'] === 'already_present')
  298. $aLines[] = 'db: already present for ' . $aStatus['language'];
  299. elseif ($aStatus['translationStatus'] === 'added' && $aStatus['recompiled'])
  300. $aLines[] = 'db: added translation for ' . $aStatus['language'] . ' and recompiled';
  301. elseif ($aStatus['translationStatus'] === 'added')
  302. $aLines[] = 'db: added translation for ' . $aStatus['language'];
  303. return implode("\n", $aLines);
  304. }
  305. /**
  306. * @return array{status: array, error?: string}
  307. */
  308. function addLanguageKey(string $sLang, string $sKey, string $sTranslation, ?string $sForcedModule): array
  309. {
  310. $sRoot = dirname(__DIR__) . '/';
  311. $aModules = loadModules($sRoot);
  312. if ($sForcedModule !== null) {
  313. if (!isset($aModules[$sForcedModule]))
  314. throw new RuntimeException("Unknown module '$sForcedModule'");
  315. $aModule = $aModules[$sForcedModule];
  316. }
  317. else {
  318. $aModule = findModuleByExistingKey($sRoot, $sKey, $aModules);
  319. if (!$aModule)
  320. $aModule = inferModule($sKey, $aModules);
  321. }
  322. $sFile = resolveLangFile($sRoot, $sLang, $aModule, loadLanguageModules($sRoot));
  323. $sRel = ltrim(str_replace('\\', '/', substr($sFile, strlen($sRoot))), '/');
  324. $sExisting = is_file($sFile) ? readLangString($sFile, $sKey) : null;
  325. if ($sExisting !== null) {
  326. $sXml = 'already_present';
  327. $bKept = $sExisting !== $sTranslation;
  328. $sStore = $sExisting;
  329. }
  330. else {
  331. if (!writeLangString($sFile, $sLang, $sKey, $sTranslation))
  332. throw new RuntimeException("Failed to write $sRel");
  333. $sXml = 'added';
  334. $bKept = false;
  335. $sStore = $sTranslation;
  336. }
  337. $aStatus = [
  338. 'language' => $sLang,
  339. 'key' => $sKey,
  340. 'file' => $sRel,
  341. 'xml' => $sXml,
  342. 'keptExisting' => $bKept,
  343. 'languageInstalled' => false,
  344. 'keyCreated' => false,
  345. 'translationStatus' => 'skipped',
  346. 'keyId' => null,
  347. 'category' => $aModule['language_category'],
  348. 'recompiled' => false,
  349. ];
  350. try {
  351. dbAddMissing($sLang, $sKey, $sStore, $aModule['language_category'], $aStatus);
  352. }
  353. catch (Throwable $oException) {
  354. return ['error' => $oException->getMessage(), 'status' => $aStatus];
  355. }
  356. return ['status' => $aStatus];
  357. }
  358. /**
  359. * @param array{languageInstalled: bool, keyCreated: bool, translationStatus: string, keyId: int|null, category: string, recompiled: bool} $aStatus
  360. */
  361. function dbAddMissing(string $sLang, string $sKey, string $sTranslation, string $sCategory, array &$aStatus): void
  362. {
  363. $iBuffers = ob_get_level();
  364. ob_start();
  365. try {
  366. bootUna();
  367. $oDb = BxDolDb::getInstance();
  368. $aLang = $oDb->getRow($oDb->prepare("SELECT `ID` AS `id` FROM `sys_localization_languages` WHERE `Name` = ? LIMIT 1", $sLang));
  369. if (!$aLang) {
  370. $aStatus['languageInstalled'] = false;
  371. return;
  372. }
  373. $aStatus['languageInstalled'] = true;
  374. $iLangId = (int)$aLang['id'];
  375. $aKey = $oDb->getRow($oDb->prepare("SELECT `ID` AS `id` FROM `sys_localization_keys` WHERE `Key` = ? LIMIT 1", $sKey));
  376. if (!$aKey) {
  377. $iCategoryId = categoryId($oDb, $sCategory);
  378. if (!$iCategoryId)
  379. throw new RuntimeException("Could not resolve language category '$sCategory'");
  380. $iInserted = (int)$oDb->query($oDb->prepare(
  381. "INSERT INTO `sys_localization_keys` (`IDCategory`, `Key`) VALUES (?, ?)",
  382. $iCategoryId,
  383. $sKey
  384. ));
  385. if ($iInserted <= 0)
  386. throw new RuntimeException('Could not insert language key');
  387. $aStatus['keyCreated'] = true;
  388. $aStatus['keyId'] = (int)$oDb->lastId();
  389. }
  390. else {
  391. $aStatus['keyId'] = (int)$aKey['id'];
  392. }
  393. $iKeyId = $aStatus['keyId'];
  394. $aString = $oDb->getRow($oDb->prepare(
  395. "SELECT `IDKey` AS `id` FROM `sys_localization_strings` WHERE `IDKey` = ? AND `IDLanguage` = ? LIMIT 1",
  396. $iKeyId,
  397. $iLangId
  398. ));
  399. if ($aString) {
  400. $aStatus['translationStatus'] = 'already_present';
  401. return;
  402. }
  403. $iInserted = (int)$oDb->query($oDb->prepare(
  404. "INSERT INTO `sys_localization_strings` (`IDKey`, `IDLanguage`, `String`) VALUES (?, ?, ?)",
  405. $iKeyId,
  406. $iLangId,
  407. $sTranslation
  408. ));
  409. if ($iInserted <= 0)
  410. throw new RuntimeException('Could not insert translation');
  411. $aStatus['translationStatus'] = 'added';
  412. bx_import('BxDolStudioLanguagesUtils');
  413. BxDolLanguages::getInstance();
  414. if (!BxDolStudioLanguagesUtils::getInstance()->compileLanguage($iLangId, true))
  415. throw new RuntimeException('Translation stored, but the language cache was not recompiled');
  416. $aStatus['recompiled'] = true;
  417. }
  418. finally {
  419. while (ob_get_level() > $iBuffers) {
  420. $sLeak = ob_get_clean();
  421. if (is_string($sLeak) && $sLeak !== '')
  422. fwrite(STDERR, $sLeak);
  423. }
  424. }
  425. }
  426. function categoryId(object $oDb, string $sCategory): int
  427. {
  428. $aCategory = $oDb->getRow($oDb->prepare(
  429. "SELECT `ID` AS `id` FROM `sys_localization_categories` WHERE `Name` = ? LIMIT 1",
  430. $sCategory
  431. ));
  432. if ($aCategory)
  433. return (int)$aCategory['id'];
  434. $iInserted = (int)$oDb->query($oDb->prepare(
  435. "INSERT INTO `sys_localization_categories` (`Name`) VALUES (?)",
  436. $sCategory
  437. ));
  438. if ($iInserted <= 0)
  439. return 0;
  440. return (int)$oDb->lastId();
  441. }
  442. function bootUna(): void
  443. {
  444. if (defined('BX_DOL'))
  445. return;
  446. $GLOBALS['bx_profiler_disable'] = true;
  447. if (!defined('BX_DOL_CRON_EXECUTE'))
  448. define('BX_DOL_CRON_EXECUTE', '1');
  449. $_ENV['UNA_SKIP_REDIRECT'] = '1';
  450. $_ENV['UNA_SKIP_MINIMAL_REQUIREMENTS'] = '1';
  451. $_ENV['UNA_SKIP_INSTALL_FOLDER_CHECK'] = '1';
  452. $_ENV['UNA_DEBUG_VISUAL_PROCESSING'] = false;
  453. $_ENV['UNA_DEBUG_EMAIL_REPORT'] = false;
  454. $_ENV['UNA_DEBUG_MODE'] = false;
  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. $sHeader = dirname(__DIR__) . '/inc/header.inc.php';
  462. if (!is_readable($sHeader))
  463. throw new RuntimeException('UNA is not installed (inc/header.inc.php is missing)');
  464. require_once $sHeader;
  465. require_once BX_DIRECTORY_PATH_INC . 'design.inc.php';
  466. }
  467. function loadModules(string $sRoot): array
  468. {
  469. $a = [
  470. 'system' => [
  471. 'name' => 'system',
  472. 'home_dir' => 'system/',
  473. 'language_category' => 'System',
  474. ],
  475. ];
  476. foreach (glob($sRoot . 'modules/*/*/install/config.php') as $sConfig) {
  477. $s = file_get_contents($sConfig);
  478. if ($s === false)
  479. continue;
  480. $sName = configValue($s, 'name');
  481. $sHome = configValue($s, 'home_dir');
  482. if ($sName === null || $sHome === null || $sName === '')
  483. continue;
  484. $sCategory = configValue($s, 'language_category');
  485. if ($sCategory === null || $sCategory === '')
  486. $sCategory = $sName;
  487. $a[$sName] = [
  488. 'name' => $sName,
  489. 'home_dir' => rtrim($sHome, '/') . '/',
  490. 'language_category' => $sCategory,
  491. ];
  492. }
  493. return $a;
  494. }
  495. function loadLanguageModules(string $sRoot): array
  496. {
  497. $a = [
  498. 'en' => ['home_dir' => 'boonex/english/'],
  499. 'ru' => ['home_dir' => 'boonex/russian/'],
  500. ];
  501. foreach (glob($sRoot . 'modules/*/*/install/config.php') as $sConfig) {
  502. $s = file_get_contents($sConfig);
  503. if ($s === false)
  504. continue;
  505. $sUri = configValue($s, 'home_uri');
  506. $sHome = configValue($s, 'home_dir');
  507. if (($sUri !== 'en' && $sUri !== 'ru') || $sHome === null)
  508. continue;
  509. $a[$sUri] = ['home_dir' => rtrim($sHome, '/') . '/'];
  510. }
  511. return $a;
  512. }
  513. function configValue(string $sSource, string $sKey): ?string
  514. {
  515. $sPattern = '/[\'"]' . preg_quote($sKey, '/') . '[\'"]\s*=>\s*[\'"]([^\'"]*)[\'"]/';
  516. if (!preg_match($sPattern, $sSource, $aMatch))
  517. return null;
  518. return $aMatch[1];
  519. }
  520. function findModuleByExistingKey(string $sRoot, string $sKey, array $aModules): ?array
  521. {
  522. $aFound = [];
  523. $aFiles = array_merge(
  524. glob($sRoot . 'modules/*/*/install/langs/*.xml') ?: [],
  525. glob($sRoot . 'modules/*/*/data/langs/*/*.xml') ?: []
  526. );
  527. foreach ($aFiles as $sFile) {
  528. if (readLangString($sFile, $sKey) === null)
  529. continue;
  530. $sNorm = str_replace('\\', '/', $sFile);
  531. $aModule = null;
  532. if (preg_match('#/data/langs/([^/]+)/[^/]+\.xml$#', $sNorm, $aMatch) && isset($aModules[$aMatch[1]]))
  533. $aModule = $aModules[$aMatch[1]];
  534. elseif (preg_match('#/modules/(.+)/install/langs/[^/]+\.xml$#', $sNorm, $aMatch)) {
  535. $sHome = $aMatch[1] . '/';
  536. foreach ($aModules as $aCandidate) {
  537. if ($aCandidate['home_dir'] === $sHome) {
  538. $aModule = $aCandidate;
  539. break;
  540. }
  541. }
  542. }
  543. if ($aModule)
  544. $aFound[$aModule['name']] = $aModule;
  545. }
  546. if (!$aFound)
  547. return null;
  548. if (isset($aFound['system']) && count($aFound) > 1)
  549. unset($aFound['system']);
  550. return reset($aFound);
  551. }
  552. function inferModule(string $sKey, array $aModules): array
  553. {
  554. $sBare = ltrim($sKey, '_');
  555. $aNames = array_keys($aModules);
  556. usort($aNames, function ($sA, $sB) {
  557. return strlen($sB) - strlen($sA);
  558. });
  559. foreach ($aNames as $sName) {
  560. if ($sName === 'system')
  561. continue;
  562. if ($sBare === $sName || strpos($sBare, $sName . '_') === 0)
  563. return $aModules[$sName];
  564. }
  565. return $aModules['system'];
  566. }
  567. function resolveLangFile(string $sRoot, string $sLang, array $aModule, array $aLangModules): string
  568. {
  569. $sPack = $sRoot . 'modules/' . $aLangModules[$sLang]['home_dir'] . 'data/langs/' . $aModule['name'] . '/' . $sLang . '.xml';
  570. if (is_file($sPack))
  571. return $sPack;
  572. if ($aModule['name'] !== 'system') {
  573. $sInstall = $sRoot . 'modules/' . $aModule['home_dir'] . 'install/langs/' . $sLang . '.xml';
  574. if (is_file($sInstall) || $sLang === 'en')
  575. return $sInstall;
  576. }
  577. return $sPack;
  578. }
  579. function readLangString(string $sPath, string $sKey): ?string
  580. {
  581. $s = @file_get_contents($sPath);
  582. if ($s === false)
  583. return null;
  584. $aVariants = array_unique([$sKey, htmlspecialchars($sKey, ENT_QUOTES | ENT_XML1, 'UTF-8')]);
  585. foreach ($aVariants as $sVariant) {
  586. $sPattern = '/<string\b[^>]*\bname=(["\'])' . preg_quote($sVariant, '/') . '\\1[^>]*>(.*?)<\/string>/s';
  587. if (!preg_match($sPattern, $s, $aMatch))
  588. continue;
  589. $sInner = $aMatch[2];
  590. if (preg_match('/^\s*<!\[CDATA\[(.*)\]\]>\s*$/s', $sInner, $aCdata))
  591. return str_replace(']]]]><![CDATA[>', ']]>', $aCdata[1]);
  592. return html_entity_decode($sInner, ENT_QUOTES | ENT_XML1, 'UTF-8');
  593. }
  594. return null;
  595. }
  596. function writeLangString(string $sFile, string $sLang, string $sKey, string $sTranslation): bool
  597. {
  598. $aMeta = [
  599. 'en' => ['flag' => 'gb', 'title' => 'English'],
  600. 'ru' => ['flag' => 'ru', 'title' => 'Russian'],
  601. ];
  602. $sNl = "\n";
  603. if (is_file($sFile)) {
  604. $sXml = file_get_contents($sFile);
  605. if ($sXml === false)
  606. return false;
  607. if (strpos($sXml, "\r\n") !== false)
  608. $sNl = "\r\n";
  609. }
  610. else {
  611. $sDir = dirname($sFile);
  612. if (!is_dir($sDir) && !mkdir($sDir, 0775, true))
  613. return false;
  614. $sXml = '<?xml version="1.0" encoding="utf-8"?>' . $sNl
  615. . '<resources name="' . $sLang . '" flag="' . $aMeta[$sLang]['flag'] . '" title="' . $aMeta[$sLang]['title'] . '">' . $sNl
  616. . '</resources>' . $sNl;
  617. }
  618. $iPos = insertionOffset($sXml, $sKey);
  619. if ($iPos === false)
  620. return false;
  621. $sIndent = indentAt($sXml, $iPos);
  622. $sLine = $sIndent . '<string name="' . htmlspecialchars($sKey, ENT_QUOTES | ENT_XML1, 'UTF-8') . '"><![CDATA[' . cdata($sTranslation) . ']]></string>' . $sNl;
  623. if ($iPos > 0 && $sXml[$iPos - 1] !== "\n")
  624. $sLine = $sNl . $sLine;
  625. $sXml = substr($sXml, 0, $iPos) . $sLine . substr($sXml, $iPos);
  626. return file_put_contents($sFile, $sXml, LOCK_EX) !== false;
  627. }
  628. /**
  629. * Byte offset where $sKey should be inserted: after the closest earlier
  630. * sibling, or before </resources> when nothing shares a meaningful prefix.
  631. * Sibling groups stay intact, so a new key lands beside a family rather
  632. * than between two keys that belong together.
  633. *
  634. * @return int|false
  635. */
  636. function insertionOffset(string $sXml, string $sKey)
  637. {
  638. $iFallback = strrpos($sXml, '</resources>');
  639. if ($iFallback === false)
  640. return false;
  641. $aEntries = langStringEntries($sXml);
  642. if (!$aEntries)
  643. return $iFallback;
  644. $iBest = 0;
  645. foreach ($aEntries as $aEntry) {
  646. $iScore = commonNonEmptySegments($sKey, $aEntry['name']);
  647. if ($iScore > $iBest)
  648. $iBest = $iScore;
  649. }
  650. if ($iBest < 2)
  651. return $iFallback;
  652. $aClose = [];
  653. foreach ($aEntries as $i => $aEntry) {
  654. if (commonNonEmptySegments($sKey, $aEntry['name']) === $iBest)
  655. $aClose[$i] = $aEntry;
  656. }
  657. $iPred = null;
  658. $sPredName = null;
  659. foreach ($aClose as $i => $aEntry) {
  660. if (strcmp($aEntry['name'], $sKey) < 0 && ($sPredName === null || strcmp($aEntry['name'], $sPredName) > 0)) {
  661. $iPred = $i;
  662. $sPredName = $aEntry['name'];
  663. }
  664. }
  665. if ($iPred !== null)
  666. return clusterEdge($aEntries, $iPred, $iBest, false);
  667. $iSucc = null;
  668. $sSuccName = null;
  669. foreach ($aClose as $i => $aEntry) {
  670. if (strcmp($aEntry['name'], $sKey) > 0 && ($sSuccName === null || strcmp($aEntry['name'], $sSuccName) < 0)) {
  671. $iSucc = $i;
  672. $sSuccName = $aEntry['name'];
  673. }
  674. }
  675. if ($iSucc !== null)
  676. return clusterEdge($aEntries, $iSucc, $iBest, true);
  677. return $iFallback;
  678. }
  679. function clusterEdge(array $aEntries, int $iAnchor, int $iMinScore, bool $bStart): int
  680. {
  681. $sAnchor = $aEntries[$iAnchor]['name'];
  682. $iEdge = $iAnchor;
  683. if ($bStart) {
  684. for ($i = $iAnchor - 1; $i >= 0; $i--) {
  685. if (commonNonEmptySegments($sAnchor, $aEntries[$i]['name']) <= $iMinScore)
  686. break;
  687. $iEdge = $i;
  688. }
  689. return $aEntries[$iEdge]['start'];
  690. }
  691. $iCount = count($aEntries);
  692. for ($i = $iAnchor + 1; $i < $iCount; $i++) {
  693. if (commonNonEmptySegments($sAnchor, $aEntries[$i]['name']) <= $iMinScore)
  694. break;
  695. $iEdge = $i;
  696. }
  697. return $aEntries[$iEdge]['end'];
  698. }
  699. function langStringEntries(string $sXml): array
  700. {
  701. $aEntries = [];
  702. $sPattern = '/^([ \t]*)<string\b[^>]*\bname=(["\'])(.*?)\2[^>]*>.*?<\/string>[ \t]*(?:\r?\n)?/ms';
  703. if (!preg_match_all($sPattern, $sXml, $aMatches, PREG_OFFSET_CAPTURE))
  704. return $aEntries;
  705. foreach ($aMatches[0] as $i => $aFull) {
  706. $aEntries[] = [
  707. 'name' => html_entity_decode($aMatches[3][$i][0], ENT_QUOTES | ENT_XML1, 'UTF-8'),
  708. 'indent' => $aMatches[1][$i][0],
  709. 'start' => $aFull[1],
  710. 'end' => $aFull[1] + strlen($aFull[0]),
  711. ];
  712. }
  713. return $aEntries;
  714. }
  715. function commonNonEmptySegments(string $sA, string $sB): int
  716. {
  717. $aA = explode('_', $sA);
  718. $aB = explode('_', $sB);
  719. $n = min(count($aA), count($aB));
  720. $iNonEmpty = 0;
  721. for ($i = 0; $i < $n; $i++) {
  722. if ($aA[$i] !== $aB[$i])
  723. break;
  724. if ($aA[$i] !== '')
  725. $iNonEmpty++;
  726. }
  727. return $iNonEmpty;
  728. }
  729. function indentAt(string $sXml, int $iPos): string
  730. {
  731. $aEntries = langStringEntries($sXml);
  732. $sIndent = ' ';
  733. foreach ($aEntries as $aEntry) {
  734. if ($aEntry['start'] <= $iPos)
  735. $sIndent = $aEntry['indent'];
  736. if ($aEntry['start'] >= $iPos)
  737. return $aEntry['indent'] !== '' ? $aEntry['indent'] : $sIndent;
  738. }
  739. return $sIndent;
  740. }
  741. function cdata(string $s): string
  742. {
  743. return str_replace(']]>', ']]]]><![CDATA[>', $s);
  744. }
  745. /**
  746. * @param string|int|null $mId
  747. * @param array<string, mixed> $aResult
  748. */
  749. function writeResult(string|int|null $mId, array $aResult): void
  750. {
  751. $aResult['resultType'] = 'complete';
  752. $aResult['_meta'] = [
  753. 'io.modelcontextprotocol/serverInfo' => [
  754. 'name' => MCP_SERVER_NAME,
  755. 'version' => MCP_SERVER_VERSION,
  756. ],
  757. ];
  758. writeMessage([
  759. 'jsonrpc' => '2.0',
  760. 'id' => $mId,
  761. 'result' => $aResult,
  762. ]);
  763. }
  764. /**
  765. * @param string|int|null $mId
  766. */
  767. function writeError(string|int|null $mId, int $iCode, string $sMessage, ?array $aData = null): void
  768. {
  769. $aError = [
  770. 'code' => $iCode,
  771. 'message' => $sMessage,
  772. ];
  773. if ($aData !== null)
  774. $aError['data'] = $aData;
  775. $aMessage = ['jsonrpc' => '2.0'];
  776. if ($mId !== null)
  777. $aMessage['id'] = $mId;
  778. $aMessage['error'] = $aError;
  779. writeMessage($aMessage);
  780. }
  781. function writeMessage(array $aMessage): void
  782. {
  783. $sJson = json_encode($aMessage, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_INVALID_UTF8_SUBSTITUTE | JSON_THROW_ON_ERROR);
  784. fwrite(STDOUT, $sJson . "\n");
  785. fflush(STDOUT);
  786. }
  787. function usage(): string
  788. {
  789. return "Usage: php tools/add_lang_key.php\n"
  790. . "Reads newline-delimited JSON-RPC (MCP 2026-07-28) from stdin and writes responses to stdout.\n"
  791. . "Methods: server/discover, tools/list, tools/call (tool name: add; arguments: language, key, translation; optional module).\n"
  792. . "\n"
  793. . " docker compose exec -T -w /opt/una php php tools/add_lang_key.php <<'EOF'\n"
  794. . " {\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"tools/call\",\"params\":{\"name\":\"add\",\"arguments\":{\"language\":\"en\",\"key\":\"_sys_example\",\"translation\":\"Hello\"},\"_meta\":{\"io.modelcontextprotocol/protocolVersion\":\"2026-07-28\",\"io.modelcontextprotocol/clientCapabilities\":{}}}}\n"
  795. . " EOF\n";
  796. }