Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

Differences Between: [Versions 310 and 403] [Versions 311 and 403] [Versions 39 and 403] [Versions 400 and 403]

   1  <?php
   2  // This file is part of Moodle - http://moodle.org/
   3  //
   4  // Moodle is free software: you can redistribute it and/or modify
   5  // it under the terms of the GNU General Public License as published by
   6  // the Free Software Foundation, either version 3 of the License, or
   7  // (at your option) any later version.
   8  //
   9  // Moodle is distributed in the hope that it will be useful,
  10  // but WITHOUT ANY WARRANTY; without even the implied warranty of
  11  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
  12  // GNU General Public License for more details.
  13  //
  14  // You should have received a copy of the GNU General Public License
  15  // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
  16  
  17  /**
  18   * Core date and time related code.
  19   *
  20   * @package   core
  21   * @copyright 2015 Totara Learning Solutions Ltd {@link http://www.totaralms.com/}
  22   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  23   * @author    Petr Skoda <petr.skoda@totaralms.com>
  24   */
  25  
  26  /**
  27   * Core date and time related code.
  28   *
  29   * @since Moodle 2.9
  30   * @package   core
  31   * @copyright 2015 Totara Learning Solutions Ltd {@link http://www.totaralms.com/}
  32   * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  33   * @author    Petr Skoda <petr.skoda@totaralms.com>
  34   */
  35  class core_date {
  36      /** @var array list of recommended zones */
  37      protected static $goodzones = null;
  38  
  39      /** @var array list of BC zones supported by PHP */
  40      protected static $bczones = null;
  41  
  42      /** @var array mapping of timezones not supported by PHP */
  43      protected static $badzones = null;
  44  
  45      /** @var string the default PHP timezone right after config.php */
  46      protected static $defaultphptimezone = null;
  47  
  48      /**
  49       * Returns a localised list of timezones.
  50       * @param string $currentvalue
  51       * @param bool $include99 should the server timezone info be included?
  52       * @return array
  53       */
  54      public static function get_list_of_timezones($currentvalue = null, $include99 = false) {
  55          self::init_zones();
  56  
  57          // Localise first.
  58          $timezones = array();
  59          foreach (self::$goodzones as $tzkey => $ignored) {
  60              $timezones[$tzkey] = self::get_localised_timezone($tzkey);
  61          }
  62          core_collator::asort($timezones);
  63  
  64          // Add '99' if requested.
  65          if ($include99 or $currentvalue == 99) {
  66              $timezones['99'] = self::get_localised_timezone('99');
  67          }
  68  
  69          if (!isset($currentvalue) or isset($timezones[$currentvalue])) {
  70              return $timezones;
  71          }
  72  
  73          if (is_numeric($currentvalue)) {
  74              // UTC offset.
  75              if ($currentvalue == 0) {
  76                  $a = 'UTC';
  77              } else {
  78                  $modifier = ($currentvalue > 0) ? '+' : '';
  79                  $a = 'UTC' . $modifier . number_format($currentvalue, 1);
  80              }
  81              $timezones[$currentvalue] = get_string('timezoneinvalid', 'core_admin', $a);
  82          } else {
  83              // Some string we don't recognise.
  84              $timezones[$currentvalue] = get_string('timezoneinvalid', 'core_admin', $currentvalue);
  85          }
  86  
  87          return $timezones;
  88      }
  89  
  90      /**
  91       * Returns localised timezone name.
  92       * @param string $tz
  93       * @return string
  94       */
  95      public static function get_localised_timezone($tz) {
  96          if ($tz == 99) {
  97              $tz = self::get_server_timezone();
  98              $tz = self::get_localised_timezone($tz);
  99              return get_string('timezoneserver', 'core_admin', $tz);
 100          }
 101  
 102          if (get_string_manager()->string_exists(strtolower($tz), 'core_timezones')) {
 103              $tz = get_string(strtolower($tz), 'core_timezones');
 104          } else if ($tz === 'GMT' or $tz === 'Etc/GMT' or $tz === 'Etc/UTC') {
 105              $tz = 'UTC';
 106          } else if (preg_match('|^Etc/GMT([+-])([0-9]+)$|', $tz, $matches)) {
 107              $sign = $matches[1] === '+' ? '-' : '+';
 108              $tz = 'UTC' . $sign . $matches[2];
 109          }
 110  
 111          return $tz;
 112      }
 113  
 114      /**
 115       * Normalise the timezone name. If timezone not supported
 116       * this method falls back to server timezone (if valid)
 117       * or default PHP timezone.
 118       *
 119       * @param int|string|float|DateTimeZone $tz
 120       * @return string timezone compatible with PHP
 121       */
 122      public static function normalise_timezone($tz) {
 123          global $CFG;
 124  
 125          if ($tz instanceof DateTimeZone) {
 126              return $tz->getName();
 127          }
 128  
 129          self::init_zones();
 130          $tz = (string)$tz;
 131  
 132          if (isset(self::$goodzones[$tz]) or isset(self::$bczones[$tz])) {
 133              return $tz;
 134          }
 135  
 136          $fixed = false;
 137          if (isset(self::$badzones[$tz])) {
 138              // Convert to known zone.
 139              $tz = self::$badzones[$tz];
 140              $fixed = true;
 141          } else if (is_numeric($tz)) {
 142              // Half hour numeric offsets were already tested, try rounding to integers here.
 143              $roundedtz = (string)(int)$tz;
 144              if (isset(self::$badzones[$roundedtz])) {
 145                  $tz = self::$badzones[$roundedtz];
 146                  $fixed = true;
 147              }
 148          }
 149  
 150          if ($fixed and isset(self::$goodzones[$tz]) or isset(self::$bczones[$tz])) {
 151              return $tz;
 152          }
 153  
 154          // Is server timezone usable?
 155          if (isset($CFG->timezone) and !is_numeric($CFG->timezone)) {
 156              $result = @timezone_open($CFG->timezone); // Hide notices if invalid.
 157              if ($result !== false) {
 158                  return $result->getName();
 159              }
 160          }
 161  
 162          // Bad luck, use the php.ini default or value set in config.php.
 163          return self::get_default_php_timezone();
 164      }
 165  
 166      /**
 167       * Returns server timezone.
 168       * @return string normalised timezone name compatible with PHP
 169       **/
 170      public static function get_server_timezone() {
 171          global $CFG;
 172  
 173          if (!isset($CFG->timezone) or $CFG->timezone == 99 or $CFG->timezone === '') {
 174              return self::get_default_php_timezone();
 175          }
 176  
 177          return self::normalise_timezone($CFG->timezone);
 178      }
 179  
 180      /**
 181       * Returns server timezone.
 182       * @return DateTimeZone
 183       **/
 184      public static function get_server_timezone_object() {
 185          $tz = self::get_server_timezone();
 186          return new DateTimeZone($tz);
 187      }
 188  
 189      /**
 190       * Set PHP default timezone to $CFG->timezone.
 191       */
 192      public static function set_default_server_timezone() {
 193          global $CFG;
 194  
 195          if (!isset($CFG->timezone) or $CFG->timezone == 99 or $CFG->timezone === '') {
 196              date_default_timezone_set(self::get_default_php_timezone());
 197              return;
 198          }
 199  
 200          $current = date_default_timezone_get();
 201          if ($current === $CFG->timezone) {
 202              // Nothing to do.
 203              return;
 204          }
 205  
 206          if (!isset(self::$goodzones)) {
 207              // For better performance try do do this without full tz init,
 208              // because this is called from lib/setup.php file on each page.
 209              $result = @timezone_open($CFG->timezone); // Ignore error if setting invalid.
 210              if ($result !== false) {
 211                  date_default_timezone_set($result->getName());
 212                  return;
 213              }
 214          }
 215  
 216          // Slow way is the last option.
 217          date_default_timezone_set(self::get_server_timezone());
 218      }
 219  
 220      /**
 221       * Returns user timezone.
 222       *
 223       * Ideally the parameter should be a real user record,
 224       * unfortunately the legacy code is using 99 for both server
 225       * and default value.
 226       *
 227       * Example of using legacy API:
 228       *    // Date for other user via legacy API.
 229       *    $datestr = userdate($time, core_date::get_user_timezone($user));
 230       *
 231       * The coding style rules in Moodle are moronic,
 232       * why cannot the parameter names have underscores in them?
 233       *
 234       * @param mixed $userorforcedtz user object or legacy forced timezone string or tz object
 235       * @return string normalised timezone name compatible with PHP
 236       */
 237      public static function get_user_timezone($userorforcedtz = null) {
 238          global $USER, $CFG;
 239  
 240          if ($userorforcedtz instanceof DateTimeZone) {
 241              return $userorforcedtz->getName();
 242          }
 243  
 244          if (isset($userorforcedtz) and !is_object($userorforcedtz) and $userorforcedtz != 99) {
 245              // Legacy code is forcing timezone in legacy API.
 246              return self::normalise_timezone($userorforcedtz);
 247          }
 248  
 249          if (isset($CFG->forcetimezone) and $CFG->forcetimezone != 99) {
 250              // Override any user timezone.
 251              return self::normalise_timezone($CFG->forcetimezone);
 252          }
 253  
 254          if ($userorforcedtz === null) {
 255              $tz = isset($USER->timezone) ? $USER->timezone : 99;
 256  
 257          } else if (is_object($userorforcedtz)) {
 258              $tz = isset($userorforcedtz->timezone) ? $userorforcedtz->timezone : 99;
 259  
 260          } else {
 261              if ($userorforcedtz == 99) {
 262                  $tz = isset($USER->timezone) ? $USER->timezone : 99;
 263              } else {
 264                  $tz = $userorforcedtz;
 265              }
 266          }
 267  
 268          if ($tz == 99) {
 269              return self::get_server_timezone();
 270          }
 271  
 272          return self::normalise_timezone($tz);
 273      }
 274  
 275      /**
 276       * Return user timezone object.
 277       *
 278       * @param mixed $userorforcedtz
 279       * @return DateTimeZone
 280       */
 281      public static function get_user_timezone_object($userorforcedtz = null) {
 282          $tz = self::get_user_timezone($userorforcedtz);
 283          return new DateTimeZone($tz);
 284      }
 285  
 286      /**
 287       * Return default timezone set in php.ini or config.php.
 288       * @return string normalised timezone compatible with PHP
 289       */
 290      public static function get_default_php_timezone() {
 291          if (!isset(self::$defaultphptimezone)) {
 292              // This should not happen.
 293              self::store_default_php_timezone();
 294          }
 295  
 296          return self::$defaultphptimezone;
 297      }
 298  
 299      /**
 300       * To be called from lib/setup.php only!
 301       */
 302      public static function store_default_php_timezone() {
 303          if ((defined('PHPUNIT_TEST') and PHPUNIT_TEST)
 304              or defined('BEHAT_SITE_RUNNING') or defined('BEHAT_TEST') or defined('BEHAT_UTIL')) {
 305              // We want all test sites to be consistent by default.
 306              self::$defaultphptimezone = 'Australia/Perth';
 307              return;
 308          }
 309          if (!isset(self::$defaultphptimezone)) {
 310              self::$defaultphptimezone = date_default_timezone_get();
 311          }
 312      }
 313  
 314      /**
 315       * Do not use directly - use $this->setTimezone('xx', $tz) instead in your test case.
 316       * @param string $tz valid timezone name
 317       */
 318      public static function phpunit_override_default_php_timezone($tz) {
 319          if (!defined('PHPUNIT_TEST')) {
 320              throw new coding_exception('core_date::phpunit_override_default_php_timezone() must be used only from unit tests');
 321          }
 322          $result = timezone_open($tz ?? ''); // This triggers error if $tz invalid.
 323          if ($result !== false) {
 324              self::$defaultphptimezone = $tz;
 325          } else {
 326              self::$defaultphptimezone = 'Australia/Perth';
 327          }
 328      }
 329  
 330      /**
 331       * To be called from phpunit reset only, after restoring $CFG.
 332       */
 333      public static function phpunit_reset() {
 334          global $CFG;
 335          if (!defined('PHPUNIT_TEST')) {
 336              throw new coding_exception('core_date::phpunit_reset() must be used only from unit tests');
 337          }
 338          self::store_default_php_timezone();
 339          date_default_timezone_set($CFG->timezone);
 340      }
 341  
 342      /**
 343       * Initialise timezone arrays, call before use.
 344       */
 345      protected static function init_zones() {
 346          if (isset(self::$goodzones)) {
 347              return;
 348          }
 349  
 350          $zones = DateTimeZone::listIdentifiers();
 351          self::$goodzones = array_fill_keys($zones, true);
 352  
 353          $zones = DateTimeZone::listIdentifiers(DateTimeZone::ALL_WITH_BC);
 354          self::$bczones = array();
 355          foreach ($zones as $zone) {
 356              if (isset(self::$goodzones[$zone])) {
 357                  continue;
 358              }
 359              self::$bczones[$zone] = true;
 360          }
 361  
 362          self::$badzones = array(
 363              // Windows time zones.
 364              'Dateline Standard Time' => 'Etc/GMT+12',
 365              'Hawaiian Standard Time' => 'Pacific/Honolulu',
 366              'Alaskan Standard Time' => 'America/Anchorage',
 367              'Pacific Standard Time (Mexico)' => 'America/Tijuana',
 368              'Pacific Standard Time' => 'America/Los_Angeles',
 369              'US Mountain Standard Time' => 'America/Phoenix',
 370              'Mountain Standard Time (Mexico)' => 'America/Chihuahua',
 371              'Mountain Standard Time' => 'America/Denver',
 372              'Central America Standard Time' => 'America/Guatemala',
 373              'Central Standard Time' => 'America/Chicago',
 374              'Central Standard Time (Mexico)' => 'America/Mexico_City',
 375              'Canada Central Standard Time' => 'America/Regina',
 376              'SA Pacific Standard Time' => 'America/Bogota',
 377              'S.A. Pacific Standard Time' => 'America/Bogota',
 378              'Eastern Standard Time' => 'America/New_York',
 379              'US Eastern Standard Time' => 'America/Indiana/Indianapolis',
 380              'U.S. Eastern Standard Time' => 'America/Indiana/Indianapolis',
 381              'Venezuela Standard Time' => 'America/Caracas',
 382              'Paraguay Standard Time' => 'America/Asuncion',
 383              'Atlantic Standard Time' => 'America/Halifax',
 384              'Central Brazilian Standard Time' => 'America/Cuiaba',
 385              'SA Western Standard Time' => 'America/La_Paz',
 386              'S.A. Western Standard Time' => 'America/La_Paz',
 387              'Pacific SA Standard Time' => 'America/Santiago',
 388              'Pacific S.A. Standard Time' => 'America/Santiago',
 389              'Newfoundland Standard Time' => 'America/St_Johns',
 390              'Newfoundland and Labrador Standard Time' => 'America/St_Johns',
 391              'E. South America Standard Time' => 'America/Sao_Paulo',
 392              'Argentina Standard Time' => 'America/Argentina/Buenos_Aires',
 393              'SA Eastern Standard Time' => 'America/Cayenne',
 394              'S.A. Eastern Standard Time' => 'America/Cayenne',
 395              'Greenland Standard Time' => 'America/Godthab',
 396              'Montevideo Standard Time' => 'America/Montevideo',
 397              'Bahia Standard Time' => 'America/Bahia',
 398              'Azores Standard Time' => 'Atlantic/Azores',
 399              'Cape Verde Standard Time' => 'Atlantic/Cape_Verde',
 400              'Morocco Standard Time' => 'Africa/Casablanca',
 401              'GMT Standard Time' => 'Europe/London',
 402              'Greenwich Standard Time' => 'Atlantic/Reykjavik',
 403              'W. Europe Standard Time' => 'Europe/Berlin',
 404              'Central Europe Standard Time' => 'Europe/Budapest',
 405              'Romance Standard Time' => 'Europe/Paris',
 406              'Central European Standard Time' => 'Europe/Warsaw',
 407              'W. Central Africa Standard Time' => 'Africa/Lagos',
 408              'Namibia Standard Time' => 'Africa/Windhoek',
 409              'Jordan Standard Time' => 'Asia/Amman',
 410              'GTB Standard Time' => 'Europe/Bucharest',
 411              'Middle East Standard Time' => 'Asia/Beirut',
 412              'Egypt Standard Time' => 'Africa/Cairo',
 413              'Syria Standard Time' => 'Asia/Damascus',
 414              'South Africa Standard Time' => 'Africa/Johannesburg',
 415              'FLE Standard Time' => 'Europe/Kiev',
 416              'Turkey Standard Time' => 'Europe/Istanbul',
 417              'Israel Standard Time' => 'Asia/Jerusalem',
 418              'Kaliningrad Standard Time' => 'Europe/Kaliningrad',
 419              'Libya Standard Time' => 'Africa/Tripoli',
 420              'Arabic Standard Time' => 'Asia/Baghdad',
 421              'Arab Standard Time' => 'Asia/Riyadh',
 422              'Belarus Standard Time' => 'Europe/Minsk',
 423              'Russian Standard Time' => 'Europe/Moscow',
 424              'E. Africa Standard Time' => 'Africa/Nairobi',
 425              'Iran Standard Time' => 'Asia/Tehran',
 426              'Arabian Standard Time' => 'Asia/Dubai',
 427              'Azerbaijan Standard Time' => 'Asia/Baku',
 428              'Russia Time Zone 3' => 'Europe/Samara',
 429              'Mauritius Standard Time' => 'Indian/Mauritius',
 430              'Georgian Standard Time' => 'Asia/Tbilisi',
 431              'Caucasus Standard Time' => 'Asia/Yerevan',
 432              'Afghanistan Standard Time' => 'Asia/Kabul',
 433              'West Asia Standard Time' => 'Asia/Tashkent',
 434              'Ekaterinburg Standard Time' => 'Asia/Yekaterinburg',
 435              'Pakistan Standard Time' => 'Asia/Karachi',
 436              'India Standard Time' => 'Asia/Kolkata', // PHP and Windows differ in spelling.
 437              'Sri Lanka Standard Time' => 'Asia/Colombo',
 438              'Nepal Standard Time' => 'Asia/Kathmandu',
 439              'Central Asia Standard Time' => 'Asia/Almaty',
 440              'Bangladesh Standard Time' => 'Asia/Dhaka',
 441              'N. Central Asia Standard Time' => 'Asia/Novosibirsk',
 442              'Myanmar Standard Time' => 'Asia/Yangon',
 443              'SE Asia Standard Time' => 'Asia/Bangkok',
 444              'S.E. Asia Standard Time' => 'Asia/Bangkok',
 445              'North Asia Standard Time' => 'Asia/Krasnoyarsk',
 446              'China Standard Time' => 'Asia/Shanghai',
 447              'North Asia East Standard Time' => 'Asia/Irkutsk',
 448              'Singapore Standard Time' => 'Asia/Singapore',
 449              'W. Australia Standard Time' => 'Australia/Perth',
 450              'Taipei Standard Time' => 'Asia/Taipei',
 451              'Ulaanbaatar Standard Time' => 'Asia/Ulaanbaatar',
 452              'Tokyo Standard Time' => 'Asia/Tokyo',
 453              'Korea Standard Time' => 'Asia/Seoul',
 454              'Yakutsk Standard Time' => 'Asia/Yakutsk',
 455              'Cen. Australia Standard Time' => 'Australia/Adelaide',
 456              'AUS Central Standard Time' => 'Australia/Darwin',
 457              'A.U.S. Central Standard Time' => 'Australia/Darwin',
 458              'E. Australia Standard Time' => 'Australia/Brisbane',
 459              'AUS Eastern Standard Time' => 'Australia/Sydney',
 460              'A.U.S. Eastern Standard Time' => 'Australia/Sydney',
 461              'West Pacific Standard Time' => 'Pacific/Port_Moresby',
 462              'Tasmania Standard Time' => 'Australia/Hobart',
 463              'Magadan Standard Time' => 'Asia/Magadan',
 464              'Vladivostok Standard Time' => 'Asia/Vladivostok',
 465              'Russia Time Zone 10' => 'Asia/Srednekolymsk',
 466              'Central Pacific Standard Time' => 'Pacific/Guadalcanal',
 467              'Russia Time Zone 11' => 'Asia/Kamchatka',
 468              'New Zealand Standard Time' => 'Pacific/Auckland',
 469              'Fiji Standard Time' => 'Pacific/Fiji',
 470              'Fiji Islands Standard Time' => 'Pacific/Fiji',
 471              'Tonga Standard Time' => 'Pacific/Tongatapu',
 472              'Samoa Standard Time' => 'Pacific/Apia',
 473              'Line Islands Standard Time' => 'Pacific/Kiritimati',
 474              'Mexico Standard Time 2' => 'America/Chihuahua',
 475              'Mexico Standard Time' => 'America/Mexico_City',
 476              'U.S. Mountain Standard Time' => 'America/Phoenix',
 477              'Mid-Atlantic Standard Time' => 'Atlantic/South_Georgia',
 478              'E. Europe Standard Time' => 'Europe/Minsk',
 479              'Transitional Islamic State of Afghanistan Standard Time' => 'Asia/Kabul',
 480              'Armenian Standard Time' => 'Asia/Yerevan',
 481              'Kamchatka Standard Time' => 'Asia/Kamchatka',
 482  
 483              // A lot more bad legacy (deprecated) time zones.
 484              'Australia/ACT' => 'Australia/Sydney',
 485              'Australia/LHI' => 'Australia/Lord_Howe',
 486              'Australia/North' => 'Australia/Darwin',
 487              'Australia/NSW' => 'Australia/Sydney',
 488              'Australia/Queensland' => 'Australia/Brisbane',
 489              'Australia/South' => 'Australia/Adelaide',
 490              'Australia/Tasmania' => 'Australia/Hobart',
 491              'Australia/Victoria' => 'Australia/Melbourne',
 492              'Australia/West' => 'Australia/Perth',
 493              'Brazil/Acre' => 'America/Rio_Branco',
 494              'Brazil/DeNoronha' => 'America/Noronha',
 495              'Brazil/East' => 'America/Sao_Paulo',
 496              'Brazil/West' => 'America/Manaus',
 497              'Canada/Atlantic' => 'America/Halifax',
 498              'Canada/Central' => 'America/Winnipeg',
 499              'Canada/Eastern' => 'America/Toronto',
 500              'Canada/Mountain' => 'America/Edmonton',
 501              'Canada/Newfoundland' => 'America/St_Johns',
 502              'Canada/Pacific' => 'America/Vancouver',
 503              'Canada/Saskatchewan' => 'America/Regina',
 504              'Canada/Yukon' => 'America/Whitehorse',
 505              'CDT' => 'America/Chicago',
 506              'CET' => 'Europe/Berlin',
 507              'Central European Time' => 'Europe/Berlin',
 508              'Central Time' => 'America/Chicago',
 509              'Chile/Continental' => 'America/Santiago',
 510              'Chile/EasterIsland' => 'Pacific/Easter',
 511              'China Time' => 'Asia/Shanghai',
 512              'CST' => 'America/Chicago',
 513              'CST6CDT' => 'America/Chicago',
 514              'Cuba' => 'America/Havana',
 515              'EDT' => 'America/New_York',
 516              'EET' => 'Europe/Kiev',
 517              'Egypt' => 'Africa/Cairo',
 518              'Eire' => 'Europe/Dublin',
 519              'EST' => 'America/Cancun',
 520              'EST5EDT' => 'America/New_York',
 521              'Eastern Time' => 'America/New_York',
 522              'Etc/Greenwich' => 'Etc/GMT',
 523              'Etc/UCT' => 'Etc/UTC',
 524              'Etc/Universal' => 'Etc/UTC',
 525              'Etc/Zulu' => 'Etc/UTC',
 526              'FET' => 'Europe/Minsk',
 527              'GB' => 'Europe/London',
 528              'GB-Eire' => 'Europe/London',
 529              'Greenwich' => 'Etc/GMT',
 530              'Hongkong' => 'Asia/Hong_Kong',
 531              'HST' => 'Pacific/Honolulu',
 532              'Iceland' => 'Atlantic/Reykjavik',
 533              'India Time' => 'Asia/Kolkata',
 534              'Iran' => 'Asia/Tehran',
 535              'Israel' => 'Asia/Jerusalem',
 536              'IST' => 'Asia/Kolkata',
 537              'Jamaica' => 'America/Jamaica',
 538              'Japan' => 'Asia/Tokyo',
 539              'Japan Standard Time' => 'Asia/Tokyo',
 540              'Japan Time' => 'Asia/Tokyo',
 541              'JST' => 'Asia/Tokyo',
 542              'Kwajalein' => 'Pacific/Kwajalein',
 543              'Libya' => 'Africa/Tripoli',
 544              'MDT' => 'America/Denver',
 545              'MET' => 'Europe/Paris',
 546              'Mexico/BajaNorte' => 'America/Tijuana',
 547              'Mexico/BajaSur' => 'America/Mazatlan',
 548              'Mexico/General' => 'America/Mexico_City',
 549              'MST' => 'America/Phoenix',
 550              'MST7MDT' => 'America/Denver',
 551              'Navajo' => 'America/Denver',
 552              'NZ' => 'Pacific/Auckland',
 553              'NZ-CHAT' => 'Pacific/Chatham',
 554              'Pacific Time' => 'America/Los_Angeles',
 555              'PDT' => 'America/Los_Angeles',
 556              'Poland' => 'Europe/Warsaw',
 557              'Portugal' => 'Europe/Lisbon',
 558              'PRC' => 'Asia/Shanghai',
 559              'PST' => 'America/Los_Angeles',
 560              'PST8PDT' => 'America/Los_Angeles',
 561              'ROC' => 'Asia/Taipei',
 562              'ROK' => 'Asia/Seoul',
 563              'Singapore' => 'Asia/Singapore',
 564              'Turkey' => 'Europe/Istanbul',
 565              'UCT' => 'Etc/UTC',
 566              'Universal' => 'Etc/UTC',
 567              'US/Alaska' => 'America/Anchorage',
 568              'US/Aleutian' => 'America/Adak',
 569              'US/Arizona' => 'America/Phoenix',
 570              'US/Central' => 'America/Chicago',
 571              'US/East-Indiana' => 'America/Indiana/Indianapolis',
 572              'US/Eastern' => 'America/New_York',
 573              'US/Hawaii' => 'Pacific/Honolulu',
 574              'US/Indiana-Starke' => 'America/Indiana/Knox',
 575              'US/Michigan' => 'America/Detroit',
 576              'US/Mountain' => 'America/Denver',
 577              'US/Pacific' => 'America/Los_Angeles',
 578              'US/Pacific-New' => 'America/Los_Angeles',
 579              'US/Samoa' => 'Pacific/Pago_Pago',
 580              'W-SU' => 'Europe/Moscow',
 581              'WET' => 'Europe/London',
 582              'Zulu' => 'Etc/UTC',
 583  
 584              // Some UTC variations.
 585              'UTC-01' => 'Etc/GMT+1',
 586              'UTC-02' => 'Etc/GMT+2',
 587              'UTC-03' => 'Etc/GMT+3',
 588              'UTC-04' => 'Etc/GMT+4',
 589              'UTC-05' => 'Etc/GMT+5',
 590              'UTC-06' => 'Etc/GMT+6',
 591              'UTC-07' => 'Etc/GMT+7',
 592              'UTC-08' => 'Etc/GMT+8',
 593              'UTC-09' => 'Etc/GMT+9',
 594  
 595              // Some weird GMTs.
 596              'Etc/GMT+0' => 'Etc/GMT',
 597              'Etc/GMT-0' => 'Etc/GMT',
 598              'Etc/GMT0' => 'Etc/GMT',
 599  
 600              // Link old timezone names with their new names.
 601              'Africa/Asmera' => 'Africa/Asmara',
 602              'Africa/Timbuktu' => 'Africa/Abidjan',
 603              'America/Argentina/ComodRivadavia' => 'America/Argentina/Catamarca',
 604              'America/Atka' => 'America/Adak',
 605              'America/Buenos_Aires' => 'America/Argentina/Buenos_Aires',
 606              'America/Catamarca' => 'America/Argentina/Catamarca',
 607              'America/Coral_Harbour' => 'America/Atikokan',
 608              'America/Cordoba' => 'America/Argentina/Cordoba',
 609              'America/Ensenada' => 'America/Tijuana',
 610              'America/Fort_Wayne' => 'America/Indiana/Indianapolis',
 611              'America/Godthab' => 'America/Nuuk',
 612              'America/Indianapolis' => 'America/Indiana/Indianapolis',
 613              'America/Jujuy' => 'America/Argentina/Jujuy',
 614              'America/Knox_IN' => 'America/Indiana/Knox',
 615              'America/Louisville' => 'America/Kentucky/Louisville',
 616              'America/Mendoza' => 'America/Argentina/Mendoza',
 617              'America/Montreal' => 'America/Toronto',
 618              'America/Porto_Acre' => 'America/Rio_Branco',
 619              'America/Rosario' => 'America/Argentina/Cordoba',
 620              'America/Santa_Isabel' => 'America/Tijuana',
 621              'America/Shiprock' => 'America/Denver',
 622              'America/Virgin' => 'America/Port_of_Spain',
 623              'Antarctica/South_Pole' => 'Pacific/Auckland',
 624              'Asia/Ashkhabad' => 'Asia/Ashgabat',
 625              'Asia/Calcutta' => 'Asia/Kolkata',
 626              'Asia/Chongqing' => 'Asia/Shanghai',
 627              'Asia/Chungking' => 'Asia/Shanghai',
 628              'Asia/Dacca' => 'Asia/Dhaka',
 629              'Asia/Harbin' => 'Asia/Shanghai',
 630              'Asia/Istanbul' => 'Europe/Istanbul',
 631              'Asia/Kashgar' => 'Asia/Urumqi',
 632              'Asia/Katmandu' => 'Asia/Kathmandu',
 633              'Asia/Macao' => 'Asia/Macau',
 634              'Asia/Rangoon' => 'Asia/Yangon',
 635              'Asia/Saigon' => 'Asia/Ho_Chi_Minh',
 636              'Asia/Tel_Aviv' => 'Asia/Jerusalem',
 637              'Asia/Thimbu' => 'Asia/Thimphu',
 638              'Asia/Ujung_Pandang' => 'Asia/Makassar',
 639              'Asia/Ulan_Bator' => 'Asia/Ulaanbaatar',
 640              'Atlantic/Faeroe' => 'Atlantic/Faroe',
 641              'Atlantic/Jan_Mayen' => 'Europe/Oslo',
 642              'Australia/Canberra' => 'Australia/Sydney',
 643              'Australia/Yancowinna' => 'Australia/Broken_Hill',
 644              'Europe/Belfast' => 'Europe/London',
 645              'Europe/Kiev' => 'Europe/Kyiv',
 646              'Europe/Nicosia' => 'Asia/Nicosia',
 647              'Europe/Tiraspol' => 'Europe/Chisinau',
 648              'Pacific/Enderbury' => 'Pacific/Kanton',
 649              'Pacific/Johnston' => 'Pacific/Honolulu',
 650              'Pacific/Ponape' => 'Pacific/Pohnpei',
 651              'Pacific/Samoa' => 'Pacific/Pago_Pago',
 652              'Pacific/Truk' => 'Pacific/Chuuk',
 653              'Pacific/Yap' => 'Pacific/Chuuk',
 654          );
 655  
 656          // Legacy GMT fallback.
 657          for ($i = -12; $i <= 14; $i++) {
 658              $off = abs($i);
 659              if ($i < 0) {
 660                  $mapto = 'Etc/GMT+' . $off;
 661                  $utc = 'UTC-' . $off;
 662                  $gmt = 'GMT-' . $off;
 663              } else if ($i > 0) {
 664                  $mapto = 'Etc/GMT-' . $off;
 665                  $utc = 'UTC+' . $off;
 666                  $gmt = 'GMT+' . $off;
 667              } else {
 668                  $mapto = 'Etc/GMT';
 669                  $utc = 'UTC';
 670                  $gmt = 'GMT';
 671              }
 672              if (isset(self::$bczones[$mapto])) {
 673                  self::$badzones[$i . ''] = $mapto;
 674                  self::$badzones[$i . '.0'] = $mapto;
 675                  self::$badzones[$utc] = $mapto;
 676                  self::$badzones[$gmt] = $mapto;
 677              }
 678          }
 679  
 680          // Legacy Moodle half an hour offsets - pick any city nearby, ideally without DST.
 681          self::$badzones['4.5'] = 'Asia/Kabul';
 682          self::$badzones['5.5'] = 'Asia/Kolkata';
 683          self::$badzones['6.5'] = 'Asia/Rangoon';
 684          self::$badzones['9.5'] = 'Australia/Darwin';
 685  
 686          // Remove bad zones that are elsewhere.
 687          foreach (self::$bczones as $zone => $unused) {
 688              if (isset(self::$badzones[$zone])) {
 689                  unset(self::$badzones[$zone]);
 690              }
 691          }
 692          foreach (self::$goodzones as $zone => $unused) {
 693              if (isset(self::$badzones[$zone])) {
 694                  unset(self::$badzones[$zone]);
 695              }
 696          }
 697      }
 698  
 699      /**
 700       * Locale-formatted strftime using IntlDateFormatter (PHP 8.1 compatible)
 701       * This provides a cross-platform alternative to strftime() for when it will be removed from PHP.
 702       * Note that output can be slightly different between libc sprintf and this function as it is using ICU.
 703       *
 704       * From:
 705       * https://github.com/alphp/strftime
 706       *
 707       * @param  string $format Date format
 708       * @param  int|string|DateTime $timestamp Timestamp
 709       * @param  string|null $locale
 710       * @return string
 711       * @author BohwaZ <https://bohwaz.net/>
 712       */
 713      public static function strftime(string $format, $timestamp = null, ?string $locale = null) : string {
 714          // Moodle-specific modification. For the IntlDateFormatter we need to use unix-style locale
 715          // from the string 'locale' even for Windows, so we can neither use moodle_getlocale().
 716          // nor rely on the setlocale() use below. We also ignore $CFG->locale because it can use
 717          // Windows format.
 718          $locale = $locale ?: get_string('locale', 'langconfig');
 719  
 720          // The following code is taken from https://github.com/alphp/strftime without modifications.
 721          // phpcs:disable
 722          if (!($timestamp instanceof DateTimeInterface)) {
 723            $timestamp = is_int($timestamp) ? '@' . $timestamp : (string) $timestamp;
 724  
 725            try {
 726              $timestamp = new DateTime($timestamp);
 727            } catch (Exception $e) {
 728              throw new InvalidArgumentException('$timestamp argument is neither a valid UNIX timestamp, a valid date-time string or a DateTime object.', 0, $e);
 729            }
 730          }
 731  
 732          $timestamp->setTimezone(new DateTimeZone(date_default_timezone_get()));
 733  
 734          if (empty($locale)) {
 735            // get current locale
 736            $locale = setlocale(LC_TIME, '0');
 737          }
 738          // remove trailing part not supported by ext-intl locale
 739          $locale = preg_replace('/[^\w-].*$/', '', $locale);
 740  
 741          $intl_formats = [
 742            '%a' => 'EEE',	 // An abbreviated textual representation of the day	 Sun through Sat
 743            '%A' => 'EEEE',	 // A full textual representation of the day	 Sunday through Saturday
 744            '%b' => 'MMM',	 // Abbreviated month name, based on the locale	 Jan through Dec
 745            '%B' => 'MMMM',	 // Full month name, based on the locale	 January through December
 746            '%h' => 'MMM',	 // Abbreviated month name, based on the locale (an alias of %b)	 Jan through Dec
 747          ];
 748  
 749          $intl_formatter = function (DateTimeInterface $timestamp, string $format) use ($intl_formats, $locale) {
 750  
 751            // Map IANA timezone DB names (used by PHP) to those used internally by the "intl" extension. The extension uses its
 752            // own data based on ICU timezones, which may not necessarily be in-sync with IANA depending on the version installed
 753            // on the local system. See: https://unicode-org.github.io/icu/userguide/datetime/timezone/#updating-the-time-zone-data
 754            $tz = $timestamp->getTimezone();
 755            $intltz = IntlTimeZone::fromDateTimeZone($tz);
 756            if ($intltz === null) {
 757                // Where intl doesn't know about a recent timezone, map it to an equivalent existing zone.
 758                $intltzname = strtr($tz->getName(), [
 759                    'America/Ciudad_Juarez' => 'America/Denver',
 760                    'America/Nuuk' => 'America/Godthab',
 761                    'Europe/Kyiv' => 'Europe/Kiev',
 762                    'Pacific/Kanton' => 'Pacific/Enderbury',
 763                ]);
 764                $intltz = IntlTimeZone::createTimeZone($intltzname);
 765            }
 766  
 767            $date_type = IntlDateFormatter::FULL;
 768            $time_type = IntlDateFormatter::FULL;
 769            $pattern = '';
 770  
 771            switch ($format) {
 772              // %c = Preferred date and time stamp based on locale
 773              // Example: Tue Feb 5 00:45:10 2009 for February 5, 2009 at 12:45:10 AM
 774              case '%c':
 775                $date_type = IntlDateFormatter::LONG;
 776                $time_type = IntlDateFormatter::SHORT;
 777                break;
 778  
 779              // %x = Preferred date representation based on locale, without the time
 780              // Example: 02/05/09 for February 5, 2009
 781              case '%x':
 782                $date_type = IntlDateFormatter::SHORT;
 783                $time_type = IntlDateFormatter::NONE;
 784                break;
 785  
 786              // Localized time format
 787              case '%X':
 788                $date_type = IntlDateFormatter::NONE;
 789                $time_type = IntlDateFormatter::MEDIUM;
 790                break;
 791  
 792              default:
 793                $pattern = $intl_formats[$format];
 794            }
 795  
 796            // In October 1582, the Gregorian calendar replaced the Julian in much of Europe, and
 797            //  the 4th October was followed by the 15th October.
 798            // ICU (including IntlDateFormattter) interprets and formats dates based on this cutover.
 799            // Posix (including strftime) and timelib (including DateTimeImmutable) instead use
 800            //  a "proleptic Gregorian calendar" - they pretend the Gregorian calendar has existed forever.
 801            // This leads to the same instants in time, as expressed in Unix time, having different representations
 802            //  in formatted strings.
 803            // To adjust for this, a custom calendar can be supplied with a cutover date arbitrarily far in the past.
 804            $calendar = IntlGregorianCalendar::createInstance($intltz);
 805            $calendar->setGregorianChange(PHP_INT_MIN);
 806  
 807            return (new IntlDateFormatter($locale, $date_type, $time_type, $intltz, $calendar, $pattern))->format($timestamp);
 808          };
 809  
 810          // Same order as https://www.php.net/manual/en/function.strftime.php
 811          $translation_table = [
 812            // Day
 813            '%a' => $intl_formatter,
 814            '%A' => $intl_formatter,
 815            '%d' => 'd',
 816            '%e' => function ($timestamp) {
 817              return sprintf('% 2u', $timestamp->format('j'));
 818            },
 819            '%j' => function ($timestamp) {
 820              // Day number in year, 001 to 366
 821              return sprintf('%03d', $timestamp->format('z')+1);
 822            },
 823            '%u' => 'N',
 824            '%w' => 'w',
 825  
 826            // Week
 827            '%U' => function ($timestamp) {
 828              // Number of weeks between date and first Sunday of year
 829              $day = new DateTime(sprintf('%d-01 Sunday', $timestamp->format('Y')));
 830              return sprintf('%02u', 1 + ($timestamp->format('z') - $day->format('z')) / 7);
 831            },
 832            '%V' => 'W',
 833            '%W' => function ($timestamp) {
 834              // Number of weeks between date and first Monday of year
 835              $day = new DateTime(sprintf('%d-01 Monday', $timestamp->format('Y')));
 836              return sprintf('%02u', 1 + ($timestamp->format('z') - $day->format('z')) / 7);
 837            },
 838  
 839            // Month
 840            '%b' => $intl_formatter,
 841            '%B' => $intl_formatter,
 842            '%h' => $intl_formatter,
 843            '%m' => 'm',
 844  
 845            // Year
 846            '%C' => function ($timestamp) {
 847              // Century (-1): 19 for 20th century
 848              return floor($timestamp->format('Y') / 100);
 849            },
 850            '%g' => function ($timestamp) {
 851              return substr($timestamp->format('o'), -2);
 852            },
 853            '%G' => 'o',
 854            '%y' => 'y',
 855            '%Y' => 'Y',
 856  
 857            // Time
 858            '%H' => 'H',
 859            '%k' => function ($timestamp) {
 860              return sprintf('% 2u', $timestamp->format('G'));
 861            },
 862            '%I' => 'h',
 863            '%l' => function ($timestamp) {
 864              return sprintf('% 2u', $timestamp->format('g'));
 865            },
 866            '%M' => 'i',
 867            '%p' => 'A', // AM PM (this is reversed on purpose!)
 868            '%P' => 'a', // am pm
 869            '%r' => 'h:i:s A', // %I:%M:%S %p
 870            '%R' => 'H:i', // %H:%M
 871            '%S' => 's',
 872            '%T' => 'H:i:s', // %H:%M:%S
 873            '%X' => $intl_formatter, // Preferred time representation based on locale, without the date
 874  
 875            // Timezone
 876            '%z' => 'O',
 877            '%Z' => 'T',
 878  
 879            // Time and Date Stamps
 880            '%c' => $intl_formatter,
 881            '%D' => 'm/d/Y',
 882            '%F' => 'Y-m-d',
 883            '%s' => 'U',
 884            '%x' => $intl_formatter,
 885          ];
 886  
 887          $out = preg_replace_callback('/(?<!%)%([_#-]?)([a-zA-Z])/', function ($match) use ($translation_table, $timestamp) {
 888            $prefix = $match[1];
 889            $char = $match[2];
 890            $pattern = '%'.$char;
 891            if ($pattern == '%n') {
 892              return "\n";
 893            } elseif ($pattern == '%t') {
 894              return "\t";
 895            }
 896  
 897            if (!isset($translation_table[$pattern])) {
 898              throw new InvalidArgumentException(sprintf('Format "%s" is unknown in time format', $pattern));
 899            }
 900  
 901            $replace = $translation_table[$pattern];
 902  
 903            if (is_string($replace)) {
 904              $result = $timestamp->format($replace);
 905            } else {
 906              $result = $replace($timestamp, $pattern);
 907            }
 908  
 909            switch ($prefix) {
 910              case '_':
 911                // replace leading zeros with spaces but keep last char if also zero
 912                return preg_replace('/\G0(?=.)/', ' ', $result);
 913              case '#':
 914              case '-':
 915                // remove leading zeros but keep last char if also zero
 916                return preg_replace('/^0+(?=.)/', '', $result);
 917            }
 918  
 919            return $result;
 920          }, $format);
 921  
 922          $out = str_replace('%%', '%', $out);
 923          return $out;
 924          // phpcs:enable
 925      }
 926  }