Commit c3d8201e authored by Robert Rollins's avatar Robert Rollins
Browse files

Issue [#2027633]: Added a fields-based iCal views row plugin, originally written by Nebel54.

parent 10aa42f7
......@@ -2,7 +2,8 @@
/**
* Alter the HTML of an event's Summary and Description, before it gets converted
* to plaintext for output in an iCal feed.
* to plaintext for output in an iCal feed. This hook is only used by the
* iCal Entity views row plugin.
*
* @param $data
* A reference to an associative array with the following keys and values:
......@@ -17,7 +18,30 @@
* - 'language': The language code that indicates which translation of field
* data should be used.
*/
function hook_date_ical_html_alter(&$data, $view, &$context) {
function hook_date_ical_html_alter(&$data, $view, $context) {
}
/**
* Alter the HTML of an event's text fields, before it gets converted
* to plaintext for output in an iCal feed. This hook is only used by the
* iCal Fields views row plugin.
*
* @param $text_fields
* A reference to an associative array with the following keys and values:
* - 'description': The description field string.
* - 'summary': The title field string
* - 'location': The location field string.
* @param $view
* The view object that is being executed to render the iCal feed.
* @param $context
* An associative array of context, with the following keys and values:
* - 'row': The single query result row that is being converted into an iCal VEVENT.
* - 'row_index': The index into the full query results for this row.
* - 'language': The language code that indicates which translation of field
* data should be used.
*/
function hook_date_ical_fields_html_alter(&$text_fields, $view, $context) {
}
......@@ -41,7 +65,7 @@ function hook_date_ical_html_alter(&$data, $view, &$context) {
* - 'language': The language code that indicates which translation of field
* data should be used.
*/
function hook_date_ical_feed_event_render_alter(&$event, $view, &$context) {
function hook_date_ical_feed_event_render_alter(&$event, $view, $context) {
// Simple example adding the location to a rendered event from a simple
// textfield called 'field_location'.
$entity_type = $context['entity_type'];
......@@ -124,7 +148,7 @@ function hook_date_ical_icalcreator_calendar_alter(&$calendar, &$context) {
* - 'source': FeedsSource object associated with this Feed.
* - 'fetcher_result': The FeedsFetcherResult object associated with this Feed.
*/
function hook_date_ical_icalcreator_component_alter(&$component, &$context) {
function hook_date_ical_icalcreator_component_alter(&$component, $context) {
// Example of what might be done with this alter hook
if ($component->getComponentType() == 'vevent') {
// Do something for vevents ...
......@@ -147,7 +171,7 @@ function hook_date_ical_icalcreator_component_alter(&$component, &$context) {
* - 'parser_result': The parsed result of the whole Calendar.
* - 'feeds_source': Contains all the metadata about the configuration of this Feed.
*/
function hook_date_ical_feeds_object_alter(&$value, &$context) {
function hook_date_ical_feeds_object_alter(&$value, $context) {
// Example of what might be done with this alter hook
if ($context['property_key'] == 'dtstart') {
// Tweak the parsed FeedsDateTime object created from the start time.
......@@ -170,7 +194,7 @@ function hook_date_ical_feeds_object_alter(&$value, &$context) {
* - 'parser_result': The parsed result of the whole Calendar.
* - 'feeds_source': Contains all the metadata about the configuration of this Feed.
*/
function hook_date_ical_timezone_alter(&$tz_string, &$context) {
function hook_date_ical_timezone_alter(&$tz_string, $context) {
// Example of what might be done with this alter hook:
if ($tz_string == 'Eastern Standard Time') {
// "Eastern Standard Time" is a deprecated timezone string, which PHP doesn't
......
......@@ -15,6 +15,7 @@ dependencies[] = date_api
; Includes
files[] = includes/date_ical_plugin_row_ical_entity.inc
files[] = includes/date_ical_plugin_row_ical_fields.inc
files[] = includes/date_ical_plugin_style_ical_feed.inc
files[] = includes/DateIcalFeedParser.inc
files[] = includes/DateIcalIcalcreatorParser.inc
......
......@@ -7,6 +7,16 @@
* TODO Figure out how to incorporate VVENUE information into the parser.
*/
/**
* Exception for when a field which isn't a date field is used by the ical_fields row plugin.
*/
class InvalidDateFieldException extends Exception { }
/**
* Exception for when a the date field for a row in the ical_fields row plugin is blank.
*/
class BlankDateFieldException extends Exception { }
/**
* Implements hook_views_api().
*/
......@@ -184,16 +194,16 @@ function date_ical_feeds_processor_targets_alter(&$targets, $entity_type, $bundl
/**
* Reformats the provided text to be compliant with the iCal spec.
* If the text contains HTML tags, those tags will be stripped
* (with <p> tags converted to \n\n), and uneeded whitespace
* will be cleaned up.
* If the text contains HTML tags, those tags will be stripped (with <p> tags
* converted to "\n\n" and link tags converted to footnotes), and uneeded
* whitespace will be cleaned up.
*
* @param $text
* The text to be reformatted.
* The text to be sanitized.
*/
function date_ical_format_text($text = '') {
function date_ical_sanitize_text($text = '') {
// Use Drupal's built-in HTML to Text converter, which does a mostly adequate
// job of making the text iCal-compliant. It's not prefect, though.
// job of making the text iCal-compliant.
$text = trim(drupal_html_to_text($text));
// Replace instances of more than one space with exactly one space. This
// cleans up the whitespace mess that gets left behind by drupal_html_to_text().
......
......@@ -10,9 +10,10 @@
*/
function date_ical_views_plugins() {
$includes_path = drupal_get_path('module', 'date_ical') . '/includes';
$data = array(
'module' => 'date_ical', // This just tells our themes are elsewhere.
// This just tells our themes are elsewhere.
'module' => 'date_ical',
'style' => array(
'date_ical' => array(
'title' => t('iCal Feed'),
......@@ -37,8 +38,17 @@ function date_ical_views_plugins() {
'uses fields' => FALSE,
'type' => 'feed',
),
'date_ical_fields' => array(
'title' => t('iCal Fields'),
'help' => t('Display fields for an iCal VEVENT item.'),
'handler' => 'date_ical_plugin_row_ical_fields',
'path' => $includes_path,
'uses options' => TRUE,
'uses fields' => TRUE,
'type' => 'feed',
),
),
);
return $data;
}
......@@ -223,8 +223,6 @@ class date_ical_plugin_row_ical_entity extends views_plugin_row {
$end->modify("+1 day");
}
module_load_include('inc', 'date_api', 'date_api_ical');
// If the user specified a LOCATION field, pull that data from the entity.
$location = '';
if (!empty($this->options['location_field']) && $this->options['location_field'] != 'none') {
......@@ -307,8 +305,8 @@ class date_ical_plugin_row_ical_entity extends views_plugin_row {
drupal_alter('date_ical_html', $data, $this->view, $context);
$event = array();
$event['summary'] = date_ical_format_text($data['summary']);
$event['description'] = date_ical_format_text($data['description']);
$event['summary'] = date_ical_sanitize_text($data['summary']);
$event['description'] = date_ical_sanitize_text($data['description']);
$event['all_day'] = $all_day;
$event['start'] = $start;
$event['end'] = $end;
......@@ -333,7 +331,8 @@ class date_ical_plugin_row_ical_entity extends views_plugin_row {
$event['last-modified'] = new DateObject($entity->changed, new DateTimeZone('UTC'));
}
// Allow other modules to alter the structured event object, before it gets converted to an iCal VEVENT.
// Allow other modules to alter the structured event object, before it gets
// passed to the style plugin to be converted into an iCal VEVENT.
drupal_alter('date_ical_feed_event_render', $event, $this->view, $context);
return $event;
......
<?php
/**
* @file
* Defines the iCal Fields row style plugin, which lets users map view fields
* to the components of the VEVENTs in the iCal feed.
*/
/**
* Plugin which creates a view on the resulting object
* and formats it as an iCal VEVENT.
*/
class date_ical_plugin_row_ical_fields extends views_plugin_row {
function option_definition() {
$options = parent::option_definition();
$options['date_field'] = array('default' => '');
$options['title_field'] = array('default' => '');
$options['description_field'] = array('default' => '');
$options['location_field'] = array('default' => '');
$options['additional_settings']['skip_blank_dates'] = array('default' => FALSE);
return $options;
}
function options_form(&$form, &$form_state) {
parent::options_form($form, $form_state);
$blank_label = array('' => t('- None -'));
$all_field_labels = $this->display->handler->get_field_labels();
$date_field_labels = $this->get_date_field_candidates($all_field_labels);
$date_field_label_options = array_merge($blank_label, $date_field_labels);
// Only offer non-date fields for the textual components.
$text_field_label_options = array_merge($blank_label, array_diff_key($all_field_labels, $date_field_labels));
$form['instructions'] = array(
// The surrounding <div> is necessary to ensure that the settings dialog expands to show everything.
'#prefix' => '<div style="font-size: 90%">',
'#suffix' => '</div>',
'#markup' => t("Before applying these settings, this view will need to be configured to load the fields you wish to map into the events in your iCal feed.
If you have no fields to choose, you may wish to click the X button in the upper-right to close this dialog."),
);
$form['date_field'] = array(
'#type' => 'select',
'#title' => t('Date field'),
'#description' => t('The views field to use as the start (and possibly end) time for each event (DTSTART/DTEND).'),
'#options' => $date_field_label_options,
'#default_value' => $this->options['date_field'],
'#required' => TRUE,
);
$form['title_field'] = array(
'#type' => 'select',
'#title' => t('Title field'),
'#description' => t('The views field to use as the title for each event (SUMMARY).'),
'#options' => $text_field_label_options,
'#default_value' => $this->options['title_field'],
'#required' => TRUE,
);
$form['description_field'] = array(
'#type' => 'select',
'#title' => t('Description field'),
'#description' => t("The views field to use as the body text for each event (DESCRIPTION).<br>
If you wish to include more than one entity field in the event body, you may want to use the 'Content: Rendered Node' views field,
and set it to the 'iCal' view mode. Then configure the iCal view mode on your event nodes to include the text you want."),
'#options' => $text_field_label_options,
'#default_value' => $this->options['description_field'],
'#required' => TRUE,
);
$form['location_field'] = array(
'#type' => 'select',
'#title' => t('Location field'),
'#description' => t('(optional) The views field to use as the location for each event (LOCATION).'),
'#options' => $text_field_label_options,
'#default_value' => $this->options['location_field'],
'#required' => FALSE,
);
$form['additional_settings'] = array(
'#type' => 'fieldset',
'#title' => t('Additional settings'),
'#collapsible' => FALSE,
'#collapsed' => FALSE,
);
$form['additional_settings']['skip_blank_dates'] = array(
'#type' => 'checkbox',
'#title' => t('Skip blank dates'),
'#description' => t('Normally, if a view result has a blank date field, the feed will display an error,
because it is impossible to create an iCal event with no date. This option makes Views skip those results, instead of erroring.'),
'#default_value' => $this->options['additional_settings']['skip_blank_dates'],
);
}
function pre_render($result) {
// Get the language for this view.
$this->language = $this->display->handler->get_option('field_language');
$substitutions = views_views_query_substitutions($this->view);
if (array_key_exists($this->language, $substitutions)) {
$this->language = $substitutions[$this->language];
}
}
/**
* Returns an Event array based on the query result from the view whose index is
* specified in the (hidden) second parameter of this function.
*/
function render($row) {
// Using func_get_args() instead of declaring $row_index in the arguments,
// because this function must be compatible with views_plugin_row::render().
$args = func_get_args();
$row_index = $args[1];
// Fetch the event's date information.
try {
$date = $this->get_row_date($row_index);
}
catch (BlankDateFieldException $e) {
if ($this->options['additional_settings']['skip_blank_dates']) {
return NULL;
}
else {
throw $e;
}
}
$event = array();
// Add the LAST-MODIFIED and URL components based on the original entity.
$entity = $row->_field_data[$this->view->base_field]['entity'];
$entity_type = $row->_field_data[$this->view->base_field]['entity_type'];
if (isset($entity->changed)) {
$event['last-modified'] = new DateObject($entity->changed, new DateTimeZone('UTC'));
}
$uri = entity_uri($entity_type, $entity);
$uri['options']['absolute'] = TRUE;
$event['url'] = url($uri['path'], $uri['options']);
// Generate a uid.
$domain = check_plain($_SERVER['SERVER_NAME']);
$event['uid'] = "calendar." . $row->{$this->view->base_field} . "." . $this->options['date_field'] . "@" . $domain;
// Create the primary text fields.
$text_fields['summary'] = $this->get_field($row_index, $this->options['title_field']);
$text_fields['description'] = $this->get_field($row_index, $this->options['description_field']);
$text_fields['location'] = $this->get_field($row_index, $this->options['location_field']);
// Allow other modules to alter the rendered text fields before they get
// sanitized for iCal-compliance. This is most useful for fields of type
// "Content: Rendered Node", which are likely to have complex HTML.
$context = array(
'row' => $row,
'row_index' => $row_index,
'language' => $this->language,
);
drupal_alter('date_ical_fields_html', $text_fields, $this->view, $context);
// Sanitize the text fields for iCal compliance, and add them to the event.
// Also strip all HTML from the summary and location fields, since they
// must be plaintext, and may have been set as links by the view.
$event['summary'] = date_ical_sanitize_text(strip_tags($text_fields['summary']));
$event['location'] = date_ical_sanitize_text(strip_tags($text_fields['location']));
$event['description'] = date_ical_sanitize_text($text_fields['description']);
// Add the date data.
$event = array_merge($event, $date);
// Allow other modules to alter the event object, before it gets passed to
// the style plugin to be converted into an iCal VEVENT.
$context = array(
'entity' => $entity,
'entity_type' => $entity_type,
'language' => $this->language,
);
drupal_alter('date_ical_feed_event_render', $event, $this->view, $context);
return $event;
}
/**
* Returns an normalized array for the current row's datefield/timestamp.
*
* @param object $row
* The current row object.
* @param int $row_index
* The current row index.
*
* @return array
* The normalized array.
*/
function get_row_date($row_index) {
// Fetch the date field value.
$date_field_value = $this->view->style_plugin->get_field_value($row_index, $this->options['date_field']);
$start = NULL;
$end = NULL;
$rrule = NULL;
$delta = 0;
$is_date_field = FALSE;
// Handle date fields.
if (isset($date_field_value[$delta]) && is_array($date_field_value[$delta])) {
$is_date_field = TRUE;
$date_field = $date_field_value[$delta];
if (!empty($date_field['value'])) {
$start = new DateObject($date_field['value'], $date_field['timezone_db']);
if (array_key_exists('value2', $date_field)) {
$end = new DateObject($date_field['value2'], $date_field['timezone_db']);
}
else {
$end = clone $start;
}
}
else {
$title = $this->view->style_plugin->get_field($row_index, $this->options['title_field']);
throw new BlankDateFieldException("The row '$title' has a blank date. An iCal entry cannot be created for it.");
}
if (isset($date_field['rrule'])) {
$rrule = $date_field['rrule'];
}
}
elseif (is_numeric($date_field_value)) {
// Handle timestamps, which are always saved in UTC.
$start = new DateObject($date_field_value, 'UTC');
$end = new DateObject($date_field_value, 'UTC');
}
else {
// $date_field_value is in an unknown format.
throw new InvalidDateFieldException("views_plugin_row_ical_fields: The field [{$this->options['date_field']}] is not a known date-style field. Please choose a datetime or timestamp field in the settings.");
}
// Set the display timezone to whichever tz is stored for this field.
// If there isn't a stored TZ, use the site default.
$timezone = isset($date_field['timezone']) ? $date_field['timezone'] : date_default_timezone(FALSE);
$start->setTimezone(new DateTimeZone($timezone));
$end->setTimezone(new DateTimeZone($timezone));
$granularity = 'second';
if ($is_date_field) {
$granularity_settings = $this->view->field[$this->options['date_field']]->field_info['settings']['granularity'];
$granularity = date_granularity_precision($granularity_settings);
}
// Check if the start and end dates indicate that this is an All Day event.
$all_day = date_is_all_day(
date_format($start, DATE_FORMAT_DATETIME),
date_format($end, DATE_FORMAT_DATETIME),
$granularity
);
if ($all_day) {
// According to RFC 2445 (clarified in RFC 5545) the DTEND value is
// non-inclusive. When dealing with All Day values, they are DATEs rather
// than DATETIMEs, so we need to add a day to conform to RFC.
$end->modify("+1 day");
}
$date = array(
'start' => $start,
'end' => $end,
'all_day' => $all_day,
'rrule' => $rrule,
);
return $date;
}
/**
* Filter the list of views fields down to only those which are supported date-type fields.
* At this time, the supported date-type fields are datetime fields and timestamps.
*
* @param array $view_fields
* An key=>value array @see views_plugin_display::get_field_labels().
*
* @return array
* An key=>value array (alias => label) of date fields.
*/
function get_date_field_candidates($view_fields) {
$handlers = $this->display->handler->get_handlers('field');
$field_candidates = array();
foreach ($view_fields as $alias => $label) {
$handler = $handlers[$alias];
if (get_class($handler) == 'views_handler_field_date'
|| (get_class($handler) == 'views_handler_field_field' && $handler->field_info['type'] == 'datetime')) {
$field_candidates[$alias] = $label;
}
}
return $field_candidates;
}
/**
* Retrieves a field value from the style plugin.
*
* @param int $index
* The index count of the row @see views_plugin_style::get_field().
* @param string $field_id
* The ID assigned to the required field in the display.
*/
function get_field($index, $field_id) {
if (empty($this->view->style_plugin) || !is_object($this->view->style_plugin) || empty($field_id)) {
return '';
}
return $this->view->style_plugin->get_field($index, $field_id);
}
}
......@@ -82,14 +82,27 @@ class date_ical_plugin_style_ical_feed extends views_plugin_style {
}
function render() {
if (empty($this->row_plugin) || $this->row_plugin->plugin_name != 'date_ical') {
debug('views_plugin_style_ical_feed: This style plugin supports only the iCal Entity row plugin.');
return t('To enable iCal output, this view\'s Format must be configured to Show: iCal Entity.');
if (empty($this->row_plugin) || !in_array($this->row_plugin->plugin_name, array('date_ical','date_ical_fields'))) {
debug('views_plugin_style_ical_feed: This style plugin supports only iCal Entity row and iCal Fields plugins.', NULL, TRUE);
return t('To enable iCal output, this view\'s Format must be configured to Show: iCal Entity or iCal Fields.');
}
$rows = array();
if ($this->row_plugin->plugin_name == 'date_ical_fields' && (empty($this->row_plugin->options['date_field'])
|| empty($this->row_plugin->options['title_field']) || empty($this->row_plugin->options['description_field']))) {
// Because these settings are marked as required in the form, this error state will rarely occur. But I ran across it during
// testing, and the error that resulted was totally non-sensical, so I'm adding this in case it does ever happen.
return t("When using the iCal Fields row plugin, the Date, Title, and Description fields are all required.
Please set them up using the Settings link under 'Format -> Show: iCal Fields'.");
}
$events = array();
foreach ($this->view->result as $row_index => $row) {
$this->view->row_index = $row_index;
$rows[] = $this->row_plugin->render($row);
try {
$events[] = $this->row_plugin->render($row, $row_index);
}
catch (Exception $e) {
debug($e->getMessage(), NULL, TRUE);
return $e->getMessage();
}
}
unset($this->view->row_index);
......@@ -121,8 +134,8 @@ class date_ical_plugin_style_ical_feed extends views_plugin_style {
// Now add the VEVENTs.
$timezones = array();
foreach ($rows as $row) {
if (empty($row)) {
foreach ($events as $event) {
if (empty($event)) {
// The row plugin returned NULL for this row, which can happen due to
// various error conditions. The only thing we can do is skip it.
continue;
......@@ -130,11 +143,11 @@ class date_ical_plugin_style_ical_feed extends views_plugin_style {
$vevent = $vcalendar->newComponent('vevent');
// Get the start date as an array.
$start = $row['start']->toArray();
$timezone = $row['start']->getTimezone()->getName();
$start = $event['start']->toArray();
$timezone = $event['start']->getTimezone()->getName();
$timezones[$timezone] = $timezone;
if ($row['all_day']) {
if ($event['all_day']) {
// All day events need to be specified as DATE, rather than DATE-TIME, or they get interpretted wrong.
$vevent->setDtstart($start['year'], $start['month'], $start['day'],
FALSE, FALSE, FALSE, FALSE, array('VALUE' => 'DATE'));
......@@ -151,15 +164,15 @@ class date_ical_plugin_style_ical_feed extends views_plugin_style {
}
// Add the Timezone info to the start date, for use later.
$start['tz'] = $row['start']->getTimezone();
$start['tz'] = $event['start']->getTimezone();
// Only add the end date if there is one.
if (!empty($row['end'])) {
$end = $row['end']->toArray();
$timezone = $row['start']->getTimezone()->getName();
if (!empty($event['end'])) {
$end = $event['end']->toArray();
$timezone = $event['start']->getTimezone()->getName();
$timezones[$timezone] = $timezone;
if ($row['all_day']) {
if ($event['all_day']) {
$vevent->setDtend($end['year'], $end['month'], $end['day'],
FALSE, FALSE, FALSE, FALSE, array('VALUE' => 'DATE'));
}
......@@ -174,15 +187,15 @@ class date_ical_plugin_style_ical_feed extends views_plugin_style {
$timezone);
}
// Keep a copy of the end date, as it's useful later.
$end['tz'] = $row['end']->getTimezone();
$end['tz'] = $event['end']->getTimezone();
}
$vevent->setProperty('uid', $row['uid']);
$vevent->setProperty('summary', $row['summary']);
$vevent->setProperty('uid', $event['uid']);
$vevent->setProperty('summary', $event['summary']);
// Handle repeating dates from the date_repeat module.
if (!empty($row['rrule']) && module_exists('date_repeat')) {
if (!empty($event['rrule']) && module_exists('date_repeat')) {
// Split the rrule into the actual rule, exceptions and additions.
list($rrule, $exceptions, $additions) = date_repeat_split_rrule($row['rrule']);
list($rrule, $exceptions, $additions) = date_repeat_split_rrule($event['rrule']);
// Add the rrule itself. We need to massage the data a bit, since iCalcreator expects RRULEs to be
// in a different format than the Date API gives them to us.
$rrule = self::convert_rrule_for_icalcreator($rrule);
......@@ -227,7 +240,7 @@ class date_ical_plugin_style_ical_feed extends views_plugin_style {
'tz' => $start['tz']->getName(),
);
// If there was an end date specified, use that too.
if (!empty($row['end'])) {
if (!empty($event['end'])) {
$add_date = array($add_date);
$add_date[] = array(
'year' => $addition_array['year'],
......@@ -247,17 +260,17 @@ class date_ical_plugin_style_ical_feed extends views_plugin_style {
$vevent->setRdate($add_dates);
}
}
if (!empty($row['url'])) {
$vevent->setUrl($row['url'], array('type' => 'URI'));
if (!empty($event['url'])) {
$vevent->setUrl($event['url'], array('type' => 'URI'));
}
if (!empty($row['location'])) {
$vevent->setProperty('location', $row['location']);
if (!empty($event['location'])) {