strata-mvc/strata

View on GitHub
src/I18n/I18n.php

Summary

Maintainability
D
2 days
Test Coverage
<?php

namespace Strata\I18n;

use Strata\Strata;
use Strata\Utility\Hash;
use Strata\I18n\Locale;
use Strata\Controller\Request;
use Strata\Router\Router;
use Gettext\Translations;
use Gettext\Translation;
use Exception;

/**
 * Handles code localization using Gettext for PHP.
 * This requires a PHP installation that is compiled with extension=php_gettext.dll
 */
class i18n
{
    /**
     * @var string The default text domain used buy the class
     */
    const DOMAIN = "strata_i18n";

    /**
     * @var array The list of instantiated locales in the application
     */
    protected $locales = array();

    /**
     * Locale @var Locale The locale that is currently active.
     */
    protected $currentLocale = null;

    /**
     * The class initializer is meant to be called only once during the
     * Strata kickoff.
     */
    public function initialize()
    {
        $this->startSession();
        $this->resetLocaleCache();

        if ($this->shouldAddWordpressHooks()) {
            $this->registerHooks();
        }
    }

    /**
     * Sets the locale based on the current process' context.
     * @return string The locale code
     */
    public function applyCurrentLanguageByContext()
    {
        $locale = $this->getCurrentLocale();
        if (!is_null($locale)) {
            return $locale->getCode();
        }
    }

    /**
     * Assigns the theme's textdomain to Wordpress using 'load_theme_textdomain'.
     * @return boolean The result of load_theme_textdomain
     * @link https://codex.wordpress.org/Function_Reference/load_theme_textdomain
     */
    public function applyLocale()
    {
        $strataLocale = $this->getCurrentLocale();
        $app = Strata::app();

        if (!is_null($strataLocale)) {
            $message =  setlocale(LC_ALL, $strataLocale->getCode() .'.UTF-8') ?
                "Localized to : " . $strataLocale->getCode() . ".UTF-8" :
                "Locale function is not available on this platform, or the given " .
                "local does not exist in this environment. Attempted to set: " .
                $strataLocale->getCode() . ".UTF-8";

            $app->setConfig("runtime.setlocale", $message);
        }

        // Set in PHP
        if (function_exists('bindtextdomain')) {
            $bound = bindtextdomain($this->getTextdomain(), Strata::getLocalePath());
            $app->setConfig("runtime.bindtextdomain", "PHP text domain was bound to <info>$bound</info>");
        }

        if (function_exists('textdomain')) {
            $bound = textdomain($this->getTextdomain());
            $app->setConfig("runtime.textdomain", "PHP message domain was bound to <info>$bound</info>");
        }

        // Set in WP
        global $locale;
        $locale = $strataLocale->getCode();

        return load_theme_textdomain($this->getTextdomain(), Strata::getLocalePath());
    }

    /**
     * Returns the current textdomain as defined either
     * from Strata's configuration or the default value.
     * @return string
     */
    public function getTextdomain()
    {
        $textDomain = Strata::app()->getConfig("i18n.textdomain");
        return is_null($textDomain) ? self::DOMAIN : $textDomain;
    }

    /**
     * Clears the current locale cache and rebuilds it based
     * on the loaded configuration.
     * @return null
     */
    public function resetLocaleCache()
    {
        $this->setLocaleSet(!$this->isLocalized() ? array() : $this->parseLocalesFromConfig());
    }

    /**
     * Sets the set of available locales.
     * @param array $localeList A list of Locale objects
     */
    public function setLocaleSet($localeList)
    {
        $this->locales = $localeList;
    }

