paths->root * * @var string * */ protected $rootPath; /** * Alternate root path for systems where there might be symlinks * * @var string * */ protected $rootPath2; /** * Translations for current language, in this format (consistent with the JSON): * * array( * 'textdomain' => array( * 'file' => 'filename', * 'translations' => array( * '[hash]' => array( * 'text' => string, // translated version of the text * ) * ) * ) * ); * * @var array * */ protected $textdomains = array(); /** * Cache of class names and the resulting textdomains * * @var array * */ protected $classNamesToTextdomains = array(); /** * Textdomains of parent classes that can be checked where applicable * * @var array * */ protected $parentTextdomains = array( // 'className' => array('parent textdomain 1', 'parent textdomain 2', 'etc.') ); /** * Is current language the default language? * * @var bool * */ protected $isDefaultLanguage = false; /** * Construct the translator and set the current language * * @param Language $currentLanguage * */ public function __construct(Language $currentLanguage) { parent::__construct(); $currentLanguage->wire($this); $this->setCurrentLanguage($currentLanguage); $this->rootPath = $this->wire()->config->paths->root; $file = __FILE__; $pos = strpos($file, '/wire/modules/LanguageSupport/'); $this->rootPath2 = $pos ? substr($file, 0, $pos+1) : ''; } /** * Set the current language and reset current stored textdomains * * @param Language $language * @return $this * */ public function setCurrentLanguage(Language $language) { if($this->currentLanguage && $language->id == $this->currentLanguage->id) return $this; $this->path = $language->filesManager->path(); // we only keep translations for one language in memory at once, // so if the language is changing, we clear out what's already in memory. if($this->currentLanguage && $language->id != $this->currentLanguage->id) $this->textdomains = array(); $this->currentLanguage = $language; $this->isDefaultLanguage = $language->isDefault(); return $this; } /** * Return the array template for a textdomain, optionally populating it with data * * @param string $file * @param string $textdomain * @param array $translations * @return array * */ protected function textdomainTemplate($file = '', $textdomain = '', array $translations = array()) { foreach($translations as $hash => $translation) { if(!strlen($translation['text'])) unset($translations[$hash]); } if(strpos($file, "\\") !== false && strpos($file, "\\" . basename($file))) { // file has MS-DOS style slashes and they are not escapes, convert to unix $file = str_replace("\\", '/', $file); } return array( 'file' => $file, 'textdomain' => $textdomain, 'translations' => $translations ); } /** * Given an object instance, return the resulting textdomain string * * This is accomplished with PHP's ReflectionClass to determine the file where the class lives * and then convert that to a textdomain string. Once determined, we cache it so that we * don't have to do this again. * * @param Wire|object $o * @return string * */ protected function objectToTextdomain($o) { $class = wireClassName($o, false); if(isset($this->classNamesToTextdomains[$class])) { $textdomain = $this->classNamesToTextdomains[$class]; } else { $reflection = new \ReflectionClass($o); $filename = $reflection->getFileName(); if($o instanceof Module) { $ds = \DIRECTORY_SEPARATOR; if(strpos($filename, "{$ds}wire{$ds}modules{$ds}") === false) { // not a core module $config = $this->wire()->config; $filename = $this->wire()->files->unixFileName($filename); $url = $config->urls($o); if($url && strpos($filename, $url) === false && strpos($filename, "/$class/") !== false) { // module likely in a symbolic link directory, so determine our own path for textdomain // rather than using the one provided by ReflectionClass list(, $filename) = explode("/$class/", $filename, 2); $filename = $config->paths($class) . $filename; } } } $textdomain = $this->filenameToTextdomain($filename); $this->classNamesToTextdomains[$class] = $textdomain; $parentTextdomains = array(); // core classes at which translations are no longer applicable // $stopClasses = array('Wire', 'WireData', 'WireArray', 'Fieldtype', 'FieldtypeMulti', 'Inputfield', 'Process'); $stopClasses = array( 'Wire', 'WireData', 'WireArray', 'Process' ); if(__NAMESPACE__) { foreach($stopClasses as $class) { $stopClass[] = __NAMESPACE__ . "\\$class"; } } /** @var \ReflectionClass $parentClass */ while($parentClass = $reflection->getParentClass()) { if(in_array($parentClass->getShortName(), $stopClasses)) break; $parentTextdomains[] = $this->filenameToTextdomain($parentClass->getFileName()); $reflection = $parentClass; } $this->parentTextdomains[$textdomain] = $parentTextdomains; } return $textdomain; } /** * Given a filename, convert it to a textdomain string * * @param string $filename * @return string * */ public function filenameToTextdomain($filename) { if(DIRECTORY_SEPARATOR != '/') $filename = str_replace(DIRECTORY_SEPARATOR, '/', $filename); if(strpos($filename, $this->rootPath) === 0) { $filename = str_replace($this->rootPath, '', $filename); } else if($this->rootPath2 && strpos($filename, $this->rootPath2) === 0) { // in case /wire/ or /site/ is a symlink $filename = str_replace($this->rootPath2, '', $filename); } else { // last resort, may not ever occur, but here anyway $pos = strrpos($filename, '/wire/'); if($pos === false) $pos = strrpos($filename, '/site/'); if($pos !== false) $filename = substr($filename, $pos+1); } // convert FileCompiler paths $pos = stripos($filename, '/cache/FileCompiler/'); if($pos) $filename = substr($filename, $pos+20); $textdomain = str_replace(array('/', '\\'), '--', ltrim($filename, '/')); $textdomain = str_replace('.', '-', $textdomain); return strtolower($textdomain); } /** * Given a textdomain string, convert it to a filename (relative to site root) * * This is determined by loading the textdomain and then grabbing the filename stored in the JSON properties * * @param string $textdomain * @return string * */ public function textdomainToFilename($textdomain) { if(!isset($this->textdomains[$textdomain])) $this->loadTextdomain($textdomain); return $this->textdomains[$textdomain]['file']; } /** * Normalize a string, filename or object to be a textdomain string * * @param string|object $textdomain * @return string * @since 3.0.154 was protected in prior versions * */ public function textdomainString($textdomain) { if(is_string($textdomain) && (strpos($textdomain, DIRECTORY_SEPARATOR) !== false || strpos($textdomain, '/') !== false)) { $textdomain = $this->filenameToTextdomain($textdomain); // @werker #424 } else if(is_object($textdomain)) { $textdomain = $this->objectToTextdomain($textdomain); } else { $textdomain = strtolower($textdomain); } // just in case there is an extension on it, remove it if(strpos($textdomain, '.')) $textdomain = basename($textdomain, '.json'); return $textdomain; } /** * Perform a translation in the given $textdomain for $text to the current language * * @param string|object $textdomain Textdomain string, filename, or object. * @param string $text Text in default language (EN) that needs to be converted to current language. * @param string $context Optional context label for the text, to differentiate from others that may be the same in English, but not other languages. * @return string Translation if available, or original EN version if translation not available. * */ public function getTranslation($textdomain, $text, $context = '') { if($this->wire()->hooks->isHooked('LanguageTranslator::getTranslation()')) { // if method has hooks, we let them run return $this->__call('getTranslation', array($textdomain, $text, $context)); } else { // if method has no hooks, we avoid any overhead return $this->___getTranslation($textdomain, $text, $context); } } /** * Implementation for the getTranslation() function - you should call getTranslation() without underscores instead. * * @param string|object $textdomain Textdomain string, filename, or object. * @param string $text Text in default language (EN) that needs to be converted to current language. * @param string $context Optional context label for the text, to differentiate from others that may be the same in English, but not other languages. * @return string Translation if available, or original EN version if translation not available. * */ public function ___getTranslation($textdomain, $text, $context = '') { // normalize textdomain to be a string, converting from filename or object if necessary $textdomain = $this->textdomainString($textdomain); $_text = $text; // if the text is already provided in the proper language then no reason to go further // if($this->currentLanguage->id == $this->defaultLanguagePageID) return $text; // hash of original text $hash = $this->getTextHash($text . $context); // translation textdomain hasn't yet been loaded, so load it if(!isset($this->textdomains[$textdomain])) $this->loadTextdomain($textdomain); // see if this translation exists if(isset($this->textdomains[$textdomain]['translations'][$hash]['text']) && strlen($this->textdomains[$textdomain]['translations'][$hash]['text'])) { // translation found $text = $this->textdomains[$textdomain]['translations'][$hash]['text']; } else if(!empty($this->parentTextdomains[$textdomain])) { // check parent class textdomains foreach($this->parentTextdomains[$textdomain] as $td) { if(!isset($this->textdomains[$td])) $this->loadTextdomain($td); if(!empty($this->textdomains[$td]['translations'][$hash]['text'])) { $text = $this->textdomains[$td]['translations'][$hash]['text']; break; } } } // see if text is available as a common translation if($text === $_text && !$this->isDefaultLanguage) { $_text = $this->commonTranslation($text); if(!empty($_text)) $text = $_text; } // if text hasn't changed at this point, we'll be returning it in the provided language since we have no translation return $text; } /** * Return ALL translations for the given textdomain * * @param string $textdomain * @return array * */ public function getTranslations($textdomain) { // normalize to string $textdomain = $this->textdomainString($textdomain); // translation textdomain hasn't yet been loaded, so load it if(!isset($this->textdomains[$textdomain])) $this->loadTextdomain($textdomain); // return the translations array return $this->textdomains[$textdomain]['translations']; } /** * Set a translation * * @param string $textdomain * @param string $text * @param string $translation * @param string $context * @return string * */ public function setTranslation($textdomain, $text, $translation, $context = '') { // get the unique hash identifier for the $text $hash = $this->getTextHash($text . $context); return $this->setTranslationFromHash($textdomain, $hash, $translation); } /** * Set a translation using an already known hash * * @param string $textdomain * @param string $hash * @param string $translation * @return string * */ public function setTranslationFromHash($textdomain, $hash, $translation) { // if the textdomain isn't yet setup, then set it up if(!isset($this->textdomains[$textdomain]) || !is_array($this->textdomains[$textdomain])) { $this->textdomains[$textdomain] = $this->textdomainTemplate(); } // populate the new translation if(strlen($translation)) $this->textdomains[$textdomain]['translations'][$hash] = array('text' => $translation); else unset($this->textdomains[$textdomain]['translations'][$hash]); // return the unique hash used to identify the translation return $hash; } /** * Remove a translation * * @param string $textdomain * @param string $hash May be the translation hash or the translated text. * @return $this * */ public function removeTranslation($textdomain, $hash) { if(empty($hash)) return $this; if(isset($this->textdomains[$textdomain]['translations'][$hash])) { // remove by $hash unset($this->textdomains[$textdomain]['translations'][$hash]); } else { // remove by given translation (in $hash) $text = $hash; foreach($this->textdomains[$textdomain]['translations'] as $hash => $translation) { if($translation['text'] === $text) { unset($this->textdomains[$textdomain]['translations'][$hash]); break; } } } return $this; } /** * Given original $text, issue a unique MD5 key used to reference it * * @param string $text * @return string * */ protected function getTextHash($text) { if(strpos($text, '\\n') !== false) $text = str_replace('\\n', "\n", $text); return md5($text); } /** * Get the JSON filename where the current languages class translations are * * @param string $textdomain * @return string * */ protected function getTextdomainTranslationFile($textdomain) { $textdomain = $this->textdomainString($textdomain); return $this->path . $textdomain . ".json"; } /** * Does a json translation file exist for the given textdomain? * * @param string $textdomain * @return bool * */ public function textdomainFileExists($textdomain) { $file = $this->getTextdomainTranslationFile($textdomain); return file_exists($file); } /** * Load translation group $textdomain into the current language translations * * @param string $textdomain * @return $this * */ public function loadTextdomain($textdomain) { $textdomain = $this->textdomainString($textdomain); if(isset($this->textdomains[$textdomain]) && is_array($this->textdomains[$textdomain])) return $this; $file = $this->getTextdomainTranslationFile($textdomain); if(is_file($file)) { $data = json_decode(file_get_contents($file), true); $this->textdomains[$textdomain] = $this->textdomainTemplate($data['file'], $data['textdomain'], $data['translations']); } else { $this->textdomains[$textdomain] = $this->textdomainTemplate('', $textdomain); } return $this; } /** * Given a source file to translate, create a new textdomain * * @param string $filename Filename or textdomain that we will be translating, relative to site root. * @param bool $filenameIsTextdomain Specify true if $filename is a textdomain instead. * @param bool $save Whether to save the language * @return string|bool Returns textdomain string if successful, or false if not. * */ public function addFileToTranslate($filename, $filenameIsTextdomain = false, $save = true) { if($filenameIsTextdomain) { $textdomain = $filename; $filename = $this->textdomainToFilename($textdomain); // $this->message($textdomain . ": " . $filename); } else { $textdomain = $this->filenameToTextdomain($filename); } $file = $this->getTextdomainTranslationFile($textdomain); if(empty($file)) { $this->error("Unable to get textdomain translation file: $textdomain"); return false; } if(is_file($file)) { $result = true; } else { $this->textdomains[$textdomain] = $this->textdomainTemplate(ltrim($filename, '/'), $textdomain); $result = file_put_contents($file, $this->encodeJSON($this->textdomains[$textdomain]), LOCK_EX); if($result && $this->config->chmodFile) chmod($file, octdec($this->config->chmodFile)); } if($result) { $fieldName = 'language_files'; if(strpos($textdomain, 'wire--') !== 0) { if($this->wire()->fields->get('language_files_site')) { $fieldName = 'language_files_site'; } } /** @var Pagefiles $pagefiles */ $pagefiles = $this->currentLanguage->$fieldName; if(!$pagefiles->has(basename($file))) { $pagefiles->add($file); if($save) $this->currentLanguage->save(); } } return $result ? $textdomain : false; } /** * Save the translation group given by $textdomain to disk in its translation file * * @param string $textdomain * @return int|bool Number of bytes written or false on failure * */ public function saveTextdomain($textdomain) { if(empty($this->textdomains[$textdomain])) return false; $data = $this->textdomains[$textdomain]; //if(empty($data['file'])) $data['file'] = $this->textdomainToFilename($textdomain); $json = $this->encodeJSON($data); $file = $this->getTextdomainTranslationFile($textdomain); $result = file_put_contents($file, $json, LOCK_EX); return $result; } /** * Unload the given textdomain string from memory * * @param string $textdomain * */ public function unloadTextdomain($textdomain) { unset($this->textdomains[$textdomain]); } /** * Return the data available for the given $textdomain string * * @param string $textdomain * @return array * */ public function getTextdomain($textdomain) { $this->loadTextdomain($textdomain); return isset($this->textdomains[$textdomain]) ? $this->textdomains[$textdomain] : array(); } /** * JSON encode language translation data * * @param array|string $value * @return string * */ public function encodeJSON($value) { if(defined("JSON_PRETTY_PRINT")) { return json_encode($value, JSON_PRETTY_PRINT); } else { return json_encode($value); } } /** * Get a common translation * * These are commonly used translations that can be used as fallbacks. * * Returns blank string if given string is not a common phrase. * Returns given $str if given string is common, but not translated here. * Returns translated $str if common and translated. * * @param string $str * @return string * */ public function commonTranslation($str) { static $level = 0; if(strlen($str) >= 15 || $level) return ''; // 15=max length of our common phrases $level++; $v = ''; switch(strtolower($str)) { case '0-plural': $v = $this->_('0-plural'); break; // Is zero (0) plural or singular? type=radios options=[0-plural:Plural,0-singular:Singular] // i.e. would language use "0 items" (plural) or "0 item" (singular)? case 'edit': $v = $this->_('Edit'); break; case 'delete': $v = $this->_('Delete'); break; case 'save': $v = $this->_('Save'); break; case 'save & exit': case 'save and exit': case 'save + exit': $v = $this->_('Save + Exit'); break; case 'cancel': $v = $this->_('Cancel'); break; case 'ok': $v = $this->_('Ok'); break; case 'new': $v = $this->_('New'); break; case 'add': $v = $this->_('Add'); break; case 'add new': $v = $this->_('Add New'); break; case 'are you sure?': $v = $this->_('Are you sure?'); break; case 'confirm': $v = $this->_('Confirm'); break; case 'import': $v = $this->_('Import'); break; case 'export': $v = $this->_('Export'); break; case 'yes': $v = $this->_('Yes'); break; case 'no': $v = $this->_('No'); break; case 'on': $v = $this->_('On'); break; case 'off': $v = $this->_('Off'); break; case 'enabled': $v = $this->_('Enabled'); break; case 'disabled': $v = $this->_('Disabled'); break; case 'example': $v = $this->_('Example'); break; case 'please note': $v = $this->_('Please note:'); break; case 'note': $v = $this->_('Note'); break; case 'notes': $v = $this->_('Notes'); break; case 'settings': $v = $this->_('Settings'); break; case 'type': $v = $this->_('Type'); break; case 'label': $v = $this->_('Label'); break; case 'name': $v = $this->_('Name'); break; case 'description': $v = $this->_('Description'); break; case 'details': $v = $this->_('Details'); break; case 'access': $v = $this->_('Access'); break; case 'advanced': $v = $this->_('Advanced'); break; case 'icon': $v = $this->_('Icon'); break; case 'system': $v = $this->_('System'); break; case 'modified': $v = $this->_('Modified'); break; case 'error': $v = $this->_('Error'); break; case ',': $v = $this->_(','); break; // Comma } $level--; return $v; } }