date_ical_plugin_row_ical_fields.inc 14.6 KB
Newer Older
1 2 3 4 5 6 7 8
<?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.
 */

/**
9
 * A Views plugin which builds an iCal VEVENT from a views row with Fields.
10 11
 */
class date_ical_plugin_row_ical_fields extends views_plugin_row {
12

13 14 15 16
  /**
   * Set up the options for the row plugin.
   */
  public function option_definition() {
17 18 19 20 21 22 23 24
    $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;
  }
25

26 27 28 29
  /**
   * Build the form for setting the row plugin's options.
   */
  public function options_form(&$form, &$form_state) {
30 31 32
    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);
33 34
    $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);
35

36
    $form['instructions'] = array(
37
      // The surrounding <div> ensures that the settings dialog expands.
38 39
      '#prefix' => '<div style="font-size: 90%">',
      '#suffix' => '</div>',
40
      '#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."),
41 42 43 44
    );
    $form['date_field'] = array(
      '#type' => 'select',
      '#title' => t('Date field'),
45 46
      '#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.'),
47 48 49 50 51 52 53 54 55 56
      '#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'],
57
      '#required' => FALSE,
58 59 60 61 62 63 64 65 66
    );
    $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'],
67
      '#required' => FALSE,
68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86
    );
    $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,
87
        because it is impossible to create an iCal event with no date. This option makes Views silently skip those results, instead.'),
88 89 90
      '#default_value' => $this->options['additional_settings']['skip_blank_dates'],
    );
  }
91

92 93 94 95
  /**
   * Set up the environment for the render() function.
   */
  public function pre_render($result) {
96 97 98 99 100 101
    // 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];
    }
102
    $this->repeated_dates = array();
103
  }
104

105
  /**
106
   * Returns an Event array row in the query with index: $row->index.
107
   */
108
  public function render($row) {
109
    $date_field_name = $this->options['date_field'];
110

111 112 113 114 115 116 117 118 119 120 121 122 123 124 125
    // 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;
          }
        }
      }
    }
126

127 128
    // Fetch the event's date information.
    try {
129 130 131 132
      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']));
133 134 135 136
        if (empty($title)) {
          $title = "Undetermined title";
        }
        throw new BlankDateFieldException(t("The row titled %title has no available Date value. An iCal entry cannot be created for it.", array('%title' => $title)));
137 138
      }
      $date = $this->get_row_date($row, $date_field_name);
139 140
    }
    catch (BlankDateFieldException $e) {
141 142
      // Unless the user has specifically said that they want to skip rows
      // with blank dates, let this exception percolate.
143 144 145 146 147 148 149
      if ($this->options['additional_settings']['skip_blank_dates']) {
        return NULL;
      }
      else {
        throw $e;
      }
    }
150

151 152
    // Create the event by starting with the date array from this row.
    $event = $date;
153

154 155
    $entity = $row->_field_data[$this->view->base_field]['entity'];
    $entity_type = $row->_field_data[$this->view->base_field]['entity_type'];
156 157 158 159 160
    // 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)) {
161
      $event['created'] = new DateObject($entity->created, 'UTC');
162
    }
163
    if (isset($entity->changed)) {
164
      $event['last-modified'] = new DateObject($entity->changed, 'UTC');
165
    }
166
    elseif (isset($entity->created)) {
167
      // If changed is unset, but created is, use that for last-modified.
168
      $event['last-modified'] = new DateObject($entity->created, 'UTC');
169
    }
170 171 172
    $uri = entity_uri($entity_type, $entity);
    $uri['options']['absolute'] = TRUE;
    $event['url'] = url($uri['path'], $uri['options']);
173

174 175
    // Generate a unique ID for this event by emulating the way the Date module
    // creates a Date ID.
176 177
    if (isset($row->{"field_data_{$date_field_name}_delta"})) {
      $date_field_delta = $row->{"field_data_{$date_field_name}_delta"};
178 179
    }
    else {
180
      // I'm not sure why the "field_data_{field_name}_delta" field is part of
181 182 183 184
      // 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;
185
      foreach ($entity->{$date_field_name}['und'] as $ndx => $date_array) {
186 187 188 189 190 191 192
        if ($date['start']->originalTime == $date_array['value']) {
          $date_field_delta = $ndx;
          break;
        }
      }
    }
    $entity_id = $row->{$this->view->base_field};
193 194
    global $base_url;
    $domain = preg_replace('#^https?://#', '', $base_url);
195
    $event['uid'] = "calendar.$entity_id.$date_field_name.$date_field_delta@$domain";
196

