Merge branch 'MDL-34171_22' of git://github.com/timhunt/moodle into MOODLE_22_STABLE
[moodle.git] / lib / olson.php
blobb4397b84ac4722f03c1bc229028c64b0de9a7e6e
1 <?php
3 // This file is part of Moodle - http://moodle.org/
4 //
5 // Moodle is free software: you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License as published by
7 // the Free Software Foundation, either version 3 of the License, or
8 // (at your option) any later version.
9 //
10 // Moodle is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
13 // GNU General Public License for more details.
15 // You should have received a copy of the GNU General Public License
16 // along with Moodle. If not, see <http://www.gnu.org/licenses/>.
18 /**
19 * @package moodlecore
20 * @copyright 1999 onwards Martin Dougiamas {@link http://moodle.com}
21 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
24 /**
25 * olson_to_timezones ($filename)
27 * Parses the olson files for Zones and DST rules.
28 * It updates the Moodle database with the Zones/DST rules
30 * @param string $filename
31 * @return bool true/false
34 function olson_to_timezones ($filename) {
36 $zones = olson_simple_zone_parser($filename);
37 $rules = olson_simple_rule_parser($filename);
39 $mdl_zones = array();
41 /**
42 *** To translate the combined Zone & Rule changes
43 *** in the Olson files to the Moodle single ruleset
44 *** format, we need to trasverse every year and see
45 *** if either the Zone or the relevant Rule has a
46 *** change. It's yuck but it yields a rationalized
47 *** set of data, which is arguably simpler.
48 ***
49 *** Also note that I am starting at the epoch (1970)
50 *** because I don't think we'll see many events scheduled
51 *** before that, anyway.
52 ***
53 **/
54 $maxyear = localtime(time(), true);
55 $maxyear = $maxyear['tm_year'] + 1900 + 10;
57 foreach ($zones as $zname => $zbyyear) { // loop over zones
58 /**
59 *** Loop over years, only adding a rule when zone or rule
60 *** have changed. All loops preserver the last seen vars
61 *** until there's an explicit decision to delete them
62 ***
63 **/
65 // clean the slate for a new zone
66 $zone = NULL;
67 $rule = NULL;
70 // Find the pre 1970 zone rule entries
72 for ($y = 1970 ; $y >= 0 ; $y--) {
73 if (array_key_exists((string)$y, $zbyyear )) { // we have a zone entry for the year
74 $zone = $zbyyear[$y];
75 //print_object("Zone $zname pre1970 is in $y\n");
76 break; // Perl's last -- get outta here
79 if (!empty($zone['rule']) && array_key_exists($zone['rule'], $rules)) {
80 $rule = NULL;
81 for ($y = 1970 ; $y > 0 ; $y--) {
82 if (array_key_exists((string)$y, $rules[$zone['rule']] )) { // we have a rule entry for the year
83 $rule = $rules[$zone['rule']][$y];
84 //print_object("Rule $rule[name] pre1970 is $y\n");
85 break; // Perl's last -- get outta here
89 if (empty($rule)) {
90 // Colombia and a few others refer to rules before they exist
91 // Perhaps we should comment out this warning...
92 // trigger_error("Cannot find rule in $zone[rule] <= 1970");
93 $rule = array();
95 } else {
96 // no DST this year!
97 $rule = array();
100 // Prepare to insert the base 1970 zone+rule
101 if (!empty($rule) && array_key_exists($zone['rule'], $rules)) {
102 // merge the two arrays into the moodle rule
103 unset($rule['name']); // warning: $rule must NOT be a reference!
104 unset($rule['year']);
105 $mdl_tz = array_merge($zone, $rule);
107 //fix (de)activate_time (AT) field to be GMT
108 $mdl_tz['dst_time'] = olson_parse_at($mdl_tz['dst_time'], 'set', $mdl_tz['gmtoff']);
109 $mdl_tz['std_time'] = olson_parse_at($mdl_tz['std_time'], 'reset', $mdl_tz['gmtoff']);
110 } else {
111 // just a simple zone
112 $mdl_tz = $zone;
113 // TODO: Add other default values here!
114 $mdl_tz['dstoff'] = 0;
117 // Fix the from year to 1970
118 $mdl_tz['year'] = 1970;
120 // add to the array
121 $mdl_zones[] = $mdl_tz;
122 //print_object("Zero entry for $zone[name] added");
124 $lasttimezone = $mdl_tz;
127 /// 1971 onwards
129 for ($y = 1971; $y < $maxyear ; $y++) {
130 $changed = false;
132 /// We create a "zonerule" entry if either zone or rule change...
134 /// force $y to string to avoid PHP
135 /// thinking of a positional array
137 if (array_key_exists((string)$y, $zbyyear)) { // we have a zone entry for the year
138 $changed = true;
139 $zone = $zbyyear[(string)$y];
141 if (!empty($zone['rule']) && array_key_exists($zone['rule'], $rules)) {
142 if (array_key_exists((string)$y, $rules[$zone['rule']])) {
143 $changed = true;
144 $rule = $rules[$zone['rule']][(string)$y];
146 } else {
147 $rule = array();
150 if ($changed) {
151 //print_object("CHANGE YEAR $y Zone $zone[name] Rule $zone[rule]\n");
152 if (!empty($rule)) {
153 // merge the two arrays into the moodle rule
154 unset($rule['name']);
155 unset($rule['year']);
156 $mdl_tz = array_merge($zone, $rule);
158 // VERY IMPORTANT!!
159 $mdl_tz['year'] = $y;
161 //fix (de)activate_time (AT) field to be GMT
162 $mdl_tz['dst_time'] = olson_parse_at($mdl_tz['dst_time'], 'set', $mdl_tz['gmtoff']);
163 $mdl_tz['std_time'] = olson_parse_at($mdl_tz['std_time'], 'reset', $mdl_tz['gmtoff']);
164 } else {
165 // just a simple zone
166 $mdl_tz = $zone;
170 if(isset($mdl_tz['dst_time']) && !strpos($mdl_tz['dst_time'], ':') || isset($mdl_tz['std_time']) && !strpos($mdl_tz['std_time'], ':')) {
171 print_object($mdl_tz);
172 print_object('---');
175 // This is the simplest way to make the != operator just below NOT take the year into account
176 $lasttimezone['year'] = $mdl_tz['year'];
178 // If not a duplicate, add and update $lasttimezone
179 if($lasttimezone != $mdl_tz) {
180 $mdl_zones[] = $lasttimezone = $mdl_tz;
188 if (function_exists('memory_get_usage')) {
189 trigger_error("We are consuming this much memory: " . get_memory_usage());
193 /// Since Moodle 1.7, rule is tzrule in DB (reserved words problem), so change it here
194 /// after everything is calculated to be properly loaded to the timezone table.
195 /// Pre 1.7 users won't have the old rule if updating this from moodle.org but it
196 /// seems that such field isn't used at all by the rest of Moodle (at least I haven't
197 /// found any use when looking for it).
199 foreach($mdl_zones as $key=>$mdl_zone) {
200 $mdl_zones[$key]['tzrule'] = $mdl_zones[$key]['rule'];
203 return $mdl_zones;
208 * olson_simple_rule_parser($filename)
210 * Parses the olson files for DST rules.
211 * It's a simple implementation that simplifies some fields
213 * @return array a multidimensional array, or false on error
216 function olson_simple_rule_parser ($filename) {
218 $file = fopen($filename, 'r', 0);
220 if (empty($file)) {
221 return false;
224 // determine the maximum year for this zone
225 $maxyear = array();
227 while ($line = fgets($file)) {
228 // only pay attention to rules lines
229 if(!preg_match('/^Rule\s/', $line)){
230 continue;
232 $line = preg_replace('/\n$/', '',$line); // chomp
233 $rule = preg_split('/\s+/', $line);
234 list($discard,
235 $name,
236 $from,
237 $to,
238 $type,
239 $in,
240 $on,
241 $at,
242 $save,
243 $letter) = $rule;
244 if (isset($maxyear[$name])) {
245 if ($maxyear[$name] < $from) {
246 $maxyear[$name] = $from;
248 } else {
249 $maxyear[$name] = $from;
254 fseek($file, 0);
256 $rules = array();
257 while ($line = fgets($file)) {
258 // only pay attention to rules lines
259 if(!preg_match('/^Rule\s/', $line)){
260 continue;
262 $line = preg_replace('/\n$/', '',$line); // chomp
263 $rule = preg_split('/\s+/', $line);
264 list($discard,
265 $name,
266 $from,
267 $to,
268 $type,
269 $in,
270 $on,
271 $at,
272 $save,
273 $letter) = $rule;
275 $srs = ($save === '0') ? 'reset' : 'set';
277 if($to == 'only') {
278 $to = $from;
280 else if($to == 'max') {
281 $to = $maxyear[$name];
284 for($i = $from; $i <= $to; ++$i) {
285 $rules[$name][$i][$srs] = $rule;
290 fclose($file);
292 $months = array('jan' => 1, 'feb' => 2,
293 'mar' => 3, 'apr' => 4,
294 'may' => 5, 'jun' => 6,
295 'jul' => 7, 'aug' => 8,
296 'sep' => 9, 'oct' => 10,
297 'nov' => 11, 'dec' => 12);
300 // now reformat it a bit to match Moodle's DST table
301 $moodle_rules = array();
302 foreach ($rules as $rule => $rulesbyyear) {
303 foreach ($rulesbyyear as $year => $rulesthisyear) {
305 if(!isset($rulesthisyear['reset'])) {
306 // No "reset" rule. We will assume that this is somewhere in the southern hemisphere
307 // after a period of not using DST, otherwise it doesn't make sense at all.
308 // With that assumption, we can put in a fake reset e.g. on Jan 1, 12:00.
310 print_object("no reset");
311 print_object($rules);
312 die();
314 $rulesthisyear['reset'] = array(
315 NULL, NULL, NULL, NULL, NULL, 'jan', 1, '12:00', '00:00', NULL
319 if(!isset($rulesthisyear['set'])) {
320 // No "set" rule. We will assume that this is somewhere in the southern hemisphere
321 // and that it begins a period of not using DST, otherwise it doesn't make sense at all.
322 // With that assumption, we can put in a fake set on Dec 31, 12:00, shifting time by 0 minutes.
323 $rulesthisyear['set'] = array(
324 NULL, $rulesthisyear['reset'][1], NULL, NULL, NULL, 'dec', 31, '12:00', '00:00', NULL
328 list($discard,
329 $name,
330 $from,
331 $to,
332 $type,
333 $in,
334 $on,
335 $at,
336 $save,
337 $letter) = $rulesthisyear['set'];
339 $moodle_rule = array();
341 // $save is sometimes just minutes
342 // and othertimes HH:MM -- only
343 // parse if relevant
344 if (!preg_match('/^\d+$/', $save)) {
345 list($hours, $mins) = explode(':', $save);
346 $save = $hours * 60 + $mins;
349 // we'll parse $at later
350 // $at = olson_parse_at($at);
351 $in = strtolower($in);
352 if(!isset($months[$in])) {
353 trigger_error('Unknown month: '.$in);
356 $moodle_rule['name'] = $name;
357 $moodle_rule['year'] = $year;
358 $moodle_rule['dstoff'] = $save; // time offset
360 $moodle_rule['dst_month'] = $months[$in]; // the month
361 $moodle_rule['dst_time'] = $at; // the time
363 // Encode index and day as per Moodle's specs
364 $on = olson_parse_on($on);
365 $moodle_rule['dst_startday'] = $on['startday'];
366 $moodle_rule['dst_weekday'] = $on['weekday'];
367 $moodle_rule['dst_skipweeks'] = $on['skipweeks'];
369 // and now the "deactivate" data
370 list($discard,
371 $name,
372 $from,
373 $to,
374 $type,
375 $in,
376 $on,
377 $at,
378 $save,
379 $letter) = $rulesthisyear['reset'];
381 // we'll parse $at later
382 // $at = olson_parse_at($at);
383 $in = strtolower($in);
384 if(!isset($months[$in])) {
385 trigger_error('Unknown month: '.$in);
388 $moodle_rule['std_month'] = $months[$in]; // the month
389 $moodle_rule['std_time'] = $at; // the time
391 // Encode index and day as per Moodle's specs
392 $on = olson_parse_on($on);
393 $moodle_rule['std_startday'] = $on['startday'];
394 $moodle_rule['std_weekday'] = $on['weekday'];
395 $moodle_rule['std_skipweeks'] = $on['skipweeks'];
397 $moodle_rules[$moodle_rule['name']][$moodle_rule['year']] = $moodle_rule;
398 //print_object($moodle_rule);
400 } // end foreach year within a rule
402 // completed with all the entries for this rule
403 // if the last entry has a TO other than 'max'
404 // then we have to deal with closing the last rule
405 //trigger_error("Rule $name ending to $to");
406 if (!empty($to) && $to !== 'max') {
407 // We can handle two cases for TO:
408 // a year, or "only"
409 $reset_rule = $moodle_rule;
410 $reset_rule['dstoff'] = '00';
411 if (preg_match('/^\d+$/', $to)){
412 $reset_rule['year'] = $to;
413 $moodle_rules[$reset_rule['name']][$reset_rule['year']] = $reset_rule;
414 } elseif ($to === 'only') {
415 $reset_rule['year'] = $reset_rule['year'] + 1;
416 $moodle_rules[$reset_rule['name']][$reset_rule['year']] = $reset_rule;
417 } else {
418 trigger_error("Strange value in TO $to rule field for rule $name");
421 } // end if $to is interesting
423 } // end foreach rule
425 return $moodle_rules;
429 * olson_simple_zone_parser($filename)
431 * Parses the olson files for zone info
433 * @return array a multidimensional array, or false on error
436 function olson_simple_zone_parser ($filename) {
438 $file = fopen($filename, 'r', 0);
440 if (empty($file)) {
441 return false;
444 $zones = array();
445 $lastzone = NULL;
447 while ($line = fgets($file)) {
448 // skip obvious non-zone lines
449 if (preg_match('/^#/', $line)) {
450 continue;
452 if (preg_match('/^(?:Rule|Link|Leap)/',$line)) {
453 $lastzone = NULL; // reset lastzone
454 continue;
457 // If there are blanks in the start of the line but the first non-ws character is a #,
458 // assume it's an "inline comment". The funny thing is that this happens only during
459 // the definition of the Rule for Europe/Athens.
460 if(substr(trim($line), 0, 1) == '#') {
461 continue;
464 /*** Notes
466 *** By splitting on space, we are only keeping the
467 *** year of the UNTIL field -- that's on purpose.
469 *** The Zone lines are followed by continuation lines
470 *** were we reuse the info from the last one seen.
472 *** We are transforming "until" fields into "from" fields
473 *** which make more sense from the Moodle perspective, so
474 *** each initial Zone entry is "from" the year 0, and for the
475 *** continuation lines, we shift the "until" from the previous field
476 *** into this line's "from".
478 *** If a RULES field contains a time instead of a rule we discard it
479 *** I have no idea of how to create a DST rule out of that
480 *** (what are the start/end times?)
482 *** We remove "until" from the data we keep, but preserve
483 *** it in $lastzone.
485 if (preg_match('/^Zone/', $line)) { // a new zone
486 $line = trim($line);
487 $line = preg_split('/\s+/', $line);
488 $zone = array();
489 list( $discard, // 'Zone'
490 $zone['name'],
491 $zone['gmtoff'],
492 $zone['rule'],
493 $discard // format
494 ) = $line;
495 // the things we do to avoid warnings
496 if (!empty($line[5])) {
497 $zone['until'] = $line[5];
499 $zone['year'] = '0';
501 $zones[$zone['name']] = array();
503 } else if (!empty($lastzone) && preg_match('/^\s+/', $line)){
504 // looks like a credible continuation line
505 $line = trim($line);
506 $line = preg_split('/\s+/', $line);
507 if (count($line) < 3) {
508 $lastzone = NULL;
509 continue;
511 // retrieve info from the lastzone
512 $zone = $lastzone;
513 $zone['year'] = $zone['until'];
514 // overwrite with current data
515 list(
516 $zone['gmtoff'],
517 $zone['rule'],
518 $discard // format
519 ) = $line;
520 // the things we do to avoid warnings
521 if (!empty($line[3])) {
522 $zone['until'] = $line[3];
525 } else {
526 $lastzone = NULL;
527 continue;
530 // tidy up, we're done
531 // perhaps we should insert in the DB at this stage?
532 $lastzone = $zone;
533 unset($zone['until']);
534 $zone['gmtoff'] = olson_parse_offset($zone['gmtoff']);
535 if ($zone['rule'] === '-') { // cleanup empty rules
536 $zone['rule'] = '';
538 if (preg_match('/:/',$zone['rule'])) {
539 // we are not handling direct SAVE rules here
540 // discard it
541 $zone['rule'] = '';
544 $zones[$zone['name']][(string)$zone['year']] = $zone;
547 return $zones;
551 * olson_parse_offset($offset)
553 * parses time offsets from the GMTOFF and SAVE
554 * fields into +/-MINUTES
556 * @return int
558 function olson_parse_offset ($offset) {
559 $offset = trim($offset);
561 // perhaps it's just minutes
562 if (preg_match('/^(-?)(\d*)$/', $offset)) {
563 return intval($offset);
565 // (-)hours:minutes(:seconds)
566 if (preg_match('/^(-?)(\d*):(\d+)/', $offset, $matches)) {
567 // we are happy to discard the seconds
568 $sign = $matches[1];
569 $hours = intval($matches[2]);
570 $seconds = intval($matches[3]);
571 $offset = $sign . ($hours*60 + $seconds);
572 return intval($offset);
575 trigger_error('Strange time format in olson_parse_offset() ' .$offset);
576 return 0;
582 * olson_parse_on_($on)
584 * see `man zic`. This function translates the following formats
585 * 5 the fifth of the month
586 * lastSun the last Sunday in the month
587 * lastMon the last Monday in the month
588 * Sun>=8 first Sunday on or after the eighth
589 * Sun<=25 last Sunday on or before the 25th
591 * to a moodle friendly format. Returns an array with:
593 * startday: the day of the month that we start counting from.
594 * if negative, it means we start from that day and
595 * count backwards. since -1 would be meaningless,
596 * it means "end of month and backwards".
597 * weekday: the day of the week that we must find. we will
598 * scan days from the startday until we find the
599 * first such weekday. 0...6 = Sun...Sat.
600 * -1 means that any day of the week will do,
601 * effectively ending the search on startday.
602 * skipweeks:after finding our end day as outlined above,
603 * skip this many weeks. this enables us to find
604 * "the second sunday >= 10". usually will be 0.
606 function olson_parse_on ($on) {
608 $rule = array();
609 $days = array('sun' => 0, 'mon' => 1,
610 'tue' => 2, 'wed' => 3,
611 'thu' => 4, 'fri' => 5,
612 'sat' => 6);
614 if(is_numeric($on)) {
615 $rule['startday'] = intval($on); // Start searching from that day
616 $rule['weekday'] = -1; // ...and stop there, no matter what weekday
617 $rule['skipweeks'] = 0; // Don't skip any weeks.
619 else {
620 $on = strtolower($on);
621 if(substr($on, 0, 4) == 'last') {
622 // e.g. lastSun
623 if(!isset($days[substr($on, 4)])) {
624 trigger_error('Unknown last weekday: '.substr($on, 4));
626 else {
627 $rule['startday'] = -1; // Start from end of month
628 $rule['weekday'] = $days[substr($on, 4)]; // Find the first such weekday
629 $rule['skipweeks'] = 0; // Don't skip any weeks.
632 else if(substr($on, 3, 2) == '>=') {
633 // e.g. Sun>=8
634 if(!isset($days[substr($on, 0, 3)])) {
635 trigger_error('Unknown >= weekday: '.substr($on, 0, 3));
637 else {
638 $rule['startday'] = intval(substr($on, 5)); // Start from that day of the month
639 $rule['weekday'] = $days[substr($on, 0, 3)]; // Find the first such weekday
640 $rule['skipweeks'] = 0; // Don't skip any weeks.
643 else if(substr($on, 3, 2) == '<=') {
644 // e.g. Sun<=25
645 if(!isset($days[substr($on, 0, 3)])) {
646 trigger_error('Unknown <= weekday: '.substr($on, 0, 3));
648 else {
649 $rule['startday'] = -intval(substr($on, 5)); // Start from that day of the month; COUNT BACKWARDS (minus sign)
650 $rule['weekday'] = $days[substr($on, 0, 3)]; // Find the first such weekday
651 $rule['skipweeks'] = 0; // Don't skip any weeks.
654 else {
655 trigger_error('unknown on '.$on);
658 return $rule;
663 * olson_parse_at($at, $set, $gmtoffset)
665 * see `man zic`. This function translates
667 * 2 time in hours
668 * 2:00 time in hours and minutes
669 * 15:00 24-hour format time (for times after noon)
670 * 1:28:14 time in hours, minutes, and seconds
672 * Any of these forms may be followed by the letter w if the given
673 * time is local "wall clock" time, s if the given time is local
674 * "standard" time, or u (or g or z) if the given time is univer-
675 * sal time; in the absence of an indicator, wall clock time is
676 * assumed.
678 * @return string a moodle friendly $at, in GMT, which is what Moodle wants
682 function olson_parse_at ($at, $set = 'set', $gmtoffset) {
684 // find the time "signature";
685 $sig = '';
686 if (preg_match('/[ugzs]$/', $at, $matches)) {
687 $sig = $matches[0];
688 $at = substr($at, 0, strlen($at)-1); // chop
691 $at = (strpos($at, ':') === false) ? $at . ':0' : $at;
692 list($hours, $mins) = explode(':', $at);
694 // GMT -- return as is!
695 if ( !empty($sig) && ( $sig === 'u'
696 || $sig === 'g'
697 || $sig === 'z' )) {
698 return $at;
701 // Wall clock
702 if (empty($sig) || $sig === 'w') {
703 if ($set !== 'set'){ // wall clock is on DST, assume by 1hr
704 $hours = $hours-1;
706 $sig = 's';
709 // Standard time
710 if (!empty($sig) && $sig === 's') {
711 $mins = $mins + $hours*60 + $gmtoffset;
712 $hours = $mins / 60;
713 $hours = (int)$hours;
714 $mins = abs($mins % 60);
715 return sprintf('%02d:%02d', $hours, $mins);
718 trigger_error('unhandled case - AT flag is ' . $matches[0]);