diff --git a/app/Languages/en/main.lng b/app/Languages/en/main.lng new file mode 100644 index 0000000..ae03058 --- /dev/null +++ b/app/Languages/en/main.lng @@ -0,0 +1,34 @@ +# Language example file + +> name: English + +ok: Ok +cancel: Cancel +help: Help + +[group:auth] + login: Login + username: Username + email: E-mail + pass: Password + + [group:login_grp] + > keywords: test, something, keyword + lost_pass: Forgot your password? + sign_in: Sign in + [end:login_grp] + + [group:register] + register: Sign up + username_placeholder: Enter username... + username_tooltip: Only letters of the English alphabet. + email_placeholder: Enter {auth.email}... + email_tooltip: Enter your real e-mail. + pass_placeholder: Enter password... + pass_tooltip: Must not be less than 4 characters. + [end:register] + + > include test +[end:auth] + +test: Test string \ No newline at end of file diff --git a/app/Languages/en/test.lng b/app/Languages/en/test.lng new file mode 100644 index 0000000..6b02a3a --- /dev/null +++ b/app/Languages/en/test.lng @@ -0,0 +1,4 @@ +> group: test_group +> keywords: test + +test_phrase: Some text... \ No newline at end of file diff --git a/app/Languages/ru.lng b/app/Languages/ru.lng new file mode 100644 index 0000000..6d420fd --- /dev/null +++ b/app/Languages/ru.lng @@ -0,0 +1,12 @@ + +> name: Русский +> fallback: en + +ok: Ок +cancel: Отмена +help: Помощь + +[group:auth] + > keywords: test + login: Логин +[end:auth] \ No newline at end of file diff --git a/config/modules.php b/config/modules.php index decf1b5..c13eff8 100644 --- a/config/modules.php +++ b/config/modules.php @@ -26,6 +26,7 @@ return [ + 'Lang', 'TemplateProcessor', 'Page', //Requires TemplateProcessor module // 'MySql', diff --git a/config/modules/lang.php b/config/modules/lang.php new file mode 100644 index 0000000..5066160 --- /dev/null +++ b/config/modules/lang.php @@ -0,0 +1,43 @@ +. + * + * @author rayleigh + * @copyright 2018 Vladislav Pashaiev + * @license LGPL-3.0+ + */ + +return +[ + // List of the codes of languages to load + 'languages' => ['en', 'ru'], + + // Code of the fallback language + 'fallback' => 'en', + + // Code of the default language + 'default' => 'en', + + // Cache enabled + 'cache' => true, + + // Path to the language files + 'lang_path' => 'app/Languages', + + // Path to the language cache + 'cache_path' => 'storage/cache/lang' +]; \ No newline at end of file diff --git a/iridium/Modules/Lang/Dictionary.php b/iridium/Modules/Lang/Dictionary.php new file mode 100644 index 0000000..48a9548 --- /dev/null +++ b/iridium/Modules/Lang/Dictionary.php @@ -0,0 +1,307 @@ +. + * + * @author rayleigh + * @copyright 2018 Vladislav Pashaiev + * @license LGPL-3.0+ + */ + +namespace Iridium\Modules\Lang; + +use Iridium\Core\Tools\ArrayTools; +use Iridium\Core\Tools\StringTools; + +/** + * Dictionary that contains language phrases. + * @package Iridium\Modules\Lang + */ +class Dictionary +{ + /** + * Separator for the phrase path. + */ + const PATH_SEPARATOR = '.'; + + /** + * @var string Code of the language. + */ + protected $code; + + /** + * @var string Code of the fallback language. + */ + private $fallbackCode; + + /** + * @var string Language name. + */ + protected $name; + + /** + * @var Group Root group. + */ + protected $group; + + /** + * @var Dictionary Fallback language dictionary. + */ + protected $fallback; + + /** + * Creates new dictionaty. + * @param string $code Code of the language. + * @param string $name Name of the language. + * @param Group $group Root group. + */ + public function __construct(string $code, string $name, Group $group) + { + $this->code = $code; + $this->group = $group; + $this->name = $name; + } + + /** + * @return string Code of the language. + */ + public function GetCode(): string + { + return $this->code; + } + + /** + * @return string Name of the language. + */ + public function GetName(): string + { + return $this->name; + } + + /** + * Sets the code of the fallback language. + * @param string $code Code of the fallback language. + * @return Dictionary Self. + */ + public function SetFallbackCode(string $code): self + { + $this->fallbackCode = $code; + return $this; + } + + /** + * @return string Code of the setted fallback language or empty string if no fallback language is setted for current language. + */ + public function GetFallbackCode(): string + { + return empty($this->fallbackCode) ? '' : $this->fallbackCode; + } + + /** + * Sets the fallback language. + * @param Dictionary $fallback Dictionary of the fallback language. + * @return Dictionary Self. + */ + public function SetFallbackLang(Dictionary $fallback): self + { + $this->fallback = $fallback; + return $this; + } + + /** + * Creates filtered dictionary from this one. + * @param string|string[] ...$keywords + * @return FilteredDictionary Filtered dictionary. + */ + public function Filter(...$keywords): FilteredDictionary + { + return new FilteredDictionary($this, ArrayTools::Flatten($keywords)); + } + + /** + * Searches for phrase with specified path. + * @param string $path Path to the phrase. + * @return null|string Phrase or null if no phrase by the specified path. + * @throws \Exception If the path is empty. + */ + public function FindPhrase(string $path) + { + if(empty($path)) { throw new \Exception('Path should not be empty.'); } + + $pathComponents = explode(self::PATH_SEPARATOR, $path); + $phraseId = array_pop($pathComponents); + $group = empty($pathComponents) ? $this->group : $this->FindGroup($pathComponents); + + if($group !== null) + { + $phrase = $group->GetPhrase($phraseId); + if($phrase !== null) + { + return $phrase; + } + } + + // Search in the fallback language + if(!empty($this->fallback)) + { + return $this->fallback->FindPhrase($path); + } + + return null; + } + + /** + * Converts dictionary to the plain array where keys is the path to the phrases and values is phrases. + * @return array Plain array that contains phrases of this dictionary. + */ + public function ToArray(): array + { + $result = []; + + $convert = function(Group $group, $gp = '') use(&$convert, &$result) + { + if($this->IsGroupSuitable($group)) + { + foreach($group->GetPhrases() as $id => $phrase) + { + $result[empty($gp) ? $id : $gp . $id] = $phrase; + } + } + + /** @var Group $sub */ + foreach($group->GetSubgroups() as $sub) + { + $groupName = $sub->GetName(); + $convert($sub, (empty($gp) ? '' : $gp) . (empty($groupName) ? '' : $groupName . self::PATH_SEPARATOR)); + } + }; + + /** @var Dictionary $lang */ + foreach($this->GetFallbackStack() as $lang) { $convert($lang->group); } + return $result; + } + + /** + * Converts dictionary to the stdClass objects hierarchy that suitable for JSON conversion and sending to the client. + * Note that phrase can be replaced by group if they have same name and if they placed in one level in the hierarchy. + * @return \stdClass stdClass objects hierarchy that contains phrases and some additional info. + */ + public function ToClientFormat(): \stdClass + { + $convert = function(Group $group, \stdClass $parent) use(&$convert) + { + if($this->IsGroupSuitable($group)) + { + foreach($group->GetPhrases() as $id => $phrase) + { + $parent->{StringTools::SnakeToCamelCase($id)} = $phrase; + } + } + + /** @var Group $sub */ + foreach($group->GetSubgroups() as $sub) + { + if($sub->IsNameless()) + { + $convert($sub, $parent); + } + else + { + $name = StringTools::SnakeToCamelCase($sub->GetName()); + $container = isset($parent->{$name}) && is_object($parent->{$name}) ? $parent->{$name} : new \stdClass; + $convert($sub, $container); + + // Do not include empty groups + if(!empty((array)$container)) + { + $parent->{$name} = $container; + } + } + } + }; + + $result = new \stdClass; + /** @var Dictionary $lang */ + foreach($this->GetFallbackStack() as $lang) { $convert($lang->group, $result); } + return $result; + } + + /** + * Searches for the group with specified path. + * @param array $pathComponents Path components to the group. + * @return Group|null Found group or null if no group with specified path. + * @throws \Exception + */ + private function FindGroup(array $pathComponents) + { + $li = count($pathComponents); + + if($li-- === 0) + { + throw new \Exception("Path must not be empty."); + } + + $seek = function(Group $parent, $i = 0) use(&$seek, $pathComponents, $li) + { + /** @var Group $sub */ + foreach($parent->GetSubgroups() as $sub) + { + if($sub->IsNameless()) + { + $found = $seek($sub); + if($found !== null) + { + return $found; + } + } + + if($sub->GetName() === $pathComponents[$i]) + { + return $i === $li ? $sub : $seek($sub, $i + 1); + } + } + + // Nothing found + return null; + }; + + return $seek($this->group); + } + + /** + * @return array List of the all languages starting from this and and ending the deepest fallback language. + */ + private function GetFallbackStack(): array + { + $result = []; + $f = $this; + + while($f !== null) + { + $result[] = $f; + $f = $f->fallback; + } + + return array_reverse($result); + } + + /** + * Determines when to look to the phrases of the group or not. + * @param Group $group Group. + * @return bool True if need to look for the phrases in specified group. + */ + protected function IsGroupSuitable(Group $group): bool { return true; } +} \ No newline at end of file diff --git a/iridium/Modules/Lang/FilteredDictionary.php b/iridium/Modules/Lang/FilteredDictionary.php new file mode 100644 index 0000000..4b1237e --- /dev/null +++ b/iridium/Modules/Lang/FilteredDictionary.php @@ -0,0 +1,45 @@ +. + * + * @author rayleigh + * @copyright 2018 Vladislav Pashaiev + * @license LGPL-3.0+ + */ + +namespace Iridium\Modules\Lang; + +/** + * Dictionary, filtered by keywords. + * @package Iridium\Modules\Lang + */ +class FilteredDictionary extends Dictionary +{ + private $keywords; + + public function __construct(Dictionary $dictionary, array $keywords) + { + parent::__construct($dictionary->code, $dictionary->name, $dictionary->group); + $this->fallback = $dictionary->fallback; + $this->keywords = $keywords; + } + + protected function IsGroupSuitable(Group $group): bool + { + return $group->HasKeywords($this->keywords); + } +} \ No newline at end of file diff --git a/iridium/Modules/Lang/Group.php b/iridium/Modules/Lang/Group.php new file mode 100644 index 0000000..50a848f --- /dev/null +++ b/iridium/Modules/Lang/Group.php @@ -0,0 +1,228 @@ +. + * + * @author rayleigh + * @copyright 2018 Vladislav Pashaiev + * @license LGPL-3.0+ + */ + +namespace Iridium\Modules\Lang; + + +/** + * Group of phrases. + * @package Iridium\Modules\Lang + */ +class Group +{ + /** + * @var string Name. + */ + private $name; + + /** + * @var array Keywords. + */ + private $keywords; + + /** + * @var Group[] List of subgroups. + */ + private $sub; + + /** + * @var string[] List of phrases. + */ + private $phrases; + + /** + * Creates new group of phrases. + * @param string $name Name of the group. + */ + public function __construct(string $name = '') + { + $this->name = $name; + $this->keywords = []; + $this->sub = []; + $this->phrases = []; + } + + /** + * @return string Name of the group. + */ + public function GetName(): string + { + return $this->name; + } + + /** + * Sets the name of this group. + * @param string $name New name of the group. + */ + public function SetName(string $name) + { + $this->name = $name; + } + + /** + * @return bool True if group is nameless. + */ + public function IsNameless(): bool + { + return empty($this->name); + } + + /** + * Adds multiple keywords to this group. + * @param array $keywords Keywords to be added. + */ + public function AddKeywords(array $keywords) + { + $this->keywords = array_merge($this->keywords, $keywords); + } + + /** + * Adds keyword to this group. + * @param string $keyword Keyword to be added. + */ + public function AddKeyword(string $keyword) + { + array_push($this->keywords, $keyword); + } + + /** + * @return array Keywords of this group. + */ + public function GetKeywords(): array + { + return $this->keywords; + } + + /** + * Determines if group has the specified keyword. + * @param string $keyword Keyword. + * @return bool True if group has specified keyword. + */ + public function HasKeyword(string $keyword): bool + { + return in_array($keyword, $this->keywords, true); + } + + /** + * Determines if group has the specified keywords. + * @param array $keywords Keywords. + * @return bool True if group has all of the specified keywords. + */ + public function HasKeywords(array $keywords): bool + { + return !array_diff($keywords, $this->keywords); + } + + /** + * Adds new phrase to the group. If phrase with specified id is already in group, it will be replaced. + * @param string $id Identifier of the phrase. + * @param string $phrase Text of the phrase. + */ + public function AddPhrase(string $id, string $phrase) + { + $this->phrases[$id] = $phrase; + } + + /** + * @param string $id Identifier of the phrase. + * @return null|string Phrase or null if no phrase with specified id. + */ + public function GetPhrase(string $id) + { + if(array_key_exists($id, $this->phrases)) { return $this->phrases[$id]; } + return null; + } + + /** + * @return array Phrases. + */ + public function GetPhrases(): array + { + return $this->phrases; + } + + /** + * Adds subgroup to this group. + * @param Group $subgroup Subgroup to be added. + */ + public function AddSubgroup(Group $subgroup) + { + $found = $this->FindSubgroup($subgroup->name); + + if(empty($found)) + { + $this->sub[] = $subgroup; + return; + } + + $found->Merge($subgroup); + } + + /** + * @return Group[] Subgroups of this group. + */ + public function GetSubgroups(): array + { + return $this->sub; + } + + /** + * Searches for the subgroup with the specified name. + * @param string $name Name of the group to be searched. + * @return Group|null Group or null if no subgroup with specified name. + */ + public function FindSubgroup(string $name) + { + foreach($this->sub as $gr) + { + if($gr->name === $name) + { + return $gr; + } + } + + return null; + } + + /** + * Merges this group with the specified end returs this group. + * @param Group $group Group for merge. + * @return Group Self. + */ + public function Merge(Group $group): self + { + $this->phrases += $group->phrases; + + foreach($group->sub as $subgroup) + { + $found = $this->FindSubgroup($subgroup->name); + + if(!empty($found)) + { + $found->Merge($subgroup); + } + } + + return $this; + } +} \ No newline at end of file diff --git a/iridium/Modules/Lang/GroupParseData.php b/iridium/Modules/Lang/GroupParseData.php new file mode 100644 index 0000000..40c6121 --- /dev/null +++ b/iridium/Modules/Lang/GroupParseData.php @@ -0,0 +1,160 @@ +. + * + * @author rayleigh + * @copyright 2018 Vladislav Pashaiev + * @license LGPL-3.0+ + */ + +namespace Iridium\Modules\Lang; + +/** + * Data for parsing the group. + * @package Iridium\Modules\Lang + */ +class GroupParseData +{ + /** + * @var string Content of the group to be parsed. + */ + private $content; + + /** + * @var int Beginning line if it is a subgroup. + */ + private $line; + + /** + * @var string Directory of file if group is a file. + */ + private $dir; + + /** + * @var string File of the group if group is a file. + */ + private $file; + + /** + * Creates new data object for parsing the group. + * @param string $content Content of the group to parse. + */ + public function __construct(string $content) + { + $this->content = $content; + } + + /** + * @return string Content of the group to be parsed. + */ + public function GetContent(): string + { + return $this->content; + } + + /** + * @param int $line Beginning line. + * @return GroupParseData + */ + public function SetLine(int $line): self + { + $this->line = $line; + return $this; + } + + /** + * @return int Beginning line. + */ + public function GetLine(): int + { + return empty($this->line) ? 1 : $this->line; + } + + /** + * Sets path to the file without extension. + * @param string $path Path to the file. + * @return GroupParseData + * @throws \Exception If file or directory name is not valid. + */ + public function SetPath(string $path): self + { + $pathComponents = explode('/', $path); + $this->file = array_pop($pathComponents); + + if(!self::CheckFileName($this->file)) + { + throw new \Exception("File name {$this->file} is not valid."); + } + + if(count($pathComponents) > 0) + { + foreach($pathComponents as $comp) + { + if(!self::CheckFileName($comp)) + { + throw new \Exception("Directory name {$comp} is not valid."); + } + } + + $this->dir = implode(DIRECTORY_SEPARATOR, $pathComponents); + } + + return $this; + } + + /** + * @return bool True, if group is a file. + */ + public function IsFile(): bool + { + return !empty($this->file); + } + + /** + * @return string Directory of the file. + */ + public function GetDir(): string + { + return empty($this->dir) ? '' : $this->dir; + } + + /** + * @return string Name of the file without extension. + */ + public function GetFile(): string + { + return empty($this->file) ? '' : $this->file; + } + + /** + * @return string Relative path to the file without extension. + */ + public function GetRelativePath(): string + { + return (empty($this->dir) ? '' : $this->dir . DIRECTORY_SEPARATOR) . (empty($this->file) ? '' : $this->file); + } + + /** + * Checks if passed file or directory name is valid. + * @param string $name File name. + * @return bool True, if file name is valid. + */ + private static function CheckFileName(string $name): bool + { + return preg_match('/^[A-Za-z0-9_]+$/', $name) === 1; + } +} \ No newline at end of file diff --git a/iridium/Modules/Lang/Lang.php b/iridium/Modules/Lang/Lang.php new file mode 100644 index 0000000..29b5f25 --- /dev/null +++ b/iridium/Modules/Lang/Lang.php @@ -0,0 +1,770 @@ +. + * + * @author rayleigh + * @copyright 2018 Vladislav Pashaiev + * @license LGPL-3.0+ + */ + +namespace Iridium\Modules\Lang; + + +use Iridium\Core\Module\IModule; + +require 'GroupParseData.php'; +require 'Group.php'; +require 'Dictionary.php'; +require 'FilteredDictionary.php'; + +/** + * Language module. + * @package Iridium\Modules\Lang + */ +final class Lang implements IModule +{ + /** + * Extension of the language file. + */ + const LANG_FILE_EXT = 'lng'; + + /** + * Extension of the phrase file. + */ + const PHRASE_FILE_EXT = 'phr'; + + /** + * Multibyte encoding of the language files. + */ + const ENCODING = 'UTF-8'; + + /** + * @var \stdClass Configuration of the module. + */ + private static $conf; + + /** + * @var Dictionary Active language. + */ + private static $active; + + /** + * @var Dictionary[] Loaded languages. + */ + private static $languages; + + /** + * Initializes the language module. + * @param array $conf Config of the module. + * @throws \Exception If some error is occurred. + */ + public static function Init(array $conf) + { + self::$conf = (object)$conf; + + if(self::$conf->cache) + { + // TODO: cache + } + + self::$languages = []; + + // Load languages + foreach(self::$conf->languages as $langCode) + { + $path = self::GetLangPath($langCode); + + if($path === null) + { + throw new \Exception("Cannot find language with code '{$langCode}'."); + } + + self::$languages[] = self::ReadLanguage($langCode, $path); + } + + // Set fallbacks + foreach(self::$languages as $lang) + { + $fallbackCode = $lang->GetFallbackCode(); + if(!empty($fallbackCode)) + { + if($fallbackCode === $lang->GetCode()) + { + throw new \Exception("Language with code {$lang->GetCode()} cannot have fallback code that equals to it's code."); + } + + foreach(self::$languages as $l) + { + if($l->GetCode() === $fallbackCode) + { + $lang->SetFallbackLang($l); + } + } + } + } + } + + /** + * Returns an array of required modules. + * @return array Required modules. + */ + public static function GetRequiredModules(): array { return []; } + + /** + * Searches for the . or the /main. file and returns path. + * @param string $code Code of the language. + * @return null|string Path or null if file not found. + */ + private static function GetLangPath(string $code) + { + // Just one file + if(file_exists(self::BuildLangFilePath($code))) + { + return $code; + } + + // Directory + $dirFilePath = $code . DIRECTORY_SEPARATOR . 'main'; + if(file_exists(self::BuildLangFilePath($dirFilePath))) + { + return $dirFilePath; + } + + return null; + } + + /** + * Sets the active language by code. + * @param string $code Code of the language that is needed to be setted as active. + * @throws \Exception If language with the specified code is not found. + */ + public static function SetActive(string $code) + { + $exist = false; + + foreach(self::$languages as $lang) + { + if($lang->GetCode() === $code) + { + self::$active = $lang; + $exist = true; + break; + } + } + + if(!$exist) + { + throw new \Exception("Language with the specified code '{$code}' is not found."); + } + } + + /** + * @return string Code of the active language or empty string if active language is not setted. + */ + public static function GetActiveCode(): string + { + return empty(self::$active) ? '' : self::$active->GetCode(); + } + + /** + * Returns dictionary of the language with the specified code. If the code is not specified (or empty), the dictionary of the active language will be returned. + * @param string $code Code of the language, dictionary of which should be returned. + * @return Dictionary Dictionary of the language. + * @throws \Exception If the code is not specified and the is no active language or the language of the specified code does not exist. + */ + public static function GetDictionary($code = ''): Dictionary + { + if(empty($code)) + { + if(empty(self::$active)) + { + throw new \Exception('Code of the language should not be empty or you should set active language.'); + } + + return self::$active; + } + + foreach(self::$languages as $lang) + { + if($lang->GetCode() === $code) + { + return $lang; + } + } + + throw new \Exception("Language with specified code was not found."); + } + + /** + * Reads language with the specified code and path and returns it's dictionary. + * @param string $code Code of the language. + * @param string $path Short path to the language file. + * @return Dictionary Dictionary of the language. + * @throws \Exception If an error is occurred while reading or parsing the file or parameter 'name' is not setted in the main file of the language. + */ + private static function ReadLanguage(string $code, string $path) : Dictionary + { + try + { + $mainFileContent = self::ReadFile(self::BuildLangFilePath($path)); + } + catch(\Exception $e) + { + throw new \Exception("Error trying to read the main file of the language with code {$code}.", 0, $e); + } + + $parseData = new GroupParseData($mainFileContent); + $parseData->SetPath($path); + + $langParams = new \stdClass; + $mainGroup = self::ProcessGroup($parseData, $langParams); + + if(empty($langParams->name)) + { + throw new \Exception("Parameter 'name' is required in the language main file."); + } + + $dict = new Dictionary($code, $langParams->name, $mainGroup); + if(!empty($langParams->fallback)) + { + $dict->SetFallbackCode($langParams->fallback); + } + + return $dict; + } + + /** + * Process group content text and returns parsed group data. + * @param GroupParseData $pd Data for parsing the group. + * @param \stdClass|null $mainParams Reference to the stdClass object that should be filled with main + * file parameters. Used only if group is a main file. + * @return Group Data of the group. + * @throws \Exception If error occured while processing the group. + */ + private static function ProcessGroup(GroupParseData $pd, \stdClass &$mainParams = null): Group + { + // Flags + $newLine = true; + $cmdOrParam = false; + $comment = false; + $groupConstr = false; + $phrase = false; + $namelessGrpCount = 0; // Count of the embedded nameless groups + + $group = new Group; // Current group + $ri = 0; // Remember '$i' + $gi = 0; // Group start '$i' + $line = $pd->GetLine(); // Number of the line + $file = $pd->GetRelativePath(); // Relative path to the current file without the extension + $groupLine = 0; // Line number of the subgroup beginning + $availableMainParams = ['name', 'fallback']; + $subgroupData = null; + $len = mb_strlen($pd->GetContent(), self::ENCODING); // Length of the content + + /** + * Generates place text based on line number and path to the file. + * @return string Place. + */ + $genPlace = static function() use(&$line, $file) + { + $result = 'at line ' . $line; + if(!empty($file)) { $result .= " in file '{$file}'"; } + return $result; + }; + + // Iterate through symbols + for($i = 0; $i < $len; $i++) + { + $char = mb_substr($pd->GetContent(), $i, 1, self::ENCODING); + + // Detect comment + if($char === '#') { $comment = true; } + + // Detect new line or comment or EOF (last symbol) + if($char === "\n" || $char === "\r" || $comment || $i === $len - 1) + { + // End of the comment + if($comment && ($char === "\n" || $char === "\r")) + { + $comment = false; + continue; + } + + // New line detected + if(!$comment) + { + $newLine = true; + $line++; + } + + // Current symbol is last + $lastSymbol = $i === $len - 1; + + // Parse command or parameter + if($cmdOrParam) + { + $cmdOrParam = false; + + // Try parse command or parameter + try + { + // Skip '>' and get rest of the line except EOL char + $data = self::ParseCommandOrParam(mb_substr($pd->GetContent(), $ri + 1, $i - $ri - ($lastSymbol ? 0 : 1))); + } + catch(\Exception $e) + { + throw new \Exception("Error trying to parse command or parameter {$genPlace()}.", 1, $e); + } + + if($data->type === 0) // Parameter + { + $isMainParam = in_array($data->name, $availableMainParams); + + if(isset($mainParams)) + { + if($isMainParam) + { + $mainParams->{$data->name} = $data->param; + } + else + { + throw new \Exception("Parameter '{$data->name}'' is not available as main file parameter {$genPlace()}."); + } + } + else + { + if($isMainParam) + { + throw new \Exception("Parameter '{$data->name}' is only allowed as main file parameter {$genPlace()}."); + } + + switch($data->name) + { + case 'keywords': // Keywords parameter + $keywords = array_map( + function($kw) { return trim($kw); }, + explode(',', $data->param) + ); + + if(empty($keywords)) + { + throw new \Exception("Value is expected for the 'keywords' parameter {$genPlace()}."); + } + + // Validate keywords + foreach($keywords as $kw) + { + if(!self::CheckKeyword($kw)) + { + throw new \Exception("Keyword '{$kw}' is invalid {$genPlace()}."); + } + } + + $group->AddKeywords($keywords); + break; + case 'group': // Group name parameter + if(empty($file)) + { + throw new \Exception("Parameter 'group' is only allowed in included file {$genPlace()}."); + } + + if(!self::CheckGroupName($data->param)) + { + throw new \Exception("Invalid group name '{$data->param}' {$genPlace()}."); + } + + $group->SetName($data->param); + break; + default: + throw new \Exception("No parameter exist with name {$data->name} {$genPlace()}."); + break; + } + } + } + else if($data->type === 1) // Command + { + switch($data->name) + { + case 'include': // File include command + if(strlen($data->param) === 0) + { + throw new \Exception("Command 'include' requires path to the file {$genPlace()}."); + } + + $incParceData = new GroupParseData(self::ReadFile(self::BuildLangFilePath($pd->GetDir() . DIRECTORY_SEPARATOR . $data->param))); + $incParceData->SetPath($pd->GetDir() . DIRECTORY_SEPARATOR . $data->param); + + try + { + $incGroup = self::ProcessGroup($incParceData); + } + catch(\Exception $e) + { + throw new \Exception("Error trying to read and parse file specified in the include command {$genPlace()}.", 6, $e); + } + + $group->AddSubgroup($incGroup); + break; + default: + throw new \Exception("No command exist with name '{$data->name}' {$genPlace()}."); + break; + } + } + } + + // Parse group construction + if($groupConstr) + { + $groupConstr = false; + + $text = trim(mb_substr($pd->GetContent(), $ri + 1, $i - $ri - ($lastSymbol ? 0 : 1))); + + if(mb_substr($text, -1) !== ']') + { + throw new \Exception("Symbol ']' at the end of the group beginning construction expected {$genPlace()}."); + } + + try + { + $data = self::ParseGroupConstruction(mb_substr($text, 0, -1)); + } + catch(\Exception $e) + { + throw new \Exception("Error trying to parse group beginning or ending {$genPlace()}.", 2, $e); + } + + if($data->end) + { + // This is the ending of the group + + if(empty($data->name)) { $namelessGrpCount--; } + + // End of the current subgroup + if(empty($data->name) && empty($subgroupData->name) && $namelessGrpCount === 0 || !empty($data->name) && $subgroupData->name === $data->name) + { + try + { + $subgrouParseData = new GroupParseData(mb_substr($pd->GetContent(), $gi, $ri - $gi - 1)); + $subgrouParseData->SetLine($groupLine); + $subgrouParseData->SetPath($pd->GetRelativePath()); + $subgroup = self::ProcessGroup($subgrouParseData); + } + catch(\Exception $e) + { + throw new \Exception("Cannot process subgroup.", 3, $e); + } + + $subgroup->SetName(empty($subgroupData->name) ? '' : $subgroupData->name); + $group->AddSubgroup($subgroup); + $subgroupData = null; + } + } + else + { + // This is the beginning of the group + + if(empty($subgroupData)) + { + // Remember where the group starts + $gi = $i + 1; + $groupLine = $line; + + // Remember subgroup data + $subgroupData = $data; + } + + if(empty($data->name)) + { + // Increment nameless groups count + $namelessGrpCount++; + } + else + { + if(!self::CheckGroupName($data->name)) + { + throw new \Exception("Invalid group name '{$data->name}' {$genPlace()}."); + } + } + } + } + + // Parse phrase + if($phrase) + { + $phrase = false; + + try + { + $data = self::ParsePhrase(mb_substr($pd->GetContent(), $ri, $i - $ri + ($lastSymbol ? 1 : 0))); + } + catch(\Exception $e) + { + throw new \Exception("Error trying to parse phrase {$genPlace()}.", 4, $e); + } + + if(!self::CheckPhraseId($data->id)) + { + throw new \Exception("Phrase identifier '{$data->id}' is invalid {$genPlace()}."); + } + + $group->AddPhrase($data->id, $data->phrase); + } + + continue; + } + + // Skip comment + if($comment) { continue; } + + // Starts with new line + if($newLine) + { + // Skip tabs and spaces + if($char === "\t" || $char === " ") + { + continue; + } + + $newLine = false; + + // Group construction + if($char === '[') + { + $groupConstr = true; + $ri = $i; // Remember start of the group construction + + continue; + } + + // Parse everything else only if subgroup isn't found + if(empty($subgroupData)) + { + // Command or parameter + if($char === '>') + { + $cmdOrParam = true; + $ri = $i; // Remember start of the command or parameter + + continue; + } + + // Phrase + $phrase = true; + $ri = $i; + } + } + } + + // End of file + + if(!empty($subgroupData)) + { + throw new \Exception( + (empty($subgroupData->name) ? + 'Nameless group constraction' : + "Group construction with name '{$subgroupData->name}'") + . ' should be closed.' + ); + } + + return $group; + } + + /** + * Reads file and returns it's content. + * @param string $filePath Path to the file. + * @return string Content of the file. + * @throws \Exception If error occured while reading the file. + */ + private static function ReadFile(string $filePath): string + { + if(($rootContent = @file_get_contents($filePath)) === false) + { + throw new \Exception("Cannot read file {$filePath}."); + } + + return $rootContent; + } + + /** + * Parses command or parameter and returns it's data. + * @param string $text Text of the command or parameter. + * @return \stdClass Data of the command or parameter. + * @throws \Exception If error occured while parsing the command or parameter. + */ + private static function ParseCommandOrParam(string $text): \stdClass + { + $result = new \stdClass(); + $text = trim($text); + + for($i = 0; $i < mb_strlen($text, self::ENCODING); $i++) + { + $symb = mb_substr($text, $i, 1, self::ENCODING); + + if($symb === ' ' || $symb === ':') + { + // Parameter + if($symb === ':') + { + $result->type = 0; + } + + // Command + if($symb === ' ') + { + $result->type = 1; + } + + $result->name = trim(mb_substr($text, 0, $i, self::ENCODING)); + $result->param = trim(mb_substr($text, $i + 1, null, self::ENCODING)); + + break; + } + } + + if(!isset($result->name)) + { + throw new \Exception("Command or parameter expected."); + } + + return $result; + } + + /** + * Parses group beginning or ending and returns it's data. + * @param string $text Text of the group beginning or ending. + * @return \stdClass Data of the command beginning or ending. + * @throws \Exception If error occured while parsing the group beginning or ending. + */ + private static function ParseGroupConstruction(string $text): \stdClass + { + $result = new \stdClass; + + for($i = 0; $i < mb_strlen($text, self::ENCODING); $i++) + { + $symb = mb_substr($text, $i, 1, self::ENCODING); + + if($symb === ':') + { + $command = trim(mb_substr($text, 0, $i, self::ENCODING)); + $result->name = trim(mb_substr($text, $i + 1, null, self::ENCODING)); + + if(empty($result->name)) + { + throw new \Exception("Name of the group is required after the ':' symbol."); + } + + break; + } + } + + if(empty($command)) + { + $command = trim($text); + } + + switch($command) + { + case 'group': + $result->end = false; + break; + case 'end': + $result->end = true; + break; + default: + throw new \Exception("Must be 'group' or 'end' in square brackets."); + break; + } + + return $result; + } + + /** + * Parses phrases identifier and text. + * @param string $text Text that should be parsed. + * @return \stdClass Parsed phrase identifier and text. + * @throws \Exception If error occured while parsing the phrase. + */ + private static function ParsePhrase(string $text): \stdClass + { + $result = new \stdClass; + + for($i = 0; $i < mb_strlen($text, self::ENCODING); $i++) + { + $symb = mb_substr($text, $i, 1, self::ENCODING); + + if($symb === ':') + { + $result->id = trim(mb_substr($text, 0, $i, self::ENCODING)); + $result->phrase = trim(mb_substr($text, $i + 1, null, self::ENCODING)); + break; + } + } + + if(empty($result->id)) + { + throw new \Exception("Phrase should have identifier."); + } + + if(empty($result->phrase)) + { + throw new \Exception("Text of the phrase should not be empty."); + } + + return $result; + } + + /** + * Checks if passed group name is valid. + * @param string $groupName Name of the group. + * @return bool True, if name is valid. + */ + private static function CheckGroupName(string $groupName): bool + { + return preg_match('/^[A-Za-z_]{2,}$/', $groupName) === 1; + } + + /** + * Checks if passed keyword is valid. + * @param string $keyword Keyword. + * @return bool True, if keyword is valid. + */ + private static function CheckKeyword(string $keyword): bool + { + return preg_match('/^[A-Za-z_ ]{2,}$/', $keyword) === 1; + } + + /** + * Checks if passed phrase identifier is valid. + * @param string $id Phrase identifier. + * @return bool True, if phrase identifier is valid. + */ + private static function CheckPhraseId(string $id): bool + { + return preg_match('/^[A-Za-z][A-Za-z0-9_]+$/', $id) === 1; + } + + /** + * Builds and returns absolute path to the language file. + * @param string $lng Language file relative (to the language files folder) path without extension. + * @return string Builded absolute path. + */ + private static function BuildLangFilePath(string $lng) + { + return ROOT_PATH . DIRECTORY_SEPARATOR . self::$conf->lang_path . DIRECTORY_SEPARATOR . $lng . '.' . self::LANG_FILE_EXT; + } +} \ No newline at end of file