Factory.php 18 KB

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