dadajuice/zephyrus

View on GitHub
src/Zephyrus/Application/Localization.php

Summary

Maintainability
C
1 day
Test Coverage
A
100%
<?php namespace Zephyrus\Application;

use Locale;
use stdClass;
use Zephyrus\Exceptions\LocalizationException;
use Zephyrus\Utilities\FileSystem\Directory;
use Zephyrus\Utilities\FileSystem\File;

class Localization
{
    private static ?Localization $instance = null;

    /**
     * Currently loaded application locale language. Maps to a directory within /locale.
     *
     * @var string|null
     */
    private ?string $appLocale = null;

    /**
     * Keeps a global reference for future uses of the complete language properties of the installed locale.
     *
     * @var array
     */
    private array $installedLanguages = [];

    /**
     * Holds the currently installed locales. Fetches the /locale directory and see what directories are available.
     *
     * @var array
     */
    private array $installedLocales = [];

    /**
     * Contains the complete cached localize texts as associative arrays. The keys are the locale (e.g. en_CA) and the
     * value is the whole associative array of localize keys.
     *
     * @var array
     */
    private array $cachedLocalizations = [];

    public static function getInstance(): self
    {
        if (is_null(self::$instance)) {
            self::$instance = new self();
        }
        return self::$instance;
    }

    /**
     * Retrieves the list of all installed languages. Meaning all the directories under /locale. Will return an array
     * of stdClass containing all the details for each language : locale, lang_code, country_code, flag_emoji, country,
     * lang.
     *
     * @return stdClass[]
     */
    public function getInstalledLanguages(): array
    {
        return $this->installedLanguages;
    }

    /**
     * Retrieves simply the names of the installed locales. For the complete object reference, use
     * getInstalledLanguages.
     *
     * @return string[]
     */
    public function getInstalledLocales(): array
    {
        return $this->installedLocales;
    }

    /**
     * Retrieves the actual loaded language. Will return an stdClass containing all the details : locale, lang_code,
     * country_code, flag_emoji, country, lang.
     *
     * @return stdClass
     */
    public function getLoadedLanguage(): stdClass
    {
        return $this->installedLanguages[$this->appLocale];
    }

    /**
     * Initialize the localization environment. If no locale is given, it will be initialized with the default locale
     * set in the config.ini file.
     *
     * @param string|null $locale
     * @throws LocalizationException
     */
    public function start(?string $locale = null): void
    {
        $this->appLocale = $locale ?? Configuration::getLocale('language');
        $this->initializeLocale();
        $this->generate();
    }

    /**
     * @param string $locale
     * @throws LocalizationException
     */
    public function changeLanguage(string $locale): void
    {
        $this->start($locale);
    }

    public function getLoadedLocale(): string
    {
        return $this->appLocale;
    }

    /**
     * Returns the entire caching array for the specified $locale (or the loaded one otherwise).
     *
     * @param string|null $locale
     * @return array
     */
    public function getCache(?string $locale = null): array
    {
        return $this->cachedLocalizations[$locale ?? $this->appLocale];
    }

    public function localize(string $key, array $args = []): string
    {
        $locale = $this->appLocale;
        $segments = explode(".", $key);
        $localizeIdentifier = $segments[0];
        if (in_array($localizeIdentifier, $this->getInstalledLocales())) {
            $locale = $localizeIdentifier;
            array_shift($segments);
        }

        $keys = $this->cachedLocalizations[$locale] ?? [];
        $result = null;
        foreach ($segments as $segment) {
            if (is_array($result)) {
                if (isset($result[$segment])) {
                    $result = $result['!' . $segment] ?? $result[$segment];
                } else {
                    $result = null;
                    break;
                }
            } else {
                if (isset($keys[$segment])) {
                    $result = $keys['!' . $segment] ?? $keys[$segment];
                } else {
                    $result = null;
                    break;
                }
            }
        }

        $resultString = (is_null($result) || is_array($result)) ? $key : $result;
        $sprintfParameters = [];
        $namedParameters = [];
        foreach ($args as $index => $arg) {
            if (is_array($arg)) { // When used from localize function, it will be an array
                foreach ($arg as $innerIndex => $innerArg) {
                    if (is_string($innerIndex)) {
                        $namedParameters[$innerIndex] = $innerArg;
                    } else {
                        $sprintfParameters[] = $innerArg;
                    }
                }
            }
            if (is_string($index)) {
                $namedParameters[$index] = $arg;
            } else {
                $sprintfParameters[] = $arg;
            }
        }

        foreach ($namedParameters as $index => $arg) {
            $resultString = str_replace('{' . $index . '}', $arg ?? "", $resultString);
        }

        $parameters[] = $resultString;
        return call_user_func_array('sprintf', array_merge($parameters, $sprintfParameters));
    }

    /**
     * Generates the localization cache for all installed languages if they are outdated or never created. Optionally,
     * you can force the whole regeneration with the boolean argument (will ignore the conditions and generate
     * everything from scratch). Throws an exception if json cannot be properly parsed.
     *
     * @param bool $force
     * @throws LocalizationException
     */
    public function generate(bool $force = false): void
    {
        foreach ($this->installedLocales as $locale) {
            if ($force || $this->prepareCache($locale) || $this->isCacheOutdated($locale)) {
                $this->generateCache($locale);
            }
        }
        $this->initializeCache();
    }

    /**
     * Removes the cache directory for the specified locale. If no locale is given, the whole cache directory will be
     * deleted.
     *
     * @param string|null $locale
     */
    public function clearCache(?string $locale = null): void
    {
        $path = ($locale) ? ROOT_DIR . "/locale/cache/$locale" : "/locale/cache";
        if (Directory::exists($path)) {
            (new Directory($path))->remove();
        }
    }

