Admidio/admidio

View on GitHub
src/Language.php

Summary

Maintainability
C
1 day
Test Coverage
<?php
namespace Admidio;

/**
 * @brief Reads language specific texts that are identified with text ids out of language xml files
 *
 * The class will read a language specific text that is identified with their
 * text id out of a language xml file. The access will be managed with the
 * \SimpleXMLElement which search through xml files.
 *
 * **Code example**
 * ```
 * // create a language data object and assign it to the language object
 * $gL10n = new Language('de');
 *
 * // read and display a language specific text with placeholders for individual content
 * echo $gL10n->get('SYS_CREATED_BY_AND_AT', array('John Doe', '2019-04-13'));
 * ```
 * @copyright The Admidio Team
 * @see https://www.admidio.org/
 * @license https://www.gnu.org/licenses/gpl-2.0.html GNU General Public License v2.0 only
 */
class Language
{
    public const REFERENCE_LANGUAGE = 'en'; // The ISO code of the default language that should be read if in the current language the text id is not translated

    /**
     * @var string The code of the language that should be read in this object
     */
    private string $language = '';
    /**
     * @var string The ISO 639-1 code of the language
     */
    private string $languageIsoCode = '';
    /**
     * @var string The language code for external libraries.
     */
    private string $languageLibs = '';
    /**
     * @var array<int,string> Array with all relevant language files
     */
    private array $languageFolderPaths = array();
    /**
     * @var array<string,string> Array with all countries and their ISO codes e.g.: array('DEU' => 'Germany' ...)
     */
    private array $countries = array();
    /**
     * @var array<string,string> Stores all read text data in an array to get quick access if a text is required several times
     */
    private array $textCache = array();
    /**
     * @var bool Set to true if the language folders of the plugins are already loaded.
     */
    private bool $pluginLanguageFoldersLoaded = false;
    /**
     * @var array<string,string> An Array with all available languages and their ISO codes
     */
    private array $languages = array();
    /**
     * @var array<string,\SimpleXMLElement> An array with all \SimpleXMLElement object of the language from all paths that are set in **$languageFolderPaths**.
     */
    private array $xmlLanguageObjects = array();
    /**
     * @var array<string,\SimpleXMLElement> An array with all \SimpleXMLElement object of the reference language from all paths that are set in **$languageFolderPaths**.
     */
    private array $xmlRefLanguageObjects = array();

    /**
     * Language constructor.
     * @param string $language The ISO code of the language for which the texts should be read e.g. **'de'**
     *                         If no language is set than the browser language will be determined.
     */
    public function __construct(string $language)
    {
        if ($language === '') {
            // get browser language and set this language as default
            $language = static::determineBrowserLanguage(self::REFERENCE_LANGUAGE);
        }

        $this->setLanguage($language);
        $this->addLanguageFolderPath(ADMIDIO_PATH . FOLDER_LANGUAGES);

        $this->addPluginLanguageFolderPaths();
    }

    /**
     * We need the sleep function at this place because otherwise the system will serialize a SimpleXMLElement
     * which will lead to an exception.
     * @return array<int,string>
     */
    public function __sleep()
    {
        return array('language', 'languageIsoCode', 'languageLibs', 'languageFolderPaths', 'languages', 'countries', 'textCache', 'pluginLanguageFoldersLoaded');
    }

    /**
     * Adds a new path of language files to the array with all language paths where Admidio
     * should search for language files.
     * @param string $languageFolderPath Server path where Admidio should search for language files.
     * @return bool Returns true if language path is added.
     *@throws \UnexpectedValueException
     */
    public function addLanguageFolderPath(string $languageFolderPath): bool
    {
        if ($languageFolderPath === '' || !is_dir($languageFolderPath)) {
            throw new \UnexpectedValueException('Invalid folder path!');
        }

        if (in_array($languageFolderPath, $this->languageFolderPaths, true)) {
            return false;
        }

        $this->languageFolderPaths[] = $languageFolderPath;

        return true;
    }