    /**
     * Sets the current locale based on either a GET parameter or the Locale URL prefix.
     * If none is found it will return the default locale.
     * @return Locale
     * @filter strata_i18n_set_current_locale_by_context
     */
    public function setCurrentLocaleByContext()
    {
        if (function_exists('apply_filters')) {
            // Give a chance for plugins to override this decision
            $locale = null;
            $locale = apply_filters('strata_i18n_set_current_locale_by_context', $locale);
            if (!is_null($locale)) {
                return $this->setLocale($locale);
            }
        }

        if (!is_null($this->currentLocale)) {
            return $this->currentLocale;
        }

        $request = new Request();

        // Under ajax, one could post the value.
        if (Router::isAjax() && $request->hasPost("locale")) {
            $locale = $this->getLocaleByCode($request->post("locale"));
            if (!is_null($locale)) {
                return $this->setLocale($locale);
            }
        }

        if ($request->hasGet("locale")) {
            $locale = $this->getLocaleByCode($request->get("locale"));
            if (!is_null($locale)) {
                return $this->setLocale($locale);
            }
        }

        $inAdmin = function_exists('is_admin') ? is_admin() : false;
        if (!$inAdmin) {
            // This validates all locales but the default one
            $urls = implode('|', $this->getLocaleRegexUrls());

            if (array_key_exists('REQUEST_URI', (array)$_SERVER)) {
                if (preg_match('/^\/('.$urls.')\//i', $_SERVER['REQUEST_URI'], $match)) {
                    $locale = $this->getLocaleByUrl($match[1]);
                    if (!is_null($locale)) {
                        return $this->setLocale($locale);
                    }
                // Also validates for lack of locale code, meaning
                // a possible default locale match when no ajax-ing.
                } elseif (preg_match('/^(?:(?!'.$urls.'))\/?/i', $_SERVER['REQUEST_URI'])) {
                    $locale = $this->getDefaultLocale();
                    if (!is_null($locale)) {
                        return $this->setLocale($locale);
                    }
                }
            }
        }

        if ($this->hasLocaleInSession()) {
            $locale = $this->getLocaleInSession();
            if (!is_null($locale)) {
                return $this->setLocale($locale);
            }
        }

        if ($this->hasDefaultLocale()) {
            return $this->setLocale($this->getDefaultLocale());
        }
    }

    /**
     * Specifies whether localization settings are present in Strata's
     * configuration array.
     * @return boolean
     */
    public function isLocalized()
    {
        return !is_null(Strata::config("i18n.locales"));
    }

    /**
     * Specifies if the list of active locales
     * has possible elements
     * @return boolean
     */
    public function hasActiveLocales()
    {
        return count($this->getLocales()) > 0;
    }

    /**
     * Returns the list of Locale objects
     * @return array
     */
    public function getLocales()
    {
        return (array)$this->locales;
    }

    /**
     * Returns a Locale object by code
     * @param  string $code The same code used when declaring the locale in the configuration value
     * @return Locale
     */
    public function getLocaleByCode($code)
    {
        $locales = $this->getLocales();
        if (array_key_exists($code, $locales)) {
            return $locales[$code];
        }
    }
    /**
     * Returns a Locale object by locale url key
     * @param  string $url The locale url as set when configuring (defaults to the locale code).
     * @return Locale
     */
    public function getLocaleByUrl($url)
    {
        foreach ($this->getLocales() as $locale) {
            if ($locale->getUrl() === $url) {
                return $locale;
            }
        }
    }

    /**
     * Returns the locale that is currently used.
     * @return Locale
     */
    public function getCurrentLocale()
    {
        if (is_null($this->currentLocale)) {
            $this->setCurrentLocaleByContext();

            if (!is_admin() || Router::isFrontendAjax()) {
                $this->applyLocale();
            }
        }

        return $this->currentLocale;
    }


    /**
     * Returns the code of the locale that is currently used.
     * @return string
     */
    public function getCurrentLocaleCode()
    {
        $locale = $this->getCurrentLocale();
        if (!is_null($locale)) {
            return $locale->getCode();
        }
    }

    /**
     * Specifies whether the application has a default locale defined.
     * @return boolean
     */
    public function hasDefaultLocale()
    {
        return !is_null($this->getDefaultLocale());
    }

    /**
     * Returns the default locale based off the localization documentation
     * specified in the Strata configuration file.
     * @return Locale
     */
    public function getDefaultLocale()
    {
        $locales = $this->getLocales();

        foreach ($locales as $locale) {
            if ($locale->isDefault()) {
                return $locale;
            }
        }

        return array_pop($locales);
    }

