From d523b2abb7b92e854e0bd3ad90e51f3122db8a8b Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 5 Jun 2026 13:23:20 -0400 Subject: [PATCH 1/8] fix(l10n): scope language caching per app and include locale in L10N instance cache Signed-off-by: Josh --- lib/private/L10N/Factory.php | 188 ++++++++++++++++------------------- 1 file changed, 88 insertions(+), 100 deletions(-) diff --git a/lib/private/L10N/Factory.php b/lib/private/L10N/Factory.php index dbf18a05c36a0..c78270d23f52b 100644 --- a/lib/private/L10N/Factory.php +++ b/lib/private/L10N/Factory.php @@ -26,33 +26,50 @@ * A factory that generates language instances */ class Factory implements IFactory { - /** @var string */ - protected $requestLanguage = ''; + /** + * Cached request language per app key. + * + * @var array + */ + protected array $requestLanguages = []; /** - * cached instances - * @var array Structure: Lang => App => \OCP\IL10N + * Cached instances. + * + * Structure: App => Lang => Locale => \OCP\IL10N + * + * @var array>> */ - protected $instances = []; + protected array $instances = []; /** - * @var array Structure: App => string[] + * @var array */ - protected $availableLanguages = []; + protected array $availableLanguages = []; /** - * @var array + * Membership map for available languages. + * + * Structure: AppKey => Lang => true + * + * @var array> */ - protected $localeCache = []; + protected array $availableLanguageMap = []; + + /** + * @var array + */ + protected array $localeCache = []; /** * @var array */ protected $availableLocales = []; - /** + /** * @var array Structure: string => callable - */ + * @var array + */ protected $pluralFunctions = []; public const COMMON_LANGUAGE_CODES = [ @@ -85,20 +102,22 @@ public function __construct( $this->cache = $cacheFactory->createLocal('L10NFactory'); } - /** - * Get a language instance - * - * @param string $app - * @param string|null $lang - * @param string|null $locale - * @return IL10N - */ + private function getAppKey(?string $app): string { + return $app ?? '__core__'; + } + + private function getLocaleKey(?string $locale): string { + return $locale ?? '__default__'; + } + #[\Override] public function get($app, $lang = null, $locale = null) { return new LazyL10N(function () use ($app, $lang, $locale) { $app = $this->appManager->cleanAppId($app); $lang = $this->cleanLanguage($lang); - $forceLang = $this->cleanLanguage($this->request->getParam('forceLanguage')) ?? $this->config->getSystemValue('force_language', false); + + $forceLang = $this->cleanLanguage($this->request->getParam('forceLanguage')) + ?? $this->config->getSystemValue('force_language', false); if (is_string($forceLang)) { $lang = $forceLang; } @@ -114,8 +133,10 @@ public function get($app, $lang = null, $locale = null) { $locale = $this->findLocale($lang); } - if (!isset($this->instances[$lang][$app])) { - $this->instances[$lang][$app] = new L10N( + $localeKey = $this->getLocaleKey($locale); + + if (!isset($this->instances[$app][$lang][$localeKey])) { + $this->instances[$app][$lang][$localeKey] = new L10N( $this, $app, $lang, @@ -124,7 +145,7 @@ public function get($app, $lang = null, $locale = null) { ); } - return $this->instances[$lang][$app]; + return $this->instances[$app][$lang][$localeKey]; }); } @@ -169,38 +190,27 @@ private function cleanLanguage(?string $lang): ?string { private function validateLanguage(string $app, ?string $lang): string { if ($lang === null || !$this->languageExists($app, $lang)) { return $this->findLanguage($app); - } else { - return $lang; } + return $lang; } - /** - * Find the best language - * - * @param string|null $appId App id or null for core - * - * @return string language If nothing works it returns 'en' - */ #[\Override] public function findLanguage(?string $appId = null): string { + $appKey = $this->getAppKey($appId); + // Step 1: Forced language always has precedence over anything else - $forceLang = $this->cleanLanguage($this->request->getParam('forceLanguage')) ?? $this->config->getSystemValue('force_language', false); + $forceLang = $this->cleanLanguage($this->request->getParam('forceLanguage')) + ?? $this->config->getSystemValue('force_language', false); if (is_string($forceLang)) { - $this->requestLanguage = $forceLang; + $this->requestLanguages[$appKey] = $forceLang; } - // Step 2: Return cached language - if ($this->requestLanguage !== '' && $this->languageExists($appId, $this->requestLanguage)) { - return $this->requestLanguage; + // Step 2: Return cached language for this app context + if (isset($this->requestLanguages[$appKey]) && $this->languageExists($appId, $this->requestLanguages[$appKey])) { + return $this->requestLanguages[$appKey]; } - /** - * Step 3: At this point Nextcloud might not yet be installed and thus the lookup - * in the preferences table might fail. For this reason we need to check - * whether the instance has already been installed - * - * @link https://github.com/owncloud/core/issues/21955 - */ + // Step 3: User preference (only if installed) if ($this->config->getSystemValueBool('installed', false)) { $userId = !is_null($this->userSession->getUser()) ? $this->userSession->getUser()->getUID() : null; if (!is_null($userId)) { @@ -212,8 +222,9 @@ public function findLanguage(?string $appId = null): string { $userId = null; $userLang = null; } + if ($userLang) { - $this->requestLanguage = $userLang; + $this->requestLanguages[$appKey] = $userLang; if ($this->languageExists($appId, $userLang)) { return $userLang; } @@ -221,21 +232,25 @@ public function findLanguage(?string $appId = null): string { // Step 4: Check the request headers try { - // Try to get the language from the Request $lang = $this->getLanguageFromRequest($appId); + $this->requestLanguages[$appKey] = $lang; + if ($userId !== null && $appId === null && !$userLang) { $this->config->setUserValue($userId, 'core', 'lang', $lang); } + return $lang; } catch (LanguageNotFoundException $e) { - // Finding language from request failed fall back to default language + // Fall back to default language (if available) $defaultLanguage = $this->config->getSystemValue('default_language', false); if ($defaultLanguage !== false && $this->languageExists($appId, $defaultLanguage)) { + $this->requestLanguages[$appKey] = $defaultLanguage; return $defaultLanguage; } } - // Step 5: fall back to English + // Step 5: Fall back to English (last resort) + $this->requestLanguages[$appKey] = 'en'; return 'en'; } @@ -257,12 +272,6 @@ public function findGenericLanguage(?string $appId = null): string { return 'en'; } - /** - * find the best locale - * - * @param string $lang - * @return null|string - */ #[\Override] public function findLocale($lang = null) { $forceLocale = $this->config->getSystemValue('force_locale', false); @@ -300,15 +309,12 @@ public function findLocale($lang = null) { return 'en_US'; } - /** - * find the matching lang from the locale - * - * @param string $app - * @param string $locale - * @return null|string - */ #[\Override] public function findLanguageFromLocale(string $app = 'core', ?string $locale = null) { + if ($locale === null || $locale === '') { + return null; + } + if ($this->languageExists($app, $locale)) { return $locale; } @@ -318,38 +324,33 @@ public function findLanguageFromLocale(string $app = 'core', ?string $locale = n if ($this->languageExists($app, $locale)) { return $locale; } + + return null; } - /** - * Find all available languages for an app - * - * @param string|null $app App id or null for core - * @return string[] an array of available languages - */ #[\Override] public function findAvailableLanguages($app = null): array { - $key = $app; - if ($key === null) { - $key = 'null'; - } + $key = $this->getAppKey($app); - if ($availableLanguages = $this->cache->get($key)) { + $availableLanguages = $this->cache->get($key); + if (is_array($availableLanguages)) { $this->availableLanguages[$key] = $availableLanguages; + $this->availableLanguageMap[$key] = array_fill_keys($availableLanguages, true); } - // also works with null as key if (!empty($this->availableLanguages[$key])) { return $this->availableLanguages[$key]; } - $available = ['en']; //english is always available + $availableSet = ['en' => true]; // English is always available $dir = $this->findL10nDir($app); + if (is_dir($dir)) { $files = scandir($dir); if ($files !== false) { foreach ($files as $file) { if (str_ends_with($file, '.json') && !str_starts_with($file, 'l10n')) { - $available[] = substr($file, 0, -5); + $availableSet[substr($file, 0, -5)] = true; } } } @@ -365,21 +366,23 @@ public function findAvailableLanguages($app = null): array { if ($files !== false) { foreach ($files as $file) { if (str_ends_with($file, '.json') && !str_starts_with($file, 'l10n')) { - $available[] = substr($file, 0, -5); + $availableSet[substr($file, 0, -5)] = true; } } } } } + $available = array_keys($availableSet); + sort($available); + $this->availableLanguages[$key] = $available; + $this->availableLanguageMap[$key] = array_fill_keys($available, true); $this->cache->set($key, $available, 60); + return $available; } - /** - * @return array|mixed - */ #[\Override] public function findAvailableLocales() { if (!empty($this->availableLocales)) { @@ -392,19 +395,18 @@ public function findAvailableLocales() { return $this->availableLocales; } - /** - * @param string|null $app App id or null for core - * @param string $lang - * @return bool - */ #[\Override] public function languageExists($app, $lang) { if ($lang === 'en') { //english is always available return true; } - $languages = $this->findAvailableLanguages($app); - return in_array($lang, $languages); + $key = $this->getAppKey($app); + if (!isset($this->availableLanguageMap[$key])) { + $this->findAvailableLanguages($app); + } + + return isset($this->availableLanguageMap[$key][$lang]); } #[\Override] @@ -425,13 +427,6 @@ public function getLanguageIterator(?IUser $user = null): ILanguageIterator { return new LanguageIterator($user, $this->config); } - /** - * Return the language to use when sending something to a user - * - * @param IUser|null $user - * @return string - * @since 20.0.0 - */ #[\Override] public function getUserLanguage(?IUser $user = null): string { $language = $this->config->getSystemValue('force_language', false); @@ -463,10 +458,6 @@ public function getUserLanguage(?IUser $user = null): string { return $this->cleanLanguage($this->request->getParam('forceLanguage')) ?? $this->config->getSystemValueString('default_language', 'en'); } - /** - * @param string $locale - * @return bool - */ #[\Override] public function localeExists($locale) { if ($locale === 'en') { //english is always available @@ -617,9 +608,6 @@ protected function findL10nDir($app = null) { return $this->serverRoot . '/core/l10n/'; } - /** - * @inheritDoc - */ #[\Override] public function getLanguages(): array { $forceLanguage = $this->config->getSystemValue('force_language', false); From 59c6e47236bc8b3822732f205bc60565c2a5116b Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 5 Jun 2026 13:47:57 -0400 Subject: [PATCH 2/8] docs(L10N): cleanup Factory comment blocks/inline comments Signed-off-by: Josh --- lib/private/L10N/Factory.php | 123 +++++++++++++++++++++++------------ 1 file changed, 83 insertions(+), 40 deletions(-) diff --git a/lib/private/L10N/Factory.php b/lib/private/L10N/Factory.php index c78270d23f52b..2761b6a27aea0 100644 --- a/lib/private/L10N/Factory.php +++ b/lib/private/L10N/Factory.php @@ -23,26 +23,30 @@ use function is_null; /** - * A factory that generates language instances + * Factory for creating language instances. */ class Factory implements IFactory { /** - * Cached request language per app key. + * Cached resolved language per app context. * * @var array */ protected array $requestLanguages = []; /** - * Cached instances. + * Cached L10N instances. * - * Structure: App => Lang => Locale => \OCP\IL10N + * Structure: app => language => localeKey => IL10N * * @var array>> */ protected array $instances = []; /** + * Cached available languages per app key. + * + * Structure: appKey => string[] + * * @var array */ protected array $availableLanguages = []; @@ -50,25 +54,32 @@ class Factory implements IFactory { /** * Membership map for available languages. * - * Structure: AppKey => Lang => true + * Structure: appKey => languageCode => true * * @var array> */ protected array $availableLanguageMap = []; /** + * Lookup cache for locale existence checks. + * + * Structure: localeCode => true + * * @var array */ protected array $localeCache = []; /** + * Cached locale metadata loaded from resources/locales.json. + * * @var array */ protected $availableLocales = []; /** - * @var array Structure: string => callable - * @var array + * Cached plural rule callbacks by language. + * + * @var array */ protected $pluralFunctions = []; @@ -102,10 +113,16 @@ public function __construct( $this->cache = $cacheFactory->createLocal('L10NFactory'); } + /** + * Returns the normalized cache key used for app-scoped caches. + */ private function getAppKey(?string $app): string { return $app ?? '__core__'; } + /** + * Returns the normalized cache key used for locale-scoped caches. + */ private function getLocaleKey(?string $locale): string { return $locale ?? '__default__'; } @@ -150,7 +167,7 @@ public function get($app, $lang = null, $locale = null) { } /** - * Remove some invalid characters before using a string as a language + * Removes unsupported characters before a value is used as a language code. * * @psalm-taint-escape callable * @psalm-taint-escape cookie @@ -173,7 +190,10 @@ private function cleanLanguage(?string $lang): ?string { } /** - * Check that $lang is an existing language and not null, otherwise return the language to use instead + * Validates a language code for the given app. + * + * Returns the provided language code when available for the app; otherwise + * falls back to the best resolved language for that app. * * @psalm-taint-escape callable * @psalm-taint-escape cookie @@ -198,19 +218,24 @@ private function validateLanguage(string $app, ?string $lang): string { public function findLanguage(?string $appId = null): string { $appKey = $this->getAppKey($appId); - // Step 1: Forced language always has precedence over anything else + // Step 1: a forced language overrides any other source. $forceLang = $this->cleanLanguage($this->request->getParam('forceLanguage')) ?? $this->config->getSystemValue('force_language', false); if (is_string($forceLang)) { $this->requestLanguages[$appKey] = $forceLang; } - // Step 2: Return cached language for this app context + // Step 2: reuse the already resolved language for this app context. if (isset($this->requestLanguages[$appKey]) && $this->languageExists($appId, $this->requestLanguages[$appKey])) { return $this->requestLanguages[$appKey]; } - // Step 3: User preference (only if installed) + // Step 3: User preference (if installed) + // + // Nextcloud may not be installed yet, so user preference lookup + // can fail before the preferences table exists. + // + // @see https://github.com/owncloud/core/issues/21955 if ($this->config->getSystemValueBool('installed', false)) { $userId = !is_null($this->userSession->getUser()) ? $this->userSession->getUser()->getUID() : null; if (!is_null($userId)) { @@ -230,7 +255,7 @@ public function findLanguage(?string $appId = null): string { } } - // Step 4: Check the request headers + // Step 4: inspect the request headers. try { $lang = $this->getLanguageFromRequest($appId); $this->requestLanguages[$appKey] = $lang; @@ -256,19 +281,20 @@ public function findLanguage(?string $appId = null): string { #[\Override] public function findGenericLanguage(?string $appId = null): string { - // Step 1: Forced language always has precedence over anything else - $forcedLanguage = $this->cleanLanguage($this->request->getParam('forceLanguage')) ?? $this->config->getSystemValue('force_language', false); + // Step 1: a forced language overrides any other source. + $forcedLanguage = $this->cleanLanguage($this->request->getParam('forceLanguage')) + ?? $this->config->getSystemValue('force_language', false); if ($forcedLanguage !== false) { return $forcedLanguage; } - // Step 2: Check if we have a default language + // Step 2: use default language (if available) $defaultLanguage = $this->config->getSystemValue('default_language', false); if ($defaultLanguage !== false && $this->languageExists($appId, $defaultLanguage)) { return $defaultLanguage; } - // Step 3: fall back to English + // Step 3: Fall back to English (last resort) return 'en'; } @@ -294,7 +320,7 @@ public function findLocale($lang = null) { return $userLocale; } - // Default : use system default locale + // Default: use system default locale $defaultLocale = $this->config->getSystemValue('default_locale', false); if ($defaultLocale !== false && $this->localeExists($defaultLocale)) { return $defaultLocale; @@ -305,7 +331,7 @@ public function findLocale($lang = null) { return $lang; } - // At last, return USA + // Fall back (last resort) return 'en_US'; } @@ -356,7 +382,7 @@ public function findAvailableLanguages($app = null): array { } } - // merge with translations from theme + // Merge translations from the active theme. $theme = $this->config->getSystemValueString('theme'); if (!empty($theme)) { $themeDir = $this->serverRoot . '/themes/' . $theme . substr($dir, strlen($this->serverRoot)); @@ -455,12 +481,13 @@ public function getUserLanguage(?IUser $user = null): string { } } - return $this->cleanLanguage($this->request->getParam('forceLanguage')) ?? $this->config->getSystemValueString('default_language', 'en'); + return $this->cleanLanguage($this->request->getParam('forceLanguage')) + ?? $this->config->getSystemValueString('default_language', 'en'); } #[\Override] public function localeExists($locale) { - if ($locale === 'en') { //english is always available + if ($locale === 'en') { // English is always available return true; } @@ -475,14 +502,18 @@ public function localeExists($locale) { } /** - * @throws LanguageNotFoundException + * Resolve the best language from the Accept-Language request header. + * + * @param string|null $app App id or null for core + * @return string + * @throws LanguageNotFoundException When no matching language can be resolved */ private function getLanguageFromRequest(?string $app = null): string { $header = $this->cleanLanguage($this->request->getHeader('ACCEPT_LANGUAGE')); if ($header !== '') { $available = $this->findAvailableLanguages($app); - // E.g. make sure that 'de' is before 'de_DE'. + // Ensure generic language codes are checked before region-specific ones, e.g. de before de_DE. sort($available); $preferences = preg_split('/,\s*/', strtolower($header)); @@ -500,7 +531,7 @@ private function getLanguageFromRequest(?string $app = null): string { } } - // Fallback from de_De to de + // Fallback from a region-specific locale, e.g. de_DE => de. foreach ($available as $available_language) { if ($preferred_language_parts[0] === $available_language) { return $available_language; @@ -513,15 +544,16 @@ private function getLanguageFromRequest(?string $app = null): string { } /** - * if default language is set to de_DE (formal German) this should be - * preferred to 'de' (non-formal German) if possible + * Prefer the configured default language when it provides a more specific match. + * + * For example, if the browser requests `de` (non-formal German) and the instance + * default language is `de_DE` (formal German), prefer `de_DE` when that translation + * exists. */ protected function respectDefaultLanguage(?string $app, string $lang): string { $result = $lang; $defaultLanguage = $this->config->getSystemValue('default_language', false); - // use formal version of german ("Sie" instead of "Du") if the default - // language is set to 'de_DE' if possible if ( is_string($defaultLanguage) && strtolower($lang) === 'de' @@ -535,10 +567,12 @@ protected function respectDefaultLanguage(?string $app, string $lang): string { } /** - * Checks if $sub is a subdirectory of $parent + * Checks whether a path is inside the given parent directory. * - * @param string $sub - * @param string $parent + * This also rejects paths containing `..`. + * + * @param string $path + * @param string $parentDirectory * @return bool */ private function isSubDirectory($sub, $parent) { @@ -556,8 +590,13 @@ private function isSubDirectory($sub, $parent) { } /** - * Get a list of language files that should be loaded + * Return the translation files to load for an app and language. + * + * Includes the base translation file and, when present, the corresponding + * theme override file. * + * @param string $app + * @param string $lang * @return string[] */ private function getL10nFilesForApp(string $app, string $lang): array { @@ -571,11 +610,10 @@ private function getL10nFilesForApp(string $app, string $lang): array { || $this->isSubDirectory($transFile, $this->appManager->getAppPath($app) . '/l10n/')) && file_exists($transFile) ) { - // load the translations file $languageFiles[] = $transFile; } - // merge with translations from theme + // Merge translations from the active theme. $theme = $this->config->getSystemValueString('theme'); if (!empty($theme)) { $transFile = $this->serverRoot . '/themes/' . $theme . substr($transFile, strlen($this->serverRoot)); @@ -588,10 +626,14 @@ private function getL10nFilesForApp(string $app, string $lang): array { } /** - * find the l10n directory + * Return the l10n directory for an app. * - * @param string $app App id or empty string for core - * @return string directory + * For `core` and `lib`, use the corresponding built-in directory when present. + * For other apps, resolve the app path and append `/l10n/`. + * Falls back to the core l10n directory when the app cannot be resolved. + * + * @param string|null $app App id or null for core + * @return string */ protected function findL10nDir($app = null) { if (in_array($app, ['core', 'lib'])) { @@ -602,7 +644,7 @@ protected function findL10nDir($app = null) { try { return $this->appManager->getAppPath($app) . '/l10n/'; } catch (AppPathNotFoundException) { - /* App not found, continue */ + // App not found, fall through to the core l10n directory. } } return $this->serverRoot . '/core/l10n/'; @@ -637,6 +679,7 @@ public function getLanguages(): array { $l = $this->get('lib', $lang); // TRANSLATORS this is the language name for the language switcher in the personal settings and should be the localized version $potentialName = $l->t('__language_name__'); + if ($l->getLanguageCode() === $lang && $potentialName[0] !== '_') { //first check if the language name is in the translation file $ln = [ 'code' => $lang, @@ -665,7 +708,7 @@ public function getLanguages(): array { ksort($commonLanguages); - // sort now by displayed language not the iso-code + // Sort by display name rather than language code. usort($otherLanguages, function ($a, $b) { if ($a['code'] === $a['name'] && $b['code'] !== $b['name']) { // If a doesn't have a name, but b does, list b before a From 687285326f17ef5a595a0b197482458ba94c2f0a Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 5 Jun 2026 14:30:55 -0400 Subject: [PATCH 3/8] refactor(L10N): clearer/more uniform variable naming in the Factory Signed-off-by: Josh --- lib/private/L10N/Factory.php | 344 +++++++++++++++++------------------ 1 file changed, 165 insertions(+), 179 deletions(-) diff --git a/lib/private/L10N/Factory.php b/lib/private/L10N/Factory.php index 2761b6a27aea0..4c9331bd5e2c8 100644 --- a/lib/private/L10N/Factory.php +++ b/lib/private/L10N/Factory.php @@ -116,14 +116,14 @@ public function __construct( /** * Returns the normalized cache key used for app-scoped caches. */ - private function getAppKey(?string $app): string { + private function getAppCacheKey(?string $app): string { return $app ?? '__core__'; } /** * Returns the normalized cache key used for locale-scoped caches. */ - private function getLocaleKey(?string $locale): string { + private function getLocaleCacheKey(?string $locale): string { return $locale ?? '__default__'; } @@ -133,15 +133,15 @@ public function get($app, $lang = null, $locale = null) { $app = $this->appManager->cleanAppId($app); $lang = $this->cleanLanguage($lang); - $forceLang = $this->cleanLanguage($this->request->getParam('forceLanguage')) + $forcedLanguage = $this->cleanLanguage($this->request->getParam('forceLanguage')) ?? $this->config->getSystemValue('force_language', false); - if (is_string($forceLang)) { - $lang = $forceLang; + if (is_string($forcedLanguage)) + $lang = $forcedLanguage; } - $forceLocale = $this->config->getSystemValue('force_locale', false); - if (is_string($forceLocale)) { - $locale = $forceLocale; + $forcedLocale = $this->config->getSystemValue('force_locale', false); + if (is_string($forcedLocale)) { + $locale = $forcedLocale; } $lang = $this->validateLanguage($app, $lang); @@ -150,10 +150,10 @@ public function get($app, $lang = null, $locale = null) { $locale = $this->findLocale($lang); } - $localeKey = $this->getLocaleKey($locale); + $localeCacheKey = $this->getLocaleCacheKey($locale); - if (!isset($this->instances[$app][$lang][$localeKey])) { - $this->instances[$app][$lang][$localeKey] = new L10N( + if (!isset($this->instances[$app][$lang][$localeCacheKey])) { + $this->instances[$app][$lang][$localeCacheKey] = new L10N( $this, $app, $lang, @@ -162,7 +162,7 @@ public function get($app, $lang = null, $locale = null) { ); } - return $this->instances[$app][$lang][$localeKey]; + return $this->instances[$app][$lang][$localeCacheKey]; }); } @@ -181,12 +181,13 @@ public function get($app, $lang = null, $locale = null) { * @psalm-taint-escape sql * @psalm-taint-escape unserialize */ - private function cleanLanguage(?string $lang): ?string { - if ($lang === null) { + private function cleanLanguage(?string $languageCode): ?string { + if ($languageCode === null) { return null; } - $lang = preg_replace('/[^a-zA-Z0-9.;,=_-]/', '', $lang); - return str_replace('..', '', $lang); + + $languageCode = preg_replace('/[^a-zA-Z0-9.;,=_-]/', '', $languageCode); + return str_replace('..', '', $languageCode); } /** @@ -207,27 +208,28 @@ private function cleanLanguage(?string $lang): ?string { * @psalm-taint-escape sql * @psalm-taint-escape unserialize */ - private function validateLanguage(string $app, ?string $lang): string { - if ($lang === null || !$this->languageExists($app, $lang)) { - return $this->findLanguage($app); + private function validateLanguage(string $appId, ?string $languageCode): string { + if ($languageCode === null || !$this->languageExists($appId, $languageCode)) { + return $this->findLanguage($appId); } - return $lang; + + return $languageCode; } #[\Override] public function findLanguage(?string $appId = null): string { - $appKey = $this->getAppKey($appId); + $appCacheKey = $this->getAppCacheKey($appId); // Step 1: a forced language overrides any other source. - $forceLang = $this->cleanLanguage($this->request->getParam('forceLanguage')) + $forcedLanguage = $this->cleanLanguage($this->request->getParam('forceLanguage')) ?? $this->config->getSystemValue('force_language', false); - if (is_string($forceLang)) { - $this->requestLanguages[$appKey] = $forceLang; + if (is_string($forcedLanguage)) { + $this->requestLanguages[$appCacheKey] = $forcedLanguage; } // Step 2: reuse the already resolved language for this app context. - if (isset($this->requestLanguages[$appKey]) && $this->languageExists($appId, $this->requestLanguages[$appKey])) { - return $this->requestLanguages[$appKey]; + if (isset($this->requestLanguages[$appCacheKey]) && $this->languageExists($appId, $this->requestLanguages[$appCacheKey])) { + return $this->requestLanguages[$appCacheKey]; } // Step 3: User preference (if installed) @@ -237,80 +239,72 @@ public function findLanguage(?string $appId = null): string { // // @see https://github.com/owncloud/core/issues/21955 if ($this->config->getSystemValueBool('installed', false)) { - $userId = !is_null($this->userSession->getUser()) ? $this->userSession->getUser()->getUID() : null; - if (!is_null($userId)) { - $userLang = $this->config->getUserValue($userId, 'core', 'lang', null); - } else { - $userLang = null; - } + $currentUser = $this->userSession->getUser(); + $userId = $currentUser !== null ? $currentUser->getUID() : null; + $userLanguage = $userId !== null ? $this->config->getUserValue($userId, 'core', 'lang', null) : null; } else { $userId = null; - $userLang = null; + $userLanguage = null; } - if ($userLang) { - $this->requestLanguages[$appKey] = $userLang; - if ($this->languageExists($appId, $userLang)) { - return $userLang; + if ($userLanguage) { + $this->requestLanguages[$appCacheKey] = $userLanguage; + if ($this->languageExists($appId, $userLanguage)) { + return $userLanguage; } } // Step 4: inspect the request headers. try { - $lang = $this->getLanguageFromRequest($appId); - $this->requestLanguages[$appKey] = $lang; + $resolvedLanguage = $this->getLanguageFromRequest($appId); + $this->requestLanguages[$appCacheKey] = $resolvedLanguage; - if ($userId !== null && $appId === null && !$userLang) { - $this->config->setUserValue($userId, 'core', 'lang', $lang); + if ($userId !== null && $appId === null && !$userLanguage) { + $this->config->setUserValue($userId, 'core', 'lang', $resolvedLanguage); } - return $lang; + return $resolvedLanguage; } catch (LanguageNotFoundException $e) { // Fall back to default language (if available) $defaultLanguage = $this->config->getSystemValue('default_language', false); if ($defaultLanguage !== false && $this->languageExists($appId, $defaultLanguage)) { - $this->requestLanguages[$appKey] = $defaultLanguage; + $this->requestLanguages[$appCacheKey] = $defaultLanguage; return $defaultLanguage; } } // Step 5: Fall back to English (last resort) - $this->requestLanguages[$appKey] = 'en'; + $this->requestLanguages[$appCacheKey] = 'en'; return 'en'; } #[\Override] public function findGenericLanguage(?string $appId = null): string { - // Step 1: a forced language overrides any other source. $forcedLanguage = $this->cleanLanguage($this->request->getParam('forceLanguage')) ?? $this->config->getSystemValue('force_language', false); if ($forcedLanguage !== false) { return $forcedLanguage; } - // Step 2: use default language (if available) $defaultLanguage = $this->config->getSystemValue('default_language', false); if ($defaultLanguage !== false && $this->languageExists($appId, $defaultLanguage)) { return $defaultLanguage; } - // Step 3: Fall back to English (last resort) return 'en'; } #[\Override] public function findLocale($lang = null) { - $forceLocale = $this->config->getSystemValue('force_locale', false); - if (is_string($forceLocale) && $this->localeExists($forceLocale)) { - return $forceLocale; + $forcedLocale = $this->config->getSystemValue('force_locale', false); + if (is_string($forcedLocale) && $this->localeExists($forcedLocale)) { + return $forcedLocale; } if ($this->config->getSystemValueBool('installed', false)) { - $userId = $this->userSession->getUser() !== null ? $this->userSession->getUser()->getUID() : null; - $userLocale = null; - if ($userId !== null) { - $userLocale = $this->config->getUserValue($userId, 'core', 'locale', null); - } + $currentUser = $this->userSession->getUser(); + $userId = $currentUser !== null ? $currentUser->getUID() : null; + $userLocale = $userId !== null ? $this->config->getUserValue($userId, 'core', 'locale', null) : null; } else { $userId = null; $userLocale = null; @@ -320,7 +314,6 @@ public function findLocale($lang = null) { return $userLocale; } - // Default: use system default locale $defaultLocale = $this->config->getSystemValue('default_locale', false); if ($defaultLocale !== false && $this->localeExists($defaultLocale)) { return $defaultLocale; @@ -331,7 +324,7 @@ public function findLocale($lang = null) { return $lang; } - // Fall back (last resort) + // Fall back return 'en_US'; } @@ -346,9 +339,9 @@ public function findLanguageFromLocale(string $app = 'core', ?string $locale = n } // Try to split e.g: fr_FR => fr - $locale = explode('_', $locale)[0]; - if ($this->languageExists($app, $locale)) { - return $locale; + $languageCode = explode('_', $locale)[0]; + if ($this->languageExists($app, $languageCode)) { + return $languageCode; } return null; @@ -356,27 +349,27 @@ public function findLanguageFromLocale(string $app = 'core', ?string $locale = n #[\Override] public function findAvailableLanguages($app = null): array { - $key = $this->getAppKey($app); + $appCacheKey = $this->getAppCacheKey($app); - $availableLanguages = $this->cache->get($key); - if (is_array($availableLanguages)) { - $this->availableLanguages[$key] = $availableLanguages; - $this->availableLanguageMap[$key] = array_fill_keys($availableLanguages, true); + $cachedLanguages = $this->cache->get($appCacheKey); + if (is_array($cachedLanguages)) { + $this->availableLanguages[$appCacheKey] = $cachedLanguages; + $this->availableLanguageMap[$appCacheKey] = array_fill_keys($cachedLanguages, true); } - if (!empty($this->availableLanguages[$key])) { - return $this->availableLanguages[$key]; + if (!empty($this->availableLanguages[$appCacheKey])) { + return $this->availableLanguages[$appCacheKey]; } - $availableSet = ['en' => true]; // English is always available - $dir = $this->findL10nDir($app); + $availableLanguageSet = ['en' => true]; // English is always available + $l10nDir = $this->findL10nDir($app); - if (is_dir($dir)) { - $files = scandir($dir); + if (is_dir($l10nDir)) { + $files = scandir($l10nDir); if ($files !== false) { - foreach ($files as $file) { - if (str_ends_with($file, '.json') && !str_starts_with($file, 'l10n')) { - $availableSet[substr($file, 0, -5)] = true; + foreach ($files as $fileName) { + if (str_ends_with($fileName, '.json') && !str_starts_with($fileName, 'l10n')) { + $availableLanguageSet[substr($fileName, 0, -5)] = true; } } } @@ -385,28 +378,28 @@ public function findAvailableLanguages($app = null): array { // Merge translations from the active theme. $theme = $this->config->getSystemValueString('theme'); if (!empty($theme)) { - $themeDir = $this->serverRoot . '/themes/' . $theme . substr($dir, strlen($this->serverRoot)); + $themeL10nDir = $this->serverRoot . '/themes/' . $theme . substr($l10nDir, strlen($this->serverRoot)); - if (is_dir($themeDir)) { - $files = scandir($themeDir); + if (is_dir($themeL10nDir)) { + $files = scandir($themeL10nDir); if ($files !== false) { - foreach ($files as $file) { - if (str_ends_with($file, '.json') && !str_starts_with($file, 'l10n')) { - $availableSet[substr($file, 0, -5)] = true; + foreach ($files as $fileName) { + if (str_ends_with($fileName, '.json') && !str_starts_with($fileName, 'l10n')) { + $availableLanguageSet[substr($fileName, 0, -5)] = true; } } } } } - $available = array_keys($availableSet); - sort($available); + $availableLanguages = array_keys($availableLanguageSet); + sort($availableLanguages); - $this->availableLanguages[$key] = $available; - $this->availableLanguageMap[$key] = array_fill_keys($available, true); - $this->cache->set($key, $available, 60); + $this->availableLanguages[$appCacheKey] = $availableLanguages; + $this->availableLanguageMap[$appCacheKey] = array_fill_keys($availableLanguages, true); + $this->cache->set($appCacheKey, $availableLanguages, 60); - return $available; + return $availableLanguages; } #[\Override] @@ -427,12 +420,13 @@ public function languageExists($app, $lang) { return true; } - $key = $this->getAppKey($app); - if (!isset($this->availableLanguageMap[$key])) { + $appCacheKey = $this->getAppCacheKey($app); + + if (!isset($this->availableLanguageMap[$appCacheKey])) { $this->findAvailableLanguages($app); } - return isset($this->availableLanguageMap[$key][$lang]); + return isset($this->availableLanguageMap[$appCacheKey][$lang]); } #[\Override] @@ -450,28 +444,29 @@ public function getLanguageIterator(?IUser $user = null): ILanguageIterator { if ($user === null) { throw new \RuntimeException('Failed to get an IUser instance'); } + return new LanguageIterator($user, $this->config); } #[\Override] public function getUserLanguage(?IUser $user = null): string { - $language = $this->config->getSystemValue('force_language', false); - if ($language !== false) { - return $language; + $forcedLanguage = $this->config->getSystemValue('force_language', false); + if ($forcedLanguage !== false) { + return $forcedLanguage; } if ($user instanceof IUser) { - $language = $this->config->getUserValue($user->getUID(), 'core', 'lang', null); - if ($language !== null) { - return $language; + $userLanguage = $this->config->getUserValue($user->getUID(), 'core', 'lang', null); + if ($userLanguage !== null) { + return $userLanguage; } - $forcedLanguage = $this->cleanLanguage($this->request->getParam('forceLanguage')); - if ($forcedLanguage !== null) { - return $forcedLanguage; + $forcedRequestLanguage = $this->cleanLanguage($this->request->getParam('forceLanguage')); + if ($forcedRequestLanguage !== null) { + return $forcedRequestLanguage; } - // Use language from request + // Use the request language for the currently authenticated user. if ($this->userSession->getUser() instanceof IUser && $user->getUID() === $this->userSession->getUser()->getUID()) { try { @@ -492,9 +487,9 @@ public function localeExists($locale) { } if ($this->localeCache === []) { - $locales = $this->findAvailableLocales(); - foreach ($locales as $l) { - $this->localeCache[$l['code']] = true; + $availableLocales = $this->findAvailableLocales(); + foreach ($availableLocales as $localeDefinition) { + $this->localeCache[$localeDefinition['code']] = true; } } @@ -509,32 +504,33 @@ public function localeExists($locale) { * @throws LanguageNotFoundException When no matching language can be resolved */ private function getLanguageFromRequest(?string $app = null): string { - $header = $this->cleanLanguage($this->request->getHeader('ACCEPT_LANGUAGE')); - if ($header !== '') { - $available = $this->findAvailableLanguages($app); + $acceptLanguageHeader = $this->cleanLanguage($this->request->getHeader('ACCEPT_LANGUAGE')); + if ($acceptLanguageHeader !== '') { + $availableLanguages = $this->findAvailableLanguages($app); // Ensure generic language codes are checked before region-specific ones, e.g. de before de_DE. - sort($available); + sort($availableLanguages); - $preferences = preg_split('/,\s*/', strtolower($header)); - foreach ($preferences as $preference) { - [$preferred_language] = explode(';', $preference); - $preferred_language = str_replace('-', '_', $preferred_language); + $languagePreferences = preg_split('/,\s*/', strtolower($acceptLanguageHeader)); + foreach ($languagePreferences as $languagePreference) { + [$preferredLanguage] = explode(';', $languagePreference); + $preferredLanguage = str_replace('-', '_', $preferredLanguage); - $preferred_language_parts = explode('_', $preferred_language); - foreach ($available as $available_language) { - if ($preferred_language === strtolower($available_language)) { - return $this->respectDefaultLanguage($app, $available_language); + $preferredLanguageParts = explode('_', $preferredLanguage); + foreach ($availableLanguages as $availableLanguage) { + if ($preferredLanguage === strtolower($availableLanguage)) { + return $this->respectDefaultLanguage($app, $availableLanguage); } - if (strtolower($available_language) === $preferred_language_parts[0] . '_' . end($preferred_language_parts)) { - return $available_language; + + if (strtolower($availableLanguage) === $preferredLanguageParts[0] . '_' . end($preferredLanguageParts)) { + return $availableLanguage; } } // Fallback from a region-specific locale, e.g. de_DE => de. - foreach ($available as $available_language) { - if ($preferred_language_parts[0] === $available_language) { - return $available_language; + foreach ($availableLanguages as $availableLanguage) { + if ($preferredLanguageParts[0] === $availableLanguage) { + return $availableLanguage; } } } @@ -551,7 +547,7 @@ private function getLanguageFromRequest(?string $app = null): string { * exists. */ protected function respectDefaultLanguage(?string $app, string $lang): string { - $result = $lang; + $resolvedLanguage = $lang; $defaultLanguage = $this->config->getSystemValue('default_language', false); if ( @@ -560,33 +556,23 @@ protected function respectDefaultLanguage(?string $app, string $lang): string { && strtolower($defaultLanguage) === 'de_de' && $this->languageExists($app, 'de_DE') ) { - $result = 'de_DE'; + $resolvedLanguage = 'de_DE'; } - return $result; + return $resolvedLanguage; } /** * Checks whether a path is inside the given parent directory. * * This also rejects paths containing `..`. - * - * @param string $path - * @param string $parentDirectory - * @return bool */ - private function isSubDirectory($sub, $parent) { - // Check whether $sub contains no ".." - if (str_contains($sub, '..')) { + private function isSubDirectory(string $path, string $parentDirectory): bool { + if (str_contains($path, '..')) { return false; } - // Check whether $sub is a subdirectory of $parent - if (str_starts_with($sub, $parent)) { - return true; - } - - return false; + return str_starts_with($path, $parentDirectory); } /** @@ -602,23 +588,23 @@ private function isSubDirectory($sub, $parent) { private function getL10nFilesForApp(string $app, string $lang): array { $languageFiles = []; - $i18nDir = $this->findL10nDir($app); - $transFile = strip_tags($i18nDir) . strip_tags($lang) . '.json'; + $l10nDir = $this->findL10nDir($app); + $translationFile = strip_tags($l10nDir) . strip_tags($lang) . '.json'; - if (($this->isSubDirectory($transFile, $this->serverRoot . '/core/l10n/') - || $this->isSubDirectory($transFile, $this->serverRoot . '/lib/l10n/') - || $this->isSubDirectory($transFile, $this->appManager->getAppPath($app) . '/l10n/')) - && file_exists($transFile) + if (($this->isSubDirectory($translationFile, $this->serverRoot . '/core/l10n/') + || $this->isSubDirectory($translationFile, $this->serverRoot . '/lib/l10n/') + || $this->isSubDirectory($translationFile, $this->appManager->getAppPath($app) . '/l10n/')) + && file_exists($translationFile) ) { - $languageFiles[] = $transFile; + $languageFiles[] = $translationFile; } // Merge translations from the active theme. $theme = $this->config->getSystemValueString('theme'); if (!empty($theme)) { - $transFile = $this->serverRoot . '/themes/' . $theme . substr($transFile, strlen($this->serverRoot)); - if (file_exists($transFile)) { - $languageFiles[] = $transFile; + $themeTranslationFile = $this->serverRoot . '/themes/' . $theme . substr($translationFile, strlen($this->serverRoot)); + if (file_exists($themeTranslationFile)) { + $languageFiles[] = $themeTranslationFile; } } @@ -633,9 +619,8 @@ private function getL10nFilesForApp(string $app, string $lang): array { * Falls back to the core l10n directory when the app cannot be resolved. * * @param string|null $app App id or null for core - * @return string */ - protected function findL10nDir($app = null) { + protected function findL10nDir(?string $app = null): string { if (in_array($app, ['core', 'lib'])) { if (file_exists($this->serverRoot . '/' . $app . '/l10n/')) { return $this->serverRoot . '/' . $app . '/l10n/'; @@ -652,74 +637,75 @@ protected function findL10nDir($app = null) { #[\Override] public function getLanguages(): array { - $forceLanguage = $this->config->getSystemValue('force_language', false); + $forcedLanguage = $this->config->getSystemValue('force_language', false); if ($forceLanguage !== false) { - $l = $this->get('lib', $forceLanguage); - $potentialName = $l->t('__language_name__'); + $l10n = $this->get('lib', $forcedLanguage); + $languageName = $l10n->t('__language_name__'); return [ 'commonLanguages' => [[ - 'code' => $forceLanguage, - 'name' => $potentialName, + 'code' => $forcedLanguage, + 'name' => $languageName, ]], 'otherLanguages' => [], ]; } $languageCodes = $this->findAvailableLanguages(); - $reduceToLanguages = $this->config->getSystemValue('reduce_to_languages', []); - if (!empty($reduceToLanguages)) { - $languageCodes = array_intersect($languageCodes, $reduceToLanguages); + $reducedLanguageCodes = $this->config->getSystemValue('reduce_to_languages', []); + if (!empty($reducedLanguageCodes)) { + $languageCodes = array_intersect($languageCodes, $reducedLanguageCodes); } $commonLanguages = []; $otherLanguages = []; - foreach ($languageCodes as $lang) { - $l = $this->get('lib', $lang); - // TRANSLATORS this is the language name for the language switcher in the personal settings and should be the localized version - $potentialName = $l->t('__language_name__'); + foreach ($languageCodes as $languageCode) { + $l10n = $this->get('lib', $languageCode); + // TRANSLATORS: this is the language name for the language switcher in the personal settings and should be the localized version + $languageName = $l10n->t('__language_name__'); - if ($l->getLanguageCode() === $lang && $potentialName[0] !== '_') { //first check if the language name is in the translation file - $ln = [ - 'code' => $lang, - 'name' => $potentialName + if ($l10n->getLanguageCode() === $languageCode && $languageName[0] !== '_') { // first check if the language name is in the translation file + $languageEntry = [ + 'code' => $languageCode, + 'name' => $languageName, ]; - } elseif ($lang === 'en') { - $ln = [ - 'code' => $lang, + } elseif ($languageCode === 'en') { + $languageEntry = [ + 'code' => $languageCode, 'name' => 'English (US)' ]; - } else { //fallback to language code - $ln = [ - 'code' => $lang, - 'name' => $lang + } else { // fallback to language code + $languageEntry = [ + 'code' => $languageCode, + 'name' => $languageCode, ]; } // put appropriate languages into appropriate arrays, to print them sorted // common languages -> divider -> other languages - if (in_array($lang, self::COMMON_LANGUAGE_CODES)) { - $commonLanguages[array_search($lang, self::COMMON_LANGUAGE_CODES, true)] = $ln; + if (in_array($languageCode, self::COMMON_LANGUAGE_CODES, true)) { + $commonLanguages[array_search($languageCode, self::COMMON_LANGUAGE_CODES, true)] = $languageEntry; } else { - $otherLanguages[] = $ln; + $otherLanguages[] = $languageEntry; } } ksort($commonLanguages); // Sort by display name rather than language code. - usort($otherLanguages, function ($a, $b) { - if ($a['code'] === $a['name'] && $b['code'] !== $b['name']) { - // If a doesn't have a name, but b does, list b before a + usort($otherLanguages, function ($left, $right) { + if ($left['code'] === $left['name'] && $right['code'] !== $right['name']) { + // If left doesn't have a name, but right does, list right before left return 1; } - if ($a['code'] !== $a['name'] && $b['code'] === $b['name']) { - // If a does have a name, but b doesn't, list a before b + if ($left['code'] !== $left['name'] && $right['code'] === $right['name']) { + // If left does have a name, but right doesn't, list left before right return -1; } + // Otherwise compare the names - return strcmp($a['name'], $b['name']); + return strcmp($left['name'], $right['name']); }); return [ From b37cac391c9f656faf7081e1c3b31656882073ee Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 5 Jun 2026 14:41:08 -0400 Subject: [PATCH 4/8] chore(L10N): fixup Factory Signed-off-by: Josh --- lib/private/L10N/Factory.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/private/L10N/Factory.php b/lib/private/L10N/Factory.php index 4c9331bd5e2c8..2afeeb4415fee 100644 --- a/lib/private/L10N/Factory.php +++ b/lib/private/L10N/Factory.php @@ -135,7 +135,7 @@ public function get($app, $lang = null, $locale = null) { $forcedLanguage = $this->cleanLanguage($this->request->getParam('forceLanguage')) ?? $this->config->getSystemValue('force_language', false); - if (is_string($forcedLanguage)) + if (is_string($forcedLanguage)) { $lang = $forcedLanguage; } @@ -593,7 +593,7 @@ private function getL10nFilesForApp(string $app, string $lang): array { if (($this->isSubDirectory($translationFile, $this->serverRoot . '/core/l10n/') || $this->isSubDirectory($translationFile, $this->serverRoot . '/lib/l10n/') - || $this->isSubDirectory($translationFile, $this->appManager->getAppPath($app) . '/l10n/')) + || $this->isSubDirectory($translationFile, $this->appManager->getAppPath($app) . '/l10n/')) // FIXME: catch AppPathNotFoundException && file_exists($translationFile) ) { $languageFiles[] = $translationFile; @@ -621,7 +621,7 @@ private function getL10nFilesForApp(string $app, string $lang): array { * @param string|null $app App id or null for core */ protected function findL10nDir(?string $app = null): string { - if (in_array($app, ['core', 'lib'])) { + if (in_array($app, ['core', 'lib'], true)) { if (file_exists($this->serverRoot . '/' . $app . '/l10n/')) { return $this->serverRoot . '/' . $app . '/l10n/'; } @@ -638,7 +638,7 @@ protected function findL10nDir(?string $app = null): string { #[\Override] public function getLanguages(): array { $forcedLanguage = $this->config->getSystemValue('force_language', false); - if ($forceLanguage !== false) { + if ($forcedLanguage !== false) { $l10n = $this->get('lib', $forcedLanguage); $languageName = $l10n->t('__language_name__'); From 70b6e788047f1a04e75e91710fcde8bc1ab3c4f1 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 5 Jun 2026 14:47:55 -0400 Subject: [PATCH 5/8] fix(L10N): avoid recomputing app l10n paths in `getL10nFilesForApp()` Reuse the already resolved l10n directory instead, preventing an unnecessary exception path for unknown apps Signed-off-by: Josh --- lib/private/L10N/Factory.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/private/L10N/Factory.php b/lib/private/L10N/Factory.php index 2afeeb4415fee..4bda120eea495 100644 --- a/lib/private/L10N/Factory.php +++ b/lib/private/L10N/Factory.php @@ -593,7 +593,7 @@ private function getL10nFilesForApp(string $app, string $lang): array { if (($this->isSubDirectory($translationFile, $this->serverRoot . '/core/l10n/') || $this->isSubDirectory($translationFile, $this->serverRoot . '/lib/l10n/') - || $this->isSubDirectory($translationFile, $this->appManager->getAppPath($app) . '/l10n/')) // FIXME: catch AppPathNotFoundException + || $this->isSubDirectory($translationFile, $l10nDir)) && file_exists($translationFile) ) { $languageFiles[] = $translationFile; From 633f4597e078ed75f8e446549de13a24135e28cf Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 5 Jun 2026 21:55:17 -0400 Subject: [PATCH 6/8] chore(L10N): avoid building language map even when unused Stop populating availableLanguageMap in the cache-hit path and make it lazy in languageExists(). Signed-off-by: Josh --- lib/private/L10N/Factory.php | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/lib/private/L10N/Factory.php b/lib/private/L10N/Factory.php index 4bda120eea495..c8bc1f7482eb9 100644 --- a/lib/private/L10N/Factory.php +++ b/lib/private/L10N/Factory.php @@ -56,6 +56,10 @@ class Factory implements IFactory { * * Structure: appKey => languageCode => true * + * This map is derived from $availableLanguages and may be built lazily in + * languageExists(). Code that updates $availableLanguages must either rebuild + * the corresponding map entry or let languageExists() do so on demand. + * * @var array> */ protected array $availableLanguageMap = []; @@ -354,7 +358,7 @@ public function findAvailableLanguages($app = null): array { $cachedLanguages = $this->cache->get($appCacheKey); if (is_array($cachedLanguages)) { $this->availableLanguages[$appCacheKey] = $cachedLanguages; - $this->availableLanguageMap[$appCacheKey] = array_fill_keys($cachedLanguages, true); + return $cachedLanguages; } if (!empty($this->availableLanguages[$appCacheKey])) { @@ -416,14 +420,21 @@ public function findAvailableLocales() { #[\Override] public function languageExists($app, $lang) { - if ($lang === 'en') { //english is always available + if ($lang === 'en') { return true; } $appCacheKey = $this->getAppCacheKey($app); if (!isset($this->availableLanguageMap[$appCacheKey])) { - $this->findAvailableLanguages($app); + if (!isset($this->availableLanguages[$appCacheKey])) { + $this->findAvailableLanguages($app); + } + + $this->availableLanguageMap[$appCacheKey] = array_fill_keys( + $this->availableLanguages[$appCacheKey] ?? [], + true + ); } return isset($this->availableLanguageMap[$appCacheKey][$lang]); From 0ba79b317162b2f177fc57259d99a56933f6b603 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 5 Jun 2026 21:59:18 -0400 Subject: [PATCH 7/8] chore(L10N): add lazy comment to Factory Signed-off-by: Josh --- lib/private/L10N/Factory.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/private/L10N/Factory.php b/lib/private/L10N/Factory.php index c8bc1f7482eb9..d62b86e531b47 100644 --- a/lib/private/L10N/Factory.php +++ b/lib/private/L10N/Factory.php @@ -431,6 +431,7 @@ public function languageExists($app, $lang) { $this->findAvailableLanguages($app); } + // The membership map is derived lazily from the cached language list. $this->availableLanguageMap[$appCacheKey] = array_fill_keys( $this->availableLanguages[$appCacheKey] ?? [], true @@ -714,7 +715,7 @@ public function getLanguages(): array { // If left does have a name, but right doesn't, list left before right return -1; } - + // Otherwise compare the names return strcmp($left['name'], $right['name']); }); From 9decd97b3cef8b6d3bc934c972a9b01ec44e0bc7 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 5 Jun 2026 22:38:50 -0400 Subject: [PATCH 8/8] refactor(L10N): eliminate some duplicate code in Factory Signed-off-by: Josh --- lib/private/L10N/Factory.php | 69 +++++++++++++++++++++--------------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/lib/private/L10N/Factory.php b/lib/private/L10N/Factory.php index d62b86e531b47..63be016da8550 100644 --- a/lib/private/L10N/Factory.php +++ b/lib/private/L10N/Factory.php @@ -20,7 +20,6 @@ use OCP\IUserSession; use OCP\L10N\IFactory; use OCP\L10N\ILanguageIterator; -use function is_null; /** * Factory for creating language instances. @@ -131,14 +130,49 @@ private function getLocaleCacheKey(?string $locale): string { return $locale ?? '__default__'; } + /** + * Return the forced language from the request or system config. + * + * Request-level `forceLanguage` takes precedence over the system + * `force_language` setting. + * + * @return string|false + */ + private function getForcedLanguage(): string|false { + return $this->cleanLanguage($this->request->getParam('forceLanguage')) + ?? $this->config->getSystemValue('force_language', false); + } + + /** + * Add language codes from a translation directory into the given set. + * + * @param string $l10nDir + * @param array &$languageSet + */ + private function addAvailableLanguagesFromDir(string $l10nDir, array &$languageSet): void { + if (!is_dir($l10nDir)) { + return; + } + + $files = scandir($l10nDir); + if ($files === false) { + return; + } + + foreach ($files as $fileName) { + if (str_ends_with($fileName, '.json') && !str_starts_with($fileName, 'l10n')) { + $languageSet[substr($fileName, 0, -5)] = true; + } + } + } + #[\Override] public function get($app, $lang = null, $locale = null) { return new LazyL10N(function () use ($app, $lang, $locale) { $app = $this->appManager->cleanAppId($app); $lang = $this->cleanLanguage($lang); - $forcedLanguage = $this->cleanLanguage($this->request->getParam('forceLanguage')) - ?? $this->config->getSystemValue('force_language', false); + $forcedLanguage = $this->getForcedLanguage(); if (is_string($forcedLanguage)) { $lang = $forcedLanguage; } @@ -225,8 +259,7 @@ public function findLanguage(?string $appId = null): string { $appCacheKey = $this->getAppCacheKey($appId); // Step 1: a forced language overrides any other source. - $forcedLanguage = $this->cleanLanguage($this->request->getParam('forceLanguage')) - ?? $this->config->getSystemValue('force_language', false); + $forcedLanguage = $this->getForcedLanguage(); if (is_string($forcedLanguage)) { $this->requestLanguages[$appCacheKey] = $forcedLanguage; } @@ -284,8 +317,7 @@ public function findLanguage(?string $appId = null): string { #[\Override] public function findGenericLanguage(?string $appId = null): string { - $forcedLanguage = $this->cleanLanguage($this->request->getParam('forceLanguage')) - ?? $this->config->getSystemValue('force_language', false); + $forcedLanguage = $this->getForcedLanguage(); if ($forcedLanguage !== false) { return $forcedLanguage; } @@ -368,32 +400,13 @@ public function findAvailableLanguages($app = null): array { $availableLanguageSet = ['en' => true]; // English is always available $l10nDir = $this->findL10nDir($app); - if (is_dir($l10nDir)) { - $files = scandir($l10nDir); - if ($files !== false) { - foreach ($files as $fileName) { - if (str_ends_with($fileName, '.json') && !str_starts_with($fileName, 'l10n')) { - $availableLanguageSet[substr($fileName, 0, -5)] = true; - } - } - } - } + $this->addAvailableLanguagesFromDir($l10nDir, $availableLanguageSet); // Merge translations from the active theme. $theme = $this->config->getSystemValueString('theme'); if (!empty($theme)) { $themeL10nDir = $this->serverRoot . '/themes/' . $theme . substr($l10nDir, strlen($this->serverRoot)); - - if (is_dir($themeL10nDir)) { - $files = scandir($themeL10nDir); - if ($files !== false) { - foreach ($files as $fileName) { - if (str_ends_with($fileName, '.json') && !str_starts_with($fileName, 'l10n')) { - $availableLanguageSet[substr($fileName, 0, -5)] = true; - } - } - } - } + $this->addAvailableLanguagesFromDir($themeL10nDir, $availableLanguageSet); } $availableLanguages = array_keys($availableLanguageSet);