1
|
<?php
|
2
|
/**
|
3
|
* @file
|
4
|
* Defines the iCal Fields row style plugin, which lets users map view fields
|
5
|
* to the components of the VEVENTs in the iCal feed.
|
6
|
*/
|
7
|
|
8
|
/**
|
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
|
* Set up the options for the row plugin.
|
15
|
*/
|
16
|
public function option_definition() {
|
17
|
$options = parent::option_definition();
|
18
|
$options['date_field'] = array('default' => '');
|
19
|
$options['title_field'] = array('default' => '');
|
20
|
$options['description_field'] = array('default' => '');
|
21
|
$options['location_field'] = array('default' => '');
|
22
|
$options['additional_settings']['skip_blank_dates'] = array('default' => FALSE);
|
23
|
return $options;
|
24
|
}
|
25
|
|
26
|
/**
|
27
|
* Build the form for setting the row plugin's options.
|
28
|
*/
|
29
|
public function options_form(&$form, &$form_state) {
|
30
|
parent::options_form($form, $form_state);
|
31
|
$all_field_labels = $this->display->handler->get_field_labels();
|
32
|
$date_field_labels = $this->get_date_field_candidates($all_field_labels);
|
33
|
$date_field_label_options = array_merge(array('first_available' => t('First populated Date field')), $date_field_labels);
|
34
|
$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
|
'#prefix' => '<div style="font-size: 90%">',
|
39
|
'#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
|
$form['date_field'] = array(
|
43
|
'#type' => 'select',
|
44
|
'#title' => t('Date field'),
|
45
|
'#description' => t('The views field to use as the start (and possibly end) time for each event (DTSTART/DTEND).
|
46
|
If you retain the default ("First populated Date field"), Date iCal will use the first non-empty Date field in the row.'),
|
47
|
'#options' => $date_field_label_options,
|
48
|
'#default_value' => $this->options['date_field'],
|
49
|
'#required' => TRUE,
|
50
|
);
|
51
|
$form['title_field'] = array(
|
52
|
'#type' => 'select',
|
53
|
'#title' => t('Title field'),
|
54
|
'#description' => t('The views field to use as the title for each event (SUMMARY).'),
|
55
|
'#options' => $text_field_label_options,
|
56
|
'#default_value' => $this->options['title_field'],
|
57
|
'#required' => FALSE,
|
58
|
);
|
59
|
$form['description_field'] = array(
|
60
|
'#type' => 'select',
|
61
|
'#title' => t('Description field'),
|
62
|
'#description' => t("The views field to use as the body text for each event (DESCRIPTION).<br>
|
63
|
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,
|
64
|
and set it to the 'iCal' view mode. Then configure the iCal view mode on your event nodes to include the text you want."),
|
65
|
'#options' => $text_field_label_options,
|
66
|
'#default_value' => $this->options['description_field'],
|
67
|
'#required' => FALSE,
|
68
|
);
|
69
|
$form['location_field'] = array(
|
70
|
'#type' => 'select',
|
71
|
'#title' => t('Location field'),
|
72
|
'#description' => t('(optional) The views field to use as the location for each event (LOCATION).'),
|
73
|
'#options' => $text_field_label_options,
|
74
|
'#default_value' => $this->options['location_field'],
|
75
|
'#required' => FALSE,
|
76
|
);
|
77
|
$form['additional_settings'] = array(
|
78
|
'#type' => 'fieldset',
|
79
|
'#title' => t('Additional settings'),
|
80
|
'#collapsible' => FALSE,
|
81
|
'#collapsed' => FALSE,
|
82
|
);
|
83
|
$form['additional_settings']['skip_blank_dates'] = array(
|
84
|
'#type' => 'checkbox',
|
85
|
'#title' => t('Skip blank dates'),
|
86
|
'#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
|
'#default_value' => $this->options['additional_settings']['skip_blank_dates'],
|
89
|
);
|
90
|
}
|
91
|
|
92
|
/**
|
93
|
* Set up the environment for the render() function.
|
94
|
*/
|
95
|
public function pre_render($result) {
|
96
|
// Get the language for this view.
|
97
|
$this->language = $this->display->handler->get_option('field_language');
|
98
|
$substitutions = views_views_query_substitutions($this->view);
|
99
|
if (array_key_exists($this->language, $substitutions)) {
|
100
|
$this->language = $substitutions[$this->language];
|
101
|
}
|
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
|
// If this view is set to use the first populated date field, check each
|
112
|
// field in the row to find the first non-NULL Date field.
|
113
|
if ($date_field_name == 'first_available') {
|
114
|
foreach (get_object_vars($row) as $name => $value) {
|
115
|
if (strpos($name, 'field_field') === 0) {
|
116
|
// This property's name starts with "field_field", which means it's
|
117
|
// the actual field data for a field in this view.
|
118
|
if (!empty($value[0]['raw']['date_type'])) {
|
119
|
// Cut off the first "field_" from $name to get the field name.
|
120
|
$date_field_name = substr($name, 6);
|
121
|
break;
|
122
|
}
|
123
|
}
|
124
|
}
|
125
|
}
|
126
|
|
127
|
// Fetch the event's date information.
|
128
|
try {
|
129
|
if ($date_field_name == 'first_available') {
|
130
|
// If $date_field_name is still 'first_available' at this point, we
|
131
|
// couldn't find an available Date value. Processing cannot proceed.
|
132
|
$title = strip_tags($this->view->style_plugin->get_field($row->index, $this->options['title_field']));
|
133
|
if (empty($title)) {
|
134
|
$title = "Undetermined title";
|
135
|
}
|
136
|
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
|
// Unless the user has specifically said that they want to skip rows
|
142
|
// with blank dates, let this exception percolate.
|
143
|
if ($this->options['additional_settings']['skip_blank_dates']) {
|
144
|
return NULL;
|
145
|
}
|
146
|
else {
|
147
|
throw $e;
|
148
|
}
|
149
|
}
|
150
|
|
151
|
// Create the event by starting with the date array from this row.
|
152
|
$event = $date;
|
153
|
|
154
|
$entity = $row->_field_data[$this->view->base_field]['entity'];
|
155
|
$entity_type = $row->_field_data[$this->view->base_field]['entity_type'];
|
156
|
// Add the CREATED, LAST-MODIFIED, and URL components based on the entity.
|
157
|
// According to the iCal standard, CREATED and LAST-MODIFIED must be UTC.
|
158
|
// Fortunately, Drupal stores timestamps in the DB as UTC, so we just need
|
159
|
// to specify that UTC be used rather than the server's local timezone.
|
160
|
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
|
$uri = entity_uri($entity_type, $entity);
|
171
|
$uri['options']['absolute'] = TRUE;
|
172
|
$event['url'] = url($uri['path'], $uri['options']);
|
173
|
|
174
|
// Generate a unique ID for this event by emulating the way the Date module
|
175
|
// creates a Date ID.
|
176
|
if (isset($row->{"field_data_{$date_field_name}_delta"})) {
|
177
|
$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
|
// the $row, so it's possible that it will sometimes be missing. If it
|
182
|
// is, make an educated guess about the delta by comparing this row's
|
183
|
// start date to each of the entity's dates.
|
184
|
$date_field_delta = 0;
|
185
|
foreach ($entity->{$date_field_name}['und'] as $ndx => $date_array) {
|
186
|
if ($date['start']->originalTime == $date_array['value']) {
|
187
|
$date_field_delta = $ndx;
|
188
|
break;
|
189
|
}
|
190
|
}
|
191
|
}
|
192
|
$entity_id = $row->{$this->view->base_field};
|
193
|
global $base_url;
|
194
|
$domain = preg_replace('#^https?://#', '', $base_url);
|
195
|
$event['uid'] = "calendar.$entity_id.$date_field_name.$date_field_delta@$domain";
|
196
|
|
197
|
// Because of the way that Date implements repeating dates, we're going to
|
198
|
// be given a separate view result for each repeat. We only want to
|
199
|
// render a VEVENT (with an RRULE) for the first instance of that date, so
|
200
|
// we need to record the entity ID and field name for each result that has
|
201
|
// an RRULE, then skip any that we've already seen.
|
202
|
if (!empty($date['rrule'])) {
|
203
|
$repeat_id = "$entity_id.$date_field_name";
|
204
|
if (!isset($this->repeated_dates[$repeat_id])) {
|
205
|
$this->repeated_dates[$repeat_id] = $repeat_id;
|
206
|
}
|
207
|
else {
|
208
|
return FALSE;
|
209
|
}
|
210
|
}
|
211
|
|
212
|
// Retrieve the rendered text fields.
|
213
|
$text_fields['summary'] = $this->get_field($row->index, $this->options['title_field']);
|
214
|
$text_fields['description'] = $this->get_field($row->index, $this->options['description_field']);
|
215
|
$text_fields['location'] = $this->get_field($row->index, $this->options['location_field']);
|
216
|
|
217
|
// Allow other modules to alter the rendered text fields before they get
|
218
|
// sanitized for iCal-compliance. This is most useful for fields of type
|
219
|
// "Content: Rendered Node", which are likely to have complex HTML.
|
220
|
$context = array(
|
221
|
'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
|
// Also strip all HTML from the summary and location fields, since they
|
230
|
// must be plaintext, and may have been set as links by the view.
|
231
|
$event['summary'] = date_ical_sanitize_text(strip_tags($text_fields['summary']));
|
232
|
$event['location'] = date_ical_sanitize_text(strip_tags($text_fields['location']));
|
233
|
$event['description'] = date_ical_sanitize_text($text_fields['description']);
|
234
|
|
235
|
// Allow other modules to alter the event object, before it gets passed to
|
236
|
// the style plugin to be converted into an iCal VEVENT.
|
237
|
drupal_alter('date_ical_export_raw_event', $event, $this->view, $context);
|
238
|
|
239
|
return $event;
|
240
|
}
|
241
|
|
242
|
/**
|
243
|
* Returns an normalized array for the current row's datefield/timestamp.
|
244
|
*
|
245
|
* @param object $row
|
246
|
* The current row object.
|
247
|
* @param string $date_field_name
|
248
|
* The name of the date field.
|
249
|
*
|
250
|
* @return array
|
251
|
* The normalized array.
|
252
|
*/
|
253
|
protected function get_row_date($row, $date_field_name) {
|
254
|
$start = NULL;
|
255
|
$end = NULL;
|
256
|
$rrule = NULL;
|
257
|
$delta = 0;
|
258
|
$is_date_field = FALSE;
|
259
|
|
260
|
// Fetch the date field value.
|
261
|
$date_field_value = $this->view->style_plugin->get_field_value($row->index, $date_field_name);
|
262
|
|
263
|
// Handle date fields.
|
264
|
if (isset($date_field_value[$delta]) && is_array($date_field_value[$delta])) {
|
265
|
$is_date_field = TRUE;
|
266
|
$date_field = $date_field_value[$delta];
|
267
|
|
268
|
$start = new DateObject($date_field['value'], $date_field['timezone_db']);
|
269
|
if (!empty($date_field['value2'])) {
|
270
|
$end = new DateObject($date_field['value2'], $date_field['timezone_db']);
|
271
|
}
|
272
|
else {
|
273
|
$end = clone $start;
|
274
|
}
|
275
|
|
276
|
if (isset($date_field['rrule'])) {
|
277
|
$rrule = $date_field['rrule'];
|
278
|
}
|
279
|
}
|
280
|
elseif (is_numeric($date_field_value)) {
|
281
|
// Handle timestamps, which are always in UTC.
|
282
|
$start = new DateObject($date_field_value, 'UTC');
|
283
|
$end = new DateObject($date_field_value, 'UTC');
|
284
|
}
|
285
|
else {
|
286
|
// Processing cannot proceed with a blank date value.
|
287
|
$title = strip_tags($this->view->style_plugin->get_field($row->index, $this->options['title_field']));
|
288
|
throw new BlankDateFieldException(t("The row %title has a blank date. An iCal entry cannot be created for it.", array('%title' => $title)));
|
289
|
}
|
290
|
|
291
|
// Set the display timezone to whichever tz is stored for this field.
|
292
|
// If there isn't a stored TZ, use the site default.
|
293
|
$timezone = isset($date_field['timezone']) ? $date_field['timezone'] : date_default_timezone(FALSE);
|
294
|
$dtz = new DateTimeZone($timezone);
|
295
|
$start->setTimezone($dtz);
|
296
|
$end->setTimezone($dtz);
|
297
|
|
298
|
$granularity = 'second';
|
299
|
if ($is_date_field) {
|
300
|
$granularity_settings = $this->view->field[$date_field_name]->field_info['settings']['granularity'];
|
301
|
$granularity = date_granularity_precision($granularity_settings);
|
302
|
}
|
303
|
|
304
|
// Check if the start and end dates indicate that this is an All Day event.
|
305
|
$all_day = date_is_all_day(
|
306
|
date_format($start, DATE_FORMAT_DATETIME),
|
307
|
date_format($end, DATE_FORMAT_DATETIME),
|
308
|
$granularity
|
309
|
);
|
310
|
|
311
|
if ($all_day) {
|
312
|
// According to RFC 2445 (clarified in RFC 5545) the DTEND value is
|
313
|
// non-inclusive. When dealing with All Day values, they are DATEs rather
|
314
|
// than DATETIMEs, so we need to add a day to conform to RFC.
|
315
|
$end->modify("+1 day");
|
316
|
}
|
317
|
|
318
|
$date = array(
|
319
|
'start' => $start,
|
320
|
'end' => $end,
|
321
|
'all_day' => $all_day,
|
322
|
'rrule' => $rrule,
|
323
|
);
|
324
|
|
325
|
return $date;
|
326
|
}
|
327
|
|
328
|
/**
|
329
|
* Filter the list of views fields down to only supported date-type fields.
|
330
|
*
|
331
|
* The supported date-type fields are timestamps and the three Date fields.
|
332
|
*
|
333
|
* @param array $view_fields
|
334
|
* An associative array like views_plugin_display::get_field_labels().
|
335
|
*
|
336
|
* @return array
|
337
|
* An associative array (alias => label) of date fields.
|
338
|
*/
|
339
|
protected function get_date_field_candidates($view_fields) {
|
340
|
$handlers = $this->display->handler->get_handlers('field');
|
341
|
$field_candidates = array();
|
342
|
// These are Date, Date (ISO format), and Date (Unix timestamp).
|
343
|
$date_fields = array('datetime', 'date', 'datestamp');
|
344
|
|
345
|
foreach ($view_fields as $alias => $label) {
|
346
|
$handler_class = get_class($handlers[$alias]);
|
347
|
if ($handler_class == 'views_handler_field_date'
|
348
|
|| ($handler_class == 'views_handler_field_field'
|
349
|
&& in_array($handlers[$alias]->field_info['type'], $date_fields))) {
|
350
|
$field_candidates[$alias] = $label;
|
351
|
}
|
352
|
}
|
353
|
return $field_candidates;
|
354
|
}
|
355
|
|
356
|
/**
|
357
|
* Retrieves a field value from the style plugin.
|
358
|
*
|
359
|
* @param int $index
|
360
|
* The index count of the row
|
361
|
* @param string $field_id
|
362
|
* The ID assigned to the required field in the display.
|
363
|
*
|
364
|
* @see views_plugin_style::get_field()
|
365
|
*/
|
366
|
protected function get_field($index, $field_id) {
|
367
|
if (empty($this->view->style_plugin) || !is_object($this->view->style_plugin) || empty($field_id)) {
|
368
|
return '';
|
369
|
}
|
370
|
return $this->view->style_plugin->get_field($index, $field_id);
|
371
|
}
|
372
|
}
|