Projet

Général

Profil

Paste
Télécharger (21,8 ko) Statistiques
| Branche: | Révision:

root / drupal7 / sites / all / modules / date_ical / libraries / ParserVcalendar.inc @ ca0757b9

1
<?php
2
/**
3
 * @file
4
 * Defines a class that parses iCalcreator vcalendar objects into
5
 * Feeds-compatible data arrays.
6
 */
7

    
8
class ParserVcalendar {
9
  /**
10
   * Variables used for parsing.
11
   */
12
  protected $calendar;
13
  protected $source;
14
  protected $fetcherResult;
15
  protected $config;
16
  protected $timezones = array();
17
  protected $xtimezone;
18
  
19
  /**
20
   * The parsed data for the component that's currently being processed.
21
   *
22
   * ParserVcalendar parses one component at a time. This array is stored as a
23
   * property so that each handler can tell what work the previous handlers
24
   * have already completed on the current component.
25
   *
26
   * @var array
27
   */
28
  protected $parsed_data = array();
29
  
30
  /**
31
   * Variables used for batch processing.
32
   */
33
  protected $totalComponents = 0;
34
  protected $lastComponentParsed = 0;
35
  
36
  /**
37
   * This is the list of iCal properties which are allowed to have more than
38
   * one entry in a single VEVENT. If we ever support parsing more than just
39
   * the first one, this listing will be useful.
40
   */
41
  protected $multi_entry_properties = array(
42
    'ATTACH', 'ATTENDEE', 'CATEGORIES', 'COMMENT', 'CONTACT', 'DESCRIPTION',
43
    'EXDATE', 'EXRULE', 'FREEBUSY', 'RDATE', 'RELATED-TO', 'RESOURCES',
44
    'RRULE', 'REQUEST-STATUS', 'TZNAME', 'X-PROP'
45
  );
46
  
47
  /**
48
   * Constructor.
49
   */
50
  public function __construct($calendar, $source, $fetcher_result, $config) {
51
    $this->calendar = $calendar;
52
    $this->source = $source;
53
    $this->mapping_sources = feeds_importer($source->id)->parser->getMappingSources();
54
    $this->fetcherResult = $fetcher_result;
55
    $this->config = $config;
56
  }
57
  
58
  /**
59
   * Parses the vcalendar object into an array of event data arrays.
60
   *
61
   * @param int $offset
62
   *   Specifies which section of the feed to start parsing at.
63
   *
64
   * @param int $limit
65
   *   Specifies how many components to parse on this run.
66
   *
67
   * @return array
68
   *   An array of parsed event data keyed by our mapping source property keys.
69
   */
70
  public function parse($offset, $limit) {
71
    // Sometimes, the feed will set a timezone for every event in the calendar
72
    // using the non-standard X-WR-TIMEZONE property. Date iCal uses this
73
    // timezone only if the date property is not in UTC and has no TZID.
74
    $xtimezone = $this->calendar->getProperty('X-WR-TIMEZONE');
75
    if (!empty($xtimezone[1])) {
76
      // Allow modules to alter the timezone string before it gets converted
77
      // into a DateTimeZone.
78
      $context = array(
79
        'property_key' => NULL,
80
        'calendar_component' => NULL,
81
        'calendar' => $this->calendar,
82
        'feeeds_source' => $this->source,
83
        'feeds_fetcher_result' => $this->fetcherResult,
84
      );
85
      drupal_alter('date_ical_import_timezone', $xtimezone[1], $context);
86
      $this->xtimezone = $this->_tzid_to_datetimezone($xtimezone[1]);
87
    }
88

    
89
    // Collect the timezones into an array, for easier access.
90
    while ($component = $this->calendar->getComponent('VTIMEZONE')) {
91
      $this->timezones[] = $component;
92
    }
93
    
94
    // This content array is used by date_ical_import_component_alter() and
95
    // date_ical_import_parsed_data_alter().
96
    $context2 = array(
97
      'calendar' => $this->calendar,
98
      'source' => $this->source,
99
      'fetcher_result' => $this->fetcherResult,
100
    );
101
    
102
    // Collect each component, so we can batch them properly in the next loop.
103
    $raw_components = array();
104
    $types = array('VEVENT', 'VTODO', 'VJOURNAL', 'VFREEBUSY', 'VALARM');
105
    foreach ($types as $type) {
106
      while ($vcalendar_component = $this->calendar->getComponent($type)) {
107
        // Allow modules to alter the vcalendar component before we parse it
108
        // into a Feeds-compatible data array.
109
        drupal_alter('date_ical_import_component', $vcalendar_component, $context2);
110
        $raw_components[] = $vcalendar_component;
111
      }
112
    }
113
    
114
    // Store this for use by DateiCalFeedsParser's batch processing code.
115
    $this->totalComponents = count($raw_components);
116
    
117
    // Parse each raw component in the current batch into a Feeds-compatible
118
    // event data array.
119
    $events = array();
120
    $batch = array_slice($raw_components, $offset, $limit, TRUE);
121
    foreach ($batch as $ndx => $raw_component) {
122
      $this->parsed_data = array();
123
      foreach ($this->mapping_sources as $property_key => $data) {
124
        $handler = NULL;
125
        if (isset($data['date_ical_parse_handler'])) {
126
          $handler = $data['date_ical_parse_handler'];
127
        }
128
        else {
129
          // This is not one of our sources, so if we don't recognize and
130
          // support it, we'll have to pass a warning to the user.
131
          if ($property_key == 'geofield') {
132
            $handler = 'parseGeofield';
133
          }
134
          else {
135
            // We can safely ignore certain sources.
136
            $known_unknowns = array(
137
              'Blank source 1', // "Black Source 1" is from Feeds Tamper.
138
              'parent:nid', // Defined in FeedsParser
139
              'parent:uid', // Defined in FeedsParser
140
            );
141
            if (!in_array($property_key, $known_unknowns)) {
142
              // Only warn the user if this mapping source is in use.
143
              foreach ($this->source->importer->processor->config['mappings'] as $mapping) {
144
                if ($mapping['source'] == $property_key) {
145
                  drupal_set_message(t('Date iCal does not recognize the "@name" Mapping Source, and must skip it.', array('@name' => $data['name'])), 'warning', FALSE);
146
                  break;
147
                }
148
              }
149
            }
150
          }
151
        }
152
        if ($handler) {
153
          $this->parsed_data[$property_key] = $this->$handler($property_key, $raw_component);
154
        }
155
        
156
        if ($property_key == 'geofield' && !empty($this->parsed_data['geofield'])) {
157
          // To make our data readable by geofield_feeds_combined_source(), we
158
          // need to put it into the format output by Simplepie 1.3.
159
          $this->parsed_data['location_latitude'] = array($this->parsed_data['geofield']['lat']);
160
          $this->parsed_data['location_longitude'] = array($this->parsed_data['geofield']['lon']);
161
        }
162
      }
163
      
164
      // Allow modules to alter the final parsed data before we send it to the
165
      // Feeds processor.
166
      drupal_alter('date_ical_import_post_parse', $this->parsed_data, $context2);
167
      
168
      // Skip this event if it's earlier than the user's specified skip time.
169
      if (!$this->_skip_current_event()) {
170
        $events[] = $this->parsed_data;
171
      }
172
      // The indices of the original $raw_components array are preserved in
173
      // $batch, so using the $ndx value here lets us communicate our progress
174
      // through the full collection of components.
175
      $this->lastComponentParsed = $ndx;
176
    }
177
    
178
    return $events;
179
  }
180
  
181
  /**
182
   * Getter for the protected totalComponents property.
183
   */
184
  public function getTotalComponents() {
185
    return $this->totalComponents;
186
  }
187
  
188
  /**
189
   * Getter for the protected lastComponentParsed property.
190
   */
191
  public function getLastComponentParsed() {
192
    return $this->lastComponentParsed;
193
  }
194
  
195
  /**
196
   * Handler that parses GEO fields.
197
   *
198
   * @return array
199
   *   The latitude and longitude values, keyed by 'lat' and 'lon'.
200
   */
201
  public function parseGeofield($property_key, $vcalendar_component) {
202
    $geo = array();
203
    if (!empty($vcalendar_component->geo['value'])) {
204
      $geo['lat'] = $vcalendar_component->geo['value']['latitude'];
205
      $geo['lon'] = $vcalendar_component->geo['value']['longitude'];
206
    }
207
    return $geo;
208
  }
209
  
210
  /**
211
   * Handler that parses text fields.
212
   *
213
   * @return string
214
   *   The parsed text property.
215
   */
216
  public function parseTextProperty($property_key, $vcalendar_component) {
217
    $text = $vcalendar_component->getProperty($property_key);
218
    // In case someone writes a hook that adds a source for a multi-entry
219
    // property and a parameter of that same property, we need to force
220
    // iCalcreator to assume it has not accessed that property, yet.
221
    // TODO: This is really just a hack. If/when multi-entry properties
222
    // become supported, this will need to be redesigned.
223
    if (in_array($property_key, $this->multi_entry_properties)) {
224
      unset($vcalendar_component->propix[$property_key]);
225
    }
226
    
227
    if ($text === FALSE) {
228
      if ($property_key == 'SUMMARY') {
229
        $uid = $vcalendar_component->getProperty('UID');
230
        throw new DateIcalParseException(t('The component with UID %uid is invalid because it has no SUMMARY (nodes require a title).', array('%uid' => $uid)));
231
      }
232
      // If the component doesn't have this property, return NULL.
233
      return NULL;
234
    }
235
    // Convert literal \n and \N into newline characters.
236
    $text = str_replace(array('\n', '\N'), "\n", $text);
237
    return $text;
238
  }
239
  
240
  /**
241
   * Handler that parses field parameters.
242
   *
243
   * @return string
244
   *   The parsed field parameter.
245
   */
246
  public function parsePropertyParameter($property_key, $vcalendar_component) {
247
    list($key, $attr) = explode(':', $property_key);
248
    $property = $vcalendar_component->getProperty($key, FALSE, TRUE);
249
    // See parseTextProperty() for why this is here.
250
    if (in_array($property_key, $this->multi_entry_properties)) {
251
      unset($vcalendar_component->propix[$property_key]);
252
    }
253
        
254
    if ($property === FALSE) {
255
      // If the component doesn't have this property, return NULL.
256
      return NULL;
257
    }
258
    $param = isset($property['params'][$attr]) ? $property['params'][$attr] : '';
259
    return $param;
260
  }
261
  
262
  /**
263
   * Handler that parses DATE-TIME and DATE fields.
264
   *
265
   * @return FeedsDateTime
266
   *   The parsed datetime object.
267
   */
268
  public function parseDateTimeProperty($property_key, $vcalendar_component) {
269
    $property = $vcalendar_component->getProperty($property_key, FALSE, TRUE);
270
    // Gather all the other date properties, so we can work with them later.
271
    $duration = $vcalendar_component->getProperty('DURATION', FALSE, TRUE);
272
    $dtstart = $vcalendar_component->getProperty('DTSTART', FALSE, TRUE);
273
    $uid = $vcalendar_component->getProperty('UID');
274
    
275
    // DATE-type properties are treated as All Day events which can span over
276
    // multiple days.
277
    // The Date module's All Day event handling was never finalized
278
    // (http://drupal.org/node/874322), which requires us to do some some
279
    // special coddling later.
280
    $is_all_day = (isset($property['params']['VALUE']) && $property['params']['VALUE'] == 'DATE');
281
    
282
    // Cover various conditions in which either DTSTART or DTEND are not set.
283
    if ($property === FALSE) {
284
      // When DTEND isn't defined, we may need to emulate it.
285
      if ($property_key == 'DTEND') {
286
        // Unset DTENDs need to emulate the DATE type from DTSTART.
287
        $is_all_day = (isset($dtstart['params']['VALUE']) && $dtstart['params']['VALUE'] == 'DATE');
288
        
289
        if ($duration !== FALSE) {
290
          // If a DURATION is defined, emulate DTEND as DTSTART + DURATION.
291
          $property = array(
292
            'value' => iCalUtilityFunctions::_duration2date($dtstart['value'], $duration['value']),
293
            'params' => $dtstart['params'],
294
          );
295
        }
296
        elseif ($is_all_day) {
297
          // If this is an all-day event with no end or duration, treat this
298
          // as a single-day event by emulating DTEND as 1 day after DTSTART.
299
          $property = $dtstart;
300
          $property['value'] = iCalUtilityFunctions::_duration2date($property['value'], array('day' => 1));
301
        }
302
        else {
303
          // This event has no end date.
304
          return NULL;
305
        }
306
      }
307
      elseif ($property_key == 'DTSTART') {
308
        // DTSTART can only be legally unset in non-VEVENT components.
309
        if ($vcalendar_component->objName == 'vevent') {
310
          throw new DateIcalParseException(t('Feed import failed! The VEVENT with UID %uid is invalid: it has no DTSTART.', array('%uid' => $uid)));
311
        }
312
        else {
313
          return NULL;
314
        }
315
      }
316
    }
317
    
318
    // When iCalcreator parses a UTC date (one that ends with Z) from an iCal
319
    // feed, it stores that 'Z' into the $property['value']['tz'] value.
320
    if (isset($property['value']['tz'])) {
321
      $property['params']['TZID'] = 'UTC';
322
    }
323
    
324
    if ($is_all_day) {
325
      if ($property_key == 'DTEND') {
326
        if ($dtstart === FALSE) {
327
          // This will almost certainly never happen, but the error message
328
          // would be incomprehensible without this check.
329
          throw new DateIcalParseException(t('Feed import failed! The event with UID %uid is invalid: it has a DTEND but no DTSTART!', array('%uid' => $uid)));
330
        }
331

    
332
        if (module_exists('date_all_day')) {
333
          // If the Date All Day module is installed, we need to rewind the
334
          // DTEND by one day, because of the problem with FeedsDateTime
335
          // mentioned below.
336
          $prev_day = iCalUtilityFunctions::_duration2date($property['value'], array('day' => -1));
337
          $property['value'] = $prev_day;
338
        }
339
      }
340
      
341
      // FeedsDateTime->setTimezone() ignores timezone changes made to dates
342
      // with no time element, which means we can't compensate for the Date
343
      // module's automatic timezone conversion when it writes to the DB. To
344
      // get around that, we must add 00:00:00 explicitly, even though this
345
      // causes other problems (see above and below).
346
      $date_string = sprintf('%d-%d-%d 00:00:00', $property['value']['year'], $property['value']['month'], $property['value']['day']);
347
      // Use the server's timezone rather than letting it default to UTC.
348
      // This will help ensure that the date value doesn't get messed up when
349
      // Date converts its timezone as the value is read from the database.
350
      // This is *essential* for All Day events, because Date stores them as
351
      // '2013-10-03 00:00:00' in the database, rather than doing the sensible
352
      // thing and storing them as '2013-10-03'.
353
      // NOTE TO MAINTAINERS:
354
      // This will not work properly if the site is configured to allow users
355
      // to set their own timezone. Unfortunately, there isn't anything that
356
      // Date iCal can do about that, as far as I can tell.
357
      $datetimezone = new DateTimeZone(date_default_timezone_get());
358
    }
359
    else {
360
      // This is a DATE-TIME property.
361
      $date_string = iCalUtilityFunctions::_format_date_time($property['value']);
362
      
363
      // Allow modules to alter the timezone string. This also allows for
364
      // setting a TZID when one was not originally set for this property.
365
      $tzid = isset($property['params']['TZID']) ? $property['params']['TZID'] : NULL;
366
      $context = array(
367
        'property_key' => $property_key,
368
        'calendar_component' => $vcalendar_component,
369
        'calendar' => $this->calendar,
370
        'feeeds_source' => $this->source,
371
        'feeds_fetcher_result' => $this->fetcherResult,
372
      );
373
      drupal_alter('date_ical_import_timezone', $tzid, $context);
374
      
375
      if (isset($tzid)) {
376
        $datetimezone = $this->_tzid_to_datetimezone($tzid);
377
      }
378
      elseif (isset($this->xtimezone)) {
379
        // No timezone was set on the parsed date property, so if a timezone
380
        // was detected for the entire iCal feed, use it.
381
        $datetimezone = $this->xtimezone;
382
      }
383
      else {
384
        $msg = t("No timezone was detected for one or more of the events in this feed, forcing Date iCal to use this server's timezone as a fallback.<br>
385
            To make timezone-less events use a different timezone, implement hook_date_ical_import_timezone_alter() in a custom module.");
386
        drupal_set_message($msg, 'status', FALSE);
387
        $this->source->log('parse', $msg, array(), WATCHDOG_NOTICE);
388
        $datetimezone = new DateTimeZone(date_default_timezone_get());
389
      }
390
    }
391
    
392
    $datetime = new FeedsDateTime($date_string, $datetimezone);
393
    return $datetime;
394
  }
395
  
396
  /**
397
   * Handler that parses multi-value fields, like the CATEGORIES component.
398
   *
399
   * @return array
400
   *   An array of strings contaning the individual values.
401
   */
402
  public function parseMultivalueProperty($property_key, $vcalendar_component) {
403
    // Since we're not telling it to give us the params data, $property will
404
    // be either FALSE, a string, or an array of strings.
405
    $property = $vcalendar_component->getProperty($property_key);
406
    if (empty($property)) {
407
      // If this multi-value property is being mapped to a Taxonomy field,
408
      // Feeds will interpret anything besides empty array as an array of
409
      // empty values (e.g. array('')). This will create a term for that
410
      // empty value, rather than leaving the field blank.
411
      return array();
412
    }
413
    if (!is_array($property)) {
414
      $property = array($property);
415
    }
416
    return $property;
417
  }
418
  
419
  /**
420
   * Handler that parses RRULE, RDATE, EXRULE, and EXDATE together.
421
   *
422
   * @return string
423
   *   The RRULE, RDATE, EXRULE, and EXDATE values concatinated with |.
424
   */
425
  public function parseRepeatProperty($property_key, $vcalendar_component) {
426
    if ($vcalendar_component->getProperty($property_key) === FALSE) {
427
      return NULL;
428
    }
429
    
430
    $uid = $vcalendar_component->getProperty('UID');
431
    $count = $this->config['indefinite_count'];
432
    // Due to a few bugs and limitations with Date Repeat, we need to massage
433
    // the RRULE a bit.
434
    if (count($vcalendar_component->rrule) > 1) {
435
      $msg = 'The event with UID %uid has multiple RRULEs, but the Date Repeat module only supports one. Only the first RRULE in the event will be used.
436
        If your events need to have a complex repeat pattern, using RDATEs should help.';
437
      watchdog('date_ical', $msg, array('%uid' => $uid), 'warning');
438
      drupal_set_message('At least one of the events in this iCal feed has multiple RRULEs, but the Date Repeat module only supports one.
439
        Only the first RRULE in an event will be used.', 'warning', FALSE);
440
      
441
      // Date Repeat will get extremely confused if it's sent multiple RRULE
442
      // values, so we need to manually pare it down to only the first one.
443
      $vcalendar_component->rrule = array($vcalendar_component->rrule[0]);
444
    }
445
    foreach ($vcalendar_component->rrule as &$rrule_data) {
446
      // RRULEs must have an INTERVAL, or Date Repeat will throw errors.
447
      if (!isset($rrule_data['value']['INTERVAL'])) {
448
        $rrule_data['value']['INTERVAL'] = '1';
449
      }
450
      
451
      if ((!isset($rrule_data['value']['COUNT']) && !isset($rrule_data['value']['UNTIL']))) {
452
        $msg = "The event with UID %uid has an indefinitely repeating RRULE, which the Date Repeat module doesn't support.
453
          As a workaround, Date iCal set the repeat count to @count. This value can be customized in the iCal parser settings.";
454
        watchdog('date_ical', $msg, array('%uid' => $uid, '@count' => $count), WATCHDOG_WARNING);
455
        if ($this->config['indefinite_message_display']) {
456
          drupal_set_message(
457
            t("At least one of the events in this iCal feed has an indefinitely repeating RRULE, which the Date Repeat module doesn't support.<br>
458
              As a workaround, Date iCal set the repeat count to @count. This value can be customized in the iCal parser settings.",
459
            array('@count' => $count)),
460
            'warning',
461
            FALSE
462
          );
463
        }
464
        $rrule_data['value']['COUNT'] = $this->config['indefinite_count'];
465
      }
466
    }
467
    
468
    $rrule = trim($vcalendar_component->createRrule());
469
    $rdate = trim($vcalendar_component->createRdate());
470
    $exrule = trim($vcalendar_component->createExrule());
471
    $exdate = trim($vcalendar_component->createExdate());
472
    return "$rrule|$rdate|$exrule|$exdate";
473
  }
474
  
475
  /**
476
   * Internal helper function for creating DateTimeZone objects.
477
   */
478
  protected function _tzid_to_datetimezone($tzid) {
479
    try {
480
      $datetimezone = new DateTimeZone($tzid);
481
    }
482
    catch (Exception $e) {
483
      // In case this is a Windows TZID, read the mapping file to try and
484
      // convert it to a real TZID.
485
      $zones = file_get_contents(drupal_get_path('module', 'date_ical') . '/libraries/windowsZones.json');
486
      $zones_assoc = json_decode($zones, TRUE);
487
      $windows_to_olson_map = array();
488
      foreach ($zones_assoc['supplemental']['windowsZones']['mapTimezones'] as $mapTimezone) {
489
        if ($mapTimezone['mapZone']['_other'] == $tzid) {
490
          // Parse out the space-separated TZIDs from $mapTimezone['mapZone']['_type'].
491
          $tzids = preg_split('/\s/', $mapTimezone['mapZone']['_type']);
492
          try {
493
            // They all have the same UTC offset, so for our purposes we can
494
            // just take the first one.
495
            return new DateTimeZone($tzids[0]);
496
          }
497
          catch (Exception $e) {
498
            // If this one also fails, we're out of luck, so just fall through
499
            // to the regular error report code.
500
            break;
501
          }
502
        }
503
      }
504
      
505
      $tz_wiki = l(t('here'), 'http://en.wikipedia.org/wiki/List_of_tz_database_time_zones#List');
506
      $help = l(t('README'), 'admin/help/date_ical', array('absolute' => TRUE));
507
      $msg = t(
508
          '"@tz" is not a valid timezone (see the TZ column !here), so Date iCal had to fall back to UTC (which is probably wrong!).<br>
509
          Please read the Date iCal !readme for instructions on how to fix this.',
510
          array('@tz' => $tzid, '!here' => $tz_wiki, '!readme' => $help)
511
      );
512
      $this->source->log('parse', $msg, array(), WATCHDOG_WARNING);
513
      drupal_set_message($msg, 'warning', FALSE);
514
      $datetimezone = new DateTimeZone('UTC');
515
    }
516
    return $datetimezone;
517
  }
518
  
519
  /**
520
   * Internal helper function for skipping old events.
521
   */
522
  protected function _skip_current_event() {
523
    // Must use !isset() here, because 0 and NULL mean different things.
524
    if (!isset($this->config['skip_days'])) {
525
      return FALSE;
526
    }
527
    $compare_date = isset($this->parsed_data['DTEND']) ? $this->parsed_data['DTEND'] : $this->parsed_data['DTSTART'];
528
    $skip_date = new FeedsDateTime("today -{$this->config['skip_days']} days", $compare_date->getTimezone());
529
    $skip = ($skip_date > $compare_date);
530
    return $skip;
531
  }
532
}