MDL-49684 timezones: rewrite timezone support
[moodle.git] / lib / classes / date.php
CommitLineData
d6e7a63d
PS
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 */
35class 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 $modifier = ($currentvalue > 0) ? '+' : '';
76 $a = 'UTC' . $modifier . number_format($currentvalue, 1);
77 $timezones[$currentvalue] = get_string('timezoneinvalid', 'core_admin', $a);
78 } else {
79 // Some string we don't recognise.
80 $timezones[$currentvalue] = get_string('timezoneinvalid', 'core_admin', $currentvalue);
81 }
82
83 return $timezones;
84 }
85
86 /**
87 * Returns localised timezone name.
88 * @param string $tz
89 * @return string
90 */
91 public static function get_localised_timezone($tz) {
92 if ($tz == 99) {
93 $tz = self::get_server_timezone();
94 $tz = self::get_localised_timezone($tz);
95 return get_string('timezoneserver', 'core_admin', $tz);
96 }
97
98 if (get_string_manager()->string_exists(strtolower($tz), 'core_timezones')) {
99 $tz = get_string(strtolower($tz), 'core_timezones');
100 } else if ($tz === 'GMT' or $tz === 'Etc/GMT' or $tz === 'Etc/UTC') {
101 $tz = 'UTC';
102 } else if (preg_match('|^Etc/GMT([+-])([0-9]+)$|', $tz, $matches)) {
103 $sign = $matches[1] === '+' ? '-' : '+';
104 $tz = 'UTC' . $sign . $matches[2];
105 }
106
107 return $tz;
108 }
109
110 /**
111 * Normalise the timezone name. If timezone not supported
112 * this method falls back to server timezone (if valid)
113 * or default PHP timezone.
114 *
115 * @param int|string|float|DateTimeZone $tz
116 * @return string timezone compatible with PHP
117 */
118 public static function normalise_timezone($tz) {
119 global $CFG;
120
121 if ($tz instanceof DateTimeZone) {
122 return $tz->getName();
123 }
124
125 self::init_zones();
126 $tz = (string)$tz;
127
128 if (isset(self::$goodzones[$tz]) or isset(self::$bczones[$tz])) {
129 return $tz;
130 }
131
132 $fixed = false;
133 if (isset(self::$badzones[$tz])) {
134 // Convert to known zone.
135 $tz = self::$badzones[$tz];
136 $fixed = true;
137 } else if (is_number($tz)) {
138 // Half hour numeric offsets were already tested, try rounding to integers here.
139 $roundedtz = (string)(int)$tz;
140 if (isset(self::$badzones[$roundedtz])) {
141 $tz = self::$badzones[$roundedtz];
142 $fixed = true;
143 }
144 }
145
146 if ($fixed and isset(self::$goodzones[$tz]) or isset(self::$bczones[$tz])) {
147 return $tz;
148 }
149
150 // Is server timezone usable?
151 if (isset($CFG->timezone) and !is_numeric($CFG->timezone)) {
152 $result = @timezone_open($CFG->timezone); // Hide notices if invalid.
153 if ($result !== false) {
154 return $result->getName();
155 }
156 }
157
158 // Bad luck, use the php.ini default or value set in config.php.
159 return self::get_default_php_timezone();
160 }
161
162 /**
163 * Returns server timezone.
164 * @return string normalised timezone name compatible with PHP
165 **/
166 public static function get_server_timezone() {
167 global $CFG;
168
169 if (!isset($CFG->timezone) or $CFG->timezone == 99 or $CFG->timezone === '') {
170 return self::get_default_php_timezone();
171 }
172
173 return self::normalise_timezone($CFG->timezone);
174 }
175
176 /**
177 * Returns server timezone.
178 * @return DateTimeZone
179 **/
180 public static function get_server_timezone_object() {
181 $tz = self::get_server_timezone();
182 return new DateTimeZone($tz);
183 }
184
185 /**
186 * Set PHP default timezone to $CFG->timezone.
187 */
188 public static function set_default_server_timezone() {
189 global $CFG;
190
191 if (!isset($CFG->timezone) or $CFG->timezone == 99 or $CFG->timezone === '') {
192 date_default_timezone_set(self::get_default_php_timezone());
193 return;
194 }
195
196 $current = date_default_timezone_get();
197 if ($current === $CFG->timezone) {
198 // Nothing to do.
199 return;
200 }
201
202 if (!isset(self::$goodzones)) {
203 // For better performance try do do this without full tz init,
204 // because this is called from lib/setup.php file on each page.
205 $result = @timezone_open($CFG->timezone); // Ignore error if setting invalid.
206 if ($result !== false) {
207 date_default_timezone_set($result->getName());
208 return;
209 }
210 }
211
212 // Slow way is the last option.
213 date_default_timezone_set(self::get_server_timezone());
214 }
215
216 /**
217 * Returns user timezone.
218 *
219 * Ideally the parameter should be a real user record,
220 * unfortunately the legacy code is using 99 for both server
221 * and default value.
222 *
223 * Example of using legacy API:
224 * // Date for other user via legacy API.
225 * $datestr = userdate($time, core_date::get_user_timezone($user));
226 *
227 * The coding style rules in Moodle are moronic,
228 * why cannot the parameter names have underscores in them?
229 *
230 * @param mixed $userorforcedtz user object or legacy forced timezone string or tz object
231 * @return string normalised timezone name compatible with PHP
232 */
233 public static function get_user_timezone($userorforcedtz = null) {
234 global $USER, $CFG;
235
236 if ($userorforcedtz instanceof DateTimeZone) {
237 return $userorforcedtz->getName();
238 }
239
240 if (isset($userorforcedtz) and !is_object($userorforcedtz) and $userorforcedtz != 99) {
241 // Legacy code is forcing timezone in legacy API.
242 return self::normalise_timezone($userorforcedtz);
243 }
244
245 if (isset($CFG->forcetimezone) and $CFG->forcetimezone != 99) {
246 // Override any user timezone.
247 return self::normalise_timezone($CFG->forcetimezone);
248 }
249
250 if ($userorforcedtz === null) {
251 $tz = isset($USER->timezone) ? $USER->timezone : 99;
252
253 } else if (is_object($userorforcedtz)) {
254 $tz = isset($userorforcedtz->timezone) ? $userorforcedtz->timezone : 99;
255
256 } else {
257 if ($userorforcedtz == 99) {
258 $tz = isset($USER->timezone) ? $USER->timezone : 99;
259 } else {
260 $tz = $userorforcedtz;
261 }
262 }
263
264 if ($tz == 99) {
265 return self::get_server_timezone();
266 }
267
268 return self::normalise_timezone($tz);
269 }
270
271 /**
272 * Return user timezone object.
273 *
274 * @param mixed $userorforcedtz
275 * @return DateTimeZone
276 */
277 public static function get_user_timezone_object($userorforcedtz = null) {
278 $tz = self::get_user_timezone($userorforcedtz);
279 return new DateTimeZone($tz);
280 }
281
282 /**
283 * Return default timezone set in php.ini or config.php.
284 * @return string normalised timezone compatible with PHP
285 */
286 public static function get_default_php_timezone() {
287 if (!isset(self::$defaultphptimezone)) {
288 // This should not happen.
289 self::store_default_php_timezone();
290 }
291
292 return self::$defaultphptimezone;
293 }
294
295 /**
296 * To be called from lib/setup.php only!
297 */
298 public static function store_default_php_timezone() {
299 if (defined('PHPUNIT_TEST') or defined('BEHAT_SITE_RUNNING') or defined('BEHAT_TEST') or defined('BEHAT_UTIL')) {
300 // We want all test sites to be consistent by default.
301 self::$defaultphptimezone = 'Australia/Perth';
302 return;
303 }
304 if (!isset(self::$defaultphptimezone)) {
305 self::$defaultphptimezone = date_default_timezone_get();
306 }
307 }
308
309 /**
310 * Do not use directly - use $this->setTimezone('xx', $tz) instead in your test case.
311 * @param string $tz valid timezone name
312 */
313 public static function phpunit_override_default_php_timezone($tz) {
314 if (!defined('PHPUNIT_TEST')) {
315 throw new coding_exception('core_date::phpunit_override_default_php_timezone() must be used only from unit tests');
316 }
317 $result = timezone_open($tz); // This triggers error if $tz invalid.
318 if ($result !== false) {
319 self::$defaultphptimezone = $tz;
320 } else {
321 self::$defaultphptimezone = 'Australia/Perth';
322 }
323 }
324
325 /**
326 * To be called from phpunit reset only, after restoring $CFG.
327 */
328 public static function phpunit_reset() {
329 global $CFG;
330 if (!defined('PHPUNIT_TEST')) {
331 throw new coding_exception('core_date::phpunit_reset() must be used only from unit tests');
332 }
333 self::store_default_php_timezone();
334 date_default_timezone_set($CFG->timezone);
335 }
336
337 /**
338 * Initialise timezone arrays, call before use.
339 */
340 protected static function init_zones() {
341 if (isset(self::$goodzones)) {
342 return;
343 }
344
345 $zones = DateTimeZone::listIdentifiers();
346 self::$goodzones = array_fill_keys($zones, true);
347
348 $zones = DateTimeZone::listIdentifiers(DateTimeZone::ALL_WITH_BC);
349 self::$bczones = array();
350 foreach ($zones as $zone) {
351 if (isset(self::$goodzones[$zone])) {
352 continue;
353 }
354 self::$bczones[$zone] = true;
355 }
356
357 self::$badzones = array(
358 // Windows time zones.
359 'Dateline Standard Time' => 'Etc/GMT+12',
360 'Hawaiian Standard Time' => 'Pacific/Honolulu',
361 'Alaskan Standard Time' => 'America/Anchorage',
362 'Pacific Standard Time (Mexico)' => 'America/Santa_Isabel',
363 'Pacific Standard Time' => 'America/Los_Angeles',
364 'US Mountain Standard Time' => 'America/Phoenix',
365 'Mountain Standard Time (Mexico)' => 'America/Chihuahua',
366 'Mountain Standard Time' => 'America/Denver',
367 'Central America Standard Time' => 'America/Guatemala',
368 'Central Standard Time' => 'America/Chicago',
369 'Central Standard Time (Mexico)' => 'America/Mexico_City',
370 'Canada Central Standard Time' => 'America/Regina',
371 'SA Pacific Standard Time' => 'America/Bogota',
372 'Eastern Standard Time' => 'America/New_York',
373 'US Eastern Standard Time' => 'America/Indianapolis',
374 'Venezuela Standard Time' => 'America/Caracas',
375 'Paraguay Standard Time' => 'America/Asuncion',
376 'Atlantic Standard Time' => 'America/Halifax',
377 'Central Brazilian Standard Time' => 'America/Cuiaba',
378 'SA Western Standard Time' => 'America/La_Paz',
379 'Pacific SA Standard Time' => 'America/Santiago',
380 'Newfoundland Standard Time' => 'America/St_Johns',
381 'E. South America Standard Time' => 'America/Sao_Paulo',
382 'Argentina Standard Time' => 'America/Buenos_Aires',
383 'SA Eastern Standard Time' => 'America/Cayenne',
384 'Greenland Standard Time' => 'America/Godthab',
385 'Montevideo Standard Time' => 'America/Montevideo',
386 'Bahia Standard Time' => 'America/Bahia',
387 'Azores Standard Time' => 'Atlantic/Azores',
388 'Cape Verde Standard Time' => 'Atlantic/Cape_Verde',
389 'Morocco Standard Time' => 'Africa/Casablanca',
390 'GMT Standard Time' => 'Europe/London',
391 'Greenwich Standard Time' => 'Atlantic/Reykjavik',
392 'W. Europe Standard Time' => 'Europe/Berlin',
393 'Central Europe Standard Time' => 'Europe/Budapest',
394 'Romance Standard Time' => 'Europe/Paris',
395 'Central European Standard Time' => 'Europe/Warsaw',
396 'W. Central Africa Standard Time' => 'Africa/Lagos',
397 'Namibia Standard Time' => 'Africa/Windhoek',
398 'Jordan Standard Time' => 'Asia/Amman',
399 'GTB Standard Time' => 'Europe/Bucharest',
400 'Middle East Standard Time' => 'Asia/Beirut',
401 'Egypt Standard Time' => 'Africa/Cairo',
402 'Syria Standard Time' => 'Asia/Damascus',
403 'South Africa Standard Time' => 'Africa/Johannesburg',
404 'FLE Standard Time' => 'Europe/Kiev',
405 'Turkey Standard Time' => 'Europe/Istanbul',
406 'Israel Standard Time' => 'Asia/Jerusalem',
407 'Kaliningrad Standard Time' => 'Europe/Kaliningrad',
408 'Libya Standard Time' => 'Africa/Tripoli',
409 'Arabic Standard Time' => 'Asia/Baghdad',
410 'Arab Standard Time' => 'Asia/Riyadh',
411 'Belarus Standard Time' => 'Europe/Minsk',
412 'Russian Standard Time' => 'Europe/Moscow',
413 'E. Africa Standard Time' => 'Africa/Nairobi',
414 'Iran Standard Time' => 'Asia/Tehran',
415 'Arabian Standard Time' => 'Asia/Dubai',
416 'Azerbaijan Standard Time' => 'Asia/Baku',
417 'Russia Time Zone 3' => 'Europe/Samara',
418 'Mauritius Standard Time' => 'Indian/Mauritius',
419 'Georgian Standard Time' => 'Asia/Tbilisi',
420 'Caucasus Standard Time' => 'Asia/Yerevan',
421 'Afghanistan Standard Time' => 'Asia/Kabul',
422 'West Asia Standard Time' => 'Asia/Tashkent',
423 'Ekaterinburg Standard Time' => 'Asia/Yekaterinburg',
424 'Pakistan Standard Time' => 'Asia/Karachi',
425 'India Standard Time' => 'Asia/Kolkata', // PHP and Windows differ in spelling.
426 'Sri Lanka Standard Time' => 'Asia/Colombo',
427 'Nepal Standard Time' => 'Asia/Katmandu',
428 'Central Asia Standard Time' => 'Asia/Almaty',
429 'Bangladesh Standard Time' => 'Asia/Dhaka',
430 'N. Central Asia Standard Time' => 'Asia/Novosibirsk',
431 'Myanmar Standard Time' => 'Asia/Rangoon',
432 'SE Asia Standard Time' => 'Asia/Bangkok',
433 'North Asia Standard Time' => 'Asia/Krasnoyarsk',
434 'China Standard Time' => 'Asia/Shanghai',
435 'North Asia East Standard Time' => 'Asia/Irkutsk',
436 'Singapore Standard Time' => 'Asia/Singapore',
437 'W. Australia Standard Time' => 'Australia/Perth',
438 'Taipei Standard Time' => 'Asia/Taipei',
439 'Ulaanbaatar Standard Time' => 'Asia/Ulaanbaatar',
440 'Tokyo Standard Time' => 'Asia/Tokyo',
441 'Korea Standard Time' => 'Asia/Seoul',
442 'Yakutsk Standard Time' => 'Asia/Yakutsk',
443 'Cen. Australia Standard Time' => 'Australia/Adelaide',
444 'AUS Central Standard Time' => 'Australia/Darwin',
445 'E. Australia Standard Time' => 'Australia/Brisbane',
446 'AUS Eastern Standard Time' => 'Australia/Sydney',
447 'West Pacific Standard Time' => 'Pacific/Port_Moresby',
448 'Tasmania Standard Time' => 'Australia/Hobart',
449 'Magadan Standard Time' => 'Asia/Magadan',
450 'Vladivostok Standard Time' => 'Asia/Vladivostok',
451 'Russia Time Zone 10' => 'Asia/Srednekolymsk',
452 'Central Pacific Standard Time' => 'Pacific/Guadalcanal',
453 'Russia Time Zone 11' => 'Asia/Kamchatka',
454 'New Zealand Standard Time' => 'Pacific/Auckland',
455 'Fiji Standard Time' => 'Pacific/Fiji',
456 'Tonga Standard Time' => 'Pacific/Tongatapu',
457 'Samoa Standard Time' => 'Pacific/Apia',
458 'Line Islands Standard Time' => 'Pacific/Kiritimati',
459
460 // A lot more bad legacy time zones.
461 'CET' => 'Europe/Berlin',
462 'Central European Time' => 'Europe/Berlin',
463 'CST' => 'America/Chicago',
464 'Central Time' => 'America/Chicago',
465 'CST6CDT' => 'America/Chicago',
466 'CDT' => 'America/Chicago',
467 'China Time' => 'Asia/Shanghai',
468 'EDT' => 'America/New_York',
469 'EST' => 'America/New_York',
470 'EST5EDT' => 'America/New_York',
471 'Eastern Time' => 'America/New_York',
472 'IST' => 'Asia/Kolkata',
473 'India Time' => 'Asia/Kolkata',
474 'JST' => 'Asia/Tokyo',
475 'Japan Time' => 'Asia/Tokyo',
476 'Japan Standard Time' => 'Asia/Tokyo',
477 'MDT' => 'America/Denver',
478 'MST' => 'America/Denver',
479 'MST7MDT' => 'America/Denver',
480 'PDT' => 'America/Los_Angeles',
481 'PST' => 'America/Los_Angeles',
482 'Pacific Time' => 'America/Los_Angeles',
483 'PST8PDT' => 'America/Los_Angeles',
484 'HST' => 'Pacific/Honolulu',
485 'WET' => 'Europe/London',
486 'EET' => 'Europe/Kiev',
487 'FET' => 'Europe/Minsk',
488
489 // Some UTC variations.
490 'UTC-01' => 'Etc/GMT+1',
491 'UTC-02' => 'Etc/GMT+2',
492 'UTC-03' => 'Etc/GMT+3',
493 'UTC-04' => 'Etc/GMT+4',
494 'UTC-05' => 'Etc/GMT+5',
495 'UTC-06' => 'Etc/GMT+6',
496 'UTC-07' => 'Etc/GMT+7',
497 'UTC-08' => 'Etc/GMT+8',
498 'UTC-09' => 'Etc/GMT+9',
499
500 // Some weird GMTs.
501 'Etc/GMT+0' => 'Etc/GMT',
502 'Etc/GMT-0' => 'Etc/GMT',
503 'Etc/GMT0' => 'Etc/GMT',
504
505 // And lastly some alternative city spelling.
506 'Asia/Calcutta' => 'Asia/Kolkata',
507 );
508
509 // Legacy GMT fallback.
510 for ($i = -14; $i <= 13; $i++) {
511 $off = abs($i);
512 if ($i < 0) {
513 $mapto = 'Etc/GMT+' . $off;
514 $utc = 'UTC-' . $off;
515 $gmt = 'GMT-' . $off;
516 } else if ($i > 0) {
517 $mapto = 'Etc/GMT-' . $off;
518 $utc = 'UTC+' . $off;
519 $gmt = 'GMT+' . $off;
520 } else {
521 $mapto = 'Etc/GMT';
522 $utc = 'UTC';
523 $gmt = 'GMT';
524 }
525 if (isset(self::$bczones[$mapto])) {
526 self::$badzones[$i . ''] = $mapto;
527 self::$badzones[$i . '.0'] = $mapto;
528 self::$badzones[$utc] = $mapto;
529 self::$badzones[$gmt] = $mapto;
530 }
531 }
532
533 // Legacy Moodle half an hour offsets - pick any city nearby, ideally without DST.
534 self::$badzones['-4.5'] = 'America/Caracas';
535 self::$badzones['4.5'] = 'Asia/Kabul';
536 self::$badzones['5.5'] = 'Asia/Kolkata';
537 self::$badzones['6.5'] = 'Asia/Rangoon';
538 self::$badzones['9.5'] = 'Australia/Darwin';
539 self::$badzones['11.5'] = 'Pacific/Norfolk';
540
541 // Remove bad zones that are elsewhere.
542 foreach (self::$bczones as $zone => $unused) {
543 if (isset(self::$badzones[$zone])) {
544 unset(self::$badzones[$zone]);
545 }
546 }
547 foreach (self::$goodzones as $zone => $unused) {
548 if (isset(self::$badzones[$zone])) {
549 unset(self::$badzones[$zone]);
550 }
551 }
552 }
553}