1
1
Fork 0
mirror of https://code.mensbeam.com/MensBeam/Arsse.git synced 2024-12-23 08:34:56 +00:00
Arsse/lib/Lang.php

231 lines
10 KiB
PHP
Raw Normal View History

<?php
/** @license MIT
* Copyright 2017 J. King, Dustin Wilson et al.
* See LICENSE and AUTHORS files for details */
declare(strict_types=1);
2017-03-28 04:12:12 +00:00
namespace JKingWeb\Arsse;
class Lang {
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
];
public $path; // path to locale files; this is a public property to facilitate unit testing
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
/** @var \MessageFormatter */
protected $formatter;
2017-08-29 14:50:31 +00:00
public function __construct(string $path = BASE."locale".DIRECTORY_SEPARATOR) {
$this->path = $path;
}
public function set(string $locale, bool $immediate = false): string {
// make sure the Intl extension is loaded
if (!$this->requirementsMet) {
$this->checkRequirements();
2017-07-21 02:40:09 +00:00
}
// if requesting the same locale as already wanted, just return (but load first if we've requested an immediate load)
if ($locale === $this->wanted) {
2017-08-29 14:50:31 +00:00
if ($immediate && !$this->synched) {
2017-07-21 02:40:09 +00:00
$this->load();
}
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
if ($locale !== "") {
$list = $this->listFiles();
// if the default locale is unavailable, this is (for now) an error
2017-08-29 14:50:31 +00:00
if (!in_array(self::DEFAULT, $list)) {
2017-07-21 02:40:09 +00:00
throw new Lang\Exception("defaultFileMissing", self::DEFAULT);
}
$this->wanted = $this->match($locale, $list);
2017-02-16 20:29:42 +00:00
} else {
$this->wanted = "";
2017-02-16 20:29:42 +00:00
}
$this->synched = false;
// load right now if asked to, otherwise load later when actually required
2017-08-29 14:50:31 +00:00
if ($immediate) {
2017-07-21 02:40:09 +00:00
$this->load();
}
return $this->wanted;
2017-02-16 20:29:42 +00:00
}
public function get(bool $loaded = false): string {
// we can either return the wanted locale (default) or the currently loaded locale
return $loaded ? $this->locale : $this->wanted;
}
public function dump(): array {
return $this->strings;
2017-02-16 20:29:42 +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
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-08-29 14:50:31 +00:00
if (!$this->synched) {
try {
$this->load();
} catch (Lang\Exception $e) {
if ($this->wanted === self::DEFAULT) {
2017-08-29 14:50:31 +00:00
$this->set("", true);
} else {
throw $e;
}
2017-02-16 20:29:42 +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-08-29 14:50:31 +00:00
if (!array_key_exists($msgID, $this->strings)) {
throw new Lang\Exception("stringMissing", ['msgID' => $msgID, 'fileList' => implode(", ", $this->loaded)]);
2017-07-21 02:40:09 +00:00
}
$msg = $this->strings[$msgID];
// variables fed to MessageFormatter must be contained in an array
2017-08-29 14:50:31 +00:00
if ($vars===null) {
// 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 14:50:31 +00:00
} elseif (!is_array($vars)) {
2017-02-16 20:29:42 +00:00
$vars = [$vars];
}
$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);
2017-08-29 14:50:31 +00:00
if ($msg===false) {
throw new Lang\Exception("dataInvalid", ['error' => $this->formatter->getErrorMessage(), 'msgID' => $msgID, 'fileList' => implode(", ", $this->loaded)]); // @codeCoverageIgnore
2017-07-21 02:40:09 +00:00
}
2017-02-16 20:29:42 +00:00
return $msg;
}
public function list(string $locale = ""): array {
2017-02-16 20:29:42 +00:00
$out = [];
$files = $this->listFiles();
2017-08-29 14:50:31 +00:00
foreach ($files as $tag) {
$out[$tag] = \Locale::getDisplayName($tag, ($locale === "") ? $tag : $locale);
2017-02-16 20:29:42 +00:00
}
return $out;
}
public function match(string $locale, array $list = null): string {
2018-08-17 12:35:13 +00:00
$list = $list ?? $this->listFiles();
$default = ($this->locale === "") ? self::DEFAULT : $this->locale;
2017-08-29 14:50:31 +00:00
return \Locale::lookup($list, $locale, true, $default);
2017-02-16 20:29:42 +00:00
}
protected function checkRequirements(): bool {
2017-08-29 14:50:31 +00:00
if (!extension_loaded("intl")) {
2018-08-17 12:35:13 +00:00
throw new ExceptionFatal("The \"Intl\" extension is required, but not loaded"); // @codeCoverageIgnore
2017-07-21 02:40:09 +00:00
}
$this->requirementsMet = true;
2017-02-16 20:29:42 +00:00
return true;
}
/** @codeCoverageIgnore */
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");
}
protected function listFiles(): array {
$out = $this->globFiles($this->path."*.php");
// trim the returned file paths to return just the language tag
2018-12-05 22:28:11 +00:00
$out = array_map(function($file) {
$file = str_replace(DIRECTORY_SEPARATOR, "/", $file); // we replace the directory separator because we don't use native paths in testing
2017-02-16 20:29:42 +00:00
$file = substr($file, strrpos($file, "/")+1);
2017-08-29 14:50:31 +00:00
return strtolower(substr($file, 0, strrpos($file, ".")));
}, $out);
// sort the results
2017-02-16 20:29:42 +00:00
natsort($out);
return $out;
}
protected function load(): bool {
if (!$this->requirementsMet) {
$this->checkRequirements();
2017-07-21 02:40:09 +00:00
}
2017-02-16 20:29:42 +00:00
// if we've requested no locale (""), just load the fallback strings and return
if ($this->wanted === "") {
$this->strings = self::REQUIRED;
$this->locale = $this->wanted;
$this->synched = true;
$this->formatter = null;
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
$tags = \Locale::parseLocale($this->wanted);
2017-02-16 20:29:42 +00:00
$files = [];
2017-08-29 14:50:31 +00:00
while (sizeof($tags) > 0) {
2017-02-16 20:29:42 +00: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
if ($tag !== self::DEFAULT) {
2017-07-21 02:40:09 +00:00
$files[] = self::DEFAULT;
}
2017-02-16 20:29:42 +00: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 14:50:31 +00:00
foreach ($loaded as $file) {
if ($file === $this->locale) {
2017-07-21 02:40:09 +00:00
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) {
2017-02-16 20:29:42 +00:00
$strings[] = self::REQUIRED;
} else {
// otherwise start with the strings we already have if we're going from e.g. "fr" to "fr_ca"
$strings[] = $this->strings;
2017-02-16 20:29:42 +00:00
}
// read files in reverse order
$files = array_reverse($files);
2017-08-29 14:50:31 +00:00
foreach ($files as $file) {
if (!file_exists($this->path."$file.php")) {
2017-07-21 02:40:09 +00:00
throw new Lang\Exception("fileMissing", $file);
2017-08-29 14:50:31 +00:00
} elseif (!is_readable($this->path."$file.php")) {
2017-07-21 02:40:09 +00:00
throw new Lang\Exception("fileUnreadable", $file);
}
2017-02-16 20:29:42 +00:00
try {
// we use output buffering in case the language file is corrupted
2017-02-16 20:29:42 +00:00
ob_start();
$arr = (include $this->path."$file.php");
2017-08-29 14:50:31 +00:00
} catch (\Throwable $e) {
2017-02-16 20:29:42 +00:00
$arr = null;
} finally {
ob_end_clean();
}
2017-08-29 14:50:31 +00:00
if (!is_array($arr)) {
2017-07-21 02:40:09 +00:00
throw new Lang\Exception("fileCorrupt", $file);
}
2017-02-16 20:29:42 +00:00
$strings[] = $arr;
}
// apply the results and return
$this->strings = call_user_func_array("array_replace_recursive", $strings);
$this->loaded = $loaded;
$this->locale = $this->wanted;
$this->synched = true;
$this->formatter = null;
2017-02-16 20:29:42 +00:00
return true;
}
2017-08-29 14:50:31 +00:00
}