    /**
     * Read language folder of each plugin in adm_plugins and add this folder to the language folder
     * array of this class.
     */
    public function addPluginLanguageFolderPaths()
    {
        global $gLogger;

        if (!$this->pluginLanguageFoldersLoaded) {
            try {
                $pluginFolders = \FileSystemUtils::getDirectoryContent(ADMIDIO_PATH . FOLDER_PLUGINS, false, true, array(\FileSystemUtils::CONTENT_TYPE_DIRECTORY));

                foreach ($pluginFolders as $pluginFolder => $type) {
                    $languageFolder = $pluginFolder . '/languages';

                    if (is_dir($languageFolder)) {
                        $this->addLanguageFolderPath($languageFolder);
                    }
                }

                $this->pluginLanguageFoldersLoaded = true;
            } catch (\RuntimeException $exception) {
                $gLogger->error('L10N: Plugins folder content could not be loaded!', array('errorMessage' => $exception->getMessage()));
            }
        }
    }

    /**
     * Determine the language from the browser preferences of the user.
     * @param string $defaultLanguage This language will be set if no browser language could be determined
     * @return string Return the preferred language code of the client browser
     */
    public static function determineBrowserLanguage(string $defaultLanguage): string
    {
        if (empty($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
            return $defaultLanguage;
        }

        $languages = preg_split('/\s*,\s*/', $_SERVER['HTTP_ACCEPT_LANGUAGE']);
        $languageSelected = $defaultLanguage;
        $prioritySelected = 0;

        foreach ($languages as $value) {
            if (!preg_match('/^([a-z]{2,3}(?:-[a-zA-Z]{2,3})?|\*)(?:\s*;\s*q=(0(?:\.\d{1,3})?|1(?:\.0{1,3})?))?$/', $value, $matches)) {
                continue;
            }

            $langCodes = explode('-', $matches[1]);

            $priority = 1.0;
            if (isset($matches[2])) {
                $priority = (float) $matches[2];
            }

            if ($prioritySelected < $priority && $langCodes[0] !== '*') {
                $languageSelected = $langCodes[0];
                $prioritySelected = $priority;
            }
        }

        return $languageSelected;
    }

    /**
     * Reads a text string out of a language xml file that is identified
     * with a unique text id e.g. SYS_COMMON. If the text contains placeholders
     * than you must set more parameters to replace them.
     * @param string $textId Unique text id of the text that should be read e.g. SYS_COMMON
     * @param array<int,string> $params Optional parameter to replace placeholders in the text.
     *                                  $params[0] will replace **#VAR1#** or **#VAR1_BOLD#**,
     *                                  $params[1] will replace **#VAR2#** or **#VAR2_BOLD#** etc.
     * @return string Returns the text string with replaced placeholders of the text id.
     *
     * **Code example**
     * ```
     * // display a text without placeholders
     * echo $gL10n->get('SYS_NUMBER');
     * // display a text with placeholders for individual content
     * echo $gL10n->get('SYS_CREATED_BY_AND_AT', array('John Doe', '2019-04-13'));
     * ```
     * @throws Exception
     */
    public function get(string $textId, array $params = array()): string
    {
        global $gLogger;

        $startTime = microtime(true);

        try {
            $text = $this->getTextFromTextId($textId);

            //$gLogger->debug('L10N: Lookup time:', array('time' => getExecutionTime($startTime), 'textId' => $textId));
        } catch (\OutOfBoundsException $exception) {
            $gLogger->debug('L10N: Lookup time:', array('time' => getExecutionTime($startTime), 'textId' => $textId));
            $gLogger->error('L10N: ' . $exception->getMessage(), array('textId' => $textId));

            // Read language folders of the plugins. Maybe there was a new plugin installed.
            $this->addPluginLanguageFolderPaths();

            // no text found then write #undefined text#
            return '#' . $textId . '#';
        }

        return self::prepareTextPlaceholders($text, $params);
    }

    /**
     * Gets an array with all languages that are possible in Admidio.
     * The array will have the following syntax e.g.: array('DE' => 'deutsch' ...)
     * @return array<string,string> Return an array with all available languages.
     */
    public function getAvailableLanguages(): array
    {
        if (count($this->languages) === 0) {
            $this->languages = self::loadAvailableLanguages();
        }

        return $this->languages;
    }

    /**
     * Returns the path of a country file.
     * @return string
     * @throws Exception
     */
    private function getCountryFile(): string
    {
        $langFile    = ADMIDIO_PATH . FOLDER_LANGUAGES . '/countries-' . $this->language . '.xml';
        $langFileRef = ADMIDIO_PATH . FOLDER_LANGUAGES . '/countries-' . $this::REFERENCE_LANGUAGE   . '.xml';

        if (is_file($langFile)) {
            return $langFile;
        }
        if (is_file($langFileRef)) {
            return $langFileRef;
        }

        throw new Exception('Country files not found!');
    }

    /**
     * Returns an array with all countries and their ISO codes (ISO 3166 ALPHA-3)
     * @return array<string,string> Array with all countries and their ISO codes (ISO 3166 ALPHA-3) e.g.: array('DEU' => 'Germany' ...)
     * @throws Exception
     */
    public function getCountries(): array
    {
        if (count($this->countries) === 0) {
            $this->countries = $this->loadCountries();
        }

        return $this->countries;
    }

    /**
     * Returns the name of the country in the language of this object. The country will be
     * identified by the ISO code (ISO 3166 ALPHA-3) e.g. 'DEU' or 'GBR' ...
     * @param string $countryIsoCode The three digits ISO code (ISO 3166 ALPHA-3) of the country where the name should be returned.
     * @return string Return the name of the country in the language of this object.
     * @throws Exception
     */
    public function getCountryName(string $countryIsoCode): string
    {
        if (empty($countryIsoCode)) {
            return '';
        }

        if (!preg_match('/^[A-Z]{3}$/', $countryIsoCode)) {
            throw new Exception('SYS_COUNTRY_ISO');
        }

        $countries = $this->getCountries();

        if (!array_key_exists($countryIsoCode, $countries)) {
            throw new Exception('Country-iso-code does not exist!');
        }

        return $countries[$countryIsoCode];
    }

    /**
     * Returns the three digits ISO code (ISO 3166 ALPHA-3) of the country. The country will be identified
     * by the name in the language of this object
     * @param string $countryName The name of the country in the language of this object.
     * @return string Return the three digits ISO code (ISO 3166 ALPHA-3) of the country.
     * @throws Exception
     */
    public function getCountryIsoCode(string $countryName): string
    {
        if ($countryName === '') {
            throw new Exception('Invalid country name!');
        }

        $countries = $this->getCountries();

        $result = array_search($countryName, $countries, true);
        if ($result === false) {
            throw new Exception('Country name does not exist!');
        }

        return $result;
    }

    /**
     * Returns the language code of the language of this object. That will also return the country specific
     * codes such as de-CH. If you only want the ISO code then call getLanguageIsoCode().
     * @return string Returns the language code of the language of this object or the reference language.
     */
    public function getLanguage(): string
    {
        return $this->language;
    }

    /**
     * Returns the ISO 639-1 code of the language of this object.
     * @return string Returns the ISO 639-1 code of the language of this object e.g. **de** or **en**.
     */
    public function getLanguageIsoCode(): string
    {
        return $this->languageIsoCode;
    }

    /**
     * Returns the language code of the language that we need for some libs e.g. datepicker or ckeditor.
     * @return string Returns the language code of the language of this object or the reference language.
     */
    public function getLanguageLibs(): string
    {
        return $this->languageLibs;
    }

    /**
     * @param string $textId Unique text id of the text that should be read e.g. SYS_COMMON
     * @return string Returns the cached text or empty string if text id isn't found
     * @throws \OutOfBoundsException
     */
    private function getTextCache(string $textId): string
    {
        if (!array_key_exists($textId, $this->textCache)) {
            throw new \OutOfBoundsException('Text-id is not cached!');
        }

        return $this->textCache[$textId];
    }

    /**
     * Reads a text string out of a language xml file that is identified with a unique text id e.g. SYS_COMMON.
     * @param string $textId Unique text id of the text that should be read e.g. SYS_COMMON
     * @return string Returns the text string of the text id.
     * @throws Exception
     */
    private function getTextFromTextId(string $textId): string
    {
        // first search text id in text-cache
        try {
            return $this->getTextCache($textId);
        } catch (\OutOfBoundsException $exception) {
            // if text id wasn't found than search for it in language
            try {
                // search for text id in every \SimpleXMLElement (language file) of the object array
                return $this->searchTextIdInLangObject($this->xmlLanguageObjects, $this->language, $textId);
            } catch (\OutOfBoundsException $exception) {
                // if text id wasn't found than search for it in reference language
                try {
                    // search for text id in every \SimpleXMLElement (language file) of the object array
                    return $this->searchTextIdInLangObject($this->xmlRefLanguageObjects, $this::REFERENCE_LANGUAGE, $textId);
                } catch (\OutOfBoundsException $exception) {
                    throw new \OutOfBoundsException($exception->getMessage());
                }
            }
        }
    }

    /**
     * Checks if a given string is a translation-string-id
     * @param string $string The string to check
     * @return bool Returns true if the given string is a translation-string-id
     */
    public static function isTranslationStringId(string $string): bool
    {
        return (bool) preg_match('/^[A-Z]{3}_([A-Z0-9]_?)*[A-Z0-9]$/', $string);
    }

    /**
     * Creates an array with all languages that are possible in Admidio.
     * The array will have the following syntax e.g.: array('DE' => 'deutsch' ...)
     * @return array<string,string>
     */
    private static function loadAvailableLanguages(): array
    {
        require(ADMIDIO_PATH . FOLDER_LANGUAGES . '/languages.php');

        return array_map(function ($languageInfos) {
            return $languageInfos['name'];
        }, $gSupportedLanguages);
    }

    /**
     * Returns an array with all countries and their ISO codes
     * @return array<string,string> Array with all countries and their ISO codes e.g.: array('DEU' => 'Germany' ...)
     * @throws Exception
     */
    private function loadCountries(): array
    {
        $countryFile = $this->getCountryFile();

        // read all countries from xml file
        try {
            $countriesXml = new \SimpleXMLElement($countryFile, 0, true);
        } catch (\Exception $exception) {
            throw new Exception($exception->getMessage());
        }
        $countries = array();

        /**
         * @var \SimpleXMLElement $xmlNode
         */
        foreach ($countriesXml->children() as $xmlNode) {
            $countries[(string) $xmlNode['name']] = (string) $xmlNode;
        }

        asort($countries, SORT_LOCALE_STRING);

        return $countries;
    }

    /**
     * Replaces all placeholders of the translation string with their values that are set through the array **$params**.
     * If the value of the array is a translation id the method will automatically try to replace this id with the
     * translation string.
     * @param string $text The translation string with the static placeholders
     * @param array<int,string> $params An array with values for each placeholder of the string.
     * @return string Returns the translation string with the replaced placeholders.
     * @throws Exception
     */
    private function prepareTextPlaceholders(string $text, array $params): string
    {
        // replace placeholder with value of parameters
        foreach ($params as $index => $param) {
            $paramNr = $index + 1;

            $param = self::translateIfTranslationStrId($param);

            $replaces = array(
                '#VAR' . $paramNr . '#'      => $param,
                '#VAR' . $paramNr . '_BOLD#' => '<strong>' . $param . '</strong>'
            );
            $text = \StringUtils::strMultiReplace($text, $replaces);
        }

        // replace square brackets with html tags
        return strtr($text, '[]', '<>');
    }

    /**
     * @param string $text
     * @return string
     */
    private static function prepareXmlText(string $text): string
    {
        // set line break with html
        // Within Android string resource all apostrophe are escaped, so we must remove the escape char
        // replace highly comma, so there are no problems in the code later
        $replaces = array(
            '\\n'  => '<br />',
            '\\\'' => '\'',
            '\''   => '&rsquo;',
            '\\"'  => '&quot;'
        );
        return \StringUtils::strMultiReplace($text, $replaces);
    }

    /**
     * Search for text id in a language xml file and return the text. If no text was found than nothing is returned.
     * @param array<string,\SimpleXMLElement> $xmlLanguageObjects The reference to an array where every SimpleXMLElement of each language path is stored
     * @param string $languageFilePath The path of the language file to search in.
     * @param string $textId The id of the text that will be searched in the file.
     * @return string Return the text in the language or nothing if text id wasn't found.
     * @throws \OutOfBoundsException|Exception
     */
    private function searchLanguageText(array &$xmlLanguageObjects, string $languageFilePath, string $textId): string
    {
        // if not exists create a \SimpleXMLElement of the language file in the language path
        // and add it to the array of language objects
        if (!array_key_exists($languageFilePath, $xmlLanguageObjects)) {
            if (!is_file($languageFilePath)) {
                // throw exception and don't log missing file because user could not fix that problem if there is no translation file
                throw new \OutOfBoundsException('Language file does not exist!');
            }

            try {
                $xmlLanguageObjects[$languageFilePath] = new \SimpleXMLElement($languageFilePath, 0, true);
            } catch (\Exception $exception) {
                throw new Exception($exception->getMessage());
            }
        }

        // text not in cache -> read from xml file in "Android Resource String" format
        $xmlNodes = $xmlLanguageObjects[$languageFilePath]->xpath('/resources/string[@name="'.$textId.'"]');

        if ($xmlNodes === false || count($xmlNodes) === 0) {
            throw new \OutOfBoundsException('Could not found text-id!');
        }

        $text = self::prepareXmlText((string) $xmlNodes[0]);

        $this->textCache[$textId] = $text;

        return $text;
    }

    /**
     * @param array<string,\SimpleXMLElement> $xmlLanguageObjects SimpleXMLElement array of each language path is stored
     * @param string $language           Language code
     * @param string $textId             Unique text id of the text that should be read e.g. SYS_COMMON
     * @return string Returns the text string of the text id.
     * @throws \UnexpectedValueException|Exception
     * @throws \OutOfBoundsException
     */
    private function searchTextIdInLangObject(array &$xmlLanguageObjects, string $language, string $textId): string
    {
        foreach ($this->languageFolderPaths as $languageFolderPath) {
            try {
                $languageFilePath = $languageFolderPath . '/' . $language . '.xml';

                return $this->searchLanguageText($xmlLanguageObjects, $languageFilePath, $textId);
            } catch (\OutOfBoundsException $exception) {
                // continue searching, no debug output because this will be default way if you have several language path through plugins
            }
        }

        throw new \OutOfBoundsException('Could not found text-id!');
    }

    /**
     * Set a language to this object. If there was a language before than initialize the cache
     * @param string $language ISO code of the language that should be set to this object.
     * @return bool Returns true if language changed.
     */
    public function setLanguage(string $language): bool
    {
        require(ADMIDIO_PATH . FOLDER_LANGUAGES . '/languages.php');

        if ($language === $this->language) {
            return false;
        }

        // initialize data
        $this->xmlLanguageObjects    = array();
        $this->xmlRefLanguageObjects = array();
        $this->countries = array();
        $this->textCache = array();

        $this->language = $language;
        $this->languageLibs = $gSupportedLanguages[$language]['libs'];
        $this->languageIsoCode = $gSupportedLanguages[$language]['isocode'];

        return true;
    }

    /**
     * Checks if a given string is a translation-string-id and translate it
     * @param string $string The string to check for translation
     * @return string Returns the translated or original string
     * @throws Exception
     */
    public static function translateIfTranslationStrId(string $string): string
    {
        global $gL10n;

        if (self::isTranslationStringId($string)) {
            return $gL10n->get($string);
        }

        return $string;
    }
}