Commit 05caebca authored by Robert Rollins's avatar Robert Rollins

RRULE import works now!

I hadn't yet tested the RRULE import code when I first pushed Date iCal 3.x-dev,
but now I have. It's much more robust than it was in 2.x.

In addition, I moved the parsing class out of DateiCalFeedsParser.inc and into
libaries/ParserVcalendar.inc, which is like how Feeds' own CSV parser is set up.
parent 11f49c89
......@@ -31,7 +31,7 @@ class DateIcalParseException extends DateIcalException {}
*/
function date_ical_hook_info() {
// TODO: Finish this.
// Use two "groups": date_ical_parse and date_ical_output.
// Use two "groups": date_ical_import and date_ical_export.
}
/**
......@@ -44,6 +44,45 @@ function date_ical_views_api() {
);
}
/**
* Implements hook_feeds_plugins().
*/
function date_ical_feeds_plugins() {
$path = drupal_get_path('module', 'date_ical') . '/includes';
$info = array();
$info['DateIcalFeedsParserOld'] = array(
'hidden' => TRUE,
'handler' => array(
'parent' => 'FeedsParser',
'class' => 'DateIcalFeedsParserOld',
'file' => 'DateIcalFeedsParserOld.inc',
'path' => $path,
),
);
$info['DateIcalIcalcreatorParser'] = array(
'name' => 'iCal parser (old)',
'description' => t('Use the iCalcreator library to parse iCal feeds.'),
'help' => 'Parse iCal feeds.',
'handler' => array(
'parent' => 'DateIcalFeedsParserOld',
'class' => 'DateIcalIcalcreatorParser',
'file' => 'DateIcalIcalcreatorParser.inc',
'path' => $path,
),
);
$info['DateiCalFeedsParser'] = array(
'name' => 'iCal parser',
'description' => t('Parse iCal feeds.'),
'handler' => array(
'parent' => 'FeedsParser',
'class' => 'DateiCalFeedsParser',
'file' => 'DateiCalFeedsParser.inc',
'path' => $path,
),
);
return $info;
}
/**
* Implements hook_theme().
*/
......@@ -158,45 +197,6 @@ function date_ical_ctools_plugin_api($owner, $api) {
}
}
/**
* Implements hook_feeds_plugins().
*/
function date_ical_feeds_plugins() {
$path = drupal_get_path('module', 'date_ical') . '/includes';
$info = array();
$info['DateIcalFeedsParserOld'] = array(
'hidden' => TRUE,
'handler' => array(
'parent' => 'FeedsParser',
'class' => 'DateIcalFeedsParserOld',
'file' => 'DateIcalFeedsParserOld.inc',
'path' => $path,
),
);
$info['DateIcalIcalcreatorParser'] = array(
'name' => 'iCal parser (old)',
'description' => t('Use the iCalcreator library to parse iCal feeds.'),
'help' => 'Parse iCal feeds.',
'handler' => array(
'parent' => 'DateIcalFeedsParserOld',
'class' => 'DateIcalIcalcreatorParser',
'file' => 'DateIcalIcalcreatorParser.inc',
'path' => $path,
),
);
$info['DateiCalFeedsParser'] = array(
'name' => 'iCal parser',
'description' => t('Parse iCal feeds.'),
'handler' => array(
'parent' => 'FeedsParser',
'class' => 'DateiCalFeedsParser',
'file' => 'DateiCalFeedsParser.inc',
'path' => $path,
),
);
return $info;
}
/**
* Implements hook_feeds_processor_targets_alter().
*
......@@ -267,104 +267,13 @@ function date_ical_feeds_set_rrule($source, $entity, $target, $feed_element) {
$info = field_info_field($field_name);
foreach ($entity->{$field_name} as $lang => $field_array) {
// Add the multiple date values that Date Repeat Field uses to represent recurring dates.
$values = date_ical_build_repeating_dates($feed_element, NULL, $info, $field_array[0]);
$values = date_repeat_build_dates($feed_element, NULL, $info, $field_array[0]);
foreach ($values as $key => $value) {
$entity->{$field_name}[$lang][$key] = $value;
}
}
}
/**
* 99% copy-pasta from date_repeat_field.module's date_repeat_build_dates() function.
* The only change is that we assume COUNT=52 on indefinitely repeating RRULEs, rather than
* giving up completely.
*/
function date_ical_build_repeating_dates($rrule = NULL, $rrule_values = NULL, $field, $item) {
module_load_include('inc', 'date_api', 'date_api_ical');
$field_name = $field['field_name'];
if (empty($rrule)) {
$rrule = date_api_ical_build_rrule($rrule_values);
}
elseif (empty($rrule_values)) {
$rrule_values = date_ical_parse_rrule(NULL, $rrule);
}
// By the time we get here, the start and end dates have been
// adjusted back to UTC, but we want localtime dates to do
// things like '+1 Tuesday', so adjust back to localtime.
$timezone = date_get_timezone($field['settings']['tz_handling'], $item['timezone']);
$timezone_db = date_get_timezone_db($field['settings']['tz_handling']);
$start = new DateObject($item['value'], $timezone_db, date_type_format($field['type']));
$start->limitGranularity($field['settings']['granularity']);
if ($timezone != $timezone_db) {
date_timezone_set($start, timezone_open($timezone));
}
if (!empty($item['value2']) && $item['value2'] != $item['value']) {
$end = new DateObject($item['value2'], date_get_timezone_db($field['settings']['tz_handling']), date_type_format($field['type']));
$end->limitGranularity($field['settings']['granularity']);
date_timezone_set($end, timezone_open($timezone));
}
else {
$end = $start;
}
$duration = $start->difference($end);
$start_datetime = date_format($start, DATE_FORMAT_DATETIME);
if (!empty($rrule_values['UNTIL']['datetime'])) {
$end = date_ical_date($rrule_values['UNTIL'], $timezone);
$end_datetime = date_format($end, DATE_FORMAT_DATETIME);
}
elseif (!empty($rrule_values['COUNT'])) {
$end_datetime = NULL;
}
else {
// No UNTIL and no COUNT means this is an indefinitely repeating RRULE, which Date Repeat Field doesn't support.
// The best we can do is pretend it has a repeat count of 52 (52 weeks in a year, most repeats are weekly)
// by inserting a COUNT=52 param into the string, right after 'RRULE:'.
$rrule = substr_replace($rrule, 'COUNT=52;', 6, 0);
$end_datetime = NULL;
}
// Split the RRULE into RRULE, EXDATE, and RDATE parts.
$parts = date_repeat_split_rrule($rrule);
$parsed_exceptions = (array) $parts[1];
$exceptions = array();
foreach ($parsed_exceptions as $exception) {
$date = date_ical_date($exception, $timezone);
$exceptions[] = date_format($date, 'Y-m-d');
}
$parsed_rdates = (array) $parts[2];
$additions = array();
foreach ($parsed_rdates as $rdate) {
$date = date_ical_date($rdate, $timezone);
$additions[] = date_format($date, 'Y-m-d');
}
$dates = date_repeat_calc($rrule, $start_datetime, $end_datetime, $exceptions, $timezone, $additions);
$value = array();
foreach ($dates as $delta => $date) {
// date_repeat_calc always returns DATE_DATETIME dates, which is
// not necessarily $field['type'] dates.
// Convert returned dates back to db timezone before storing.
$date_start = new DateObject($date, $timezone, DATE_FORMAT_DATETIME);
$date_start->limitGranularity($field['settings']['granularity']);
date_timezone_set($date_start, timezone_open($timezone_db));
$date_end = clone($date_start);
date_modify($date_end, '+' . $duration . ' seconds');
$value[$delta] = array(
'value' => date_format($date_start, date_type_format($field['type'])),
'value2' => date_format($date_end, date_type_format($field['type'])),
'offset' => date_offset_get($date_start),
'offset2' => date_offset_get($date_end),
'timezone' => $timezone,
'rrule' => $rrule,
);
}
return $value;
}
/**
* Identify all potential fields that could populate the optional LOCATION component of iCal output.
*/
......
......@@ -42,13 +42,60 @@ class DateiCalFeedsParser extends FeedsParser {
);
drupal_alter('date_ical_import_calendar', $calendar, $context);
// We've got a vcalendar object created from the parsed feed data. Now we
// need to convert that vcalendar into an array of Feeds-compatible parsed
// data arrays. DateiCalCalendarParser does this conversion.
$parser = new DateiCalCalendarParser($calendar, $source, $fetcher_result);
// We've got a vcalendar object created from the feed data. Now we need to
// convert that vcalendar into an array of Feeds-compatible data arrays.
// ParserVcalendar does this conversion.
require_once(DRUPAL_ROOT . '/' . drupal_get_path('module', 'date_ical') . '/libraries/ParserVcalendar.inc');
$source_config = $source->getConfigFor($this);
$parser = new ParserVcalendar($calendar, $source, $fetcher_result, $source_config);
return new FeedsParserResult($parser->parse());
}
/**
* Define our default configuration settings for when the user performs an
* import.
*/
public function sourceDefaults() {
return array(
'indefinite_count' => $this->config['indefinite_count'],
);
}
/**
* Define our default configuration settings for when the user visits the
* config page.
*/
public function configDefaults() {
return array(
'indefinite_count' => '52',
);
}
/**
* Build configuration form.
*/
public function configForm(&$form_state) {
$form = array();
$form['indefinite_count'] = array(
'#title' => t('Indefinite COUNT'),
'#type' => 'select',
'#options' => array(
'31' => '31',
'52' => '52',
'90' => '90',
'365' => '365',
),
'#description' => t('Indefinitely repeating events are not supported. The repeat count will instead be set to this number.'),
'#default_value' => $this->config['indefinite_count'],
);
return $form;
}
/**
* Creates the list of mapping sources offered by DateiCalFeedsParser.
*
* @return array
*/
public static function getiCalMappingSources() {
// NOTE TO MAINTAINERS:
// The order of these properties determines their parse order! Since we
......@@ -110,317 +157,3 @@ class DateiCalFeedsParser extends FeedsParser {
return $sources;
}
}
/**
* Functionality to parse an iCalcreator vcalendar object into an array of
* Feeds-compatible data arrays.
*/
class DateiCalCalendarParser {
protected $calendar;
protected $source;
protected $fetcher_result;
protected $timezones = array();
protected $xtimezone;
public function __construct($calendar, $source, $fetcher_result) {
$this->calendar = $calendar;
$this->source = $source;
$this->fetcher_result = $fetcher_result;
}
/**
* Parses the vcalendar object into an array of event data arrays.
*
* @return array
* An array keyed by the same property keys as returned by
* DateiCalFeedsParser::getiCalMappingSources().
*/
public function parse() {
// Sometimes, the feed will set a timezone for every event in the calendar
// using the non-standard X-WR-TIMEZONE property. Date iCal uses this
// timezone only if the date property is not in UTC and has no TZID.
$xtimezone = $this->calendar->getProperty('X-WR-TIMEZONE');
if (!empty($xtimezone[1])) {
// Allow modules to alter the timezone string before it gets converted
// into a DateTimeZone.
$context = array(
'property_key' => NULL,
'calendar_component' => NULL,
'calendar' => $this->calendar,
'feeeds_source' => $this->source,
'feeds_fetcher_result' => $this->fetcher_result,
);
drupal_alter('date_ical_import_timezone', $xtimezone[1], $context);
$this->xtimezone = $this->_tzid_to_datetimezone($xtimezone[1]);
}
// Collect the timezones into an array, for easier access.
while ($component = $this->calendar->getComponent('VTIMEZONE')) {
$this->timezones[] = $component;
}
// Parse each calendar component it into a Feeds-compatible data array.
$events = array();
$component_types = array('VEVENT', 'VTODO', 'VJOURNAL', 'VFREEBUSY', 'VALARM');
foreach ($component_types as $component_type) {
while ($vcalendar_component = $this->calendar->getComponent($component_type)) {
// Allow modules to alter the vcalendar component before we parse it
// into a Feeds-compatible data array.
$context = array(
'calendar' => $this->calendar,
'source' => $this->source,
'fetcher_result' => $this->fetcher_result,
);
drupal_alter('date_ical_import_component', $vcalendar_component, $context);
$parsed_component = array();
foreach (DateiCalFeedsParser::getiCalMappingSources() as $property_key => $data) {
$handler = $data['date_ical_parse_handler'];
$parsed_component[$property_key] = $this->$handler($property_key, $vcalendar_component);
}
$events[] = $parsed_component;
}
}
return $events;
}
/**
* Parses text fields.
*
* @return string
*/
public function parseTextProperty($property_key, $vcalendar_component) {
$text = $vcalendar_component->getProperty($property_key);
if ($text === FALSE) {
if ($property_key == 'SUMMARY') {
$uid = $vcalendar_component->getProperty('UID');
throw new DateIcalParseException(t('The component with UID %uid is invalid because it has no SUMMARY (nodes require a title).', array('%uid' => $uid)));
}
// If the component doesn't have this property, return NULL.
return NULL;
}
// Convert literal \n and \N into newline characters.
$text = str_replace(array('\n', '\N'), "\n", $text);
return $text;
}
/**
* Parses field parameters.
*
* @return string
*/
public function parsePropertyParameter($property_key, $vcalendar_component) {
list($key, $attr) = explode(':', $property_key);
$property = $vcalendar_component->getProperty($key, FALSE, TRUE);
if ($property === FALSE) {
// If the component doesn't have this property, return NULL.
return NULL;
}
return isset($property['params'][$attr]) ? $property['params'][$attr]: '';
}
/**
* Parses datetime fields.
*
* @return FeedsDateTime
*/
public function parseDateTimeProperty($property_key, $vcalendar_component) {
$property = $vcalendar_component->getProperty($property_key, FALSE, TRUE);
// Gather all the other date properties, so we can work with them later.
$duration = $vcalendar_component->getProperty('DURATION', FALSE, TRUE);
$dtstart = $vcalendar_component->getProperty('DTSTART', FALSE, TRUE);
$dtend = $vcalendar_component->getProperty('DTEND', FALSE, TRUE);
$uid = $vcalendar_component->getProperty('UID');
if ($property === FALSE) {
if ($property_key == 'DTEND') {
return NULL;
}
else if ($property_key == 'DTSTART') {
if ($vcalendar_component->objName == 'vevent') {
throw new DateIcalParseException(t('Feed import failed! The VEVENT with UID %uid is invalid: it has no DTSTART.', array('%uid' => $uid)));
}
else {
return NULL;
}
}
}
// It's frustrating that iCalcreator gives us date data in a different
// format than what it expects us to give back.
if (isset($property['params']['TZID'])) {
$property['value']['tz'] = $property['params']['TZID'];
}
if (isset($property['params']['VALUE']) && $property['params']['VALUE'] == 'DATE') {
// DATE-type values are treated as All Day events, with no time-of-day.
// They can span over multiple days.
// The Date module's All Day event handling was never finalized:
// http://drupal.org/node/874322
if ($property_key == 'DTEND') {
if ($dtstart === FALSE) {
// This will almost certainly never happen, but the error message
// in this case should be comprehensible.
throw new DateIcalParseException(t('Feed import failed! The event with UID %uid is invalid: it has a DTEND but no DTSTART!', array('%uid' => $uid)));
}
// If the Date All Day module is installed, single-day All Day events
// will be displayed wrong unless we ignore the DTEND value.
if (module_exists('date_all_day')) {
$prev_day = iCalUtilityFunctions::_duration2date($property['value'], array('day' => -1));
if ($dtstart['value'] == $prev_day) {
return NULL;
}
else {
// If Date All Day is installed and this All Day event spans
// multiple days, we need to rewind the DTEND by one day, because
// of the problem with FeedsDateTime mentioned below.
$property['value'] = $prev_day;
}
}
}
else if ($property_key == 'DTSTART') {
// NOTE TO MAINTAINERS: This is why DTSTART *must* be parsed first!
// If DTEND is parsed first, this block will have no effect.
if ($dtend === FALSE && $duration === FALSE) {
// If the All Day event has no DTEND and no DURATION, assume the
// event is a single day: set DTEND = DTSTART + 1 day.
$end = $property['value'];
$end['day'] += 1;
$vcalendar_component->setDtend($end['year'], $end['month'], $end['day'], FALSE, FALSE, FALSE, FALSE, array('VALUE' => 'DATE'));
}
}
// FeedsDateTime->setTimezone() ignores timezone changes made to dates
// with no time element, which means we can't compensate for the Date
// module's automatic conversion to UTC when it writes to the DB. To get
// around that, we must add 00:00:00 explicitly.
$date_string = sprintf('%d-%d-%d 00:00:00', $property['value']['year'], $property['value']['month'], $property['value']['day']);
// Use the server's timezone rather than letting it default to UTC.
// This will ensure that the date value doesn't get messed up when Date
// converts it back from UTC when it's read from the database.
$datetimezone = new DateTimeZone(date_default_timezone_get());
}
else {
// This is a DATE-TIME property.
$date_string = iCalUtilityFunctions::_format_date_time($property['value']);
if (isset($property['value']['tz'])) {
// Z == Zulu == UTC. DateTimeZone won't acept Z, so change it to UTC.
if (strtoupper($property['value']['tz']) == 'Z') {
$property['value']['tz'] = 'UTC';
}
// Allow modules to alter the timezone string before it gets converted
// into a DateTimeZone.
$context = array(
'property_key' => $property_key,
'calendar_component' => $vcalendar_component,
'calendar' => $this->calendar,
'feeeds_source' => $this->source,
'feeds_fetcher_result' => $this->fetcher_result,
);
drupal_alter('date_ical_import_timezone', $property['value']['tz'], $context);
$datetimezone = $this->_tzid_to_datetimezone($property['value']['tz']);
}
else if (isset($this->xtimezone)) {
// No timezone was set on the parsed date property, so if a timezone
// was detected for the entire iCal feed, use it.
$datetimezone = $this->xtimezone;
}
else if (count($this->timezones) == 1) {
// There is exactly one VTIMEZONE in this feed, this date field doesn't
// specify a timezone, and there's no X-WR-TIMEZONE. The best we can do
// is assume this field should use the sole available TZID.
$datetimezone = $this->_tzid_to_datetimezone($this->timezones[0]->getProperty('TZID'));
}
else {
drupal_set_message(t('No timezone detected for the @key property of the event with UID %uid. Falling back to UTC.',
array('%uid' => $uid, '@key' => $property_key)), 'warning');
$datetimezone = new DateTimeZone('UTC');
}
}
// NOTE TO MAINTAINERS: This is why DTSTART *must* be parsed first!
// If DTEND is parsed first, this block will have no effect.
if ($property_key == 'DTSTART' && $dtend === FALSE && $duration !== FALSE) {
// In order to call $vcalendar_component->setDtend() correctly for both
// DATE and DATE-TIME values, we need to build this dummy array first.
$new_dtend = array(
'year' => NULL,
'month' => NULL,
'day' => NULL,
'hour' => NULL,
'min' => NULL,
'sec' => NULL,
'tz' => NULL,
'params' => $property['params'],
);
// If this component has no DTEND, but it does have a DURATION, set
// DTEND = DTSTART + DURATION.
$new_dtend = array_merge($new_dtend, iCalUtilityFunctions::_duration2date($property['value'], $duration['value']));
call_user_func_array(array($vcalendar_component, 'setDtend'), $new_dtend);
}
return new FeedsDateTime($date_string, $datetimezone);
}
/**
* Parses multi-value fields, like the CATEGORIES component.
*
* @return array
* An array of strings contaning the individual values.
*/
public function parseMultivalueProperty($property_key, $vcalendar_component) {
// Since we're not telling it to give us the params data, $property will
// be either FALSE, a string, or an array of strings.
$property = $vcalendar_component->getProperty($property_key);
if (empty($property)) {
// If this multi-value property is being mapped to a Taxonomy field,
// Feeds will interpret anything besides empty array as an array of
// empty values (e.g. array('')). This will create a term for that
// empty value, rather than leaving the field blank.
return array();
}
if (!is_array($property)) {
$property = array($property);
}
return $property;
}
/**
* Format RRULEs, which specify when and how often the event is repeated.
*
* @return string
* An RRULE string, with EXDATE and RDATE values separated by \n.
* This is to make the RRULE compatible with date_repeat_split_rrule().
*/
public function parseRepeatProperty($property_key, $vcalendar_component) {
if ($vcalendar_component->getProperty($property_key) === FALSE) {
return NULL;
}
$rrule = trim($vcalendar_component->createRrule());
$exdate = trim($vcalendar_component->createExdate());
$rdate = trim($vcalendar_component->createRdate());
return "$rrule\n$exdate\n$rdate";
}
protected function _tzid_to_datetimezone($tzid) {
try {
$datetimezone = new DateTimeZone($tzid);
}
catch (Exception $e) {
$link = l('here', 'http://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List', array('absolute' => TRUE));
$msg = t(
'"@tz" is not a valid timezone (see the TZ column !here), so Date iCal used UTC (which is probably wrong!).<br>
Try implementing hook_date_ical_import_timezone_alter() in a custom module to fix this problem.',
array('@tz' => $tzid, '!here' => $link)
);
$this->source->log('parse', $msg, array(), WATCHDOG_WARNING);
drupal_set_message($msg, 'warning', FALSE);
$datetimezone = new DateTimeZone("UTC");
}
return $datetimezone;
}
}
<?php
/**
* @file
* Defines a class that parses iCalcreator vcalendar objects into
* Feeds-compatible data arrays.
*/
class ParserVcalendar {
protected $calendar;
protected $source;
protected $fetcher_result;
protected $config;
protected $timezones = array();
protected $xtimezone;
public function __construct($calendar, $source, $fetcher_result, $config) {
$this->calendar = $calendar;
$this->source = $source;
$this->fetcher_result = $fetcher_result;
$this->config = $config;
}
/**
* Parses the vcalendar object into an array of event data arrays.
*
* @return array
* An array keyed by the same property keys as returned by
* DateiCalFeedsParser::getiCalMappingSources().
*/
public function parse() {
// Sometimes, the feed will set a timezone for every event in the calendar
// using the non-standard X-WR-TIMEZONE property. Date iCal uses this
// timezone only if the date property is not in UTC and has no TZID.
$xtimezone = $this->calendar->getProperty('X-WR-TIMEZONE');
if (!empty($xtimezone[1])) {
// Allow modules to alter the timezone string before it gets converted
// into a DateTimeZone.
$context = array(
'property_key' => NULL,
'calendar_component' => NULL,
'calendar' => $this->calendar,
'feeeds_source' => $this->source,
'feeds_fetcher_result' => $this->fetcher_result,
);
drupal_alter('date_ical_import_timezone', $xtimezone[1], $context);
$this->xtimezone = $this->_tzid_to_datetimezone($xtimezone[1]);
}
// Collect the timezones into an array, for easier access.
while ($component = $this->calendar->getComponent('VTIMEZONE')) {