197 198 199 200 201 202 203 204 205 206 207 208 209 210
    // 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;
      }
    }
211

212
    // Retrieve the rendered text fields.
213 214 215
    $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']);
216

217 218 219 220 221
    // 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,
222
      'row_index' => $row->index,
223
      'language' => $this->language,
224
      'options' => $this->options,
225
    );
226
    drupal_alter('date_ical_export_html', $text_fields, $this->view, $context);
227

228
    // Sanitize the text fields for iCal compliance, and add them to the event.
229 230
    $event['summary'] = date_ical_sanitize_text($text_fields['summary']);
    $event['location'] = date_ical_sanitize_text($text_fields['location']);
231
    $event['description'] = date_ical_sanitize_text($text_fields['description']);
232

233
    // Allow other modules to alter the event object before it gets passed to
234
    // the style plugin to be converted into an iCal VEVENT.
235
    drupal_alter('date_ical_export_raw_event', $event, $this->view, $context);
236

237 238 239 240 241 242 243 244
    return $event;
  }

  /**
   * Returns an normalized array for the current row's datefield/timestamp.
   *
   * @param object $row
   *   The current row object.
245 246
   * @param string $date_field_name
   *   The name of the date field.
247 248 249
   *
   * @return array
   *   The normalized array.
250 251
   */
  protected function get_row_date($row, $date_field_name) {
252 253 254 255 256
    $start = NULL;
    $end   = NULL;
    $rrule = NULL;
    $delta = 0;
    $is_date_field = FALSE;
257

258
    // Fetch the date field value.
259
    $date_field_value = $this->view->style_plugin->get_field_value($row->index, $date_field_name);
260

261 262 263 264
    // 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];
265

266
      $start = new DateObject($date_field['value'], $date_field['timezone_db']);
267
      if (!empty($date_field['value2'])) {
268
        $end = new DateObject($date_field['value2'], $date_field['timezone_db']);
269 270
      }
      else {
271
        $end = clone $start;
272
      }
273

274 275 276 277 278
      if (isset($date_field['rrule'])) {
        $rrule = $date_field['rrule'];
      }
    }
    elseif (is_numeric($date_field_value)) {
279
      // Handle timestamps, which are always in UTC.
280 281 282 283
      $start = new DateObject($date_field_value, 'UTC');
      $end   = new DateObject($date_field_value, 'UTC');
    }
    else {
284 285 286
      // 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)));
287
    }
288

289 290 291
    // 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
292 293 294
    $dtz = new DateTimeZone($timezone);
    $start->setTimezone($dtz);
    $end->setTimezone($dtz);
295

296 297
    $granularity = 'second';
    if ($is_date_field) {
298
      $granularity_settings = $this->view->field[$date_field_name]->field_info['settings']['granularity'];
299 300
      $granularity = date_granularity_precision($granularity_settings);
    }
301

302 303 304 305 306 307
    // 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
    );
308

309 310 311 312 313 314
    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");
    }
315

316 317 318 319 320 321
    $date = array(
      'start' => $start,
      'end' => $end,
      'all_day' => $all_day,
      'rrule' => $rrule,
    );
322

323 324 325 326
    return $date;
  }

  /**
327 328 329
   * Filter the list of views fields down to only supported date-type fields.
   *
   * The supported date-type fields are timestamps and the three Date fields.
330 331
   *
   * @param array $view_fields
332
   *   An associative array like views_plugin_display::get_field_labels().
333 334
   *
   * @return array
335
   *   An associative array (alias => label) of date fields.
336
   */
337
  protected function get_date_field_candidates($view_fields) {
338 339
    $handlers = $this->display->handler->get_handlers('field');
    $field_candidates = array();
340 341
    // These are Date, Date (ISO format), and Date (Unix timestamp).
    $date_fields = array('datetime', 'date', 'datestamp');
342

343
    foreach ($view_fields as $alias => $label) {
344 345 346 347
      $handler_class = get_class($handlers[$alias]);
      if ($handler_class == 'views_handler_field_date'
          || ($handler_class == 'views_handler_field_field'
            && in_array($handlers[$alias]->field_info['type'], $date_fields))) {
348 349 350 351 352 353 354 355 356 357
        $field_candidates[$alias] = $label;
      }
    }
    return $field_candidates;
  }

  /**
   * Retrieves a field value from the style plugin.
   *
   * @param int $index
358
   *   The index count of the row
359 360
   * @param string $field_id
   *   The ID assigned to the required field in the display.
361
   *
362
   * @see views_plugin_style::get_field()
363
   */
364
  protected function get_field($index, $field_id) {
365 366 367 368 369 370
    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);
  }
}