upstream V3 development https://www.gnusocial.rocks/v3
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

349 lines
16 KiB

  1. <?php
  2. declare(strict_types = 1);
  3. // {{{ License
  4. // This file is part of GNU social - https://www.gnu.org/software/social
  5. //
  6. // GNU social is free software: you can redistribute it and/or modify
  7. // it under the terms of the GNU Affero General Public License as published by
  8. // the Free Software Foundation, either version 3 of the License, or
  9. // (at your option) any later version.
  10. //
  11. // GNU social is distributed in the hope that it will be useful,
  12. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  13. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  14. // GNU Affero General Public License for more details.
  15. //
  16. // You should have received a copy of the GNU Affero General Public License
  17. // along with GNU social. If not, see <http://www.gnu.org/licenses/>.
  18. // }}}
  19. /**
  20. * Utility functions for i18n
  21. *
  22. * @category I18n
  23. * @package GNU social
  24. *
  25. * @author Matthew Gregg <matthew.gregg@gmail.com>
  26. * @author Ciaran Gultnieks <ciaran@ciarang.com>
  27. * @author Evan Prodromou <evan@status.net>
  28. * @author Diogo Cordeiro <diogo@fc.up.pt>
  29. * @author Hugo Sales <hugo@hsal.es>
  30. * @copyright 2010, 2018-2021 Free Software Foundation, Inc http://www.fsf.org
  31. * @license https://www.gnu.org/licenses/agpl.html GNU AGPL v3 or later
  32. */
  33. namespace App\Core\I18n;
  34. use App\Util\Common;
  35. use App\Util\Exception\ServerException;
  36. use App\Util\Formatting;
  37. use InvalidArgumentException;
  38. use Symfony\Contracts\Translation\TranslatorInterface;
  39. // Locale category constants are usually predefined, but may not be
  40. // on some systems such as Win32.
  41. $LC_CATEGORIES = [
  42. 'LC_CTYPE',
  43. 'LC_NUMERIC',
  44. 'LC_TIME',
  45. 'LC_COLLATE',
  46. 'LC_MONETARY',
  47. 'LC_MESSAGES',
  48. 'LC_ALL',
  49. ];
  50. foreach ($LC_CATEGORIES as $key => $name) {
  51. if (!\defined($name)) {
  52. \define($name, $key);
  53. }
  54. }
  55. abstract class I18n
  56. {
  57. public static ?TranslatorInterface $translator = null;
  58. public static function setTranslator($trans): void
  59. {
  60. self::$translator = $trans;
  61. }
  62. /**
  63. * Looks for which plugin we've been called from to get the gettext domain;
  64. * if not in a plugin subdirectory, we'll use the default 'core+intl-icu'.
  65. *
  66. * @throws ServerException
  67. */
  68. public static function _mdomain(string $path): string
  69. {
  70. /*
  71. 0 =>
  72. array
  73. 'file' => string '/var/www/mublog/plugins/FeedSub/FeedSubPlugin.php' (length=49)
  74. 'line' => int 77
  75. 'function' => string '_m' (length=2)
  76. 'args' =>
  77. array
  78. 0 => &string 'Feeds' (length=5)
  79. */
  80. static $cached;
  81. if (!isset($cached[$path])) {
  82. $path = Formatting::normalizePath($path);
  83. $cached[$path] = Formatting::moduleFromPath($path);
  84. }
  85. return $cached[$path] ?? 'core+intl-icu';
  86. }
  87. /**
  88. * Content negotiation for language codes. Gets our highest rated translation language that the client accepts
  89. *
  90. * @param string $http_accept_lang_header HTTP Accept-Language header
  91. *
  92. * @return string language code for best language match, false otherwise
  93. */
  94. public static function clientPreferredLanguage(string $http_accept_lang_header): string|bool
  95. {
  96. $client_langs = [];
  97. $all_languages = self::getAllLanguages();
  98. preg_match_all(
  99. '"(((\S\S)-?(\S\S)?)(;q=([0-9.]+))?)\s*(,\s*|$)"',
  100. mb_strtolower($http_accept_lang_header),
  101. $http_langs,
  102. );
  103. for ($i = 0; $i < \count($http_langs); ++$i) {
  104. if (!empty($http_langs[2][$i])) {
  105. // if no q default to 1.0
  106. $client_langs[$http_langs[2][$i]] = ($http_langs[6][$i] ? (float) $http_langs[6][$i] : 1.0 - ($i * 0.01));
  107. }
  108. if (!empty($http_langs[3][$i]) && empty($client_langs[$http_langs[3][$i]])) {
  109. // if a catchall default 0.01 lower
  110. $client_langs[$http_langs[3][$i]] = ($http_langs[6][$i] ? (float) $http_langs[6][$i] - 0.01 : 0.99);
  111. }
  112. }
  113. // sort in descending q
  114. arsort($client_langs);
  115. foreach ($client_langs as $lang => $q) {
  116. if (isset($all_languages[$lang])) {
  117. return $all_languages[$lang]['lang'];
  118. }
  119. }
  120. return false;
  121. }
  122. /**
  123. * returns a simple code -> name mapping for languages
  124. *
  125. * @return array map of available languages by code to language name
  126. */
  127. public static function getNiceLanguageList(): array
  128. {
  129. $nice_lang = [];
  130. $all_languages = self::getAllLanguages();
  131. foreach ($all_languages as $lang) {
  132. $nice_lang[$lang['lang']] = $lang['name'];
  133. }
  134. return $nice_lang;
  135. }
  136. /**
  137. * Check whether a language is right-to-left
  138. *
  139. * @param string $lang_value language code of the language to check
  140. *
  141. * @return bool true if language is rtl
  142. */
  143. public static function isRTL(string $lang_value): bool
  144. {
  145. foreach (self::getAllLanguages() as $code => $info) {
  146. if ($lang_value == $info['lang']) {
  147. return $info['direction'] == 'rtl';
  148. }
  149. }
  150. throw new InvalidArgumentException('is_rtl function received an invalid lang to test. Lang was: ' . $lang_value);
  151. }
  152. /**
  153. * Get a list of all languages that are enabled in the default config
  154. *
  155. * @return array mapping of language codes to language info
  156. */
  157. public static function getAllLanguages(): array
  158. {
  159. return [
  160. 'af' => ['q' => 0.8, 'lang' => 'af', 'name' => 'Afrikaans', 'direction' => 'ltr'],
  161. 'ar' => ['q' => 0.8, 'lang' => 'ar', 'name' => 'Arabic', 'direction' => 'rtl'],
  162. 'ast' => ['q' => 1, 'lang' => 'ast', 'name' => 'Asturian', 'direction' => 'ltr'],
  163. 'eu' => ['q' => 1, 'lang' => 'eu', 'name' => 'Basque', 'direction' => 'ltr'],
  164. 'be-tarask' => ['q' => 0.5, 'lang' => 'be-tarask', 'name' => 'Belarusian (Taraškievica orthography)', 'direction' => 'ltr'],
  165. 'br' => ['q' => 0.8, 'lang' => 'br', 'name' => 'Breton', 'direction' => 'ltr'],
  166. 'bg' => ['q' => 0.8, 'lang' => 'bg', 'name' => 'Bulgarian', 'direction' => 'ltr'],
  167. 'my' => ['q' => 1, 'lang' => 'my', 'name' => 'Burmese', 'direction' => 'ltr'],
  168. 'ca' => ['q' => 0.5, 'lang' => 'ca', 'name' => 'Catalan', 'direction' => 'ltr'],
  169. 'zh-cn' => ['q' => 0.9, 'lang' => 'zh_CN', 'name' => 'Chinese (Simplified)', 'direction' => 'ltr'],
  170. 'zh-hant' => ['q' => 0.2, 'lang' => 'zh_TW', 'name' => 'Chinese (Taiwanese)', 'direction' => 'ltr'],
  171. 'ksh' => ['q' => 1, 'lang' => 'ksh', 'name' => 'Colognian', 'direction' => 'ltr'],
  172. 'cs' => ['q' => 0.5, 'lang' => 'cs', 'name' => 'Czech', 'direction' => 'ltr'],
  173. 'da' => ['q' => 0.8, 'lang' => 'da', 'name' => 'Danish', 'direction' => 'ltr'],
  174. 'nl' => ['q' => 0.5, 'lang' => 'nl', 'name' => 'Dutch', 'direction' => 'ltr'],
  175. 'arz' => ['q' => 0.8, 'lang' => 'arz', 'name' => 'Egyptian Spoken Arabic', 'direction' => 'rtl'],
  176. 'en' => ['q' => 1, 'lang' => 'en', 'name' => 'English', 'direction' => 'ltr'],
  177. 'en-us' => ['q' => 1, 'lang' => 'en', 'name' => 'English (US)', 'direction' => 'ltr'],
  178. 'en-gb' => ['q' => 1, 'lang' => 'en_GB', 'name' => 'English (UK)', 'direction' => 'ltr'],
  179. 'eo' => ['q' => 0.8, 'lang' => 'eo', 'name' => 'Esperanto', 'direction' => 'ltr'],
  180. 'fi' => ['q' => 1, 'lang' => 'fi', 'name' => 'Finnish', 'direction' => 'ltr'],
  181. 'fr' => ['q' => 1, 'lang' => 'fr', 'name' => 'French', 'direction' => 'ltr'],
  182. 'fr-fr' => ['q' => 1, 'lang' => 'fr', 'name' => 'French (France)', 'direction' => 'ltr'],
  183. 'fur' => ['q' => 0.8, 'lang' => 'fur', 'name' => 'Friulian', 'direction' => 'ltr'],
  184. 'gl' => ['q' => 0.8, 'lang' => 'gl', 'name' => 'Galician', 'direction' => 'ltr'],
  185. 'ka' => ['q' => 0.8, 'lang' => 'ka', 'name' => 'Georgian', 'direction' => 'ltr'],
  186. 'de' => ['q' => 0.8, 'lang' => 'de', 'name' => 'German', 'direction' => 'ltr'],
  187. 'el' => ['q' => 0.1, 'lang' => 'el', 'name' => 'Greek', 'direction' => 'ltr'],
  188. 'he' => ['q' => 0.5, 'lang' => 'he', 'name' => 'Hebrew', 'direction' => 'rtl'],
  189. 'hu' => ['q' => 0.8, 'lang' => 'hu', 'name' => 'Hungarian', 'direction' => 'ltr'],
  190. 'is' => ['q' => 0.1, 'lang' => 'is', 'name' => 'Icelandic', 'direction' => 'ltr'],
  191. 'id' => ['q' => 1, 'lang' => 'id', 'name' => 'Indonesian', 'direction' => 'ltr'],
  192. 'ia' => ['q' => 0.8, 'lang' => 'ia', 'name' => 'Interlingua', 'direction' => 'ltr'],
  193. 'ga' => ['q' => 0.5, 'lang' => 'ga', 'name' => 'Irish', 'direction' => 'ltr'],
  194. 'it' => ['q' => 1, 'lang' => 'it', 'name' => 'Italian', 'direction' => 'ltr'],
  195. 'ja' => ['q' => 0.5, 'lang' => 'ja', 'name' => 'Japanese', 'direction' => 'ltr'],
  196. 'ko' => ['q' => 0.9, 'lang' => 'ko', 'name' => 'Korean', 'direction' => 'ltr'],
  197. 'lv' => ['q' => 1, 'lang' => 'lv', 'name' => 'Latvian', 'direction' => 'ltr'],
  198. 'lt' => ['q' => 1, 'lang' => 'lt', 'name' => 'Lithuanian', 'direction' => 'ltr'],
  199. 'lb' => ['q' => 1, 'lang' => 'lb', 'name' => 'Luxembourgish', 'direction' => 'ltr'],
  200. 'mk' => ['q' => 0.5, 'lang' => 'mk', 'name' => 'Macedonian', 'direction' => 'ltr'],
  201. 'mg' => ['q' => 1, 'lang' => 'mg', 'name' => 'Malagasy', 'direction' => 'ltr'],
  202. 'ms' => ['q' => 1, 'lang' => 'ms', 'name' => 'Malay', 'direction' => 'ltr'],
  203. 'ml' => ['q' => 0.5, 'lang' => 'ml', 'name' => 'Malayalam', 'direction' => 'ltr'],
  204. 'ne' => ['q' => 1, 'lang' => 'ne', 'name' => 'Nepali', 'direction' => 'ltr'],
  205. 'nb' => ['q' => 0.1, 'lang' => 'nb', 'name' => 'Norwegian (Bokmål)', 'direction' => 'ltr'],
  206. 'no' => ['q' => 0.1, 'lang' => 'nb', 'name' => 'Norwegian (Bokmål)', 'direction' => 'ltr'],
  207. 'nn' => ['q' => 1, 'lang' => 'nn', 'name' => 'Norwegian (Nynorsk)', 'direction' => 'ltr'],
  208. 'fa' => ['q' => 1, 'lang' => 'fa', 'name' => 'Persian', 'direction' => 'rtl'],
  209. 'pl' => ['q' => 0.5, 'lang' => 'pl', 'name' => 'Polish', 'direction' => 'ltr'],
  210. 'pt' => ['q' => 1, 'lang' => 'pt', 'name' => 'Portuguese', 'direction' => 'ltr'],
  211. 'pt-br' => ['q' => 0.9, 'lang' => 'pt_BR', 'name' => 'Brazilian Portuguese', 'direction' => 'ltr'],
  212. 'ru' => ['q' => 0.9, 'lang' => 'ru', 'name' => 'Russian', 'direction' => 'ltr'],
  213. 'sr-ec' => ['q' => 1, 'lang' => 'sr-ec', 'name' => 'Serbian', 'direction' => 'ltr'],
  214. 'es' => ['q' => 1, 'lang' => 'es', 'name' => 'Spanish', 'direction' => 'ltr'],
  215. 'sv' => ['q' => 0.8, 'lang' => 'sv', 'name' => 'Swedish', 'direction' => 'ltr'],
  216. 'tl' => ['q' => 0.8, 'lang' => 'tl', 'name' => 'Tagalog', 'direction' => 'ltr'],
  217. 'ta' => ['q' => 1, 'lang' => 'ta', 'name' => 'Tamil', 'direction' => 'ltr'],
  218. 'te' => ['q' => 0.3, 'lang' => 'te', 'name' => 'Telugu', 'direction' => 'ltr'],
  219. 'tr' => ['q' => 0.5, 'lang' => 'tr', 'name' => 'Turkish', 'direction' => 'ltr'],
  220. 'uk' => ['q' => 1, 'lang' => 'uk', 'name' => 'Ukrainian', 'direction' => 'ltr'],
  221. 'hsb' => ['q' => 0.8, 'lang' => 'hsb', 'name' => 'Upper Sorbian', 'direction' => 'ltr'],
  222. 'ur' => ['q' => 1, 'lang' => 'ur_PK', 'name' => 'Urdu (Pakistan)', 'direction' => 'rtl'],
  223. 'vi' => ['q' => 0.8, 'lang' => 'vi', 'name' => 'Vietnamese', 'direction' => 'ltr'],
  224. ];
  225. }
  226. /**
  227. * Format the given associative array $messages in the ICU
  228. * translation format, with the given $params. Allows for a
  229. * declarative use of the translation engine, for example
  230. * `formatICU(['she' => ['She has one foo', 'She has many foo'],
  231. * 'he' => ['He has one foo', 'He has many foo']], ['she' => 1])`
  232. *
  233. * @see http://userguide.icu-project.org/formatparse/messages
  234. */
  235. public static function formatICU(array $messages, array $params): string
  236. {
  237. $res = '';
  238. foreach (\array_slice($params, 0, 1, true) as $var => $type) {
  239. if (\is_int($type)) {
  240. $pref = '=';
  241. $op = 'plural';
  242. } elseif (\is_string($type)) {
  243. $pref = '';
  244. $op = 'select';
  245. } else {
  246. throw new InvalidArgumentException('Invalid variable type. (int|string) only');
  247. }
  248. $res = "{$var}, {$op}, ";
  249. $i = 0;
  250. $cnt = \count($messages) - 1;
  251. foreach ($messages as $val => $m) {
  252. if ($i !== $cnt) {
  253. $res .= "{$pref}{$val}";
  254. } else {
  255. $res .= 'other';
  256. }
  257. if (\is_array($m)) {
  258. $res .= ' {' . self::formatICU($m, \array_slice($params, 1, null, true)) . '} ';
  259. } elseif (\is_string($m)) {
  260. $res .= " {{$m}} ";
  261. } else {
  262. throw new InvalidArgumentException('Invalid message array');
  263. }
  264. ++$i;
  265. }
  266. }
  267. return "{{$res}}";
  268. }
  269. }
  270. /**
  271. * Wrapper for symfony translation with smart domain detection.
  272. *
  273. * If calling from a plugin, this function checks which plugin it was
  274. * being called from and uses that as text domain, which will have
  275. * been set up during plugin initialization.
  276. *
  277. * Also handles plurals and contexts depending on what parameters
  278. * are passed to it:
  279. *
  280. * _m(string $msg) -- simple message
  281. * _m(string $ctx, string $msg) -- message with context
  282. * _m(string|string[] $msg, array $params) -- message
  283. * _m(string $ctx, string|string[] $msg, array $params) -- combination of the previous two
  284. *
  285. * @throws ServerException
  286. *
  287. * @todo add parameters
  288. */
  289. function _m(...$args): string
  290. {
  291. // Get the file where this function was called from, reducing the
  292. // memory and performance impact by not returning the arguments,
  293. // and only 2 frames (this and previous)
  294. $domain = I18n::_mdomain(debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS, 2)[0]['file']);
  295. switch (\count($args)) {
  296. case 1:
  297. // Empty parameters, simple message
  298. return I18n::$translator->trans($args[0], [], $domain, Common::currentLanguage()->getLocale());
  299. case 3:
  300. // @codeCoverageIgnoreStart
  301. if (\is_int($args[2])) {
  302. throw new InvalidArgumentException('Calling `_m()` with a number for pluralization is deprecated, '
  303. . 'use an explicit parameter', );
  304. }
  305. // @codeCoverageIgnoreEnd
  306. // Falthrough
  307. // no break
  308. case 2:
  309. if (\is_array($args[0])) {
  310. $args[0] = I18n::formatICU($args[0], $args[1]);
  311. }
  312. if (\is_string($args[0])) {
  313. $msg = $args[0];
  314. $params = $args[1] ?? [];
  315. return I18n::$translator->trans($msg, $params, $domain, Common::currentLanguage()->getLocale());
  316. }
  317. // Fallthrough
  318. // no break
  319. default:
  320. // @codeCoverageIgnoreStart
  321. throw new InvalidArgumentException("Bad parameters to `_m()` for domain {$domain}");
  322. // @codeCoverageIgnoreEnd
  323. }
  324. }