Factory.php 18 KB

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