<?php namespace Drupal\recurring_events; use Drupal\Core\StringTranslation\TranslationInterface; use Drupal\Core\Database\Connection; use Drupal\Core\Datetime\DrupalDateTime; use Drupal\Core\Logger\LoggerChannelFactoryInterface; use Drupal\recurring_events\Entity\EventSeries; use Drupal\Core\Form\FormStateInterface; use Drupal\Core\Messenger\Messenger; use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface; use Drupal\Core\Entity\EntityFieldManager; use Drupal\Core\Field\FieldTypePluginManager; use Drupal\Core\StringTranslation\StringTranslationTrait; use Drupal\Core\Extension\ModuleHandlerInterface; use Drupal\Core\Entity\EntityTypeManagerInterface; use Drupal\Core\KeyValueStore\KeyValueFactoryInterface; use Drupal\recurring_events\Entity\EventInstance; use Drupal\field_inheritance\Entity\FieldInheritanceInterface; use Symfony\Component\DependencyInjection\ContainerInterface; /** * Provides a service with helper functions useful during event creation. */ class EventCreationService { use StringTranslationTrait; /** * The translation interface. * * @var \Drupal\Core\StringTranslation\TranslationInterface */ private $translation; /** * The database connection. * * @var \Drupal\Core\Database\Connection */ private $database; /** * Logger Factory. * * @var \Drupal\Core\Logger\LoggerChannel */ protected $loggerChannel; /** * The messenger service. * * @var \Drupal\Core\Messenger\Messenger */ protected $messenger; /** * The field type plugin manager. * * @var Drupal\Core\Field\FieldTypePluginManager */ protected $fieldTypePluginManager; /** * The entity field manager. * * @var Drupal\Core\Entity\EntityFieldManager */ protected $entityFieldManager; /** * The module handler service. * * @var \Drupal\Core\Extension\ModuleHandlerInterface */ protected $moduleHandler; /** * The entity type manager service. * * @var \Drupal\Core\Entity\EntityTypeManagerInterface */ protected $entityTypeManager; /** * The key value storage service. * * @var \Drupal\Core\KeyValueStore\KeyValueFactoryInterface */ protected $keyValueStore; /** * Class constructor. * * @param \Drupal\Core\StringTranslation\TranslationInterface $translation * The translation interface. * @param \Drupal\Core\Database\Connection $database * The database connection. * @param \Drupal\Core\Logger\LoggerChannelFactoryInterface $logger * The logger factory. * @param \Drupal\Core\Messenger\Messenger $messenger * The messenger service. * @param \Drupal\Core\Field\FieldTypePluginManager $field_type_plugin_manager * The field type plugin manager. * @param \Drupal\Core\Entity\EntityFieldManager $entity_field_manager * The entity field manager. * @param \Drupal\Core\Extension\ModuleHandlerInterface $module_handler * The module handler service. * @param \Drupal\Core\Entity\EntityTypeManagerInterface $entity_type_manager * The entity type manager service. * @param \Drupal\Core\KeyValueStore\KeyValueFactoryInterface $key_value * The key value storage service. */ public function __construct(TranslationInterface $translation, Connection $database, LoggerChannelFactoryInterface $logger, Messenger $messenger, FieldTypePluginManager $field_type_plugin_manager, EntityFieldManager $entity_field_manager, ModuleHandlerInterface $module_handler, EntityTypeManagerInterface $entity_type_manager, KeyValueFactoryInterface $key_value) { $this->translation = $translation; $this->database = $database; $this->loggerChannel = $logger->get('recurring_events'); $this->messenger = $messenger; $this->fieldTypePluginManager = $field_type_plugin_manager; $this->entityFieldManager = $entity_field_manager; $this->moduleHandler = $module_handler; $this->entityTypeManager = $entity_type_manager; $this->keyValueStore = $key_value; } /** * {@inheritdoc} */ public static function create(ContainerInterface $container) { return new static( $container->get('string_translation'), $container->get('database'), $container->get('logger.factory'), $container->get('messenger'), $container->get('plugin.manager.field.field_type'), $container->get('entity_field.manager'), $container->get('module_handler'), $container->get('entity_type.manager'), $container->get('keyvalue') ); } /** * Check whether there have been form recurring configuration changes. * * @param Drupal\recurring_events\Entity\EventSeries $event * The stored event series entity. * @param Drupal\Core\Form\FormStateInterface $form_state * The form state of an updated event series entity. * * @return bool * TRUE if recurring config changes, FALSE otherwise. */ public function checkForFormRecurConfigChanges(EventSeries $event, FormStateInterface $form_state) { $entity_config = $this->convertArrayLowercaseSorted( (array) $this->convertEntityConfigToArray($event)); $form_config = $this->convertArrayLowercaseSorted( (array) $this->convertFormConfigToArray($form_state)); return !(serialize($entity_config) === serialize($form_config)); } /** * Check whether there have been original recurring configuration changes. * * @param Drupal\recurring_events\Entity\EventSeries $event * The stored event series entity. * @param Drupal\recurring_events\Entity\EventSeries $original * The original stored event series entity. * * @return bool * TRUE if recurring config changes, FALSE otherwise. */ public function checkForOriginalRecurConfigChanges(EventSeries $event, EventSeries $original) { $entity_config = $this->convertArrayLowercaseSorted( (array) $this->convertEntityConfigToArray($event)); $original_config = $this->convertArrayLowercaseSorted( (array) $this->convertEntityConfigToArray($original)); return !(serialize($entity_config) === serialize($original_config)); } /** * Converts an EventSeries entity's recurring configuration to an array. * * @param Drupal\recurring_events\Entity\EventSeries $event * The stored event series entity. * * @return array * The recurring configuration as an array. */ public function convertEntityConfigToArray(EventSeries $event) { $config = []; $config['type'] = $event->getRecurType(); $config['excluded_dates'] = $event->getExcludedDates(); $config['included_dates'] = $event->getIncludedDates(); if ($config['type'] === 'custom') { $config['custom_dates'] = $event->getCustomDates(); } else { $field_definition = $this->fieldTypePluginManager->getDefinition($config['type']); $field_class = $field_definition['class']; $config += $field_class::convertEntityConfigToArray($event); } $this->moduleHandler->alter('recurring_events_entity_config_array', $config); return $config; } /** * Converts a form state object's recurring configuration to an array. * * @param Drupal\Core\Form\FormStateInterface $form_state * The form state of an updated event series entity. * * @return array * The recurring configuration as an array. */ public function convertFormConfigToArray(FormStateInterface $form_state) { $config = []; $utc_timezone = new \DateTimeZone(DateTimeItemInterface::STORAGE_TIMEZONE); $user_input = $form_state->getValues(); $config['type'] = $user_input['recur_type'][0]['value']; $config['excluded_dates'] = []; if (!empty($user_input['excluded_dates'])) { $config['excluded_dates'] = $this->getDatesFromForm($user_input['excluded_dates']); } $config['included_dates'] = []; if (!empty($user_input['included_dates'])) { $config['included_dates'] = $this->getDatesFromForm($user_input['included_dates']); } if ($config['type'] === 'custom') { foreach ($user_input['custom_date'] as $key => $custom_date) { if (!is_numeric($key)) { continue; } $start_date = $end_date = NULL; if (!empty($custom_date['value']) && !empty($custom_date['end_value'])) { $start_timestamp = $custom_date['value']->format(DateTimeItemInterface::DATETIME_STORAGE_FORMAT); $end_timestamp = $custom_date['end_value']->format(DateTimeItemInterface::DATETIME_STORAGE_FORMAT); $start_date = DrupalDateTime::createFromFormat(DateTimeItemInterface::DATETIME_STORAGE_FORMAT, $start_timestamp, $utc_timezone); $end_date = DrupalDateTime::createFromFormat(DateTimeItemInterface::DATETIME_STORAGE_FORMAT, $end_timestamp, $utc_timezone); $config['custom_dates'][] = [ 'start_date' => $start_date, 'end_date' => $end_date, ]; } } } else { $field_definition = $this->fieldTypePluginManager->getDefinition($config['type']); $field_class = $field_definition['class']; $config += $field_class::convertFormConfigToArray($form_state); } $this->moduleHandler->alter('recurring_events_form_config_array', $config); return $config; } /** * Normalize an array for equality checks, without having to worry about order * or casing discrepencies. * * @param array $input * The array to clean and sort. * * @return array * A cleaned array. */ public static function convertArrayLowercaseSorted(array $input) { foreach ($input as $key => $val) { if (is_object($val)) { $input[$key] = self::convertArrayLowercaseSorted((array) $val); } if (is_array($val)) { $input[$key] = self::convertArrayLowercaseSorted($val); } if (is_string($val)) { $input[$key] = strtolower($val); } } uksort($input, 'strcmp'); return $input; } /** * Build diff array between stored entity and form state. * * @param Drupal\recurring_events\Entity\EventSeries $event * The stored event series entity. * @param Drupal\Core\Form\FormStateInterface $form_state * (Optional) The form state of an updated event series entity. * @param Drupal\recurring_events\Entity\EventSeries $edited * (Optional) The edited event series entity. * * @return array * An array of differences. */ public function buildDiffArray(EventSeries $event, FormStateInterface $form_state = NULL, EventSeries $edited = NULL) { $diff = []; $entity_config = $this->convertEntityConfigToArray($event); $form_config = []; if (!is_null($form_state)) { $form_config = $this->convertFormConfigToArray($form_state); } if (!is_null($edited)) { $form_config = $this->convertEntityConfigToArray($edited); } if (empty($form_config)) { return $diff; } if ($entity_config['type'] !== $form_config['type']) { $diff['type'] = [ 'label' => $this->translation->translate('Recur Type'), 'stored' => $entity_config['type'], 'override' => $form_config['type'], ]; } else { if ($entity_config['excluded_dates'] !== $form_config['excluded_dates']) { $entity_dates = $this->buildDateString($entity_config['excluded_dates']); $config_dates = $this->buildDateString($form_config['excluded_dates']); $diff['excluded_dates'] = [ 'label' => $this->translation->translate('Excluded Dates'), 'stored' => $entity_dates, 'override' => $config_dates, ]; } if ($entity_config['included_dates'] !== $form_config['included_dates']) { $entity_dates = $this->buildDateString($entity_config['included_dates']); $config_dates = $this->buildDateString($form_config['included_dates']); $diff['included_dates'] = [ 'label' => $this->translation->translate('Included Dates'), 'stored' => $entity_dates, 'override' => $config_dates, ]; } if ($entity_config['type'] === 'custom') { if ($entity_config['custom_dates'] !== $form_config['custom_dates']) { $stored_start_ends = $overridden_start_ends = []; $user_timezone = new \DateTimeZone(date_default_timezone_get()); foreach ($entity_config['custom_dates'] as $date) { if (!empty($date['start_date']) && !empty($date['end_date'])) { $date['start_date']->setTimezone($user_timezone); $date['end_date']->setTimezone($user_timezone); $stored_start_ends[] = $date['start_date']->format('Y-m-d h:ia') . ' - ' . $date['end_date']->format('Y-m-d h:ia'); } } foreach ($form_config['custom_dates'] as $date) { if (!empty($date['start_date']) && !empty($date['end_date'])) { $date['start_date']->setTimezone($user_timezone); $date['end_date']->setTimezone($user_timezone); $overridden_start_ends[] = $date['start_date']->format('Y-m-d h:ia') . ' - ' . $date['end_date']->format('Y-m-d h:ia'); } } $diff['custom_dates'] = [ 'label' => $this->translation->translate('Custom Dates'), 'stored' => implode(', ', $stored_start_ends), 'override' => implode(', ', $overridden_start_ends), ]; } } else { $field_definition = $this->fieldTypePluginManager->getDefinition($entity_config['type']); $field_class = $field_definition['class']; $diff += $field_class::buildDiffArray($entity_config, $form_config); } } $this->moduleHandler->alter('recurring_events_diff_array', $diff); return $diff; } /** * Clear out existing event instances.. * * @param Drupal\recurring_events\Entity\EventSeries $event * The event series entity. */ public function clearEventInstances(EventSeries $event) { // Allow other modules to react prior to the deletion of all instances. $this->moduleHandler->invokeAll('recurring_events_save_pre_instances_deletion', [ $event ]); // Find all the instances and delete them. $instances = $event->event_instances->referencedEntities(); if (!empty($instances)) { foreach ($instances as $instance) { // Allow other modules to react prior to deleting a specific // instance after a date configuration change. $this->moduleHandler->invokeAll('recurring_events_save_pre_instance_deletion', [ $event, $instance, ]); $instance->delete(); // Allow other modules to react after deleting a specific instance // after a date configuration change. $this->moduleHandler->invokeAll('recurring_events_save_post_instance_deletion', [ $event, $instance, ]); } $this->messenger->addStatus($this->translation->translate('A total of %count existing event instances were removed', [ '%count' => count($instances), ])); } // Allow other modules to react after the deletion of all instances. $this->moduleHandler->invokeAll('recurring_events_save_post_instances_deletion', [ $event ]); $this->entityTypeManager->getStorage('eventseries')->resetCache([$event->id()]); } /** * Create the event instances from the form state. * * @param \Drupal\recurring_events\Entity\EventSeries $event * The stored event series entity. * * @return \Drupal\recurring_events\Entity\EventInstance[] * An array of event instances created for the series. * * @throws \Drupal\Component\Plugin\Exception\PluginNotFoundException */ public function createInstances(EventSeries $event): array { $form_data = $this->convertEntityConfigToArray($event); $event_instances = []; if (!empty($form_data['type'])) { if ($form_data['type'] === 'custom') { if (!empty($form_data['custom_dates'])) { $events_to_create = []; foreach ($form_data['custom_dates'] as $date_range) { // Set this event to be created. $events_to_create[$date_range['start_date']->format('r')] = [ 'start_date' => $date_range['start_date'], 'end_date' => $date_range['end_date'], ]; } // Allow modules to alter the array of event instances before they // get created. $this->moduleHandler->alter('recurring_events_event_instances_pre_create', $events_to_create, $event); if (!empty($events_to_create)) { foreach ($events_to_create as $custom_event) { $instance = $this->createEventInstance($event, $custom_event['start_date'], $custom_event['end_date']); $this->configureDefaultInheritances($instance, $event->id()); $event_instances[] = $instance; } } } } else { $field_definition = $this->fieldTypePluginManager->getDefinition($form_data['type']); $field_class = $field_definition['class']; $events_to_create = $field_class::calculateInstances($form_data); // Allow modules to alter the array of event instances before they // get created. $this->moduleHandler->alter('recurring_events_event_instances_pre_create', $events_to_create, $event); if (!empty($events_to_create)) { foreach ($events_to_create as $event_to_create) { $instance = $this->createEventInstance($event, $event_to_create['start_date'], $event_to_create['end_date']); $this->configureDefaultInheritances($instance, $event->id()); $event_instances[] = $instance; } } } } // Create a message to indicate how many instances were changed. $this->messenger->addMessage($this->translation->translate('A total of %items event instances were created as part of this event series.', [ '%items' => count($event_instances), ])); return $event_instances; } /** * Create an event instance from an event series. * * @param Drupal\recurring_events\Entity\EventSeries $event * The stored event series entity. * @param Drupal\Core\Datetime\DrupalDateTime $start_date * The start date and time of the event. * @param Drupal\Core\Datetime\DrupalDateTime $end_date * The end date and time of the event. * * @return \Drupal\recurring_events\Entity\EventInstance * The created event instance entity object. */ public function createEventInstance(EventSeries $event, DrupalDateTime $start_date, DrupalDateTime $end_date) { $data = [ 'eventseries_id' => $event->id(), 'date' => [ 'value' => $start_date->format(DateTimeItemInterface::DATETIME_STORAGE_FORMAT), 'end_value' => $end_date->format(DateTimeItemInterface::DATETIME_STORAGE_FORMAT), ], 'type' => $event->getType(), 'uid' => $event->getOwnerId(), ]; $this->moduleHandler->alter('recurring_events_event_instance', $data); $storage = $this->entityTypeManager->getStorage('eventinstance'); if ($event->isDefaultTranslation()) { $entity = $storage->create($data); } else { // Grab the untranslated event series. $original = $event->getUntranslated(); // Find the corresponding default language event instance that matches // the date and time of the version we wish to translate, so that we are // mapping the translations from default language to translated language // appropriately. $entity_ids = $storage->getQuery() ->condition('date__value', $data['date']['value']) ->condition('date__end_value', $data['date']['end_value']) ->condition('eventseries_id', $data['eventseries_id']) ->condition('type', $data['type']) ->condition('langcode', $original->language()->getId()) ->accessCheck(FALSE) ->execute(); if (!empty($entity_ids)) { // Load the default language version of the event instance. $entity = $storage->load(reset($entity_ids)); // Only add a translation if we do not already have one. if (!$entity->hasTranslation($event->language()->getId())) { $entity->addTranslation($event->language()->getId(), $data); } } } if ($entity) { $entity->save(); } else { $this->loggerChannel->warning('Missing event instance in default language. Translation could not be created'); } return $entity; } /** * Configure the default field inheritances for event instances. * * @param Drupal\recurring_events\Entity\EventInstance $instance * The event instance. * @param int $series_id * The event series entity ID. */ public function configureDefaultInheritances(EventInstance $instance, int $series_id = NULL) { if (is_null($series_id)) { $series_id = $instance->eventseries_id->target_id; } if (!empty($series_id)) { // Configure the field inheritances for this instance. $entity_type = $instance->getEntityTypeId(); $bundle = $instance->bundle(); $inherited_fields = $this->entityTypeManager->getStorage('field_inheritance')->loadByProperties([ 'sourceEntityType' => 'eventseries', 'destinationEntityType' => $entity_type, 'destinationEntityBundle' => $bundle, ]); if (!empty($inherited_fields)) { $state_key = $entity_type . ':' . $instance->uuid(); $state = $this->keyValueStore->get('field_inheritance'); $state_values = $state->get($state_key); if (empty($state_values)) { $state_values = [ 'enabled' => TRUE, ]; if (!empty($inherited_fields)) { foreach ($inherited_fields as $inherited_field) { $name = $inherited_field->idWithoutTypeAndBundle(); $state_values[$name] = [ 'entity' => $series_id, ]; } } $state->set($state_key, $state_values); } } } } /** * When adding a new field inheritance, add the default values for it. * * @param Drupal\recurring_events\Entity\EventInstance $instance * The event instance for which to configure default inheritance values. * @param Drupal\field_inheritance\Entity\FieldInheritanceInterface $field_inheritance * The field inheritance being created or updated. */ public function addNewDefaultInheritance(EventInstance $instance, FieldInheritanceInterface $field_inheritance) { $state_key = 'eventinstance:' . $instance->uuid(); $state = $this->keyValueStore->get('field_inheritance'); $state_values = $state->get($state_key); $name = $field_inheritance->idWithoutTypeAndBundle(); if (!empty($state_values[$name])) { return; } $state_values[$name] = [ 'entity' => $instance->eventseries_id->target_id, ]; $state->set($state_key, $state_values); } /** * Get exclude/include dates from form. * * @param array $field * The field from which to retrieve the dates. * * @return array * An array of dates. */ private function getDatesFromForm(array $field) { $dates = []; if (!empty($field)) { foreach ($field as $key => $date) { if (!is_numeric($key)) { continue; } if (!empty($date['value']) && !empty($date['end_value'])) { $dates[] = [ 'value' => $date['value']->format('Y-m-d'), 'end_value' => $date['end_value']->format('Y-m-d'), ]; } } } return $dates; } /** * Build a string from excluded or included date ranges. * * @var array $config * The configuration from which to build a string. * * @return string * The formatted date string. */ private function buildDateString(array $config) { $string = ''; $string_parts = []; if (!empty($config)) { foreach ($config as $date) { $range = $this->translation->translate('@start_date to @end_date', [ '@start_date' => $date['value'], '@end_date' => $date['end_value'], ]); $string_parts[] = '(' . $range . ')'; } $string = implode(', ', $string_parts); } return $string; } /** * Retrieve the recur field types. * * @param bool $allow_alter * Allow altering of the field types. * * @return array * An array of field types. */ public function getRecurFieldTypes($allow_alter = TRUE) { // Build an array of recur type field options based on FieldTypes that // implement the Drupal\recurring_events\RecurringEventsFieldTypeInterface // interface. Allow for other modules to customize this list with an alter // hook. $recur_fields = []; $fields = $this->entityFieldManager->getBaseFieldDefinitions('eventseries'); foreach ($fields as $field) { $field_definition = $this->fieldTypePluginManager->getDefinition($field->getType()); $class = new \ReflectionClass($field_definition['class']); if ($class->implementsInterface('\Drupal\recurring_events\RecurringEventsFieldTypeInterface')) { $recur_fields[$field->getName()] = $field->getLabel(); } } $recur_fields['custom'] = $this->t('Custom/Single Event'); if ($allow_alter) { $this->moduleHandler->alter('recurring_events_recur_field_types', $recur_fields); } return $recur_fields; } /** * Update instance status. * * @param Drupal\recurring_events\Entity\EventInstance $instance * The event instance for which to update the status. * @param Drupal\recurring_events\Entity\EventSeries $event * The event series entity. */ public function updateInstanceStatus(EventInstance $instance, EventSeries $event) { $original_event = $event->original; $field_name = 'status'; if ($this->moduleHandler->moduleExists('workflows')) { if ($event->hasField('moderation_state') && $instance->hasField('moderation_state')) { $series_query = $this->entityTypeManager->getStorage('workflow')->getQuery()->accessCheck(FALSE); $series_query->condition('type_settings.entity_types.eventseries.*', $event->bundle()); $series_workflows = $series_query->accessCheck(FALSE)->execute(); $series_workflows = array_keys($series_workflows); $series_workflow = reset($series_workflows); $instance_query = $this->entityTypeManager->getStorage('workflow')->getQuery()->accessCheck(FALSE); $instance_query->condition('type_settings.entity_types.eventinstance.*', $instance->bundle()); $instance_workflows = $instance_query->accessCheck(FALSE)->execute(); $instance_workflows = array_keys($instance_workflows); $instance_workflow = reset($instance_workflows); // We only want to mimic moderation state if the series and instance use // the same workflows, otherwise we cannot guarantee the states match. if ($instance_workflow === $series_workflow) { $field_name = 'moderation_state'; } else { return FALSE; } } } $new_state = $event->get($field_name)->getValue(); $instance_state = $instance->get($field_name)->getValue(); if (!empty($original_event)) { $original_state = $original_event->get($field_name)->getValue(); } else { $instance->set($field_name, $new_state); return TRUE; } // If the instance state matches the original state of the series we want // to also update the instance state. if ($instance_state === $original_state) { $instance->set($field_name, $new_state); return TRUE; } return FALSE; } }