2016-09-29 21:58:09 -04:00
< ? php
2017-11-16 20:23:18 -05:00
/** @ license MIT
* Copyright 2017 J . King , Dustin Wilson et al .
* See LICENSE and AUTHORS files for details */
2016-09-29 21:58:09 -04:00
declare ( strict_types = 1 );
2017-03-28 00:12:12 -04:00
namespace JKingWeb\Arsse ;
2016-09-29 21:58:09 -04:00
class Lang {
2020-03-01 18:32:01 -05:00
public const DEFAULT = " en " ; // fallback locale
protected const REQUIRED = [ // collection of absolutely required strings to handle pathological errors
2017-03-28 00:12:12 -04:00
'Exception.JKingWeb/Arsse/Exception.uncoded' => 'The specified exception symbol {0} has no code specified in AbstractException.php' ,
'Exception.JKingWeb/Arsse/Exception.unknown' => 'An unknown error has occurred' ,
'Exception.JKingWeb/Arsse/Lang/Exception.defaultFileMissing' => 'Default language file "{0}" missing' ,
'Exception.JKingWeb/Arsse/Lang/Exception.fileMissing' => 'Language file "{0}" is not available' ,
'Exception.JKingWeb/Arsse/Lang/Exception.fileUnreadable' => 'Insufficient permissions to read language file "{0}"' ,
'Exception.JKingWeb/Arsse/Lang/Exception.fileCorrupt' => 'Language file "{0}" is corrupt or does not conform to expected format' ,
'Exception.JKingWeb/Arsse/Lang/Exception.stringMissing' => 'Message string "{msgID}" missing from all loaded language files ({fileList})' ,
'Exception.JKingWeb/Arsse/Lang/Exception.stringInvalid' => 'Message string "{msgID}" is not a valid ICU message string (language files loaded: {fileList})' ,
2017-02-16 15:29:42 -05:00
];
2016-09-29 21:58:09 -04:00
2017-03-28 18:50:00 -04:00
public $path ; // path to locale files; this is a public property to facilitate unit testing
2017-09-05 19:35:14 -04:00
protected $requirementsMet = false ; // whether the Intl extension is loaded
2017-03-28 18:50:00 -04:00
protected $synched = false ; // whether the wanted locale is actually loaded (lazy loading is used by default)
protected $wanted = self :: DEFAULT ; // the currently requested locale
protected $locale = " " ; // the currently loaded locale
protected $loaded = []; // the cascade of loaded locale file names
protected $strings = self :: REQUIRED ; // the loaded locale strings, merged
2019-01-20 22:40:49 -05:00
/** @var \MessageFormatter */
protected $formatter ;
2016-09-29 21:58:09 -04:00
2017-08-29 10:50:31 -04:00
public function __construct ( string $path = BASE . " locale " . DIRECTORY_SEPARATOR ) {
2017-03-28 18:50:00 -04:00
$this -> path = $path ;
}
2016-09-29 21:58:09 -04:00
2017-03-28 18:50:00 -04:00
public function set ( string $locale , bool $immediate = false ) : string {
2017-02-16 17:50:34 -05:00
// make sure the Intl extension is loaded
2017-09-05 19:35:14 -04:00
if ( ! $this -> requirementsMet ) {
$this -> checkRequirements ();
2017-07-20 22:40:09 -04:00
}
2017-02-16 17:50:34 -05:00
// if requesting the same locale as already wanted, just return (but load first if we've requested an immediate load)
2019-01-11 10:38:06 -05:00
if ( $locale === $this -> wanted ) {
2017-08-29 10:50:31 -04:00
if ( $immediate && ! $this -> synched ) {
2017-07-20 22:40:09 -04:00
$this -> load ();
}
2017-02-16 17:50:34 -05:00
return $locale ;
}
// if we've requested a locale other than the null locale, fetch the list of available files and find the closest match e.g. en_ca_somedialect -> en_ca
2019-01-11 10:38:06 -05:00
if ( $locale !== " " ) {
2017-03-28 18:50:00 -04:00
$list = $this -> listFiles ();
2017-02-16 17:50:34 -05:00
// if the default locale is unavailable, this is (for now) an error
2017-08-29 10:50:31 -04:00
if ( ! in_array ( self :: DEFAULT , $list )) {
2017-07-20 22:40:09 -04:00
throw new Lang\Exception ( " defaultFileMissing " , self :: DEFAULT );
}
2017-03-28 18:50:00 -04:00
$this -> wanted = $this -> match ( $locale , $list );
2017-02-16 15:29:42 -05:00
} else {
2017-03-28 18:50:00 -04:00
$this -> wanted = " " ;
2017-02-16 15:29:42 -05:00
}
2017-03-28 18:50:00 -04:00
$this -> synched = false ;
2017-02-16 17:50:34 -05:00
// load right now if asked to, otherwise load later when actually required
2017-08-29 10:50:31 -04:00
if ( $immediate ) {
2017-07-20 22:40:09 -04:00
$this -> load ();
}
2017-03-28 18:50:00 -04:00
return $this -> wanted ;
2017-02-16 15:29:42 -05:00
}
2016-09-29 21:58:09 -04:00
2017-03-28 18:50:00 -04:00
public function get ( bool $loaded = false ) : string {
2017-02-16 17:50:34 -05:00
// we can either return the wanted locale (default) or the currently loaded locale
2017-03-28 18:50:00 -04:00
return $loaded ? $this -> locale : $this -> wanted ;
}
public function dump () : array {
return $this -> strings ;
2017-02-16 15:29:42 -05:00
}
2016-09-29 21:58:09 -04:00
2017-03-28 18:50:00 -04:00
public function msg ( string $msgID , $vars = null ) : string {
return $this ( $msgID , $vars );
2017-02-16 15:29:42 -05:00
}
2017-02-09 16:39:13 -05:00
2017-03-28 18:50:00 -04:00
public function __invoke ( string $msgID , $vars = null ) : string {
2017-02-16 15:29:42 -05:00
// if we're trying to load the system default language and it fails, we have a chicken and egg problem, so we catch the exception and load no language file instead
2017-08-29 10:50:31 -04:00
if ( ! $this -> synched ) {
try {
$this -> load ();
} catch ( Lang\Exception $e ) {
2019-01-11 10:38:06 -05:00
if ( $this -> wanted === self :: DEFAULT ) {
2017-08-29 10:50:31 -04:00
$this -> set ( " " , true );
} else {
throw $e ;
}
2017-02-16 15:29:42 -05:00
}
}
2017-02-16 17:50:34 -05:00
// if the requested message is not present in any of the currently loaded language files, throw an exception
// note that this is indicative of a programming error since the default locale should have all strings
2017-08-29 10:50:31 -04:00
if ( ! array_key_exists ( $msgID , $this -> strings )) {
throw new Lang\Exception ( " stringMissing " , [ 'msgID' => $msgID , 'fileList' => implode ( " , " , $this -> loaded )]);
2017-07-20 22:40:09 -04:00
}
2017-03-28 18:50:00 -04:00
$msg = $this -> strings [ $msgID ];
2017-02-16 17:50:34 -05:00
// variables fed to MessageFormatter must be contained in an array
2020-03-01 15:16:50 -05:00
if ( $vars === null ) {
2017-02-16 17:50:34 -05:00
// even though strings not given parameters will not get formatted, we do not optimize this case away: we still want to catch invalid strings
$vars = [];
2017-08-29 10:50:31 -04:00
} elseif ( ! is_array ( $vars )) {
2017-02-16 15:29:42 -05:00
$vars = [ $vars ];
}
2019-01-20 22:40:49 -05:00
$this -> formatter = $this -> formatter ? ? new \MessageFormatter ( $this -> locale , " Initial message " );
if ( ! $this -> formatter -> setPattern ( $msg )) {
throw new Lang\Exception ( " stringInvalid " , [ 'error' => $this -> formatter -> getErrorMessage (), 'msgID' => $msgID , 'fileList' => implode ( " , " , $this -> loaded )]);
}
$msg = $this -> formatter -> format ( $vars );
2020-03-01 15:16:50 -05:00
if ( $msg === false ) {
2019-01-20 22:40:49 -05:00
throw new Lang\Exception ( " dataInvalid " , [ 'error' => $this -> formatter -> getErrorMessage (), 'msgID' => $msgID , 'fileList' => implode ( " , " , $this -> loaded )]); // @codeCoverageIgnore
2017-07-20 22:40:09 -04:00
}
2017-02-16 15:29:42 -05:00
return $msg ;
}
2016-09-29 21:58:09 -04:00
2017-03-28 18:50:00 -04:00
public function list ( string $locale = " " ) : array {
2017-02-16 15:29:42 -05:00
$out = [];
2017-03-28 18:50:00 -04:00
$files = $this -> listFiles ();
2017-08-29 10:50:31 -04:00
foreach ( $files as $tag ) {
2019-01-11 10:38:06 -05:00
$out [ $tag ] = \Locale :: getDisplayName ( $tag , ( $locale === " " ) ? $tag : $locale );
2017-02-16 15:29:42 -05:00
}
return $out ;
}
2016-09-29 21:58:09 -04:00
2017-03-28 18:50:00 -04:00
public function match ( string $locale , array $list = null ) : string {
2018-08-17 08:35:13 -04:00
$list = $list ? ? $this -> listFiles ();
2019-01-11 10:38:06 -05:00
$default = ( $this -> locale === " " ) ? self :: DEFAULT : $this -> locale ;
2017-08-29 10:50:31 -04:00
return \Locale :: lookup ( $list , $locale , true , $default );
2017-02-16 15:29:42 -05:00
}
2016-09-29 21:58:09 -04:00
2017-09-05 19:35:14 -04:00
protected function checkRequirements () : bool {
2017-08-29 10:50:31 -04:00
if ( ! extension_loaded ( " intl " )) {
2018-08-17 08:35:13 -04:00
throw new ExceptionFatal ( " The \" Intl \" extension is required, but not loaded " ); // @codeCoverageIgnore
2017-07-20 22:40:09 -04:00
}
2017-09-05 19:35:14 -04:00
$this -> requirementsMet = true ;
2017-02-16 15:29:42 -05:00
return true ;
}
2016-09-29 21:58:09 -04:00
2017-09-05 19:35:14 -04:00
/** @codeCoverageIgnore */
2017-06-01 13:50:46 -04:00
protected function globFiles ( string $path ) : array {
// we wrap PHP's glob function in this method so that unit tests may override it
return glob ( $path . " *.php " );
}
2017-03-28 18:50:00 -04:00
protected function listFiles () : array {
2017-06-01 13:50:46 -04:00
$out = $this -> globFiles ( $this -> path . " *.php " );
2017-02-16 17:50:34 -05:00
// trim the returned file paths to return just the language tag
2018-12-05 17:28:11 -05:00
$out = array_map ( function ( $file ) {
2017-06-01 13:50:46 -04:00
$file = str_replace ( DIRECTORY_SEPARATOR , " / " , $file ); // we replace the directory separator because we don't use native paths in testing
2020-03-01 15:16:50 -05:00
$file = substr ( $file , strrpos ( $file , " / " ) + 1 );
2017-08-29 10:50:31 -04:00
return strtolower ( substr ( $file , 0 , strrpos ( $file , " . " )));
}, $out );
2017-02-16 17:50:34 -05:00
// sort the results
2017-02-16 15:29:42 -05:00
natsort ( $out );
return $out ;
}
2017-03-28 18:50:00 -04:00
protected function load () : bool {
2017-09-05 19:35:14 -04:00
if ( ! $this -> requirementsMet ) {
$this -> checkRequirements ();
2017-07-20 22:40:09 -04:00
}
2017-02-16 15:29:42 -05:00
// if we've requested no locale (""), just load the fallback strings and return
2019-01-11 10:38:06 -05:00
if ( $this -> wanted === " " ) {
2017-03-28 18:50:00 -04:00
$this -> strings = self :: REQUIRED ;
$this -> locale = $this -> wanted ;
$this -> synched = true ;
2019-01-20 22:40:49 -05:00
$this -> formatter = null ;
2017-02-16 15:29:42 -05:00
return true ;
}
// decompose the requested locale from specific to general, building a list of files to load
2017-03-28 18:50:00 -04:00
$tags = \Locale :: parseLocale ( $this -> wanted );
2017-02-16 15:29:42 -05:00
$files = [];
2017-08-29 10:50:31 -04:00
while ( sizeof ( $tags ) > 0 ) {
2017-02-16 15:29:42 -05:00
$files [] = strtolower ( \Locale :: composeLocale ( $tags ));
$tag = array_pop ( $tags );
}
// include the default locale as the base if the most general locale requested is not the default
2019-01-11 10:38:06 -05:00
if ( $tag !== self :: DEFAULT ) {
2017-07-20 22:40:09 -04:00
$files [] = self :: DEFAULT ;
}
2017-02-16 15:29:42 -05:00
// save the list of files to be loaded for later reference
$loaded = $files ;
// reduce the list of files to be loaded to the minimum necessary (e.g. if we go from "fr" to "fr_ca", we don't need to load "fr" or "en")
$files = [];
2017-08-29 10:50:31 -04:00
foreach ( $loaded as $file ) {
2019-01-11 10:38:06 -05:00
if ( $file === $this -> locale ) {
2017-07-20 22:40:09 -04:00
break ;
}
2017-02-16 15:29:42 -05:00
$files [] = $file ;
}
// if we need to load all files, start with the fallback strings
$strings = [];
2019-01-11 10:38:06 -05:00
if ( $files === $loaded ) {
2017-02-16 15:29:42 -05:00
$strings [] = self :: REQUIRED ;
} else {
// otherwise start with the strings we already have if we're going from e.g. "fr" to "fr_ca"
2017-03-28 18:50:00 -04:00
$strings [] = $this -> strings ;
2017-02-16 15:29:42 -05:00
}
// read files in reverse order
$files = array_reverse ( $files );
2017-08-29 10:50:31 -04:00
foreach ( $files as $file ) {
if ( ! file_exists ( $this -> path . " $file .php " )) {
2017-07-20 22:40:09 -04:00
throw new Lang\Exception ( " fileMissing " , $file );
2017-08-29 10:50:31 -04:00
} elseif ( ! is_readable ( $this -> path . " $file .php " )) {
2017-07-20 22:40:09 -04:00
throw new Lang\Exception ( " fileUnreadable " , $file );
}
2017-02-16 15:29:42 -05:00
try {
2017-02-16 17:50:34 -05:00
// we use output buffering in case the language file is corrupted
2017-02-16 15:29:42 -05:00
ob_start ();
2017-03-28 18:50:00 -04:00
$arr = ( include $this -> path . " $file .php " );
2017-08-29 10:50:31 -04:00
} catch ( \Throwable $e ) {
2017-02-16 15:29:42 -05:00
$arr = null ;
} finally {
ob_end_clean ();
}
2017-08-29 10:50:31 -04:00
if ( ! is_array ( $arr )) {
2017-07-20 22:40:09 -04:00
throw new Lang\Exception ( " fileCorrupt " , $file );
}
2017-02-16 15:29:42 -05:00
$strings [] = $arr ;
}
// apply the results and return
2017-03-28 18:50:00 -04:00
$this -> strings = call_user_func_array ( " array_replace_recursive " , $strings );
$this -> loaded = $loaded ;
$this -> locale = $this -> wanted ;
$this -> synched = true ;
2019-01-20 22:40:49 -05:00
$this -> formatter = null ;
2017-02-16 15:29:42 -05:00
return true ;
}
2017-08-29 10:50:31 -04:00
}