date_ical_plugin_row_ical_fields.inc 14.6 KB
Newer Older
1 2 3 4 5 6 7 8 9 10 11 12
<?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 {
13
  
14 15 16 17 18 19 20 21 22
  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;
  }
23
  
24 25 26 27
  function options_form(&$form, &$form_state) {
    parent::options_form($form, $form_state);
    $all_field_labels = $this->display->handler->get_field_labels();
    $date_field_labels = $this->get_date_field_candidates($all_field_labels);
28 29
    $date_field_label_options = array_merge(array('first_available' => t('First populated Date field')), $date_field_labels);
    $text_field_label_options = array_merge(array('' => t('- None -')), $all_field_labels);
30 31 32 33 34
    
    $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>',
35
      '#markup' => t("Once you've finished setting up the fields for this View, you may want to return to this dialog to set the Date field."),
36 37 38 39
    );
    $form['date_field'] = array(
      '#type' => 'select',
      '#title' => t('Date field'),
40 41
      '#description' => t('The views field to use as the start (and possibly end) time for each event (DTSTART/DTEND).
        If you retain the default ("First populated Date field"), Date iCal will use the first non-empty Date field in the row.'),
42 43 44 45 46 47 48 49 50 51
      '#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'],
52
      '#required' => FALSE,
53 54 55 56 57 58 59 60 61
    );
    $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'],
62
      '#required' => FALSE,
63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81
    );
    $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,
82
        because it is impossible to create an iCal event with no date. This option makes Views silently skip those results, instead.'),
83 84 85 86 87 88 89 90 91 92 93
      '#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];
    }
94
    $this->repeated_dates = array();
95 96 97
  }
  
  /**
98 99
   * Returns an Event array based on the query result from the view whose
   * index is $row->index.
100 101
   */
  function render($row) {
102
    $date_field_name = $this->options['date_field'];
103
    
104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119
    // If this view is set to use the first populated date field, check each
    // field in the row to find the first non-NULL Date field.
    if ($date_field_name == 'first_available') {
      foreach (get_object_vars($row) as $name => $value) {
        if (strpos($name, 'field_field') === 0) {
          // This property's name starts with "field_field", which means it's
          // the actual field data for a field in this view.
          if (!empty($value[0]['raw']['date_type'])) {
            // Cut off the first "field_" from $name to get the field name.
            $date_field_name = substr($name, 6);
            break;
          }
        }
      }
    }
    
120 121
    // Fetch the event's date information.
    try {
122 123 124 125 126 127 128
      if ($date_field_name == 'first_available') {
        // If $date_field_name is still 'first_available' at this point, we
        // couldn't find an available Date value. Processing cannot proceed.
        $title = strip_tags($this->view->style_plugin->get_field($row->index, $this->options['title_field']));
        throw new BlankDateFieldException(t("The row %title has no available Date value. An iCal entry cannot be created for it.", array('%title' => $title)));
      }
      $date = $this->get_row_date($row, $date_field_name);
129 130
    }
    catch (BlankDateFieldException $e) {
131 132
      // Unless the user has specifically said that they want to skip rows
      // with blank dates, let this exception percolate.
133 134 135 136 137 138 139 140
      if ($this->options['additional_settings']['skip_blank_dates']) {
        return NULL;
      }
      else {
        throw $e;
      }
    }
    
141 142 143
    // Create the event by starting with the date array from this row.
    $event = $date;
    
144 145
    $entity = $row->_field_data[$this->view->base_field]['entity'];
    $entity_type = $row->_field_data[$this->view->base_field]['entity_type'];
146 147 148 149 150
    // Add the CREATED, LAST-MODIFIED, and URL components based on the entity.
    // According to the iCal standard, CREATED and LAST-MODIFIED must be UTC.
    // Fortunately, Drupal stores timestamps in the DB as UTC, so we just need
    // to specify that UTC be used rather than the server's local timezone.
    if (isset($entity->created)) {
151
      $event['created'] = new DateObject($entity->created, 'UTC');
152
    }
153
    if (isset($entity->changed)) {
154
      $event['last-modified'] = new DateObject($entity->changed, 'UTC');
155
    }
156
    else if (isset($entity->created)) {
157
      // If changed is unset, but created is, use that for last-modified.
158
      $event['last-modified'] = new DateObject($entity->created, 'UTC');
159
    }
160 161 162
    $uri = entity_uri($entity_type, $entity);
    $uri['options']['absolute'] = TRUE;
    $event['url'] = url($uri['path'], $uri['options']);
163
    
164 165
    // Generate a unique ID for this event by emulating the way the Date module
    // creates a Date ID.
166 167
    if (isset($row->{"field_data_{$date_field_name}_delta"})) {
      $date_field_delta = $row->{"field_data_{$date_field_name}_delta"};
168 169
    }
    else {
170
      // I'm not sure why the "field_data_{field_name}_delta" field is part of
171 172 173 174
      // the $row, so it's possible that it will sometimes be missing. If it
      // is, make an educated guess about the delta by comparing this row's
      // start date to each of the entity's dates.
      $date_field_delta = 0;
175
      foreach ($entity->{$date_field_name}['und'] as $ndx => $date_array) {
176 177 178 179 180 181 182
        if ($date['start']->originalTime == $date_array['value']) {
          $date_field_delta = $ndx;
          break;
        }
      }
    }
    $entity_id = $row->{$this->view->base_field};
183
    $domain = check_plain($_SERVER['SERVER_NAME']);
184
    $event['uid'] = "calendar.$entity_id.$date_field_name.$date_field_delta@$domain";
185
    
186 187 188 189 190 191 192 193 194 195 196 197 198 199 200
    // Because of the way that Date implements repeating dates, we're going to
    // be given a separate view result for each repeat. We only want to
    // render a VEVENT (with an RRULE) for the first instance of that date, so
    // we need to record the entity ID and field name for each result that has
    // an RRULE, then skip any that we've already seen.
    if (!empty($date['rrule'])) {
      $repeat_id = "$entity_id.$date_field_name";
      if (!isset($this->repeated_dates[$repeat_id])) {
        $this->repeated_dates[$repeat_id] = $repeat_id;
      }
      else {
        return FALSE;
      }
    }
    
201
    // Retrieve the rendered text fields.
202 203 204
    $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']);
205 206 207 208 209 210
    
    // 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,
211
      'row_index' => $row->index,
212
      'language' => $this->language,
213
      'options' => $this->options,
214
    );
