2022-03-08 15:55:41 +01:00
< ? php namespace ProcessWire ;
/**
* ProcessWire Language Translator
*
2024-04-04 14:37:20 +02:00
* ProcessWire 3. x , Copyright 2023 by Ryan Cramer
2022-03-08 15:55:41 +01:00
* https :// processwire . com
*
*
*/
class LanguageTranslator extends Wire {
/**
* Language ( Page ) instance of the current language
*
* @ var Language
*
*/
protected $currentLanguage ;
/**
* Path where language files are stored
*
* i . e . language_files path for current $language
*
* @ var string
*
*/
protected $path ;
/**
* Root path of installation , same as wire ( 'config' ) -> 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 ) {
2023-03-10 19:41:40 +01:00
parent :: __construct ();
2022-03-08 15:55:41 +01:00
$currentLanguage -> wire ( $this );
$this -> setCurrentLanguage ( $currentLanguage );
2023-03-10 19:41:40 +01:00
$this -> rootPath = $this -> wire () -> config -> paths -> root ;
2022-03-08 15:55:41 +01:00
$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 ) {
2022-11-05 18:32:48 +01:00
if ( strpos ( $text , '\\n' ) !== false ) $text = str_replace ( '\\n' , " \n " , $text );
2022-03-08 15:55:41 +01:00
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 );
2024-04-04 14:37:20 +02:00
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 ));
}
2022-03-08 15:55:41 +01:00
if ( $result ) {
$fieldName = 'language_files' ;
if ( strpos ( $textdomain , 'wire--' ) !== 0 ) {
2024-04-04 14:37:20 +02:00
if ( $this -> wire () -> fields -> get ( 'language_files_site' )) {
2022-03-08 15:55:41 +01:00
$fieldName = 'language_files_site' ;
}
}
2024-04-04 14:37:20 +02:00
/** @var Pagefiles $pagefiles */
$pagefiles = $this -> currentLanguage -> $fieldName ;
if ( ! $pagefiles -> has ( basename ( $file ))) {
$pagefiles -> add ( $file );
if ( $save ) $this -> currentLanguage -> save ();
}
2022-03-08 15:55:41 +01:00
}
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
*
2022-11-05 18:32:48 +01:00
* @ param array | string $value
2022-03-08 15:55:41 +01:00
* @ return string
*
*/
2022-11-05 18:32:48 +01:00
public function encodeJSON ( $value ) {
2022-03-08 15:55:41 +01:00
if ( defined ( " JSON_PRETTY_PRINT " )) {
2022-11-05 18:32:48 +01:00
return json_encode ( $value , JSON_PRETTY_PRINT );
2022-03-08 15:55:41 +01:00
} else {
2022-11-05 18:32:48 +01:00
return json_encode ( $value );
2022-03-08 15:55:41 +01:00
}
}
/**
* 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 )) {
2024-04-04 14:37:20 +02:00
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)?
2022-03-08 15:55:41 +01:00
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 ;
2024-04-04 14:37:20 +02:00
case ',' : $v = $this -> _ ( ',' ); break ; // Comma
2022-03-08 15:55:41 +01:00
}
$level -- ;
return $v ;
}
}