Commit 4f6e6499 authored by John Brandenburg's avatar John Brandenburg
Browse files

Issue #3092917 by bburg: Support VTIMEZONE objects for daylight savings.x

parent b8d990b7
Loading
Loading
Loading
Loading
+32 −23
Original line number Diff line number Diff line
@@ -19,6 +19,7 @@ use Eluceo\iCal\Property\Event\RecurrenceRule;
use Eluceo\iCal\Property\Event\ExDate;
use Drupal\smart_date_recur\Entity\SmartDateRule;
use Drupal\smart_date\SmartDateTrait;
use Drupal\views_ical\ViewsIcalHelper;


/**
@@ -38,7 +39,11 @@ use Drupal\smart_date\SmartDateTrait;
 * )
 */
class IcalFieldsWizard extends Fields {
  // What is the point of this?

  /**
   * @var \Drupal\views_ical\ViewsIcalHelperInterface
   */
  private $helper;

  /**
   * Render a row object. This usually passes through to a theme template
@@ -53,6 +58,7 @@ class IcalFieldsWizard extends Fields {
  public function render($row) {
    $renderer = $this->getRenderer();
    $style = $this->view->getStyle();
    $this->helper = $style->getHelper();
    $style_options = $style->options;
     /** @var \Drupal\Core\Field\FieldDefinitionInterface[] $field_storage_definitions */
    // $field_storage_definitions = $style->entityFieldManager->getFieldStorageDefinitions($this->view->field[$options['date_field']]->definition['entity_type']);
@@ -77,7 +83,7 @@ class IcalFieldsWizard extends Fields {

    // Make sure the events are made as per the configuration in view.
    /** @var string $timezone_override */
    $timezone_override = $this->view->field[$style_options['date_field']]->options['settings']['timezone_override'] ?? FALSE;
    $timezone_override = $this->view->field[$style_options['date_field']]->options['settings']['timezone_override'];
    if ($timezone_override) {
      $timezone = new \DateTimeZone($timezone_override);
    }
@@ -85,6 +91,8 @@ class IcalFieldsWizard extends Fields {
      $timezone = new \DateTimeZone($user_timezone);
    }

    // Provide an opportunity to

    // Use date_recur's API to generate the events.
    // Recurring events will be automatically handled here.
    if ($date_field_type === 'date_recur') {
@@ -104,7 +112,6 @@ class IcalFieldsWizard extends Fields {
    // This field type is actually deprecated by the date_all_day module.
    else if ($date_field_type === 'daterange_all_day') {
      throw new \Exception('daterange_all_day fields not supported.');
      //$this->helper->addEvent($events, $row , $timezone, $this->options);
    }
    else if($date_field_type === 'smartdate') {
      $this->addSmartDateEvent($events, $row, $timezone, $style_options);
@@ -236,9 +243,9 @@ class IcalFieldsWizard extends Fields {
        $event->addRecurrenceRule($rrule);
      }
      else {
        $helper = $entity->field_recur[0]->getHelper();
        $rrules = $helper->getRules();
        $exdates = $helper->getExcluded();
        $rruleHelper = $entity->field_recur[0]->getHelper();
        $rrules = $rruleHelper->getRules();
        $exdates = $rruleHelper->getExcluded();

        // Parse EXDATEs.
        if ($exdates) {
@@ -361,6 +368,7 @@ class IcalFieldsWizard extends Fields {
      $start_datetime = new \DateTime($date_entry['value'], $utc_timezone);
      $start_datetime->setTimezone($timezone);
      $event->setDtStart($start_datetime);
      $this->helper->addTimezone($timezone, $start_datetime);

      // Loop over field values so we can support daterange fields with multiple cardinality.
      if (!empty($date_entry['end_value'])) {
@@ -368,6 +376,7 @@ class IcalFieldsWizard extends Fields {
        $end_datetime->setTimezone($timezone);

        $event->setDtEnd($end_datetime);
        $this->helper->addTimezone($timezone, $end_datetime);

        // If this is a date_all_day field, pull the all day option from that.
        if($date_all_day = false) {
@@ -421,6 +430,7 @@ class IcalFieldsWizard extends Fields {
      $start_datetime = new \DateTime($date_entry['value'], $utc_timezone);
      $start_datetime->setTimezone($timezone);
      $event->setDtStart($start_datetime);
      $this->helper->addTimezone($timezone, $start_datetime);

      // Set the end time
      $end_date_field_values = $entity->get($field_mapping['end_date_field'])->getValue();
@@ -428,6 +438,7 @@ class IcalFieldsWizard extends Fields {
      $end_datetime = new \DateTime($end_date_entry['value'], $utc_timezone);
      $end_datetime->setTimezone($timezone);
      $event->setDtEnd($end_datetime);
      $this->helper->addTimezone($timezone, $end_datetime);

      // All day events.
      if (isset($field_mapping['no_time_field']) && $field_mapping['no_time_field'] != 'none') {
@@ -458,20 +469,18 @@ class IcalFieldsWizard extends Fields {
  public function addSmartDateEvent(array &$events, \Drupal\views\ResultRow $row, \DateTimeZone $timezone, array $fieldMapping): void {

    $entity = $this->getEntity($row);

    $datefieldValues = $entity->get($fieldMapping['date_field'])->getValue();
    $processedRules = [];
    foreach ($datefieldValues as $delta => $datefieldValue) {
      $dateValue = $datefieldValue['value'];
      $dateEndValue = $datefieldValue['end_value'];
      $dateRrule = $datefieldValue['rrule'];
      $dateTZ = $datefieldValue['timezone'] ?: $timezone;
    $processed_rules = [];

      if (in_array($dateRrule, $processedRules)) {
    foreach ($datefieldValues as $delta => $datefieldValue) {
      $dateValue = $datefieldValues[$delta]['value'];
      $dateEndValue = $datefieldValues[$delta]['end_value'];
      $dateRrule = $datefieldValues[$delta]['rrule'];
      $dateTZ = $datefieldValues[$delta]['timezone'] ?: $timezone;
      if (in_array($dateRrule, $processed_rules)) {
        continue;
      }
      else if (isset($datefieldValue['rrule'])) {
        $processedRules[$datefieldValue['rrule']] = $datefieldValue['rrule'];
      }

      // Generate the event.
      $event = $this->createDefaultEvent($entity, $fieldMapping, $row);
@@ -481,18 +490,17 @@ class IcalFieldsWizard extends Fields {
      $startDatetime->setTimestamp(trim($dateValue));
      $startDatetime->setTimezone($dateTZ);
      $event->setDtStart($startDatetime);
      $this->helper->addTimezone($timezone, $startDatetime);

      // Set the end time.
      $endDatetime = new \DateTime();
      $endDatetime->setTimestamp(trim($dateEndValue));
      $endDatetime->setTimezone($dateTZ);
      $event->setDtEnd($endDatetime);

      $startTime = $startDatetime->getTimestamp();
      $endTime = $endDatetime->getTimestamp();
      $this->helper->addTimezone($timezone, $endDatetime);

      // Can the date be considered all-day?
      if (SmartDateTrait::isAllDay($startTime, $endTime, $dateTZ->getName())) {
      if (SmartDateTrait::isAllDay($startDatetime->getTimestamp(), $endDatetime->getTimestamp(), $dateTZ)) {
        $event->setNoTime(TRUE);
      }

@@ -534,7 +542,7 @@ class IcalFieldsWizard extends Fields {
        if ($recurRuleObject->getUntil()) {
          $icalRrule->setUntil($recurRuleObject->getUntil());
        }
        $event->setRecurrenceRule($icalRrule); // TODO: Replace deprecated method
        $event->setRecurrenceRule($icalRrule);
      }
      $events[] = $event;
    }
@@ -571,12 +579,13 @@ class IcalFieldsWizard extends Fields {
        $start_datetime = $occurrence->getStart();
        $start_datetime->setTimezone($timezone);
        $event->setDtStart($start_datetime);
        $this->helper->addTimezone($timezone, $start_datetime);

        /** @var \DateTime $end_datetime */
        $end_datetime = $occurrence->getEnd();
        $end_datetime->setTimezone($timezone);
        $event->setDtEnd($end_datetime);

        $this->helper->addTimezone($timezone, $end_datetime);
        $current_date = date_create();

        // Only include future occurrences and only the first one because we will rely on rrules.
+17 −1
Original line number Diff line number Diff line
@@ -92,6 +92,7 @@ class IcalWizard extends StylePluginBase {
    $options['no_time_field'] = ['default' => 'none'];
    $options['uid_field'] = ['default' => 'none'];
    $options['default_transparency'] = ['default' => 'transparent'];
    $options['use_vtimezone'] = ['default' => true];

    return $options;
  }
@@ -137,6 +138,15 @@ class IcalWizard extends StylePluginBase {
      '#description' => $this->t('Please identify the field to use to indicate an event will be all-day. If the date field uses the "Date all day" module, this option does not need to be set, and will be pulled automatically from the date field. TODO: Implement this.'),
    );

    $form['use_vtimezone'] = array(
      '#type' => 'checkbox',
      '#title' => $this->t('Use VTIMEZONE'),
      '#options' => $field_options,
      '#default_value' => $this->options['use_vtimezone'] ?? true,
      '#description' => $this->t('Use a VTIMEZONE entry. Enabling this may fix any issues with times not showing correctly for daylight savings. This was added relatively recently in the module, even though it is a part of the <a href="https://www.rfc-editor.org/rfc/rfc5545#section-3.6.5" target="_blank" rel="noopener noreferrer">iCal spec</a> so it can be toggled off if it breaks any installations here. VTIMEZONE objects are important for any dates showing as recurring, which cross daylight savings boundries. Future recurring events may not show up as the correct time. Also Outlook desktop client calendars have shown issues with single events not showing the correct time without these, regardless of recurring status.'),
    );


    $form['summary_field'] = array(
      '#type' => 'select',
      '#title' => $this->t('SUMMARY field'),
@@ -211,12 +221,17 @@ class IcalWizard extends StylePluginBase {


  /**
   * @return Calendar
   * @return Eluceo\iCal\Component\Calendar
   */
  public function getCalendar(){
    return $this->calendar;
  }

  public function getHelper() {
    return $this->helper;
  }


  /**
   * {@inheritdoc}
   */
@@ -234,6 +249,7 @@ class IcalWizard extends StylePluginBase {
    $this->calendar = $calendar;

    $parent_render = parent::render();
    $this->calendar->setTimezone($this->vTimezone);

    // Sets the 'X-WR-CALNAME" property. Just use the View name here.
    if ($this->view->getTitle()) {
+168 −0
Original line number Diff line number Diff line
@@ -3,6 +3,7 @@
namespace Drupal\views_ical;

use Drupal\Core\Entity\ContentEntityInterface;
use Drupal\feeds\Feeds\Target\DateTime;
use Eluceo\iCal\Component\Event;
use Drupal\views\ResultRow;

@@ -324,4 +325,171 @@ final class ViewsIcalHelper implements ViewsIcalHelperInterface {
  }


  /**
   * Timezones, with daylight savings transitions must be added as objects to the calendar object itself.
   * Not adding these can lead to events being 1 hour off for outlook client app calenders during daylight savings periods.
   *
   * Found this SO after writing the below method, consider adapting this to work with eluceo/iCal.
   * https://stackoverflow.com/questions/6682304/generating-an-icalender-vtimezone-component-from-phps-timezone-value
   * @param $timezone
   * @return void
   * @throws \Exception
   */
  public function addTimezone($timezone, $datetime) {
    $style = $this->view->getStyle();
    $options = $style->options;
    if (!$options['use_vtimezone']) {
      return;
    }
    // Initialize the used timezone transitions.
    if (!isset($style->usedTimezoneTransitions)) {
      $style->usedTimezoneTransitions = [];
    }

    if ($style->checktransitions = false) {
      return;
    }
    // This will get the timezone transition prior to the current.
    // getTransitions will return the first item in the array starting with the passed minimum time. We'd like to get the
    // actual start time instead. This simplifies logic later.
    $secondsInYear = 18410000;
    $transitions = $timezone->getTransitions(
      // Subtract two years (probably only need 18 months, just to be sure we include all of the previous transition, so we can start THIS one at the right time)
      $datetime->getTimestamp() - $secondsInYear - $secondsInYear,
      $datetime->getTimestamp() + $secondsInYear) ;

    // Do a binary search for the current "transition"
    // binary search was written before realizing that getTransitions() could be passed arguments... Just kept it though.
    $high = count($transitions) -1;
    $low = 0;
    $found = false;
    $loops = 0;

    while(!$found) {
      $midpoint = (int) floor(($low + $high) / 2);
      $transition = $transitions[$midpoint];
      $transitionDate = new \DateTime($transition['time']);
      if (isset($transitions[$midpoint+1])) {
        $transitionPlus = $transitions[$midpoint+1];
      }
      else {
        // If there is no $start+1, then we are on the last transition, assume the current transition is the one we are
        // looking for.
        $found = true;
        $prevTransition = $transitions[$midpoint-1];
        $transition['index'] = $midpoint;
        break;
      }
      $transitionDatePlus = new \DateTime($transitionPlus['time']);
      // If this transition is within 9 months in the past
      if ($datetime >= $transitionDate && $datetime < $transitionDatePlus) {
        $found = true;
        $prevTransition = $transitions[$midpoint - 1];
        $transition['index'] = $midpoint;
        break;
      }
      else if ($datetime < $transitionDate && $datetime < $transitionDatePlus) {
        // if we went too high, we need to pick a value between the current start and previous one.
        $high = $midpoint - 1;
      }
      else {
        // We are too low, go higher.
        $low = $midpoint + 1;
      }
      // Safety bail in case there is no result.
      $loops = $loops++;
      if ($loops > 10) {
        return;
      }
    }

    $tz  = $timezone->getName();
    $dtz = $timezone;
    if (!isset($style->vTimezone)) {
      $style->vTimezone = new \Eluceo\iCal\Component\Timezone($tz);
    }

    // This timezone transition was already added, so skip to not add a duplicate.
    if (in_array($transition['time'], $style->usedTimezoneTransitions)) {
      return;
    }

    // Create timezone rules
    if ($transition['isdst']) {
      $vTimezoneRule = new \Eluceo\iCal\Component\TimezoneRule(\Eluceo\iCal\Component\TimezoneRule::TYPE_DAYLIGHT);
    }
    else {
      $vTimezoneRule = new \Eluceo\iCal\Component\TimezoneRule(\Eluceo\iCal\Component\TimezoneRule::TYPE_STANDARD);
    }
    $vTimezoneRule->setTzName($transition['abbr']);
    $vTimezoneRule->setDtStart(new \DateTime($transition['time'], $dtz));
    $vTimezoneRule->setTzOffsetFrom($this->convertOffset($prevTransition['offset']));
    $vTimezoneRule->setTzOffsetTo($this->convertOffset($transition['offset']));

    // We aren't bothering with recurrance rules, we just add an entry for every timezone rule.
    // Add this
    $style->vTimezone->addComponent($vTimezoneRule);

    // For good measure, we also add the timezone transition for the prior transition.
    $transitionPrev = $transitions[$transition['index'] - 1];
    if (!in_array($transitionPrev['time'], $style->usedTimezoneTransitions)) {
      if ($transitionPrev['isdst']) {
        $vTimezoneRulePrev = new \Eluceo\iCal\Component\TimezoneRule(\Eluceo\iCal\Component\TimezoneRule::TYPE_DAYLIGHT);
      }
      else {
        $vTimezoneRulePrev = new \Eluceo\iCal\Component\TimezoneRule(\Eluceo\iCal\Component\TimezoneRule::TYPE_STANDARD);
      }
      $vTimezoneRulePrev->setTzName($transitionPrev['abbr']);
      $vTimezoneRulePrev->setDtStart(new \DateTime($transitionPrev['time'], $dtz));
      $vTimezoneRulePrev->setTzOffsetFrom($this->convertOffset($transitions[$transition['index'] - 2]['offset']));
      $vTimezoneRulePrev->setTzOffsetTo($this->convertOffset($transitionPrev['offset']));
      $style->vTimezone->addComponent($vTimezoneRulePrev);
      $style->usedTimezoneTransitions[$transitionPrev['time']] = $transitionPrev['time'];
    }

    $style->usedTimezoneTransitions[$transition['time']] = $transition['time'];
  }

  /**
   * Converts a numerical timezone offset in seconds to a string based one in minutes
   * e.g. -14400 becomes "-0400"
   *
   * TODO: Move this and related methods to a helper, it has no business being in this class.
   * @param $transition
   * @return void
   */
  public function convertOffset($offset) {
    // determine whether this has a leading or
    $hours = abs($offset / 60 / 60);
    if ($offset > 0) {
      $direction = '+';
    }
    else if ($offset < 0) {
      $direction = '-';
    }
    else {
      $direction = '';
    }

    // Determine if it should have any leading zeros.
    if ($hours < 10) {
      $leadingZero = "0";
    }
    else {
      $leadingZero = "";
    }

    // If it's between hours.
    if ($hours % 1 !== 0) {
      // TODO: Are there any timezones not at the hour or halfs?
      $trailing = '30';
    }
    else {
      $trailing = '00';
    }

    return $direction . $leadingZero . $hours . $trailing;
  }


}
+1 −1

File changed.

Contains only whitespace changes.