Merge commit 'catalyst/MOODLE_19_STABLE' into mdl19-linuxchix
[moodle-linuxchix.git] / lib / olson.php
blob9b185e824916ba9732045643f08ce01e7a4529c3
1 <?php //$Id$
3 /***
4 *** olson_to_timezones ($filename)
5 ***
6 *** Parses the olson files for Zones and DST rules.
7 *** It updates the Moodle database with the Zones/DST rules
8 ***
9 *** Returns true/false
10 ***
12 function olson_to_timezones ($filename) {
14 $zones = olson_simple_zone_parser($filename);
15 $rules = olson_simple_rule_parser($filename);
17 $mdl_zones = array();
19 /**
20 *** To translate the combined Zone & Rule changes
21 *** in the Olson files to the Moodle single ruleset
22 *** format, we need to trasverse every year and see
23 *** if either the Zone or the relevant Rule has a
24 *** change. It's yuck but it yields a rationalized
25 *** set of data, which is arguably simpler.
26 ***
27 *** Also note that I am starting at the epoch (1970)
28 *** because I don't think we'll see many events scheduled
29 *** before that, anyway.
30 ***
31 **/
32 $maxyear = localtime(time(), true);
33 $maxyear = $maxyear['tm_year'] + 1900 + 10;
35 foreach ($zones as $zname => $zbyyear) { // loop over zones
36 /**
37 *** Loop over years, only adding a rule when zone or rule
38 *** have changed. All loops preserver the last seen vars
39 *** until there's an explicit decision to delete them
40 ***
41 **/
43 // clean the slate for a new zone
44 $zone = NULL;
45 $rule = NULL;
48 // Find the pre 1970 zone rule entries
50 for ($y = 1970 ; $y >= 0 ; $y--) {
51 if (array_key_exists((string)$y, $zbyyear )) { // we have a zone entry for the year
52 $zone = $zbyyear[$y];
53 //print_object("Zone $zname pre1970 is in $y\n");
54 break; // Perl's last -- get outta here
57 if (!empty($zone['rule']) && array_key_exists($zone['rule'], $rules)) {
58 $rule = NULL;
59 for ($y = 1970 ; $y > 0 ; $y--) {
60 if (array_key_exists((string)$y, $rules[$zone['rule']] )) { // we have a rule entry for the year
61 $rule = $rules[$zone['rule']][$y];
62 //print_object("Rule $rule[name] pre1970 is $y\n");
63 break; // Perl's last -- get outta here
67 if (empty($rule)) {
68 // Colombia and a few others refer to rules before they exist
69 // Perhaps we should comment out this warning...
70 // trigger_error("Cannot find rule in $zone[rule] <= 1970");
71 $rule = array();
73 } else {
74 // no DST this year!
75 $rule = array();
78 // Prepare to insert the base 1970 zone+rule
79 if (!empty($rule) && array_key_exists($zone['rule'], $rules)) {
80 // merge the two arrays into the moodle rule
81 unset($rule['name']); // warning: $rule must NOT be a reference!
82 unset($rule['year']);
83 $mdl_tz = array_merge($zone, $rule);
85 //fix (de)activate_time (AT) field to be GMT
86 $mdl_tz['dst_time'] = olson_parse_at($mdl_tz['dst_time'], 'set', $mdl_tz['gmtoff']);
87 $mdl_tz['std_time'] = olson_parse_at($mdl_tz['std_time'], 'reset', $mdl_tz['gmtoff']);
88 } else {
89 // just a simple zone
90 $mdl_tz = $zone;
91 // TODO: Add other default values here!
92 $mdl_tz['dstoff'] = 0;
95 // Fix the from year to 1970
96 $mdl_tz['year'] = 1970;
98 // add to the array
99 $mdl_zones[] = $mdl_tz;
100 //print_object("Zero entry for $zone[name] added");
102 $lasttimezone = $mdl_tz;
105 /// 1971 onwards
106 ///
107 for ($y = 1971; $y < $maxyear ; $y++) {
108 $changed = false;
110 /// We create a "zonerule" entry if either zone or rule change...
112 /// force $y to string to avoid PHP
113 /// thinking of a positional array
115 if (array_key_exists((string)$y, $zbyyear)) { // we have a zone entry for the year
116 $changed = true;
117 $zone = $zbyyear[(string)$y];
119 if (!empty($zone['rule']) && array_key_exists($zone['rule'], $rules)) {
120 if (array_key_exists((string)$y, $rules[$zone['rule']])) {
121 $changed = true;
122 $rule = $rules[$zone['rule']][(string)$y];
124 } else {
125 $rule = array();
128 if ($changed) {
129 //print_object("CHANGE YEAR $y Zone $zone[name] Rule $zone[rule]\n");
130 if (!empty($rule)) {
131 // merge the two arrays into the moodle rule
132 unset($rule['name']);
133 unset($rule['year']);
134 $mdl_tz = array_merge($zone, $rule);
136 // VERY IMPORTANT!!
137 $mdl_tz['year'] = $y;
139 //fix (de)activate_time (AT) field to be GMT
140 $mdl_tz['dst_time'] = olson_parse_at($mdl_tz['dst_time'], 'set', $mdl_tz['gmtoff']);
141 $mdl_tz['std_time'] = olson_parse_at($mdl_tz['std_time'], 'reset', $mdl_tz['gmtoff']);
142 } else {
143 // just a simple zone
144 $mdl_tz = $zone;
148 if(isset($mdl_tz['dst_time']) && !strpos($mdl_tz['dst_time'], ':') || isset($mdl_tz['std_time']) && !strpos($mdl_tz['std_time'], ':')) {
149 print_object($mdl_tz);
150 print_object('---');
153 // This is the simplest way to make the != operator just below NOT take the year into account
154 $lasttimezone['year'] = $mdl_tz['year'];
156 // If not a duplicate, add and update $lasttimezone
157 if($lasttimezone != $mdl_tz) {
158 $mdl_zones[] = $lasttimezone = $mdl_tz;
166 if (function_exists('memory_get_usage')) {
167 trigger_error("We are consuming this much memory: " . get_memory_usage());
171 /// Since Moodle 1.7, rule is tzrule in DB (reserved words problem), so change it here
172 /// after everything is calculated to be properly loaded to the timezone table.
173 /// Pre 1.7 users won't have the old rule if updating this from moodle.org but it
174 /// seems that such field isn't used at all by the rest of Moodle (at least I haven't
175 /// found any use when looking for it).
177 foreach($mdl_zones as $key=>$mdl_zone) {
178 $mdl_zones[$key]['tzrule'] = $mdl_zones[$key]['rule'];
181 return $mdl_zones;
185 /***
186 *** olson_simple_rule_parser($filename)
188 *** Parses the olson files for DST rules.
189 *** It's a simple implementation that simplifies some fields
191 *** Returns a multidimensional array, or false on error
194 function olson_simple_rule_parser ($filename) {
196 $file = fopen($filename, 'r', 0);
198 if (empty($file)) {
199 return false;
202 // determine the maximum year for this zone
203 $maxyear = array();
205 while ($line = fgets($file)) {
206 // only pay attention to rules lines
207 if(!preg_match('/^Rule\s/', $line)){
208 continue;
210 $line = preg_replace('/\n$/', '',$line); // chomp
211 $rule = preg_split('/\s+/', $line);
212 list($discard,
213 $name,
214 $from,
215 $to,
216 $type,
217 $in,
218 $on,
219 $at,
220 $save,
221 $letter) = $rule;
222 if (isset($maxyear[$name])) {
223 if ($maxyear[$name] < $from) {
224 $maxyear[$name] = $from;
226 } else {
227 $maxyear[$name] = $from;
232 fseek($file, 0);
234 $rules = array();
235 while ($line = fgets($file)) {
236 // only pay attention to rules lines
237 if(!preg_match('/^Rule\s/', $line)){
238 continue;
240 $line = preg_replace('/\n$/', '',$line); // chomp
241 $rule = preg_split('/\s+/', $line);
242 list($discard,
243 $name,
244 $from,
245 $to,
246 $type,
247 $in,
248 $on,
249 $at,
250 $save,
251 $letter) = $rule;
253 $srs = ($save === '0') ? 'reset' : 'set';
255 if($to == 'only') {
256 $to = $from;
258 else if($to == 'max') {
259 $to = $maxyear[$name];
262 for($i = $from; $i <= $to; ++$i) {
263 $rules[$name][$i][$srs] = $rule;
268 fclose($file);
270 $months = array('jan' => 1, 'feb' => 2,
271 'mar' => 3, 'apr' => 4,
272 'may' => 5, 'jun' => 6,
273 'jul' => 7, 'aug' => 8,
274 'sep' => 9, 'oct' => 10,
275 'nov' => 11, 'dec' => 12);
278 // now reformat it a bit to match Moodle's DST table
279 $moodle_rules = array();
280 foreach ($rules as $rule => $rulesbyyear) {
281 foreach ($rulesbyyear as $year => $rulesthisyear) {
283 if(!isset($rulesthisyear['reset'])) {
284 // No "reset" rule. We will assume that this is somewhere in the southern hemisphere
285 // after a period of not using DST, otherwise it doesn't make sense at all.
286 // With that assumption, we can put in a fake reset e.g. on Jan 1, 12:00.
288 print_object("no reset");
289 print_object($rules);
290 die();
292 $rulesthisyear['reset'] = array(
293 NULL, NULL, NULL, NULL, NULL, 'jan', 1, '12:00', '00:00', NULL
297 if(!isset($rulesthisyear['set'])) {
298 // No "set" rule. We will assume that this is somewhere in the southern hemisphere
299 // and that it begins a period of not using DST, otherwise it doesn't make sense at all.
300 // With that assumption, we can put in a fake set on Dec 31, 12:00, shifting time by 0 minutes.
301 $rulesthisyear['set'] = array(
302 NULL, $rulesthisyear['reset'][1], NULL, NULL, NULL, 'dec', 31, '12:00', '00:00', NULL
306 list($discard,
307 $name,
308 $from,
309 $to,
310 $type,
311 $in,
312 $on,
313 $at,
314 $save,
315 $letter) = $rulesthisyear['set'];
317 $moodle_rule = array();
319 // $save is sometimes just minutes
320 // and othertimes HH:MM -- only
321 // parse if relevant
322 if (!preg_match('/^\d+$/', $save)) {
323 list($hours, $mins) = explode(':', $save);
324 $save = $hours * 60 + $mins;
327 // we'll parse $at later
328 // $at = olson_parse_at($at);
329 $in = strtolower($in);
330 if(!isset($months[$in])) {
331 trigger_error('Unknown month: '.$in);
334 $moodle_rule['name'] = $name;
335 $moodle_rule['year'] = $year;
336 $moodle_rule['dstoff'] = $save; // time offset
338 $moodle_rule['dst_month'] = $months[$in]; // the month
339 $moodle_rule['dst_time'] = $at; // the time
341 // Encode index and day as per Moodle's specs
342 $on = olson_parse_on($on);
343 $moodle_rule['dst_startday'] = $on['startday'];
344 $moodle_rule['dst_weekday'] = $on['weekday'];
345 $moodle_rule['dst_skipweeks'] = $on['skipweeks'];
347 // and now the "deactivate" data
348 list($discard,
349 $name,
350 $from,
351 $to,
352 $type,
353 $in,
354 $on,
355 $at,
356 $save,
357 $letter) = $rulesthisyear['reset'];
359 // we'll parse $at later
360 // $at = olson_parse_at($at);
361 $in = strtolower($in);
362 if(!isset($months[$in])) {
363 trigger_error('Unknown month: '.$in);
366 $moodle_rule['std_month'] = $months[$in]; // the month
367 $moodle_rule['std_time'] = $at; // the time
369 // Encode index and day as per Moodle's specs
370 $on = olson_parse_on($on);
371 $moodle_rule['std_startday'] = $on['startday'];
372 $moodle_rule['std_weekday'] = $on['weekday'];
373 $moodle_rule['std_skipweeks'] = $on['skipweeks'];
375 $moodle_rules[$moodle_rule['name']][$moodle_rule['year']] = $moodle_rule;
376 //print_object($moodle_rule);
378 } // end foreach year within a rule
380 // completed with all the entries for this rule
381 // if the last entry has a TO other than 'max'
382 // then we have to deal with closing the last rule
383 //trigger_error("Rule $name ending to $to");
384 if (!empty($to) && $to !== 'max') {
385 // We can handle two cases for TO:
386 // a year, or "only"
387 $reset_rule = $moodle_rule;
388 $reset_rule['dstoff'] = '00';
389 if (preg_match('/^\d+$/', $to)){
390 $reset_rule['year'] = $to;
391 $moodle_rules[$reset_rule['name']][$reset_rule['year']] = $reset_rule;
392 } elseif ($to === 'only') {
393 $reset_rule['year'] = $reset_rule['year'] + 1;
394 $moodle_rules[$reset_rule['name']][$reset_rule['year']] = $reset_rule;
395 } else {
396 trigger_error("Strange value in TO $to rule field for rule $name");
399 } // end if $to is interesting
401 } // end foreach rule
403 return $moodle_rules;
406 /***
407 *** olson_simple_zone_parser($filename)
409 *** Parses the olson files for zone info
411 *** Returns a multidimensional array, or false on error
414 function olson_simple_zone_parser ($filename) {
416 $file = fopen($filename, 'r', 0);
418 if (empty($file)) {
419 return false;
422 $zones = array();
423 $lastzone = NULL;
425 while ($line = fgets($file)) {
426 // skip obvious non-zone lines
427 if (preg_match('/^#/', $line)) {
428 continue;
430 if (preg_match('/^(?:Rule|Link|Leap)/',$line)) {
431 $lastzone = NULL; // reset lastzone
432 continue;
435 // If there are blanks in the start of the line but the first non-ws character is a #,
436 // assume it's an "inline comment". The funny thing is that this happens only during
437 // the definition of the Rule for Europe/Athens.
438 if(substr(trim($line), 0, 1) == '#') {
439 continue;
442 /*** Notes
444 *** By splitting on space, we are only keeping the
445 *** year of the UNTIL field -- that's on purpose.
447 *** The Zone lines are followed by continuation lines
448 *** were we reuse the info from the last one seen.
450 *** We are transforming "until" fields into "from" fields
451 *** which make more sense from the Moodle perspective, so
452 *** each initial Zone entry is "from" the year 0, and for the
453 *** continuation lines, we shift the "until" from the previous field
454 *** into this line's "from".
456 *** If a RULES field contains a time instead of a rule we discard it
457 *** I have no idea of how to create a DST rule out of that
458 *** (what are the start/end times?)
460 *** We remove "until" from the data we keep, but preserve
461 *** it in $lastzone.
463 if (preg_match('/^Zone/', $line)) { // a new zone
464 $line = trim($line);
465 $line = preg_split('/\s+/', $line);
466 $zone = array();
467 list( $discard, // 'Zone'
468 $zone['name'],
469 $zone['gmtoff'],
470 $zone['rule'],
471 $discard // format
472 ) = $line;
473 // the things we do to avoid warnings
474 if (!empty($line[5])) {
475 $zone['until'] = $line[5];
477 $zone['year'] = '0';
479 $zones[$zone['name']] = array();
481 } else if (!empty($lastzone) && preg_match('/^\s+/', $line)){
482 // looks like a credible continuation line
483 $line = trim($line);
484 $line = preg_split('/\s+/', $line);
485 if (count($line) < 3) {
486 $lastzone = NULL;
487 continue;
489 // retrieve info from the lastzone
490 $zone = $lastzone;
491 $zone['year'] = $zone['until'];
492 // overwrite with current data
493 list(
494 $zone['gmtoff'],
495 $zone['rule'],
496 $discard // format
497 ) = $line;
498 // the things we do to avoid warnings
499 if (!empty($line[3])) {
500 $zone['until'] = $line[3];
503 } else {
504 $lastzone = NULL;
505 continue;
508 // tidy up, we're done
509 // perhaps we should insert in the DB at this stage?
510 $lastzone = $zone;
511 unset($zone['until']);
512 $zone['gmtoff'] = olson_parse_offset($zone['gmtoff']);
513 if ($zone['rule'] === '-') { // cleanup empty rules
514 $zone['rule'] = '';
516 if (preg_match('/:/',$zone['rule'])) {
517 // we are not handling direct SAVE rules here
518 // discard it
519 $zone['rule'] = '';
522 $zones[$zone['name']][(string)$zone['year']] = $zone;
525 return $zones;
528 /***
529 *** olson_parse_offset($offset)
531 *** parses time offsets from the GMTOFF and SAVE
532 *** fields into +/-MINUTES
534 function olson_parse_offset ($offset) {
535 $offset = trim($offset);
537 // perhaps it's just minutes
538 if (preg_match('/^(-?)(\d*)$/', $offset)) {
539 return intval($offset);
541 // (-)hours:minutes(:seconds)
542 if (preg_match('/^(-?)(\d*):(\d+)/', $offset, $matches)) {
543 // we are happy to discard the seconds
544 $sign = $matches[1];
545 $hours = intval($matches[2]);
546 $seconds = intval($matches[3]);
547 $offset = $sign . ($hours*60 + $seconds);
548 return intval($offset);
551 trigger_error('Strange time format in olson_parse_offset() ' .$offset);
552 return 0;
557 /***
558 *** olson_parse_on_($on)
560 *** see `man zic`. This function translates the following formats
561 *** 5 the fifth of the month
562 *** lastSun the last Sunday in the month
563 *** lastMon the last Monday in the month
564 *** Sun>=8 first Sunday on or after the eighth
565 *** Sun<=25 last Sunday on or before the 25th
567 *** to a moodle friendly format. Returns an array with:
569 *** startday: the day of the month that we start counting from.
570 *** if negative, it means we start from that day and
571 *** count backwards. since -1 would be meaningless,
572 *** it means "end of month and backwards".
573 *** weekday: the day of the week that we must find. we will
574 *** scan days from the startday until we find the
575 *** first such weekday. 0...6 = Sun...Sat.
576 *** -1 means that any day of the week will do,
577 *** effectively ending the search on startday.
578 *** skipweeks:after finding our end day as outlined above,
579 *** skip this many weeks. this enables us to find
580 *** "the second sunday >= 10". usually will be 0.
582 function olson_parse_on ($on) {
584 $rule = array();
585 $days = array('sun' => 0, 'mon' => 1,
586 'tue' => 2, 'wed' => 3,
587 'thu' => 4, 'fri' => 5,
588 'sat' => 6);
590 if(is_numeric($on)) {
591 $rule['startday'] = intval($on); // Start searching from that day
592 $rule['weekday'] = -1; // ...and stop there, no matter what weekday
593 $rule['skipweeks'] = 0; // Don't skip any weeks.
595 else {
596 $on = strtolower($on);
597 if(substr($on, 0, 4) == 'last') {
598 // e.g. lastSun
599 if(!isset($days[substr($on, 4)])) {
600 trigger_error('Unknown last weekday: '.substr($on, 4));
602 else {
603 $rule['startday'] = -1; // Start from end of month
604 $rule['weekday'] = $days[substr($on, 4)]; // Find the first such weekday
605 $rule['skipweeks'] = 0; // Don't skip any weeks.
608 else if(substr($on, 3, 2) == '>=') {
609 // e.g. Sun>=8
610 if(!isset($days[substr($on, 0, 3)])) {
611 trigger_error('Unknown >= weekday: '.substr($on, 0, 3));
613 else {
614 $rule['startday'] = intval(substr($on, 5)); // Start from that day of the month
615 $rule['weekday'] = $days[substr($on, 0, 3)]; // Find the first such weekday
616 $rule['skipweeks'] = 0; // Don't skip any weeks.
619 else if(substr($on, 3, 2) == '<=') {
620 // e.g. Sun<=25
621 if(!isset($days[substr($on, 0, 3)])) {
622 trigger_error('Unknown <= weekday: '.substr($on, 0, 3));
624 else {
625 $rule['startday'] = -intval(substr($on, 5)); // Start from that day of the month; COUNT BACKWARDS (minus sign)
626 $rule['weekday'] = $days[substr($on, 0, 3)]; // Find the first such weekday
627 $rule['skipweeks'] = 0; // Don't skip any weeks.
630 else {
631 trigger_error('unknown on '.$on);
634 return $rule;
638 /***
639 *** olson_parse_at($at, $set, $gmtoffset)
641 *** see `man zic`. This function translates
643 *** 2 time in hours
644 *** 2:00 time in hours and minutes
645 *** 15:00 24-hour format time (for times after noon)
646 *** 1:28:14 time in hours, minutes, and seconds
648 *** Any of these forms may be followed by the letter w if the given
649 *** time is local "wall clock" time, s if the given time is local
650 *** "standard" time, or u (or g or z) if the given time is univer-
651 *** sal time; in the absence of an indicator, wall clock time is
652 *** assumed.
654 *** returns a moodle friendly $at, in GMT, which is what Moodle wants
656 ***
658 function olson_parse_at ($at, $set = 'set', $gmtoffset) {
660 // find the time "signature";
661 $sig = '';
662 if (preg_match('/[ugzs]$/', $at, $matches)) {
663 $sig = $matches[0];
664 $at = substr($at, 0, strlen($at)-1); // chop
667 list($hours, $mins) = explode(':', $at);
669 // GMT -- return as is!
670 if ( !empty($sig) && ( $sig === 'u'
671 || $sig === 'g'
672 || $sig === 'z' )) {
673 return $at;
676 // Wall clock
677 if (empty($sig) || $sig === 'w') {
678 if ($set !== 'set'){ // wall clock is on DST, assume by 1hr
679 $hours = $hours-1;
681 $sig = 's';
684 // Standard time
685 if (!empty($sig) && $sig === 's') {
686 $mins = $mins + $hours*60 + $gmtoffset;
687 $hours = $mins / 60;
688 $hours = (int)$hours;
689 $mins = abs($mins % 60);
690 return sprintf('%02d:%02d', $hours, $mins);
693 trigger_error('unhandled case - AT flag is ' . $matches[0]);