    /**
     * Specifies whether the application is currently using the
     * default locale.
     * @return boolean
     */
    public function currentLocaleIsDefault()
    {
        $currentLocale = $this->getCurrentLocale();
        if ($currentLocale) {
            return $currentLocale->isDefault();
        }

        return true;
    }

    /**
     * Checks whether a locale has been stored in the user's session.
     * @return boolean
     */
    public function hasLocaleInSession()
    {
        return array_key_exists($this->getSessionKey(), $_SESSION);
    }

    /**
     * Returns the locale that is saved in the session array.
     * This only returns a value the first time it's called because we don't want to
     * save the session permanently. It's used only to go through 1 page transition.
     * ex: when saving a post in the backend, we lose the locale in the process.
     * @return Locale
     */
    public function getLocaleInSession()
    {
        $localeCode = $_SESSION[$this->getSessionKey()];
        return $this->getLocaleByCode($localeCode);
    }

    /**
     * Starts a PHP session if there was none started already.
     * @return int Session id
     */
    private function startSession()
    {
        $sessionId = session_id();

        if (!$sessionId && !headers_sent()) {
            return session_start();
        }

        return $sessionId;
    }

    /**
     * The goal of dumping the code in the session vars is only to
     * keep a fallback value when someone (for instance) renders a 404
     * when browsing in a locale. Having the value in session prevents the 404
     * to be rendered with the default locale.
     *
     * The value in session should not have more weight then the other methods
     * in setCurrentLocaleByContext();
     *
     * @see setCurrentLocaleByContext
     */
    public function saveCurrentLocaleToSession()
    {
        $_SESSION[$this->getSessionKey()] = $this->getCurrentLocaleCode();
    }

    /**
     * Loads the translations from the locale's PO file and returns the list.
     * @param  string $localeCode
     * @return array
     */
    public function getTranslations($localeCode)
    {
        $locale = $this->getLocaleByCode($localeCode);

        if (!$locale->hasPoFile()) {
            throw new Exception(sprintf(__("The project has never been scanned for %s.", 'strata'), $locale->getNativeLabel()));
        }

        return Translations::fromPoFile($locale->getPoFilePath());
    }

    /**
     * Saves the translations to .po and .mo files.
     * @param  Locale $locale The locale of the translations
     * @param  array  $postedTranslations A list of translations
     * @return null
     */
    public function saveTranslations(Locale $locale, array $postedTranslations)
    {
        $editedTranslations = $this->postedValuesToTranslation($locale, $postedTranslations);
        $editedTranslations->toPoFile($locale->getPoFilePath(WP_ENV));

        return $this->generateTranslationFiles($locale);
    }

    public function postedValuesToTranslation(Locale $locale, array $postedTranslations)
    {
        $envPoFile = $locale->getPoFilePath(WP_ENV);
        $activeTranslations = $locale->hasPoFile(WP_ENV) ?
            Translations::fromPoFile($envPoFile) :
            new Translations();

        foreach ($postedTranslations as $t) {
            if (!empty($t['translation'])) {
                $original = html_entity_decode($t['original']);
                $context = html_entity_decode($t['context']);
                $translationText = html_entity_decode($t['translation']);
                $plural = html_entity_decode($t['pluralTranslation']);

                $translation = $this->addOrCreateString($activeTranslations, $context, $original);
                $translation->setTranslation($translationText);
                $translation->setPluralTranslation($plural);
            }
        }

        return $activeTranslations;
    }

    public function addOrCreateString(Translations $translationSet, $context, $original)
    {
        if (is_null($translationSet)) {
            throw new Exception("Did not find a translation set to query.");
        }

        $translation = $translationSet->find($context, $original);
        if ($translation === false) {
            $translation = new Translation($context, $original, "");
            $translationSet[] = $translation;
        }

        return $translation;
    }

