recurring_events-2.0.x-dev/src/EventCreationService.php
src/EventCreationService.php
<?php
declare(strict_types=1);
namespace Drupal\recurring_events;
use Drupal\Core\Database\Connection;
use Drupal\Core\Datetime\DrupalDateTime;
use Drupal\Core\Entity\EntityFieldManagerInterface;
use Drupal\Core\Entity\EntityTypeManagerInterface;
use Drupal\Core\Extension\ModuleHandlerInterface;
use Drupal\Core\Field\FieldTypePluginManagerInterface;
use Drupal\Core\Form\FormStateInterface;
use Drupal\Core\KeyValueStore\KeyValueFactoryInterface;
use Drupal\Core\Logger\LoggerChannelFactoryInterface;
use Drupal\Core\Logger\LoggerChannelInterface;
use Drupal\Core\Messenger\MessengerInterface;
use Drupal\Core\StringTranslation\StringTranslationTrait;
use Drupal\Core\StringTranslation\TranslationInterface;
use Drupal\datetime\Plugin\Field\FieldType\DateTimeItemInterface;
use Drupal\field_inheritance\Entity\FieldInheritanceInterface;
use Drupal\recurring_events\Entity\EventInstance;
use Drupal\recurring_events\Entity\EventSeries;
use Symfony\Component\DependencyInjection\Attribute\Autowire;
/**
* Provides a service with helper functions useful during event creation.
*/
class EventCreationService {
use StringTranslationTrait;
/**
* Logger Factory.
*
* @var \Drupal\Core\Logger\LoggerChannel
*/
protected LoggerChannelInterface $loggerChannel;
public function __construct(
protected readonly TranslationInterface $translation,
protected readonly Connection $database,
protected readonly LoggerChannelFactoryInterface $logger,
protected readonly MessengerInterface $messenger,
protected readonly FieldTypePluginManagerInterface $fieldTypePluginManager,
protected readonly EntityFieldManagerInterface $entityFieldManager,
protected readonly ModuleHandlerInterface $moduleHandler,
protected readonly EntityTypeManagerInterface $entityTypeManager,
#[Autowire(service: 'keyvalue')]
protected readonly KeyValueFactoryInterface $keyValueStore,
) {
$this->loggerChannel = $logger->get('recurring_events');
}
/**
* 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') {
if ($custom_dates = $event->getCustomDates()) {
$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'] ?? 'custom';
$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') {
$user_input['custom_date'] = $user_input['custom_date'] ?? [];
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.
*
* Do not worry about order or casing discrepancies.
*
* @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|null $form_state
* (Optional) The form state of an updated event series entity.
* @param \Drupal\recurring_events\Entity\EventSeries|null $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') {
$form_config['custom_dates'] = $form_config['custom_dates'] ?? [];
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();
$initial_instance_count = count($instances);
$this->moduleHandler->invokeAll('recurring_events_save_pre_instances_deletion_alter', [&$instances]);
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),
]));
$final_instance_count = count($instances);
if ($initial_instance_count > $final_instance_count) {
$this->messenger->addStatus($this->translation->translate('%count existing event instances were skipped.', [
'%count' => $initial_instance_count - $final_instance_count,
]));
}
}
// 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()]);
}
/**
* Creates the event instances from the form state.
*
* This is intended to be called on newly created event series entities. When
* an existing event series entity needs to have the instances recreated, make
* sure to clear the existing instances first using ::clearEventInstances().
*
* @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']);
if ($instance) {
$this->configureDefaultInheritances($instance, (int) $event->id());
$instance->save();
$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']);
if ($instance) {
$this->configureDefaultInheritances($instance, (int) $event->id());
$instance->save();
$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) {
$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|null $series_id
* Optional event series entity ID. If omitted, the event series ID will be
* taken from the instance.
*/
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)) {
return;
}
// 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)) {
return;
}
$inherited_field = reset($inherited_fields);
$field = $instance->get('field_inheritance')->first()?->getValue() ?? [
'enabled' => TRUE,
'fields' => [],
'entities' => [
$inherited_field->sourceIdentifier() => ['entity' => $series_id],
],
];
foreach ($inherited_fields as $inherited_field) {
$name = $inherited_field->idWithoutTypeAndBundle();
$field['fields'][$name] = [
'entity' => $series_id,
'skip' => FALSE,
];
}
$instance->set('field_inheritance', $field);
}
/**
* 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) {
$field = $instance->get('field_inheritance')->first()?->getValue() ?? [
'enabled' => TRUE,
'fields' => [],
];
$name = $field_inheritance->idWithoutTypeAndBundle();
$field['fields'][$name] = [
'entity' => $instance->eventseries_id->target_id,
'skip' => FALSE,
];
}
/**
* 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;
}
}
