[ Index ]

PHP Cross Reference of Drupal 6 (gatewave)

title

Body

[close]

/sites/all/modules/date/date_repeat/ -> date_repeat_calc.inc (source)

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


Generated: Thu Mar 24 11:18:33 2011 Cross-referenced by PHPXref 0.7