    /**
     * This function also includes empty strings in the process.
     * @param  Locale $locale [description]
     * @param  [type] $from   [description]
     * @param  [type] $into   [description]
     * @return [type]         [description]
     */
    public function hardTranslationSetMerge(Locale $locale, $from, $into)
    {
        foreach ($from as $translation) {
            $translationString = $translation->getTranslation();
            $original = $translation->getOriginal();
            $context = $translation->getContext();
            $merged = $this->addOrCreateString($into, $context, $original);
            $merged->setTranslation($translationString);

            // The following raises an array to string warning
            // $merged->setPluralTranslation($translation->getPluralTranslation());
        }
    }

    public function generateTranslationFiles(Locale $locale, $activeTranslations = null)
    {
        $envPoFile = $locale->getPoFilePath(WP_ENV);
        $poFile = $locale->getPoFilePath();
        $moFile = $locale->getMoFilePath();

        if (is_null($activeTranslations)) {
            $activeTranslations = $locale->hasPoFile() ?
                Translations::fromPoFile($locale->getPoFilePath()) :
                new Translations();
        }

        // Add local modifications to the default set should there
        // be any.
        if ($locale->hasPoFile(WP_ENV)) {
             $this->hardTranslationSetMerge($locale, Translations::fromPoFile($envPoFile), $activeTranslations);
        }

        $textDomain = Strata::i18n()->getTextdomain();
        $activeTranslations->setDomain($textDomain);
        $activeTranslations->setHeader('Language', $locale->getCode());
        $activeTranslations->setHeader('Text Domain', $textDomain);
        $activeTranslations->setHeader('X-Domain', $textDomain);

        @unlink($poFile);
        @unlink($moFile);

        $activeTranslations->toPoFile($poFile);
        $activeTranslations->toMoFile($moFile);
    }

    /**
     * Compares $locale to the current locale to see if it is currently
     * active.
     * @param  Locale $locale
     * @return boolean
     */
    public function isCurrentlyActive(Locale $locale)
    {
        $current = $this->getCurrentLocale();
        return $locale->getCode() === $current->getCode();
    }

    /**
     * Sets the active locale
     * @param Locale $locale
     * @return Locale
     */
    public function setLocale(Locale $locale)
    {
        $this->currentLocale = $locale;
        $this->saveCurrentLocaleToSession();

        return $this->currentLocale;
    }

    /**
     * Returns the localization key in the session array.
     * @return string
     */
    private function getSessionKey()
    {
        if (is_admin() && !Router::isFrontendAjax()) {
            return self::DOMAIN . "_admin";
        }

        return self::DOMAIN . "_front";
    }

    /**
     * Registers the Wordpress hooks required by this class.
     * @return null
     */
    protected function registerHooks()
    {
        // if (!is_admin() || Router::isFrontendAjax()) {
        //     // add_action('after_setup_theme', array($this, "applyLocale"), 1);
        //     // add_filter('locale', array($this, "applyCurrentLanguageByContext"), 999);
        // }
    }

    /**
     * Specifies whether the class should be registering the Wordpress hooks.
     * Based on Wordpress existence (to account for Shell) and locale presence.
     * @return boolean
     */
    private function shouldAddWordpressHooks()
    {
        return function_exists('add_action') && $this->isLocalized();
    }

    /**
     * Goes through the list of localization configurations values in Strata's
     * configuration file.
     * @return array A list of instantiated Locale object.
     */
    protected function parseLocalesFromConfig()
    {
        $localeInfos = Hash::normalize((array)Strata::config("i18n.locales"));
        $locales = array();

        foreach ($localeInfos as $key => $config) {
            $locales[$key] = new Locale($key, $config);
        }

        return $locales;
    }

    /**
     * Returns the list of urls identifiers of all the
     * active locales
     * @return array
     */
    private function getLocaleRegexUrls()
    {
        $urls = array();
        foreach ($this->getLocales() as $locale) {
            if ($locale->hasACustomUrl()) {
                $urls[] =  preg_quote($locale->getUrl(), '/');
            }
        }
        return $urls;
    }
}