| [ Index ] |
PHP Cross Reference of Drupal 6 (yi-drupal) |
[Summary view] [Print] [Text view]
1 <?php 2 /** 3 * @file 4 * Code to compute the dates that match an iCal RRULE. 5 * 6 * Moved to a separate file since it is not used on most pages 7 * so the code is not parsed unless needed. 8 * 9 * Extensive simpletests have been created to test the RRULE calculation 10 * results against official examples from RFC 2445. 11 * 12 * These calculations are expensive and results should be stored or cached 13 * so the calculation code is not called more often than necessary. 14 * 15 * Currently implemented: 16 * INTERVAL, UNTIL, COUNT, EXDATE, RDATE, BYDAY, BYMONTHDAY, BYMONTH, 17 * YEARLY, MONTHLY, WEEKLY, DAILY 18 * 19 * Currently not implemented: 20 * 21 * BYYEARDAY, MINUTELY, HOURLY, SECONDLY, BYMINUTE, BYHOUR, BYSECOND 22 * These could be implemented in the future. 23 * 24 * BYSETPOS 25 * Seldom used anywhere, so no reason to complicated the code. 26 */ 27 28 /** 29 * Private implementation of date_repeat_calc(). 30 * 31 * Compute dates that match the requested rule, within a specified date range. 32 */ 33 function _date_repeat_calc($rrule, $start, $end, $exceptions, $timezone, $additions) { 34 module_load_include('inc', 'date_api', 'date_api_ical'); 35 36 if (empty($timezone)) { 37 $timezone = date_default_timezone_name(); 38 } 39 40 // Make sure the 'EXCEPTIONS' string isn't appended to the rule. 41 $parts = explode("\n", $rrule); 42 if (count($parts)) { 43 $rrule = $parts[0]; 44 } 45 // Get the parsed array of rule values. 46 $rrule = date_ical_parse_rrule('RRULE:', $rrule); 47 48 // These default values indicate there is no RRULE here. 49 if ($rrule['FREQ'] == 'NONE' || (isset($rrule['INTERVAL']) && $rrule['INTERVAL'] == 0)) { 50 return array(); 51 } 52 53 // Create a date object for the start and end dates. 54 $start_date = date_make_date($start, $timezone); 55 56 // Versions of PHP greater than PHP 5.3.5 require that we set an explicit time when 57 // using date_modify() or the time may not match the original value. Adding this 58 // modifier gives us the same results in both older and newer versions of PHP. 59 $modify_time = ' ' . $start_date->format('g:ia'); 60 61 // If the rule has an UNTIL, see if that is earlier than the end date. 62 if (!empty($rrule['UNTIL'])) { 63 $end_date = date_make_date($end, $timezone); 64 $until_date = date_ical_date($rrule['UNTIL'], $timezone); 65 if (date_format($until_date, 'U') < date_format($end_date, 'U')) { 66 $end_date = $until_date; 67 } 68 } 69 // The only valid option for an empty end date is when we have a count. 70 elseif (empty($end)) { 71 if (!empty($rrule['COUNT'])) { 72 $end_date = NULL; 73 } 74 else { 75 return array(); 76 } 77 } 78 else { 79 $end_date = date_make_date($end, $timezone); 80 } 81 82 // Get an integer value for the interval, if none given, '1' is implied. 83 if (empty($rrule['INTERVAL'])) { 84 $rrule['INTERVAL'] = 1; 85 } 86 $interval = max(1, $rrule['INTERVAL']); 87 $count = isset($rrule['COUNT']) ? $rrule['COUNT'] : NULL; 88 89 if (empty($rrule['FREQ'])) { 90 $rrule['FREQ'] = 'DAILY'; 91 } 92 93 // Make sure DAILY frequency isn't used in places it won't work; 94 if (!empty($rrule['BYMONTHDAY']) && !in_array($rrule['FREQ'], array('MONTHLY', 'YEARLY'))) { 95 $rrule['FREQ'] = 'MONTHLY'; 96 } 97 elseif (!empty($rrule['BYDAY']) && !in_array($rrule['FREQ'], array('MONTHLY', 'WEEKLY', 'YEARLY'))) { 98 $rrule['FREQ'] = 'WEEKLY'; 99 } 100 101 // Find the time period to jump forward between dates. 102 switch ($rrule['FREQ']) { 103 case 'DAILY': 104 $jump = $interval . ' days'; 105 break; 106 case 'WEEKLY': 107 $jump = $interval . ' weeks'; 108 break; 109 case 'MONTHLY': 110 $jump = $interval . ' months'; 111 break; 112 case 'YEARLY': 113 $jump = $interval . ' years'; 114 break; 115 } 116 $rrule = date_repeat_adjust_rrule($rrule, $start_date); 117 118 // The start date always goes into the results, whether or not it meets 119 // the rules. RFC 2445 includes examples where the start date DOES NOT 120 // meet the rules, but the expected results always include the start date. 121 $days = array(date_format($start_date, DATE_FORMAT_DATETIME)); 122 123 // BYMONTHDAY will look for specific days of the month in one or more months. 124 // This process is only valid when frequency is monthly or yearly. 125 126 if (!empty($rrule['BYMONTHDAY'])) { 127 $finished = FALSE; 128 $current_day = drupal_clone($start_date); 129 $direction_days = array(); 130 // Deconstruct the day in case it has a negative modifier. 131 foreach ($rrule['BYMONTHDAY'] as $day) { 132 preg_match("@(-)?([0-9]{1,2})@", $day, $regs); 133 if (!empty($regs[2])) { 134 // Convert parameters into full day name, count, and direction. 135 $direction_days[$day] = array( 136 'direction' => !empty($regs[1]) ? $regs[1] : '+', 137 'direction_count' => $regs[2], 138 ); 139 } 140 } 141 while (!$finished) { 142 $period_finished = FALSE; 143 while (!$period_finished) { 144 foreach ($rrule['BYMONTHDAY'] as $monthday) { 145 $day = $direction_days[$monthday]; 146 $current_day = date_repeat_set_month_day($current_day, NULL, $day['direction_count'], $day['direction'], $timezone, $modify_time); 147 date_repeat_add_dates($days, $current_day, $start_date, $end_date, $exceptions, $rrule); 148 if ($finished = date_repeat_is_finished($current_day, $days, $count, $end_date)) { 149 $period_finished = TRUE; 150 } 151 } 152 // If it's monthly, keep looping through months, one INTERVAL at a time. 153 if ($rrule['FREQ'] == 'MONTHLY') { 154 if ($finished = date_repeat_is_finished($current_day, $days, $count, $end_date)) { 155 $period_finished = TRUE; 156 } 157 // Back up to first of month and jump. 158 $current_day = date_repeat_set_month_day($current_day, NULL, 1, '+', $timezone, $modify_time); 159 date_modify($current_day, '+' . $jump . $modify_time); 160 } 161 // If it's yearly, break out of the loop at the 162 // end of every year, and jump one INTERVAL in years. 163 else { 164 if (date_format($current_day, 'n') == 12) { 165 $period_finished = TRUE; 166 } 167 else { 168 // Back up to first of month and jump. 169 $current_day = date_repeat_set_month_day($current_day, NULL, 1, '+', $timezone, $modify_time); 170 date_modify($current_day, '+1 month' . $modify_time); 171 } 172 } 173 } 174 if ($rrule['FREQ'] == 'YEARLY') { 175 // Back up to first of year and jump. 176 $current_day = date_repeat_set_year_day($current_day, NULL, 1, '+', $timezone, $modify_time); 177 date_modify($current_day, '+' . $jump . $modify_time); 178 } 179 $finished = date_repeat_is_finished($current_day, $days, $count, $end_date); 180 } 181 } 182 183 // This is the simple fallback case, not looking for any BYDAY, 184 // just repeating the start date. Because of imputed BYDAY above, this 185 // will only test TRUE for a DAILY or less frequency (like HOURLY). 186 187 elseif (empty($rrule['BYDAY'])) { 188 // $current_day will keep track of where we are in the calculation. 189 $current_day = drupal_clone($start_date); 190 $finished = FALSE; 191 $months = !empty($rrule['BYMONTH']) ? $rrule['BYMONTH'] : array(); 192 while (!$finished) { 193 date_repeat_add_dates($days, $current_day, $start_date, $end_date, $exceptions, $rrule); 194 $finished = date_repeat_is_finished($current_day, $days, $count, $end_date); 195 date_modify($current_day, '+' . $jump . $modify_time); 196 } 197 } 198 199 else { 200 201 // More complex searches for day names and criteria like '-1SU' or '2TU,2TH', 202 // require that we interate through the whole time period checking each BYDAY. 203 204 // Create helper array to pull day names out of iCal day strings. 205 $day_names = date_repeat_dow_day_options(FALSE); 206 $days_of_week = array_keys($day_names); 207 208 // Parse out information about the BYDAYs and separate them 209 // depending on whether they have directional parameters like -1SU or 2TH. 210 $month_days = array(); 211 $week_days = array(); 212 213 // Find the right first day of the week to use, iCal rules say Monday 214 // should be used if none is specified. 215 $week_start_rule = !empty($rrule['WKST']) ? trim($rrule['WKST']) : 'MO'; 216 $week_start_day = $day_names[$week_start_rule]; 217 218 // Make sure the week days array is sorted into week order, 219 // we use the $ordered_keys to get the right values into the key 220 // and force the array to that order. Needed later when we 221 // iterate through each week looking for days so we don't 222 // jump to the next week when we hit a day out of order. 223 $ordered = date_repeat_days_ordered($week_start_rule); 224 $ordered_keys = array_flip($ordered); 225 226 foreach ($rrule['BYDAY'] as $day) { 227 preg_match("@(-)?([0-9]+)?([SU|MO|TU|WE|TH|FR|SA]{2})@", trim($day), $regs); 228 if (!empty($regs[2])) { 229 // Convert parameters into full day name, count, and direction. 230 $direction_days[] = array( 231 'day' => $day_names[$regs[3]], 232 'direction' => !empty($regs[1]) ? $regs[1] : '+', 233 'direction_count' => $regs[2], 234 ); 235 } 236 else { 237 $week_days[$ordered_keys[$regs[3]]] = $day_names[$regs[3]]; 238 } 239 } 240 ksort($week_days); 241 242 // BYDAYs with parameters like -1SU (last Sun) or 2TH (second Thur) 243 // need to be processed one month or year at a time. 244 if (!empty($direction_days) && in_array($rrule['FREQ'], array('MONTHLY', 'YEARLY'))) { 245 $finished = FALSE; 246 $current_day = drupal_clone($start_date); 247 while (!$finished) { 248 foreach ($direction_days as $day) { 249 // Find the BYDAY date in the current month. 250 if ($rrule['FREQ'] == 'MONTHLY') { 251 $current_day = date_repeat_set_month_day($current_day, $day['day'], $day['direction_count'], $day['direction'], $timezone, $modify_time); 252 } 253 else { 254 $current_day = date_repeat_set_year_day($current_day, $day['day'], $day['direction_count'], $day['direction'], $timezone, $modify_time); 255 } 256 date_repeat_add_dates($days, $current_day, $start_date, $end_date, $exceptions, $rrule); 257 } 258 $finished = date_repeat_is_finished($current_day, $days, $count, $end_date); 259 // Reset to beginning of period before jumping to next period. 260 // Needed especially when working with values like 'last Saturday' 261 // to be sure we don't skip months like February. 262 $year = date_format($current_day, 'Y'); 263 $month = date_format($current_day, 'n'); 264 if ($rrule['FREQ'] == 'MONTHLY') { 265 date_date_set($current_day, $year, $month, 1); 266 } 267 else { 268 date_date_set($current_day, $year, 1, 1); 269 } 270 // Jump to the next period. 271 date_modify($current_day, '+' . $jump . $modify_time); 272 } 273 } 274 275 // For BYDAYs without parameters,like TU,TH (every Tues and Thur), 276 // we look for every one of those days during the frequency period. 277 // Iterate through periods of a WEEK, MONTH, or YEAR, checking for 278 // the days of the week that match our criteria for each week in the 279 // period, then jumping ahead to the next week, month, or year, 280 // an INTERVAL at a time. 281 282 if (!empty($week_days) && in_array($rrule['FREQ'], array('MONTHLY', 'WEEKLY', 'YEARLY'))) { 283 $finished = FALSE; 284 $current_day = drupal_clone($start_date); 285 $format = $rrule['FREQ'] == 'YEARLY' ? 'Y' : 'n'; 286 $current_period = date_format($current_day, $format); 287 288 // Back up to the beginning of the week in case we are somewhere in the 289 // middle of the possible week days, needed so we don't prematurely 290 // jump to the next week. The date_repeat_add_dates() function will 291 // keep dates outside the range from getting added. 292 if (date_format($current_day, 'l') != $day_names[$day]) { 293 date_modify($current_day, '-1 ' . $week_start_day . $modify_time); 294 } 295 while (!$finished) { 296 $period_finished = FALSE; 297 while (!$period_finished) { 298 $moved = FALSE; 299 foreach ($week_days as $delta => $day) { 300 // Find the next occurence of each day in this week, only add it 301 // if we are still in the current month or year. The date_repeat_add_dates 302 // function is insufficient to test whether to include this date 303 // if we are using a rule like 'every other month', so we must 304 // explicitly test it here. 305 306 // If we're already on the right day, don't jump or we 307 // will prematurely move into the next week. 308 if (date_format($current_day, 'l') != $day) { 309 date_modify($current_day, '+1 ' . $day . $modify_time); 310 $moved = TRUE; 311 } 312 if ($rrule['FREQ'] == 'WEEKLY' || date_format($current_day, $format) == $current_period) { 313 date_repeat_add_dates($days, $current_day, $start_date, $end_date, $exceptions, $rrule); 314 } 315 } 316 $finished = date_repeat_is_finished($current_day, $days, $count, $end_date); 317 318 // Make sure we don't get stuck in endless loop if the current 319 // day never got changed above. 320 if (!$moved) { 321 date_modify($current_day, '+1 day' . $modify_time); 322 } 323 324 // If this is a WEEKLY frequency, stop after each week, 325 // otherwise, stop when we've moved outside the current period. 326 // Jump to the end of the week, then test the period. 327 if ($finished || $rrule['FREQ'] == 'WEEKLY') { 328 $period_finished = TRUE; 329 } 330 elseif ($rrule['FREQ'] != 'WEEKLY' && date_format($current_day, $format) != $current_period) { 331 $period_finished = TRUE; 332 } 333 } 334 335 if ($finished) { 336 continue; 337 } 338 339 // We'll be at the end of a week, month, or year when 340 // we get to this point in the code. 341 342 // Go back to the beginning of this period before we jump, to 343 // ensure we jump to the first day of the next period. 344 switch ($rrule['FREQ']) { 345 case 'WEEKLY': 346 date_modify($current_day, '+1 ' . $week_start_day . $modify_time); 347 date_modify($current_day, '-1 week' . $modify_time); 348 break; 349 case 'MONTHLY': 350 date_modify($current_day, '-' . (date_format($current_day, 'j') - 1) . ' days' . $modify_time); 351 date_modify($current_day, '-1 month' . $modify_time); 352 break; 353 case 'YEARLY': 354 date_modify($current_day, '-' . date_format($current_day, 'z') . ' days' . $modify_time); 355 date_modify($current_day, '-1 year' . $modify_time); 356 break; 357 } 358 // Jump ahead to the next period to be evaluated. 359 date_modify($current_day, '+' . $jump . $modify_time); 360 $current_period = date_format($current_day, $format); 361 $finished = date_repeat_is_finished($current_day, $days, $count, $end_date); 362 } 363 } 364 } 365 366 // add additional dates 367 foreach ($additions as $addition) { 368 $date = date_make_date($addition . ' ' . date_format($start_date, 'H:i:s'), $timezone); 369 $days[] = date_format($date, DATE_FORMAT_DATETIME); 370 } 371 372 sort($days); 373 return $days; 374 } 375 376 /** 377 * See if the RRULE needs some imputed values added to it. 378 */ 379 function date_repeat_adjust_rrule($rrule, $start_date) { 380 // If this is not a valid value, do nothing; 381 if (empty($rrule) || empty($rrule['FREQ'])) { 382 return array(); 383 } 384 385 // RFC 2445 says if no day or monthday is specified when creating repeats for 386 // weeks, months, or years, impute the value from the start date. 387 388 if (empty($rrule['BYDAY']) && $rrule['FREQ'] == 'WEEKLY') { 389 $rrule['BYDAY'] = array(date_repeat_dow2day(date_format($start_date, 'w'))); 390 } 391 elseif (empty($rrule['BYDAY']) && empty($rrule['BYMONTHDAY']) && $rrule['FREQ'] == 'MONTHLY') { 392 $rrule['BYMONTHDAY'] = array(date_format($start_date, 'j')); 393 } 394 elseif (empty($rrule['BYDAY']) && empty($rrule['BYMONTHDAY']) && empty($rrule['BYYEARDAY']) && $rrule['FREQ'] == 'YEARLY') { 395 $rrule['BYMONTHDAY'] = array(date_format($start_date, 'j')); 396 if (empty($rrule['BYMONTH'])) { 397 $rrule['BYMONTH'] = array(date_format($start_date, 'n')); 398 } 399 } 400 // If we are processing rules for period other than YEARLY or MONTHLY 401 // and have BYDAYS like 2SU or -1SA, simplify them to SU or SA since the 402 // position rules make no sense in other periods and just add complexity. 403 404 elseif (!empty($rrule['BYDAY']) && !in_array($rrule['FREQ'], array('MONTHLY', 'YEARLY'))) { 405 foreach ($rrule['BYDAY'] as $delta => $BYDAY) { 406 $rrule['BYDAY'][$delta] = substr($BYDAY, -2); 407 } 408 } 409 410 return $rrule; 411 } 412 413 /** 414 * Helper function to add found date to the $dates array. 415 * 416 * Check that the date to be added is between the start and end date 417 * and that it is not in the $exceptions, nor already in the $days array, 418 * and that it meets other criteria in the RRULE. 419 */ 420 function date_repeat_add_dates(&$days, $current_day, $start_date, $end_date, $exceptions, $rrule) { 421 if (isset($rrule['COUNT']) && sizeof($days) >= $rrule['COUNT']) { 422 return FALSE; 423 } 424 $formatted = date_format($current_day, DATE_FORMAT_DATETIME); 425 if (!empty($end_date) && $formatted > date_format($end_date, DATE_FORMAT_DATETIME)) { 426 return FALSE; 427 } 428 if ($formatted < date_format($start_date, DATE_FORMAT_DATETIME)) { 429 return FALSE; 430 } 431 if (in_array(date_format($current_day, 'Y-m-d'), $exceptions)) { 432 return FALSE; 433 } 434 if (!empty($rrule['BYDAY'])) { 435 $BYDAYS = $rrule['BYDAY']; 436 foreach ($BYDAYS as $delta => $BYDAY) { 437 $BYDAYS[$delta] = substr($BYDAY, -2); 438 } 439 if (!in_array(date_repeat_dow2day(date_format($current_day, 'w')), $BYDAYS)) { 440 return FALSE; 441 }} 442 if (!empty($rrule['BYYEAR']) && !in_array(date_format($current_day, 'Y'), $rrule['BYYEAR'])) { 443 return FALSE; 444 } 445 if (!empty($rrule['BYMONTH']) && !in_array(date_format($current_day, 'n'), $rrule['BYMONTH'])) { 446 return FALSE; 447 } 448 if (!empty($rrule['BYMONTHDAY'])) { 449 // Test month days, but only if there are no negative numbers. 450 $test = TRUE; 451 $BYMONTHDAYS = array(); 452 foreach ($rrule['BYMONTHDAY'] as $day) { 453 if ($day > 0) { 454 $BYMONTHDAYS[] = $day; 455 } 456 else { 457 $test = FALSE; 458 break; 459 } 460 } 461 if ($test && !empty($BYMONTHDAYS) && !in_array(date_format($current_day, 'j'), $BYMONTHDAYS)) { 462 return FALSE; 463 } 464 } 465 // Don't add a day if it is already saved so we don't throw the count off. 466 if (in_array($formatted, $days)) { 467 return TRUE; 468 } 469 else { 470 $days[] = $formatted; 471 } 472 } 473 474 /** 475 * Stop when $current_day is greater than $end_date or $count is reached. 476 */ 477 function date_repeat_is_finished($current_day, $days, $count, $end_date) { 478 if (($count && sizeof($days) >= $count) 479 || (!empty($end_date) && date_format($current_day, 'U') > date_format($end_date, 'U'))) { 480 return TRUE; 481 } 482 else { 483 return FALSE; 484 } 485 } 486 487 /** 488 * Set a date object to a specific day of the month. 489 * 490 * Example, 491 * date_set_month_day($date, 'Sunday', 2, '-') 492 * will reset $date to the second to last Sunday in the month. 493 * If $day is empty, will set to the number of days from the 494 * beginning or end of the month. 495 */ 496 function date_repeat_set_month_day($date_in, $day, $count = 1, $direction = '+', $timezone = 'UTC', $modify_time) { 497 if (is_object($date_in)) { 498 $current_month = date_format($date_in, 'n'); 499 500 // Reset to the start of the month. 501 // We should be able to do this with date_date_set(), but 502 // for some reason the date occasionally gets confused if run 503 // through this function multiple times. It seems to work 504 // reliably if we create a new object each time. 505 $datetime = date_format($date_in, DATE_FORMAT_DATETIME); 506 $datetime = substr_replace($datetime, '01', 8, 2); 507 $date = date_make_date($datetime, $timezone); 508 if ($direction == '-') { 509 // For negative search, start from the end of the month. 510 date_modify($date, '+1 month' . $modify_time); 511 } 512 else { 513 // For positive search, back up one day to get outside the 514 // current month, so we can catch the first of the month. 515 date_modify($date, '-1 day' . $modify_time); 516 } 517 518 if (empty($day)) { 519 date_modify($date, $direction . $count . ' days' . $modify_time); 520 } 521 else { 522 // Use the English text for order, like First Sunday 523 // instead of +1 Sunday to overcome PHP5 bug, (see #369020). 524 $order = date_order(); 525 $step = $count <= 5 ? $order[$direction . $count] : $count; 526 date_modify($date, $step . ' ' . $day . $modify_time); 527 } 528 529 // If that takes us outside the current month, don't go there. 530 if (date_format($date, 'n') == $current_month) { 531 return $date; 532 } 533 } 534 return $date_in; 535 } 536 537 /** 538 * Set a date object to a specific day of the year. 539 * 540 * Example, 541 * date_set_year_day($date, 'Sunday', 2, '-') 542 * will reset $date to the second to last Sunday in the year. 543 * If $day is empty, will set to the number of days from the 544 * beginning or end of the year. 545 */ 546 function date_repeat_set_year_day($date_in, $day, $count = 1, $direction = '+', $timezone = 'UTC', $modify_time) { 547 if (is_object($date_in)) { 548 $current_year = date_format($date_in, 'Y'); 549 550 // Reset to the start of the month. 551 // See note above. 552 $datetime = date_format($date_in, DATE_FORMAT_DATETIME); 553 $datetime = substr_replace($datetime, '01-01', 5, 5); 554 $date = date_make_date($datetime, $timezone); 555 if ($direction == '-') { 556 // For negative search, start from the end of the year. 557 date_modify($date, '+1 year' . $modify_time); 558 } 559 else { 560 // For positive search, back up one day to get outside the 561 // current year, so we can catch the first of the year. 562 date_modify($date, '-1 day' . $modify_time); 563 } 564 if (empty($day)) { 565 date_modify($date, $direction . $count . ' days' . $modify_time); 566 } 567 else { 568 // Use the English text for order, like First Sunday 569 // instead of +1 Sunday to overcome PHP5 bug, (see #369020). 570 $order = date_order(); 571 $step = $count <= 5 ? $order[$direction . $count] : $count; 572 date_modify($date, $step . ' ' . $day . $modify_time); 573 } 574 575 // If that takes us outside the current year, don't go there. 576 if (date_format($date, 'Y') == $current_year) { 577 return $date; 578 } 579 } 580 return $date_in; 581 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body
| Generated: Mon Jul 9 18:01:44 2012 | Cross-referenced by PHPXref 0.7 |