2016-09-30 01:58:09 +00:00
< ? php
declare ( strict_types = 1 );
2017-03-28 04:12:12 +00:00
namespace JKingWeb\Arsse ;
2017-03-28 22:50:00 +00:00
use Webmozart\Glob\Glob ;
2016-09-30 01:58:09 +00:00
class Lang {
2017-02-16 22:50:34 +00:00
const DEFAULT = " en " ; // fallback locale
const REQUIRED = [ // collection of absolutely required strings to handle pathological errors
2017-03-28 04:12:12 +00: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 20:29:42 +00:00
];
2016-09-30 01:58:09 +00:00
2017-03-28 22:50:00 +00:00
public $path ; // path to locale files; this is a public property to facilitate unit testing
static protected $requirementsMet = false ; // whether the Intl extension is loaded
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
2016-09-30 01:58:09 +00:00
2017-03-28 22:50:00 +00:00
function __construct ( string $path = BASE . " locale " . DIRECTORY_SEPARATOR ) {
$this -> path = $path ;
}
2016-09-30 01:58:09 +00:00
2017-03-28 22:50:00 +00:00
public function set ( string $locale , bool $immediate = false ) : string {
2017-02-16 22:50:34 +00:00
// make sure the Intl extension is loaded
2017-03-28 22:50:00 +00:00
if ( ! static :: $requirementsMet ) static :: checkRequirements ();
2017-02-16 22:50:34 +00:00
// if requesting the same locale as already wanted, just return (but load first if we've requested an immediate load)
2017-03-28 22:50:00 +00:00
if ( $locale == $this -> wanted ) {
if ( $immediate && ! $this -> synched ) $this -> load ();
2017-02-16 22:50:34 +00: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
2017-02-16 20:29:42 +00:00
if ( $locale != " " ) {
2017-03-28 22:50:00 +00:00
$list = $this -> listFiles ();
2017-02-16 22:50:34 +00:00
// if the default locale is unavailable, this is (for now) an error
2017-02-16 20:29:42 +00:00
if ( ! in_array ( self :: DEFAULT , $list )) throw new Lang\Exception ( " defaultFileMissing " , self :: DEFAULT );
2017-03-28 22:50:00 +00:00
$this -> wanted = $this -> match ( $locale , $list );
2017-02-16 20:29:42 +00:00
} else {
2017-03-28 22:50:00 +00:00
$this -> wanted = " " ;
2017-02-16 20:29:42 +00:00
}
2017-03-28 22:50:00 +00:00
$this -> synched = false ;
2017-02-16 22:50:34 +00:00
// load right now if asked to, otherwise load later when actually required
2017-03-28 22:50:00 +00:00
if ( $immediate ) $this -> load ();
return $this -> wanted ;
2017-02-16 20:29:42 +00:00
}
2016-09-30 01:58:09 +00:00
2017-03-28 22:50:00 +00:00
public function get ( bool $loaded = false ) : string {
2017-02-16 22:50:34 +00:00
// we can either return the wanted locale (default) or the currently loaded locale
2017-03-28 22:50:00 +00:00
return $loaded ? $this -> locale : $this -> wanted ;
}
public function dump () : array {
return $this -> strings ;
2017-02-16 20:29:42 +00:00
}
2016-09-30 01:58:09 +00:00
2017-03-28 22:50:00 +00:00
public function msg ( string $msgID , $vars = null ) : string {
return $this ( $msgID , $vars );
2017-02-16 20:29:42 +00:00
}
2017-02-09 21:39:13 +00:00
2017-03-28 22:50:00 +00:00
public function __invoke ( string $msgID , $vars = null ) : string {
2017-02-16 20:29:42 +00: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-03-28 22:50:00 +00:00
if ( ! $this -> synched ) try { $this -> load ();} catch ( Lang\Exception $e ) {
if ( $this -> wanted == self :: DEFAULT ) {
$this -> set ( " " , true );
2017-02-16 20:29:42 +00:00
} else {
throw $e ;
}
}
2017-02-16 22:50:34 +00: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-03-28 22:50:00 +00:00
if ( ! array_key_exists ( $msgID , $this -> strings )) throw new Lang\Exception ( " stringMissing " , [ 'msgID' => $msgID , 'fileList' => implode ( " , " , $this -> loaded )]);
$msg = $this -> strings [ $msgID ];
2017-02-16 22:50:34 +00:00
// variables fed to MessageFormatter must be contained in an array
2017-02-16 20:29:42 +00:00
if ( $vars === null ) {
2017-02-16 22:50:34 +00: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-02-16 20:29:42 +00:00
} else if ( ! is_array ( $vars )) {
$vars = [ $vars ];
}
2017-03-28 22:50:00 +00:00
$msg = \MessageFormatter :: formatMessage ( $this -> locale , $msg , $vars );
if ( $msg === false ) throw new Lang\Exception ( " stringInvalid " , [ 'msgID' => $msgID , 'fileList' => implode ( " , " , $this -> loaded )]);
2017-02-16 20:29:42 +00:00
return $msg ;
}
2016-09-30 01:58:09 +00:00
2017-03-28 22:50:00 +00:00
public function list ( string $locale = " " ) : array {
2017-02-16 20:29:42 +00:00
$out = [];
2017-03-28 22:50:00 +00:00
$files = $this -> listFiles ();
2017-02-16 20:29:42 +00:00
foreach ( $files as $tag ) {
$out [ $tag ] = \Locale :: getDisplayName ( $tag , ( $locale == " " ) ? $tag : $locale );
}
return $out ;
}
2016-09-30 01:58:09 +00:00
2017-03-28 22:50:00 +00:00
public function match ( string $locale , array $list = null ) : string {
if ( $list === null ) $list = $this -> listFiles ();
$default = ( $this -> locale == " " ) ? self :: DEFAULT : $this -> locale ;
2017-02-16 20:29:42 +00:00
return \Locale :: lookup ( $list , $locale , true , $default );
}
2016-09-30 01:58:09 +00:00
2017-02-16 20:29:42 +00:00
static protected function checkRequirements () : bool {
if ( ! extension_loaded ( " intl " )) throw new ExceptionFatal ( " The \" Intl \" extension is required, but not loaded " );
2017-03-28 22:50:00 +00:00
static :: $requirementsMet = true ;
2017-02-16 20:29:42 +00:00
return true ;
}
2016-09-30 01:58:09 +00:00
2017-03-28 22:50:00 +00:00
protected function listFiles () : array {
$out = glob ( $this -> path . " *.php " );
2017-02-16 20:29:42 +00:00
// built-in glob doesn't work with vfsStream (and this other glob doesn't seem to work with Windows paths), so we try both
2017-03-28 22:50:00 +00:00
if ( empty ( $out )) $out = Glob :: glob ( $this -> path . " *.php " ); // FIXME: we should just mock glob() in tests instead and make this a dev dependency
2017-02-16 22:50:34 +00:00
// trim the returned file paths to return just the language tag
2017-02-16 20:29:42 +00:00
$out = array_map ( function ( $file ) {
$file = str_replace ( DIRECTORY_SEPARATOR , " / " , $file );
$file = substr ( $file , strrpos ( $file , " / " ) + 1 );
return strtolower ( substr ( $file , 0 , strrpos ( $file , " . " )));
}, $out );
2017-02-16 22:50:34 +00:00
// sort the results
2017-02-16 20:29:42 +00:00
natsort ( $out );
return $out ;
}
2017-03-28 22:50:00 +00:00
protected function load () : bool {
2017-02-16 20:29:42 +00:00
if ( ! self :: $requirementsMet ) self :: checkRequirements ();
// if we've requested no locale (""), just load the fallback strings and return
2017-03-28 22:50:00 +00:00
if ( $this -> wanted == " " ) {
$this -> strings = self :: REQUIRED ;
$this -> locale = $this -> wanted ;
$this -> synched = true ;
2017-02-16 20:29:42 +00:00
return true ;
}
// decompose the requested locale from specific to general, building a list of files to load
2017-03-28 22:50:00 +00:00
$tags = \Locale :: parseLocale ( $this -> wanted );
2017-02-16 20:29:42 +00:00
$files = [];
while ( sizeof ( $tags ) > 0 ) {
$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
if ( $tag != self :: DEFAULT ) $files [] = self :: DEFAULT ;
// 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 = [];
foreach ( $loaded as $file ) {
2017-03-28 22:50:00 +00:00
if ( $file == $this -> locale ) break ;
2017-02-16 20:29:42 +00:00
$files [] = $file ;
}
// if we need to load all files, start with the fallback strings
$strings = [];
if ( $files == $loaded ) {
$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 22:50:00 +00:00
$strings [] = $this -> strings ;
2017-02-16 20:29:42 +00:00
}
// read files in reverse order
$files = array_reverse ( $files );
foreach ( $files as $file ) {
2017-03-28 22:50:00 +00:00
if ( ! file_exists ( $this -> path . " $file .php " )) throw new Lang\Exception ( " fileMissing " , $file );
if ( ! is_readable ( $this -> path . " $file .php " )) throw new Lang\Exception ( " fileUnreadable " , $file );
2017-02-16 20:29:42 +00:00
try {
2017-02-16 22:50:34 +00:00
// we use output buffering in case the language file is corrupted
2017-02-16 20:29:42 +00:00
ob_start ();
2017-03-28 22:50:00 +00:00
$arr = ( include $this -> path . " $file .php " );
2017-02-16 20:29:42 +00:00
} catch ( \Throwable $e ) {
$arr = null ;
} finally {
ob_end_clean ();
}
if ( ! is_array ( $arr )) throw new Lang\Exception ( " fileCorrupt " , $file );
$strings [] = $arr ;
}
// apply the results and return
2017-03-28 22:50:00 +00:00
$this -> strings = call_user_func_array ( " array_replace_recursive " , $strings );
$this -> loaded = $loaded ;
$this -> locale = $this -> wanted ;
$this -> synched = true ;
2017-02-16 20:29:42 +00:00
return true ;
}
2016-09-30 01:58:09 +00:00
}