500 lines
16 KiB
PHP
500 lines
16 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
/**
|
|
* @defgroup i18n I18N
|
|
* Implements localization concerns such as locale files, languages, currencies, and country lists.
|
|
*/
|
|
|
|
/**
|
|
* @file classes/i18n/Locale.php
|
|
*
|
|
* Copyright (c) 2014-2021 Simon Fraser University
|
|
* Copyright (c) 2000-2021 John Willinsky
|
|
* Distributed under the GNU GPL v3. For full terms see the file docs/COPYING.
|
|
*
|
|
* @class Locale
|
|
*
|
|
* @ingroup i18n
|
|
*
|
|
* @brief Provides methods for loading locale data and translating strings identified by unique keys
|
|
*/
|
|
|
|
namespace PKP\i18n;
|
|
|
|
use Closure;
|
|
use DateInterval;
|
|
use DirectoryIterator;
|
|
use Illuminate\Support\Facades\Cache;
|
|
use InvalidArgumentException;
|
|
use PKP\config\Config;
|
|
use PKP\core\PKPRequest;
|
|
use PKP\facades\Repo;
|
|
use PKP\i18n\interfaces\LocaleInterface;
|
|
use PKP\i18n\translation\LocaleBundle;
|
|
use PKP\plugins\Hook;
|
|
use PKP\plugins\PluginRegistry;
|
|
use PKP\session\SessionManager;
|
|
use RecursiveDirectoryIterator;
|
|
use RecursiveIteratorIterator;
|
|
use RecursiveRegexIterator;
|
|
use RegexIterator;
|
|
use ResourceBundle;
|
|
use Sokil\IsoCodes\Database\Countries;
|
|
use Sokil\IsoCodes\Database\Currencies;
|
|
use Sokil\IsoCodes\Database\LanguagesInterface;
|
|
use Sokil\IsoCodes\Database\Scripts;
|
|
use Sokil\IsoCodes\IsoCodesFactory;
|
|
use SplFileInfo;
|
|
|
|
class Locale implements LocaleInterface
|
|
{
|
|
/** Max lifetime for the locale metadata cache, the cache is built by scanning the provided paths */
|
|
protected const MAX_CACHE_LIFETIME = '1 hour';
|
|
|
|
/**
|
|
* @var callable Formatter for missing locale keys
|
|
* Receives the locale key and must return a string
|
|
*/
|
|
protected ?Closure $missingKeyHandler = null;
|
|
|
|
/** Current locale cache */
|
|
protected ?string $locale = null;
|
|
|
|
/** @var int[] Folders where locales can be found, where key = path and value = loading priority */
|
|
protected array $paths = [];
|
|
|
|
/** @var callable[] Custom locale loaders */
|
|
protected array $loaders = [];
|
|
|
|
/** Keeps the request */
|
|
protected ?PKPRequest $request = null;
|
|
|
|
/** @var LocaleMetadata[]|null Discovered locales cache */
|
|
protected ?array $locales = null;
|
|
|
|
/** Primary locale cache */
|
|
protected ?string $primaryLocale = null;
|
|
|
|
/** @var string[]|null Supported form locales cache, where key = locale and value = name */
|
|
protected ?array $supportedFormLocaleNames = null;
|
|
|
|
/** @var string[]|null Supported locales cache, where key = locale and value = name */
|
|
protected ?array $supportedLocaleNames = null;
|
|
|
|
/** @var string[]|null Supported locales cache */
|
|
protected ?array $supportedLocales = null;
|
|
|
|
/** @var LocaleBundle[] Keeps a cache for the locale bundles */
|
|
protected array $localeBundles = [];
|
|
|
|
/** @var string[][][]|null Discovered locale files, keyed first by base path and then by locale */
|
|
protected array $localeFiles = [];
|
|
|
|
/** Keeps cached data related only to the current locale */
|
|
protected array $cache = [];
|
|
|
|
/**
|
|
* @copy \Illuminate\Contracts\Translation\Translator::get()
|
|
*
|
|
* @param null|mixed $locale
|
|
*/
|
|
public function get($key, array $params = [], $locale = null): string
|
|
{
|
|
return $this->translate($key, null, $params, $locale);
|
|
}
|
|
|
|
/**
|
|
* @copy \Illuminate\Contracts\Translation\Translator::choice()
|
|
*
|
|
* @param null|mixed $locale
|
|
*/
|
|
public function choice($key, $number, array $params = [], $locale = null): string
|
|
{
|
|
return $this->translate($key, $number, $params, $locale);
|
|
}
|
|
|
|
/**
|
|
* @copy \Illuminate\Contracts\Translation\Translator::getLocale()
|
|
*/
|
|
public function getLocale(): string
|
|
{
|
|
if (isset($this->locale)) {
|
|
return $this->locale;
|
|
}
|
|
$request = $this->_getRequest();
|
|
$locale = $request->getUserVar('setLocale')
|
|
?: (SessionManager::hasSession() ? SessionManager::getManager()->getUserSession()->getSessionVar('currentLocale') : null)
|
|
?: $request->getCookieVar('currentLocale');
|
|
$this->setLocale($locale);
|
|
return $this->locale;
|
|
}
|
|
|
|
/**
|
|
* @copy \Illuminate\Contracts\Translation\Translator::setLocale()
|
|
*/
|
|
public function setLocale($locale): void
|
|
{
|
|
if (!$this->isLocaleValid($locale) || !$this->isSupported($locale)) {
|
|
if ($locale) {
|
|
error_log((string) new InvalidArgumentException("Invalid/unsupported locale \"{$locale}\", default locale restored"));
|
|
}
|
|
$locale = $this->getPrimaryLocale();
|
|
}
|
|
|
|
$this->locale = $locale;
|
|
setlocale(LC_ALL, 'C.utf8', 'C');
|
|
\Locale::setDefault(\Locale::lookup(ResourceBundle::getLocales(''), $locale, true));
|
|
}
|
|
|
|
/**
|
|
* @copy LocaleInterface::getPrimaryLocale()
|
|
*/
|
|
public function getPrimaryLocale(): string
|
|
{
|
|
if (isset($this->primaryLocale)) {
|
|
return $this->primaryLocale;
|
|
}
|
|
$request = $this->_getRequest();
|
|
$locale = SessionManager::isDisabled() ? null : $request->getContext()?->getPrimaryLocale() ?? $request->getSite()?->getPrimaryLocale();
|
|
return $this->primaryLocale = $this->isLocaleValid($locale) ? $locale : $this->getDefaultLocale();
|
|
}
|
|
|
|
/**
|
|
* @copy LocaleInterface::registerPath()
|
|
*/
|
|
public function registerPath(string $path, int $priority = 0): void
|
|
{
|
|
$path = new SplFileInfo($path);
|
|
if (!$path->isDir()) {
|
|
throw new InvalidArgumentException("\"{$path}\" isn't a valid folder");
|
|
}
|
|
|
|
// Invalidate the loaded bundles cache
|
|
$realPath = $path->getRealPath();
|
|
if (($this->paths[$realPath] ?? null) !== $priority) {
|
|
$this->paths[$realPath] = $priority;
|
|
$this->localeBundles = [];
|
|
$this->locales = null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @copy LocaleInterface::registerLoader()
|
|
*/
|
|
public function registerLoader(callable $fileLoader, int $priority = 0): void
|
|
{
|
|
// Invalidate the loaded bundles cache
|
|
if (array_search($fileLoader, $this->loaders[$priority] ?? [], true) === false) {
|
|
$this->loaders[$priority][] = $fileLoader;
|
|
$this->localeBundles = [];
|
|
ksort($this->loaders, SORT_NUMERIC);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @copy LocaleInterface::isLocaleValid()
|
|
*/
|
|
public function isLocaleValid(?string $locale): bool
|
|
{
|
|
return !empty($locale) && preg_match(LocaleInterface::LOCALE_EXPRESSION, $locale);
|
|
}
|
|
|
|
/**
|
|
* @copy LocaleInterface::getMetadata()
|
|
*/
|
|
public function getMetadata(string $locale): ?LocaleMetadata
|
|
{
|
|
return $this->getLocales()[$locale] ?? null;
|
|
}
|
|
|
|
/**
|
|
* @copy LocaleInterface::getLocales()
|
|
*/
|
|
public function getLocales(): array
|
|
{
|
|
$key = __METHOD__ . static::MAX_CACHE_LIFETIME . array_reduce(
|
|
array_keys($this->paths),
|
|
fn (string $hash, string $path): string => sha1($hash . $path),
|
|
''
|
|
);
|
|
$expiration = DateInterval::createFromDateString(static::MAX_CACHE_LIFETIME);
|
|
return $this->locales ??= Cache::remember($key, $expiration, function () {
|
|
$locales = [];
|
|
foreach (array_keys($this->paths) as $folder) {
|
|
foreach (new DirectoryIterator($folder) as $cursor) {
|
|
if ($cursor->isDir() && $this->isLocaleValid($cursor->getBasename())) {
|
|
$locales[$cursor->getBasename()] ??= new LocaleMetadata($cursor->getBasename());
|
|
}
|
|
}
|
|
}
|
|
ksort($locales);
|
|
return $locales;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @copy LocaleInterface::installLocale()
|
|
*/
|
|
public function installLocale(string $locale): void
|
|
{
|
|
Repo::emailTemplate()->dao->installEmailTemplateLocaleData(Repo::emailTemplate()->dao->getMainEmailTemplatesFilename(), [$locale]);
|
|
// Load all plugins so they can add locale data if needed
|
|
PluginRegistry::loadAllPlugins();
|
|
Hook::call('Locale::installLocale', [&$locale]);
|
|
}
|
|
|
|
/**
|
|
* @copy LocaleInterface::uninstallLocale()
|
|
*/
|
|
public function uninstallLocale(string $locale): void
|
|
{
|
|
// Delete locale-specific data
|
|
Repo::emailTemplate()->dao->deleteEmailTemplatesByLocale($locale);
|
|
}
|
|
|
|
/**
|
|
* Retrieves whether the given locale is supported
|
|
*/
|
|
public function isSupported(string $locale): bool
|
|
{
|
|
return isset($this->_getSupportedLocales()[$locale]);
|
|
}
|
|
|
|
/**
|
|
* @copy LocaleInterface::getSupportedFormLocales()
|
|
*/
|
|
public function getSupportedFormLocales(): array
|
|
{
|
|
return $this->supportedFormLocaleNames ??= (SessionManager::isDisabled() ? null : $this->_getRequest()->getContext()?->getSupportedFormLocaleNames())
|
|
?? $this->getSupportedLocales();
|
|
}
|
|
|
|
/**
|
|
* @copy LocaleInterface::getSupportedLocales()
|
|
*/
|
|
public function getSupportedLocales(): array
|
|
{
|
|
return $this->supportedLocaleNames ??= array_map(fn (string $locale) => $this->getMetadata($locale)->getDisplayName(), $this->_getSupportedLocales());
|
|
}
|
|
|
|
/**
|
|
* @copy LocaleInterface::setMissingKeyHandler()
|
|
*/
|
|
public function setMissingKeyHandler(?callable $handler): void
|
|
{
|
|
$this->missingKeyHandler = $handler;
|
|
}
|
|
|
|
/**
|
|
* @copy LocaleInterface::getMissingKeyHandler()
|
|
*/
|
|
public function getMissingKeyHandler(): ?callable
|
|
{
|
|
return $this->missingKeyHandler;
|
|
}
|
|
|
|
/**
|
|
* @copy LocaleInterface::getBundle()
|
|
*/
|
|
public function getBundle(?string $locale = null, bool $useCache = true): LocaleBundle
|
|
{
|
|
$locale ??= $this->getLocale();
|
|
$getter = function () use ($locale): LocaleBundle {
|
|
$bundle = [];
|
|
foreach ($this->paths as $folder => $priority) {
|
|
$bundle += $this->_getLocaleFiles($folder, $locale, $priority);
|
|
}
|
|
foreach ($this->loaders as $loader) {
|
|
$loader($locale, $bundle);
|
|
}
|
|
return new LocaleBundle($locale, $bundle);
|
|
};
|
|
return $useCache ? $this->localeBundles[$locale] ??= $getter() : $getter();
|
|
}
|
|
|
|
/**
|
|
* @copy LocaleInterface::getDefaultLocale()
|
|
*/
|
|
public function getDefaultLocale(): string
|
|
{
|
|
return Config::getVar('i18n', 'locale');
|
|
}
|
|
|
|
/**
|
|
* @copy LocaleInterface::getCountries()
|
|
*/
|
|
public function getCountries(?string $locale = null): Countries
|
|
{
|
|
return $this->_getLocaleCache(__METHOD__, $locale, fn () => $this->_getIsoCodes($locale)->getCountries());
|
|
}
|
|
|
|
/**
|
|
* @copy LocaleInterface::getCurrencies()
|
|
*/
|
|
public function getCurrencies(?string $locale = null): Currencies
|
|
{
|
|
return $this->_getLocaleCache(__METHOD__, $locale, fn () => $this->_getIsoCodes($locale)->getCurrencies());
|
|
}
|
|
|
|
/**
|
|
* @copy LocaleInterface::getLanguages()
|
|
*/
|
|
public function getLanguages(?string $locale = null, bool $fromCache = true): LanguagesInterface
|
|
{
|
|
if ($fromCache) {
|
|
return $this->_getLocaleCache(
|
|
__METHOD__,
|
|
$locale,
|
|
fn () => $this->_getIsoCodes($locale)->getLanguages()
|
|
);
|
|
}
|
|
|
|
return $this->_getIsoCodes($locale)->getLanguages();
|
|
}
|
|
|
|
/**
|
|
* @copy LocaleInterface::getScripts()
|
|
*/
|
|
public function getScripts(?string $locale = null): Scripts
|
|
{
|
|
return $this->_getLocaleCache(__METHOD__, $locale, fn () => $this->_getIsoCodes($locale)->getScripts());
|
|
}
|
|
|
|
/**
|
|
* @copy LocaleInterface::getFormattedDisplayNames()
|
|
*/
|
|
public function getFormattedDisplayNames(array $filterByLocales = null, array $locales = null, int $langLocaleStatus = LocaleMetadata::LANGUAGE_LOCALE_WITH, bool $omitLocaleCodeInDisplay = true): array
|
|
{
|
|
$locales ??= $this->getLocales();
|
|
|
|
if ($filterByLocales !== null) {
|
|
$filterByLocales = array_intersect_key($locales, array_flip($filterByLocales));
|
|
}
|
|
|
|
$locales = $this->getFilteredLocales($locales, $filterByLocales ? array_keys($filterByLocales) : null);
|
|
|
|
$localeCodesCount = array_count_values(
|
|
collect(array_keys($filterByLocales ?? $locales))
|
|
->map(fn (string $value) => trim(explode('@', explode('_', $value)[0])[0]))
|
|
->toArray()
|
|
);
|
|
|
|
return collect($locales)
|
|
->map(function (LocaleMetadata $locale, string $localeKey) use ($localeCodesCount, $langLocaleStatus, $omitLocaleCodeInDisplay) {
|
|
$localeCode = trim(explode('@', explode('_', $localeKey)[0])[0]);
|
|
$localeDisplay = $locale->getDisplayName(null, ($localeCodesCount[$localeCode] ?? 0) > 1, $langLocaleStatus);
|
|
return $localeDisplay . ($omitLocaleCodeInDisplay ? '' : " ({$localeKey})");
|
|
})
|
|
->toArray();
|
|
}
|
|
|
|
/**
|
|
* Get the filtered locales by locale codes
|
|
*
|
|
* @param array $locales List of available all locales
|
|
* @param array $filterByLocales List of locales code to filter by the returned formatted names list
|
|
*
|
|
* @return array The list of locales with formatted display name
|
|
*/
|
|
protected function getFilteredLocales(array $locales, array $filterByLocales = null): array
|
|
{
|
|
if (!$filterByLocales) {
|
|
return $locales;
|
|
}
|
|
|
|
return array_intersect_key($locales, array_flip($filterByLocales));
|
|
}
|
|
|
|
/**
|
|
* Translates the texts
|
|
*/
|
|
protected function translate(string $key, ?int $number, array $params, ?string $locale): string
|
|
{
|
|
if (($key = trim($key)) === '') {
|
|
return '';
|
|
}
|
|
|
|
$locale ??= $this->getLocale();
|
|
$localeBundle = $this->getBundle($locale);
|
|
$value = $number === null ? $localeBundle->translateSingular($key, $params) : $localeBundle->translatePlural($key, $number, $params);
|
|
if ($value !== null || Hook::call('Locale::translate', [&$value, $key, $params, $number, $locale, $localeBundle])) {
|
|
return $value;
|
|
}
|
|
|
|
// In order to reduce the noise, we're only logging missing entries for the en locale
|
|
// TODO: Allow the other missing entries to be logged once the Laravel's logging is setup
|
|
if ($locale === LocaleInterface::DEFAULT_LOCALE) {
|
|
error_log("Missing locale key \"{$key}\" for the locale \"{$locale}\"");
|
|
}
|
|
return is_callable($this->missingKeyHandler) ? ($this->missingKeyHandler)($key) : '##' . htmlentities($key) . '##';
|
|
}
|
|
|
|
/**
|
|
* Retrieves a cached item only if it belongs to the current locale. If it doesn't exist, the getter will be called
|
|
*/
|
|
private function _getLocaleCache(string $key, ?string $locale, callable $getter)
|
|
{
|
|
if (($locale ??= $this->getLocale()) !== $this->getLocale()) {
|
|
return $getter();
|
|
}
|
|
if (!isset($this->cache[$key][$locale])) {
|
|
// Ensures the previous cache is cleared
|
|
$this->cache[$key] = [$locale => $getter()];
|
|
}
|
|
return $this->cache[$key][$locale];
|
|
}
|
|
|
|
/**
|
|
* Given a locale folder, retrieves all locale files (.po)
|
|
*
|
|
* @return int[]
|
|
*/
|
|
private function _getLocaleFiles(string $folder, string $locale, int $priority): array
|
|
{
|
|
$files = $this->localeFiles[$folder][$locale] ?? null;
|
|
if ($files === null) {
|
|
$files = [];
|
|
if (is_dir($path = "{$folder}/{$locale}")) {
|
|
$directory = new RecursiveDirectoryIterator($path);
|
|
$iterator = new RecursiveIteratorIterator($directory);
|
|
$files = array_keys(iterator_to_array(new RegexIterator($iterator, '/\.po$/i', RecursiveRegexIterator::GET_MATCH)));
|
|
}
|
|
$this->localeFiles[$folder][$locale] = $files;
|
|
}
|
|
return array_fill_keys($files, $priority);
|
|
}
|
|
|
|
/**
|
|
* Retrieves the request
|
|
*/
|
|
private function _getRequest(): PKPRequest
|
|
{
|
|
return app(PKPRequest::class);
|
|
}
|
|
|
|
/**
|
|
* Retrieves the ISO codes factory
|
|
*/
|
|
private function _getIsoCodes(string $locale = null): IsoCodesFactory
|
|
{
|
|
return app(IsoCodesFactory::class, $locale ? ['locale' => $locale] : []);
|
|
}
|
|
|
|
/**
|
|
* Retrieves the supported locales
|
|
*
|
|
* @return string[]
|
|
*/
|
|
private function _getSupportedLocales(): array
|
|
{
|
|
if (isset($this->supportedLocales)) {
|
|
return $this->supportedLocales;
|
|
}
|
|
$locales = (SessionManager::isDisabled() ? null : $this->_getRequest()->getContext()?->getSupportedLocales() ?? $this->_getRequest()->getSite()?->getSupportedLocales())
|
|
?? array_map(fn (LocaleMetadata $locale) => $locale->locale, $this->getLocales());
|
|
return $this->supportedLocales = array_combine($locales, $locales);
|
|
}
|
|
}
|