    /**
     * Generates a single language cache. Will completely remove any existing directories concerning this locale
     * beforehand and completely generate cache. Throws an exception if json cannot be properly parsed.
     *
     * @param string $locale
     * @throws LocalizationException
     */
    private function generateCache(string $locale): void
    {
        $globalArray = $this->buildGlobalArrayFromJsonFiles($locale);
        $arrayCode = '<?php' . PHP_EOL . '$localizeCache = ' . var_export($globalArray, true) . ';' . PHP_EOL . 'return $localizeCache;' . PHP_EOL;
        file_put_contents(ROOT_DIR . "/locale/cache/$locale/generated.php", $arrayCode);
    }

    /**
     * Verifies if the cache needs to be regenerated for the specified locale.
     *
     * @param string $locale
     * @return bool
     */
    private function isCacheOutdated(string $locale): bool
    {
        $lastModifiedLocaleJson = $this->getDirectoryLastModifiedTime(ROOT_DIR . "/locale/$locale");
        $lastModifiedLocaleCache = $this->getDirectoryLastModifiedTime(ROOT_DIR . "/locale/cache/$locale");
        return $lastModifiedLocaleJson > $lastModifiedLocaleCache;
    }

    /**
     * Creates the cache directory for the specified locale if they do not exist. Returns true if a directory was
     * created, false otherwise.
     *
     * @param string $locale
     * @return bool
     */
    private function prepareCache(string $locale): bool
    {
        $newlyCreated = false;
        if (!Directory::exists(ROOT_DIR . "/locale/cache")) {
            Directory::create(ROOT_DIR . "/locale/cache");
            $newlyCreated = true;
        }

        $path = ROOT_DIR . "/locale/cache/$locale";
        if (!Directory::exists($path)) {
            Directory::create($path);
            $newlyCreated = true;
        }
        return $newlyCreated;
    }

    /**
     * Builds an associative array containing all the json values to generate.
     *
     * @param string $locale
     * @throws LocalizationException
     * @return array
     */
    private function buildGlobalArrayFromJsonFiles(string $locale): array
    {
        $globalArray = [];
        foreach (Directory::recursiveGlob(ROOT_DIR . "/locale/$locale/*.json") as $file) {
            $string = file_get_contents($file);
            $jsonAssociativeArray = json_decode($string, true);
            $jsonLastError = json_last_error();
            if ($jsonLastError > JSON_ERROR_NONE) {
                throw new LocalizationException($jsonLastError, $file);
            }

            // Merge values if key exists from another file. Allows to have the same localization key in multiple files
            // and merge them at generation time.
            foreach ($jsonAssociativeArray as $key => $values) {
                $globalArray[$key] = (key_exists($key, $globalArray))
                    ? array_replace_recursive($globalArray[$key], $values)
                    : $values;
            }
        }
        return $globalArray;
    }

    private function getDirectoryLastModifiedTime($directory)
    {
        $lastModifiedTime = 0;
        $directoryLastModifiedTime = filemtime($directory);
        foreach (glob("$directory/*") as $file) {
            $fileLastModifiedTime = (is_file($file)) ? filemtime($file) : $this->getDirectoryLastModifiedTime($file);
            $lastModifiedTime = max($fileLastModifiedTime, $directoryLastModifiedTime, $lastModifiedTime);
        }
        return $lastModifiedTime;
    }

    private function initializeLocale(): void
    {
        $charset = Configuration::getLocale('charset');
        $locale = $this->appLocale . '.' . $charset;
        Locale::setDefault($this->appLocale);
        setlocale(LC_MESSAGES, $locale);
        setlocale(LC_TIME, $locale);
        setlocale(LC_CTYPE, $locale);
        putenv("LANG=" . $this->appLocale);
        date_default_timezone_set(Configuration::getLocale('timezone'));
    }

    private function initializeCache(): void
    {
        foreach ($this->installedLocales as $locale) {
            $this->cachedLocalizations[$locale] = require ROOT_DIR . "/locale/cache/$locale/generated.php";
        }
    }

    private function __construct()
    {
        $this->buildInstalledLanguages();
        $this->buildInstalledLocales();
    }

    /**
     * Converts the 2 letters country code into the corresponding flag emoji.
     *
     * @param string $countryCode
     * @return string
     */
    private function getFlagEmoji(string $countryCode): string
    {
        $codePoints = array_map(function ($char) {
            return 127397 + ord($char);
        }, str_split(strtoupper($countryCode)));
        return mb_convert_encoding('&#' . implode(';&#', $codePoints) . ';', 'UTF-8', 'HTML-ENTITIES');
    }

    private function buildInstalledLocales(): void
    {
        $this->installedLocales = array_keys($this->installedLanguages);
    }

    private function buildInstalledLanguages(): void
    {
        $dirs = array_filter(glob(ROOT_DIR . '/locale/*'), 'is_dir');
        array_walk($dirs, function (&$value) {
            $value = basename($value);
        });
        $dirs = array_filter($dirs, function ($value) {
            return $value != "cache";
        });
        $languages = [];
        foreach ($dirs as $dir) {
            $languages[$dir] = $this->buildLanguage($dir);
        }
        $this->installedLanguages = $languages;
    }

    private function buildLanguage(string $locale): stdClass
    {
        $parts = explode("_", $locale);
        return (object) [
            'locale' => $locale,
            'lang_code' => $parts[0],
            'country_code' => $parts[1],
            'flag_emoji' => $this->getFlagEmoji($parts[1]),
            'country' => locale_get_display_region($locale),
            'lang' => locale_get_display_language($locale)
        ];
    }
}