Commit a4ce4942 authored by Dan Lobelle's avatar Dan Lobelle Committed by Owen Bush
Browse files

Issue #3321235 by muriqui, owenbush: Improve iCal line wrapping

parent 928f0a79
Loading
Loading
Loading
Loading
+4 −1
Original line number Diff line number Diff line
@@ -11,6 +11,9 @@
    "source": "http://cgit.drupalcode.org/recurring_events"
  },
  "require": {
    "drupal/field_inheritance": "^1"
    "drupal/field_inheritance": "^2"
  },
  "require-dev": {
    "drupal/token": "^1.11"
  }
}
+55 −20
Original line number Diff line number Diff line
@@ -5,7 +5,6 @@ namespace Drupal\recurring_events_ical;
use Drupal\Component\Render\PlainTextOutput;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Utility\Token;
use Drupal\recurring_events\Entity\EventInstance;
use Drupal\recurring_events\Entity\EventSeries;
use Drupal\recurring_events\EventInterface;
use Symfony\Component\HttpFoundation\RequestStack;
@@ -18,6 +17,7 @@ class EventICal implements EventICalInterface {
  const VERSION = '2.0';
  const PRODID = '-//Drupal//recurring_events_ical//2.0//EN';
  const DATETIMEFORMAT = 'Ymd\THis\Z';
  const LINELENGTH = 75;

  /**
   * The entity type manager service.
@@ -75,13 +75,17 @@ class EventICal implements EventICalInterface {

    foreach ($instances as $instance) {
      $output[] = 'BEGIN:VEVENT';
      $output[] = 'UID:' . $instance->uuid() . '@' . $this->request->getHost();
      $output[] = $this->prepareValue('UID', $instance->uuid() . '@' . $this->request->getHost());
      $output[] = 'DTSTAMP:' . date(static::DATETIMEFORMAT, $instance->getChangedTime());
      $output[] = 'DTSTART:' . $instance->date->start_date->format(static::DATETIMEFORMAT);
      $output[] = 'DTEND:' . $instance->date->end_date->format(static::DATETIMEFORMAT);
      if ($mapping) {
        foreach ($mapping->getAllProperties() as $property => $value) {
          $value = $this->prepareValue($property, $value, $instance);
          // Process any tokens, removing those that have no replacement.
          $value = $this->token->replace($value, ['eventinstance' => $instance], [
            'clear' => TRUE,
          ]);
          $value = $this->prepareValue($property, $value);
          if (!empty($value)) {
            $output[] = $value;
          }
@@ -90,13 +94,13 @@ class EventICal implements EventICalInterface {
      else {
        // The summary is required, so if there's no mapping in place, default
        // to the event label.
        $output[] = 'SUMMARY:' . $instance->label();
        $output[] = $this->prepareValue('SUMMARY', $instance->label());
      }
      $output[] = 'END:VEVENT';
    }

    $output[] = 'END:VCALENDAR';
    return implode("\n", $output);
    return implode("\r\n", $output);
  }

  /**
@@ -106,18 +110,11 @@ class EventICal implements EventICalInterface {
   *   The property name.
   * @param string $value
   *   The raw value.
   * @param \Drupal\recurring_events\Entity\EventInstance $instance
   *   The event instance.
   *
   * @return string
   *   The sanitized value.
   *   The property and sanitized value, formatted as required by RFC 5545.
   */
  protected function prepareValue(string $property, string $value, EventInstance $instance): string {
    // Process any tokens, removing any that have no replacement.
    $value = $this->token->replace($value, ['eventinstance' => $instance], [
      'clear' => TRUE,
    ]);

  protected function prepareValue(string $property, string $value): string {
    // Sanitize twice to ensure that all tags and HTML codes are removed. For
    // example, if the value came in as "<p>Hello,'World!</p>",
    // the first pass would return "<p>Hello,&nbsp;World!</p>" and the second
@@ -129,13 +126,51 @@ class EventICal implements EventICalInterface {
    if (empty($value)) {
      return '';
    }
    $value = strtoupper($property) . ':' . $value;

    // Change all existing line endings to literal \n.
    $value = str_replace(["\r\n", "\n\r", "\r", "\n"], '\n', $value);

    // RFC 5545 3.1 requires lines longer than 75 bytes to be wrapped with CRLF
    // followed by a single space.
    $wrapped = [];
    // Remember: strlen() counts bytes, not characters.
    while (strlen($value) > static::LINELENGTH) {
      // Grab a chunk up to line length, without splitting multibyte characters.
      $chunk = $this->cut($value, 0, static::LINELENGTH, 'UTF-8');
      $wrapped[] = $chunk;
      // The required space after the CRLF counts against the line length, so
      // add it to the front of the remaining text for the next loop.
      $value = ' ' . $this->cut($value, strlen($chunk), NULL, 'UTF-8');
    }
    // $value now contains whatever is left on the last line after wrapping.
    $wrapped[] = $value;

    // RFC 5545 3.1 requires lines longer than 75 characters (including the
    // property name and line break characters) to be wrapped with CRLF followed
    // by a single space, which will be ignored on import. We include an
    // additional space so that the words before and after the break don't run
    // together when recomposed.
    return wordwrap(strtoupper($property) . ':' . $value, 71, "\r\n  ", TRUE);
    return implode("\r\n", $wrapped);
  }

  /**
   * Wrapper for mb_strcut because Symfony's Mbstring polyfill doesn't have it.
   *
   * @param string $string
   *   The string being cut.
   * @param int $start
   *   The start position in bytes.
   * @param int|null $length
   *   The length of the cut in bytes. If NULL, runs to the end of the string.
   * @param string|null $encoding
   *   The character encoding.
   *
   * @return string
   *   The portion of $string specified by the start and length parameters.
   *
   * @see mb_strcut()
   */
  protected function cut(string $string, int $start, ?int $length = NULL, ?string $encoding = NULL): string {
    if (function_exists('mb_strcut')) {
      return mb_strcut($string, $start, $length, $encoding);
    }
    return substr($string, $start, $length);
  }

}
+216 −0
Original line number Diff line number Diff line
<?php

namespace Drupal\Tests\recurring_events_ical\Kernel;

use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface;
use Drupal\KernelTests\KernelTestBase;
use Drupal\recurring_events\Entity\EventSeries;

/**
 * @coversDefaultClass \Drupal\recurring_events_ical\EventICal
 * @group recurring_events_ical
 */
class EventICalTest extends KernelTestBase {

  /**
   * {@inheritdoc}
   */
  protected static $modules = [
    'datetime',
    'datetime_range',
    'field',
    'field_inheritance',
    'options',
    'recurring_events',
    'recurring_events_ical',
    'system',
    'text',
    'token',
    'user',
  ];

  /**
   * {@inheritdoc}
   */
  protected static $configSchemaCheckerExclusions = [
    'core.entity_view_display.eventseries.default.default',
    'core.entity_view_display.eventseries.default.list',
    'core.entity_view_display.eventinstance.default.default',
    'core.entity_view_display.eventinstance.default.list',
  ];

  /**
   * The service under test.
   *
   * @var \Drupal\recurring_events_ical\EventICalInterface
   */
  protected $eventICal;

  /**
   * The entity type manager.
   *
   * @var \Drupal\Core\Entity\EntityTypeManagerInterface
   */
  protected $entityTypeManager;

  /**
   * {@inheritdoc}
   */
  protected function setUp(): void {
    parent::setUp();

    $this->installEntitySchema('field_inheritance');
    $this->installEntitySchema('eventseries_type');
    $this->installEntitySchema('eventinstance_type');
    $this->installEntitySchema('eventseries');
    $this->installEntitySchema('eventinstance');
    $this->installEntitySchema('event_ical_mapping');
    $this->installEntitySchema('user');
    $this->installConfig([
      'field_inheritance',
      'recurring_events',
      'recurring_events_ical',
      'datetime',
      'system',
      'user',
    ]);

    $this->eventICal = $this->container->get('recurring_events_ical.event_ical');
    $this->entityTypeManager = $this->container->get('entity_type.manager');
  }

  /**
   * Tests EventICal::render() for basic values.
   */
  public function testRenderBasic() {
    $event = $this->createEventSeries(
      'Test event with a title that is longer than 75 characters. Seriously, it just keeps going and going.',
      [
        [
          'start' => '2022-01-01 00:00:00',
          'end' => '2022-01-01 00:30:00',
        ],
      ]
    );

    $iCal = explode("\r\n", $this->eventICal->render($event));
    $this->assertPreamble($iCal);
    $this->assertSame('DTSTART:20220101T000000Z', $iCal[6]);
    $this->assertSame('DTEND:20220101T003000Z', $iCal[7]);
    $this->assertSame('SUMMARY:Test event with a title that is longer than 75 characters. Seriousl', $iCal[8]);
    $this->assertSame(' y, it just keeps going and going.', $iCal[9]);
    $this->assertSame('END:VEVENT', $iCal[10]);
    $this->assertSame('END:VCALENDAR', $iCal[11]);
  }

  /**
   * Tests EventICal::render() for mapped token values.
   */
  public function testRenderMapped() {
    $event = $this->createEventSeries(
      'Mapped event series',
      [
        [
          'start' => '2022-01-01 00:00:00',
          'end' => '2022-01-01 00:30:00',
        ],
      ],
      "<h2>Agenda</h2>\n\n<ul>\n\t<li>Talk about a lot of boring things</li>\n<li>Talk about more boring things</li>\n<li>Pizza</li>\n</ul>"
    );

    $mapping = $this->entityTypeManager->getStorage('event_ical_mapping')->create([
      'id' => 'default',
      'label' => 'Default',
      'properties' => [
        'summary' => '[eventinstance:title]',
        'contact' => 'That Guy',
        'description' => '[eventinstance:description]',
        'geo' => '12.34;56.78',
        'location' => 'Conference Room',
        'priority' => '1',
        'url' => '[eventinstance:url]',
      ],
    ]);
    $mapping->save();

    $iCal = explode("\r\n", $this->eventICal->render($event));
    $this->assertPreamble($iCal);
    $this->assertSame('DTSTART:20220101T000000Z', $iCal[6]);
    $this->assertSame('DTEND:20220101T003000Z', $iCal[7]);
    $this->assertSame('SUMMARY:Mapped event series', $iCal[8]);
    $this->assertSame('CONTACT:That Guy', $iCal[9]);
    $this->assertSame('DESCRIPTION:Agenda\n\n\n' . "\t" . 'Talk about a lot of boring things\nTalk about more', $iCal[10]);
    $this->assertSame('  boring things\nPizza', $iCal[11]);
    $this->assertSame('GEO:12.34;56.78', $iCal[12]);
    $this->assertSame('LOCATION:Conference Room', $iCal[13]);
    $this->assertSame('PRIORITY:1', $iCal[14]);
    $this->assertMatchesRegularExpression('~URL:(http|https)://(.+)/events/([0-9]+)~', $iCal[15]);
    $this->assertSame('END:VEVENT', $iCal[16]);
    $this->assertSame('END:VCALENDAR', $iCal[17]);
  }

  /**
   * Creates an event series for use in a test.
   *
   * @param string $title
   *   The series title.
   * @param array $dates
   *   An array of event start/end dates in the format: [
   *     [
   *       'start' => 'YYYY-MM-DD HH:MM:SS',
   *       'end' => 'YYYY-MM-DD HH:MM:SS',
   *     ],
   *     ...
   *   ].
   * @param string $body
   *   (optional) The event's body text.
   *
   * @return \Drupal\recurring_events\Entity\EventSeries
   *   The event series.
   *
   * @throws \Drupal\Component\Plugin\Exception\InvalidPluginDefinitionException
   * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException
   * @throws \Drupal\Core\Entity\EntityStorageException
   */
  protected function createEventSeries(string $title, array $dates, string $body = ''): EventSeries {
    $customDates = [];
    foreach ($dates as $date) {
      $start = new DrupalDateTime($date['start']);
      $end = new DrupalDateTime($date['end']);
      $customDates[] = [
        'value' => $start->format(DateTimeItemInterface::DATETIME_STORAGE_FORMAT),
        'end_value' => $end->format(DateTimeItemInterface::DATETIME_STORAGE_FORMAT),
      ];
    }

    /** @var \Drupal\recurring_events\Entity\EventSeries $event */
    $event = $this->entityTypeManager->getStorage('eventseries')->create([
      'title' => $title,
      'uid' => 1,
      'type' => 'default',
      'recur_type' => 'custom',
      'custom_date' => $customDates,
      'body' => $body,
    ]);
    $event->save();

    return $event;
  }

  /**
   * Asserts that the first six lines of iCal data are correct.
   *
   * @param array $iCal
   *   The iCal rendering exploded as an array.
   */
  protected function assertPreamble(array $iCal) {
    $this->assertSame('BEGIN:VCALENDAR', $iCal[0]);
    $this->assertSame('VERSION:2.0', $iCal[1]);
    $this->assertSame('PRODID:-//Drupal//recurring_events_ical//2.0//EN', $iCal[2]);
    $this->assertSame('BEGIN:VEVENT', $iCal[3]);
    $this->assertMatchesRegularExpression('/UID:([a-z0-9\-]+)@(.+)/', $iCal[4]);
    $this->assertMatchesRegularExpression('/DTSTAMP:([0-9]{8})T([0-9]{6})Z/', $iCal[5]);
  }

}