Merge branch '45699-29' of git://github.com/samhemelryk/moodle
[moodle.git] / lib / olson.php
blob2150f86f3d6b334bfbfc8122c423ccb3b0c8e135
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 // Look for zone and rule information up to 10 years in the future.
37 $maxyear = localtime(time(), true);
38 $maxyear = $maxyear['tm_year'] + 1900 + 10;
40 $zones = olson_simple_zone_parser($filename, $maxyear);
41 $rules = olson_simple_rule_parser($filename, $maxyear);
43 $mdl_zones = array();
45 /**
46 *** To translate the combined Zone & Rule changes
47 *** in the Olson files to the Moodle single ruleset
48 *** format, we need to trasverse every year and see
49 *** if either the Zone or the relevant Rule has a
50 *** change. It's yuck but it yields a rationalized
51 *** set of data, which is arguably simpler.
52 ***
53 *** Also note that I am starting at the epoch (1970)
54 *** because I don't think we'll see many events scheduled
55 *** before that, anyway.
56 ***
57 **/
59 foreach ($zones as $zname => $zbyyear) { // loop over zones
60 /**
61 *** Loop over years, only adding a rule when zone or rule
62 *** have changed. All loops preserver the last seen vars
63 *** until there's an explicit decision to delete them
64 ***
65 **/
67 // clean the slate for a new zone
68 $zone = NULL;
69 $rule = NULL;
72 // Find the pre 1970 zone rule entries
74 for ($y = 1970 ; $y >= 0 ; $y--) {
75 if (array_key_exists((string)$y, $zbyyear )) { // we have a zone entry for the year
76 $zone = $zbyyear[$y];
77 //print_object("Zone $zname pre1970 is in $y\n");
78 break; // Perl's last -- get outta here
81 if (!empty($zone['rule']) && array_key_exists($zone['rule'], $rules)) {
82 $rule = NULL;
83 for ($y = 1970 ; $y > 0 ; $y--) {
84 if (array_key_exists((string)$y, $rules[$zone['rule']] )) { // we have a rule entry for the year
85 $rule = $rules[$zone['rule']][$y];
86 //print_object("Rule $rule[name] pre1970 is $y\n");
87 break; // Perl's last -- get outta here
91 if (empty($rule)) {
92 // Colombia and a few others refer to rules before they exist
93 // Perhaps we should comment out this warning...
94 // trigger_error("Cannot find rule in $zone[rule] <= 1970");
95 $rule = array();
97 } else {
98 // no DST this year!
99 $rule = array();
102 // Prepare to insert the base 1970 zone+rule
103 if (!empty($rule) && array_key_exists($zone['rule'], $rules)) {
104 // merge the two arrays into the moodle rule
105 unset($rule['name']); // warning: $rule must NOT be a reference!
106 unset($rule['year']);
107 $mdl_tz = array_merge($zone, $rule);
109 //fix (de)activate_time (AT) field to be GMT
110 $mdl_tz['dst_time'] = olson_parse_at($mdl_tz['dst_time'], 'set', $mdl_tz['gmtoff']);
111 $mdl_tz['std_time'] = olson_parse_at($mdl_tz['std_time'], 'reset', $mdl_tz['gmtoff']);
112 } else {
113 // just a simple zone
114 $mdl_tz = $zone;
115 // TODO: Add other default values here!
116 $mdl_tz['dstoff'] = 0;
119 // Fix the from year to 1970
120 $mdl_tz['year'] = 1970;
122 // add to the array
123 $mdl_zones[] = $mdl_tz;
124 //print_object("Zero entry for $zone[name] added");
126 $lasttimezone = $mdl_tz;
129 /// 1971 onwards
131 for ($y = 1971; $y < $maxyear ; $y++) {
132 $changed = false;
134 /// We create a "zonerule" entry if either zone or rule change...
136 /// force $y to string to avoid PHP
137 /// thinking of a positional array
139 if (array_key_exists((string)$y, $zbyyear)) { // we have a zone entry for the year
140 $changed = true;
141 $zone = $zbyyear[(string)$y];
143 if (!empty($zone['rule']) && array_key_exists($zone['rule'], $rules)) {
144 if (array_key_exists((string)$y, $rules[$zone['rule']])) {
145 $changed = true;
146 $rule = $rules[$zone['rule']][(string)$y];
148 } else {
149 $rule = array();
152 if ($changed) {
153 //print_object("CHANGE YEAR $y Zone $zone[name] Rule $zone[rule]\n");
154 if (!empty($rule)) {
155 // merge the two arrays into the moodle rule
156 unset($rule['name']);
157 unset($rule['year']);
158 $mdl_tz = array_merge($zone, $rule);
160 // VERY IMPORTANT!!
161 $mdl_tz['year'] = $y;
163 //fix (de)activate_time (AT) field to be GMT
164 $mdl_tz['dst_time'] = olson_parse_at($mdl_tz['dst_time'], 'set', $mdl_tz['gmtoff']);
165 $mdl_tz['std_time'] = olson_parse_at($mdl_tz['std_time'], 'reset', $mdl_tz['gmtoff']);
166 } else {
167 // just a simple zone
168 $mdl_tz = $zone;
172 if(isset($mdl_tz['dst_time']) && !strpos($mdl_tz['dst_time'], ':') || isset($mdl_tz['std_time']) && !strpos($mdl_tz['std_time'], ':')) {
173 print_object($mdl_tz);
174 print_object('---');
177 // This is the simplest way to make the != operator just below NOT take the year into account
178 $lasttimezone['year'] = $mdl_tz['year'];
180 // If not a duplicate, add and update $lasttimezone
181 if($lasttimezone != $mdl_tz) {
182 $mdl_zones[] = $lasttimezone = $mdl_tz;
190 if (function_exists('memory_get_usage')) {
191 trigger_error("We are consuming this much memory: " . get_memory_usage());
195 /// Since Moodle 1.7, rule is tzrule in DB (reserved words problem), so change it here
196 /// after everything is calculated to be properly loaded to the timezone table.
197 /// Pre 1.7 users won't have the old rule if updating this from moodle.org but it
198 /// seems that such field isn't used at all by the rest of Moodle (at least I haven't
199 /// found any use when looking for it).
201 foreach($mdl_zones as $key=>$mdl_zone) {
202 $mdl_zones[$key]['tzrule'] = $mdl_zones[$key]['rule'];
205 return $mdl_zones;
210 * olson_simple_rule_parser($filename)
212 * Parses the olson files for DST rules.
213 * It's a simple implementation that simplifies some fields
215 * @return array a multidimensional array, or false on error
218 function olson_simple_rule_parser($filename, $maxyear) {
220 $file = fopen($filename, 'r', 0);
222 if (empty($file)) {
223 return false;
226 while ($line = fgets($file)) {
227 // only pay attention to rules lines
228 if(!preg_match('/^Rule\s/', $line)){
229 continue;
231 $line = preg_replace('/\n$/', '',$line); // chomp
232 $rule = preg_split('/\s+/', $line);
233 list($discard,
234 $name,
235 $from,
236 $to,
237 $type,
238 $in,
239 $on,
240 $at,
241 $save,
242 $letter) = $rule;
245 fseek($file, 0);
247 $rules = array();
248 while ($line = fgets($file)) {
249 // only pay attention to rules lines
250 if(!preg_match('/^Rule\s/', $line)){
251 continue;
253 $line = preg_replace('/\n$/', '',$line); // chomp
254 $rule = preg_split('/\s+/', $line);
255 list($discard,
256 $name,
257 $from,
258 $to,
259 $type,
260 $in,
261 $on,
262 $at,
263 $save,
264 $letter) = $rule;
266 $srs = ($save === '0') ? 'reset' : 'set';
268 if($to == 'only') {
269 $to = $from;
271 else if($to == 'max') {
272 $to = $maxyear;
275 for($i = $from; $i <= $to; ++$i) {
276 $rules[$name][$i][$srs] = $rule;
281 fclose($file);
283 $months = array('jan' => 1, 'feb' => 2,
284 'mar' => 3, 'apr' => 4,
285 'may' => 5, 'jun' => 6,
286 'jul' => 7, 'aug' => 8,
287 'sep' => 9, 'oct' => 10,
288 'nov' => 11, 'dec' => 12);
291 // now reformat it a bit to match Moodle's DST table
292 $moodle_rules = array();
293 foreach ($rules as $rule => $rulesbyyear) {
294 foreach ($rulesbyyear as $year => $rulesthisyear) {
296 if(!isset($rulesthisyear['reset'])) {
297 // No "reset" rule. We will assume that this is somewhere in the southern hemisphere
298 // after a period of not using DST, otherwise it doesn't make sense at all.
299 // With that assumption, we can put in a fake reset e.g. on Jan 1, 12:00.
301 print_object("no reset");
302 print_object($rules);
303 die();
305 $rulesthisyear['reset'] = array(
306 NULL, NULL, NULL, NULL, NULL, 'jan', 1, '12:00', '00:00', NULL
310 if(!isset($rulesthisyear['set'])) {
311 // No "set" rule. We will assume that this is somewhere in the southern hemisphere
312 // and that it begins a period of not using DST, otherwise it doesn't make sense at all.
313 // With that assumption, we can put in a fake set on Dec 31, 12:00, shifting time by 0 minutes.
314 $rulesthisyear['set'] = array(
315 NULL, $rulesthisyear['reset'][1], NULL, NULL, NULL, 'dec', 31, '12:00', '00:00', NULL
319 list($discard,
320 $name,
321 $from,
322 $to,
323 $type,
324 $in,
325 $on,
326 $at,
327 $save,
328 $letter) = $rulesthisyear['set'];
330 $moodle_rule = array();
332 // $save is sometimes just minutes
333 // and othertimes HH:MM -- only
334 // parse if relevant
335 if (!preg_match('/^\d+$/', $save)) {
336 list($hours, $mins) = explode(':', $save);
337 $save = $hours * 60 + $mins;
340 // we'll parse $at later
341 // $at = olson_parse_at($at);
342 $in = strtolower($in);
343 if(!isset($months[$in])) {
344 trigger_error('Unknown month: '.$in);
347 $moodle_rule['name'] = $name;
348 $moodle_rule['year'] = $year;
349 $moodle_rule['dstoff'] = $save; // time offset
351 $moodle_rule['dst_month'] = $months[$in]; // the month
352 $moodle_rule['dst_time'] = $at; // the time
354 // Encode index and day as per Moodle's specs
355 $on = olson_parse_on($on);
356 $moodle_rule['dst_startday'] = $on['startday'];
357 $moodle_rule['dst_weekday'] = $on['weekday'];
358 $moodle_rule['dst_skipweeks'] = $on['skipweeks'];
360 // and now the "deactivate" data
361 list($discard,
362 $name,
363 $from,
364 $to,
365 $type,
366 $in,
367 $on,
368 $at,
369 $save,
370 $letter) = $rulesthisyear['reset'];
372 // we'll parse $at later
373 // $at = olson_parse_at($at);
374 $in = strtolower($in);
375 if(!isset($months[$in])) {
376 trigger_error('Unknown month: '.$in);
379 $moodle_rule['std_month'] = $months[$in]; // the month
380 $moodle_rule['std_time'] = $at; // the time
382 // Encode index and day as per Moodle's specs
383 $on = olson_parse_on($on);
384 $moodle_rule['std_startday'] = $on['startday'];
385 $moodle_rule['std_weekday'] = $on['weekday'];
386 $moodle_rule['std_skipweeks'] = $on['skipweeks'];
388 $moodle_rules[$moodle_rule['name']][$moodle_rule['year']] = $moodle_rule;
389 //print_object($moodle_rule);
391 } // end foreach year within a rule
393 // completed with all the entries for this rule
394 // if the last entry has a TO other than 'max'
395 // then we have to deal with closing the last rule
396 //trigger_error("Rule $name ending to $to");
397 if (!empty($to) && $to !== 'max') {
398 // We can handle two cases for TO:
399 // a year, or "only"
400 $reset_rule = $moodle_rule;
401 $reset_rule['dstoff'] = '00';
402 if (preg_match('/^\d+$/', $to)){
403 $reset_rule['year'] = $to;
404 $moodle_rules[$reset_rule['name']][$reset_rule['year']] = $reset_rule;
405 } elseif ($to === 'only') {
406 $reset_rule['year'] = $reset_rule['year'] + 1;
407 $moodle_rules[$reset_rule['name']][$reset_rule['year']] = $reset_rule;
408 } else {
409 trigger_error("Strange value in TO $to rule field for rule $name");
412 } // end if $to is interesting
414 } // end foreach rule
416 return $moodle_rules;
420 * olson_simple_zone_parser($filename)
422 * Parses the olson files for zone info
424 * @return array a multidimensional array, or false on error
427 function olson_simple_zone_parser($filename, $maxyear) {
429 $file = fopen($filename, 'r', 0);
431 if (empty($file)) {
432 return false;
435 $zones = array();
436 $lastzone = NULL;
438 while ($line = fgets($file)) {
439 // skip obvious non-zone lines
440 if (preg_match('/^#/', $line)) {
441 continue;
443 if (preg_match('/^(?:Rule|Link|Leap)/',$line)) {
444 $lastzone = NULL; // reset lastzone
445 continue;
448 // If there are blanks in the start of the line but the first non-ws character is a #,
449 // assume it's an "inline comment". The funny thing is that this happens only during
450 // the definition of the Rule for Europe/Athens.
451 if(substr(trim($line), 0, 1) == '#') {
452 continue;
455 /*** Notes
457 *** By splitting on space, we are only keeping the
458 *** year of the UNTIL field -- that's on purpose.
460 *** The Zone lines are followed by continuation lines
461 *** were we reuse the info from the last one seen.
463 *** We are transforming "until" fields into "from" fields
464 *** which make more sense from the Moodle perspective, so
465 *** each initial Zone entry is "from" the year 0, and for the
466 *** continuation lines, we shift the "until" from the previous field
467 *** into this line's "from".
469 *** If a RULES field contains a time instead of a rule we discard it
470 *** I have no idea of how to create a DST rule out of that
471 *** (what are the start/end times?)
473 *** We remove "until" from the data we keep, but preserve
474 *** it in $lastzone.
476 if (preg_match('/^Zone/', $line)) { // a new zone
477 $line = trim($line);
478 $line = preg_split('/\s+/', $line);
479 $zone = array();
480 list( $discard, // 'Zone'
481 $zone['name'],
482 $zone['gmtoff'],
483 $zone['rule'],
484 $discard // format
485 ) = $line;
486 // the things we do to avoid warnings
487 if (!empty($line[5])) {
488 $zone['until'] = $line[5];
490 $zone['year'] = '0';
492 $zones[$zone['name']] = array();
494 } else if (!empty($lastzone) && preg_match('/^\s+/', $line)){
495 // looks like a credible continuation line
496 $line = trim($line);
497 $line = preg_split('/\s+/', $line);
498 if (count($line) < 3) {
499 $lastzone = NULL;
500 continue;
502 // retrieve info from the lastzone
503 $zone = $lastzone;
504 $zone['year'] = $zone['until'];
505 // overwrite with current data
506 list(
507 $zone['gmtoff'],
508 $zone['rule'],
509 $discard // format
510 ) = $line;
511 // the things we do to avoid warnings
512 if (!empty($line[3])) {
513 $zone['until'] = $line[3];
516 } else {
517 $lastzone = NULL;
518 continue;
521 // tidy up, we're done
522 // perhaps we should insert in the DB at this stage?
523 $lastzone = $zone;
524 unset($zone['until']);
525 $zone['gmtoff'] = olson_parse_offset($zone['gmtoff']);
526 if ($zone['rule'] === '-') { // cleanup empty rules
527 $zone['rule'] = '';
529 if (preg_match('/:/',$zone['rule'])) {
530 // we are not handling direct SAVE rules here
531 // discard it
532 $zone['rule'] = '';
535 $zones[$zone['name']][(string)$zone['year']] = $zone;
538 return $zones;
542 * olson_parse_offset($offset)
544 * parses time offsets from the GMTOFF and SAVE
545 * fields into +/-MINUTES
547 * @return int
549 function olson_parse_offset ($offset) {
550 $offset = trim($offset);
552 // perhaps it's just minutes
553 if (preg_match('/^(-?)(\d*)$/', $offset)) {
554 return intval($offset);
556 // (-)hours:minutes(:seconds)
557 if (preg_match('/^(-?)(\d*):(\d+)/', $offset, $matches)) {
558 // we are happy to discard the seconds
559 $sign = $matches[1];
560 $hours = intval($matches[2]);
561 $seconds = intval($matches[3]);
562 $offset = $sign . ($hours*60 + $seconds);
563 return intval($offset);
566 trigger_error('Strange time format in olson_parse_offset() ' .$offset);
567 return 0;
573 * olson_parse_on_($on)
575 * see `man zic`. This function translates the following formats
576 * 5 the fifth of the month
577 * lastSun the last Sunday in the month
578 * lastMon the last Monday in the month
579 * Sun>=8 first Sunday on or after the eighth
580 * Sun<=25 last Sunday on or before the 25th
582 * to a moodle friendly format. Returns an array with:
584 * startday: the day of the month that we start counting from.
585 * if negative, it means we start from that day and
586 * count backwards. since -1 would be meaningless,
587 * it means "end of month and backwards".
588 * weekday: the day of the week that we must find. we will
589 * scan days from the startday until we find the
590 * first such weekday. 0...6 = Sun...Sat.
591 * -1 means that any day of the week will do,
592 * effectively ending the search on startday.
593 * skipweeks:after finding our end day as outlined above,
594 * skip this many weeks. this enables us to find
595 * "the second sunday >= 10". usually will be 0.
597 function olson_parse_on ($on) {
599 $rule = array();
600 $days = array('sun' => 0, 'mon' => 1,
601 'tue' => 2, 'wed' => 3,
602 'thu' => 4, 'fri' => 5,
603 'sat' => 6);
605 if(is_numeric($on)) {
606 $rule['startday'] = intval($on); // Start searching from that day
607 $rule['weekday'] = -1; // ...and stop there, no matter what weekday
608 $rule['skipweeks'] = 0; // Don't skip any weeks.
610 else {
611 $on = strtolower($on);
612 if(substr($on, 0, 4) == 'last') {
613 // e.g. lastSun
614 if(!isset($days[substr($on, 4)])) {
615 trigger_error('Unknown last weekday: '.substr($on, 4));
617 else {
618 $rule['startday'] = -1; // Start from end of month
619 $rule['weekday'] = $days[substr($on, 4)]; // Find the first such weekday
620 $rule['skipweeks'] = 0; // Don't skip any weeks.
623 else if(substr($on, 3, 2) == '>=') {
624 // e.g. Sun>=8
625 if(!isset($days[substr($on, 0, 3)])) {
626 trigger_error('Unknown >= weekday: '.substr($on, 0, 3));
628 else {
629 $rule['startday'] = intval(substr($on, 5)); // Start from that day of the month
630 $rule['weekday'] = $days[substr($on, 0, 3)]; // Find the first such weekday
631 $rule['skipweeks'] = 0; // Don't skip any weeks.
634 else if(substr($on, 3, 2) == '<=') {
635 // e.g. Sun<=25
636 if(!isset($days[substr($on, 0, 3)])) {
637 trigger_error('Unknown <= weekday: '.substr($on, 0, 3));
639 else {
640 $rule['startday'] = -intval(substr($on, 5)); // Start from that day of the month; COUNT BACKWARDS (minus sign)
641 $rule['weekday'] = $days[substr($on, 0, 3)]; // Find the first such weekday
642 $rule['skipweeks'] = 0; // Don't skip any weeks.
645 else {
646 trigger_error('unknown on '.$on);
649 return $rule;
654 * olson_parse_at($at, $set, $gmtoffset)
656 * see `man zic`. This function translates
658 * 2 time in hours
659 * 2:00 time in hours and minutes
660 * 15:00 24-hour format time (for times after noon)
661 * 1:28:14 time in hours, minutes, and seconds
663 * Any of these forms may be followed by the letter w if the given
664 * time is local "wall clock" time, s if the given time is local
665 * "standard" time, or u (or g or z) if the given time is univer-
666 * sal time; in the absence of an indicator, wall clock time is
667 * assumed.
669 * @return string a moodle friendly $at, in GMT, which is what Moodle wants
673 function olson_parse_at ($at, $set = 'set', $gmtoffset) {
675 // find the time "signature";
676 $sig = '';
677 if (preg_match('/[ugzs]$/', $at, $matches)) {
678 $sig = $matches[0];
679 $at = substr($at, 0, strlen($at)-1); // chop
682 $at = (strpos($at, ':') === false) ? $at . ':0' : $at;
683 list($hours, $mins) = explode(':', $at);
685 // GMT -- return as is!
686 if ( !empty($sig) && ( $sig === 'u'
687 || $sig === 'g'
688 || $sig === 'z' )) {
689 return $at;
692 // Wall clock
693 if (empty($sig) || $sig === 'w') {
694 if ($set !== 'set'){ // wall clock is on DST, assume by 1hr
695 $hours = $hours-1;
697 $sig = 's';
700 // Standard time
701 if (!empty($sig) && $sig === 's') {
702 $mins = $mins + $hours*60 + $gmtoffset;
703 $hours = $mins / 60;
704 $hours = (int)$hours;
705 $mins = abs($mins % 60);
706 return sprintf('%02d:%02d', $hours, $mins);
709 trigger_error('unhandled case - AT flag is ' . $matches[0]);