'full', self::LONG => 'long', self::MEDIUM => 'medium', self::SHORT => 'short', self::NONE => 'none' ); private const DEFAULT_LOCALE = 'en'; private $locale; private $dateType; private $timeType; private $timezone; private $calendar; private $pattern; public function __construct( ?string $locale, int $dateType = self::FULL, int $timeType = self::FULL, $timezone = null, $calendar = null, ?string $pattern = null) { if (!function_exists('strftime')) { throw new Exception("Neither the IntlDateFormatter class nor the strftime() function exist on this server"); } // Emulate PHP 8.4 and later by detecting invalid locales now, in order to avoid problems later on. if (isset($locale) && !System::isAvailableLocale($locale)) { $message = 'Argument #1 ($locale) "' . $locale . '" is invalid'; $throwable = (version_compare(PHP_VERSION, '8.0') >= 0) ? '\ValueError' : '\Exception'; throw new $throwable($message); } $this->locale = $locale; $this->dateType = $dateType; $this->timeType = $timeType; $this->timezone = $timezone ?? date_default_timezone_get(); $this->calendar = $calendar ?? self::GREGORIAN; if (!isset($pattern)) { $file = MRBS_ROOT . "/intl/types/" . self::TYPE_NAMES[$this->dateType] . "_" . self::TYPE_NAMES[$this->timeType] . ".ini"; if (is_readable($file)) { $patterns = parse_ini_file($file); if (!empty($patterns)) { $pattern = $patterns[Language::convertToBcp47($this->locale)] ?? $patterns[self::DEFAULT_LOCALE] ?? null; } } } if (!isset($pattern)) { throw new Exception("Could not get pattern"); } $this->setPattern($pattern); } public function format($datetime) { // $datetime can be many types // TODO: Handle the remaining possible types if ($datetime instanceof DateTimeInterface) { $timestamp = $datetime->getTimestamp(); } else { $timestamp = (int)$datetime; } $converter = new IntlDatePatternConverter(new FormatterStrftime()); return $this->strftimePlus($converter->convert($this->pattern), $timestamp); } //Get the calendar type used for the IntlDateFormatter public function getCalendar() { return $this->calendar ?? false; } // Get the datetype used for the IntlDateFormatter public function getDateType() { return $this->dateType ?? false; } // Get the locale used by formatter public function getLocale(int $type=Locale::ACTUAL_LOCALE) { switch ($type) { // TODO: Do something with $type, though it's not exactly clear what the difference is // TODO: between the two types. See also https://www.php.net/manual/en/collator.getlocale.php case LOCALE::ACTUAL_LOCALE: case LOCALE::VALID_LOCALE: return $this->locale ?? false; break; default: return false; break; } } // Get the pattern used for the IntlDateFormatter public function getPattern() { return $this->pattern ?? false; } // Get the timetype used for the IntlDateFormatter public function getTimeType() { return $this->timeType ?? false; } // The standard PHP version can also return false public function setPattern(string $pattern): bool { $this->pattern = $pattern; return true; } // Format a local time/date according to locale settings, returning the // result as a UTF-8 string. This function is based on strftime() // $time can be an int or a float (union type declarations not supported until PHP 8.0) // $locale can either be a string or an array of locales. If $locale // is not set then the current locale is used. // // This method extends the standard PHP strftime() function and adds extra formats: // // %f Numeric representation of the month 1 (for January) through 12 (for December) // without leading zeroes. Won't // necessarily work in locales that don't // use [0..9] for the month. // // %i One/two digit day of the month, with no 1 to 31 // leading space // // %o Hour in 12-hour format, with no space 1 through 12 // preceding single digits // // %q Minute in the hour, with no leading zero 4 // // %v Seconds, with no leading zero // // %E Day of year, with no leading zeroes private function strftimePlus(string $format, int $timestamp): string { $server_os = System::getServerOS(); // Set the temporary locale. Note that $this->locale could be an array of locales, // so we need to find out which locale actually worked. if (!empty($this->locale)) { $old_locale = setlocale(LC_TIME, '0'); if (false === ($new_locale = Language::setLocale(LC_TIME, $this->locale))) { $new_locale = $old_locale; $locale = is_array($this->locale) ? json_encode($this->locale) : "'$this->locale'"; $message = "Could not set locale to $locale; continuing to use '$old_locale'"; trigger_error($message, E_USER_WARNING); } } elseif ($server_os == "windows") { // If we are running Windows we have to set the locale again in case another script // running in the same process has changed the locale since we first set it. See the // warning on the PHP manual page for setlocale(): // // "The locale information is maintained per process, not per thread. If you are // running PHP on a multithreaded server API like IIS or Apache on Windows, you may // experience sudden changes in locale settings while a script is running, though // the script itself never called setlocale(). This happens due to other scripts // running in different threads of the same process at the same time, changing the // process-wide locale using setlocale()." $new_locale = Language::getInstance()->getWebLocale(); Language::setLocale(LC_ALL, $new_locale); } else { $new_locale = null; } $result = self::doStrftimePlus($format, $timestamp, $new_locale); // Restore the original locale if (!empty($this->locale)) { setlocale(LC_TIME, $old_locale); } return $result; } /** * Wrapper for strftime() that suppresses deprecation errors * * @return false|string */ private static function doStrftime(string $format, ?int $timestamp = null) { assert(version_compare(MRBS_MIN_PHP_VERSION, '8.0.0', '<'), "The line below can be removed."); $timestamp = $timestamp ?? time(); // $timestamp only became nullable in strftime() in PHP 8.0.0 // Temporarily suppress deprecation errors so that we are not flooded with them. // We have a single message in init.inc. $error_level = error_reporting(); error_reporting($error_level & ~E_DEPRECATED); $result = strftime($format, $timestamp); error_reporting($error_level); return $result; } /** * Test whether a specifier is supported by strftime() and replace it with an alternative if not. */ private static function testAndReplaceFormat(string $specifier, string $replacement, string $format) : string { if (false === self::doStrftime($specifier)) { return str_replace($specifier, $replacement, $format); } return $format; } private static function doStrftimePlus(string $format, int $timestamp, ?string $locale): string { // Test whether certain specifiers are supported on this OS for this locale. We do an actual test, // rather than just checking which OS we are running on, because it is more reliable. $doubtful_specifiers = [ '%R' => '%H:%M', // Not supported on Windows '%P' => '%p', // Not supported on Windows, macOS and also some locales '%l' => '%I', // Not supported on Windows '%e' => '%#d' // Not supported on Windows ]; foreach ($doubtful_specifiers as $specifier => $replacement) { $format = self::testAndReplaceFormat($specifier, $replacement, $format); } // %p doesn't actually work in some locales, so we have to patch it up ourselves by using // date() instead of strftime(). // Note that we may be using %p instead of %P, because %P isn't supported for this locale. If that's // the case we're going to get a lowercase result, instead of uppercase as intended. But that // probably doesn't matter as that locale would almost certainly be using a 24-hour format anyway, // which is why %P isn't supported in the first place. if (preg_match('/%p/', $format) && (false === self::doStrftime('%p', $timestamp))) { $format = preg_replace('/%p/', date('a', $timestamp), $format); } $result = ''; // Split the format into individual tokens so that we can process our extensions $tokens = self::parseStrftimeFormat($format); foreach ($tokens as $token) { if (mb_strlen($token) === 1) { $result .= $token; } else { switch ($token) { case '%E': // We want the day of the year without leading zeroes. $formatted = self::doStrftimePlus('%j', $timestamp, $locale); $formatted = ltrim($formatted, '0'); break; case '%f': // We want a month number without leading zeroes. We can't use date('n', $time) // because date will return an English answer with a month made up of the characters // [0..9] which won't be correct for all locales. $formatted = self::doStrftimePlus('%m', $timestamp, $locale); $formatted = ($formatted === '00') ? '0' : ltrim($formatted, '0'); break; case '%i': $formatted = ltrim(self::doStrftimePlus('%e', $timestamp, $locale)); break; case '%J': // We want the week of the year without leading zeroes. $formatted = self::doStrftimePlus('%V', $timestamp, $locale); $formatted = ltrim($formatted, '0'); break; case '%o': $formatted = ltrim(self::doStrftimePlus('%l', $timestamp, $locale)); break; case '%q': // We want a minute without leading zeroes. $formatted = self::doStrftimePlus('%M', $timestamp, $locale); $formatted = ($formatted === '00') ? '0' : ltrim($formatted, '0'); break; case '%v': // We want seconds without leading zeroes. $formatted = self::doStrftimePlus('%S', $timestamp, $locale); $formatted = ($formatted === '00') ? '0' : ltrim($formatted, '0'); break; default: $formatted = self::doStrftime($token, $timestamp); break; } $result .= System::utf8ConvertFromLocale($formatted, $locale); } } return $result; } // Parses a strftime format into an array of strings, which will either be two or three-character // formats or one-character text strings. private static function parseStrftimeFormat(string $format): array { $result = array(); // Split the format into an array of multibyte characters $chars = preg_split("//u", $format, 0, PREG_SPLIT_NO_EMPTY); while (null !== ($char = array_shift($chars))) { if ($char !== '%') { // It's ordinary text $result[] = $char; } else { // Get the next character which will either be a conversion specifier or an escaped character $char = array_shift($chars); switch ($char) { case null: throw new Exception("Invalid format '$format'"); break; case 'n': $result[] = "\n"; break; case 't': $result[] = "\t"; break; case '%': $result[] = "%"; break; case '#': // This covers the case of '%#d' on Windows $char = array_shift($chars); if (!isset($char)) { throw new Exception("Invalid format '$format'"); } else { $result [] = "%#$char"; } break; default: $result [] = "%$char"; break; } } } return $result; } }