first commit
[step2_drupal.git] / date / date_api_ical.inc
blob6258b34ccdff73a9dd6a0437ac24ae309305780f
1 <?php
2 /* $Id: date_api_ical.inc,v 1.35.4.26 2009/02/21 12:53:25 karens Exp $ */
3 /**
4  * @file
5  * Parse iCal data.
6  *
7  * This file must be included when these functions are needed.
8  */
9 /**
10  * Return an array of iCalendar information from an iCalendar file.
11  *
12  *   No timezone adjustment is performed in the import since the timezone
13  *   conversion needed will vary depending on whether the value is being
14  *   imported into the database (when it needs to be converted to UTC), is being
15  *   viewed on a site that has user-configurable timezones (when it needs to be
16  *   converted to the user's timezone), if it needs to be converted to the
17  *   site timezone, or if it is a date without a timezone which should not have
18  *   any timezone conversion applied.
19  *
20  *   Properties that have dates and times are converted to sub-arrays like:
21  *      'datetime'   => date in YYYY-MM-DD HH:MM format, not timezone adjusted
22  *      'all_day'    => whether this is an all-day event
23  *      'tz'         => the timezone of the date, could be blank for absolute
24  *                      times that should get no timezone conversion.
25  *
26  *   Exception dates can have muliple values and are returned as arrays
27  *   like the above for each exception date.
28  *
29  *   Most other properties are returned as PROPERTY => VALUE.
30  *
31  *   Each item in the VCALENDAR will return an array like:
32  *   [0] => Array (
33  *     [TYPE] => VEVENT
34  *     [UID] => 104
35  *     [SUMMARY] => An example event
36  *     [URL] => http://example.com/node/1
37  *     [DTSTART] => Array (
38  *       [datetime] => 1997-09-07 09:00:00
39  *       [all_day] => 0
40  *       [tz] => US/Eastern
41  *     )
42  *     [DTEND] => Array (
43  *       [datetime] => 1997-09-07 11:00:00
44  *       [all_day] => 0
45  *       [tz] => US/Eastern
46  *     )
47  *     [RRULE] => Array (
48  *       [FREQ] => Array (
49  *         [0] => MONTHLY
50  *       )
51  *       [BYDAY] => Array (
52  *         [0] => 1SU
53  *         [1] => -1SU
54  *       )
55  *     )
56  *     [EXDATE] => Array (
57  *       [0] = Array (
58  *         [datetime] => 1997-09-21 09:00:00
59  *         [all_day] => 0
60  *         [tz] => US/Eastern
61  *       )
62  *       [1] = Array (
63  *         [datetime] => 1997-10-05 09:00:00
64  *         [all_day] => 0
65  *         [tz] => US/Eastern
66  *       )
67  *     )
68  *   )
69  *
70  * @param $filename
71  *   Location (local or remote) of a valid iCalendar file
72  * @return array
73  *   An array with all the elements from the ical
74  * @todo
75  *   figure out how to handle this if subgroups are nested,
76  *   like a VALARM nested inside a VEVENT.
77  */
78 function date_ical_import($filename) {
79   // Fetch the iCal data. If file is a URL, use drupal_http_request. fopen
80   // isn't always configured to allow network connections.
81   if (substr($filename, 0, 4) == 'http') {
82     // Fetch the ical data from the specified network location
83     $icaldatafetch = drupal_http_request($filename);
84     // Check the return result
85     if ($icaldatafetch->error) {
86       watchdog('date ical', 'HTTP Request Error importing %filename: @error', array('%filename' => $filename, '@error' => $icaldatafetch->error));
87       return false;
88     }
89     // Break the return result into one array entry per lines
90     $icaldatafolded = explode("\n", $icaldatafetch->data);
91   }
92   else {
93     $icaldatafolded = @file($filename, FILE_IGNORE_NEW_LINES);
94     if ($icaldatafolded === FALSE) {
95       watchdog('date ical', 'Failed to open file: %filename', array('%filename' => $filename));
96       return false;
97     }
98   }
99   // Verify this is iCal data
100   if (trim($icaldatafolded[0]) != 'BEGIN:VCALENDAR') {
101     watchdog('date ical', 'Invalid calendar file: %filename', array('%filename' => $filename));
102     return false;
103   }
104   return date_ical_parse($icaldatafolded);
108  * Return an array of iCalendar information from an iCalendar file.
110  * As date_ical_import() but different param.
112  * @param $icaldatafolded
113  *   an array of lines from an ical feed.
114  * @return array
115  *   An array with all the elements from the ical.
116  */
117 function date_ical_parse($icaldatafolded = array()) {
118   $items = array();
119     
120   // Verify this is iCal data
121   if (trim($icaldatafolded[0]) != 'BEGIN:VCALENDAR') {
122     watchdog('date ical', 'Invalid calendar file.');
123     return false;
124   }
127   // "unfold" wrapped lines
128   $icaldata = array();
129   foreach ($icaldatafolded as $line) {
130     $out = array();
131     // See if this looks like the beginning of a new property or value.
132     // If not, it is a continuation of the previous line.
133     // The regex is to ensure that wrapped QUOTED-PRINTABLE data
134     // is kept intact.
135     if (!preg_match('/([A-Z]+)[:;](.*)/', $line, $out)) {
136       $line = array_pop($icaldata) . ($line);
137     }
138     $icaldata[] = $line;
139   }
140   unset($icaldatafolded);
142   // Parse the iCal information
143   $parents = array();
144   $subgroups = array();
145   $vcal = '';
146   foreach ($icaldata as $line) {
147     $line = trim($line);
148     $vcal .= $line ."\n";
149     // Deal with begin/end tags separately
150     if (preg_match('/(BEGIN|END):V(\S+)/', $line, $matches)) {
151       $closure = $matches[1];
152       $type = 'V'. $matches[2];
153       if ($closure == 'BEGIN') {
154         array_push($parents, $type);
155         array_push($subgroups, array());
156       }
157       else if ($closure == 'END') {
158         end($subgroups);
159         $subgroup =& $subgroups[key($subgroups)];
160         switch ($type) {
161           case 'VCALENDAR':
162             if (prev($subgroups) == false) {
163               $items[] = array_pop($subgroups);
164             }
165             else {
166               $parent[array_pop($parents)][] = array_pop($subgroups);
167             }
168             break;
169           // Add the timezones in with their index their TZID
170           case 'VTIMEZONE':
171             $subgroup = end($subgroups);
172             $id = $subgroup['TZID'];
173             unset($subgroup['TZID']);
175             // Append this subgroup onto the one above it
176             prev($subgroups);
177             $parent =& $subgroups[key($subgroups)];
179             $parent[$type][$id] = $subgroup;
181             array_pop($subgroups);
182             array_pop($parents);
183             break;
184           // Do some fun stuff with durations and all_day events
185           // and then append to parent
186           case 'VEVENT':
187           case 'VALARM':
188           case 'VTODO':
189           case 'VJOURNAL':
190           case 'VVENUE':
191           case 'VFREEBUSY':
192           default:
193             // Can't be sure whether DTSTART is before or after DURATION,
194             // so parse DURATION at the end.
195             if (isset($subgroup['DURATION'])) {
196               date_ical_parse_duration($subgroup, 'DURATION');
197             }
198             // Add a top-level indication for the 'All day' condition.
199             // Leave it in the individual date components, too, so it
200             // is always available even when you are working with only
201             // a portion of the VEVENT array, like in Feed API parsers.
202             $subgroup['all_day'] = FALSE;
203             if (!empty($subgroup['DTSTART']) && (!empty($subgroup['DTSTART']['all_day']) || 
204             (empty($subgroup['DTEND']) && empty($subgroup['RRULE']) && empty($subgroup['RRULE']['COUNT'])))) {
205               // Many programs output DTEND for an all day event as the
206               // following day, reset this to the same day for internal use.
207               $subgroup['all_day'] = TRUE;
208               $subgroup['DTEND'] = $subgroup['DTSTART'];
209             }
210           // Add this element to the parent as an array under the
211           // component name
212           default:
213             prev($subgroups);
214             $parent =& $subgroups[key($subgroups)];
216             $parent[$type][] = $subgroup;
218             array_pop($subgroups);
219             array_pop($parents);
220             break;
221         }
222       }
223     }
224     // Handle all other possibilities
225     else {
226       // Grab current subgroup
227       end($subgroups);
228       $subgroup =& $subgroups[key($subgroups)];
230       // Split up the line into nice pieces for PROPERTYNAME,
231       // PROPERTYATTRIBUTES, and PROPERTYVALUE
232       preg_match('/([^;:]+)(?:;([^:]*))?:(.+)/', $line, $matches);
233       $name = !empty($matches[1]) ? strtoupper(trim($matches[1])) : '';
234       $field = !empty($matches[2]) ? $matches[2] : '';
235       $data = !empty($matches[3]) ? $matches[3] : '';
236       $parse_result = '';
237       switch ($name) {
238         // Keep blank lines out of the results.
239         case '':
240           break;
242           // Lots of properties have date values that must be parsed out.
243         case 'CREATED':
244         case 'LAST-MODIFIED':
245         case 'DTSTART':
246         case 'DTEND':
247         case 'DTSTAMP':
248         case 'RDATE':
249         case 'FREEBUSY':
250         case 'DUE':
251         case 'COMPLETED':
252           $parse_result = date_ical_parse_date($field, $data);
253           break;
255         case 'EXDATE':
256           $parse_result = date_ical_parse_exceptions($field, $data);
257           break;
259         case 'TRIGGER':
260           // A TRIGGER can either be a date or in the form -PT1H.
261           if (!empty($field)) {
262             $parse_result = date_ical_parse_date($field, $data);
263           }
264           else {
265             $parse_result = array('DATA' => $data);
266           }
267           break;
268           
269         case 'DURATION':
270           // Can't be sure whether DTSTART is before or after DURATION in
271           // the VEVENT, so store the data and parse it at the end.
272           $parse_result = array('DATA' => $data);
273           break;
275         case 'RRULE':
276         case 'EXRULE':
277           $parse_result = date_ical_parse_rrule($field, $data);
278           break;
280         case 'SUMMARY':
281         case 'DESCRIPTION':
282           $parse_result = date_ical_parse_text($field, $data);
283           break;
285         case 'LOCATION':
286           $parse_result = date_ical_parse_location($field, $data);
287           break;
289           // For all other properties, just store the property and the value.
290           // This can be expanded on in the future if other properties should
291           // be given special treatment.
292         default:
293           $parse_result = $data;
294           break;
295       }
297       // Store the result of our parsing
298       $subgroup[$name] = $parse_result;
299     }
300   }
301   return $items;
305  * Parse a ical date element.
307  * Possible formats to parse include:
308  *   PROPERTY:YYYYMMDD[T][HH][MM][SS][Z]
309  *   PROPERTY;VALUE=DATE:YYYYMMDD[T][HH][MM][SS][Z]
310  *   PROPERTY;VALUE=DATE-TIME:YYYYMMDD[T][HH][MM][SS][Z]
311  *   PROPERTY;TZID=XXXXXXXX;VALUE=DATE:YYYYMMDD[T][HH][MM][SS]
312  *   PROPERTY;TZID=XXXXXXXX:YYYYMMDD[T][HH][MM][SS]
314  *   The property and the colon before the date are removed in the import
315  *   process above and we are left with $field and $data.
317  *  @param $field
318  *    The text before the colon and the date, i.e.
319  *    ';VALUE=DATE:', ';VALUE=DATE-TIME:', ';TZID='
320  *  @param $data
321  *    The date itself, after the colon, in the format YYYYMMDD[T][HH][MM][SS][Z]
322  *    'Z', if supplied, means the date is in UTC.
324  *  @return array
325  *   $items array, consisting of:
326  *      'datetime'   => date in YYYY-MM-DD HH:MM format, not timezone adjusted
327  *      'all_day'    => whether this is an all-day event with no time
328  *      'tz'         => the timezone of the date, could be blank if the ical
329  *                      has no timezone; the ical specs say no timezone
330  *                      conversion should be done if no timezone info is
331  *                      supplied
332  *  @todo
333  *   Another option for dates is the format PROPERTY;VALUE=PERIOD:XXXX. The period
334  *   may include a duration, or a date and a duration, or two dates, so would
335  *   have to be split into parts and run through date_ical_parse_date() and
336  *   date_ical_parse_duration(). This is not commonly used, so ignored for now.
337  *   It will take more work to figure how to support that.
338  */
339 function date_ical_parse_date($field, $data) {
340   $items = array('datetime' => '', 'all_day' => '', 'tz' => '');
341   if (empty($data)) {
342     return $items;
343   }
344   // Make this a little more whitespace independent
345   $data = trim($data);
347   // Turn the properties into a nice indexed array of
348   // array(PROPERTYNAME => PROPERTYVALUE);
349   $field_parts = preg_split('/[;:]/', $field);
350   $properties = array();
351   foreach ($field_parts as $part) {
352     if (strpos($part, '=') !== false) {
353       $tmp = explode('=', $part);
354       $properties[$tmp[0]] = $tmp[1];
355     }
356   }
358   // Make this a little more whitespace independent
359   $data = trim($data);
361   // Record if a time has been found
362   $has_time = false;
364   // If a format is specified, parse it according to that format
365   if (isset($properties['VALUE'])) {
366     switch ($properties['VALUE']) {
367       case 'DATE':
368         preg_match (DATE_REGEX_ICAL_DATE, $data, $regs);
369         $datetime = date_pad($regs[1]) .'-'. date_pad($regs[2]) .'-'. date_pad($regs[3]); // Date
370         break;
371       case 'DATE-TIME':
372         preg_match (DATE_REGEX_ICAL_DATETIME, $data, $regs);
373         $datetime = date_pad($regs[1]) .'-'. date_pad($regs[2]) .'-'. date_pad($regs[3]); // Date
374         $datetime .= ' '. date_pad($regs[4]) .':'. date_pad($regs[5]) .':'. date_pad($regs[6]); // Time
375         $has_time = true;
376         break;
377     }
378   }
379   // If no format is specified, attempt a loose match
380   else {
381     preg_match (DATE_REGEX_LOOSE, $data, $regs);
382     $datetime = date_pad($regs[1]) .'-'. date_pad($regs[2]) .'-'. date_pad($regs[3]); // Date
383     if (isset($regs[4])) {
384       $has_time = true;
385       $datetime .= ' '.date_pad($regs[5]) .':'. date_pad($regs[6]) .':'. date_pad($regs[7]); // Time
386     }
387   }
389   // Use timezone if explicitly declared
390   if (isset($properties['TZID'])) {
391     $tz = $properties['TZID'];
392     // Fix commonly used alternatives like US-Eastern which should be US/Eastern.
393     $tz = str_replace('-', '/', $tz);
394     // Unset invalid timezone names.
395     if (!date_timezone_is_valid($tz)) {
396       $tz = '';
397     }
398   }
399   // If declared as UTC with terminating 'Z', use that timezone
400   else if (strpos($data, 'Z') !== false) {
401     $tz = 'UTC';
402   }
403   // Otherwise this date is floating...
404   else {
405     $tz = '';
406   }
408   $items['datetime'] = $datetime;
409   $items['all_day'] = $has_time ? false : true;
410   $items['tz'] = $tz;
411   return $items;
415  * Parse an ical repeat rule.
417  * @return array
418  *   Array in the form of PROPERTY => array(VALUES)
419  *   PROPERTIES include FREQ, INTERVAL, COUNT, BYDAY, BYMONTH, BYYEAR, UNTIL
420  */
421 function date_ical_parse_rrule($field, $data) {
422   $data = str_replace('RRULE:', '', $data);
423   $items = array('DATA' => $data);
424   $rrule = explode(';', $data);
425   foreach ($rrule as $key => $value) {
426     $param = explode('=', $value);
427     // Must be some kind of invalid data.
428     if (count($param) != 2) {
429       continue;
430     }
431     if ($param[0] == 'UNTIL') {
432       $values = date_ical_parse_date('', $param[1]);
433     }
434     else {
435       $values = explode(',', $param[1]);
436     }
437     // Different treatment for items that can have multiple values and those that cannot.
438     if (in_array($param[0], array('FREQ', 'INTERVAL', 'COUNT', 'WKST'))) {
439       $items[$param[0]] = $param[1];
440     }
441     else {
442       $items[$param[0]] = $values;
443     }
444   }
445   return $items;
449  * Parse exception dates (can be multiple values).
451  * @return array
452  *   an array of date value arrays.
453  */
454 function date_ical_parse_exceptions($field, $data) {
455   $data = str_replace('EXDATE:', '', $data);
456   $items = array('DATA' => $data);
457   $ex_dates = explode(',', $data);
458   foreach ($ex_dates as $ex_date) {
459     $items[] = date_ical_parse_date('', $ex_date);
460   }
461  return $items;
465  * Parse the duration of the event.
466  * Example:
467  *  DURATION:PT1H30M
468  *  DURATION:P1Y2M
470  *  @param $subgroup
471  *   array of other values in the vevent so we can check for DTSTART
472  */
473 function date_ical_parse_duration(&$subgroup, $field = 'DURATION') {
474   $items = $subgroup[$field];
475   $data  = $items['DATA'];
476   preg_match('/^P(\d{1,4}[Y])?(\d{1,2}[M])?(\d{1,2}[W])?(\d{1,2}[D])?([T]{0,1})?(\d{1,2}[H])?(\d{1,2}[M])?(\d{1,2}[S])?/', $data, $duration);
477   $items['year'] = isset($duration[1]) ? str_replace('Y', '', $duration[1]) : '';
478   $items['month'] = isset($duration[2]) ?str_replace('M', '', $duration[2]) : '';
479   $items['week'] = isset($duration[3]) ?str_replace('W', '', $duration[3]) : '';
480   $items['day'] = isset($duration[4]) ?str_replace('D', '', $duration[4]) : '';
481   $items['hour'] = isset($duration[6]) ?str_replace('H', '', $duration[6]) : '';
482   $items['minute'] = isset($duration[7]) ?str_replace('M', '', $duration[7]) : '';
483   $items['second'] = isset($duration[8]) ?str_replace('S', '', $duration[8]) : '';
484   $start_date = array_key_exists('DTSTART', $subgroup) ? $subgroup['DTSTART']['datetime'] : date_format(date_now(), DATE_FORMAT_ISO);
485   $timezone = array_key_exists('DTSTART', $subgroup) ? $subgroup['DTSTART']['tz'] : variable_get('date_default_timezone_name', NULL);
486   if (empty($timezone)) {
487     $timezone = 'UTC';
488   }
489   $date = date_make_date($start_date, $timezone);
490   $date2 = drupal_clone($date);
491   foreach ($items as $item => $count) {
492     if ($count > 0) {
493       date_modify($date2, '+'. $count .' '. $item);
494     }
495   }
496   $format = isset($subgroup['DTSTART']['type']) && $subgroup['DTSTART']['type'] == 'DATE' ? 'Y-m-d' : 'Y-m-d H:i:s';
497   $subgroup['DTEND'] = array(
498     'datetime' => date_format($date2, DATE_FORMAT_DATETIME),
499     'all_day' => isset($subgroup['DTSTART']['all_day']) ? $subgroup['DTSTART']['all_day'] : 0,
500     'tz' => $timezone,
501     );
502   $duration = date_format($date2, 'U') - date_format($date, 'U');
503   $subgroup['DURATION'] = array('DATA' => $data, 'DURATION' => $duration);
507  * Parse and clean up ical text elements.
508  */
509 function date_ical_parse_text($field, $data) {
510   if (strstr($field, 'QUOTED-PRINTABLE')) {
511     $data = quoted_printable_decode($data);
512   }
513   // Strip line breaks within element
514   $data = str_replace(array("\r\n ", "\n ", "\r "), '', $data);
515   // Put in line breaks where encoded
516   $data = str_replace(array("\\n", "\\N"), "\n", $data);
517   // Remove other escaping
518   $data = stripslashes($data);
519   return $data;
523  * Parse location elements.
524  * 
525  * Catch situations like the upcoming.org feed that uses
526  * LOCATION;VENUE-UID="http://upcoming.yahoo.com/venue/104/":111 First Street...
527  * or more normal LOCATION;UID=123:111 First Street...
528  * Upcoming feed would have been improperly broken on the ':' in http://
529  * so we paste the $field and $data back together first.
530  * 
531  * Use non-greedy check for ':' in case there are more of them in the address.
532  */
533 function date_ical_parse_location($field, $data) {
534   if (preg_match('/UID=[?"](.+)[?"][*?:](.+)/', $field .':'. $data, $matches)) {
535     $location = array();
536     $location['UID'] = $matches[1];
537     $location['DESCRIPTION'] = stripslashes($matches[2]);
538     return $location;  
539   }
540   else {
541     // Remove other escaping
542     $location = stripslashes($data);
543     return $location;
544   }
548  * Return a date object for the ical date, adjusted to its local timezone.
550  *  @param $ical_date
551  *    an array of ical date information created in the ical import.
552  *  @param $to_tz
553  *    the timezone to convert the date's value to.
554  *  @return object
555  *    a timezone-adjusted date object
556  */
557 function date_ical_date($ical_date, $to_tz = FALSE) {
558   // If the ical date has no timezone, must assume it is stateless
559   // so treat it as a local date.
560   if (empty($ical_date['tz'])) {
561     $from_tz = date_default_timezone_name();
562   }
563   else {
564     $from_tz = $ical_date['tz'];
565   }
566   $date = date_make_date($ical_date['datetime'], $from_tz);
567   if ($to_tz && $ical_date['tz'] != '' && $to_tz != $ical_date['tz']) {
568     date_timezone_set($date, timezone_open($to_tz));
569   }
570   return $date;
574  * Escape #text elements for safe iCal use
576  * @param $text
577  *   Text to escape
579  * @return
580  *   Escaped text
582  */
583 function date_ical_escape_text($text) {
584   //$text = strip_tags($text);
585   $text = str_replace('"', '\"', $text);
586   $text = str_replace("\\", "\\\\", $text);
587   $text = str_replace(",", "\,", $text);
588   $text = str_replace(":", "\:", $text);
589   $text = str_replace(";", "\;", $text);
590   $text = str_replace("\n", "\n ", $text);
591   return trim($text);
595  * Build an iCal RULE from $form_values.
597  * @param $form_values
598  *   an array constructed like the one created by date_ical_parse_rrule()
600  *     [RRULE] => Array (
601  *       [FREQ] => Array (
602  *         [0] => MONTHLY
603  *       )
604  *       [BYDAY] => Array (
605  *         [0] => 1SU
606  *         [1] => -1SU
607  *       )
608  *       [UNTIL] => Array (
609  *         [datetime] => 1997-21-31 09:00:00
610  *         [all_day] => 0
611  *         [tz] => US/Eastern
612  *       )
613  *     )
614  *     [EXDATE] => Array (
615  *       [0] = Array (
616  *         [datetime] => 1997-09-21 09:00:00
617  *         [all_day] => 0
618  *         [tz] => US/Eastern
619  *       )
620  *       [1] = Array (
621  *         [datetime] => 1997-10-05 09:00:00
622  *         [all_day] => 0
623  *         [tz] => US/Eastern
624  *       )
625  *     )
627  */
628 function date_api_ical_build_rrule($form_values) {
629   $RRULE = '';
630   if (empty($form_values) || !is_array($form_values)) {
631     return $RRULE;
632   }
633   //grab the RRULE data and put them into iCal RRULE format
634   $RRULE .= 'RRULE:FREQ='. (!array_key_exists('FREQ', $form_values) ? 'DAILY' : $form_values['FREQ']);
635   $RRULE .= ';INTERVAL='. (!array_key_exists('INTERVAL', $form_values) ? 1 : $form_values['INTERVAL']);
637   // Unset the empty 'All' values.
638   if (array_key_exists('BYDAY', $form_values)) unset($form_values['BYDAY']['']);
639   if (array_key_exists('BYMONTH', $form_values)) unset($form_values['BYMONTH']['']);
640   if (array_key_exists('BYMONTHDAY', $form_values)) unset($form_values['BYMONTHDAY']['']);
642   if (array_key_exists('BYDAY', $form_values) && $BYDAY = implode(",", $form_values['BYDAY'])) {
643     $RRULE .= ';BYDAY='. $BYDAY;
644   }
645   if (array_key_exists('BYMONTH', $form_values) && $BYMONTH = implode(",", $form_values['BYMONTH'])) {
646     $RRULE .= ';BYMONTH='. $BYMONTH;
647   }
648   if (array_key_exists('BYMONTHDAY', $form_values) && $BYMONTHDAY = implode(",", $form_values['BYMONTHDAY'])) {
649     $RRULE .= ';BYMONTHDAY='. $BYMONTHDAY;
650   }
651   // The UNTIL date is supposed to always be expressed in UTC.
652   if (array_key_exists('UNTIL', $form_values) && array_key_exists('datetime', $form_values['UNTIL'])) {
653     $until = date_ical_date($form_values['UNTIL'], 'UTC');
654     $RRULE .= ';UNTIL='. date_format($until, DATE_FORMAT_ICAL) .'Z';
655   }
656   // Our form doesn't allow a value for COUNT, but it may be needed by
657   // modules using the API, so add it to the rule.
658   if (array_key_exists('COUNT', $form_values)) {
659     $RRULE .= ';COUNT='. $form_values['COUNT'];
660   }
662   // iCal rules presume the week starts on Monday unless otherwise specified,
663   // so we'll specify it.
664   if (array_key_exists('WKST', $form_values)) {
665     $RRULE .= ';WKST='. $form_values['WKST'];
666   }
667   else {
668     $RRULE .= ';WKST='. date_repeat_dow2day(variable_get('date_first_day', 1));
669   }
671   // Exceptions dates go last, on their own line.
672   if (isset($form_values['EXDATE']) && is_array($form_values['EXDATE'])) {
673     $ex_dates = array();
674     foreach ($form_values['EXDATE'] as $value) {
675       $ex_date = date_convert($value['datetime'], DATE_DATETIME, DATE_ICAL);
676       if (!empty($ex_date)) {
677         $ex_dates[] = $ex_date;
678       }
679     }
680     if (!empty($ex_dates)) {
681       sort($ex_dates);
682       $RRULE .= chr(13) . chr(10) .'EXDATE:'. implode(',', $ex_dates);
683     }
684   }
685   elseif (!empty($form_values['EXDATE'])) {
686     $RRULE .= chr(13) . chr(10) .'EXDATE:'. $form_values['EXDATE'];
687   }
688   return $RRULE;