215
    drupal_alter('date_ical_export_html', $text_fields, $this->view, $context);
216 217 218 219 220 221 222 223 224 225
    
    // 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']);
    
    // Allow other modules to alter the event object, before it gets passed to
    // the style plugin to be converted into an iCal VEVENT.
226
    drupal_alter('date_ical_export_raw_event', $event, $this->view, $context);
227 228 229 230 231 232 233 234 235
    
    return $event;
  }

  /**
   * Returns an normalized array for the current row's datefield/timestamp.
   *
   * @param object $row
   *   The current row object.
236 237
   * @param string $date_field_name
   *   The name of the date field.
238 239 240 241
   *
   * @return array
   *   The normalized array.
  */
242
  function get_row_date($row, $date_field_name) {
243 244 245 246 247
    $start = NULL;
    $end   = NULL;
    $rrule = NULL;
    $delta = 0;
    $is_date_field = FALSE;
248
    
249
    // Fetch the date field value.
250
    $date_field_value = $this->view->style_plugin->get_field_value($row->index, $date_field_name);
251
    
252 253 254 255
    // 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];
256
      
257
      $start = new DateObject($date_field['value'], $date_field['timezone_db']);
258
      if (!empty($date_field['value2'])) {
259
        $end = new DateObject($date_field['value2'], $date_field['timezone_db']);
260 261
      }
      else {
262
        $end = clone $start;
263 264 265 266 267 268 269
      }
      
      if (isset($date_field['rrule'])) {
        $rrule = $date_field['rrule'];
      }
    }
    elseif (is_numeric($date_field_value)) {
270
      // Handle timestamps, which are always in UTC.
271 272 273 274
      $start = new DateObject($date_field_value, 'UTC');
      $end   = new DateObject($date_field_value, 'UTC');
    }
    else {
275 276 277
      // Processing cannot proceed with a blank date value.
      $title = strip_tags($this->view->style_plugin->get_field($row->index, $this->options['title_field']));
      throw new BlankDateFieldException(t("The row %title has a blank date. An iCal entry cannot be created for it.", array('%title' => $title)));
278
    }
279
    
280 281 282
    // 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);
Robert Rollins's avatar
Robert Rollins committed
283 284 285
    $dtz = new DateTimeZone($timezone);
    $start->setTimezone($dtz);
    $end->setTimezone($dtz);
286
    
287 288
    $granularity = 'second';
    if ($is_date_field) {
289
      $granularity_settings = $this->view->field[$date_field_name]->field_info['settings']['granularity'];
290 291
      $granularity = date_granularity_precision($granularity_settings);
    }
292
    
293 294 295 296 297 298
    // 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
    );
299
    
300 301 302 303 304 305
    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");
    }
306
    
307 308 309 310 311 312
    $date = array(
      'start' => $start,
      'end' => $end,
      'all_day' => $all_day,
      'rrule' => $rrule,
    );
313
    
314 315 316 317 318
    return $date;
  }

  /**
   * Filter the list of views fields down to only those which are supported date-type fields.
319
   * At this time, the supported date-type fields are timestamps and the three Date fields.
320 321 322 323 324 325 326 327 328 329 330 331 332
   *
   * @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'
333
        || (get_class($handler) == 'views_handler_field_field' &&
334
          // These are Date, Date (ISO format), and Date (Unix timestamp).
335
          in_array($handler->field_info['type'], array('datetime', 'date', 'datestamp')))) {
336 337 338 339 340 341 342 343 344 345
        $field_candidates[$alias] = $label;
      }
    }
    return $field_candidates;
  }

  /**
   * Retrieves a field value from the style plugin.
   *
   * @param int $index
346
   *   The index count of the row
347 348
   * @param string $field_id
   *   The ID assigned to the required field in the display.
349 350
   *
   * @see views_plugin_style::get_field().
351 352 353 354 355 356 357 358
   */
  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);
  }
}