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.

681 lines
18 KiB

3 years ago
  1. <?php
  2. /**
  3. * @copyright Copyright (c) 2016, ownCloud, Inc.
  4. * @copyright 2016 Roeland Jago Douma <roeland@famdouma.nl>
  5. * @copyright 2016 Lukas Reschke <lukas@statuscode.ch>
  6. *
  7. * @author Arthur Schiwon <blizzz@arthur-schiwon.de>
  8. * @author Bart Visscher <bartv@thisnet.nl>
  9. * @author Bjoern Schiessle <bjoern@schiessle.org>
  10. * @author Christoph Wurst <christoph@winzerhof-wurst.at>
  11. * @author Georg Ehrke <oc.list@georgehrke.com>
  12. * @author GretaD <gretadoci@gmail.com>
  13. * @author Joas Schilling <coding@schilljs.com>
  14. * @author John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
  15. * @author Lukas Reschke <lukas@statuscode.ch>
  16. * @author Morris Jobke <hey@morrisjobke.de>
  17. * @author Robin Appelman <robin@icewind.nl>
  18. * @author Robin McCorkell <robin@mccorkell.me.uk>
  19. * @author Roeland Jago Douma <roeland@famdouma.nl>
  20. * @author Thomas Citharel <nextcloud@tcit.fr>
  21. *
  22. * @license AGPL-3.0
  23. *
  24. * This code is free software: you can redistribute it and/or modify
  25. * it under the terms of the GNU Affero General Public License, version 3,
  26. * as published by the Free Software Foundation.
  27. *
  28. * This program is distributed in the hope that it will be useful,
  29. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  30. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  31. * GNU Affero General Public License for more details.
  32. *
  33. * You should have received a copy of the GNU Affero General Public License, version 3,
  34. * along with this program. If not, see <http://www.gnu.org/licenses/>
  35. *
  36. */
  37. namespace OC\L10N;
  38. use OCP\IConfig;
  39. use OCP\IRequest;
  40. use OCP\IUser;
  41. use OCP\IUserSession;
  42. use OCP\L10N\IFactory;
  43. use OCP\L10N\ILanguageIterator;
  44. /**
  45. * A factory that generates language instances
  46. */
  47. class Factory implements IFactory {
  48. /** @var string */
  49. protected $requestLanguage = '';
  50. /**
  51. * cached instances
  52. * @var array Structure: Lang => App => \OCP\IL10N
  53. */
  54. protected $instances = [];
  55. /**
  56. * @var array Structure: App => string[]
  57. */
  58. protected $availableLanguages = [];
  59. /**
  60. * @var array
  61. */
  62. protected $availableLocales = [];
  63. /**
  64. * @var array Structure: string => callable
  65. */
  66. protected $pluralFunctions = [];
  67. public const COMMON_LANGUAGE_CODES = [
  68. 'en', 'es', 'fr', 'de', 'de_DE', 'ja', 'ar', 'ru', 'nl', 'it',
  69. 'pt_BR', 'pt_PT', 'da', 'fi_FI', 'nb_NO', 'sv', 'tr', 'zh_CN', 'ko'
  70. ];
  71. /** @var IConfig */
  72. protected $config;
  73. /** @var IRequest */
  74. protected $request;
  75. /** @var IUserSession */
  76. protected $userSession;
  77. /** @var string */
  78. protected $serverRoot;
  79. /**
  80. * @param IConfig $config
  81. * @param IRequest $request
  82. * @param IUserSession $userSession
  83. * @param string $serverRoot
  84. */
  85. public function __construct(IConfig $config,
  86. IRequest $request,
  87. IUserSession $userSession,
  88. $serverRoot) {
  89. $this->config = $config;
  90. $this->request = $request;
  91. $this->userSession = $userSession;
  92. $this->serverRoot = $serverRoot;
  93. }
  94. /**
  95. * Get a language instance
  96. *
  97. * @param string $app
  98. * @param string|null $lang
  99. * @param string|null $locale
  100. * @return \OCP\IL10N
  101. */
  102. public function get($app, $lang = null, $locale = null) {
  103. return new LazyL10N(function () use ($app, $lang, $locale) {
  104. $app = \OC_App::cleanAppId($app);
  105. if ($lang !== null) {
  106. $lang = str_replace(['\0', '/', '\\', '..'], '', (string)$lang);
  107. }
  108. $forceLang = $this->config->getSystemValue('force_language', false);
  109. if (is_string($forceLang)) {
  110. $lang = $forceLang;
  111. }
  112. $forceLocale = $this->config->getSystemValue('force_locale', false);
  113. if (is_string($forceLocale)) {
  114. $locale = $forceLocale;
  115. }
  116. if ($lang === null || !$this->languageExists($app, $lang)) {
  117. $lang = $this->findLanguage($app);
  118. }
  119. if ($locale === null || !$this->localeExists($locale)) {
  120. $locale = $this->findLocale($lang);
  121. }
  122. if (!isset($this->instances[$lang][$app])) {
  123. $this->instances[$lang][$app] = new L10N(
  124. $this, $app, $lang, $locale,
  125. $this->getL10nFilesForApp($app, $lang)
  126. );
  127. }
  128. return $this->instances[$lang][$app];
  129. });
  130. }
  131. /**
  132. * Find the best language
  133. *
  134. * @param string|null $app App id or null for core
  135. * @return string language If nothing works it returns 'en'
  136. */
  137. public function findLanguage($app = null) {
  138. $forceLang = $this->config->getSystemValue('force_language', false);
  139. if (is_string($forceLang)) {
  140. $this->requestLanguage = $forceLang;
  141. }
  142. if ($this->requestLanguage !== '' && $this->languageExists($app, $this->requestLanguage)) {
  143. return $this->requestLanguage;
  144. }
  145. /**
  146. * At this point Nextcloud might not yet be installed and thus the lookup
  147. * in the preferences table might fail. For this reason we need to check
  148. * whether the instance has already been installed
  149. *
  150. * @link https://github.com/owncloud/core/issues/21955
  151. */
  152. if ($this->config->getSystemValue('installed', false)) {
  153. $userId = !is_null($this->userSession->getUser()) ? $this->userSession->getUser()->getUID() : null;
  154. if (!is_null($userId)) {
  155. $userLang = $this->config->getUserValue($userId, 'core', 'lang', null);
  156. } else {
  157. $userLang = null;
  158. }
  159. } else {
  160. $userId = null;
  161. $userLang = null;
  162. }
  163. if ($userLang) {
  164. $this->requestLanguage = $userLang;
  165. if ($this->languageExists($app, $userLang)) {
  166. return $userLang;
  167. }
  168. }
  169. try {
  170. // Try to get the language from the Request
  171. $lang = $this->getLanguageFromRequest($app);
  172. if ($userId !== null && $app === null && !$userLang) {
  173. $this->config->setUserValue($userId, 'core', 'lang', $lang);
  174. }
  175. return $lang;
  176. } catch (LanguageNotFoundException $e) {
  177. // Finding language from request failed fall back to default language
  178. $defaultLanguage = $this->config->getSystemValue('default_language', false);
  179. if ($defaultLanguage !== false && $this->languageExists($app, $defaultLanguage)) {
  180. return $defaultLanguage;
  181. }
  182. }
  183. // We could not find any language so fall back to english
  184. return 'en';
  185. }
  186. /**
  187. * find the best locale
  188. *
  189. * @param string $lang
  190. * @return null|string
  191. */
  192. public function findLocale($lang = null) {
  193. $forceLocale = $this->config->getSystemValue('force_locale', false);
  194. if (is_string($forceLocale) && $this->localeExists($forceLocale)) {
  195. return $forceLocale;
  196. }
  197. if ($this->config->getSystemValue('installed', false)) {
  198. $userId = null !== $this->userSession->getUser() ? $this->userSession->getUser()->getUID() : null;
  199. $userLocale = null;
  200. if (null !== $userId) {
  201. $userLocale = $this->config->getUserValue($userId, 'core', 'locale', null);
  202. }
  203. } else {
  204. $userId = null;
  205. $userLocale = null;
  206. }
  207. if ($userLocale && $this->localeExists($userLocale)) {
  208. return $userLocale;
  209. }
  210. // Default : use system default locale
  211. $defaultLocale = $this->config->getSystemValue('default_locale', false);
  212. if ($defaultLocale !== false && $this->localeExists($defaultLocale)) {
  213. return $defaultLocale;
  214. }
  215. // If no user locale set, use lang as locale
  216. if (null !== $lang && $this->localeExists($lang)) {
  217. return $lang;
  218. }
  219. // At last, return USA
  220. return 'en_US';
  221. }
  222. /**
  223. * find the matching lang from the locale
  224. *
  225. * @param string $app
  226. * @param string $locale
  227. * @return null|string
  228. */
  229. public function findLanguageFromLocale(string $app = 'core', string $locale = null) {
  230. if ($this->languageExists($app, $locale)) {
  231. return $locale;
  232. }
  233. // Try to split e.g: fr_FR => fr
  234. $locale = explode('_', $locale)[0];
  235. if ($this->languageExists($app, $locale)) {
  236. return $locale;
  237. }
  238. }
  239. /**
  240. * Find all available languages for an app
  241. *
  242. * @param string|null $app App id or null for core
  243. * @return array an array of available languages
  244. */
  245. public function findAvailableLanguages($app = null) {
  246. $key = $app;
  247. if ($key === null) {
  248. $key = 'null';
  249. }
  250. // also works with null as key
  251. if (!empty($this->availableLanguages[$key])) {
  252. return $this->availableLanguages[$key];
  253. }
  254. $available = ['en']; //english is always available
  255. $dir = $this->findL10nDir($app);
  256. if (is_dir($dir)) {
  257. $files = scandir($dir);
  258. if ($files !== false) {
  259. foreach ($files as $file) {
  260. if (substr($file, -5) === '.json' && substr($file, 0, 4) !== 'l10n') {
  261. $available[] = substr($file, 0, -5);
  262. }
  263. }
  264. }
  265. }
  266. // merge with translations from theme
  267. $theme = $this->config->getSystemValue('theme');
  268. if (!empty($theme)) {
  269. $themeDir = $this->serverRoot . '/themes/' . $theme . substr($dir, strlen($this->serverRoot));
  270. if (is_dir($themeDir)) {
  271. $files = scandir($themeDir);
  272. if ($files !== false) {
  273. foreach ($files as $file) {
  274. if (substr($file, -5) === '.json' && substr($file, 0, 4) !== 'l10n') {
  275. $available[] = substr($file, 0, -5);
  276. }
  277. }
  278. }
  279. }
  280. }
  281. $this->availableLanguages[$key] = $available;
  282. return $available;
  283. }
  284. /**
  285. * @return array|mixed
  286. */
  287. public function findAvailableLocales() {
  288. if (!empty($this->availableLocales)) {
  289. return $this->availableLocales;
  290. }
  291. $localeData = file_get_contents(\OC::$SERVERROOT . '/resources/locales.json');
  292. $this->availableLocales = \json_decode($localeData, true);
  293. return $this->availableLocales;
  294. }
  295. /**
  296. * @param string|null $app App id or null for core
  297. * @param string $lang
  298. * @return bool
  299. */
  300. public function languageExists($app, $lang) {
  301. if ($lang === 'en') {//english is always available
  302. return true;
  303. }
  304. $languages = $this->findAvailableLanguages($app);
  305. return array_search($lang, $languages) !== false;
  306. }
  307. public function getLanguageIterator(IUser $user = null): ILanguageIterator {
  308. $user = $user ?? $this->userSession->getUser();
  309. if ($user === null) {
  310. throw new \RuntimeException('Failed to get an IUser instance');
  311. }
  312. return new LanguageIterator($user, $this->config);
  313. }
  314. /**
  315. * Return the language to use when sending something to a user
  316. *
  317. * @param IUser|null $user
  318. * @return string
  319. * @since 20.0.0
  320. */
  321. public function getUserLanguage(IUser $user = null): string {
  322. $language = $this->config->getSystemValue('force_language', false);
  323. if ($language !== false) {
  324. return $language;
  325. }
  326. if ($user instanceof IUser) {
  327. $language = $this->config->getUserValue($user->getUID(), 'core', 'lang', null);
  328. if ($language !== null) {
  329. return $language;
  330. }
  331. }
  332. return $this->config->getSystemValue('default_language', 'en');
  333. }
  334. /**
  335. * @param string $locale
  336. * @return bool
  337. */
  338. public function localeExists($locale) {
  339. if ($locale === 'en') { //english is always available
  340. return true;
  341. }
  342. $locales = $this->findAvailableLocales();
  343. $userLocale = array_filter($locales, function ($value) use ($locale) {
  344. return $locale === $value['code'];
  345. });
  346. return !empty($userLocale);
  347. }
  348. /**
  349. * @param string|null $app
  350. * @return string
  351. * @throws LanguageNotFoundException
  352. */
  353. private function getLanguageFromRequest($app) {
  354. $header = $this->request->getHeader('ACCEPT_LANGUAGE');
  355. if ($header !== '') {
  356. $available = $this->findAvailableLanguages($app);
  357. // E.g. make sure that 'de' is before 'de_DE'.
  358. sort($available);
  359. $preferences = preg_split('/,\s*/', strtolower($header));
  360. foreach ($preferences as $preference) {
  361. list($preferred_language) = explode(';', $preference);
  362. $preferred_language = str_replace('-', '_', $preferred_language);
  363. foreach ($available as $available_language) {
  364. if ($preferred_language === strtolower($available_language)) {
  365. return $this->respectDefaultLanguage($app, $available_language);
  366. }
  367. }
  368. // Fallback from de_De to de
  369. foreach ($available as $available_language) {
  370. if (substr($preferred_language, 0, 2) === $available_language) {
  371. return $available_language;
  372. }
  373. }
  374. }
  375. }
  376. throw new LanguageNotFoundException();
  377. }
  378. /**
  379. * if default language is set to de_DE (formal German) this should be
  380. * preferred to 'de' (non-formal German) if possible
  381. *
  382. * @param string|null $app
  383. * @param string $lang
  384. * @return string
  385. */
  386. protected function respectDefaultLanguage($app, $lang) {
  387. $result = $lang;
  388. $defaultLanguage = $this->config->getSystemValue('default_language', false);
  389. // use formal version of german ("Sie" instead of "Du") if the default
  390. // language is set to 'de_DE' if possible
  391. if (is_string($defaultLanguage) &&
  392. strtolower($lang) === 'de' &&
  393. strtolower($defaultLanguage) === 'de_de' &&
  394. $this->languageExists($app, 'de_DE')
  395. ) {
  396. $result = 'de_DE';
  397. }
  398. return $result;
  399. }
  400. /**
  401. * Checks if $sub is a subdirectory of $parent
  402. *
  403. * @param string $sub
  404. * @param string $parent
  405. * @return bool
  406. */
  407. private function isSubDirectory($sub, $parent) {
  408. // Check whether $sub contains no ".."
  409. if (strpos($sub, '..') !== false) {
  410. return false;
  411. }
  412. // Check whether $sub is a subdirectory of $parent
  413. if (strpos($sub, $parent) === 0) {
  414. return true;
  415. }
  416. return false;
  417. }
  418. /**
  419. * Get a list of language files that should be loaded
  420. *
  421. * @param string $app
  422. * @param string $lang
  423. * @return string[]
  424. */
  425. // FIXME This method is only public, until OC_L10N does not need it anymore,
  426. // FIXME This is also the reason, why it is not in the public interface
  427. public function getL10nFilesForApp($app, $lang) {
  428. $languageFiles = [];
  429. $i18nDir = $this->findL10nDir($app);
  430. $transFile = strip_tags($i18nDir) . strip_tags($lang) . '.json';
  431. if (($this->isSubDirectory($transFile, $this->serverRoot . '/core/l10n/')
  432. || $this->isSubDirectory($transFile, $this->serverRoot . '/lib/l10n/')
  433. || $this->isSubDirectory($transFile, \OC_App::getAppPath($app) . '/l10n/')
  434. )
  435. && file_exists($transFile)) {
  436. // load the translations file
  437. $languageFiles[] = $transFile;
  438. }
  439. // merge with translations from theme
  440. $theme = $this->config->getSystemValue('theme');
  441. if (!empty($theme)) {
  442. $transFile = $this->serverRoot . '/themes/' . $theme . substr($transFile, strlen($this->serverRoot));
  443. if (file_exists($transFile)) {
  444. $languageFiles[] = $transFile;
  445. }
  446. }
  447. return $languageFiles;
  448. }
  449. /**
  450. * find the l10n directory
  451. *
  452. * @param string $app App id or empty string for core
  453. * @return string directory
  454. */
  455. protected function findL10nDir($app = null) {
  456. if (in_array($app, ['core', 'lib'])) {
  457. if (file_exists($this->serverRoot . '/' . $app . '/l10n/')) {
  458. return $this->serverRoot . '/' . $app . '/l10n/';
  459. }
  460. } elseif ($app && \OC_App::getAppPath($app) !== false) {
  461. // Check if the app is in the app folder
  462. return \OC_App::getAppPath($app) . '/l10n/';
  463. }
  464. return $this->serverRoot . '/core/l10n/';
  465. }
  466. /**
  467. * Creates a function from the plural string
  468. *
  469. * Parts of the code is copied from Habari:
  470. * https://github.com/habari/system/blob/master/classes/locale.php
  471. * @param string $string
  472. * @return string
  473. */
  474. public function createPluralFunction($string) {
  475. if (isset($this->pluralFunctions[$string])) {
  476. return $this->pluralFunctions[$string];
  477. }
  478. if (preg_match('/^\s*nplurals\s*=\s*(\d+)\s*;\s*plural=(.*)$/u', $string, $matches)) {
  479. // sanitize
  480. $nplurals = preg_replace('/[^0-9]/', '', $matches[1]);
  481. $plural = preg_replace('#[^n0-9:\(\)\?\|\&=!<>+*/\%-]#', '', $matches[2]);
  482. $body = str_replace(
  483. [ 'plural', 'n', '$n$plurals', ],
  484. [ '$plural', '$n', '$nplurals', ],
  485. 'nplurals='. $nplurals . '; plural=' . $plural
  486. );
  487. // add parents
  488. // important since PHP's ternary evaluates from left to right
  489. $body .= ';';
  490. $res = '';
  491. $p = 0;
  492. $length = strlen($body);
  493. for ($i = 0; $i < $length; $i++) {
  494. $ch = $body[$i];
  495. switch ($ch) {
  496. case '?':
  497. $res .= ' ? (';
  498. $p++;
  499. break;
  500. case ':':
  501. $res .= ') : (';
  502. break;
  503. case ';':
  504. $res .= str_repeat(')', $p) . ';';
  505. $p = 0;
  506. break;
  507. default:
  508. $res .= $ch;
  509. }
  510. }
  511. $body = $res . 'return ($plural>=$nplurals?$nplurals-1:$plural);';
  512. $function = create_function('$n', $body);
  513. $this->pluralFunctions[$string] = $function;
  514. return $function;
  515. } else {
  516. // default: one plural form for all cases but n==1 (english)
  517. $function = create_function(
  518. '$n',
  519. '$nplurals=2;$plural=($n==1?0:1);return ($plural>=$nplurals?$nplurals-1:$plural);'
  520. );
  521. $this->pluralFunctions[$string] = $function;
  522. return $function;
  523. }
  524. }
  525. /**
  526. * returns the common language and other languages in an
  527. * associative array
  528. *
  529. * @return array
  530. */
  531. public function getLanguages() {
  532. $forceLanguage = $this->config->getSystemValue('force_language', false);
  533. if ($forceLanguage !== false) {
  534. $l = $this->get('lib', $forceLanguage);
  535. $potentialName = (string) $l->t('__language_name__');
  536. return [
  537. 'commonlanguages' => [[
  538. 'code' => $forceLanguage,
  539. 'name' => $potentialName,
  540. ]],
  541. 'languages' => [],
  542. ];
  543. }
  544. $languageCodes = $this->findAvailableLanguages();
  545. $commonLanguages = [];
  546. $languages = [];
  547. foreach ($languageCodes as $lang) {
  548. $l = $this->get('lib', $lang);
  549. // TRANSLATORS this is the language name for the language switcher in the personal settings and should be the localized version
  550. $potentialName = (string) $l->t('__language_name__');
  551. if ($l->getLanguageCode() === $lang && $potentialName[0] !== '_') {//first check if the language name is in the translation file
  552. $ln = [
  553. 'code' => $lang,
  554. 'name' => $potentialName
  555. ];
  556. } elseif ($lang === 'en') {
  557. $ln = [
  558. 'code' => $lang,
  559. 'name' => 'English (US)'
  560. ];
  561. } else {//fallback to language code
  562. $ln = [
  563. 'code' => $lang,
  564. 'name' => $lang
  565. ];
  566. }
  567. // put appropriate languages into appropriate arrays, to print them sorted
  568. // common languages -> divider -> other languages
  569. if (in_array($lang, self::COMMON_LANGUAGE_CODES)) {
  570. $commonLanguages[array_search($lang, self::COMMON_LANGUAGE_CODES)] = $ln;
  571. } else {
  572. $languages[] = $ln;
  573. }
  574. }
  575. ksort($commonLanguages);
  576. // sort now by displayed language not the iso-code
  577. usort($languages, function ($a, $b) {
  578. if ($a['code'] === $a['name'] && $b['code'] !== $b['name']) {
  579. // If a doesn't have a name, but b does, list b before a
  580. return 1;
  581. }
  582. if ($a['code'] !== $a['name'] && $b['code'] === $b['name']) {
  583. // If a does have a name, but b doesn't, list a before b
  584. return -1;
  585. }
  586. // Otherwise compare the names
  587. return strcmp($a['name'], $b['name']);
  588. });
  589. return [
  590. // reset indexes
  591. 'commonlanguages' => array_values($commonLanguages),
  592. 'languages' => $languages
  593. ];
  594. }
  595. }