Factory.php 18 KB

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